feat: server-authoritative mob sync for multiplayer — animals/hostiles now synced across players
This commit is contained in:
parent
0ed7d9966d
commit
1f921f7620
182
game.js
182
game.js
|
|
@ -83,8 +83,9 @@
|
||||||
// ==================== SOCKET.IO КЛИЕНТ ====================
|
// ==================== SOCKET.IO КЛИЕНТ ====================
|
||||||
let socket = null;
|
let socket = null;
|
||||||
let isMultiplayer = false; // Флаг для мультиплеерного режима
|
let isMultiplayer = false; // Флаг для мультиплеерного режима
|
||||||
const otherPlayers = new Map(); // socket_id -> {x, y, color}
|
const otherPlayers = new Map(); // socket_id -> {x, y, color}
|
||||||
let mySocketId = null;
|
const serverMobs = new Map(); // id -> mob (server-authoritative in MP)
|
||||||
|
let mySocketId = null;
|
||||||
|
|
||||||
// Throttle для отправки позиции (10-20 раз в секунду)
|
// Throttle для отправки позиции (10-20 раз в секунду)
|
||||||
let lastMoveSendTime = 0;
|
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) => {
|
socket.on('player_joined', (data) => {
|
||||||
|
|
@ -257,13 +266,61 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
// Игрок покинул
|
// Игрок покинул
|
||||||
socket.on('player_left', (data) => {
|
socket.on('player_left', (data) => {
|
||||||
console.log('Player left:', data.socket_id);
|
console.log('Player left:', data.socket_id);
|
||||||
otherPlayers.delete(data.socket_id);
|
otherPlayers.delete(data.socket_id);
|
||||||
addChatMessage('Система', `Игрок покинул игру`);
|
addChatMessage('Система', `Игрок покинул игру`);
|
||||||
// Обновляем видимость кнопки сохранения
|
// Обновляем видимость кнопки сохранения
|
||||||
updateSaveButtonVisibility();
|
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) => {
|
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 dx = Math.floor(m.x / TILE) - startGX;
|
||||||
const dy = Math.floor(m.y / TILE) - startGY;
|
const dy = Math.floor(m.y / TILE) - startGY;
|
||||||
if (dx >= 0 && dx < viewW && dy >= 0 && dy < viewH) {
|
if (dx >= 0 && dx < viewW && dy >= 0 && dy < viewH) {
|
||||||
|
|
@ -2232,28 +2290,38 @@
|
||||||
|
|
||||||
// клик по мобу (в режиме mine)
|
// клик по мобу (в режиме mine)
|
||||||
if(mode()==='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--){
|
for(let i=mobs.length-1;i>=0;i--){
|
||||||
const m = mobs[i];
|
const m = mobs[i];
|
||||||
if(wx>=m.x && wx<=m.x+m.w && wy>=m.y && wy<=m.y+m.h){
|
if(wx>=m.x && wx<=m.x+m.w && wy>=m.y && wy<=m.y+m.h){
|
||||||
// Урон зависит от меча
|
|
||||||
let dmg = 1;
|
let dmg = 1;
|
||||||
const swordTypes = ['iron_sword','stone_sword','wood_sword'];
|
const swordTypes = ['iron_sword','stone_sword','wood_sword'];
|
||||||
for (const st of swordTypes) {
|
for (const st of swordTypes) {
|
||||||
if (inv[st] > 0) {
|
if (inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; }
|
||||||
dmg = TOOLS[st].damage || 3;
|
|
||||||
useTool(st);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
m.hp -= dmg;
|
m.hp -= dmg;
|
||||||
m.vx += (m.x - player.x) * 2;
|
m.vx += (m.x - player.x) * 2;
|
||||||
m.vy -= 200;
|
m.vy -= 200;
|
||||||
playSound('attack'); // Звук атаки игрока
|
playSound('attack');
|
||||||
if(m.hp<=0){
|
if(m.hp<=0){
|
||||||
// дроп еды
|
|
||||||
if(m.kind === 'chicken') playSound('hurt_chicken');
|
if(m.kind === 'chicken') playSound('hurt_chicken');
|
||||||
inv.meat += (m.kind==='chicken' ? 1 : 2);
|
inv.meat += (m.kind==='chicken' ? 1 : 2);
|
||||||
// скелет дропает стрелы (иногда лук)
|
|
||||||
if(m.kind === 'skeleton'){
|
if(m.kind === 'skeleton'){
|
||||||
inv.arrow += 2 + Math.floor(Math.random()*3);
|
inv.arrow += 2 + Math.floor(Math.random()*3);
|
||||||
if(Math.random() < 0.15) inv.bow = (inv.bow||0) + 1;
|
if(Math.random() < 0.15) inv.bow = (inv.bow||0) + 1;
|
||||||
|
|
@ -3038,24 +3106,39 @@
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Попал в моба
|
// Попал в моба — server mobs first in multiplayer
|
||||||
for(let j = mobs.length-1; j>=0; j--){
|
let hitMob = false;
|
||||||
const m = mobs[j];
|
if(isMultiplayer){
|
||||||
if(p.x > m.x && p.x < m.x+m.w && p.y > m.y && p.y < m.y+m.h){
|
for (const [id, sm] of serverMobs) {
|
||||||
m.hp -= p.dmg;
|
if(sm.dead) continue;
|
||||||
m.vx += p.vx * 0.2;
|
if(p.x > sm.x && p.x < sm.x+sm.w && p.y > sm.y && p.y < sm.y+sm.h){
|
||||||
m.vy -= 200;
|
socket.emit('mob_arrow_hit', { id: sm.id, dmg: p.dmg, vx: p.vx });
|
||||||
if(m.hp <= 0){
|
projectiles.splice(i, 1);
|
||||||
inv.meat += (m.kind==='chicken' ? 1 : 2);
|
hitMob = true;
|
||||||
if(m.kind === 'skeleton'){
|
break;
|
||||||
inv.arrow += 2 + Math.floor(Math.random()*3);
|
}
|
||||||
if(Math.random() < 0.15) inv.bow = (inv.bow||0) + 1;
|
}
|
||||||
}
|
}
|
||||||
mobs.splice(j, 1);
|
if(!hitMob){
|
||||||
rebuildHotbar();
|
// 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;
|
spawnT += dt;
|
||||||
if(spawnT > 1.8 && mobs.length < 30){
|
if(!isMultiplayer && spawnT > 1.8 && mobs.length < 30){
|
||||||
spawnT = 0;
|
spawnT = 0;
|
||||||
|
|
||||||
// Выбираем сторону спавна (левая или правая)
|
// Выбираем сторону спавна (левая или правая)
|
||||||
|
|
@ -3118,11 +3201,13 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// mobs update
|
// mobs update — только локальные (singleplayer)
|
||||||
for(let i=mobs.length-1;i>=0;i--){
|
if(!isMultiplayer){
|
||||||
const m = mobs[i];
|
for(let i=mobs.length-1;i>=0;i--){
|
||||||
mobAI(m, dt);
|
const m = mobs[i];
|
||||||
if(m.hp<=0) mobs.splice(i,1);
|
mobAI(m, dt);
|
||||||
|
if(m.hp<=0) mobs.splice(i,1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// particles
|
// particles
|
||||||
|
|
@ -3201,7 +3286,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// mobs
|
// mobs
|
||||||
for(const m of mobs){
|
const allMobsRender = isMultiplayer ? Array.from(serverMobs.values()) : mobs;
|
||||||
|
for(const m of allMobsRender){
|
||||||
if(m.kind==='zombie'){
|
if(m.kind==='zombie'){
|
||||||
ctx.fillStyle = '#2ecc71';
|
ctx.fillStyle = '#2ecc71';
|
||||||
ctx.fillRect(m.x, m.y, m.w, m.h);
|
ctx.fillRect(m.x, m.y, m.w, m.h);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
<title>GrechkaCraft: Multiplayer</title>
|
<title>GrechkaCraft: Multiplayer</title>
|
||||||
<!-- Socket.io Client -->
|
<!-- Socket.io Client -->
|
||||||
<script src="https://cdn.socket.io/4.7.4/socket.io.min.js"></script>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|
@ -92,6 +92,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="game.js?v=6"></script>
|
<script src="game.js?v=7"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue