1286 lines
No EOL
43 KiB
JavaScript
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);
|
|
} |