6999 lines
242 KiB
JavaScript
6999 lines
242 KiB
JavaScript
// GrechkaCraft — auto-bundled from ES modules
|
||
(function() {
|
||
"use strict";
|
||
|
||
// Touch device detection & PWA service worker
|
||
(function() {
|
||
// Touch device classes
|
||
if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
|
||
document.body.classList.add('touch-device');
|
||
}
|
||
// Orientation handler
|
||
function updateOrientation() {
|
||
if (window.innerHeight > window.innerWidth) {
|
||
document.body.classList.remove('landscape');
|
||
document.body.classList.add('portrait');
|
||
} else {
|
||
document.body.classList.remove('portrait');
|
||
document.body.classList.add('landscape');
|
||
}
|
||
}
|
||
window.addEventListener('resize', updateOrientation);
|
||
window.addEventListener('orientationchange', function() {
|
||
setTimeout(updateOrientation, 100);
|
||
});
|
||
updateOrientation();
|
||
// PWA: service worker for fullscreen install
|
||
if ('serviceWorker' in navigator) {
|
||
navigator.serviceWorker.register('sw.js').catch(function(err) {
|
||
console.warn('[SW] Registration failed:', err);
|
||
});
|
||
}
|
||
// Fullscreen: hide browser chrome on mobile
|
||
function goFullscreen() {
|
||
if (window.Telegram?.WebApp) {
|
||
Telegram.WebApp.expand();
|
||
try { Telegram.WebApp.requestFullscreen(); } catch(e) {}
|
||
return;
|
||
}
|
||
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
|
||
var el = document.documentElement;
|
||
if (el.requestFullscreen) el.requestFullscreen().catch(function(){});
|
||
else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen();
|
||
}
|
||
// Scroll to hide address bar on iOS/Android
|
||
window.scrollTo(0, 0);
|
||
}
|
||
// Try on first interaction
|
||
document.addEventListener('click', goFullscreen, { once: true });
|
||
document.addEventListener('touchstart', goFullscreen, { once: true });
|
||
// Retry on orientation change (browser chrome reappears on rotate)
|
||
window.addEventListener('orientationchange', function() {
|
||
setTimeout(goFullscreen, 300);
|
||
});
|
||
// Also on resize (mobile address bar hides/shows)
|
||
window.addEventListener('resize', function() {
|
||
if (window.innerHeight > window.innerWidth) return; // portrait — fine
|
||
setTimeout(function() {
|
||
window.scrollTo(0, 0);
|
||
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
|
||
goFullscreen();
|
||
}
|
||
}, 200);
|
||
});
|
||
// Lock scroll to prevent pull-to-refresh
|
||
document.addEventListener('touchmove', function(e) {
|
||
if (e.target === document.documentElement || e.target === document.body) {
|
||
e.preventDefault();
|
||
}
|
||
}, { passive: false });
|
||
// TG Mini App: expand to full screen
|
||
if (window.Telegram?.WebApp) {
|
||
Telegram.WebApp.expand();
|
||
if (Telegram.WebApp.requestFullscreen) {
|
||
Telegram.WebApp.requestFullscreen();
|
||
}
|
||
}
|
||
})();
|
||
|
||
|
||
|
||
// === core/constants.js ===
|
||
// ==================== КОНСТАНТЫ ИГРЫ ====================
|
||
|
||
const TILE = 40;
|
||
|
||
// Мир
|
||
const SEA_GY = 14; // уровень воды (gy) - уменьшил для меньших водоёмов
|
||
const BEDROCK_GY = 140; // глубина бедрока (gy), чем больше - тем глубже
|
||
const GEN_MARGIN_X = 40; // запас генерации по X (в тайлах) — увеличен для плавной прогрузки
|
||
|
||
// Физика
|
||
const GRAV = 2200;
|
||
const GRAV_WATER = 550;
|
||
const MOVE = 320;
|
||
const JUMP = 760;
|
||
|
||
// День/ночь
|
||
const DAY_LEN = 360; // замедлил смену дня/ночи в 3 раза
|
||
|
||
// Сохранение
|
||
const SAVE_KEY = 'minegrechka_save';
|
||
|
||
// Сеть
|
||
const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду
|
||
|
||
// Погода
|
||
const MAX_RAINDROPS = 200;
|
||
|
||
// Чат
|
||
const MAX_CHAT_MESSAGES = 50;
|
||
|
||
// Голосовой чат
|
||
const VOICE_SERVER = 'wss://voicegrech.mkn8n.ru';
|
||
|
||
// === core/state.js ===
|
||
// Все мутабельные переменные игры в одном объекте состояния
|
||
const state = {
|
||
// Константы (для доступа из модулей)
|
||
TILE,
|
||
SEA_GY,
|
||
BEDROCK_GY,
|
||
GEN_MARGIN_X,
|
||
GRAV,
|
||
GRAV_WATER,
|
||
MOVE,
|
||
JUMP,
|
||
DAY_LEN,
|
||
SAVE_KEY,
|
||
MOVE_SEND_INTERVAL,
|
||
MAX_RAINDROPS,
|
||
MAX_CHAT_MESSAGES,
|
||
VOICE_SERVER,
|
||
|
||
// Камера
|
||
camX: 0,
|
||
camY: 0,
|
||
W: 0,
|
||
H: 0,
|
||
dpr: 1,
|
||
|
||
// Canvas/DOM (заполняется в main.js)
|
||
gameEl: null,
|
||
canvas: null,
|
||
ctx: null,
|
||
lightC: null,
|
||
lightCtx: null,
|
||
|
||
// День/ночь
|
||
worldTime: 0,
|
||
isNightTime: false,
|
||
dayNightCycle: true,
|
||
|
||
// Мультиплеер
|
||
isMultiplayer: false,
|
||
mySocketId: null,
|
||
socket: null,
|
||
voiceSocket: null,
|
||
voicePosT: 0,
|
||
|
||
// Сервер
|
||
SERVER_URL: '',
|
||
TELEGRAM_BOT_USERNAME: '',
|
||
TELEGRAM_APP_SHORT_NAME: '',
|
||
|
||
// Игрок
|
||
playerName: null,
|
||
worldId: null,
|
||
|
||
// Инвентарь/UI
|
||
selected: "dirt",
|
||
showFullInventory: false,
|
||
craftOpen: false,
|
||
inventoryOpen: false,
|
||
chatOpen: false,
|
||
minimapOpen: false,
|
||
modeIdx: 0,
|
||
currentFurnaceKey: null,
|
||
|
||
// Мир
|
||
worldSeed: Math.floor(Math.random() * 1000000),
|
||
|
||
// Погода
|
||
weatherType: "clear",
|
||
isRaining: false,
|
||
rainIntensity: 0,
|
||
weatherTimer: 0,
|
||
weatherChangeInterval: 60 + Math.random() * 120,
|
||
snowflakes: [],
|
||
lightningFlash: 0,
|
||
lightningTimer: 0,
|
||
|
||
// Мобы/спавн
|
||
spawnT: 0,
|
||
|
||
// Цикл
|
||
last: 0,
|
||
prevJump: false,
|
||
|
||
// Сеть — throttle отправки позиции
|
||
lastMoveSendTime: 0,
|
||
lastSentX: 0,
|
||
lastSentY: 0,
|
||
|
||
// Mining progress
|
||
mining: { gx: null, gy: null, progress: 0, hardness: 0 },
|
||
|
||
// Trade/Market
|
||
myTgId: null,
|
||
tradePanel: null,
|
||
tradeContent: null,
|
||
marketPanel: null,
|
||
marketContent: null,
|
||
|
||
// DOM-ссылки (заполняются в main.js)
|
||
hpEl: null,
|
||
foodEl: null,
|
||
sxEl: null,
|
||
syEl: null,
|
||
todEl: null,
|
||
worldIdEl: null,
|
||
playerCountEl: null,
|
||
deathEl: null,
|
||
hotbarEl: null,
|
||
craftPanel: null,
|
||
recipesEl: null,
|
||
inventoryPanel: null,
|
||
inventoryGrid: null,
|
||
multiplayerStatus: null,
|
||
tempEl: null,
|
||
|
||
// Игрок
|
||
player: {
|
||
x: 6 * TILE,
|
||
y: 0 * TILE,
|
||
w: 34,
|
||
h: 34,
|
||
vx: 0,
|
||
vy: 0,
|
||
grounded: false,
|
||
inWater: false,
|
||
headInWater: false,
|
||
hp: 100,
|
||
hunger: 100,
|
||
o2: 100,
|
||
invuln: 0,
|
||
fallStartY: 0,
|
||
lastStepTime: 0,
|
||
sleeping: false,
|
||
inBoat: false,
|
||
armor: 0,
|
||
equippedArmor: null,
|
||
temperature: 15,
|
||
slowTimer: 0
|
||
},
|
||
|
||
// Точка спавна
|
||
spawnPoint: { x: 6 * TILE, y: 0 * TILE },
|
||
|
||
// Инвентарь
|
||
inv: {
|
||
dirt: 6, stone: 0, sand: 0, gravel: 0, clay: 0,
|
||
wood: 0, planks: 0, ladder: 0, leaves: 0, coal: 0,
|
||
copper_ore: 0, iron_ore: 0, gold_ore: 0, diamond_ore: 0,
|
||
brick: 0, glass: 0,
|
||
tnt: 1, campfire: 0, torch: 0,
|
||
meat: 0, cooked: 0, arrow: 0,
|
||
wood_pickaxe: 0, stone_pickaxe: 0, iron_pickaxe: 0,
|
||
wood_sword: 0, stone_sword: 0, iron_sword: 0,
|
||
wood_axe: 0, stone_axe: 0, iron_axe: 0,
|
||
wood_shovel: 0, stone_shovel: 0, iron_shovel: 0,
|
||
iron_armor: 0, gold_armor: 0,
|
||
bow: 0, furnace: 0,
|
||
bed: 0, boat: 0,
|
||
iron_ingot: 0, gold_ingot: 0, copper_ingot: 0,
|
||
cobblestone: 0,
|
||
// New items
|
||
chicken_meat: 0, feather: 0, bone: 0, gunpowder: 0,
|
||
wheat: 0, bread: 0, carrot: 0, potato: 0, baked_potato: 0,
|
||
mushroom_stew: 0,
|
||
scorpion_stinger: 0, polar_fur: 0, slime_ball: 0, eagle_feather: 0,
|
||
snow: 0, ice: 0, cactus: 0, mushroom: 0, moss: 0, farmland: 0,
|
||
spruce_leaves: 0, dead_bush: 0, swamp_water: 0,
|
||
wheat_seeds: 0, wheat_stage0: 0, carrot_stage0: 0, potato_stage0: 0
|
||
},
|
||
|
||
// Лодка
|
||
boat: {
|
||
x: 0, y: 0,
|
||
w: 34, h: 34,
|
||
vx: 0, vy: 0,
|
||
active: false,
|
||
inWater: false
|
||
},
|
||
|
||
// Ввод
|
||
inp: { up: false, down: false, left: false, right: false, jump: false, mine: false, build: false, bow: false, l: false, r: false, j: false, s: false },
|
||
|
||
// Мышь
|
||
mouse: { x: 0, y: 0 },
|
||
|
||
// Другие игроки (MP)
|
||
otherPlayers: new Map(),
|
||
|
||
// Серверные мобы (MP)
|
||
serverMobs: new Map(),
|
||
|
||
// Мобы
|
||
mobs: [],
|
||
|
||
// Снаряды
|
||
projectiles: [],
|
||
|
||
// Отслеживание изменений мира
|
||
placedBlocks: [],
|
||
removedBlocks: [],
|
||
|
||
// Серверные изменения
|
||
serverOverrides: new Map(),
|
||
|
||
// Чат
|
||
chatMessages: [],
|
||
|
||
// Погода — капли
|
||
raindrops: [],
|
||
|
||
// Облака
|
||
clouds: Array.from({ length: 10 }, () => ({
|
||
x: Math.random() * 2000,
|
||
y: -200 - Math.random() * 260,
|
||
w: 80 + Math.random() * 120,
|
||
s: 12 + Math.random() * 20
|
||
})),
|
||
|
||
// Частицы
|
||
parts: [],
|
||
|
||
// Активный TNT
|
||
activeTNT: new Set(),
|
||
|
||
// Прочность инструментов
|
||
toolDurability: new Map(),
|
||
protections: new Map(),
|
||
|
||
// Последние выбранные предметы
|
||
recentItems: [],
|
||
|
||
// Активные печи
|
||
activeFurnaces: new Map(),
|
||
cropTimers: new Map(),
|
||
|
||
// Сгенерированные колонны
|
||
generated: new Set(),
|
||
|
||
// Изображение героя
|
||
heroImg: new Image(),
|
||
|
||
// Сетка мира
|
||
grid: new Map(),
|
||
blocks: [],
|
||
|
||
// In-memory save fallback
|
||
inMemorySave: null,
|
||
};
|
||
|
||
// === config.js ===
|
||
// ==================== КОНФИГУРАЦИЯ СЕРВЕРА ====================
|
||
// This module only sets SERVER_URL. Player name and world ID are handled in main.js
|
||
// to avoid duplicate prompts.
|
||
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const SERVER_URL = urlParams.get('server') || 'https://apigrech.mkn8n.ru';
|
||
|
||
// Защита от mixed content
|
||
if (location.protocol === 'https:' && SERVER_URL.startsWith('http://')) {
|
||
console.warn('⚠️ Mixed content warning: page is HTTPS but server URL is HTTP');
|
||
alert('⚠️ Предупреждение: страница загружена по HTTPS, но сервер использует HTTP. Это может вызвать проблемы.');
|
||
}
|
||
|
||
// === ui/start-menu.js ===
|
||
// ==================== START MENU ====================
|
||
|
||
|
||
// Detect Telegram WebApp
|
||
const isTgWebApp = !!(window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData);
|
||
let tgUser = null;
|
||
let tgSaveLoaded = false;
|
||
|
||
if (isTgWebApp) {
|
||
try {
|
||
Telegram.WebApp.ready();
|
||
Telegram.WebApp.expand();
|
||
tgUser = Telegram.WebApp.initDataUnsafe?.user || null;
|
||
console.log('[TG] Mini App detected, user:', tgUser?.id, tgUser?.username);
|
||
// Set player name from TG
|
||
if (tgUser && tgUser.first_name) {
|
||
const tgName = tgUser.username || tgUser.first_name;
|
||
localStorage.setItem('minegrechka_playerName', tgName);
|
||
}
|
||
} catch (e) {
|
||
console.warn('[TG] WebApp init error:', e);
|
||
}
|
||
}
|
||
|
||
// Export TG info for other modules
|
||
|
||
|
||
// ==================== HELPERS ====================
|
||
|
||
function generateShortWorldCode() {
|
||
// Generate readable 6-char code: consonants + digits (easy to copy/share)
|
||
const c = 'bcdfghjkmnpqrstvwxyz';
|
||
const d = '23456789';
|
||
let code = '';
|
||
for (let i = 0; i < 3; i++) code += c[Math.floor(Math.random() * c.length)];
|
||
for (let i = 0; i < 3; i++) code += d[Math.floor(Math.random() * d.length)];
|
||
return code;
|
||
}
|
||
|
||
function showToast(msg, duration) {
|
||
duration = duration || 1800;
|
||
const t = document.createElement('div');
|
||
t.textContent = msg;
|
||
t.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.85);color:#2ecc71;padding:10px 24px;border-radius:10px;font:bold 16px Arial,sans-serif;z-index:99999;pointer-events:none;transition:opacity 0.4s;';
|
||
document.body.appendChild(t);
|
||
setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 400); }, duration);
|
||
}
|
||
|
||
function customAlert(msg) {
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'custom-modal-overlay';
|
||
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:100000;';
|
||
const box = document.createElement('div');
|
||
box.style.cssText = 'background:#C6C6C6;border:4px solid #000;padding:24px;max-width:360px;width:90%;box-shadow:4px 4px 0 #000;font-family:Courier New,monospace;';
|
||
const text = document.createElement('div');
|
||
text.textContent = msg;
|
||
text.style.marginBottom = '16px';
|
||
text.style.fontWeight = 'bold';
|
||
text.style.fontSize = '16px';
|
||
const btn = document.createElement('button');
|
||
btn.textContent = 'OK';
|
||
btn.style.cssText = 'padding:8px 24px;font-family:Courier New,monospace;font-weight:bold;cursor:pointer;border:3px solid #000;background:#5A8F3C;color:#fff;box-shadow:2px 2px 0 #000;';
|
||
btn.onclick = () => overlay.remove();
|
||
box.appendChild(text);
|
||
box.appendChild(btn);
|
||
overlay.appendChild(box);
|
||
document.body.appendChild(overlay);
|
||
}
|
||
|
||
// ==================== MAIN START MENU ====================
|
||
|
||
// Minecraft-style CSS for the start menu
|
||
const mcCSS = `
|
||
.mc-overlay { position:fixed;top:0;left:0;width:100%;height:100%;z-index:99999;
|
||
display:flex;align-items:center;justify-content:center;font-family:'Courier New',monospace;
|
||
image-rendering:pixelated; }
|
||
.mc-bg { position:absolute;inset:0;background:#000;opacity:0.75; }
|
||
.mc-dirt { position:absolute;bottom:0;left:0;right:0;height:40%;
|
||
background:repeating-linear-gradient(0deg,#8B6914 0px,#8B6914 2px,#7A5B12 2px,#7A5B12 4px);opacity:0.3; }
|
||
.mc-box { position:relative;z-index:1;background:#C6C6C6;border:4px solid #000;
|
||
padding:0;max-width:420px;width:92%;box-shadow:4px 4px 0 #000;max-height:90vh;display:flex;flex-direction:column; }
|
||
.mc-titlebar { background:#1a1a2e;padding:12px 16px;text-align:center;
|
||
border-bottom:4px solid #000; }
|
||
.mc-title { font-size:24px;font-weight:bold;color:#FFAA00;text-shadow:2px 2px 0 #000;
|
||
letter-spacing:2px; }
|
||
.mc-subtitle { font-size:13px;color:#AAA;margin-top:4px; }
|
||
.mc-body { padding:16px;background:#C6C6C6;max-height:80vh;overflow-y:auto;-webkit-overflow-scrolling:touch; }
|
||
.mc-btn { display:block;width:100%;padding:10px 16px;margin-bottom:8px;
|
||
font-family:'Courier New',monospace;font-size:16px;font-weight:bold;
|
||
cursor:pointer;text-align:center;border:3px solid #000;
|
||
image-rendering:pixelated;box-shadow:3px 3px 0 #000;
|
||
transition:transform 0.05s,box-shadow 0.05s; }
|
||
.mc-btn:active { transform:translate(2px,2px);box-shadow:1px 1px 0 #000; }
|
||
.mc-btn-green { background:#5A8F3C;color:#E0E0E0;border-color:#3A5F1C;text-shadow:1px 1px 0 #222; }
|
||
.mc-btn-green:hover { background:#6DAF4E; }
|
||
.mc-btn-blue { background:#3C5F9E;color:#E0E0E0;border-color:#2A4070;text-shadow:1px 1px 0 #222; }
|
||
.mc-btn-blue:hover { background:#4B73BE; }
|
||
.mc-btn-purple { background:#7B3FA0;color:#E0E0E0;border-color:#5A2C78;text-shadow:1px 1px 0 #222; }
|
||
.mc-btn-purple:hover { background:#9555BE; }
|
||
.mc-btn-orange { background:#B86E00;color:#E0E0E0;border-color:#8A5200;text-shadow:1px 1px 0 #222; }
|
||
.mc-btn-orange:hover { background:#DA8500; }
|
||
.mc-btn-red { background:#9E2B2B;color:#E0E0E0;border-color:#6E1B1B;text-shadow:1px 1px 0 #222; }
|
||
.mc-btn-red:hover { background:#BE3B3B; }
|
||
.mc-btn-gray { background:#555;color:#AAA;border-color:#333;text-shadow:1px 1px 0 #111; }
|
||
.mc-btn-gray:hover { background:#666;color:#DDD; }
|
||
.mc-btn-outline { background:transparent;color:#555;border-color:#555;
|
||
text-shadow:none;box-shadow:2px 2px 0 #333; }
|
||
.mc-btn-outline:hover { background:#DDD;color:#222; }
|
||
.mc-input { width:100%;padding:8px 12px;font-family:'Courier New',monospace;
|
||
font-size:16px;background:#FFF;border:3px solid #000;color:#222;
|
||
box-shadow:inset 2px 2px 0 #999;margin:4px 0;box-sizing:border-box; }
|
||
.mc-input:focus { outline:none;border-color:#FFAA00; }
|
||
.mc-label { font-size:13px;color:#444;font-weight:bold;margin:8px 0 4px; }
|
||
.mc-divider { border:none;border-top:2px solid #999;margin:12px 0; }
|
||
.mc-friend-row { display:flex;justify-content:space-between;align-items:center;
|
||
padding:6px 8px;margin-bottom:4px;background:#E8E8E8;border:2px solid #999;
|
||
box-shadow:2px 2px 0 #999; }
|
||
.mc-friend-btn { padding:4px 10px;font-family:'Courier New',monospace;
|
||
font-size:13px;font-weight:bold;cursor:pointer;border:2px solid #000;
|
||
box-shadow:1px 1px 0 #000; }
|
||
.mc-friend-btn:active { transform:translate(1px,1px);box-shadow:none; }
|
||
.mc-friend-btn-green { background:#5A8F3C;color:#E0E0E0; }
|
||
.mc-friend-btn-red { background:#9E2B2B;color:#E0E0E0; }
|
||
.mc-checkbox-row { display:flex;align-items:center;gap:8px;font-size:13px;color:#444;cursor:pointer; }
|
||
.mc-checkbox { width:18px;height:18px;accent-color:#5A8F3C; }
|
||
`;
|
||
|
||
function hideBoot(){var b=document.getElementById("boot");if(b){b.remove();}}
|
||
|
||
function showStartMenu() {
|
||
return new Promise(async (resolve) => {
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const worldParam = urlParams.get('world');
|
||
const tgStartParam = (isTgWebApp && Telegram.WebApp.initDataUnsafe?.start_param)
|
||
? Telegram.WebApp.initDataUnsafe.start_param.trim()
|
||
: null;
|
||
|
||
// If world is in URL or TG start param — skip menu, auto-join.
|
||
// `default` is NOT a playable world: the server returns blocks:[] for it,
|
||
// so auto-joining leaves the player staring at empty sky with no menu to
|
||
// escape to. Treat it as "no world chosen" and show the menu instead.
|
||
const RESERVED_WORLD_IDS = new Set(['', 'default', 'lobby', 'null', 'undefined']);
|
||
if ((worldParam && worldParam.trim() !== '' && !RESERVED_WORLD_IDS.has(worldParam.trim())) || tgStartParam) {
|
||
const worldId = (worldParam && worldParam.trim()) || tgStartParam;
|
||
console.log('[Menu] Joining world from URL/startParam:', worldId);
|
||
|
||
// Set player name from TG or localStorage or default
|
||
if (!state.playerName) {
|
||
if (isTgWebApp && tgUser) {
|
||
state.playerName = tgUser.username || tgUser.first_name || 'Игрок';
|
||
} else {
|
||
state.playerName = localStorage.getItem('minegrechka_playerName') || 'Игрок';
|
||
}
|
||
localStorage.setItem('minegrechka_playerName', state.playerName);
|
||
}
|
||
|
||
hideBoot();
|
||
resolve(worldId);
|
||
return;
|
||
}
|
||
|
||
// Inject CSS
|
||
const styleEl = document.createElement('style');
|
||
styleEl.textContent = mcCSS;
|
||
document.head.appendChild(styleEl);
|
||
|
||
// Build overlay
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'mc-overlay';
|
||
overlay.id = 'startMenu';
|
||
overlay.innerHTML = '<div class="mc-bg"></div><div class="mc-dirt"></div>';
|
||
|
||
const box = document.createElement('div');
|
||
box.className = 'mc-box';
|
||
|
||
// Title bar
|
||
const titleBar = document.createElement('div');
|
||
titleBar.className = 'mc-titlebar';
|
||
titleBar.innerHTML = '<div class="mc-title">⛏️ GrechkaCraft</div>' +
|
||
'<div class="mc-subtitle">' + (isTgWebApp ? ('Привет, ' + (tgUser?.first_name || 'Игрок') + '!') : 'Voxel-мир в браузере') + '</div>';
|
||
box.appendChild(titleBar);
|
||
|
||
const body = document.createElement('div');
|
||
body.className = 'mc-body';
|
||
|
||
// Mutable worldId holder
|
||
let selectedWorldId = null;
|
||
|
||
// === Buttons ===
|
||
|
||
// 🌍 New World
|
||
const btnNew = document.createElement('button');
|
||
btnNew.className = 'mc-btn mc-btn-green';
|
||
btnNew.textContent = '🌍 Новый мир';
|
||
btnNew.onclick = () => {
|
||
selectedWorldId = generateShortWorldCode();
|
||
overlay.remove();
|
||
styleEl.remove();
|
||
resolve(selectedWorldId);
|
||
};
|
||
body.appendChild(btnNew);
|
||
|
||
// 🔗 Join by code
|
||
const btnJoin = document.createElement('button');
|
||
btnJoin.className = 'mc-btn mc-btn-blue';
|
||
btnJoin.textContent = '🔗 Вступить по коду';
|
||
btnJoin.onclick = () => {
|
||
body.innerHTML = '';
|
||
const lbl = document.createElement('div');
|
||
lbl.className = 'mc-label';
|
||
lbl.textContent = 'Код мира:';
|
||
body.appendChild(lbl);
|
||
const codeInput = document.createElement('input');
|
||
codeInput.className = 'mc-input';
|
||
codeInput.type = 'text';
|
||
codeInput.placeholder = 'bkh237';
|
||
codeInput.maxLength = 12;
|
||
body.appendChild(codeInput);
|
||
const btnGo = document.createElement('button');
|
||
btnGo.className = 'mc-btn mc-btn-blue';
|
||
btnGo.textContent = 'Войти →';
|
||
btnGo.onclick = () => {
|
||
const code = codeInput.value.trim().toLowerCase();
|
||
if (code.length >= 3) {
|
||
selectedWorldId = code;
|
||
overlay.remove();
|
||
styleEl.remove();
|
||
resolve(selectedWorldId);
|
||
}
|
||
};
|
||
body.appendChild(btnGo);
|
||
const btnBack = document.createElement('button');
|
||
btnBack.className = 'mc-btn mc-btn-gray';
|
||
btnBack.textContent = '← Назад';
|
||
btnBack.onclick = () => { overlay.remove(); styleEl.remove(); showStartMenu().then(resolve); };
|
||
body.appendChild(btnBack);
|
||
codeInput.focus();
|
||
codeInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') btnGo.click(); });
|
||
};
|
||
body.appendChild(btnJoin);
|
||
|
||
// 💾 Continue saved game
|
||
const hasLocalSave = !!localStorage.getItem(state.SAVE_KEY || 'minegrechka_save');
|
||
if (isTgWebApp || hasLocalSave) {
|
||
const btnHistory = document.createElement('button');
|
||
btnHistory.className = 'mc-btn mc-btn-purple';
|
||
btnHistory.textContent = '💾 Продолжить игру';
|
||
btnHistory.onclick = async () => {
|
||
if (isTgWebApp && tgUser) {
|
||
try {
|
||
const resp = await fetch(state.SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id);
|
||
const data = await resp.json();
|
||
if (data.ok && data.world_id) {
|
||
selectedWorldId = data.world_id;
|
||
tgSaveLoaded = true;
|
||
overlay.remove();
|
||
styleEl.remove();
|
||
resolve(selectedWorldId);
|
||
return;
|
||
}
|
||
} catch (e) { console.warn('[TG] Load save failed:', e); }
|
||
}
|
||
if (hasLocalSave) {
|
||
// Continue with a new world code (the save will be loaded separately by initDB/loadGame)
|
||
selectedWorldId = generateShortWorldCode();
|
||
overlay.remove();
|
||
styleEl.remove();
|
||
resolve(selectedWorldId);
|
||
return;
|
||
}
|
||
customAlert('Сохранений не найдено');
|
||
};
|
||
body.appendChild(btnHistory);
|
||
}
|
||
|
||
// === TG-only buttons ===
|
||
if (isTgWebApp && window.Telegram && Telegram.WebApp.switchInlineQuery) {
|
||
// 📤 Invite friends
|
||
const btnInvite = document.createElement('button');
|
||
btnInvite.className = 'mc-btn mc-btn-outline';
|
||
btnInvite.style.marginTop = '16px';
|
||
btnInvite.textContent = '📤 Пригласить друзей';
|
||
btnInvite.onclick = () => {
|
||
const code = selectedWorldId || generateShortWorldCode();
|
||
const url = 'https://t.me/' + state.TELEGRAM_BOT_USERNAME + '/app?startapp=' + encodeURIComponent(code);
|
||
if (Telegram.WebApp && Telegram.WebApp.openTelegramLink) {
|
||
Telegram.WebApp.openTelegramLink('https://t.me/share/url?url=' + encodeURIComponent(url) + '&text=' + encodeURIComponent('🏗️ Заходи в GrechkaCraft! Мир: ' + code));
|
||
} else {
|
||
navigator.clipboard.writeText(url).then(() => showToast('📋 Скопировано!')).catch(() => showToast(url));
|
||
}
|
||
};
|
||
body.appendChild(btnInvite);
|
||
|
||
// 👥 Friends
|
||
const btnFriends = document.createElement('button');
|
||
btnFriends.className = 'mc-btn mc-btn-orange';
|
||
btnFriends.style.marginTop = '8px';
|
||
btnFriends.textContent = '👥 Друзья';
|
||
btnFriends.onclick = async () => {
|
||
try {
|
||
const resp = await fetch(state.SERVER_URL + '/api/tg/friends?tg_id=' + tgUser.id);
|
||
const data = await resp.json();
|
||
body.innerHTML = '';
|
||
|
||
// Back button at top
|
||
const btnBackTop = document.createElement('button');
|
||
btnBackTop.className = 'mc-btn mc-btn-gray';
|
||
btnBackTop.textContent = '← Назад';
|
||
btnBackTop.style.marginBottom = '12px';
|
||
btnBackTop.onclick = () => { overlay.remove(); styleEl.remove(); showStartMenu().then(resolve); };
|
||
body.appendChild(btnBackTop);
|
||
|
||
// Nickname section
|
||
if (data.nickname) {
|
||
const nickDiv = document.createElement('div');
|
||
nickDiv.className = 'mc-friend-row';
|
||
nickDiv.innerHTML = '🔗 Твой ник: <b>' + data.nickname + '</b>';
|
||
nickDiv.style.background = '#D5E8C5';
|
||
body.appendChild(nickDiv);
|
||
} else {
|
||
const nickLabel = document.createElement('div');
|
||
nickLabel.className = 'mc-label';
|
||
nickLabel.style.color = '#9E2B2B';
|
||
nickLabel.textContent = '⚡ Задай ник, чтобы друзья могли тебя найти:';
|
||
body.appendChild(nickLabel);
|
||
const nickRow = document.createElement('div');
|
||
nickRow.style.display = 'flex';
|
||
nickRow.style.gap = '4px';
|
||
nickRow.style.marginBottom = '8px';
|
||
const nickInput = document.createElement('input');
|
||
nickInput.className = 'mc-input';
|
||
nickInput.style.flex = '1';
|
||
nickInput.type = 'text';
|
||
nickInput.maxLength = 16;
|
||
nickInput.placeholder = 'Твой ник';
|
||
const nickBtn = document.createElement('button');
|
||
nickBtn.className = 'mc-btn mc-btn-green';
|
||
nickBtn.style.width = 'auto';
|
||
nickBtn.style.marginBottom = '0';
|
||
nickBtn.style.padding = '8px 16px';
|
||
nickBtn.textContent = '✓';
|
||
nickBtn.onclick = async () => {
|
||
const n = nickInput.value.trim().toLowerCase();
|
||
if (!n || n.length < 2) { showToast('❌ Минимум 2 символа'); return; }
|
||
const check = await fetch(state.SERVER_URL + '/api/nick/check?nick=' + encodeURIComponent(n));
|
||
const cd = await check.json();
|
||
if (!cd.available) { showToast('❌ Ник занят'); return; }
|
||
const setResp = await fetch(state.SERVER_URL + '/api/nick/set', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ tg_id: tgUser.id, nickname: n })
|
||
});
|
||
const sd = await setResp.json();
|
||
if (sd.ok) { showToast('✅ Ник: ' + sd.nickname); btnFriends.onclick(); } else { showToast('❌ Ошибка'); }
|
||
};
|
||
nickRow.appendChild(nickInput);
|
||
nickRow.appendChild(nickBtn);
|
||
body.appendChild(nickRow);
|
||
}
|
||
|
||
// Friends list
|
||
if (data.friends && data.friends.length) {
|
||
const listLabel = document.createElement('div');
|
||
listLabel.className = 'mc-label';
|
||
listLabel.textContent = '👥 Друзья:';
|
||
body.appendChild(listLabel);
|
||
for (const f of data.friends) {
|
||
const fDiv = document.createElement('div');
|
||
fDiv.className = 'mc-friend-row';
|
||
const statusDot = f.online ? '🟢' : '⚫';
|
||
let info = statusDot + ' <b>' + f.nickname + '</b>';
|
||
if (f.online && f.world_id) info += ' — мир ' + f.world_id;
|
||
const fInfo = document.createElement('div');
|
||
fInfo.innerHTML = info;
|
||
fDiv.appendChild(fInfo);
|
||
if (f.online && f.world_id) {
|
||
const joinBtn = document.createElement('button');
|
||
joinBtn.className = 'mc-friend-btn mc-friend-btn-green';
|
||
joinBtn.textContent = '🎮 Вступить';
|
||
joinBtn.onclick = () => {
|
||
selectedWorldId = f.world_id;
|
||
overlay.remove();
|
||
styleEl.remove();
|
||
resolve(selectedWorldId);
|
||
};
|
||
fDiv.appendChild(joinBtn);
|
||
}
|
||
body.appendChild(fDiv);
|
||
}
|
||
} else if (data.friends && !data.friends.length) {
|
||
const noFriends = document.createElement('div');
|
||
noFriends.className = 'mc-label';
|
||
noFriends.style.color = '#888';
|
||
noFriends.textContent = 'Пока нет друзей';
|
||
body.appendChild(noFriends);
|
||
}
|
||
|
||
// Pending friend requests
|
||
if (data.pending && data.pending.length) {
|
||
const pLabel = document.createElement('div');
|
||
pLabel.className = 'mc-label';
|
||
pLabel.style.color = '#B86E00';
|
||
pLabel.textContent = '📨 Запросы в друзья:';
|
||
body.appendChild(pLabel);
|
||
for (const p of data.pending) {
|
||
const pItem = document.createElement('div');
|
||
pItem.className = 'mc-friend-row';
|
||
const span = document.createElement('span');
|
||
span.textContent = p.from;
|
||
pItem.appendChild(span);
|
||
const btns = document.createElement('div');
|
||
btns.style.display = 'flex';
|
||
btns.style.gap = '4px';
|
||
const acceptBtn = document.createElement('button');
|
||
acceptBtn.className = 'mc-friend-btn mc-friend-btn-green';
|
||
acceptBtn.textContent = '✓';
|
||
acceptBtn.onclick = async () => {
|
||
const r = await fetch(state.SERVER_URL + '/api/friends/accept', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ tg_id: tgUser.id, request_id: p.id })
|
||
});
|
||
const d = await r.json();
|
||
if (d.ok) { showToast('✅ Друг добавлен!'); btnFriends.onclick(); } else { showToast('❌ Ошибка'); }
|
||
};
|
||
const declineBtn = document.createElement('button');
|
||
declineBtn.className = 'mc-friend-btn mc-friend-btn-red';
|
||
declineBtn.textContent = '✕';
|
||
declineBtn.onclick = async () => {
|
||
await fetch(state.SERVER_URL + '/api/friends/decline', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ tg_id: tgUser.id, request_id: p.id })
|
||
});
|
||
showToast('Отклонено');
|
||
btnFriends.onclick();
|
||
};
|
||
btns.appendChild(acceptBtn);
|
||
btns.appendChild(declineBtn);
|
||
pItem.appendChild(btns);
|
||
body.appendChild(pItem);
|
||
}
|
||
}
|
||
|
||
// Add friend
|
||
body.appendChild(document.createElement('hr'));
|
||
const addLabel = document.createElement('div');
|
||
addLabel.className = 'mc-label';
|
||
addLabel.textContent = '➕ Добавить друга по нику:';
|
||
body.appendChild(addLabel);
|
||
const addRow = document.createElement('div');
|
||
addRow.style.display = 'flex';
|
||
addRow.style.gap = '4px';
|
||
const addInput = document.createElement('input');
|
||
addInput.className = 'mc-input';
|
||
addInput.style.flex = '1';
|
||
addInput.type = 'text';
|
||
addInput.maxLength = 16;
|
||
addInput.placeholder = 'Ник друга';
|
||
const addBtn = document.createElement('button');
|
||
addBtn.className = 'mc-btn mc-btn-blue';
|
||
addBtn.style.width = 'auto';
|
||
addBtn.style.marginBottom = '0';
|
||
addBtn.style.padding = '8px 16px';
|
||
addBtn.textContent = '+';
|
||
addBtn.onclick = async () => {
|
||
const nick = addInput.value.trim().toLowerCase();
|
||
if (!nick || nick.length < 2) { showToast('❌ Минимум 2 символа'); return; }
|
||
const r = await fetch(state.SERVER_URL + '/api/friends/request', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ tg_id: tgUser.id, nickname: nick })
|
||
});
|
||
const d = await r.json();
|
||
if (d.ok) { showToast(d.message === 'Already friends' ? '✅ Уже друзья!' : '📨 Запрос отправлен!'); }
|
||
else { showToast('❌ ' + (d.error || 'Ошибка')); }
|
||
};
|
||
addRow.appendChild(addInput);
|
||
addRow.appendChild(addBtn);
|
||
body.appendChild(addRow);
|
||
|
||
// Privacy checkboxes
|
||
const settings = data.settings || { allow_requests: 1, notify_online: 1 };
|
||
const settingsDiv = document.createElement('div');
|
||
settingsDiv.style.marginTop = '12px';
|
||
const reqLabel = document.createElement('label');
|
||
reqLabel.className = 'mc-checkbox-row';
|
||
const reqCb = document.createElement('input');
|
||
reqCb.className = 'mc-checkbox';
|
||
reqCb.type = 'checkbox';
|
||
reqCb.checked = settings.allow_requests;
|
||
reqCb.onchange = async () => {
|
||
await fetch(state.SERVER_URL + '/api/friends/settings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
tg_id: tgUser.id,
|
||
allow_requests: reqCb.checked ? 1 : 0,
|
||
notify_online: settings.notify_online
|
||
})
|
||
});
|
||
};
|
||
reqLabel.appendChild(reqCb);
|
||
reqLabel.appendChild(document.createTextNode(' Запросы в друзья'));
|
||
settingsDiv.appendChild(reqLabel);
|
||
body.appendChild(settingsDiv);
|
||
} catch (e) { showToast('❌ Ошибка загрузки друзей'); console.error(e); }
|
||
};
|
||
body.appendChild(btnFriends);
|
||
}
|
||
|
||
// Player name input — always visible, with Play button
|
||
{
|
||
const nameDivider = document.createElement('hr');
|
||
nameDivider.className = 'mc-divider';
|
||
body.appendChild(nameDivider);
|
||
const nameLabel = document.createElement('div');
|
||
nameLabel.className = 'mc-label';
|
||
nameLabel.textContent = 'Твоё имя:';
|
||
body.appendChild(nameLabel);
|
||
const nameRow = document.createElement('div');
|
||
nameRow.style.cssText = 'display:flex;gap:6px;';
|
||
const nameInput = document.createElement('input');
|
||
nameInput.className = 'mc-input';
|
||
nameInput.style.flex = '1';
|
||
nameInput.type = 'text';
|
||
nameInput.placeholder = 'Игрок';
|
||
nameInput.value = state.playerName || localStorage.getItem('minegrechka_playerName') || (tgUser ? (tgUser.username || tgUser.first_name || '') : '');
|
||
nameInput.maxLength = 20;
|
||
nameRow.appendChild(nameInput);
|
||
|
||
const playBtn = document.createElement('button');
|
||
playBtn.className = 'mc-btn mc-btn-green';
|
||
playBtn.style.cssText = 'width:auto;margin-bottom:0;padding:10px 20px;white-space:nowrap;';
|
||
playBtn.textContent = '▶ Играть';
|
||
playBtn.onclick = () => {
|
||
const name = nameInput.value.trim() || 'Игрок';
|
||
state.playerName = name;
|
||
localStorage.setItem('minegrechka_playerName', name);
|
||
// If no worldId yet, generate one and start
|
||
if (!selectedWorldId && !state.worldId) {
|
||
selectedWorldId = generateShortWorldCode();
|
||
}
|
||
overlay.remove();
|
||
styleEl.remove();
|
||
resolve(selectedWorldId || state.worldId);
|
||
};
|
||
nameRow.appendChild(playBtn);
|
||
body.appendChild(nameRow);
|
||
|
||
// Allow Enter key to start game
|
||
nameInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') playBtn.click();
|
||
});
|
||
|
||
// Auto-focus input on mobile
|
||
setTimeout(() => nameInput.focus(), 100);
|
||
}
|
||
|
||
box.appendChild(body);
|
||
overlay.appendChild(box);
|
||
document.body.appendChild(overlay);
|
||
hideBoot();
|
||
|
||
// Set default player name if still empty
|
||
if (!state.playerName) {
|
||
state.playerName = localStorage.getItem('minegrechka_playerName') || 'Игрок';
|
||
localStorage.setItem('minegrechka_playerName', state.playerName);
|
||
}
|
||
|
||
// Auto-load TG save info (background fetch)
|
||
if (isTgWebApp && tgUser && !worldParam) {
|
||
fetch(state.SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id)
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.ok && data.world_id) {
|
||
// could update UI but menu is already shown
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
});
|
||
}
|
||
|
||
// === core/canvas.js ===
|
||
// ==================== ХОЛСТ И РАЗМЕРЫ ====================
|
||
|
||
|
||
function initCanvas() {
|
||
const gameEl = document.getElementById('game');
|
||
const canvas = document.getElementById('c');
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
// offscreen light map
|
||
const lightC = document.createElement('canvas');
|
||
const lightCtx = lightC.getContext('2d');
|
||
|
||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||
|
||
function resize() {
|
||
state.W = gameEl.clientWidth;
|
||
state.H = gameEl.clientHeight;
|
||
canvas.width = state.W * dpr;
|
||
canvas.height = state.H * dpr;
|
||
lightC.width = state.W * dpr;
|
||
lightC.height = state.H * dpr;
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
}
|
||
|
||
window.addEventListener('resize', resize);
|
||
resize();
|
||
|
||
// Store everything on state
|
||
state.gameEl = gameEl;
|
||
state.canvas = canvas;
|
||
state.ctx = ctx;
|
||
state.lightC = lightC;
|
||
state.lightCtx = lightCtx;
|
||
state.dpr = dpr;
|
||
}
|
||
|
||
// Convenience exports for modules that use canvas directly
|
||
function getCanvas() { return state.canvas; }
|
||
function getCtx() { return state.ctx; }
|
||
function getLightC() { return state.lightC; }
|
||
function getLightCtx() { return state.lightCtx; }
|
||
|
||
// === data/blocks.js ===
|
||
const BLOCKS = {
|
||
air: { n:'Воздух', solid:false, hardness:0 },
|
||
grass: { n:'Трава', c:'#7cfc00', solid:true, hardness:3, toolType:'shovel' },
|
||
dirt: { n:'Грязь', c:'#8b4513', solid:true, hardness:3, toolType:'shovel' },
|
||
stone: { n:'Камень', c:'#7f8c8d', solid:true, hardness:6, toolType:'pickaxe' },
|
||
sand: { n:'Песок', c:'#f4d06f', solid:true, hardness:2, toolType:'shovel' },
|
||
gravel: { n:'Гравий', c:'#95a5a6', solid:true, hardness:3, toolType:'shovel' },
|
||
clay: { n:'Глина', c:'#74b9ff', solid:true, hardness:3, toolType:'shovel' },
|
||
wood: { n:'Дерево', c:'#d35400', solid:true, hardness:4, toolType:'axe' },
|
||
planks: { n:'Доски', c:'#e67e22', solid:true, hardness:3, toolType:'axe' },
|
||
ladder: { n:'Лестница',c:'#d35400', solid:false, climbable:true, hardness:1, toolType:null },
|
||
leaves: { n:'Листва', c:'#2ecc71', solid:true, hardness:1, toolType:'axe', minTool:null },
|
||
glass: { n:'Стекло', c:'rgba(200,240,255,0.25)', solid:true, alpha:0.55, hardness:1, toolType:null },
|
||
water: { n:'Вода', c:'rgba(52,152,219,0.55)', solid:false, fluid:true, hardness:0 },
|
||
coal: { n:'Уголь', c:'#2c3e50', solid:true, hardness:8, toolType:'pickaxe', minTool:'wood' },
|
||
copper_ore:{ n:'Медь', c:'#e17055', solid:true, hardness:10, toolType:'pickaxe', minTool:'wood' },
|
||
iron_ore: { n:'Железо', c:'#dcdde1', solid:true, hardness:12, toolType:'pickaxe', minTool:'stone' },
|
||
iron_armor:{ n:'Железная броня', c:'#95a5a6', solid:false, armor:0.5, hardness:0 },
|
||
gold_ore: { n:'Золото', c:'#f1c40f', solid:true, hardness:15, toolType:'pickaxe', minTool:'stone' },
|
||
diamond_ore:{n:'Алмаз', c:'#00a8ff', solid:true, hardness:20, toolType:'pickaxe', minTool:'iron' },
|
||
brick: { n:'Кирпич', c:'#c0392b', solid:true, hardness:8, toolType:'pickaxe' },
|
||
tnt: { n:'TNT', c:'#e74c3c', solid:true, explosive:true, hardness:1, toolType:null },
|
||
campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius:280, hardness:2, toolType:null },
|
||
torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:220, hardness:1, toolType:null },
|
||
bedrock: { n:'Бедрок', c:'#2d3436', solid:true, unbreakable:true, hardness:Infinity },
|
||
flower: { n:'Цветок', c:'#ff4757', solid:false, decor:true, hardness:0 },
|
||
bed: { n:'Кровать', c:'#e91e63', solid:true, bed:true, hardness:2, toolType:'axe' },
|
||
boat: { n:'Лодка', c:'#8B4513', solid:false, hardness:0 },
|
||
furnace: { n:'Печь', c:'#696969', solid:true, smelting:true, hardness:8, toolType:'pickaxe', minTool:'wood' },
|
||
cobblestone: { n:'Булыжник', c:'#7f8c8d', solid:true, hardness:6, toolType:'pickaxe' },
|
||
flag: { n:'Флаг', c:'#e74c3c', solid:true, hardness:1, toolType:'axe', protectionRadius:10 },
|
||
// === BIOME BLOCKS ===
|
||
snow: { n:'Снег', c:'#ecf0f1', solid:true, hardness:1, toolType:'shovel' },
|
||
ice: { n:'Лёд', c:'#74b9ff', solid:true, hardness:1, slip:true, toolType:'pickaxe' },
|
||
cactus: { n:'Кактус', c:'#27ae60', solid:true, hardness:1, hurt:true, toolType:null },
|
||
mushroom: { n:'Гриб', c:'#e74c3c', solid:false, decor:true, hardness:0 },
|
||
moss: { n:'Мох', c:'#0a6640', solid:true, hardness:1, toolType:null },
|
||
swamp_water:{ n:'Болотная вода', c:'rgba(100,120,40,0.6)', solid:false, fluid:true, poison:true, hardness:0 },
|
||
farmland: { n:'Грядка', c:'#8B6914', solid:true, hardness:1, farmable:true, toolType:'shovel' },
|
||
dead_bush: { n:'Сухой куст', c:'#b2bec3', solid:false, decor:true, hardness:0 },
|
||
spruce_leaves:{ n:'Ель', c:'#0a6640', solid:true, hardness:1, toolType:'axe' },
|
||
// === CROP STAGES ===
|
||
wheat_stage0:{ n:'Росток пшеницы', c:'#a8e6a0', solid:false, decor:true, hardness:0, growthStage:0, growthTime:30, growsTo:'wheat_stage1' },
|
||
wheat_stage1:{ n:'Пшеница', c:'#7dcea0', solid:false, decor:true, hardness:0, growthStage:1, growthTime:30, growsTo:'wheat_stage2' },
|
||
wheat_stage2:{ n:'Пшеница', c:'#b8d730', solid:false, decor:true, hardness:0, growthStage:2, growthTime:30, growsTo:'wheat_stage3' },
|
||
wheat_stage3:{ n:'Пшеница', c:'#f1c40f', solid:false, decor:true, hardness:0, harvestable:true, harvestItem:'wheat', harvestQty:2 },
|
||
carrot_stage0:{ n:'Росток моркови', c:'#a8e6a0', solid:false, decor:true, hardness:0, growthStage:0, growthTime:30, growsTo:'carrot_stage1' },
|
||
carrot_stage1:{ n:'Морковь', c:'#f0c27a', solid:false, decor:true, hardness:0, growthStage:1, growthTime:30, growsTo:'carrot_stage2' },
|
||
carrot_stage2:{ n:'Морковь', c:'#e8a040', solid:false, decor:true, hardness:0, growthStage:2, growthTime:30, growsTo:'carrot_stage3' },
|
||
carrot_stage3:{ n:'Морковь', c:'#e67e22', solid:false, decor:true, hardness:0, harvestable:true, harvestItem:'carrot', harvestQty:3 },
|
||
potato_stage0:{ n:'Росток картофеля', c:'#a8e6a0', solid:false, decor:true, hardness:0, growthStage:0, growthTime:30, growsTo:'potato_stage1' },
|
||
potato_stage1:{ n:'Картофель', c:'#c8b888', solid:false, decor:true, hardness:0, growthStage:1, growthTime:30, growsTo:'potato_stage2' },
|
||
potato_stage2:{ n:'Картофель', c:'#bfaa78', solid:false, decor:true, hardness:0, growthStage:2, growthTime:30, growsTo:'potato_stage3' },
|
||
potato_stage3:{ n:'Картофель', c:'#dfe6e9', solid:false, decor:true, hardness:0, harvestable:true, harvestItem:'potato', harvestQty:2 },
|
||
};
|
||
|
||
// === world/world-storage.js ===
|
||
// Мир-хранилище — используем state.grid и state.blocks для единого источника данных
|
||
// Это гарантирует, что все модули (включая loop.js) видят одни и те же данные
|
||
const grid = state.grid; // Map: key "gx,gy" => {gx,gy,t, ...}
|
||
const blocks = state.blocks; // Array для рендера/перебора видимых
|
||
|
||
function k(gx, gy) { return gx + ',' + gy; }
|
||
|
||
function getBlock(gx, gy) { return grid.get(k(gx, gy)); }
|
||
|
||
function hasBlock(gx, gy) { return grid.has(k(gx, gy)); }
|
||
|
||
function isSolid(gx, gy) {
|
||
const b = getBlock(gx, gy);
|
||
if (!b || b.dead) return false;
|
||
const def = BLOCKS[b.t];
|
||
return !!def.solid && !def.fluid && !def.decor;
|
||
}
|
||
|
||
function setBlock(gx, gy, t, isPlayerPlaced = false) {
|
||
const key = k(gx, gy);
|
||
if (grid.has(key)) return false;
|
||
const b = { gx, gy, t, dead: false, active: false, fuse: 0 };
|
||
grid.set(key, b);
|
||
blocks.push(b);
|
||
|
||
// Отслеживаем блоки, установленные игроком
|
||
if (isPlayerPlaced) {
|
||
state.placedBlocks.push({ gx, gy, t });
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
function removeBlock(gx, gy) {
|
||
const key = k(gx, gy);
|
||
const b = grid.get(key);
|
||
if (!b) return null;
|
||
if (BLOCKS[b.t].unbreakable) return null;
|
||
grid.delete(key);
|
||
b.dead = true;
|
||
|
||
// Отслеживаем удалённые блоки
|
||
const wasPlayerPlaced = state.placedBlocks.some(pb => pb.gx === gx && pb.gy === gy);
|
||
if (wasPlayerPlaced) {
|
||
// Удаляем из placedBlocks
|
||
state.placedBlocks = state.placedBlocks.filter(pb => !(pb.gx === gx && pb.gy === gy));
|
||
} else {
|
||
// Это природный блок - добавляем в removedBlocks
|
||
state.removedBlocks.push({ gx, gy });
|
||
}
|
||
|
||
return b;
|
||
}
|
||
|
||
// === world/generation.js ===
|
||
// Генерация (по X, на всю глубину до bedrock)
|
||
const generated = state.generated; // Set of gx already generated
|
||
|
||
// ========== BIOME SYSTEM ==========
|
||
const BIOMES = {
|
||
plains: { name:'Равнина', surface:'grass', subsurface:'dirt', trees:true, flowers:true, treeChance:0.12 },
|
||
desert: { name:'Пустыня', surface:'sand', subsurface:'sand', trees:false, flowers:false, treeChance:0 },
|
||
tundra: { name:'Тундра', surface:'snow', subsurface:'dirt', trees:true, flowers:false, treeChance:0.06 },
|
||
swamp: { name:'Болото', surface:'moss', subsurface:'dirt', trees:true, flowers:false, treeChance:0.10 },
|
||
mountains:{ name:'Горы', surface:'stone', subsurface:'stone', trees:false, flowers:false, treeChance:0 }
|
||
};
|
||
|
||
function getBiome(gx) {
|
||
const temp = Math.sin(gx*0.003 + state.worldSeed*0.01)*0.5 + Math.sin(gx*0.007 + state.worldSeed*0.02)*0.3 + 0.5;
|
||
const humid = Math.sin(gx*0.004 + state.worldSeed*0.015 + 1000)*0.5 + Math.cos(gx*0.006 + state.worldSeed*0.02 + 2000)*0.3 + 0.5;
|
||
const mtVal = Math.sin(gx*0.001 + state.worldSeed*0.005)*0.5 + 0.5;
|
||
if (temp > 0.7) return 'desert';
|
||
if (temp < 0.3) return 'tundra';
|
||
if (humid > 0.7 && temp >= 0.3 && temp <= 0.7) return 'swamp';
|
||
if (temp >= 0.5 && temp <= 0.7 && mtVal > 0.75) return 'mountains';
|
||
return 'plains';
|
||
}
|
||
|
||
const biomeCache = {};
|
||
function getCachedBiome(gx) {
|
||
const chunk = Math.floor(gx / 8);
|
||
if (biomeCache[chunk] === undefined) biomeCache[chunk] = getBiome(chunk * 8);
|
||
return biomeCache[chunk];
|
||
}
|
||
|
||
// ========== STRUCTURES ==========
|
||
function placeStructure(startGx, startGy, pattern) {
|
||
for (let dy = 0; dy < pattern.length; dy++) {
|
||
for (let dx = 0; dx < pattern[dy].length; dx++) {
|
||
const bt = pattern[dy][dx];
|
||
if (bt && bt !== 'air') {
|
||
setBlock(startGx + dx, startGy + dy, bt);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const PYRAMID_PATTERN = [
|
||
['sand','sand','sand','sand','sand','sand','sand'],
|
||
['sand','stone','stone','stone','stone','stone','sand'],
|
||
['sand','stone','air','air','air','stone','sand'],
|
||
['sand','stone','air','air','air','stone','sand'],
|
||
['sand','stone','air','air','air','stone','sand'],
|
||
['sand','sand','stone','stone','stone','sand','sand']
|
||
];
|
||
|
||
const HOUSE_PATTERN = [
|
||
['air','planks','planks','planks','planks','air'],
|
||
['planks','air','air','air','air','planks'],
|
||
['planks','air','torch','air','air','planks'],
|
||
['planks','air','air','air','air','planks'],
|
||
['planks','planks','air','planks','planks','planks']
|
||
];
|
||
|
||
const HUT_PATTERN = [
|
||
['air','wood','wood','wood','air'],
|
||
['wood','air','air','air','wood'],
|
||
['wood','air','torch','air','wood'],
|
||
['moss','moss','air','moss','moss']
|
||
];
|
||
|
||
function surfaceGyAt(gx) {
|
||
const biome = getCachedBiome(gx);
|
||
const n1 = Math.sin(gx*0.025 + state.worldSeed*0.001)*8;
|
||
const n2 = Math.sin(gx*0.012 + state.worldSeed*0.002)*12;
|
||
const n3 = Math.sin(gx*0.006 + state.worldSeed*0.003)*6;
|
||
const n4 = Math.sin(gx*0.045 + state.worldSeed*0.004)*4;
|
||
const n5 = Math.cos(gx*0.018 + state.worldSeed*0.005)*5;
|
||
let h;
|
||
switch(biome) {
|
||
case 'desert':
|
||
h = Math.floor(SEA_GY - 4 + n3*0.3 + n4*0.5);
|
||
break;
|
||
case 'tundra':
|
||
h = Math.floor(SEA_GY - 6 + n2*0.5 + n3*0.4 + n5*0.3);
|
||
break;
|
||
case 'swamp':
|
||
h = Math.floor(SEA_GY - 2 + n3*0.2 + n4*0.3);
|
||
h = Math.max(h, SEA_GY - 3);
|
||
break;
|
||
case 'mountains':
|
||
h = Math.floor(SEA_GY - 15 + n1*1.5 + n2*1.2 + n3);
|
||
break;
|
||
default: // plains
|
||
h = Math.floor(SEA_GY - 8 + n1 + n2 + n3 + n4 + n5);
|
||
}
|
||
return h;
|
||
}
|
||
|
||
function genColumn(gx) {
|
||
if (generated.has(gx)) return;
|
||
generated.add(gx);
|
||
|
||
const sgy = surfaceGyAt(gx);
|
||
const biome = getCachedBiome(gx);
|
||
|
||
// === Вода и поверхность ===
|
||
if (sgy > SEA_GY) {
|
||
for (let gy = SEA_GY; gy < sgy; gy++) {
|
||
const blockType = (biome === 'swamp' && gy >= SEA_GY - 1) ? 'swamp_water' : 'water';
|
||
setBlock(gx, gy, blockType);
|
||
}
|
||
setBlock(gx, sgy, 'sand');
|
||
} else {
|
||
const b = BIOMES[biome];
|
||
setBlock(gx, sgy, b.surface);
|
||
// болото: случайные лужи болотной воды
|
||
if (biome === 'swamp' && seededRandom(gx*3, sgy) < 0.15) {
|
||
setBlock(gx, sgy-1, 'swamp_water');
|
||
}
|
||
// тундра: лёд на воде рядом
|
||
if (biome === 'tundra' && sgy === SEA_GY && seededRandom(gx, SEA_GY-1) < 0.3) {
|
||
setBlock(gx, SEA_GY-1, 'ice');
|
||
}
|
||
}
|
||
|
||
// === Подповерхностные слои ===
|
||
for (let gy = sgy + 1; gy <= BEDROCK_GY; gy++) {
|
||
if (gy === BEDROCK_GY) {
|
||
setBlock(gx, gy, 'bedrock');
|
||
continue;
|
||
}
|
||
|
||
let t = BIOMES[biome].subsurface;
|
||
|
||
// глубже — камень
|
||
if (gy > sgy + 3) t = 'stone';
|
||
|
||
// пустыня: sand глубже
|
||
if (biome === 'desert' && gy <= sgy + 6) t = 'sand';
|
||
|
||
// болото: глина ближе к поверхности
|
||
if (biome === 'swamp' && gy <= sgy + 2 && seededRandom(gx, gy) < 0.3) t = 'clay';
|
||
|
||
// горы: gravel
|
||
if (biome === 'mountains' && gy > sgy + 4 && seededRandom(gx, gy) < 0.12) t = 'gravel';
|
||
|
||
// общие руды
|
||
const depth = gy - sgy;
|
||
const r = seededRandom(gx, gy);
|
||
if (t === 'stone') {
|
||
if (r < 0.06) t = 'coal';
|
||
else if (r < 0.10) t = 'copper_ore';
|
||
else if (r < 0.13) t = 'iron_ore';
|
||
else if (depth > 40 && r < 0.145) t = 'gold_ore';
|
||
else if (depth > 70 && r < 0.152) t = 'diamond_ore';
|
||
}
|
||
|
||
setBlock(gx, gy, t);
|
||
}
|
||
|
||
// === Растительность и декор ===
|
||
const b = BIOMES[biome];
|
||
const top = getBlock(gx, sgy);
|
||
|
||
// цветы (только plains)
|
||
if (biome === 'plains' && top && top.t === 'grass' && seededRandom(gx, sgy-1) < 0.10) {
|
||
setBlock(gx, sgy-1, 'flower');
|
||
}
|
||
|
||
// деревья
|
||
if (b.trees && seededRandom(gx*7, sgy-2) < b.treeChance) {
|
||
if (biome === 'tundra') {
|
||
const th = 3 + Math.floor(seededRandom(gx, sgy) * 3);
|
||
for (let i = 0; i < th; i++) setBlock(gx, sgy-1-i, 'wood');
|
||
for (let row = 0; row < th; row++) {
|
||
const w = Math.min(row + 1, 2);
|
||
for (let dx = -w; dx <= w; dx++) {
|
||
const ly = sgy-1-th+row;
|
||
if (ly >= 0) setBlock(gx+dx, ly, 'spruce_leaves');
|
||
}
|
||
}
|
||
} else if (biome === 'swamp') {
|
||
setBlock(gx, sgy-1, 'wood');
|
||
setBlock(gx, sgy-2, 'moss');
|
||
setBlock(gx-1, sgy-2, 'leaves');
|
||
setBlock(gx+1, sgy-2, 'leaves');
|
||
} else {
|
||
setBlock(gx, sgy-1, 'wood');
|
||
setBlock(gx, sgy-2, 'wood');
|
||
setBlock(gx, sgy-3, 'leaves');
|
||
setBlock(gx-1, sgy-3, 'leaves');
|
||
setBlock(gx+1, sgy-3, 'leaves');
|
||
}
|
||
}
|
||
|
||
// кактусы (пустыня)
|
||
if (biome === 'desert' && seededRandom(gx*11, sgy) < 0.04) {
|
||
const ch = 1 + Math.floor(seededRandom(gx, sgy+1) * 2);
|
||
for (let i = 0; i < ch; i++) setBlock(gx, sgy-1-i, 'cactus');
|
||
}
|
||
|
||
// грибы (болото)
|
||
if (biome === 'swamp' && seededRandom(gx*13, sgy) < 0.06) {
|
||
setBlock(gx, sgy-1, 'mushroom');
|
||
}
|
||
|
||
// сухие кусты (пустыня)
|
||
if (biome === 'desert' && seededRandom(gx*17, sgy) < 0.05) {
|
||
setBlock(gx, sgy-1, 'dead_bush');
|
||
}
|
||
|
||
// === Структуры мира ===
|
||
// Пирамида в пустыне
|
||
if (biome === 'desert' && ((gx % 200 + 200) % 200) === 47 && sgy < SEA_GY && sgy > SEA_GY - 5) {
|
||
placeStructure(gx, sgy - 5, PYRAMID_PATTERN);
|
||
}
|
||
// Дом в равнине
|
||
if (biome === 'plains' && ((gx % 150 + 150) % 150) === 33 && sgy < SEA_GY) {
|
||
placeStructure(gx, sgy - 4, HOUSE_PATTERN);
|
||
}
|
||
// Хижина в болоте
|
||
if (biome === 'swamp' && ((gx % 180 + 180) % 180) === 55 && sgy < SEA_GY) {
|
||
placeStructure(gx, sgy - 3, HUT_PATTERN);
|
||
}
|
||
|
||
// Применяем серверные оверрайды для этой колонны
|
||
const colPrefix = gx + ',';
|
||
for (const [key, ov] of state.serverOverrides) {
|
||
if (!key.startsWith(colPrefix)) continue;
|
||
if (ov.op === 'remove') {
|
||
const b = grid.get(key);
|
||
if (b) { grid.delete(key); b.dead = true; }
|
||
} else if (ov.op === 'set') {
|
||
if (!grid.has(key)) {
|
||
const gy = parseInt(key.split(',')[1]);
|
||
const nb = { gx, gy, t: ov.t, dead: false, active: false, fuse: 0 };
|
||
grid.set(key, nb);
|
||
blocks.push(nb);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Перегенерация видимых чанков (используется при загрузке сохранения)
|
||
function regenerateVisibleChunks() {
|
||
const gx0 = Math.floor(state.camX / TILE);
|
||
for (let gx = gx0 - GEN_MARGIN_X; gx <= gx0 + GEN_MARGIN_X; gx++) {
|
||
generated.delete(gx);
|
||
genColumn(gx);
|
||
}
|
||
}
|
||
|
||
function ensureGenAroundCamera() {
|
||
const gx0 = Math.floor(state.camX / TILE);
|
||
for (let gx = gx0 - GEN_MARGIN_X; gx <= gx0 + GEN_MARGIN_X; gx++) {
|
||
genColumn(gx);
|
||
}
|
||
}
|
||
|
||
function seededRandom(gx, gy) {
|
||
const n = Math.sin(gx * 12.9898 + gy * 78.233 + state.worldSeed * 0.1) * 43758.5453;
|
||
return n - Math.floor(n);
|
||
}
|
||
|
||
// === audio/sound-engine.js ===
|
||
// Звуковой движок
|
||
const sounds = {};
|
||
|
||
|
||
|
||
function loadSound(id, src) {
|
||
const audio = new Audio();
|
||
audio.src = src;
|
||
audio.volume = 0.3;
|
||
sounds[id] = audio;
|
||
}
|
||
|
||
// Загрузка звуков
|
||
loadSound('splash', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//splash.mp3');
|
||
loadSound('sand1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand1.mp3');
|
||
loadSound('snow1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//snow1.mp3');
|
||
loadSound('stone1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1.mp3');
|
||
loadSound('wood1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1.mp3');
|
||
loadSound('cloth1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//cloth1.mp3');
|
||
loadSound('fire', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//fire.mp3');
|
||
loadSound('hit1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hit1.mp3');
|
||
loadSound('attack', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//attack.mp3');
|
||
loadSound('hurt_chicken', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hurt1_chicken.mp3');
|
||
loadSound('stone_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1%20(1).mp3');
|
||
loadSound('wood_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1%20(1).mp3');
|
||
loadSound('click', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//click.mp3');
|
||
loadSound('explode1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//explode1.mp3');
|
||
loadSound('glass1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//glass1.mp3');
|
||
loadSound('eat1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//eat1.mp3');
|
||
loadSound('step', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand2.mp3');
|
||
|
||
function playSound(id) {
|
||
if (sounds[id]) {
|
||
sounds[id].currentTime = 0;
|
||
sounds[id].play().catch(e => console.error('Sound error:', e));
|
||
}
|
||
}
|
||
|
||
// === ui/chat.js ===
|
||
// ==================== ЧАТ ====================
|
||
|
||
|
||
|
||
|
||
const chatMessages = [];
|
||
|
||
function addChatMessage(sender, message) {
|
||
const time = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||
chatMessages.push({ sender, message, time });
|
||
if (chatMessages.length > MAX_CHAT_MESSAGES) {
|
||
chatMessages.shift();
|
||
}
|
||
renderChatMessages();
|
||
}
|
||
|
||
function renderChatMessages() {
|
||
const chatMessagesEl = document.getElementById('chatMessages');
|
||
if (!chatMessagesEl) return;
|
||
|
||
chatMessagesEl.innerHTML = chatMessages.map(m =>
|
||
`<div style="margin-bottom:4px;"><span style="color:#aaa;font-size:11px;">${m.time}</span> <strong style="color:${m.sender === 'Система' ? '#f39c12' : '#3498db'};">${m.sender}:</strong> ${m.message}</div>`
|
||
).join('');
|
||
|
||
// Прокручиваем вниз
|
||
chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
|
||
}
|
||
|
||
function sendChatMessage(message) {
|
||
if (!message || message.trim() === '') return;
|
||
|
||
if (state.isMultiplayer && state.socket && state.socket.connected) {
|
||
state.socket.emit('chat_message', { message: message.trim() });
|
||
} else {
|
||
addChatMessage('Вы', message.trim());
|
||
}
|
||
}
|
||
|
||
function initChat() {
|
||
document.getElementById('chatToggle').onclick = () => {
|
||
playSound('click');
|
||
state.chatOpen = !state.chatOpen;
|
||
document.getElementById('chatPanel').style.display = state.chatOpen ? 'block' : 'none';
|
||
if (state.chatOpen) {
|
||
document.getElementById('chatInput').focus();
|
||
}
|
||
};
|
||
|
||
document.getElementById('chatClose').onclick = () => {
|
||
playSound('click');
|
||
state.chatOpen = false;
|
||
document.getElementById('chatPanel').style.display = 'none';
|
||
};
|
||
|
||
document.getElementById('chatSend').onclick = () => {
|
||
const input = document.getElementById('chatInput');
|
||
sendChatMessage(input.value);
|
||
input.value = '';
|
||
};
|
||
|
||
document.getElementById('chatInput').addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter') {
|
||
sendChatMessage(e.target.value);
|
||
e.target.value = '';
|
||
}
|
||
});
|
||
}
|
||
|
||
// === entities/player.js ===
|
||
// Функция для расчёта урона с учётом брони
|
||
function calculateDamage(baseDamage) {
|
||
const reduction = state.player.armor;
|
||
const actualDamage = baseDamage * (1 - reduction);
|
||
return actualDamage;
|
||
}
|
||
|
||
// Инициализация игрока
|
||
function initPlayer() {
|
||
state.player = {
|
||
x: 6 * TILE,
|
||
y: 0 * TILE,
|
||
w: 34,
|
||
h: 34,
|
||
vx: 0,
|
||
vy: 0,
|
||
grounded: false,
|
||
inWater: false,
|
||
headInWater: false,
|
||
hp: 100,
|
||
hunger: 100,
|
||
o2: 100,
|
||
invuln: 0,
|
||
fallStartY: 0,
|
||
lastStepTime: 0,
|
||
sleeping: false,
|
||
inBoat: false,
|
||
armor: 0,
|
||
equippedArmor: null,
|
||
temperature: 15,
|
||
slowTimer: 0
|
||
};
|
||
|
||
state.spawnPoint = { x: 6 * TILE, y: 0 * TILE };
|
||
}
|
||
|
||
// === data/items.js ===
|
||
const ITEMS = {
|
||
meat: { n:'Сырое мясо', icon:'🥩', food:15 },
|
||
cooked: { n:'Жареное мясо', icon:'🍖', food:45 },
|
||
arrow: { n:'Стрела', icon:'➡️', stack:64 },
|
||
chicken_meat: { n:'Курица', icon:'🍗', food:12, stack:64 },
|
||
feather: { n:'Перо', icon:'🪶', stack:64 },
|
||
bone: { n:'Кость', icon:'🦴', stack:64 },
|
||
gunpowder: { n:'Порох', icon:'💥', stack:64 },
|
||
// === FARMING ITEMS ===
|
||
wheat: { n:'Пшеница', icon:'🌾', stack:64 },
|
||
bread: { n:'Хлеб', icon:'🍞', food:30 },
|
||
carrot: { n:'Морковь', icon:'🥕', food:8, stack:64 },
|
||
potato: { n:'Картофель', icon:'🥔', stack:64 },
|
||
baked_potato:{ n:'Печёная картошка', icon:'🥔', food:25 },
|
||
wheat_seeds: { n:'Семена пшеницы', icon:'🌱', stack:64 },
|
||
mushroom_stew:{ n:'Грибной суп', icon:'🍲', food:40 },
|
||
// === MOB DROP ITEMS ===
|
||
scorpion_stinger:{ n:'Жало скорпиона', icon:'🔺', stack:64 },
|
||
polar_fur: { n:'Шкура медведя', icon:'🧥', stack:64 },
|
||
slime_ball: { n:'Слизь', icon:'🟢', stack:64 },
|
||
eagle_feather:{ n:'Перо орла', icon:'🪶', stack:64 },
|
||
// === ARMOR ===
|
||
gold_armor: { n:'Золотая броня', icon:'🛡️', stack:1, armor:0.65 },
|
||
};
|
||
|
||
// Runtime extensions — предметы от обжига
|
||
ITEMS.iron_ingot = { n:'Железный слиток', icon:'🔩' };
|
||
ITEMS.gold_ingot = { n:'Золотой слиток', icon:'🪙' };
|
||
ITEMS.copper_ingot = { n:'Медный слиток', icon:'🟤' };
|
||
|
||
// Axes and shovels
|
||
ITEMS.wood_axe = { n:'Деревянный топор', icon:'🪓' };
|
||
ITEMS.stone_axe = { n:'Каменный топор', icon:'🪓' };
|
||
ITEMS.iron_axe = { n:'Железный топор', icon:'🪓' };
|
||
ITEMS.wood_shovel = { n:'Деревянная лопата', icon:'🔱' };
|
||
ITEMS.stone_shovel = { n:'Каменная лопата', icon:'🔱' };
|
||
ITEMS.iron_shovel = { n:'Железная лопата', icon:'🔱' };
|
||
|
||
// === data/tools.js ===
|
||
const TOOLS = {
|
||
wood_pickaxe: { n:'Деревянная кирка', icon:'⛏️', damage:1, durability:60, toolType:'pickaxe', toolTier:'wood', speed:2 },
|
||
stone_pickaxe: { n:'Каменная кирка', icon:'⛏️', damage:2, durability:132, toolType:'pickaxe', toolTier:'stone', speed:4 },
|
||
iron_pickaxe: { n:'Железная кирка', icon:'⛏️', damage:3, durability:250, toolType:'pickaxe', toolTier:'iron', speed:6 },
|
||
wood_sword: { n:'Деревянный меч', icon:'🗡️', damage:4, durability:60 },
|
||
stone_sword: { n:'Каменный меч', icon:'🗡️', damage:5, durability:132 },
|
||
iron_sword: { n:'Железный меч', icon:'🗡️', damage:7, durability:250 },
|
||
wood_axe: { n:'Деревянный топор', icon:'🪓', damage:3, durability:60, toolType:'axe', toolTier:'wood', speed:2 },
|
||
stone_axe: { n:'Каменный топор', icon:'🪓', damage:4, durability:132, toolType:'axe', toolTier:'stone', speed:4 },
|
||
iron_axe: { n:'Железный топор', icon:'🪓', damage:5, durability:250, toolType:'axe', toolTier:'iron', speed:6 },
|
||
wood_shovel: { n:'Деревянная лопата', icon:'🔱', damage:1, durability:60, toolType:'shovel', toolTier:'wood', speed:2 },
|
||
stone_shovel: { n:'Каменная лопата', icon:'🔱', damage:2, durability:132, toolType:'shovel', toolTier:'stone', speed:4 },
|
||
iron_shovel: { n:'Железная лопата', icon:'🔱', damage:3, durability:250, toolType:'shovel', toolTier:'iron', speed:6 },
|
||
bow: { n:'Лук', icon:'🏹', damage:10, durability:200 },
|
||
};
|
||
|
||
|
||
|
||
|
||
function addTool(type) {
|
||
const maxDur = TOOLS[type]?.durability || 60;
|
||
const key = `${type}_${Date.now()}_${Math.random().toString(36).slice(2,6)}`;
|
||
state.toolDurability.set(key, { type, current: maxDur, max: maxDur });
|
||
}
|
||
|
||
function useTool(type) {
|
||
for (const [tid, dur] of state.toolDurability) {
|
||
if (dur.type === type && dur.current > 0) {
|
||
dur.current--;
|
||
if (dur.current <= 0) {
|
||
state.toolDurability.delete(tid);
|
||
state.inv[type] = Math.max(0, (state.inv[type] || 0) - 1);
|
||
return true; // broke
|
||
}
|
||
return false; // used but not broke
|
||
}
|
||
}
|
||
return false; // no durability entry found
|
||
}
|
||
|
||
function getBestTool(blockType) {
|
||
const block = BLOCKS[blockType];
|
||
if (!block || !block.toolType) return null;
|
||
const toolType = block.toolType;
|
||
for (const tier of ['iron', 'stone', 'wood']) {
|
||
const toolId = `${tier}_${toolType}`;
|
||
if (TOOLS[toolId] && state.inv[toolId] > 0) return toolId;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function toolTierLessThan(toolTier, minTier) {
|
||
const tiers = { wood: 1, stone: 2, iron: 3 };
|
||
return (tiers[toolTier] || 0) < (tiers[minTier] || 0);
|
||
}
|
||
|
||
function getMiningSpeed(blockType) {
|
||
const block = BLOCKS[blockType];
|
||
if (!block) return 1;
|
||
const toolId = getBestTool(blockType);
|
||
if (toolId) {
|
||
const tool = TOOLS[toolId];
|
||
if (tool.toolType === block.toolType) return tool.speed;
|
||
}
|
||
return 1; // Bare hands = 1 click per hit
|
||
}
|
||
|
||
// === render/textures.js ===
|
||
// Текстуры блоков (простые)
|
||
|
||
|
||
const tex = {};
|
||
|
||
// --- Item/tool sprite generator ---\n
|
||
const itemTex = {};
|
||
|
||
function drawArrow(ctx, color) {
|
||
ctx.fillStyle = '#8B4513';
|
||
ctx.fillRect(8, 14, 16, 3);
|
||
ctx.fillStyle = color;
|
||
ctx.beginPath();
|
||
ctx.moveTo(24, 15);
|
||
ctx.lineTo(18, 10);
|
||
ctx.lineTo(18, 20);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
ctx.fillStyle = '#e74c3c';
|
||
ctx.beginPath();
|
||
ctx.moveTo(8, 15);
|
||
ctx.lineTo(4, 10);
|
||
ctx.lineTo(10, 14);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
ctx.beginPath();
|
||
ctx.moveTo(8, 16);
|
||
ctx.lineTo(4, 21);
|
||
ctx.lineTo(10, 17);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
}
|
||
|
||
function drawPickaxe(ctx, headColor, handleColor) {
|
||
ctx.fillStyle = handleColor || '#8B4513';
|
||
ctx.save();
|
||
ctx.translate(16, 16);
|
||
ctx.rotate(Math.PI / 4);
|
||
ctx.fillRect(-2, 0, 4, 18);
|
||
ctx.fillStyle = headColor;
|
||
ctx.fillRect(-8, -3, 16, 5);
|
||
ctx.fillRect(-8, -3, 3, 8);
|
||
ctx.fillRect(5, -3, 3, 8);
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawAxe(ctx, headColor, handleColor) {
|
||
ctx.fillStyle = handleColor || '#8B4513';
|
||
ctx.save();
|
||
ctx.translate(16, 16);
|
||
ctx.rotate(Math.PI / 4);
|
||
ctx.fillRect(-2, 0, 4, 18);
|
||
ctx.fillStyle = headColor;
|
||
ctx.fillRect(-2, -8, 10, 8);
|
||
ctx.fillRect(-2, -8, 4, 12);
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawShovel(ctx, headColor, handleColor) {
|
||
ctx.fillStyle = handleColor || '#8B4513';
|
||
ctx.save();
|
||
ctx.translate(16, 16);
|
||
ctx.rotate(Math.PI / 4);
|
||
ctx.fillRect(-2, 0, 4, 18);
|
||
ctx.fillStyle = headColor;
|
||
ctx.fillRect(-6, -8, 12, 8);
|
||
ctx.fillRect(-4, -12, 8, 4);
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawSword(ctx, bladeColor, guardColor) {
|
||
ctx.fillStyle = '#8B4513';
|
||
ctx.fillRect(14, 18, 4, 10);
|
||
ctx.fillStyle = guardColor || '#f1c40f';
|
||
ctx.fillRect(10, 16, 12, 3);
|
||
ctx.fillStyle = bladeColor;
|
||
ctx.beginPath();
|
||
ctx.moveTo(16, 2);
|
||
ctx.lineTo(12, 16);
|
||
ctx.lineTo(20, 16);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
}
|
||
|
||
function drawBow(ctx) {
|
||
ctx.strokeStyle = '#8B4513';
|
||
ctx.lineWidth = 3;
|
||
ctx.beginPath();
|
||
ctx.arc(12, 16, 12, -Math.PI * 0.7, Math.PI * 0.7);
|
||
ctx.stroke();
|
||
ctx.strokeStyle = '#ddd';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(12, 4);
|
||
ctx.lineTo(12, 28);
|
||
ctx.stroke();
|
||
}
|
||
|
||
function drawIngot(ctx, color) {
|
||
ctx.fillStyle = color;
|
||
ctx.beginPath();
|
||
ctx.moveTo(6, 12);
|
||
ctx.lineTo(26, 12);
|
||
ctx.lineTo(24, 24);
|
||
ctx.lineTo(8, 24);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
||
ctx.fillRect(8, 8, 16, 4);
|
||
}
|
||
|
||
function drawMeat(ctx, cooked) {
|
||
ctx.fillStyle = cooked ? '#8B4513' : '#e74c3c';
|
||
ctx.beginPath();
|
||
ctx.ellipse(16, 18, 10, 8, 0, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.fillStyle = '#f5f5dc';
|
||
ctx.fillRect(8, 6, 3, 12);
|
||
ctx.fillRect(14, 4, 3, 8);
|
||
}
|
||
|
||
function makeItemTex(id) {
|
||
const c = document.createElement('canvas');
|
||
c.width = 32; c.height = 32;
|
||
const g = c.getContext('2d');
|
||
|
||
const tierColors = {
|
||
wood: { head: '#a0522d', blade: '#a0522d' },
|
||
stone: { head: '#7f8c8d', blade: '#95a5a6' },
|
||
iron: { head: '#bdc3c7', blade: '#ecf0f1' },
|
||
};
|
||
|
||
switch (id) {
|
||
case 'arrow':
|
||
drawArrow(g, '#7f8c8d');
|
||
break;
|
||
case 'bow':
|
||
drawBow(g);
|
||
break;
|
||
case 'wood_pickaxe':
|
||
drawPickaxe(g, tierColors.wood.head, tierColors.wood.blade);
|
||
break;
|
||
case 'stone_pickaxe':
|
||
drawPickaxe(g, tierColors.stone.head);
|
||
break;
|
||
case 'iron_pickaxe':
|
||
drawPickaxe(g, tierColors.iron.head);
|
||
break;
|
||
case 'wood_axe':
|
||
drawAxe(g, tierColors.wood.head);
|
||
break;
|
||
case 'stone_axe':
|
||
drawAxe(g, tierColors.stone.head);
|
||
break;
|
||
case 'iron_axe':
|
||
drawAxe(g, tierColors.iron.head);
|
||
break;
|
||
case 'wood_shovel':
|
||
drawShovel(g, tierColors.wood.head);
|
||
break;
|
||
case 'stone_shovel':
|
||
drawShovel(g, tierColors.stone.head);
|
||
break;
|
||
case 'iron_shovel':
|
||
drawShovel(g, tierColors.iron.head);
|
||
break;
|
||
case 'wood_sword':
|
||
drawSword(g, tierColors.wood.blade, '#a0522d');
|
||
break;
|
||
case 'stone_sword':
|
||
drawSword(g, tierColors.stone.blade, '#7f8c8d');
|
||
break;
|
||
case 'iron_sword':
|
||
drawSword(g, tierColors.iron.blade, '#bdc3c7');
|
||
break;
|
||
case 'iron_ingot':
|
||
drawIngot(g, '#bdc3c7');
|
||
break;
|
||
case 'gold_ingot':
|
||
drawIngot(g, '#f1c40f');
|
||
break;
|
||
case 'copper_ingot':
|
||
drawIngot(g, '#e67e22');
|
||
break;
|
||
case 'meat':
|
||
drawMeat(g, false);
|
||
break;
|
||
case 'cooked':
|
||
drawMeat(g, true);
|
||
break;
|
||
case 'wheat':
|
||
g.fillStyle = '#f1c40f';
|
||
g.fillRect(12, 8, 3, 18);
|
||
g.fillRect(8, 6, 10, 4);
|
||
break;
|
||
case 'bread':
|
||
g.fillStyle = '#d4a030';
|
||
g.fillRect(6, 12, 20, 12);
|
||
g.fillStyle = '#c08820';
|
||
g.fillRect(6, 12, 20, 3);
|
||
break;
|
||
case 'carrot':
|
||
g.fillStyle = '#e67e22';
|
||
g.fillRect(14, 10, 4, 18);
|
||
g.fillStyle = '#27ae60';
|
||
g.fillRect(12, 6, 8, 5);
|
||
break;
|
||
case 'potato':
|
||
g.fillStyle = '#d4a050';
|
||
g.beginPath();
|
||
g.ellipse(16, 16, 8, 6, 0, 0, Math.PI * 2);
|
||
g.fill();
|
||
g.fillStyle = '#b88a40';
|
||
g.fillRect(10, 14, 2, 1);
|
||
g.fillRect(20, 16, 2, 1);
|
||
break;
|
||
case 'baked_potato':
|
||
g.fillStyle = '#c08820';
|
||
g.beginPath();
|
||
g.ellipse(16, 16, 8, 6, 0, 0, Math.PI * 2);
|
||
g.fill();
|
||
g.fillStyle = '#f5e6cc';
|
||
g.fillRect(12, 12, 8, 6);
|
||
break;
|
||
case 'mushroom_stew':
|
||
g.fillStyle = '#8B4513';
|
||
g.fillRect(10, 16, 12, 12);
|
||
g.fillStyle = '#e74c3c';
|
||
g.beginPath();
|
||
g.ellipse(16, 14, 8, 5, 0, Math.PI, 0);
|
||
g.fill();
|
||
break;
|
||
case 'scorpion_stinger':
|
||
g.fillStyle = '#d35400';
|
||
g.beginPath();
|
||
g.moveTo(16, 4);
|
||
g.lineTo(10, 28);
|
||
g.lineTo(22, 28);
|
||
g.closePath();
|
||
g.fill();
|
||
break;
|
||
case 'polar_fur':
|
||
g.fillStyle = '#ecf0f1';
|
||
g.fillRect(6, 6, 20, 20);
|
||
g.fillStyle = '#bdc3c7';
|
||
g.fillRect(12, 12, 8, 8);
|
||
break;
|
||
case 'slime_ball':
|
||
g.fillStyle = 'rgba(46,204,113,0.8)';
|
||
g.beginPath();
|
||
g.arc(16, 16, 10, 0, Math.PI * 2);
|
||
g.fill();
|
||
g.fillStyle = '#fff';
|
||
g.fillRect(10, 10, 4, 4);
|
||
g.fillRect(18, 10, 4, 4);
|
||
break;
|
||
case 'eagle_feather':
|
||
g.fillStyle = '#8B4513';
|
||
g.fillRect(14, 4, 4, 24);
|
||
g.fillStyle = '#ecf0f1';
|
||
g.fillRect(10, 6, 12, 10);
|
||
break;
|
||
case 'chicken_meat':
|
||
drawMeat(g, false);
|
||
g.fillStyle = '#fff';
|
||
g.fillRect(12, 8, 4, 4);
|
||
break;
|
||
case 'feather':
|
||
g.fillStyle = '#ecf0f1';
|
||
g.fillRect(14, 6, 4, 20);
|
||
g.fillStyle = '#bbb';
|
||
g.fillRect(12, 8, 8, 8);
|
||
break;
|
||
case 'bone':
|
||
g.fillStyle = '#f5f5dc';
|
||
g.fillRect(12, 10, 8, 14);
|
||
g.fillRect(8, 8, 4, 4);
|
||
g.fillRect(20, 8, 4, 4);
|
||
g.fillRect(8, 22, 4, 4);
|
||
g.fillRect(20, 22, 4, 4);
|
||
break;
|
||
case 'gunpowder':
|
||
g.fillStyle = '#555';
|
||
g.fillRect(8, 10, 6, 6);
|
||
g.fillRect(18, 14, 6, 6);
|
||
g.fillRect(12, 20, 6, 6);
|
||
break;
|
||
case 'gold_armor':
|
||
g.fillStyle = '#f1c40f';
|
||
g.fillRect(6, 6, 20, 20);
|
||
g.fillStyle = '#f39c12';
|
||
g.fillRect(10, 4, 12, 6);
|
||
g.fillRect(4, 10, 6, 16);
|
||
g.fillRect(22, 10, 6, 16);
|
||
break;
|
||
case 'wheat_seeds':
|
||
g.fillStyle = '#7dcea0';
|
||
g.fillRect(14, 16, 4, 10);
|
||
g.fillStyle = '#a8e6a0';
|
||
g.fillRect(10, 8, 4, 4);
|
||
g.fillRect(18, 6, 4, 4);
|
||
g.fillRect(14, 4, 4, 4);
|
||
break;
|
||
default:
|
||
g.fillStyle = '#7f8c8d';
|
||
g.fillRect(4, 4, 24, 24);
|
||
g.fillStyle = 'rgba(255,255,255,0.2)';
|
||
g.fillRect(4, 4, 24, 4);
|
||
}
|
||
return c;
|
||
}
|
||
|
||
function makeTex(type) {
|
||
const t = BLOCKS[type];
|
||
const c = document.createElement('canvas');
|
||
c.width = 32; c.height = 32;
|
||
const g = c.getContext('2d');
|
||
|
||
if (type === 'tnt') {
|
||
g.fillStyle = '#c0392b'; g.fillRect(0, 0, 32, 32);
|
||
g.fillStyle = '#fff'; g.fillRect(0, 12, 32, 8);
|
||
g.fillStyle = '#000'; g.font = 'bold 10px sans-serif'; g.fillText('TNT', 6, 20);
|
||
return c;
|
||
}
|
||
if (type === 'campfire') {
|
||
g.fillStyle = '#5d4037'; g.fillRect(4, 26, 24, 6);
|
||
g.fillStyle = '#3e2723'; g.fillRect(7, 23, 18, 4);
|
||
return c;
|
||
}
|
||
if (type === 'torch') {
|
||
g.fillStyle = '#6d4c41'; g.fillRect(14, 10, 4, 18);
|
||
g.fillStyle = '#f39c12'; g.fillRect(12, 6, 8, 8);
|
||
return c;
|
||
}
|
||
if (type === 'glass') {
|
||
g.fillStyle = 'rgba(200,240,255,0.25)'; g.fillRect(0, 0, 32, 32);
|
||
g.strokeStyle = 'rgba(255,255,255,0.65)'; g.strokeRect(2, 2, 28, 28);
|
||
g.beginPath(); g.moveTo(5, 27); g.lineTo(27, 5); g.stroke();
|
||
return c;
|
||
}
|
||
if (type === 'water') {
|
||
g.fillStyle = t.c; g.fillRect(0, 0, 32, 32);
|
||
g.fillStyle = 'rgba(255,255,255,0.08)';
|
||
g.fillRect(0, 6, 32, 2);
|
||
return c;
|
||
}
|
||
if (type === 'bed') {
|
||
g.fillStyle = '#e91e63';
|
||
g.fillRect(0, 0, 32, 32);
|
||
g.fillStyle = '#f8bbd0';
|
||
g.fillRect(2, 2, 14, 14);
|
||
g.fillStyle = '#c2185b';
|
||
g.fillRect(16, 4, 14, 24);
|
||
g.fillStyle = '#e91e63';
|
||
g.fillRect(18, 6, 10, 20);
|
||
return c;
|
||
}
|
||
if (type === 'flower') {
|
||
g.fillStyle = '#2ecc71'; g.fillRect(14, 14, 4, 18);
|
||
g.fillStyle = t.c; g.beginPath(); g.arc(16, 12, 6, 0, 6.28); g.fill();
|
||
return c;
|
||
}
|
||
if (type === 'boat') {
|
||
g.fillStyle = '#8B4513';
|
||
g.fillRect(2, 12, 28, 8);
|
||
g.fillStyle = '#A0522D';
|
||
g.fillRect(0, 10, 32, 12);
|
||
g.fillStyle = '#DEB887';
|
||
g.fillRect(4, 14, 24, 4);
|
||
g.fillStyle = '#654321';
|
||
g.fillRect(2, 20, 28, 4);
|
||
return c;
|
||
}
|
||
if (type === 'ladder') {
|
||
g.fillStyle = '#8B4513';
|
||
g.fillRect(4, 0, 4, 32);
|
||
g.fillRect(24, 0, 4, 32);
|
||
g.fillStyle = '#A0522D';
|
||
g.fillRect(4, 4, 24, 3);
|
||
g.fillRect(4, 12, 24, 3);
|
||
g.fillRect(4, 20, 24, 3);
|
||
g.fillRect(4, 28, 24, 3);
|
||
return c;
|
||
}
|
||
// === BIOME BLOCK TEXTURES ===
|
||
if (type === 'snow') {
|
||
g.fillStyle = '#ecf0f1';
|
||
g.fillRect(0, 0, 32, 32);
|
||
g.fillStyle = '#dfe6e9';
|
||
for (let i = 0; i < 4; i++) g.fillRect((Math.random()*26)|0, (Math.random()*26)|0, 5, 3);
|
||
return c;
|
||
}
|
||
if (type === 'ice') {
|
||
g.fillStyle = '#74b9ff';
|
||
g.fillRect(0, 0, 32, 32);
|
||
g.strokeStyle = 'rgba(255,255,255,0.4)';
|
||
g.beginPath(); g.moveTo(4,10); g.lineTo(20,16); g.lineTo(10,28); g.stroke();
|
||
g.beginPath(); g.moveTo(18,4); g.lineTo(28,12); g.stroke();
|
||
return c;
|
||
}
|
||
if (type === 'cactus') {
|
||
g.fillStyle = '#27ae60';
|
||
g.fillRect(6, 2, 20, 28);
|
||
g.fillStyle = '#2ecc71';
|
||
g.fillRect(2, 8, 6, 4);
|
||
g.fillRect(24, 14, 6, 4);
|
||
g.fillStyle = '#1e8449';
|
||
g.fillRect(12, 0, 2, 30);
|
||
g.fillRect(18, 0, 2, 30);
|
||
return c;
|
||
}
|
||
if (type === 'mushroom') {
|
||
g.fillStyle = '#f5e6cc'; g.fillRect(14, 20, 4, 12);
|
||
g.fillStyle = '#e74c3c'; g.beginPath(); g.arc(16, 14, 10, Math.PI, 0); g.fill();
|
||
g.fillStyle = '#fff'; g.fillRect(10, 10, 3, 3); g.fillRect(17, 8, 4, 3);
|
||
return c;
|
||
}
|
||
if (type === 'moss') {
|
||
g.fillStyle = '#0a6640';
|
||
g.fillRect(0, 0, 32, 32);
|
||
g.fillStyle = '#1e8449';
|
||
for (let i = 0; i < 6; i++) g.fillRect((Math.random()*26)|0, (Math.random()*26)|0, 4, 3);
|
||
return c;
|
||
}
|
||
if (type === 'swamp_water') {
|
||
g.fillStyle = 'rgba(100,120,40,0.6)';
|
||
g.fillRect(0, 0, 32, 32);
|
||
g.fillStyle = 'rgba(80,100,20,0.3)';
|
||
g.fillRect(0, 10, 32, 2);
|
||
g.fillRect(0, 22, 32, 2);
|
||
return c;
|
||
}
|
||
if (type === 'farmland') {
|
||
g.fillStyle = '#8B6914';
|
||
g.fillRect(0, 0, 32, 32);
|
||
g.fillStyle = '#7a5c10';
|
||
for (let i = 0; i < 4; i++) g.fillRect(0, 6+i*8, 32, 2);
|
||
g.fillStyle = '#6B4E0A';
|
||
g.fillRect(8, 2, 2, 28);
|
||
g.fillRect(18, 2, 2, 28);
|
||
return c;
|
||
}
|
||
if (type === 'dead_bush') {
|
||
g.fillStyle = '#b2bec3';
|
||
g.fillRect(12, 16, 2, 16);
|
||
g.fillRect(8, 14, 8, 2);
|
||
g.fillRect(16, 12, 8, 2);
|
||
g.fillRect(6, 18, 4, 2);
|
||
return c;
|
||
}
|
||
if (type === 'spruce_leaves') {
|
||
g.fillStyle = '#0a6640';
|
||
g.fillRect(0, 0, 32, 32);
|
||
g.fillStyle = '#0d7a4d';
|
||
for (let i = 0; i < 5; i++) g.fillRect((Math.random()*24)|0, (Math.random()*24)|0, 6, 4);
|
||
return c;
|
||
}
|
||
// CROP STAGES
|
||
if (type.startsWith('wheat_stage') || type.startsWith('carrot_stage') || type.startsWith('potato_stage')) {
|
||
const st = parseInt(type.charAt(type.length-1));
|
||
const colors = type.startsWith('wheat') ? ['#a8e6a0','#7dcea0','#b8d730','#f1c40f'] :
|
||
type.startsWith('carrot') ? ['#a8e6a0','#f0c27a','#e8a040','#e67e22'] :
|
||
['#a8e6a0','#c8b888','#bfaa78','#dfe6e9'];
|
||
g.fillStyle = '#5d4037';
|
||
g.fillRect(15, 18, 2, 14);
|
||
if (st >= 1) {
|
||
g.fillStyle = colors[st];
|
||
g.fillRect(10, 8 + (3-st)*3, 12, 10);
|
||
} else {
|
||
g.fillStyle = '#a8e6a0';
|
||
g.fillRect(14, 22, 4, 4);
|
||
}
|
||
return c;
|
||
}
|
||
|
||
g.fillStyle = t.c || '#000';
|
||
g.fillRect(0, 0, 32, 32);
|
||
|
||
g.fillStyle = 'rgba(0,0,0,0.10)';
|
||
for (let i = 0; i < 6; i++) g.fillRect((Math.random() * 28) | 0, (Math.random() * 28) | 0, 4, 4);
|
||
|
||
if (type.endsWith('_ore') || type === 'coal') {
|
||
g.fillStyle = 'rgba(0,0,0,0.35)';
|
||
for (let i = 0; i < 4; i++) g.fillRect((Math.random() * 24) | 0, (Math.random() * 24) | 0, 6, 6);
|
||
}
|
||
return c;
|
||
}
|
||
|
||
function initTextures() {
|
||
Object.keys(BLOCKS).forEach(k => tex[k] = makeTex(k));
|
||
// Item/tool sprites
|
||
const itemIds = [
|
||
'arrow','bow',
|
||
'wood_pickaxe','stone_pickaxe','iron_pickaxe',
|
||
'wood_axe','stone_axe','iron_axe',
|
||
'wood_shovel','stone_shovel','iron_shovel',
|
||
'wood_sword','stone_sword','iron_sword',
|
||
'iron_ingot','gold_ingot','copper_ingot',
|
||
'meat','cooked',
|
||
'wheat','bread','carrot','potato','baked_potato','mushroom_stew','wheat_seeds',
|
||
'chicken_meat','feather','bone','gunpowder',
|
||
'scorpion_stinger','polar_fur','slime_ball','eagle_feather',
|
||
'gold_armor',
|
||
];
|
||
itemIds.forEach(id => { itemTex[id] = makeItemTex(id); });
|
||
}
|
||
|
||
// === ui/hotbar.js ===
|
||
// Hotbar UI — 3-slot Minecraft-style bottom bar + selected item indicator
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
const HOTBAR_SLOTS = 3;
|
||
|
||
let selectedItemEl = null;
|
||
|
||
function addToRecentItems(id) {
|
||
if (!id) return;
|
||
state.recentItems = state.recentItems.filter(item => item !== id);
|
||
state.recentItems.unshift(id);
|
||
state.recentItems = state.recentItems.slice(0, 5);
|
||
}
|
||
|
||
function renderSlotIcon(el, id) {
|
||
if (BLOCKS[id] && tex[id]) {
|
||
el.style.backgroundImage = `url(${tex[id].toDataURL()})`;
|
||
el.style.backgroundSize = 'cover';
|
||
} else if (itemTex[id]) {
|
||
el.style.backgroundImage = `url(${itemTex[id].toDataURL()})`;
|
||
el.style.backgroundSize = 'cover';
|
||
} else if (ITEMS[id]) {
|
||
el.textContent = ITEMS[id].icon || '📦';
|
||
} else if (TOOLS[id]) {
|
||
el.textContent = TOOLS[id].icon || '🔧';
|
||
} else if (id === 'iron_armor') {
|
||
el.textContent = '🛡️';
|
||
el.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)';
|
||
} else {
|
||
el.textContent = id.charAt(0).toUpperCase();
|
||
el.style.background = '#555';
|
||
el.style.color = '#fff';
|
||
el.style.fontWeight = '700';
|
||
el.style.fontSize = '14px';
|
||
}
|
||
}
|
||
|
||
function updateSelectedItem() {
|
||
return; // selectedItem removed — highlight in hotbar is enough
|
||
if (!selectedItemEl) selectedItemEl = document.getElementById('selectedItem');
|
||
if (!selectedItemEl) return;
|
||
const sel = state.selected;
|
||
if (!sel || !state.inv[sel] || state.inv[sel] <= 0) {
|
||
selectedItemEl.style.display = 'none';
|
||
return;
|
||
}
|
||
selectedItemEl.style.display = 'flex';
|
||
// Clear previous content
|
||
selectedItemEl.textContent = '';
|
||
selectedItemEl.style.backgroundImage = '';
|
||
selectedItemEl.style.background = 'rgba(0,0,0,0.45)';
|
||
// Render icon
|
||
if (BLOCKS[sel] && tex[sel]) {
|
||
selectedItemEl.style.backgroundImage = `url(${tex[sel].toDataURL()})`;
|
||
selectedItemEl.style.backgroundSize = 'cover';
|
||
} else if (itemTex[sel]) {
|
||
selectedItemEl.style.backgroundImage = `url(${itemTex[sel].toDataURL()})`;
|
||
selectedItemEl.style.backgroundSize = 'cover';
|
||
} else if (ITEMS[sel]) {
|
||
selectedItemEl.textContent = ITEMS[sel].icon || '📦';
|
||
} else if (TOOLS[sel]) {
|
||
selectedItemEl.textContent = TOOLS[sel].icon || '🔧';
|
||
} else {
|
||
selectedItemEl.textContent = sel.charAt(0).toUpperCase();
|
||
}
|
||
}
|
||
|
||
function rebuildHotbar() {
|
||
const hotbarEl = state.hotbarEl;
|
||
const inv = state.inv;
|
||
const selected = state.selected;
|
||
const toolDurability = state.toolDurability;
|
||
|
||
hotbarEl.innerHTML = '';
|
||
|
||
// Get up to 3 recent items with count > 0
|
||
const items = state.recentItems.filter(id => inv[id] > 0).slice(0, HOTBAR_SLOTS);
|
||
|
||
// If selected item not in list and has count > 0, push to front
|
||
if (inv[selected] > 0 && !items.includes(selected)) {
|
||
items.unshift(selected);
|
||
if (items.length > HOTBAR_SLOTS) items.pop();
|
||
}
|
||
|
||
// 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();
|
||
}
|
||
|
||
// === render/particles.js ===
|
||
// Частицы (взрыв)
|
||
const parts = [];
|
||
|
||
function spawnExplosion(x, y, power) {
|
||
const n = Math.floor(16 + power * 10);
|
||
for (let i = 0; i < n; i++) {
|
||
parts.push({
|
||
x, y,
|
||
vx: (Math.random() - 0.5) * (300 + power * 200),
|
||
vy: (Math.random() - 0.5) * (300 + power * 200),
|
||
t: 0.7, c: '#ffa500'
|
||
});
|
||
}
|
||
}
|
||
|
||
// === world/tnt.js ===
|
||
// TNT логика: цепь + усиление
|
||
function activateTNT(b, fuse = 3.2) {
|
||
if (b.dead) return;
|
||
if (b.active) return;
|
||
b.active = true;
|
||
b.fuse = fuse;
|
||
state.activeTNT.add(k(b.gx, b.gy));
|
||
}
|
||
|
||
function explodeAt(gx, gy) {
|
||
const center = getBlock(gx, gy);
|
||
if (!center) return;
|
||
|
||
// усиление: считаем сколько TNT рядом (в радиусе 2) и активируем их «почти сразу»
|
||
let bonus = 0;
|
||
for (let x = gx - 2; x <= gx + 2; x++) {
|
||
for (let y = gy - 2; y <= gy + 2; y++) {
|
||
const b = getBlock(x, y);
|
||
if (b && !b.dead && b.t === 'tnt' && !(x === gx && y === gy)) {
|
||
bonus += 0.8;
|
||
activateTNT(b, 0.12); // цепь
|
||
}
|
||
}
|
||
}
|
||
|
||
const power = 1 + bonus; // условная мощность
|
||
const radius = 3.2 + bonus * 0.7; // радиус разрушения в тайлах
|
||
const dmgR = 150 + bonus * 60; // радиус урона в пикселях
|
||
|
||
removeBlock(gx, gy);
|
||
state.activeTNT.delete(k(gx, gy));
|
||
playSound('explode1'); // Звук взрыва
|
||
spawnExplosion(gx * TILE + TILE / 2, gy * TILE + TILE / 2, power);
|
||
|
||
for (let x = Math.floor(gx - radius); x <= Math.ceil(gx + radius); x++) {
|
||
for (let y = Math.floor(gy - radius); y <= Math.ceil(gy + radius); y++) {
|
||
const d = Math.hypot(x - gx, y - gy);
|
||
if (d > radius) continue;
|
||
const b = getBlock(x, y);
|
||
if (!b || b.dead) continue;
|
||
if (BLOCKS[b.t].fluid) continue;
|
||
if (BLOCKS[b.t].unbreakable) continue;
|
||
if (b.t === 'tnt') { activateTNT(b, 0.12); continue; }
|
||
removeBlock(x, y);
|
||
if (state.inv[b.t] !== undefined && Math.random() < 0.20) state.inv[b.t]++; // немного дропа
|
||
}
|
||
}
|
||
rebuildHotbar();
|
||
|
||
// урон
|
||
const hurt = (e) => {
|
||
const dx = (e.x + e.w / 2) - (gx * TILE + TILE / 2);
|
||
const dy = (e.y + e.h / 2) - (gy * TILE + TILE / 2);
|
||
const dist = Math.hypot(dx, dy);
|
||
if (dist < dmgR) {
|
||
const dmg = (dmgR - dist) * 0.06 * power;
|
||
if (e === state.player) {
|
||
const actualDamage = calculateDamage(dmg);
|
||
state.player.hp -= actualDamage;
|
||
} else {
|
||
e.hp -= dmg;
|
||
}
|
||
e.vx += (dx / dist || 0) * 600;
|
||
e.vy -= 320;
|
||
}
|
||
};
|
||
hurt(state.player);
|
||
state.mobs.forEach(hurt);
|
||
}
|
||
|
||
// === ui/inventory.js ===
|
||
// Inventory UI
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
function renderInventory() {
|
||
const inventoryGrid = state.inventoryGrid;
|
||
const inv = state.inv;
|
||
const selected = state.selected;
|
||
inventoryGrid.innerHTML = '';
|
||
|
||
const items = Object.keys(inv).filter(id => inv[id] > 0);
|
||
|
||
for (let i = 0; i < 21; i++) {
|
||
const slot = document.createElement('div');
|
||
slot.className = 'inv-slot' + (i < items.length && items[i] === selected ? ' sel' : '');
|
||
|
||
if (i < items.length) {
|
||
const id = items[i];
|
||
// Canvas sprite: block > itemTex > emoji fallback > unknown item fallback
|
||
if (BLOCKS[id] && tex[id]) {
|
||
slot.style.backgroundImage = `url(${tex[id].toDataURL()})`;
|
||
slot.style.backgroundSize = 'cover';
|
||
} else if (itemTex[id]) {
|
||
slot.style.backgroundImage = `url(${itemTex[id].toDataURL()})`;
|
||
slot.style.backgroundSize = 'cover';
|
||
} else if (ITEMS[id]) {
|
||
slot.textContent = ITEMS[id].icon || '📦';
|
||
} else if (TOOLS[id]) {
|
||
slot.textContent = TOOLS[id].icon || '🔧';
|
||
} else if (id === 'iron_armor') {
|
||
slot.textContent = '🛡️';
|
||
slot.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)';
|
||
} else {
|
||
// Unknown item fallback — grey box with first letter
|
||
slot.textContent = id.charAt(0).toUpperCase();
|
||
slot.style.background = '#555';
|
||
slot.style.color = '#fff';
|
||
slot.style.fontWeight = '700';
|
||
slot.style.fontSize = '14px';
|
||
}
|
||
|
||
const count = document.createElement('div');
|
||
count.className = 'inv-count';
|
||
count.textContent = inv[id];
|
||
slot.appendChild(count);
|
||
|
||
slot.onclick = () => {
|
||
playSound('click');
|
||
state.selected = id;
|
||
state.recentItems = state.recentItems.filter(item => item !== id);
|
||
state.recentItems.unshift(id);
|
||
state.recentItems = state.recentItems.slice(0, 5);
|
||
rebuildHotbar();
|
||
renderInventory();
|
||
};
|
||
|
||
// Двойной клик для надевания брони
|
||
slot.ondblclick = () => {
|
||
if (id === 'iron_armor' && inv.iron_armor > 0) {
|
||
if (state.player.equippedArmor === 'iron_armor') {
|
||
state.player.equippedArmor = null;
|
||
state.player.armor = 0;
|
||
} else {
|
||
state.player.equippedArmor = 'iron_armor';
|
||
state.player.armor = BLOCKS['iron_armor'].armor;
|
||
}
|
||
playSound('click');
|
||
renderInventory();
|
||
}
|
||
};
|
||
}
|
||
|
||
inventoryGrid.appendChild(slot);
|
||
}
|
||
}
|
||
|
||
// === game/save.js ===
|
||
// ==================== СИСТЕМА СОХРАНЕНИЯ ИГРЫ ====================
|
||
|
||
|
||
|
||
|
||
|
||
|
||
const SAVE_STORAGE_KEY = state.SAVE_KEY;
|
||
let db = null; // Оставляем для совместимости, но не используем
|
||
|
||
// Инициализация (localStorage + in-memory fallback)
|
||
function initDB() {
|
||
return new Promise((resolve) => {
|
||
console.log('Используем localStorage для сохранений (sandbox режим)');
|
||
resolve(null);
|
||
});
|
||
}
|
||
|
||
function saveGame() {
|
||
// В MP не сохраняем мир — сервер хранит блоки и seed
|
||
if (state.isMultiplayer) {
|
||
console.log('[MP] Skipping world save — server is authoritative');
|
||
return;
|
||
}
|
||
|
||
const saveData = {
|
||
version: 2,
|
||
worldSeed: state.worldSeed,
|
||
player: {
|
||
x: state.player.x,
|
||
y: state.player.y,
|
||
hp: state.player.hp,
|
||
hunger: state.player.hunger,
|
||
o2: state.player.o2
|
||
},
|
||
inventory: state.inv,
|
||
time: state.worldTime,
|
||
isNight: state.isNightTime,
|
||
dayNightCycle: state.dayNightCycle,
|
||
// Сохраняем только изменения
|
||
placedBlocks: state.placedBlocks.slice(),
|
||
removedBlocks: state.removedBlocks.slice()
|
||
};
|
||
|
||
const saveSize = JSON.stringify(saveData).length;
|
||
console.log('Сохранение: player HP:', state.player.hp, 'hunger:', state.player.hunger, 'o2:', state.player.o2);
|
||
|
||
// Пробуем сохранить в localStorage (основной метод для персистентности)
|
||
try {
|
||
localStorage.setItem(SAVE_STORAGE_KEY, JSON.stringify(saveData));
|
||
console.log(`Игра сохранена в localStorage (размер: ${saveSize} байт)`);
|
||
} catch (e) {
|
||
console.warn('Ошибка сохранения в localStorage, используем только in-memory:', e);
|
||
|
||
// Если localStorage недоступен, используем in-memory fallback
|
||
state.inMemorySave = saveData;
|
||
console.log(`Игра сохранена в памяти (размер: ${saveSize} байт)`);
|
||
}
|
||
}
|
||
|
||
function loadGame() {
|
||
return new Promise((resolve, reject) => {
|
||
// Пробуем localStorage
|
||
try {
|
||
const localSave = localStorage.getItem(SAVE_STORAGE_KEY);
|
||
if (localSave) {
|
||
const parsed = JSON.parse(localSave);
|
||
console.log('Загружено из localStorage, player HP:', parsed.player?.hp);
|
||
resolve(parsed);
|
||
return;
|
||
}
|
||
} 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);
|
||
console.log('isMultiplayer:', state.isMultiplayer);
|
||
|
||
// Миграция версий
|
||
if (saveData.version === 1) {
|
||
migrateV1toV2(saveData);
|
||
}
|
||
|
||
// В MP режиме сервер — истина, не даём локальному сейву перетирать мир
|
||
if (state.isMultiplayer) {
|
||
console.log('[MP] Skipping world seed/block restore from local save');
|
||
// Восстанавливаем только игрока и инвентарь
|
||
if (saveData.player) {
|
||
state.player.hunger = saveData.player.hunger;
|
||
state.player.o2 = saveData.player.o2;
|
||
state.player.hp = (saveData.player.hp > 0) ? saveData.player.hp : 100;
|
||
}
|
||
if (saveData.inventory) {
|
||
for (const key in saveData.inventory) {
|
||
state.inv[key] = saveData.inventory[key];
|
||
}
|
||
}
|
||
if (state.recentItems.length === 0) {
|
||
state.recentItems = Object.keys(state.inv).filter(id => state.inv[id] > 0).slice(0, 5);
|
||
}
|
||
rebuildHotbar();
|
||
if (typeof renderInventory === 'function') renderInventory();
|
||
console.log('=== applySave END (MP mode, world skipped) ===');
|
||
return;
|
||
}
|
||
|
||
// Восстанавливаем seed (только одиночная игра)
|
||
if (saveData.worldSeed !== undefined) {
|
||
state.worldSeed = saveData.worldSeed;
|
||
}
|
||
|
||
// Восстанавливаем игрока
|
||
if (saveData.player) {
|
||
state.player.x = saveData.player.x;
|
||
state.player.y = saveData.player.y;
|
||
state.player.hunger = saveData.player.hunger;
|
||
state.player.o2 = saveData.player.o2;
|
||
|
||
// Обновляем spawnPoint на позицию из сохранения
|
||
state.spawnPoint.x = state.player.x;
|
||
state.spawnPoint.y = state.player.y;
|
||
|
||
// Проверяем HP из сохранения - если <= 0, устанавливаем 100
|
||
const savedHP = saveData.player.hp;
|
||
console.log('Saved HP from file:', savedHP);
|
||
if (savedHP <= 0) {
|
||
console.log('WARNING: Saved HP is <= 0, setting to 100!');
|
||
state.player.hp = 100;
|
||
} else {
|
||
state.player.hp = savedHP;
|
||
}
|
||
console.log('player HP after restore:', state.player.hp);
|
||
console.log('spawnPoint обновлён из сохранения: x=', state.spawnPoint.x, 'y=', state.spawnPoint.y);
|
||
} else {
|
||
console.log('No player data in save, setting default HP: 100');
|
||
state.player.hp = 100;
|
||
}
|
||
|
||
console.log('=== applySave END ===');
|
||
|
||
// Восстанавливаем инвентарь
|
||
if (saveData.inventory) {
|
||
for (const key in saveData.inventory) {
|
||
state.inv[key] = saveData.inventory[key];
|
||
}
|
||
}
|
||
|
||
// Восстанавливаем время
|
||
if (saveData.time !== undefined) {
|
||
state.worldTime = saveData.time;
|
||
}
|
||
|
||
// Восстанавливаем день/ночь
|
||
if (saveData.isNight !== undefined) {
|
||
state.isNightTime = saveData.isNight;
|
||
}
|
||
if (saveData.dayNightCycle !== undefined) {
|
||
state.dayNightCycle = saveData.dayNightCycle;
|
||
}
|
||
|
||
// Перегенерируем мир по seed
|
||
regenerateVisibleChunks();
|
||
|
||
// Применяем изменения (только для v2)
|
||
if (saveData.version === 2) {
|
||
// Применяем блоки, установленные игроком
|
||
for (const block of saveData.placedBlocks) {
|
||
setBlock(block.gx, block.gy, block.t, true);
|
||
}
|
||
|
||
// Применяем удалённые блоки
|
||
for (const block of saveData.removedBlocks) {
|
||
removeBlock(block.gx, block.gy);
|
||
}
|
||
|
||
// Восстанавливаем массивы изменений
|
||
state.placedBlocks = saveData.placedBlocks || [];
|
||
state.removedBlocks = saveData.removedBlocks || [];
|
||
}
|
||
|
||
// Fill recentItems from inventory if empty (hotbar won.t render otherwise)
|
||
if (state.recentItems.length === 0) {
|
||
state.recentItems = Object.keys(state.inv).filter(id => state.inv[id] > 0).slice(0, 5);
|
||
}
|
||
rebuildHotbar();
|
||
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 ===
|
||
// Socket helpers: цвет, отправка позиции/блоков
|
||
|
||
|
||
|
||
|
||
|
||
// Генерация случайного цвета для игрока на основе socket_id
|
||
function getRandomPlayerColor(socketId) {
|
||
const colors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e91e63', '#00bcd4'];
|
||
let hash = 0;
|
||
for (let i = 0; i < socketId.length; i++) {
|
||
hash = ((hash << 5) - hash) + socketId.charCodeAt(i);
|
||
hash = hash & hash;
|
||
}
|
||
return colors[Math.abs(hash) % colors.length];
|
||
}
|
||
|
||
// Throttle для отправки позиции (10-20 раз в секунду)
|
||
let lastMoveSendTime = 0;
|
||
|
||
let lastSentX = 0, lastSentY = 0;
|
||
|
||
// Отправка позиции игрока (с throttle)
|
||
function sendPlayerPosition() {
|
||
if (!state.isMultiplayer || !state.socket || !state.socket.connected) return;
|
||
|
||
const now = performance.now() / 1000;
|
||
if (now - lastMoveSendTime < MOVE_SEND_INTERVAL) return;
|
||
|
||
// Отправляем только если позиция изменилась
|
||
const dx = Math.abs(state.player.x - lastSentX);
|
||
const dy = Math.abs(state.player.y - lastSentY);
|
||
if (dx < 1 && dy < 1) return;
|
||
|
||
lastMoveSendTime = now;
|
||
lastSentX = state.player.x;
|
||
lastSentY = state.player.y;
|
||
|
||
state.socket.emit('player_move', { x: state.player.x, y: state.player.y, player_name: state.playerName });
|
||
}
|
||
|
||
// Отправка изменения блока
|
||
function sendBlockChange(gx, gy, t, op) {
|
||
if (!state.isMultiplayer || !state.socket || !state.socket.connected) return;
|
||
|
||
state.socket.emit('block_change', { gx, gy, t, op });
|
||
}
|
||
|
||
// === ui/trade.js ===
|
||
// Trade UI — личная торговля (лицом к лицу)
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
function itemName(id) {
|
||
return BLOCKS[id]?.n || TOOLS[id]?.n || ITEMS[id]?.n || id;
|
||
}
|
||
function itemIcon(id) {
|
||
if (BLOCKS[id]) return '🟫';
|
||
if (TOOLS[id]) return TOOLS[id].icon || '🔧';
|
||
if (ITEMS[id]) return ITEMS[id].icon || '📦';
|
||
return '📦';
|
||
}
|
||
|
||
let tradeOpen = false;
|
||
let currentTradeId = null;
|
||
let myOffer = {};
|
||
let partnerOffer = {};
|
||
let iConfirmed = false;
|
||
|
||
function isTradeOpen() { return tradeOpen; }
|
||
|
||
function initTradeSocket(socket) {
|
||
// Incoming trade request
|
||
socket.on('trade_request', (data) => {
|
||
playSound('click');
|
||
// Show trade request modal
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'tradeRequestOverlay';
|
||
overlay.className = 'custom-modal-overlay';
|
||
overlay.innerHTML = `
|
||
<div class="custom-modal-box">
|
||
<div style="font-size:18px;margin-bottom:12px;">🤝 ${data.from_name} хочет торговать</div>
|
||
<div class="modal-btns">
|
||
<button id="tradeAcceptBtn" style="background:#2ecc71;padding:10px 20px;border:none;border-radius:10px;color:#fff;font-weight:900;font-size:15px;cursor:pointer;">Принять</button>
|
||
<button id="tradeDeclineBtn" style="background:#e74c3c;padding:10px 20px;border:none;border-radius:10px;color:#fff;font-weight:900;font-size:15px;cursor:pointer;">Отклонить</button>
|
||
</div>
|
||
</div>`;
|
||
document.getElementById('game').appendChild(overlay);
|
||
|
||
document.getElementById('tradeAcceptBtn').addEventListener('click', () => {
|
||
overlay.remove();
|
||
socket.emit('trade_accept', { from_socket_id: data.from });
|
||
});
|
||
document.getElementById('tradeDeclineBtn').addEventListener('click', () => {
|
||
overlay.remove();
|
||
socket.emit('trade_decline', { from_socket_id: data.from });
|
||
});
|
||
});
|
||
|
||
socket.on('trade_start', (data) => {
|
||
currentTradeId = data.trade_id;
|
||
myOffer = {};
|
||
partnerOffer = {};
|
||
iConfirmed = false;
|
||
tradeOpen = true;
|
||
renderTradeUI(data.partner_name);
|
||
state.tradePanel.style.display = 'block';
|
||
});
|
||
|
||
socket.on('trade_update', (data) => {
|
||
partnerOffer = data.player1Offer || data.player2Offer || {};
|
||
// Determine which offer is the partner's
|
||
const isP1 = state.socket && state.socket.id === data.player1Offer;
|
||
// The partner's offer is the one we didn't send
|
||
// Actually the server sends both — we figure out which is ours
|
||
// Simpler: just use the data directly
|
||
if (state.mySocketId) {
|
||
// We need to know which offer in the update is ours vs partner's
|
||
// The trade_update event doesn't tell us, but we know myOffer already
|
||
// So partnerOffer = the one that's not myOffer
|
||
}
|
||
renderTradeOffers();
|
||
});
|
||
|
||
socket.on('trade_status', (data) => {
|
||
// Update confirm buttons
|
||
const confirmBtn = document.getElementById('tradeConfirmBtn');
|
||
if (confirmBtn) {
|
||
const amIPlayer1 = true; // We need to track this
|
||
if (iConfirmed) {
|
||
confirmBtn.textContent = '✅ Ожидание...';
|
||
confirmBtn.disabled = true;
|
||
confirmBtn.style.background = '#555';
|
||
}
|
||
}
|
||
// Show partner confirmation status
|
||
const partnerStatus = document.getElementById('partnerConfirmStatus');
|
||
if (partnerStatus) {
|
||
const partnerConfirmed = data.player1Confirm || data.player2Confirm;
|
||
partnerStatus.textContent = partnerConfirmed ? '✅ Подтвердил' : '⏳ Ещё не подтвердил';
|
||
}
|
||
});
|
||
|
||
socket.on('trade_complete', (data) => {
|
||
// Add received items to inventory
|
||
const received = data.received || {};
|
||
for (const [item, qty] of Object.entries(received)) {
|
||
if (qty > 0) {
|
||
state.inv[item] = (state.inv[item] || 0) + qty;
|
||
}
|
||
}
|
||
// Remove sent items from inventory
|
||
const sent = data.sent || {};
|
||
for (const [item, qty] of Object.entries(sent)) {
|
||
if (qty > 0) {
|
||
state.inv[item] = (state.inv[item] || 0) - qty;
|
||
if (state.inv[item] < 0) state.inv[item] = 0;
|
||
}
|
||
}
|
||
addChatMessage('Система', '🤝 Сделка завершена!');
|
||
closeTrade();
|
||
});
|
||
|
||
socket.on('trade_declined', () => {
|
||
addChatMessage('Система', 'Торговля отклонена');
|
||
closeTrade();
|
||
});
|
||
|
||
socket.on('trade_cancelled', () => {
|
||
addChatMessage('Система', 'Торговля отменена');
|
||
closeTrade();
|
||
});
|
||
|
||
socket.on('trade_error', (data) => {
|
||
addChatMessage('Система', '❌ ' + data.msg);
|
||
});
|
||
}
|
||
|
||
function renderTradeUI(partnerName) {
|
||
const panel = state.tradePanel;
|
||
const content = state.tradeContent;
|
||
content.innerHTML = `
|
||
<div style="text-align:center;color:#f1c40f;font-weight:900;font-size:16px;margin-bottom:8px;">🤝 Торговля с ${partnerName}</div>
|
||
<div style="display:flex;gap:12px;">
|
||
<div id="myOfferArea" style="flex:1;">
|
||
<div style="color:#2ecc71;font-weight:900;font-size:13px;margin-bottom:4px;">Вы предлагаете:</div>
|
||
<div id="myOfferItems" style="min-height:40px;padding:6px;border-radius:8px;background:rgba(46,204,113,0.1);border:1px solid rgba(46,204,113,0.3);"></div>
|
||
<button id="tradeAddItemBtn" style="width:100%;margin-top:6px;padding:6px;background:#3498db;color:#fff;border:none;border-radius:6px;font-weight:700;cursor:pointer;">+ Добавить</button>
|
||
</div>
|
||
<div style="display:flex;align-items:center;font-size:24px;">⇄</div>
|
||
<div id="partnerOfferArea" style="flex:1;">
|
||
<div style="color:#e74c3c;font-weight:900;font-size:13px;margin-bottom:4px;">Партнёр предлагает:</div>
|
||
<div id="partnerOfferItems" style="min-height:40px;padding:6px;border-radius:8px;background:rgba(231,76,60,0.1);border:1px solid rgba(231,76,60,0.3);">
|
||
<span style="color:#888;font-size:12px;">Ожидание...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="partnerConfirmStatus" style="color:#bbb;font-size:11px;text-align:center;margin-top:6px;">⏳ Партнёр ещё не подтвердил</div>
|
||
<div style="display:flex;gap:8px;margin-top:10px;">
|
||
<button id="tradeConfirmBtn" style="flex:1;padding:8px;background:#2ecc71;color:#fff;border:none;border-radius:8px;font-weight:900;cursor:pointer;">✅ Подтвердить</button>
|
||
<button id="tradeCancelBtn" style="flex:1;padding:8px;background:#e74c3c;color:#fff;border:none;border-radius:8px;font-weight:900;cursor:pointer;">✕ Отмена</button>
|
||
</div>`;
|
||
|
||
document.getElementById('tradeAddItemBtn')?.addEventListener('click', showAddItemDialog);
|
||
document.getElementById('tradeConfirmBtn')?.addEventListener('click', confirmTrade);
|
||
document.getElementById('tradeCancelBtn')?.addEventListener('click', cancelTrade);
|
||
}
|
||
|
||
function renderTradeOffers() {
|
||
const myItems = document.getElementById('myOfferItems');
|
||
if (myItems) {
|
||
const keys = Object.keys(myOffer).filter(k => myOffer[k] > 0);
|
||
if (keys.length === 0) {
|
||
myItems.innerHTML = '<span style="color:#888;font-size:12px;">Ничего</span>';
|
||
} else {
|
||
myItems.innerHTML = keys.map(k => `<div style="color:#fff;font-size:12px;">${itemIcon(k)} ${itemName(k)} x${myOffer[k]}</div>`).join('');
|
||
}
|
||
}
|
||
}
|
||
|
||
function showAddItemDialog() {
|
||
const inv = state.inv;
|
||
const items = Object.keys(inv).filter(id => inv[id] > 0);
|
||
if (items.length === 0) {
|
||
addChatMessage('Система', 'Инвентарь пуст!');
|
||
return;
|
||
}
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'tradeItemPicker';
|
||
overlay.className = 'custom-modal-overlay';
|
||
let html = '<div class="custom-modal-box" style="max-height:80vh;overflow-y:auto;max-width:360px;"><div style="font-weight:900;font-size:16px;margin-bottom:8px;">Что предложить?</div>';
|
||
for (const id of items) {
|
||
// Show already offered count
|
||
const offered = myOffer[id] || 0;
|
||
const available = inv[id] - offered;
|
||
if (available <= 0) continue;
|
||
html += `<div style="display:flex;align-items:center;gap:8px;padding:6px;border-radius:6px;cursor:pointer;border:1px solid rgba(255,255,255,0.1);margin-bottom:4px;" class="trade-pick-item" data-item="${id}">
|
||
<span style="font-size:18px;">${itemIcon(id)}</span>
|
||
<span style="flex:1;color:#fff;font-size:13px;">${itemName(id)}</span>
|
||
<span style="color:#bbb;font-size:11px;">x${available}</span>
|
||
</div>`;
|
||
}
|
||
html += '</div></div>';
|
||
overlay.innerHTML = html;
|
||
document.getElementById('game').appendChild(overlay);
|
||
|
||
overlay.querySelectorAll('.trade-pick-item').forEach(el => {
|
||
el.addEventListener('click', () => {
|
||
const itemId = el.dataset.item;
|
||
// Ask quantity
|
||
const available = inv[itemId] - (myOffer[itemId] || 0);
|
||
const qty = Math.min(available, available > 1 ? promptForQty(itemId, available) : 1);
|
||
if (qty > 0) {
|
||
myOffer[itemId] = (myOffer[itemId] || 0) + qty;
|
||
renderTradeOffers();
|
||
// Reset confirm when offer changes
|
||
iConfirmed = false;
|
||
const btn = document.getElementById('tradeConfirmBtn');
|
||
if (btn) { btn.textContent = '✅ Подтвердить'; btn.disabled = false; btn.style.background = '#2ecc71'; }
|
||
}
|
||
overlay.remove();
|
||
});
|
||
});
|
||
}
|
||
|
||
function promptForQty(itemId, max) {
|
||
// Simple: just use 1 for now, can enhance later with a proper dialog
|
||
const val = parseInt(prompt(`Сколько ${itemName(itemId)}? (макс: ${max})`, '1'));
|
||
return isNaN(val) || val < 1 ? 1 : Math.min(val, max);
|
||
}
|
||
|
||
function confirmTrade() {
|
||
if (!currentTradeId || !state.socket) return;
|
||
|
||
// Deduct offered items from inventory
|
||
for (const [item, qty] of Object.entries(myOffer)) {
|
||
if (qty > 0) {
|
||
if (state.inv[item] < qty) {
|
||
addChatMessage('Система', `Недостаточно ${itemName(item)}!`);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Send offer + confirm
|
||
state.socket.emit('trade_offer', { trade_id: currentTradeId, offer: myOffer });
|
||
|
||
// Small delay then confirm
|
||
setTimeout(() => {
|
||
state.socket.emit('trade_confirm', { trade_id: currentTradeId });
|
||
iConfirmed = true;
|
||
const btn = document.getElementById('tradeConfirmBtn');
|
||
if (btn) { btn.textContent = '✅ Ожидание...'; btn.disabled = true; btn.style.background = '#555'; }
|
||
}, 200);
|
||
}
|
||
|
||
function cancelTrade() {
|
||
if (!currentTradeId || !state.socket) return;
|
||
state.socket.emit('trade_cancel', { trade_id: currentTradeId });
|
||
closeTrade();
|
||
}
|
||
|
||
function closeTrade() {
|
||
tradeOpen = false;
|
||
currentTradeId = null;
|
||
myOffer = {};
|
||
partnerOffer = {};
|
||
iConfirmed = false;
|
||
if (state.tradePanel) state.tradePanel.style.display = 'none';
|
||
// Remove any pick overlay
|
||
document.getElementById('tradeItemPicker')?.remove();
|
||
}
|
||
|
||
function requestTradeWithPlayer(targetName) {
|
||
if (!state.socket || !state.isMultiplayer) {
|
||
addChatMessage('Система', 'Торговля доступна только в мультиплеере');
|
||
return;
|
||
}
|
||
state.socket.emit('trade_request', { target_name: targetName });
|
||
addChatMessage('Система', `Запрос отправлен ${targetName}...`);
|
||
}
|
||
|
||
// === multiplayer/socket.js ===
|
||
// Socket.IO клиент — инициализация и все обработчики
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
// Mob sizes for server mob sync (server doesn't send w/h)
|
||
const MOB_SIZES = {
|
||
pig: { w: 34, h: 24 },
|
||
chicken: { w: 26, h: 22 },
|
||
zombie: { w: 34, h: 50 },
|
||
creeper: { w: 34, h: 50 },
|
||
skeleton: { w: 34, h: 50 },
|
||
scorpion: { w: 26, h: 26 },
|
||
polar_bear: { w: 40, h: 34 },
|
||
slime: { w: 24, h: 24 },
|
||
eagle: { w: 30, h: 22 },
|
||
};
|
||
|
||
function applyMobSize(sm) {
|
||
const s = MOB_SIZES[sm.kind];
|
||
if (s && !sm.w) { sm.w = s.w; sm.h = s.h; }
|
||
return sm;
|
||
}
|
||
|
||
let socket = null;
|
||
|
||
|
||
|
||
function initSocket() {
|
||
try {
|
||
socket = io(SERVER_URL, {
|
||
path: '/socket.io/',
|
||
transports: ['websocket', 'polling']
|
||
});
|
||
|
||
// Store socket on state for other modules
|
||
state.socket = socket;
|
||
|
||
socket.on('connect', () => {
|
||
console.log(`Connected to server: ${SERVER_URL}, socket.id: ${socket.id}, worldId: ${state.worldId}`);
|
||
state.mySocketId = socket.id;
|
||
state.isMultiplayer = true;
|
||
|
||
// Присоединяемся к миру
|
||
socket.emit('join_world', { world_id: state.worldId, player_name: state.playerName });
|
||
|
||
// Показываем в UI
|
||
if (state.worldIdEl) state.worldIdEl.textContent = state.worldId;
|
||
if (state.multiplayerStatus) state.multiplayerStatus.style.display = 'block';
|
||
|
||
// Initialize trade socket handlers
|
||
initTradeSocket(socket);
|
||
});
|
||
|
||
socket.on('connect_error', (error) => {
|
||
console.error('Socket connection error:', error);
|
||
state.isMultiplayer = false;
|
||
});
|
||
|
||
socket.on('disconnect', () => {
|
||
console.log('Disconnected from server');
|
||
state.isMultiplayer = false;
|
||
state.otherPlayers.clear();
|
||
if (state.multiplayerStatus) state.multiplayerStatus.style.display = 'none';
|
||
});
|
||
|
||
// Обработка world_state
|
||
socket.on('world_state', (data) => {
|
||
console.log('Received world_state:', data);
|
||
|
||
if (data.seed !== undefined && data.seed !== state.worldSeed) {
|
||
const oldSeed = state.worldSeed;
|
||
state.worldSeed = data.seed;
|
||
console.log('World seed changed from', oldSeed, 'to', state.worldSeed);
|
||
|
||
state.generated.clear();
|
||
grid.clear();
|
||
blocks.length = 0;
|
||
state.placedBlocks = [];
|
||
state.removedBlocks = [];
|
||
console.log('World regenerated with new seed:', state.worldSeed);
|
||
}
|
||
|
||
if (data.blocks && Array.isArray(data.blocks)) {
|
||
for (const block of data.blocks) {
|
||
const key = k(block.gx, block.gy);
|
||
state.serverOverrides.set(key, { op: block.op, t: block.t });
|
||
if (block.op === 'set') {
|
||
setBlock(block.gx, block.gy, block.t, false);
|
||
} else if (block.op === 'remove') {
|
||
removeBlock(block.gx, block.gy);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (data.time !== undefined) {
|
||
state.worldTime = data.time;
|
||
state.isNightTime = state.worldTime > 0.5;
|
||
}
|
||
// Weather from server
|
||
if (data.weatherType) {
|
||
state.weatherType = data.weatherType;
|
||
state.weatherChangeInterval = Infinity;
|
||
state.isRaining = (data.weatherType === 'rain' || data.weatherType === 'storm' || data.weatherType === 'thunder');
|
||
}
|
||
|
||
// Spawn point
|
||
{
|
||
const startGX = 6;
|
||
for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx);
|
||
const surfaceY = surfaceGyAt(startGX);
|
||
let safeGY = surfaceY - 1;
|
||
const aboveBlock = getBlock(startGX, surfaceY - 1);
|
||
if (aboveBlock && aboveBlock.t === 'water') {
|
||
for (let gy = SEA_GY - 1; gy >= 0; gy--) {
|
||
const b = getBlock(startGX, gy);
|
||
if (!b || b.dead || b.t === 'air' || b.t === 'water') continue;
|
||
safeGY = gy - 1;
|
||
break;
|
||
}
|
||
}
|
||
state.spawnPoint.x = startGX * TILE;
|
||
state.spawnPoint.y = safeGY * TILE;
|
||
console.log('Client-side spawn point:', state.spawnPoint, 'surfaceY:', surfaceY, 'safeGY:', safeGY);
|
||
}
|
||
|
||
state.player.x = state.spawnPoint.x;
|
||
state.player.y = state.spawnPoint.y;
|
||
state.player.vx = 0;
|
||
state.player.vy = 0;
|
||
state.player.fallStartY = state.player.y;
|
||
console.log('Player moved to spawn point:', state.player.x, state.player.y);
|
||
|
||
state.player.hp = 100;
|
||
state.player.hunger = 100;
|
||
state.player.o2 = 100;
|
||
state.player.invuln = 0;
|
||
// Mark that we've received authoritative world state — prevents local save override
|
||
state._worldSynced = true;
|
||
console.log('[MULTIPLAYER CONNECT] Player HP set to 100% on connect');
|
||
|
||
if (data.players && Array.isArray(data.players)) {
|
||
state.otherPlayers.clear();
|
||
for (const p of data.players) {
|
||
if (p.socket_id !== state.mySocketId) {
|
||
state.otherPlayers.set(p.socket_id, {
|
||
x: p.x,
|
||
y: p.y,
|
||
color: getRandomPlayerColor(p.socket_id),
|
||
name: p.player_name || 'Игрок'
|
||
});
|
||
}
|
||
}
|
||
state.playerCountEl.textContent = data.players.length;
|
||
}
|
||
// Server mobs
|
||
if (data.mobs && Array.isArray(data.mobs)) {
|
||
state.serverMobs.clear();
|
||
for (const m of data.mobs) {
|
||
let sm = { ...m, maxHp: m.maxHp || m.hp, vx: m.vx || 0, vy: m.vy || 0, grounded: false, inWater: false, aiT: 0, dir: m.dir || 1, dead: false, fuse: m.fuse || 0, shootCooldown: 2, speed: m.speed || 80 };
|
||
applyMobSize(sm);
|
||
state.serverMobs.set(m.id, sm);
|
||
}
|
||
}
|
||
|
||
// Set myTgId from server if provided
|
||
if (data.tg_id) state.myTgId = data.tg_id;
|
||
});
|
||
|
||
socket.on('player_joined', (data) => {
|
||
console.log('Player joined:', data.socket_id);
|
||
if (data.socket_id !== state.mySocketId) {
|
||
const spawnGX = 6;
|
||
genColumn(spawnGX);
|
||
const surfaceY = surfaceGyAt(spawnGX);
|
||
const safeSpawnX = spawnGX * TILE;
|
||
const safeSpawnY = (surfaceY - 1) * TILE;
|
||
|
||
state.otherPlayers.set(data.socket_id, {
|
||
x: safeSpawnX,
|
||
y: safeSpawnY,
|
||
color: getRandomPlayerColor(data.socket_id),
|
||
name: data.player_name || 'Игрок'
|
||
});
|
||
addChatMessage('Система', `Игрок присоединился`);
|
||
updateSaveButtonVisibility();
|
||
}
|
||
});
|
||
|
||
socket.on('player_moved', (data) => {
|
||
if (data.socket_id !== state.mySocketId && state.otherPlayers.has(data.socket_id)) {
|
||
const p = state.otherPlayers.get(data.socket_id);
|
||
p.x = data.x;
|
||
p.y = data.y;
|
||
if (data.player_name) {
|
||
p.name = data.player_name;
|
||
}
|
||
}
|
||
});
|
||
|
||
socket.on('player_left', (data) => {
|
||
console.log('Player left:', data.socket_id);
|
||
state.otherPlayers.delete(data.socket_id);
|
||
addChatMessage('Система', `Игрок покинул игру`);
|
||
updateSaveButtonVisibility();
|
||
});
|
||
|
||
// === MOB SYNC ===
|
||
socket.on('mob_spawned', (data) => {
|
||
let sm = { ...data, maxHp: data.maxHp || data.hp, vx: data.vx || 0, vy: data.vy || 0, grounded: false, inWater: false, aiT: 0, dir: data.dir || 1, dead: false, fuse: data.fuse || 0, shootCooldown: 2, speed: data.speed || 80 };
|
||
applyMobSize(sm);
|
||
state.serverMobs.set(data.id, sm);
|
||
});
|
||
|
||
socket.on('mob_positions', (arr) => {
|
||
for (const u of arr) {
|
||
const sm = state.serverMobs.get(u.id);
|
||
if (sm) { sm.x = u.x; sm.y = u.y; sm.vx = u.vx; sm.vy = u.vy; sm.dir = u.dir; sm.hp = u.hp; sm.fuse = u.fuse || 0; }
|
||
}
|
||
});
|
||
|
||
socket.on('mob_despawned', (data) => { state.serverMobs.delete(data.id); });
|
||
|
||
socket.on('mob_died', (data) => {
|
||
const sm = state.serverMobs.get(data.id);
|
||
if (sm && data.killer === state.mySocketId) {
|
||
if (sm.kind === 'chicken') playSound('hurt_chicken');
|
||
state.inv.meat += (sm.kind === 'chicken' ? 1 : 2);
|
||
if (sm.kind === 'skeleton') {
|
||
state.inv.arrow += 2 + Math.floor(Math.random() * 3);
|
||
if (Math.random() < 0.15) state.inv.bow = (state.inv.bow || 0) + 1;
|
||
}
|
||
rebuildHotbar();
|
||
}
|
||
state.serverMobs.delete(data.id);
|
||
});
|
||
|
||
socket.on('mob_hurt_ack', (data) => {
|
||
const sm = state.serverMobs.get(data.id);
|
||
if (sm) sm.hp = data.hp;
|
||
});
|
||
|
||
socket.on('mob_explode', (data) => {
|
||
explodeAt(data.gx, data.gy);
|
||
state.serverMobs.delete(data.id);
|
||
});
|
||
|
||
socket.on('mob_shoot', (data) => {
|
||
state.projectiles.push({
|
||
x: data.x, y: data.y, vx: data.vx, vy: data.vy,
|
||
dmg: data.dmg, owner: 'mob', life: data.life
|
||
});
|
||
});
|
||
|
||
socket.on('block_changed', (data) => {
|
||
const key = k(data.gx, data.gy);
|
||
state.serverOverrides.set(key, { op: data.op, t: data.t });
|
||
if (data.op === 'set') {
|
||
setBlock(data.gx, data.gy, data.t, false);
|
||
} else if (data.op === 'remove') {
|
||
removeBlock(data.gx, data.gy);
|
||
}
|
||
});
|
||
|
||
socket.on('chat_message', (data) => {
|
||
const senderName = data.socket_id === state.mySocketId ? 'Вы' : `Игрок ${data.socket_id.substring(0, 6)}`;
|
||
addChatMessage(senderName, data.message);
|
||
});
|
||
|
||
socket.on('time_sync', (data) => {
|
||
if (data.time !== undefined) {
|
||
state.worldTime = data.time;
|
||
state.isNightTime = state.worldTime > 0.5;
|
||
}
|
||
});
|
||
|
||
// Weather sync from server (multiplayer)
|
||
socket.on('weather_update', (data) => {
|
||
if (!state.isMultiplayer) return;
|
||
if (data.type) {
|
||
state.weatherType = data.type;
|
||
state.weatherChangeInterval = Infinity;
|
||
state.isRaining = (data.type === 'rain' || data.type === 'storm' || data.type === 'thunder');
|
||
}
|
||
});
|
||
|
||
} catch (e) {
|
||
console.error('Error initializing socket:', e);
|
||
state.isMultiplayer = false;
|
||
}
|
||
}
|
||
|
||
// === world/water.js ===
|
||
// Физика жидкости
|
||
const waterUpdateQueue = new Set();
|
||
let waterUpdateTimer = 0;
|
||
const WATER_UPDATE_INTERVAL = 0.05; // Обновляем воду каждые 0.05 секунды
|
||
|
||
function updateWaterPhysics(dt) {
|
||
waterUpdateTimer += dt;
|
||
if (waterUpdateTimer < WATER_UPDATE_INTERVAL) return;
|
||
waterUpdateTimer = 0;
|
||
|
||
// Ограничиваем количество водных блоков для обработки (оптимизация)
|
||
const MAX_WATER_BLOCKS_PER_UPDATE = 50;
|
||
let processedCount = 0;
|
||
|
||
// Собираем только видимые водные блоки в очередь (оптимизация)
|
||
waterUpdateQueue.clear();
|
||
const W = state.W;
|
||
const H = state.H;
|
||
const minGX = Math.floor(state.camX / TILE) - 10;
|
||
const maxGX = Math.floor((state.camX + W) / TILE) + 10;
|
||
const minGY = Math.floor(state.camY / TILE) - 10;
|
||
const maxGY = Math.floor((state.camY + H) / TILE) + 10;
|
||
|
||
for (const b of blocks) {
|
||
if (processedCount >= MAX_WATER_BLOCKS_PER_UPDATE) break;
|
||
if (!b.dead && b.t === 'water' &&
|
||
b.gx >= minGX && b.gx <= maxGX &&
|
||
b.gy >= minGY && b.gy <= maxGY) {
|
||
waterUpdateQueue.add(k(b.gx, b.gy));
|
||
processedCount++;
|
||
}
|
||
}
|
||
|
||
// Обновляем воду с ограничением глубины распространения
|
||
const processed = new Set();
|
||
const toAdd = [];
|
||
const MAX_WATER_DEPTH = 20; // Максимальная глубина распространения воды
|
||
|
||
for (const key of waterUpdateQueue) {
|
||
if (processed.has(key)) continue;
|
||
const b = grid.get(key);
|
||
if (!b || b.dead) continue;
|
||
processed.add(key);
|
||
|
||
const gx = b.gx;
|
||
const gy = b.gy;
|
||
|
||
// Проверяем глубину - не распространяем воду слишком глубоко
|
||
if (gy > SEA_GY + MAX_WATER_DEPTH) continue;
|
||
|
||
// Проверяем, можно ли воде упасть вниз
|
||
const belowKey = k(gx, gy + 1);
|
||
const below = grid.get(belowKey);
|
||
|
||
// Внизу пусто - вода создаёт новый блок внизу (но не удаляется сверху)
|
||
if (!below || below.dead) {
|
||
// Ограничиваем создание новых водных блоков
|
||
if (toAdd.length < 20) { // Максимум 20 новых блоков за обновление
|
||
toAdd.push({ gx, gy: gy + 1, t: 'water' });
|
||
processed.add(belowKey);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Если внизу не вода и не твёрдый блок - вода может течь вниз
|
||
if (!isSolid(gx, gy + 1) && below && below.t !== 'water') {
|
||
if (toAdd.length < 20) {
|
||
toAdd.push({ gx, gy: gy + 1, t: 'water' });
|
||
processed.add(belowKey);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Если внизу твёрдый блок или вода - вода растекается горизонтально
|
||
// Проверяем левую сторону
|
||
const leftKey = k(gx - 1, gy);
|
||
const left = grid.get(leftKey);
|
||
if (!left || left.dead) {
|
||
if (toAdd.length < 20) {
|
||
toAdd.push({ gx: gx - 1, gy, t: 'water' });
|
||
processed.add(leftKey);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Проверяем правую сторону
|
||
const rightKey = k(gx + 1, gy);
|
||
const right = grid.get(rightKey);
|
||
if (!right || right.dead) {
|
||
if (toAdd.length < 20) {
|
||
toAdd.push({ gx: gx + 1, gy, t: 'water' });
|
||
processed.add(rightKey);
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Применяем изменения (только добавляем новые блоки)
|
||
for (const newData of toAdd) {
|
||
const key = k(newData.gx, newData.gy);
|
||
if (!grid.has(key)) {
|
||
const b = {
|
||
gx: newData.gx,
|
||
gy: newData.gy,
|
||
t: newData.t,
|
||
dead: false,
|
||
active: false,
|
||
fuse: 0
|
||
};
|
||
grid.set(key, b);
|
||
blocks.push(b);
|
||
}
|
||
}
|
||
|
||
// Очищаем мёртвые блоки из массива
|
||
for (let i = blocks.length - 1; i >= 0; i--) {
|
||
if (blocks[i].dead) {
|
||
blocks.splice(i, 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
// === physics/water-detect.js ===
|
||
function isWaterAt(px, py) {
|
||
const gx = Math.floor(px / TILE);
|
||
const gy = Math.floor(py / TILE);
|
||
const b = getBlock(gx, gy);
|
||
return !!(b && (b.t === 'water' || b.t === 'swamp_water'));
|
||
}
|
||
|
||
function isSwampWaterAt(px, py) {
|
||
const gx = Math.floor(px / TILE);
|
||
const gy = Math.floor(py / TILE);
|
||
const b = getBlock(gx, gy);
|
||
return !!(b && b.t === 'swamp_water');
|
||
}
|
||
|
||
function updateWaterFlag(e) {
|
||
const cx = e.x + e.w / 2;
|
||
const wasInWater = e.inWater;
|
||
|
||
// В воде, если в воде хотя бы центр/ноги (чтобы корректно работать у поверхности)
|
||
const mid = isWaterAt(cx, e.y + e.h / 2);
|
||
const feet = isWaterAt(cx, e.y + e.h - 2);
|
||
e.inWater = mid || feet;
|
||
|
||
// Болотная вода
|
||
e.inSwampWater = isSwampWaterAt(cx, e.y + e.h / 2);
|
||
|
||
// Голова под водой — для кислорода/урона
|
||
e.headInWater = isWaterAt(cx, e.y + 4);
|
||
|
||
// Звук при падении в воду
|
||
if (e === state.player && !wasInWater && e.inWater && e.vy > 100) {
|
||
playSound('splash');
|
||
}
|
||
|
||
// Болотная вода — отравление
|
||
if (e === state.player && e.inSwampWater) {
|
||
state.player.hp -= 3 * (typeof dt !== 'undefined' ? dt : 0.016);
|
||
}
|
||
}
|
||
|
||
// === physics/collision.js ===
|
||
function resolveY(e) {
|
||
// Всегда пересчитываем grounded (не держим "липким")
|
||
e.grounded = false;
|
||
|
||
const x1 = e.x + 2;
|
||
const x2 = e.x + e.w - 2;
|
||
|
||
// Проверяем, находится ли игрок на лестнице (по центру)
|
||
const cx = e.x + e.w / 2;
|
||
const cy = e.y + e.h / 2;
|
||
const gx = Math.floor(cx / TILE);
|
||
const gy = Math.floor(cy / TILE);
|
||
const b = getBlock(gx, gy);
|
||
const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable;
|
||
|
||
// Если на лестнице - можно двигаться вверх/вниз
|
||
if (onLadder) {
|
||
e.grounded = true;
|
||
|
||
// Если нажимаем прыжок на лестнице - поднимаемся
|
||
if (state.inp.jump) {
|
||
e.vy = -200;
|
||
}
|
||
// Если нажимаем вниз - спускаемся
|
||
else if (state.inp.down) {
|
||
e.vy = 100;
|
||
}
|
||
// Иначе - остаёмся на месте (нет гравитации)
|
||
else {
|
||
e.vy = 0;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Проверяем, можно ли запрыгнуть на лестницу рядом (слева или справа)
|
||
const leftGX = Math.floor((e.x - 4) / TILE);
|
||
const rightGX = Math.floor((e.x + e.w + 4) / TILE);
|
||
const playerGY = Math.floor((e.y + e.h / 2) / TILE);
|
||
|
||
const leftBlock = getBlock(leftGX, playerGY);
|
||
const rightBlock = getBlock(rightGX, playerGY);
|
||
const leftLadder = leftBlock && BLOCKS[leftBlock.t] && BLOCKS[leftBlock.t].climbable;
|
||
const rightLadder = rightBlock && BLOCKS[rightBlock.t] && BLOCKS[rightBlock.t].climbable;
|
||
|
||
// Если рядом есть лестница и игрок прыгает - притягиваем к ней
|
||
if ((leftLadder || rightLadder) && state.inp.jump && e.vy < 0) {
|
||
// Перемещаем игрока к лестнице
|
||
if (leftLadder && e.x > leftGX * TILE + TILE / 2) {
|
||
e.x = leftGX * TILE + TILE / 2 - e.w / 2;
|
||
} else if (rightLadder && e.x < rightGX * TILE + TILE / 2) {
|
||
e.x = rightGX * TILE + TILE / 2 - e.w / 2;
|
||
}
|
||
e.grounded = true;
|
||
e.vy = -150; // меньший прыжок при запрыгивании на лестницу
|
||
return;
|
||
}
|
||
|
||
// 1) Если движемся вниз ИЛИ стоим (vy >= 0) — проверяем опору под ногами
|
||
// Берём точку на 1px ниже стопы, чтобы не зависеть от ровного попадания в границу тайла.
|
||
if (e.vy >= 0) {
|
||
const probeY = e.y + e.h + 1;
|
||
const gy = Math.floor(probeY / TILE);
|
||
const gxA = Math.floor(x1 / TILE);
|
||
const gxB = Math.floor(x2 / TILE);
|
||
|
||
if (isSolid(gxA, gy) || isSolid(gxB, gy)) {
|
||
e.y = gy * TILE - e.h; // прижимаем к полу
|
||
e.vy = 0;
|
||
e.grounded = true;
|
||
|
||
// урон от падения — только игроку и только не в воде
|
||
if (e === state.player && !state.player.inWater) {
|
||
const fallTiles = (e.y - e.fallStartY) / TILE;
|
||
if (fallTiles > 6) {
|
||
const damage = calculateDamage((fallTiles - 6) * 10);
|
||
state.player.hp -= damage;
|
||
}
|
||
}
|
||
if (e === state.player) e.fallStartY = e.y;
|
||
}
|
||
}
|
||
|
||
// 2) Проверка на возможность запрыгнуть на блок, если игрок рядом с ним
|
||
if (e.vy < 0 && e === state.player) {
|
||
const gy = Math.floor(e.y / TILE);
|
||
const gxA = Math.floor(x1 / TILE);
|
||
const gxB = Math.floor(x2 / TILE);
|
||
|
||
// Проверяем, есть ли блок рядом с игроком
|
||
if ((isSolid(gxA, gy) || isSolid(gxB, gy)) && !isSolid(gxA, gy - 1) && !isSolid(gxB, gy - 1)) {
|
||
e.y = (gy + 1) * TILE;
|
||
e.vy = 0;
|
||
e.grounded = true;
|
||
if (e === state.player) e.fallStartY = e.y;
|
||
console.log("Jumped onto block!");
|
||
}
|
||
}
|
||
|
||
// 2) Если движемся вверх — проверяем потолок
|
||
if (e.vy < 0) {
|
||
const gy = Math.floor(e.y / TILE);
|
||
const gxA = Math.floor(x1 / TILE);
|
||
const gxB = Math.floor(x2 / TILE);
|
||
if (isSolid(gxA, gy) || isSolid(gxB, gy)) {
|
||
e.y = (gy + 1) * TILE;
|
||
e.vy = 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
function resolveX(e) {
|
||
const y1 = e.y + 2;
|
||
const y2 = e.y + e.h - 2;
|
||
|
||
// Проверяем, находимся ли мы на лестнице
|
||
const cx = e.x + e.w / 2;
|
||
const cy = e.y + e.h / 2;
|
||
const gx = Math.floor(cx / TILE);
|
||
const gy = Math.floor(cy / TILE);
|
||
const b = getBlock(gx, gy);
|
||
const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable;
|
||
|
||
if (e.vx > 0) {
|
||
const gx = Math.floor((e.x + e.w) / TILE);
|
||
const gyA = Math.floor(y1 / TILE);
|
||
const gyB = Math.floor(y2 / TILE);
|
||
const solidA = isSolid(gx, gyA);
|
||
const solidB = isSolid(gx, gyB);
|
||
|
||
if (solidA || solidB) {
|
||
e.x = gx * TILE - e.w;
|
||
e.vx = 0;
|
||
}
|
||
} else if (e.vx < 0) {
|
||
const gx = Math.floor(e.x / TILE);
|
||
const gyA = Math.floor(y1 / TILE);
|
||
const gyB = Math.floor(y2 / TILE);
|
||
const solidA = isSolid(gx, gyA);
|
||
const solidB = isSolid(gx, gyB);
|
||
|
||
if (solidA || solidB) {
|
||
e.x = (gx + 1) * TILE;
|
||
e.vx = 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
// === entities/mob-ai.js ===
|
||
// Моб AI
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
function mobAI(m, dt) {
|
||
updateWaterFlag(m);
|
||
|
||
if (m.kind === 'zombie') {
|
||
const night = isNight();
|
||
if (!night) { m.hp -= 30 * dt; m.vx *= 0.5; return; }
|
||
const dir = Math.sign((state.player.x) - m.x);
|
||
m.vx = dir * m.speed;
|
||
if (m.inWater && Math.random() < 0.06) m.vy = -260;
|
||
if (Math.abs((m.x + m.w / 2) - (state.player.x + state.player.w / 2)) < 28 &&
|
||
Math.abs((m.y + m.h / 2) - (state.player.y + state.player.h / 2)) < 40 &&
|
||
state.player.invuln <= 0) {
|
||
const damage = calculateDamage(15);
|
||
state.player.hp -= damage;
|
||
state.player.invuln = 0.8;
|
||
state.player.vx += dir * 420;
|
||
state.player.vy -= 260;
|
||
playSound('hit1');
|
||
}
|
||
} else if (m.kind === 'creeper') {
|
||
const night = isNight();
|
||
if (!night) { m.hp -= 30 * dt; m.vx *= 0.5; return; }
|
||
const dir = Math.sign((state.player.x) - m.x);
|
||
const dist = Math.hypot((state.player.x + state.player.w / 2) - (m.x + m.w / 2), (state.player.y + state.player.h / 2) - (m.y + m.h / 2));
|
||
m.vx = dir * m.speed;
|
||
if (m.inWater && Math.random() < 0.06) m.vy = -260;
|
||
if (dist < 60) {
|
||
m.fuse -= dt;
|
||
if (m.fuse <= 0) {
|
||
explodeAt(Math.floor((m.x + m.w / 2) / TILE), Math.floor((m.y + m.h / 2) / TILE));
|
||
m.hp = 0;
|
||
}
|
||
} else {
|
||
if (dist < 40) {
|
||
m.fuse = 0.5;
|
||
}
|
||
}
|
||
} else if (m.kind === 'skeleton') {
|
||
const night = isNight();
|
||
if (!night) { m.hp -= 30 * dt; m.vx *= 0.5; return; }
|
||
const dir = Math.sign((state.player.x) - m.x);
|
||
const dist = Math.hypot((state.player.x + state.player.w / 2) - (m.x + m.w / 2), (state.player.y + state.player.h / 2) - (m.y + m.h / 2));
|
||
m.vx = dir * m.speed;
|
||
if (m.inWater && Math.random() < 0.06) m.vy = -260;
|
||
m.shootCooldown -= dt;
|
||
if (dist < 300 && m.shootCooldown <= 0) {
|
||
m.shootCooldown = 2.0;
|
||
const dx = (state.player.x + state.player.w / 2) - (m.x + m.w / 2);
|
||
const dy = (state.player.y + state.player.h / 2) - (m.y + m.h / 2);
|
||
const angle = Math.atan2(dy, dx);
|
||
const speed = 450;
|
||
state.projectiles.push({
|
||
x: m.x + m.w / 2,
|
||
y: m.y + m.h / 3,
|
||
vx: Math.cos(angle) * speed,
|
||
vy: Math.sin(angle) * speed,
|
||
dmg: 6,
|
||
owner: 'mob',
|
||
life: 3
|
||
});
|
||
}
|
||
} else if (m.kind === 'scorpion') {
|
||
// Скорпион — бежит к игроку, яд (замедление)
|
||
const dir = Math.sign((state.player.x) - m.x);
|
||
m.vx = dir * m.speed;
|
||
if (m.inWater && Math.random() < 0.06) m.vy = -260;
|
||
if (Math.abs((m.x + m.w / 2) - (state.player.x + state.player.w / 2)) < 28 &&
|
||
Math.abs((m.y + m.h / 2) - (state.player.y + state.player.h / 2)) < 40 &&
|
||
state.player.invuln <= 0) {
|
||
const damage = calculateDamage(8);
|
||
state.player.hp -= damage;
|
||
state.player.invuln = 0.8;
|
||
state.player.slowTimer = 3;
|
||
state.player.vx += dir * 300;
|
||
state.player.vy -= 200;
|
||
playSound('hit1');
|
||
}
|
||
} else if (m.kind === 'polar_bear') {
|
||
// Белый медведь — нейтрален, бродит
|
||
m.aiT -= dt;
|
||
if (m.aiT <= 0) {
|
||
m.aiT = 2.0 + Math.random() * 3;
|
||
m.dir = Math.random() < 0.5 ? -1 : 1;
|
||
if (Math.random() < 0.3) m.dir = 0;
|
||
}
|
||
m.vx = m.dir * m.speed;
|
||
if (m.inWater) m.vy = -120;
|
||
} else if (m.kind === 'slime') {
|
||
// Слизь — прыгает к игроку
|
||
const dir = Math.sign((state.player.x + state.player.w / 2) - (m.x + m.w / 2));
|
||
m.aiT -= dt;
|
||
if (m.aiT <= 0) {
|
||
m.aiT = 1.5 + Math.random() * 1.5;
|
||
m.dir = dir;
|
||
m.vy = -200;
|
||
}
|
||
m.vx = m.dir * m.speed;
|
||
} else if (m.kind === 'eagle') {
|
||
// Орёл — летает, атакует пикированием
|
||
const dx = (state.player.x + state.player.w / 2) - (m.x + m.w / 2);
|
||
const dy = (state.player.y + state.player.h / 2) - (m.y + m.h / 2);
|
||
const dist = Math.hypot(dx, dy);
|
||
if (dist < 400) {
|
||
m.vx = Math.sign(dx) * m.speed;
|
||
m.vy = dy > 0 ? 60 : -60;
|
||
} else {
|
||
m.aiT -= dt;
|
||
if (m.aiT <= 0) {
|
||
m.aiT = 2 + Math.random() * 3;
|
||
m.dir = Math.random() < 0.5 ? -1 : 1;
|
||
}
|
||
m.vx = m.dir * m.speed * 0.5;
|
||
m.vy = Math.sin(performance.now() / 1000) * 30;
|
||
}
|
||
// Атака при касании
|
||
if (dist < 30 && state.player.invuln <= 0) {
|
||
const damage = calculateDamage(10);
|
||
state.player.hp -= damage;
|
||
state.player.invuln = 0.8;
|
||
state.player.vy -= 200;
|
||
playSound('hit1');
|
||
}
|
||
// Орёл не падает — летающий моб
|
||
m.vy *= 0.5;
|
||
m.grounded = false;
|
||
} else {
|
||
// животные (pig, chicken)
|
||
m.aiT -= dt;
|
||
if (m.aiT <= 0) {
|
||
m.aiT = 1.8 + Math.random() * 2.5;
|
||
m.dir = Math.random() < 0.5 ? -1 : 1;
|
||
if (Math.random() < 0.25) m.dir = 0;
|
||
}
|
||
m.vx = m.dir * (m.kind === 'chicken' ? 55 : 40);
|
||
if (m.inWater) m.vy = -120;
|
||
}
|
||
|
||
// Мобы прыгают при столкновении со стеной
|
||
if (m.grounded && Math.abs(m.vx) > 5 && m.kind !== 'eagle') {
|
||
const probeX = m.vx > 0 ? m.x + m.w + 2 : m.x - 2;
|
||
const probeGY = Math.floor((m.y + m.h - 1) / TILE);
|
||
if (isSolid(Math.floor(probeX / TILE), probeGY)) {
|
||
m.vy = -280;
|
||
}
|
||
}
|
||
|
||
// физика моба
|
||
const g = (m.inWater && m.kind !== 'eagle') ? GRAV_WATER : GRAV;
|
||
m.vy += g * dt;
|
||
|
||
m.y += m.vy * dt; m.grounded = false; resolveY(m);
|
||
m.x += m.vx * dt; resolveX(m);
|
||
}
|
||
|
||
function isNight() {
|
||
return state.worldTime > 0.5;
|
||
}
|
||
|
||
// Mob loot table
|
||
function getMobLoot(kind) {
|
||
const table = {
|
||
chicken: [{item:'chicken_meat',min:1,max:2,chance:1},{item:'feather',min:0,max:1,chance:0.5}],
|
||
pig: [{item:'meat',min:1,max:2,chance:1}],
|
||
zombie: [{item:'meat',min:0,max:1,chance:0.5},{item:'iron_ingot',min:0,max:1,chance:0.3}],
|
||
skeleton: [{item:'arrow',min:2,max:4,chance:1},{item:'bone',min:0,max:2,chance:0.6},{item:'bow',min:1,max:1,chance:0.15}],
|
||
creeper: [{item:'gunpowder',min:1,max:2,chance:1}],
|
||
scorpion: [{item:'scorpion_stinger',min:0,max:1,chance:0.4}],
|
||
polar_bear:[{item:'polar_fur',min:1,max:2,chance:0.8},{item:'meat',min:1,max:2,chance:1}],
|
||
slime: [{item:'slime_ball',min:1,max:2,chance:1}],
|
||
eagle: [{item:'eagle_feather',min:1,max:2,chance:0.7}]
|
||
};
|
||
return table[kind] || [];
|
||
}
|
||
|
||
// === data/recipes.js ===
|
||
const RECIPES = [
|
||
{ out:'planks', qty:4, cost:{ wood:1 } },
|
||
{ out:'ladder', qty:3, cost:{ planks:7 } },
|
||
{ out:'torch', qty:2, cost:{ coal:1, planks:1 } },
|
||
{ out:'glass', qty:1, cost:{ sand:3 } },
|
||
{ out:'brick', qty:1, cost:{ stone:2, clay:1 } },
|
||
{ out:'campfire', qty:1, cost:{ wood:1, coal:1 } },
|
||
{ out:'tnt', qty:1, cost:{ sand:2, coal:1 } },
|
||
{ out:'bed', qty:1, cost:{ wood: 3, planks: 3 } },
|
||
{ out:'boat', qty:1, cost:{ wood: 5 } },
|
||
{ out:'wood_pickaxe', qty:1, cost:{ wood: 3, planks: 2 } },
|
||
{ out:'stone_pickaxe', qty:1, cost:{ wood: 1, stone: 3 } },
|
||
{ out:'iron_pickaxe', qty:1, cost:{ wood: 1, iron_ore: 2 } },
|
||
{ out:'wood_sword', qty:1, cost:{ wood: 2, planks: 1 } },
|
||
{ out:'stone_sword', qty:1, cost:{ wood: 1, stone: 2 } },
|
||
{ out:'iron_sword', qty:1, cost:{ wood: 1, iron_ore: 1 } },
|
||
{ out:'wood_axe', qty:1, cost:{ wood:3, planks:2 } },
|
||
{ out:'stone_axe', qty:1, cost:{ wood:1, stone:3 } },
|
||
{ out:'iron_axe', qty:1, cost:{ wood:1, iron_ore:2 } },
|
||
{ out:'wood_shovel', qty:1, cost:{ wood:1, planks:1 } },
|
||
{ out:'stone_shovel', qty:1, cost:{ wood:1, stone:2 } },
|
||
{ out:'iron_shovel', qty:1, cost:{ wood:1, iron_ore:1 } },
|
||
{ out:'iron_armor', qty:1, cost:{ iron_ore: 5 } },
|
||
{ out:'gold_armor', qty:1, cost:{ gold_ore: 8 } },
|
||
{ out:'furnace', qty:1, cost:{ stone: 8 } },
|
||
{ out:'bow', qty:1, cost:{ wood: 3, planks: 2 } },
|
||
{ out:'arrow', qty:4, cost:{ stone: 1, wood: 1 } },
|
||
{ out:'flag', qty:1, cost:{ planks:3, iron_ore:1 } },
|
||
{ out:'bread', qty:1, cost:{ wheat: 3 } },
|
||
{ out:'farmland', qty:1, cost:{ dirt:1 } },
|
||
{ out:'wheat_seeds', qty:1, cost:{ farmland:1, dirt:1 } },
|
||
];
|
||
|
||
// Рецепты печи (обжиг)
|
||
const SMELTING_RECIPES = [
|
||
{ in:'sand', qty:1, out:'glass', outQty:1, time:3 },
|
||
{ in:'clay', qty:1, out:'brick', outQty:1, time:3 },
|
||
{ in:'iron_ore', qty:1, out:'iron_ingot', outQty:1, time:5 },
|
||
{ in:'gold_ore', qty:1, out:'gold_ingot', outQty:1, time:5 },
|
||
{ in:'copper_ore', qty:1, out:'copper_ingot', outQty:1, time:4 },
|
||
{ in:'meat', qty:1, out:'cooked', outQty:1, time:2 },
|
||
{ in:'cobblestone', qty:1, out:'stone', outQty:1, time:2 },
|
||
{ in:'potato', qty:1, out:'baked_potato', outQty:1, time:2 }
|
||
];
|
||
|
||
// === ui/furnace.js ===
|
||
// ==================== ПЕЧЬ (ОБЖИГ) ====================
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
const furnacePanel = document.getElementById('furnacePanel');
|
||
const furnaceContent = document.getElementById('furnaceContent');
|
||
|
||
function initFurnace() {
|
||
document.getElementById('furnaceClose').onclick = () => {
|
||
furnacePanel.style.display = 'none';
|
||
state.currentFurnaceKey = null;
|
||
};
|
||
}
|
||
|
||
function openFurnaceUI(gx, gy) {
|
||
state.currentFurnaceKey = `${gx},${gy}`;
|
||
furnacePanel.style.display = 'block';
|
||
renderFurnaceUI();
|
||
}
|
||
|
||
function renderFurnaceUI() {
|
||
if (!state.currentFurnaceKey) return;
|
||
|
||
// Проверяем что печь всё ещё существует
|
||
const [fgx, fgy] = state.currentFurnaceKey.split(',').map(Number);
|
||
const fb = getBlock(fgx, fgy);
|
||
if (!fb || fb.t !== 'furnace') {
|
||
furnacePanel.style.display = 'none';
|
||
state.currentFurnaceKey = null;
|
||
return;
|
||
}
|
||
|
||
// Текущий процесс обжига
|
||
const active = state.activeFurnaces.get(state.currentFurnaceKey);
|
||
|
||
let html = '<div style="color:#fff;font-size:13px;">';
|
||
|
||
// Доступные рецепты — показываем только те, для которых есть ресурсы
|
||
for (let i = 0; i < SMELTING_RECIPES.length; i++) {
|
||
const recipe = SMELTING_RECIPES[i];
|
||
const haveCount = state.inv[recipe.in] || 0;
|
||
const canSmelt = haveCount >= recipe.qty;
|
||
|
||
// Иконка результата
|
||
const outDef = BLOCKS[recipe.out];
|
||
const outItem = ITEMS[recipe.out];
|
||
const iconStr = outItem ? outItem.icon : (outDef ? '🧱' : '❓');
|
||
const nameStr = outItem ? outItem.n : (outDef ? outDef.n : recipe.out);
|
||
const inItem = ITEMS[recipe.in];
|
||
const inDef = BLOCKS[recipe.in];
|
||
const inName = inItem ? inItem.n : (inDef ? inDef.n : recipe.in);
|
||
|
||
html += `<div class="recipe">`;
|
||
html += `<div class="ricon" style="font-size:24px;display:flex;align-items:center;justify-content:center;">${iconStr}</div>`;
|
||
html += `<div class="rinfo">`;
|
||
html += `<div class="rname">${nameStr}</div>`;
|
||
html += `<div class="rcost">${inName} x${recipe.qty} (есть: ${haveCount}) • ${recipe.time}с</div>`;
|
||
html += `</div>`;
|
||
html += `<button class="rcraft" onclick="window._smelt(${i})" ${canSmelt ? '' : 'disabled'}>🔥</button>`;
|
||
html += `</div>`;
|
||
}
|
||
|
||
// Текущий прогресс
|
||
if (active) {
|
||
const pct = Math.min(100, Math.floor((active.progress / active.recipe.time) * 100));
|
||
html += `<div style="margin-top:10px;padding:8px;background:rgba(255,255,255,0.1);border-radius:8px;">`;
|
||
html += `<div style="color:#f39c12;font-weight:900;">🔥 Обжиг: ${pct}%</div>`;
|
||
html += `<div style="background:#333;height:8px;border-radius:4px;margin-top:4px;">`;
|
||
html += `<div style="background:#f39c12;height:8px;border-radius:4px;width:${pct}%;"></div>`;
|
||
html += `</div></div>`;
|
||
}
|
||
|
||
html += '</div>';
|
||
furnaceContent.innerHTML = html;
|
||
}
|
||
|
||
// Глобальная функция для кнопки обжига
|
||
window._smelt = (recipeIdx) => {
|
||
if (!state.currentFurnaceKey) return;
|
||
const recipe = SMELTING_RECIPES[recipeIdx];
|
||
if ((state.inv[recipe.in] || 0) < recipe.qty) return;
|
||
|
||
// Уже обжигаем в этой печи?
|
||
if (state.activeFurnaces.has(state.currentFurnaceKey)) return;
|
||
|
||
// Забираем ресурсы
|
||
state.inv[recipe.in] -= recipe.qty;
|
||
|
||
// Запускаем обжиг
|
||
state.activeFurnaces.set(state.currentFurnaceKey, {
|
||
recipe: recipe,
|
||
progress: 0
|
||
});
|
||
|
||
playSound('fire');
|
||
rebuildHotbar();
|
||
renderFurnaceUI();
|
||
};
|
||
|
||
// Тик печей — вызывается в главном цикле
|
||
function tickFurnaces(dt) {
|
||
for (const [key, furnace] of state.activeFurnaces) {
|
||
furnace.progress += dt;
|
||
if (furnace.progress >= furnace.recipe.time) {
|
||
// Обжиг завершён — выдаём результат
|
||
const outItem = furnace.recipe.out;
|
||
if (ITEMS[outItem]) {
|
||
state.inv[outItem] = (state.inv[outItem] || 0) + furnace.recipe.outQty;
|
||
} else if (BLOCKS[outItem]) {
|
||
state.inv[outItem] = (state.inv[outItem] || 0) + furnace.recipe.outQty;
|
||
}
|
||
playSound('stone_build');
|
||
state.activeFurnaces.delete(key);
|
||
|
||
// Если эта печь открыта — обновляем UI
|
||
if (key === state.currentFurnaceKey) {
|
||
renderFurnaceUI();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// === 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);
|
||
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/mobs.js ===
|
||
// Сущности: животные + зомби + новые мобы
|
||
|
||
class Entity {
|
||
constructor(x, y, w, h) {
|
||
this.x = x;
|
||
this.y = y;
|
||
this.w = w;
|
||
this.h = h;
|
||
this.vx = 0;
|
||
this.vy = 0;
|
||
this.hp = 3;
|
||
this.grounded = false;
|
||
this.inWater = false;
|
||
this.aiT = 0;
|
||
this.dir = 1;
|
||
}
|
||
}
|
||
|
||
class Pig extends Entity {
|
||
constructor(x, y) { super(x, y, 34, 24); this.kind = 'pig'; this.hp = 2; }
|
||
}
|
||
|
||
class Chicken extends Entity {
|
||
constructor(x, y) { super(x, y, 26, 22); this.kind = 'chicken'; this.hp = 1; }
|
||
}
|
||
|
||
class Zombie extends Entity {
|
||
constructor(x, y) { super(x, y, 34, 50); this.kind = 'zombie'; this.hp = 4; this.speed = 80 + Math.random() * 40; }
|
||
}
|
||
|
||
class Creeper extends Entity {
|
||
constructor(x, y) { super(x, y, 34, 50); this.kind = 'creeper'; this.hp = 4; this.speed = 60 + Math.random() * 30; this.fuse = 3.2; }
|
||
}
|
||
|
||
class Skeleton extends Entity {
|
||
constructor(x, y) { super(x, y, 34, 50); this.kind = 'skeleton'; this.hp = 4; this.speed = 70 + Math.random() * 30; this.shootCooldown = 0; }
|
||
}
|
||
|
||
class Scorpion extends Entity {
|
||
constructor(x, y) { super(x, y, 26, 26); this.kind = 'scorpion'; this.hp = 3; this.speed = 90; this.fuse = 0; this.shootCooldown = 0; }
|
||
}
|
||
|
||
class PolarBear extends Entity {
|
||
constructor(x, y) { super(x, y, 40, 34); this.kind = 'polar_bear'; this.hp = 8; this.speed = 50; this.fuse = 0; this.shootCooldown = 2; }
|
||
}
|
||
|
||
class Slime extends Entity {
|
||
constructor(x, y) { super(x, y, 24, 24); this.kind = 'slime'; this.hp = 2; this.speed = 30; this.fuse = 0; this.shootCooldown = 2; }
|
||
}
|
||
|
||
class Eagle extends Entity {
|
||
constructor(x, y) { super(x, y, 30, 22); this.kind = 'eagle'; this.hp = 3; this.speed = 120; this.fuse = 0; this.shootCooldown = 0; }
|
||
}
|
||
|
||
// === ui/minimap.js ===
|
||
// ==================== МИНИКАРТА ====================
|
||
|
||
|
||
|
||
|
||
|
||
|
||
const minimapWrap = document.getElementById('minimapWrap');
|
||
const minimapCanvas = document.getElementById('minimap');
|
||
const minimapCtx = minimapCanvas.getContext('2d');
|
||
|
||
// Цвета блоков для миникарты (по 1 пикселю на блок)
|
||
const MINIMAP_COLORS = {
|
||
grass: '#4a8c2a', dirt: '#6b3410', stone: '#6a6a6a', sand: '#d4b84a',
|
||
gravel: '#888', clay: '#5a9ad4', wood: '#a63d00', planks: '#c46a10',
|
||
leaves: '#2a8a3a', glass: '#aaddee', water: '#2980b9', coal: '#1a1a2a',
|
||
copper_ore: '#a05535', iron_ore: '#b0b0b0', gold_ore: '#d4a017',
|
||
diamond_ore: '#0090d0', brick: '#8a2010', tnt: '#c02020',
|
||
campfire: '#d08020', torch: '#d0a020', bedrock: '#1a1a1a',
|
||
flower: '#d03050', bed: '#c03060', ladder: '#a04000', boat: '#6b3410'
|
||
};
|
||
|
||
function initMinimap() {
|
||
document.getElementById('mapToggle').onclick = () => {
|
||
playSound('click');
|
||
state.minimapOpen = !state.minimapOpen;
|
||
minimapWrap.style.display = state.minimapOpen ? 'block' : 'none';
|
||
};
|
||
|
||
const minimapClose = document.getElementById('minimapClose');
|
||
if (minimapClose) {
|
||
minimapClose.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
state.minimapOpen = false;
|
||
minimapWrap.style.display = 'none';
|
||
});
|
||
}
|
||
}
|
||
|
||
function renderMinimap() {
|
||
if (!state.minimapOpen) return;
|
||
const mW = minimapCanvas.width;
|
||
const mH = minimapCanvas.height;
|
||
const scale = 2; // пикселей на блок
|
||
const TILE = state.TILE;
|
||
const player = state.player;
|
||
|
||
// Область карты — центрирована на игроке
|
||
const pGX = Math.floor(player.x / TILE);
|
||
const pGY = Math.floor(player.y / TILE);
|
||
const viewW = Math.floor(mW / scale);
|
||
const viewH = Math.floor(mH / scale);
|
||
const startGX = pGX - Math.floor(viewW / 2);
|
||
const startGY = pGY - Math.floor(viewH / 2);
|
||
|
||
// Очищаем
|
||
minimapCtx.fillStyle = isNight() ? '#070816' : '#87CEEB';
|
||
minimapCtx.fillRect(0, 0, mW, mH);
|
||
|
||
// Рисуем блоки
|
||
const imgData = minimapCtx.createImageData(mW, mH);
|
||
const data = imgData.data;
|
||
|
||
for (let dx = 0; dx < viewW; dx++) {
|
||
for (let dy = 0; dy < viewH; dy++) {
|
||
const gx = startGX + dx;
|
||
const gy = startGY + dy;
|
||
const b = getBlock(gx, gy);
|
||
if (!b || b.dead || b.t === 'air') continue;
|
||
|
||
const color = MINIMAP_COLORS[b.t];
|
||
if (!color) continue;
|
||
|
||
// Парсим hex цвет
|
||
const r = parseInt(color.slice(1, 3), 16);
|
||
const g = parseInt(color.slice(3, 5), 16);
|
||
const bl = parseInt(color.slice(5, 7), 16);
|
||
|
||
// Заполняем scale x scale пикселей
|
||
for (let sx = 0; sx < scale; sx++) {
|
||
for (let sy = 0; sy < scale; sy++) {
|
||
const px = dx * scale + sx;
|
||
const py = dy * scale + sy;
|
||
if (px >= mW || py >= mH) continue;
|
||
const idx = (py * mW + px) * 4;
|
||
data[idx] = r;
|
||
data[idx + 1] = g;
|
||
data[idx + 2] = bl;
|
||
data[idx + 3] = 255;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
minimapCtx.putImageData(imgData, 0, 0);
|
||
|
||
// Игрок — белый пиксель по центру
|
||
minimapCtx.fillStyle = '#fff';
|
||
minimapCtx.fillRect(Math.floor(mW / 2) - 2, Math.floor(mH / 2) - 2, 4, 4);
|
||
|
||
// Другие игроки — жёлтые точки
|
||
for (const [sid, p] of state.otherPlayers) {
|
||
const dx = Math.floor(p.x / TILE) - startGX;
|
||
const dy = Math.floor(p.y / TILE) - startGY;
|
||
if (dx >= 0 && dx < viewW && dy >= 0 && dy < viewH) {
|
||
minimapCtx.fillStyle = '#f1c40f';
|
||
minimapCtx.fillRect(dx * scale - 1, dy * scale - 1, 3, 3);
|
||
}
|
||
}
|
||
|
||
// Мобы — красные (враждебные) / зелёные (животные)
|
||
const allMobsForMap = state.isMultiplayer ? Array.from(state.serverMobs.values()) : state.mobs;
|
||
for (const m of allMobsForMap) {
|
||
const dx = Math.floor(m.x / TILE) - startGX;
|
||
const dy = Math.floor(m.y / TILE) - startGY;
|
||
if (dx >= 0 && dx < viewW && dy >= 0 && dy < viewH) {
|
||
const hostile = m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton';
|
||
minimapCtx.fillStyle = hostile ? '#e74c3c' : '#2ecc71';
|
||
minimapCtx.fillRect(dx * scale, dy * scale, 2, 2);
|
||
}
|
||
}
|
||
}
|
||
|
||
// === game/modes.js ===
|
||
// ==================== РЕЖИМЫ ====================
|
||
|
||
|
||
|
||
const MODES = [{ id: 'mine', icon: '⛏️' }, { id: 'build', icon: '🧱' }];
|
||
|
||
|
||
|
||
function mode() {
|
||
return MODES[state.modeIdx].id;
|
||
}
|
||
|
||
function initModes() {
|
||
const modeBtn = document.getElementById('modeBtn');
|
||
modeBtn.onclick = () => {
|
||
playSound('click'); // Звук клика по кнопке режима
|
||
state.modeIdx = (state.modeIdx + 1) % MODES.length;
|
||
modeBtn.textContent = MODES[state.modeIdx].icon;
|
||
};
|
||
}
|
||
|
||
// === input/mouse-handler.js ===
|
||
// ==================== ВЗАИМОДЕЙСТВИЕ МЫШЬ/ТАП ====================
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
// Crop stage to seed/harvest mapping
|
||
const CROP_SEED_MAP = {
|
||
wheat_stage0: 'wheat_seeds', wheat_stage1: 'wheat_seeds', wheat_stage2: 'wheat_seeds', wheat_stage3: 'wheat_seeds',
|
||
carrot_stage0: 'carrot', carrot_stage1: 'carrot', carrot_stage2: 'carrot', carrot_stage3: 'carrot',
|
||
potato_stage0: 'potato', potato_stage1: 'potato', potato_stage2: 'potato', potato_stage3: 'potato'
|
||
};
|
||
const CROP_HARVEST_MAP = { wheat_stage3: 'wheat', carrot_stage3: 'carrot', potato_stage3: 'potato' };
|
||
|
||
// Helper: handle block drops including farming items
|
||
function handleBlockDrop(removed, inv) {
|
||
// Grass drops wheat_seeds (50%)
|
||
if (removed.t === 'grass') {
|
||
if (Math.random() < 0.5) inv.wheat_seeds = (inv.wheat_seeds || 0) + 1;
|
||
return;
|
||
}
|
||
// Crop drops: seeds for stages 0-2, harvest+seed for stage 3
|
||
const harvestItem = CROP_HARVEST_MAP[removed.t];
|
||
const seedItem = CROP_SEED_MAP[removed.t];
|
||
if (harvestItem) {
|
||
inv[harvestItem] = (inv[harvestItem] || 0) + (BLOCKS[removed.t].harvestQty || 1); addToRecentItems(harvestItem);
|
||
if (seedItem) { inv[seedItem] = (inv[seedItem] || 0) + 1; addToRecentItems(seedItem); }
|
||
} else if (seedItem) {
|
||
// Early crop stage — return seed
|
||
inv[seedItem] = (inv[seedItem] || 0) + 1; addToRecentItems(seedItem);
|
||
} else {
|
||
inv[removed.t] = (inv[removed.t] || 0) + 1; addToRecentItems(removed.t);
|
||
}
|
||
}
|
||
|
||
const mouse = { x: null, y: null };
|
||
|
||
// Hold-to-mine tracking
|
||
let miningHeld = false;
|
||
let miningInterval = null;
|
||
|
||
function initMouseHandlers() {
|
||
const canvas = state.canvas;
|
||
|
||
canvas.addEventListener('pointermove', (e) => {
|
||
const r = canvas.getBoundingClientRect();
|
||
mouse.x = e.clientX - r.left;
|
||
mouse.y = e.clientY - r.top;
|
||
});
|
||
|
||
canvas.addEventListener('pointerdown', (e) => {
|
||
if (state.craftOpen) return;
|
||
if (state.player.hp <= 0) return;
|
||
|
||
const r = canvas.getBoundingClientRect();
|
||
const sx = e.clientX - r.left;
|
||
const sy = e.clientY - r.top;
|
||
|
||
const wx = sx + state.camX;
|
||
const wy = sy + state.camY;
|
||
|
||
const gx = Math.floor(wx / state.TILE);
|
||
const gy = Math.floor(wy / state.TILE);
|
||
|
||
// Пробуждение: клик по любой кровати когда спишь
|
||
const b = getBlock(gx, gy);
|
||
if (state.player.sleeping && b && b.t === 'bed') {
|
||
state.player.sleeping = false;
|
||
return;
|
||
}
|
||
|
||
if (state.player.sleeping) return; // Нельзя взаимодействовать во время сна
|
||
|
||
// Клик по печи — открываем панель обжига
|
||
if (b && b.t === 'furnace' && mode() === 'mine') {
|
||
openFurnaceUI(gx, gy);
|
||
return;
|
||
}
|
||
|
||
// клик по мобу (в режиме mine)
|
||
if (mode() === 'mine') {
|
||
// Check server mobs first (multiplayer)
|
||
if (state.isMultiplayer) {
|
||
for (const [id, sm] of state.serverMobs) {
|
||
if (sm.dead) continue;
|
||
if (wx >= sm.x && wx <= sm.x + sm.w && wy >= sm.y && wy <= sm.y + sm.h) {
|
||
let dmg = 1;
|
||
const swordTypes = ['iron_sword', 'stone_sword', 'wood_sword'];
|
||
for (const st of swordTypes) {
|
||
if (state.inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; }
|
||
}
|
||
state.socket.emit('mob_hurt', { id: sm.id, dmg });
|
||
playSound('attack');
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
// Local mobs (singleplayer or if not hit server mob)
|
||
for (let i = state.mobs.length - 1; i >= 0; i--) {
|
||
const m = state.mobs[i];
|
||
if (wx >= m.x && wx <= m.x + m.w && wy >= m.y && wy <= m.y + m.h) {
|
||
let dmg = 1;
|
||
const swordTypes = ['iron_sword', 'stone_sword', 'wood_sword'];
|
||
for (const st of swordTypes) {
|
||
if (state.inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; }
|
||
}
|
||
m.hp -= dmg;
|
||
m.vx += (m.x - state.player.x) * 2;
|
||
m.vy -= 200;
|
||
playSound('attack');
|
||
if (m.hp <= 0) {
|
||
if (m.kind === 'chicken') playSound('hurt_chicken');
|
||
state.inv.meat += (m.kind === 'chicken' ? 1 : 2);
|
||
if (m.kind === 'skeleton') {
|
||
state.inv.arrow += 2 + Math.floor(Math.random() * 3);
|
||
if (Math.random() < 0.15) state.inv.bow = (state.inv.bow || 0) + 1;
|
||
}
|
||
state.mobs.splice(i, 1);
|
||
rebuildHotbar();
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Лук — стреляем стрелой
|
||
if (state.selected === 'bow' && state.inv.bow > 0 && state.inv.arrow > 0) {
|
||
const aimX = wx - state.player.x - state.player.w / 2;
|
||
const aimY = wy - state.player.y - state.player.h / 2;
|
||
const angle = Math.atan2(aimY, aimX);
|
||
state.projectiles.push({
|
||
x: state.player.x + state.player.w / 2,
|
||
y: state.player.y + state.player.h / 3,
|
||
vx: Math.cos(angle) * 550,
|
||
vy: Math.sin(angle) * 550,
|
||
dmg: 10,
|
||
owner: 'player',
|
||
life: 4
|
||
});
|
||
state.inv.arrow--;
|
||
useTool('bow');
|
||
playSound('hit1');
|
||
rebuildHotbar();
|
||
return;
|
||
}
|
||
|
||
// еда (предмет)
|
||
if (ITEMS[state.selected] && state.inv[state.selected] > 0) {
|
||
const it = ITEMS[state.selected];
|
||
if (state.player.hp < 100 || state.player.hunger < 100) {
|
||
playSound('eat1');
|
||
state.player.hunger = Math.min(100, state.player.hunger + it.food);
|
||
state.player.hp = Math.min(100, state.player.hp + 15);
|
||
state.inv[state.selected]--;
|
||
rebuildHotbar();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// жарка на костре: выбран meat + клик по campfire
|
||
if (b && b.t === 'campfire' && state.selected === 'meat' && state.inv.meat > 0) {
|
||
playSound('fire');
|
||
state.inv.meat--;
|
||
state.inv.cooked++;
|
||
rebuildHotbar();
|
||
return;
|
||
}
|
||
|
||
// Сон на кровати: клик по bed
|
||
if (b && b.t === 'bed' && isNight()) {
|
||
state.player.sleeping = true;
|
||
saveGame();
|
||
return;
|
||
}
|
||
|
||
// ====== MINING PROGRESSION SYSTEM ======
|
||
if (mode() === 'mine') {
|
||
if (!b) return;
|
||
// Allow mining crops (growthStage) and harvestable blocks, block other decor and fluids
|
||
if (BLOCKS[b.t].fluid || (BLOCKS[b.t].decor && BLOCKS[b.t].growthStage === undefined && !BLOCKS[b.t].harvestable)) return;
|
||
|
||
if (b.t === 'tnt') { activateTNT(b, 3.2); return; }
|
||
|
||
// Calculate mining progress
|
||
const hardness = BLOCKS[b.t].hardness || 1;
|
||
if (hardness === Infinity) return; // unbreakable (bedrock)
|
||
if (hardness === 0) {
|
||
// Instant-mine blocks (flower, boat, crops, etc.)
|
||
const removed = removeBlock(gx, gy);
|
||
if (removed) {
|
||
handleBlockDrop(removed, state.inv);
|
||
sendBlockChange(gx, gy, removed.t, 'remove');
|
||
rebuildHotbar();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Same block being mined?
|
||
if (state.mining.gx === gx && state.mining.gy === gy) {
|
||
state.mining.progress += getMiningSpeed(b.t);
|
||
} else {
|
||
// New block, start mining
|
||
state.mining = { gx, gy, progress: getMiningSpeed(b.t), hardness };
|
||
}
|
||
|
||
// Play mining hit sound
|
||
if (b.t === 'glass') playSound('glass1');
|
||
else if (b.t === 'sand') playSound('sand1');
|
||
else if (b.t === 'stone' || b.t.endsWith('_ore')) playSound('stone1');
|
||
else if (b.t === 'wood' || b.t === 'planks') playSound('wood1');
|
||
else playSound('cloth1');
|
||
|
||
// Check if block is fully mined
|
||
if (state.mining.progress >= hardness) {
|
||
// Check drop requirements
|
||
const minTool = BLOCKS[b.t].minTool;
|
||
let drops = true;
|
||
if (minTool) {
|
||
const toolId = getBestTool(b.t);
|
||
if (toolId) {
|
||
const tool = TOOLS[toolId];
|
||
if (toolTierLessThan(tool.toolTier, minTool)) drops = false;
|
||
else useTool(toolId);
|
||
} else {
|
||
drops = false;
|
||
}
|
||
} else {
|
||
// Use tool durability if applicable
|
||
const toolId = getBestTool(b.t);
|
||
if (toolId) useTool(toolId);
|
||
}
|
||
|
||
const removed = removeBlock(gx, gy);
|
||
if (removed) {
|
||
if (drops) {
|
||
handleBlockDrop(removed, state.inv);
|
||
} else {
|
||
// Drop cobblestone instead for stone-type blocks
|
||
if (BLOCKS[removed.t]?.toolType === 'pickaxe') {
|
||
state.inv.cobblestone = (state.inv.cobblestone || 0) + 1;
|
||
}
|
||
// Else nothing drops
|
||
}
|
||
sendBlockChange(gx, gy, removed.t, 'remove');
|
||
|
||
// Sound for block break
|
||
if (removed.t === 'glass') playSound('glass1');
|
||
else if (removed.t === 'sand') playSound('sand1');
|
||
else if (removed.t === 'stone' || removed.t.endsWith('_ore')) playSound('stone1');
|
||
else if (removed.t === 'wood') playSound('wood1');
|
||
else playSound('cloth1');
|
||
|
||
rebuildHotbar();
|
||
}
|
||
state.mining = { gx: null, gy: null, progress: 0, hardness: 0 };
|
||
}
|
||
|
||
// Start hold-to-mine for desktop
|
||
miningHeld = true;
|
||
if (miningInterval) clearInterval(miningInterval);
|
||
miningInterval = setInterval(() => {
|
||
if (!miningHeld) { clearInterval(miningInterval); miningInterval = null; return; }
|
||
// Re-check the block still exists at mining position
|
||
const mb = getBlock(state.mining.gx, state.mining.gy);
|
||
if (!mb || mb.dead || mb.t !== b.t) {
|
||
miningHeld = false;
|
||
clearInterval(miningInterval);
|
||
miningInterval = null;
|
||
return;
|
||
}
|
||
// Accumulate progress
|
||
if (state.mining.gx !== null && state.mining.hardness > 0) {
|
||
state.mining.progress += getMiningSpeed(mb.t) * 0.3; // 0.3x per tick for held mining
|
||
|
||
if (state.mining.progress >= state.mining.hardness) {
|
||
const mgx = state.mining.gx;
|
||
const mgy = state.mining.gy;
|
||
const mMinTool = BLOCKS[mb.t].minTool;
|
||
let mDrops = true;
|
||
if (mMinTool) {
|
||
const mToolId = getBestTool(mb.t);
|
||
if (mToolId) {
|
||
const mTool = TOOLS[mToolId];
|
||
if (toolTierLessThan(mTool.toolTier, mMinTool)) mDrops = false;
|
||
else useTool(mToolId);
|
||
} else {
|
||
mDrops = false;
|
||
}
|
||
} else {
|
||
const mToolId = getBestTool(mb.t);
|
||
if (mToolId) useTool(mToolId);
|
||
}
|
||
|
||
const removed = removeBlock(mgx, mgy);
|
||
if (removed) {
|
||
if (mDrops) {
|
||
handleBlockDrop(removed, state.inv);
|
||
} else {
|
||
if (BLOCKS[removed.t]?.toolType === 'pickaxe') {
|
||
state.inv.cobblestone = (state.inv.cobblestone || 0) + 1;
|
||
}
|
||
}
|
||
sendBlockChange(mgx, mgy, removed.t, 'remove');
|
||
rebuildHotbar();
|
||
}
|
||
state.mining = { gx: null, gy: null, progress: 0, hardness: 0 };
|
||
miningHeld = false;
|
||
clearInterval(miningInterval);
|
||
miningInterval = null;
|
||
}
|
||
}
|
||
}, 100);
|
||
|
||
return;
|
||
}
|
||
|
||
// ====== BUILD MODE ======
|
||
if (mode() === 'build') {
|
||
// Planting seeds on farmland (works even without farmland in inventory)
|
||
const seedToCropBlock = {
|
||
wheat_seeds: 'wheat_stage0',
|
||
carrot: 'carrot_stage0',
|
||
potato: 'potato_stage0'
|
||
};
|
||
if (seedToCropBlock[state.selected] && state.inv[state.selected] > 0) {
|
||
// Clicking on farmland — plant above it
|
||
if (b && b.t === 'farmland') {
|
||
const aboveKey = gx + ',' + (gy - 1);
|
||
const above = state.grid.get(aboveKey);
|
||
if (!above || above.dead) {
|
||
setBlock(gx, gy - 1, seedToCropBlock[state.selected], true);
|
||
state.inv[state.selected]--;
|
||
const cropBlock = state.grid.get(gx + ',' + (gy - 1));
|
||
if (cropBlock) cropBlock.growthTimer = 0;
|
||
rebuildHotbar();
|
||
return;
|
||
}
|
||
}
|
||
// Clicking on empty space above farmland — plant there
|
||
if (!b) {
|
||
const belowKey = gx + ',' + (gy + 1);
|
||
const below = state.grid.get(belowKey);
|
||
if (below && below.t === 'farmland') {
|
||
setBlock(gx, gy, seedToCropBlock[state.selected], true);
|
||
state.inv[state.selected]--;
|
||
const cropBlock = state.grid.get(gx + ',' + gy);
|
||
if (cropBlock) cropBlock.growthTimer = 0;
|
||
rebuildHotbar();
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (state.inv[state.selected] <= 0) return;
|
||
if (!BLOCKS[state.selected]) return;
|
||
if (b) return; // занято
|
||
|
||
// Проверяем, ставим ли лодку
|
||
if (state.selected === 'boat') {
|
||
const waterBelow = getBlock(gx, gy + 1);
|
||
if (!waterBelow || waterBelow.t !== 'water') {
|
||
return;
|
||
}
|
||
state.boat.x = gx * state.TILE;
|
||
state.boat.y = gy * state.TILE;
|
||
state.boat.vx = 0;
|
||
state.boat.vy = 0;
|
||
state.boat.active = true;
|
||
state.boat.inWater = true;
|
||
state.player.inBoat = true;
|
||
state.player.x = state.boat.x;
|
||
state.player.y = state.boat.y;
|
||
state.player.vx = 0;
|
||
state.player.vy = 0;
|
||
playSound('splash');
|
||
state.inv[state.selected]--;
|
||
rebuildHotbar();
|
||
return;
|
||
}
|
||
|
||
// запрет ставить в игрока
|
||
const TILE = state.TILE;
|
||
const bx = gx * TILE, by = gy * TILE;
|
||
const overlap = !(bx >= state.player.x + state.player.w || bx + TILE <= state.player.x || by >= state.player.y + state.player.h || by + TILE <= state.player.y);
|
||
if (overlap) return;
|
||
|
||
setBlock(gx, gy, state.selected, true);
|
||
state.inv[state.selected]--;
|
||
|
||
sendBlockChange(gx, gy, state.selected, 'set');
|
||
|
||
if (state.selected === 'stone' || state.selected === 'brick') playSound('stone_build');
|
||
else if (state.selected === 'wood' || state.selected === 'planks') playSound('wood_build');
|
||
else if (state.selected === 'glass') playSound('glass1');
|
||
else if (state.selected === 'sand') playSound('sand1');
|
||
else if (state.selected === 'dirt' || state.selected === 'grass') playSound('cloth1');
|
||
|
||
rebuildHotbar();
|
||
return;
|
||
}
|
||
});
|
||
|
||
// Release mouse — stop hold-to-mine
|
||
canvas.addEventListener('pointerup', () => {
|
||
miningHeld = false;
|
||
});
|
||
|
||
canvas.addEventListener('pointerleave', () => {
|
||
miningHeld = false;
|
||
// Reset mining when mouse leaves canvas
|
||
});
|
||
}
|
||
|
||
// === render/draw-fire.js ===
|
||
// Рисование костра: огонь поверх текстуры
|
||
function drawFire(ctx, wx, wy, now) {
|
||
const baseX = wx;
|
||
const baseY = wy;
|
||
const flick = 6 + (Math.sin(now / 90) + 1) * 4;
|
||
ctx.fillStyle = 'rgba(255,140,0,0.85)';
|
||
ctx.beginPath();
|
||
ctx.moveTo(baseX + 10, baseY + 30);
|
||
ctx.lineTo(baseX + 20, baseY + 30 - flick);
|
||
ctx.lineTo(baseX + 30, baseY + 30);
|
||
ctx.fill();
|
||
|
||
ctx.fillStyle = 'rgba(255,230,150,0.75)';
|
||
ctx.beginPath();
|
||
ctx.moveTo(baseX + 14, baseY + 30);
|
||
ctx.lineTo(baseX + 20, baseY + 30 - (flick * 0.7));
|
||
ctx.lineTo(baseX + 26, baseY + 30);
|
||
ctx.fill();
|
||
}
|
||
|
||
// === game/loop.js ===
|
||
// ==================== ГЛАВНЫЙ ИГРОВОЙ ЦИКЛ ====================
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
// ==================== LOOP ====================
|
||
let last = performance.now();
|
||
let prevJump = false;
|
||
// При возврате на вкладку — сбрасываем last чтобы не было скачка dt
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (!document.hidden) last = performance.now();
|
||
});
|
||
|
||
|
||
|
||
function loop(now) {
|
||
const rawDt = Math.min(0.05, (now - last) / 1000);
|
||
last = now;
|
||
// Sub-stepping: разбиваем физику на шаги ≤16ms чтобы не проваливаться сквозь блоки
|
||
const PHYSICS_STEP = 0.016;
|
||
const steps = Math.max(1, Math.ceil(rawDt / PHYSICS_STEP));
|
||
const dt = rawDt / steps;
|
||
|
||
const player = state.player;
|
||
const inv = state.inv;
|
||
const inp = state.inp;
|
||
const clouds = state.clouds;
|
||
const mobs = state.mobs;
|
||
const projectiles = state.projectiles;
|
||
const parts = state.parts;
|
||
const boat = state.boat;
|
||
|
||
const jumpPressed = inp.j && !prevJump;
|
||
prevJump = inp.j;
|
||
|
||
// В MP время синхронизируется с сервера
|
||
if (state.isMultiplayer) {
|
||
// Сон всё ещё работает локально (только лечение, без ускорения времени)
|
||
if (player.sleeping && isNight()) {
|
||
player.hp = Math.min(100, player.hp + dt * 20);
|
||
if (!isNight()) {
|
||
player.sleeping = false;
|
||
}
|
||
}
|
||
} else {
|
||
// SP: ускорение времени во время сна или замороженный цикл
|
||
if (!state.dayNightCycle) {
|
||
// Цикл день/ночь выключен — время стоит
|
||
} else if (player.sleeping && isNight()) {
|
||
state.worldTime += dt * 8 / state.DAY_LEN;
|
||
player.hp = Math.min(100, player.hp + dt * 20);
|
||
if (!isNight()) {
|
||
player.sleeping = false;
|
||
}
|
||
} else {
|
||
state.worldTime += dt / state.DAY_LEN;
|
||
}
|
||
if (state.worldTime >= 1) state.worldTime -= 1;
|
||
}
|
||
|
||
// камера следует за игроком по X/Y
|
||
state.camX = Math.floor((player.x + player.w / 2) - state.W / 2);
|
||
state.camY = Math.floor((player.y + player.h / 2) - state.H / 2);
|
||
|
||
if (!Number.isFinite(player.x) || !Number.isFinite(player.y)) {
|
||
var startGX = 6;
|
||
for (var sdx = -3; sdx <= 3; sdx++) genColumn(startGX + sdx);
|
||
player.x = startGX * state.TILE;
|
||
player.y = (surfaceGyAt(startGX) - 1) * state.TILE;
|
||
player.vx = player.vy = 0;
|
||
state.camX = Math.floor((player.x + player.w / 2) - state.W / 2);
|
||
state.camY = Math.floor((player.y + player.h / 2) - state.H / 2);
|
||
console.warn('[Loop] player pos was broken, respawned at start');
|
||
}
|
||
|
||
ensureGenAroundCamera();
|
||
|
||
// clouds parallax
|
||
for (const c of clouds) {
|
||
c.x -= c.s * dt;
|
||
if (c.x + c.w < state.camX - 400) c.x = state.camX + state.W + Math.random() * 700;
|
||
}
|
||
|
||
// player
|
||
updateWaterFlag(player);
|
||
|
||
// кислород/утопление
|
||
if (player.headInWater) {
|
||
player.o2 = Math.max(0, player.o2 - 6 * dt);
|
||
if (player.o2 === 0) {
|
||
const damage = calculateDamage(4 * dt);
|
||
player.hp -= damage;
|
||
}
|
||
} else {
|
||
player.o2 = Math.min(100, player.o2 + 10 * dt);
|
||
}
|
||
|
||
// голод убывает
|
||
player.hunger = Math.max(0, player.hunger - dt * 0.2);
|
||
|
||
// ========== TEMPERATURE ==========
|
||
if (player.temperature === undefined) player.temperature = 15;
|
||
const BIOME_TEMP = { snow: -20, plains: 15, swamp: 20, desert: 45, mountains: 5 };
|
||
const COLD_THRESHOLD = -5;
|
||
const HEAT_THRESHOLD = 35;
|
||
const HEAT_RADIUS = 5;
|
||
const pGX = Math.floor(player.x / state.TILE);
|
||
const biome = getCachedBiome(pGX);
|
||
const pGY = Math.floor(player.y / state.TILE);
|
||
const sGY = surfaceGyAt(pGX);
|
||
const isUndergroundTemp = (pGY - sGY) > 2;
|
||
let targetTemp = BIOME_TEMP[biome] || 15;
|
||
if (isUndergroundTemp) targetTemp = 15;
|
||
if (isNight() && !isUndergroundTemp) targetTemp -= 10;
|
||
if ((state.weatherType === 'rain' || state.weatherType === 'storm') && !isUndergroundTemp) targetTemp -= 5;
|
||
player.temperature += (targetTemp - player.temperature) * dt * 0.3;
|
||
let nearHeat = false, nearCool = false;
|
||
for (let dx = -HEAT_RADIUS; dx <= HEAT_RADIUS; dx++) {
|
||
for (let dy = -HEAT_RADIUS; dy <= HEAT_RADIUS; dy++) {
|
||
const b = getBlock(pGX + dx, pGY + dy);
|
||
if (!b || b.dead) continue;
|
||
if (b.t === 'campfire' || b.t === 'torch') nearHeat = true;
|
||
if (b.t === 'water' && biome === 'desert') nearCool = true;
|
||
}
|
||
}
|
||
if (nearHeat) {
|
||
player.temperature += (30 - player.temperature) * dt * 2;
|
||
if (player.hp < 100) player.hp = Math.min(100, player.hp + 8 * dt);
|
||
}
|
||
if (nearCool) {
|
||
player.temperature += (15 - player.temperature) * dt * 1.5;
|
||
if (player.hp < 100 && player.temperature > HEAT_THRESHOLD) player.hp = Math.min(100, player.hp + 8 * dt);
|
||
}
|
||
if (isUndergroundTemp && !nearHeat) {
|
||
if (player.hp < 100 && player.temperature >= COLD_THRESHOLD && player.temperature <= HEAT_THRESHOLD) {
|
||
player.hp = Math.min(100, player.hp + 2 * dt);
|
||
}
|
||
}
|
||
const aboveBlk = getBlock(pGX, pGY - 1);
|
||
const inShade = aboveBlk && BLOCKS[aboveBlk.t] && BLOCKS[aboveBlk.t].solid && aboveBlk.t !== 'glass';
|
||
if (inShade && !isUndergroundTemp && !nearCool) {
|
||
player.temperature -= 5 * dt;
|
||
if (player.temperature > HEAT_THRESHOLD - 2 && player.hp < 100) player.hp = Math.min(100, player.hp + 1 * dt);
|
||
}
|
||
if (player.temperature < COLD_THRESHOLD && !isUndergroundTemp) {
|
||
const severity = Math.abs(player.temperature - COLD_THRESHOLD) / 15;
|
||
player.hp -= 1 * severity * dt;
|
||
if (severity > 0.5) player.vx *= (1 - 0.3 * Math.min(1, severity) * dt);
|
||
}
|
||
if (player.temperature > HEAT_THRESHOLD && !isUndergroundTemp) {
|
||
const severity = (player.temperature - HEAT_THRESHOLD) / 15;
|
||
player.hp -= 1 * severity * dt;
|
||
if (severity > 0.5) player.hunger -= 0.5 * severity * dt;
|
||
}
|
||
if (player.slowTimer > 0) player.slowTimer -= dt;
|
||
|
||
// Кактус — урон при касании
|
||
const pCGX = Math.floor((player.x + player.w / 2) / state.TILE);
|
||
const pCGY = Math.floor((player.y + player.h / 2) / state.TILE);
|
||
for (let cdx = -1; cdx <= 1; cdx++) {
|
||
for (let cdy = -1; cdy <= 1; cdy++) {
|
||
const cb = getBlock(pCGX + cdx, pCGY + cdy);
|
||
if (cb && !cb.dead && BLOCKS[cb.t] && BLOCKS[cb.t].hurt) {
|
||
if (player.invuln <= 0) {
|
||
player.hp -= 2;
|
||
player.invuln = 0.5;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Лёд — скольжение
|
||
const iceBlock = getBlock(pCGX, pCGY + 1);
|
||
if (iceBlock && BLOCKS[iceBlock.t] && BLOCKS[iceBlock.t].slip && player.grounded) {
|
||
player.vx *= 0.98;
|
||
}
|
||
|
||
// Игрок не может двигаться во время сна
|
||
if (player.sleeping) {
|
||
player.vx = 0;
|
||
player.vy = 0;
|
||
} else {
|
||
const dir = (inp.r ? 1 : 0) - (inp.l ? 1 : 0);
|
||
if (dir) player.vx = dir * state.MOVE;
|
||
else player.vx *= 0.82;
|
||
}
|
||
|
||
// Звук шагов при движении по земле
|
||
if (player.grounded && !player.inWater && Math.abs(player.vx) > 50) {
|
||
const stepInterval = 0.35;
|
||
if (now / 1000 - player.lastStepTime > stepInterval) {
|
||
playSound('step');
|
||
player.lastStepTime = now / 1000;
|
||
}
|
||
}
|
||
|
||
// прыжок/плавание
|
||
if (player.inBoat) {
|
||
const dir = (inp.r ? 1 : 0) - (inp.l ? 1 : 0);
|
||
if (dir) boat.vx = dir * state.MOVE;
|
||
else boat.vx *= 0.95;
|
||
boat.vy = 0;
|
||
player.x = boat.x + 2;
|
||
player.y = boat.y - 4;
|
||
player.vx = boat.vx;
|
||
player.vy = boat.vy;
|
||
player.grounded = true;
|
||
player.inWater = false;
|
||
|
||
if (jumpPressed) {
|
||
inv.boat = (inv.boat || 0) + 1;
|
||
player.inBoat = false;
|
||
boat.active = false;
|
||
player.y += state.TILE;
|
||
player.vy = -state.JUMP * 0.5;
|
||
playSound('splash');
|
||
}
|
||
|
||
} else if (player.inWater) {
|
||
player.vx *= 0.90;
|
||
player.vy *= 0.92;
|
||
|
||
if (!jumpPressed && !inp.j) {
|
||
player.vy += state.GRAV_WATER * dt;
|
||
} else {
|
||
if (jumpPressed) {
|
||
player.vy = Math.min(player.vy, -520);
|
||
} else if (inp.j) {
|
||
player.vy = Math.min(player.vy, -260);
|
||
}
|
||
}
|
||
|
||
} else {
|
||
if (jumpPressed && player.grounded && !player.sleeping) {
|
||
player.vy = -state.JUMP;
|
||
player.grounded = false;
|
||
player.fallStartY = player.y;
|
||
}
|
||
}
|
||
|
||
// Гравитация
|
||
if (!player.inWater && !player.inBoat) {
|
||
player.vy += state.GRAV * dt;
|
||
}
|
||
|
||
// Обновляем позицию лодки
|
||
if (boat.active) {
|
||
boat.x += boat.vx * dt;
|
||
boat.y += boat.vy * dt;
|
||
|
||
const boatGX = Math.floor(boat.x / state.TILE);
|
||
const boatGY = Math.floor(boat.y / state.TILE);
|
||
const below = getBlock(boatGX, boatGY + 1);
|
||
|
||
if (!below || below.t !== 'water') {
|
||
inv.boat = (inv.boat || 0) + 1;
|
||
player.inBoat = false;
|
||
boat.active = false;
|
||
player.y += state.TILE;
|
||
player.vy = -200;
|
||
playSound('splash');
|
||
}
|
||
}
|
||
|
||
if (player.inBoat && !boat.active) {
|
||
inv.boat = (inv.boat || 0) + 1;
|
||
player.inBoat = false;
|
||
player.y += state.TILE;
|
||
player.vy = -200;
|
||
playSound('splash');
|
||
}
|
||
|
||
// Sub-stepped physics
|
||
for (let step = 0; step < steps; step++) {
|
||
player.y += player.vy * dt;
|
||
resolveY(player);
|
||
player.x += player.vx * dt;
|
||
resolveX(player);
|
||
}
|
||
|
||
sendPlayerPosition();
|
||
|
||
updateWaterPhysics(dt);
|
||
|
||
if (!state.isMultiplayer) { updateWeather(dt); }
|
||
updateRain(dt);
|
||
|
||
player.invuln = Math.max(0, player.invuln - dt);
|
||
|
||
// Voice position update
|
||
state.voicePosT += dt;
|
||
if (state.voicePosT > 0.5 && state.voiceSocket && state.voiceSocket.connected) {
|
||
state.voicePosT = 0;
|
||
state.voiceSocket.emit('voice_pos', { x: player.x, y: player.y });
|
||
}
|
||
|
||
// Furnace tick
|
||
tickFurnaces(dt);
|
||
|
||
if (state.currentFurnaceKey && Math.random() < 0.1) {
|
||
renderFurnaceUI();
|
||
}
|
||
|
||
// Projectile tick
|
||
for (let i = projectiles.length - 1; i >= 0; i--) {
|
||
const p = projectiles[i];
|
||
p.x += p.vx * dt;
|
||
p.y += p.vy * dt;
|
||
p.vy += 400 * dt;
|
||
p.life -= dt;
|
||
|
||
const gx = Math.floor(p.x / state.TILE);
|
||
const gy = Math.floor(p.y / state.TILE);
|
||
const blk = getBlock(gx, gy);
|
||
if (blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid) {
|
||
if (p.owner === 'player' && Math.random() < 0.5) inv.arrow++;
|
||
projectiles.splice(i, 1);
|
||
continue;
|
||
}
|
||
|
||
if (p.owner === 'mob') {
|
||
if (p.x > player.x && p.x < player.x + player.w && p.y > player.y && p.y < player.y + player.h) {
|
||
if (player.invuln <= 0) {
|
||
player.hp -= calculateDamage(p.dmg);
|
||
player.invuln = 0.4;
|
||
player.vx += p.vx * 0.3;
|
||
player.vy -= 150;
|
||
playSound('hit1');
|
||
}
|
||
projectiles.splice(i, 1);
|
||
continue;
|
||
}
|
||
} else {
|
||
let hitMob = false;
|
||
if (state.isMultiplayer) {
|
||
for (const [id, sm] of state.serverMobs) {
|
||
if (sm.dead) continue;
|
||
if (p.x > sm.x && p.x < sm.x + sm.w && p.y > sm.y && p.y < sm.y + sm.h) {
|
||
state.socket.emit('mob_arrow_hit', { id: sm.id, dmg: p.dmg, vx: p.vx });
|
||
projectiles.splice(i, 1);
|
||
hitMob = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (!hitMob) {
|
||
for (let j = mobs.length - 1; j >= 0; j--) {
|
||
const m = mobs[j];
|
||
if (p.x > m.x && p.x < m.x + m.w && p.y > m.y && p.y < m.y + m.h) {
|
||
m.hp -= p.dmg;
|
||
m.vx += p.vx * 0.2;
|
||
m.vy -= 200;
|
||
if (m.hp <= 0) {
|
||
inv.meat += (m.kind === 'chicken' ? 1 : 2);
|
||
if (m.kind === 'skeleton') {
|
||
inv.arrow += 2 + Math.floor(Math.random() * 3);
|
||
if (Math.random() < 0.15) inv.bow = (inv.bow || 0) + 1;
|
||
}
|
||
mobs.splice(j, 1);
|
||
rebuildHotbar();
|
||
}
|
||
projectiles.splice(i, 1);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (p.life <= 0) projectiles.splice(i, 1);
|
||
}
|
||
|
||
// TNT tick
|
||
for (const key of Array.from(state.activeTNT)) {
|
||
const b = state.grid.get(key);
|
||
if (!b || b.dead) { state.activeTNT.delete(key); continue; }
|
||
b.fuse -= dt;
|
||
if (b.fuse <= 0) {
|
||
explodeAt(b.gx, b.gy);
|
||
}
|
||
}
|
||
|
||
|
||
// ====== CROP GROWTH ======
|
||
for (const [key, blk] of state.grid) {
|
||
if (blk.dead) continue;
|
||
const bDef = BLOCKS[blk.t];
|
||
if (bDef.growthStage === undefined) continue;
|
||
if (bDef.harvestable) continue; // stage 3 = fully grown
|
||
if (!blk.growthTimer && blk.growthTimer !== 0) blk.growthTimer = 0;
|
||
blk.growthTimer += dt;
|
||
// Growth time from block def (default 30s per stage)
|
||
const growTime = bDef.growthTime || 30;
|
||
if (blk.growthTimer >= growTime) {
|
||
blk.growthTimer = 0;
|
||
const nextStage = bDef.growsTo;
|
||
if (nextStage) {
|
||
blk.t = nextStage;
|
||
// Update texture reference
|
||
if (tex[nextStage]) {
|
||
// Block type changed — will render new texture next frame
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// mobs spawn — only in singleplayer
|
||
state.spawnT += dt;
|
||
if (!state.isMultiplayer && state.spawnT > 1.8 && mobs.length < 30) {
|
||
state.spawnT = 0;
|
||
|
||
const spawnLeft = Math.random() < 0.5;
|
||
const gx = spawnLeft
|
||
? Math.floor((state.camX - 200) / state.TILE)
|
||
: Math.floor((state.camX + state.W + 200) / state.TILE);
|
||
|
||
genColumn(gx);
|
||
const sgy = surfaceGyAt(gx);
|
||
const wx = gx * state.TILE + 4;
|
||
const wy = (sgy - 2) * state.TILE;
|
||
|
||
const top = getBlock(gx, sgy);
|
||
if (top && top.t === 'water') {
|
||
// skip
|
||
} else {
|
||
const night = isNight();
|
||
const spawnBiome = getCachedBiome(gx);
|
||
if (night) {
|
||
const hostileCount = mobs.filter(m => m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton' || m.kind === 'scorpion' || m.kind === 'slime').length;
|
||
if (hostileCount < 12) {
|
||
const rand = Math.random();
|
||
if (spawnBiome === 'desert' && rand < 0.6) {
|
||
mobs.push(new Scorpion(wx, wy));
|
||
} else if (spawnBiome === 'swamp' && rand < 0.5) {
|
||
mobs.push(new Slime(wx, wy));
|
||
} else if (rand < 0.35) {
|
||
mobs.push(new Zombie(wx, wy));
|
||
} else if (rand < 0.55) {
|
||
mobs.push(new Creeper(wx, wy));
|
||
} else {
|
||
mobs.push(new Skeleton(wx, wy));
|
||
}
|
||
}
|
||
}
|
||
// Daytime biome mobs
|
||
const animalCount = mobs.filter(m => m.kind === 'pig' || m.kind === 'chicken' || m.kind === 'polar_bear' || m.kind === 'eagle').length;
|
||
if (animalCount < 8) {
|
||
if (spawnBiome === 'tundra' && Math.random() < 0.6) {
|
||
mobs.push(new PolarBear(wx, wy));
|
||
} else if (spawnBiome === 'mountains' && Math.random() < 0.4) {
|
||
mobs.push(new Eagle(wx, wy));
|
||
} else {
|
||
mobs.push(Math.random() < 0.5 ? new Pig(wx, wy) : new Chicken(wx, wy));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// mobs update — только локальные
|
||
if (!state.isMultiplayer) {
|
||
for (let i = mobs.length - 1; i >= 0; i--) {
|
||
const m = mobs[i];
|
||
mobAI(m, dt);
|
||
if (m.hp <= 0) mobs.splice(i, 1);
|
||
}
|
||
}
|
||
|
||
// particles
|
||
for (let i = parts.length - 1; i >= 0; i--) {
|
||
const p = parts[i];
|
||
p.t -= dt;
|
||
p.x += p.vx * dt;
|
||
p.y += p.vy * dt;
|
||
p.vy += state.GRAV * dt;
|
||
if (p.t <= 0) parts.splice(i, 1);
|
||
}
|
||
|
||
// death
|
||
if (player.hp <= 0) {
|
||
state.deathEl.style.display = 'flex';
|
||
} else if (state.deathEl.style.display === 'flex') {
|
||
state.deathEl.style.display = 'none';
|
||
}
|
||
|
||
// ==================== RENDER ====================
|
||
const ctx = state.ctx;
|
||
const W = state.W;
|
||
const H = state.H;
|
||
const camX = state.camX;
|
||
const camY = state.camY;
|
||
const TILE = state.TILE;
|
||
const tex = state.tex;
|
||
const night = isNight();
|
||
|
||
// sky
|
||
const playerGx = Math.floor(player.x / state.TILE); const skyBiome = getBiome(playerGx); ctx.fillStyle = skyColor(night, skyBiome, state.weatherType, state.worldTime);
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
// clouds
|
||
ctx.save();
|
||
ctx.translate(-camX * 0.5, -camY * 0.15);
|
||
ctx.fillStyle = 'rgba(255,255,255,0.65)';
|
||
for (const c of clouds) {
|
||
ctx.fillRect(c.x, c.y, c.w, 26);
|
||
ctx.fillRect(c.x + 20, c.y - 10, c.w * 0.6, 22);
|
||
}
|
||
ctx.restore();
|
||
|
||
// world
|
||
ctx.save();
|
||
ctx.translate(-camX, -camY);
|
||
|
||
const minGX = Math.floor(camX / TILE) - 2;
|
||
const maxGX = Math.floor((camX + W) / TILE) + 2;
|
||
const minGY = Math.floor(camY / TILE) - 6;
|
||
const maxGY = Math.floor((camY + H) / TILE) + 6;
|
||
const blocks = state.blocks;
|
||
|
||
// draw blocks
|
||
for (const b of blocks) {
|
||
if (b.dead) continue;
|
||
if (b.gx < minGX || b.gx > maxGX || b.gy < minGY || b.gy > maxGY) continue;
|
||
|
||
const def = BLOCKS[b.t];
|
||
if (def.alpha) {
|
||
ctx.save();
|
||
ctx.globalAlpha = def.alpha;
|
||
ctx.drawImage(tex[b.t], b.gx * TILE, b.gy * TILE, TILE, TILE);
|
||
ctx.restore();
|
||
} else {
|
||
ctx.drawImage(tex[b.t], b.gx * TILE, b.gy * TILE, TILE, TILE);
|
||
}
|
||
|
||
// TNT мигает
|
||
if (b.t === 'tnt' && b.active && Math.sin(now / 60) > 0) {
|
||
ctx.fillStyle = 'rgba(255,255,255,0.45)';
|
||
ctx.fillRect(b.gx * TILE, b.gy * TILE, TILE, TILE);
|
||
}
|
||
|
||
// огонь костра
|
||
if (b.t === 'campfire') {
|
||
drawFire(ctx, b.gx * TILE, b.gy * TILE, now);
|
||
}
|
||
// Печь — огонь когда обжигает
|
||
if (b.t === 'furnace' && state.activeFurnaces.has(`${b.gx},${b.gy}`)) {
|
||
drawFire(ctx, b.gx * TILE + 8, b.gy * TILE + 5, now);
|
||
}
|
||
}
|
||
|
||
// ====== MINING CRACK OVERLAY ======
|
||
if (state.mining.gx !== null && state.mining.hardness > 0) {
|
||
const mg = state.mining;
|
||
const progress = Math.min(1, mg.progress / mg.hardness);
|
||
const cx = mg.gx * TILE - camX + camX; // already in world coords since we translated
|
||
const cy = mg.gy * TILE - camY + camY;
|
||
// Actually, we're in translated context, so just use world coords
|
||
const wx = mg.gx * TILE;
|
||
const wy = mg.gy * TILE;
|
||
ctx.save();
|
||
ctx.globalAlpha = Math.min(0.8, progress);
|
||
// Draw crack lines
|
||
const numCracks = Math.floor(progress * 5);
|
||
ctx.strokeStyle = '#000';
|
||
ctx.lineWidth = 2;
|
||
for (let i = 0; i < numCracks; i++) {
|
||
ctx.beginPath();
|
||
ctx.moveTo(wx + TILE/2, wy + TILE/2);
|
||
const angle = (i * 72 + 36) * Math.PI / 180;
|
||
ctx.lineTo(wx + TILE/2 + Math.cos(angle) * TILE * 0.4, wy + TILE/2 + Math.sin(angle) * TILE * 0.4);
|
||
ctx.stroke();
|
||
}
|
||
// Darkening overlay
|
||
ctx.fillStyle = `rgba(0,0,0,${progress * 0.4})`;
|
||
ctx.fillRect(wx, wy, TILE, TILE);
|
||
|
||
// Progress bar under block
|
||
ctx.globalAlpha = 1;
|
||
ctx.fillStyle = '#333';
|
||
ctx.fillRect(wx, wy + TILE - 4, TILE, 4);
|
||
ctx.fillStyle = '#2ecc71';
|
||
ctx.fillRect(wx, wy + TILE - 4, TILE * progress, 4);
|
||
|
||
ctx.restore();
|
||
}
|
||
|
||
// mobs
|
||
const allMobsRender = state.isMultiplayer ? Array.from(state.serverMobs.values()) : mobs;
|
||
for (const m of allMobsRender) {
|
||
if (m.kind === 'zombie') {
|
||
ctx.fillStyle = '#2ecc71';
|
||
ctx.fillRect(m.x, m.y, m.w, m.h);
|
||
ctx.fillStyle = '#c0392b';
|
||
ctx.fillRect(m.x + 6, m.y + 12, 6, 6);
|
||
ctx.fillRect(m.x + 22, m.y + 12, 6, 6);
|
||
} else if (m.kind === 'pig') {
|
||
ctx.fillStyle = '#ffb6c1';
|
||
ctx.fillRect(m.x, m.y, m.w, m.h);
|
||
ctx.fillStyle = '#000';
|
||
ctx.fillRect(m.x + 22, m.y + 5, 3, 3);
|
||
ctx.fillStyle = '#ff69b4';
|
||
ctx.fillRect(m.x + 28, m.y + 12, 6, 6);
|
||
} else if (m.kind === 'chicken') {
|
||
ctx.fillStyle = '#ecf0f1';
|
||
ctx.fillRect(m.x, m.y, m.w, m.h);
|
||
ctx.fillStyle = '#f39c12';
|
||
ctx.fillRect(m.x + 18, m.y + 10, 6, 4);
|
||
ctx.fillStyle = '#000';
|
||
ctx.fillRect(m.x + 8, m.y + 6, 3, 3);
|
||
} else if (m.kind === 'creeper') {
|
||
ctx.fillStyle = '#4CAF50';
|
||
ctx.fillRect(m.x, m.y, m.w, m.h);
|
||
ctx.fillStyle = '#000';
|
||
ctx.fillRect(m.x + 8, m.y + 8, 4, 4);
|
||
ctx.fillRect(m.x + 22, m.y + 8, 4, 4);
|
||
ctx.fillRect(m.x + 12, m.y + 20, 10, 4);
|
||
ctx.fillStyle = '#4CAF50';
|
||
ctx.fillRect(m.x + 4, m.y + 30, 6, 20);
|
||
ctx.fillRect(m.x + 24, m.y + 30, 6, 20);
|
||
} else if (m.kind === 'skeleton') {
|
||
ctx.fillStyle = '#ECEFF1';
|
||
ctx.fillRect(m.x + 10, m.y + 20, 14, 12);
|
||
ctx.fillRect(m.x + 8, m.y + 0, 18, 18);
|
||
ctx.fillStyle = '#000';
|
||
ctx.fillRect(m.x + 10, m.y + 6, 4, 4);
|
||
ctx.fillRect(m.x + 20, m.y + 6, 4, 4);
|
||
ctx.fillRect(m.x + 15, m.y + 12, 4, 2);
|
||
ctx.fillStyle = '#ECEFF1';
|
||
ctx.fillRect(m.x + 2, m.y + 20, 6, 14);
|
||
ctx.fillRect(m.x + 26, m.y + 20, 6, 14);
|
||
ctx.fillRect(m.x + 10, m.y + 32, 6, 18);
|
||
ctx.fillRect(m.x + 18, m.y + 32, 6, 18);
|
||
ctx.save();
|
||
ctx.translate(m.x + 30, m.y + 22);
|
||
ctx.strokeStyle = '#8B4513';
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
ctx.arc(0, 0, 8, -Math.PI * 0.7, Math.PI * 0.7);
|
||
ctx.stroke();
|
||
ctx.strokeStyle = '#ccc';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(8 * Math.cos(-Math.PI * 0.7), 8 * Math.sin(-Math.PI * 0.7));
|
||
ctx.lineTo(8 * Math.cos(Math.PI * 0.7), 8 * Math.sin(Math.PI * 0.7));
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
} else if (m.kind === 'scorpion') {
|
||
// Scorpion: dark brown body, claws, tail
|
||
ctx.fillStyle = '#5D4037';
|
||
ctx.fillRect(m.x, m.y, m.w, m.h);
|
||
ctx.fillStyle = '#8D6E63';
|
||
ctx.fillRect(m.x + 2, m.y + 10, 6, 8); // left claw
|
||
ctx.fillRect(m.x + m.w - 8, m.y + 10, 6, 8); // right claw
|
||
// Tail
|
||
ctx.fillStyle = '#5D4037';
|
||
ctx.beginPath();
|
||
ctx.moveTo(m.x + m.w / 2, m.y);
|
||
ctx.lineTo(m.x + m.w / 2 - 3, m.y - 10);
|
||
ctx.lineTo(m.x + m.w / 2 + 3, m.y - 10);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
ctx.fillStyle = '#FF5722';
|
||
ctx.fillRect(m.x + m.w / 2 - 2, m.y - 12, 4, 4); // stinger
|
||
} else if (m.kind === 'polar_bear') {
|
||
// Polar bear: white body, black nose
|
||
ctx.fillStyle = '#ECEFF1';
|
||
ctx.fillRect(m.x, m.y, m.w, m.h);
|
||
ctx.fillStyle = '#263238';
|
||
ctx.fillRect(m.x + m.w / 2 - 2, m.y + 6, 4, 3); // nose
|
||
ctx.fillRect(m.x + 8, m.y + 12, 3, 3); // eye
|
||
ctx.fillRect(m.x + m.w - 11, m.y + 12, 3, 3); // eye
|
||
// legs
|
||
ctx.fillStyle = '#CFD8DC';
|
||
ctx.fillRect(m.x + 6, m.y + m.h - 8, 8, 8);
|
||
ctx.fillRect(m.x + m.w - 14, m.y + m.h - 8, 8, 8);
|
||
} else if (m.kind === 'slime') {
|
||
// Slime: green bouncy blob
|
||
ctx.fillStyle = '#4CAF50';
|
||
ctx.beginPath();
|
||
ctx.arc(m.x + m.w / 2, m.y + m.h / 2, m.w / 2, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
// Eyes
|
||
ctx.fillStyle = '#fff';
|
||
ctx.fillRect(m.x + 6, m.y + 7, 5, 5);
|
||
ctx.fillRect(m.x + m.w - 11, m.y + 7, 5, 5);
|
||
ctx.fillStyle = '#000';
|
||
ctx.fillRect(m.x + 8, m.y + 9, 2, 2);
|
||
ctx.fillRect(m.x + m.w - 9, m.y + 9, 2, 2);
|
||
} else if (m.kind === 'eagle') {
|
||
// Eagle: brown body, wings, yellow beak
|
||
ctx.fillStyle = '#5D4037';
|
||
ctx.fillRect(m.x + m.w / 2 - 4, m.y + 6, 8, 14); // body
|
||
// Wings
|
||
ctx.fillStyle = '#795548';
|
||
ctx.fillRect(m.x, m.y + 8, m.w / 2 - 4, 6);
|
||
ctx.fillRect(m.x + m.w / 2 + 4, m.y + 8, m.w / 2 - 4, 6);
|
||
// Head
|
||
ctx.fillStyle = '#ECEFF1';
|
||
ctx.fillRect(m.x + m.w / 2 - 3, m.y + 2, 6, 6);
|
||
// Beak
|
||
ctx.fillStyle = '#FFC107';
|
||
ctx.fillRect(m.x + m.w / 2 + 3, m.y + 4, 4, 2);
|
||
// Eye
|
||
ctx.fillStyle = '#000';
|
||
ctx.fillRect(m.x + m.w / 2 - 1, m.y + 4, 2, 2);
|
||
}
|
||
}
|
||
|
||
// boat
|
||
if (boat.active) {
|
||
ctx.drawImage(tex['boat'], boat.x - (TILE - boat.w) / 2, boat.y - (TILE - boat.h) / 2, TILE, TILE);
|
||
}
|
||
|
||
// other players
|
||
for (const [socketId, p] of state.otherPlayers) {
|
||
if (state.heroImg?.complete) {
|
||
ctx.drawImage(state.heroImg, p.x - (TILE - player.w) / 2, p.y - (TILE - player.h) / 2, TILE, TILE);
|
||
} else {
|
||
ctx.fillStyle = p.color;
|
||
ctx.fillRect(p.x, p.y, 34, 34);
|
||
}
|
||
ctx.fillStyle = '#fff';
|
||
ctx.font = '12px system-ui';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(p.name, p.x + 17, p.y - 8);
|
||
}
|
||
|
||
// player
|
||
if (state.heroImg?.complete) {
|
||
ctx.drawImage(state.heroImg, player.x - (TILE - player.w) / 2, player.y - (TILE - player.h) / 2, TILE, TILE);
|
||
} else {
|
||
ctx.fillStyle = '#fff';
|
||
ctx.fillRect(player.x, player.y, player.w, player.h);
|
||
}
|
||
|
||
// projectiles
|
||
for (const p of projectiles) {
|
||
const angle = Math.atan2(p.vy, p.vx);
|
||
ctx.save();
|
||
ctx.translate(p.x, p.y);
|
||
ctx.rotate(angle);
|
||
ctx.fillStyle = p.owner === 'mob' ? '#c0392b' : '#f1c40f';
|
||
ctx.fillRect(-12, -1.5, 24, 3);
|
||
ctx.beginPath();
|
||
ctx.moveTo(12, -4);
|
||
ctx.lineTo(16, 0);
|
||
ctx.lineTo(12, 4);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
ctx.fillStyle = '#888';
|
||
ctx.fillRect(-12, -3, 4, 2);
|
||
ctx.fillRect(-12, 1, 4, 2);
|
||
ctx.restore();
|
||
}
|
||
|
||
// particles
|
||
for (const p of parts) {
|
||
ctx.fillStyle = p.c;
|
||
ctx.fillRect(p.x - 2, p.y - 2, 4, 4);
|
||
}
|
||
|
||
// Стрелы скелета
|
||
for (const m of mobs) {
|
||
if (m.kind === 'skeleton' && m.shootCooldown > 0.5) {
|
||
const arrowX = m.x + m.w / 2;
|
||
const arrowY = m.y + 15;
|
||
const targetX = player.x + player.w / 2;
|
||
const targetY = player.y + player.h / 2;
|
||
const angle = Math.atan2(targetY - arrowY, targetX - arrowX);
|
||
const dx = targetX - arrowX;
|
||
const dy = targetY - arrowY;
|
||
const dist = Math.hypot(dx, dy);
|
||
|
||
ctx.save();
|
||
ctx.translate(arrowX, arrowY);
|
||
ctx.rotate(angle);
|
||
ctx.fillStyle = '#ECEFF1';
|
||
ctx.fillRect(0, -1, 16, 2);
|
||
ctx.restore();
|
||
|
||
if (dist < 150 && player.invuln <= 0) {
|
||
player.hp -= 8;
|
||
player.invuln = 0.5;
|
||
player.vx += Math.cos(angle) * 300;
|
||
player.vy -= 200;
|
||
playSound('hit1');
|
||
}
|
||
}
|
||
}
|
||
|
||
// build ghost
|
||
if (mode() === 'build' && mouse.x !== null && !state.craftOpen && player.hp > 0) {
|
||
const wx = mouse.x + camX;
|
||
const wy = mouse.y + camY;
|
||
const ggx = Math.floor(wx / TILE);
|
||
const ggy = Math.floor(wy / TILE);
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeRect(ggx * TILE, ggy * TILE, TILE, TILE);
|
||
}
|
||
|
||
ctx.restore();
|
||
|
||
// lighting overlay at night
|
||
if (night) {
|
||
const lightC = state.lightC;
|
||
const lightCtx = state.lightCtx;
|
||
|
||
lightC.width = W * state.dpr;
|
||
lightC.height = H * state.dpr;
|
||
lightCtx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0);
|
||
lightCtx.fillStyle = 'rgba(0,0,12,0.82)';
|
||
lightCtx.fillRect(0, 0, W, H);
|
||
|
||
lightCtx.globalCompositeOperation = 'destination-out';
|
||
|
||
function castLight(sx, sy, radius) {
|
||
const flick = 0.97 + Math.sin(now / 300 + sx * 0.01) * 0.015 + Math.sin(now / 470 + sy * 0.02) * 0.015;
|
||
const r = radius * flick;
|
||
const steps2 = 24;
|
||
const dists = new Float32Array(steps2);
|
||
for (let i = 0; i < steps2; i++) {
|
||
const ang = (i / steps2) * Math.PI * 2;
|
||
const ddx = Math.cos(ang);
|
||
const ddy = Math.sin(ang);
|
||
let maxDist = r;
|
||
for (let step = TILE * 0.3; step < r; step += TILE * 0.35) {
|
||
const lgx = Math.floor((sx + ddx * step) / TILE);
|
||
const lgy = Math.floor((sy + ddy * step) / TILE);
|
||
const blk = getBlock(lgx, lgy);
|
||
if (blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid && step > TILE * 0.3) {
|
||
maxDist = step;
|
||
break;
|
||
}
|
||
}
|
||
dists[i] = maxDist;
|
||
}
|
||
const cx = sx - camX, cy = sy - camY;
|
||
const maxR = Math.max(...dists);
|
||
const grad = lightCtx.createRadialGradient(cx, cy, 0, cx, cy, maxR);
|
||
grad.addColorStop(0, 'rgba(255,255,255,1)');
|
||
grad.addColorStop(0.5, 'rgba(255,255,255,0.65)');
|
||
grad.addColorStop(1, 'rgba(255,255,255,0)');
|
||
lightCtx.fillStyle = grad;
|
||
lightCtx.beginPath();
|
||
for (let i = 0; i <= steps2; i++) {
|
||
const idx = i % steps2;
|
||
const ang = (idx / steps2) * Math.PI * 2;
|
||
const px = cx + Math.cos(ang) * dists[idx];
|
||
const py = cy + Math.sin(ang) * dists[idx];
|
||
if (i === 0) lightCtx.moveTo(px, py);
|
||
else lightCtx.lineTo(px, py);
|
||
}
|
||
lightCtx.closePath();
|
||
lightCtx.fill();
|
||
}
|
||
|
||
for (const b of blocks) {
|
||
if (b.dead) continue;
|
||
if (b.gx < minGX - 5 || b.gx > maxGX + 5 || b.gy < minGY - 5 || b.gy > maxGY + 5) continue;
|
||
const def = BLOCKS[b.t];
|
||
if (def.lightRadius) {
|
||
castLight(b.gx * TILE + TILE / 2, b.gy * TILE + TILE / 2, def.lightRadius);
|
||
}
|
||
}
|
||
|
||
lightCtx.globalCompositeOperation = 'source-over';
|
||
ctx.drawImage(lightC, 0, 0, W, H);
|
||
|
||
ctx.save();
|
||
ctx.globalCompositeOperation = 'lighter';
|
||
for (const b of blocks) {
|
||
if (b.dead) continue;
|
||
if (b.gx < minGX - 3 || b.gx > maxGX + 3 || b.gy < minGY - 3 || b.gy > maxGY + 3) continue;
|
||
const def = BLOCKS[b.t];
|
||
if (def.lightRadius) {
|
||
const flick = 0.9 + Math.sin(now / 350 + b.gx * 3.7) * 0.05 + Math.sin(now / 530 + b.gy * 2.3) * 0.05;
|
||
const wx = b.gx * TILE + TILE / 2 - camX;
|
||
const wy = b.gy * TILE + TILE / 2 - camY;
|
||
const r = def.lightRadius * 0.85;
|
||
const grad = ctx.createRadialGradient(wx, wy, 0, wx, wy, r);
|
||
grad.addColorStop(0, `rgba(255,180,80,0.28)`);
|
||
grad.addColorStop(0.5, `rgba(255,140,40,0.13)`);
|
||
grad.addColorStop(1, 'rgba(255,100,20,0)');
|
||
ctx.fillStyle = grad;
|
||
ctx.beginPath();
|
||
ctx.arc(wx, wy, r, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
// Дождь
|
||
drawWeather(ctx);
|
||
if (Math.random() < 0.25) {
|
||
state.hpEl.textContent = Math.max(0, Math.ceil(player.hp));
|
||
state.foodEl.textContent = Math.ceil(player.hunger);
|
||
document.getElementById('o2').textContent = Math.ceil(player.o2);
|
||
// Temperature display
|
||
const tempEl = document.getElementById('temp');
|
||
if (tempEl) {
|
||
const t = Math.round(player.temperature);
|
||
tempEl.textContent = t + '°C';
|
||
if (player.temperature < COLD_THRESHOLD) tempEl.style.color = '#74b9ff';
|
||
else if (player.temperature > HEAT_THRESHOLD) tempEl.style.color = '#e17055';
|
||
else tempEl.style.color = '#55efc4';
|
||
}
|
||
state.sxEl.textContent = Math.floor(player.x / TILE);
|
||
state.syEl.textContent = Math.floor(player.y / TILE);
|
||
state.todEl.textContent = night ? 'Ночь' : 'День';
|
||
state.worldIdEl.textContent = state.worldId;
|
||
if (state.isMultiplayer) {
|
||
document.getElementById('multiplayerStatus').style.display = 'flex';
|
||
state.playerCountEl.textContent = state.otherPlayers.size + 1;
|
||
} else {
|
||
document.getElementById('multiplayerStatus').style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// Индикатор сна
|
||
if (player.sleeping) {
|
||
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||
ctx.fillRect(0, 0, W, H);
|
||
ctx.fillStyle = '#fff';
|
||
ctx.font = 'bold 32px system-ui';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('💤 Спим...', W / 2, H / 2);
|
||
ctx.font = '18px system-ui';
|
||
ctx.fillText('Нажмите на кровать чтобы проснуться', W / 2, H / 2 + 40);
|
||
}
|
||
|
||
// Миникарта
|
||
if (state.minimapOpen && Math.random() < 0.25) {
|
||
renderMinimap();
|
||
}
|
||
|
||
requestAnimationFrame(loop);
|
||
}
|
||
|
||
// === ui/market.js ===
|
||
// ==================== РЫНОК ПРЕДЛОЖЕНИЙ ====================
|
||
|
||
|
||
|
||
let marketPanel, marketClose, marketContent;
|
||
|
||
function initMarket() {
|
||
marketPanel = document.getElementById('marketPanel');
|
||
marketClose = document.getElementById('marketClose');
|
||
marketContent = document.getElementById('marketContent');
|
||
|
||
if (!marketPanel) return;
|
||
|
||
if (marketClose) marketClose.addEventListener('click', () => closeMarket());
|
||
document.addEventListener('toggleMarket', () => toggleMarket());
|
||
}
|
||
|
||
function toggleMarket() {
|
||
if (!marketPanel) return;
|
||
if (marketPanel.style.display === 'none' || !marketPanel.style.display) {
|
||
openMarket();
|
||
} else {
|
||
closeMarket();
|
||
}
|
||
}
|
||
|
||
function closeMarket() {
|
||
if (marketPanel) marketPanel.style.display = 'none';
|
||
}
|
||
|
||
function openMarket() {
|
||
if (marketPanel) {
|
||
marketPanel.style.display = 'block';
|
||
renderMarket();
|
||
}
|
||
}
|
||
|
||
function renderMarket() {
|
||
if (!marketContent) return;
|
||
if (!state.socket) {
|
||
marketContent.innerHTML = '<p style="color:#aaa;">Подключение к серверу...</p>';
|
||
return;
|
||
}
|
||
|
||
state.socket.emit('market_list', {}, (orders) => {
|
||
if (!orders || !orders.length) {
|
||
marketContent.innerHTML = '<p style="color:#aaa;text-align:center;">Предложений пока нет</p><div style="margin-top:12px;"><button id="createOrderBtn" style="width:100%;padding:10px;background:#4a8c4a;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:14px;">+ Выставить предмет</button></div>';
|
||
const btn = document.getElementById('createOrderBtn');
|
||
if (btn) btn.addEventListener('click', showCreateOrder);
|
||
return;
|
||
}
|
||
|
||
let html = '<div style="margin-bottom:8px;"><button id="createOrderBtn" style="padding:8px 14px;background:#4a8c4a;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px;">+ Выставить предмет</button></div>';
|
||
html += orders.map(o => '<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 0;border-bottom:1px solid rgba(255,255,255,0.1);"><div><span style="color:#ffd700;">' + (o.offer_item||'') + '</span> x' + (o.offer_qty||1) + ' <span style="color:#888;"> ⇄ </span><span style="color:#7ecfff;">' + (o.want_item||'') + '</span> x' + (o.want_qty||1) + '</div><div style="font-size:11px;color:#888;">' + (o.seller || 'Аноним') + '</div></div>').join('');
|
||
marketContent.innerHTML = html;
|
||
const btn = document.getElementById('createOrderBtn');
|
||
if (btn) btn.addEventListener('click', showCreateOrder);
|
||
});
|
||
}
|
||
|
||
function showCreateOrder() {
|
||
if (!marketContent) return;
|
||
const invKeys = Object.keys(state.inv || {});
|
||
const invOpts = invKeys.filter(k => (state.inv[k]||0) > 0).map(k => '<option value="'+k+'">'+k+' (x'+state.inv[k]+')</option>').join('');
|
||
|
||
marketContent.innerHTML = '<div style="padding:8px;"><h3 style="margin:0 0 10px;font-size:14px;">Выставить предмет</h3><label style="font-size:12px;color:#aaa;">Отдаю:</label><select id="offerItem" style="width:100%;margin-bottom:8px;padding:6px;background:#222;color:#fff;border:1px solid #555;border-radius:4px;">'+invOpts+'</select><label style="font-size:12px;color:#aaa;">Хочу:</label><input id="wantItem" placeholder="Название предмета" style="width:100%;margin-bottom:8px;padding:6px;background:#222;color:#fff;border:1px solid #555;border-radius:4px;" /><div style="display:flex;gap:8px;"><button id="submitOrder" style="flex:1;padding:8px;background:#4a8c4a;color:#fff;border:none;border-radius:6px;cursor:pointer;">Выставить</button><button id="cancelOrder" style="flex:1;padding:8px;background:#555;color:#fff;border:none;border-radius:6px;cursor:pointer;">Отмена</button></div></div>';
|
||
|
||
const cancelBtn = document.getElementById('cancelOrder');
|
||
if (cancelBtn) cancelBtn.addEventListener('click', () => renderMarket());
|
||
const submitBtn = document.getElementById('submitOrder');
|
||
if (submitBtn) submitBtn.addEventListener('click', () => {
|
||
const offerItem = document.getElementById('offerItem');
|
||
const wantItem = document.getElementById('wantItem');
|
||
if (!offerItem || !wantItem) return;
|
||
const offer = offerItem.value;
|
||
const want = wantItem.value.trim();
|
||
if (!offer || !want) { alert('Выберите предмет и укажите что хотите получить'); return; }
|
||
if (state.socket) {
|
||
state.socket.emit('market_create', { offer_item: offer, offer_qty: 1, want_item: want, want_qty: 1 });
|
||
playSound('click');
|
||
setTimeout(renderMarket, 500);
|
||
}
|
||
});
|
||
}
|
||
|
||
// === ui/settings.js ===
|
||
// Настройки
|
||
|
||
|
||
let settingsPanel = null;
|
||
let dayNightToggle = null;
|
||
|
||
// Weather button IDs in order
|
||
const WEATHER_BTNS = ['weatherClear', 'weatherRain', 'weatherSnow', 'weatherThunder'];
|
||
const WEATHER_ACTIVE_BG = '#3498db';
|
||
const WEATHER_INACTIVE_BG = '#555';
|
||
|
||
function setWeatherButtonActive(activeId) {
|
||
for (const id of WEATHER_BTNS) {
|
||
const btn = document.getElementById(id);
|
||
if (btn) {
|
||
btn.style.background = (id === activeId) ? WEATHER_ACTIVE_BG : WEATHER_INACTIVE_BG;
|
||
}
|
||
}
|
||
}
|
||
|
||
function initSettings() {
|
||
settingsPanel = document.getElementById('settingsPanel');
|
||
dayNightToggle = document.getElementById('dayNightToggle');
|
||
|
||
if (!settingsPanel) return;
|
||
|
||
const closeBtn = document.getElementById('settingsClose');
|
||
if (closeBtn) {
|
||
closeBtn.addEventListener('click', () => {
|
||
settingsPanel.style.display = 'none';
|
||
});
|
||
}
|
||
|
||
// Day/night cycle toggle
|
||
if (dayNightToggle) {
|
||
dayNightToggle.checked = state.dayNightCycle !== false;
|
||
dayNightToggle.addEventListener('change', () => {
|
||
state.dayNightCycle = dayNightToggle.checked;
|
||
});
|
||
}
|
||
|
||
// Time-of-day slider
|
||
const timeSlider = document.getElementById('timeSlider');
|
||
if (timeSlider) {
|
||
timeSlider.value = Math.round(state.worldTime * 100);
|
||
timeSlider.addEventListener('input', () => {
|
||
if (state.isMultiplayer) return;
|
||
state.worldTime = parseInt(timeSlider.value) / 100;
|
||
if (state.dayNightCycle) {
|
||
state.dayNightCycle = false;
|
||
if (dayNightToggle) dayNightToggle.checked = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
// ====== Weather controls (Ясно / Дождь / Снег / Гроза) ======
|
||
const weatherIntensitySlider = document.getElementById('weatherIntensity');
|
||
|
||
// Helper: apply a weather type
|
||
function applyWeather(type) {
|
||
if (state.isMultiplayer) return; // Погода от сервера в MP
|
||
state.weatherType = type;
|
||
state.weatherChangeInterval = Infinity; // Lock weather manually
|
||
|
||
switch (type) {
|
||
case 'clear':
|
||
state.isRaining = false;
|
||
state.rainIntensity = 0;
|
||
if (weatherIntensitySlider) weatherIntensitySlider.value = 0;
|
||
break;
|
||
case 'rain':
|
||
state.isRaining = true;
|
||
state.rainIntensity = parseInt(weatherIntensitySlider?.value || 40) / 100;
|
||
break;
|
||
case 'snow':
|
||
state.isRaining = false; // snow uses its own rendering
|
||
state.rainIntensity = parseInt(weatherIntensitySlider?.value || 40) / 100;
|
||
break;
|
||
case 'thunder':
|
||
state.isRaining = true; // thunder includes rain
|
||
state.rainIntensity = parseInt(weatherIntensitySlider?.value || 60) / 100;
|
||
if (weatherIntensitySlider) weatherIntensitySlider.value = Math.round(state.rainIntensity * 100);
|
||
break;
|
||
}
|
||
|
||
setWeatherButtonActive('weather' + type.charAt(0).toUpperCase() + type.slice(1));
|
||
}
|
||
|
||
// Button handlers
|
||
for (const id of WEATHER_BTNS) {
|
||
const btn = document.getElementById(id);
|
||
if (btn) {
|
||
const weatherType = btn.dataset.weather || id.replace('weather', '').toLowerCase();
|
||
btn.addEventListener('click', () => applyWeather(weatherType));
|
||
}
|
||
}
|
||
|
||
// Intensity slider
|
||
if (weatherIntensitySlider) {
|
||
weatherIntensitySlider.addEventListener('input', () => {
|
||
const val = parseInt(weatherIntensitySlider.value) / 100;
|
||
if (state.weatherType !== 'clear') {
|
||
state.rainIntensity = val;
|
||
if (state.weatherType === 'thunder' || state.weatherType === 'rain') {
|
||
state.isRaining = true;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Listen for toggleSettings custom event
|
||
document.addEventListener('toggleSettings', () => {
|
||
if (settingsPanel) {
|
||
// Sync all controls when panel opens
|
||
if (timeSlider) timeSlider.value = Math.round(state.worldTime * 100);
|
||
if (dayNightToggle) dayNightToggle.checked = state.dayNightCycle !== false;
|
||
if (weatherIntensitySlider) weatherIntensitySlider.value = Math.round(state.rainIntensity * 100);
|
||
// Highlight active weather button
|
||
const wt = state.weatherType || (state.isRaining ? 'rain' : 'clear');
|
||
const activeId = 'weather' + wt.charAt(0).toUpperCase() + wt.slice(1);
|
||
setWeatherButtonActive(activeId);
|
||
settingsPanel.style.display = settingsPanel.style.display === 'none' ? 'block' : 'none';
|
||
}
|
||
});
|
||
}
|
||
|
||
function isDayNightEnabled() {
|
||
return state.dayNightCycle !== false;
|
||
}
|
||
|
||
// === ui/respawn.js ===
|
||
// ==================== RESPAWN ====================
|
||
|
||
|
||
|
||
|
||
function initRespawn() {
|
||
document.getElementById('respawnBtn').onclick = async () => {
|
||
playSound('click'); // Звук клика по кнопке
|
||
|
||
console.log('=== RESPAWN CLICKED ===');
|
||
console.log('isMultiplayer:', state.isMultiplayer);
|
||
console.log('otherPlayers.size:', state.otherPlayers.size);
|
||
console.log('player.hp before respawn:', state.player.hp);
|
||
|
||
const player = state.player;
|
||
const deathEl = state.deathEl;
|
||
|
||
// В мультиплеере не загружаем сохранение, а возрождаемся в начальной точке
|
||
if (state.isMultiplayer && state.otherPlayers.size > 0) {
|
||
console.log('Мультиплеер режим - возрождение в начальной точке');
|
||
player.hp = 100;
|
||
player.hunger = 100;
|
||
player.o2 = 100;
|
||
player.vx = player.vy = 0;
|
||
player.invuln = 0;
|
||
player.x = state.spawnPoint.x;
|
||
player.y = state.spawnPoint.y;
|
||
player.fallStartY = player.y;
|
||
console.log('Возрождение в начальной точке, HP:', player.hp);
|
||
} else {
|
||
console.log('Одиночный режим - загружаем последнее сохранение');
|
||
// Одиночный режим - загружаем последнее сохранение
|
||
const loadedSave = await loadGame();
|
||
if (loadedSave) {
|
||
await applySave(loadedSave);
|
||
console.log('Загружено последнее сохранение после смерти, final HP:', player.hp);
|
||
} else {
|
||
// Если сохранения нет, возрождаемся в начальной точке
|
||
player.hp = 100;
|
||
player.hunger = 100;
|
||
player.o2 = 100;
|
||
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/controls.js ===
|
||
// ==================== УПРАВЛЕНИЕ ====================
|
||
|
||
|
||
const inp = state.inp;
|
||
|
||
function bindHold(el, key) {
|
||
const down = (e) => { e.preventDefault(); state.inp[key] = true; };
|
||
const up = (e) => { e.preventDefault(); state.inp[key] = false; };
|
||
el.addEventListener('pointerdown', down);
|
||
el.addEventListener('pointerup', up);
|
||
el.addEventListener('pointerleave', up);
|
||
}
|
||
|
||
function initControls() {
|
||
const leftBtn = document.getElementById('left');
|
||
const rightBtn = document.getElementById('right');
|
||
const jumpBtn = document.getElementById('jump');
|
||
const downBtn = document.getElementById('down');
|
||
|
||
if (leftBtn) bindHold(leftBtn, 'l');
|
||
if (rightBtn) bindHold(rightBtn, 'r');
|
||
if (jumpBtn) bindHold(jumpBtn, 'j');
|
||
if (downBtn) bindHold(downBtn, 's');
|
||
|
||
window.addEventListener('keydown', (e) => {
|
||
if (e.code === 'KeyA' || e.code === 'ArrowLeft') state.inp.l = true;
|
||
if (e.code === 'KeyD' || e.code === 'ArrowRight') state.inp.r = true;
|
||
if (e.code === 'Space' || e.code === 'KeyW' || e.code === 'ArrowUp') state.inp.j = true;
|
||
if (e.code === 'KeyS' || e.code === 'ArrowDown') state.inp.s = true;
|
||
});
|
||
window.addEventListener('keyup', (e) => {
|
||
if (e.code === 'KeyA' || e.code === 'ArrowLeft') state.inp.l = false;
|
||
if (e.code === 'KeyD' || e.code === 'ArrowRight') state.inp.r = false;
|
||
if (e.code === 'Space' || e.code === 'KeyW' || e.code === 'ArrowUp') state.inp.j = false;
|
||
if (e.code === 'KeyS' || e.code === 'ArrowDown') state.inp.s = false;
|
||
});
|
||
}
|
||
|
||
// === multiplayer/voice-chat.js ===
|
||
// Голосовой чат
|
||
|
||
|
||
|
||
|
||
|
||
let voiceSocket = null;
|
||
let voiceStream = null;
|
||
let audioCtx = null;
|
||
let voiceProcessor = null;
|
||
let voiceActive = false;
|
||
|
||
// Кнопка микрофона
|
||
const voiceBtn = document.createElement('div');
|
||
voiceBtn.innerHTML = '🎤<span style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:36px;color:#e74c3c;font-weight:bold;">/</span>';
|
||
voiceBtn.title = 'Голосовой чат (выкл)';
|
||
voiceBtn.style.cssText = 'position:absolute;top:10px;right:130px;width:52px;height:52px;border-radius:12px;background:#555;z-index:200;display:flex;align-items:center;justify-content:center;font-size:24px;cursor:pointer;border:2px solid rgba(255,255,255,0.9);box-shadow:0 4px 0 rgba(0,0,0,0.5);pointer-events:auto;';
|
||
document.querySelector('.ui').appendChild(voiceBtn);
|
||
|
||
// Индикатор говорящего
|
||
const speakingIndicator = document.createElement('div');
|
||
speakingIndicator.style.cssText = 'position:absolute;top:130px;right:10px;z-index:200;display:none;background:rgba(0,0,0,0.7);color:#2ecc71;padding:4px 8px;border-radius:6px;font-size:12px;';
|
||
speakingIndicator.textContent = '🔊';
|
||
document.querySelector('.ui').appendChild(speakingIndicator);
|
||
let speakingTimeout = null;
|
||
|
||
voiceBtn.onclick = async () => {
|
||
if (voiceActive) {
|
||
// Выключить
|
||
voiceActive = false;
|
||
voiceBtn.innerHTML = '🎤<span style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:36px;color:#e74c3c;font-weight:bold;">/</span>';
|
||
voiceBtn.style.background = '#555';
|
||
if (voiceStream) {
|
||
voiceStream.getTracks().forEach(t => t.stop());
|
||
voiceStream = null;
|
||
}
|
||
if (voiceProcessor) { voiceProcessor.disconnect(); voiceProcessor = null; }
|
||
if (audioCtx) { audioCtx.close(); audioCtx = null; }
|
||
if (voiceSocket) { voiceSocket.disconnect(); voiceSocket = null; }
|
||
return;
|
||
}
|
||
|
||
// Включить
|
||
try {
|
||
voiceStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 24000 } });
|
||
audioCtx = new AudioContext({ sampleRate: 24000 });
|
||
const source = audioCtx.createMediaStreamSource(voiceStream);
|
||
voiceProcessor = audioCtx.createScriptProcessor(4096, 1, 1);
|
||
|
||
voiceProcessor.onaudioprocess = (e) => {
|
||
if (!voiceActive || !voiceSocket || !voiceSocket.connected) return;
|
||
const pcm = e.inputBuffer.getChannelData(0);
|
||
// Конвертируем float32 → int16 для экономии трафика
|
||
const int16 = new Int16Array(pcm.length);
|
||
for (let i = 0; i < pcm.length; i++) {
|
||
const s = Math.max(-1, Math.min(1, pcm[i]));
|
||
int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
||
}
|
||
voiceSocket.emit('voice_data', int16.buffer);
|
||
};
|
||
|
||
source.connect(voiceProcessor);
|
||
voiceProcessor.connect(audioCtx.createGain()); // не в destination — чтобы не слышать себя
|
||
|
||
// Подключаемся к голосовому серверу
|
||
voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] });
|
||
voiceSocket.on('connect', () => {
|
||
voiceSocket.emit('voice_join', { world_id: state.worldId, x: state.player.x, y: state.player.y, name: state.playerName || 'Игрок' });
|
||
});
|
||
|
||
voiceSocket.on('voice_in', (payload) => {
|
||
// Воспроизводим входящий голос
|
||
const { data, meta, volume: rawVolume } = payload;
|
||
const volume = rawVolume !== undefined ? rawVolume : 1.0;
|
||
if (!audioCtx || audioCtx.state === 'closed') return;
|
||
|
||
// Int16 → Float32 (volume applied via gain node, not in conversion)
|
||
const int16 = new Int16Array(data);
|
||
const float32 = new Float32Array(int16.length);
|
||
for (let i = 0; i < int16.length; i++) {
|
||
float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF);
|
||
}
|
||
|
||
const buf = audioCtx.createBuffer(1, float32.length, 24000);
|
||
buf.getChannelData(0).set(float32);
|
||
const src = audioCtx.createBufferSource();
|
||
src.buffer = buf;
|
||
|
||
const gain = audioCtx.createGain();
|
||
gain.gain.value = volume;
|
||
src.connect(gain).connect(audioCtx.destination);
|
||
src.start();
|
||
|
||
// Индикатор
|
||
speakingIndicator.style.display = 'block';
|
||
speakingIndicator.textContent = `🔊 ${meta.name}`;
|
||
clearTimeout(speakingTimeout);
|
||
speakingTimeout = setTimeout(() => { speakingIndicator.style.display = 'none'; }, 500);
|
||
});
|
||
|
||
voiceActive = true;
|
||
voiceBtn.textContent = '🎤';
|
||
voiceBtn.style.background = '#2ecc71';
|
||
} catch (e) {
|
||
console.error('Voice error:', e);
|
||
voiceBtn.style.background = '#e74c3c';
|
||
}
|
||
};
|
||
|
||
function initVoice() {
|
||
// Voice position update hook — should be called from main loop
|
||
let voicePosT = 0;
|
||
return {
|
||
update(dt) {
|
||
voicePosT += dt;
|
||
if (voicePosT > 0.5 && voiceSocket && voiceSocket.connected) {
|
||
voicePosT = 0;
|
||
voiceSocket.emit('voice_pos', { x: state.player.x, y: state.player.y });
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
// === ui/craft.js ===
|
||
// Craft UI
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
function canCraft(r) {
|
||
const inv = state.inv;
|
||
for (const res in r.cost) {
|
||
if ((inv[res] || 0) < r.cost[res]) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function renderCraft() {
|
||
const inv = state.inv;
|
||
const recipesEl = state.recipesEl;
|
||
recipesEl.innerHTML = '';
|
||
for (const r of RECIPES) {
|
||
const row = document.createElement('div');
|
||
row.className = 'recipe';
|
||
const icon = document.createElement('div');
|
||
icon.className = 'ricon';
|
||
// Иконка результата — canvas sprite > emoji
|
||
if (tex[r.out]) {
|
||
icon.style.backgroundImage = `url(${tex[r.out].toDataURL()})`;
|
||
} else if (itemTex[r.out]) {
|
||
icon.style.backgroundImage = `url(${itemTex[r.out].toDataURL()})`;
|
||
} else if (TOOLS[r.out]) {
|
||
icon.textContent = TOOLS[r.out].icon || '🔧';
|
||
icon.style.fontSize = '24px';
|
||
icon.style.display = 'flex';
|
||
icon.style.alignItems = 'center';
|
||
icon.style.justifyContent = 'center';
|
||
} else if (ITEMS[r.out]) {
|
||
icon.textContent = ITEMS[r.out].icon || '📦';
|
||
icon.style.fontSize = '24px';
|
||
icon.style.display = 'flex';
|
||
icon.style.alignItems = 'center';
|
||
icon.style.justifyContent = 'center';
|
||
}
|
||
const info = document.createElement('div');
|
||
info.className = 'rinfo';
|
||
const nm = document.createElement('div');
|
||
nm.className = 'rname';
|
||
const itemName = BLOCKS[r.out]?.n || TOOLS[r.out]?.n || ITEMS[r.out]?.n || r.out;
|
||
nm.textContent = `${itemName} x${r.qty}`;
|
||
const cs = document.createElement('div');
|
||
cs.className = 'rcost';
|
||
cs.textContent = Object.keys(r.cost).map(x => {
|
||
const cn = BLOCKS[x]?.n || TOOLS[x]?.n || ITEMS[x]?.n || x;
|
||
return `${cn}: ${(inv[x] || 0)}/${r.cost[x]}`;
|
||
}).join(' ');
|
||
info.appendChild(nm); info.appendChild(cs);
|
||
const btn = document.createElement('button');
|
||
btn.className = 'rcraft';
|
||
btn.textContent = 'Создать';
|
||
btn.disabled = !canCraft(r);
|
||
btn.onclick = () => {
|
||
if (!canCraft(r)) return;
|
||
playSound('click');
|
||
for (const res in r.cost) inv[res] -= r.cost[res];
|
||
inv[r.out] = (inv[r.out] || 0) + r.qty;
|
||
if (TOOLS[r.out]) addTool(r.out);
|
||
if (!state.recentItems.includes(r.out)) { state.recentItems.unshift(r.out); state.recentItems = state.recentItems.slice(0, 5); }
|
||
rebuildHotbar();
|
||
renderCraft();
|
||
renderInventory();
|
||
};
|
||
row.appendChild(icon); row.appendChild(info); row.appendChild(btn);
|
||
recipesEl.appendChild(row);
|
||
}
|
||
}
|
||
|
||
let craftOpen = false;
|
||
let inventoryOpen = false;
|
||
|
||
function toggleCraft() {
|
||
playSound('click');
|
||
craftOpen = !craftOpen;
|
||
state.craftPanel.style.display = craftOpen ? 'block' : 'none';
|
||
if (craftOpen) {
|
||
renderCraft();
|
||
inventoryOpen = false;
|
||
state.inventoryPanel.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function closeCraft() {
|
||
playSound('click');
|
||
craftOpen = false;
|
||
state.craftPanel.style.display = 'none';
|
||
}
|
||
|
||
function toggleInventory() {
|
||
playSound('click');
|
||
inventoryOpen = true;
|
||
state.inventoryPanel.style.display = 'block';
|
||
renderInventory();
|
||
craftOpen = false;
|
||
state.craftPanel.style.display = 'none';
|
||
}
|
||
|
||
function closeInventory() {
|
||
playSound('click');
|
||
inventoryOpen = false;
|
||
state.inventoryPanel.style.display = 'none';
|
||
}
|
||
|
||
// === main.js ===
|
||
// ==================== ENTRY POINT ====================
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
// ==================== 1) CONFIG — parse URL ====================
|
||
// urlParams defined in config.js
|
||
state.SERVER_URL = SERVER_URL;
|
||
state.TELEGRAM_BOT_USERNAME = 'Grechkacraft_bot';
|
||
state.TELEGRAM_APP_SHORT_NAME = 'minegrechka';
|
||
|
||
// Set TG-related state
|
||
if (isTgWebApp) {
|
||
state.myTgId = tgUser?.id || null;
|
||
}
|
||
|
||
state.heroImg = new Image(); state.heroImg.src = 'https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png';
|
||
|
||
// ==================== 2) START MENU ====================
|
||
// Show the start menu before initializing the game.
|
||
// The menu returns a Promise that resolves with the worldId.
|
||
async function startGame() {
|
||
const worldId = await showStartMenu();
|
||
|
||
// Set worldId and playerName
|
||
state.worldId = worldId;
|
||
if (!state.playerName) {
|
||
state.playerName = localStorage.getItem('minegrechka_playerName') || 'Игрок';
|
||
localStorage.setItem('minegrechka_playerName', state.playerName);
|
||
}
|
||
console.log('[Launch] worldId:', state.worldId, 'player:', state.playerName, 'tg:', isTgWebApp);
|
||
|
||
// Validate TG initData if in TG context
|
||
if (isTgWebApp && window.Telegram && Telegram.WebApp.initData) {
|
||
try {
|
||
const resp = await fetch(state.SERVER_URL + '/api/tg/auth', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ initData: Telegram.WebApp.initData })
|
||
});
|
||
const data = await resp.json();
|
||
if (data.ok && data.tg_id) {
|
||
console.log('[TG] Authenticated, tg_id:', data.tg_id);
|
||
state.myTgId = data.tg_id;
|
||
}
|
||
} catch (e) { console.warn('[TG] Auth failed:', e); }
|
||
}
|
||
|
||
// 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();
|
||
|
||
// ==================== 8) MOUSE — initMouseHandlers ====================
|
||
initMouseHandlers();
|
||
|
||
// ==================== 9) UI MODULES — init all UI ====================
|
||
initChat();
|
||
initFurnace();
|
||
initMinimap();
|
||
initSaveControls();
|
||
initMarket();
|
||
initRespawn();
|
||
initShare();
|
||
initSettings();
|
||
initModes();
|
||
|
||
// ==================== 10) SOCKET — initSocket ====================
|
||
initSocket();
|
||
|
||
// ==================== 11) VOICE — initVoice ====================
|
||
initVoice();
|
||
|
||
// ==================== 12) SAVE — loadGame, applySave ====================
|
||
// Ensure hotbar has items on first load
|
||
if (state.recentItems.length === 0) {
|
||
state.recentItems = Object.keys(state.inv).filter(k => state.inv[k] > 0).slice(0, 5);
|
||
}
|
||
// Make sure selected item is valid and in recentItems
|
||
if (!state.selected || !state.inv[state.selected] || state.inv[state.selected] <= 0) {
|
||
state.selected = state.recentItems[0] || 'dirt';
|
||
}
|
||
if (!state.recentItems.includes(state.selected)) {
|
||
state.recentItems.unshift(state.selected);
|
||
state.recentItems = state.recentItems.slice(0, 5);
|
||
}
|
||
rebuildHotbar();
|
||
|
||
// 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;
|
||
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;
|
||
|
||
// старт — на поверхности
|
||
const startGX = 6;
|
||
for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx);
|
||
const surfaceY = surfaceGyAt(startGX);
|
||
let safeGY = surfaceY - 1;
|
||
const aboveBlock = getBlock(startGX, surfaceY - 1);
|
||
if (aboveBlock && aboveBlock.t === 'water') {
|
||
for (let gy = state.SEA_GY - 1; gy >= 0; gy--) {
|
||
const b = getBlock(startGX, gy);
|
||
if (!b || b.dead || b.t === 'air' || b.t === 'water') continue;
|
||
safeGY = gy - 1;
|
||
break;
|
||
}
|
||
}
|
||
state.player.y = safeGY * state.TILE;
|
||
state.player.x = startGX * state.TILE;
|
||
state.player.fallStartY = state.player.y;
|
||
|
||
state.spawnPoint.x = state.player.x;
|
||
state.spawnPoint.y = state.player.y;
|
||
|
||
console.log('Новая игра: startGX=', startGX, 'surfaceY=', surfaceY, 'player.y=', state.player.y);
|
||
|
||
// Генерируем карту вокруг стартовой позиции
|
||
for (let gx = startGX - 50; gx <= startGX + 50; gx++) {
|
||
genColumn(gx);
|
||
}
|
||
}
|
||
|
||
// Автосейв при скрытии страницы
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.hidden && !state.isMultiplayer) {
|
||
saveGame();
|
||
}
|
||
});
|
||
|
||
// Автосейв перед закрытием страницы
|
||
window.addEventListener('beforeunload', () => {
|
||
if (!state.isMultiplayer) {
|
||
saveGame();
|
||
}
|
||
});
|
||
}).catch(err => {
|
||
console.error('Ошибка инициализации:', err);
|
||
const startGX = 6;
|
||
genColumn(startGX);
|
||
state.player.y = (surfaceGyAt(startGX) - 1) * state.TILE;
|
||
state.player.fallStartY = state.player.y;
|
||
|
||
for (let gx = startGX - 50; gx <= startGX + 50; gx++) {
|
||
genColumn(gx);
|
||
}
|
||
});
|
||
|
||
// ==================== 13) LOOP — startLoop ====================
|
||
requestAnimationFrame(loop);
|
||
|
||
// ==================== 14) WORLD-GEN PLACEHOLDER ====================
|
||
// 2D-мир генерируется лениво, колонками вокруг игрока. В первые секунды
|
||
// на экране только небо и HUD — пользователь считает это поломкой.
|
||
installWorldGenPlaceholder();
|
||
}
|
||
|
||
// ==================== WORLD-GEN PLACEHOLDER ====================
|
||
var _wgPh = null;
|
||
|
||
function worldHasVisibleBlocks() {
|
||
try {
|
||
var gx = Math.floor((state.player && state.player.x || 0) / state.TILE);
|
||
for (var dx = -3; dx <= 3; dx++) {
|
||
for (var up = 0; up < 30; up++) {
|
||
var b = getBlock(gx + dx, up);
|
||
if (b && b.t && b.t !== 'air' && b.t !== 'water' && b.t !== 'swamp_water') return true;
|
||
}
|
||
}
|
||
} catch (e) { /* getBlock может быть недоступен на ранних кадрах */ }
|
||
return false;
|
||
}
|
||
|
||
function installWorldGenPlaceholder() {
|
||
if (_wgPh || document.getElementById('wgPh')) return;
|
||
hideBoot();
|
||
|
||
var el = document.createElement('div');
|
||
el.id = 'wgPh';
|
||
el.setAttribute('role', 'status');
|
||
el.setAttribute('aria-live', 'polite');
|
||
el.innerHTML =
|
||
'<div class="wgPh-box">' +
|
||
'<div class="wgPh-title">Генерация мира</div>' +
|
||
'<div class="wgPh-bar"><i></i></div>' +
|
||
'<div class="wgPh-hint">Первые секунды мир строится по колонкам</div>' +
|
||
'</div>';
|
||
document.body.appendChild(el);
|
||
_wgPh = el;
|
||
|
||
var st = document.createElement('style');
|
||
st.textContent = [
|
||
'#wgPh{position:fixed;inset:0;z-index:2147483000;display:flex;align-items:center;',
|
||
'justify-content:center;background:linear-gradient(#0f1720,#1c2b3a);',
|
||
'font:14px/1.4 system-ui,-apple-system,sans-serif;color:#e8f0f8}',
|
||
'#wgPh.gone{display:none}',
|
||
'.wgPh-box{text-align:center;max-width:320px;padding:0 24px}',
|
||
'.wgPh-title{font-size:18px;margin-bottom:14px}',
|
||
'.wgPh-bar{height:6px;background:rgba(255,255,255,.12);border-radius:3px;overflow:hidden}',
|
||
'.wgPh-bar>i{display:block;height:100%;width:35%;background:#5a8f3c;border-radius:3px;',
|
||
'animation:wgPhSlide 1.1s ease-in-out infinite}',
|
||
'@keyframes wgPhSlide{0%{margin-left:-35%}100%{margin-left:100%}}',
|
||
'.wgPh-hint{margin-top:12px;color:#8fa3b8;font-size:12px}'
|
||
].join('');
|
||
document.head.appendChild(st);
|
||
|
||
var deadline = Date.now() + 60000;
|
||
var born = Date.now();
|
||
var tick = function() {
|
||
if (!_wgPh || !_wgPh.isConnected) return;
|
||
if (Date.now() > deadline) {
|
||
el.remove();
|
||
return;
|
||
}
|
||
if (!worldHasVisibleBlocks() || Date.now() - born < 600) el.classList.remove('gone');
|
||
else el.classList.add('gone');
|
||
setTimeout(tick, 200);
|
||
};
|
||
setTimeout(tick, 150);
|
||
}
|
||
|
||
// Start the game
|
||
startGame().catch(err => {
|
||
console.error('Fatal error starting game:', err);
|
||
});
|
||
|
||
// Debug: give items
|
||
window.debugGive = function(id, count) {
|
||
state.inv[id] = (state.inv[id] || 0) + (count || 1);
|
||
if (!state.recentItems.includes(id)) { state.recentItems.unshift(id); state.recentItems = state.recentItems.slice(0, 5); }
|
||
rebuildHotbar();
|
||
// Can't call renderInventory from here, but user can click inv
|
||
console.log('Gave', count || 1, id, '=>', state.inv[id]);
|
||
console.log('inv keys>0:', Object.keys(state.inv).filter(k => state.inv[k] > 0).join(', '));
|
||
};
|
||
window.debugInv = function() { return Object.keys(state.inv).filter(k => state.inv[k] > 0).map(k => k + ':' + state.inv[k]); };
|
||
|
||
})();
|