From 8150369bb76c808aca5a306a90a0a4c2ade4fcfa Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 5 Mar 2026 10:39:31 +0000 Subject: [PATCH] Autosave: 20260305-103931 --- api/room_action.php | 166 +++++++++++ api/room_state.php | 47 +++ api/room_stream.php | 99 +++++++ assets/css/custom.css | 248 +++++++--------- assets/js/main.js | 342 ++++++++++++++++++++-- db/migrations/2026_03_05_create_rooms.sql | 10 + includes/rooms.php | 336 +++++++++++++++++++++ index.php | 320 ++++++++++++-------- match.php | 122 ++++++++ room.php | 205 +++++++++++++ 10 files changed, 1613 insertions(+), 282 deletions(-) create mode 100644 api/room_action.php create mode 100644 api/room_state.php create mode 100644 api/room_stream.php create mode 100644 db/migrations/2026_03_05_create_rooms.sql create mode 100644 includes/rooms.php create mode 100644 match.php create mode 100644 room.php diff --git a/api/room_action.php b/api/room_action.php new file mode 100644 index 0000000..d00f811 --- /dev/null +++ b/api/room_action.php @@ -0,0 +1,166 @@ + false, 'error' => 'Invalid request']); + exit; +} + +$room = get_room($roomId); +if (!$room) { + echo json_encode(['success' => false, 'error' => 'Room not found']); + exit; +} + +$sessionPlayer = get_session_player($roomId); +if (!$sessionPlayer) { + echo json_encode(['success' => false, 'error' => 'Player not in room']); + exit; +} + +$state = $room['state']; +$players = $state['players'] ?? []; +$playerIndex = null; +foreach ($players as $index => $player) { + if ($player['token'] === $sessionPlayer['token']) { + $playerIndex = $index; + break; + } +} + +if ($playerIndex === null) { + echo json_encode(['success' => false, 'error' => 'Player not found']); + exit; +} + +if ($action === 'start') { + if (!empty($players[$playerIndex]['is_host']) && $room['status'] === 'waiting') { + $room['status'] = 'playing'; + $state['started_at'] = now_ms(); + $room['state'] = $state; + $room = update_room_tick($room); + save_room_state($roomId, $room['state'], $room['status']); + echo json_encode([ + 'success' => true, + 'status' => 'playing', + 'room' => [ + 'id' => $room['id'], + 'name' => $room['name'], + 'status' => $room['status'], + 'max_players' => (int) $room['max_players'] + ], + 'state' => $room['state'], + 'server_time' => now_ms() + ]); + exit; + } + echo json_encode(['success' => false, 'error' => 'Not allowed']); + exit; +} + +if ($room['status'] !== 'playing') { + echo json_encode(['success' => false, 'error' => 'Match not active']); + exit; +} + +if ($action === 'move') { + $dir = $input['dir'] ?? ''; + $moves = [ + 'up' => [0, -1], + 'down' => [0, 1], + 'left' => [-1, 0], + 'right' => [1, 0], + ]; + if (!isset($moves[$dir])) { + echo json_encode(['success' => false, 'error' => 'Invalid direction']); + exit; + } + $player = $players[$playerIndex]; + if (!$player['alive']) { + echo json_encode(['success' => false, 'error' => 'Player eliminated']); + exit; + } + $nx = $player['x'] + $moves[$dir][0]; + $ny = $player['y'] + $moves[$dir][1]; + $tile = $state['map']['tiles'][$ny][$nx] ?? 1; + $bombs = $state['bombs'] ?? []; + $bombBlocked = false; + foreach ($bombs as $bomb) { + if ($bomb['x'] === $nx && $bomb['y'] === $ny) { + $bombBlocked = true; + break; + } + } + if ($tile === 0 && !$bombBlocked) { + $player['x'] = $nx; + $player['y'] = $ny; + if (!empty($state['powerups'])) { + foreach ($state['powerups'] as $idx => $powerup) { + if ($powerup['x'] === $nx && $powerup['y'] === $ny) { + apply_powerup($player, $powerup['type']); + unset($state['powerups'][$idx]); + } + } + $state['powerups'] = array_values($state['powerups']); + } + $players[$playerIndex] = $player; + } +} + +if ($action === 'bomb') { + $player = $players[$playerIndex]; + if (!$player['alive']) { + echo json_encode(['success' => false, 'error' => 'Player eliminated']); + exit; + } + $bombs = $state['bombs'] ?? []; + $owned = array_filter($bombs, fn($b) => $b['owner'] === $player['token']); + $hasBomb = false; + foreach ($bombs as $bomb) { + if ($bomb['x'] === $player['x'] && $bomb['y'] === $player['y']) { + $hasBomb = true; + break; + } + } + if (count($owned) < $player['bombs'] && !$hasBomb) { + $bombs[] = [ + 'x' => $player['x'], + 'y' => $player['y'], + 'owner' => $player['token'], + 'placed_at' => now_ms(), + 'blast' => $player['blast'] + ]; + $state['bombs'] = $bombs; + } +} + +$state['players'] = $players; +$room['state'] = $state; +$room = update_room_tick($room); +save_room_state($roomId, $room['state'], $room['status']); + +echo json_encode([ + 'success' => true, + 'room' => [ + 'id' => $room['id'], + 'name' => $room['name'], + 'status' => $room['status'], + 'max_players' => (int) $room['max_players'] + ], + 'state' => $room['state'], + 'server_time' => now_ms() +]); diff --git a/api/room_state.php b/api/room_state.php new file mode 100644 index 0000000..e47975a --- /dev/null +++ b/api/room_state.php @@ -0,0 +1,47 @@ + false, 'error' => 'Missing room id']); + exit; +} + +$room = get_room($roomId); +if (!$room) { + echo json_encode(['success' => false, 'error' => 'Room not found']); + exit; +} + +$state = $room['state'] ?? []; +$hasDynamicObjects = !empty($state['bombs']) || !empty($state['explosions']); +if ($room['status'] === 'playing' || $hasDynamicObjects) { + $room = update_room_tick($room); + save_room_state($roomId, $room['state'], $room['status']); +} + +$sessionPlayer = get_session_player($roomId); +$playerToken = $sessionPlayer['token'] ?? null; + +echo json_encode([ + 'success' => true, + 'room' => [ + 'id' => $room['id'], + 'name' => $room['name'], + 'status' => $room['status'], + 'max_players' => (int) $room['max_players'] + ], + 'state' => $room['state'], + 'player_token' => $playerToken, + 'server_time' => now_ms() +]); diff --git a/api/room_stream.php b/api/room_stream.php new file mode 100644 index 0000000..6425696 --- /dev/null +++ b/api/room_stream.php @@ -0,0 +1,99 @@ + false, 'error' => 'Missing room id']); + exit; +} + +$sessionPlayer = get_session_player($roomId); +$playerToken = $sessionPlayer['token'] ?? null; +session_write_close(); + +header('Content-Type: text/event-stream'); +header('Cache-Control: no-cache, no-store, must-revalidate'); +header('Pragma: no-cache'); +header('Expires: 0'); +header('X-Accel-Buffering: no'); + +@ini_set('zlib.output_compression', '0'); +@ini_set('output_buffering', '0'); +while (ob_get_level() > 0) { + @ob_end_flush(); +} +ob_implicit_flush(true); +set_time_limit(0); +ignore_user_abort(true); + +$startedAt = time(); +$lastHash = ''; + +while (!connection_aborted()) { + $room = get_room($roomId); + if (!$room) { + echo "event: room_closed\n"; + echo 'data: {"success":false,"error":"Room not found"}' . "\n\n"; + flush(); + break; + } + + $state = $room['state'] ?? []; + $hasDynamicObjects = !empty($state['bombs']) || !empty($state['explosions']); + if ($room['status'] === 'playing' || $hasDynamicObjects) { + $room = update_room_tick($room); + save_room_state((int) $room['id'], $room['state'], (string) $room['status']); + } + + $payload = [ + 'success' => true, + 'room' => [ + 'id' => (int) $room['id'], + 'name' => $room['name'], + 'status' => $room['status'], + 'max_players' => (int) $room['max_players'], + ], + 'state' => $room['state'], + 'player_token' => $playerToken, + 'server_time' => now_ms(), + ]; + + $json = json_encode($payload, JSON_UNESCAPED_UNICODE); + if ($json === false) { + break; + } + + $stateForHash = $payload['state']; + unset($stateForHash['last_tick']); + $hash = sha1(json_encode([ + 'room' => $payload['room'], + 'state' => $stateForHash, + 'player_token' => $payload['player_token'], + ], JSON_UNESCAPED_UNICODE) ?: $json); + if ($hash !== $lastHash) { + $lastHash = $hash; + echo "event: state\n"; + echo 'data: ' . $json . "\n\n"; + flush(); + } else { + echo "event: ping\n"; + echo 'data: {"ts":' . now_ms() . "}\n\n"; + flush(); + } + + usleep(($room['status'] === 'playing') ? 120000 : 350000); + + if ((time() - $startedAt) >= 25) { + break; + } +} diff --git a/assets/css/custom.css b/assets/css/custom.css index 789132e..4a527e3 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -1,161 +1,135 @@ body { - background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); - background-size: 400% 400%; - animation: gradient 15s ease infinite; - color: #212529; - font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background: #f4f5f7; + color: #0f172a; + font-family: 'Inter', system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 14px; margin: 0; min-height: 100vh; } -.main-wrapper { +.panel { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05); +} + +.form-control, +.form-select { + border-radius: 8px; + border-color: #d1d5db; +} + +.form-control:focus, +.form-select:focus { + box-shadow: 0 0 0 0.2rem rgba(17, 24, 39, 0.15); + border-color: #111827; +} + +.btn { + border-radius: 8px; + padding: 0.5rem 1rem; +} + +.btn-dark { + background: #111827; + border-color: #111827; +} + +.btn-dark:hover { + background: #0b1220; + border-color: #0b1220; +} + +.room-status-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 16px; + margin-top: 12px; +} + +.room-status-grid .label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #6b7280; +} + +.room-status-grid .value { + font-weight: 600; + margin-top: 4px; +} + +.game-board { + display: grid; + grid-template-columns: repeat(13, 28px); + grid-template-rows: repeat(11, 28px); + gap: 4px; + justify-content: center; +} + +.game-cell { + width: 28px; + height: 28px; + border-radius: 6px; + background: #f8fafc; + border: 1px solid #e2e8f0; + position: relative; display: flex; align-items: center; justify-content: center; - min-height: 100vh; - width: 100%; - padding: 20px; - box-sizing: border-box; - position: relative; - z-index: 1; + font-size: 10px; } -@keyframes gradient { - 0% { - background-position: 0% 50%; - } - 50% { - background-position: 100% 50%; - } - 100% { - background-position: 0% 50%; - } +.cell-solid { + background: #111827; + border-color: #111827; } -.chat-container { - width: 100%; - max-width: 600px; - background: rgba(255, 255, 255, 0.85); - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 20px; - display: flex; - flex-direction: column; - height: 85vh; - box-shadow: 0 20px 40px rgba(0,0,0,0.2); - backdrop-filter: blur(15px); - -webkit-backdrop-filter: blur(15px); - overflow: hidden; +.cell-breakable { + background: #cbd5f5; + border-color: #94a3b8; } -.chat-header { - padding: 1.5rem; - border-bottom: 1px solid rgba(0, 0, 0, 0.05); - background: rgba(255, 255, 255, 0.5); - font-weight: 700; - font-size: 1.1rem; - display: flex; - justify-content: space-between; +.cell-powerup { + border: 1px dashed #0f172a; +} + +.cell-bomb::after { + content: ''; + width: 12px; + height: 12px; + border-radius: 50%; + background: #111827; + display: block; +} + +.cell-explosion { + background: #111827; + color: #fff; +} + +.player-chip { + width: 16px; + height: 16px; + border-radius: 4px; + display: inline-flex; align-items: center; -} - -.chat-messages { - flex: 1; - overflow-y: auto; - padding: 1.5rem; - display: flex; - flex-direction: column; - gap: 1.25rem; -} - -/* Custom Scrollbar */ -::-webkit-scrollbar { - width: 6px; -} - -::-webkit-scrollbar-track { - background: transparent; -} - -::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.3); - border-radius: 10px; -} - -::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.5); -} - -.message { - max-width: 85%; - padding: 0.85rem 1.1rem; - border-radius: 16px; - line-height: 1.5; - font-size: 0.95rem; - box-shadow: 0 4px 15px rgba(0,0,0,0.05); - animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(20px) scale(0.95); } - to { opacity: 1; transform: translateY(0) scale(1); } -} - -.message.visitor { - align-self: flex-end; - background: linear-gradient(135deg, #212529 0%, #343a40 100%); + justify-content: center; + font-size: 9px; color: #fff; - border-bottom-right-radius: 4px; } -.message.bot { - align-self: flex-start; - background: #ffffff; - color: #212529; - border-bottom-left-radius: 4px; -} - -.chat-input-area { - padding: 1.25rem; - background: rgba(255, 255, 255, 0.5); - border-top: 1px solid rgba(0, 0, 0, 0.05); -} - -.chat-input-area form { +.player-list-item { display: flex; - gap: 0.75rem; + align-items: center; + justify-content: space-between; + padding: 6px 0; + border-bottom: 1px solid #e5e7eb; } -.chat-input-area input { - flex: 1; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - padding: 0.75rem 1rem; - outline: none; - background: rgba(255, 255, 255, 0.9); - transition: all 0.3s ease; -} - -.chat-input-area input:focus { - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2); -} - -.chat-input-area button { - background: #212529; - color: #fff; - border: none; - padding: 0.75rem 1.5rem; - border-radius: 12px; - cursor: pointer; - font-weight: 600; - transition: all 0.3s ease; -} - -.chat-input-area button:hover { - background: #000; - transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(0,0,0,0.2); +.player-list-item:last-child { + border-bottom: none; } /* Background Animations */ @@ -400,4 +374,4 @@ body { .no-messages { text-align: center; color: #777; -} \ No newline at end of file +} diff --git a/assets/js/main.js b/assets/js/main.js index d349598..1f45cb7 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,39 +1,321 @@ document.addEventListener('DOMContentLoaded', () => { - const chatForm = document.getElementById('chat-form'); - const chatInput = document.getElementById('chat-input'); - const chatMessages = document.getElementById('chat-messages'); + const roomId = window.ROOM_ID; + if (!roomId) return; - const appendMessage = (text, sender) => { - const msgDiv = document.createElement('div'); - msgDiv.classList.add('message', sender); - msgDiv.textContent = text; - chatMessages.appendChild(msgDiv); - chatMessages.scrollTop = chatMessages.scrollHeight; + const playerToken = window.PLAYER_TOKEN; + const roomPage = document.getElementById('player-list'); + const matchBoard = document.getElementById('game-board'); + const toastEl = document.getElementById('room-toast') || document.getElementById('match-toast'); + const toastBody = document.getElementById('room-toast-body') || document.getElementById('match-toast-body'); + const toast = toastEl ? new bootstrap.Toast(toastEl, { delay: 2000 }) : null; + + let lastState = null; + let lastMoveAt = 0; + let roomRedirectScheduled = false; + let moveInFlight = false; + let queuedMoveDir = null; + let pollTimer = null; + let stream = null; + let reconnectTimer = null; + + const showToast = (message) => { + if (!toast || !toastBody) return; + toastBody.textContent = message; + toast.show(); }; - chatForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const message = chatInput.value.trim(); - if (!message) return; - - appendMessage(message, 'visitor'); - chatInput.value = ''; - + const fetchState = async () => { try { - const response = await fetch('api/chat.php', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message }) - }); + const response = await fetch(`/api/room_state.php?room_id=${roomId}`, { cache: 'no-store' }); const data = await response.json(); - - // Artificial delay for realism - setTimeout(() => { - appendMessage(data.reply, 'bot'); - }, 500); + if (!data.success) return; + renderState(data); } catch (error) { - console.error('Error:', error); - appendMessage("Sorry, something went wrong. Please try again.", 'bot'); + console.error(error); } - }); + }; + + const startPolling = (intervalMs) => { + if (pollTimer) return; + pollTimer = setInterval(fetchState, intervalMs); + }; + + const stopPolling = () => { + if (!pollTimer) return; + clearInterval(pollTimer); + pollTimer = null; + }; + + const connectRealtime = () => { + if (!window.EventSource) { + startPolling(matchBoard ? 200 : 900); + return; + } + if (stream) return; + + stream = new EventSource(`/api/room_stream.php?room_id=${roomId}`); + stream.addEventListener('state', (event) => { + try { + const data = JSON.parse(event.data); + if (data && data.success) { + renderState(data); + } + } catch (error) { + console.error(error); + } + }); + stream.addEventListener('open', () => { + stopPolling(); + }); + stream.addEventListener('error', () => { + if (stream) { + stream.close(); + stream = null; + } + startPolling(matchBoard ? 250 : 900); + if (!reconnectTimer) { + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connectRealtime(); + }, 1500); + } + }); + }; + + const sendAction = async (action, payload = {}) => { + const formData = new URLSearchParams({ room_id: roomId, action, ...payload }); + const response = await fetch('/api/room_action.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: formData.toString(), + cache: 'no-store' + }); + const data = await response.json(); + if (data && data.success && data.room && data.state) { + renderState(data); + } + return data; + }; + + const sendMoveAction = async (dir) => { + if (moveInFlight) { + queuedMoveDir = dir; + return; + } + moveInFlight = true; + try { + await sendAction('move', { dir }); + } finally { + moveInFlight = false; + if (queuedMoveDir) { + const nextDir = queuedMoveDir; + queuedMoveDir = null; + sendMoveAction(nextDir); + } + } + }; + + const renderState = (data) => { + const { room, state } = data; + if (!state) return; + + if (roomPage) { + renderRoomPage(room, state); + } + if (matchBoard) { + renderMatchPage(room, state); + } + if (lastState && lastState.room.status !== room.status) { + showToast(`Статус матча: ${room.status}`); + } + lastState = data; + }; + + const renderRoomPage = (room, state) => { + const list = document.getElementById('player-list'); + const count = document.getElementById('room-count'); + const status = document.getElementById('room-state'); + const winner = document.getElementById('room-winner'); + + list.innerHTML = ''; + state.players.forEach((player) => { + const li = document.createElement('li'); + li.className = 'player-list-item'; + const name = document.createElement('span'); + name.textContent = player.name + (player.token === playerToken ? ' (вы)' : ''); + const chip = document.createElement('span'); + chip.className = 'player-chip'; + chip.style.background = player.color || '#111827'; + chip.textContent = player.alive ? 'OK' : 'KO'; + li.appendChild(name); + li.appendChild(chip); + list.appendChild(li); + }); + + count.textContent = `${state.players.length} / ${room.max_players}`; + status.textContent = room.status; + winner.textContent = state.winner || '—'; + + if (room.status === 'playing') { + const startBtn = document.getElementById('start-match'); + if (startBtn) startBtn.disabled = true; + + const isCurrentPlayerInRoom = !!playerToken && state.players.some((p) => p.token === playerToken); + if (isCurrentPlayerInRoom && !roomRedirectScheduled) { + roomRedirectScheduled = true; + showToast('Матч начался. Переходим в игру...'); + setTimeout(() => { + window.location.href = `/match.php?id=${roomId}`; + }, 600); + } + } + }; + + const renderMatchPage = (room, state) => { + const statusEl = document.getElementById('match-status'); + const playersList = document.getElementById('match-players'); + if (!matchBoard.dataset.ready) { + matchBoard.style.gridTemplateColumns = `repeat(${state.map.w}, 28px)`; + matchBoard.style.gridTemplateRows = `repeat(${state.map.h}, 28px)`; + matchBoard.innerHTML = ''; + for (let y = 0; y < state.map.h; y++) { + for (let x = 0; x < state.map.w; x++) { + const cell = document.createElement('div'); + cell.className = 'game-cell'; + cell.dataset.x = x; + cell.dataset.y = y; + matchBoard.appendChild(cell); + } + } + matchBoard.dataset.ready = 'true'; + } + + const cells = Array.from(matchBoard.children); + cells.forEach((cell) => { + cell.className = 'game-cell'; + cell.textContent = ''; + }); + + const bombs = state.bombs || []; + const explosions = state.explosions || []; + const powerups = state.powerups || []; + + for (let y = 0; y < state.map.h; y++) { + for (let x = 0; x < state.map.w; x++) { + const tile = state.map.tiles[y][x]; + const cell = cells[y * state.map.w + x]; + if (!cell) continue; + if (tile === 1) cell.classList.add('cell-solid'); + if (tile === 2) cell.classList.add('cell-breakable'); + } + } + + powerups.forEach((powerup) => { + const cell = cells[powerup.y * state.map.w + powerup.x]; + if (cell) cell.classList.add('cell-powerup'); + }); + + bombs.forEach((bomb) => { + const cell = cells[bomb.y * state.map.w + bomb.x]; + if (cell) cell.classList.add('cell-bomb'); + }); + + explosions.forEach((exp) => { + const cell = cells[exp.y * state.map.w + exp.x]; + if (cell) cell.classList.add('cell-explosion'); + }); + + state.players.forEach((player) => { + const cell = cells[player.y * state.map.w + player.x]; + if (!cell) return; + const chip = document.createElement('span'); + chip.className = 'player-chip'; + chip.style.background = player.color || '#111827'; + chip.textContent = player.name.slice(0, 1).toUpperCase(); + if (!player.alive) chip.style.opacity = '0.35'; + cell.appendChild(chip); + }); + + playersList.innerHTML = ''; + state.players.forEach((player) => { + const li = document.createElement('li'); + li.className = 'player-list-item'; + const name = document.createElement('span'); + name.textContent = player.name + (player.token === playerToken ? ' (вы)' : ''); + const chip = document.createElement('span'); + chip.className = 'player-chip'; + chip.style.background = player.color || '#111827'; + chip.textContent = player.alive ? 'OK' : 'KO'; + li.appendChild(name); + li.appendChild(chip); + playersList.appendChild(li); + }); + + statusEl.textContent = room.status === 'playing' + ? 'Матч идет' + : room.status === 'finished' + ? `Матч завершен. Победитель: ${state.winner || '—'}` + : 'Ожидание старта'; + + const me = state.players.find((p) => p.token === playerToken); + if (me && !me.alive) { + showToast('Вы выбиты. Дождитесь окончания матча.'); + } + }; + + if (roomPage) { + const startBtn = document.getElementById('start-match'); + if (startBtn) { + startBtn.addEventListener('click', async () => { + const res = await sendAction('start'); + if (res.success) { + showToast('Матч запущен.'); + setTimeout(() => { + window.location.href = `/match.php?id=${roomId}`; + }, 700); + } else { + showToast(res.error || 'Не удалось запустить матч.'); + } + }); + } + } + + if (matchBoard) { + document.getElementById('place-bomb').addEventListener('click', () => { + sendAction('bomb'); + }); + + window.addEventListener('keydown', (event) => { + const now = Date.now(); + const keyMap = { + ArrowUp: 'up', + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right', + w: 'up', + s: 'down', + a: 'left', + d: 'right' + }; + if (event.code === 'Space') { + sendAction('bomb'); + return; + } + const dir = keyMap[event.key]; + if (dir) { + const me = lastState?.state?.players?.find((p) => p.token === playerToken); + const speed = Math.max(1, Number(me?.speed || 1)); + const moveCooldown = Math.max(45, Math.floor(120 / speed)); + if (now - lastMoveAt < moveCooldown) return; + lastMoveAt = now; + sendMoveAction(dir); + } + }); + } + + fetchState(); + connectRealtime(); + if (!stream) { + startPolling(matchBoard ? 250 : 900); + } }); diff --git a/db/migrations/2026_03_05_create_rooms.sql b/db/migrations/2026_03_05_create_rooms.sql new file mode 100644 index 0000000..748f36e --- /dev/null +++ b/db/migrations/2026_03_05_create_rooms.sql @@ -0,0 +1,10 @@ +-- Bomber Rooms MVP schema (idempotent) +CREATE TABLE IF NOT EXISTS rooms ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(120) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'waiting', + max_players INT NOT NULL DEFAULT 4, + state_json LONGTEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/includes/rooms.php b/includes/rooms.php new file mode 100644 index 0000000..12282ef --- /dev/null +++ b/includes/rooms.php @@ -0,0 +1,336 @@ +exec($sql); +} + +function now_ms(): int { + return (int) floor(microtime(true) * 1000); +} + +function random_token(): string { + return bin2hex(random_bytes(8)); +} + +function spawn_points(int $w, int $h): array { + return [ + ['x' => 1, 'y' => 1], + ['x' => $w - 2, 'y' => 1], + ['x' => 1, 'y' => $h - 2], + ['x' => $w - 2, 'y' => $h - 2], + ]; +} + +function generate_map(int $w = 13, int $h = 11): array { + $tiles = []; + for ($y = 0; $y < $h; $y++) { + $row = []; + for ($x = 0; $x < $w; $x++) { + $tile = 0; + if ($x === 0 || $y === 0 || $x === $w - 1 || $y === $h - 1) { + $tile = 1; + } elseif ($x % 2 === 0 && $y % 2 === 0) { + $tile = 1; + } elseif (mt_rand(1, 100) <= 60) { + $tile = 2; + } + $row[] = $tile; + } + $tiles[] = $row; + } + + $spawns = spawn_points($w, $h); + foreach ($spawns as $spawn) { + for ($dy = -1; $dy <= 1; $dy++) { + for ($dx = -1; $dx <= 1; $dx++) { + $sx = $spawn['x'] + $dx; + $sy = $spawn['y'] + $dy; + if ($sx >= 0 && $sy >= 0 && $sx < $w && $sy < $h) { + $tiles[$sy][$sx] = 0; + } + } + } + } + + return [ + 'w' => $w, + 'h' => $h, + 'tiles' => $tiles + ]; +} + +function default_state(string $playerName, string $token, bool $host = true): array { + $map = generate_map(); + $spawns = spawn_points($map['w'], $map['h']); + $colors = ['#111827', '#374151', '#6b7280', '#9ca3af']; + $player = [ + 'token' => $token, + 'name' => $playerName, + 'x' => $spawns[0]['x'], + 'y' => $spawns[0]['y'], + 'alive' => true, + 'is_host' => $host, + 'bombs' => 1, + 'blast' => 2, + 'speed' => 1, + 'color' => $colors[0] + ]; + + return [ + 'map' => $map, + 'players' => [$player], + 'bombs' => [], + 'explosions' => [], + 'powerups' => [], + 'started_at' => null, + 'last_tick' => now_ms() + ]; +} + +function get_room(int $roomId): ?array { + $stmt = db()->prepare('SELECT * FROM rooms WHERE id = :id'); + $stmt->execute([':id' => $roomId]); + $row = $stmt->fetch(); + if (!$row) { + return null; + } + $row['state'] = json_decode($row['state_json'], true) ?: []; + return $row; +} + +function save_room_state(int $roomId, array $state, string $status): void { + $stmt = db()->prepare('UPDATE rooms SET state_json = :state_json, status = :status WHERE id = :id'); + $stmt->execute([ + ':state_json' => json_encode($state, JSON_UNESCAPED_UNICODE), + ':status' => $status, + ':id' => $roomId + ]); +} + +function create_room(string $roomName, string $playerName, string $token, int $maxPlayers = 4): int { + $state = default_state($playerName, $token, true); + $stmt = db()->prepare('INSERT INTO rooms (name, status, max_players, state_json) VALUES (:name, :status, :max_players, :state_json)'); + $stmt->execute([ + ':name' => $roomName, + ':status' => 'waiting', + ':max_players' => $maxPlayers, + ':state_json' => json_encode($state, JSON_UNESCAPED_UNICODE) + ]); + return (int) db()->lastInsertId(); +} + +function add_player_to_room(array $room, string $playerName, string $token): array { + $state = $room['state']; + $players = $state['players'] ?? []; + $maxPlayers = (int) $room['max_players']; + if (count($players) >= $maxPlayers) { + throw new RuntimeException('Room is full'); + } + $spawns = spawn_points($state['map']['w'], $state['map']['h']); + $colors = ['#111827', '#374151', '#6b7280', '#9ca3af']; + $index = count($players); + $spawn = $spawns[$index % count($spawns)]; + $player = [ + 'token' => $token, + 'name' => $playerName, + 'x' => $spawn['x'], + 'y' => $spawn['y'], + 'alive' => true, + 'is_host' => false, + 'bombs' => 1, + 'blast' => 2, + 'speed' => 1, + 'color' => $colors[$index % count($colors)] + ]; + $players[] = $player; + $state['players'] = $players; + return $state; +} + +function remove_player_from_room(array $room, string $token): ?array { + $state = $room['state']; + $players = $state['players'] ?? []; + $newPlayers = array_values(array_filter($players, fn($p) => $p['token'] !== $token)); + + if (count($newPlayers) === 0) { + return null; + } + + $hostExists = false; + foreach ($newPlayers as $p) { + if (!empty($p['is_host'])) { + $hostExists = true; + break; + } + } + + if (!$hostExists) { + $newPlayers[0]['is_host'] = true; + } + + $state['players'] = $newPlayers; + $room['state'] = $state; + return $room; +} + +function delete_room(int $roomId): void { + $stmt = db()->prepare('DELETE FROM rooms WHERE id = :id'); + $stmt->execute([':id' => $roomId]); +} + +function cleanup_empty_rooms(): int { + $stmt = db()->prepare('SELECT id, state_json FROM rooms'); + $stmt->execute(); + $rows = $stmt->fetchAll(); + $deleted = 0; + + foreach ($rows as $row) { + $state = json_decode($row['state_json'] ?? '', true); + $players = is_array($state) ? ($state['players'] ?? null) : null; + $isEmpty = !is_array($players) || count($players) === 0; + if ($isEmpty) { + delete_room((int) $row['id']); + $deleted++; + } + } + + return $deleted; +} + +function get_session_player(int $roomId): ?array { + return $_SESSION['room_players'][$roomId] ?? null; +} + +function set_session_player(int $roomId, string $token, string $name): void { + if (!isset($_SESSION['room_players'])) { + $_SESSION['room_players'] = []; + } + $_SESSION['room_players'][$roomId] = ['token' => $token, 'name' => $name]; +} + +function update_room_tick(array $room): array { + $state = $room['state']; + $now = now_ms(); + $state['explosions'] = array_values(array_filter($state['explosions'] ?? [], function ($exp) use ($now) { + return ($exp['until'] ?? 0) > $now; + })); + + $bombs = $state['bombs'] ?? []; + $remaining = []; + $newExplosions = []; + foreach ($bombs as $bomb) { + if (($bomb['placed_at'] ?? 0) + 2000 <= $now) { + $blast = $bomb['blast'] ?? 2; + $newExplosions = array_merge($newExplosions, compute_explosion_tiles($state, $bomb['x'], $bomb['y'], $blast)); + } else { + $remaining[] = $bomb; + } + } + $state['bombs'] = $remaining; + + if ($newExplosions) { + $tiles = $state['map']['tiles']; + foreach ($newExplosions as $exp) { + $tiles[$exp['y']][$exp['x']] = $exp['tile_after']; + if (!empty($exp['powerup'])) { + $state['powerups'][] = ['x' => $exp['x'], 'y' => $exp['y'], 'type' => $exp['powerup']]; + } + $state['explosions'][] = [ + 'x' => $exp['x'], + 'y' => $exp['y'], + 'until' => $now + 550 + ]; + } + $state['map']['tiles'] = $tiles; + } + + $players = $state['players'] ?? []; + foreach ($players as &$player) { + if (!$player['alive']) { + continue; + } + foreach ($state['explosions'] as $exp) { + if ($exp['x'] === $player['x'] && $exp['y'] === $player['y']) { + $player['alive'] = false; + } + } + } + unset($player); + $state['players'] = $players; + + $alive = array_filter($players, fn($p) => $p['alive']); + if ($room['status'] === 'playing' && count($alive) <= 1) { + $room['status'] = 'finished'; + $state['winner'] = $alive ? array_values($alive)[0]['name'] : null; + } + + $state['last_tick'] = $now; + $room['state'] = $state; + return $room; +} + +function compute_explosion_tiles(array $state, int $x, int $y, int $blast): array { + $tiles = $state['map']['tiles']; + $w = $state['map']['w']; + $h = $state['map']['h']; + $out = []; + $pushTile = function (int $tx, int $ty, bool $breakableHit = false) use (&$out, $tiles) { + $powerup = null; + if ($breakableHit && mt_rand(1, 100) <= 20) { + $types = ['blast', 'bombs', 'speed']; + $powerup = $types[array_rand($types)]; + } + $out[] = [ + 'x' => $tx, + 'y' => $ty, + 'tile_after' => $breakableHit ? 0 : $tiles[$ty][$tx], + 'powerup' => $powerup + ]; + }; + + $pushTile($x, $y); + $dirs = [[1,0],[-1,0],[0,1],[0,-1]]; + foreach ($dirs as $dir) { + for ($i = 1; $i <= $blast; $i++) { + $tx = $x + $dir[0] * $i; + $ty = $y + $dir[1] * $i; + if ($tx < 0 || $ty < 0 || $tx >= $w || $ty >= $h) { + break; + } + $tile = $tiles[$ty][$tx]; + if ($tile === 1) { + break; + } + if ($tile === 2) { + $pushTile($tx, $ty, true); + break; + } + $pushTile($tx, $ty); + } + } + return $out; +} + +function apply_powerup(array &$player, string $type): void { + if ($type === 'blast') { + $player['blast'] = min(5, $player['blast'] + 1); + } elseif ($type === 'bombs') { + $player['bombs'] = min(5, $player['bombs'] + 1); + } elseif ($type === 'speed') { + $player['speed'] = min(3, $player['speed'] + 1); + } +} diff --git a/index.php b/index.php index 7205f3d..9c4e53b 100644 --- a/index.php +++ b/index.php @@ -4,147 +4,237 @@ declare(strict_types=1); @error_reporting(E_ALL); @date_default_timezone_set('UTC'); +session_start(); +require_once __DIR__ . '/includes/rooms.php'; + +ensure_rooms_schema(); +cleanup_empty_rooms(); + +$errors = []; +$flash = $_SESSION['flash'] ?? null; +unset($_SESSION['flash']); + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = $_POST['action'] ?? ''; + $playerName = trim($_POST['player_name'] ?? ''); + $roomName = trim($_POST['room_name'] ?? ''); + if ($action === 'create') { + if ($playerName === '') { + $errors[] = 'Enter a player name to create a room.'; + } + if ($roomName === '') { + $roomName = 'Retro Arena'; + } + if (!$errors) { + $token = random_token(); + $roomId = create_room($roomName, $playerName, $token, 4); + set_session_player($roomId, $token, $playerName); + header('Location: room.php?id=' . $roomId); + exit; + } + } + + if ($action === 'join') { + $roomId = (int) ($_POST['room_id'] ?? 0); + if ($roomId <= 0) { + $errors[] = 'Select a room to join.'; + } + if ($playerName === '') { + $errors[] = 'Enter a player name to join.'; + } + if (!$errors) { + $room = get_room($roomId); + if (!$room) { + $errors[] = 'Room not found.'; + } else { + try { + $token = random_token(); + $state = add_player_to_room($room, $playerName, $token); + save_room_state($roomId, $state, $room['status']); + set_session_player($roomId, $token, $playerName); + header('Location: room.php?id=' . $roomId); + exit; + } catch (RuntimeException $e) { + $errors[] = $e->getMessage(); + } + } + } + } +} + +$stmt = db()->prepare('SELECT * FROM rooms ORDER BY updated_at DESC LIMIT 20'); +$stmt->execute(); +$rooms = $stmt->fetchAll(); + $phpVersion = PHP_VERSION; $now = date('Y-m-d H:i:s'); ?> - + - New Style + Bomber Rooms — Online Multiplayer Lobby - - - + + - - - - - - + + -
-
-

Analyzing your requirements and generating your website…

-
- Loading… + + +
+
+
+
+

Retro multiplayer MVP

+

Классический Bomberman онлайн — лобби, комнаты, матч

+

Создайте комнату, позовите друзей и запустите быстрый матч. MVP синхронизирует движение и бомбы через частый опрос — в следующем шаге добавим WebSocket для настоящего realtime.

+ +
+
+
+

Статус MVP

+
    +
  • Комнаты
  • +
  • Сетка13 × 11
  • +
  • Текущее время UTC
  • +
+
+
+
+
+
+ +
+
+ +
+ + +
+ +
+ + +
+
+
+

Создать комнату

+
+ +
+ + +
+
+ + +
+ +
+
+
+
+
+

Как работает матч

+
    +
  1. Создайте комнату и дождитесь 2–4 игроков.
  2. +
  3. Хост запускает матч — генерируется карта.
  4. +
  5. Двигайтесь WASD/стрелками, ставьте бомбы (Space).
  6. +
  7. Выигрывает последний выживший.
  8. +
+
+
+
+ +
+
+

Активные комнаты

+ комнат +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
КомнатаСтатусИгрокиДействие
Пока нет комнат — создайте первую выше.
+
+
#
+
/ +
+ + + + +
+
+
+
+
-