grechka-game/game3d.js

8488 lines
347 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 = '<div class="mc-bg"></div><div class="mc-dirt"></div>';
const box = document.createElement('div');
box.className = 'mc-box';
// Title bar
const titleBar = document.createElement('div');
titleBar.className = 'mc-titlebar';
titleBar.innerHTML = '<div class="mc-title">⛏️ GrechkaCraft</div>' +
'<div class="mc-subtitle">' + (isTgWebApp ? ('Привет, ' + (tgUser?.first_name || 'Игрок') + '!') : 'Voxel-мир в браузере') + '</div>';
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 = '🔗 Твой ник: <b>' + data.nickname + '</b>';
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 + ' <b>' + f.nickname + '</b>';
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 =>
`<div style="margin-bottom:4px;"><span style="color:#aaa;font-size:11px;">${m.time}</span> <strong style="color:${m.sender === 'Система' ? '#f39c12' : '#3498db'};">${m.sender}:</strong> ${m.message}</div>`
).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 = `
<div class="custom-modal-box">
<div style="font-size:18px;margin-bottom:12px;">🤝 ${data.from_name} хочет торговать</div>
<div class="modal-btns">
<button id="tradeAcceptBtn" style="background:#2ecc71;padding:10px 20px;border:none;border-radius:10px;color:#fff;font-weight:900;font-size:15px;cursor:pointer;">Принять</button>
<button id="tradeDeclineBtn" style="background:#e74c3c;padding:10px 20px;border:none;border-radius:10px;color:#fff;font-weight:900;font-size:15px;cursor:pointer;">Отклонить</button>
</div>
</div>`;
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 = `
<div style="text-align:center;color:#f1c40f;font-weight:900;font-size:16px;margin-bottom:8px;">🤝 Торговля с ${partnerName}</div>
<div style="display:flex;gap:12px;">
<div id="myOfferArea" style="flex:1;">
<div style="color:#2ecc71;font-weight:900;font-size:13px;margin-bottom:4px;">Вы предлагаете:</div>
<div id="myOfferItems" style="min-height:40px;padding:6px;border-radius:8px;background:rgba(46,204,113,0.1);border:1px solid rgba(46,204,113,0.3);"></div>
<button id="tradeAddItemBtn" style="width:100%;margin-top:6px;padding:6px;background:#3498db;color:#fff;border:none;border-radius:6px;font-weight:700;cursor:pointer;">+ Добавить</button>
</div>
<div style="display:flex;align-items:center;font-size:24px;">⇄</div>
<div id="partnerOfferArea" style="flex:1;">
<div style="color:#e74c3c;font-weight:900;font-size:13px;margin-bottom:4px;">Партнёр предлагает:</div>
<div id="partnerOfferItems" style="min-height:40px;padding:6px;border-radius:8px;background:rgba(231,76,60,0.1);border:1px solid rgba(231,76,60,0.3);">
<span style="color:#888;font-size:12px;">Ожидание...</span>
</div>
</div>
</div>
<div id="partnerConfirmStatus" style="color:#bbb;font-size:11px;text-align:center;margin-top:6px;">⏳ Партнёр ещё не подтвердил</div>
<div style="display:flex;gap:8px;margin-top:10px;">
<button id="tradeConfirmBtn" style="flex:1;padding:8px;background:#2ecc71;color:#fff;border:none;border-radius:8px;font-weight:900;cursor:pointer;">✅ Подтвердить</button>
<button id="tradeCancelBtn" style="flex:1;padding:8px;background:#e74c3c;color:#fff;border:none;border-radius:8px;font-weight:900;cursor:pointer;">✕ Отмена</button>
</div>`;
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 = '<span style="color:#888;font-size:12px;">Ничего</span>';
} else {
myItems.innerHTML = keys.map(k => `<div style="color:#fff;font-size:12px;">${itemIcon(k)} ${itemName(k)} x${myOffer[k]}</div>`).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 = '<div class="custom-modal-box" style="max-height:80vh;overflow-y:auto;max-width:360px;"><div style="font-weight:900;font-size:16px;margin-bottom:8px;">Что предложить?</div>';
for (const id of items) {
// Show already offered count
const offered = myOffer[id] || 0;
const available = inv[id] - offered;
if (available <= 0) continue;
html += `<div style="display:flex;align-items:center;gap:8px;padding:6px;border-radius:6px;cursor:pointer;border:1px solid rgba(255,255,255,0.1);margin-bottom:4px;" class="trade-pick-item" data-item="${id}">
<span style="font-size:18px;">${itemIcon(id)}</span>
<span style="flex:1;color:#fff;font-size:13px;">${itemName(id)}</span>
<span style="color:#bbb;font-size:11px;">x${available}</span>
</div>`;
}
html += '</div></div>';
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 = '<div style="color:#fff;font-size:13px;">';
// Доступные рецепты — показываем только те, для которых есть ресурсы
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 += `<div class="recipe">`;
html += `<div class="ricon" style="font-size:24px;display:flex;align-items:center;justify-content:center;">${iconStr}</div>`;
html += `<div class="rinfo">`;
html += `<div class="rname">${nameStr}</div>`;
html += `<div class="rcost">${inName} x${recipe.qty} (есть: ${haveCount}) • ${recipe.time}с</div>`;
html += `</div>`;
html += `<button class="rcraft" onclick="window._smelt(${i})" ${canSmelt ? '' : 'disabled'}>🔥</button>`;
html += `</div>`;
}
// Текущий прогресс
if (active) {
const pct = Math.min(100, Math.floor((active.progress / active.recipe.time) * 100));
html += `<div style="margin-top:10px;padding:8px;background:rgba(255,255,255,0.1);border-radius:8px;">`;
html += `<div style="color:#f39c12;font-weight:900;">🔥 Обжиг: ${pct}%</div>`;
html += `<div style="background:#333;height:8px;border-radius:4px;margin-top:4px;">`;
html += `<div style="background:#f39c12;height:8px;border-radius:4px;width:${pct}%;"></div>`;
html += `</div></div>`;
}
html += '</div>';
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 = '<p style="color:#aaa;">Подключение к серверу...</p>';
return;
}
state.socket.emit('market_list', {}, (orders) => {
if (!orders || !orders.length) {
marketContent.innerHTML = '<p style="color:#aaa;text-align:center;">Предложений пока нет</p><div style="margin-top:12px;"><button id="createOrderBtn" style="width:100%;padding:10px;background:#4a8c4a;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:14px;">+ Выставить предмет</button></div>';
const btn = document.getElementById('createOrderBtn');
if (btn) btn.addEventListener('click', showCreateOrder);
return;
}
let html = '<div style="margin-bottom:8px;"><button id="createOrderBtn" style="padding:8px 14px;background:#4a8c4a;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px;">+ Выставить предмет</button></div>';
html += orders.map(o => '<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 0;border-bottom:1px solid rgba(255,255,255,0.1);"><div><span style="color:#ffd700;">' + (o.offer_item||'') + '</span> x' + (o.offer_qty||1) + ' <span style="color:#888;"> ⇄ </span><span style="color:#7ecfff;">' + (o.want_item||'') + '</span> x' + (o.want_qty||1) + '</div><div style="font-size:11px;color:#888;">' + (o.seller || 'Аноним') + '</div></div>').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 => '<option value="'+k+'">'+k+' (x'+state.inv[k]+')</option>').join('');
marketContent.innerHTML = '<div style="padding:8px;"><h3 style="margin:0 0 10px;font-size:14px;">Выставить предмет</h3><label style="font-size:12px;color:#aaa;">Отдаю:</label><select id="offerItem" style="width:100%;margin-bottom:8px;padding:6px;background:#222;color:#fff;border:1px solid #555;border-radius:4px;">'+invOpts+'</select><label style="font-size:12px;color:#aaa;">Хочу:</label><input id="wantItem" placeholder="Название предмета" style="width:100%;margin-bottom:8px;padding:6px;background:#222;color:#fff;border:1px solid #555;border-radius:4px;" /><div style="display:flex;gap:8px;"><button id="submitOrder" style="flex:1;padding:8px;background:#4a8c4a;color:#fff;border:none;border-radius:6px;cursor:pointer;">Выставить</button><button id="cancelOrder" style="flex:1;padding:8px;background:#555;color:#fff;border:none;border-radius:6px;cursor:pointer;">Отмена</button></div></div>';
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 = '🎤<span style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:36px;color:#e74c3c;font-weight:bold;">/</span>';
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 = '🎤<span style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:36px;color:#e74c3c;font-weight:bold;">/</span>';
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 =
'<div style="position:absolute;inset:0;overflow:hidden;pointer-events:none">' +
// солнце
'<div style="position:absolute;left:50%;top:26%;width:170px;height:170px;margin:-85px 0 0 -85px;' +
'border-radius:50%;background:radial-gradient(circle,#fff6d8 0%,#ffe08a 42%,rgba(255,214,120,0) 72%);' +
'filter:blur(2px);animation:v3sun 6s ease-in-out infinite"></div>' +
// облака
'<div style="position:absolute;top:14%;left:-30%;width:52%;height:40px;border-radius:50%;' +
'background:radial-gradient(ellipse,#ffffff8a 0%,#ffffff3d 56%,rgba(255,255,255,0) 78%);' +
'filter:blur(3px);animation:v3cloud 40s linear infinite"></div>' +
'<div style="position:absolute;top:25%;left:-40%;width:38%;height:26px;border-radius:50%;' +
'background:radial-gradient(ellipse,#ffffff70 0%,#ffffff30 56%,rgba(255,255,255,0) 78%);' +
'filter:blur(3px);animation:v3cloud 58s linear infinite"></div>' +
// холмы
'<div style="position:absolute;left:-10%;bottom:0;width:120%;height:38%;border-radius:50% 50% 0 0;' +
'background:linear-gradient(180deg,#2f7a3d,#1d5427)"></div>' +
'<div style="position:absolute;left:6%;bottom:0;width:80%;height:26%;border-radius:50% 50% 0 0;' +
'background:linear-gradient(180deg,#3a8f47,#256030)"></div>' +
// гречка — колос: стебель + 6 зёрен вокруг него. Раньше зёрна строились
// отрицательным margin от пустого блока и висели над землёй точками.
'<div style="position:absolute;left:50%;bottom:15%;margin-left:-11px;' +
'width:22px;height:56px;animation:v3bob 2.4s ease-in-out infinite">' +
'<div style="position:absolute;left:9px;bottom:0;width:4px;height:46px;background:#7dbf4a;border-radius:2px 2px 0 0"></div>' +
'<div style="position:absolute;left:1px;bottom:38px;width:10px;height:10px;background:#e8c95c;border-radius:50% 50% 50% 0;transform:rotate(-45deg)"></div>' +
'<div style="position:absolute;left:12px;bottom:38px;width:10px;height:10px;background:#e8c95c;border-radius:50% 50% 0 50%;transform:rotate(45deg)"></div>' +
'<div style="position:absolute;left:3px;bottom:29px;width:9px;height:9px;background:#dcbb4e;border-radius:50% 50% 50% 0;transform:rotate(-45deg)"></div>' +
'<div style="position:absolute;left:11px;bottom:29px;width:9px;height:9px;background:#dcbb4e;border-radius:50% 50% 0 50%;transform:rotate(45deg)"></div>' +
'<div style="position:absolute;left:4px;bottom:20px;width:8px;height:8px;background:#cfae42;border-radius:50% 50% 50% 0;transform:rotate(-45deg)"></div>' +
'<div style="position:absolute;left:11px;bottom:20px;width:8px;height:8px;background:#cfae42;border-radius:50% 50% 0 50%;transform:rotate(45deg)"></div>' +
'<div style="position:absolute;left:7px;bottom:44px;width:9px;height:11px;background:#dfc163;border-radius:50% 50% 45% 45%"></div>' +
'</div>' +
'<div style="width:8px;height:8px;margin:-38px -8px 0 0;background:#dcbb4e;border-radius:50% 50% 50% 0;transform:rotate(-40deg)"></div>' +
'<div style="width:8px;height:8px;margin:-32px 0 0 0;background:#dcbb4e;border-radius:50% 50% 0 50%;transform:rotate(40deg)"></div>' +
'<div style="width:7px;height:7px;margin:-26px -7px 0 0;background:#cfae42;border-radius:50% 50% 50% 0;transform:rotate(-40deg)"></div>' +
'<div style="width:7px;height:7px;margin:-20px 0 0 0;background:#cfae42;border-radius:50% 50% 0 50%;transform:rotate(40deg)"></div></div>' +
'</div>' +
'<style>' +
'@keyframes v3sun{0%,100%{transform:scale(1);opacity:.9}50%{transform:scale(1.08);opacity:1}}' +
'@keyframes v3cloud{from{transform:translateX(0)}to{transform:translateX(280%)}}' +
'@keyframes v3bob{0%,100%{transform:translate(-50%,0)}50%{transform:translate(-50%,-10px)}}' +
'@keyframes v3spin{to{transform:rotate(360deg)}}' +
'</style>' +
'<div style="position:relative;display:flex;flex-direction:column;align-items:center;gap:16px">' +
'<div style="font-size:clamp(22px,5vw,34px);font-weight:800;letter-spacing:.5px;' +
'text-shadow:0 3px 14px #0007">Гречка Craft 3D</div>' +
'<div id="v3loadstage" style="font-size:13px;opacity:.82;min-height:19px">Подготовка…</div>' +
'<div id="v3loadbar" style="width:min(330px,74vw);height:12px;border-radius:8px;' +
'background:#ffffff2b;overflow:hidden;box-shadow:inset 0 1px 3px #0005,0 6px 18px #0004">' +
'<div id="v3loadfill" style="width:0%;height:100%;border-radius:8px;' +
'background:linear-gradient(90deg,#7ee08a,#2ecc71);' +
'box-shadow:0 0 12px #7ee08aaa;transition:width .18s ease-out"></div></div>' +
'<div id="v3loadtxt" style="font-size:13px;opacity:.8;font-variant-numeric:tabular-nums">0%</div>' +
'<div id="v3loadhint" style="font-size:12px;opacity:.55;max-width:min(420px,86vw)">' +
'Строим мир: рельеф, биомы, деревья, руды и гречка</div>' +
'</div>';
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]); };
})();