Commit e4824004 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: Ludo full overhaul — multiplayer modes, social, SFX

Phase 1 — Room/Mode Picker:
- New ludo-room scene with menu, setup, and searching views
- Player count picker (2/3/4 players)
- Bot/human distribution selector (1 human + N bots, 2 humans + N bots, etc)
- Bot difficulty picker (easy/medium/hard)
- Seat preview showing diagonal (2P) or skip-corner (3P) positioning
- Play table routes Ludo single/multi to the new room

Phase 2 — Social Features:
- Emote button (😄) in dice area opens full panel
- 8 emoji reactions + 6 preset Arabic phrases
- Emotes animate FROM the sender's player panel (speech bubble or floating emoji)
- Bot emotes on captures use the new bubble system
- Live mode emote transmission via mp.sendEmote
- 3-second cooldown between sends

Phase 3 — SFX Completion:
- sfx_dice_shake: during dice animation roll
- sfx_boost: rolling a 6 (extra turn excitement)
- sfx_piece_home: piece reaches center
- sfx_turn_start: your turn notification in live mode
- sfx_emote: sending/receiving social actions
- All new slots added to branding.php admin
- Synthesized fallbacks in audio.js for each new sound

Phase 4 — Multiplayer & Seat Mapping:
- rules.createGame accepts seats array for board-slot mapping
- 2-player = diagonal (Red vs Yellow), 3-player = skip Yellow
- boardSlot property on each player drives position/color/captures
- getPiecePosition/getHomeBasePosition use boardSlot not player index
- Game-over condition: numPlayers-1 finishers (not hardcoded 3)
- local-multi mode: multiple humans share one device, bots fill rest
- Turn rotation correctly skips only finished players
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 512110c0
......@@ -505,6 +505,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['asset'])) {
['slot' => 'sfx_place', 'label' => '📍 وضع (Place)', 'hint' => 'وضع قطعة دومينو'],
['slot' => 'sfx_reward', 'label' => '🎁 مكافأة (Reward)', 'hint' => 'فتح المكافأة اليومية'],
['slot' => 'sfx_match_found', 'label' => '🎯 وُجِدت مباراة (Match Found)', 'hint' => 'الاقتران مع خصم'],
['slot' => 'sfx_dice_shake', 'label' => '🎲 رجّ النرد (Dice Shake)', 'hint' => 'صوت رجّ النرد قبل الإطلاق — لودو'],
['slot' => 'sfx_boost', 'label' => '🚀 بوست/ستة (Boost/Six)', 'hint' => 'رمي 6 — دور إضافي — لودو'],
['slot' => 'sfx_piece_home', 'label' => '🏠 وصول البيت (Piece Home)', 'hint' => 'قطعة وصلت المركز — لودو'],
['slot' => 'sfx_turn_start', 'label' => '🔔 دورك (Turn Start)', 'hint' => 'تنبيه بدء دورك — لودو/دومينو'],
['slot' => 'sfx_emote', 'label' => '💬 إيموت (Emote)', 'hint' => 'إرسال إيموجي/عبارة اجتماعية'],
];
foreach ($soundSlots as $s):
$current = $theme['assets'][$s['slot']] ?? null;
......
......@@ -21,7 +21,12 @@ const sounds = {
dice: { src: null, freq: 250, dur: 0.3 },
place: { src: null, freq: 350, dur: 0.1 },
reward: { src: null, freq: 660, dur: 0.4 },
match_found: { src: null, freq: 740, dur: 0.3 }
match_found: { src: null, freq: 740, dur: 0.3 },
sfx_dice_shake: { src: null, freq: 180, dur: 0.4 },
sfx_boost: { src: null, freq: 880, dur: 0.25 },
sfx_piece_home: { src: null, freq: 660, dur: 0.35 },
sfx_turn_start: { src: null, freq: 520, dur: 0.15 },
sfx_emote: { src: null, freq: 440, dur: 0.08 }
};
function getCtx() {
......@@ -44,7 +49,9 @@ function resume() {
function preloadCustomAudio() {
for (const name of Object.keys(sounds)) {
const url = getAsset('sfx_' + name);
// Keys starting with sfx_ are already the full slot name
const slot = name.startsWith('sfx_') ? name : 'sfx_' + name;
const url = getAsset(slot);
if (url) {
const audio = new Audio();
audio.preload = 'auto';
......
......@@ -3,18 +3,22 @@ const HOME_ENTRY_POS = 50;
const HOME_STRETCH = 5;
const FINISH_POS = HOME_ENTRY_POS + 1 + HOME_STRETCH; // 56 — the 6th home column square (outer middle of center 3x3)
// Must match board-map.js START_SQUARES exactly
const START_POSITIONS = [0, 13, 26, 39];
export const START_POSITIONS = [0, 13, 26, 39];
const SAFE_SQUARES = [0, 8, 13, 21, 26, 34, 39, 47];
// Player order: 0=Red(BL), 1=Green(TL), 2=Yellow(TR), 3=Blue(BR)
export const COLORS = ['red', 'green', 'yellow', 'blue'];
export const COLOR_CSS = ['#E53935', '#43A047', '#FDD835', '#1E88E5'];
export function createGame(numPlayers = 4) {
export function createGame(numPlayers = 4, seats = null) {
// seats maps player index to board slot: e.g. [0,2] means player0→Red(BL), player1→Yellow(TR)
const seatMap = seats || Array.from({ length: numPlayers }, (_, i) => i);
const players = [];
for (let i = 0; i < numPlayers; i++) {
const boardSlot = seatMap[i];
players.push({
color: COLORS[i],
color: COLORS[boardSlot],
boardSlot,
pieces: [
{ id: `${i}-0`, pos: -1, finished: false },
{ id: `${i}-1`, pos: -1, finished: false },
......@@ -32,6 +36,7 @@ export function createGame(numPlayers = 4) {
diceValue: null,
rolled: false,
numPlayers,
seatMap,
winners: [],
gameOver: false,
extraTurn: false
......@@ -167,7 +172,8 @@ export function getValidMoves(game, playerIdx, dice) {
if (newPos > HOME_ENTRY_POS) {
moves.push({ pieceId: piece.id, from: piece.pos, to: newPos, type: 'home' });
} else {
const globalPos = (newPos + START_POSITIONS[playerIdx]) % SHARED_PATH_LENGTH;
const boardSlot = game.players[playerIdx].boardSlot ?? playerIdx;
const globalPos = (newPos + START_POSITIONS[boardSlot]) % SHARED_PATH_LENGTH;
const captured = checkCapture(game, playerIdx, globalPos);
moves.push({ pieceId: piece.id, from: piece.pos, to: newPos, type: captured ? 'capture' : 'move' });
}
......@@ -188,7 +194,8 @@ export function applyMove(game, playerIdx, move) {
if (move.type === 'enter') {
piece.pos = 0;
const globalPos = START_POSITIONS[playerIdx];
const boardSlot = game.players[playerIdx].boardSlot ?? playerIdx;
const globalPos = START_POSITIONS[boardSlot];
move._capturedPlayers = captureAt(game, playerIdx, globalPos);
game.extraTurn = true;
} else if (move.type === 'finish') {
......@@ -198,8 +205,8 @@ export function applyMove(game, playerIdx, move) {
if (player.pieces.every(p => p.finished)) {
player.finished = true;
game.winners.push(playerIdx);
// Game ends when 3 players finish — the 4th is the loser
if (game.winners.length >= 3) {
// Game ends when all but one player finish
if (game.winners.length >= game.numPlayers - 1) {
const lastPlayer = game.players.findIndex((p, i) => !p.finished);
if (lastPlayer !== -1) game.winners.push(lastPlayer);
game.gameOver = true;
......@@ -207,7 +214,8 @@ export function applyMove(game, playerIdx, move) {
}
} else if (move.type === 'capture') {
piece.pos = move.to;
const globalPos = (move.to + START_POSITIONS[playerIdx]) % SHARED_PATH_LENGTH;
const boardSlot = game.players[playerIdx].boardSlot ?? playerIdx;
const globalPos = (move.to + START_POSITIONS[boardSlot]) % SHARED_PATH_LENGTH;
move._capturedPlayers = captureAt(game, playerIdx, globalPos);
game.extraTurn = true;
} else if (move.type === 'home') {
......@@ -246,10 +254,11 @@ function checkCapture(game, playerIdx, globalPos) {
for (let i = 0; i < game.numPlayers; i++) {
if (i === playerIdx) continue;
const theirSlot = game.players[i].boardSlot ?? i;
for (const piece of game.players[i].pieces) {
if (piece.pos === -1 || piece.finished) continue;
if (piece.pos > HOME_ENTRY_POS) continue;
const theirGlobal = (piece.pos + START_POSITIONS[i]) % SHARED_PATH_LENGTH;
const theirGlobal = (piece.pos + START_POSITIONS[theirSlot]) % SHARED_PATH_LENGTH;
if (theirGlobal === globalPos) return true;
}
}
......@@ -262,11 +271,11 @@ function captureAt(game, playerIdx, globalPos) {
for (let i = 0; i < game.numPlayers; i++) {
if (i === playerIdx) continue;
const theirSlot = game.players[i].boardSlot ?? i;
for (const piece of game.players[i].pieces) {
if (piece.pos === -1 || piece.finished) continue;
if (piece.pos > HOME_ENTRY_POS) continue;
const theirGlobal = (piece.pos + START_POSITIONS[i]) % SHARED_PATH_LENGTH;
// Double-check: never capture a piece sitting on ANY safe square
const theirGlobal = (piece.pos + START_POSITIONS[theirSlot]) % SHARED_PATH_LENGTH;
if (SAFE_SQUARES.includes(theirGlobal)) continue;
if (theirGlobal === globalPos) {
piece.pos = -1;
......
import * as scene from '../../core/scene.js';
import { mountGame } from './scenes/game.js';
import { mountResult } from './scenes/result.js';
import { mountRoom } from './scenes/room.js';
import { mountRoom, unmountRoom } from './scenes/room.js';
scene.register('ludo-game', mountGame);
scene.register('ludo-result', mountResult);
scene.register('ludo-room', mountRoom);
scene.register('ludo-room', mountRoom, unmountRoom);
......@@ -7,7 +7,6 @@ import { createCanvas, clear } from '../../../core/canvas.js';
import * as rules from '../logic/rules.js';
import * as juice from '../../../core/juice.js';
import { getPiecePosition, getHomeBasePosition, SAFE_SQUARES, HOME_COLUMNS, SHARED_PATH } from '../logic/board-map.js';
import * as emoteSystem from '../../chess/components/emotes.js';
import * as mp from '../../../core/multiplayer.js';
import { emoji, getAsset, getColor } from '../../../core/theme.js';
import * as matchLive from '../../../core/match-live.js';
......@@ -64,28 +63,39 @@ function renderPanel(p) {
}
export function mountGame(el, params) {
const { mode = 'bot', numPlayers = 4 } = params;
const { mode = 'bot', numPlayers = 4, seats, humanCount = 1, difficulty = 'medium' } = params;
scene.enterGameMode();
myPlayerIndex = params.playerIndex || 0;
matchId = params.matchId || null;
isHost = myPlayerIndex === 0;
// Determine active seat indices (which of [0,1,2,3] are playing)
const activeSeats = seats || [0, 1, 2, 3].slice(0, numPlayers);
if (mode === 'live' && params.players) {
PLAYER_NAMES = params.players.map((p, i) => {
if (i === myPlayerIndex) return 'أنت';
if (p.startsWith('bot')) return 'Bot ' + p.split('_')[1];
return 'لاعب ' + (i + 1);
});
} else if (mode === 'local-multi') {
PLAYER_NAMES = activeSeats.map((seatIdx, i) => {
if (i === 0) return 'أنت';
if (i < humanCount) return 'لاعب ' + (i + 1);
return 'Bot ' + (i - humanCount + 1);
});
} else {
PLAYER_NAMES = ['أنت', 'Bot 1', 'Bot 2', 'Bot 3'];
PLAYER_NAMES = ['أنت', 'Bot 1', 'Bot 2', 'Bot 3'].slice(0, numPlayers);
}
game = rules.createGame(numPlayers);
game = rules.createGame(numPlayers, activeSeats);
game.mode = mode;
game.startTime = Date.now();
game.turboMode = false;
game.turnCount = 0;
game.activeSeats = activeSeats;
game.difficulty = difficulty;
game.humanPlayers = PLAYER_NAMES.map((n, i) => n.startsWith('Bot') ? -1 : i).filter(i => i >= 0);
validMoves = [];
diceAnimating = false;
......@@ -93,30 +103,32 @@ export function mountGame(el, params) {
if (livePoller) { clearInterval(livePoller); livePoller = null; }
const player = store.get('player') || {};
const panels = [0,1,2,3].map(i => {
const panels = Array.from({ length: numPlayers }, (_, i) => {
const isMe = i === myPlayerIndex;
const isBot = PLAYER_NAMES[i].startsWith('Bot');
const isBot = PLAYER_NAMES[i]?.startsWith('Bot');
const avatar = isMe && player.avatar_url ? `<img src="${player.avatar_url}" style="width:100%;height:100%;object-fit:cover;border-radius:50%;">` : isBot ? '🤖' : '👤';
const name = isMe ? (player.display_name || player.username || 'أنت') : PLAYER_NAMES[i];
const name = isMe ? (player.display_name || player.username || 'أنت') : (PLAYER_NAMES[i] || 'Bot');
const boardSlot = activeSeats[i] ?? i;
const level = isMe ? `Lv.${player.level || 1}` : (isBot ? '' : '');
return { i, avatar, name, level, color: COLORS[i] };
return { i, avatar, name, level, color: COLORS[boardSlot] };
});
el.innerHTML = `
<div style="display:flex;flex-direction:column;height:100%;background:linear-gradient(180deg,#0d0d1a 0%,#1a1a2e 50%,#0d0d1a 100%);justify-content:flex-end;">
<div style="display:flex;justify-content:space-between;padding:8px 12px;background:rgba(15,15,30,0.9);border-bottom:1px solid rgba(255,255,255,0.04);direction:ltr;">
${renderPanel(panels[1])}
${renderPanel(panels[2])}
${panels.length > 1 ? renderPanel(panels[1]) : '<div></div>'}
${panels.length > 2 ? renderPanel(panels[2]) : '<div></div>'}
</div>
<div id="ludo-wrap" style="flex:1;display:flex;align-items:center;justify-content:center;padding:6px;min-height:0;"></div>
<div style="display:flex;justify-content:space-between;padding:8px 12px;background:rgba(15,15,30,0.9);border-top:1px solid rgba(255,255,255,0.04);direction:ltr;">
${renderPanel(panels[0])}
${renderPanel(panels[3])}
${panels.length > 3 ? renderPanel(panels[3]) : '<div></div>'}
</div>
<div id="turn-timer-bar" style="height:4px;background:rgba(255,255,255,0.05);position:relative;overflow:hidden;"><div id="turn-timer-fill" style="position:absolute;inset:0;background:linear-gradient(90deg,#4ade80,#E4AC38,#EF4444);transform:scaleX(1);transform-origin:left;transition:none;"></div></div>
<div id="dice-area" style="display:flex;align-items:center;gap:12px;padding:14px 16px;background:linear-gradient(180deg,#12122a,#0a0a1a);border-top:1px solid rgba(228,172,56,0.15);justify-content:center;padding-bottom:max(14px, env(safe-area-inset-bottom, 0px));position:relative;overflow:hidden;">
<div style="position:absolute;inset:0;background:radial-gradient(ellipse at 50% 0%,rgba(228,172,56,0.06) 0%,transparent 70%);pointer-events:none;"></div>
<button class="btn btn-secondary" id="exit-btn" style="min-height:44px;min-width:44px;padding:0;font-size:13px;color:#EF4444;border-radius:50%;background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.2);">✕</button>
<button class="btn btn-secondary" id="emote-btn" style="min-height:44px;min-width:44px;padding:0;font-size:18px;border-radius:50%;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);">😄</button>
<div id="dice-box" style="width:56px;height:56px;background:linear-gradient(145deg,#ffffff,#f0ede8);border-radius:12px;display:grid;grid-template:repeat(3,1fr)/repeat(3,1fr);padding:7px;box-shadow:0 4px 12px rgba(0,0,0,0.4),inset 0 2px 0 rgba(255,255,255,0.9),0 0 0 2px rgba(228,172,56,0.15);transition:transform 0.15s cubic-bezier(0.34,1.56,0.64,1);">
</div>
<button class="btn btn-primary" id="roll-btn" style="font-size:15px;padding:14px 32px;min-height:52px;border-radius:14px;background:linear-gradient(135deg,#E4AC38,#F59E0B);box-shadow:0 4px 14px rgba(228,172,56,0.3);font-weight:800;" disabled>ارمِ النرد</button>
......@@ -142,6 +154,10 @@ export function mountGame(el, params) {
@keyframes diceSix{0%,100%{box-shadow:0 0 8px rgba(228,172,56,0.4);}50%{box-shadow:0 0 20px rgba(228,172,56,0.9);}}
@keyframes rollBtnPulse{0%,100%{box-shadow:0 4px 14px rgba(228,172,56,0.3);}50%{box-shadow:0 4px 24px rgba(228,172,56,0.6);}}
#roll-btn:not([disabled]){animation:rollBtnPulse 2s ease-in-out infinite;}
@keyframes emojiBubble{0%{opacity:0;transform:translate(-50%,-100%) scale(0);}15%{opacity:1;transform:translate(-50%,-100%) scale(1.3);}70%{opacity:1;transform:translate(-50%,-130%) scale(1);}100%{opacity:0;transform:translate(-50%,-160%) scale(0.7);}}
@keyframes phraseBubble{0%{opacity:0;transform:translate(-50%,-80%) scale(0.7);}15%{opacity:1;transform:translate(-50%,-100%) scale(1);}75%{opacity:1;transform:translate(-50%,-100%) scale(1);}100%{opacity:0;transform:translate(-50%,-120%) scale(0.8);}}
@keyframes fadeIn{from{opacity:0;transform:translateX(-50%) translateY(8px);}to{opacity:1;transform:translateX(-50%) translateY(0);}}
.lep-emoji:active,.lep-phrase:active{transform:scale(0.9);background:rgba(228,172,56,0.15)!important;}
</style>
`;
......@@ -183,7 +199,7 @@ export function mountGame(el, params) {
});
renderDiceFace(el.querySelector('#dice-box'), 1);
for (let i = 0; i < 4; i++) {
for (let i = 0; i < numPlayers; i++) {
const md = el.querySelector(`#dice-${i}`);
if (md) { renderMiniDice(md, 1); md.className = 'pp-dice'; }
}
......@@ -193,18 +209,9 @@ export function mountGame(el, params) {
updatePanels(el);
el.querySelector('#roll-btn').addEventListener('click', () => handleRoll(el));
el.querySelector('#exit-btn').addEventListener('click', () => handleExit(el));
el.querySelector('#emote-btn').addEventListener('click', () => showEmotePanel(el));
// Emotes + multiplayer
const emoteWrap = el.querySelector('#ludo-wrap');
emoteSystem.create(emoteWrap, (emote) => {
audio.play('notification');
// Animate from MY panel position to center
const myPanel = el.querySelector(`#pp-${myPlayerIndex}`);
emoteSystem.showReceived(emoteWrap, emote.emoji, myPanel);
if (game.mode === 'live' && matchId) {
mp.sendEmote(matchId, 'ludo', emote.key);
}
});
// Emotes + multiplayer (panel created on demand via emote-btn)
// Live mode setup — full multiplayer integration
if (mode === 'live' && matchId) {
......@@ -214,12 +221,9 @@ export function mountGame(el, params) {
});
mp.startDisconnectWatch(matchId, 'ludo', 60000);
mp.onEmoteReceived((emote) => {
// Animate from OPPONENT's panel position to center
// Find which player sent it (not me)
const senderIdx = emote.from === store.get('auth.userId') ? myPlayerIndex : (myPlayerIndex === 0 ? 1 : 0);
const oppPanel = el.querySelector(`#pp-${senderIdx}`);
emoteSystem.showReceived(emoteWrap, emote.key === 'gg' ? emoji('handshake', '🤝', 24) : emote.key === 'good_move' ? '👏' : '😮', oppPanel);
audio.play('notification');
showEmoteBubble(el, senderIdx, emote.key);
audio.play('sfx_emote', 'ui');
});
// Fetch opponent profiles and update player panels
......@@ -278,6 +282,9 @@ function renderDiceFace(diceBox, value) {
}
function isMyTurn() {
if (game.mode === 'local-multi') {
return game.humanPlayers.includes(game.currentPlayer);
}
return game.currentPlayer === myPlayerIndex;
}
......@@ -442,11 +449,9 @@ async function botLoop(el) {
}
if (move.type === 'capture' && Math.random() > 0.5) {
const emoteWrap = el.querySelector('#ludo-wrap');
setTimeout(() => {
const botEmotes = ['😂', '💪', '🎉', '😎'];
const botPanel2 = el.querySelector(`#pp-${game.currentPlayer}`);
emoteSystem.showReceived(emoteWrap, botEmotes[Math.floor(Math.random() * botEmotes.length)], botPanel2);
showEmoteBubble(el, game.currentPlayer, botEmotes[Math.floor(Math.random() * botEmotes.length)]);
}, 300);
}
......@@ -548,7 +553,7 @@ function startLudoPolling(el) {
// If it's now my turn, stop polling
if (isMyTurn()) {
clearInterval(livePoller); livePoller = null;
audio.play('notification');
audio.play('sfx_turn_start', 'ui');
} else {
// If it's a bot's turn and I'm host, run it
const currentPlayerStr = PLAYER_NAMES[game.currentPlayer];
......@@ -634,8 +639,9 @@ function waitForPieceSelection(el, moves) {
const pieceIdx = parseInt(move.pieceId.split('-')[1]);
const piece = game.players[pIdx].pieces[pieceIdx];
let pos;
if (piece.pos === -1) pos = getHomeBasePosition(pIdx, pieceIdx, cellSize);
else pos = getPiecePosition(piece.pos, pIdx, cellSize);
const slot = game.players[pIdx]?.boardSlot ?? pIdx;
if (piece.pos === -1) pos = getHomeBasePosition(slot, pieceIdx, cellSize);
else pos = getPiecePosition(piece.pos, slot, cellSize);
if (!pos) continue;
const dist = Math.sqrt((cx - pos.x) ** 2 + (cy - pos.y) ** 2);
......@@ -698,7 +704,8 @@ async function animateMove(el, move) {
});
piece._bounceOffset = 0;
// Landing burst
const pos = getPiecePosition(0, pIdx, cellSize);
const enterSlot = game.players[pIdx]?.boardSlot ?? pIdx;
const pos = getPiecePosition(0, enterSlot, cellSize);
if (pos) {
const rect = canvas.getBoundingClientRect();
const sx = rect.left + (pos.x / boardSize) * rect.width;
......@@ -734,7 +741,8 @@ async function animateMove(el, move) {
// Landing effect on final square
if (steps >= 3) {
const pos = getPiecePosition(toPos, pIdx, cellSize);
const landSlot = game.players[pIdx]?.boardSlot ?? pIdx;
const pos = getPiecePosition(toPos, landSlot, cellSize);
if (pos) {
const rect = canvas.getBoundingClientRect();
const sx = rect.left + (pos.x / boardSize) * rect.width;
......@@ -795,13 +803,13 @@ function afterMove(el, move) {
}
} else if (move.type === 'finish') {
if (mover === myPlayerIndex) {
audio.play('win', 'reward');
audio.play('sfx_piece_home', 'reward');
juice.hapticSuccess();
const boardRect = canvas.getBoundingClientRect();
fireworkBurst(boardRect.left + boardRect.width / 2, boardRect.top + boardRect.height / 2, COLORS[mover]);
juice.screenFlash(COLORS[mover] + '22', 500);
} else {
audio.play('notification');
audio.play('sfx_piece_home', 'game');
}
const panel = el.querySelector(`#pp-${mover}`);
if (panel) panel.animate([{ background: 'rgba(76,175,80,0.3)' }, { background: 'transparent' }], { duration: 600 });
......@@ -865,17 +873,18 @@ function renderFrame() {
const pulse = 0.2 + Math.sin(now * 0.004) * 0.1;
validMoves.forEach(move => {
const pIdx = parseInt(move.pieceId.split('-')[0]);
const destPos = getPiecePosition(move.to, pIdx, cs);
const bSlot = game.players[pIdx]?.boardSlot ?? pIdx;
const destPos = getPiecePosition(move.to, bSlot, cs);
if (destPos) {
const r = cs * 0.42;
ctx.beginPath();
ctx.arc(destPos.x, destPos.y, r, 0, Math.PI * 2);
ctx.globalAlpha = pulse;
ctx.fillStyle = COLORS[pIdx];
ctx.fillStyle = COLORS[bSlot];
ctx.fill();
ctx.globalAlpha = 0.6;
ctx.setLineDash([4, 3]);
ctx.strokeStyle = COLORS[pIdx];
ctx.strokeStyle = COLORS[bSlot];
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.setLineDash([]);
......@@ -964,9 +973,10 @@ function drawPieces(cs) {
game.players.forEach((player, pIdx) => {
player.pieces.forEach((piece, pieceIdx) => {
if (piece.finished) return;
const bSlot = player.boardSlot ?? pIdx;
let pos;
if (piece.pos === -1) pos = getHomeBasePosition(pIdx, pieceIdx, cs);
else pos = getPiecePosition(piece.pos, pIdx, cs);
if (piece.pos === -1) pos = getHomeBasePosition(bSlot, pieceIdx, cs);
else pos = getPiecePosition(piece.pos, bSlot, cs);
if (!pos) return;
const key = `${Math.round(pos.x)},${Math.round(pos.y)}`;
if (!cellOccupants.has(key)) cellOccupants.set(key, []);
......@@ -1009,14 +1019,15 @@ function drawPieces(cs) {
// Shadow
ctx.beginPath(); ctx.arc(ox, oy + 2, r, 0, Math.PI*2); ctx.fillStyle = 'rgba(0,0,0,0.2)'; ctx.fill();
const spriteImg = pawnImages[pIdx];
const bSlot2 = game.players[pIdx]?.boardSlot ?? pIdx;
const spriteImg = pawnImages[bSlot2];
const pColor = COLORS[bSlot2];
if (spriteImg) {
const size = r * 2;
ctx.drawImage(spriteImg, ox - size/2, drawY - size/2, size, size);
} else {
// Fallback: canvas-drawn pawn
ctx.beginPath(); ctx.arc(ox, drawY - r*0.15, r*0.65, 0, Math.PI*2); ctx.fillStyle = COLORS[pIdx]; ctx.fill();
ctx.beginPath(); ctx.ellipse(ox, drawY + r*0.4, r*0.8, r*0.4, 0, 0, Math.PI*2); ctx.fillStyle = COLORS[pIdx]; ctx.fill();
ctx.beginPath(); ctx.arc(ox, drawY - r*0.15, r*0.65, 0, Math.PI*2); ctx.fillStyle = pColor; ctx.fill();
ctx.beginPath(); ctx.ellipse(ox, drawY + r*0.4, r*0.8, r*0.4, 0, 0, Math.PI*2); ctx.fillStyle = pColor; ctx.fill();
ctx.beginPath(); ctx.arc(ox, drawY - r*0.15, r*0.65, 0, Math.PI*2); ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke();
ctx.beginPath(); ctx.ellipse(ox, drawY + r*0.4, r*0.8, r*0.4, 0, 0, Math.PI*2); ctx.strokeStyle = '#fff'; ctx.lineWidth = 1; ctx.stroke();
ctx.beginPath(); ctx.arc(ox - r*0.2, drawY - r*0.35, r*0.18, 0, Math.PI*2); ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.fill();
......@@ -1041,7 +1052,7 @@ function checkTurboMode(el) {
function updatePanels(el) {
checkTurboMode(el);
for (let i = 0; i < 4; i++) {
for (let i = 0; i < game.numPlayers; i++) {
const p = el.querySelector(`#pp-${i}`);
if (p) {
const wasActive = p.classList.contains('active');
......@@ -1121,6 +1132,7 @@ function animateDice(el, playerIdx) {
mainDice.style.opacity = '1';
}
audio.play('sfx_dice_shake', 'game');
let count = 0;
const maxFrames = game.turboMode ? 8 : 14;
const shakeAnim = setInterval(() => {
......@@ -1155,10 +1167,10 @@ function animateDice(el, playerIdx) {
juice.shake(el.querySelector(isMe ? '#dice-area' : `#pp-${playerIdx}`), 4, 200);
if (dice === 6) {
audio.play('sfx_boost', 'reward');
mainDice.style.boxShadow = '0 0 20px #E4AC38, 0 4px 14px rgba(0,0,0,0.4)';
setTimeout(() => { mainDice.style.boxShadow = '0 4px 14px rgba(0,0,0,0.4),inset 0 1px 0 rgba(255,255,255,0.8)'; }, 800);
juice.hapticSuccess();
// Flash the player panel gold on a 6
const panel = el.querySelector(`#pp-${playerIdx}`);
if (panel) panel.animate([{background:'rgba(228,172,56,0.3)'},{background:'transparent'}], {duration:600});
}
......@@ -1259,3 +1271,140 @@ function endGame(el) {
bus.emit('game:ended', { gameKey: 'ludo', result, place });
}, 1500);
}
// ===== SOCIAL: EMOTES + PHRASES =====
const EMOTES = [
{ key: '😂', label: 'ههه' },
{ key: '😮', label: 'واو' },
{ key: '😡', label: 'غضب' },
{ key: '👏', label: 'برافو' },
{ key: '🔥', label: 'حماس' },
{ key: '😢', label: 'حزين' },
{ key: '💪', label: 'قوي' },
{ key: '😎', label: 'كول' },
];
const PHRASES = [
{ key: 'gl', text: 'حظ سعيد!' },
{ key: 'gg', text: 'GG!' },
{ key: 'hurry', text: 'يلا بسرعة!' },
{ key: 'nice', text: 'نايس!' },
{ key: 'oops', text: 'يا ساتر!' },
{ key: 'wow', text: 'ما شاء الله!' },
];
let emoteCooldown = false;
function showEmotePanel(el) {
const existing = el.querySelector('#ludo-emote-panel');
if (existing) { existing.remove(); return; }
const panel = document.createElement('div');
panel.id = 'ludo-emote-panel';
panel.style.cssText = `
position:absolute;bottom:80px;left:50%;transform:translateX(-50%);
background:#1a1a2e;border:1px solid rgba(228,172,56,0.15);
border-radius:16px;padding:12px 14px;z-index:70;
box-shadow:0 8px 30px rgba(0,0,0,0.7);
animation:fadeIn 0.2s ease;display:flex;flex-direction:column;gap:10px;
max-width:320px;width:90vw;
`;
panel.innerHTML = `
<div style="display:flex;gap:4px;justify-content:center;flex-wrap:wrap;">
${EMOTES.map(e => `
<button class="lep-emoji" data-key="${e.key}" title="${e.label}" style="font-size:24px;background:none;border:none;cursor:pointer;padding:6px;border-radius:8px;transition:transform 0.1s,background 0.1s;">
${e.key}
</button>
`).join('')}
</div>
<div style="height:1px;background:rgba(255,255,255,0.06);"></div>
<div style="display:flex;gap:6px;flex-wrap:wrap;justify-content:center;">
${PHRASES.map(p => `
<button class="lep-phrase" data-key="${p.key}" data-text="${p.text}" style="font-size:12px;font-weight:600;padding:6px 12px;border-radius:10px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);color:#e2e8f0;cursor:pointer;transition:transform 0.1s,background 0.1s;">
${p.text}
</button>
`).join('')}
</div>
`;
el.querySelector('#ludo-wrap')?.appendChild(panel);
panel.querySelectorAll('.lep-emoji').forEach(btn => {
btn.addEventListener('click', () => {
if (emoteCooldown) return;
sendSocialAction(el, btn.dataset.key, 'emoji');
panel.remove();
});
});
panel.querySelectorAll('.lep-phrase').forEach(btn => {
btn.addEventListener('click', () => {
if (emoteCooldown) return;
sendSocialAction(el, btn.dataset.text, 'phrase');
panel.remove();
});
});
setTimeout(() => {
const closeFn = (e) => {
if (!panel.contains(e.target) && e.target.id !== 'emote-btn') {
panel.remove();
document.removeEventListener('pointerdown', closeFn);
}
};
document.addEventListener('pointerdown', closeFn);
}, 100);
}
function sendSocialAction(el, content, type) {
emoteCooldown = true;
setTimeout(() => { emoteCooldown = false; }, 3000);
audio.play('sfx_emote', 'ui');
showEmoteBubble(el, myPlayerIndex, content, type);
if (game.mode === 'live' && matchId) {
mp.sendEmote(matchId, 'ludo', content);
}
}
function showEmoteBubble(el, senderIdx, content, type = 'emoji') {
const panel = el.querySelector(`#pp-${senderIdx}`);
if (!panel) return;
const rect = panel.getBoundingClientRect();
const wrapRect = el.getBoundingClientRect();
const bubble = document.createElement('div');
bubble.className = 'ludo-emote-bubble';
if (type === 'phrase') {
bubble.style.cssText = `
position:absolute;z-index:60;pointer-events:none;
left:${rect.left - wrapRect.left + rect.width / 2}px;
top:${rect.top - wrapRect.top - 10}px;
transform:translate(-50%,-100%);
background:#1a1a2e;border:1px solid rgba(228,172,56,0.2);
border-radius:12px;padding:6px 12px;
font-size:13px;font-weight:700;color:#f8fafc;
white-space:nowrap;
animation:phraseBubble 2.5s cubic-bezier(0.34,1.56,0.64,1) forwards;
`;
bubble.textContent = content;
} else {
bubble.style.cssText = `
position:absolute;z-index:60;pointer-events:none;
left:${rect.left - wrapRect.left + rect.width / 2}px;
top:${rect.top - wrapRect.top - 10}px;
transform:translate(-50%,-100%);
font-size:36px;
animation:emojiBubble 2s cubic-bezier(0.34,1.56,0.64,1) forwards;
`;
bubble.textContent = content;
}
el.appendChild(bubble);
bubble.addEventListener('animationend', () => bubble.remove());
}
import * as scene from '../../../core/scene.js';
import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js';
import * as store from '../../../core/store.js';
import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js';
export function mountRoom(el, params) {
const { mode = 'menu' } = params || {};
if (mode === 'menu') {
renderMenu(el);
} else if (mode === 'setup') {
renderSetup(el, params);
} else if (mode === 'searching') {
renderSearching(el, params);
}
}
function renderMenu(el) {
el.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:var(--s-4);">
<div style="font-size:24px;font-weight:700;">${t('game.ludo')}</div>
<div style="color:var(--text-secondary);">${t('play.searching')}</div>
<div class="pulse" style="width:60px;height:60px;border-radius:50%;border:3px solid var(--ludo-primary);display:flex;align-items:center;justify-content:center;">${emoji('ludo_hex', '⬡', 32)}</div>
<button class="btn btn-secondary" id="cancel-btn">${t('play.cancel')}</button>
<div class="lr-wrap">
<div class="lr-hero">
<div class="lr-icon">${emoji('dice', '🎲', 56)}</div>
<h1 class="lr-title">لودو</h1>
<p class="lr-subtitle">أول من يوصّل كل قطعه يفوز!</p>
</div>
<div class="lr-buttons">
<button class="lr-btn lr-btn-primary" id="btn-local">
<span class="lr-btn-icon">${emoji('gamepad', '🎮', 22)}</span>
<span class="lr-btn-label">لعب محلي</span>
<span class="lr-btn-desc">اختر عدد اللاعبين والبوتات</span>
</button>
<button class="lr-btn lr-btn-online" id="btn-online">
<span class="lr-btn-icon">${emoji('globe', '🌍', 22)}</span>
<span class="lr-btn-label">أونلاين</span>
<span class="lr-btn-desc">العب ضد لاعبين حقيقيين</span>
</button>
<button class="lr-btn lr-btn-friend" id="btn-friend">
<span class="lr-btn-icon">${emoji('handshake', '🤝', 20)}</span>
<span class="lr-btn-label">تحدي صديق</span>
<span class="lr-btn-desc">ادعُ أصدقاءك للعب</span>
</button>
</div>
<button class="lr-back" id="btn-back">رجوع</button>
</div>
${getStyles()}
`;
el.querySelector('#cancel-btn').addEventListener('click', () => { audio.play('click'); scene.pop(); });
el.querySelector('#btn-local').addEventListener('click', () => {
audio.play('click');
scene.replace('ludo-room', { mode: 'setup', type: 'local' });
});
el.querySelector('#btn-online').addEventListener('click', () => {
audio.play('click');
if (store.get('auth.isGuest')) {
bus.emit('toast', { text: 'سجّل دخولك للعب أونلاين' });
return;
}
scene.replace('ludo-room', { mode: 'setup', type: 'online' });
});
el.querySelector('#btn-friend').addEventListener('click', () => {
audio.play('click');
if (store.get('auth.isGuest')) {
bus.emit('toast', { text: 'سجّل دخولك لتحدي صديق' });
return;
}
scene.push('challenge-friend', { game: 'ludo' });
});
el.querySelector('#btn-back').addEventListener('click', () => {
audio.play('click');
bus.emit('navigate', { world: 'play', scene: 'play-table' });
});
}
function renderSetup(el, params) {
const { type = 'local' } = params;
const isOnline = type === 'online';
el.innerHTML = `
<div class="lr-wrap">
<div class="lr-hero" style="margin-bottom:16px;">
<h2 class="lr-title" style="font-size:20px;">${isOnline ? 'إعدادات الأونلاين' : 'إعدادات اللعب'}</h2>
<p class="lr-subtitle">${isOnline ? 'اختر عدد اللاعبين الحقيقيين' : 'اختر تشكيلة اللاعبين'}</p>
</div>
<!-- Player Count -->
<div class="lr-section">
<div class="lr-section-title">عدد اللاعبين</div>
<div class="lr-grid" id="player-count-grid">
<button class="lr-chip lr-chip-active" data-count="4">
<span class="lr-chip-num">4</span>
<span class="lr-chip-label">كلاسيك</span>
</button>
<button class="lr-chip" data-count="3">
<span class="lr-chip-num">3</span>
<span class="lr-chip-label">ثلاثي</span>
</button>
<button class="lr-chip" data-count="2">
<span class="lr-chip-num">2</span>
<span class="lr-chip-label">مبارزة</span>
</button>
</div>
</div>
<!-- Bot/Human Distribution (local only) -->
${!isOnline ? `
<div class="lr-section" id="distribution-section">
<div class="lr-section-title">توزيعة اللاعبين</div>
<div id="dist-options"></div>
</div>
` : ''}
${!isOnline ? `
<!-- Bot Difficulty -->
<div class="lr-section" id="difficulty-section">
<div class="lr-section-title">مستوى البوت</div>
<div class="lr-grid" id="difficulty-grid">
<button class="lr-chip" data-diff="easy">
<span>😊</span>
<span class="lr-chip-label">سهل</span>
</button>
<button class="lr-chip lr-chip-active" data-diff="medium">
<span>🧐</span>
<span class="lr-chip-label">متوسط</span>
</button>
<button class="lr-chip" data-diff="hard">
<span>🧠</span>
<span class="lr-chip-label">صعب</span>
</button>
</div>
</div>
` : ''}
<!-- Seating Preview -->
<div class="lr-section">
<div class="lr-section-title">ترتيب المقاعد</div>
<div id="seat-preview" class="lr-seat-preview"></div>
</div>
<!-- Start Button -->
<button class="lr-btn lr-btn-start" id="btn-start">
${isOnline ? 'ابحث عن مباراة' : 'ابدأ اللعب'}
</button>
<button class="lr-back" id="btn-back-setup">رجوع</button>
</div>
${getStyles()}
`;
let playerCount = 4;
let humanCount = 1;
let difficulty = 'medium';
// Player count selection
el.querySelectorAll('#player-count-grid .lr-chip').forEach(btn => {
btn.addEventListener('click', () => {
audio.play('click');
el.querySelectorAll('#player-count-grid .lr-chip').forEach(b => b.classList.remove('lr-chip-active'));
btn.classList.add('lr-chip-active');
playerCount = parseInt(btn.dataset.count);
if (!isOnline) updateDistribution();
updateSeatPreview();
});
});
// Difficulty selection (local only)
if (!isOnline) {
el.querySelectorAll('#difficulty-grid .lr-chip').forEach(btn => {
btn.addEventListener('click', () => {
audio.play('click');
el.querySelectorAll('#difficulty-grid .lr-chip').forEach(b => b.classList.remove('lr-chip-active'));
btn.classList.add('lr-chip-active');
difficulty = btn.dataset.diff;
});
});
}
function updateDistribution() {
const container = el.querySelector('#dist-options');
if (!container) return;
const options = [];
for (let h = 1; h <= playerCount; h++) {
const bots = playerCount - h;
const label = h === playerCount
? `${h} لاعبين (بدون بوت)`
: h === 1
? `لاعب واحد + ${bots} بوت`
: `${h} لاعبين + ${bots} بوت`;
options.push({ humans: h, bots, label });
}
container.innerHTML = `<div class="lr-grid">${options.map((opt, i) => `
<button class="lr-chip ${i === 0 ? 'lr-chip-active' : ''}" data-humans="${opt.humans}">
<span class="lr-chip-label">${opt.label}</span>
</button>
`).join('')}</div>`;
humanCount = 1;
container.querySelectorAll('.lr-chip').forEach(btn => {
btn.addEventListener('click', () => {
audio.play('click');
container.querySelectorAll('.lr-chip').forEach(b => b.classList.remove('lr-chip-active'));
btn.classList.add('lr-chip-active');
humanCount = parseInt(btn.dataset.humans);
updateSeatPreview();
// Show/hide difficulty based on whether bots exist
const diffSec = el.querySelector('#difficulty-section');
if (diffSec) diffSec.style.display = (playerCount - humanCount > 0) ? '' : 'none';
});
});
updateSeatPreview();
}
function updateSeatPreview() {
const preview = el.querySelector('#seat-preview');
if (!preview) return;
const seats = getSeatPositions(playerCount);
const colors = ['#E53935', '#43A047', '#FDD835', '#1E88E5'];
const labels = ['أحمر', 'أخضر', 'أصفر', 'أزرق'];
const positions = ['bottom-left', 'top-left', 'top-right', 'bottom-right'];
preview.innerHTML = `
<div class="lr-board-mini">
${[0,1,2,3].map(i => {
const active = seats.includes(i);
const isHuman = active && seats.indexOf(i) < humanCount;
const isBot = active && !isHuman;
return `
<div class="lr-seat lr-seat-${positions[i]} ${active ? 'lr-seat-active' : 'lr-seat-empty'}" style="--seat-color:${colors[i]};">
<div class="lr-seat-dot">${isHuman ? '👤' : isBot ? '🤖' : ''}</div>
<div class="lr-seat-label">${active ? labels[i] : '—'}</div>
</div>
`;
}).join('')}
<div class="lr-board-center">🎲</div>
</div>
`;
}
// Start button
el.querySelector('#btn-start').addEventListener('click', () => {
audio.play('click');
const seats = getSeatPositions(playerCount);
if (isOnline) {
scene.push('play-queue', { game: 'ludo', mode: 'human', playerCount, seats });
} else {
scene.enterGameMode();
scene.replace('ludo-game', {
mode: humanCount > 1 ? 'local-multi' : 'bot',
numPlayers: playerCount,
seats,
humanCount,
difficulty,
playerIndex: 0
});
}
});
el.querySelector('#btn-back-setup').addEventListener('click', () => {
audio.play('click');
scene.replace('ludo-room', { mode: 'menu' });
});
// Initial render
if (!isOnline) updateDistribution();
updateSeatPreview();
}
function getSeatPositions(playerCount) {
if (playerCount === 4) return [0, 1, 2, 3];
if (playerCount === 3) return [0, 1, 3]; // skip Yellow (top-right)
if (playerCount === 2) return [0, 2]; // diagonal: Red(BL) vs Yellow(TR)
return [0, 1, 2, 3];
}
function renderSearching(el, params) {
el.innerHTML = `
<div class="lr-wrap">
<div class="lr-hero">
<div class="lr-pulse-ring">
<div class="lr-pulse-inner">${emoji('dice', '🎲', 32)}</div>
</div>
<h2 class="lr-title" style="font-size:18px;margin-top:20px;">جاري البحث...</h2>
<p class="lr-subtitle">بنوصّلك بلاعبين قريب</p>
</div>
<button class="lr-btn lr-btn-friend" id="btn-cancel" style="max-width:200px;">إلغاء</button>
</div>
${getStyles()}
`;
el.querySelector('#btn-cancel').addEventListener('click', () => {
audio.play('click');
scene.pop();
});
}
function getStyles() {
return `<style>
.lr-wrap {
display:flex;flex-direction:column;align-items:center;justify-content:center;
height:100%;padding:20px;
background:linear-gradient(180deg, #070d14 0%, #0a1420 40%, #0d1a2a 100%);
overflow-y:auto;
}
.lr-hero { text-align:center;margin-bottom:24px; }
.lr-icon { font-size:56px;margin-bottom:12px;animation:lrFloat 3s ease-in-out infinite; }
.lr-title { font-size:26px;font-weight:800;color:#f8fafc;margin:0; }
.lr-subtitle { font-size:13px;color:#E4AC38;margin:8px 0 0;opacity:0.9; }
.lr-buttons { display:flex;flex-direction:column;gap:12px;width:100%;max-width:320px; }
.lr-btn {
display:flex;align-items:center;gap:10px;flex-wrap:wrap;
min-height:56px;border-radius:16px;font-size:16px;font-weight:700;
border:none;cursor:pointer;padding:14px 18px;
transition:transform 0.15s cubic-bezier(0.34,1.56,0.64,1), box-shadow 0.2s;
box-shadow:0 4px 16px rgba(0,0,0,0.3);
}
.lr-btn:active { transform:scale(0.95); }
.lr-btn-primary { background:linear-gradient(135deg,#E4AC38 0%,#d4940a 100%);color:#1a1a1a; }
.lr-btn-online { background:linear-gradient(135deg,#8B5CF6 0%,#7C3AED 100%);color:#fff; }
.lr-btn-friend { background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.1);color:#e2e8f0;min-height:50px;font-size:14px;box-shadow:none; }
.lr-btn-start { width:100%;max-width:320px;justify-content:center;background:linear-gradient(135deg,#10b981,#06b6d4);color:#fff;font-size:17px;font-weight:800;margin-top:16px; }
.lr-btn-icon { display:flex;flex-shrink:0; }
.lr-btn-label { font-weight:700;font-size:16px; }
.lr-btn-desc { width:100%;font-size:11px;opacity:0.7;font-weight:400;margin-top:2px; }
.lr-back { margin-top:16px;font-size:13px;color:#64748b;background:none;border:none;cursor:pointer;padding:8px 16px; }
.lr-section { width:100%;max-width:320px;margin-bottom:16px; }
.lr-section-title { font-size:12px;font-weight:700;color:#94a3b8;margin-bottom:8px;text-align:center; }
.lr-grid { display:flex;gap:8px;justify-content:center;flex-wrap:wrap; }
.lr-chip {
display:flex;align-items:center;gap:6px;
padding:10px 16px;border-radius:12px;
background:rgba(255,255,255,0.04);border:1.5px solid rgba(255,255,255,0.08);
color:#94a3b8;font-size:13px;font-weight:600;cursor:pointer;
transition:all 0.2s cubic-bezier(0.34,1.56,0.64,1);
}
.lr-chip:active { transform:scale(0.95); }
.lr-chip-active { background:rgba(228,172,56,0.1);border-color:rgba(228,172,56,0.4);color:#E4AC38; }
.lr-chip-num { font-size:18px;font-weight:800; }
.lr-chip-label { font-size:12px; }
.lr-seat-preview { display:flex;justify-content:center;margin-top:8px; }
.lr-board-mini {
position:relative;width:140px;height:140px;
background:linear-gradient(135deg,#1a2844,#0f1a30);
border-radius:12px;border:1px solid rgba(255,255,255,0.06);
}
.lr-board-center { position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:24px; }
.lr-seat {
position:absolute;display:flex;flex-direction:column;align-items:center;gap:2px;
transition:all 0.3s cubic-bezier(0.34,1.56,0.64,1);
}
.lr-seat-bottom-left { bottom:8px;left:8px; }
.lr-seat-top-left { top:8px;left:8px; }
.lr-seat-top-right { top:8px;right:8px; }
.lr-seat-bottom-right { bottom:8px;right:8px; }
.lr-seat-dot {
width:28px;height:28px;border-radius:50%;
display:flex;align-items:center;justify-content:center;font-size:14px;
border:2px solid var(--seat-color);
background:rgba(0,0,0,0.3);
transition:all 0.3s;
}
.lr-seat-active .lr-seat-dot { background:color-mix(in srgb, var(--seat-color) 20%, transparent); }
.lr-seat-empty .lr-seat-dot { opacity:0.2;border-style:dashed; }
.lr-seat-label { font-size:9px;color:#64748b; }
.lr-seat-active .lr-seat-label { color:var(--seat-color); }
.lr-pulse-ring {
width:80px;height:80px;border-radius:50%;
border:3px solid rgba(228,172,56,0.4);
display:flex;align-items:center;justify-content:center;
animation:lrPulse 2s ease-in-out infinite;
margin:0 auto;
}
.lr-pulse-inner { font-size:32px; }
@keyframes lrFloat { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-6px)} }
@keyframes lrPulse { 0%,100%{border-color:rgba(228,172,56,0.4);transform:scale(1)} 50%{border-color:rgba(228,172,56,0.7);transform:scale(1.05)} }
</style>`;
}
export function unmountRoom() {}
......@@ -358,6 +358,8 @@ function showGameMenu(menu, game) {
scene.push('play-bot-select', { game: game.key });
} else if (game.key === 'domino') {
scene.push('domino-room', { mode: 'bot-pick' });
} else if (game.key === 'ludo') {
scene.push('ludo-room', { mode: 'setup', type: 'local' });
} else {
const gameScene = game.key + '-game';
scene.push(gameScene, { mode: 'bot', game: game.key });
......@@ -380,6 +382,8 @@ function showGameMenu(menu, game) {
menu.classList.add('hidden');
if (game.key === 'chess') {
scene.push('play-time-select', { game: game.key, mode: 'human' });
} else if (game.key === 'ludo') {
scene.push('ludo-room', { mode: 'setup', type: 'online' });
} else {
scene.push('play-queue', { game: game.key, mode: 'human', timeControl: 'standard' });
}
......
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