// GrechkaCraft — auto-bundled from ES modules (function() { "use strict"; // === core/constants.js === // ==================== КОНСТАНТЫ ИГРЫ ==================== const TILE = 40; // Мир // Уровень моря. Было 14 при фактической поверхности 4..12: вода была ВЫШЕ // всей суши, заливала мир, игрок всегда стоял в воде (gy=6 под водой 7..9), // камера схлопывалась в первое лицо, и кадр показывал пустое небо. // Ставим 3 — заметно ниже любой реальной поверхности, вода появляется // только в настоящих низинах, а не везде. const SEA_GY = 3; 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.30, // старт в полдень (0 = рассвет, 0.5+ = ночь) 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, // Мир blocksVersion: 0, 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, z: 0, w: 34, h: 34, d: 34, vx: 0, vy: 0, vz: 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, // Опыт и уровень. Раньше этих полей не было вовсе: элементы #xplevel // и #xpbar были в HTML, но player.xp нигде не рос, и HUD вечно показывал // «Lv.1 | 0/50». Порог уровня — 50 * level. xp: 0, level: 1 }, // Точка спавна 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, ax: 0, ay: 0, // аналоговые оси джойстика (-1..1) kfwd: 0, kside: 0, // клавиатура: вперёд/назад и влево/вправо (-1..1) mineHeld: 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, }; // === 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 // 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 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 — 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); // 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 === // ==================== ХОЛСТ 3D (WebGL) — GrechkaCraft v2 ==================== // Тот же контракт, что canvas.js: выставляет state.W/H, хранит canvas. // WebGL-контекст создаёт render3d (лениво, при первом кадре). function initCanvas() { const gameEl = document.getElementById('game'); const canvas = document.getElementById('c'); const dpr = Math.max(1, window.devicePixelRatio || 1); function resize() { state.W = gameEl.clientWidth; state.H = gameEl.clientHeight; } window.addEventListener('resize', resize); resize(); state.gameEl = gameEl; state.canvas = canvas; state.ctx = null; state.lightC = null; state.lightCtx = null; state.dpr = dpr; } function getCanvas() { return state.canvas; } function getCtx() { return null; } function getLightC() { return null; } function getLightCtx() { return null; } // === 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 ==================== // Ключ блока: "x,y,z". Единый источник для рендера, физики, сети и сейвов. // Мир по всем трём осям: X — вперёд, Y — вверх, Z — вбок (глубина мира). const grid = state.grid; // Map: "x,y,z" => { gx, gy, gz, t, dead } const blocks = state.blocks; // Array для перебора/рендера function k3(gx, gy, gz) { return gx + ',' + gy + ',' + gz; } function getBlock(gx, gy, gz) { const b = grid.get(k3(gx, gy, gz)); if (b && b.dead) return null; return b || null; } function hasBlock(gx, gy, gz) { return grid.has(k3(gx, gy, gz)); } function isSolid(gx, gy, gz) { const b = getBlock(gx, gy, gz); if (!b) return false; const def = BLOCKS[b.t]; return !!def.solid && !def.fluid && !def.decor && !def.climbable; } // Непрозрачный ли блок (для отсечения внутренних граней не используется — мы рендерим кубы) function isOpaque(gx, gy, gz) { const b = getBlock(gx, gy, gz); if (!b) return false; const def = BLOCKS[b.t]; return !!def.solid && !def.fluid && !def.decor && !def.climbable; } // Третий аргумент 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 }); } 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); if (!b) return null; 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)); } else { state.removedBlocks.push({ gx, gy, gz }); } state.blocksVersion = (state.blocksVersion || 0) + 1; return b; } // === world/generation3d.js === // ==================== ГЕНЕРАЦИЯ 3D — GrechkaCraft v3 ==================== // Объёмный мир: столбцы (x,z), рельеф из 2D-шума, пещеры, руды, растительность. // Та же система биомов, что в 2D-игре, но по двум координатам. // Радиус мира по Z — насколько далеко вбок генерируем вокруг игрока // Полу-ширина мира. 20 тайлов = мир 40x40 при глубине 140 — отсюда «мелкий // мир»: игрок упирался в край за 20 шагов. 48 даёт комфортные 96x96 тайлов. // Мир генерируется целиком один раз (pumpWorldGen) порциями по 200 колонок. const WORLD_DEPTH = 48; const CHUNK = 8; // шаг генерации // ========== ШУМ (детерминированный, от worldSeed) ========== function hash2(x, z) { let h = x * 374761393 + z * 668265263 + state.worldSeed * 1274126177; h = (h ^ (h >> 13)) * 1274126177; return ((h ^ (h >> 16)) >>> 0) / 4294967295; } function smooth(t) { return t * t * (3 - 2 * t); } function valueNoise2(x, z, scale) { const fx = x / scale, fz = z / scale; const x0 = Math.floor(fx), z0 = Math.floor(fz); const tx = smooth(fx - x0), tz = smooth(fz - z0); const a = hash2(x0, z0), b = hash2(x0 + 1, z0); const c = hash2(x0, z0 + 1), d = hash2(x0 + 1, z0 + 1); return (a * (1 - tx) + b * tx) * (1 - tz) + (c * (1 - tx) + d * tx) * tz; } function fbm2(x, z, octaves = 4) { let sum = 0, amp = 0.5, freq = 1, norm = 0; for (let i = 0; i < octaves; i++) { sum += valueNoise2(x * freq, z * freq, 1) * amp; norm += amp; amp *= 0.5; freq *= 2; } return sum / norm; } // ========== БИОМЫ (те же пять, что в 2D) ========== const BIOMES = { plains: { name:'Равнина', surface:'grass', subsurface:'dirt', trees:true, treeChance:0.11 }, desert: { name:'Пустыня', surface:'sand', subsurface:'sand', trees:false, treeChance:0 }, tundra: { name:'Тундра', surface:'snow', subsurface:'dirt', trees:true, treeChance:0.05 }, swamp: { name:'Болото', surface:'moss', subsurface:'dirt', trees:true, treeChance:0.09 }, mountains: { name:'Горы', surface:'stone', subsurface:'stone', trees:false, treeChance:0 }, }; function getBiome(gx, gz) { const s = state.worldSeed; const temp = fbm2(gx * 0.6, gz * 0.6 + 11, 3) * 0.5 + Math.sin(gx * 0.003 + s * 0.01) * 0.25 + 0.4; const humid = fbm2(gx * 0.5 + 500, gz * 0.5 + 700, 3) * 0.5 + Math.cos(gx * 0.006 + s * 0.02) * 0.2 + 0.4; const mt = fbm2(gx * 0.25 + 900, gz * 0.25 + 300, 3); if (mt > 0.78) return 'mountains'; if (temp > 0.66) return 'desert'; if (temp < 0.34) return 'tundra'; if (humid > 0.66) return 'swamp'; return 'plains'; } const biomeCache = new Map(); function getCachedBiome(gx, gz) { const cx = Math.floor(gx / CHUNK), cz = Math.floor(gz / CHUNK); const key = cx + ',' + cz; if (!biomeCache.has(key)) biomeCache.set(key, getBiome(cx * CHUNK, cz * CHUNK)); return biomeCache.get(key); } // ========== РЕЛЬЕФ ========== // Высота поверхности в колонке (x,z). Одна и та же функция на сервере и клиенте. function surfaceGyAt(gx, gz) { const biome = getCachedBiome(gx, gz); const n1 = Math.sin(gx * 0.025 + state.worldSeed * 0.001) * 8; const n2 = Math.sin(gz * 0.021 - state.worldSeed * 0.0015) * 7; const n3 = Math.sin((gx + gz) * 0.012 + state.worldSeed * 0.002) * 10; const n4 = fbm2(gx * 0.5, gz * 0.5, 3) * 9; let h; switch (biome) { case 'desert': h = 8 + n1 * 0.5 + n3 * 0.3 + n4 * 0.2; break; case 'mountains': h = 16 + n3 * 1.1 + n4 * 1.2; break; case 'tundra': h = 9 + n1 * 0.6 + n3 * 0.5 + n4 * 0.4; break; case 'swamp': h = 4 + n1 * 0.4 + n2 * 0.6 + n4 * 0.3; break; default: h = 10 + n1 * 0.7 + n2 * 0.7 + n3 * 0.4 + n4 * 0.4; } return Math.max(2, Math.min(BEDROCK_GY - 12, Math.round(h))); } function seaLevel() { return SEA_GY; } // ========== ПЕЩЕРЫ ========== function inCave(gx, gy, gz) { if (gy < 3) return false; const c1 = fbm2(gx * 0.9 + 11, gz * 0.9 + gy * 1.7, 2); const c2 = fbm2(gx * 0.9 - 40, gz * 0.9 + gy * 1.7 + 90, 2); // две «трубки» шума: пересечение даёт объёмные полости return c1 > 0.62 && c2 > 0.60 && gy < surfaceGyAt(gx, gz) - 3; } // ========== РУДЫ ========== function oreAt(gx, gy, gz) { if (gy < 2) return null; const depth = gy / BEDROCK_GY; const r = hash2(gx * 7 + gy, gz * 13); const r2 = hash2(gx * 3 - gy, gz * 5 + 17); if (depth < 0.30 && r > 0.982) return 'coal'; if (depth < 0.26 && r > 0.990 && r2 > 0.6) return 'copper_ore'; if (depth < 0.20 && r > 0.988 && r2 > 0.4) return 'iron_ore'; if (depth < 0.13 && r > 0.994) return 'gold_ore'; if (depth < 0.09 && r > 0.9965) return 'diamond_ore'; return null; } // ========== ДЕРЕВО (по XY, заполняет и Z) ========== function growTree(gx, gy, gz, biome) { const def = BIOMES[biome]; if (!def || !def.trees) return; if (hash2(gx * 31, gz * 17) > def.treeChance) return; 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', false, false); } // крона объёмом: псевдосфера const cy = gy - h; const rad = 2; for (let dy = -2; dy <= 1; dy++) { for (let dz = -rad; dz <= rad; dz++) { for (let dx = -rad; dx <= rad; dx++) { 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, false, false); } } } } 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', 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" — уже сгенерированные чанки // ========== ГЕНЕРАЦИЯ ЧАНКА ========== function genChunk(cx, cz) { const key = cx + ',' + cz; if (generated.has(key)) return false; generated.add(key); const x0 = cx * CHUNK, z0 = cz * CHUNK; for (let x = x0; x < x0 + CHUNK; x++) { for (let z = z0; z < z0 + CHUNK; z++) { genColumn(x, z); } } return true; } function genColumn(gx, gz) { const biome = getCachedBiome(gx, gz); const top = surfaceGyAt(gx, gz); const sea = seaLevel(); // Генерируем до bedrock, но не ниже уровня «пещерной зоны» — экономим память 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', 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, false, false); } continue; } // пещеры — только под поверхностью if (gy < top - 2 && inCave(gx, gy, gz)) continue; const ore = oreAt(gx, gy, gz); if (ore) { setBlock(gx, gy, gz, ore, false, false); continue; } const def = BIOMES[biome]; 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); } // вода в пещерах не заливаем — это гравитация // растительность const above = getBlock(gx, top, gz); if (above && !BLOCKS_NO_SOLID(above.t)) growDecor(gx, top, gz, biome); if (above && !BLOCKS_NO_SOLID(above.t) && hash2(gx * 13, gz * 17) > 0.93) growTree(gx, top, gz, biome); } function BLOCKS_NO_SOLID(t) { return t === 'water' || t === 'swamp_water' || t === 'air'; } // ========== ОДИН ФИКСИРОВАННЫЙ МИР ========== // Раньше мир «стримился» вокруг игрока, а дальние чанки ВЫЧИЩАЛИСЬ. Из-за // этого горизонт пустел, а мелкая дальняя генерация давала обрезанную землю — // отсюда «остров, висящий в пространстве». Теперь мир генерируется ЦЕЛИКОМ // один раз и больше никогда не вычищается. Чтобы вкладка не зависла на // сотнях тысяч блоков, генерация идёт порциями: pumpWorldGen() зовётся из // игрового цикла, а isWorldReady() показывает готовность для загрузочного экрана. const genChunks = new Set(); 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 === // Звуковой движок 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 = '#27ae60'; g.fillRect(12, 6, 8, 5); break; case 'potato': g.fillStyle = '#d4a050'; g.beginPath(); g.ellipse(16, 16, 8, 6, 0, 0, Math.PI * 2); g.fill(); 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 = '#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; } 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() { 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(); } // 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'); state.selected = id; // Move to front of recentItems state.recentItems = state.recentItems.filter(item => item !== id); state.recentItems.unshift(id); 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); } // Update selected item indicator above hotbar updateSelectedItem(); } // === world/tnt3d.js === // ==================== TNT В 3D — GrechkaCraft v3 ==================== // Взрыв сферический: сносит блоки в радиусе по всем трём осям. function activateTNT3D(b, fuseSeconds) { b.active = true; b.fuse = fuseSeconds; playSound('click'); } function explodeTNT3D(b, st) { const TILE = st.TILE; const cx = b.gx, cy = b.gy, cz = b.gz || 0; const radius = 3.2; const r2 = radius * radius; // убрать сам блок removeBlock(cx, cy, cz); const toRemove = []; const r = Math.ceil(radius); for (let dx = -r; dx <= r; dx++) { for (let dy = -r; dy <= r; dy++) { for (let dz = -r; dz <= r; dz++) { const d2 = dx * dx + dy * dy + dz * dz; if (d2 > r2) continue; const blk = getBlock(cx + dx, cy + dy, cz + dz); if (!blk) continue; const def = BLOCKS[blk.t]; if (!def) continue; if (def.unbreakable) continue; // взрывная сила падает с расстоянием if (d2 > r2 * 0.55 && blk.t !== 'dirt' && blk.t !== 'sand' && blk.t !== 'grass') continue; toRemove.push({ gx: cx + dx, gy: cy + dy, gz: cz + dz, t: blk.t }); } } } for (const rem of toRemove) removeBlock(rem.gx, rem.gy, rem.gz); // частицы const parts = st.parts || (st.parts = []); const n = 30; for (let i = 0; i < n; i++) { const a = Math.random() * Math.PI * 2; const p = Math.acos(2 * Math.random() - 1); const sp = 60 + Math.random() * 180; parts.push({ x: (cx + 0.5) * TILE, y: (cy + 0.5) * TILE, z: (cz + 0.5) * TILE, vx: Math.sin(p) * Math.cos(a) * sp, vy: Math.cos(p) * sp, vz: Math.sin(p) * Math.sin(a) * sp, life: 0.6 + Math.random() * 0.5, c: Math.random() > 0.5 ? '#ff7a1a' : '#f1c40f', }); } // урон мобам и игроку в радиусе const dmg = (e) => { const ex = (e.x + e.w / 2) / TILE, ey = (e.y + e.h / 2) / TILE, ez = ((e.z || 0) + e.d / 2) / TILE; const d = Math.hypot(ex - cx, ey - cy, ez - cz); if (d < radius + 1) { const f = 1 - d / (radius + 1); e.hp -= 14 * f; e.vx += (ex - cx) * 220; e.vy -= 300 * f; e.vz = (e.vz || 0) + (ez - cz) * 220; } }; for (const m of st.mobs) dmg(m); const p = st.player; dmg(p); if (p.hp <= 0 && p.invuln <= 0) { p.hp = 0; p.invuln = 1.5; } playSound('explode1'); // взрывные частицы убрать по таймеру st.explosionFlash = 0.4; setBlock(cx, cy, cz, 'air'); void setBlock; } // === 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() { 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; } } 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('Миграция завершена'); } function applySave(saveData) { if (!saveData) return; console.log('=== applySave START ==='); console.log('player HP before applySave:', state.player.hp); console.log('saveData.player.hp:', saveData.player?.hp); // Миграция версий if (saveData.version === 1) { migrateV1toV2(saveData); } // Восстанавливаем seed if (saveData.worldSeed !== undefined) { state.worldSeed = saveData.worldSeed; } // Восстанавливаем игрока if (saveData.player) { // ВАЖНО: в 3D координаты из сейва НЕ применяем. Сейв 2D-версии содержит // y в другой системе (там игрок мог стоять на 2D-поверхности), из-за чего // после загрузки player.y улетал на +119 тайлов (5286 вместо 526) — игрок // оказывался под миром и падал на дно. Позицию ставит ensurePlayerOnGround // после генерации, а из сейва берём только состояние. state.player.hunger = saveData.player.hunger; state.player.o2 = saveData.player.o2; // Опыт и уровень восстанавливаем с проверкой: старые сейвы полей не // содержат, и 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; 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('Сохранение: координаты игрока НЕ применяются в 3D (позицию ставит ensurePlayerOnGround)'); } 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 resetGenerationCache(); ensureGenAroundCamera(); // Применяем изменения (только для v2) if (saveData.version === 2) { // Применяем блоки, установленные игроком for (const block of saveData.placedBlocks) { setBlock(block.gx, block.gy, block.gz || 0, block.t, true); } // Применяем удалённые блоки for (const block of saveData.removedBlocks) { removeBlock(block.gx, block.gy, block.gz || 0); } // Восстанавливаем массивы изменений 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; if (menuDropdown) menuDropdown.classList.toggle('open', dropdownOpen); }); } // Закрытие по клику вне 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; if (menuDropdown) menuDropdown.classList.remove('open'); alert('Игра сохранена!'); }); } // 🔄 Новый мир 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); try { const newUrl = new URL(window.location.href); newUrl.searchParams.set('world', state.worldId); window.history.replaceState(null, '', newUrl.toString()); } catch(e) {} location.reload(); } else { dropdownOpen = false; if (menuDropdown) menuDropdown.classList.remove('open'); } }); } // 🏪 Рынок if (marketItem) { marketItem.addEventListener('click', () => { dropdownOpen = false; if (menuDropdown) menuDropdown.classList.remove('open'); document.dispatchEvent(new CustomEvent('toggleMarket')); }); } // 📤 Пригласить if (shareItem) { shareItem.addEventListener('click', () => { dropdownOpen = false; if (menuDropdown) menuDropdown.classList.remove('open'); shareWorld(); }); } // ⚙️ Настройки 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'; }); } // Закрытие настроек const settingsClose = document.getElementById('settingsClose'); if (settingsClose) { settingsClose.addEventListener('click', () => { const settingsPanel = document.getElementById('settingsPanel'); if (settingsPanel) settingsPanel.style.display = 'none'; }); } // Тогл день/ночи const dayNightToggle = document.getElementById('dayNightToggle'); if (dayNightToggle) { dayNightToggle.addEventListener('change', () => { state.dayNightCycle = dayNightToggle.checked; saveGame(); }); } // 🏠 Главное меню 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 === // ==================== СЕТЬ: отправка позиции и блоков ==================== // Три правки против 2D-версии: // 1) шлём Z — без него игрок в 3D стоит в плоскости и не двигается вглубь; // 2) шлём gz в block_change — иначе все блоки ложатся в gz=0; // 3) порог отправки по Z, иначе движение вглубь не отправляется вовсе. // Генерация случайного цвета для игрока на основе 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, lastSentZ = 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; // Отправляем только если позиция изменилась ПО ВСЕМ ОСЯМ. // Раньше z не сравнивался — движение вглубь мира не отправлялось. const z = state.player.z || 0; const dx = Math.abs(state.player.x - lastSentX); const dy = Math.abs(state.player.y - lastSentY); 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, z: z, player_name: state.playerName }); } // Отправка изменения блока. 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, gz: gz || 0, 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 = `
🤝 ${data.from_name} хочет торговать
`; document.getElementById('game').appendChild(overlay); document.getElementById('tradeAcceptBtn').addEventListener('click', () => { overlay.remove(); socket.emit('trade_accept', { from_socket_id: data.from }); }); document.getElementById('tradeDeclineBtn').addEventListener('click', () => { overlay.remove(); socket.emit('trade_decline', { from_socket_id: data.from }); }); }); 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'; } } // 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; } } // 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; } } 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}
Вы предлагаете:
⇄
Партнёр предлагает:
Ожидание...
⏳ Партнёр ещё не подтвердил
`; 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('Система', 'Инвентарь пуст!'); 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; } } } // 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}...`); } // === 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 клиент — инициализация и все обработчики // 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; // Окклюзивная сетка обновляется только из 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 = 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); } else if (block.op === 'remove') { removeBlock(block.gx, block.gy, block.gz || 0); } } } if (data.time !== undefined) { state.worldTime = data.time; state.isNightTime = state.worldTime > 0.5; } // Стартовая колонка: X=6 по центру мира, Z игрока. // Z фиксируем ОДИН РАЗ при входе: если брать текущую позицию каждый // раз, то второй world_state ищет площадку уже рядом с первой // найденной точкой и уводит игрока всё дальше в сторону. if (state.__spawnGZ === undefined) { state.__spawnGZ = Math.floor((state.player.z || 0) / state.TILE); } 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; 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) { 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) { // Позиция ДРУГОГО игрока. Формула та же, что и у нас: feet на // верхней грани опоры. Раньше было (surfaceY - 1) * TILE — это // тайл ПОД поверхностью, то есть чужий игрок появлялся в грунте. const spawnGX = 6; 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; // верхняя грань тайла 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; // Ось Z обязательна: без неё чужие игроки стоят в одной плоскости // и не двигаются вглубь мира. Раньше z просто игнорировался. p.z = data.z || 0; 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); // Ось 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); }); 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) => { explodeTNT3D({ gx: data.gx, gy: data.gy, gz: data.gz || 0 }, state); 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 = 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); } else if (data.op === 'remove') { removeBlock(data.gx, data.gy, data.gz || 0); } }); socket.on('chat_message', (data) => { const senderName = data.socket_id === state.mySocketId ? 'Вы' : `Игрок ${data.socket_id.substring(0, 6)}`; addChatMessage(senderName, data.message); }); socket.on('time_update', (data) => { if (data.time !== undefined) { state.worldTime = data.time; state.isNightTime = state.worldTime > 0.5; } }); } catch (e) { console.error('Error initializing socket:', e); state.isMultiplayer = false; } } // === physics/water-detect3d.js === // ==================== ВОДА 3D — GrechkaCraft v3 ==================== // Та же логика 2D-игры (кислород, всплеск, болото), но с учётом Z. function blockAt3(px, py, pz) { const gx = Math.floor(px / TILE); const gy = Math.floor(py / TILE); const gz = Math.floor(pz / TILE); return getBlock(gx, gy, gz); } function isWaterAt(px, py, pz) { const b = blockAt3(px, py, pz); return !!(b && (b.t === 'water' || b.t === 'swamp_water')); } function isSwampWaterAt(px, py, pz) { const b = blockAt3(px, py, pz); return !!(b && b.t === 'swamp_water'); } // Обновляет флаги воды на сущности (игрок или моб). // dt передаётся явно — в 2D-версии он был неявной переменной цикла. function updateWaterFlag3(e, dt) { const cx = e.x + e.w / 2; const cz = (e.z || 0) + (e.d || e.w) / 2; const wasInWater = e.inWater; const mid = isWaterAt(cx, e.y + e.h / 2, cz); const feet = isWaterAt(cx, e.y + e.h - 2, cz); e.inWater = mid || feet; e.inSwampWater = isSwampWaterAt(cx, e.y + e.h / 2, cz); e.headInWater = isWaterAt(cx, e.y + 4, cz); if (e === state.player && !wasInWater && e.inWater && e.vy > 100) { playSound('splash'); } if (e === state.player && e.inSwampWater) { state.player.hp -= 3 * (typeof dt === 'number' ? dt : 0.016); } } // Лодка тонет, если под ней нет воды (с учётом Z). function updateWaterPhysics3(dt) { void dt; } // === physics/collision3d.js === // ==================== ФИЗИКА 3D — GrechkaCraft v3 ==================== // Осевое разрешение столкновений по X → Z → Y, как в Minecraft. // Автошаг на 1 блок, прыжок, лестницы, плавание, лодка. const EPS = 0.001; // 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 + e.h, y1: e.y, z0: e.z, z1: e.z + e.d, }; } function solidAt(gx, gy, gz) { const b = getBlock(gx, gy, gz); if (!b) return false; const def = BLOCKS[b.t]; if (!def) return false; if (def.fluid) return false; if (def.decor || def.climbable) return false; return !!def.solid; } function climbableAt(gx, gy, gz) { const b = getBlock(gx, gy, gz); return !!(b && BLOCKS[b.t] && BLOCKS[b.t].climbable); } function fluidAt(gx, gy, gz) { const b = getBlock(gx, gy, gz); return !!(b && BLOCKS[b.t] && BLOCKS[b.t].fluid); } // Пересекает ли коробка сущности твёрдый блок // Пол под ногами. ТЕЛО ТОНКОЕ: при 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 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++) { for (let gz = z0; gz <= z1; gz++) { if (solidAt(gx, gy, gz)) return true; } } } return false; } // Стоит ли сущность на чём-то (для grounded) function checkGrounded(e) { const bx = boxOf(e); // Опора = тайл под ногами = под НИЗОМ тела (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++) { for (let gz = z0; gz <= z1; gz++) { if (solidAt(gx, gy, gz)) return true; } } return false; } function onLadder(e) { const cx = Math.floor((e.x + e.w / 2) / TILE); const cy = Math.floor((e.y + e.h / 2) / TILE); const cz = Math.floor((e.z + e.d / 2) / TILE); return climbableAt(cx, cy, cz); } function inFluid(e) { const cx = Math.floor((e.x + e.w / 2) / TILE); const cy = Math.floor((e.y + e.h * 0.4) / TILE); const cz = Math.floor((e.z + e.d / 2) / TILE); return fluidAt(cx, cy, cz); } // Разрешение одной оси. delta — смещение за кадр. function moveAxis(e, axis, delta) { if (delta === 0) return false; const bx = boxOf(e); if (axis === 'x') { bx.x0 += delta; bx.x1 += delta; } else if (axis === 'z') { bx.z0 += delta; bx.z1 += delta; } else { bx.y0 += delta; bx.y1 += delta; } if (!hitsSolid(bx)) { if (axis === 'x') e.x += delta; else if (axis === 'z') e.z += delta; else e.y += delta; return false; } const dir = Math.sign(delta); // Автошаг вверх на 1 блок if ((axis === 'x' || axis === 'z') && e.grounded) { const savedX = e.x, savedY = e.y, savedZ = e.z; if (axis === 'x') e.x += delta; else e.z += delta; e.y -= TILE; if (!hitsSolid(boxOf(e))) { return true; // шаг удался, продолжим движение } 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; 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; if (axis === 'x') e.x += stepPx; else if (axis === 'z') e.z += stepPx; else e.y += stepPx; } if (axis === 'x') e.vx = 0; else if (axis === 'z') e.vz = 0; else e.vy = 0; return true; } 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 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; } // === 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, gz) { state.currentFurnaceKey = `${gx},${gy},${gz || 0}`; furnacePanel.style.display = 'block'; renderFurnaceUI(); } function renderFurnaceUI() { if (!state.currentFurnaceKey) return; // Проверяем что печь всё ещё существует const [fgx, fgy, fgz] = state.currentFurnaceKey.split(',').map(Number); const fb = getBlock(fgx, fgy, fgz || 0); 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(); } } } } // === entities/mob-ai3d.js === // ==================== МОБЫ 3D: ИИ + ФИЗИКА — GrechkaCraft v3 ==================== // Вся логика из 2D-игры (все 9 видов, ночные/дневные, лут), но цель — // настоящая 3D-точка игрока (x,y,z) и прыжки через стены по всем осям. function isNight() { return state.worldTime > 0.5; } // Центры сущностей в тайлах function cx(e) { return (e.x + e.w / 2) / TILE; } function cy(e) { return (e.y + e.h / 2) / TILE; } function cz(e) { return ((e.z || 0) + (e.d || e.w) / 2) / TILE; } function playerC() { const p = state.player; return { x: cx(p), y: cy(p), z: cz(p) }; } function dist3(a, b) { return Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z); } // Тянет моба к игроку по обеим горизонтальным осям (X и Z) function steerToPlayer(m, pc) { const dx = pc.x - cx(m); const dz = pc.z - cz(m); const len = Math.hypot(dx, dz) || 1; m.vx = (dx / len) * (m.speed || 60); m.vz = (dz / len) * (m.speed || 60); m.dir = dx >= 0 ? 1 : -1; m.faceYaw = Math.atan2(dz, dx); } function touchPlayer(m, pc, damage, knock = 1) { const d = dist3({ x: cx(m), y: cy(m), z: cz(m) }, pc); if (d < 1.6 && state.player.invuln <= 0) { state.player.hp -= calculateDamage(damage); state.player.invuln = 0.8; const dx = state.player.x - m.x; const dz = (state.player.z || 0) - (m.z || 0); const len = Math.hypot(dx, dz) || 1; state.player.vx += (dx / len) * 420 * knock; state.player.vz += (dz / len) * 420 * knock; state.player.vy -= 260 * knock; playSound('hit1'); return true; } return false; } function mobAI(m, dt) { updateWaterFlag3(m, dt); const pc = playerC(); const night = isNight(); // ---------- ночные мобы днём горят ---------- // ВАЖНО: не делаем ранний 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; } else { m.burning = false; } if (burning) { // горящий моб только падает и горит — никакого ИИ } else if (m.kind === 'zombie') { steerToPlayer(m, pc); if (m.inWater && Math.random() < 0.06) m.vy = -260; touchPlayer(m, pc, 15); } else if (m.kind === 'creeper') { steerToPlayer(m, pc); const d = dist3({ x: cx(m), y: cy(m), z: cz(m) }, pc); if (m.inWater && Math.random() < 0.06) m.vy = -260; if (d < 3) { m.fuse -= dt; if (m.fuse <= 0) { explodeTNT3D({ gx: Math.floor(cx(m)), gy: Math.floor(cy(m)), gz: Math.floor(cz(m)) }, state); m.hp = 0; } } else if (d < 2.5) { m.fuse = 0.5; } } else if (m.kind === 'skeleton') { steerToPlayer(m, pc); const d = dist3({ x: cx(m), y: cy(m), z: cz(m) }, pc); m.shootCooldown -= dt; if (d < 15 && m.shootCooldown <= 0) { m.shootCooldown = 2.0; const ox = m.x + m.w / 2, oy = m.y + m.h / 3, oz = (m.z || 0) + (m.d || m.w) / 2; const tx = state.player.x + state.player.w / 2; const ty = state.player.y + state.player.h / 2; const tz = (state.player.z || 0) + (state.player.d || state.player.w) / 2; // полёт стрелы по 3D const ddx = tx - ox, ddy = ty - oy, ddz = tz - oz; const dist = Math.hypot(ddx, ddy, ddz) || 1; const speed = 450; state.projectiles.push({ x: ox, y: oy, z: oz, vx: ddx / dist * speed, vy: ddy / dist * speed, vz: ddz / dist * speed, dmg: 6, owner: 'mob', life: 3, }); } } else if (m.kind === 'scorpion') { steerToPlayer(m, pc); if (m.inWater && Math.random() < 0.06) m.vy = -260; if (touchPlayer(m, pc, 8)) state.player.slowTimer = 3; } else if (m.kind === 'polar_bear') { // нейтрален: бродит m.aiT -= dt; if (m.aiT <= 0) { m.aiT = 2.0 + Math.random() * 3; const a = Math.random() * Math.PI * 2; m.walkX = Math.cos(a); m.walkZ = Math.sin(a); if (Math.random() < 0.3) { m.walkX = 0; m.walkZ = 0; } } m.vx = (m.walkX || 0) * m.speed; m.vz = (m.walkZ || 0) * m.speed; if (m.inWater) m.vy = -120; touchPlayer(m, pc, 10, 1.2); } else if (m.kind === 'slime') { // прыгает к игроку m.aiT -= dt; if (m.aiT <= 0) { m.aiT = 1.5 + Math.random() * 1.5; steerToPlayer(m, pc); m.vy = -200; } touchPlayer(m, pc, 8); } 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; m.vx = dx / hl * m.speed; m.vz = dz / hl * m.speed; m.vy = dy > 0 ? 60 : -60; } else { m.aiT -= dt; if (m.aiT <= 0) { m.aiT = 2 + Math.random() * 3; const a = Math.random() * Math.PI * 2; m.walkX = Math.cos(a); m.walkZ = Math.sin(a); } m.vx = (m.walkX || 0) * m.speed * 0.5; m.vz = (m.walkZ || 0) * m.speed * 0.5; m.vy = Math.sin(performance.now() / 1000) * 30; } if (d < 1.6 && state.player.invuln <= 0) { state.player.hp -= calculateDamage(10); state.player.invuln = 0.8; state.player.vy -= 200; playSound('hit1'); } m.vy *= 0.5; } else { // животные (pig, chicken): бродят m.aiT -= dt; if (m.aiT <= 0) { m.aiT = 1.8 + Math.random() * 2.5; const a = Math.random() * Math.PI * 2; m.walkX = Math.cos(a); m.walkZ = Math.sin(a); if (Math.random() < 0.25) { m.walkX = 0; m.walkZ = 0; } } const sp = m.kind === 'chicken' ? 55 : 40; m.vx = (m.walkX || 0) * sp; m.vz = (m.walkZ || 0) * sp; if (m.inWater) m.vy = -120; } // ---------- прыжок через препятствие по всем осям ---------- if (m.grounded && !m.flying && m.kind !== 'eagle') { const speed = Math.hypot(m.vx || 0, m.vz || 0); if (speed > 5) { // Проверяем отдельно по 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; } } // ---------- ФИЗИКА ---------- if (!m.flying) { const g = m.inWater ? GRAV_WATER : GRAV; m.vy += g * dt; } m.y += m.vy * dt; m.x += (m.vx || 0) * dt; m.z += (m.vz || 0) * dt; resolveX(m, dt); resolveZ(m, dt); resolveY(m, dt); // орёл не падает if (m.flying) m.grounded = false; } // Лут — та же таблица, что в 2D 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] || []; } // Выдаёт лут моба в инвентарь. Возвращает список выданных предметов. 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) ==================== // Возможные погоды по биому 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 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); } } 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, Math.floor((state.player.z || 0) / state.TILE)); 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: 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.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); } } // Убираем снег при смене погоды 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.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(); } 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(); } 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(); } } // === entities/mobs3d.js === // Сущности: животные + зомби + новые мобы class Entity { constructor(x, y, w, h) { this.x = x; this.y = y; this.z = 0; // третье измерение: глубина мира this.w = w; this.h = h; this.d = w; // размер по Z this.vx = 0; this.vy = 0; this.vz = 0; this.hp = 3; this.grounded = false; this.inWater = false; this.aiT = 0; this.dir = 1; } } class Pig extends Entity { constructor(x, y, z) { super(x, y, 34, 24); this.kind = 'pig'; this.z = z || 0; this.hp = 2; } } class Chicken extends Entity { constructor(x, y, z) { super(x, y, 26, 22); this.kind = 'chicken'; this.z = z || 0; this.hp = 1; } } class Zombie extends Entity { constructor(x, y, z) { super(x, y, 34, 50); this.kind = 'zombie'; this.z = z || 0; this.hp = 4; this.speed = 80 + Math.random() * 40; } } class Creeper extends Entity { constructor(x, y, z) { super(x, y, 34, 50); this.kind = 'creeper'; this.z = z || 0; this.hp = 4; this.speed = 60 + Math.random() * 30; this.fuse = 3.2; } } class Skeleton extends Entity { constructor(x, y, z) { super(x, y, 34, 50); this.kind = 'skeleton'; this.z = z || 0; this.hp = 4; this.speed = 70 + Math.random() * 30; this.shootCooldown = 0; } } class Scorpion extends Entity { constructor(x, y, z) { super(x, y, 26, 26); this.kind = 'scorpion'; this.z = z || 0; this.hp = 3; this.speed = 90; this.fuse = 0; this.shootCooldown = 0; } } class PolarBear extends Entity { constructor(x, y, z) { super(x, y, 40, 34); this.kind = 'polar_bear'; this.z = z || 0; this.hp = 8; this.speed = 50; this.fuse = 0; this.shootCooldown = 2; } } class Slime extends Entity { constructor(x, y, z) { super(x, y, 24, 24); this.kind = 'slime'; this.z = z || 0; this.hp = 2; this.speed = 30; this.fuse = 0; this.shootCooldown = 2; } } class Eagle extends Entity { constructor(x, y, z) { super(x, y, 30, 22); this.kind = 'eagle'; this.z = z || 0; this.hp = 3; this.speed = 120; this.fuse = 0; this.shootCooldown = 0; } } // === 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-handler3d.js === // ==================== ВЗАИМОДЕЙСТВИЕ 3D (мышь/тап) — GrechkaCraft v3 ==================== // Ключевое отличие от 2D: блок добывается/ставится ТОЛЬКО если игрок рядом // (в пределах REACH). Касание экрана вдали — лишь поворот камеры. // ---------- ДИСТАНЦИЯ ДОБЫЧИ (как в майнкрафте) ---------- const REACH = 5.2; // тайлов от центра игрока до центра блока const REACH_SQ = REACH * REACH; function distToBlockSq(gx, gy, gz) { const p = state.player; const TILE = state.TILE; const px = (p.x + p.w / 2) / TILE; const py = (p.y + p.h / 2) / TILE; const pz = ((p.z || 0) + (p.d || p.w) / 2) / TILE; const dx = gx + 0.5 - px; const dy = gy + 0.5 - py; const dz = (gz || 0) + 0.5 - pz; return dx * dx + dy * dy + dz * dz; } function withinReach(gx, gy, gz) { return distToBlockSq(gx, gy, gz) <= REACH_SQ; } // Моб тоже бьётся только вблизи function withinReachEntity(e) { const p = state.player; const TILE = state.TILE; const px = (p.x + p.w / 2) / TILE; const py = (p.y + p.h / 2) / TILE; const pz = ((p.z || 0) + (p.d || p.w) / 2) / TILE; const ex = (e.x + e.w / 2) / TILE; const ey = (e.y + e.h / 2) / TILE; const ez = ((e.z || 0) + (e.d || e.w) / 2) / TILE; const dx = ex - px, dy = ey - py, dz = ez - pz; return dx * dx + dy * dy + dz * dz <= REACH_SQ; } const CROP_SEED_MAP = { wheat_seeds: 'wheat_stage0', carrot: 'carrot_stage0', potato: 'potato_stage0', }; 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; } 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) { 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 }; let miningHeld = false; let miningInterval = null; function stopMining() { miningHeld = false; if (miningInterval) { clearInterval(miningInterval); miningInterval = null; } } // Основное действие. ВСЕ координаты — ТАЙЛОВЫЕ (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; // Защита от вызова в пикселях (старый путь из тач-кнопки). if (Math.abs(gx) > 512 || Math.abs(gy) > 512) { gx = Math.floor(gx / TILE); gy = Math.floor(gy / 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]) { const cand = getBlock(gx, gy + dy, z); if (cand) { gy += dy; b = cand; break; } } } 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') { state.player.sleeping = false; return; } if (state.player.sleeping) return; // Печь — открываем панель обжига if (b.t === 'furnace' && mode() === 'mine') { openFurnaceUI(gx, gy, z); return; } // ---------- Урон мобам (только вблизи) ---------- if (mode() === 'mine') { let handled = false; if (state.isMultiplayer) { for (const [id, sm] of state.serverMobs) { if (sm.dead) continue; if (!withinReachEntity(sm)) continue; if (state.player.invuln <= 0) { let dmg = 1; for (const st of ['iron_sword', 'stone_sword', 'wood_sword']) { if (state.inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; } } state.socket.emit('mob_hurt', { id, dmg }); playSound('attack'); handled = true; } break; } } if (!handled) { for (let i = state.mobs.length - 1; i >= 0; i--) { const m = state.mobs[i]; if (!withinReachEntity(m)) continue; let dmg = 1; for (const st of ['iron_sword', 'stone_sword', 'wood_sword']) { if (state.inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; } } m.hp -= dmg; const kx = (state.player.x + state.player.w / 2) - (m.x + m.w / 2); const kz = ((state.player.z || 0) + (state.player.d || state.player.w) / 2) - ((m.z || 0) + (m.d || m.w) / 2); m.vx += (kx / (Math.hypot(kx, kz) || 1)) * 400; m.vz += (kz / (Math.hypot(kx, kz) || 1)) * 400; m.vy -= 200; playSound('attack'); if (m.hp <= 0) { if (m.kind === 'chicken') playSound('hurt_chicken'); // Лут по виду моба. Раньше ВСЕ давали 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(); } handled = true; break; } } if (handled) return; } // ---------- Лук ---------- if (state.selected === 'bow' && state.inv.bow > 0 && state.inv.arrow > 0) { const p = state.player; const ox = p.x + p.w / 2, oy = p.y + p.h / 3, oz = (p.z || 0) + (p.d || p.w) / 2; const tx = wx, ty = wy, tz = (z + 0.5) * TILE; const dx = tx - ox, dy = ty - oy, dz = tz - oz; const dist = Math.hypot(dx, dy, dz) || 1; const speed = 550; state.projectiles.push({ x: ox, y: oy, z: oz, vx: dx / dist * speed, vy: dy / dist * speed, vz: dz / dist * speed, 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; } // ---------- Жарка на костре ---------- if (b.t === 'campfire' && state.selected === 'meat' && state.inv.meat > 0) { playSound('fire'); state.inv.meat--; state.inv.cooked = (state.inv.cooked || 0) + 1; rebuildHotbar(); return; } // ---------- Сон ---------- if (b.t === 'bed' && isNight()) { state.player.sleeping = true; saveGame(); return; } // ---------- ДОБЫЧА ---------- if (mode() === 'mine') { const def = BLOCKS[b.t]; if (def.fluid) return; if (def.decor && def.growthStage === undefined && !def.harvestable) { // декорации (цветы, грибы) ломаются мгновенно ниже, здесь только подстраховка return; } if (b.t === 'tnt') { activateTNT3D(b, 3.2); return; } const hardness = def.hardness || 1; if (hardness === Infinity) return; if (hardness === 0) { const removed = removeBlock(gx, gy, z); if (removed) { handleBlockDrop(removed, state.inv); sendBlockChange(gx, gy, z, removed.t, 'remove'); rebuildHotbar(); } return; } if (state.mining.gx === gx && state.mining.gy === gy && (state.mining.gz || 0) === z) { state.mining.progress += getMiningSpeed(b.t); } else { state.mining = { gx, gy, gz: z, 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) { finishMining(gx, gy, z, b.t); } else { startHoldMining(gx, gy, z, b.t); } return; } // ---------- СТРОИТЕЛЬСТВО ---------- if (mode() === 'build') { // посев на грядку const seedToCrop = CROP_SEED_MAP[state.selected]; if (seedToCrop && state.inv[state.selected] > 0) { if (b.t === 'farmland' && !getBlock(gx, gy - 1, z)) { setBlock(gx, gy - 1, z, seedToCrop, true); state.inv[state.selected]--; rebuildHotbar(); return; } if (!b) { const below = getBlock(gx, gy + 1, z); if (below && below.t === 'farmland') { setBlock(gx, gy, z, seedToCrop, true); state.inv[state.selected]--; rebuildHotbar(); return; } } } // цель — соседняя клетка по нормали грани let tx2 = gx, ty2 = gy, tz2 = z; if (face) { if (Math.abs(face.x) >= Math.abs(face.y) && Math.abs(face.x) >= Math.abs(face.z)) tx2 += Math.sign(face.x); else if (Math.abs(face.y) >= Math.abs(face.z)) ty2 += Math.sign(face.y); else tz2 += Math.sign(face.z); } else { ty2 -= 1; } // по Y: в игре +y вниз, three +y вверх → нормаль -y = вниз = +gy if (face && Math.abs(face.y) >= Math.abs(face.x) && Math.abs(face.y) >= Math.abs(face.z)) { ty2 = gy - Math.sign(face.y); } if (getBlock(tx2, ty2, tz2)) return; if (state.inv[state.selected] <= 0) return; if (!BLOCKS[state.selected]) return; // не ставим в себя const p = state.player; const bx = tx2 * TILE, by = ty2 * TILE, bz = tz2 * TILE; const overlap = !(bx >= p.x + p.w || bx + TILE <= p.x || by >= p.y + p.h || by + TILE <= p.y || bz >= (p.z || 0) + (p.d || p.w) || bz + TILE <= (p.z || 0)); if (overlap) return; setBlock(tx2, ty2, tz2, state.selected, true); state.inv[state.selected]--; sendBlockChange(tx2, ty2, tz2, 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 playSound('cloth1'); rebuildHotbar(); } } function finishMining(gx, gy, gz, type) { const def = BLOCKS[type]; const minTool = def.minTool; let drops = true; if (minTool) { const toolId = getBestTool(type); if (toolId) { const tool = TOOLS[toolId]; if (toolTierLessThan(tool.toolTier, minTool)) drops = false; else useTool(toolId); } else { drops = false; } } else { const toolId = getBestTool(type); if (toolId) useTool(toolId); } const removed = removeBlock(gx, gy, gz); if (removed) { if (drops) { handleBlockDrop(removed, state.inv); } else if (def.toolType === 'pickaxe') { state.inv.cobblestone = (state.inv.cobblestone || 0) + 1; } sendBlockChange(gx, gy, gz, removed.t, 'remove'); playSound('stone1'); rebuildHotbar(); } stopMining(); state.mining = { gx: null, gy: null, gz: null, progress: 0, hardness: 0 }; } function startHoldMining(gx, gy, gz, type) { miningHeld = true; if (miningInterval) clearInterval(miningInterval); miningInterval = setInterval(() => { if (!miningHeld) { clearInterval(miningInterval); miningInterval = null; return; } const mg = state.mining; if (mg.gx === null) { miningHeld = false; clearInterval(miningInterval); miningInterval = null; return; } const mb = getBlock(mg.gx, mg.gy, mg.gz || 0); if (!mb || mb.dead || mb.t !== type) { miningHeld = false; clearInterval(miningInterval); miningInterval = null; return; } if (!withinReach(mg.gx, mg.gy, mg.gz || 0)) { // отошли — добыча прекращается miningHeld = false; clearInterval(miningInterval); miningInterval = null; state.mining = { gx: null, gy: null, gz: null, progress: 0, hardness: 0 }; return; } mg.progress += getMiningSpeed(mb.t) * 0.3; if (mg.progress >= mg.hardness) { finishMining(mg.gx, mg.gy, mg.gz || 0, mb.t); } }, 100); } // Подсказка «слишком далеко» — один элемент, не спамит let hintEl = null; let hintTimer = null; function showReachHint() { if (!hintEl) { hintEl = document.createElement('div'); hintEl.style.cssText = 'position:fixed;left:50%;top:18%;transform:translateX(-50%);' + 'z-index:300;background:rgba(0,0,0,.72);color:#fff;padding:8px 14px;border-radius:8px;' + 'font:600 14px system-ui;pointer-events:none;'; document.body.appendChild(hintEl); } hintEl.textContent = 'Слишком далеко — подойди ближе'; hintEl.style.display = 'block'; clearTimeout(hintTimer); 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). // Ноль rig = ступни; высота рисунка ~3.07 юнита при PARTS-координатах. const HERO_COL = { blue: { hex: 0x0232fe, d: 0.60 }, white: { hex: 0xffffff, d: 0.86 }, cyan: { hex: 0x00a1ff, d: 1.00 }, navy: { hex: 0x001a85, d: 1.12 }, }; const HERO_PARTS = {"head":[[-0.90035,2.93184,0.40909,0.13636,"blue"],[-1.17308,2.79547,0.13636,0.13636,"blue"],[-0.62762,2.79547,0.13636,0.13636,"blue"],[-0.96853,2.65911,0.27273,0.13636,"blue"],[-0.49126,2.65911,0.13636,0.13636,"blue"],[-1.17308,2.52275,0.13636,0.13636,"blue"],[-0.62762,2.52275,0.13636,0.13636,"blue"],[0.12238,2.52275,1.09091,0.13636,"blue"],[-0.83217,2.38638,0.81818,0.13636,"blue"],[1.00874,2.38638,0.68182,0.13636,"blue"],[-0.83217,2.25002,0.27273,0.13636,"blue"],[1.41783,2.25002,0.13636,0.13636,"blue"],[-1.03671,2.11365,0.13636,0.13636,"blue"],[1.28147,2.11365,0.13636,0.13636,"blue"],[0.12238,2.38638,1.09091,0.13636,"white"],[0.32692,2.25002,2.04545,0.13636,"white"],[-0.6958,2.11365,0.54545,0.13636,"white"],[-0.15035,2.11365,0.27273,0.13636,"white"],[0.66783,2.11365,1.09091,0.13636,"white"],[-0.3549,2.11365,0.13636,0.13636,"cyan"],[0.0542,2.11365,0.13636,0.13636,"cyan"]],"body":[[-1.17308,1.97729,0.13636,0.13636,"blue"],[1.1451,1.97729,0.13636,0.13636,"blue"],[-0.96853,1.84093,0.27273,0.13636,"blue"],[1.28147,1.77275,0.13636,0.27273,"blue"],[-0.90035,1.70456,0.13636,0.13636,"blue"],[-1.1049,1.5682,0.27273,0.13636,"blue"],[-0.42308,1.5682,0.27273,0.13636,"blue"],[0.53147,1.5682,0.27273,0.13636,"blue"],[1.07692,1.5682,0.27273,0.13636,"blue"],[1.41783,1.5682,0.13636,0.13636,"blue"],[-1.30944,1.43184,0.13636,0.13636,"blue"],[1.00874,1.43184,0.13636,0.13636,"blue"],[1.28147,1.43184,0.13636,0.13636,"blue"],[-1.17308,1.15911,0.13636,0.40909,"blue"],[1.1451,1.22729,0.13636,0.27273,"blue"],[-1.4458,1.02275,0.13636,0.13636,"blue"],[1.00874,1.02275,0.13636,0.13636,"blue"],[-1.4458,0.88638,0.40909,0.13636,"blue"],[-0.96853,0.88638,0.27273,0.13636,"blue"],[-0.08217,0.88638,0.68182,0.13636,"blue"],[0.87238,0.88638,0.13636,0.13636,"blue"],[-1.51399,0.75002,0.54545,0.13636,"blue"],[-0.90035,0.75002,0.13636,0.13636,"blue"],[0.66783,0.75002,0.27273,0.13636,"blue"],[1.00874,0.75002,0.13636,0.13636,"blue"],[-0.6958,1.97729,0.81818,0.13636,"white"],[-0.01399,1.97729,0.27273,0.13636,"white"],[0.8042,1.97729,0.54545,0.13636,"white"],[-0.6958,1.77275,0.27273,0.27273,"white"],[-0.21853,1.84093,0.13636,0.13636,"white"],[0.32692,1.84093,0.13636,0.13636,"white"],[0.94056,1.77275,0.54545,0.27273,"white"],[0.0542,1.63638,0.68182,0.27273,"white"],[-0.76399,1.5682,0.40909,0.13636,"white"],[0.8042,1.5682,0.27273,0.13636,"white"],[1.28147,1.5682,0.13636,0.13636,"white"],[-1.1049,1.43184,0.27273,0.13636,"white"],[-0.01399,1.43184,1.36364,0.13636,"white"],[-0.21853,1.29547,1.5,0.13636,"white"],[0.87238,1.29547,0.40909,0.13636,"white"],[-0.90035,1.15911,0.13636,0.13636,"white"],[-0.08217,1.15911,1.22727,0.13636,"white"],[0.73601,1.15911,0.13636,0.13636,"white"],[1.00874,1.15911,0.13636,0.13636,"white"],[-1.03671,1.02275,0.13636,0.13636,"white"],[-0.01399,1.02275,1.36364,0.13636,"white"],[-0.62762,0.88638,0.40909,0.13636,"white"],[0.53147,0.88638,0.54545,0.13636,"white"],[-0.15035,0.75002,1.36364,0.13636,"white"],[0.87238,0.75002,0.13636,0.13636,"white"],[-0.21853,1.97729,0.13636,0.13636,"cyan"],[0.32692,1.97729,0.40909,0.13636,"cyan"],[0.0542,1.84093,0.40909,0.13636,"cyan"],[-0.83217,1.43184,0.27273,0.13636,"cyan"],[0.8042,1.43184,0.27273,0.13636,"cyan"],[-1.03671,1.22729,0.13636,0.27273,"cyan"],[0.59965,1.22729,0.13636,0.27273,"cyan"],[-0.76399,1.15911,0.13636,0.13636,"cyan"],[0.87238,1.15911,0.13636,0.13636,"cyan"],[-0.83217,1.02275,0.27273,0.13636,"cyan"],[0.8042,1.02275,0.27273,0.13636,"cyan"]],"tail":[[-1.71853,0.61365,0.13636,0.13636,"blue"],[-1.03671,0.61365,0.13636,0.13636,"blue"],[-0.62762,0.61365,0.40909,0.13636,"blue"],[-1.65035,0.47729,0.27273,0.13636,"blue"],[-0.90035,0.47729,0.13636,0.13636,"blue"],[-1.4458,0.34093,0.40909,0.13636,"blue"],[-0.62762,0.34093,0.40909,0.13636,"blue"],[-0.90035,0.20456,0.95455,0.13636,"blue"],[-0.90035,0.61365,0.13636,0.13636,"white"]],"legs":[[0.3951,0.61365,0.27273,0.13636,"blue"],[0.87238,0.61365,0.13636,0.13636,"blue"],[-0.55944,0.47729,0.27273,0.13636,"blue"],[0.32692,0.34093,0.13636,0.40909,"blue"],[-0.21853,0.20456,0.13636,0.13636,"blue"],[0.0542,0.20456,0.13636,0.13636,"blue"],[-0.08217,0.0682,0.95455,0.13636,"blue"],[-0.3549,0.47729,0.13636,0.13636,"white"],[-0.08217,0.34093,0.68182,0.13636,"white"],[-0.3549,0.20456,0.13636,0.13636,"white"],[-0.08217,0.20456,0.13636,0.13636,"white"],[0.19056,0.20456,0.13636,0.13636,"white"],[-0.08217,0.61365,0.68182,0.13636,"cyan"],[-0.01399,0.47729,0.54545,0.13636,"cyan"]],"eye":[[-0.42308,1.77275,0.27273,0.27273,"navy"],[0.53147,1.77275,0.27273,0.27273,"navy"]]}; const HERO_PIVOT = {"head":[0.0,2.5909],"body":[0.0,0.979],"legs":[-0.035,0.9091],"tail":[-0.7343,1.3287],"eye":[0.0524,2.1294]}; 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. Рендерим кубы по всем трём // осям, отсекаем те, что не видны с камеры (соседи со всех шести сторон). let TD = null; function TDparseColor(c) { const m = /rgba?\(([^)]+)\)/.exec(c); if (!m) return { color: new THREE.Color(c), opacity: 1 }; const p = m[1].split(',').map(x => parseFloat(x)); return { color: new THREE.Color(p[0] / 255, p[1] / 255, p[2] / 255), opacity: p.length > 3 ? p[3] : 1 }; } function TDblockMat(t) { const def = BLOCKS[t] || {}; const { color, opacity } = TDparseColor(def.c || '#ff00ff'); const a = def.alpha !== undefined ? def.alpha : opacity; 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]; if (!d) return false; 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 = {}; 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 j = HERO_PIVOT[name]; g.position.set(j[0] * HERO_SCALE, j[1] * HERO_SCALE, 0); 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 [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, slime: 0x4caf50, eagle: 0x5d4037, }; function TDnameSprite(name) { const cv = document.createElement('canvas'); cv.width = 256; cv.height = 48; const g = cv.getContext('2d'); g.font = 'bold 26px system-ui'; g.textAlign = 'center'; g.fillStyle = 'rgba(0,0,0,0.45)'; g.fillRect(0, 0, 256, 48); g.fillStyle = '#fff'; g.fillText(String(name || '?').slice(0, 14), 128, 33); const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(cv), depthTest: false })); sp.scale.set(2.2, 0.41, 1); return sp; } // ==================== INIT ==================== function TDinit() { const canvas = state.canvas; // 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; } renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.BasicShadowMap; const scene = new THREE.Scene(); scene.background = new THREE.Color(0x8fc7f5); // Туман заканчивается на границе прорисовки (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); // Свет: сумма была 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.06); scene.add(hemi, sun, sun.target, amb); const hero = TDbuildHeroRig(); scene.add(hero.group); // Рабочая область: только блоки рядом с игроком (по всем осям) const crack = new THREE.Mesh( new THREE.BoxGeometry(1.004, 1.004, 1.004), new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0 }) ); crack.visible = false; scene.add(crack); const ghost = new THREE.Mesh( new THREE.BoxGeometry(1.02, 1.02, 1.02), new THREE.MeshBasicMaterial({ color: 0xffffff, wireframe: true, transparent: true, opacity: 0.9 }) ); ghost.visible = false; scene.add(ghost); // куб подсветки выбранного блока const outline = new THREE.LineSegments( 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 = []; // Облака: 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 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); } 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(); } // ==================== БЛОКИ ==================== // Полный пересбор мешей редкий; в обычном кадре — только обновление матриц. // Радиус прорисовки. Должен ЗНАЧИТЕЛЬНО превышать дистанцию камеры // (макс 34 тайла) — иначе за краем сетки видно пустое небо, и мир выглядит // парящим островом. 46 тайлов при тумане с 1000 до 1500: край рельефа // успевает уйти в туман, а не обрываться в пустоту. const VIEW_R = 46; // тайлов по всем осям // Границы плотной сетки: за ними 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 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) { TD.scene.remove(e.mesh); e.mesh.geometry.dispose(); e.mesh.material.dispose(); } TD.blockMeshes = []; const byType = new Map(); for (const b of state.blocks) { if (b.dead) continue; if (!BLOCKS[b.t]) continue; 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); } const boxGeo = new THREE.BoxGeometry(1, 1, 1); const planeGeo = new THREE.PlaneGeometry(0.92, 0.92); for (const [t, list] of byType) { const fluid = TDisFluid(t); const flat = !fluid && TDisFlat(t); const mesh = new THREE.InstancedMesh(flat ? planeGeo : boxGeo, TDblockMat(t), list.length); mesh.castShadow = !flat; mesh.receiveShadow = true; if (flat) mesh.material.side = THREE.DoubleSide; const m = new THREE.Matrix4(); const q = new THREE.Quaternion(); const e = new THREE.Euler(); for (let i = 0; i < list.length; i++) { const b = list[i]; if (flat) { // плоские блоки (цветы) — поворачиваем к камере, стоят на земле e.set(-Math.PI / 2, 0, 0); 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.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); } mesh.setMatrixAt(i, m); } mesh.instanceMatrix.needsUpdate = true; mesh.userData.list = list; TD.scene.add(mesh); TD.blockMeshes.push({ mesh, type: t }); } } // ==================== PICK (рейкаст по 3D) ==================== const TDray = new THREE.Raycaster(); const TDndc = new THREE.Vector2(); function TDpick(sx, sy) { const r = state.canvas.getBoundingClientRect(); 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); 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; // ТОЧНЫЕ координаты блока берём из 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 }; } // ==================== ВВОД ==================== function TDbindInput(canvas) { canvas.addEventListener('pointerdown', (e) => { TD.ptrs.set(e.pointerId, { x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, moved: false, t: performance.now() }); if (TD.ptrs.size === 2) { const q = [...TD.ptrs.values()]; TD.pinchD = Math.hypot(q[0].x - q[1].x, q[0].y - q[1].y); } stopMining(); }); canvas.addEventListener('pointermove', (e) => { const q = TD.ptrs.get(e.pointerId); if (!q) return; const dx = e.clientX - q.x, dy = e.clientY - q.y; q.x = e.clientX; q.y = e.clientY; if (Math.hypot(e.clientX - q.sx, e.clientY - q.sy) > 8) q.moved = true; if (TD.ptrs.size === 2) { const v = [...TD.ptrs.values()]; const d = Math.hypot(v[0].x - v[1].x, v[0].y - v[1].y); if (TD.pinchD > 0 && d > 0) TD.dist = Math.min(34, Math.max(4, TD.dist * TD.pinchD / d)); TD.pinchD = d; return; } if (q.moved) { TD.yaw -= dx * 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) { 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); canvas.addEventListener('pointercancel', (e) => { TD.ptrs.delete(e.pointerId); TD.pinchD = 0; }); canvas.addEventListener('contextmenu', (e) => e.preventDefault()); canvas.addEventListener('wheel', (e) => { e.preventDefault(); e.stopPropagation(); TD.dist = Math.min(34, Math.max(4, TD.dist * (1 + e.deltaY * 0.001))); }, { passive: false, capture: true }); } // ==================== СУЩНОСТИ ==================== function TDmobMesh(m) { let e = TD.mobs.get(m); if (!e) { const g = new THREE.Group(); // Точные копии мобов 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: bodyG }; TD.mobs.set(m, e); } return e; } function TDplayerEntry(id, p) { let e = TD.players.get(id); if (!e) { const rig = TDbuildHeroRig(); const label = TDnameSprite(p.name); label.position.y = 2.2; rig.group.add(label); TD.scene.add(rig.group); e = { rig, label }; TD.players.set(id, e); } return e; } // ==================== КАДР ==================== function renderFrame3D(now) { if (!TD) TDinit(); const p = state.player; const TILE = state.TILE; TD.frame++; const dt = Math.min(0.05, Math.max(0.001, (now - (TD.lastT || now - 16)) / 1000)); TD.lastT = now; const ptx = (p.x + (p.w || 34) / 2) / TILE; const pgy = (p.y + (p.h || 34) / 2) / TILE; // игровый Y (вниз) const py3 = -pgy; // three Y (вверх) const ptz = ((p.z || 0) + (p.d || 34) / 2) / TILE; // пересборка мешей: при изменении мира или раз в ~2 сек (для плавной подгрузки) // пересборка не чаще раза в 700 мс и только при изменении мира const ver = state.blocksVersion || 0; if ((ver !== TD.lastVersion && now - TD.meshT > 700) || !TD.blockMeshes.length) { TD.meshT = now; TD.lastVersion = ver; TDrebuildBlocks(ptx, pgy, ptz); } // небо/свет. Первые секунды после старта держим полдень, чтобы не было «рассвета» const night = isNight(); const sky = skyColor(night, 'plains', state.weatherType, state.worldTime); TD.scene.background.set(sky); TD.scene.fog.color.copy(TD.scene.background); TD.sun.intensity = night ? 0.14 : 1.5; TD.hemi.intensity = night ? 0.3 : 0.85; TD.amb.intensity = night ? 0.34 : 0.25; if (state.lightningFlash > 0) TD.hemi.intensity += Math.min(2.2, state.lightningFlash * 2.4); TD.sun.position.set(ptx + 20, py3 + 36, ptz + 20); TD.sun.target.position.set(ptx, py3, ptz); TD.sun.target.updateMatrixWorld(); // камера-орбита; yaw — тот же угол, что и для WASD camDir.yaw = TD.yaw; const cp = Math.cos(TD.pitch), sp2 = Math.sin(TD.pitch); // Камера на уровне центра игрока. Поднимать базовую высоту нельзя: // при 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.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; 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; const seen = new Set(); for (const m of allMobs) { if (m.dead) continue; seen.add(m); const e = TDmobMesh(m); e.body.scale.set((m.w || 30) / TILE, (m.h || 30) / TILE, (m.d || m.w || 30) / TILE); e.g.position.set( (m.x + (m.w || 30) / 2) / TILE, -(m.y + (m.h || 30) / 2) / TILE, ((m.z || 0) + (m.d || m.w || 30) / 2) / TILE ); } for (const [m, e] of TD.mobs) { if (!seen.has(m)) { TD.scene.remove(e.g); TD.mobs.delete(m); } } // игроки сети const seenP = new Set(); if (state.otherPlayers) { for (const [id, pl] of state.otherPlayers) { seenP.add(id); const e = TDplayerEntry(id, pl); e.rig.group.position.set((pl.x + 17) / TILE, -pl.y / TILE, ((pl.z || 0) + 17) / TILE); } } for (const [id, e] of TD.players) { if (!seenP.has(id)) { TD.scene.remove(e.rig.group); TD.players.delete(id); } } // лодка if (state.boat && state.boat.active) { TD.boatMesh.visible = true; TD.boatMesh.position.set(state.boat.x / TILE, -state.boat.y / TILE, (state.boat.z || 0) / TILE); } else TD.boatMesh.visible = false; // снаряды const projs = state.projectiles || []; for (let i = 0; i < TD.projPool.length; i++) { const mesh = TD.projPool[i]; const pr = projs[i]; if (!pr) { mesh.visible = false; continue; } mesh.visible = true; mesh.material.color.set(pr.owner === 'mob' ? 0xc0392b : 0xf1c40f); mesh.position.set(pr.x / TILE, -pr.y / TILE, (pr.z || 0) / TILE); mesh.rotation.z = -Math.atan2(pr.vy, pr.vx); } // частицы const parts = state.parts || []; const n = Math.min(parts.length, TD.PMAX); for (let i = 0; i < n; i++) { const pt = parts[i]; TD.pPos[i * 3] = pt.x / TILE; TD.pPos[i * 3 + 1] = -pt.y / TILE; TD.pPos[i * 3 + 2] = (pt.z || 0) / TILE; let cc = TD.colCache.get(pt.c); if (!cc) { cc = new THREE.Color(pt.c || '#ffffff'); TD.colCache.set(pt.c, cc); } TD.pCol[i * 3] = cc.r; TD.pCol[i * 3 + 1] = cc.g; TD.pCol[i * 3 + 2] = cc.b; } TD.points.geometry.setDrawRange(0, n); TD.points.geometry.attributes.position.needsUpdate = true; TD.points.geometry.attributes.color.needsUpdate = true; // облака const clouds = state.clouds || []; for (let i = 0; i < TD.cloudMeshes.length; i++) { const cm = TD.cloudMeshes[i]; const c = clouds.length ? clouds[i % clouds.length] : null; if (!c) { cm.visible = false; continue; } cm.visible = true; cm.scale.set((c.w || 120) / TILE, 1, 1); // Выше и дальше игрока: раньше облака висели в 12 тайлах по Z прямо перед // камерой и перекрывали обзор серой пеленой. cm.position.set(c.x / TILE, -c.y / TILE, ptz - 30); } // огни if (TD.frame % 20 === 0 || !TD.fireCache) { const found = []; for (const b of state.blocks) { if (b.dead) continue; const def = BLOCKS[b.t]; if (!def) continue; const lit = def.lightRadius || b.t === 'campfire' || (b.t === 'furnace' && state.activeFurnaces && state.activeFurnaces.has(b.gx + ',' + b.gy + ',' + (b.gz || 0))); if (!lit) continue; const dx = b.gx - ptx, dy = -b.gy + pty, dz = (b.gz || 0) - ptz; found.push({ t: b.t, gx: b.gx, gy: b.gy, gz: b.gz || 0, d: dx * dx + dy * dy + dz * dz }); } found.sort((a, b) => a.d - b.d); TD.fireCache = found.slice(0, 8); } for (const L of TD.lightPool) L.intensity = 0; for (const f of TD.flamePool) f.visible = false; let li = 0, fi = 0; for (const src of (TD.fireCache || [])) { if (li < TD.lightPool.length) { const L = TD.lightPool[li++]; L.position.set(src.gx + 0.5, -src.gy + 0.5, src.gz + 0.5); L.intensity = 1.6 + Math.sin(now / 130 + src.gx) * 0.35; } if ((src.t === 'campfire' || src.t === 'furnace') && fi < TD.flamePool.length) { const f = TD.flamePool[fi++]; f.visible = true; f.position.set(src.gx + 0.5, -src.gy + 0.78 + Math.sin(now / 90 + src.gy) * 0.06, src.gz + 0.5); const sc2 = 0.9 + Math.sin(now / 70 + src.gx * 2) * 0.18; f.scale.set(sc2, sc2 * (1 + Math.sin(now / 55) * 0.12), sc2); } } // погода if (state.isRaining) { TD.rain.pts.visible = true; for (let i = 0; i < TD.rain.n; i++) { let y = TD.rain.pos[i * 3 + 1] - dt * 26; let x = TD.rain.pos[i * 3]; if (y < py3 - 12) { y = py3 + 12; x = ptx + (Math.random() - 0.5) * 40; } TD.rain.pos[i * 3] = x; TD.rain.pos[i * 3 + 1] = y; TD.rain.pos[i * 3 + 2] = ptz + 6; } TD.rain.pts.geometry.attributes.position.needsUpdate = true; } else TD.rain.pts.visible = false; if (state.weatherType === 'snow') { TD.snow.pts.visible = true; for (let i = 0; i < TD.snow.n; i++) { let y = TD.snow.pos[i * 3 + 1] - dt * 2; let x = TD.snow.pos[i * 3] + Math.sin(now / 900 + i) * dt * 1.5; if (y < py3 - 12) { y = py3 + 12; x = ptx + (Math.random() - 0.5) * 40; } TD.snow.pos[i * 3] = x; TD.snow.pos[i * 3 + 1] = y; TD.snow.pos[i * 3 + 2] = ptz + 6; } TD.snow.pts.geometry.attributes.position.needsUpdate = true; } else TD.snow.pts.visible = false; // подсветка блока под курсором / прогресс добычи const mg = state.mining; if (mg && mg.gx !== null && mg.hardness > 0) { TD.crack.visible = true; TD.crack.position.set(mg.gx + 0.5, -mg.gy - 0.5, (mg.gz || 0) + 0.5); TD.crack.material.opacity = Math.min(0.55, (mg.progress / mg.hardness) * 0.55); } else TD.crack.visible = false; if (mode() === 'build' && TD.lastTap) { TD.ghost.visible = true; TD.ghost.position.set(TD.lastTap.gx + 0.5, -TD.lastTap.gy - 0.5, (TD.lastTap.gz || 0) + 0.5); } else TD.ghost.visible = false; if (state.W !== window.innerWidth || state.H !== window.innerHeight) TDresize(); TD.camera.aspect = window.innerWidth / window.innerHeight; TD.camera.updateProjectionMatrix(); TD.renderer.render(TD.scene, TD.camera); } // === game/loop3d.js === // ==================== ЦИКЛ ИГРЫ 3D — GrechkaCraft v3 ==================== // Всё как в 2D-игре, но движение по трём осям: X (вперёд-назад по миру), // Y (вверх-вниз), Z (вбок). WASD работают относительно взгляда камеры. let last = performance.now(); let prevJump = false; document.addEventListener('visibilitychange', () => { if (!document.hidden) last = performance.now(); }); // Направление взгляда камеры (задаётся рендером) — WASD относительно него 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; const steps = Math.max(1, Math.ceil(rawDt / PHYSICS_STEP)); const dt = rawDt / steps; const player = state.player; const inp = state.inp; const mobs = state.mobs; 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; // ---------- день/ночь ---------- 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()) player.sleeping = false; } else { state.worldTime += dt / state.DAY_LEN; } if (state.worldTime >= 1) state.worldTime -= 1; // ---------- генерация мира ---------- // Мир один и фиксированный: строится целиком порциями, ничего не вычищается. ensureGenAroundCamera(); state.worldGenReady = isWorldReady(); state.worldGenProgress = worldGenProgress(); // ---------- кислород/утопление ---------- if (player.headInWater) { player.o2 = Math.max(0, player.o2 - 6 * dt); if (player.o2 === 0) player.hp -= calculateDamage(4 * dt); } else { player.o2 = Math.min(100, player.o2 + 10 * dt); } player.hunger = Math.max(0, player.hunger - dt * 0.2); // ---------- температура по биому (теперь биом 2D) ---------- 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 pGX = Math.floor((player.x + player.w / 2) / state.TILE); const pGZ = Math.floor((player.z + player.d / 2) / state.TILE); const pGY = Math.floor((player.y + player.h / 2) / state.TILE); const biome = getCachedBiome(pGX, pGZ); const sGY = surfaceGyAt(pGX, pGZ); const isUnderground = (pGY - sGY) > 2; let targetTemp = BIOME_TEMP[biome] || 15; if (isUnderground) targetTemp = 15; if (isNight() && !isUnderground) targetTemp -= 10; if ((state.weatherType === 'rain' || state.weatherType === 'storm') && !isUnderground) targetTemp -= 5; player.temperature += (targetTemp - player.temperature) * dt * 0.3; // костры/факелы греют, вода в пустыне холодит — ищем в кубе вокруг let nearHeat = false, nearCool = false; for (let dx = -2; dx <= 2 && !nearHeat; dx++) { for (let dy = -2; dy <= 2 && !nearHeat; dy++) { for (let dz = -2; dz <= 2 && !nearHeat; dz++) { const b = getBlock(pGX + dx, pGY + dy, pGZ + dz); if (b && (b.t === 'campfire' || b.t === 'torch' || b.t === 'furnace')) nearHeat = true; } } } if (!nearHeat && biome === 'desert') { for (let dx = -2; dx <= 2 && !nearCool; dx++) { for (let dz = -2; dz <= 2 && !nearCool; dz++) { const b = getBlock(pGX + dx, pGY, pGZ + dz); if (b && b.t === 'water') 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 (isUnderground && !nearHeat && 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, pGZ); if (aboveBlk && BLOCKS[aboveBlk.t] && BLOCKS[aboveBlk.t].solid && aboveBlk.t !== 'glass' && !isUnderground && !nearCool) { player.temperature -= 5 * dt; } if (player.temperature < COLD_THRESHOLD) { player.hp -= 1 * dt; player.hunger -= 0.5 * dt; } if (player.slowTimer > 0) player.slowTimer -= dt; // ---------- кактус: урон в кубе ---------- for (let dx = -1; dx <= 1; dx++) { for (let dy = -1; dy <= 1; dy++) { for (let dz = -1; dz <= 1; dz++) { const cb = getBlock(pGX + dx, pGY + dy, pGZ + dz); if (cb && BLOCKS[cb.t] && BLOCKS[cb.t].hurt && player.invuln <= 0) { player.hp -= 2; player.invuln = 0.5; } } } } // ---------- лёд: скольжение ---------- const underBlk = getBlock(pGX, pGY - 1, pGZ); if (underBlk && BLOCKS[underBlk.t] && BLOCKS[underBlk.t].slip && player.grounded) { player.vx *= 0.98; player.vz *= 0.98; } // ---------- ДВИЖЕНИЕ: WASD относительно камеры ---------- if (player.sleeping) { player.vx = player.vy = player.vz = 0; } else { // Оси ввода -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); let dx = side * cy - fwd * sy; 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 * speed; player.vz = dz * speed; player.facing = Math.atan2(dz, dx); } else { player.vx *= 0.82; player.vz *= 0.82; } } // шаги if (player.grounded && !player.inWater && Math.hypot(player.vx, player.vz) > 50) { if (now / 1000 - player.lastStepTime > 0.35) { playSound('step'); player.lastStepTime = now / 1000; } } // ---------- прыжок / плавание / лодка ---------- if (player.inBoat) { player.vy = 0; player.x = boat.x + 2; player.y = boat.y - 4; player.z = boat.z + 2; player.grounded = true; player.inWater = false; if (jumpPressed) { state.inv.boat = (state.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.vz *= 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; boat.z += (boat.vz || 0) * dt; const boatGX = Math.floor(boat.x / state.TILE); const boatGY = Math.floor(boat.y / state.TILE); const boatGZ = Math.floor(boat.z / state.TILE); const below = getBlock(boatGX, boatGY + 1, boatGZ); if (!below || (below.t !== 'water' && below.t !== 'swamp_water')) { state.inv.boat = (state.inv.boat || 0) + 1; player.inBoat = false; boat.active = false; player.y += state.TILE; player.vy = -200; playSound('splash'); } } if (player.inBoat && !boat.active) { state.inv.boat = (state.inv.boat || 0) + 1; player.inBoat = false; player.y += state.TILE; player.vy = -200; playSound('splash'); } // ---------- границы мира по Z ---------- const maxZ = WORLD_DEPTH * state.TILE; 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++) { resolveX(player, sub); resolveZ(player, sub); resolveY(player, sub); } // лестница: вертикальное перемещение if (onLadder(player)) { if (inp.j) player.vy = -200; else if (inp.s || inp.down) player.vy = 100; else player.vy = 0; } // урон от падения: дистанция от последнего касания земли if (player.grounded) { if (player.fallStartY !== undefined) { const fallDist = player.fallStartY - player.y; if (fallDist > state.TILE * 6) { player.hp -= calculateDamage((fallDist / state.TILE - 6) * 2.5); } } player.fallStartY = player.y; } else if (player.vy > 0 && player.fallStartY === undefined) { player.fallStartY = player.y; } // вода updateWaterFlag3(player, dt); sendPlayerPosition(); updateWeather(dt); updateRain(dt); player.invuln = Math.max(0, player.invuln - dt); // ---------- мобы ---------- state.spawnT += dt; if (state.spawnT > 6) { state.spawnT = 0; spawnMobs3D(); } for (const m of mobs) { mobAI(m, dt); // физика 3D внутри mob-ai3d } // снаряды for (let i = projectiles.length - 1; i >= 0; i--) { const pr = projectiles[i]; pr.x += pr.vx * dt; pr.y += pr.vy * dt; pr.life -= dt; // столкновение с блоком (по Z игрока/снаряда) const gx = Math.floor(pr.x / state.TILE); const gy = Math.floor(pr.y / state.TILE); const gz = Math.floor((pr.z || player.z) / state.TILE); const b = getBlock(gx, gy, gz); if (b && BLOCKS[b.t] && BLOCKS[b.t].solid) { projectiles.splice(i, 1); continue; } if (pr.life <= 0) projectiles.splice(i, 1); } // ---------- TNT ---------- for (const b of state.blocks) { if (b.dead || b.t !== 'tnt' || !b.active) continue; b.fuse -= dt; if (b.fuse <= 0) { explodeTNT3D(b, state); } } // ---------- печь ---------- tickFurnaces(dt); if (state.currentFurnaceKey && Math.random() < 0.1) renderFurnaceUI(); // ---------- смерть/возрождение ---------- if (player.hp <= 0 && state.deathEl && state.deathEl.style.display !== 'flex') { player.hp = 0; state.deathEl.style.display = 'flex'; } else if (player.hp > 0 && state.deathEl && state.deathEl.style.display === 'flex') { state.deathEl.style.display = 'none'; } // ---------- HUD ---------- if (Math.random() < 0.25) { state.hpEl.textContent = Math.max(0, Math.ceil(player.hp)); state.foodEl.textContent = Math.ceil(player.hunger); const o2El = document.getElementById('o2'); if (o2El) o2El.textContent = Math.ceil(player.o2); 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 = pGX; 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'; state.playerCountEl.textContent = state.otherPlayers.size + 1; } else { const st = document.getElementById('multiplayerStatus'); if (st) st.style.display = 'none'; } } // сон — DOM-оверлей const sleepOv = document.getElementById('sleepOverlay'); if (sleepOv) sleepOv.style.display = player.sleeping ? 'flex' : 'none'; // ---------- РЕНДЕР ---------- renderFrame3D(now); requestAnimationFrame(loop); } // ---------- спавн мобов в 3D ---------- function spawnMobs3D() { const p = state.player; const pcx = p.x / state.TILE; const pcy = p.y / state.TILE; const pcz = (p.z || 0) / state.TILE; const kinds = [Zombie, Pig, Chicken, Creeper, Skeleton, Scorpion, PolarBear, Slime, Eagle]; for (let i = 0; i < 2; i++) { const ang = Math.random() * Math.PI * 2; const dist = 14 + Math.random() * 12; const mx = Math.floor(pcx + Math.cos(ang) * dist); const mz = Math.floor(pcz + Math.sin(ang) * dist); if (Math.abs(mz) > WORLD_DEPTH) continue; const sy = surfaceGyAt(mx, mz) - 1; if (sy < 1) continue; const K = kinds[Math.floor(Math.random() * kinds.length)]; const m = new K(mx * state.TILE, sy * state.TILE, mz * state.TILE); m.z = mz * state.TILE; m.d = m.d || m.w; m.vz = 0; state.mobs.push(m); } } // === 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'; }); } } 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, 0); 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); } } } // === 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 (!marketContent) return; 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 => '
' + (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', () => { 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) { 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; 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 ==='); }; } // === input/controls3d.js === // ==================== УПРАВЛЕНИЕ 3D — клавиатура + мышь ==================== // Клавиши работают напрямую через state.inp.fwd / state.inp.side (аналоговые оси -1..1), // поэтому движение на компе идёт по той же схеме, что и джойстик на телефоне. // клавиши, которые не должны скроллить страницу/открывать меню const SWALLOW = new Set([ 'Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD', ]); // истина, если игрок сейчас печатает в поле ввода 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() { window.addEventListener('keydown', (e) => { if (typing()) return; if (e.repeat) return; if (SWALLOW.has(e.code)) e.preventDefault(); applyKey(e.code, true); }); window.addEventListener('keyup', (e) => { 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 === // Голосовой чат 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 }); } } }; } // === ui/craft.js === // Craft UI 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 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'; } 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(); renderInventory(); }; 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'; } // === main3d.js === // ==================== ENTRY POINT ==================== dismissBootScreen(); // ==================== 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'; // Диаг-доступ только по ?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(); // 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; // ==================== 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(); initJoystick(); // ==================== 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(); // 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('Загружено сохранение, 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; // spawnPoint.y копируется из player.y (см. save.js:130), то есть это // ВЕРХ коллайдера — вычитать высоту нельзя. 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(); state.player.hp = 100; state.player.hunger = 100; state.player.o2 = 100; state.player.vx = state.player.vy = 0; state.player.invuln = 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; // Предгенерация вокруг спавна. Радиус по 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); } } // после генерации ищем сухую опору: идём вверх, пока под ноздрями вода/болото // Ищем тайл ТВЁРДОГО блока-опоры: самый верхний твёрдый блок колонки, // над которым не вода. Раньше 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, 'biome=', getCachedBiome(startGX, startGZ), 'gy=', startGY, 'y=', state.player.y); } // Автосейв при скрытии страницы document.addEventListener('visibilitychange', () => { if (document.hidden) { saveGame(); } }); // Автосейв перед закрытием страницы window.addEventListener('beforeunload', () => { saveGame(); }); }).catch(err => { console.error('Ошибка инициализации:', err); // Аварийный старт: только колонка игрока, остальное достроит pumpWorldGen // из игрового цикла. Раньше здесь генерировалась узкая полоса 9x40, из-за // чего мир выглядел островом. const startGX = 6, startGZ = 0; genColumn(startGX, startGZ); state.player.y = surfaceGyAt(startGX, startGZ) * state.TILE - state.player.h; state.player.fallStartY = state.player.y; }); // ==================== 13) LOOP — startLoop ==================== // Мир строится порциями в цикле, поэтому сначала запускаем цикл, затем // ждём готовности с прогрессом на экране. requestAnimationFrame(loop); // Ставим игрока на поверхность ДО генерации мира, чтобы он не начал // падать в пустоту, и повторяем после готовности. ensurePlayerOnGround(); await waitWorldReady(); ensurePlayerOnGround(); // После фикса игрок может стоять в блоке, если гравитация успела осесть. // Даём физике несколько кадров и закрепляем ещё раз. await new Promise(r => setTimeout(r, 120)); ensurePlayerOnGround(); } // Start the game (v3 — 3D) 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]); }; })();