diff --git a/Dockerfile b/Dockerfile index cdc5e28..f47a2d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,7 @@ COPY style.css /usr/share/nginx/html/style.css COPY game.js /usr/share/nginx/html/game.js COPY three147.min.js /usr/share/nginx/html/three147.min.js COPY game3d.js /usr/share/nginx/html/game3d.js +COPY tex/ /usr/share/nginx/html/tex/ COPY index-v2.html /usr/share/nginx/html/v2.html COPY manifest.json /usr/share/nginx/html/manifest.json COPY sw.js /usr/share/nginx/html/sw.js diff --git a/_archive/game-monolith-archive.tar.gz b/_archive/game-monolith-archive.tar.gz new file mode 100644 index 0000000..c6b2c49 Binary files /dev/null and b/_archive/game-monolith-archive.tar.gz differ diff --git a/build.err b/build.err new file mode 100644 index 0000000..e69de29 diff --git a/build.js b/build.js new file mode 100644 index 0000000..03d0623 --- /dev/null +++ b/build.js @@ -0,0 +1,107 @@ +#!/usr/bin/env node +/** + * Simple ES-module → IIFE bundler for GrechkaCraft + * Resolves import/export, wraps in single IIFE, no external deps. + * Usage: node build.js > game.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 = []; + +const fileExports = {}; +const fileImports = {}; + +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 = []; + 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); + } + const re2 = /export\s+(?:async\s+)?(?:const|let|var|function|class)\s+(\w+)/g; + while ((m = re2.exec(content)) !== null) { + exports.push({ local: m[1], exported: m[1] }); + } + if (/export\s+default\s+/.test(content)) { + 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] }); + } + 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); + + fileExports[relPath] = collectExports(content); + fileImports[relPath] = collectImports(content); + + 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+(?:async\s+)?(const|let|var|function|class)\s/g, '$1 '); + content = content.replace(/export\s*\{[^}]*\};?/g, ''); + + chunks.push({ relPath, content }); +} + +processFile(path.join(SRC, ENTRY)); + +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 1cda237..182981b 100644 --- a/game.js +++ b/game.js @@ -1,973 +1,2268 @@ -(() => { - // 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; - var MOVE = 320; - var JUMP = 760; - var DAY_LEN = 360; - var SAVE_KEY = "minegrechka_save"; - var MOVE_SEND_INTERVAL = 0.05; - var MAX_RAINDROPS = 200; - var MAX_CHAT_MESSAGES = 50; - var VOICE_SERVER = "wss://voicegrech.mkn8n.ru"; +// GrechkaCraft — auto-bundled from ES modules +(function() { +"use strict"; - // src/core/state.js - var state = { - // Константы (для доступа из модулей) - TILE, - SEA_GY, - BEDROCK_GY, - GEN_MARGIN_X, - GRAV, - GRAV_WATER, - MOVE, - JUMP, - DAY_LEN, - SAVE_KEY, - MOVE_SEND_INTERVAL, - MAX_RAINDROPS, - MAX_CHAT_MESSAGES, - VOICE_SERVER, - // Камера - camX: 0, - camY: 0, - W: 0, - H: 0, - dpr: 1, - // Canvas/DOM (заполняется в main.js) - gameEl: null, - canvas: null, - ctx: null, - lightC: null, - lightCtx: null, - // День/ночь - worldTime: 0, - isNightTime: false, - // Мультиплеер - isMultiplayer: false, - mySocketId: null, - socket: null, - voiceSocket: null, - voicePosT: 0, - // Сервер - SERVER_URL: "", - TELEGRAM_BOT_USERNAME: "", - TELEGRAM_APP_SHORT_NAME: "", - // Игрок - playerName: null, - worldId: null, - // Инвентарь/UI - selected: 0, - showFullInventory: false, - craftOpen: false, - inventoryOpen: false, - chatOpen: false, - minimapOpen: false, - modeIdx: 0, - currentFurnaceKey: null, - // Мир - 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, - // Mining progress - mining: { gx: null, gy: null, progress: 0, hardness: 0 }, - // Trade/Market - myTgId: null, - tradePanel: null, - tradeContent: null, - marketPanel: null, - marketContent: null, - // DOM-ссылки (заполняются в main.js) - hpEl: null, - foodEl: null, - sxEl: null, - syEl: null, - todEl: null, - worldIdEl: null, - playerCountEl: null, - deathEl: null, - hotbarEl: null, - craftPanel: null, - recipesEl: null, - inventoryPanel: null, - inventoryGrid: null, - multiplayerStatus: null, - // Игрок - 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, - wood_axe: 0, - stone_axe: 0, - iron_axe: 0, - wood_shovel: 0, - stone_shovel: 0, - iron_shovel: 0, - iron_armor: 0, - bow: 0, - furnace: 0, - bed: 0, - boat: 0, - iron_ingot: 0, - gold_ingot: 0, - copper_ingot: 0, - cobblestone: 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, l: false, r: false, j: false, s: 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, - // Сетка мира - grid: /* @__PURE__ */ new Map(), - blocks: [], - // In-memory save fallback - inMemorySave: null - }; - - // src/core/canvas.js - function initCanvas() { - const gameEl = document.getElementById("game"); - const canvas = document.getElementById("c"); - const ctx = canvas.getContext("2d"); - const lightC = document.createElement("canvas"); - const lightCtx = lightC.getContext("2d"); - const dpr = Math.max(1, window.devicePixelRatio || 1); - function resize() { - state.W = gameEl.clientWidth; - state.H = gameEl.clientHeight; - canvas.width = state.W * dpr; - canvas.height = state.H * dpr; - lightC.width = state.W * dpr; - lightC.height = state.H * dpr; - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - } - window.addEventListener("resize", resize); - resize(); - state.gameEl = gameEl; - state.canvas = canvas; - state.ctx = ctx; - state.lightC = lightC; - state.lightCtx = lightCtx; - state.dpr = dpr; +// Touch device detection & PWA service worker +(function() { + // Touch device classes + if ('ontouchstart' in window || navigator.maxTouchPoints > 0) { + document.body.classList.add('touch-device'); } - - // 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."); - } - - // src/data/blocks.js - var BLOCKS = { - air: { n: "\u0412\u043E\u0437\u0434\u0443\u0445", solid: false, hardness: 0 }, - grass: { n: "\u0422\u0440\u0430\u0432\u0430", c: "#7cfc00", solid: true, hardness: 3, toolType: "shovel" }, - dirt: { n: "\u0413\u0440\u044F\u0437\u044C", c: "#8b4513", solid: true, hardness: 3, toolType: "shovel" }, - stone: { n: "\u041A\u0430\u043C\u0435\u043D\u044C", c: "#7f8c8d", solid: true, hardness: 6, toolType: "pickaxe" }, - sand: { n: "\u041F\u0435\u0441\u043E\u043A", c: "#f4d06f", solid: true, hardness: 2, toolType: "shovel" }, - gravel: { n: "\u0413\u0440\u0430\u0432\u0438\u0439", c: "#95a5a6", solid: true, hardness: 3, toolType: "shovel" }, - clay: { n: "\u0413\u043B\u0438\u043D\u0430", c: "#74b9ff", solid: true, hardness: 3, toolType: "shovel" }, - wood: { n: "\u0414\u0435\u0440\u0435\u0432\u043E", c: "#d35400", solid: true, hardness: 4, toolType: "axe" }, - planks: { n: "\u0414\u043E\u0441\u043A\u0438", c: "#e67e22", solid: true, hardness: 3, toolType: "axe" }, - ladder: { n: "\u041B\u0435\u0441\u0442\u043D\u0438\u0446\u0430", c: "#d35400", solid: false, climbable: true, hardness: 1, toolType: null }, - leaves: { n: "\u041B\u0438\u0441\u0442\u0432\u0430", c: "#2ecc71", solid: true, hardness: 1, toolType: "axe", minTool: null }, - glass: { n: "\u0421\u0442\u0435\u043A\u043B\u043E", c: "rgba(200,240,255,0.25)", solid: true, alpha: 0.55, hardness: 1, toolType: null }, - water: { n: "\u0412\u043E\u0434\u0430", c: "rgba(52,152,219,0.55)", solid: false, fluid: true, hardness: 0 }, - coal: { n: "\u0423\u0433\u043E\u043B\u044C", c: "#2c3e50", solid: true, hardness: 8, toolType: "pickaxe", minTool: "wood" }, - copper_ore: { n: "\u041C\u0435\u0434\u044C", c: "#e17055", solid: true, hardness: 10, toolType: "pickaxe", minTool: "wood" }, - iron_ore: { n: "\u0416\u0435\u043B\u0435\u0437\u043E", c: "#dcdde1", solid: true, hardness: 12, toolType: "pickaxe", minTool: "stone" }, - iron_armor: { n: "\u0416\u0435\u043B\u0435\u0437\u043D\u0430\u044F \u0431\u0440\u043E\u043D\u044F", c: "#95a5a6", solid: false, armor: 0.5, hardness: 0 }, - gold_ore: { n: "\u0417\u043E\u043B\u043E\u0442\u043E", c: "#f1c40f", solid: true, hardness: 15, toolType: "pickaxe", minTool: "stone" }, - diamond_ore: { n: "\u0410\u043B\u043C\u0430\u0437", c: "#00a8ff", solid: true, hardness: 20, toolType: "pickaxe", minTool: "iron" }, - brick: { n: "\u041A\u0438\u0440\u043F\u0438\u0447", c: "#c0392b", solid: true, hardness: 8, toolType: "pickaxe" }, - tnt: { n: "TNT", c: "#e74c3c", solid: true, explosive: true, hardness: 1, toolType: null }, - campfire: { n: "\u041A\u043E\u0441\u0442\u0451\u0440", c: "#e67e22", solid: true, lightRadius: 190, hardness: 2, toolType: null }, - torch: { n: "\u0424\u0430\u043A\u0435\u043B", c: "#f9ca24", solid: true, lightRadius: 140, hardness: 1, toolType: null }, - bedrock: { n: "\u0411\u0435\u0434\u0440\u043E\u043A", c: "#2d3436", solid: true, unbreakable: true, hardness: Infinity }, - flower: { n: "\u0426\u0432\u0435\u0442\u043E\u043A", c: "#ff4757", solid: false, decor: true, hardness: 0 }, - bed: { n: "\u041A\u0440\u043E\u0432\u0430\u0442\u044C", c: "#e91e63", solid: true, bed: true, hardness: 2, toolType: "axe" }, - boat: { n: "\u041B\u043E\u0434\u043A\u0430", c: "#8B4513", solid: false, hardness: 0 }, - furnace: { n: "\u041F\u0435\u0447\u044C", c: "#696969", solid: true, smelting: true, hardness: 8, toolType: "pickaxe", minTool: "wood" }, - cobblestone: { n: "\u0411\u0443\u043B\u044B\u0436\u043D\u0438\u043A", c: "#7f8c8d", solid: true, hardness: 6, toolType: "pickaxe" } - }; - - // src/world/world-storage.js - var grid = state.grid; - var blocks = state.blocks; - function k(gx, gy) { - return gx + "," + gy; - } - function getBlock(gx, gy) { - return grid.get(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) { - state.placedBlocks = state.placedBlocks.filter((pb) => !(pb.gx === gx && pb.gy === gy)); + // Orientation handler + function updateOrientation() { + if (window.innerHeight > window.innerWidth) { + document.body.classList.remove('landscape'); + document.body.classList.add('portrait'); } 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 = getBlock(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; - } - } 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); - } - } + document.body.classList.remove('portrait'); + document.body.classList.add('landscape'); } } - 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"); - function playSound(id) { - if (sounds[id]) { - sounds[id].currentTime = 0; - sounds[id].play().catch((e) => console.error("Sound error:", e)); - } - } - - // src/ui/chat.js - var chatMessages = []; - var MAX_CHAT_MESSAGES2 = 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_MESSAGES2) { - 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 = ""; - } + window.addEventListener('resize', updateOrientation); + window.addEventListener('orientationchange', function() { + setTimeout(updateOrientation, 100); + }); + updateOrientation(); + // PWA: service worker for fullscreen install + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('sw.js').catch(function(err) { + console.warn('[SW] Registration failed:', err); }); } - - // 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; + // Fullscreen: hide browser chrome on mobile + function goFullscreen() { + if (window.Telegram?.WebApp) { + Telegram.WebApp.expand(); + try { Telegram.WebApp.requestFullscreen(); } catch(e) {} + return; + } + if (!document.fullscreenElement && !document.webkitFullscreenElement) { + var el = document.documentElement; + if (el.requestFullscreen) el.requestFullscreen().catch(function(){}); + else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen(); + } + // Scroll to hide address bar on iOS/Android + window.scrollTo(0, 0); } - - // 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 } - }; - 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}" }; - ITEMS.wood_axe = { n: "\u0414\u0435\u0440\u0435\u0432\u044F\u043D\u043D\u044B\u0439 \u0442\u043E\u043F\u043E\u0440", icon: "\u{1FA93}" }; - ITEMS.stone_axe = { n: "\u041A\u0430\u043C\u0435\u043D\u043D\u044B\u0439 \u0442\u043E\u043F\u043E\u0440", icon: "\u{1FA93}" }; - ITEMS.iron_axe = { n: "\u0416\u0435\u043B\u0435\u0437\u043D\u044B\u0439 \u0442\u043E\u043F\u043E\u0440", icon: "\u{1FA93}" }; - ITEMS.wood_shovel = { n: "\u0414\u0435\u0440\u0435\u0432\u044F\u043D\u043D\u0430\u044F \u043B\u043E\u043F\u0430\u0442\u0430", icon: "\u{1F531}" }; - ITEMS.stone_shovel = { n: "\u041A\u0430\u043C\u0435\u043D\u043D\u0430\u044F \u043B\u043E\u043F\u0430\u0442\u0430", icon: "\u{1F531}" }; - ITEMS.iron_shovel = { n: "\u0416\u0435\u043B\u0435\u0437\u043D\u0430\u044F \u043B\u043E\u043F\u0430\u0442\u0430", icon: "\u{1F531}" }; - - // 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", damage: 1, durability: 60, toolType: "pickaxe", toolTier: "wood", speed: 2 }, - stone_pickaxe: { n: "\u041A\u0430\u043C\u0435\u043D\u043D\u0430\u044F \u043A\u0438\u0440\u043A\u0430", icon: "\u26CF\uFE0F", damage: 2, durability: 132, toolType: "pickaxe", toolTier: "stone", speed: 4 }, - iron_pickaxe: { n: "\u0416\u0435\u043B\u0435\u0437\u043D\u0430\u044F \u043A\u0438\u0440\u043A\u0430", icon: "\u26CF\uFE0F", damage: 3, durability: 250, toolType: "pickaxe", toolTier: "iron", speed: 6 }, - wood_sword: { n: "\u0414\u0435\u0440\u0435\u0432\u044F\u043D\u043D\u044B\u0439 \u043C\u0435\u0447", icon: "\u{1F5E1}\uFE0F", damage: 4, durability: 60 }, - stone_sword: { n: "\u041A\u0430\u043C\u0435\u043D\u043D\u044B\u0439 \u043C\u0435\u0447", icon: "\u{1F5E1}\uFE0F", damage: 5, durability: 132 }, - iron_sword: { n: "\u0416\u0435\u043B\u0435\u0437\u043D\u044B\u0439 \u043C\u0435\u0447", icon: "\u{1F5E1}\uFE0F", damage: 7, durability: 250 }, - wood_axe: { n: "\u0414\u0435\u0440\u0435\u0432\u044F\u043D\u043D\u044B\u0439 \u0442\u043E\u043F\u043E\u0440", icon: "\u{1FA93}", damage: 3, durability: 60, toolType: "axe", toolTier: "wood", speed: 2 }, - stone_axe: { n: "\u041A\u0430\u043C\u0435\u043D\u043D\u044B\u0439 \u0442\u043E\u043F\u043E\u0440", icon: "\u{1FA93}", damage: 4, durability: 132, toolType: "axe", toolTier: "stone", speed: 4 }, - iron_axe: { n: "\u0416\u0435\u043B\u0435\u0437\u043D\u044B\u0439 \u0442\u043E\u043F\u043E\u0440", icon: "\u{1FA93}", damage: 5, durability: 250, toolType: "axe", toolTier: "iron", speed: 6 }, - wood_shovel: { n: "\u0414\u0435\u0440\u0435\u0432\u044F\u043D\u043D\u0430\u044F \u043B\u043E\u043F\u0430\u0442\u0430", icon: "\u{1F531}", damage: 1, durability: 60, toolType: "shovel", toolTier: "wood", speed: 2 }, - stone_shovel: { n: "\u041A\u0430\u043C\u0435\u043D\u043D\u0430\u044F \u043B\u043E\u043F\u0430\u0442\u0430", icon: "\u{1F531}", damage: 2, durability: 132, toolType: "shovel", toolTier: "stone", speed: 4 }, - iron_shovel: { n: "\u0416\u0435\u043B\u0435\u0437\u043D\u0430\u044F \u043B\u043E\u043F\u0430\u0442\u0430", icon: "\u{1F531}", damage: 3, durability: 250, toolType: "shovel", toolTier: "iron", speed: 6 }, - bow: { n: "\u041B\u0443\u043A", icon: "\u{1F3F9}", damage: 10, durability: 200 } - }; - 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 }); + // Try on first interaction + document.addEventListener('click', goFullscreen, { once: true }); + document.addEventListener('touchstart', goFullscreen, { once: true }); + // Retry on orientation change (browser chrome reappears on rotate) + window.addEventListener('orientationchange', function() { + setTimeout(goFullscreen, 300); + }); + // Also on resize (mobile address bar hides/shows) + window.addEventListener('resize', function() { + if (window.innerHeight > window.innerWidth) return; // portrait — fine + setTimeout(function() { + window.scrollTo(0, 0); + if (!document.fullscreenElement && !document.webkitFullscreenElement) { + goFullscreen(); + } + }, 200); + }); + // Lock scroll to prevent pull-to-refresh + document.addEventListener('touchmove', function(e) { + if (e.target === document.documentElement || e.target === document.body) { + e.preventDefault(); + } + }, { passive: false }); + // TG Mini App: expand to full screen + if (window.Telegram?.WebApp) { + Telegram.WebApp.expand(); + if (Telegram.WebApp.requestFullscreen) { + Telegram.WebApp.requestFullscreen(); + } } - 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; +})(); + + + +// === core/constants.js === +// ==================== КОНСТАНТЫ ИГРЫ ==================== + +const TILE = 40; + +// Мир +const SEA_GY = 14; // уровень воды (gy) - уменьшил для меньших водоёмов +const BEDROCK_GY = 140; // глубина бедрока (gy), чем больше - тем глубже +const GEN_MARGIN_X = 40; // запас генерации по X (в тайлах) — увеличен для плавной прогрузки + +// Физика +const GRAV = 2200; +const GRAV_WATER = 550; +const MOVE = 320; +const JUMP = 760; + +// День/ночь +const DAY_LEN = 360; // замедлил смену дня/ночи в 3 раза + +// Сохранение +const SAVE_KEY = 'minegrechka_save'; + +// Сеть +const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду + +// Погода +const MAX_RAINDROPS = 200; + +// Чат +const MAX_CHAT_MESSAGES = 50; + +// Голосовой чат +const VOICE_SERVER = 'wss://voicegrech.mkn8n.ru'; + +// === core/state.js === +// Все мутабельные переменные игры в одном объекте состояния +const state = { + // Константы (для доступа из модулей) + TILE, + SEA_GY, + BEDROCK_GY, + GEN_MARGIN_X, + GRAV, + GRAV_WATER, + MOVE, + JUMP, + DAY_LEN, + SAVE_KEY, + MOVE_SEND_INTERVAL, + MAX_RAINDROPS, + MAX_CHAT_MESSAGES, + VOICE_SERVER, + + // Камера + camX: 0, + camY: 0, + W: 0, + H: 0, + dpr: 1, + + // Canvas/DOM (заполняется в main.js) + gameEl: null, + canvas: null, + ctx: null, + lightC: null, + lightCtx: null, + + // День/ночь + worldTime: 0, + isNightTime: false, + dayNightCycle: true, + + // Мультиплеер + isMultiplayer: false, + mySocketId: null, + socket: null, + voiceSocket: null, + voicePosT: 0, + + // Сервер + SERVER_URL: '', + TELEGRAM_BOT_USERNAME: '', + TELEGRAM_APP_SHORT_NAME: '', + + // Игрок + playerName: null, + worldId: null, + + // Инвентарь/UI + selected: "dirt", + showFullInventory: false, + craftOpen: false, + inventoryOpen: false, + chatOpen: false, + minimapOpen: false, + modeIdx: 0, + currentFurnaceKey: null, + + // Мир + worldSeed: Math.floor(Math.random() * 1000000), + + // Погода + weatherType: "clear", + isRaining: false, + rainIntensity: 0, + weatherTimer: 0, + weatherChangeInterval: 60 + Math.random() * 120, + snowflakes: [], + lightningFlash: 0, + lightningTimer: 0, + + // Мобы/спавн + spawnT: 0, + + // Цикл + last: 0, + prevJump: false, + + // Сеть — throttle отправки позиции + lastMoveSendTime: 0, + lastSentX: 0, + lastSentY: 0, + + // Mining progress + mining: { gx: null, gy: null, progress: 0, hardness: 0 }, + + // Trade/Market + myTgId: null, + tradePanel: null, + tradeContent: null, + marketPanel: null, + marketContent: null, + + // DOM-ссылки (заполняются в main.js) + hpEl: null, + foodEl: null, + sxEl: null, + syEl: null, + todEl: null, + worldIdEl: null, + playerCountEl: null, + deathEl: null, + hotbarEl: null, + craftPanel: null, + recipesEl: null, + inventoryPanel: null, + inventoryGrid: null, + multiplayerStatus: null, + tempEl: null, + + // Игрок + 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, + temperature: 15, + slowTimer: 0 + }, + + // Точка спавна + 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, + wood_axe: 0, stone_axe: 0, iron_axe: 0, + wood_shovel: 0, stone_shovel: 0, iron_shovel: 0, + iron_armor: 0, gold_armor: 0, + bow: 0, furnace: 0, + bed: 0, boat: 0, + iron_ingot: 0, gold_ingot: 0, copper_ingot: 0, + cobblestone: 0, + // New items + chicken_meat: 0, feather: 0, bone: 0, gunpowder: 0, + wheat: 0, bread: 0, carrot: 0, potato: 0, baked_potato: 0, + mushroom_stew: 0, + scorpion_stinger: 0, polar_fur: 0, slime_ball: 0, eagle_feather: 0, + snow: 0, ice: 0, cactus: 0, mushroom: 0, moss: 0, farmland: 0, + spruce_leaves: 0, dead_bush: 0, swamp_water: 0, + wheat_seeds: 0, wheat_stage0: 0, carrot_stage0: 0, potato_stage0: 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, l: false, r: false, j: false, s: 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(), + protections: new Map(), + + // Последние выбранные предметы + recentItems: [], + + // Активные печи + activeFurnaces: new Map(), + cropTimers: new Map(), + + // Сгенерированные колонны + generated: new Set(), + + // Изображение героя + heroImg: new Image(), + + // Сетка мира + grid: new Map(), + blocks: [], + + // In-memory save fallback + inMemorySave: null, +}; + +// === config.js === +// ==================== КОНФИГУРАЦИЯ СЕРВЕРА ==================== +// This module only sets SERVER_URL. Player name and world ID are handled in main.js +// to avoid duplicate prompts. + +const urlParams = new URLSearchParams(window.location.search); +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. Это может вызвать проблемы.'); +} + +// === ui/start-menu.js === +// ==================== START MENU ==================== + + +// Detect Telegram WebApp +const isTgWebApp = !!(window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData); +let tgUser = null; +let tgSaveLoaded = false; + +if (isTgWebApp) { + try { + Telegram.WebApp.ready(); + Telegram.WebApp.expand(); + tgUser = Telegram.WebApp.initDataUnsafe?.user || null; + console.log('[TG] Mini App detected, user:', tgUser?.id, tgUser?.username); + // Set player name from TG + if (tgUser && tgUser.first_name) { + const tgName = tgUser.username || tgUser.first_name; + localStorage.setItem('minegrechka_playerName', tgName); + } + } catch (e) { + console.warn('[TG] WebApp init error:', e); + } +} + +// Export TG info for other modules + + +// ==================== HELPERS ==================== + +function generateShortWorldCode() { + // Generate readable 6-char code: consonants + digits (easy to copy/share) + const c = 'bcdfghjkmnpqrstvwxyz'; + const d = '23456789'; + let code = ''; + for (let i = 0; i < 3; i++) code += c[Math.floor(Math.random() * c.length)]; + for (let i = 0; i < 3; i++) code += d[Math.floor(Math.random() * d.length)]; + return code; +} + +function showToast(msg, duration) { + duration = duration || 1800; + const t = document.createElement('div'); + t.textContent = msg; + t.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.85);color:#2ecc71;padding:10px 24px;border-radius:10px;font:bold 16px Arial,sans-serif;z-index:99999;pointer-events:none;transition:opacity 0.4s;'; + document.body.appendChild(t); + setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 400); }, duration); +} + +function customAlert(msg) { + const overlay = document.createElement('div'); + overlay.className = 'custom-modal-overlay'; + overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:100000;'; + const box = document.createElement('div'); + box.style.cssText = 'background:#C6C6C6;border:4px solid #000;padding:24px;max-width:360px;width:90%;box-shadow:4px 4px 0 #000;font-family:Courier New,monospace;'; + const text = document.createElement('div'); + text.textContent = msg; + text.style.marginBottom = '16px'; + text.style.fontWeight = 'bold'; + text.style.fontSize = '16px'; + const btn = document.createElement('button'); + btn.textContent = 'OK'; + btn.style.cssText = 'padding:8px 24px;font-family:Courier New,monospace;font-weight:bold;cursor:pointer;border:3px solid #000;background:#5A8F3C;color:#fff;box-shadow:2px 2px 0 #000;'; + btn.onclick = () => overlay.remove(); + box.appendChild(text); + box.appendChild(btn); + overlay.appendChild(box); + document.body.appendChild(overlay); +} + +// ==================== MAIN START MENU ==================== + +// Minecraft-style CSS for the start menu +const mcCSS = ` + .mc-overlay { position:fixed;top:0;left:0;width:100%;height:100%;z-index:99999; + display:flex;align-items:center;justify-content:center;font-family:'Courier New',monospace; + image-rendering:pixelated; } + .mc-bg { position:absolute;inset:0;background:#000;opacity:0.75; } + .mc-dirt { position:absolute;bottom:0;left:0;right:0;height:40%; + background:repeating-linear-gradient(0deg,#8B6914 0px,#8B6914 2px,#7A5B12 2px,#7A5B12 4px);opacity:0.3; } + .mc-box { position:relative;z-index:1;background:#C6C6C6;border:4px solid #000; + padding:0;max-width:420px;width:92%;box-shadow:4px 4px 0 #000;max-height:90vh;display:flex;flex-direction:column; } + .mc-titlebar { background:#1a1a2e;padding:12px 16px;text-align:center; + border-bottom:4px solid #000; } + .mc-title { font-size:24px;font-weight:bold;color:#FFAA00;text-shadow:2px 2px 0 #000; + letter-spacing:2px; } + .mc-subtitle { font-size:13px;color:#AAA;margin-top:4px; } + .mc-body { padding:16px;background:#C6C6C6;max-height:80vh;overflow-y:auto;-webkit-overflow-scrolling:touch; } + .mc-btn { display:block;width:100%;padding:10px 16px;margin-bottom:8px; + font-family:'Courier New',monospace;font-size:16px;font-weight:bold; + cursor:pointer;text-align:center;border:3px solid #000; + image-rendering:pixelated;box-shadow:3px 3px 0 #000; + transition:transform 0.05s,box-shadow 0.05s; } + .mc-btn:active { transform:translate(2px,2px);box-shadow:1px 1px 0 #000; } + .mc-btn-green { background:#5A8F3C;color:#E0E0E0;border-color:#3A5F1C;text-shadow:1px 1px 0 #222; } + .mc-btn-green:hover { background:#6DAF4E; } + .mc-btn-blue { background:#3C5F9E;color:#E0E0E0;border-color:#2A4070;text-shadow:1px 1px 0 #222; } + .mc-btn-blue:hover { background:#4B73BE; } + .mc-btn-purple { background:#7B3FA0;color:#E0E0E0;border-color:#5A2C78;text-shadow:1px 1px 0 #222; } + .mc-btn-purple:hover { background:#9555BE; } + .mc-btn-orange { background:#B86E00;color:#E0E0E0;border-color:#8A5200;text-shadow:1px 1px 0 #222; } + .mc-btn-orange:hover { background:#DA8500; } + .mc-btn-red { background:#9E2B2B;color:#E0E0E0;border-color:#6E1B1B;text-shadow:1px 1px 0 #222; } + .mc-btn-red:hover { background:#BE3B3B; } + .mc-btn-gray { background:#555;color:#AAA;border-color:#333;text-shadow:1px 1px 0 #111; } + .mc-btn-gray:hover { background:#666;color:#DDD; } + .mc-btn-outline { background:transparent;color:#555;border-color:#555; + text-shadow:none;box-shadow:2px 2px 0 #333; } + .mc-btn-outline:hover { background:#DDD;color:#222; } + .mc-input { width:100%;padding:8px 12px;font-family:'Courier New',monospace; + font-size:16px;background:#FFF;border:3px solid #000;color:#222; + box-shadow:inset 2px 2px 0 #999;margin:4px 0;box-sizing:border-box; } + .mc-input:focus { outline:none;border-color:#FFAA00; } + .mc-label { font-size:13px;color:#444;font-weight:bold;margin:8px 0 4px; } + .mc-divider { border:none;border-top:2px solid #999;margin:12px 0; } + .mc-friend-row { display:flex;justify-content:space-between;align-items:center; + padding:6px 8px;margin-bottom:4px;background:#E8E8E8;border:2px solid #999; + box-shadow:2px 2px 0 #999; } + .mc-friend-btn { padding:4px 10px;font-family:'Courier New',monospace; + font-size:13px;font-weight:bold;cursor:pointer;border:2px solid #000; + box-shadow:1px 1px 0 #000; } + .mc-friend-btn:active { transform:translate(1px,1px);box-shadow:none; } + .mc-friend-btn-green { background:#5A8F3C;color:#E0E0E0; } + .mc-friend-btn-red { background:#9E2B2B;color:#E0E0E0; } + .mc-checkbox-row { display:flex;align-items:center;gap:8px;font-size:13px;color:#444;cursor:pointer; } + .mc-checkbox { width:18px;height:18px;accent-color:#5A8F3C; } +`; + +function hideBoot(){var b=document.getElementById("boot");if(b){b.remove();}} + +function showStartMenu() { + return new Promise(async (resolve) => { + const urlParams = new URLSearchParams(window.location.search); + const worldParam = urlParams.get('world'); + const tgStartParam = (isTgWebApp && Telegram.WebApp.initDataUnsafe?.start_param) + ? Telegram.WebApp.initDataUnsafe.start_param.trim() + : null; + + // If world is in URL or TG start param — skip menu, auto-join. + // `default` is NOT a playable world: the server returns blocks:[] for it, + // so auto-joining leaves the player staring at empty sky with no menu to + // escape to. Treat it as "no world chosen" and show the menu instead. + const RESERVED_WORLD_IDS = new Set(['', 'default', 'lobby', 'null', 'undefined']); + if ((worldParam && worldParam.trim() !== '' && !RESERVED_WORLD_IDS.has(worldParam.trim())) || tgStartParam) { + const worldId = (worldParam && worldParam.trim()) || tgStartParam; + console.log('[Menu] Joining world from URL/startParam:', worldId); + + // Set player name from TG or localStorage or default + if (!state.playerName) { + if (isTgWebApp && tgUser) { + state.playerName = tgUser.username || tgUser.first_name || 'Игрок'; + } else { + state.playerName = localStorage.getItem('minegrechka_playerName') || 'Игрок'; } - return false; + localStorage.setItem('minegrechka_playerName', state.playerName); + } + + hideBoot(); + resolve(worldId); + return; + } + + // Inject CSS + const styleEl = document.createElement('style'); + styleEl.textContent = mcCSS; + document.head.appendChild(styleEl); + + // Build overlay + const overlay = document.createElement('div'); + overlay.className = 'mc-overlay'; + overlay.id = 'startMenu'; + overlay.innerHTML = '
'; + + const box = document.createElement('div'); + box.className = 'mc-box'; + + // Title bar + const titleBar = document.createElement('div'); + titleBar.className = 'mc-titlebar'; + titleBar.innerHTML = '
⛏️ GrechkaCraft
' + + '
' + (isTgWebApp ? ('Привет, ' + (tgUser?.first_name || 'Игрок') + '!') : 'Voxel-мир в браузере') + '
'; + box.appendChild(titleBar); + + const body = document.createElement('div'); + body.className = 'mc-body'; + + // Mutable worldId holder + let selectedWorldId = null; + + // === Buttons === + + // 🌍 New World + const btnNew = document.createElement('button'); + btnNew.className = 'mc-btn mc-btn-green'; + btnNew.textContent = '🌍 Новый мир'; + btnNew.onclick = () => { + selectedWorldId = generateShortWorldCode(); + overlay.remove(); + styleEl.remove(); + resolve(selectedWorldId); + }; + body.appendChild(btnNew); + + // 🔗 Join by code + const btnJoin = document.createElement('button'); + btnJoin.className = 'mc-btn mc-btn-blue'; + btnJoin.textContent = '🔗 Вступить по коду'; + btnJoin.onclick = () => { + body.innerHTML = ''; + const lbl = document.createElement('div'); + lbl.className = 'mc-label'; + lbl.textContent = 'Код мира:'; + body.appendChild(lbl); + const codeInput = document.createElement('input'); + codeInput.className = 'mc-input'; + codeInput.type = 'text'; + codeInput.placeholder = 'bkh237'; + codeInput.maxLength = 12; + body.appendChild(codeInput); + const btnGo = document.createElement('button'); + btnGo.className = 'mc-btn mc-btn-blue'; + btnGo.textContent = 'Войти →'; + btnGo.onclick = () => { + const code = codeInput.value.trim().toLowerCase(); + if (code.length >= 3) { + selectedWorldId = code; + overlay.remove(); + styleEl.remove(); + resolve(selectedWorldId); + } + }; + body.appendChild(btnGo); + const btnBack = document.createElement('button'); + btnBack.className = 'mc-btn mc-btn-gray'; + btnBack.textContent = '← Назад'; + btnBack.onclick = () => { overlay.remove(); styleEl.remove(); showStartMenu().then(resolve); }; + body.appendChild(btnBack); + codeInput.focus(); + codeInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') btnGo.click(); }); + }; + body.appendChild(btnJoin); + + // 💾 Continue saved game + const hasLocalSave = !!localStorage.getItem(state.SAVE_KEY || 'minegrechka_save'); + if (isTgWebApp || hasLocalSave) { + const btnHistory = document.createElement('button'); + btnHistory.className = 'mc-btn mc-btn-purple'; + btnHistory.textContent = '💾 Продолжить игру'; + btnHistory.onclick = async () => { + if (isTgWebApp && tgUser) { + try { + const resp = await fetch(state.SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id); + const data = await resp.json(); + if (data.ok && data.world_id) { + selectedWorldId = data.world_id; + tgSaveLoaded = true; + overlay.remove(); + styleEl.remove(); + resolve(selectedWorldId); + return; + } + } catch (e) { console.warn('[TG] Load save failed:', e); } + } + if (hasLocalSave) { + // Continue with a new world code (the save will be loaded separately by initDB/loadGame) + selectedWorldId = generateShortWorldCode(); + overlay.remove(); + styleEl.remove(); + resolve(selectedWorldId); + return; + } + customAlert('Сохранений не найдено'); + }; + body.appendChild(btnHistory); + } + + // === TG-only buttons === + if (isTgWebApp && window.Telegram && Telegram.WebApp.switchInlineQuery) { + // 📤 Invite friends + const btnInvite = document.createElement('button'); + btnInvite.className = 'mc-btn mc-btn-outline'; + btnInvite.style.marginTop = '16px'; + btnInvite.textContent = '📤 Пригласить друзей'; + btnInvite.onclick = () => { + const code = selectedWorldId || generateShortWorldCode(); + const url = 'https://t.me/' + state.TELEGRAM_BOT_USERNAME + '/app?startapp=' + encodeURIComponent(code); + if (Telegram.WebApp && Telegram.WebApp.openTelegramLink) { + Telegram.WebApp.openTelegramLink('https://t.me/share/url?url=' + encodeURIComponent(url) + '&text=' + encodeURIComponent('🏗️ Заходи в GrechkaCraft! Мир: ' + code)); + } else { + navigator.clipboard.writeText(url).then(() => showToast('📋 Скопировано!')).catch(() => showToast(url)); + } + }; + body.appendChild(btnInvite); + + // 👥 Friends + const btnFriends = document.createElement('button'); + btnFriends.className = 'mc-btn mc-btn-orange'; + btnFriends.style.marginTop = '8px'; + btnFriends.textContent = '👥 Друзья'; + btnFriends.onclick = async () => { + try { + const resp = await fetch(state.SERVER_URL + '/api/tg/friends?tg_id=' + tgUser.id); + const data = await resp.json(); + body.innerHTML = ''; + + // Back button at top + const btnBackTop = document.createElement('button'); + btnBackTop.className = 'mc-btn mc-btn-gray'; + btnBackTop.textContent = '← Назад'; + btnBackTop.style.marginBottom = '12px'; + btnBackTop.onclick = () => { overlay.remove(); styleEl.remove(); showStartMenu().then(resolve); }; + body.appendChild(btnBackTop); + + // Nickname section + if (data.nickname) { + const nickDiv = document.createElement('div'); + nickDiv.className = 'mc-friend-row'; + nickDiv.innerHTML = '🔗 Твой ник: ' + data.nickname + ''; + nickDiv.style.background = '#D5E8C5'; + body.appendChild(nickDiv); + } else { + const nickLabel = document.createElement('div'); + nickLabel.className = 'mc-label'; + nickLabel.style.color = '#9E2B2B'; + nickLabel.textContent = '⚡ Задай ник, чтобы друзья могли тебя найти:'; + body.appendChild(nickLabel); + const nickRow = document.createElement('div'); + nickRow.style.display = 'flex'; + nickRow.style.gap = '4px'; + nickRow.style.marginBottom = '8px'; + const nickInput = document.createElement('input'); + nickInput.className = 'mc-input'; + nickInput.style.flex = '1'; + nickInput.type = 'text'; + nickInput.maxLength = 16; + nickInput.placeholder = 'Твой ник'; + const nickBtn = document.createElement('button'); + nickBtn.className = 'mc-btn mc-btn-green'; + nickBtn.style.width = 'auto'; + nickBtn.style.marginBottom = '0'; + nickBtn.style.padding = '8px 16px'; + nickBtn.textContent = '✓'; + nickBtn.onclick = async () => { + const n = nickInput.value.trim().toLowerCase(); + if (!n || n.length < 2) { showToast('❌ Минимум 2 символа'); return; } + const check = await fetch(state.SERVER_URL + '/api/nick/check?nick=' + encodeURIComponent(n)); + const cd = await check.json(); + if (!cd.available) { showToast('❌ Ник занят'); return; } + const setResp = await fetch(state.SERVER_URL + '/api/nick/set', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tg_id: tgUser.id, nickname: n }) + }); + const sd = await setResp.json(); + if (sd.ok) { showToast('✅ Ник: ' + sd.nickname); btnFriends.onclick(); } else { showToast('❌ Ошибка'); } + }; + nickRow.appendChild(nickInput); + nickRow.appendChild(nickBtn); + body.appendChild(nickRow); + } + + // Friends list + if (data.friends && data.friends.length) { + const listLabel = document.createElement('div'); + listLabel.className = 'mc-label'; + listLabel.textContent = '👥 Друзья:'; + body.appendChild(listLabel); + for (const f of data.friends) { + const fDiv = document.createElement('div'); + fDiv.className = 'mc-friend-row'; + const statusDot = f.online ? '🟢' : '⚫'; + let info = statusDot + ' ' + f.nickname + ''; + if (f.online && f.world_id) info += ' — мир ' + f.world_id; + const fInfo = document.createElement('div'); + fInfo.innerHTML = info; + fDiv.appendChild(fInfo); + if (f.online && f.world_id) { + const joinBtn = document.createElement('button'); + joinBtn.className = 'mc-friend-btn mc-friend-btn-green'; + joinBtn.textContent = '🎮 Вступить'; + joinBtn.onclick = () => { + selectedWorldId = f.world_id; + overlay.remove(); + styleEl.remove(); + resolve(selectedWorldId); + }; + fDiv.appendChild(joinBtn); + } + body.appendChild(fDiv); + } + } else if (data.friends && !data.friends.length) { + const noFriends = document.createElement('div'); + noFriends.className = 'mc-label'; + noFriends.style.color = '#888'; + noFriends.textContent = 'Пока нет друзей'; + body.appendChild(noFriends); + } + + // Pending friend requests + if (data.pending && data.pending.length) { + const pLabel = document.createElement('div'); + pLabel.className = 'mc-label'; + pLabel.style.color = '#B86E00'; + pLabel.textContent = '📨 Запросы в друзья:'; + body.appendChild(pLabel); + for (const p of data.pending) { + const pItem = document.createElement('div'); + pItem.className = 'mc-friend-row'; + const span = document.createElement('span'); + span.textContent = p.from; + pItem.appendChild(span); + const btns = document.createElement('div'); + btns.style.display = 'flex'; + btns.style.gap = '4px'; + const acceptBtn = document.createElement('button'); + acceptBtn.className = 'mc-friend-btn mc-friend-btn-green'; + acceptBtn.textContent = '✓'; + acceptBtn.onclick = async () => { + const r = await fetch(state.SERVER_URL + '/api/friends/accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tg_id: tgUser.id, request_id: p.id }) + }); + const d = await r.json(); + if (d.ok) { showToast('✅ Друг добавлен!'); btnFriends.onclick(); } else { showToast('❌ Ошибка'); } + }; + const declineBtn = document.createElement('button'); + declineBtn.className = 'mc-friend-btn mc-friend-btn-red'; + declineBtn.textContent = '✕'; + declineBtn.onclick = async () => { + await fetch(state.SERVER_URL + '/api/friends/decline', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tg_id: tgUser.id, request_id: p.id }) + }); + showToast('Отклонено'); + btnFriends.onclick(); + }; + btns.appendChild(acceptBtn); + btns.appendChild(declineBtn); + pItem.appendChild(btns); + body.appendChild(pItem); + } + } + + // Add friend + body.appendChild(document.createElement('hr')); + const addLabel = document.createElement('div'); + addLabel.className = 'mc-label'; + addLabel.textContent = '➕ Добавить друга по нику:'; + body.appendChild(addLabel); + const addRow = document.createElement('div'); + addRow.style.display = 'flex'; + addRow.style.gap = '4px'; + const addInput = document.createElement('input'); + addInput.className = 'mc-input'; + addInput.style.flex = '1'; + addInput.type = 'text'; + addInput.maxLength = 16; + addInput.placeholder = 'Ник друга'; + const addBtn = document.createElement('button'); + addBtn.className = 'mc-btn mc-btn-blue'; + addBtn.style.width = 'auto'; + addBtn.style.marginBottom = '0'; + addBtn.style.padding = '8px 16px'; + addBtn.textContent = '+'; + addBtn.onclick = async () => { + const nick = addInput.value.trim().toLowerCase(); + if (!nick || nick.length < 2) { showToast('❌ Минимум 2 символа'); return; } + const r = await fetch(state.SERVER_URL + '/api/friends/request', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tg_id: tgUser.id, nickname: nick }) + }); + const d = await r.json(); + if (d.ok) { showToast(d.message === 'Already friends' ? '✅ Уже друзья!' : '📨 Запрос отправлен!'); } + else { showToast('❌ ' + (d.error || 'Ошибка')); } + }; + addRow.appendChild(addInput); + addRow.appendChild(addBtn); + body.appendChild(addRow); + + // Privacy checkboxes + const settings = data.settings || { allow_requests: 1, notify_online: 1 }; + const settingsDiv = document.createElement('div'); + settingsDiv.style.marginTop = '12px'; + const reqLabel = document.createElement('label'); + reqLabel.className = 'mc-checkbox-row'; + const reqCb = document.createElement('input'); + reqCb.className = 'mc-checkbox'; + reqCb.type = 'checkbox'; + reqCb.checked = settings.allow_requests; + reqCb.onchange = async () => { + await fetch(state.SERVER_URL + '/api/friends/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tg_id: tgUser.id, + allow_requests: reqCb.checked ? 1 : 0, + notify_online: settings.notify_online + }) + }); + }; + reqLabel.appendChild(reqCb); + reqLabel.appendChild(document.createTextNode(' Запросы в друзья')); + settingsDiv.appendChild(reqLabel); + body.appendChild(settingsDiv); + } catch (e) { showToast('❌ Ошибка загрузки друзей'); console.error(e); } + }; + body.appendChild(btnFriends); + } + + // Player name input — always visible, with Play button + { + const nameDivider = document.createElement('hr'); + nameDivider.className = 'mc-divider'; + body.appendChild(nameDivider); + const nameLabel = document.createElement('div'); + nameLabel.className = 'mc-label'; + nameLabel.textContent = 'Твоё имя:'; + body.appendChild(nameLabel); + const nameRow = document.createElement('div'); + nameRow.style.cssText = 'display:flex;gap:6px;'; + const nameInput = document.createElement('input'); + nameInput.className = 'mc-input'; + nameInput.style.flex = '1'; + nameInput.type = 'text'; + nameInput.placeholder = 'Игрок'; + nameInput.value = state.playerName || localStorage.getItem('minegrechka_playerName') || (tgUser ? (tgUser.username || tgUser.first_name || '') : ''); + nameInput.maxLength = 20; + nameRow.appendChild(nameInput); + + const playBtn = document.createElement('button'); + playBtn.className = 'mc-btn mc-btn-green'; + playBtn.style.cssText = 'width:auto;margin-bottom:0;padding:10px 20px;white-space:nowrap;'; + playBtn.textContent = '▶ Играть'; + playBtn.onclick = () => { + const name = nameInput.value.trim() || 'Игрок'; + state.playerName = name; + localStorage.setItem('minegrechka_playerName', name); + // If no worldId yet, generate one and start + if (!selectedWorldId && !state.worldId) { + selectedWorldId = generateShortWorldCode(); + } + overlay.remove(); + styleEl.remove(); + resolve(selectedWorldId || state.worldId); + }; + nameRow.appendChild(playBtn); + body.appendChild(nameRow); + + // Allow Enter key to start game + nameInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') playBtn.click(); + }); + + // Auto-focus input on mobile + setTimeout(() => nameInput.focus(), 100); + } + + box.appendChild(body); + overlay.appendChild(box); + document.body.appendChild(overlay); + hideBoot(); + + // Set default player name if still empty + if (!state.playerName) { + state.playerName = localStorage.getItem('minegrechka_playerName') || 'Игрок'; + localStorage.setItem('minegrechka_playerName', state.playerName); + } + + // Auto-load TG save info (background fetch) + if (isTgWebApp && tgUser && !worldParam) { + fetch(state.SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id) + .then(r => r.json()) + .then(data => { + if (data.ok && data.world_id) { + // could update UI but menu is already shown + } + }) + .catch(() => {}); + } + }); +} + +// === core/canvas.js === +// ==================== ХОЛСТ И РАЗМЕРЫ ==================== + + +function initCanvas() { + const gameEl = document.getElementById('game'); + const canvas = document.getElementById('c'); + const ctx = canvas.getContext('2d'); + + // offscreen light map + const lightC = document.createElement('canvas'); + const lightCtx = lightC.getContext('2d'); + + const dpr = Math.max(1, window.devicePixelRatio || 1); + + function resize() { + state.W = gameEl.clientWidth; + state.H = gameEl.clientHeight; + canvas.width = state.W * dpr; + canvas.height = state.H * dpr; + lightC.width = state.W * dpr; + lightC.height = state.H * dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + } + + window.addEventListener('resize', resize); + resize(); + + // Store everything on state + state.gameEl = gameEl; + state.canvas = canvas; + state.ctx = ctx; + state.lightC = lightC; + state.lightCtx = lightCtx; + state.dpr = dpr; +} + +// Convenience exports for modules that use canvas directly +function getCanvas() { return state.canvas; } +function getCtx() { return state.ctx; } +function getLightC() { return state.lightC; } +function getLightCtx() { return state.lightCtx; } + +// === data/blocks.js === +const BLOCKS = { + air: { n:'Воздух', solid:false, hardness:0 }, + grass: { n:'Трава', c:'#7cfc00', solid:true, hardness:3, toolType:'shovel' }, + dirt: { n:'Грязь', c:'#8b4513', solid:true, hardness:3, toolType:'shovel' }, + stone: { n:'Камень', c:'#7f8c8d', solid:true, hardness:6, toolType:'pickaxe' }, + sand: { n:'Песок', c:'#f4d06f', solid:true, hardness:2, toolType:'shovel' }, + gravel: { n:'Гравий', c:'#95a5a6', solid:true, hardness:3, toolType:'shovel' }, + clay: { n:'Глина', c:'#74b9ff', solid:true, hardness:3, toolType:'shovel' }, + wood: { n:'Дерево', c:'#d35400', solid:true, hardness:4, toolType:'axe' }, + planks: { n:'Доски', c:'#e67e22', solid:true, hardness:3, toolType:'axe' }, + ladder: { n:'Лестница',c:'#d35400', solid:false, climbable:true, hardness:1, toolType:null }, + leaves: { n:'Листва', c:'#2ecc71', solid:true, hardness:1, toolType:'axe', minTool:null }, + glass: { n:'Стекло', c:'rgba(200,240,255,0.25)', solid:true, alpha:0.55, hardness:1, toolType:null }, + water: { n:'Вода', c:'rgba(52,152,219,0.55)', solid:false, fluid:true, hardness:0 }, + coal: { n:'Уголь', c:'#2c3e50', solid:true, hardness:8, toolType:'pickaxe', minTool:'wood' }, + copper_ore:{ n:'Медь', c:'#e17055', solid:true, hardness:10, toolType:'pickaxe', minTool:'wood' }, + iron_ore: { n:'Железо', c:'#dcdde1', solid:true, hardness:12, toolType:'pickaxe', minTool:'stone' }, + iron_armor:{ n:'Железная броня', c:'#95a5a6', solid:false, armor:0.5, hardness:0 }, + gold_ore: { n:'Золото', c:'#f1c40f', solid:true, hardness:15, toolType:'pickaxe', minTool:'stone' }, + diamond_ore:{n:'Алмаз', c:'#00a8ff', solid:true, hardness:20, toolType:'pickaxe', minTool:'iron' }, + brick: { n:'Кирпич', c:'#c0392b', solid:true, hardness:8, toolType:'pickaxe' }, + tnt: { n:'TNT', c:'#e74c3c', solid:true, explosive:true, hardness:1, toolType:null }, + campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius:280, hardness:2, toolType:null }, + torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:220, hardness:1, toolType:null }, + bedrock: { n:'Бедрок', c:'#2d3436', solid:true, unbreakable:true, hardness:Infinity }, + flower: { n:'Цветок', c:'#ff4757', solid:false, decor:true, hardness:0 }, + bed: { n:'Кровать', c:'#e91e63', solid:true, bed:true, hardness:2, toolType:'axe' }, + boat: { n:'Лодка', c:'#8B4513', solid:false, hardness:0 }, + furnace: { n:'Печь', c:'#696969', solid:true, smelting:true, hardness:8, toolType:'pickaxe', minTool:'wood' }, + cobblestone: { n:'Булыжник', c:'#7f8c8d', solid:true, hardness:6, toolType:'pickaxe' }, + flag: { n:'Флаг', c:'#e74c3c', solid:true, hardness:1, toolType:'axe', protectionRadius:10 }, + // === BIOME BLOCKS === + snow: { n:'Снег', c:'#ecf0f1', solid:true, hardness:1, toolType:'shovel' }, + ice: { n:'Лёд', c:'#74b9ff', solid:true, hardness:1, slip:true, toolType:'pickaxe' }, + cactus: { n:'Кактус', c:'#27ae60', solid:true, hardness:1, hurt:true, toolType:null }, + mushroom: { n:'Гриб', c:'#e74c3c', solid:false, decor:true, hardness:0 }, + moss: { n:'Мох', c:'#0a6640', solid:true, hardness:1, toolType:null }, + swamp_water:{ n:'Болотная вода', c:'rgba(100,120,40,0.6)', solid:false, fluid:true, poison:true, hardness:0 }, + farmland: { n:'Грядка', c:'#8B6914', solid:true, hardness:1, farmable:true, toolType:'shovel' }, + dead_bush: { n:'Сухой куст', c:'#b2bec3', solid:false, decor:true, hardness:0 }, + spruce_leaves:{ n:'Ель', c:'#0a6640', solid:true, hardness:1, toolType:'axe' }, + // === CROP STAGES === + wheat_stage0:{ n:'Росток пшеницы', c:'#a8e6a0', solid:false, decor:true, hardness:0, growthStage:0, growthTime:30, growsTo:'wheat_stage1' }, + wheat_stage1:{ n:'Пшеница', c:'#7dcea0', solid:false, decor:true, hardness:0, growthStage:1, growthTime:30, growsTo:'wheat_stage2' }, + wheat_stage2:{ n:'Пшеница', c:'#b8d730', solid:false, decor:true, hardness:0, growthStage:2, growthTime:30, growsTo:'wheat_stage3' }, + wheat_stage3:{ n:'Пшеница', c:'#f1c40f', solid:false, decor:true, hardness:0, harvestable:true, harvestItem:'wheat', harvestQty:2 }, + carrot_stage0:{ n:'Росток моркови', c:'#a8e6a0', solid:false, decor:true, hardness:0, growthStage:0, growthTime:30, growsTo:'carrot_stage1' }, + carrot_stage1:{ n:'Морковь', c:'#f0c27a', solid:false, decor:true, hardness:0, growthStage:1, growthTime:30, growsTo:'carrot_stage2' }, + carrot_stage2:{ n:'Морковь', c:'#e8a040', solid:false, decor:true, hardness:0, growthStage:2, growthTime:30, growsTo:'carrot_stage3' }, + carrot_stage3:{ n:'Морковь', c:'#e67e22', solid:false, decor:true, hardness:0, harvestable:true, harvestItem:'carrot', harvestQty:3 }, + potato_stage0:{ n:'Росток картофеля', c:'#a8e6a0', solid:false, decor:true, hardness:0, growthStage:0, growthTime:30, growsTo:'potato_stage1' }, + potato_stage1:{ n:'Картофель', c:'#c8b888', solid:false, decor:true, hardness:0, growthStage:1, growthTime:30, growsTo:'potato_stage2' }, + potato_stage2:{ n:'Картофель', c:'#bfaa78', solid:false, decor:true, hardness:0, growthStage:2, growthTime:30, growsTo:'potato_stage3' }, + potato_stage3:{ n:'Картофель', c:'#dfe6e9', solid:false, decor:true, hardness:0, harvestable:true, harvestItem:'potato', harvestQty:2 }, +}; + +// === world/world-storage.js === +// Мир-хранилище — используем state.grid и state.blocks для единого источника данных +// Это гарантирует, что все модули (включая loop.js) видят одни и те же данные +const grid = state.grid; // Map: key "gx,gy" => {gx,gy,t, ...} +const blocks = state.blocks; // Array для рендера/перебора видимых + +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; +} + +// === world/generation.js === +// Генерация (по X, на всю глубину до bedrock) +const generated = state.generated; // Set of gx already generated + +// ========== BIOME SYSTEM ========== +const BIOMES = { + plains: { name:'Равнина', surface:'grass', subsurface:'dirt', trees:true, flowers:true, treeChance:0.12 }, + desert: { name:'Пустыня', surface:'sand', subsurface:'sand', trees:false, flowers:false, treeChance:0 }, + tundra: { name:'Тундра', surface:'snow', subsurface:'dirt', trees:true, flowers:false, treeChance:0.06 }, + swamp: { name:'Болото', surface:'moss', subsurface:'dirt', trees:true, flowers:false, treeChance:0.10 }, + mountains:{ name:'Горы', surface:'stone', subsurface:'stone', trees:false, flowers:false, treeChance:0 } +}; + +function getBiome(gx) { + const temp = Math.sin(gx*0.003 + state.worldSeed*0.01)*0.5 + Math.sin(gx*0.007 + state.worldSeed*0.02)*0.3 + 0.5; + const humid = Math.sin(gx*0.004 + state.worldSeed*0.015 + 1000)*0.5 + Math.cos(gx*0.006 + state.worldSeed*0.02 + 2000)*0.3 + 0.5; + const mtVal = Math.sin(gx*0.001 + state.worldSeed*0.005)*0.5 + 0.5; + if (temp > 0.7) return 'desert'; + if (temp < 0.3) return 'tundra'; + if (humid > 0.7 && temp >= 0.3 && temp <= 0.7) return 'swamp'; + if (temp >= 0.5 && temp <= 0.7 && mtVal > 0.75) return 'mountains'; + return 'plains'; +} + +const biomeCache = {}; +function getCachedBiome(gx) { + const chunk = Math.floor(gx / 8); + if (biomeCache[chunk] === undefined) biomeCache[chunk] = getBiome(chunk * 8); + return biomeCache[chunk]; +} + +// ========== STRUCTURES ========== +function placeStructure(startGx, startGy, pattern) { + for (let dy = 0; dy < pattern.length; dy++) { + for (let dx = 0; dx < pattern[dy].length; dx++) { + const bt = pattern[dy][dx]; + if (bt && bt !== 'air') { + setBlock(startGx + dx, startGy + dy, bt); } } - return false; } - function getBestTool(blockType) { - const block = BLOCKS[blockType]; - if (!block || !block.toolType) return null; - const toolType = block.toolType; - for (const tier of ["iron", "stone", "wood"]) { - const toolId = `${tier}_${toolType}`; - if (TOOLS[toolId] && state.inv[toolId] > 0) return toolId; +} + +const PYRAMID_PATTERN = [ + ['sand','sand','sand','sand','sand','sand','sand'], + ['sand','stone','stone','stone','stone','stone','sand'], + ['sand','stone','air','air','air','stone','sand'], + ['sand','stone','air','air','air','stone','sand'], + ['sand','stone','air','air','air','stone','sand'], + ['sand','sand','stone','stone','stone','sand','sand'] +]; + +const HOUSE_PATTERN = [ + ['air','planks','planks','planks','planks','air'], + ['planks','air','air','air','air','planks'], + ['planks','air','torch','air','air','planks'], + ['planks','air','air','air','air','planks'], + ['planks','planks','air','planks','planks','planks'] +]; + +const HUT_PATTERN = [ + ['air','wood','wood','wood','air'], + ['wood','air','air','air','wood'], + ['wood','air','torch','air','wood'], + ['moss','moss','air','moss','moss'] +]; + +function surfaceGyAt(gx) { + const biome = getCachedBiome(gx); + 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; + let h; + switch(biome) { + case 'desert': + h = Math.floor(SEA_GY - 4 + n3*0.3 + n4*0.5); + break; + case 'tundra': + h = Math.floor(SEA_GY - 6 + n2*0.5 + n3*0.4 + n5*0.3); + break; + case 'swamp': + h = Math.floor(SEA_GY - 2 + n3*0.2 + n4*0.3); + h = Math.max(h, SEA_GY - 3); + break; + case 'mountains': + h = Math.floor(SEA_GY - 15 + n1*1.5 + n2*1.2 + n3); + break; + default: // plains + 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); + const biome = getCachedBiome(gx); + + // === Вода и поверхность === + if (sgy > SEA_GY) { + for (let gy = SEA_GY; gy < sgy; gy++) { + const blockType = (biome === 'swamp' && gy >= SEA_GY - 1) ? 'swamp_water' : 'water'; + setBlock(gx, gy, blockType); } - return null; - } - function toolTierLessThan(toolTier, minTier) { - const tiers = { wood: 1, stone: 2, iron: 3 }; - return (tiers[toolTier] || 0) < (tiers[minTier] || 0); - } - function getMiningSpeed(blockType) { - const block = BLOCKS[blockType]; - if (!block) return 1; - const toolId = getBestTool(blockType); - if (toolId) { - const tool = TOOLS[toolId]; - if (tool.toolType === block.toolType) return tool.speed; + setBlock(gx, sgy, 'sand'); + } else { + const b = BIOMES[biome]; + setBlock(gx, sgy, b.surface); + // болото: случайные лужи болотной воды + if (biome === 'swamp' && seededRandom(gx*3, sgy) < 0.15) { + setBlock(gx, sgy-1, 'swamp_water'); + } + // тундра: лёд на воде рядом + if (biome === 'tundra' && sgy === SEA_GY && seededRandom(gx, SEA_GY-1) < 0.3) { + setBlock(gx, SEA_GY-1, 'ice'); } - return 1; } - // src/render/textures.js - var tex = {}; - var itemTex = {}; - function drawArrow(ctx, color) { - ctx.fillStyle = "#8B4513"; - ctx.fillRect(8, 14, 16, 3); - ctx.fillStyle = color; - ctx.beginPath(); - ctx.moveTo(24, 15); - ctx.lineTo(18, 10); - ctx.lineTo(18, 20); - ctx.closePath(); - ctx.fill(); - ctx.fillStyle = "#e74c3c"; - ctx.beginPath(); - ctx.moveTo(8, 15); - ctx.lineTo(4, 10); - ctx.lineTo(10, 14); - ctx.closePath(); - ctx.fill(); - ctx.beginPath(); - ctx.moveTo(8, 16); - ctx.lineTo(4, 21); - ctx.lineTo(10, 17); - ctx.closePath(); - ctx.fill(); - } - function drawPickaxe(ctx, headColor, handleColor) { - ctx.fillStyle = handleColor || "#8B4513"; - ctx.save(); - ctx.translate(16, 16); - ctx.rotate(Math.PI / 4); - ctx.fillRect(-2, 0, 4, 18); - ctx.fillStyle = headColor; - ctx.fillRect(-8, -3, 16, 5); - ctx.fillRect(-8, -3, 3, 8); - ctx.fillRect(5, -3, 3, 8); - ctx.restore(); - } - function drawAxe(ctx, headColor, handleColor) { - ctx.fillStyle = handleColor || "#8B4513"; - ctx.save(); - ctx.translate(16, 16); - ctx.rotate(Math.PI / 4); - ctx.fillRect(-2, 0, 4, 18); - ctx.fillStyle = headColor; - ctx.fillRect(-2, -8, 10, 8); - ctx.fillRect(-2, -8, 4, 12); - ctx.restore(); - } - function drawShovel(ctx, headColor, handleColor) { - ctx.fillStyle = handleColor || "#8B4513"; - ctx.save(); - ctx.translate(16, 16); - ctx.rotate(Math.PI / 4); - ctx.fillRect(-2, 0, 4, 18); - ctx.fillStyle = headColor; - ctx.fillRect(-6, -8, 12, 8); - ctx.fillRect(-4, -12, 8, 4); - ctx.restore(); - } - function drawSword(ctx, bladeColor, guardColor) { - ctx.fillStyle = "#8B4513"; - ctx.fillRect(14, 18, 4, 10); - ctx.fillStyle = guardColor || "#f1c40f"; - ctx.fillRect(10, 16, 12, 3); - ctx.fillStyle = bladeColor; - ctx.beginPath(); - ctx.moveTo(16, 2); - ctx.lineTo(12, 16); - ctx.lineTo(20, 16); - ctx.closePath(); - ctx.fill(); - } - function drawBow(ctx) { - ctx.strokeStyle = "#8B4513"; - ctx.lineWidth = 3; - ctx.beginPath(); - ctx.arc(12, 16, 12, -Math.PI * 0.7, Math.PI * 0.7); - ctx.stroke(); - ctx.strokeStyle = "#ddd"; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(12, 4); - ctx.lineTo(12, 28); - ctx.stroke(); - } - function drawIngot(ctx, color) { - ctx.fillStyle = color; - ctx.beginPath(); - ctx.moveTo(6, 12); - ctx.lineTo(26, 12); - ctx.lineTo(24, 24); - ctx.lineTo(8, 24); - ctx.closePath(); - ctx.fill(); - ctx.fillStyle = "rgba(255,255,255,0.3)"; - ctx.fillRect(8, 8, 16, 4); - } - function drawMeat(ctx, cooked) { - ctx.fillStyle = cooked ? "#8B4513" : "#e74c3c"; - ctx.beginPath(); - ctx.ellipse(16, 18, 10, 8, 0, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = "#f5f5dc"; - ctx.fillRect(8, 6, 3, 12); - ctx.fillRect(14, 4, 3, 8); - } - function makeItemTex(id) { - const c = document.createElement("canvas"); - c.width = 32; - c.height = 32; - const g = c.getContext("2d"); - const tierColors = { - wood: { head: "#a0522d", blade: "#a0522d" }, - stone: { head: "#7f8c8d", blade: "#95a5a6" }, - iron: { head: "#bdc3c7", blade: "#ecf0f1" } - }; - switch (id) { - case "arrow": - drawArrow(g, "#7f8c8d"); - break; - case "bow": - drawBow(g); - break; - case "wood_pickaxe": - drawPickaxe(g, tierColors.wood.head, tierColors.wood.blade); - break; - case "stone_pickaxe": - drawPickaxe(g, tierColors.stone.head); - break; - case "iron_pickaxe": - drawPickaxe(g, tierColors.iron.head); - break; - case "wood_axe": - drawAxe(g, tierColors.wood.head); - break; - case "stone_axe": - drawAxe(g, tierColors.stone.head); - break; - case "iron_axe": - drawAxe(g, tierColors.iron.head); - break; - case "wood_shovel": - drawShovel(g, tierColors.wood.head); - break; - case "stone_shovel": - drawShovel(g, tierColors.stone.head); - break; - case "iron_shovel": - drawShovel(g, tierColors.iron.head); - break; - case "wood_sword": - drawSword(g, tierColors.wood.blade, "#a0522d"); - break; - case "stone_sword": - drawSword(g, tierColors.stone.blade, "#7f8c8d"); - break; - case "iron_sword": - drawSword(g, tierColors.iron.blade, "#bdc3c7"); - break; - case "iron_ingot": - drawIngot(g, "#bdc3c7"); - break; - case "gold_ingot": - drawIngot(g, "#f1c40f"); - break; - case "copper_ingot": - drawIngot(g, "#e67e22"); - break; - case "meat": - drawMeat(g, false); - break; - case "cooked": - drawMeat(g, true); - break; - default: - g.fillStyle = "#7f8c8d"; - g.fillRect(4, 4, 24, 24); - g.fillStyle = "rgba(255,255,255,0.2)"; - g.fillRect(4, 4, 24, 4); + // === Подповерхностные слои === + for (let gy = sgy + 1; gy <= BEDROCK_GY; gy++) { + if (gy === BEDROCK_GY) { + setBlock(gx, gy, 'bedrock'); + continue; } - return c; + + let t = BIOMES[biome].subsurface; + + // глубже — камень + if (gy > sgy + 3) t = 'stone'; + + // пустыня: sand глубже + if (biome === 'desert' && gy <= sgy + 6) t = 'sand'; + + // болото: глина ближе к поверхности + if (biome === 'swamp' && gy <= sgy + 2 && seededRandom(gx, gy) < 0.3) t = 'clay'; + + // горы: gravel + if (biome === 'mountains' && gy > sgy + 4 && seededRandom(gx, gy) < 0.12) 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); } - 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; + + // === Растительность и декор === + const b = BIOMES[biome]; + const top = getBlock(gx, sgy); + + // цветы (только plains) + if (biome === 'plains' && top && top.t === 'grass' && seededRandom(gx, sgy-1) < 0.10) { + setBlock(gx, sgy-1, 'flower'); + } + + // деревья + if (b.trees && seededRandom(gx*7, sgy-2) < b.treeChance) { + if (biome === 'tundra') { + const th = 3 + Math.floor(seededRandom(gx, sgy) * 3); + for (let i = 0; i < th; i++) setBlock(gx, sgy-1-i, 'wood'); + for (let row = 0; row < th; row++) { + const w = Math.min(row + 1, 2); + for (let dx = -w; dx <= w; dx++) { + const ly = sgy-1-th+row; + if (ly >= 0) setBlock(gx+dx, ly, 'spruce_leaves'); + } + } + } else if (biome === 'swamp') { + setBlock(gx, sgy-1, 'wood'); + setBlock(gx, sgy-2, 'moss'); + setBlock(gx-1, sgy-2, 'leaves'); + setBlock(gx+1, sgy-2, 'leaves'); + } else { + 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'); } - if (type === "campfire") { - g.fillStyle = "#5d4037"; - g.fillRect(4, 26, 24, 6); - g.fillStyle = "#3e2723"; - g.fillRect(7, 23, 18, 4); - return c; + } + + // кактусы (пустыня) + if (biome === 'desert' && seededRandom(gx*11, sgy) < 0.04) { + const ch = 1 + Math.floor(seededRandom(gx, sgy+1) * 2); + for (let i = 0; i < ch; i++) setBlock(gx, sgy-1-i, 'cactus'); + } + + // грибы (болото) + if (biome === 'swamp' && seededRandom(gx*13, sgy) < 0.06) { + setBlock(gx, sgy-1, 'mushroom'); + } + + // сухие кусты (пустыня) + if (biome === 'desert' && seededRandom(gx*17, sgy) < 0.05) { + setBlock(gx, sgy-1, 'dead_bush'); + } + + // === Структуры мира === + // Пирамида в пустыне + if (biome === 'desert' && ((gx % 200 + 200) % 200) === 47 && sgy < SEA_GY && sgy > SEA_GY - 5) { + placeStructure(gx, sgy - 5, PYRAMID_PATTERN); + } + // Дом в равнине + if (biome === 'plains' && ((gx % 150 + 150) % 150) === 33 && sgy < SEA_GY) { + placeStructure(gx, sgy - 4, HOUSE_PATTERN); + } + // Хижина в болоте + if (biome === 'swamp' && ((gx % 180 + 180) % 180) === 55 && sgy < SEA_GY) { + placeStructure(gx, sgy - 3, HUT_PATTERN); + } + + // Применяем серверные оверрайды для этой колонны + 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); + } } - if (type === "torch") { - g.fillStyle = "#6d4c41"; + } +} + +// Перегенерация видимых чанков (используется при загрузке сохранения) +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); +} + +// === audio/sound-engine.js === +// Звуковой движок +const 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'); + +function playSound(id) { + if (sounds[id]) { + sounds[id].currentTime = 0; + sounds[id].play().catch(e => console.error('Sound error:', e)); + } +} + +// === ui/chat.js === +// ==================== ЧАТ ==================== + + + + +const chatMessages = []; + +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 (state.isMultiplayer && state.socket && state.socket.connected) { + state.socket.emit('chat_message', { message: message.trim() }); + } else { + addChatMessage('Вы', 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 = ''; + } + }); +} + +// === entities/player.js === +// Функция для расчёта урона с учётом брони +function calculateDamage(baseDamage) { + const reduction = state.player.armor; + const actualDamage = baseDamage * (1 - reduction); + 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, + equippedArmor: null, + temperature: 15, + slowTimer: 0 + }; + + state.spawnPoint = { x: 6 * TILE, y: 0 * TILE }; +} + +// === data/items.js === +const ITEMS = { + meat: { n:'Сырое мясо', icon:'🥩', food:15 }, + cooked: { n:'Жареное мясо', icon:'🍖', food:45 }, + arrow: { n:'Стрела', icon:'➡️', stack:64 }, + chicken_meat: { n:'Курица', icon:'🍗', food:12, stack:64 }, + feather: { n:'Перо', icon:'🪶', stack:64 }, + bone: { n:'Кость', icon:'🦴', stack:64 }, + gunpowder: { n:'Порох', icon:'💥', stack:64 }, + // === FARMING ITEMS === + wheat: { n:'Пшеница', icon:'🌾', stack:64 }, + bread: { n:'Хлеб', icon:'🍞', food:30 }, + carrot: { n:'Морковь', icon:'🥕', food:8, stack:64 }, + potato: { n:'Картофель', icon:'🥔', stack:64 }, + baked_potato:{ n:'Печёная картошка', icon:'🥔', food:25 }, + wheat_seeds: { n:'Семена пшеницы', icon:'🌱', stack:64 }, + mushroom_stew:{ n:'Грибной суп', icon:'🍲', food:40 }, + // === MOB DROP ITEMS === + scorpion_stinger:{ n:'Жало скорпиона', icon:'🔺', stack:64 }, + polar_fur: { n:'Шкура медведя', icon:'🧥', stack:64 }, + slime_ball: { n:'Слизь', icon:'🟢', stack:64 }, + eagle_feather:{ n:'Перо орла', icon:'🪶', stack:64 }, + // === ARMOR === + gold_armor: { n:'Золотая броня', icon:'🛡️', stack:1, armor:0.65 }, +}; + +// Runtime extensions — предметы от обжига +ITEMS.iron_ingot = { n:'Железный слиток', icon:'🔩' }; +ITEMS.gold_ingot = { n:'Золотой слиток', icon:'🪙' }; +ITEMS.copper_ingot = { n:'Медный слиток', icon:'🟤' }; + +// Axes and shovels +ITEMS.wood_axe = { n:'Деревянный топор', icon:'🪓' }; +ITEMS.stone_axe = { n:'Каменный топор', icon:'🪓' }; +ITEMS.iron_axe = { n:'Железный топор', icon:'🪓' }; +ITEMS.wood_shovel = { n:'Деревянная лопата', icon:'🔱' }; +ITEMS.stone_shovel = { n:'Каменная лопата', icon:'🔱' }; +ITEMS.iron_shovel = { n:'Железная лопата', icon:'🔱' }; + +// === data/tools.js === +const TOOLS = { + wood_pickaxe: { n:'Деревянная кирка', icon:'⛏️', damage:1, durability:60, toolType:'pickaxe', toolTier:'wood', speed:2 }, + stone_pickaxe: { n:'Каменная кирка', icon:'⛏️', damage:2, durability:132, toolType:'pickaxe', toolTier:'stone', speed:4 }, + iron_pickaxe: { n:'Железная кирка', icon:'⛏️', damage:3, durability:250, toolType:'pickaxe', toolTier:'iron', speed:6 }, + wood_sword: { n:'Деревянный меч', icon:'🗡️', damage:4, durability:60 }, + stone_sword: { n:'Каменный меч', icon:'🗡️', damage:5, durability:132 }, + iron_sword: { n:'Железный меч', icon:'🗡️', damage:7, durability:250 }, + wood_axe: { n:'Деревянный топор', icon:'🪓', damage:3, durability:60, toolType:'axe', toolTier:'wood', speed:2 }, + stone_axe: { n:'Каменный топор', icon:'🪓', damage:4, durability:132, toolType:'axe', toolTier:'stone', speed:4 }, + iron_axe: { n:'Железный топор', icon:'🪓', damage:5, durability:250, toolType:'axe', toolTier:'iron', speed:6 }, + wood_shovel: { n:'Деревянная лопата', icon:'🔱', damage:1, durability:60, toolType:'shovel', toolTier:'wood', speed:2 }, + stone_shovel: { n:'Каменная лопата', icon:'🔱', damage:2, durability:132, toolType:'shovel', toolTier:'stone', speed:4 }, + iron_shovel: { n:'Железная лопата', icon:'🔱', damage:3, durability:250, toolType:'shovel', toolTier:'iron', speed:6 }, + bow: { n:'Лук', icon:'🏹', damage:10, durability:200 }, +}; + + + + +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 }); +} + +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 +} + +function getBestTool(blockType) { + const block = BLOCKS[blockType]; + if (!block || !block.toolType) return null; + const toolType = block.toolType; + for (const tier of ['iron', 'stone', 'wood']) { + const toolId = `${tier}_${toolType}`; + if (TOOLS[toolId] && state.inv[toolId] > 0) return toolId; + } + return null; +} + +function toolTierLessThan(toolTier, minTier) { + const tiers = { wood: 1, stone: 2, iron: 3 }; + return (tiers[toolTier] || 0) < (tiers[minTier] || 0); +} + +function getMiningSpeed(blockType) { + const block = BLOCKS[blockType]; + if (!block) return 1; + const toolId = getBestTool(blockType); + if (toolId) { + const tool = TOOLS[toolId]; + if (tool.toolType === block.toolType) return tool.speed; + } + return 1; // Bare hands = 1 click per hit +} + +// === render/textures.js === +// Текстуры блоков (простые) + + +const tex = {}; + +// --- Item/tool sprite generator ---\n +const itemTex = {}; + +function drawArrow(ctx, color) { + ctx.fillStyle = '#8B4513'; + ctx.fillRect(8, 14, 16, 3); + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(24, 15); + ctx.lineTo(18, 10); + ctx.lineTo(18, 20); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = '#e74c3c'; + ctx.beginPath(); + ctx.moveTo(8, 15); + ctx.lineTo(4, 10); + ctx.lineTo(10, 14); + ctx.closePath(); + ctx.fill(); + ctx.beginPath(); + ctx.moveTo(8, 16); + ctx.lineTo(4, 21); + ctx.lineTo(10, 17); + ctx.closePath(); + ctx.fill(); +} + +function drawPickaxe(ctx, headColor, handleColor) { + ctx.fillStyle = handleColor || '#8B4513'; + ctx.save(); + ctx.translate(16, 16); + ctx.rotate(Math.PI / 4); + ctx.fillRect(-2, 0, 4, 18); + ctx.fillStyle = headColor; + ctx.fillRect(-8, -3, 16, 5); + ctx.fillRect(-8, -3, 3, 8); + ctx.fillRect(5, -3, 3, 8); + ctx.restore(); +} + +function drawAxe(ctx, headColor, handleColor) { + ctx.fillStyle = handleColor || '#8B4513'; + ctx.save(); + ctx.translate(16, 16); + ctx.rotate(Math.PI / 4); + ctx.fillRect(-2, 0, 4, 18); + ctx.fillStyle = headColor; + ctx.fillRect(-2, -8, 10, 8); + ctx.fillRect(-2, -8, 4, 12); + ctx.restore(); +} + +function drawShovel(ctx, headColor, handleColor) { + ctx.fillStyle = handleColor || '#8B4513'; + ctx.save(); + ctx.translate(16, 16); + ctx.rotate(Math.PI / 4); + ctx.fillRect(-2, 0, 4, 18); + ctx.fillStyle = headColor; + ctx.fillRect(-6, -8, 12, 8); + ctx.fillRect(-4, -12, 8, 4); + ctx.restore(); +} + +function drawSword(ctx, bladeColor, guardColor) { + ctx.fillStyle = '#8B4513'; + ctx.fillRect(14, 18, 4, 10); + ctx.fillStyle = guardColor || '#f1c40f'; + ctx.fillRect(10, 16, 12, 3); + ctx.fillStyle = bladeColor; + ctx.beginPath(); + ctx.moveTo(16, 2); + ctx.lineTo(12, 16); + ctx.lineTo(20, 16); + ctx.closePath(); + ctx.fill(); +} + +function drawBow(ctx) { + ctx.strokeStyle = '#8B4513'; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.arc(12, 16, 12, -Math.PI * 0.7, Math.PI * 0.7); + ctx.stroke(); + ctx.strokeStyle = '#ddd'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(12, 4); + ctx.lineTo(12, 28); + ctx.stroke(); +} + +function drawIngot(ctx, color) { + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(6, 12); + ctx.lineTo(26, 12); + ctx.lineTo(24, 24); + ctx.lineTo(8, 24); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.fillRect(8, 8, 16, 4); +} + +function drawMeat(ctx, cooked) { + ctx.fillStyle = cooked ? '#8B4513' : '#e74c3c'; + ctx.beginPath(); + ctx.ellipse(16, 18, 10, 8, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#f5f5dc'; + ctx.fillRect(8, 6, 3, 12); + ctx.fillRect(14, 4, 3, 8); +} + +function makeItemTex(id) { + const c = document.createElement('canvas'); + c.width = 32; c.height = 32; + const g = c.getContext('2d'); + + const tierColors = { + wood: { head: '#a0522d', blade: '#a0522d' }, + stone: { head: '#7f8c8d', blade: '#95a5a6' }, + iron: { head: '#bdc3c7', blade: '#ecf0f1' }, + }; + + switch (id) { + case 'arrow': + drawArrow(g, '#7f8c8d'); + break; + case 'bow': + drawBow(g); + break; + case 'wood_pickaxe': + drawPickaxe(g, tierColors.wood.head, tierColors.wood.blade); + break; + case 'stone_pickaxe': + drawPickaxe(g, tierColors.stone.head); + break; + case 'iron_pickaxe': + drawPickaxe(g, tierColors.iron.head); + break; + case 'wood_axe': + drawAxe(g, tierColors.wood.head); + break; + case 'stone_axe': + drawAxe(g, tierColors.stone.head); + break; + case 'iron_axe': + drawAxe(g, tierColors.iron.head); + break; + case 'wood_shovel': + drawShovel(g, tierColors.wood.head); + break; + case 'stone_shovel': + drawShovel(g, tierColors.stone.head); + break; + case 'iron_shovel': + drawShovel(g, tierColors.iron.head); + break; + case 'wood_sword': + drawSword(g, tierColors.wood.blade, '#a0522d'); + break; + case 'stone_sword': + drawSword(g, tierColors.stone.blade, '#7f8c8d'); + break; + case 'iron_sword': + drawSword(g, tierColors.iron.blade, '#bdc3c7'); + break; + case 'iron_ingot': + drawIngot(g, '#bdc3c7'); + break; + case 'gold_ingot': + drawIngot(g, '#f1c40f'); + break; + case 'copper_ingot': + drawIngot(g, '#e67e22'); + break; + case 'meat': + drawMeat(g, false); + break; + case 'cooked': + drawMeat(g, true); + break; + case 'wheat': + g.fillStyle = '#f1c40f'; + g.fillRect(12, 8, 3, 18); + g.fillRect(8, 6, 10, 4); + break; + case 'bread': + g.fillStyle = '#d4a030'; + g.fillRect(6, 12, 20, 12); + g.fillStyle = '#c08820'; + g.fillRect(6, 12, 20, 3); + break; + case 'carrot': + g.fillStyle = '#e67e22'; 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.fillStyle = '#27ae60'; + g.fillRect(12, 6, 8, 5); + break; + case 'potato': + g.fillStyle = '#d4a050'; 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.ellipse(16, 16, 8, 6, 0, 0, Math.PI * 2); 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.fillStyle = '#b88a40'; + g.fillRect(10, 14, 2, 1); + g.fillRect(20, 16, 2, 1); + break; + case 'baked_potato': + g.fillStyle = '#c08820'; + g.beginPath(); + g.ellipse(16, 16, 8, 6, 0, 0, Math.PI * 2); + g.fill(); + g.fillStyle = '#f5e6cc'; + g.fillRect(12, 12, 8, 6); + break; + case 'mushroom_stew': + g.fillStyle = '#8B4513'; + g.fillRect(10, 16, 12, 12); + g.fillStyle = '#e74c3c'; + g.beginPath(); + g.ellipse(16, 14, 8, 5, 0, Math.PI, 0); + g.fill(); + break; + case 'scorpion_stinger': + g.fillStyle = '#d35400'; + g.beginPath(); + g.moveTo(16, 4); + g.lineTo(10, 28); + g.lineTo(22, 28); + g.closePath(); + g.fill(); + break; + case 'polar_fur': + g.fillStyle = '#ecf0f1'; + g.fillRect(6, 6, 20, 20); + g.fillStyle = '#bdc3c7'; + g.fillRect(12, 12, 8, 8); + break; + case 'slime_ball': + g.fillStyle = 'rgba(46,204,113,0.8)'; + g.beginPath(); + g.arc(16, 16, 10, 0, Math.PI * 2); + g.fill(); + g.fillStyle = '#fff'; + g.fillRect(10, 10, 4, 4); + g.fillRect(18, 10, 4, 4); + break; + case 'eagle_feather': + g.fillStyle = '#8B4513'; + g.fillRect(14, 4, 4, 24); + g.fillStyle = '#ecf0f1'; + g.fillRect(10, 6, 12, 10); + break; + case 'chicken_meat': + drawMeat(g, false); + g.fillStyle = '#fff'; + g.fillRect(12, 8, 4, 4); + break; + case 'feather': + g.fillStyle = '#ecf0f1'; + g.fillRect(14, 6, 4, 20); + g.fillStyle = '#bbb'; + g.fillRect(12, 8, 8, 8); + break; + case 'bone': + g.fillStyle = '#f5f5dc'; + g.fillRect(12, 10, 8, 14); + g.fillRect(8, 8, 4, 4); + g.fillRect(20, 8, 4, 4); + g.fillRect(8, 22, 4, 4); + g.fillRect(20, 22, 4, 4); + break; + case 'gunpowder': + g.fillStyle = '#555'; + g.fillRect(8, 10, 6, 6); + g.fillRect(18, 14, 6, 6); + g.fillRect(12, 20, 6, 6); + break; + case 'gold_armor': + g.fillStyle = '#f1c40f'; + g.fillRect(6, 6, 20, 20); + g.fillStyle = '#f39c12'; + g.fillRect(10, 4, 12, 6); + g.fillRect(4, 10, 6, 16); + g.fillRect(22, 10, 6, 16); + break; + case 'wheat_seeds': + g.fillStyle = '#7dcea0'; + g.fillRect(14, 16, 4, 10); + g.fillStyle = '#a8e6a0'; + g.fillRect(10, 8, 4, 4); + g.fillRect(18, 6, 4, 4); + g.fillRect(14, 4, 4, 4); + break; + default: + g.fillStyle = '#7f8c8d'; + g.fillRect(4, 4, 24, 24); + g.fillStyle = 'rgba(255,255,255,0.2)'; + g.fillRect(4, 4, 24, 4); + } + return c; +} + +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 = "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 = '#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; + } + // === BIOME BLOCK TEXTURES === + if (type === 'snow') { + g.fillStyle = '#ecf0f1'; + g.fillRect(0, 0, 32, 32); + g.fillStyle = '#dfe6e9'; + for (let i = 0; i < 4; i++) g.fillRect((Math.random()*26)|0, (Math.random()*26)|0, 5, 3); + return c; + } + if (type === 'ice') { + g.fillStyle = '#74b9ff'; + g.fillRect(0, 0, 32, 32); + g.strokeStyle = 'rgba(255,255,255,0.4)'; + g.beginPath(); g.moveTo(4,10); g.lineTo(20,16); g.lineTo(10,28); g.stroke(); + g.beginPath(); g.moveTo(18,4); g.lineTo(28,12); g.stroke(); + return c; + } + if (type === 'cactus') { + g.fillStyle = '#27ae60'; + g.fillRect(6, 2, 20, 28); + g.fillStyle = '#2ecc71'; + g.fillRect(2, 8, 6, 4); + g.fillRect(24, 14, 6, 4); + g.fillStyle = '#1e8449'; + g.fillRect(12, 0, 2, 30); + g.fillRect(18, 0, 2, 30); + return c; + } + if (type === 'mushroom') { + g.fillStyle = '#f5e6cc'; g.fillRect(14, 20, 4, 12); + g.fillStyle = '#e74c3c'; g.beginPath(); g.arc(16, 14, 10, Math.PI, 0); g.fill(); + g.fillStyle = '#fff'; g.fillRect(10, 10, 3, 3); g.fillRect(17, 8, 4, 3); + return c; + } + if (type === 'moss') { + g.fillStyle = '#0a6640'; + g.fillRect(0, 0, 32, 32); + g.fillStyle = '#1e8449'; + for (let i = 0; i < 6; i++) g.fillRect((Math.random()*26)|0, (Math.random()*26)|0, 4, 3); + return c; + } + if (type === 'swamp_water') { + g.fillStyle = 'rgba(100,120,40,0.6)'; + g.fillRect(0, 0, 32, 32); + g.fillStyle = 'rgba(80,100,20,0.3)'; + g.fillRect(0, 10, 32, 2); + g.fillRect(0, 22, 32, 2); + return c; + } + if (type === 'farmland') { + g.fillStyle = '#8B6914'; + g.fillRect(0, 0, 32, 32); + g.fillStyle = '#7a5c10'; + for (let i = 0; i < 4; i++) g.fillRect(0, 6+i*8, 32, 2); + g.fillStyle = '#6B4E0A'; + g.fillRect(8, 2, 2, 28); + g.fillRect(18, 2, 2, 28); + return c; + } + if (type === 'dead_bush') { + g.fillStyle = '#b2bec3'; + g.fillRect(12, 16, 2, 16); + g.fillRect(8, 14, 8, 2); + g.fillRect(16, 12, 8, 2); + g.fillRect(6, 18, 4, 2); + return c; + } + if (type === 'spruce_leaves') { + g.fillStyle = '#0a6640'; + g.fillRect(0, 0, 32, 32); + g.fillStyle = '#0d7a4d'; + for (let i = 0; i < 5; i++) g.fillRect((Math.random()*24)|0, (Math.random()*24)|0, 6, 4); + return c; + } + // CROP STAGES + if (type.startsWith('wheat_stage') || type.startsWith('carrot_stage') || type.startsWith('potato_stage')) { + const st = parseInt(type.charAt(type.length-1)); + const colors = type.startsWith('wheat') ? ['#a8e6a0','#7dcea0','#b8d730','#f1c40f'] : + type.startsWith('carrot') ? ['#a8e6a0','#f0c27a','#e8a040','#e67e22'] : + ['#a8e6a0','#c8b888','#bfaa78','#dfe6e9']; + g.fillStyle = '#5d4037'; + g.fillRect(15, 18, 2, 14); + if (st >= 1) { + g.fillStyle = colors[st]; + g.fillRect(10, 8 + (3-st)*3, 12, 10); + } else { + g.fillStyle = '#a8e6a0'; + g.fillRect(14, 22, 4, 4); } return c; } - function initTextures() { - Object.keys(BLOCKS).forEach((k2) => tex[k2] = makeTex(k2)); - const itemIds = [ - "arrow", - "bow", - "wood_pickaxe", - "stone_pickaxe", - "iron_pickaxe", - "wood_axe", - "stone_axe", - "iron_axe", - "wood_shovel", - "stone_shovel", - "iron_shovel", - "wood_sword", - "stone_sword", - "iron_sword", - "iron_ingot", - "gold_ingot", - "copper_ingot", - "meat", - "cooked" - ]; - itemIds.forEach((id) => { - itemTex[id] = makeItemTex(id); - }); + + 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; +} + +function initTextures() { + Object.keys(BLOCKS).forEach(k => tex[k] = makeTex(k)); + // Item/tool sprites + const itemIds = [ + 'arrow','bow', + 'wood_pickaxe','stone_pickaxe','iron_pickaxe', + 'wood_axe','stone_axe','iron_axe', + 'wood_shovel','stone_shovel','iron_shovel', + 'wood_sword','stone_sword','iron_sword', + 'iron_ingot','gold_ingot','copper_ingot', + 'meat','cooked', + 'wheat','bread','carrot','potato','baked_potato','mushroom_stew','wheat_seeds', + 'chicken_meat','feather','bone','gunpowder', + 'scorpion_stinger','polar_fur','slime_ball','eagle_feather', + 'gold_armor', + ]; + itemIds.forEach(id => { itemTex[id] = makeItemTex(id); }); +} + +// === ui/hotbar.js === +// Hotbar UI — 3-slot Minecraft-style bottom bar + selected item indicator + + + + + + + +const HOTBAR_SLOTS = 3; + +let selectedItemEl = null; + +function addToRecentItems(id) { + if (!id) return; + state.recentItems = state.recentItems.filter(item => item !== id); + state.recentItems.unshift(id); + state.recentItems = state.recentItems.slice(0, 5); +} + +function renderSlotIcon(el, id) { + if (BLOCKS[id] && tex[id]) { + el.style.backgroundImage = `url(${tex[id].toDataURL()})`; + el.style.backgroundSize = 'cover'; + } else if (itemTex[id]) { + el.style.backgroundImage = `url(${itemTex[id].toDataURL()})`; + el.style.backgroundSize = 'cover'; + } else if (ITEMS[id]) { + el.textContent = ITEMS[id].icon || '📦'; + } else if (TOOLS[id]) { + el.textContent = TOOLS[id].icon || '🔧'; + } else if (id === 'iron_armor') { + el.textContent = '🛡️'; + el.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)'; + } else { + el.textContent = id.charAt(0).toUpperCase(); + el.style.background = '#555'; + el.style.color = '#fff'; + el.style.fontWeight = '700'; + el.style.fontSize = '14px'; + } +} + +function updateSelectedItem() { + return; // selectedItem removed — highlight in hotbar is enough + if (!selectedItemEl) selectedItemEl = document.getElementById('selectedItem'); + if (!selectedItemEl) return; + const sel = state.selected; + if (!sel || !state.inv[sel] || state.inv[sel] <= 0) { + selectedItemEl.style.display = 'none'; + return; + } + selectedItemEl.style.display = 'flex'; + // Clear previous content + selectedItemEl.textContent = ''; + selectedItemEl.style.backgroundImage = ''; + selectedItemEl.style.background = 'rgba(0,0,0,0.45)'; + // Render icon + if (BLOCKS[sel] && tex[sel]) { + selectedItemEl.style.backgroundImage = `url(${tex[sel].toDataURL()})`; + selectedItemEl.style.backgroundSize = 'cover'; + } else if (itemTex[sel]) { + selectedItemEl.style.backgroundImage = `url(${itemTex[sel].toDataURL()})`; + selectedItemEl.style.backgroundSize = 'cover'; + } else if (ITEMS[sel]) { + selectedItemEl.textContent = ITEMS[sel].icon || '📦'; + } else if (TOOLS[sel]) { + selectedItemEl.textContent = TOOLS[sel].icon || '🔧'; + } else { + selectedItemEl.textContent = sel.charAt(0).toUpperCase(); + } +} + +function rebuildHotbar() { + const hotbarEl = state.hotbarEl; + const inv = state.inv; + const selected = state.selected; + const toolDurability = state.toolDurability; + + hotbarEl.innerHTML = ''; + + // Get up to 3 recent items with count > 0 + const items = state.recentItems.filter(id => inv[id] > 0).slice(0, HOTBAR_SLOTS); + + // If selected item not in list and has count > 0, push to front + if (inv[selected] > 0 && !items.includes(selected)) { + items.unshift(selected); + if (items.length > HOTBAR_SLOTS) items.pop(); } - // 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] && tex[id]) { - s.style.backgroundImage = `url(${tex[id].toDataURL()})`; - s.style.backgroundSize = "cover"; - } else if (itemTex[id]) { - s.style.backgroundImage = `url(${itemTex[id].toDataURL()})`; - s.style.backgroundSize = "cover"; - } else if (ITEMS[id]) { - s.textContent = ITEMS[id].icon || "\u{1F4E6}"; - } else if (TOOLS[id]) { - s.textContent = TOOLS[id].icon || "\u{1F527}"; - } 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"; + // Always render HOTBAR_SLOTS slots (fill empties) + for (let si = 0; si < HOTBAR_SLOTS; si++) { + const id = items[si] || null; + const s = document.createElement('div'); + s.className = 'slot' + (id && id === selected ? ' sel' : ''); + + // Slot number indicator (top-left) + const numEl = document.createElement('div'); + numEl.style.cssText = 'position:absolute;top:1px;left:3px;font-size:10px;color:rgba(255,255,255,0.55);font-weight:700;pointer-events:none;text-shadow:1px 1px 1px rgba(0,0,0,0.7);'; + numEl.textContent = (si + 1).toString(); + s.appendChild(numEl); + + if (id) { + renderSlotIcon(s, id); + + const c = document.createElement('div'); + c.className = 'count'; c.textContent = inv[id]; s.appendChild(c); s.onclick = () => { - playSound("click"); + playSound('click'); state.selected = id; - state.recentItems = state.recentItems.filter((item) => item !== id); + // Move to front of recentItems + 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"; + + 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) { @@ -978,1261 +2273,2185 @@ } } if (maxDur > 0) { - const bar = document.createElement("div"); + 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 fill = document.createElement('div'); const pct = curDur / maxDur; - const color = pct > 0.5 ? "#2ecc71" : pct > 0.25 ? "#f39c12" : "#e74c3c"; + 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); } + hotbarEl.appendChild(s); } - // 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" - }); - } - } + // Update selected item indicator above hotbar + updateSelectedItem(); +} - // 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 = getBlock(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 = 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] !== 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); - } +// === render/particles.js === +// Частицы (взрыв) +const parts = []; - // src/game/save.js - var SAVE_KEY2 = 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 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' }); } - 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_KEY2, 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)`); +} + +// === world/tnt.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); // цепь + } } } - function loadGame() { - return new Promise((resolve, reject) => { - try { - const localSave = localStorage.getItem(SAVE_KEY2); - 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); + + 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; } - 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); + e.vx += (dx / dist || 0) * 600; + e.vy -= 320; + } + }; + hurt(state.player); + state.mobs.forEach(hurt); +} + +// === ui/inventory.js === +// Inventory UI + + + + + + + + +function renderInventory() { + const inventoryGrid = state.inventoryGrid; + const inv = state.inv; + const selected = state.selected; + inventoryGrid.innerHTML = ''; + + 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]; + // Canvas sprite: block > itemTex > emoji fallback > unknown item fallback + if (BLOCKS[id] && tex[id]) { + slot.style.backgroundImage = `url(${tex[id].toDataURL()})`; + slot.style.backgroundSize = 'cover'; + } else if (itemTex[id]) { + slot.style.backgroundImage = `url(${itemTex[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%)'; + } else { + // Unknown item fallback — grey box with first letter + slot.textContent = id.charAt(0).toUpperCase(); + slot.style.background = '#555'; + slot.style.color = '#fff'; + slot.style.fontWeight = '700'; + slot.style.fontSize = '14px'; + } + + 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); + 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; + } else { + state.player.equippedArmor = 'iron_armor'; + state.player.armor = BLOCKS['iron_armor'].armor; + } + playSound('click'); + renderInventory(); + } + }; + } + + inventoryGrid.appendChild(slot); + } +} + +// === game/save.js === +// ==================== СИСТЕМА СОХРАНЕНИЯ ИГРЫ ==================== + + + + + + +const SAVE_STORAGE_KEY = state.SAVE_KEY; +let db = null; // Оставляем для совместимости, но не используем + +// Инициализация (localStorage + in-memory fallback) +function initDB() { + return new Promise((resolve) => { + console.log('Используем localStorage для сохранений (sandbox режим)'); + resolve(null); + }); +} + +function saveGame() { + // В MP не сохраняем мир — сервер хранит блоки и seed + if (state.isMultiplayer) { + console.log('[MP] Skipping world save — server is authoritative'); + return; + } + + 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, + dayNightCycle: state.dayNightCycle, + // Сохраняем только изменения + 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_STORAGE_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} байт)`); + } +} + +function loadGame() { + return new Promise((resolve, reject) => { + // Пробуем localStorage + try { + const localSave = localStorage.getItem(SAVE_STORAGE_KEY); + if (localSave) { + const parsed = JSON.parse(localSave); + console.log('Загружено из localStorage, player HP:', parsed.player?.hp); + resolve(parsed); 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); + } catch (e) { + console.warn('Ошибка доступа к localStorage:', e); } - if (saveData.worldSeed !== void 0) { - state.worldSeed = saveData.worldSeed; + + // Если 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 +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('Миграция завершена'); +} + +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); + console.log('isMultiplayer:', state.isMultiplayer); + + // Миграция версий + if (saveData.version === 1) { + migrateV1toV2(saveData); + } + + // В MP режиме сервер — истина, не даём локальному сейву перетирать мир + if (state.isMultiplayer) { + console.log('[MP] Skipping world seed/block restore from local save'); + // Восстанавливаем только игрока и инвентарь 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 { - console.log("No player data in save, setting default HP: 100"); - state.player.hp = 100; + state.player.hp = (saveData.player.hp > 0) ? saveData.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 !== 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 || []; + if (state.recentItems.length === 0) { + state.recentItems = Object.keys(state.inv).filter(id => state.inv[id] > 0).slice(0, 5); } rebuildHotbar(); - console.log("\u0418\u0433\u0440\u0430 \u0437\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u0430"); + if (typeof renderInventory === 'function') renderInventory(); + console.log('=== applySave END (MP mode, world skipped) ==='); + return; } - // src/ui/save-controls.js - var dropdownOpen = false; - function initSaveControls() { - const menuBtn = document.getElementById("menuBtn"); - const menuDropdown = document.getElementById("menuDropdown"); - const saveItem = document.getElementById("saveItem"); - const resetItem = document.getElementById("resetItem"); - const marketItem = document.getElementById("marketItem"); - menuBtn.onclick = null; - menuBtn.addEventListener("click", (e) => { + // Восстанавливаем 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; + } + if (saveData.dayNightCycle !== undefined) { + state.dayNightCycle = saveData.dayNightCycle; + } + + // Перегенерируем мир по 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 || []; + } + + // Fill recentItems from inventory if empty (hotbar won.t render otherwise) + if (state.recentItems.length === 0) { + state.recentItems = Object.keys(state.inv).filter(id => state.inv[id] > 0).slice(0, 5); + } + rebuildHotbar(); + renderInventory(); + console.log('Игра загружена'); +} + +// === ui/share.js === +// ==================== ПОДЕЛИТЬСЯ МИРОМ ==================== + + + + +function initShare() { + // Keep worldId click-to-copy as before + const worldIdEl = document.getElementById('worldId'); + if (worldIdEl) { + worldIdEl.onclick = () => shareWorld(); + } +} + +function shareWorld() { + const shareUrl = new URL(window.location.href); + shareUrl.searchParams.set('world', state.worldId); + const url = shareUrl.toString(); + + // In Telegram WebApp — use native share + if (isTgWebApp && window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.switchInlineQuery) { + const inviteUrl = 'https://t.me/' + (state.TELEGRAM_BOT_USERNAME || 'grechka_mkn8n_bot') + '/app?startapp=' + encodeURIComponent(state.worldId); + Telegram.WebApp.openTelegramLink( + 'https://t.me/share/url?url=' + encodeURIComponent(inviteUrl) + + '&text=' + encodeURIComponent('🏗️ Заходи в GrechkaCraft! Код мира: ' + state.worldId) + ); + return; + } + + // Otherwise — show custom modal with URL + copy button + const overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);z-index:100000;display:flex;align-items:center;justify-content:center;'; + + const box = document.createElement('div'); + box.style.cssText = 'background:#C6C6C6;border:4px solid #000;padding:20px;max-width:400px;width:90%;box-shadow:4px 4px 0 #000;font-family:Courier New,monospace;'; + + const title = document.createElement('div'); + title.textContent = '📤 Пригласить друзей'; + title.style.cssText = 'font-size:18px;font-weight:bold;margin-bottom:12px;color:#222;'; + box.appendChild(title); + + const codeLabel = document.createElement('div'); + codeLabel.style.cssText = 'font-size:13px;color:#444;font-weight:bold;margin-bottom:4px;'; + codeLabel.textContent = 'Код мира:'; + box.appendChild(codeLabel); + + const codeBox = document.createElement('div'); + codeBox.style.cssText = 'font-size:20px;font-weight:bold;background:#fff;border:3px solid #000;padding:8px 12px;margin-bottom:12px;letter-spacing:2px;text-align:center;color:#5A8F3C;'; + codeBox.textContent = state.worldId; + box.appendChild(codeBox); + + const linkLabel = document.createElement('div'); + linkLabel.style.cssText = 'font-size:13px;color:#444;font-weight:bold;margin-bottom:4px;'; + linkLabel.textContent = 'Ссылка:'; + box.appendChild(linkLabel); + + const linkBox = document.createElement('input'); + linkBox.type = 'text'; + linkBox.readOnly = true; + linkBox.value = url; + linkBox.style.cssText = 'width:100%;padding:8px;font-family:Courier New,monospace;font-size:13px;background:#fff;border:3px solid #000;color:#222;margin-bottom:12px;box-sizing:border-box;'; + linkBox.onclick = () => linkBox.select(); + box.appendChild(linkBox); + + const btnRow = document.createElement('div'); + btnRow.style.cssText = 'display:flex;gap:8px;'; + + const btnCopy = document.createElement('button'); + btnCopy.textContent = '📋 Скопировать'; + btnCopy.style.cssText = 'flex:1;padding:10px;font-family:Courier New,monospace;font-weight:bold;cursor:pointer;border:3px solid #000;background:#5A8F3C;color:#fff;box-shadow:2px 2px 0 #000;'; + btnCopy.onclick = () => { + navigator.clipboard.writeText(url).then(() => { + showToast('📋 Ссылка скопирована!'); + overlay.remove(); + }).catch(() => { + linkBox.select(); + document.execCommand('copy'); + showToast('📋 Ссылка скопирована!'); + overlay.remove(); + }); + }; + btnRow.appendChild(btnCopy); + + const btnCode = document.createElement('button'); + btnCode.textContent = '📋 Код'; + btnCode.style.cssText = 'padding:10px;font-family:Courier New,monospace;font-weight:bold;cursor:pointer;border:3px solid #000;background:#3C5F9E;color:#fff;box-shadow:2px 2px 0 #000;'; + btnCode.onclick = () => { + navigator.clipboard.writeText(state.worldId).then(() => { + showToast('📋 Код скопирован: ' + state.worldId); + }).catch(() => { + showToast('Код: ' + state.worldId); + }); + }; + btnRow.appendChild(btnCode); + + const btnClose = document.createElement('button'); + btnClose.textContent = '✕'; + btnClose.style.cssText = 'padding:10px 14px;font-family:Courier New,monospace;font-weight:bold;cursor:pointer;border:3px solid #000;background:#9E2B2B;color:#fff;box-shadow:2px 2px 0 #000;'; + btnClose.onclick = () => overlay.remove(); + btnRow.appendChild(btnClose); + + box.appendChild(btnRow); + overlay.appendChild(box); + + // Close on click outside box + overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; + + document.body.appendChild(overlay); +} + +// === ui/save-controls.js === +// ==================== МЕНЮ ⋯ + СОХРАНЕНИЕ / СБРОС ==================== + + + + + + +let dropdownOpen = false; + +function initSaveControls() { + const menuBtn = document.getElementById('menuBtn'); + const menuDropdown = document.getElementById('menuDropdown'); + const saveItem = document.getElementById('saveItem'); + const resetItem = document.getElementById('resetItem'); + const marketItem = document.getElementById('marketItem'); + const shareItem = document.getElementById('shareItem'); + const settingsItem = document.getElementById('settingsItem'); + const quitItem = document.getElementById('quitItem'); + + // Убить возможный старый onclick + if (menuBtn) menuBtn.onclick = null; + + // Тогл меню + if (menuBtn) { + menuBtn.addEventListener('click', (e) => { e.stopPropagation(); dropdownOpen = !dropdownOpen; - menuDropdown.classList.toggle("open", dropdownOpen); + if (menuDropdown) menuDropdown.classList.toggle('open', dropdownOpen); }); - document.addEventListener("click", () => { - if (dropdownOpen) { - dropdownOpen = false; - menuDropdown.classList.remove("open"); - } - }); - menuDropdown.addEventListener("click", (e) => { - e.stopPropagation(); - }); - saveItem.addEventListener("click", () => { - playSound("click"); + } + + // Закрытие по клику вне + document.addEventListener('click', () => { + if (dropdownOpen) { + dropdownOpen = false; + if (menuDropdown) menuDropdown.classList.remove('open'); + } + }); + + if (menuDropdown) { + menuDropdown.addEventListener('click', (e) => e.stopPropagation()); + } + + // 💾 Сохранить + if (saveItem) { + saveItem.addEventListener('click', () => { + playSound('click'); saveGame(); dropdownOpen = false; - menuDropdown.classList.remove("open"); - alert("\u0418\u0433\u0440\u0430 \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0430!"); + if (menuDropdown) menuDropdown.classList.remove('open'); + alert('Игра сохранена!'); }); - resetItem.addEventListener("click", () => { - 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); - } + } + + // 🔄 Новый мир + if (resetItem) { + resetItem.addEventListener('click', () => { + if (confirm('Вы уверены, что хотите удалить сохранение и начать новую игру?')) { + playSound('click'); + try { localStorage.removeItem(state.SAVE_KEY); } catch(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); - if (typeof window.history !== "undefined" && typeof window.history.replaceState === "function") { - window.history.replaceState(null, "", newUrl.toString()); - console.log("URL \u043E\u0431\u043D\u043E\u0432\u043B\u0451\u043D:", newUrl.toString()); - } - } catch (e) { - console.error("\u041E\u0448\u0438\u0431\u043A\u0430 \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0438\u044F URL:", e); - } + newUrl.searchParams.set('world', state.worldId); + window.history.replaceState(null, '', newUrl.toString()); + } catch(e) {} location.reload(); } else { dropdownOpen = false; - menuDropdown.classList.remove("open"); + if (menuDropdown) menuDropdown.classList.remove('open'); } }); - marketItem.addEventListener("click", () => { + } + + // 🏪 Рынок + if (marketItem) { + marketItem.addEventListener('click', () => { dropdownOpen = false; - menuDropdown.classList.remove("open"); - document.dispatchEvent(new CustomEvent("toggleMarket")); + if (menuDropdown) menuDropdown.classList.remove('open'); + document.dispatchEvent(new CustomEvent('toggleMarket')); }); } - function updateSaveButtonVisibility() { - const saveItem = document.getElementById("saveItem"); - if (!saveItem) return; - if (state.isMultiplayer && state.otherPlayers.size > 0) { - saveItem.style.display = "none"; - } else { - saveItem.style.display = "block"; - } + + // 📤 Пригласить + if (shareItem) { + shareItem.addEventListener('click', () => { + dropdownOpen = false; + if (menuDropdown) menuDropdown.classList.remove('open'); + shareWorld(); + }); } - // 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_INTERVAL2 = 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_INTERVAL2) 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 }); + // ⚙️ Настройки + if (settingsItem) { + settingsItem.addEventListener('click', () => { + dropdownOpen = false; + menuDropdown.classList.remove('open'); + const panel = document.getElementById('settingsPanel'); + if (!panel) return; + // Sync controls with current state + const toggle = document.getElementById('dayNightToggle'); + const timeSlider = document.getElementById('timeSlider'); + const wSlider = document.getElementById('weatherIntensity'); + if (toggle) toggle.checked = state.dayNightCycle !== false; + if (timeSlider) timeSlider.value = Math.round(state.worldTime * 100); + if (wSlider) wSlider.value = Math.round(state.rainIntensity * 100); + // Sync weather buttons — highlight the active type + const wt = state.weatherType || (state.isRaining ? 'rain' : 'clear'); + const activeId = 'weather' + wt.charAt(0).toUpperCase() + wt.slice(1); + for (const bid of ['weatherClear','weatherRain','weatherSnow','weatherThunder']) { + const b = document.getElementById(bid); + if (b) b.style.background = (bid === activeId) ? '#3498db' : '#555'; + } + panel.style.display = 'block'; + }); } - // src/ui/trade.js - function itemName(id) { - return BLOCKS[id]?.n || TOOLS[id]?.n || ITEMS[id]?.n || id; + // Закрытие настроек + const settingsClose = document.getElementById('settingsClose'); + if (settingsClose) { + settingsClose.addEventListener('click', () => { + const settingsPanel = document.getElementById('settingsPanel'); + if (settingsPanel) settingsPanel.style.display = 'none'; + }); } - function itemIcon(id) { - if (BLOCKS[id]) return "\u{1F7EB}"; - if (TOOLS[id]) return TOOLS[id].icon || "\u{1F527}"; - if (ITEMS[id]) return ITEMS[id].icon || "\u{1F4E6}"; - return "\u{1F4E6}"; + + // Тогл день/ночи + const dayNightToggle = document.getElementById('dayNightToggle'); + if (dayNightToggle) { + dayNightToggle.addEventListener('change', () => { + state.dayNightCycle = dayNightToggle.checked; + saveGame(); + }); } - var tradeOpen = false; - var currentTradeId = null; - var myOffer = {}; - var partnerOffer = {}; - var iConfirmed = false; - function initTradeSocket(socket2) { - socket2.on("trade_request", (data) => { - playSound("click"); - const overlay = document.createElement("div"); - overlay.id = "tradeRequestOverlay"; - overlay.className = "custom-modal-overlay"; - overlay.innerHTML = ` + + // 🏠 Главное меню + if (quitItem) { + quitItem.addEventListener('click', () => { + dropdownOpen = false; + if (menuDropdown) menuDropdown.classList.remove('open'); + saveGame(); + try { + const url = new URL(window.location.href); + url.searchParams.delete('world'); + window.location.href = url.toString(); + } catch(e) { location.reload(); } + }); + } +} + +function updateSaveButtonVisibility() { + const saveItem = document.getElementById('saveItem'); + if (!saveItem) return; + saveItem.style.display = (state.isMultiplayer && state.otherPlayers.size > 0) ? 'none' : 'block'; +} + +// === multiplayer/socket-helpers.js === +// Socket helpers: цвет, отправка позиции/блоков + + + + + +// Генерация случайного цвета для игрока на основе 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 для отправки позиции (10-20 раз в секунду) +let lastMoveSendTime = 0; + +let lastSentX = 0, lastSentY = 0; + +// Отправка позиции игрока (с throttle) +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 }); +} + +// Отправка изменения блока +function sendBlockChange(gx, gy, t, op) { + if (!state.isMultiplayer || !state.socket || !state.socket.connected) return; + + state.socket.emit('block_change', { gx, gy, t, op }); +} + +// === ui/trade.js === +// Trade UI — личная торговля (лицом к лицу) + + + + + + + +function itemName(id) { + return BLOCKS[id]?.n || TOOLS[id]?.n || ITEMS[id]?.n || id; +} +function itemIcon(id) { + if (BLOCKS[id]) return '🟫'; + if (TOOLS[id]) return TOOLS[id].icon || '🔧'; + if (ITEMS[id]) return ITEMS[id].icon || '📦'; + return '📦'; +} + +let tradeOpen = false; +let currentTradeId = null; +let myOffer = {}; +let partnerOffer = {}; +let iConfirmed = false; + +function isTradeOpen() { return tradeOpen; } + +function initTradeSocket(socket) { + // Incoming trade request + socket.on('trade_request', (data) => { + playSound('click'); + // Show trade request modal + const overlay = document.createElement('div'); + overlay.id = 'tradeRequestOverlay'; + overlay.className = 'custom-modal-overlay'; + overlay.innerHTML = `
-
\u{1F91D} ${data.from_name} \u0445\u043E\u0447\u0435\u0442 \u0442\u043E\u0440\u0433\u043E\u0432\u0430\u0442\u044C
+
🤝 ${data.from_name} хочет торговать
`; - document.getElementById("game").appendChild(overlay); - document.getElementById("tradeAcceptBtn").addEventListener("click", () => { - overlay.remove(); - socket2.emit("trade_accept", { from_socket_id: data.from }); - }); - document.getElementById("tradeDeclineBtn").addEventListener("click", () => { - overlay.remove(); - socket2.emit("trade_decline", { from_socket_id: data.from }); - }); + document.getElementById('game').appendChild(overlay); + + document.getElementById('tradeAcceptBtn').addEventListener('click', () => { + overlay.remove(); + socket.emit('trade_accept', { from_socket_id: data.from }); }); - socket2.on("trade_start", (data) => { - currentTradeId = data.trade_id; - myOffer = {}; - partnerOffer = {}; - iConfirmed = false; - tradeOpen = true; - renderTradeUI(data.partner_name); - state.tradePanel.style.display = "block"; + document.getElementById('tradeDeclineBtn').addEventListener('click', () => { + overlay.remove(); + socket.emit('trade_decline', { from_socket_id: data.from }); }); - socket2.on("trade_update", (data) => { - partnerOffer = data.player1Offer || data.player2Offer || {}; - const isP1 = state.socket && state.socket.id === data.player1Offer; - if (state.mySocketId) { + }); + + socket.on('trade_start', (data) => { + currentTradeId = data.trade_id; + myOffer = {}; + partnerOffer = {}; + iConfirmed = false; + tradeOpen = true; + renderTradeUI(data.partner_name); + state.tradePanel.style.display = 'block'; + }); + + socket.on('trade_update', (data) => { + partnerOffer = data.player1Offer || data.player2Offer || {}; + // Determine which offer is the partner's + const isP1 = state.socket && state.socket.id === data.player1Offer; + // The partner's offer is the one we didn't send + // Actually the server sends both — we figure out which is ours + // Simpler: just use the data directly + if (state.mySocketId) { + // We need to know which offer in the update is ours vs partner's + // The trade_update event doesn't tell us, but we know myOffer already + // So partnerOffer = the one that's not myOffer + } + renderTradeOffers(); + }); + + socket.on('trade_status', (data) => { + // Update confirm buttons + const confirmBtn = document.getElementById('tradeConfirmBtn'); + if (confirmBtn) { + const amIPlayer1 = true; // We need to track this + if (iConfirmed) { + confirmBtn.textContent = '✅ Ожидание...'; + confirmBtn.disabled = true; + confirmBtn.style.background = '#555'; } - renderTradeOffers(); - }); - socket2.on("trade_status", (data) => { - const confirmBtn = document.getElementById("tradeConfirmBtn"); - if (confirmBtn) { - const amIPlayer1 = true; - if (iConfirmed) { - confirmBtn.textContent = "\u2705 \u041E\u0436\u0438\u0434\u0430\u043D\u0438\u0435..."; - confirmBtn.disabled = true; - confirmBtn.style.background = "#555"; - } + } + // Show partner confirmation status + const partnerStatus = document.getElementById('partnerConfirmStatus'); + if (partnerStatus) { + const partnerConfirmed = data.player1Confirm || data.player2Confirm; + partnerStatus.textContent = partnerConfirmed ? '✅ Подтвердил' : '⏳ Ещё не подтвердил'; + } + }); + + socket.on('trade_complete', (data) => { + // Add received items to inventory + const received = data.received || {}; + for (const [item, qty] of Object.entries(received)) { + if (qty > 0) { + state.inv[item] = (state.inv[item] || 0) + qty; } - const partnerStatus = document.getElementById("partnerConfirmStatus"); - if (partnerStatus) { - const partnerConfirmed = data.player1Confirm || data.player2Confirm; - partnerStatus.textContent = partnerConfirmed ? "\u2705 \u041F\u043E\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043B" : "\u23F3 \u0415\u0449\u0451 \u043D\u0435 \u043F\u043E\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043B"; + } + // Remove sent items from inventory + const sent = data.sent || {}; + for (const [item, qty] of Object.entries(sent)) { + if (qty > 0) { + state.inv[item] = (state.inv[item] || 0) - qty; + if (state.inv[item] < 0) state.inv[item] = 0; } - }); - socket2.on("trade_complete", (data) => { - const received = data.received || {}; - for (const [item, qty] of Object.entries(received)) { - if (qty > 0) { - state.inv[item] = (state.inv[item] || 0) + qty; - } - } - const sent = data.sent || {}; - for (const [item, qty] of Object.entries(sent)) { - if (qty > 0) { - state.inv[item] = (state.inv[item] || 0) - qty; - if (state.inv[item] < 0) state.inv[item] = 0; - } - } - addChatMessage("\u0421\u0438\u0441\u0442\u0435\u043C\u0430", "\u{1F91D} \u0421\u0434\u0435\u043B\u043A\u0430 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043D\u0430!"); - closeTrade(); - }); - socket2.on("trade_declined", () => { - addChatMessage("\u0421\u0438\u0441\u0442\u0435\u043C\u0430", "\u0422\u043E\u0440\u0433\u043E\u0432\u043B\u044F \u043E\u0442\u043A\u043B\u043E\u043D\u0435\u043D\u0430"); - closeTrade(); - }); - socket2.on("trade_cancelled", () => { - addChatMessage("\u0421\u0438\u0441\u0442\u0435\u043C\u0430", "\u0422\u043E\u0440\u0433\u043E\u0432\u043B\u044F \u043E\u0442\u043C\u0435\u043D\u0435\u043D\u0430"); - closeTrade(); - }); - socket2.on("trade_error", (data) => { - addChatMessage("\u0421\u0438\u0441\u0442\u0435\u043C\u0430", "\u274C " + data.msg); - }); - } - function renderTradeUI(partnerName) { - const panel = state.tradePanel; - const content = state.tradeContent; - content.innerHTML = ` -
\u{1F91D} \u0422\u043E\u0440\u0433\u043E\u0432\u043B\u044F \u0441 ${partnerName}
+ } + addChatMessage('Система', '🤝 Сделка завершена!'); + closeTrade(); + }); + + socket.on('trade_declined', () => { + addChatMessage('Система', 'Торговля отклонена'); + closeTrade(); + }); + + socket.on('trade_cancelled', () => { + addChatMessage('Система', 'Торговля отменена'); + closeTrade(); + }); + + socket.on('trade_error', (data) => { + addChatMessage('Система', '❌ ' + data.msg); + }); +} + +function renderTradeUI(partnerName) { + const panel = state.tradePanel; + const content = state.tradeContent; + content.innerHTML = ` +
🤝 Торговля с ${partnerName}
-
\u0412\u044B \u043F\u0440\u0435\u0434\u043B\u0430\u0433\u0430\u0435\u0442\u0435:
+
Вы предлагаете:
- +
-
\u21C4
+
⇄
-
\u041F\u0430\u0440\u0442\u043D\u0451\u0440 \u043F\u0440\u0435\u0434\u043B\u0430\u0433\u0430\u0435\u0442:
+
Партнёр предлагает:
- \u041E\u0436\u0438\u0434\u0430\u043D\u0438\u0435... + Ожидание...
-
\u23F3 \u041F\u0430\u0440\u0442\u043D\u0451\u0440 \u0435\u0449\u0451 \u043D\u0435 \u043F\u043E\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043B
+
⏳ Партнёр ещё не подтвердил
- - + +
`; - document.getElementById("tradeAddItemBtn")?.addEventListener("click", showAddItemDialog); - document.getElementById("tradeConfirmBtn")?.addEventListener("click", confirmTrade); - document.getElementById("tradeCancelBtn")?.addEventListener("click", cancelTrade); - } - function renderTradeOffers() { - const myItems = document.getElementById("myOfferItems"); - if (myItems) { - const keys = Object.keys(myOffer).filter((k2) => myOffer[k2] > 0); - if (keys.length === 0) { - myItems.innerHTML = '\u041D\u0438\u0447\u0435\u0433\u043E'; - } else { - myItems.innerHTML = keys.map((k2) => `
${itemIcon(k2)} ${itemName(k2)} x${myOffer[k2]}
`).join(""); - } + + document.getElementById('tradeAddItemBtn')?.addEventListener('click', showAddItemDialog); + document.getElementById('tradeConfirmBtn')?.addEventListener('click', confirmTrade); + document.getElementById('tradeCancelBtn')?.addEventListener('click', cancelTrade); +} + +function renderTradeOffers() { + const myItems = document.getElementById('myOfferItems'); + if (myItems) { + const keys = Object.keys(myOffer).filter(k => myOffer[k] > 0); + if (keys.length === 0) { + myItems.innerHTML = 'Ничего'; + } else { + myItems.innerHTML = keys.map(k => `
${itemIcon(k)} ${itemName(k)} x${myOffer[k]}
`).join(''); } } - function showAddItemDialog() { - const inv = state.inv; - const items = Object.keys(inv).filter((id) => inv[id] > 0); - if (items.length === 0) { - addChatMessage("\u0421\u0438\u0441\u0442\u0435\u043C\u0430", "\u0418\u043D\u0432\u0435\u043D\u0442\u0430\u0440\u044C \u043F\u0443\u0441\u0442!"); - return; - } - const overlay = document.createElement("div"); - overlay.id = "tradeItemPicker"; - overlay.className = "custom-modal-overlay"; - let html = '
\u0427\u0442\u043E \u043F\u0440\u0435\u0434\u043B\u043E\u0436\u0438\u0442\u044C?
'; - for (const id of items) { - const offered = myOffer[id] || 0; - const available = inv[id] - offered; - if (available <= 0) continue; - html += `
+} + +function showAddItemDialog() { + const inv = state.inv; + const items = Object.keys(inv).filter(id => inv[id] > 0); + if (items.length === 0) { + addChatMessage('Система', 'Инвентарь пуст!'); + return; + } + + const overlay = document.createElement('div'); + overlay.id = 'tradeItemPicker'; + overlay.className = 'custom-modal-overlay'; + let html = '
Что предложить?
'; + for (const id of items) { + // Show already offered count + const offered = myOffer[id] || 0; + const available = inv[id] - offered; + if (available <= 0) continue; + html += `
${itemIcon(id)} ${itemName(id)} x${available}
`; + } + html += '
'; + overlay.innerHTML = html; + document.getElementById('game').appendChild(overlay); + + overlay.querySelectorAll('.trade-pick-item').forEach(el => { + el.addEventListener('click', () => { + const itemId = el.dataset.item; + // Ask quantity + const available = inv[itemId] - (myOffer[itemId] || 0); + const qty = Math.min(available, available > 1 ? promptForQty(itemId, available) : 1); + if (qty > 0) { + myOffer[itemId] = (myOffer[itemId] || 0) + qty; + renderTradeOffers(); + // Reset confirm when offer changes + iConfirmed = false; + const btn = document.getElementById('tradeConfirmBtn'); + if (btn) { btn.textContent = '✅ Подтвердить'; btn.disabled = false; btn.style.background = '#2ecc71'; } + } + overlay.remove(); + }); + }); +} + +function promptForQty(itemId, max) { + // Simple: just use 1 for now, can enhance later with a proper dialog + const val = parseInt(prompt(`Сколько ${itemName(itemId)}? (макс: ${max})`, '1')); + return isNaN(val) || val < 1 ? 1 : Math.min(val, max); +} + +function confirmTrade() { + if (!currentTradeId || !state.socket) return; + + // Deduct offered items from inventory + for (const [item, qty] of Object.entries(myOffer)) { + if (qty > 0) { + if (state.inv[item] < qty) { + addChatMessage('Система', `Недостаточно ${itemName(item)}!`); + return; + } } - html += "
"; - overlay.innerHTML = html; - document.getElementById("game").appendChild(overlay); - overlay.querySelectorAll(".trade-pick-item").forEach((el) => { - el.addEventListener("click", () => { - const itemId = el.dataset.item; - const available = inv[itemId] - (myOffer[itemId] || 0); - const qty = Math.min(available, available > 1 ? promptForQty(itemId, available) : 1); - if (qty > 0) { - myOffer[itemId] = (myOffer[itemId] || 0) + qty; - renderTradeOffers(); - iConfirmed = false; - const btn = document.getElementById("tradeConfirmBtn"); - if (btn) { - btn.textContent = "\u2705 \u041F\u043E\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044C"; - btn.disabled = false; - btn.style.background = "#2ecc71"; + } + + // Send offer + confirm + state.socket.emit('trade_offer', { trade_id: currentTradeId, offer: myOffer }); + + // Small delay then confirm + setTimeout(() => { + state.socket.emit('trade_confirm', { trade_id: currentTradeId }); + iConfirmed = true; + const btn = document.getElementById('tradeConfirmBtn'); + if (btn) { btn.textContent = '✅ Ожидание...'; btn.disabled = true; btn.style.background = '#555'; } + }, 200); +} + +function cancelTrade() { + if (!currentTradeId || !state.socket) return; + state.socket.emit('trade_cancel', { trade_id: currentTradeId }); + closeTrade(); +} + +function closeTrade() { + tradeOpen = false; + currentTradeId = null; + myOffer = {}; + partnerOffer = {}; + iConfirmed = false; + if (state.tradePanel) state.tradePanel.style.display = 'none'; + // Remove any pick overlay + document.getElementById('tradeItemPicker')?.remove(); +} + +function requestTradeWithPlayer(targetName) { + if (!state.socket || !state.isMultiplayer) { + addChatMessage('Система', 'Торговля доступна только в мультиплеере'); + return; + } + state.socket.emit('trade_request', { target_name: targetName }); + addChatMessage('Система', `Запрос отправлен ${targetName}...`); +} + +// === multiplayer/socket.js === +// Socket.IO клиент — инициализация и все обработчики + + + + + + + + + + + + + + +// Mob sizes for server mob sync (server doesn't send w/h) +const MOB_SIZES = { + pig: { w: 34, h: 24 }, + chicken: { w: 26, h: 22 }, + zombie: { w: 34, h: 50 }, + creeper: { w: 34, h: 50 }, + skeleton: { w: 34, h: 50 }, + scorpion: { w: 26, h: 26 }, + polar_bear: { w: 40, h: 34 }, + slime: { w: 24, h: 24 }, + eagle: { w: 30, h: 22 }, +}; + +function applyMobSize(sm) { + const s = MOB_SIZES[sm.kind]; + if (s && !sm.w) { sm.w = s.w; sm.h = s.h; } + return sm; +} + +let socket = null; + + + +function initSocket() { + try { + socket = io(SERVER_URL, { + path: '/socket.io/', + transports: ['websocket', 'polling'] + }); + + // Store socket on state for other modules + state.socket = socket; + + socket.on('connect', () => { + console.log(`Connected to server: ${SERVER_URL}, socket.id: ${socket.id}, worldId: ${state.worldId}`); + state.mySocketId = socket.id; + state.isMultiplayer = true; + + // Присоединяемся к миру + socket.emit('join_world', { world_id: state.worldId, player_name: state.playerName }); + + // Показываем в UI + if (state.worldIdEl) state.worldIdEl.textContent = state.worldId; + if (state.multiplayerStatus) state.multiplayerStatus.style.display = 'block'; + + // Initialize trade socket handlers + initTradeSocket(socket); + }); + + 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(); + if (state.multiplayerStatus) state.multiplayerStatus.style.display = 'none'; + }); + + // Обработка world_state + socket.on('world_state', (data) => { + console.log('Received world_state:', data); + + 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); + + 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); } } - overlay.remove(); + } + + if (data.time !== undefined) { + state.worldTime = data.time; + state.isNightTime = state.worldTime > 0.5; + } + // Weather from server + if (data.weatherType) { + state.weatherType = data.weatherType; + state.weatherChangeInterval = Infinity; + state.isRaining = (data.weatherType === 'rain' || data.weatherType === 'storm' || data.weatherType === 'thunder'); + } + + // Spawn point + { + 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; + // Mark that we've received authoritative world state — prevents local save override + state._worldSynced = true; + 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) { + let 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 }; + applyMobSize(sm); + state.serverMobs.set(m.id, sm); + } + } + + // Set myTgId from server if provided + if (data.tg_id) state.myTgId = data.tg_id; + }); + + 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 === + socket.on('mob_spawned', (data) => { + let 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 }; + applyMobSize(sm); + 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 }); }); - } - function promptForQty(itemId, max) { - const val = parseInt(prompt(`\u0421\u043A\u043E\u043B\u044C\u043A\u043E ${itemName(itemId)}? (\u043C\u0430\u043A\u0441: ${max})`, "1")); - return isNaN(val) || val < 1 ? 1 : Math.min(val, max); - } - function confirmTrade() { - if (!currentTradeId || !state.socket) return; - for (const [item, qty] of Object.entries(myOffer)) { - if (qty > 0) { - if (state.inv[item] < qty) { - addChatMessage("\u0421\u0438\u0441\u0442\u0435\u043C\u0430", `\u041D\u0435\u0434\u043E\u0441\u0442\u0430\u0442\u043E\u0447\u043D\u043E ${itemName(item)}!`); - return; - } - } - } - state.socket.emit("trade_offer", { trade_id: currentTradeId, offer: myOffer }); - setTimeout(() => { - state.socket.emit("trade_confirm", { trade_id: currentTradeId }); - iConfirmed = true; - const btn = document.getElementById("tradeConfirmBtn"); - if (btn) { - btn.textContent = "\u2705 \u041E\u0436\u0438\u0434\u0430\u043D\u0438\u0435..."; - btn.disabled = true; - btn.style.background = "#555"; - } - }, 200); - } - function cancelTrade() { - if (!currentTradeId || !state.socket) return; - state.socket.emit("trade_cancel", { trade_id: currentTradeId }); - closeTrade(); - } - function closeTrade() { - tradeOpen = false; - currentTradeId = null; - myOffer = {}; - partnerOffer = {}; - iConfirmed = false; - if (state.tradePanel) state.tradePanel.style.display = "none"; - document.getElementById("tradeItemPicker")?.remove(); - } - // src/multiplayer/socket.js - var socket = null; - function initSocket() { - try { - socket = io(SERVER_URL, { - path: "/socket.io/", - transports: ["websocket", "polling"] - }); - state.socket = socket; - socket.on("connect", () => { - console.log(`Connected to server: ${SERVER_URL}, socket.id: ${socket.id}, worldId: ${state.worldId}`); - state.mySocketId = socket.id; - state.isMultiplayer = true; - socket.emit("join_world", { world_id: state.worldId, player_name: state.playerName }); - if (state.worldIdEl) state.worldIdEl.textContent = state.worldId; - if (state.multiplayerStatus) state.multiplayerStatus.style.display = "block"; - initTradeSocket(socket); - }); - 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(); - if (state.multiplayerStatus) 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); - } - } - if (data.tg_id) state.myTgId = data.tg_id; - }); - 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; + 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_sync', (data) => { + if (data.time !== undefined) { + state.worldTime = data.time; + state.isNightTime = state.worldTime > 0.5; + } + }); + + // Weather sync from server (multiplayer) + socket.on('weather_update', (data) => { + if (!state.isMultiplayer) return; + if (data.type) { + state.weatherType = data.type; + state.weatherChangeInterval = Infinity; + state.isRaining = (data.type === 'rain' || data.type === 'storm' || data.type === 'thunder'); + } + }); + + } catch (e) { + console.error('Error initializing socket:', e); + state.isMultiplayer = false; + } +} + +// === world/water.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 W = state.W; + const H = state.H; + 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++; } } - // src/data/recipes.js - var 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: "wood_axe", qty: 1, cost: { wood: 3, planks: 2 } }, - { out: "stone_axe", qty: 1, cost: { wood: 1, stone: 3 } }, - { out: "iron_axe", qty: 1, cost: { wood: 1, iron_ore: 2 } }, - { out: "wood_shovel", qty: 1, cost: { wood: 1, planks: 1 } }, - { out: "stone_shovel", qty: 1, cost: { wood: 1, stone: 2 } }, - { out: "iron_shovel", 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 } } - ]; - 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 } - ]; + // Обновляем воду с ограничением глубины распространения + const processed = new Set(); + const toAdd = []; + const MAX_WATER_DEPTH = 20; // Максимальная глубина распространения воды - // 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; - waterUpdateTimer = 0; - const MAX_WATER_BLOCKS_PER_UPDATE = 50; - let processedCount = 0; - waterUpdateQueue.clear(); - const W = state.W; - const H = state.H; - 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++; + 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; } - const processed = /* @__PURE__ */ 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) { - 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; + + // Если внизу не вода и не твёрдый блок - вода может течь вниз + 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; } - 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); + + // Если внизу твёрдый блок или вода - вода растекается горизонтально + // Проверяем левую сторону + 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; } - for (let i = blocks.length - 1; i >= 0; i--) { - if (blocks[i].dead) { - blocks.splice(i, 1); + + // Проверяем правую сторону + 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; } } - // src/physics/water-detect.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"); + // Применяем изменения (только добавляем новые блоки) + 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); } } - // 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 = getBlock(gx, gy); - const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable; - if (onLadder) { + // Очищаем мёртвые блоки из массива + for (let i = blocks.length - 1; i >= 0; i--) { + if (blocks[i].dead) { + blocks.splice(i, 1); + } + } +} + +// === physics/water-detect.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' || b.t === 'swamp_water')); +} + +function isSwampWaterAt(px, py) { + const gx = Math.floor(px / TILE); + const gy = Math.floor(py / TILE); + const b = getBlock(gx, gy); + return !!(b && b.t === 'swamp_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.inSwampWater = isSwampWaterAt(cx, e.y + e.h / 2); + + // Голова под водой — для кислорода/урона + e.headInWater = isWaterAt(cx, e.y + 4); + + // Звук при падении в воду + if (e === state.player && !wasInWater && e.inWater && e.vy > 100) { + playSound('splash'); + } + + // Болотная вода — отравление + if (e === state.player && e.inSwampWater) { + state.player.hp -= 3 * (typeof dt !== 'undefined' ? dt : 0.016); + } +} + +// === physics/collision.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 (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; - } - 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; - } + + // урон от падения — только игроку и только не в воде + 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; - } - } - 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; - } - } - } - 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 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; } + if (e === state.player) e.fallStartY = e.y; } } - // 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; + // 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; + } + } +} + +// === entities/mob-ai.js === +// Моб 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((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; } - 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 { + 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 if (m.kind === 'scorpion') { + // Скорпион — бежит к игроку, яд (замедление) + 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(8); + state.player.hp -= damage; + state.player.invuln = 0.8; + state.player.slowTimer = 3; + state.player.vx += dir * 300; + state.player.vy -= 200; + playSound('hit1'); + } + } else if (m.kind === 'polar_bear') { + // Белый медведь — нейтрален, бродит + m.aiT -= dt; + if (m.aiT <= 0) { + m.aiT = 2.0 + Math.random() * 3; + m.dir = Math.random() < 0.5 ? -1 : 1; + if (Math.random() < 0.3) m.dir = 0; + } + m.vx = m.dir * m.speed; + if (m.inWater) m.vy = -120; + } else if (m.kind === 'slime') { + // Слизь — прыгает к игроку + const dir = Math.sign((state.player.x + state.player.w / 2) - (m.x + m.w / 2)); + m.aiT -= dt; + if (m.aiT <= 0) { + m.aiT = 1.5 + Math.random() * 1.5; + m.dir = dir; + m.vy = -200; + } + m.vx = m.dir * m.speed; + } else if (m.kind === 'eagle') { + // Орёл — летает, атакует пикированием + 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 dist = Math.hypot(dx, dy); + if (dist < 400) { + m.vx = Math.sign(dx) * m.speed; + m.vy = dy > 0 ? 60 : -60; } else { m.aiT -= dt; if (m.aiT <= 0) { - m.aiT = 1.8 + Math.random() * 2.5; + m.aiT = 2 + Math.random() * 3; 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; + m.vx = m.dir * m.speed * 0.5; + m.vy = Math.sin(performance.now() / 1000) * 30; } - const g = m.inWater ? GRAV_WATER : GRAV; - m.vy += g * dt; - m.y += m.vy * dt; + // Атака при касании + if (dist < 30 && state.player.invuln <= 0) { + const damage = calculateDamage(10); + state.player.hp -= damage; + state.player.invuln = 0.8; + state.player.vy -= 200; + playSound('hit1'); + } + // Орёл не падает — летающий моб + m.vy *= 0.5; m.grounded = false; - resolveY(m); - m.x += m.vx * dt; - resolveX(m); - } - function isNight() { - return state.worldTime > 0.5; + } else { + // животные (pig, chicken) + 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; } - // 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 = getBlock(fgx, fgy); - if (!fb || fb.t !== "furnace") { - furnacePanel.style.display = "none"; - state.currentFurnaceKey = null; - return; + // Мобы прыгают при столкновении со стеной + if (m.grounded && Math.abs(m.vx) > 5 && m.kind !== 'eagle') { + const probeX = m.vx > 0 ? m.x + m.w + 2 : m.x - 2; + const probeGY = Math.floor((m.y + m.h - 1) / TILE); + if (isSolid(Math.floor(probeX / TILE), probeGY)) { + m.vy = -280; } - 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(); + + // физика моба + const g = (m.inWater && m.kind !== 'eagle') ? 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; +} + +// Mob loot table +function getMobLoot(kind) { + const table = { + chicken: [{item:'chicken_meat',min:1,max:2,chance:1},{item:'feather',min:0,max:1,chance:0.5}], + pig: [{item:'meat',min:1,max:2,chance:1}], + zombie: [{item:'meat',min:0,max:1,chance:0.5},{item:'iron_ingot',min:0,max:1,chance:0.3}], + skeleton: [{item:'arrow',min:2,max:4,chance:1},{item:'bone',min:0,max:2,chance:0.6},{item:'bow',min:1,max:1,chance:0.15}], + creeper: [{item:'gunpowder',min:1,max:2,chance:1}], + scorpion: [{item:'scorpion_stinger',min:0,max:1,chance:0.4}], + polar_bear:[{item:'polar_fur',min:1,max:2,chance:0.8},{item:'meat',min:1,max:2,chance:1}], + slime: [{item:'slime_ball',min:1,max:2,chance:1}], + eagle: [{item:'eagle_feather',min:1,max:2,chance:0.7}] }; - 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(); - } + return table[kind] || []; +} + +// === data/recipes.js === +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:'wood_axe', qty:1, cost:{ wood:3, planks:2 } }, + { out:'stone_axe', qty:1, cost:{ wood:1, stone:3 } }, + { out:'iron_axe', qty:1, cost:{ wood:1, iron_ore:2 } }, + { out:'wood_shovel', qty:1, cost:{ wood:1, planks:1 } }, + { out:'stone_shovel', qty:1, cost:{ wood:1, stone:2 } }, + { out:'iron_shovel', qty:1, cost:{ wood:1, iron_ore:1 } }, + { out:'iron_armor', qty:1, cost:{ iron_ore: 5 } }, + { out:'gold_armor', qty:1, cost:{ gold_ore: 8 } }, + { 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 } }, + { out:'flag', qty:1, cost:{ planks:3, iron_ore:1 } }, + { out:'bread', qty:1, cost:{ wheat: 3 } }, + { out:'farmland', qty:1, cost:{ dirt:1 } }, + { out:'wheat_seeds', qty:1, cost:{ farmland:1, dirt: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 }, + { in:'potato', qty:1, out:'baked_potato', outQty:1, time:2 } +]; + +// === ui/furnace.js === +// ==================== ПЕЧЬ (ОБЖИГ) ==================== + + + + + + + + +const furnacePanel = document.getElementById('furnacePanel'); +const 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 = 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(); +}; + +// Тик печей — вызывается в главном цикле +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(); } } } +} - // 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; +// === world/weather.js === +// ==================== ПОГОДА (biome-aware) ==================== + + + + + + +// Возможные погоды по биому +const BIOME_WEATHER = { + snow: ['clear', 'snow', 'blizzard'], + tundra: ['clear', 'snow', 'blizzard'], + forest: ['clear', 'rain', 'storm'], + plains: ['clear', 'rain'], + desert: ['clear', 'clear', 'rain'], + swamp: ['clear', 'rain', 'storm'], + mountains: ['clear', 'rain', 'snow'] +}; + +// Помощник интерполяции цветов +function lerpColor(a, b, t) { + const ar = parseInt(a.slice(1,3),16), ag = parseInt(a.slice(3,5),16), ab = parseInt(a.slice(5,7),16); + const br = parseInt(b.slice(1,3),16), bg = parseInt(b.slice(3,5),16), bb = parseInt(b.slice(5,7),16); + const r = Math.round(ar + (br - ar) * t); + const g = Math.round(ag + (bg - ag) * t); + const bl = Math.round(ab + (bb - ab) * t); + return '#' + ((1<<24)|(r<<16)|(g<<8)|bl).toString(16).slice(1); +} + +// Цвета неба по биому, погоде и времени суток +function skyColor(night, biome, weatherType, worldTime) { + function weatherSky() { + switch (weatherType) { + case 'blizzard': return '#8a9bae'; + case 'storm': return '#3d4f5f'; + case 'rain': return '#6B7B8D'; + case 'snow': return '#b8c6d4'; + case 'clear': return null; + default: return null; } - 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 dayColor = (biome === 'desert') ? '#f4d9a0' : (biome === 'tundra' || biome === 'snow') ? '#c4d4e0' : (biome === 'swamp') ? '#4a6741' : '#87CEEB'; + const dawnColor = '#FF9940'; + const duskMidColor = '#FF6B35'; + const duskEndColor = '#1a0533'; + const nightColor = '#070816'; + + let skyColorResult; + + if (worldTime < 0.05) { + const t = worldTime / 0.05; + skyColorResult = lerpColor(dawnColor, dayColor, t); + } else if (worldTime < 0.45) { + skyColorResult = dayColor; + } else if (worldTime < 0.55) { + const t = (worldTime - 0.45) / 0.1; + if (t < 0.5) { + skyColorResult = lerpColor(dayColor, duskMidColor, t * 2); + } else { + skyColorResult = lerpColor(duskMidColor, duskEndColor, (t - 0.5) * 2); } - const spawnRate = Math.floor(state.rainIntensity * 60 * dt); - for (let i = 0; i < spawnRate && state.raindrops.length < state.MAX_RAINDROPS; i++) { - state.raindrops.push({ + } else { + skyColorResult = nightColor; + if (weatherType === 'blizzard') return '#1a1a2e'; + if (weatherType === 'storm') return '#0a0a15'; + return nightColor; + } + + const wc = weatherSky(); + if (wc) { + const blendT = 0.6; + skyColorResult = lerpColor(skyColorResult, wc, blendT); + } + + return skyColorResult; +} + +function updateWeather(dt) { + state.weatherTimer += dt; + if (state.weatherTimer >= state.weatherChangeInterval) { + state.weatherTimer = 0; + state.weatherChangeInterval = 60 + Math.random() * 120; + + // Определяем биом по позиции игрока + const playerGx = Math.floor(state.player.x / state.TILE); + const biome = getCachedBiome(playerGx); + const weathers = BIOME_WEATHER[biome] || ['clear']; + + const night = isNight(); + const weights = weathers.map(w => { + if (w === 'clear') return night ? 2 : 4; + if (w === 'rain') return night ? 3 : 2; + if (w === 'snow') return night ? 2 : 3; + if (w === 'storm') return night ? 3 : 1; + if (w === 'blizzard') return night ? 3 : 1; + return 1; + }); + const total = weights.reduce((a, b) => a + b, 0); + let r = Math.random() * total; + state.weatherType = weathers[0]; + for (let i = 0; i < weathers.length; i++) { + r -= weights[i]; + if (r <= 0) { state.weatherType = weathers[i]; break; } + } + + state.isRaining = (state.weatherType === 'rain' || state.weatherType === 'storm'); + } + + // Плавная интерполяция интенсивности дождя + const rainTarget = (state.weatherType === 'rain' || state.weatherType === 'storm') ? 0.6 : 0; + state.rainIntensity += (rainTarget - state.rainIntensity) * dt * 0.5; + if (state.rainIntensity < 0.01) state.rainIntensity = 0; + + // Молния (только при storm) + if (state.weatherType === 'storm') { + state.lightningTimer -= dt; + if (state.lightningTimer <= 0) { + state.lightningFlash = 0.25; + state.lightningTimer = 4 + Math.random() * 12; + } + } + if (state.lightningFlash > 0) { + state.lightningFlash -= dt; + if (state.lightningFlash < 0) state.lightningFlash = 0; + } + + // Снежинки (при snow / blizzard) + if (state.weatherType === 'snow' || state.weatherType === 'blizzard') { + const spawnCount = state.weatherType === 'blizzard' ? Math.floor(80 * dt) : Math.floor(30 * dt); + const maxFlakes = state.weatherType === 'blizzard' ? 600 : 250; + for (let i = 0; i < spawnCount && state.snowflakes.length < maxFlakes; i++) { + state.snowflakes.push({ x: state.camX + Math.random() * state.W, y: state.camY - 20, - vy: 400 + Math.random() * 200, - len: 8 + Math.random() * 12 + vy: 40 + Math.random() * 60, + vx: (state.weatherType === 'blizzard' ? -60 - Math.random() * 80 : -10 - Math.random() * 20), + size: 2 + Math.random() * 3, + wobble: Math.random() * Math.PI * 2 }); } - 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); - } + } + // Обновление снежинок + for (let i = state.snowflakes.length - 1; i >= 0; i--) { + const s = state.snowflakes[i]; + s.y += s.vy * dt; + s.x += s.vx * dt; + s.wobble += dt * 3; + s.x += Math.sin(s.wobble) * 15 * dt; + if (s.y > state.camY + state.H + 20 || s.x < state.camX - 50 || s.x > state.camX + state.W + 50) { + state.snowflakes.splice(i, 1); } } - function drawRain() { - if (state.raindrops.length === 0) return; - const ctx = state.ctx; + // Убираем снег при смене погоды + if (state.weatherType !== 'snow' && state.weatherType !== 'blizzard' && !state.isRaining) { + if (state.snowflakes.length > 0) { + state.snowflakes.splice(0, Math.ceil(state.snowflakes.length * dt * 2)); + } + } +} + +function updateRain(dt) { + if (!state.isRaining || state.rainIntensity < 0.01) { + if (state.raindrops.length > 0) { + state.raindrops.splice(0, Math.ceil(state.raindrops.length * dt * 3)); + } + 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 drawWeather(ctx) { + if (state.raindrops.length > 0) { ctx.save(); - ctx.strokeStyle = "rgba(174,194,224,0.5)"; + ctx.strokeStyle = 'rgba(174,194,224,0.5)'; ctx.lineWidth = 1.5; ctx.beginPath(); for (const d of state.raindrops) { @@ -2243,513 +4462,741 @@ ctx.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 (!state.minimapOpen) return; - const mW = minimapCanvas.width; - const mH = minimapCanvas.height; - 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.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; - 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; - 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 / TILE2) - startGX; - const dy = Math.floor(p.y / TILE2) - 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 / 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"; - minimapCtx.fillRect(dx * scale, dy * scale, 2, 2); - } + if (state.snowflakes.length > 0) { + ctx.save(); + ctx.fillStyle = 'rgba(255,255,255,0.85)'; + for (const s of state.snowflakes) { + ctx.beginPath(); + ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2); + ctx.fill(); } + ctx.restore(); } - // src/game/modes.js - var MODES = [{ id: "mine", icon: "\u26CF\uFE0F" }, { id: "build", icon: "\u{1F9F1}" }]; - function mode() { - return MODES[state.modeIdx].id; - } - function initModes() { - const modeBtn = document.getElementById("modeBtn"); - modeBtn.onclick = () => { - playSound("click"); - state.modeIdx = (state.modeIdx + 1) % MODES.length; - modeBtn.textContent = MODES[state.modeIdx].icon; - }; + if (state.lightningFlash > 0) { + ctx.save(); + ctx.fillStyle = `rgba(255,255,255,${Math.min(1, state.lightningFlash * 4)})`; + ctx.fillRect(0, 0, state.W, state.H); + ctx.restore(); } +} - // src/input/mouse-handler.js - var mouse = { x: null, y: null }; - var miningHeld = false; - var miningInterval = null; - 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; +// === entities/mobs.js === +// Сущности: животные + зомби + новые мобы + +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; + } +} + +class Pig extends Entity { + constructor(x, y) { super(x, y, 34, 24); this.kind = 'pig'; this.hp = 2; } +} + +class Chicken extends Entity { + constructor(x, y) { super(x, y, 26, 22); this.kind = 'chicken'; this.hp = 1; } +} + +class Zombie extends Entity { + constructor(x, y) { super(x, y, 34, 50); this.kind = 'zombie'; this.hp = 4; this.speed = 80 + Math.random() * 40; } +} + +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; } +} + +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; } +} + +class Scorpion extends Entity { + constructor(x, y) { super(x, y, 26, 26); this.kind = 'scorpion'; this.hp = 3; this.speed = 90; this.fuse = 0; this.shootCooldown = 0; } +} + +class PolarBear extends Entity { + constructor(x, y) { super(x, y, 40, 34); this.kind = 'polar_bear'; this.hp = 8; this.speed = 50; this.fuse = 0; this.shootCooldown = 2; } +} + +class Slime extends Entity { + constructor(x, y) { super(x, y, 24, 24); this.kind = 'slime'; this.hp = 2; this.speed = 30; this.fuse = 0; this.shootCooldown = 2; } +} + +class Eagle extends Entity { + constructor(x, y) { super(x, y, 30, 22); this.kind = 'eagle'; this.hp = 3; this.speed = 120; this.fuse = 0; this.shootCooldown = 0; } +} + +// === ui/minimap.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' +}; + +function initMinimap() { + document.getElementById('mapToggle').onclick = () => { + playSound('click'); + state.minimapOpen = !state.minimapOpen; + minimapWrap.style.display = state.minimapOpen ? 'block' : 'none'; + }; + + const minimapClose = document.getElementById('minimapClose'); + if (minimapClose) { + minimapClose.addEventListener('click', (e) => { + e.stopPropagation(); + state.minimapOpen = false; + minimapWrap.style.display = 'none'; }); - 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); + } +} + +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 (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; - } - 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 (!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; } - 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) { + } + } + } + + 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); + } + } +} + +// === game/modes.js === +// ==================== РЕЖИМЫ ==================== + + + +const MODES = [{ id: 'mine', icon: '⛏️' }, { id: 'build', icon: '🧱' }]; + + + +function mode() { + return MODES[state.modeIdx].id; +} + +function initModes() { + const modeBtn = document.getElementById('modeBtn'); + modeBtn.onclick = () => { + playSound('click'); // Звук клика по кнопке режима + state.modeIdx = (state.modeIdx + 1) % MODES.length; + modeBtn.textContent = MODES[state.modeIdx].icon; + }; +} + +// === input/mouse-handler.js === +// ==================== ВЗАИМОДЕЙСТВИЕ МЫШЬ/ТАП ==================== + + + + + + + + + + + + + + + +// Crop stage to seed/harvest mapping +const CROP_SEED_MAP = { + wheat_stage0: 'wheat_seeds', wheat_stage1: 'wheat_seeds', wheat_stage2: 'wheat_seeds', wheat_stage3: 'wheat_seeds', + carrot_stage0: 'carrot', carrot_stage1: 'carrot', carrot_stage2: 'carrot', carrot_stage3: 'carrot', + potato_stage0: 'potato', potato_stage1: 'potato', potato_stage2: 'potato', potato_stage3: 'potato' +}; +const CROP_HARVEST_MAP = { wheat_stage3: 'wheat', carrot_stage3: 'carrot', potato_stage3: 'potato' }; + +// Helper: handle block drops including farming items +function handleBlockDrop(removed, inv) { + // Grass drops wheat_seeds (50%) + if (removed.t === 'grass') { + if (Math.random() < 0.5) inv.wheat_seeds = (inv.wheat_seeds || 0) + 1; + return; + } + // Crop drops: seeds for stages 0-2, harvest+seed for stage 3 + const harvestItem = CROP_HARVEST_MAP[removed.t]; + const seedItem = CROP_SEED_MAP[removed.t]; + if (harvestItem) { + inv[harvestItem] = (inv[harvestItem] || 0) + (BLOCKS[removed.t].harvestQty || 1); addToRecentItems(harvestItem); + if (seedItem) { inv[seedItem] = (inv[seedItem] || 0) + 1; addToRecentItems(seedItem); } + } else if (seedItem) { + // Early crop stage — return seed + inv[seedItem] = (inv[seedItem] || 0) + 1; addToRecentItems(seedItem); + } else { + inv[removed.t] = (inv[removed.t] || 0) + 1; addToRecentItems(removed.t); + } +} + +const mouse = { x: null, y: null }; + +// Hold-to-mine tracking +let miningHeld = false; +let miningInterval = null; + +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"]; + 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(); + 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 (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; + // 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 (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]--; + } + + // Лук — стреляем стрелой + 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; + } + + // ====== MINING PROGRESSION SYSTEM ====== + if (mode() === 'mine') { + if (!b) return; + // Allow mining crops (growthStage) and harvestable blocks, block other decor and fluids + if (BLOCKS[b.t].fluid || (BLOCKS[b.t].decor && BLOCKS[b.t].growthStage === undefined && !BLOCKS[b.t].harvestable)) return; + + if (b.t === 'tnt') { activateTNT(b, 3.2); return; } + + // Calculate mining progress + const hardness = BLOCKS[b.t].hardness || 1; + if (hardness === Infinity) return; // unbreakable (bedrock) + if (hardness === 0) { + // Instant-mine blocks (flower, boat, crops, etc.) + const removed = removeBlock(gx, gy); + if (removed) { + handleBlockDrop(removed, state.inv); + sendBlockChange(gx, gy, removed.t, 'remove'); rebuildHotbar(); } return; } - if (b && b.t === "campfire" && state.selected === "meat" && state.inv.meat > 0) { - playSound("fire"); - state.inv.meat--; - state.inv.cooked++; - rebuildHotbar(); - return; + + // Same block being mined? + if (state.mining.gx === gx && state.mining.gy === gy) { + state.mining.progress += getMiningSpeed(b.t); + } else { + // New block, start mining + state.mining = { gx, gy, progress: getMiningSpeed(b.t), hardness }; } - 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 hardness = BLOCKS[b.t].hardness || 1; - if (hardness === Infinity) return; - if (hardness === 0) { - const removed = removeBlock(gx, gy); - if (removed) { - state.inv[removed.t] = (state.inv[removed.t] || 0) + 1; - sendBlockChange(gx, gy, removed.t, "remove"); - rebuildHotbar(); - } - return; - } - if (state.mining.gx === gx && state.mining.gy === gy) { - state.mining.progress += getMiningSpeed(b.t); - } else { - state.mining = { gx, gy, progress: getMiningSpeed(b.t), hardness }; - } - if (b.t === "glass") playSound("glass1"); - else if (b.t === "sand") playSound("sand1"); - else if (b.t === "stone" || b.t.endsWith("_ore")) playSound("stone1"); - else if (b.t === "wood" || b.t === "planks") playSound("wood1"); - else playSound("cloth1"); - if (state.mining.progress >= hardness) { - const minTool = BLOCKS[b.t].minTool; - let drops = true; - if (minTool) { - const toolId = getBestTool(b.t); - if (toolId) { - const tool = TOOLS[toolId]; - if (toolTierLessThan(tool.toolTier, minTool)) drops = false; - else useTool(toolId); - } else { - drops = false; - } + + // Play mining hit sound + if (b.t === 'glass') playSound('glass1'); + else if (b.t === 'sand') playSound('sand1'); + else if (b.t === 'stone' || b.t.endsWith('_ore')) playSound('stone1'); + else if (b.t === 'wood' || b.t === 'planks') playSound('wood1'); + else playSound('cloth1'); + + // Check if block is fully mined + if (state.mining.progress >= hardness) { + // Check drop requirements + const minTool = BLOCKS[b.t].minTool; + let drops = true; + if (minTool) { + const toolId = getBestTool(b.t); + if (toolId) { + const tool = TOOLS[toolId]; + if (toolTierLessThan(tool.toolTier, minTool)) drops = false; + else useTool(toolId); } else { - const toolId = getBestTool(b.t); - if (toolId) useTool(toolId); + drops = false; } - const removed = removeBlock(gx, gy); - if (removed) { - if (drops) { - state.inv[removed.t] = (state.inv[removed.t] || 0) + 1; - } else { - if (BLOCKS[removed.t]?.toolType === "pickaxe") { - state.inv.cobblestone = (state.inv.cobblestone || 0) + 1; - } - } - sendBlockChange(gx, gy, removed.t, "remove"); - if (removed.t === "glass") playSound("glass1"); - else if (removed.t === "sand") playSound("sand1"); - else if (removed.t === "stone" || removed.t.endsWith("_ore")) playSound("stone1"); - else if (removed.t === "wood") playSound("wood1"); - else playSound("cloth1"); - rebuildHotbar(); - } - state.mining = { gx: null, gy: null, progress: 0, hardness: 0 }; + } else { + // Use tool durability if applicable + const toolId = getBestTool(b.t); + if (toolId) useTool(toolId); } - miningHeld = true; - if (miningInterval) clearInterval(miningInterval); - miningInterval = setInterval(() => { - if (!miningHeld) { - clearInterval(miningInterval); - miningInterval = null; - return; + + const removed = removeBlock(gx, gy); + if (removed) { + if (drops) { + handleBlockDrop(removed, state.inv); + } else { + // Drop cobblestone instead for stone-type blocks + if (BLOCKS[removed.t]?.toolType === 'pickaxe') { + state.inv.cobblestone = (state.inv.cobblestone || 0) + 1; + } + // Else nothing drops } - const mb = getBlock(state.mining.gx, state.mining.gy); - if (!mb || mb.dead || mb.t !== b.t) { + sendBlockChange(gx, gy, removed.t, 'remove'); + + // Sound for block break + if (removed.t === 'glass') playSound('glass1'); + else if (removed.t === 'sand') playSound('sand1'); + else if (removed.t === 'stone' || removed.t.endsWith('_ore')) playSound('stone1'); + else if (removed.t === 'wood') playSound('wood1'); + else playSound('cloth1'); + + rebuildHotbar(); + } + state.mining = { gx: null, gy: null, progress: 0, hardness: 0 }; + } + + // Start hold-to-mine for desktop + miningHeld = true; + if (miningInterval) clearInterval(miningInterval); + miningInterval = setInterval(() => { + if (!miningHeld) { clearInterval(miningInterval); miningInterval = null; return; } + // Re-check the block still exists at mining position + const mb = getBlock(state.mining.gx, state.mining.gy); + if (!mb || mb.dead || mb.t !== b.t) { + miningHeld = false; + clearInterval(miningInterval); + miningInterval = null; + return; + } + // Accumulate progress + if (state.mining.gx !== null && state.mining.hardness > 0) { + state.mining.progress += getMiningSpeed(mb.t) * 0.3; // 0.3x per tick for held mining + + if (state.mining.progress >= state.mining.hardness) { + const mgx = state.mining.gx; + const mgy = state.mining.gy; + const mMinTool = BLOCKS[mb.t].minTool; + let mDrops = true; + if (mMinTool) { + const mToolId = getBestTool(mb.t); + if (mToolId) { + const mTool = TOOLS[mToolId]; + if (toolTierLessThan(mTool.toolTier, mMinTool)) mDrops = false; + else useTool(mToolId); + } else { + mDrops = false; + } + } else { + const mToolId = getBestTool(mb.t); + if (mToolId) useTool(mToolId); + } + + const removed = removeBlock(mgx, mgy); + if (removed) { + if (mDrops) { + handleBlockDrop(removed, state.inv); + } else { + if (BLOCKS[removed.t]?.toolType === 'pickaxe') { + state.inv.cobblestone = (state.inv.cobblestone || 0) + 1; + } + } + sendBlockChange(mgx, mgy, removed.t, 'remove'); + rebuildHotbar(); + } + state.mining = { gx: null, gy: null, progress: 0, hardness: 0 }; miningHeld = false; clearInterval(miningInterval); miningInterval = null; + } + } + }, 100); + + return; + } + + // ====== BUILD MODE ====== + if (mode() === 'build') { + // Planting seeds on farmland (works even without farmland in inventory) + const seedToCropBlock = { + wheat_seeds: 'wheat_stage0', + carrot: 'carrot_stage0', + potato: 'potato_stage0' + }; + if (seedToCropBlock[state.selected] && state.inv[state.selected] > 0) { + // Clicking on farmland — plant above it + if (b && b.t === 'farmland') { + const aboveKey = gx + ',' + (gy - 1); + const above = state.grid.get(aboveKey); + if (!above || above.dead) { + setBlock(gx, gy - 1, seedToCropBlock[state.selected], true); + state.inv[state.selected]--; + const cropBlock = state.grid.get(gx + ',' + (gy - 1)); + if (cropBlock) cropBlock.growthTimer = 0; + rebuildHotbar(); return; } - if (state.mining.gx !== null && state.mining.hardness > 0) { - state.mining.progress += getMiningSpeed(mb.t) * 0.3; - if (state.mining.progress >= state.mining.hardness) { - const mgx = state.mining.gx; - const mgy = state.mining.gy; - const mMinTool = BLOCKS[mb.t].minTool; - let mDrops = true; - if (mMinTool) { - const mToolId = getBestTool(mb.t); - if (mToolId) { - const mTool = TOOLS[mToolId]; - if (toolTierLessThan(mTool.toolTier, mMinTool)) mDrops = false; - else useTool(mToolId); - } else { - mDrops = false; - } - } else { - const mToolId = getBestTool(mb.t); - if (mToolId) useTool(mToolId); - } - const removed = removeBlock(mgx, mgy); - if (removed) { - if (mDrops) { - state.inv[removed.t] = (state.inv[removed.t] || 0) + 1; - } else { - if (BLOCKS[removed.t]?.toolType === "pickaxe") { - state.inv.cobblestone = (state.inv.cobblestone || 0) + 1; - } - } - sendBlockChange(mgx, mgy, removed.t, "remove"); - rebuildHotbar(); - } - state.mining = { gx: null, gy: null, progress: 0, hardness: 0 }; - miningHeld = false; - clearInterval(miningInterval); - miningInterval = null; - } + } + // Clicking on empty space above farmland — plant there + if (!b) { + const belowKey = gx + ',' + (gy + 1); + const below = state.grid.get(belowKey); + if (below && below.t === 'farmland') { + setBlock(gx, gy, seedToCropBlock[state.selected], true); + state.inv[state.selected]--; + const cropBlock = state.grid.get(gx + ',' + gy); + if (cropBlock) cropBlock.growthTimer = 0; + rebuildHotbar(); + return; } - }, 100); - 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(); + + 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; } - 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.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]--; - 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 === "dirt" || state.selected === "grass") playSound("cloth1"); rebuildHotbar(); return; } - }); - canvas.addEventListener("pointerup", () => { - miningHeld = false; - }); - canvas.addEventListener("pointerleave", () => { - miningHeld = false; - }); - } - // src/render/draw-fire.js - 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(); - } + // запрет ставить в игрока + 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; - // src/game/loop.js - var last = performance.now(); - var prevJump = false; - document.addEventListener("visibilitychange", () => { - if (!document.hidden) last = performance.now(); + 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 === 'dirt' || state.selected === 'grass') playSound('cloth1'); + + rebuildHotbar(); + return; + } }); - function loop(now) { - const rawDt = Math.min(0.05, (now - last) / 1e3); - last = now; - 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 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; + + // Release mouse — stop hold-to-mine + canvas.addEventListener('pointerup', () => { + miningHeld = false; + }); + + canvas.addEventListener('pointerleave', () => { + miningHeld = false; + // Reset mining when mouse leaves canvas + }); +} + +// === render/draw-fire.js === +// Рисование костра: огонь поверх текстуры +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(); +} + +// === game/loop.js === +// ==================== ГЛАВНЫЙ ИГРОВОЙ ЦИКЛ ==================== + + + + + + + + + + + + + + + + + + + + + + + + +// ==================== LOOP ==================== +let last = performance.now(); +let prevJump = false; +// При возврате на вкладку — сбрасываем last чтобы не было скачка dt +document.addEventListener('visibilitychange', () => { + if (!document.hidden) last = performance.now(); +}); + + + +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; + + // В MP время синхронизируется с сервера + if (state.isMultiplayer) { + // Сон всё ещё работает локально (только лечение, без ускорения времени) if (player.sleeping && isNight()) { + player.hp = Math.min(100, player.hp + dt * 20); + if (!isNight()) { + player.sleeping = false; + } + } + } else { + // SP: ускорение времени во время сна или замороженный цикл + if (!state.dayNightCycle) { + // Цикл день/ночь выключен — время стоит + } else if (player.sleeping && isNight()) { state.worldTime += dt * 8 / state.DAY_LEN; player.hp = Math.min(100, player.hp + dt * 20); if (!isNight()) { @@ -2759,686 +5206,1142 @@ state.worldTime += dt / state.DAY_LEN; } 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(); - 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; + } + + // камера следует за игроком по 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); + + if (!Number.isFinite(player.x) || !Number.isFinite(player.y)) { + var startGX = 6; + for (var sdx = -3; sdx <= 3; sdx++) genColumn(startGX + sdx); + player.x = startGX * state.TILE; + player.y = (surfaceGyAt(startGX) - 1) * state.TILE; + player.vx = player.vy = 0; + state.camX = Math.floor((player.x + player.w / 2) - state.W / 2); + state.camY = Math.floor((player.y + player.h / 2) - state.H / 2); + console.warn('[Loop] player pos was broken, respawned at start'); + } + + 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); + + // кислород/утопление + if (player.headInWater) { + player.o2 = Math.max(0, player.o2 - 6 * dt); + if (player.o2 === 0) { + const damage = calculateDamage(4 * dt); + player.hp -= damage; } - updateWaterFlag(player); - 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); - } - player.hunger = Math.max(0, player.hunger - dt * 0.2); - if (player.sleeping) { - player.vx = 0; - player.vy = 0; - } else { - 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 / 1e3 - player.lastStepTime > stepInterval) { - playSound("step"); - player.lastStepTime = now / 1e3; + } else { + player.o2 = Math.min(100, player.o2 + 10 * dt); + } + + // голод убывает + player.hunger = Math.max(0, player.hunger - dt * 0.2); + + // ========== TEMPERATURE ========== + if (player.temperature === undefined) player.temperature = 15; + const BIOME_TEMP = { snow: -20, plains: 15, swamp: 20, desert: 45, mountains: 5 }; + const COLD_THRESHOLD = -5; + const HEAT_THRESHOLD = 35; + const HEAT_RADIUS = 5; + const pGX = Math.floor(player.x / state.TILE); + const biome = getCachedBiome(pGX); + const pGY = Math.floor(player.y / state.TILE); + const sGY = surfaceGyAt(pGX); + const isUndergroundTemp = (pGY - sGY) > 2; + let targetTemp = BIOME_TEMP[biome] || 15; + if (isUndergroundTemp) targetTemp = 15; + if (isNight() && !isUndergroundTemp) targetTemp -= 10; + if ((state.weatherType === 'rain' || state.weatherType === 'storm') && !isUndergroundTemp) targetTemp -= 5; + player.temperature += (targetTemp - player.temperature) * dt * 0.3; + let nearHeat = false, nearCool = false; + for (let dx = -HEAT_RADIUS; dx <= HEAT_RADIUS; dx++) { + for (let dy = -HEAT_RADIUS; dy <= HEAT_RADIUS; dy++) { + const b = getBlock(pGX + dx, pGY + dy); + if (!b || b.dead) continue; + if (b.t === 'campfire' || b.t === 'torch') nearHeat = true; + if (b.t === 'water' && biome === 'desert') nearCool = true; } } - 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.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"); + if (nearHeat) { + player.temperature += (30 - player.temperature) * dt * 2; + if (player.hp < 100) player.hp = Math.min(100, player.hp + 8 * dt); + } + if (nearCool) { + player.temperature += (15 - player.temperature) * dt * 1.5; + if (player.hp < 100 && player.temperature > HEAT_THRESHOLD) player.hp = Math.min(100, player.hp + 8 * dt); + } + if (isUndergroundTemp && !nearHeat) { + if (player.hp < 100 && player.temperature >= COLD_THRESHOLD && player.temperature <= HEAT_THRESHOLD) { + player.hp = Math.min(100, player.hp + 2 * dt); } - } else if (player.inWater) { - player.vx *= 0.9; - player.vy *= 0.92; - if (!jumpPressed && !inp2.j) { - player.vy += state.GRAV_WATER * dt; - } else { - if (jumpPressed) { - player.vy = Math.min(player.vy, -520); - } else if (inp2.j) { - player.vy = Math.min(player.vy, -260); + } + const aboveBlk = getBlock(pGX, pGY - 1); + const inShade = aboveBlk && BLOCKS[aboveBlk.t] && BLOCKS[aboveBlk.t].solid && aboveBlk.t !== 'glass'; + if (inShade && !isUndergroundTemp && !nearCool) { + player.temperature -= 5 * dt; + if (player.temperature > HEAT_THRESHOLD - 2 && player.hp < 100) player.hp = Math.min(100, player.hp + 1 * dt); + } + if (player.temperature < COLD_THRESHOLD && !isUndergroundTemp) { + const severity = Math.abs(player.temperature - COLD_THRESHOLD) / 15; + player.hp -= 1 * severity * dt; + if (severity > 0.5) player.vx *= (1 - 0.3 * Math.min(1, severity) * dt); + } + if (player.temperature > HEAT_THRESHOLD && !isUndergroundTemp) { + const severity = (player.temperature - HEAT_THRESHOLD) / 15; + player.hp -= 1 * severity * dt; + if (severity > 0.5) player.hunger -= 0.5 * severity * dt; + } + if (player.slowTimer > 0) player.slowTimer -= dt; + + // Кактус — урон при касании + const pCGX = Math.floor((player.x + player.w / 2) / state.TILE); + const pCGY = Math.floor((player.y + player.h / 2) / state.TILE); + for (let cdx = -1; cdx <= 1; cdx++) { + for (let cdy = -1; cdy <= 1; cdy++) { + const cb = getBlock(pCGX + cdx, pCGY + cdy); + if (cb && !cb.dead && BLOCKS[cb.t] && BLOCKS[cb.t].hurt) { + if (player.invuln <= 0) { + player.hp -= 2; + player.invuln = 0.5; } } - } 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; + } + + // Лёд — скольжение + const iceBlock = getBlock(pCGX, pCGY + 1); + if (iceBlock && BLOCKS[iceBlock.t] && BLOCKS[iceBlock.t].slip && player.grounded) { + player.vx *= 0.98; + } + + // Игрок не может двигаться во время сна + 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 (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) { + } + + // прыжок/плавание + 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"); + playSound('splash'); } - for (let step = 0; step < steps; step++) { - player.y += player.vy * dt; - resolveY(player); - player.x += player.vx * dt; - resolveX(player); + } + + 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); + + if (!state.isMultiplayer) { 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); + + 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++; + projectiles.splice(i, 1); + continue; } - sendPlayerPosition(); - updateWaterPhysics(dt); - updateWeather(dt); - updateRain(dt); - player.invuln = Math.max(0, player.invuln - dt); - 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 }); - } - tickFurnaces(dt); - if (state.currentFurnaceKey && Math.random() < 0.1) { - renderFurnaceUI(); - } - 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++; + + 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; } - 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 { - 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; - } + } else { + 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) { - 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(); + } + 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) { + 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; } - projectiles.splice(i, 1); - break; + mobs.splice(j, 1); + rebuildHotbar(); } + projectiles.splice(i, 1); + break; } } } - if (p.life <= 0) projectiles.splice(i, 1); } - 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 (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); + } + } + + + // ====== CROP GROWTH ====== + for (const [key, blk] of state.grid) { + if (blk.dead) continue; + const bDef = BLOCKS[blk.t]; + if (bDef.growthStage === undefined) continue; + if (bDef.harvestable) continue; // stage 3 = fully grown + if (!blk.growthTimer && blk.growthTimer !== 0) blk.growthTimer = 0; + blk.growthTimer += dt; + // Growth time from block def (default 30s per stage) + const growTime = bDef.growthTime || 30; + if (blk.growthTimer >= growTime) { + blk.growthTimer = 0; + const nextStage = bDef.growsTo; + if (nextStage) { + blk.t = nextStage; + // Update texture reference + if (tex[nextStage]) { + // Block type changed — will render new texture next frame + } } } - 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") { - } else { - 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) { - mobs.push(new Zombie(wx, wy)); - } else if (rand < 0.55) { - mobs.push(new Creeper(wx, wy)); - } else { - mobs.push(new Skeleton(wx, wy)); - } + } + + // mobs spawn — only in singleplayer + 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(); + const spawnBiome = getCachedBiome(gx); + if (night) { + const hostileCount = mobs.filter(m => m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton' || m.kind === 'scorpion' || m.kind === 'slime').length; + if (hostileCount < 12) { + const rand = Math.random(); + if (spawnBiome === 'desert' && rand < 0.6) { + mobs.push(new Scorpion(wx, wy)); + } else if (spawnBiome === 'swamp' && rand < 0.5) { + mobs.push(new Slime(wx, wy)); + } else 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) { + } + // Daytime biome mobs + const animalCount = mobs.filter(m => m.kind === 'pig' || m.kind === 'chicken' || m.kind === 'polar_bear' || m.kind === 'eagle').length; + if (animalCount < 8) { + if (spawnBiome === 'tundra' && Math.random() < 0.6) { + mobs.push(new PolarBear(wx, wy)); + } else if (spawnBiome === 'mountains' && Math.random() < 0.4) { + mobs.push(new Eagle(wx, wy)); + } else { mobs.push(Math.random() < 0.5 ? new Pig(wx, wy) : new Chicken(wx, wy)); } } } - 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); - } + } + + // mobs update — только локальные + 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); } - 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 += state.GRAV * dt; - if (p.t <= 0) parts2.splice(i, 1); - } - if (player.hp <= 0) { - state.deathEl.style.display = "flex"; - } else if (state.deathEl.style.display === "flex") { - state.deathEl.style.display = "none"; - } - const ctx = state.ctx; - const W = state.W; - const H = state.H; - const camX = state.camX; - const camY = state.camY; - const TILE2 = state.TILE; - const tex2 = state.tex; - const night = isNight(); - ctx.fillStyle = night ? "#070816" : state.isRaining ? "#6B7B8D" : "#87CEEB"; - ctx.fillRect(0, 0, W, H); - 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(); - ctx.save(); - ctx.translate(-camX, -camY); - const minGX = Math.floor(camX / TILE2) - 2; - const maxGX = Math.floor((camX + W) / TILE2) + 2; - const minGY = Math.floor(camY / TILE2) - 6; - const maxGY = Math.floor((camY + H) / 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(tex2[b.t], b.gx * TILE2, b.gy * TILE2, TILE2, TILE2); - ctx.restore(); - } else { - ctx.drawImage(tex2[b.t], b.gx * TILE2, b.gy * TILE2, TILE2, TILE2); - } - if (b.t === "tnt" && b.active && Math.sin(now / 60) > 0) { - ctx.fillStyle = "rgba(255,255,255,0.45)"; - ctx.fillRect(b.gx * TILE2, b.gy * TILE2, TILE2, TILE2); - } - if (b.t === "campfire") { - drawFire(ctx, b.gx * TILE2, b.gy * TILE2, now); - } - if (b.t === "furnace" && state.activeFurnaces.has(`${b.gx},${b.gy}`)) { - drawFire(ctx, b.gx * TILE2 + 8, b.gy * TILE2 + 5, now); - } - } - if (state.mining.gx !== null && state.mining.hardness > 0) { - const mg = state.mining; - const progress = Math.min(1, mg.progress / mg.hardness); - const cx = mg.gx * TILE2 - camX + camX; - const cy = mg.gy * TILE2 - camY + camY; - const wx = mg.gx * TILE2; - const wy = mg.gy * TILE2; + } + + // 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') { + 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 + const playerGx = Math.floor(player.x / state.TILE); const skyBiome = getBiome(playerGx); ctx.fillStyle = skyColor(night, skyBiome, state.weatherType, state.worldTime); + ctx.fillRect(0, 0, W, H); + + // clouds + 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 = Math.min(0.8, progress); - const numCracks = Math.floor(progress * 5); - ctx.strokeStyle = "#000"; - ctx.lineWidth = 2; - for (let i = 0; i < numCracks; i++) { - ctx.beginPath(); - ctx.moveTo(wx + TILE2 / 2, wy + TILE2 / 2); - const angle = (i * 72 + 36) * Math.PI / 180; - ctx.lineTo(wx + TILE2 / 2 + Math.cos(angle) * TILE2 * 0.4, wy + TILE2 / 2 + Math.sin(angle) * TILE2 * 0.4); - ctx.stroke(); - } - ctx.fillStyle = `rgba(0,0,0,${progress * 0.4})`; - ctx.fillRect(wx, wy, TILE2, TILE2); - ctx.globalAlpha = 1; - ctx.fillStyle = "#333"; - ctx.fillRect(wx, wy + TILE2 - 4, TILE2, 4); - ctx.fillStyle = "#2ecc71"; - ctx.fillRect(wx, wy + TILE2 - 4, TILE2 * progress, 4); + ctx.globalAlpha = def.alpha; + ctx.drawImage(tex[b.t], b.gx * TILE, b.gy * TILE, TILE, TILE); ctx.restore(); - } - 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") { - 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") { - 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.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") { - 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(); - } - } - if (boat.active) { - ctx.drawImage(tex2["boat"], boat.x - (TILE2 - boat.w) / 2, boat.y - (TILE2 - boat.h) / 2, TILE2, TILE2); - } - for (const [socketId, p] of state.otherPlayers) { - if (state.heroImg.complete) { - ctx.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); - } - ctx.fillStyle = "#fff"; - ctx.font = "12px system-ui"; - ctx.textAlign = "center"; - ctx.fillText(p.name, p.x + 17, p.y - 8); - } - if (state.heroImg.complete) { - ctx.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); + ctx.drawImage(tex[b.t], b.gx * TILE, b.gy * TILE, TILE, TILE); } - 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); + + // 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); + } + } + + // ====== MINING CRACK OVERLAY ====== + if (state.mining.gx !== null && state.mining.hardness > 0) { + const mg = state.mining; + const progress = Math.min(1, mg.progress / mg.hardness); + const cx = mg.gx * TILE - camX + camX; // already in world coords since we translated + const cy = mg.gy * TILE - camY + camY; + // Actually, we're in translated context, so just use world coords + const wx = mg.gx * TILE; + const wy = mg.gy * TILE; + ctx.save(); + ctx.globalAlpha = Math.min(0.8, progress); + // Draw crack lines + const numCracks = Math.floor(progress * 5); + ctx.strokeStyle = '#000'; + ctx.lineWidth = 2; + for (let i = 0; i < numCracks; i++) { ctx.beginPath(); - ctx.moveTo(12, -4); - ctx.lineTo(16, 0); - ctx.lineTo(12, 4); + ctx.moveTo(wx + TILE/2, wy + TILE/2); + const angle = (i * 72 + 36) * Math.PI / 180; + ctx.lineTo(wx + TILE/2 + Math.cos(angle) * TILE * 0.4, wy + TILE/2 + Math.sin(angle) * TILE * 0.4); + ctx.stroke(); + } + // Darkening overlay + ctx.fillStyle = `rgba(0,0,0,${progress * 0.4})`; + ctx.fillRect(wx, wy, TILE, TILE); + + // Progress bar under block + ctx.globalAlpha = 1; + ctx.fillStyle = '#333'; + ctx.fillRect(wx, wy + TILE - 4, TILE, 4); + ctx.fillStyle = '#2ecc71'; + ctx.fillRect(wx, wy + TILE - 4, TILE * progress, 4); + + ctx.restore(); + } + + // 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') { + 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') { + 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.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') { + 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(); + } else if (m.kind === 'scorpion') { + // Scorpion: dark brown body, claws, tail + ctx.fillStyle = '#5D4037'; + ctx.fillRect(m.x, m.y, m.w, m.h); + ctx.fillStyle = '#8D6E63'; + ctx.fillRect(m.x + 2, m.y + 10, 6, 8); // left claw + ctx.fillRect(m.x + m.w - 8, m.y + 10, 6, 8); // right claw + // Tail + ctx.fillStyle = '#5D4037'; + ctx.beginPath(); + ctx.moveTo(m.x + m.w / 2, m.y); + ctx.lineTo(m.x + m.w / 2 - 3, m.y - 10); + ctx.lineTo(m.x + m.w / 2 + 3, m.y - 10); ctx.closePath(); ctx.fill(); - ctx.fillStyle = "#888"; - ctx.fillRect(-12, -3, 4, 2); - ctx.fillRect(-12, 1, 4, 2); + ctx.fillStyle = '#FF5722'; + ctx.fillRect(m.x + m.w / 2 - 2, m.y - 12, 4, 4); // stinger + } else if (m.kind === 'polar_bear') { + // Polar bear: white body, black nose + ctx.fillStyle = '#ECEFF1'; + ctx.fillRect(m.x, m.y, m.w, m.h); + ctx.fillStyle = '#263238'; + ctx.fillRect(m.x + m.w / 2 - 2, m.y + 6, 4, 3); // nose + ctx.fillRect(m.x + 8, m.y + 12, 3, 3); // eye + ctx.fillRect(m.x + m.w - 11, m.y + 12, 3, 3); // eye + // legs + ctx.fillStyle = '#CFD8DC'; + ctx.fillRect(m.x + 6, m.y + m.h - 8, 8, 8); + ctx.fillRect(m.x + m.w - 14, m.y + m.h - 8, 8, 8); + } else if (m.kind === 'slime') { + // Slime: green bouncy blob + ctx.fillStyle = '#4CAF50'; + ctx.beginPath(); + ctx.arc(m.x + m.w / 2, m.y + m.h / 2, m.w / 2, 0, Math.PI * 2); + ctx.fill(); + // Eyes + ctx.fillStyle = '#fff'; + ctx.fillRect(m.x + 6, m.y + 7, 5, 5); + ctx.fillRect(m.x + m.w - 11, m.y + 7, 5, 5); + ctx.fillStyle = '#000'; + ctx.fillRect(m.x + 8, m.y + 9, 2, 2); + ctx.fillRect(m.x + m.w - 9, m.y + 9, 2, 2); + } else if (m.kind === 'eagle') { + // Eagle: brown body, wings, yellow beak + ctx.fillStyle = '#5D4037'; + ctx.fillRect(m.x + m.w / 2 - 4, m.y + 6, 8, 14); // body + // Wings + ctx.fillStyle = '#795548'; + ctx.fillRect(m.x, m.y + 8, m.w / 2 - 4, 6); + ctx.fillRect(m.x + m.w / 2 + 4, m.y + 8, m.w / 2 - 4, 6); + // Head + ctx.fillStyle = '#ECEFF1'; + ctx.fillRect(m.x + m.w / 2 - 3, m.y + 2, 6, 6); + // Beak + ctx.fillStyle = '#FFC107'; + ctx.fillRect(m.x + m.w / 2 + 3, m.y + 4, 4, 2); + // Eye + ctx.fillStyle = '#000'; + ctx.fillRect(m.x + m.w / 2 - 1, m.y + 4, 2, 2); + } + } + + // boat + if (boat.active) { + ctx.drawImage(tex['boat'], boat.x - (TILE - boat.w) / 2, boat.y - (TILE - boat.h) / 2, TILE, TILE); + } + + // other players + 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(); - } - for (const p of parts2) { - 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"); - } + + if (dist < 150 && player.invuln <= 0) { + player.hp -= 8; + player.invuln = 0.5; + player.vx += Math.cos(angle) * 300; + player.vy -= 200; + playSound('hit1'); } } - 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 / TILE2); - const ggy = Math.floor(wy / TILE2); - ctx.strokeStyle = "rgba(255,255,255,0.9)"; - ctx.lineWidth = 2; - ctx.strokeRect(ggx * TILE2, ggy * TILE2, TILE2, TILE2); + } + + // 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 + 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.97 + Math.sin(now / 300 + sx * 0.01) * 0.015 + Math.sin(now / 470 + sy * 0.02) * 0.015; + const r = radius * flick; + const steps2 = 24; + 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.3; step < r; step += TILE * 0.35) { + 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.9 + Math.sin(now / 350 + b.gx * 3.7) * 0.05 + Math.sin(now / 530 + b.gy * 2.3) * 0.05; + const wx = b.gx * TILE + TILE / 2 - camX; + const wy = b.gy * TILE + TILE / 2 - camY; + const r = def.lightRadius * 0.85; + const grad = ctx.createRadialGradient(wx, wy, 0, wx, wy, r); + grad.addColorStop(0, `rgba(255,180,80,0.28)`); + grad.addColorStop(0.5, `rgba(255,140,40,0.13)`); + 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(); - 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; - 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 = 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 = getBlock(lgx, lgy); - if (blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid && step > TILE2 * 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(); - }; - 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"; - 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 * TILE2 + TILE2 / 2, b.gy * TILE2 + TILE2 / 2, def.lightRadius); - } - } - lightCtx.globalCompositeOperation = "source-over"; - ctx.drawImage(lightC, 0, 0, W, H); - ctx.save(); - ctx.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 * 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(); - } - } - 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 / 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"; - } - } - 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("\u{1F4A4} \u0421\u043F\u0438\u043C...", W / 2, H / 2); - ctx.font = "18px system-ui"; - ctx.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", W / 2, H / 2 + 40); - } - if (state.minimapOpen && Math.random() < 0.25) { - renderMinimap(); - } - requestAnimationFrame(loop); } - // src/ui/market.js - var marketPanel = document.getElementById("marketPanel"); - var marketClose = document.getElementById("marketClose"); - var marketContent = document.getElementById("marketContent"); - function initMarket() { - marketClose.addEventListener("click", () => closeMarket()); - document.addEventListener("toggleMarket", () => toggleMarket()); - } - function toggleMarket() { - if (marketPanel.style.display === "none" || !marketPanel.style.display) { - openMarket(); + // Дождь + drawWeather(ctx); + 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); + // Temperature display + const tempEl = document.getElementById('temp'); + if (tempEl) { + const t = Math.round(player.temperature); + tempEl.textContent = t + '°C'; + if (player.temperature < COLD_THRESHOLD) tempEl.style.color = '#74b9ff'; + else if (player.temperature > HEAT_THRESHOLD) tempEl.style.color = '#e17055'; + else tempEl.style.color = '#55efc4'; + } + 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 { - closeMarket(); + document.getElementById('multiplayerStatus').style.display = 'none'; } } - function closeMarket() { - marketPanel.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); } - function openMarket() { - marketPanel.style.display = "block"; + + // Миникарта + if (state.minimapOpen && Math.random() < 0.25) { + renderMinimap(); + } + + requestAnimationFrame(loop); +} + +// === ui/market.js === +// ==================== РЫНОК ПРЕДЛОЖЕНИЙ ==================== + + + +let marketPanel, marketClose, marketContent; + +function initMarket() { + marketPanel = document.getElementById('marketPanel'); + marketClose = document.getElementById('marketClose'); + marketContent = document.getElementById('marketContent'); + + if (!marketPanel) return; + + if (marketClose) marketClose.addEventListener('click', () => closeMarket()); + document.addEventListener('toggleMarket', () => toggleMarket()); +} + +function toggleMarket() { + if (!marketPanel) return; + if (marketPanel.style.display === 'none' || !marketPanel.style.display) { + openMarket(); + } else { + closeMarket(); + } +} + +function closeMarket() { + if (marketPanel) marketPanel.style.display = 'none'; +} + +function openMarket() { + if (marketPanel) { + marketPanel.style.display = 'block'; renderMarket(); } - function renderMarket() { - if (!state.socket) { - marketContent.innerHTML = '

\u041F\u043E\u0434\u043A\u043B\u044E\u0447\u0435\u043D\u0438\u0435 \u043A \u0441\u0435\u0440\u0432\u0435\u0440\u0443...

'; - return; - } - state.socket.emit("market_list", {}, (orders) => { - if (!orders || !orders.length) { - marketContent.innerHTML = ` -

\u041F\u0440\u0435\u0434\u043B\u043E\u0436\u0435\u043D\u0438\u0439 \u043F\u043E\u043A\u0430 \u043D\u0435\u0442

-
- -
- `; - const btn2 = document.getElementById("createOrderBtn"); - if (btn2) btn2.addEventListener("click", showCreateOrder); - return; - } - let html = '
'; - html += orders.map((o) => ` -
-
- ${o.offer_item} \xD7${o.offer_qty} - \u21C4 - ${o.want_item} \xD7${o.want_qty} -
-
${o.seller || "\u0410\u043D\u043E\u043D\u0438\u043C"}
-
- `).join(""); - marketContent.innerHTML = html; - const btn = document.getElementById("createOrderBtn"); - if (btn) btn.addEventListener("click", showCreateOrder); - }); - } - function showCreateOrder() { - const inv = state.player.inventory || []; - const items = inv.filter((slot) => slot && slot.id); - marketContent.innerHTML = ` -
-

\u0412\u044B\u0441\u0442\u0430\u0432\u0438\u0442\u044C \u043F\u0440\u0435\u0434\u043C\u0435\u0442

- - - - -
- - -
-
- `; - document.getElementById("cancelOrder").addEventListener("click", () => renderMarket()); - document.getElementById("submitOrder").addEventListener("click", () => { - const offerItem = document.getElementById("offerItem").value; - const wantItem = document.getElementById("wantItem").value.trim(); - if (!offerItem || !wantItem) { - alert("\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u043F\u0440\u0435\u0434\u043C\u0435\u0442 \u0438 \u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u0447\u0442\u043E \u0445\u043E\u0442\u0438\u0442\u0435 \u043F\u043E\u043B\u0443\u0447\u0438\u0442\u044C"); - return; - } - if (state.socket) { - state.socket.emit("market_create", { - offer_item: offerItem, - offer_qty: 1, - want_item: wantItem, - want_qty: 1 - }); - playSound("click"); - setTimeout(renderMarket, 500); - } - }); +} + +function renderMarket() { + if (!marketContent) return; + if (!state.socket) { + marketContent.innerHTML = '

Подключение к серверу...

'; + return; } - // 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"); + state.socket.emit('market_list', {}, (orders) => { + if (!orders || !orders.length) { + marketContent.innerHTML = '

Предложений пока нет

'; + const btn = document.getElementById('createOrderBtn'); + if (btn) btn.addEventListener('click', showCreateOrder); + return; + } + + let html = '
'; + html += orders.map(o => '
' + (o.offer_item||'') + ' x' + (o.offer_qty||1) + ' ⇄ ' + (o.want_item||'') + ' x' + (o.want_qty||1) + '
' + (o.seller || 'Аноним') + '
').join(''); + marketContent.innerHTML = html; + const btn = document.getElementById('createOrderBtn'); + if (btn) btn.addEventListener('click', showCreateOrder); + }); +} + +function showCreateOrder() { + if (!marketContent) return; + const invKeys = Object.keys(state.inv || {}); + const invOpts = invKeys.filter(k => (state.inv[k]||0) > 0).map(k => '').join(''); + + marketContent.innerHTML = '

Выставить предмет

'; + + const cancelBtn = document.getElementById('cancelOrder'); + if (cancelBtn) cancelBtn.addEventListener('click', () => renderMarket()); + const submitBtn = document.getElementById('submitOrder'); + if (submitBtn) submitBtn.addEventListener('click', () => { + const offerItem = document.getElementById('offerItem'); + const wantItem = document.getElementById('wantItem'); + if (!offerItem || !wantItem) return; + const offer = offerItem.value; + const want = wantItem.value.trim(); + if (!offer || !want) { alert('Выберите предмет и укажите что хотите получить'); return; } + if (state.socket) { + state.socket.emit('market_create', { offer_item: offer, offer_qty: 1, want_item: want, want_qty: 1 }); + playSound('click'); + setTimeout(renderMarket, 500); + } + }); +} + +// === ui/settings.js === +// Настройки + + +let settingsPanel = null; +let dayNightToggle = null; + +// Weather button IDs in order +const WEATHER_BTNS = ['weatherClear', 'weatherRain', 'weatherSnow', 'weatherThunder']; +const WEATHER_ACTIVE_BG = '#3498db'; +const WEATHER_INACTIVE_BG = '#555'; + +function setWeatherButtonActive(activeId) { + for (const id of WEATHER_BTNS) { + const btn = document.getElementById(id); + if (btn) { + btn.style.background = (id === activeId) ? WEATHER_ACTIVE_BG : WEATHER_INACTIVE_BG; + } + } +} + +function initSettings() { + settingsPanel = document.getElementById('settingsPanel'); + dayNightToggle = document.getElementById('dayNightToggle'); + + if (!settingsPanel) return; + + const closeBtn = document.getElementById('settingsClose'); + if (closeBtn) { + closeBtn.addEventListener('click', () => { + settingsPanel.style.display = 'none'; + }); + } + + // Day/night cycle toggle + if (dayNightToggle) { + dayNightToggle.checked = state.dayNightCycle !== false; + dayNightToggle.addEventListener('change', () => { + state.dayNightCycle = dayNightToggle.checked; + }); + } + + // Time-of-day slider + const timeSlider = document.getElementById('timeSlider'); + if (timeSlider) { + timeSlider.value = Math.round(state.worldTime * 100); + timeSlider.addEventListener('input', () => { + if (state.isMultiplayer) return; + state.worldTime = parseInt(timeSlider.value) / 100; + if (state.dayNightCycle) { + state.dayNightCycle = false; + if (dayNightToggle) dayNightToggle.checked = false; + } + }); + } + + // ====== Weather controls (Ясно / Дождь / Снег / Гроза) ====== + const weatherIntensitySlider = document.getElementById('weatherIntensity'); + + // Helper: apply a weather type + function applyWeather(type) { + if (state.isMultiplayer) return; // Погода от сервера в MP + state.weatherType = type; + state.weatherChangeInterval = Infinity; // Lock weather manually + + switch (type) { + case 'clear': + state.isRaining = false; + state.rainIntensity = 0; + if (weatherIntensitySlider) weatherIntensitySlider.value = 0; + break; + case 'rain': + state.isRaining = true; + state.rainIntensity = parseInt(weatherIntensitySlider?.value || 40) / 100; + break; + case 'snow': + state.isRaining = false; // snow uses its own rendering + state.rainIntensity = parseInt(weatherIntensitySlider?.value || 40) / 100; + break; + case 'thunder': + state.isRaining = true; // thunder includes rain + state.rainIntensity = parseInt(weatherIntensitySlider?.value || 60) / 100; + if (weatherIntensitySlider) weatherIntensitySlider.value = Math.round(state.rainIntensity * 100); + break; + } + + setWeatherButtonActive('weather' + type.charAt(0).toUpperCase() + type.slice(1)); + } + + // Button handlers + for (const id of WEATHER_BTNS) { + const btn = document.getElementById(id); + if (btn) { + const weatherType = btn.dataset.weather || id.replace('weather', '').toLowerCase(); + btn.addEventListener('click', () => applyWeather(weatherType)); + } + } + + // Intensity slider + if (weatherIntensitySlider) { + weatherIntensitySlider.addEventListener('input', () => { + const val = parseInt(weatherIntensitySlider.value) / 100; + if (state.weatherType !== 'clear') { + state.rainIntensity = val; + if (state.weatherType === 'thunder' || state.weatherType === 'rain') { + state.isRaining = true; + } + } + }); + } + + // Listen for toggleSettings custom event + document.addEventListener('toggleSettings', () => { + if (settingsPanel) { + // Sync all controls when panel opens + if (timeSlider) timeSlider.value = Math.round(state.worldTime * 100); + if (dayNightToggle) dayNightToggle.checked = state.dayNightCycle !== false; + if (weatherIntensitySlider) weatherIntensitySlider.value = Math.round(state.rainIntensity * 100); + // Highlight active weather button + const wt = state.weatherType || (state.isRaining ? 'rain' : 'clear'); + const activeId = 'weather' + wt.charAt(0).toUpperCase() + wt.slice(1); + setWeatherButtonActive(activeId); + settingsPanel.style.display = settingsPanel.style.display === 'none' ? 'block' : 'none'; + } + }); +} + +function isDayNightEnabled() { + return state.dayNightCycle !== false; +} + +// === ui/respawn.js === +// ==================== RESPAWN ==================== + + + + +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; @@ -3447,416 +6350,421 @@ 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('Возрождение в начальной точке, 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_SERVER2 = "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_SERVER2, { transports: ["websocket"] }); - voiceSocket.on("connect", () => { - voiceSocket.emit("voice_join", { world_id: state.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"; + + console.log('player.hp after respawn logic:', player.hp); + console.log('Hiding death screen...'); + deathEl.style.display = 'none'; + console.log('=== RESPAWN END ==='); + }; +} + +// === input/controls.js === +// ==================== УПРАВЛЕНИЕ ==================== + + +const 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; + }); +} + +// === multiplayer/voice-chat.js === +// Голосовой чат + + + + + +let voiceSocket = null; +let voiceStream = null; +let audioCtx = null; +let voiceProcessor = null; +let voiceActive = false; + +// Кнопка микрофона +const voiceBtn = document.createElement('div'); +voiceBtn.innerHTML = '🎤/'; +voiceBtn.title = 'Голосовой чат (выкл)'; +voiceBtn.style.cssText = 'position:absolute;top:10px;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: state.worldId, x: state.player.x, y: state.player.y, name: state.playerName || 'Игрок' }); + }); + + voiceSocket.on('voice_in', (payload) => { + // Воспроизводим входящий голос + const { data, meta, volume: rawVolume } = payload; + const volume = rawVolume !== undefined ? rawVolume : 1.0; + if (!audioCtx || audioCtx.state === 'closed') return; + + // Int16 → Float32 (volume applied via gain node, not in conversion) + 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); + } + + 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'; + } +}; + +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 }); + } } }; - 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/ui/inventory.js - function renderInventory() { - const inventoryGrid = state.inventoryGrid; - const inv = state.inv; - const selected = state.selected; - inventoryGrid.innerHTML = ""; - 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] && tex[id]) { - slot.style.backgroundImage = `url(${tex[id].toDataURL()})`; - slot.style.backgroundSize = "cover"; - } else if (itemTex[id]) { - slot.style.backgroundImage = `url(${itemTex[id].toDataURL()})`; - slot.style.backgroundSize = "cover"; - } else if (ITEMS[id]) { - slot.textContent = ITEMS[id].icon || "\u{1F4E6}"; - } else if (TOOLS[id]) { - slot.textContent = TOOLS[id].icon || "\u{1F527}"; - } else if (id === "iron_armor") { - slot.textContent = "\u{1F6E1}\uFE0F"; - 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); - 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; - } else { - state.player.equippedArmor = "iron_armor"; - state.player.armor = BLOCKS["iron_armor"].armor; - } - playSound("click"); - renderInventory(); - } - }; - } - inventoryGrid.appendChild(slot); - } - } +// === ui/craft.js === +// Craft UI - // src/ui/craft.js - function canCraft(r) { - const inv = state.inv; - for (const res in r.cost) { - if ((inv[res] || 0) < r.cost[res]) return false; - } - return true; + + + + + + + + + + +function canCraft(r) { + const inv = state.inv; + for (const res in r.cost) { + if ((inv[res] || 0) < r.cost[res]) return false; } - 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 (itemTex[r.out]) { - icon.style.backgroundImage = `url(${itemTex[r.out].toDataURL()})`; - } else if (TOOLS[r.out]) { - icon.textContent = TOOLS[r.out].icon || "\u{1F527}"; - 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 || "\u{1F4E6}"; - 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 itemName2 = BLOCKS[r.out]?.n || TOOLS[r.out]?.n || ITEMS[r.out]?.n || r.out; - nm.textContent = `${itemName2} 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 = "\u0421\u043E\u0437\u0434\u0430\u0442\u044C"; - 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); + return true; +} + +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'; + // Иконка результата — canvas sprite > emoji + if (tex[r.out]) { + icon.style.backgroundImage = `url(${tex[r.out].toDataURL()})`; + } else if (itemTex[r.out]) { + icon.style.backgroundImage = `url(${itemTex[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'; } - } - var craftOpen = false; - var inventoryOpen = false; - function toggleCraft() { - playSound("click"); - craftOpen = !craftOpen; - state.craftPanel.style.display = craftOpen ? "block" : "none"; - if (craftOpen) { + 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); + if (!state.recentItems.includes(r.out)) { state.recentItems.unshift(r.out); state.recentItems = state.recentItems.slice(0, 5); } + rebuildHotbar(); renderCraft(); - inventoryOpen = false; - state.inventoryPanel.style.display = "none"; - } + renderInventory(); + }; + row.appendChild(icon); row.appendChild(info); row.appendChild(btn); + recipesEl.appendChild(row); } - function closeCraft() { - playSound("click"); - craftOpen = false; - state.craftPanel.style.display = "none"; - } - function toggleInventory() { - playSound("click"); - inventoryOpen = true; - state.inventoryPanel.style.display = "block"; - renderInventory(); - craftOpen = false; - state.craftPanel.style.display = "none"; - } - function closeInventory() { - playSound("click"); +} + +let craftOpen = false; +let inventoryOpen = false; + +function toggleCraft() { + playSound('click'); + craftOpen = !craftOpen; + state.craftPanel.style.display = craftOpen ? 'block' : 'none'; + if (craftOpen) { + renderCraft(); inventoryOpen = false; - state.inventoryPanel.style.display = "none"; + state.inventoryPanel.style.display = 'none'; + } +} + +function closeCraft() { + playSound('click'); + craftOpen = false; + state.craftPanel.style.display = 'none'; +} + +function toggleInventory() { + playSound('click'); + inventoryOpen = true; + state.inventoryPanel.style.display = 'block'; + renderInventory(); + craftOpen = false; + state.craftPanel.style.display = 'none'; +} + +function closeInventory() { + playSound('click'); + inventoryOpen = false; + state.inventoryPanel.style.display = 'none'; +} + +// === main.js === +// ==================== ENTRY POINT ==================== + + + + + + + + + + + + + + + + + + + + + + + + + + +// ==================== 1) CONFIG — parse URL ==================== +// urlParams defined in config.js +state.SERVER_URL = SERVER_URL; +state.TELEGRAM_BOT_USERNAME = 'Grechkacraft_bot'; +state.TELEGRAM_APP_SHORT_NAME = 'minegrechka'; + +// Set TG-related state +if (isTgWebApp) { + state.myTgId = tgUser?.id || null; +} + +state.heroImg = new Image(); state.heroImg.src = 'https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png'; + +// ==================== 2) START MENU ==================== +// Show the start menu before initializing the game. +// The menu returns a Promise that resolves with the worldId. +async function startGame() { + const worldId = await showStartMenu(); + + // Set worldId and playerName + state.worldId = worldId; + if (!state.playerName) { + state.playerName = localStorage.getItem('minegrechka_playerName') || 'Игрок'; + localStorage.setItem('minegrechka_playerName', state.playerName); + } + console.log('[Launch] worldId:', state.worldId, 'player:', state.playerName, 'tg:', isTgWebApp); + + // Validate TG initData if in TG context + if (isTgWebApp && window.Telegram && Telegram.WebApp.initData) { + try { + const resp = await fetch(state.SERVER_URL + '/api/tg/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ initData: Telegram.WebApp.initData }) + }); + const data = await resp.json(); + if (data.ok && data.tg_id) { + console.log('[TG] Authenticated, tg_id:', data.tg_id); + state.myTgId = data.tg_id; + } + } catch (e) { console.warn('[TG] Auth failed:', e); } } - // src/main.js - var urlParams2 = new URLSearchParams(window.location.search); - state.SERVER_URL = SERVER_URL; - state.TELEGRAM_BOT_USERNAME = "Grechkacraft_bot"; - state.TELEGRAM_APP_SHORT_NAME = "minegrechka"; - 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 worldParam = urlParams2.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); + // Update URL with world code + try { + const newUrl = new URL(window.location.href); + newUrl.searchParams.set('world', state.worldId); + if (typeof history !== 'undefined' && history.replaceState) { + history.replaceState(null, '', newUrl.toString()); } - console.log("Generated new worldId for browser:", state.worldId); - } - console.log("Final worldId:", state.worldId, "Player name:", state.playerName); + } catch (e) {} + + // ==================== 3) CANVAS — init from canvas module ==================== initCanvas(); - 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"); - state.hotbarEl = document.getElementById("hotbar"); - state.craftPanel = document.getElementById("craftPanel"); - state.recipesEl = document.getElementById("recipes"); - state.inventoryPanel = document.getElementById("inventoryPanel"); - state.inventoryGrid = document.getElementById("inventoryGrid"); - state.marketPanel = document.getElementById("marketPanel"); - state.marketContent = document.getElementById("marketContent"); - state.tradePanel = document.getElementById("tradePanel"); - state.tradeContent = document.getElementById("tradeContent"); - state.multiplayerStatus = document.getElementById("multiplayerStatus"); + + // ==================== 4) STATE — set DOM refs ==================== + 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'); + state.hotbarEl = document.getElementById('hotbar'); + state.craftPanel = document.getElementById('craftPanel'); + state.recipesEl = document.getElementById('recipes'); + state.inventoryPanel = document.getElementById('inventoryPanel'); + state.inventoryGrid = document.getElementById('inventoryGrid'); + state.marketPanel = document.getElementById('marketPanel'); + state.marketContent = document.getElementById('marketContent'); + state.tradePanel = document.getElementById('tradePanel'); + state.tradeContent = document.getElementById('tradeContent'); + state.multiplayerStatus = document.getElementById('multiplayerStatus'); + + // ==================== 5) TEXTURES — initTextures() ==================== initTextures(); state.tex = tex; - state.itemTex = itemTex; - var craftBtn = document.getElementById("craftBtn"); - var invToggle = document.getElementById("invToggle"); - var craftClose = document.getElementById("craftClose"); - var inventoryClose = document.getElementById("inventoryClose"); + + // ==================== 6) CRAFT/INVENTORY BUTTONS ==================== + const craftBtn = document.getElementById('craftBtn'); + const invToggle = document.getElementById('invToggle'); + const craftClose = document.getElementById('craftClose'); + const inventoryClose = document.getElementById('inventoryClose'); + if (craftBtn) craftBtn.onclick = () => toggleCraft(); if (craftClose) craftClose.onclick = () => closeCraft(); if (invToggle) invToggle.onclick = () => toggleInventory(); if (inventoryClose) inventoryClose.onclick = () => closeInventory(); + + // ==================== 7) CONTROLS — initControls ==================== initControls(); + + // ==================== 8) MOUSE — initMouseHandlers ==================== initMouseHandlers(); + + // ==================== 9) UI MODULES — init all UI ==================== initChat(); initFurnace(); initMinimap(); @@ -3864,17 +6772,65 @@ initMarket(); initRespawn(); initShare(); + initSettings(); initModes(); + + // ==================== 10) SOCKET — initSocket ==================== initSocket(); + + // ==================== 11) VOICE — initVoice ==================== initVoice(); + + // ==================== 12) SAVE — loadGame, applySave ==================== + // Ensure hotbar has items on first load + if (state.recentItems.length === 0) { + state.recentItems = Object.keys(state.inv).filter(k => state.inv[k] > 0).slice(0, 5); + } + // Make sure selected item is valid and in recentItems + if (!state.selected || !state.inv[state.selected] || state.inv[state.selected] <= 0) { + state.selected = state.recentItems[0] || 'dirt'; + } + if (!state.recentItems.includes(state.selected)) { + state.recentItems.unshift(state.selected); + state.recentItems = state.recentItems.slice(0, 5); + } rebuildHotbar(); + + // Keyboard hotbar switching (1/2/3) + document.addEventListener('keydown', (e) => { + if (state.showFullInventory || state.craftOpen || state.inventoryOpen || state.chatOpen) return; + if (e.key >= '1' && e.key <= '3') { + const items = state.recentItems.filter(id => state.inv[id] > 0).slice(0, 3); + const idx = parseInt(e.key) - 1; + if (items[idx]) { + state.selected = items[idx]; + rebuildHotbar(); + } + } + }); + + // Scroll wheel hotbar cycling + document.addEventListener('wheel', (e) => { + if (state.showFullInventory || state.craftOpen || state.inventoryOpen || state.chatOpen) return; + const items = state.recentItems.filter(id => state.inv[id] > 0).slice(0, 3); + if (items.length === 0) return; + let idx = items.indexOf(state.selected); + if (idx < 0) idx = 0; + if (e.deltaY > 0) idx = (idx + 1) % items.length; + else idx = (idx - 1 + items.length) % items.length; + state.selected = items[idx]; + rebuildHotbar(); + e.preventDefault(); + }, { passive: false }); + 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); + console.log('Загружено сохранение, 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"); + console.log('WARNING: HP <= 0 после загрузки, возрождаемся'); state.player.hp = 100; state.player.hunger = 100; state.player.o2 = 100; @@ -3885,21 +6841,27 @@ 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"); + console.log('Сохранение не найдено, начинаем новую игру'); + // Fill recentItems from inventory + state.recentItems = Object.keys(state.inv).filter(k => state.inv[k] > 0).slice(0, 5); + rebuildHotbar(); + 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") { + 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; + if (!b || b.dead || b.t === 'air' || b.t === 'water') continue; safeGY = gy - 1; break; } @@ -3907,30 +6869,130 @@ 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); + + 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) { + + // Автосейв при скрытии страницы + document.addEventListener('visibilitychange', () => { + if (document.hidden && !state.isMultiplayer) { saveGame(); } }); - window.addEventListener("beforeunload", () => { - saveGame(); + + // Автосейв перед закрытием страницы + window.addEventListener('beforeunload', () => { + if (!state.isMultiplayer) { + 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); + }).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); + + // ==================== 14) WORLD-GEN PLACEHOLDER ==================== + // 2D-мир генерируется лениво, колонками вокруг игрока. В первые секунды + // на экране только небо и HUD — пользователь считает это поломкой. + installWorldGenPlaceholder(); +} + +// ==================== WORLD-GEN PLACEHOLDER ==================== +var _wgPh = null; + +function worldHasVisibleBlocks() { + try { + var gx = Math.floor((state.player && state.player.x || 0) / state.TILE); + for (var dx = -3; dx <= 3; dx++) { + for (var up = 0; up < 30; up++) { + var b = getBlock(gx + dx, up); + if (b && b.t && b.t !== 'air' && b.t !== 'water' && b.t !== 'swamp_water') return true; + } + } + } catch (e) { /* getBlock может быть недоступен на ранних кадрах */ } + return false; +} + +function installWorldGenPlaceholder() { + if (_wgPh || document.getElementById('wgPh')) return; + hideBoot(); + + var el = document.createElement('div'); + el.id = 'wgPh'; + el.setAttribute('role', 'status'); + el.setAttribute('aria-live', 'polite'); + el.innerHTML = + '
' + + '
Генерация мира
' + + '
' + + '
Первые секунды мир строится по колонкам
' + + '
'; + document.body.appendChild(el); + _wgPh = el; + + var st = document.createElement('style'); + st.textContent = [ + '#wgPh{position:fixed;inset:0;z-index:2147483000;display:flex;align-items:center;', + 'justify-content:center;background:linear-gradient(#0f1720,#1c2b3a);', + 'font:14px/1.4 system-ui,-apple-system,sans-serif;color:#e8f0f8}', + '#wgPh.gone{display:none}', + '.wgPh-box{text-align:center;max-width:320px;padding:0 24px}', + '.wgPh-title{font-size:18px;margin-bottom:14px}', + '.wgPh-bar{height:6px;background:rgba(255,255,255,.12);border-radius:3px;overflow:hidden}', + '.wgPh-bar>i{display:block;height:100%;width:35%;background:#5a8f3c;border-radius:3px;', + 'animation:wgPhSlide 1.1s ease-in-out infinite}', + '@keyframes wgPhSlide{0%{margin-left:-35%}100%{margin-left:100%}}', + '.wgPh-hint{margin-top:12px;color:#8fa3b8;font-size:12px}' + ].join(''); + document.head.appendChild(st); + + var deadline = Date.now() + 60000; + var born = Date.now(); + var tick = function() { + if (!_wgPh || !_wgPh.isConnected) return; + if (Date.now() > deadline) { + el.remove(); + return; + } + if (!worldHasVisibleBlocks() || Date.now() - born < 600) el.classList.remove('gone'); + else el.classList.add('gone'); + setTimeout(tick, 200); + }; + setTimeout(tick, 150); +} + +// Start the game +startGame().catch(err => { + console.error('Fatal error starting game:', err); +}); + +// Debug: give items +window.debugGive = function(id, count) { + state.inv[id] = (state.inv[id] || 0) + (count || 1); + if (!state.recentItems.includes(id)) { state.recentItems.unshift(id); state.recentItems = state.recentItems.slice(0, 5); } + rebuildHotbar(); + // Can't call renderInventory from here, but user can click inv + console.log('Gave', count || 1, id, '=>', state.inv[id]); + console.log('inv keys>0:', Object.keys(state.inv).filter(k => state.inv[k] > 0).join(', ')); +}; +window.debugInv = function() { return Object.keys(state.inv).filter(k => state.inv[k] > 0).map(k => k + ':' + state.inv[k]); }; + })(); diff --git a/game.js_new b/game.js_new new file mode 100644 index 0000000..bdcebce --- /dev/null +++ b/game.js_new @@ -0,0 +1,5190 @@ +// GrechkaCraft — auto-bundled from ES modules +(function() { +"use strict"; + +// === core/constants.js === +// ==================== КОНСТАНТЫ ИГРЫ ==================== + +const TILE = 40; + +// Мир +const SEA_GY = 14; // уровень воды (gy) - уменьшил для меньших водоёмов +const BEDROCK_GY = 140; // глубина бедрока (gy), чем больше - тем глубже +const GEN_MARGIN_X = 40; // запас генерации по X (в тайлах) — увеличен для плавной прогрузки + +// Физика +const GRAV = 2200; +const GRAV_WATER = 550; +const MOVE = 320; +const JUMP = 760; + +// День/ночь +const DAY_LEN = 360; // замедлил смену дня/ночи в 3 раза + +// Сохранение +const SAVE_KEY = 'minegrechka_save'; + +// Сеть +const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду + +// Погода +const MAX_RAINDROPS = 200; + +// Чат +const MAX_CHAT_MESSAGES = 50; + +// Голосовой чат +const VOICE_SERVER = 'wss://voicegrech.mkn8n.ru'; + +// === core/state.js === +// Все мутабельные переменные игры в одном объекте состояния +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 +}; + +// === config.js === +// ==================== КОНФИГУРАЦИЯ СЕРВЕРА ==================== +// Возможность переопределить сервер через query string +const urlParams = new URLSearchParams(window.location.search); +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 из других модулей +function setWorldId(id) { worldId = id; } +function setPlayerName(name) { playerName = name; } + +// === ui/start-menu.js === +// ==================== START MENU ==================== + + +// Detect Telegram WebApp +const isTgWebApp = !!(window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData); +let tgUser = null; +let tgSaveLoaded = false; + +if (isTgWebApp) { + try { + Telegram.WebApp.ready(); + Telegram.WebApp.expand(); + tgUser = Telegram.WebApp.initDataUnsafe?.user || null; + console.log('[TG] Mini App detected, user:', tgUser?.id, tgUser?.username); + // Set player name from TG + if (tgUser && tgUser.first_name) { + const tgName = tgUser.username || tgUser.first_name; + localStorage.setItem('minegrechka_playerName', tgName); + } + } catch (e) { + console.warn('[TG] WebApp init error:', e); + } +} + +// Export TG info for other modules + + +// ==================== HELPERS ==================== + +function generateShortWorldCode() { + // Generate readable 6-char code: consonants + digits (easy to copy/share) + const c = 'bcdfghjkmnpqrstvwxyz'; + const d = '23456789'; + let code = ''; + for (let i = 0; i < 3; i++) code += c[Math.floor(Math.random() * c.length)]; + for (let i = 0; i < 3; i++) code += d[Math.floor(Math.random() * d.length)]; + return code; +} + +function showToast(msg, duration) { + duration = duration || 1800; + const t = document.createElement('div'); + t.textContent = msg; + t.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.85);color:#2ecc71;padding:10px 24px;border-radius:10px;font:bold 16px Arial,sans-serif;z-index:99999;pointer-events:none;transition:opacity 0.4s;'; + document.body.appendChild(t); + setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 400); }, duration); +} + +function customAlert(msg) { + const overlay = document.createElement('div'); + overlay.className = 'custom-modal-overlay'; + overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:100000;'; + const box = document.createElement('div'); + box.style.cssText = 'background:#C6C6C6;border:4px solid #000;padding:24px;max-width:360px;width:90%;box-shadow:4px 4px 0 #000;font-family:Courier New,monospace;'; + const text = document.createElement('div'); + text.textContent = msg; + text.style.marginBottom = '16px'; + text.style.fontWeight = 'bold'; + text.style.fontSize = '16px'; + const btn = document.createElement('button'); + btn.textContent = 'OK'; + btn.style.cssText = 'padding:8px 24px;font-family:Courier New,monospace;font-weight:bold;cursor:pointer;border:3px solid #000;background:#5A8F3C;color:#fff;box-shadow:2px 2px 0 #000;'; + btn.onclick = () => overlay.remove(); + box.appendChild(text); + box.appendChild(btn); + overlay.appendChild(box); + document.body.appendChild(overlay); +} + +// ==================== MAIN START MENU ==================== + +// Minecraft-style CSS for the start menu +const mcCSS = ` + .mc-overlay { position:fixed;top:0;left:0;width:100%;height:100%;z-index:99999; + display:flex;align-items:center;justify-content:center;font-family:'Courier New',monospace; + image-rendering:pixelated; } + .mc-bg { position:absolute;inset:0;background:#000;opacity:0.75; } + .mc-dirt { position:absolute;bottom:0;left:0;right:0;height:40%; + background:repeating-linear-gradient(0deg,#8B6914 0px,#8B6914 2px,#7A5B12 2px,#7A5B12 4px);opacity:0.3; } + .mc-box { position:relative;z-index:1;background:#C6C6C6;border:4px solid #000; + padding:0;max-width:420px;width:92%;box-shadow:4px 4px 0 #000; } + .mc-titlebar { background:#1a1a2e;padding:12px 16px;text-align:center; + border-bottom:4px solid #000; } + .mc-title { font-size:24px;font-weight:bold;color:#FFAA00;text-shadow:2px 2px 0 #000; + letter-spacing:2px; } + .mc-subtitle { font-size:13px;color:#AAA;margin-top:4px; } + .mc-body { padding:16px;background:#C6C6C6; } + .mc-btn { display:block;width:100%;padding:10px 16px;margin-bottom:8px; + font-family:'Courier New',monospace;font-size:16px;font-weight:bold; + cursor:pointer;text-align:center;border:3px solid #000; + image-rendering:pixelated;box-shadow:3px 3px 0 #000; + transition:transform 0.05s,box-shadow 0.05s; } + .mc-btn:active { transform:translate(2px,2px);box-shadow:1px 1px 0 #000; } + .mc-btn-green { background:#5A8F3C;color:#E0E0E0;border-color:#3A5F1C;text-shadow:1px 1px 0 #222; } + .mc-btn-green:hover { background:#6DAF4E; } + .mc-btn-blue { background:#3C5F9E;color:#E0E0E0;border-color:#2A4070;text-shadow:1px 1px 0 #222; } + .mc-btn-blue:hover { background:#4B73BE; } + .mc-btn-purple { background:#7B3FA0;color:#E0E0E0;border-color:#5A2C78;text-shadow:1px 1px 0 #222; } + .mc-btn-purple:hover { background:#9555BE; } + .mc-btn-orange { background:#B86E00;color:#E0E0E0;border-color:#8A5200;text-shadow:1px 1px 0 #222; } + .mc-btn-orange:hover { background:#DA8500; } + .mc-btn-red { background:#9E2B2B;color:#E0E0E0;border-color:#6E1B1B;text-shadow:1px 1px 0 #222; } + .mc-btn-red:hover { background:#BE3B3B; } + .mc-btn-gray { background:#555;color:#AAA;border-color:#333;text-shadow:1px 1px 0 #111; } + .mc-btn-gray:hover { background:#666;color:#DDD; } + .mc-btn-outline { background:transparent;color:#555;border-color:#555; + text-shadow:none;box-shadow:2px 2px 0 #333; } + .mc-btn-outline:hover { background:#DDD;color:#222; } + .mc-input { width:100%;padding:8px 12px;font-family:'Courier New',monospace; + font-size:16px;background:#FFF;border:3px solid #000;color:#222; + box-shadow:inset 2px 2px 0 #999;margin:4px 0;box-sizing:border-box; } + .mc-input:focus { outline:none;border-color:#FFAA00; } + .mc-label { font-size:13px;color:#444;font-weight:bold;margin:8px 0 4px; } + .mc-divider { border:none;border-top:2px solid #999;margin:12px 0; } + .mc-friend-row { display:flex;justify-content:space-between;align-items:center; + padding:6px 8px;margin-bottom:4px;background:#E8E8E8;border:2px solid #999; + box-shadow:2px 2px 0 #999; } + .mc-friend-btn { padding:4px 10px;font-family:'Courier New',monospace; + font-size:13px;font-weight:bold;cursor:pointer;border:2px solid #000; + box-shadow:1px 1px 0 #000; } + .mc-friend-btn:active { transform:translate(1px,1px);box-shadow:none; } + .mc-friend-btn-green { background:#5A8F3C;color:#E0E0E0; } + .mc-friend-btn-red { background:#9E2B2B;color:#E0E0E0; } + .mc-checkbox-row { display:flex;align-items:center;gap:8px;font-size:13px;color:#444;cursor:pointer; } + .mc-checkbox { width:18px;height:18px;accent-color:#5A8F3C; } +`; + +function showStartMenu() { + return new Promise(async (resolve) => { + const urlParams = new URLSearchParams(window.location.search); + const worldParam = urlParams.get('world'); + const tgStartParam = (isTgWebApp && Telegram.WebApp.initDataUnsafe?.start_param) + ? Telegram.WebApp.initDataUnsafe.start_param.trim() + : null; + + // If world is in URL or TG start param — skip menu, auto-join + if ((worldParam && worldParam.trim() !== '') || tgStartParam) { + const worldId = (worldParam && worldParam.trim()) || tgStartParam; + console.log('[Menu] Joining world from URL/startParam:', worldId); + + // Set player name from TG or localStorage or default + if (!state.playerName) { + if (isTgWebApp && tgUser) { + state.playerName = tgUser.username || tgUser.first_name || 'Игрок'; + } else { + state.playerName = localStorage.getItem('minegrechka_playerName') || 'Игрок'; + } + localStorage.setItem('minegrechka_playerName', state.playerName); + } + + resolve(worldId); + return; + } + + // Inject CSS + const styleEl = document.createElement('style'); + styleEl.textContent = mcCSS; + document.head.appendChild(styleEl); + + // Build overlay + const overlay = document.createElement('div'); + overlay.className = 'mc-overlay'; + overlay.id = 'startMenu'; + overlay.innerHTML = '
'; + + const box = document.createElement('div'); + box.className = 'mc-box'; + + // Title bar + const titleBar = document.createElement('div'); + titleBar.className = 'mc-titlebar'; + titleBar.innerHTML = '
⛏️ GrechkaCraft
' + + '
' + (isTgWebApp ? ('Привет, ' + (tgUser?.first_name || 'Игрок') + '!') : 'Voxel-мир в браузере') + '
'; + box.appendChild(titleBar); + + const body = document.createElement('div'); + body.className = 'mc-body'; + + // Mutable worldId holder + let selectedWorldId = null; + + // === Buttons === + + // 🌍 New World + const btnNew = document.createElement('button'); + btnNew.className = 'mc-btn mc-btn-green'; + btnNew.textContent = '🌍 Новый мир'; + btnNew.onclick = () => { + selectedWorldId = generateShortWorldCode(); + overlay.remove(); + styleEl.remove(); + resolve(selectedWorldId); + }; + body.appendChild(btnNew); + + // 🔗 Join by code + const btnJoin = document.createElement('button'); + btnJoin.className = 'mc-btn mc-btn-blue'; + btnJoin.textContent = '🔗 Вступить по коду'; + btnJoin.onclick = () => { + body.innerHTML = ''; + const lbl = document.createElement('div'); + lbl.className = 'mc-label'; + lbl.textContent = 'Код мира:'; + body.appendChild(lbl); + const codeInput = document.createElement('input'); + codeInput.className = 'mc-input'; + codeInput.type = 'text'; + codeInput.placeholder = 'bkh237'; + codeInput.maxLength = 12; + body.appendChild(codeInput); + const btnGo = document.createElement('button'); + btnGo.className = 'mc-btn mc-btn-blue'; + btnGo.textContent = 'Войти →'; + btnGo.onclick = () => { + const code = codeInput.value.trim().toLowerCase(); + if (code.length >= 3) { + selectedWorldId = code; + overlay.remove(); + styleEl.remove(); + resolve(selectedWorldId); + } + }; + body.appendChild(btnGo); + const btnBack = document.createElement('button'); + btnBack.className = 'mc-btn mc-btn-gray'; + btnBack.textContent = '← Назад'; + btnBack.onclick = () => { overlay.remove(); styleEl.remove(); showStartMenu().then(resolve); }; + body.appendChild(btnBack); + codeInput.focus(); + codeInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') btnGo.click(); }); + }; + body.appendChild(btnJoin); + + // 💾 Continue saved game + const hasLocalSave = !!localStorage.getItem(state.SAVE_KEY || 'minegrechka_save'); + if (isTgWebApp || hasLocalSave) { + const btnHistory = document.createElement('button'); + btnHistory.className = 'mc-btn mc-btn-purple'; + btnHistory.textContent = '💾 Продолжить игру'; + btnHistory.onclick = async () => { + if (isTgWebApp && tgUser) { + try { + const resp = await fetch(state.SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id); + const data = await resp.json(); + if (data.ok && data.world_id) { + selectedWorldId = data.world_id; + tgSaveLoaded = true; + overlay.remove(); + styleEl.remove(); + resolve(selectedWorldId); + return; + } + } catch (e) { console.warn('[TG] Load save failed:', e); } + } + if (hasLocalSave) { + // Continue with a new world code (the save will be loaded separately by initDB/loadGame) + selectedWorldId = generateShortWorldCode(); + overlay.remove(); + styleEl.remove(); + resolve(selectedWorldId); + return; + } + customAlert('Сохранений не найдено'); + }; + body.appendChild(btnHistory); + } + + // === TG-only buttons === + if (isTgWebApp && window.Telegram && Telegram.WebApp.switchInlineQuery) { + // 📤 Invite friends + const btnInvite = document.createElement('button'); + btnInvite.className = 'mc-btn mc-btn-outline'; + btnInvite.style.marginTop = '16px'; + btnInvite.textContent = '📤 Пригласить друзей'; + btnInvite.onclick = () => { + const code = selectedWorldId || generateShortWorldCode(); + const url = 'https://t.me/' + state.TELEGRAM_BOT_USERNAME + '/app?startapp=' + encodeURIComponent(code); + if (Telegram.WebApp && Telegram.WebApp.openTelegramLink) { + Telegram.WebApp.openTelegramLink('https://t.me/share/url?url=' + encodeURIComponent(url) + '&text=' + encodeURIComponent('🏗️ Заходи в GrechkaCraft! Мир: ' + code)); + } else { + navigator.clipboard.writeText(url).then(() => showToast('📋 Скопировано!')).catch(() => showToast(url)); + } + }; + body.appendChild(btnInvite); + + // 👥 Friends + const btnFriends = document.createElement('button'); + btnFriends.className = 'mc-btn mc-btn-orange'; + btnFriends.style.marginTop = '8px'; + btnFriends.textContent = '👥 Друзья'; + btnFriends.onclick = async () => { + try { + const resp = await fetch(state.SERVER_URL + '/api/tg/friends?tg_id=' + tgUser.id); + const data = await resp.json(); + body.innerHTML = ''; + + // Back button at top + const btnBackTop = document.createElement('button'); + btnBackTop.className = 'mc-btn mc-btn-gray'; + btnBackTop.textContent = '← Назад'; + btnBackTop.style.marginBottom = '12px'; + btnBackTop.onclick = () => { overlay.remove(); styleEl.remove(); showStartMenu().then(resolve); }; + body.appendChild(btnBackTop); + + // Nickname section + if (data.nickname) { + const nickDiv = document.createElement('div'); + nickDiv.className = 'mc-friend-row'; + nickDiv.innerHTML = '🔗 Твой ник: ' + data.nickname + ''; + nickDiv.style.background = '#D5E8C5'; + body.appendChild(nickDiv); + } else { + const nickLabel = document.createElement('div'); + nickLabel.className = 'mc-label'; + nickLabel.style.color = '#9E2B2B'; + nickLabel.textContent = '⚡ Задай ник, чтобы друзья могли тебя найти:'; + body.appendChild(nickLabel); + const nickRow = document.createElement('div'); + nickRow.style.display = 'flex'; + nickRow.style.gap = '4px'; + nickRow.style.marginBottom = '8px'; + const nickInput = document.createElement('input'); + nickInput.className = 'mc-input'; + nickInput.style.flex = '1'; + nickInput.type = 'text'; + nickInput.maxLength = 16; + nickInput.placeholder = 'Твой ник'; + const nickBtn = document.createElement('button'); + nickBtn.className = 'mc-btn mc-btn-green'; + nickBtn.style.width = 'auto'; + nickBtn.style.marginBottom = '0'; + nickBtn.style.padding = '8px 16px'; + nickBtn.textContent = '✓'; + nickBtn.onclick = async () => { + const n = nickInput.value.trim().toLowerCase(); + if (!n || n.length < 2) { showToast('❌ Минимум 2 символа'); return; } + const check = await fetch(state.SERVER_URL + '/api/nick/check?nick=' + encodeURIComponent(n)); + const cd = await check.json(); + if (!cd.available) { showToast('❌ Ник занят'); return; } + const setResp = await fetch(state.SERVER_URL + '/api/nick/set', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tg_id: tgUser.id, nickname: n }) + }); + const sd = await setResp.json(); + if (sd.ok) { showToast('✅ Ник: ' + sd.nickname); btnFriends.onclick(); } else { showToast('❌ Ошибка'); } + }; + nickRow.appendChild(nickInput); + nickRow.appendChild(nickBtn); + body.appendChild(nickRow); + } + + // Friends list + if (data.friends && data.friends.length) { + const listLabel = document.createElement('div'); + listLabel.className = 'mc-label'; + listLabel.textContent = '👥 Друзья:'; + body.appendChild(listLabel); + for (const f of data.friends) { + const fDiv = document.createElement('div'); + fDiv.className = 'mc-friend-row'; + const statusDot = f.online ? '🟢' : '⚫'; + let info = statusDot + ' ' + f.nickname + ''; + if (f.online && f.world_id) info += ' — мир ' + f.world_id; + const fInfo = document.createElement('div'); + fInfo.innerHTML = info; + fDiv.appendChild(fInfo); + if (f.online && f.world_id) { + const joinBtn = document.createElement('button'); + joinBtn.className = 'mc-friend-btn mc-friend-btn-green'; + joinBtn.textContent = '🎮 Вступить'; + joinBtn.onclick = () => { + selectedWorldId = f.world_id; + overlay.remove(); + styleEl.remove(); + resolve(selectedWorldId); + }; + fDiv.appendChild(joinBtn); + } + body.appendChild(fDiv); + } + } else if (data.friends && !data.friends.length) { + const noFriends = document.createElement('div'); + noFriends.className = 'mc-label'; + noFriends.style.color = '#888'; + noFriends.textContent = 'Пока нет друзей'; + body.appendChild(noFriends); + } + + // Pending friend requests + if (data.pending && data.pending.length) { + const pLabel = document.createElement('div'); + pLabel.className = 'mc-label'; + pLabel.style.color = '#B86E00'; + pLabel.textContent = '📨 Запросы в друзья:'; + body.appendChild(pLabel); + for (const p of data.pending) { + const pItem = document.createElement('div'); + pItem.className = 'mc-friend-row'; + const span = document.createElement('span'); + span.textContent = p.from; + pItem.appendChild(span); + const btns = document.createElement('div'); + btns.style.display = 'flex'; + btns.style.gap = '4px'; + const acceptBtn = document.createElement('button'); + acceptBtn.className = 'mc-friend-btn mc-friend-btn-green'; + acceptBtn.textContent = '✓'; + acceptBtn.onclick = async () => { + const r = await fetch(state.SERVER_URL + '/api/friends/accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tg_id: tgUser.id, request_id: p.id }) + }); + const d = await r.json(); + if (d.ok) { showToast('✅ Друг добавлен!'); btnFriends.onclick(); } else { showToast('❌ Ошибка'); } + }; + const declineBtn = document.createElement('button'); + declineBtn.className = 'mc-friend-btn mc-friend-btn-red'; + declineBtn.textContent = '✕'; + declineBtn.onclick = async () => { + await fetch(state.SERVER_URL + '/api/friends/decline', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tg_id: tgUser.id, request_id: p.id }) + }); + showToast('Отклонено'); + btnFriends.onclick(); + }; + btns.appendChild(acceptBtn); + btns.appendChild(declineBtn); + pItem.appendChild(btns); + body.appendChild(pItem); + } + } + + // Add friend + body.appendChild(document.createElement('hr')); + const addLabel = document.createElement('div'); + addLabel.className = 'mc-label'; + addLabel.textContent = '➕ Добавить друга по нику:'; + body.appendChild(addLabel); + const addRow = document.createElement('div'); + addRow.style.display = 'flex'; + addRow.style.gap = '4px'; + const addInput = document.createElement('input'); + addInput.className = 'mc-input'; + addInput.style.flex = '1'; + addInput.type = 'text'; + addInput.maxLength = 16; + addInput.placeholder = 'Ник друга'; + const addBtn = document.createElement('button'); + addBtn.className = 'mc-btn mc-btn-blue'; + addBtn.style.width = 'auto'; + addBtn.style.marginBottom = '0'; + addBtn.style.padding = '8px 16px'; + addBtn.textContent = '+'; + addBtn.onclick = async () => { + const nick = addInput.value.trim().toLowerCase(); + if (!nick || nick.length < 2) { showToast('❌ Минимум 2 символа'); return; } + const r = await fetch(state.SERVER_URL + '/api/friends/request', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tg_id: tgUser.id, nickname: nick }) + }); + const d = await r.json(); + if (d.ok) { showToast(d.message === 'Already friends' ? '✅ Уже друзья!' : '📨 Запрос отправлен!'); } + else { showToast('❌ ' + (d.error || 'Ошибка')); } + }; + addRow.appendChild(addInput); + addRow.appendChild(addBtn); + body.appendChild(addRow); + + // Privacy checkboxes + const settings = data.settings || { allow_requests: 1, notify_online: 1 }; + const settingsDiv = document.createElement('div'); + settingsDiv.style.marginTop = '12px'; + const reqLabel = document.createElement('label'); + reqLabel.className = 'mc-checkbox-row'; + const reqCb = document.createElement('input'); + reqCb.className = 'mc-checkbox'; + reqCb.type = 'checkbox'; + reqCb.checked = settings.allow_requests; + reqCb.onchange = async () => { + await fetch(state.SERVER_URL + '/api/friends/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tg_id: tgUser.id, + allow_requests: reqCb.checked ? 1 : 0, + notify_online: settings.notify_online + }) + }); + }; + reqLabel.appendChild(reqCb); + reqLabel.appendChild(document.createTextNode(' Запросы в друзья')); + settingsDiv.appendChild(reqLabel); + body.appendChild(settingsDiv); + } catch (e) { showToast('❌ Ошибка загрузки друзей'); console.error(e); } + }; + body.appendChild(btnFriends); + } + + // Player name input (only if not already set) + if (!state.playerName) { + const nameDivider = document.createElement('hr'); + nameDivider.className = 'mc-divider'; + body.appendChild(nameDivider); + const nameLabel = document.createElement('div'); + nameLabel.className = 'mc-label'; + nameLabel.textContent = 'Твоё имя:'; + body.appendChild(nameLabel); + const nameInput = document.createElement('input'); + nameInput.className = 'mc-input'; + nameInput.type = 'text'; + nameInput.placeholder = 'Ваше имя'; + nameInput.value = tgUser ? (tgUser.username || tgUser.first_name || '') : ''; + nameInput.maxLength = 20; + nameInput.addEventListener('input', () => { + if (nameInput.value.trim()) { + state.playerName = nameInput.value.trim(); + localStorage.setItem('minegrechka_playerName', state.playerName); + } + }); + body.appendChild(nameInput); + } + + box.appendChild(body); + overlay.appendChild(box); + document.body.appendChild(overlay); + + // Pre-fill player name from TG if available + if (isTgWebApp && tgUser && !state.playerName) { + state.playerName = tgUser.username || tgUser.first_name || 'Игрок'; + localStorage.setItem('minegrechka_playerName', state.playerName); + } + + // Auto-load TG save info (background fetch) + if (isTgWebApp && tgUser && !worldParam) { + fetch(state.SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id) + .then(r => r.json()) + .then(data => { + if (data.ok && data.world_id) { + // could update UI but menu is already shown + } + }) + .catch(() => {}); + } + }); +} + +// === core/canvas.js === +// ==================== ХОЛСТ И РАЗМЕРЫ ==================== + +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; } + +// === data/blocks.js === +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 } +}; + +// === world/world-storage.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; +} + +// === world/generation.js === +// Генерация (по X, на всю глубину до bedrock) +const generated = state.generated; // Set of gx already generated + +// ==================== БИОМЫ ==================== +const BIOMES = { + plains: { name:'Равнина', surface:'grass', subsurface:'dirt', trees:true, flowers:true, treeChance:0.12 }, + desert: { name:'Пустыня', surface:'sand', subsurface:'sand', trees:false, flowers:false, treeChance:0 }, + tundra: { name:'Тундра', surface:'snow', subsurface:'dirt', trees:true, flowers:false, treeChance:0.06 }, + swamp: { name:'Болото', surface:'moss', subsurface:'dirt', trees:true, flowers:false, treeChance:0.10 }, + mountains:{ name:'Горы', surface:'stone', subsurface:'stone', trees:false, flowers:false, treeChance:0 } +}; + +function getBiome(gx) { + const temp = Math.sin(gx*0.003 + state.worldSeed*0.01)*0.5 + Math.sin(gx*0.007 + state.worldSeed*0.02)*0.3 + 0.5; + const humid = Math.sin(gx*0.004 + state.worldSeed*0.015 + 1000)*0.5 + Math.cos(gx*0.006 + state.worldSeed*0.02 + 2000)*0.3 + 0.5; + const mtVal = Math.sin(gx*0.001 + state.worldSeed*0.005)*0.5 + 0.5; + if (temp > 0.7) return 'desert'; + if (temp < 0.3) return 'tundra'; + if (humid > 0.7 && temp >= 0.3 && temp <= 0.7) return 'swamp'; + if (temp >= 0.5 && temp <= 0.7 && mtVal > 0.75) return 'mountains'; + return 'plains'; +} + +const biomeCache = {}; +function getCachedBiome(gx) { + const chunk = Math.floor(gx / 8); // cache per 8-tile chunk + if (biomeCache[chunk] === undefined) biomeCache[chunk] = getBiome(chunk * 8); + return biomeCache[chunk]; +} + +// ==================== СТРУКТУРЫ ==================== +function placeStructure(startGx, startGy, pattern) { + for (let dy = 0; dy < pattern.length; dy++) { + for (let dx = 0; dx < pattern[dy].length; dx++) { + const bt = pattern[dy][dx]; + if (bt && bt !== 'air') { + setBlock(startGx + dx, startGy + dy, bt); + } + } + } +} + +const PYRAMID_PATTERN = [ + ['sand','sand','sand','sand','sand','sand','sand'], + ['sand','stone','stone','stone','stone','stone','sand'], + ['sand','stone','air','air','air','stone','sand'], + ['sand','stone','air','air','air','stone','sand'], + ['sand','stone','air','air','air','stone','sand'], + ['sand','sand','stone','stone','stone','sand','sand'] +]; + +const HOUSE_PATTERN = [ + ['air','planks','planks','planks','planks','air'], + ['planks','air','air','air','air','planks'], + ['planks','air','torch','air','air','planks'], + ['planks','air','air','air','air','planks'], + ['planks','planks','air','planks','planks','planks'] +]; + +const HUT_PATTERN = [ + ['air','wood','wood','wood','air'], + ['wood','air','air','air','wood'], + ['wood','air','torch','air','wood'], + ['moss','moss','air','moss','moss'] +]; + +// ==================== ПОВЕРХНОСТЬ (BIOME-AWARE) ==================== +function surfaceGyAt(gx) { + const biome = getCachedBiome(gx); + // Базовый шум (одинаков для всех биомов) + 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; + let h; + switch(biome) { + case 'desert': + h = Math.floor(SEA_GY - 4 + n3*0.3 + n4*0.5); // плоско, чуть выше + break; + case 'tundra': + h = Math.floor(SEA_GY - 6 + n2*0.5 + n3*0.4 + n5*0.3); // пологие холмы + break; + case 'swamp': + h = Math.floor(SEA_GY - 2 + n3*0.2 + n4*0.3); // очень плоско, у воды + h = Math.max(h, SEA_GY - 3); // не слишком глубоко + break; + case 'mountains': + h = Math.floor(SEA_GY - 15 + n1*1.5 + n2*1.2 + n3); // высокие пики + break; + default: // plains + 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); + const biome = getCachedBiome(gx); + + // === Вода и поверхность === + if (sgy > SEA_GY) { + for (let gy = SEA_GY; gy < sgy; gy++) { + const blockType = (biome === 'swamp' && gy >= SEA_GY - 1) ? 'swamp_water' : 'water'; + setBlock(gx, gy, blockType); + } + setBlock(gx, sgy, 'sand'); + } else { + const b = BIOMES[biome]; + setBlock(gx, sgy, b.surface); + // болото: случайные лужи болотной воды + if (biome === 'swamp' && seededRandom(gx*3, sgy) < 0.15) { + setBlock(gx, sgy-1, 'swamp_water'); + } + // тундра: лёд на воде рядом + if (biome === 'tundra' && sgy === SEA_GY && seededRandom(gx, SEA_GY-1) < 0.3) { + setBlock(gx, SEA_GY-1, 'ice'); + } + } + + // === Подповерхностные слои === + for (let gy = sgy + 1; gy <= BEDROCK_GY; gy++) { + if (gy === BEDROCK_GY) { + setBlock(gx, gy, 'bedrock'); + continue; + } + + let t = BIOMES[biome].subsurface; + + // глубже — камень + if (gy > sgy + 3) t = 'stone'; + + // пустыня: sand глубже + if (biome === 'desert' && gy <= sgy + 6) t = 'sand'; + + // болото: глина ближе к поверхности + if (biome === 'swamp' && gy <= sgy + 2 && seededRandom(gx, gy) < 0.3) t = 'clay'; + + // горы: gravel + if (biome === 'mountains' && gy > sgy + 4 && seededRandom(gx, gy) < 0.12) 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 b = BIOMES[biome]; + const top = getBlock(gx, sgy); + + // цветы (только plains) + if (biome === 'plains' && top && top.t === 'grass' && seededRandom(gx, sgy-1) < 0.10) { + setBlock(gx, sgy-1, 'flower'); + } + + // деревья + if (b.trees && seededRandom(gx*7, sgy-2) < b.treeChance) { + if (biome === 'tundra') { + const th = 3 + Math.floor(seededRandom(gx, sgy) * 3); + for (let i = 0; i < th; i++) setBlock(gx, sgy-1-i, 'wood'); + for (let row = 0; row < th; row++) { + const w = Math.min(row + 1, 2); + for (let dx = -w; dx <= w; dx++) { + const ly = sgy-1-th+row; + if (ly >= 0) setBlock(gx+dx, ly, 'spruce_leaves'); + } + } + } else if (biome === 'swamp') { + setBlock(gx, sgy-1, 'wood'); + setBlock(gx, sgy-2, 'moss'); + setBlock(gx-1, sgy-2, 'leaves'); + setBlock(gx+1, sgy-2, 'leaves'); + } else { + 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'); + } + } + + // кактусы (пустыня) + if (biome === 'desert' && seededRandom(gx*11, sgy) < 0.04) { + const ch = 1 + Math.floor(seededRandom(gx, sgy+1) * 2); + for (let i = 0; i < ch; i++) setBlock(gx, sgy-1-i, 'cactus'); + } + + // грибы (болото) + if (biome === 'swamp' && seededRandom(gx*13, sgy) < 0.06) { + setBlock(gx, sgy-1, 'mushroom'); + } + + // сухие кусты (пустыня) + if (biome === 'desert' && seededRandom(gx*17, sgy) < 0.05) { + setBlock(gx, sgy-1, 'dead_bush'); + } + + // === Структуры мира === + // Пирамида в пустыне + if (biome === 'desert' && ((gx % 200 + 200) % 200) === 47 && sgy < SEA_GY && sgy > SEA_GY - 5) { + placeStructure(gx, sgy - 5, PYRAMID_PATTERN); + } + // Дом в равнине + if (biome === 'plains' && ((gx % 150 + 150) % 150) === 33 && sgy < SEA_GY) { + placeStructure(gx, sgy - 4, HOUSE_PATTERN); + } + // Хижина в болоте + if (biome === 'swamp' && ((gx % 180 + 180) % 180) === 55 && sgy < SEA_GY) { + placeStructure(gx, sgy - 3, HUT_PATTERN); + } + + // Применяем серверные оверрайды для этой колонны + 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); +} + +// === audio/sound-engine.js === +// Звуковой движок +const 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'); + +function playSound(id) { + if (sounds[id]) { + sounds[id].currentTime = 0; + sounds[id].play().catch(e => console.error('Sound error:', e)); + } +} + +// === ui/chat.js === +// ==================== ЧАТ ==================== + + + + +const chatMessages = []; + +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 (state.isMultiplayer && state.socket && state.socket.connected) { + state.socket.emit('chat_message', { message: message.trim() }); + } else { + addChatMessage('Вы', 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 = ''; + } + }); +} + +// === entities/player.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 }; +} + +// === data/items.js === +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:'🟤' }; + +// === data/tools.js === +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 } } +}; + + + +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 }); +} + +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 +} + +// === render/textures.js === +// Текстуры блоков (простые) + + +const 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'; + 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; +} + +function initTextures() { + Object.keys(BLOCKS).forEach(k => tex[k] = makeTex(k)); +} + +// === ui/hotbar.js === +// Hotbar UI + + + + + + + +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); + } +} + +// === render/particles.js === +// Частицы (взрыв) +const 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' + }); + } +} + +// === world/tnt.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); +} + +// === game/save.js === +// ==================== СИСТЕМА СОХРЕНЕНИЯ ИГРЫ ==================== + + + + + +let db = null; // Оставляем для совместимости, но не используем + +// Инициализация (localStorage + in-memory fallback) +function initDB() { + return new Promise((resolve) => { + console.log('Используем localStorage для сохранений (sandbox режим)'); + resolve(null); + }); +} + +function saveGame() { + // В MP не сохраняем мир — сервер хранит блоки и seed + if (state.isMultiplayer) { + console.log('[MP] Skipping world save — server is authoritative'); + return; + } + + 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, + dayNightCycle: state.dayNightCycle, + // Сохраняем только изменения + 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} байт)`); + } +} + +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 +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); + console.log('isMultiplayer:', state.isMultiplayer); + + // Миграция версий + if (saveData.version === 1) { + migrateV1toV2(saveData); + } + + // В MP режиме сервер — истина, не даём локальному сейву перетирать мир + if (state.isMultiplayer) { + console.log('[MP] Skipping world seed/block restore from local save — server is authoritative'); + // Восстанавливаем только игрока и инвентарь, мир не трогаем + if (saveData.player) { + state.player.hunger = saveData.player.hunger; + state.player.o2 = saveData.player.o2; + state.player.hp = (saveData.player.hp > 0) ? saveData.player.hp : 100; + console.log('Restored player state from save (hp, hunger, o2)'); + } + if (saveData.inventory) { + for (const key in saveData.inventory) { + state.inv[key] = saveData.inventory[key]; + } + console.log('Restored inventory from save'); + } + // Fill recentItems from inventory if empty + if (state.recentItems.length === 0) { + state.recentItems = Object.keys(state.inv).filter(id => state.inv[id] > 0).slice(0, 5); + } + rebuildHotbar(); + console.log('=== applySave END (MP mode, world skipped) ==='); + return; + } + + // Восстанавливаем 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; + } + if (saveData.dayNightCycle !== undefined) { + state.dayNightCycle = saveData.dayNightCycle; + } + + // Перегенерируем мир по 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 || []; + } + + // Fill recentItems from inventory if empty (hotbar won't render otherwise) + if (state.recentItems.length === 0) { + state.recentItems = Object.keys(state.inv).filter(id => state.inv[id] > 0).slice(0, 5); + } + rebuildHotbar(); + console.log('Игра загружена'); +} + +// === ui/save-controls.js === +// ==================== МЕНЮ ⋯ + СОХРАНЕНИЕ / СБРОС ==================== + + + + +let dropdownOpen = false; + +function initSaveControls() { + const menuBtn = document.getElementById('menuBtn'); + const menuDropdown = document.getElementById('menuDropdown'); + const saveItem = document.getElementById('saveItem'); + const resetItem = document.getElementById('resetItem'); + const marketItem = document.getElementById('marketItem'); + const settingsItem = document.getElementById('settingsItem'); + + // Убить возможный старый onclick (предотвращает race condition) + menuBtn.onclick = null; + + // Тогл меню + menuBtn.addEventListener('click', (e) => { + e.stopPropagation(); + dropdownOpen = !dropdownOpen; + menuDropdown.classList.toggle('open', dropdownOpen); + }); + + // Закрытие по клику вне + document.addEventListener('click', () => { + if (dropdownOpen) { + dropdownOpen = false; + menuDropdown.classList.remove('open'); + } + }); + + // Предотвращаем закрытие при клике внутри дропдауна + menuDropdown.addEventListener('click', (e) => { + e.stopPropagation(); + }); + + // 💾 Сохранить + saveItem.addEventListener('click', () => { + playSound('click'); + saveGame(); + dropdownOpen = false; + menuDropdown.classList.remove('open'); + alert('Игра сохранена!'); + }); + + // 🔄 Новый мир + resetItem.addEventListener('click', () => { + if (confirm('Вы уверены, что хотите удалить сохранение и начать новую игру?')) { + playSound('click'); + + try { + localStorage.removeItem(state.SAVE_KEY); + console.log('Сохранение удалено из localStorage'); + } catch (e) { + console.warn('Ошибка удаления сохранения:', e); + } + + state.inMemorySave = null; + state.worldId = Math.random().toString(36).substring(2, 10); + console.log('Новый worldId после сброса:', state.worldId); + + try { + const newUrl = new URL(window.location.href); + newUrl.searchParams.set('world', state.worldId); + if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') { + window.history.replaceState(null, '', newUrl.toString()); + console.log('URL обновлён:', newUrl.toString()); + } + } catch (e) { + console.error('Ошибка обновления URL:', e); + } + + location.reload(); + } else { + dropdownOpen = false; + menuDropdown.classList.remove('open'); + } + }); + + // 🏪 Рынок + marketItem.addEventListener('click', () => { + dropdownOpen = false; + menuDropdown.classList.remove('open'); + // toggleMarket импортируется в main.js, вызовем через кастомное событие + document.dispatchEvent(new CustomEvent('toggleMarket')); + }); + + // ⚙️ Настройки + if (settingsItem) { + settingsItem.addEventListener('click', () => { + dropdownOpen = false; + menuDropdown.classList.remove('open'); + document.dispatchEvent(new CustomEvent('toggleSettings')); + }); + } +} + +// Показываем/скрываем пункт сохранения в зависимости от режима +function updateSaveButtonVisibility() { + const saveItem = document.getElementById('saveItem'); + if (!saveItem) return; + if (state.isMultiplayer && state.otherPlayers.size > 0) { + saveItem.style.display = 'none'; + } else { + saveItem.style.display = 'block'; + } +} + +// === multiplayer/socket-helpers.js === +// Socket helpers: цвет, отправка позиции/блоков + + + +// Генерация случайного цвета для игрока на основе 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 для отправки позиции (10-20 раз в секунду) +let lastMoveSendTime = 0; +const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду +let lastSentX = 0, lastSentY = 0; + +// Отправка позиции игрока (с throttle) +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 }); +} + +// Отправка изменения блока +function sendBlockChange(gx, gy, t, op) { + if (!state.isMultiplayer || !state.socket || !state.socket.connected) return; + + state.socket.emit('block_change', { gx, gy, t, op }); +} + +// === multiplayer/socket.js === +// Socket.IO клиент — инициализация и все обработчики + + + + + + + + + + + + + + +let 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 }); + + // Показываем в 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; + } + // Погода с сервера (мультиплеер) + if (data.weatherType) { + state.weatherType = data.weatherType; + state.weatherChangeInterval = Infinity; + state.isRaining = (data.weatherType === 'rain' || data.weatherType === 'storm' || data.weatherType === 'thunder'); + } + + // Всегда считаем 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_sync', (data) => { + if (data.time !== undefined) { + state.worldTime = data.time; + state.isNightTime = state.worldTime > 0.5; + } + }); + + // Погода с сервера (мультиплеер) + socket.on('weather_update', (data) => { + if (!state.isMultiplayer) return; + if (data.type) { + state.weatherType = data.type; + state.weatherChangeInterval = Infinity; + state.isRaining = (data.type === 'rain' || data.type === 'storm' || data.type === 'thunder'); + } + }); + + // ==================== РЫНОК ==================== + socket.on('market_update', (data) => { + // Update local market cache if needed + // The market UI will refresh automatically when opened + }); + + socket.on('market_error', (data) => { + alert(data.message || 'Ошибка рынка'); + }); + + socket.on('market_accepted', (data) => { + // Notify user about successful trade + if (data.gotItem && data.gaveItem) { + const gotName = BLOCKS[data.gotItem]?.n || ITEMS[data.gotItem]?.n || TOOLS[data.gotItem]?.n || data.gotItem; + const gaveName = BLOCKS[data.gaveItem]?.n || ITEMS[data.gaveItem]?.n || TOOLS[data.gaveItem]?.n || data.gaveItem; + + if (data.gotItem === 'iron_armor') gotName = 'Железная броня'; + if (data.gaveItem === 'iron_armor') gaveName = 'Железная броня'; + + alert(`Обмен успешен! Вы получили: ${gotName} ×${data.gotQty}\nВы отдали: ${gaveName} ×${data.gaveQty}`); + } else { + alert('Обмен успешен!'); + } + + // Refresh market if open + if (state.marketPanel && state.marketPanel.style.display === 'block') { + renderMarket(); + } + }); + } catch (e) { + console.error('Error initializing socket:', e); + state.isMultiplayer = false; + } +} +} + +// === world/water.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); + } + } +} + +// === physics/water-detect.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'); + } +} + +// === physics/collision.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; + } + } +} + +// === entities/mob-ai.js === +// Моб 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((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); +} + +function isNight() { + // Автоматический цикл: ночь когда worldTime > 0.5 + return state.worldTime > 0.5; +} + +// === data/recipes.js === +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 } // булыжник → камень +]; + +// === ui/furnace.js === +// ==================== ПЕЧЬ (ОБЖИГ) ==================== + + + + + + + + +const furnacePanel = document.getElementById('furnacePanel'); +const 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 = 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(); +}; + +// Тик печей — вызывается в главном цикле +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(); + } + } + } +} + +// === world/weather.js === +// ==================== ПОГОДА ==================== + + + +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; +} + +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); + } + } +} + +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(); +} + +// === entities/mobs.js === +// Сущности: животные + зомби + +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; + } +} + +class Pig extends Entity { + constructor(x, y) { super(x, y, 34, 24); this.kind = 'pig'; this.hp = 2; } +} + +class Chicken extends Entity { + constructor(x, y) { super(x, y, 26, 22); this.kind = 'chicken'; this.hp = 1; } +} + +class Zombie extends Entity { + constructor(x, y) { super(x, y, 34, 50); this.kind = 'zombie'; this.hp = 4; this.speed = 80 + Math.random() * 40; } +} + +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; } +} + +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; } +} + +// === ui/minimap.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' +}; + +function initMinimap() { + document.getElementById('mapToggle').onclick = () => { + playSound('click'); + state.minimapOpen = !state.minimapOpen; + minimapWrap.style.display = state.minimapOpen ? 'block' : 'none'; + }; +} + +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); + } + } +} + +// === game/modes.js === +// ==================== РЕЖИМЫ ==================== + + + +const MODES = [{ id: 'mine', icon: '⛏️' }, { id: 'build', icon: '🧱' }]; + + + +function mode() { + return MODES[state.modeIdx].id; +} + +function initModes() { + const modeBtn = document.getElementById('modeBtn'); + modeBtn.onclick = () => { + playSound('click'); // Звук клика по кнопке режима + state.modeIdx = (state.modeIdx + 1) % MODES.length; + modeBtn.textContent = MODES[state.modeIdx].icon; + }; +} + +// === input/mouse-handler.js === +// ==================== ВЗАИМОДЕЙСТВИЕ МЫШЬ/ТАП ==================== + + + + + + + + + + + + + + + +const mouse = { x: null, y: null }; + +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; + } + }); +} + +// === render/draw-fire.js === +// Рисование костра: огонь поверх текстуры +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(); +} + +// === game/loop.js === +// ==================== ГЛАВНЫЙ ИГРОВОЙ ЦИКЛ ==================== + + + + + + + + + + + + + + + + + + + + + + + +// ==================== LOOP ==================== +let last = performance.now(); +let prevJump = false; +// При возврате на вкладку — сбрасываем last чтобы не было скачка dt +document.addEventListener('visibilitychange', () => { + if (!document.hidden) last = performance.now(); +}); + + + +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; + + // В MP время синхронизируется с сервера + if (state.isMultiplayer) { + // Сон всё ещё работает локально (только лечение, без ускорения времени) + if (player.sleeping && isNight()) { + player.hp = Math.min(100, player.hp + dt * 20); + if (!isNight()) { + player.sleeping = false; + } + } + } else { + // Ускорение времени во время сна + 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); + + // Погода и дождь (в MP погода с сервера) + if (!state.isMultiplayer) { 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); +} + +// === ui/market.js === +// ==================== РЫНОК ПРЕДЛОЖЕНИЙ ==================== + + + +const marketPanel = document.getElementById('marketPanel'); +const marketClose = document.getElementById('marketClose'); +const marketContent = document.getElementById('marketContent'); + +function initMarket() { + // Кнопка закрытия + marketClose.addEventListener('click', () => closeMarket()); + + // Слушаем событие от save-controls.js + document.addEventListener('toggleMarket', () => toggleMarket()); +} + +function toggleMarket() { + if (marketPanel.style.display === 'none' || !marketPanel.style.display) { + openMarket(); + } else { + closeMarket(); + } +} + +function closeMarket() { + marketPanel.style.display = 'none'; +} + +function openMarket() { + marketPanel.style.display = 'block'; + renderMarket(); +} + +function renderMarket() { + if (!state.socket) { + marketContent.innerHTML = '

Подключение к серверу...

'; + return; + } + + // Запрашиваем список ордеров с сервера + state.socket.emit('market_list', {}, (orders) => { + if (!orders || !orders.length) { + marketContent.innerHTML = ` +

Предложений пока нет

+
+ +
+ `; + const btn = document.getElementById('createOrderBtn'); + if (btn) btn.addEventListener('click', showCreateOrder); + return; + } + + let html = '
'; + + html += orders.map(o => { + // Determine if current player can accept this order (not their own) + const isOwnOrder = o.seller_id === state.mySocketId; + const canAccept = !isOwnOrder && state.isMultiplayer; + + return ` +
+
+
+ ${BLOCKS[o.offer_item] ? `` : ''} + ${o.offer_item} ×${o.offer_qty} +
+ ⇄ +
+ ${BLOCKS[o.want_item] ? `` : ''} + ${o.want_item} ×${o.want_qty} +
+
+
+
${o.seller || 'Аноним'}
+ ${canAccept ? + `` : + isOwnOrder ? + `` : + `Ваше предложение` + } +
+
+ `; + }).join(''); + + marketContent.innerHTML = html; + + // Add event listeners for accept/cancel buttons + document.querySelectorAll('.accept-order-btn').forEach(btn => { + btn.addEventListener('click', () => { + const orderId = btn.getAttribute('data-order-id'); + if (state.socket) { + state.socket.emit('market_accept', { orderId: orderId }); + } + }); + }); + + document.querySelectorAll('.cancel-order-btn').forEach(btn => { + btn.addEventListener('click', () => { + const orderId = btn.getAttribute('data-order-id'); + if (state.socket) { + state.socket.emit('market_cancel', { id: orderId }); + } + }); + }); + + const createBtn = document.getElementById('createOrderBtn'); + if (createBtn) createBtn.addEventListener('click', showCreateOrder); + }); +} + +function showCreateOrder() { + const invItems = Object.keys(state.inv).filter(id => state.inv[id] > 0); + + // Create item options with images for offer + const offerOptions = invItems.map(id => { + const count = state.inv[id]; + let itemHTML = ''; + if (BLOCKS[id]) { + itemHTML = ``; + } else if (ITEMS[id]) { + itemHTML = `${ITEMS[id].icon}`; + } else if (TOOLS[id]) { + itemHTML = `${TOOLS[id].icon}`; + } else if (id === 'iron_armor') { + itemHTML = `🛡️`; + } + return ``; + }).join(''); + + marketContent.innerHTML = ` +
+

Выставить предмет на обмен

+ +
+ + +
+ + + +
+
+ +
+ + +
+ + +
+
+ +
+ + +
+
+ `; + + // Update max count when offer item changes + const offerSelect = document.getElementById('offerItem'); + const offerCountInput = document.getElementById('offerCount'); + const offerMaxCountSpan = document.getElementById('offerMaxCount'); + + function updateOfferMax() { + const selectedId = offerSelect.value; + const maxCount = state.inv[selectedId] || 0; + offerCountInput.max = maxCount; + offerCountInput.value = Math.min(parseInt(offerCountInput.value) || 1, maxCount); + offerMaxCountSpan.textContent = `Максимум: ${maxCount}`; + + // If current value exceeds max, adjust it + if (parseInt(offerCountInput.value) > maxCount) { + offerCountInput.value = maxCount; + } + } + + offerSelect.addEventListener('change', updateOfferMax); + offerCountInput.addEventListener('input', () => { + const val = parseInt(offerCountInput.value) || 1; + const max = parseInt(offerSelect.value ? (state.inv[offerSelect.value] || 0) : 0); + if (val > max) offerCountInput.value = max; + }); + + // Initialize + if (offerSelect.options.length > 0) { + offerSelect.selectedIndex = 0; + updateOfferMax(); + } + + document.getElementById('cancelOrder').addEventListener('click', () => renderMarket()); + document.getElementById('submitOrder').addEventListener('click', () => { + const offerItem = offerSelect.value; + const offerQty = parseInt(offerCountInput.value) || 1; + const wantItem = document.getElementById('wantItem').value; + const wantQty = parseInt(document.getElementById('wantCount').value) || 1; + + if (!offerItem || !wantItem) { + alert('Выберите оба предмета'); + return; + } + + const offerMax = state.inv[offerItem] || 0; + if (offerQty > offerMax) { + alert(`Недостаточно предметов. Доступно: ${offerMax}`); + return; + } + + if (state.socket) { + // Lock items for trade + state.socket.emit('market_lock_items', { + offer_item: offerItem, + offer_qty: offerQty, + want_item: wantItem, + want_qty: wantQty + }, (result) => { + if (result.success) { + // Items locked, now create the offer + state.socket.emit('market_create', { + offer_item: offerItem, + offer_qty: offerQty, + want_item: wantItem, + want_qty: wantQty + }); + playSound('click'); + setTimeout(renderMarket, 500); + } else { + alert(result.message || 'Не удалось заблокировать предметы для обмена'); + } + }); + } + }); +} + +// === ui/respawn.js === +// ==================== RESPAWN ==================== + + + + +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 ==='); + }; +} + +// === ui/share.js === +// ==================== ПОДЕЛИТЬСЯ МИРОМ ==================== + + +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); + } + }; +} + +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); + } +} + +// === input/controls.js === +// ==================== УПРАВЛЕНИЕ ==================== + + +const 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; + }); +} + +// === multiplayer/voice-chat.js === +// Голосовой чат + + + +let voiceSocket = null; +let voiceStream = null; +let audioCtx = null; +let voiceProcessor = null; +let voiceActive = false; +let voiceMode = 'near'; // 'near' — 600px radius, 'world' — global +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 voiceModeBtn = document.createElement('div'); +voiceModeBtn.innerHTML = '📢'; +voiceModeBtn.title = 'Режим: рядом (600px)'; +voiceModeBtn.style.cssText = 'position:absolute;top:74px;right:190px;width:48px;height:52px;border-radius:12px;background:#3498db;z-index:200;display:flex;align-items:center;justify-content:center;font-size:20px;cursor:pointer;border:2px solid rgba(255,255,255,0.7);box-shadow:0 4px 0 rgba(0,0,0,0.5);pointer-events:auto;color:#fff;font-weight:bold;'; +document.querySelector('.ui').appendChild(voiceModeBtn); + +voiceModeBtn.onclick = () => { + if (voiceMode === 'near') { + voiceMode = 'world'; + voiceModeBtn.innerHTML = '🌍'; + voiceModeBtn.title = 'Режим: весь мир'; + voiceModeBtn.style.background = '#e67e22'; + } else { + voiceMode = 'near'; + voiceModeBtn.innerHTML = '📢'; + voiceModeBtn.title = 'Режим: рядом (600px)'; + voiceModeBtn.style.background = '#3498db'; + } + if (voiceSocket && voiceSocket.connected) { + voiceSocket.emit('voice_mode', { mode: voiceMode }); + } +}; + +// Индикатор говорящего +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 || 'Игрок', mode: voiceMode }); + }); + + 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'; + } +}; + +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 }); + } + } + }; +} + +// === ui/inventory.js === +// Inventory UI + + + + + + + + +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); + } +} + +// === ui/craft.js === +// Craft UI + + + + + + + + + + + +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; +} + +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; + +function toggleCraft() { + playSound('click'); // Звук клика по кнопке + craftOpen = !craftOpen; + state.craftPanel.style.display = craftOpen ? 'block' : 'none'; + if (craftOpen) { + renderCraft(); + // Закрываем инвентарь если открыт крафт + inventoryOpen = false; + state.inventoryPanel.style.display = 'none'; + } +} + +function closeCraft() { + playSound('click'); // Звук клика по кнопке + craftOpen = false; + state.craftPanel.style.display = 'none'; +} + +function toggleInventory() { + playSound('click'); // Звук клика по кнопке + inventoryOpen = true; + state.inventoryPanel.style.display = 'block'; + renderInventory(); + // Закрываем крафт если открыт инвентарь + craftOpen = false; + state.craftPanel.style.display = 'none'; +} + +function closeInventory() { + playSound('click'); // Звук клика по кнопке + inventoryOpen = false; + state.inventoryPanel.style.display = 'none'; +} + +// === main.js === +// ==================== ENTRY POINT ==================== + + + + + + + + + + + + + + + + + + + + + + + + + +// ==================== 1) CONFIG — parse URL ==================== +const urlParams = new URLSearchParams(window.location.search); +state.SERVER_URL = SERVER_URL; +state.TELEGRAM_BOT_USERNAME = 'Grechkacraft_bot'; +state.TELEGRAM_APP_SHORT_NAME = 'minegrechka'; + +// Set TG-related state +if (isTgWebApp) { + state.myTgId = tgUser?.id || null; +} + +state.heroImg.src = 'https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png'; + +// ==================== 2) START MENU ==================== +// Show the start menu before initializing the game. +// The menu returns a Promise that resolves with the worldId. +async function startGame() { + const worldId = await showStartMenu(); + + // Set worldId and playerName + state.worldId = worldId; + if (!state.playerName) { + state.playerName = localStorage.getItem('minegrechka_playerName') || 'Игрок'; + localStorage.setItem('minegrechka_playerName', state.playerName); + } + console.log('[Launch] worldId:', state.worldId, 'player:', state.playerName, 'tg:', isTgWebApp); + + // Validate TG initData if in TG context + if (isTgWebApp && window.Telegram && Telegram.WebApp.initData) { + try { + const resp = await fetch(state.SERVER_URL + '/api/tg/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ initData: Telegram.WebApp.initData }) + }); + const data = await resp.json(); + if (data.ok && data.tg_id) { + console.log('[TG] Authenticated, tg_id:', data.tg_id); + state.myTgId = data.tg_id; + } + } catch (e) { console.warn('[TG] Auth failed:', e); } + } + + // Update URL with world code + try { + const newUrl = new URL(window.location.href); + newUrl.searchParams.set('world', state.worldId); + if (typeof history !== 'undefined' && history.replaceState) { + history.replaceState(null, '', newUrl.toString()); + } + } catch (e) {} + + // ==================== 3) CANVAS — init from canvas module ==================== + initCanvas(); + + // ==================== 4) STATE — set DOM refs ==================== + 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'); + state.hotbarEl = document.getElementById('hotbar'); + state.craftPanel = document.getElementById('craftPanel'); + state.recipesEl = document.getElementById('recipes'); + state.inventoryPanel = document.getElementById('inventoryPanel'); + state.inventoryGrid = document.getElementById('inventoryGrid'); + state.marketPanel = document.getElementById('marketPanel'); + state.marketContent = document.getElementById('marketContent'); + state.tradePanel = document.getElementById('tradePanel'); + state.tradeContent = document.getElementById('tradeContent'); + state.multiplayerStatus = document.getElementById('multiplayerStatus'); + + // ==================== 5) TEXTURES — initTextures() ==================== + initTextures(); + state.tex = tex; + state.itemTex = itemTex; + + // ==================== 6) CRAFT/INVENTORY BUTTONS ==================== + const craftBtn = document.getElementById('craftBtn'); + const invToggle = document.getElementById('invToggle'); + const craftClose = document.getElementById('craftClose'); + const inventoryClose = document.getElementById('inventoryClose'); + + if (craftBtn) craftBtn.onclick = () => toggleCraft(); + if (craftClose) craftClose.onclick = () => closeCraft(); + if (invToggle) invToggle.onclick = () => toggleInventory(); + if (inventoryClose) inventoryClose.onclick = () => closeInventory(); + + // ==================== 7) CONTROLS — initControls ==================== + initControls(); + + // ==================== 8) MOUSE — initMouseHandlers ==================== + initMouseHandlers(); + + // ==================== 9) UI MODULES — init all UI ==================== + initChat(); + initFurnace(); + initMinimap(); + initSaveControls(); + initMarket(); + initRespawn(); + initShare(); + initModes(); + + // ==================== 10) SOCKET — initSocket ==================== + initSocket(); + + // ==================== 11) VOICE — initVoice ==================== + initVoice(); + + // ==================== 12) 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); +} + +// Start the game +startGame().catch(err => { + console.error('Fatal error starting game:', err); +}); + +})(); diff --git a/game3d.js b/game3d.js index 518e447..5dadef0 100644 --- a/game3d.js +++ b/game3d.js @@ -8,7 +8,12 @@ const TILE = 40; // Мир -const SEA_GY = 14; // уровень воды (gy) - уменьшил для меньших водоёмов +// Уровень моря. Было 14 при фактической поверхности 4..12: вода была ВЫШЕ +// всей суши, заливала мир, игрок всегда стоял в воде (gy=6 под водой 7..9), +// камера схлопывалась в первое лицо, и кадр показывал пустое небо. +// Ставим 3 — заметно ниже любой реальной поверхности, вода появляется +// только в настоящих низинах, а не везде. +const SEA_GY = 3; const BEDROCK_GY = 140; // глубина бедрока (gy), чем больше - тем глубже const GEN_MARGIN_X = 40; // запас генерации по X (в тайлах) — увеличен для плавной прогрузки @@ -178,7 +183,12 @@ const state = { armor: 0, equippedArmor: null, temperature: 15, - slowTimer: 0 + slowTimer: 0, + // Опыт и уровень. Раньше этих полей не было вовсе: элементы #xplevel + // и #xpbar были в HTML, но player.xp нигде не рос, и HUD вечно показывал + // «Lv.1 | 0/50». Порог уровня — 50 * level. + xp: 0, + level: 1 }, // Точка спавна @@ -221,7 +231,10 @@ const state = { }, // Ввод - inp: { up: false, down: false, left: false, right: false, jump: false, mine: false, build: false, bow: false, l: false, r: false, j: false, s: false }, + inp: { up: false, down: false, left: false, right: false, jump: false, mine: false, build: false, bow: false, l: false, r: false, j: false, s: false, + ax: 0, ay: 0, // аналоговые оси джойстика (-1..1) + kfwd: 0, kside: 0, // клавиатура: вперёд/назад и влево/вправо (-1..1) + mineHeld: false }, // Мышь mouse: { x: 0, y: 0 }, @@ -290,6 +303,63 @@ const state = { inMemorySave: null, }; +// === data/blocks.js === +const BLOCKS = { + air: { n:'Воздух', solid:false, hardness:0 }, + grass: { n:'Трава', c:'#5da42a', solid:true, hardness:3, toolType:'shovel' }, + dirt: { n:'Грязь', c:'#8a5a2b', solid:true, hardness:3, toolType:'shovel' }, + stone: { n:'Камень', c:'#7f8c8d', solid:true, hardness:6, toolType:'pickaxe' }, + sand: { n:'Песок', c:'#e0c070', solid:true, hardness:2, toolType:'shovel' }, + gravel: { n:'Гравий', c:'#95a5a6', solid:true, hardness:3, toolType:'shovel' }, + clay: { n:'Глина', c:'#74b9ff', solid:true, hardness:3, toolType:'shovel' }, + wood: { n:'Дерево', c:'#d35400', solid:true, hardness:4, toolType:'axe' }, + planks: { n:'Доски', c:'#e67e22', solid:true, hardness:3, toolType:'axe' }, + ladder: { n:'Лестница',c:'#d35400', solid:false, climbable:true, hardness:1, toolType:null }, + leaves: { n:'Листва', c:'#2ecc71', solid:true, hardness:1, toolType:'axe', minTool:null }, + glass: { n:'Стекло', c:'rgba(200,240,255,0.25)', solid:true, alpha:0.55, hardness:1, toolType:null }, + water: { n:'Вода', c:'rgba(52,152,219,0.55)', solid:false, fluid:true, hardness:0 }, + coal: { n:'Уголь', c:'#2c3e50', solid:true, hardness:8, toolType:'pickaxe', minTool:'wood' }, + copper_ore:{ n:'Медь', c:'#e17055', solid:true, hardness:10, toolType:'pickaxe', minTool:'wood' }, + iron_ore: { n:'Железо', c:'#dcdde1', solid:true, hardness:12, toolType:'pickaxe', minTool:'stone' }, + iron_armor:{ n:'Железная броня', c:'#95a5a6', solid:false, armor:0.5, hardness:0 }, + gold_ore: { n:'Золото', c:'#f1c40f', solid:true, hardness:15, toolType:'pickaxe', minTool:'stone' }, + diamond_ore:{n:'Алмаз', c:'#00a8ff', solid:true, hardness:20, toolType:'pickaxe', minTool:'iron' }, + brick: { n:'Кирпич', c:'#c0392b', solid:true, hardness:8, toolType:'pickaxe' }, + tnt: { n:'TNT', c:'#e74c3c', solid:true, explosive:true, hardness:1, toolType:null }, + campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius:280, hardness:2, toolType:null }, + torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:220, hardness:1, toolType:null }, + bedrock: { n:'Бедрок', c:'#2d3436', solid:true, unbreakable:true, hardness:Infinity }, + flower: { n:'Цветок', c:'#ff4757', solid:false, decor:true, hardness:0 }, + bed: { n:'Кровать', c:'#e91e63', solid:true, bed:true, hardness:2, toolType:'axe' }, + boat: { n:'Лодка', c:'#8B4513', solid:false, hardness:0 }, + furnace: { n:'Печь', c:'#696969', solid:true, smelting:true, hardness:8, toolType:'pickaxe', minTool:'wood' }, + cobblestone: { n:'Булыжник', c:'#7f8c8d', solid:true, hardness:6, toolType:'pickaxe' }, + flag: { n:'Флаг', c:'#e74c3c', solid:true, hardness:1, toolType:'axe', protectionRadius:10 }, + // === BIOME BLOCKS === + snow: { n:'Снег', c:'#cfdbe4', solid:true, hardness:1, toolType:'shovel' }, + ice: { n:'Лёд', c:'#74b9ff', solid:true, hardness:1, slip:true, toolType:'pickaxe' }, + cactus: { n:'Кактус', c:'#27ae60', solid:true, hardness:1, hurt:true, toolType:null }, + mushroom: { n:'Гриб', c:'#e74c3c', solid:false, decor:true, hardness:0 }, + moss: { n:'Мох', c:'#0a6640', solid:true, hardness:1, toolType:null }, + swamp_water:{ n:'Болотная вода', c:'rgba(100,120,40,0.6)', solid:false, fluid:true, poison:true, hardness:0 }, + farmland: { n:'Грядка', c:'#8B6914', solid:true, hardness:1, farmable:true, toolType:'shovel' }, + dead_bush: { n:'Сухой куст', c:'#b2bec3', solid:false, decor:true, hardness:0 }, + spruce_leaves:{ n:'Ель', c:'#0a6640', solid:true, hardness:1, toolType:'axe' }, + // === CROP STAGES === + wheat_stage0:{ n:'Росток пшеницы', c:'#a8e6a0', solid:false, decor:true, hardness:0, growthStage:0, growthTime:30, growsTo:'wheat_stage1' }, + wheat_stage1:{ n:'Пшеница', c:'#7dcea0', solid:false, decor:true, hardness:0, growthStage:1, growthTime:30, growsTo:'wheat_stage2' }, + wheat_stage2:{ n:'Пшеница', c:'#b8d730', solid:false, decor:true, hardness:0, growthStage:2, growthTime:30, growsTo:'wheat_stage3' }, + wheat_stage3:{ n:'Пшеница', c:'#f1c40f', solid:false, decor:true, hardness:0, harvestable:true, harvestItem:'wheat', harvestQty:2 }, + carrot_stage0:{ n:'Росток моркови', c:'#a8e6a0', solid:false, decor:true, hardness:0, growthStage:0, growthTime:30, growsTo:'carrot_stage1' }, + carrot_stage1:{ n:'Морковь', c:'#f0c27a', solid:false, decor:true, hardness:0, growthStage:1, growthTime:30, growsTo:'carrot_stage2' }, + carrot_stage2:{ n:'Морковь', c:'#e8a040', solid:false, decor:true, hardness:0, growthStage:2, growthTime:30, growsTo:'carrot_stage3' }, + carrot_stage3:{ n:'Морковь', c:'#e67e22', solid:false, decor:true, hardness:0, harvestable:true, harvestItem:'carrot', harvestQty:3 }, + potato_stage0:{ n:'Росток картофеля', c:'#a8e6a0', solid:false, decor:true, hardness:0, growthStage:0, growthTime:30, growsTo:'potato_stage1' }, + potato_stage1:{ n:'Картофель', c:'#c8b888', solid:false, decor:true, hardness:0, growthStage:1, growthTime:30, growsTo:'potato_stage2' }, + potato_stage2:{ n:'Картофель', c:'#bfaa78', solid:false, decor:true, hardness:0, growthStage:2, growthTime:30, growsTo:'potato_stage3' }, + potato_stage3:{ n:'Картофель', c:'#dfe6e9', solid:false, decor:true, hardness:0, harvestable:true, harvestItem:'potato', harvestQty:2 }, +}; + // === config.js === // ==================== КОНФИГУРАЦИЯ СЕРВЕРА ==================== // This module only sets SERVER_URL. Player name and world ID are handled in main.js @@ -914,62 +984,75 @@ function getCtx() { return null; } function getLightC() { return null; } function getLightCtx() { return null; } -// === data/blocks.js === -const BLOCKS = { - air: { n:'Воздух', solid:false, hardness:0 }, - grass: { n:'Трава', c:'#7cfc00', solid:true, hardness:3, toolType:'shovel' }, - dirt: { n:'Грязь', c:'#8b4513', solid:true, hardness:3, toolType:'shovel' }, - stone: { n:'Камень', c:'#7f8c8d', solid:true, hardness:6, toolType:'pickaxe' }, - sand: { n:'Песок', c:'#f4d06f', solid:true, hardness:2, toolType:'shovel' }, - gravel: { n:'Гравий', c:'#95a5a6', solid:true, hardness:3, toolType:'shovel' }, - clay: { n:'Глина', c:'#74b9ff', solid:true, hardness:3, toolType:'shovel' }, - wood: { n:'Дерево', c:'#d35400', solid:true, hardness:4, toolType:'axe' }, - planks: { n:'Доски', c:'#e67e22', solid:true, hardness:3, toolType:'axe' }, - ladder: { n:'Лестница',c:'#d35400', solid:false, climbable:true, hardness:1, toolType:null }, - leaves: { n:'Листва', c:'#2ecc71', solid:true, hardness:1, toolType:'axe', minTool:null }, - glass: { n:'Стекло', c:'rgba(200,240,255,0.25)', solid:true, alpha:0.55, hardness:1, toolType:null }, - water: { n:'Вода', c:'rgba(52,152,219,0.55)', solid:false, fluid:true, hardness:0 }, - coal: { n:'Уголь', c:'#2c3e50', solid:true, hardness:8, toolType:'pickaxe', minTool:'wood' }, - copper_ore:{ n:'Медь', c:'#e17055', solid:true, hardness:10, toolType:'pickaxe', minTool:'wood' }, - iron_ore: { n:'Железо', c:'#dcdde1', solid:true, hardness:12, toolType:'pickaxe', minTool:'stone' }, - iron_armor:{ n:'Железная броня', c:'#95a5a6', solid:false, armor:0.5, hardness:0 }, - gold_ore: { n:'Золото', c:'#f1c40f', solid:true, hardness:15, toolType:'pickaxe', minTool:'stone' }, - diamond_ore:{n:'Алмаз', c:'#00a8ff', solid:true, hardness:20, toolType:'pickaxe', minTool:'iron' }, - brick: { n:'Кирпич', c:'#c0392b', solid:true, hardness:8, toolType:'pickaxe' }, - tnt: { n:'TNT', c:'#e74c3c', solid:true, explosive:true, hardness:1, toolType:null }, - campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius:280, hardness:2, toolType:null }, - torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:220, hardness:1, toolType:null }, - bedrock: { n:'Бедрок', c:'#2d3436', solid:true, unbreakable:true, hardness:Infinity }, - flower: { n:'Цветок', c:'#ff4757', solid:false, decor:true, hardness:0 }, - bed: { n:'Кровать', c:'#e91e63', solid:true, bed:true, hardness:2, toolType:'axe' }, - boat: { n:'Лодка', c:'#8B4513', solid:false, hardness:0 }, - furnace: { n:'Печь', c:'#696969', solid:true, smelting:true, hardness:8, toolType:'pickaxe', minTool:'wood' }, - cobblestone: { n:'Булыжник', c:'#7f8c8d', solid:true, hardness:6, toolType:'pickaxe' }, - flag: { n:'Флаг', c:'#e74c3c', solid:true, hardness:1, toolType:'axe', protectionRadius:10 }, - // === BIOME BLOCKS === - snow: { n:'Снег', c:'#ecf0f1', solid:true, hardness:1, toolType:'shovel' }, - ice: { n:'Лёд', c:'#74b9ff', solid:true, hardness:1, slip:true, toolType:'pickaxe' }, - cactus: { n:'Кактус', c:'#27ae60', solid:true, hardness:1, hurt:true, toolType:null }, - mushroom: { n:'Гриб', c:'#e74c3c', solid:false, decor:true, hardness:0 }, - moss: { n:'Мох', c:'#0a6640', solid:true, hardness:1, toolType:null }, - swamp_water:{ n:'Болотная вода', c:'rgba(100,120,40,0.6)', solid:false, fluid:true, poison:true, hardness:0 }, - farmland: { n:'Грядка', c:'#8B6914', solid:true, hardness:1, farmable:true, toolType:'shovel' }, - dead_bush: { n:'Сухой куст', c:'#b2bec3', solid:false, decor:true, hardness:0 }, - spruce_leaves:{ n:'Ель', c:'#0a6640', solid:true, hardness:1, toolType:'axe' }, - // === CROP STAGES === - wheat_stage0:{ n:'Росток пшеницы', c:'#a8e6a0', solid:false, decor:true, hardness:0, growthStage:0, growthTime:30, growsTo:'wheat_stage1' }, - wheat_stage1:{ n:'Пшеница', c:'#7dcea0', solid:false, decor:true, hardness:0, growthStage:1, growthTime:30, growsTo:'wheat_stage2' }, - wheat_stage2:{ n:'Пшеница', c:'#b8d730', solid:false, decor:true, hardness:0, growthStage:2, growthTime:30, growsTo:'wheat_stage3' }, - wheat_stage3:{ n:'Пшеница', c:'#f1c40f', solid:false, decor:true, hardness:0, harvestable:true, harvestItem:'wheat', harvestQty:2 }, - carrot_stage0:{ n:'Росток моркови', c:'#a8e6a0', solid:false, decor:true, hardness:0, growthStage:0, growthTime:30, growsTo:'carrot_stage1' }, - carrot_stage1:{ n:'Морковь', c:'#f0c27a', solid:false, decor:true, hardness:0, growthStage:1, growthTime:30, growsTo:'carrot_stage2' }, - carrot_stage2:{ n:'Морковь', c:'#e8a040', solid:false, decor:true, hardness:0, growthStage:2, growthTime:30, growsTo:'carrot_stage3' }, - carrot_stage3:{ n:'Морковь', c:'#e67e22', solid:false, decor:true, hardness:0, harvestable:true, harvestItem:'carrot', harvestQty:3 }, - potato_stage0:{ n:'Росток картофеля', c:'#a8e6a0', solid:false, decor:true, hardness:0, growthStage:0, growthTime:30, growsTo:'potato_stage1' }, - potato_stage1:{ n:'Картофель', c:'#c8b888', solid:false, decor:true, hardness:0, growthStage:1, growthTime:30, growsTo:'potato_stage2' }, - potato_stage2:{ n:'Картофель', c:'#bfaa78', solid:false, decor:true, hardness:0, growthStage:2, growthTime:30, growsTo:'potato_stage3' }, - potato_stage3:{ n:'Картофель', c:'#dfe6e9', solid:false, decor:true, hardness:0, harvestable:true, harvestItem:'potato', harvestQty:2 }, -}; +// === world/occ3d.js === +// ==================== ОККЛЮЗИЯ 3D — плотная сетка ==================== +// Границы мира фиксированы генератором: WORLD_DEPTH = 48 по X и Z (то есть +// gx,gz из -48..48), а по Y генерация идёт от BEDROCK_GY-1 = 44 вниз до 0. +// 97 * 45 * 97 = 423 855 клеток — 414 КБ в Uint8Array. +// +// Зачем: isHiddenAt проверяла 6 соседей через getBlock, а тот строил строку +// "gx,gy,gz" и искал в Map. На 68k кандидатов это ~409k строковых +// конкатенаций — профиль показал 129 мс из 153 мс всего перестроения мешей. +// Здесь 6 чтений массива вместо 6 строковых lookup'ов. +// +// ВАЖНО: сетку обязаны обновлять все, кто меняет мир — setBlock/removeBlock +// в world-storage3d.js. Иначе окклюзия разойдётся с реальными блоками и +// внутренние грани начнут просвечивать (или исчезнут видимые блоки). + + +const OCC_X = 2 * 48 + 1; // 97 +const OCC_Z = 2 * 48 + 1; +const OCC_Y = 45; // gy 0..44 +const OCC_MIN = -48, OCC_MAX = 48, OCC_MAX_GY = 44; + +// Значения ячеек +const OCC_EMPTY = 0; // блока нет +const OCC_PRESENT = 1; // блок есть, но он прозрачный/жидкий/декор +const OCC_OPAQUE = 2; // непрозрачный блок — закрывает соседей + +const occGrid = new Uint8Array(OCC_X * OCC_Y * OCC_Z); + +// Раскладка: (gy*OCC_Z + (gz-OCC_MIN))*OCC_X + (gx-OCC_MIN) +// => шаг по gx = 1, по gz = OCC_X, по gy = OCC_X*OCC_Z +const OCC_STEP_X = 1; +const OCC_STEP_Z = OCC_X; +const OCC_STEP_Y = OCC_X * OCC_Z; + +function occIndex(gx, gy, gz) { + if (gx < OCC_MIN || gx > OCC_MAX || gz < OCC_MIN || gz > OCC_MAX || + gy < 0 || gy > OCC_MAX_GY) return -1; + return (gy * OCC_Z + (gz - OCC_MIN)) * OCC_X + (gx - OCC_MIN); +} + +// Плотность блока для оклюзии +function occDensity(t) { + const def = BLOCKS[t]; + if (!def) return OCC_EMPTY; + const opaque = !!def.solid && !def.fluid && !def.decor && !def.climbable && + def.alpha === undefined; + return opaque ? OCC_OPAQUE : OCC_PRESENT; +} + +function occSet(gx, gy, gz, density) { + const i = occIndex(gx, gy, gz); + if (i >= 0) occGrid[i] = density; +} + +// Пересобрать сетку целиком из массива блоков. Нужна при загрузке сейва и +// после полной генерации — там setBlock вызывается с bumpWorld=false, но +// запись в сетку всё равно идёт, так что это страховка на случай, когда +// массив блоков был подменён снаружи. +function occRebuildFrom(blocksArr) { + occGrid.fill(OCC_EMPTY); + for (let i = 0; i < blocksArr.length; i++) { + const b = blocksArr[i]; + if (b.dead) continue; + const idx = occIndex(b.gx, b.gy, b.gz || 0); + if (idx >= 0) occGrid[idx] = occDensity(b.t); + } +} + +function occClear() { occGrid.fill(OCC_EMPTY); } // === world/world-storage3d.js === // ==================== ХРАНИЛИЩЕ МИРА 3D — GrechkaCraft v3 ==================== @@ -978,6 +1061,7 @@ const BLOCKS = { + const grid = state.grid; // Map: "x,y,z" => { gx, gy, gz, t, dead } const blocks = state.blocks; // Array для перебора/рендера @@ -1006,19 +1090,32 @@ function isOpaque(gx, gy, gz) { return !!def.solid && !def.fluid && !def.decor && !def.climbable; } -function setBlock(gx, gy, gz, t, isPlayerPlaced = false) { +// Третий аргумент isPlayerPlaced. Четвёртый — bumpWorld: при генерации мира +// версию рендера дёргать нельзя (тысячи блоков за кадр = полная перестройка +// мешей и дёрганье текстур). Меняем мир только при действиях игрока. +function setBlock(gx, gy, gz, t, isPlayerPlaced = false, bumpWorld = true) { const key = k3(gx, gy, gz); if (grid.has(key)) return false; const b = { gx, gy, gz, t, dead: false, active: false, fuse: 0 }; grid.set(key, b); blocks.push(b); + // Окклюзивная сетка обязана знать про новый блок: без этого внутренние + // грани соседей останутся видны (или пропадут видимые блоки). + occSet(gx, gy, gz, occDensity(t)); if (isPlayerPlaced) { state.placedBlocks.push({ gx, gy, gz, t }); } - state.blocksVersion = (state.blocksVersion || 0) + 1; + if (bumpWorld) { + state.blocksVersion = (state.blocksVersion || 0) + 1; + } return true; } +// Версия рендера: бамп делает только игрок (постановка/разрушение блока) +function touchWorld() { + state.blocksVersion = (state.blocksVersion || 0) + 1; +} + function removeBlock(gx, gy, gz) { const key = k3(gx, gy, gz); const b = grid.get(key); @@ -1026,6 +1123,9 @@ function removeBlock(gx, gy, gz) { if (BLOCKS[b.t].unbreakable) return null; grid.delete(key); b.dead = true; + // Клетка освободилась — сбрасываем окклюзию, иначе пустота продолжит + // считаться непрозрачной и пропадёт блок вокруг дырки. + occSet(gx, gy, gz, OCC_EMPTY); const wasPlayerPlaced = state.placedBlocks.some(pb => pb.gx === gx && pb.gy === gy && pb.gz === gz); if (wasPlayerPlaced) { state.placedBlocks = state.placedBlocks.filter(pb => !(pb.gx === gx && pb.gy === gy && pb.gz === gz)); @@ -1045,7 +1145,10 @@ function removeBlock(gx, gy, gz) { // Радиус мира по Z — насколько далеко вбок генерируем вокруг игрока -const WORLD_DEPTH = 20; // полу-ширина: z от -20 до +20 +// Полу-ширина мира. 20 тайлов = мир 40x40 при глубине 140 — отсюда «мелкий +// мир»: игрок упирался в край за 20 шагов. 48 даёт комфортные 96x96 тайлов. +// Мир генерируется целиком один раз (pumpWorldGen) порциями по 200 колонок. +const WORLD_DEPTH = 48; const CHUNK = 8; // шаг генерации // ========== ШУМ (детерминированный, от worldSeed) ========== @@ -1158,7 +1261,7 @@ function growTree(gx, gy, gz, biome) { const h = 4 + Math.floor(hash2(gx * 5, gz * 11) * 3); const leafType = biome === 'tundra' || biome === 'swamp' ? 'spruce_leaves' : 'leaves'; for (let i = 1; i <= h; i++) { - if (!getBlock(gx, gy - i, gz)) setBlock(gx, gy - i, gz, 'wood'); + if (!getBlock(gx, gy - i, gz)) setBlock(gx, gy - i, gz, 'wood', false, false); } // крона объёмом: псевдосфера const cy = gy - h; @@ -1169,7 +1272,7 @@ function growTree(gx, gy, gz, biome) { const dist = dx * dx + dy * dy * 2 + dz * dz; if (dist > rad * rad + 1) continue; const yy = cy + dy; - if (!getBlock(gx + dx, yy, gz + dz)) setBlock(gx + dx, yy, gz + dz, leafType); + if (!getBlock(gx + dx, yy, gz + dz)) setBlock(gx + dx, yy, gz + dz, leafType, false, false); } } } @@ -1178,10 +1281,10 @@ function growTree(gx, gy, gz, biome) { function growDecor(gx, gy, gz, biome) { const def = BIOMES[biome]; const r = hash2(gx * 23 + 5, gz * 29 + 7); - if (def.flowers && r > 0.96 && !getBlock(gx, gy, gz)) setBlock(gx, gy, gz, 'flower'); - if ((biome === 'swamp' || biome === 'plains') && r > 0.985 && !getBlock(gx, gy, gz)) setBlock(gx, gy, gz, 'mushroom'); - if (biome === 'desert' && r > 0.978 && !getBlock(gx, gy, gz) && gy + 1 < surfaceGyAt(gx, gz)) setBlock(gx, gy, gz, 'cactus'); - if (biome === 'tundra' && r > 0.978 && !getBlock(gx, gy, gz) && gy + 2 < surfaceGyAt(gx, gz)) setBlock(gx, gy, gz, 'ice'); + if (def.flowers && r > 0.96 && !getBlock(gx, gy, gz)) setBlock(gx, gy, gz, 'flower', false, false); + if ((biome === 'swamp' || biome === 'plains') && r > 0.985 && !getBlock(gx, gy, gz)) setBlock(gx, gy, gz, 'mushroom', false, false); + if (biome === 'desert' && r > 0.978 && !getBlock(gx, gy, gz) && gy + 1 < surfaceGyAt(gx, gz)) setBlock(gx, gy, gz, 'cactus', false, false); + if (biome === 'tundra' && r > 0.978 && !getBlock(gx, gy, gz) && gy + 2 < surfaceGyAt(gx, gz)) setBlock(gx, gy, gz, 'ice', false, false); } const generated = new Set(); // "cx,cz" — уже сгенерированные чанки @@ -1209,14 +1312,14 @@ function genColumn(gx, gz) { for (let gy = Math.min(BEDROCK_GY - 1, 44); gy >= 0; gy--) { // бедрок внизу if (gy >= BEDROCK_GY - 2) { - if (!getBlock(gx, gy, gz)) setBlock(gx, gy, gz, 'bedrock'); + if (!getBlock(gx, gy, gz)) setBlock(gx, gy, gz, 'bedrock', false, false); continue; } if (gy > top) { // над поверхностью: вода до уровня моря if (gy <= sea) { const t = (biome === 'swamp') ? 'swamp_water' : 'water'; - if (!getBlock(gx, gy, gz)) setBlock(gx, gy, gz, t); + if (!getBlock(gx, gy, gz)) setBlock(gx, gy, gz, t, false, false); } continue; } @@ -1224,12 +1327,12 @@ function genColumn(gx, gz) { if (gy < top - 2 && inCave(gx, gy, gz)) continue; const ore = oreAt(gx, gy, gz); - if (ore) { setBlock(gx, gy, gz, ore); continue; } + if (ore) { setBlock(gx, gy, gz, ore, false, false); continue; } const def = BIOMES[biome]; - if (gy === top) setBlock(gx, gy, gz, def.surface); - else if (gy > top - (biome === 'desert' || biome === 'mountains' ? 5 : 3)) setBlock(gx, gy, gz, def.subsurface); - else setBlock(gx, gy, gz, 'stone'); + if (gy === top) setBlock(gx, gy, gz, def.surface, false, false); + else if (gy > top - (biome === 'desert' || biome === 'mountains' ? 5 : 3)) setBlock(gx, gy, gz, def.subsurface, false, false); + else setBlock(gx, gy, gz, 'stone', false, false); } // вода в пещерах не заливаем — это гравитация @@ -1241,37 +1344,73 @@ function genColumn(gx, gz) { function BLOCKS_NO_SOLID(t) { return t === 'water' || t === 'swamp_water' || t === 'air'; } -// =========─ ГЕНЕРАЦИЯ ВОКРУГ ИГРОКА =========─ -// Держим чанки сгенерированными вокруг позиции игрока по X и Z. +// ========== ОДИН ФИКСИРОВАННЫЙ МИР ========== +// Раньше мир «стримился» вокруг игрока, а дальние чанки ВЫЧИЩАЛИСЬ. Из-за +// этого горизонт пустел, а мелкая дальняя генерация давала обрезанную землю — +// отсюда «остров, висящий в пространстве». Теперь мир генерируется ЦЕЛИКОМ +// один раз и больше никогда не вычищается. Чтобы вкладка не зависла на +// сотнях тысяч блоков, генерация идёт порциями: pumpWorldGen() зовётся из +// игрового цикла, а isWorldReady() показывает готовность для загрузочного экрана. const genChunks = new Set(); -function ensureGenAroundCamera() { - const px = Math.floor(state.player.x / TILE); - const pz = Math.floor(state.player.z / TILE); - const rx = 3, rz = Math.ceil(WORLD_DEPTH / CHUNK) + 1; - const cx = Math.floor(px / CHUNK), cz = Math.floor(pz / CHUNK); - for (let dx = -rx; dx <= rx; dx++) { - for (let dz = -rz; dz <= rz; dz++) { - const kx = cx + dx, kz = cz + dz; - const key = kx + ',' + kz; - if (genChunks.has(key)) continue; - genChunks.add(key); - genChunk(kx, kz); - } - } - // чистим слишком далёкие - for (const key of genChunks) { - const [a, b] = key.split(',').map(Number); - if (Math.abs(a - cx) > rx + 2 || Math.abs(b - cz) > rz + 2) { - genChunks.delete(key); - generated.delete(key); +let genQueue = null; // [gx,gz,gx,gz,...] — остаток работы +let genCursor = 0; +let worldReady = false; // мир сгенерирован целиком + +// Всего колонок (2*WORLD_DEPTH+1)^2. При 48 это 97*97 = 9409 колонок по ~45 +// блоков ≈ 420k блоков. За один кадр это зависание вкладки, отсюда очередь. +const WORLD_BATCH = 200; // колонок за один вызов (~9k блоков) + +function buildQueue() { + const side = 2 * WORLD_DEPTH + 1; + const q = new Int32Array(side * side * 2); + let i = 0; + // Сначала колонки рядом с центром — игрок появляется уже с готовой землёй. + for (let gz = -WORLD_DEPTH; gz <= WORLD_DEPTH; gz++) { + for (let gx = -WORLD_DEPTH; gx <= WORLD_DEPTH; gx++) { + q[i++] = gx; q[i++] = gz; } } + return q; } +// Прогресс 0..1 — для экрана загрузки. +function worldGenProgress() { + if (worldReady) return 1; + if (!genQueue) genQueue = buildQueue(); + return genCursor / genQueue.length; +} + +// Возвращает true, когда мир готов целиком. +function pumpWorldGen() { + if (worldReady) return true; + if (!genQueue) genQueue = buildQueue(); + const total = genQueue.length; + const n = Math.min(WORLD_BATCH, (total - genCursor) >> 1); + for (let i = 0; i < n; i++) { + genColumn(genQueue[genCursor], genQueue[genCursor + 1]); + genCursor += 2; + } + if (genCursor >= total) { + genQueue = null; + worldReady = true; + state.worldGenReady = true; + } + return worldReady; +} + +function isWorldReady() { return worldReady; } + +// Совместимость с вызовами из loop3d: мир не стримится, а достраивается. +function ensureGenAroundCamera() { return pumpWorldGen(); } + function resetGenerationCache() { generated.clear(); genChunks.clear(); biomeCache.clear(); + genQueue = null; + genCursor = 0; + worldReady = false; + state.worldGenReady = false; } // === audio/sound-engine.js === @@ -2198,7 +2337,7 @@ function rebuildHotbar() { function activateTNT3D(b, fuseSeconds) { b.active = true; b.fuse = fuseSeconds; - playSound('fuse'); + playSound('click'); } function explodeTNT3D(b, st) { @@ -2266,7 +2405,7 @@ function explodeTNT3D(b, st) { dmg(p); if (p.hp <= 0 && p.invuln <= 0) { p.hp = 0; p.invuln = 1.5; } - playSound('explode'); + playSound('explode1'); // взрывные частицы убрать по таймеру st.explosionFlash = 0.4; @@ -2479,14 +2618,17 @@ function applySave(saveData) { // Восстанавливаем игрока if (saveData.player) { - state.player.x = saveData.player.x; - state.player.y = saveData.player.y; + // ВАЖНО: в 3D координаты из сейва НЕ применяем. Сейв 2D-версии содержит + // y в другой системе (там игрок мог стоять на 2D-поверхности), из-за чего + // после загрузки player.y улетал на +119 тайлов (5286 вместо 526) — игрок + // оказывался под миром и падал на дно. Позицию ставит ensurePlayerOnGround + // после генерации, а из сейва берём только состояние. 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; + // Опыт и уровень восстанавливаем с проверкой: старые сейвы полей не + // содержат, и undefined в player.xp ломает арифметику уровня. + state.player.xp = Number(saveData.player.xp) || 0; + state.player.level = Number(saveData.player.level) || 1; // Проверяем HP из сохранения - если <= 0, устанавливаем 100 const savedHP = saveData.player.hp; @@ -2498,7 +2640,7 @@ function applySave(saveData) { state.player.hp = savedHP; } console.log('player HP after restore:', state.player.hp); - console.log('spawnPoint обновлён из сохранения: x=', state.spawnPoint.x, 'y=', state.spawnPoint.y); + console.log('Сохранение: координаты игрока НЕ применяются в 3D (позицию ставит ensurePlayerOnGround)'); } else { console.log('No player data in save, setting default HP: 100'); state.player.hp = 100; @@ -2825,8 +2967,11 @@ function updateSaveButtonVisibility() { } // === multiplayer/socket-helpers.js === -// Socket helpers: цвет, отправка позиции/блоков - +// ==================== СЕТЬ: отправка позиции и блоков ==================== +// Три правки против 2D-версии: +// 1) шлём Z — без него игрок в 3D стоит в плоскости и не двигается вглубь; +// 2) шлём gz в block_change — иначе все блоки ложатся в gz=0; +// 3) порог отправки по Z, иначе движение вглубь не отправляется вовсе. @@ -2844,8 +2989,7 @@ function getRandomPlayerColor(socketId) { // Throttle для отправки позиции (10-20 раз в секунду) let lastMoveSendTime = 0; - -let lastSentX = 0, lastSentY = 0; +let lastSentX = 0, lastSentY = 0, lastSentZ = 0; // Отправка позиции игрока (с throttle) function sendPlayerPosition() { @@ -2854,23 +2998,30 @@ function sendPlayerPosition() { const now = performance.now() / 1000; if (now - lastMoveSendTime < MOVE_SEND_INTERVAL) return; - // Отправляем только если позиция изменилась + // Отправляем только если позиция изменилась ПО ВСЕМ ОСЯМ. + // Раньше z не сравнивался — движение вглубь мира не отправлялось. + const z = state.player.z || 0; const dx = Math.abs(state.player.x - lastSentX); const dy = Math.abs(state.player.y - lastSentY); - if (dx < 1 && dy < 1) return; + const dz = Math.abs(z - lastSentZ); + if (dx < 1 && dy < 1 && dz < 1) return; lastMoveSendTime = now; lastSentX = state.player.x; lastSentY = state.player.y; + lastSentZ = z; - state.socket.emit('player_move', { x: state.player.x, y: state.player.y, player_name: state.playerName }); + state.socket.emit('player_move', { + x: state.player.x, y: state.player.y, z: z, + player_name: state.playerName + }); } -// Отправка изменения блока -function sendBlockChange(gx, gy, t, op) { +// Отправка изменения блока. gz обязателен: сервер и клиент ключуют блок как "x,y,z". +function sendBlockChange(gx, gy, gz, t, op) { if (!state.isMultiplayer || !state.socket || !state.socket.connected) return; - state.socket.emit('block_change', { gx, gy, t, op }); + state.socket.emit('block_change', { gx, gy, gz: gz || 0, t, op }); } // === ui/trade.js === @@ -3152,6 +3303,164 @@ function requestTradeWithPlayer(targetName) { addChatMessage('Система', `Запрос отправлен ${targetName}...`); } +// === spawn3d.js === +// ==================== СПАВН: единая реализация для всех путей ==================== +// Раньше логика спавна была продублирована в main3d.js (ensurePlayerOnGround) +// и в socket.js (обработчик world_state). Две копии расходились: одна искала +// площадку с 2 тайлами воздуха, другая — с 3, и одна считала feet как +// (top+1)*T, другая как (top-1)*T. Итог: игрок то тонул в грунте, то падал +// в яму, камера уходила в стену, схлопывалась в первое лицо (camDist = 0), +// и весь кадр занимало пустое небо. +// +// Теперь здесь ЕДИНАЯ реализация, которую зовут и main3d, и socket.js. +// +// ---------- МАТЕМАТИКА (ось Y ВНИЗ: чем больше y, тем НИЖЕ) ---------- +// Тайл gy занимает [gy*T, (gy+1)*T]. Чем больше gy — тем НИЖЕ на экране. +// +// Опора top — самый верхний блок колонки (трава/песок/камень). +// Верхняя грань опоры = (top+1)*T. Игрок стоит НА ней: +// feet = (top+1)*T +// p.y = feet - h (p.y — ВЕРХ коллайдера) +// +// Тело занимает [p.y, feet] = [(top+1)*T - h, (top+1)*T]. +// При h=34 и T=40 это последние 34 px тайла top плюс 0 px выше, то есть +// тело физически В тайле top. Чтобы тело оказалось ВЫШЕ опоры, feet должен +// быть (top+1)*T и h — 34, тогда тело = [top*T+6, (top+1)*T]: тайл top +// занят опорой, и гравитация опустит игрока на верхнюю грань top+1*T. +// +// ПРАКТИКА: после установки игрок должен оказаться в тайле top+1 (воздух). +// Проверяем это: getBlock(gx, top+1, gz) === null. + + + + + + +const isWater = (gx, gy, gz) => { + const b = getBlock(gx, gy, gz); + if (!b) return false; + const d = BLOCKS[b.t]; + return !!(d && d.fluid); +}; + +// Пусто = нет блока, либо блок не твёрдый, не жидкость, не лестница. +// Трава/мoss/snow имеют solid:true — они и есть поверхность, на ней стоят. +const isEmpty = (gx, gy, gz) => { + const b = getBlock(gx, gy, gz); + if (!b) return true; + const d = BLOCKS[b.t]; + return !d || (!d.solid && !d.fluid && !d.climbable); +}; + +// Клетка годится, если: +// - top — верхний твёрдый тайл (не вода), +// - top+1 и top+2 пусты (тело игрока), +// - top+3 пуст (камера). +// Проверять top-1 бессмысленно: это ВСЕГДА грунт под поверхностью. +// Клетка годится, если top — верхний твёрдый тайл колонки (не вода), +// и тайлы top-1, top-2, top-3 пусты: ноги, голова и место для камеры. +// Обход СВЕРХУ ВНИЗ: берём самый верхний твёрдый тайл, у которого есть +// воздух. Если брать ниже, попадём внутрь толщи грунта. +// Трава/мoss/snow — декорация ПОВЕРХ грунта: она не твёрдая опора, а +// getBlock отдаёт её как обычный блок. Если брать самый верхний блок, +// получаем top = трава, а под ней (top-1) — dirt, и проверка «воздух +// выше» отвергает ВСЯ колонку: сухой площадки не найдено рядом. +// +// Правильно: опора = самый верхний блок, под которым начинается ВОЗДУХ. +// То есть идём сверху вниз, пропускаем decor-слой, и берём первый твёрдый +// слой, у которого выше (меньшие gy) 3 тайла пустых. +// Поверхность для стоянки = ВЕРХНЯЯ граница мира в колонке. +// Опора top = самый верхний блок. Но под ним (top-1) ВСЕГДА грунт — +// трава лежит на dirt, песок на stone. Проверять top-1 как «воздух» бессмысленно: +// это отвергало каждую колонку («сухой площадки не найдено рядом»). +// +// Что реально нужно: над верхним блоком колонки (top-1, top-2, top-3 — +// то есть МЕНЬШИЕ gy) должно быть ПУСТО. Для колонки dirt, grass: +// gy=10 grass, gy=9 dirt, gy=8 dirt, gy=7 … пусто +// верхний блок = 10 (grass). Выше = 9 (dirt) — ЗАНЯТО. Значит опора не 10. +// Настоящая свободная кромка = gy=7, опора под ней = 6. +// +// Значит ищем: тайл top, где top-1, top-2 пусты (ноги+голова), и top+1 +// ТВЁРДЫЙ (это опора, на ней стоим). Камера требует ещё top-2 пуст. +// Опора top = САМЫЙ ВЕРХНИЙ блок колонки (не вода). +// Над ним (top+1, top+2, top+3 — БОЛЬШИЕ gy, то есть НИЖЕ по оси Y… нет, +// больше gy = ниже) — стоп. Здесь Y ВНИЗ, поэтому «выше опоры» = top+1? +// Нет: верхняя грань тайла top = (top+1)*T, значит пространство ВЫШЕ опоры +// лежит в тайле top-1 (меньший y = выше). Проверяем top-1, top-2, top-3. +const tryColumn = (gx, gz) => { + let top = -1; + for (let gy = 60; gy >= 1; gy--) { + const b = getBlock(gx, gy, gz); + if (b && !isWater(gx, gy, gz)) { top = gy; break; } + } + if (top < 4) return -1; + // свободное место ВЫШЕ опоры: тайлы с МЕНЬШИМ gy + if (!isEmpty(gx, top - 1, gz)) return -1; + if (!isEmpty(gx, top - 2, gz)) return -1; + if (!isEmpty(gx, top - 3, gz)) return -1; + return top; +}; + +// Расширяющаяся спираль: своя колонка, затем кольца до 40 тайлов. +function findStandSpot(startGX, startGZ) { + for (let r = 0; r <= 40; r++) { + for (let dz = -r; dz <= r; dz++) { + for (let dx = -r; dx <= r; dx++) { + if (r > 0 && Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue; + const gx = startGX + dx, gz = startGZ + dz; + const top = tryColumn(gx, gz); + if (top >= 0) return { gx, gz, top }; + } + } + } + return null; +} + +// Применяет найденную площадку к игроку. genRadius — сколько колонок +// сгенерировать вокруг, чтобы поиск имел смысл (в неготовом мире колонки +// пусты и tryColumn ничего не найдёт). +function placePlayerOnStandSpot(startGX, startGZ, genRadius) { + const T = state.TILE; + const p = state.player; + if (typeof genRadius === 'number') { + for (let dx = -genRadius; dx <= genRadius; dx++) { + for (let dz = -genRadius; dz <= genRadius; dz++) { + // каждый 4-й по диагонали — экономия, остальные колонки рядом + // всё равно генерируются по мере надобности + if (((dx + dz) & 3) !== 0) continue; + try { genColumn(startGX + dx, startGZ + dz); } catch (e) { /* нет генератора */ } + } + } + } + const spot = findStandSpot(startGX, startGZ); + if (!spot) { + console.warn('[Spawn] сухой площадки не найдено рядом', startGX, startGZ); + return null; + } + p.x = spot.gx * T; + p.z = spot.gz * T; + // feet = top*T (верхняя грань опоры). player.y — ВЕРХ коллайдера, + // то есть на h пикселей ВЫШЕ feet по оси Y. + p.y = (spot.top + 1) * T - p.h; // верхняя грань опоры минус высота + p.vx = 0; p.vy = 0; p.vz = 0; + p.grounded = true; + p.fallStartY = p.y; + state.spawnPoint.x = p.x; + state.spawnPoint.y = p.y; + state.spawnPoint.z = p.z; + console.log('[Spawn] игрок на опоре', spot.gx, spot.top, spot.gz, 'p.y=', p.y); + return spot; +} + +// Точка входа: читает текущую позицию игрока и ставит на площадку рядом. +function ensurePlayerOnGround() { + const T = state.TILE; + const p = state.player; + const curGX = Math.floor((p.x + p.w / 2) / T); + const curGZ = Math.floor((p.z + p.d / 2) / T); + return placePlayerOnStandSpot(curGX, curGZ, 12); +} + // === multiplayer/socket.js === // Socket.IO клиент — инициализация и все обработчики @@ -3168,6 +3477,8 @@ function requestTradeWithPlayer(targetName) { + + // Mob sizes for server mob sync (server doesn't send w/h) const MOB_SIZES = { pig: { w: 34, h: 24 }, @@ -3241,14 +3552,25 @@ function initSocket() { state.generated.clear(); grid.clear(); blocks.length = 0; + // Окклюзивная сетка обновляется только из setBlock/removeBlock, а здесь + // мир чистится напрямую. Без сброса сетка осталась бы от старого сида + // и перестроение мешей отсекло бы свежие блоки как скрытые. + occClear(); state.placedBlocks = []; state.removedBlocks = []; console.log('World regenerated with new seed:', state.worldSeed); + // КРИТИЧНО: после перегенерации позиция игрока невалидна — он стоит + // в координатах ПРЕДЫДУЩЕГО мира и оказывается в яме. Ставим заново + // и пересчитываем опору. Иначе камера уходит в грунт, схлопывается + // в первое лицо (camDist = 0), и весь кадр занимает пустое небо. + if (typeof ensurePlayerOnGround === 'function') { + try { ensurePlayerOnGround(); } catch (e) { /* не критично */ } + } } if (data.blocks && Array.isArray(data.blocks)) { for (const block of data.blocks) { - const key = k(block.gx, block.gy, block.gz || 0); + const key = k3(block.gx, block.gy, block.gz || 0); state.serverOverrides.set(key, { op: block.op, t: block.t }); if (block.op === 'set') { setBlock(block.gx, block.gy, block.gz || 0, block.t, false); @@ -3263,32 +3585,25 @@ function initSocket() { state.isNightTime = state.worldTime > 0.5; } - // Spawn point - { - const startGX = 6; - for (let dx = -4; dx <= 4; dx++) for (let dz = -24; dz <= 24; dz++) genColumn(startGX + dx, dz); - const surfaceY = surfaceGyAt(startGX, Math.floor((state.player.z || 0) / state.TILE)); - 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, Math.floor((state.player.z || 0) / state.TILE)); - 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); + // Стартовая колонка: X=6 по центру мира, Z игрока. + // Z фиксируем ОДИН РАЗ при входе: если брать текущую позицию каждый + // раз, то второй world_state ищет площадку уже рядом с первой + // найденной точкой и уводит игрока всё дальше в сторону. + if (state.__spawnGZ === undefined) { + state.__spawnGZ = Math.floor((state.player.z || 0) / state.TILE); } - - state.player.x = state.spawnPoint.x; - state.player.y = state.spawnPoint.y; + const startGX = 6; + const startGZ = state.__spawnGZ; + // Спавн: единая реализация из spawn3d.js. Раньше здесь была вторая + // копия поиска площадки, которая считала feet как (top-1)*T и искала + // воздух НИЖЕ top — игрок тонул в грунте либо падал в яму. + placePlayerOnStandSpot(startGX, startGZ, 20); + // Позицию уже выставил placePlayerOnStandSpot (feet = (top+1)*T, + // player.y = feet - h). Здесь только обнуляем скорости. state.player.vx = 0; state.player.vy = 0; + state.player.vz = 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; @@ -3327,11 +3642,15 @@ function initSocket() { socket.on('player_joined', (data) => { console.log('Player joined:', data.socket_id); if (data.socket_id !== state.mySocketId) { + // Позиция ДРУГОГО игрока. Формула та же, что и у нас: feet на + // верхней грани опоры. Раньше было (surfaceY - 1) * TILE — это + // тайл ПОД поверхностью, то есть чужий игрок появлялся в грунте. const spawnGX = 6; - genColumn(spawnGX, Math.floor((state.player.z || 0) / state.TILE)); - const surfaceY = surfaceGyAt(spawnGX, Math.floor((state.player.z || 0) / state.TILE)); + const spawnGZ = Math.floor((state.player.z || 0) / state.TILE); + genColumn(spawnGX, spawnGZ); + const surfaceY = surfaceGyAt(spawnGX, spawnGZ); const safeSpawnX = spawnGX * TILE; - const safeSpawnY = (surfaceY - 1) * TILE; + const safeSpawnY = (surfaceY + 1) * TILE; // верхняя грань тайла state.otherPlayers.set(data.socket_id, { x: safeSpawnX, @@ -3349,6 +3668,9 @@ function initSocket() { const p = state.otherPlayers.get(data.socket_id); p.x = data.x; p.y = data.y; + // Ось Z обязательна: без неё чужие игроки стоят в одной плоскости + // и не двигаются вглубь мира. Раньше z просто игнорировался. + p.z = data.z || 0; if (data.player_name) { p.name = data.player_name; } @@ -3366,6 +3688,11 @@ function initSocket() { socket.on('mob_spawned', (data) => { let 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 }; applyMobSize(sm); + // Ось Z у серверных мобов: без неё в сетевом режиме все мобы + // стоят в одной плоскости и не преследуют игрока по глубине. + if (sm.z === undefined) sm.z = data.z || 0; + if (sm.vz === undefined) sm.vz = data.vz || 0; + if (sm.d === undefined) sm.d = data.d || sm.w || 30; state.serverMobs.set(data.id, sm); }); @@ -3410,7 +3737,7 @@ function initSocket() { }); socket.on('block_changed', (data) => { - const key = k(data.gx, data.gy, data.gz || 0); + const key = k3(data.gx, data.gy, data.gz || 0); state.serverOverrides.set(key, { op: data.op, t: data.t }); if (data.op === 'set') { setBlock(data.gx, data.gy, data.gz || 0, data.t, false); @@ -3500,11 +3827,16 @@ function updateWaterPhysics3(dt) { const EPS = 0.001; -// Собственная AABB-коробка сущности: x/z — центр, y — низ (как в 2D-игре: e.x, e.y = левый-ниний) +// AABB сущности. ВАЖНО: в игровом коде принято, что e.y — ЭТО ВЕРХ тела, +// а низ тела = e.y + e.h (ось Y направлена вниз, гравитация тянет в +y). +// Раньше здесь стояло y0: e.y, y1: e.y + e.h — то есть НИЗ=e.y. Это +// противоречие ломало всё: игрок оказывался внутри блока, grounded был +// всегда false, а прыжок гасился столкновением на первом шаге. +// Теперь: y0 = e.y + e.h (низ), y1 = e.y (верх) — согласовано с игрой. function boxOf(e) { return { x0: e.x, x1: e.x + e.w, - y0: e.y, y1: e.y + e.h, + y0: e.y + e.h, y1: e.y, z0: e.z, z1: e.z + e.d, }; } @@ -3530,9 +3862,37 @@ function fluidAt(gx, gy, gz) { } // Пересекает ли коробка сущности твёрдый блок -function hitsSolid(bx) { +// Пол под ногами. ТЕЛО ТОНКОЕ: при h = 34 и TILE = 40, как только y0 +// пересекает границу тайла, floor(y0/TILE) указывает на пустой тайл над +// опорой и коллизия перестаёт видеть пол. Замер: игрок оседал на 0.85 +// тайла внутрь блока. Поэтому дополнительно проверяем тайл строго ПОД +// нижней границей тела — на расстоянии EPS, чтобы поймать опору. +function groundSolidUnder(bx) { + const gy = Math.floor((bx.y0 + 0.5) / TILE); const x0 = Math.floor(bx.x0 / TILE), x1 = Math.floor((bx.x1 - EPS) / TILE); - const y0 = Math.floor(bx.y0 / TILE), y1 = Math.floor((bx.y1 - EPS) / TILE); + const z0 = Math.floor(bx.z0 / TILE), z1 = Math.floor((bx.z1 - EPS) / TILE); + for (let gx = x0; gx <= x1; gx++) { + for (let gz = z0; gz <= z1; gz++) { + if (solidAt(gx, gy, gz)) return true; + } + } + return false; +} + +function hitsSolid(bx) { + if (groundSolidUnder(bx)) return true; + const x0 = Math.floor(bx.x0 / TILE), x1 = Math.floor((bx.x1 - EPS) / TILE); + // По Y берём тайлы СТРОГО пересекающиеся с телом. Низ тела (y0) может + // лежать ровно на границе тайла: когда игрок стоит на опоре, y0 кратно + // TILE и floor(y0/TILE) указывает на САМ блок опоры, а не на тайл под + // ним. Из-за этого игрок «вставал» на 0.85 тайла внутрь блока: замер + // давал feet_gy = 7.85 при опоре на тайле 7 (snow). + // Поэтому нижнюю границу округляем ВНИЗ, а верхнюю — вверх. + // Тайлы по Y: от верхнего до нижнего. Нижняя граница тела bx.y0 округляется + // ВНИЗ (bx.y0 - EPS): когда ноги стоят ровно на границе тайла, floor даёт + // сам блок опоры, а не тайл над ним — из-за этого grounded срабатывал + // слишком рано, а игрок оседал на 0.85 тайла ВНУТРЬ блока. + const y0 = Math.floor((bx.y0 - EPS) / TILE), y1 = Math.floor((bx.y1 + EPS) / TILE); const z0 = Math.floor(bx.z0 / TILE), z1 = Math.floor((bx.z1 - EPS) / TILE); for (let gx = x0; gx <= x1; gx++) { for (let gy = y0; gy <= y1; gy++) { @@ -3547,7 +3907,13 @@ function hitsSolid(bx) { // Стоит ли сущность на чём-то (для grounded) function checkGrounded(e) { const bx = boxOf(e); - const gy = Math.floor((bx.y1 + 1) / TILE); + // Опора = тайл под ногами = под НИЗОМ тела (bx.y0). Раньше тут стоял + // bx.y1, который с новой boxOf — это ВЕРХ тела, то есть блок над головой. + // Опора — тайл СТРОГО ПОД ногами. Когда низ тела стоит ровно на границе + // (y0 кратно TILE), floor(y0/TILE) указывает на сам блок опоры, а не на + // тайл под ним, и grounded считается в тот момент, когда игрок ещё не + // встал. Поэтому округляем вниз: верхняя граница опоры = bx.y0. + const gy = Math.floor((bx.y0 - EPS) / TILE); const x0 = Math.floor(bx.x0 / TILE), x1 = Math.floor((bx.x1 - EPS) / TILE); const z0 = Math.floor(bx.z0 / TILE), z1 = Math.floor((bx.z1 - EPS) / TILE); for (let gx = x0; gx <= x1; gx++) { @@ -3600,17 +3966,19 @@ function moveAxis(e, axis, delta) { e.x = savedX; e.y = savedY; e.z = savedZ; } - // Откат к границе блока - // шаг за блок, пока не пересечение + // Откат: двигаемся по 0.5 px, пока не упрёмся в твёрдый блок. + // ТЕЛО ТОНКОЕ (h = 34 < TILE = 40), поэтому нельзя полагаться только на + // floor() текущего положения: как только y0 пересекает границу тайла, + // тайл под ногами становится пустым и коллизия перестаёт видеть пол. + // Замер: игрок оседал на feet_gy = 8.85 при опоре на тайле 7. + // Поэтому шаг делаем ОТ ПОТОЛКА вниз и держим последнюю валидную позицию. const stepPx = dir * 0.5; - let moved = 0; for (let i = 0; i < TILE * 4; i++) { const test = boxOf(e); if (axis === 'x') { test.x0 += stepPx; test.x1 += stepPx; } else if (axis === 'z') { test.z0 += stepPx; test.z1 += stepPx; } else { test.y0 += stepPx; test.y1 += stepPx; } if (hitsSolid(test)) break; - moved += stepPx; if (axis === 'x') e.x += stepPx; else if (axis === 'z') e.z += stepPx; else e.y += stepPx; @@ -3625,13 +3993,35 @@ function moveAxis(e, axis, delta) { function resolveX(e, dt) { return moveAxis(e, 'x', e.vx * dt); } function resolveZ(e, dt) { return moveAxis(e, 'z', (e.vz || 0) * dt); } function resolveY(e, dt) { - const hit = moveAxis(e, 'y', e.vy * dt); - e.grounded = false; - if (hit && e.vy >= 0) { - e.grounded = checkGrounded(e); - } else if (!hit) { - e.grounded = checkGrounded(e); + const delta = e.vy * dt; + // Движение ВВЕРХ (vy < 0): блокируем только если реально упёрлись головой. + // Раньше moveAxis проверял весь объём коробки, и блок ПОД ногами гасил + // прыжок в тот же кадр (vy сразу становился 0). + let hit = false; + if (delta < 0) { + const probe = boxOf(e); + probe.y0 += delta; // низ тела + probe.y1 += delta; // верх тела (ось Y вниз => уменьшение = вверх) + if (hitsSolid(probe)) { + // откатываем ровно до грани + const step = -0.5; + for (let i = 0; i < TILE * 4; i++) { + const t = boxOf(e); + t.y0 += step; t.y1 += step; + if (hitsSolid(t)) break; + e.y += step; + } + hit = true; + } else { + e.y += delta; + } + e.grounded = false; + return hit; } + + hit = moveAxis(e, 'y', delta); + + e.grounded = checkGrounded(e); return hit; } @@ -3822,6 +4212,7 @@ function tickFurnaces(dt) { + function isNight() { return state.worldTime > 0.5; } @@ -3874,14 +4265,21 @@ function mobAI(m, dt) { const night = isNight(); // ---------- ночные мобы днём горят ---------- - if ((m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton') && !night) { + // ВАЖНО: не делаем ранний return — иначе моб зависает в воздухе + // (физика ниже не выполняется). Ставим флаг и идём дальше. + const burning = (m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton') && !night; + if (burning) { + m.burning = true; m.hp -= 30 * dt; m.vx *= 0.5; m.vz *= 0.5; - return; + } else { + m.burning = false; } - if (m.kind === 'zombie') { + if (burning) { + // горящий моб только падает и горит — никакого ИИ + } else if (m.kind === 'zombie') { steerToPlayer(m, pc); if (m.inWater && Math.random() < 0.06) m.vy = -260; touchPlayer(m, pc, 15); @@ -3950,6 +4348,7 @@ function mobAI(m, dt) { } else if (m.kind === 'eagle') { // летает, пикирует const d = dist3({ x: cx(m), y: cy(m), z: cz(m) }, pc); + m.flying = true; // флаг ДО физики, иначе орёл получает гравитацию if (d < 20) { const dx = pc.x - cx(m), dz = pc.z - cz(m), dy = pc.y - cy(m); const hl = Math.hypot(dx, dz) || 1; @@ -3975,7 +4374,6 @@ function mobAI(m, dt) { playSound('hit1'); } m.vy *= 0.5; - m.flying = true; } else { // животные (pig, chicken): бродят m.aiT -= dt; @@ -3996,12 +4394,18 @@ function mobAI(m, dt) { if (m.grounded && !m.flying && m.kind !== 'eagle') { const speed = Math.hypot(m.vx || 0, m.vz || 0); if (speed > 5) { - const gx = Math.floor((m.x + (m.vx > 0 ? m.w + 2 : -2)) / TILE); - const gz = Math.floor(((m.z || 0) + (m.vz > 0 ? (m.d || m.w) + 2 : -2)) / TILE); - const gy = Math.floor((m.y + m.h - 1) / TILE); - const bx = getBlock(gx, gy, Math.floor(cz(m))); - const bz = getBlock(Math.floor(cx(m)), gy, gz); - if (bx || bz) m.vy = -280; + // Проверяем отдельно по X и по Z, каждая на своей оси: + // препятствие по X смотрим в колонке Z моба, и наоборот. + const feetGY = Math.floor((m.y + m.h - 1) / TILE); + const bodyGY = Math.floor((m.y + m.h / 2) / TILE); + const mcz = Math.floor(cz(m)); + let blocked = false; + if (m.vx > 0) blocked = blocked || !!getBlock(Math.floor((m.x + m.w + 2) / TILE), bodyGY, mcz); + if (m.vx < 0) blocked = blocked || !!getBlock(Math.floor((m.x - 2) / TILE), bodyGY, mcz); + if (m.vz > 0) blocked = blocked || !!getBlock(Math.floor(cx(m)), bodyGY, Math.floor(((m.z || 0) + (m.d || m.w) + 2) / TILE)); + if (m.vz < 0) blocked = blocked || !!getBlock(Math.floor(cx(m)), bodyGY, Math.floor(((m.z || 0) - 2) / TILE)); + if (blocked) m.vy = -280; + void feetGY; } } @@ -4037,6 +4441,43 @@ function getMobLoot(kind) { return table[kind] || []; } +// Выдаёт лут моба в инвентарь. Возвращает список выданных предметов. +function grantMobLoot(kind) { + const granted = []; + for (const drop of getMobLoot(kind)) { + if (Math.random() > drop.chance) continue; + const qty = drop.min + Math.floor(Math.random() * (drop.max - drop.min + 1)); + if (qty <= 0) continue; + if (state.inv[drop.item] === undefined) state.inv[drop.item] = 0; + state.inv[drop.item] += qty; + granted.push({ item: drop.item, qty }); + } + if (granted.length) rebuildHotbar(); + return granted; +} + +// Убийство моба: помечает мёртвым, даёт лут, чистит из массива. +// Вызывается из mouse-handler (удар) и из цикла (смерть от урона/взрыва). +function killMob(m) { + if (m.dead) return []; + m.dead = true; + m.hp = 0; + const granted = grantMobLoot(m.kind); + if (m.kind === 'chicken') playSound('hurt_chicken'); + else playSound('attack'); + const i = state.mobs.indexOf(m); + if (i >= 0) state.mobs.splice(i, 1); + return granted; +} + +// Смерть от урона (взрыв, падение в лаву и т.п.) — вызывается из цикла каждый кадр. +function reapDeadMobs() { + for (let i = state.mobs.length - 1; i >= 0; i--) { + const m = state.mobs[i]; + if (m.hp <= 0) killMob(m); + } +} + // === world/weather.js === // ==================== ПОГОДА (biome-aware) ==================== @@ -4396,7 +4837,24 @@ const CROP_SEED_MAP = { }; const CROP_HARVEST_MAP = { wheat_stage3: 'wheat', carrot_stage3: 'carrot', potato_stage3: 'potato' }; +// Начисление опыта. В 3D XP не считался вообще: элементы #xplevel/#xpbar +// были в HTML, но без обработчика, а player.xp нигде не рос. Теперь опыт +// даётся за добычу блока, и порог на уровень — XP_PER_LEVEL * level. +function addXP(amount) { + const pr = state.player; + if (!pr) return; + pr.xp = (pr.xp || 0) + (amount || 0); + const per = 50; + pr.level = pr.level || 1; + while (pr.xp >= per * pr.level) { + pr.xp -= per * pr.level; + pr.level++; + } +} + function handleBlockDrop(removed, inv) { + // Опыт за блок: 1 за обычный, 3 за руду. + addXP(/ore|gold_iron|diamond/i.test(removed.t) ? 3 : 1); if (removed.t === 'grass') { if (Math.random() < 0.5) inv.wheat_seeds = (inv.wheat_seeds || 0) + 1; return; @@ -4426,20 +4884,37 @@ function stopMining() { if (miningInterval) { clearInterval(miningInterval); miningInterval = null; } } -// Основное действие. coords — результат рейкаста; face — нормаль грани. -function handleTap(wx, wy, gz, face) { - if (state.craftOpen) return; - if (state.player.hp <= 0) return; +// Основное действие. ВСЕ координаты — ТАЙЛОВЫЕ (gx, gy, gz), face — нормаль. +// Раньше wx/wy приходили в ПИКСЕЛЯХ (и делились на TILE), а gz — уже +// тайловой. Смешение единиц сдвигало цель на 1 тайл: клик попадал в +// соседний блок, и печь «не открывалась» (клик уходил в dirt рядом). +// Рейкаст в render3d отдаёт block.gx/gy/gz из userData.list — тоже тайловые. +function handleTap(gx, gy, gz, face) { + state.tapDbg = 'start'; + if (state.craftOpen) { state.tapDbg = 'craft-open'; return; } + if (state.player.hp <= 0) { state.tapDbg = 'dead'; return; } const TILE = state.TILE; - // gz приходит из рейкаста; если нет — берём Z игрока - const z = gz === undefined || gz === null ? Math.floor((state.player.z || 0) / TILE) : gz; + // Защита от вызова в пикселях (старый путь из тач-кнопки). + if (Math.abs(gx) > 512 || Math.abs(gy) > 512) { + gx = Math.floor(gx / TILE); + gy = Math.floor(gy / TILE); + } - // Ближайший блок к точке касания - let gx = Math.floor(wx / TILE); - let gy = Math.floor(wy / TILE); + let z = gz === undefined || gz === null + ? Math.floor(((state.player.z || 0) + state.player.d / 2) / TILE) + : (Math.abs(gz) > 512 ? Math.floor(gz / TILE) : gz); let b = getBlock(gx, gy, z); + // Рейкаст бьёт по грани: блока по этой оси может не быть, тогда ищем + // ближайший непустой сосед по X/Y/Z — иначе клик «попадал» в пустоту. + if (!b) { + for (const d of [[1,0,0],[-1,0,0],[0,1,0],[0,-1,0],[0,0,1],[0,0,-1]]) { + const cand = getBlock(gx + d[0], gy + d[1], z + d[2]); + if (cand) { gx += d[0]; gy += d[1]; z += d[2]; b = cand; break; } + } + } + // Если блока нет — пробуем тот же столбец, но сдвинутые на 1 (декоративные/плоские) if (!b) { for (const dy of [-1, 1, 0]) { @@ -4448,14 +4923,16 @@ function handleTap(wx, wy, gz, face) { } } - if (!b) return; + if (!b) { state.tapDbg = 'no-block'; return; } // ---------- ДОСТИЖИМОСТЬ ---------- if (!withinReach(gx, gy, z)) { // Слишком далеко: подсказка, ничего не ломаем + state.tapDbg = 'far:' + gx + ',' + gy + ',' + z; showReachHint(); return; } + state.tapDbg = 'ok:' + (b ? b.t : '?') + ':' + gx + ',' + gy + ',' + z; // Пробуждение: клик по кровати когда спишь if (state.player.sleeping && b.t === 'bed') { @@ -4506,10 +4983,28 @@ function handleTap(wx, wy, gz, face) { 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; + // Лут по виду моба. Раньше ВСЕ давали 2 мяса, поэтому зомби и белый + // медведь роняли сырое мясо, а скелет — стрелы, но не кости. + const LOOT = { + chicken: { chicken_meat: 1, feather: 1 + Math.floor(Math.random() * 2) }, + pig: { meat: 2 + Math.floor(Math.random() * 2) }, + cow: { meat: 2 + Math.floor(Math.random() * 2) }, + sheep: { meat: 1 + Math.floor(Math.random() * 2), bone: 1 }, + zombie: { meat: 1 }, + skeleton: { bone: 1 + Math.floor(Math.random() * 2), arrow: 2 + Math.floor(Math.random() * 3) }, + creeper: { gunpowder: 1 + Math.floor(Math.random() * 2) }, + slime: { slime_ball: 1 + Math.floor(Math.random() * 2) }, + scorpion: { scorpion_stinger: 1 + Math.floor(Math.random() * 2), meat: 1 }, + polarbear: { polar_fur: 1, meat: 3 + Math.floor(Math.random() * 2) }, + eagle: { eagle_feather: 1 + Math.floor(Math.random() * 2), feather: 1 } + }; + const drop = LOOT[m.kind] || { meat: 1 }; + for (const item in drop) { + const n = drop[item]; + if (n > 0) state.inv[item] = (state.inv[item] || 0) + n; + } + if (m.kind === 'skeleton' && Math.random() < 0.15) { + state.inv.bow = (state.inv.bow || 0) + 1; } state.mobs.splice(i, 1); rebuildHotbar(); @@ -4574,7 +5069,10 @@ function handleTap(wx, wy, gz, face) { if (mode() === 'mine') { const def = BLOCKS[b.t]; if (def.fluid) return; - if (def.decor && def.growthStage === undefined && !def.harvestable) return; + if (def.decor && def.growthStage === undefined && !def.harvestable) { + // декорации (цветы, грибы) ломаются мгновенно ниже, здесь только подстраховка + return; + } if (b.t === 'tnt') { activateTNT3D(b, 3.2); return; } @@ -4752,6 +5250,35 @@ function showReachHint() { hintTimer = setTimeout(() => { if (hintEl) hintEl.style.display = 'none'; }, 1200); } +// Диаг-хук: доступен только по ?diag=1, в обычной игре не создаётся +if (/[?&]diag=1/.test(location.search)) { + // setBlock/removeBlock нужны для браузерных проверок добычи и установки: + // без них тест не может подготовить блок перед игроком. + window.__m3d = { REACH, REACH_SQ, withinReach, withinReachEntity, handleTap, stopMining, + getBlock, setBlock, removeBlock, isSolid, isOpaque, hasBlock }; +} + +// Кнопка копки на джойстике: удержание = добыча блока под прицелом. +// TDtarget и startHoldMining живут в render3d/mouse-handler соответственно, +// но ЭТОТ модуль их не видит: обращение к TDtarget давало +// "TDtarget is not defined" и кнопка копки не работала вообще. Рендер +// публикует цель через TDaim(), а удержание запускается handleTap по +// координатам цели — того же пути, что и обычный клик мышью. +function TDaim() { + return (typeof window !== 'undefined' && window.__td && window.__td.target) || null; +} +window.addEventListener('v2-mine', (e) => { + const down = !!(e.detail && e.detail.down); + if (!down) { stopMining(); return; } + const t = TDaim(); + if (!t) return; + const blk = getBlock(t.gx, t.gy, t.gz); + if (!blk) return; + // Оба действия через handleTap: он сам различает добычу и установку + // по режиму и запускает удержание через setInterval. + handleTap(t.gx, t.gy, t.gz, t.face); +}); + // === render/hero-rig.js === // ==================== ДАННЫЕ ГЕРОЯ (из платформёра grechka_game.html) ==================== // COL/PARTS/PIVOT дословно оттуда: 107 прямоугольников, 5 частей (head/body/legs/tail/eye). @@ -4770,6 +5297,103 @@ const HERO_PIVOT = {"head":[0.0,2.5909],"body":[0.0,0.979],"legs":[-0.035,0.9091 const HERO_SCALE = 0.50; +// === entities/mob-sprites3d.js === +// Точные копии мобов 2D-игры (game.js, fillRect-композиции) для 3D. +// НИЧЕГО НЕ ВЫДУМАНО: те же прямоугольники, те же цвета, те же пропорции. +// Координаты локальные: x вправо от левого края, y ВВЕРХ от низа, в пикселях +// (TILE=40). Конвертировано из 2D (y вниз): y3d = h - dy - rh. +// Глубина выдавливания задаётся при построении (доля от ширины). +const MOB_SPRITES = { + zombie: { w: 34, h: 50, rects: [ + [0, 0, 34, 50, '#2ecc71'], + [6, 32, 6, 6, '#c0392b'], [22, 32, 6, 6, '#c0392b'], + ]}, + pig: { w: 34, h: 24, rects: [ + [0, 0, 34, 24, '#ffb6c1'], + [22, 16, 3, 3, '#000000'], + [28, 6, 6, 6, '#ff69b4'], + ]}, + chicken: { w: 26, h: 22, rects: [ + [0, 0, 26, 22, '#ecf0f1'], + [18, 8, 6, 4, '#f39c12'], + [8, 13, 3, 3, '#000000'], + ]}, + creeper: { w: 34, h: 50, rects: [ + [0, 0, 34, 50, '#4caf50'], + [8, 38, 4, 4, '#000000'], [22, 38, 4, 4, '#000000'], + [12, 26, 10, 4, '#000000'], + [4, 0, 6, 20, '#4caf50'], [24, 0, 6, 20, '#4caf50'], + ]}, + skeleton: { w: 34, h: 50, rects: [ + [10, 18, 14, 12, '#eceff1'], + [8, 32, 18, 18, '#eceff1'], + [10, 40, 4, 4, '#000000'], [20, 40, 4, 4, '#000000'], + [15, 36, 4, 2, '#000000'], + [2, 16, 6, 14, '#eceff1'], [26, 16, 6, 14, '#eceff1'], + [10, 0, 6, 18, '#eceff1'], [18, 0, 6, 18, '#eceff1'], + [30, 20, 2, 16, '#8b4513'], + ]}, + scorpion: { w: 26, h: 26, rects: [ + [0, 0, 26, 26, '#5d4037'], + [2, 8, 6, 8, '#8d6e63'], [18, 8, 6, 8, '#8d6e63'], + [10, 26, 6, 10, '#5d4037'], + [11, 34, 4, 4, '#ff5722'], + ]}, + polar_bear: { w: 40, h: 34, rects: [ + [0, 0, 40, 34, '#eceff1'], + [18, 25, 4, 3, '#263238'], + [8, 19, 3, 3, '#263238'], [29, 19, 3, 3, '#263238'], + [6, 0, 8, 8, '#cfd8dc'], [26, 0, 8, 8, '#cfd8dc'], + ]}, + slime: { w: 24, h: 24, rects: [ + [0, 0, 24, 24, '#4caf50'], + [6, 12, 5, 5, '#ffffff'], [13, 12, 5, 5, '#ffffff'], + [8, 13, 2, 2, '#000000'], [15, 13, 2, 2, '#000000'], + ]}, + eagle: { w: 30, h: 22, rects: [ + [11, 2, 8, 14, '#5d4037'], + [0, 8, 11, 6, '#795548'], [19, 8, 11, 6, '#795548'], + [12, 14, 6, 6, '#eceff1'], + [18, 16, 4, 2, '#ffc107'], + [14, 16, 2, 2, '#000000'], + ]}, +}; + +// === entities/mob-tex3d.js === +// NN-текстуры мобов (нарисованы в anymodel/nano-banana, одобрены игроком). +// Фас/профиль/спина натягиваются на грани бокса в TDmobMesh. +// Героя НЕ трогаем — он оригинальная пиксель-экструзия из hero-rig.js. +// Верх/низ бокса — плоский средний цвет спрайта. +const MOB_TEX = { + zombie: { top: '#3da027' }, + pig: { top: '#ea77a1' }, + chicken: { top: '#dcc8bf' }, + creeper: { top: '#4a7f3e' }, + skeleton: { top: '#b5b3b1' }, + scorpion: { top: '#37221a' }, + polar_bear: { top: '#dcdcdb' }, + slime: { top: '#7caa71' }, + eagle: { top: '#806041' }, +}; + +const MOB_TEX_V = '3'; + +const _mobTexCache = {}; + +function TDmobTex(kind, view, mirror) { + const key = kind + '_' + view + (mirror ? '_m' : ''); + let t = _mobTexCache[key]; + if (!t) { + t = new THREE.TextureLoader().load('tex/' + kind + '_' + view + '.png?v=' + MOB_TEX_V); + if (mirror) { + t.wrapS = THREE.RepeatWrapping; + t.repeat.x = -1; + } + _mobTexCache[key] = t; + } + return t; +} + // === render/render3d.js === // ==================== РЕНДЕР 3D — GrechkaCraft v3 ==================== // Объёмный мир: у каждого блока есть gx/gy/gz. Рендерим кубы по всем трём @@ -4784,6 +5408,9 @@ const HERO_SCALE = 0.50; + + + let TD = null; function TDparseColor(c) { @@ -4800,6 +5427,12 @@ function TDblockMat(t) { return new THREE.MeshLambertMaterial({ color, transparent: a < 1, opacity: a }); } +// ---------- ОККЛЮЗИЯ: живёт в world/occ3d.js ---------- +// isHiddenAt проверяла 6 соседей через getBlock, а тот строил строку "gx,gy,gz" +// и искал в Map. При 68k кандидатах это ~409k строковых конкатенаций — +// профиль показал 129 мс из 153 мс всего перестроения мешей. Теперь окклюзия +// читает плотный Uint8Array (occ3d.js), обновляемый из setBlock/removeBlock. + function TDisFluid(t) { const d = BLOCKS[t]; return !!(d && d.fluid); } function TDisFlat(t) { const d = BLOCKS[t]; @@ -4807,36 +5440,114 @@ function TDisFlat(t) { return !d.solid || !!d.decor || !!d.climbable; } -// ---------- герой: тот же пиксельный рисунок, кубики ---------- +// ---------- герой: объёмная гречка ---------- +// Пиксельный рисунок 2D-игры (hero-rig.js) выдавливается в глубину: каждая +// часть — настоящий объём одинаковой глубины, а не плоская пластина. Поэтому +// при вращении камеры герой остаётся объёмным, а не превращается в картонку. function TDbuildHeroRig() { const group = new THREE.Group(); const P = {}; + // Toon-растушёвка как в эталонной игре: 4 ступени дают «мультяшный» вид, + // к которому привык игрок, вместо плоского диффузного Lambert. const MAT = {}; - for (const k of Object.keys(HERO_COL)) MAT[k] = new THREE.MeshLambertMaterial({ color: HERO_COL[k].hex }); + const gradTex = (() => { + const steps = 4; + const g = new THREE.DataTexture(new Uint8Array(steps * 4), steps, 1, THREE.RGBAFormat); + for (let i = 0; i < steps; i++) { + const v = Math.round(255 * (0.72 + 0.28 * (i / (steps - 1)))); + g.image.data.set([v, v, v, 255], i * 4); + } + g.minFilter = THREE.LinearMipmapLinearFilter; + g.magFilter = THREE.LinearFilter; + g.generateMipmaps = true; + g.needsUpdate = true; + return g; + })(); + for (const k of Object.keys(HERO_COL)) { + MAT[k] = new THREE.MeshToonMaterial({ color: HERO_COL[k].hex, gradientMap: gradTex }); + } + + // глубина тела в мировых единицах — ширина одного «пикселя» рисунка + // Риг как в эталонной платформенной игре: каждый пиксель рисунка — призма, + // глубина которой берётся ИЗ ЦВЕТА (COL[colorKey].d), а не из общей константы. + // Общая константа давала «растянутую с двух сторон пластину»: у синего слоя + // d=0.6, у тёмно-синих глаз d=1.12 — разная толщина и есть объём. + // Плюс renderOrder = d*1000 — иначе Three.js сортирует слои случайно и + // дальние прямоугольники рисуются поверх ближних. + const dOf = (colorKey) => (HERO_COL[colorKey] && HERO_COL[colorKey].d) || 0.8; + for (const name of Object.keys(HERO_PARTS)) { - const g = new THREE.Group(); + const g = new THREE.Group(); // узел вращения (сустав) const j = HERO_PIVOT[name]; g.position.set(j[0] * HERO_SCALE, j[1] * HERO_SCALE, 0); - const inner = new THREE.Group(); + const inner = new THREE.Group(); // содержимое узла inner.position.set(-j[0] * HERO_SCALE, -j[1] * HERO_SCALE, 0); g.add(inner); group.add(g); P[name] = g; + for (const r of HERO_PARTS[name]) { - const depth = HERO_COL[r[4]].d * HERO_SCALE; - const m = new THREE.Mesh( - new THREE.BoxGeometry(r[2] * HERO_SCALE, r[3] * HERO_SCALE, depth), - MAT[r[4]] - ); - m.position.set((r[0] - 0.24) * HERO_SCALE, r[1] * HERO_SCALE, depth / 2); + const [rx, ry, rw, rh, colorKey] = r; + const d = dOf(colorKey) * HERO_SCALE; + const w = rw * HERO_SCALE; + const h = rh * HERO_SCALE; + const geo = new THREE.BoxGeometry(w, h, d); + // центрируем по X (в рисунке левый край -1.17.., центр около -0.24), + // по Z смещаем к зрителю, чтобы слои не уходили вглубь земли + geo.translate((rx - 0.24) * HERO_SCALE + w / 2, ry * HERO_SCALE + h / 2, d / 2); + const m = new THREE.Mesh(geo, MAT[colorKey] || MAT.blue); + m.renderOrder = Math.round(d * 1000); m.castShadow = true; m.receiveShadow = true; inner.add(m); } } + + // Нос — объёмный, выносим на переднюю грань тела + const nose = new THREE.Mesh( + new THREE.BoxGeometry(0.16, 0.12, 0.18), + new THREE.MeshToonMaterial({ color: 0xf7c873, gradientMap: gradTex }) + ); + nose.position.set(0.05, 2.13 * HERO_SCALE, 0.30 * HERO_SCALE + 0.02); + P.head.add(nose); + return { group, P }; } +// Простое склеивание геометрий (буферы BoxGeometry одинакового формата) +function TDmergeGeometries(geos) { + if (geos.length === 1) return geos[0]; + let vCount = 0, iCount = 0; + for (const g of geos) { + vCount += g.attributes.position.count; + iCount += g.index ? g.index.count : 0; + } + const pos = new Float32Array(vCount * 3); + const nor = new Float32Array(vCount * 3); + const uv = new Float32Array(vCount * 2); + const idx = new Uint32Array(iCount); + let vo = 0, io = 0; + for (const g of geos) { + const p = g.attributes.position, n = g.attributes.normal, u = g.attributes.uv; + pos.set(p.array, vo * 3); + if (n) nor.set(n.array, vo * 3); + if (u) uv.set(u.array, vo * 2); + if (g.index) { + const gi = g.index.array; + for (let k = 0; k < gi.length; k++) idx[io + k] = gi[k] + vo; + io += gi.length; + } + vo += p.count; + } + const out = new THREE.BufferGeometry(); + out.setAttribute('position', new THREE.BufferAttribute(pos, 3)); + out.setAttribute('normal', new THREE.BufferAttribute(nor, 3)); + out.setAttribute('uv', new THREE.BufferAttribute(uv, 2)); + out.setIndex(new THREE.BufferAttribute(idx, 1)); + out.computeBoundingSphere(); + return out; +} + const TD_MOB_COLORS = { zombie: 0x2ecc71, pig: 0xffb6c1, chicken: 0xecf0f1, creeper: 0x4caf50, skeleton: 0xeceff1, scorpion: 0x5d4037, polar_bear: 0xeceff1, @@ -4861,7 +5572,14 @@ function TDnameSprite(name) { // ==================== INIT ==================== function TDinit() { const canvas = state.canvas; - const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); + // preserveDrawingBuffer ОБЯЗАТЕЛЕН: без него canvas.toDataURL() + // возвращает ЧЁРНЫЙ кадр, и невозможно снять скриншот для проверки вида. + // Стоит копейка производительности, но даёт рабочую диагностику. + const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true }); + if (renderer.toneMapping !== undefined && THREE.ACESFilmicToneMapping !== undefined) { + renderer.toneMapping = THREE.ACESFilmicToneMapping; + renderer.toneMappingExposure = 0.92; // >1 даёт сплошной засвет + } if (renderer.outputEncoding !== undefined && THREE.sRGBEncoding !== undefined) { renderer.outputEncoding = THREE.sRGBEncoding; } @@ -4870,18 +5588,36 @@ function TDinit() { const scene = new THREE.Scene(); scene.background = new THREE.Color(0x8fc7f5); - scene.fog = new THREE.Fog(0x8fc7f5, 30, 90); - const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 300); + // Туман заканчивается на границе прорисовки (VIEW_R = 14 тайлов = 560). + // Было 30..90 при TILE=40 — то есть видимость всего ~2 тайла, отсюда + // жалоба «прорисовка десять блоков». Теперь туман мягкий и соответствует дальности. + // Дальность: VIEW_R = 26 тайлов (1040 px). При 14 тайлах мир обрывался + // заметно раньше тумана — отсюда «остров, висящий в пространстве». + // Туман начинается за пределами прорисовки (VIEW_R=30 тайлов = 1200 px), + // иначе мир «обрывается» раньше, чем заканчиваются блоки. + // VIEW_R = 30 тайлов = 1200 px. Туман должен начинаться ЗНАЧИТЕЛЬНО дальше + // этой границы, иначе весь видимый мир оказывается в сплошном цвете неба — + // это и был «сплошной засвет». Держим запас: туман с 60% дальности. + // VIEW_R = 46 тайлов = 1840 px. Туман ОТКРЫВАЕТСЯ ДО края прорисовки + // (near 760) и полностью гаснет на 1560: рельеф растворяется в дымке, + // вместо того чтобы обрываться в пустое небо. Раньше near=1000 был позже + // края — отсюда «парящий остров». + scene.fog = new THREE.Fog(0x8fc7f5, 760, 1560); + const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 1700); - const hemi = new THREE.HemisphereLight(0xcfe6ff, 0x6ab04c, 0.85); - const sun = new THREE.DirectionalLight(0xffffff, 1.5); + // Свет: сумма была 2.6 при отсутствии tone mapping — отсюда ультра-яркие + // пастельные тона. Ставим суммарно ~1.15 и включаем тонмаппинг. + // Свет суммарно ~0.95. При 1.3 и без tone mapping земля выгорала в бежевый + // («ультра-яркие тона»), терялся цвет блоков. + const hemi = new THREE.HemisphereLight(0xcfe6ff, 0x5a8f3c, 0.34); + const sun = new THREE.DirectionalLight(0xfff4e0, 0.61); sun.castShadow = true; sun.shadow.mapSize.set(512, 512); const sc = sun.shadow.camera; sc.left = -26; sc.right = 26; sc.top = 26; sc.bottom = -26; sc.near = 1; sc.far = 140; sun.shadow.normalBias = 0.05; - const amb = new THREE.AmbientLight(0xffffff, 0.25); + const amb = new THREE.AmbientLight(0xffffff, 0.06); scene.add(hemi, sun, sun.target, amb); const hero = TDbuildHeroRig(); @@ -4904,119 +5640,242 @@ function TDinit() { // куб подсветки выбранного блока const outline = new THREE.LineSegments( - new THREE.EdgesGeometry(new THREE.BoxGeometry(1.03, 1.03, 1.03)), - new THREE.LineBasicMaterial({ color: 0xffffff }) + new THREE.EdgesGeometry(new THREE.BoxGeometry(1.02, 1.02, 1.02)), + new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.85 }) ); outline.visible = false; scene.add(outline); + // Прицел: блок, на который смотрит игрок. Обновляется каждый кадр рейкастом. + // Камера стоит за игроком, поэтому целимся от камеры вперёд по направлению взгляда. + const raycaster = new THREE.Raycaster(); + const _rcDir = new THREE.Vector3(); + const aim = { gx: 0, gy: 0, gz: 0, px: 0, py: 0, face: null, dist: 0, valid: false }; + const cloudMeshes = []; - const cloudMat = new THREE.MeshLambertMaterial({ color: 0xffffff, transparent: true, opacity: 0.75 }); + // Облака: MeshBasic, а не Lambert. Lambert без нормального света давал серые + // пятна в небе (визуальный артефакт). Basic всегда чисто-белый. + const cloudMat = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.85, depthWrite: false }); for (let i = 0; i < 16; i++) { const m = new THREE.Mesh(new THREE.BoxGeometry(4, 0.8, 2.2), cloudMat); scene.add(m); cloudMeshes.push(m); } - const projPool = []; - for (let i = 0; i < 24; i++) { - const m = new THREE.Mesh(new THREE.BoxGeometry(0.55, 0.1, 0.1), new THREE.MeshBasicMaterial({ color: 0xf1c40f })); - m.visible = false; - scene.add(m); - projPool.push(m); + // ---------- Прицел: блок перед игроком по направлению взгляда ---------- + const AIM_MAX = 7; // тайлов, как в майнкрафте + const projPool = []; + for (let i = 0; i < 24; i++) { + const m = new THREE.Mesh(new THREE.BoxGeometry(0.55, 0.1, 0.1), new THREE.MeshBasicMaterial({ color: 0xf1c40f })); + m.visible = false; + scene.add(m); + projPool.push(m); + } + + const PMAX = 800; + const pGeo = new THREE.BufferGeometry(); + const pPos = new Float32Array(PMAX * 3); + const pCol = new Float32Array(PMAX * 3); + pGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3)); + pGeo.setAttribute('color', new THREE.BufferAttribute(pCol, 3)); + const points = new THREE.Points(pGeo, new THREE.PointsMaterial({ size: 0.16, vertexColors: true, depthWrite: false })); + points.frustumCulled = false; + scene.add(points); + + function makeFall(n, color, size) { + const g = new THREE.BufferGeometry(); + const pos = new Float32Array(n * 3); + g.setAttribute('position', new THREE.BufferAttribute(pos, 3)); + const pts = new THREE.Points(g, new THREE.PointsMaterial({ color, size, transparent: true, opacity: 0.7, depthWrite: false })); + pts.frustumCulled = false; + pts.visible = false; + scene.add(pts); + return { pts, pos, n }; + } + const rain = makeFall(500, 0x74b9ff, 0.2); + const snow = makeFall(300, 0xffffff, 0.17); + + const lightPool = []; + for (let i = 0; i < 6; i++) { + const L = new THREE.PointLight(0xff9a3c, 0, 16); + scene.add(L); + lightPool.push(L); + } + const flamePool = []; + for (let i = 0; i < 8; i++) { + const f = new THREE.Mesh(new THREE.ConeGeometry(0.3, 0.75, 6), new THREE.MeshBasicMaterial({ color: 0xff7a1a })); + f.visible = false; + scene.add(f); + flamePool.push(f); + } + + const boatMesh = new THREE.Mesh( + new THREE.BoxGeometry(1.4, 0.5, 1.0), + new THREE.MeshLambertMaterial({ color: 0x8b4513 }) + ); + boatMesh.visible = false; + boatMesh.castShadow = true; + scene.add(boatMesh); + + let TDtarget = null; // блок под прицелом — для кнопки копки + + TD = { + get target() { return TDtarget; }, + updateAim(px, py, pz) { + // Направление луча = КУДА СМОТРИТ КАМЕРА, а не «на игрока». + // Раньше здесь стояло (px - camera.position.x, ...), то есть луч всегда + // упирался в самого героя, и прицел показывал блок ПОД НОГАМИ игрока + // (face: {y:1}) — добыть и поставить было невозможно. + // Камера уже развёрнута lookAt вдоль курса, поэтому берём его из неё. + camera.getWorldDirection(_rcDir); + // Дистанция камера->игрок — отдельная величина. getWorldDirection уже + // возвращает ЕДИНИЧНЫЙ вектор, поэтому брать длину из _rcDir нельзя: + // получалось camDist = 1, near = 1.15, и всё, что дальше, отбрасывалось + // как «за игроком» — прицел всегда указывал в опору под ногами. + const camDist = Math.sqrt( + (px - camera.position.x) ** 2 + (py - camera.position.y) ** 2 + (pz - camera.position.z) ** 2 + ); + if (camDist < 1e-6) return aim; + _rcDir.normalize(); + + // Рейкаст ОБЯЗАН быть направленным: без set() он стреляет из (0,0,0) + // в -Z, то есть мимо мира — прицел и добыча не работали вообще. + // Луч: из камеры через игрока. Блоки между камерой и игроком отбрасываем. + raycaster.set(camera.position, _rcDir); + raycaster.far = camDist + AIM_MAX; + // Луч идёт ОТ КАМЕРЫ ЧЕРЕЗ ИГРОКА. Всё, что ближе игрока (distance < + // camDist), — это блоки между камерой и игроком, их надо отбросить. + // Граница должна быть СТРОГО больше дистанции до игрока, иначе блок + // ОПОРЫ под ногами (его верхняя грань лежит ровно на луче) попадает + // в выборку, и прицел всегда указывает в землю под игроком: + // target = { gx: <колонка игрока>, face: {y: 1} }, добыть что-то нельзя. + raycaster.near = camDist + 0.15; + + const meshes = TD.blockMeshes; + TD.aimCalls = (TD.aimCalls || 0) + 1; + let best = null, bestD = camDist + AIM_MAX, _rawHits = 0; + for (const e of meshes) { + const hits = raycaster.intersectObject(e.mesh, false); + for (const h of hits) { + _rawHits++; + if (h.distance < camDist - 0.5) continue; // это блок ПЕРЕД игроком, не наш + if (h.distance < bestD) { bestD = h.distance; best = { h, e }; } + } + } + if (!best || best.h.instanceId === undefined) { outline.visible = false; aim.valid = false; + TD.aimDbg = { hits: _rawHits, meshes: meshes.length, camDist: +camDist.toFixed(2), why: best ? 'no-instance' : 'no-best' }; return aim; } + + // Точные координаты блока лежат в меше (userData.list). Матричная + // конвертация была неверной: по X/Z давала +1 тайл, по Y — минус. + // Поэтому прицел указывал в пустоту с мусором вроде (-15,-15,15). + const _b = best.e.mesh.userData.list[best.h.instanceId]; + if (!_b) { outline.visible = false; aim.valid = false; + TD.aimDbg = { hits: _rawHits, meshes: meshes.length, camDist: +camDist.toFixed(2), why: 'no-list' }; return aim; } + const gx = _b.gx, gy = _b.gy, gz = (_b.gz || 0); + const n = best.h.face ? best.h.face.normal : null; + // нормаль в локальных осях куба = мировые (инстансы только переносятся) + const face = n ? { x: Math.round(n.x), y: Math.round(n.y), z: Math.round(n.z) } : null; + + aim.gx = gx; aim.gy = gy; aim.gz = gz; + aim.px = gx + 0.5; aim.py = gy + 0.5; aim.face = face; + aim.dist = bestD - camDist; aim.valid = true; // дистанция от ИГРОКА, не от камеры + TD.aimDbg = { hits: _rawHits, meshes: meshes.length, camDist: +camDist.toFixed(2) }; + TDtarget = aim; + + // Y в three инвертирован: позиция инстанса (gx+0.5,-gy-0.5,gz+0.5). + // Было (aim.px, aim.py=gy+0.5, ...) — рамка висела в зеркальной точке. + outline.position.set(gx + 0.5, -gy - 0.5, gz + 0.5); + outline.visible = true; + }, + renderer, scene, camera, hemi, sun, amb, hero, + crack, ghost, outline, cloudMeshes, projPool, points, pPos, pCol, PMAX, + rain, snow, lightPool, flamePool, boatMesh, + blockMeshes: [], lastVersion: -1, meshT: 0, + mobs: new Map(), players: new Map(), + yaw: 0.0, pitch: 0.28, dist: 10, // 0.28 — вид сбоку; 0.55 смотрел в небо + ptrs: new Map(), pinchD: 0, + lastTap: null, hovered: null, fireCache: null, + heroStep: 0, + + heroTime: 0, face: 1, frame: 0, lastT: 0, + colCache: new Map(), + }; + // DIAG-зонд: повторяет луч прицела и возвращает сырые цифры. + TD.probeAim = function() { + const out = { meshes: TD.blockMeshes.length, near: +raycaster.near.toFixed(2), far: +raycaster.far.toFixed(2) }; + try { + const all = []; + for (const e of TD.blockMeshes) { + const h = raycaster.intersectObject(e.mesh, false); + if (h.length) all.push({ type: e.type, n: h.length, d0: +h[0].distance.toFixed(2), iid: h[0].instanceId }); + } + out.hits = all.slice(0, 8); out.total = all.reduce((a, x) => a + x.n, 0); + out.rayO = camera.position.toArray().map(v => +v.toFixed(2)); + // контрольный замер без near/far: если хиты есть — режет фильтр дистанций + const _n = raycaster.near, _f = raycaster.far; + raycaster.near = 0; raycaster.far = 100; + let wide = 0, firstD = -1; + for (const e of TD.blockMeshes) { + const h = raycaster.intersectObject(e.mesh, false); + if (h.length && !wide) { wide = h.length; firstD = +h[0].distance.toFixed(2); } + else if (h.length) wide += h.length; + } + raycaster.near = _n; raycaster.far = _f; + out.wide = wide; out.firstD = firstD; + } catch (err) { out.err = String(err).slice(0, 100); } + return out; + }; + TD.aim = aim; TD.outline = outline; TD.pick = TDpick; TD.tap = handleTap; + // TD публикуется ВСЕГДА: кнопка копки на мобильном читает отсюда + // цель (window.__td.target). Раньше объект создавался только по + // ?diag=1, и тач-кнопка «копка» молча не работала в обычной игре. + window.__td = TD; + if (/[?&]diag=1/.test(location.search)) { /* диаг-режим: хук уже есть */ } + TDbindInput(canvas); + TDresize(); + window.addEventListener('resize', TDresize); } - const PMAX = 800; - const pGeo = new THREE.BufferGeometry(); - const pPos = new Float32Array(PMAX * 3); - const pCol = new Float32Array(PMAX * 3); - pGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3)); - pGeo.setAttribute('color', new THREE.BufferAttribute(pCol, 3)); - const points = new THREE.Points(pGeo, new THREE.PointsMaterial({ size: 0.16, vertexColors: true, depthWrite: false })); - points.frustumCulled = false; - scene.add(points); - - function makeFall(n, color, size) { - const g = new THREE.BufferGeometry(); - const pos = new Float32Array(n * 3); - g.setAttribute('position', new THREE.BufferAttribute(pos, 3)); - const pts = new THREE.Points(g, new THREE.PointsMaterial({ color, size, transparent: true, opacity: 0.7, depthWrite: false })); - pts.frustumCulled = false; - pts.visible = false; - scene.add(pts); - return { pts, pos, n }; - } - const rain = makeFall(500, 0x74b9ff, 0.2); - const snow = makeFall(300, 0xffffff, 0.17); - - const lightPool = []; - for (let i = 0; i < 6; i++) { - const L = new THREE.PointLight(0xff9a3c, 0, 16); - scene.add(L); - lightPool.push(L); - } - const flamePool = []; - for (let i = 0; i < 8; i++) { - const f = new THREE.Mesh(new THREE.ConeGeometry(0.3, 0.75, 6), new THREE.MeshBasicMaterial({ color: 0xff7a1a })); - f.visible = false; - scene.add(f); - flamePool.push(f); + function TDresize() { + if (!TD) return; + const w = window.innerWidth, h = window.innerHeight; + state.W = w; + state.H = h; + TD.renderer.setPixelRatio(1); + TD.renderer.setSize(w, h, false); + TD.camera.aspect = w / h; + TD.camera.updateProjectionMatrix(); } - const boatMesh = new THREE.Mesh( - new THREE.BoxGeometry(1.4, 0.5, 1.0), - new THREE.MeshLambertMaterial({ color: 0x8b4513 }) - ); - boatMesh.visible = false; - boatMesh.castShadow = true; - scene.add(boatMesh); + // ==================== БЛОКИ ==================== + // Полный пересбор мешей редкий; в обычном кадре — только обновление матриц. + // Радиус прорисовки. Должен ЗНАЧИТЕЛЬНО превышать дистанцию камеры + // (макс 34 тайла) — иначе за краем сетки видно пустое небо, и мир выглядит + // парящим островом. 46 тайлов при тумане с 1000 до 1500: край рельефа + // успевает уйти в туман, а не обрываться в пустоту. + const VIEW_R = 46; // тайлов по всем осям - TD = { - renderer, scene, camera, hemi, sun, amb, hero, - crack, ghost, outline, cloudMeshes, projPool, points, pPos, pCol, PMAX, - rain, snow, lightPool, flamePool, boatMesh, - blockMeshes: [], lastVersion: -1, meshT: 0, - mobs: new Map(), players: new Map(), - yaw: 0.0, pitch: 0.42, dist: 10, - ptrs: new Map(), pinchD: 0, - lastTap: null, hovered: null, fireCache: null, - heroStep: 0, face: 1, frame: 0, lastT: 0, - colCache: new Map(), - }; - TDbindInput(canvas); - TDresize(); - window.addEventListener('resize', TDresize); -} + // Границы плотной сетки: за ними occIndex отдаёт -1, и «сосед» через край + // массива не должен заворачиваться на противоположную сторону мира. + function isHiddenAt(gx, gy, gz) { + // Окклюзия читает плотную сетку occ3d.js, а не getBlock: 6 чтений массива + // против 6 строковых ключей "gx,gy,gz". Семантика та же — все 6 соседей + // непрозрачные. Раскладка индекса: (gy*OCC_Z + (gz-OCC_MIN))*OCC_X + (gx-OCC_MIN), + // то есть шаг по gx = 1, по gz = OCC_X, по gy = OCC_X*OCC_Z. + if (gx <= OCC_MIN || gx >= OCC_MAX || gz <= OCC_MIN || gz >= OCC_MAX || + gy <= 0 || gy >= OCC_MAX_GY) return false; + const i = occIndex(gx, gy, gz); + return occGrid[i - OCC_STEP_X] === OCC_OPAQUE && occGrid[i + OCC_STEP_X] === OCC_OPAQUE && + occGrid[i - OCC_STEP_Z] === OCC_OPAQUE && occGrid[i + OCC_STEP_Z] === OCC_OPAQUE && + occGrid[i - OCC_STEP_Y] === OCC_OPAQUE && occGrid[i + OCC_STEP_Y] === OCC_OPAQUE; + } -function TDresize() { - if (!TD) return; - const w = window.innerWidth, h = window.innerHeight; - state.W = w; - state.H = h; - TD.renderer.setPixelRatio(1); - TD.renderer.setSize(w, h, false); - TD.camera.aspect = w / h; - TD.camera.updateProjectionMatrix(); -} - -// ==================== БЛОКИ ==================== -// Полный пересбор мешей редкий; в обычном кадре — только обновление матриц. -const VIEW_R = 14; // радиус прорисовки в тайлах по всем осям - -function isHiddenAt(gx, gy, gz) { - // спрятан ли блок: все 6 соседей непрозрачные? - return isOpaqueAt(gx + 1, gy, gz) && isOpaqueAt(gx - 1, gy, gz) && - isOpaqueAt(gx, gy + 1, gz) && isOpaqueAt(gx, gy - 1, gz) && - isOpaqueAt(gx, gy, gz + 1) && isOpaqueAt(gx, gy, gz - 1); -} - -function isOpaqueAt(gx, gy, gz) { - const b = getBlock(gx, gy, gz); - if (!b) return false; - const def = BLOCKS[b.t]; - if (!def) return false; - return !!def.solid && !def.fluid && !def.decor && !def.climbable && def.alpha === undefined; -} + function isOpaqueAt(gx, gy, gz) { + // Одинокий lookup по сетке окклюзии (а не getBlock со строковым ключом). + // Используется точечно там, где нужен именно факт непрозрачности клетки. + const i = occIndex(gx, gy, gz); + return i >= 0 && occGrid[i] === OCC_OPAQUE; + } function TDrebuildBlocks(px, py, pz) { for (const e of TD.blockMeshes) { @@ -5032,6 +5891,13 @@ function TDrebuildBlocks(px, py, pz) { const dx = b.gx - px, dy = b.gy - py, dz = (b.gz || 0) - pz; // py — игровой gy if (dx * dx + dy * dy + dz * dz > VIEW_R * VIEW_R) continue; if (!TDisFluid(b.t) && !TDisFlat(b.t) && isHiddenAt(b.gx, b.gy, b.gz || 0)) continue; + // Воду рисуем ТОЛЬКО верхним слоем. Раньше в колонке было до 30 кубов воды + // подряд, и каждый был полупрозрачным — вместе они давали «мир в + // полупрозрачных квадратах» и 29k лишних инстансов. + if (TDisFluid(b.t)) { + const up = getBlock(b.gx, b.gy - 1, b.gz || 0); + if (up && TDisFluid(up.t)) continue; // не верхний слой — пропускаем + } if (!byType.has(b.t)) byType.set(b.t, []); byType.get(b.t).push(b); } @@ -5055,8 +5921,8 @@ function TDrebuildBlocks(px, py, pz) { q.setFromEuler(e); m.compose(new THREE.Vector3(b.gx + 0.5, -b.gy + 0.05, (b.gz || 0) + 0.5), q, new THREE.Vector3(1, 1, 1)); } else if (fluid) { - m.makeScale(1, 0.82, 1); - m.setPosition(b.gx + 0.5, -b.gy - 0.09, (b.gz || 0) + 0.5); + m.makeScale(1, 0.9, 1); + m.setPosition(b.gx + 0.5, -b.gy - 0.5, (b.gz || 0) + 0.5); } else { m.identity(); m.setPosition(b.gx + 0.5, -b.gy - 0.5, (b.gz || 0) + 0.5); @@ -5079,17 +5945,36 @@ function TDpick(sx, sy) { TDndc.x = ((sx - r.left) / r.width) * 2 - 1; TDndc.y = -((sy - r.top) / r.height) * 2 + 1; TDray.setFromCamera(TDndc, TD.camera); + // Отбрасываем блоки МЕЖДУ камерой и игроком — ровно как делает updateAim. + // Без этого pick возвращал ПЕРВОЕ пересечение луча (землю под ногами + // героя или блок за его спиной), а прицел показывал блок ПОСЛЕ игрока. + // Клик в итоге попадал на 1-9 тайлов в сторону от того, что видно. + const _pl = state.player; + const _px = (_pl.x + _pl.w / 2) / state.TILE; + const _py = -(_pl.y + _pl.h / 2) / state.TILE; + const _pz = ((_pl.z || 0) + (_pl.d || _pl.w) / 2) / state.TILE; + const _camDist = Math.hypot(_px - TD.camera.position.x, _py - TD.camera.position.y, _pz - TD.camera.position.z); const hits = TDray.intersectObjects(TD.blockMeshes.map(x => x.mesh), false); - if (!hits.length) return null; - const h = hits[0]; + let h = null; + for (const cand of hits) { + if (cand.distance < _camDist + 0.15) continue; // камера, герой, опора + h = cand; + break; + } + if (!h) return null; const b = h.object.userData.list[h.instanceId]; if (!b) return null; - // мировые игровые координаты точки удара - const gx = Math.floor(h.point.x); - const gy = -Math.floor(h.point.y); - const gz = Math.floor(h.point.z); - const wx = (h.point.x) * state.TILE; - const wy = (-h.point.y) * state.TILE; + // ТОЧНЫЕ координаты блока берём из userData.list, а НЕ из h.point. +// При попадании в грань куба h.point лежит РОВНО на границе тайла +// (например z = -3.0), и floor() давал СОСЕДНИЙ блок: прицел показывал +// один блок, а клик ломал другой (в 1 тайле в сторону). Из-за этого печь +// «не открывалась»: клик уходил в соседний тайл, где furnace нет. +const gx = b.gx; +const gy = b.gy; +const gz = b.gz != null ? b.gz : (b.gz || 0); +// point нужен для точной точки попадания (установка по грани) +const wx = (h.point.x) * state.TILE; +const wy = (-h.point.y) * state.TILE; return { gx, gy, gz, wx, wy, face: h.face ? h.face.normal : null }; } @@ -5119,20 +6004,32 @@ function TDbindInput(canvas) { } if (q.moved) { TD.yaw -= dx * 0.005; - TD.pitch = Math.min(1.45, Math.max(0.06, TD.pitch + dy * 0.005)); + TD.pitch = Math.min(0.95, Math.max(0.06, TD.pitch + dy * 0.005)); // >0.9 — камера в небе, горизонта нет } }); const up = (e) => { + TD.tapDbg = TD.tapDbg || { ups: 0, noq: 0, moved: 0, slow: 0, picks: 0, taps: 0 }; + TD.tapDbg.ups++; const q = TD.ptrs.get(e.pointerId); TD.ptrs.delete(e.pointerId); TD.pinchD = 0; - if (q && !q.moved && performance.now() - q.t < 600) { - const hit = TDpick(e.clientX, e.clientY); - if (hit) { - TD.lastTap = hit; - handleTap(hit.wx, hit.wy, hit.gz, hit.face); - } + if (!q) { TD.tapDbg.noq++; return; } + if (q.moved) { TD.tapDbg.moved++; return; } + // Ограничение в 600 мс БЫЛО БАГОМ: игрок нажимает и держит (например, + // целится), а потом отпускает — и действие отменялось совсем, добыча и + // установка становились невозможны. Долгое удержание нужно не отменять, + // а считать осознанным: если указатель не двигался, действуем по отпусканию. + // Отменяем только если указатель двигался (это поворот камеры) — + // проверка q.moved уже стоит выше. + TD.tapDbg.picks++; + const hit = TDpick(e.clientX, e.clientY); + if (hit) { + TD.tapDbg.taps++; + TD.lastTap = hit; + // Передаём ТАЙЛОВЫЕ координаты блока: handleTap больше не делит + // wx/wy на TILE, иначе цель смещалась на 1 тайл. + handleTap(hit.gx, hit.gy, hit.gz, hit.face); } }; canvas.addEventListener('pointerup', up); @@ -5150,20 +6047,52 @@ function TDmobMesh(m) { let e = TD.mobs.get(m); if (!e) { const g = new THREE.Group(); - const body = new THREE.Mesh( - new THREE.BoxGeometry(1, 1, 1), - new THREE.MeshLambertMaterial({ color: TD_MOB_COLORS[m.kind] || 0x2ecc71 }) - ); - body.castShadow = true; - g.add(body); - const eyeM = new THREE.MeshBasicMaterial({ color: 0x111111 }); - const e1 = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.14, 0.05), eyeM); - e1.position.set(-0.16, 0.22, 0.26); - const e2 = e1.clone(); - e2.position.x = 0.16; - g.add(e1, e2); + // Точные копии мобов 2D-игры (MOB_SPRITES: те же rects и цвета, что в game.js), + // выдавлены в глубину. Координаты нормализованы (0..1) — реальный размер + // задаёт e.body.scale ниже, как раньше делал бокс 1x1x1. + const bodyG = new THREE.Group(); + g.add(bodyG); + const spec = MOB_SPRITES[m.kind]; + if (MOB_TEX[m.kind]) { + // NN-текстуры (одобрены игроком): фас +z, спина -z, профиль ±x, + // верх/низ — плоский средний цвет. alphaTest: силуэт по альфе спрайта. + const mat = (view, mirror) => new THREE.MeshLambertMaterial({ + map: TDmobTex(m.kind, view, mirror), alphaTest: 0.5, + }); + const top = new THREE.MeshLambertMaterial({ color: MOB_TEX[m.kind].top }); + const bm = new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + [mat('side'), mat('side', true), top, top, mat('front'), mat('back')] + ); + bm.castShadow = true; + bodyG.add(bm); + } else if (spec) { + const zBody = 0.6; + spec.rects.forEach(([rx, ry, rw, rh, c], i) => { + const deep = i === 0 ? zBody : zBody + 0.15; + const geo = new THREE.BoxGeometry(rw / spec.w, rh / spec.h, deep); + geo.translate((rx + rw / 2) / spec.w - 0.5, (ry + rh / 2) / spec.h - 0.5, deep / 2); + const mesh = new THREE.Mesh(geo, new THREE.MeshLambertMaterial({ color: c })); + mesh.renderOrder = 100 + i; + mesh.castShadow = true; + bodyG.add(mesh); + }); + } else { + const bm = new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshLambertMaterial({ color: TD_MOB_COLORS[m.kind] || 0x2ecc71 }) + ); + bm.castShadow = true; + bodyG.add(bm); + const eyeM = new THREE.MeshBasicMaterial({ color: 0x111111 }); + const e1 = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.14, 0.05), eyeM); + e1.position.set(-0.16, 0.22, 0.26); + const e2 = e1.clone(); + e2.position.x = 0.16; + bodyG.add(e1, e2); + } TD.scene.add(g); - e = { g, body }; + e = { g, body: bodyG }; TD.mobs.set(m, e); } return e; @@ -5222,23 +6151,207 @@ function renderFrame3D(now) { // камера-орбита; yaw — тот же угол, что и для WASD camDir.yaw = TD.yaw; const cp = Math.cos(TD.pitch), sp2 = Math.sin(TD.pitch); - TD.camera.position.set( - ptx + TD.dist * Math.sin(TD.yaw) * cp, - py3 + TD.dist * sp2, - ptz + TD.dist * Math.cos(TD.yaw) * cp + // Камера на уровне центра игрока. Поднимать базовую высоту нельзя: + // при 2.4/2.6 тайла мир ПРОПАДАЛ совсем (проверено) — вероятно, блоки + // отсекаются по вертикали относительно игрока, а не относительно камеры. + // Камера от игрока (вид сбоку). Требования: + // 1) не садиться ВНУТРЬ блока — иначе экран забит текстурой; + // 2) зум должен ОТЪЕЗЖАТЬ, а не упираться: игрок в яме, земля сзади + // уже на 1.6 тайла, поэтому просто укорачивать бесполезно — + // при блокировке поднимаем камеру выше и смотрим сверху; + // 3) минимальная дистанция 3.0 — ближе герой занимает треть экрана. + { + // точка занята твёрдым блоком с учётом зарадиуса R (камера «толстая») + // ---------- КАМЕРА: единый якорь и честная проверка ---------- + // Игровая Y направлена ВНИЗ (гравитация тянет в +y), three Y — вверх. + // Всё ниже работает в three-координатах: eyeY = -(gameY) + запас. + const CAM_R = 0.22; // запас от грани куба + const EYE = 0.62; // высота глаз над пятками (в тайлах) + const solidTile = (gx, gy, gz) => { + const b = getBlock(gx, gy, gz); + if (!b) return false; + const d = BLOCKS[b.t]; + return !!(d && d.solid && !d.fluid && !d.decor && !d.climbable); + }; + // ЖЁСТКАЯ ГАРАНТИЯ: только свой тайл. Если он твёрдый — камера внутри. + // Именно эта проверка обязана быть непротиворечивой, всё остальное — + // только для плавности. + const insideSolid = (x, y, z) => + solidTile(Math.floor(x), Math.floor(-y), Math.floor(z)); + // Мягкая проверка: свой тайл + близость к грани соседнего куба. + const solidNear = (x, y, z) => { + const gx = Math.floor(x), gy = Math.floor(-y), gz = Math.floor(z); + if (solidTile(gx, gy, gz)) return true; + if (solidTile(gx + 1, gy, gz) && x - (gx + 1) < 0.5 + CAM_R) return true; + if (solidTile(gx - 1, gy, gz) && (gx + 1) - x < 0.5 + CAM_R) return true; + if (solidTile(gx, gy + 1, gz) && (-y) - (gy + 1) < 0.5 + CAM_R) return true; + if (solidTile(gx, gy - 1, gz) && (gy + 1) - (-y) < 0.5 + CAM_R) return true; + if (solidTile(gx, gy, gz + 1) && z - (gz + 1) < 0.5 + CAM_R) return true; + if (solidTile(gx, gy, gz - 1) && (gz + 1) - z < 0.5 + CAM_R) return true; + return false; + }; + // Якорь — точка на уровне глаз героя. Физика не даёт игроку быть в блоке, + // поэтому якорь гарантированно свободен и является последним откатом. + const anchorX = ptx, anchorY = py3 + EYE, anchorZ = ptz; + // Высота камеры над якорем. Раньше жёстко 1.2 + d*0.16: в яме глубиной + // 4 тайла этого НЕ хватало, solveDist возвращал 0, включался первый план + // (camDist=0) и кадр показывал пустое небо — игрок не видел вообще ничего. + // Наклон растёт с дистанцией, и на больших d камера должна явно подниматься. + const heightFor = (d) => 1.2 + d * 0.22; + // Смещение камеры относительно якоря при дистанции d и наклоне pitch. + const offsetAt = (d, pitch) => { + const maxSin = Math.min(0.55, heightFor(d) / Math.max(d, 0.001)); + const sn = Math.min(Math.sin(pitch), maxSin); + const c = Math.sqrt(Math.max(0.05, 1 - sn * sn)); + return { dx: Math.sin(TD.yaw) * c * d, dy: sn * d, dz: Math.cos(TD.yaw) * c * d }; + }; + // Ищем максимальную безопасную дистанцию: идём от желаемой к якорю и + // берём последнюю свободную точку. Никакого «угадывания» на глаз. + const solveDist = (d, pitch) => { + const o = offsetAt(d, pitch); + if (!solidNear(anchorX + o.dx, anchorY + o.dy, anchorZ + o.dz)) return d; + const steps = Math.ceil(d / 0.2); + for (let i = steps - 1; i >= 1; i--) { + const t = d * (i / steps); + const p2 = offsetAt(t, pitch); + const x = anchorX + p2.dx, y = anchorY + p2.dy, z = anchorZ + p2.dz; + if (!solidNear(x, y, z)) { + // Жёсткая проверка по СВОЕМУ тайлу обязана выполняться. + if (!insideSolid(x, y, z)) return t; + } + } + return 0; + }; + const PITCH_MAX_AUTO = 0.62; // выше — вид сверху, горизонта нет + let pitch = Math.min(TD.pitch, PITCH_MAX_AUTO); + let dist = solveDist(TD.dist, pitch); + if (dist <= 0.2) { + // За спиной глухо на этой высоте — пробуем поднять камеру. + for (let cand = pitch + 0.12; cand <= PITCH_MAX_AUTO + 0.001; cand += 0.12) { + const d2 = solveDist(TD.dist, cand); + if (d2 > 0.2) { pitch = cand; dist = d2; break; } + } + } + let firstPerson = false; + if (dist <= 0.2) { + // Свободного места за спиной нет вообще: вид от первого лица. + firstPerson = true; + // Ищем свободный тайл для глаза: перебираем вверх от якоря. + // Ищем свободное место для глаза рядом с головой, а НЕ где угодно + // выше. Диапазон раньше был 0..10 тайлов: если игрок в углублении, + // камера улетала на 8 тайлов вверх, где уже нет блоков, и прицел + // смотрел в пустоту (target: null, pick: gy = -1). Игрок при этом + // стоял на gy = 7, то есть в 8 тайлах ниже камеры. + let ex = anchorX, ey = anchorY, ez = anchorZ, found = false; + const hd = Math.sin(TD.yaw) * 0.18, hz = Math.cos(TD.yaw) * 0.18; + for (let up = 0; up <= 4.0 && !found; up += 0.35) { + const cx = anchorX + hd, cy = anchorY + up, cz = anchorZ + hz; + if (!solidNear(cx, cy, cz)) { ex = cx; ey = cy; ez = cz; found = true; } + } + if (!found) { ex = anchorX; ey = anchorY; ez = anchorZ; } + TD.camera.position.set(ex, ey, ez); + TD.camDist = 0; TD.camPitch = +pitch.toFixed(2); TD.camHeight = 0; + // Смотрим вдоль курса с небольшим наклоном вниз, как в обычном + // third-person: горизонтальный lookAt на 8 тайлов уводил взгляд + // в небо, когда камера оказывалась высоко над игроком. + // Направление взгляда = МИНУС курса yaw. Камера в первом лице стоит + // на якоре плюс смещение hd/hz = (sin(yaw), cos(yaw)) * 0.18, то есть + // СЗАДИ игрока по курсу. Смотреть в (sin(yaw), cos(yaw)) — значит + // смотреть НАЗАД, в упор в тот блок, ради которого включился + // первый план: наблюдалось взгляд [0,0,1] при camDist = 0. + TD.camera.lookAt( + ex - Math.sin(TD.yaw) * 8, + ey - Math.sin(pitch) * 8, + ez - Math.cos(TD.yaw) * 8 + ); + TD.camFirstPerson = true; + TD.updateAim(ptx, py3, ptz); + if (TD.hero && TD.hero.group) { + // Риг ставим на землю иначе после return основной путь не дойдёт. + const PWH0 = p.h || 34; + TD.hero.group.scale.setScalar((PWH0 / TILE) / (3.2 * HERO_SCALE)); + TD.hero.group.position.set(ptx, -Math.round(((p.y || 0) + PWH0) / TILE), ptz); + if (p.facing !== undefined) TD.hero.group.rotation.y = -p.facing; + TD.hero.group.visible = false; + } + return; + } + TD.camFirstPerson = false; + if (TD.hero && TD.hero.group) TD.hero.group.visible = true; + const ofs = offsetAt(dist, pitch); + TD.camHeight = +ofs.dy.toFixed(2); + TD.camPitch = +pitch.toFixed(2); + TD.camDist = +dist.toFixed(2); + TD.camera.position.set(anchorX + ofs.dx, anchorY + ofs.dy, anchorZ + ofs.dz); +// Позиция уже задана выше. Ничего не перезаписываем: старый код ставил + // камеру второй раз, по другой формуле, и возвращал её в грунт. + } +// Взгляд идёт ОТ камеры ЧЕРЕЗ игрока и дальше — так прицел попадает в + // точку перед героем, а не ему в спину. Раньше lookAt(ptx, lookY, ptz) + // разворачивал камеру НА игрока, и updateAim всегда целился в него самого: + // target был { gx: <колонка игрока>, face: { y: 1 } }, то есть опора под + // ногами. Добыть или поставить что-либо было невозможно. + // Направление взгляда: камера стоит ЗА игроком, поэтому «вперёд» = от камеры + // мимо игрока, то есть player - camera. Если развернуть камеру НА игрока + // (lookAt(ptx,...)), луч уходит назад и попадает в опору под ногами: + // наблюдалось target gz: -4 при игроке gz: 0 и face {y: 1}. + const _fwd = new THREE.Vector3(ptx - TD.camera.position.x, py3 + 0.5 - TD.camera.position.y, ptz - TD.camera.position.z); + if (_fwd.lengthSq() < 1e-6) _fwd.set(Math.sin(TD.yaw), 0, Math.cos(TD.yaw)); + _fwd.normalize(); + TD.camera.lookAt( + TD.camera.position.x + _fwd.x, + TD.camera.position.y + _fwd.y, + TD.camera.position.z + _fwd.z ); - TD.camera.lookAt(ptx, py3, ptz); - // герой: позиция в ногах коллайдера, поворот по направлению взгляда - TD.hero.group.position.set(ptx, -p.y / TILE, ptz); + // Прицел: луч из камеры вдоль взгляда. Обновляем после позиционирования. + TD.updateAim(ptx, py3, ptz); + + // Герой стоит НОГАМИ на поверхности: p.y в пикселях, низ коллайдера = p.y + p.h. + // Раньше ставился p.y — верх коллайдера, из-за чего персонаж тонул в блоках. + // Ноги = p.y + p.h. Физика держит игрока ГЛУБОКО в блоке-опоре (feet уходит + // на h пикселей внутрь), поэтому ставим ноги ровно на верхнюю грань + // блока под ними: -floor(feet/TILE). Иначе персонаж тонет в текстуре. + // Физика и рисунок ДОЛЖНЫ совпадать. Коллайдер игрока h=34 при TILE=40 — + // это 0.85 тайла, а риг из рисунка при HERO_SCALE=0.5 даёт 1.6 тайла. + // Из-за этого персонаж выглядел втрое выше, чем есть, и «висел» над блоком. + // Масштабируем риг под коллайдер и ставим ноги ровно на верхнюю грань опоры. + const PWH = p.h || 34; + const rigH = 3.2 * HERO_SCALE; // высота рига в тайлах + const heroScale = (PWH / TILE) / rigH; // подгонка под коллайдер + TD.hero.group.scale.setScalar(heroScale); + // feet = p.y + h в пикселях; в тайлах это и есть тайл опоры, на верхней + // грани которого стоят ноги. Y направлена вниз, поэтому знак минус. + const feetGY = Math.round(((p.y || 0) + PWH) / TILE); + const heroFeetY = -feetGY; + TD.hero.group.position.set(ptx, heroFeetY, ptz); if (p.facing !== undefined) { const wantYaw = -p.facing; TD.hero.group.rotation.y += (wantYaw - TD.hero.group.rotation.y) * Math.min(1, 9 * dt); } + // Анимация — как в эталонной игре: ноги качаются, хвост всегда живой, + // корпус сжимается в прыжке и чуть дышит в покое, голова наклоняется + // по скорости. Без этого риг выглядит как неподвижная картинка. + TD.heroTime += dt; const moving = Math.hypot(p.vx || 0, p.vz || 0) > 20 && p.grounded; if (moving) TD.heroStep += dt * 11; - TD.hero.P.legs.rotation.z = moving ? Math.sin(TD.heroStep) * 0.34 : 0; - TD.hero.P.legs.rotation.x = moving ? Math.cos(TD.heroStep) * 0.22 : 0; + const HP = TD.hero.P; + HP.legs.rotation.z = moving ? Math.sin(TD.heroStep) * 0.34 : 0; + HP.legs.rotation.x = moving ? Math.cos(TD.heroStep) * 0.22 : 0; + // хвост: покачивание всегда, сильнее при беге + const tailSwing = Math.sin(TD.heroTime * 6 + (moving ? TD.heroStep : 0)) * (moving ? 0.3 : 0.14); + HP.tail.rotation.z += (tailSwing - HP.tail.rotation.z) * Math.min(1, 8 * dt); + // голова чуть наклоняется по горизонтальной скорости + const lean = (p.vx || 0) * 0.0006; + HP.head.rotation.z += (-lean - HP.head.rotation.z) * Math.min(1, 6 * dt); + // корпус: в прыжке вытянут вверх, на земле «дышит» + if (!p.grounded) { + const stretch = (p.vy || 0) < 0 ? 1.08 : 0.94; // вверх — вытянут, вниз — сжат + HP.body.scale.y += (stretch - HP.body.scale.y) * Math.min(1, 8 * dt); + } else { + const breathe = 1 + 0.015 * Math.sin(TD.heroTime * 2.2); + HP.body.scale.y += (breathe - HP.body.scale.y) * Math.min(1, 6 * dt); + } // мобы const allMobs = state.isMultiplayer ? Array.from(state.serverMobs.values()) : state.mobs; @@ -5313,7 +6426,9 @@ function renderFrame3D(now) { if (!c) { cm.visible = false; continue; } cm.visible = true; cm.scale.set((c.w || 120) / TILE, 1, 1); - cm.position.set(c.x / TILE, -c.y / TILE, ptz - 12); + // Выше и дальше игрока: раньше облака висели в 12 тайлах по Z прямо перед + // камерой и перекрывали обзор серой пеленой. + cm.position.set(c.x / TILE, -c.y / TILE, ptz - 30); } // огни @@ -5429,6 +6544,33 @@ document.addEventListener('visibilitychange', () => { const camDir = { yaw: 0, pitch: 0 }; function loop(now) { + // ---------- поверхность: верх ТВЁРДОГО блока в колонке ---------- + // surfaceGyAt может вернуть тайл, который сам занят блоком (например под + // «травой»). Тогда ноги оказываются внутри блока: grounded держится, но + // прыжок гасится столкновением на первом же шаге вверх. + // Верхний ТВЁРДЫЙ тайл колонки (ось Y вниз: чем больше gy, тем ниже). + // Полный скан колонки — единственный надёжный способ: любые эвристики + // вида «шагни вверх, пока твёрдые» уходили за границу мира (gy = -1). + // Опора = ПОСЛЕДНИЙ твёрдый блок колонки (поверхность земли). + // Ось Y вниз, поэтому чем больше gy, тем ниже; поверхность — максимальный + // твёрдый gy, перед которым уже вода/воздух. + function TDsolidTop(cx, cz) { + // ВЕРХНИЙ твёрдый блок колонны = поверхность, на которой можно стоять. + // Раньше брался ПОСЛЕДНИЙ (самый нижый, bedrock) — игрока ставило + // в фундамент, тело оказывалось внутри блока, камера входила в текстуру. + // Обход СВЕРХУ ВНИЗ: первый твёрдый блок, до которого дошли сверху — это и + // есть поверхность. Раньше цикл шёл снизу (gy=0) и возвращал ДНО мира + // (stone на gy=0), после чего страховка безопасности уносила игрока на + // BEDROCK_GY-6 = 133 — то есть глубоко под мир, где только воздух. + const TOP = Math.min(state.BEDROCK_GY + 2, 220); + for (let gy = TOP; gy >= 0; gy--) { + if (isSolid(cx, gy, cz)) return gy; + } + return -1; + } + + + const rawDt = Math.min(0.05, (now - last) / 1000); last = now; const PHYSICS_STEP = 0.016; @@ -5441,6 +6583,10 @@ function loop(now) { const projectiles = state.projectiles; const boat = state.boat; + // на компе джойстик не трогают — полная скорость с клавиатуры + const keyboardOnly = Math.abs(inp.kfwd || 0) > 0 || Math.abs(inp.kside || 0) > 0; + + const jumpPressed = inp.j && !prevJump; prevJump = inp.j; @@ -5457,7 +6603,10 @@ function loop(now) { if (state.worldTime >= 1) state.worldTime -= 1; // ---------- генерация мира ---------- + // Мир один и фиксированный: строится целиком порциями, ничего не вычищается. ensureGenAroundCamera(); + state.worldGenReady = isWorldReady(); + state.worldGenProgress = worldGenProgress(); // ---------- кислород/утопление ---------- if (player.headInWater) { @@ -5550,12 +6699,15 @@ function loop(now) { if (player.sleeping) { player.vx = player.vy = player.vz = 0; } else { - // локальные оси ввода - let fwd = 0, side = 0; - if (inp.up) fwd -= 1; - if (inp.r || inp.right) side += 1; + // Оси ввода -1..1. Джойстик даёт аналог (ax/ay), клавиатура — дискретно (kfwd/kside). + let fwd = (inp.ay || 0) + (inp.kfwd || 0); + let side = (inp.ax || 0) + (inp.kside || 0); if (inp.l || inp.left) side -= 1; + if (inp.r || inp.right) side += 1; if (inp.d || inp.back) fwd += 1; + if (inp.up) fwd -= 1; + fwd = Math.max(-1, Math.min(1, fwd)); + side = Math.max(-1, Math.min(1, side)); // в мировых осях через угол камеры const cy = Math.cos(camDir.yaw), sy = Math.sin(camDir.yaw); @@ -5563,9 +6715,11 @@ function loop(now) { let dz = side * sy + fwd * cy; const len = Math.hypot(dx, dz); if (len > 0.0001) { + // аналоговое управление: чем дальше наклонён джойстик, тем быстрее + const speed = state.MOVE * (keyboardOnly ? 1 : Math.max(0.45, Math.min(1, len))); dx /= len; dz /= len; - player.vx = dx * state.MOVE; - player.vz = dz * state.MOVE; + player.vx = dx * speed; + player.vz = dz * speed; player.facing = Math.atan2(dz, dx); } else { player.vx *= 0.82; @@ -5652,12 +6806,56 @@ function loop(now) { if (player.z < -maxZ) { player.z = -maxZ; player.vz = 0; } if (player.z > maxZ) { player.z = maxZ; player.vz = 0; } + // ---------- страховка: не проваливаемся в пустоту ---------- + // Две проверки: + // 1) ушёл глубже бедрока — физически дно мира, телепорт наверх; + // 2) стоял и медленно «уезжал» вниз (протухший коллайдер) — тоже наверх. + { + const feetGY = Math.floor((player.y + player.h) / state.TILE); + const headGY = Math.floor(player.y / state.TILE); + const cx = Math.floor((player.x + player.w / 2) / state.TILE); + const cz = Math.floor((player.z + player.d / 2) / state.TILE); + // Спасать только когда под ногами ПУСТОТА (обрыв, провал, bedrock), + // а не по близости к «поверхности». Проверка feetGY < sGY - 12 срабатывала + // на любом холме и телепортировала игрока наверх — отсюда «улетел». + const groundUnder = isSolid(cx, feetGY, cz); + const groundNext = isSolid(cx, feetGY - 1, cz); + const FLOOR = Math.max(state.BEDROCK_GY - 1, 0); + if (feetGY > FLOOR + 2 || headGY > FLOOR || (!groundUnder && !groundNext)) { + // ВАЖНО: player.y — ВЕРХ коллайдера, ноги = y + h. + // Стоять на поверхности sGY значит: ноги на верхней грани блока, + // то есть y + h = sGY * TILE => y = sGY * TILE - h. + // Раньше было y = (sGY+1) * TILE — ноги уезжали на h пикселей + // в тело блока: игрок тонул, grounded был всегда false. + // -1 = под колонкой пусто (обрыв). Тогда оставляем игрока падать: внизу + // bedrock, он встанет сам. Раньше тут был FLOOR-6 = 133 — игрока + // выносило в пустоту над миром. + const safeY = TDsolidTop(cx, cz); + // safeY < 0 = под колонкой пусто (обрыв над миром). Тогда игрока не + // трогаем: пусть падает, внизу bedrock. + if (safeY >= 0) { + // Ось Y направлена ВНИЗ. Верхняя грань блока safeY = safeY * TILE, + // Ось Y направлена ВНИЗ. Тайл safeY занимает [safeY*T, (safeY+1)*T]. + // Верхняя грань = (safeY+1)*T. Ноги (y + h) стоят РОВНО на ней: + // y = (safeY+1)*T - h. Раньше было safeY*T - h, и ноги оказывались + // внутри тайла safeY: игрок тонул, grounded был false, камера уходила + // в блок и кадр показывал пустое небо. + player.y = (safeY + 1) * state.TILE - player.h; + player.vy = 0; + player.grounded = true; + player.fallStartY = player.y; + } + } + } + // ---------- ФИЗИКА: X → Z → Y ---------- const sub = dt; + // ВАЖНО: координаты НЕ двигаем здесь. resolveX/Z/Y сами применяют + // смещение через moveAxis (там же проверка столкновения и автошаг). + // Двойное применение смещения ломало прыжок: вверх тело смещалось + // вручную, а moveAxis проверял ту же позицию и откатывал обратно, + // обнуляя vy в тот же кадр. for (let step = 0; step < steps; step++) { - player.x += player.vx * sub; - player.z += player.vz * sub; - player.y += player.vy * sub; resolveX(player, sub); resolveZ(player, sub); resolveY(player, sub); @@ -5757,6 +6955,24 @@ function loop(now) { state.syEl.textContent = pGY; state.todEl.textContent = isNight() ? 'Ночь' : 'День'; state.worldIdEl.textContent = state.worldId; + // XP-бар: в 3D считается ЗДЕСЬ. Раньше элементы #xplevel и #xpbar были + // в HTML, но не имели обработчика вообще — ни в 2D, ни в 3D, и висели + // вечно на «Lv.1 | 0/50». Порог уровня: 50 * level. + const xpEl = document.getElementById('xpbar'); + const lvlEl = document.getElementById('xplevel'); + if (xpEl && lvlEl) { + const lvl = player.level || 1; + const need = 50 * lvl; + const xp = player.xp || 0; + // И уровень, и бар пишем КАЖДЫЙ раз: раньше lvlEl.textContent + // только читалcя, но никогда не присваивался, и на экране вечно + // висело «Lv.1» независимо от реального уровня. + lvlEl.textContent = String(lvl); + xpEl.textContent = Math.min(xp, need) + '/' + need; + if (lvlEl.parentElement) { + lvlEl.parentElement.style.color = xp >= need ? '#7ee08a' : ''; + } + } if (state.isMultiplayer) { const st = document.getElementById('multiplayerStatus'); if (st) st.style.display = 'flex'; @@ -6198,43 +7414,266 @@ function initRespawn() { }; } -// === input/controls.js === -// ==================== УПРАВЛЕНИЕ ==================== +// === input/controls3d.js === +// ==================== УПРАВЛЕНИЕ 3D — клавиатура + мышь ==================== +// Клавиши работают напрямую через state.inp.fwd / state.inp.side (аналоговые оси -1..1), +// поэтому движение на компе идёт по той же схеме, что и джойстик на телефоне. -const inp = state.inp; +// клавиши, которые не должны скроллить страницу/открывать меню +const SWALLOW = new Set([ + 'Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', + 'KeyW', 'KeyA', 'KeyS', 'KeyD', +]); -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 typing() { + const el = document.activeElement; + if (!el) return false; + const t = el.tagName; + return t === 'INPUT' || t === 'TEXTAREA' || el.isContentEditable; +} + +function applyKey(code, down) { + const inp = state.inp; + switch (code) { + // движение вперёд/назад и влево/вправо — по камере + case 'KeyW': case 'ArrowUp': inp.kfwd = down ? -1 : 0; break; + case 'KeyS': case 'ArrowDown': inp.kfwd = down ? 1 : 0; break; + case 'KeyA': case 'ArrowLeft': inp.kside = down ? -1 : 0; break; + case 'KeyD': case 'ArrowRight': inp.kside = down ? 1 : 0; break; + // прыжок + case 'Space': case 'KeyZ': inp.j = down; break; + // спуск по лестнице / вниз + case 'ShiftLeft': case 'KeyC': inp.s = down; break; + default: return false; + } + return true; } 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; + if (typing()) return; + if (e.repeat) return; + if (SWALLOW.has(e.code)) e.preventDefault(); + applyKey(e.code, 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; + if (typing()) return; + applyKey(e.code, false); }); + + // если окно потеряло фокус — сбрасываем всё, иначе персонаж «убегает» сам + window.addEventListener('blur', () => { + const inp = state.inp; + inp.kfwd = 0; inp.kside = 0; inp.j = false; inp.s = false; + }); + + // на всякий случай дублируем на document (фокус может быть на канвасе) + document.addEventListener('keydown', (e) => { + if (typing()) return; + if (e.repeat) return; + applyKey(e.code, true); + }); + document.addEventListener('keyup', (e) => { + if (typing()) return; + applyKey(e.code, false); + }); +} + +// === input/joystick3d.js === +// ==================== ДЖОЙСТИК 3D — GrechkaCraft v3 ==================== +// Аналоговый джойстик: движение во все стороны по горизонтали (X и Z), +// камера вращается вторым пальцем по экрану. Кнопки прыжка и копки рядом. + + +let joyEl = null, knobEl = null, jumpEl = null, mineEl = null; +let joyId = null; // активный pointer на джойстике +let jcx = 0, jcy = 0, jrad = 56; // центр джойстика и рабочий радиус в px +let touchSeated = false; // джойстик сейчас показан +let hideT = 0; // таймер скрытия + +function setKnob(dx, dy) { + if (!knobEl) return; + knobEl.style.transform = `translate(${dx}px, ${dy}px)`; +} + +function resetAxes() { + state.inp.ax = 0; + state.inp.ay = 0; + setKnob(0, 0); + if (joyEl) joyEl.classList.remove('act'); +} + +function initJoystick() { + joyEl = document.getElementById('v2joy'); + knobEl = joyEl ? joyEl.querySelector('.knob') : null; + jumpEl = document.getElementById('v2jump'); + mineEl = document.getElementById('v2mine'); + if (!joyEl) return; + + const rectCenter = () => { + const r = joyEl.getBoundingClientRect(); + jcx = r.left + r.width / 2; + jcy = r.top + r.height / 2; + jrad = r.width / 2 - 12; + }; + + joyEl.addEventListener('pointerdown', (e) => { + e.preventDefault(); + rectCenter(); + joyId = e.pointerId; + joyEl.setPointerCapture(joyId); + joyEl.classList.add('act'); + update(e); + }); + + joyEl.addEventListener('pointermove', (e) => { + if (e.pointerId !== joyId) return; + e.preventDefault(); + update(e); + }); + + const end = (e) => { + if (e.pointerId !== joyId) return; + joyId = null; + resetAxes(); + }; + joyEl.addEventListener('pointerup', end); + joyEl.addEventListener('pointercancel', end); + + function update(e) { + let dx = e.clientX - jcx; + let dy = e.clientY - jcy; + const len = Math.hypot(dx, dy); + if (len > jrad) { // не даём уйти за круг + dx = (dx / len) * jrad; + dy = (dy / len) * jrad; + } + setKnob(dx, dy); + // мёртвая зона — чтобы лёгкое касание не дёргало персонажа + const nx = dx / jrad, ny = dy / jrad; + const mag = Math.hypot(nx, ny); + const DEAD = 0.18; + if (mag < DEAD) { + state.inp.ax = 0; + state.inp.ay = 0; + } else { + // нормализуем так, чтобы край джойстика давал полную скорость + const k = (mag - DEAD) / (1 - DEAD) / mag; + state.inp.ax = nx * k; + state.inp.ay = ny * k; + } + } + + // прыжок + if (jumpEl) { + jumpEl.addEventListener('pointerdown', (e) => { + e.preventDefault(); + state.inp.j = true; + }); + const up = (e) => { e.preventDefault(); state.inp.j = false; }; + jumpEl.addEventListener('pointerup', up); + jumpEl.addEventListener('pointercancel', up); + jumpEl.addEventListener('pointerleave', up); + } + + // копка: добыча/установка — удержание + if (mineEl) { + mineEl.addEventListener('pointerdown', (e) => { + e.preventDefault(); + state.inp.mineHeld = true; + window.dispatchEvent(new CustomEvent('v2-mine', { detail: { down: true } })); + }); + const up = (e) => { + e.preventDefault(); + state.inp.mineHeld = false; + window.dispatchEvent(new CustomEvent('v2-mine', { detail: { down: false } })); + }; + mineEl.addEventListener('pointerup', up); + mineEl.addEventListener('pointercancel', up); + mineEl.addEventListener('pointerleave', up); + } + + // держим джойстик в нуле, если палец ушёл за экран + window.addEventListener('pointerup', (e) => { + if (joyId !== null && e.pointerId === joyId) { joyId = null; resetAxes(); } + }); + window.addEventListener('blur', resetAxes); + + // ---------- джойстик: только если палец в левой нижней четверти экрана ---------- + // Показываем при касании, прячем через 0.6 с после отпускания — чтобы + // не перекрывать хотбар с избранными блоками. + const ZONE = () => ({ w: window.innerWidth * 0.5, h: window.innerHeight * 0.5 }); + const inZone = (x, y) => { + const z = ZONE(); + return x < z.w && y > window.innerHeight - z.h; + }; + + const wake = () => { + clearTimeout(hideT); + // Кнопки прыжка и копки видны ВСЕГДА на тач-устройствах: без прыжка + // и добычи играть невозможно, и требование контекстного показа + // касалось только джойстика. + [jumpEl, mineEl].forEach(e => e && e.classList.add('on')); + if (touchSeated) return; + touchSeated = true; + if (joyEl) joyEl.classList.add('on'); + }; + const sleepSoon = () => { + clearTimeout(hideT); + hideT = setTimeout(() => { + if (joyId === null) { + touchSeated = false; + // Прячем ТОЛЬКО джойстик. Раньше класс 'on' снимался со всех + // элементов или не снимался вовсе: в первом случае исчезали + // кнопки прыжка и копки, во втором джойстик висел поверх + // хотбара всё время. + if (joyEl) joyEl.classList.remove('on'); + [jumpEl, mineEl].forEach(e => e && e.classList.add('on')); + } + }, 600); + }; + + // Первый контакт с зоной — сажаем джойстик под палец + window.addEventListener('pointerdown', (e) => { + if (e.pointerType === 'mouse') return; + if (!inZone(e.clientX, e.clientY)) return; + wake(); + if (joyId !== null) return; + // если палец попал в кнопки — не мешаем им + // contains() бросает исключение, если e.target — не Node (например, + // window при синтетическом событии). Проверяем instanceof. + const isNode = e.target && typeof e.target === 'object' && 'nodeType' in e.target; + if (isNode && ((jumpEl && jumpEl.contains(e.target)) || (mineEl && mineEl.contains(e.target)))) return; + // ищем ближайшую точку джойстика к пальцу + const r = joyEl.getBoundingClientRect(); + const cxr = r.left + r.width / 2, cyr = r.top + r.height / 2; + const far = Math.hypot(e.clientX - cxr, e.clientY - cyr) > r.width * 0.75; + if (far) { jcx = e.clientX; jcy = e.clientY; } + // Запоминаем палец СРАЗУ: без этого joyId оставался null, update() + // не считал смещение, и inp.ax/inp.ay всегда были 0 — джойстик + // показывался, но игрок не двигался. Обработчик самого джойстика + // тоже ставит joyEl.classList.add('act'). + if (joyId === null) { + joyId = e.pointerId; + joyEl.classList.add('act'); + update(e); + } + }, { passive: true, capture: true }); + + window.addEventListener('pointerup', sleepSoon, { passive: true }); + window.addEventListener('pointercancel', sleepSoon, { passive: true }); + window.addEventListener('blur', () => { touchSeated = false; }); + + // десктоп: всё скрыто, клавиатура + if (window.matchMedia('(hover: hover) and (pointer: fine)').matches) { + [joyEl, jumpEl, mineEl].forEach(e => e && e.classList.remove('on')); + } + // Автопоказ на старте УБРАН: джойстик должен появляться только по + // касанию левой нижней зоны, а не висеть поверх хотбара сразу. + sleepSoon(); } // === multiplayer/voice-chat.js === @@ -6503,6 +7942,12 @@ function closeInventory() { + + + + + +dismissBootScreen(); // ==================== 1) CONFIG — parse URL ==================== // urlParams defined in config.js @@ -6517,9 +7962,178 @@ if (isTgWebApp) { state.heroImg = new Image(); state.heroImg.src = 'https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png'; +// Диаг-доступ только по ?diag=1 — в обычной игре не включается +if (/[?&]diag=1/.test(location.search)) window.__st = state; + // ==================== 2) START MENU ==================== // Show the start menu before initializing the game. // The menu returns a Promise that resolves with the worldId. +// ==================== ЭКРАН ЗАГРУЗКИ МИРА ==================== +// Мир 96x96x45 ≈ 420k блоков. Он строится порциями в игровом цикле, пока +// пользователь смотрит на прогресс. Раньше экран ждал молча, и вход выглядел +// как зависший. +function showWorldLoader() { + let el = document.getElementById('v3load'); + if (!el) { + el = document.createElement('div'); + el.id = 'v3load'; + el.style.cssText = [ + 'position:fixed', 'inset:0', 'z-index:2147483000', + 'display:flex', 'flex-direction:column', + 'align-items:center', 'justify-content:center', + 'background:linear-gradient(180deg,#0d1b3e 0%,#1b3a6b 42%,#3f6fa8 68%,#8fc7f5 100%)', + 'font:600 15px/1.4 system-ui,-apple-system,Segoe UI,sans-serif', + 'color:#eaf3ff', 'text-align:center', 'padding:24px', + 'overflow:hidden' + ].join(';'); + // Фон: солнце, дымящиеся холмы, бегущие облака. Всё на CSS — грузится + // мгновенно и не ждёт ассетов, поэтому экран появляется без белой вспышки. + el.innerHTML = + '
' + + // солнце + '
' + + // облака + '
' + + '
' + + // холмы + '
' + + '
' + + // гречка — колос: стебель + 6 зёрен вокруг него. Раньше зёрна строились + // отрицательным margin от пустого блока и висели над землёй точками. + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
Гречка Craft 3D
' + + '
Подготовка…
' + + '
' + + '
' + + '
0%
' + + '
' + + 'Строим мир: рельеф, биомы, деревья, руды и гречка
' + + '
'; + document.body.appendChild(el); + } + el.style.display = 'flex'; + return el; +} + +function hideWorldLoader() { + const el = document.getElementById('v3load'); + if (el) el.style.display = 'none'; +} + +// Статичная заставка из index-v2.html живёт, пока грузится JS-бандл. Гасим её +// сразу, как только скрипт стартует: дальше свою работу делает экран генерации +// мира с реальным прогрессом. +function dismissBootScreen() { + const b = document.getElementById('v3boot'); + if (!b) return; + if (typeof window.__v3bootDone === 'function') window.__v3bootDone(); + b.style.transition = 'opacity .3s ease'; + b.style.opacity = '0'; + setTimeout(() => { b.style.display = 'none'; }, 320); +} + +// Ждём готовности мира, обновляя полосу прогресса. +function waitWorldReady(maxMs = 180000) { + const el = showWorldLoader(); + const fill = document.getElementById('v3loadfill'); + const txt = document.getElementById('v3loadtxt'); + const stage = document.getElementById('v3loadstage'); + const hint = document.getElementById('v3loadhint'); + // Этапы по доле готовности. Один текст «Готовим мир…» не объяснял, что + // происходит, и загрузка выглядела как зависание. + const STAGES = [ + [0.02, 'Расстилаем рельеф', 'Режем холмы и долины'], + [0.18, 'Раздаём биомы', 'Луга, лес, песок и снег'], + [0.42, 'Заросли и деревья', 'Сажаем лес и кусты'], + [0.66, 'Закладываем руды', 'Уголь, железо, золото'], + [0.84, 'Сеем гречку', 'Главный урожай'], + [0.97, 'Ставим игрока', 'Ищем сухой берег'] + ]; + return new Promise(resolve => { + const t0 = performance.now(); + const tick = () => { + const pr = isWorldReady() ? 1 : worldGenProgress(); + const pct = Math.round(pr * 100); + if (fill) fill.style.width = pct + '%'; + if (txt) txt.textContent = pct + '%'; + if (stage) { + let cur = STAGES[0]; + for (const st of STAGES) if (pr >= st[0]) cur = st; + if (stage.textContent !== cur[1]) stage.textContent = cur[1]; + if (hint) hint.textContent = cur[2]; + } + if (isWorldReady() || performance.now() - t0 > maxMs) { + const ok = isWorldReady(); + if (fill) fill.style.width = '100%'; + if (txt) txt.textContent = '100%'; + if (stage) stage.textContent = ok ? 'Мир готов!' : 'Погружаем…'; + if (hint) hint.textContent = ok ? 'Приятной игры' : 'Мир не успел — заходим как есть'; + // Короткая пауза, чтобы «Мир готов!» успел прочитать, и плавный уход. + setTimeout(() => { + if (el) el.style.transition = 'opacity .35s ease'; + if (el) el.style.opacity = '0'; + setTimeout(hideWorldLoader, 360); + resolve(isWorldReady()); + }, ok ? 420 : 120); + return; + } + requestAnimationFrame(tick); + }; + tick(); + }); +} + + +// Гарантируем, что игрок стоит на твёрдом блоке. Спавн вычисляется ДО генерации +// мира, поэтому опора под ногами часто не успевает построиться — игрок падал +// до bedrock (Y=132) и висел в пустоте. После готовности мира проверяем и +// ставим на верхний твёрдый блок своей колонки. +// ---------- СПАВН: findStandSpot ---------- +// Критерии пригодного места (все обязательны): +// 1. сверху колонки есть твёрдый блок — на него ставим ноги; +// 2. над ним 2 свободных тайла — игрок и запас на прыжок; +// 3. нет воды в радиусе 3 тайла и выше поверхности; +// 4. в тайле над головой (top-1) нет твёрдого блока — иначе игрок +// окажется в пещере под блоком; +// 5. блок под опорой тоже твёрдый (не «парящая» трава над пещерой). +// Поиск идёт по расширяющейся спирали, а не по одной колонке: раньше +// игрока ставило в воду или в толщу снега, потому что подходил только +// собственный тайл. async function startGame() { const worldId = await showStartMenu(); @@ -6596,6 +8210,7 @@ async function startGame() { // ==================== 7) CONTROLS — initControls ==================== initControls(); + initJoystick(); // ==================== 9) UI MODULES — init all UI ==================== initChat(); @@ -6668,6 +8283,8 @@ async function startGame() { state.player.hunger = 100; state.player.o2 = 100; state.player.x = state.spawnPoint.x; + // spawnPoint.y копируется из player.y (см. save.js:130), то есть это + // ВЕРХ коллайдера — вычитать высоту нельзя. state.player.y = state.spawnPoint.y; state.player.vx = state.player.vy = 0; state.player.invuln = 0; @@ -6685,31 +8302,133 @@ async function startGame() { state.player.vx = state.player.vy = 0; state.player.invuln = 0; - // старт — на поверхности - const startGX = 6, startGZ = 0; + // Старт — сухая суша в центре мира. Перебираем ближайшие колонки и берём + // первую без воды/болота, чтобы не начинать в трясине. + let startGX = 6, startGZ = 0, startGY = 0, found = false; + const PREF_BIOMES = ['plains', 'desert']; // сухая, безопасная земля + outer: + for (let r = 0; r <= 60 && !found; r++) { + for (let dz = -r; dz <= r; dz++) { + for (let dx = -r; dx <= r; dx++) { + if (Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue; // только кольцо + const gx = 6 + dx, gz = dz; + if (Math.abs(gz) > 40) continue; // раньше 18 — при WORLD_DEPTH=48 искал в узкой полосе и не находил суши + const b = getCachedBiome(gx, gz); + if (!PREF_BIOMES.includes(b)) continue; + // Условие «sy > SEA_GY + 3» отбрасывало ВСЕ колонки: в этом мире поверхность + // лежит на gy 8..14, SEA_GY = 14, то есть требование sy > 17 не + // выполнялось НИГДЕ — игрока всегда выносило в fallback, и тот + // ставил его в болото. Теперь проверяем настоящую колонку. + genColumn(gx, gz); + const pSolid = (px, py, pz) => { + const bb = getBlock(px, py, pz); + if (!bb) return false; + const dd = BLOCKS[bb.t]; + return !!(dd && dd.solid && !dd.fluid && !dd.decor && !dd.climbable); + }; + const pFluid = (px, py, pz) => { + const bb = getBlock(px, py, pz); + return !!(bb && BLOCKS[bb.t] && BLOCKS[bb.t].fluid); + }; + let ptop = -1; + for (let gy = Math.min(state.BEDROCK_GY + 2, 220); gy >= 1; gy--) { + if (pSolid(gx, gy, gz)) { ptop = gy; break; } + } + if (ptop < 1) continue; // нет опоры + if (pFluid(gx, ptop - 1, gz)) continue; // лужа под ногами + let near = false; + for (let dx = -3; dx <= 3 && !near; dx++) + for (let dz = -3; dz <= 3 && !near; dz++) + for (let gy = Math.min(state.SEA_GY, 220); gy > ptop; gy--) + if (pFluid(gx + dx, gy, gz + dz)) { near = true; break; } + if (near) continue; // вокруг вода + const sy = ptop; + startGX = gx; startGZ = gz; startGY = sy; found = true; // sy = блок-опора + break outer; + } + } + } + if (!found) { + // Суши по биому не нашлось — ищем любую колонку выше уровня моря. + let bestGY = -1, bestX = 6, bestZ = 0; + for (let r = 0; r <= 40; r++) { + for (let dz = -r; dz <= r; dz++) { + for (let dx = -r; dx <= r; dx++) { + if (Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue; + const gx = 6 + dx, gz = dz; + if (Math.abs(gz) > 40) continue; + // Тот же дефект, что и в основной ветке: SEA_GY + 2 = 16, + // а поверхность в мире 8..14, поэтому условие не выполнялось + // НИГДЕ и fallback тоже ничего не находил. + const sy = surfaceGyAt(gx, gz); + if (sy < state.SEA_GY - 4 && sy > bestGY) { bestGY = sy; bestX = gx; bestZ = gz; } + } + } + if (bestGY > 0) break; + } + if (bestGY > 0) { startGX = bestX; startGZ = bestZ; startGY = bestGY; found = true; } + } + if (!found) { startGX = 6; startGZ = 0; startGY = surfaceGyAt(6, 0) + 2; } + state.player.x = startGX * state.TILE; state.player.z = startGZ * state.TILE; state.player.vx = state.player.vy = state.player.vz = 0; - for (let dx = -4; dx <= 4; dx++) { - for (let dz = -20; dz <= 20; dz++) genColumn(startGX + dx, startGZ + dz); - } - const surfaceY = surfaceGyAt(startGX, startGZ); - let safeGY = surfaceY - 1; - const aboveBlock = getBlock(startGX, surfaceY - 1, startGZ); - if (aboveBlock && (aboveBlock.t === 'water' || aboveBlock.t === 'swamp_water')) { - for (let gy = state.SEA_GY - 1; gy >= 0; gy--) { - const b = getBlock(startGX, gy, startGZ); - if (!b || b.dead || b.t === 'air' || b.t === 'water') continue; - safeGY = gy - 1; - break; + // Предгенерация вокруг спавна. Радиус по X был 4 тайла, по Z 20 — при + // этом по Z уже генерировалось до 24, а по X мир вырождался в полосу + // шириной 9 тайлов (замер: world x от 2 до 10 при игроке на 6). + for (let dx = -48; dx <= 48; dx++) { + for (let dz = -48; dz <= 48; dz++) { + if (dx * dx + dz * dz > 48 * 48) continue; + genColumn(startGX + dx, startGZ + dz); } } - state.player.y = safeGY * state.TILE; + // после генерации ищем сухую опору: идём вверх, пока под ноздрями вода/болото + // Ищем тайл ТВЁРДОГО блока-опоры: самый верхний твёрдый блок колонки, + // над которым не вода. Раньше startGY был на единицу ниже, и игрок + // оказывался ВНУТРИ блока (утопание) или в небе над ним. + { + let topGY = -1; + for (let gy = 1; gy < 200; gy++) { + const bl = getBlock(startGX, gy, startGZ); + if (!bl || bl.dead) break; + const def = BLOCKS[bl.t]; + if (!def || !def.solid || def.fluid || def.decor || def.climbable) break; + topGY = gy; + } + if (topGY > 0) startGY = topGY; + else startGY = Math.max(surfaceGyAt(startGX, startGZ), state.SEA_GY); + } + // ВАЖНО: player.y — ВЕРХ коллайдера, ноги = y + h. + // Стоять на блоке startGY => y = startGY * TILE - h. + // зазор 0.5px, иначе первый шаг вверх гасит импульс прыжка + // Ось Y направлена ВНИЗ. Верхняя грань блока startGY = startGY*TILE, + // ноги = y + h => y = startGY * TILE - h. + // Опора под ногами ОБЯЗАТЕЛЬНА. Если блока нет — ставим его, иначе игрок + // проваливается до bedrock (проверено: Y уходил в 132 при BEDROCK_GY=140). + { + const footBlk = getBlock(startGX, startGY, startGZ); + const footDef = footBlk ? BLOCKS[footBlk.t] : null; + const solid = !!(footDef && footDef.solid && !footDef.fluid && !footDef.decor); + if (!solid) { + setBlock(startGX, startGY, startGZ, 'grass', true); + } else if (startGY > 1) { + // проверяем, что это и правда верхняя точка опоры + const above = getBlock(startGX, startGY - 1, startGZ); + if (above) { + const ad = BLOCKS[above.t]; + if (!ad || !ad.solid || ad.fluid || ad.decor) removeBlock(startGX, startGY - 1, startGZ); + } + } + } + // Ось Y вниз: верхняя грань блока startGY = startGY*TILE, значит + // feet = startGY*TILE и p.y = startGY*TILE - h. + state.player.y = startGY * state.TILE - state.player.h; state.player.fallStartY = state.player.y; + state.player.grounded = true; state.spawnPoint.x = state.player.x; state.spawnPoint.y = state.player.y; state.spawnPoint.z = state.player.z; - console.log('Новая 3D игра: gx=', startGX, 'gz=', startGZ, 'surfaceY=', surfaceY, 'y=', state.player.y); + console.log('Новая 3D игра: gx=', startGX, 'gz=', startGZ, 'biome=', getCachedBiome(startGX, startGZ), 'gy=', startGY, 'y=', state.player.y); } // Автосейв при скрытии страницы @@ -6725,17 +8444,28 @@ async function startGame() { }); }).catch(err => { console.error('Ошибка инициализации:', err); + // Аварийный старт: только колонка игрока, остальное достроит pumpWorldGen + // из игрового цикла. Раньше здесь генерировалась узкая полоса 9x40, из-за + // чего мир выглядел островом. const startGX = 6, startGZ = 0; genColumn(startGX, startGZ); - state.player.y = (surfaceGyAt(startGX, startGZ) - 1) * state.TILE; + state.player.y = surfaceGyAt(startGX, startGZ) * state.TILE - state.player.h; state.player.fallStartY = state.player.y; - for (let gx = startGX - 4; gx <= startGX + 4; gx++) { - for (let gz = -20; gz <= 20; gz++) genColumn(gx, startGZ + gz); - } }); // ==================== 13) LOOP — startLoop ==================== + // Мир строится порциями в цикле, поэтому сначала запускаем цикл, затем + // ждём готовности с прогрессом на экране. requestAnimationFrame(loop); + // Ставим игрока на поверхность ДО генерации мира, чтобы он не начал + // падать в пустоту, и повторяем после готовности. + ensurePlayerOnGround(); + await waitWorldReady(); + ensurePlayerOnGround(); + // После фикса игрок может стоять в блоке, если гравитация успела осесть. + // Даём физике несколько кадров и закрепляем ещё раз. + await new Promise(r => setTimeout(r, 120)); + ensurePlayerOnGround(); } // Start the game (v3 — 3D) diff --git a/index-v2.html b/index-v2.html index 7a26e96..6391f6f 100644 --- a/index-v2.html +++ b/index-v2.html @@ -15,6 +15,85 @@ + +
+
+
+
+
+
+
+
+
+ +
+
Гречка Craft 3D
+
Загружаем игру…
+
+
+
0%
+
+ + + @@ -65,12 +144,10 @@
-
-
⬅️
-
⬆️
-
⬇️
- -
+ +
+
⬆️
+
⛏️
- + diff --git a/index.html b/index.html index a779040..5d7abfb 100644 --- a/index.html +++ b/index.html @@ -2,13 +2,61 @@ - -GrechkaCraft: Multiplayer - - - + + + + + + + +GrechkaCraft + + + + +
+ +
+ +
Гречка
+
v79
+
+
Загрузка игры…
+ +
+ +
@@ -17,6 +65,7 @@
❤️ 100   🍗 100
🫁 100
+
🌡️ 15°C
📍 X:0 Y:0
🕒 День
🌐 default
@@ -35,13 +84,16 @@ +
+
@@ -103,8 +155,37 @@
+ + - + + diff --git a/index.html_new b/index.html_new new file mode 100644 index 0000000..d9219f3 --- /dev/null +++ b/index.html_new @@ -0,0 +1,125 @@ + + + + + + + + + + + +GrechkaCraft + + + + + + +
+ + +
+
+
❤️ 100   🍗 100
+
🫁 100
+
📍 X:0 Y:0
+
🕒 День
+
🌐 default
+
⭐ Lv.1 | XP: 0/50
+ +
+ +
⛏️
+
🔨
+ +
💬
+
📦
+
🗺️
+ + +
+
+ + + + + + + +
+
⬅️
+
⬆️
+
⬇️
+ +
+ + + + + + + + + + + + + +
+ + + + diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..89d4c84 --- /dev/null +++ b/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "GrechkaCraft", + "short_name": "GrechkaCraft", + "description": "Voxel игра в браузере", + "start_url": "/?v=73", + "display": "fullscreen", + "orientation": "any", + "background_color": "#111111", + "theme_color": "#1a1a2e", + "categories": ["games"], + "icons": [ + { + "src": "imgs/grechka_1.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + } + ] +} diff --git a/src/core/state.js b/src/core/state.js index 66df448..c39a42d 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -35,6 +35,7 @@ export const state = { // День/ночь worldTime: 0, isNightTime: false, + dayNightCycle: true, // Мультиплеер isMultiplayer: false, @@ -53,7 +54,7 @@ export const state = { worldId: null, // Инвентарь/UI - selected: 0, + selected: "dirt", showFullInventory: false, craftOpen: false, inventoryOpen: false, @@ -66,10 +67,14 @@ export const state = { worldSeed: Math.floor(Math.random() * 1000000), // Погода + weatherType: "clear", isRaining: false, rainIntensity: 0, weatherTimer: 0, weatherChangeInterval: 60 + Math.random() * 120, + snowflakes: [], + lightningFlash: 0, + lightningTimer: 0, // Мобы/спавн spawnT: 0, @@ -108,6 +113,7 @@ export const state = { inventoryPanel: null, inventoryGrid: null, multiplayerStatus: null, + tempEl: null, // Игрок player: { @@ -129,7 +135,9 @@ export const state = { sleeping: false, inBoat: false, armor: 0, - equippedArmor: null + equippedArmor: null, + temperature: 15, + slowTimer: 0 }, // Точка спавна @@ -147,11 +155,19 @@ export const state = { wood_sword: 0, stone_sword: 0, iron_sword: 0, wood_axe: 0, stone_axe: 0, iron_axe: 0, wood_shovel: 0, stone_shovel: 0, iron_shovel: 0, - iron_armor: 0, + iron_armor: 0, gold_armor: 0, bow: 0, furnace: 0, bed: 0, boat: 0, iron_ingot: 0, gold_ingot: 0, copper_ingot: 0, cobblestone: 0, + // New items + chicken_meat: 0, feather: 0, bone: 0, gunpowder: 0, + wheat: 0, bread: 0, carrot: 0, potato: 0, baked_potato: 0, + mushroom_stew: 0, + scorpion_stinger: 0, polar_fur: 0, slime_ball: 0, eagle_feather: 0, + snow: 0, ice: 0, cactus: 0, mushroom: 0, moss: 0, farmland: 0, + spruce_leaves: 0, dead_bush: 0, swamp_water: 0, + wheat_seeds: 0, wheat_stage0: 0, carrot_stage0: 0, potato_stage0: 0 }, // Лодка @@ -210,18 +226,20 @@ export const state = { // Прочность инструментов toolDurability: new Map(), + protections: new Map(), // Последние выбранные предметы recentItems: [], // Активные печи activeFurnaces: new Map(), + cropTimers: new Map(), // Сгенерированные колонны generated: new Set(), // Изображение героя - heroImg: null, + heroImg: new Image(), // Сетка мира grid: new Map(), diff --git a/src/data/blocks.js b/src/data/blocks.js index 8a7b8d5..5f8994d 100644 --- a/src/data/blocks.js +++ b/src/data/blocks.js @@ -20,12 +20,36 @@ export const BLOCKS = { diamond_ore:{n:'Алмаз', c:'#00a8ff', solid:true, hardness:20, toolType:'pickaxe', minTool:'iron' }, brick: { n:'Кирпич', c:'#c0392b', solid:true, hardness:8, toolType:'pickaxe' }, tnt: { n:'TNT', c:'#e74c3c', solid:true, explosive:true, hardness:1, toolType:null }, - campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius:190, hardness:2, toolType:null }, - torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:140, hardness:1, toolType:null }, + campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius:280, hardness:2, toolType:null }, + torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:220, hardness:1, toolType:null }, bedrock: { n:'Бедрок', c:'#2d3436', solid:true, unbreakable:true, hardness:Infinity }, flower: { n:'Цветок', c:'#ff4757', solid:false, decor:true, hardness:0 }, bed: { n:'Кровать', c:'#e91e63', solid:true, bed:true, hardness:2, toolType:'axe' }, boat: { n:'Лодка', c:'#8B4513', solid:false, hardness:0 }, furnace: { n:'Печь', c:'#696969', solid:true, smelting:true, hardness:8, toolType:'pickaxe', minTool:'wood' }, cobblestone: { n:'Булыжник', c:'#7f8c8d', solid:true, hardness:6, toolType:'pickaxe' }, -}; \ No newline at end of file + flag: { n:'Флаг', c:'#e74c3c', solid:true, hardness:1, toolType:'axe', protectionRadius:10 }, + // === BIOME BLOCKS === + snow: { n:'Снег', c:'#ecf0f1', solid:true, hardness:1, toolType:'shovel' }, + ice: { n:'Лёд', c:'#74b9ff', solid:true, hardness:1, slip:true, toolType:'pickaxe' }, + cactus: { n:'Кактус', c:'#27ae60', solid:true, hardness:1, hurt:true, toolType:null }, + mushroom: { n:'Гриб', c:'#e74c3c', solid:false, decor:true, hardness:0 }, + moss: { n:'Мох', c:'#0a6640', solid:true, hardness:1, toolType:null }, + swamp_water:{ n:'Болотная вода', c:'rgba(100,120,40,0.6)', solid:false, fluid:true, poison:true, hardness:0 }, + farmland: { n:'Грядка', c:'#8B6914', solid:true, hardness:1, farmable:true, toolType:'shovel' }, + dead_bush: { n:'Сухой куст', c:'#b2bec3', solid:false, decor:true, hardness:0 }, + spruce_leaves:{ n:'Ель', c:'#0a6640', solid:true, hardness:1, toolType:'axe' }, + // === CROP STAGES === + wheat_stage0:{ n:'Росток пшеницы', c:'#a8e6a0', solid:false, decor:true, hardness:0, growthStage:0, growthTime:30, growsTo:'wheat_stage1' }, + wheat_stage1:{ n:'Пшеница', c:'#7dcea0', solid:false, decor:true, hardness:0, growthStage:1, growthTime:30, growsTo:'wheat_stage2' }, + wheat_stage2:{ n:'Пшеница', c:'#b8d730', solid:false, decor:true, hardness:0, growthStage:2, growthTime:30, growsTo:'wheat_stage3' }, + wheat_stage3:{ n:'Пшеница', c:'#f1c40f', solid:false, decor:true, hardness:0, harvestable:true, harvestItem:'wheat', harvestQty:2 }, + carrot_stage0:{ n:'Росток моркови', c:'#a8e6a0', solid:false, decor:true, hardness:0, growthStage:0, growthTime:30, growsTo:'carrot_stage1' }, + carrot_stage1:{ n:'Морковь', c:'#f0c27a', solid:false, decor:true, hardness:0, growthStage:1, growthTime:30, growsTo:'carrot_stage2' }, + carrot_stage2:{ n:'Морковь', c:'#e8a040', solid:false, decor:true, hardness:0, growthStage:2, growthTime:30, growsTo:'carrot_stage3' }, + carrot_stage3:{ n:'Морковь', c:'#e67e22', solid:false, decor:true, hardness:0, harvestable:true, harvestItem:'carrot', harvestQty:3 }, + potato_stage0:{ n:'Росток картофеля', c:'#a8e6a0', solid:false, decor:true, hardness:0, growthStage:0, growthTime:30, growsTo:'potato_stage1' }, + potato_stage1:{ n:'Картофель', c:'#c8b888', solid:false, decor:true, hardness:0, growthStage:1, growthTime:30, growsTo:'potato_stage2' }, + potato_stage2:{ n:'Картофель', c:'#bfaa78', solid:false, decor:true, hardness:0, growthStage:2, growthTime:30, growsTo:'potato_stage3' }, + potato_stage3:{ n:'Картофель', c:'#dfe6e9', solid:false, decor:true, hardness:0, harvestable:true, harvestItem:'potato', harvestQty:2 }, +}; diff --git a/src/data/items.js b/src/data/items.js index 1ef6cd7..cead22f 100644 --- a/src/data/items.js +++ b/src/data/items.js @@ -2,6 +2,25 @@ export const ITEMS = { meat: { n:'Сырое мясо', icon:'🥩', food:15 }, cooked: { n:'Жареное мясо', icon:'🍖', food:45 }, arrow: { n:'Стрела', icon:'➡️', stack:64 }, + chicken_meat: { n:'Курица', icon:'🍗', food:12, stack:64 }, + feather: { n:'Перо', icon:'🪶', stack:64 }, + bone: { n:'Кость', icon:'🦴', stack:64 }, + gunpowder: { n:'Порох', icon:'💥', stack:64 }, + // === FARMING ITEMS === + wheat: { n:'Пшеница', icon:'🌾', stack:64 }, + bread: { n:'Хлеб', icon:'🍞', food:30 }, + carrot: { n:'Морковь', icon:'🥕', food:8, stack:64 }, + potato: { n:'Картофель', icon:'🥔', stack:64 }, + baked_potato:{ n:'Печёная картошка', icon:'🥔', food:25 }, + wheat_seeds: { n:'Семена пшеницы', icon:'🌱', stack:64 }, + mushroom_stew:{ n:'Грибной суп', icon:'🍲', food:40 }, + // === MOB DROP ITEMS === + scorpion_stinger:{ n:'Жало скорпиона', icon:'🔺', stack:64 }, + polar_fur: { n:'Шкура медведя', icon:'🧥', stack:64 }, + slime_ball: { n:'Слизь', icon:'🟢', stack:64 }, + eagle_feather:{ n:'Перо орла', icon:'🪶', stack:64 }, + // === ARMOR === + gold_armor: { n:'Золотая броня', icon:'🛡️', stack:1, armor:0.65 }, }; // Runtime extensions — предметы от обжига @@ -15,4 +34,4 @@ ITEMS.stone_axe = { n:'Каменный топор', icon:'🪓' }; ITEMS.iron_axe = { n:'Железный топор', icon:'🪓' }; ITEMS.wood_shovel = { n:'Деревянная лопата', icon:'🔱' }; ITEMS.stone_shovel = { n:'Каменная лопата', icon:'🔱' }; -ITEMS.iron_shovel = { n:'Железная лопата', icon:'🔱' }; \ No newline at end of file +ITEMS.iron_shovel = { n:'Железная лопата', icon:'🔱' }; diff --git a/src/data/recipes.js b/src/data/recipes.js index dcf3e33..f4b9d37 100644 --- a/src/data/recipes.js +++ b/src/data/recipes.js @@ -21,9 +21,14 @@ export const RECIPES = [ { out:'stone_shovel', qty:1, cost:{ wood:1, stone:2 } }, { out:'iron_shovel', qty:1, cost:{ wood:1, iron_ore:1 } }, { out:'iron_armor', qty:1, cost:{ iron_ore: 5 } }, + { out:'gold_armor', qty:1, cost:{ gold_ore: 8 } }, { 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 } } + { out:'arrow', qty:4, cost:{ stone: 1, wood: 1 } }, + { out:'flag', qty:1, cost:{ planks:3, iron_ore:1 } }, + { out:'bread', qty:1, cost:{ wheat: 3 } }, + { out:'farmland', qty:1, cost:{ dirt:1 } }, + { out:'wheat_seeds', qty:1, cost:{ farmland:1, dirt:1 } }, ]; // Рецепты печи (обжиг) @@ -34,5 +39,6 @@ export const SMELTING_RECIPES = [ { 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 + { in:'cobblestone', qty:1, out:'stone', outQty:1, time:2 }, + { in:'potato', qty:1, out:'baked_potato', outQty:1, time:2 } +]; diff --git a/src/entities/mob-ai.js b/src/entities/mob-ai.js index 08f04c1..e50366c 100644 --- a/src/entities/mob-ai.js +++ b/src/entities/mob-ai.js @@ -7,18 +7,18 @@ 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'; +import { isSolid } from '../world/world-storage.js'; +import { getCachedBiome } from '../world/generation.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) { @@ -27,20 +27,15 @@ export function mobAI(m, dt) { state.player.invuln = 0.8; state.player.vx += dir * 420; state.player.vy -= 260; - playSound('hit1'); // Звук при атаке зомби + 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) { @@ -48,23 +43,17 @@ export function mobAI(m, dt) { m.hp = 0; } } else { - // Поджигаем если очень близко if (dist < 40) { - m.fuse = 0.5; // Быстрый взрыв + 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; @@ -82,8 +71,72 @@ export function mobAI(m, dt) { life: 3 }); } + } else if (m.kind === 'scorpion') { + // Скорпион — бежит к игроку, яд (замедление) + 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(8); + state.player.hp -= damage; + state.player.invuln = 0.8; + state.player.slowTimer = 3; + state.player.vx += dir * 300; + state.player.vy -= 200; + playSound('hit1'); + } + } else if (m.kind === 'polar_bear') { + // Белый медведь — нейтрален, бродит + m.aiT -= dt; + if (m.aiT <= 0) { + m.aiT = 2.0 + Math.random() * 3; + m.dir = Math.random() < 0.5 ? -1 : 1; + if (Math.random() < 0.3) m.dir = 0; + } + m.vx = m.dir * m.speed; + if (m.inWater) m.vy = -120; + } else if (m.kind === 'slime') { + // Слизь — прыгает к игроку + const dir = Math.sign((state.player.x + state.player.w / 2) - (m.x + m.w / 2)); + m.aiT -= dt; + if (m.aiT <= 0) { + m.aiT = 1.5 + Math.random() * 1.5; + m.dir = dir; + m.vy = -200; + } + m.vx = m.dir * m.speed; + } else if (m.kind === 'eagle') { + // Орёл — летает, атакует пикированием + 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 dist = Math.hypot(dx, dy); + if (dist < 400) { + m.vx = Math.sign(dx) * m.speed; + m.vy = dy > 0 ? 60 : -60; + } else { + m.aiT -= dt; + if (m.aiT <= 0) { + m.aiT = 2 + Math.random() * 3; + m.dir = Math.random() < 0.5 ? -1 : 1; + } + m.vx = m.dir * m.speed * 0.5; + m.vy = Math.sin(performance.now() / 1000) * 30; + } + // Атака при касании + if (dist < 30 && state.player.invuln <= 0) { + const damage = calculateDamage(10); + state.player.hp -= damage; + state.player.invuln = 0.8; + state.player.vy -= 200; + playSound('hit1'); + } + // Орёл не падает — летающий моб + m.vy *= 0.5; + m.grounded = false; } else { - // животные + // животные (pig, chicken) m.aiT -= dt; if (m.aiT <= 0) { m.aiT = 1.8 + Math.random() * 2.5; @@ -94,8 +147,17 @@ export function mobAI(m, dt) { if (m.inWater) m.vy = -120; } + // Мобы прыгают при столкновении со стеной + if (m.grounded && Math.abs(m.vx) > 5 && m.kind !== 'eagle') { + const probeX = m.vx > 0 ? m.x + m.w + 2 : m.x - 2; + const probeGY = Math.floor((m.y + m.h - 1) / TILE); + if (isSolid(Math.floor(probeX / TILE), probeGY)) { + m.vy = -280; + } + } + // физика моба - const g = m.inWater ? GRAV_WATER : GRAV; + const g = (m.inWater && m.kind !== 'eagle') ? GRAV_WATER : GRAV; m.vy += g * dt; m.y += m.vy * dt; m.grounded = false; resolveY(m); @@ -103,6 +165,21 @@ export function mobAI(m, dt) { } export function isNight() { - // Автоматический цикл: ночь когда worldTime > 0.5 return state.worldTime > 0.5; -} \ No newline at end of file +} + +// Mob loot table +export function getMobLoot(kind) { + const table = { + chicken: [{item:'chicken_meat',min:1,max:2,chance:1},{item:'feather',min:0,max:1,chance:0.5}], + pig: [{item:'meat',min:1,max:2,chance:1}], + zombie: [{item:'meat',min:0,max:1,chance:0.5},{item:'iron_ingot',min:0,max:1,chance:0.3}], + skeleton: [{item:'arrow',min:2,max:4,chance:1},{item:'bone',min:0,max:2,chance:0.6},{item:'bow',min:1,max:1,chance:0.15}], + creeper: [{item:'gunpowder',min:1,max:2,chance:1}], + scorpion: [{item:'scorpion_stinger',min:0,max:1,chance:0.4}], + polar_bear:[{item:'polar_fur',min:1,max:2,chance:0.8},{item:'meat',min:1,max:2,chance:1}], + slime: [{item:'slime_ball',min:1,max:2,chance:1}], + eagle: [{item:'eagle_feather',min:1,max:2,chance:0.7}] + }; + return table[kind] || []; +} diff --git a/src/entities/mobs.js b/src/entities/mobs.js index be54425..770328f 100644 --- a/src/entities/mobs.js +++ b/src/entities/mobs.js @@ -1,4 +1,4 @@ -// Сущности: животные + зомби +// Сущности: животные + зомби + новые мобы export class Entity { constructor(x, y, w, h) { @@ -34,4 +34,20 @@ export class Creeper extends Entity { 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 +} + +export class Scorpion extends Entity { + constructor(x, y) { super(x, y, 26, 26); this.kind = 'scorpion'; this.hp = 3; this.speed = 90; this.fuse = 0; this.shootCooldown = 0; } +} + +export class PolarBear extends Entity { + constructor(x, y) { super(x, y, 40, 34); this.kind = 'polar_bear'; this.hp = 8; this.speed = 50; this.fuse = 0; this.shootCooldown = 2; } +} + +export class Slime extends Entity { + constructor(x, y) { super(x, y, 24, 24); this.kind = 'slime'; this.hp = 2; this.speed = 30; this.fuse = 0; this.shootCooldown = 2; } +} + +export class Eagle extends Entity { + constructor(x, y) { super(x, y, 30, 22); this.kind = 'eagle'; this.hp = 3; this.speed = 120; this.fuse = 0; this.shootCooldown = 0; } +} diff --git a/src/entities/player.js b/src/entities/player.js index 6503201..577682c 100644 --- a/src/entities/player.js +++ b/src/entities/player.js @@ -3,12 +3,8 @@ 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; } @@ -32,12 +28,13 @@ function initPlayer() { lastStepTime: 0, sleeping: false, inBoat: false, - armor: 0, // Текущий уровень защиты (0 = без брони, 0.5 = железная броня) - equippedArmor: null // Тип надетой брони + armor: 0, + equippedArmor: null, + temperature: 15, + slowTimer: 0 }; - // Сохраняем начальную позицию для возрождения state.spawnPoint = { x: 6 * TILE, y: 0 * TILE }; } -export { calculateDamage, initPlayer }; \ No newline at end of file +export { calculateDamage, initPlayer }; diff --git a/src/game/loop.js b/src/game/loop.js index 963a071..d469362 100644 --- a/src/game/loop.js +++ b/src/game/loop.js @@ -10,9 +10,9 @@ 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 { updateWeather, updateRain, drawWeather, skyColor } from '../world/weather.js'; +import { ensureGenAroundCamera, surfaceGyAt, genColumn, getCachedBiome, getBiome } from '../world/generation.js'; +import { Zombie, Creeper, Skeleton, Pig, Chicken, Scorpion, PolarBear, Slime, Eagle } from '../entities/mobs.js'; import { mobAI } from '../entities/mob-ai.js'; import { explodeAt } from '../world/tnt.js'; import { sendPlayerPosition } from '../multiplayer/socket-helpers.js'; @@ -21,6 +21,7 @@ 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'; +import { itemTex } from '../render/textures.js'; // ==================== LOOP ==================== let last = performance.now(); @@ -52,8 +53,10 @@ export function loop(now) { const jumpPressed = inp.j && !prevJump; prevJump = inp.j; - // Ускорение времени во время сна - if (player.sleeping && isNight()) { + // Ускорение времени во время сна или замороженный цикл + if (!state.dayNightCycle) { + // Цикл день/ночь выключен — время стоит + } else if (player.sleeping && isNight()) { state.worldTime += dt * 8 / state.DAY_LEN; player.hp = Math.min(100, player.hp + dt * 20); if (!isNight()) { @@ -68,6 +71,19 @@ export function loop(now) { state.camX = Math.floor((player.x + player.w / 2) - state.W / 2); state.camY = Math.floor((player.y + player.h / 2) - state.H / 2); + // Самовосстановление при битых координатах (NaN/undefined из старого сейва): + // иначе камера уезжает в NaN — видно только небо, без блоков и персонажа. + if (!Number.isFinite(player.x) || !Number.isFinite(player.y)) { + const startGX = 6; + for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx); + player.x = startGX * state.TILE; + player.y = (surfaceGyAt(startGX) - 1) * state.TILE; + player.vx = player.vy = 0; + state.camX = Math.floor((player.x + player.w / 2) - state.W / 2); + state.camY = Math.floor((player.y + player.h / 2) - state.H / 2); + console.warn('[Loop] player pos was broken, respawned at start'); + } + ensureGenAroundCamera(); // clouds parallax @@ -93,6 +109,83 @@ export function loop(now) { // голод убывает player.hunger = Math.max(0, player.hunger - dt * 0.2); + // ========== TEMPERATURE ========== + if (player.temperature === undefined) player.temperature = 15; + const BIOME_TEMP = { snow: -20, plains: 15, swamp: 20, desert: 45, mountains: 5 }; + const COLD_THRESHOLD = -5; + const HEAT_THRESHOLD = 35; + const HEAT_RADIUS = 5; + const pGX = Math.floor(player.x / state.TILE); + const biome = getCachedBiome(pGX); + const pGY = Math.floor(player.y / state.TILE); + const sGY = surfaceGyAt(pGX); + const isUndergroundTemp = (pGY - sGY) > 2; + let targetTemp = BIOME_TEMP[biome] || 15; + if (isUndergroundTemp) targetTemp = 15; + if (isNight() && !isUndergroundTemp) targetTemp -= 10; + if ((state.weatherType === 'rain' || state.weatherType === 'storm') && !isUndergroundTemp) targetTemp -= 5; + player.temperature += (targetTemp - player.temperature) * dt * 0.3; + let nearHeat = false, nearCool = false; + for (let dx = -HEAT_RADIUS; dx <= HEAT_RADIUS; dx++) { + for (let dy = -HEAT_RADIUS; dy <= HEAT_RADIUS; dy++) { + const b = getBlock(pGX + dx, pGY + dy); + if (!b || b.dead) continue; + if (b.t === 'campfire' || b.t === 'torch') nearHeat = true; + if (b.t === 'water' && biome === 'desert') nearCool = true; + } + } + if (nearHeat) { + player.temperature += (30 - player.temperature) * dt * 2; + if (player.hp < 100) player.hp = Math.min(100, player.hp + 8 * dt); + } + if (nearCool) { + player.temperature += (15 - player.temperature) * dt * 1.5; + if (player.hp < 100 && player.temperature > HEAT_THRESHOLD) player.hp = Math.min(100, player.hp + 8 * dt); + } + if (isUndergroundTemp && !nearHeat) { + if (player.hp < 100 && player.temperature >= COLD_THRESHOLD && player.temperature <= HEAT_THRESHOLD) { + player.hp = Math.min(100, player.hp + 2 * dt); + } + } + const aboveBlk = getBlock(pGX, pGY - 1); + const inShade = aboveBlk && BLOCKS[aboveBlk.t] && BLOCKS[aboveBlk.t].solid && aboveBlk.t !== 'glass'; + if (inShade && !isUndergroundTemp && !nearCool) { + player.temperature -= 5 * dt; + if (player.temperature > HEAT_THRESHOLD - 2 && player.hp < 100) player.hp = Math.min(100, player.hp + 1 * dt); + } + if (player.temperature < COLD_THRESHOLD && !isUndergroundTemp) { + const severity = Math.abs(player.temperature - COLD_THRESHOLD) / 15; + player.hp -= 1 * severity * dt; + if (severity > 0.5) player.vx *= (1 - 0.3 * Math.min(1, severity) * dt); + } + if (player.temperature > HEAT_THRESHOLD && !isUndergroundTemp) { + const severity = (player.temperature - HEAT_THRESHOLD) / 15; + player.hp -= 1 * severity * dt; + if (severity > 0.5) player.hunger -= 0.5 * severity * dt; + } + if (player.slowTimer > 0) player.slowTimer -= dt; + + // Кактус — урон при касании + const pCGX = Math.floor((player.x + player.w / 2) / state.TILE); + const pCGY = Math.floor((player.y + player.h / 2) / state.TILE); + for (let cdx = -1; cdx <= 1; cdx++) { + for (let cdy = -1; cdy <= 1; cdy++) { + const cb = getBlock(pCGX + cdx, pCGY + cdy); + if (cb && !cb.dead && BLOCKS[cb.t] && BLOCKS[cb.t].hurt) { + if (player.invuln <= 0) { + player.hp -= 2; + player.invuln = 0.5; + } + } + } + } + + // Лёд — скольжение + const iceBlock = getBlock(pCGX, pCGY + 1); + if (iceBlock && BLOCKS[iceBlock.t] && BLOCKS[iceBlock.t].slip && player.grounded) { + player.vx *= 0.98; + } + // Игрок не может двигаться во время сна if (player.sleeping) { player.vx = 0; @@ -297,6 +390,30 @@ export function loop(now) { } } + + // ====== CROP GROWTH ====== + for (const [key, blk] of state.grid) { + if (blk.dead) continue; + const bDef = BLOCKS[blk.t]; + if (bDef.growthStage === undefined) continue; + if (bDef.harvestable) continue; // stage 3 = fully grown + if (!blk.growthTimer && blk.growthTimer !== 0) blk.growthTimer = 0; + blk.growthTimer += dt; + // Growth time from block def (default 30s per stage) + const growTime = bDef.growthTime || 30; + if (blk.growthTimer >= growTime) { + blk.growthTimer = 0; + const nextStage = bDef.growsTo; + if (nextStage) { + blk.t = nextStage; + // Update texture reference + if (tex[nextStage]) { + // Block type changed — will render new texture next frame + } + } + } + } + // mobs spawn — only in singleplayer state.spawnT += dt; if (!state.isMultiplayer && state.spawnT > 1.8 && mobs.length < 30) { @@ -317,11 +434,16 @@ export function loop(now) { // skip } else { const night = isNight(); + const spawnBiome = getCachedBiome(gx); if (night) { - const hostileCount = mobs.filter(m => m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton').length; + const hostileCount = mobs.filter(m => m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton' || m.kind === 'scorpion' || m.kind === 'slime').length; if (hostileCount < 12) { const rand = Math.random(); - if (rand < 0.35) { + if (spawnBiome === 'desert' && rand < 0.6) { + mobs.push(new Scorpion(wx, wy)); + } else if (spawnBiome === 'swamp' && rand < 0.5) { + mobs.push(new Slime(wx, wy)); + } else if (rand < 0.35) { mobs.push(new Zombie(wx, wy)); } else if (rand < 0.55) { mobs.push(new Creeper(wx, wy)); @@ -330,9 +452,16 @@ export function loop(now) { } } } - const animalCount = mobs.filter(m => m.kind === 'pig' || m.kind === 'chicken').length; + // Daytime biome mobs + const animalCount = mobs.filter(m => m.kind === 'pig' || m.kind === 'chicken' || m.kind === 'polar_bear' || m.kind === 'eagle').length; if (animalCount < 8) { - mobs.push(Math.random() < 0.5 ? new Pig(wx, wy) : new Chicken(wx, wy)); + if (spawnBiome === 'tundra' && Math.random() < 0.6) { + mobs.push(new PolarBear(wx, wy)); + } else if (spawnBiome === 'mountains' && Math.random() < 0.4) { + mobs.push(new Eagle(wx, wy)); + } else { + mobs.push(Math.random() < 0.5 ? new Pig(wx, wy) : new Chicken(wx, wy)); + } } } } @@ -374,7 +503,7 @@ export function loop(now) { const night = isNight(); // sky - ctx.fillStyle = night ? '#070816' : (state.isRaining ? '#6B7B8D' : '#87CEEB'); + const playerGx = Math.floor(player.x / state.TILE); const skyBiome = getBiome(playerGx); ctx.fillStyle = skyColor(night, skyBiome, state.weatherType, state.worldTime); ctx.fillRect(0, 0, W, H); // clouds @@ -524,6 +653,65 @@ export function loop(now) { ctx.lineTo(8 * Math.cos(Math.PI * 0.7), 8 * Math.sin(Math.PI * 0.7)); ctx.stroke(); ctx.restore(); + } else if (m.kind === 'scorpion') { + // Scorpion: dark brown body, claws, tail + ctx.fillStyle = '#5D4037'; + ctx.fillRect(m.x, m.y, m.w, m.h); + ctx.fillStyle = '#8D6E63'; + ctx.fillRect(m.x + 2, m.y + 10, 6, 8); // left claw + ctx.fillRect(m.x + m.w - 8, m.y + 10, 6, 8); // right claw + // Tail + ctx.fillStyle = '#5D4037'; + ctx.beginPath(); + ctx.moveTo(m.x + m.w / 2, m.y); + ctx.lineTo(m.x + m.w / 2 - 3, m.y - 10); + ctx.lineTo(m.x + m.w / 2 + 3, m.y - 10); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = '#FF5722'; + ctx.fillRect(m.x + m.w / 2 - 2, m.y - 12, 4, 4); // stinger + } else if (m.kind === 'polar_bear') { + // Polar bear: white body, black nose + ctx.fillStyle = '#ECEFF1'; + ctx.fillRect(m.x, m.y, m.w, m.h); + ctx.fillStyle = '#263238'; + ctx.fillRect(m.x + m.w / 2 - 2, m.y + 6, 4, 3); // nose + ctx.fillRect(m.x + 8, m.y + 12, 3, 3); // eye + ctx.fillRect(m.x + m.w - 11, m.y + 12, 3, 3); // eye + // legs + ctx.fillStyle = '#CFD8DC'; + ctx.fillRect(m.x + 6, m.y + m.h - 8, 8, 8); + ctx.fillRect(m.x + m.w - 14, m.y + m.h - 8, 8, 8); + } else if (m.kind === 'slime') { + // Slime: green bouncy blob + ctx.fillStyle = '#4CAF50'; + ctx.beginPath(); + ctx.arc(m.x + m.w / 2, m.y + m.h / 2, m.w / 2, 0, Math.PI * 2); + ctx.fill(); + // Eyes + ctx.fillStyle = '#fff'; + ctx.fillRect(m.x + 6, m.y + 7, 5, 5); + ctx.fillRect(m.x + m.w - 11, m.y + 7, 5, 5); + ctx.fillStyle = '#000'; + ctx.fillRect(m.x + 8, m.y + 9, 2, 2); + ctx.fillRect(m.x + m.w - 9, m.y + 9, 2, 2); + } else if (m.kind === 'eagle') { + // Eagle: brown body, wings, yellow beak + ctx.fillStyle = '#5D4037'; + ctx.fillRect(m.x + m.w / 2 - 4, m.y + 6, 8, 14); // body + // Wings + ctx.fillStyle = '#795548'; + ctx.fillRect(m.x, m.y + 8, m.w / 2 - 4, 6); + ctx.fillRect(m.x + m.w / 2 + 4, m.y + 8, m.w / 2 - 4, 6); + // Head + ctx.fillStyle = '#ECEFF1'; + ctx.fillRect(m.x + m.w / 2 - 3, m.y + 2, 6, 6); + // Beak + ctx.fillStyle = '#FFC107'; + ctx.fillRect(m.x + m.w / 2 + 3, m.y + 4, 4, 2); + // Eye + ctx.fillStyle = '#000'; + ctx.fillRect(m.x + m.w / 2 - 1, m.y + 4, 2, 2); } } @@ -534,7 +722,7 @@ export function loop(now) { // other players for (const [socketId, p] of state.otherPlayers) { - if (state.heroImg.complete) { + 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; @@ -547,7 +735,7 @@ export function loop(now) { } // player - if (state.heroImg.complete) { + 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'; @@ -636,16 +824,16 @@ export function loop(now) { 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 flick = 0.97 + Math.sin(now / 300 + sx * 0.01) * 0.015 + Math.sin(now / 470 + sy * 0.02) * 0.015; const r = radius * flick; - const steps2 = 12; + const steps2 = 24; 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) { + for (let step = TILE * 0.3; step < r; step += TILE * 0.35) { const lgx = Math.floor((sx + ddx * step) / TILE); const lgy = Math.floor((sy + ddy * step) / TILE); const blk = getBlock(lgx, lgy); @@ -695,13 +883,13 @@ export function loop(now) { 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 flick = 0.9 + Math.sin(now / 350 + b.gx * 3.7) * 0.05 + Math.sin(now / 530 + b.gy * 2.3) * 0.05; const wx = b.gx * TILE + TILE / 2 - camX; const wy = b.gy * TILE + TILE / 2 - camY; - const r = def.lightRadius * 0.6 * flick; + const r = def.lightRadius * 0.85; 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(0, `rgba(255,180,80,0.28)`); + grad.addColorStop(0.5, `rgba(255,140,40,0.13)`); grad.addColorStop(1, 'rgba(255,100,20,0)'); ctx.fillStyle = grad; ctx.beginPath(); @@ -713,11 +901,20 @@ export function loop(now) { } // Дождь - drawRain(); + drawWeather(ctx); 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); + // Temperature display + const tempEl = document.getElementById('temp'); + if (tempEl) { + const t = Math.round(player.temperature); + tempEl.textContent = t + '°C'; + if (player.temperature < COLD_THRESHOLD) tempEl.style.color = '#74b9ff'; + else if (player.temperature > HEAT_THRESHOLD) tempEl.style.color = '#e17055'; + else tempEl.style.color = '#55efc4'; + } state.sxEl.textContent = Math.floor(player.x / TILE); state.syEl.textContent = Math.floor(player.y / TILE); state.todEl.textContent = night ? 'Ночь' : 'День'; diff --git a/src/game/save.js b/src/game/save.js index fd788ae..65b9c7b 100644 --- a/src/game/save.js +++ b/src/game/save.js @@ -3,8 +3,9 @@ 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'; +import { renderInventory } from '../ui/inventory.js'; -const SAVE_KEY = state.SAVE_KEY; +const SAVE_STORAGE_KEY = state.SAVE_KEY; let db = null; // Оставляем для совместимости, но не используем // Инициализация (localStorage + in-memory fallback) @@ -29,6 +30,7 @@ export function saveGame() { inventory: state.inv, time: state.worldTime, isNight: state.isNightTime, + dayNightCycle: state.dayNightCycle, // Сохраняем только изменения placedBlocks: state.placedBlocks.slice(), removedBlocks: state.removedBlocks.slice() @@ -39,7 +41,7 @@ export function saveGame() { // Пробуем сохранить в localStorage (основной метод для персистентности) try { - localStorage.setItem(SAVE_KEY, JSON.stringify(saveData)); + localStorage.setItem(SAVE_STORAGE_KEY, JSON.stringify(saveData)); console.log(`Игра сохранена в localStorage (размер: ${saveSize} байт)`); } catch (e) { console.warn('Ошибка сохранения в localStorage, используем только in-memory:', e); @@ -54,7 +56,7 @@ export function loadGame() { return new Promise((resolve, reject) => { // Пробуем localStorage try { - const localSave = localStorage.getItem(SAVE_KEY); + const localSave = localStorage.getItem(SAVE_STORAGE_KEY); if (localSave) { const parsed = JSON.parse(localSave); console.log('Загружено из localStorage, player HP:', parsed.player?.hp); @@ -161,6 +163,9 @@ export async function applySave(saveData) { if (saveData.isNight !== undefined) { state.isNightTime = saveData.isNight; } + if (saveData.dayNightCycle !== undefined) { + state.dayNightCycle = saveData.dayNightCycle; + } // Перегенерируем мир по seed regenerateVisibleChunks(); @@ -182,6 +187,11 @@ export async function applySave(saveData) { state.removedBlocks = saveData.removedBlocks || []; } + // Fill recentItems from inventory if empty (hotbar won.t render otherwise) + if (state.recentItems.length === 0) { + state.recentItems = Object.keys(state.inv).filter(id => state.inv[id] > 0).slice(0, 5); + } rebuildHotbar(); + renderInventory(); console.log('Игра загружена'); } \ No newline at end of file diff --git a/src/input/mouse-handler.js b/src/input/mouse-handler.js index b492dc7..100bb43 100644 --- a/src/input/mouse-handler.js +++ b/src/input/mouse-handler.js @@ -6,7 +6,7 @@ import { TOOLS } from '../data/tools.js'; import { getBestTool, useTool, toolTierLessThan, getMiningSpeed } 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 { rebuildHotbar, addToRecentItems } from '../ui/hotbar.js'; import { mode } from '../game/modes.js'; import { openFurnaceUI } from '../ui/furnace.js'; import { saveGame } from '../game/save.js'; @@ -14,6 +14,35 @@ import { activateTNT } from '../world/tnt.js'; import { sendBlockChange } from '../multiplayer/socket-helpers.js'; import { isNight } from '../entities/mob-ai.js'; +// Crop stage to seed/harvest mapping +const CROP_SEED_MAP = { + wheat_stage0: 'wheat_seeds', wheat_stage1: 'wheat_seeds', wheat_stage2: 'wheat_seeds', wheat_stage3: 'wheat_seeds', + carrot_stage0: 'carrot', carrot_stage1: 'carrot', carrot_stage2: 'carrot', carrot_stage3: 'carrot', + potato_stage0: 'potato', potato_stage1: 'potato', potato_stage2: 'potato', potato_stage3: 'potato' +}; +const CROP_HARVEST_MAP = { wheat_stage3: 'wheat', carrot_stage3: 'carrot', potato_stage3: 'potato' }; + +// Helper: handle block drops including farming items +function handleBlockDrop(removed, inv) { + // Grass drops wheat_seeds (50%) + if (removed.t === 'grass') { + if (Math.random() < 0.5) inv.wheat_seeds = (inv.wheat_seeds || 0) + 1; + return; + } + // Crop drops: seeds for stages 0-2, harvest+seed for stage 3 + const harvestItem = CROP_HARVEST_MAP[removed.t]; + const seedItem = CROP_SEED_MAP[removed.t]; + if (harvestItem) { + inv[harvestItem] = (inv[harvestItem] || 0) + (BLOCKS[removed.t].harvestQty || 1); addToRecentItems(harvestItem); + if (seedItem) { inv[seedItem] = (inv[seedItem] || 0) + 1; addToRecentItems(seedItem); } + } else if (seedItem) { + // Early crop stage — return seed + inv[seedItem] = (inv[seedItem] || 0) + 1; addToRecentItems(seedItem); + } else { + inv[removed.t] = (inv[removed.t] || 0) + 1; addToRecentItems(removed.t); + } +} + export const mouse = { x: null, y: null }; // Hold-to-mine tracking @@ -157,7 +186,8 @@ export function initMouseHandlers() { // ====== MINING PROGRESSION SYSTEM ====== if (mode() === 'mine') { if (!b) return; - if (BLOCKS[b.t].fluid || BLOCKS[b.t].decor) return; + // Allow mining crops (growthStage) and harvestable blocks, block other decor and fluids + if (BLOCKS[b.t].fluid || (BLOCKS[b.t].decor && BLOCKS[b.t].growthStage === undefined && !BLOCKS[b.t].harvestable)) return; if (b.t === 'tnt') { activateTNT(b, 3.2); return; } @@ -165,10 +195,10 @@ export function initMouseHandlers() { const hardness = BLOCKS[b.t].hardness || 1; if (hardness === Infinity) return; // unbreakable (bedrock) if (hardness === 0) { - // Instant-mine blocks (flower, boat, etc.) + // Instant-mine blocks (flower, boat, crops, etc.) const removed = removeBlock(gx, gy); if (removed) { - state.inv[removed.t] = (state.inv[removed.t] || 0) + 1; + handleBlockDrop(removed, state.inv); sendBlockChange(gx, gy, removed.t, 'remove'); rebuildHotbar(); } @@ -213,7 +243,7 @@ export function initMouseHandlers() { const removed = removeBlock(gx, gy); if (removed) { if (drops) { - state.inv[removed.t] = (state.inv[removed.t] || 0) + 1; + handleBlockDrop(removed, state.inv); } else { // Drop cobblestone instead for stone-type blocks if (BLOCKS[removed.t]?.toolType === 'pickaxe') { @@ -274,7 +304,7 @@ export function initMouseHandlers() { const removed = removeBlock(mgx, mgy); if (removed) { if (mDrops) { - state.inv[removed.t] = (state.inv[removed.t] || 0) + 1; + handleBlockDrop(removed, state.inv); } else { if (BLOCKS[removed.t]?.toolType === 'pickaxe') { state.inv.cobblestone = (state.inv.cobblestone || 0) + 1; @@ -296,6 +326,41 @@ export function initMouseHandlers() { // ====== BUILD MODE ====== if (mode() === 'build') { + // Planting seeds on farmland (works even without farmland in inventory) + const seedToCropBlock = { + wheat_seeds: 'wheat_stage0', + carrot: 'carrot_stage0', + potato: 'potato_stage0' + }; + if (seedToCropBlock[state.selected] && state.inv[state.selected] > 0) { + // Clicking on farmland — plant above it + if (b && b.t === 'farmland') { + const aboveKey = gx + ',' + (gy - 1); + const above = state.grid.get(aboveKey); + if (!above || above.dead) { + setBlock(gx, gy - 1, seedToCropBlock[state.selected], true); + state.inv[state.selected]--; + const cropBlock = state.grid.get(gx + ',' + (gy - 1)); + if (cropBlock) cropBlock.growthTimer = 0; + rebuildHotbar(); + return; + } + } + // Clicking on empty space above farmland — plant there + if (!b) { + const belowKey = gx + ',' + (gy + 1); + const below = state.grid.get(belowKey); + if (below && below.t === 'farmland') { + setBlock(gx, gy, seedToCropBlock[state.selected], true); + state.inv[state.selected]--; + const cropBlock = state.grid.get(gx + ',' + gy); + if (cropBlock) cropBlock.growthTimer = 0; + rebuildHotbar(); + return; + } + } + } + if (state.inv[state.selected] <= 0) return; if (!BLOCKS[state.selected]) return; if (b) return; // занято diff --git a/src/main.js b/src/main.js index 7e986e4..ff557f8 100644 --- a/src/main.js +++ b/src/main.js @@ -1,16 +1,13 @@ // ==================== ENTRY POINT ==================== import { state } from './core/state.js'; -import { initCanvas } from './core/canvas.js'; import { SERVER_URL } from './config.js'; +import { showStartMenu, hideBoot, isTgWebApp, tgUser } from './ui/start-menu.js'; +import { initCanvas } from './core/canvas.js'; import { initSocket } from './multiplayer/socket.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, tex, itemTex } from './render/textures.js'; +import { initTextures, tex } from './render/textures.js'; import { getBlock, setBlock, removeBlock } from './world/world-storage.js'; -import { genColumn, surfaceGyAt, ensureGenAroundCamera, regenerateVisibleChunks } from './world/generation.js'; +import { genColumn, surfaceGyAt } 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'; @@ -18,6 +15,7 @@ import { initFurnace } from './ui/furnace.js'; import { initMinimap } from './ui/minimap.js'; import { initSaveControls, updateSaveButtonVisibility } from './ui/save-controls.js'; import { initMarket } from './ui/market.js'; +import { initSettings } from './ui/settings.js'; import { initRespawn } from './ui/respawn.js'; import { initShare } from './ui/share.js'; import { initControls } from './input/controls.js'; @@ -26,199 +24,335 @@ import { initModes } from './game/modes.js'; import { initVoice } from './multiplayer/voice-chat.js'; import { rebuildHotbar } from './ui/hotbar.js'; import { toggleCraft, closeCraft, toggleInventory, closeInventory } from './ui/craft.js'; -import { renderCraft } from './ui/craft.js'; -// ==================== 1) CONFIG — parse URL/getName ==================== -const urlParams = new URLSearchParams(window.location.search); +// ==================== 1) CONFIG — parse URL ==================== +// urlParams defined in config.js state.SERVER_URL = SERVER_URL; state.TELEGRAM_BOT_USERNAME = 'Grechkacraft_bot'; state.TELEGRAM_APP_SHORT_NAME = 'minegrechka'; -// 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); +// Set TG-related state +if (isTgWebApp) { + state.myTgId = tgUser?.id || null; } -// 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.heroImg = new Image(); state.heroImg.src = 'https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png'; -state.worldId = (worldParam && worldParam.trim() !== '') ? worldParam : null; -console.log('worldId after params:', state.worldId, 'type:', typeof state.worldId); +// ==================== 2) START MENU ==================== +// Show the start menu before initializing the game. +// The menu returns a Promise that resolves with the worldId. +async function startGame() { + const worldId = await showStartMenu(); + + // Set worldId and playerName + state.worldId = worldId; + if (!state.playerName) { + state.playerName = localStorage.getItem('minegrechka_playerName') || 'Игрок'; + localStorage.setItem('minegrechka_playerName', state.playerName); + } + console.log('[Launch] worldId:', state.worldId, 'player:', state.playerName, 'tg:', isTgWebApp); -if (!state.worldId) { - state.worldId = Math.random().toString(36).substring(2, 10); - console.log('Generated worldId:', state.worldId); + // Validate TG initData if in TG context + if (isTgWebApp && window.Telegram && Telegram.WebApp.initData) { + try { + const resp = await fetch(state.SERVER_URL + '/api/tg/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ initData: Telegram.WebApp.initData }) + }); + const data = await resp.json(); + if (data.ok && data.tg_id) { + console.log('[TG] Authenticated, tg_id:', data.tg_id); + state.myTgId = data.tg_id; + } + } catch (e) { console.warn('[TG] Auth failed:', e); } + } + // Update URL with world code 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!'); + if (typeof history !== 'undefined' && history.replaceState) { + history.replaceState(null, '', newUrl.toString()); } - } catch (e) { - console.error('Error updating URL:', e); + } catch (e) {} + + // ==================== 3) CANVAS — init from canvas module ==================== + initCanvas(); + + // ==================== 4) STATE — set DOM refs ==================== + 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'); + state.hotbarEl = document.getElementById('hotbar'); + state.craftPanel = document.getElementById('craftPanel'); + state.recipesEl = document.getElementById('recipes'); + state.inventoryPanel = document.getElementById('inventoryPanel'); + state.inventoryGrid = document.getElementById('inventoryGrid'); + state.marketPanel = document.getElementById('marketPanel'); + state.marketContent = document.getElementById('marketContent'); + state.tradePanel = document.getElementById('tradePanel'); + state.tradeContent = document.getElementById('tradeContent'); + state.multiplayerStatus = document.getElementById('multiplayerStatus'); + + // ==================== 5) TEXTURES — initTextures() ==================== + initTextures(); + state.tex = tex; + + // ==================== 6) CRAFT/INVENTORY BUTTONS ==================== + const craftBtn = document.getElementById('craftBtn'); + const invToggle = document.getElementById('invToggle'); + const craftClose = document.getElementById('craftClose'); + const inventoryClose = document.getElementById('inventoryClose'); + + if (craftBtn) craftBtn.onclick = () => toggleCraft(); + if (craftClose) craftClose.onclick = () => closeCraft(); + if (invToggle) invToggle.onclick = () => toggleInventory(); + if (inventoryClose) inventoryClose.onclick = () => closeInventory(); + + // ==================== 7) CONTROLS — initControls ==================== + initControls(); + + // ==================== 8) MOUSE — initMouseHandlers ==================== + initMouseHandlers(); + + // ==================== 9) UI MODULES — init all UI ==================== + initChat(); + initFurnace(); + initMinimap(); + initSaveControls(); + initMarket(); + initRespawn(); + initShare(); + initSettings(); + initModes(); + + // ==================== 10) SOCKET — initSocket ==================== + initSocket(); + + // ==================== 11) VOICE — initVoice ==================== + initVoice(); + + // ==================== 12) SAVE — loadGame, applySave ==================== + // Ensure hotbar has items on first load + if (state.recentItems.length === 0) { + state.recentItems = Object.keys(state.inv).filter(k => state.inv[k] > 0).slice(0, 5); } + // Make sure selected item is valid and in recentItems + if (!state.selected || !state.inv[state.selected] || state.inv[state.selected] <= 0) { + state.selected = state.recentItems[0] || 'dirt'; + } + if (!state.recentItems.includes(state.selected)) { + state.recentItems.unshift(state.selected); + state.recentItems = state.recentItems.slice(0, 5); + } + rebuildHotbar(); - console.log('Generated new worldId for browser:', state.worldId); -} + // Keyboard hotbar switching (1/2/3) + document.addEventListener('keydown', (e) => { + if (state.showFullInventory || state.craftOpen || state.inventoryOpen || state.chatOpen) return; + if (e.key >= '1' && e.key <= '3') { + const items = state.recentItems.filter(id => state.inv[id] > 0).slice(0, 3); + const idx = parseInt(e.key) - 1; + if (items[idx]) { + state.selected = items[idx]; + rebuildHotbar(); + } + } + }); -console.log('Final worldId:', state.worldId, 'Player name:', state.playerName); + // Scroll wheel hotbar cycling + document.addEventListener('wheel', (e) => { + if (state.showFullInventory || state.craftOpen || state.inventoryOpen || state.chatOpen) return; + const items = state.recentItems.filter(id => state.inv[id] > 0).slice(0, 3); + if (items.length === 0) return; + let idx = items.indexOf(state.selected); + if (idx < 0) idx = 0; + if (e.deltaY > 0) idx = (idx + 1) % items.length; + else idx = (idx - 1 + items.length) % items.length; + state.selected = items[idx]; + rebuildHotbar(); + e.preventDefault(); + }, { passive: false }); -// ==================== 2) CANVAS — init from canvas module ==================== -initCanvas(); + initDB().then(async () => { + const loadedSave = await loadGame(); + if (loadedSave) { + await applySave(loadedSave); + console.log('Загружено сохранение, HP:', state.player.hp); -// ==================== 3) STATE — set DOM refs ==================== -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'); -state.hotbarEl = document.getElementById('hotbar'); -state.craftPanel = document.getElementById('craftPanel'); -state.recipesEl = document.getElementById('recipes'); -state.inventoryPanel = document.getElementById('inventoryPanel'); -state.inventoryGrid = document.getElementById('inventoryGrid'); -state.marketPanel = document.getElementById('marketPanel'); -state.marketContent = document.getElementById('marketContent'); -state.tradePanel = document.getElementById('tradePanel'); -state.tradeContent = document.getElementById('tradeContent'); -state.multiplayerStatus = document.getElementById('multiplayerStatus'); + 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('Сохранение не найдено, начинаем новую игру'); + // Fill recentItems from inventory + state.recentItems = Object.keys(state.inv).filter(k => state.inv[k] > 0).slice(0, 5); + rebuildHotbar(); -// ==================== 4) TEXTURES — initTextures() ==================== -initTextures(); -state.tex = tex; -state.itemTex = itemTex; - -// ==================== 5) AUDIO — sounds already loaded by sound-engine.js module init ==================== -// (sound-engine.js loads all sounds at module init time) - -// ==================== 6) CRAFT/INVENTORY BUTTONS ==================== -const craftBtn = document.getElementById('craftBtn'); -const invToggle = document.getElementById('invToggle'); -const craftClose = document.getElementById('craftClose'); -const inventoryClose = document.getElementById('inventoryClose'); - -if (craftBtn) craftBtn.onclick = () => toggleCraft(); -if (craftClose) craftClose.onclick = () => closeCraft(); -if (invToggle) invToggle.onclick = () => toggleInventory(); -if (inventoryClose) inventoryClose.onclick = () => closeInventory(); - -// ==================== 7) CONTROLS — initControls ==================== -initControls(); - -// ==================== 8) MOUSE — initMouseHandlers ==================== -initMouseHandlers(); - -// ==================== 9) UI MODULES — init all UI ==================== -initChat(); -initFurnace(); -initMinimap(); -initSaveControls(); -initMarket(); -initRespawn(); -initShare(); -initModes(); - -// ==================== 10) SOCKET — initSocket ==================== -initSocket(); - -// ==================== 11) VOICE — initVoice ==================== -initVoice(); - -// ==================== 12) 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; + + // старт — на поверхности + 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; - } - } 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; + state.spawnPoint.x = state.player.x; + state.spawnPoint.y = state.player.y; - // старт — на поверхности - 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; + console.log('Новая игра: startGX=', startGX, 'surfaceY=', surfaceY, 'player.y=', state.player.y); + + // Генерируем карту вокруг стартовой позиции + for (let gx = startGX - 50; gx <= startGX + 50; gx++) { + genColumn(gx); } } - state.player.y = safeGY * state.TILE; - state.player.x = startGX * state.TILE; + + // Автосейв при скрытии страницы + 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; - 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(); + // ==================== 13) LOOP — startLoop ==================== + requestAnimationFrame(loop); + + // ==================== 14) WORLD-GEN PLACEHOLDER ==================== + // 2D-мир генерируется лениво, колонками вокруг игрока. В первые секунды + // на экране только небо и HUD — пользователь считает это поломкой. + // Показываем заглушку, пока реальных блоков не видно, и гасим её при первом + // отрисованном блоке. Счётчики — из generated (Set сгенерированных колонок). + installWorldGenPlaceholder(); +} + +// ==================== WORLD-GEN PLACEHOLDER ==================== +let _wgPh = null; + +function installWorldGenPlaceholder() { + if (_wgPh || document.getElementById('wgPh')) return; + hideBoot(); + + const el = document.createElement('div'); + el.id = 'wgPh'; + el.setAttribute('role', 'status'); + el.setAttribute('aria-live', 'polite'); + el.innerHTML = + '
' + + '
Генерация мира
' + + '
' + + '
Первые секунды мир строится по колонкам
' + + '
'; + document.body.appendChild(el); + _wgPh = el; + + // Стили — отдельным ' + + '
' + + '
Гречка Craft 3D
' + + '
Подготовка…
' + + '
' + + '
' + + '
0%
' + + '
' + + 'Строим мир: рельеф, биомы, деревья, руды и гречка
' + + '
'; + document.body.appendChild(el); + } + el.style.display = 'flex'; + return el; +} + +function hideWorldLoader() { + const el = document.getElementById('v3load'); + if (el) el.style.display = 'none'; +} + +// Статичная заставка из index-v2.html живёт, пока грузится JS-бандл. Гасим её +// сразу, как только скрипт стартует: дальше свою работу делает экран генерации +// мира с реальным прогрессом. +function dismissBootScreen() { + const b = document.getElementById('v3boot'); + if (!b) return; + if (typeof window.__v3bootDone === 'function') window.__v3bootDone(); + b.style.transition = 'opacity .3s ease'; + b.style.opacity = '0'; + setTimeout(() => { b.style.display = 'none'; }, 320); +} + +// Ждём готовности мира, обновляя полосу прогресса. +function waitWorldReady(maxMs = 180000) { + const el = showWorldLoader(); + const fill = document.getElementById('v3loadfill'); + const txt = document.getElementById('v3loadtxt'); + const stage = document.getElementById('v3loadstage'); + const hint = document.getElementById('v3loadhint'); + // Этапы по доле готовности. Один текст «Готовим мир…» не объяснял, что + // происходит, и загрузка выглядела как зависание. + const STAGES = [ + [0.02, 'Расстилаем рельеф', 'Режем холмы и долины'], + [0.18, 'Раздаём биомы', 'Луга, лес, песок и снег'], + [0.42, 'Заросли и деревья', 'Сажаем лес и кусты'], + [0.66, 'Закладываем руды', 'Уголь, железо, золото'], + [0.84, 'Сеем гречку', 'Главный урожай'], + [0.97, 'Ставим игрока', 'Ищем сухой берег'] + ]; + return new Promise(resolve => { + const t0 = performance.now(); + const tick = () => { + const pr = isWorldReady() ? 1 : worldGenProgress(); + const pct = Math.round(pr * 100); + if (fill) fill.style.width = pct + '%'; + if (txt) txt.textContent = pct + '%'; + if (stage) { + let cur = STAGES[0]; + for (const st of STAGES) if (pr >= st[0]) cur = st; + if (stage.textContent !== cur[1]) stage.textContent = cur[1]; + if (hint) hint.textContent = cur[2]; + } + if (isWorldReady() || performance.now() - t0 > maxMs) { + const ok = isWorldReady(); + if (fill) fill.style.width = '100%'; + if (txt) txt.textContent = '100%'; + if (stage) stage.textContent = ok ? 'Мир готов!' : 'Погружаем…'; + if (hint) hint.textContent = ok ? 'Приятной игры' : 'Мир не успел — заходим как есть'; + // Короткая пауза, чтобы «Мир готов!» успел прочитать, и плавный уход. + setTimeout(() => { + if (el) el.style.transition = 'opacity .35s ease'; + if (el) el.style.opacity = '0'; + setTimeout(hideWorldLoader, 360); + resolve(isWorldReady()); + }, ok ? 420 : 120); + return; + } + requestAnimationFrame(tick); + }; + tick(); + }); +} + + +// Гарантируем, что игрок стоит на твёрдом блоке. Спавн вычисляется ДО генерации +// мира, поэтому опора под ногами часто не успевает построиться — игрок падал +// до bedrock (Y=132) и висел в пустоте. После готовности мира проверяем и +// ставим на верхний твёрдый блок своей колонки. +// ---------- СПАВН: findStandSpot ---------- +// Критерии пригодного места (все обязательны): +// 1. сверху колонки есть твёрдый блок — на него ставим ноги; +// 2. над ним 2 свободных тайла — игрок и запас на прыжок; +// 3. нет воды в радиусе 3 тайла и выше поверхности; +// 4. в тайле над головой (top-1) нет твёрдого блока — иначе игрок +// окажется в пещере под блоком; +// 5. блок под опорой тоже твёрдый (не «парящая» трава над пещерой). +// Поиск идёт по расширяющейся спирали, а не по одной колонке: раньше +// игрока ставило в воду или в толщу снега, потому что подходил только +// собственный тайл. async function startGame() { const worldId = await showStartMenu(); @@ -116,6 +291,7 @@ async function startGame() { // ==================== 7) CONTROLS — initControls ==================== initControls(); + initJoystick(); // ==================== 9) UI MODULES — init all UI ==================== initChat(); @@ -188,6 +364,8 @@ async function startGame() { state.player.hunger = 100; state.player.o2 = 100; state.player.x = state.spawnPoint.x; + // spawnPoint.y копируется из player.y (см. save.js:130), то есть это + // ВЕРХ коллайдера — вычитать высоту нельзя. state.player.y = state.spawnPoint.y; state.player.vx = state.player.vy = 0; state.player.invuln = 0; @@ -205,31 +383,133 @@ async function startGame() { state.player.vx = state.player.vy = 0; state.player.invuln = 0; - // старт — на поверхности - const startGX = 6, startGZ = 0; + // Старт — сухая суша в центре мира. Перебираем ближайшие колонки и берём + // первую без воды/болота, чтобы не начинать в трясине. + let startGX = 6, startGZ = 0, startGY = 0, found = false; + const PREF_BIOMES = ['plains', 'desert']; // сухая, безопасная земля + outer: + for (let r = 0; r <= 60 && !found; r++) { + for (let dz = -r; dz <= r; dz++) { + for (let dx = -r; dx <= r; dx++) { + if (Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue; // только кольцо + const gx = 6 + dx, gz = dz; + if (Math.abs(gz) > 40) continue; // раньше 18 — при WORLD_DEPTH=48 искал в узкой полосе и не находил суши + const b = getCachedBiome(gx, gz); + if (!PREF_BIOMES.includes(b)) continue; + // Условие «sy > SEA_GY + 3» отбрасывало ВСЕ колонки: в этом мире поверхность + // лежит на gy 8..14, SEA_GY = 14, то есть требование sy > 17 не + // выполнялось НИГДЕ — игрока всегда выносило в fallback, и тот + // ставил его в болото. Теперь проверяем настоящую колонку. + genColumn(gx, gz); + const pSolid = (px, py, pz) => { + const bb = getBlock(px, py, pz); + if (!bb) return false; + const dd = BLOCKS[bb.t]; + return !!(dd && dd.solid && !dd.fluid && !dd.decor && !dd.climbable); + }; + const pFluid = (px, py, pz) => { + const bb = getBlock(px, py, pz); + return !!(bb && BLOCKS[bb.t] && BLOCKS[bb.t].fluid); + }; + let ptop = -1; + for (let gy = Math.min(state.BEDROCK_GY + 2, 220); gy >= 1; gy--) { + if (pSolid(gx, gy, gz)) { ptop = gy; break; } + } + if (ptop < 1) continue; // нет опоры + if (pFluid(gx, ptop - 1, gz)) continue; // лужа под ногами + let near = false; + for (let dx = -3; dx <= 3 && !near; dx++) + for (let dz = -3; dz <= 3 && !near; dz++) + for (let gy = Math.min(state.SEA_GY, 220); gy > ptop; gy--) + if (pFluid(gx + dx, gy, gz + dz)) { near = true; break; } + if (near) continue; // вокруг вода + const sy = ptop; + startGX = gx; startGZ = gz; startGY = sy; found = true; // sy = блок-опора + break outer; + } + } + } + if (!found) { + // Суши по биому не нашлось — ищем любую колонку выше уровня моря. + let bestGY = -1, bestX = 6, bestZ = 0; + for (let r = 0; r <= 40; r++) { + for (let dz = -r; dz <= r; dz++) { + for (let dx = -r; dx <= r; dx++) { + if (Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue; + const gx = 6 + dx, gz = dz; + if (Math.abs(gz) > 40) continue; + // Тот же дефект, что и в основной ветке: SEA_GY + 2 = 16, + // а поверхность в мире 8..14, поэтому условие не выполнялось + // НИГДЕ и fallback тоже ничего не находил. + const sy = surfaceGyAt(gx, gz); + if (sy < state.SEA_GY - 4 && sy > bestGY) { bestGY = sy; bestX = gx; bestZ = gz; } + } + } + if (bestGY > 0) break; + } + if (bestGY > 0) { startGX = bestX; startGZ = bestZ; startGY = bestGY; found = true; } + } + if (!found) { startGX = 6; startGZ = 0; startGY = surfaceGyAt(6, 0) + 2; } + state.player.x = startGX * state.TILE; state.player.z = startGZ * state.TILE; state.player.vx = state.player.vy = state.player.vz = 0; - for (let dx = -4; dx <= 4; dx++) { - for (let dz = -20; dz <= 20; dz++) genColumn(startGX + dx, startGZ + dz); - } - const surfaceY = surfaceGyAt(startGX, startGZ); - let safeGY = surfaceY - 1; - const aboveBlock = getBlock(startGX, surfaceY - 1, startGZ); - if (aboveBlock && (aboveBlock.t === 'water' || aboveBlock.t === 'swamp_water')) { - for (let gy = state.SEA_GY - 1; gy >= 0; gy--) { - const b = getBlock(startGX, gy, startGZ); - if (!b || b.dead || b.t === 'air' || b.t === 'water') continue; - safeGY = gy - 1; - break; + // Предгенерация вокруг спавна. Радиус по X был 4 тайла, по Z 20 — при + // этом по Z уже генерировалось до 24, а по X мир вырождался в полосу + // шириной 9 тайлов (замер: world x от 2 до 10 при игроке на 6). + for (let dx = -48; dx <= 48; dx++) { + for (let dz = -48; dz <= 48; dz++) { + if (dx * dx + dz * dz > 48 * 48) continue; + genColumn(startGX + dx, startGZ + dz); } } - state.player.y = safeGY * state.TILE; + // после генерации ищем сухую опору: идём вверх, пока под ноздрями вода/болото + // Ищем тайл ТВЁРДОГО блока-опоры: самый верхний твёрдый блок колонки, + // над которым не вода. Раньше startGY был на единицу ниже, и игрок + // оказывался ВНУТРИ блока (утопание) или в небе над ним. + { + let topGY = -1; + for (let gy = 1; gy < 200; gy++) { + const bl = getBlock(startGX, gy, startGZ); + if (!bl || bl.dead) break; + const def = BLOCKS[bl.t]; + if (!def || !def.solid || def.fluid || def.decor || def.climbable) break; + topGY = gy; + } + if (topGY > 0) startGY = topGY; + else startGY = Math.max(surfaceGyAt(startGX, startGZ), state.SEA_GY); + } + // ВАЖНО: player.y — ВЕРХ коллайдера, ноги = y + h. + // Стоять на блоке startGY => y = startGY * TILE - h. + // зазор 0.5px, иначе первый шаг вверх гасит импульс прыжка + // Ось Y направлена ВНИЗ. Верхняя грань блока startGY = startGY*TILE, + // ноги = y + h => y = startGY * TILE - h. + // Опора под ногами ОБЯЗАТЕЛЬНА. Если блока нет — ставим его, иначе игрок + // проваливается до bedrock (проверено: Y уходил в 132 при BEDROCK_GY=140). + { + const footBlk = getBlock(startGX, startGY, startGZ); + const footDef = footBlk ? BLOCKS[footBlk.t] : null; + const solid = !!(footDef && footDef.solid && !footDef.fluid && !footDef.decor); + if (!solid) { + setBlock(startGX, startGY, startGZ, 'grass', true); + } else if (startGY > 1) { + // проверяем, что это и правда верхняя точка опоры + const above = getBlock(startGX, startGY - 1, startGZ); + if (above) { + const ad = BLOCKS[above.t]; + if (!ad || !ad.solid || ad.fluid || ad.decor) removeBlock(startGX, startGY - 1, startGZ); + } + } + } + // Ось Y вниз: верхняя грань блока startGY = startGY*TILE, значит + // feet = startGY*TILE и p.y = startGY*TILE - h. + state.player.y = startGY * state.TILE - state.player.h; state.player.fallStartY = state.player.y; + state.player.grounded = true; state.spawnPoint.x = state.player.x; state.spawnPoint.y = state.player.y; state.spawnPoint.z = state.player.z; - console.log('Новая 3D игра: gx=', startGX, 'gz=', startGZ, 'surfaceY=', surfaceY, 'y=', state.player.y); + console.log('Новая 3D игра: gx=', startGX, 'gz=', startGZ, 'biome=', getCachedBiome(startGX, startGZ), 'gy=', startGY, 'y=', state.player.y); } // Автосейв при скрытии страницы @@ -245,17 +525,28 @@ async function startGame() { }); }).catch(err => { console.error('Ошибка инициализации:', err); + // Аварийный старт: только колонка игрока, остальное достроит pumpWorldGen + // из игрового цикла. Раньше здесь генерировалась узкая полоса 9x40, из-за + // чего мир выглядел островом. const startGX = 6, startGZ = 0; genColumn(startGX, startGZ); - state.player.y = (surfaceGyAt(startGX, startGZ) - 1) * state.TILE; + state.player.y = surfaceGyAt(startGX, startGZ) * state.TILE - state.player.h; state.player.fallStartY = state.player.y; - for (let gx = startGX - 4; gx <= startGX + 4; gx++) { - for (let gz = -20; gz <= 20; gz++) genColumn(gx, startGZ + gz); - } }); // ==================== 13) LOOP — startLoop ==================== + // Мир строится порциями в цикле, поэтому сначала запускаем цикл, затем + // ждём готовности с прогрессом на экране. requestAnimationFrame(loop); + // Ставим игрока на поверхность ДО генерации мира, чтобы он не начал + // падать в пустоту, и повторяем после готовности. + ensurePlayerOnGround(); + await waitWorldReady(); + ensurePlayerOnGround(); + // После фикса игрок может стоять в блоке, если гравитация успела осесть. + // Даём физике несколько кадров и закрепляем ещё раз. + await new Promise(r => setTimeout(r, 120)); + ensurePlayerOnGround(); } // Start the game (v3 — 3D) diff --git a/srcv3/multiplayer/socket-helpers.js b/srcv3/multiplayer/socket-helpers.js index 9a98176..973b05f 100644 --- a/srcv3/multiplayer/socket-helpers.js +++ b/srcv3/multiplayer/socket-helpers.js @@ -1,9 +1,12 @@ -// Socket helpers: цвет, отправка позиции/блоков +// ==================== СЕТЬ: отправка позиции и блоков ==================== +// Три правки против 2D-версии: +// 1) шлём Z — без него игрок в 3D стоит в плоскости и не двигается вглубь; +// 2) шлём gz в block_change — иначе все блоки ложатся в gz=0; +// 3) порог отправки по Z, иначе движение вглубь не отправляется вовсе. import { state } from '../core/state.js'; import { SERVER_URL } from '../config.js'; import { MOVE_SEND_INTERVAL } from '../core/constants.js'; - // Генерация случайного цвета для игрока на основе socket_id export function getRandomPlayerColor(socketId) { const colors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e91e63', '#00bcd4']; @@ -17,8 +20,7 @@ export function getRandomPlayerColor(socketId) { // Throttle для отправки позиции (10-20 раз в секунду) let lastMoveSendTime = 0; - -let lastSentX = 0, lastSentY = 0; +let lastSentX = 0, lastSentY = 0, lastSentZ = 0; // Отправка позиции игрока (с throttle) export function sendPlayerPosition() { @@ -27,21 +29,28 @@ export function sendPlayerPosition() { const now = performance.now() / 1000; if (now - lastMoveSendTime < MOVE_SEND_INTERVAL) return; - // Отправляем только если позиция изменилась + // Отправляем только если позиция изменилась ПО ВСЕМ ОСЯМ. + // Раньше z не сравнивался — движение вглубь мира не отправлялось. + const z = state.player.z || 0; const dx = Math.abs(state.player.x - lastSentX); const dy = Math.abs(state.player.y - lastSentY); - if (dx < 1 && dy < 1) return; + const dz = Math.abs(z - lastSentZ); + if (dx < 1 && dy < 1 && dz < 1) return; lastMoveSendTime = now; lastSentX = state.player.x; lastSentY = state.player.y; + lastSentZ = z; - state.socket.emit('player_move', { x: state.player.x, y: state.player.y, player_name: state.playerName }); + state.socket.emit('player_move', { + x: state.player.x, y: state.player.y, z: z, + player_name: state.playerName + }); } -// Отправка изменения блока -export function sendBlockChange(gx, gy, t, op) { +// Отправка изменения блока. gz обязателен: сервер и клиент ключуют блок как "x,y,z". +export function sendBlockChange(gx, gy, gz, 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 + state.socket.emit('block_change', { gx, gy, gz: gz || 0, t, op }); +} diff --git a/srcv3/multiplayer/socket.js b/srcv3/multiplayer/socket.js index 142d617..a039f15 100644 --- a/srcv3/multiplayer/socket.js +++ b/srcv3/multiplayer/socket.js @@ -3,7 +3,8 @@ import { state } from '../core/state.js'; import { SERVER_URL } from '../config.js'; import { TILE, SEA_GY } from '../core/constants.js'; import { BLOCKS } from '../data/blocks.js'; -import { k3 as k, setBlock, removeBlock, grid, blocks, getBlock } from '../world/world-storage3d.js'; +import { k3, setBlock, removeBlock, grid, blocks, getBlock } from '../world/world-storage3d.js'; +import { occClear } from '../world/occ3d.js'; import { genColumn, surfaceGyAt } from '../world/generation3d.js'; import { addChatMessage } from '../ui/chat.js'; import { calculateDamage } from '../entities/player.js'; @@ -13,6 +14,7 @@ import { explodeTNT3D } from '../world/tnt3d.js'; import { updateSaveButtonVisibility } from '../ui/save-controls.js'; import { getRandomPlayerColor, sendPlayerPosition, sendBlockChange } from './socket-helpers.js'; import { initTradeSocket } from '../ui/trade.js'; +import { placePlayerOnStandSpot, ensurePlayerOnGround } from '../spawn3d.js'; // Mob sizes for server mob sync (server doesn't send w/h) const MOB_SIZES = { pig: { w: 34, h: 24 }, @@ -86,14 +88,25 @@ export function initSocket() { state.generated.clear(); grid.clear(); blocks.length = 0; + // Окклюзивная сетка обновляется только из setBlock/removeBlock, а здесь + // мир чистится напрямую. Без сброса сетка осталась бы от старого сида + // и перестроение мешей отсекло бы свежие блоки как скрытые. + occClear(); state.placedBlocks = []; state.removedBlocks = []; console.log('World regenerated with new seed:', state.worldSeed); + // КРИТИЧНО: после перегенерации позиция игрока невалидна — он стоит + // в координатах ПРЕДЫДУЩЕГО мира и оказывается в яме. Ставим заново + // и пересчитываем опору. Иначе камера уходит в грунт, схлопывается + // в первое лицо (camDist = 0), и весь кадр занимает пустое небо. + if (typeof ensurePlayerOnGround === 'function') { + try { ensurePlayerOnGround(); } catch (e) { /* не критично */ } + } } if (data.blocks && Array.isArray(data.blocks)) { for (const block of data.blocks) { - const key = k(block.gx, block.gy, block.gz || 0); + const key = k3(block.gx, block.gy, block.gz || 0); state.serverOverrides.set(key, { op: block.op, t: block.t }); if (block.op === 'set') { setBlock(block.gx, block.gy, block.gz || 0, block.t, false); @@ -108,32 +121,25 @@ export function initSocket() { state.isNightTime = state.worldTime > 0.5; } - // Spawn point - { - const startGX = 6; - for (let dx = -4; dx <= 4; dx++) for (let dz = -24; dz <= 24; dz++) genColumn(startGX + dx, dz); - const surfaceY = surfaceGyAt(startGX, Math.floor((state.player.z || 0) / state.TILE)); - 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, Math.floor((state.player.z || 0) / state.TILE)); - 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); + // Стартовая колонка: X=6 по центру мира, Z игрока. + // Z фиксируем ОДИН РАЗ при входе: если брать текущую позицию каждый + // раз, то второй world_state ищет площадку уже рядом с первой + // найденной точкой и уводит игрока всё дальше в сторону. + if (state.__spawnGZ === undefined) { + state.__spawnGZ = Math.floor((state.player.z || 0) / state.TILE); } - - state.player.x = state.spawnPoint.x; - state.player.y = state.spawnPoint.y; + const startGX = 6; + const startGZ = state.__spawnGZ; + // Спавн: единая реализация из spawn3d.js. Раньше здесь была вторая + // копия поиска площадки, которая считала feet как (top-1)*T и искала + // воздух НИЖЕ top — игрок тонул в грунте либо падал в яму. + placePlayerOnStandSpot(startGX, startGZ, 20); + // Позицию уже выставил placePlayerOnStandSpot (feet = (top+1)*T, + // player.y = feet - h). Здесь только обнуляем скорости. state.player.vx = 0; state.player.vy = 0; + state.player.vz = 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; @@ -172,11 +178,15 @@ export function initSocket() { socket.on('player_joined', (data) => { console.log('Player joined:', data.socket_id); if (data.socket_id !== state.mySocketId) { + // Позиция ДРУГОГО игрока. Формула та же, что и у нас: feet на + // верхней грани опоры. Раньше было (surfaceY - 1) * TILE — это + // тайл ПОД поверхностью, то есть чужий игрок появлялся в грунте. const spawnGX = 6; - genColumn(spawnGX, Math.floor((state.player.z || 0) / state.TILE)); - const surfaceY = surfaceGyAt(spawnGX, Math.floor((state.player.z || 0) / state.TILE)); + const spawnGZ = Math.floor((state.player.z || 0) / state.TILE); + genColumn(spawnGX, spawnGZ); + const surfaceY = surfaceGyAt(spawnGX, spawnGZ); const safeSpawnX = spawnGX * TILE; - const safeSpawnY = (surfaceY - 1) * TILE; + const safeSpawnY = (surfaceY + 1) * TILE; // верхняя грань тайла state.otherPlayers.set(data.socket_id, { x: safeSpawnX, @@ -194,6 +204,9 @@ export function initSocket() { const p = state.otherPlayers.get(data.socket_id); p.x = data.x; p.y = data.y; + // Ось Z обязательна: без неё чужие игроки стоят в одной плоскости + // и не двигаются вглубь мира. Раньше z просто игнорировался. + p.z = data.z || 0; if (data.player_name) { p.name = data.player_name; } @@ -211,6 +224,11 @@ export function initSocket() { socket.on('mob_spawned', (data) => { let 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 }; applyMobSize(sm); + // Ось Z у серверных мобов: без неё в сетевом режиме все мобы + // стоят в одной плоскости и не преследуют игрока по глубине. + if (sm.z === undefined) sm.z = data.z || 0; + if (sm.vz === undefined) sm.vz = data.vz || 0; + if (sm.d === undefined) sm.d = data.d || sm.w || 30; state.serverMobs.set(data.id, sm); }); @@ -255,7 +273,7 @@ export function initSocket() { }); socket.on('block_changed', (data) => { - const key = k(data.gx, data.gy, data.gz || 0); + const key = k3(data.gx, data.gy, data.gz || 0); state.serverOverrides.set(key, { op: data.op, t: data.t }); if (data.op === 'set') { setBlock(data.gx, data.gy, data.gz || 0, data.t, false); diff --git a/srcv3/physics/collision3d.js b/srcv3/physics/collision3d.js index 33e4480..148d9f0 100644 --- a/srcv3/physics/collision3d.js +++ b/srcv3/physics/collision3d.js @@ -8,11 +8,16 @@ import { getBlock, isSolid } from '../world/world-storage3d.js'; const EPS = 0.001; -// Собственная AABB-коробка сущности: x/z — центр, y — низ (как в 2D-игре: e.x, e.y = левый-ниний) +// AABB сущности. ВАЖНО: в игровом коде принято, что e.y — ЭТО ВЕРХ тела, +// а низ тела = e.y + e.h (ось Y направлена вниз, гравитация тянет в +y). +// Раньше здесь стояло y0: e.y, y1: e.y + e.h — то есть НИЗ=e.y. Это +// противоречие ломало всё: игрок оказывался внутри блока, grounded был +// всегда false, а прыжок гасился столкновением на первом шаге. +// Теперь: y0 = e.y + e.h (низ), y1 = e.y (верх) — согласовано с игрой. function boxOf(e) { return { x0: e.x, x1: e.x + e.w, - y0: e.y, y1: e.y + e.h, + y0: e.y + e.h, y1: e.y, z0: e.z, z1: e.z + e.d, }; } @@ -38,9 +43,37 @@ function fluidAt(gx, gy, gz) { } // Пересекает ли коробка сущности твёрдый блок -function hitsSolid(bx) { +// Пол под ногами. ТЕЛО ТОНКОЕ: при h = 34 и TILE = 40, как только y0 +// пересекает границу тайла, floor(y0/TILE) указывает на пустой тайл над +// опорой и коллизия перестаёт видеть пол. Замер: игрок оседал на 0.85 +// тайла внутрь блока. Поэтому дополнительно проверяем тайл строго ПОД +// нижней границей тела — на расстоянии EPS, чтобы поймать опору. +function groundSolidUnder(bx) { + const gy = Math.floor((bx.y0 + 0.5) / TILE); const x0 = Math.floor(bx.x0 / TILE), x1 = Math.floor((bx.x1 - EPS) / TILE); - const y0 = Math.floor(bx.y0 / TILE), y1 = Math.floor((bx.y1 - EPS) / TILE); + const z0 = Math.floor(bx.z0 / TILE), z1 = Math.floor((bx.z1 - EPS) / TILE); + for (let gx = x0; gx <= x1; gx++) { + for (let gz = z0; gz <= z1; gz++) { + if (solidAt(gx, gy, gz)) return true; + } + } + return false; +} + +function hitsSolid(bx) { + if (groundSolidUnder(bx)) return true; + const x0 = Math.floor(bx.x0 / TILE), x1 = Math.floor((bx.x1 - EPS) / TILE); + // По Y берём тайлы СТРОГО пересекающиеся с телом. Низ тела (y0) может + // лежать ровно на границе тайла: когда игрок стоит на опоре, y0 кратно + // TILE и floor(y0/TILE) указывает на САМ блок опоры, а не на тайл под + // ним. Из-за этого игрок «вставал» на 0.85 тайла внутрь блока: замер + // давал feet_gy = 7.85 при опоре на тайле 7 (snow). + // Поэтому нижнюю границу округляем ВНИЗ, а верхнюю — вверх. + // Тайлы по Y: от верхнего до нижнего. Нижняя граница тела bx.y0 округляется + // ВНИЗ (bx.y0 - EPS): когда ноги стоят ровно на границе тайла, floor даёт + // сам блок опоры, а не тайл над ним — из-за этого grounded срабатывал + // слишком рано, а игрок оседал на 0.85 тайла ВНУТРЬ блока. + const y0 = Math.floor((bx.y0 - EPS) / TILE), y1 = Math.floor((bx.y1 + EPS) / TILE); const z0 = Math.floor(bx.z0 / TILE), z1 = Math.floor((bx.z1 - EPS) / TILE); for (let gx = x0; gx <= x1; gx++) { for (let gy = y0; gy <= y1; gy++) { @@ -55,7 +88,13 @@ function hitsSolid(bx) { // Стоит ли сущность на чём-то (для grounded) function checkGrounded(e) { const bx = boxOf(e); - const gy = Math.floor((bx.y1 + 1) / TILE); + // Опора = тайл под ногами = под НИЗОМ тела (bx.y0). Раньше тут стоял + // bx.y1, который с новой boxOf — это ВЕРХ тела, то есть блок над головой. + // Опора — тайл СТРОГО ПОД ногами. Когда низ тела стоит ровно на границе + // (y0 кратно TILE), floor(y0/TILE) указывает на сам блок опоры, а не на + // тайл под ним, и grounded считается в тот момент, когда игрок ещё не + // встал. Поэтому округляем вниз: верхняя граница опоры = bx.y0. + const gy = Math.floor((bx.y0 - EPS) / TILE); const x0 = Math.floor(bx.x0 / TILE), x1 = Math.floor((bx.x1 - EPS) / TILE); const z0 = Math.floor(bx.z0 / TILE), z1 = Math.floor((bx.z1 - EPS) / TILE); for (let gx = x0; gx <= x1; gx++) { @@ -108,17 +147,19 @@ function moveAxis(e, axis, delta) { e.x = savedX; e.y = savedY; e.z = savedZ; } - // Откат к границе блока - // шаг за блок, пока не пересечение + // Откат: двигаемся по 0.5 px, пока не упрёмся в твёрдый блок. + // ТЕЛО ТОНКОЕ (h = 34 < TILE = 40), поэтому нельзя полагаться только на + // floor() текущего положения: как только y0 пересекает границу тайла, + // тайл под ногами становится пустым и коллизия перестаёт видеть пол. + // Замер: игрок оседал на feet_gy = 8.85 при опоре на тайле 7. + // Поэтому шаг делаем ОТ ПОТОЛКА вниз и держим последнюю валидную позицию. const stepPx = dir * 0.5; - let moved = 0; for (let i = 0; i < TILE * 4; i++) { const test = boxOf(e); if (axis === 'x') { test.x0 += stepPx; test.x1 += stepPx; } else if (axis === 'z') { test.z0 += stepPx; test.z1 += stepPx; } else { test.y0 += stepPx; test.y1 += stepPx; } if (hitsSolid(test)) break; - moved += stepPx; if (axis === 'x') e.x += stepPx; else if (axis === 'z') e.z += stepPx; else e.y += stepPx; @@ -133,13 +174,35 @@ function moveAxis(e, axis, delta) { export function resolveX(e, dt) { return moveAxis(e, 'x', e.vx * dt); } export function resolveZ(e, dt) { return moveAxis(e, 'z', (e.vz || 0) * dt); } export function resolveY(e, dt) { - const hit = moveAxis(e, 'y', e.vy * dt); - e.grounded = false; - if (hit && e.vy >= 0) { - e.grounded = checkGrounded(e); - } else if (!hit) { - e.grounded = checkGrounded(e); + const delta = e.vy * dt; + // Движение ВВЕРХ (vy < 0): блокируем только если реально упёрлись головой. + // Раньше moveAxis проверял весь объём коробки, и блок ПОД ногами гасил + // прыжок в тот же кадр (vy сразу становился 0). + let hit = false; + if (delta < 0) { + const probe = boxOf(e); + probe.y0 += delta; // низ тела + probe.y1 += delta; // верх тела (ось Y вниз => уменьшение = вверх) + if (hitsSolid(probe)) { + // откатываем ровно до грани + const step = -0.5; + for (let i = 0; i < TILE * 4; i++) { + const t = boxOf(e); + t.y0 += step; t.y1 += step; + if (hitsSolid(t)) break; + e.y += step; + } + hit = true; + } else { + e.y += delta; + } + e.grounded = false; + return hit; } + + hit = moveAxis(e, 'y', delta); + + e.grounded = checkGrounded(e); return hit; } diff --git a/srcv3/render/render3d.js b/srcv3/render/render3d.js index d5bd613..9c79cc8 100644 --- a/srcv3/render/render3d.js +++ b/srcv3/render/render3d.js @@ -8,8 +8,11 @@ import { skyColor } from '../world/weather.js'; import { mode } from '../game/modes.js'; import { handleTap, stopMining } from '../input/mouse-handler3d.js'; import { getBlock } from '../world/world-storage3d.js'; +import { occGrid, occIndex, OCC_OPAQUE, OCC_MIN, OCC_MAX, OCC_MAX_GY, OCC_STEP_X, OCC_STEP_Z, OCC_STEP_Y } from '../world/occ3d.js'; import { camDir } from '../game/loop3d.js'; import { HERO_COL, HERO_PARTS, HERO_PIVOT, HERO_SCALE } from './hero-rig.js'; +import { MOB_SPRITES } from '../entities/mob-sprites3d.js'; +import { MOB_TEX, TDmobTex } from '../entities/mob-tex3d.js'; let TD = null; @@ -27,6 +30,12 @@ function TDblockMat(t) { return new THREE.MeshLambertMaterial({ color, transparent: a < 1, opacity: a }); } +// ---------- ОККЛЮЗИЯ: живёт в world/occ3d.js ---------- +// isHiddenAt проверяла 6 соседей через getBlock, а тот строил строку "gx,gy,gz" +// и искал в Map. При 68k кандидатах это ~409k строковых конкатенаций — +// профиль показал 129 мс из 153 мс всего перестроения мешей. Теперь окклюзия +// читает плотный Uint8Array (occ3d.js), обновляемый из setBlock/removeBlock. + function TDisFluid(t) { const d = BLOCKS[t]; return !!(d && d.fluid); } function TDisFlat(t) { const d = BLOCKS[t]; @@ -34,36 +43,114 @@ function TDisFlat(t) { return !d.solid || !!d.decor || !!d.climbable; } -// ---------- герой: тот же пиксельный рисунок, кубики ---------- +// ---------- герой: объёмная гречка ---------- +// Пиксельный рисунок 2D-игры (hero-rig.js) выдавливается в глубину: каждая +// часть — настоящий объём одинаковой глубины, а не плоская пластина. Поэтому +// при вращении камеры герой остаётся объёмным, а не превращается в картонку. function TDbuildHeroRig() { const group = new THREE.Group(); const P = {}; + // Toon-растушёвка как в эталонной игре: 4 ступени дают «мультяшный» вид, + // к которому привык игрок, вместо плоского диффузного Lambert. const MAT = {}; - for (const k of Object.keys(HERO_COL)) MAT[k] = new THREE.MeshLambertMaterial({ color: HERO_COL[k].hex }); + const gradTex = (() => { + const steps = 4; + const g = new THREE.DataTexture(new Uint8Array(steps * 4), steps, 1, THREE.RGBAFormat); + for (let i = 0; i < steps; i++) { + const v = Math.round(255 * (0.72 + 0.28 * (i / (steps - 1)))); + g.image.data.set([v, v, v, 255], i * 4); + } + g.minFilter = THREE.LinearMipmapLinearFilter; + g.magFilter = THREE.LinearFilter; + g.generateMipmaps = true; + g.needsUpdate = true; + return g; + })(); + for (const k of Object.keys(HERO_COL)) { + MAT[k] = new THREE.MeshToonMaterial({ color: HERO_COL[k].hex, gradientMap: gradTex }); + } + + // глубина тела в мировых единицах — ширина одного «пикселя» рисунка + // Риг как в эталонной платформенной игре: каждый пиксель рисунка — призма, + // глубина которой берётся ИЗ ЦВЕТА (COL[colorKey].d), а не из общей константы. + // Общая константа давала «растянутую с двух сторон пластину»: у синего слоя + // d=0.6, у тёмно-синих глаз d=1.12 — разная толщина и есть объём. + // Плюс renderOrder = d*1000 — иначе Three.js сортирует слои случайно и + // дальние прямоугольники рисуются поверх ближних. + const dOf = (colorKey) => (HERO_COL[colorKey] && HERO_COL[colorKey].d) || 0.8; + for (const name of Object.keys(HERO_PARTS)) { - const g = new THREE.Group(); + const g = new THREE.Group(); // узел вращения (сустав) const j = HERO_PIVOT[name]; g.position.set(j[0] * HERO_SCALE, j[1] * HERO_SCALE, 0); - const inner = new THREE.Group(); + const inner = new THREE.Group(); // содержимое узла inner.position.set(-j[0] * HERO_SCALE, -j[1] * HERO_SCALE, 0); g.add(inner); group.add(g); P[name] = g; + for (const r of HERO_PARTS[name]) { - const depth = HERO_COL[r[4]].d * HERO_SCALE; - const m = new THREE.Mesh( - new THREE.BoxGeometry(r[2] * HERO_SCALE, r[3] * HERO_SCALE, depth), - MAT[r[4]] - ); - m.position.set((r[0] - 0.24) * HERO_SCALE, r[1] * HERO_SCALE, depth / 2); + const [rx, ry, rw, rh, colorKey] = r; + const d = dOf(colorKey) * HERO_SCALE; + const w = rw * HERO_SCALE; + const h = rh * HERO_SCALE; + const geo = new THREE.BoxGeometry(w, h, d); + // центрируем по X (в рисунке левый край -1.17.., центр около -0.24), + // по Z смещаем к зрителю, чтобы слои не уходили вглубь земли + geo.translate((rx - 0.24) * HERO_SCALE + w / 2, ry * HERO_SCALE + h / 2, d / 2); + const m = new THREE.Mesh(geo, MAT[colorKey] || MAT.blue); + m.renderOrder = Math.round(d * 1000); m.castShadow = true; m.receiveShadow = true; inner.add(m); } } + + // Нос — объёмный, выносим на переднюю грань тела + const nose = new THREE.Mesh( + new THREE.BoxGeometry(0.16, 0.12, 0.18), + new THREE.MeshToonMaterial({ color: 0xf7c873, gradientMap: gradTex }) + ); + nose.position.set(0.05, 2.13 * HERO_SCALE, 0.30 * HERO_SCALE + 0.02); + P.head.add(nose); + return { group, P }; } +// Простое склеивание геометрий (буферы BoxGeometry одинакового формата) +function TDmergeGeometries(geos) { + if (geos.length === 1) return geos[0]; + let vCount = 0, iCount = 0; + for (const g of geos) { + vCount += g.attributes.position.count; + iCount += g.index ? g.index.count : 0; + } + const pos = new Float32Array(vCount * 3); + const nor = new Float32Array(vCount * 3); + const uv = new Float32Array(vCount * 2); + const idx = new Uint32Array(iCount); + let vo = 0, io = 0; + for (const g of geos) { + const p = g.attributes.position, n = g.attributes.normal, u = g.attributes.uv; + pos.set(p.array, vo * 3); + if (n) nor.set(n.array, vo * 3); + if (u) uv.set(u.array, vo * 2); + if (g.index) { + const gi = g.index.array; + for (let k = 0; k < gi.length; k++) idx[io + k] = gi[k] + vo; + io += gi.length; + } + vo += p.count; + } + const out = new THREE.BufferGeometry(); + out.setAttribute('position', new THREE.BufferAttribute(pos, 3)); + out.setAttribute('normal', new THREE.BufferAttribute(nor, 3)); + out.setAttribute('uv', new THREE.BufferAttribute(uv, 2)); + out.setIndex(new THREE.BufferAttribute(idx, 1)); + out.computeBoundingSphere(); + return out; +} + const TD_MOB_COLORS = { zombie: 0x2ecc71, pig: 0xffb6c1, chicken: 0xecf0f1, creeper: 0x4caf50, skeleton: 0xeceff1, scorpion: 0x5d4037, polar_bear: 0xeceff1, @@ -88,7 +175,14 @@ function TDnameSprite(name) { // ==================== INIT ==================== function TDinit() { const canvas = state.canvas; - const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); + // preserveDrawingBuffer ОБЯЗАТЕЛЕН: без него canvas.toDataURL() + // возвращает ЧЁРНЫЙ кадр, и невозможно снять скриншот для проверки вида. + // Стоит копейка производительности, но даёт рабочую диагностику. + const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true }); + if (renderer.toneMapping !== undefined && THREE.ACESFilmicToneMapping !== undefined) { + renderer.toneMapping = THREE.ACESFilmicToneMapping; + renderer.toneMappingExposure = 0.92; // >1 даёт сплошной засвет + } if (renderer.outputEncoding !== undefined && THREE.sRGBEncoding !== undefined) { renderer.outputEncoding = THREE.sRGBEncoding; } @@ -97,18 +191,36 @@ function TDinit() { const scene = new THREE.Scene(); scene.background = new THREE.Color(0x8fc7f5); - scene.fog = new THREE.Fog(0x8fc7f5, 30, 90); - const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 300); + // Туман заканчивается на границе прорисовки (VIEW_R = 14 тайлов = 560). + // Было 30..90 при TILE=40 — то есть видимость всего ~2 тайла, отсюда + // жалоба «прорисовка десять блоков». Теперь туман мягкий и соответствует дальности. + // Дальность: VIEW_R = 26 тайлов (1040 px). При 14 тайлах мир обрывался + // заметно раньше тумана — отсюда «остров, висящий в пространстве». + // Туман начинается за пределами прорисовки (VIEW_R=30 тайлов = 1200 px), + // иначе мир «обрывается» раньше, чем заканчиваются блоки. + // VIEW_R = 30 тайлов = 1200 px. Туман должен начинаться ЗНАЧИТЕЛЬНО дальше + // этой границы, иначе весь видимый мир оказывается в сплошном цвете неба — + // это и был «сплошной засвет». Держим запас: туман с 60% дальности. + // VIEW_R = 46 тайлов = 1840 px. Туман ОТКРЫВАЕТСЯ ДО края прорисовки + // (near 760) и полностью гаснет на 1560: рельеф растворяется в дымке, + // вместо того чтобы обрываться в пустое небо. Раньше near=1000 был позже + // края — отсюда «парящий остров». + scene.fog = new THREE.Fog(0x8fc7f5, 760, 1560); + const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 1700); - const hemi = new THREE.HemisphereLight(0xcfe6ff, 0x6ab04c, 0.85); - const sun = new THREE.DirectionalLight(0xffffff, 1.5); + // Свет: сумма была 2.6 при отсутствии tone mapping — отсюда ультра-яркие + // пастельные тона. Ставим суммарно ~1.15 и включаем тонмаппинг. + // Свет суммарно ~0.95. При 1.3 и без tone mapping земля выгорала в бежевый + // («ультра-яркие тона»), терялся цвет блоков. + const hemi = new THREE.HemisphereLight(0xcfe6ff, 0x5a8f3c, 0.34); + const sun = new THREE.DirectionalLight(0xfff4e0, 0.61); sun.castShadow = true; sun.shadow.mapSize.set(512, 512); const sc = sun.shadow.camera; sc.left = -26; sc.right = 26; sc.top = 26; sc.bottom = -26; sc.near = 1; sc.far = 140; sun.shadow.normalBias = 0.05; - const amb = new THREE.AmbientLight(0xffffff, 0.25); + const amb = new THREE.AmbientLight(0xffffff, 0.06); scene.add(hemi, sun, sun.target, amb); const hero = TDbuildHeroRig(); @@ -131,119 +243,242 @@ function TDinit() { // куб подсветки выбранного блока const outline = new THREE.LineSegments( - new THREE.EdgesGeometry(new THREE.BoxGeometry(1.03, 1.03, 1.03)), - new THREE.LineBasicMaterial({ color: 0xffffff }) + new THREE.EdgesGeometry(new THREE.BoxGeometry(1.02, 1.02, 1.02)), + new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.85 }) ); outline.visible = false; scene.add(outline); + // Прицел: блок, на который смотрит игрок. Обновляется каждый кадр рейкастом. + // Камера стоит за игроком, поэтому целимся от камеры вперёд по направлению взгляда. + const raycaster = new THREE.Raycaster(); + const _rcDir = new THREE.Vector3(); + const aim = { gx: 0, gy: 0, gz: 0, px: 0, py: 0, face: null, dist: 0, valid: false }; + const cloudMeshes = []; - const cloudMat = new THREE.MeshLambertMaterial({ color: 0xffffff, transparent: true, opacity: 0.75 }); + // Облака: MeshBasic, а не Lambert. Lambert без нормального света давал серые + // пятна в небе (визуальный артефакт). Basic всегда чисто-белый. + const cloudMat = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.85, depthWrite: false }); for (let i = 0; i < 16; i++) { const m = new THREE.Mesh(new THREE.BoxGeometry(4, 0.8, 2.2), cloudMat); scene.add(m); cloudMeshes.push(m); } - const projPool = []; - for (let i = 0; i < 24; i++) { - const m = new THREE.Mesh(new THREE.BoxGeometry(0.55, 0.1, 0.1), new THREE.MeshBasicMaterial({ color: 0xf1c40f })); - m.visible = false; - scene.add(m); - projPool.push(m); + // ---------- Прицел: блок перед игроком по направлению взгляда ---------- + const AIM_MAX = 7; // тайлов, как в майнкрафте + const projPool = []; + for (let i = 0; i < 24; i++) { + const m = new THREE.Mesh(new THREE.BoxGeometry(0.55, 0.1, 0.1), new THREE.MeshBasicMaterial({ color: 0xf1c40f })); + m.visible = false; + scene.add(m); + projPool.push(m); + } + + const PMAX = 800; + const pGeo = new THREE.BufferGeometry(); + const pPos = new Float32Array(PMAX * 3); + const pCol = new Float32Array(PMAX * 3); + pGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3)); + pGeo.setAttribute('color', new THREE.BufferAttribute(pCol, 3)); + const points = new THREE.Points(pGeo, new THREE.PointsMaterial({ size: 0.16, vertexColors: true, depthWrite: false })); + points.frustumCulled = false; + scene.add(points); + + function makeFall(n, color, size) { + const g = new THREE.BufferGeometry(); + const pos = new Float32Array(n * 3); + g.setAttribute('position', new THREE.BufferAttribute(pos, 3)); + const pts = new THREE.Points(g, new THREE.PointsMaterial({ color, size, transparent: true, opacity: 0.7, depthWrite: false })); + pts.frustumCulled = false; + pts.visible = false; + scene.add(pts); + return { pts, pos, n }; + } + const rain = makeFall(500, 0x74b9ff, 0.2); + const snow = makeFall(300, 0xffffff, 0.17); + + const lightPool = []; + for (let i = 0; i < 6; i++) { + const L = new THREE.PointLight(0xff9a3c, 0, 16); + scene.add(L); + lightPool.push(L); + } + const flamePool = []; + for (let i = 0; i < 8; i++) { + const f = new THREE.Mesh(new THREE.ConeGeometry(0.3, 0.75, 6), new THREE.MeshBasicMaterial({ color: 0xff7a1a })); + f.visible = false; + scene.add(f); + flamePool.push(f); + } + + const boatMesh = new THREE.Mesh( + new THREE.BoxGeometry(1.4, 0.5, 1.0), + new THREE.MeshLambertMaterial({ color: 0x8b4513 }) + ); + boatMesh.visible = false; + boatMesh.castShadow = true; + scene.add(boatMesh); + + let TDtarget = null; // блок под прицелом — для кнопки копки + + TD = { + get target() { return TDtarget; }, + updateAim(px, py, pz) { + // Направление луча = КУДА СМОТРИТ КАМЕРА, а не «на игрока». + // Раньше здесь стояло (px - camera.position.x, ...), то есть луч всегда + // упирался в самого героя, и прицел показывал блок ПОД НОГАМИ игрока + // (face: {y:1}) — добыть и поставить было невозможно. + // Камера уже развёрнута lookAt вдоль курса, поэтому берём его из неё. + camera.getWorldDirection(_rcDir); + // Дистанция камера->игрок — отдельная величина. getWorldDirection уже + // возвращает ЕДИНИЧНЫЙ вектор, поэтому брать длину из _rcDir нельзя: + // получалось camDist = 1, near = 1.15, и всё, что дальше, отбрасывалось + // как «за игроком» — прицел всегда указывал в опору под ногами. + const camDist = Math.sqrt( + (px - camera.position.x) ** 2 + (py - camera.position.y) ** 2 + (pz - camera.position.z) ** 2 + ); + if (camDist < 1e-6) return aim; + _rcDir.normalize(); + + // Рейкаст ОБЯЗАН быть направленным: без set() он стреляет из (0,0,0) + // в -Z, то есть мимо мира — прицел и добыча не работали вообще. + // Луч: из камеры через игрока. Блоки между камерой и игроком отбрасываем. + raycaster.set(camera.position, _rcDir); + raycaster.far = camDist + AIM_MAX; + // Луч идёт ОТ КАМЕРЫ ЧЕРЕЗ ИГРОКА. Всё, что ближе игрока (distance < + // camDist), — это блоки между камерой и игроком, их надо отбросить. + // Граница должна быть СТРОГО больше дистанции до игрока, иначе блок + // ОПОРЫ под ногами (его верхняя грань лежит ровно на луче) попадает + // в выборку, и прицел всегда указывает в землю под игроком: + // target = { gx: <колонка игрока>, face: {y: 1} }, добыть что-то нельзя. + raycaster.near = camDist + 0.15; + + const meshes = TD.blockMeshes; + TD.aimCalls = (TD.aimCalls || 0) + 1; + let best = null, bestD = camDist + AIM_MAX, _rawHits = 0; + for (const e of meshes) { + const hits = raycaster.intersectObject(e.mesh, false); + for (const h of hits) { + _rawHits++; + if (h.distance < camDist - 0.5) continue; // это блок ПЕРЕД игроком, не наш + if (h.distance < bestD) { bestD = h.distance; best = { h, e }; } + } + } + if (!best || best.h.instanceId === undefined) { outline.visible = false; aim.valid = false; + TD.aimDbg = { hits: _rawHits, meshes: meshes.length, camDist: +camDist.toFixed(2), why: best ? 'no-instance' : 'no-best' }; return aim; } + + // Точные координаты блока лежат в меше (userData.list). Матричная + // конвертация была неверной: по X/Z давала +1 тайл, по Y — минус. + // Поэтому прицел указывал в пустоту с мусором вроде (-15,-15,15). + const _b = best.e.mesh.userData.list[best.h.instanceId]; + if (!_b) { outline.visible = false; aim.valid = false; + TD.aimDbg = { hits: _rawHits, meshes: meshes.length, camDist: +camDist.toFixed(2), why: 'no-list' }; return aim; } + const gx = _b.gx, gy = _b.gy, gz = (_b.gz || 0); + const n = best.h.face ? best.h.face.normal : null; + // нормаль в локальных осях куба = мировые (инстансы только переносятся) + const face = n ? { x: Math.round(n.x), y: Math.round(n.y), z: Math.round(n.z) } : null; + + aim.gx = gx; aim.gy = gy; aim.gz = gz; + aim.px = gx + 0.5; aim.py = gy + 0.5; aim.face = face; + aim.dist = bestD - camDist; aim.valid = true; // дистанция от ИГРОКА, не от камеры + TD.aimDbg = { hits: _rawHits, meshes: meshes.length, camDist: +camDist.toFixed(2) }; + TDtarget = aim; + + // Y в three инвертирован: позиция инстанса (gx+0.5,-gy-0.5,gz+0.5). + // Было (aim.px, aim.py=gy+0.5, ...) — рамка висела в зеркальной точке. + outline.position.set(gx + 0.5, -gy - 0.5, gz + 0.5); + outline.visible = true; + }, + renderer, scene, camera, hemi, sun, amb, hero, + crack, ghost, outline, cloudMeshes, projPool, points, pPos, pCol, PMAX, + rain, snow, lightPool, flamePool, boatMesh, + blockMeshes: [], lastVersion: -1, meshT: 0, + mobs: new Map(), players: new Map(), + yaw: 0.0, pitch: 0.28, dist: 10, // 0.28 — вид сбоку; 0.55 смотрел в небо + ptrs: new Map(), pinchD: 0, + lastTap: null, hovered: null, fireCache: null, + heroStep: 0, + + heroTime: 0, face: 1, frame: 0, lastT: 0, + colCache: new Map(), + }; + // DIAG-зонд: повторяет луч прицела и возвращает сырые цифры. + TD.probeAim = function() { + const out = { meshes: TD.blockMeshes.length, near: +raycaster.near.toFixed(2), far: +raycaster.far.toFixed(2) }; + try { + const all = []; + for (const e of TD.blockMeshes) { + const h = raycaster.intersectObject(e.mesh, false); + if (h.length) all.push({ type: e.type, n: h.length, d0: +h[0].distance.toFixed(2), iid: h[0].instanceId }); + } + out.hits = all.slice(0, 8); out.total = all.reduce((a, x) => a + x.n, 0); + out.rayO = camera.position.toArray().map(v => +v.toFixed(2)); + // контрольный замер без near/far: если хиты есть — режет фильтр дистанций + const _n = raycaster.near, _f = raycaster.far; + raycaster.near = 0; raycaster.far = 100; + let wide = 0, firstD = -1; + for (const e of TD.blockMeshes) { + const h = raycaster.intersectObject(e.mesh, false); + if (h.length && !wide) { wide = h.length; firstD = +h[0].distance.toFixed(2); } + else if (h.length) wide += h.length; + } + raycaster.near = _n; raycaster.far = _f; + out.wide = wide; out.firstD = firstD; + } catch (err) { out.err = String(err).slice(0, 100); } + return out; + }; + TD.aim = aim; TD.outline = outline; TD.pick = TDpick; TD.tap = handleTap; + // TD публикуется ВСЕГДА: кнопка копки на мобильном читает отсюда + // цель (window.__td.target). Раньше объект создавался только по + // ?diag=1, и тач-кнопка «копка» молча не работала в обычной игре. + window.__td = TD; + if (/[?&]diag=1/.test(location.search)) { /* диаг-режим: хук уже есть */ } + TDbindInput(canvas); + TDresize(); + window.addEventListener('resize', TDresize); } - const PMAX = 800; - const pGeo = new THREE.BufferGeometry(); - const pPos = new Float32Array(PMAX * 3); - const pCol = new Float32Array(PMAX * 3); - pGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3)); - pGeo.setAttribute('color', new THREE.BufferAttribute(pCol, 3)); - const points = new THREE.Points(pGeo, new THREE.PointsMaterial({ size: 0.16, vertexColors: true, depthWrite: false })); - points.frustumCulled = false; - scene.add(points); - - function makeFall(n, color, size) { - const g = new THREE.BufferGeometry(); - const pos = new Float32Array(n * 3); - g.setAttribute('position', new THREE.BufferAttribute(pos, 3)); - const pts = new THREE.Points(g, new THREE.PointsMaterial({ color, size, transparent: true, opacity: 0.7, depthWrite: false })); - pts.frustumCulled = false; - pts.visible = false; - scene.add(pts); - return { pts, pos, n }; - } - const rain = makeFall(500, 0x74b9ff, 0.2); - const snow = makeFall(300, 0xffffff, 0.17); - - const lightPool = []; - for (let i = 0; i < 6; i++) { - const L = new THREE.PointLight(0xff9a3c, 0, 16); - scene.add(L); - lightPool.push(L); - } - const flamePool = []; - for (let i = 0; i < 8; i++) { - const f = new THREE.Mesh(new THREE.ConeGeometry(0.3, 0.75, 6), new THREE.MeshBasicMaterial({ color: 0xff7a1a })); - f.visible = false; - scene.add(f); - flamePool.push(f); + function TDresize() { + if (!TD) return; + const w = window.innerWidth, h = window.innerHeight; + state.W = w; + state.H = h; + TD.renderer.setPixelRatio(1); + TD.renderer.setSize(w, h, false); + TD.camera.aspect = w / h; + TD.camera.updateProjectionMatrix(); } - const boatMesh = new THREE.Mesh( - new THREE.BoxGeometry(1.4, 0.5, 1.0), - new THREE.MeshLambertMaterial({ color: 0x8b4513 }) - ); - boatMesh.visible = false; - boatMesh.castShadow = true; - scene.add(boatMesh); + // ==================== БЛОКИ ==================== + // Полный пересбор мешей редкий; в обычном кадре — только обновление матриц. + // Радиус прорисовки. Должен ЗНАЧИТЕЛЬНО превышать дистанцию камеры + // (макс 34 тайла) — иначе за краем сетки видно пустое небо, и мир выглядит + // парящим островом. 46 тайлов при тумане с 1000 до 1500: край рельефа + // успевает уйти в туман, а не обрываться в пустоту. + const VIEW_R = 46; // тайлов по всем осям - TD = { - renderer, scene, camera, hemi, sun, amb, hero, - crack, ghost, outline, cloudMeshes, projPool, points, pPos, pCol, PMAX, - rain, snow, lightPool, flamePool, boatMesh, - blockMeshes: [], lastVersion: -1, meshT: 0, - mobs: new Map(), players: new Map(), - yaw: 0.0, pitch: 0.42, dist: 10, - ptrs: new Map(), pinchD: 0, - lastTap: null, hovered: null, fireCache: null, - heroStep: 0, face: 1, frame: 0, lastT: 0, - colCache: new Map(), - }; - TDbindInput(canvas); - TDresize(); - window.addEventListener('resize', TDresize); -} + // Границы плотной сетки: за ними occIndex отдаёт -1, и «сосед» через край + // массива не должен заворачиваться на противоположную сторону мира. + function isHiddenAt(gx, gy, gz) { + // Окклюзия читает плотную сетку occ3d.js, а не getBlock: 6 чтений массива + // против 6 строковых ключей "gx,gy,gz". Семантика та же — все 6 соседей + // непрозрачные. Раскладка индекса: (gy*OCC_Z + (gz-OCC_MIN))*OCC_X + (gx-OCC_MIN), + // то есть шаг по gx = 1, по gz = OCC_X, по gy = OCC_X*OCC_Z. + if (gx <= OCC_MIN || gx >= OCC_MAX || gz <= OCC_MIN || gz >= OCC_MAX || + gy <= 0 || gy >= OCC_MAX_GY) return false; + const i = occIndex(gx, gy, gz); + return occGrid[i - OCC_STEP_X] === OCC_OPAQUE && occGrid[i + OCC_STEP_X] === OCC_OPAQUE && + occGrid[i - OCC_STEP_Z] === OCC_OPAQUE && occGrid[i + OCC_STEP_Z] === OCC_OPAQUE && + occGrid[i - OCC_STEP_Y] === OCC_OPAQUE && occGrid[i + OCC_STEP_Y] === OCC_OPAQUE; + } -function TDresize() { - if (!TD) return; - const w = window.innerWidth, h = window.innerHeight; - state.W = w; - state.H = h; - TD.renderer.setPixelRatio(1); - TD.renderer.setSize(w, h, false); - TD.camera.aspect = w / h; - TD.camera.updateProjectionMatrix(); -} - -// ==================== БЛОКИ ==================== -// Полный пересбор мешей редкий; в обычном кадре — только обновление матриц. -const VIEW_R = 14; // радиус прорисовки в тайлах по всем осям - -function isHiddenAt(gx, gy, gz) { - // спрятан ли блок: все 6 соседей непрозрачные? - return isOpaqueAt(gx + 1, gy, gz) && isOpaqueAt(gx - 1, gy, gz) && - isOpaqueAt(gx, gy + 1, gz) && isOpaqueAt(gx, gy - 1, gz) && - isOpaqueAt(gx, gy, gz + 1) && isOpaqueAt(gx, gy, gz - 1); -} - -function isOpaqueAt(gx, gy, gz) { - const b = getBlock(gx, gy, gz); - if (!b) return false; - const def = BLOCKS[b.t]; - if (!def) return false; - return !!def.solid && !def.fluid && !def.decor && !def.climbable && def.alpha === undefined; -} + function isOpaqueAt(gx, gy, gz) { + // Одинокий lookup по сетке окклюзии (а не getBlock со строковым ключом). + // Используется точечно там, где нужен именно факт непрозрачности клетки. + const i = occIndex(gx, gy, gz); + return i >= 0 && occGrid[i] === OCC_OPAQUE; + } function TDrebuildBlocks(px, py, pz) { for (const e of TD.blockMeshes) { @@ -259,6 +494,13 @@ function TDrebuildBlocks(px, py, pz) { const dx = b.gx - px, dy = b.gy - py, dz = (b.gz || 0) - pz; // py — игровой gy if (dx * dx + dy * dy + dz * dz > VIEW_R * VIEW_R) continue; if (!TDisFluid(b.t) && !TDisFlat(b.t) && isHiddenAt(b.gx, b.gy, b.gz || 0)) continue; + // Воду рисуем ТОЛЬКО верхним слоем. Раньше в колонке было до 30 кубов воды + // подряд, и каждый был полупрозрачным — вместе они давали «мир в + // полупрозрачных квадратах» и 29k лишних инстансов. + if (TDisFluid(b.t)) { + const up = getBlock(b.gx, b.gy - 1, b.gz || 0); + if (up && TDisFluid(up.t)) continue; // не верхний слой — пропускаем + } if (!byType.has(b.t)) byType.set(b.t, []); byType.get(b.t).push(b); } @@ -282,8 +524,8 @@ function TDrebuildBlocks(px, py, pz) { q.setFromEuler(e); m.compose(new THREE.Vector3(b.gx + 0.5, -b.gy + 0.05, (b.gz || 0) + 0.5), q, new THREE.Vector3(1, 1, 1)); } else if (fluid) { - m.makeScale(1, 0.82, 1); - m.setPosition(b.gx + 0.5, -b.gy - 0.09, (b.gz || 0) + 0.5); + m.makeScale(1, 0.9, 1); + m.setPosition(b.gx + 0.5, -b.gy - 0.5, (b.gz || 0) + 0.5); } else { m.identity(); m.setPosition(b.gx + 0.5, -b.gy - 0.5, (b.gz || 0) + 0.5); @@ -306,17 +548,36 @@ function TDpick(sx, sy) { TDndc.x = ((sx - r.left) / r.width) * 2 - 1; TDndc.y = -((sy - r.top) / r.height) * 2 + 1; TDray.setFromCamera(TDndc, TD.camera); + // Отбрасываем блоки МЕЖДУ камерой и игроком — ровно как делает updateAim. + // Без этого pick возвращал ПЕРВОЕ пересечение луча (землю под ногами + // героя или блок за его спиной), а прицел показывал блок ПОСЛЕ игрока. + // Клик в итоге попадал на 1-9 тайлов в сторону от того, что видно. + const _pl = state.player; + const _px = (_pl.x + _pl.w / 2) / state.TILE; + const _py = -(_pl.y + _pl.h / 2) / state.TILE; + const _pz = ((_pl.z || 0) + (_pl.d || _pl.w) / 2) / state.TILE; + const _camDist = Math.hypot(_px - TD.camera.position.x, _py - TD.camera.position.y, _pz - TD.camera.position.z); const hits = TDray.intersectObjects(TD.blockMeshes.map(x => x.mesh), false); - if (!hits.length) return null; - const h = hits[0]; + let h = null; + for (const cand of hits) { + if (cand.distance < _camDist + 0.15) continue; // камера, герой, опора + h = cand; + break; + } + if (!h) return null; const b = h.object.userData.list[h.instanceId]; if (!b) return null; - // мировые игровые координаты точки удара - const gx = Math.floor(h.point.x); - const gy = -Math.floor(h.point.y); - const gz = Math.floor(h.point.z); - const wx = (h.point.x) * state.TILE; - const wy = (-h.point.y) * state.TILE; + // ТОЧНЫЕ координаты блока берём из userData.list, а НЕ из h.point. +// При попадании в грань куба h.point лежит РОВНО на границе тайла +// (например z = -3.0), и floor() давал СОСЕДНИЙ блок: прицел показывал +// один блок, а клик ломал другой (в 1 тайле в сторону). Из-за этого печь +// «не открывалась»: клик уходил в соседний тайл, где furnace нет. +const gx = b.gx; +const gy = b.gy; +const gz = b.gz != null ? b.gz : (b.gz || 0); +// point нужен для точной точки попадания (установка по грани) +const wx = (h.point.x) * state.TILE; +const wy = (-h.point.y) * state.TILE; return { gx, gy, gz, wx, wy, face: h.face ? h.face.normal : null }; } @@ -346,20 +607,32 @@ function TDbindInput(canvas) { } if (q.moved) { TD.yaw -= dx * 0.005; - TD.pitch = Math.min(1.45, Math.max(0.06, TD.pitch + dy * 0.005)); + TD.pitch = Math.min(0.95, Math.max(0.06, TD.pitch + dy * 0.005)); // >0.9 — камера в небе, горизонта нет } }); const up = (e) => { + TD.tapDbg = TD.tapDbg || { ups: 0, noq: 0, moved: 0, slow: 0, picks: 0, taps: 0 }; + TD.tapDbg.ups++; const q = TD.ptrs.get(e.pointerId); TD.ptrs.delete(e.pointerId); TD.pinchD = 0; - if (q && !q.moved && performance.now() - q.t < 600) { - const hit = TDpick(e.clientX, e.clientY); - if (hit) { - TD.lastTap = hit; - handleTap(hit.wx, hit.wy, hit.gz, hit.face); - } + if (!q) { TD.tapDbg.noq++; return; } + if (q.moved) { TD.tapDbg.moved++; return; } + // Ограничение в 600 мс БЫЛО БАГОМ: игрок нажимает и держит (например, + // целится), а потом отпускает — и действие отменялось совсем, добыча и + // установка становились невозможны. Долгое удержание нужно не отменять, + // а считать осознанным: если указатель не двигался, действуем по отпусканию. + // Отменяем только если указатель двигался (это поворот камеры) — + // проверка q.moved уже стоит выше. + TD.tapDbg.picks++; + const hit = TDpick(e.clientX, e.clientY); + if (hit) { + TD.tapDbg.taps++; + TD.lastTap = hit; + // Передаём ТАЙЛОВЫЕ координаты блока: handleTap больше не делит + // wx/wy на TILE, иначе цель смещалась на 1 тайл. + handleTap(hit.gx, hit.gy, hit.gz, hit.face); } }; canvas.addEventListener('pointerup', up); @@ -377,20 +650,52 @@ function TDmobMesh(m) { let e = TD.mobs.get(m); if (!e) { const g = new THREE.Group(); - const body = new THREE.Mesh( - new THREE.BoxGeometry(1, 1, 1), - new THREE.MeshLambertMaterial({ color: TD_MOB_COLORS[m.kind] || 0x2ecc71 }) - ); - body.castShadow = true; - g.add(body); - const eyeM = new THREE.MeshBasicMaterial({ color: 0x111111 }); - const e1 = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.14, 0.05), eyeM); - e1.position.set(-0.16, 0.22, 0.26); - const e2 = e1.clone(); - e2.position.x = 0.16; - g.add(e1, e2); + // Точные копии мобов 2D-игры (MOB_SPRITES: те же rects и цвета, что в game.js), + // выдавлены в глубину. Координаты нормализованы (0..1) — реальный размер + // задаёт e.body.scale ниже, как раньше делал бокс 1x1x1. + const bodyG = new THREE.Group(); + g.add(bodyG); + const spec = MOB_SPRITES[m.kind]; + if (MOB_TEX[m.kind]) { + // NN-текстуры (одобрены игроком): фас +z, спина -z, профиль ±x, + // верх/низ — плоский средний цвет. alphaTest: силуэт по альфе спрайта. + const mat = (view, mirror) => new THREE.MeshLambertMaterial({ + map: TDmobTex(m.kind, view, mirror), alphaTest: 0.5, + }); + const top = new THREE.MeshLambertMaterial({ color: MOB_TEX[m.kind].top }); + const bm = new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + [mat('side'), mat('side', true), top, top, mat('front'), mat('back')] + ); + bm.castShadow = true; + bodyG.add(bm); + } else if (spec) { + const zBody = 0.6; + spec.rects.forEach(([rx, ry, rw, rh, c], i) => { + const deep = i === 0 ? zBody : zBody + 0.15; + const geo = new THREE.BoxGeometry(rw / spec.w, rh / spec.h, deep); + geo.translate((rx + rw / 2) / spec.w - 0.5, (ry + rh / 2) / spec.h - 0.5, deep / 2); + const mesh = new THREE.Mesh(geo, new THREE.MeshLambertMaterial({ color: c })); + mesh.renderOrder = 100 + i; + mesh.castShadow = true; + bodyG.add(mesh); + }); + } else { + const bm = new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshLambertMaterial({ color: TD_MOB_COLORS[m.kind] || 0x2ecc71 }) + ); + bm.castShadow = true; + bodyG.add(bm); + const eyeM = new THREE.MeshBasicMaterial({ color: 0x111111 }); + const e1 = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.14, 0.05), eyeM); + e1.position.set(-0.16, 0.22, 0.26); + const e2 = e1.clone(); + e2.position.x = 0.16; + bodyG.add(e1, e2); + } TD.scene.add(g); - e = { g, body }; + e = { g, body: bodyG }; TD.mobs.set(m, e); } return e; @@ -449,23 +754,207 @@ export function renderFrame3D(now) { // камера-орбита; yaw — тот же угол, что и для WASD camDir.yaw = TD.yaw; const cp = Math.cos(TD.pitch), sp2 = Math.sin(TD.pitch); - TD.camera.position.set( - ptx + TD.dist * Math.sin(TD.yaw) * cp, - py3 + TD.dist * sp2, - ptz + TD.dist * Math.cos(TD.yaw) * cp + // Камера на уровне центра игрока. Поднимать базовую высоту нельзя: + // при 2.4/2.6 тайла мир ПРОПАДАЛ совсем (проверено) — вероятно, блоки + // отсекаются по вертикали относительно игрока, а не относительно камеры. + // Камера от игрока (вид сбоку). Требования: + // 1) не садиться ВНУТРЬ блока — иначе экран забит текстурой; + // 2) зум должен ОТЪЕЗЖАТЬ, а не упираться: игрок в яме, земля сзади + // уже на 1.6 тайла, поэтому просто укорачивать бесполезно — + // при блокировке поднимаем камеру выше и смотрим сверху; + // 3) минимальная дистанция 3.0 — ближе герой занимает треть экрана. + { + // точка занята твёрдым блоком с учётом зарадиуса R (камера «толстая») + // ---------- КАМЕРА: единый якорь и честная проверка ---------- + // Игровая Y направлена ВНИЗ (гравитация тянет в +y), three Y — вверх. + // Всё ниже работает в three-координатах: eyeY = -(gameY) + запас. + const CAM_R = 0.22; // запас от грани куба + const EYE = 0.62; // высота глаз над пятками (в тайлах) + const solidTile = (gx, gy, gz) => { + const b = getBlock(gx, gy, gz); + if (!b) return false; + const d = BLOCKS[b.t]; + return !!(d && d.solid && !d.fluid && !d.decor && !d.climbable); + }; + // ЖЁСТКАЯ ГАРАНТИЯ: только свой тайл. Если он твёрдый — камера внутри. + // Именно эта проверка обязана быть непротиворечивой, всё остальное — + // только для плавности. + const insideSolid = (x, y, z) => + solidTile(Math.floor(x), Math.floor(-y), Math.floor(z)); + // Мягкая проверка: свой тайл + близость к грани соседнего куба. + const solidNear = (x, y, z) => { + const gx = Math.floor(x), gy = Math.floor(-y), gz = Math.floor(z); + if (solidTile(gx, gy, gz)) return true; + if (solidTile(gx + 1, gy, gz) && x - (gx + 1) < 0.5 + CAM_R) return true; + if (solidTile(gx - 1, gy, gz) && (gx + 1) - x < 0.5 + CAM_R) return true; + if (solidTile(gx, gy + 1, gz) && (-y) - (gy + 1) < 0.5 + CAM_R) return true; + if (solidTile(gx, gy - 1, gz) && (gy + 1) - (-y) < 0.5 + CAM_R) return true; + if (solidTile(gx, gy, gz + 1) && z - (gz + 1) < 0.5 + CAM_R) return true; + if (solidTile(gx, gy, gz - 1) && (gz + 1) - z < 0.5 + CAM_R) return true; + return false; + }; + // Якорь — точка на уровне глаз героя. Физика не даёт игроку быть в блоке, + // поэтому якорь гарантированно свободен и является последним откатом. + const anchorX = ptx, anchorY = py3 + EYE, anchorZ = ptz; + // Высота камеры над якорем. Раньше жёстко 1.2 + d*0.16: в яме глубиной + // 4 тайла этого НЕ хватало, solveDist возвращал 0, включался первый план + // (camDist=0) и кадр показывал пустое небо — игрок не видел вообще ничего. + // Наклон растёт с дистанцией, и на больших d камера должна явно подниматься. + const heightFor = (d) => 1.2 + d * 0.22; + // Смещение камеры относительно якоря при дистанции d и наклоне pitch. + const offsetAt = (d, pitch) => { + const maxSin = Math.min(0.55, heightFor(d) / Math.max(d, 0.001)); + const sn = Math.min(Math.sin(pitch), maxSin); + const c = Math.sqrt(Math.max(0.05, 1 - sn * sn)); + return { dx: Math.sin(TD.yaw) * c * d, dy: sn * d, dz: Math.cos(TD.yaw) * c * d }; + }; + // Ищем максимальную безопасную дистанцию: идём от желаемой к якорю и + // берём последнюю свободную точку. Никакого «угадывания» на глаз. + const solveDist = (d, pitch) => { + const o = offsetAt(d, pitch); + if (!solidNear(anchorX + o.dx, anchorY + o.dy, anchorZ + o.dz)) return d; + const steps = Math.ceil(d / 0.2); + for (let i = steps - 1; i >= 1; i--) { + const t = d * (i / steps); + const p2 = offsetAt(t, pitch); + const x = anchorX + p2.dx, y = anchorY + p2.dy, z = anchorZ + p2.dz; + if (!solidNear(x, y, z)) { + // Жёсткая проверка по СВОЕМУ тайлу обязана выполняться. + if (!insideSolid(x, y, z)) return t; + } + } + return 0; + }; + const PITCH_MAX_AUTO = 0.62; // выше — вид сверху, горизонта нет + let pitch = Math.min(TD.pitch, PITCH_MAX_AUTO); + let dist = solveDist(TD.dist, pitch); + if (dist <= 0.2) { + // За спиной глухо на этой высоте — пробуем поднять камеру. + for (let cand = pitch + 0.12; cand <= PITCH_MAX_AUTO + 0.001; cand += 0.12) { + const d2 = solveDist(TD.dist, cand); + if (d2 > 0.2) { pitch = cand; dist = d2; break; } + } + } + let firstPerson = false; + if (dist <= 0.2) { + // Свободного места за спиной нет вообще: вид от первого лица. + firstPerson = true; + // Ищем свободный тайл для глаза: перебираем вверх от якоря. + // Ищем свободное место для глаза рядом с головой, а НЕ где угодно + // выше. Диапазон раньше был 0..10 тайлов: если игрок в углублении, + // камера улетала на 8 тайлов вверх, где уже нет блоков, и прицел + // смотрел в пустоту (target: null, pick: gy = -1). Игрок при этом + // стоял на gy = 7, то есть в 8 тайлах ниже камеры. + let ex = anchorX, ey = anchorY, ez = anchorZ, found = false; + const hd = Math.sin(TD.yaw) * 0.18, hz = Math.cos(TD.yaw) * 0.18; + for (let up = 0; up <= 4.0 && !found; up += 0.35) { + const cx = anchorX + hd, cy = anchorY + up, cz = anchorZ + hz; + if (!solidNear(cx, cy, cz)) { ex = cx; ey = cy; ez = cz; found = true; } + } + if (!found) { ex = anchorX; ey = anchorY; ez = anchorZ; } + TD.camera.position.set(ex, ey, ez); + TD.camDist = 0; TD.camPitch = +pitch.toFixed(2); TD.camHeight = 0; + // Смотрим вдоль курса с небольшим наклоном вниз, как в обычном + // third-person: горизонтальный lookAt на 8 тайлов уводил взгляд + // в небо, когда камера оказывалась высоко над игроком. + // Направление взгляда = МИНУС курса yaw. Камера в первом лице стоит + // на якоре плюс смещение hd/hz = (sin(yaw), cos(yaw)) * 0.18, то есть + // СЗАДИ игрока по курсу. Смотреть в (sin(yaw), cos(yaw)) — значит + // смотреть НАЗАД, в упор в тот блок, ради которого включился + // первый план: наблюдалось взгляд [0,0,1] при camDist = 0. + TD.camera.lookAt( + ex - Math.sin(TD.yaw) * 8, + ey - Math.sin(pitch) * 8, + ez - Math.cos(TD.yaw) * 8 + ); + TD.camFirstPerson = true; + TD.updateAim(ptx, py3, ptz); + if (TD.hero && TD.hero.group) { + // Риг ставим на землю иначе после return основной путь не дойдёт. + const PWH0 = p.h || 34; + TD.hero.group.scale.setScalar((PWH0 / TILE) / (3.2 * HERO_SCALE)); + TD.hero.group.position.set(ptx, -Math.round(((p.y || 0) + PWH0) / TILE), ptz); + if (p.facing !== undefined) TD.hero.group.rotation.y = -p.facing; + TD.hero.group.visible = false; + } + return; + } + TD.camFirstPerson = false; + if (TD.hero && TD.hero.group) TD.hero.group.visible = true; + const ofs = offsetAt(dist, pitch); + TD.camHeight = +ofs.dy.toFixed(2); + TD.camPitch = +pitch.toFixed(2); + TD.camDist = +dist.toFixed(2); + TD.camera.position.set(anchorX + ofs.dx, anchorY + ofs.dy, anchorZ + ofs.dz); +// Позиция уже задана выше. Ничего не перезаписываем: старый код ставил + // камеру второй раз, по другой формуле, и возвращал её в грунт. + } +// Взгляд идёт ОТ камеры ЧЕРЕЗ игрока и дальше — так прицел попадает в + // точку перед героем, а не ему в спину. Раньше lookAt(ptx, lookY, ptz) + // разворачивал камеру НА игрока, и updateAim всегда целился в него самого: + // target был { gx: <колонка игрока>, face: { y: 1 } }, то есть опора под + // ногами. Добыть или поставить что-либо было невозможно. + // Направление взгляда: камера стоит ЗА игроком, поэтому «вперёд» = от камеры + // мимо игрока, то есть player - camera. Если развернуть камеру НА игрока + // (lookAt(ptx,...)), луч уходит назад и попадает в опору под ногами: + // наблюдалось target gz: -4 при игроке gz: 0 и face {y: 1}. + const _fwd = new THREE.Vector3(ptx - TD.camera.position.x, py3 + 0.5 - TD.camera.position.y, ptz - TD.camera.position.z); + if (_fwd.lengthSq() < 1e-6) _fwd.set(Math.sin(TD.yaw), 0, Math.cos(TD.yaw)); + _fwd.normalize(); + TD.camera.lookAt( + TD.camera.position.x + _fwd.x, + TD.camera.position.y + _fwd.y, + TD.camera.position.z + _fwd.z ); - TD.camera.lookAt(ptx, py3, ptz); - // герой: позиция в ногах коллайдера, поворот по направлению взгляда - TD.hero.group.position.set(ptx, -p.y / TILE, ptz); + // Прицел: луч из камеры вдоль взгляда. Обновляем после позиционирования. + TD.updateAim(ptx, py3, ptz); + + // Герой стоит НОГАМИ на поверхности: p.y в пикселях, низ коллайдера = p.y + p.h. + // Раньше ставился p.y — верх коллайдера, из-за чего персонаж тонул в блоках. + // Ноги = p.y + p.h. Физика держит игрока ГЛУБОКО в блоке-опоре (feet уходит + // на h пикселей внутрь), поэтому ставим ноги ровно на верхнюю грань + // блока под ними: -floor(feet/TILE). Иначе персонаж тонет в текстуре. + // Физика и рисунок ДОЛЖНЫ совпадать. Коллайдер игрока h=34 при TILE=40 — + // это 0.85 тайла, а риг из рисунка при HERO_SCALE=0.5 даёт 1.6 тайла. + // Из-за этого персонаж выглядел втрое выше, чем есть, и «висел» над блоком. + // Масштабируем риг под коллайдер и ставим ноги ровно на верхнюю грань опоры. + const PWH = p.h || 34; + const rigH = 3.2 * HERO_SCALE; // высота рига в тайлах + const heroScale = (PWH / TILE) / rigH; // подгонка под коллайдер + TD.hero.group.scale.setScalar(heroScale); + // feet = p.y + h в пикселях; в тайлах это и есть тайл опоры, на верхней + // грани которого стоят ноги. Y направлена вниз, поэтому знак минус. + const feetGY = Math.round(((p.y || 0) + PWH) / TILE); + const heroFeetY = -feetGY; + TD.hero.group.position.set(ptx, heroFeetY, ptz); if (p.facing !== undefined) { const wantYaw = -p.facing; TD.hero.group.rotation.y += (wantYaw - TD.hero.group.rotation.y) * Math.min(1, 9 * dt); } + // Анимация — как в эталонной игре: ноги качаются, хвост всегда живой, + // корпус сжимается в прыжке и чуть дышит в покое, голова наклоняется + // по скорости. Без этого риг выглядит как неподвижная картинка. + TD.heroTime += dt; const moving = Math.hypot(p.vx || 0, p.vz || 0) > 20 && p.grounded; if (moving) TD.heroStep += dt * 11; - TD.hero.P.legs.rotation.z = moving ? Math.sin(TD.heroStep) * 0.34 : 0; - TD.hero.P.legs.rotation.x = moving ? Math.cos(TD.heroStep) * 0.22 : 0; + const HP = TD.hero.P; + HP.legs.rotation.z = moving ? Math.sin(TD.heroStep) * 0.34 : 0; + HP.legs.rotation.x = moving ? Math.cos(TD.heroStep) * 0.22 : 0; + // хвост: покачивание всегда, сильнее при беге + const tailSwing = Math.sin(TD.heroTime * 6 + (moving ? TD.heroStep : 0)) * (moving ? 0.3 : 0.14); + HP.tail.rotation.z += (tailSwing - HP.tail.rotation.z) * Math.min(1, 8 * dt); + // голова чуть наклоняется по горизонтальной скорости + const lean = (p.vx || 0) * 0.0006; + HP.head.rotation.z += (-lean - HP.head.rotation.z) * Math.min(1, 6 * dt); + // корпус: в прыжке вытянут вверх, на земле «дышит» + if (!p.grounded) { + const stretch = (p.vy || 0) < 0 ? 1.08 : 0.94; // вверх — вытянут, вниз — сжат + HP.body.scale.y += (stretch - HP.body.scale.y) * Math.min(1, 8 * dt); + } else { + const breathe = 1 + 0.015 * Math.sin(TD.heroTime * 2.2); + HP.body.scale.y += (breathe - HP.body.scale.y) * Math.min(1, 6 * dt); + } // мобы const allMobs = state.isMultiplayer ? Array.from(state.serverMobs.values()) : state.mobs; @@ -540,7 +1029,9 @@ export function renderFrame3D(now) { if (!c) { cm.visible = false; continue; } cm.visible = true; cm.scale.set((c.w || 120) / TILE, 1, 1); - cm.position.set(c.x / TILE, -c.y / TILE, ptz - 12); + // Выше и дальше игрока: раньше облака висели в 12 тайлах по Z прямо перед + // камерой и перекрывали обзор серой пеленой. + cm.position.set(c.x / TILE, -c.y / TILE, ptz - 30); } // огни diff --git a/srcv3/spawn3d.js b/srcv3/spawn3d.js new file mode 100644 index 0000000..746e875 --- /dev/null +++ b/srcv3/spawn3d.js @@ -0,0 +1,156 @@ +// ==================== СПАВН: единая реализация для всех путей ==================== +// Раньше логика спавна была продублирована в main3d.js (ensurePlayerOnGround) +// и в socket.js (обработчик world_state). Две копии расходились: одна искала +// площадку с 2 тайлами воздуха, другая — с 3, и одна считала feet как +// (top+1)*T, другая как (top-1)*T. Итог: игрок то тонул в грунте, то падал +// в яму, камера уходила в стену, схлопывалась в первое лицо (camDist = 0), +// и весь кадр занимало пустое небо. +// +// Теперь здесь ЕДИНАЯ реализация, которую зовут и main3d, и socket.js. +// +// ---------- МАТЕМАТИКА (ось Y ВНИЗ: чем больше y, тем НИЖЕ) ---------- +// Тайл gy занимает [gy*T, (gy+1)*T]. Чем больше gy — тем НИЖЕ на экране. +// +// Опора top — самый верхний блок колонки (трава/песок/камень). +// Верхняя грань опоры = (top+1)*T. Игрок стоит НА ней: +// feet = (top+1)*T +// p.y = feet - h (p.y — ВЕРХ коллайдера) +// +// Тело занимает [p.y, feet] = [(top+1)*T - h, (top+1)*T]. +// При h=34 и T=40 это последние 34 px тайла top плюс 0 px выше, то есть +// тело физически В тайле top. Чтобы тело оказалось ВЫШЕ опоры, feet должен +// быть (top+1)*T и h — 34, тогда тело = [top*T+6, (top+1)*T]: тайл top +// занят опорой, и гравитация опустит игрока на верхнюю грань top+1*T. +// +// ПРАКТИКА: после установки игрок должен оказаться в тайле top+1 (воздух). +// Проверяем это: getBlock(gx, top+1, gz) === null. + +import { state } from './core/state.js'; +import { getBlock } from './world/world-storage3d.js'; +import { genColumn } from './world/generation3d.js'; +import { BLOCKS } from './data/blocks.js'; + +const isWater = (gx, gy, gz) => { + const b = getBlock(gx, gy, gz); + if (!b) return false; + const d = BLOCKS[b.t]; + return !!(d && d.fluid); +}; + +// Пусто = нет блока, либо блок не твёрдый, не жидкость, не лестница. +// Трава/мoss/snow имеют solid:true — они и есть поверхность, на ней стоят. +const isEmpty = (gx, gy, gz) => { + const b = getBlock(gx, gy, gz); + if (!b) return true; + const d = BLOCKS[b.t]; + return !d || (!d.solid && !d.fluid && !d.climbable); +}; + +// Клетка годится, если: +// - top — верхний твёрдый тайл (не вода), +// - top+1 и top+2 пусты (тело игрока), +// - top+3 пуст (камера). +// Проверять top-1 бессмысленно: это ВСЕГДА грунт под поверхностью. +// Клетка годится, если top — верхний твёрдый тайл колонки (не вода), +// и тайлы top-1, top-2, top-3 пусты: ноги, голова и место для камеры. +// Обход СВЕРХУ ВНИЗ: берём самый верхний твёрдый тайл, у которого есть +// воздух. Если брать ниже, попадём внутрь толщи грунта. +// Трава/мoss/snow — декорация ПОВЕРХ грунта: она не твёрдая опора, а +// getBlock отдаёт её как обычный блок. Если брать самый верхний блок, +// получаем top = трава, а под ней (top-1) — dirt, и проверка «воздух +// выше» отвергает ВСЯ колонку: сухой площадки не найдено рядом. +// +// Правильно: опора = самый верхний блок, под которым начинается ВОЗДУХ. +// То есть идём сверху вниз, пропускаем decor-слой, и берём первый твёрдый +// слой, у которого выше (меньшие gy) 3 тайла пустых. +// Поверхность для стоянки = ВЕРХНЯЯ граница мира в колонке. +// Опора top = самый верхний блок. Но под ним (top-1) ВСЕГДА грунт — +// трава лежит на dirt, песок на stone. Проверять top-1 как «воздух» бессмысленно: +// это отвергало каждую колонку («сухой площадки не найдено рядом»). +// +// Что реально нужно: над верхним блоком колонки (top-1, top-2, top-3 — +// то есть МЕНЬШИЕ gy) должно быть ПУСТО. Для колонки dirt, grass: +// gy=10 grass, gy=9 dirt, gy=8 dirt, gy=7 … пусто +// верхний блок = 10 (grass). Выше = 9 (dirt) — ЗАНЯТО. Значит опора не 10. +// Настоящая свободная кромка = gy=7, опора под ней = 6. +// +// Значит ищем: тайл top, где top-1, top-2 пусты (ноги+голова), и top+1 +// ТВЁРДЫЙ (это опора, на ней стоим). Камера требует ещё top-2 пуст. +// Опора top = САМЫЙ ВЕРХНИЙ блок колонки (не вода). +// Над ним (top+1, top+2, top+3 — БОЛЬШИЕ gy, то есть НИЖЕ по оси Y… нет, +// больше gy = ниже) — стоп. Здесь Y ВНИЗ, поэтому «выше опоры» = top+1? +// Нет: верхняя грань тайла top = (top+1)*T, значит пространство ВЫШЕ опоры +// лежит в тайле top-1 (меньший y = выше). Проверяем top-1, top-2, top-3. +const tryColumn = (gx, gz) => { + let top = -1; + for (let gy = 60; gy >= 1; gy--) { + const b = getBlock(gx, gy, gz); + if (b && !isWater(gx, gy, gz)) { top = gy; break; } + } + if (top < 4) return -1; + // свободное место ВЫШЕ опоры: тайлы с МЕНЬШИМ gy + if (!isEmpty(gx, top - 1, gz)) return -1; + if (!isEmpty(gx, top - 2, gz)) return -1; + if (!isEmpty(gx, top - 3, gz)) return -1; + return top; +}; + +// Расширяющаяся спираль: своя колонка, затем кольца до 40 тайлов. +export function findStandSpot(startGX, startGZ) { + for (let r = 0; r <= 40; r++) { + for (let dz = -r; dz <= r; dz++) { + for (let dx = -r; dx <= r; dx++) { + if (r > 0 && Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue; + const gx = startGX + dx, gz = startGZ + dz; + const top = tryColumn(gx, gz); + if (top >= 0) return { gx, gz, top }; + } + } + } + return null; +} + +// Применяет найденную площадку к игроку. genRadius — сколько колонок +// сгенерировать вокруг, чтобы поиск имел смысл (в неготовом мире колонки +// пусты и tryColumn ничего не найдёт). +export function placePlayerOnStandSpot(startGX, startGZ, genRadius) { + const T = state.TILE; + const p = state.player; + if (typeof genRadius === 'number') { + for (let dx = -genRadius; dx <= genRadius; dx++) { + for (let dz = -genRadius; dz <= genRadius; dz++) { + // каждый 4-й по диагонали — экономия, остальные колонки рядом + // всё равно генерируются по мере надобности + if (((dx + dz) & 3) !== 0) continue; + try { genColumn(startGX + dx, startGZ + dz); } catch (e) { /* нет генератора */ } + } + } + } + const spot = findStandSpot(startGX, startGZ); + if (!spot) { + console.warn('[Spawn] сухой площадки не найдено рядом', startGX, startGZ); + return null; + } + p.x = spot.gx * T; + p.z = spot.gz * T; + // feet = top*T (верхняя грань опоры). player.y — ВЕРХ коллайдера, + // то есть на h пикселей ВЫШЕ feet по оси Y. + p.y = (spot.top + 1) * T - p.h; // верхняя грань опоры минус высота + p.vx = 0; p.vy = 0; p.vz = 0; + p.grounded = true; + p.fallStartY = p.y; + state.spawnPoint.x = p.x; + state.spawnPoint.y = p.y; + state.spawnPoint.z = p.z; + console.log('[Spawn] игрок на опоре', spot.gx, spot.top, spot.gz, 'p.y=', p.y); + return spot; +} + +// Точка входа: читает текущую позицию игрока и ставит на площадку рядом. +export function ensurePlayerOnGround() { + const T = state.TILE; + const p = state.player; + const curGX = Math.floor((p.x + p.w / 2) / T); + const curGZ = Math.floor((p.z + p.d / 2) / T); + return placePlayerOnStandSpot(curGX, curGZ, 12); +} diff --git a/srcv3/world/generation3d.js b/srcv3/world/generation3d.js index a7effd1..baac085 100644 --- a/srcv3/world/generation3d.js +++ b/srcv3/world/generation3d.js @@ -6,7 +6,10 @@ import { state } from '../core/state.js'; import { getBlock, setBlock, k3 } from './world-storage3d.js'; // Радиус мира по Z — насколько далеко вбок генерируем вокруг игрока -export const WORLD_DEPTH = 20; // полу-ширина: z от -20 до +20 +// Полу-ширина мира. 20 тайлов = мир 40x40 при глубине 140 — отсюда «мелкий +// мир»: игрок упирался в край за 20 шагов. 48 даёт комфортные 96x96 тайлов. +// Мир генерируется целиком один раз (pumpWorldGen) порциями по 200 колонок. +export const WORLD_DEPTH = 48; const CHUNK = 8; // шаг генерации // ========== ШУМ (детерминированный, от worldSeed) ========== @@ -119,7 +122,7 @@ function growTree(gx, gy, gz, biome) { const h = 4 + Math.floor(hash2(gx * 5, gz * 11) * 3); const leafType = biome === 'tundra' || biome === 'swamp' ? 'spruce_leaves' : 'leaves'; for (let i = 1; i <= h; i++) { - if (!getBlock(gx, gy - i, gz)) setBlock(gx, gy - i, gz, 'wood'); + if (!getBlock(gx, gy - i, gz)) setBlock(gx, gy - i, gz, 'wood', false, false); } // крона объёмом: псевдосфера const cy = gy - h; @@ -130,7 +133,7 @@ function growTree(gx, gy, gz, biome) { const dist = dx * dx + dy * dy * 2 + dz * dz; if (dist > rad * rad + 1) continue; const yy = cy + dy; - if (!getBlock(gx + dx, yy, gz + dz)) setBlock(gx + dx, yy, gz + dz, leafType); + if (!getBlock(gx + dx, yy, gz + dz)) setBlock(gx + dx, yy, gz + dz, leafType, false, false); } } } @@ -139,10 +142,10 @@ function growTree(gx, gy, gz, biome) { function growDecor(gx, gy, gz, biome) { const def = BIOMES[biome]; const r = hash2(gx * 23 + 5, gz * 29 + 7); - if (def.flowers && r > 0.96 && !getBlock(gx, gy, gz)) setBlock(gx, gy, gz, 'flower'); - if ((biome === 'swamp' || biome === 'plains') && r > 0.985 && !getBlock(gx, gy, gz)) setBlock(gx, gy, gz, 'mushroom'); - if (biome === 'desert' && r > 0.978 && !getBlock(gx, gy, gz) && gy + 1 < surfaceGyAt(gx, gz)) setBlock(gx, gy, gz, 'cactus'); - if (biome === 'tundra' && r > 0.978 && !getBlock(gx, gy, gz) && gy + 2 < surfaceGyAt(gx, gz)) setBlock(gx, gy, gz, 'ice'); + if (def.flowers && r > 0.96 && !getBlock(gx, gy, gz)) setBlock(gx, gy, gz, 'flower', false, false); + if ((biome === 'swamp' || biome === 'plains') && r > 0.985 && !getBlock(gx, gy, gz)) setBlock(gx, gy, gz, 'mushroom', false, false); + if (biome === 'desert' && r > 0.978 && !getBlock(gx, gy, gz) && gy + 1 < surfaceGyAt(gx, gz)) setBlock(gx, gy, gz, 'cactus', false, false); + if (biome === 'tundra' && r > 0.978 && !getBlock(gx, gy, gz) && gy + 2 < surfaceGyAt(gx, gz)) setBlock(gx, gy, gz, 'ice', false, false); } const generated = new Set(); // "cx,cz" — уже сгенерированные чанки @@ -170,14 +173,14 @@ export function genColumn(gx, gz) { for (let gy = Math.min(BEDROCK_GY - 1, 44); gy >= 0; gy--) { // бедрок внизу if (gy >= BEDROCK_GY - 2) { - if (!getBlock(gx, gy, gz)) setBlock(gx, gy, gz, 'bedrock'); + if (!getBlock(gx, gy, gz)) setBlock(gx, gy, gz, 'bedrock', false, false); continue; } if (gy > top) { // над поверхностью: вода до уровня моря if (gy <= sea) { const t = (biome === 'swamp') ? 'swamp_water' : 'water'; - if (!getBlock(gx, gy, gz)) setBlock(gx, gy, gz, t); + if (!getBlock(gx, gy, gz)) setBlock(gx, gy, gz, t, false, false); } continue; } @@ -185,12 +188,12 @@ export function genColumn(gx, gz) { if (gy < top - 2 && inCave(gx, gy, gz)) continue; const ore = oreAt(gx, gy, gz); - if (ore) { setBlock(gx, gy, gz, ore); continue; } + if (ore) { setBlock(gx, gy, gz, ore, false, false); continue; } const def = BIOMES[biome]; - if (gy === top) setBlock(gx, gy, gz, def.surface); - else if (gy > top - (biome === 'desert' || biome === 'mountains' ? 5 : 3)) setBlock(gx, gy, gz, def.subsurface); - else setBlock(gx, gy, gz, 'stone'); + if (gy === top) setBlock(gx, gy, gz, def.surface, false, false); + else if (gy > top - (biome === 'desert' || biome === 'mountains' ? 5 : 3)) setBlock(gx, gy, gz, def.subsurface, false, false); + else setBlock(gx, gy, gz, 'stone', false, false); } // вода в пещерах не заливаем — это гравитация @@ -202,37 +205,73 @@ export function genColumn(gx, gz) { function BLOCKS_NO_SOLID(t) { return t === 'water' || t === 'swamp_water' || t === 'air'; } -// =========─ ГЕНЕРАЦИЯ ВОКРУГ ИГРОКА =========─ -// Держим чанки сгенерированными вокруг позиции игрока по X и Z. +// ========== ОДИН ФИКСИРОВАННЫЙ МИР ========== +// Раньше мир «стримился» вокруг игрока, а дальние чанки ВЫЧИЩАЛИСЬ. Из-за +// этого горизонт пустел, а мелкая дальняя генерация давала обрезанную землю — +// отсюда «остров, висящий в пространстве». Теперь мир генерируется ЦЕЛИКОМ +// один раз и больше никогда не вычищается. Чтобы вкладка не зависла на +// сотнях тысяч блоков, генерация идёт порциями: pumpWorldGen() зовётся из +// игрового цикла, а isWorldReady() показывает готовность для загрузочного экрана. const genChunks = new Set(); -export function ensureGenAroundCamera() { - const px = Math.floor(state.player.x / TILE); - const pz = Math.floor(state.player.z / TILE); - const rx = 3, rz = Math.ceil(WORLD_DEPTH / CHUNK) + 1; - const cx = Math.floor(px / CHUNK), cz = Math.floor(pz / CHUNK); - for (let dx = -rx; dx <= rx; dx++) { - for (let dz = -rz; dz <= rz; dz++) { - const kx = cx + dx, kz = cz + dz; - const key = kx + ',' + kz; - if (genChunks.has(key)) continue; - genChunks.add(key); - genChunk(kx, kz); - } - } - // чистим слишком далёкие - for (const key of genChunks) { - const [a, b] = key.split(',').map(Number); - if (Math.abs(a - cx) > rx + 2 || Math.abs(b - cz) > rz + 2) { - genChunks.delete(key); - generated.delete(key); +let genQueue = null; // [gx,gz,gx,gz,...] — остаток работы +let genCursor = 0; +let worldReady = false; // мир сгенерирован целиком + +// Всего колонок (2*WORLD_DEPTH+1)^2. При 48 это 97*97 = 9409 колонок по ~45 +// блоков ≈ 420k блоков. За один кадр это зависание вкладки, отсюда очередь. +const WORLD_BATCH = 200; // колонок за один вызов (~9k блоков) + +function buildQueue() { + const side = 2 * WORLD_DEPTH + 1; + const q = new Int32Array(side * side * 2); + let i = 0; + // Сначала колонки рядом с центром — игрок появляется уже с готовой землёй. + for (let gz = -WORLD_DEPTH; gz <= WORLD_DEPTH; gz++) { + for (let gx = -WORLD_DEPTH; gx <= WORLD_DEPTH; gx++) { + q[i++] = gx; q[i++] = gz; } } + return q; } +// Прогресс 0..1 — для экрана загрузки. +export function worldGenProgress() { + if (worldReady) return 1; + if (!genQueue) genQueue = buildQueue(); + return genCursor / genQueue.length; +} + +// Возвращает true, когда мир готов целиком. +export function pumpWorldGen() { + if (worldReady) return true; + if (!genQueue) genQueue = buildQueue(); + const total = genQueue.length; + const n = Math.min(WORLD_BATCH, (total - genCursor) >> 1); + for (let i = 0; i < n; i++) { + genColumn(genQueue[genCursor], genQueue[genCursor + 1]); + genCursor += 2; + } + if (genCursor >= total) { + genQueue = null; + worldReady = true; + state.worldGenReady = true; + } + return worldReady; +} + +export function isWorldReady() { return worldReady; } + +// Совместимость с вызовами из loop3d: мир не стримится, а достраивается. +export function ensureGenAroundCamera() { return pumpWorldGen(); } + export function resetGenerationCache() { generated.clear(); genChunks.clear(); biomeCache.clear(); + genQueue = null; + genCursor = 0; + worldReady = false; + state.worldGenReady = false; } export { CHUNK, k3, getBlock, setBlock }; diff --git a/srcv3/world/occ3d.js b/srcv3/world/occ3d.js new file mode 100644 index 0000000..9c75a3b --- /dev/null +++ b/srcv3/world/occ3d.js @@ -0,0 +1,68 @@ +// ==================== ОККЛЮЗИЯ 3D — плотная сетка ==================== +// Границы мира фиксированы генератором: WORLD_DEPTH = 48 по X и Z (то есть +// gx,gz из -48..48), а по Y генерация идёт от BEDROCK_GY-1 = 44 вниз до 0. +// 97 * 45 * 97 = 423 855 клеток — 414 КБ в Uint8Array. +// +// Зачем: isHiddenAt проверяла 6 соседей через getBlock, а тот строил строку +// "gx,gy,gz" и искал в Map. На 68k кандидатов это ~409k строковых +// конкатенаций — профиль показал 129 мс из 153 мс всего перестроения мешей. +// Здесь 6 чтений массива вместо 6 строковых lookup'ов. +// +// ВАЖНО: сетку обязаны обновлять все, кто меняет мир — setBlock/removeBlock +// в world-storage3d.js. Иначе окклюзия разойдётся с реальными блоками и +// внутренние грани начнут просвечивать (или исчезнут видимые блоки). +import { BLOCKS } from '../data/blocks.js'; + +export const OCC_X = 2 * 48 + 1; // 97 +export const OCC_Z = 2 * 48 + 1; +export const OCC_Y = 45; // gy 0..44 +export const OCC_MIN = -48, OCC_MAX = 48, OCC_MAX_GY = 44; + +// Значения ячеек +export const OCC_EMPTY = 0; // блока нет +export const OCC_PRESENT = 1; // блок есть, но он прозрачный/жидкий/декор +export const OCC_OPAQUE = 2; // непрозрачный блок — закрывает соседей + +const occGrid = new Uint8Array(OCC_X * OCC_Y * OCC_Z); + +// Раскладка: (gy*OCC_Z + (gz-OCC_MIN))*OCC_X + (gx-OCC_MIN) +// => шаг по gx = 1, по gz = OCC_X, по gy = OCC_X*OCC_Z +export const OCC_STEP_X = 1; +export const OCC_STEP_Z = OCC_X; +export const OCC_STEP_Y = OCC_X * OCC_Z; + +export function occIndex(gx, gy, gz) { + if (gx < OCC_MIN || gx > OCC_MAX || gz < OCC_MIN || gz > OCC_MAX || + gy < 0 || gy > OCC_MAX_GY) return -1; + return (gy * OCC_Z + (gz - OCC_MIN)) * OCC_X + (gx - OCC_MIN); +} + +// Плотность блока для оклюзии +export function occDensity(t) { + const def = BLOCKS[t]; + if (!def) return OCC_EMPTY; + const opaque = !!def.solid && !def.fluid && !def.decor && !def.climbable && + def.alpha === undefined; + return opaque ? OCC_OPAQUE : OCC_PRESENT; +} + +export function occSet(gx, gy, gz, density) { + const i = occIndex(gx, gy, gz); + if (i >= 0) occGrid[i] = density; +} + +// Пересобрать сетку целиком из массива блоков. Нужна при загрузке сейва и +// после полной генерации — там setBlock вызывается с bumpWorld=false, но +// запись в сетку всё равно идёт, так что это страховка на случай, когда +// массив блоков был подменён снаружи. +export function occRebuildFrom(blocksArr) { + occGrid.fill(OCC_EMPTY); + for (let i = 0; i < blocksArr.length; i++) { + const b = blocksArr[i]; + if (b.dead) continue; + const idx = occIndex(b.gx, b.gy, b.gz || 0); + if (idx >= 0) occGrid[idx] = occDensity(b.t); + } +} + +export function occClear() { occGrid.fill(OCC_EMPTY); } diff --git a/srcv3/world/tnt3d.js b/srcv3/world/tnt3d.js index 8e4f886..f4ed9f0 100644 --- a/srcv3/world/tnt3d.js +++ b/srcv3/world/tnt3d.js @@ -8,7 +8,7 @@ import { playSound } from '../audio/sound-engine.js'; export function activateTNT3D(b, fuseSeconds) { b.active = true; b.fuse = fuseSeconds; - playSound('fuse'); + playSound('click'); } export function explodeTNT3D(b, st) { @@ -76,7 +76,7 @@ export function explodeTNT3D(b, st) { dmg(p); if (p.hp <= 0 && p.invuln <= 0) { p.hp = 0; p.invuln = 1.5; } - playSound('explode'); + playSound('explode1'); // взрывные частицы убрать по таймеру st.explosionFlash = 0.4; diff --git a/srcv3/world/world-storage3d.js b/srcv3/world/world-storage3d.js index 619f000..a433c92 100644 --- a/srcv3/world/world-storage3d.js +++ b/srcv3/world/world-storage3d.js @@ -3,6 +3,7 @@ // Мир по всем трём осям: X — вперёд, Y — вверх, Z — вбок (глубина мира). import { BLOCKS } from '../data/blocks.js'; import { state } from '../core/state.js'; +import { occSet, occDensity, occRebuildFrom, occClear, OCC_EMPTY } from './occ3d.js'; const grid = state.grid; // Map: "x,y,z" => { gx, gy, gz, t, dead } const blocks = state.blocks; // Array для перебора/рендера @@ -32,19 +33,32 @@ function isOpaque(gx, gy, gz) { return !!def.solid && !def.fluid && !def.decor && !def.climbable; } -function setBlock(gx, gy, gz, t, isPlayerPlaced = false) { +// Третий аргумент isPlayerPlaced. Четвёртый — bumpWorld: при генерации мира +// версию рендера дёргать нельзя (тысячи блоков за кадр = полная перестройка +// мешей и дёрганье текстур). Меняем мир только при действиях игрока. +function setBlock(gx, gy, gz, t, isPlayerPlaced = false, bumpWorld = true) { const key = k3(gx, gy, gz); if (grid.has(key)) return false; const b = { gx, gy, gz, t, dead: false, active: false, fuse: 0 }; grid.set(key, b); blocks.push(b); + // Окклюзивная сетка обязана знать про новый блок: без этого внутренние + // грани соседей останутся видны (или пропадут видимые блоки). + occSet(gx, gy, gz, occDensity(t)); if (isPlayerPlaced) { state.placedBlocks.push({ gx, gy, gz, t }); } - state.blocksVersion = (state.blocksVersion || 0) + 1; + if (bumpWorld) { + state.blocksVersion = (state.blocksVersion || 0) + 1; + } return true; } +// Версия рендера: бамп делает только игрок (постановка/разрушение блока) +function touchWorld() { + state.blocksVersion = (state.blocksVersion || 0) + 1; +} + function removeBlock(gx, gy, gz) { const key = k3(gx, gy, gz); const b = grid.get(key); @@ -52,6 +66,9 @@ function removeBlock(gx, gy, gz) { if (BLOCKS[b.t].unbreakable) return null; grid.delete(key); b.dead = true; + // Клетка освободилась — сбрасываем окклюзию, иначе пустота продолжит + // считаться непрозрачной и пропадёт блок вокруг дырки. + occSet(gx, gy, gz, OCC_EMPTY); const wasPlayerPlaced = state.placedBlocks.some(pb => pb.gx === gx && pb.gy === gy && pb.gz === gz); if (wasPlayerPlaced) { state.placedBlocks = state.placedBlocks.filter(pb => !(pb.gx === gx && pb.gy === gy && pb.gz === gz)); @@ -62,4 +79,4 @@ function removeBlock(gx, gy, gz) { return b; } -export { grid, blocks, k3, getBlock, hasBlock, isSolid, isOpaque, setBlock, removeBlock }; +export { grid, blocks, k3, getBlock, hasBlock, isSolid, isOpaque, setBlock, removeBlock, touchWorld, occRebuildFrom, occClear }; diff --git a/style.css b/style.css index 7d9a764..5e47062 100644 --- a/style.css +++ b/style.css @@ -1,28 +1,26 @@ -/* Minegrechka Game Styles - v1.2 */ -html, body { margin:0; padding:0; width:100%; height:100%; overflow:hidden; background:#111; font-family: system-ui, sans-serif; user-select:none; -webkit-user-select:none; touch-action:none; } +/* GrechkaCraft Styles */ +html, body { margin:0; padding:0; width:100%; height:100%; overflow:hidden; overflow:clip; background:#111; overscroll-behavior: none; -webkit-overflow-scrolling: auto; font-family: system-ui, sans-serif; user-select:none; -webkit-user-select:none; touch-action:none; } #game { position:absolute; top:0; left:0; right:0; bottom:0; background:#87CEEB; overflow:hidden; } canvas { display:block; width:100%; height:100%; image-rendering:pixelated; } -#controls { position:absolute; left:0; right:0; bottom:0; height:140px; background:#222; border-top:4px solid #444; z-index:100; display:none; } -/* Стили для кнопок управления (.btn вместо .cbtn для совместимости с index.html) */ -.btn { position:absolute; top:50%; transform:translateY(-50%); width:74px; height:74px; border-radius:14px; - background:#333; border:3px solid #555; color:#fff; font-size:32px; display:flex; align-items:center; justify-content:center; - box-shadow:0 7px 0 #111; pointer-events:auto; z-index:101; } -.btn:active { transform:translateY(-46%); box-shadow:0 3px 0 #111; background:#444; } -#left { left:18px; } -#right { left:102px; } -#down { left:186px; background:#2980b9; border-color:#3498db; } -#jump { right:18px; background:#d35400; border-color:#e67e22; } - -/* Скрываем панель на десктопе (широкие экраны) */ -@media (min-width: 769px) { - #controls { display: none !important; } +/* Controls — всегда скрыты, показываются только на тач-устройствах */ +#controls { position:absolute; left:0; right:0; bottom:0; height:auto; background:transparent; border:none; z-index:100; display:none; pointer-events:none; } +@media (hover: none) and (pointer: coarse) { + #controls { display: block; } +} +/* Также для landscape телефонов где ширина может быть большой */ +@media (max-width: 1024px) and (pointer: coarse) { + #controls { display: block; } } -/* Показываем панель на мобильных устройствах (узкие экраны) */ -@media (max-width: 768px) { - #controls { display: block !important; } -} +.btn { position:absolute; bottom:10px; width:62px; height:62px; border-radius:14px; + background:#333; border:3px solid #555; color:#fff; font-size:28px; display:flex; align-items:center; justify-content:center; + box-shadow:0 5px 0 #111; pointer-events:auto; z-index:101; } +.btn:active { transform:translateY(2px); box-shadow:0 2px 0 #111; background:#444; } +#left { left:10px; } +#right { left:82px; } +#down { left:154px; background:#2980b9; border-color:#3498db; } +#jump { right:10px; background:#d35400; border-color:#e67e22; } .ui { position:absolute; inset:0; pointer-events:none; z-index:20; } .pe { pointer-events:auto; } @@ -31,31 +29,59 @@ canvas { display:block; width:100%; height:100%; image-rendering:pixelated; } background: rgba(0,0,0,0.55); padding:8px; border-radius:10px; text-shadow:1px 1px 0 #000; } #stats .row{ display:flex; gap:10px; align-items:center; } -.rbtn { position:absolute; right:10px; width:52px; height:52px; border-radius:12px; +.rbtn { position:absolute; right:10px; width:48px; height:48px; border-radius:12px; display:flex; align-items:center; justify-content:center; border:2px solid rgba(255,255,255,0.9); - font-size:24px; cursor:pointer; pointer-events:auto; box-shadow:0 4px 0 rgba(0,0,0,0.5); } -.rbtn:active { transform: translateY(4px); box-shadow:none; } + font-size:22px; cursor:pointer; pointer-events:auto; box-shadow:0 3px 0 rgba(0,0,0,0.5); } +.rbtn:active { transform: translateY(3px); box-shadow:none; } #modeBtn { top:10px; background:#f39c12; } -#mapToggle { top:10px; right:70px !important; background:#1abc9c; } -#craftBtn { top:74px; right:10px !important; background:#9b59b6; } -#invToggle { top:74px; right:70px !important; background:#3498db; } -#menuBtn { top:74px; right:130px !important; background:#555; } +#mapToggle { top:10px; right:66px !important; background:#1abc9c; } +#craftBtn { top:66px; right:10px !important; background:#9b59b6; } +#invToggle { top:66px; right:66px !important; background:#3498db; } +#menuBtn { top:66px; right:122px !important; background:#555; } #chatToggle { display: none !important; } #chatPanel { display: none !important; } -#hotbar { position:absolute; left:50%; transform:translateX(-50%); bottom:10px; display:flex; gap:6px; - background: rgba(0,0,0,0.60); padding:6px; border-radius:12px; pointer-events:auto; - overflow-x: auto; overflow-y: hidden; max-width: 80%; } +/* === HOTBAR — позиционирование от ориентации === */ +#hotbar { position:absolute; left:50%; transform:translateX(-50%); bottom:12px; display:flex; gap:4px; + background: rgba(0,0,0,0.70); padding:6px 8px; border-radius:12px; pointer-events:auto; z-index:90; } -/* Поднимаем hotbar на тач-устройствах, чтобы не перекрывать кнопки */ -body.touch-device #hotbar { - bottom: 150px; +/* Тач-устройства в портрете: hotbar над controls */ +@media (hover: none) and (pointer: coarse) and (orientation: portrait) { + #hotbar { bottom: 82px; } + #selectedItem { display: none !important; } } -.slot { width:38px; height:38px; border:2px solid rgba(255,255,255,0.22); border-radius:10px; - position:relative; overflow:hidden; cursor:pointer; background: rgba(255,255,255,0.07); - display:flex; align-items:center; justify-content:center; font-size:18px; } -.slot.sel { border-color:#f1c40f; box-shadow: 0 0 0 2px rgba(241,196,15,0.18) inset; } -.count { position:absolute; right:3px; bottom:1px; font-size:10px; color:#fff; font-weight:900; text-shadow:1px 1px 0 #000; } +/* Тач-устройства в ландшафте: controls скрыты или узкие, hotbar внизу */ +@media (hover: none) and (pointer: coarse) and (orientation: landscape) { + #hotbar { bottom: 10px; } + #selectedItem { display: none !important; } +} +/* Маленькие экраны в ландшафте (телефоны) */ +@media (max-height: 500px) { + #hotbar { bottom: 6px; gap: 2px; padding: 4px 6px; } + #hotbar .slot { width: 36px; height: 36px; font-size: 16px; } + #selectedItem { display: none !important; } + + .btn { width: 50px; height: 50px; font-size: 22px; } + #left { left: 6px; } + #right { left: 64px; } + #down { left: 122px; } + #jump { right: 6px; } + .rbtn { width: 40px; height: 40px; font-size: 18px; } + #modeBtn { top: 6px; } + #mapToggle { top: 6px; right: 54px !important; } + #craftBtn { top: 52px; right: 6px !important; } + #invToggle { top: 52px; right: 54px !important; } + #menuBtn { top: 52px; right: 102px !important; } + #stats { font-size: 11px; padding: 4px 6px; } + #stats .row { gap: 4px; } +} + +.slot { width:44px; height:44px; border:2px solid rgba(255,255,255,0.25); border-radius:8px; + position:relative; overflow:hidden; cursor:pointer; background: rgba(0,0,0,0.45); + display:flex; align-items:center; justify-content:center; font-size:20px; + transition: border-color 0.15s, box-shadow 0.15s; } +.slot.sel { border-color:#f1c40f; box-shadow: 0 0 8px 2px rgba(241,196,15,0.55), inset 0 0 6px rgba(241,196,15,0.25); background: rgba(241,196,15,0.08); } +.count { position:absolute; right:2px; bottom:1px; font-size:10px; color:#fff; font-weight:900; text-shadow:1px 1px 0 #000; } /* Craft modal */ #craftPanel { display:none; position:absolute; left:14px; right:14px; top:14px; bottom:14px; @@ -114,3 +140,12 @@ body.touch-device #hotbar { /* Market panel */ #marketPanel { display:none; position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); width:360px; max-width:90%; background:rgba(10,10,12,0.95); border:2px solid rgba(255,255,255,0.85); border-radius:14px; pointer-events:auto; padding:12px; z-index:200; } + +/* Settings panel */ +#settingsPanel { position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); width:320px; max-width:90%; background:rgba(10,10,12,0.95); border:2px solid rgba(255,255,255,0.85); border-radius:14px; pointer-events:auto; padding:12px; z-index:200; } + +/* Selected item indicator */ +#selectedItem { display: none !important; } + background: rgba(0,0,0,0.45); border:2px solid #f1c40f; box-shadow: 0 0 12px 3px rgba(241,196,15,0.45); + display:flex; align-items:center; justify-content:center; font-size:22px; z-index:90; pointer-events:none; + background-size:cover; background-position:center; } \ No newline at end of file diff --git a/style.css_new b/style.css_new new file mode 100644 index 0000000..5e47062 --- /dev/null +++ b/style.css_new @@ -0,0 +1,151 @@ +/* GrechkaCraft Styles */ +html, body { margin:0; padding:0; width:100%; height:100%; overflow:hidden; overflow:clip; background:#111; overscroll-behavior: none; -webkit-overflow-scrolling: auto; font-family: system-ui, sans-serif; user-select:none; -webkit-user-select:none; touch-action:none; } +#game { position:absolute; top:0; left:0; right:0; bottom:0; background:#87CEEB; overflow:hidden; } +canvas { display:block; width:100%; height:100%; image-rendering:pixelated; } + +/* Controls — всегда скрыты, показываются только на тач-устройствах */ +#controls { position:absolute; left:0; right:0; bottom:0; height:auto; background:transparent; border:none; z-index:100; display:none; pointer-events:none; } +@media (hover: none) and (pointer: coarse) { + #controls { display: block; } +} +/* Также для landscape телефонов где ширина может быть большой */ +@media (max-width: 1024px) and (pointer: coarse) { + #controls { display: block; } +} + +.btn { position:absolute; bottom:10px; width:62px; height:62px; border-radius:14px; + background:#333; border:3px solid #555; color:#fff; font-size:28px; display:flex; align-items:center; justify-content:center; + box-shadow:0 5px 0 #111; pointer-events:auto; z-index:101; } +.btn:active { transform:translateY(2px); box-shadow:0 2px 0 #111; background:#444; } +#left { left:10px; } +#right { left:82px; } +#down { left:154px; background:#2980b9; border-color:#3498db; } +#jump { right:10px; background:#d35400; border-color:#e67e22; } + +.ui { position:absolute; inset:0; pointer-events:none; z-index:20; } +.pe { pointer-events:auto; } + +#stats { position:absolute; left:10px; top:10px; color:#fff; font-weight:800; font-size:14px; + background: rgba(0,0,0,0.55); padding:8px; border-radius:10px; text-shadow:1px 1px 0 #000; } +#stats .row{ display:flex; gap:10px; align-items:center; } + +.rbtn { position:absolute; right:10px; width:48px; height:48px; border-radius:12px; + display:flex; align-items:center; justify-content:center; border:2px solid rgba(255,255,255,0.9); + font-size:22px; cursor:pointer; pointer-events:auto; box-shadow:0 3px 0 rgba(0,0,0,0.5); } +.rbtn:active { transform: translateY(3px); box-shadow:none; } +#modeBtn { top:10px; background:#f39c12; } +#mapToggle { top:10px; right:66px !important; background:#1abc9c; } +#craftBtn { top:66px; right:10px !important; background:#9b59b6; } +#invToggle { top:66px; right:66px !important; background:#3498db; } +#menuBtn { top:66px; right:122px !important; background:#555; } +#chatToggle { display: none !important; } +#chatPanel { display: none !important; } + +/* === HOTBAR — позиционирование от ориентации === */ +#hotbar { position:absolute; left:50%; transform:translateX(-50%); bottom:12px; display:flex; gap:4px; + background: rgba(0,0,0,0.70); padding:6px 8px; border-radius:12px; pointer-events:auto; z-index:90; } + +/* Тач-устройства в портрете: hotbar над controls */ +@media (hover: none) and (pointer: coarse) and (orientation: portrait) { + #hotbar { bottom: 82px; } + #selectedItem { display: none !important; } +} +/* Тач-устройства в ландшафте: controls скрыты или узкие, hotbar внизу */ +@media (hover: none) and (pointer: coarse) and (orientation: landscape) { + #hotbar { bottom: 10px; } + #selectedItem { display: none !important; } +} +/* Маленькие экраны в ландшафте (телефоны) */ +@media (max-height: 500px) { + #hotbar { bottom: 6px; gap: 2px; padding: 4px 6px; } + #hotbar .slot { width: 36px; height: 36px; font-size: 16px; } + #selectedItem { display: none !important; } + + .btn { width: 50px; height: 50px; font-size: 22px; } + #left { left: 6px; } + #right { left: 64px; } + #down { left: 122px; } + #jump { right: 6px; } + .rbtn { width: 40px; height: 40px; font-size: 18px; } + #modeBtn { top: 6px; } + #mapToggle { top: 6px; right: 54px !important; } + #craftBtn { top: 52px; right: 6px !important; } + #invToggle { top: 52px; right: 54px !important; } + #menuBtn { top: 52px; right: 102px !important; } + #stats { font-size: 11px; padding: 4px 6px; } + #stats .row { gap: 4px; } +} + +.slot { width:44px; height:44px; border:2px solid rgba(255,255,255,0.25); border-radius:8px; + position:relative; overflow:hidden; cursor:pointer; background: rgba(0,0,0,0.45); + display:flex; align-items:center; justify-content:center; font-size:20px; + transition: border-color 0.15s, box-shadow 0.15s; } +.slot.sel { border-color:#f1c40f; box-shadow: 0 0 8px 2px rgba(241,196,15,0.55), inset 0 0 6px rgba(241,196,15,0.25); background: rgba(241,196,15,0.08); } +.count { position:absolute; right:2px; bottom:1px; font-size:10px; color:#fff; font-weight:900; text-shadow:1px 1px 0 #000; } + +/* Craft modal */ +#craftPanel { display:none; position:absolute; left:14px; right:14px; top:14px; bottom:14px; + background: rgba(10,10,12,0.92); border:2px solid rgba(255,255,255,0.85); border-radius:14px; + pointer-events:auto; padding:12px; overflow:auto; z-index: 200; } + +/* Inventory modal */ +#inventoryPanel { display:none; position:absolute; left:50%; top:50%; transform: translate(-50%, -50%); + width: 420px; max-width: 90%; background: rgba(10,10,12,0.92); border:2px solid rgba(255,255,255,0.85); + border-radius:14px; pointer-events:auto; padding:12px; z-index: 200; } +#inventoryPanel .top { display:flex; justify-content:space-between; align-items:center; color:#fff; margin-bottom:10px; } +#inventoryGrid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; margin-top: 10px; } +.inv-slot { width: 48px; height: 48px; border: 2px solid rgba(255,255,255,0.22); border-radius: 8px; + position: relative; overflow: hidden; cursor: pointer; background: rgba(255,255,255,0.07); + display: flex; align-items: center; justify-content: center; font-size: 24px; } +.inv-slot.sel { border-color: #f1c40f; box-shadow: 0 0 0 2px rgba(241,196,15,0.18) inset; } +.inv-count { position: absolute; right: 3px; bottom: 1px; font-size: 12px; color: #fff; + font-weight: 900; text-shadow: 1px 1px 0 #000; background: rgba(0,0,0,0.5); padding: 1px 3px; border-radius: 4px; } +.equipped-indicator { position: absolute; top: 2px; right: 2px; font-size: 14px; color: #2ecc71; font-weight: 900; text-shadow: 1px 1px 0 #000; } +#craftPanel .top { display:flex; justify-content:space-between; align-items:center; color:#fff; margin-bottom:10px; } +#craftPanel .close { background:#c0392b; border:none; color:#fff; font-weight:900; padding:8px 10px; border-radius:10px; cursor:pointer; } +#inventoryPanel .close { background:#c0392b; border:none; color:#fff; font-weight:900; padding:8px 10px; border-radius:10px; cursor:pointer; } +.recipe { display:flex; align-items:center; gap:10px; padding:10px; border-radius:12px; + background: rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.10); margin-bottom:8px; } +.ricon { width:32px; height:32px; border-radius:8px; background-size:cover; image-rendering:pixelated; } +.rinfo { flex:1; } +.rname { color:#fff; font-weight:900; font-size:14px; } +.rcost { color:#bbb; font-size:11px; line-height:1.25; } +.rcraft { background:#2ecc71; border:none; color:#fff; font-weight:900; padding:8px 10px; border-radius:10px; cursor:pointer; } +.rcraft:disabled { background:#444; color:#888; cursor:not-allowed; } + +#death { display:none; position:absolute; inset:0; background: rgba(60,0,0,0.88); + z-index:200; color:#fff; pointer-events:auto; align-items:center; justify-content:center; flex-direction:column; gap:12px; } +#death button { padding:12px 18px; font-size:18px; font-weight:900; border:none; border-radius:12px; cursor:pointer; } + +/* Panel header + close button fix */ +.panel-header { display:flex; justify-content:space-between; align-items:center; color:#fff; font-weight:900; font-size:18px; margin-bottom:12px; padding-bottom:8px; border-bottom:1px solid rgba(255,255,255,0.15); } +.panel-header .close { background:#c0392b; border:none; color:#fff; font-weight:900; padding:8px 12px; border-radius:10px; cursor:pointer; font-size:16px; min-width:36px; text-align:center; flex-shrink:0; margin-left:12px; } + +/* Custom modal (alerts/confirms) */ +.custom-modal-overlay { position:absolute; inset:0; background:rgba(0,0,0,0.7); z-index:9999; display:flex; align-items:center; justify-content:center; } +.custom-modal-box { background:#1a1a2e; border:2px solid #e74c3c; border-radius:16px; padding:24px 32px; color:#fff; font-size:16px; font-weight:700; text-align:center; max-width:320px; box-shadow:0 8px 32px rgba(0,0,0,0.5); } +.custom-modal-box .modal-btns { display:flex; gap:10px; justify-content:center; margin-top:16px; } +.custom-modal-box button { font-weight:900; padding:10px 20px; border-radius:10px; font-size:15px; cursor:pointer; border:none; color:#fff; } +.custom-modal-box .btn-yes { background:#e74c3c; } +.custom-modal-box .btn-no { background:#555; } +.custom-modal-box .btn-ok { background:#2ecc71; } + +/* Menu dropdown ⋯ */ +.menu-dropdown { position:absolute; top:120px; right:58px; background:rgba(20,20,30,0.95); border:2px solid rgba(255,255,255,0.7); border-radius:10px; display:none; flex-direction:column; min-width:140px; z-index:300; pointer-events:auto; overflow:hidden; } +.menu-dropdown.open { display:flex; } +.menu-item { padding:10px 14px; color:#fff; font-size:14px; font-weight:700; cursor:pointer; border-bottom:1px solid rgba(255,255,255,0.1); } +.menu-item:last-child { border-bottom:none; } +.menu-item:hover { background:rgba(255,255,255,0.12); } +.menu-item:active { background:rgba(255,255,255,0.2); } + +/* Market panel */ +#marketPanel { display:none; position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); width:360px; max-width:90%; background:rgba(10,10,12,0.95); border:2px solid rgba(255,255,255,0.85); border-radius:14px; pointer-events:auto; padding:12px; z-index:200; } + +/* Settings panel */ +#settingsPanel { position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); width:320px; max-width:90%; background:rgba(10,10,12,0.95); border:2px solid rgba(255,255,255,0.85); border-radius:14px; pointer-events:auto; padding:12px; z-index:200; } + +/* Selected item indicator */ +#selectedItem { display: none !important; } + background: rgba(0,0,0,0.45); border:2px solid #f1c40f; box-shadow: 0 0 12px 3px rgba(241,196,15,0.45); + display:flex; align-items:center; justify-content:center; font-size:22px; z-index:90; pointer-events:none; + background-size:cover; background-position:center; } \ No newline at end of file diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..09b79f7 --- /dev/null +++ b/sw.js @@ -0,0 +1,106 @@ +// Service Worker для GrechkaCraft. +// +// Раньше на любую сетевую ошибку отдавался `new Response('Offline', {status: 503})`. +// Из-за этого обычный обрыв сети (моргнувший Wi-Fi, потерянный пакет) выглядел +// как «сервер упал»: страница грузилась, но весь JS и мир приходили как 503, +// и пользователь видел либо пустой экран, либо сообщение об ошибке. +// +// Теперь: +// - статика (HTML/JS/CSS/ассеты) кэшируется и отдаётся из кэша при сбое; +// - навигация при сбое отдаёт закэшированную копию страницы; +// - если кэша нет — отдаём 200 с минимальной страницей-заглушкой, а НЕ 503, +// потому что 503 для пользователя неотличим от «сервис умер». + +const CACHE = 'grechka-v3'; + +const PRECACHE_URLS = [ + './', + './index.html', + './index-v2.html', + './game.js', + './game3d.js', + './style.css', + './manifest.json', +]; + +self.addEventListener('install', (e) => { + self.skipWaiting(); + e.waitUntil( + caches.open(CACHE) + // addAll падает целиком, если хоть один URL недоступен — кэшируем по одному. + .then((c) => Promise.all( + PRECACHE_URLS.map((u) => c.add(u).catch(() => null)) + )) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener('activate', (e) => { + e.waitUntil( + caches.keys() + .then((keys) => Promise.all( + keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)) + )) + .then(() => self.clients.claim()) + ); +}); + +const OFFLINE_PAGE = ` + +Гречка — нет связи +
+

Нет связи с сервером

+

Игра не смогла загрузиться. Проверь интернет и нажми «Попробовать снова».

+ +
`; + +self.addEventListener('fetch', (e) => { + const req = e.request; + if (req.method !== 'GET') return; + + const url = new URL(req.url); + + // Кросс-доменные запросы (socket.io CDN, apigrech, telegram) не трогаем. + if (url.origin !== self.location.origin) return; + + // Навигация: сначала сеть, при сбое — кэш страницы, иначе заглушка. + if (req.mode === 'navigate') { + e.respondWith( + fetch(req) + .then((res) => { + const copy = res.clone(); + caches.open(CACHE).then((c) => c.put(req, copy)); + return res; + }) + .catch(() => caches.match(req, { ignoreSearch: true }) + .then((r) => r || caches.match('./index.html')) + .then((r) => r || new Response(OFFLINE_PAGE, { + status: 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }))) + ); + return; + } + + // Статика: сначала сеть, кэшируем свежее; при сбое — из кэша. + e.respondWith( + fetch(req) + .then((res) => { + if (res && res.ok && res.type === 'basic') { + const copy = res.clone(); + caches.open(CACHE).then((c) => c.put(req, copy)); + } + return res; + }) + .catch(() => caches.match(req, { ignoreSearch: true }) + .then((r) => r || new Response('', { status: 200 }))) + ); +}); diff --git a/tex/chicken_back.png b/tex/chicken_back.png new file mode 100644 index 0000000..217f0e8 Binary files /dev/null and b/tex/chicken_back.png differ diff --git a/tex/chicken_front.png b/tex/chicken_front.png new file mode 100644 index 0000000..8174cf9 Binary files /dev/null and b/tex/chicken_front.png differ diff --git a/tex/chicken_side.png b/tex/chicken_side.png new file mode 100644 index 0000000..bbb49a2 Binary files /dev/null and b/tex/chicken_side.png differ diff --git a/tex/creeper_back.png b/tex/creeper_back.png new file mode 100644 index 0000000..959112a Binary files /dev/null and b/tex/creeper_back.png differ diff --git a/tex/creeper_front.png b/tex/creeper_front.png new file mode 100644 index 0000000..593265c Binary files /dev/null and b/tex/creeper_front.png differ diff --git a/tex/creeper_side.png b/tex/creeper_side.png new file mode 100644 index 0000000..23c0cad Binary files /dev/null and b/tex/creeper_side.png differ diff --git a/tex/eagle_back.png b/tex/eagle_back.png new file mode 100644 index 0000000..c4b4012 Binary files /dev/null and b/tex/eagle_back.png differ diff --git a/tex/eagle_front.png b/tex/eagle_front.png new file mode 100644 index 0000000..e84375b Binary files /dev/null and b/tex/eagle_front.png differ diff --git a/tex/eagle_side.png b/tex/eagle_side.png new file mode 100644 index 0000000..392570d Binary files /dev/null and b/tex/eagle_side.png differ diff --git a/tex/pig_back.png b/tex/pig_back.png new file mode 100644 index 0000000..cef2150 Binary files /dev/null and b/tex/pig_back.png differ diff --git a/tex/pig_front.png b/tex/pig_front.png new file mode 100644 index 0000000..1a98a2e Binary files /dev/null and b/tex/pig_front.png differ diff --git a/tex/pig_side.png b/tex/pig_side.png new file mode 100644 index 0000000..2889c02 Binary files /dev/null and b/tex/pig_side.png differ diff --git a/tex/polar_bear_back.png b/tex/polar_bear_back.png new file mode 100644 index 0000000..ba98642 Binary files /dev/null and b/tex/polar_bear_back.png differ diff --git a/tex/polar_bear_front.png b/tex/polar_bear_front.png new file mode 100644 index 0000000..10eb8c5 Binary files /dev/null and b/tex/polar_bear_front.png differ diff --git a/tex/polar_bear_side.png b/tex/polar_bear_side.png new file mode 100644 index 0000000..08f8bd2 Binary files /dev/null and b/tex/polar_bear_side.png differ diff --git a/tex/scorpion_back.png b/tex/scorpion_back.png new file mode 100644 index 0000000..a1c39bf Binary files /dev/null and b/tex/scorpion_back.png differ diff --git a/tex/scorpion_front.png b/tex/scorpion_front.png new file mode 100644 index 0000000..86a8e86 Binary files /dev/null and b/tex/scorpion_front.png differ diff --git a/tex/scorpion_side.png b/tex/scorpion_side.png new file mode 100644 index 0000000..353fa88 Binary files /dev/null and b/tex/scorpion_side.png differ diff --git a/tex/skeleton_back.png b/tex/skeleton_back.png new file mode 100644 index 0000000..06e8cfa Binary files /dev/null and b/tex/skeleton_back.png differ diff --git a/tex/skeleton_front.png b/tex/skeleton_front.png new file mode 100644 index 0000000..6c8b5bf Binary files /dev/null and b/tex/skeleton_front.png differ diff --git a/tex/skeleton_side.png b/tex/skeleton_side.png new file mode 100644 index 0000000..fb4f2a8 Binary files /dev/null and b/tex/skeleton_side.png differ diff --git a/tex/slime_back.png b/tex/slime_back.png new file mode 100644 index 0000000..219d74a Binary files /dev/null and b/tex/slime_back.png differ diff --git a/tex/slime_front.png b/tex/slime_front.png new file mode 100644 index 0000000..5306867 Binary files /dev/null and b/tex/slime_front.png differ diff --git a/tex/slime_side.png b/tex/slime_side.png new file mode 100644 index 0000000..f3ce574 Binary files /dev/null and b/tex/slime_side.png differ diff --git a/tex/zombie_back.png b/tex/zombie_back.png new file mode 100644 index 0000000..a20c46c Binary files /dev/null and b/tex/zombie_back.png differ diff --git a/tex/zombie_front.png b/tex/zombie_front.png new file mode 100644 index 0000000..df43477 Binary files /dev/null and b/tex/zombie_front.png differ diff --git a/tex/zombie_side.png b/tex/zombie_side.png new file mode 100644 index 0000000..8e6bebe Binary files /dev/null and b/tex/zombie_side.png differ