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…
+
+
+
Bomber Rooms
+
-
= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.
-
This page will update automatically as the plan is implemented.
-
Runtime: PHP = htmlspecialchars($phpVersion) ?> — UTC = htmlspecialchars($now) ?>
+
+
+
+
+
+
+
+
Retro multiplayer MVP
+
Классический Bomberman онлайн — лобби, комнаты, матч
+
Создайте комнату, позовите друзей и запустите быстрый матч. MVP синхронизирует движение и бомбы через частый опрос — в следующем шаге добавим WebSocket для настоящего realtime.
+
+
+
+
+
Статус MVP
+
+ Комнаты = count($rooms) ?>
+ Сетка 13 × 11
+ Текущее время = htmlspecialchars($now) ?> UTC
+
+
+
+
+
+
+
+
+
+
+
= htmlspecialchars($flash) ?>
+
+
+
+ = htmlspecialchars(implode(' ', $errors)) ?>
+
+
+
+
+
+
+
+
Как работает матч
+
+ Создайте комнату и дождитесь 2–4 игроков.
+ Хост запускает матч — генерируется карта.
+ Двигайтесь WASD/стрелками, ставьте бомбы (Space).
+ Выигрывает последний выживший.
+
+
+
+
+
+
+
+
Активные комнаты
+ = count($rooms) ?> комнат
+
+
+
+
+
+
+ Комната
+ Статус
+ Игроки
+ Действие
+
+
+
+
+
+ Пока нет комнат — создайте первую выше.
+
+
+
+
+
+
+ = htmlspecialchars($room['name']) ?>
+ #= (int) $room['id'] ?>
+
+ = htmlspecialchars($room['status']) ?>
+ = $playerCount ?> / = (int) $room['max_players'] ?>
+
+
+
+
+
+
+
+
+
+
+
-
- Page updated: = htmlspecialchars($now) ?> (UTC)
+
+
+
+
PHP = htmlspecialchars($phpVersion) ?>
+
Обновлено = htmlspecialchars($now) ?> UTC
+
/healthz
+
+
+
+
diff --git a/match.php b/match.php
new file mode 100644
index 0000000..6173b74
--- /dev/null
+++ b/match.php
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+ Матч — = htmlspecialchars($room['name']) ?> | Bomber Rooms
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Матч #= (int) $roomId ?>
+
= htmlspecialchars($room['name']) ?>
+
+
+ Управление: WASD / ←↑→↓, бомба — Space
+
+
+
+
+
+
+
+ Ожидание состояния...
+ PHP = htmlspecialchars($phpVersion) ?>
+
+
+
+
+
Состав и статус
+
+
Поставить бомбу
+
Вернуться в комнату
+
+
Бонусы: увеличение взрыва, +бомбы, скорость
+
Realtime синхронизация включена через push‑stream.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/room.php b/room.php
new file mode 100644
index 0000000..ec12069
--- /dev/null
+++ b/room.php
@@ -0,0 +1,205 @@
+getMessage();
+ }
+ }
+}
+
+$sessionPlayer = get_session_player($roomId);
+$state = $room['state'];
+$players = $state['players'] ?? [];
+$isMember = false;
+$isHost = false;
+foreach ($players as $player) {
+ if ($sessionPlayer && $player['token'] === $sessionPlayer['token']) {
+ $isMember = true;
+ $isHost = !empty($player['is_host']);
+ break;
+ }
+}
+
+$phpVersion = PHP_VERSION;
+?>
+
+
+
+
+
+ Комната = htmlspecialchars($room['name']) ?> — Bomber Rooms
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Комната #= (int) $roomId ?>
+
= htmlspecialchars($room['name']) ?>
+
Статус: = htmlspecialchars($room['status']) ?>
+
+
+ Игроков: = count($players) ?> / = (int) $room['max_players'] ?>
+
+
+
+
+
= htmlspecialchars(implode(' ', $errors)) ?>
+
+
+
+
+
+
+
+
+
+
+
Ожидание старта
+
Матч запустится после команды хоста. Статус обновляется автоматически.
+
+
+ Подсказка: комната обновляется в realtime через push‑stream, fallback — обычный polling.
+
+
+
+
+
+
+
+
+
+
+
Обновление комнаты.
+
+
+
+
+
+
+
+
+
+