Compare commits

..

1 Commits

Author SHA1 Message Date
Flatlogic Bot
8150369bb7 Autosave: 20260305-103931 2026-03-05 10:39:31 +00:00
10 changed files with 1613 additions and 282 deletions

166
api/room_action.php Normal file
View File

@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
session_start();
require_once __DIR__ . '/../includes/rooms.php';
header('Content-Type: application/json');
ensure_rooms_schema();
$input = $_POST;
$roomId = (int) ($input['room_id'] ?? 0);
$action = $input['action'] ?? '';
if ($roomId <= 0 || $action === '') {
echo json_encode(['success' => 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()
]);

47
api/room_state.php Normal file
View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
session_start();
require_once __DIR__ . '/../includes/rooms.php';
header('Content-Type: application/json');
ensure_rooms_schema();
$roomId = (int) ($_GET['room_id'] ?? 0);
if ($roomId <= 0) {
echo json_encode(['success' => 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()
]);

99
api/room_stream.php Normal file
View File

@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
session_start();
require_once __DIR__ . '/../includes/rooms.php';
ensure_rooms_schema();
$roomId = (int) ($_GET['room_id'] ?? 0);
if ($roomId <= 0) {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['success' => 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;
}
}

View File

@ -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;
}
}

View File

@ -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);
}
});

View File

@ -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;

336
includes/rooms.php Normal file
View File

@ -0,0 +1,336 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../db/config.php';
function ensure_rooms_schema(): void {
$sql = <<<SQL
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;
SQL;
db()->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);
}
}

320
index.php
View File

@ -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');
?>
<!doctype html>
<html lang="en">
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>New Style</title>
<title>Bomber Rooms Online Multiplayer Lobby</title>
<?php
// Read project preview data from environment
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?>
<?php if ($projectDescription): ?>
<!-- Meta description -->
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<!-- Open Graph meta tags -->
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<!-- Twitter meta tags -->
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<?php else: ?>
<meta name="description" content="Лобби и комнаты для классического Bomberman: создавайте матч, подключайтесь к друзьям и запускайте игру." />
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<!-- Open Graph image -->
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<?php endif; ?>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
}
.loader {
margin: 1.25rem auto 1.25rem;
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hint {
opacity: 0.9;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
h1 {
font-size: 3rem;
font-weight: 700;
margin: 0 0 1rem;
letter-spacing: -1px;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
code {
background: rgba(0,0,0,0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
footer {
position: absolute;
bottom: 1rem;
font-size: 0.8rem;
opacity: 0.7;
}
</style>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="assets/css/custom.css?v=<?= time(); ?>" />
</head>
<body>
<main>
<div class="card">
<h1>Analyzing your requirements and generating your website…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
<nav class="navbar navbar-expand-lg navbar-light bg-white border-bottom small shadow-sm sticky-top">
<div class="container">
<a class="navbar-brand fw-semibold text-dark" href="/">Bomber Rooms</a>
<div class="d-flex gap-2">
<a class="btn btn-outline-dark btn-sm" href="#rooms">Комнаты</a>
<a class="btn btn-dark btn-sm" href="#create">Создать матч</a>
</div>
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will update automatically as the plan is implemented.</p>
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
</div>
</nav>
<header class="py-5 border-bottom bg-body">
<div class="container">
<div class="row align-items-center g-4">
<div class="col-lg-7">
<p class="text-uppercase text-muted small mb-2">Retro multiplayer MVP</p>
<h1 class="display-6 fw-semibold">Классический Bomberman онлайн лобби, комнаты, матч</h1>
<p class="text-muted mt-3 mb-4">Создайте комнату, позовите друзей и запустите быстрый матч. MVP синхронизирует движение и бомбы через частый опрос в следующем шаге добавим WebSocket для настоящего realtime.</p>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-dark" href="#create">Запустить комнату</a>
<a class="btn btn-outline-dark" href="#rooms">Смотреть лобби</a>
</div>
</div>
<div class="col-lg-5">
<div class="panel p-4">
<h2 class="h6 text-uppercase text-muted">Статус MVP</h2>
<ul class="list-unstyled mb-0 small">
<li class="d-flex justify-content-between py-2 border-bottom"><span>Комнаты</span><span><?= count($rooms) ?></span></li>
<li class="d-flex justify-content-between py-2 border-bottom"><span>Сетка</span><span>13 × 11</span></li>
<li class="d-flex justify-content-between py-2"><span>Текущее время</span><span><?= htmlspecialchars($now) ?> UTC</span></li>
</ul>
</div>
</div>
</div>
</div>
</header>
<main class="py-5">
<div class="container">
<?php if ($flash): ?>
<div class="alert alert-info"><?= htmlspecialchars($flash) ?></div>
<?php endif; ?>
<?php if ($errors): ?>
<div class="alert alert-danger">
<?= htmlspecialchars(implode(' ', $errors)) ?>
</div>
<?php endif; ?>
<div id="create" class="row g-4 mb-5">
<div class="col-lg-6">
<div class="panel p-4 h-100">
<h2 class="h5 fw-semibold mb-3">Создать комнату</h2>
<form method="post" class="vstack gap-3">
<input type="hidden" name="action" value="create">
<div>
<label class="form-label small text-muted">Название комнаты</label>
<input type="text" name="room_name" class="form-control" placeholder="Например: Retro Arena">
</div>
<div>
<label class="form-label small text-muted">Ваш ник</label>
<input type="text" name="player_name" class="form-control" placeholder="Player 1" required>
</div>
<button class="btn btn-dark w-100">Создать и войти</button>
</form>
</div>
</div>
<div class="col-lg-6">
<div class="panel p-4 h-100">
<h2 class="h5 fw-semibold mb-3">Как работает матч</h2>
<ol class="small text-muted mb-0">
<li class="mb-2">Создайте комнату и дождитесь 24 игроков.</li>
<li class="mb-2">Хост запускает матч генерируется карта.</li>
<li class="mb-2">Двигайтесь WASD/стрелками, ставьте бомбы (Space).</li>
<li>Выигрывает последний выживший.</li>
</ol>
</div>
</div>
</div>
<section id="rooms" class="mb-5">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h5 fw-semibold mb-0">Активные комнаты</h2>
<span class="text-muted small"><?= count($rooms) ?> комнат</span>
</div>
<div class="panel p-0 overflow-hidden">
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>Комната</th>
<th>Статус</th>
<th>Игроки</th>
<th>Действие</th>
</tr>
</thead>
<tbody>
<?php if (!$rooms): ?>
<tr>
<td colspan="4" class="text-center text-muted py-4">Пока нет комнат создайте первую выше.</td>
</tr>
<?php else: ?>
<?php foreach ($rooms as $room): ?>
<?php
$state = json_decode($room['state_json'], true) ?: [];
$playerCount = count($state['players'] ?? []);
?>
<tr>
<td>
<div class="fw-semibold"><?= htmlspecialchars($room['name']) ?></div>
<div class="text-muted small">#<?= (int) $room['id'] ?></div>
</td>
<td><span class="badge text-bg-light border"><?= htmlspecialchars($room['status']) ?></span></td>
<td><?= $playerCount ?> / <?= (int) $room['max_players'] ?></td>
<td>
<form method="post" class="d-flex gap-2 align-items-center">
<input type="hidden" name="action" value="join">
<input type="hidden" name="room_id" value="<?= (int) $room['id'] ?>">
<input type="text" name="player_name" class="form-control form-control-sm" placeholder="Ник" required>
<button class="btn btn-outline-dark btn-sm">Войти</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</section>
</div>
</main>
<footer>
Page updated: <?= htmlspecialchars($now) ?> (UTC)
<footer class="border-top py-4">
<div class="container small text-muted d-flex justify-content-between flex-wrap gap-2">
<span>PHP <?= htmlspecialchars($phpVersion) ?></span>
<span>Обновлено <?= htmlspecialchars($now) ?> UTC</span>
<a class="text-muted" href="/healthz">/healthz</a>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="assets/js/main.js?v=<?= time(); ?>"></script>
</body>
</html>

122
match.php Normal file
View File

@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
session_start();
require_once __DIR__ . '/includes/rooms.php';
ensure_rooms_schema();
$roomId = (int) ($_GET['id'] ?? 0);
if ($roomId <= 0) {
http_response_code(404);
echo 'Room not found';
exit;
}
$room = get_room($roomId);
if (!$room) {
http_response_code(404);
echo 'Room not found';
exit;
}
$sessionPlayer = get_session_player($roomId);
if (!$sessionPlayer) {
$_SESSION['flash'] = 'Сначала войдите в комнату.';
header('Location: /room.php?id=' . $roomId);
exit;
}
$phpVersion = PHP_VERSION;
?>
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Матч <?= htmlspecialchars($room['name']) ?> | Bomber Rooms</title>
<?php
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?>
<?php if ($projectDescription): ?>
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<?php endif; ?>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="assets/css/custom.css?v=<?= time(); ?>" />
</head>
<body>
<nav class="navbar navbar-light bg-white border-bottom small shadow-sm sticky-top">
<div class="container">
<a class="navbar-brand fw-semibold text-dark" href="/">Bomber Rooms</a>
<div class="d-flex gap-2">
<a class="btn btn-outline-dark btn-sm" href="room.php?id=<?= (int) $roomId ?>">Комната</a>
<a class="btn btn-dark btn-sm" href="/">Лобби</a>
</div>
</div>
</nav>
<main class="py-4">
<div class="container">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3 mb-4">
<div>
<p class="text-uppercase text-muted small mb-1">Матч #<?= (int) $roomId ?></p>
<h1 class="h4 fw-semibold mb-0"><?= htmlspecialchars($room['name']) ?></h1>
</div>
<div class="panel px-3 py-2 small text-muted">
Управление: WASD / ←↑→↓, бомба Space
</div>
</div>
<div class="row g-4">
<div class="col-lg-8">
<div class="panel p-3">
<div id="game-board" class="game-board" aria-live="polite"></div>
</div>
<div class="d-flex justify-content-between align-items-center mt-3 small text-muted">
<span id="match-status">Ожидание состояния...</span>
<span>PHP <?= htmlspecialchars($phpVersion) ?></span>
</div>
</div>
<div class="col-lg-4">
<div class="panel p-4 h-100">
<h2 class="h6 text-uppercase text-muted">Состав и статус</h2>
<ul id="match-players" class="list-unstyled small mb-3"></ul>
<button id="place-bomb" class="btn btn-dark w-100 mb-2">Поставить бомбу</button>
<a class="btn btn-outline-dark w-100" href="room.php?id=<?= (int) $roomId ?>">Вернуться в комнату</a>
<div class="mt-4 small text-muted">
<div class="mb-1">Бонусы: увеличение взрыва, +бомбы, скорость</div>
<div>Realtime синхронизация включена через pushstream.</div>
</div>
</div>
</div>
</div>
</div>
</main>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div id="match-toast" class="toast align-items-center text-bg-dark border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body" id="match-toast-body">Статус обновлен.</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
</div>
<script>
window.ROOM_ID = <?= (int) $roomId ?>;
window.PLAYER_TOKEN = <?= json_encode($sessionPlayer['token']) ?>;
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="assets/js/main.js?v=<?= time(); ?>"></script>
</body>
</html>

205
room.php Normal file
View File

@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
session_start();
require_once __DIR__ . '/includes/rooms.php';
ensure_rooms_schema();
$roomId = (int) ($_GET['id'] ?? 0);
if ($roomId <= 0) {
http_response_code(404);
echo 'Room not found';
exit;
}
if (isset($_GET['leave'])) {
$sessionPlayer = get_session_player($roomId);
if ($sessionPlayer) {
$room = get_room($roomId);
if ($room) {
$newRoom = remove_player_from_room($room, $sessionPlayer['token']);
if ($newRoom === null) {
delete_room($roomId);
} else {
save_room_state($roomId, $newRoom['state'], $newRoom['status']);
}
}
}
unset($_SESSION['room_players'][$roomId]);
header('Location: /');
exit;
}
$room = get_room($roomId);
if (!$room) {
http_response_code(404);
echo 'Room not found';
exit;
}
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'join') {
$playerName = trim($_POST['player_name'] ?? '');
if ($playerName === '') {
$errors[] = 'Введите ник.';
} 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);
$room['state'] = $state;
} catch (RuntimeException $e) {
$errors[] = $e->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;
?>
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Комната <?= htmlspecialchars($room['name']) ?> — Bomber Rooms</title>
<?php
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?>
<?php if ($projectDescription): ?>
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<?php endif; ?>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="assets/css/custom.css?v=<?= time(); ?>" />
</head>
<body>
<nav class="navbar navbar-light bg-white border-bottom small shadow-sm sticky-top">
<div class="container">
<a class="navbar-brand fw-semibold text-dark" href="/">Bomber Rooms</a>
<div class="d-flex gap-2">
<a class="btn btn-outline-dark btn-sm" href="/">Лобби</a>
<a class="btn btn-dark btn-sm" href="room.php?id=<?= (int) $roomId ?>&leave=1">Выйти</a>
</div>
</div>
</nav>
<main class="py-5">
<div class="container">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<p class="text-uppercase text-muted small mb-2">Комната #<?= (int) $roomId ?></p>
<h1 class="h3 fw-semibold mb-2"><?= htmlspecialchars($room['name']) ?></h1>
<p class="text-muted mb-0">Статус: <span class="badge text-bg-light border"><?= htmlspecialchars($room['status']) ?></span></p>
</div>
<div class="panel px-3 py-2 small text-muted">
Игроков: <strong><?= count($players) ?> / <?= (int) $room['max_players'] ?></strong>
</div>
</div>
<?php if ($errors): ?>
<div class="alert alert-danger"><?= htmlspecialchars(implode(' ', $errors)) ?></div>
<?php endif; ?>
<?php if (!$isMember): ?>
<div class="panel p-4 mb-4">
<h2 class="h5 fw-semibold mb-3">Войти в комнату</h2>
<form method="post" class="row g-3">
<input type="hidden" name="action" value="join">
<div class="col-md-6">
<label class="form-label small text-muted">Ваш ник</label>
<input type="text" name="player_name" class="form-control" required>
</div>
<div class="col-12">
<button class="btn btn-dark">Войти и ожидать</button>
</div>
</form>
</div>
<?php endif; ?>
<div class="row g-4">
<div class="col-lg-4">
<div class="panel p-4 h-100">
<h2 class="h6 text-uppercase text-muted">Игроки в комнате</h2>
<ul id="player-list" class="list-unstyled small mb-4"></ul>
<div class="d-grid gap-2">
<?php if ($isHost && $room['status'] === 'waiting'): ?>
<button id="start-match" class="btn btn-dark">Запустить матч</button>
<?php endif; ?>
<?php if ($room['status'] === 'playing'): ?>
<a class="btn btn-dark" href="match.php?id=<?= (int) $roomId ?>">Перейти в матч</a>
<?php endif; ?>
<?php if ($room['status'] === 'finished'): ?>
<a class="btn btn-outline-dark" href="match.php?id=<?= (int) $roomId ?>">Смотреть итог</a>
<?php endif; ?>
<a class="btn btn-outline-dark" href="/">Вернуться в лобби</a>
</div>
</div>
</div>
<div class="col-lg-8">
<div class="panel p-4 h-100">
<h2 class="h6 text-uppercase text-muted">Ожидание старта</h2>
<p class="text-muted">Матч запустится после команды хоста. Статус обновляется автоматически.</p>
<div class="room-status-grid">
<div>
<div class="label">Подключено</div>
<div id="room-count" class="value"></div>
</div>
<div>
<div class="label">Состояние</div>
<div id="room-state" class="value"></div>
</div>
<div>
<div class="label">Победитель</div>
<div id="room-winner" class="value"></div>
</div>
</div>
<div class="mt-4 small text-muted">
<strong>Подсказка:</strong> комната обновляется в realtime через pushstream, fallback обычный polling.
</div>
</div>
</div>
</div>
</div>
</main>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div id="room-toast" class="toast align-items-center text-bg-dark border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body" id="room-toast-body">Обновление комнаты.</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
</div>
<script>
window.ROOM_ID = <?= (int) $roomId ?>;
window.PLAYER_TOKEN = <?= $sessionPlayer ? json_encode($sessionPlayer['token']) : 'null' ?>;
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="assets/js/main.js?v=<?= time(); ?>"></script>
</body>
</html>