diff --git a/build.js b/build.js
new file mode 100644
index 0000000..2e1ee8c
--- /dev/null
+++ b/build.js
@@ -0,0 +1,128 @@
+#!/usr/bin/env node
+/**
+ * Simple ES-module → IIFE bundler for GrechkaCraft
+ * Resolves import/export, wraps in single IIFE, no external deps.
+ * Usage: node bundle.js > ../game-bundled.js
+ */
+const fs = require('fs');
+const path = require('path');
+
+const SRC = path.join(__dirname, 'src');
+const ENTRY = 'main.js';
+
+const visited = new Set();
+const chunks = [];
+
+// Named export → variable name mapping per file
+const fileExports = {}; // file -> [{local, exported}]
+const fileImports = {}; // file -> [{names, from}]
+
+function resolveImportPath(fromPath, importerDir) {
+ let resolved = path.resolve(importerDir, fromPath);
+ if (!fs.existsSync(resolved) && fs.existsSync(resolved + '.js')) {
+ resolved += '.js';
+ }
+ return resolved;
+}
+
+function collectExports(content) {
+ const exports = [];
+ // export { a, b as c }
+ const re1 = /export\s*\{([^}]+)\}/g;
+ let m;
+ while ((m = re1.exec(content)) !== null) {
+ const names = m[1].split(',').map(s => {
+ const parts = s.trim().split(/\s+as\s+/);
+ return { local: parts[0].trim(), exported: (parts[1] || parts[0]).trim() };
+ });
+ exports.push(...names);
+ }
+ // export const/let/var/function/class name
+ const re2 = /export\s+(?:const|let|var|function|class)\s+(\w+)/g;
+ while ((m = re2.exec(content)) !== null) {
+ exports.push({ local: m[1], exported: m[1] });
+ }
+ // export default ...
+ if (/export\s+default\s+/.test(content)) {
+ // Find the expression after export default
+ // Simpler: just use __default as name
+ exports.push({ local: '__default__', exported: 'default' });
+ }
+ return exports;
+}
+
+function collectImports(content) {
+ const imports = [];
+ const re = /import\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g;
+ let m;
+ while ((m = re.exec(content)) !== null) {
+ const names = m[1].split(',').map(s => {
+ const parts = s.trim().split(/\s+as\s+/);
+ return { local: (parts[1] || parts[0]).trim(), imported: parts[0].trim() };
+ });
+ imports.push({ names, from: m[2] });
+ }
+ // import default
+ const re2 = /import\s+(\w+)\s+from\s*['"]([^'"]+)['"]/g;
+ while ((m = re2.exec(content)) !== null) {
+ imports.push({ names: [{ local: m[1], imported: 'default' }], from: m[2] });
+ }
+ return imports;
+}
+
+function processFile(filePath) {
+ const relPath = path.relative(SRC, filePath);
+ if (visited.has(relPath)) return;
+ visited.add(relPath);
+
+ let content = fs.readFileSync(filePath, 'utf8');
+ const dir = path.dirname(filePath);
+
+ // Collect exports/imports BEFORE stripping
+ fileExports[relPath] = collectExports(content);
+ fileImports[relPath] = collectImports(content);
+
+ // Process dependencies first (depth-first)
+ for (const imp of fileImports[relPath]) {
+ const depPath = resolveImportPath(imp.from, dir);
+ processFile(depPath);
+ }
+
+ // Strip import statements
+ content = content.replace(/import\s*\{[^}]+\}\s*from\s*['"][^'"]+['"];?/g, '');
+ content = content.replace(/import\s+\w+\s+from\s*['"][^'"]+['"];?/g, '');
+ // Strip export keyword but keep declarations
+ content = content.replace(/export\s+default\s+/, 'const __default__ = ');
+ content = content.replace(/export\s+(const|let|var|function|class)\s/g, '$1 ');
+ content = content.replace(/export\s*\{[^}]*\};?/g, '');
+
+ chunks.push({ relPath, content });
+}
+
+// Phase 1: Collect all files, build dependency graph
+processFile(path.join(SRC, ENTRY));
+
+// Phase 2: Build rename map
+// For each file's import { X as Y } from './other.js', we need to know:
+// What is X called in other.js? Then rename Y → X_original
+// Build: originalName (in source file) → what it's called elsewhere
+
+// Simpler approach: since we wrap everything in one scope,
+// we just need to ensure no name collisions.
+// For now: trust that most names are unique across modules.
+// If collision, prefix with module path.
+
+// Phase 3: Emit
+let output = '// GrechkaCraft — auto-bundled from ES modules\n';
+output += '(function() {\n';
+output += '"use strict";\n\n';
+
+for (const chunk of chunks) {
+ output += `// === ${chunk.relPath} ===\n`;
+ output += chunk.content.trim();
+ output += '\n\n';
+}
+
+output += '})();\n';
+
+process.stdout.write(output);
\ No newline at end of file
diff --git a/game.js b/game.js
index 40e2795..7c2825f 100644
--- a/game.js
+++ b/game.js
@@ -1,848 +1,1275 @@
(() => {
- // ==================== КОНФИГУРАЦИЯ СЕРВЕРА ====================
- // Возможность переопределить сервер через query string
- const urlParams = new URLSearchParams(window.location.search);
- const SERVER_URL = urlParams.get('server') || 'https://apigrech.mkn8n.ru';
- const TELEGRAM_BOT_USERNAME = 'Grechkacraft_bot'; // Имя бота для share-ссылки
- const TELEGRAM_APP_SHORT_NAME = 'minegrechka'; // Короткое имя Mini App
-
- // Защита от mixed content
- if (location.protocol === 'https:' && SERVER_URL.startsWith('http://')) {
- console.warn('⚠️ Mixed content warning: page is HTTPS but server URL is HTTP');
- alert('⚠️ Предупреждение: страница загружена по HTTPS, но сервер использует HTTP. Это может вызвать проблемы.');
+ // src/core/constants.js
+ var TILE = 40;
+ var SEA_GY = 14;
+ var BEDROCK_GY = 140;
+ var GEN_MARGIN_X = 40;
+ var GRAV = 2200;
+ var GRAV_WATER = 550;
+
+ // src/core/state.js
+ var state = {
+ // Камера
+ camX: 0,
+ camY: 0,
+ // День/ночь
+ worldTime: 0,
+ isNightTime: false,
+ // Мультиплеер
+ isMultiplayer: false,
+ mySocketId: null,
+ socket: null,
+ // Инвентарь/UI
+ selected: 0,
+ showFullInventory: false,
+ craftOpen: false,
+ inventoryOpen: false,
+ chatOpen: false,
+ modeIdx: 0,
+ // Мир
+ worldSeed: Math.floor(Math.random() * 1e6),
+ // Погода
+ isRaining: false,
+ rainIntensity: 0,
+ weatherTimer: 0,
+ weatherChangeInterval: 60 + Math.random() * 120,
+ // Мобы/спавн
+ spawnT: 0,
+ // Цикл
+ last: 0,
+ prevJump: false,
+ // Сеть — throttle отправки позиции
+ lastMoveSendTime: 0,
+ lastSentX: 0,
+ lastSentY: 0,
+ // Игрок
+ player: {
+ x: 6 * TILE,
+ y: 0 * TILE,
+ w: 34,
+ h: 34,
+ vx: 0,
+ vy: 0,
+ grounded: false,
+ inWater: false,
+ headInWater: false,
+ hp: 100,
+ hunger: 100,
+ o2: 100,
+ invuln: 0,
+ fallStartY: 0,
+ lastStepTime: 0,
+ sleeping: false,
+ inBoat: false,
+ armor: 0,
+ equippedArmor: null
+ },
+ // Точка спавна
+ spawnPoint: { x: 6 * TILE, y: 0 * TILE },
+ // Инвентарь
+ inv: {
+ dirt: 6,
+ stone: 0,
+ sand: 0,
+ gravel: 0,
+ clay: 0,
+ wood: 0,
+ planks: 0,
+ ladder: 0,
+ leaves: 0,
+ coal: 0,
+ copper_ore: 0,
+ iron_ore: 0,
+ gold_ore: 0,
+ diamond_ore: 0,
+ brick: 0,
+ glass: 0,
+ tnt: 1,
+ campfire: 0,
+ torch: 0,
+ meat: 0,
+ cooked: 0,
+ arrow: 0,
+ wood_pickaxe: 0,
+ stone_pickaxe: 0,
+ iron_pickaxe: 0,
+ wood_sword: 0,
+ stone_sword: 0,
+ iron_sword: 0,
+ iron_armor: 0,
+ bow: 0,
+ furnace: 0,
+ bed: 0,
+ boat: 0,
+ iron_ingot: 0,
+ gold_ingot: 0,
+ copper_ingot: 0
+ },
+ // Лодка
+ boat: {
+ x: 0,
+ y: 0,
+ w: 34,
+ h: 34,
+ vx: 0,
+ vy: 0,
+ active: false,
+ inWater: false
+ },
+ // Ввод
+ inp: { up: false, down: false, left: false, right: false, jump: false, mine: false, build: false, bow: false },
+ // Мышь
+ mouse: { x: 0, y: 0 },
+ // Другие игроки (MP)
+ otherPlayers: /* @__PURE__ */ new Map(),
+ // Серверные мобы (MP)
+ serverMobs: /* @__PURE__ */ new Map(),
+ // Мобы
+ mobs: [],
+ // Снаряды
+ projectiles: [],
+ // Отслеживание изменений мира
+ placedBlocks: [],
+ removedBlocks: [],
+ // Серверные изменения
+ serverOverrides: /* @__PURE__ */ new Map(),
+ // Чат
+ chatMessages: [],
+ // Погода — капли
+ raindrops: [],
+ // Облака
+ clouds: Array.from({ length: 10 }, () => ({
+ x: Math.random() * 2e3,
+ y: -200 - Math.random() * 260,
+ w: 80 + Math.random() * 120,
+ s: 12 + Math.random() * 20
+ })),
+ // Частицы
+ parts: [],
+ // Активный TNT
+ activeTNT: /* @__PURE__ */ new Set(),
+ // Прочность инструментов
+ toolDurability: /* @__PURE__ */ new Map(),
+ // Последние выбранные предметы
+ recentItems: [],
+ // Активные печи
+ activeFurnaces: /* @__PURE__ */ new Map(),
+ // Сгенерированные колонны
+ generated: /* @__PURE__ */ new Set(),
+ // Изображение героя
+ heroImg: null
+ };
+
+ // src/config.js
+ var urlParams = new URLSearchParams(window.location.search);
+ var SERVER_URL = urlParams.get("server") || "https://apigrech.mkn8n.ru";
+ if (location.protocol === "https:" && SERVER_URL.startsWith("http://")) {
+ console.warn("\u26A0\uFE0F Mixed content warning: page is HTTPS but server URL is HTTP");
+ alert("\u26A0\uFE0F \u041F\u0440\u0435\u0434\u0443\u043F\u0440\u0435\u0436\u0434\u0435\u043D\u0438\u0435: \u0441\u0442\u0440\u0430\u043D\u0438\u0446\u0430 \u0437\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u0430 \u043F\u043E HTTPS, \u043D\u043E \u0441\u0435\u0440\u0432\u0435\u0440 \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0435\u0442 HTTP. \u042D\u0442\u043E \u043C\u043E\u0436\u0435\u0442 \u0432\u044B\u0437\u0432\u0430\u0442\u044C \u043F\u0440\u043E\u0431\u043B\u0435\u043C\u044B.");
}
-
- // ==================== WORLD ID И ИГРОКА ====================
- let worldId = null;
- let playerName = localStorage.getItem('minegrechka_playerName') || null;
-
- // Запрашиваем имя игрока, если его нет
+ var worldId = null;
+ var playerName = localStorage.getItem("minegrechka_playerName") || null;
if (!playerName) {
- playerName = prompt('Введите ваше имя для игры:') || 'Игрок';
- localStorage.setItem('minegrechka_playerName', playerName);
- console.log('Player name set:', playerName);
+ playerName = prompt("\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0432\u0430\u0448\u0435 \u0438\u043C\u044F \u0434\u043B\u044F \u0438\u0433\u0440\u044B:") || "\u0418\u0433\u0440\u043E\u043A";
+ localStorage.setItem("minegrechka_playerName", playerName);
+ console.log("Player name set:", playerName);
}
-
- // Берём worldId из URL или генерируем новый
- console.log('Current URL:', window.location.href);
- const worldParam = urlParams.get('world');
- console.log('world param:', worldParam);
-
- // Проверяем на null, undefined или пустую строку
- worldId = (worldParam && worldParam.trim() !== '') ? worldParam : null;
-
- console.log('worldId after params:', worldId, 'type:', typeof worldId);
-
- // Если worldId отсутствует - генерируем новый и записываем в URL
+ console.log("Current URL:", window.location.href);
+ var worldParam = urlParams.get("world");
+ console.log("world param:", worldParam);
+ worldId = worldParam && worldParam.trim() !== "" ? worldParam : null;
+ console.log("worldId after params:", worldId, "type:", typeof worldId);
if (!worldId) {
worldId = Math.random().toString(36).substring(2, 10);
- console.log('Generated worldId:', worldId);
-
+ console.log("Generated worldId:", worldId);
try {
const newUrl = new URL(window.location.href);
- newUrl.searchParams.set('world', worldId);
+ newUrl.searchParams.set("world", worldId);
const newUrlString = newUrl.toString();
- console.log('New URL to set:', newUrlString);
-
- // Проверяем, поддерживается ли history API
- if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') {
- window.history.replaceState(null, '', newUrlString);
- console.log('URL after replaceState:', window.location.href);
- console.log('URL after replaceState (direct check):', window.location.search);
+ console.log("New URL to set:", newUrlString);
+ if (typeof window.history !== "undefined" && typeof window.history.replaceState === "function") {
+ window.history.replaceState(null, "", newUrlString);
+ console.log("URL after replaceState:", window.location.href);
+ console.log("URL after replaceState (direct check):", window.location.search);
} else {
- console.error('History API not supported!');
+ console.error("History API not supported!");
}
} catch (e) {
- console.error('Error updating URL:', e);
+ console.error("Error updating URL:", e);
}
-
- console.log('Generated new worldId for browser:', worldId);
+ console.log("Generated new worldId for browser:", worldId);
}
-
- console.log('Final worldId:', worldId, 'Player name:', playerName);
-
+ console.log("Final worldId:", worldId, "Player name:", playerName);
console.log(`Server URL: ${SERVER_URL}, World ID: ${worldId}`);
-
- // Обработчик клика на worldId для копирования ссылки
- document.getElementById('worldId').onclick = () => {
- const shareUrl = new URL(window.location.href);
- shareUrl.searchParams.set('world', worldId);
- const shareUrlString = shareUrl.toString();
-
- if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(shareUrlString).then(() => {
- alert('Ссылка скопирована!');
- }).catch(() => {
- alert('Ссылка на мир:\n' + shareUrlString);
- });
- } else {
- alert('Ссылка на мир:\n' + shareUrlString);
- }
+
+ // src/data/blocks.js
+ var BLOCKS = {
+ air: { n: "\u0412\u043E\u0437\u0434\u0443\u0445", solid: false },
+ grass: { n: "\u0422\u0440\u0430\u0432\u0430", c: "#7cfc00", solid: true },
+ dirt: { n: "\u0413\u0440\u044F\u0437\u044C", c: "#8b4513", solid: true },
+ stone: { n: "\u041A\u0430\u043C\u0435\u043D\u044C", c: "#7f8c8d", solid: true },
+ sand: { n: "\u041F\u0435\u0441\u043E\u043A", c: "#f4d06f", solid: true },
+ gravel: { n: "\u0413\u0440\u0430\u0432\u0438\u0439", c: "#95a5a6", solid: true },
+ clay: { n: "\u0413\u043B\u0438\u043D\u0430", c: "#74b9ff", solid: true },
+ wood: { n: "\u0414\u0435\u0440\u0435\u0432\u043E", c: "#d35400", solid: true },
+ planks: { n: "\u0414\u043E\u0441\u043A\u0438", c: "#e67e22", solid: true },
+ ladder: { n: "\u041B\u0435\u0441\u0442\u043D\u0438\u0446\u0430", c: "#d35400", solid: false, climbable: true },
+ leaves: { n: "\u041B\u0438\u0441\u0442\u0432\u0430", c: "#2ecc71", solid: true },
+ glass: { n: "\u0421\u0442\u0435\u043A\u043B\u043E", c: "rgba(200,240,255,0.25)", solid: true, alpha: 0.55 },
+ water: { n: "\u0412\u043E\u0434\u0430", c: "rgba(52,152,219,0.55)", solid: false, fluid: true },
+ coal: { n: "\u0423\u0433\u043E\u043B\u044C", c: "#2c3e50", solid: true },
+ copper_ore: { n: "\u041C\u0435\u0434\u044C", c: "#e17055", solid: true },
+ iron_ore: { n: "\u0416\u0435\u043B\u0435\u0437\u043E", c: "#dcdde1", solid: true },
+ iron_armor: { n: "\u0416\u0435\u043B\u0435\u0437\u043D\u0430\u044F \u0431\u0440\u043E\u043D\u044F", c: "#95a5a6", solid: false, armor: 0.5 },
+ gold_ore: { n: "\u0417\u043E\u043B\u043E\u0442\u043E", c: "#f1c40f", solid: true },
+ diamond_ore: { n: "\u0410\u043B\u043C\u0430\u0437", c: "#00a8ff", solid: true },
+ brick: { n: "\u041A\u0438\u0440\u043F\u0438\u0447", c: "#c0392b", solid: true },
+ tnt: { n: "TNT", c: "#e74c3c", solid: true, explosive: true },
+ campfire: { n: "\u041A\u043E\u0441\u0442\u0451\u0440", c: "#e67e22", solid: true, lightRadius: 190 },
+ torch: { n: "\u0424\u0430\u043A\u0435\u043B", c: "#f9ca24", solid: true, lightRadius: 140 },
+ bedrock: { n: "\u0411\u0435\u0434\u0440\u043E\u043A", c: "#2d3436", solid: true, unbreakable: true },
+ flower: { n: "\u0426\u0432\u0435\u0442\u043E\u043A", c: "#ff4757", solid: false, decor: true },
+ bed: { n: "\u041A\u0440\u043E\u0432\u0430\u0442\u044C", c: "#e91e63", solid: true, bed: true },
+ boat: { n: "\u041B\u043E\u0434\u043A\u0430", c: "#8B4513", solid: false },
+ furnace: { n: "\u041F\u0435\u0447\u044C", c: "#696969", solid: true, smelting: true }
};
-
- // ==================== SOCKET.IO КЛИЕНТ ====================
- let socket = null;
- let isMultiplayer = false; // Флаг для мультиплеерного режима
- const otherPlayers = new Map(); // socket_id -> {x, y, color}
- const serverMobs = new Map(); // id -> mob (server-authoritative in MP)
- let mySocketId = null;
-
- // Throttle для отправки позиции (10-20 раз в секунду)
- let lastMoveSendTime = 0;
- const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду
- let lastSentX = 0, lastSentY = 0;
-
- function initSocket() {
- try {
- socket = io(SERVER_URL, {
- path: '/socket.io/',
- transports: ['websocket', 'polling']
- });
-
- socket.on('connect', () => {
- console.log(`Connected to server: ${SERVER_URL}, socket.id: ${socket.id}, worldId: ${worldId}`);
- mySocketId = socket.id;
- isMultiplayer = true;
-
- // Присоединяемся к миру
- socket.emit('join_world', { world_id: worldId, player_name: playerName });
-
- // Показываем в UI
- worldIdEl.textContent = worldId;
- multiplayerStatus.style.display = 'block';
- });
-
- socket.on('connect_error', (error) => {
- console.error('Socket connection error:', error);
- isMultiplayer = false;
- });
-
- socket.on('disconnect', () => {
- console.log('Disconnected from server');
- isMultiplayer = false;
- otherPlayers.clear();
- multiplayerStatus.style.display = 'none';
- });
-
- // Обработка world_state
- socket.on('world_state', (data) => {
- console.log('Received world_state:', data);
-
- // Устанавливаем seed и перегенерируем мир если он изменился
- if (data.seed !== undefined && data.seed !== worldSeed) {
- const oldSeed = worldSeed;
- worldSeed = data.seed;
- console.log('World seed changed from', oldSeed, 'to', worldSeed);
-
- // Очищаем и перегенерируем мир с новым seed
- generated.clear();
- grid.clear();
- blocks.length = 0;
- placedBlocks = [];
- removedBlocks = [];
- console.log('World regenerated with new seed:', worldSeed);
- }
-
- // Применяем блоки — сохраняем в serverOverrides для применения после genColumn
- if (data.blocks && Array.isArray(data.blocks)) {
- for (const block of data.blocks) {
- const key = k(block.gx, block.gy);
- serverOverrides.set(key, { op: block.op, t: block.t });
- // Также пробуем применить сразу (если колонна уже сгенерирована)
- if (block.op === 'set') {
- setBlock(block.gx, block.gy, block.t, false);
- } else if (block.op === 'remove') {
- removeBlock(block.gx, block.gy);
- }
- }
- }
-
- // Устанавливаем время
- if (data.time !== undefined) {
- worldTime = data.time;
- isNightTime = worldTime > 0.5;
- }
-
- // Всегда считаем spawnPoint на клиенте по той же формуле что и surfaceGyAt
- // Это гарантирует совпадение с terrain generation
- {
- const startGX = 6;
- // Генерируем колонну и соседние для безопасного спавна
- for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx);
- const surfaceY = surfaceGyAt(startGX);
- // Ищем ближайшую небудущую позицию сверху вниз от поверхности
- let safeGY = surfaceY - 1;
- // Проверяем что над поверхностью воздух (не в воде)
- const aboveBlock = getBlock(startGX, surfaceY - 1);
- if (aboveBlock && aboveBlock.t === 'water') {
- // Если в воде — ищем поверхность выше уровня моря
- for (let gy = SEA_GY - 1; gy >= 0; gy--) {
- const b = getBlock(startGX, gy);
- if (!b || b.dead || b.t === 'air' || b.t === 'water') continue;
- safeGY = gy - 1;
- break;
- }
- }
- spawnPoint.x = startGX * TILE;
- spawnPoint.y = safeGY * TILE;
- console.log('Client-side spawn point:', spawnPoint, 'surfaceY:', surfaceY, 'safeGY:', safeGY);
+
+ // src/world/world-storage.js
+ var grid = /* @__PURE__ */ new Map();
+ var blocks = [];
+ function k(gx, gy) {
+ return gx + "," + gy;
+ }
+ function getBlock2(gx, gy) {
+ return grid.get(k(gx, gy));
+ }
+ function isSolid(gx, gy) {
+ const b = getBlock2(gx, gy);
+ if (!b || b.dead) return false;
+ const def = BLOCKS[b.t];
+ return !!def.solid && !def.fluid && !def.decor;
+ }
+ function setBlock(gx, gy, t, isPlayerPlaced = false) {
+ const key = k(gx, gy);
+ if (grid.has(key)) return false;
+ const b = { gx, gy, t, dead: false, active: false, fuse: 0 };
+ grid.set(key, b);
+ blocks.push(b);
+ if (isPlayerPlaced) {
+ state.placedBlocks.push({ gx, gy, t });
+ }
+ return true;
+ }
+ function removeBlock(gx, gy) {
+ const key = k(gx, gy);
+ const b = grid.get(key);
+ if (!b) return null;
+ if (BLOCKS[b.t].unbreakable) return null;
+ grid.delete(key);
+ b.dead = true;
+ const wasPlayerPlaced = state.placedBlocks.some((pb) => pb.gx === gx && pb.gy === gy);
+ if (wasPlayerPlaced) {
+ state.placedBlocks = state.placedBlocks.filter((pb) => !(pb.gx === gx && pb.gy === gy));
+ } else {
+ state.removedBlocks.push({ gx, gy });
+ }
+ return b;
+ }
+
+ // src/world/generation.js
+ var generated = state.generated;
+ function surfaceGyAt(gx) {
+ const n1 = Math.sin(gx * 0.025 + state.worldSeed * 1e-3) * 8;
+ const n2 = Math.sin(gx * 0.012 + state.worldSeed * 2e-3) * 12;
+ const n3 = Math.sin(gx * 6e-3 + state.worldSeed * 3e-3) * 6;
+ const n4 = Math.sin(gx * 0.045 + state.worldSeed * 4e-3) * 4;
+ const n5 = Math.cos(gx * 0.018 + state.worldSeed * 5e-3) * 5;
+ const h = Math.floor(SEA_GY - 8 + n1 + n2 + n3 + n4 + n5);
+ return h;
+ }
+ function genColumn(gx) {
+ if (generated.has(gx)) return;
+ generated.add(gx);
+ const sgy = surfaceGyAt(gx);
+ if (sgy > SEA_GY) {
+ for (let gy = SEA_GY; gy < sgy; gy++) {
+ setBlock(gx, gy, "water");
+ }
+ setBlock(gx, sgy, "sand");
+ } else {
+ if (sgy < SEA_GY - 10) setBlock(gx, sgy, "stone");
+ else setBlock(gx, sgy, "grass");
+ }
+ for (let gy = sgy + 1; gy <= BEDROCK_GY; gy++) {
+ if (gy === BEDROCK_GY) {
+ setBlock(gx, gy, "bedrock");
+ continue;
+ }
+ let t = "stone";
+ if (gy <= sgy + 3) t = "dirt";
+ if (sgy > SEA_GY && gy === sgy + 1 && seededRandom(gx, gy) < 0.25) t = "clay";
+ if (gy > sgy + 6 && seededRandom(gx, gy) < 0.07) t = "gravel";
+ const depth = gy - sgy;
+ const r = seededRandom(gx, gy);
+ if (t === "stone") {
+ if (r < 0.06) t = "coal";
+ else if (r < 0.1) t = "copper_ore";
+ else if (r < 0.13) t = "iron_ore";
+ else if (depth > 40 && r < 0.145) t = "gold_ore";
+ else if (depth > 70 && r < 0.152) t = "diamond_ore";
+ }
+ setBlock(gx, gy, t);
+ }
+ const top = getBlock2(gx, sgy);
+ if (top && top.t === "grass") {
+ if (seededRandom(gx, sgy - 1) < 0.1) {
+ setBlock(gx, sgy - 1, "flower");
+ }
+ if (seededRandom(gx, sgy - 2) < 0.12) {
+ setBlock(gx, sgy - 1, "wood");
+ setBlock(gx, sgy - 2, "wood");
+ setBlock(gx, sgy - 3, "leaves");
+ setBlock(gx - 1, sgy - 3, "leaves");
+ setBlock(gx + 1, sgy - 3, "leaves");
+ }
+ }
+ const colPrefix = gx + ",";
+ for (const [key, ov] of state.serverOverrides) {
+ if (!key.startsWith(colPrefix)) continue;
+ if (ov.op === "remove") {
+ const b = grid.get(key);
+ if (b) {
+ grid.delete(key);
+ b.dead = true;
}
-
- // Устанавливаем игрока в точку спавна
- player.x = spawnPoint.x;
- player.y = spawnPoint.y;
- player.vx = 0;
- player.vy = 0;
- player.fallStartY = player.y;
- console.log('Player moved to spawn point:', player.x, player.y);
-
- // Устанавливаем HP на 100% при каждом подключении к миру
- player.hp = 100;
- player.hunger = 100;
- player.o2 = 100;
- player.invuln = 0;
- console.log('[MULTIPLAYER CONNECT] Player HP set to 100% on connect');
-
- // Обновляем список игроков
- if (data.players && Array.isArray(data.players)) {
- otherPlayers.clear();
- for (const p of data.players) {
- if (p.socket_id !== mySocketId) {
- otherPlayers.set(p.socket_id, {
- x: p.x,
- y: p.y,
- color: getRandomPlayerColor(p.socket_id),
- name: p.player_name || 'Игрок'
- });
- }
- }
- // Обновляем счётчик игроков
- playerCountEl.textContent = data.players.length;
+ } else if (ov.op === "set") {
+ if (!grid.has(key)) {
+ const gy = parseInt(key.split(",")[1]);
+ const nb = { gx, gy, t: ov.t, dead: false, active: false, fuse: 0 };
+ grid.set(key, nb);
+ blocks.push(nb);
}
- // Server mobs
- if (data.mobs && Array.isArray(data.mobs)) {
- serverMobs.clear();
- for (const m of data.mobs) {
- const sm = { ...m, maxHp: m.maxHp || m.hp, vx: m.vx||0, vy: m.vy||0, grounded:false, inWater:false, aiT:0, dir:m.dir||1, dead:false, fuse:m.fuse||0, shootCooldown:2, speed: m.speed || 80 };
- serverMobs.set(m.id, sm);
- }
- }
- });
-
- // Игрок присоединился
- socket.on('player_joined', (data) => {
- console.log('Player joined:', data.socket_id);
- if (data.socket_id !== mySocketId) {
- // Генерируем безопасную позицию для нового игрока
- const spawnGX = 6;
- genColumn(spawnGX);
- const surfaceY = surfaceGyAt(spawnGX);
- const safeSpawnX = spawnGX * TILE;
- const safeSpawnY = (surfaceY - 1) * TILE;
-
- otherPlayers.set(data.socket_id, {
- x: safeSpawnX,
- y: safeSpawnY,
- color: getRandomPlayerColor(data.socket_id),
- name: data.player_name || 'Игрок'
- });
- addChatMessage('Система', `Игрок присоединился`);
- // Обновляем видимость кнопки сохранения
- updateSaveButtonVisibility();
- }
- });
-
- // Игрок переместился
- socket.on('player_moved', (data) => {
- if (data.socket_id !== mySocketId && otherPlayers.has(data.socket_id)) {
- const p = otherPlayers.get(data.socket_id);
- p.x = data.x;
- p.y = data.y;
- // Обновляем имя, если оно пришло
- if (data.player_name) {
- p.name = data.player_name;
- }
- }
- });
-
- // Игрок покинул
- socket.on('player_left', (data) => {
- console.log('Player left:', data.socket_id);
- otherPlayers.delete(data.socket_id);
- addChatMessage('Система', `Игрок покинул игру`);
- // Обновляем видимость кнопки сохранения
- updateSaveButtonVisibility();
- });
-
- // === MOB SYNC (multiplayer) ===
-
- socket.on('mob_spawned', (data) => {
- const sm = { ...data, maxHp: data.maxHp || data.hp, vx: data.vx||0, vy: data.vy||0, grounded:false, inWater:false, aiT:0, dir:data.dir||1, dead:false, fuse:data.fuse||0, shootCooldown:2, speed: data.speed || 80 };
- serverMobs.set(data.id, sm);
- });
-
- socket.on('mob_positions', (arr) => {
- for (const u of arr) {
- const sm = serverMobs.get(u.id);
- if (sm) { sm.x=u.x; sm.y=u.y; sm.vx=u.vx; sm.vy=u.vy; sm.dir=u.dir; sm.hp=u.hp; sm.fuse=u.fuse||0; }
- }
- });
-
- socket.on('mob_despawned', (data) => { serverMobs.delete(data.id); });
-
- socket.on('mob_died', (data) => {
- const sm = serverMobs.get(data.id);
- if (sm && data.killer === mySocketId) {
- // Give loot to the killer
- if (sm.kind === 'chicken') playSound('hurt_chicken');
- inv.meat += (sm.kind==='chicken' ? 1 : 2);
- if (sm.kind === 'skeleton') {
- inv.arrow += 2 + Math.floor(Math.random()*3);
- if (Math.random() < 0.15) inv.bow = (inv.bow||0) + 1;
- }
- rebuildHotbar();
- }
- serverMobs.delete(data.id);
- });
-
- socket.on('mob_hurt_ack', (data) => {
- const sm = serverMobs.get(data.id);
- if (sm) sm.hp = data.hp;
- });
-
- socket.on('mob_explode', (data) => {
- explodeAt(data.gx, data.gy);
- serverMobs.delete(data.id);
- });
-
- socket.on('mob_shoot', (data) => {
- projectiles.push({
- x: data.x, y: data.y, vx: data.vx, vy: data.vy,
- dmg: data.dmg, owner: 'mob', life: data.life
- });
- });
-
- // Блок изменён
- socket.on('block_changed', (data) => {
- const key = k(data.gx, data.gy);
- serverOverrides.set(key, { op: data.op, t: data.t });
- if (data.op === 'set') {
- setBlock(data.gx, data.gy, data.t, false);
- } else if (data.op === 'remove') {
- removeBlock(data.gx, data.gy);
- }
- });
-
- // Сообщение в чат
- socket.on('chat_message', (data) => {
- const senderName = data.socket_id === mySocketId ? 'Вы' : `Игрок ${data.socket_id.substring(0, 6)}`;
- addChatMessage(senderName, data.message);
- });
-
- // Обновление времени
- socket.on('time_update', (data) => {
- if (data.time !== undefined) {
- worldTime = data.time;
- isNightTime = worldTime > 0.5;
- }
- });
-
- } catch (e) {
- console.error('Error initializing socket:', e);
- isMultiplayer = false;
- }
- }
-
- // Генерация случайного цвета для игрока на основе socket_id
- function getRandomPlayerColor(socketId) {
- const colors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e91e63', '#00bcd4'];
- let hash = 0;
- for (let i = 0; i < socketId.length; i++) {
- hash = ((hash << 5) - hash) + socketId.charCodeAt(i);
- hash = hash & hash;
- }
- return colors[Math.abs(hash) % colors.length];
- }
-
- // Отправка позиции игрока (с throttle)
- function sendPlayerPosition() {
- if (!isMultiplayer || !socket || !socket.connected) return;
-
- const now = performance.now() / 1000;
- if (now - lastMoveSendTime < MOVE_SEND_INTERVAL) return;
-
- // Отправляем только если позиция изменилась
- const dx = Math.abs(player.x - lastSentX);
- const dy = Math.abs(player.y - lastSentY);
- if (dx < 1 && dy < 1) return;
-
- lastMoveSendTime = now;
- lastSentX = player.x;
- lastSentY = player.y;
-
- socket.emit('player_move', { x: player.x, y: player.y, player_name: playerName });
- }
-
- // Отправка изменения блока
- function sendBlockChange(gx, gy, t, op) {
- if (!isMultiplayer || !socket || !socket.connected) return;
-
- socket.emit('block_change', { gx, gy, t, op });
- }
-
- // ==================== ЧАТ ====================
- const chatMessages = [];
- const MAX_CHAT_MESSAGES = 20;
-
- function addChatMessage(sender, message) {
- const time = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
- chatMessages.push({ sender, message, time });
- if (chatMessages.length > MAX_CHAT_MESSAGES) {
- chatMessages.shift();
- }
- renderChatMessages();
- }
-
- function renderChatMessages() {
- const chatMessagesEl = document.getElementById('chatMessages');
- if (!chatMessagesEl) return;
-
- chatMessagesEl.innerHTML = chatMessages.map(m =>
- `
${m.time} ${m.sender}: ${m.message}
`
- ).join('');
-
- // Прокручиваем вниз
- chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
- }
-
- function sendChatMessage(message) {
- if (!message || message.trim() === '') return;
-
- if (isMultiplayer && socket && socket.connected) {
- socket.emit('chat_message', { message: message.trim() });
- } else {
- addChatMessage('Вы', message.trim());
- }
- }
-
- // ==================== ПОДЕЛИТЬСЯ МИРОМ ====================
- function shareWorld() {
- const shareUrl = new URL(window.location.href);
- shareUrl.searchParams.set('world', worldId);
- const shareUrlString = shareUrl.toString();
-
- // Копируем в буфер обмена
- if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(shareUrlString).then(() => {
- alert('Ссылка скопирована!');
- }).catch(() => {
- alert('Ссылка на мир:\n' + shareUrlString);
- });
- } else {
- alert('Ссылка на мир:\n' + shareUrlString);
- }
- }
-
- // ==================== ИНИЦИАЛИЗАЦИЯ UI ====================
- let chatOpen = false;
-
- document.getElementById('chatToggle').onclick = () => {
- playSound('click');
- chatOpen = !chatOpen;
- document.getElementById('chatPanel').style.display = chatOpen ? 'block' : 'none';
- if (chatOpen) {
- document.getElementById('chatInput').focus();
- }
- };
-
- document.getElementById('chatClose').onclick = () => {
- playSound('click');
- chatOpen = false;
- document.getElementById('chatPanel').style.display = 'none';
- };
-
- document.getElementById('chatSend').onclick = () => {
- const input = document.getElementById('chatInput');
- sendChatMessage(input.value);
- input.value = '';
- };
-
- document.getElementById('chatInput').addEventListener('keypress', (e) => {
- if (e.key === 'Enter') {
- sendChatMessage(e.target.value);
- e.target.value = '';
- }
- });
-
- // ==================== ИНИЦИАЛИЗАЦИЯ СОКЕТА ====================
- // Инициализируем socket
- initSocket();
-
- // ==================== ЗВУКОВОЙ ДВИЖОК ====================
- const sounds = {};
+ }
+ }
+ }
+ function regenerateVisibleChunks() {
+ const gx0 = Math.floor(state.camX / TILE);
+ for (let gx = gx0 - GEN_MARGIN_X; gx <= gx0 + GEN_MARGIN_X; gx++) {
+ generated.delete(gx);
+ genColumn(gx);
+ }
+ }
+ function ensureGenAroundCamera() {
+ const gx0 = Math.floor(state.camX / TILE);
+ for (let gx = gx0 - GEN_MARGIN_X; gx <= gx0 + GEN_MARGIN_X; gx++) {
+ genColumn(gx);
+ }
+ }
+ function seededRandom(gx, gy) {
+ const n = Math.sin(gx * 12.9898 + gy * 78.233 + state.worldSeed * 0.1) * 43758.5453;
+ return n - Math.floor(n);
+ }
+
+ // src/audio/sound-engine.js
+ var sounds = {};
function loadSound(id, src) {
const audio = new Audio();
audio.src = src;
audio.volume = 0.3;
sounds[id] = audio;
}
-
- // Загрузка звуков
- loadSound('splash', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//splash.mp3');
- loadSound('sand1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand1.mp3');
- loadSound('snow1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//snow1.mp3');
- loadSound('stone1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1.mp3');
- loadSound('wood1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1.mp3');
- loadSound('cloth1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//cloth1.mp3');
- loadSound('fire', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//fire.mp3');
- loadSound('hit1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hit1.mp3');
- loadSound('attack', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//attack.mp3');
- loadSound('hurt_chicken', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hurt1_chicken.mp3');
- loadSound('stone_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1%20(1).mp3');
- loadSound('wood_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1%20(1).mp3');
- loadSound('click', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//click.mp3');
- loadSound('explode1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//explode1.mp3');
- loadSound('glass1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//glass1.mp3');
- loadSound('eat1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//eat1.mp3');
- loadSound('step', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand2.mp3');
-
+ loadSound("splash", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//splash.mp3");
+ loadSound("sand1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand1.mp3");
+ loadSound("snow1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//snow1.mp3");
+ loadSound("stone1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1.mp3");
+ loadSound("wood1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1.mp3");
+ loadSound("cloth1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//cloth1.mp3");
+ loadSound("fire", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//fire.mp3");
+ loadSound("hit1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hit1.mp3");
+ loadSound("attack", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//attack.mp3");
+ loadSound("hurt_chicken", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hurt1_chicken.mp3");
+ loadSound("stone_build", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1%20(1).mp3");
+ loadSound("wood_build", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1%20(1).mp3");
+ loadSound("click", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//click.mp3");
+ loadSound("explode1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//explode1.mp3");
+ loadSound("glass1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//glass1.mp3");
+ loadSound("eat1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//eat1.mp3");
+ loadSound("step", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand2.mp3");
function playSound(id) {
- if(sounds[id]) {
+ if (sounds[id]) {
sounds[id].currentTime = 0;
- sounds[id].play().catch(e => console.error('Sound error:', e));
+ sounds[id].play().catch((e) => console.error("Sound error:", e));
}
}
-
- // Играем звук при прыжке
- const gameEl = document.getElementById('game');
- const canvas = document.getElementById('c');
- const ctx = canvas.getContext('2d');
-
- // offscreen light map (не вставляем в DOM)
- const lightC = document.createElement('canvas');
- const lightCtx = lightC.getContext('2d');
-
- const dpr = Math.max(1, window.devicePixelRatio || 1);
- let W=0, H=0;
-
- const TILE = 40;
-
- // Мир
- const SEA_GY = 14; // уровень воды (gy) - уменьшил для меньших водоёмов
- const BEDROCK_GY = 140; // глубина бедрока (gy), чем больше - тем глубже
- const GEN_MARGIN_X = 40; // запас генерации по X (в тайлах) — увеличен для плавной прогрузки
-
- const heroImg = new Image();
- heroImg.src = 'https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png';
-
- // Состояние инвентаря
- let showFullInventory = false;
- let recentItems = []; // Последние 5 выбранных предметов
-
- const BLOCKS = {
- air: { n:'Воздух', solid:false },
- grass: { n:'Трава', c:'#7cfc00', solid:true },
- dirt: { n:'Грязь', c:'#8b4513', solid:true },
- stone: { n:'Камень', c:'#7f8c8d', solid:true },
- sand: { n:'Песок', c:'#f4d06f', solid:true },
- gravel: { n:'Гравий', c:'#95a5a6', solid:true },
- clay: { n:'Глина', c:'#74b9ff', solid:true },
- wood: { n:'Дерево', c:'#d35400', solid:true },
- planks: { n:'Доски', c:'#e67e22', solid:true },
- ladder: { n:'Лестница',c:'#d35400', solid:false, climbable:true },
- leaves: { n:'Листва', c:'#2ecc71', solid:true },
- glass: { n:'Стекло', c:'rgba(200,240,255,0.25)', solid:true, alpha:0.55 },
- water: { n:'Вода', c:'rgba(52,152,219,0.55)', solid:false, fluid:true },
- coal: { n:'Уголь', c:'#2c3e50', solid:true },
- copper_ore:{ n:'Медь', c:'#e17055', solid:true },
- iron_ore: { n:'Железо', c:'#dcdde1', solid:true },
- iron_armor:{ n:'Железная броня', c:'#95a5a6', solid:false, armor:0.5 },
- gold_ore: { n:'Золото', c:'#f1c40f', solid:true },
- diamond_ore:{n:'Алмаз', c:'#00a8ff', solid:true },
- brick: { n:'Кирпич', c:'#c0392b', solid:true },
- tnt: { n:'TNT', c:'#e74c3c', solid:true, explosive:true },
- campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius:190 },
- torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:140 },
- bedrock: { n:'Бедрок', c:'#2d3436', solid:true, unbreakable:true },
- flower: { n:'Цветок', c:'#ff4757', solid:false, decor:true },
- bed: { n:'Кровать', c:'#e91e63', solid:true, bed:true },
- boat: { n:'Лодка', c:'#8B4513', solid:false },
- furnace: { n:'Печь', c:'#696969', solid:true, smelting:true }
- };
-
- const ITEMS = {
- meat: { n:'Сырое мясо', icon:'🥩', food:15 },
- cooked: { n:'Жареное мясо', icon:'🍖', food:45 },
- arrow: { n:'Стрела', icon:'➡️', stack:64 },
- };
-
- // Seed мира для детерминированной генерации
- // Инициализируем случайным seed, но он будет перезаписан сервером в мультиплеере
- let worldSeed = Math.floor(Math.random() * 1000000);
-
- // Отслеживание изменений мира (для оптимизированного сохранения)
- let placedBlocks = []; // [{gx, gy, t}] - блоки, установленные игроком
- let removedBlocks = []; // [{gx, gy}] - блоки, удалённые игроком
- // Серверные изменения — применяются после genColumn чтобы не перезатирались
- const serverOverrides = new Map(); // key "gx,gy" => {op:'set'|'remove', t?:string}
-
- // Инструменты
- const TOOLS = {
- wood_pickaxe: { n:'Деревянная кирка', icon:'⛏️', durability: 60, miningPower: 1, craft: { wood: 3, planks: 2 } },
- stone_pickaxe: { n:'Каменная кирка', icon:'⛏️', durability: 130, miningPower: 2, craft: { wood: 1, stone: 3 } },
- iron_pickaxe: { n:'Железная кирка', icon:'⛏️', durability: 250, miningPower: 3, craft: { wood: 1, iron_ore: 2 } },
- wood_sword: { n:'Деревянный меч', icon:'⚔️', durability: 40, damage: 5, craft: { wood: 2, planks: 1 } },
- stone_sword: { n:'Каменный меч', icon:'⚔️', durability: 100, damage: 8, craft: { wood: 1, stone: 2 } },
- iron_sword: { n:'Железный меч', icon:'⚔️', durability: 200, damage: 12, craft: { wood: 1, iron_ore: 1 } },
- bow: { n:'Лук', icon:'🏹', durability: 150, craft: { wood: 3, string: 0 } }
+ // src/ui/chat.js
+ var chatMessages = [];
+ var MAX_CHAT_MESSAGES = 20;
+ function addChatMessage(sender, message) {
+ const time = (/* @__PURE__ */ new Date()).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
+ chatMessages.push({ sender, message, time });
+ if (chatMessages.length > MAX_CHAT_MESSAGES) {
+ chatMessages.shift();
+ }
+ renderChatMessages();
+ }
+ function renderChatMessages() {
+ const chatMessagesEl = document.getElementById("chatMessages");
+ if (!chatMessagesEl) return;
+ chatMessagesEl.innerHTML = chatMessages.map(
+ (m) => `${m.time} ${m.sender}: ${m.message}
`
+ ).join("");
+ chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
+ }
+ function sendChatMessage(message) {
+ if (!message || message.trim() === "") return;
+ if (state.isMultiplayer && state.socket && state.socket.connected) {
+ state.socket.emit("chat_message", { message: message.trim() });
+ } else {
+ addChatMessage("\u0412\u044B", message.trim());
+ }
+ }
+ function initChat() {
+ document.getElementById("chatToggle").onclick = () => {
+ playSound("click");
+ state.chatOpen = !state.chatOpen;
+ document.getElementById("chatPanel").style.display = state.chatOpen ? "block" : "none";
+ if (state.chatOpen) {
+ document.getElementById("chatInput").focus();
+ }
+ };
+ document.getElementById("chatClose").onclick = () => {
+ playSound("click");
+ state.chatOpen = false;
+ document.getElementById("chatPanel").style.display = "none";
+ };
+ document.getElementById("chatSend").onclick = () => {
+ const input = document.getElementById("chatInput");
+ sendChatMessage(input.value);
+ input.value = "";
+ };
+ document.getElementById("chatInput").addEventListener("keypress", (e) => {
+ if (e.key === "Enter") {
+ sendChatMessage(e.target.value);
+ e.target.value = "";
+ }
+ });
+ }
+
+ // src/entities/player.js
+ function calculateDamage(baseDamage) {
+ const reduction = state.player.armor;
+ const actualDamage = baseDamage * (1 - reduction);
+ console.log("[DAMAGE] Base:", baseDamage, "- Armor:", reduction, "- Actual:", actualDamage.toFixed(1));
+ return actualDamage;
+ }
+
+ // src/data/items.js
+ var ITEMS = {
+ meat: { n: "\u0421\u044B\u0440\u043E\u0435 \u043C\u044F\u0441\u043E", icon: "\u{1F969}", food: 15 },
+ cooked: { n: "\u0416\u0430\u0440\u0435\u043D\u043E\u0435 \u043C\u044F\u0441\u043E", icon: "\u{1F356}", food: 45 },
+ arrow: { n: "\u0421\u0442\u0440\u0435\u043B\u0430", icon: "\u27A1\uFE0F", stack: 64 }
};
-
- // Текстуры блоков (простые)
- const tex = {};
+ ITEMS.iron_ingot = { n: "\u0416\u0435\u043B\u0435\u0437\u043D\u044B\u0439 \u0441\u043B\u0438\u0442\u043E\u043A", icon: "\u{1F529}" };
+ ITEMS.gold_ingot = { n: "\u0417\u043E\u043B\u043E\u0442\u043E\u0439 \u0441\u043B\u0438\u0442\u043E\u043A", icon: "\u{1FA99}" };
+ ITEMS.copper_ingot = { n: "\u041C\u0435\u0434\u043D\u044B\u0439 \u0441\u043B\u0438\u0442\u043E\u043A", icon: "\u{1F7E4}" };
+
+ // src/data/tools.js
+ var TOOLS = {
+ wood_pickaxe: { n: "\u0414\u0435\u0440\u0435\u0432\u044F\u043D\u043D\u0430\u044F \u043A\u0438\u0440\u043A\u0430", icon: "\u26CF\uFE0F", durability: 60, miningPower: 1, craft: { wood: 3, planks: 2 } },
+ stone_pickaxe: { n: "\u041A\u0430\u043C\u0435\u043D\u043D\u0430\u044F \u043A\u0438\u0440\u043A\u0430", icon: "\u26CF\uFE0F", durability: 130, miningPower: 2, craft: { wood: 1, stone: 3 } },
+ iron_pickaxe: { n: "\u0416\u0435\u043B\u0435\u0437\u043D\u0430\u044F \u043A\u0438\u0440\u043A\u0430", icon: "\u26CF\uFE0F", durability: 250, miningPower: 3, craft: { wood: 1, iron_ore: 2 } },
+ wood_sword: { n: "\u0414\u0435\u0440\u0435\u0432\u044F\u043D\u043D\u044B\u0439 \u043C\u0435\u0447", icon: "\u2694\uFE0F", durability: 40, damage: 5, craft: { wood: 2, planks: 1 } },
+ stone_sword: { n: "\u041A\u0430\u043C\u0435\u043D\u043D\u044B\u0439 \u043C\u0435\u0447", icon: "\u2694\uFE0F", durability: 100, damage: 8, craft: { wood: 1, stone: 2 } },
+ iron_sword: { n: "\u0416\u0435\u043B\u0435\u0437\u043D\u044B\u0439 \u043C\u0435\u0447", icon: "\u2694\uFE0F", durability: 200, damage: 12, craft: { wood: 1, iron_ore: 1 } },
+ bow: { n: "\u041B\u0443\u043A", icon: "\u{1F3F9}", durability: 150, craft: { wood: 3, string: 0 } }
+ };
+ function useTool(type) {
+ for (const [tid, dur] of state.toolDurability) {
+ if (dur.type === type && dur.current > 0) {
+ dur.current--;
+ if (dur.current <= 0) {
+ state.toolDurability.delete(tid);
+ state.inv[type] = Math.max(0, (state.inv[type] || 0) - 1);
+ return true;
+ }
+ return false;
+ }
+ }
+ return false;
+ }
+
+ // src/render/textures.js
+ var tex = {};
function makeTex(type) {
const t = BLOCKS[type];
- const c = document.createElement('canvas');
- c.width = 32; c.height = 32;
- const g = c.getContext('2d');
-
- if (type === 'tnt') {
- g.fillStyle='#c0392b'; g.fillRect(0,0,32,32);
- g.fillStyle='#fff'; g.fillRect(0,12,32,8);
- g.fillStyle='#000'; g.font='bold 10px sans-serif'; g.fillText('TNT',6,20);
- return c;
- }
- if (type === 'campfire') {
- g.fillStyle='#5d4037'; g.fillRect(4,26,24,6);
- g.fillStyle='#3e2723'; g.fillRect(7,23,18,4);
- return c;
- }
- if (type === 'torch') {
- g.fillStyle='#6d4c41'; g.fillRect(14,10,4,18);
- g.fillStyle='#f39c12'; g.fillRect(12,6,8,8);
- return c;
- }
- if (type === 'glass') {
- g.fillStyle='rgba(200,240,255,0.25)'; g.fillRect(0,0,32,32);
- g.strokeStyle='rgba(255,255,255,0.65)'; g.strokeRect(2,2,28,28);
- g.beginPath(); g.moveTo(5,27); g.lineTo(27,5); g.stroke();
- return c;
- }
- if (type === 'water') {
- g.fillStyle = t.c; g.fillRect(0,0,32,32);
- g.fillStyle = 'rgba(255,255,255,0.08)';
- g.fillRect(0,6,32,2);
- return c;
- }
- if (type === 'bed') {
- // Основание кровати
- g.fillStyle = '#e91e63';
+ const c = document.createElement("canvas");
+ c.width = 32;
+ c.height = 32;
+ const g = c.getContext("2d");
+ if (type === "tnt") {
+ g.fillStyle = "#c0392b";
g.fillRect(0, 0, 32, 32);
- // Подушка
- g.fillStyle = '#f8bbd0';
+ g.fillStyle = "#fff";
+ g.fillRect(0, 12, 32, 8);
+ g.fillStyle = "#000";
+ g.font = "bold 10px sans-serif";
+ g.fillText("TNT", 6, 20);
+ return c;
+ }
+ if (type === "campfire") {
+ g.fillStyle = "#5d4037";
+ g.fillRect(4, 26, 24, 6);
+ g.fillStyle = "#3e2723";
+ g.fillRect(7, 23, 18, 4);
+ return c;
+ }
+ if (type === "torch") {
+ g.fillStyle = "#6d4c41";
+ g.fillRect(14, 10, 4, 18);
+ g.fillStyle = "#f39c12";
+ g.fillRect(12, 6, 8, 8);
+ return c;
+ }
+ if (type === "glass") {
+ g.fillStyle = "rgba(200,240,255,0.25)";
+ g.fillRect(0, 0, 32, 32);
+ g.strokeStyle = "rgba(255,255,255,0.65)";
+ g.strokeRect(2, 2, 28, 28);
+ g.beginPath();
+ g.moveTo(5, 27);
+ g.lineTo(27, 5);
+ g.stroke();
+ return c;
+ }
+ if (type === "water") {
+ g.fillStyle = t.c;
+ g.fillRect(0, 0, 32, 32);
+ g.fillStyle = "rgba(255,255,255,0.08)";
+ g.fillRect(0, 6, 32, 2);
+ return c;
+ }
+ if (type === "bed") {
+ g.fillStyle = "#e91e63";
+ g.fillRect(0, 0, 32, 32);
+ g.fillStyle = "#f8bbd0";
g.fillRect(2, 2, 14, 14);
- // Одеяло
- g.fillStyle = '#c2185b';
+ g.fillStyle = "#c2185b";
g.fillRect(16, 4, 14, 24);
- // Детали одеяла
- g.fillStyle = '#e91e63';
+ g.fillStyle = "#e91e63";
g.fillRect(18, 6, 10, 20);
return c;
}
- if (type === 'flower') {
- g.fillStyle='#2ecc71'; g.fillRect(14,14,4,18);
- g.fillStyle=t.c; g.beginPath(); g.arc(16,12,6,0,6.28); g.fill();
+ if (type === "flower") {
+ g.fillStyle = "#2ecc71";
+ g.fillRect(14, 14, 4, 18);
+ g.fillStyle = t.c;
+ g.beginPath();
+ g.arc(16, 12, 6, 0, 6.28);
+ g.fill();
return c;
}
- if (type === 'boat') {
- // Корпус лодки
- g.fillStyle = '#8B4513';
+ if (type === "boat") {
+ g.fillStyle = "#8B4513";
g.fillRect(2, 12, 28, 8);
- // Борта
- g.fillStyle = '#A0522D';
+ g.fillStyle = "#A0522D";
g.fillRect(0, 10, 32, 12);
- // Внутренность
- g.fillStyle = '#DEB887';
+ g.fillStyle = "#DEB887";
g.fillRect(4, 14, 24, 4);
- // Дно
- g.fillStyle = '#654321';
+ g.fillStyle = "#654321";
g.fillRect(2, 20, 28, 4);
return c;
}
- if (type === 'ladder') {
- // Боковые стойки лестницы
- g.fillStyle = '#8B4513';
+ if (type === "ladder") {
+ g.fillStyle = "#8B4513";
g.fillRect(4, 0, 4, 32);
g.fillRect(24, 0, 4, 32);
- // Ступени
- g.fillStyle = '#A0522D';
+ g.fillStyle = "#A0522D";
g.fillRect(4, 4, 24, 3);
g.fillRect(4, 12, 24, 3);
g.fillRect(4, 20, 24, 3);
g.fillRect(4, 28, 24, 3);
return c;
}
-
- g.fillStyle = t.c || '#000';
- g.fillRect(0,0,32,32);
-
- g.fillStyle = 'rgba(0,0,0,0.10)';
- for (let i=0;i<6;i++) g.fillRect((Math.random()*28)|0, (Math.random()*28)|0, 4,4);
-
- if (type.endsWith('_ore') || type==='coal') {
- g.fillStyle = 'rgba(0,0,0,0.35)';
- for (let i=0;i<4;i++) g.fillRect((Math.random()*24)|0, (Math.random()*24)|0, 6,6);
+ g.fillStyle = t.c || "#000";
+ g.fillRect(0, 0, 32, 32);
+ g.fillStyle = "rgba(0,0,0,0.10)";
+ for (let i = 0; i < 6; i++) g.fillRect(Math.random() * 28 | 0, Math.random() * 28 | 0, 4, 4);
+ if (type.endsWith("_ore") || type === "coal") {
+ g.fillStyle = "rgba(0,0,0,0.35)";
+ for (let i = 0; i < 4; i++) g.fillRect(Math.random() * 24 | 0, Math.random() * 24 | 0, 6, 6);
}
return c;
}
- Object.keys(BLOCKS).forEach(k => tex[k] = makeTex(k));
-
- // Мир-хранилище
- const grid = new Map(); // key "gx,gy" => {gx,gy,t, ...}
- const blocks = []; // для рендера/перебора видимых
- function k(gx,gy){ return gx+','+gy; }
- function getBlock(gx,gy){ return grid.get(k(gx,gy)); }
- function hasBlock(gx,gy){ return grid.has(k(gx,gy)); }
- function isSolid(gx,gy){
- const b = getBlock(gx,gy);
- if(!b || b.dead) return false;
- const def = BLOCKS[b.t];
- return !!def.solid && !def.fluid && !def.decor;
+ function initTextures() {
+ Object.keys(BLOCKS).forEach((k2) => tex[k2] = makeTex(k2));
}
- function setBlock(gx,gy,t, isPlayerPlaced = false){
- const key = k(gx,gy);
- if(grid.has(key)) return false;
- const b = { gx, gy, t, dead:false, active:false, fuse:0 };
- grid.set(key, b);
- blocks.push(b);
-
- // Отслеживаем блоки, установленные игроком
- if(isPlayerPlaced){
- placedBlocks.push({gx, gy, t});
+
+ // src/ui/hotbar.js
+ function rebuildHotbar() {
+ const hotbarEl = state.hotbarEl;
+ const inv = state.inv;
+ const selected = state.selected;
+ const recentItems = state.recentItems;
+ const toolDurability = state.toolDurability;
+ hotbarEl.innerHTML = "";
+ const items = recentItems.filter((id) => inv[id] > 0).slice(0, 5);
+ for (const id of items) {
+ const s = document.createElement("div");
+ s.className = "slot" + (id === selected ? " sel" : "");
+ if (BLOCKS[id]) {
+ s.style.backgroundImage = `url(${tex[id].toDataURL()})`;
+ s.style.backgroundSize = "cover";
+ } else if (ITEMS[id]) {
+ s.textContent = ITEMS[id].icon;
+ } else if (TOOLS[id]) {
+ s.textContent = TOOLS[id].icon;
+ } else if (id === "iron_armor") {
+ s.textContent = "\u{1F6E1}\uFE0F";
+ s.style.background = "linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)";
+ }
+ const c = document.createElement("div");
+ c.className = "count";
+ c.textContent = inv[id];
+ s.appendChild(c);
+ s.onclick = () => {
+ playSound("click");
+ state.selected = id;
+ state.recentItems = state.recentItems.filter((item) => item !== id);
+ state.recentItems.unshift(id);
+ state.recentItems = state.recentItems.slice(0, 5);
+ rebuildHotbar();
+ };
+ if (id === "iron_armor" && state.player.equippedArmor === "iron_armor") {
+ const equipped = document.createElement("div");
+ equipped.className = "equipped-indicator";
+ equipped.textContent = "\u2713";
+ s.appendChild(equipped);
+ }
+ if (TOOLS[id] && inv[id] > 0) {
+ let curDur = 0, maxDur = TOOLS[id].durability;
+ for (const [tid, dur] of toolDurability) {
+ if (dur.type === id) {
+ curDur = dur.current;
+ maxDur = dur.max;
+ break;
+ }
+ }
+ if (maxDur > 0) {
+ const bar = document.createElement("div");
+ bar.style.cssText = `position:absolute;bottom:0;left:0;right:0;height:3px;background:#333;border-radius:0 0 6px 6px;overflow:hidden;`;
+ const fill = document.createElement("div");
+ const pct = curDur / maxDur;
+ const color = pct > 0.5 ? "#2ecc71" : pct > 0.25 ? "#f39c12" : "#e74c3c";
+ fill.style.cssText = `width:${pct * 100}%;height:100%;background:${color};`;
+ bar.appendChild(fill);
+ s.appendChild(bar);
+ }
+ }
+ hotbarEl.appendChild(s);
}
-
- return true;
}
- function removeBlock(gx,gy){
- const key = k(gx,gy);
- const b = grid.get(key);
- if(!b) return null;
- if(BLOCKS[b.t].unbreakable) return null;
- grid.delete(key);
- b.dead = true;
-
- // Отслеживаем удалённые блоки
- const wasPlayerPlaced = placedBlocks.some(pb => pb.gx === gx && pb.gy === gy);
- if(wasPlayerPlaced){
- // Удаляем из placedBlocks
- placedBlocks = placedBlocks.filter(pb => !(pb.gx === gx && pb.gy === gy));
+
+ // src/render/particles.js
+ var parts = [];
+ function spawnExplosion(x, y, power) {
+ const n = Math.floor(16 + power * 10);
+ for (let i = 0; i < n; i++) {
+ parts.push({
+ x,
+ y,
+ vx: (Math.random() - 0.5) * (300 + power * 200),
+ vy: (Math.random() - 0.5) * (300 + power * 200),
+ t: 0.7,
+ c: "#ffa500"
+ });
+ }
+ }
+
+ // src/world/tnt.js
+ function activateTNT(b, fuse = 3.2) {
+ if (b.dead) return;
+ if (b.active) return;
+ b.active = true;
+ b.fuse = fuse;
+ state.activeTNT.add(k(b.gx, b.gy));
+ }
+ function explodeAt(gx, gy) {
+ const center = getBlock2(gx, gy);
+ if (!center) return;
+ let bonus = 0;
+ for (let x = gx - 2; x <= gx + 2; x++) {
+ for (let y = gy - 2; y <= gy + 2; y++) {
+ const b = getBlock2(x, y);
+ if (b && !b.dead && b.t === "tnt" && !(x === gx && y === gy)) {
+ bonus += 0.8;
+ activateTNT(b, 0.12);
+ }
+ }
+ }
+ const power = 1 + bonus;
+ const radius = 3.2 + bonus * 0.7;
+ const dmgR = 150 + bonus * 60;
+ removeBlock(gx, gy);
+ state.activeTNT.delete(k(gx, gy));
+ playSound("explode1");
+ spawnExplosion(gx * TILE + TILE / 2, gy * TILE + TILE / 2, power);
+ for (let x = Math.floor(gx - radius); x <= Math.ceil(gx + radius); x++) {
+ for (let y = Math.floor(gy - radius); y <= Math.ceil(gy + radius); y++) {
+ const d = Math.hypot(x - gx, y - gy);
+ if (d > radius) continue;
+ const b = getBlock2(x, y);
+ if (!b || b.dead) continue;
+ if (BLOCKS[b.t].fluid) continue;
+ if (BLOCKS[b.t].unbreakable) continue;
+ if (b.t === "tnt") {
+ activateTNT(b, 0.12);
+ continue;
+ }
+ removeBlock(x, y);
+ if (state.inv[b.t] !== void 0 && Math.random() < 0.2) state.inv[b.t]++;
+ }
+ }
+ rebuildHotbar();
+ const hurt = (e) => {
+ const dx = e.x + e.w / 2 - (gx * TILE + TILE / 2);
+ const dy = e.y + e.h / 2 - (gy * TILE + TILE / 2);
+ const dist = Math.hypot(dx, dy);
+ if (dist < dmgR) {
+ const dmg = (dmgR - dist) * 0.06 * power;
+ if (e === state.player) {
+ const actualDamage = calculateDamage(dmg);
+ state.player.hp -= actualDamage;
+ } else {
+ e.hp -= dmg;
+ }
+ e.vx += (dx / dist || 0) * 600;
+ e.vy -= 320;
+ }
+ };
+ hurt(state.player);
+ state.mobs.forEach(hurt);
+ }
+
+ // src/game/save.js
+ var SAVE_KEY = state.SAVE_KEY;
+ function initDB() {
+ return new Promise((resolve) => {
+ console.log("\u0418\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0435\u043C localStorage \u0434\u043B\u044F \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0439 (sandbox \u0440\u0435\u0436\u0438\u043C)");
+ resolve(null);
+ });
+ }
+ function saveGame() {
+ const saveData = {
+ version: 2,
+ worldSeed: state.worldSeed,
+ player: {
+ x: state.player.x,
+ y: state.player.y,
+ hp: state.player.hp,
+ hunger: state.player.hunger,
+ o2: state.player.o2
+ },
+ inventory: state.inv,
+ time: state.worldTime,
+ isNight: state.isNightTime,
+ // Сохраняем только изменения
+ placedBlocks: state.placedBlocks.slice(),
+ removedBlocks: state.removedBlocks.slice()
+ };
+ const saveSize = JSON.stringify(saveData).length;
+ console.log("\u0421\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435: player HP:", state.player.hp, "hunger:", state.player.hunger, "o2:", state.player.o2);
+ try {
+ localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
+ console.log(`\u0418\u0433\u0440\u0430 \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0430 \u0432 localStorage (\u0440\u0430\u0437\u043C\u0435\u0440: ${saveSize} \u0431\u0430\u0439\u0442)`);
+ } catch (e) {
+ console.warn("\u041E\u0448\u0438\u0431\u043A\u0430 \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u044F \u0432 localStorage, \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0435\u043C \u0442\u043E\u043B\u044C\u043A\u043E in-memory:", e);
+ state.inMemorySave = saveData;
+ console.log(`\u0418\u0433\u0440\u0430 \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0430 \u0432 \u043F\u0430\u043C\u044F\u0442\u0438 (\u0440\u0430\u0437\u043C\u0435\u0440: ${saveSize} \u0431\u0430\u0439\u0442)`);
+ }
+ }
+ function loadGame() {
+ return new Promise((resolve, reject) => {
+ try {
+ const localSave = localStorage.getItem(SAVE_KEY);
+ if (localSave) {
+ const parsed = JSON.parse(localSave);
+ console.log("\u0417\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u043E \u0438\u0437 localStorage, player HP:", parsed.player?.hp);
+ resolve(parsed);
+ return;
+ }
+ } catch (e) {
+ console.warn("\u041E\u0448\u0438\u0431\u043A\u0430 \u0434\u043E\u0441\u0442\u0443\u043F\u0430 \u043A localStorage:", e);
+ }
+ if (state.inMemorySave) {
+ console.log("\u0417\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u043E \u0438\u0437 in-memory \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u044F, player HP:", state.inMemorySave.player?.hp);
+ resolve(state.inMemorySave);
+ return;
+ }
+ console.log("\u0421\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E");
+ resolve(null);
+ });
+ }
+ function migrateV1toV2(saveData) {
+ console.log("\u041C\u0438\u0433\u0440\u0430\u0446\u0438\u044F \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u044F \u0441 \u0432\u0435\u0440\u0441\u0438\u0438 1 \u043D\u0430 \u0432\u0435\u0440\u0441\u0438\u044E 2...");
+ saveData.worldSeed = state.worldSeed;
+ saveData.placedBlocks = [];
+ saveData.removedBlocks = [];
+ delete saveData.generatedBlocks;
+ saveData.version = 2;
+ console.log("\u041C\u0438\u0433\u0440\u0430\u0446\u0438\u044F \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043D\u0430");
+ }
+ async function applySave(saveData) {
+ if (!saveData) return;
+ console.log("=== applySave START ===");
+ console.log("player HP before applySave:", state.player.hp);
+ console.log("saveData.player.hp:", saveData.player?.hp);
+ if (saveData.version === 1) {
+ migrateV1toV2(saveData);
+ }
+ if (saveData.worldSeed !== void 0) {
+ state.worldSeed = saveData.worldSeed;
+ }
+ if (saveData.player) {
+ state.player.x = saveData.player.x;
+ state.player.y = saveData.player.y;
+ state.player.hunger = saveData.player.hunger;
+ state.player.o2 = saveData.player.o2;
+ state.spawnPoint.x = state.player.x;
+ state.spawnPoint.y = state.player.y;
+ const savedHP = saveData.player.hp;
+ console.log("Saved HP from file:", savedHP);
+ if (savedHP <= 0) {
+ console.log("WARNING: Saved HP is <= 0, setting to 100!");
+ state.player.hp = 100;
+ } else {
+ state.player.hp = savedHP;
+ }
+ console.log("player HP after restore:", state.player.hp);
+ console.log("spawnPoint \u043E\u0431\u043D\u043E\u0432\u043B\u0451\u043D \u0438\u0437 \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u044F: x=", state.spawnPoint.x, "y=", state.spawnPoint.y);
} else {
- // Это природный блок - добавляем в removedBlocks
- removedBlocks.push({gx, gy});
+ console.log("No player data in save, setting default HP: 100");
+ state.player.hp = 100;
}
-
- return b;
+ console.log("=== applySave END ===");
+ if (saveData.inventory) {
+ for (const key in saveData.inventory) {
+ state.inv[key] = saveData.inventory[key];
+ }
+ }
+ if (saveData.time !== void 0) {
+ state.worldTime = saveData.time;
+ }
+ if (saveData.isNight !== void 0) {
+ state.isNightTime = saveData.isNight;
+ }
+ regenerateVisibleChunks();
+ if (saveData.version === 2) {
+ for (const block of saveData.placedBlocks) {
+ setBlock(block.gx, block.gy, block.t, true);
+ }
+ for (const block of saveData.removedBlocks) {
+ removeBlock(block.gx, block.gy);
+ }
+ state.placedBlocks = saveData.placedBlocks || [];
+ state.removedBlocks = saveData.removedBlocks || [];
+ }
+ rebuildHotbar();
+ console.log("\u0418\u0433\u0440\u0430 \u0437\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u0430");
}
-
- // Физика жидкости
- const waterUpdateQueue = new Set();
- let waterUpdateTimer = 0;
- const WATER_UPDATE_INTERVAL = 0.05; // Обновляем воду каждые 0.05 секунды
-
- function updateWaterPhysics(dt){
+
+ // src/ui/save-controls.js
+ function initSaveControls() {
+ const saveBtn = document.getElementById("saveBtn");
+ saveBtn.onclick = () => {
+ playSound("click");
+ saveGame();
+ alert("\u0418\u0433\u0440\u0430 \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0430!");
+ };
+ const resetBtn = document.getElementById("resetBtn");
+ resetBtn.onclick = () => {
+ if (confirm("\u0412\u044B \u0443\u0432\u0435\u0440\u0435\u043D\u044B, \u0447\u0442\u043E \u0445\u043E\u0442\u0438\u0442\u0435 \u0443\u0434\u0430\u043B\u0438\u0442\u044C \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435 \u0438 \u043D\u0430\u0447\u0430\u0442\u044C \u043D\u043E\u0432\u0443\u044E \u0438\u0433\u0440\u0443?")) {
+ playSound("click");
+ try {
+ localStorage.removeItem(state.SAVE_KEY);
+ console.log("\u0421\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435 \u0443\u0434\u0430\u043B\u0435\u043D\u043E \u0438\u0437 localStorage");
+ } catch (e) {
+ console.warn("\u041E\u0448\u0438\u0431\u043A\u0430 \u0443\u0434\u0430\u043B\u0435\u043D\u0438\u044F \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u044F:", e);
+ }
+ state.inMemorySave = null;
+ state.worldId = Math.random().toString(36).substring(2, 10);
+ console.log("\u041D\u043E\u0432\u044B\u0439 worldId \u043F\u043E\u0441\u043B\u0435 \u0441\u0431\u0440\u043E\u0441\u0430:", state.worldId);
+ try {
+ const newUrl = new URL(window.location.href);
+ newUrl.searchParams.set("world", state.worldId);
+ const newUrlString = newUrl.toString();
+ if (typeof window.history !== "undefined" && typeof window.history.replaceState === "function") {
+ window.history.replaceState(null, "", newUrlString);
+ console.log("URL \u043E\u0431\u043D\u043E\u0432\u043B\u0451\u043D:", newUrlString);
+ }
+ } catch (e) {
+ console.error("\u041E\u0448\u0438\u0431\u043A\u0430 \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0438\u044F URL:", e);
+ }
+ location.reload();
+ }
+ };
+ }
+ function updateSaveButtonVisibility() {
+ const saveBtn = document.getElementById("saveBtn");
+ if (state.isMultiplayer && state.otherPlayers.size > 0) {
+ saveBtn.style.display = "none";
+ } else {
+ saveBtn.style.display = "flex";
+ }
+ }
+
+ // src/multiplayer/socket-helpers.js
+ function getRandomPlayerColor(socketId) {
+ const colors = ["#e74c3c", "#3498db", "#2ecc71", "#f39c12", "#9b59b6", "#1abc9c", "#e91e63", "#00bcd4"];
+ let hash = 0;
+ for (let i = 0; i < socketId.length; i++) {
+ hash = (hash << 5) - hash + socketId.charCodeAt(i);
+ hash = hash & hash;
+ }
+ return colors[Math.abs(hash) % colors.length];
+ }
+ var lastMoveSendTime = 0;
+ var MOVE_SEND_INTERVAL = 0.05;
+ var lastSentX = 0;
+ var lastSentY = 0;
+ function sendPlayerPosition() {
+ if (!state.isMultiplayer || !state.socket || !state.socket.connected) return;
+ const now = performance.now() / 1e3;
+ if (now - lastMoveSendTime < MOVE_SEND_INTERVAL) return;
+ const dx = Math.abs(state.player.x - lastSentX);
+ const dy = Math.abs(state.player.y - lastSentY);
+ if (dx < 1 && dy < 1) return;
+ lastMoveSendTime = now;
+ lastSentX = state.player.x;
+ lastSentY = state.player.y;
+ state.socket.emit("player_move", { x: state.player.x, y: state.player.y, player_name: state.playerName });
+ }
+ function sendBlockChange(gx, gy, t, op) {
+ if (!state.isMultiplayer || !state.socket || !state.socket.connected) return;
+ state.socket.emit("block_change", { gx, gy, t, op });
+ }
+
+ // src/multiplayer/socket.js
+ var socket = null;
+ function initSocket() {
+ try {
+ socket = io(SERVER_URL, {
+ path: "/socket.io/",
+ transports: ["websocket", "polling"]
+ });
+ socket.on("connect", () => {
+ console.log(`Connected to server: ${SERVER_URL}, socket.id: ${socket.id}, worldId: ${worldId}`);
+ state.mySocketId = socket.id;
+ state.isMultiplayer = true;
+ socket.emit("join_world", { world_id: worldId, player_name: playerName });
+ state.worldIdEl.textContent = worldId;
+ state.multiplayerStatus.style.display = "block";
+ });
+ socket.on("connect_error", (error) => {
+ console.error("Socket connection error:", error);
+ state.isMultiplayer = false;
+ });
+ socket.on("disconnect", () => {
+ console.log("Disconnected from server");
+ state.isMultiplayer = false;
+ state.otherPlayers.clear();
+ state.multiplayerStatus.style.display = "none";
+ });
+ socket.on("world_state", (data) => {
+ console.log("Received world_state:", data);
+ if (data.seed !== void 0 && data.seed !== state.worldSeed) {
+ const oldSeed = state.worldSeed;
+ state.worldSeed = data.seed;
+ console.log("World seed changed from", oldSeed, "to", state.worldSeed);
+ state.generated.clear();
+ grid.clear();
+ blocks.length = 0;
+ state.placedBlocks = [];
+ state.removedBlocks = [];
+ console.log("World regenerated with new seed:", state.worldSeed);
+ }
+ if (data.blocks && Array.isArray(data.blocks)) {
+ for (const block of data.blocks) {
+ const key = k(block.gx, block.gy);
+ state.serverOverrides.set(key, { op: block.op, t: block.t });
+ if (block.op === "set") {
+ setBlock(block.gx, block.gy, block.t, false);
+ } else if (block.op === "remove") {
+ removeBlock(block.gx, block.gy);
+ }
+ }
+ }
+ if (data.time !== void 0) {
+ state.worldTime = data.time;
+ state.isNightTime = state.worldTime > 0.5;
+ }
+ {
+ const startGX = 6;
+ for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx);
+ const surfaceY = surfaceGyAt(startGX);
+ let safeGY = surfaceY - 1;
+ const aboveBlock = getBlock(startGX, surfaceY - 1);
+ if (aboveBlock && aboveBlock.t === "water") {
+ for (let gy = SEA_GY - 1; gy >= 0; gy--) {
+ const b = getBlock(startGX, gy);
+ if (!b || b.dead || b.t === "air" || b.t === "water") continue;
+ safeGY = gy - 1;
+ break;
+ }
+ }
+ state.spawnPoint.x = startGX * TILE;
+ state.spawnPoint.y = safeGY * TILE;
+ console.log("Client-side spawn point:", state.spawnPoint, "surfaceY:", surfaceY, "safeGY:", safeGY);
+ }
+ state.player.x = state.spawnPoint.x;
+ state.player.y = state.spawnPoint.y;
+ state.player.vx = 0;
+ state.player.vy = 0;
+ state.player.fallStartY = state.player.y;
+ console.log("Player moved to spawn point:", state.player.x, state.player.y);
+ state.player.hp = 100;
+ state.player.hunger = 100;
+ state.player.o2 = 100;
+ state.player.invuln = 0;
+ console.log("[MULTIPLAYER CONNECT] Player HP set to 100% on connect");
+ if (data.players && Array.isArray(data.players)) {
+ state.otherPlayers.clear();
+ for (const p of data.players) {
+ if (p.socket_id !== state.mySocketId) {
+ state.otherPlayers.set(p.socket_id, {
+ x: p.x,
+ y: p.y,
+ color: getRandomPlayerColor(p.socket_id),
+ name: p.player_name || "\u0418\u0433\u0440\u043E\u043A"
+ });
+ }
+ }
+ state.playerCountEl.textContent = data.players.length;
+ }
+ if (data.mobs && Array.isArray(data.mobs)) {
+ state.serverMobs.clear();
+ for (const m of data.mobs) {
+ const sm = { ...m, maxHp: m.maxHp || m.hp, vx: m.vx || 0, vy: m.vy || 0, grounded: false, inWater: false, aiT: 0, dir: m.dir || 1, dead: false, fuse: m.fuse || 0, shootCooldown: 2, speed: m.speed || 80 };
+ state.serverMobs.set(m.id, sm);
+ }
+ }
+ });
+ socket.on("player_joined", (data) => {
+ console.log("Player joined:", data.socket_id);
+ if (data.socket_id !== state.mySocketId) {
+ const spawnGX = 6;
+ genColumn(spawnGX);
+ const surfaceY = surfaceGyAt(spawnGX);
+ const safeSpawnX = spawnGX * TILE;
+ const safeSpawnY = (surfaceY - 1) * TILE;
+ state.otherPlayers.set(data.socket_id, {
+ x: safeSpawnX,
+ y: safeSpawnY,
+ color: getRandomPlayerColor(data.socket_id),
+ name: data.player_name || "\u0418\u0433\u0440\u043E\u043A"
+ });
+ addChatMessage("\u0421\u0438\u0441\u0442\u0435\u043C\u0430", `\u0418\u0433\u0440\u043E\u043A \u043F\u0440\u0438\u0441\u043E\u0435\u0434\u0438\u043D\u0438\u043B\u0441\u044F`);
+ updateSaveButtonVisibility();
+ }
+ });
+ socket.on("player_moved", (data) => {
+ if (data.socket_id !== state.mySocketId && state.otherPlayers.has(data.socket_id)) {
+ const p = state.otherPlayers.get(data.socket_id);
+ p.x = data.x;
+ p.y = data.y;
+ if (data.player_name) {
+ p.name = data.player_name;
+ }
+ }
+ });
+ socket.on("player_left", (data) => {
+ console.log("Player left:", data.socket_id);
+ state.otherPlayers.delete(data.socket_id);
+ addChatMessage("\u0421\u0438\u0441\u0442\u0435\u043C\u0430", `\u0418\u0433\u0440\u043E\u043A \u043F\u043E\u043A\u0438\u043D\u0443\u043B \u0438\u0433\u0440\u0443`);
+ updateSaveButtonVisibility();
+ });
+ socket.on("mob_spawned", (data) => {
+ const sm = { ...data, maxHp: data.maxHp || data.hp, vx: data.vx || 0, vy: data.vy || 0, grounded: false, inWater: false, aiT: 0, dir: data.dir || 1, dead: false, fuse: data.fuse || 0, shootCooldown: 2, speed: data.speed || 80 };
+ state.serverMobs.set(data.id, sm);
+ });
+ socket.on("mob_positions", (arr) => {
+ for (const u of arr) {
+ const sm = state.serverMobs.get(u.id);
+ if (sm) {
+ sm.x = u.x;
+ sm.y = u.y;
+ sm.vx = u.vx;
+ sm.vy = u.vy;
+ sm.dir = u.dir;
+ sm.hp = u.hp;
+ sm.fuse = u.fuse || 0;
+ }
+ }
+ });
+ socket.on("mob_despawned", (data) => {
+ state.serverMobs.delete(data.id);
+ });
+ socket.on("mob_died", (data) => {
+ const sm = state.serverMobs.get(data.id);
+ if (sm && data.killer === state.mySocketId) {
+ if (sm.kind === "chicken") playSound("hurt_chicken");
+ state.inv.meat += sm.kind === "chicken" ? 1 : 2;
+ if (sm.kind === "skeleton") {
+ state.inv.arrow += 2 + Math.floor(Math.random() * 3);
+ if (Math.random() < 0.15) state.inv.bow = (state.inv.bow || 0) + 1;
+ }
+ rebuildHotbar();
+ }
+ state.serverMobs.delete(data.id);
+ });
+ socket.on("mob_hurt_ack", (data) => {
+ const sm = state.serverMobs.get(data.id);
+ if (sm) sm.hp = data.hp;
+ });
+ socket.on("mob_explode", (data) => {
+ explodeAt(data.gx, data.gy);
+ state.serverMobs.delete(data.id);
+ });
+ socket.on("mob_shoot", (data) => {
+ state.projectiles.push({
+ x: data.x,
+ y: data.y,
+ vx: data.vx,
+ vy: data.vy,
+ dmg: data.dmg,
+ owner: "mob",
+ life: data.life
+ });
+ });
+ socket.on("block_changed", (data) => {
+ const key = k(data.gx, data.gy);
+ state.serverOverrides.set(key, { op: data.op, t: data.t });
+ if (data.op === "set") {
+ setBlock(data.gx, data.gy, data.t, false);
+ } else if (data.op === "remove") {
+ removeBlock(data.gx, data.gy);
+ }
+ });
+ socket.on("chat_message", (data) => {
+ const senderName = data.socket_id === state.mySocketId ? "\u0412\u044B" : `\u0418\u0433\u0440\u043E\u043A ${data.socket_id.substring(0, 6)}`;
+ addChatMessage(senderName, data.message);
+ });
+ socket.on("time_update", (data) => {
+ if (data.time !== void 0) {
+ state.worldTime = data.time;
+ state.isNightTime = state.worldTime > 0.5;
+ }
+ });
+ } catch (e) {
+ console.error("Error initializing socket:", e);
+ state.isMultiplayer = false;
+ }
+ }
+
+ // src/data/recipes.js
+ var SMELTING_RECIPES = [
+ { in: "sand", qty: 1, out: "glass", outQty: 1, time: 3 },
+ // песок → стекло
+ { in: "clay", qty: 1, out: "brick", outQty: 1, time: 3 },
+ // глина → кирпич
+ { in: "iron_ore", qty: 1, out: "iron_ingot", outQty: 1, time: 5 },
+ // железо → слиток
+ { in: "gold_ore", qty: 1, out: "gold_ingot", outQty: 1, time: 5 },
+ // золото → слиток
+ { in: "copper_ore", qty: 1, out: "copper_ingot", outQty: 1, time: 4 },
+ // медь → слиток
+ { in: "meat", qty: 1, out: "cooked", outQty: 1, time: 2 },
+ // сырое мясо → жареное
+ { in: "cobblestone", qty: 1, out: "stone", outQty: 1, time: 2 }
+ // булыжник → камень
+ ];
+
+ // src/core/canvas.js
+ var gameEl = document.getElementById("game");
+ var canvas = document.getElementById("c");
+ var ctx = canvas.getContext("2d");
+ var lightC = document.createElement("canvas");
+ var lightCtx = lightC.getContext("2d");
+ var dpr = Math.max(1, window.devicePixelRatio || 1);
+ var W = 0;
+ var H = 0;
+
+ // src/world/water.js
+ var waterUpdateQueue = /* @__PURE__ */ new Set();
+ var waterUpdateTimer = 0;
+ var WATER_UPDATE_INTERVAL = 0.05;
+ function updateWaterPhysics(dt) {
waterUpdateTimer += dt;
- if(waterUpdateTimer < WATER_UPDATE_INTERVAL) return;
+ if (waterUpdateTimer < WATER_UPDATE_INTERVAL) return;
waterUpdateTimer = 0;
-
- // Ограничиваем количество водных блоков для обработки (оптимизация)
const MAX_WATER_BLOCKS_PER_UPDATE = 50;
let processedCount = 0;
-
- // Собираем только видимые водные блоки в очередь (оптимизация)
waterUpdateQueue.clear();
- const minGX = Math.floor(camX/TILE) - 10;
- const maxGX = Math.floor((camX+W)/TILE) + 10;
- const minGY = Math.floor(camY/TILE) - 10;
- const maxGY = Math.floor((camY+H)/TILE) + 10;
-
- for(const b of blocks){
- if(processedCount >= MAX_WATER_BLOCKS_PER_UPDATE) break;
- if(!b.dead && b.t === 'water' &&
- b.gx >= minGX && b.gx <= maxGX &&
- b.gy >= minGY && b.gy <= maxGY){
+ const minGX = Math.floor(state.camX / TILE) - 10;
+ const maxGX = Math.floor((state.camX + W) / TILE) + 10;
+ const minGY = Math.floor(state.camY / TILE) - 10;
+ const maxGY = Math.floor((state.camY + H) / TILE) + 10;
+ for (const b of blocks) {
+ if (processedCount >= MAX_WATER_BLOCKS_PER_UPDATE) break;
+ if (!b.dead && b.t === "water" && b.gx >= minGX && b.gx <= maxGX && b.gy >= minGY && b.gy <= maxGY) {
waterUpdateQueue.add(k(b.gx, b.gy));
processedCount++;
}
}
-
- // Обновляем воду с ограничением глубины распространения
- const processed = new Set();
+ const processed = /* @__PURE__ */ new Set();
const toAdd = [];
- const MAX_WATER_DEPTH = 20; // Максимальная глубина распространения воды
-
- for(const key of waterUpdateQueue){
- if(processed.has(key)) continue;
+ const MAX_WATER_DEPTH = 20;
+ for (const key of waterUpdateQueue) {
+ if (processed.has(key)) continue;
const b = grid.get(key);
- if(!b || b.dead) continue;
+ if (!b || b.dead) continue;
processed.add(key);
-
const gx = b.gx;
const gy = b.gy;
-
- // Проверяем глубину - не распространяем воду слишком глубоко
- if(gy > SEA_GY + MAX_WATER_DEPTH) continue;
-
- // Проверяем, можно ли воде упасть вниз
+ if (gy > SEA_GY + MAX_WATER_DEPTH) continue;
const belowKey = k(gx, gy + 1);
const below = grid.get(belowKey);
-
- // Внизу пусто - вода создаёт новый блок внизу (но не удаляется сверху)
- if(!below || below.dead){
- // Ограничиваем создание новых водных блоков
- if(toAdd.length < 20){ // Максимум 20 новых блоков за обновление
- toAdd.push({gx, gy: gy + 1, t: 'water'});
+ if (!below || below.dead) {
+ if (toAdd.length < 20) {
+ toAdd.push({ gx, gy: gy + 1, t: "water" });
processed.add(belowKey);
}
continue;
}
-
- // Если внизу не вода и не твёрдый блок - вода может течь вниз
- if(!isSolid(gx, gy + 1) && below && below.t !== 'water'){
- if(toAdd.length < 20){
- toAdd.push({gx, gy: gy + 1, t: 'water'});
+ if (!isSolid(gx, gy + 1) && below && below.t !== "water") {
+ if (toAdd.length < 20) {
+ toAdd.push({ gx, gy: gy + 1, t: "water" });
processed.add(belowKey);
}
continue;
}
-
- // Если внизу твёрдый блок или вода - вода растекается горизонтально
- // Проверяем левую сторону
const leftKey = k(gx - 1, gy);
const left = grid.get(leftKey);
- if(!left || left.dead){
- if(toAdd.length < 20){
- toAdd.push({gx: gx - 1, gy, t: 'water'});
+ if (!left || left.dead) {
+ if (toAdd.length < 20) {
+ toAdd.push({ gx: gx - 1, gy, t: "water" });
processed.add(leftKey);
}
continue;
}
-
- // Проверяем правую сторону
const rightKey = k(gx + 1, gy);
const right = grid.get(rightKey);
- if(!right || right.dead){
- if(toAdd.length < 20){
- toAdd.push({gx: gx + 1, gy, t: 'water'});
+ if (!right || right.dead) {
+ if (toAdd.length < 20) {
+ toAdd.push({ gx: gx + 1, gy, t: "water" });
processed.add(rightKey);
}
continue;
}
}
-
- // Применяем изменения (только добавляем новые блоки)
- for(const newData of toAdd){
+ for (const newData of toAdd) {
const key = k(newData.gx, newData.gy);
- if(!grid.has(key)){
+ if (!grid.has(key)) {
const b = {
gx: newData.gx,
gy: newData.gy,
@@ -855,190 +1282,500 @@
blocks.push(b);
}
}
-
- // Очищаем мёртвые блоки из массива
- for(let i = blocks.length - 1; i >= 0; i--){
- if(blocks[i].dead){
+ for (let i = blocks.length - 1; i >= 0; i--) {
+ if (blocks[i].dead) {
blocks.splice(i, 1);
}
}
}
-
- // Инвентарь
- const inv = {
- dirt:6, stone:0, sand:0, gravel:0, clay:0,
- wood:0, planks:0, ladder:0, leaves:0, coal:0,
- copper_ore:0, iron_ore:0, gold_ore:0, diamond_ore:0,
- brick:0, glass:0,
- tnt:1, campfire:0, torch:0,
- meat:0, cooked:0, arrow:0,
- wood_pickaxe:0, stone_pickaxe:0, iron_pickaxe:0,
- wood_sword:0, stone_sword:0, iron_sword:0,
- iron_armor:0,
- bow:0, furnace:0,
- bed:0, boat:0,
- iron_ingot:0, gold_ingot:0, copper_ingot:0
- };
- let selected = 'dirt';
-
- // Прочность инструментов: Map<"tooltype_id", {current, max}>
- // При крафте инструмента создаём запись с max durability
- const toolDurability = new Map();
-
- function addTool(type) {
- const def = TOOLS[type];
- if (!def) return;
- const id = `${type}_${Date.now()}_${Math.random().toString(36).slice(2,6)}`;
- toolDurability.set(id, { type, current: def.durability, max: def.durability });
- return id;
+
+ // src/physics/water-detect.js
+ function isWaterAt(px, py) {
+ const gx = Math.floor(px / TILE);
+ const gy = Math.floor(py / TILE);
+ const b = getBlock2(gx, gy);
+ return !!(b && b.t === "water");
}
-
- function getToolDurability(id) {
- return toolDurability.get(id);
+ function updateWaterFlag(e) {
+ const cx = e.x + e.w / 2;
+ const wasInWater = e.inWater;
+ const mid = isWaterAt(cx, e.y + e.h / 2);
+ const feet = isWaterAt(cx, e.y + e.h - 2);
+ e.inWater = mid || feet;
+ e.headInWater = isWaterAt(cx, e.y + 4);
+ if (e === state.player && !wasInWater && e.inWater && e.vy > 100) {
+ playSound("splash");
+ }
}
-
- // Найти лучший инструмент данного типа в инвентаре
- function findBestTool(toolType) {
- if (inv[toolType] <= 0) return null;
- // Возвращаем первый попавшийся — упрощённо
- return toolType;
- }
-
- // Использовать инструмент (уменьшить прочность). Возвращает true если сломался
- function useTool(toolType) {
- // Ищем любой инструмент этого типа с прочностью
- for (const [id, dur] of toolDurability) {
- if (dur.type === toolType) {
- dur.current--;
- if (dur.current <= 0) {
- toolDurability.delete(id);
- inv[toolType]--;
- rebuildHotbar();
- return true; // сломался
+
+ // src/physics/collision.js
+ function resolveY(e) {
+ e.grounded = false;
+ const x1 = e.x + 2;
+ const x2 = e.x + e.w - 2;
+ const cx = e.x + e.w / 2;
+ const cy = e.y + e.h / 2;
+ const gx = Math.floor(cx / TILE);
+ const gy = Math.floor(cy / TILE);
+ const b = getBlock2(gx, gy);
+ const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable;
+ if (onLadder) {
+ e.grounded = true;
+ if (state.inp.jump) {
+ e.vy = -200;
+ } else if (state.inp.down) {
+ e.vy = 100;
+ } else {
+ e.vy = 0;
+ }
+ return;
+ }
+ const leftGX = Math.floor((e.x - 4) / TILE);
+ const rightGX = Math.floor((e.x + e.w + 4) / TILE);
+ const playerGY = Math.floor((e.y + e.h / 2) / TILE);
+ const leftBlock = getBlock2(leftGX, playerGY);
+ const rightBlock = getBlock2(rightGX, playerGY);
+ const leftLadder = leftBlock && BLOCKS[leftBlock.t] && BLOCKS[leftBlock.t].climbable;
+ const rightLadder = rightBlock && BLOCKS[rightBlock.t] && BLOCKS[rightBlock.t].climbable;
+ if ((leftLadder || rightLadder) && state.inp.jump && e.vy < 0) {
+ if (leftLadder && e.x > leftGX * TILE + TILE / 2) {
+ e.x = leftGX * TILE + TILE / 2 - e.w / 2;
+ } else if (rightLadder && e.x < rightGX * TILE + TILE / 2) {
+ e.x = rightGX * TILE + TILE / 2 - e.w / 2;
+ }
+ e.grounded = true;
+ e.vy = -150;
+ return;
+ }
+ if (e.vy >= 0) {
+ const probeY = e.y + e.h + 1;
+ const gy2 = Math.floor(probeY / TILE);
+ const gxA = Math.floor(x1 / TILE);
+ const gxB = Math.floor(x2 / TILE);
+ if (isSolid(gxA, gy2) || isSolid(gxB, gy2)) {
+ e.y = gy2 * TILE - e.h;
+ e.vy = 0;
+ e.grounded = true;
+ if (e === state.player && !state.player.inWater) {
+ const fallTiles = (e.y - e.fallStartY) / TILE;
+ if (fallTiles > 6) {
+ const damage = calculateDamage((fallTiles - 6) * 10);
+ state.player.hp -= damage;
+ }
}
- return false;
+ if (e === state.player) e.fallStartY = e.y;
+ }
+ }
+ if (e.vy < 0 && e === state.player) {
+ const gy2 = Math.floor(e.y / TILE);
+ const gxA = Math.floor(x1 / TILE);
+ const gxB = Math.floor(x2 / TILE);
+ if ((isSolid(gxA, gy2) || isSolid(gxB, gy2)) && !isSolid(gxA, gy2 - 1) && !isSolid(gxB, gy2 - 1)) {
+ e.y = (gy2 + 1) * TILE;
+ e.vy = 0;
+ e.grounded = true;
+ if (e === state.player) e.fallStartY = e.y;
+ console.log("Jumped onto block!");
+ }
+ }
+ if (e.vy < 0) {
+ const gy2 = Math.floor(e.y / TILE);
+ const gxA = Math.floor(x1 / TILE);
+ const gxB = Math.floor(x2 / TILE);
+ if (isSolid(gxA, gy2) || isSolid(gxB, gy2)) {
+ e.y = (gy2 + 1) * TILE;
+ e.vy = 0;
}
}
- return false;
}
-
- const RECIPES = [
- { out:'planks', qty:4, cost:{ wood:1 } },
- { out:'ladder', qty:3, cost:{ planks:7 } },
- { out:'torch', qty:2, cost:{ coal:1, planks:1 } },
- { out:'glass', qty:1, cost:{ sand:3 } },
- { out:'brick', qty:1, cost:{ stone:2, clay:1 } },
- { out:'campfire', qty:1, cost:{ wood:1, coal:1 } },
- { out:'tnt', qty:1, cost:{ sand:2, coal:1 } },
- { out:'bed', qty:1, cost:{ wood: 3, planks: 3 } },
- { out:'boat', qty:1, cost:{ wood: 5 } },
- { out:'wood_pickaxe', qty:1, cost:{ wood: 3, planks: 2 } },
- { out:'stone_pickaxe', qty:1, cost:{ wood: 1, stone: 3 } },
- { out:'iron_pickaxe', qty:1, cost:{ wood: 1, iron_ore: 2 } },
- { out:'wood_sword', qty:1, cost:{ wood: 2, planks: 1 } },
- { out:'stone_sword', qty:1, cost:{ wood: 1, stone: 2 } },
- { out:'iron_sword', qty:1, cost:{ wood: 1, iron_ore: 1 } },
- { out:'iron_armor', qty:1, cost:{ iron_ore: 5 } },
- { out:'furnace', qty:1, cost:{ stone: 8 } },
- { out:'bow', qty:1, cost:{ wood: 3, planks: 2 } },
- { out:'arrow', qty:4, cost:{ stone: 1, wood: 1 } }
- ];
-
- // Рецепты печи (обжиг)
- const SMELTING_RECIPES = [
- { in:'sand', qty:1, out:'glass', outQty:1, time:3 }, // песок → стекло
- { in:'clay', qty:1, out:'brick', outQty:1, time:3 }, // глина → кирпич
- { in:'iron_ore', qty:1, out:'iron_ingot', outQty:1, time:5 }, // железо → слиток
- { in:'gold_ore', qty:1, out:'gold_ingot', outQty:1, time:5 }, // золото → слиток
- { in:'copper_ore', qty:1, out:'copper_ingot', outQty:1, time:4 }, // медь → слиток
- { in:'meat', qty:1, out:'cooked', outQty:1, time:2 }, // сырое мясо → жареное
- { in:'cobblestone', qty:1, out:'stone', outQty:1, time:2 } // булыжник → камень
- ];
-
- // Новые предметы от обжига
- ITEMS.iron_ingot = { n:'Железный слиток', icon:'🔩' };
- ITEMS.gold_ingot = { n:'Золотой слиток', icon:'🪙' };
- ITEMS.copper_ingot = { n:'Медный слиток', icon:'🟤' };
-
- // Активные печи: Map ключа блока → { recipe, progress, totalTime }
- const activeFurnaces = new Map();
-
- // UI
- const hpEl = document.getElementById('hp');
- const foodEl = document.getElementById('food');
- const sxEl = document.getElementById('sx');
- const syEl = document.getElementById('sy');
- const todEl = document.getElementById('tod');
- const worldIdEl = document.getElementById('worldId');
- const playerCountEl = document.getElementById('playerCount');
- const hotbarEl = document.getElementById('hotbar');
- const craftPanel = document.getElementById('craftPanel');
- const recipesEl = document.getElementById('recipes');
- const deathEl = document.getElementById('death');
- const inventoryPanel = document.getElementById('inventoryPanel');
- const inventoryGrid = document.getElementById('inventoryGrid');
-
- // ==================== МИНИКАРТА ====================
- const minimapWrap = document.getElementById('minimapWrap');
- const minimapCanvas = document.getElementById('minimap');
- const minimapCtx = minimapCanvas.getContext('2d');
- let minimapOpen = false;
-
- document.getElementById('mapToggle').onclick = () => {
- playSound('click');
- minimapOpen = !minimapOpen;
- minimapWrap.style.display = minimapOpen ? 'block' : 'none';
+ function resolveX(e) {
+ const y1 = e.y + 2;
+ const y2 = e.y + e.h - 2;
+ const cx = e.x + e.w / 2;
+ const cy = e.y + e.h / 2;
+ const gx = Math.floor(cx / TILE);
+ const gy = Math.floor(cy / TILE);
+ const b = getBlock2(gx, gy);
+ const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable;
+ if (e.vx > 0) {
+ const gx2 = Math.floor((e.x + e.w) / TILE);
+ const gyA = Math.floor(y1 / TILE);
+ const gyB = Math.floor(y2 / TILE);
+ const solidA = isSolid(gx2, gyA);
+ const solidB = isSolid(gx2, gyB);
+ if (solidA || solidB) {
+ e.x = gx2 * TILE - e.w;
+ e.vx = 0;
+ }
+ } else if (e.vx < 0) {
+ const gx2 = Math.floor(e.x / TILE);
+ const gyA = Math.floor(y1 / TILE);
+ const gyB = Math.floor(y2 / TILE);
+ const solidA = isSolid(gx2, gyA);
+ const solidB = isSolid(gx2, gyB);
+ if (solidA || solidB) {
+ e.x = (gx2 + 1) * TILE;
+ e.vx = 0;
+ }
+ }
+ }
+
+ // src/entities/mob-ai.js
+ function mobAI(m, dt) {
+ updateWaterFlag(m);
+ if (m.kind === "zombie") {
+ const night = isNight();
+ if (!night) {
+ m.hp -= 30 * dt;
+ m.vx *= 0.5;
+ return;
+ }
+ const dir = Math.sign(state.player.x - m.x);
+ m.vx = dir * m.speed;
+ if (m.inWater && Math.random() < 0.06) m.vy = -260;
+ if (Math.abs(m.x + m.w / 2 - (state.player.x + state.player.w / 2)) < 28 && Math.abs(m.y + m.h / 2 - (state.player.y + state.player.h / 2)) < 40 && state.player.invuln <= 0) {
+ const damage = calculateDamage(15);
+ state.player.hp -= damage;
+ state.player.invuln = 0.8;
+ state.player.vx += dir * 420;
+ state.player.vy -= 260;
+ playSound("hit1");
+ }
+ } else if (m.kind === "creeper") {
+ const night = isNight();
+ if (!night) {
+ m.hp -= 30 * dt;
+ m.vx *= 0.5;
+ return;
+ }
+ const dir = Math.sign(state.player.x - m.x);
+ const dist = Math.hypot(state.player.x + state.player.w / 2 - (m.x + m.w / 2), state.player.y + state.player.h / 2 - (m.y + m.h / 2));
+ m.vx = dir * m.speed;
+ if (m.inWater && Math.random() < 0.06) m.vy = -260;
+ if (dist < 60) {
+ m.fuse -= dt;
+ if (m.fuse <= 0) {
+ explodeAt(Math.floor((m.x + m.w / 2) / TILE), Math.floor((m.y + m.h / 2) / TILE));
+ m.hp = 0;
+ }
+ } else {
+ if (dist < 40) {
+ m.fuse = 0.5;
+ }
+ }
+ } else if (m.kind === "skeleton") {
+ const night = isNight();
+ if (!night) {
+ m.hp -= 30 * dt;
+ m.vx *= 0.5;
+ return;
+ }
+ const dir = Math.sign(state.player.x - m.x);
+ const dist = Math.hypot(state.player.x + state.player.w / 2 - (m.x + m.w / 2), state.player.y + state.player.h / 2 - (m.y + m.h / 2));
+ m.vx = dir * m.speed;
+ if (m.inWater && Math.random() < 0.06) m.vy = -260;
+ m.shootCooldown -= dt;
+ if (dist < 300 && m.shootCooldown <= 0) {
+ m.shootCooldown = 2;
+ const dx = state.player.x + state.player.w / 2 - (m.x + m.w / 2);
+ const dy = state.player.y + state.player.h / 2 - (m.y + m.h / 2);
+ const angle = Math.atan2(dy, dx);
+ const speed = 450;
+ state.projectiles.push({
+ x: m.x + m.w / 2,
+ y: m.y + m.h / 3,
+ vx: Math.cos(angle) * speed,
+ vy: Math.sin(angle) * speed,
+ dmg: 6,
+ owner: "mob",
+ life: 3
+ });
+ }
+ } else {
+ m.aiT -= dt;
+ if (m.aiT <= 0) {
+ m.aiT = 1.8 + Math.random() * 2.5;
+ m.dir = Math.random() < 0.5 ? -1 : 1;
+ if (Math.random() < 0.25) m.dir = 0;
+ }
+ m.vx = m.dir * (m.kind === "chicken" ? 55 : 40);
+ if (m.inWater) m.vy = -120;
+ }
+ const g = m.inWater ? GRAV_WATER : GRAV;
+ m.vy += g * dt;
+ m.y += m.vy * dt;
+ m.grounded = false;
+ resolveY(m);
+ m.x += m.vx * dt;
+ resolveX(m);
+ }
+ function isNight() {
+ return state.worldTime > 0.5;
+ }
+
+ // src/ui/furnace.js
+ var furnacePanel = document.getElementById("furnacePanel");
+ var furnaceContent = document.getElementById("furnaceContent");
+ function initFurnace() {
+ document.getElementById("furnaceClose").onclick = () => {
+ furnacePanel.style.display = "none";
+ state.currentFurnaceKey = null;
+ };
+ }
+ function openFurnaceUI(gx, gy) {
+ state.currentFurnaceKey = `${gx},${gy}`;
+ furnacePanel.style.display = "block";
+ renderFurnaceUI();
+ }
+ function renderFurnaceUI() {
+ if (!state.currentFurnaceKey) return;
+ const [fgx, fgy] = state.currentFurnaceKey.split(",").map(Number);
+ const fb = getBlock2(fgx, fgy);
+ if (!fb || fb.t !== "furnace") {
+ furnacePanel.style.display = "none";
+ state.currentFurnaceKey = null;
+ return;
+ }
+ const active = state.activeFurnaces.get(state.currentFurnaceKey);
+ let html = '';
+ for (let i = 0; i < SMELTING_RECIPES.length; i++) {
+ const recipe = SMELTING_RECIPES[i];
+ const haveCount = state.inv[recipe.in] || 0;
+ const canSmelt = haveCount >= recipe.qty;
+ const outDef = BLOCKS[recipe.out];
+ const outItem = ITEMS[recipe.out];
+ const iconStr = outItem ? outItem.icon : outDef ? "\u{1F9F1}" : "\u2753";
+ const nameStr = outItem ? outItem.n : outDef ? outDef.n : recipe.out;
+ const inItem = ITEMS[recipe.in];
+ const inDef = BLOCKS[recipe.in];
+ const inName = inItem ? inItem.n : inDef ? inDef.n : recipe.in;
+ html += `
`;
+ html += `
${iconStr}
`;
+ html += `
`;
+ html += `
${nameStr}
`;
+ html += `
${inName} x${recipe.qty} (\u0435\u0441\u0442\u044C: ${haveCount}) \u2022 ${recipe.time}\u0441
`;
+ html += `
`;
+ html += `
`;
+ html += `
`;
+ }
+ if (active) {
+ const pct = Math.min(100, Math.floor(active.progress / active.recipe.time * 100));
+ html += `
`;
+ html += `
\u{1F525} \u041E\u0431\u0436\u0438\u0433: ${pct}%
`;
+ html += `
`;
+ html += `
`;
+ html += `
`;
+ }
+ html += "
";
+ furnaceContent.innerHTML = html;
+ }
+ window._smelt = (recipeIdx) => {
+ if (!state.currentFurnaceKey) return;
+ const recipe = SMELTING_RECIPES[recipeIdx];
+ if ((state.inv[recipe.in] || 0) < recipe.qty) return;
+ if (state.activeFurnaces.has(state.currentFurnaceKey)) return;
+ state.inv[recipe.in] -= recipe.qty;
+ state.activeFurnaces.set(state.currentFurnaceKey, {
+ recipe,
+ progress: 0
+ });
+ playSound("fire");
+ rebuildHotbar();
+ renderFurnaceUI();
};
-
- // Цвета блоков для миникарты (по 1 пикселю на блок)
- const MINIMAP_COLORS = {
- grass: '#4a8c2a', dirt: '#6b3410', stone: '#6a6a6a', sand: '#d4b84a',
- gravel: '#888', clay: '#5a9ad4', wood: '#a63d00', planks: '#c46a10',
- leaves: '#2a8a3a', glass: '#aaddee', water: '#2980b9', coal: '#1a1a2a',
- copper_ore: '#a05535', iron_ore: '#b0b0b0', gold_ore: '#d4a017',
- diamond_ore: '#0090d0', brick: '#8a2010', tnt: '#c02020',
- campfire: '#d08020', torch: '#d0a020', bedrock: '#1a1a1a',
- flower: '#d03050', bed: '#c03060', ladder: '#a04000', boat: '#6b3410'
+ function tickFurnaces(dt) {
+ for (const [key, furnace] of state.activeFurnaces) {
+ furnace.progress += dt;
+ if (furnace.progress >= furnace.recipe.time) {
+ const outItem = furnace.recipe.out;
+ if (ITEMS[outItem]) {
+ state.inv[outItem] = (state.inv[outItem] || 0) + furnace.recipe.outQty;
+ } else if (BLOCKS[outItem]) {
+ state.inv[outItem] = (state.inv[outItem] || 0) + furnace.recipe.outQty;
+ }
+ playSound("stone_build");
+ state.activeFurnaces.delete(key);
+ if (key === state.currentFurnaceKey) {
+ renderFurnaceUI();
+ }
+ }
+ }
+ }
+
+ // src/world/weather.js
+ function updateWeather(dt) {
+ state.weatherTimer += dt;
+ if (state.weatherTimer >= state.weatherChangeInterval) {
+ state.weatherTimer = 0;
+ state.weatherChangeInterval = 60 + Math.random() * 120;
+ const nightChance = isNight() ? 0.25 : 0.4;
+ state.isRaining = Math.random() < nightChance;
+ }
+ const target = state.isRaining ? 0.4 + Math.random() * 0.01 : 0;
+ state.rainIntensity += (target - state.rainIntensity) * dt * 0.5;
+ if (state.rainIntensity < 0.01) state.rainIntensity = 0;
+ }
+ function updateRain(dt) {
+ if (!state.isRaining || state.rainIntensity < 0.01) {
+ state.raindrops.length = 0;
+ return;
+ }
+ const spawnRate = Math.floor(state.rainIntensity * 60 * dt);
+ for (let i = 0; i < spawnRate && state.raindrops.length < state.MAX_RAINDROPS; i++) {
+ state.raindrops.push({
+ x: state.camX + Math.random() * state.W,
+ y: state.camY - 20,
+ vy: 400 + Math.random() * 200,
+ len: 8 + Math.random() * 12
+ });
+ }
+ for (let i = state.raindrops.length - 1; i >= 0; i--) {
+ const d = state.raindrops[i];
+ d.y += d.vy * dt;
+ d.x -= 30 * dt;
+ if (d.y > state.camY + state.H + 20) {
+ state.raindrops.splice(i, 1);
+ }
+ }
+ }
+ function drawRain() {
+ if (state.raindrops.length === 0) return;
+ const ctx2 = state.ctx;
+ ctx2.save();
+ ctx2.strokeStyle = "rgba(174,194,224,0.5)";
+ ctx2.lineWidth = 1.5;
+ ctx2.beginPath();
+ for (const d of state.raindrops) {
+ ctx2.moveTo(d.x, d.y);
+ ctx2.lineTo(d.x - 3, d.y + d.len);
+ }
+ ctx2.stroke();
+ ctx2.restore();
+ }
+
+ // src/entities/mobs.js
+ var Entity = class {
+ constructor(x, y, w, h) {
+ this.x = x;
+ this.y = y;
+ this.w = w;
+ this.h = h;
+ this.vx = 0;
+ this.vy = 0;
+ this.hp = 3;
+ this.grounded = false;
+ this.inWater = false;
+ this.aiT = 0;
+ this.dir = 1;
+ }
};
-
+ var Pig = class extends Entity {
+ constructor(x, y) {
+ super(x, y, 34, 24);
+ this.kind = "pig";
+ this.hp = 2;
+ }
+ };
+ var Chicken = class extends Entity {
+ constructor(x, y) {
+ super(x, y, 26, 22);
+ this.kind = "chicken";
+ this.hp = 1;
+ }
+ };
+ var Zombie = class extends Entity {
+ constructor(x, y) {
+ super(x, y, 34, 50);
+ this.kind = "zombie";
+ this.hp = 4;
+ this.speed = 80 + Math.random() * 40;
+ }
+ };
+ var Creeper = class extends Entity {
+ constructor(x, y) {
+ super(x, y, 34, 50);
+ this.kind = "creeper";
+ this.hp = 4;
+ this.speed = 60 + Math.random() * 30;
+ this.fuse = 3.2;
+ }
+ };
+ var Skeleton = class extends Entity {
+ constructor(x, y) {
+ super(x, y, 34, 50);
+ this.kind = "skeleton";
+ this.hp = 4;
+ this.speed = 70 + Math.random() * 30;
+ this.shootCooldown = 0;
+ }
+ };
+
+ // src/ui/minimap.js
+ var minimapWrap = document.getElementById("minimapWrap");
+ var minimapCanvas = document.getElementById("minimap");
+ var minimapCtx = minimapCanvas.getContext("2d");
+ var MINIMAP_COLORS = {
+ grass: "#4a8c2a",
+ dirt: "#6b3410",
+ stone: "#6a6a6a",
+ sand: "#d4b84a",
+ gravel: "#888",
+ clay: "#5a9ad4",
+ wood: "#a63d00",
+ planks: "#c46a10",
+ leaves: "#2a8a3a",
+ glass: "#aaddee",
+ water: "#2980b9",
+ coal: "#1a1a2a",
+ copper_ore: "#a05535",
+ iron_ore: "#b0b0b0",
+ gold_ore: "#d4a017",
+ diamond_ore: "#0090d0",
+ brick: "#8a2010",
+ tnt: "#c02020",
+ campfire: "#d08020",
+ torch: "#d0a020",
+ bedrock: "#1a1a1a",
+ flower: "#d03050",
+ bed: "#c03060",
+ ladder: "#a04000",
+ boat: "#6b3410"
+ };
+ function initMinimap() {
+ document.getElementById("mapToggle").onclick = () => {
+ playSound("click");
+ state.minimapOpen = !state.minimapOpen;
+ minimapWrap.style.display = state.minimapOpen ? "block" : "none";
+ };
+ }
function renderMinimap() {
- if (!minimapOpen) return;
+ if (!state.minimapOpen) return;
const mW = minimapCanvas.width;
const mH = minimapCanvas.height;
- const scale = 2; // пикселей на блок
-
- // Область карты — центрирована на игроке
- const pGX = Math.floor(player.x / TILE);
- const pGY = Math.floor(player.y / TILE);
+ const scale = 2;
+ const TILE2 = state.TILE;
+ const player = state.player;
+ const pGX = Math.floor(player.x / TILE2);
+ const pGY = Math.floor(player.y / TILE2);
const viewW = Math.floor(mW / scale);
const viewH = Math.floor(mH / scale);
const startGX = pGX - Math.floor(viewW / 2);
const startGY = pGY - Math.floor(viewH / 2);
-
- // Очищаем
- minimapCtx.fillStyle = isNight() ? '#070816' : '#87CEEB';
+ minimapCtx.fillStyle = isNight() ? "#070816" : "#87CEEB";
minimapCtx.fillRect(0, 0, mW, mH);
-
- // Рисуем блоки
const imgData = minimapCtx.createImageData(mW, mH);
const data = imgData.data;
-
for (let dx = 0; dx < viewW; dx++) {
for (let dy = 0; dy < viewH; dy++) {
const gx = startGX + dx;
const gy = startGY + dy;
- const b = getBlock(gx, gy);
- if (!b || b.dead || b.t === 'air') continue;
-
+ const b = getBlock2(gx, gy);
+ if (!b || b.dead || b.t === "air") continue;
const color = MINIMAP_COLORS[b.t];
if (!color) continue;
-
- // Парсим hex цвет
- const r = parseInt(color.slice(1,3), 16);
- const g = parseInt(color.slice(3,5), 16);
- const bl = parseInt(color.slice(5,7), 16);
-
- // Заполняем scale x scale пикселей
+ const r = parseInt(color.slice(1, 3), 16);
+ const g = parseInt(color.slice(3, 5), 16);
+ const bl = parseInt(color.slice(5, 7), 16);
for (let sx = 0; sx < scale; sx++) {
for (let sy = 0; sy < scale; sy++) {
const px = dx * scale + sx;
@@ -1046,2092 +1783,462 @@
if (px >= mW || py >= mH) continue;
const idx = (py * mW + px) * 4;
data[idx] = r;
- data[idx+1] = g;
- data[idx+2] = bl;
- data[idx+3] = 255;
+ data[idx + 1] = g;
+ data[idx + 2] = bl;
+ data[idx + 3] = 255;
}
}
}
}
-
minimapCtx.putImageData(imgData, 0, 0);
-
- // Игрок — белый пиксель по центру
- minimapCtx.fillStyle = '#fff';
- minimapCtx.fillRect(Math.floor(mW/2) - 2, Math.floor(mH/2) - 2, 4, 4);
-
- // Другие игроки — жёлтые точки
- for (const [sid, p] of otherPlayers) {
- const dx = Math.floor(p.x / TILE) - startGX;
- const dy = Math.floor(p.y / TILE) - startGY;
+ minimapCtx.fillStyle = "#fff";
+ minimapCtx.fillRect(Math.floor(mW / 2) - 2, Math.floor(mH / 2) - 2, 4, 4);
+ for (const [sid, p] of state.otherPlayers) {
+ const dx = Math.floor(p.x / TILE2) - startGX;
+ const dy = Math.floor(p.y / TILE2) - startGY;
if (dx >= 0 && dx < viewW && dy >= 0 && dy < viewH) {
- minimapCtx.fillStyle = '#f1c40f';
+ minimapCtx.fillStyle = "#f1c40f";
minimapCtx.fillRect(dx * scale - 1, dy * scale - 1, 3, 3);
}
}
-
- // Мобы — красные (враждебные) / зелёные (животные)
- const allMobsForMap = isMultiplayer ? Array.from(serverMobs.values()) : mobs;
+ const allMobsForMap = state.isMultiplayer ? Array.from(state.serverMobs.values()) : state.mobs;
for (const m of allMobsForMap) {
- const dx = Math.floor(m.x / TILE) - startGX;
- const dy = Math.floor(m.y / TILE) - startGY;
+ const dx = Math.floor(m.x / TILE2) - startGX;
+ const dy = Math.floor(m.y / TILE2) - startGY;
if (dx >= 0 && dx < viewW && dy >= 0 && dy < viewH) {
- const hostile = m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton';
- minimapCtx.fillStyle = hostile ? '#e74c3c' : '#2ecc71';
+ const hostile = m.kind === "zombie" || m.kind === "creeper" || m.kind === "skeleton";
+ minimapCtx.fillStyle = hostile ? "#e74c3c" : "#2ecc71";
minimapCtx.fillRect(dx * scale, dy * scale, 2, 2);
}
}
}
-
- // ==================== ПЕЧЬ (ОБЖИГ) ====================
- const furnacePanel = document.getElementById('furnacePanel');
- const furnaceContent = document.getElementById('furnaceContent');
- let currentFurnaceKey = null; // "gx,gy" текущей открытой печи
-
- document.getElementById('furnaceClose').onclick = () => {
- furnacePanel.style.display = 'none';
- currentFurnaceKey = null;
- };
-
- function openFurnaceUI(gx, gy) {
- currentFurnaceKey = `${gx},${gy}`;
- furnacePanel.style.display = 'block';
- renderFurnaceUI();
+
+ // src/game/modes.js
+ var MODES = [{ id: "mine", icon: "\u26CF\uFE0F" }, { id: "build", icon: "\u{1F9F1}" }];
+ function mode() {
+ return MODES[state.modeIdx].id;
}
-
- function renderFurnaceUI() {
- if (!currentFurnaceKey) return;
-
- // Проверяем что печь всё ещё существует
- const [fgx, fgy] = currentFurnaceKey.split(',').map(Number);
- const fb = getBlock(fgx, fgy);
- if (!fb || fb.t !== 'furnace') {
- furnacePanel.style.display = 'none';
- currentFurnaceKey = null;
- return;
- }
-
- // Текущий процесс обжига
- const active = activeFurnaces.get(currentFurnaceKey);
-
- let html = '';
-
- // Доступные рецепты — показываем только те, для которых есть ресурсы
- for (let i = 0; i < SMELTING_RECIPES.length; i++) {
- const recipe = SMELTING_RECIPES[i];
- const haveCount = inv[recipe.in] || 0;
- const canSmelt = haveCount >= recipe.qty;
-
- // Иконка результата
- const outDef = BLOCKS[recipe.out];
- const outItem = ITEMS[recipe.out];
- const iconStr = outItem ? outItem.icon : (outDef ? '🧱' : '❓');
- const nameStr = outItem ? outItem.n : (outDef ? outDef.n : recipe.out);
- const inItem = ITEMS[recipe.in];
- const inDef = BLOCKS[recipe.in];
- const inName = inItem ? inItem.n : (inDef ? inDef.n : recipe.in);
-
- html += `
`;
- html += `
${iconStr}
`;
- html += `
`;
- html += `
${nameStr}
`;
- html += `
${inName} x${recipe.qty} (есть: ${haveCount}) • ${recipe.time}с
`;
- html += `
`;
- html += `
`;
- html += `
`;
- }
-
- // Текущий прогресс
- if (active) {
- const pct = Math.min(100, Math.floor((active.progress / active.recipe.time) * 100));
- html += `
`;
- html += `
🔥 Обжиг: ${pct}%
`;
- html += `
`;
- html += `
`;
- html += `
`;
- }
-
- html += '
';
- furnaceContent.innerHTML = html;
- }
-
- // Глобальная функция для кнопки обжига
- window._smelt = (recipeIdx) => {
- if (!currentFurnaceKey) return;
- const recipe = SMELTING_RECIPES[recipeIdx];
- if ((inv[recipe.in] || 0) < recipe.qty) return;
-
- // Уже обжигаем в этой печи?
- if (activeFurnaces.has(currentFurnaceKey)) return;
-
- // Забираем ресурсы
- inv[recipe.in] -= recipe.qty;
-
- // Запускаем обжиг
- activeFurnaces.set(currentFurnaceKey, {
- recipe: recipe,
- progress: 0
- });
-
- playSound('fire');
- rebuildHotbar();
- renderFurnaceUI();
- };
-
- // Тик печей — вызывается в главном цикле
- function tickFurnaces(dt) {
- for (const [key, furnace] of activeFurnaces) {
- furnace.progress += dt;
- if (furnace.progress >= furnace.recipe.time) {
- // Обжиг завершён — выдаём результат
- const outItem = furnace.recipe.out;
- if (ITEMS[outItem]) {
- inv[outItem] = (inv[outItem] || 0) + furnace.recipe.outQty;
- } else if (BLOCKS[outItem]) {
- inv[outItem] = (inv[outItem] || 0) + furnace.recipe.outQty;
- }
- playSound('stone_build');
- activeFurnaces.delete(key);
-
- // Если эта печь открыта — обновляем UI
- if (key === currentFurnaceKey) {
- renderFurnaceUI();
- }
- }
- }
- }
-
- // ==================== ГОЛОСОВОЙ ЧАТ ====================
- let voiceSocket = null;
- let voiceStream = null;
- let audioCtx = null;
- let voiceProcessor = null;
- let voiceActive = false;
- const VOICE_SERVER = 'https://voicegrech.mkn8n.ru';
-
- // Кнопка микрофона
- const voiceBtn = document.createElement('div');
- voiceBtn.innerHTML = '🎤/';
- voiceBtn.title = 'Голосовой чат (выкл)';
- voiceBtn.style.cssText = 'position:absolute;top:74px;right:130px;width:52px;height:52px;border-radius:12px;background:#555;z-index:200;display:flex;align-items:center;justify-content:center;font-size:24px;cursor:pointer;border:2px solid rgba(255,255,255,0.9);box-shadow:0 4px 0 rgba(0,0,0,0.5);pointer-events:auto;';
- document.querySelector('.ui').appendChild(voiceBtn);
-
- // Индикатор говорящего
- const speakingIndicator = document.createElement('div');
- speakingIndicator.style.cssText = 'position:absolute;top:130px;right:10px;z-index:200;display:none;background:rgba(0,0,0,0.7);color:#2ecc71;padding:4px 8px;border-radius:6px;font-size:12px;';
- speakingIndicator.textContent = '🔊';
- document.querySelector('.ui').appendChild(speakingIndicator);
- let speakingTimeout = null;
-
- voiceBtn.onclick = async () => {
- if (voiceActive) {
- // Выключить
- voiceActive = false;
- voiceBtn.innerHTML = '🎤/';
- voiceBtn.style.background = '#555';
- if (voiceStream) {
- voiceStream.getTracks().forEach(t => t.stop());
- voiceStream = null;
- }
- if (voiceProcessor) { voiceProcessor.disconnect(); voiceProcessor = null; }
- if (audioCtx) { audioCtx.close(); audioCtx = null; }
- if (voiceSocket) { voiceSocket.disconnect(); voiceSocket = null; }
- return;
- }
-
- // Включить
- try {
- voiceStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 24000 } });
- audioCtx = new AudioContext({ sampleRate: 24000 });
- const source = audioCtx.createMediaStreamSource(voiceStream);
- voiceProcessor = audioCtx.createScriptProcessor(4096, 1, 1);
-
- voiceProcessor.onaudioprocess = (e) => {
- if (!voiceActive || !voiceSocket || !voiceSocket.connected) return;
- const pcm = e.inputBuffer.getChannelData(0);
- // Конвертируем float32 → int16 для экономии трафика
- const int16 = new Int16Array(pcm.length);
- for (let i = 0; i < pcm.length; i++) {
- const s = Math.max(-1, Math.min(1, pcm[i]));
- int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
- }
- voiceSocket.emit('voice_data', int16.buffer);
- };
-
- source.connect(voiceProcessor);
- voiceProcessor.connect(audioCtx.createGain()); // не в destination — чтобы не слышать себя
-
- // Подключаемся к голосовому серверу
- voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] });
- voiceSocket.on('connect', () => {
- voiceSocket.emit('voice_join', { world_id: worldId, x: player.x, y: player.y, name: playerName || 'Игрок' });
- });
-
- voiceSocket.on('voice_in', (payload) => {
- // Воспроизводим входящий голос
- const { data, meta, volume } = payload;
- if (!audioCtx || audioCtx.state === 'closed') return;
-
- // Int16 → Float32
- const int16 = new Int16Array(data);
- const float32 = new Float32Array(int16.length);
- for (let i = 0; i < int16.length; i++) {
- float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF) * volume;
- }
-
- const buf = audioCtx.createBuffer(1, float32.length, 24000);
- buf.getChannelData(0).set(float32);
- const src = audioCtx.createBufferSource();
- src.buffer = buf;
-
- const gain = audioCtx.createGain();
- gain.gain.value = volume;
- src.connect(gain).connect(audioCtx.destination);
- src.start();
-
- // Индикатор
- speakingIndicator.style.display = 'block';
- speakingIndicator.textContent = `🔊 ${meta.name}`;
- clearTimeout(speakingTimeout);
- speakingTimeout = setTimeout(() => { speakingIndicator.style.display = 'none'; }, 500);
- });
-
- voiceActive = true;
- voiceBtn.textContent = '🎤';
- voiceBtn.style.background = '#2ecc71';
- } catch(e) {
- console.error('Voice error:', e);
- voiceBtn.style.background = '#e74c3c';
- }
- };
-
- // Обновляем позицию для voice server
- const origPlayerMove = () => {};
- // Хук в главный цикл — обновляем позицию каждые ~500ms
- let voicePosT = 0;
-
- // Клик на часы для включения ночи
- todEl.style.cursor = 'pointer';
- todEl.onclick = () => {
- playSound('click');
- worldTime = 0.6; // Устанавливаем ночь
- isNightTime = true;
- };
-
- function rebuildHotbar(){
- hotbarEl.innerHTML='';
-
- // Показываем последние 5 выбранных предметов (если они есть в инвентаре)
- const items = recentItems.filter(id => inv[id] > 0).slice(0, 5);
-
- for(const id of items){
- const s = document.createElement('div');
- s.className = 'slot'+(id===selected?' sel':'');
- if(BLOCKS[id]) {
- s.style.backgroundImage = `url(${tex[id].toDataURL()})`;
- s.style.backgroundSize = 'cover';
- } else if(ITEMS[id]) {
- s.textContent = ITEMS[id].icon;
- } else if(TOOLS[id]) {
- s.textContent = TOOLS[id].icon;
- } else if(id === 'iron_armor') {
- s.textContent = '🛡️';
- s.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)';
- }
- const c = document.createElement('div');
- c.className='count';
- c.textContent = inv[id];
- s.appendChild(c);
- s.onclick = () => {
- playSound('click'); // Звук клика по инвентарю
- selected=id;
- // Обновляем список последних предметов
- recentItems = recentItems.filter(item => item !== id); // Удаляем если уже есть
- recentItems.unshift(id); // Добавляем в начало
- recentItems = recentItems.slice(0, 5); // Оставляем только 5
- rebuildHotbar();
- };
-
- // Показываем индикатор надетой брони
- if(id === 'iron_armor' && player.equippedArmor === 'iron_armor') {
- const equipped = document.createElement('div');
- equipped.className = 'equipped-indicator';
- equipped.textContent = '✓';
- s.appendChild(equipped);
- }
-
- // Durability bar для инструментов
- if(TOOLS[id] && inv[id] > 0) {
- // Находим текущую прочность
- let curDur = 0, maxDur = TOOLS[id].durability;
- for (const [tid, dur] of toolDurability) {
- if (dur.type === id) {
- curDur = dur.current;
- maxDur = dur.max;
- break;
- }
- }
- if (maxDur > 0) {
- const bar = document.createElement('div');
- bar.style.cssText = `position:absolute;bottom:0;left:0;right:0;height:3px;background:#333;border-radius:0 0 6px 6px;overflow:hidden;`;
- const fill = document.createElement('div');
- const pct = curDur / maxDur;
- const color = pct > 0.5 ? '#2ecc71' : pct > 0.25 ? '#f39c12' : '#e74c3c';
- fill.style.cssText = `width:${pct*100}%;height:100%;background:${color};`;
- bar.appendChild(fill);
- s.appendChild(bar);
- }
- }
- hotbarEl.appendChild(s);
- }
- }
-
- function renderInventory() {
- inventoryGrid.innerHTML = '';
-
- // Создаём сетку инвентаря 7x3
- const items = Object.keys(inv).filter(id => inv[id] > 0);
-
- // Добавляем пустые слоты для полной сетки
- for(let i = 0; i < 21; i++) {
- const slot = document.createElement('div');
- slot.className = 'inv-slot' + (i < items.length && items[i] === selected ? ' sel' : '');
-
- if(i < items.length) {
- const id = items[i];
- if(BLOCKS[id]) {
- slot.style.backgroundImage = `url(${tex[id].toDataURL()})`;
- slot.style.backgroundSize = 'cover';
- } else if(ITEMS[id]) {
- slot.textContent = ITEMS[id].icon;
- } else if(TOOLS[id]) {
- slot.textContent = TOOLS[id].icon;
- } else if(id === 'iron_armor') {
- slot.textContent = '🛡️';
- slot.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)';
- }
-
- const count = document.createElement('div');
- count.className = 'inv-count';
- count.textContent = inv[id];
- slot.appendChild(count);
-
- slot.onclick = () => {
- playSound('click'); // Звук клика по инвентарю
- selected = id;
- // Обновляем список последних предметов
- recentItems = recentItems.filter(item => item !== id); // Удаляем если уже есть
- recentItems.unshift(id); // Добавляем в начало
- recentItems = recentItems.slice(0, 5); // Оставляем только 5
- rebuildHotbar();
- renderInventory();
- };
-
- // Двойной клик для надевания брони
- slot.ondblclick = () => {
- if(id === 'iron_armor' && inv.iron_armor > 0) {
- // Если уже надета броня - снимаем её
- if(player.equippedArmor === 'iron_armor') {
- player.equippedArmor = null;
- player.armor = 0;
- console.log('[ARMOR] Iron armor unequipped');
- } else {
- // Надеваем броню
- player.equippedArmor = 'iron_armor';
- player.armor = BLOCKS['iron_armor'].armor;
- console.log('[ARMOR] Iron armor equipped - armor:', player.armor);
- }
- playSound('click');
- renderInventory();
- }
- };
- }
-
- inventoryGrid.appendChild(slot);
- }
- }
-
- function canCraft(r){
- console.log('[CRAFT] Checking recipe for:', r.out, '- cost:', r.cost);
- for(const res in r.cost){
- const have = inv[res] || 0;
- const need = r.cost[res];
- console.log('[CRAFT] Resource:', res, '- have:', have, '- need:', need, '- can craft:', have >= need);
- if(have < need) return false;
- }
- return true;
- }
- function renderCraft(){
- recipesEl.innerHTML='';
- for(const r of RECIPES){
- const row = document.createElement('div');
- row.className='recipe';
- const icon = document.createElement('div');
- icon.className='ricon';
- // Иконка — блок, инструмент или предмет
- if(tex[r.out]){
- icon.style.backgroundImage = `url(${tex[r.out].toDataURL()})`;
- } else if(TOOLS[r.out]){
- icon.textContent = TOOLS[r.out].icon;
- icon.style.fontSize = '24px';
- icon.style.display = 'flex';
- icon.style.alignItems = 'center';
- icon.style.justifyContent = 'center';
- } else if(ITEMS[r.out]){
- icon.textContent = ITEMS[r.out].icon;
- icon.style.fontSize = '24px';
- icon.style.display = 'flex';
- icon.style.alignItems = 'center';
- icon.style.justifyContent = 'center';
- }
- const info = document.createElement('div');
- info.className='rinfo';
- const nm = document.createElement('div');
- nm.className='rname';
- const itemName = BLOCKS[r.out]?.n || TOOLS[r.out]?.n || ITEMS[r.out]?.n || r.out;
- nm.textContent = `${itemName} x${r.qty}`;
- const cs = document.createElement('div');
- cs.className='rcost';
- cs.textContent = Object.keys(r.cost).map(x => {
- const cn = BLOCKS[x]?.n || TOOLS[x]?.n || ITEMS[x]?.n || x;
- return `${cn}: ${(inv[x]||0)}/${r.cost[x]}`;
- }).join(' ');
- info.appendChild(nm); info.appendChild(cs);
- const btn = document.createElement('button');
- btn.className='rcraft';
- btn.textContent='Создать';
- btn.disabled = !canCraft(r);
- btn.onclick = () => {
- if(!canCraft(r)) return;
- playSound('click');
- for(const res in r.cost) inv[res]-=r.cost[res];
- inv[r.out] = (inv[r.out]||0) + r.qty;
- if(TOOLS[r.out]) addTool(r.out);
- rebuildHotbar();
- renderCraft();
- };
- row.appendChild(icon); row.appendChild(info); row.appendChild(btn);
- recipesEl.appendChild(row);
- }
- }
-
- let craftOpen=false;
- let inventoryOpen = false;
-
- document.getElementById('craftBtn').onclick = () => {
- playSound('click'); // Звук клика по кнопке
- craftOpen = !craftOpen;
- craftPanel.style.display = craftOpen ? 'block' : 'none';
- if(craftOpen) {
- renderCraft();
- // Закрываем инвентарь если открыт крафт
- inventoryOpen = false;
- inventoryPanel.style.display = 'none';
- }
- };
- document.getElementById('craftClose').onclick = () => {
- playSound('click'); // Звук клика по кнопке
- craftOpen = false;
- craftPanel.style.display = 'none';
- };
-
- // Кнопка открытия инвентаря
- document.getElementById('invToggle').onclick = () => {
- playSound('click'); // Звук клика по кнопке
- inventoryOpen = true;
- inventoryPanel.style.display = 'block';
- renderInventory();
- // Закрываем крафт если открыт инвентарь
- craftOpen = false;
- craftPanel.style.display = 'none';
- };
-
- document.getElementById('inventoryClose').onclick = () => {
- playSound('click'); // Звук клика по кнопке
- inventoryOpen = false;
- inventoryPanel.style.display = 'none';
- };
-
- // Кнопка сохранения игры (только для одиночного режима)
- const saveBtn = document.getElementById('saveBtn');
- saveBtn.onclick = () => {
- playSound('click');
- saveGame();
- alert('Игра сохранена!');
- };
-
- // Кнопка сброса игры (удаление сохранения и создание нового мира)
- const resetBtn = document.getElementById('resetBtn');
- resetBtn.onclick = () => {
- if (confirm('Вы уверены, что хотите удалить сохранение и начать новую игру?')) {
- playSound('click');
-
- // Удаляем сохранение из localStorage
- try {
- localStorage.removeItem(SAVE_KEY);
- console.log('Сохранение удалено из localStorage');
- } catch (e) {
- console.warn('Ошибка удаления сохранения:', e);
- }
-
- // Сбрасываем in-memory сохранение
- inMemorySave = null;
-
- // Генерируем новый worldId
- worldId = Math.random().toString(36).substring(2, 10);
- console.log('Новый worldId после сброса:', worldId);
-
- // Обновляем URL
- try {
- const newUrl = new URL(window.location.href);
- newUrl.searchParams.set('world', worldId);
- const newUrlString = newUrl.toString();
-
- if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') {
- window.history.replaceState(null, '', newUrlString);
- console.log('URL обновлён:', newUrlString);
- }
- } catch (e) {
- console.error('Ошибка обновления URL:', e);
- }
-
- // Перезагружаем страницу
- location.reload();
- }
- };
-
- // Показываем кнопку сохранения только если играем одни
- function updateSaveButtonVisibility() {
- if (isMultiplayer && otherPlayers.size > 0) {
- saveBtn.style.display = 'none';
- } else {
- saveBtn.style.display = 'flex';
- }
- }
-
- // Режимы
- const MODES = [{id:'mine',icon:'⛏️'},{id:'build',icon:'🧱'}];
- let modeIdx=0;
- const modeBtn = document.getElementById('modeBtn');
- function mode(){ return MODES[modeIdx].id; }
- modeBtn.onclick = () => {
- playSound('click'); // Звук клика по кнопке режима
- modeIdx=(modeIdx+1)%MODES.length; modeBtn.textContent=MODES[modeIdx].icon;
- };
-
- // День/ночь (автоматический цикл)
- let isNightTime = false;
-
- // Управление
- const inp = { l:false, r:false, j:false, s:false };
- function bindHold(el, key){
- const down=(e)=>{ e.preventDefault(); inp[key]=true; };
- const up=(e)=>{ e.preventDefault(); inp[key]=false; };
- el.addEventListener('pointerdown', down);
- el.addEventListener('pointerup', up);
- el.addEventListener('pointerleave', up);
- }
- const leftBtn = document.getElementById('left');
- const rightBtn = document.getElementById('right');
- const jumpBtn = document.getElementById('jump');
- const downBtn = document.getElementById('down');
-
- if(leftBtn) bindHold(leftBtn,'l');
- if(rightBtn) bindHold(rightBtn,'r');
- if(jumpBtn) bindHold(jumpBtn,'j');
- if(downBtn) bindHold(downBtn,'s');
-
- window.addEventListener('keydown', (e)=>{
- if(e.code==='KeyA'||e.code==='ArrowLeft') inp.l=true;
- if(e.code==='KeyD'||e.code==='ArrowRight') inp.r=true;
- if(e.code==='Space'||e.code==='KeyW'||e.code==='ArrowUp') inp.j=true;
- if(e.code==='KeyS'||e.code==='ArrowDown') inp.s=true;
- });
- window.addEventListener('keyup', (e)=>{
- if(e.code==='KeyA'||e.code==='ArrowLeft') inp.l=false;
- if(e.code==='KeyD'||e.code==='ArrowRight') inp.r=false;
- if(e.code==='Space'||e.code==='KeyW'||e.code==='ArrowUp') inp.j=false;
- if(e.code==='KeyS'||e.code==='ArrowDown') inp.s=false;
- });
-
- // Лодка
- const boat = {
- x: 0, y: 0,
- w: 34, h: 34,
- vx: 0, vy: 0,
- active: false,
- inWater: false
- };
-
- // Функция для расчёта урона с учётом брони
- function calculateDamage(baseDamage) {
- // Броня снижает урон пропорционально
- // armor: 0 = без брони (100% урона)
- // armor: 0.5 = железная броня (50% урона)
- const reduction = player.armor;
- const actualDamage = baseDamage * (1 - reduction);
- console.log('[DAMAGE] Base:', baseDamage, '- Armor:', reduction, '- Actual:', actualDamage.toFixed(1));
- return actualDamage;
- }
-
- // Игрок
- const player = {
- x: 6*TILE, y: 0*TILE,
- w: 34, h: 34,
- vx: 0, vy: 0,
- grounded: false,
- inWater: false,
- headInWater: false,
- hp: 100,
- hunger: 100,
- o2: 100,
- invuln: 0,
- fallStartY: 0,
- lastStepTime: 0,
- sleeping: false,
- inBoat: false,
- armor: 0, // Текущий уровень защиты (0 = без брони, 0.5 = железная броня)
- equippedArmor: null // Тип надетой брони
- };
-
- // Сохраняем начальную позицию для возрождения
- const spawnPoint = { x: 6*TILE, y: 0*TILE };
-
- // Система сохранения игры (localStorage + in-memory fallback)
- const SAVE_KEY = 'minegrechka_save';
- let db = null; // Оставляем для совместимости, но не используем
- let inMemorySave = null; // Запасное сохранение в памяти
-
- // Инициализация (localStorage + in-memory fallback)
- function initDB(){
- return new Promise((resolve) => {
- console.log('Используем localStorage для сохранений (sandbox режим)');
- resolve(null);
- });
- }
-
- // Детерминированный генератор псевдослучайных чисел на основе seed
- function seededRandom(gx, gy){
- const n = Math.sin(gx * 12.9898 + gy * 78.233 + worldSeed * 0.1) * 43758.5453;
- return n - Math.floor(n);
- }
-
- function saveGame(){
- const saveData = {
- version: 2,
- worldSeed: worldSeed,
- player: {
- x: player.x,
- y: player.y,
- hp: player.hp,
- hunger: player.hunger,
- o2: player.o2
- },
- inventory: inv,
- time: worldTime,
- isNight: isNightTime,
- // Сохраняем только изменения
- placedBlocks: placedBlocks.slice(),
- removedBlocks: removedBlocks.slice()
+ function initModes() {
+ const modeBtn = document.getElementById("modeBtn");
+ modeBtn.onclick = () => {
+ playSound("click");
+ state.modeIdx = (state.modeIdx + 1) % MODES.length;
+ modeBtn.textContent = MODES[state.modeIdx].icon;
};
-
- const saveSize = JSON.stringify(saveData).length;
- console.log('Сохранение: player HP:', player.hp, 'hunger:', player.hunger, 'o2:', player.o2);
-
- // Пробуем сохранить в localStorage (основной метод для персистентности)
- try {
- localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
- console.log(`Игра сохранена в localStorage (размер: ${saveSize} байт)`);
- } catch(e){
- console.warn('Ошибка сохранения в localStorage, используем только in-memory:', e);
-
- // Если localStorage недоступен, используем in-memory fallback
- inMemorySave = saveData;
- console.log(`Игра сохранена в памяти (размер: ${saveSize} байт)`);
- }
}
-
- function loadGame(){
- return new Promise((resolve, reject) => {
- // Пробуем localStorage
- try {
- const localSave = localStorage.getItem(SAVE_KEY);
- if(localSave){
- const parsed = JSON.parse(localSave);
- console.log('Загружено из localStorage, player HP:', parsed.player?.hp);
- resolve(parsed);
- return;
- }
- } catch(e){
- console.warn('Ошибка доступа к localStorage:', e);
- }
-
- // Если localStorage недоступен, используем in-memory сохранение
- if(inMemorySave){
- console.log('Загружено из in-memory сохранения, player HP:', inMemorySave.player?.hp);
- resolve(inMemorySave);
+
+ // src/input/mouse-handler.js
+ var mouse = { x: null, y: null };
+ function initMouseHandlers() {
+ const canvas2 = state.canvas;
+ canvas2.addEventListener("pointermove", (e) => {
+ const r = canvas2.getBoundingClientRect();
+ mouse.x = e.clientX - r.left;
+ mouse.y = e.clientY - r.top;
+ });
+ canvas2.addEventListener("pointerdown", (e) => {
+ if (state.craftOpen) return;
+ if (state.player.hp <= 0) return;
+ const r = canvas2.getBoundingClientRect();
+ const sx = e.clientX - r.left;
+ const sy = e.clientY - r.top;
+ const wx = sx + state.camX;
+ const wy = sy + state.camY;
+ const gx = Math.floor(wx / state.TILE);
+ const gy = Math.floor(wy / state.TILE);
+ const b = getBlock2(gx, gy);
+ if (state.player.sleeping && b && b.t === "bed") {
+ state.player.sleeping = false;
return;
}
-
- console.log('Сохранение не найдено');
- resolve(null);
- });
- }
-
- // Миграция с версии 1 на версию 2
- function migrateV1toV2(saveData){
- console.log('Миграция сохранения с версии 1 на версию 2...');
-
- // Сохраняем seed из текущей игры (так как v1 его не хранил)
- saveData.worldSeed = worldSeed;
-
- // Инициализируем массивы изменений
- saveData.placedBlocks = [];
- saveData.removedBlocks = [];
-
- // Для v1 нам нужно сравнить сгенерированные блоки с тем, что должно быть по seed
- // Это сложно сделать без полной перегенерации, поэтому для v1 сохраняем только seed
- // и при загрузке просто перегенерируем мир
-
- // Удаляем старые данные
- delete saveData.generatedBlocks;
-
- saveData.version = 2;
- console.log('Миграция завершена');
- }
-
- async function applySave(saveData){
- if(!saveData) return;
-
- console.log('=== applySave START ===');
- console.log('player HP before applySave:', player.hp);
- console.log('saveData.player.hp:', saveData.player?.hp);
-
- // Миграция версий
- if(saveData.version === 1){
- migrateV1toV2(saveData);
- }
-
- // Восстанавливаем seed
- if(saveData.worldSeed !== undefined){
- worldSeed = saveData.worldSeed;
- }
-
- // Восстанавливаем игрока
- if(saveData.player){
- player.x = saveData.player.x;
- player.y = saveData.player.y;
- player.hunger = saveData.player.hunger;
- player.o2 = saveData.player.o2;
-
- // Обновляем spawnPoint на позицию из сохранения
- spawnPoint.x = player.x;
- spawnPoint.y = player.y;
-
- // Проверяем HP из сохранения - если <= 0, устанавливаем 100
- const savedHP = saveData.player.hp;
- console.log('Saved HP from file:', savedHP);
- if(savedHP <= 0){
- console.log('WARNING: Saved HP is <= 0, setting to 100!');
- player.hp = 100;
- } else {
- player.hp = savedHP;
+ if (state.player.sleeping) return;
+ if (b && b.t === "furnace" && mode() === "mine") {
+ openFurnaceUI(gx, gy);
+ return;
}
- console.log('player HP after restore:', player.hp);
- console.log('spawnPoint обновлён из сохранения: x=', spawnPoint.x, 'y=', spawnPoint.y);
- } else {
- console.log('No player data in save, setting default HP: 100');
- player.hp = 100;
- }
-
- console.log('=== applySave END ===');
-
- // Восстанавливаем инвентарь
- if(saveData.inventory){
- for(const key in saveData.inventory){
- inv[key] = saveData.inventory[key];
- }
- }
-
- // Восстанавливаем время
- if(saveData.time !== undefined){
- worldTime = saveData.time;
- }
-
- // Восстанавливаем день/ночь
- if(saveData.isNight !== undefined){
- isNightTime = saveData.isNight;
- }
-
- // Перегенерируем мир по seed
- regenerateVisibleChunks();
-
- // Применяем изменения (только для v2)
- if(saveData.version === 2){
- // Применяем блоки, установленные игроком
- for(const block of saveData.placedBlocks){
- setBlock(block.gx, block.gy, block.t, true);
- }
-
- // Применяем удалённые блоки
- for(const block of saveData.removedBlocks){
- removeBlock(block.gx, block.gy);
- }
-
- // Восстанавливаем массивы изменений
- placedBlocks = saveData.placedBlocks || [];
- removedBlocks = saveData.removedBlocks || [];
- }
-
- rebuildHotbar();
- console.log('Игра загружена');
- }
-
- // Камера (двухосевая)
- let camX=0, camY=0;
-
- // День/ночь
- let worldTime=0;
- const DAY_LEN=360; // замедлил смену дня/ночи в 3 раза
-
- // Облака
- const clouds = Array.from({length:10}, ()=>({
- x: Math.random()*2000,
- y: -200 - Math.random()*260, // выше экрана, потому что мир по Y теперь двигается
- w: 80+Math.random()*120,
- s: 12+Math.random()*20
- }));
-
- // Дождь
- let isRaining = false;
- let rainIntensity = 0; // 0..1
- let weatherTimer = 0;
- let weatherChangeInterval = 60 + Math.random() * 120; // смена погоды 60-180с
- const raindrops = [];
- const MAX_RAINDROPS = 200;
-
- function updateWeather(dt) {
- weatherTimer += dt;
- if (weatherTimer >= weatherChangeInterval) {
- weatherTimer = 0;
- weatherChangeInterval = 60 + Math.random() * 120;
- // Выбираем новую погоду: 40% дождь днём, 25% дождь ночью, остальное ясно
- const nightChance = isNight() ? 0.25 : 0.40;
- isRaining = Math.random() < nightChance;
- }
- // Плавная интерполяция интенсивности
- const target = isRaining ? (0.4 + Math.random() * 0.01) : 0;
- rainIntensity += (target - rainIntensity) * dt * 0.5;
- if (rainIntensity < 0.01) rainIntensity = 0;
- }
-
- function updateRain(dt) {
- if (!isRaining || rainIntensity < 0.01) {
- raindrops.length = 0;
- return;
- }
- // Спавн капель
- const spawnRate = Math.floor(rainIntensity * 60 * dt); // ~60 капель/сек при интенсити=1
- for (let i = 0; i < spawnRate && raindrops.length < MAX_RAINDROPS; i++) {
- raindrops.push({
- x: camX + Math.random() * W,
- y: camY - 20,
- vy: 400 + Math.random() * 200,
- len: 8 + Math.random() * 12
- });
- }
- // Обновление
- for (let i = raindrops.length - 1; i >= 0; i--) {
- const d = raindrops[i];
- d.y += d.vy * dt;
- d.x -= 30 * dt; // лёгкий ветер
- if (d.y > camY + H + 20) {
- raindrops.splice(i, 1);
- }
- }
- }
-
- function drawRain() {
- if (raindrops.length === 0) return;
- ctx.save();
- ctx.strokeStyle = 'rgba(174,194,224,0.5)';
- ctx.lineWidth = 1.5;
- ctx.beginPath();
- for (const d of raindrops) {
- ctx.moveTo(d.x, d.y);
- ctx.lineTo(d.x - 3, d.y + d.len);
- }
- ctx.stroke();
- ctx.restore();
- }
-
- // Частицы (взрыв)
- const parts = [];
- function spawnExplosion(x,y, power){
- const n = Math.floor(16 + power*10);
- for(let i=0;i 100){
- playSound('splash');
- }
- }
-
- function resolveY(e){
- // Всегда пересчитываем grounded (не держим "липким")
- e.grounded = false;
-
- const x1 = e.x + 2;
- const x2 = e.x + e.w - 2;
-
- // Проверяем, находится ли игрок на лестнице (по центру)
- const cx = e.x + e.w/2;
- const cy = e.y + e.h/2;
- const gx = Math.floor(cx / TILE);
- const gy = Math.floor(cy / TILE);
- const b = getBlock(gx, gy);
- const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable;
-
- // Если на лестнице - можно двигаться вверх/вниз
- if(onLadder){
- e.grounded = true;
-
- // Если нажимаем прыжок на лестнице - поднимаемся
- if(inp.j){
- e.vy = -200;
- }
- // Если нажимаем вниз - спускаемся
- else if(inp.s){
- e.vy = 100;
- }
- // Иначе - остаёмся на месте (нет гравитации)
- else {
- e.vy = 0;
- }
- return;
- }
-
- // Проверяем, можно ли запрыгнуть на лестницу рядом (слева или справа)
- const leftGX = Math.floor((e.x - 4) / TILE);
- const rightGX = Math.floor((e.x + e.w + 4) / TILE);
- const playerGY = Math.floor((e.y + e.h/2) / TILE);
-
- const leftBlock = getBlock(leftGX, playerGY);
- const rightBlock = getBlock(rightGX, playerGY);
- const leftLadder = leftBlock && BLOCKS[leftBlock.t] && BLOCKS[leftBlock.t].climbable;
- const rightLadder = rightBlock && BLOCKS[rightBlock.t] && BLOCKS[rightBlock.t].climbable;
-
- // Если рядом есть лестница и игрок прыгает - притягиваем к ней
- if((leftLadder || rightLadder) && inp.j && e.vy < 0){
- // Перемещаем игрока к лестнице
- if(leftLadder && e.x > leftGX * TILE + TILE/2){
- e.x = leftGX * TILE + TILE/2 - e.w/2;
- } else if(rightLadder && e.x < rightGX * TILE + TILE/2){
- e.x = rightGX * TILE + TILE/2 - e.w/2;
- }
- e.grounded = true;
- e.vy = -150; // меньший прыжок при запрыгивании на лестницу
- return;
- }
-
- // 1) Если движемся вниз ИЛИ стоим (vy >= 0) — проверяем опору под ногами
- // Берём точку на 1px ниже стопы, чтобы не зависеть от ровного попадания в границу тайла.
- if(e.vy >= 0){
- const probeY = e.y + e.h + 1;
- const gy = Math.floor(probeY / TILE);
- const gxA = Math.floor(x1 / TILE);
- const gxB = Math.floor(x2 / TILE);
-
- if(isSolid(gxA, gy) || isSolid(gxB, gy)){
- e.y = gy * TILE - e.h; // прижимаем к полу
- e.vy = 0;
- e.grounded = true;
-
- // урон от падения — только игроку и только не в воде
- if(e === player && !player.inWater){
- const fallTiles = (e.y - e.fallStartY) / TILE;
- if(fallTiles > 6) {
- const damage = calculateDamage((fallTiles - 6) * 10);
- player.hp -= damage;
+ if (mode() === "mine") {
+ if (state.isMultiplayer) {
+ for (const [id, sm] of state.serverMobs) {
+ if (sm.dead) continue;
+ if (wx >= sm.x && wx <= sm.x + sm.w && wy >= sm.y && wy <= sm.y + sm.h) {
+ let dmg = 1;
+ const swordTypes = ["iron_sword", "stone_sword", "wood_sword"];
+ for (const st of swordTypes) {
+ if (state.inv[st] > 0) {
+ dmg = TOOLS[st].damage || 3;
+ useTool(st);
+ break;
+ }
+ }
+ state.socket.emit("mob_hurt", { id: sm.id, dmg });
+ playSound("attack");
+ return;
+ }
}
}
- if(e === player) e.fallStartY = e.y;
- }
- }
-
- // 2) Проверка на возможность запрыгнуть на блок, если игрок рядом с ним
- if(e.vy < 0 && e === player){
- const gy = Math.floor(e.y / TILE);
- const gxA = Math.floor(x1 / TILE);
- const gxB = Math.floor(x2 / TILE);
-
- // Проверяем, есть ли блок рядом с игроком
- if((isSolid(gxA, gy) || isSolid(gxB, gy)) && !isSolid(gxA, gy - 1) && !isSolid(gxB, gy - 1)){
- e.y = (gy + 1) * TILE;
- e.vy = 0;
- e.grounded = true;
- if(e === player) e.fallStartY = e.y;
- console.log("Jumped onto block!");
- }
- }
-
- // 2) Если движемся вверх — проверяем потолок
- if(e.vy < 0){
- const gy = Math.floor(e.y / TILE);
- const gxA = Math.floor(x1 / TILE);
- const gxB = Math.floor(x2 / TILE);
- if(isSolid(gxA, gy) || isSolid(gxB, gy)){
- e.y = (gy + 1) * TILE;
- e.vy = 0;
- }
- }
- }
-
- function resolveX(e){
- const y1 = e.y + 2;
- const y2 = e.y + e.h - 2;
-
- // Проверяем, находимся ли мы на лестнице
- const cx = e.x + e.w/2;
- const cy = e.y + e.h/2;
- const gx = Math.floor(cx / TILE);
- const gy = Math.floor(cy / TILE);
- const b = getBlock(gx, gy);
- const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable;
-
- if(e.vx > 0){
- const gx = Math.floor((e.x + e.w)/TILE);
- const gyA = Math.floor(y1/TILE);
- const gyB = Math.floor(y2/TILE);
- const solidA = isSolid(gx, gyA);
- const solidB = isSolid(gx, gyB);
-
- if(solidA || solidB){
- e.x = gx*TILE - e.w;
- e.vx = 0;
- }
- } else if(e.vx < 0){
- const gx = Math.floor(e.x/TILE);
- const gyA = Math.floor(y1/TILE);
- const gyB = Math.floor(y2/TILE);
- const solidA = isSolid(gx, gyA);
- const solidB = isSolid(gx, gyB);
-
- if(solidA || solidB){
- e.x = (gx+1)*TILE;
- e.vx = 0;
- }
- }
- }
-
- // TNT логика: цепь + усиление
- const activeTNT = new Set(); // хранит key
- function activateTNT(b, fuse=3.2){
- if(b.dead) return;
- if(b.active) return;
- b.active=true;
- b.fuse=fuse;
- activeTNT.add(k(b.gx,b.gy));
- }
-
- function explodeAt(gx,gy){
- const center = getBlock(gx,gy);
- if(!center) return;
-
- // усиление: считаем сколько TNT рядом (в радиусе 2) и активируем их «почти сразу»
- let bonus = 0;
- for(let x=gx-2; x<=gx+2; x++){
- for(let y=gy-2; y<=gy+2; y++){
- const b = getBlock(x,y);
- if(b && !b.dead && b.t==='tnt' && !(x===gx && y===gy)){
- bonus += 0.8;
- activateTNT(b, 0.12); // цепь
- }
- }
- }
-
- const power = 1 + bonus; // условная мощность
- const radius = 3.2 + bonus*0.7; // радиус разрушения в тайлах
- const dmgR = 150 + bonus*60; // радиус урона в пикселях
-
- removeBlock(gx,gy);
- activeTNT.delete(k(gx,gy));
- playSound('explode1'); // Звук взрыва
- spawnExplosion(gx*TILE + TILE/2, gy*TILE + TILE/2, power);
-
- for(let x=Math.floor(gx-radius); x<=Math.ceil(gx+radius); x++){
- for(let y=Math.floor(gy-radius); y<=Math.ceil(gy+radius); y++){
- const d = Math.hypot(x-gx, y-gy);
- if(d > radius) continue;
- const b = getBlock(x,y);
- if(!b || b.dead) continue;
- if(BLOCKS[b.t].fluid) continue;
- if(BLOCKS[b.t].unbreakable) continue;
- if(b.t==='tnt') { activateTNT(b, 0.12); continue; }
- removeBlock(x,y);
- if(inv[b.t] !== undefined && Math.random()<0.20) inv[b.t]++; // немного дропа
- }
- }
- rebuildHotbar();
-
- // урон
- const hurt = (e)=>{
- const dx = (e.x+e.w/2) - (gx*TILE+TILE/2);
- const dy = (e.y+e.h/2) - (gy*TILE+TILE/2);
- const dist = Math.hypot(dx,dy);
- if(dist < dmgR){
- const dmg = (dmgR - dist) * 0.06 * power;
- if(e === player) {
- const actualDamage = calculateDamage(dmg);
- player.hp -= actualDamage;
- } else {
- e.hp -= dmg;
- }
- e.vx += (dx/dist || 0) * 600;
- e.vy -= 320;
- }
- };
- hurt(player);
- mobs.forEach(hurt);
- }
-
- // Взаимодействие мышь/тап
- const mouse = { x:null, y:null };
- canvas.addEventListener('pointermove', (e)=>{
- const r = canvas.getBoundingClientRect();
- mouse.x = e.clientX - r.left;
- mouse.y = e.clientY - r.top;
- });
-
- canvas.addEventListener('pointerdown', (e)=>{
- if(craftOpen) return;
- if(player.hp<=0) return;
-
- const r = canvas.getBoundingClientRect();
- const sx = e.clientX - r.left;
- const sy = e.clientY - r.top;
-
- const wx = sx + camX;
- const wy = sy + camY;
-
- const gx = Math.floor(wx / TILE);
- const gy = Math.floor(wy / TILE);
-
- // Пробуждение: клик по любой кровати когда спишь
- const b = getBlock(gx,gy);
- if(player.sleeping && b && b.t==='bed'){
- player.sleeping = false;
- return;
- }
-
- if(player.sleeping) return; // Нельзя взаимодействовать во время сна
-
- // Клик по печи — открываем панель обжига
- if(b && b.t === 'furnace' && mode() === 'mine'){
- openFurnaceUI(gx, gy);
- return;
- }
-
- // клик по мобу (в режиме mine)
- if(mode()==='mine'){
- // Check server mobs first (multiplayer)
- if(isMultiplayer){
- for (const [id, sm] of serverMobs) {
- if(sm.dead) continue;
- if(wx>=sm.x && wx<=sm.x+sm.w && wy>=sm.y && wy<=sm.y+sm.h){
+ for (let i = state.mobs.length - 1; i >= 0; i--) {
+ const m = state.mobs[i];
+ if (wx >= m.x && wx <= m.x + m.w && wy >= m.y && wy <= m.y + m.h) {
let dmg = 1;
- const swordTypes = ['iron_sword','stone_sword','wood_sword'];
+ const swordTypes = ["iron_sword", "stone_sword", "wood_sword"];
for (const st of swordTypes) {
- if (inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; }
+ if (state.inv[st] > 0) {
+ dmg = TOOLS[st].damage || 3;
+ useTool(st);
+ break;
+ }
+ }
+ m.hp -= dmg;
+ m.vx += (m.x - state.player.x) * 2;
+ m.vy -= 200;
+ playSound("attack");
+ if (m.hp <= 0) {
+ if (m.kind === "chicken") playSound("hurt_chicken");
+ state.inv.meat += m.kind === "chicken" ? 1 : 2;
+ if (m.kind === "skeleton") {
+ state.inv.arrow += 2 + Math.floor(Math.random() * 3);
+ if (Math.random() < 0.15) state.inv.bow = (state.inv.bow || 0) + 1;
+ }
+ state.mobs.splice(i, 1);
+ rebuildHotbar();
}
- socket.emit('mob_hurt', { id: sm.id, dmg });
- playSound('attack');
return;
}
}
}
- // Local mobs (singleplayer or if not hit server mob)
- for(let i=mobs.length-1;i>=0;i--){
- const m = mobs[i];
- if(wx>=m.x && wx<=m.x+m.w && wy>=m.y && wy<=m.y+m.h){
- let dmg = 1;
- const swordTypes = ['iron_sword','stone_sword','wood_sword'];
- for (const st of swordTypes) {
- if (inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; }
- }
- m.hp -= dmg;
- m.vx += (m.x - player.x) * 2;
- m.vy -= 200;
- playSound('attack');
- if(m.hp<=0){
- if(m.kind === 'chicken') playSound('hurt_chicken');
- inv.meat += (m.kind==='chicken' ? 1 : 2);
- if(m.kind === 'skeleton'){
- inv.arrow += 2 + Math.floor(Math.random()*3);
- if(Math.random() < 0.15) inv.bow = (inv.bow||0) + 1;
- }
- mobs.splice(i,1);
- rebuildHotbar();
- }
- return;
- }
- }
- }
-
- // Лук — стреляем стрелой
- if(selected === 'bow' && inv.bow > 0 && inv.arrow > 0){
- const aimX = wx - player.x - player.w/2;
- const aimY = wy - player.y - player.h/2;
- const angle = Math.atan2(aimY, aimX);
- projectiles.push({
- x: player.x + player.w/2,
- y: player.y + player.h/3,
- vx: Math.cos(angle) * 550,
- vy: Math.sin(angle) * 550,
- dmg: 10,
- owner: 'player',
- life: 4
- });
- inv.arrow--;
- useTool('bow');
- playSound('hit1');
- rebuildHotbar();
- return;
- }
-
- // еда (предмет)
- if(ITEMS[selected] && inv[selected]>0){
- const it = ITEMS[selected];
- if(player.hp < 100 || player.hunger < 100){
- playSound('eat1'); // Звук употребления еды
- player.hunger = Math.min(100, player.hunger + it.food);
- player.hp = Math.min(100, player.hp + 15);
- inv[selected]--;
- rebuildHotbar();
- }
- return;
- }
-
- // жарка на костре: выбран meat + клик по campfire
- if(b && b.t==='campfire' && selected==='meat' && inv.meat>0){
- playSound('fire'); // Звук при жарке на костре
- inv.meat--; inv.cooked++;
- rebuildHotbar();
- return;
- }
-
- // Сон на кровати: клик по bed
- if(b && b.t==='bed' && isNight()){
- player.sleeping = true;
- saveGame(); // Сохраняем при отходе ко сну
- return;
- }
-
- if(mode()==='mine'){
- if(!b) return;
- if(BLOCKS[b.t].fluid || BLOCKS[b.t].decor) return;
-
- if(b.t==='tnt'){ activateTNT(b, 3.2); return; } // не взрывается сразу
-
- const removed = removeBlock(gx,gy);
- if(removed){
- inv[removed.t] = (inv[removed.t]||0) + 1;
-
- // Тратим прочность кирки (если есть в инвентаре)
- const pickTypes = ['iron_pickaxe','stone_pickaxe','wood_pickaxe'];
- for (const pt of pickTypes) {
- if (inv[pt] > 0) {
- const broke = useTool(pt);
- if (broke) playSound('cloth1'); // звук поломки
- break;
- }
- }
-
- // Отправляем изменение блока на сервер
- sendBlockChange(gx, gy, removed.t, 'remove');
-
- // Звуки при добыче блоков
- if(removed.t === 'glass') playSound('glass1');
- else if(removed.t === 'sand') playSound('sand1');
- else if(removed.t === 'snow') playSound('snow1');
- else if(removed.t === 'stone' || removed.t.endsWith('_ore')) playSound('stone1');
- else if(removed.t === 'wood') playSound('wood1');
- else playSound('cloth1');
-
- rebuildHotbar();
- }
- return;
- }
-
- if(mode()==='build'){
- if(inv[selected] <= 0) return;
- if(!BLOCKS[selected]) return;
- if(b) return; // занято
-
- // Проверяем, ставим ли лодку
- if(selected === 'boat'){
- // Лодку можно ставить только на воду
- const waterBelow = getBlock(gx, gy+1);
- if(!waterBelow || waterBelow.t !== 'water'){
- return;
- }
-
- // Создаём лодку
- boat.x = gx * TILE;
- boat.y = gy * TILE;
- boat.vx = 0;
- boat.vy = 0;
- boat.active = true;
- boat.inWater = true;
-
- // Сажаем игрока в лодку
- player.inBoat = true;
- player.x = boat.x;
- player.y = boat.y;
- player.vx = 0;
- player.vy = 0;
-
- playSound('splash');
- inv[selected]--;
+ if (state.selected === "bow" && state.inv.bow > 0 && state.inv.arrow > 0) {
+ const aimX = wx - state.player.x - state.player.w / 2;
+ const aimY = wy - state.player.y - state.player.h / 2;
+ const angle = Math.atan2(aimY, aimX);
+ state.projectiles.push({
+ x: state.player.x + state.player.w / 2,
+ y: state.player.y + state.player.h / 3,
+ vx: Math.cos(angle) * 550,
+ vy: Math.sin(angle) * 550,
+ dmg: 10,
+ owner: "player",
+ life: 4
+ });
+ state.inv.arrow--;
+ useTool("bow");
+ playSound("hit1");
rebuildHotbar();
return;
}
-
- // запрет ставить в игрока
- const bx = gx*TILE, by = gy*TILE;
- const overlap = !(bx >= player.x+player.w || bx+TILE <= player.x || by >= player.y+player.h || by+TILE <= player.y);
- if(overlap) return;
-
- setBlock(gx,gy,selected, true); // true = блок установлен игроком
- inv[selected]--;
-
- // Отправляем изменение блока на сервер
- sendBlockChange(gx, gy, selected, 'set');
-
- // Звук при строительстве
- if(selected === 'stone' || selected === 'brick') playSound('stone_build');
- else if(selected === 'wood' || selected === 'planks') playSound('wood_build');
- else if(selected === 'glass') playSound('glass1');
- else if(selected === 'sand') playSound('sand1');
- else if(selected === 'snow') playSound('snow1');
- else if(selected === 'dirt' || selected === 'grass') playSound('cloth1');
-
- rebuildHotbar();
- return;
- }
- });
-
- // Генерация (по X, на всю глубину до bedrock)
- const generated = new Set(); // gx already generated
- function surfaceGyAt(gx){
- // базовая поверхность выше уровня воды с вариациями + "горы"
- // Используем seed для детерминированной генерации
- // Увеличили амплитуду и добавили больше частот для разнообразия
- const n1 = Math.sin(gx*0.025 + worldSeed*0.001)*8; // крупные горы
- const n2 = Math.sin(gx*0.012 + worldSeed*0.002)*12; // средние горы
- const n3 = Math.sin(gx*0.006 + worldSeed*0.003)*6; // мелкие холмы
- const n4 = Math.sin(gx*0.045 + worldSeed*0.004)*4; // детали
- const n5 = Math.cos(gx*0.018 + worldSeed*0.005)*5; // дополнительные вариации
- const h = Math.floor(SEA_GY - 8 + n1 + n2 + n3 + n4 + n5); // чем меньше gy - тем выше
- return h;
- }
-
- function genColumn(gx){
- if(generated.has(gx)) return;
- generated.add(gx);
-
- const sgy = surfaceGyAt(gx);
-
- // вода (если поверхность ниже уровня моря => sgy > SEA_GY)
- if(sgy > SEA_GY){
- for(let gy=SEA_GY; gy SEA_GY && gy === sgy+1 && seededRandom(gx, gy) < 0.25) t='clay';
- if(gy > sgy+6 && seededRandom(gx, gy) < 0.07) t='gravel';
-
- // руды: чем глубже, тем интереснее
- const depth = gy - sgy;
- const r = seededRandom(gx, gy);
- if(t==='stone'){
- if(r < 0.06) t='coal';
- else if(r < 0.10) t='copper_ore';
- else if(r < 0.13) t='iron_ore';
- else if(depth > 40 && r < 0.145) t='gold_ore';
- else if(depth > 70 && r < 0.152) t='diamond_ore';
- }
-
- setBlock(gx,gy,t);
- }
-
- // Деревья и цветы (только на траве, и не в воде)
- const top = getBlock(gx, sgy);
- if(top && top.t==='grass'){
- if(seededRandom(gx, sgy-1) < 0.10){
- setBlock(gx, sgy-1,'flower');
- }
- if(seededRandom(gx, sgy-2) < 0.12){
- // простое дерево
- setBlock(gx, sgy-1, 'wood');
- setBlock(gx, sgy-2, 'wood');
- setBlock(gx, sgy-3, 'leaves');
- setBlock(gx-1, sgy-3,'leaves');
- setBlock(gx+1, sgy-3,'leaves');
- }
- }
-
- // Применяем серверные оверрайды для этой колонны
- const colPrefix = gx + ',';
- for (const [key, ov] of serverOverrides) {
- if (!key.startsWith(colPrefix)) continue;
- if (ov.op === 'remove') {
- const b = grid.get(key);
- if (b) { grid.delete(key); b.dead = true; }
- } else if (ov.op === 'set') {
- if (!grid.has(key)) {
- const gy = parseInt(key.split(',')[1]);
- const nb = { gx, gy, t: ov.t, dead: false, active: false, fuse: 0 };
- grid.set(key, nb);
- blocks.push(nb);
+ if (ITEMS[state.selected] && state.inv[state.selected] > 0) {
+ const it = ITEMS[state.selected];
+ if (state.player.hp < 100 || state.player.hunger < 100) {
+ playSound("eat1");
+ state.player.hunger = Math.min(100, state.player.hunger + it.food);
+ state.player.hp = Math.min(100, state.player.hp + 15);
+ state.inv[state.selected]--;
+ rebuildHotbar();
}
+ return;
}
- }
+ if (b && b.t === "campfire" && state.selected === "meat" && state.inv.meat > 0) {
+ playSound("fire");
+ state.inv.meat--;
+ state.inv.cooked++;
+ rebuildHotbar();
+ return;
+ }
+ if (b && b.t === "bed" && isNight()) {
+ state.player.sleeping = true;
+ saveGame();
+ return;
+ }
+ if (mode() === "mine") {
+ if (!b) return;
+ if (BLOCKS[b.t].fluid || BLOCKS[b.t].decor) return;
+ if (b.t === "tnt") {
+ activateTNT(b, 3.2);
+ return;
+ }
+ const removed = removeBlock(gx, gy);
+ if (removed) {
+ state.inv[removed.t] = (state.inv[removed.t] || 0) + 1;
+ const pickTypes = ["iron_pickaxe", "stone_pickaxe", "wood_pickaxe"];
+ for (const pt of pickTypes) {
+ if (state.inv[pt] > 0) {
+ const broke = useTool(pt);
+ if (broke) playSound("cloth1");
+ break;
+ }
+ }
+ sendBlockChange(gx, gy, removed.t, "remove");
+ if (removed.t === "glass") playSound("glass1");
+ else if (removed.t === "sand") playSound("sand1");
+ else if (removed.t === "snow") playSound("snow1");
+ else if (removed.t === "stone" || removed.t.endsWith("_ore")) playSound("stone1");
+ else if (removed.t === "wood") playSound("wood1");
+ else playSound("cloth1");
+ rebuildHotbar();
+ }
+ return;
+ }
+ if (mode() === "build") {
+ if (state.inv[state.selected] <= 0) return;
+ if (!BLOCKS[state.selected]) return;
+ if (b) return;
+ if (state.selected === "boat") {
+ const waterBelow = getBlock2(gx, gy + 1);
+ if (!waterBelow || waterBelow.t !== "water") {
+ return;
+ }
+ state.boat.x = gx * state.TILE;
+ state.boat.y = gy * state.TILE;
+ state.boat.vx = 0;
+ state.boat.vy = 0;
+ state.boat.active = true;
+ state.boat.inWater = true;
+ state.player.inBoat = true;
+ state.player.x = state.boat.x;
+ state.player.y = state.boat.y;
+ state.player.vx = 0;
+ state.player.vy = 0;
+ playSound("splash");
+ state.inv[state.selected]--;
+ rebuildHotbar();
+ return;
+ }
+ const TILE2 = state.TILE;
+ const bx = gx * TILE2, by = gy * TILE2;
+ const overlap = !(bx >= state.player.x + state.player.w || bx + TILE2 <= state.player.x || by >= state.player.y + state.player.h || by + TILE2 <= state.player.y);
+ if (overlap) return;
+ setBlock(gx, gy, state.selected, true);
+ state.inv[state.selected]--;
+ sendBlockChange(gx, gy, state.selected, "set");
+ if (state.selected === "stone" || state.selected === "brick") playSound("stone_build");
+ else if (state.selected === "wood" || state.selected === "planks") playSound("wood_build");
+ else if (state.selected === "glass") playSound("glass1");
+ else if (state.selected === "sand") playSound("sand1");
+ else if (state.selected === "snow") playSound("snow1");
+ else if (state.selected === "dirt" || state.selected === "grass") playSound("cloth1");
+ rebuildHotbar();
+ return;
+ }
+ });
}
-
- // Перегенерация видимых чанков (используется при загрузке сохранения)
- function regenerateVisibleChunks(){
- const gx0 = Math.floor(camX/TILE);
- for(let gx=gx0-GEN_MARGIN_X; gx<=gx0+GEN_MARGIN_X; gx++){
- // Принудительно перегенерируем колонну
- generated.delete(gx);
- genColumn(gx);
- }
- }
-
- function ensureGenAroundCamera(){
- const gx0 = Math.floor(camX/TILE);
- for(let gx=gx0-GEN_MARGIN_X; gx<=gx0+GEN_MARGIN_X; gx++){
- genColumn(gx);
- }
- }
-
- // Лут с дерева/листвы: дерево -> wood; листья -> leaves
- // (уже в mine добавляется inv[type] автоматически)
-
- // Рисование костра: огонь поверх текстуры
- function drawFire(wx,wy,now){
+
+ // src/render/draw-fire.js
+ function drawFire(ctx2, wx, wy, now) {
const baseX = wx;
const baseY = wy;
- const flick = 6 + (Math.sin(now/90)+1)*4;
- ctx.fillStyle = 'rgba(255,140,0,0.85)';
- ctx.beginPath();
- ctx.moveTo(baseX+10, baseY+30);
- ctx.lineTo(baseX+20, baseY+30-flick);
- ctx.lineTo(baseX+30, baseY+30);
- ctx.fill();
-
- ctx.fillStyle = 'rgba(255,230,150,0.75)';
- ctx.beginPath();
- ctx.moveTo(baseX+14, baseY+30);
- ctx.lineTo(baseX+20, baseY+30-(flick*0.7));
- ctx.lineTo(baseX+26, baseY+30);
- ctx.fill();
+ const flick = 6 + (Math.sin(now / 90) + 1) * 4;
+ ctx2.fillStyle = "rgba(255,140,0,0.85)";
+ ctx2.beginPath();
+ ctx2.moveTo(baseX + 10, baseY + 30);
+ ctx2.lineTo(baseX + 20, baseY + 30 - flick);
+ ctx2.lineTo(baseX + 30, baseY + 30);
+ ctx2.fill();
+ ctx2.fillStyle = "rgba(255,230,150,0.75)";
+ ctx2.beginPath();
+ ctx2.moveTo(baseX + 14, baseY + 30);
+ ctx2.lineTo(baseX + 20, baseY + 30 - flick * 0.7);
+ ctx2.lineTo(baseX + 26, baseY + 30);
+ ctx2.fill();
}
-
- // Моб AI
- function mobAI(m, dt){
- updateWaterFlag(m);
-
- if(m.kind==='zombie'){
- // активность ночью
- const night = isNight();
- if(!night){ m.hp -= 30*dt; m.vx *= 0.5; return; }
- const dir = Math.sign((player.x) - m.x);
- m.vx = dir * m.speed;
- if(m.inWater && Math.random()<0.06) m.vy = -260;
- // атака
- if(Math.abs((m.x+ m.w/2) - (player.x+player.w/2)) < 28 &&
- Math.abs((m.y+ m.h/2) - (player.y+player.h/2)) < 40 &&
- player.invuln <= 0){
- const damage = calculateDamage(15);
- player.hp -= damage;
- player.invuln = 0.8;
- player.vx += dir*420;
- player.vy -= 260;
- playSound('hit1'); // Звук при атаке зомби
- }
- } else if(m.kind==='creeper'){
- // активность ночью
- const night = isNight();
- if(!night){ m.hp -= 30*dt; m.vx *= 0.5; return; }
- const dir = Math.sign((player.x) - m.x);
- const dist = Math.hypot((player.x+player.w/2) - (m.x+m.w/2), (player.y+player.h/2) - (m.y+m.h/2));
-
- // Движение к игроку
- m.vx = dir * m.speed;
- if(m.inWater && Math.random()<0.06) m.vy = -260;
-
- // Взрыв если близко к игроку
- if(dist < 60){
- m.fuse -= dt;
- if(m.fuse <= 0){
- explodeAt(Math.floor((m.x+m.w/2)/TILE), Math.floor((m.y+m.h/2)/TILE));
- m.hp = 0;
- }
- } else {
- // Поджигаем если очень близко
- if(dist < 40){
- m.fuse = 0.5; // Быстрый взрыв
- }
- }
- } else if(m.kind==='skeleton'){
- // активность ночью
- const night = isNight();
- if(!night){ m.hp -= 30*dt; m.vx *= 0.5; return; }
- const dir = Math.sign((player.x) - m.x);
- const dist = Math.hypot((player.x+player.w/2) - (m.x+m.w/2), (player.y+player.h/2) - (m.y+m.h/2));
-
- // Движение к игроку
- m.vx = dir * m.speed;
- if(m.inWater && Math.random()<0.06) m.vy = -260;
-
- // Стрельба стрелами
- m.shootCooldown -= dt;
- if(dist < 300 && m.shootCooldown <= 0){
- m.shootCooldown = 2.0;
- const dx = (player.x+player.w/2) - (m.x+m.w/2);
- const dy = (player.y+player.h/2) - (m.y+m.h/2);
- const angle = Math.atan2(dy, dx);
- const speed = 450;
- projectiles.push({
- x: m.x + m.w/2,
- y: m.y + m.h/3,
- vx: Math.cos(angle) * speed,
- vy: Math.sin(angle) * speed,
- dmg: 6,
- owner: 'mob',
- life: 3
- });
- }
- } else {
- // животные
- m.aiT -= dt;
- if(m.aiT <= 0){
- m.aiT = 1.8 + Math.random()*2.5;
- m.dir = Math.random()<0.5 ? -1 : 1;
- if(Math.random()<0.25) m.dir = 0;
- }
- m.vx = m.dir * (m.kind==='chicken' ? 55 : 40);
- if(m.inWater) m.vy = -120;
- }
-
- // физика моба
- const g = m.inWater ? GRAV_WATER : GRAV;
- m.vy += g*dt;
-
- m.y += m.vy*dt; m.grounded=false; resolveY(m);
- m.x += m.vx*dt; resolveX(m);
- }
-
- function isNight(){
- // Автоматический цикл: ночь когда worldTime > 0.5
- return worldTime > 0.5;
- }
-
- // Respawn
- document.getElementById('respawnBtn').onclick = async () => {
- playSound('click'); // Звук клика по кнопке
-
- console.log('=== RESPAWN CLICKED ===');
- console.log('isMultiplayer:', isMultiplayer);
- console.log('otherPlayers.size:', otherPlayers.size);
- console.log('player.hp before respawn:', player.hp);
-
- // В мультиплеере не загружаем сохранение, а возрождаемся в начальной точке
- if (isMultiplayer && otherPlayers.size > 0) {
- console.log('Мультиплеер режим - возрождение в начальной точке');
- player.hp = 100;
- player.hunger = 100;
- player.o2 = 100;
- player.vx = player.vy = 0;
- player.invuln = 0;
- player.x = spawnPoint.x;
- player.y = spawnPoint.y;
- player.fallStartY = player.y;
- console.log('Возрождение в начальной точке, HP:', player.hp);
- } else {
- console.log('Одиночный режим - загружаем последнее сохранение');
- // Одиночный режим - загружаем последнее сохранение
- const loadedSave = await loadGame();
- if(loadedSave){
- await applySave(loadedSave);
- console.log('Загружено последнее сохранение после смерти, final HP:', player.hp);
- } else {
- // Если сохранения нет, возрождаемся в начальной точке
- player.hp = 100;
- player.hunger = 100;
- player.o2 = 100;
- player.vx = player.vy = 0;
- player.invuln = 0;
- player.x = spawnPoint.x;
- player.y = spawnPoint.y;
- player.fallStartY = player.y;
- console.log('Возрождение в начальной точке, HP:', player.hp);
- }
- }
-
- console.log('player.hp after respawn logic:', player.hp);
- console.log('Hiding death screen...');
- deathEl.style.display='none';
- console.log('=== RESPAWN END ===');
- };
-
- // Resize
- function resize(){
- W = gameEl.clientWidth;
- H = gameEl.clientHeight;
- canvas.width = W*dpr;
- canvas.height = H*dpr;
- lightC.width = W*dpr;
- lightC.height = H*dpr;
- ctx.setTransform(dpr,0,0,dpr,0,0);
- }
- window.addEventListener('resize', resize);
-
- // init
- resize();
- rebuildHotbar();
-
- // Инициализируем и загружаем сохранение
- initDB().then(async () => {
- // Пытаемся загрузить сохранённую игру
- const loadedSave = await loadGame();
- if(loadedSave){
- await applySave(loadedSave);
- console.log('Загружено сохранение, HP:', player.hp);
-
- // Проверяем HP после загрузки - если <= 0, возрождаемся
- if (player.hp <= 0) {
- console.log('WARNING: HP <= 0 после загрузки, возрождаемся');
- player.hp = 100;
- player.hunger = 100;
- player.o2 = 100;
- player.x = spawnPoint.x;
- player.y = spawnPoint.y;
- player.vx = player.vy = 0;
- player.invuln = 0;
- player.fallStartY = player.y;
- }
- } else {
- console.log('Сохранение не найдено, начинаем новую игру');
-
- // Инициализируем игрока для новой игры
- player.hp = 100;
- player.hunger = 100;
- player.o2 = 100;
- player.vx = player.vy = 0;
- player.invuln = 0;
-
- // старт — на поверхности (используем ту же логику что и в world_state)
- const startGX = 6;
- for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx);
- const surfaceY = surfaceGyAt(startGX);
- let safeGY = surfaceY - 1;
- const aboveBlock = getBlock(startGX, surfaceY - 1);
- if (aboveBlock && aboveBlock.t === 'water') {
- for (let gy = SEA_GY - 1; gy >= 0; gy--) {
- const b = getBlock(startGX, gy);
- if (!b || b.dead || b.t === 'air' || b.t === 'water') continue;
- safeGY = gy - 1;
- break;
- }
- }
- player.y = safeGY * TILE;
- player.x = startGX * TILE;
- player.fallStartY = player.y;
-
- // Обновляем spawnPoint, чтобы возрождение было на поверхности
- spawnPoint.x = player.x;
- spawnPoint.y = player.y;
-
- console.log('Новая игра: startGX=', startGX, 'surfaceY=', surfaceY, 'player.y=', player.y, 'player.hp=', player.hp);
- console.log('spawnPoint обновлён: x=', spawnPoint.x, 'y=', spawnPoint.y);
-
- // Генерируем карту вокруг стартовой позиции при инициализации
- for(let gx = startGX - 50; gx <= startGX + 50; gx++){
- genColumn(gx);
- }
- }
-
- // Автосейв при скрытии страницы (защита от потери прогресса)
- document.addEventListener('visibilitychange', () => {
- if(document.hidden){
- saveGame();
- }
- });
-
- // Автосейв перед закрытием страницы (защита от потери прогресса)
- window.addEventListener('beforeunload', () => {
- saveGame();
- });
- }).catch(err => {
- console.error('Ошибка инициализации:', err);
- // При ошибке начинаем новую игру
- const startGX = 6;
- genColumn(startGX);
- player.y = (surfaceGyAt(startGX)-1)*TILE;
- player.fallStartY = player.y;
-
- for(let gx = startGX - 50; gx <= startGX + 50; gx++){
- genColumn(gx);
- }
- });
-
- // main loop
- let last = performance.now();
- let prevJump = false;
- // При возврате на вкладку — сбрасываем last чтобы не было скачка dt
- document.addEventListener('visibilitychange', () => {
+
+ // src/game/loop.js
+ var last = performance.now();
+ var prevJump = false;
+ document.addEventListener("visibilitychange", () => {
if (!document.hidden) last = performance.now();
});
- function loop(now){
- const rawDt = Math.min(0.05, (now-last)/1000);
+ function loop(now) {
+ const rawDt = Math.min(0.05, (now - last) / 1e3);
last = now;
- // Sub-stepping: разбиваем физику на шаги ≤16ms чтобы не проваливаться сквозь блоки
const PHYSICS_STEP = 0.016;
const steps = Math.max(1, Math.ceil(rawDt / PHYSICS_STEP));
const dt = rawDt / steps;
-
- const jumpPressed = inp.j && !prevJump;
- prevJump = inp.j;
-
- // Ускорение времени во время сна
- if(player.sleeping && isNight()){
- worldTime += dt * 8 / DAY_LEN; // В 8 раз быстрее
- // Восстанавливаем здоровье во время сна
+ const player = state.player;
+ const inv = state.inv;
+ const inp2 = state.inp;
+ const clouds = state.clouds;
+ const mobs = state.mobs;
+ const projectiles = state.projectiles;
+ const parts2 = state.parts;
+ const boat = state.boat;
+ const jumpPressed = inp2.j && !prevJump;
+ prevJump = inp2.j;
+ if (player.sleeping && isNight()) {
+ state.worldTime += dt * 8 / state.DAY_LEN;
player.hp = Math.min(100, player.hp + dt * 20);
- // Автоматическое пробуждение когда наступает день
- if(!isNight()){
+ if (!isNight()) {
player.sleeping = false;
}
} else {
- worldTime += dt / DAY_LEN;
+ state.worldTime += dt / state.DAY_LEN;
}
- if(worldTime >= 1) worldTime -= 1;
-
- // камера следует за игроком по X/Y
- camX = Math.floor((player.x + player.w/2) - W/2);
- camY = Math.floor((player.y + player.h/2) - H/2);
-
+ if (state.worldTime >= 1) state.worldTime -= 1;
+ state.camX = Math.floor(player.x + player.w / 2 - state.W / 2);
+ state.camY = Math.floor(player.y + player.h / 2 - state.H / 2);
ensureGenAroundCamera();
-
- // clouds parallax
- for(const c of clouds){
+ for (const c of clouds) {
c.x -= c.s * dt;
- if(c.x + c.w < camX - 400) c.x = camX + W + Math.random()*700;
+ if (c.x + c.w < state.camX - 400) c.x = state.camX + state.W + Math.random() * 700;
}
-
- // player
updateWaterFlag(player);
-
- // кислород/утопление: тратим O2 только если голова под водой, иначе восстанавливаем [web:223]
- if(player.headInWater){
- player.o2 = Math.max(0, player.o2 - 6*dt); // замедлил в 3.7 раза
- if(player.o2 === 0){
- const damage = calculateDamage(4*dt);
+ if (player.headInWater) {
+ player.o2 = Math.max(0, player.o2 - 6 * dt);
+ if (player.o2 === 0) {
+ const damage = calculateDamage(4 * dt);
player.hp -= damage;
}
} else {
- player.o2 = Math.min(100, player.o2 + 10*dt); // замедлил восстановление в 4 раза
+ player.o2 = Math.min(100, player.o2 + 10 * dt);
}
-
- // голод убывает, но HP не отнимает (как просили)
- player.hunger = Math.max(0, player.hunger - dt*0.2); // замедлил в 4 раза
-
- // Игрок не может двигаться во время сна
- if(player.sleeping){
+ player.hunger = Math.max(0, player.hunger - dt * 0.2);
+ if (player.sleeping) {
player.vx = 0;
player.vy = 0;
} else {
- const dir = (inp.r?1:0) - (inp.l?1:0);
- if(dir) player.vx = dir*MOVE;
+ const dir = (inp2.r ? 1 : 0) - (inp2.l ? 1 : 0);
+ if (dir) player.vx = dir * state.MOVE;
else player.vx *= 0.82;
}
-
- // Звук шагов при движении по земле
- if(player.grounded && !player.inWater && Math.abs(player.vx) > 50){
- const stepInterval = 0.35; // Интервал между шагами в секундах
- if(now/1000 - player.lastStepTime > stepInterval){
- playSound('step');
- player.lastStepTime = now/1000;
+ if (player.grounded && !player.inWater && Math.abs(player.vx) > 50) {
+ const stepInterval = 0.35;
+ if (now / 1e3 - player.lastStepTime > stepInterval) {
+ playSound("step");
+ player.lastStepTime = now / 1e3;
}
}
-
- // прыжок/плавание (новая логика)
- if(player.inBoat){
- // Игрок в лодке - лодка следует за игроком
- const dir = (inp.r?1:0) - (inp.l?1:0);
- if(dir) boat.vx = dir * MOVE;
+ if (player.inBoat) {
+ const dir = (inp2.r ? 1 : 0) - (inp2.l ? 1 : 0);
+ if (dir) boat.vx = dir * state.MOVE;
else boat.vx *= 0.95;
-
- // Лодка плавает на воде
boat.vy = 0;
-
- // Игрок следует за лодкой (сидит внутри неё)
- player.x = boat.x + 2; // Игрок по центру лодки
- player.y = boat.y - 4; // Игрок выше лодки (сидит внутри)
+ player.x = boat.x + 2;
+ player.y = boat.y - 4;
player.vx = boat.vx;
player.vy = boat.vy;
player.grounded = true;
- player.inWater = false; // Игрок не в воде когда в лодке
-
- // Прыжок из лодки (высадка)
- if(jumpPressed){
- // Возвращаем лодку в инвентарь
+ player.inWater = false;
+ if (jumpPressed) {
inv.boat = (inv.boat || 0) + 1;
-
player.inBoat = false;
boat.active = false;
- player.y += TILE; // Прыгаем из лодки
- player.vy = -JUMP * 0.5;
- playSound('splash');
+ player.y += state.TILE;
+ player.vy = -state.JUMP * 0.5;
+ playSound("splash");
}
-
- } else if(player.inWater){
- // сопротивление в воде
- player.vx *= 0.90;
+ } else if (player.inWater) {
+ player.vx *= 0.9;
player.vy *= 0.92;
-
- // Если не нажимаем прыжок - тонем (гравитация в воде)
- if(!jumpPressed && !inp.j){
- // Применяем гравитацию в воде - игрок тонет
- player.vy += GRAV_WATER * dt;
+ if (!jumpPressed && !inp2.j) {
+ player.vy += state.GRAV_WATER * dt;
} else {
- // Если нажимаем прыжок - поднимаемся на поверхность
- if(jumpPressed){
- player.vy = Math.min(player.vy, -520); // рывок вверх
- } else if(inp.j){
- // если держим — мягкое всплытие
+ if (jumpPressed) {
+ player.vy = Math.min(player.vy, -520);
+ } else if (inp2.j) {
player.vy = Math.min(player.vy, -260);
}
}
-
} else {
- // обычный прыжок (только по нажатию)
- if(jumpPressed && player.grounded && !player.sleeping){
- player.vy = -JUMP;
+ if (jumpPressed && player.grounded && !player.sleeping) {
+ player.vy = -state.JUMP;
player.grounded = false;
player.fallStartY = player.y;
}
}
-
- // Гравитация применяется только вне воды и вне лодки
- if(!player.inWater && !player.inBoat){
- player.vy += GRAV*dt;
+ if (!player.inWater && !player.inBoat) {
+ player.vy += state.GRAV * dt;
}
-
- // Обновляем позицию лодки
- if(boat.active){
+ if (boat.active) {
boat.x += boat.vx * dt;
boat.y += boat.vy * dt;
-
- // Лодка не выходит за пределы воды
- const boatGX = Math.floor(boat.x / TILE);
- const boatGY = Math.floor(boat.y / TILE);
- const below = getBlock(boatGX, boatGY + 1);
-
- if(!below || below.t !== 'water'){
- // Если лодка вышла из воды - выкидываем игрока
+ const boatGX = Math.floor(boat.x / state.TILE);
+ const boatGY = Math.floor(boat.y / state.TILE);
+ const below = getBlock2(boatGX, boatGY + 1);
+ if (!below || below.t !== "water") {
inv.boat = (inv.boat || 0) + 1;
player.inBoat = false;
boat.active = false;
- player.y += TILE;
+ player.y += state.TILE;
player.vy = -200;
- playSound('splash');
+ playSound("splash");
}
}
-
- // Проверяем, не доплыл ли игрок из лодки
- if(player.inBoat && !boat.active){
+ if (player.inBoat && !boat.active) {
inv.boat = (inv.boat || 0) + 1;
player.inBoat = false;
- player.y += TILE;
+ player.y += state.TILE;
player.vy = -200;
- playSound('splash');
+ playSound("splash");
}
-
- // Sub-stepped physics: применяем движение мелкими шагами
for (let step = 0; step < steps; step++) {
- player.y += player.vy*dt;
+ player.y += player.vy * dt;
resolveY(player);
- player.x += player.vx*dt;
+ player.x += player.vx * dt;
resolveX(player);
}
-
- // Отправляем позицию на сервер (мультиплеер)
sendPlayerPosition();
-
- // Обновляем физику воды
updateWaterPhysics(dt);
-
- // Погода и дождь
updateWeather(dt);
updateRain(dt);
-
player.invuln = Math.max(0, player.invuln - dt);
-
- // Voice position update
- voicePosT += dt;
- if(voicePosT > 0.5 && voiceSocket && voiceSocket.connected){
- voicePosT = 0;
- voiceSocket.emit('voice_pos', { x: player.x, y: player.y });
+ state.voicePosT += dt;
+ if (state.voicePosT > 0.5 && state.voiceSocket && state.voiceSocket.connected) {
+ state.voicePosT = 0;
+ state.voiceSocket.emit("voice_pos", { x: player.x, y: player.y });
}
-
- // Furnace tick
tickFurnaces(dt);
-
- // Обновляем UI печи если открыта
- if(currentFurnaceKey && Math.random() < 0.1){
+ if (state.currentFurnaceKey && Math.random() < 0.1) {
renderFurnaceUI();
}
-
- // Projectile tick (стрелы)
- for(let i = projectiles.length-1; i>=0; i--){
+ for (let i = projectiles.length - 1; i >= 0; i--) {
const p = projectiles[i];
p.x += p.vx * dt;
p.y += p.vy * dt;
- p.vy += 400 * dt; // гравитация
+ p.vy += 400 * dt;
p.life -= dt;
-
- // Столкновение с блоком
- const gx = Math.floor(p.x / TILE);
- const gy = Math.floor(p.y / TILE);
- const blk = getBlock(gx, gy);
- if(blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid){
- // Врезался в стену
- if(p.owner === 'player' && Math.random() < 0.5) inv.arrow++; // подбираем ~50%
+ const gx = Math.floor(p.x / state.TILE);
+ const gy = Math.floor(p.y / state.TILE);
+ const blk = getBlock2(gx, gy);
+ if (blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid) {
+ if (p.owner === "player" && Math.random() < 0.5) inv.arrow++;
projectiles.splice(i, 1);
continue;
}
-
- // Столкновение с сущностью
- if(p.owner === 'mob'){
- // Попал в игрока
- if(p.x > player.x && p.x < player.x+player.w && p.y > player.y && p.y < player.y+player.h){
- if(player.invuln <= 0){
+ if (p.owner === "mob") {
+ if (p.x > player.x && p.x < player.x + player.w && p.y > player.y && p.y < player.y + player.h) {
+ if (player.invuln <= 0) {
player.hp -= calculateDamage(p.dmg);
player.invuln = 0.4;
player.vx += p.vx * 0.3;
player.vy -= 150;
- playSound('hit1');
+ playSound("hit1");
}
projectiles.splice(i, 1);
continue;
}
} else {
- // Попал в моба — server mobs first in multiplayer
let hitMob = false;
- if(isMultiplayer){
- for (const [id, sm] of serverMobs) {
- if(sm.dead) continue;
- if(p.x > sm.x && p.x < sm.x+sm.w && p.y > sm.y && p.y < sm.y+sm.h){
- socket.emit('mob_arrow_hit', { id: sm.id, dmg: p.dmg, vx: p.vx });
+ if (state.isMultiplayer) {
+ for (const [id, sm] of state.serverMobs) {
+ if (sm.dead) continue;
+ if (p.x > sm.x && p.x < sm.x + sm.w && p.y > sm.y && p.y < sm.y + sm.h) {
+ state.socket.emit("mob_arrow_hit", { id: sm.id, dmg: p.dmg, vx: p.vx });
projectiles.splice(i, 1);
hitMob = true;
break;
}
}
}
- if(!hitMob){
- // Local mobs
- for(let j = mobs.length-1; j>=0; j--){
+ if (!hitMob) {
+ for (let j = mobs.length - 1; j >= 0; j--) {
const m = mobs[j];
- if(p.x > m.x && p.x < m.x+m.w && p.y > m.y && p.y < m.y+m.h){
+ if (p.x > m.x && p.x < m.x + m.w && p.y > m.y && p.y < m.y + m.h) {
m.hp -= p.dmg;
m.vx += p.vx * 0.2;
m.vy -= 200;
- if(m.hp <= 0){
- inv.meat += (m.kind==='chicken' ? 1 : 2);
- if(m.kind === 'skeleton'){
- inv.arrow += 2 + Math.floor(Math.random()*3);
- if(Math.random() < 0.15) inv.bow = (inv.bow||0) + 1;
+ if (m.hp <= 0) {
+ inv.meat += m.kind === "chicken" ? 1 : 2;
+ if (m.kind === "skeleton") {
+ inv.arrow += 2 + Math.floor(Math.random() * 3);
+ if (Math.random() < 0.15) inv.bow = (inv.bow || 0) + 1;
}
mobs.splice(j, 1);
rebuildHotbar();
@@ -3142,469 +2249,762 @@
}
}
}
-
- // Таймаут
- if(p.life <= 0) projectiles.splice(i, 1);
+ if (p.life <= 0) projectiles.splice(i, 1);
}
-
- // TNT tick
- for(const key of Array.from(activeTNT)){
- const b = grid.get(key);
- if(!b || b.dead){ activeTNT.delete(key); continue; }
+ for (const key of Array.from(state.activeTNT)) {
+ const b = state.grid.get(key);
+ if (!b || b.dead) {
+ state.activeTNT.delete(key);
+ continue;
+ }
b.fuse -= dt;
- if(b.fuse <= 0){
- explodeAt(b.gx,b.gy);
+ if (b.fuse <= 0) {
+ explodeAt(b.gx, b.gy);
}
}
-
- // mobs spawn (с обеих сторон камеры) — только в одиночном режиме
- spawnT += dt;
- if(!isMultiplayer && spawnT > 1.8 && mobs.length < 30){
- spawnT = 0;
-
- // Выбираем сторону спавна (левая или правая)
+ state.spawnT += dt;
+ if (!state.isMultiplayer && state.spawnT > 1.8 && mobs.length < 30) {
+ state.spawnT = 0;
const spawnLeft = Math.random() < 0.5;
- const gx = spawnLeft
- ? Math.floor((camX - 200)/TILE)
- : Math.floor((camX + W + 200)/TILE);
-
+ const gx = spawnLeft ? Math.floor((state.camX - 200) / state.TILE) : Math.floor((state.camX + state.W + 200) / state.TILE);
genColumn(gx);
const sgy = surfaceGyAt(gx);
- const wx = gx*TILE + 4;
- const wy = (sgy-2)*TILE;
-
- // не спавнить в воде
- const top = getBlock(gx, sgy);
- if(top && top.t==='water') {
- // skip
+ const wx = gx * state.TILE + 4;
+ const wy = (sgy - 2) * state.TILE;
+ const top = getBlock2(gx, sgy);
+ if (top && top.t === "water") {
} else {
- const night = isNight();
- if(night){
- // Ночью спавним враждебных мобов (максимум 12 хостайл)
- const hostileCount = mobs.filter(m => m.kind==='zombie'||m.kind==='creeper'||m.kind==='skeleton').length;
- if(hostileCount < 12){
+ const night2 = isNight();
+ if (night2) {
+ const hostileCount = mobs.filter((m) => m.kind === "zombie" || m.kind === "creeper" || m.kind === "skeleton").length;
+ if (hostileCount < 12) {
const rand = Math.random();
- if(rand < 0.35){
+ if (rand < 0.35) {
mobs.push(new Zombie(wx, wy));
- } else if(rand < 0.55){
+ } else if (rand < 0.55) {
mobs.push(new Creeper(wx, wy));
} else {
mobs.push(new Skeleton(wx, wy));
}
}
}
- // Животные спавнятся и днём и ночью (с лимитом)
- const animalCount = mobs.filter(m => m.kind==='pig'||m.kind==='chicken').length;
- if(animalCount < 8){
- mobs.push(Math.random()<0.5 ? new Pig(wx, wy) : new Chicken(wx, wy));
+ const animalCount = mobs.filter((m) => m.kind === "pig" || m.kind === "chicken").length;
+ if (animalCount < 8) {
+ mobs.push(Math.random() < 0.5 ? new Pig(wx, wy) : new Chicken(wx, wy));
}
}
}
-
- // mobs update — только локальные (singleplayer)
- if(!isMultiplayer){
- for(let i=mobs.length-1;i>=0;i--){
+ if (!state.isMultiplayer) {
+ for (let i = mobs.length - 1; i >= 0; i--) {
const m = mobs[i];
mobAI(m, dt);
- if(m.hp<=0) mobs.splice(i,1);
+ if (m.hp <= 0) mobs.splice(i, 1);
}
}
-
- // particles
- for(let i=parts.length-1;i>=0;i--){
- const p = parts[i];
+ for (let i = parts2.length - 1; i >= 0; i--) {
+ const p = parts2[i];
p.t -= dt;
- p.x += p.vx*dt;
- p.y += p.vy*dt;
- p.vy += GRAV*dt;
- if(p.t <= 0) parts.splice(i,1);
+ p.x += p.vx * dt;
+ p.y += p.vy * dt;
+ p.vy += state.GRAV * dt;
+ if (p.t <= 0) parts2.splice(i, 1);
}
-
- // death
- if(player.hp <= 0){
- deathEl.style.display='flex';
- } else if(deathEl.style.display === 'flex') {
- // Если HP > 0 но экран смерти всё ещё показан - скрываем его
- deathEl.style.display='none';
+ if (player.hp <= 0) {
+ state.deathEl.style.display = "flex";
+ } else if (state.deathEl.style.display === "flex") {
+ state.deathEl.style.display = "none";
}
-
- // render
+ const ctx2 = state.ctx;
+ const W2 = state.W;
+ const H2 = state.H;
+ const camX = state.camX;
+ const camY = state.camY;
+ const TILE2 = state.TILE;
+ const tex2 = state.tex;
const night = isNight();
-
- // sky
- ctx.fillStyle = night ? '#070816' : (isRaining ? '#6B7B8D' : '#87CEEB');
- ctx.fillRect(0,0,W,H);
-
- // clouds (parallax x/y)
- ctx.save();
- ctx.translate(-camX*0.5, -camY*0.15);
- ctx.fillStyle = 'rgba(255,255,255,0.65)';
- for(const c of clouds){
- ctx.fillRect(c.x, c.y, c.w, 26);
- ctx.fillRect(c.x+20, c.y-10, c.w*0.6, 22);
+ ctx2.fillStyle = night ? "#070816" : state.isRaining ? "#6B7B8D" : "#87CEEB";
+ ctx2.fillRect(0, 0, W2, H2);
+ ctx2.save();
+ ctx2.translate(-camX * 0.5, -camY * 0.15);
+ ctx2.fillStyle = "rgba(255,255,255,0.65)";
+ for (const c of clouds) {
+ ctx2.fillRect(c.x, c.y, c.w, 26);
+ ctx2.fillRect(c.x + 20, c.y - 10, c.w * 0.6, 22);
}
- ctx.restore();
-
- // world
- ctx.save();
- ctx.translate(-camX, -camY);
-
- const minGX = Math.floor(camX/TILE)-2;
- const maxGX = Math.floor((camX+W)/TILE)+2;
- const minGY = Math.floor(camY/TILE)-6;
- const maxGY = Math.floor((camY+H)/TILE)+6;
-
- // draw blocks (по массиву, но фильтруем диапазоном)
- for(const b of blocks){
- if(b.dead) continue;
- if(b.gx < minGX || b.gx > maxGX || b.gy < minGY || b.gy > maxGY) continue;
-
+ ctx2.restore();
+ ctx2.save();
+ ctx2.translate(-camX, -camY);
+ const minGX = Math.floor(camX / TILE2) - 2;
+ const maxGX = Math.floor((camX + W2) / TILE2) + 2;
+ const minGY = Math.floor(camY / TILE2) - 6;
+ const maxGY = Math.floor((camY + H2) / TILE2) + 6;
+ const blocks2 = state.blocks;
+ for (const b of blocks2) {
+ if (b.dead) continue;
+ if (b.gx < minGX || b.gx > maxGX || b.gy < minGY || b.gy > maxGY) continue;
const def = BLOCKS[b.t];
- if(def.alpha){
- ctx.save();
- ctx.globalAlpha = def.alpha;
- ctx.drawImage(tex[b.t], b.gx*TILE, b.gy*TILE, TILE, TILE);
- ctx.restore();
+ if (def.alpha) {
+ ctx2.save();
+ ctx2.globalAlpha = def.alpha;
+ ctx2.drawImage(tex2[b.t], b.gx * TILE2, b.gy * TILE2, TILE2, TILE2);
+ ctx2.restore();
} else {
- ctx.drawImage(tex[b.t], b.gx*TILE, b.gy*TILE, TILE, TILE);
+ ctx2.drawImage(tex2[b.t], b.gx * TILE2, b.gy * TILE2, TILE2, TILE2);
}
-
- // TNT мигает, если активирован
- if(b.t==='tnt' && b.active && Math.sin(now/60)>0){
- ctx.fillStyle='rgba(255,255,255,0.45)';
- ctx.fillRect(b.gx*TILE, b.gy*TILE, TILE, TILE);
+ if (b.t === "tnt" && b.active && Math.sin(now / 60) > 0) {
+ ctx2.fillStyle = "rgba(255,255,255,0.45)";
+ ctx2.fillRect(b.gx * TILE2, b.gy * TILE2, TILE2, TILE2);
}
-
- // огонь костра
- if(b.t==='campfire'){
- drawFire(b.gx*TILE, b.gy*TILE, now);
+ if (b.t === "campfire") {
+ drawFire(ctx2, b.gx * TILE2, b.gy * TILE2, now);
}
- // Печь — огонь когда обжигает
- if(b.t==='furnace' && activeFurnaces.has(`${b.gx},${b.gy}`)){
- drawFire(b.gx*TILE + 8, b.gy*TILE + 5, now);
+ if (b.t === "furnace" && state.activeFurnaces.has(`${b.gx},${b.gy}`)) {
+ drawFire(ctx2, b.gx * TILE2 + 8, b.gy * TILE2 + 5, now);
}
}
-
- // mobs
- const allMobsRender = isMultiplayer ? Array.from(serverMobs.values()) : mobs;
- for(const m of allMobsRender){
- if(m.kind==='zombie'){
- ctx.fillStyle = '#2ecc71';
- ctx.fillRect(m.x, m.y, m.w, m.h);
- ctx.fillStyle = '#c0392b';
- ctx.fillRect(m.x+6, m.y+12, 6,6);
- ctx.fillRect(m.x+22, m.y+12, 6,6);
- } else if(m.kind==='pig'){
- ctx.fillStyle = '#ffb6c1';
- ctx.fillRect(m.x, m.y, m.w, m.h);
- ctx.fillStyle = '#000';
- ctx.fillRect(m.x+22, m.y+5, 3,3);
- ctx.fillStyle = '#ff69b4';
- ctx.fillRect(m.x+28, m.y+12, 6,6);
- } else if(m.kind==='chicken'){
- // chicken
- ctx.fillStyle = '#ecf0f1';
- ctx.fillRect(m.x, m.y, m.w, m.h);
- ctx.fillStyle = '#f39c12';
- ctx.fillRect(m.x+18, m.y+10, 6,4);
- ctx.fillStyle = '#000';
- ctx.fillRect(m.x+8, m.y+6, 3,3);
- } else if(m.kind==='creeper'){
- // creeper
- ctx.fillStyle = '#4CAF50';
- ctx.fillRect(m.x, m.y, m.w, m.h);
- // Глаза
- ctx.fillStyle = '#000';
- ctx.fillRect(m.x+8, m.y+8, 4,4);
- ctx.fillRect(m.x+22, m.y+8, 4,4);
- // Рот
- ctx.fillStyle = '#000';
- ctx.fillRect(m.x+12, m.y+20, 10,4);
- // Ноги
- ctx.fillStyle = '#4CAF50';
- ctx.fillRect(m.x+4, m.y+30, 6,20);
- ctx.fillRect(m.x+24, m.y+30, 6,20);
- } else if(m.kind==='skeleton'){
- // skeleton - детализированный
- // Тело
- ctx.fillStyle = '#ECEFF1';
- ctx.fillRect(m.x+10, m.y+20, 14, 12);
- // Череп
- ctx.fillRect(m.x+8, m.y+0, 18, 18);
- // Глазницы
- ctx.fillStyle = '#000';
- ctx.fillRect(m.x+10, m.y+6, 4,4);
- ctx.fillRect(m.x+20, m.y+6, 4,4);
- // Нос
- ctx.fillRect(m.x+15, m.y+12, 4,2);
- // Руки
- ctx.fillStyle = '#ECEFF1';
- ctx.fillRect(m.x+2, m.y+20, 6,14);
- ctx.fillRect(m.x+26, m.y+20, 6,14);
- // Ноги
- ctx.fillRect(m.x+10, m.y+32, 6, 18);
- ctx.fillRect(m.x+18, m.y+32, 6, 18);
- // Лук в руке
- ctx.save();
- ctx.translate(m.x + 30, m.y + 22);
- ctx.strokeStyle = '#8B4513';
- ctx.lineWidth = 2;
- ctx.beginPath();
- ctx.arc(0, 0, 8, -Math.PI*0.7, Math.PI*0.7);
- ctx.stroke();
- // Тетива
- ctx.strokeStyle = '#ccc';
- ctx.lineWidth = 1;
- ctx.beginPath();
- ctx.moveTo(8*Math.cos(-Math.PI*0.7), 8*Math.sin(-Math.PI*0.7));
- ctx.lineTo(8*Math.cos(Math.PI*0.7), 8*Math.sin(Math.PI*0.7));
- ctx.stroke();
- ctx.restore();
+ const allMobsRender = state.isMultiplayer ? Array.from(state.serverMobs.values()) : mobs;
+ for (const m of allMobsRender) {
+ if (m.kind === "zombie") {
+ ctx2.fillStyle = "#2ecc71";
+ ctx2.fillRect(m.x, m.y, m.w, m.h);
+ ctx2.fillStyle = "#c0392b";
+ ctx2.fillRect(m.x + 6, m.y + 12, 6, 6);
+ ctx2.fillRect(m.x + 22, m.y + 12, 6, 6);
+ } else if (m.kind === "pig") {
+ ctx2.fillStyle = "#ffb6c1";
+ ctx2.fillRect(m.x, m.y, m.w, m.h);
+ ctx2.fillStyle = "#000";
+ ctx2.fillRect(m.x + 22, m.y + 5, 3, 3);
+ ctx2.fillStyle = "#ff69b4";
+ ctx2.fillRect(m.x + 28, m.y + 12, 6, 6);
+ } else if (m.kind === "chicken") {
+ ctx2.fillStyle = "#ecf0f1";
+ ctx2.fillRect(m.x, m.y, m.w, m.h);
+ ctx2.fillStyle = "#f39c12";
+ ctx2.fillRect(m.x + 18, m.y + 10, 6, 4);
+ ctx2.fillStyle = "#000";
+ ctx2.fillRect(m.x + 8, m.y + 6, 3, 3);
+ } else if (m.kind === "creeper") {
+ ctx2.fillStyle = "#4CAF50";
+ ctx2.fillRect(m.x, m.y, m.w, m.h);
+ ctx2.fillStyle = "#000";
+ ctx2.fillRect(m.x + 8, m.y + 8, 4, 4);
+ ctx2.fillRect(m.x + 22, m.y + 8, 4, 4);
+ ctx2.fillStyle = "#000";
+ ctx2.fillRect(m.x + 12, m.y + 20, 10, 4);
+ ctx2.fillStyle = "#4CAF50";
+ ctx2.fillRect(m.x + 4, m.y + 30, 6, 20);
+ ctx2.fillRect(m.x + 24, m.y + 30, 6, 20);
+ } else if (m.kind === "skeleton") {
+ ctx2.fillStyle = "#ECEFF1";
+ ctx2.fillRect(m.x + 10, m.y + 20, 14, 12);
+ ctx2.fillRect(m.x + 8, m.y + 0, 18, 18);
+ ctx2.fillStyle = "#000";
+ ctx2.fillRect(m.x + 10, m.y + 6, 4, 4);
+ ctx2.fillRect(m.x + 20, m.y + 6, 4, 4);
+ ctx2.fillRect(m.x + 15, m.y + 12, 4, 2);
+ ctx2.fillStyle = "#ECEFF1";
+ ctx2.fillRect(m.x + 2, m.y + 20, 6, 14);
+ ctx2.fillRect(m.x + 26, m.y + 20, 6, 14);
+ ctx2.fillRect(m.x + 10, m.y + 32, 6, 18);
+ ctx2.fillRect(m.x + 18, m.y + 32, 6, 18);
+ ctx2.save();
+ ctx2.translate(m.x + 30, m.y + 22);
+ ctx2.strokeStyle = "#8B4513";
+ ctx2.lineWidth = 2;
+ ctx2.beginPath();
+ ctx2.arc(0, 0, 8, -Math.PI * 0.7, Math.PI * 0.7);
+ ctx2.stroke();
+ ctx2.strokeStyle = "#ccc";
+ ctx2.lineWidth = 1;
+ ctx2.beginPath();
+ ctx2.moveTo(8 * Math.cos(-Math.PI * 0.7), 8 * Math.sin(-Math.PI * 0.7));
+ ctx2.lineTo(8 * Math.cos(Math.PI * 0.7), 8 * Math.sin(Math.PI * 0.7));
+ ctx2.stroke();
+ ctx2.restore();
}
}
-
- // boat (рисуем первой, чтобы игрок был внутри неё)
- if(boat.active){
- ctx.drawImage(tex['boat'], boat.x - (TILE-boat.w)/2, boat.y - (TILE-boat.h)/2, TILE, TILE);
+ if (boat.active) {
+ ctx2.drawImage(tex2["boat"], boat.x - (TILE2 - boat.w) / 2, boat.y - (TILE2 - boat.h) / 2, TILE2, TILE2);
}
-
- // other players (multiplayer)
- for(const [socketId, p] of otherPlayers){
- if(heroImg.complete){
- ctx.drawImage(heroImg, p.x - (TILE-player.w)/2, p.y - (TILE-player.h)/2, TILE, TILE);
+ for (const [socketId, p] of state.otherPlayers) {
+ if (state.heroImg.complete) {
+ ctx2.drawImage(state.heroImg, p.x - (TILE2 - player.w) / 2, p.y - (TILE2 - player.h) / 2, TILE2, TILE2);
} else {
- ctx.fillStyle = p.color;
- ctx.fillRect(p.x, p.y, 34, 34);
+ ctx2.fillStyle = p.color;
+ ctx2.fillRect(p.x, p.y, 34, 34);
}
- // Имя игрока (мелко над персонажем)
- ctx.fillStyle = '#fff';
- ctx.font = '12px system-ui';
- ctx.textAlign = 'center';
- ctx.fillText(p.name, p.x + 17, p.y - 8);
+ ctx2.fillStyle = "#fff";
+ ctx2.font = "12px system-ui";
+ ctx2.textAlign = "center";
+ ctx2.fillText(p.name, p.x + 17, p.y - 8);
}
-
- // player
- if(heroImg.complete){
- ctx.drawImage(heroImg, player.x - (TILE-player.w)/2, player.y - (TILE-player.h)/2, TILE, TILE);
+ if (state.heroImg.complete) {
+ ctx2.drawImage(state.heroImg, player.x - (TILE2 - player.w) / 2, player.y - (TILE2 - player.h) / 2, TILE2, TILE2);
} else {
- ctx.fillStyle='#fff';
- ctx.fillRect(player.x, player.y, player.w, player.h);
+ ctx2.fillStyle = "#fff";
+ ctx2.fillRect(player.x, player.y, player.w, player.h);
}
-
- // projectiles (стрелы)
- for(const p of projectiles){
+ for (const p of projectiles) {
const angle = Math.atan2(p.vy, p.vx);
- ctx.save();
- ctx.translate(p.x, p.y);
- ctx.rotate(angle);
- ctx.fillStyle = p.owner === 'mob' ? '#c0392b' : '#f1c40f';
- ctx.fillRect(-12, -1.5, 24, 3);
- // наконечник
- ctx.beginPath();
- ctx.moveTo(12, -4);
- ctx.lineTo(16, 0);
- ctx.lineTo(12, 4);
- ctx.closePath();
- ctx.fill();
- // оперение
- ctx.fillStyle = '#888';
- ctx.fillRect(-12, -3, 4, 2);
- ctx.fillRect(-12, 1, 4, 2);
- ctx.restore();
+ ctx2.save();
+ ctx2.translate(p.x, p.y);
+ ctx2.rotate(angle);
+ ctx2.fillStyle = p.owner === "mob" ? "#c0392b" : "#f1c40f";
+ ctx2.fillRect(-12, -1.5, 24, 3);
+ ctx2.beginPath();
+ ctx2.moveTo(12, -4);
+ ctx2.lineTo(16, 0);
+ ctx2.lineTo(12, 4);
+ ctx2.closePath();
+ ctx2.fill();
+ ctx2.fillStyle = "#888";
+ ctx2.fillRect(-12, -3, 4, 2);
+ ctx2.fillRect(-12, 1, 4, 2);
+ ctx2.restore();
}
-
- // particles
- for(const p of parts){
- ctx.fillStyle = p.c;
- ctx.fillRect(p.x-2, p.y-2, 4, 4);
+ for (const p of parts2) {
+ ctx2.fillStyle = p.c;
+ ctx2.fillRect(p.x - 2, p.y - 2, 4, 4);
}
-
- // Стрелы скелета
- for(const m of mobs){
- if(m.kind==='skeleton' && m.shootCooldown > 0.5){
- // Рисуем стрелу
- const arrowX = m.x + m.w/2;
+ for (const m of mobs) {
+ if (m.kind === "skeleton" && m.shootCooldown > 0.5) {
+ const arrowX = m.x + m.w / 2;
const arrowY = m.y + 15;
- const targetX = player.x + player.w/2;
- const targetY = player.y + player.h/2;
+ const targetX = player.x + player.w / 2;
+ const targetY = player.y + player.h / 2;
const angle = Math.atan2(targetY - arrowY, targetX - arrowX);
- const speed = 400;
-
- // Проверяем, попала ли стрела
const dx = targetX - arrowX;
const dy = targetY - arrowY;
const dist = Math.hypot(dx, dy);
-
- // Рисуем стрелу
- ctx.save();
- ctx.translate(arrowX, arrowY);
- ctx.rotate(angle);
- ctx.fillStyle = '#ECEFF1';
- ctx.fillRect(0, -1, 16, 2);
- ctx.restore();
-
- // Урон игроку если попали
- if(dist < 150 && player.invuln <= 0){
+ ctx2.save();
+ ctx2.translate(arrowX, arrowY);
+ ctx2.rotate(angle);
+ ctx2.fillStyle = "#ECEFF1";
+ ctx2.fillRect(0, -1, 16, 2);
+ ctx2.restore();
+ if (dist < 150 && player.invuln <= 0) {
player.hp -= 8;
player.invuln = 0.5;
player.vx += Math.cos(angle) * 300;
player.vy -= 200;
- playSound('hit1');
+ playSound("hit1");
}
}
}
-
- // build ghost
- if(mode()==='build' && mouse.x!==null && !craftOpen && player.hp>0){
+ if (mode() === "build" && mouse.x !== null && !state.craftOpen && player.hp > 0) {
const wx = mouse.x + camX;
const wy = mouse.y + camY;
- const gx = Math.floor(wx/TILE);
- const gy = Math.floor(wy/TILE);
- ctx.strokeStyle = 'rgba(255,255,255,0.9)';
- ctx.lineWidth = 2;
- ctx.strokeRect(gx*TILE, gy*TILE, TILE, TILE);
+ const ggx = Math.floor(wx / TILE2);
+ const ggy = Math.floor(wy / TILE2);
+ ctx2.strokeStyle = "rgba(255,255,255,0.9)";
+ ctx2.lineWidth = 2;
+ ctx2.strokeRect(ggx * TILE2, ggy * TILE2, TILE2, TILE2);
}
-
- ctx.restore();
-
- // lighting overlay at night (Minecraft-style: offscreen lightmap, wall occlusion, warm flicker)
- if(night){
- // 1) Рисуем тёмный оверлей на offscreen canvas
- lightC.width = W*dpr;
- lightC.height = H*dpr;
- lightCtx.setTransform(dpr,0,0,dpr,0,0);
- lightCtx.fillStyle = 'rgba(0,0,12,0.82)';
- lightCtx.fillRect(0,0,W,H);
-
- // 2) Вырезаем «окна света» через destination-out — свет не проходит сквозь стены
- lightCtx.globalCompositeOperation = 'destination-out';
-
- // Функция: рисуем мягкий луч света с затуханием за стенами
- function castLight(sx, sy, radius) {
- const flick = 0.88 + Math.sin(now/80 + sx*0.01)*0.06 + Math.sin(now/130 + sy*0.02)*0.06;
+ ctx2.restore();
+ if (night) {
+ let castLight = function(sx, sy, radius) {
+ const flick = 0.88 + Math.sin(now / 80 + sx * 0.01) * 0.06 + Math.sin(now / 130 + sy * 0.02) * 0.06;
const r = radius * flick;
- // 12 лучей — достаточно для мягкого круга
- const steps = 12;
- // Собираем дистанции до стен по лучам
- const dists = new Float32Array(steps);
- for(let i=0; i TILE*0.3){
+ for (let step = TILE2 * 0.5; step < r; step += TILE2 * 0.6) {
+ const lgx = Math.floor((sx + ddx * step) / TILE2);
+ const lgy = Math.floor((sy + ddy * step) / TILE2);
+ const blk = getBlock2(lgx, lgy);
+ if (blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid && step > TILE2 * 0.3) {
maxDist = step;
break;
}
}
dists[i] = maxDist;
}
- // Рисуем сглаженный полигон по dists
- const cx = sx-camX, cy = sy-camY;
- // Центр: яркая точка
+ const cx = sx - camX, cy = sy - camY;
const maxR = Math.max(...dists);
- const grad = lightCtx.createRadialGradient(cx, cy, 0, cx, cy, maxR);
- grad.addColorStop(0, 'rgba(255,255,255,1)');
- grad.addColorStop(0.5, 'rgba(255,255,255,0.65)');
- grad.addColorStop(1, 'rgba(255,255,255,0)');
- lightCtx.fillStyle = grad;
- // Рисуем shape по dists (звездоподобный полигон)
- lightCtx.beginPath();
- for(let i=0; i<=steps; i++){
- const idx = i % steps;
- const nextIdx = (i+1) % steps;
- const avgD = (dists[idx] + dists[nextIdx]) / 2;
- const angle = (idx/steps) * Math.PI * 2;
- const px = cx + Math.cos(angle) * dists[idx];
- const py = cy + Math.sin(angle) * dists[idx];
- if(i===0) lightCtx.moveTo(px, py);
- else lightCtx.lineTo(px, py);
+ const grad = lightCtx2.createRadialGradient(cx, cy, 0, cx, cy, maxR);
+ grad.addColorStop(0, "rgba(255,255,255,1)");
+ grad.addColorStop(0.5, "rgba(255,255,255,0.65)");
+ grad.addColorStop(1, "rgba(255,255,255,0)");
+ lightCtx2.fillStyle = grad;
+ lightCtx2.beginPath();
+ for (let i = 0; i <= steps2; i++) {
+ const idx = i % steps2;
+ const ang = idx / steps2 * Math.PI * 2;
+ const px = cx + Math.cos(ang) * dists[idx];
+ const py = cy + Math.sin(ang) * dists[idx];
+ if (i === 0) lightCtx2.moveTo(px, py);
+ else lightCtx2.lineTo(px, py);
}
- lightCtx.closePath();
- lightCtx.fill();
- }
-
- // Источники света
- for(const b of blocks){
- if(b.dead) continue;
- if(b.gx < minGX-5 || b.gx > maxGX+5 || b.gy < minGY-5 || b.gy > maxGY+5) continue;
+ lightCtx2.closePath();
+ lightCtx2.fill();
+ };
+ const lightC2 = state.lightC;
+ const lightCtx2 = state.lightCtx;
+ lightC2.width = W2 * state.dpr;
+ lightC2.height = H2 * state.dpr;
+ lightCtx2.setTransform(state.dpr, 0, 0, state.dpr, 0, 0);
+ lightCtx2.fillStyle = "rgba(0,0,12,0.82)";
+ lightCtx2.fillRect(0, 0, W2, H2);
+ lightCtx2.globalCompositeOperation = "destination-out";
+ for (const b of blocks2) {
+ if (b.dead) continue;
+ if (b.gx < minGX - 5 || b.gx > maxGX + 5 || b.gy < minGY - 5 || b.gy > maxGY + 5) continue;
const def = BLOCKS[b.t];
- if(def.lightRadius){
- castLight(b.gx*TILE + TILE/2, b.gy*TILE + TILE/2, def.lightRadius);
+ if (def.lightRadius) {
+ castLight(b.gx * TILE2 + TILE2 / 2, b.gy * TILE2 + TILE2 / 2, def.lightRadius);
}
}
-
- // 3) Накладываем lightmap на основной canvas
- lightCtx.globalCompositeOperation = 'source-over';
- ctx.drawImage(lightC, 0, 0, W, H);
-
- // 4) Тёплый оверлей от источников света (additive, мягкий)
- ctx.save();
- ctx.globalCompositeOperation = 'lighter';
- for(const b of blocks){
- if(b.dead) continue;
- if(b.gx < minGX-3 || b.gx > maxGX+3 || b.gy < minGY-3 || b.gy > maxGY+3) continue;
+ lightCtx2.globalCompositeOperation = "source-over";
+ ctx2.drawImage(lightC2, 0, 0, W2, H2);
+ ctx2.save();
+ ctx2.globalCompositeOperation = "lighter";
+ for (const b of blocks2) {
+ if (b.dead) continue;
+ if (b.gx < minGX - 3 || b.gx > maxGX + 3 || b.gy < minGY - 3 || b.gy > maxGY + 3) continue;
const def = BLOCKS[b.t];
- if(def.lightRadius){
- const flick = 0.7 + Math.sin(now/90 + b.gx*3.7)*0.15 + Math.sin(now/140 + b.gy*2.3)*0.15;
- const wx = b.gx*TILE + TILE/2 - camX;
- const wy = b.gy*TILE + TILE/2 - camY;
+ if (def.lightRadius) {
+ const flick = 0.7 + Math.sin(now / 90 + b.gx * 3.7) * 0.15 + Math.sin(now / 140 + b.gy * 2.3) * 0.15;
+ const wx = b.gx * TILE2 + TILE2 / 2 - camX;
+ const wy = b.gy * TILE2 + TILE2 / 2 - camY;
const r = def.lightRadius * 0.6 * flick;
- const grad = ctx.createRadialGradient(wx,wy, 0, wx,wy, r);
- grad.addColorStop(0, `rgba(255,180,80,${0.12*flick})`);
- grad.addColorStop(0.5, `rgba(255,140,40,${0.06*flick})`);
- grad.addColorStop(1, 'rgba(255,100,20,0)');
- ctx.fillStyle = grad;
- ctx.beginPath();
- ctx.arc(wx, wy, r, 0, Math.PI*2);
- ctx.fill();
+ const grad = ctx2.createRadialGradient(wx, wy, 0, wx, wy, r);
+ grad.addColorStop(0, `rgba(255,180,80,${0.12 * flick})`);
+ grad.addColorStop(0.5, `rgba(255,140,40,${0.06 * flick})`);
+ grad.addColorStop(1, "rgba(255,100,20,0)");
+ ctx2.fillStyle = grad;
+ ctx2.beginPath();
+ ctx2.arc(wx, wy, r, 0, Math.PI * 2);
+ ctx2.fill();
}
}
- ctx.restore();
+ ctx2.restore();
}
-
- // Дождь (после ночного оверлея)
drawRain();
- if(Math.random()<0.25){
- hpEl.textContent = Math.max(0, Math.ceil(player.hp));
- foodEl.textContent = Math.ceil(player.hunger);
- document.getElementById('o2').textContent = Math.ceil(player.o2);
- sxEl.textContent = Math.floor(player.x/TILE);
- syEl.textContent = Math.floor(player.y/TILE);
- todEl.textContent = night ? 'Ночь' : 'День';
- worldIdEl.textContent = worldId;
- if(isMultiplayer){
- document.getElementById('multiplayerStatus').style.display = 'flex';
- playerCountEl.textContent = otherPlayers.size + 1; // +1 = мы сами
+ if (Math.random() < 0.25) {
+ state.hpEl.textContent = Math.max(0, Math.ceil(player.hp));
+ state.foodEl.textContent = Math.ceil(player.hunger);
+ document.getElementById("o2").textContent = Math.ceil(player.o2);
+ state.sxEl.textContent = Math.floor(player.x / TILE2);
+ state.syEl.textContent = Math.floor(player.y / TILE2);
+ state.todEl.textContent = night ? "\u041D\u043E\u0447\u044C" : "\u0414\u0435\u043D\u044C";
+ state.worldIdEl.textContent = state.worldId;
+ if (state.isMultiplayer) {
+ document.getElementById("multiplayerStatus").style.display = "flex";
+ state.playerCountEl.textContent = state.otherPlayers.size + 1;
} else {
- document.getElementById('multiplayerStatus').style.display = 'none';
+ document.getElementById("multiplayerStatus").style.display = "none";
}
}
-
- // Индикатор сна
- if(player.sleeping){
- ctx.fillStyle = 'rgba(0,0,0,0.7)';
- ctx.fillRect(0, 0, W, H);
- ctx.fillStyle = '#fff';
- ctx.font = 'bold 32px system-ui';
- ctx.textAlign = 'center';
- ctx.fillText('💤 Спим...', W/2, H/2);
- ctx.font = '18px system-ui';
- ctx.fillText('Нажмите на кровать чтобы проснуться', W/2, H/2 + 40);
+ if (player.sleeping) {
+ ctx2.fillStyle = "rgba(0,0,0,0.7)";
+ ctx2.fillRect(0, 0, W2, H2);
+ ctx2.fillStyle = "#fff";
+ ctx2.font = "bold 32px system-ui";
+ ctx2.textAlign = "center";
+ ctx2.fillText("\u{1F4A4} \u0421\u043F\u0438\u043C...", W2 / 2, H2 / 2);
+ ctx2.font = "18px system-ui";
+ ctx2.fillText("\u041D\u0430\u0436\u043C\u0438\u0442\u0435 \u043D\u0430 \u043A\u0440\u043E\u0432\u0430\u0442\u044C \u0447\u0442\u043E\u0431\u044B \u043F\u0440\u043E\u0441\u043D\u0443\u0442\u044C\u0441\u044F", W2 / 2, H2 / 2 + 40);
}
-
- // Миникарта (обновляем раз в ~4 кадра для оптимизации)
- if(minimapOpen && Math.random() < 0.25){
+ if (state.minimapOpen && Math.random() < 0.25) {
renderMinimap();
}
-
requestAnimationFrame(loop);
}
-
+
+ // src/ui/respawn.js
+ function initRespawn() {
+ document.getElementById("respawnBtn").onclick = async () => {
+ playSound("click");
+ console.log("=== RESPAWN CLICKED ===");
+ console.log("isMultiplayer:", state.isMultiplayer);
+ console.log("otherPlayers.size:", state.otherPlayers.size);
+ console.log("player.hp before respawn:", state.player.hp);
+ const player = state.player;
+ const deathEl = state.deathEl;
+ if (state.isMultiplayer && state.otherPlayers.size > 0) {
+ console.log("\u041C\u0443\u043B\u044C\u0442\u0438\u043F\u043B\u0435\u0435\u0440 \u0440\u0435\u0436\u0438\u043C - \u0432\u043E\u0437\u0440\u043E\u0436\u0434\u0435\u043D\u0438\u0435 \u0432 \u043D\u0430\u0447\u0430\u043B\u044C\u043D\u043E\u0439 \u0442\u043E\u0447\u043A\u0435");
+ player.hp = 100;
+ player.hunger = 100;
+ player.o2 = 100;
+ player.vx = player.vy = 0;
+ player.invuln = 0;
+ player.x = state.spawnPoint.x;
+ player.y = state.spawnPoint.y;
+ player.fallStartY = player.y;
+ console.log("\u0412\u043E\u0437\u0440\u043E\u0436\u0434\u0435\u043D\u0438\u0435 \u0432 \u043D\u0430\u0447\u0430\u043B\u044C\u043D\u043E\u0439 \u0442\u043E\u0447\u043A\u0435, HP:", player.hp);
+ } else {
+ console.log("\u041E\u0434\u0438\u043D\u043E\u0447\u043D\u044B\u0439 \u0440\u0435\u0436\u0438\u043C - \u0437\u0430\u0433\u0440\u0443\u0436\u0430\u0435\u043C \u043F\u043E\u0441\u043B\u0435\u0434\u043D\u0435\u0435 \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435");
+ const loadedSave = await loadGame();
+ if (loadedSave) {
+ await applySave(loadedSave);
+ console.log("\u0417\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u043E \u043F\u043E\u0441\u043B\u0435\u0434\u043D\u0435\u0435 \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435 \u043F\u043E\u0441\u043B\u0435 \u0441\u043C\u0435\u0440\u0442\u0438, final HP:", player.hp);
+ } else {
+ player.hp = 100;
+ player.hunger = 100;
+ player.o2 = 100;
+ player.vx = player.vy = 0;
+ player.invuln = 0;
+ player.x = state.spawnPoint.x;
+ player.y = state.spawnPoint.y;
+ player.fallStartY = player.y;
+ console.log("\u0412\u043E\u0437\u0440\u043E\u0436\u0434\u0435\u043D\u0438\u0435 \u0432 \u043D\u0430\u0447\u0430\u043B\u044C\u043D\u043E\u0439 \u0442\u043E\u0447\u043A\u0435, HP:", player.hp);
+ }
+ }
+ console.log("player.hp after respawn logic:", player.hp);
+ console.log("Hiding death screen...");
+ deathEl.style.display = "none";
+ console.log("=== RESPAWN END ===");
+ };
+ }
+
+ // src/ui/share.js
+ function initShare() {
+ document.getElementById("worldId").onclick = () => {
+ const shareUrl = new URL(window.location.href);
+ shareUrl.searchParams.set("world", state.worldId);
+ const shareUrlString = shareUrl.toString();
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(shareUrlString).then(() => {
+ alert("\u0421\u0441\u044B\u043B\u043A\u0430 \u0441\u043A\u043E\u043F\u0438\u0440\u043E\u0432\u0430\u043D\u0430!");
+ }).catch(() => {
+ alert("\u0421\u0441\u044B\u043B\u043A\u0430 \u043D\u0430 \u043C\u0438\u0440:\n" + shareUrlString);
+ });
+ } else {
+ alert("\u0421\u0441\u044B\u043B\u043A\u0430 \u043D\u0430 \u043C\u0438\u0440:\n" + shareUrlString);
+ }
+ };
+ }
+
+ // src/input/controls.js
+ var inp = state.inp;
+ function bindHold(el, key) {
+ const down = (e) => {
+ e.preventDefault();
+ state.inp[key] = true;
+ };
+ const up = (e) => {
+ e.preventDefault();
+ state.inp[key] = false;
+ };
+ el.addEventListener("pointerdown", down);
+ el.addEventListener("pointerup", up);
+ el.addEventListener("pointerleave", up);
+ }
+ function initControls() {
+ const leftBtn = document.getElementById("left");
+ const rightBtn = document.getElementById("right");
+ const jumpBtn = document.getElementById("jump");
+ const downBtn = document.getElementById("down");
+ if (leftBtn) bindHold(leftBtn, "l");
+ if (rightBtn) bindHold(rightBtn, "r");
+ if (jumpBtn) bindHold(jumpBtn, "j");
+ if (downBtn) bindHold(downBtn, "s");
+ window.addEventListener("keydown", (e) => {
+ if (e.code === "KeyA" || e.code === "ArrowLeft") state.inp.l = true;
+ if (e.code === "KeyD" || e.code === "ArrowRight") state.inp.r = true;
+ if (e.code === "Space" || e.code === "KeyW" || e.code === "ArrowUp") state.inp.j = true;
+ if (e.code === "KeyS" || e.code === "ArrowDown") state.inp.s = true;
+ });
+ window.addEventListener("keyup", (e) => {
+ if (e.code === "KeyA" || e.code === "ArrowLeft") state.inp.l = false;
+ if (e.code === "KeyD" || e.code === "ArrowRight") state.inp.r = false;
+ if (e.code === "Space" || e.code === "KeyW" || e.code === "ArrowUp") state.inp.j = false;
+ if (e.code === "KeyS" || e.code === "ArrowDown") state.inp.s = false;
+ });
+ }
+
+ // src/multiplayer/voice-chat.js
+ var voiceSocket = null;
+ var voiceStream = null;
+ var audioCtx = null;
+ var voiceProcessor = null;
+ var voiceActive = false;
+ var VOICE_SERVER = "https://voicegrech.mkn8n.ru";
+ var voiceBtn = document.createElement("div");
+ voiceBtn.innerHTML = '\u{1F3A4}/';
+ voiceBtn.title = "\u0413\u043E\u043B\u043E\u0441\u043E\u0432\u043E\u0439 \u0447\u0430\u0442 (\u0432\u044B\u043A\u043B)";
+ voiceBtn.style.cssText = "position:absolute;top:74px;right:130px;width:52px;height:52px;border-radius:12px;background:#555;z-index:200;display:flex;align-items:center;justify-content:center;font-size:24px;cursor:pointer;border:2px solid rgba(255,255,255,0.9);box-shadow:0 4px 0 rgba(0,0,0,0.5);pointer-events:auto;";
+ document.querySelector(".ui").appendChild(voiceBtn);
+ var speakingIndicator = document.createElement("div");
+ speakingIndicator.style.cssText = "position:absolute;top:130px;right:10px;z-index:200;display:none;background:rgba(0,0,0,0.7);color:#2ecc71;padding:4px 8px;border-radius:6px;font-size:12px;";
+ speakingIndicator.textContent = "\u{1F50A}";
+ document.querySelector(".ui").appendChild(speakingIndicator);
+ var speakingTimeout = null;
+ voiceBtn.onclick = async () => {
+ if (voiceActive) {
+ voiceActive = false;
+ voiceBtn.innerHTML = '\u{1F3A4}/';
+ voiceBtn.style.background = "#555";
+ if (voiceStream) {
+ voiceStream.getTracks().forEach((t) => t.stop());
+ voiceStream = null;
+ }
+ if (voiceProcessor) {
+ voiceProcessor.disconnect();
+ voiceProcessor = null;
+ }
+ if (audioCtx) {
+ audioCtx.close();
+ audioCtx = null;
+ }
+ if (voiceSocket) {
+ voiceSocket.disconnect();
+ voiceSocket = null;
+ }
+ return;
+ }
+ try {
+ voiceStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 24e3 } });
+ audioCtx = new AudioContext({ sampleRate: 24e3 });
+ const source = audioCtx.createMediaStreamSource(voiceStream);
+ voiceProcessor = audioCtx.createScriptProcessor(4096, 1, 1);
+ voiceProcessor.onaudioprocess = (e) => {
+ if (!voiceActive || !voiceSocket || !voiceSocket.connected) return;
+ const pcm = e.inputBuffer.getChannelData(0);
+ const int16 = new Int16Array(pcm.length);
+ for (let i = 0; i < pcm.length; i++) {
+ const s = Math.max(-1, Math.min(1, pcm[i]));
+ int16[i] = s < 0 ? s * 32768 : s * 32767;
+ }
+ voiceSocket.emit("voice_data", int16.buffer);
+ };
+ source.connect(voiceProcessor);
+ voiceProcessor.connect(audioCtx.createGain());
+ voiceSocket = io(VOICE_SERVER, { transports: ["websocket"] });
+ voiceSocket.on("connect", () => {
+ voiceSocket.emit("voice_join", { world_id: worldId, x: state.player.x, y: state.player.y, name: state.playerName || "\u0418\u0433\u0440\u043E\u043A" });
+ });
+ voiceSocket.on("voice_in", (payload) => {
+ const { data, meta, volume } = payload;
+ if (!audioCtx || audioCtx.state === "closed") return;
+ const int16 = new Int16Array(data);
+ const float32 = new Float32Array(int16.length);
+ for (let i = 0; i < int16.length; i++) {
+ float32[i] = int16[i] / (int16[i] < 0 ? 32768 : 32767) * volume;
+ }
+ const buf = audioCtx.createBuffer(1, float32.length, 24e3);
+ buf.getChannelData(0).set(float32);
+ const src = audioCtx.createBufferSource();
+ src.buffer = buf;
+ const gain = audioCtx.createGain();
+ gain.gain.value = volume;
+ src.connect(gain).connect(audioCtx.destination);
+ src.start();
+ speakingIndicator.style.display = "block";
+ speakingIndicator.textContent = `\u{1F50A} ${meta.name}`;
+ clearTimeout(speakingTimeout);
+ speakingTimeout = setTimeout(() => {
+ speakingIndicator.style.display = "none";
+ }, 500);
+ });
+ voiceActive = true;
+ voiceBtn.textContent = "\u{1F3A4}";
+ voiceBtn.style.background = "#2ecc71";
+ } catch (e) {
+ console.error("Voice error:", e);
+ voiceBtn.style.background = "#e74c3c";
+ }
+ };
+ function initVoice() {
+ let voicePosT = 0;
+ return {
+ update(dt) {
+ voicePosT += dt;
+ if (voicePosT > 0.5 && voiceSocket && voiceSocket.connected) {
+ voicePosT = 0;
+ voiceSocket.emit("voice_pos", { x: state.player.x, y: state.player.y });
+ }
+ }
+ };
+ }
+
+ // src/main.js
+ var urlParams2 = new URLSearchParams(window.location.search);
+ state.SERVER_URL = urlParams2.get("server") || "https://apigrech.mkn8n.ru";
+ state.TELEGRAM_BOT_USERNAME = "Grechkacraft_bot";
+ state.TELEGRAM_APP_SHORT_NAME = "minegrechka";
+ if (location.protocol === "https:" && state.SERVER_URL.startsWith("http://")) {
+ console.warn("\u26A0\uFE0F Mixed content warning: page is HTTPS but server URL is HTTP");
+ alert("\u26A0\uFE0F \u041F\u0440\u0435\u0434\u0443\u043F\u0440\u0435\u0436\u0434\u0435\u043D\u0438\u0435: \u0441\u0442\u0440\u0430\u043D\u0438\u0446\u0430 \u0437\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u0430 \u043F\u043E HTTPS, \u043D\u043E \u0441\u0435\u0440\u0432\u0435\u0440 \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0435\u0442 HTTP. \u042D\u0442\u043E \u043C\u043E\u0436\u0435\u0442 \u0432\u044B\u0437\u0432\u0430\u0442\u044C \u043F\u0440\u043E\u0431\u043B\u0435\u043C\u044B.");
+ }
+ state.playerName = localStorage.getItem("minegrechka_playerName") || null;
+ if (!state.playerName) {
+ state.playerName = prompt("\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0432\u0430\u0448\u0435 \u0438\u043C\u044F \u0434\u043B\u044F \u0438\u0433\u0440\u044B:") || "\u0418\u0433\u0440\u043E\u043A";
+ localStorage.setItem("minegrechka_playerName", state.playerName);
+ console.log("Player name set:", state.playerName);
+ }
+ console.log("Current URL:", window.location.href);
+ var worldParam2 = urlParams2.get("world");
+ console.log("world param:", worldParam2);
+ state.worldId = worldParam2 && worldParam2.trim() !== "" ? worldParam2 : null;
+ console.log("worldId after params:", state.worldId, "type:", typeof state.worldId);
+ if (!state.worldId) {
+ state.worldId = Math.random().toString(36).substring(2, 10);
+ console.log("Generated worldId:", state.worldId);
+ try {
+ const newUrl = new URL(window.location.href);
+ newUrl.searchParams.set("world", state.worldId);
+ const newUrlString = newUrl.toString();
+ console.log("New URL to set:", newUrlString);
+ if (typeof window.history !== "undefined" && typeof window.history.replaceState === "function") {
+ window.history.replaceState(null, "", newUrlString);
+ console.log("URL after replaceState:", window.location.href);
+ } else {
+ console.error("History API not supported!");
+ }
+ } catch (e) {
+ console.error("Error updating URL:", e);
+ }
+ console.log("Generated new worldId for browser:", state.worldId);
+ }
+ console.log("Final worldId:", state.worldId, "Player name:", state.playerName);
+ state.gameEl = document.getElementById("game");
+ state.canvas = document.getElementById("c");
+ state.ctx = state.canvas.getContext("2d");
+ state.lightC = document.createElement("canvas");
+ state.lightCtx = state.lightC.getContext("2d");
+ state.dpr = Math.max(1, window.devicePixelRatio || 1);
+ function resize() {
+ state.W = state.gameEl.clientWidth;
+ state.H = state.gameEl.clientHeight;
+ state.canvas.width = state.W * state.dpr;
+ state.canvas.height = state.H * state.dpr;
+ state.lightC.width = state.W * state.dpr;
+ state.lightC.height = state.H * state.dpr;
+ state.ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0);
+ }
+ window.addEventListener("resize", resize);
+ resize();
+ state.otherPlayers = /* @__PURE__ */ new Map();
+ state.serverMobs = /* @__PURE__ */ new Map();
+ state.grid = /* @__PURE__ */ new Map();
+ state.blocks = [];
+ state.generated = /* @__PURE__ */ new Set();
+ state.serverOverrides = /* @__PURE__ */ new Map();
+ state.activeFurnaces = /* @__PURE__ */ new Map();
+ state.activeTNT = /* @__PURE__ */ new Set();
+ state.toolDurability = /* @__PURE__ */ new Map();
+ state.worldSeed = Math.floor(Math.random() * 1e6);
+ state.clouds = Array.from({ length: 10 }, () => ({
+ x: Math.random() * 2e3,
+ y: -200 - Math.random() * 260,
+ w: 80 + Math.random() * 120,
+ s: 12 + Math.random() * 20
+ }));
+ state.weatherTimer = 0;
+ state.weatherChangeInterval = 60 + Math.random() * 120;
+ state.player.x = 6 * state.TILE;
+ state.player.y = 0;
+ state.spawnPoint.x = 6 * state.TILE;
+ state.spawnPoint.y = 0;
+ state.heroImg = new Image();
+ state.heroImg.src = "https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png";
+ state.hpEl = document.getElementById("hp");
+ state.foodEl = document.getElementById("food");
+ state.sxEl = document.getElementById("sx");
+ state.syEl = document.getElementById("sy");
+ state.todEl = document.getElementById("tod");
+ state.worldIdEl = document.getElementById("worldId");
+ state.playerCountEl = document.getElementById("playerCount");
+ state.deathEl = document.getElementById("death");
+ initTextures();
+ loadSound("splash", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//splash.mp3");
+ loadSound("sand1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand1.mp3");
+ loadSound("snow1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//snow1.mp3");
+ loadSound("stone1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1.mp3");
+ loadSound("wood1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1.mp3");
+ loadSound("cloth1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//cloth1.mp3");
+ loadSound("fire", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//fire.mp3");
+ loadSound("hit1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hit1.mp3");
+ loadSound("attack", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//attack.mp3");
+ loadSound("hurt_chicken", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hurt1_chicken.mp3");
+ loadSound("stone_build", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1%20(1).mp3");
+ loadSound("wood_build", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1%20(1).mp3");
+ loadSound("click", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//click.mp3");
+ loadSound("explode1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//explode1.mp3");
+ loadSound("glass1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//glass1.mp3");
+ loadSound("eat1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//eat1.mp3");
+ loadSound("step", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand2.mp3");
+ initControls();
+ initMouseHandlers();
+ initChat();
+ initFurnace();
+ initMinimap();
+ initSaveControls();
+ initRespawn();
+ initShare();
+ initModes();
+ initSocket();
+ initVoice();
+ rebuildHotbar();
+ initDB().then(async () => {
+ const loadedSave = await loadGame();
+ if (loadedSave) {
+ await applySave(loadedSave);
+ console.log("\u0417\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u043E \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435, HP:", state.player.hp);
+ if (state.player.hp <= 0) {
+ console.log("WARNING: HP <= 0 \u043F\u043E\u0441\u043B\u0435 \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0438, \u0432\u043E\u0437\u0440\u043E\u0436\u0434\u0430\u0435\u043C\u0441\u044F");
+ state.player.hp = 100;
+ state.player.hunger = 100;
+ state.player.o2 = 100;
+ state.player.x = state.spawnPoint.x;
+ state.player.y = state.spawnPoint.y;
+ state.player.vx = state.player.vy = 0;
+ state.player.invuln = 0;
+ state.player.fallStartY = state.player.y;
+ }
+ } else {
+ console.log("\u0421\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E, \u043D\u0430\u0447\u0438\u043D\u0430\u0435\u043C \u043D\u043E\u0432\u0443\u044E \u0438\u0433\u0440\u0443");
+ state.player.hp = 100;
+ state.player.hunger = 100;
+ state.player.o2 = 100;
+ state.player.vx = state.player.vy = 0;
+ state.player.invuln = 0;
+ const startGX = 6;
+ for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx);
+ const surfaceY = surfaceGyAt(startGX);
+ let safeGY = surfaceY - 1;
+ const aboveBlock = getBlock2(startGX, surfaceY - 1);
+ if (aboveBlock && aboveBlock.t === "water") {
+ for (let gy = state.SEA_GY - 1; gy >= 0; gy--) {
+ const b = getBlock2(startGX, gy);
+ if (!b || b.dead || b.t === "air" || b.t === "water") continue;
+ safeGY = gy - 1;
+ break;
+ }
+ }
+ state.player.y = safeGY * state.TILE;
+ state.player.x = startGX * state.TILE;
+ state.player.fallStartY = state.player.y;
+ state.spawnPoint.x = state.player.x;
+ state.spawnPoint.y = state.player.y;
+ console.log("\u041D\u043E\u0432\u0430\u044F \u0438\u0433\u0440\u0430: startGX=", startGX, "surfaceY=", surfaceY, "player.y=", state.player.y);
+ for (let gx = startGX - 50; gx <= startGX + 50; gx++) {
+ genColumn(gx);
+ }
+ }
+ document.addEventListener("visibilitychange", () => {
+ if (document.hidden) {
+ saveGame();
+ }
+ });
+ window.addEventListener("beforeunload", () => {
+ saveGame();
+ });
+ }).catch((err) => {
+ console.error("\u041E\u0448\u0438\u0431\u043A\u0430 \u0438\u043D\u0438\u0446\u0438\u0430\u043B\u0438\u0437\u0430\u0446\u0438\u0438:", err);
+ const startGX = 6;
+ genColumn(startGX);
+ state.player.y = (surfaceGyAt(startGX) - 1) * state.TILE;
+ state.player.fallStartY = state.player.y;
+ for (let gx = startGX - 50; gx <= startGX + 50; gx++) {
+ genColumn(gx);
+ }
+ });
requestAnimationFrame(loop);
})();
diff --git a/src/audio/sound-engine.js b/src/audio/sound-engine.js
new file mode 100644
index 0000000..8d1671e
--- /dev/null
+++ b/src/audio/sound-engine.js
@@ -0,0 +1,37 @@
+// Звуковой движок
+const sounds = {};
+
+export { sounds };
+
+export function loadSound(id, src) {
+ const audio = new Audio();
+ audio.src = src;
+ audio.volume = 0.3;
+ sounds[id] = audio;
+}
+
+// Загрузка звуков
+loadSound('splash', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//splash.mp3');
+loadSound('sand1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand1.mp3');
+loadSound('snow1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//snow1.mp3');
+loadSound('stone1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1.mp3');
+loadSound('wood1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1.mp3');
+loadSound('cloth1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//cloth1.mp3');
+loadSound('fire', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//fire.mp3');
+loadSound('hit1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hit1.mp3');
+loadSound('attack', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//attack.mp3');
+loadSound('hurt_chicken', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hurt1_chicken.mp3');
+loadSound('stone_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1%20(1).mp3');
+loadSound('wood_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1%20(1).mp3');
+loadSound('click', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//click.mp3');
+loadSound('explode1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//explode1.mp3');
+loadSound('glass1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//glass1.mp3');
+loadSound('eat1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//eat1.mp3');
+loadSound('step', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand2.mp3');
+
+export function playSound(id) {
+ if (sounds[id]) {
+ sounds[id].currentTime = 0;
+ sounds[id].play().catch(e => console.error('Sound error:', e));
+ }
+}
\ No newline at end of file
diff --git a/src/config.js b/src/config.js
new file mode 100644
index 0000000..e90eea2
--- /dev/null
+++ b/src/config.js
@@ -0,0 +1,67 @@
+// ==================== КОНФИГУРАЦИЯ СЕРВЕРА ====================
+// Возможность переопределить сервер через query string
+const urlParams = new URLSearchParams(window.location.search);
+export const SERVER_URL = urlParams.get('server') || 'https://apigrech.mkn8n.ru';
+
+// Защита от mixed content
+if (location.protocol === 'https:' && SERVER_URL.startsWith('http://')) {
+ console.warn('⚠️ Mixed content warning: page is HTTPS but server URL is HTTP');
+ alert('⚠️ Предупреждение: страница загружена по HTTPS, но сервер использует HTTP. Это может вызвать проблемы.');
+}
+
+// ==================== WORLD ID И ИГРОКА ====================
+let worldId = null;
+let playerName = localStorage.getItem('minegrechka_playerName') || null;
+
+// Запрашиваем имя игрока, если его нет
+if (!playerName) {
+ playerName = prompt('Введите ваше имя для игры:') || 'Игрок';
+ localStorage.setItem('minegrechka_playerName', playerName);
+ console.log('Player name set:', playerName);
+}
+
+// Берём worldId из URL или генерируем новый
+console.log('Current URL:', window.location.href);
+const worldParam = urlParams.get('world');
+console.log('world param:', worldParam);
+
+// Проверяем на null, undefined или пустую строку
+worldId = (worldParam && worldParam.trim() !== '') ? worldParam : null;
+
+console.log('worldId after params:', worldId, 'type:', typeof worldId);
+
+// Если worldId отсутствует - генерируем новый и записываем в URL
+if (!worldId) {
+ worldId = Math.random().toString(36).substring(2, 10);
+ console.log('Generated worldId:', worldId);
+
+ try {
+ const newUrl = new URL(window.location.href);
+ newUrl.searchParams.set('world', worldId);
+ const newUrlString = newUrl.toString();
+ console.log('New URL to set:', newUrlString);
+
+ // Проверяем, поддерживается ли history API
+ if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') {
+ window.history.replaceState(null, '', newUrlString);
+ console.log('URL after replaceState:', window.location.href);
+ console.log('URL after replaceState (direct check):', window.location.search);
+ } else {
+ console.error('History API not supported!');
+ }
+ } catch (e) {
+ console.error('Error updating URL:', e);
+ }
+
+ console.log('Generated new worldId for browser:', worldId);
+}
+
+console.log('Final worldId:', worldId, 'Player name:', playerName);
+
+console.log(`Server URL: ${SERVER_URL}, World ID: ${worldId}`);
+
+// Setters для изменения worldId/playerName из других модулей
+export function setWorldId(id) { worldId = id; }
+export function setPlayerName(name) { playerName = name; }
+
+export { worldId, playerName };
\ No newline at end of file
diff --git a/src/core/canvas.js b/src/core/canvas.js
new file mode 100644
index 0000000..3bd9abe
--- /dev/null
+++ b/src/core/canvas.js
@@ -0,0 +1,27 @@
+// ==================== ХОЛСТ И РАЗМЕРЫ ====================
+
+const gameEl = document.getElementById('game');
+const canvas = document.getElementById('c');
+const ctx = canvas.getContext('2d');
+
+// offscreen light map (не вставляем в DOM)
+const lightC = document.createElement('canvas');
+const lightCtx = lightC.getContext('2d');
+
+const dpr = Math.max(1, window.devicePixelRatio || 1);
+let W = 0, H = 0;
+
+function resize() {
+ W = gameEl.clientWidth;
+ H = gameEl.clientHeight;
+ canvas.width = W * dpr;
+ canvas.height = H * dpr;
+ lightC.width = W * dpr;
+ lightC.height = H * dpr;
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+}
+
+function setW(v) { W = v; }
+function setH(v) { H = v; }
+
+export { canvas, ctx, lightC, lightCtx, dpr, W, H, gameEl, resize, setW, setH };
\ No newline at end of file
diff --git a/src/core/constants.js b/src/core/constants.js
new file mode 100644
index 0000000..e48a06f
--- /dev/null
+++ b/src/core/constants.js
@@ -0,0 +1,32 @@
+// ==================== КОНСТАНТЫ ИГРЫ ====================
+
+export const TILE = 40;
+
+// Мир
+export const SEA_GY = 14; // уровень воды (gy) - уменьшил для меньших водоёмов
+export const BEDROCK_GY = 140; // глубина бедрока (gy), чем больше - тем глубже
+export const GEN_MARGIN_X = 40; // запас генерации по X (в тайлах) — увеличен для плавной прогрузки
+
+// Физика
+export const GRAV = 2200;
+export const GRAV_WATER = 550;
+export const MOVE = 320;
+export const JUMP = 760;
+
+// День/ночь
+export const DAY_LEN = 360; // замедлил смену дня/ночи в 3 раза
+
+// Сохранение
+export const SAVE_KEY = 'minegrechka_save';
+
+// Сеть
+export const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду
+
+// Погода
+export const MAX_RAINDROPS = 200;
+
+// Чат
+export const MAX_CHAT_MESSAGES = 50;
+
+// Голосовой чат
+export const VOICE_SERVER = 'wss://voicegrech.mkn8n.ru';
\ No newline at end of file
diff --git a/src/core/state.js b/src/core/state.js
new file mode 100644
index 0000000..17552ba
--- /dev/null
+++ b/src/core/state.js
@@ -0,0 +1,157 @@
+import { TILE } from './constants.js';
+
+// Все мутабельные переменные игры в одном объекте состояния
+export const state = {
+ // Камера
+ camX: 0,
+ camY: 0,
+
+ // День/ночь
+ worldTime: 0,
+ isNightTime: false,
+
+ // Мультиплеер
+ isMultiplayer: false,
+ mySocketId: null,
+ socket: null,
+
+ // Инвентарь/UI
+ selected: 0,
+ showFullInventory: false,
+ craftOpen: false,
+ inventoryOpen: false,
+ chatOpen: false,
+ modeIdx: 0,
+
+ // Мир
+ worldSeed: Math.floor(Math.random() * 1000000),
+
+ // Погода
+ isRaining: false,
+ rainIntensity: 0,
+ weatherTimer: 0,
+ weatherChangeInterval: 60 + Math.random() * 120,
+
+ // Мобы/спавн
+ spawnT: 0,
+
+ // Цикл
+ last: 0,
+ prevJump: false,
+
+ // Сеть — throttle отправки позиции
+ lastMoveSendTime: 0,
+ lastSentX: 0,
+ lastSentY: 0,
+
+ // Игрок
+ player: {
+ x: 6 * TILE,
+ y: 0 * TILE,
+ w: 34,
+ h: 34,
+ vx: 0,
+ vy: 0,
+ grounded: false,
+ inWater: false,
+ headInWater: false,
+ hp: 100,
+ hunger: 100,
+ o2: 100,
+ invuln: 0,
+ fallStartY: 0,
+ lastStepTime: 0,
+ sleeping: false,
+ inBoat: false,
+ armor: 0,
+ equippedArmor: null
+ },
+
+ // Точка спавна
+ spawnPoint: { x: 6 * TILE, y: 0 * TILE },
+
+ // Инвентарь
+ inv: {
+ dirt: 6, stone: 0, sand: 0, gravel: 0, clay: 0,
+ wood: 0, planks: 0, ladder: 0, leaves: 0, coal: 0,
+ copper_ore: 0, iron_ore: 0, gold_ore: 0, diamond_ore: 0,
+ brick: 0, glass: 0,
+ tnt: 1, campfire: 0, torch: 0,
+ meat: 0, cooked: 0, arrow: 0,
+ wood_pickaxe: 0, stone_pickaxe: 0, iron_pickaxe: 0,
+ wood_sword: 0, stone_sword: 0, iron_sword: 0,
+ iron_armor: 0,
+ bow: 0, furnace: 0,
+ bed: 0, boat: 0,
+ iron_ingot: 0, gold_ingot: 0, copper_ingot: 0
+ },
+
+ // Лодка
+ boat: {
+ x: 0, y: 0,
+ w: 34, h: 34,
+ vx: 0, vy: 0,
+ active: false,
+ inWater: false
+ },
+
+ // Ввод
+ inp: { up: false, down: false, left: false, right: false, jump: false, mine: false, build: false, bow: false },
+
+ // Мышь
+ mouse: { x: 0, y: 0 },
+
+ // Другие игроки (MP)
+ otherPlayers: new Map(),
+
+ // Серверные мобы (MP)
+ serverMobs: new Map(),
+
+ // Мобы
+ mobs: [],
+
+ // Снаряды
+ projectiles: [],
+
+ // Отслеживание изменений мира
+ placedBlocks: [],
+ removedBlocks: [],
+
+ // Серверные изменения
+ serverOverrides: new Map(),
+
+ // Чат
+ chatMessages: [],
+
+ // Погода — капли
+ raindrops: [],
+
+ // Облака
+ clouds: Array.from({ length: 10 }, () => ({
+ x: Math.random() * 2000,
+ y: -200 - Math.random() * 260,
+ w: 80 + Math.random() * 120,
+ s: 12 + Math.random() * 20
+ })),
+
+ // Частицы
+ parts: [],
+
+ // Активный TNT
+ activeTNT: new Set(),
+
+ // Прочность инструментов
+ toolDurability: new Map(),
+
+ // Последние выбранные предметы
+ recentItems: [],
+
+ // Активные печи
+ activeFurnaces: new Map(),
+
+ // Сгенерированные колонны
+ generated: new Set(),
+
+ // Изображение героя
+ heroImg: null
+};
\ No newline at end of file
diff --git a/src/data/blocks.js b/src/data/blocks.js
new file mode 100644
index 0000000..03beedf
--- /dev/null
+++ b/src/data/blocks.js
@@ -0,0 +1,30 @@
+export const BLOCKS = {
+ air: { n:'Воздух', solid:false },
+ grass: { n:'Трава', c:'#7cfc00', solid:true },
+ dirt: { n:'Грязь', c:'#8b4513', solid:true },
+ stone: { n:'Камень', c:'#7f8c8d', solid:true },
+ sand: { n:'Песок', c:'#f4d06f', solid:true },
+ gravel: { n:'Гравий', c:'#95a5a6', solid:true },
+ clay: { n:'Глина', c:'#74b9ff', solid:true },
+ wood: { n:'Дерево', c:'#d35400', solid:true },
+ planks: { n:'Доски', c:'#e67e22', solid:true },
+ ladder: { n:'Лестница',c:'#d35400', solid:false, climbable:true },
+ leaves: { n:'Листва', c:'#2ecc71', solid:true },
+ glass: { n:'Стекло', c:'rgba(200,240,255,0.25)', solid:true, alpha:0.55 },
+ water: { n:'Вода', c:'rgba(52,152,219,0.55)', solid:false, fluid:true },
+ coal: { n:'Уголь', c:'#2c3e50', solid:true },
+ copper_ore:{ n:'Медь', c:'#e17055', solid:true },
+ iron_ore: { n:'Железо', c:'#dcdde1', solid:true },
+ iron_armor:{ n:'Железная броня', c:'#95a5a6', solid:false, armor:0.5 },
+ gold_ore: { n:'Золото', c:'#f1c40f', solid:true },
+ diamond_ore:{n:'Алмаз', c:'#00a8ff', solid:true },
+ brick: { n:'Кирпич', c:'#c0392b', solid:true },
+ tnt: { n:'TNT', c:'#e74c3c', solid:true, explosive:true },
+ campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius:190 },
+ torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:140 },
+ bedrock: { n:'Бедрок', c:'#2d3436', solid:true, unbreakable:true },
+ flower: { n:'Цветок', c:'#ff4757', solid:false, decor:true },
+ bed: { n:'Кровать', c:'#e91e63', solid:true, bed:true },
+ boat: { n:'Лодка', c:'#8B4513', solid:false },
+ furnace: { n:'Печь', c:'#696969', solid:true, smelting:true }
+};
\ No newline at end of file
diff --git a/src/data/items.js b/src/data/items.js
new file mode 100644
index 0000000..ea0439d
--- /dev/null
+++ b/src/data/items.js
@@ -0,0 +1,10 @@
+export const ITEMS = {
+ meat: { n:'Сырое мясо', icon:'🥩', food:15 },
+ cooked: { n:'Жареное мясо', icon:'🍖', food:45 },
+ arrow: { n:'Стрела', icon:'➡️', stack:64 },
+};
+
+// Runtime extensions — предметы от обжига
+ITEMS.iron_ingot = { n:'Железный слиток', icon:'🔩' };
+ITEMS.gold_ingot = { n:'Золотой слиток', icon:'🪙' };
+ITEMS.copper_ingot = { n:'Медный слиток', icon:'🟤' };
\ No newline at end of file
diff --git a/src/data/recipes.js b/src/data/recipes.js
new file mode 100644
index 0000000..cc0dfa2
--- /dev/null
+++ b/src/data/recipes.js
@@ -0,0 +1,32 @@
+export const RECIPES = [
+ { out:'planks', qty:4, cost:{ wood:1 } },
+ { out:'ladder', qty:3, cost:{ planks:7 } },
+ { out:'torch', qty:2, cost:{ coal:1, planks:1 } },
+ { out:'glass', qty:1, cost:{ sand:3 } },
+ { out:'brick', qty:1, cost:{ stone:2, clay:1 } },
+ { out:'campfire', qty:1, cost:{ wood:1, coal:1 } },
+ { out:'tnt', qty:1, cost:{ sand:2, coal:1 } },
+ { out:'bed', qty:1, cost:{ wood: 3, planks: 3 } },
+ { out:'boat', qty:1, cost:{ wood: 5 } },
+ { out:'wood_pickaxe', qty:1, cost:{ wood: 3, planks: 2 } },
+ { out:'stone_pickaxe', qty:1, cost:{ wood: 1, stone: 3 } },
+ { out:'iron_pickaxe', qty:1, cost:{ wood: 1, iron_ore: 2 } },
+ { out:'wood_sword', qty:1, cost:{ wood: 2, planks: 1 } },
+ { out:'stone_sword', qty:1, cost:{ wood: 1, stone: 2 } },
+ { out:'iron_sword', qty:1, cost:{ wood: 1, iron_ore: 1 } },
+ { out:'iron_armor', qty:1, cost:{ iron_ore: 5 } },
+ { out:'furnace', qty:1, cost:{ stone: 8 } },
+ { out:'bow', qty:1, cost:{ wood: 3, planks: 2 } },
+ { out:'arrow', qty:4, cost:{ stone: 1, wood: 1 } }
+];
+
+// Рецепты печи (обжиг)
+export const SMELTING_RECIPES = [
+ { in:'sand', qty:1, out:'glass', outQty:1, time:3 }, // песок → стекло
+ { in:'clay', qty:1, out:'brick', outQty:1, time:3 }, // глина → кирпич
+ { in:'iron_ore', qty:1, out:'iron_ingot', outQty:1, time:5 }, // железо → слиток
+ { in:'gold_ore', qty:1, out:'gold_ingot', outQty:1, time:5 }, // золото → слиток
+ { in:'copper_ore', qty:1, out:'copper_ingot', outQty:1, time:4 }, // медь → слиток
+ { in:'meat', qty:1, out:'cooked', outQty:1, time:2 }, // сырое мясо → жареное
+ { in:'cobblestone', qty:1, out:'stone', outQty:1, time:2 } // булыжник → камень
+];
\ No newline at end of file
diff --git a/src/data/tools.js b/src/data/tools.js
new file mode 100644
index 0000000..10e56eb
--- /dev/null
+++ b/src/data/tools.js
@@ -0,0 +1,32 @@
+export const TOOLS = {
+ wood_pickaxe: { n:'Деревянная кирка', icon:'⛏️', durability: 60, miningPower: 1, craft: { wood: 3, planks: 2 } },
+ stone_pickaxe: { n:'Каменная кирка', icon:'⛏️', durability: 130, miningPower: 2, craft: { wood: 1, stone: 3 } },
+ iron_pickaxe: { n:'Железная кирка', icon:'⛏️', durability: 250, miningPower: 3, craft: { wood: 1, iron_ore: 2 } },
+ wood_sword: { n:'Деревянный меч', icon:'⚔️', durability: 40, damage: 5, craft: { wood: 2, planks: 1 } },
+ stone_sword: { n:'Каменный меч', icon:'⚔️', durability: 100, damage: 8, craft: { wood: 1, stone: 2 } },
+ iron_sword: { n:'Железный меч', icon:'⚔️', durability: 200, damage: 12, craft: { wood: 1, iron_ore: 1 } },
+ bow: { n:'Лук', icon:'🏹', durability: 150, craft: { wood: 3, string: 0 } }
+};
+
+import { state } from '../core/state.js';
+
+export function addTool(type) {
+ const maxDur = TOOLS[type]?.durability || 60;
+ const key = `${type}_${Date.now()}_${Math.random().toString(36).slice(2,6)}`;
+ state.toolDurability.set(key, { type, current: maxDur, max: maxDur });
+}
+
+export function useTool(type) {
+ for (const [tid, dur] of state.toolDurability) {
+ if (dur.type === type && dur.current > 0) {
+ dur.current--;
+ if (dur.current <= 0) {
+ state.toolDurability.delete(tid);
+ state.inv[type] = Math.max(0, (state.inv[type] || 0) - 1);
+ return true; // broke
+ }
+ return false; // used but not broke
+ }
+ }
+ return false; // no durability entry found
+}
\ No newline at end of file
diff --git a/src/entities/boat.js b/src/entities/boat.js
new file mode 100644
index 0000000..545ce6b
--- /dev/null
+++ b/src/entities/boat.js
@@ -0,0 +1,19 @@
+// Лодка
+import { state } from '../core/state.js';
+
+export const boat = {
+ x: 0, y: 0,
+ w: 34, h: 34,
+ vx: 0, vy: 0,
+ active: false,
+ inWater: false
+};
+
+export function initBoat() {
+ boat.x = 0;
+ boat.y = 0;
+ boat.vx = 0;
+ boat.vy = 0;
+ boat.active = false;
+ boat.inWater = false;
+}
\ No newline at end of file
diff --git a/src/entities/mob-ai.js b/src/entities/mob-ai.js
new file mode 100644
index 0000000..08f04c1
--- /dev/null
+++ b/src/entities/mob-ai.js
@@ -0,0 +1,108 @@
+// Моб AI
+import { state } from '../core/state.js';
+import { GRAV, GRAV_WATER, TILE } from '../core/constants.js';
+import { updateWaterFlag } from '../physics/water-detect.js';
+import { calculateDamage } from '../entities/player.js';
+import { playSound } from '../audio/sound-engine.js';
+import { explodeAt } from '../world/tnt.js';
+import { resolveY } from '../physics/collision.js';
+import { resolveX } from '../physics/collision.js';
+
+export function mobAI(m, dt) {
+ updateWaterFlag(m);
+
+ if (m.kind === 'zombie') {
+ // активность ночью
+ const night = isNight();
+ if (!night) { m.hp -= 30 * dt; m.vx *= 0.5; return; }
+ const dir = Math.sign((state.player.x) - m.x);
+ m.vx = dir * m.speed;
+ if (m.inWater && Math.random() < 0.06) m.vy = -260;
+ // атака
+ if (Math.abs((m.x + m.w / 2) - (state.player.x + state.player.w / 2)) < 28 &&
+ Math.abs((m.y + m.h / 2) - (state.player.y + state.player.h / 2)) < 40 &&
+ state.player.invuln <= 0) {
+ const damage = calculateDamage(15);
+ state.player.hp -= damage;
+ state.player.invuln = 0.8;
+ state.player.vx += dir * 420;
+ state.player.vy -= 260;
+ playSound('hit1'); // Звук при атаке зомби
+ }
+ } else if (m.kind === 'creeper') {
+ // активность ночью
+ const night = isNight();
+ if (!night) { m.hp -= 30 * dt; m.vx *= 0.5; return; }
+ const dir = Math.sign((state.player.x) - m.x);
+ const dist = Math.hypot((state.player.x + state.player.w / 2) - (m.x + m.w / 2), (state.player.y + state.player.h / 2) - (m.y + m.h / 2));
+
+ // Движение к игроку
+ m.vx = dir * m.speed;
+ if (m.inWater && Math.random() < 0.06) m.vy = -260;
+
+ // Взрыв если близко к игроку
+ if (dist < 60) {
+ m.fuse -= dt;
+ if (m.fuse <= 0) {
+ explodeAt(Math.floor((m.x + m.w / 2) / TILE), Math.floor((m.y + m.h / 2) / TILE));
+ m.hp = 0;
+ }
+ } else {
+ // Поджигаем если очень близко
+ if (dist < 40) {
+ m.fuse = 0.5; // Быстрый взрыв
+ }
+ }
+ } else if (m.kind === 'skeleton') {
+ // активность ночью
+ const night = isNight();
+ if (!night) { m.hp -= 30 * dt; m.vx *= 0.5; return; }
+ const dir = Math.sign((state.player.x) - m.x);
+ const dist = Math.hypot((state.player.x + state.player.w / 2) - (m.x + m.w / 2), (state.player.y + state.player.h / 2) - (m.y + m.h / 2));
+
+ // Движение к игроку
+ m.vx = dir * m.speed;
+ if (m.inWater && Math.random() < 0.06) m.vy = -260;
+
+ // Стрельба стрелами
+ m.shootCooldown -= dt;
+ if (dist < 300 && m.shootCooldown <= 0) {
+ m.shootCooldown = 2.0;
+ const dx = (state.player.x + state.player.w / 2) - (m.x + m.w / 2);
+ const dy = (state.player.y + state.player.h / 2) - (m.y + m.h / 2);
+ const angle = Math.atan2(dy, dx);
+ const speed = 450;
+ state.projectiles.push({
+ x: m.x + m.w / 2,
+ y: m.y + m.h / 3,
+ vx: Math.cos(angle) * speed,
+ vy: Math.sin(angle) * speed,
+ dmg: 6,
+ owner: 'mob',
+ life: 3
+ });
+ }
+ } else {
+ // животные
+ m.aiT -= dt;
+ if (m.aiT <= 0) {
+ m.aiT = 1.8 + Math.random() * 2.5;
+ m.dir = Math.random() < 0.5 ? -1 : 1;
+ if (Math.random() < 0.25) m.dir = 0;
+ }
+ m.vx = m.dir * (m.kind === 'chicken' ? 55 : 40);
+ if (m.inWater) m.vy = -120;
+ }
+
+ // физика моба
+ const g = m.inWater ? GRAV_WATER : GRAV;
+ m.vy += g * dt;
+
+ m.y += m.vy * dt; m.grounded = false; resolveY(m);
+ m.x += m.vx * dt; resolveX(m);
+}
+
+export function isNight() {
+ // Автоматический цикл: ночь когда worldTime > 0.5
+ return state.worldTime > 0.5;
+}
\ No newline at end of file
diff --git a/src/entities/mobs.js b/src/entities/mobs.js
new file mode 100644
index 0000000..be54425
--- /dev/null
+++ b/src/entities/mobs.js
@@ -0,0 +1,37 @@
+// Сущности: животные + зомби
+
+export class Entity {
+ constructor(x, y, w, h) {
+ this.x = x;
+ this.y = y;
+ this.w = w;
+ this.h = h;
+ this.vx = 0;
+ this.vy = 0;
+ this.hp = 3;
+ this.grounded = false;
+ this.inWater = false;
+ this.aiT = 0;
+ this.dir = 1;
+ }
+}
+
+export class Pig extends Entity {
+ constructor(x, y) { super(x, y, 34, 24); this.kind = 'pig'; this.hp = 2; }
+}
+
+export class Chicken extends Entity {
+ constructor(x, y) { super(x, y, 26, 22); this.kind = 'chicken'; this.hp = 1; }
+}
+
+export class Zombie extends Entity {
+ constructor(x, y) { super(x, y, 34, 50); this.kind = 'zombie'; this.hp = 4; this.speed = 80 + Math.random() * 40; }
+}
+
+export class Creeper extends Entity {
+ constructor(x, y) { super(x, y, 34, 50); this.kind = 'creeper'; this.hp = 4; this.speed = 60 + Math.random() * 30; this.fuse = 3.2; }
+}
+
+export class Skeleton extends Entity {
+ constructor(x, y) { super(x, y, 34, 50); this.kind = 'skeleton'; this.hp = 4; this.speed = 70 + Math.random() * 30; this.shootCooldown = 0; }
+}
\ No newline at end of file
diff --git a/src/entities/player.js b/src/entities/player.js
new file mode 100644
index 0000000..6503201
--- /dev/null
+++ b/src/entities/player.js
@@ -0,0 +1,43 @@
+import { TILE } from '../core/constants.js';
+import { state } from '../core/state.js';
+
+// Функция для расчёта урона с учётом брони
+function calculateDamage(baseDamage) {
+ // Броня снижает урон пропорционально
+ // armor: 0 = без брони (100% урона)
+ // armor: 0.5 = железная броня (50% урона)
+ const reduction = state.player.armor;
+ const actualDamage = baseDamage * (1 - reduction);
+ console.log('[DAMAGE] Base:', baseDamage, '- Armor:', reduction, '- Actual:', actualDamage.toFixed(1));
+ return actualDamage;
+}
+
+// Инициализация игрока
+function initPlayer() {
+ state.player = {
+ x: 6 * TILE,
+ y: 0 * TILE,
+ w: 34,
+ h: 34,
+ vx: 0,
+ vy: 0,
+ grounded: false,
+ inWater: false,
+ headInWater: false,
+ hp: 100,
+ hunger: 100,
+ o2: 100,
+ invuln: 0,
+ fallStartY: 0,
+ lastStepTime: 0,
+ sleeping: false,
+ inBoat: false,
+ armor: 0, // Текущий уровень защиты (0 = без брони, 0.5 = железная броня)
+ equippedArmor: null // Тип надетой брони
+ };
+
+ // Сохраняем начальную позицию для возрождения
+ state.spawnPoint = { x: 6 * TILE, y: 0 * TILE };
+}
+
+export { calculateDamage, initPlayer };
\ No newline at end of file
diff --git a/src/game/loop.js b/src/game/loop.js
new file mode 100644
index 0000000..1b29b29
--- /dev/null
+++ b/src/game/loop.js
@@ -0,0 +1,758 @@
+// ==================== ГЛАВНЫЙ ИГРОВОЙ ЦИКЛ ====================
+import { state } from '../core/state.js';
+import { BLOCKS } from '../data/blocks.js';
+import { playSound } from '../audio/sound-engine.js';
+import { getBlock } from '../world/world-storage.js';
+import { updateWaterPhysics } from '../world/water.js';
+import { updateWaterFlag } from '../physics/water-detect.js';
+import { resolveY, resolveX } from '../physics/collision.js';
+import { calculateDamage } from '../entities/player.js';
+import { isNight } from '../entities/mob-ai.js';
+import { tickFurnaces } from '../ui/furnace.js';
+import { renderFurnaceUI } from '../ui/furnace.js';
+import { updateWeather, updateRain, drawRain } from '../world/weather.js';
+import { ensureGenAroundCamera, surfaceGyAt, genColumn } from '../world/generation.js';
+import { Zombie, Creeper, Skeleton, Pig, Chicken } from '../entities/mobs.js';
+import { mobAI } from '../entities/mob-ai.js';
+import { explodeAt } from '../world/tnt.js';
+import { sendPlayerPosition } from '../multiplayer/socket-helpers.js';
+import { renderMinimap } from '../ui/minimap.js';
+import { rebuildHotbar } from '../ui/hotbar.js';
+import { mode } from '../game/modes.js';
+import { mouse } from '../input/mouse-handler.js';
+import { drawFire } from '../render/draw-fire.js';
+
+// ==================== LOOP ====================
+let last = performance.now();
+let prevJump = false;
+// При возврате на вкладку — сбрасываем last чтобы не было скачка dt
+document.addEventListener('visibilitychange', () => {
+ if (!document.hidden) last = performance.now();
+});
+
+
+
+export function loop(now) {
+ const rawDt = Math.min(0.05, (now - last) / 1000);
+ last = now;
+ // Sub-stepping: разбиваем физику на шаги ≤16ms чтобы не проваливаться сквозь блоки
+ const PHYSICS_STEP = 0.016;
+ const steps = Math.max(1, Math.ceil(rawDt / PHYSICS_STEP));
+ const dt = rawDt / steps;
+
+ const player = state.player;
+ const inv = state.inv;
+ const inp = state.inp;
+ const clouds = state.clouds;
+ const mobs = state.mobs;
+ const projectiles = state.projectiles;
+ const parts = state.parts;
+ const boat = state.boat;
+
+ const jumpPressed = inp.j && !prevJump;
+ prevJump = inp.j;
+
+ // Ускорение времени во время сна
+ if (player.sleeping && isNight()) {
+ state.worldTime += dt * 8 / state.DAY_LEN; // В 8 раз быстрее
+ // Восстанавливаем здоровье во время сна
+ player.hp = Math.min(100, player.hp + dt * 20);
+ // Автоматическое пробуждение когда наступает день
+ if (!isNight()) {
+ player.sleeping = false;
+ }
+ } else {
+ state.worldTime += dt / state.DAY_LEN;
+ }
+ if (state.worldTime >= 1) state.worldTime -= 1;
+
+ // камера следует за игроком по X/Y
+ state.camX = Math.floor((player.x + player.w / 2) - state.W / 2);
+ state.camY = Math.floor((player.y + player.h / 2) - state.H / 2);
+
+ ensureGenAroundCamera();
+
+ // clouds parallax
+ for (const c of clouds) {
+ c.x -= c.s * dt;
+ if (c.x + c.w < state.camX - 400) c.x = state.camX + state.W + Math.random() * 700;
+ }
+
+ // player
+ updateWaterFlag(player);
+
+ // кислород/утопление: тратим O2 только если голова под водой, иначе восстанавливаем
+ if (player.headInWater) {
+ player.o2 = Math.max(0, player.o2 - 6 * dt); // замедлил в 3.7 раза
+ if (player.o2 === 0) {
+ const damage = calculateDamage(4 * dt);
+ player.hp -= damage;
+ }
+ } else {
+ player.o2 = Math.min(100, player.o2 + 10 * dt); // замедлил восстановление в 4 раза
+ }
+
+ // голод убывает, но HP не отнимает (как просили)
+ player.hunger = Math.max(0, player.hunger - dt * 0.2); // замедлил в 4 раза
+
+ // Игрок не может двигаться во время сна
+ if (player.sleeping) {
+ player.vx = 0;
+ player.vy = 0;
+ } else {
+ const dir = (inp.r ? 1 : 0) - (inp.l ? 1 : 0);
+ if (dir) player.vx = dir * state.MOVE;
+ else player.vx *= 0.82;
+ }
+
+ // Звук шагов при движении по земле
+ if (player.grounded && !player.inWater && Math.abs(player.vx) > 50) {
+ const stepInterval = 0.35; // Интервал между шагами в секундах
+ if (now / 1000 - player.lastStepTime > stepInterval) {
+ playSound('step');
+ player.lastStepTime = now / 1000;
+ }
+ }
+
+ // прыжок/плавание (новая логика)
+ if (player.inBoat) {
+ // Игрок в лодке - лодка следует за игроком
+ const dir = (inp.r ? 1 : 0) - (inp.l ? 1 : 0);
+ if (dir) boat.vx = dir * state.MOVE;
+ else boat.vx *= 0.95;
+
+ // Лодка плавает на воде
+ boat.vy = 0;
+
+ // Игрок следует за лодкой (сидит внутри неё)
+ player.x = boat.x + 2; // Игрок по центру лодки
+ player.y = boat.y - 4; // Игрок выше лодки (сидит внутри)
+ player.vx = boat.vx;
+ player.vy = boat.vy;
+ player.grounded = true;
+ player.inWater = false; // Игрок не в воде когда в лодке
+
+ // Прыжок из лодки (высадка)
+ if (jumpPressed) {
+ // Возвращаем лодку в инвентарь
+ inv.boat = (inv.boat || 0) + 1;
+
+ player.inBoat = false;
+ boat.active = false;
+ player.y += state.TILE; // Прыгаем из лодки
+ player.vy = -state.JUMP * 0.5;
+ playSound('splash');
+ }
+
+ } else if (player.inWater) {
+ // сопротивление в воде
+ player.vx *= 0.90;
+ player.vy *= 0.92;
+
+ // Если не нажимаем прыжок - тонем (гравитация в воде)
+ if (!jumpPressed && !inp.j) {
+ // Применяем гравитацию в воде - игрок тонет
+ player.vy += state.GRAV_WATER * dt;
+ } else {
+ // Если нажимаем прыжок - поднимаемся на поверхность
+ if (jumpPressed) {
+ player.vy = Math.min(player.vy, -520); // рывок вверх
+ } else if (inp.j) {
+ // если держим — мягкое всплытие
+ player.vy = Math.min(player.vy, -260);
+ }
+ }
+
+ } else {
+ // обычный прыжок (только по нажатию)
+ if (jumpPressed && player.grounded && !player.sleeping) {
+ player.vy = -state.JUMP;
+ player.grounded = false;
+ player.fallStartY = player.y;
+ }
+ }
+
+ // Гравитация применяется только вне воды и вне лодки
+ if (!player.inWater && !player.inBoat) {
+ player.vy += state.GRAV * dt;
+ }
+
+ // Обновляем позицию лодки
+ if (boat.active) {
+ boat.x += boat.vx * dt;
+ boat.y += boat.vy * dt;
+
+ // Лодка не выходит за пределы воды
+ const boatGX = Math.floor(boat.x / state.TILE);
+ const boatGY = Math.floor(boat.y / state.TILE);
+ const below = getBlock(boatGX, boatGY + 1);
+
+ if (!below || below.t !== 'water') {
+ // Если лодка вышла из воды - выкидываем игрока
+ inv.boat = (inv.boat || 0) + 1;
+ player.inBoat = false;
+ boat.active = false;
+ player.y += state.TILE;
+ player.vy = -200;
+ playSound('splash');
+ }
+ }
+
+ // Проверяем, не доплыл ли игрок из лодки
+ if (player.inBoat && !boat.active) {
+ inv.boat = (inv.boat || 0) + 1;
+ player.inBoat = false;
+ player.y += state.TILE;
+ player.vy = -200;
+ playSound('splash');
+ }
+
+ // Sub-stepped physics: применяем движение мелкими шагами
+ for (let step = 0; step < steps; step++) {
+ player.y += player.vy * dt;
+ resolveY(player);
+ player.x += player.vx * dt;
+ resolveX(player);
+ }
+
+ // Отправляем позицию на сервер (мультиплеер)
+ sendPlayerPosition();
+
+ // Обновляем физику воды
+ updateWaterPhysics(dt);
+
+ // Погода и дождь
+ updateWeather(dt);
+ updateRain(dt);
+
+ player.invuln = Math.max(0, player.invuln - dt);
+
+ // Voice position update
+ state.voicePosT += dt;
+ if (state.voicePosT > 0.5 && state.voiceSocket && state.voiceSocket.connected) {
+ state.voicePosT = 0;
+ state.voiceSocket.emit('voice_pos', { x: player.x, y: player.y });
+ }
+
+ // Furnace tick
+ tickFurnaces(dt);
+
+ // Обновляем UI печи если открыта
+ if (state.currentFurnaceKey && Math.random() < 0.1) {
+ renderFurnaceUI();
+ }
+
+ // Projectile tick (стрелы)
+ for (let i = projectiles.length - 1; i >= 0; i--) {
+ const p = projectiles[i];
+ p.x += p.vx * dt;
+ p.y += p.vy * dt;
+ p.vy += 400 * dt; // гравитация
+ p.life -= dt;
+
+ // Столкновение с блоком
+ const gx = Math.floor(p.x / state.TILE);
+ const gy = Math.floor(p.y / state.TILE);
+ const blk = getBlock(gx, gy);
+ if (blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid) {
+ // Врезался в стену
+ if (p.owner === 'player' && Math.random() < 0.5) inv.arrow++; // подбираем ~50%
+ projectiles.splice(i, 1);
+ continue;
+ }
+
+ // Столкновение с сущностью
+ if (p.owner === 'mob') {
+ // Попал в игрока
+ if (p.x > player.x && p.x < player.x + player.w && p.y > player.y && p.y < player.y + player.h) {
+ if (player.invuln <= 0) {
+ player.hp -= calculateDamage(p.dmg);
+ player.invuln = 0.4;
+ player.vx += p.vx * 0.3;
+ player.vy -= 150;
+ playSound('hit1');
+ }
+ projectiles.splice(i, 1);
+ continue;
+ }
+ } else {
+ // Попал в моба — server mobs first in multiplayer
+ let hitMob = false;
+ if (state.isMultiplayer) {
+ for (const [id, sm] of state.serverMobs) {
+ if (sm.dead) continue;
+ if (p.x > sm.x && p.x < sm.x + sm.w && p.y > sm.y && p.y < sm.y + sm.h) {
+ state.socket.emit('mob_arrow_hit', { id: sm.id, dmg: p.dmg, vx: p.vx });
+ projectiles.splice(i, 1);
+ hitMob = true;
+ break;
+ }
+ }
+ }
+ if (!hitMob) {
+ // Local mobs
+ for (let j = mobs.length - 1; j >= 0; j--) {
+ const m = mobs[j];
+ if (p.x > m.x && p.x < m.x + m.w && p.y > m.y && p.y < m.y + m.h) {
+ m.hp -= p.dmg;
+ m.vx += p.vx * 0.2;
+ m.vy -= 200;
+ if (m.hp <= 0) {
+ inv.meat += (m.kind === 'chicken' ? 1 : 2);
+ if (m.kind === 'skeleton') {
+ inv.arrow += 2 + Math.floor(Math.random() * 3);
+ if (Math.random() < 0.15) inv.bow = (inv.bow || 0) + 1;
+ }
+ mobs.splice(j, 1);
+ rebuildHotbar();
+ }
+ projectiles.splice(i, 1);
+ break;
+ }
+ }
+ }
+ }
+
+ // Таймаут
+ if (p.life <= 0) projectiles.splice(i, 1);
+ }
+
+ // TNT tick
+ for (const key of Array.from(state.activeTNT)) {
+ const b = state.grid.get(key);
+ if (!b || b.dead) { state.activeTNT.delete(key); continue; }
+ b.fuse -= dt;
+ if (b.fuse <= 0) {
+ explodeAt(b.gx, b.gy);
+ }
+ }
+
+ // mobs spawn (с обеих сторон камеры) — только в одиночном режиме
+ state.spawnT += dt;
+ if (!state.isMultiplayer && state.spawnT > 1.8 && mobs.length < 30) {
+ state.spawnT = 0;
+
+ // Выбираем сторону спавна (левая или правая)
+ const spawnLeft = Math.random() < 0.5;
+ const gx = spawnLeft
+ ? Math.floor((state.camX - 200) / state.TILE)
+ : Math.floor((state.camX + state.W + 200) / state.TILE);
+
+ genColumn(gx);
+ const sgy = surfaceGyAt(gx);
+ const wx = gx * state.TILE + 4;
+ const wy = (sgy - 2) * state.TILE;
+
+ // не спавнить в воде
+ const top = getBlock(gx, sgy);
+ if (top && top.t === 'water') {
+ // skip
+ } else {
+ const night = isNight();
+ if (night) {
+ // Ночью спавним враждебных мобов (максимум 12 хостайл)
+ const hostileCount = mobs.filter(m => m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton').length;
+ if (hostileCount < 12) {
+ const rand = Math.random();
+ if (rand < 0.35) {
+ mobs.push(new Zombie(wx, wy));
+ } else if (rand < 0.55) {
+ mobs.push(new Creeper(wx, wy));
+ } else {
+ mobs.push(new Skeleton(wx, wy));
+ }
+ }
+ }
+ // Животные спавнятся и днём и ночью (с лимитом)
+ const animalCount = mobs.filter(m => m.kind === 'pig' || m.kind === 'chicken').length;
+ if (animalCount < 8) {
+ mobs.push(Math.random() < 0.5 ? new Pig(wx, wy) : new Chicken(wx, wy));
+ }
+ }
+ }
+
+ // mobs update — только локальные (singleplayer)
+ if (!state.isMultiplayer) {
+ for (let i = mobs.length - 1; i >= 0; i--) {
+ const m = mobs[i];
+ mobAI(m, dt);
+ if (m.hp <= 0) mobs.splice(i, 1);
+ }
+ }
+
+ // particles
+ for (let i = parts.length - 1; i >= 0; i--) {
+ const p = parts[i];
+ p.t -= dt;
+ p.x += p.vx * dt;
+ p.y += p.vy * dt;
+ p.vy += state.GRAV * dt;
+ if (p.t <= 0) parts.splice(i, 1);
+ }
+
+ // death
+ if (player.hp <= 0) {
+ state.deathEl.style.display = 'flex';
+ } else if (state.deathEl.style.display === 'flex') {
+ // Если HP > 0 но экран смерти всё ещё показан - скрываем его
+ state.deathEl.style.display = 'none';
+ }
+
+ // ==================== RENDER ====================
+ const ctx = state.ctx;
+ const W = state.W;
+ const H = state.H;
+ const camX = state.camX;
+ const camY = state.camY;
+ const TILE = state.TILE;
+ const tex = state.tex;
+ const night = isNight();
+
+ // sky
+ ctx.fillStyle = night ? '#070816' : (state.isRaining ? '#6B7B8D' : '#87CEEB');
+ ctx.fillRect(0, 0, W, H);
+
+ // clouds (parallax x/y)
+ ctx.save();
+ ctx.translate(-camX * 0.5, -camY * 0.15);
+ ctx.fillStyle = 'rgba(255,255,255,0.65)';
+ for (const c of clouds) {
+ ctx.fillRect(c.x, c.y, c.w, 26);
+ ctx.fillRect(c.x + 20, c.y - 10, c.w * 0.6, 22);
+ }
+ ctx.restore();
+
+ // world
+ ctx.save();
+ ctx.translate(-camX, -camY);
+
+ const minGX = Math.floor(camX / TILE) - 2;
+ const maxGX = Math.floor((camX + W) / TILE) + 2;
+ const minGY = Math.floor(camY / TILE) - 6;
+ const maxGY = Math.floor((camY + H) / TILE) + 6;
+ const blocks = state.blocks;
+
+ // draw blocks (по массиву, но фильтруем диапазоном)
+ for (const b of blocks) {
+ if (b.dead) continue;
+ if (b.gx < minGX || b.gx > maxGX || b.gy < minGY || b.gy > maxGY) continue;
+
+ const def = BLOCKS[b.t];
+ if (def.alpha) {
+ ctx.save();
+ ctx.globalAlpha = def.alpha;
+ ctx.drawImage(tex[b.t], b.gx * TILE, b.gy * TILE, TILE, TILE);
+ ctx.restore();
+ } else {
+ ctx.drawImage(tex[b.t], b.gx * TILE, b.gy * TILE, TILE, TILE);
+ }
+
+ // TNT мигает, если активирован
+ if (b.t === 'tnt' && b.active && Math.sin(now / 60) > 0) {
+ ctx.fillStyle = 'rgba(255,255,255,0.45)';
+ ctx.fillRect(b.gx * TILE, b.gy * TILE, TILE, TILE);
+ }
+
+ // огонь костра
+ if (b.t === 'campfire') {
+ drawFire(ctx, b.gx * TILE, b.gy * TILE, now);
+ }
+ // Печь — огонь когда обжигает
+ if (b.t === 'furnace' && state.activeFurnaces.has(`${b.gx},${b.gy}`)) {
+ drawFire(ctx, b.gx * TILE + 8, b.gy * TILE + 5, now);
+ }
+ }
+
+ // mobs
+ const allMobsRender = state.isMultiplayer ? Array.from(state.serverMobs.values()) : mobs;
+ for (const m of allMobsRender) {
+ if (m.kind === 'zombie') {
+ ctx.fillStyle = '#2ecc71';
+ ctx.fillRect(m.x, m.y, m.w, m.h);
+ ctx.fillStyle = '#c0392b';
+ ctx.fillRect(m.x + 6, m.y + 12, 6, 6);
+ ctx.fillRect(m.x + 22, m.y + 12, 6, 6);
+ } else if (m.kind === 'pig') {
+ ctx.fillStyle = '#ffb6c1';
+ ctx.fillRect(m.x, m.y, m.w, m.h);
+ ctx.fillStyle = '#000';
+ ctx.fillRect(m.x + 22, m.y + 5, 3, 3);
+ ctx.fillStyle = '#ff69b4';
+ ctx.fillRect(m.x + 28, m.y + 12, 6, 6);
+ } else if (m.kind === 'chicken') {
+ // chicken
+ ctx.fillStyle = '#ecf0f1';
+ ctx.fillRect(m.x, m.y, m.w, m.h);
+ ctx.fillStyle = '#f39c12';
+ ctx.fillRect(m.x + 18, m.y + 10, 6, 4);
+ ctx.fillStyle = '#000';
+ ctx.fillRect(m.x + 8, m.y + 6, 3, 3);
+ } else if (m.kind === 'creeper') {
+ // creeper
+ ctx.fillStyle = '#4CAF50';
+ ctx.fillRect(m.x, m.y, m.w, m.h);
+ // Глаза
+ ctx.fillStyle = '#000';
+ ctx.fillRect(m.x + 8, m.y + 8, 4, 4);
+ ctx.fillRect(m.x + 22, m.y + 8, 4, 4);
+ // Рот
+ ctx.fillStyle = '#000';
+ ctx.fillRect(m.x + 12, m.y + 20, 10, 4);
+ // Ноги
+ ctx.fillStyle = '#4CAF50';
+ ctx.fillRect(m.x + 4, m.y + 30, 6, 20);
+ ctx.fillRect(m.x + 24, m.y + 30, 6, 20);
+ } else if (m.kind === 'skeleton') {
+ // skeleton - детализированный
+ ctx.fillStyle = '#ECEFF1';
+ ctx.fillRect(m.x + 10, m.y + 20, 14, 12);
+ ctx.fillRect(m.x + 8, m.y + 0, 18, 18);
+ ctx.fillStyle = '#000';
+ ctx.fillRect(m.x + 10, m.y + 6, 4, 4);
+ ctx.fillRect(m.x + 20, m.y + 6, 4, 4);
+ ctx.fillRect(m.x + 15, m.y + 12, 4, 2);
+ ctx.fillStyle = '#ECEFF1';
+ ctx.fillRect(m.x + 2, m.y + 20, 6, 14);
+ ctx.fillRect(m.x + 26, m.y + 20, 6, 14);
+ ctx.fillRect(m.x + 10, m.y + 32, 6, 18);
+ ctx.fillRect(m.x + 18, m.y + 32, 6, 18);
+ // Лук в руке
+ ctx.save();
+ ctx.translate(m.x + 30, m.y + 22);
+ ctx.strokeStyle = '#8B4513';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.arc(0, 0, 8, -Math.PI * 0.7, Math.PI * 0.7);
+ ctx.stroke();
+ ctx.strokeStyle = '#ccc';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(8 * Math.cos(-Math.PI * 0.7), 8 * Math.sin(-Math.PI * 0.7));
+ ctx.lineTo(8 * Math.cos(Math.PI * 0.7), 8 * Math.sin(Math.PI * 0.7));
+ ctx.stroke();
+ ctx.restore();
+ }
+ }
+
+ // boat (рисуем первой, чтобы игрок был внутри неё)
+ if (boat.active) {
+ ctx.drawImage(tex['boat'], boat.x - (TILE - boat.w) / 2, boat.y - (TILE - boat.h) / 2, TILE, TILE);
+ }
+
+ // other players (multiplayer)
+ for (const [socketId, p] of state.otherPlayers) {
+ if (state.heroImg.complete) {
+ ctx.drawImage(state.heroImg, p.x - (TILE - player.w) / 2, p.y - (TILE - player.h) / 2, TILE, TILE);
+ } else {
+ ctx.fillStyle = p.color;
+ ctx.fillRect(p.x, p.y, 34, 34);
+ }
+ ctx.fillStyle = '#fff';
+ ctx.font = '12px system-ui';
+ ctx.textAlign = 'center';
+ ctx.fillText(p.name, p.x + 17, p.y - 8);
+ }
+
+ // player
+ if (state.heroImg.complete) {
+ ctx.drawImage(state.heroImg, player.x - (TILE - player.w) / 2, player.y - (TILE - player.h) / 2, TILE, TILE);
+ } else {
+ ctx.fillStyle = '#fff';
+ ctx.fillRect(player.x, player.y, player.w, player.h);
+ }
+
+ // projectiles (стрелы)
+ for (const p of projectiles) {
+ const angle = Math.atan2(p.vy, p.vx);
+ ctx.save();
+ ctx.translate(p.x, p.y);
+ ctx.rotate(angle);
+ ctx.fillStyle = p.owner === 'mob' ? '#c0392b' : '#f1c40f';
+ ctx.fillRect(-12, -1.5, 24, 3);
+ ctx.beginPath();
+ ctx.moveTo(12, -4);
+ ctx.lineTo(16, 0);
+ ctx.lineTo(12, 4);
+ ctx.closePath();
+ ctx.fill();
+ ctx.fillStyle = '#888';
+ ctx.fillRect(-12, -3, 4, 2);
+ ctx.fillRect(-12, 1, 4, 2);
+ ctx.restore();
+ }
+
+ // particles
+ for (const p of parts) {
+ ctx.fillStyle = p.c;
+ ctx.fillRect(p.x - 2, p.y - 2, 4, 4);
+ }
+
+ // Стрелы скелета
+ for (const m of mobs) {
+ if (m.kind === 'skeleton' && m.shootCooldown > 0.5) {
+ const arrowX = m.x + m.w / 2;
+ const arrowY = m.y + 15;
+ const targetX = player.x + player.w / 2;
+ const targetY = player.y + player.h / 2;
+ const angle = Math.atan2(targetY - arrowY, targetX - arrowX);
+ const dx = targetX - arrowX;
+ const dy = targetY - arrowY;
+ const dist = Math.hypot(dx, dy);
+
+ ctx.save();
+ ctx.translate(arrowX, arrowY);
+ ctx.rotate(angle);
+ ctx.fillStyle = '#ECEFF1';
+ ctx.fillRect(0, -1, 16, 2);
+ ctx.restore();
+
+ if (dist < 150 && player.invuln <= 0) {
+ player.hp -= 8;
+ player.invuln = 0.5;
+ player.vx += Math.cos(angle) * 300;
+ player.vy -= 200;
+ playSound('hit1');
+ }
+ }
+ }
+
+ // build ghost
+ if (mode() === 'build' && mouse.x !== null && !state.craftOpen && player.hp > 0) {
+ const wx = mouse.x + camX;
+ const wy = mouse.y + camY;
+ const ggx = Math.floor(wx / TILE);
+ const ggy = Math.floor(wy / TILE);
+ ctx.strokeStyle = 'rgba(255,255,255,0.9)';
+ ctx.lineWidth = 2;
+ ctx.strokeRect(ggx * TILE, ggy * TILE, TILE, TILE);
+ }
+
+ ctx.restore();
+
+ // lighting overlay at night (Minecraft-style: offscreen lightmap, wall occlusion, warm flicker)
+ if (night) {
+ const lightC = state.lightC;
+ const lightCtx = state.lightCtx;
+
+ lightC.width = W * state.dpr;
+ lightC.height = H * state.dpr;
+ lightCtx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0);
+ lightCtx.fillStyle = 'rgba(0,0,12,0.82)';
+ lightCtx.fillRect(0, 0, W, H);
+
+ lightCtx.globalCompositeOperation = 'destination-out';
+
+ function castLight(sx, sy, radius) {
+ const flick = 0.88 + Math.sin(now / 80 + sx * 0.01) * 0.06 + Math.sin(now / 130 + sy * 0.02) * 0.06;
+ const r = radius * flick;
+ const steps2 = 12;
+ const dists = new Float32Array(steps2);
+ for (let i = 0; i < steps2; i++) {
+ const ang = (i / steps2) * Math.PI * 2;
+ const ddx = Math.cos(ang);
+ const ddy = Math.sin(ang);
+ let maxDist = r;
+ for (let step = TILE * 0.5; step < r; step += TILE * 0.6) {
+ const lgx = Math.floor((sx + ddx * step) / TILE);
+ const lgy = Math.floor((sy + ddy * step) / TILE);
+ const blk = getBlock(lgx, lgy);
+ if (blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid && step > TILE * 0.3) {
+ maxDist = step;
+ break;
+ }
+ }
+ dists[i] = maxDist;
+ }
+ const cx = sx - camX, cy = sy - camY;
+ const maxR = Math.max(...dists);
+ const grad = lightCtx.createRadialGradient(cx, cy, 0, cx, cy, maxR);
+ grad.addColorStop(0, 'rgba(255,255,255,1)');
+ grad.addColorStop(0.5, 'rgba(255,255,255,0.65)');
+ grad.addColorStop(1, 'rgba(255,255,255,0)');
+ lightCtx.fillStyle = grad;
+ lightCtx.beginPath();
+ for (let i = 0; i <= steps2; i++) {
+ const idx = i % steps2;
+ const ang = (idx / steps2) * Math.PI * 2;
+ const px = cx + Math.cos(ang) * dists[idx];
+ const py = cy + Math.sin(ang) * dists[idx];
+ if (i === 0) lightCtx.moveTo(px, py);
+ else lightCtx.lineTo(px, py);
+ }
+ lightCtx.closePath();
+ lightCtx.fill();
+ }
+
+ for (const b of blocks) {
+ if (b.dead) continue;
+ if (b.gx < minGX - 5 || b.gx > maxGX + 5 || b.gy < minGY - 5 || b.gy > maxGY + 5) continue;
+ const def = BLOCKS[b.t];
+ if (def.lightRadius) {
+ castLight(b.gx * TILE + TILE / 2, b.gy * TILE + TILE / 2, def.lightRadius);
+ }
+ }
+
+ lightCtx.globalCompositeOperation = 'source-over';
+ ctx.drawImage(lightC, 0, 0, W, H);
+
+ ctx.save();
+ ctx.globalCompositeOperation = 'lighter';
+ for (const b of blocks) {
+ if (b.dead) continue;
+ if (b.gx < minGX - 3 || b.gx > maxGX + 3 || b.gy < minGY - 3 || b.gy > maxGY + 3) continue;
+ const def = BLOCKS[b.t];
+ if (def.lightRadius) {
+ const flick = 0.7 + Math.sin(now / 90 + b.gx * 3.7) * 0.15 + Math.sin(now / 140 + b.gy * 2.3) * 0.15;
+ const wx = b.gx * TILE + TILE / 2 - camX;
+ const wy = b.gy * TILE + TILE / 2 - camY;
+ const r = def.lightRadius * 0.6 * flick;
+ const grad = ctx.createRadialGradient(wx, wy, 0, wx, wy, r);
+ grad.addColorStop(0, `rgba(255,180,80,${0.12 * flick})`);
+ grad.addColorStop(0.5, `rgba(255,140,40,${0.06 * flick})`);
+ grad.addColorStop(1, 'rgba(255,100,20,0)');
+ ctx.fillStyle = grad;
+ ctx.beginPath();
+ ctx.arc(wx, wy, r, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ }
+ ctx.restore();
+ }
+
+ // Дождь (после ночного оверлея)
+ drawRain();
+ if (Math.random() < 0.25) {
+ state.hpEl.textContent = Math.max(0, Math.ceil(player.hp));
+ state.foodEl.textContent = Math.ceil(player.hunger);
+ document.getElementById('o2').textContent = Math.ceil(player.o2);
+ state.sxEl.textContent = Math.floor(player.x / TILE);
+ state.syEl.textContent = Math.floor(player.y / TILE);
+ state.todEl.textContent = night ? 'Ночь' : 'День';
+ state.worldIdEl.textContent = state.worldId;
+ if (state.isMultiplayer) {
+ document.getElementById('multiplayerStatus').style.display = 'flex';
+ state.playerCountEl.textContent = state.otherPlayers.size + 1;
+ } else {
+ document.getElementById('multiplayerStatus').style.display = 'none';
+ }
+ }
+
+ // Индикатор сна
+ if (player.sleeping) {
+ ctx.fillStyle = 'rgba(0,0,0,0.7)';
+ ctx.fillRect(0, 0, W, H);
+ ctx.fillStyle = '#fff';
+ ctx.font = 'bold 32px system-ui';
+ ctx.textAlign = 'center';
+ ctx.fillText('💤 Спим...', W / 2, H / 2);
+ ctx.font = '18px system-ui';
+ ctx.fillText('Нажмите на кровать чтобы проснуться', W / 2, H / 2 + 40);
+ }
+
+ // Миникарта (обновляем раз в ~4 кадра для оптимизации)
+ if (state.minimapOpen && Math.random() < 0.25) {
+ renderMinimap();
+ }
+
+ requestAnimationFrame(loop);
+}
\ No newline at end of file
diff --git a/src/game/modes.js b/src/game/modes.js
new file mode 100644
index 0000000..90d09f9
--- /dev/null
+++ b/src/game/modes.js
@@ -0,0 +1,20 @@
+// ==================== РЕЖИМЫ ====================
+import { state } from '../core/state.js';
+import { playSound } from '../audio/sound-engine.js';
+
+const MODES = [{ id: 'mine', icon: '⛏️' }, { id: 'build', icon: '🧱' }];
+
+export { MODES };
+
+export function mode() {
+ return MODES[state.modeIdx].id;
+}
+
+export function initModes() {
+ const modeBtn = document.getElementById('modeBtn');
+ modeBtn.onclick = () => {
+ playSound('click'); // Звук клика по кнопке режима
+ state.modeIdx = (state.modeIdx + 1) % MODES.length;
+ modeBtn.textContent = MODES[state.modeIdx].icon;
+ };
+}
\ No newline at end of file
diff --git a/src/game/save.js b/src/game/save.js
new file mode 100644
index 0000000..fd788ae
--- /dev/null
+++ b/src/game/save.js
@@ -0,0 +1,187 @@
+// ==================== СИСТЕМА СОХРАНЕНИЯ ИГРЫ ====================
+import { state } from '../core/state.js';
+import { getBlock, setBlock, removeBlock } from '../world/world-storage.js';
+import { regenerateVisibleChunks } from '../world/generation.js';
+import { rebuildHotbar } from '../ui/hotbar.js';
+
+const SAVE_KEY = state.SAVE_KEY;
+let db = null; // Оставляем для совместимости, но не используем
+
+// Инициализация (localStorage + in-memory fallback)
+export function initDB() {
+ return new Promise((resolve) => {
+ console.log('Используем localStorage для сохранений (sandbox режим)');
+ resolve(null);
+ });
+}
+
+export function saveGame() {
+ const saveData = {
+ version: 2,
+ worldSeed: state.worldSeed,
+ player: {
+ x: state.player.x,
+ y: state.player.y,
+ hp: state.player.hp,
+ hunger: state.player.hunger,
+ o2: state.player.o2
+ },
+ inventory: state.inv,
+ time: state.worldTime,
+ isNight: state.isNightTime,
+ // Сохраняем только изменения
+ placedBlocks: state.placedBlocks.slice(),
+ removedBlocks: state.removedBlocks.slice()
+ };
+
+ const saveSize = JSON.stringify(saveData).length;
+ console.log('Сохранение: player HP:', state.player.hp, 'hunger:', state.player.hunger, 'o2:', state.player.o2);
+
+ // Пробуем сохранить в localStorage (основной метод для персистентности)
+ try {
+ localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
+ console.log(`Игра сохранена в localStorage (размер: ${saveSize} байт)`);
+ } catch (e) {
+ console.warn('Ошибка сохранения в localStorage, используем только in-memory:', e);
+
+ // Если localStorage недоступен, используем in-memory fallback
+ state.inMemorySave = saveData;
+ console.log(`Игра сохранена в памяти (размер: ${saveSize} байт)`);
+ }
+}
+
+export function loadGame() {
+ return new Promise((resolve, reject) => {
+ // Пробуем localStorage
+ try {
+ const localSave = localStorage.getItem(SAVE_KEY);
+ if (localSave) {
+ const parsed = JSON.parse(localSave);
+ console.log('Загружено из localStorage, player HP:', parsed.player?.hp);
+ resolve(parsed);
+ return;
+ }
+ } catch (e) {
+ console.warn('Ошибка доступа к localStorage:', e);
+ }
+
+ // Если localStorage недоступен, используем in-memory сохранение
+ if (state.inMemorySave) {
+ console.log('Загружено из in-memory сохранения, player HP:', state.inMemorySave.player?.hp);
+ resolve(state.inMemorySave);
+ return;
+ }
+
+ console.log('Сохранение не найдено');
+ resolve(null);
+ });
+}
+
+// Миграция с версии 1 на версию 2
+export function migrateV1toV2(saveData) {
+ console.log('Миграция сохранения с версии 1 на версию 2...');
+
+ // Сохраняем seed из текущей игры (так как v1 его не хранил)
+ saveData.worldSeed = state.worldSeed;
+
+ // Инициализируем массивы изменений
+ saveData.placedBlocks = [];
+ saveData.removedBlocks = [];
+
+ // Для v1 нам нужно сравнить сгенерированные блоки с тем, что должно быть по seed
+ // Это сложно сделать без полной перегенерации, поэтому для v1 сохраняем только seed
+ // и при загрузке просто перегенерируем мир
+
+ // Удаляем старые данные
+ delete saveData.generatedBlocks;
+
+ saveData.version = 2;
+ console.log('Миграция завершена');
+}
+
+export async function applySave(saveData) {
+ if (!saveData) return;
+
+ console.log('=== applySave START ===');
+ console.log('player HP before applySave:', state.player.hp);
+ console.log('saveData.player.hp:', saveData.player?.hp);
+
+ // Миграция версий
+ if (saveData.version === 1) {
+ migrateV1toV2(saveData);
+ }
+
+ // Восстанавливаем seed
+ if (saveData.worldSeed !== undefined) {
+ state.worldSeed = saveData.worldSeed;
+ }
+
+ // Восстанавливаем игрока
+ if (saveData.player) {
+ state.player.x = saveData.player.x;
+ state.player.y = saveData.player.y;
+ state.player.hunger = saveData.player.hunger;
+ state.player.o2 = saveData.player.o2;
+
+ // Обновляем spawnPoint на позицию из сохранения
+ state.spawnPoint.x = state.player.x;
+ state.spawnPoint.y = state.player.y;
+
+ // Проверяем HP из сохранения - если <= 0, устанавливаем 100
+ const savedHP = saveData.player.hp;
+ console.log('Saved HP from file:', savedHP);
+ if (savedHP <= 0) {
+ console.log('WARNING: Saved HP is <= 0, setting to 100!');
+ state.player.hp = 100;
+ } else {
+ state.player.hp = savedHP;
+ }
+ console.log('player HP after restore:', state.player.hp);
+ console.log('spawnPoint обновлён из сохранения: x=', state.spawnPoint.x, 'y=', state.spawnPoint.y);
+ } else {
+ console.log('No player data in save, setting default HP: 100');
+ state.player.hp = 100;
+ }
+
+ console.log('=== applySave END ===');
+
+ // Восстанавливаем инвентарь
+ if (saveData.inventory) {
+ for (const key in saveData.inventory) {
+ state.inv[key] = saveData.inventory[key];
+ }
+ }
+
+ // Восстанавливаем время
+ if (saveData.time !== undefined) {
+ state.worldTime = saveData.time;
+ }
+
+ // Восстанавливаем день/ночь
+ if (saveData.isNight !== undefined) {
+ state.isNightTime = saveData.isNight;
+ }
+
+ // Перегенерируем мир по seed
+ regenerateVisibleChunks();
+
+ // Применяем изменения (только для v2)
+ if (saveData.version === 2) {
+ // Применяем блоки, установленные игроком
+ for (const block of saveData.placedBlocks) {
+ setBlock(block.gx, block.gy, block.t, true);
+ }
+
+ // Применяем удалённые блоки
+ for (const block of saveData.removedBlocks) {
+ removeBlock(block.gx, block.gy);
+ }
+
+ // Восстанавливаем массивы изменений
+ state.placedBlocks = saveData.placedBlocks || [];
+ state.removedBlocks = saveData.removedBlocks || [];
+ }
+
+ rebuildHotbar();
+ console.log('Игра загружена');
+}
\ No newline at end of file
diff --git a/src/input/controls.js b/src/input/controls.js
new file mode 100644
index 0000000..c9a9614
--- /dev/null
+++ b/src/input/controls.js
@@ -0,0 +1,37 @@
+// ==================== УПРАВЛЕНИЕ ====================
+import { state } from '../core/state.js';
+
+export const inp = state.inp;
+
+export function bindHold(el, key) {
+ const down = (e) => { e.preventDefault(); state.inp[key] = true; };
+ const up = (e) => { e.preventDefault(); state.inp[key] = false; };
+ el.addEventListener('pointerdown', down);
+ el.addEventListener('pointerup', up);
+ el.addEventListener('pointerleave', up);
+}
+
+export function initControls() {
+ const leftBtn = document.getElementById('left');
+ const rightBtn = document.getElementById('right');
+ const jumpBtn = document.getElementById('jump');
+ const downBtn = document.getElementById('down');
+
+ if (leftBtn) bindHold(leftBtn, 'l');
+ if (rightBtn) bindHold(rightBtn, 'r');
+ if (jumpBtn) bindHold(jumpBtn, 'j');
+ if (downBtn) bindHold(downBtn, 's');
+
+ window.addEventListener('keydown', (e) => {
+ if (e.code === 'KeyA' || e.code === 'ArrowLeft') state.inp.l = true;
+ if (e.code === 'KeyD' || e.code === 'ArrowRight') state.inp.r = true;
+ if (e.code === 'Space' || e.code === 'KeyW' || e.code === 'ArrowUp') state.inp.j = true;
+ if (e.code === 'KeyS' || e.code === 'ArrowDown') state.inp.s = true;
+ });
+ window.addEventListener('keyup', (e) => {
+ if (e.code === 'KeyA' || e.code === 'ArrowLeft') state.inp.l = false;
+ if (e.code === 'KeyD' || e.code === 'ArrowRight') state.inp.r = false;
+ if (e.code === 'Space' || e.code === 'KeyW' || e.code === 'ArrowUp') state.inp.j = false;
+ if (e.code === 'KeyS' || e.code === 'ArrowDown') state.inp.s = false;
+ });
+}
\ No newline at end of file
diff --git a/src/input/mouse-handler.js b/src/input/mouse-handler.js
new file mode 100644
index 0000000..cfb6014
--- /dev/null
+++ b/src/input/mouse-handler.js
@@ -0,0 +1,247 @@
+// ==================== ВЗАИМОДЕЙСТВИЕ МЫШЬ/ТАП ====================
+import { state } from '../core/state.js';
+import { BLOCKS } from '../data/blocks.js';
+import { ITEMS } from '../data/items.js';
+import { TOOLS } from '../data/tools.js';
+import { getBlock, setBlock, removeBlock } from '../world/world-storage.js';
+import { playSound } from '../audio/sound-engine.js';
+import { rebuildHotbar } from '../ui/hotbar.js';
+import { mode } from '../game/modes.js';
+import { openFurnaceUI } from '../ui/furnace.js';
+import { saveGame } from '../game/save.js';
+import { useTool } from '../data/tools.js';
+import { activateTNT } from '../world/tnt.js';
+import { sendBlockChange } from '../multiplayer/socket-helpers.js';
+import { isNight } from '../entities/mob-ai.js';
+
+export const mouse = { x: null, y: null };
+
+export function initMouseHandlers() {
+ const canvas = state.canvas;
+
+ canvas.addEventListener('pointermove', (e) => {
+ const r = canvas.getBoundingClientRect();
+ mouse.x = e.clientX - r.left;
+ mouse.y = e.clientY - r.top;
+ });
+
+ canvas.addEventListener('pointerdown', (e) => {
+ if (state.craftOpen) return;
+ if (state.player.hp <= 0) return;
+
+ const r = canvas.getBoundingClientRect();
+ const sx = e.clientX - r.left;
+ const sy = e.clientY - r.top;
+
+ const wx = sx + state.camX;
+ const wy = sy + state.camY;
+
+ const gx = Math.floor(wx / state.TILE);
+ const gy = Math.floor(wy / state.TILE);
+
+ // Пробуждение: клик по любой кровати когда спишь
+ const b = getBlock(gx, gy);
+ if (state.player.sleeping && b && b.t === 'bed') {
+ state.player.sleeping = false;
+ return;
+ }
+
+ if (state.player.sleeping) return; // Нельзя взаимодействовать во время сна
+
+ // Клик по печи — открываем панель обжига
+ if (b && b.t === 'furnace' && mode() === 'mine') {
+ openFurnaceUI(gx, gy);
+ return;
+ }
+
+ // клик по мобу (в режиме mine)
+ if (mode() === 'mine') {
+ // Check server mobs first (multiplayer)
+ if (state.isMultiplayer) {
+ for (const [id, sm] of state.serverMobs) {
+ if (sm.dead) continue;
+ if (wx >= sm.x && wx <= sm.x + sm.w && wy >= sm.y && wy <= sm.y + sm.h) {
+ let dmg = 1;
+ const swordTypes = ['iron_sword', 'stone_sword', 'wood_sword'];
+ for (const st of swordTypes) {
+ if (state.inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; }
+ }
+ state.socket.emit('mob_hurt', { id: sm.id, dmg });
+ playSound('attack');
+ return;
+ }
+ }
+ }
+ // Local mobs (singleplayer or if not hit server mob)
+ for (let i = state.mobs.length - 1; i >= 0; i--) {
+ const m = state.mobs[i];
+ if (wx >= m.x && wx <= m.x + m.w && wy >= m.y && wy <= m.y + m.h) {
+ let dmg = 1;
+ const swordTypes = ['iron_sword', 'stone_sword', 'wood_sword'];
+ for (const st of swordTypes) {
+ if (state.inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; }
+ }
+ m.hp -= dmg;
+ m.vx += (m.x - state.player.x) * 2;
+ m.vy -= 200;
+ playSound('attack');
+ if (m.hp <= 0) {
+ if (m.kind === 'chicken') playSound('hurt_chicken');
+ state.inv.meat += (m.kind === 'chicken' ? 1 : 2);
+ if (m.kind === 'skeleton') {
+ state.inv.arrow += 2 + Math.floor(Math.random() * 3);
+ if (Math.random() < 0.15) state.inv.bow = (state.inv.bow || 0) + 1;
+ }
+ state.mobs.splice(i, 1);
+ rebuildHotbar();
+ }
+ return;
+ }
+ }
+ }
+
+ // Лук — стреляем стрелой
+ if (state.selected === 'bow' && state.inv.bow > 0 && state.inv.arrow > 0) {
+ const aimX = wx - state.player.x - state.player.w / 2;
+ const aimY = wy - state.player.y - state.player.h / 2;
+ const angle = Math.atan2(aimY, aimX);
+ state.projectiles.push({
+ x: state.player.x + state.player.w / 2,
+ y: state.player.y + state.player.h / 3,
+ vx: Math.cos(angle) * 550,
+ vy: Math.sin(angle) * 550,
+ dmg: 10,
+ owner: 'player',
+ life: 4
+ });
+ state.inv.arrow--;
+ useTool('bow');
+ playSound('hit1');
+ rebuildHotbar();
+ return;
+ }
+
+ // еда (предмет)
+ if (ITEMS[state.selected] && state.inv[state.selected] > 0) {
+ const it = ITEMS[state.selected];
+ if (state.player.hp < 100 || state.player.hunger < 100) {
+ playSound('eat1'); // Звук употребления еды
+ state.player.hunger = Math.min(100, state.player.hunger + it.food);
+ state.player.hp = Math.min(100, state.player.hp + 15);
+ state.inv[state.selected]--;
+ rebuildHotbar();
+ }
+ return;
+ }
+
+ // жарка на костре: выбран meat + клик по campfire
+ if (b && b.t === 'campfire' && state.selected === 'meat' && state.inv.meat > 0) {
+ playSound('fire'); // Звук при жарке на костре
+ state.inv.meat--;
+ state.inv.cooked++;
+ rebuildHotbar();
+ return;
+ }
+
+ // Сон на кровати: клик по bed
+ if (b && b.t === 'bed' && isNight()) {
+ state.player.sleeping = true;
+ saveGame(); // Сохраняем при отходе ко сну
+ return;
+ }
+
+ if (mode() === 'mine') {
+ if (!b) return;
+ if (BLOCKS[b.t].fluid || BLOCKS[b.t].decor) return;
+
+ if (b.t === 'tnt') { activateTNT(b, 3.2); return; } // не взрывается сразу
+
+ const removed = removeBlock(gx, gy);
+ if (removed) {
+ state.inv[removed.t] = (state.inv[removed.t] || 0) + 1;
+
+ // Тратим прочность кирки (если есть в инвентаре)
+ const pickTypes = ['iron_pickaxe', 'stone_pickaxe', 'wood_pickaxe'];
+ for (const pt of pickTypes) {
+ if (state.inv[pt] > 0) {
+ const broke = useTool(pt);
+ if (broke) playSound('cloth1'); // звук поломки
+ break;
+ }
+ }
+
+ // Отправляем изменение блока на сервер
+ sendBlockChange(gx, gy, removed.t, 'remove');
+
+ // Звуки при добыче блоков
+ if (removed.t === 'glass') playSound('glass1');
+ else if (removed.t === 'sand') playSound('sand1');
+ else if (removed.t === 'snow') playSound('snow1');
+ else if (removed.t === 'stone' || removed.t.endsWith('_ore')) playSound('stone1');
+ else if (removed.t === 'wood') playSound('wood1');
+ else playSound('cloth1');
+
+ rebuildHotbar();
+ }
+ return;
+ }
+
+ if (mode() === 'build') {
+ if (state.inv[state.selected] <= 0) return;
+ if (!BLOCKS[state.selected]) return;
+ if (b) return; // занято
+
+ // Проверяем, ставим ли лодку
+ if (state.selected === 'boat') {
+ // Лодку можно ставить только на воду
+ const waterBelow = getBlock(gx, gy + 1);
+ if (!waterBelow || waterBelow.t !== 'water') {
+ return;
+ }
+
+ // Создаём лодку
+ state.boat.x = gx * state.TILE;
+ state.boat.y = gy * state.TILE;
+ state.boat.vx = 0;
+ state.boat.vy = 0;
+ state.boat.active = true;
+ state.boat.inWater = true;
+
+ // Сажаем игрока в лодку
+ state.player.inBoat = true;
+ state.player.x = state.boat.x;
+ state.player.y = state.boat.y;
+ state.player.vx = 0;
+ state.player.vy = 0;
+
+ playSound('splash');
+ state.inv[state.selected]--;
+ rebuildHotbar();
+ return;
+ }
+
+ // запрет ставить в игрока
+ const TILE = state.TILE;
+ const bx = gx * TILE, by = gy * TILE;
+ const overlap = !(bx >= state.player.x + state.player.w || bx + TILE <= state.player.x || by >= state.player.y + state.player.h || by + TILE <= state.player.y);
+ if (overlap) return;
+
+ setBlock(gx, gy, state.selected, true); // true = блок установлен игроком
+ state.inv[state.selected]--;
+
+ // Отправляем изменение блока на сервер
+ sendBlockChange(gx, gy, state.selected, 'set');
+
+ // Звук при строительстве
+ if (state.selected === 'stone' || state.selected === 'brick') playSound('stone_build');
+ else if (state.selected === 'wood' || state.selected === 'planks') playSound('wood_build');
+ else if (state.selected === 'glass') playSound('glass1');
+ else if (state.selected === 'sand') playSound('sand1');
+ else if (state.selected === 'snow') playSound('snow1');
+ else if (state.selected === 'dirt' || state.selected === 'grass') playSound('cloth1');
+
+ rebuildHotbar();
+ return;
+ }
+ });
+}
\ No newline at end of file
diff --git a/src/main.js b/src/main.js
new file mode 100644
index 0000000..5496d81
--- /dev/null
+++ b/src/main.js
@@ -0,0 +1,278 @@
+// ==================== ENTRY POINT ====================
+import { state } from './core/state.js';
+import { initSocket } from './multiplayer/socket.js';
+import { sendBlockChange, sendPlayerPosition } from './multiplayer/socket-helpers.js';
+import { loadSound, playSound } from './audio/sound-engine.js';
+import { BLOCKS } from './data/blocks.js';
+import { ITEMS } from './data/items.js';
+import { TOOLS } from './data/tools.js';
+import { SMELTING_RECIPES } from './data/recipes.js';
+import { initTextures } from './render/textures.js';
+import { getBlock, setBlock, removeBlock } from './world/world-storage.js';
+import { genColumn, surfaceGyAt, ensureGenAroundCamera, regenerateVisibleChunks } from './world/generation.js';
+import { initDB, loadGame, applySave, saveGame } from './game/save.js';
+import { loop } from './game/loop.js';
+import { initChat } from './ui/chat.js';
+import { initFurnace } from './ui/furnace.js';
+import { initMinimap } from './ui/minimap.js';
+import { initSaveControls, updateSaveButtonVisibility } from './ui/save-controls.js';
+import { initRespawn } from './ui/respawn.js';
+import { initShare } from './ui/share.js';
+import { initControls } from './input/controls.js';
+import { initMouseHandlers } from './input/mouse-handler.js';
+import { initModes } from './game/modes.js';
+import { initVoice } from './multiplayer/voice-chat.js';
+import { resolveY, resolveX } from './physics/collision.js';
+import { calculateDamage } from './entities/player.js';
+import { updateWaterFlag } from './physics/water-detect.js';
+import { updateWaterPhysics } from './world/water.js';
+import { explodeAt, activateTNT } from './world/tnt.js';
+import { useTool } from './data/tools.js';
+import { rebuildHotbar } from './ui/hotbar.js';
+
+// ==================== 1) CONFIG — parse URL/getName ====================
+const urlParams = new URLSearchParams(window.location.search);
+state.SERVER_URL = urlParams.get('server') || 'https://apigrech.mkn8n.ru';
+state.TELEGRAM_BOT_USERNAME = 'Grechkacraft_bot';
+state.TELEGRAM_APP_SHORT_NAME = 'minegrechka';
+
+// Защита от mixed content
+if (location.protocol === 'https:' && state.SERVER_URL.startsWith('http://')) {
+ console.warn('⚠️ Mixed content warning: page is HTTPS but server URL is HTTP');
+ alert('⚠️ Предупреждение: страница загружена по HTTPS, но сервер использует HTTP. Это может вызвать проблемы.');
+}
+
+// Player name
+state.playerName = localStorage.getItem('minegrechka_playerName') || null;
+if (!state.playerName) {
+ state.playerName = prompt('Введите ваше имя для игры:') || 'Игрок';
+ localStorage.setItem('minegrechka_playerName', state.playerName);
+ console.log('Player name set:', state.playerName);
+}
+
+// World ID from URL or generate new
+console.log('Current URL:', window.location.href);
+const worldParam = urlParams.get('world');
+console.log('world param:', worldParam);
+
+state.worldId = (worldParam && worldParam.trim() !== '') ? worldParam : null;
+console.log('worldId after params:', state.worldId, 'type:', typeof state.worldId);
+
+if (!state.worldId) {
+ state.worldId = Math.random().toString(36).substring(2, 10);
+ console.log('Generated worldId:', state.worldId);
+
+ try {
+ const newUrl = new URL(window.location.href);
+ newUrl.searchParams.set('world', state.worldId);
+ const newUrlString = newUrl.toString();
+ console.log('New URL to set:', newUrlString);
+
+ if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') {
+ window.history.replaceState(null, '', newUrlString);
+ console.log('URL after replaceState:', window.location.href);
+ } else {
+ console.error('History API not supported!');
+ }
+ } catch (e) {
+ console.error('Error updating URL:', e);
+ }
+
+ console.log('Generated new worldId for browser:', state.worldId);
+}
+
+console.log('Final worldId:', state.worldId, 'Player name:', state.playerName);
+
+// ==================== 2) CANVAS — get elements, resize ====================
+state.gameEl = document.getElementById('game');
+state.canvas = document.getElementById('c');
+state.ctx = state.canvas.getContext('2d');
+
+// offscreen light map
+state.lightC = document.createElement('canvas');
+state.lightCtx = state.lightC.getContext('2d');
+
+state.dpr = Math.max(1, window.devicePixelRatio || 1);
+
+function resize() {
+ state.W = state.gameEl.clientWidth;
+ state.H = state.gameEl.clientHeight;
+ state.canvas.width = state.W * state.dpr;
+ state.canvas.height = state.H * state.dpr;
+ state.lightC.width = state.W * state.dpr;
+ state.lightC.height = state.H * state.dpr;
+ state.ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0);
+}
+window.addEventListener('resize', resize);
+resize();
+
+// ==================== 3) STATE — init player, inv, etc ====================
+state.otherPlayers = new Map();
+state.serverMobs = new Map();
+state.grid = new Map();
+state.blocks = [];
+state.generated = new Set();
+state.serverOverrides = new Map();
+state.activeFurnaces = new Map();
+state.activeTNT = new Set();
+state.toolDurability = new Map();
+state.worldSeed = Math.floor(Math.random() * 1000000);
+
+// Clouds
+state.clouds = Array.from({ length: 10 }, () => ({
+ x: Math.random() * 2000,
+ y: -200 - Math.random() * 260,
+ w: 80 + Math.random() * 120,
+ s: 12 + Math.random() * 20
+}));
+
+// Weather
+state.weatherTimer = 0;
+state.weatherChangeInterval = 60 + Math.random() * 120;
+
+// Player spawn
+state.player.x = 6 * state.TILE;
+state.player.y = 0;
+state.spawnPoint.x = 6 * state.TILE;
+state.spawnPoint.y = 0;
+
+// Hero image
+state.heroImg = new Image();
+state.heroImg.src = 'https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png';
+
+// UI elements
+state.hpEl = document.getElementById('hp');
+state.foodEl = document.getElementById('food');
+state.sxEl = document.getElementById('sx');
+state.syEl = document.getElementById('sy');
+state.todEl = document.getElementById('tod');
+state.worldIdEl = document.getElementById('worldId');
+state.playerCountEl = document.getElementById('playerCount');
+state.deathEl = document.getElementById('death');
+
+// ==================== 4) TEXTURES — initTextures() ====================
+initTextures();
+
+// ==================== 5) AUDIO — loadSound for each ====================
+loadSound('splash', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//splash.mp3');
+loadSound('sand1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand1.mp3');
+loadSound('snow1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//snow1.mp3');
+loadSound('stone1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1.mp3');
+loadSound('wood1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1.mp3');
+loadSound('cloth1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//cloth1.mp3');
+loadSound('fire', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//fire.mp3');
+loadSound('hit1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hit1.mp3');
+loadSound('attack', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//attack.mp3');
+loadSound('hurt_chicken', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hurt1_chicken.mp3');
+loadSound('stone_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1%20(1).mp3');
+loadSound('wood_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1%20(1).mp3');
+loadSound('click', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//click.mp3');
+loadSound('explode1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//explode1.mp3');
+loadSound('glass1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//glass1.mp3');
+loadSound('eat1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//eat1.mp3');
+loadSound('step', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand2.mp3');
+
+// ==================== 6) CONTROLS — initControls ====================
+initControls();
+
+// ==================== 7) MOUSE — initMouseHandlers ====================
+initMouseHandlers();
+
+// ==================== 8) UI MODULES — init all UI ====================
+initChat();
+initFurnace();
+initMinimap();
+initSaveControls();
+initRespawn();
+initShare();
+initModes();
+
+// ==================== 9) SOCKET — initSocket ====================
+initSocket();
+
+// ==================== 10) VOICE — initVoice ====================
+initVoice();
+
+// ==================== 11) SAVE — loadGame, applySave ====================
+rebuildHotbar();
+
+initDB().then(async () => {
+ const loadedSave = await loadGame();
+ if (loadedSave) {
+ await applySave(loadedSave);
+ console.log('Загружено сохранение, HP:', state.player.hp);
+
+ if (state.player.hp <= 0) {
+ console.log('WARNING: HP <= 0 после загрузки, возрождаемся');
+ state.player.hp = 100;
+ state.player.hunger = 100;
+ state.player.o2 = 100;
+ state.player.x = state.spawnPoint.x;
+ state.player.y = state.spawnPoint.y;
+ state.player.vx = state.player.vy = 0;
+ state.player.invuln = 0;
+ state.player.fallStartY = state.player.y;
+ }
+ } else {
+ console.log('Сохранение не найдено, начинаем новую игру');
+
+ state.player.hp = 100;
+ state.player.hunger = 100;
+ state.player.o2 = 100;
+ state.player.vx = state.player.vy = 0;
+ state.player.invuln = 0;
+
+ // старт — на поверхности
+ const startGX = 6;
+ for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx);
+ const surfaceY = surfaceGyAt(startGX);
+ let safeGY = surfaceY - 1;
+ const aboveBlock = getBlock(startGX, surfaceY - 1);
+ if (aboveBlock && aboveBlock.t === 'water') {
+ for (let gy = state.SEA_GY - 1; gy >= 0; gy--) {
+ const b = getBlock(startGX, gy);
+ if (!b || b.dead || b.t === 'air' || b.t === 'water') continue;
+ safeGY = gy - 1;
+ break;
+ }
+ }
+ state.player.y = safeGY * state.TILE;
+ state.player.x = startGX * state.TILE;
+ state.player.fallStartY = state.player.y;
+
+ state.spawnPoint.x = state.player.x;
+ state.spawnPoint.y = state.player.y;
+
+ console.log('Новая игра: startGX=', startGX, 'surfaceY=', surfaceY, 'player.y=', state.player.y);
+
+ // Генерируем карту вокруг стартовой позиции
+ for (let gx = startGX - 50; gx <= startGX + 50; gx++) {
+ genColumn(gx);
+ }
+ }
+
+ // Автосейв при скрытии страницы
+ document.addEventListener('visibilitychange', () => {
+ if (document.hidden) {
+ saveGame();
+ }
+ });
+
+ // Автосейв перед закрытием страницы
+ window.addEventListener('beforeunload', () => {
+ saveGame();
+ });
+}).catch(err => {
+ console.error('Ошибка инициализации:', err);
+ const startGX = 6;
+ genColumn(startGX);
+ state.player.y = (surfaceGyAt(startGX) - 1) * state.TILE;
+ state.player.fallStartY = state.player.y;
+
+ for (let gx = startGX - 50; gx <= startGX + 50; gx++) {
+ genColumn(gx);
+ }
+});
+
+// ==================== 13) LOOP — startLoop ====================
+requestAnimationFrame(loop);
\ No newline at end of file
diff --git a/src/multiplayer/socket-helpers.js b/src/multiplayer/socket-helpers.js
new file mode 100644
index 0000000..5823d0a
--- /dev/null
+++ b/src/multiplayer/socket-helpers.js
@@ -0,0 +1,45 @@
+// Socket helpers: цвет, отправка позиции/блоков
+import { state } from '../core/state.js';
+import { SERVER_URL } from '../config.js';
+
+// Генерация случайного цвета для игрока на основе socket_id
+export function getRandomPlayerColor(socketId) {
+ const colors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e91e63', '#00bcd4'];
+ let hash = 0;
+ for (let i = 0; i < socketId.length; i++) {
+ hash = ((hash << 5) - hash) + socketId.charCodeAt(i);
+ hash = hash & hash;
+ }
+ return colors[Math.abs(hash) % colors.length];
+}
+
+// Throttle для отправки позиции (10-20 раз в секунду)
+let lastMoveSendTime = 0;
+const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду
+let lastSentX = 0, lastSentY = 0;
+
+// Отправка позиции игрока (с throttle)
+export function sendPlayerPosition() {
+ if (!state.isMultiplayer || !state.socket || !state.socket.connected) return;
+
+ const now = performance.now() / 1000;
+ if (now - lastMoveSendTime < MOVE_SEND_INTERVAL) return;
+
+ // Отправляем только если позиция изменилась
+ const dx = Math.abs(state.player.x - lastSentX);
+ const dy = Math.abs(state.player.y - lastSentY);
+ if (dx < 1 && dy < 1) return;
+
+ lastMoveSendTime = now;
+ lastSentX = state.player.x;
+ lastSentY = state.player.y;
+
+ state.socket.emit('player_move', { x: state.player.x, y: state.player.y, player_name: state.playerName });
+}
+
+// Отправка изменения блока
+export function sendBlockChange(gx, gy, t, op) {
+ if (!state.isMultiplayer || !state.socket || !state.socket.connected) return;
+
+ state.socket.emit('block_change', { gx, gy, t, op });
+}
\ No newline at end of file
diff --git a/src/multiplayer/socket.js b/src/multiplayer/socket.js
new file mode 100644
index 0000000..94bebb8
--- /dev/null
+++ b/src/multiplayer/socket.js
@@ -0,0 +1,279 @@
+// Socket.IO клиент — инициализация и все обработчики
+import { state } from '../core/state.js';
+import { SERVER_URL, worldId, playerName } from '../config.js';
+import { TILE, SEA_GY } from '../core/constants.js';
+import { BLOCKS } from '../data/blocks.js';
+import { k, setBlock, removeBlock, grid, blocks } from '../world/world-storage.js';
+import { genColumn, surfaceGyAt } from '../world/generation.js';
+import { addChatMessage } from '../ui/chat.js';
+import { calculateDamage } from '../entities/player.js';
+import { playSound } from '../audio/sound-engine.js';
+import { rebuildHotbar } from '../ui/hotbar.js';
+import { explodeAt } from '../world/tnt.js';
+import { updateSaveButtonVisibility } from '../ui/save-controls.js';
+import { getRandomPlayerColor, sendPlayerPosition, sendBlockChange } from './socket-helpers.js';
+
+let socket = null;
+
+export { socket };
+
+export function initSocket() {
+ try {
+ socket = io(SERVER_URL, {
+ path: '/socket.io/',
+ transports: ['websocket', 'polling']
+ });
+
+ socket.on('connect', () => {
+ console.log(`Connected to server: ${SERVER_URL}, socket.id: ${socket.id}, worldId: ${worldId}`);
+ state.mySocketId = socket.id;
+ state.isMultiplayer = true;
+
+ // Присоединяемся к миру
+ socket.emit('join_world', { world_id: worldId, player_name: playerName });
+
+ // Показываем в UI
+ state.worldIdEl.textContent = worldId;
+ state.multiplayerStatus.style.display = 'block';
+ });
+
+ socket.on('connect_error', (error) => {
+ console.error('Socket connection error:', error);
+ state.isMultiplayer = false;
+ });
+
+ socket.on('disconnect', () => {
+ console.log('Disconnected from server');
+ state.isMultiplayer = false;
+ state.otherPlayers.clear();
+ state.multiplayerStatus.style.display = 'none';
+ });
+
+ // Обработка world_state
+ socket.on('world_state', (data) => {
+ console.log('Received world_state:', data);
+
+ // Устанавливаем seed и перегенерируем мир если он изменился
+ if (data.seed !== undefined && data.seed !== state.worldSeed) {
+ const oldSeed = state.worldSeed;
+ state.worldSeed = data.seed;
+ console.log('World seed changed from', oldSeed, 'to', state.worldSeed);
+
+ // Очищаем и перегенерируем мир с новым seed
+ state.generated.clear();
+ grid.clear();
+ blocks.length = 0;
+ state.placedBlocks = [];
+ state.removedBlocks = [];
+ console.log('World regenerated with new seed:', state.worldSeed);
+ }
+
+ // Применяем блоки — сохраняем в state.serverOverrides для применения после genColumn
+ if (data.blocks && Array.isArray(data.blocks)) {
+ for (const block of data.blocks) {
+ const key = k(block.gx, block.gy);
+ state.serverOverrides.set(key, { op: block.op, t: block.t });
+ // Также пробуем применить сразу (если колонна уже сгенерирована)
+ if (block.op === 'set') {
+ setBlock(block.gx, block.gy, block.t, false);
+ } else if (block.op === 'remove') {
+ removeBlock(block.gx, block.gy);
+ }
+ }
+ }
+
+ // Устанавливаем время
+ if (data.time !== undefined) {
+ state.worldTime = data.time;
+ state.isNightTime = state.worldTime > 0.5;
+ }
+
+ // Всегда считаем spawnPoint на клиенте по той же формуле что и surfaceGyAt
+ // Это гарантирует совпадение с terrain generation
+ {
+ const startGX = 6;
+ // Генерируем колонну и соседние для безопасного спавна
+ for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx);
+ const surfaceY = surfaceGyAt(startGX);
+ // Ищем ближайшую небудущую позицию сверху вниз от поверхности
+ let safeGY = surfaceY - 1;
+ // Проверяем что над поверхностью воздух (не в воде)
+ const aboveBlock = getBlock(startGX, surfaceY - 1);
+ if (aboveBlock && aboveBlock.t === 'water') {
+ // Если в воде — ищем поверхность выше уровня моря
+ for (let gy = SEA_GY - 1; gy >= 0; gy--) {
+ const b = getBlock(startGX, gy);
+ if (!b || b.dead || b.t === 'air' || b.t === 'water') continue;
+ safeGY = gy - 1;
+ break;
+ }
+ }
+ state.spawnPoint.x = startGX * TILE;
+ state.spawnPoint.y = safeGY * TILE;
+ console.log('Client-side spawn point:', state.spawnPoint, 'surfaceY:', surfaceY, 'safeGY:', safeGY);
+ }
+
+ // Устанавливаем игрока в точку спавна
+ state.player.x = state.spawnPoint.x;
+ state.player.y = state.spawnPoint.y;
+ state.player.vx = 0;
+ state.player.vy = 0;
+ state.player.fallStartY = state.player.y;
+ console.log('Player moved to spawn point:', state.player.x, state.player.y);
+
+ // Устанавливаем HP на 100% при каждом подключении к миру
+ state.player.hp = 100;
+ state.player.hunger = 100;
+ state.player.o2 = 100;
+ state.player.invuln = 0;
+ console.log('[MULTIPLAYER CONNECT] Player HP set to 100% on connect');
+
+ // Обновляем список игроков
+ if (data.players && Array.isArray(data.players)) {
+ state.otherPlayers.clear();
+ for (const p of data.players) {
+ if (p.socket_id !== state.mySocketId) {
+ state.otherPlayers.set(p.socket_id, {
+ x: p.x,
+ y: p.y,
+ color: getRandomPlayerColor(p.socket_id),
+ name: p.player_name || 'Игрок'
+ });
+ }
+ }
+ // Обновляем счётчик игроков
+ state.playerCountEl.textContent = data.players.length;
+ }
+ // Server mobs
+ if (data.mobs && Array.isArray(data.mobs)) {
+ state.serverMobs.clear();
+ for (const m of data.mobs) {
+ const sm = { ...m, maxHp: m.maxHp || m.hp, vx: m.vx || 0, vy: m.vy || 0, grounded: false, inWater: false, aiT: 0, dir: m.dir || 1, dead: false, fuse: m.fuse || 0, shootCooldown: 2, speed: m.speed || 80 };
+ state.serverMobs.set(m.id, sm);
+ }
+ }
+ });
+
+ // Игрок присоединился
+ socket.on('player_joined', (data) => {
+ console.log('Player joined:', data.socket_id);
+ if (data.socket_id !== state.mySocketId) {
+ // Генерируем безопасную позицию для нового игрока
+ const spawnGX = 6;
+ genColumn(spawnGX);
+ const surfaceY = surfaceGyAt(spawnGX);
+ const safeSpawnX = spawnGX * TILE;
+ const safeSpawnY = (surfaceY - 1) * TILE;
+
+ state.otherPlayers.set(data.socket_id, {
+ x: safeSpawnX,
+ y: safeSpawnY,
+ color: getRandomPlayerColor(data.socket_id),
+ name: data.player_name || 'Игрок'
+ });
+ addChatMessage('Система', `Игрок присоединился`);
+ // Обновляем видимость кнопки сохранения
+ updateSaveButtonVisibility();
+ }
+ });
+
+ // Игрок переместился
+ socket.on('player_moved', (data) => {
+ if (data.socket_id !== state.mySocketId && state.otherPlayers.has(data.socket_id)) {
+ const p = state.otherPlayers.get(data.socket_id);
+ p.x = data.x;
+ p.y = data.y;
+ // Обновляем имя, если оно пришло
+ if (data.player_name) {
+ p.name = data.player_name;
+ }
+ }
+ });
+
+ // Игрок покинул
+ socket.on('player_left', (data) => {
+ console.log('Player left:', data.socket_id);
+ state.otherPlayers.delete(data.socket_id);
+ addChatMessage('Система', `Игрок покинул игру`);
+ // Обновляем видимость кнопки сохранения
+ updateSaveButtonVisibility();
+ });
+
+ // === MOB SYNC (multiplayer) ===
+
+ socket.on('mob_spawned', (data) => {
+ const sm = { ...data, maxHp: data.maxHp || data.hp, vx: data.vx || 0, vy: data.vy || 0, grounded: false, inWater: false, aiT: 0, dir: data.dir || 1, dead: false, fuse: data.fuse || 0, shootCooldown: 2, speed: data.speed || 80 };
+ state.serverMobs.set(data.id, sm);
+ });
+
+ socket.on('mob_positions', (arr) => {
+ for (const u of arr) {
+ const sm = state.serverMobs.get(u.id);
+ if (sm) { sm.x = u.x; sm.y = u.y; sm.vx = u.vx; sm.vy = u.vy; sm.dir = u.dir; sm.hp = u.hp; sm.fuse = u.fuse || 0; }
+ }
+ });
+
+ socket.on('mob_despawned', (data) => { state.serverMobs.delete(data.id); });
+
+ socket.on('mob_died', (data) => {
+ const sm = state.serverMobs.get(data.id);
+ if (sm && data.killer === state.mySocketId) {
+ // Give loot to the killer
+ if (sm.kind === 'chicken') playSound('hurt_chicken');
+ state.inv.meat += (sm.kind === 'chicken' ? 1 : 2);
+ if (sm.kind === 'skeleton') {
+ state.inv.arrow += 2 + Math.floor(Math.random() * 3);
+ if (Math.random() < 0.15) state.inv.bow = (state.inv.bow || 0) + 1;
+ }
+ rebuildHotbar();
+ }
+ state.serverMobs.delete(data.id);
+ });
+
+ socket.on('mob_hurt_ack', (data) => {
+ const sm = state.serverMobs.get(data.id);
+ if (sm) sm.hp = data.hp;
+ });
+
+ socket.on('mob_explode', (data) => {
+ explodeAt(data.gx, data.gy);
+ state.serverMobs.delete(data.id);
+ });
+
+ socket.on('mob_shoot', (data) => {
+ state.projectiles.push({
+ x: data.x, y: data.y, vx: data.vx, vy: data.vy,
+ dmg: data.dmg, owner: 'mob', life: data.life
+ });
+ });
+
+ // Блок изменён
+ socket.on('block_changed', (data) => {
+ const key = k(data.gx, data.gy);
+ state.serverOverrides.set(key, { op: data.op, t: data.t });
+ if (data.op === 'set') {
+ setBlock(data.gx, data.gy, data.t, false);
+ } else if (data.op === 'remove') {
+ removeBlock(data.gx, data.gy);
+ }
+ });
+
+ // Сообщение в чат
+ socket.on('chat_message', (data) => {
+ const senderName = data.socket_id === state.mySocketId ? 'Вы' : `Игрок ${data.socket_id.substring(0, 6)}`;
+ addChatMessage(senderName, data.message);
+ });
+
+ // Обновление времени
+ socket.on('time_update', (data) => {
+ if (data.time !== undefined) {
+ state.worldTime = data.time;
+ state.isNightTime = state.worldTime > 0.5;
+ }
+ });
+
+ } catch (e) {
+ console.error('Error initializing socket:', e);
+ state.isMultiplayer = false;
+ }
+}
\ No newline at end of file
diff --git a/src/multiplayer/voice-chat.js b/src/multiplayer/voice-chat.js
new file mode 100644
index 0000000..affc6b4
--- /dev/null
+++ b/src/multiplayer/voice-chat.js
@@ -0,0 +1,120 @@
+// Голосовой чат
+import { state } from '../core/state.js';
+import { worldId } from '../config.js';
+
+let voiceSocket = null;
+let voiceStream = null;
+let audioCtx = null;
+let voiceProcessor = null;
+let voiceActive = false;
+const VOICE_SERVER = 'https://voicegrech.mkn8n.ru';
+
+// Кнопка микрофона
+const voiceBtn = document.createElement('div');
+voiceBtn.innerHTML = '🎤/';
+voiceBtn.title = 'Голосовой чат (выкл)';
+voiceBtn.style.cssText = 'position:absolute;top:74px;right:130px;width:52px;height:52px;border-radius:12px;background:#555;z-index:200;display:flex;align-items:center;justify-content:center;font-size:24px;cursor:pointer;border:2px solid rgba(255,255,255,0.9);box-shadow:0 4px 0 rgba(0,0,0,0.5);pointer-events:auto;';
+document.querySelector('.ui').appendChild(voiceBtn);
+
+// Индикатор говорящего
+const speakingIndicator = document.createElement('div');
+speakingIndicator.style.cssText = 'position:absolute;top:130px;right:10px;z-index:200;display:none;background:rgba(0,0,0,0.7);color:#2ecc71;padding:4px 8px;border-radius:6px;font-size:12px;';
+speakingIndicator.textContent = '🔊';
+document.querySelector('.ui').appendChild(speakingIndicator);
+let speakingTimeout = null;
+
+voiceBtn.onclick = async () => {
+ if (voiceActive) {
+ // Выключить
+ voiceActive = false;
+ voiceBtn.innerHTML = '🎤/';
+ voiceBtn.style.background = '#555';
+ if (voiceStream) {
+ voiceStream.getTracks().forEach(t => t.stop());
+ voiceStream = null;
+ }
+ if (voiceProcessor) { voiceProcessor.disconnect(); voiceProcessor = null; }
+ if (audioCtx) { audioCtx.close(); audioCtx = null; }
+ if (voiceSocket) { voiceSocket.disconnect(); voiceSocket = null; }
+ return;
+ }
+
+ // Включить
+ try {
+ voiceStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 24000 } });
+ audioCtx = new AudioContext({ sampleRate: 24000 });
+ const source = audioCtx.createMediaStreamSource(voiceStream);
+ voiceProcessor = audioCtx.createScriptProcessor(4096, 1, 1);
+
+ voiceProcessor.onaudioprocess = (e) => {
+ if (!voiceActive || !voiceSocket || !voiceSocket.connected) return;
+ const pcm = e.inputBuffer.getChannelData(0);
+ // Конвертируем float32 → int16 для экономии трафика
+ const int16 = new Int16Array(pcm.length);
+ for (let i = 0; i < pcm.length; i++) {
+ const s = Math.max(-1, Math.min(1, pcm[i]));
+ int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
+ }
+ voiceSocket.emit('voice_data', int16.buffer);
+ };
+
+ source.connect(voiceProcessor);
+ voiceProcessor.connect(audioCtx.createGain()); // не в destination — чтобы не слышать себя
+
+ // Подключаемся к голосовому серверу
+ voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] });
+ voiceSocket.on('connect', () => {
+ voiceSocket.emit('voice_join', { world_id: worldId, x: state.player.x, y: state.player.y, name: state.playerName || 'Игрок' });
+ });
+
+ voiceSocket.on('voice_in', (payload) => {
+ // Воспроизводим входящий голос
+ const { data, meta, volume } = payload;
+ if (!audioCtx || audioCtx.state === 'closed') return;
+
+ // Int16 → Float32
+ const int16 = new Int16Array(data);
+ const float32 = new Float32Array(int16.length);
+ for (let i = 0; i < int16.length; i++) {
+ float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF) * volume;
+ }
+
+ const buf = audioCtx.createBuffer(1, float32.length, 24000);
+ buf.getChannelData(0).set(float32);
+ const src = audioCtx.createBufferSource();
+ src.buffer = buf;
+
+ const gain = audioCtx.createGain();
+ gain.gain.value = volume;
+ src.connect(gain).connect(audioCtx.destination);
+ src.start();
+
+ // Индикатор
+ speakingIndicator.style.display = 'block';
+ speakingIndicator.textContent = `🔊 ${meta.name}`;
+ clearTimeout(speakingTimeout);
+ speakingTimeout = setTimeout(() => { speakingIndicator.style.display = 'none'; }, 500);
+ });
+
+ voiceActive = true;
+ voiceBtn.textContent = '🎤';
+ voiceBtn.style.background = '#2ecc71';
+ } catch (e) {
+ console.error('Voice error:', e);
+ voiceBtn.style.background = '#e74c3c';
+ }
+};
+
+export function initVoice() {
+ // Voice position update hook — should be called from main loop
+ let voicePosT = 0;
+ return {
+ update(dt) {
+ voicePosT += dt;
+ if (voicePosT > 0.5 && voiceSocket && voiceSocket.connected) {
+ voicePosT = 0;
+ voiceSocket.emit('voice_pos', { x: state.player.x, y: state.player.y });
+ }
+ }
+ };
+}
\ No newline at end of file
diff --git a/src/physics/collision.js b/src/physics/collision.js
new file mode 100644
index 0000000..4fd0f1b
--- /dev/null
+++ b/src/physics/collision.js
@@ -0,0 +1,154 @@
+import { TILE } from '../core/constants.js';
+import { BLOCKS } from '../data/blocks.js';
+import { state } from '../core/state.js';
+import { getBlock, isSolid } from '../world/world-storage.js';
+import { calculateDamage } from '../entities/player.js';
+
+function resolveY(e) {
+ // Всегда пересчитываем grounded (не держим "липким")
+ e.grounded = false;
+
+ const x1 = e.x + 2;
+ const x2 = e.x + e.w - 2;
+
+ // Проверяем, находится ли игрок на лестнице (по центру)
+ const cx = e.x + e.w / 2;
+ const cy = e.y + e.h / 2;
+ const gx = Math.floor(cx / TILE);
+ const gy = Math.floor(cy / TILE);
+ const b = getBlock(gx, gy);
+ const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable;
+
+ // Если на лестнице - можно двигаться вверх/вниз
+ if (onLadder) {
+ e.grounded = true;
+
+ // Если нажимаем прыжок на лестнице - поднимаемся
+ if (state.inp.jump) {
+ e.vy = -200;
+ }
+ // Если нажимаем вниз - спускаемся
+ else if (state.inp.down) {
+ e.vy = 100;
+ }
+ // Иначе - остаёмся на месте (нет гравитации)
+ else {
+ e.vy = 0;
+ }
+ return;
+ }
+
+ // Проверяем, можно ли запрыгнуть на лестницу рядом (слева или справа)
+ const leftGX = Math.floor((e.x - 4) / TILE);
+ const rightGX = Math.floor((e.x + e.w + 4) / TILE);
+ const playerGY = Math.floor((e.y + e.h / 2) / TILE);
+
+ const leftBlock = getBlock(leftGX, playerGY);
+ const rightBlock = getBlock(rightGX, playerGY);
+ const leftLadder = leftBlock && BLOCKS[leftBlock.t] && BLOCKS[leftBlock.t].climbable;
+ const rightLadder = rightBlock && BLOCKS[rightBlock.t] && BLOCKS[rightBlock.t].climbable;
+
+ // Если рядом есть лестница и игрок прыгает - притягиваем к ней
+ if ((leftLadder || rightLadder) && state.inp.jump && e.vy < 0) {
+ // Перемещаем игрока к лестнице
+ if (leftLadder && e.x > leftGX * TILE + TILE / 2) {
+ e.x = leftGX * TILE + TILE / 2 - e.w / 2;
+ } else if (rightLadder && e.x < rightGX * TILE + TILE / 2) {
+ e.x = rightGX * TILE + TILE / 2 - e.w / 2;
+ }
+ e.grounded = true;
+ e.vy = -150; // меньший прыжок при запрыгивании на лестницу
+ return;
+ }
+
+ // 1) Если движемся вниз ИЛИ стоим (vy >= 0) — проверяем опору под ногами
+ // Берём точку на 1px ниже стопы, чтобы не зависеть от ровного попадания в границу тайла.
+ if (e.vy >= 0) {
+ const probeY = e.y + e.h + 1;
+ const gy = Math.floor(probeY / TILE);
+ const gxA = Math.floor(x1 / TILE);
+ const gxB = Math.floor(x2 / TILE);
+
+ if (isSolid(gxA, gy) || isSolid(gxB, gy)) {
+ e.y = gy * TILE - e.h; // прижимаем к полу
+ e.vy = 0;
+ e.grounded = true;
+
+ // урон от падения — только игроку и только не в воде
+ if (e === state.player && !state.player.inWater) {
+ const fallTiles = (e.y - e.fallStartY) / TILE;
+ if (fallTiles > 6) {
+ const damage = calculateDamage((fallTiles - 6) * 10);
+ state.player.hp -= damage;
+ }
+ }
+ if (e === state.player) e.fallStartY = e.y;
+ }
+ }
+
+ // 2) Проверка на возможность запрыгнуть на блок, если игрок рядом с ним
+ if (e.vy < 0 && e === state.player) {
+ const gy = Math.floor(e.y / TILE);
+ const gxA = Math.floor(x1 / TILE);
+ const gxB = Math.floor(x2 / TILE);
+
+ // Проверяем, есть ли блок рядом с игроком
+ if ((isSolid(gxA, gy) || isSolid(gxB, gy)) && !isSolid(gxA, gy - 1) && !isSolid(gxB, gy - 1)) {
+ e.y = (gy + 1) * TILE;
+ e.vy = 0;
+ e.grounded = true;
+ if (e === state.player) e.fallStartY = e.y;
+ console.log("Jumped onto block!");
+ }
+ }
+
+ // 2) Если движемся вверх — проверяем потолок
+ if (e.vy < 0) {
+ const gy = Math.floor(e.y / TILE);
+ const gxA = Math.floor(x1 / TILE);
+ const gxB = Math.floor(x2 / TILE);
+ if (isSolid(gxA, gy) || isSolid(gxB, gy)) {
+ e.y = (gy + 1) * TILE;
+ e.vy = 0;
+ }
+ }
+}
+
+function resolveX(e) {
+ const y1 = e.y + 2;
+ const y2 = e.y + e.h - 2;
+
+ // Проверяем, находимся ли мы на лестнице
+ const cx = e.x + e.w / 2;
+ const cy = e.y + e.h / 2;
+ const gx = Math.floor(cx / TILE);
+ const gy = Math.floor(cy / TILE);
+ const b = getBlock(gx, gy);
+ const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable;
+
+ if (e.vx > 0) {
+ const gx = Math.floor((e.x + e.w) / TILE);
+ const gyA = Math.floor(y1 / TILE);
+ const gyB = Math.floor(y2 / TILE);
+ const solidA = isSolid(gx, gyA);
+ const solidB = isSolid(gx, gyB);
+
+ if (solidA || solidB) {
+ e.x = gx * TILE - e.w;
+ e.vx = 0;
+ }
+ } else if (e.vx < 0) {
+ const gx = Math.floor(e.x / TILE);
+ const gyA = Math.floor(y1 / TILE);
+ const gyB = Math.floor(y2 / TILE);
+ const solidA = isSolid(gx, gyA);
+ const solidB = isSolid(gx, gyB);
+
+ if (solidA || solidB) {
+ e.x = (gx + 1) * TILE;
+ e.vx = 0;
+ }
+ }
+}
+
+export { resolveY, resolveX };
\ No newline at end of file
diff --git a/src/physics/water-detect.js b/src/physics/water-detect.js
new file mode 100644
index 0000000..8b7b80d
--- /dev/null
+++ b/src/physics/water-detect.js
@@ -0,0 +1,31 @@
+import { TILE } from '../core/constants.js';
+import { state } from '../core/state.js';
+import { getBlock } from '../world/world-storage.js';
+import { playSound } from '../audio/sound-engine.js';
+
+function isWaterAt(px, py) {
+ const gx = Math.floor(px / TILE);
+ const gy = Math.floor(py / TILE);
+ const b = getBlock(gx, gy);
+ return !!(b && b.t === 'water');
+}
+
+function updateWaterFlag(e) {
+ const cx = e.x + e.w / 2;
+ const wasInWater = e.inWater;
+
+ // В воде, если в воде хотя бы центр/ноги (чтобы корректно работать у поверхности)
+ const mid = isWaterAt(cx, e.y + e.h / 2);
+ const feet = isWaterAt(cx, e.y + e.h - 2);
+ e.inWater = mid || feet;
+
+ // Голова под водой — для кислорода/урона
+ e.headInWater = isWaterAt(cx, e.y + 4);
+
+ // Звук при падении в воду
+ if (e === state.player && !wasInWater && e.inWater && e.vy > 100) {
+ playSound('splash');
+ }
+}
+
+export { isWaterAt, updateWaterFlag };
\ No newline at end of file
diff --git a/src/render/draw-fire.js b/src/render/draw-fire.js
new file mode 100644
index 0000000..7c56e97
--- /dev/null
+++ b/src/render/draw-fire.js
@@ -0,0 +1,19 @@
+// Рисование костра: огонь поверх текстуры
+export function drawFire(ctx, wx, wy, now) {
+ const baseX = wx;
+ const baseY = wy;
+ const flick = 6 + (Math.sin(now / 90) + 1) * 4;
+ ctx.fillStyle = 'rgba(255,140,0,0.85)';
+ ctx.beginPath();
+ ctx.moveTo(baseX + 10, baseY + 30);
+ ctx.lineTo(baseX + 20, baseY + 30 - flick);
+ ctx.lineTo(baseX + 30, baseY + 30);
+ ctx.fill();
+
+ ctx.fillStyle = 'rgba(255,230,150,0.75)';
+ ctx.beginPath();
+ ctx.moveTo(baseX + 14, baseY + 30);
+ ctx.lineTo(baseX + 20, baseY + 30 - (flick * 0.7));
+ ctx.lineTo(baseX + 26, baseY + 30);
+ ctx.fill();
+}
\ No newline at end of file
diff --git a/src/render/lighting.js b/src/render/lighting.js
new file mode 100644
index 0000000..7900450
--- /dev/null
+++ b/src/render/lighting.js
@@ -0,0 +1,105 @@
+// Lighting overlay at night (Minecraft-style: offscreen lightmap, wall occlusion, warm flicker)
+import { state } from '../core/state.js';
+import { TILE } from '../core/constants.js';
+import { BLOCKS } from '../data/blocks.js';
+import { getBlock } from '../world/world-storage.js';
+
+export function renderLighting(ctx, W, H, camX, camY, lightC, lightCtx, dpr, blocks, minGX, maxGX, minGY, maxGY, now) {
+ // 1) Рисуем тёмный оверлей на offscreen canvas
+ lightC.width = W * dpr;
+ lightC.height = H * dpr;
+ lightCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ lightCtx.fillStyle = 'rgba(0,0,12,0.82)';
+ lightCtx.fillRect(0, 0, W, H);
+
+ // 2) Вырезаем «окна света» через destination-out — свет не проходит сквозь стены
+ lightCtx.globalCompositeOperation = 'destination-out';
+
+ // Функция: рисуем мягкий луч света с затуханием за стенами
+ function castLight(sx, sy, radius) {
+ const flick = 0.88 + Math.sin(now / 80 + sx * 0.01) * 0.06 + Math.sin(now / 130 + sy * 0.02) * 0.06;
+ const r = radius * flick;
+ // 12 лучей — достаточно для мягкого круга
+ const steps = 12;
+ // Собираем дистанции до стен по лучам
+ const dists = new Float32Array(steps);
+ for (let i = 0; i < steps; i++) {
+ const angle = (i / steps) * Math.PI * 2;
+ const dx = Math.cos(angle);
+ const dy = Math.sin(angle);
+ let maxDist = r;
+ // Идём по лучу пока не упрёмся в стену
+ for (let step = TILE * 0.5; step < r; step += TILE * 0.6) {
+ const gx = Math.floor((sx + dx * step) / TILE);
+ const gy = Math.floor((sy + dy * step) / TILE);
+ const blk = getBlock(gx, gy);
+ if (blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid && step > TILE * 0.3) {
+ maxDist = step;
+ break;
+ }
+ }
+ dists[i] = maxDist;
+ }
+ // Рисуем сглаженный полигон по dists
+ const cx = sx - camX, cy = sy - camY;
+ // Центр: яркая точка
+ const maxR = Math.max(...dists);
+ const grad = lightCtx.createRadialGradient(cx, cy, 0, cx, cy, maxR);
+ grad.addColorStop(0, 'rgba(255,255,255,1)');
+ grad.addColorStop(0.5, 'rgba(255,255,255,0.65)');
+ grad.addColorStop(1, 'rgba(255,255,255,0)');
+ lightCtx.fillStyle = grad;
+ // Рисуем shape по dists (звездоподобный полигон)
+ lightCtx.beginPath();
+ for (let i = 0; i <= steps; i++) {
+ const idx = i % steps;
+ const nextIdx = (i + 1) % steps;
+ const avgD = (dists[idx] + dists[nextIdx]) / 2;
+ const angle = (idx / steps) * Math.PI * 2;
+ const px = cx + Math.cos(angle) * dists[idx];
+ const py = cy + Math.sin(angle) * dists[idx];
+ if (i === 0) lightCtx.moveTo(px, py);
+ else lightCtx.lineTo(px, py);
+ }
+ lightCtx.closePath();
+ lightCtx.fill();
+ }
+
+ // Источники света
+ for (const b of blocks) {
+ if (b.dead) continue;
+ if (b.gx < minGX - 5 || b.gx > maxGX + 5 || b.gy < minGY - 5 || b.gy > maxGY + 5) continue;
+ const def = BLOCKS[b.t];
+ if (def.lightRadius) {
+ castLight(b.gx * TILE + TILE / 2, b.gy * TILE + TILE / 2, def.lightRadius);
+ }
+ }
+
+ // 3) Накладываем lightmap на основной canvas
+ lightCtx.globalCompositeOperation = 'source-over';
+ ctx.drawImage(lightC, 0, 0, W, H);
+
+ // 4) Тёплый оверлей от источников света (additive, мягкий)
+ ctx.save();
+ ctx.globalCompositeOperation = 'lighter';
+ for (const b of blocks) {
+ if (b.dead) continue;
+ if (b.gx < minGX - 3 || b.gx > maxGX + 3 || b.gy < minGY - 3 || b.gy > maxGY + 3) continue;
+ const def = BLOCKS[b.t];
+ if (def.lightRadius) {
+ const flick = 0.7 + Math.sin(now / 90 + b.gx * 3.7) * 0.15 + Math.sin(now / 140 + b.gy * 2.3) * 0.15;
+ const wx = b.gx * TILE + TILE / 2 - camX;
+ const wy = b.gy * TILE + TILE / 2 - camY;
+ const r = def.lightRadius * 0.6 * flick;
+ const grad = ctx.createRadialGradient(wx, wy, 0, wx, wy, r);
+ grad.addColorStop(0, `rgba(255,180,80,${0.12 * flick})`);
+ grad.addColorStop(0.5, `rgba(255,140,40,${0.06 * flick})`);
+ grad.addColorStop(1, 'rgba(255,100,20,0)');
+ ctx.fillStyle = grad;
+ ctx.beginPath();
+ ctx.arc(wx, wy, r, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ }
+ ctx.restore();
+}
\ No newline at end of file
diff --git a/src/render/particles.js b/src/render/particles.js
new file mode 100644
index 0000000..0b2e764
--- /dev/null
+++ b/src/render/particles.js
@@ -0,0 +1,14 @@
+// Частицы (взрыв)
+export const parts = [];
+
+export function spawnExplosion(x, y, power) {
+ const n = Math.floor(16 + power * 10);
+ for (let i = 0; i < n; i++) {
+ parts.push({
+ x, y,
+ vx: (Math.random() - 0.5) * (300 + power * 200),
+ vy: (Math.random() - 0.5) * (300 + power * 200),
+ t: 0.7, c: '#ffa500'
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/render/textures.js b/src/render/textures.js
new file mode 100644
index 0000000..1d0e575
--- /dev/null
+++ b/src/render/textures.js
@@ -0,0 +1,104 @@
+// Текстуры блоков (простые)
+import { BLOCKS } from '../data/blocks.js';
+
+export const tex = {};
+
+export function makeTex(type) {
+ const t = BLOCKS[type];
+ const c = document.createElement('canvas');
+ c.width = 32; c.height = 32;
+ const g = c.getContext('2d');
+
+ if (type === 'tnt') {
+ g.fillStyle = '#c0392b'; g.fillRect(0, 0, 32, 32);
+ g.fillStyle = '#fff'; g.fillRect(0, 12, 32, 8);
+ g.fillStyle = '#000'; g.font = 'bold 10px sans-serif'; g.fillText('TNT', 6, 20);
+ return c;
+ }
+ if (type === 'campfire') {
+ g.fillStyle = '#5d4037'; g.fillRect(4, 26, 24, 6);
+ g.fillStyle = '#3e2723'; g.fillRect(7, 23, 18, 4);
+ return c;
+ }
+ if (type === 'torch') {
+ g.fillStyle = '#6d4c41'; g.fillRect(14, 10, 4, 18);
+ g.fillStyle = '#f39c12'; g.fillRect(12, 6, 8, 8);
+ return c;
+ }
+ if (type === 'glass') {
+ g.fillStyle = 'rgba(200,240,255,0.25)'; g.fillRect(0, 0, 32, 32);
+ g.strokeStyle = 'rgba(255,255,255,0.65)'; g.strokeRect(2, 2, 28, 28);
+ g.beginPath(); g.moveTo(5, 27); g.lineTo(27, 5); g.stroke();
+ return c;
+ }
+ if (type === 'water') {
+ g.fillStyle = t.c; g.fillRect(0, 0, 32, 32);
+ g.fillStyle = 'rgba(255,255,255,0.08)';
+ g.fillRect(0, 6, 32, 2);
+ return c;
+ }
+ if (type === 'bed') {
+ // Основание кровати
+ g.fillStyle = '#e91e63';
+ g.fillRect(0, 0, 32, 32);
+ // Подушка
+ g.fillStyle = '#f8bbd0';
+ g.fillRect(2, 2, 14, 14);
+ // Одеяло
+ g.fillStyle = '#c2185b';
+ g.fillRect(16, 4, 14, 24);
+ // Детали одеяла
+ g.fillStyle = '#e91e63';
+ g.fillRect(18, 6, 10, 20);
+ return c;
+ }
+ if (type === 'flower') {
+ g.fillStyle = '#2ecc71'; g.fillRect(14, 14, 4, 18);
+ g.fillStyle = t.c; g.beginPath(); g.arc(16, 12, 6, 0, 6.28); g.fill();
+ return c;
+ }
+ if (type === 'boat') {
+ // Корпус лодки
+ g.fillStyle = '#8B4513';
+ g.fillRect(2, 12, 28, 8);
+ // Борта
+ g.fillStyle = '#A0522D';
+ g.fillRect(0, 10, 32, 12);
+ // Внутренность
+ g.fillStyle = '#DEB887';
+ g.fillRect(4, 14, 24, 4);
+ // Дно
+ g.fillStyle = '#654321';
+ g.fillRect(2, 20, 28, 4);
+ return c;
+ }
+ if (type === 'ladder') {
+ // Боковые стойки лестницы
+ g.fillStyle = '#8B4513';
+ g.fillRect(4, 0, 4, 32);
+ g.fillRect(24, 0, 4, 32);
+ // Ступени
+ g.fillStyle = '#A0522D';
+ g.fillRect(4, 4, 24, 3);
+ g.fillRect(4, 12, 24, 3);
+ g.fillRect(4, 20, 24, 3);
+ g.fillRect(4, 28, 24, 3);
+ return c;
+ }
+
+ g.fillStyle = t.c || '#000';
+ g.fillRect(0, 0, 32, 32);
+
+ g.fillStyle = 'rgba(0,0,0,0.10)';
+ for (let i = 0; i < 6; i++) g.fillRect((Math.random() * 28) | 0, (Math.random() * 28) | 0, 4, 4);
+
+ if (type.endsWith('_ore') || type === 'coal') {
+ g.fillStyle = 'rgba(0,0,0,0.35)';
+ for (let i = 0; i < 4; i++) g.fillRect((Math.random() * 24) | 0, (Math.random() * 24) | 0, 6, 6);
+ }
+ return c;
+}
+
+export function initTextures() {
+ Object.keys(BLOCKS).forEach(k => tex[k] = makeTex(k));
+}
\ No newline at end of file
diff --git a/src/ui/chat.js b/src/ui/chat.js
new file mode 100644
index 0000000..cd95fb0
--- /dev/null
+++ b/src/ui/chat.js
@@ -0,0 +1,67 @@
+// ==================== ЧАТ ====================
+import { state } from '../core/state.js';
+import { playSound } from '../audio/sound-engine.js';
+
+const chatMessages = [];
+const MAX_CHAT_MESSAGES = 20;
+
+export function addChatMessage(sender, message) {
+ const time = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
+ chatMessages.push({ sender, message, time });
+ if (chatMessages.length > MAX_CHAT_MESSAGES) {
+ chatMessages.shift();
+ }
+ renderChatMessages();
+}
+
+export function renderChatMessages() {
+ const chatMessagesEl = document.getElementById('chatMessages');
+ if (!chatMessagesEl) return;
+
+ chatMessagesEl.innerHTML = chatMessages.map(m =>
+ `${m.time} ${m.sender}: ${m.message}
`
+ ).join('');
+
+ // Прокручиваем вниз
+ chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
+}
+
+export function sendChatMessage(message) {
+ if (!message || message.trim() === '') return;
+
+ if (state.isMultiplayer && state.socket && state.socket.connected) {
+ state.socket.emit('chat_message', { message: message.trim() });
+ } else {
+ addChatMessage('Вы', message.trim());
+ }
+}
+
+export function initChat() {
+ document.getElementById('chatToggle').onclick = () => {
+ playSound('click');
+ state.chatOpen = !state.chatOpen;
+ document.getElementById('chatPanel').style.display = state.chatOpen ? 'block' : 'none';
+ if (state.chatOpen) {
+ document.getElementById('chatInput').focus();
+ }
+ };
+
+ document.getElementById('chatClose').onclick = () => {
+ playSound('click');
+ state.chatOpen = false;
+ document.getElementById('chatPanel').style.display = 'none';
+ };
+
+ document.getElementById('chatSend').onclick = () => {
+ const input = document.getElementById('chatInput');
+ sendChatMessage(input.value);
+ input.value = '';
+ };
+
+ document.getElementById('chatInput').addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') {
+ sendChatMessage(e.target.value);
+ e.target.value = '';
+ }
+ });
+}
\ No newline at end of file
diff --git a/src/ui/craft.js b/src/ui/craft.js
new file mode 100644
index 0000000..deaa3db
--- /dev/null
+++ b/src/ui/craft.js
@@ -0,0 +1,116 @@
+// Craft UI
+import { state } from '../core/state.js';
+import { BLOCKS } from '../data/blocks.js';
+import { ITEMS } from '../data/items.js';
+import { TOOLS } from '../data/tools.js';
+import { RECIPES } from '../data/recipes.js';
+import { addTool } from '../data/tools.js';
+import { tex } from '../render/textures.js';
+import { playSound } from '../audio/sound-engine.js';
+import { rebuildHotbar } from './hotbar.js';
+import { renderInventory } from './inventory.js';
+
+export function canCraft(r) {
+ const inv = state.inv;
+ console.log('[CRAFT] Checking recipe for:', r.out, '- cost:', r.cost);
+ for (const res in r.cost) {
+ const have = inv[res] || 0;
+ const need = r.cost[res];
+ console.log('[CRAFT] Resource:', res, '- have:', have, '- need:', need, '- can craft:', have >= need);
+ if (have < need) return false;
+ }
+ return true;
+}
+
+export function renderCraft() {
+ const inv = state.inv;
+ const recipesEl = state.recipesEl;
+ recipesEl.innerHTML = '';
+ for (const r of RECIPES) {
+ const row = document.createElement('div');
+ row.className = 'recipe';
+ const icon = document.createElement('div');
+ icon.className = 'ricon';
+ // Иконка — блок, инструмент или предмет
+ if (tex[r.out]) {
+ icon.style.backgroundImage = `url(${tex[r.out].toDataURL()})`;
+ } else if (TOOLS[r.out]) {
+ icon.textContent = TOOLS[r.out].icon;
+ icon.style.fontSize = '24px';
+ icon.style.display = 'flex';
+ icon.style.alignItems = 'center';
+ icon.style.justifyContent = 'center';
+ } else if (ITEMS[r.out]) {
+ icon.textContent = ITEMS[r.out].icon;
+ icon.style.fontSize = '24px';
+ icon.style.display = 'flex';
+ icon.style.alignItems = 'center';
+ icon.style.justifyContent = 'center';
+ }
+ const info = document.createElement('div');
+ info.className = 'rinfo';
+ const nm = document.createElement('div');
+ nm.className = 'rname';
+ const itemName = BLOCKS[r.out]?.n || TOOLS[r.out]?.n || ITEMS[r.out]?.n || r.out;
+ nm.textContent = `${itemName} x${r.qty}`;
+ const cs = document.createElement('div');
+ cs.className = 'rcost';
+ cs.textContent = Object.keys(r.cost).map(x => {
+ const cn = BLOCKS[x]?.n || TOOLS[x]?.n || ITEMS[x]?.n || x;
+ return `${cn}: ${(inv[x] || 0)}/${r.cost[x]}`;
+ }).join(' ');
+ info.appendChild(nm); info.appendChild(cs);
+ const btn = document.createElement('button');
+ btn.className = 'rcraft';
+ btn.textContent = 'Создать';
+ btn.disabled = !canCraft(r);
+ btn.onclick = () => {
+ if (!canCraft(r)) return;
+ playSound('click');
+ for (const res in r.cost) inv[res] -= r.cost[res];
+ inv[r.out] = (inv[r.out] || 0) + r.qty;
+ if (TOOLS[r.out]) addTool(r.out);
+ rebuildHotbar();
+ renderCraft();
+ };
+ row.appendChild(icon); row.appendChild(info); row.appendChild(btn);
+ recipesEl.appendChild(row);
+ }
+}
+
+let craftOpen = false;
+let inventoryOpen = false;
+
+export function toggleCraft() {
+ playSound('click'); // Звук клика по кнопке
+ craftOpen = !craftOpen;
+ state.craftPanel.style.display = craftOpen ? 'block' : 'none';
+ if (craftOpen) {
+ renderCraft();
+ // Закрываем инвентарь если открыт крафт
+ inventoryOpen = false;
+ state.inventoryPanel.style.display = 'none';
+ }
+}
+
+export function closeCraft() {
+ playSound('click'); // Звук клика по кнопке
+ craftOpen = false;
+ state.craftPanel.style.display = 'none';
+}
+
+export function toggleInventory() {
+ playSound('click'); // Звук клика по кнопке
+ inventoryOpen = true;
+ state.inventoryPanel.style.display = 'block';
+ renderInventory();
+ // Закрываем крафт если открыт инвентарь
+ craftOpen = false;
+ state.craftPanel.style.display = 'none';
+}
+
+export function closeInventory() {
+ playSound('click'); // Звук клика по кнопке
+ inventoryOpen = false;
+ state.inventoryPanel.style.display = 'none';
+}
\ No newline at end of file
diff --git a/src/ui/dom-refs.js b/src/ui/dom-refs.js
new file mode 100644
index 0000000..f280ccb
--- /dev/null
+++ b/src/ui/dom-refs.js
@@ -0,0 +1,14 @@
+// DOM-ссылки — все document.getElementById
+export const hpEl = document.getElementById('hp');
+export const foodEl = document.getElementById('food');
+export const sxEl = document.getElementById('sx');
+export const syEl = document.getElementById('sy');
+export const todEl = document.getElementById('tod');
+export const worldIdEl = document.getElementById('worldId');
+export const playerCountEl = document.getElementById('playerCount');
+export const hotbarEl = document.getElementById('hotbar');
+export const craftPanel = document.getElementById('craftPanel');
+export const recipesEl = document.getElementById('recipes');
+export const deathEl = document.getElementById('death');
+export const inventoryPanel = document.getElementById('inventoryPanel');
+export const inventoryGrid = document.getElementById('inventoryGrid');
\ No newline at end of file
diff --git a/src/ui/furnace.js b/src/ui/furnace.js
new file mode 100644
index 0000000..8a4b87c
--- /dev/null
+++ b/src/ui/furnace.js
@@ -0,0 +1,126 @@
+// ==================== ПЕЧЬ (ОБЖИГ) ====================
+import { state } from '../core/state.js';
+import { BLOCKS } from '../data/blocks.js';
+import { ITEMS } from '../data/items.js';
+import { SMELTING_RECIPES } from '../data/recipes.js';
+import { playSound } from '../audio/sound-engine.js';
+import { rebuildHotbar } from '../ui/hotbar.js';
+import { getBlock } from '../world/world-storage.js';
+
+const furnacePanel = document.getElementById('furnacePanel');
+const furnaceContent = document.getElementById('furnaceContent');
+
+export function initFurnace() {
+ document.getElementById('furnaceClose').onclick = () => {
+ furnacePanel.style.display = 'none';
+ state.currentFurnaceKey = null;
+ };
+}
+
+export function openFurnaceUI(gx, gy) {
+ state.currentFurnaceKey = `${gx},${gy}`;
+ furnacePanel.style.display = 'block';
+ renderFurnaceUI();
+}
+
+export function renderFurnaceUI() {
+ if (!state.currentFurnaceKey) return;
+
+ // Проверяем что печь всё ещё существует
+ const [fgx, fgy] = state.currentFurnaceKey.split(',').map(Number);
+ const fb = getBlock(fgx, fgy);
+ if (!fb || fb.t !== 'furnace') {
+ furnacePanel.style.display = 'none';
+ state.currentFurnaceKey = null;
+ return;
+ }
+
+ // Текущий процесс обжига
+ const active = state.activeFurnaces.get(state.currentFurnaceKey);
+
+ let html = '';
+
+ // Доступные рецепты — показываем только те, для которых есть ресурсы
+ for (let i = 0; i < SMELTING_RECIPES.length; i++) {
+ const recipe = SMELTING_RECIPES[i];
+ const haveCount = state.inv[recipe.in] || 0;
+ const canSmelt = haveCount >= recipe.qty;
+
+ // Иконка результата
+ const outDef = BLOCKS[recipe.out];
+ const outItem = ITEMS[recipe.out];
+ const iconStr = outItem ? outItem.icon : (outDef ? '🧱' : '❓');
+ const nameStr = outItem ? outItem.n : (outDef ? outDef.n : recipe.out);
+ const inItem = ITEMS[recipe.in];
+ const inDef = BLOCKS[recipe.in];
+ const inName = inItem ? inItem.n : (inDef ? inDef.n : recipe.in);
+
+ html += `
`;
+ html += `
${iconStr}
`;
+ html += `
`;
+ html += `
${nameStr}
`;
+ html += `
${inName} x${recipe.qty} (есть: ${haveCount}) • ${recipe.time}с
`;
+ html += `
`;
+ html += `
`;
+ html += `
`;
+ }
+
+ // Текущий прогресс
+ if (active) {
+ const pct = Math.min(100, Math.floor((active.progress / active.recipe.time) * 100));
+ html += `
`;
+ html += `
🔥 Обжиг: ${pct}%
`;
+ html += `
`;
+ html += `
`;
+ html += `
`;
+ }
+
+ html += '
';
+ furnaceContent.innerHTML = html;
+}
+
+// Глобальная функция для кнопки обжига
+window._smelt = (recipeIdx) => {
+ if (!state.currentFurnaceKey) return;
+ const recipe = SMELTING_RECIPES[recipeIdx];
+ if ((state.inv[recipe.in] || 0) < recipe.qty) return;
+
+ // Уже обжигаем в этой печи?
+ if (state.activeFurnaces.has(state.currentFurnaceKey)) return;
+
+ // Забираем ресурсы
+ state.inv[recipe.in] -= recipe.qty;
+
+ // Запускаем обжиг
+ state.activeFurnaces.set(state.currentFurnaceKey, {
+ recipe: recipe,
+ progress: 0
+ });
+
+ playSound('fire');
+ rebuildHotbar();
+ renderFurnaceUI();
+};
+
+// Тик печей — вызывается в главном цикле
+export function tickFurnaces(dt) {
+ for (const [key, furnace] of state.activeFurnaces) {
+ furnace.progress += dt;
+ if (furnace.progress >= furnace.recipe.time) {
+ // Обжиг завершён — выдаём результат
+ const outItem = furnace.recipe.out;
+ if (ITEMS[outItem]) {
+ state.inv[outItem] = (state.inv[outItem] || 0) + furnace.recipe.outQty;
+ } else if (BLOCKS[outItem]) {
+ state.inv[outItem] = (state.inv[outItem] || 0) + furnace.recipe.outQty;
+ }
+ playSound('stone_build');
+ state.activeFurnaces.delete(key);
+
+ // Если эта печь открыта — обновляем UI
+ if (key === state.currentFurnaceKey) {
+ renderFurnaceUI();
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ui/hotbar.js b/src/ui/hotbar.js
new file mode 100644
index 0000000..deccf0c
--- /dev/null
+++ b/src/ui/hotbar.js
@@ -0,0 +1,81 @@
+// Hotbar UI
+import { state } from '../core/state.js';
+import { BLOCKS } from '../data/blocks.js';
+import { ITEMS } from '../data/items.js';
+import { TOOLS } from '../data/tools.js';
+import { tex } from '../render/textures.js';
+import { playSound } from '../audio/sound-engine.js';
+
+export function rebuildHotbar() {
+ const hotbarEl = state.hotbarEl;
+ const inv = state.inv;
+ const selected = state.selected;
+ const recentItems = state.recentItems;
+ const toolDurability = state.toolDurability;
+
+ hotbarEl.innerHTML = '';
+
+ // Показываем последние 5 выбранных предметов (если они есть в инвентаре)
+ const items = recentItems.filter(id => inv[id] > 0).slice(0, 5);
+
+ for (const id of items) {
+ const s = document.createElement('div');
+ s.className = 'slot' + (id === selected ? ' sel' : '');
+ if (BLOCKS[id]) {
+ s.style.backgroundImage = `url(${tex[id].toDataURL()})`;
+ s.style.backgroundSize = 'cover';
+ } else if (ITEMS[id]) {
+ s.textContent = ITEMS[id].icon;
+ } else if (TOOLS[id]) {
+ s.textContent = TOOLS[id].icon;
+ } else if (id === 'iron_armor') {
+ s.textContent = '🛡️';
+ s.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)';
+ }
+ const c = document.createElement('div');
+ c.className = 'count';
+ c.textContent = inv[id];
+ s.appendChild(c);
+ s.onclick = () => {
+ playSound('click'); // Звук клика по инвентарю
+ state.selected = id;
+ // Обновляем список последних предметов
+ state.recentItems = state.recentItems.filter(item => item !== id); // Удаляем если уже есть
+ state.recentItems.unshift(id); // Добавляем в начало
+ state.recentItems = state.recentItems.slice(0, 5); // Оставляем только 5
+ rebuildHotbar();
+ };
+
+ // Показываем индикатор надетой брони
+ if (id === 'iron_armor' && state.player.equippedArmor === 'iron_armor') {
+ const equipped = document.createElement('div');
+ equipped.className = 'equipped-indicator';
+ equipped.textContent = '✓';
+ s.appendChild(equipped);
+ }
+
+ // Durability bar для инструментов
+ if (TOOLS[id] && inv[id] > 0) {
+ // Находим текущую прочность
+ let curDur = 0, maxDur = TOOLS[id].durability;
+ for (const [tid, dur] of toolDurability) {
+ if (dur.type === id) {
+ curDur = dur.current;
+ maxDur = dur.max;
+ break;
+ }
+ }
+ if (maxDur > 0) {
+ const bar = document.createElement('div');
+ bar.style.cssText = `position:absolute;bottom:0;left:0;right:0;height:3px;background:#333;border-radius:0 0 6px 6px;overflow:hidden;`;
+ const fill = document.createElement('div');
+ const pct = curDur / maxDur;
+ const color = pct > 0.5 ? '#2ecc71' : pct > 0.25 ? '#f39c12' : '#e74c3c';
+ fill.style.cssText = `width:${pct * 100}%;height:100%;background:${color};`;
+ bar.appendChild(fill);
+ s.appendChild(bar);
+ }
+ }
+ hotbarEl.appendChild(s);
+ }
+}
\ No newline at end of file
diff --git a/src/ui/inventory.js b/src/ui/inventory.js
new file mode 100644
index 0000000..c7cc20c
--- /dev/null
+++ b/src/ui/inventory.js
@@ -0,0 +1,76 @@
+// Inventory UI
+import { state } from '../core/state.js';
+import { BLOCKS } from '../data/blocks.js';
+import { ITEMS } from '../data/items.js';
+import { TOOLS } from '../data/tools.js';
+import { tex } from '../render/textures.js';
+import { playSound } from '../audio/sound-engine.js';
+import { rebuildHotbar } from './hotbar.js';
+
+export function renderInventory() {
+ const inventoryGrid = state.inventoryGrid;
+ const inv = state.inv;
+ const selected = state.selected;
+ inventoryGrid.innerHTML = '';
+
+ // Создаём сетку инвентаря 7x3
+ const items = Object.keys(inv).filter(id => inv[id] > 0);
+
+ // Добавляем пустые слоты для полной сетки
+ for (let i = 0; i < 21; i++) {
+ const slot = document.createElement('div');
+ slot.className = 'inv-slot' + (i < items.length && items[i] === selected ? ' sel' : '');
+
+ if (i < items.length) {
+ const id = items[i];
+ if (BLOCKS[id]) {
+ slot.style.backgroundImage = `url(${tex[id].toDataURL()})`;
+ slot.style.backgroundSize = 'cover';
+ } else if (ITEMS[id]) {
+ slot.textContent = ITEMS[id].icon;
+ } else if (TOOLS[id]) {
+ slot.textContent = TOOLS[id].icon;
+ } else if (id === 'iron_armor') {
+ slot.textContent = '🛡️';
+ slot.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)';
+ }
+
+ const count = document.createElement('div');
+ count.className = 'inv-count';
+ count.textContent = inv[id];
+ slot.appendChild(count);
+
+ slot.onclick = () => {
+ playSound('click'); // Звук клика по инвентарю
+ state.selected = id;
+ // Обновляем список последних предметов
+ state.recentItems = state.recentItems.filter(item => item !== id); // Удаляем если уже есть
+ state.recentItems.unshift(id); // Добавляем в начало
+ state.recentItems = state.recentItems.slice(0, 5); // Оставляем только 5
+ rebuildHotbar();
+ renderInventory();
+ };
+
+ // Двойной клик для надевания брони
+ slot.ondblclick = () => {
+ if (id === 'iron_armor' && inv.iron_armor > 0) {
+ // Если уже надета броня - снимаем её
+ if (state.player.equippedArmor === 'iron_armor') {
+ state.player.equippedArmor = null;
+ state.player.armor = 0;
+ console.log('[ARMOR] Iron armor unequipped');
+ } else {
+ // Надеваем броню
+ state.player.equippedArmor = 'iron_armor';
+ state.player.armor = BLOCKS['iron_armor'].armor;
+ console.log('[ARMOR] Iron armor equipped - armor:', state.player.armor);
+ }
+ playSound('click');
+ renderInventory();
+ }
+ };
+ }
+
+ inventoryGrid.appendChild(slot);
+ }
+}
\ No newline at end of file
diff --git a/src/ui/minimap.js b/src/ui/minimap.js
new file mode 100644
index 0000000..7a21a92
--- /dev/null
+++ b/src/ui/minimap.js
@@ -0,0 +1,113 @@
+// ==================== МИНИКАРТА ====================
+import { state } from '../core/state.js';
+import { BLOCKS } from '../data/blocks.js';
+import { getBlock } from '../world/world-storage.js';
+import { playSound } from '../audio/sound-engine.js';
+import { isNight } from '../entities/mob-ai.js';
+
+const minimapWrap = document.getElementById('minimapWrap');
+const minimapCanvas = document.getElementById('minimap');
+const minimapCtx = minimapCanvas.getContext('2d');
+
+// Цвета блоков для миникарты (по 1 пикселю на блок)
+const MINIMAP_COLORS = {
+ grass: '#4a8c2a', dirt: '#6b3410', stone: '#6a6a6a', sand: '#d4b84a',
+ gravel: '#888', clay: '#5a9ad4', wood: '#a63d00', planks: '#c46a10',
+ leaves: '#2a8a3a', glass: '#aaddee', water: '#2980b9', coal: '#1a1a2a',
+ copper_ore: '#a05535', iron_ore: '#b0b0b0', gold_ore: '#d4a017',
+ diamond_ore: '#0090d0', brick: '#8a2010', tnt: '#c02020',
+ campfire: '#d08020', torch: '#d0a020', bedrock: '#1a1a1a',
+ flower: '#d03050', bed: '#c03060', ladder: '#a04000', boat: '#6b3410'
+};
+
+export function initMinimap() {
+ document.getElementById('mapToggle').onclick = () => {
+ playSound('click');
+ state.minimapOpen = !state.minimapOpen;
+ minimapWrap.style.display = state.minimapOpen ? 'block' : 'none';
+ };
+}
+
+export function renderMinimap() {
+ if (!state.minimapOpen) return;
+ const mW = minimapCanvas.width;
+ const mH = minimapCanvas.height;
+ const scale = 2; // пикселей на блок
+ const TILE = state.TILE;
+ const player = state.player;
+
+ // Область карты — центрирована на игроке
+ const pGX = Math.floor(player.x / TILE);
+ const pGY = Math.floor(player.y / TILE);
+ const viewW = Math.floor(mW / scale);
+ const viewH = Math.floor(mH / scale);
+ const startGX = pGX - Math.floor(viewW / 2);
+ const startGY = pGY - Math.floor(viewH / 2);
+
+ // Очищаем
+ minimapCtx.fillStyle = isNight() ? '#070816' : '#87CEEB';
+ minimapCtx.fillRect(0, 0, mW, mH);
+
+ // Рисуем блоки
+ const imgData = minimapCtx.createImageData(mW, mH);
+ const data = imgData.data;
+
+ for (let dx = 0; dx < viewW; dx++) {
+ for (let dy = 0; dy < viewH; dy++) {
+ const gx = startGX + dx;
+ const gy = startGY + dy;
+ const b = getBlock(gx, gy);
+ if (!b || b.dead || b.t === 'air') continue;
+
+ const color = MINIMAP_COLORS[b.t];
+ if (!color) continue;
+
+ // Парсим hex цвет
+ const r = parseInt(color.slice(1, 3), 16);
+ const g = parseInt(color.slice(3, 5), 16);
+ const bl = parseInt(color.slice(5, 7), 16);
+
+ // Заполняем scale x scale пикселей
+ for (let sx = 0; sx < scale; sx++) {
+ for (let sy = 0; sy < scale; sy++) {
+ const px = dx * scale + sx;
+ const py = dy * scale + sy;
+ if (px >= mW || py >= mH) continue;
+ const idx = (py * mW + px) * 4;
+ data[idx] = r;
+ data[idx + 1] = g;
+ data[idx + 2] = bl;
+ data[idx + 3] = 255;
+ }
+ }
+ }
+ }
+
+ minimapCtx.putImageData(imgData, 0, 0);
+
+ // Игрок — белый пиксель по центру
+ minimapCtx.fillStyle = '#fff';
+ minimapCtx.fillRect(Math.floor(mW / 2) - 2, Math.floor(mH / 2) - 2, 4, 4);
+
+ // Другие игроки — жёлтые точки
+ for (const [sid, p] of state.otherPlayers) {
+ const dx = Math.floor(p.x / TILE) - startGX;
+ const dy = Math.floor(p.y / TILE) - startGY;
+ if (dx >= 0 && dx < viewW && dy >= 0 && dy < viewH) {
+ minimapCtx.fillStyle = '#f1c40f';
+ minimapCtx.fillRect(dx * scale - 1, dy * scale - 1, 3, 3);
+ }
+ }
+
+ // Мобы — красные (враждебные) / зелёные (животные)
+ const allMobsForMap = state.isMultiplayer ? Array.from(state.serverMobs.values()) : state.mobs;
+ for (const m of allMobsForMap) {
+ const dx = Math.floor(m.x / TILE) - startGX;
+ const dy = Math.floor(m.y / TILE) - startGY;
+ if (dx >= 0 && dx < viewW && dy >= 0 && dy < viewH) {
+ const hostile = m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton';
+ minimapCtx.fillStyle = hostile ? '#e74c3c' : '#2ecc71';
+ minimapCtx.fillRect(dx * scale, dy * scale, 2, 2);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ui/respawn.js b/src/ui/respawn.js
new file mode 100644
index 0000000..17ab794
--- /dev/null
+++ b/src/ui/respawn.js
@@ -0,0 +1,56 @@
+// ==================== RESPAWN ====================
+import { state } from '../core/state.js';
+import { playSound } from '../audio/sound-engine.js';
+import { loadGame, applySave } from '../game/save.js';
+
+export function initRespawn() {
+ document.getElementById('respawnBtn').onclick = async () => {
+ playSound('click'); // Звук клика по кнопке
+
+ console.log('=== RESPAWN CLICKED ===');
+ console.log('isMultiplayer:', state.isMultiplayer);
+ console.log('otherPlayers.size:', state.otherPlayers.size);
+ console.log('player.hp before respawn:', state.player.hp);
+
+ const player = state.player;
+ const deathEl = state.deathEl;
+
+ // В мультиплеере не загружаем сохранение, а возрождаемся в начальной точке
+ if (state.isMultiplayer && state.otherPlayers.size > 0) {
+ console.log('Мультиплеер режим - возрождение в начальной точке');
+ player.hp = 100;
+ player.hunger = 100;
+ player.o2 = 100;
+ player.vx = player.vy = 0;
+ player.invuln = 0;
+ player.x = state.spawnPoint.x;
+ player.y = state.spawnPoint.y;
+ player.fallStartY = player.y;
+ console.log('Возрождение в начальной точке, HP:', player.hp);
+ } else {
+ console.log('Одиночный режим - загружаем последнее сохранение');
+ // Одиночный режим - загружаем последнее сохранение
+ const loadedSave = await loadGame();
+ if (loadedSave) {
+ await applySave(loadedSave);
+ console.log('Загружено последнее сохранение после смерти, final HP:', player.hp);
+ } else {
+ // Если сохранения нет, возрождаемся в начальной точке
+ player.hp = 100;
+ player.hunger = 100;
+ player.o2 = 100;
+ player.vx = player.vy = 0;
+ player.invuln = 0;
+ player.x = state.spawnPoint.x;
+ player.y = state.spawnPoint.y;
+ player.fallStartY = player.y;
+ console.log('Возрождение в начальной точке, HP:', player.hp);
+ }
+ }
+
+ console.log('player.hp after respawn logic:', player.hp);
+ console.log('Hiding death screen...');
+ deathEl.style.display = 'none';
+ console.log('=== RESPAWN END ===');
+ };
+}
\ No newline at end of file
diff --git a/src/ui/save-controls.js b/src/ui/save-controls.js
new file mode 100644
index 0000000..445a7c4
--- /dev/null
+++ b/src/ui/save-controls.js
@@ -0,0 +1,62 @@
+// ==================== КНОПКИ СОХРАНЕНИЯ / СБРОСА ====================
+import { state } from '../core/state.js';
+import { playSound } from '../audio/sound-engine.js';
+import { saveGame } from '../game/save.js';
+
+export function initSaveControls() {
+ const saveBtn = document.getElementById('saveBtn');
+ saveBtn.onclick = () => {
+ playSound('click');
+ saveGame();
+ alert('Игра сохранена!');
+ };
+
+ const resetBtn = document.getElementById('resetBtn');
+ resetBtn.onclick = () => {
+ if (confirm('Вы уверены, что хотите удалить сохранение и начать новую игру?')) {
+ playSound('click');
+
+ // Удаляем сохранение из localStorage
+ try {
+ localStorage.removeItem(state.SAVE_KEY);
+ console.log('Сохранение удалено из localStorage');
+ } catch (e) {
+ console.warn('Ошибка удаления сохранения:', e);
+ }
+
+ // Сбрасываем in-memory сохранение
+ state.inMemorySave = null;
+
+ // Генерируем новый worldId
+ state.worldId = Math.random().toString(36).substring(2, 10);
+ console.log('Новый worldId после сброса:', state.worldId);
+
+ // Обновляем URL
+ try {
+ const newUrl = new URL(window.location.href);
+ newUrl.searchParams.set('world', state.worldId);
+ const newUrlString = newUrl.toString();
+
+ if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') {
+ window.history.replaceState(null, '', newUrlString);
+ console.log('URL обновлён:', newUrlString);
+ }
+ } catch (e) {
+ console.error('Ошибка обновления URL:', e);
+ }
+
+ // Перезагружаем страницу
+ location.reload();
+ }
+ };
+}
+
+// Показываем кнопку сохранения только если играем одни
+export function updateSaveButtonVisibility() {
+ const saveBtn = document.getElementById('saveBtn');
+ if (state.isMultiplayer && state.otherPlayers.size > 0) {
+ saveBtn.style.display = 'none';
+ } else {
+ saveBtn.style.display = 'flex';
+ }
+}
\ No newline at end of file
diff --git a/src/ui/share.js b/src/ui/share.js
new file mode 100644
index 0000000..32d72a0
--- /dev/null
+++ b/src/ui/share.js
@@ -0,0 +1,38 @@
+// ==================== ПОДЕЛИТЬСЯ МИРОМ ====================
+import { state } from '../core/state.js';
+
+export function initShare() {
+ // Обработчик клика на worldId для копирования ссылки
+ document.getElementById('worldId').onclick = () => {
+ const shareUrl = new URL(window.location.href);
+ shareUrl.searchParams.set('world', state.worldId);
+ const shareUrlString = shareUrl.toString();
+
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(shareUrlString).then(() => {
+ alert('Ссылка скопирована!');
+ }).catch(() => {
+ alert('Ссылка на мир:\n' + shareUrlString);
+ });
+ } else {
+ alert('Ссылка на мир:\n' + shareUrlString);
+ }
+ };
+}
+
+export function shareWorld() {
+ const shareUrl = new URL(window.location.href);
+ shareUrl.searchParams.set('world', state.worldId);
+ const shareUrlString = shareUrl.toString();
+
+ // Копируем в буфер обмена
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(shareUrlString).then(() => {
+ alert('Ссылка скопирована!');
+ }).catch(() => {
+ alert('Ссылка на мир:\n' + shareUrlString);
+ });
+ } else {
+ alert('Ссылка на мир:\n' + shareUrlString);
+ }
+}
\ No newline at end of file
diff --git a/src/world/generation.js b/src/world/generation.js
new file mode 100644
index 0000000..8da9697
--- /dev/null
+++ b/src/world/generation.js
@@ -0,0 +1,126 @@
+import { TILE, SEA_GY, BEDROCK_GY, GEN_MARGIN_X } from '../core/constants.js';
+import { state } from '../core/state.js';
+import { grid, blocks, setBlock, getBlock, k } from './world-storage.js';
+
+// Генерация (по X, на всю глубину до bedrock)
+const generated = state.generated; // Set of gx already generated
+
+function surfaceGyAt(gx) {
+ // базовая поверхность выше уровня воды с вариациями + "горы"
+ // Используем seed для детерминированной генерации
+ // Увеличили амплитуду и добавили больше частот для разнообразия
+ const n1 = Math.sin(gx * 0.025 + state.worldSeed * 0.001) * 8; // крупные горы
+ const n2 = Math.sin(gx * 0.012 + state.worldSeed * 0.002) * 12; // средние горы
+ const n3 = Math.sin(gx * 0.006 + state.worldSeed * 0.003) * 6; // мелкие холмы
+ const n4 = Math.sin(gx * 0.045 + state.worldSeed * 0.004) * 4; // детали
+ const n5 = Math.cos(gx * 0.018 + state.worldSeed * 0.005) * 5; // дополнительные вариации
+ const h = Math.floor(SEA_GY - 8 + n1 + n2 + n3 + n4 + n5); // чем меньше gy - тем выше
+ return h;
+}
+
+function genColumn(gx) {
+ if (generated.has(gx)) return;
+ generated.add(gx);
+
+ const sgy = surfaceGyAt(gx);
+
+ // вода (если поверхность ниже уровня моря => sgy > SEA_GY)
+ if (sgy > SEA_GY) {
+ for (let gy = SEA_GY; gy < sgy; gy++) {
+ setBlock(gx, gy, 'water');
+ }
+ // пляж
+ setBlock(gx, sgy, 'sand');
+ } else {
+ // верхний блок: снег на высоких точках
+ if (sgy < SEA_GY - 10) setBlock(gx, sgy, 'stone');
+ else setBlock(gx, sgy, 'grass');
+ }
+
+ // подповерхностные слои
+ for (let gy = sgy + 1; gy <= BEDROCK_GY; gy++) {
+ if (gy === BEDROCK_GY) {
+ setBlock(gx, gy, 'bedrock');
+ continue;
+ }
+
+ let t = 'stone';
+
+ // ближе к поверхности
+ if (gy <= sgy + 3) t = 'dirt';
+
+ // биомы/материалы
+ if (sgy > SEA_GY && gy === sgy + 1 && seededRandom(gx, gy) < 0.25) t = 'clay';
+ if (gy > sgy + 6 && seededRandom(gx, gy) < 0.07) t = 'gravel';
+
+ // руды: чем глубже, тем интереснее
+ const depth = gy - sgy;
+ const r = seededRandom(gx, gy);
+ if (t === 'stone') {
+ if (r < 0.06) t = 'coal';
+ else if (r < 0.10) t = 'copper_ore';
+ else if (r < 0.13) t = 'iron_ore';
+ else if (depth > 40 && r < 0.145) t = 'gold_ore';
+ else if (depth > 70 && r < 0.152) t = 'diamond_ore';
+ }
+
+ setBlock(gx, gy, t);
+ }
+
+ // Деревья и цветы (только на траве, и не в воде)
+ const top = getBlock(gx, sgy);
+ if (top && top.t === 'grass') {
+ if (seededRandom(gx, sgy - 1) < 0.10) {
+ setBlock(gx, sgy - 1, 'flower');
+ }
+ if (seededRandom(gx, sgy - 2) < 0.12) {
+ // простое дерево
+ setBlock(gx, sgy - 1, 'wood');
+ setBlock(gx, sgy - 2, 'wood');
+ setBlock(gx, sgy - 3, 'leaves');
+ setBlock(gx - 1, sgy - 3, 'leaves');
+ setBlock(gx + 1, sgy - 3, 'leaves');
+ }
+ }
+
+ // Применяем серверные оверрайды для этой колонны
+ const colPrefix = gx + ',';
+ for (const [key, ov] of state.serverOverrides) {
+ if (!key.startsWith(colPrefix)) continue;
+ if (ov.op === 'remove') {
+ const b = grid.get(key);
+ if (b) { grid.delete(key); b.dead = true; }
+ } else if (ov.op === 'set') {
+ if (!grid.has(key)) {
+ const gy = parseInt(key.split(',')[1]);
+ const nb = { gx, gy, t: ov.t, dead: false, active: false, fuse: 0 };
+ grid.set(key, nb);
+ blocks.push(nb);
+ }
+ }
+ }
+}
+
+// Перегенерация видимых чанков (используется при загрузке сохранения)
+function regenerateVisibleChunks() {
+ const gx0 = Math.floor(state.camX / TILE);
+ for (let gx = gx0 - GEN_MARGIN_X; gx <= gx0 + GEN_MARGIN_X; gx++) {
+ // Принудительно перегенерируем колонну
+ generated.delete(gx);
+ genColumn(gx);
+ }
+}
+
+function ensureGenAroundCamera() {
+ const gx0 = Math.floor(state.camX / TILE);
+ for (let gx = gx0 - GEN_MARGIN_X; gx <= gx0 + GEN_MARGIN_X; gx++) {
+ genColumn(gx);
+ }
+}
+
+function seededRandom(gx, gy) {
+ const n = Math.sin(gx * 12.9898 + gy * 78.233 + state.worldSeed * 0.1) * 43758.5453;
+ return n - Math.floor(n);
+}
+
+export { generated, surfaceGyAt, genColumn, regenerateVisibleChunks, ensureGenAroundCamera, seededRandom };
\ No newline at end of file
diff --git a/src/world/tnt.js b/src/world/tnt.js
new file mode 100644
index 0000000..11e145b
--- /dev/null
+++ b/src/world/tnt.js
@@ -0,0 +1,80 @@
+import { TILE } from '../core/constants.js';
+import { BLOCKS } from '../data/blocks.js';
+import { state } from '../core/state.js';
+import { getBlock, removeBlock, k } from './world-storage.js';
+import { calculateDamage } from '../entities/player.js';
+import { playSound } from '../audio/sound-engine.js';
+import { spawnExplosion } from '../render/particles.js';
+import { rebuildHotbar } from '../ui/hotbar.js';
+
+// TNT логика: цепь + усиление
+function activateTNT(b, fuse = 3.2) {
+ if (b.dead) return;
+ if (b.active) return;
+ b.active = true;
+ b.fuse = fuse;
+ state.activeTNT.add(k(b.gx, b.gy));
+}
+
+function explodeAt(gx, gy) {
+ const center = getBlock(gx, gy);
+ if (!center) return;
+
+ // усиление: считаем сколько TNT рядом (в радиусе 2) и активируем их «почти сразу»
+ let bonus = 0;
+ for (let x = gx - 2; x <= gx + 2; x++) {
+ for (let y = gy - 2; y <= gy + 2; y++) {
+ const b = getBlock(x, y);
+ if (b && !b.dead && b.t === 'tnt' && !(x === gx && y === gy)) {
+ bonus += 0.8;
+ activateTNT(b, 0.12); // цепь
+ }
+ }
+ }
+
+ const power = 1 + bonus; // условная мощность
+ const radius = 3.2 + bonus * 0.7; // радиус разрушения в тайлах
+ const dmgR = 150 + bonus * 60; // радиус урона в пикселях
+
+ removeBlock(gx, gy);
+ state.activeTNT.delete(k(gx, gy));
+ playSound('explode1'); // Звук взрыва
+ spawnExplosion(gx * TILE + TILE / 2, gy * TILE + TILE / 2, power);
+
+ for (let x = Math.floor(gx - radius); x <= Math.ceil(gx + radius); x++) {
+ for (let y = Math.floor(gy - radius); y <= Math.ceil(gy + radius); y++) {
+ const d = Math.hypot(x - gx, y - gy);
+ if (d > radius) continue;
+ const b = getBlock(x, y);
+ if (!b || b.dead) continue;
+ if (BLOCKS[b.t].fluid) continue;
+ if (BLOCKS[b.t].unbreakable) continue;
+ if (b.t === 'tnt') { activateTNT(b, 0.12); continue; }
+ removeBlock(x, y);
+ if (state.inv[b.t] !== undefined && Math.random() < 0.20) state.inv[b.t]++; // немного дропа
+ }
+ }
+ rebuildHotbar();
+
+ // урон
+ const hurt = (e) => {
+ const dx = (e.x + e.w / 2) - (gx * TILE + TILE / 2);
+ const dy = (e.y + e.h / 2) - (gy * TILE + TILE / 2);
+ const dist = Math.hypot(dx, dy);
+ if (dist < dmgR) {
+ const dmg = (dmgR - dist) * 0.06 * power;
+ if (e === state.player) {
+ const actualDamage = calculateDamage(dmg);
+ state.player.hp -= actualDamage;
+ } else {
+ e.hp -= dmg;
+ }
+ e.vx += (dx / dist || 0) * 600;
+ e.vy -= 320;
+ }
+ };
+ hurt(state.player);
+ state.mobs.forEach(hurt);
+}
+
+export { activateTNT, explodeAt };
\ No newline at end of file
diff --git a/src/world/water.js b/src/world/water.js
new file mode 100644
index 0000000..efb6627
--- /dev/null
+++ b/src/world/water.js
@@ -0,0 +1,126 @@
+import { TILE, SEA_GY } from '../core/constants.js';
+import { state } from '../core/state.js';
+import { W, H } from '../core/canvas.js';
+import { grid, blocks, k, isSolid } from './world-storage.js';
+
+// Физика жидкости
+const waterUpdateQueue = new Set();
+let waterUpdateTimer = 0;
+const WATER_UPDATE_INTERVAL = 0.05; // Обновляем воду каждые 0.05 секунды
+
+function updateWaterPhysics(dt) {
+ waterUpdateTimer += dt;
+ if (waterUpdateTimer < WATER_UPDATE_INTERVAL) return;
+ waterUpdateTimer = 0;
+
+ // Ограничиваем количество водных блоков для обработки (оптимизация)
+ const MAX_WATER_BLOCKS_PER_UPDATE = 50;
+ let processedCount = 0;
+
+ // Собираем только видимые водные блоки в очередь (оптимизация)
+ waterUpdateQueue.clear();
+ const minGX = Math.floor(state.camX / TILE) - 10;
+ const maxGX = Math.floor((state.camX + W) / TILE) + 10;
+ const minGY = Math.floor(state.camY / TILE) - 10;
+ const maxGY = Math.floor((state.camY + H) / TILE) + 10;
+
+ for (const b of blocks) {
+ if (processedCount >= MAX_WATER_BLOCKS_PER_UPDATE) break;
+ if (!b.dead && b.t === 'water' &&
+ b.gx >= minGX && b.gx <= maxGX &&
+ b.gy >= minGY && b.gy <= maxGY) {
+ waterUpdateQueue.add(k(b.gx, b.gy));
+ processedCount++;
+ }
+ }
+
+ // Обновляем воду с ограничением глубины распространения
+ const processed = new Set();
+ const toAdd = [];
+ const MAX_WATER_DEPTH = 20; // Максимальная глубина распространения воды
+
+ for (const key of waterUpdateQueue) {
+ if (processed.has(key)) continue;
+ const b = grid.get(key);
+ if (!b || b.dead) continue;
+ processed.add(key);
+
+ const gx = b.gx;
+ const gy = b.gy;
+
+ // Проверяем глубину - не распространяем воду слишком глубоко
+ if (gy > SEA_GY + MAX_WATER_DEPTH) continue;
+
+ // Проверяем, можно ли воде упасть вниз
+ const belowKey = k(gx, gy + 1);
+ const below = grid.get(belowKey);
+
+ // Внизу пусто - вода создаёт новый блок внизу (но не удаляется сверху)
+ if (!below || below.dead) {
+ // Ограничиваем создание новых водных блоков
+ if (toAdd.length < 20) { // Максимум 20 новых блоков за обновление
+ toAdd.push({ gx, gy: gy + 1, t: 'water' });
+ processed.add(belowKey);
+ }
+ continue;
+ }
+
+ // Если внизу не вода и не твёрдый блок - вода может течь вниз
+ if (!isSolid(gx, gy + 1) && below && below.t !== 'water') {
+ if (toAdd.length < 20) {
+ toAdd.push({ gx, gy: gy + 1, t: 'water' });
+ processed.add(belowKey);
+ }
+ continue;
+ }
+
+ // Если внизу твёрдый блок или вода - вода растекается горизонтально
+ // Проверяем левую сторону
+ const leftKey = k(gx - 1, gy);
+ const left = grid.get(leftKey);
+ if (!left || left.dead) {
+ if (toAdd.length < 20) {
+ toAdd.push({ gx: gx - 1, gy, t: 'water' });
+ processed.add(leftKey);
+ }
+ continue;
+ }
+
+ // Проверяем правую сторону
+ const rightKey = k(gx + 1, gy);
+ const right = grid.get(rightKey);
+ if (!right || right.dead) {
+ if (toAdd.length < 20) {
+ toAdd.push({ gx: gx + 1, gy, t: 'water' });
+ processed.add(rightKey);
+ }
+ continue;
+ }
+ }
+
+ // Применяем изменения (только добавляем новые блоки)
+ for (const newData of toAdd) {
+ const key = k(newData.gx, newData.gy);
+ if (!grid.has(key)) {
+ const b = {
+ gx: newData.gx,
+ gy: newData.gy,
+ t: newData.t,
+ dead: false,
+ active: false,
+ fuse: 0
+ };
+ grid.set(key, b);
+ blocks.push(b);
+ }
+ }
+
+ // Очищаем мёртвые блоки из массива
+ for (let i = blocks.length - 1; i >= 0; i--) {
+ if (blocks[i].dead) {
+ blocks.splice(i, 1);
+ }
+ }
+}
+
+export { updateWaterPhysics };
\ No newline at end of file
diff --git a/src/world/weather.js b/src/world/weather.js
new file mode 100644
index 0000000..a140737
--- /dev/null
+++ b/src/world/weather.js
@@ -0,0 +1,59 @@
+// ==================== ПОГОДА ====================
+import { state } from '../core/state.js';
+import { isNight } from '../entities/mob-ai.js';
+
+export function updateWeather(dt) {
+ state.weatherTimer += dt;
+ if (state.weatherTimer >= state.weatherChangeInterval) {
+ state.weatherTimer = 0;
+ state.weatherChangeInterval = 60 + Math.random() * 120;
+ // Выбираем новую погоду: 40% дождь днём, 25% дождь ночью, остальное ясно
+ const nightChance = isNight() ? 0.25 : 0.40;
+ state.isRaining = Math.random() < nightChance;
+ }
+ // Плавная интерполяция интенсивности
+ const target = state.isRaining ? (0.4 + Math.random() * 0.01) : 0;
+ state.rainIntensity += (target - state.rainIntensity) * dt * 0.5;
+ if (state.rainIntensity < 0.01) state.rainIntensity = 0;
+}
+
+export function updateRain(dt) {
+ if (!state.isRaining || state.rainIntensity < 0.01) {
+ state.raindrops.length = 0;
+ return;
+ }
+ // Спавн капель
+ const spawnRate = Math.floor(state.rainIntensity * 60 * dt); // ~60 капель/сек при интенсити=1
+ for (let i = 0; i < spawnRate && state.raindrops.length < state.MAX_RAINDROPS; i++) {
+ state.raindrops.push({
+ x: state.camX + Math.random() * state.W,
+ y: state.camY - 20,
+ vy: 400 + Math.random() * 200,
+ len: 8 + Math.random() * 12
+ });
+ }
+ // Обновление
+ for (let i = state.raindrops.length - 1; i >= 0; i--) {
+ const d = state.raindrops[i];
+ d.y += d.vy * dt;
+ d.x -= 30 * dt; // лёгкий ветер
+ if (d.y > state.camY + state.H + 20) {
+ state.raindrops.splice(i, 1);
+ }
+ }
+}
+
+export function drawRain() {
+ if (state.raindrops.length === 0) return;
+ const ctx = state.ctx;
+ ctx.save();
+ ctx.strokeStyle = 'rgba(174,194,224,0.5)';
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ for (const d of state.raindrops) {
+ ctx.moveTo(d.x, d.y);
+ ctx.lineTo(d.x - 3, d.y + d.len);
+ }
+ ctx.stroke();
+ ctx.restore();
+}
\ No newline at end of file
diff --git a/src/world/world-storage.js b/src/world/world-storage.js
new file mode 100644
index 0000000..fb249cc
--- /dev/null
+++ b/src/world/world-storage.js
@@ -0,0 +1,57 @@
+import { BLOCKS } from '../data/blocks.js';
+import { state } from '../core/state.js';
+
+// Мир-хранилище
+const grid = new Map(); // key "gx,gy" => {gx,gy,t, ...}
+const blocks = []; // для рендера/перебора видимых
+
+function k(gx, gy) { return gx + ',' + gy; }
+
+function getBlock(gx, gy) { return grid.get(k(gx, gy)); }
+
+function hasBlock(gx, gy) { return grid.has(k(gx, gy)); }
+
+function isSolid(gx, gy) {
+ const b = getBlock(gx, gy);
+ if (!b || b.dead) return false;
+ const def = BLOCKS[b.t];
+ return !!def.solid && !def.fluid && !def.decor;
+}
+
+function setBlock(gx, gy, t, isPlayerPlaced = false) {
+ const key = k(gx, gy);
+ if (grid.has(key)) return false;
+ const b = { gx, gy, t, dead: false, active: false, fuse: 0 };
+ grid.set(key, b);
+ blocks.push(b);
+
+ // Отслеживаем блоки, установленные игроком
+ if (isPlayerPlaced) {
+ state.placedBlocks.push({ gx, gy, t });
+ }
+
+ return true;
+}
+
+function removeBlock(gx, gy) {
+ const key = k(gx, gy);
+ const b = grid.get(key);
+ if (!b) return null;
+ if (BLOCKS[b.t].unbreakable) return null;
+ grid.delete(key);
+ b.dead = true;
+
+ // Отслеживаем удалённые блоки
+ const wasPlayerPlaced = state.placedBlocks.some(pb => pb.gx === gx && pb.gy === gy);
+ if (wasPlayerPlaced) {
+ // Удаляем из placedBlocks
+ state.placedBlocks = state.placedBlocks.filter(pb => !(pb.gx === gx && pb.gy === gy));
+ } else {
+ // Это природный блок - добавляем в removedBlocks
+ state.removedBlocks.push({ gx, gy });
+ }
+
+ return b;
+}
+
+export { grid, blocks, k, getBlock, hasBlock, isSolid, setBlock, removeBlock };
\ No newline at end of file