Commit fcff8eb1 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: complete domino game — multiplayer, bot AI, Elo, lobby

Full mobile domino implementation with all 6 phases:
- Canvas board with snake-path layout, endpoint glow, ghost preview
- HTML fan hand with drag-and-snap + tap-to-play controls
- 3-level bot AI (beginner/intermediate/expert) with server records
- Live multiplayer via polling (match-live.js integration)
- Emote system, resign sync, disconnect handling
- Multi-round scoring (first to 100), round overlays
- Server API (domino-match.php) with Elo, coins, XP rewards
- Bot difficulty picker, friend challenge flow, lobby integration
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 7372dc29
# Multiplayer Rules — EL3AB Platform
This document captures every hard-won lesson from building Chess and Ludo multiplayer. Follow these rules exactly when adding any new multiplayer game.
---
## Architecture Overview
```
┌──────────────┐ POST (2s poll) ┌──────────────┐
│ Player A │ ───────────────────────▶ │ game.php │
│ (Browser) │ ◀─────────────────────── │ (PHP API) │
└──────────────┘ └──────┬───────┘
│ supabaseService()
┌──────────────┐ POST (2s poll) ┌──────▼───────┐
│ Player B │ ───────────────────────▶ │ Supabase │
│ (Browser) │ ◀─────────────────────── │ (PostgreSQL) │
└──────────────┘ └──────────────┘
```
- **No WebSocket** — all multiplayer is polling-based (every 2 seconds)
- **Server is source of truth** — clients are renderers, not authorities
- **Service key for ALL writes** — user tokens are blocked by RLS on the `matches` table
---
## Critical Rule #1: ALWAYS Use `supabaseService()`
**NEVER use `$db` (user token) for match operations.** Supabase RLS blocks user-token UPDATEs on the `matches` table. Every INSERT/UPDATE/DELETE on matches MUST go through:
```php
$sdb = supabaseService();
$sdb->update('matches', [...], ['id' => 'eq.' . $matchId]);
```
If you use `$db->update(...)` it will return success but the row won't change. This is a silent failure that looks like sync is broken.
---
## Critical Rule #2: Poll Data Must ALWAYS Be Forwarded
In `match-live.js`, the `onMove` callback must fire on EVERY poll response, not just when `move_count` changes. Many game events don't increment move_count:
- Resign
- Draw offer / accept / decline
- Emotes
- Game state changes (player joined, etc.)
```js
// WRONG — skips resign/draw/emotes
if (moveCount > lastMoveCount) {
onMove?.(data);
}
// CORRECT — always forward, track moveCount separately
if (moveCount > lastMoveCount) {
lastMoveCount = moveCount;
}
onMove?.(data); // always
```
---
## Critical Rule #3: game_state is MERGED, Not Replaced
The `game_state` JSONB column is a shared scratchpad. Multiple systems write to it:
- Moves (last_move info)
- Draw offers (draw_offer, draw_offer_t)
- Emotes (emote key, from, timestamp)
- Pings (ping, t)
- Invites metadata
The server MERGES incoming game_state with existing:
```php
$existing = json_decode($match['game_state'], true) ?: [];
$merged = array_merge($existing, $newState);
$update['game_state'] = json_encode($merged);
```
**Never** overwrite game_state entirely. Always send only the keys you're changing.
---
## The `matches` Table Schema
| Column | Type | Purpose |
|--------|------|---------|
| id | uuid | Primary key |
| game_key | text | 'chess', 'ludo', 'domino', etc. |
| match_type | text | 'ranked', 'friendly', 'bot', 'tournament' |
| white_player_id | uuid | Player 1 (or host) |
| black_player_id | uuid | Player 2 (or guest) |
| status | text | 'waiting', 'in_progress', 'completed' |
| result | text | 'white_wins', 'black_wins', 'draw', 'aborted', 'declined' |
| time_control | text | 'bullet_1_0', 'blitz_3_0', 'blitz_5_0', 'rapid_10_0' |
| initial_time_ms | int | Starting time per player in ms |
| white_time_remaining_ms | int | Current clock for white |
| black_time_remaining_ms | int | Current clock for black |
| current_fen | text | Current board state (chess FEN, or JSON for other games) |
| moves | jsonb | Array of move objects |
| move_count | int | Total moves played (critical for sync detection) |
| game_state | jsonb | Shared scratchpad (emotes, draw offers, pings, metadata) |
| is_rated | bool | Whether this affects Elo |
| tournament_id | uuid | If part of a tournament |
| created_at | timestamptz | When match was created |
| started_at | timestamptz | When first move was made |
| ended_at | timestamptz | When match completed |
| updated_at | timestamptz | Last modification (used for abandon detection) |
---
## Match Lifecycle
```
waiting ──────▶ in_progress ──────▶ completed
│ ▲
│ (declined) │
└─────────────────▶ completed ───────┘
```
### Status Transitions
| From | To | Trigger |
|------|----|---------|
| waiting | in_progress | Opponent accepts invite |
| waiting | completed (declined) | Opponent declines or timeout |
| in_progress | completed (white/black_wins) | Checkmate, resign, timeout, abandon |
| in_progress | completed (draw) | Agreement, stalemate, repetition, 50-move |
| in_progress | completed (aborted) | Both players inactive 30s+ |
---
## API Actions (game.php)
| Action | Method | Purpose |
|--------|--------|---------|
| start | POST | Create a new match |
| get | POST | Fetch match state (used by polling) |
| move | POST | Submit a move or update game_state |
| resign | POST | Player surrenders |
| draw | POST | Accept a draw (marks match completed) |
| complete | POST | End game with result + Elo calculation |
### Adding a New Game's API
Create `api/{game}-match.php` following the same pattern. Must have:
- `start` — create match row
- `get` — return full match (polling reads this)
- `move` — update match state (merge game_state)
- `resign` — mark completed with winner
- `complete` — final result + stats/Elo
---
## Frontend Architecture
### Files That Handle Multiplayer
| File | Role |
|------|------|
| `core/match-session.js` | Polling loop (2s), ping (10s), disconnect detection |
| `core/match-live.js` | Glue between session and UI — forwards data, manages connection state |
| `core/match-ui.js` | Connection status toasts (reconnecting, opponent disconnected) |
| `core/multiplayer.js` | Opponent bar, emotes, friend add from game, report |
### Polling Flow (Every 2 Seconds)
```
match-session.js polls ──▶ game.php action:get
Returns full match row
match-live.js onOpponentMove(data)
game's handleLivePollData(el, data)
┌───────────┼───────────────┐
▼ ▼ ▼
Check moves Check status Check game_state
(new FEN?) (completed?) (draw offer? emote?)
```
### What the Game's Poll Handler Must Check
Every game must process ALL of these in its poll handler:
```js
function handleLivePollData(el, data) {
// 1. Emotes (from game_state)
checkForEmote(data.game_state, myId);
// 2. Draw offer (from game_state)
checkDrawOffer(el, data.game_state, myId);
checkDrawResponse(el, data.game_state, myId);
// 3. New move (move_count changed + FEN different)
if (!isMyTurn && data.move_count > lastKnownMoveCount) {
// Apply opponent's move, animate, play sound, switch clock
}
// 4. Game ended (status changed to completed)
if (data.status === 'completed' && !gameOver) {
// Determine if win/loss/draw from data.result
endGame(result, reason);
}
}
```
---
## Draw Offer Flow
```
Player A Server Player B
│ │ │
│── move(game_state: │ │
│ {draw_offer: A, t: now})────▶│ │
│ │◀── poll ─────────────────────│
│ │── returns game_state ────────▶│
│ │ │── shows dialog
│ │ │
│ │◀── draw(match_id) ───────────│ (accepts)
│ │ sets status:completed │
│ │ result:draw │
│── poll ───────────────────────▶│ │
│◀── status:completed,draw ─────│ │
│── shows draw result │ │
```
### Draw Offer Rules
- `draw_offer` in game_state = who offered (their user ID)
- `draw_offer_t` = timestamp (prevents re-showing same offer)
- Acceptance calls `action: 'draw'` which atomically marks match completed
- Rejection writes `draw_offer: null, draw_declined: userId, draw_declined_t: now`
- Auto-dismiss offer dialog after 30 seconds
---
## Resign Flow
```
Player A Server Player B
│ │ │
│── resign(match_id) ──────────▶│ │
│ │ sets status:completed │
│ │ result:black/white_wins │
│ │ │
│ │◀── poll ─────────────────────│
│ │── status:completed ──────────▶│
│ │ │── shows opponent won
```
### Resign Rules
- Server determines winner from who resigned (if white resigns → black_wins)
- Must validate match isn't already completed (prevent double-resign race)
- Client shows loss immediately after calling resign (don't wait for poll)
- Opponent detects via `data.status === 'completed'` in poll
---
## Clock Sync
- Clocks run CLIENT-SIDE for responsiveness
- Server stores `white_time_remaining_ms` and `black_time_remaining_ms`
- On every MOVE, client sends updated clock values
- On every POLL, client syncs from server values (server is authority)
- Clock reaching 0 → client calls `action: 'complete'` with timeout result
```js
// After receiving opponent's move:
if (data.white_time_remaining_ms) clock.white = data.white_time_remaining_ms;
if (data.black_time_remaining_ms) clock.black = data.black_time_remaining_ms;
clock.switch(); // Start my clock, stop opponent's
```
---
## Connection & Disconnect Handling
### Timeouts (match-session.js)
| Constant | Value | Meaning |
|----------|-------|---------|
| POLL_INTERVAL | 2000ms | Poll server every 2s |
| PING_INTERVAL | 10000ms | Send ping every 10s |
| DISCONNECT_THRESHOLD | 30000ms | Show "opponent disconnected" after 30s no activity |
| ABANDON_THRESHOLD | 60000ms | Auto-win after 60s no activity |
### Tab Visibility
- When tab goes to background → stop polling (save bandwidth)
- When tab returns → immediately poll once, then resume normal interval
- Reset disconnect timer on tab return (don't false-trigger)
### Auto-Abandon Detection (Server-Side in handleGet)
```php
if (match is in_progress && updated_at > 30s ago) {
mark as completed, result: aborted
}
```
---
## Friend Invite → Lobby → Game Flow
```
Challenge Scene ──▶ friends.php invite ──▶ Lobby Scene (host waits)
│ polls game.php get (status?)
Friend's Banner ──▶ friends.php accept-invite ──▶ Lobby Scene (guest)
│ status changes to in_progress
Game Scene (both players)
```
### Invite Rules
- Creates match with `status: 'waiting'`, `match_type: 'friendly'`
- `game_state` contains `invite_from`, `invite_to`, `invite_t`
- Invites expire after 2 minutes (server filters by invite_t)
- Acceptance changes status to `in_progress` + sets `started_at`
- Decline changes status to `completed`, result to `declined`
- Lobby polls every 2s until status changes from 'waiting'
---
## Emote System
### Sending
```js
net.post('game.php', {
action: 'move',
match_id: matchId,
game_state: JSON.stringify({ emote: { key: 'laugh', from: userId, t: Date.now() } })
});
```
### Receiving
- Poll reads `game_state.emote`
- Only show if `emote.from !== myId` (don't show own emotes)
- Only show if `emote.t > lastEmoteTimestamp` (don't re-show)
- Only show if `Date.now() - emote.t < 15000` (expire after 15s)
---
## UI/UX Layout Rules (Mobile-First, RTL)
### Screen Layout Structure (Portrait 390×844)
```
┌─────────────────────────────────┐
│ Opponent Bar (name, avatar, clock) │ ← compact, 48px max
├─────────────────────────────────┤
│ │
│ GAME BOARD │ ← flex:1, fills available space
│ (canvas, max 500px wide) │
│ │
├─────────────────────────────────┤
│ Player Bar (name, avatar, clock) │ ← compact, 48px max
├─────────────────────────────────┤
│ Info Row (opening, material, etc) │ ← optional, 1 line
├─────────────────────────────────┤
│ Move History (horizontal scroll) │ ← max 40px height
├─────────────────────────────────┤
│ Controls (resign, draw, etc) │ ← min-height:48px, thumb reach
│ padding-bottom: safe-area-inset │
└─────────────────────────────────┘
```
### Key Layout Rules
1. **NO ads in game view** — removed permanently, causes layout issues on small phones
2. **justify-content: center or flex-end** — dead space at TOP, never between board and controls
3. **Board sizes to container**`Math.min(containerWidth - padding, containerHeight - padding, 500)`
4. **Board container MUST have closing tag** — unclosed divs cause flex:1 to leak
5. **Use `env(safe-area-inset-bottom)` not `var(--safe-bottom)`** for notch phones
6. **Controls must have `min-height: 48px`** — thumb-friendly touch targets
7. **Clocks: monospace font, min-width: 64px, text-align: center**
### Opponent Bar (Multiplayer)
```js
import * as mp from '../../../core/multiplayer.js';
mp.renderOpponentBar(container, opponentData, { showRating: true });
```
- Shows avatar, name, rating, connection dot (green/yellow/red)
- Tap opens action menu: View Profile, Add Friend, Report
- Positioned at top of game layout
---
## Adding a New Multiplayer Game — Checklist
### Backend
- [ ] Create `api/{game}-match.php` with actions: start, get, move, resign, draw, complete
- [ ] ALL database operations use `supabaseService()` — never user token
- [ ] `move` action merges game_state, doesn't overwrite
- [ ] `resign` validates match status isn't already completed
- [ ] `draw` atomically sets status + result
- [ ] `complete` calculates Elo + awards coins + records stats
### Frontend — Game Scene
- [ ] Import and use `matchLive.start(matchId, gameType, { onMove, onGameEnd })`
- [ ] Implement `handleLivePollData(el, data)` that checks:
- [ ] Emotes (game_state.emote)
- [ ] Draw offer (game_state.draw_offer)
- [ ] Draw response (game_state.draw_accepted / draw_declined)
- [ ] New moves (move_count changed)
- [ ] Game end (status === 'completed')
- [ ] Send moves via `net.post(endpoint, { action: 'move', match_id, ...moveData })`
- [ ] Resign via `net.post(endpoint, { action: 'resign', match_id })`
- [ ] Draw offer via `net.post(endpoint, { action: 'move', match_id, game_state: JSON.stringify({draw_offer, draw_offer_t}) })`
- [ ] Draw accept via `net.post(endpoint, { action: 'draw', match_id })`
- [ ] Clock sync on every received move
- [ ] Call `endGame()` locally immediately after resign (don't wait for poll)
- [ ] Use `mp.renderOpponentBar()` for opponent info
### Frontend — Module Registration
- [ ] Register game scene in `modules/{game}/mod.js`
- [ ] Game receives params: `{ mode, matchId, color, timeControl, isFriendly, tournamentId }`
- [ ] Call `scene.enterGameMode()` on mount
- [ ] Clean up intervals/timers on unmount
### Frontend — Lobby Integration
- [ ] Friend invite navigates to `game-lobby` scene (not directly to game)
- [ ] Lobby polls match status until opponent accepts
- [ ] On status change to `in_progress`, navigate to game scene
- [ ] Guest (acceptor) can start immediately (they triggered the status change)
---
## Common Bugs & Their Causes
| Symptom | Cause | Fix |
|---------|-------|-----|
| Resign not visible to opponent | RLS blocking write OR poll only fires on moveCount change | Use supabaseService() + always forward poll data |
| Draw offer never shows | game_state write failed silently (RLS) | Use supabaseService() for handleGameMove |
| Clock desync | Client not syncing from server on received moves | Always update clock from `data.*_time_remaining_ms` |
| Board not rendering | Container div not closed → flex layout broken | Check HTML template has matching open/close divs |
| Opponent never appears in lobby | Invite creates match but acceptor goes to different matchId | Both sides must use the SAME match_id from the invite |
| Game state keeps resetting | game_state overwritten instead of merged | Always merge: `array_merge($existing, $new)` |
| Move plays twice | moveCount check missing on move processing | Only apply move when `data.move_count > lastKnownMoveCount` |
| "Match not found" on resign | Using getOne() which throws on empty result | Use get() with limit:1 and check array |
| Emote shows to sender | Missing `emote.from !== myId` check | Always filter out own emotes |
| Draw dialog reappears | Missing timestamp dedup (`lastDrawOfferHandled`) | Track last handled timestamp, skip if ≤ |
---
## Testing Multiplayer
### Manual Test Flow (Two Browser Tabs)
1. Tab A: Login as User 1, start a game (or send invite)
2. Tab B: Login as User 2, join the game (or accept invite)
3. Verify: both see the board, correct colors assigned
4. Tab A: Make a move → verify Tab B sees it within 3s
5. Tab B: Make a move → verify Tab A sees it within 3s
6. Test resign: Tab A resigns → verify Tab B shows "you won"
7. Test draw: Tab A offers draw → verify Tab B sees dialog → accept → both see draw
8. Test disconnect: close Tab B → verify Tab A shows "opponent disconnected" after 30s
### Edge Cases to Test
- Refresh mid-game → should reconnect and load current state
- Both players resign simultaneously → only first one should succeed
- Draw offer then resign before response → resign takes priority
- Network drop during move → move should retry or show error
- Tab background for 2+ minutes → should resync on return
---
## File Locations Reference
```
api/game.php — Chess match API
api/ludo-match.php — Ludo match API
api/friends.php — Invite system (create/accept/decline)
api/chat.php — Friend messaging
public/js/core/match-session.js — Polling engine (DO NOT MODIFY LIGHTLY)
public/js/core/match-live.js — Data forwarding layer
public/js/core/match-ui.js — Connection status toasts
public/js/core/multiplayer.js — Opponent bar, emotes, friend add
public/js/modules/chess/scenes/game.js — Chess game (reference implementation)
public/js/modules/ludo/scenes/game.js — Ludo game
public/js/modules/play/scenes/lobby.js — Pre-game lobby
public/js/modules/play/scenes/challenge.js — Friend challenge picker
```
---
## Golden Rule
**If it works in single-player but breaks in multiplayer, the problem is almost certainly one of:**
1. RLS blocking the write (use supabaseService)
2. Poll data not being forwarded (match-live.js filtering)
3. game_state being overwritten instead of merged
4. Missing dedup (timestamp checks on offers/emotes)
Check these four things first. They account for 90% of multiplayer bugs on this platform.
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
$token = requireAuth();
$userId = getUserId($token);
$input = getInput();
$action = $input['action'] ?? '';
switch ($action) {
case 'start': handleStart($userId, $input); break;
case 'queue': handleQueue($userId, $input); break;
case 'status': handleStatus($userId); break;
case 'move': handleMove($userId, $input); break;
case 'get': handleGet($userId, $input); break;
case 'resign': handleResign($userId, $input); break;
case 'complete': handleComplete($userId, $input); break;
default: jsonError('Invalid action');
}
function handleStart(string $userId, array $input): void {
$sdb = supabaseService();
$mode = $input['mode'] ?? 'bot';
$botLevel = $input['bot_level'] ?? 'intermediate';
$players = [$userId, 'bot_' . $botLevel];
$matchData = [
'status' => 'in_progress',
'players' => json_encode($players),
'current_turn' => 0,
'board' => '[]',
'hands' => '{}',
'boneyard' => '[]',
'moves' => '[]',
'scores' => json_encode(['0' => 0, '1' => 0]),
'game_state' => json_encode(['move_count' => 0, 'round' => 1, 'mode' => $mode, 'bot_level' => $botLevel]),
'host_id' => $userId,
'created_by' => $userId
];
$match = $sdb->insert('domino_matches', $matchData);
$id = $match[0]['id'] ?? $match['id'] ?? null;
if ($id) {
jsonResponse(['id' => $id, 'status' => 'in_progress']);
} else {
jsonError('Failed to create match');
}
}
function handleQueue(string $userId, array $input): void {
$sdb = supabaseService();
$sdb->delete('domino_queue', ['user_id' => 'eq.' . $userId]);
$searchUrl = SUPABASE_REST . '/domino_queue'
. '?user_id=neq.' . $userId
. '&match_id=is.null'
. '&select=id,user_id'
. '&limit=1';
$ch = curl_init($searchUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'apikey: ' . SUPABASE_SERVICE_KEY,
'Authorization: Bearer ' . SUPABASE_SERVICE_KEY,
'Content-Type: application/json'
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$result = curl_exec($ch);
curl_close($ch);
$opponents = json_decode($result, true);
if (!empty($opponents) && isset($opponents[0])) {
$opponent = $opponents[0];
$players = [$opponent['user_id'], $userId];
$matchData = [
'status' => 'in_progress',
'players' => json_encode($players),
'current_turn' => 0,
'board' => '[]',
'hands' => '{}',
'boneyard' => '[]',
'moves' => '[]',
'scores' => json_encode(['0' => 0, '1' => 0]),
'game_state' => json_encode(['move_count' => 0, 'round' => 1, 'mode' => 'live']),
'host_id' => $opponent['user_id']
];
$match = $sdb->insert('domino_matches', $matchData);
$matchId = $match[0]['id'] ?? $match['id'] ?? null;
if ($matchId) {
$sdb->update('domino_queue', ['match_id' => $matchId], ['id' => 'eq.' . $opponent['id']]);
jsonResponse([
'match_id' => $matchId,
'player_index' => 1,
'players' => $players,
'color' => 'player2'
]);
}
}
$sdb->insert('domino_queue', ['user_id' => $userId]);
jsonResponse(['queued' => true]);
}
function handleStatus(string $userId): void {
$sdb = supabaseService();
$entry = $sdb->get('domino_queue', [
'user_id' => 'eq.' . $userId,
'select' => 'id,match_id',
'limit' => 1
]);
if (!empty($entry) && !isset($entry['error']) && !empty($entry[0]['match_id'])) {
$matchId = $entry[0]['match_id'];
$sdb->delete('domino_queue', ['user_id' => 'eq.' . $userId]);
$matches = $sdb->get('domino_matches', ['id' => 'eq.' . $matchId, 'select' => 'players', 'limit' => 1]);
$players = [];
if (!empty($matches) && !isset($matches['error'])) {
$players = json_decode($matches[0]['players'] ?? '[]', true);
}
$playerIndex = array_search($userId, $players);
jsonResponse([
'match_id' => $matchId,
'player_index' => $playerIndex !== false ? $playerIndex : 0,
'players' => $players
]);
}
jsonResponse(['waiting' => true]);
}
function handleMove(string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required');
$sdb = supabaseService();
$update = [];
if (isset($input['board'])) {
$board = $input['board'];
$update['board'] = is_string($board) ? $board : json_encode($board);
}
if (isset($input['hands'])) {
$hands = $input['hands'];
$update['hands'] = is_string($hands) ? $hands : json_encode($hands);
}
if (isset($input['boneyard'])) {
$boneyard = $input['boneyard'];
$update['boneyard'] = is_string($boneyard) ? $boneyard : json_encode($boneyard);
}
if (isset($input['current_turn'])) {
$update['current_turn'] = intval($input['current_turn']);
}
if (isset($input['scores'])) {
$update['scores'] = is_string($input['scores']) ? $input['scores'] : json_encode($input['scores']);
}
if (isset($input['game_state'])) {
$existing = $sdb->get('domino_matches', ['id' => 'eq.' . $matchId, 'select' => 'game_state', 'limit' => 1]);
$existingGs = [];
if (!empty($existing) && !isset($existing['error']) && !empty($existing[0]['game_state'])) {
$existingGs = json_decode($existing[0]['game_state'], true) ?: [];
}
$newGs = is_string($input['game_state']) ? json_decode($input['game_state'], true) : $input['game_state'];
$merged = array_merge($existingGs, $newGs ?: []);
$update['game_state'] = json_encode($merged);
}
if (isset($input['status'])) {
$update['status'] = $input['status'];
}
if (isset($input['winner_id'])) {
$update['winner_id'] = $input['winner_id'];
}
if (isset($input['result'])) {
$update['result'] = $input['result'];
}
if (!empty($update)) {
$update['updated_at'] = date('c');
$sdb->update('domino_matches', $update, ['id' => 'eq.' . $matchId]);
}
jsonResponse(['success' => true]);
}
function handleGet(string $userId, array $input): void {
$matchId = $input['match_id'] ?? ($_GET['match_id'] ?? '');
if (!$matchId) jsonError('match_id required');
$sdb = supabaseService();
$matches = $sdb->get('domino_matches', ['id' => 'eq.' . $matchId, 'select' => '*', 'limit' => 1]);
if (empty($matches) || isset($matches['error'])) jsonError('Not found', 404);
$match = $matches[0];
$players = json_decode($match['players'] ?? '[]', true);
$playerIndex = array_search($userId, $players);
$hands = json_decode($match['hands'] ?? '{}', true);
$response = $match;
$response['my_hand'] = $hands[strval($playerIndex)] ?? [];
$response['opponent_count'] = 0;
foreach ($hands as $idx => $h) {
if (intval($idx) !== $playerIndex) {
$response['opponent_count'] = count($h);
}
}
unset($response['hands']);
jsonResponse($response);
}
function handleResign(string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required');
$sdb = supabaseService();
$matches = $sdb->get('domino_matches', ['id' => 'eq.' . $matchId, 'select' => 'id,status,players', 'limit' => 1]);
if (empty($matches) || isset($matches['error'])) jsonError('Match not found', 404);
$match = $matches[0];
if ($match['status'] === 'completed') jsonError('Match already ended');
$players = json_decode($match['players'] ?? '[]', true);
$playerIndex = array_search($userId, $players);
$winnerIndex = $playerIndex === 0 ? 1 : 0;
$winnerId = $players[$winnerIndex] ?? null;
$sdb->update('domino_matches', [
'status' => 'completed',
'result' => 'player' . ($winnerIndex + 1) . '_wins',
'winner_id' => $winnerId,
'game_state' => json_encode(['resigned_by' => $userId, 'move_count' => 999]),
'updated_at' => date('c')
], ['id' => 'eq.' . $matchId]);
jsonResponse(['result' => 'resigned']);
}
function handleComplete(string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required');
$sdb = supabaseService();
$playerResult = $input['result'] ?? '';
$winnerId = $input['winner_id'] ?? null;
$scores = $input['scores'] ?? null;
$opponentRating = intval($input['opponent_rating'] ?? 1200);
$update = [
'status' => 'completed',
'result' => $playerResult,
'updated_at' => date('c')
];
if ($winnerId) $update['winner_id'] = $winnerId;
if ($scores) $update['scores'] = is_string($scores) ? $scores : json_encode($scores);
$sdb->update('domino_matches', $update, ['id' => 'eq.' . $matchId]);
$outcome = 'loss';
if ($winnerId === $userId) $outcome = 'win';
elseif ($playerResult === 'draw') $outcome = 'draw';
elseif (strpos($playerResult, 'player') !== false) {
$match = $sdb->get('domino_matches', ['id' => 'eq.' . $matchId, 'select' => 'players', 'limit' => 1]);
if (!empty($match) && !isset($match['error'])) {
$players = json_decode($match[0]['players'] ?? '[]', true);
$playerIdx = array_search($userId, $players);
$winnerIdx = intval(str_replace(['player', '_wins'], '', $playerResult)) - 1;
if ($playerIdx === $winnerIdx) $outcome = 'win';
}
}
$profiles = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'elo_domino,games_played,total_wins,total_draws,total_losses,win_streak,coins,xp,level', 'limit' => 1]);
$profile = is_array($profiles) && !empty($profiles) && !isset($profiles['error']) ? $profiles[0] : null;
$ratingChange = 0;
$coins = 10;
$xp = 15;
$newRating = 1200;
if ($profile) {
$playerRating = $profile['elo_domino'] ?? 1200;
$score = ($outcome === 'win') ? 1.0 : (($outcome === 'draw') ? 0.5 : 0.0);
$newRating = calculateElo($playerRating, $opponentRating, $score);
$ratingChange = $newRating - $playerRating;
$updates = ['elo_domino' => $newRating, 'games_played' => ($profile['games_played'] ?? 0) + 1];
if ($outcome === 'win') {
$updates['total_wins'] = ($profile['total_wins'] ?? 0) + 1;
$updates['win_streak'] = ($profile['win_streak'] ?? 0) + 1;
} elseif ($outcome === 'draw') {
$updates['total_draws'] = ($profile['total_draws'] ?? 0) + 1;
$updates['win_streak'] = 0;
} else {
$updates['total_losses'] = ($profile['total_losses'] ?? 0) + 1;
$updates['win_streak'] = 0;
}
$sdb->update('profiles', $updates, ['id' => 'eq.' . $userId]);
$sdb->insert('rating_history', [
'player_id' => $userId,
'game_key' => 'domino',
'time_control_type' => 'standard',
'rating_before' => $playerRating,
'rating_after' => $newRating,
'rating_change' => $ratingChange,
'match_id' => $matchId,
'result' => $outcome
]);
$rewardConfig = $sdb->get('reward_config', ['select' => 'key,value']);
$rewards = [];
if (is_array($rewardConfig) && !isset($rewardConfig['error'])) {
foreach ($rewardConfig as $r) $rewards[$r['key']] = intval($r['value']);
}
$coins = ($outcome === 'win') ? ($rewards['domino_win_coins'] ?? 50) : (($outcome === 'draw') ? ($rewards['domino_draw_coins'] ?? 20) : ($rewards['domino_loss_coins'] ?? 10));
$xp = ($outcome === 'win') ? ($rewards['domino_win_xp'] ?? 30) : (($outcome === 'draw') ? ($rewards['domino_draw_xp'] ?? 15) : ($rewards['domino_loss_xp'] ?? 10));
$sdb->rpc('award_coins', ['p_player_id' => $userId, 'p_amount' => $coins, 'p_reason' => 'Domino ' . $outcome]);
$currentCoins = ($profile['coins'] ?? 0) + $coins;
$sdb->insert('economy_transactions', [
'player_id' => $userId,
'type' => 'game_reward',
'currency' => 'coins',
'amount' => $coins,
'balance_after' => $currentCoins,
'reason' => 'Domino ' . $outcome
]);
}
jsonResponse([
'success' => true,
'result' => $outcome,
'rating_before' => $profile ? ($profile['elo_domino'] ?? 1200) : 1200,
'rating_after' => $newRating,
'rating_change' => $ratingChange,
'coins_earned' => $coins,
'xp_earned' => $xp
]);
}
function calculateElo(int $playerRating, int $opponentRating, float $score): int {
$k = 32;
if ($playerRating > 2400) $k = 16;
elseif ($playerRating > 2000) $k = 24;
$expected = 1.0 / (1.0 + pow(10, ($opponentRating - $playerRating) / 400.0));
$newRating = round($playerRating + $k * ($score - $expected));
return max(100, $newRating);
}
...@@ -92,7 +92,7 @@ function startPolling() { ...@@ -92,7 +92,7 @@ function startPolling() {
if (currentSession.isBackground) return; // Don't poll when tab is hidden if (currentSession.isBackground) return; // Don't poll when tab is hidden
try { try {
const endpoint = currentSession.gameType === 'ludo' ? 'ludo-match.php' : 'game.php'; const endpoint = currentSession.gameType === 'ludo' ? 'ludo-match.php' : currentSession.gameType === 'domino' ? 'domino-match.php' : 'game.php';
const data = await net.post(endpoint, { const data = await net.post(endpoint, {
action: 'get', action: 'get',
match_id: currentSession.matchId match_id: currentSession.matchId
...@@ -122,7 +122,7 @@ function startPinging() { ...@@ -122,7 +122,7 @@ function startPinging() {
currentSession.pingTimer = setInterval(async () => { currentSession.pingTimer = setInterval(async () => {
if (!currentSession || !currentSession.isActive) return; if (!currentSession || !currentSession.isActive) return;
try { try {
const endpoint = currentSession.gameType === 'ludo' ? 'ludo-match.php' : 'game.php'; const endpoint = currentSession.gameType === 'ludo' ? 'ludo-match.php' : currentSession.gameType === 'domino' ? 'domino-match.php' : 'game.php';
await net.post(endpoint, { await net.post(endpoint, {
action: 'move', action: 'move',
match_id: currentSession.matchId, match_id: currentSession.matchId,
...@@ -166,7 +166,7 @@ function setupVisibilityHandler() { ...@@ -166,7 +166,7 @@ function setupVisibilityHandler() {
// Immediately fetch latest state // Immediately fetch latest state
if (currentSession.onOpponentMove) { if (currentSession.onOpponentMove) {
const endpoint = currentSession.gameType === 'ludo' ? 'ludo-match.php' : 'game.php'; const endpoint = currentSession.gameType === 'ludo' ? 'ludo-match.php' : currentSession.gameType === 'domino' ? 'domino-match.php' : 'game.php';
net.post(endpoint, { action: 'get', match_id: currentSession.matchId }) net.post(endpoint, { action: 'get', match_id: currentSession.matchId })
.then(data => { .then(data => {
if (data && !data.error) { if (data && !data.error) {
......
import { createCanvas, clear } from '../../../core/canvas.js';
import { computeLayout, hitTestEndpoint, getSnapRadius } from '../logic/layout.js';
import { drawTile, drawEndpointGlow } from './tile-renderer.js';
import { TILE_W, TILE_H, DOUBLE_W, DOUBLE_H } from '../logic/layout.js';
export class DominoBoard {
constructor(container, options = {}) {
this.container = container;
this.onEndpointTap = options.onEndpointTap || null;
const rect = container.getBoundingClientRect();
this.width = rect.width || 370;
this.height = rect.height || 360;
const { canvas, ctx } = createCanvas(container, this.width, this.height);
canvas.style.cssText = 'width:100%;height:100%;border-radius:12px;touch-action:none;';
this.canvas = canvas;
this.ctx = ctx;
this.chain = [];
this.layout = { tiles: [], endpoints: { left: null, right: null } };
this.ghost = null;
this.activeEndpoint = null;
this.panOffset = { x: 0, y: 0 };
this.animatingTile = null;
this._setupPanListener();
}
setChain(chain) {
this.chain = chain;
this.layout = computeLayout(chain, this.width, this.height);
this.draw();
}
setGhost(tile, end, valid) {
if (!tile || !end) { this.ghost = null; this.draw(); return; }
const ep = end === 'left' ? this.layout.endpoints.left : this.layout.endpoints.right;
if (!ep) { this.ghost = null; this.draw(); return; }
const dbl = tile.left === tile.right;
const w = dbl ? DOUBLE_W : TILE_H;
const h = dbl ? DOUBLE_H : TILE_W;
this.ghost = { tile, x: ep.x, y: ep.y, w, h, rotation: 90, valid };
this.draw();
}
clearGhost() {
this.ghost = null;
this.draw();
}
setActiveEndpoint(end) {
this.activeEndpoint = end;
this.draw();
}
clearActiveEndpoint() {
this.activeEndpoint = null;
this.draw();
}
getEndpointScreenPos(end) {
const ep = end === 'left' ? this.layout.endpoints.left : this.layout.endpoints.right;
if (!ep) return null;
const rect = this.canvas.getBoundingClientRect();
const scaleX = rect.width / this.width;
const scaleY = rect.height / this.height;
return { x: rect.left + ep.x * scaleX, y: rect.top + ep.y * scaleY, value: ep.value };
}
hitTestEndpoints(screenX, screenY) {
const rect = this.canvas.getBoundingClientRect();
const canvasX = (screenX - rect.left) * (this.width / rect.width);
const canvasY = (screenY - rect.top) * (this.height / rect.height);
const leftEp = this.layout.endpoints.left;
const rightEp = this.layout.endpoints.right;
if (leftEp && hitTestEndpoint(canvasX, canvasY, leftEp)) return { end: 'left', value: leftEp.value };
if (rightEp && hitTestEndpoint(canvasX, canvasY, rightEp)) return { end: 'right', value: rightEp.value };
return null;
}
animatePlacement(tile, end, callback) {
const ep = end === 'left' ? this.layout.endpoints.left : this.layout.endpoints.right;
if (!ep) { callback?.(); return; }
const dbl = tile.left === tile.right;
const w = dbl ? DOUBLE_W : TILE_H;
const h = dbl ? DOUBLE_H : TILE_W;
const startScale = 1.3;
const duration = 180;
const startTime = performance.now();
const animate = (now) => {
const t = Math.min((now - startTime) / duration, 1);
const ease = 1 - Math.pow(1 - t, 3);
const scale = startScale + (1 - startScale) * ease;
this.animatingTile = { tile, x: ep.x, y: ep.y, w, h, rotation: 90, scale };
this.draw();
if (t < 1) {
requestAnimationFrame(animate);
} else {
this.animatingTile = null;
callback?.();
}
};
requestAnimationFrame(animate);
}
draw() {
const ctx = this.ctx;
clear(ctx, this.width, this.height);
ctx.fillStyle = '#0d3815';
ctx.fillRect(0, 0, this.width, this.height);
this._drawFelt(ctx);
if (this.chain.length === 0) {
ctx.fillStyle = 'rgba(255,255,255,0.12)';
ctx.font = '600 14px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('ضع أول قطعة', this.width / 2, this.height / 2);
this._drawEndpointIndicators(ctx);
return;
}
for (const lt of this.layout.tiles) {
drawTile(ctx, lt.x, lt.y, lt.w, lt.h, lt.tile, { rotation: lt.rotation === 90 ? 0 : 90 });
}
this._drawEndpointIndicators(ctx);
if (this.ghost) {
drawTile(ctx, this.ghost.x, this.ghost.y, this.ghost.w, this.ghost.h,
this.ghost.tile, { rotation: 0, ghost: true, invalid: !this.ghost.valid });
}
if (this.animatingTile) {
const at = this.animatingTile;
ctx.save();
ctx.translate(at.x, at.y);
ctx.scale(at.scale, at.scale);
ctx.translate(-at.x, -at.y);
drawTile(ctx, at.x, at.y, at.w, at.h, at.tile, { rotation: 0 });
ctx.restore();
}
}
_drawFelt(ctx) {
ctx.fillStyle = 'rgba(30,80,40,0.3)';
for (let i = 0; i < this.width; i += 16) {
for (let j = 0; j < this.height; j += 16) {
if ((i + j) % 32 === 0) ctx.fillRect(i, j, 8, 8);
}
}
}
_drawEndpointIndicators(ctx) {
const leftEp = this.layout.endpoints.left;
const rightEp = this.layout.endpoints.right;
if (this.chain.length === 0) {
drawEndpointGlow(ctx, this.width / 2, this.height / 2, getSnapRadius(), true);
return;
}
if (this.activeEndpoint === 'left' || this.activeEndpoint === 'both') {
if (leftEp) drawEndpointGlow(ctx, leftEp.x, leftEp.y, getSnapRadius(), true);
}
if (this.activeEndpoint === 'right' || this.activeEndpoint === 'both') {
if (rightEp) drawEndpointGlow(ctx, rightEp.x, rightEp.y, getSnapRadius(), true);
}
}
_setupPanListener() {
let startX, startY, isPanning = false;
this.canvas.addEventListener('pointerdown', (e) => {
if (this.onEndpointTap) {
const hit = this.hitTestEndpoints(e.clientX, e.clientY);
if (hit) { this.onEndpointTap(hit); return; }
}
startX = e.clientX;
startY = e.clientY;
isPanning = false;
});
this.canvas.addEventListener('pointermove', (e) => {
if (startX === undefined) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (Math.abs(dx) + Math.abs(dy) > 15) isPanning = true;
});
this.canvas.addEventListener('pointerup', () => { startX = undefined; });
}
destroy() {
this.canvas.remove();
}
}
const TILE_BG = '#FFFFF0';
const TILE_BORDER = '#444';
const PIP_COLOR = '#1a1a1a';
const DOUBLE_ACCENT = '#e8e0c8';
const PIP_POSITIONS = {
0: [],
1: [[0, 0]],
2: [[-1, -1], [1, 1]],
3: [[-1, -1], [0, 0], [1, 1]],
4: [[-1, -1], [1, -1], [-1, 1], [1, 1]],
5: [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]],
6: [[-1, -1], [1, -1], [-1, 0], [1, 0], [-1, 1], [1, 1]]
};
export function drawTile(ctx, x, y, w, h, tile, options = {}) {
const { rotation = 0, alpha = 1, glow = false, ghost = false, invalid = false, highlight = false } = options;
ctx.save();
ctx.translate(x, y);
ctx.rotate((rotation * Math.PI) / 180);
ctx.globalAlpha = ghost ? 0.4 : alpha;
const hw = w / 2;
const hh = h / 2;
const r = 4;
if (glow || highlight) {
ctx.shadowColor = invalid ? 'rgba(239,68,68,0.6)' : 'rgba(16,185,129,0.6)';
ctx.shadowBlur = 12;
}
ctx.beginPath();
ctx.roundRect(-hw, -hh, w, h, r);
ctx.fillStyle = ghost ? (invalid ? 'rgba(239,68,68,0.2)' : 'rgba(16,185,129,0.2)') : TILE_BG;
ctx.fill();
ctx.strokeStyle = ghost ? (invalid ? '#ef4444' : '#10b981') : TILE_BORDER;
ctx.lineWidth = ghost ? 2 : 1.5;
ctx.stroke();
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
if (!ghost) {
const isDouble = tile.left === tile.right;
const isHorizontal = w > h;
if (isHorizontal) {
ctx.beginPath();
ctx.moveTo(0, -hh + 3);
ctx.lineTo(0, hh - 3);
ctx.strokeStyle = '#aaa';
ctx.lineWidth = 1;
ctx.stroke();
if (isDouble) {
ctx.fillStyle = DOUBLE_ACCENT;
ctx.beginPath();
ctx.roundRect(-hw + 1, -hh + 1, w - 2, h - 2, r - 1);
ctx.fill();
ctx.strokeStyle = TILE_BORDER;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.roundRect(-hw, -hh, w, h, r);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, -hh + 3);
ctx.lineTo(0, hh - 3);
ctx.strokeStyle = '#999';
ctx.lineWidth = 1;
ctx.stroke();
}
const spread = Math.min(hw * 0.35, 7);
const pipR = Math.min(hw * 0.08, 2.8);
drawPips(ctx, -hw / 2, 0, spread, pipR, tile.left);
drawPips(ctx, hw / 2, 0, spread, pipR, tile.right);
} else {
ctx.beginPath();
ctx.moveTo(-hw + 3, 0);
ctx.lineTo(hw - 3, 0);
ctx.strokeStyle = '#aaa';
ctx.lineWidth = 1;
ctx.stroke();
if (isDouble) {
ctx.fillStyle = DOUBLE_ACCENT;
ctx.beginPath();
ctx.roundRect(-hw + 1, -hh + 1, w - 2, h - 2, r - 1);
ctx.fill();
ctx.strokeStyle = TILE_BORDER;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.roundRect(-hw, -hh, w, h, r);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(-hw + 3, 0);
ctx.lineTo(hw - 3, 0);
ctx.strokeStyle = '#999';
ctx.lineWidth = 1;
ctx.stroke();
}
const spread = Math.min(hh * 0.35, 7);
const pipR = Math.min(hh * 0.08, 2.8);
drawPips(ctx, 0, -hh / 2, spread, pipR, tile.left);
drawPips(ctx, 0, hh / 2, spread, pipR, tile.right);
}
}
ctx.restore();
}
function drawPips(ctx, cx, cy, spread, radius, value) {
const positions = PIP_POSITIONS[value] || [];
ctx.fillStyle = PIP_COLOR;
for (const [px, py] of positions) {
ctx.beginPath();
ctx.arc(cx + px * spread, cy + py * spread, radius, 0, Math.PI * 2);
ctx.fill();
}
}
export function drawEndpointGlow(ctx, x, y, radius, valid) {
const color = valid ? 'rgba(16,185,129,0.5)' : 'rgba(239,68,68,0.4)';
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
gradient.addColorStop(0, color);
gradient.addColorStop(0.6, valid ? 'rgba(16,185,129,0.15)' : 'rgba(239,68,68,0.1)');
gradient.addColorStop(1, 'transparent');
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = gradient;
ctx.fill();
ctx.beginPath();
ctx.arc(x, y, 8, 0, Math.PI * 2);
ctx.fillStyle = valid ? '#10b981' : '#ef4444';
ctx.globalAlpha = 0.7;
ctx.fill();
ctx.globalAlpha = 1;
}
import * as audio from '../../../core/audio.js';
import * as juice from '../../../core/juice.js';
import { canPlay } from '../logic/rules.js';
export class DominoDrag {
constructor(options = {}) {
this.board = options.board;
this.onPlace = options.onPlace || null;
this.onCancel = options.onCancel || null;
this.getEnds = options.getEnds || (() => ({ leftEnd: null, rightEnd: null }));
this.active = false;
this.tile = null;
this.proxyEl = null;
this.nearEnd = null;
this.isValid = false;
this._onMove = this._onMove.bind(this);
this._onUp = this._onUp.bind(this);
}
start(tile, startInfo) {
this.tile = tile;
this.active = true;
this.nearEnd = null;
this.isValid = false;
this._createProxy(tile, startInfo.x, startInfo.y);
document.addEventListener('pointermove', this._onMove);
document.addEventListener('pointerup', this._onUp);
document.addEventListener('pointercancel', this._onUp);
juice.hapticLight?.();
audio.play('click');
}
_createProxy(tile, x, y) {
const el = document.createElement('div');
el.className = 'drag-proxy';
el.innerHTML = `
<div style="width:40px;height:70px;background:#fffff0;border:2px solid #10b981;border-radius:6px;
display:flex;flex-direction:column;align-items:center;justify-content:space-between;padding:5px 4px;
box-shadow:0 8px 24px rgba(0,0,0,0.4);transform:scale(1.1);">
<div style="display:flex;flex-wrap:wrap;width:22px;height:22px;align-items:center;justify-content:center;gap:1px;">
${this._renderPips(tile.left)}
</div>
<div style="width:80%;height:1px;background:#bbb;"></div>
<div style="display:flex;flex-wrap:wrap;width:22px;height:22px;align-items:center;justify-content:center;gap:1px;">
${this._renderPips(tile.right)}
</div>
</div>
`;
el.style.cssText = `
position:fixed;z-index:9999;pointer-events:none;
left:${x - 20}px;top:${y - 35}px;
transition:transform 0.1s;
`;
document.body.appendChild(el);
this.proxyEl = el;
}
_renderPips(value) {
let s = '';
for (let i = 0; i < value; i++) {
s += '<span style="width:5px;height:5px;border-radius:50%;background:#1a1a1a;"></span>';
}
return s;
}
_onMove(e) {
if (!this.active) return;
const x = e.clientX;
const y = e.clientY;
this.proxyEl.style.left = (x - 20) + 'px';
this.proxyEl.style.top = (y - 35) + 'px';
const hit = this.board.hitTestEndpoints(x, y);
const { leftEnd, rightEnd } = this.getEnds();
if (hit) {
const endValue = hit.value;
const tileMatches = this.tile.left === endValue || this.tile.right === endValue;
if (tileMatches) {
this.nearEnd = hit.end;
this.isValid = true;
this.board.setGhost(this.tile, hit.end, true);
this.proxyEl.style.transform = 'scale(1.15)';
this.proxyEl.querySelector('div').style.borderColor = '#10b981';
} else {
this.nearEnd = hit.end;
this.isValid = false;
this.board.setGhost(this.tile, hit.end, false);
this.proxyEl.style.transform = 'scale(0.95)';
this.proxyEl.querySelector('div').style.borderColor = '#ef4444';
}
} else {
if (this.nearEnd) {
this.board.clearGhost();
this.nearEnd = null;
this.isValid = false;
this.proxyEl.style.transform = 'scale(1.1)';
this.proxyEl.querySelector('div').style.borderColor = '#10b981';
}
}
}
_onUp(e) {
if (!this.active) return;
this.active = false;
document.removeEventListener('pointermove', this._onMove);
document.removeEventListener('pointerup', this._onUp);
document.removeEventListener('pointercancel', this._onUp);
this.board.clearGhost();
if (this.nearEnd && this.isValid) {
this._animateSnap(() => {
juice.hapticMedium?.();
audio.play('place', 'game');
this.onPlace?.(this.tile, this.nearEnd);
this._cleanup();
});
} else {
this._animateBounceBack(() => {
if (this.nearEnd && !this.isValid) {
juice.hapticError?.();
}
this.onCancel?.();
this._cleanup();
});
}
}
_animateSnap(callback) {
if (!this.proxyEl) { callback(); return; }
this.proxyEl.style.transition = 'all 0.15s cubic-bezier(0.16,1,0.3,1)';
this.proxyEl.style.opacity = '0';
this.proxyEl.style.transform = 'scale(0.5)';
setTimeout(callback, 150);
}
_animateBounceBack(callback) {
if (!this.proxyEl) { callback(); return; }
this.proxyEl.style.transition = 'all 0.25s cubic-bezier(0.34,1.56,0.64,1)';
this.proxyEl.style.opacity = '0';
this.proxyEl.style.transform = 'translateY(40px) scale(0.7)';
setTimeout(callback, 250);
}
_cleanup() {
if (this.proxyEl) {
this.proxyEl.remove();
this.proxyEl = null;
}
this.tile = null;
this.nearEnd = null;
this.isValid = false;
}
getStyle() {
return `.drag-proxy { will-change: transform, left, top; }`;
}
destroy() {
this._cleanup();
document.removeEventListener('pointermove', this._onMove);
document.removeEventListener('pointerup', this._onUp);
document.removeEventListener('pointercancel', this._onUp);
}
}
import * as audio from '../../../core/audio.js';
export class DominoHand {
constructor(container, options = {}) {
this.container = container;
this.onTileSelect = options.onTileSelect || null;
this.onDragStart = options.onDragStart || null;
this.tiles = [];
this.validTileIds = new Set();
this.selectedTileId = null;
this.disabled = false;
container.style.cssText = `
display:flex;gap:2px;padding:10px 6px;overflow-x:auto;
background:linear-gradient(180deg,#0a2a0e 0%,#0d3311 100%);
border-top:1px solid rgba(255,255,255,0.08);
min-height:96px;align-items:center;justify-content:center;
flex-wrap:wrap;scroll-snap-type:x proximity;
-webkit-overflow-scrolling:touch;
`;
}
setTiles(tiles, validTileIds) {
this.tiles = tiles;
this.validTileIds = validTileIds;
this.render();
}
setSelected(tileId) {
this.selectedTileId = tileId;
this.render();
}
clearSelection() {
this.selectedTileId = null;
this.render();
}
setDisabled(disabled) {
this.disabled = disabled;
this.render();
}
render() {
const hand = this.tiles;
this.container.innerHTML = hand.map((tile, i) => {
const playable = this.validTileIds.has(tile.id);
const selected = this.selectedTileId === tile.id;
const overlap = i > 0 ? 'margin-left:-6px;' : '';
return `
<div class="dh-tile ${playable ? 'dh-playable' : ''} ${selected ? 'dh-selected' : ''}"
data-id="${tile.id}" data-idx="${i}"
style="${overlap}scroll-snap-align:center;${this.disabled ? 'pointer-events:none;' : ''}">
<div class="dh-top">${this._renderPipGrid(tile.left)}</div>
<div class="dh-divider"></div>
<div class="dh-bottom">${this._renderPipGrid(tile.right)}</div>
</div>`;
}).join('');
this._bindEvents();
}
_renderPipGrid(value) {
if (value === 0) return '';
let dots = '';
for (let i = 0; i < value; i++) {
dots += '<span class="dh-pip"></span>';
}
return dots;
}
_bindEvents() {
const tiles = this.container.querySelectorAll('.dh-tile');
tiles.forEach(el => {
let startX = 0, startY = 0, isDrag = false;
el.addEventListener('pointerdown', (e) => {
if (this.disabled) return;
startX = e.clientX;
startY = e.clientY;
isDrag = false;
el.setPointerCapture(e.pointerId);
});
el.addEventListener('pointermove', (e) => {
if (!startX && !startY) return;
const dx = Math.abs(e.clientX - startX);
const dy = Math.abs(e.clientY - startY);
if ((dx > 10 || dy > 10) && !isDrag) {
isDrag = true;
const id = el.dataset.id;
const tile = this.tiles.find(t => t.id === id);
if (tile && this.validTileIds.has(id)) {
el.releasePointerCapture(e.pointerId);
const rect = el.getBoundingClientRect();
this.onDragStart?.(tile, { x: rect.left + rect.width / 2, y: rect.top, startEvent: e });
}
}
});
el.addEventListener('pointerup', (e) => {
if (!isDrag && startX) {
const id = el.dataset.id;
const tile = this.tiles.find(t => t.id === id);
if (tile && this.validTileIds.has(id)) {
audio.play('click');
this.onTileSelect?.(tile);
}
}
startX = 0; startY = 0; isDrag = false;
});
el.addEventListener('pointercancel', () => { startX = 0; startY = 0; isDrag = false; });
});
}
getStyle() {
return `
.dh-tile {
width:40px;height:70px;
background:#fffff0;
border:2px solid #555;
border-radius:6px;
display:flex;flex-direction:column;
align-items:center;justify-content:space-between;
padding:5px 4px;
cursor:pointer;
transition:transform 0.15s cubic-bezier(0.34,1.56,0.64,1),
border-color 0.15s,opacity 0.15s,box-shadow 0.15s;
opacity:0.45;
user-select:none;
touch-action:none;
flex-shrink:0;
}
.dh-tile.dh-playable {
opacity:1;
border-color:#4ade80;
box-shadow:0 0 8px rgba(74,222,128,0.25);
}
.dh-tile.dh-playable:active { transform:scale(0.94); }
.dh-tile.dh-selected {
transform:translateY(-12px) scale(1.05);
border-color:#10b981;
box-shadow:0 6px 16px rgba(16,185,129,0.4);
}
@keyframes tileGlow {
0%,100% { box-shadow:0 0 6px rgba(74,222,128,0.2); }
50% { box-shadow:0 0 14px rgba(74,222,128,0.5); }
}
.dh-tile.dh-playable { animation:tileGlow 2s ease-in-out infinite; }
.dh-tile.dh-selected { animation:none; }
.dh-divider { width:80%;height:1px;background:#bbb;flex-shrink:0; }
.dh-top, .dh-bottom {
display:flex;flex-wrap:wrap;
width:22px;height:22px;
align-items:center;justify-content:center;
gap:1px;
}
.dh-pip {
width:5px;height:5px;
border-radius:50%;
background:#1a1a1a;
}
`;
}
destroy() {
this.container.innerHTML = '';
}
}
import { getValidMoves, isDouble, countPips, getEnds } from './rules.js';
const PERSONALITIES = {
beginner: { thinkMin: 1200, thinkMax: 2500, strategy: 'random', emoteChance: 0.1 },
intermediate: { thinkMin: 800, thinkMax: 1800, strategy: 'pip_priority', emoteChance: 0.15 },
expert: { thinkMin: 500, thinkMax: 1200, strategy: 'strategic', emoteChance: 0.08 }
};
export function getPersonality(level = 'intermediate') {
return PERSONALITIES[level] || PERSONALITIES.intermediate;
}
export function getThinkDelay(personality) {
const { thinkMin, thinkMax } = personality;
return thinkMin + Math.random() * (thinkMax - thinkMin);
}
export function pickMove(hand, leftEnd, rightEnd, chain, opponentCount, personality) {
const moves = getValidMoves(hand, leftEnd, rightEnd);
if (moves.length === 0) return null;
if (moves.length === 1) return moves[0];
switch (personality.strategy) {
case 'random':
return moves[Math.floor(Math.random() * moves.length)];
case 'pip_priority':
return pickByPipPriority(moves);
case 'strategic':
return pickStrategic(moves, hand, leftEnd, rightEnd, chain, opponentCount);
default:
return moves[0];
}
}
function pickByPipPriority(moves) {
moves.sort((a, b) => {
const aVal = a.tile.left + a.tile.right;
const bVal = b.tile.left + b.tile.right;
return bVal - aVal;
});
return moves[0];
}
function pickStrategic(moves, hand, leftEnd, rightEnd, chain, opponentCount) {
const scored = moves.map(move => {
let score = 0;
if (isDouble(move.tile)) score += 4;
score += (move.tile.left + move.tile.right) * 0.6;
const remaining = hand.filter(t => t.id !== move.tile.id);
const simResult = simulateEnds(chain, move);
const futureCount = countFuturePlays(remaining, simResult.leftEnd, simResult.rightEnd);
score += futureCount * 2.5;
if (opponentCount <= 2) {
const valueCounts = countValueInHand(remaining);
const endValues = [simResult.leftEnd, simResult.rightEnd];
for (const v of endValues) {
if (v !== null && (valueCounts[v] || 0) >= 2) {
score += 1;
}
}
}
return { move, score };
});
scored.sort((a, b) => b.score - a.score);
return scored[0].move;
}
function simulateEnds(chain, move) {
const { tile, end } = move;
if (chain.length === 0) {
return { leftEnd: tile.left, rightEnd: tile.right };
}
const currentEnds = getEnds(chain);
let { leftEnd, rightEnd } = currentEnds;
if (end === 'left') {
if (tile.right === leftEnd) leftEnd = tile.left;
else if (tile.left === leftEnd) leftEnd = tile.right;
} else {
if (tile.left === rightEnd) rightEnd = tile.right;
else if (tile.right === rightEnd) rightEnd = tile.left;
}
return { leftEnd, rightEnd };
}
function countFuturePlays(hand, leftEnd, rightEnd) {
let count = 0;
for (const t of hand) {
if (t.left === leftEnd || t.right === leftEnd ||
t.left === rightEnd || t.right === rightEnd) {
count++;
}
}
return count;
}
function countValueInHand(hand) {
const counts = {};
for (const t of hand) {
counts[t.left] = (counts[t.left] || 0) + 1;
counts[t.right] = (counts[t.right] || 0) + 1;
}
return counts;
}
export function shouldEmote(personality) {
return Math.random() < personality.emoteChance;
}
export function getRandomEmote() {
const emotes = ['laugh', 'think', 'wow', 'angry', 'gg'];
return emotes[Math.floor(Math.random() * emotes.length)];
}
const TILE_W = 26;
const TILE_H = 50;
const DOUBLE_W = 26;
const DOUBLE_H = 26;
const GAP = 3;
const MARGIN = 16;
const DIRECTIONS = [
{ dx: 1, dy: 0 },
{ dx: 0, dy: 1 },
{ dx: -1, dy: 0 },
{ dx: 0, dy: -1 }
];
export function computeLayout(chain, canvasW, canvasH) {
if (chain.length === 0) return { tiles: [], endpoints: { left: null, right: null } };
const tiles = [];
let dirIdx = 0;
let cx = canvasW / 2;
let cy = canvasH / 2;
for (let i = 0; i < chain.length; i++) {
const tile = chain[i];
const dbl = tile.left === tile.right;
const dir = DIRECTIONS[dirIdx];
const w = dbl ? DOUBLE_W : (dir.dx !== 0 ? TILE_H : TILE_W);
const h = dbl ? DOUBLE_H : (dir.dx !== 0 ? TILE_W : TILE_H);
const rotation = dbl ? (dir.dx !== 0 ? 0 : 90) : (dir.dx !== 0 ? 90 : 0);
tiles.push({ tile, x: cx, y: cy, w, h, rotation, isDouble: dbl });
const stepW = dbl ? DOUBLE_W : (dir.dx !== 0 ? TILE_H : TILE_W);
const stepH = dbl ? DOUBLE_H : (dir.dy !== 0 ? TILE_H : TILE_W);
const advance = (dir.dx !== 0 ? stepW : stepH) + GAP;
let nextX = cx + dir.dx * advance;
let nextY = cy + dir.dy * advance;
if (i < chain.length - 1 && wouldExceedBounds(nextX, nextY, chain[i + 1], dirIdx, canvasW, canvasH)) {
dirIdx = (dirIdx + 1) % 4;
const newDir = DIRECTIONS[dirIdx];
const offsetDist = (dir.dx !== 0 ? TILE_W : TILE_H) + GAP * 2;
cx += newDir.dx * offsetDist;
cy += newDir.dy * offsetDist;
} else {
cx = nextX;
cy = nextY;
}
}
const bounds = getBounds(tiles);
const offsetX = (canvasW - (bounds.maxX + bounds.minX)) / 2 - bounds.minX;
const offsetY = (canvasH - (bounds.maxY + bounds.minY)) / 2 - bounds.minY;
tiles.forEach(t => { t.x += offsetX; t.y += offsetY; });
const endpoints = {
left: tiles.length > 0 ? getEndpointPos(tiles[0], 'left', chain) : null,
right: tiles.length > 0 ? getEndpointPos(tiles[tiles.length - 1], 'right', chain) : null
};
return { tiles, endpoints };
}
function wouldExceedBounds(nx, ny, nextTile, dirIdx, cw, ch) {
const dir = DIRECTIONS[dirIdx];
const dbl = nextTile.left === nextTile.right;
const halfW = (dbl ? DOUBLE_W : (dir.dx !== 0 ? TILE_H : TILE_W)) / 2;
const halfH = (dbl ? DOUBLE_H : (dir.dy !== 0 ? TILE_H : TILE_W)) / 2;
return (nx - halfW < MARGIN || nx + halfW > cw - MARGIN ||
ny - halfH < MARGIN || ny + halfH > ch - MARGIN);
}
function getBounds(tiles) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const t of tiles) {
minX = Math.min(minX, t.x - t.w / 2);
minY = Math.min(minY, t.y - t.h / 2);
maxX = Math.max(maxX, t.x + t.w / 2);
maxY = Math.max(maxY, t.y + t.h / 2);
}
return { minX, minY, maxX, maxY };
}
function getEndpointPos(layoutTile, side, chain) {
const dir = side === 'left' ? -1 : 1;
const t = layoutTile;
const rot = t.rotation;
let ex, ey;
if (rot === 90) {
ex = t.x + dir * (t.w / 2 + 14);
ey = t.y;
} else {
ex = t.x;
ey = t.y + dir * (t.h / 2 + 14);
}
const endValue = side === 'left' ? chain[0].left : chain[chain.length - 1].right;
return { x: ex, y: ey, value: endValue };
}
export function getSnapRadius() { return 55; }
export function hitTestEndpoint(px, py, endpoint) {
if (!endpoint) return false;
const dx = px - endpoint.x;
const dy = py - endpoint.y;
return Math.sqrt(dx * dx + dy * dy) < getSnapRadius();
}
export { TILE_W, TILE_H, DOUBLE_W, DOUBLE_H, GAP };
...@@ -108,3 +108,29 @@ export function getRoundWinner(hands) { ...@@ -108,3 +108,29 @@ export function getRoundWinner(hands) {
}); });
return { winner, scores: hands.map(countPips) }; return { winner, scores: hands.map(countPips) };
} }
export function scoreRound(hands, winnerIdx) {
let total = 0;
hands.forEach((hand, i) => {
if (i !== winnerIdx) total += countPips(hand);
});
return total;
}
export function hasValidMove(hand, leftEnd, rightEnd) {
if (leftEnd === null) return hand.length > 0;
return hand.some(t =>
t.left === leftEnd || t.right === leftEnd ||
t.left === rightEnd || t.right === rightEnd
);
}
export function determinePlayEnd(tile, leftEnd, rightEnd) {
if (leftEnd === null) return 'right';
const playsLeft = tile.left === leftEnd || tile.right === leftEnd;
const playsRight = tile.left === rightEnd || tile.right === rightEnd;
if (playsLeft && playsRight) return 'both';
if (playsLeft) return 'left';
if (playsRight) return 'right';
return null;
}
import * as scene from '../../core/scene.js'; import * as scene from '../../core/scene.js';
import { mountGame } from './scenes/game.js'; import { mountGame, unmountGame } from './scenes/game.js';
import { mountResult } from './scenes/result.js'; import { mountResult } from './scenes/result.js';
import { mountRoom } from './scenes/room.js'; import { mountRoom, unmountRoom } from './scenes/room.js';
scene.register('domino-game', mountGame); scene.register('domino-game', mountGame, unmountGame);
scene.register('domino-result', mountResult); scene.register('domino-result', mountResult);
scene.register('domino-room', mountRoom); scene.register('domino-room', mountRoom, unmountRoom);
import * as bus from '../../../core/bus.js'; import * as bus from '../../../core/bus.js';
import * as scene from '../../../core/scene.js'; import * as scene from '../../../core/scene.js';
import * as audio from '../../../core/audio.js'; import * as audio from '../../../core/audio.js';
import * as juice from '../../../core/juice.js';
import * as net from '../../../core/net.js';
import * as store from '../../../core/store.js'; import * as store from '../../../core/store.js';
import * as matchLive from '../../../core/match-live.js';
import * as mp from '../../../core/multiplayer.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { createCanvas, clear } from '../../../core/canvas.js'; import { emoji } from '../../../core/theme.js';
import * as rules from '../logic/rules.js'; import * as rules from '../logic/rules.js';
import * as bot from '../logic/bot.js';
import { DominoBoard } from '../canvas/board.js';
import { DominoHand } from '../components/hand.js';
import { DominoDrag } from '../components/drag.js';
let state, tableCtx, tableCanvas, tableW, tableH; let state, board, hand, drag, liveSession;
let botTimeout = null;
const TABLE_COLOR = '#1B5E20';
const TABLE_FELT = '#2E7D32';
const TILE_BG = '#FFFFF0';
const TILE_BORDER = '#333';
const PIP_COLOR = '#111';
export function mountGame(el, params) { export function mountGame(el, params) {
const { mode = 'bot', numPlayers = 2 } = params; const { mode = 'bot', matchId, playerIndex = 0, players, botLevel = 'intermediate' } = params;
scene.enterGameMode(); scene.enterGameMode();
const allTiles = rules.shuffle(rules.createDominoSet()); state = createInitialState(mode, matchId, playerIndex, botLevel, players);
const { hands, boneyard } = rules.dealHands(allTiles, numPlayers); dealNewRound();
el.innerHTML = buildLayout(mode);
injectStyles(el);
const boardContainer = el.querySelector('#domino-board');
board = new DominoBoard(boardContainer, {
onEndpointTap: (hit) => handleEndpointTap(el, hit)
});
board.setChain(state.chain);
const handContainer = el.querySelector('#domino-hand-area');
hand = new DominoHand(handContainer, {
onTileSelect: (tile) => handleTileSelect(el, tile),
onDragStart: (tile, info) => handleDragStart(el, tile, info)
});
drag = new DominoDrag({
board,
onPlace: (tile, end) => executePlacement(el, tile, end),
onCancel: () => { hand.clearSelection(); state.selectedTile = null; },
getEnds: () => ({ leftEnd: state.leftEnd, rightEnd: state.rightEnd })
});
updateUI(el);
refreshHand();
el.querySelector('#btn-draw').addEventListener('click', () => drawFromBoneyard(el));
el.querySelector('#btn-pass').addEventListener('click', () => passTurn(el));
el.querySelector('#btn-resign')?.addEventListener('click', () => confirmResign(el));
el.querySelector('#btn-emote')?.addEventListener('click', () => showEmoteMenu(el));
if (mode === 'bot') {
createServerRecord();
if (state.currentPlayer !== state.myPlayerIndex) {
scheduleBotTurn(el);
}
} else if (mode === 'live' && matchId) {
setupLiveMultiplayer(el, matchId);
}
state = { bus.emit('game:started', { gameKey: 'domino', mode, matchId });
hands, boneyard, chain: [], }
function createInitialState(mode, matchId, playerIndex, botLevel, players) {
return {
mode, matchId, myPlayerIndex: playerIndex,
players: players || [],
hands: [[], []], boneyard: [], chain: [],
leftEnd: null, rightEnd: null, leftEnd: null, rightEnd: null,
currentPlayer: 0, numPlayers, mode, currentPlayer: 0, numPlayers: 2,
scores: new Array(numPlayers).fill(0), matchScores: [0, 0], roundNumber: 1, targetScore: 100,
gameOver: false, selectedTile: null selectedTile: null, gameOver: false, matchOver: false,
moveCount: 0, lastSyncMoveCount: 0,
botPersonality: bot.getPersonality(botLevel),
botThinking: false,
lastEmoteHandled: 0,
lastDrawOfferHandled: 0,
}; };
}
function dealNewRound() {
const allTiles = rules.shuffle(rules.createDominoSet());
const { hands, boneyard } = rules.dealHands(allTiles, state.numPlayers);
state.hands = hands;
state.boneyard = boneyard;
state.chain = [];
state.leftEnd = null;
state.rightEnd = null;
state.gameOver = false;
state.selectedTile = null;
}
el.innerHTML = ` function buildLayout(mode) {
<div style="display:flex;flex-direction:column;height:100%;background:#0d3311;"> const isLive = mode === 'live';
<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:#0a2a0e;border-bottom:1px solid rgba(255,255,255,0.1);"> return `
<span id="domino-turn" style="font-size:13px;font-weight:600;color:#4CAF50;">${t('game.your_turn')}</span> <div id="domino-wrap" style="display:flex;flex-direction:column;height:100%;background:#081a0c;position:relative;">
<span style="font-size:14px;font-weight:700;color:#fff;">${t('game.domino')}</span> <!-- Opponent bar -->
<div id="domino-opp-bar" style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:#061408;border-bottom:1px solid rgba(255,255,255,0.06);">
<div style="display:flex;align-items:center;gap:8px;">
<div id="opp-avatar" style="width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,#10b981,#06b6d4);display:flex;align-items:center;justify-content:center;font-size:14px;">${isLive ? '👤' : '🤖'}</div>
<div>
<div id="opp-name" style="font-size:13px;font-weight:700;color:#f0fdf4;">${isLive ? 'خصم' : 'بوت'}</div>
<div id="opp-count" style="font-size:11px;color:#86efac;">7 قطع</div>
</div>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<div id="bot-thinking" style="font-size:11px;color:#fbbf24;display:none;">يفكر...</div>
${isLive ? '<div id="conn-dot" style="width:8px;height:8px;border-radius:50%;background:#4ade80;"></div>' : ''}
<div id="boneyard-count" style="font-size:11px;color:#6ee7b7;background:rgba(16,185,129,0.1);padding:4px 10px;border-radius:8px;">المخزن: 14</div>
</div>
</div> </div>
<div style="display:flex;justify-content:space-between;padding:4px 12px;background:#0a2a0e;">
<span style="font-size:11px;color:#81C784;">الخصم: <span id="opp-count">${hands[1]?.length || 0}</span> قطع</span> <!-- Emote display area -->
<span style="font-size:11px;color:#81C784;">المخزن: <span id="bone-count">${boneyard.length}</span></span> <div id="emote-display" style="position:absolute;top:56px;left:50%;transform:translateX(-50%);z-index:50;pointer-events:none;"></div>
<!-- Canvas board -->
<div id="domino-board" style="flex:1;min-height:0;padding:6px;"></div>
<!-- Score bar -->
<div id="score-bar" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:6px 12px;background:#061408;">
<span style="font-size:12px;color:#86efac;">أنت: <b id="my-score">0</b></span>
<span style="font-size:11px;color:#475569;">|</span>
<span style="font-size:12px;color:#fca5a5;">خصم: <b id="opp-score">0</b></span>
<span style="font-size:11px;color:#475569;">|</span>
<span style="font-size:11px;color:#fbbf24;">${emoji('target', '🎯', 12)} 100</span>
<span style="font-size:11px;color:#475569;">|</span>
<span style="font-size:11px;color:#94a3b8;">ج<span id="round-num">1</span></span>
</div> </div>
<div id="domino-table" style="flex:1;display:flex;align-items:center;justify-content:center;padding:8px;overflow:hidden;"></div>
<div id="domino-hand" style="display:flex;gap:6px;padding:10px 8px;overflow-x:auto;background:#0a2a0e;border-top:1px solid rgba(255,255,255,0.1);min-height:90px;align-items:center;justify-content:center;flex-wrap:wrap;"></div> <!-- Status -->
<div style="display:flex;gap:8px;padding:8px 12px;background:#0a2a0e;"> <div id="turn-status" style="text-align:center;padding:4px;font-size:13px;font-weight:600;color:#4ade80;">دورك!</div>
<button class="btn btn-secondary" id="domino-draw" style="flex:1;font-size:13px;background:#1B5E20;border-color:#388E3C;color:#fff;">سحب</button>
<button class="btn btn-secondary" id="domino-pass" style="flex:1;font-size:13px;background:#1B5E20;border-color:#388E3C;color:#fff;">تمرير</button> <!-- Hand area -->
<div id="domino-hand-area"></div>
<!-- Controls -->
<div id="domino-controls" style="display:flex;gap:6px;padding:8px 12px;padding-bottom:calc(8px + env(safe-area-inset-bottom,0px));background:#061408;border-top:1px solid rgba(255,255,255,0.06);">
<button class="btn btn-secondary" id="btn-resign" style="flex:0.6;font-size:11px;min-height:44px;background:rgba(239,68,68,0.1);border-color:rgba(239,68,68,0.3);color:#fca5a5;border-radius:12px;">استسلام</button>
<button class="btn btn-secondary" id="btn-emote" style="flex:0.5;font-size:16px;min-height:44px;background:rgba(255,255,255,0.05);border-color:rgba(255,255,255,0.1);border-radius:12px;">😄</button>
<button class="btn btn-secondary" id="btn-draw" style="flex:1;font-size:13px;min-height:44px;background:rgba(16,185,129,0.15);border-color:rgba(16,185,129,0.3);color:#6ee7b7;border-radius:12px;font-weight:700;">سحب</button>
<button class="btn btn-secondary" id="btn-pass" style="flex:0.7;font-size:12px;min-height:44px;background:rgba(251,191,36,0.1);border-color:rgba(251,191,36,0.3);color:#fde68a;border-radius:12px;display:none;">تمرير</button>
</div> </div>
</div> </div>
`; `;
}
function injectStyles(el) {
const style = document.createElement('style');
style.textContent = (hand?.getStyle() || '') + (drag?.getStyle() || '') + `
@keyframes fadeIn { from{opacity:0} to{opacity:1} }
@keyframes emoteFloat { 0%{transform:translateX(-50%) scale(0);opacity:0} 20%{transform:translateX(-50%) scale(1.2);opacity:1} 80%{opacity:1} 100%{transform:translateX(-50%) translateY(-20px);opacity:0} }
.emote-bubble { animation: emoteFloat 2s ease forwards; position:absolute;left:50%;font-size:36px; }
`;
el.appendChild(style);
}
// Setup table canvas // ═══════════════════════════════════════
const tableContainer = el.querySelector('#domino-table'); // LIVE MULTIPLAYER
tableW = tableContainer.clientWidth || 360; // ═══════════════════════════════════════
tableH = tableContainer.clientHeight || 300;
const { canvas, ctx } = createCanvas(tableContainer, tableW, tableH);
canvas.style.cssText = 'width:100%;height:100%;border-radius:8px;';
tableCtx = ctx;
tableCanvas = canvas;
renderTable(); function setupLiveMultiplayer(el, matchId) {
renderHand(el); liveSession = matchLive.start(matchId, 'domino', {
onMove: (data) => handleLivePollData(el, data),
onGameEnd: (data) => {
if (!state.gameOver && !state.matchOver) {
endMatch(el, 'loss', 'abandon');
}
}
});
el.querySelector('#domino-draw').addEventListener('click', () => drawTile(el)); fetchOpponentProfile(el);
el.querySelector('#domino-pass').addEventListener('click', () => passTurn(el)); mp.startDisconnectWatch?.(matchId, 'domino', 60000);
bus.emit('game:started', { gameKey: 'domino', mode }); fetchInitialState(el, matchId);
} }
function renderTable() { async function fetchOpponentProfile(el) {
const ctx = tableCtx; if (!state.matchId) return;
clear(ctx, tableW, tableH); try {
const data = await net.post('domino-match.php', { action: 'get', match_id: state.matchId });
if (data?.players) {
const myId = store.get('auth.userId');
const players = typeof data.players === 'string' ? JSON.parse(data.players) : data.players;
const oppId = players.find(p => p !== myId);
if (oppId) {
try {
const profile = await net.post('profile.php', { action: 'get', user_id: oppId });
if (profile?.display_name) {
const nameEl = el.querySelector('#opp-name');
if (nameEl) nameEl.textContent = profile.display_name;
}
} catch (e) {}
}
}
} catch (e) {}
}
// Felt background async function fetchInitialState(el, matchId) {
ctx.fillStyle = TABLE_COLOR; try {
ctx.fillRect(0, 0, tableW, tableH); const data = await net.post('domino-match.php', { action: 'get', match_id: matchId });
// Subtle texture if (data && data.board) {
ctx.fillStyle = TABLE_FELT; const board_data = typeof data.board === 'string' ? JSON.parse(data.board) : data.board;
for (let i = 0; i < tableW; i += 20) { if (board_data.length > 0) {
for (let j = 0; j < tableH; j += 20) { state.chain = board_data;
if ((i + j) % 40 === 0) ctx.fillRect(i, j, 10, 10); const ends = rules.getEnds(state.chain);
state.leftEnd = ends.leftEnd;
state.rightEnd = ends.rightEnd;
}
} }
} if (data?.my_hand && data.my_hand.length > 0) {
state.hands[state.myPlayerIndex] = data.my_hand;
}
if (data?.game_state) {
const gs = typeof data.game_state === 'string' ? JSON.parse(data.game_state) : data.game_state;
if (gs.scores) state.matchScores = [gs.scores['0'] || 0, gs.scores['1'] || 0];
if (gs.round) state.roundNumber = gs.round;
if (gs.move_count) { state.moveCount = gs.move_count; state.lastSyncMoveCount = gs.move_count; }
}
if (data?.current_turn !== undefined) state.currentPlayer = data.current_turn;
if (data?.opponent_count !== undefined) {
const oppIdx = 1 - state.myPlayerIndex;
state.hands[oppIdx] = new Array(data.opponent_count).fill({ left: 0, right: 0, id: 'hidden' });
}
const boneyard = data?.boneyard ? (typeof data.boneyard === 'string' ? JSON.parse(data.boneyard) : data.boneyard) : [];
state.boneyard = boneyard;
// Draw chain board.setChain(state.chain);
if (state.chain.length === 0) { updateUI(el);
ctx.fillStyle = 'rgba(255,255,255,0.15)'; refreshHand();
ctx.font = '14px sans-serif'; } catch (e) {}
ctx.textAlign = 'center'; }
ctx.fillText('ابدأ اللعب', tableW / 2, tableH / 2);
function handleLivePollData(el, data) {
if (!data || data.error || state.matchOver) return;
mp.updateConnectionStatus?.(true);
const connDot = el.querySelector('#conn-dot');
if (connDot) connDot.style.background = '#4ade80';
const gs = data.game_state ? (typeof data.game_state === 'string' ? JSON.parse(data.game_state) : data.game_state) : {};
const myId = store.get('auth.userId');
checkEmote(el, gs, myId);
if (data.status === 'completed' && !state.matchOver) {
const resigned = gs.resigned_by;
if (resigned && resigned !== myId) {
endMatch(el, 'win', 'resign');
} else if (resigned === myId) {
// Already handled locally
} else {
const winnerId = data.winner_id;
const result = winnerId === myId ? 'win' : 'loss';
endMatch(el, result, 'completed');
}
return; return;
} }
const tileW = 24; const remoteMoveCount = gs.move_count || 0;
const tileH = 44; if (remoteMoveCount > state.lastSyncMoveCount) {
const gap = 2; state.lastSyncMoveCount = remoteMoveCount;
const totalWidth = state.chain.length * (tileW + gap); state.moveCount = remoteMoveCount;
let startX = Math.max(4, (tableW - totalWidth) / 2);
const cy = tableH / 2; const lastMove = gs.last_move;
if (lastMove && lastMove.player !== state.myPlayerIndex) {
state.chain.forEach((tile, i) => { applyOpponentMove(el, data, gs, lastMove);
const x = startX + i * (tileW + gap); }
const y = cy - tileH / 2; }
drawTileOnTable(ctx, x, y, tileW, tileH, tile);
}); if (data.current_turn !== undefined) {
state.currentPlayer = data.current_turn;
}
if (data.opponent_count !== undefined) {
const oppIdx = 1 - state.myPlayerIndex;
const oppEl = el.querySelector('#opp-count');
if (oppEl) oppEl.textContent = `${data.opponent_count} قطع`;
}
if (gs.scores) {
state.matchScores = [gs.scores['0'] || 0, gs.scores['1'] || 0];
}
if (gs.round && gs.round > state.roundNumber) {
state.roundNumber = gs.round;
handleRemoteNewRound(el, data, gs);
}
updateUI(el);
refreshHand();
} }
function drawTileOnTable(ctx, x, y, w, h, tile) { function applyOpponentMove(el, data, gs, lastMove) {
// Tile background const boardData = data.board ? (typeof data.board === 'string' ? JSON.parse(data.board) : data.board) : state.chain;
ctx.fillStyle = TILE_BG; state.chain = boardData;
ctx.strokeStyle = TILE_BORDER;
ctx.lineWidth = 1; if (gs.left_end !== undefined) state.leftEnd = gs.left_end;
ctx.beginPath(); if (gs.right_end !== undefined) state.rightEnd = gs.right_end;
ctx.roundRect(x, y, w, h, 3);
ctx.fill(); const boneyard = data.boneyard ? (typeof data.boneyard === 'string' ? JSON.parse(data.boneyard) : data.boneyard) : state.boneyard;
ctx.stroke(); state.boneyard = boneyard;
// Divider line board.animatePlacement({ left: 0, right: 0, id: lastMove.tile_id || 'opp' }, lastMove.end || 'right', () => {
ctx.beginPath(); board.setChain(state.chain);
ctx.moveTo(x + 2, y + h / 2);
ctx.lineTo(x + w - 2, y + h / 2);
ctx.strokeStyle = '#999';
ctx.lineWidth = 1;
ctx.stroke();
// Draw pips
drawPips(ctx, x + w / 2, y + h / 4, w * 0.7, tile.left);
drawPips(ctx, x + w / 2, y + h * 3 / 4, w * 0.7, tile.right);
}
function drawPips(ctx, cx, cy, size, value) {
const r = size * 0.1;
ctx.fillStyle = PIP_COLOR;
const positions = getPipPositions(value, size * 0.3);
positions.forEach(([dx, dy]) => {
ctx.beginPath();
ctx.arc(cx + dx, cy + dy, r, 0, Math.PI * 2);
ctx.fill();
}); });
}
function getPipPositions(value, spread) { audio.play('place', 'game');
switch (value) {
case 0: return []; if (data.my_hand) {
case 1: return [[0, 0]]; state.hands[state.myPlayerIndex] = data.my_hand;
case 2: return [[-spread, -spread], [spread, spread]];
case 3: return [[-spread, -spread], [0, 0], [spread, spread]];
case 4: return [[-spread, -spread], [spread, -spread], [-spread, spread], [spread, spread]];
case 5: return [[-spread, -spread], [spread, -spread], [0, 0], [-spread, spread], [spread, spread]];
case 6: return [[-spread, -spread], [spread, -spread], [-spread, 0], [spread, 0], [-spread, spread], [spread, spread]];
default: return [];
} }
} }
function renderHand(el) { function handleRemoteNewRound(el, data, gs) {
const handEl = el.querySelector('#domino-hand'); state.gameOver = false;
const hand = state.hands[0]; if (data.my_hand) state.hands[state.myPlayerIndex] = data.my_hand;
const validMoves = state.chain.length === 0
? hand.map(t => ({ tile: t }))
: rules.getValidMoves(hand, state.leftEnd, state.rightEnd);
const validIds = new Set(validMoves.map(m => m.tile.id));
handEl.innerHTML = hand.map(tile => {
const playable = validIds.has(tile.id);
const selected = state.selectedTile?.id === tile.id;
return `
<div class="domino-tile ${playable ? 'playable' : ''} ${selected ? 'selected' : ''}"
data-id="${tile.id}"
style="width:36px;height:64px;background:${TILE_BG};border:2px solid ${selected ? '#4CAF50' : playable ? '#81C784' : '#555'};border-radius:4px;display:flex;flex-direction:column;align-items:center;justify-content:space-around;padding:3px;cursor:${playable ? 'pointer' : 'default'};opacity:${playable ? '1' : '0.5'};transition:transform 0.15s;${selected ? 'transform:translateY(-6px);box-shadow:0 4px 12px rgba(76,175,80,0.4);' : ''}">
<div style="display:flex;flex-wrap:wrap;width:20px;height:20px;align-items:center;justify-content:center;">
${renderPipDots(tile.left)}
</div>
<div style="width:80%;height:1px;background:#999;"></div>
<div style="display:flex;flex-wrap:wrap;width:20px;height:20px;align-items:center;justify-content:center;">
${renderPipDots(tile.right)}
</div>
</div>`;
}).join('');
handEl.querySelectorAll('.domino-tile.playable').forEach(tileEl => { const boardData = data.board ? (typeof data.board === 'string' ? JSON.parse(data.board) : data.board) : [];
tileEl.addEventListener('click', () => { state.chain = boardData;
audio.play('click'); const ends = rules.getEnds(state.chain);
const id = tileEl.dataset.id; state.leftEnd = ends.leftEnd;
const tile = hand.find(t => t.id === id); state.rightEnd = ends.rightEnd;
if (!tile) return;
if (state.selectedTile?.id === id) { const boneyard = data.boneyard ? (typeof data.boneyard === 'string' ? JSON.parse(data.boneyard) : data.boneyard) : [];
playTile(el, tile); state.boneyard = boneyard;
} else {
state.selectedTile = tile; board.setChain(state.chain);
renderHand(el); }
}
async function syncMoveToServer(tile, end) {
if (state.mode !== 'live' || !state.matchId) return;
try {
await net.post('domino-match.php', {
action: 'move',
match_id: state.matchId,
board: state.chain,
boneyard: state.boneyard,
hands: JSON.stringify({ [state.myPlayerIndex]: state.hands[state.myPlayerIndex] }),
current_turn: state.currentPlayer,
game_state: JSON.stringify({
move_count: state.moveCount,
last_move: { player: state.myPlayerIndex, tile_id: tile.id, end, t: Date.now() },
left_end: state.leftEnd,
right_end: state.rightEnd,
scores: { '0': state.matchScores[0], '1': state.matchScores[1] },
round: state.roundNumber,
})
});
} catch (e) {}
}
async function syncDrawToServer() {
if (state.mode !== 'live' || !state.matchId) return;
try {
await net.post('domino-match.php', {
action: 'move',
match_id: state.matchId,
boneyard: state.boneyard,
hands: JSON.stringify({ [state.myPlayerIndex]: state.hands[state.myPlayerIndex] }),
current_turn: state.currentPlayer,
game_state: JSON.stringify({
move_count: state.moveCount,
left_end: state.leftEnd,
right_end: state.rightEnd,
})
});
} catch (e) {}
}
async function syncPassToServer() {
if (state.mode !== 'live' || !state.matchId) return;
try {
await net.post('domino-match.php', {
action: 'move',
match_id: state.matchId,
current_turn: state.currentPlayer,
game_state: JSON.stringify({
move_count: ++state.moveCount,
last_move: { player: state.myPlayerIndex, action: 'pass', t: Date.now() },
left_end: state.leftEnd,
right_end: state.rightEnd,
})
});
} catch (e) {}
}
async function syncRoundEndToServer(winnerIdx, roundPoints) {
if (state.mode !== 'live' || !state.matchId) return;
try {
await net.post('domino-match.php', {
action: 'move',
match_id: state.matchId,
board: state.chain,
current_turn: state.currentPlayer,
game_state: JSON.stringify({
move_count: state.moveCount,
scores: { '0': state.matchScores[0], '1': state.matchScores[1] },
round: state.roundNumber,
round_ended: true,
round_winner: winnerIdx,
round_points: roundPoints,
})
});
} catch (e) {}
}
// ═══════════════════════════════════════
// EMOTES
// ═══════════════════════════════════════
function checkEmote(el, gs, myId) {
if (!gs.emote) return;
const emote = gs.emote;
if (emote.from === myId) return;
if (emote.t <= state.lastEmoteHandled) return;
if (Date.now() - emote.t > 15000) return;
state.lastEmoteHandled = emote.t;
showEmoteBubble(el, emote.key);
}
function showEmoteBubble(el, emoteKey) {
const emotes = { laugh: '😂', think: '🤔', wow: '😮', angry: '😡', gg: '👏', love: '❤️', fire: '🔥', cry: '😢' };
const display = el.querySelector('#emote-display');
if (!display) return;
const bubble = document.createElement('div');
bubble.className = 'emote-bubble';
bubble.textContent = emotes[emoteKey] || '😄';
display.appendChild(bubble);
audio.play('notification');
setTimeout(() => bubble.remove(), 2000);
}
function showEmoteMenu(el) {
const emotes = [
{ key: 'laugh', icon: '😂' }, { key: 'think', icon: '🤔' },
{ key: 'wow', icon: '😮' }, { key: 'angry', icon: '😡' },
{ key: 'gg', icon: '👏' }, { key: 'love', icon: '❤️' },
{ key: 'fire', icon: '🔥' }, { key: 'cry', icon: '😢' }
];
const existing = el.querySelector('#emote-menu');
if (existing) { existing.remove(); return; }
const menu = document.createElement('div');
menu.id = 'emote-menu';
menu.style.cssText = `
position:absolute;bottom:110px;left:50%;transform:translateX(-50%);
display:flex;gap:6px;padding:10px 14px;background:#1a1a2e;
border-radius:16px;border:1px solid rgba(255,255,255,0.1);
box-shadow:0 8px 24px rgba(0,0,0,0.5);z-index:60;
animation:fadeIn 0.2s ease;
`;
menu.innerHTML = emotes.map(e => `
<button data-emote="${e.key}" style="font-size:24px;background:none;border:none;cursor:pointer;padding:4px;border-radius:8px;transition:transform 0.1s;">
${e.icon}
</button>
`).join('');
const wrap = el.querySelector('#domino-wrap');
wrap.appendChild(menu);
menu.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', () => {
const key = btn.dataset.emote;
sendEmote(key);
showEmoteBubble(el, key);
menu.remove();
}); });
}); });
setTimeout(() => menu.remove(), 5000);
}
async function sendEmote(key) {
if (!state.matchId) return;
const myId = store.get('auth.userId');
try {
await net.post('domino-match.php', {
action: 'move',
match_id: state.matchId,
game_state: JSON.stringify({ emote: { key, from: myId, t: Date.now() } })
});
} catch (e) {}
} }
function renderPipDots(value) { // ═══════════════════════════════════════
if (value === 0) return ''; // INTERACTION HANDLERS
let dots = ''; // ═══════════════════════════════════════
for (let i = 0; i < value; i++) {
dots += '<div style="width:4px;height:4px;border-radius:50%;background:#111;margin:1px;"></div>'; function handleTileSelect(el, tile) {
if (state.currentPlayer !== state.myPlayerIndex || state.gameOver) return;
const playEnd = rules.determinePlayEnd(tile, state.leftEnd, state.rightEnd);
if (!playEnd) return;
if (state.selectedTile?.id === tile.id) {
if (playEnd === 'both') {
board.setActiveEndpoint('both');
return;
}
executePlacement(el, tile, playEnd === 'left' ? 'left' : 'right');
return;
}
state.selectedTile = tile;
hand.setSelected(tile.id);
if (playEnd === 'both') {
board.setActiveEndpoint('both');
} else {
executePlacement(el, tile, playEnd);
} }
return dots;
} }
function playTile(el, tile) { function handleEndpointTap(el, hit) {
if (state.currentPlayer !== 0 || state.gameOver) return; if (!state.selectedTile) return;
const tile = state.selectedTile;
const canPlayOnEnd = tile.left === hit.value || tile.right === hit.value;
if (canPlayOnEnd) {
executePlacement(el, tile, hit.end);
}
}
const validMoves = rules.getValidMoves([tile], state.leftEnd, state.rightEnd); function handleDragStart(el, tile, info) {
if (validMoves.length === 0) return; if (state.currentPlayer !== state.myPlayerIndex || state.gameOver) return;
state.selectedTile = tile;
hand.setSelected(tile.id);
drag.start(tile, info);
}
// ═══════════════════════════════════════
// GAME LOGIC
// ═══════════════════════════════════════
function executePlacement(el, tile, end) {
if (state.gameOver) return;
const end = validMoves[0].end; const actualEnd = state.chain.length === 0 ? 'right' : end;
const result = rules.placeTile(state.chain, tile, end === 'first' ? 'right' : end); const result = rules.placeTile(state.chain, tile, actualEnd);
state.chain = result.chain; state.chain = result.chain;
state.leftEnd = result.leftEnd; state.leftEnd = result.leftEnd;
state.rightEnd = result.rightEnd; state.rightEnd = result.rightEnd;
state.hands[0] = state.hands[0].filter(t => t.id !== tile.id); state.hands[state.myPlayerIndex] = state.hands[state.myPlayerIndex].filter(t => t.id !== tile.id);
state.selectedTile = null; state.selectedTile = null;
state.moveCount++;
board.clearActiveEndpoint();
board.animatePlacement(tile, actualEnd, () => {
board.setChain(state.chain);
});
audio.play('place', 'game'); audio.play('place', 'game');
juice.hapticMedium?.();
if (state.hands[0].length === 0 || rules.checkRoundEnd(state.hands, state.boneyard, state.chain)) { syncMoveToServer(tile, actualEnd);
endRound(el); return;
}
if (checkRoundEnd(el)) return;
nextTurn(el); nextTurn(el);
} }
function drawTile(el) { function drawFromBoneyard(el) {
if (state.currentPlayer !== 0 || state.boneyard.length === 0) return; if (state.currentPlayer !== state.myPlayerIndex) return;
if (state.boneyard.length === 0) return;
if (state.gameOver) return;
if (rules.hasValidMove(state.hands[state.myPlayerIndex], state.leftEnd, state.rightEnd)) return;
const tile = state.boneyard.pop(); const tile = state.boneyard.pop();
state.hands[0].push(tile); state.hands[state.myPlayerIndex].push(tile);
audio.play('click'); audio.play('click');
el.querySelector('#bone-count').textContent = state.boneyard.length; juice.hapticLight?.();
renderHand(el);
syncDrawToServer();
updateUI(el);
refreshHand();
} }
function passTurn(el) { function passTurn(el) {
if (state.currentPlayer !== 0) return; if (state.currentPlayer !== state.myPlayerIndex) return;
if (state.boneyard.length > 0) return;
if (rules.hasValidMove(state.hands[state.myPlayerIndex], state.leftEnd, state.rightEnd)) return;
syncPassToServer();
nextTurn(el); nextTurn(el);
} }
function nextTurn(el) { function nextTurn(el) {
state.currentPlayer = (state.currentPlayer + 1) % state.numPlayers; state.currentPlayer = (state.currentPlayer + 1) % state.numPlayers;
const turnEl = el.querySelector('#domino-turn'); updateUI(el);
refreshHand();
if (state.mode === 'bot' && state.currentPlayer !== state.myPlayerIndex) {
scheduleBotTurn(el);
}
}
function checkRoundEnd(el) {
if (state.hands[state.currentPlayer]?.length === 0) {
endRound(el, state.currentPlayer);
return true;
}
if (rules.checkRoundEnd(state.hands, state.boneyard, state.chain)) {
const { winner } = rules.getRoundWinner(state.hands);
endRound(el, winner);
return true;
}
return false;
}
function endRound(el, winnerIdx) {
state.gameOver = true;
const roundPoints = rules.scoreRound(state.hands, winnerIdx);
state.matchScores[winnerIdx] += roundPoints;
const isMyWin = winnerIdx === state.myPlayerIndex;
audio.play(isMyWin ? 'win' : 'lose', 'game');
if (isMyWin) {
juice.hapticSuccess?.();
const boardEl = el.querySelector('#domino-board');
if (boardEl) {
const rect = boardEl.getBoundingClientRect();
juice.confetti?.(rect.left + rect.width / 2, rect.top + rect.height / 2, 20);
}
}
syncRoundEndToServer(winnerIdx, roundPoints);
updateUI(el);
if (state.currentPlayer === 0) { if (state.matchScores[0] >= state.targetScore || state.matchScores[1] >= state.targetScore) {
turnEl.textContent = t('game.your_turn'); const matchWinner = state.matchScores[0] >= state.targetScore ? 0 : 1;
turnEl.style.color = '#4CAF50'; const result = matchWinner === state.myPlayerIndex ? 'win' : 'loss';
renderHand(el); endMatch(el, result, 'score');
} else { } else {
turnEl.textContent = t('game.opponent_turn'); showRoundOverlay(el, winnerIdx, roundPoints);
turnEl.style.color = '#81C784';
setTimeout(() => botTurn(el), 800);
} }
renderTable();
} }
function botTurn(el) { function showRoundOverlay(el, winnerIdx, points) {
if (state.gameOver) return; const isMyWin = winnerIdx === state.myPlayerIndex;
const hand = state.hands[state.currentPlayer]; const overlay = document.createElement('div');
const validMoves = rules.getValidMoves(hand, state.leftEnd, state.rightEnd); overlay.id = 'round-overlay';
overlay.style.cssText = `
position:absolute;inset:0;z-index:100;display:flex;flex-direction:column;
align-items:center;justify-content:center;gap:12px;
background:rgba(0,0,0,0.85);animation:fadeIn 0.3s ease;
`;
overlay.innerHTML = `
<div style="font-size:48px;">${isMyWin ? emoji('trophy', '🏆', 48) : emoji('skull', '💀', 48)}</div>
<div style="font-size:20px;font-weight:800;color:${isMyWin ? '#4ade80' : '#fca5a5'};">
${isMyWin ? 'فزت بالجولة!' : 'خسرت الجولة'}
</div>
<div style="font-size:14px;color:#94a3b8;">+${points} نقطة</div>
<div style="display:flex;gap:16px;margin-top:8px;">
<div style="text-align:center;">
<div style="font-size:24px;font-weight:800;color:#4ade80;">${state.matchScores[state.myPlayerIndex]}</div>
<div style="font-size:11px;color:#6ee7b7;">أنت</div>
</div>
<div style="font-size:20px;color:#475569;align-self:center;">—</div>
<div style="text-align:center;">
<div style="font-size:24px;font-weight:800;color:#fca5a5;">${state.matchScores[1 - state.myPlayerIndex]}</div>
<div style="font-size:11px;color:#fca5a5;">خصم</div>
</div>
</div>
<div style="font-size:12px;color:#64748b;margin-top:8px;">الجولة التالية تبدأ...</div>
`;
const wrap = el.querySelector('#domino-wrap');
wrap.appendChild(overlay);
setTimeout(() => {
overlay.remove();
startNewRound(el);
}, 2500);
}
function startNewRound(el) {
state.roundNumber++;
dealNewRound();
state.currentPlayer = state.matchScores[0] <= state.matchScores[1] ? 0 : 1;
if (state.mode === 'live') {
syncNewRoundToServer();
}
board.setChain(state.chain);
updateUI(el);
refreshHand();
if (state.mode === 'bot' && state.currentPlayer !== state.myPlayerIndex) {
scheduleBotTurn(el);
}
}
async function syncNewRoundToServer() {
if (!state.matchId) return;
try {
await net.post('domino-match.php', {
action: 'move',
match_id: state.matchId,
board: state.chain,
boneyard: state.boneyard,
hands: JSON.stringify({ [state.myPlayerIndex]: state.hands[state.myPlayerIndex] }),
current_turn: state.currentPlayer,
game_state: JSON.stringify({
move_count: state.moveCount,
scores: { '0': state.matchScores[0], '1': state.matchScores[1] },
round: state.roundNumber,
left_end: state.leftEnd,
right_end: state.rightEnd,
})
});
} catch (e) {}
}
function endMatch(el, result, reason) {
if (state.matchOver) return;
state.matchOver = true;
state.gameOver = true;
liveSession?.cleanup?.();
mp.stopDisconnectWatch?.();
if (result === 'win') {
audio.play('win', 'reward');
juice.hapticSuccess?.();
const boardEl = el.querySelector('#domino-board');
if (boardEl) {
const rect = boardEl.getBoundingClientRect();
juice.confetti?.(rect.left + rect.width / 2, rect.top + rect.height / 2, 40);
}
} else {
audio.play('lose', 'game');
}
if (state.matchId && state.mode === 'live') {
const myId = store.get('auth.userId');
net.post('domino-match.php', {
action: 'complete',
match_id: state.matchId,
result: result === 'win' ? 'player' + (state.myPlayerIndex + 1) + '_wins' : 'player' + (2 - state.myPlayerIndex) + '_wins',
winner_id: result === 'win' ? myId : undefined,
scores: JSON.stringify({ '0': state.matchScores[0], '1': state.matchScores[1] })
}).catch(() => {});
}
setTimeout(() => {
scene.exitGameMode();
scene.replace('domino-result', {
result, reason, mode: state.mode,
myScore: state.matchScores[state.myPlayerIndex],
oppScore: state.matchScores[1 - state.myPlayerIndex],
rounds: state.roundNumber,
matchId: state.matchId
});
bus.emit('game:ended', { gameKey: 'domino', result, mode: state.mode });
}, 1200);
}
// ═══════════════════════════════════════
// BOT
// ═══════════════════════════════════════
function scheduleBotTurn(el) {
state.botThinking = true;
const thinkEl = el.querySelector('#bot-thinking');
if (thinkEl) thinkEl.style.display = 'block';
const delay = bot.getThinkDelay(state.botPersonality);
botTimeout = setTimeout(() => executeBotTurn(el), delay);
}
if (validMoves.length === 0) { function executeBotTurn(el) {
if (state.gameOver || state.matchOver) return;
state.botThinking = false;
const thinkEl = el.querySelector('#bot-thinking');
if (thinkEl) thinkEl.style.display = 'none';
const botIdx = state.currentPlayer;
const botHand = state.hands[botIdx];
if (!rules.hasValidMove(botHand, state.leftEnd, state.rightEnd)) {
if (state.boneyard.length > 0) { if (state.boneyard.length > 0) {
hand.push(state.boneyard.pop()); botHand.push(state.boneyard.pop());
el.querySelector('#bone-count').textContent = state.boneyard.length; audio.play('click');
setTimeout(() => botTurn(el), 400); updateUI(el);
botTimeout = setTimeout(() => executeBotTurn(el), 400);
return; return;
} }
nextTurn(el); return; if (checkRoundEnd(el)) return;
nextTurn(el);
return;
} }
const move = validMoves[Math.floor(Math.random() * validMoves.length)]; const oppCount = state.hands[state.myPlayerIndex].length;
const result = rules.placeTile(state.chain, move.tile, move.end === 'first' ? 'right' : move.end); const move = bot.pickMove(botHand, state.leftEnd, state.rightEnd, state.chain, oppCount, state.botPersonality);
if (!move) { nextTurn(el); return; }
const actualEnd = state.chain.length === 0 ? 'right' : (move.end === 'first' ? 'right' : move.end);
const result = rules.placeTile(state.chain, move.tile, actualEnd);
state.chain = result.chain; state.chain = result.chain;
state.leftEnd = result.leftEnd; state.leftEnd = result.leftEnd;
state.rightEnd = result.rightEnd; state.rightEnd = result.rightEnd;
state.hands[state.currentPlayer] = hand.filter(t => t.id !== move.tile.id); state.hands[botIdx] = botHand.filter(t => t.id !== move.tile.id);
audio.play('place', 'game'); state.moveCount++;
el.querySelector('#opp-count').textContent = state.hands[1]?.length || 0; board.animatePlacement(move.tile, actualEnd, () => {
board.setChain(state.chain);
});
audio.play('place', 'game');
if (state.hands[state.currentPlayer].length === 0 || rules.checkRoundEnd(state.hands, state.boneyard, state.chain)) { if (bot.shouldEmote(state.botPersonality)) {
endRound(el); return; setTimeout(() => showEmoteBubble(el, bot.getRandomEmote()), 300);
} }
updateUI(el);
if (checkRoundEnd(el)) return;
nextTurn(el); nextTurn(el);
} }
function endRound(el) { // ═══════════════════════════════════════
// UI
// ═══════════════════════════════════════
function updateUI(el) {
const isMyTurn = state.currentPlayer === state.myPlayerIndex;
const turnEl = el.querySelector('#turn-status');
if (turnEl) {
turnEl.textContent = state.gameOver ? 'انتهت الجولة' : isMyTurn ? 'دورك!' : 'الخصم يلعب...';
turnEl.style.color = state.gameOver ? '#94a3b8' : isMyTurn ? '#4ade80' : '#fbbf24';
}
const oppCountEl = el.querySelector('#opp-count');
if (oppCountEl) {
const oppIdx = 1 - state.myPlayerIndex;
oppCountEl.textContent = `${state.hands[oppIdx]?.length || 0} قطع`;
}
const boneEl = el.querySelector('#boneyard-count');
if (boneEl) boneEl.textContent = `المخزن: ${state.boneyard.length}`;
const myScoreEl = el.querySelector('#my-score');
const oppScoreEl = el.querySelector('#opp-score');
if (myScoreEl) myScoreEl.textContent = state.matchScores[state.myPlayerIndex];
if (oppScoreEl) oppScoreEl.textContent = state.matchScores[1 - state.myPlayerIndex];
const roundEl = el.querySelector('#round-num');
if (roundEl) roundEl.textContent = state.roundNumber;
const drawBtn = el.querySelector('#btn-draw');
const passBtn = el.querySelector('#btn-pass');
const myHand = state.hands[state.myPlayerIndex];
const hasMove = rules.hasValidMove(myHand, state.leftEnd, state.rightEnd);
if (isMyTurn && !hasMove && state.boneyard.length > 0 && !state.gameOver) {
if (drawBtn) { drawBtn.style.display = ''; drawBtn.style.animation = 'tileGlow 1.5s ease-in-out infinite'; }
if (passBtn) passBtn.style.display = 'none';
} else if (isMyTurn && !hasMove && state.boneyard.length === 0 && !state.gameOver) {
if (drawBtn) drawBtn.style.display = 'none';
if (passBtn) passBtn.style.display = '';
} else {
if (drawBtn) { drawBtn.style.display = ''; drawBtn.style.animation = ''; }
if (passBtn) passBtn.style.display = 'none';
}
}
function refreshHand() {
const myHand = state.hands[state.myPlayerIndex];
const validIds = new Set();
if (state.currentPlayer === state.myPlayerIndex && !state.gameOver) {
const moves = state.chain.length === 0
? myHand.map(t => ({ tile: t }))
: rules.getValidMoves(myHand, state.leftEnd, state.rightEnd);
moves.forEach(m => validIds.add(m.tile.id));
}
hand.setTiles(myHand, validIds);
hand.setDisabled(state.currentPlayer !== state.myPlayerIndex || state.gameOver);
}
function confirmResign(el) {
if (state.gameOver || state.matchOver) return;
const confirmed = confirm('هل تريد الاستسلام؟');
if (!confirmed) return;
state.matchOver = true;
state.gameOver = true; state.gameOver = true;
const { winner, scores } = rules.getRoundWinner(state.hands);
const result = winner === 0 ? 'win' : 'loss';
audio.play(result === 'win' ? 'win' : 'lose', 'game');
if (state.matchId) {
net.post('domino-match.php', { action: 'resign', match_id: state.matchId }).catch(() => {});
}
audio.play('lose', 'game');
setTimeout(() => { setTimeout(() => {
scene.exitGameMode(); scene.exitGameMode();
scene.replace('domino-result', { result, scores, mode: state.mode }); scene.replace('domino-result', {
bus.emit('game:ended', { gameKey: 'domino', result, mode: state.mode }); result: 'loss', mode: state.mode, resigned: true,
}, 1000); myScore: state.matchScores[state.myPlayerIndex],
oppScore: state.matchScores[1 - state.myPlayerIndex],
rounds: state.roundNumber, matchId: state.matchId
});
bus.emit('game:ended', { gameKey: 'domino', result: 'loss', mode: state.mode });
}, 500);
}
// ═══════════════════════════════════════
// SERVER
// ═══════════════════════════════════════
async function createServerRecord() {
try {
const data = await net.post('domino-match.php', {
action: 'start',
mode: 'bot',
bot_level: state.botPersonality.strategy
});
if (data?.id) state.matchId = data.id;
} catch (e) {}
}
// ═══════════════════════════════════════
// CLEANUP
// ═══════════════════════════════════════
export function unmountGame() {
if (botTimeout) { clearTimeout(botTimeout); botTimeout = null; }
liveSession?.cleanup?.();
mp.stopDisconnectWatch?.();
board?.destroy();
hand?.destroy();
drag?.destroy();
state = null;
liveSession = null;
} }
import * as scene from '../../../core/scene.js'; import * as scene from '../../../core/scene.js';
import * as bus from '../../../core/bus.js'; import * as bus from '../../../core/bus.js';
import * as audio from '../../../core/audio.js'; import * as audio from '../../../core/audio.js';
import * as juice from '../../../core/juice.js';
import * as net from '../../../core/net.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js'; import { emoji } from '../../../core/theme.js';
export function mountResult(el, params) { export function mountResult(el, params) {
const { result, scores = [] } = params; const { result, myScore = 0, oppScore = 0, rounds = 1, mode = 'bot', resigned = false, matchId, reason } = params;
const isWin = result === 'win'; const isWin = result === 'win';
const icon = isWin ? emoji('trophy', '🏆', 64) : emoji('skull', '💀', 64); const isDraw = result === 'draw';
const title = isWin ? t('game.you_win') : t('game.you_lose');
const color = isWin ? 'var(--win)' : 'var(--loss)'; const icon = isWin ? emoji('trophy', '🏆', 56) : isDraw ? emoji('handshake', '🤝', 56) : emoji('skull', '💀', 56);
const title = resigned ? 'استسلمت' : reason === 'resign' ? 'الخصم استسلم!' : reason === 'abandon' ? 'الخصم انقطع' : isWin ? 'فوز!' : isDraw ? 'تعادل' : 'خسارة';
const titleColor = isWin ? '#4ade80' : isDraw ? '#fbbf24' : '#fca5a5';
el.innerHTML = ` el.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:var(--s-6);padding:var(--s-6);"> <div id="result-wrap" style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:16px;padding:24px;background:linear-gradient(180deg,#081a0c 0%,#0a2a0e 100%);">
<div style="font-size:64px;animation:float 2s ease-in-out infinite;">${icon}</div> <div id="result-icon" style="font-size:56px;animation:bounceIn 0.5s cubic-bezier(0.34,1.56,0.64,1);">${icon}</div>
<div style="font-size:28px;font-weight:800;color:${color};">${title}</div> <div style="font-size:28px;font-weight:800;color:${titleColor};animation:fadeSlideUp 0.4s ease 0.2s both;">${title}</div>
<div style="color:var(--text-secondary);font-size:14px;">النقاط: ${scores.join(' - ')}</div>
<div style="display:flex;gap:var(--s-3);margin-top:var(--s-6);"> <!-- Score summary -->
<button class="btn btn-primary" id="btn-again">${t('game.rematch')}</button> <div style="display:flex;gap:20px;align-items:center;animation:fadeSlideUp 0.4s ease 0.3s both;">
<button class="btn btn-secondary" id="btn-back">${t('game.back')}</button> <div style="text-align:center;">
<div style="font-size:32px;font-weight:800;color:#4ade80;" id="score-me">0</div>
<div style="font-size:11px;color:#86efac;">أنت</div>
</div>
<div style="font-size:18px;color:#475569;">—</div>
<div style="text-align:center;">
<div style="font-size:32px;font-weight:800;color:#fca5a5;" id="score-opp">0</div>
<div style="font-size:11px;color:#fca5a5;">خصم</div>
</div>
</div>
<div style="font-size:12px;color:#64748b;animation:fadeSlideUp 0.4s ease 0.35s both;">${rounds} ${rounds === 1 ? 'جولة' : 'جولات'}</div>
<!-- Rewards (populated after server response) -->
<div id="rewards-section" style="display:flex;gap:12px;margin-top:8px;animation:fadeSlideUp 0.4s ease 0.5s both;">
<div style="display:flex;align-items:center;gap:4px;padding:8px 14px;background:rgba(251,191,36,0.1);border:1px solid rgba(251,191,36,0.2);border-radius:10px;">
<span style="font-size:16px;">${emoji('coin', '🪙', 16)}</span>
<span style="font-size:14px;font-weight:700;color:#fbbf24;" id="coins-earned">...</span>
</div>
<div style="display:flex;align-items:center;gap:4px;padding:8px 14px;background:rgba(139,92,246,0.1);border:1px solid rgba(139,92,246,0.2);border-radius:10px;">
<span style="font-size:16px;">${emoji('star', '⭐', 16)}</span>
<span style="font-size:14px;font-weight:700;color:#a78bfa;" id="xp-earned">...</span>
</div>
</div>
<!-- Rating change -->
<div id="rating-section" style="font-size:14px;font-weight:600;color:#94a3b8;animation:fadeSlideUp 0.4s ease 0.6s both;">التصنيف: ...</div>
<!-- Actions -->
<div style="display:flex;gap:10px;margin-top:16px;width:100%;max-width:300px;animation:fadeSlideUp 0.4s ease 0.7s both;">
<button class="btn btn-primary" id="btn-rematch" style="flex:1;min-height:48px;border-radius:14px;font-size:15px;font-weight:700;background:linear-gradient(135deg,#10b981,#06b6d4);">إعادة</button>
<button class="btn btn-secondary" id="btn-back" style="flex:1;min-height:48px;border-radius:14px;font-size:15px;font-weight:700;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);color:#e2e8f0;">رجوع</button>
</div> </div>
</div> </div>
<style>
@keyframes bounceIn { 0%{transform:scale(0);opacity:0} 60%{transform:scale(1.2)} 100%{transform:scale(1);opacity:1} }
@keyframes fadeSlideUp { from{transform:translateY(12px);opacity:0} to{transform:translateY(0);opacity:1} }
</style>
`; `;
el.querySelector('#btn-again').addEventListener('click', () => { animateCounter(el.querySelector('#score-me'), 0, myScore, 800);
animateCounter(el.querySelector('#score-opp'), 0, oppScore, 800);
if (isWin) {
setTimeout(() => {
const iconEl = el.querySelector('#result-icon');
if (iconEl) {
const rect = iconEl.getBoundingClientRect();
juice.confetti?.(rect.left + rect.width / 2, rect.top + rect.height / 2, 30);
}
}, 400);
}
completeOnServer(el, matchId, result, mode);
el.querySelector('#btn-rematch').addEventListener('click', () => {
audio.play('click'); audio.play('click');
scene.replace('domino-game', { mode: params.mode }); scene.replace('domino-game', { mode });
}); });
el.querySelector('#btn-back').addEventListener('click', () => { el.querySelector('#btn-back').addEventListener('click', () => {
...@@ -33,3 +87,58 @@ export function mountResult(el, params) { ...@@ -33,3 +87,58 @@ export function mountResult(el, params) {
bus.emit('navigate', { world: 'play', scene: 'play-table' }); bus.emit('navigate', { world: 'play', scene: 'play-table' });
}); });
} }
async function completeOnServer(el, matchId, result, mode) {
const fallbackCoins = result === 'win' ? 50 : result === 'draw' ? 20 : 10;
const fallbackXp = 15;
const fallbackRating = result === 'win' ? 12 : result === 'draw' ? 1 : -8;
let coins = fallbackCoins;
let xp = fallbackXp;
let ratingChange = fallbackRating;
if (matchId) {
try {
const data = await net.post('domino-match.php', {
action: 'complete',
match_id: matchId,
result: result === 'win' ? 'player_wins' : result === 'draw' ? 'draw' : 'player_loses',
winner_id: result === 'win' ? (await net.post('domino-match.php', { action: 'get', match_id: matchId }))?.winner_id : undefined,
opponent_rating: 1200
});
if (data && !data.error) {
coins = data.coins_earned ?? fallbackCoins;
xp = data.xp_earned ?? fallbackXp;
ratingChange = data.rating_change ?? fallbackRating;
}
} catch (e) {}
}
const coinsEl = el.querySelector('#coins-earned');
const xpEl = el.querySelector('#xp-earned');
const ratingEl = el.querySelector('#rating-section');
if (coinsEl) coinsEl.textContent = `+${coins}`;
if (xpEl) xpEl.textContent = `+${xp} XP`;
if (ratingEl) {
const sign = ratingChange >= 0 ? '+' : '';
const color = ratingChange > 0 ? '#4ade80' : ratingChange === 0 ? '#fbbf24' : '#fca5a5';
ratingEl.innerHTML = `التصنيف: <span style="color:${color};font-weight:700;">${sign}${ratingChange}</span>`;
}
bus.emit('coins:earned', { amount: coins });
bus.emit('xp:earned', { amount: xp });
}
function animateCounter(el, from, to, duration) {
if (!el || from === to) { if (el) el.textContent = to; return; }
const start = performance.now();
function frame(now) {
const progress = Math.min((now - start) / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
el.textContent = Math.round(from + (to - from) * eased);
if (progress < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
import * as scene from '../../../core/scene.js'; import * as scene from '../../../core/scene.js';
import * as bus from '../../../core/bus.js';
import * as audio from '../../../core/audio.js'; import * as audio from '../../../core/audio.js';
import * as net from '../../../core/net.js';
import * as store from '../../../core/store.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js'; import { emoji } from '../../../core/theme.js';
let pollTimer = null;
export function mountRoom(el, params) { export function mountRoom(el, params) {
const { mode = 'menu', challengeId, friendId, friendName } = params || {};
if (mode === 'bot-pick') {
renderBotPicker(el);
} else if (mode === 'lobby') {
renderLobby(el, { challengeId, friendId, friendName });
} else {
renderMenu(el);
}
}
function renderMenu(el) {
el.innerHTML = ` el.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:var(--s-4);"> <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:20px;padding:24px;background:linear-gradient(180deg,#081a0c 0%,#0a2a0e 100%);">
<div style="font-size:24px;font-weight:700;">${t('game.domino')}</div> <div style="font-size:48px;">${emoji('domino_tile', '🁣', 48)}</div>
<div style="color:var(--text-secondary);">${t('play.searching')}</div> <div style="font-size:22px;font-weight:800;color:#f0fdf4;">دومينو</div>
<div class="pulse" style="width:60px;height:60px;border-radius:50%;border:3px solid var(--domino-primary);display:flex;align-items:center;justify-content:center;">${emoji('domino_tile', '⬚', 32)}</div> <div style="font-size:13px;color:#86efac;text-align:center;max-width:260px;">أول من يوصل 100 نقطة يفوز!</div>
<button class="btn btn-secondary" id="cancel-btn">${t('play.cancel')}</button>
<div style="display:flex;flex-direction:column;gap:10px;width:100%;max-width:300px;margin-top:12px;">
<button class="btn btn-primary" id="btn-bot" style="min-height:56px;border-radius:16px;font-size:16px;font-weight:700;background:linear-gradient(135deg,#10b981,#059669);display:flex;align-items:center;justify-content:center;gap:8px;">
${emoji('robot', '🤖', 20)} ضد البوت
</button>
<button class="btn btn-primary" id="btn-online" style="min-height:56px;border-radius:16px;font-size:16px;font-weight:700;background:linear-gradient(135deg,#06b6d4,#0891b2);display:flex;align-items:center;justify-content:center;gap:8px;">
${emoji('globe', '🌍', 20)} أونلاين
</button>
<button class="btn btn-secondary" id="btn-friend" style="min-height:48px;border-radius:14px;font-size:14px;font-weight:600;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);color:#e2e8f0;display:flex;align-items:center;justify-content:center;gap:8px;">
${emoji('handshake', '🤝', 18)} تحدي صديق
</button>
</div>
<button class="btn btn-secondary" id="btn-back" style="margin-top:12px;font-size:13px;color:#64748b;background:none;border:none;">رجوع</button>
</div> </div>
`; `;
el.querySelector('#cancel-btn').addEventListener('click', () => { el.querySelector('#btn-bot').addEventListener('click', () => {
audio.play('click'); audio.play('click');
scene.replace('domino-room', { mode: 'bot-pick' });
});
el.querySelector('#btn-online').addEventListener('click', () => {
audio.play('click');
scene.push('play-queue', { game: 'domino' });
});
el.querySelector('#btn-friend').addEventListener('click', () => {
audio.play('click');
scene.push('challenge-friend', { game: 'domino' });
});
el.querySelector('#btn-back').addEventListener('click', () => {
audio.play('click');
bus.emit('navigate', { world: 'play', scene: 'play-table' });
});
}
function renderBotPicker(el) {
const levels = [
{ key: 'beginner', label: 'مبتدئ', desc: 'يلعب عشوائي', icon: '😊', color: '#4ade80' },
{ key: 'intermediate', label: 'متوسط', desc: 'يفضل النقاط العالية', icon: '🧐', color: '#fbbf24' },
{ key: 'expert', label: 'خبير', desc: 'استراتيجي ومخادع', icon: '🧠', color: '#f87171' }
];
el.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:20px;padding:24px;background:linear-gradient(180deg,#081a0c 0%,#0a2a0e 100%);">
<div style="font-size:20px;font-weight:700;color:#f0fdf4;">اختر مستوى البوت</div>
<div style="display:flex;flex-direction:column;gap:10px;width:100%;max-width:300px;">
${levels.map(l => `
<button class="btn-level" data-level="${l.key}" style="
display:flex;align-items:center;gap:12px;padding:16px;
background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.08);
border-radius:14px;cursor:pointer;width:100%;text-align:right;
transition:transform 0.1s,border-color 0.2s;
">
<span style="font-size:28px;">${l.icon}</span>
<div style="flex:1;">
<div style="font-size:15px;font-weight:700;color:${l.color};">${l.label}</div>
<div style="font-size:12px;color:#94a3b8;margin-top:2px;">${l.desc}</div>
</div>
<div style="font-size:18px;color:#475569;">←</div>
</button>
`).join('')}
</div>
<button class="btn btn-secondary" id="btn-back-bot" style="margin-top:8px;font-size:13px;color:#64748b;background:none;border:none;">رجوع</button>
</div>
`;
el.querySelectorAll('.btn-level').forEach(btn => {
btn.addEventListener('click', () => {
audio.play('click');
const level = btn.dataset.level;
scene.enterGameMode();
scene.replace('domino-game', { mode: 'bot', botLevel: level });
});
});
el.querySelector('#btn-back-bot').addEventListener('click', () => {
audio.play('click');
scene.replace('domino-room', { mode: 'menu' });
});
}
function renderLobby(el, { challengeId, friendId, friendName }) {
const isHost = !challengeId;
el.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:16px;padding:24px;background:linear-gradient(180deg,#081a0c 0%,#0a2a0e 100%);">
<div style="font-size:40px;">${emoji('domino_tile', '🁣', 40)}</div>
<div style="font-size:18px;font-weight:700;color:#f0fdf4;">
${isHost ? 'بانتظار الصديق...' : `تحدي من ${friendName || 'صديق'}`}
</div>
<div id="lobby-status" style="display:flex;flex-direction:column;align-items:center;gap:8px;">
<div class="pulse" style="width:60px;height:60px;border-radius:50%;border:3px solid #10b981;display:flex;align-items:center;justify-content:center;">
${emoji('clock', '⏳', 24)}
</div>
<div style="font-size:13px;color:#86efac;" id="lobby-msg">${isHost ? 'أرسل الدعوة لصديقك' : 'اضغط قبول للبدء'}</div>
</div>
<div style="display:flex;gap:10px;margin-top:12px;">
${!isHost ? `<button class="btn btn-primary" id="btn-accept" style="min-height:48px;padding:0 24px;border-radius:14px;font-size:15px;font-weight:700;background:linear-gradient(135deg,#10b981,#06b6d4);">قبول</button>` : ''}
<button class="btn btn-secondary" id="btn-cancel-lobby" style="min-height:48px;padding:0 24px;border-radius:14px;font-size:14px;background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);color:#fca5a5;">إلغاء</button>
</div>
</div>
`;
if (isHost) {
pollForAcceptance(el, friendId);
}
el.querySelector('#btn-accept')?.addEventListener('click', () => {
audio.play('click');
acceptChallenge(el, challengeId);
});
el.querySelector('#btn-cancel-lobby').addEventListener('click', () => {
audio.play('click');
cleanupLobby();
scene.pop(); scene.pop();
}); });
} }
async function pollForAcceptance(el, friendId) {
try {
const data = await net.post('domino-match.php', {
action: 'start',
mode: 'friend',
friend_id: friendId
});
if (data?.id) {
const matchId = data.id;
const msgEl = el.querySelector('#lobby-msg');
if (msgEl) msgEl.textContent = 'تم إنشاء المباراة، بانتظار القبول...';
pollTimer = setInterval(async () => {
try {
const status = await net.post('domino-match.php', { action: 'get', match_id: matchId });
if (status?.status === 'in_progress' && status?.game_state) {
const gs = typeof status.game_state === 'string' ? JSON.parse(status.game_state) : status.game_state;
if (gs.accepted) {
cleanupLobby();
audio.play('notification');
scene.replace('domino-game', { mode: 'live', matchId, playerIndex: 0 });
}
}
} catch (e) {}
}, 2500);
}
} catch (e) {}
}
async function acceptChallenge(el, challengeId) {
try {
await net.post('domino-match.php', {
action: 'move',
match_id: challengeId,
game_state: JSON.stringify({ accepted: true, accepted_at: Date.now() })
});
cleanupLobby();
audio.play('notification');
scene.replace('domino-game', { mode: 'live', matchId: challengeId, playerIndex: 1 });
} catch (e) {}
}
function cleanupLobby() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
export function unmountRoom() {
cleanupLobby();
}
...@@ -147,6 +147,7 @@ function showChallengeOptions(el, targetId, targetName, friendProfile) { ...@@ -147,6 +147,7 @@ function showChallengeOptions(el, targetId, targetName, friendProfile) {
<div style="display:flex;gap:8px;margin-bottom:14px;"> <div style="display:flex;gap:8px;margin-bottom:14px;">
<button class="cfo-game active" data-game="chess" style="flex:1;padding:12px;border-radius:12px;background:#2563EB;border:2px solid #2563EB;color:#fff;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;transition:all 0.15s;">♟ شطرنج</button> <button class="cfo-game active" data-game="chess" style="flex:1;padding:12px;border-radius:12px;background:#2563EB;border:2px solid #2563EB;color:#fff;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;transition:all 0.15s;">♟ شطرنج</button>
<button class="cfo-game" data-game="ludo" style="flex:1;padding:12px;border-radius:12px;background:#1a1a2e;border:2px solid rgba(255,255,255,0.08);color:#94a3b8;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;transition:all 0.15s;">🎲 لودو</button> <button class="cfo-game" data-game="ludo" style="flex:1;padding:12px;border-radius:12px;background:#1a1a2e;border:2px solid rgba(255,255,255,0.08);color:#94a3b8;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;transition:all 0.15s;">🎲 لودو</button>
<button class="cfo-game" data-game="domino" style="flex:1;padding:12px;border-radius:12px;background:#1a1a2e;border:2px solid rgba(255,255,255,0.08);color:#94a3b8;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;transition:all 0.15s;">🁣 دومينو</button>
</div> </div>
<!-- Time control --> <!-- Time control -->
...@@ -178,7 +179,7 @@ function showChallengeOptions(el, targetId, targetName, friendProfile) { ...@@ -178,7 +179,7 @@ function showChallengeOptions(el, targetId, targetName, friendProfile) {
dialog.querySelectorAll('.cfo-game').forEach(b => { b.style.background = '#1a1a2e'; b.style.borderColor = 'rgba(255,255,255,0.08)'; b.style.color = '#94a3b8'; b.classList.remove('active'); }); dialog.querySelectorAll('.cfo-game').forEach(b => { b.style.background = '#1a1a2e'; b.style.borderColor = 'rgba(255,255,255,0.08)'; b.style.color = '#94a3b8'; b.classList.remove('active'); });
btn.style.background = '#2563EB'; btn.style.borderColor = '#2563EB'; btn.style.color = '#fff'; btn.classList.add('active'); btn.style.background = '#2563EB'; btn.style.borderColor = '#2563EB'; btn.style.color = '#fff'; btn.classList.add('active');
selectedGame = btn.dataset.game; selectedGame = btn.dataset.game;
dialog.querySelector('#cfo-time').style.display = selectedGame === 'ludo' ? 'none' : 'grid'; dialog.querySelector('#cfo-time').style.display = (selectedGame === 'ludo' || selectedGame === 'domino') ? 'none' : 'grid';
}); });
}); });
...@@ -209,7 +210,7 @@ function showChallengeOptions(el, targetId, targetName, friendProfile) { ...@@ -209,7 +210,7 @@ function showChallengeOptions(el, targetId, targetName, friendProfile) {
net.post('chat.php', { net.post('chat.php', {
action: 'send', action: 'send',
friend_id: targetId, friend_id: targetId,
content: `أرسل تحدي ${selectedGame === 'ludo' ? 'لودو' : 'شطرنج'}`, content: `أرسل تحدي ${selectedGame === 'ludo' ? 'لودو' : selectedGame === 'domino' ? 'دومينو' : 'شطرنج'}`,
message_type: 'invite', message_type: 'invite',
metadata: { game_key: selectedGame, time_control: selectedTc, match_id: res.match_id } metadata: { game_key: selectedGame, time_control: selectedTc, match_id: res.match_id }
}).catch(() => {}); }).catch(() => {});
......
...@@ -43,7 +43,7 @@ export function mountLobby(el, params = {}) { ...@@ -43,7 +43,7 @@ export function mountLobby(el, params = {}) {
<span style="font-size:20px;">${gameIcon}</span> <span style="font-size:20px;">${gameIcon}</span>
<span style="font-size:13px;font-weight:600;">${gameLabel}</span> <span style="font-size:13px;font-weight:600;">${gameLabel}</span>
</div> </div>
${gameKey !== 'ludo' ? `<div class="lobby-time-badge">${emoji('clock', '⏱️', 13)} ${tcLabel}</div>` : ''} ${gameKey === 'chess' ? `<div class="lobby-time-badge">${emoji('clock', '⏱️', 13)} ${tcLabel}</div>` : ''}
</div> </div>
<!-- Players --> <!-- Players -->
...@@ -133,9 +133,11 @@ export function mountLobby(el, params = {}) { ...@@ -133,9 +133,11 @@ export function mountLobby(el, params = {}) {
async function pollMatchStatus(el, params) { async function pollMatchStatus(el, params) {
if (!matchId) return; if (!matchId) return;
const gameKey = params.gameKey || 'chess';
const endpoint = gameKey === 'ludo' ? 'ludo-match.php' : gameKey === 'domino' ? 'domino-match.php' : 'game.php';
try { try {
const res = await net.post('game.php', { action: 'get', match_id: matchId }); const res = await net.post(endpoint, { action: 'get', match_id: matchId });
if (!res || res.error) return; if (!res || res.error) return;
if (res.status === 'in_progress') { if (res.status === 'in_progress') {
...@@ -181,6 +183,13 @@ function startGame(el, params) { ...@@ -181,6 +183,13 @@ function startGame(el, params) {
matchId: params.matchId, matchId: params.matchId,
isFriendly: true isFriendly: true
}); });
} else if (gameKey === 'domino') {
scene.replace('domino-game', {
mode: 'live',
matchId: params.matchId,
playerIndex: params.isHost ? 0 : 1,
isFriendly: true
});
} }
} }
......
...@@ -45,7 +45,7 @@ export function mountQueue(el, params) { ...@@ -45,7 +45,7 @@ export function mountQueue(el, params) {
} }
async function joinQueue(params) { async function joinQueue(params) {
const endpoint = params.game === 'ludo' ? 'ludo-match.php' : 'matchmaking.php'; const endpoint = params.game === 'ludo' ? 'ludo-match.php' : params.game === 'domino' ? 'domino-match.php' : 'matchmaking.php';
try { try {
const data = await net.post(endpoint, { const data = await net.post(endpoint, {
action: 'queue', action: 'queue',
...@@ -64,7 +64,7 @@ async function joinQueue(params) { ...@@ -64,7 +64,7 @@ async function joinQueue(params) {
} }
function pollForMatch(params) { function pollForMatch(params) {
const endpoint = params.game === 'ludo' ? 'ludo-match.php' : 'matchmaking.php'; const endpoint = params.game === 'ludo' ? 'ludo-match.php' : params.game === 'domino' ? 'domino-match.php' : 'matchmaking.php';
unsub = setInterval(async () => { unsub = setInterval(async () => {
try { try {
const data = await net.post(endpoint, { action: 'status', game_key: params.game }); const data = await net.post(endpoint, { action: 'status', game_key: params.game });
......
...@@ -330,6 +330,8 @@ function showGameMenu(menu, game) { ...@@ -330,6 +330,8 @@ function showGameMenu(menu, game) {
menu.classList.add('hidden'); menu.classList.add('hidden');
if (game.key === 'chess') { if (game.key === 'chess') {
scene.push('play-bot-select', { game: game.key }); scene.push('play-bot-select', { game: game.key });
} else if (game.key === 'domino') {
scene.push('domino-room', { mode: 'bot-pick' });
} else { } else {
const gameScene = game.key + '-game'; const gameScene = game.key + '-game';
scene.push(gameScene, { mode: 'bot', game: game.key }); scene.push(gameScene, { mode: 'bot', game: game.key });
......
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