Commit 868682de authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: bigger game menu, in-game emotes, full tournament system with Swiss pairings

Game Menu:
- Buttons: 72px min-height (was 52), 20px padding, 48px icons
- Feature chips: 40px min-height, 13px font, 10px gaps
- Menu takes up to 75vh for better visibility

In-Game Emotes:
- 💬 toggle button in bottom-right during chess game
- 8 preset emotes: GG, Good Move, Think, Hurry, Wow, Laugh, Angry, Hello
- 3-second cooldown between sends
- Floating emoji animation when received
- Sound feedback on send

Tournament System:
- New API: /api/swiss.php (tournament, standings, rounds, pairings, my-games)
- Integrates with Swiss API at swissapi.caprover.al-arcade.com
- Tournament Detail scene with 4 tabs:
  - Info: stats grid, prize pool, registration button
  - Standings: ranked player list with score + buchholz
  - Rounds: expandable rounds → click to load pairings
  - My Games: player's own tournament matches
- Syncs with el3ab-management SwissApiService endpoints
- Tournament cards in list now navigate to full detail view
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 00e476cc
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../config/constants.php';
$action = $_GET['action'] ?? (getInput()['action'] ?? '');
switch ($action) {
case 'tournament':
getTournament();
break;
case 'standings':
getStandings();
break;
case 'rounds':
getRounds();
break;
case 'pairings':
getPairings();
break;
case 'my-games':
getMyGames();
break;
default:
jsonError('Invalid action');
}
function swissApi(string $method, string $path, ?array $body = null): array {
$url = SWISS_API . $path;
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
if ($body) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($httpCode >= 400) {
return ['error' => $data['message'] ?? 'Swiss API error', 'code' => $httpCode];
}
return $data ?? [];
}
function getTournament(): void {
$id = $_GET['id'] ?? '';
if (!$id) jsonError('id required');
// Get from Supabase
require_once __DIR__ . '/../includes/supabase.php';
$db = supabaseService();
$tournaments = $db->get('el3ab_tournaments', ['id' => 'eq.' . $id, 'limit' => 1]);
$tournament = is_array($tournaments) && !empty($tournaments) && !isset($tournaments['error']) ? $tournaments[0] : null;
if (!$tournament) jsonError('Tournament not found', 404);
// Get Swiss API data if linked
if (!empty($tournament['swiss_api_tournament_id'])) {
$swiss = swissApi('GET', '/tournaments/' . $tournament['swiss_api_tournament_id']);
if (!isset($swiss['error'])) {
$tournament['swiss_data'] = $swiss;
}
}
// Get registrations count
$regs = $db->get('tournament_registrations', ['tournament_id' => 'eq.' . $id, 'status' => 'eq.registered', 'select' => 'id']);
$tournament['player_count'] = is_array($regs) && !isset($regs['error']) ? count($regs) : 0;
jsonResponse($tournament);
}
function getStandings(): void {
$tournamentId = $_GET['tournament_id'] ?? '';
if (!$tournamentId) jsonError('tournament_id required');
require_once __DIR__ . '/../includes/supabase.php';
$db = supabaseService();
$tournament = $db->get('el3ab_tournaments', ['id' => 'eq.' . $tournamentId, 'select' => 'swiss_api_tournament_id', 'limit' => 1]);
if (empty($tournament) || isset($tournament['error'])) jsonError('Not found', 404);
$swissId = $tournament[0]['swiss_api_tournament_id'] ?? null;
if ($swissId) {
$standings = swissApi('GET', '/tournaments/' . $swissId . '/standings');
if (!isset($standings['error'])) {
jsonResponse(['standings' => $standings]);
}
}
jsonResponse(['standings' => []]);
}
function getRounds(): void {
$tournamentId = $_GET['tournament_id'] ?? '';
if (!$tournamentId) jsonError('tournament_id required');
require_once __DIR__ . '/../includes/supabase.php';
$db = supabaseService();
$rounds = $db->get('el3ab_tournament_rounds', [
'tournament_id' => 'eq.' . $tournamentId,
'select' => 'id,round_number,status,started_at,completed_at,pairings',
'order' => 'round_number.asc'
]);
jsonResponse(['rounds' => is_array($rounds) && !isset($rounds['error']) ? $rounds : []]);
}
function getPairings(): void {
$roundId = $_GET['round_id'] ?? '';
if (!$roundId) jsonError('round_id required');
require_once __DIR__ . '/../includes/supabase.php';
$db = supabaseService();
$round = $db->get('el3ab_tournament_rounds', ['id' => 'eq.' . $roundId, 'select' => 'pairings,results', 'limit' => 1]);
if (empty($round) || isset($round['error'])) jsonResponse(['pairings' => []]);
$pairings = json_decode($round[0]['pairings'] ?? '[]', true);
$results = json_decode($round[0]['results'] ?? '[]', true);
jsonResponse(['pairings' => $pairings, 'results' => $results]);
}
function getMyGames(): void {
$token = requireAuth();
$userId = getUserId($token);
$tournamentId = $_GET['tournament_id'] ?? '';
if (!$tournamentId) jsonError('tournament_id required');
require_once __DIR__ . '/../includes/supabase.php';
$db = supabase($token);
$matches = $db->get('matches', [
'tournament_id' => 'eq.' . $tournamentId,
'or' => "(white_player_id.eq.{$userId},black_player_id.eq.{$userId})",
'select' => 'id,white_player_id,black_player_id,result,status,tournament_round,created_at',
'order' => 'tournament_round.asc'
]);
jsonResponse(['games' => is_array($matches) && !isset($matches['error']) ? $matches : []]);
}
// In-game emote system for chess
// Preset emotes that both players can send during a match
const EMOTES = [
{ key: 'gg', emoji: '🤝', label: 'GG' },
{ key: 'good_move', emoji: '👏', label: 'نقلة ممتازة' },
{ key: 'think', emoji: '🤔', label: 'يفكر...' },
{ key: 'hurry', emoji: '⏱️', label: 'أسرع' },
{ key: 'wow', emoji: '😮', label: 'واو!' },
{ key: 'laugh', emoji: '😂', label: 'هههه' },
{ key: 'angry', emoji: '😤', label: 'غاضب' },
{ key: 'hello', emoji: '👋', label: 'مرحبا' },
];
let emoteBar = null;
let emoteCallback = null;
let lastEmoteTime = 0;
const COOLDOWN = 3000; // 3 seconds between emotes
export function create(container, onSend) {
emoteCallback = onSend;
emoteBar = document.createElement('div');
emoteBar.className = 'emote-bar';
emoteBar.innerHTML = `
<button class="emote-toggle" id="emote-toggle">💬</button>
<div class="emote-panel hidden" id="emote-panel">
${EMOTES.map(e => `
<button class="emote-btn" data-key="${e.key}" title="${e.label}">
<span style="font-size:22px;">${e.emoji}</span>
</button>
`).join('')}
</div>
`;
const style = document.createElement('style');
style.textContent = `
.emote-bar { position:absolute;bottom:52px;right:8px;z-index:30;display:flex;flex-direction:column;align-items:flex-end;gap:6px; }
.emote-toggle { width:40px;height:40px;border-radius:50%;background:#1e1e3a;border:1px solid rgba(255,255,255,0.1);color:#f8fafc;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:transform 0.15s,background 0.15s;box-shadow:0 2px 8px rgba(0,0,0,0.3); }
.emote-toggle:active { transform:scale(0.9);background:#2a2a5a; }
.emote-panel { display:flex;gap:6px;padding:8px;background:#1e1e3a;border-radius:12px;border:1px solid rgba(255,255,255,0.08);box-shadow:0 4px 20px rgba(0,0,0,0.5);animation:slideUpBounce 0.3s cubic-bezier(0.16,1,0.3,1); }
.emote-panel.hidden { display:none; }
.emote-btn { width:40px;height:40px;border-radius:8px;background:rgba(255,255,255,0.05);border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:transform 0.1s,background 0.15s; }
.emote-btn:hover { background:rgba(255,255,255,0.1); }
.emote-btn:active { transform:scale(0.85); }
.emote-btn.cooldown { opacity:0.3;pointer-events:none; }
.emote-received { position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:48px;animation:emoteFloat 2s ease-out forwards;pointer-events:none;z-index:40; }
@keyframes emoteFloat { 0%{opacity:1;transform:translate(-50%,-50%) scale(0.5);} 30%{transform:translate(-50%,-50%) scale(1.2);} 60%{opacity:1;transform:translate(-50%,-80%) scale(1);} 100%{opacity:0;transform:translate(-50%,-120%) scale(0.8);} }
`;
container.appendChild(style);
container.appendChild(emoteBar);
// Toggle panel
emoteBar.querySelector('#emote-toggle').addEventListener('click', () => {
const panel = emoteBar.querySelector('#emote-panel');
panel.classList.toggle('hidden');
});
// Emote buttons
emoteBar.querySelectorAll('.emote-btn').forEach(btn => {
btn.addEventListener('click', () => {
const now = Date.now();
if (now - lastEmoteTime < COOLDOWN) return;
lastEmoteTime = now;
const key = btn.dataset.key;
const emote = EMOTES.find(e => e.key === key);
if (emote && emoteCallback) {
emoteCallback(emote);
showSentFeedback(btn);
}
// Close panel
emoteBar.querySelector('#emote-panel').classList.add('hidden');
});
});
}
function showSentFeedback(btn) {
btn.classList.add('cooldown');
setTimeout(() => btn.classList.remove('cooldown'), COOLDOWN);
}
export function showReceived(container, emote) {
const el = document.createElement('div');
el.className = 'emote-received';
el.textContent = emote.emoji || emote;
container.appendChild(el);
setTimeout(() => el.remove(), 2000);
}
export function destroy() {
if (emoteBar) {
emoteBar.remove();
emoteBar = null;
}
}
......@@ -10,6 +10,7 @@ import { ChessClock, parseTimeControl } from '../logic/clock.js';
import * as juice from '../../../core/juice.js';
import { getOpeningName } from '../logic/openings.js';
import { getMaterialAdvantage, formatAdvantage } from '../logic/material.js';
import * as emoteSystem from '../components/emotes.js';
let board, clock, gameState;
......@@ -148,6 +149,13 @@ export function mountGame(el, params) {
requestBotMove(el);
}
// Emote system
const boardContainer = el.querySelector('#board-container');
emoteSystem.create(boardContainer, (emote) => {
audio.play('notification');
emoteSystem.showReceived(boardContainer, emote.emoji);
});
bus.emit('game:started', { gameKey: 'chess', matchId, opponent: botId, mode });
}
......
......@@ -89,14 +89,15 @@ export function mountTable(el) {
left: 0;
right: 0;
background: #0f0f1e;
border-top-left-radius: 24px;
border-top-right-radius: 24px;
padding: 24px 20px;
padding-bottom: calc(24px + var(--tab-height, 60px) + var(--safe-bottom, 0px));
border-top-left-radius: 28px;
border-top-right-radius: 28px;
padding: 28px 20px;
padding-bottom: calc(28px + var(--tab-height, 60px) + var(--safe-bottom, 0px));
z-index: 50;
transform: translateY(0);
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: 0 -8px 40px rgba(0,0,0,0.6);
max-height: 75vh;
}
.game-menu.hidden {
transform: translateY(100%);
......@@ -129,17 +130,18 @@ export function mountTable(el) {
.menu-btn {
display: flex;
align-items: center;
gap: 14px;
gap: 16px;
width: 100%;
padding: 16px;
border-radius: 14px;
padding: 20px;
border-radius: 16px;
border: none;
cursor: pointer;
margin-bottom: 10px;
margin-bottom: 12px;
transition: transform 0.1s, background 0.15s;
text-align: right;
min-height: 72px;
}
.menu-btn:active { transform: scale(0.97); }
.menu-btn:active { transform: scale(0.96); }
.menu-btn-primary {
background: var(--game-gradient, linear-gradient(135deg, #2563eb, #3b82f6));
color: white;
......@@ -150,21 +152,21 @@ export function mountTable(el) {
color: #f8fafc;
}
.menu-btn-icon {
width: 40px;
height: 40px;
border-radius: 10px;
width: 48px;
height: 48px;
border-radius: 12px;
background: rgba(255,255,255,0.15);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-size: 24px;
flex-shrink: 0;
}
.menu-btn-text {
flex: 1;
}
.menu-btn-label {
font-size: 15px;
font-size: 17px;
font-weight: 700;
}
.menu-btn-desc {
......@@ -174,19 +176,23 @@ export function mountTable(el) {
}
.menu-features {
display: flex;
gap: 8px;
margin-top: 6px;
gap: 10px;
margin-top: 12px;
flex-wrap: wrap;
}
.feature-chip {
padding: 6px 12px;
border-radius: 8px;
padding: 10px 16px;
border-radius: 10px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.06);
color: #94a3b8;
font-size: 11px;
border: 1px solid rgba(255,255,255,0.08);
color: #e2e8f0;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
transition: background 0.15s, transform 0.1s;
min-height: 40px;
display: flex;
align-items: center;
}
.feature-chip:active { background: rgba(255,255,255,0.1); }
</style>
......
import * as scene from '../../core/scene.js';
import { mountLeaderboard } from './scenes/leaderboard.js';
import { mountTournaments } from './scenes/tournaments.js';
import { mountTournamentDetail } from './scenes/tournament-detail.js';
scene.register('leaderboard', mountLeaderboard);
scene.register('tournaments', mountTournaments);
scene.register('tournament-detail', mountTournamentDetail);
This diff is collapsed.
......@@ -78,7 +78,7 @@ function renderTournaments(el, tournaments) {
list.querySelectorAll('.tournament-card').forEach(card => {
card.addEventListener('click', () => {
audio.play('click');
showTournamentDetail(el, card.dataset.id, tournaments.find(t => t.id === card.dataset.id));
scene.push('tournament-detail', { tournamentId: card.dataset.id });
});
});
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment