Files
2026-05-24 22:24:39 +07:00

1243 lines
51 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "&lt;").replace(/>/g, "&gt;");
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}`);
});