// Socket.IO клиент — инициализация и все обработчики import { state } from '../core/state.js'; import { SERVER_URL } from '../config.js'; import { TILE, SEA_GY } from '../core/constants.js'; import { BLOCKS } from '../data/blocks.js'; import { k3, setBlock, removeBlock, grid, blocks, getBlock } from '../world/world-storage3d.js'; import { occClear } from '../world/occ3d.js'; import { genColumn, surfaceGyAt } from '../world/generation3d.js'; import { addChatMessage } from '../ui/chat.js'; import { calculateDamage } from '../entities/player.js'; import { playSound } from '../audio/sound-engine.js'; import { rebuildHotbar } from '../ui/hotbar.js'; import { explodeTNT3D } from '../world/tnt3d.js'; import { updateSaveButtonVisibility } from '../ui/save-controls.js'; import { getRandomPlayerColor, sendPlayerPosition, sendBlockChange } from './socket-helpers.js'; import { initTradeSocket } from '../ui/trade.js'; import { placePlayerOnStandSpot, ensurePlayerOnGround } from '../spawn3d.js'; // 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; export { socket }; export function initSocket() { try { socket = io(SERVER_URL, { path: '/socket.io/', transports: ['websocket', 'polling'] }); // Store socket on state for other modules state.socket = socket; socket.on('connect', () => { console.log(`Connected to server: ${SERVER_URL}, socket.id: ${socket.id}, worldId: ${state.worldId}`); state.mySocketId = socket.id; state.isMultiplayer = true; // Присоединяемся к миру socket.emit('join_world', { world_id: state.worldId, player_name: state.playerName }); // Показываем в UI if (state.worldIdEl) state.worldIdEl.textContent = state.worldId; if (state.multiplayerStatus) state.multiplayerStatus.style.display = 'block'; // Initialize trade socket handlers initTradeSocket(socket); }); socket.on('connect_error', (error) => { console.error('Socket connection error:', error); state.isMultiplayer = false; }); socket.on('disconnect', () => { console.log('Disconnected from server'); state.isMultiplayer = false; state.otherPlayers.clear(); if (state.multiplayerStatus) state.multiplayerStatus.style.display = 'none'; }); // Обработка world_state socket.on('world_state', (data) => { console.log('Received world_state:', data); if (data.seed !== undefined && data.seed !== state.worldSeed) { const oldSeed = state.worldSeed; state.worldSeed = data.seed; console.log('World seed changed from', oldSeed, 'to', state.worldSeed); state.generated.clear(); grid.clear(); blocks.length = 0; // Окклюзивная сетка обновляется только из setBlock/removeBlock, а здесь // мир чистится напрямую. Без сброса сетка осталась бы от старого сида // и перестроение мешей отсекло бы свежие блоки как скрытые. occClear(); state.placedBlocks = []; state.removedBlocks = []; console.log('World regenerated with new seed:', state.worldSeed); // КРИТИЧНО: после перегенерации позиция игрока невалидна — он стоит // в координатах ПРЕДЫДУЩЕГО мира и оказывается в яме. Ставим заново // и пересчитываем опору. Иначе камера уходит в грунт, схлопывается // в первое лицо (camDist = 0), и весь кадр занимает пустое небо. if (typeof ensurePlayerOnGround === 'function') { try { ensurePlayerOnGround(); } catch (e) { /* не критично */ } } } if (data.blocks && Array.isArray(data.blocks)) { for (const block of data.blocks) { const key = k3(block.gx, block.gy, block.gz || 0); state.serverOverrides.set(key, { op: block.op, t: block.t }); if (block.op === 'set') { setBlock(block.gx, block.gy, block.gz || 0, block.t, false); } else if (block.op === 'remove') { removeBlock(block.gx, block.gy, block.gz || 0); } } } if (data.time !== undefined) { state.worldTime = data.time; state.isNightTime = state.worldTime > 0.5; } // Стартовая колонка: X=6 по центру мира, Z игрока. // Z фиксируем ОДИН РАЗ при входе: если брать текущую позицию каждый // раз, то второй world_state ищет площадку уже рядом с первой // найденной точкой и уводит игрока всё дальше в сторону. if (state.__spawnGZ === undefined) { state.__spawnGZ = Math.floor((state.player.z || 0) / state.TILE); } const startGX = 6; const startGZ = state.__spawnGZ; // Спавн: единая реализация из spawn3d.js. Раньше здесь была вторая // копия поиска площадки, которая считала feet как (top-1)*T и искала // воздух НИЖЕ top — игрок тонул в грунте либо падал в яму. placePlayerOnStandSpot(startGX, startGZ, 20); // Позицию уже выставил placePlayerOnStandSpot (feet = (top+1)*T, // player.y = feet - h). Здесь только обнуляем скорости. state.player.vx = 0; state.player.vy = 0; state.player.vz = 0; state.player.fallStartY = state.player.y; state.player.hp = 100; state.player.hunger = 100; state.player.o2 = 100; state.player.invuln = 0; console.log('[MULTIPLAYER CONNECT] Player HP set to 100% on connect'); if (data.players && Array.isArray(data.players)) { state.otherPlayers.clear(); for (const p of data.players) { if (p.socket_id !== state.mySocketId) { state.otherPlayers.set(p.socket_id, { x: p.x, y: p.y, color: getRandomPlayerColor(p.socket_id), name: p.player_name || 'Игрок' }); } } state.playerCountEl.textContent = data.players.length; } // Server mobs if (data.mobs && Array.isArray(data.mobs)) { state.serverMobs.clear(); for (const m of data.mobs) { let sm = { ...m, maxHp: m.maxHp || m.hp, vx: m.vx || 0, vy: m.vy || 0, grounded: false, inWater: false, aiT: 0, dir: m.dir || 1, dead: false, fuse: m.fuse || 0, shootCooldown: 2, speed: m.speed || 80 }; applyMobSize(sm); state.serverMobs.set(m.id, sm); } } // Set myTgId from server if provided if (data.tg_id) state.myTgId = data.tg_id; }); socket.on('player_joined', (data) => { console.log('Player joined:', data.socket_id); if (data.socket_id !== state.mySocketId) { // Позиция ДРУГОГО игрока. Формула та же, что и у нас: feet на // верхней грани опоры. Раньше было (surfaceY - 1) * TILE — это // тайл ПОД поверхностью, то есть чужий игрок появлялся в грунте. const spawnGX = 6; const spawnGZ = Math.floor((state.player.z || 0) / state.TILE); genColumn(spawnGX, spawnGZ); const surfaceY = surfaceGyAt(spawnGX, spawnGZ); const safeSpawnX = spawnGX * TILE; const safeSpawnY = (surfaceY + 1) * TILE; // верхняя грань тайла state.otherPlayers.set(data.socket_id, { x: safeSpawnX, y: safeSpawnY, color: getRandomPlayerColor(data.socket_id), name: data.player_name || 'Игрок' }); addChatMessage('Система', `Игрок присоединился`); updateSaveButtonVisibility(); } }); socket.on('player_moved', (data) => { if (data.socket_id !== state.mySocketId && state.otherPlayers.has(data.socket_id)) { const p = state.otherPlayers.get(data.socket_id); p.x = data.x; p.y = data.y; // Ось Z обязательна: без неё чужие игроки стоят в одной плоскости // и не двигаются вглубь мира. Раньше z просто игнорировался. p.z = data.z || 0; if (data.player_name) { p.name = data.player_name; } } }); socket.on('player_left', (data) => { console.log('Player left:', data.socket_id); state.otherPlayers.delete(data.socket_id); addChatMessage('Система', `Игрок покинул игру`); updateSaveButtonVisibility(); }); // === MOB SYNC === socket.on('mob_spawned', (data) => { let sm = { ...data, maxHp: data.maxHp || data.hp, vx: data.vx || 0, vy: data.vy || 0, grounded: false, inWater: false, aiT: 0, dir: data.dir || 1, dead: false, fuse: data.fuse || 0, shootCooldown: 2, speed: data.speed || 80 }; applyMobSize(sm); // Ось Z у серверных мобов: без неё в сетевом режиме все мобы // стоят в одной плоскости и не преследуют игрока по глубине. if (sm.z === undefined) sm.z = data.z || 0; if (sm.vz === undefined) sm.vz = data.vz || 0; if (sm.d === undefined) sm.d = data.d || sm.w || 30; state.serverMobs.set(data.id, sm); }); socket.on('mob_positions', (arr) => { for (const u of arr) { const sm = state.serverMobs.get(u.id); if (sm) { sm.x = u.x; sm.y = u.y; sm.vx = u.vx; sm.vy = u.vy; sm.dir = u.dir; sm.hp = u.hp; sm.fuse = u.fuse || 0; } } }); socket.on('mob_despawned', (data) => { state.serverMobs.delete(data.id); }); socket.on('mob_died', (data) => { const sm = state.serverMobs.get(data.id); if (sm && data.killer === state.mySocketId) { if (sm.kind === 'chicken') playSound('hurt_chicken'); state.inv.meat += (sm.kind === 'chicken' ? 1 : 2); if (sm.kind === 'skeleton') { state.inv.arrow += 2 + Math.floor(Math.random() * 3); if (Math.random() < 0.15) state.inv.bow = (state.inv.bow || 0) + 1; } rebuildHotbar(); } state.serverMobs.delete(data.id); }); socket.on('mob_hurt_ack', (data) => { const sm = state.serverMobs.get(data.id); if (sm) sm.hp = data.hp; }); socket.on('mob_explode', (data) => { explodeTNT3D({ gx: data.gx, gy: data.gy, gz: data.gz || 0 }, state); state.serverMobs.delete(data.id); }); socket.on('mob_shoot', (data) => { state.projectiles.push({ x: data.x, y: data.y, vx: data.vx, vy: data.vy, dmg: data.dmg, owner: 'mob', life: data.life }); }); socket.on('block_changed', (data) => { const key = k3(data.gx, data.gy, data.gz || 0); state.serverOverrides.set(key, { op: data.op, t: data.t }); if (data.op === 'set') { setBlock(data.gx, data.gy, data.gz || 0, data.t, false); } else if (data.op === 'remove') { removeBlock(data.gx, data.gy, data.gz || 0); } }); socket.on('chat_message', (data) => { const senderName = data.socket_id === state.mySocketId ? 'Вы' : `Игрок ${data.socket_id.substring(0, 6)}`; addChatMessage(senderName, data.message); }); socket.on('time_update', (data) => { if (data.time !== undefined) { state.worldTime = data.time; state.isNightTime = state.worldTime > 0.5; } }); } catch (e) { console.error('Error initializing socket:', e); state.isMultiplayer = false; } }