monquigrid/public/multiplayer-game.js
2025-07-16 23:56:37 +00:00

1286 lines
No EOL
43 KiB
JavaScript

// Multiplayer Grid Battle Game Client
// Integrates with SQLite backend via REST API
// Game configuration - loaded from server
let GAME_CONFIG = null;
// Multiplayer game state
let multiplayerState = null;
// Initialize game state after config is loaded
function initializeGameState() {
multiplayerState = {
// Connection info
gameId: null,
playerId: null,
playerNumber: null,
playerName: null,
// Game state
gameStatus: 'disconnected', // disconnected, waiting, playing, finished
currentTurn: GAME_CONFIG.INITIAL_TURN,
turnStatus: 'waiting_for_submission', // waiting_for_submission, waiting_for_opponent, executed
// Grid and players
grid: [],
players: {},
// Turn management
turnActions: [],
movesRemaining: GAME_CONFIG.MOVES_PER_TURN,
shotAvailable: true,
ghostPreviews: [],
// Polling
pollTimer: null,
lastPollTime: 0,
// Game end handling
pendingGameEnd: null,
finalTurnAnimated: false
};
}
// DOM elements
const gameGrid = document.getElementById('game-grid');
const statusText = document.getElementById('status-text');
const playerName = document.getElementById('player-name');
const movesRemaining = document.getElementById('moves-remaining');
const shotAvailable = document.getElementById('shot-available');
const actionsQueued = document.getElementById('actions-queued');
const submitButton = document.getElementById('submit-turn');
const undoButton = document.getElementById('undo-action');
// Initialize when page loads
document.addEventListener('DOMContentLoaded', async function() {
await loadGameConfig();
initializeGameState();
initializeMultiplayerGame();
setupEventListeners();
});
// Load game configuration from server
async function loadGameConfig() {
try {
console.log('Loading game configuration from server...');
const response = await fetch('/api/config');
const result = await response.json();
if (result.success) {
GAME_CONFIG = result.config;
console.log('Game configuration loaded:', GAME_CONFIG);
} else {
throw new Error('Failed to load config from server');
}
} catch (error) {
console.error('Error loading config, using fallback:', error);
// Fallback configuration if server config fails
GAME_CONFIG = {
GRID_WIDTH: 10,
GRID_HEIGHT: 10,
MOVES_PER_TURN: 4,
SHOTS_PER_TURN: 1,
CELL_SIZE_PX: 30,
POLLING_INTERVAL: 1500,
PLAYER_ONE_NUMBER: 1,
PLAYER_TWO_NUMBER: 2,
MAX_PLAYERS: 2,
INITIAL_TURN: 1,
GAME_END_DELAY: 2000,
MOVEMENT_ANIMATION_DELAY: 200,
ERROR_FLASH_DURATION: 500,
COPY_FEEDBACK_DELAY: 2000,
DIRECTION_OFFSETS: {
NORTH: { row: -1, col: 0 },
SOUTH: { row: 1, col: 0 },
EAST: { row: 0, col: 1 },
WEST: { row: 0, col: -1 }
}
};
}
}
async function initializeMultiplayerGame() {
console.log('Initializing Multiplayer Grid Battle Game...');
// Initialize UI first (grid needs to exist before adding players)
initializeGrid();
renderGrid();
updateGameStatus('Ready to start');
updateMovesDisplay();
updateActionButtons();
// Check if we're joining an existing game via URL
const urlPath = window.location.pathname;
console.log('Current URL path:', urlPath);
const gameIdMatch = urlPath.match(/\/game\/([a-f0-9-]+)/);
if (gameIdMatch) {
// Joining existing game
console.log('Detected game ID in URL:', gameIdMatch[1]);
multiplayerState.gameId = gameIdMatch[1];
await showJoinGameInterface();
} else {
// Creating new game
console.log('No game ID in URL, creating new game');
await showCreateGameInterface();
}
}
async function showCreateGameInterface() {
const playerNameInput = prompt('Enter your name to create a new game:');
if (!playerNameInput || playerNameInput.trim().length === 0) {
updateGameStatus('Name required to create game');
return;
}
multiplayerState.playerName = playerNameInput.trim();
updatePlayerInfo(multiplayerState.playerName);
try {
updateGameStatus('Creating game...');
console.log('Sending API request to create game for:', multiplayerState.playerName);
const response = await fetch('/api/games', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playerName: multiplayerState.playerName })
});
console.log('API response status:', response.status);
const result = await response.json();
console.log('API response data:', result);
if (result.success) {
multiplayerState.gameId = result.gameId;
multiplayerState.playerId = result.playerId;
multiplayerState.playerNumber = result.playerNumber;
multiplayerState.gameStatus = 'waiting';
// Add player 1 to grid
addPlayerToGrid(result.playerNumber, result.spawnPosition.x, result.spawnPosition.y);
// Store player 1 data for movement validation
multiplayerState.players[result.playerNumber] = {
id: result.playerId,
name: multiplayerState.playerName,
x: result.spawnPosition.x,
y: result.spawnPosition.y
};
// Show invite link
showInviteLink(result.inviteLink);
// Start polling for player 2
startGamePolling();
updateGameStatus('Waiting for player 2 to join...');
} else {
throw new Error(result.error || 'Failed to create game');
}
} catch (error) {
console.error('Error creating game:', error);
console.error('Error details:', error.stack);
updateGameStatus('Failed to create game: ' + error.message);
// Also show alert for debugging
alert('Error creating game: ' + error.message + '\nCheck console for details.');
}
}
async function showJoinGameInterface() {
const playerNameInput = prompt('Enter your name to join the game:');
if (!playerNameInput || playerNameInput.trim().length === 0) {
updateGameStatus('Name required to join game');
return;
}
multiplayerState.playerName = playerNameInput.trim();
updatePlayerInfo(multiplayerState.playerName);
try {
updateGameStatus('Joining game...');
const response = await fetch(`/api/games/${multiplayerState.gameId}/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playerName: multiplayerState.playerName })
});
const result = await response.json();
if (result.success) {
multiplayerState.playerId = result.playerId;
multiplayerState.playerNumber = result.playerNumber;
multiplayerState.gameStatus = 'playing';
// Load full game state
await loadGameState();
// Start polling for game updates
startGamePolling();
updateGameStatus('Game started! Plan your moves.');
updateActionButtons(); // Enable buttons for Player 2
} else {
throw new Error(result.error || 'Failed to join game');
}
} catch (error) {
console.error('Error joining game:', error);
updateGameStatus('Failed to join game: ' + error.message);
}
}
async function loadGameState() {
try {
const response = await fetch(`/api/games/${multiplayerState.gameId}`);
const gameState = await response.json();
if (gameState.game && gameState.players) {
// Update game info
multiplayerState.currentTurn = gameState.game.current_turn;
multiplayerState.gameStatus = gameState.game.status;
// Clear existing players
clearPlayersFromGrid();
// Add all players to grid
gameState.players.forEach(player => {
if (player.is_alive) {
addPlayerToGrid(player.player_number, player.current_x, player.current_y);
} else {
// Show dead player
showPlayerDeath(player.current_x, player.current_y);
}
multiplayerState.players[player.player_number] = {
id: player.player_id,
name: player.username,
x: player.current_x,
y: player.current_y,
alive: Boolean(player.is_alive)
};
});
renderGrid();
}
} catch (error) {
console.error('Error loading game state:', error);
}
}
function showInviteLink(inviteLink) {
console.log('showInviteLink called with:', inviteLink);
// Create a temporary modal-like display
const linkDisplay = document.createElement('div');
linkDisplay.style.cssText = `
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
background: #2a2a2a; border: 2px solid #00ff00; border-radius: 8px;
padding: 20px; z-index: 1000; max-width: 500px;
color: white; text-align: center;
`;
linkDisplay.innerHTML = `
<h3>Game Created!</h3>
<p>Share this link with your opponent:</p>
<input type="text" value="${inviteLink}" readonly style="
width: 100%; padding: 10px; margin: 10px 0;
background: #333; color: white; border: 1px solid #555;
border-radius: 4px; text-align: center;
">
<button id="copy-link-btn" style="
background: #0066ff; color: white; border: none;
padding: 10px 20px; border-radius: 4px; cursor: pointer;
">Copy Link</button>
<button onclick="this.parentElement.remove()" style="
background: #666; color: white; border: none;
padding: 10px 20px; border-radius: 4px; cursor: pointer; margin-left: 10px;
">Close</button>
`;
// Add event listener for copy button
const copyBtn = linkDisplay.querySelector('#copy-link-btn');
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(inviteLink);
copyBtn.textContent = 'Copied!';
setTimeout(() => {
copyBtn.textContent = 'Copy Link';
}, 2000);
} catch (error) {
console.error('Failed to copy link:', error);
// Fallback: select the text for manual copying
const input = linkDisplay.querySelector('input');
input.select();
input.setSelectionRange(0, 99999); // For mobile devices
copyBtn.textContent = 'Selected - Press Ctrl+C';
}
});
document.body.appendChild(linkDisplay);
}
function startGamePolling() {
if (multiplayerState.pollTimer) {
clearInterval(multiplayerState.pollTimer);
}
multiplayerState.pollTimer = setInterval(async () => {
await pollGameStatus();
}, GAME_CONFIG.POLLING_INTERVAL);
console.log('Started polling for game updates');
}
async function pollGameStatus() {
try {
const response = await fetch(`/api/games/${multiplayerState.gameId}/status?playerId=${multiplayerState.playerId}`);
const status = await response.json();
if (status.needsUpdate || status.game.current_turn !== multiplayerState.currentTurn) {
await handleGameUpdate(status);
}
} catch (error) {
console.error('Polling error:', error);
}
}
async function handleGameUpdate(status) {
const previousStatus = multiplayerState.gameStatus;
const previousTurn = multiplayerState.currentTurn;
// Update local state
multiplayerState.gameStatus = status.game.status;
multiplayerState.currentTurn = status.game.current_turn;
multiplayerState.turnStatus = status.turnStatus;
// Handle status changes
if (previousStatus === 'waiting' && status.game.status === 'playing') {
updateGameStatus('Player 2 joined! Game started.');
await loadGameState();
updateActionButtons(); // Re-enable buttons now that game is playing
}
if (previousTurn !== status.game.current_turn ||
(status.game.status === 'finished' && !multiplayerState.finalTurnAnimated)) {
// New turn started OR game ended - show what happened in previous turn
const animationTurn = status.game.status === 'finished' ? status.game.current_turn : previousTurn;
if (status.game.status === 'finished') {
multiplayerState.finalTurnAnimated = true;
}
await showTurnResults(status.recentEvents, animationTurn);
// Only reset turn state and load if game is still playing
if (status.game.status === 'playing') {
resetTurnState();
await loadGameState();
updateGameStatus(`Turn ${status.game.current_turn} - Plan your moves`);
} else {
// Game ended - load final state but don't reset turn state
await loadGameState();
}
}
// Update turn status display
if (status.turnStatus === 'waiting_for_opponent') {
updateGameStatus('Waiting for opponent to submit turn...');
} else if (status.turnStatus === 'executed') {
updateGameStatus('Turn executed! Plan your next moves.');
}
// Handle game end
if (status.game.status === 'finished') {
handleGameEnd(status.game);
}
}
function handleGameEnd(game) {
console.log('Game ended detected, but deferring end screen until animations complete');
clearInterval(multiplayerState.pollTimer);
// Disable controls
document.querySelectorAll('.move-btn, .shoot-btn').forEach(btn => {
btn.disabled = true;
});
submitButton.disabled = true;
undoButton.disabled = true;
// Store game end info but don't show screen yet - let animations finish
multiplayerState.pendingGameEnd = {
isWinner: game.winner_id === multiplayerState.playerId,
game: game
};
}
function resetTurnState() {
multiplayerState.turnActions = [];
multiplayerState.movesRemaining = GAME_CONFIG.MOVES_PER_TURN;
multiplayerState.shotAvailable = true;
clearGhostPreviews();
updateMovesDisplay();
updateActionButtons();
}
// API Integration Functions
async function submitTurn() {
if (multiplayerState.turnActions.length === 0) {
updateGameStatus('No actions to submit');
return;
}
try {
updateGameStatus('Submitting turn...');
const response = await fetch(`/api/games/${multiplayerState.gameId}/submit-turn`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playerId: multiplayerState.playerId,
actions: multiplayerState.turnActions
})
});
const result = await response.json();
if (result.success) {
if (result.bothPlayersSubmitted) {
updateGameStatus('Both players submitted! Executing turn...');
} else {
updateGameStatus('Turn submitted. Waiting for opponent...');
}
// Disable controls until next turn
document.querySelectorAll('.move-btn, .shoot-btn').forEach(btn => {
btn.disabled = true;
});
submitButton.disabled = true;
} else {
throw new Error(result.error || 'Failed to submit turn');
}
} catch (error) {
console.error('Error submitting turn:', error);
updateGameStatus('Failed to submit turn: ' + error.message);
}
}
// Grid Management Functions
function initializeGrid() {
multiplayerState.grid = [];
for (let row = 0; row < GAME_CONFIG.GRID_HEIGHT; row++) {
multiplayerState.grid[row] = [];
for (let col = 0; col < GAME_CONFIG.GRID_WIDTH; col++) {
multiplayerState.grid[row][col] = {
type: 'empty',
player: null,
bullet: null
};
}
}
}
function addPlayerToGrid(playerNumber, x, y) {
console.log(`Adding player ${playerNumber} to grid at position (${x}, ${y})`);
if (!multiplayerState.grid || multiplayerState.grid.length === 0) {
console.error('Grid not initialized when trying to add player');
return;
}
if (isValidPosition(x, y)) {
multiplayerState.grid[y][x].player = playerNumber;
console.log(`Player ${playerNumber} successfully added to grid`);
} else {
console.error(`Invalid position for player ${playerNumber}: (${x}, ${y})`);
}
}
function clearPlayersFromGrid() {
for (let row = 0; row < GAME_CONFIG.GRID_HEIGHT; row++) {
for (let col = 0; col < GAME_CONFIG.GRID_WIDTH; col++) {
multiplayerState.grid[row][col].player = null;
}
}
}
function renderGrid() {
// Clear existing grid
gameGrid.innerHTML = '';
// Create grid cells
for (let row = 0; row < GAME_CONFIG.GRID_HEIGHT; row++) {
for (let col = 0; col < GAME_CONFIG.GRID_WIDTH; col++) {
const cell = createGridCell(row, col);
gameGrid.appendChild(cell);
}
}
}
function createGridCell(row, col) {
const cell = document.createElement('div');
cell.className = 'grid-cell';
cell.dataset.row = row;
cell.dataset.col = col;
// Add cell content based on game state
const cellData = multiplayerState.grid[row][col];
if (cellData.player) {
cell.classList.add(`player-${cellData.player}`);
cell.textContent = cellData.player === 1 ? 'P1' : 'P2';
}
if (cellData.bullet) {
cell.classList.add('bullet');
cell.textContent = '•';
}
return cell;
}
// Action Management (reuse existing functions with API integration)
function setupEventListeners() {
// Move buttons
document.querySelectorAll('.move-btn').forEach(button => {
button.addEventListener('click', function() {
const direction = this.dataset.direction;
addMoveAction(direction);
});
});
// Shoot buttons
document.querySelectorAll('.shoot-btn').forEach(button => {
button.addEventListener('click', function() {
const direction = this.dataset.direction;
addShootAction(direction);
});
});
// Submit turn button
submitButton.addEventListener('click', function() {
submitTurn();
});
// Undo button
undoButton.addEventListener('click', function() {
undoLastAction();
});
}
function addMoveAction(direction) {
if (multiplayerState.gameStatus !== 'playing') {
updateGameStatus('Game not active');
return;
}
if (multiplayerState.movesRemaining <= 0) {
flashErrorButton('move', direction);
updateGameStatus('No moves remaining this turn');
return;
}
// Check if this move would be valid
if (!isValidMoveAction(direction)) {
flashErrorButton('move', direction);
return;
}
const action = {
type: 'move',
direction: direction,
timestamp: Date.now()
};
multiplayerState.turnActions.push(action);
multiplayerState.movesRemaining--;
updateGhostPreviews();
updateMovesDisplay();
updateActionButtons();
console.log(`Added move action: ${direction}`);
updateGameStatus(`Move added: ${direction.toUpperCase()}`);
}
function addShootAction(direction) {
if (multiplayerState.gameStatus !== 'playing') {
updateGameStatus('Game not active');
return;
}
if (!multiplayerState.shotAvailable) {
flashErrorButton('shoot', direction);
updateGameStatus('Shot already used this turn');
return;
}
const action = {
type: 'shoot',
direction: direction,
timestamp: Date.now()
};
multiplayerState.turnActions.push(action);
multiplayerState.shotAvailable = false;
updateGhostPreviews();
updateMovesDisplay();
updateActionButtons();
console.log(`Added shoot action: ${direction}`);
updateGameStatus(`Shot added: ${direction.toUpperCase()}`);
}
// UI Update Functions
function updateGameStatus(message) {
statusText.textContent = message;
console.log(`Status: ${message}`);
}
function updatePlayerInfo(name) {
playerName.textContent = name;
}
function updateMovesDisplay() {
const used = GAME_CONFIG.MOVES_PER_TURN - multiplayerState.movesRemaining;
movesRemaining.textContent = `Moves: ${used}/${GAME_CONFIG.MOVES_PER_TURN}`;
if (multiplayerState.shotAvailable) {
shotAvailable.textContent = 'Shot: Available';
shotAvailable.classList.remove('used');
} else {
shotAvailable.textContent = 'Shot: Used';
shotAvailable.classList.add('used');
}
actionsQueued.textContent = `Actions: ${multiplayerState.turnActions.length}`;
}
function updateActionButtons() {
const hasActions = multiplayerState.turnActions.length > 0;
const gameActive = multiplayerState.gameStatus === 'playing';
submitButton.disabled = !hasActions || !gameActive;
undoButton.disabled = !hasActions || !gameActive;
// Enable/disable action buttons based on game state
document.querySelectorAll('.move-btn, .shoot-btn').forEach(btn => {
btn.disabled = !gameActive;
});
}
function undoLastAction() {
if (multiplayerState.turnActions.length === 0) {
updateGameStatus('No actions to undo');
return;
}
// Remove the last action
const lastAction = multiplayerState.turnActions.pop();
// Restore the appropriate resource
if (lastAction.type === 'move') {
multiplayerState.movesRemaining++;
} else if (lastAction.type === 'shoot') {
multiplayerState.shotAvailable = true;
}
// Update visual elements
updateGhostPreviews();
updateMovesDisplay();
updateActionButtons();
const actionType = lastAction.type.toUpperCase();
const direction = lastAction.direction.toUpperCase();
console.log(`Undid ${actionType} action: ${direction}`);
updateGameStatus(`Undid: ${actionType} ${direction}`);
}
// Include all the existing validation and preview functions with minimal changes
// (using multiplayerState instead of gameState)
function isValidMoveAction(direction) {
if (!multiplayerState.players[multiplayerState.playerNumber]) {
return false;
}
let finalPos = calculateFinalPlayerPosition();
const offset = getDirectionOffset(direction);
const newPos = {
row: finalPos.y + offset.row,
col: finalPos.x + offset.col
};
if (!isValidPosition(newPos.col, newPos.row)) {
updateGameStatus(`Cannot move ${direction.toUpperCase()} - out of bounds`);
return false;
}
if (isPositionInPlayerPath(newPos)) {
updateGameStatus(`Cannot move ${direction.toUpperCase()} - cannot move through yourself`);
return false;
}
return true;
}
function calculateFinalPlayerPosition() {
const currentPlayer = multiplayerState.players[multiplayerState.playerNumber];
if (!currentPlayer) return { x: 0, y: 0 };
let currentPos = { x: currentPlayer.x, y: currentPlayer.y };
multiplayerState.turnActions.forEach(action => {
if (action.type === 'move') {
const offset = getDirectionOffset(action.direction);
const newPos = {
x: currentPos.x + offset.col,
y: currentPos.y + offset.row
};
if (isValidPosition(newPos.x, newPos.y)) {
currentPos = newPos;
}
}
});
return currentPos;
}
function isPositionInPlayerPath(targetPos) {
const playerPath = calculatePlayerPath();
return playerPath.some(pos =>
pos.x === targetPos.col && pos.y === targetPos.row
);
}
function calculatePlayerPath() {
const path = [];
const currentPlayer = multiplayerState.players[multiplayerState.playerNumber];
if (!currentPlayer) return path;
let currentPos = { x: currentPlayer.x, y: currentPlayer.y };
path.push({ ...currentPos });
multiplayerState.turnActions.forEach(action => {
if (action.type === 'move') {
const offset = getDirectionOffset(action.direction);
const newPos = {
x: currentPos.x + offset.col,
y: currentPos.y + offset.row
};
if (isValidPosition(newPos.x, newPos.y)) {
currentPos = newPos;
path.push({ ...currentPos });
}
}
});
return path;
}
// Ghost preview functions (adapted for multiplayer state with sequential execution)
function updateGhostPreviews() {
clearGhostPreviews();
const currentPlayer = multiplayerState.players[multiplayerState.playerNumber];
if (!currentPlayer) return;
let currentPos = { x: currentPlayer.x, y: currentPlayer.y };
// Process actions sequentially to show where shots will fire from
multiplayerState.turnActions.forEach((action, index) => {
if (action.type === 'move') {
const offset = getDirectionOffset(action.direction);
const newPos = {
x: currentPos.x + offset.col,
y: currentPos.y + offset.row
};
if (isValidPosition(newPos.x, newPos.y)) {
currentPos = newPos;
addGhostPreview(newPos.y, newPos.x, 'ghost-move');
}
} else if (action.type === 'shoot') {
// Show bullet path from current position in the sequence
showBulletPath(currentPos.y, currentPos.x, action.direction);
}
});
}
function showBulletPath(startRow, startCol, direction) {
const offset = getDirectionOffset(direction);
let currentRow = startRow + offset.row;
let currentCol = startCol + offset.col;
while (isValidPosition(currentCol, currentRow)) {
addGhostPreview(currentRow, currentCol, 'ghost-bullet');
currentRow += offset.row;
currentCol += offset.col;
}
}
function addGhostPreview(row, col, className) {
const cell = getCellElement(row, col);
if (cell && !cell.classList.contains('player-1') && !cell.classList.contains('player-2')) {
cell.classList.add(className);
multiplayerState.ghostPreviews.push({ row, col, className });
}
}
function clearGhostPreviews() {
multiplayerState.ghostPreviews.forEach(preview => {
const cell = getCellElement(preview.row, preview.col);
if (cell) {
cell.classList.remove(preview.className);
}
});
multiplayerState.ghostPreviews = [];
}
function getCellElement(row, col) {
return document.querySelector(`[data-row="${row}"][data-col="${col}"]`);
}
function flashErrorButton(actionType, direction) {
const buttonSelector = actionType === 'move' ?
`.move-btn[data-direction="${direction}"]` :
`.shoot-btn[data-direction="${direction}"]`;
const button = document.querySelector(buttonSelector);
if (button) {
button.style.animation = 'error-flash 0.5s ease-out';
setTimeout(() => {
button.style.animation = '';
}, 500);
}
if (actionType === 'move') {
flashGhostPreviewsRed();
}
}
function flashGhostPreviewsRed() {
const ghostCells = document.querySelectorAll('.ghost-move');
ghostCells.forEach(cell => {
cell.style.animation = 'ghost-error-flash 0.6s ease-out';
setTimeout(() => {
cell.style.animation = 'pulse 1.5s ease-in-out infinite';
}, 600);
});
}
// Utility functions
function getDirectionOffset(direction) {
const directionKey = direction.toUpperCase();
const offset = GAME_CONFIG.DIRECTION_OFFSETS[directionKey];
return offset || { row: 0, col: 0 };
}
function isValidPosition(x, y) {
return x >= 0 && x < GAME_CONFIG.GRID_WIDTH &&
y >= 0 && y < GAME_CONFIG.GRID_HEIGHT;
}
// Show visual feedback for what happened in the previous turn
async function showTurnResults(recentEvents, turnNumber) {
if (!recentEvents || recentEvents.length === 0) return;
// Filter events from the executed turn
const turnEvents = recentEvents.filter(event => event.turn_number === turnNumber);
// Group events by action_index and organize by type
const eventsByActionIndex = {};
turnEvents.forEach(event => {
try {
const eventData = JSON.parse(event.event_data);
const actionIndex = eventData.action_index !== undefined ? eventData.action_index : 0;
if (!eventsByActionIndex[actionIndex]) {
eventsByActionIndex[actionIndex] = {
movements: [],
shots: [],
hits: []
};
}
if (event.event_type === 'player_moved') {
eventsByActionIndex[actionIndex].movements.push({
...event,
eventData: eventData
});
} else if (event.event_type === 'shot_fired') {
eventsByActionIndex[actionIndex].shots.push({
...event,
eventData: eventData
});
} else if (event.event_type === 'player_hit') {
eventsByActionIndex[actionIndex].hits.push({
...event,
eventData: eventData
});
}
} catch (error) {
console.error('Error parsing event data:', error);
}
});
// Execute animations in sequence by action index
const actionIndices = Object.keys(eventsByActionIndex).sort((a, b) => parseInt(a) - parseInt(b));
for (const actionIndex of actionIndices) {
const stepEvents = eventsByActionIndex[actionIndex];
console.log(`Animating action step ${parseInt(actionIndex) + 1}`);
updateGameStatus(`Showing turn ${turnNumber} step ${parseInt(actionIndex) + 1}...`);
// Step 1: Animate all player movements for this action step
const movementPromises = stepEvents.movements.map(event =>
animatePlayerMovement(
event.eventData.from_x,
event.eventData.from_y,
event.eventData.to_x,
event.eventData.to_y,
event.player_number
)
);
await Promise.all(movementPromises);
// Step 2: Fire all shots for this action step (simultaneously with movements)
const shotPromises = stepEvents.shots.map(event =>
animateBulletTrail(
event.eventData.bullet_path,
event.eventData.shooter_x,
event.eventData.shooter_y
)
);
await Promise.all(shotPromises);
// Step 3: Show hit effects
stepEvents.hits.forEach(event => {
highlightHit(event.eventData.target_x, event.eventData.target_y);
showPlayerDeath(event.eventData.target_x, event.eventData.target_y);
});
// Brief pause between action steps
if (parseInt(actionIndex) < actionIndices.length - 1) {
await new Promise(resolve => setTimeout(resolve, GAME_CONFIG.ACTION_STEP_DELAY));
}
}
// Check if game ended during this turn and show end screen after animations
if (multiplayerState.pendingGameEnd) {
console.log('Animations complete, showing game end screen after delay');
updateGameStatus('Battle complete! Determining winner...');
// Wait 2 seconds to let players absorb what happened, then show end screen
setTimeout(() => {
showGameEndScreen(
multiplayerState.pendingGameEnd.isWinner,
multiplayerState.pendingGameEnd.game
);
multiplayerState.pendingGameEnd = null;
}, 2000);
} else {
updateGameStatus(`Turn ${turnNumber} complete - Plan your next moves`);
}
}
// Animate player movement from one position to another
async function animatePlayerMovement(fromX, fromY, toX, toY, playerNumber) {
console.log(`Animating player ${playerNumber} from (${fromX},${fromY}) to (${toX},${toY})`);
// Clear previous position
const fromCell = getCellElement(fromY, fromX);
if (fromCell) {
fromCell.classList.remove(`player-${playerNumber}`);
fromCell.textContent = '';
}
// Set new position with movement effect
const toCell = getCellElement(toY, toX);
if (toCell) {
// Add temporary movement highlighting
toCell.classList.add('player-moving');
// Brief delay to show movement
await new Promise(resolve => setTimeout(resolve, GAME_CONFIG.MOVEMENT_ANIMATION_DELAY));
// Remove movement effect and set final player position
toCell.classList.remove('player-moving');
toCell.classList.add(`player-${playerNumber}`);
toCell.textContent = playerNumber === 1 ? 'P1' : 'P2';
console.log(`Player ${playerNumber} moved to (${toX},${toY})`);
}
}
// Animate bullet trail visualization
async function animateBulletTrail(bulletPath, shooterX, shooterY) {
if (!bulletPath || bulletPath.length === 0) return;
// Highlight each cell in the bullet path with a small delay
for (let i = 0; i < bulletPath.length; i++) {
const pathCell = bulletPath[i];
const cell = getCellElement(pathCell.y, pathCell.x);
if (cell && !cell.classList.contains('player-1') && !cell.classList.contains('player-2')) {
cell.classList.add('bullet-trail');
// Remove the bullet trail class after animation completes
setTimeout(() => {
cell.classList.remove('bullet-trail');
}, 5000);
}
// Small delay between bullet trail segments for visual effect
if (i < bulletPath.length - 1) {
await new Promise(resolve => setTimeout(resolve, GAME_CONFIG.BULLET_SEGMENT_DELAY));
}
}
}
// Highlight where a player was hit
function highlightHit(x, y) {
const cell = getCellElement(y, x);
if (cell) {
cell.classList.add('bullet-hit');
// Remove hit highlight after animation
setTimeout(() => {
cell.classList.remove('bullet-hit');
}, 1000);
}
}
// Show death effect for a player
function showPlayerDeath(x, y) {
const cell = getCellElement(y, x);
if (cell) {
// Remove player class and add death effect
cell.classList.remove('player-1', 'player-2');
cell.classList.add('player-dead');
// The death effect will persist to show where the player died
console.log(`Player death effect shown at (${x},${y})`);
}
}
// Show game end screen
function showGameEndScreen(isWinner, game) {
// Create overlay
const overlay = document.createElement('div');
overlay.className = 'game-end-overlay';
// Create modal
const modal = document.createElement('div');
modal.className = `game-end-modal ${isWinner ? 'winner' : 'loser'}`;
// Create content
const title = document.createElement('div');
title.className = 'game-end-title';
title.textContent = isWinner ? 'YOU WIN!' : 'GAME OVER';
const message = document.createElement('div');
message.className = 'game-end-message';
message.textContent = isWinner
? 'Congratulations! You defeated your opponent!'
: 'Better luck next time! You were eliminated.';
// Create buttons
const buttonContainer = document.createElement('div');
buttonContainer.style.marginTop = '20px';
const playAgainBtn = document.createElement('button');
playAgainBtn.className = 'game-end-button primary';
playAgainBtn.textContent = 'Play Again';
playAgainBtn.onclick = async () => {
await startRematch();
};
const newGameBtn = document.createElement('button');
newGameBtn.className = 'game-end-button';
newGameBtn.textContent = 'New Game';
newGameBtn.onclick = () => {
window.location.href = '/';
};
const replayBtn = document.createElement('button');
replayBtn.className = 'game-end-button';
replayBtn.textContent = 'View Replay';
replayBtn.onclick = () => {
// Future feature - for now just show an alert
alert('Replay feature coming soon!');
};
buttonContainer.appendChild(playAgainBtn);
buttonContainer.appendChild(newGameBtn);
buttonContainer.appendChild(replayBtn);
// Assemble modal
modal.appendChild(title);
modal.appendChild(message);
modal.appendChild(buttonContainer);
// Assemble overlay
overlay.appendChild(modal);
// Add to page
document.body.appendChild(overlay);
// Update status
updateGameStatus(isWinner ? 'Victory!' : 'Defeated!');
}
// Start a rematch with the same players
async function startRematch() {
try {
updateGameStatus('Creating rematch...');
// Remove game end overlay
const overlay = document.querySelector('.game-end-overlay');
if (overlay) {
overlay.remove();
}
const response = await fetch(`/api/games/${multiplayerState.gameId}/rematch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playerId: multiplayerState.playerId,
playerName: multiplayerState.playerName
})
});
const result = await response.json();
if (result.success) {
// Stop old polling first
if (multiplayerState.pollTimer) {
clearInterval(multiplayerState.pollTimer);
multiplayerState.pollTimer = null;
}
// Reset local state for new game
multiplayerState.gameId = result.newGameId;
multiplayerState.playerId = result.playerId;
multiplayerState.playerNumber = result.playerNumber;
multiplayerState.gameStatus = result.status;
multiplayerState.currentTurn = 1;
multiplayerState.turnStatus = 'waiting_for_submission';
multiplayerState.pendingGameEnd = null;
multiplayerState.finalTurnAnimated = false;
// Clear old game state
resetTurnState();
clearPlayersFromGrid();
// Initialize grid fresh
initializeGrid();
renderGrid();
// Add player to new spawn position
if (result.spawnPosition) {
addPlayerToGrid(result.playerNumber, result.spawnPosition.x, result.spawnPosition.y);
multiplayerState.players[result.playerNumber] = {
id: result.playerId,
name: multiplayerState.playerName,
x: result.spawnPosition.x,
y: result.spawnPosition.y,
alive: true
};
}
// Load new game state
await loadGameState();
// Restart polling for the new game
startGamePolling();
// Update URL without page reload
window.history.pushState({}, '', `/game/${result.newGameId}`);
if (result.status === 'waiting') {
updateGameStatus('Waiting for opponent to join rematch...');
showRematchInvite(result.inviteLink);
} else {
updateGameStatus('Rematch started! Plan your moves.');
updateActionButtons();
}
} else {
throw new Error(result.error || 'Failed to create rematch');
}
} catch (error) {
console.error('Error starting rematch:', error);
updateGameStatus('Failed to start rematch: ' + error.message);
}
}
// Show rematch invite link
function showRematchInvite(inviteLink) {
const linkDisplay = document.createElement('div');
linkDisplay.style.cssText = `
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
background: #2a2a2a; border: 2px solid #00ff00; border-radius: 8px;
padding: 20px; z-index: 1000; max-width: 500px;
color: white; text-align: center;
`;
linkDisplay.innerHTML = `
<h3>Rematch Created!</h3>
<p>Share this link with your opponent for the rematch:</p>
<input type="text" value="${inviteLink}" readonly style="
width: 100%; padding: 10px; margin: 10px 0;
background: #333; color: white; border: 1px solid #555;
border-radius: 4px; text-align: center;
">
<button id="copy-rematch-link-btn" style="
background: #0066ff; color: white; border: none;
padding: 10px 20px; border-radius: 4px; cursor: pointer;
">Copy Link</button>
<button onclick="this.parentElement.remove()" style="
background: #666; color: white; border: none;
padding: 10px 20px; border-radius: 4px; cursor: pointer; margin-left: 10px;
">Close</button>
`;
// Add event listener for copy button
const copyBtn = linkDisplay.querySelector('#copy-rematch-link-btn');
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(inviteLink);
copyBtn.textContent = 'Copied!';
setTimeout(() => {
copyBtn.textContent = 'Copy Link';
}, 2000);
} catch (error) {
console.error('Failed to copy link:', error);
// Fallback: select the text for manual copying
const input = linkDisplay.querySelector('input');
input.select();
input.setSelectionRange(0, 99999); // For mobile devices
copyBtn.textContent = 'Selected - Press Ctrl+C';
}
});
document.body.appendChild(linkDisplay);
}