feat: server-authoritative mob sync for multiplayer — animals/hostiles now synced across players

This commit is contained in:
Mk 2026-05-26 04:11:39 +00:00
parent 0ed7d9966d
commit 1f921f7620
2 changed files with 136 additions and 50 deletions

182
game.js
View File

@ -83,8 +83,9 @@
// ==================== SOCKET.IO КЛИЕНТ ====================
let socket = null;
let isMultiplayer = false; // Флаг для мультиплеерного режима
const otherPlayers = new Map(); // socket_id -> {x, y, color}
let mySocketId = null;
const otherPlayers = new Map(); // socket_id -> {x, y, color}
const serverMobs = new Map(); // id -> mob (server-authoritative in MP)
let mySocketId = null;
// Throttle для отправки позиции (10-20 раз в секунду)
let lastMoveSendTime = 0;
@ -215,10 +216,18 @@
});
}
}
// Обновляем счётчик игроков
playerCountEl.textContent = data.players.length;
}
});
// Обновляем счётчик игроков
playerCountEl.textContent = data.players.length;
}
// Server mobs
if (data.mobs && Array.isArray(data.mobs)) {
serverMobs.clear();
for (const m of data.mobs) {
const 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 };
serverMobs.set(m.id, sm);
}
}
});
// Игрок присоединился
socket.on('player_joined', (data) => {
@ -257,13 +266,61 @@
});
// Игрок покинул
socket.on('player_left', (data) => {
console.log('Player left:', data.socket_id);
otherPlayers.delete(data.socket_id);
addChatMessage('Система', `Игрок покинул игру`);
// Обновляем видимость кнопки сохранения
updateSaveButtonVisibility();
});
socket.on('player_left', (data) => {
console.log('Player left:', data.socket_id);
otherPlayers.delete(data.socket_id);
addChatMessage('Система', `Игрок покинул игру`);
// Обновляем видимость кнопки сохранения
updateSaveButtonVisibility();
});
// === MOB SYNC (multiplayer) ===
socket.on('mob_spawned', (data) => {
const 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 };
serverMobs.set(data.id, sm);
});
socket.on('mob_positions', (arr) => {
for (const u of arr) {
const sm = 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) => { serverMobs.delete(data.id); });
socket.on('mob_died', (data) => {
const sm = serverMobs.get(data.id);
if (sm && data.killer === mySocketId) {
// Give loot to the killer
if (sm.kind === 'chicken') playSound('hurt_chicken');
inv.meat += (sm.kind==='chicken' ? 1 : 2);
if (sm.kind === 'skeleton') {
inv.arrow += 2 + Math.floor(Math.random()*3);
if (Math.random() < 0.15) inv.bow = (inv.bow||0) + 1;
}
rebuildHotbar();
}
serverMobs.delete(data.id);
});
socket.on('mob_hurt_ack', (data) => {
const sm = serverMobs.get(data.id);
if (sm) sm.hp = data.hp;
});
socket.on('mob_explode', (data) => {
explodeAt(data.gx, data.gy);
serverMobs.delete(data.id);
});
socket.on('mob_shoot', (data) => {
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) => {
@ -1014,7 +1071,8 @@
}
// Мобы — красные (враждебные) / зелёные (животные)
for (const m of mobs) {
const allMobsForMap = isMultiplayer ? Array.from(serverMobs.values()) : 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) {
@ -2232,28 +2290,38 @@
// клик по мобу (в режиме mine)
if(mode()==='mine'){
// Check server mobs first (multiplayer)
if(isMultiplayer){
for (const [id, sm] of 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 (inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; }
}
socket.emit('mob_hurt', { id: sm.id, dmg });
playSound('attack');
return;
}
}
}
// Local mobs (singleplayer or if not hit server mob)
for(let i=mobs.length-1;i>=0;i--){
const m = 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 (inv[st] > 0) {
dmg = TOOLS[st].damage || 3;
useTool(st);
break;
}
if (inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; }
}
m.hp -= dmg;
m.vx += (m.x - player.x) * 2;
m.vy -= 200;
playSound('attack'); // Звук атаки игрока
playSound('attack');
if(m.hp<=0){
// дроп еды
if(m.kind === 'chicken') playSound('hurt_chicken');
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;
@ -3038,24 +3106,39 @@
continue;
}
} else {
// Попал в моба
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();
// Попал в моба — server mobs first in multiplayer
let hitMob = false;
if(isMultiplayer){
for (const [id, sm] of 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){
socket.emit('mob_arrow_hit', { id: sm.id, dmg: p.dmg, vx: p.vx });
projectiles.splice(i, 1);
hitMob = true;
break;
}
}
}
if(!hitMob){
// Local mobs
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;
}
projectiles.splice(i, 1);
break;
}
}
}
@ -3074,9 +3157,9 @@
}
}
// mobs spawn (с обеих сторон камеры)
// mobs spawn (с обеих сторон камеры) — только в одиночном режиме
spawnT += dt;
if(spawnT > 1.8 && mobs.length < 30){
if(!isMultiplayer && spawnT > 1.8 && mobs.length < 30){
spawnT = 0;
// Выбираем сторону спавна (левая или правая)
@ -3118,11 +3201,13 @@
}
}
// mobs update
for(let i=mobs.length-1;i>=0;i--){
const m = mobs[i];
mobAI(m, dt);
if(m.hp<=0) mobs.splice(i,1);
// mobs update — только локальные (singleplayer)
if(!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
@ -3201,7 +3286,8 @@
}
// mobs
for(const m of mobs){
const allMobsRender = isMultiplayer ? Array.from(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);

View File

@ -6,7 +6,7 @@
<title>GrechkaCraft: Multiplayer</title>
<!-- Socket.io Client -->
<script src="https://cdn.socket.io/4.7.4/socket.io.min.js"></script>
<link rel="stylesheet" href="style.css?v=4">
<link rel="stylesheet" href="style.css?v=5">
</head>
<body>
@ -92,6 +92,6 @@
</div>
</div>
<script src="game.js?v=6"></script>
<script src="game.js?v=7"></script>
</body>
</html>