// GrechkaCraft — auto-bundled from ES modules
(function() {
"use strict";
// === core/constants.js ===
// ==================== КОНСТАНТЫ ИГРЫ ====================
const TILE = 40;
// Мир
// Уровень моря. Было 14 при фактической поверхности 4..12: вода была ВЫШЕ
// всей суши, заливала мир, игрок всегда стоял в воде (gy=6 под водой 7..9),
// камера схлопывалась в первое лицо, и кадр показывал пустое небо.
// Ставим 3 — заметно ниже любой реальной поверхности, вода появляется
// только в настоящих низинах, а не везде.
const SEA_GY = 3;
const BEDROCK_GY = 140; // глубина бедрока (gy), чем больше - тем глубже
const GEN_MARGIN_X = 40; // запас генерации по X (в тайлах) — увеличен для плавной прогрузки
// Физика
const GRAV = 2200;
const GRAV_WATER = 550;
const MOVE = 320;
const JUMP = 760;
// День/ночь
const DAY_LEN = 360; // замедлил смену дня/ночи в 3 раза
// Сохранение
const SAVE_KEY = 'minegrechka_save';
// Сеть
const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду
// Погода
const MAX_RAINDROPS = 200;
// Чат
const MAX_CHAT_MESSAGES = 50;
// Голосовой чат
const VOICE_SERVER = 'wss://voicegrech.mkn8n.ru';
// === core/state.js ===
// Все мутабельные переменные игры в одном объекте состояния
const state = {
// Константы (для доступа из модулей)
TILE,
SEA_GY,
BEDROCK_GY,
GEN_MARGIN_X,
GRAV,
GRAV_WATER,
MOVE,
JUMP,
DAY_LEN,
SAVE_KEY,
MOVE_SEND_INTERVAL,
MAX_RAINDROPS,
MAX_CHAT_MESSAGES,
VOICE_SERVER,
// Камера
camX: 0,
camY: 0,
W: 0,
H: 0,
dpr: 1,
// Canvas/DOM (заполняется в main.js)
gameEl: null,
canvas: null,
ctx: null,
lightC: null,
lightCtx: null,
// День/ночь
worldTime: 0.30, // старт в полдень (0 = рассвет, 0.5+ = ночь)
isNightTime: false,
dayNightCycle: true,
// Мультиплеер
isMultiplayer: false,
mySocketId: null,
socket: null,
voiceSocket: null,
voicePosT: 0,
// Сервер
SERVER_URL: '',
TELEGRAM_BOT_USERNAME: '',
TELEGRAM_APP_SHORT_NAME: '',
// Игрок
playerName: null,
worldId: null,
// Инвентарь/UI
selected: "dirt",
showFullInventory: false,
craftOpen: false,
inventoryOpen: false,
chatOpen: false,
minimapOpen: false,
modeIdx: 0,
currentFurnaceKey: null,
// Мир
blocksVersion: 0,
worldSeed: Math.floor(Math.random() * 1000000),
// Погода
weatherType: "clear",
isRaining: false,
rainIntensity: 0,
weatherTimer: 0,
weatherChangeInterval: 60 + Math.random() * 120,
snowflakes: [],
lightningFlash: 0,
lightningTimer: 0,
// Мобы/спавн
spawnT: 0,
// Цикл
last: 0,
prevJump: false,
// Сеть — throttle отправки позиции
lastMoveSendTime: 0,
lastSentX: 0,
lastSentY: 0,
// Mining progress
mining: { gx: null, gy: null, progress: 0, hardness: 0 },
// Trade/Market
myTgId: null,
tradePanel: null,
tradeContent: null,
marketPanel: null,
marketContent: null,
// DOM-ссылки (заполняются в main.js)
hpEl: null,
foodEl: null,
sxEl: null,
syEl: null,
todEl: null,
worldIdEl: null,
playerCountEl: null,
deathEl: null,
hotbarEl: null,
craftPanel: null,
recipesEl: null,
inventoryPanel: null,
inventoryGrid: null,
multiplayerStatus: null,
tempEl: null,
// Игрок
player: {
x: 6 * TILE,
y: 0 * TILE,
z: 0,
w: 34,
h: 34,
d: 34,
vx: 0,
vy: 0,
vz: 0,
grounded: false,
inWater: false,
headInWater: false,
hp: 100,
hunger: 100,
o2: 100,
invuln: 0,
fallStartY: 0,
lastStepTime: 0,
sleeping: false,
inBoat: false,
armor: 0,
equippedArmor: null,
temperature: 15,
slowTimer: 0,
// Опыт и уровень. Раньше этих полей не было вовсе: элементы #xplevel
// и #xpbar были в HTML, но player.xp нигде не рос, и HUD вечно показывал
// «Lv.1 | 0/50». Порог уровня — 50 * level.
xp: 0,
level: 1
},
// Точка спавна
spawnPoint: { x: 6 * TILE, y: 0 * TILE },
// Инвентарь
inv: {
dirt: 6, stone: 0, sand: 0, gravel: 0, clay: 0,
wood: 0, planks: 0, ladder: 0, leaves: 0, coal: 0,
copper_ore: 0, iron_ore: 0, gold_ore: 0, diamond_ore: 0,
brick: 0, glass: 0,
tnt: 1, campfire: 0, torch: 0,
meat: 0, cooked: 0, arrow: 0,
wood_pickaxe: 0, stone_pickaxe: 0, iron_pickaxe: 0,
wood_sword: 0, stone_sword: 0, iron_sword: 0,
wood_axe: 0, stone_axe: 0, iron_axe: 0,
wood_shovel: 0, stone_shovel: 0, iron_shovel: 0,
iron_armor: 0, gold_armor: 0,
bow: 0, furnace: 0,
bed: 0, boat: 0,
iron_ingot: 0, gold_ingot: 0, copper_ingot: 0,
cobblestone: 0,
// New items
chicken_meat: 0, feather: 0, bone: 0, gunpowder: 0,
wheat: 0, bread: 0, carrot: 0, potato: 0, baked_potato: 0,
mushroom_stew: 0,
scorpion_stinger: 0, polar_fur: 0, slime_ball: 0, eagle_feather: 0,
snow: 0, ice: 0, cactus: 0, mushroom: 0, moss: 0, farmland: 0,
spruce_leaves: 0, dead_bush: 0, swamp_water: 0,
wheat_seeds: 0, wheat_stage0: 0, carrot_stage0: 0, potato_stage0: 0
},
// Лодка
boat: {
x: 0, y: 0,
w: 34, h: 34,
vx: 0, vy: 0,
active: false,
inWater: false
},
// Ввод
inp: { up: false, down: false, left: false, right: false, jump: false, mine: false, build: false, bow: false, l: false, r: false, j: false, s: false,
ax: 0, ay: 0, // аналоговые оси джойстика (-1..1)
kfwd: 0, kside: 0, // клавиатура: вперёд/назад и влево/вправо (-1..1)
mineHeld: false },
// Мышь
mouse: { x: 0, y: 0 },
// Другие игроки (MP)
otherPlayers: new Map(),
// Серверные мобы (MP)
serverMobs: new Map(),
// Мобы
mobs: [],
// Снаряды
projectiles: [],
// Отслеживание изменений мира
placedBlocks: [],
removedBlocks: [],
// Серверные изменения
serverOverrides: new Map(),
// Чат
chatMessages: [],
// Погода — капли
raindrops: [],
// Облака
clouds: Array.from({ length: 10 }, () => ({
x: Math.random() * 2000,
y: -200 - Math.random() * 260,
w: 80 + Math.random() * 120,
s: 12 + Math.random() * 20
})),
// Частицы
parts: [],
// Активный TNT
activeTNT: new Set(),
// Прочность инструментов
toolDurability: new Map(),
protections: new Map(),
// Последние выбранные предметы
recentItems: [],
// Активные печи
activeFurnaces: new Map(),
cropTimers: new Map(),
// Сгенерированные колонны
generated: new Set(),
// Изображение героя
heroImg: new Image(),
// Сетка мира
grid: new Map(),
blocks: [],
// In-memory save fallback
inMemorySave: null,
};
// === data/blocks.js ===
const BLOCKS = {
air: { n:'Воздух', solid:false, hardness:0 },
grass: { n:'Трава', c:'#5da42a', solid:true, hardness:3, toolType:'shovel' },
dirt: { n:'Грязь', c:'#8a5a2b', solid:true, hardness:3, toolType:'shovel' },
stone: { n:'Камень', c:'#7f8c8d', solid:true, hardness:6, toolType:'pickaxe' },
sand: { n:'Песок', c:'#e0c070', solid:true, hardness:2, toolType:'shovel' },
gravel: { n:'Гравий', c:'#95a5a6', solid:true, hardness:3, toolType:'shovel' },
clay: { n:'Глина', c:'#74b9ff', solid:true, hardness:3, toolType:'shovel' },
wood: { n:'Дерево', c:'#d35400', solid:true, hardness:4, toolType:'axe' },
planks: { n:'Доски', c:'#e67e22', solid:true, hardness:3, toolType:'axe' },
ladder: { n:'Лестница',c:'#d35400', solid:false, climbable:true, hardness:1, toolType:null },
leaves: { n:'Листва', c:'#2ecc71', solid:true, hardness:1, toolType:'axe', minTool:null },
glass: { n:'Стекло', c:'rgba(200,240,255,0.25)', solid:true, alpha:0.55, hardness:1, toolType:null },
water: { n:'Вода', c:'rgba(52,152,219,0.55)', solid:false, fluid:true, hardness:0 },
coal: { n:'Уголь', c:'#2c3e50', solid:true, hardness:8, toolType:'pickaxe', minTool:'wood' },
copper_ore:{ n:'Медь', c:'#e17055', solid:true, hardness:10, toolType:'pickaxe', minTool:'wood' },
iron_ore: { n:'Железо', c:'#dcdde1', solid:true, hardness:12, toolType:'pickaxe', minTool:'stone' },
iron_armor:{ n:'Железная броня', c:'#95a5a6', solid:false, armor:0.5, hardness:0 },
gold_ore: { n:'Золото', c:'#f1c40f', solid:true, hardness:15, toolType:'pickaxe', minTool:'stone' },
diamond_ore:{n:'Алмаз', c:'#00a8ff', solid:true, hardness:20, toolType:'pickaxe', minTool:'iron' },
brick: { n:'Кирпич', c:'#c0392b', solid:true, hardness:8, toolType:'pickaxe' },
tnt: { n:'TNT', c:'#e74c3c', solid:true, explosive:true, hardness:1, toolType:null },
campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius:280, hardness:2, toolType:null },
torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:220, hardness:1, toolType:null },
bedrock: { n:'Бедрок', c:'#2d3436', solid:true, unbreakable:true, hardness:Infinity },
flower: { n:'Цветок', c:'#ff4757', solid:false, decor:true, hardness:0 },
bed: { n:'Кровать', c:'#e91e63', solid:true, bed:true, hardness:2, toolType:'axe' },
boat: { n:'Лодка', c:'#8B4513', solid:false, hardness:0 },
furnace: { n:'Печь', c:'#696969', solid:true, smelting:true, hardness:8, toolType:'pickaxe', minTool:'wood' },
cobblestone: { n:'Булыжник', c:'#7f8c8d', solid:true, hardness:6, toolType:'pickaxe' },
flag: { n:'Флаг', c:'#e74c3c', solid:true, hardness:1, toolType:'axe', protectionRadius:10 },
// === BIOME BLOCKS ===
snow: { n:'Снег', c:'#cfdbe4', solid:true, hardness:1, toolType:'shovel' },
ice: { n:'Лёд', c:'#74b9ff', solid:true, hardness:1, slip:true, toolType:'pickaxe' },
cactus: { n:'Кактус', c:'#27ae60', solid:true, hardness:1, hurt:true, toolType:null },
mushroom: { n:'Гриб', c:'#e74c3c', solid:false, decor:true, hardness:0 },
moss: { n:'Мох', c:'#0a6640', solid:true, hardness:1, toolType:null },
swamp_water:{ n:'Болотная вода', c:'rgba(100,120,40,0.6)', solid:false, fluid:true, poison:true, hardness:0 },
farmland: { n:'Грядка', c:'#8B6914', solid:true, hardness:1, farmable:true, toolType:'shovel' },
dead_bush: { n:'Сухой куст', c:'#b2bec3', solid:false, decor:true, hardness:0 },
spruce_leaves:{ n:'Ель', c:'#0a6640', solid:true, hardness:1, toolType:'axe' },
// === CROP STAGES ===
wheat_stage0:{ n:'Росток пшеницы', c:'#a8e6a0', solid:false, decor:true, hardness:0, growthStage:0, growthTime:30, growsTo:'wheat_stage1' },
wheat_stage1:{ n:'Пшеница', c:'#7dcea0', solid:false, decor:true, hardness:0, growthStage:1, growthTime:30, growsTo:'wheat_stage2' },
wheat_stage2:{ n:'Пшеница', c:'#b8d730', solid:false, decor:true, hardness:0, growthStage:2, growthTime:30, growsTo:'wheat_stage3' },
wheat_stage3:{ n:'Пшеница', c:'#f1c40f', solid:false, decor:true, hardness:0, harvestable:true, harvestItem:'wheat', harvestQty:2 },
carrot_stage0:{ n:'Росток моркови', c:'#a8e6a0', solid:false, decor:true, hardness:0, growthStage:0, growthTime:30, growsTo:'carrot_stage1' },
carrot_stage1:{ n:'Морковь', c:'#f0c27a', solid:false, decor:true, hardness:0, growthStage:1, growthTime:30, growsTo:'carrot_stage2' },
carrot_stage2:{ n:'Морковь', c:'#e8a040', solid:false, decor:true, hardness:0, growthStage:2, growthTime:30, growsTo:'carrot_stage3' },
carrot_stage3:{ n:'Морковь', c:'#e67e22', solid:false, decor:true, hardness:0, harvestable:true, harvestItem:'carrot', harvestQty:3 },
potato_stage0:{ n:'Росток картофеля', c:'#a8e6a0', solid:false, decor:true, hardness:0, growthStage:0, growthTime:30, growsTo:'potato_stage1' },
potato_stage1:{ n:'Картофель', c:'#c8b888', solid:false, decor:true, hardness:0, growthStage:1, growthTime:30, growsTo:'potato_stage2' },
potato_stage2:{ n:'Картофель', c:'#bfaa78', solid:false, decor:true, hardness:0, growthStage:2, growthTime:30, growsTo:'potato_stage3' },
potato_stage3:{ n:'Картофель', c:'#dfe6e9', solid:false, decor:true, hardness:0, harvestable:true, harvestItem:'potato', harvestQty:2 },
};
// === config.js ===
// ==================== КОНФИГУРАЦИЯ СЕРВЕРА ====================
// This module only sets SERVER_URL. Player name and world ID are handled in main.js
// to avoid duplicate prompts.
const urlParams = new URLSearchParams(window.location.search);
const SERVER_URL = urlParams.get('server') || 'https://apigrech.mkn8n.ru';
// Защита от mixed content
if (location.protocol === 'https:' && SERVER_URL.startsWith('http://')) {
console.warn('⚠️ Mixed content warning: page is HTTPS but server URL is HTTP');
alert('⚠️ Предупреждение: страница загружена по HTTPS, но сервер использует HTTP. Это может вызвать проблемы.');
}
// === ui/start-menu.js ===
// ==================== START MENU ====================
// Detect Telegram WebApp
const isTgWebApp = !!(window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData);
let tgUser = null;
let tgSaveLoaded = false;
if (isTgWebApp) {
try {
Telegram.WebApp.ready();
Telegram.WebApp.expand();
tgUser = Telegram.WebApp.initDataUnsafe?.user || null;
console.log('[TG] Mini App detected, user:', tgUser?.id, tgUser?.username);
// Set player name from TG
if (tgUser && tgUser.first_name) {
const tgName = tgUser.username || tgUser.first_name;
localStorage.setItem('minegrechka_playerName', tgName);
}
} catch (e) {
console.warn('[TG] WebApp init error:', e);
}
}
// Export TG info for other modules
// ==================== HELPERS ====================
function generateShortWorldCode() {
// Generate readable 6-char code: consonants + digits (easy to copy/share)
const c = 'bcdfghjkmnpqrstvwxyz';
const d = '23456789';
let code = '';
for (let i = 0; i < 3; i++) code += c[Math.floor(Math.random() * c.length)];
for (let i = 0; i < 3; i++) code += d[Math.floor(Math.random() * d.length)];
return code;
}
function showToast(msg, duration) {
duration = duration || 1800;
const t = document.createElement('div');
t.textContent = msg;
t.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.85);color:#2ecc71;padding:10px 24px;border-radius:10px;font:bold 16px Arial,sans-serif;z-index:99999;pointer-events:none;transition:opacity 0.4s;';
document.body.appendChild(t);
setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 400); }, duration);
}
function customAlert(msg) {
const overlay = document.createElement('div');
overlay.className = 'custom-modal-overlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:100000;';
const box = document.createElement('div');
box.style.cssText = 'background:#C6C6C6;border:4px solid #000;padding:24px;max-width:360px;width:90%;box-shadow:4px 4px 0 #000;font-family:Courier New,monospace;';
const text = document.createElement('div');
text.textContent = msg;
text.style.marginBottom = '16px';
text.style.fontWeight = 'bold';
text.style.fontSize = '16px';
const btn = document.createElement('button');
btn.textContent = 'OK';
btn.style.cssText = 'padding:8px 24px;font-family:Courier New,monospace;font-weight:bold;cursor:pointer;border:3px solid #000;background:#5A8F3C;color:#fff;box-shadow:2px 2px 0 #000;';
btn.onclick = () => overlay.remove();
box.appendChild(text);
box.appendChild(btn);
overlay.appendChild(box);
document.body.appendChild(overlay);
}
// ==================== MAIN START MENU ====================
// Minecraft-style CSS for the start menu
const mcCSS = `
.mc-overlay { position:fixed;top:0;left:0;width:100%;height:100%;z-index:99999;
display:flex;align-items:center;justify-content:center;font-family:'Courier New',monospace;
image-rendering:pixelated; }
.mc-bg { position:absolute;inset:0;background:#000;opacity:0.75; }
.mc-dirt { position:absolute;bottom:0;left:0;right:0;height:40%;
background:repeating-linear-gradient(0deg,#8B6914 0px,#8B6914 2px,#7A5B12 2px,#7A5B12 4px);opacity:0.3; }
.mc-box { position:relative;z-index:1;background:#C6C6C6;border:4px solid #000;
padding:0;max-width:420px;width:92%;box-shadow:4px 4px 0 #000;max-height:90vh;display:flex;flex-direction:column; }
.mc-titlebar { background:#1a1a2e;padding:12px 16px;text-align:center;
border-bottom:4px solid #000; }
.mc-title { font-size:24px;font-weight:bold;color:#FFAA00;text-shadow:2px 2px 0 #000;
letter-spacing:2px; }
.mc-subtitle { font-size:13px;color:#AAA;margin-top:4px; }
.mc-body { padding:16px;background:#C6C6C6;max-height:80vh;overflow-y:auto;-webkit-overflow-scrolling:touch; }
.mc-btn { display:block;width:100%;padding:10px 16px;margin-bottom:8px;
font-family:'Courier New',monospace;font-size:16px;font-weight:bold;
cursor:pointer;text-align:center;border:3px solid #000;
image-rendering:pixelated;box-shadow:3px 3px 0 #000;
transition:transform 0.05s,box-shadow 0.05s; }
.mc-btn:active { transform:translate(2px,2px);box-shadow:1px 1px 0 #000; }
.mc-btn-green { background:#5A8F3C;color:#E0E0E0;border-color:#3A5F1C;text-shadow:1px 1px 0 #222; }
.mc-btn-green:hover { background:#6DAF4E; }
.mc-btn-blue { background:#3C5F9E;color:#E0E0E0;border-color:#2A4070;text-shadow:1px 1px 0 #222; }
.mc-btn-blue:hover { background:#4B73BE; }
.mc-btn-purple { background:#7B3FA0;color:#E0E0E0;border-color:#5A2C78;text-shadow:1px 1px 0 #222; }
.mc-btn-purple:hover { background:#9555BE; }
.mc-btn-orange { background:#B86E00;color:#E0E0E0;border-color:#8A5200;text-shadow:1px 1px 0 #222; }
.mc-btn-orange:hover { background:#DA8500; }
.mc-btn-red { background:#9E2B2B;color:#E0E0E0;border-color:#6E1B1B;text-shadow:1px 1px 0 #222; }
.mc-btn-red:hover { background:#BE3B3B; }
.mc-btn-gray { background:#555;color:#AAA;border-color:#333;text-shadow:1px 1px 0 #111; }
.mc-btn-gray:hover { background:#666;color:#DDD; }
.mc-btn-outline { background:transparent;color:#555;border-color:#555;
text-shadow:none;box-shadow:2px 2px 0 #333; }
.mc-btn-outline:hover { background:#DDD;color:#222; }
.mc-input { width:100%;padding:8px 12px;font-family:'Courier New',monospace;
font-size:16px;background:#FFF;border:3px solid #000;color:#222;
box-shadow:inset 2px 2px 0 #999;margin:4px 0;box-sizing:border-box; }
.mc-input:focus { outline:none;border-color:#FFAA00; }
.mc-label { font-size:13px;color:#444;font-weight:bold;margin:8px 0 4px; }
.mc-divider { border:none;border-top:2px solid #999;margin:12px 0; }
.mc-friend-row { display:flex;justify-content:space-between;align-items:center;
padding:6px 8px;margin-bottom:4px;background:#E8E8E8;border:2px solid #999;
box-shadow:2px 2px 0 #999; }
.mc-friend-btn { padding:4px 10px;font-family:'Courier New',monospace;
font-size:13px;font-weight:bold;cursor:pointer;border:2px solid #000;
box-shadow:1px 1px 0 #000; }
.mc-friend-btn:active { transform:translate(1px,1px);box-shadow:none; }
.mc-friend-btn-green { background:#5A8F3C;color:#E0E0E0; }
.mc-friend-btn-red { background:#9E2B2B;color:#E0E0E0; }
.mc-checkbox-row { display:flex;align-items:center;gap:8px;font-size:13px;color:#444;cursor:pointer; }
.mc-checkbox { width:18px;height:18px;accent-color:#5A8F3C; }
`;
function showStartMenu() {
return new Promise(async (resolve) => {
const urlParams = new URLSearchParams(window.location.search);
const worldParam = urlParams.get('world');
const tgStartParam = (isTgWebApp && Telegram.WebApp.initDataUnsafe?.start_param)
? Telegram.WebApp.initDataUnsafe.start_param.trim()
: null;
// If world is in URL or TG start param — skip menu, auto-join
if ((worldParam && worldParam.trim() !== '') || tgStartParam) {
const worldId = (worldParam && worldParam.trim()) || tgStartParam;
console.log('[Menu] Joining world from URL/startParam:', worldId);
// Set player name from TG or localStorage or default
if (!state.playerName) {
if (isTgWebApp && tgUser) {
state.playerName = tgUser.username || tgUser.first_name || 'Игрок';
} else {
state.playerName = localStorage.getItem('minegrechka_playerName') || 'Игрок';
}
localStorage.setItem('minegrechka_playerName', state.playerName);
}
resolve(worldId);
return;
}
// Inject CSS
const styleEl = document.createElement('style');
styleEl.textContent = mcCSS;
document.head.appendChild(styleEl);
// Build overlay
const overlay = document.createElement('div');
overlay.className = 'mc-overlay';
overlay.id = 'startMenu';
overlay.innerHTML = '
';
const box = document.createElement('div');
box.className = 'mc-box';
// Title bar
const titleBar = document.createElement('div');
titleBar.className = 'mc-titlebar';
titleBar.innerHTML = '⛏️ GrechkaCraft
' +
'' + (isTgWebApp ? ('Привет, ' + (tgUser?.first_name || 'Игрок') + '!') : 'Voxel-мир в браузере') + '
';
box.appendChild(titleBar);
const body = document.createElement('div');
body.className = 'mc-body';
// Mutable worldId holder
let selectedWorldId = null;
// === Buttons ===
// 🌍 New World
const btnNew = document.createElement('button');
btnNew.className = 'mc-btn mc-btn-green';
btnNew.textContent = '🌍 Новый мир';
btnNew.onclick = () => {
selectedWorldId = generateShortWorldCode();
overlay.remove();
styleEl.remove();
resolve(selectedWorldId);
};
body.appendChild(btnNew);
// 🔗 Join by code
const btnJoin = document.createElement('button');
btnJoin.className = 'mc-btn mc-btn-blue';
btnJoin.textContent = '🔗 Вступить по коду';
btnJoin.onclick = () => {
body.innerHTML = '';
const lbl = document.createElement('div');
lbl.className = 'mc-label';
lbl.textContent = 'Код мира:';
body.appendChild(lbl);
const codeInput = document.createElement('input');
codeInput.className = 'mc-input';
codeInput.type = 'text';
codeInput.placeholder = 'bkh237';
codeInput.maxLength = 12;
body.appendChild(codeInput);
const btnGo = document.createElement('button');
btnGo.className = 'mc-btn mc-btn-blue';
btnGo.textContent = 'Войти →';
btnGo.onclick = () => {
const code = codeInput.value.trim().toLowerCase();
if (code.length >= 3) {
selectedWorldId = code;
overlay.remove();
styleEl.remove();
resolve(selectedWorldId);
}
};
body.appendChild(btnGo);
const btnBack = document.createElement('button');
btnBack.className = 'mc-btn mc-btn-gray';
btnBack.textContent = '← Назад';
btnBack.onclick = () => { overlay.remove(); styleEl.remove(); showStartMenu().then(resolve); };
body.appendChild(btnBack);
codeInput.focus();
codeInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') btnGo.click(); });
};
body.appendChild(btnJoin);
// 💾 Continue saved game
const hasLocalSave = !!localStorage.getItem(state.SAVE_KEY || 'minegrechka_save');
if (isTgWebApp || hasLocalSave) {
const btnHistory = document.createElement('button');
btnHistory.className = 'mc-btn mc-btn-purple';
btnHistory.textContent = '💾 Продолжить игру';
btnHistory.onclick = async () => {
if (isTgWebApp && tgUser) {
try {
const resp = await fetch(state.SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id);
const data = await resp.json();
if (data.ok && data.world_id) {
selectedWorldId = data.world_id;
tgSaveLoaded = true;
overlay.remove();
styleEl.remove();
resolve(selectedWorldId);
return;
}
} catch (e) { console.warn('[TG] Load save failed:', e); }
}
if (hasLocalSave) {
// Continue with a new world code (the save will be loaded separately by initDB/loadGame)
selectedWorldId = generateShortWorldCode();
overlay.remove();
styleEl.remove();
resolve(selectedWorldId);
return;
}
customAlert('Сохранений не найдено');
};
body.appendChild(btnHistory);
}
// === TG-only buttons ===
if (isTgWebApp && window.Telegram && Telegram.WebApp.switchInlineQuery) {
// 📤 Invite friends
const btnInvite = document.createElement('button');
btnInvite.className = 'mc-btn mc-btn-outline';
btnInvite.style.marginTop = '16px';
btnInvite.textContent = '📤 Пригласить друзей';
btnInvite.onclick = () => {
const code = selectedWorldId || generateShortWorldCode();
const url = 'https://t.me/' + state.TELEGRAM_BOT_USERNAME + '/app?startapp=' + encodeURIComponent(code);
if (Telegram.WebApp && Telegram.WebApp.openTelegramLink) {
Telegram.WebApp.openTelegramLink('https://t.me/share/url?url=' + encodeURIComponent(url) + '&text=' + encodeURIComponent('🏗️ Заходи в GrechkaCraft! Мир: ' + code));
} else {
navigator.clipboard.writeText(url).then(() => showToast('📋 Скопировано!')).catch(() => showToast(url));
}
};
body.appendChild(btnInvite);
// 👥 Friends
const btnFriends = document.createElement('button');
btnFriends.className = 'mc-btn mc-btn-orange';
btnFriends.style.marginTop = '8px';
btnFriends.textContent = '👥 Друзья';
btnFriends.onclick = async () => {
try {
const resp = await fetch(state.SERVER_URL + '/api/tg/friends?tg_id=' + tgUser.id);
const data = await resp.json();
body.innerHTML = '';
// Back button at top
const btnBackTop = document.createElement('button');
btnBackTop.className = 'mc-btn mc-btn-gray';
btnBackTop.textContent = '← Назад';
btnBackTop.style.marginBottom = '12px';
btnBackTop.onclick = () => { overlay.remove(); styleEl.remove(); showStartMenu().then(resolve); };
body.appendChild(btnBackTop);
// Nickname section
if (data.nickname) {
const nickDiv = document.createElement('div');
nickDiv.className = 'mc-friend-row';
nickDiv.innerHTML = '🔗 Твой ник: ' + data.nickname + ' ';
nickDiv.style.background = '#D5E8C5';
body.appendChild(nickDiv);
} else {
const nickLabel = document.createElement('div');
nickLabel.className = 'mc-label';
nickLabel.style.color = '#9E2B2B';
nickLabel.textContent = '⚡ Задай ник, чтобы друзья могли тебя найти:';
body.appendChild(nickLabel);
const nickRow = document.createElement('div');
nickRow.style.display = 'flex';
nickRow.style.gap = '4px';
nickRow.style.marginBottom = '8px';
const nickInput = document.createElement('input');
nickInput.className = 'mc-input';
nickInput.style.flex = '1';
nickInput.type = 'text';
nickInput.maxLength = 16;
nickInput.placeholder = 'Твой ник';
const nickBtn = document.createElement('button');
nickBtn.className = 'mc-btn mc-btn-green';
nickBtn.style.width = 'auto';
nickBtn.style.marginBottom = '0';
nickBtn.style.padding = '8px 16px';
nickBtn.textContent = '✓';
nickBtn.onclick = async () => {
const n = nickInput.value.trim().toLowerCase();
if (!n || n.length < 2) { showToast('❌ Минимум 2 символа'); return; }
const check = await fetch(state.SERVER_URL + '/api/nick/check?nick=' + encodeURIComponent(n));
const cd = await check.json();
if (!cd.available) { showToast('❌ Ник занят'); return; }
const setResp = await fetch(state.SERVER_URL + '/api/nick/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tg_id: tgUser.id, nickname: n })
});
const sd = await setResp.json();
if (sd.ok) { showToast('✅ Ник: ' + sd.nickname); btnFriends.onclick(); } else { showToast('❌ Ошибка'); }
};
nickRow.appendChild(nickInput);
nickRow.appendChild(nickBtn);
body.appendChild(nickRow);
}
// Friends list
if (data.friends && data.friends.length) {
const listLabel = document.createElement('div');
listLabel.className = 'mc-label';
listLabel.textContent = '👥 Друзья:';
body.appendChild(listLabel);
for (const f of data.friends) {
const fDiv = document.createElement('div');
fDiv.className = 'mc-friend-row';
const statusDot = f.online ? '🟢' : '⚫';
let info = statusDot + ' ' + f.nickname + ' ';
if (f.online && f.world_id) info += ' — мир ' + f.world_id;
const fInfo = document.createElement('div');
fInfo.innerHTML = info;
fDiv.appendChild(fInfo);
if (f.online && f.world_id) {
const joinBtn = document.createElement('button');
joinBtn.className = 'mc-friend-btn mc-friend-btn-green';
joinBtn.textContent = '🎮 Вступить';
joinBtn.onclick = () => {
selectedWorldId = f.world_id;
overlay.remove();
styleEl.remove();
resolve(selectedWorldId);
};
fDiv.appendChild(joinBtn);
}
body.appendChild(fDiv);
}
} else if (data.friends && !data.friends.length) {
const noFriends = document.createElement('div');
noFriends.className = 'mc-label';
noFriends.style.color = '#888';
noFriends.textContent = 'Пока нет друзей';
body.appendChild(noFriends);
}
// Pending friend requests
if (data.pending && data.pending.length) {
const pLabel = document.createElement('div');
pLabel.className = 'mc-label';
pLabel.style.color = '#B86E00';
pLabel.textContent = '📨 Запросы в друзья:';
body.appendChild(pLabel);
for (const p of data.pending) {
const pItem = document.createElement('div');
pItem.className = 'mc-friend-row';
const span = document.createElement('span');
span.textContent = p.from;
pItem.appendChild(span);
const btns = document.createElement('div');
btns.style.display = 'flex';
btns.style.gap = '4px';
const acceptBtn = document.createElement('button');
acceptBtn.className = 'mc-friend-btn mc-friend-btn-green';
acceptBtn.textContent = '✓';
acceptBtn.onclick = async () => {
const r = await fetch(state.SERVER_URL + '/api/friends/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tg_id: tgUser.id, request_id: p.id })
});
const d = await r.json();
if (d.ok) { showToast('✅ Друг добавлен!'); btnFriends.onclick(); } else { showToast('❌ Ошибка'); }
};
const declineBtn = document.createElement('button');
declineBtn.className = 'mc-friend-btn mc-friend-btn-red';
declineBtn.textContent = '✕';
declineBtn.onclick = async () => {
await fetch(state.SERVER_URL + '/api/friends/decline', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tg_id: tgUser.id, request_id: p.id })
});
showToast('Отклонено');
btnFriends.onclick();
};
btns.appendChild(acceptBtn);
btns.appendChild(declineBtn);
pItem.appendChild(btns);
body.appendChild(pItem);
}
}
// Add friend
body.appendChild(document.createElement('hr'));
const addLabel = document.createElement('div');
addLabel.className = 'mc-label';
addLabel.textContent = '➕ Добавить друга по нику:';
body.appendChild(addLabel);
const addRow = document.createElement('div');
addRow.style.display = 'flex';
addRow.style.gap = '4px';
const addInput = document.createElement('input');
addInput.className = 'mc-input';
addInput.style.flex = '1';
addInput.type = 'text';
addInput.maxLength = 16;
addInput.placeholder = 'Ник друга';
const addBtn = document.createElement('button');
addBtn.className = 'mc-btn mc-btn-blue';
addBtn.style.width = 'auto';
addBtn.style.marginBottom = '0';
addBtn.style.padding = '8px 16px';
addBtn.textContent = '+';
addBtn.onclick = async () => {
const nick = addInput.value.trim().toLowerCase();
if (!nick || nick.length < 2) { showToast('❌ Минимум 2 символа'); return; }
const r = await fetch(state.SERVER_URL + '/api/friends/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tg_id: tgUser.id, nickname: nick })
});
const d = await r.json();
if (d.ok) { showToast(d.message === 'Already friends' ? '✅ Уже друзья!' : '📨 Запрос отправлен!'); }
else { showToast('❌ ' + (d.error || 'Ошибка')); }
};
addRow.appendChild(addInput);
addRow.appendChild(addBtn);
body.appendChild(addRow);
// Privacy checkboxes
const settings = data.settings || { allow_requests: 1, notify_online: 1 };
const settingsDiv = document.createElement('div');
settingsDiv.style.marginTop = '12px';
const reqLabel = document.createElement('label');
reqLabel.className = 'mc-checkbox-row';
const reqCb = document.createElement('input');
reqCb.className = 'mc-checkbox';
reqCb.type = 'checkbox';
reqCb.checked = settings.allow_requests;
reqCb.onchange = async () => {
await fetch(state.SERVER_URL + '/api/friends/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tg_id: tgUser.id,
allow_requests: reqCb.checked ? 1 : 0,
notify_online: settings.notify_online
})
});
};
reqLabel.appendChild(reqCb);
reqLabel.appendChild(document.createTextNode(' Запросы в друзья'));
settingsDiv.appendChild(reqLabel);
body.appendChild(settingsDiv);
} catch (e) { showToast('❌ Ошибка загрузки друзей'); console.error(e); }
};
body.appendChild(btnFriends);
}
// Player name input — always visible, with Play button
{
const nameDivider = document.createElement('hr');
nameDivider.className = 'mc-divider';
body.appendChild(nameDivider);
const nameLabel = document.createElement('div');
nameLabel.className = 'mc-label';
nameLabel.textContent = 'Твоё имя:';
body.appendChild(nameLabel);
const nameRow = document.createElement('div');
nameRow.style.cssText = 'display:flex;gap:6px;';
const nameInput = document.createElement('input');
nameInput.className = 'mc-input';
nameInput.style.flex = '1';
nameInput.type = 'text';
nameInput.placeholder = 'Игрок';
nameInput.value = state.playerName || localStorage.getItem('minegrechka_playerName') || (tgUser ? (tgUser.username || tgUser.first_name || '') : '');
nameInput.maxLength = 20;
nameRow.appendChild(nameInput);
const playBtn = document.createElement('button');
playBtn.className = 'mc-btn mc-btn-green';
playBtn.style.cssText = 'width:auto;margin-bottom:0;padding:10px 20px;white-space:nowrap;';
playBtn.textContent = '▶ Играть';
playBtn.onclick = () => {
const name = nameInput.value.trim() || 'Игрок';
state.playerName = name;
localStorage.setItem('minegrechka_playerName', name);
// If no worldId yet, generate one and start
if (!selectedWorldId && !state.worldId) {
selectedWorldId = generateShortWorldCode();
}
overlay.remove();
styleEl.remove();
resolve(selectedWorldId || state.worldId);
};
nameRow.appendChild(playBtn);
body.appendChild(nameRow);
// Allow Enter key to start game
nameInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') playBtn.click();
});
// Auto-focus input on mobile
setTimeout(() => nameInput.focus(), 100);
}
box.appendChild(body);
overlay.appendChild(box);
document.body.appendChild(overlay);
// Set default player name if still empty
if (!state.playerName) {
state.playerName = localStorage.getItem('minegrechka_playerName') || 'Игрок';
localStorage.setItem('minegrechka_playerName', state.playerName);
}
// Auto-load TG save info (background fetch)
if (isTgWebApp && tgUser && !worldParam) {
fetch(state.SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id)
.then(r => r.json())
.then(data => {
if (data.ok && data.world_id) {
// could update UI but menu is already shown
}
})
.catch(() => {});
}
});
}
// === core/canvas.js ===
// ==================== ХОЛСТ 3D (WebGL) — GrechkaCraft v2 ====================
// Тот же контракт, что canvas.js: выставляет state.W/H, хранит canvas.
// WebGL-контекст создаёт render3d (лениво, при первом кадре).
function initCanvas() {
const gameEl = document.getElementById('game');
const canvas = document.getElementById('c');
const dpr = Math.max(1, window.devicePixelRatio || 1);
function resize() {
state.W = gameEl.clientWidth;
state.H = gameEl.clientHeight;
}
window.addEventListener('resize', resize);
resize();
state.gameEl = gameEl;
state.canvas = canvas;
state.ctx = null;
state.lightC = null;
state.lightCtx = null;
state.dpr = dpr;
}
function getCanvas() { return state.canvas; }
function getCtx() { return null; }
function getLightC() { return null; }
function getLightCtx() { return null; }
// === world/occ3d.js ===
// ==================== ОККЛЮЗИЯ 3D — плотная сетка ====================
// Границы мира фиксированы генератором: WORLD_DEPTH = 48 по X и Z (то есть
// gx,gz из -48..48), а по Y генерация идёт от BEDROCK_GY-1 = 44 вниз до 0.
// 97 * 45 * 97 = 423 855 клеток — 414 КБ в Uint8Array.
//
// Зачем: isHiddenAt проверяла 6 соседей через getBlock, а тот строил строку
// "gx,gy,gz" и искал в Map. На 68k кандидатов это ~409k строковых
// конкатенаций — профиль показал 129 мс из 153 мс всего перестроения мешей.
// Здесь 6 чтений массива вместо 6 строковых lookup'ов.
//
// ВАЖНО: сетку обязаны обновлять все, кто меняет мир — setBlock/removeBlock
// в world-storage3d.js. Иначе окклюзия разойдётся с реальными блоками и
// внутренние грани начнут просвечивать (или исчезнут видимые блоки).
const OCC_X = 2 * 48 + 1; // 97
const OCC_Z = 2 * 48 + 1;
const OCC_Y = 45; // gy 0..44
const OCC_MIN = -48, OCC_MAX = 48, OCC_MAX_GY = 44;
// Значения ячеек
const OCC_EMPTY = 0; // блока нет
const OCC_PRESENT = 1; // блок есть, но он прозрачный/жидкий/декор
const OCC_OPAQUE = 2; // непрозрачный блок — закрывает соседей
const occGrid = new Uint8Array(OCC_X * OCC_Y * OCC_Z);
// Раскладка: (gy*OCC_Z + (gz-OCC_MIN))*OCC_X + (gx-OCC_MIN)
// => шаг по gx = 1, по gz = OCC_X, по gy = OCC_X*OCC_Z
const OCC_STEP_X = 1;
const OCC_STEP_Z = OCC_X;
const OCC_STEP_Y = OCC_X * OCC_Z;
function occIndex(gx, gy, gz) {
if (gx < OCC_MIN || gx > OCC_MAX || gz < OCC_MIN || gz > OCC_MAX ||
gy < 0 || gy > OCC_MAX_GY) return -1;
return (gy * OCC_Z + (gz - OCC_MIN)) * OCC_X + (gx - OCC_MIN);
}
// Плотность блока для оклюзии
function occDensity(t) {
const def = BLOCKS[t];
if (!def) return OCC_EMPTY;
const opaque = !!def.solid && !def.fluid && !def.decor && !def.climbable &&
def.alpha === undefined;
return opaque ? OCC_OPAQUE : OCC_PRESENT;
}
function occSet(gx, gy, gz, density) {
const i = occIndex(gx, gy, gz);
if (i >= 0) occGrid[i] = density;
}
// Пересобрать сетку целиком из массива блоков. Нужна при загрузке сейва и
// после полной генерации — там setBlock вызывается с bumpWorld=false, но
// запись в сетку всё равно идёт, так что это страховка на случай, когда
// массив блоков был подменён снаружи.
function occRebuildFrom(blocksArr) {
occGrid.fill(OCC_EMPTY);
for (let i = 0; i < blocksArr.length; i++) {
const b = blocksArr[i];
if (b.dead) continue;
const idx = occIndex(b.gx, b.gy, b.gz || 0);
if (idx >= 0) occGrid[idx] = occDensity(b.t);
}
}
function occClear() { occGrid.fill(OCC_EMPTY); }
// === world/world-storage3d.js ===
// ==================== ХРАНИЛИЩЕ МИРА 3D — GrechkaCraft v3 ====================
// Ключ блока: "x,y,z". Единый источник для рендера, физики, сети и сейвов.
// Мир по всем трём осям: X — вперёд, Y — вверх, Z — вбок (глубина мира).
const grid = state.grid; // Map: "x,y,z" => { gx, gy, gz, t, dead }
const blocks = state.blocks; // Array для перебора/рендера
function k3(gx, gy, gz) { return gx + ',' + gy + ',' + gz; }
function getBlock(gx, gy, gz) {
const b = grid.get(k3(gx, gy, gz));
if (b && b.dead) return null;
return b || null;
}
function hasBlock(gx, gy, gz) { return grid.has(k3(gx, gy, gz)); }
function isSolid(gx, gy, gz) {
const b = getBlock(gx, gy, gz);
if (!b) return false;
const def = BLOCKS[b.t];
return !!def.solid && !def.fluid && !def.decor && !def.climbable;
}
// Непрозрачный ли блок (для отсечения внутренних граней не используется — мы рендерим кубы)
function isOpaque(gx, gy, gz) {
const b = getBlock(gx, gy, gz);
if (!b) return false;
const def = BLOCKS[b.t];
return !!def.solid && !def.fluid && !def.decor && !def.climbable;
}
// Третий аргумент isPlayerPlaced. Четвёртый — bumpWorld: при генерации мира
// версию рендера дёргать нельзя (тысячи блоков за кадр = полная перестройка
// мешей и дёрганье текстур). Меняем мир только при действиях игрока.
function setBlock(gx, gy, gz, t, isPlayerPlaced = false, bumpWorld = true) {
const key = k3(gx, gy, gz);
if (grid.has(key)) return false;
const b = { gx, gy, gz, t, dead: false, active: false, fuse: 0 };
grid.set(key, b);
blocks.push(b);
// Окклюзивная сетка обязана знать про новый блок: без этого внутренние
// грани соседей останутся видны (или пропадут видимые блоки).
occSet(gx, gy, gz, occDensity(t));
if (isPlayerPlaced) {
state.placedBlocks.push({ gx, gy, gz, t });
}
if (bumpWorld) {
state.blocksVersion = (state.blocksVersion || 0) + 1;
}
return true;
}
// Версия рендера: бамп делает только игрок (постановка/разрушение блока)
function touchWorld() {
state.blocksVersion = (state.blocksVersion || 0) + 1;
}
function removeBlock(gx, gy, gz) {
const key = k3(gx, gy, gz);
const b = grid.get(key);
if (!b) return null;
if (BLOCKS[b.t].unbreakable) return null;
grid.delete(key);
b.dead = true;
// Клетка освободилась — сбрасываем окклюзию, иначе пустота продолжит
// считаться непрозрачной и пропадёт блок вокруг дырки.
occSet(gx, gy, gz, OCC_EMPTY);
const wasPlayerPlaced = state.placedBlocks.some(pb => pb.gx === gx && pb.gy === gy && pb.gz === gz);
if (wasPlayerPlaced) {
state.placedBlocks = state.placedBlocks.filter(pb => !(pb.gx === gx && pb.gy === gy && pb.gz === gz));
} else {
state.removedBlocks.push({ gx, gy, gz });
}
state.blocksVersion = (state.blocksVersion || 0) + 1;
return b;
}
// === world/generation3d.js ===
// ==================== ГЕНЕРАЦИЯ 3D — GrechkaCraft v3 ====================
// Объёмный мир: столбцы (x,z), рельеф из 2D-шума, пещеры, руды, растительность.
// Та же система биомов, что в 2D-игре, но по двум координатам.
// Радиус мира по Z — насколько далеко вбок генерируем вокруг игрока
// Полу-ширина мира. 20 тайлов = мир 40x40 при глубине 140 — отсюда «мелкий
// мир»: игрок упирался в край за 20 шагов. 48 даёт комфортные 96x96 тайлов.
// Мир генерируется целиком один раз (pumpWorldGen) порциями по 200 колонок.
const WORLD_DEPTH = 48;
const CHUNK = 8; // шаг генерации
// ========== ШУМ (детерминированный, от worldSeed) ==========
function hash2(x, z) {
let h = x * 374761393 + z * 668265263 + state.worldSeed * 1274126177;
h = (h ^ (h >> 13)) * 1274126177;
return ((h ^ (h >> 16)) >>> 0) / 4294967295;
}
function smooth(t) { return t * t * (3 - 2 * t); }
function valueNoise2(x, z, scale) {
const fx = x / scale, fz = z / scale;
const x0 = Math.floor(fx), z0 = Math.floor(fz);
const tx = smooth(fx - x0), tz = smooth(fz - z0);
const a = hash2(x0, z0), b = hash2(x0 + 1, z0);
const c = hash2(x0, z0 + 1), d = hash2(x0 + 1, z0 + 1);
return (a * (1 - tx) + b * tx) * (1 - tz) + (c * (1 - tx) + d * tx) * tz;
}
function fbm2(x, z, octaves = 4) {
let sum = 0, amp = 0.5, freq = 1, norm = 0;
for (let i = 0; i < octaves; i++) {
sum += valueNoise2(x * freq, z * freq, 1) * amp;
norm += amp;
amp *= 0.5;
freq *= 2;
}
return sum / norm;
}
// ========== БИОМЫ (те же пять, что в 2D) ==========
const BIOMES = {
plains: { name:'Равнина', surface:'grass', subsurface:'dirt', trees:true, treeChance:0.11 },
desert: { name:'Пустыня', surface:'sand', subsurface:'sand', trees:false, treeChance:0 },
tundra: { name:'Тундра', surface:'snow', subsurface:'dirt', trees:true, treeChance:0.05 },
swamp: { name:'Болото', surface:'moss', subsurface:'dirt', trees:true, treeChance:0.09 },
mountains: { name:'Горы', surface:'stone', subsurface:'stone', trees:false, treeChance:0 },
};
function getBiome(gx, gz) {
const s = state.worldSeed;
const temp = fbm2(gx * 0.6, gz * 0.6 + 11, 3) * 0.5 + Math.sin(gx * 0.003 + s * 0.01) * 0.25 + 0.4;
const humid = fbm2(gx * 0.5 + 500, gz * 0.5 + 700, 3) * 0.5 + Math.cos(gx * 0.006 + s * 0.02) * 0.2 + 0.4;
const mt = fbm2(gx * 0.25 + 900, gz * 0.25 + 300, 3);
if (mt > 0.78) return 'mountains';
if (temp > 0.66) return 'desert';
if (temp < 0.34) return 'tundra';
if (humid > 0.66) return 'swamp';
return 'plains';
}
const biomeCache = new Map();
function getCachedBiome(gx, gz) {
const cx = Math.floor(gx / CHUNK), cz = Math.floor(gz / CHUNK);
const key = cx + ',' + cz;
if (!biomeCache.has(key)) biomeCache.set(key, getBiome(cx * CHUNK, cz * CHUNK));
return biomeCache.get(key);
}
// ========== РЕЛЬЕФ ==========
// Высота поверхности в колонке (x,z). Одна и та же функция на сервере и клиенте.
function surfaceGyAt(gx, gz) {
const biome = getCachedBiome(gx, gz);
const n1 = Math.sin(gx * 0.025 + state.worldSeed * 0.001) * 8;
const n2 = Math.sin(gz * 0.021 - state.worldSeed * 0.0015) * 7;
const n3 = Math.sin((gx + gz) * 0.012 + state.worldSeed * 0.002) * 10;
const n4 = fbm2(gx * 0.5, gz * 0.5, 3) * 9;
let h;
switch (biome) {
case 'desert': h = 8 + n1 * 0.5 + n3 * 0.3 + n4 * 0.2; break;
case 'mountains': h = 16 + n3 * 1.1 + n4 * 1.2; break;
case 'tundra': h = 9 + n1 * 0.6 + n3 * 0.5 + n4 * 0.4; break;
case 'swamp': h = 4 + n1 * 0.4 + n2 * 0.6 + n4 * 0.3; break;
default: h = 10 + n1 * 0.7 + n2 * 0.7 + n3 * 0.4 + n4 * 0.4;
}
return Math.max(2, Math.min(BEDROCK_GY - 12, Math.round(h)));
}
function seaLevel() { return SEA_GY; }
// ========== ПЕЩЕРЫ ==========
function inCave(gx, gy, gz) {
if (gy < 3) return false;
const c1 = fbm2(gx * 0.9 + 11, gz * 0.9 + gy * 1.7, 2);
const c2 = fbm2(gx * 0.9 - 40, gz * 0.9 + gy * 1.7 + 90, 2);
// две «трубки» шума: пересечение даёт объёмные полости
return c1 > 0.62 && c2 > 0.60 && gy < surfaceGyAt(gx, gz) - 3;
}
// ========== РУДЫ ==========
function oreAt(gx, gy, gz) {
if (gy < 2) return null;
const depth = gy / BEDROCK_GY;
const r = hash2(gx * 7 + gy, gz * 13);
const r2 = hash2(gx * 3 - gy, gz * 5 + 17);
if (depth < 0.30 && r > 0.982) return 'coal';
if (depth < 0.26 && r > 0.990 && r2 > 0.6) return 'copper_ore';
if (depth < 0.20 && r > 0.988 && r2 > 0.4) return 'iron_ore';
if (depth < 0.13 && r > 0.994) return 'gold_ore';
if (depth < 0.09 && r > 0.9965) return 'diamond_ore';
return null;
}
// ========== ДЕРЕВО (по XY, заполняет и Z) ==========
function growTree(gx, gy, gz, biome) {
const def = BIOMES[biome];
if (!def || !def.trees) return;
if (hash2(gx * 31, gz * 17) > def.treeChance) return;
const h = 4 + Math.floor(hash2(gx * 5, gz * 11) * 3);
const leafType = biome === 'tundra' || biome === 'swamp' ? 'spruce_leaves' : 'leaves';
for (let i = 1; i <= h; i++) {
if (!getBlock(gx, gy - i, gz)) setBlock(gx, gy - i, gz, 'wood', false, false);
}
// крона объёмом: псевдосфера
const cy = gy - h;
const rad = 2;
for (let dy = -2; dy <= 1; dy++) {
for (let dz = -rad; dz <= rad; dz++) {
for (let dx = -rad; dx <= rad; dx++) {
const dist = dx * dx + dy * dy * 2 + dz * dz;
if (dist > rad * rad + 1) continue;
const yy = cy + dy;
if (!getBlock(gx + dx, yy, gz + dz)) setBlock(gx + dx, yy, gz + dz, leafType, false, false);
}
}
}
}
function growDecor(gx, gy, gz, biome) {
const def = BIOMES[biome];
const r = hash2(gx * 23 + 5, gz * 29 + 7);
if (def.flowers && r > 0.96 && !getBlock(gx, gy, gz)) setBlock(gx, gy, gz, 'flower', false, false);
if ((biome === 'swamp' || biome === 'plains') && r > 0.985 && !getBlock(gx, gy, gz)) setBlock(gx, gy, gz, 'mushroom', false, false);
if (biome === 'desert' && r > 0.978 && !getBlock(gx, gy, gz) && gy + 1 < surfaceGyAt(gx, gz)) setBlock(gx, gy, gz, 'cactus', false, false);
if (biome === 'tundra' && r > 0.978 && !getBlock(gx, gy, gz) && gy + 2 < surfaceGyAt(gx, gz)) setBlock(gx, gy, gz, 'ice', false, false);
}
const generated = new Set(); // "cx,cz" — уже сгенерированные чанки
// ========== ГЕНЕРАЦИЯ ЧАНКА ==========
function genChunk(cx, cz) {
const key = cx + ',' + cz;
if (generated.has(key)) return false;
generated.add(key);
const x0 = cx * CHUNK, z0 = cz * CHUNK;
for (let x = x0; x < x0 + CHUNK; x++) {
for (let z = z0; z < z0 + CHUNK; z++) {
genColumn(x, z);
}
}
return true;
}
function genColumn(gx, gz) {
const biome = getCachedBiome(gx, gz);
const top = surfaceGyAt(gx, gz);
const sea = seaLevel();
// Генерируем до bedrock, но не ниже уровня «пещерной зоны» — экономим память
for (let gy = Math.min(BEDROCK_GY - 1, 44); gy >= 0; gy--) {
// бедрок внизу
if (gy >= BEDROCK_GY - 2) {
if (!getBlock(gx, gy, gz)) setBlock(gx, gy, gz, 'bedrock', false, false);
continue;
}
if (gy > top) {
// над поверхностью: вода до уровня моря
if (gy <= sea) {
const t = (biome === 'swamp') ? 'swamp_water' : 'water';
if (!getBlock(gx, gy, gz)) setBlock(gx, gy, gz, t, false, false);
}
continue;
}
// пещеры — только под поверхностью
if (gy < top - 2 && inCave(gx, gy, gz)) continue;
const ore = oreAt(gx, gy, gz);
if (ore) { setBlock(gx, gy, gz, ore, false, false); continue; }
const def = BIOMES[biome];
if (gy === top) setBlock(gx, gy, gz, def.surface, false, false);
else if (gy > top - (biome === 'desert' || biome === 'mountains' ? 5 : 3)) setBlock(gx, gy, gz, def.subsurface, false, false);
else setBlock(gx, gy, gz, 'stone', false, false);
}
// вода в пещерах не заливаем — это гравитация
// растительность
const above = getBlock(gx, top, gz);
if (above && !BLOCKS_NO_SOLID(above.t)) growDecor(gx, top, gz, biome);
if (above && !BLOCKS_NO_SOLID(above.t) && hash2(gx * 13, gz * 17) > 0.93) growTree(gx, top, gz, biome);
}
function BLOCKS_NO_SOLID(t) { return t === 'water' || t === 'swamp_water' || t === 'air'; }
// ========== ОДИН ФИКСИРОВАННЫЙ МИР ==========
// Раньше мир «стримился» вокруг игрока, а дальние чанки ВЫЧИЩАЛИСЬ. Из-за
// этого горизонт пустел, а мелкая дальняя генерация давала обрезанную землю —
// отсюда «остров, висящий в пространстве». Теперь мир генерируется ЦЕЛИКОМ
// один раз и больше никогда не вычищается. Чтобы вкладка не зависла на
// сотнях тысяч блоков, генерация идёт порциями: pumpWorldGen() зовётся из
// игрового цикла, а isWorldReady() показывает готовность для загрузочного экрана.
const genChunks = new Set();
let genQueue = null; // [gx,gz,gx,gz,...] — остаток работы
let genCursor = 0;
let worldReady = false; // мир сгенерирован целиком
// Всего колонок (2*WORLD_DEPTH+1)^2. При 48 это 97*97 = 9409 колонок по ~45
// блоков ≈ 420k блоков. За один кадр это зависание вкладки, отсюда очередь.
const WORLD_BATCH = 200; // колонок за один вызов (~9k блоков)
function buildQueue() {
const side = 2 * WORLD_DEPTH + 1;
const q = new Int32Array(side * side * 2);
let i = 0;
// Сначала колонки рядом с центром — игрок появляется уже с готовой землёй.
for (let gz = -WORLD_DEPTH; gz <= WORLD_DEPTH; gz++) {
for (let gx = -WORLD_DEPTH; gx <= WORLD_DEPTH; gx++) {
q[i++] = gx; q[i++] = gz;
}
}
return q;
}
// Прогресс 0..1 — для экрана загрузки.
function worldGenProgress() {
if (worldReady) return 1;
if (!genQueue) genQueue = buildQueue();
return genCursor / genQueue.length;
}
// Возвращает true, когда мир готов целиком.
function pumpWorldGen() {
if (worldReady) return true;
if (!genQueue) genQueue = buildQueue();
const total = genQueue.length;
const n = Math.min(WORLD_BATCH, (total - genCursor) >> 1);
for (let i = 0; i < n; i++) {
genColumn(genQueue[genCursor], genQueue[genCursor + 1]);
genCursor += 2;
}
if (genCursor >= total) {
genQueue = null;
worldReady = true;
state.worldGenReady = true;
}
return worldReady;
}
function isWorldReady() { return worldReady; }
// Совместимость с вызовами из loop3d: мир не стримится, а достраивается.
function ensureGenAroundCamera() { return pumpWorldGen(); }
function resetGenerationCache() {
generated.clear();
genChunks.clear();
biomeCache.clear();
genQueue = null;
genCursor = 0;
worldReady = false;
state.worldGenReady = false;
}
// === audio/sound-engine.js ===
// Звуковой движок
const sounds = {};
function loadSound(id, src) {
const audio = new Audio();
audio.src = src;
audio.volume = 0.3;
sounds[id] = audio;
}
// Загрузка звуков
loadSound('splash', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//splash.mp3');
loadSound('sand1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand1.mp3');
loadSound('snow1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//snow1.mp3');
loadSound('stone1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1.mp3');
loadSound('wood1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1.mp3');
loadSound('cloth1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//cloth1.mp3');
loadSound('fire', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//fire.mp3');
loadSound('hit1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hit1.mp3');
loadSound('attack', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//attack.mp3');
loadSound('hurt_chicken', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hurt1_chicken.mp3');
loadSound('stone_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1%20(1).mp3');
loadSound('wood_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1%20(1).mp3');
loadSound('click', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//click.mp3');
loadSound('explode1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//explode1.mp3');
loadSound('glass1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//glass1.mp3');
loadSound('eat1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//eat1.mp3');
loadSound('step', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand2.mp3');
function playSound(id) {
if (sounds[id]) {
sounds[id].currentTime = 0;
sounds[id].play().catch(e => console.error('Sound error:', e));
}
}
// === ui/chat.js ===
// ==================== ЧАТ ====================
const chatMessages = [];
function addChatMessage(sender, message) {
const time = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
chatMessages.push({ sender, message, time });
if (chatMessages.length > MAX_CHAT_MESSAGES) {
chatMessages.shift();
}
renderChatMessages();
}
function renderChatMessages() {
const chatMessagesEl = document.getElementById('chatMessages');
if (!chatMessagesEl) return;
chatMessagesEl.innerHTML = chatMessages.map(m =>
`${m.time} ${m.sender}: ${m.message}
`
).join('');
// Прокручиваем вниз
chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
}
function sendChatMessage(message) {
if (!message || message.trim() === '') return;
if (state.isMultiplayer && state.socket && state.socket.connected) {
state.socket.emit('chat_message', { message: message.trim() });
} else {
addChatMessage('Вы', message.trim());
}
}
function initChat() {
document.getElementById('chatToggle').onclick = () => {
playSound('click');
state.chatOpen = !state.chatOpen;
document.getElementById('chatPanel').style.display = state.chatOpen ? 'block' : 'none';
if (state.chatOpen) {
document.getElementById('chatInput').focus();
}
};
document.getElementById('chatClose').onclick = () => {
playSound('click');
state.chatOpen = false;
document.getElementById('chatPanel').style.display = 'none';
};
document.getElementById('chatSend').onclick = () => {
const input = document.getElementById('chatInput');
sendChatMessage(input.value);
input.value = '';
};
document.getElementById('chatInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
sendChatMessage(e.target.value);
e.target.value = '';
}
});
}
// === entities/player.js ===
// Функция для расчёта урона с учётом брони
function calculateDamage(baseDamage) {
const reduction = state.player.armor;
const actualDamage = baseDamage * (1 - reduction);
return actualDamage;
}
// Инициализация игрока
function initPlayer() {
state.player = {
x: 6 * TILE,
y: 0 * TILE,
w: 34,
h: 34,
vx: 0,
vy: 0,
grounded: false,
inWater: false,
headInWater: false,
hp: 100,
hunger: 100,
o2: 100,
invuln: 0,
fallStartY: 0,
lastStepTime: 0,
sleeping: false,
inBoat: false,
armor: 0,
equippedArmor: null,
temperature: 15,
slowTimer: 0
};
state.spawnPoint = { x: 6 * TILE, y: 0 * TILE };
}
// === data/items.js ===
const ITEMS = {
meat: { n:'Сырое мясо', icon:'🥩', food:15 },
cooked: { n:'Жареное мясо', icon:'🍖', food:45 },
arrow: { n:'Стрела', icon:'➡️', stack:64 },
chicken_meat: { n:'Курица', icon:'🍗', food:12, stack:64 },
feather: { n:'Перо', icon:'🪶', stack:64 },
bone: { n:'Кость', icon:'🦴', stack:64 },
gunpowder: { n:'Порох', icon:'💥', stack:64 },
// === FARMING ITEMS ===
wheat: { n:'Пшеница', icon:'🌾', stack:64 },
bread: { n:'Хлеб', icon:'🍞', food:30 },
carrot: { n:'Морковь', icon:'🥕', food:8, stack:64 },
potato: { n:'Картофель', icon:'🥔', stack:64 },
baked_potato:{ n:'Печёная картошка', icon:'🥔', food:25 },
wheat_seeds: { n:'Семена пшеницы', icon:'🌱', stack:64 },
mushroom_stew:{ n:'Грибной суп', icon:'🍲', food:40 },
// === MOB DROP ITEMS ===
scorpion_stinger:{ n:'Жало скорпиона', icon:'🔺', stack:64 },
polar_fur: { n:'Шкура медведя', icon:'🧥', stack:64 },
slime_ball: { n:'Слизь', icon:'🟢', stack:64 },
eagle_feather:{ n:'Перо орла', icon:'🪶', stack:64 },
// === ARMOR ===
gold_armor: { n:'Золотая броня', icon:'🛡️', stack:1, armor:0.65 },
};
// Runtime extensions — предметы от обжига
ITEMS.iron_ingot = { n:'Железный слиток', icon:'🔩' };
ITEMS.gold_ingot = { n:'Золотой слиток', icon:'🪙' };
ITEMS.copper_ingot = { n:'Медный слиток', icon:'🟤' };
// Axes and shovels
ITEMS.wood_axe = { n:'Деревянный топор', icon:'🪓' };
ITEMS.stone_axe = { n:'Каменный топор', icon:'🪓' };
ITEMS.iron_axe = { n:'Железный топор', icon:'🪓' };
ITEMS.wood_shovel = { n:'Деревянная лопата', icon:'🔱' };
ITEMS.stone_shovel = { n:'Каменная лопата', icon:'🔱' };
ITEMS.iron_shovel = { n:'Железная лопата', icon:'🔱' };
// === data/tools.js ===
const TOOLS = {
wood_pickaxe: { n:'Деревянная кирка', icon:'⛏️', damage:1, durability:60, toolType:'pickaxe', toolTier:'wood', speed:2 },
stone_pickaxe: { n:'Каменная кирка', icon:'⛏️', damage:2, durability:132, toolType:'pickaxe', toolTier:'stone', speed:4 },
iron_pickaxe: { n:'Железная кирка', icon:'⛏️', damage:3, durability:250, toolType:'pickaxe', toolTier:'iron', speed:6 },
wood_sword: { n:'Деревянный меч', icon:'🗡️', damage:4, durability:60 },
stone_sword: { n:'Каменный меч', icon:'🗡️', damage:5, durability:132 },
iron_sword: { n:'Железный меч', icon:'🗡️', damage:7, durability:250 },
wood_axe: { n:'Деревянный топор', icon:'🪓', damage:3, durability:60, toolType:'axe', toolTier:'wood', speed:2 },
stone_axe: { n:'Каменный топор', icon:'🪓', damage:4, durability:132, toolType:'axe', toolTier:'stone', speed:4 },
iron_axe: { n:'Железный топор', icon:'🪓', damage:5, durability:250, toolType:'axe', toolTier:'iron', speed:6 },
wood_shovel: { n:'Деревянная лопата', icon:'🔱', damage:1, durability:60, toolType:'shovel', toolTier:'wood', speed:2 },
stone_shovel: { n:'Каменная лопата', icon:'🔱', damage:2, durability:132, toolType:'shovel', toolTier:'stone', speed:4 },
iron_shovel: { n:'Железная лопата', icon:'🔱', damage:3, durability:250, toolType:'shovel', toolTier:'iron', speed:6 },
bow: { n:'Лук', icon:'🏹', damage:10, durability:200 },
};
function addTool(type) {
const maxDur = TOOLS[type]?.durability || 60;
const key = `${type}_${Date.now()}_${Math.random().toString(36).slice(2,6)}`;
state.toolDurability.set(key, { type, current: maxDur, max: maxDur });
}
function useTool(type) {
for (const [tid, dur] of state.toolDurability) {
if (dur.type === type && dur.current > 0) {
dur.current--;
if (dur.current <= 0) {
state.toolDurability.delete(tid);
state.inv[type] = Math.max(0, (state.inv[type] || 0) - 1);
return true; // broke
}
return false; // used but not broke
}
}
return false; // no durability entry found
}
function getBestTool(blockType) {
const block = BLOCKS[blockType];
if (!block || !block.toolType) return null;
const toolType = block.toolType;
for (const tier of ['iron', 'stone', 'wood']) {
const toolId = `${tier}_${toolType}`;
if (TOOLS[toolId] && state.inv[toolId] > 0) return toolId;
}
return null;
}
function toolTierLessThan(toolTier, minTier) {
const tiers = { wood: 1, stone: 2, iron: 3 };
return (tiers[toolTier] || 0) < (tiers[minTier] || 0);
}
function getMiningSpeed(blockType) {
const block = BLOCKS[blockType];
if (!block) return 1;
const toolId = getBestTool(blockType);
if (toolId) {
const tool = TOOLS[toolId];
if (tool.toolType === block.toolType) return tool.speed;
}
return 1; // Bare hands = 1 click per hit
}
// === render/textures.js ===
// Текстуры блоков (простые)
const tex = {};
// --- Item/tool sprite generator ---\n
const itemTex = {};
function drawArrow(ctx, color) {
ctx.fillStyle = '#8B4513';
ctx.fillRect(8, 14, 16, 3);
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(24, 15);
ctx.lineTo(18, 10);
ctx.lineTo(18, 20);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#e74c3c';
ctx.beginPath();
ctx.moveTo(8, 15);
ctx.lineTo(4, 10);
ctx.lineTo(10, 14);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.moveTo(8, 16);
ctx.lineTo(4, 21);
ctx.lineTo(10, 17);
ctx.closePath();
ctx.fill();
}
function drawPickaxe(ctx, headColor, handleColor) {
ctx.fillStyle = handleColor || '#8B4513';
ctx.save();
ctx.translate(16, 16);
ctx.rotate(Math.PI / 4);
ctx.fillRect(-2, 0, 4, 18);
ctx.fillStyle = headColor;
ctx.fillRect(-8, -3, 16, 5);
ctx.fillRect(-8, -3, 3, 8);
ctx.fillRect(5, -3, 3, 8);
ctx.restore();
}
function drawAxe(ctx, headColor, handleColor) {
ctx.fillStyle = handleColor || '#8B4513';
ctx.save();
ctx.translate(16, 16);
ctx.rotate(Math.PI / 4);
ctx.fillRect(-2, 0, 4, 18);
ctx.fillStyle = headColor;
ctx.fillRect(-2, -8, 10, 8);
ctx.fillRect(-2, -8, 4, 12);
ctx.restore();
}
function drawShovel(ctx, headColor, handleColor) {
ctx.fillStyle = handleColor || '#8B4513';
ctx.save();
ctx.translate(16, 16);
ctx.rotate(Math.PI / 4);
ctx.fillRect(-2, 0, 4, 18);
ctx.fillStyle = headColor;
ctx.fillRect(-6, -8, 12, 8);
ctx.fillRect(-4, -12, 8, 4);
ctx.restore();
}
function drawSword(ctx, bladeColor, guardColor) {
ctx.fillStyle = '#8B4513';
ctx.fillRect(14, 18, 4, 10);
ctx.fillStyle = guardColor || '#f1c40f';
ctx.fillRect(10, 16, 12, 3);
ctx.fillStyle = bladeColor;
ctx.beginPath();
ctx.moveTo(16, 2);
ctx.lineTo(12, 16);
ctx.lineTo(20, 16);
ctx.closePath();
ctx.fill();
}
function drawBow(ctx) {
ctx.strokeStyle = '#8B4513';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(12, 16, 12, -Math.PI * 0.7, Math.PI * 0.7);
ctx.stroke();
ctx.strokeStyle = '#ddd';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(12, 4);
ctx.lineTo(12, 28);
ctx.stroke();
}
function drawIngot(ctx, color) {
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(6, 12);
ctx.lineTo(26, 12);
ctx.lineTo(24, 24);
ctx.lineTo(8, 24);
ctx.closePath();
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.fillRect(8, 8, 16, 4);
}
function drawMeat(ctx, cooked) {
ctx.fillStyle = cooked ? '#8B4513' : '#e74c3c';
ctx.beginPath();
ctx.ellipse(16, 18, 10, 8, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#f5f5dc';
ctx.fillRect(8, 6, 3, 12);
ctx.fillRect(14, 4, 3, 8);
}
function makeItemTex(id) {
const c = document.createElement('canvas');
c.width = 32; c.height = 32;
const g = c.getContext('2d');
const tierColors = {
wood: { head: '#a0522d', blade: '#a0522d' },
stone: { head: '#7f8c8d', blade: '#95a5a6' },
iron: { head: '#bdc3c7', blade: '#ecf0f1' },
};
switch (id) {
case 'arrow':
drawArrow(g, '#7f8c8d');
break;
case 'bow':
drawBow(g);
break;
case 'wood_pickaxe':
drawPickaxe(g, tierColors.wood.head, tierColors.wood.blade);
break;
case 'stone_pickaxe':
drawPickaxe(g, tierColors.stone.head);
break;
case 'iron_pickaxe':
drawPickaxe(g, tierColors.iron.head);
break;
case 'wood_axe':
drawAxe(g, tierColors.wood.head);
break;
case 'stone_axe':
drawAxe(g, tierColors.stone.head);
break;
case 'iron_axe':
drawAxe(g, tierColors.iron.head);
break;
case 'wood_shovel':
drawShovel(g, tierColors.wood.head);
break;
case 'stone_shovel':
drawShovel(g, tierColors.stone.head);
break;
case 'iron_shovel':
drawShovel(g, tierColors.iron.head);
break;
case 'wood_sword':
drawSword(g, tierColors.wood.blade, '#a0522d');
break;
case 'stone_sword':
drawSword(g, tierColors.stone.blade, '#7f8c8d');
break;
case 'iron_sword':
drawSword(g, tierColors.iron.blade, '#bdc3c7');
break;
case 'iron_ingot':
drawIngot(g, '#bdc3c7');
break;
case 'gold_ingot':
drawIngot(g, '#f1c40f');
break;
case 'copper_ingot':
drawIngot(g, '#e67e22');
break;
case 'meat':
drawMeat(g, false);
break;
case 'cooked':
drawMeat(g, true);
break;
case 'wheat':
g.fillStyle = '#f1c40f';
g.fillRect(12, 8, 3, 18);
g.fillRect(8, 6, 10, 4);
break;
case 'bread':
g.fillStyle = '#d4a030';
g.fillRect(6, 12, 20, 12);
g.fillStyle = '#c08820';
g.fillRect(6, 12, 20, 3);
break;
case 'carrot':
g.fillStyle = '#e67e22';
g.fillRect(14, 10, 4, 18);
g.fillStyle = '#27ae60';
g.fillRect(12, 6, 8, 5);
break;
case 'potato':
g.fillStyle = '#d4a050';
g.beginPath();
g.ellipse(16, 16, 8, 6, 0, 0, Math.PI * 2);
g.fill();
g.fillStyle = '#b88a40';
g.fillRect(10, 14, 2, 1);
g.fillRect(20, 16, 2, 1);
break;
case 'baked_potato':
g.fillStyle = '#c08820';
g.beginPath();
g.ellipse(16, 16, 8, 6, 0, 0, Math.PI * 2);
g.fill();
g.fillStyle = '#f5e6cc';
g.fillRect(12, 12, 8, 6);
break;
case 'mushroom_stew':
g.fillStyle = '#8B4513';
g.fillRect(10, 16, 12, 12);
g.fillStyle = '#e74c3c';
g.beginPath();
g.ellipse(16, 14, 8, 5, 0, Math.PI, 0);
g.fill();
break;
case 'scorpion_stinger':
g.fillStyle = '#d35400';
g.beginPath();
g.moveTo(16, 4);
g.lineTo(10, 28);
g.lineTo(22, 28);
g.closePath();
g.fill();
break;
case 'polar_fur':
g.fillStyle = '#ecf0f1';
g.fillRect(6, 6, 20, 20);
g.fillStyle = '#bdc3c7';
g.fillRect(12, 12, 8, 8);
break;
case 'slime_ball':
g.fillStyle = 'rgba(46,204,113,0.8)';
g.beginPath();
g.arc(16, 16, 10, 0, Math.PI * 2);
g.fill();
g.fillStyle = '#fff';
g.fillRect(10, 10, 4, 4);
g.fillRect(18, 10, 4, 4);
break;
case 'eagle_feather':
g.fillStyle = '#8B4513';
g.fillRect(14, 4, 4, 24);
g.fillStyle = '#ecf0f1';
g.fillRect(10, 6, 12, 10);
break;
case 'chicken_meat':
drawMeat(g, false);
g.fillStyle = '#fff';
g.fillRect(12, 8, 4, 4);
break;
case 'feather':
g.fillStyle = '#ecf0f1';
g.fillRect(14, 6, 4, 20);
g.fillStyle = '#bbb';
g.fillRect(12, 8, 8, 8);
break;
case 'bone':
g.fillStyle = '#f5f5dc';
g.fillRect(12, 10, 8, 14);
g.fillRect(8, 8, 4, 4);
g.fillRect(20, 8, 4, 4);
g.fillRect(8, 22, 4, 4);
g.fillRect(20, 22, 4, 4);
break;
case 'gunpowder':
g.fillStyle = '#555';
g.fillRect(8, 10, 6, 6);
g.fillRect(18, 14, 6, 6);
g.fillRect(12, 20, 6, 6);
break;
case 'gold_armor':
g.fillStyle = '#f1c40f';
g.fillRect(6, 6, 20, 20);
g.fillStyle = '#f39c12';
g.fillRect(10, 4, 12, 6);
g.fillRect(4, 10, 6, 16);
g.fillRect(22, 10, 6, 16);
break;
case 'wheat_seeds':
g.fillStyle = '#7dcea0';
g.fillRect(14, 16, 4, 10);
g.fillStyle = '#a8e6a0';
g.fillRect(10, 8, 4, 4);
g.fillRect(18, 6, 4, 4);
g.fillRect(14, 4, 4, 4);
break;
default:
g.fillStyle = '#7f8c8d';
g.fillRect(4, 4, 24, 24);
g.fillStyle = 'rgba(255,255,255,0.2)';
g.fillRect(4, 4, 24, 4);
}
return c;
}
function makeTex(type) {
const t = BLOCKS[type];
const c = document.createElement('canvas');
c.width = 32; c.height = 32;
const g = c.getContext('2d');
if (type === 'tnt') {
g.fillStyle = '#c0392b'; g.fillRect(0, 0, 32, 32);
g.fillStyle = '#fff'; g.fillRect(0, 12, 32, 8);
g.fillStyle = '#000'; g.font = 'bold 10px sans-serif'; g.fillText('TNT', 6, 20);
return c;
}
if (type === 'campfire') {
g.fillStyle = '#5d4037'; g.fillRect(4, 26, 24, 6);
g.fillStyle = '#3e2723'; g.fillRect(7, 23, 18, 4);
return c;
}
if (type === 'torch') {
g.fillStyle = '#6d4c41'; g.fillRect(14, 10, 4, 18);
g.fillStyle = '#f39c12'; g.fillRect(12, 6, 8, 8);
return c;
}
if (type === 'glass') {
g.fillStyle = 'rgba(200,240,255,0.25)'; g.fillRect(0, 0, 32, 32);
g.strokeStyle = 'rgba(255,255,255,0.65)'; g.strokeRect(2, 2, 28, 28);
g.beginPath(); g.moveTo(5, 27); g.lineTo(27, 5); g.stroke();
return c;
}
if (type === 'water') {
g.fillStyle = t.c; g.fillRect(0, 0, 32, 32);
g.fillStyle = 'rgba(255,255,255,0.08)';
g.fillRect(0, 6, 32, 2);
return c;
}
if (type === 'bed') {
g.fillStyle = '#e91e63';
g.fillRect(0, 0, 32, 32);
g.fillStyle = '#f8bbd0';
g.fillRect(2, 2, 14, 14);
g.fillStyle = '#c2185b';
g.fillRect(16, 4, 14, 24);
g.fillStyle = '#e91e63';
g.fillRect(18, 6, 10, 20);
return c;
}
if (type === 'flower') {
g.fillStyle = '#2ecc71'; g.fillRect(14, 14, 4, 18);
g.fillStyle = t.c; g.beginPath(); g.arc(16, 12, 6, 0, 6.28); g.fill();
return c;
}
if (type === 'boat') {
g.fillStyle = '#8B4513';
g.fillRect(2, 12, 28, 8);
g.fillStyle = '#A0522D';
g.fillRect(0, 10, 32, 12);
g.fillStyle = '#DEB887';
g.fillRect(4, 14, 24, 4);
g.fillStyle = '#654321';
g.fillRect(2, 20, 28, 4);
return c;
}
if (type === 'ladder') {
g.fillStyle = '#8B4513';
g.fillRect(4, 0, 4, 32);
g.fillRect(24, 0, 4, 32);
g.fillStyle = '#A0522D';
g.fillRect(4, 4, 24, 3);
g.fillRect(4, 12, 24, 3);
g.fillRect(4, 20, 24, 3);
g.fillRect(4, 28, 24, 3);
return c;
}
// === BIOME BLOCK TEXTURES ===
if (type === 'snow') {
g.fillStyle = '#ecf0f1';
g.fillRect(0, 0, 32, 32);
g.fillStyle = '#dfe6e9';
for (let i = 0; i < 4; i++) g.fillRect((Math.random()*26)|0, (Math.random()*26)|0, 5, 3);
return c;
}
if (type === 'ice') {
g.fillStyle = '#74b9ff';
g.fillRect(0, 0, 32, 32);
g.strokeStyle = 'rgba(255,255,255,0.4)';
g.beginPath(); g.moveTo(4,10); g.lineTo(20,16); g.lineTo(10,28); g.stroke();
g.beginPath(); g.moveTo(18,4); g.lineTo(28,12); g.stroke();
return c;
}
if (type === 'cactus') {
g.fillStyle = '#27ae60';
g.fillRect(6, 2, 20, 28);
g.fillStyle = '#2ecc71';
g.fillRect(2, 8, 6, 4);
g.fillRect(24, 14, 6, 4);
g.fillStyle = '#1e8449';
g.fillRect(12, 0, 2, 30);
g.fillRect(18, 0, 2, 30);
return c;
}
if (type === 'mushroom') {
g.fillStyle = '#f5e6cc'; g.fillRect(14, 20, 4, 12);
g.fillStyle = '#e74c3c'; g.beginPath(); g.arc(16, 14, 10, Math.PI, 0); g.fill();
g.fillStyle = '#fff'; g.fillRect(10, 10, 3, 3); g.fillRect(17, 8, 4, 3);
return c;
}
if (type === 'moss') {
g.fillStyle = '#0a6640';
g.fillRect(0, 0, 32, 32);
g.fillStyle = '#1e8449';
for (let i = 0; i < 6; i++) g.fillRect((Math.random()*26)|0, (Math.random()*26)|0, 4, 3);
return c;
}
if (type === 'swamp_water') {
g.fillStyle = 'rgba(100,120,40,0.6)';
g.fillRect(0, 0, 32, 32);
g.fillStyle = 'rgba(80,100,20,0.3)';
g.fillRect(0, 10, 32, 2);
g.fillRect(0, 22, 32, 2);
return c;
}
if (type === 'farmland') {
g.fillStyle = '#8B6914';
g.fillRect(0, 0, 32, 32);
g.fillStyle = '#7a5c10';
for (let i = 0; i < 4; i++) g.fillRect(0, 6+i*8, 32, 2);
g.fillStyle = '#6B4E0A';
g.fillRect(8, 2, 2, 28);
g.fillRect(18, 2, 2, 28);
return c;
}
if (type === 'dead_bush') {
g.fillStyle = '#b2bec3';
g.fillRect(12, 16, 2, 16);
g.fillRect(8, 14, 8, 2);
g.fillRect(16, 12, 8, 2);
g.fillRect(6, 18, 4, 2);
return c;
}
if (type === 'spruce_leaves') {
g.fillStyle = '#0a6640';
g.fillRect(0, 0, 32, 32);
g.fillStyle = '#0d7a4d';
for (let i = 0; i < 5; i++) g.fillRect((Math.random()*24)|0, (Math.random()*24)|0, 6, 4);
return c;
}
// CROP STAGES
if (type.startsWith('wheat_stage') || type.startsWith('carrot_stage') || type.startsWith('potato_stage')) {
const st = parseInt(type.charAt(type.length-1));
const colors = type.startsWith('wheat') ? ['#a8e6a0','#7dcea0','#b8d730','#f1c40f'] :
type.startsWith('carrot') ? ['#a8e6a0','#f0c27a','#e8a040','#e67e22'] :
['#a8e6a0','#c8b888','#bfaa78','#dfe6e9'];
g.fillStyle = '#5d4037';
g.fillRect(15, 18, 2, 14);
if (st >= 1) {
g.fillStyle = colors[st];
g.fillRect(10, 8 + (3-st)*3, 12, 10);
} else {
g.fillStyle = '#a8e6a0';
g.fillRect(14, 22, 4, 4);
}
return c;
}
g.fillStyle = t.c || '#000';
g.fillRect(0, 0, 32, 32);
g.fillStyle = 'rgba(0,0,0,0.10)';
for (let i = 0; i < 6; i++) g.fillRect((Math.random() * 28) | 0, (Math.random() * 28) | 0, 4, 4);
if (type.endsWith('_ore') || type === 'coal') {
g.fillStyle = 'rgba(0,0,0,0.35)';
for (let i = 0; i < 4; i++) g.fillRect((Math.random() * 24) | 0, (Math.random() * 24) | 0, 6, 6);
}
return c;
}
function initTextures() {
Object.keys(BLOCKS).forEach(k => tex[k] = makeTex(k));
// Item/tool sprites
const itemIds = [
'arrow','bow',
'wood_pickaxe','stone_pickaxe','iron_pickaxe',
'wood_axe','stone_axe','iron_axe',
'wood_shovel','stone_shovel','iron_shovel',
'wood_sword','stone_sword','iron_sword',
'iron_ingot','gold_ingot','copper_ingot',
'meat','cooked',
'wheat','bread','carrot','potato','baked_potato','mushroom_stew','wheat_seeds',
'chicken_meat','feather','bone','gunpowder',
'scorpion_stinger','polar_fur','slime_ball','eagle_feather',
'gold_armor',
];
itemIds.forEach(id => { itemTex[id] = makeItemTex(id); });
}
// === ui/hotbar.js ===
// Hotbar UI — 3-slot Minecraft-style bottom bar + selected item indicator
const HOTBAR_SLOTS = 3;
let selectedItemEl = null;
function addToRecentItems(id) {
if (!id) return;
state.recentItems = state.recentItems.filter(item => item !== id);
state.recentItems.unshift(id);
state.recentItems = state.recentItems.slice(0, 5);
}
function renderSlotIcon(el, id) {
if (BLOCKS[id] && tex[id]) {
el.style.backgroundImage = `url(${tex[id].toDataURL()})`;
el.style.backgroundSize = 'cover';
} else if (itemTex[id]) {
el.style.backgroundImage = `url(${itemTex[id].toDataURL()})`;
el.style.backgroundSize = 'cover';
} else if (ITEMS[id]) {
el.textContent = ITEMS[id].icon || '📦';
} else if (TOOLS[id]) {
el.textContent = TOOLS[id].icon || '🔧';
} else if (id === 'iron_armor') {
el.textContent = '🛡️';
el.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)';
} else {
el.textContent = id.charAt(0).toUpperCase();
el.style.background = '#555';
el.style.color = '#fff';
el.style.fontWeight = '700';
el.style.fontSize = '14px';
}
}
function updateSelectedItem() {
if (!selectedItemEl) selectedItemEl = document.getElementById('selectedItem');
if (!selectedItemEl) return;
const sel = state.selected;
if (!sel || !state.inv[sel] || state.inv[sel] <= 0) {
selectedItemEl.style.display = 'none';
return;
}
selectedItemEl.style.display = 'flex';
// Clear previous content
selectedItemEl.textContent = '';
selectedItemEl.style.backgroundImage = '';
selectedItemEl.style.background = 'rgba(0,0,0,0.45)';
// Render icon
if (BLOCKS[sel] && tex[sel]) {
selectedItemEl.style.backgroundImage = `url(${tex[sel].toDataURL()})`;
selectedItemEl.style.backgroundSize = 'cover';
} else if (itemTex[sel]) {
selectedItemEl.style.backgroundImage = `url(${itemTex[sel].toDataURL()})`;
selectedItemEl.style.backgroundSize = 'cover';
} else if (ITEMS[sel]) {
selectedItemEl.textContent = ITEMS[sel].icon || '📦';
} else if (TOOLS[sel]) {
selectedItemEl.textContent = TOOLS[sel].icon || '🔧';
} else {
selectedItemEl.textContent = sel.charAt(0).toUpperCase();
}
}
function rebuildHotbar() {
const hotbarEl = state.hotbarEl;
const inv = state.inv;
const selected = state.selected;
const toolDurability = state.toolDurability;
hotbarEl.innerHTML = '';
// Get up to 3 recent items with count > 0
const items = state.recentItems.filter(id => inv[id] > 0).slice(0, HOTBAR_SLOTS);
// If selected item not in list and has count > 0, push to front
if (inv[selected] > 0 && !items.includes(selected)) {
items.unshift(selected);
if (items.length > HOTBAR_SLOTS) items.pop();
}
// Always render HOTBAR_SLOTS slots (fill empties)
for (let si = 0; si < HOTBAR_SLOTS; si++) {
const id = items[si] || null;
const s = document.createElement('div');
s.className = 'slot' + (id && id === selected ? ' sel' : '');
// Slot number indicator (top-left)
const numEl = document.createElement('div');
numEl.style.cssText = 'position:absolute;top:1px;left:3px;font-size:10px;color:rgba(255,255,255,0.55);font-weight:700;pointer-events:none;text-shadow:1px 1px 1px rgba(0,0,0,0.7);';
numEl.textContent = (si + 1).toString();
s.appendChild(numEl);
if (id) {
renderSlotIcon(s, id);
const c = document.createElement('div');
c.className = 'count';
c.textContent = inv[id];
s.appendChild(c);
s.onclick = () => {
playSound('click');
state.selected = id;
// Move to front of recentItems
state.recentItems = state.recentItems.filter(item => item !== id);
state.recentItems.unshift(id);
rebuildHotbar();
};
if (id === 'iron_armor' && state.player.equippedArmor === 'iron_armor') {
const equipped = document.createElement('div');
equipped.className = 'equipped-indicator';
equipped.textContent = '✓';
s.appendChild(equipped);
}
// Durability bar
if (TOOLS[id] && inv[id] > 0) {
let curDur = 0, maxDur = TOOLS[id].durability;
for (const [tid, dur] of toolDurability) {
if (dur.type === id) {
curDur = dur.current;
maxDur = dur.max;
break;
}
}
if (maxDur > 0) {
const bar = document.createElement('div');
bar.style.cssText = `position:absolute;bottom:0;left:0;right:0;height:3px;background:#333;border-radius:0 0 6px 6px;overflow:hidden;`;
const fill = document.createElement('div');
const pct = curDur / maxDur;
const color = pct > 0.5 ? '#2ecc71' : pct > 0.25 ? '#f39c12' : '#e74c3c';
fill.style.cssText = `width:${pct * 100}%;height:100%;background:${color};`;
bar.appendChild(fill);
s.appendChild(bar);
}
}
}
hotbarEl.appendChild(s);
}
// Update selected item indicator above hotbar
updateSelectedItem();
}
// === world/tnt3d.js ===
// ==================== TNT В 3D — GrechkaCraft v3 ====================
// Взрыв сферический: сносит блоки в радиусе по всем трём осям.
function activateTNT3D(b, fuseSeconds) {
b.active = true;
b.fuse = fuseSeconds;
playSound('click');
}
function explodeTNT3D(b, st) {
const TILE = st.TILE;
const cx = b.gx, cy = b.gy, cz = b.gz || 0;
const radius = 3.2;
const r2 = radius * radius;
// убрать сам блок
removeBlock(cx, cy, cz);
const toRemove = [];
const r = Math.ceil(radius);
for (let dx = -r; dx <= r; dx++) {
for (let dy = -r; dy <= r; dy++) {
for (let dz = -r; dz <= r; dz++) {
const d2 = dx * dx + dy * dy + dz * dz;
if (d2 > r2) continue;
const blk = getBlock(cx + dx, cy + dy, cz + dz);
if (!blk) continue;
const def = BLOCKS[blk.t];
if (!def) continue;
if (def.unbreakable) continue;
// взрывная сила падает с расстоянием
if (d2 > r2 * 0.55 && blk.t !== 'dirt' && blk.t !== 'sand' && blk.t !== 'grass') continue;
toRemove.push({ gx: cx + dx, gy: cy + dy, gz: cz + dz, t: blk.t });
}
}
}
for (const rem of toRemove) removeBlock(rem.gx, rem.gy, rem.gz);
// частицы
const parts = st.parts || (st.parts = []);
const n = 30;
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
const p = Math.acos(2 * Math.random() - 1);
const sp = 60 + Math.random() * 180;
parts.push({
x: (cx + 0.5) * TILE,
y: (cy + 0.5) * TILE,
z: (cz + 0.5) * TILE,
vx: Math.sin(p) * Math.cos(a) * sp,
vy: Math.cos(p) * sp,
vz: Math.sin(p) * Math.sin(a) * sp,
life: 0.6 + Math.random() * 0.5,
c: Math.random() > 0.5 ? '#ff7a1a' : '#f1c40f',
});
}
// урон мобам и игроку в радиусе
const dmg = (e) => {
const ex = (e.x + e.w / 2) / TILE, ey = (e.y + e.h / 2) / TILE, ez = ((e.z || 0) + e.d / 2) / TILE;
const d = Math.hypot(ex - cx, ey - cy, ez - cz);
if (d < radius + 1) {
const f = 1 - d / (radius + 1);
e.hp -= 14 * f;
e.vx += (ex - cx) * 220;
e.vy -= 300 * f;
e.vz = (e.vz || 0) + (ez - cz) * 220;
}
};
for (const m of st.mobs) dmg(m);
const p = st.player;
dmg(p);
if (p.hp <= 0 && p.invuln <= 0) { p.hp = 0; p.invuln = 1.5; }
playSound('explode1');
// взрывные частицы убрать по таймеру
st.explosionFlash = 0.4;
setBlock(cx, cy, cz, 'air');
void setBlock;
}
// === ui/inventory.js ===
// Inventory UI
function renderInventory() {
const inventoryGrid = state.inventoryGrid;
const inv = state.inv;
const selected = state.selected;
inventoryGrid.innerHTML = '';
const items = Object.keys(inv).filter(id => inv[id] > 0);
for (let i = 0; i < 21; i++) {
const slot = document.createElement('div');
slot.className = 'inv-slot' + (i < items.length && items[i] === selected ? ' sel' : '');
if (i < items.length) {
const id = items[i];
// Canvas sprite: block > itemTex > emoji fallback > unknown item fallback
if (BLOCKS[id] && tex[id]) {
slot.style.backgroundImage = `url(${tex[id].toDataURL()})`;
slot.style.backgroundSize = 'cover';
} else if (itemTex[id]) {
slot.style.backgroundImage = `url(${itemTex[id].toDataURL()})`;
slot.style.backgroundSize = 'cover';
} else if (ITEMS[id]) {
slot.textContent = ITEMS[id].icon || '📦';
} else if (TOOLS[id]) {
slot.textContent = TOOLS[id].icon || '🔧';
} else if (id === 'iron_armor') {
slot.textContent = '🛡️';
slot.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)';
} else {
// Unknown item fallback — grey box with first letter
slot.textContent = id.charAt(0).toUpperCase();
slot.style.background = '#555';
slot.style.color = '#fff';
slot.style.fontWeight = '700';
slot.style.fontSize = '14px';
}
const count = document.createElement('div');
count.className = 'inv-count';
count.textContent = inv[id];
slot.appendChild(count);
slot.onclick = () => {
playSound('click');
state.selected = id;
state.recentItems = state.recentItems.filter(item => item !== id);
state.recentItems.unshift(id);
state.recentItems = state.recentItems.slice(0, 5);
rebuildHotbar();
renderInventory();
};
// Двойной клик для надевания брони
slot.ondblclick = () => {
if (id === 'iron_armor' && inv.iron_armor > 0) {
if (state.player.equippedArmor === 'iron_armor') {
state.player.equippedArmor = null;
state.player.armor = 0;
} else {
state.player.equippedArmor = 'iron_armor';
state.player.armor = BLOCKS['iron_armor'].armor;
}
playSound('click');
renderInventory();
}
};
}
inventoryGrid.appendChild(slot);
}
}
// === game/save.js ===
// ==================== СИСТЕМА СОХРАНЕНИЯ ИГРЫ ====================
const SAVE_STORAGE_KEY = state.SAVE_KEY;
let db = null; // Оставляем для совместимости, но не используем
// Инициализация (localStorage + in-memory fallback)
function initDB() {
return new Promise((resolve) => {
console.log('Используем localStorage для сохранений (sandbox режим)');
resolve(null);
});
}
function saveGame() {
const saveData = {
version: 2,
worldSeed: state.worldSeed,
player: {
x: state.player.x,
y: state.player.y,
hp: state.player.hp,
hunger: state.player.hunger,
o2: state.player.o2
},
inventory: state.inv,
time: state.worldTime,
isNight: state.isNightTime,
dayNightCycle: state.dayNightCycle,
// Сохраняем только изменения
placedBlocks: state.placedBlocks.slice(),
removedBlocks: state.removedBlocks.slice()
};
const saveSize = JSON.stringify(saveData).length;
console.log('Сохранение: player HP:', state.player.hp, 'hunger:', state.player.hunger, 'o2:', state.player.o2);
// Пробуем сохранить в localStorage (основной метод для персистентности)
try {
localStorage.setItem(SAVE_STORAGE_KEY, JSON.stringify(saveData));
console.log(`Игра сохранена в localStorage (размер: ${saveSize} байт)`);
} catch (e) {
console.warn('Ошибка сохранения в localStorage, используем только in-memory:', e);
// Если localStorage недоступен, используем in-memory fallback
state.inMemorySave = saveData;
console.log(`Игра сохранена в памяти (размер: ${saveSize} байт)`);
}
}
function loadGame() {
return new Promise((resolve, reject) => {
// Пробуем localStorage
try {
const localSave = localStorage.getItem(SAVE_STORAGE_KEY);
if (localSave) {
const parsed = JSON.parse(localSave);
console.log('Загружено из localStorage, player HP:', parsed.player?.hp);
resolve(parsed);
return;
}
} catch (e) {
console.warn('Ошибка доступа к localStorage:', e);
}
// Если localStorage недоступен, используем in-memory сохранение
if (state.inMemorySave) {
console.log('Загружено из in-memory сохранения, player HP:', state.inMemorySave.player?.hp);
resolve(state.inMemorySave);
return;
}
console.log('Сохранение не найдено');
resolve(null);
});
}
// Миграция с версии 1 на версию 2
function migrateV1toV2(saveData) {
console.log('Миграция сохранения с версии 1 на версию 2...');
// Сохраняем seed из текущей игры (так как v1 его не хранил)
saveData.worldSeed = state.worldSeed;
// Инициализируем массивы изменений
saveData.placedBlocks = [];
saveData.removedBlocks = [];
// Для v1 нам нужно сравнить сгенерированные блоки с тем, что должно быть по seed
// Это сложно сделать без полной перегенерации, поэтому для v1 сохраняем только seed
// и при загрузке просто перегенерируем мир
// Удаляем старые данные
delete saveData.generatedBlocks;
saveData.version = 2;
console.log('Миграция завершена');
}
function applySave(saveData) {
if (!saveData) return;
console.log('=== applySave START ===');
console.log('player HP before applySave:', state.player.hp);
console.log('saveData.player.hp:', saveData.player?.hp);
// Миграция версий
if (saveData.version === 1) {
migrateV1toV2(saveData);
}
// Восстанавливаем seed
if (saveData.worldSeed !== undefined) {
state.worldSeed = saveData.worldSeed;
}
// Восстанавливаем игрока
if (saveData.player) {
// ВАЖНО: в 3D координаты из сейва НЕ применяем. Сейв 2D-версии содержит
// y в другой системе (там игрок мог стоять на 2D-поверхности), из-за чего
// после загрузки player.y улетал на +119 тайлов (5286 вместо 526) — игрок
// оказывался под миром и падал на дно. Позицию ставит ensurePlayerOnGround
// после генерации, а из сейва берём только состояние.
state.player.hunger = saveData.player.hunger;
state.player.o2 = saveData.player.o2;
// Опыт и уровень восстанавливаем с проверкой: старые сейвы полей не
// содержат, и undefined в player.xp ломает арифметику уровня.
state.player.xp = Number(saveData.player.xp) || 0;
state.player.level = Number(saveData.player.level) || 1;
// Проверяем HP из сохранения - если <= 0, устанавливаем 100
const savedHP = saveData.player.hp;
console.log('Saved HP from file:', savedHP);
if (savedHP <= 0) {
console.log('WARNING: Saved HP is <= 0, setting to 100!');
state.player.hp = 100;
} else {
state.player.hp = savedHP;
}
console.log('player HP after restore:', state.player.hp);
console.log('Сохранение: координаты игрока НЕ применяются в 3D (позицию ставит ensurePlayerOnGround)');
} else {
console.log('No player data in save, setting default HP: 100');
state.player.hp = 100;
}
console.log('=== applySave END ===');
// Восстанавливаем инвентарь
if (saveData.inventory) {
for (const key in saveData.inventory) {
state.inv[key] = saveData.inventory[key];
}
}
// Восстанавливаем время
if (saveData.time !== undefined) {
state.worldTime = saveData.time;
}
// Восстанавливаем день/ночь
if (saveData.isNight !== undefined) {
state.isNightTime = saveData.isNight;
}
if (saveData.dayNightCycle !== undefined) {
state.dayNightCycle = saveData.dayNightCycle;
}
// Перегенерируем мир по seed
resetGenerationCache();
ensureGenAroundCamera();
// Применяем изменения (только для v2)
if (saveData.version === 2) {
// Применяем блоки, установленные игроком
for (const block of saveData.placedBlocks) {
setBlock(block.gx, block.gy, block.gz || 0, block.t, true);
}
// Применяем удалённые блоки
for (const block of saveData.removedBlocks) {
removeBlock(block.gx, block.gy, block.gz || 0);
}
// Восстанавливаем массивы изменений
state.placedBlocks = saveData.placedBlocks || [];
state.removedBlocks = saveData.removedBlocks || [];
}
// Fill recentItems from inventory if empty (hotbar won.t render otherwise)
if (state.recentItems.length === 0) {
state.recentItems = Object.keys(state.inv).filter(id => state.inv[id] > 0).slice(0, 5);
}
rebuildHotbar();
renderInventory();
console.log('Игра загружена');
}
// === ui/share.js ===
// ==================== ПОДЕЛИТЬСЯ МИРОМ ====================
function initShare() {
// Keep worldId click-to-copy as before
const worldIdEl = document.getElementById('worldId');
if (worldIdEl) {
worldIdEl.onclick = () => shareWorld();
}
}
function shareWorld() {
const shareUrl = new URL(window.location.href);
shareUrl.searchParams.set('world', state.worldId);
const url = shareUrl.toString();
// In Telegram WebApp — use native share
if (isTgWebApp && window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.switchInlineQuery) {
const inviteUrl = 'https://t.me/' + (state.TELEGRAM_BOT_USERNAME || 'grechka_mkn8n_bot') + '/app?startapp=' + encodeURIComponent(state.worldId);
Telegram.WebApp.openTelegramLink(
'https://t.me/share/url?url=' + encodeURIComponent(inviteUrl) +
'&text=' + encodeURIComponent('🏗️ Заходи в GrechkaCraft! Код мира: ' + state.worldId)
);
return;
}
// Otherwise — show custom modal with URL + copy button
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);z-index:100000;display:flex;align-items:center;justify-content:center;';
const box = document.createElement('div');
box.style.cssText = 'background:#C6C6C6;border:4px solid #000;padding:20px;max-width:400px;width:90%;box-shadow:4px 4px 0 #000;font-family:Courier New,monospace;';
const title = document.createElement('div');
title.textContent = '📤 Пригласить друзей';
title.style.cssText = 'font-size:18px;font-weight:bold;margin-bottom:12px;color:#222;';
box.appendChild(title);
const codeLabel = document.createElement('div');
codeLabel.style.cssText = 'font-size:13px;color:#444;font-weight:bold;margin-bottom:4px;';
codeLabel.textContent = 'Код мира:';
box.appendChild(codeLabel);
const codeBox = document.createElement('div');
codeBox.style.cssText = 'font-size:20px;font-weight:bold;background:#fff;border:3px solid #000;padding:8px 12px;margin-bottom:12px;letter-spacing:2px;text-align:center;color:#5A8F3C;';
codeBox.textContent = state.worldId;
box.appendChild(codeBox);
const linkLabel = document.createElement('div');
linkLabel.style.cssText = 'font-size:13px;color:#444;font-weight:bold;margin-bottom:4px;';
linkLabel.textContent = 'Ссылка:';
box.appendChild(linkLabel);
const linkBox = document.createElement('input');
linkBox.type = 'text';
linkBox.readOnly = true;
linkBox.value = url;
linkBox.style.cssText = 'width:100%;padding:8px;font-family:Courier New,monospace;font-size:13px;background:#fff;border:3px solid #000;color:#222;margin-bottom:12px;box-sizing:border-box;';
linkBox.onclick = () => linkBox.select();
box.appendChild(linkBox);
const btnRow = document.createElement('div');
btnRow.style.cssText = 'display:flex;gap:8px;';
const btnCopy = document.createElement('button');
btnCopy.textContent = '📋 Скопировать';
btnCopy.style.cssText = 'flex:1;padding:10px;font-family:Courier New,monospace;font-weight:bold;cursor:pointer;border:3px solid #000;background:#5A8F3C;color:#fff;box-shadow:2px 2px 0 #000;';
btnCopy.onclick = () => {
navigator.clipboard.writeText(url).then(() => {
showToast('📋 Ссылка скопирована!');
overlay.remove();
}).catch(() => {
linkBox.select();
document.execCommand('copy');
showToast('📋 Ссылка скопирована!');
overlay.remove();
});
};
btnRow.appendChild(btnCopy);
const btnCode = document.createElement('button');
btnCode.textContent = '📋 Код';
btnCode.style.cssText = 'padding:10px;font-family:Courier New,monospace;font-weight:bold;cursor:pointer;border:3px solid #000;background:#3C5F9E;color:#fff;box-shadow:2px 2px 0 #000;';
btnCode.onclick = () => {
navigator.clipboard.writeText(state.worldId).then(() => {
showToast('📋 Код скопирован: ' + state.worldId);
}).catch(() => {
showToast('Код: ' + state.worldId);
});
};
btnRow.appendChild(btnCode);
const btnClose = document.createElement('button');
btnClose.textContent = '✕';
btnClose.style.cssText = 'padding:10px 14px;font-family:Courier New,monospace;font-weight:bold;cursor:pointer;border:3px solid #000;background:#9E2B2B;color:#fff;box-shadow:2px 2px 0 #000;';
btnClose.onclick = () => overlay.remove();
btnRow.appendChild(btnClose);
box.appendChild(btnRow);
overlay.appendChild(box);
// Close on click outside box
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
document.body.appendChild(overlay);
}
// === ui/save-controls.js ===
// ==================== МЕНЮ ⋯ + СОХРАНЕНИЕ / СБРОС ====================
let dropdownOpen = false;
function initSaveControls() {
const menuBtn = document.getElementById('menuBtn');
const menuDropdown = document.getElementById('menuDropdown');
const saveItem = document.getElementById('saveItem');
const resetItem = document.getElementById('resetItem');
const marketItem = document.getElementById('marketItem');
const shareItem = document.getElementById('shareItem');
const settingsItem = document.getElementById('settingsItem');
const quitItem = document.getElementById('quitItem');
// Убить возможный старый onclick
if (menuBtn) menuBtn.onclick = null;
// Тогл меню
if (menuBtn) {
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
dropdownOpen = !dropdownOpen;
if (menuDropdown) menuDropdown.classList.toggle('open', dropdownOpen);
});
}
// Закрытие по клику вне
document.addEventListener('click', () => {
if (dropdownOpen) {
dropdownOpen = false;
if (menuDropdown) menuDropdown.classList.remove('open');
}
});
if (menuDropdown) {
menuDropdown.addEventListener('click', (e) => e.stopPropagation());
}
// 💾 Сохранить
if (saveItem) {
saveItem.addEventListener('click', () => {
playSound('click');
saveGame();
dropdownOpen = false;
if (menuDropdown) menuDropdown.classList.remove('open');
alert('Игра сохранена!');
});
}
// 🔄 Новый мир
if (resetItem) {
resetItem.addEventListener('click', () => {
if (confirm('Вы уверены, что хотите удалить сохранение и начать новую игру?')) {
playSound('click');
try { localStorage.removeItem(state.SAVE_KEY); } catch(e) {}
state.inMemorySave = null;
state.worldId = Math.random().toString(36).substring(2, 10);
try {
const newUrl = new URL(window.location.href);
newUrl.searchParams.set('world', state.worldId);
window.history.replaceState(null, '', newUrl.toString());
} catch(e) {}
location.reload();
} else {
dropdownOpen = false;
if (menuDropdown) menuDropdown.classList.remove('open');
}
});
}
// 🏪 Рынок
if (marketItem) {
marketItem.addEventListener('click', () => {
dropdownOpen = false;
if (menuDropdown) menuDropdown.classList.remove('open');
document.dispatchEvent(new CustomEvent('toggleMarket'));
});
}
// 📤 Пригласить
if (shareItem) {
shareItem.addEventListener('click', () => {
dropdownOpen = false;
if (menuDropdown) menuDropdown.classList.remove('open');
shareWorld();
});
}
// ⚙️ Настройки
if (settingsItem) {
settingsItem.addEventListener('click', () => {
dropdownOpen = false;
menuDropdown.classList.remove('open');
const panel = document.getElementById('settingsPanel');
if (!panel) return;
// Sync controls with current state
const toggle = document.getElementById('dayNightToggle');
const timeSlider = document.getElementById('timeSlider');
const wSlider = document.getElementById('weatherIntensity');
if (toggle) toggle.checked = state.dayNightCycle !== false;
if (timeSlider) timeSlider.value = Math.round(state.worldTime * 100);
if (wSlider) wSlider.value = Math.round(state.rainIntensity * 100);
// Sync weather buttons — highlight the active type
const wt = state.weatherType || (state.isRaining ? 'rain' : 'clear');
const activeId = 'weather' + wt.charAt(0).toUpperCase() + wt.slice(1);
for (const bid of ['weatherClear','weatherRain','weatherSnow','weatherThunder']) {
const b = document.getElementById(bid);
if (b) b.style.background = (bid === activeId) ? '#3498db' : '#555';
}
panel.style.display = 'block';
});
}
// Закрытие настроек
const settingsClose = document.getElementById('settingsClose');
if (settingsClose) {
settingsClose.addEventListener('click', () => {
const settingsPanel = document.getElementById('settingsPanel');
if (settingsPanel) settingsPanel.style.display = 'none';
});
}
// Тогл день/ночи
const dayNightToggle = document.getElementById('dayNightToggle');
if (dayNightToggle) {
dayNightToggle.addEventListener('change', () => {
state.dayNightCycle = dayNightToggle.checked;
saveGame();
});
}
// 🏠 Главное меню
if (quitItem) {
quitItem.addEventListener('click', () => {
dropdownOpen = false;
if (menuDropdown) menuDropdown.classList.remove('open');
saveGame();
try {
const url = new URL(window.location.href);
url.searchParams.delete('world');
window.location.href = url.toString();
} catch(e) { location.reload(); }
});
}
}
function updateSaveButtonVisibility() {
const saveItem = document.getElementById('saveItem');
if (!saveItem) return;
saveItem.style.display = (state.isMultiplayer && state.otherPlayers.size > 0) ? 'none' : 'block';
}
// === multiplayer/socket-helpers.js ===
// ==================== СЕТЬ: отправка позиции и блоков ====================
// Три правки против 2D-версии:
// 1) шлём Z — без него игрок в 3D стоит в плоскости и не двигается вглубь;
// 2) шлём gz в block_change — иначе все блоки ложатся в gz=0;
// 3) порог отправки по Z, иначе движение вглубь не отправляется вовсе.
// Генерация случайного цвета для игрока на основе socket_id
function getRandomPlayerColor(socketId) {
const colors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e91e63', '#00bcd4'];
let hash = 0;
for (let i = 0; i < socketId.length; i++) {
hash = ((hash << 5) - hash) + socketId.charCodeAt(i);
hash = hash & hash;
}
return colors[Math.abs(hash) % colors.length];
}
// Throttle для отправки позиции (10-20 раз в секунду)
let lastMoveSendTime = 0;
let lastSentX = 0, lastSentY = 0, lastSentZ = 0;
// Отправка позиции игрока (с throttle)
function sendPlayerPosition() {
if (!state.isMultiplayer || !state.socket || !state.socket.connected) return;
const now = performance.now() / 1000;
if (now - lastMoveSendTime < MOVE_SEND_INTERVAL) return;
// Отправляем только если позиция изменилась ПО ВСЕМ ОСЯМ.
// Раньше z не сравнивался — движение вглубь мира не отправлялось.
const z = state.player.z || 0;
const dx = Math.abs(state.player.x - lastSentX);
const dy = Math.abs(state.player.y - lastSentY);
const dz = Math.abs(z - lastSentZ);
if (dx < 1 && dy < 1 && dz < 1) return;
lastMoveSendTime = now;
lastSentX = state.player.x;
lastSentY = state.player.y;
lastSentZ = z;
state.socket.emit('player_move', {
x: state.player.x, y: state.player.y, z: z,
player_name: state.playerName
});
}
// Отправка изменения блока. gz обязателен: сервер и клиент ключуют блок как "x,y,z".
function sendBlockChange(gx, gy, gz, t, op) {
if (!state.isMultiplayer || !state.socket || !state.socket.connected) return;
state.socket.emit('block_change', { gx, gy, gz: gz || 0, t, op });
}
// === ui/trade.js ===
// Trade UI — личная торговля (лицом к лицу)
function itemName(id) {
return BLOCKS[id]?.n || TOOLS[id]?.n || ITEMS[id]?.n || id;
}
function itemIcon(id) {
if (BLOCKS[id]) return '🟫';
if (TOOLS[id]) return TOOLS[id].icon || '🔧';
if (ITEMS[id]) return ITEMS[id].icon || '📦';
return '📦';
}
let tradeOpen = false;
let currentTradeId = null;
let myOffer = {};
let partnerOffer = {};
let iConfirmed = false;
function isTradeOpen() { return tradeOpen; }
function initTradeSocket(socket) {
// Incoming trade request
socket.on('trade_request', (data) => {
playSound('click');
// Show trade request modal
const overlay = document.createElement('div');
overlay.id = 'tradeRequestOverlay';
overlay.className = 'custom-modal-overlay';
overlay.innerHTML = `
🤝 ${data.from_name} хочет торговать
Принять
Отклонить
`;
document.getElementById('game').appendChild(overlay);
document.getElementById('tradeAcceptBtn').addEventListener('click', () => {
overlay.remove();
socket.emit('trade_accept', { from_socket_id: data.from });
});
document.getElementById('tradeDeclineBtn').addEventListener('click', () => {
overlay.remove();
socket.emit('trade_decline', { from_socket_id: data.from });
});
});
socket.on('trade_start', (data) => {
currentTradeId = data.trade_id;
myOffer = {};
partnerOffer = {};
iConfirmed = false;
tradeOpen = true;
renderTradeUI(data.partner_name);
state.tradePanel.style.display = 'block';
});
socket.on('trade_update', (data) => {
partnerOffer = data.player1Offer || data.player2Offer || {};
// Determine which offer is the partner's
const isP1 = state.socket && state.socket.id === data.player1Offer;
// The partner's offer is the one we didn't send
// Actually the server sends both — we figure out which is ours
// Simpler: just use the data directly
if (state.mySocketId) {
// We need to know which offer in the update is ours vs partner's
// The trade_update event doesn't tell us, but we know myOffer already
// So partnerOffer = the one that's not myOffer
}
renderTradeOffers();
});
socket.on('trade_status', (data) => {
// Update confirm buttons
const confirmBtn = document.getElementById('tradeConfirmBtn');
if (confirmBtn) {
const amIPlayer1 = true; // We need to track this
if (iConfirmed) {
confirmBtn.textContent = '✅ Ожидание...';
confirmBtn.disabled = true;
confirmBtn.style.background = '#555';
}
}
// Show partner confirmation status
const partnerStatus = document.getElementById('partnerConfirmStatus');
if (partnerStatus) {
const partnerConfirmed = data.player1Confirm || data.player2Confirm;
partnerStatus.textContent = partnerConfirmed ? '✅ Подтвердил' : '⏳ Ещё не подтвердил';
}
});
socket.on('trade_complete', (data) => {
// Add received items to inventory
const received = data.received || {};
for (const [item, qty] of Object.entries(received)) {
if (qty > 0) {
state.inv[item] = (state.inv[item] || 0) + qty;
}
}
// Remove sent items from inventory
const sent = data.sent || {};
for (const [item, qty] of Object.entries(sent)) {
if (qty > 0) {
state.inv[item] = (state.inv[item] || 0) - qty;
if (state.inv[item] < 0) state.inv[item] = 0;
}
}
addChatMessage('Система', '🤝 Сделка завершена!');
closeTrade();
});
socket.on('trade_declined', () => {
addChatMessage('Система', 'Торговля отклонена');
closeTrade();
});
socket.on('trade_cancelled', () => {
addChatMessage('Система', 'Торговля отменена');
closeTrade();
});
socket.on('trade_error', (data) => {
addChatMessage('Система', '❌ ' + data.msg);
});
}
function renderTradeUI(partnerName) {
const panel = state.tradePanel;
const content = state.tradeContent;
content.innerHTML = `
🤝 Торговля с ${partnerName}
Вы предлагаете:
+ Добавить
⇄
Партнёр предлагает:
Ожидание...
⏳ Партнёр ещё не подтвердил
✅ Подтвердить
✕ Отмена
`;
document.getElementById('tradeAddItemBtn')?.addEventListener('click', showAddItemDialog);
document.getElementById('tradeConfirmBtn')?.addEventListener('click', confirmTrade);
document.getElementById('tradeCancelBtn')?.addEventListener('click', cancelTrade);
}
function renderTradeOffers() {
const myItems = document.getElementById('myOfferItems');
if (myItems) {
const keys = Object.keys(myOffer).filter(k => myOffer[k] > 0);
if (keys.length === 0) {
myItems.innerHTML = 'Ничего ';
} else {
myItems.innerHTML = keys.map(k => `${itemIcon(k)} ${itemName(k)} x${myOffer[k]}
`).join('');
}
}
}
function showAddItemDialog() {
const inv = state.inv;
const items = Object.keys(inv).filter(id => inv[id] > 0);
if (items.length === 0) {
addChatMessage('Система', 'Инвентарь пуст!');
return;
}
const overlay = document.createElement('div');
overlay.id = 'tradeItemPicker';
overlay.className = 'custom-modal-overlay';
let html = 'Что предложить?
';
for (const id of items) {
// Show already offered count
const offered = myOffer[id] || 0;
const available = inv[id] - offered;
if (available <= 0) continue;
html += `
${itemIcon(id)}
${itemName(id)}
x${available}
`;
}
html += '
';
overlay.innerHTML = html;
document.getElementById('game').appendChild(overlay);
overlay.querySelectorAll('.trade-pick-item').forEach(el => {
el.addEventListener('click', () => {
const itemId = el.dataset.item;
// Ask quantity
const available = inv[itemId] - (myOffer[itemId] || 0);
const qty = Math.min(available, available > 1 ? promptForQty(itemId, available) : 1);
if (qty > 0) {
myOffer[itemId] = (myOffer[itemId] || 0) + qty;
renderTradeOffers();
// Reset confirm when offer changes
iConfirmed = false;
const btn = document.getElementById('tradeConfirmBtn');
if (btn) { btn.textContent = '✅ Подтвердить'; btn.disabled = false; btn.style.background = '#2ecc71'; }
}
overlay.remove();
});
});
}
function promptForQty(itemId, max) {
// Simple: just use 1 for now, can enhance later with a proper dialog
const val = parseInt(prompt(`Сколько ${itemName(itemId)}? (макс: ${max})`, '1'));
return isNaN(val) || val < 1 ? 1 : Math.min(val, max);
}
function confirmTrade() {
if (!currentTradeId || !state.socket) return;
// Deduct offered items from inventory
for (const [item, qty] of Object.entries(myOffer)) {
if (qty > 0) {
if (state.inv[item] < qty) {
addChatMessage('Система', `Недостаточно ${itemName(item)}!`);
return;
}
}
}
// Send offer + confirm
state.socket.emit('trade_offer', { trade_id: currentTradeId, offer: myOffer });
// Small delay then confirm
setTimeout(() => {
state.socket.emit('trade_confirm', { trade_id: currentTradeId });
iConfirmed = true;
const btn = document.getElementById('tradeConfirmBtn');
if (btn) { btn.textContent = '✅ Ожидание...'; btn.disabled = true; btn.style.background = '#555'; }
}, 200);
}
function cancelTrade() {
if (!currentTradeId || !state.socket) return;
state.socket.emit('trade_cancel', { trade_id: currentTradeId });
closeTrade();
}
function closeTrade() {
tradeOpen = false;
currentTradeId = null;
myOffer = {};
partnerOffer = {};
iConfirmed = false;
if (state.tradePanel) state.tradePanel.style.display = 'none';
// Remove any pick overlay
document.getElementById('tradeItemPicker')?.remove();
}
function requestTradeWithPlayer(targetName) {
if (!state.socket || !state.isMultiplayer) {
addChatMessage('Система', 'Торговля доступна только в мультиплеере');
return;
}
state.socket.emit('trade_request', { target_name: targetName });
addChatMessage('Система', `Запрос отправлен ${targetName}...`);
}
// === spawn3d.js ===
// ==================== СПАВН: единая реализация для всех путей ====================
// Раньше логика спавна была продублирована в main3d.js (ensurePlayerOnGround)
// и в socket.js (обработчик world_state). Две копии расходились: одна искала
// площадку с 2 тайлами воздуха, другая — с 3, и одна считала feet как
// (top+1)*T, другая как (top-1)*T. Итог: игрок то тонул в грунте, то падал
// в яму, камера уходила в стену, схлопывалась в первое лицо (camDist = 0),
// и весь кадр занимало пустое небо.
//
// Теперь здесь ЕДИНАЯ реализация, которую зовут и main3d, и socket.js.
//
// ---------- МАТЕМАТИКА (ось Y ВНИЗ: чем больше y, тем НИЖЕ) ----------
// Тайл gy занимает [gy*T, (gy+1)*T]. Чем больше gy — тем НИЖЕ на экране.
//
// Опора top — самый верхний блок колонки (трава/песок/камень).
// Верхняя грань опоры = (top+1)*T. Игрок стоит НА ней:
// feet = (top+1)*T
// p.y = feet - h (p.y — ВЕРХ коллайдера)
//
// Тело занимает [p.y, feet] = [(top+1)*T - h, (top+1)*T].
// При h=34 и T=40 это последние 34 px тайла top плюс 0 px выше, то есть
// тело физически В тайле top. Чтобы тело оказалось ВЫШЕ опоры, feet должен
// быть (top+1)*T и h — 34, тогда тело = [top*T+6, (top+1)*T]: тайл top
// занят опорой, и гравитация опустит игрока на верхнюю грань top+1*T.
//
// ПРАКТИКА: после установки игрок должен оказаться в тайле top+1 (воздух).
// Проверяем это: getBlock(gx, top+1, gz) === null.
const isWater = (gx, gy, gz) => {
const b = getBlock(gx, gy, gz);
if (!b) return false;
const d = BLOCKS[b.t];
return !!(d && d.fluid);
};
// Пусто = нет блока, либо блок не твёрдый, не жидкость, не лестница.
// Трава/мoss/snow имеют solid:true — они и есть поверхность, на ней стоят.
const isEmpty = (gx, gy, gz) => {
const b = getBlock(gx, gy, gz);
if (!b) return true;
const d = BLOCKS[b.t];
return !d || (!d.solid && !d.fluid && !d.climbable);
};
// Клетка годится, если:
// - top — верхний твёрдый тайл (не вода),
// - top+1 и top+2 пусты (тело игрока),
// - top+3 пуст (камера).
// Проверять top-1 бессмысленно: это ВСЕГДА грунт под поверхностью.
// Клетка годится, если top — верхний твёрдый тайл колонки (не вода),
// и тайлы top-1, top-2, top-3 пусты: ноги, голова и место для камеры.
// Обход СВЕРХУ ВНИЗ: берём самый верхний твёрдый тайл, у которого есть
// воздух. Если брать ниже, попадём внутрь толщи грунта.
// Трава/мoss/snow — декорация ПОВЕРХ грунта: она не твёрдая опора, а
// getBlock отдаёт её как обычный блок. Если брать самый верхний блок,
// получаем top = трава, а под ней (top-1) — dirt, и проверка «воздух
// выше» отвергает ВСЯ колонку: сухой площадки не найдено рядом.
//
// Правильно: опора = самый верхний блок, под которым начинается ВОЗДУХ.
// То есть идём сверху вниз, пропускаем decor-слой, и берём первый твёрдый
// слой, у которого выше (меньшие gy) 3 тайла пустых.
// Поверхность для стоянки = ВЕРХНЯЯ граница мира в колонке.
// Опора top = самый верхний блок. Но под ним (top-1) ВСЕГДА грунт —
// трава лежит на dirt, песок на stone. Проверять top-1 как «воздух» бессмысленно:
// это отвергало каждую колонку («сухой площадки не найдено рядом»).
//
// Что реально нужно: над верхним блоком колонки (top-1, top-2, top-3 —
// то есть МЕНЬШИЕ gy) должно быть ПУСТО. Для колонки dirt, grass:
// gy=10 grass, gy=9 dirt, gy=8 dirt, gy=7 … пусто
// верхний блок = 10 (grass). Выше = 9 (dirt) — ЗАНЯТО. Значит опора не 10.
// Настоящая свободная кромка = gy=7, опора под ней = 6.
//
// Значит ищем: тайл top, где top-1, top-2 пусты (ноги+голова), и top+1
// ТВЁРДЫЙ (это опора, на ней стоим). Камера требует ещё top-2 пуст.
// Опора top = САМЫЙ ВЕРХНИЙ блок колонки (не вода).
// Над ним (top+1, top+2, top+3 — БОЛЬШИЕ gy, то есть НИЖЕ по оси Y… нет,
// больше gy = ниже) — стоп. Здесь Y ВНИЗ, поэтому «выше опоры» = top+1?
// Нет: верхняя грань тайла top = (top+1)*T, значит пространство ВЫШЕ опоры
// лежит в тайле top-1 (меньший y = выше). Проверяем top-1, top-2, top-3.
const tryColumn = (gx, gz) => {
let top = -1;
for (let gy = 60; gy >= 1; gy--) {
const b = getBlock(gx, gy, gz);
if (b && !isWater(gx, gy, gz)) { top = gy; break; }
}
if (top < 4) return -1;
// свободное место ВЫШЕ опоры: тайлы с МЕНЬШИМ gy
if (!isEmpty(gx, top - 1, gz)) return -1;
if (!isEmpty(gx, top - 2, gz)) return -1;
if (!isEmpty(gx, top - 3, gz)) return -1;
return top;
};
// Расширяющаяся спираль: своя колонка, затем кольца до 40 тайлов.
function findStandSpot(startGX, startGZ) {
for (let r = 0; r <= 40; r++) {
for (let dz = -r; dz <= r; dz++) {
for (let dx = -r; dx <= r; dx++) {
if (r > 0 && Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue;
const gx = startGX + dx, gz = startGZ + dz;
const top = tryColumn(gx, gz);
if (top >= 0) return { gx, gz, top };
}
}
}
return null;
}
// Применяет найденную площадку к игроку. genRadius — сколько колонок
// сгенерировать вокруг, чтобы поиск имел смысл (в неготовом мире колонки
// пусты и tryColumn ничего не найдёт).
function placePlayerOnStandSpot(startGX, startGZ, genRadius) {
const T = state.TILE;
const p = state.player;
if (typeof genRadius === 'number') {
for (let dx = -genRadius; dx <= genRadius; dx++) {
for (let dz = -genRadius; dz <= genRadius; dz++) {
// каждый 4-й по диагонали — экономия, остальные колонки рядом
// всё равно генерируются по мере надобности
if (((dx + dz) & 3) !== 0) continue;
try { genColumn(startGX + dx, startGZ + dz); } catch (e) { /* нет генератора */ }
}
}
}
const spot = findStandSpot(startGX, startGZ);
if (!spot) {
console.warn('[Spawn] сухой площадки не найдено рядом', startGX, startGZ);
return null;
}
p.x = spot.gx * T;
p.z = spot.gz * T;
// feet = top*T (верхняя грань опоры). player.y — ВЕРХ коллайдера,
// то есть на h пикселей ВЫШЕ feet по оси Y.
p.y = (spot.top + 1) * T - p.h; // верхняя грань опоры минус высота
p.vx = 0; p.vy = 0; p.vz = 0;
p.grounded = true;
p.fallStartY = p.y;
state.spawnPoint.x = p.x;
state.spawnPoint.y = p.y;
state.spawnPoint.z = p.z;
console.log('[Spawn] игрок на опоре', spot.gx, spot.top, spot.gz, 'p.y=', p.y);
return spot;
}
// Точка входа: читает текущую позицию игрока и ставит на площадку рядом.
function ensurePlayerOnGround() {
const T = state.TILE;
const p = state.player;
const curGX = Math.floor((p.x + p.w / 2) / T);
const curGZ = Math.floor((p.z + p.d / 2) / T);
return placePlayerOnStandSpot(curGX, curGZ, 12);
}
// === multiplayer/socket.js ===
// Socket.IO клиент — инициализация и все обработчики
// Mob sizes for server mob sync (server doesn't send w/h)
const MOB_SIZES = {
pig: { w: 34, h: 24 },
chicken: { w: 26, h: 22 },
zombie: { w: 34, h: 50 },
creeper: { w: 34, h: 50 },
skeleton: { w: 34, h: 50 },
scorpion: { w: 26, h: 26 },
polar_bear: { w: 40, h: 34 },
slime: { w: 24, h: 24 },
eagle: { w: 30, h: 22 },
};
function applyMobSize(sm) {
const s = MOB_SIZES[sm.kind];
if (s && !sm.w) { sm.w = s.w; sm.h = s.h; }
return sm;
}
let socket = null;
function initSocket() {
try {
socket = io(SERVER_URL, {
path: '/socket.io/',
transports: ['websocket', 'polling']
});
// Store socket on state for other modules
state.socket = socket;
socket.on('connect', () => {
console.log(`Connected to server: ${SERVER_URL}, socket.id: ${socket.id}, worldId: ${state.worldId}`);
state.mySocketId = socket.id;
state.isMultiplayer = true;
// Присоединяемся к миру
socket.emit('join_world', { world_id: state.worldId, player_name: state.playerName });
// Показываем в UI
if (state.worldIdEl) state.worldIdEl.textContent = state.worldId;
if (state.multiplayerStatus) state.multiplayerStatus.style.display = 'block';
// Initialize trade socket handlers
initTradeSocket(socket);
});
socket.on('connect_error', (error) => {
console.error('Socket connection error:', error);
state.isMultiplayer = false;
});
socket.on('disconnect', () => {
console.log('Disconnected from server');
state.isMultiplayer = false;
state.otherPlayers.clear();
if (state.multiplayerStatus) state.multiplayerStatus.style.display = 'none';
});
// Обработка world_state
socket.on('world_state', (data) => {
console.log('Received world_state:', data);
if (data.seed !== undefined && data.seed !== state.worldSeed) {
const oldSeed = state.worldSeed;
state.worldSeed = data.seed;
console.log('World seed changed from', oldSeed, 'to', state.worldSeed);
state.generated.clear();
grid.clear();
blocks.length = 0;
// Окклюзивная сетка обновляется только из setBlock/removeBlock, а здесь
// мир чистится напрямую. Без сброса сетка осталась бы от старого сида
// и перестроение мешей отсекло бы свежие блоки как скрытые.
occClear();
state.placedBlocks = [];
state.removedBlocks = [];
console.log('World regenerated with new seed:', state.worldSeed);
// КРИТИЧНО: после перегенерации позиция игрока невалидна — он стоит
// в координатах ПРЕДЫДУЩЕГО мира и оказывается в яме. Ставим заново
// и пересчитываем опору. Иначе камера уходит в грунт, схлопывается
// в первое лицо (camDist = 0), и весь кадр занимает пустое небо.
if (typeof ensurePlayerOnGround === 'function') {
try { ensurePlayerOnGround(); } catch (e) { /* не критично */ }
}
}
if (data.blocks && Array.isArray(data.blocks)) {
for (const block of data.blocks) {
const key = k3(block.gx, block.gy, block.gz || 0);
state.serverOverrides.set(key, { op: block.op, t: block.t });
if (block.op === 'set') {
setBlock(block.gx, block.gy, block.gz || 0, block.t, false);
} else if (block.op === 'remove') {
removeBlock(block.gx, block.gy, block.gz || 0);
}
}
}
if (data.time !== undefined) {
state.worldTime = data.time;
state.isNightTime = state.worldTime > 0.5;
}
// Стартовая колонка: X=6 по центру мира, Z игрока.
// Z фиксируем ОДИН РАЗ при входе: если брать текущую позицию каждый
// раз, то второй world_state ищет площадку уже рядом с первой
// найденной точкой и уводит игрока всё дальше в сторону.
if (state.__spawnGZ === undefined) {
state.__spawnGZ = Math.floor((state.player.z || 0) / state.TILE);
}
const startGX = 6;
const startGZ = state.__spawnGZ;
// Спавн: единая реализация из spawn3d.js. Раньше здесь была вторая
// копия поиска площадки, которая считала feet как (top-1)*T и искала
// воздух НИЖЕ top — игрок тонул в грунте либо падал в яму.
placePlayerOnStandSpot(startGX, startGZ, 20);
// Позицию уже выставил placePlayerOnStandSpot (feet = (top+1)*T,
// player.y = feet - h). Здесь только обнуляем скорости.
state.player.vx = 0;
state.player.vy = 0;
state.player.vz = 0;
state.player.fallStartY = state.player.y;
state.player.hp = 100;
state.player.hunger = 100;
state.player.o2 = 100;
state.player.invuln = 0;
console.log('[MULTIPLAYER CONNECT] Player HP set to 100% on connect');
if (data.players && Array.isArray(data.players)) {
state.otherPlayers.clear();
for (const p of data.players) {
if (p.socket_id !== state.mySocketId) {
state.otherPlayers.set(p.socket_id, {
x: p.x,
y: p.y,
color: getRandomPlayerColor(p.socket_id),
name: p.player_name || 'Игрок'
});
}
}
state.playerCountEl.textContent = data.players.length;
}
// Server mobs
if (data.mobs && Array.isArray(data.mobs)) {
state.serverMobs.clear();
for (const m of data.mobs) {
let sm = { ...m, maxHp: m.maxHp || m.hp, vx: m.vx || 0, vy: m.vy || 0, grounded: false, inWater: false, aiT: 0, dir: m.dir || 1, dead: false, fuse: m.fuse || 0, shootCooldown: 2, speed: m.speed || 80 };
applyMobSize(sm);
state.serverMobs.set(m.id, sm);
}
}
// Set myTgId from server if provided
if (data.tg_id) state.myTgId = data.tg_id;
});
socket.on('player_joined', (data) => {
console.log('Player joined:', data.socket_id);
if (data.socket_id !== state.mySocketId) {
// Позиция ДРУГОГО игрока. Формула та же, что и у нас: feet на
// верхней грани опоры. Раньше было (surfaceY - 1) * TILE — это
// тайл ПОД поверхностью, то есть чужий игрок появлялся в грунте.
const spawnGX = 6;
const spawnGZ = Math.floor((state.player.z || 0) / state.TILE);
genColumn(spawnGX, spawnGZ);
const surfaceY = surfaceGyAt(spawnGX, spawnGZ);
const safeSpawnX = spawnGX * TILE;
const safeSpawnY = (surfaceY + 1) * TILE; // верхняя грань тайла
state.otherPlayers.set(data.socket_id, {
x: safeSpawnX,
y: safeSpawnY,
color: getRandomPlayerColor(data.socket_id),
name: data.player_name || 'Игрок'
});
addChatMessage('Система', `Игрок присоединился`);
updateSaveButtonVisibility();
}
});
socket.on('player_moved', (data) => {
if (data.socket_id !== state.mySocketId && state.otherPlayers.has(data.socket_id)) {
const p = state.otherPlayers.get(data.socket_id);
p.x = data.x;
p.y = data.y;
// Ось Z обязательна: без неё чужие игроки стоят в одной плоскости
// и не двигаются вглубь мира. Раньше z просто игнорировался.
p.z = data.z || 0;
if (data.player_name) {
p.name = data.player_name;
}
}
});
socket.on('player_left', (data) => {
console.log('Player left:', data.socket_id);
state.otherPlayers.delete(data.socket_id);
addChatMessage('Система', `Игрок покинул игру`);
updateSaveButtonVisibility();
});
// === MOB SYNC ===
socket.on('mob_spawned', (data) => {
let sm = { ...data, maxHp: data.maxHp || data.hp, vx: data.vx || 0, vy: data.vy || 0, grounded: false, inWater: false, aiT: 0, dir: data.dir || 1, dead: false, fuse: data.fuse || 0, shootCooldown: 2, speed: data.speed || 80 };
applyMobSize(sm);
// Ось Z у серверных мобов: без неё в сетевом режиме все мобы
// стоят в одной плоскости и не преследуют игрока по глубине.
if (sm.z === undefined) sm.z = data.z || 0;
if (sm.vz === undefined) sm.vz = data.vz || 0;
if (sm.d === undefined) sm.d = data.d || sm.w || 30;
state.serverMobs.set(data.id, sm);
});
socket.on('mob_positions', (arr) => {
for (const u of arr) {
const sm = state.serverMobs.get(u.id);
if (sm) { sm.x = u.x; sm.y = u.y; sm.vx = u.vx; sm.vy = u.vy; sm.dir = u.dir; sm.hp = u.hp; sm.fuse = u.fuse || 0; }
}
});
socket.on('mob_despawned', (data) => { state.serverMobs.delete(data.id); });
socket.on('mob_died', (data) => {
const sm = state.serverMobs.get(data.id);
if (sm && data.killer === state.mySocketId) {
if (sm.kind === 'chicken') playSound('hurt_chicken');
state.inv.meat += (sm.kind === 'chicken' ? 1 : 2);
if (sm.kind === 'skeleton') {
state.inv.arrow += 2 + Math.floor(Math.random() * 3);
if (Math.random() < 0.15) state.inv.bow = (state.inv.bow || 0) + 1;
}
rebuildHotbar();
}
state.serverMobs.delete(data.id);
});
socket.on('mob_hurt_ack', (data) => {
const sm = state.serverMobs.get(data.id);
if (sm) sm.hp = data.hp;
});
socket.on('mob_explode', (data) => {
explodeTNT3D({ gx: data.gx, gy: data.gy, gz: data.gz || 0 }, state);
state.serverMobs.delete(data.id);
});
socket.on('mob_shoot', (data) => {
state.projectiles.push({
x: data.x, y: data.y, vx: data.vx, vy: data.vy,
dmg: data.dmg, owner: 'mob', life: data.life
});
});
socket.on('block_changed', (data) => {
const key = k3(data.gx, data.gy, data.gz || 0);
state.serverOverrides.set(key, { op: data.op, t: data.t });
if (data.op === 'set') {
setBlock(data.gx, data.gy, data.gz || 0, data.t, false);
} else if (data.op === 'remove') {
removeBlock(data.gx, data.gy, data.gz || 0);
}
});
socket.on('chat_message', (data) => {
const senderName = data.socket_id === state.mySocketId ? 'Вы' : `Игрок ${data.socket_id.substring(0, 6)}`;
addChatMessage(senderName, data.message);
});
socket.on('time_update', (data) => {
if (data.time !== undefined) {
state.worldTime = data.time;
state.isNightTime = state.worldTime > 0.5;
}
});
} catch (e) {
console.error('Error initializing socket:', e);
state.isMultiplayer = false;
}
}
// === physics/water-detect3d.js ===
// ==================== ВОДА 3D — GrechkaCraft v3 ====================
// Та же логика 2D-игры (кислород, всплеск, болото), но с учётом Z.
function blockAt3(px, py, pz) {
const gx = Math.floor(px / TILE);
const gy = Math.floor(py / TILE);
const gz = Math.floor(pz / TILE);
return getBlock(gx, gy, gz);
}
function isWaterAt(px, py, pz) {
const b = blockAt3(px, py, pz);
return !!(b && (b.t === 'water' || b.t === 'swamp_water'));
}
function isSwampWaterAt(px, py, pz) {
const b = blockAt3(px, py, pz);
return !!(b && b.t === 'swamp_water');
}
// Обновляет флаги воды на сущности (игрок или моб).
// dt передаётся явно — в 2D-версии он был неявной переменной цикла.
function updateWaterFlag3(e, dt) {
const cx = e.x + e.w / 2;
const cz = (e.z || 0) + (e.d || e.w) / 2;
const wasInWater = e.inWater;
const mid = isWaterAt(cx, e.y + e.h / 2, cz);
const feet = isWaterAt(cx, e.y + e.h - 2, cz);
e.inWater = mid || feet;
e.inSwampWater = isSwampWaterAt(cx, e.y + e.h / 2, cz);
e.headInWater = isWaterAt(cx, e.y + 4, cz);
if (e === state.player && !wasInWater && e.inWater && e.vy > 100) {
playSound('splash');
}
if (e === state.player && e.inSwampWater) {
state.player.hp -= 3 * (typeof dt === 'number' ? dt : 0.016);
}
}
// Лодка тонет, если под ней нет воды (с учётом Z).
function updateWaterPhysics3(dt) {
void dt;
}
// === physics/collision3d.js ===
// ==================== ФИЗИКА 3D — GrechkaCraft v3 ====================
// Осевое разрешение столкновений по X → Z → Y, как в Minecraft.
// Автошаг на 1 блок, прыжок, лестницы, плавание, лодка.
const EPS = 0.001;
// AABB сущности. ВАЖНО: в игровом коде принято, что e.y — ЭТО ВЕРХ тела,
// а низ тела = e.y + e.h (ось Y направлена вниз, гравитация тянет в +y).
// Раньше здесь стояло y0: e.y, y1: e.y + e.h — то есть НИЗ=e.y. Это
// противоречие ломало всё: игрок оказывался внутри блока, grounded был
// всегда false, а прыжок гасился столкновением на первом шаге.
// Теперь: y0 = e.y + e.h (низ), y1 = e.y (верх) — согласовано с игрой.
function boxOf(e) {
return {
x0: e.x, x1: e.x + e.w,
y0: e.y + e.h, y1: e.y,
z0: e.z, z1: e.z + e.d,
};
}
function solidAt(gx, gy, gz) {
const b = getBlock(gx, gy, gz);
if (!b) return false;
const def = BLOCKS[b.t];
if (!def) return false;
if (def.fluid) return false;
if (def.decor || def.climbable) return false;
return !!def.solid;
}
function climbableAt(gx, gy, gz) {
const b = getBlock(gx, gy, gz);
return !!(b && BLOCKS[b.t] && BLOCKS[b.t].climbable);
}
function fluidAt(gx, gy, gz) {
const b = getBlock(gx, gy, gz);
return !!(b && BLOCKS[b.t] && BLOCKS[b.t].fluid);
}
// Пересекает ли коробка сущности твёрдый блок
// Пол под ногами. ТЕЛО ТОНКОЕ: при h = 34 и TILE = 40, как только y0
// пересекает границу тайла, floor(y0/TILE) указывает на пустой тайл над
// опорой и коллизия перестаёт видеть пол. Замер: игрок оседал на 0.85
// тайла внутрь блока. Поэтому дополнительно проверяем тайл строго ПОД
// нижней границей тела — на расстоянии EPS, чтобы поймать опору.
function groundSolidUnder(bx) {
const gy = Math.floor((bx.y0 + 0.5) / TILE);
const x0 = Math.floor(bx.x0 / TILE), x1 = Math.floor((bx.x1 - EPS) / TILE);
const z0 = Math.floor(bx.z0 / TILE), z1 = Math.floor((bx.z1 - EPS) / TILE);
for (let gx = x0; gx <= x1; gx++) {
for (let gz = z0; gz <= z1; gz++) {
if (solidAt(gx, gy, gz)) return true;
}
}
return false;
}
function hitsSolid(bx) {
if (groundSolidUnder(bx)) return true;
const x0 = Math.floor(bx.x0 / TILE), x1 = Math.floor((bx.x1 - EPS) / TILE);
// По Y берём тайлы СТРОГО пересекающиеся с телом. Низ тела (y0) может
// лежать ровно на границе тайла: когда игрок стоит на опоре, y0 кратно
// TILE и floor(y0/TILE) указывает на САМ блок опоры, а не на тайл под
// ним. Из-за этого игрок «вставал» на 0.85 тайла внутрь блока: замер
// давал feet_gy = 7.85 при опоре на тайле 7 (snow).
// Поэтому нижнюю границу округляем ВНИЗ, а верхнюю — вверх.
// Тайлы по Y: от верхнего до нижнего. Нижняя граница тела bx.y0 округляется
// ВНИЗ (bx.y0 - EPS): когда ноги стоят ровно на границе тайла, floor даёт
// сам блок опоры, а не тайл над ним — из-за этого grounded срабатывал
// слишком рано, а игрок оседал на 0.85 тайла ВНУТРЬ блока.
const y0 = Math.floor((bx.y0 - EPS) / TILE), y1 = Math.floor((bx.y1 + EPS) / TILE);
const z0 = Math.floor(bx.z0 / TILE), z1 = Math.floor((bx.z1 - EPS) / TILE);
for (let gx = x0; gx <= x1; gx++) {
for (let gy = y0; gy <= y1; gy++) {
for (let gz = z0; gz <= z1; gz++) {
if (solidAt(gx, gy, gz)) return true;
}
}
}
return false;
}
// Стоит ли сущность на чём-то (для grounded)
function checkGrounded(e) {
const bx = boxOf(e);
// Опора = тайл под ногами = под НИЗОМ тела (bx.y0). Раньше тут стоял
// bx.y1, который с новой boxOf — это ВЕРХ тела, то есть блок над головой.
// Опора — тайл СТРОГО ПОД ногами. Когда низ тела стоит ровно на границе
// (y0 кратно TILE), floor(y0/TILE) указывает на сам блок опоры, а не на
// тайл под ним, и grounded считается в тот момент, когда игрок ещё не
// встал. Поэтому округляем вниз: верхняя граница опоры = bx.y0.
const gy = Math.floor((bx.y0 - EPS) / TILE);
const x0 = Math.floor(bx.x0 / TILE), x1 = Math.floor((bx.x1 - EPS) / TILE);
const z0 = Math.floor(bx.z0 / TILE), z1 = Math.floor((bx.z1 - EPS) / TILE);
for (let gx = x0; gx <= x1; gx++) {
for (let gz = z0; gz <= z1; gz++) {
if (solidAt(gx, gy, gz)) return true;
}
}
return false;
}
function onLadder(e) {
const cx = Math.floor((e.x + e.w / 2) / TILE);
const cy = Math.floor((e.y + e.h / 2) / TILE);
const cz = Math.floor((e.z + e.d / 2) / TILE);
return climbableAt(cx, cy, cz);
}
function inFluid(e) {
const cx = Math.floor((e.x + e.w / 2) / TILE);
const cy = Math.floor((e.y + e.h * 0.4) / TILE);
const cz = Math.floor((e.z + e.d / 2) / TILE);
return fluidAt(cx, cy, cz);
}
// Разрешение одной оси. delta — смещение за кадр.
function moveAxis(e, axis, delta) {
if (delta === 0) return false;
const bx = boxOf(e);
if (axis === 'x') { bx.x0 += delta; bx.x1 += delta; }
else if (axis === 'z') { bx.z0 += delta; bx.z1 += delta; }
else { bx.y0 += delta; bx.y1 += delta; }
if (!hitsSolid(bx)) {
if (axis === 'x') e.x += delta;
else if (axis === 'z') e.z += delta;
else e.y += delta;
return false;
}
const dir = Math.sign(delta);
// Автошаг вверх на 1 блок
if ((axis === 'x' || axis === 'z') && e.grounded) {
const savedX = e.x, savedY = e.y, savedZ = e.z;
if (axis === 'x') e.x += delta; else e.z += delta;
e.y -= TILE;
if (!hitsSolid(boxOf(e))) {
return true; // шаг удался, продолжим движение
}
e.x = savedX; e.y = savedY; e.z = savedZ;
}
// Откат: двигаемся по 0.5 px, пока не упрёмся в твёрдый блок.
// ТЕЛО ТОНКОЕ (h = 34 < TILE = 40), поэтому нельзя полагаться только на
// floor() текущего положения: как только y0 пересекает границу тайла,
// тайл под ногами становится пустым и коллизия перестаёт видеть пол.
// Замер: игрок оседал на feet_gy = 8.85 при опоре на тайле 7.
// Поэтому шаг делаем ОТ ПОТОЛКА вниз и держим последнюю валидную позицию.
const stepPx = dir * 0.5;
for (let i = 0; i < TILE * 4; i++) {
const test = boxOf(e);
if (axis === 'x') { test.x0 += stepPx; test.x1 += stepPx; }
else if (axis === 'z') { test.z0 += stepPx; test.z1 += stepPx; }
else { test.y0 += stepPx; test.y1 += stepPx; }
if (hitsSolid(test)) break;
if (axis === 'x') e.x += stepPx;
else if (axis === 'z') e.z += stepPx;
else e.y += stepPx;
}
if (axis === 'x') e.vx = 0;
else if (axis === 'z') e.vz = 0;
else e.vy = 0;
return true;
}
function resolveX(e, dt) { return moveAxis(e, 'x', e.vx * dt); }
function resolveZ(e, dt) { return moveAxis(e, 'z', (e.vz || 0) * dt); }
function resolveY(e, dt) {
const delta = e.vy * dt;
// Движение ВВЕРХ (vy < 0): блокируем только если реально упёрлись головой.
// Раньше moveAxis проверял весь объём коробки, и блок ПОД ногами гасил
// прыжок в тот же кадр (vy сразу становился 0).
let hit = false;
if (delta < 0) {
const probe = boxOf(e);
probe.y0 += delta; // низ тела
probe.y1 += delta; // верх тела (ось Y вниз => уменьшение = вверх)
if (hitsSolid(probe)) {
// откатываем ровно до грани
const step = -0.5;
for (let i = 0; i < TILE * 4; i++) {
const t = boxOf(e);
t.y0 += step; t.y1 += step;
if (hitsSolid(t)) break;
e.y += step;
}
hit = true;
} else {
e.y += delta;
}
e.grounded = false;
return hit;
}
hit = moveAxis(e, 'y', delta);
e.grounded = checkGrounded(e);
return hit;
}
// === data/recipes.js ===
const RECIPES = [
{ out:'planks', qty:4, cost:{ wood:1 } },
{ out:'ladder', qty:3, cost:{ planks:7 } },
{ out:'torch', qty:2, cost:{ coal:1, planks:1 } },
{ out:'glass', qty:1, cost:{ sand:3 } },
{ out:'brick', qty:1, cost:{ stone:2, clay:1 } },
{ out:'campfire', qty:1, cost:{ wood:1, coal:1 } },
{ out:'tnt', qty:1, cost:{ sand:2, coal:1 } },
{ out:'bed', qty:1, cost:{ wood: 3, planks: 3 } },
{ out:'boat', qty:1, cost:{ wood: 5 } },
{ out:'wood_pickaxe', qty:1, cost:{ wood: 3, planks: 2 } },
{ out:'stone_pickaxe', qty:1, cost:{ wood: 1, stone: 3 } },
{ out:'iron_pickaxe', qty:1, cost:{ wood: 1, iron_ore: 2 } },
{ out:'wood_sword', qty:1, cost:{ wood: 2, planks: 1 } },
{ out:'stone_sword', qty:1, cost:{ wood: 1, stone: 2 } },
{ out:'iron_sword', qty:1, cost:{ wood: 1, iron_ore: 1 } },
{ out:'wood_axe', qty:1, cost:{ wood:3, planks:2 } },
{ out:'stone_axe', qty:1, cost:{ wood:1, stone:3 } },
{ out:'iron_axe', qty:1, cost:{ wood:1, iron_ore:2 } },
{ out:'wood_shovel', qty:1, cost:{ wood:1, planks:1 } },
{ out:'stone_shovel', qty:1, cost:{ wood:1, stone:2 } },
{ out:'iron_shovel', qty:1, cost:{ wood:1, iron_ore:1 } },
{ out:'iron_armor', qty:1, cost:{ iron_ore: 5 } },
{ out:'gold_armor', qty:1, cost:{ gold_ore: 8 } },
{ out:'furnace', qty:1, cost:{ stone: 8 } },
{ out:'bow', qty:1, cost:{ wood: 3, planks: 2 } },
{ out:'arrow', qty:4, cost:{ stone: 1, wood: 1 } },
{ out:'flag', qty:1, cost:{ planks:3, iron_ore:1 } },
{ out:'bread', qty:1, cost:{ wheat: 3 } },
{ out:'farmland', qty:1, cost:{ dirt:1 } },
{ out:'wheat_seeds', qty:1, cost:{ farmland:1, dirt:1 } },
];
// Рецепты печи (обжиг)
const SMELTING_RECIPES = [
{ in:'sand', qty:1, out:'glass', outQty:1, time:3 },
{ in:'clay', qty:1, out:'brick', outQty:1, time:3 },
{ in:'iron_ore', qty:1, out:'iron_ingot', outQty:1, time:5 },
{ in:'gold_ore', qty:1, out:'gold_ingot', outQty:1, time:5 },
{ in:'copper_ore', qty:1, out:'copper_ingot', outQty:1, time:4 },
{ in:'meat', qty:1, out:'cooked', outQty:1, time:2 },
{ in:'cobblestone', qty:1, out:'stone', outQty:1, time:2 },
{ in:'potato', qty:1, out:'baked_potato', outQty:1, time:2 }
];
// === ui/furnace.js ===
// ==================== ПЕЧЬ (ОБЖИГ) ====================
const furnacePanel = document.getElementById('furnacePanel');
const furnaceContent = document.getElementById('furnaceContent');
function initFurnace() {
document.getElementById('furnaceClose').onclick = () => {
furnacePanel.style.display = 'none';
state.currentFurnaceKey = null;
};
}
function openFurnaceUI(gx, gy, gz) {
state.currentFurnaceKey = `${gx},${gy},${gz || 0}`;
furnacePanel.style.display = 'block';
renderFurnaceUI();
}
function renderFurnaceUI() {
if (!state.currentFurnaceKey) return;
// Проверяем что печь всё ещё существует
const [fgx, fgy, fgz] = state.currentFurnaceKey.split(',').map(Number);
const fb = getBlock(fgx, fgy, fgz || 0);
if (!fb || fb.t !== 'furnace') {
furnacePanel.style.display = 'none';
state.currentFurnaceKey = null;
return;
}
// Текущий процесс обжига
const active = state.activeFurnaces.get(state.currentFurnaceKey);
let html = '';
// Доступные рецепты — показываем только те, для которых есть ресурсы
for (let i = 0; i < SMELTING_RECIPES.length; i++) {
const recipe = SMELTING_RECIPES[i];
const haveCount = state.inv[recipe.in] || 0;
const canSmelt = haveCount >= recipe.qty;
// Иконка результата
const outDef = BLOCKS[recipe.out];
const outItem = ITEMS[recipe.out];
const iconStr = outItem ? outItem.icon : (outDef ? '🧱' : '❓');
const nameStr = outItem ? outItem.n : (outDef ? outDef.n : recipe.out);
const inItem = ITEMS[recipe.in];
const inDef = BLOCKS[recipe.in];
const inName = inItem ? inItem.n : (inDef ? inDef.n : recipe.in);
html += `
`;
html += `
${iconStr}
`;
html += `
`;
html += `
${nameStr}
`;
html += `
${inName} x${recipe.qty} (есть: ${haveCount}) • ${recipe.time}с
`;
html += `
`;
html += `
🔥 `;
html += `
`;
}
// Текущий прогресс
if (active) {
const pct = Math.min(100, Math.floor((active.progress / active.recipe.time) * 100));
html += `
`;
html += `
🔥 Обжиг: ${pct}%
`;
html += `
`;
}
html += '
';
furnaceContent.innerHTML = html;
}
// Глобальная функция для кнопки обжига
window._smelt = (recipeIdx) => {
if (!state.currentFurnaceKey) return;
const recipe = SMELTING_RECIPES[recipeIdx];
if ((state.inv[recipe.in] || 0) < recipe.qty) return;
// Уже обжигаем в этой печи?
if (state.activeFurnaces.has(state.currentFurnaceKey)) return;
// Забираем ресурсы
state.inv[recipe.in] -= recipe.qty;
// Запускаем обжиг
state.activeFurnaces.set(state.currentFurnaceKey, {
recipe: recipe,
progress: 0
});
playSound('fire');
rebuildHotbar();
renderFurnaceUI();
};
// Тик печей — вызывается в главном цикле
function tickFurnaces(dt) {
for (const [key, furnace] of state.activeFurnaces) {
furnace.progress += dt;
if (furnace.progress >= furnace.recipe.time) {
// Обжиг завершён — выдаём результат
const outItem = furnace.recipe.out;
if (ITEMS[outItem]) {
state.inv[outItem] = (state.inv[outItem] || 0) + furnace.recipe.outQty;
} else if (BLOCKS[outItem]) {
state.inv[outItem] = (state.inv[outItem] || 0) + furnace.recipe.outQty;
}
playSound('stone_build');
state.activeFurnaces.delete(key);
// Если эта печь открыта — обновляем UI
if (key === state.currentFurnaceKey) {
renderFurnaceUI();
}
}
}
}
// === entities/mob-ai3d.js ===
// ==================== МОБЫ 3D: ИИ + ФИЗИКА — GrechkaCraft v3 ====================
// Вся логика из 2D-игры (все 9 видов, ночные/дневные, лут), но цель —
// настоящая 3D-точка игрока (x,y,z) и прыжки через стены по всем осям.
function isNight() {
return state.worldTime > 0.5;
}
// Центры сущностей в тайлах
function cx(e) { return (e.x + e.w / 2) / TILE; }
function cy(e) { return (e.y + e.h / 2) / TILE; }
function cz(e) { return ((e.z || 0) + (e.d || e.w) / 2) / TILE; }
function playerC() {
const p = state.player;
return { x: cx(p), y: cy(p), z: cz(p) };
}
function dist3(a, b) {
return Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
}
// Тянет моба к игроку по обеим горизонтальным осям (X и Z)
function steerToPlayer(m, pc) {
const dx = pc.x - cx(m);
const dz = pc.z - cz(m);
const len = Math.hypot(dx, dz) || 1;
m.vx = (dx / len) * (m.speed || 60);
m.vz = (dz / len) * (m.speed || 60);
m.dir = dx >= 0 ? 1 : -1;
m.faceYaw = Math.atan2(dz, dx);
}
function touchPlayer(m, pc, damage, knock = 1) {
const d = dist3({ x: cx(m), y: cy(m), z: cz(m) }, pc);
if (d < 1.6 && state.player.invuln <= 0) {
state.player.hp -= calculateDamage(damage);
state.player.invuln = 0.8;
const dx = state.player.x - m.x;
const dz = (state.player.z || 0) - (m.z || 0);
const len = Math.hypot(dx, dz) || 1;
state.player.vx += (dx / len) * 420 * knock;
state.player.vz += (dz / len) * 420 * knock;
state.player.vy -= 260 * knock;
playSound('hit1');
return true;
}
return false;
}
function mobAI(m, dt) {
updateWaterFlag3(m, dt);
const pc = playerC();
const night = isNight();
// ---------- ночные мобы днём горят ----------
// ВАЖНО: не делаем ранний return — иначе моб зависает в воздухе
// (физика ниже не выполняется). Ставим флаг и идём дальше.
const burning = (m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton') && !night;
if (burning) {
m.burning = true;
m.hp -= 30 * dt;
m.vx *= 0.5;
m.vz *= 0.5;
} else {
m.burning = false;
}
if (burning) {
// горящий моб только падает и горит — никакого ИИ
} else if (m.kind === 'zombie') {
steerToPlayer(m, pc);
if (m.inWater && Math.random() < 0.06) m.vy = -260;
touchPlayer(m, pc, 15);
} else if (m.kind === 'creeper') {
steerToPlayer(m, pc);
const d = dist3({ x: cx(m), y: cy(m), z: cz(m) }, pc);
if (m.inWater && Math.random() < 0.06) m.vy = -260;
if (d < 3) {
m.fuse -= dt;
if (m.fuse <= 0) {
explodeTNT3D({ gx: Math.floor(cx(m)), gy: Math.floor(cy(m)), gz: Math.floor(cz(m)) }, state);
m.hp = 0;
}
} else if (d < 2.5) {
m.fuse = 0.5;
}
} else if (m.kind === 'skeleton') {
steerToPlayer(m, pc);
const d = dist3({ x: cx(m), y: cy(m), z: cz(m) }, pc);
m.shootCooldown -= dt;
if (d < 15 && m.shootCooldown <= 0) {
m.shootCooldown = 2.0;
const ox = m.x + m.w / 2, oy = m.y + m.h / 3, oz = (m.z || 0) + (m.d || m.w) / 2;
const tx = state.player.x + state.player.w / 2;
const ty = state.player.y + state.player.h / 2;
const tz = (state.player.z || 0) + (state.player.d || state.player.w) / 2;
// полёт стрелы по 3D
const ddx = tx - ox, ddy = ty - oy, ddz = tz - oz;
const dist = Math.hypot(ddx, ddy, ddz) || 1;
const speed = 450;
state.projectiles.push({
x: ox, y: oy, z: oz,
vx: ddx / dist * speed,
vy: ddy / dist * speed,
vz: ddz / dist * speed,
dmg: 6, owner: 'mob', life: 3,
});
}
} else if (m.kind === 'scorpion') {
steerToPlayer(m, pc);
if (m.inWater && Math.random() < 0.06) m.vy = -260;
if (touchPlayer(m, pc, 8)) state.player.slowTimer = 3;
} else if (m.kind === 'polar_bear') {
// нейтрален: бродит
m.aiT -= dt;
if (m.aiT <= 0) {
m.aiT = 2.0 + Math.random() * 3;
const a = Math.random() * Math.PI * 2;
m.walkX = Math.cos(a);
m.walkZ = Math.sin(a);
if (Math.random() < 0.3) { m.walkX = 0; m.walkZ = 0; }
}
m.vx = (m.walkX || 0) * m.speed;
m.vz = (m.walkZ || 0) * m.speed;
if (m.inWater) m.vy = -120;
touchPlayer(m, pc, 10, 1.2);
} else if (m.kind === 'slime') {
// прыгает к игроку
m.aiT -= dt;
if (m.aiT <= 0) {
m.aiT = 1.5 + Math.random() * 1.5;
steerToPlayer(m, pc);
m.vy = -200;
}
touchPlayer(m, pc, 8);
} else if (m.kind === 'eagle') {
// летает, пикирует
const d = dist3({ x: cx(m), y: cy(m), z: cz(m) }, pc);
m.flying = true; // флаг ДО физики, иначе орёл получает гравитацию
if (d < 20) {
const dx = pc.x - cx(m), dz = pc.z - cz(m), dy = pc.y - cy(m);
const hl = Math.hypot(dx, dz) || 1;
m.vx = dx / hl * m.speed;
m.vz = dz / hl * m.speed;
m.vy = dy > 0 ? 60 : -60;
} else {
m.aiT -= dt;
if (m.aiT <= 0) {
m.aiT = 2 + Math.random() * 3;
const a = Math.random() * Math.PI * 2;
m.walkX = Math.cos(a);
m.walkZ = Math.sin(a);
}
m.vx = (m.walkX || 0) * m.speed * 0.5;
m.vz = (m.walkZ || 0) * m.speed * 0.5;
m.vy = Math.sin(performance.now() / 1000) * 30;
}
if (d < 1.6 && state.player.invuln <= 0) {
state.player.hp -= calculateDamage(10);
state.player.invuln = 0.8;
state.player.vy -= 200;
playSound('hit1');
}
m.vy *= 0.5;
} else {
// животные (pig, chicken): бродят
m.aiT -= dt;
if (m.aiT <= 0) {
m.aiT = 1.8 + Math.random() * 2.5;
const a = Math.random() * Math.PI * 2;
m.walkX = Math.cos(a);
m.walkZ = Math.sin(a);
if (Math.random() < 0.25) { m.walkX = 0; m.walkZ = 0; }
}
const sp = m.kind === 'chicken' ? 55 : 40;
m.vx = (m.walkX || 0) * sp;
m.vz = (m.walkZ || 0) * sp;
if (m.inWater) m.vy = -120;
}
// ---------- прыжок через препятствие по всем осям ----------
if (m.grounded && !m.flying && m.kind !== 'eagle') {
const speed = Math.hypot(m.vx || 0, m.vz || 0);
if (speed > 5) {
// Проверяем отдельно по X и по Z, каждая на своей оси:
// препятствие по X смотрим в колонке Z моба, и наоборот.
const feetGY = Math.floor((m.y + m.h - 1) / TILE);
const bodyGY = Math.floor((m.y + m.h / 2) / TILE);
const mcz = Math.floor(cz(m));
let blocked = false;
if (m.vx > 0) blocked = blocked || !!getBlock(Math.floor((m.x + m.w + 2) / TILE), bodyGY, mcz);
if (m.vx < 0) blocked = blocked || !!getBlock(Math.floor((m.x - 2) / TILE), bodyGY, mcz);
if (m.vz > 0) blocked = blocked || !!getBlock(Math.floor(cx(m)), bodyGY, Math.floor(((m.z || 0) + (m.d || m.w) + 2) / TILE));
if (m.vz < 0) blocked = blocked || !!getBlock(Math.floor(cx(m)), bodyGY, Math.floor(((m.z || 0) - 2) / TILE));
if (blocked) m.vy = -280;
void feetGY;
}
}
// ---------- ФИЗИКА ----------
if (!m.flying) {
const g = m.inWater ? GRAV_WATER : GRAV;
m.vy += g * dt;
}
m.y += m.vy * dt;
m.x += (m.vx || 0) * dt;
m.z += (m.vz || 0) * dt;
resolveX(m, dt);
resolveZ(m, dt);
resolveY(m, dt);
// орёл не падает
if (m.flying) m.grounded = false;
}
// Лут — та же таблица, что в 2D
function getMobLoot(kind) {
const table = {
chicken: [{item:'chicken_meat',min:1,max:2,chance:1},{item:'feather',min:0,max:1,chance:0.5}],
pig: [{item:'meat',min:1,max:2,chance:1}],
zombie: [{item:'meat',min:0,max:1,chance:0.5},{item:'iron_ingot',min:0,max:1,chance:0.3}],
skeleton: [{item:'arrow',min:2,max:4,chance:1},{item:'bone',min:0,max:2,chance:0.6},{item:'bow',min:1,max:1,chance:0.15}],
creeper: [{item:'gunpowder',min:1,max:2,chance:1}],
scorpion: [{item:'scorpion_stinger',min:0,max:1,chance:0.4}],
polar_bear:[{item:'polar_fur',min:1,max:2,chance:0.8},{item:'meat',min:1,max:2,chance:1}],
slime: [{item:'slime_ball',min:1,max:2,chance:1}],
eagle: [{item:'eagle_feather',min:1,max:2,chance:0.7}],
};
return table[kind] || [];
}
// Выдаёт лут моба в инвентарь. Возвращает список выданных предметов.
function grantMobLoot(kind) {
const granted = [];
for (const drop of getMobLoot(kind)) {
if (Math.random() > drop.chance) continue;
const qty = drop.min + Math.floor(Math.random() * (drop.max - drop.min + 1));
if (qty <= 0) continue;
if (state.inv[drop.item] === undefined) state.inv[drop.item] = 0;
state.inv[drop.item] += qty;
granted.push({ item: drop.item, qty });
}
if (granted.length) rebuildHotbar();
return granted;
}
// Убийство моба: помечает мёртвым, даёт лут, чистит из массива.
// Вызывается из mouse-handler (удар) и из цикла (смерть от урона/взрыва).
function killMob(m) {
if (m.dead) return [];
m.dead = true;
m.hp = 0;
const granted = grantMobLoot(m.kind);
if (m.kind === 'chicken') playSound('hurt_chicken');
else playSound('attack');
const i = state.mobs.indexOf(m);
if (i >= 0) state.mobs.splice(i, 1);
return granted;
}
// Смерть от урона (взрыв, падение в лаву и т.п.) — вызывается из цикла каждый кадр.
function reapDeadMobs() {
for (let i = state.mobs.length - 1; i >= 0; i--) {
const m = state.mobs[i];
if (m.hp <= 0) killMob(m);
}
}
// === world/weather.js ===
// ==================== ПОГОДА (biome-aware) ====================
// Возможные погоды по биому
const BIOME_WEATHER = {
snow: ['clear', 'snow', 'blizzard'],
tundra: ['clear', 'snow', 'blizzard'],
forest: ['clear', 'rain', 'storm'],
plains: ['clear', 'rain'],
desert: ['clear', 'clear', 'rain'],
swamp: ['clear', 'rain', 'storm'],
mountains: ['clear', 'rain', 'snow']
};
// Помощник интерполяции цветов
function lerpColor(a, b, t) {
const ar = parseInt(a.slice(1,3),16), ag = parseInt(a.slice(3,5),16), ab = parseInt(a.slice(5,7),16);
const br = parseInt(b.slice(1,3),16), bg = parseInt(b.slice(3,5),16), bb = parseInt(b.slice(5,7),16);
const r = Math.round(ar + (br - ar) * t);
const g = Math.round(ag + (bg - ag) * t);
const bl = Math.round(ab + (bb - ab) * t);
return '#' + ((1<<24)|(r<<16)|(g<<8)|bl).toString(16).slice(1);
}
// Цвета неба по биому, погоде и времени суток
function skyColor(night, biome, weatherType, worldTime) {
function weatherSky() {
switch (weatherType) {
case 'blizzard': return '#8a9bae';
case 'storm': return '#3d4f5f';
case 'rain': return '#6B7B8D';
case 'snow': return '#b8c6d4';
case 'clear': return null;
default: return null;
}
}
const dayColor = (biome === 'desert') ? '#f4d9a0' : (biome === 'tundra' || biome === 'snow') ? '#c4d4e0' : (biome === 'swamp') ? '#4a6741' : '#87CEEB';
const dawnColor = '#FF9940';
const duskMidColor = '#FF6B35';
const duskEndColor = '#1a0533';
const nightColor = '#070816';
let skyColorResult;
if (worldTime < 0.05) {
const t = worldTime / 0.05;
skyColorResult = lerpColor(dawnColor, dayColor, t);
} else if (worldTime < 0.45) {
skyColorResult = dayColor;
} else if (worldTime < 0.55) {
const t = (worldTime - 0.45) / 0.1;
if (t < 0.5) {
skyColorResult = lerpColor(dayColor, duskMidColor, t * 2);
} else {
skyColorResult = lerpColor(duskMidColor, duskEndColor, (t - 0.5) * 2);
}
} else {
skyColorResult = nightColor;
if (weatherType === 'blizzard') return '#1a1a2e';
if (weatherType === 'storm') return '#0a0a15';
return nightColor;
}
const wc = weatherSky();
if (wc) {
const blendT = 0.6;
skyColorResult = lerpColor(skyColorResult, wc, blendT);
}
return skyColorResult;
}
function updateWeather(dt) {
state.weatherTimer += dt;
if (state.weatherTimer >= state.weatherChangeInterval) {
state.weatherTimer = 0;
state.weatherChangeInterval = 60 + Math.random() * 120;
// Определяем биом по позиции игрока
const playerGx = Math.floor(state.player.x / state.TILE);
const biome = getCachedBiome(playerGx, Math.floor((state.player.z || 0) / state.TILE));
const weathers = BIOME_WEATHER[biome] || ['clear'];
const night = isNight();
const weights = weathers.map(w => {
if (w === 'clear') return night ? 2 : 4;
if (w === 'rain') return night ? 3 : 2;
if (w === 'snow') return night ? 2 : 3;
if (w === 'storm') return night ? 3 : 1;
if (w === 'blizzard') return night ? 3 : 1;
return 1;
});
const total = weights.reduce((a, b) => a + b, 0);
let r = Math.random() * total;
state.weatherType = weathers[0];
for (let i = 0; i < weathers.length; i++) {
r -= weights[i];
if (r <= 0) { state.weatherType = weathers[i]; break; }
}
state.isRaining = (state.weatherType === 'rain' || state.weatherType === 'storm');
}
// Плавная интерполяция интенсивности дождя
const rainTarget = (state.weatherType === 'rain' || state.weatherType === 'storm') ? 0.6 : 0;
state.rainIntensity += (rainTarget - state.rainIntensity) * dt * 0.5;
if (state.rainIntensity < 0.01) state.rainIntensity = 0;
// Молния (только при storm)
if (state.weatherType === 'storm') {
state.lightningTimer -= dt;
if (state.lightningTimer <= 0) {
state.lightningFlash = 0.25;
state.lightningTimer = 4 + Math.random() * 12;
}
}
if (state.lightningFlash > 0) {
state.lightningFlash -= dt;
if (state.lightningFlash < 0) state.lightningFlash = 0;
}
// Снежинки (при snow / blizzard)
if (state.weatherType === 'snow' || state.weatherType === 'blizzard') {
const spawnCount = state.weatherType === 'blizzard' ? Math.floor(80 * dt) : Math.floor(30 * dt);
const maxFlakes = state.weatherType === 'blizzard' ? 600 : 250;
for (let i = 0; i < spawnCount && state.snowflakes.length < maxFlakes; i++) {
state.snowflakes.push({
x: state.camX + Math.random() * state.W,
y: state.camY - 20,
vy: 40 + Math.random() * 60,
vx: (state.weatherType === 'blizzard' ? -60 - Math.random() * 80 : -10 - Math.random() * 20),
size: 2 + Math.random() * 3,
wobble: Math.random() * Math.PI * 2
});
}
}
// Обновление снежинок
for (let i = state.snowflakes.length - 1; i >= 0; i--) {
const s = state.snowflakes[i];
s.y += s.vy * dt;
s.x += s.vx * dt;
s.wobble += dt * 3;
s.x += Math.sin(s.wobble) * 15 * dt;
if (s.y > state.camY + state.H + 20 || s.x < state.camX - 50 || s.x > state.camX + state.W + 50) {
state.snowflakes.splice(i, 1);
}
}
// Убираем снег при смене погоды
if (state.weatherType !== 'snow' && state.weatherType !== 'blizzard' && !state.isRaining) {
if (state.snowflakes.length > 0) {
state.snowflakes.splice(0, Math.ceil(state.snowflakes.length * dt * 2));
}
}
}
function updateRain(dt) {
if (!state.isRaining || state.rainIntensity < 0.01) {
if (state.raindrops.length > 0) {
state.raindrops.splice(0, Math.ceil(state.raindrops.length * dt * 3));
}
return;
}
const spawnRate = Math.floor(state.rainIntensity * 60 * dt);
for (let i = 0; i < spawnRate && state.raindrops.length < state.MAX_RAINDROPS; i++) {
state.raindrops.push({
x: state.camX + Math.random() * state.W,
y: state.camY - 20,
vy: 400 + Math.random() * 200,
len: 8 + Math.random() * 12
});
}
for (let i = state.raindrops.length - 1; i >= 0; i--) {
const d = state.raindrops[i];
d.y += d.vy * dt;
d.x -= 30 * dt;
if (d.y > state.camY + state.H + 20) {
state.raindrops.splice(i, 1);
}
}
}
function drawWeather(ctx) {
if (state.raindrops.length > 0) {
ctx.save();
ctx.strokeStyle = 'rgba(174,194,224,0.5)';
ctx.lineWidth = 1.5;
ctx.beginPath();
for (const d of state.raindrops) {
ctx.moveTo(d.x, d.y);
ctx.lineTo(d.x - 3, d.y + d.len);
}
ctx.stroke();
ctx.restore();
}
if (state.snowflakes.length > 0) {
ctx.save();
ctx.fillStyle = 'rgba(255,255,255,0.85)';
for (const s of state.snowflakes) {
ctx.beginPath();
ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
if (state.lightningFlash > 0) {
ctx.save();
ctx.fillStyle = `rgba(255,255,255,${Math.min(1, state.lightningFlash * 4)})`;
ctx.fillRect(0, 0, state.W, state.H);
ctx.restore();
}
}
// === entities/mobs3d.js ===
// Сущности: животные + зомби + новые мобы
class Entity {
constructor(x, y, w, h) {
this.x = x;
this.y = y;
this.z = 0; // третье измерение: глубина мира
this.w = w;
this.h = h;
this.d = w; // размер по Z
this.vx = 0;
this.vy = 0;
this.vz = 0;
this.hp = 3;
this.grounded = false;
this.inWater = false;
this.aiT = 0;
this.dir = 1;
}
}
class Pig extends Entity {
constructor(x, y, z) { super(x, y, 34, 24); this.kind = 'pig'; this.z = z || 0; this.hp = 2; }
}
class Chicken extends Entity {
constructor(x, y, z) { super(x, y, 26, 22); this.kind = 'chicken'; this.z = z || 0; this.hp = 1; }
}
class Zombie extends Entity {
constructor(x, y, z) { super(x, y, 34, 50); this.kind = 'zombie'; this.z = z || 0; this.hp = 4; this.speed = 80 + Math.random() * 40; }
}
class Creeper extends Entity {
constructor(x, y, z) { super(x, y, 34, 50); this.kind = 'creeper'; this.z = z || 0; this.hp = 4; this.speed = 60 + Math.random() * 30; this.fuse = 3.2; }
}
class Skeleton extends Entity {
constructor(x, y, z) { super(x, y, 34, 50); this.kind = 'skeleton'; this.z = z || 0; this.hp = 4; this.speed = 70 + Math.random() * 30; this.shootCooldown = 0; }
}
class Scorpion extends Entity {
constructor(x, y, z) { super(x, y, 26, 26); this.kind = 'scorpion'; this.z = z || 0; this.hp = 3; this.speed = 90; this.fuse = 0; this.shootCooldown = 0; }
}
class PolarBear extends Entity {
constructor(x, y, z) { super(x, y, 40, 34); this.kind = 'polar_bear'; this.z = z || 0; this.hp = 8; this.speed = 50; this.fuse = 0; this.shootCooldown = 2; }
}
class Slime extends Entity {
constructor(x, y, z) { super(x, y, 24, 24); this.kind = 'slime'; this.z = z || 0; this.hp = 2; this.speed = 30; this.fuse = 0; this.shootCooldown = 2; }
}
class Eagle extends Entity {
constructor(x, y, z) { super(x, y, 30, 22); this.kind = 'eagle'; this.z = z || 0; this.hp = 3; this.speed = 120; this.fuse = 0; this.shootCooldown = 0; }
}
// === game/modes.js ===
// ==================== РЕЖИМЫ ====================
const MODES = [{ id: 'mine', icon: '⛏️' }, { id: 'build', icon: '🧱' }];
function mode() {
return MODES[state.modeIdx].id;
}
function initModes() {
const modeBtn = document.getElementById('modeBtn');
modeBtn.onclick = () => {
playSound('click'); // Звук клика по кнопке режима
state.modeIdx = (state.modeIdx + 1) % MODES.length;
modeBtn.textContent = MODES[state.modeIdx].icon;
};
}
// === input/mouse-handler3d.js ===
// ==================== ВЗАИМОДЕЙСТВИЕ 3D (мышь/тап) — GrechkaCraft v3 ====================
// Ключевое отличие от 2D: блок добывается/ставится ТОЛЬКО если игрок рядом
// (в пределах REACH). Касание экрана вдали — лишь поворот камеры.
// ---------- ДИСТАНЦИЯ ДОБЫЧИ (как в майнкрафте) ----------
const REACH = 5.2; // тайлов от центра игрока до центра блока
const REACH_SQ = REACH * REACH;
function distToBlockSq(gx, gy, gz) {
const p = state.player;
const TILE = state.TILE;
const px = (p.x + p.w / 2) / TILE;
const py = (p.y + p.h / 2) / TILE;
const pz = ((p.z || 0) + (p.d || p.w) / 2) / TILE;
const dx = gx + 0.5 - px;
const dy = gy + 0.5 - py;
const dz = (gz || 0) + 0.5 - pz;
return dx * dx + dy * dy + dz * dz;
}
function withinReach(gx, gy, gz) {
return distToBlockSq(gx, gy, gz) <= REACH_SQ;
}
// Моб тоже бьётся только вблизи
function withinReachEntity(e) {
const p = state.player;
const TILE = state.TILE;
const px = (p.x + p.w / 2) / TILE;
const py = (p.y + p.h / 2) / TILE;
const pz = ((p.z || 0) + (p.d || p.w) / 2) / TILE;
const ex = (e.x + e.w / 2) / TILE;
const ey = (e.y + e.h / 2) / TILE;
const ez = ((e.z || 0) + (e.d || e.w) / 2) / TILE;
const dx = ex - px, dy = ey - py, dz = ez - pz;
return dx * dx + dy * dy + dz * dz <= REACH_SQ;
}
const CROP_SEED_MAP = {
wheat_seeds: 'wheat_stage0',
carrot: 'carrot_stage0',
potato: 'potato_stage0',
};
const CROP_HARVEST_MAP = { wheat_stage3: 'wheat', carrot_stage3: 'carrot', potato_stage3: 'potato' };
// Начисление опыта. В 3D XP не считался вообще: элементы #xplevel/#xpbar
// были в HTML, но без обработчика, а player.xp нигде не рос. Теперь опыт
// даётся за добычу блока, и порог на уровень — XP_PER_LEVEL * level.
function addXP(amount) {
const pr = state.player;
if (!pr) return;
pr.xp = (pr.xp || 0) + (amount || 0);
const per = 50;
pr.level = pr.level || 1;
while (pr.xp >= per * pr.level) {
pr.xp -= per * pr.level;
pr.level++;
}
}
function handleBlockDrop(removed, inv) {
// Опыт за блок: 1 за обычный, 3 за руду.
addXP(/ore|gold_iron|diamond/i.test(removed.t) ? 3 : 1);
if (removed.t === 'grass') {
if (Math.random() < 0.5) inv.wheat_seeds = (inv.wheat_seeds || 0) + 1;
return;
}
const harvestItem = CROP_HARVEST_MAP[removed.t];
const seedItem = CROP_SEED_MAP[removed.t];
if (harvestItem) {
inv[harvestItem] = (inv[harvestItem] || 0) + (BLOCKS[removed.t].harvestQty || 1);
addToRecentItems(harvestItem);
if (seedItem) { inv[seedItem] = (inv[seedItem] || 0) + 1; addToRecentItems(seedItem); }
} else if (seedItem) {
inv[seedItem] = (inv[seedItem] || 0) + 1;
addToRecentItems(seedItem);
} else {
inv[removed.t] = (inv[removed.t] || 0) + 1;
addToRecentItems(removed.t);
}
}
const mouse = { x: null, y: null };
let miningHeld = false;
let miningInterval = null;
function stopMining() {
miningHeld = false;
if (miningInterval) { clearInterval(miningInterval); miningInterval = null; }
}
// Основное действие. ВСЕ координаты — ТАЙЛОВЫЕ (gx, gy, gz), face — нормаль.
// Раньше wx/wy приходили в ПИКСЕЛЯХ (и делились на TILE), а gz — уже
// тайловой. Смешение единиц сдвигало цель на 1 тайл: клик попадал в
// соседний блок, и печь «не открывалась» (клик уходил в dirt рядом).
// Рейкаст в render3d отдаёт block.gx/gy/gz из userData.list — тоже тайловые.
function handleTap(gx, gy, gz, face) {
state.tapDbg = 'start';
if (state.craftOpen) { state.tapDbg = 'craft-open'; return; }
if (state.player.hp <= 0) { state.tapDbg = 'dead'; return; }
const TILE = state.TILE;
// Защита от вызова в пикселях (старый путь из тач-кнопки).
if (Math.abs(gx) > 512 || Math.abs(gy) > 512) {
gx = Math.floor(gx / TILE);
gy = Math.floor(gy / TILE);
}
let z = gz === undefined || gz === null
? Math.floor(((state.player.z || 0) + state.player.d / 2) / TILE)
: (Math.abs(gz) > 512 ? Math.floor(gz / TILE) : gz);
let b = getBlock(gx, gy, z);
// Рейкаст бьёт по грани: блока по этой оси может не быть, тогда ищем
// ближайший непустой сосед по X/Y/Z — иначе клик «попадал» в пустоту.
if (!b) {
for (const d of [[1,0,0],[-1,0,0],[0,1,0],[0,-1,0],[0,0,1],[0,0,-1]]) {
const cand = getBlock(gx + d[0], gy + d[1], z + d[2]);
if (cand) { gx += d[0]; gy += d[1]; z += d[2]; b = cand; break; }
}
}
// Если блока нет — пробуем тот же столбец, но сдвинутые на 1 (декоративные/плоские)
if (!b) {
for (const dy of [-1, 1, 0]) {
const cand = getBlock(gx, gy + dy, z);
if (cand) { gy += dy; b = cand; break; }
}
}
if (!b) { state.tapDbg = 'no-block'; return; }
// ---------- ДОСТИЖИМОСТЬ ----------
if (!withinReach(gx, gy, z)) {
// Слишком далеко: подсказка, ничего не ломаем
state.tapDbg = 'far:' + gx + ',' + gy + ',' + z;
showReachHint();
return;
}
state.tapDbg = 'ok:' + (b ? b.t : '?') + ':' + gx + ',' + gy + ',' + z;
// Пробуждение: клик по кровати когда спишь
if (state.player.sleeping && b.t === 'bed') {
state.player.sleeping = false;
return;
}
if (state.player.sleeping) return;
// Печь — открываем панель обжига
if (b.t === 'furnace' && mode() === 'mine') {
openFurnaceUI(gx, gy, z);
return;
}
// ---------- Урон мобам (только вблизи) ----------
if (mode() === 'mine') {
let handled = false;
if (state.isMultiplayer) {
for (const [id, sm] of state.serverMobs) {
if (sm.dead) continue;
if (!withinReachEntity(sm)) continue;
if (state.player.invuln <= 0) {
let dmg = 1;
for (const st of ['iron_sword', 'stone_sword', 'wood_sword']) {
if (state.inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; }
}
state.socket.emit('mob_hurt', { id, dmg });
playSound('attack');
handled = true;
}
break;
}
}
if (!handled) {
for (let i = state.mobs.length - 1; i >= 0; i--) {
const m = state.mobs[i];
if (!withinReachEntity(m)) continue;
let dmg = 1;
for (const st of ['iron_sword', 'stone_sword', 'wood_sword']) {
if (state.inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; }
}
m.hp -= dmg;
const kx = (state.player.x + state.player.w / 2) - (m.x + m.w / 2);
const kz = ((state.player.z || 0) + (state.player.d || state.player.w) / 2) - ((m.z || 0) + (m.d || m.w) / 2);
m.vx += (kx / (Math.hypot(kx, kz) || 1)) * 400;
m.vz += (kz / (Math.hypot(kx, kz) || 1)) * 400;
m.vy -= 200;
playSound('attack');
if (m.hp <= 0) {
if (m.kind === 'chicken') playSound('hurt_chicken');
// Лут по виду моба. Раньше ВСЕ давали 2 мяса, поэтому зомби и белый
// медведь роняли сырое мясо, а скелет — стрелы, но не кости.
const LOOT = {
chicken: { chicken_meat: 1, feather: 1 + Math.floor(Math.random() * 2) },
pig: { meat: 2 + Math.floor(Math.random() * 2) },
cow: { meat: 2 + Math.floor(Math.random() * 2) },
sheep: { meat: 1 + Math.floor(Math.random() * 2), bone: 1 },
zombie: { meat: 1 },
skeleton: { bone: 1 + Math.floor(Math.random() * 2), arrow: 2 + Math.floor(Math.random() * 3) },
creeper: { gunpowder: 1 + Math.floor(Math.random() * 2) },
slime: { slime_ball: 1 + Math.floor(Math.random() * 2) },
scorpion: { scorpion_stinger: 1 + Math.floor(Math.random() * 2), meat: 1 },
polarbear: { polar_fur: 1, meat: 3 + Math.floor(Math.random() * 2) },
eagle: { eagle_feather: 1 + Math.floor(Math.random() * 2), feather: 1 }
};
const drop = LOOT[m.kind] || { meat: 1 };
for (const item in drop) {
const n = drop[item];
if (n > 0) state.inv[item] = (state.inv[item] || 0) + n;
}
if (m.kind === 'skeleton' && Math.random() < 0.15) {
state.inv.bow = (state.inv.bow || 0) + 1;
}
state.mobs.splice(i, 1);
rebuildHotbar();
}
handled = true;
break;
}
}
if (handled) return;
}
// ---------- Лук ----------
if (state.selected === 'bow' && state.inv.bow > 0 && state.inv.arrow > 0) {
const p = state.player;
const ox = p.x + p.w / 2, oy = p.y + p.h / 3, oz = (p.z || 0) + (p.d || p.w) / 2;
const tx = wx, ty = wy, tz = (z + 0.5) * TILE;
const dx = tx - ox, dy = ty - oy, dz = tz - oz;
const dist = Math.hypot(dx, dy, dz) || 1;
const speed = 550;
state.projectiles.push({
x: ox, y: oy, z: oz,
vx: dx / dist * speed, vy: dy / dist * speed, vz: dz / dist * speed,
dmg: 10, owner: 'player', life: 4,
});
state.inv.arrow--;
useTool('bow');
playSound('hit1');
rebuildHotbar();
return;
}
// ---------- Еда ----------
if (ITEMS[state.selected] && state.inv[state.selected] > 0) {
const it = ITEMS[state.selected];
if (state.player.hp < 100 || state.player.hunger < 100) {
playSound('eat1');
state.player.hunger = Math.min(100, state.player.hunger + it.food);
state.player.hp = Math.min(100, state.player.hp + 15);
state.inv[state.selected]--;
rebuildHotbar();
}
return;
}
// ---------- Жарка на костре ----------
if (b.t === 'campfire' && state.selected === 'meat' && state.inv.meat > 0) {
playSound('fire');
state.inv.meat--;
state.inv.cooked = (state.inv.cooked || 0) + 1;
rebuildHotbar();
return;
}
// ---------- Сон ----------
if (b.t === 'bed' && isNight()) {
state.player.sleeping = true;
saveGame();
return;
}
// ---------- ДОБЫЧА ----------
if (mode() === 'mine') {
const def = BLOCKS[b.t];
if (def.fluid) return;
if (def.decor && def.growthStage === undefined && !def.harvestable) {
// декорации (цветы, грибы) ломаются мгновенно ниже, здесь только подстраховка
return;
}
if (b.t === 'tnt') { activateTNT3D(b, 3.2); return; }
const hardness = def.hardness || 1;
if (hardness === Infinity) return;
if (hardness === 0) {
const removed = removeBlock(gx, gy, z);
if (removed) {
handleBlockDrop(removed, state.inv);
sendBlockChange(gx, gy, z, removed.t, 'remove');
rebuildHotbar();
}
return;
}
if (state.mining.gx === gx && state.mining.gy === gy && (state.mining.gz || 0) === z) {
state.mining.progress += getMiningSpeed(b.t);
} else {
state.mining = { gx, gy, gz: z, progress: getMiningSpeed(b.t), hardness };
}
if (b.t === 'glass') playSound('glass1');
else if (b.t === 'sand') playSound('sand1');
else if (b.t === 'stone' || b.t.endsWith('_ore')) playSound('stone1');
else if (b.t === 'wood' || b.t === 'planks') playSound('wood1');
else playSound('cloth1');
if (state.mining.progress >= hardness) {
finishMining(gx, gy, z, b.t);
} else {
startHoldMining(gx, gy, z, b.t);
}
return;
}
// ---------- СТРОИТЕЛЬСТВО ----------
if (mode() === 'build') {
// посев на грядку
const seedToCrop = CROP_SEED_MAP[state.selected];
if (seedToCrop && state.inv[state.selected] > 0) {
if (b.t === 'farmland' && !getBlock(gx, gy - 1, z)) {
setBlock(gx, gy - 1, z, seedToCrop, true);
state.inv[state.selected]--;
rebuildHotbar();
return;
}
if (!b) {
const below = getBlock(gx, gy + 1, z);
if (below && below.t === 'farmland') {
setBlock(gx, gy, z, seedToCrop, true);
state.inv[state.selected]--;
rebuildHotbar();
return;
}
}
}
// цель — соседняя клетка по нормали грани
let tx2 = gx, ty2 = gy, tz2 = z;
if (face) {
if (Math.abs(face.x) >= Math.abs(face.y) && Math.abs(face.x) >= Math.abs(face.z)) tx2 += Math.sign(face.x);
else if (Math.abs(face.y) >= Math.abs(face.z)) ty2 += Math.sign(face.y);
else tz2 += Math.sign(face.z);
} else {
ty2 -= 1;
}
// по Y: в игре +y вниз, three +y вверх → нормаль -y = вниз = +gy
if (face && Math.abs(face.y) >= Math.abs(face.x) && Math.abs(face.y) >= Math.abs(face.z)) {
ty2 = gy - Math.sign(face.y);
}
if (getBlock(tx2, ty2, tz2)) return;
if (state.inv[state.selected] <= 0) return;
if (!BLOCKS[state.selected]) return;
// не ставим в себя
const p = state.player;
const bx = tx2 * TILE, by = ty2 * TILE, bz = tz2 * TILE;
const overlap = !(bx >= p.x + p.w || bx + TILE <= p.x ||
by >= p.y + p.h || by + TILE <= p.y ||
bz >= (p.z || 0) + (p.d || p.w) || bz + TILE <= (p.z || 0));
if (overlap) return;
setBlock(tx2, ty2, tz2, state.selected, true);
state.inv[state.selected]--;
sendBlockChange(tx2, ty2, tz2, state.selected, 'set');
if (state.selected === 'stone' || state.selected === 'brick') playSound('stone_build');
else if (state.selected === 'wood' || state.selected === 'planks') playSound('wood_build');
else if (state.selected === 'glass') playSound('glass1');
else if (state.selected === 'sand') playSound('sand1');
else playSound('cloth1');
rebuildHotbar();
}
}
function finishMining(gx, gy, gz, type) {
const def = BLOCKS[type];
const minTool = def.minTool;
let drops = true;
if (minTool) {
const toolId = getBestTool(type);
if (toolId) {
const tool = TOOLS[toolId];
if (toolTierLessThan(tool.toolTier, minTool)) drops = false;
else useTool(toolId);
} else {
drops = false;
}
} else {
const toolId = getBestTool(type);
if (toolId) useTool(toolId);
}
const removed = removeBlock(gx, gy, gz);
if (removed) {
if (drops) {
handleBlockDrop(removed, state.inv);
} else if (def.toolType === 'pickaxe') {
state.inv.cobblestone = (state.inv.cobblestone || 0) + 1;
}
sendBlockChange(gx, gy, gz, removed.t, 'remove');
playSound('stone1');
rebuildHotbar();
}
stopMining();
state.mining = { gx: null, gy: null, gz: null, progress: 0, hardness: 0 };
}
function startHoldMining(gx, gy, gz, type) {
miningHeld = true;
if (miningInterval) clearInterval(miningInterval);
miningInterval = setInterval(() => {
if (!miningHeld) { clearInterval(miningInterval); miningInterval = null; return; }
const mg = state.mining;
if (mg.gx === null) { miningHeld = false; clearInterval(miningInterval); miningInterval = null; return; }
const mb = getBlock(mg.gx, mg.gy, mg.gz || 0);
if (!mb || mb.dead || mb.t !== type) {
miningHeld = false;
clearInterval(miningInterval);
miningInterval = null;
return;
}
if (!withinReach(mg.gx, mg.gy, mg.gz || 0)) {
// отошли — добыча прекращается
miningHeld = false;
clearInterval(miningInterval);
miningInterval = null;
state.mining = { gx: null, gy: null, gz: null, progress: 0, hardness: 0 };
return;
}
mg.progress += getMiningSpeed(mb.t) * 0.3;
if (mg.progress >= mg.hardness) {
finishMining(mg.gx, mg.gy, mg.gz || 0, mb.t);
}
}, 100);
}
// Подсказка «слишком далеко» — один элемент, не спамит
let hintEl = null;
let hintTimer = null;
function showReachHint() {
if (!hintEl) {
hintEl = document.createElement('div');
hintEl.style.cssText = 'position:fixed;left:50%;top:18%;transform:translateX(-50%);' +
'z-index:300;background:rgba(0,0,0,.72);color:#fff;padding:8px 14px;border-radius:8px;' +
'font:600 14px system-ui;pointer-events:none;';
document.body.appendChild(hintEl);
}
hintEl.textContent = 'Слишком далеко — подойди ближе';
hintEl.style.display = 'block';
clearTimeout(hintTimer);
hintTimer = setTimeout(() => { if (hintEl) hintEl.style.display = 'none'; }, 1200);
}
// Диаг-хук: доступен только по ?diag=1, в обычной игре не создаётся
if (/[?&]diag=1/.test(location.search)) {
// setBlock/removeBlock нужны для браузерных проверок добычи и установки:
// без них тест не может подготовить блок перед игроком.
window.__m3d = { REACH, REACH_SQ, withinReach, withinReachEntity, handleTap, stopMining,
getBlock, setBlock, removeBlock, isSolid, isOpaque, hasBlock };
}
// Кнопка копки на джойстике: удержание = добыча блока под прицелом.
// TDtarget и startHoldMining живут в render3d/mouse-handler соответственно,
// но ЭТОТ модуль их не видит: обращение к TDtarget давало
// "TDtarget is not defined" и кнопка копки не работала вообще. Рендер
// публикует цель через TDaim(), а удержание запускается handleTap по
// координатам цели — того же пути, что и обычный клик мышью.
function TDaim() {
return (typeof window !== 'undefined' && window.__td && window.__td.target) || null;
}
window.addEventListener('v2-mine', (e) => {
const down = !!(e.detail && e.detail.down);
if (!down) { stopMining(); return; }
const t = TDaim();
if (!t) return;
const blk = getBlock(t.gx, t.gy, t.gz);
if (!blk) return;
// Оба действия через handleTap: он сам различает добычу и установку
// по режиму и запускает удержание через setInterval.
handleTap(t.gx, t.gy, t.gz, t.face);
});
// === render/hero-rig.js ===
// ==================== ДАННЫЕ ГЕРОЯ (из платформёра grechka_game.html) ====================
// COL/PARTS/PIVOT дословно оттуда: 107 прямоугольников, 5 частей (head/body/legs/tail/eye).
// Ноль rig = ступни; высота рисунка ~3.07 юнита при PARTS-координатах.
const HERO_COL = {
blue: { hex: 0x0232fe, d: 0.60 },
white: { hex: 0xffffff, d: 0.86 },
cyan: { hex: 0x00a1ff, d: 1.00 },
navy: { hex: 0x001a85, d: 1.12 },
};
const HERO_PARTS = {"head":[[-0.90035,2.93184,0.40909,0.13636,"blue"],[-1.17308,2.79547,0.13636,0.13636,"blue"],[-0.62762,2.79547,0.13636,0.13636,"blue"],[-0.96853,2.65911,0.27273,0.13636,"blue"],[-0.49126,2.65911,0.13636,0.13636,"blue"],[-1.17308,2.52275,0.13636,0.13636,"blue"],[-0.62762,2.52275,0.13636,0.13636,"blue"],[0.12238,2.52275,1.09091,0.13636,"blue"],[-0.83217,2.38638,0.81818,0.13636,"blue"],[1.00874,2.38638,0.68182,0.13636,"blue"],[-0.83217,2.25002,0.27273,0.13636,"blue"],[1.41783,2.25002,0.13636,0.13636,"blue"],[-1.03671,2.11365,0.13636,0.13636,"blue"],[1.28147,2.11365,0.13636,0.13636,"blue"],[0.12238,2.38638,1.09091,0.13636,"white"],[0.32692,2.25002,2.04545,0.13636,"white"],[-0.6958,2.11365,0.54545,0.13636,"white"],[-0.15035,2.11365,0.27273,0.13636,"white"],[0.66783,2.11365,1.09091,0.13636,"white"],[-0.3549,2.11365,0.13636,0.13636,"cyan"],[0.0542,2.11365,0.13636,0.13636,"cyan"]],"body":[[-1.17308,1.97729,0.13636,0.13636,"blue"],[1.1451,1.97729,0.13636,0.13636,"blue"],[-0.96853,1.84093,0.27273,0.13636,"blue"],[1.28147,1.77275,0.13636,0.27273,"blue"],[-0.90035,1.70456,0.13636,0.13636,"blue"],[-1.1049,1.5682,0.27273,0.13636,"blue"],[-0.42308,1.5682,0.27273,0.13636,"blue"],[0.53147,1.5682,0.27273,0.13636,"blue"],[1.07692,1.5682,0.27273,0.13636,"blue"],[1.41783,1.5682,0.13636,0.13636,"blue"],[-1.30944,1.43184,0.13636,0.13636,"blue"],[1.00874,1.43184,0.13636,0.13636,"blue"],[1.28147,1.43184,0.13636,0.13636,"blue"],[-1.17308,1.15911,0.13636,0.40909,"blue"],[1.1451,1.22729,0.13636,0.27273,"blue"],[-1.4458,1.02275,0.13636,0.13636,"blue"],[1.00874,1.02275,0.13636,0.13636,"blue"],[-1.4458,0.88638,0.40909,0.13636,"blue"],[-0.96853,0.88638,0.27273,0.13636,"blue"],[-0.08217,0.88638,0.68182,0.13636,"blue"],[0.87238,0.88638,0.13636,0.13636,"blue"],[-1.51399,0.75002,0.54545,0.13636,"blue"],[-0.90035,0.75002,0.13636,0.13636,"blue"],[0.66783,0.75002,0.27273,0.13636,"blue"],[1.00874,0.75002,0.13636,0.13636,"blue"],[-0.6958,1.97729,0.81818,0.13636,"white"],[-0.01399,1.97729,0.27273,0.13636,"white"],[0.8042,1.97729,0.54545,0.13636,"white"],[-0.6958,1.77275,0.27273,0.27273,"white"],[-0.21853,1.84093,0.13636,0.13636,"white"],[0.32692,1.84093,0.13636,0.13636,"white"],[0.94056,1.77275,0.54545,0.27273,"white"],[0.0542,1.63638,0.68182,0.27273,"white"],[-0.76399,1.5682,0.40909,0.13636,"white"],[0.8042,1.5682,0.27273,0.13636,"white"],[1.28147,1.5682,0.13636,0.13636,"white"],[-1.1049,1.43184,0.27273,0.13636,"white"],[-0.01399,1.43184,1.36364,0.13636,"white"],[-0.21853,1.29547,1.5,0.13636,"white"],[0.87238,1.29547,0.40909,0.13636,"white"],[-0.90035,1.15911,0.13636,0.13636,"white"],[-0.08217,1.15911,1.22727,0.13636,"white"],[0.73601,1.15911,0.13636,0.13636,"white"],[1.00874,1.15911,0.13636,0.13636,"white"],[-1.03671,1.02275,0.13636,0.13636,"white"],[-0.01399,1.02275,1.36364,0.13636,"white"],[-0.62762,0.88638,0.40909,0.13636,"white"],[0.53147,0.88638,0.54545,0.13636,"white"],[-0.15035,0.75002,1.36364,0.13636,"white"],[0.87238,0.75002,0.13636,0.13636,"white"],[-0.21853,1.97729,0.13636,0.13636,"cyan"],[0.32692,1.97729,0.40909,0.13636,"cyan"],[0.0542,1.84093,0.40909,0.13636,"cyan"],[-0.83217,1.43184,0.27273,0.13636,"cyan"],[0.8042,1.43184,0.27273,0.13636,"cyan"],[-1.03671,1.22729,0.13636,0.27273,"cyan"],[0.59965,1.22729,0.13636,0.27273,"cyan"],[-0.76399,1.15911,0.13636,0.13636,"cyan"],[0.87238,1.15911,0.13636,0.13636,"cyan"],[-0.83217,1.02275,0.27273,0.13636,"cyan"],[0.8042,1.02275,0.27273,0.13636,"cyan"]],"tail":[[-1.71853,0.61365,0.13636,0.13636,"blue"],[-1.03671,0.61365,0.13636,0.13636,"blue"],[-0.62762,0.61365,0.40909,0.13636,"blue"],[-1.65035,0.47729,0.27273,0.13636,"blue"],[-0.90035,0.47729,0.13636,0.13636,"blue"],[-1.4458,0.34093,0.40909,0.13636,"blue"],[-0.62762,0.34093,0.40909,0.13636,"blue"],[-0.90035,0.20456,0.95455,0.13636,"blue"],[-0.90035,0.61365,0.13636,0.13636,"white"]],"legs":[[0.3951,0.61365,0.27273,0.13636,"blue"],[0.87238,0.61365,0.13636,0.13636,"blue"],[-0.55944,0.47729,0.27273,0.13636,"blue"],[0.32692,0.34093,0.13636,0.40909,"blue"],[-0.21853,0.20456,0.13636,0.13636,"blue"],[0.0542,0.20456,0.13636,0.13636,"blue"],[-0.08217,0.0682,0.95455,0.13636,"blue"],[-0.3549,0.47729,0.13636,0.13636,"white"],[-0.08217,0.34093,0.68182,0.13636,"white"],[-0.3549,0.20456,0.13636,0.13636,"white"],[-0.08217,0.20456,0.13636,0.13636,"white"],[0.19056,0.20456,0.13636,0.13636,"white"],[-0.08217,0.61365,0.68182,0.13636,"cyan"],[-0.01399,0.47729,0.54545,0.13636,"cyan"]],"eye":[[-0.42308,1.77275,0.27273,0.27273,"navy"],[0.53147,1.77275,0.27273,0.27273,"navy"]]};
const HERO_PIVOT = {"head":[0.0,2.5909],"body":[0.0,0.979],"legs":[-0.035,0.9091],"tail":[-0.7343,1.3287],"eye":[0.0524,2.1294]};
const HERO_SCALE = 0.50;
// === entities/mob-sprites3d.js ===
// Точные копии мобов 2D-игры (game.js, fillRect-композиции) для 3D.
// НИЧЕГО НЕ ВЫДУМАНО: те же прямоугольники, те же цвета, те же пропорции.
// Координаты локальные: x вправо от левого края, y ВВЕРХ от низа, в пикселях
// (TILE=40). Конвертировано из 2D (y вниз): y3d = h - dy - rh.
// Глубина выдавливания задаётся при построении (доля от ширины).
const MOB_SPRITES = {
zombie: { w: 34, h: 50, rects: [
[0, 0, 34, 50, '#2ecc71'],
[6, 32, 6, 6, '#c0392b'], [22, 32, 6, 6, '#c0392b'],
]},
pig: { w: 34, h: 24, rects: [
[0, 0, 34, 24, '#ffb6c1'],
[22, 16, 3, 3, '#000000'],
[28, 6, 6, 6, '#ff69b4'],
]},
chicken: { w: 26, h: 22, rects: [
[0, 0, 26, 22, '#ecf0f1'],
[18, 8, 6, 4, '#f39c12'],
[8, 13, 3, 3, '#000000'],
]},
creeper: { w: 34, h: 50, rects: [
[0, 0, 34, 50, '#4caf50'],
[8, 38, 4, 4, '#000000'], [22, 38, 4, 4, '#000000'],
[12, 26, 10, 4, '#000000'],
[4, 0, 6, 20, '#4caf50'], [24, 0, 6, 20, '#4caf50'],
]},
skeleton: { w: 34, h: 50, rects: [
[10, 18, 14, 12, '#eceff1'],
[8, 32, 18, 18, '#eceff1'],
[10, 40, 4, 4, '#000000'], [20, 40, 4, 4, '#000000'],
[15, 36, 4, 2, '#000000'],
[2, 16, 6, 14, '#eceff1'], [26, 16, 6, 14, '#eceff1'],
[10, 0, 6, 18, '#eceff1'], [18, 0, 6, 18, '#eceff1'],
[30, 20, 2, 16, '#8b4513'],
]},
scorpion: { w: 26, h: 26, rects: [
[0, 0, 26, 26, '#5d4037'],
[2, 8, 6, 8, '#8d6e63'], [18, 8, 6, 8, '#8d6e63'],
[10, 26, 6, 10, '#5d4037'],
[11, 34, 4, 4, '#ff5722'],
]},
polar_bear: { w: 40, h: 34, rects: [
[0, 0, 40, 34, '#eceff1'],
[18, 25, 4, 3, '#263238'],
[8, 19, 3, 3, '#263238'], [29, 19, 3, 3, '#263238'],
[6, 0, 8, 8, '#cfd8dc'], [26, 0, 8, 8, '#cfd8dc'],
]},
slime: { w: 24, h: 24, rects: [
[0, 0, 24, 24, '#4caf50'],
[6, 12, 5, 5, '#ffffff'], [13, 12, 5, 5, '#ffffff'],
[8, 13, 2, 2, '#000000'], [15, 13, 2, 2, '#000000'],
]},
eagle: { w: 30, h: 22, rects: [
[11, 2, 8, 14, '#5d4037'],
[0, 8, 11, 6, '#795548'], [19, 8, 11, 6, '#795548'],
[12, 14, 6, 6, '#eceff1'],
[18, 16, 4, 2, '#ffc107'],
[14, 16, 2, 2, '#000000'],
]},
};
// === entities/mob-tex3d.js ===
// NN-текстуры мобов (нарисованы в anymodel/nano-banana, одобрены игроком).
// Фас/профиль/спина натягиваются на грани бокса в TDmobMesh.
// Героя НЕ трогаем — он оригинальная пиксель-экструзия из hero-rig.js.
// Верх/низ бокса — плоский средний цвет спрайта.
const MOB_TEX = {
zombie: { top: '#3da027' },
pig: { top: '#ea77a1' },
chicken: { top: '#dcc8bf' },
creeper: { top: '#4a7f3e' },
skeleton: { top: '#b5b3b1' },
scorpion: { top: '#37221a' },
polar_bear: { top: '#dcdcdb' },
slime: { top: '#7caa71' },
eagle: { top: '#806041' },
};
const MOB_TEX_V = '3';
const _mobTexCache = {};
function TDmobTex(kind, view, mirror) {
const key = kind + '_' + view + (mirror ? '_m' : '');
let t = _mobTexCache[key];
if (!t) {
t = new THREE.TextureLoader().load('tex/' + kind + '_' + view + '.png?v=' + MOB_TEX_V);
if (mirror) {
t.wrapS = THREE.RepeatWrapping;
t.repeat.x = -1;
}
_mobTexCache[key] = t;
}
return t;
}
// === render/render3d.js ===
// ==================== РЕНДЕР 3D — GrechkaCraft v3 ====================
// Объёмный мир: у каждого блока есть gx/gy/gz. Рендерим кубы по всем трём
// осям, отсекаем те, что не видны с камеры (соседи со всех шести сторон).
let TD = null;
function TDparseColor(c) {
const m = /rgba?\(([^)]+)\)/.exec(c);
if (!m) return { color: new THREE.Color(c), opacity: 1 };
const p = m[1].split(',').map(x => parseFloat(x));
return { color: new THREE.Color(p[0] / 255, p[1] / 255, p[2] / 255), opacity: p.length > 3 ? p[3] : 1 };
}
function TDblockMat(t) {
const def = BLOCKS[t] || {};
const { color, opacity } = TDparseColor(def.c || '#ff00ff');
const a = def.alpha !== undefined ? def.alpha : opacity;
return new THREE.MeshLambertMaterial({ color, transparent: a < 1, opacity: a });
}
// ---------- ОККЛЮЗИЯ: живёт в world/occ3d.js ----------
// isHiddenAt проверяла 6 соседей через getBlock, а тот строил строку "gx,gy,gz"
// и искал в Map. При 68k кандидатах это ~409k строковых конкатенаций —
// профиль показал 129 мс из 153 мс всего перестроения мешей. Теперь окклюзия
// читает плотный Uint8Array (occ3d.js), обновляемый из setBlock/removeBlock.
function TDisFluid(t) { const d = BLOCKS[t]; return !!(d && d.fluid); }
function TDisFlat(t) {
const d = BLOCKS[t];
if (!d) return false;
return !d.solid || !!d.decor || !!d.climbable;
}
// ---------- герой: объёмная гречка ----------
// Пиксельный рисунок 2D-игры (hero-rig.js) выдавливается в глубину: каждая
// часть — настоящий объём одинаковой глубины, а не плоская пластина. Поэтому
// при вращении камеры герой остаётся объёмным, а не превращается в картонку.
function TDbuildHeroRig() {
const group = new THREE.Group();
const P = {};
// Toon-растушёвка как в эталонной игре: 4 ступени дают «мультяшный» вид,
// к которому привык игрок, вместо плоского диффузного Lambert.
const MAT = {};
const gradTex = (() => {
const steps = 4;
const g = new THREE.DataTexture(new Uint8Array(steps * 4), steps, 1, THREE.RGBAFormat);
for (let i = 0; i < steps; i++) {
const v = Math.round(255 * (0.72 + 0.28 * (i / (steps - 1))));
g.image.data.set([v, v, v, 255], i * 4);
}
g.minFilter = THREE.LinearMipmapLinearFilter;
g.magFilter = THREE.LinearFilter;
g.generateMipmaps = true;
g.needsUpdate = true;
return g;
})();
for (const k of Object.keys(HERO_COL)) {
MAT[k] = new THREE.MeshToonMaterial({ color: HERO_COL[k].hex, gradientMap: gradTex });
}
// глубина тела в мировых единицах — ширина одного «пикселя» рисунка
// Риг как в эталонной платформенной игре: каждый пиксель рисунка — призма,
// глубина которой берётся ИЗ ЦВЕТА (COL[colorKey].d), а не из общей константы.
// Общая константа давала «растянутую с двух сторон пластину»: у синего слоя
// d=0.6, у тёмно-синих глаз d=1.12 — разная толщина и есть объём.
// Плюс renderOrder = d*1000 — иначе Three.js сортирует слои случайно и
// дальние прямоугольники рисуются поверх ближних.
const dOf = (colorKey) => (HERO_COL[colorKey] && HERO_COL[colorKey].d) || 0.8;
for (const name of Object.keys(HERO_PARTS)) {
const g = new THREE.Group(); // узел вращения (сустав)
const j = HERO_PIVOT[name];
g.position.set(j[0] * HERO_SCALE, j[1] * HERO_SCALE, 0);
const inner = new THREE.Group(); // содержимое узла
inner.position.set(-j[0] * HERO_SCALE, -j[1] * HERO_SCALE, 0);
g.add(inner);
group.add(g);
P[name] = g;
for (const r of HERO_PARTS[name]) {
const [rx, ry, rw, rh, colorKey] = r;
const d = dOf(colorKey) * HERO_SCALE;
const w = rw * HERO_SCALE;
const h = rh * HERO_SCALE;
const geo = new THREE.BoxGeometry(w, h, d);
// центрируем по X (в рисунке левый край -1.17.., центр около -0.24),
// по Z смещаем к зрителю, чтобы слои не уходили вглубь земли
geo.translate((rx - 0.24) * HERO_SCALE + w / 2, ry * HERO_SCALE + h / 2, d / 2);
const m = new THREE.Mesh(geo, MAT[colorKey] || MAT.blue);
m.renderOrder = Math.round(d * 1000);
m.castShadow = true;
m.receiveShadow = true;
inner.add(m);
}
}
// Нос — объёмный, выносим на переднюю грань тела
const nose = new THREE.Mesh(
new THREE.BoxGeometry(0.16, 0.12, 0.18),
new THREE.MeshToonMaterial({ color: 0xf7c873, gradientMap: gradTex })
);
nose.position.set(0.05, 2.13 * HERO_SCALE, 0.30 * HERO_SCALE + 0.02);
P.head.add(nose);
return { group, P };
}
// Простое склеивание геометрий (буферы BoxGeometry одинакового формата)
function TDmergeGeometries(geos) {
if (geos.length === 1) return geos[0];
let vCount = 0, iCount = 0;
for (const g of geos) {
vCount += g.attributes.position.count;
iCount += g.index ? g.index.count : 0;
}
const pos = new Float32Array(vCount * 3);
const nor = new Float32Array(vCount * 3);
const uv = new Float32Array(vCount * 2);
const idx = new Uint32Array(iCount);
let vo = 0, io = 0;
for (const g of geos) {
const p = g.attributes.position, n = g.attributes.normal, u = g.attributes.uv;
pos.set(p.array, vo * 3);
if (n) nor.set(n.array, vo * 3);
if (u) uv.set(u.array, vo * 2);
if (g.index) {
const gi = g.index.array;
for (let k = 0; k < gi.length; k++) idx[io + k] = gi[k] + vo;
io += gi.length;
}
vo += p.count;
}
const out = new THREE.BufferGeometry();
out.setAttribute('position', new THREE.BufferAttribute(pos, 3));
out.setAttribute('normal', new THREE.BufferAttribute(nor, 3));
out.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
out.setIndex(new THREE.BufferAttribute(idx, 1));
out.computeBoundingSphere();
return out;
}
const TD_MOB_COLORS = {
zombie: 0x2ecc71, pig: 0xffb6c1, chicken: 0xecf0f1, creeper: 0x4caf50,
skeleton: 0xeceff1, scorpion: 0x5d4037, polar_bear: 0xeceff1,
slime: 0x4caf50, eagle: 0x5d4037,
};
function TDnameSprite(name) {
const cv = document.createElement('canvas');
cv.width = 256; cv.height = 48;
const g = cv.getContext('2d');
g.font = 'bold 26px system-ui';
g.textAlign = 'center';
g.fillStyle = 'rgba(0,0,0,0.45)';
g.fillRect(0, 0, 256, 48);
g.fillStyle = '#fff';
g.fillText(String(name || '?').slice(0, 14), 128, 33);
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(cv), depthTest: false }));
sp.scale.set(2.2, 0.41, 1);
return sp;
}
// ==================== INIT ====================
function TDinit() {
const canvas = state.canvas;
// preserveDrawingBuffer ОБЯЗАТЕЛЕН: без него canvas.toDataURL()
// возвращает ЧЁРНЫЙ кадр, и невозможно снять скриншот для проверки вида.
// Стоит копейка производительности, но даёт рабочую диагностику.
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true });
if (renderer.toneMapping !== undefined && THREE.ACESFilmicToneMapping !== undefined) {
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.92; // >1 даёт сплошной засвет
}
if (renderer.outputEncoding !== undefined && THREE.sRGBEncoding !== undefined) {
renderer.outputEncoding = THREE.sRGBEncoding;
}
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.BasicShadowMap;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x8fc7f5);
// Туман заканчивается на границе прорисовки (VIEW_R = 14 тайлов = 560).
// Было 30..90 при TILE=40 — то есть видимость всего ~2 тайла, отсюда
// жалоба «прорисовка десять блоков». Теперь туман мягкий и соответствует дальности.
// Дальность: VIEW_R = 26 тайлов (1040 px). При 14 тайлах мир обрывался
// заметно раньше тумана — отсюда «остров, висящий в пространстве».
// Туман начинается за пределами прорисовки (VIEW_R=30 тайлов = 1200 px),
// иначе мир «обрывается» раньше, чем заканчиваются блоки.
// VIEW_R = 30 тайлов = 1200 px. Туман должен начинаться ЗНАЧИТЕЛЬНО дальше
// этой границы, иначе весь видимый мир оказывается в сплошном цвете неба —
// это и был «сплошной засвет». Держим запас: туман с 60% дальности.
// VIEW_R = 46 тайлов = 1840 px. Туман ОТКРЫВАЕТСЯ ДО края прорисовки
// (near 760) и полностью гаснет на 1560: рельеф растворяется в дымке,
// вместо того чтобы обрываться в пустое небо. Раньше near=1000 был позже
// края — отсюда «парящий остров».
scene.fog = new THREE.Fog(0x8fc7f5, 760, 1560);
const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 1700);
// Свет: сумма была 2.6 при отсутствии tone mapping — отсюда ультра-яркие
// пастельные тона. Ставим суммарно ~1.15 и включаем тонмаппинг.
// Свет суммарно ~0.95. При 1.3 и без tone mapping земля выгорала в бежевый
// («ультра-яркие тона»), терялся цвет блоков.
const hemi = new THREE.HemisphereLight(0xcfe6ff, 0x5a8f3c, 0.34);
const sun = new THREE.DirectionalLight(0xfff4e0, 0.61);
sun.castShadow = true;
sun.shadow.mapSize.set(512, 512);
const sc = sun.shadow.camera;
sc.left = -26; sc.right = 26; sc.top = 26; sc.bottom = -26;
sc.near = 1; sc.far = 140;
sun.shadow.normalBias = 0.05;
const amb = new THREE.AmbientLight(0xffffff, 0.06);
scene.add(hemi, sun, sun.target, amb);
const hero = TDbuildHeroRig();
scene.add(hero.group);
// Рабочая область: только блоки рядом с игроком (по всем осям)
const crack = new THREE.Mesh(
new THREE.BoxGeometry(1.004, 1.004, 1.004),
new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0 })
);
crack.visible = false;
scene.add(crack);
const ghost = new THREE.Mesh(
new THREE.BoxGeometry(1.02, 1.02, 1.02),
new THREE.MeshBasicMaterial({ color: 0xffffff, wireframe: true, transparent: true, opacity: 0.9 })
);
ghost.visible = false;
scene.add(ghost);
// куб подсветки выбранного блока
const outline = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(1.02, 1.02, 1.02)),
new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.85 })
);
outline.visible = false;
scene.add(outline);
// Прицел: блок, на который смотрит игрок. Обновляется каждый кадр рейкастом.
// Камера стоит за игроком, поэтому целимся от камеры вперёд по направлению взгляда.
const raycaster = new THREE.Raycaster();
const _rcDir = new THREE.Vector3();
const aim = { gx: 0, gy: 0, gz: 0, px: 0, py: 0, face: null, dist: 0, valid: false };
const cloudMeshes = [];
// Облака: MeshBasic, а не Lambert. Lambert без нормального света давал серые
// пятна в небе (визуальный артефакт). Basic всегда чисто-белый.
const cloudMat = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.85, depthWrite: false });
for (let i = 0; i < 16; i++) {
const m = new THREE.Mesh(new THREE.BoxGeometry(4, 0.8, 2.2), cloudMat);
scene.add(m);
cloudMeshes.push(m);
}
// ---------- Прицел: блок перед игроком по направлению взгляда ----------
const AIM_MAX = 7; // тайлов, как в майнкрафте
const projPool = [];
for (let i = 0; i < 24; i++) {
const m = new THREE.Mesh(new THREE.BoxGeometry(0.55, 0.1, 0.1), new THREE.MeshBasicMaterial({ color: 0xf1c40f }));
m.visible = false;
scene.add(m);
projPool.push(m);
}
const PMAX = 800;
const pGeo = new THREE.BufferGeometry();
const pPos = new Float32Array(PMAX * 3);
const pCol = new Float32Array(PMAX * 3);
pGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3));
pGeo.setAttribute('color', new THREE.BufferAttribute(pCol, 3));
const points = new THREE.Points(pGeo, new THREE.PointsMaterial({ size: 0.16, vertexColors: true, depthWrite: false }));
points.frustumCulled = false;
scene.add(points);
function makeFall(n, color, size) {
const g = new THREE.BufferGeometry();
const pos = new Float32Array(n * 3);
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
const pts = new THREE.Points(g, new THREE.PointsMaterial({ color, size, transparent: true, opacity: 0.7, depthWrite: false }));
pts.frustumCulled = false;
pts.visible = false;
scene.add(pts);
return { pts, pos, n };
}
const rain = makeFall(500, 0x74b9ff, 0.2);
const snow = makeFall(300, 0xffffff, 0.17);
const lightPool = [];
for (let i = 0; i < 6; i++) {
const L = new THREE.PointLight(0xff9a3c, 0, 16);
scene.add(L);
lightPool.push(L);
}
const flamePool = [];
for (let i = 0; i < 8; i++) {
const f = new THREE.Mesh(new THREE.ConeGeometry(0.3, 0.75, 6), new THREE.MeshBasicMaterial({ color: 0xff7a1a }));
f.visible = false;
scene.add(f);
flamePool.push(f);
}
const boatMesh = new THREE.Mesh(
new THREE.BoxGeometry(1.4, 0.5, 1.0),
new THREE.MeshLambertMaterial({ color: 0x8b4513 })
);
boatMesh.visible = false;
boatMesh.castShadow = true;
scene.add(boatMesh);
let TDtarget = null; // блок под прицелом — для кнопки копки
TD = {
get target() { return TDtarget; },
updateAim(px, py, pz) {
// Направление луча = КУДА СМОТРИТ КАМЕРА, а не «на игрока».
// Раньше здесь стояло (px - camera.position.x, ...), то есть луч всегда
// упирался в самого героя, и прицел показывал блок ПОД НОГАМИ игрока
// (face: {y:1}) — добыть и поставить было невозможно.
// Камера уже развёрнута lookAt вдоль курса, поэтому берём его из неё.
camera.getWorldDirection(_rcDir);
// Дистанция камера->игрок — отдельная величина. getWorldDirection уже
// возвращает ЕДИНИЧНЫЙ вектор, поэтому брать длину из _rcDir нельзя:
// получалось camDist = 1, near = 1.15, и всё, что дальше, отбрасывалось
// как «за игроком» — прицел всегда указывал в опору под ногами.
const camDist = Math.sqrt(
(px - camera.position.x) ** 2 + (py - camera.position.y) ** 2 + (pz - camera.position.z) ** 2
);
if (camDist < 1e-6) return aim;
_rcDir.normalize();
// Рейкаст ОБЯЗАН быть направленным: без set() он стреляет из (0,0,0)
// в -Z, то есть мимо мира — прицел и добыча не работали вообще.
// Луч: из камеры через игрока. Блоки между камерой и игроком отбрасываем.
raycaster.set(camera.position, _rcDir);
raycaster.far = camDist + AIM_MAX;
// Луч идёт ОТ КАМЕРЫ ЧЕРЕЗ ИГРОКА. Всё, что ближе игрока (distance <
// camDist), — это блоки между камерой и игроком, их надо отбросить.
// Граница должна быть СТРОГО больше дистанции до игрока, иначе блок
// ОПОРЫ под ногами (его верхняя грань лежит ровно на луче) попадает
// в выборку, и прицел всегда указывает в землю под игроком:
// target = { gx: <колонка игрока>, face: {y: 1} }, добыть что-то нельзя.
raycaster.near = camDist + 0.15;
const meshes = TD.blockMeshes;
TD.aimCalls = (TD.aimCalls || 0) + 1;
let best = null, bestD = camDist + AIM_MAX, _rawHits = 0;
for (const e of meshes) {
const hits = raycaster.intersectObject(e.mesh, false);
for (const h of hits) {
_rawHits++;
if (h.distance < camDist - 0.5) continue; // это блок ПЕРЕД игроком, не наш
if (h.distance < bestD) { bestD = h.distance; best = { h, e }; }
}
}
if (!best || best.h.instanceId === undefined) { outline.visible = false; aim.valid = false;
TD.aimDbg = { hits: _rawHits, meshes: meshes.length, camDist: +camDist.toFixed(2), why: best ? 'no-instance' : 'no-best' }; return aim; }
// Точные координаты блока лежат в меше (userData.list). Матричная
// конвертация была неверной: по X/Z давала +1 тайл, по Y — минус.
// Поэтому прицел указывал в пустоту с мусором вроде (-15,-15,15).
const _b = best.e.mesh.userData.list[best.h.instanceId];
if (!_b) { outline.visible = false; aim.valid = false;
TD.aimDbg = { hits: _rawHits, meshes: meshes.length, camDist: +camDist.toFixed(2), why: 'no-list' }; return aim; }
const gx = _b.gx, gy = _b.gy, gz = (_b.gz || 0);
const n = best.h.face ? best.h.face.normal : null;
// нормаль в локальных осях куба = мировые (инстансы только переносятся)
const face = n ? { x: Math.round(n.x), y: Math.round(n.y), z: Math.round(n.z) } : null;
aim.gx = gx; aim.gy = gy; aim.gz = gz;
aim.px = gx + 0.5; aim.py = gy + 0.5; aim.face = face;
aim.dist = bestD - camDist; aim.valid = true; // дистанция от ИГРОКА, не от камеры
TD.aimDbg = { hits: _rawHits, meshes: meshes.length, camDist: +camDist.toFixed(2) };
TDtarget = aim;
// Y в three инвертирован: позиция инстанса (gx+0.5,-gy-0.5,gz+0.5).
// Было (aim.px, aim.py=gy+0.5, ...) — рамка висела в зеркальной точке.
outline.position.set(gx + 0.5, -gy - 0.5, gz + 0.5);
outline.visible = true;
},
renderer, scene, camera, hemi, sun, amb, hero,
crack, ghost, outline, cloudMeshes, projPool, points, pPos, pCol, PMAX,
rain, snow, lightPool, flamePool, boatMesh,
blockMeshes: [], lastVersion: -1, meshT: 0,
mobs: new Map(), players: new Map(),
yaw: 0.0, pitch: 0.28, dist: 10, // 0.28 — вид сбоку; 0.55 смотрел в небо
ptrs: new Map(), pinchD: 0,
lastTap: null, hovered: null, fireCache: null,
heroStep: 0,
heroTime: 0, face: 1, frame: 0, lastT: 0,
colCache: new Map(),
};
// DIAG-зонд: повторяет луч прицела и возвращает сырые цифры.
TD.probeAim = function() {
const out = { meshes: TD.blockMeshes.length, near: +raycaster.near.toFixed(2), far: +raycaster.far.toFixed(2) };
try {
const all = [];
for (const e of TD.blockMeshes) {
const h = raycaster.intersectObject(e.mesh, false);
if (h.length) all.push({ type: e.type, n: h.length, d0: +h[0].distance.toFixed(2), iid: h[0].instanceId });
}
out.hits = all.slice(0, 8); out.total = all.reduce((a, x) => a + x.n, 0);
out.rayO = camera.position.toArray().map(v => +v.toFixed(2));
// контрольный замер без near/far: если хиты есть — режет фильтр дистанций
const _n = raycaster.near, _f = raycaster.far;
raycaster.near = 0; raycaster.far = 100;
let wide = 0, firstD = -1;
for (const e of TD.blockMeshes) {
const h = raycaster.intersectObject(e.mesh, false);
if (h.length && !wide) { wide = h.length; firstD = +h[0].distance.toFixed(2); }
else if (h.length) wide += h.length;
}
raycaster.near = _n; raycaster.far = _f;
out.wide = wide; out.firstD = firstD;
} catch (err) { out.err = String(err).slice(0, 100); }
return out;
};
TD.aim = aim; TD.outline = outline; TD.pick = TDpick; TD.tap = handleTap;
// TD публикуется ВСЕГДА: кнопка копки на мобильном читает отсюда
// цель (window.__td.target). Раньше объект создавался только по
// ?diag=1, и тач-кнопка «копка» молча не работала в обычной игре.
window.__td = TD;
if (/[?&]diag=1/.test(location.search)) { /* диаг-режим: хук уже есть */ }
TDbindInput(canvas);
TDresize();
window.addEventListener('resize', TDresize);
}
function TDresize() {
if (!TD) return;
const w = window.innerWidth, h = window.innerHeight;
state.W = w;
state.H = h;
TD.renderer.setPixelRatio(1);
TD.renderer.setSize(w, h, false);
TD.camera.aspect = w / h;
TD.camera.updateProjectionMatrix();
}
// ==================== БЛОКИ ====================
// Полный пересбор мешей редкий; в обычном кадре — только обновление матриц.
// Радиус прорисовки. Должен ЗНАЧИТЕЛЬНО превышать дистанцию камеры
// (макс 34 тайла) — иначе за краем сетки видно пустое небо, и мир выглядит
// парящим островом. 46 тайлов при тумане с 1000 до 1500: край рельефа
// успевает уйти в туман, а не обрываться в пустоту.
const VIEW_R = 46; // тайлов по всем осям
// Границы плотной сетки: за ними occIndex отдаёт -1, и «сосед» через край
// массива не должен заворачиваться на противоположную сторону мира.
function isHiddenAt(gx, gy, gz) {
// Окклюзия читает плотную сетку occ3d.js, а не getBlock: 6 чтений массива
// против 6 строковых ключей "gx,gy,gz". Семантика та же — все 6 соседей
// непрозрачные. Раскладка индекса: (gy*OCC_Z + (gz-OCC_MIN))*OCC_X + (gx-OCC_MIN),
// то есть шаг по gx = 1, по gz = OCC_X, по gy = OCC_X*OCC_Z.
if (gx <= OCC_MIN || gx >= OCC_MAX || gz <= OCC_MIN || gz >= OCC_MAX ||
gy <= 0 || gy >= OCC_MAX_GY) return false;
const i = occIndex(gx, gy, gz);
return occGrid[i - OCC_STEP_X] === OCC_OPAQUE && occGrid[i + OCC_STEP_X] === OCC_OPAQUE &&
occGrid[i - OCC_STEP_Z] === OCC_OPAQUE && occGrid[i + OCC_STEP_Z] === OCC_OPAQUE &&
occGrid[i - OCC_STEP_Y] === OCC_OPAQUE && occGrid[i + OCC_STEP_Y] === OCC_OPAQUE;
}
function isOpaqueAt(gx, gy, gz) {
// Одинокий lookup по сетке окклюзии (а не getBlock со строковым ключом).
// Используется точечно там, где нужен именно факт непрозрачности клетки.
const i = occIndex(gx, gy, gz);
return i >= 0 && occGrid[i] === OCC_OPAQUE;
}
function TDrebuildBlocks(px, py, pz) {
for (const e of TD.blockMeshes) {
TD.scene.remove(e.mesh);
e.mesh.geometry.dispose();
e.mesh.material.dispose();
}
TD.blockMeshes = [];
const byType = new Map();
for (const b of state.blocks) {
if (b.dead) continue;
if (!BLOCKS[b.t]) continue;
const dx = b.gx - px, dy = b.gy - py, dz = (b.gz || 0) - pz; // py — игровой gy
if (dx * dx + dy * dy + dz * dz > VIEW_R * VIEW_R) continue;
if (!TDisFluid(b.t) && !TDisFlat(b.t) && isHiddenAt(b.gx, b.gy, b.gz || 0)) continue;
// Воду рисуем ТОЛЬКО верхним слоем. Раньше в колонке было до 30 кубов воды
// подряд, и каждый был полупрозрачным — вместе они давали «мир в
// полупрозрачных квадратах» и 29k лишних инстансов.
if (TDisFluid(b.t)) {
const up = getBlock(b.gx, b.gy - 1, b.gz || 0);
if (up && TDisFluid(up.t)) continue; // не верхний слой — пропускаем
}
if (!byType.has(b.t)) byType.set(b.t, []);
byType.get(b.t).push(b);
}
const boxGeo = new THREE.BoxGeometry(1, 1, 1);
const planeGeo = new THREE.PlaneGeometry(0.92, 0.92);
for (const [t, list] of byType) {
const fluid = TDisFluid(t);
const flat = !fluid && TDisFlat(t);
const mesh = new THREE.InstancedMesh(flat ? planeGeo : boxGeo, TDblockMat(t), list.length);
mesh.castShadow = !flat;
mesh.receiveShadow = true;
if (flat) mesh.material.side = THREE.DoubleSide;
const m = new THREE.Matrix4();
const q = new THREE.Quaternion();
const e = new THREE.Euler();
for (let i = 0; i < list.length; i++) {
const b = list[i];
if (flat) {
// плоские блоки (цветы) — поворачиваем к камере, стоят на земле
e.set(-Math.PI / 2, 0, 0);
q.setFromEuler(e);
m.compose(new THREE.Vector3(b.gx + 0.5, -b.gy + 0.05, (b.gz || 0) + 0.5), q, new THREE.Vector3(1, 1, 1));
} else if (fluid) {
m.makeScale(1, 0.9, 1);
m.setPosition(b.gx + 0.5, -b.gy - 0.5, (b.gz || 0) + 0.5);
} else {
m.identity();
m.setPosition(b.gx + 0.5, -b.gy - 0.5, (b.gz || 0) + 0.5);
}
mesh.setMatrixAt(i, m);
}
mesh.instanceMatrix.needsUpdate = true;
mesh.userData.list = list;
TD.scene.add(mesh);
TD.blockMeshes.push({ mesh, type: t });
}
}
// ==================== PICK (рейкаст по 3D) ====================
const TDray = new THREE.Raycaster();
const TDndc = new THREE.Vector2();
function TDpick(sx, sy) {
const r = state.canvas.getBoundingClientRect();
TDndc.x = ((sx - r.left) / r.width) * 2 - 1;
TDndc.y = -((sy - r.top) / r.height) * 2 + 1;
TDray.setFromCamera(TDndc, TD.camera);
// Отбрасываем блоки МЕЖДУ камерой и игроком — ровно как делает updateAim.
// Без этого pick возвращал ПЕРВОЕ пересечение луча (землю под ногами
// героя или блок за его спиной), а прицел показывал блок ПОСЛЕ игрока.
// Клик в итоге попадал на 1-9 тайлов в сторону от того, что видно.
const _pl = state.player;
const _px = (_pl.x + _pl.w / 2) / state.TILE;
const _py = -(_pl.y + _pl.h / 2) / state.TILE;
const _pz = ((_pl.z || 0) + (_pl.d || _pl.w) / 2) / state.TILE;
const _camDist = Math.hypot(_px - TD.camera.position.x, _py - TD.camera.position.y, _pz - TD.camera.position.z);
const hits = TDray.intersectObjects(TD.blockMeshes.map(x => x.mesh), false);
let h = null;
for (const cand of hits) {
if (cand.distance < _camDist + 0.15) continue; // камера, герой, опора
h = cand;
break;
}
if (!h) return null;
const b = h.object.userData.list[h.instanceId];
if (!b) return null;
// ТОЧНЫЕ координаты блока берём из userData.list, а НЕ из h.point.
// При попадании в грань куба h.point лежит РОВНО на границе тайла
// (например z = -3.0), и floor() давал СОСЕДНИЙ блок: прицел показывал
// один блок, а клик ломал другой (в 1 тайле в сторону). Из-за этого печь
// «не открывалась»: клик уходил в соседний тайл, где furnace нет.
const gx = b.gx;
const gy = b.gy;
const gz = b.gz != null ? b.gz : (b.gz || 0);
// point нужен для точной точки попадания (установка по грани)
const wx = (h.point.x) * state.TILE;
const wy = (-h.point.y) * state.TILE;
return { gx, gy, gz, wx, wy, face: h.face ? h.face.normal : null };
}
// ==================== ВВОД ====================
function TDbindInput(canvas) {
canvas.addEventListener('pointerdown', (e) => {
TD.ptrs.set(e.pointerId, { x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, moved: false, t: performance.now() });
if (TD.ptrs.size === 2) {
const q = [...TD.ptrs.values()];
TD.pinchD = Math.hypot(q[0].x - q[1].x, q[0].y - q[1].y);
}
stopMining();
});
canvas.addEventListener('pointermove', (e) => {
const q = TD.ptrs.get(e.pointerId);
if (!q) return;
const dx = e.clientX - q.x, dy = e.clientY - q.y;
q.x = e.clientX; q.y = e.clientY;
if (Math.hypot(e.clientX - q.sx, e.clientY - q.sy) > 8) q.moved = true;
if (TD.ptrs.size === 2) {
const v = [...TD.ptrs.values()];
const d = Math.hypot(v[0].x - v[1].x, v[0].y - v[1].y);
if (TD.pinchD > 0 && d > 0) TD.dist = Math.min(34, Math.max(4, TD.dist * TD.pinchD / d));
TD.pinchD = d;
return;
}
if (q.moved) {
TD.yaw -= dx * 0.005;
TD.pitch = Math.min(0.95, Math.max(0.06, TD.pitch + dy * 0.005)); // >0.9 — камера в небе, горизонта нет
}
});
const up = (e) => {
TD.tapDbg = TD.tapDbg || { ups: 0, noq: 0, moved: 0, slow: 0, picks: 0, taps: 0 };
TD.tapDbg.ups++;
const q = TD.ptrs.get(e.pointerId);
TD.ptrs.delete(e.pointerId);
TD.pinchD = 0;
if (!q) { TD.tapDbg.noq++; return; }
if (q.moved) { TD.tapDbg.moved++; return; }
// Ограничение в 600 мс БЫЛО БАГОМ: игрок нажимает и держит (например,
// целится), а потом отпускает — и действие отменялось совсем, добыча и
// установка становились невозможны. Долгое удержание нужно не отменять,
// а считать осознанным: если указатель не двигался, действуем по отпусканию.
// Отменяем только если указатель двигался (это поворот камеры) —
// проверка q.moved уже стоит выше.
TD.tapDbg.picks++;
const hit = TDpick(e.clientX, e.clientY);
if (hit) {
TD.tapDbg.taps++;
TD.lastTap = hit;
// Передаём ТАЙЛОВЫЕ координаты блока: handleTap больше не делит
// wx/wy на TILE, иначе цель смещалась на 1 тайл.
handleTap(hit.gx, hit.gy, hit.gz, hit.face);
}
};
canvas.addEventListener('pointerup', up);
canvas.addEventListener('pointercancel', (e) => { TD.ptrs.delete(e.pointerId); TD.pinchD = 0; });
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
e.stopPropagation();
TD.dist = Math.min(34, Math.max(4, TD.dist * (1 + e.deltaY * 0.001)));
}, { passive: false, capture: true });
}
// ==================== СУЩНОСТИ ====================
function TDmobMesh(m) {
let e = TD.mobs.get(m);
if (!e) {
const g = new THREE.Group();
// Точные копии мобов 2D-игры (MOB_SPRITES: те же rects и цвета, что в game.js),
// выдавлены в глубину. Координаты нормализованы (0..1) — реальный размер
// задаёт e.body.scale ниже, как раньше делал бокс 1x1x1.
const bodyG = new THREE.Group();
g.add(bodyG);
const spec = MOB_SPRITES[m.kind];
if (MOB_TEX[m.kind]) {
// NN-текстуры (одобрены игроком): фас +z, спина -z, профиль ±x,
// верх/низ — плоский средний цвет. alphaTest: силуэт по альфе спрайта.
const mat = (view, mirror) => new THREE.MeshLambertMaterial({
map: TDmobTex(m.kind, view, mirror), alphaTest: 0.5,
});
const top = new THREE.MeshLambertMaterial({ color: MOB_TEX[m.kind].top });
const bm = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
[mat('side'), mat('side', true), top, top, mat('front'), mat('back')]
);
bm.castShadow = true;
bodyG.add(bm);
} else if (spec) {
const zBody = 0.6;
spec.rects.forEach(([rx, ry, rw, rh, c], i) => {
const deep = i === 0 ? zBody : zBody + 0.15;
const geo = new THREE.BoxGeometry(rw / spec.w, rh / spec.h, deep);
geo.translate((rx + rw / 2) / spec.w - 0.5, (ry + rh / 2) / spec.h - 0.5, deep / 2);
const mesh = new THREE.Mesh(geo, new THREE.MeshLambertMaterial({ color: c }));
mesh.renderOrder = 100 + i;
mesh.castShadow = true;
bodyG.add(mesh);
});
} else {
const bm = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshLambertMaterial({ color: TD_MOB_COLORS[m.kind] || 0x2ecc71 })
);
bm.castShadow = true;
bodyG.add(bm);
const eyeM = new THREE.MeshBasicMaterial({ color: 0x111111 });
const e1 = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.14, 0.05), eyeM);
e1.position.set(-0.16, 0.22, 0.26);
const e2 = e1.clone();
e2.position.x = 0.16;
bodyG.add(e1, e2);
}
TD.scene.add(g);
e = { g, body: bodyG };
TD.mobs.set(m, e);
}
return e;
}
function TDplayerEntry(id, p) {
let e = TD.players.get(id);
if (!e) {
const rig = TDbuildHeroRig();
const label = TDnameSprite(p.name);
label.position.y = 2.2;
rig.group.add(label);
TD.scene.add(rig.group);
e = { rig, label };
TD.players.set(id, e);
}
return e;
}
// ==================== КАДР ====================
function renderFrame3D(now) {
if (!TD) TDinit();
const p = state.player;
const TILE = state.TILE;
TD.frame++;
const dt = Math.min(0.05, Math.max(0.001, (now - (TD.lastT || now - 16)) / 1000));
TD.lastT = now;
const ptx = (p.x + (p.w || 34) / 2) / TILE;
const pgy = (p.y + (p.h || 34) / 2) / TILE; // игровый Y (вниз)
const py3 = -pgy; // three Y (вверх)
const ptz = ((p.z || 0) + (p.d || 34) / 2) / TILE;
// пересборка мешей: при изменении мира или раз в ~2 сек (для плавной подгрузки)
// пересборка не чаще раза в 700 мс и только при изменении мира
const ver = state.blocksVersion || 0;
if ((ver !== TD.lastVersion && now - TD.meshT > 700) || !TD.blockMeshes.length) {
TD.meshT = now;
TD.lastVersion = ver;
TDrebuildBlocks(ptx, pgy, ptz);
}
// небо/свет. Первые секунды после старта держим полдень, чтобы не было «рассвета»
const night = isNight();
const sky = skyColor(night, 'plains', state.weatherType, state.worldTime);
TD.scene.background.set(sky);
TD.scene.fog.color.copy(TD.scene.background);
TD.sun.intensity = night ? 0.14 : 1.5;
TD.hemi.intensity = night ? 0.3 : 0.85;
TD.amb.intensity = night ? 0.34 : 0.25;
if (state.lightningFlash > 0) TD.hemi.intensity += Math.min(2.2, state.lightningFlash * 2.4);
TD.sun.position.set(ptx + 20, py3 + 36, ptz + 20);
TD.sun.target.position.set(ptx, py3, ptz);
TD.sun.target.updateMatrixWorld();
// камера-орбита; yaw — тот же угол, что и для WASD
camDir.yaw = TD.yaw;
const cp = Math.cos(TD.pitch), sp2 = Math.sin(TD.pitch);
// Камера на уровне центра игрока. Поднимать базовую высоту нельзя:
// при 2.4/2.6 тайла мир ПРОПАДАЛ совсем (проверено) — вероятно, блоки
// отсекаются по вертикали относительно игрока, а не относительно камеры.
// Камера от игрока (вид сбоку). Требования:
// 1) не садиться ВНУТРЬ блока — иначе экран забит текстурой;
// 2) зум должен ОТЪЕЗЖАТЬ, а не упираться: игрок в яме, земля сзади
// уже на 1.6 тайла, поэтому просто укорачивать бесполезно —
// при блокировке поднимаем камеру выше и смотрим сверху;
// 3) минимальная дистанция 3.0 — ближе герой занимает треть экрана.
{
// точка занята твёрдым блоком с учётом зарадиуса R (камера «толстая»)
// ---------- КАМЕРА: единый якорь и честная проверка ----------
// Игровая Y направлена ВНИЗ (гравитация тянет в +y), three Y — вверх.
// Всё ниже работает в three-координатах: eyeY = -(gameY) + запас.
const CAM_R = 0.22; // запас от грани куба
const EYE = 0.62; // высота глаз над пятками (в тайлах)
const solidTile = (gx, gy, gz) => {
const b = getBlock(gx, gy, gz);
if (!b) return false;
const d = BLOCKS[b.t];
return !!(d && d.solid && !d.fluid && !d.decor && !d.climbable);
};
// ЖЁСТКАЯ ГАРАНТИЯ: только свой тайл. Если он твёрдый — камера внутри.
// Именно эта проверка обязана быть непротиворечивой, всё остальное —
// только для плавности.
const insideSolid = (x, y, z) =>
solidTile(Math.floor(x), Math.floor(-y), Math.floor(z));
// Мягкая проверка: свой тайл + близость к грани соседнего куба.
const solidNear = (x, y, z) => {
const gx = Math.floor(x), gy = Math.floor(-y), gz = Math.floor(z);
if (solidTile(gx, gy, gz)) return true;
if (solidTile(gx + 1, gy, gz) && x - (gx + 1) < 0.5 + CAM_R) return true;
if (solidTile(gx - 1, gy, gz) && (gx + 1) - x < 0.5 + CAM_R) return true;
if (solidTile(gx, gy + 1, gz) && (-y) - (gy + 1) < 0.5 + CAM_R) return true;
if (solidTile(gx, gy - 1, gz) && (gy + 1) - (-y) < 0.5 + CAM_R) return true;
if (solidTile(gx, gy, gz + 1) && z - (gz + 1) < 0.5 + CAM_R) return true;
if (solidTile(gx, gy, gz - 1) && (gz + 1) - z < 0.5 + CAM_R) return true;
return false;
};
// Якорь — точка на уровне глаз героя. Физика не даёт игроку быть в блоке,
// поэтому якорь гарантированно свободен и является последним откатом.
const anchorX = ptx, anchorY = py3 + EYE, anchorZ = ptz;
// Высота камеры над якорем. Раньше жёстко 1.2 + d*0.16: в яме глубиной
// 4 тайла этого НЕ хватало, solveDist возвращал 0, включался первый план
// (camDist=0) и кадр показывал пустое небо — игрок не видел вообще ничего.
// Наклон растёт с дистанцией, и на больших d камера должна явно подниматься.
const heightFor = (d) => 1.2 + d * 0.22;
// Смещение камеры относительно якоря при дистанции d и наклоне pitch.
const offsetAt = (d, pitch) => {
const maxSin = Math.min(0.55, heightFor(d) / Math.max(d, 0.001));
const sn = Math.min(Math.sin(pitch), maxSin);
const c = Math.sqrt(Math.max(0.05, 1 - sn * sn));
return { dx: Math.sin(TD.yaw) * c * d, dy: sn * d, dz: Math.cos(TD.yaw) * c * d };
};
// Ищем максимальную безопасную дистанцию: идём от желаемой к якорю и
// берём последнюю свободную точку. Никакого «угадывания» на глаз.
const solveDist = (d, pitch) => {
const o = offsetAt(d, pitch);
if (!solidNear(anchorX + o.dx, anchorY + o.dy, anchorZ + o.dz)) return d;
const steps = Math.ceil(d / 0.2);
for (let i = steps - 1; i >= 1; i--) {
const t = d * (i / steps);
const p2 = offsetAt(t, pitch);
const x = anchorX + p2.dx, y = anchorY + p2.dy, z = anchorZ + p2.dz;
if (!solidNear(x, y, z)) {
// Жёсткая проверка по СВОЕМУ тайлу обязана выполняться.
if (!insideSolid(x, y, z)) return t;
}
}
return 0;
};
const PITCH_MAX_AUTO = 0.62; // выше — вид сверху, горизонта нет
let pitch = Math.min(TD.pitch, PITCH_MAX_AUTO);
let dist = solveDist(TD.dist, pitch);
if (dist <= 0.2) {
// За спиной глухо на этой высоте — пробуем поднять камеру.
for (let cand = pitch + 0.12; cand <= PITCH_MAX_AUTO + 0.001; cand += 0.12) {
const d2 = solveDist(TD.dist, cand);
if (d2 > 0.2) { pitch = cand; dist = d2; break; }
}
}
let firstPerson = false;
if (dist <= 0.2) {
// Свободного места за спиной нет вообще: вид от первого лица.
firstPerson = true;
// Ищем свободный тайл для глаза: перебираем вверх от якоря.
// Ищем свободное место для глаза рядом с головой, а НЕ где угодно
// выше. Диапазон раньше был 0..10 тайлов: если игрок в углублении,
// камера улетала на 8 тайлов вверх, где уже нет блоков, и прицел
// смотрел в пустоту (target: null, pick: gy = -1). Игрок при этом
// стоял на gy = 7, то есть в 8 тайлах ниже камеры.
let ex = anchorX, ey = anchorY, ez = anchorZ, found = false;
const hd = Math.sin(TD.yaw) * 0.18, hz = Math.cos(TD.yaw) * 0.18;
for (let up = 0; up <= 4.0 && !found; up += 0.35) {
const cx = anchorX + hd, cy = anchorY + up, cz = anchorZ + hz;
if (!solidNear(cx, cy, cz)) { ex = cx; ey = cy; ez = cz; found = true; }
}
if (!found) { ex = anchorX; ey = anchorY; ez = anchorZ; }
TD.camera.position.set(ex, ey, ez);
TD.camDist = 0; TD.camPitch = +pitch.toFixed(2); TD.camHeight = 0;
// Смотрим вдоль курса с небольшим наклоном вниз, как в обычном
// third-person: горизонтальный lookAt на 8 тайлов уводил взгляд
// в небо, когда камера оказывалась высоко над игроком.
// Направление взгляда = МИНУС курса yaw. Камера в первом лице стоит
// на якоре плюс смещение hd/hz = (sin(yaw), cos(yaw)) * 0.18, то есть
// СЗАДИ игрока по курсу. Смотреть в (sin(yaw), cos(yaw)) — значит
// смотреть НАЗАД, в упор в тот блок, ради которого включился
// первый план: наблюдалось взгляд [0,0,1] при camDist = 0.
TD.camera.lookAt(
ex - Math.sin(TD.yaw) * 8,
ey - Math.sin(pitch) * 8,
ez - Math.cos(TD.yaw) * 8
);
TD.camFirstPerson = true;
TD.updateAim(ptx, py3, ptz);
if (TD.hero && TD.hero.group) {
// Риг ставим на землю иначе после return основной путь не дойдёт.
const PWH0 = p.h || 34;
TD.hero.group.scale.setScalar((PWH0 / TILE) / (3.2 * HERO_SCALE));
TD.hero.group.position.set(ptx, -Math.round(((p.y || 0) + PWH0) / TILE), ptz);
if (p.facing !== undefined) TD.hero.group.rotation.y = -p.facing;
TD.hero.group.visible = false;
}
return;
}
TD.camFirstPerson = false;
if (TD.hero && TD.hero.group) TD.hero.group.visible = true;
const ofs = offsetAt(dist, pitch);
TD.camHeight = +ofs.dy.toFixed(2);
TD.camPitch = +pitch.toFixed(2);
TD.camDist = +dist.toFixed(2);
TD.camera.position.set(anchorX + ofs.dx, anchorY + ofs.dy, anchorZ + ofs.dz);
// Позиция уже задана выше. Ничего не перезаписываем: старый код ставил
// камеру второй раз, по другой формуле, и возвращал её в грунт.
}
// Взгляд идёт ОТ камеры ЧЕРЕЗ игрока и дальше — так прицел попадает в
// точку перед героем, а не ему в спину. Раньше lookAt(ptx, lookY, ptz)
// разворачивал камеру НА игрока, и updateAim всегда целился в него самого:
// target был { gx: <колонка игрока>, face: { y: 1 } }, то есть опора под
// ногами. Добыть или поставить что-либо было невозможно.
// Направление взгляда: камера стоит ЗА игроком, поэтому «вперёд» = от камеры
// мимо игрока, то есть player - camera. Если развернуть камеру НА игрока
// (lookAt(ptx,...)), луч уходит назад и попадает в опору под ногами:
// наблюдалось target gz: -4 при игроке gz: 0 и face {y: 1}.
const _fwd = new THREE.Vector3(ptx - TD.camera.position.x, py3 + 0.5 - TD.camera.position.y, ptz - TD.camera.position.z);
if (_fwd.lengthSq() < 1e-6) _fwd.set(Math.sin(TD.yaw), 0, Math.cos(TD.yaw));
_fwd.normalize();
TD.camera.lookAt(
TD.camera.position.x + _fwd.x,
TD.camera.position.y + _fwd.y,
TD.camera.position.z + _fwd.z
);
// Прицел: луч из камеры вдоль взгляда. Обновляем после позиционирования.
TD.updateAim(ptx, py3, ptz);
// Герой стоит НОГАМИ на поверхности: p.y в пикселях, низ коллайдера = p.y + p.h.
// Раньше ставился p.y — верх коллайдера, из-за чего персонаж тонул в блоках.
// Ноги = p.y + p.h. Физика держит игрока ГЛУБОКО в блоке-опоре (feet уходит
// на h пикселей внутрь), поэтому ставим ноги ровно на верхнюю грань
// блока под ними: -floor(feet/TILE). Иначе персонаж тонет в текстуре.
// Физика и рисунок ДОЛЖНЫ совпадать. Коллайдер игрока h=34 при TILE=40 —
// это 0.85 тайла, а риг из рисунка при HERO_SCALE=0.5 даёт 1.6 тайла.
// Из-за этого персонаж выглядел втрое выше, чем есть, и «висел» над блоком.
// Масштабируем риг под коллайдер и ставим ноги ровно на верхнюю грань опоры.
const PWH = p.h || 34;
const rigH = 3.2 * HERO_SCALE; // высота рига в тайлах
const heroScale = (PWH / TILE) / rigH; // подгонка под коллайдер
TD.hero.group.scale.setScalar(heroScale);
// feet = p.y + h в пикселях; в тайлах это и есть тайл опоры, на верхней
// грани которого стоят ноги. Y направлена вниз, поэтому знак минус.
const feetGY = Math.round(((p.y || 0) + PWH) / TILE);
const heroFeetY = -feetGY;
TD.hero.group.position.set(ptx, heroFeetY, ptz);
if (p.facing !== undefined) {
const wantYaw = -p.facing;
TD.hero.group.rotation.y += (wantYaw - TD.hero.group.rotation.y) * Math.min(1, 9 * dt);
}
// Анимация — как в эталонной игре: ноги качаются, хвост всегда живой,
// корпус сжимается в прыжке и чуть дышит в покое, голова наклоняется
// по скорости. Без этого риг выглядит как неподвижная картинка.
TD.heroTime += dt;
const moving = Math.hypot(p.vx || 0, p.vz || 0) > 20 && p.grounded;
if (moving) TD.heroStep += dt * 11;
const HP = TD.hero.P;
HP.legs.rotation.z = moving ? Math.sin(TD.heroStep) * 0.34 : 0;
HP.legs.rotation.x = moving ? Math.cos(TD.heroStep) * 0.22 : 0;
// хвост: покачивание всегда, сильнее при беге
const tailSwing = Math.sin(TD.heroTime * 6 + (moving ? TD.heroStep : 0)) * (moving ? 0.3 : 0.14);
HP.tail.rotation.z += (tailSwing - HP.tail.rotation.z) * Math.min(1, 8 * dt);
// голова чуть наклоняется по горизонтальной скорости
const lean = (p.vx || 0) * 0.0006;
HP.head.rotation.z += (-lean - HP.head.rotation.z) * Math.min(1, 6 * dt);
// корпус: в прыжке вытянут вверх, на земле «дышит»
if (!p.grounded) {
const stretch = (p.vy || 0) < 0 ? 1.08 : 0.94; // вверх — вытянут, вниз — сжат
HP.body.scale.y += (stretch - HP.body.scale.y) * Math.min(1, 8 * dt);
} else {
const breathe = 1 + 0.015 * Math.sin(TD.heroTime * 2.2);
HP.body.scale.y += (breathe - HP.body.scale.y) * Math.min(1, 6 * dt);
}
// мобы
const allMobs = state.isMultiplayer ? Array.from(state.serverMobs.values()) : state.mobs;
const seen = new Set();
for (const m of allMobs) {
if (m.dead) continue;
seen.add(m);
const e = TDmobMesh(m);
e.body.scale.set((m.w || 30) / TILE, (m.h || 30) / TILE, (m.d || m.w || 30) / TILE);
e.g.position.set(
(m.x + (m.w || 30) / 2) / TILE,
-(m.y + (m.h || 30) / 2) / TILE,
((m.z || 0) + (m.d || m.w || 30) / 2) / TILE
);
}
for (const [m, e] of TD.mobs) {
if (!seen.has(m)) { TD.scene.remove(e.g); TD.mobs.delete(m); }
}
// игроки сети
const seenP = new Set();
if (state.otherPlayers) {
for (const [id, pl] of state.otherPlayers) {
seenP.add(id);
const e = TDplayerEntry(id, pl);
e.rig.group.position.set((pl.x + 17) / TILE, -pl.y / TILE, ((pl.z || 0) + 17) / TILE);
}
}
for (const [id, e] of TD.players) {
if (!seenP.has(id)) { TD.scene.remove(e.rig.group); TD.players.delete(id); }
}
// лодка
if (state.boat && state.boat.active) {
TD.boatMesh.visible = true;
TD.boatMesh.position.set(state.boat.x / TILE, -state.boat.y / TILE, (state.boat.z || 0) / TILE);
} else TD.boatMesh.visible = false;
// снаряды
const projs = state.projectiles || [];
for (let i = 0; i < TD.projPool.length; i++) {
const mesh = TD.projPool[i];
const pr = projs[i];
if (!pr) { mesh.visible = false; continue; }
mesh.visible = true;
mesh.material.color.set(pr.owner === 'mob' ? 0xc0392b : 0xf1c40f);
mesh.position.set(pr.x / TILE, -pr.y / TILE, (pr.z || 0) / TILE);
mesh.rotation.z = -Math.atan2(pr.vy, pr.vx);
}
// частицы
const parts = state.parts || [];
const n = Math.min(parts.length, TD.PMAX);
for (let i = 0; i < n; i++) {
const pt = parts[i];
TD.pPos[i * 3] = pt.x / TILE;
TD.pPos[i * 3 + 1] = -pt.y / TILE;
TD.pPos[i * 3 + 2] = (pt.z || 0) / TILE;
let cc = TD.colCache.get(pt.c);
if (!cc) { cc = new THREE.Color(pt.c || '#ffffff'); TD.colCache.set(pt.c, cc); }
TD.pCol[i * 3] = cc.r; TD.pCol[i * 3 + 1] = cc.g; TD.pCol[i * 3 + 2] = cc.b;
}
TD.points.geometry.setDrawRange(0, n);
TD.points.geometry.attributes.position.needsUpdate = true;
TD.points.geometry.attributes.color.needsUpdate = true;
// облака
const clouds = state.clouds || [];
for (let i = 0; i < TD.cloudMeshes.length; i++) {
const cm = TD.cloudMeshes[i];
const c = clouds.length ? clouds[i % clouds.length] : null;
if (!c) { cm.visible = false; continue; }
cm.visible = true;
cm.scale.set((c.w || 120) / TILE, 1, 1);
// Выше и дальше игрока: раньше облака висели в 12 тайлах по Z прямо перед
// камерой и перекрывали обзор серой пеленой.
cm.position.set(c.x / TILE, -c.y / TILE, ptz - 30);
}
// огни
if (TD.frame % 20 === 0 || !TD.fireCache) {
const found = [];
for (const b of state.blocks) {
if (b.dead) continue;
const def = BLOCKS[b.t];
if (!def) continue;
const lit = def.lightRadius || b.t === 'campfire' ||
(b.t === 'furnace' && state.activeFurnaces && state.activeFurnaces.has(b.gx + ',' + b.gy + ',' + (b.gz || 0)));
if (!lit) continue;
const dx = b.gx - ptx, dy = -b.gy + pty, dz = (b.gz || 0) - ptz;
found.push({ t: b.t, gx: b.gx, gy: b.gy, gz: b.gz || 0, d: dx * dx + dy * dy + dz * dz });
}
found.sort((a, b) => a.d - b.d);
TD.fireCache = found.slice(0, 8);
}
for (const L of TD.lightPool) L.intensity = 0;
for (const f of TD.flamePool) f.visible = false;
let li = 0, fi = 0;
for (const src of (TD.fireCache || [])) {
if (li < TD.lightPool.length) {
const L = TD.lightPool[li++];
L.position.set(src.gx + 0.5, -src.gy + 0.5, src.gz + 0.5);
L.intensity = 1.6 + Math.sin(now / 130 + src.gx) * 0.35;
}
if ((src.t === 'campfire' || src.t === 'furnace') && fi < TD.flamePool.length) {
const f = TD.flamePool[fi++];
f.visible = true;
f.position.set(src.gx + 0.5, -src.gy + 0.78 + Math.sin(now / 90 + src.gy) * 0.06, src.gz + 0.5);
const sc2 = 0.9 + Math.sin(now / 70 + src.gx * 2) * 0.18;
f.scale.set(sc2, sc2 * (1 + Math.sin(now / 55) * 0.12), sc2);
}
}
// погода
if (state.isRaining) {
TD.rain.pts.visible = true;
for (let i = 0; i < TD.rain.n; i++) {
let y = TD.rain.pos[i * 3 + 1] - dt * 26;
let x = TD.rain.pos[i * 3];
if (y < py3 - 12) { y = py3 + 12; x = ptx + (Math.random() - 0.5) * 40; }
TD.rain.pos[i * 3] = x;
TD.rain.pos[i * 3 + 1] = y;
TD.rain.pos[i * 3 + 2] = ptz + 6;
}
TD.rain.pts.geometry.attributes.position.needsUpdate = true;
} else TD.rain.pts.visible = false;
if (state.weatherType === 'snow') {
TD.snow.pts.visible = true;
for (let i = 0; i < TD.snow.n; i++) {
let y = TD.snow.pos[i * 3 + 1] - dt * 2;
let x = TD.snow.pos[i * 3] + Math.sin(now / 900 + i) * dt * 1.5;
if (y < py3 - 12) { y = py3 + 12; x = ptx + (Math.random() - 0.5) * 40; }
TD.snow.pos[i * 3] = x;
TD.snow.pos[i * 3 + 1] = y;
TD.snow.pos[i * 3 + 2] = ptz + 6;
}
TD.snow.pts.geometry.attributes.position.needsUpdate = true;
} else TD.snow.pts.visible = false;
// подсветка блока под курсором / прогресс добычи
const mg = state.mining;
if (mg && mg.gx !== null && mg.hardness > 0) {
TD.crack.visible = true;
TD.crack.position.set(mg.gx + 0.5, -mg.gy - 0.5, (mg.gz || 0) + 0.5);
TD.crack.material.opacity = Math.min(0.55, (mg.progress / mg.hardness) * 0.55);
} else TD.crack.visible = false;
if (mode() === 'build' && TD.lastTap) {
TD.ghost.visible = true;
TD.ghost.position.set(TD.lastTap.gx + 0.5, -TD.lastTap.gy - 0.5, (TD.lastTap.gz || 0) + 0.5);
} else TD.ghost.visible = false;
if (state.W !== window.innerWidth || state.H !== window.innerHeight) TDresize();
TD.camera.aspect = window.innerWidth / window.innerHeight;
TD.camera.updateProjectionMatrix();
TD.renderer.render(TD.scene, TD.camera);
}
// === game/loop3d.js ===
// ==================== ЦИКЛ ИГРЫ 3D — GrechkaCraft v3 ====================
// Всё как в 2D-игре, но движение по трём осям: X (вперёд-назад по миру),
// Y (вверх-вниз), Z (вбок). WASD работают относительно взгляда камеры.
let last = performance.now();
let prevJump = false;
document.addEventListener('visibilitychange', () => {
if (!document.hidden) last = performance.now();
});
// Направление взгляда камеры (задаётся рендером) — WASD относительно него
const camDir = { yaw: 0, pitch: 0 };
function loop(now) {
// ---------- поверхность: верх ТВЁРДОГО блока в колонке ----------
// surfaceGyAt может вернуть тайл, который сам занят блоком (например под
// «травой»). Тогда ноги оказываются внутри блока: grounded держится, но
// прыжок гасится столкновением на первом же шаге вверх.
// Верхний ТВЁРДЫЙ тайл колонки (ось Y вниз: чем больше gy, тем ниже).
// Полный скан колонки — единственный надёжный способ: любые эвристики
// вида «шагни вверх, пока твёрдые» уходили за границу мира (gy = -1).
// Опора = ПОСЛЕДНИЙ твёрдый блок колонки (поверхность земли).
// Ось Y вниз, поэтому чем больше gy, тем ниже; поверхность — максимальный
// твёрдый gy, перед которым уже вода/воздух.
function TDsolidTop(cx, cz) {
// ВЕРХНИЙ твёрдый блок колонны = поверхность, на которой можно стоять.
// Раньше брался ПОСЛЕДНИЙ (самый нижый, bedrock) — игрока ставило
// в фундамент, тело оказывалось внутри блока, камера входила в текстуру.
// Обход СВЕРХУ ВНИЗ: первый твёрдый блок, до которого дошли сверху — это и
// есть поверхность. Раньше цикл шёл снизу (gy=0) и возвращал ДНО мира
// (stone на gy=0), после чего страховка безопасности уносила игрока на
// BEDROCK_GY-6 = 133 — то есть глубоко под мир, где только воздух.
const TOP = Math.min(state.BEDROCK_GY + 2, 220);
for (let gy = TOP; gy >= 0; gy--) {
if (isSolid(cx, gy, cz)) return gy;
}
return -1;
}
const rawDt = Math.min(0.05, (now - last) / 1000);
last = now;
const PHYSICS_STEP = 0.016;
const steps = Math.max(1, Math.ceil(rawDt / PHYSICS_STEP));
const dt = rawDt / steps;
const player = state.player;
const inp = state.inp;
const mobs = state.mobs;
const projectiles = state.projectiles;
const boat = state.boat;
// на компе джойстик не трогают — полная скорость с клавиатуры
const keyboardOnly = Math.abs(inp.kfwd || 0) > 0 || Math.abs(inp.kside || 0) > 0;
const jumpPressed = inp.j && !prevJump;
prevJump = inp.j;
// ---------- день/ночь ----------
if (!state.dayNightCycle) {
// время стоит
} else if (player.sleeping && isNight()) {
state.worldTime += dt * 8 / state.DAY_LEN;
player.hp = Math.min(100, player.hp + dt * 20);
if (!isNight()) player.sleeping = false;
} else {
state.worldTime += dt / state.DAY_LEN;
}
if (state.worldTime >= 1) state.worldTime -= 1;
// ---------- генерация мира ----------
// Мир один и фиксированный: строится целиком порциями, ничего не вычищается.
ensureGenAroundCamera();
state.worldGenReady = isWorldReady();
state.worldGenProgress = worldGenProgress();
// ---------- кислород/утопление ----------
if (player.headInWater) {
player.o2 = Math.max(0, player.o2 - 6 * dt);
if (player.o2 === 0) player.hp -= calculateDamage(4 * dt);
} else {
player.o2 = Math.min(100, player.o2 + 10 * dt);
}
player.hunger = Math.max(0, player.hunger - dt * 0.2);
// ---------- температура по биому (теперь биом 2D) ----------
if (player.temperature === undefined) player.temperature = 15;
const BIOME_TEMP = { snow: -20, plains: 15, swamp: 20, desert: 45, mountains: 5 };
const COLD_THRESHOLD = -5;
const HEAT_THRESHOLD = 35;
const pGX = Math.floor((player.x + player.w / 2) / state.TILE);
const pGZ = Math.floor((player.z + player.d / 2) / state.TILE);
const pGY = Math.floor((player.y + player.h / 2) / state.TILE);
const biome = getCachedBiome(pGX, pGZ);
const sGY = surfaceGyAt(pGX, pGZ);
const isUnderground = (pGY - sGY) > 2;
let targetTemp = BIOME_TEMP[biome] || 15;
if (isUnderground) targetTemp = 15;
if (isNight() && !isUnderground) targetTemp -= 10;
if ((state.weatherType === 'rain' || state.weatherType === 'storm') && !isUnderground) targetTemp -= 5;
player.temperature += (targetTemp - player.temperature) * dt * 0.3;
// костры/факелы греют, вода в пустыне холодит — ищем в кубе вокруг
let nearHeat = false, nearCool = false;
for (let dx = -2; dx <= 2 && !nearHeat; dx++) {
for (let dy = -2; dy <= 2 && !nearHeat; dy++) {
for (let dz = -2; dz <= 2 && !nearHeat; dz++) {
const b = getBlock(pGX + dx, pGY + dy, pGZ + dz);
if (b && (b.t === 'campfire' || b.t === 'torch' || b.t === 'furnace')) nearHeat = true;
}
}
}
if (!nearHeat && biome === 'desert') {
for (let dx = -2; dx <= 2 && !nearCool; dx++) {
for (let dz = -2; dz <= 2 && !nearCool; dz++) {
const b = getBlock(pGX + dx, pGY, pGZ + dz);
if (b && b.t === 'water') nearCool = true;
}
}
}
if (nearHeat) {
player.temperature += (30 - player.temperature) * dt * 2;
if (player.hp < 100) player.hp = Math.min(100, player.hp + 8 * dt);
}
if (nearCool) {
player.temperature += (15 - player.temperature) * dt * 1.5;
if (player.hp < 100 && player.temperature > HEAT_THRESHOLD) player.hp = Math.min(100, player.hp + 8 * dt);
}
if (isUnderground && !nearHeat && player.hp < 100 &&
player.temperature >= COLD_THRESHOLD && player.temperature <= HEAT_THRESHOLD) {
player.hp = Math.min(100, player.hp + 2 * dt);
}
const aboveBlk = getBlock(pGX, pGY + 1, pGZ);
if (aboveBlk && BLOCKS[aboveBlk.t] && BLOCKS[aboveBlk.t].solid && aboveBlk.t !== 'glass' && !isUnderground && !nearCool) {
player.temperature -= 5 * dt;
}
if (player.temperature < COLD_THRESHOLD) {
player.hp -= 1 * dt;
player.hunger -= 0.5 * dt;
}
if (player.slowTimer > 0) player.slowTimer -= dt;
// ---------- кактус: урон в кубе ----------
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
for (let dz = -1; dz <= 1; dz++) {
const cb = getBlock(pGX + dx, pGY + dy, pGZ + dz);
if (cb && BLOCKS[cb.t] && BLOCKS[cb.t].hurt && player.invuln <= 0) {
player.hp -= 2;
player.invuln = 0.5;
}
}
}
}
// ---------- лёд: скольжение ----------
const underBlk = getBlock(pGX, pGY - 1, pGZ);
if (underBlk && BLOCKS[underBlk.t] && BLOCKS[underBlk.t].slip && player.grounded) {
player.vx *= 0.98;
player.vz *= 0.98;
}
// ---------- ДВИЖЕНИЕ: WASD относительно камеры ----------
if (player.sleeping) {
player.vx = player.vy = player.vz = 0;
} else {
// Оси ввода -1..1. Джойстик даёт аналог (ax/ay), клавиатура — дискретно (kfwd/kside).
let fwd = (inp.ay || 0) + (inp.kfwd || 0);
let side = (inp.ax || 0) + (inp.kside || 0);
if (inp.l || inp.left) side -= 1;
if (inp.r || inp.right) side += 1;
if (inp.d || inp.back) fwd += 1;
if (inp.up) fwd -= 1;
fwd = Math.max(-1, Math.min(1, fwd));
side = Math.max(-1, Math.min(1, side));
// в мировых осях через угол камеры
const cy = Math.cos(camDir.yaw), sy = Math.sin(camDir.yaw);
let dx = side * cy - fwd * sy;
let dz = side * sy + fwd * cy;
const len = Math.hypot(dx, dz);
if (len > 0.0001) {
// аналоговое управление: чем дальше наклонён джойстик, тем быстрее
const speed = state.MOVE * (keyboardOnly ? 1 : Math.max(0.45, Math.min(1, len)));
dx /= len; dz /= len;
player.vx = dx * speed;
player.vz = dz * speed;
player.facing = Math.atan2(dz, dx);
} else {
player.vx *= 0.82;
player.vz *= 0.82;
}
}
// шаги
if (player.grounded && !player.inWater && Math.hypot(player.vx, player.vz) > 50) {
if (now / 1000 - player.lastStepTime > 0.35) {
playSound('step');
player.lastStepTime = now / 1000;
}
}
// ---------- прыжок / плавание / лодка ----------
if (player.inBoat) {
player.vy = 0;
player.x = boat.x + 2;
player.y = boat.y - 4;
player.z = boat.z + 2;
player.grounded = true;
player.inWater = false;
if (jumpPressed) {
state.inv.boat = (state.inv.boat || 0) + 1;
player.inBoat = false;
boat.active = false;
player.y += state.TILE;
player.vy = -state.JUMP * 0.5;
playSound('splash');
}
} else if (player.inWater) {
player.vx *= 0.90;
player.vz *= 0.90;
player.vy *= 0.92;
if (!jumpPressed && !inp.j) {
player.vy += state.GRAV_WATER * dt;
} else if (jumpPressed) {
player.vy = Math.min(player.vy, -520);
} else if (inp.j) {
player.vy = Math.min(player.vy, -260);
}
} else {
if (jumpPressed && player.grounded && !player.sleeping) {
player.vy = -state.JUMP;
player.grounded = false;
player.fallStartY = player.y;
}
}
if (!player.inWater && !player.inBoat) {
player.vy += state.GRAV * dt;
}
// ---------- лодка: плывёт, тонет вне воды ----------
if (boat.active) {
boat.x += boat.vx * dt;
boat.y += boat.vy * dt;
boat.z += (boat.vz || 0) * dt;
const boatGX = Math.floor(boat.x / state.TILE);
const boatGY = Math.floor(boat.y / state.TILE);
const boatGZ = Math.floor(boat.z / state.TILE);
const below = getBlock(boatGX, boatGY + 1, boatGZ);
if (!below || (below.t !== 'water' && below.t !== 'swamp_water')) {
state.inv.boat = (state.inv.boat || 0) + 1;
player.inBoat = false;
boat.active = false;
player.y += state.TILE;
player.vy = -200;
playSound('splash');
}
}
if (player.inBoat && !boat.active) {
state.inv.boat = (state.inv.boat || 0) + 1;
player.inBoat = false;
player.y += state.TILE;
player.vy = -200;
playSound('splash');
}
// ---------- границы мира по Z ----------
const maxZ = WORLD_DEPTH * state.TILE;
if (player.z < -maxZ) { player.z = -maxZ; player.vz = 0; }
if (player.z > maxZ) { player.z = maxZ; player.vz = 0; }
// ---------- страховка: не проваливаемся в пустоту ----------
// Две проверки:
// 1) ушёл глубже бедрока — физически дно мира, телепорт наверх;
// 2) стоял и медленно «уезжал» вниз (протухший коллайдер) — тоже наверх.
{
const feetGY = Math.floor((player.y + player.h) / state.TILE);
const headGY = Math.floor(player.y / state.TILE);
const cx = Math.floor((player.x + player.w / 2) / state.TILE);
const cz = Math.floor((player.z + player.d / 2) / state.TILE);
// Спасать только когда под ногами ПУСТОТА (обрыв, провал, bedrock),
// а не по близости к «поверхности». Проверка feetGY < sGY - 12 срабатывала
// на любом холме и телепортировала игрока наверх — отсюда «улетел».
const groundUnder = isSolid(cx, feetGY, cz);
const groundNext = isSolid(cx, feetGY - 1, cz);
const FLOOR = Math.max(state.BEDROCK_GY - 1, 0);
if (feetGY > FLOOR + 2 || headGY > FLOOR || (!groundUnder && !groundNext)) {
// ВАЖНО: player.y — ВЕРХ коллайдера, ноги = y + h.
// Стоять на поверхности sGY значит: ноги на верхней грани блока,
// то есть y + h = sGY * TILE => y = sGY * TILE - h.
// Раньше было y = (sGY+1) * TILE — ноги уезжали на h пикселей
// в тело блока: игрок тонул, grounded был всегда false.
// -1 = под колонкой пусто (обрыв). Тогда оставляем игрока падать: внизу
// bedrock, он встанет сам. Раньше тут был FLOOR-6 = 133 — игрока
// выносило в пустоту над миром.
const safeY = TDsolidTop(cx, cz);
// safeY < 0 = под колонкой пусто (обрыв над миром). Тогда игрока не
// трогаем: пусть падает, внизу bedrock.
if (safeY >= 0) {
// Ось Y направлена ВНИЗ. Верхняя грань блока safeY = safeY * TILE,
// Ось Y направлена ВНИЗ. Тайл safeY занимает [safeY*T, (safeY+1)*T].
// Верхняя грань = (safeY+1)*T. Ноги (y + h) стоят РОВНО на ней:
// y = (safeY+1)*T - h. Раньше было safeY*T - h, и ноги оказывались
// внутри тайла safeY: игрок тонул, grounded был false, камера уходила
// в блок и кадр показывал пустое небо.
player.y = (safeY + 1) * state.TILE - player.h;
player.vy = 0;
player.grounded = true;
player.fallStartY = player.y;
}
}
}
// ---------- ФИЗИКА: X → Z → Y ----------
const sub = dt;
// ВАЖНО: координаты НЕ двигаем здесь. resolveX/Z/Y сами применяют
// смещение через moveAxis (там же проверка столкновения и автошаг).
// Двойное применение смещения ломало прыжок: вверх тело смещалось
// вручную, а moveAxis проверял ту же позицию и откатывал обратно,
// обнуляя vy в тот же кадр.
for (let step = 0; step < steps; step++) {
resolveX(player, sub);
resolveZ(player, sub);
resolveY(player, sub);
}
// лестница: вертикальное перемещение
if (onLadder(player)) {
if (inp.j) player.vy = -200;
else if (inp.s || inp.down) player.vy = 100;
else player.vy = 0;
}
// урон от падения: дистанция от последнего касания земли
if (player.grounded) {
if (player.fallStartY !== undefined) {
const fallDist = player.fallStartY - player.y;
if (fallDist > state.TILE * 6) {
player.hp -= calculateDamage((fallDist / state.TILE - 6) * 2.5);
}
}
player.fallStartY = player.y;
} else if (player.vy > 0 && player.fallStartY === undefined) {
player.fallStartY = player.y;
}
// вода
updateWaterFlag3(player, dt);
sendPlayerPosition();
updateWeather(dt);
updateRain(dt);
player.invuln = Math.max(0, player.invuln - dt);
// ---------- мобы ----------
state.spawnT += dt;
if (state.spawnT > 6) {
state.spawnT = 0;
spawnMobs3D();
}
for (const m of mobs) {
mobAI(m, dt); // физика 3D внутри mob-ai3d
}
// снаряды
for (let i = projectiles.length - 1; i >= 0; i--) {
const pr = projectiles[i];
pr.x += pr.vx * dt;
pr.y += pr.vy * dt;
pr.life -= dt;
// столкновение с блоком (по Z игрока/снаряда)
const gx = Math.floor(pr.x / state.TILE);
const gy = Math.floor(pr.y / state.TILE);
const gz = Math.floor((pr.z || player.z) / state.TILE);
const b = getBlock(gx, gy, gz);
if (b && BLOCKS[b.t] && BLOCKS[b.t].solid) {
projectiles.splice(i, 1);
continue;
}
if (pr.life <= 0) projectiles.splice(i, 1);
}
// ---------- TNT ----------
for (const b of state.blocks) {
if (b.dead || b.t !== 'tnt' || !b.active) continue;
b.fuse -= dt;
if (b.fuse <= 0) {
explodeTNT3D(b, state);
}
}
// ---------- печь ----------
tickFurnaces(dt);
if (state.currentFurnaceKey && Math.random() < 0.1) renderFurnaceUI();
// ---------- смерть/возрождение ----------
if (player.hp <= 0 && state.deathEl && state.deathEl.style.display !== 'flex') {
player.hp = 0;
state.deathEl.style.display = 'flex';
} else if (player.hp > 0 && state.deathEl && state.deathEl.style.display === 'flex') {
state.deathEl.style.display = 'none';
}
// ---------- HUD ----------
if (Math.random() < 0.25) {
state.hpEl.textContent = Math.max(0, Math.ceil(player.hp));
state.foodEl.textContent = Math.ceil(player.hunger);
const o2El = document.getElementById('o2');
if (o2El) o2El.textContent = Math.ceil(player.o2);
const tempEl = document.getElementById('temp');
if (tempEl) {
const t = Math.round(player.temperature);
tempEl.textContent = t + '°C';
if (player.temperature < COLD_THRESHOLD) tempEl.style.color = '#74b9ff';
else if (player.temperature > HEAT_THRESHOLD) tempEl.style.color = '#e17055';
else tempEl.style.color = '#55efc4';
}
state.sxEl.textContent = pGX;
state.syEl.textContent = pGY;
state.todEl.textContent = isNight() ? 'Ночь' : 'День';
state.worldIdEl.textContent = state.worldId;
// XP-бар: в 3D считается ЗДЕСЬ. Раньше элементы #xplevel и #xpbar были
// в HTML, но не имели обработчика вообще — ни в 2D, ни в 3D, и висели
// вечно на «Lv.1 | 0/50». Порог уровня: 50 * level.
const xpEl = document.getElementById('xpbar');
const lvlEl = document.getElementById('xplevel');
if (xpEl && lvlEl) {
const lvl = player.level || 1;
const need = 50 * lvl;
const xp = player.xp || 0;
// И уровень, и бар пишем КАЖДЫЙ раз: раньше lvlEl.textContent
// только читалcя, но никогда не присваивался, и на экране вечно
// висело «Lv.1» независимо от реального уровня.
lvlEl.textContent = String(lvl);
xpEl.textContent = Math.min(xp, need) + '/' + need;
if (lvlEl.parentElement) {
lvlEl.parentElement.style.color = xp >= need ? '#7ee08a' : '';
}
}
if (state.isMultiplayer) {
const st = document.getElementById('multiplayerStatus');
if (st) st.style.display = 'flex';
state.playerCountEl.textContent = state.otherPlayers.size + 1;
} else {
const st = document.getElementById('multiplayerStatus');
if (st) st.style.display = 'none';
}
}
// сон — DOM-оверлей
const sleepOv = document.getElementById('sleepOverlay');
if (sleepOv) sleepOv.style.display = player.sleeping ? 'flex' : 'none';
// ---------- РЕНДЕР ----------
renderFrame3D(now);
requestAnimationFrame(loop);
}
// ---------- спавн мобов в 3D ----------
function spawnMobs3D() {
const p = state.player;
const pcx = p.x / state.TILE;
const pcy = p.y / state.TILE;
const pcz = (p.z || 0) / state.TILE;
const kinds = [Zombie, Pig, Chicken, Creeper, Skeleton, Scorpion, PolarBear, Slime, Eagle];
for (let i = 0; i < 2; i++) {
const ang = Math.random() * Math.PI * 2;
const dist = 14 + Math.random() * 12;
const mx = Math.floor(pcx + Math.cos(ang) * dist);
const mz = Math.floor(pcz + Math.sin(ang) * dist);
if (Math.abs(mz) > WORLD_DEPTH) continue;
const sy = surfaceGyAt(mx, mz) - 1;
if (sy < 1) continue;
const K = kinds[Math.floor(Math.random() * kinds.length)];
const m = new K(mx * state.TILE, sy * state.TILE, mz * state.TILE);
m.z = mz * state.TILE;
m.d = m.d || m.w;
m.vz = 0;
state.mobs.push(m);
}
}
// === ui/minimap.js ===
// ==================== МИНИКАРТА ====================
const minimapWrap = document.getElementById('minimapWrap');
const minimapCanvas = document.getElementById('minimap');
const minimapCtx = minimapCanvas.getContext('2d');
// Цвета блоков для миникарты (по 1 пикселю на блок)
const MINIMAP_COLORS = {
grass: '#4a8c2a', dirt: '#6b3410', stone: '#6a6a6a', sand: '#d4b84a',
gravel: '#888', clay: '#5a9ad4', wood: '#a63d00', planks: '#c46a10',
leaves: '#2a8a3a', glass: '#aaddee', water: '#2980b9', coal: '#1a1a2a',
copper_ore: '#a05535', iron_ore: '#b0b0b0', gold_ore: '#d4a017',
diamond_ore: '#0090d0', brick: '#8a2010', tnt: '#c02020',
campfire: '#d08020', torch: '#d0a020', bedrock: '#1a1a1a',
flower: '#d03050', bed: '#c03060', ladder: '#a04000', boat: '#6b3410'
};
function initMinimap() {
document.getElementById('mapToggle').onclick = () => {
playSound('click');
state.minimapOpen = !state.minimapOpen;
minimapWrap.style.display = state.minimapOpen ? 'block' : 'none';
};
const minimapClose = document.getElementById('minimapClose');
if (minimapClose) {
minimapClose.addEventListener('click', (e) => {
e.stopPropagation();
state.minimapOpen = false;
minimapWrap.style.display = 'none';
});
}
}
function renderMinimap() {
if (!state.minimapOpen) return;
const mW = minimapCanvas.width;
const mH = minimapCanvas.height;
const scale = 2; // пикселей на блок
const TILE = state.TILE;
const player = state.player;
// Область карты — центрирована на игроке
const pGX = Math.floor(player.x / TILE);
const pGY = Math.floor(player.y / TILE);
const viewW = Math.floor(mW / scale);
const viewH = Math.floor(mH / scale);
const startGX = pGX - Math.floor(viewW / 2);
const startGY = pGY - Math.floor(viewH / 2);
// Очищаем
minimapCtx.fillStyle = isNight() ? '#070816' : '#87CEEB';
minimapCtx.fillRect(0, 0, mW, mH);
// Рисуем блоки
const imgData = minimapCtx.createImageData(mW, mH);
const data = imgData.data;
for (let dx = 0; dx < viewW; dx++) {
for (let dy = 0; dy < viewH; dy++) {
const gx = startGX + dx;
const gy = startGY + dy;
const b = getBlock(gx, gy, 0);
if (!b || b.dead || b.t === 'air') continue;
const color = MINIMAP_COLORS[b.t];
if (!color) continue;
// Парсим hex цвет
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const bl = parseInt(color.slice(5, 7), 16);
// Заполняем scale x scale пикселей
for (let sx = 0; sx < scale; sx++) {
for (let sy = 0; sy < scale; sy++) {
const px = dx * scale + sx;
const py = dy * scale + sy;
if (px >= mW || py >= mH) continue;
const idx = (py * mW + px) * 4;
data[idx] = r;
data[idx + 1] = g;
data[idx + 2] = bl;
data[idx + 3] = 255;
}
}
}
}
minimapCtx.putImageData(imgData, 0, 0);
// Игрок — белый пиксель по центру
minimapCtx.fillStyle = '#fff';
minimapCtx.fillRect(Math.floor(mW / 2) - 2, Math.floor(mH / 2) - 2, 4, 4);
// Другие игроки — жёлтые точки
for (const [sid, p] of state.otherPlayers) {
const dx = Math.floor(p.x / TILE) - startGX;
const dy = Math.floor(p.y / TILE) - startGY;
if (dx >= 0 && dx < viewW && dy >= 0 && dy < viewH) {
minimapCtx.fillStyle = '#f1c40f';
minimapCtx.fillRect(dx * scale - 1, dy * scale - 1, 3, 3);
}
}
// Мобы — красные (враждебные) / зелёные (животные)
const allMobsForMap = state.isMultiplayer ? Array.from(state.serverMobs.values()) : state.mobs;
for (const m of allMobsForMap) {
const dx = Math.floor(m.x / TILE) - startGX;
const dy = Math.floor(m.y / TILE) - startGY;
if (dx >= 0 && dx < viewW && dy >= 0 && dy < viewH) {
const hostile = m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton';
minimapCtx.fillStyle = hostile ? '#e74c3c' : '#2ecc71';
minimapCtx.fillRect(dx * scale, dy * scale, 2, 2);
}
}
}
// === ui/market.js ===
// ==================== РЫНОК ПРЕДЛОЖЕНИЙ ====================
let marketPanel, marketClose, marketContent;
function initMarket() {
marketPanel = document.getElementById('marketPanel');
marketClose = document.getElementById('marketClose');
marketContent = document.getElementById('marketContent');
if (!marketPanel) return;
if (marketClose) marketClose.addEventListener('click', () => closeMarket());
document.addEventListener('toggleMarket', () => toggleMarket());
}
function toggleMarket() {
if (!marketPanel) return;
if (marketPanel.style.display === 'none' || !marketPanel.style.display) {
openMarket();
} else {
closeMarket();
}
}
function closeMarket() {
if (marketPanel) marketPanel.style.display = 'none';
}
function openMarket() {
if (marketPanel) {
marketPanel.style.display = 'block';
renderMarket();
}
}
function renderMarket() {
if (!marketContent) return;
if (!state.socket) {
marketContent.innerHTML = 'Подключение к серверу...
';
return;
}
state.socket.emit('market_list', {}, (orders) => {
if (!orders || !orders.length) {
marketContent.innerHTML = 'Предложений пока нет
+ Выставить предмет
';
const btn = document.getElementById('createOrderBtn');
if (btn) btn.addEventListener('click', showCreateOrder);
return;
}
let html = '+ Выставить предмет
';
html += orders.map(o => '' + (o.offer_item||'') + ' x' + (o.offer_qty||1) + ' ⇄ ' + (o.want_item||'') + ' x' + (o.want_qty||1) + '
' + (o.seller || 'Аноним') + '
').join('');
marketContent.innerHTML = html;
const btn = document.getElementById('createOrderBtn');
if (btn) btn.addEventListener('click', showCreateOrder);
});
}
function showCreateOrder() {
if (!marketContent) return;
const invKeys = Object.keys(state.inv || {});
const invOpts = invKeys.filter(k => (state.inv[k]||0) > 0).map(k => ''+k+' (x'+state.inv[k]+') ').join('');
marketContent.innerHTML = '';
const cancelBtn = document.getElementById('cancelOrder');
if (cancelBtn) cancelBtn.addEventListener('click', () => renderMarket());
const submitBtn = document.getElementById('submitOrder');
if (submitBtn) submitBtn.addEventListener('click', () => {
const offerItem = document.getElementById('offerItem');
const wantItem = document.getElementById('wantItem');
if (!offerItem || !wantItem) return;
const offer = offerItem.value;
const want = wantItem.value.trim();
if (!offer || !want) { alert('Выберите предмет и укажите что хотите получить'); return; }
if (state.socket) {
state.socket.emit('market_create', { offer_item: offer, offer_qty: 1, want_item: want, want_qty: 1 });
playSound('click');
setTimeout(renderMarket, 500);
}
});
}
// === ui/settings.js ===
// Настройки
let settingsPanel = null;
let dayNightToggle = null;
// Weather button IDs in order
const WEATHER_BTNS = ['weatherClear', 'weatherRain', 'weatherSnow', 'weatherThunder'];
const WEATHER_ACTIVE_BG = '#3498db';
const WEATHER_INACTIVE_BG = '#555';
function setWeatherButtonActive(activeId) {
for (const id of WEATHER_BTNS) {
const btn = document.getElementById(id);
if (btn) {
btn.style.background = (id === activeId) ? WEATHER_ACTIVE_BG : WEATHER_INACTIVE_BG;
}
}
}
function initSettings() {
settingsPanel = document.getElementById('settingsPanel');
dayNightToggle = document.getElementById('dayNightToggle');
if (!settingsPanel) return;
const closeBtn = document.getElementById('settingsClose');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
settingsPanel.style.display = 'none';
});
}
// Day/night cycle toggle
if (dayNightToggle) {
dayNightToggle.checked = state.dayNightCycle !== false;
dayNightToggle.addEventListener('change', () => {
state.dayNightCycle = dayNightToggle.checked;
});
}
// Time-of-day slider
const timeSlider = document.getElementById('timeSlider');
if (timeSlider) {
timeSlider.value = Math.round(state.worldTime * 100);
timeSlider.addEventListener('input', () => {
state.worldTime = parseInt(timeSlider.value) / 100;
if (state.dayNightCycle) {
state.dayNightCycle = false;
if (dayNightToggle) dayNightToggle.checked = false;
}
});
}
// ====== Weather controls (Ясно / Дождь / Снег / Гроза) ======
const weatherIntensitySlider = document.getElementById('weatherIntensity');
// Helper: apply a weather type
function applyWeather(type) {
state.weatherType = type;
state.weatherChangeInterval = Infinity; // Lock weather manually
switch (type) {
case 'clear':
state.isRaining = false;
state.rainIntensity = 0;
if (weatherIntensitySlider) weatherIntensitySlider.value = 0;
break;
case 'rain':
state.isRaining = true;
state.rainIntensity = parseInt(weatherIntensitySlider?.value || 40) / 100;
break;
case 'snow':
state.isRaining = false; // snow uses its own rendering
state.rainIntensity = parseInt(weatherIntensitySlider?.value || 40) / 100;
break;
case 'thunder':
state.isRaining = true; // thunder includes rain
state.rainIntensity = parseInt(weatherIntensitySlider?.value || 60) / 100;
if (weatherIntensitySlider) weatherIntensitySlider.value = Math.round(state.rainIntensity * 100);
break;
}
setWeatherButtonActive('weather' + type.charAt(0).toUpperCase() + type.slice(1));
}
// Button handlers
for (const id of WEATHER_BTNS) {
const btn = document.getElementById(id);
if (btn) {
const weatherType = btn.dataset.weather || id.replace('weather', '').toLowerCase();
btn.addEventListener('click', () => applyWeather(weatherType));
}
}
// Intensity slider
if (weatherIntensitySlider) {
weatherIntensitySlider.addEventListener('input', () => {
const val = parseInt(weatherIntensitySlider.value) / 100;
if (state.weatherType !== 'clear') {
state.rainIntensity = val;
if (state.weatherType === 'thunder' || state.weatherType === 'rain') {
state.isRaining = true;
}
}
});
}
// Listen for toggleSettings custom event
document.addEventListener('toggleSettings', () => {
if (settingsPanel) {
// Sync all controls when panel opens
if (timeSlider) timeSlider.value = Math.round(state.worldTime * 100);
if (dayNightToggle) dayNightToggle.checked = state.dayNightCycle !== false;
if (weatherIntensitySlider) weatherIntensitySlider.value = Math.round(state.rainIntensity * 100);
// Highlight active weather button
const wt = state.weatherType || (state.isRaining ? 'rain' : 'clear');
const activeId = 'weather' + wt.charAt(0).toUpperCase() + wt.slice(1);
setWeatherButtonActive(activeId);
settingsPanel.style.display = settingsPanel.style.display === 'none' ? 'block' : 'none';
}
});
}
function isDayNightEnabled() {
return state.dayNightCycle !== false;
}
// === ui/respawn.js ===
// ==================== RESPAWN ====================
function initRespawn() {
document.getElementById('respawnBtn').onclick = async () => {
playSound('click'); // Звук клика по кнопке
console.log('=== RESPAWN CLICKED ===');
console.log('isMultiplayer:', state.isMultiplayer);
console.log('otherPlayers.size:', state.otherPlayers.size);
console.log('player.hp before respawn:', state.player.hp);
const player = state.player;
const deathEl = state.deathEl;
// В мультиплеере не загружаем сохранение, а возрождаемся в начальной точке
if (state.isMultiplayer && state.otherPlayers.size > 0) {
console.log('Мультиплеер режим - возрождение в начальной точке');
player.hp = 100;
player.hunger = 100;
player.o2 = 100;
player.vx = player.vy = 0;
player.invuln = 0;
player.x = state.spawnPoint.x;
player.y = state.spawnPoint.y;
player.fallStartY = player.y;
console.log('Возрождение в начальной точке, HP:', player.hp);
} else {
console.log('Одиночный режим - загружаем последнее сохранение');
// Одиночный режим - загружаем последнее сохранение
const loadedSave = await loadGame();
if (loadedSave) {
await applySave(loadedSave);
console.log('Загружено последнее сохранение после смерти, final HP:', player.hp);
} else {
// Если сохранения нет, возрождаемся в начальной точке
player.hp = 100;
player.hunger = 100;
player.o2 = 100;
player.vx = player.vy = 0;
player.invuln = 0;
player.x = state.spawnPoint.x;
player.y = state.spawnPoint.y;
player.fallStartY = player.y;
console.log('Возрождение в начальной точке, HP:', player.hp);
}
}
console.log('player.hp after respawn logic:', player.hp);
console.log('Hiding death screen...');
deathEl.style.display = 'none';
console.log('=== RESPAWN END ===');
};
}
// === input/controls3d.js ===
// ==================== УПРАВЛЕНИЕ 3D — клавиатура + мышь ====================
// Клавиши работают напрямую через state.inp.fwd / state.inp.side (аналоговые оси -1..1),
// поэтому движение на компе идёт по той же схеме, что и джойстик на телефоне.
// клавиши, которые не должны скроллить страницу/открывать меню
const SWALLOW = new Set([
'Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
'KeyW', 'KeyA', 'KeyS', 'KeyD',
]);
// истина, если игрок сейчас печатает в поле ввода
function typing() {
const el = document.activeElement;
if (!el) return false;
const t = el.tagName;
return t === 'INPUT' || t === 'TEXTAREA' || el.isContentEditable;
}
function applyKey(code, down) {
const inp = state.inp;
switch (code) {
// движение вперёд/назад и влево/вправо — по камере
case 'KeyW': case 'ArrowUp': inp.kfwd = down ? -1 : 0; break;
case 'KeyS': case 'ArrowDown': inp.kfwd = down ? 1 : 0; break;
case 'KeyA': case 'ArrowLeft': inp.kside = down ? -1 : 0; break;
case 'KeyD': case 'ArrowRight': inp.kside = down ? 1 : 0; break;
// прыжок
case 'Space': case 'KeyZ': inp.j = down; break;
// спуск по лестнице / вниз
case 'ShiftLeft': case 'KeyC': inp.s = down; break;
default: return false;
}
return true;
}
function initControls() {
window.addEventListener('keydown', (e) => {
if (typing()) return;
if (e.repeat) return;
if (SWALLOW.has(e.code)) e.preventDefault();
applyKey(e.code, true);
});
window.addEventListener('keyup', (e) => {
if (typing()) return;
applyKey(e.code, false);
});
// если окно потеряло фокус — сбрасываем всё, иначе персонаж «убегает» сам
window.addEventListener('blur', () => {
const inp = state.inp;
inp.kfwd = 0; inp.kside = 0; inp.j = false; inp.s = false;
});
// на всякий случай дублируем на document (фокус может быть на канвасе)
document.addEventListener('keydown', (e) => {
if (typing()) return;
if (e.repeat) return;
applyKey(e.code, true);
});
document.addEventListener('keyup', (e) => {
if (typing()) return;
applyKey(e.code, false);
});
}
// === input/joystick3d.js ===
// ==================== ДЖОЙСТИК 3D — GrechkaCraft v3 ====================
// Аналоговый джойстик: движение во все стороны по горизонтали (X и Z),
// камера вращается вторым пальцем по экрану. Кнопки прыжка и копки рядом.
let joyEl = null, knobEl = null, jumpEl = null, mineEl = null;
let joyId = null; // активный pointer на джойстике
let jcx = 0, jcy = 0, jrad = 56; // центр джойстика и рабочий радиус в px
let touchSeated = false; // джойстик сейчас показан
let hideT = 0; // таймер скрытия
function setKnob(dx, dy) {
if (!knobEl) return;
knobEl.style.transform = `translate(${dx}px, ${dy}px)`;
}
function resetAxes() {
state.inp.ax = 0;
state.inp.ay = 0;
setKnob(0, 0);
if (joyEl) joyEl.classList.remove('act');
}
function initJoystick() {
joyEl = document.getElementById('v2joy');
knobEl = joyEl ? joyEl.querySelector('.knob') : null;
jumpEl = document.getElementById('v2jump');
mineEl = document.getElementById('v2mine');
if (!joyEl) return;
const rectCenter = () => {
const r = joyEl.getBoundingClientRect();
jcx = r.left + r.width / 2;
jcy = r.top + r.height / 2;
jrad = r.width / 2 - 12;
};
joyEl.addEventListener('pointerdown', (e) => {
e.preventDefault();
rectCenter();
joyId = e.pointerId;
joyEl.setPointerCapture(joyId);
joyEl.classList.add('act');
update(e);
});
joyEl.addEventListener('pointermove', (e) => {
if (e.pointerId !== joyId) return;
e.preventDefault();
update(e);
});
const end = (e) => {
if (e.pointerId !== joyId) return;
joyId = null;
resetAxes();
};
joyEl.addEventListener('pointerup', end);
joyEl.addEventListener('pointercancel', end);
function update(e) {
let dx = e.clientX - jcx;
let dy = e.clientY - jcy;
const len = Math.hypot(dx, dy);
if (len > jrad) { // не даём уйти за круг
dx = (dx / len) * jrad;
dy = (dy / len) * jrad;
}
setKnob(dx, dy);
// мёртвая зона — чтобы лёгкое касание не дёргало персонажа
const nx = dx / jrad, ny = dy / jrad;
const mag = Math.hypot(nx, ny);
const DEAD = 0.18;
if (mag < DEAD) {
state.inp.ax = 0;
state.inp.ay = 0;
} else {
// нормализуем так, чтобы край джойстика давал полную скорость
const k = (mag - DEAD) / (1 - DEAD) / mag;
state.inp.ax = nx * k;
state.inp.ay = ny * k;
}
}
// прыжок
if (jumpEl) {
jumpEl.addEventListener('pointerdown', (e) => {
e.preventDefault();
state.inp.j = true;
});
const up = (e) => { e.preventDefault(); state.inp.j = false; };
jumpEl.addEventListener('pointerup', up);
jumpEl.addEventListener('pointercancel', up);
jumpEl.addEventListener('pointerleave', up);
}
// копка: добыча/установка — удержание
if (mineEl) {
mineEl.addEventListener('pointerdown', (e) => {
e.preventDefault();
state.inp.mineHeld = true;
window.dispatchEvent(new CustomEvent('v2-mine', { detail: { down: true } }));
});
const up = (e) => {
e.preventDefault();
state.inp.mineHeld = false;
window.dispatchEvent(new CustomEvent('v2-mine', { detail: { down: false } }));
};
mineEl.addEventListener('pointerup', up);
mineEl.addEventListener('pointercancel', up);
mineEl.addEventListener('pointerleave', up);
}
// держим джойстик в нуле, если палец ушёл за экран
window.addEventListener('pointerup', (e) => {
if (joyId !== null && e.pointerId === joyId) { joyId = null; resetAxes(); }
});
window.addEventListener('blur', resetAxes);
// ---------- джойстик: только если палец в левой нижней четверти экрана ----------
// Показываем при касании, прячем через 0.6 с после отпускания — чтобы
// не перекрывать хотбар с избранными блоками.
const ZONE = () => ({ w: window.innerWidth * 0.5, h: window.innerHeight * 0.5 });
const inZone = (x, y) => {
const z = ZONE();
return x < z.w && y > window.innerHeight - z.h;
};
const wake = () => {
clearTimeout(hideT);
// Кнопки прыжка и копки видны ВСЕГДА на тач-устройствах: без прыжка
// и добычи играть невозможно, и требование контекстного показа
// касалось только джойстика.
[jumpEl, mineEl].forEach(e => e && e.classList.add('on'));
if (touchSeated) return;
touchSeated = true;
if (joyEl) joyEl.classList.add('on');
};
const sleepSoon = () => {
clearTimeout(hideT);
hideT = setTimeout(() => {
if (joyId === null) {
touchSeated = false;
// Прячем ТОЛЬКО джойстик. Раньше класс 'on' снимался со всех
// элементов или не снимался вовсе: в первом случае исчезали
// кнопки прыжка и копки, во втором джойстик висел поверх
// хотбара всё время.
if (joyEl) joyEl.classList.remove('on');
[jumpEl, mineEl].forEach(e => e && e.classList.add('on'));
}
}, 600);
};
// Первый контакт с зоной — сажаем джойстик под палец
window.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'mouse') return;
if (!inZone(e.clientX, e.clientY)) return;
wake();
if (joyId !== null) return;
// если палец попал в кнопки — не мешаем им
// contains() бросает исключение, если e.target — не Node (например,
// window при синтетическом событии). Проверяем instanceof.
const isNode = e.target && typeof e.target === 'object' && 'nodeType' in e.target;
if (isNode && ((jumpEl && jumpEl.contains(e.target)) || (mineEl && mineEl.contains(e.target)))) return;
// ищем ближайшую точку джойстика к пальцу
const r = joyEl.getBoundingClientRect();
const cxr = r.left + r.width / 2, cyr = r.top + r.height / 2;
const far = Math.hypot(e.clientX - cxr, e.clientY - cyr) > r.width * 0.75;
if (far) { jcx = e.clientX; jcy = e.clientY; }
// Запоминаем палец СРАЗУ: без этого joyId оставался null, update()
// не считал смещение, и inp.ax/inp.ay всегда были 0 — джойстик
// показывался, но игрок не двигался. Обработчик самого джойстика
// тоже ставит joyEl.classList.add('act').
if (joyId === null) {
joyId = e.pointerId;
joyEl.classList.add('act');
update(e);
}
}, { passive: true, capture: true });
window.addEventListener('pointerup', sleepSoon, { passive: true });
window.addEventListener('pointercancel', sleepSoon, { passive: true });
window.addEventListener('blur', () => { touchSeated = false; });
// десктоп: всё скрыто, клавиатура
if (window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
[joyEl, jumpEl, mineEl].forEach(e => e && e.classList.remove('on'));
}
// Автопоказ на старте УБРАН: джойстик должен появляться только по
// касанию левой нижней зоны, а не висеть поверх хотбара сразу.
sleepSoon();
}
// === multiplayer/voice-chat.js ===
// Голосовой чат
let voiceSocket = null;
let voiceStream = null;
let audioCtx = null;
let voiceProcessor = null;
let voiceActive = false;
// Кнопка микрофона
const voiceBtn = document.createElement('div');
voiceBtn.innerHTML = '🎤/ ';
voiceBtn.title = 'Голосовой чат (выкл)';
voiceBtn.style.cssText = 'position:absolute;top:10px;right:130px;width:52px;height:52px;border-radius:12px;background:#555;z-index:200;display:flex;align-items:center;justify-content:center;font-size:24px;cursor:pointer;border:2px solid rgba(255,255,255,0.9);box-shadow:0 4px 0 rgba(0,0,0,0.5);pointer-events:auto;';
document.querySelector('.ui').appendChild(voiceBtn);
// Индикатор говорящего
const speakingIndicator = document.createElement('div');
speakingIndicator.style.cssText = 'position:absolute;top:130px;right:10px;z-index:200;display:none;background:rgba(0,0,0,0.7);color:#2ecc71;padding:4px 8px;border-radius:6px;font-size:12px;';
speakingIndicator.textContent = '🔊';
document.querySelector('.ui').appendChild(speakingIndicator);
let speakingTimeout = null;
voiceBtn.onclick = async () => {
if (voiceActive) {
// Выключить
voiceActive = false;
voiceBtn.innerHTML = '🎤/ ';
voiceBtn.style.background = '#555';
if (voiceStream) {
voiceStream.getTracks().forEach(t => t.stop());
voiceStream = null;
}
if (voiceProcessor) { voiceProcessor.disconnect(); voiceProcessor = null; }
if (audioCtx) { audioCtx.close(); audioCtx = null; }
if (voiceSocket) { voiceSocket.disconnect(); voiceSocket = null; }
return;
}
// Включить
try {
voiceStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 24000 } });
audioCtx = new AudioContext({ sampleRate: 24000 });
const source = audioCtx.createMediaStreamSource(voiceStream);
voiceProcessor = audioCtx.createScriptProcessor(4096, 1, 1);
voiceProcessor.onaudioprocess = (e) => {
if (!voiceActive || !voiceSocket || !voiceSocket.connected) return;
const pcm = e.inputBuffer.getChannelData(0);
// Конвертируем float32 → int16 для экономии трафика
const int16 = new Int16Array(pcm.length);
for (let i = 0; i < pcm.length; i++) {
const s = Math.max(-1, Math.min(1, pcm[i]));
int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
voiceSocket.emit('voice_data', int16.buffer);
};
source.connect(voiceProcessor);
voiceProcessor.connect(audioCtx.createGain()); // не в destination — чтобы не слышать себя
// Подключаемся к голосовому серверу
voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] });
voiceSocket.on('connect', () => {
voiceSocket.emit('voice_join', { world_id: state.worldId, x: state.player.x, y: state.player.y, name: state.playerName || 'Игрок' });
});
voiceSocket.on('voice_in', (payload) => {
// Воспроизводим входящий голос
const { data, meta, volume: rawVolume } = payload;
const volume = rawVolume !== undefined ? rawVolume : 1.0;
if (!audioCtx || audioCtx.state === 'closed') return;
// Int16 → Float32 (volume applied via gain node, not in conversion)
const int16 = new Int16Array(data);
const float32 = new Float32Array(int16.length);
for (let i = 0; i < int16.length; i++) {
float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF);
}
const buf = audioCtx.createBuffer(1, float32.length, 24000);
buf.getChannelData(0).set(float32);
const src = audioCtx.createBufferSource();
src.buffer = buf;
const gain = audioCtx.createGain();
gain.gain.value = volume;
src.connect(gain).connect(audioCtx.destination);
src.start();
// Индикатор
speakingIndicator.style.display = 'block';
speakingIndicator.textContent = `🔊 ${meta.name}`;
clearTimeout(speakingTimeout);
speakingTimeout = setTimeout(() => { speakingIndicator.style.display = 'none'; }, 500);
});
voiceActive = true;
voiceBtn.textContent = '🎤';
voiceBtn.style.background = '#2ecc71';
} catch (e) {
console.error('Voice error:', e);
voiceBtn.style.background = '#e74c3c';
}
};
function initVoice() {
// Voice position update hook — should be called from main loop
let voicePosT = 0;
return {
update(dt) {
voicePosT += dt;
if (voicePosT > 0.5 && voiceSocket && voiceSocket.connected) {
voicePosT = 0;
voiceSocket.emit('voice_pos', { x: state.player.x, y: state.player.y });
}
}
};
}
// === ui/craft.js ===
// Craft UI
function canCraft(r) {
const inv = state.inv;
for (const res in r.cost) {
if ((inv[res] || 0) < r.cost[res]) return false;
}
return true;
}
function renderCraft() {
const inv = state.inv;
const recipesEl = state.recipesEl;
recipesEl.innerHTML = '';
for (const r of RECIPES) {
const row = document.createElement('div');
row.className = 'recipe';
const icon = document.createElement('div');
icon.className = 'ricon';
// Иконка результата — canvas sprite > emoji
if (tex[r.out]) {
icon.style.backgroundImage = `url(${tex[r.out].toDataURL()})`;
} else if (itemTex[r.out]) {
icon.style.backgroundImage = `url(${itemTex[r.out].toDataURL()})`;
} else if (TOOLS[r.out]) {
icon.textContent = TOOLS[r.out].icon || '🔧';
icon.style.fontSize = '24px';
icon.style.display = 'flex';
icon.style.alignItems = 'center';
icon.style.justifyContent = 'center';
} else if (ITEMS[r.out]) {
icon.textContent = ITEMS[r.out].icon || '📦';
icon.style.fontSize = '24px';
icon.style.display = 'flex';
icon.style.alignItems = 'center';
icon.style.justifyContent = 'center';
}
const info = document.createElement('div');
info.className = 'rinfo';
const nm = document.createElement('div');
nm.className = 'rname';
const itemName = BLOCKS[r.out]?.n || TOOLS[r.out]?.n || ITEMS[r.out]?.n || r.out;
nm.textContent = `${itemName} x${r.qty}`;
const cs = document.createElement('div');
cs.className = 'rcost';
cs.textContent = Object.keys(r.cost).map(x => {
const cn = BLOCKS[x]?.n || TOOLS[x]?.n || ITEMS[x]?.n || x;
return `${cn}: ${(inv[x] || 0)}/${r.cost[x]}`;
}).join(' ');
info.appendChild(nm); info.appendChild(cs);
const btn = document.createElement('button');
btn.className = 'rcraft';
btn.textContent = 'Создать';
btn.disabled = !canCraft(r);
btn.onclick = () => {
if (!canCraft(r)) return;
playSound('click');
for (const res in r.cost) inv[res] -= r.cost[res];
inv[r.out] = (inv[r.out] || 0) + r.qty;
if (TOOLS[r.out]) addTool(r.out);
if (!state.recentItems.includes(r.out)) { state.recentItems.unshift(r.out); state.recentItems = state.recentItems.slice(0, 5); }
rebuildHotbar();
renderCraft();
renderInventory();
};
row.appendChild(icon); row.appendChild(info); row.appendChild(btn);
recipesEl.appendChild(row);
}
}
let craftOpen = false;
let inventoryOpen = false;
function toggleCraft() {
playSound('click');
craftOpen = !craftOpen;
state.craftPanel.style.display = craftOpen ? 'block' : 'none';
if (craftOpen) {
renderCraft();
inventoryOpen = false;
state.inventoryPanel.style.display = 'none';
}
}
function closeCraft() {
playSound('click');
craftOpen = false;
state.craftPanel.style.display = 'none';
}
function toggleInventory() {
playSound('click');
inventoryOpen = true;
state.inventoryPanel.style.display = 'block';
renderInventory();
craftOpen = false;
state.craftPanel.style.display = 'none';
}
function closeInventory() {
playSound('click');
inventoryOpen = false;
state.inventoryPanel.style.display = 'none';
}
// === main3d.js ===
// ==================== ENTRY POINT ====================
dismissBootScreen();
// ==================== 1) CONFIG — parse URL ====================
// urlParams defined in config.js
state.SERVER_URL = SERVER_URL;
state.TELEGRAM_BOT_USERNAME = 'Grechkacraft_bot';
state.TELEGRAM_APP_SHORT_NAME = 'minegrechka';
// Set TG-related state
if (isTgWebApp) {
state.myTgId = tgUser?.id || null;
}
state.heroImg = new Image(); state.heroImg.src = 'https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png';
// Диаг-доступ только по ?diag=1 — в обычной игре не включается
if (/[?&]diag=1/.test(location.search)) window.__st = state;
// ==================== 2) START MENU ====================
// Show the start menu before initializing the game.
// The menu returns a Promise that resolves with the worldId.
// ==================== ЭКРАН ЗАГРУЗКИ МИРА ====================
// Мир 96x96x45 ≈ 420k блоков. Он строится порциями в игровом цикле, пока
// пользователь смотрит на прогресс. Раньше экран ждал молча, и вход выглядел
// как зависший.
function showWorldLoader() {
let el = document.getElementById('v3load');
if (!el) {
el = document.createElement('div');
el.id = 'v3load';
el.style.cssText = [
'position:fixed', 'inset:0', 'z-index:2147483000',
'display:flex', 'flex-direction:column',
'align-items:center', 'justify-content:center',
'background:linear-gradient(180deg,#0d1b3e 0%,#1b3a6b 42%,#3f6fa8 68%,#8fc7f5 100%)',
'font:600 15px/1.4 system-ui,-apple-system,Segoe UI,sans-serif',
'color:#eaf3ff', 'text-align:center', 'padding:24px',
'overflow:hidden'
].join(';');
// Фон: солнце, дымящиеся холмы, бегущие облака. Всё на CSS — грузится
// мгновенно и не ждёт ассетов, поэтому экран появляется без белой вспышки.
el.innerHTML =
'' +
// солнце
'
' +
// облака
'
' +
'
' +
// холмы
'
' +
'
' +
// гречка — колос: стебель + 6 зёрен вокруг него. Раньше зёрна строились
// отрицательным margin от пустого блока и висели над землёй точками.
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'' +
'' +
'' +
'
Гречка Craft 3D
' +
'
Подготовка…
' +
'
' +
'
0%
' +
'
' +
'Строим мир: рельеф, биомы, деревья, руды и гречка
' +
'
';
document.body.appendChild(el);
}
el.style.display = 'flex';
return el;
}
function hideWorldLoader() {
const el = document.getElementById('v3load');
if (el) el.style.display = 'none';
}
// Статичная заставка из index-v2.html живёт, пока грузится JS-бандл. Гасим её
// сразу, как только скрипт стартует: дальше свою работу делает экран генерации
// мира с реальным прогрессом.
function dismissBootScreen() {
const b = document.getElementById('v3boot');
if (!b) return;
if (typeof window.__v3bootDone === 'function') window.__v3bootDone();
b.style.transition = 'opacity .3s ease';
b.style.opacity = '0';
setTimeout(() => { b.style.display = 'none'; }, 320);
}
// Ждём готовности мира, обновляя полосу прогресса.
function waitWorldReady(maxMs = 180000) {
const el = showWorldLoader();
const fill = document.getElementById('v3loadfill');
const txt = document.getElementById('v3loadtxt');
const stage = document.getElementById('v3loadstage');
const hint = document.getElementById('v3loadhint');
// Этапы по доле готовности. Один текст «Готовим мир…» не объяснял, что
// происходит, и загрузка выглядела как зависание.
const STAGES = [
[0.02, 'Расстилаем рельеф', 'Режем холмы и долины'],
[0.18, 'Раздаём биомы', 'Луга, лес, песок и снег'],
[0.42, 'Заросли и деревья', 'Сажаем лес и кусты'],
[0.66, 'Закладываем руды', 'Уголь, железо, золото'],
[0.84, 'Сеем гречку', 'Главный урожай'],
[0.97, 'Ставим игрока', 'Ищем сухой берег']
];
return new Promise(resolve => {
const t0 = performance.now();
const tick = () => {
const pr = isWorldReady() ? 1 : worldGenProgress();
const pct = Math.round(pr * 100);
if (fill) fill.style.width = pct + '%';
if (txt) txt.textContent = pct + '%';
if (stage) {
let cur = STAGES[0];
for (const st of STAGES) if (pr >= st[0]) cur = st;
if (stage.textContent !== cur[1]) stage.textContent = cur[1];
if (hint) hint.textContent = cur[2];
}
if (isWorldReady() || performance.now() - t0 > maxMs) {
const ok = isWorldReady();
if (fill) fill.style.width = '100%';
if (txt) txt.textContent = '100%';
if (stage) stage.textContent = ok ? 'Мир готов!' : 'Погружаем…';
if (hint) hint.textContent = ok ? 'Приятной игры' : 'Мир не успел — заходим как есть';
// Короткая пауза, чтобы «Мир готов!» успел прочитать, и плавный уход.
setTimeout(() => {
if (el) el.style.transition = 'opacity .35s ease';
if (el) el.style.opacity = '0';
setTimeout(hideWorldLoader, 360);
resolve(isWorldReady());
}, ok ? 420 : 120);
return;
}
requestAnimationFrame(tick);
};
tick();
});
}
// Гарантируем, что игрок стоит на твёрдом блоке. Спавн вычисляется ДО генерации
// мира, поэтому опора под ногами часто не успевает построиться — игрок падал
// до bedrock (Y=132) и висел в пустоте. После готовности мира проверяем и
// ставим на верхний твёрдый блок своей колонки.
// ---------- СПАВН: findStandSpot ----------
// Критерии пригодного места (все обязательны):
// 1. сверху колонки есть твёрдый блок — на него ставим ноги;
// 2. над ним 2 свободных тайла — игрок и запас на прыжок;
// 3. нет воды в радиусе 3 тайла и выше поверхности;
// 4. в тайле над головой (top-1) нет твёрдого блока — иначе игрок
// окажется в пещере под блоком;
// 5. блок под опорой тоже твёрдый (не «парящая» трава над пещерой).
// Поиск идёт по расширяющейся спирали, а не по одной колонке: раньше
// игрока ставило в воду или в толщу снега, потому что подходил только
// собственный тайл.
async function startGame() {
const worldId = await showStartMenu();
// Set worldId and playerName
state.worldId = worldId;
if (!state.playerName) {
state.playerName = localStorage.getItem('minegrechka_playerName') || 'Игрок';
localStorage.setItem('minegrechka_playerName', state.playerName);
}
console.log('[Launch] worldId:', state.worldId, 'player:', state.playerName, 'tg:', isTgWebApp);
// Validate TG initData if in TG context
if (isTgWebApp && window.Telegram && Telegram.WebApp.initData) {
try {
const resp = await fetch(state.SERVER_URL + '/api/tg/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ initData: Telegram.WebApp.initData })
});
const data = await resp.json();
if (data.ok && data.tg_id) {
console.log('[TG] Authenticated, tg_id:', data.tg_id);
state.myTgId = data.tg_id;
}
} catch (e) { console.warn('[TG] Auth failed:', e); }
}
// Update URL with world code
try {
const newUrl = new URL(window.location.href);
newUrl.searchParams.set('world', state.worldId);
if (typeof history !== 'undefined' && history.replaceState) {
history.replaceState(null, '', newUrl.toString());
}
} catch (e) {}
// ==================== 3) CANVAS — init from canvas module ====================
initCanvas();
// ==================== 4) STATE — set DOM refs ====================
state.hpEl = document.getElementById('hp');
state.foodEl = document.getElementById('food');
state.sxEl = document.getElementById('sx');
state.syEl = document.getElementById('sy');
state.todEl = document.getElementById('tod');
state.worldIdEl = document.getElementById('worldId');
state.playerCountEl = document.getElementById('playerCount');
state.deathEl = document.getElementById('death');
state.hotbarEl = document.getElementById('hotbar');
state.craftPanel = document.getElementById('craftPanel');
state.recipesEl = document.getElementById('recipes');
state.inventoryPanel = document.getElementById('inventoryPanel');
state.inventoryGrid = document.getElementById('inventoryGrid');
state.marketPanel = document.getElementById('marketPanel');
state.marketContent = document.getElementById('marketContent');
state.tradePanel = document.getElementById('tradePanel');
state.tradeContent = document.getElementById('tradeContent');
state.multiplayerStatus = document.getElementById('multiplayerStatus');
// ==================== 5) TEXTURES — initTextures() ====================
initTextures();
state.tex = tex;
// ==================== 6) CRAFT/INVENTORY BUTTONS ====================
const craftBtn = document.getElementById('craftBtn');
const invToggle = document.getElementById('invToggle');
const craftClose = document.getElementById('craftClose');
const inventoryClose = document.getElementById('inventoryClose');
if (craftBtn) craftBtn.onclick = () => toggleCraft();
if (craftClose) craftClose.onclick = () => closeCraft();
if (invToggle) invToggle.onclick = () => toggleInventory();
if (inventoryClose) inventoryClose.onclick = () => closeInventory();
// ==================== 7) CONTROLS — initControls ====================
initControls();
initJoystick();
// ==================== 9) UI MODULES — init all UI ====================
initChat();
initFurnace();
initMinimap();
initSaveControls();
initMarket();
initRespawn();
initShare();
initSettings();
initModes();
// ==================== 10) SOCKET — initSocket ====================
initSocket();
// ==================== 11) VOICE — initVoice ====================
initVoice();
// ==================== 12) SAVE — loadGame, applySave ====================
// Ensure hotbar has items on first load
if (state.recentItems.length === 0) {
state.recentItems = Object.keys(state.inv).filter(k => state.inv[k] > 0).slice(0, 5);
}
// Make sure selected item is valid and in recentItems
if (!state.selected || !state.inv[state.selected] || state.inv[state.selected] <= 0) {
state.selected = state.recentItems[0] || 'dirt';
}
if (!state.recentItems.includes(state.selected)) {
state.recentItems.unshift(state.selected);
state.recentItems = state.recentItems.slice(0, 5);
}
rebuildHotbar();
// Keyboard hotbar switching (1/2/3)
document.addEventListener('keydown', (e) => {
if (state.showFullInventory || state.craftOpen || state.inventoryOpen || state.chatOpen) return;
if (e.key >= '1' && e.key <= '3') {
const items = state.recentItems.filter(id => state.inv[id] > 0).slice(0, 3);
const idx = parseInt(e.key) - 1;
if (items[idx]) {
state.selected = items[idx];
rebuildHotbar();
}
}
});
// Scroll wheel hotbar cycling
document.addEventListener('wheel', (e) => {
if (state.showFullInventory || state.craftOpen || state.inventoryOpen || state.chatOpen) return;
const items = state.recentItems.filter(id => state.inv[id] > 0).slice(0, 3);
if (items.length === 0) return;
let idx = items.indexOf(state.selected);
if (idx < 0) idx = 0;
if (e.deltaY > 0) idx = (idx + 1) % items.length;
else idx = (idx - 1 + items.length) % items.length;
state.selected = items[idx];
rebuildHotbar();
e.preventDefault();
}, { passive: false });
initDB().then(async () => {
const loadedSave = await loadGame();
if (loadedSave) {
await applySave(loadedSave);
console.log('Загружено сохранение, HP:', state.player.hp);
if (state.player.hp <= 0) {
console.log('WARNING: HP <= 0 после загрузки, возрождаемся');
state.player.hp = 100;
state.player.hunger = 100;
state.player.o2 = 100;
state.player.x = state.spawnPoint.x;
// spawnPoint.y копируется из player.y (см. save.js:130), то есть это
// ВЕРХ коллайдера — вычитать высоту нельзя.
state.player.y = state.spawnPoint.y;
state.player.vx = state.player.vy = 0;
state.player.invuln = 0;
state.player.fallStartY = state.player.y;
}
} else {
console.log('Сохранение не найдено, начинаем новую игру');
// Fill recentItems from inventory
state.recentItems = Object.keys(state.inv).filter(k => state.inv[k] > 0).slice(0, 5);
rebuildHotbar();
state.player.hp = 100;
state.player.hunger = 100;
state.player.o2 = 100;
state.player.vx = state.player.vy = 0;
state.player.invuln = 0;
// Старт — сухая суша в центре мира. Перебираем ближайшие колонки и берём
// первую без воды/болота, чтобы не начинать в трясине.
let startGX = 6, startGZ = 0, startGY = 0, found = false;
const PREF_BIOMES = ['plains', 'desert']; // сухая, безопасная земля
outer:
for (let r = 0; r <= 60 && !found; r++) {
for (let dz = -r; dz <= r; dz++) {
for (let dx = -r; dx <= r; dx++) {
if (Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue; // только кольцо
const gx = 6 + dx, gz = dz;
if (Math.abs(gz) > 40) continue; // раньше 18 — при WORLD_DEPTH=48 искал в узкой полосе и не находил суши
const b = getCachedBiome(gx, gz);
if (!PREF_BIOMES.includes(b)) continue;
// Условие «sy > SEA_GY + 3» отбрасывало ВСЕ колонки: в этом мире поверхность
// лежит на gy 8..14, SEA_GY = 14, то есть требование sy > 17 не
// выполнялось НИГДЕ — игрока всегда выносило в fallback, и тот
// ставил его в болото. Теперь проверяем настоящую колонку.
genColumn(gx, gz);
const pSolid = (px, py, pz) => {
const bb = getBlock(px, py, pz);
if (!bb) return false;
const dd = BLOCKS[bb.t];
return !!(dd && dd.solid && !dd.fluid && !dd.decor && !dd.climbable);
};
const pFluid = (px, py, pz) => {
const bb = getBlock(px, py, pz);
return !!(bb && BLOCKS[bb.t] && BLOCKS[bb.t].fluid);
};
let ptop = -1;
for (let gy = Math.min(state.BEDROCK_GY + 2, 220); gy >= 1; gy--) {
if (pSolid(gx, gy, gz)) { ptop = gy; break; }
}
if (ptop < 1) continue; // нет опоры
if (pFluid(gx, ptop - 1, gz)) continue; // лужа под ногами
let near = false;
for (let dx = -3; dx <= 3 && !near; dx++)
for (let dz = -3; dz <= 3 && !near; dz++)
for (let gy = Math.min(state.SEA_GY, 220); gy > ptop; gy--)
if (pFluid(gx + dx, gy, gz + dz)) { near = true; break; }
if (near) continue; // вокруг вода
const sy = ptop;
startGX = gx; startGZ = gz; startGY = sy; found = true; // sy = блок-опора
break outer;
}
}
}
if (!found) {
// Суши по биому не нашлось — ищем любую колонку выше уровня моря.
let bestGY = -1, bestX = 6, bestZ = 0;
for (let r = 0; r <= 40; r++) {
for (let dz = -r; dz <= r; dz++) {
for (let dx = -r; dx <= r; dx++) {
if (Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue;
const gx = 6 + dx, gz = dz;
if (Math.abs(gz) > 40) continue;
// Тот же дефект, что и в основной ветке: SEA_GY + 2 = 16,
// а поверхность в мире 8..14, поэтому условие не выполнялось
// НИГДЕ и fallback тоже ничего не находил.
const sy = surfaceGyAt(gx, gz);
if (sy < state.SEA_GY - 4 && sy > bestGY) { bestGY = sy; bestX = gx; bestZ = gz; }
}
}
if (bestGY > 0) break;
}
if (bestGY > 0) { startGX = bestX; startGZ = bestZ; startGY = bestGY; found = true; }
}
if (!found) { startGX = 6; startGZ = 0; startGY = surfaceGyAt(6, 0) + 2; }
state.player.x = startGX * state.TILE;
state.player.z = startGZ * state.TILE;
state.player.vx = state.player.vy = state.player.vz = 0;
// Предгенерация вокруг спавна. Радиус по X был 4 тайла, по Z 20 — при
// этом по Z уже генерировалось до 24, а по X мир вырождался в полосу
// шириной 9 тайлов (замер: world x от 2 до 10 при игроке на 6).
for (let dx = -48; dx <= 48; dx++) {
for (let dz = -48; dz <= 48; dz++) {
if (dx * dx + dz * dz > 48 * 48) continue;
genColumn(startGX + dx, startGZ + dz);
}
}
// после генерации ищем сухую опору: идём вверх, пока под ноздрями вода/болото
// Ищем тайл ТВЁРДОГО блока-опоры: самый верхний твёрдый блок колонки,
// над которым не вода. Раньше startGY был на единицу ниже, и игрок
// оказывался ВНУТРИ блока (утопание) или в небе над ним.
{
let topGY = -1;
for (let gy = 1; gy < 200; gy++) {
const bl = getBlock(startGX, gy, startGZ);
if (!bl || bl.dead) break;
const def = BLOCKS[bl.t];
if (!def || !def.solid || def.fluid || def.decor || def.climbable) break;
topGY = gy;
}
if (topGY > 0) startGY = topGY;
else startGY = Math.max(surfaceGyAt(startGX, startGZ), state.SEA_GY);
}
// ВАЖНО: player.y — ВЕРХ коллайдера, ноги = y + h.
// Стоять на блоке startGY => y = startGY * TILE - h.
// зазор 0.5px, иначе первый шаг вверх гасит импульс прыжка
// Ось Y направлена ВНИЗ. Верхняя грань блока startGY = startGY*TILE,
// ноги = y + h => y = startGY * TILE - h.
// Опора под ногами ОБЯЗАТЕЛЬНА. Если блока нет — ставим его, иначе игрок
// проваливается до bedrock (проверено: Y уходил в 132 при BEDROCK_GY=140).
{
const footBlk = getBlock(startGX, startGY, startGZ);
const footDef = footBlk ? BLOCKS[footBlk.t] : null;
const solid = !!(footDef && footDef.solid && !footDef.fluid && !footDef.decor);
if (!solid) {
setBlock(startGX, startGY, startGZ, 'grass', true);
} else if (startGY > 1) {
// проверяем, что это и правда верхняя точка опоры
const above = getBlock(startGX, startGY - 1, startGZ);
if (above) {
const ad = BLOCKS[above.t];
if (!ad || !ad.solid || ad.fluid || ad.decor) removeBlock(startGX, startGY - 1, startGZ);
}
}
}
// Ось Y вниз: верхняя грань блока startGY = startGY*TILE, значит
// feet = startGY*TILE и p.y = startGY*TILE - h.
state.player.y = startGY * state.TILE - state.player.h;
state.player.fallStartY = state.player.y;
state.player.grounded = true;
state.spawnPoint.x = state.player.x;
state.spawnPoint.y = state.player.y;
state.spawnPoint.z = state.player.z;
console.log('Новая 3D игра: gx=', startGX, 'gz=', startGZ, 'biome=', getCachedBiome(startGX, startGZ), 'gy=', startGY, 'y=', state.player.y);
}
// Автосейв при скрытии страницы
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
saveGame();
}
});
// Автосейв перед закрытием страницы
window.addEventListener('beforeunload', () => {
saveGame();
});
}).catch(err => {
console.error('Ошибка инициализации:', err);
// Аварийный старт: только колонка игрока, остальное достроит pumpWorldGen
// из игрового цикла. Раньше здесь генерировалась узкая полоса 9x40, из-за
// чего мир выглядел островом.
const startGX = 6, startGZ = 0;
genColumn(startGX, startGZ);
state.player.y = surfaceGyAt(startGX, startGZ) * state.TILE - state.player.h;
state.player.fallStartY = state.player.y;
});
// ==================== 13) LOOP — startLoop ====================
// Мир строится порциями в цикле, поэтому сначала запускаем цикл, затем
// ждём готовности с прогрессом на экране.
requestAnimationFrame(loop);
// Ставим игрока на поверхность ДО генерации мира, чтобы он не начал
// падать в пустоту, и повторяем после готовности.
ensurePlayerOnGround();
await waitWorldReady();
ensurePlayerOnGround();
// После фикса игрок может стоять в блоке, если гравитация успела осесть.
// Даём физике несколько кадров и закрепляем ещё раз.
await new Promise(r => setTimeout(r, 120));
ensurePlayerOnGround();
}
// Start the game (v3 — 3D)
startGame().catch(err => {
console.error('Fatal error starting game:', err);
});
// Debug: give items
window.debugGive = function(id, count) {
state.inv[id] = (state.inv[id] || 0) + (count || 1);
if (!state.recentItems.includes(id)) { state.recentItems.unshift(id); state.recentItems = state.recentItems.slice(0, 5); }
rebuildHotbar();
// Can't call renderInventory from here, but user can click inv
console.log('Gave', count || 1, id, '=>', state.inv[id]);
console.log('inv keys>0:', Object.keys(state.inv).filter(k => state.inv[k] > 0).join(', '));
};
window.debugInv = function() { return Object.keys(state.inv).filter(k => state.inv[k] > 0).map(k => k + ':' + state.inv[k]); };
})();