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
This diff is collapsed.
This diff is collapsed.
......@@ -92,7 +92,7 @@ function startPolling() {
if (currentSession.isBackground) return; // Don't poll when tab is hidden
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, {
action: 'get',
match_id: currentSession.matchId
......@@ -122,7 +122,7 @@ function startPinging() {
currentSession.pingTimer = setInterval(async () => {
if (!currentSession || !currentSession.isActive) return;
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, {
action: 'move',
match_id: currentSession.matchId,
......@@ -166,7 +166,7 @@ function setupVisibilityHandler() {
// Immediately fetch latest state
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 })
.then(data => {
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) {
});
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 { mountGame } from './scenes/game.js';
import { mountGame, unmountGame } 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('domino-game', mountGame);
scene.register('domino-game', mountGame, unmountGame);
scene.register('domino-result', mountResult);
scene.register('domino-room', mountRoom);
scene.register('domino-room', mountRoom, unmountRoom);
This diff is collapsed.
import * as scene from '../../../core/scene.js';
import * as bus from '../../../core/bus.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 { emoji } from '../../../core/theme.js';
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 icon = isWin ? emoji('trophy', '🏆', 64) : emoji('skull', '💀', 64);
const title = isWin ? t('game.you_win') : t('game.you_lose');
const color = isWin ? 'var(--win)' : 'var(--loss)';
const isDraw = result === 'draw';
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 = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:var(--s-6);padding:var(--s-6);">
<div style="font-size:64px;animation:float 2s ease-in-out infinite;">${icon}</div>
<div style="font-size:28px;font-weight:800;color:${color};">${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);">
<button class="btn btn-primary" id="btn-again">${t('game.rematch')}</button>
<button class="btn btn-secondary" id="btn-back">${t('game.back')}</button>
<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 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:${titleColor};animation:fadeSlideUp 0.4s ease 0.2s both;">${title}</div>
<!-- Score summary -->
<div style="display:flex;gap:20px;align-items:center;animation:fadeSlideUp 0.4s ease 0.3s both;">
<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>
<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');
scene.replace('domino-game', { mode: params.mode });
scene.replace('domino-game', { mode });
});
el.querySelector('#btn-back').addEventListener('click', () => {
......@@ -33,3 +87,58 @@ export function mountResult(el, params) {
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 bus from '../../../core/bus.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 { emoji } from '../../../core/theme.js';
let pollTimer = null;
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 = `
<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:48px;">${emoji('domino_tile', '🁣', 48)}</div>
<div style="font-size:22px;font-weight:800;color:#f0fdf4;">دومينو</div>
<div style="font-size:13px;color:#86efac;text-align:center;max-width:260px;">أول من يوصل 100 نقطة يفوز!</div>
<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>
`;
el.querySelector('#btn-bot').addEventListener('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:var(--s-4);">
<div style="font-size:24px;font-weight:700;">${t('game.domino')}</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(--domino-primary);display:flex;align-items:center;justify-content:center;">${emoji('domino_tile', '⬚', 32)}</div>
<button class="btn btn-secondary" id="cancel-btn">${t('play.cancel')}</button>
<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>
`;
el.querySelector('#cancel-btn').addEventListener('click', () => {
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();
});
}
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) {
<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" 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>
<!-- Time control -->
......@@ -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'); });
btn.style.background = '#2563EB'; btn.style.borderColor = '#2563EB'; btn.style.color = '#fff'; btn.classList.add('active');
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) {
net.post('chat.php', {
action: 'send',
friend_id: targetId,
content: `أرسل تحدي ${selectedGame === 'ludo' ? 'لودو' : 'شطرنج'}`,
content: `أرسل تحدي ${selectedGame === 'ludo' ? 'لودو' : selectedGame === 'domino' ? 'دومينو' : 'شطرنج'}`,
message_type: 'invite',
metadata: { game_key: selectedGame, time_control: selectedTc, match_id: res.match_id }
}).catch(() => {});
......
......@@ -43,7 +43,7 @@ export function mountLobby(el, params = {}) {
<span style="font-size:20px;">${gameIcon}</span>
<span style="font-size:13px;font-weight:600;">${gameLabel}</span>
</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>
<!-- Players -->
......@@ -133,9 +133,11 @@ export function mountLobby(el, params = {}) {
async function pollMatchStatus(el, params) {
if (!matchId) return;
const gameKey = params.gameKey || 'chess';
const endpoint = gameKey === 'ludo' ? 'ludo-match.php' : gameKey === 'domino' ? 'domino-match.php' : 'game.php';
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.status === 'in_progress') {
......@@ -181,6 +183,13 @@ function startGame(el, params) {
matchId: params.matchId,
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) {
}
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 {
const data = await net.post(endpoint, {
action: 'queue',
......@@ -64,7 +64,7 @@ async function joinQueue(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 () => {
try {
const data = await net.post(endpoint, { action: 'status', game_key: params.game });
......
......@@ -330,6 +330,8 @@ function showGameMenu(menu, game) {
menu.classList.add('hidden');
if (game.key === 'chess') {
scene.push('play-bot-select', { game: game.key });
} else if (game.key === 'domino') {
scene.push('domino-room', { mode: 'bot-pick' });
} else {
const gameScene = game.key + '-game';
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