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);
This diff is collapsed.
This diff is collapsed.
......@@ -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