const express = require('express'); const app = express(); const http = require('http').createServer(app); const io = require('socket.io')(http); const path = require('path'); const fs = require('fs'); // Глобальная защита от падения сервера process.on('uncaughtException', (err) => { console.error('SERVER ERROR CAUGHT:', err); }); app.use(express.static(path.join(__dirname, 'public'))); if (!fs.existsSync('./saves')){ fs.mkdirSync('./saves'); } const BLOCK_SIZE = 32; const WORLD_WIDTH = 400; const WORLD_HEIGHT = 150; const DAY_DURATION = 600000; // 10 минут const GRAVITY = 0.5; const BLOCKS = { AIR: 0, DIRT: 1, STONE: 2, GRASS: 3, WOOD: 4, LEAVES: 5, WORKBENCH: 6, FURNACE: 7, SAND: 8, COAL_ORE: 9, IRON_ORE: 10, GOLD_ORE: 11, COPPER_ORE: 12, GLASS: 13, BRICK: 14, TORCH: 15, WATER: 16, CHEST: 17 }; // Блоки, через которые можно проходить (фоновые, как деревья в Террарии) const PASSABLE_BLOCKS = [BLOCKS.AIR, BLOCKS.WOOD, BLOCKS.LEAVES, BLOCKS.TORCH]; function isPassable(blockType) { return PASSABLE_BLOCKS.includes(blockType); } const ITEMS = { WOODEN_SWORD: 100, WOODEN_PICKAXE: 101, WOODEN_AXE: 102, STONE_SWORD: 103, STONE_PICKAXE: 104, STONE_AXE: 105, IRON_SWORD: 106, IRON_PICKAXE: 107, IRON_AXE: 108, COAL: 109, IRON_BAR: 110, GOLD_BAR: 111, COPPER_BAR: 112, HEALTH_POTION: 113, MANA_POTION: 114 }; // Характеристики мобов const MOB_STATS = { slime: { hp: 20, damage: 1, speed: 1.5, jumpForce: 6, attackRange: 30, aggroRange: 150, attackCooldown: 1000, width: 32, height: 32 }, zombie: { hp: 40, damage: 2, speed: 1.2, jumpForce: 8, attackRange: 35, aggroRange: 200, attackCooldown: 800, width: 32, height: 48 }, skeleton: { hp: 30, damage: 2, speed: 1.8, jumpForce: 7, attackRange: 40, aggroRange: 250, attackCooldown: 1200, width: 32, height: 48 }, demon: { hp: 60, damage: 3, speed: 2.0, jumpForce: 10, attackRange: 45, aggroRange: 300, attackCooldown: 600, width: 32, height: 36 } }; const lobbies = {}; function getEmptyInventory() { let inv = {}; [...Object.values(BLOCKS), ...Object.values(ITEMS)].forEach(id => inv[id] = 0); return inv; } function generateWorld() { let world = Array(WORLD_HEIGHT).fill().map(() => Array(WORLD_WIDTH).fill(BLOCKS.AIR)); const surfaceHeight = []; // Базовый уровень поверхности const baseLevel = 50; // Уровень воды const waterLevel = baseLevel + 12; // Генерация поверхности с разнообразным рельефом for (let x = 0; x < WORLD_WIDTH; x++) { let height = baseLevel; height += Math.sin(x * 0.02) * 8; height += Math.sin(x * 0.05) * 4; height += Math.cos(x * 0.1) * 2; // Озеро слева (x: 15-70) - глубокая впадина if (x >= 15 && x <= 70) { const lakeCenter = 42; const distFromCenter = Math.abs(x - lakeCenter); const lakeHalfWidth = 28; if (distFromCenter < lakeHalfWidth) { const depthFactor = 1 - (distFromCenter / lakeHalfWidth); const maxDepth = 20; height = waterLevel + 3 + Math.floor(maxDepth * depthFactor * depthFactor); } } surfaceHeight[x] = Math.floor(height); } // Заполнение блоками for (let x = 0; x < WORLD_WIDTH; x++) { const surface = surfaceHeight[x]; for (let y = surface; y < WORLD_HEIGHT; y++) { if (y === surface) { // Песок вокруг озера и на дне if (x >= 10 && x <= 75) { world[y][x] = BLOCKS.SAND; } else { world[y][x] = Math.random() < 0.1 ? BLOCKS.SAND : BLOCKS.GRASS; } } else if (y < surface + 4 + Math.floor(Math.random() * 2)) { if (x >= 10 && x <= 75) { world[y][x] = BLOCKS.SAND; } else { world[y][x] = BLOCKS.DIRT; } } else { world[y][x] = BLOCKS.STONE; const depth = y - surface; if (Math.random() < 0.025 && depth > 8) world[y][x] = BLOCKS.COAL_ORE; else if (Math.random() < 0.012 && depth > 15) world[y][x] = BLOCKS.COPPER_ORE; else if (Math.random() < 0.010 && depth > 25) world[y][x] = BLOCKS.IRON_ORE; else if (Math.random() < 0.006 && depth > 40) world[y][x] = BLOCKS.GOLD_ORE; } } } // Заливаем озеро водой for (let x = 15; x <= 70; x++) { const bottomY = surfaceHeight[x]; if (bottomY > waterLevel) { for (let y = waterLevel; y < bottomY; y++) { if (y >= 0 && y < WORLD_HEIGHT) { world[y][x] = BLOCKS.WATER; } } } } // Пещеры for (let i = 0; i < 30; i++) { let cx = Math.floor(Math.random() * (WORLD_WIDTH - 40)) + 20; let cy = Math.floor(Math.random() * 50) + 70; const caveLength = Math.floor(Math.random() * 40) + 20; for (let j = 0; j < caveLength; j++) { const radius = Math.floor(Math.random() * 3) + 2; for (let dy = -radius; dy <= radius; dy++) { for (let dx = -radius; dx <= radius; dx++) { if (dx*dx + dy*dy <= radius*radius) { const nx = cx + dx; const ny = cy + dy; if (ny >= 60 && ny < WORLD_HEIGHT && nx >= 0 && nx < WORLD_WIDTH) { if (world[ny][nx] !== BLOCKS.WATER) { world[ny][nx] = BLOCKS.AIR; } } } } } cx += Math.floor(Math.random() * 5) - 2; cy += Math.floor(Math.random() * 5) - 2; cx = Math.max(5, Math.min(WORLD_WIDTH - 5, cx)); cy = Math.max(60, Math.min(WORLD_HEIGHT - 5, cy)); } } // Деревья справа от озера for (let x = 85; x < WORLD_WIDTH - 10; x += Math.floor(Math.random() * 8) + 6) { const surface = surfaceHeight[x]; if (!world[surface] || world[surface][x] !== BLOCKS.GRASS) continue; if (surface < WORLD_HEIGHT - 1 && surface > 10) { const treeHeight = Math.floor(Math.random() * 4) + 5; for (let y = surface - treeHeight; y < surface; y++) { if (y >= 0) world[y][x] = BLOCKS.WOOD; } // Крона (овальная форма - широкая и высокая) for (let dy = -4; dy <= 1; dy++) { for (let dx = -3; dx <= 3; dx++) { const ty = surface - treeHeight + dy; const tx = x + dx; // Овальная форма: шире по X, выше по Y const distX = dx / 3.5; // Ширина ~7 блоков const distY = (dy + 1.5) / 2.5; // Высота ~5 блоков const dist = Math.sqrt(distX * distX + distY * distY); if (dist > 1) continue; if (ty >= 0 && ty < WORLD_HEIGHT && tx >= 0 && tx < WORLD_WIDTH) { if (world[ty][tx] === BLOCKS.AIR) { world[ty][tx] = BLOCKS.LEAVES; } } } } } } // Немного деревьев слева от озера for (let x = 3; x < 10; x += Math.floor(Math.random() * 4) + 3) { const surface = surfaceHeight[x]; if (!world[surface] || world[surface][x] !== BLOCKS.GRASS) continue; if (surface < WORLD_HEIGHT - 1 && surface > 10) { const treeHeight = Math.floor(Math.random() * 3) + 4; for (let y = surface - treeHeight; y < surface; y++) { if (y >= 0) world[y][x] = BLOCKS.WOOD; } // Крона (овальная форма) for (let dy = -3; dy <= 1; dy++) { for (let dx = -2; dx <= 2; dx++) { const ty = surface - treeHeight + dy; const tx = x + dx; // Овальная форма const distX = dx / 2.5; const distY = (dy + 1) / 2; const dist = Math.sqrt(distX * distX + distY * distY); if (dist > 1) continue; if (ty >= 0 && ty < WORLD_HEIGHT && tx >= 0 && tx < WORLD_WIDTH) { if (world[ty][tx] === BLOCKS.AIR) { world[ty][tx] = BLOCKS.LEAVES; } } } } } } return world; } function saveLobby(lobbyId) { if (!lobbies[lobbyId]) return; const savedPlayersData = lobbies[lobbyId].savedPlayers || {}; for (let socketId in lobbies[lobbyId].players) { const p = lobbies[lobbyId].players[socketId]; savedPlayersData[p.nickname] = { inventory: p.inventory, hotbar: p.hotbarSlots, health: p.health || 20, team: p.team || null }; } const data = { world: lobbies[lobbyId].world, savedPlayers: savedPlayersData, timeOfDay: lobbies[lobbyId].timeOfDay, dayCount: lobbies[lobbyId].dayCount || 1, // Сохраняем счётчик дней chests: lobbies[lobbyId].chests || {} // Сохраняем сундуки }; fs.writeFile(`./saves/${lobbyId}.json`, JSON.stringify(data), (err) => { if (err) console.error(`Err save:`, err); }); } function loadLobby(lobbyId) { try { if (fs.existsSync(`./saves/${lobbyId}.json`)) { const raw = fs.readFileSync(`./saves/${lobbyId}.json`); const data = JSON.parse(raw); return { world: data.world, savedPlayers: data.savedPlayers || {}, players: {}, mobs: [], chests: data.chests || {}, droppedItems: [], // Выпавшие предметы nextDropId: 1, timeOfDay: data.timeOfDay || 0, dayCount: data.dayCount || 1, // Счётчик дней lastUpdate: Date.now() }; } } catch (e) { console.error("Load err:", e); } return null; } // Проверка столкновения двух прямоугольников function checkCollision(x1, y1, w1, h1, x2, y2, w2, h2) { return x1 < x2 + w2 && x1 + w1 > x2 && y1 < y2 + h2 && y1 + h1 > y2; } // Расстояние между двумя объектами function getDistance(obj1, obj2) { const cx1 = obj1.x + (obj1.width || 24) / 2; const cy1 = obj1.y + (obj1.height || 48) / 2; const cx2 = obj2.x + obj2.width / 2; const cy2 = obj2.y + obj2.height / 2; return Math.sqrt(Math.pow(cx1 - cx2, 2) + Math.pow(cy1 - cy2, 2)); } // Найти ближайшего игрока к мобу function findNearestPlayer(mob, players) { let nearest = null; let minDist = Infinity; for (let id in players) { const player = players[id]; if (player.health <= 0) continue; // Пропускаем мёртвых игроков const dist = getDistance(player, mob); if (dist < minDist) { minDist = dist; nearest = { id, player, distance: dist }; } } return nearest; } // Проверка столкновения с блоками (игнорирует фоновые блоки) function checkBlockCollision(x, y, width, height, world) { const left = Math.floor(x / BLOCK_SIZE); const right = Math.floor((x + width - 1) / BLOCK_SIZE); const top = Math.floor(y / BLOCK_SIZE); const bottom = Math.floor((y + height - 1) / BLOCK_SIZE); for (let by = top; by <= bottom; by++) { for (let bx = left; bx <= right; bx++) { if (by >= 0 && by < WORLD_HEIGHT && bx >= 0 && bx < WORLD_WIDTH) { const block = world[by] && world[by][bx]; if (block && !isPassable(block)) return true; } } } return false; } setInterval(() => { Object.keys(lobbies).forEach(id => saveLobby(id)); }, 30000); // === ТЕКУЧЕСТЬ ВОДЫ === const WATER_UPDATE_INTERVAL = 100; // Быстрее обновление const WATER_MAX_SPREAD = 7; // Максимальное распространение в стороны function updateWaterForLobby(lobby, lobbyId) { const world = lobby.world; const waterUpdates = []; // Функция подсчёта расстояния до источника воды (блок воды сверху или большое количество воды рядом) function countNearbyWaterSource(x, y) { let waterAbove = 0; // Проверяем воду сверху (это источник) for (let checkY = y - 1; checkY >= Math.max(0, y - 10); checkY--) { if (world[checkY] && world[checkY][x] === BLOCKS.WATER) { waterAbove++; } else { break; } } return waterAbove; } // Функция проверки, есть ли рядом источник воды function hasWaterSource(x, y) { // Проверяем воду сверху if (y > 0 && world[y - 1] && world[y - 1][x] === BLOCKS.WATER) { return true; } // Проверяем сколько воды рядом по горизонтали в пределах WATER_MAX_SPREAD let waterLeft = 0; let waterRight = 0; for (let i = 1; i <= WATER_MAX_SPREAD; i++) { if (x - i >= 0 && world[y] && world[y][x - i] === BLOCKS.WATER) { // Проверяем есть ли у этой воды источник сверху if (y > 0 && world[y - 1] && world[y - 1][x - i] === BLOCKS.WATER) { return true; } waterLeft++; } else { break; } } for (let i = 1; i <= WATER_MAX_SPREAD; i++) { if (x + i < WORLD_WIDTH && world[y] && world[y][x + i] === BLOCKS.WATER) { // Проверяем есть ли у этой воды источник сверху if (y > 0 && world[y - 1] && world[y - 1][x + i] === BLOCKS.WATER) { return true; } waterRight++; } else { break; } } return false; } // Функция подсчёта расстояния до ближайшего источника function getDistanceToSource(x, y) { // Ищем воду с источником сверху for (let dist = 0; dist <= WATER_MAX_SPREAD; dist++) { // Проверяем слева if (x - dist >= 0 && world[y] && world[y][x - dist] === BLOCKS.WATER) { if (y > 0 && world[y - 1] && world[y - 1][x - dist] === BLOCKS.WATER) { return dist; } } // Проверяем справа if (x + dist < WORLD_WIDTH && world[y] && world[y][x + dist] === BLOCKS.WATER) { if (y > 0 && world[y - 1] && world[y - 1][x + dist] === BLOCKS.WATER) { return dist; } } } return WATER_MAX_SPREAD + 1; } // Сканируем весь мир for (let y = WORLD_HEIGHT - 2; y >= 0; y--) { if (!world[y]) continue; for (let x = 0; x < WORLD_WIDTH; x++) { if (world[y][x] === BLOCKS.WATER) { // Течём вниз - всегда if (y + 1 < WORLD_HEIGHT && world[y + 1] && world[y + 1][x] === BLOCKS.AIR) { waterUpdates.push({ x, y: y + 1, type: BLOCKS.WATER }); } // Течём в стороны только если снизу твёрдый блок if (y + 1 < WORLD_HEIGHT && world[y + 1] && world[y + 1][x] !== BLOCKS.AIR && world[y + 1][x] !== BLOCKS.WATER) { // Проверяем расстояние до источника const distToSource = getDistanceToSource(x, y); // Распространяемся только если не достигли максимума if (distToSource < WATER_MAX_SPREAD) { // Влево if (x - 1 >= 0 && world[y][x - 1] === BLOCKS.AIR) { const newDist = getDistanceToSource(x - 1, y); if (newDist <= WATER_MAX_SPREAD) { waterUpdates.push({ x: x - 1, y, type: BLOCKS.WATER }); } } // Вправо if (x + 1 < WORLD_WIDTH && world[y][x + 1] === BLOCKS.AIR) { const newDist = getDistanceToSource(x + 1, y); if (newDist <= WATER_MAX_SPREAD) { waterUpdates.push({ x: x + 1, y, type: BLOCKS.WATER }); } } } } } } } // Применяем обновления и отправляем клиентам const maxUpdates = 50; // Больше обновлений за тик for (let i = 0; i < Math.min(waterUpdates.length, maxUpdates); i++) { const update = waterUpdates[i]; if (world[update.y] && world[update.y][update.x] === BLOCKS.AIR) { world[update.y][update.x] = update.type; // Отправляем обновление всем игрокам в лобби io.to(lobbyId).emit('blockUpdated', { x: update.x, y: update.y, type: update.type }); } } } // === ГЛАВНЫЙ ИГРОВОЙ ЦИКЛ === setInterval(() => { try { const dt = 33; const now = Date.now(); Object.keys(lobbies).forEach(lobbyId => { const lobby = lobbies[lobbyId]; if (!lobby) return; // Счётчик дней const prevTimeOfDay = lobby.timeOfDay; lobby.timeOfDay += dt / DAY_DURATION; if (lobby.timeOfDay >= 1) { lobby.timeOfDay = 0; lobby.dayCount = (lobby.dayCount || 1) + 1; // Увеличиваем счётчик дней } if (!lobby.dayCount) lobby.dayCount = 1; // --- СПАВН МОБОВ --- const isNight = lobby.timeOfDay > 0.40 && lobby.timeOfDay < 0.70; const dayCount = lobby.dayCount || 1; // Днём очень мало мобов (1-2 слизня), ночью больше let maxMobs = isNight ? Math.min(3 + dayCount, 12) : 2; let spawnChance = isNight ? 0.015 : 0.002; const playerIds = Object.keys(lobby.players); if (lobby.mobs.length < maxMobs && Math.random() < spawnChance && playerIds.length > 0) { // Выбираем случайного игрока для спавна рядом с ним const randomPlayer = lobby.players[playerIds[Math.floor(Math.random() * playerIds.length)]]; // Спавн за пределами экрана (500-800 пикселей от игрока) const minDistance = 500; const maxDistance = 800; const spawnDistance = minDistance + Math.random() * (maxDistance - minDistance); const spawnDirection = Math.random() > 0.5 ? 1 : -1; let spawnX = randomPlayer.x + spawnDirection * spawnDistance; // Ограничиваем границами мира spawnX = Math.max(BLOCK_SIZE * 5, Math.min(spawnX, (WORLD_WIDTH - 5) * BLOCK_SIZE)); // Проверяем что спавн далеко от ВСЕХ игроков И от других мобов let tooClose = false; for (let pid of playerIds) { const p = lobby.players[pid]; const dist = Math.abs(p.x - spawnX); if (dist < 450) { tooClose = true; break; } } // Проверяем что не слишком близко к другим мобам (чтобы не сбивались в кучу) if (!tooClose) { for (let mob of lobby.mobs) { const dist = Math.abs(mob.x - spawnX); if (dist < 150) { tooClose = true; break; } } } if (tooClose) return; // Не спавним если слишком близко // Находим высоту спавна НА ЗЕМЛЕ (ищем твёрдый блок, игнорируя деревья) let spawnY = 0; const bx = Math.floor(spawnX / BLOCK_SIZE); let foundGround = false; for(let y = 0; y < WORLD_HEIGHT; y++) { const block = lobby.world[y] && lobby.world[y][bx]; if (block && !isPassable(block) && block !== BLOCKS.WATER) { spawnY = (y - 1) * BLOCK_SIZE; // Спавн прямо над землёй foundGround = true; break; } } if (!foundGround) return; // Не спавним если нет земли // Определение типа моба с прогрессией по дням // День 1-4: только слизни // День 5-9: слизни + зомби // День 10+: слизни + зомби + скелеты // Демоны не спавнятся обычным способом let type = 'slime'; if (isNight) { const rand = Math.random(); if (dayCount >= 10) { // День 10+: слизни, зомби, скелеты if (rand < 0.35) type = 'zombie'; else if (rand < 0.55) type = 'skeleton'; else type = 'slime'; } else if (dayCount >= 5) { // День 5-9: слизни и зомби if (rand < 0.45) type = 'zombie'; else type = 'slime'; } else { // День 1-4: только слизни type = 'slime'; } } else { // Днём только слизни (очень редко) type = 'slime'; } const stats = MOB_STATS[type]; lobby.mobs.push({ id: Date.now() + Math.random(), x: spawnX, y: spawnY - stats.height + BLOCK_SIZE, // Корректная позиция на земле vx: 0, vy: 0, width: stats.width, height: stats.height, health: stats.hp, maxHealth: stats.hp, type: type, direction: 1, lastAttack: 0, targetPlayer: null, onGround: true // Спавнится на земле }); } // --- ИИ И ФИЗИКА МОБОВ --- lobby.mobs.forEach(mob => { const stats = MOB_STATS[mob.type] || MOB_STATS.slime; // Поиск ближайшего игрока const nearest = findNearestPlayer(mob, lobby.players); if (nearest && nearest.distance < stats.aggroRange) { mob.targetPlayer = nearest.id; const player = nearest.player; // Движение к игроку const dx = player.x - mob.x; if (Math.abs(dx) > stats.attackRange) { // Двигаемся к игроку mob.vx = dx > 0 ? stats.speed : -stats.speed; mob.direction = dx > 0 ? 1 : -1; } else { // Близко к игроку - атакуем mob.vx *= 0.8; // Замедляемся // Проверяем кулдаун атаки if (now - mob.lastAttack > stats.attackCooldown) { // Проверяем коллизию для атаки const attackDist = getDistance(player, mob); if (attackDist < stats.attackRange + 20) { mob.lastAttack = now; // Наносим урон игроку player.health = (player.health || 20) - stats.damage; // Отталкиваем игрока const knockbackX = (player.x - mob.x) > 0 ? 8 : -8; const knockbackY = -5; // Отправляем событие урона io.to(nearest.id).emit('playerDamaged', { health: player.health, knockbackX: knockbackX, knockbackY: knockbackY, attackerId: mob.id, damage: stats.damage }); // Если игрок умер if (player.health <= 0) { io.to(nearest.id).emit('playerDied'); } } } } // Прыжок если игрок выше или есть препятствие if (mob.onGround) { const playerAbove = player.y < mob.y - 32; const obstacleAhead = checkBlockCollision( mob.x + mob.direction * 20, mob.y - 10, mob.width, mob.height - 10, lobby.world ); if (playerAbove || obstacleAhead) { mob.vy = -stats.jumpForce; mob.onGround = false; } } } else { // Нет цели - бродим случайно mob.targetPlayer = null; if (Math.random() < 0.02) { mob.vx = (Math.random() - 0.5) * stats.speed * 2; mob.direction = mob.vx > 0 ? 1 : -1; } // Случайные прыжки для слизней if (mob.type === 'slime' && mob.onGround && Math.random() < 0.03) { mob.vy = -stats.jumpForce; mob.onGround = false; } } // Гравитация mob.vy += GRAVITY; if (mob.vy > 15) mob.vy = 15; // Применяем скорость mob.x += mob.vx; mob.y += mob.vy; // Трение mob.vx *= 0.95; // Коллизия с землёй (игнорируем фоновые блоки) const bx = Math.floor((mob.x + mob.width/2) / BLOCK_SIZE); const by = Math.floor((mob.y + mob.height) / BLOCK_SIZE); mob.onGround = false; if (by >= 0 && by < WORLD_HEIGHT && bx >= 0 && bx < WORLD_WIDTH) { const groundBlock = lobby.world[by] && lobby.world[by][bx]; if (groundBlock && !isPassable(groundBlock)) { mob.y = by * BLOCK_SIZE - mob.height; mob.vy = 0; mob.onGround = true; } } // Коллизия со стенами (игнорируем фоновые блоки) const wallBx = Math.floor((mob.x + mob.width/2 + mob.direction * mob.width/2) / BLOCK_SIZE); const wallBy = Math.floor((mob.y + mob.height/2) / BLOCK_SIZE); if (wallBy >= 0 && wallBy < WORLD_HEIGHT && wallBx >= 0 && wallBx < WORLD_WIDTH) { const wallBlock = lobby.world[wallBy] && lobby.world[wallBy][wallBx]; if (wallBlock && !isPassable(wallBlock)) { mob.vx = 0; mob.x -= mob.direction * 2; } } // Границы мира if (mob.x < 0) { mob.x = 0; mob.vx *= -1; } if (mob.x > (WORLD_WIDTH - 1) * BLOCK_SIZE) { mob.x = (WORLD_WIDTH - 1) * BLOCK_SIZE; mob.vx *= -1; } if (mob.y > (WORLD_HEIGHT + 10) * BLOCK_SIZE) mob.health = 0; }); // Удаляем мёртвых мобов lobby.mobs = lobby.mobs.filter(m => m.health > 0); // Обновляем текучесть воды updateWaterForLobby(lobby, lobbyId); // Отправляем обновление состояния io.to(lobbyId).emit('gameStateUpdate', { timeOfDay: lobby.timeOfDay, dayCount: lobby.dayCount || 1, mobs: lobby.mobs.map(m => ({ id: m.id, x: m.x, y: m.y, width: m.width, height: m.height, health: m.health, maxHealth: m.maxHealth, type: m.type, direction: m.direction, targetPlayer: m.targetPlayer })) }); }); } catch (err) { console.error("Game loop error:", err); } }, 33); io.on('connection', (socket) => { let currentLobby = null; socket.on('getLobbies', () => { try { const savedFiles = fs.readdirSync('./saves').filter(f => f.endsWith('.json')).map(f => f.replace('.json', '')); const activeIds = Object.keys(lobbies); const allIds = [...new Set([...savedFiles, ...activeIds])]; socket.emit('lobbyList', allIds.map(id => ({ id, players: lobbies[id] ? Object.keys(lobbies[id].players).length : 0, status: lobbies[id] ? 'Active' : 'Saved' }))); } catch(e) { socket.emit('lobbyList', []); } }); // Удаление мира socket.on('deleteWorld', (lobbyId) => { try { // Проверяем, что в лобби никого нет if (lobbies[lobbyId] && Object.keys(lobbies[lobbyId].players).length > 0) { socket.emit('errorMsg', 'Нельзя удалить мир, в котором есть игроки!'); return; } // Удаляем из памяти if (lobbies[lobbyId]) { delete lobbies[lobbyId]; } // Удаляем файл сохранения const savePath = `./saves/${lobbyId}.json`; if (fs.existsSync(savePath)) { fs.unlinkSync(savePath); } socket.emit('worldDeleted', lobbyId); // Обновляем список лобби для всех io.emit('lobbyListUpdated'); } catch(e) { console.error('Error deleting world:', e); socket.emit('errorMsg', 'Ошибка при удалении мира'); } }); socket.on('createLobby', (data) => handleJoin(socket, data)); socket.on('joinLobby', (data) => handleJoin(socket, data)); function handleJoin(socket, data) { const { lobbyId, nickname } = data; let safeNick = (nickname || "Player").trim().substring(0, 15); if (safeNick.length === 0) safeNick = "Player"; if (!lobbies[lobbyId]) { const loaded = loadLobby(lobbyId); if (loaded) { lobbies[lobbyId] = loaded; } else { lobbies[lobbyId] = { world: generateWorld(), players: {}, savedPlayers: {}, mobs: [], chests: {}, droppedItems: [], // Выпавшие предметы nextDropId: 1, timeOfDay: 0, lastUpdate: Date.now() }; } } const players = lobbies[lobbyId].players; for (let id in players) { if (players[id].nickname === safeNick) { socket.emit('errorMsg', 'Игрок с таким именем уже есть в лобби!'); return; } } joinLobby(socket, lobbyId, safeNick); } function joinLobby(socket, lobbyId, safeNick) { currentLobby = lobbyId; socket.join(lobbyId); const spawnX = Math.floor(WORLD_WIDTH / 2); let spawnY = 0; const world = lobbies[lobbyId].world; for (let y = 0; y < WORLD_HEIGHT; y++) { if (world[y] && world[y][spawnX] !== BLOCKS.AIR) { spawnY = (y - 2) * BLOCK_SIZE; break; } } let playerInv = getEmptyInventory(); let playerHotbar = [0, 0, 0, 0, 0, 0, 0, 0, 0]; // Пустой хотбар let playerHealth = 20; if (lobbies[lobbyId].savedPlayers[safeNick]) { playerInv = lobbies[lobbyId].savedPlayers[safeNick].inventory; playerHotbar = lobbies[lobbyId].savedPlayers[safeNick].hotbar; playerHealth = lobbies[lobbyId].savedPlayers[safeNick].health || 20; } lobbies[lobbyId].players[socket.id] = { x: spawnX * BLOCK_SIZE, y: spawnY, width: 24, height: 48, vx: 0, vy: 0, direction: 1, walkCycle: 0, heldItem: null, nickname: safeNick, inventory: playerInv, hotbarSlots: playerHotbar, health: playerHealth, maxHealth: 20, lastDamageTime: 0, team: null // null, 'red', 'blue', 'green', 'yellow' }; socket.emit('gameStart', { world: lobbies[lobbyId].world, players: lobbies[lobbyId].players, selfId: socket.id, spawn: { x: spawnX * BLOCK_SIZE, y: spawnY }, nickname: safeNick, inventory: playerInv, hotbarSlots: playerHotbar, health: playerHealth, chests: lobbies[lobbyId].chests || {} }); // Отправляем список существующих дропов if (lobbies[lobbyId].droppedItems && lobbies[lobbyId].droppedItems.length > 0) { socket.emit('droppedItemsSync', lobbies[lobbyId].droppedItems); } socket.to(lobbyId).emit('playerJoined', { id: socket.id, ...lobbies[lobbyId].players[socket.id] }); } socket.on('playerMove', (data) => { if (!currentLobby || !lobbies[currentLobby]) return; const player = lobbies[currentLobby].players[socket.id]; if (player) { player.x = data.x; player.y = data.y; player.vx = data.vx; player.vy = data.vy; player.direction = data.direction; player.walkCycle = data.walkCycle; player.heldItem = data.heldItem; if (data.team !== undefined) player.team = data.team; socket.to(currentLobby).emit('playerMoved', { id: socket.id, x: player.x, y: player.y, direction: player.direction, walkCycle: player.walkCycle, heldItem: player.heldItem, nickname: player.nickname, team: player.team }); } }); // Смена команды socket.on('setTeam', (team) => { if (!currentLobby || !lobbies[currentLobby]) return; const player = lobbies[currentLobby].players[socket.id]; if (player) { player.team = team; // Оповещаем всех игроков о смене команды io.to(currentLobby).emit('playerTeamChanged', { id: socket.id, team: team }); } }); // PvP атака socket.on('attackPlayer', (data) => { if (!currentLobby || !lobbies[currentLobby]) return; const attacker = lobbies[currentLobby].players[socket.id]; const target = lobbies[currentLobby].players[data.targetId]; if (!attacker || !target) return; // Проверка команд - нельзя атаковать союзников if (attacker.team && attacker.team === target.team) return; // Проверка кулдауна урона цели const now = Date.now(); if (now - (target.lastDamageTime || 0) < 1000) return; target.lastDamageTime = now; // Наносим урон target.health = (target.health || 20) - data.damage; if (target.health < 0) target.health = 0; // Отправляем событие урона цели io.to(data.targetId).emit('pvpDamage', { damage: data.damage, knockbackX: data.knockbackX, knockbackY: data.knockbackY, attackerName: attacker.nickname, attackerId: socket.id }); // Если цель убита if (target.health <= 0) { io.to(currentLobby).emit('chatMessage', { nickname: '⚔️ PvP', text: `${attacker.nickname} убил ${target.nickname}!` }); } }); socket.on('updateBlock', (data) => { if (!currentLobby || !lobbies[currentLobby]) return; const { x, y, type } = data; // Запрет на размещение воды игроками if (type === BLOCKS.WATER) return; if(y >= 0 && y < WORLD_HEIGHT && x >= 0 && x < WORLD_WIDTH) { const world = lobbies[currentLobby].world; if (world[y]) { const currentBlock = world[y][x]; // Запрет на ломание воды if (type === 0 && currentBlock === BLOCKS.WATER) return; // Если ломаем блок (type === 0) if (type === 0) { world[y][x] = type; io.to(currentLobby).emit('blockUpdated', { x, y, type }); return; } // Если ставим блок - проверяем что можно ставить на воздух или воду if (currentBlock === BLOCKS.AIR || currentBlock === BLOCKS.WATER) { // Проверка что рядом есть блок (не воздух и не вода) let hasNeighbor = false; const neighbors = [[x-1, y], [x+1, y], [x, y-1], [x, y+1]]; for (const [nx, ny] of neighbors) { if (ny >= 0 && ny < WORLD_HEIGHT && nx >= 0 && nx < WORLD_WIDTH) { const neighborBlock = world[ny] && world[ny][nx]; if (neighborBlock && neighborBlock !== BLOCKS.AIR && neighborBlock !== BLOCKS.WATER) { hasNeighbor = true; break; } } } if (hasNeighbor) { world[y][x] = type; io.to(currentLobby).emit('blockUpdated', { x, y, type }); } } } } }); socket.on('damageMob', (mobId) => { if (!currentLobby || !lobbies[currentLobby]) return; const lobby = lobbies[currentLobby]; const mobIndex = lobby.mobs.findIndex(m => m.id === mobId); if (mobIndex !== -1) { const mob = lobby.mobs[mobIndex]; const player = lobby.players[socket.id]; let damage = 1; if(player && player.heldItem) { if(player.heldItem === ITEMS.WOODEN_SWORD) damage = 3; if(player.heldItem === ITEMS.STONE_SWORD) damage = 5; if(player.heldItem === ITEMS.IRON_SWORD) damage = 7; } mob.health -= damage; if(player) { mob.vx = (mob.x - player.x > 0 ? 1 : -1) * 8; mob.vy = -6; } if (mob.health <= 0) { lobby.mobs.splice(mobIndex, 1); // Дроп с мобов if (player) { const mobType = mob.type; // Можно добавить дроп предметов } } io.to(currentLobby).emit('gameStateUpdate', { timeOfDay: lobby.timeOfDay, mobs: lobby.mobs.map(m => ({ id: m.id, x: m.x, y: m.y, width: m.width, height: m.height, health: m.health, maxHealth: m.maxHealth, type: m.type, direction: m.direction })) }); } }); // Синхронизация здоровья игрока socket.on('syncHealth', (health) => { if (!currentLobby || !lobbies[currentLobby]) return; const player = lobbies[currentLobby].players[socket.id]; if (player) { player.health = health; } }); // Респавн игрока socket.on('playerRespawn', () => { if (!currentLobby || !lobbies[currentLobby]) return; const player = lobbies[currentLobby].players[socket.id]; if (player) { player.health = player.maxHealth; // Можно добавить телепорт на спавн } }); socket.on('syncInventory', (data) => { if (!currentLobby || !lobbies[currentLobby]) return; const p = lobbies[currentLobby].players[socket.id]; if (p) { p.inventory = data.inventory; p.hotbarSlots = data.hotbarSlots; } }); // Обновление содержимого сундука socket.on('updateChest', (data) => { if (!currentLobby || !lobbies[currentLobby]) return; const { x, y, items } = data; const key = `${x},${y}`; if (!lobbies[currentLobby].chests) { lobbies[currentLobby].chests = {}; } lobbies[currentLobby].chests[key] = { items }; // Отправляем обновление всем игрокам в лобби socket.to(currentLobby).emit('chestUpdated', { x, y, items }); }); // Запрос содержимого сундука socket.on('getChest', (data) => { if (!currentLobby || !lobbies[currentLobby]) return; const { x, y } = data; const key = `${x},${y}`; const chests = lobbies[currentLobby].chests || {}; const chest = chests[key] || { items: {} }; socket.emit('chestData', { x, y, items: chest.items }); }); // Удаление сундука (перенос вещей в инвентарь) socket.on('breakChest', (data) => { if (!currentLobby || !lobbies[currentLobby]) return; const { x, y } = data; const key = `${x},${y}`; const chests = lobbies[currentLobby].chests || {}; const chest = chests[key]; if (chest && chest.items) { const player = lobbies[currentLobby].players[socket.id]; if (player) { // Переносим вещи из сундука в инвентарь игрока for (let itemId in chest.items) { const count = chest.items[itemId]; if (count > 0) { player.inventory[itemId] = (player.inventory[itemId] || 0) + count; } } // Отправляем обновлённый инвентарь игроку socket.emit('inventoryUpdated', { inventory: player.inventory }); } } // Удаляем сундук из данных delete chests[key]; // Оповещаем всех о разрушении сундука io.to(currentLobby).emit('chestRemoved', { x, y }); }); // === СИНХРОНИЗАЦИЯ ДРОПА ПРЕДМЕТОВ === // Когда игрок создаёт дроп (ломает блок, выбрасывает предмет) socket.on('itemDropped', (data) => { if (!currentLobby || !lobbies[currentLobby]) return; const lobby = lobbies[currentLobby]; const drop = { id: lobby.nextDropId++, x: data.x, y: data.y, vx: data.vx || (Math.random() - 0.5) * 4, vy: data.vy || -3, itemId: data.itemId, count: data.count || 1, createdBy: socket.id, pickupDelay: 500, // 0.5 секунды до подбора createdAt: Date.now() }; if (!lobby.droppedItems) lobby.droppedItems = []; lobby.droppedItems.push(drop); // Отправляем всем игрокам в лобби (включая отправителя для синхронизации id) io.to(currentLobby).emit('itemDroppedSync', drop); }); // Когда игрок подбирает дроп socket.on('itemPickedUp', (dropId) => { if (!currentLobby || !lobbies[currentLobby]) return; const lobby = lobbies[currentLobby]; if (!lobby.droppedItems) return; const index = lobby.droppedItems.findIndex(d => d.id === dropId); if (index !== -1) { const drop = lobby.droppedItems[index]; // Проверяем таймер подбора на сервере const timePassed = Date.now() - (drop.createdAt || 0); if (timePassed < (drop.pickupDelay || 0)) { return; // Ещё нельзя подбирать } lobby.droppedItems.splice(index, 1); // Оповещаем всех игроков что дроп подобран io.to(currentLobby).emit('itemRemovedSync', dropId); } }); socket.on('chatMessage', (msg) => { if (currentLobby && lobbies[currentLobby].players[socket.id]) { const nickname = lobbies[currentLobby].players[socket.id].nickname; const safeMsg = msg.replace(//g, ">"); io.to(currentLobby).emit('chatMessage', { nickname: nickname, text: safeMsg }); } }); socket.on('pingCheck', () => { socket.emit('pongCheck'); }); socket.on('disconnect', () => { if (currentLobby && lobbies[currentLobby]) { const p = lobbies[currentLobby].players[socket.id]; if(p) { lobbies[currentLobby].savedPlayers[p.nickname] = { inventory: p.inventory, hotbar: p.hotbarSlots, health: p.health }; io.to(currentLobby).emit('playerDisconnected', { id: socket.id, nickname: p.nickname }); delete lobbies[currentLobby].players[socket.id]; saveLobby(currentLobby); } } }); }); const PORT = process.env.PORT || 3000; const HOST = '0.0.0.0'; http.listen(PORT, HOST, () => { console.log(`Сервер запущен! LAN: http://192.168.18.50:${PORT}`); });