dok/sockets/arena.socket.js
2026-04-13 17:21:54 +01:00

645 lines
20 KiB
JavaScript
Raw 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.

/* ============================================================
sockets/arena.socket.js
1v1 Matchmaking + 2v2 Team-Lobby + 4v4 Team-Lobby
inkl. Kampfphase nach end_turn
============================================================ */
const { runCombatPhase } = require('./combat'); // combat.js liegt im selben /sockets/ Ordner
const waitingPool = new Map();
const LEVEL_RANGE = 5;
const READY_TIMEOUT = 30;
/* ── 2v2 State ── */
const teams2v2 = new Map();
const readyTeams2v2 = new Map();
/* ── 4v4 State ── */
const teams4v4 = new Map();
const readyTeams4v4 = new Map();
function generateId() {
return `${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
}
/* ═══════════════════════════════════════════════════════════
HELPER: Generisch für 2v2 UND 4v4
═══════════════════════════════════════════════════════════ */
function getTeamMap(mode) {
return mode === "4v4" ? teams4v4 : teams2v2;
}
function getReadyMap(mode) {
return mode === "4v4" ? readyTeams4v4 : readyTeams2v2;
}
function getMaxPlayers(mode) {
return mode === "4v4" ? 4 : 2;
}
function broadcastLobbies(io, mode) {
const teams = getTeamMap(mode);
const max = getMaxPlayers(mode);
const list = [];
for (const [teamId, team] of teams) {
if (team.players.length < max) {
list.push({
teamId,
leader: team.players[0]?.player?.name || "?",
leaderLevel: team.players[0]?.player?.level || 1,
count: team.players.length,
max,
});
}
}
io.emit(`${mode}_lobbies`, list);
}
function broadcastTeamStatus(io, teamId, mode) {
const team = getTeamMap(mode).get(teamId);
if (!team) return;
const data = {
teamId,
leaderId: team.leaderId,
players: team.players.map((p) => ({
socketId: p.socketId,
name: p.player.name,
level: p.player.level,
ready: team.ready.has(p.socketId),
})),
count: team.players.length,
max: getMaxPlayers(mode),
};
for (const p of team.players) {
io.to(p.socketId).emit(`${mode}_team_update`, data);
}
}
function tryMatchmaking_nvn(io, mode) {
const readyMap = getReadyMap(mode);
const readyList = Array.from(readyMap.values());
if (readyList.length < 2) return;
const team1 = readyList[0];
const team2 = readyList[1];
readyMap.delete(team1.id);
readyMap.delete(team2.id);
getTeamMap(mode).delete(team1.id);
getTeamMap(mode).delete(team2.id);
const matchId = `${mode}_${generateId()}`;
const allPlayers = [
...team1.players.map((p) => ({ team: 1, ...p })),
...team2.players.map((p) => ({ team: 2, ...p })),
];
for (const p of allPlayers) {
const opponents = allPlayers
.filter((x) => x.team !== p.team)
.map((x) => x.player.name);
const teammates = allPlayers
.filter((x) => x.team === p.team && x.socketId !== p.socketId)
.map((x) => x.player.name);
const teamRef = p.team === 1 ? team1 : team2;
const slotIndex =
teamRef.players.findIndex((x) => x.socketId === p.socketId) + 1;
io.to(p.socketId).emit(`match_found_${mode}`, {
matchId,
myTeam: p.team,
teammates,
opponents,
mySlot: `team${p.team}_player${slotIndex}`,
});
}
console.log(
`[${mode}] Match: Team1(${team1.players.map((p) => p.player.name)})` +
` vs Team2(${team2.players.map((p) => p.player.name)}) | ${matchId}`,
);
}
function leaveAllTeams_nvn(socketId, io, mode) {
const teams = getTeamMap(mode);
const ready = getReadyMap(mode);
for (const [teamId, team] of teams) {
const idx = team.players.findIndex((p) => p.socketId === socketId);
if (idx === -1) continue;
const playerName = team.players[idx].player.name;
team.players.splice(idx, 1);
team.ready.delete(socketId);
ready.delete(teamId);
if (team.players.length === 0) {
teams.delete(teamId);
console.log(`[${mode}] Team ${teamId} aufgelöst.`);
} else {
if (team.leaderId === socketId) {
team.leaderId = team.players[0].socketId;
console.log(
`[${mode}] Neuer Leader in Team ${teamId}: ${team.players[0].player.name}`,
);
}
broadcastTeamStatus(io, teamId, mode);
for (const p of team.players) {
io.to(p.socketId).emit(`${mode}_partner_left`, { name: playerName });
}
console.log(`[${mode}] ${playerName} hat Team ${teamId} verlassen.`);
}
broadcastLobbies(io, mode);
break;
}
}
/* ── 1v1 Matchmaking ── */
function tryMatchmaking(io, newSocketId) {
const challenger = waitingPool.get(newSocketId);
if (!challenger) return;
for (const [id, entry] of waitingPool) {
if (id === newSocketId) continue;
if (Math.abs(entry.player.level - challenger.player.level) <= LEVEL_RANGE) {
waitingPool.delete(newSocketId);
waitingPool.delete(id);
const matchId = `match_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
challenger.socket.emit("match_found", {
matchId,
opponent: entry.player,
mySlot: "player1",
});
entry.socket.emit("match_found", {
matchId,
opponent: challenger.player,
mySlot: "player2",
});
console.log(
`[1v1] ${challenger.player.name} vs ${entry.player.name} | ${matchId}`,
);
return;
}
}
}
/* ── Bereit-Timer (1v1) ── */
function startReadyTimer(io, matchId) {
if (!io._arenaTimers) io._arenaTimers = new Map();
if (io._arenaTimers.has(matchId)) return;
let remaining = READY_TIMEOUT;
emitToMatch(io, matchId, "ready_timer", { remaining });
const interval = setInterval(() => {
remaining--;
emitToMatch(io, matchId, "ready_timer", { remaining });
if (remaining <= 0) {
clearInterval(interval);
io._arenaTimers.delete(matchId);
console.log(`[1v1] Match ${matchId} abgebrochen Zeit abgelaufen.`);
emitToMatch(io, matchId, "match_cancelled", {
reason: "timeout",
message: "Zeit abgelaufen.",
});
}
}, 1000);
io._arenaTimers.set(matchId, interval);
}
function stopReadyTimer(io, matchId) {
if (!io._arenaTimers) return;
const interval = io._arenaTimers.get(matchId);
if (interval) {
clearInterval(interval);
io._arenaTimers.delete(matchId);
}
}
/* ═══════════════════════════════════════════════════════════
KAMPFPHASE HELPER
Berechnet wie lange die Client-Animation dauert,
damit turn_change erst NACH den Animationen gesendet wird.
═══════════════════════════════════════════════════════════ */
function calcCombatDuration(events) {
// Muss mit den COMBAT_DELAY_* Werten in 1v1.js übereinstimmen
const DELAY_BANNER = 600; // initiale Pause vor erstem Event
const DELAY_MOVE = 350;
const DELAY_ATTACK = 450;
const DELAY_DIE = 300;
const DELAY_FINAL = 500; // finalBoard sync
const BUFFER = 400; // Sicherheitspuffer
let total = DELAY_BANNER + DELAY_FINAL + BUFFER;
for (const ev of events) {
if (ev.type === 'move') total += DELAY_MOVE;
if (ev.type === 'attack') total += DELAY_ATTACK;
if (ev.type === 'die') total += DELAY_DIE;
}
return total;
}
/* ── boardState → boardCards-Array (für reconnect/boardSync) ── */
function boardStateToCards(boardState) {
return Object.entries(boardState).map(([boardSlot, entry]) => ({
boardSlot,
card : entry.card,
owner: entry.owner,
slot : entry.owner, // Rückwärtskompatibilität
}));
}
/* ═══════════════════════════════════════════════════════════
Handler-Generator für 2v2 und 4v4
═══════════════════════════════════════════════════════════ */
function registerTeamModeHandlers(io, socket, mode) {
const max = getMaxPlayers(mode);
/* Lobby-Liste anfordern */
socket.on(`get_${mode}_lobbies`, () => {
const teams = getTeamMap(mode);
const list = [];
for (const [teamId, team] of teams) {
if (team.players.length < max) {
list.push({
teamId,
leader: team.players[0]?.player?.name || "?",
leaderLevel: team.players[0]?.player?.level || 1,
count: team.players.length,
max,
});
}
}
socket.emit(`${mode}_lobbies`, list);
});
/* Team erstellen */
socket.on(`create_${mode}_team`, (playerData) => {
leaveAllTeams_nvn(socket.id, io, mode);
const player = {
id: playerData.id,
name: playerData.name,
level: Number(playerData.level) || 1,
};
const teamId = `team_${mode}_${generateId()}`;
getTeamMap(mode).set(teamId, {
id: teamId,
leaderId: socket.id,
players: [{ socketId: socket.id, player }],
ready: new Set(),
});
socket.emit(`${mode}_team_joined`, { teamId, isLeader: true });
broadcastTeamStatus(io, teamId, mode);
broadcastLobbies(io, mode);
console.log(`[${mode}] Team ${teamId} erstellt von ${player.name}`);
});
/* Team beitreten */
socket.on(`join_${mode}_team`, (data) => {
const { teamId, playerData } = data;
const team = getTeamMap(mode).get(teamId);
if (!team)
return socket.emit(`${mode}_error`, {
message: "Team nicht mehr verfügbar.",
});
if (team.players.length >= max)
return socket.emit(`${mode}_error`, {
message: "Team ist bereits voll.",
});
if (team.players.find((p) => p.socketId === socket.id)) return;
leaveAllTeams_nvn(socket.id, io, mode);
const player = {
id: playerData.id,
name: playerData.name,
level: Number(playerData.level) || 1,
};
team.players.push({ socketId: socket.id, player });
socket.emit(`${mode}_team_joined`, { teamId, isLeader: false });
broadcastTeamStatus(io, teamId, mode);
broadcastLobbies(io, mode);
console.log(`[${mode}] ${player.name} hat Team ${teamId} beigetreten`);
});
/* Team verlassen */
socket.on(`leave_${mode}_team`, () => {
leaveAllTeams_nvn(socket.id, io, mode);
});
/* ── Kick (nur Leader) ── */
socket.on(`kick_from_${mode}_team`, (data) => {
const { teamId, targetSocketId } = data;
const team = getTeamMap(mode).get(teamId);
if (!team) return;
if (team.leaderId !== socket.id) {
return socket.emit(`${mode}_error`, {
message: "Nur der Team-Leader kann Spieler entfernen.",
});
}
if (targetSocketId === socket.id) return;
const idx = team.players.findIndex((p) => p.socketId === targetSocketId);
if (idx === -1) return;
const kickedName = team.players[idx].player.name;
team.players.splice(idx, 1);
team.ready.delete(targetSocketId);
getReadyMap(mode).delete(teamId);
io.to(targetSocketId).emit(`${mode}_kicked`, {
message: "Du wurdest vom Team-Leader aus dem Team entfernt.",
});
broadcastTeamStatus(io, teamId, mode);
broadcastLobbies(io, mode);
console.log(`[${mode}] ${kickedName} wurde aus Team ${teamId} gekickt.`);
});
/* Bereit klicken */
socket.on(`${mode}_player_ready`, (data) => {
const { teamId } = data;
const team = getTeamMap(mode).get(teamId);
if (!team || team.players.length < max) return;
team.ready.add(socket.id);
broadcastTeamStatus(io, teamId, mode);
console.log(
`[${mode}] Bereit in Team ${teamId}: ${team.ready.size}/${max}`,
);
if (team.ready.size >= max) {
getReadyMap(mode).set(teamId, team);
for (const p of team.players) {
io.to(p.socketId).emit(`${mode}_searching`, {
message: "Suche nach Gegnerteam…",
});
}
console.log(`[${mode}] Team ${teamId} sucht Gegner.`);
tryMatchmaking_nvn(io, mode);
}
});
}
/* ── Hilfsfunktionen: Direkte Socket-Zustellung ── */
function getRoom(io, matchId) {
return io._arenaRooms?.get(matchId) || null;
}
function emitToMatch(io, matchId, event, data) {
const room = getRoom(io, matchId);
if (!room) {
console.warn(`[emitToMatch] Kein Room für ${matchId}`);
return;
}
["player1", "player2"].forEach((slot) => {
if (room.sockets[slot]) {
io.to(room.sockets[slot]).emit(event, data);
console.log(`[emitToMatch] ${event}${slot} (${room.sockets[slot]})`);
}
});
}
function emitToOpponent(io, matchId, senderSlot, event, data) {
const room = getRoom(io, matchId);
if (!room) return;
const oppSlot = senderSlot === "player1" ? "player2" : "player1";
if (room.sockets[oppSlot]) {
io.to(room.sockets[oppSlot]).emit(event, data);
}
}
/* ═══════════════════════════════════════════════════════════
HAUPT-HANDLER
═══════════════════════════════════════════════════════════ */
function registerArenaHandlers(io, socket) {
/* ── 1v1 ── */
socket.on("join_1v1", (playerData) => {
if (waitingPool.has(socket.id)) return;
const player = {
id: playerData.id,
name: playerData.name,
level: Number(playerData.level) || 1,
};
waitingPool.set(socket.id, { socket, player });
socket.emit("queue_status", {
status: "waiting",
poolSize: waitingPool.size,
});
console.log(
`[1v1] ${player.name} (Lvl ${player.level}) im Pool. Größe: ${waitingPool.size}`,
);
tryMatchmaking(io, socket.id);
});
socket.on("leave_1v1", () => {
if (waitingPool.delete(socket.id))
socket.emit("queue_status", { status: "left" });
});
socket.on("arena_join", (data) => {
const { matchId, slot, playerName } = data;
console.log(
`[1v1] arena_join empfangen: matchId=${matchId}, slot=${slot}, name=${playerName}, socketId=${socket.id}`,
);
if (!matchId || !slot) {
console.warn(`[1v1] arena_join abgewiesen matchId oder slot fehlt`);
return;
}
if (!io._arenaRooms) io._arenaRooms = new Map();
if (!io._arenaRooms.has(matchId)) {
io._arenaRooms.set(matchId, {
sockets : {},
names : {},
boardCards: [], // Array-Format für boardSync/reconnect
boardState: {}, // NEU: { [slotId]: { card, owner } } für Kampfphase
leftSlot : null, // NEU: welcher Slot ist der linke Spieler
});
}
const room = io._arenaRooms.get(matchId);
room.sockets[slot] = socket.id;
if (playerName && playerName !== "Spieler") {
room.names[slot] = playerName;
} else if (!room.names[slot] || room.names[slot] === "Spieler") {
room.names[slot] = playerName || "Spieler";
}
console.log(
`[1v1] Name gesetzt: slot=${slot}, name=${room.names[slot]}, playerName=${playerName}`,
);
socket.join("arena_" + matchId);
if (room.boardCards?.length > 0) {
socket.emit("board_sync", { cards: room.boardCards });
}
const otherSlot = slot === "player1" ? "player2" : "player1";
if (room.sockets[otherSlot]) {
console.log(
`[1v1] Beide Spieler da → arena_ready senden | Match ${matchId}`,
);
emitToMatch(io, matchId, "arena_ready", {
player1 : room.names["player1"] || "Spieler 1",
player2 : room.names["player2"] || "Spieler 2",
boardSync: room.boardCards || [],
});
startReadyTimer(io, matchId);
} else {
console.log(
`[1v1] Erster Spieler joined, warte auf zweiten | Match ${matchId}`,
);
socket
.to("arena_" + matchId)
.emit("arena_opponent_joined", { name: room.names[slot], slot });
}
});
socket.on("player_ready", (data) => {
const { matchId, slot } = data;
if (!matchId || !slot) return;
if (!io._arenaReady) io._arenaReady = new Map();
if (!io._arenaReady.has(matchId)) io._arenaReady.set(matchId, new Set());
const readySet = io._arenaReady.get(matchId);
readySet.add(slot);
const readyData = {
readyCount: readySet.size,
readySlots: Array.from(readySet),
};
emitToMatch(io, matchId, "ready_status", readyData);
console.log(
`[1v1] ready_status: ${readySet.size}/2 bereit | Match ${matchId}`,
);
if (readySet.size >= 2) {
stopReadyTimer(io, matchId);
io._arenaReady.delete(matchId);
console.log(
`[1v1] Beide bereit warte auf start_turn_request | Match ${matchId}`,
);
}
});
socket.on("player_surrender", (data) => {
const { matchId, slot } = data;
io.to("arena_" + matchId).emit("player_surrendered", { slot });
});
/* ── Karte gespielt ── */
socket.on("card_played", (data) => {
const { matchId, slot, boardSlot, card } = data;
if (!matchId) return;
// An Gegner senden
emitToOpponent(io, matchId, slot, "card_played", data);
// Im Server-State speichern
const room = io._arenaRooms?.get(matchId);
if (room) {
// boardState mit Owner speichern (für Kampfphase)
room.boardState[boardSlot] = { card, owner: slot };
// boardCards neu aufbauen (für Reconnect/boardSync)
room.boardCards = boardStateToCards(room.boardState);
}
console.log(
`[1v1] card_played: ${card?.name}${boardSlot} (owner: ${slot}) | Match ${matchId}`,
);
});
/* ── Startspieler festlegen ── */
socket.on("start_turn_request", (data) => {
const { matchId, starterSlot } = data;
if (!matchId || !starterSlot) return;
if (!io._turnInit) io._turnInit = new Set();
if (io._turnInit.has(matchId)) return;
io._turnInit.add(matchId);
setTimeout(() => io._turnInit?.delete(matchId), 60000);
// NEU: linken Spieler im Room merken (wird für Kampfphase benötigt)
const room = io._arenaRooms?.get(matchId);
if (room) room.leftSlot = starterSlot;
const boardCards = room?.boardCards || [];
emitToMatch(io, matchId, "turn_change", {
activeSlot: starterSlot,
boardSync : boardCards,
});
console.log(
`[1v1] Spiel startet → ${starterSlot} (linker Spieler) beginnt | Match ${matchId}`,
);
});
/* ── Zug beenden → Kampfphase → Zugwechsel ── */
socket.on("end_turn", (data) => {
const { matchId, slot } = data;
if (!matchId || !slot) return;
const room = io._arenaRooms?.get(matchId);
if (!room) return;
const leftSlot = room.leftSlot || 'player1';
const boardState = room.boardState;
const nextSlot = slot === "player1" ? "player2" : "player1";
/* ── Kampfphase berechnen ── */
const combatEvents = runCombatPhase(boardState, leftSlot);
// boardCards nach Kampf aktualisieren (Karten die gestorben sind fehlen jetzt)
room.boardCards = boardStateToCards(boardState);
// finalBoard: aktueller Stand nach Kampf
const finalBoard = room.boardCards;
// Kampf-Events + finales Board an beide Clients senden
emitToMatch(io, matchId, 'combat_phase', {
events : combatEvents,
finalBoard: finalBoard,
});
console.log(
`[1v1] Kampfphase: ${combatEvents.length} Events | Zug: ${slot}${nextSlot} | Match ${matchId}`,
);
// Zugwechsel NACH den Client-Animationen senden
const animDuration = calcCombatDuration(combatEvents);
setTimeout(() => {
emitToMatch(io, matchId, "turn_change", {
activeSlot: nextSlot,
boardSync : room.boardCards, // aktualisierter Stand nach Kampf
});
console.log(
`[1v1] turn_change gesendet nach ${animDuration}ms | ${slot}${nextSlot} | Match ${matchId}`,
);
}, animDuration);
});
/* ── 2v2 & 4v4 ── */
registerTeamModeHandlers(io, socket, "2v2");
registerTeamModeHandlers(io, socket, "4v4");
/* ── Disconnect ── */
socket.on("disconnect", () => {
if (waitingPool.delete(socket.id)) {
console.log(`[1v1] ${socket.id} disconnected aus Pool entfernt.`);
}
leaveAllTeams_nvn(socket.id, io, "2v2");
leaveAllTeams_nvn(socket.id, io, "4v4");
});
}
module.exports = { registerArenaHandlers };