Commit 0bc5a2fb authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: domino visual overhaul + tap-to-confirm + pan/zoom

Theme:
- Replace green felt table with dark navy (#0f1623) matching app identity
- Gold accent (#E4AC38) replaces green for highlights and UI elements
- Tiles have warm ivory (#F5F0E8) with subtle shadows on dark bg
- All UI bars/controls use app's #0f0f1e background

Layout engine rewrite:
- Chain starts from center, grows outward naturally
- Proper double placement (perpendicular to chain direction)
- Non-doubles align correctly with chain direction
- Snake path with smooth turns at virtual bounds

Interaction (tap-to-confirm):
- Tap tile → highlights valid endpoints on board
- Tap highlighted endpoint → confirms placement
- NEVER auto-places on tap — always requires explicit destination
- Tap same tile again → deselects it
- Status shows "اختر مكان الوضع" when tile is selected

Board improvements:
- Pan gesture: drag on empty board area to scroll
- Auto-zoom: smoothly zooms out as chain grows large
- Endpoint glow uses gold dashed-ring indicator
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent c8ffafd0
import { createCanvas, clear } from '../../../core/canvas.js';
import { computeLayout, hitTestEndpoint, getSnapRadius } from '../logic/layout.js';
import { computeLayout, hitTestEndpoint, getSnapRadius, getAutoZoom } from '../logic/layout.js';
import { drawTile, drawEndpointGlow } from './tile-renderer.js';
import { TILE_W, TILE_H, DOUBLE_W, DOUBLE_H } from '../logic/layout.js';
import { TILE_W, TILE_H, DOUBLE_SIZE } from '../logic/layout.js';
export class DominoBoard {
constructor(container, options = {}) {
......@@ -10,7 +10,7 @@ export class DominoBoard {
const rect = container.getBoundingClientRect();
this.width = rect.width || 370;
this.height = rect.height || 360;
this.height = rect.height || 400;
const { canvas, ctx } = createCanvas(container, this.width, this.height);
canvas.style.cssText = 'width:100%;height:100%;border-radius:12px;touch-action:none;';
......@@ -18,29 +18,61 @@ export class DominoBoard {
this.ctx = ctx;
this.chain = [];
this.layout = { tiles: [], endpoints: { left: null, right: null } };
this.layout = { tiles: [], endpoints: { left: null, right: null }, bounds: {} };
this.ghost = null;
this.activeEndpoint = null;
this.panOffset = { x: 0, y: 0 };
this.animatingTile = null;
this._setupPanListener();
// Pan & zoom state
this.pan = { x: 0, y: 0 };
this.zoom = 1;
this.targetZoom = 1;
this._zoomAnimFrame = null;
this._setupInteraction();
}
setChain(chain) {
this.chain = chain;
this.layout = computeLayout(chain, this.width, this.height);
this._autoZoom();
this.draw();
}
_autoZoom() {
if (this.chain.length < 4) {
this.targetZoom = 1;
} else {
const bounds = this.layout.bounds;
this.targetZoom = getAutoZoom(bounds, this.width, this.height);
}
this._animateZoom();
}
_animateZoom() {
if (this._zoomAnimFrame) cancelAnimationFrame(this._zoomAnimFrame);
const animate = () => {
const diff = this.targetZoom - this.zoom;
if (Math.abs(diff) < 0.005) {
this.zoom = this.targetZoom;
this.draw();
return;
}
this.zoom += diff * 0.08;
this.draw();
this._zoomAnimFrame = requestAnimationFrame(animate);
};
animate();
}
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;
const w = dbl ? DOUBLE_SIZE : TILE_H;
const h = dbl ? DOUBLE_SIZE : TILE_W;
this.ghost = { tile, x: ep.x, y: ep.y, w, h, rotation: 90, valid };
this.draw();
......@@ -67,7 +99,11 @@ export class DominoBoard {
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 };
const cx = this.width / 2 + this.pan.x;
const cy = this.height / 2 + this.pan.y;
const sx = (ep.x - cx) * this.zoom + this.width / 2;
const sy = (ep.y - cy) * this.zoom + this.height / 2;
return { x: rect.left + sx * scaleX, y: rect.top + sy * scaleY, value: ep.value };
}
hitTestEndpoints(screenX, screenY) {
......@@ -75,11 +111,18 @@ export class DominoBoard {
const canvasX = (screenX - rect.left) * (this.width / rect.width);
const canvasY = (screenY - rect.top) * (this.height / rect.height);
// Convert screen coords to world coords (accounting for pan/zoom)
const cx = this.width / 2 + this.pan.x;
const cy = this.height / 2 + this.pan.y;
const worldX = (canvasX - this.width / 2) / this.zoom + cx;
const worldY = (canvasY - this.height / 2) / this.zoom + cy;
const leftEp = this.layout.endpoints.left;
const rightEp = this.layout.endpoints.right;
const radius = getSnapRadius() / this.zoom;
if (leftEp && hitTestEndpoint(canvasX, canvasY, leftEp)) return { end: 'left', value: leftEp.value };
if (rightEp && hitTestEndpoint(canvasX, canvasY, rightEp)) return { end: 'right', value: rightEp.value };
if (leftEp && dist(worldX, worldY, leftEp.x, leftEp.y) < radius) return { end: 'left', value: leftEp.value };
if (rightEp && dist(worldX, worldY, rightEp.x, rightEp.y) < radius) return { end: 'right', value: rightEp.value };
return null;
}
......@@ -88,17 +131,16 @@ export class DominoBoard {
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 w = dbl ? DOUBLE_SIZE : TILE_H;
const h = dbl ? DOUBLE_SIZE : TILE_W;
const startScale = 1.3;
const duration = 180;
const duration = 200;
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;
const scale = 1.3 + (1 - 1.3) * ease;
this.animatingTile = { tile, x: ep.x, y: ep.y, w, h, rotation: 90, scale };
this.draw();
......@@ -117,61 +159,82 @@ export class DominoBoard {
const ctx = this.ctx;
clear(ctx, this.width, this.height);
ctx.fillStyle = '#0d3815';
// Dark background matching app theme
ctx.fillStyle = '#0f1623';
ctx.fillRect(0, 0, this.width, this.height);
this._drawFelt(ctx);
// Subtle pattern
this._drawPattern(ctx);
// Apply pan/zoom transform
ctx.save();
const cx = this.width / 2;
const cy = this.height / 2;
ctx.translate(cx, cy);
ctx.scale(this.zoom, this.zoom);
ctx.translate(-cx - this.pan.x, -cy - this.pan.y);
if (this.chain.length === 0) {
ctx.fillStyle = 'rgba(255,255,255,0.12)';
ctx.font = '600 14px system-ui, sans-serif';
// Empty board indicator
ctx.fillStyle = 'rgba(255,255,255,0.08)';
ctx.font = '600 13px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('ضع أول قطعة', this.width / 2, this.height / 2);
this._drawEndpointIndicators(ctx);
this._drawCenterGlow(ctx);
ctx.restore();
return;
}
// Draw chain tiles
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 });
drawTile(ctx, lt.x, lt.y, lt.w, lt.h, lt.tile, { rotation: lt.rotation });
}
// Draw endpoint indicators (when active for tap-to-confirm)
this._drawEndpointIndicators(ctx);
// Ghost tile (during drag)
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 });
this.ghost.tile, { rotation: this.ghost.rotation, ghost: true, invalid: !this.ghost.valid });
}
// Animating tile
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 });
drawTile(ctx, at.x, at.y, at.w, at.h, at.tile, { rotation: at.rotation });
ctx.restore();
}
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);
_drawPattern(ctx) {
// Subtle grid dots
ctx.fillStyle = 'rgba(255,255,255,0.02)';
for (let i = 0; i < this.width; i += 20) {
for (let j = 0; j < this.height; j += 20) {
ctx.beginPath();
ctx.arc(i, j, 1, 0, Math.PI * 2);
ctx.fill();
}
}
}
_drawCenterGlow(ctx) {
const x = this.width / 2, y = this.height / 2;
drawEndpointGlow(ctx, x, y, getSnapRadius(), true);
}
_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);
}
......@@ -180,30 +243,60 @@ export class DominoBoard {
}
}
_setupPanListener() {
_setupInteraction() {
let startX, startY, isPanning = false;
let lastPanX = 0, lastPanY = 0;
this.canvas.addEventListener('pointerdown', (e) => {
if (this.onEndpointTap) {
// Check endpoint tap first
if (this.activeEndpoint && this.onEndpointTap) {
const hit = this.hitTestEndpoints(e.clientX, e.clientY);
if (hit) { this.onEndpointTap(hit); return; }
if (hit) {
this.onEndpointTap(hit);
return;
}
}
startX = e.clientX;
startY = e.clientY;
lastPanX = this.pan.x;
lastPanY = this.pan.y;
isPanning = false;
e.preventDefault();
});
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;
if (Math.abs(dx) + Math.abs(dy) > 8) {
isPanning = true;
this.pan.x = lastPanX - dx / this.zoom;
this.pan.y = lastPanY - dy / this.zoom;
this.draw();
}
});
this.canvas.addEventListener('pointerup', (e) => {
if (!isPanning && startX !== undefined && this.onEndpointTap) {
const hit = this.hitTestEndpoints(e.clientX, e.clientY);
if (hit) this.onEndpointTap(hit);
}
startX = undefined;
isPanning = false;
});
this.canvas.addEventListener('pointerup', () => { startX = undefined; });
this.canvas.addEventListener('pointercancel', () => {
startX = undefined;
isPanning = false;
});
}
destroy() {
if (this._zoomAnimFrame) cancelAnimationFrame(this._zoomAnimFrame);
this.canvas.remove();
}
}
function dist(x1, y1, x2, y2) {
return Math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2);
}
const TILE_BG = '#FFFFF0';
const TILE_BORDER = '#444';
const TILE_BG = '#F5F0E8';
const TILE_BORDER = '#8B7355';
const PIP_COLOR = '#1a1a1a';
const DOUBLE_ACCENT = '#e8e0c8';
const DOUBLE_ACCENT = '#EDE5D4';
const PIP_POSITIONS = {
0: [],
......@@ -19,90 +19,76 @@ export function drawTile(ctx, x, y, w, h, tile, options = {}) {
ctx.save();
ctx.translate(x, y);
ctx.rotate((rotation * Math.PI) / 180);
ctx.globalAlpha = ghost ? 0.4 : alpha;
ctx.globalAlpha = ghost ? 0.35 : alpha;
const hw = w / 2;
const hh = h / 2;
const r = 4;
const r = 3;
// Shadow for non-ghost tiles
if (!ghost) {
ctx.shadowColor = 'rgba(0,0,0,0.4)';
ctx.shadowBlur = 4;
ctx.shadowOffsetY = 2;
}
if (glow || highlight) {
ctx.shadowColor = invalid ? 'rgba(239,68,68,0.6)' : 'rgba(16,185,129,0.6)';
ctx.shadowBlur = 12;
ctx.shadowColor = invalid ? 'rgba(239,68,68,0.7)' : 'rgba(228,172,56,0.7)';
ctx.shadowBlur = 14;
}
// Tile body
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.fillStyle = ghost ? (invalid ? 'rgba(239,68,68,0.15)' : 'rgba(228,172,56,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;
ctx.shadowOffsetY = 0;
ctx.strokeStyle = ghost ? (invalid ? '#ef4444' : '#E4AC38') : TILE_BORDER;
ctx.lineWidth = ghost ? 1.5 : 1;
ctx.stroke();
if (!ghost) {
const isDouble = tile.left === tile.right;
const isHorizontal = w > h;
if (isHorizontal) {
if (isDouble) {
ctx.fillStyle = DOUBLE_ACCENT;
ctx.beginPath();
ctx.moveTo(0, -hh + 3);
ctx.lineTo(0, hh - 3);
ctx.strokeStyle = '#aaa';
ctx.roundRect(-hw + 1, -hh + 1, w - 2, h - 2, r - 1);
ctx.fill();
ctx.strokeStyle = TILE_BORDER;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(-hw, -hh, w, h, r);
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);
if (isHorizontal) {
// Divider line (vertical center)
ctx.beginPath();
ctx.moveTo(0, -hh + 2);
ctx.lineTo(0, hh - 2);
ctx.strokeStyle = '#B8A88A';
ctx.lineWidth = 0.8;
ctx.stroke();
const spread = Math.min(hw * 0.32, 6);
const pipR = Math.min(hw * 0.07, 2.5);
drawPips(ctx, -hw / 2, 0, spread, pipR, tile.left);
drawPips(ctx, hw / 2, 0, spread, pipR, tile.right);
} else {
// Divider line (horizontal center)
ctx.beginPath();
ctx.moveTo(-hw + 3, 0);
ctx.lineTo(hw - 3, 0);
ctx.strokeStyle = '#aaa';
ctx.lineWidth = 1;
ctx.moveTo(-hw + 2, 0);
ctx.lineTo(hw - 2, 0);
ctx.strokeStyle = '#B8A88A';
ctx.lineWidth = 0.8;
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);
const spread = Math.min(hh * 0.32, 6);
const pipR = Math.min(hh * 0.07, 2.5);
drawPips(ctx, 0, -hh / 2, spread, pipR, tile.left);
drawPips(ctx, 0, hh / 2, spread, pipR, tile.right);
}
......@@ -122,10 +108,10 @@ function drawPips(ctx, cx, cy, spread, radius, value) {
}
export function drawEndpointGlow(ctx, x, y, radius, valid) {
const color = valid ? 'rgba(16,185,129,0.5)' : 'rgba(239,68,68,0.4)';
const color = valid ? 'rgba(228,172,56,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(0.5, valid ? 'rgba(228,172,56,0.15)' : 'rgba(239,68,68,0.1)');
gradient.addColorStop(1, 'transparent');
ctx.beginPath();
......@@ -133,10 +119,20 @@ export function drawEndpointGlow(ctx, x, y, radius, valid) {
ctx.fillStyle = gradient;
ctx.fill();
// Center dot
ctx.beginPath();
ctx.arc(x, y, 8, 0, Math.PI * 2);
ctx.fillStyle = valid ? '#10b981' : '#ef4444';
ctx.globalAlpha = 0.7;
ctx.arc(x, y, 6, 0, Math.PI * 2);
ctx.fillStyle = valid ? '#E4AC38' : '#ef4444';
ctx.globalAlpha = 0.8;
ctx.fill();
ctx.globalAlpha = 1;
// Pulsing ring
ctx.beginPath();
ctx.arc(x, y, radius * 0.6, 0, Math.PI * 2);
ctx.strokeStyle = valid ? 'rgba(228,172,56,0.3)' : 'rgba(239,68,68,0.3)';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 4]);
ctx.stroke();
ctx.setLineDash([]);
}
......@@ -13,8 +13,8 @@ export class DominoHand {
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);
background:#0f0f1e;
border-top:1px solid rgba(255,255,255,0.06);
min-height:96px;align-items:center;justify-content:center;
flex-wrap:wrap;scroll-snap-type:x proximity;
-webkit-overflow-scrolling:touch;
......@@ -120,8 +120,8 @@ export class DominoHand {
return `
.dh-tile {
width:40px;height:70px;
background:#fffff0;
border:2px solid #555;
background:#F5F0E8;
border:2px solid rgba(255,255,255,0.15);
border-radius:6px;
display:flex;flex-direction:column;
align-items:center;justify-content:space-between;
......@@ -129,29 +129,30 @@ export class DominoHand {
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;
opacity:0.4;
user-select:none;
touch-action:none;
flex-shrink:0;
box-shadow:0 2px 6px rgba(0,0,0,0.3);
}
.dh-tile.dh-playable {
opacity:1;
border-color:#4ade80;
box-shadow:0 0 8px rgba(74,222,128,0.25);
border-color:#E4AC38;
box-shadow:0 0 8px rgba(228,172,56,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);
transform:translateY(-12px) scale(1.08);
border-color:#E4AC38;
box-shadow:0 6px 20px rgba(228,172,56,0.5);
}
@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); }
0%,100% { box-shadow:0 0 6px rgba(228,172,56,0.2); }
50% { box-shadow:0 0 14px rgba(228,172,56,0.45); }
}
.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-divider { width:80%;height:1px;background:#B8A88A;flex-shrink:0; }
.dh-top, .dh-bottom {
display:flex;flex-wrap:wrap;
width:22px;height:22px;
......
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 }
// Domino chain layout engine
// Chain starts from center, grows outward. Turns when hitting bounds.
// Doubles are perpendicular (crosswise). Non-doubles follow chain direction.
export const TILE_W = 24;
export const TILE_H = 48;
export const DOUBLE_SIZE = 24;
export const GAP = 2;
const MARGIN = 20;
// Direction vectors for chain growth
const DIRS = [
{ dx: 1, dy: 0 }, // right
{ dx: 0, dy: 1 }, // down
{ dx: -1, dy: 0 }, // left
{ dx: 0, dy: -1 }, // up
];
export function computeLayout(chain, canvasW, canvasH) {
if (chain.length === 0) return { tiles: [], endpoints: { left: null, right: null } };
if (chain.length === 0) {
return {
tiles: [],
endpoints: { left: null, right: null },
bounds: { minX: 0, minY: 0, maxX: canvasW, maxY: canvasH }
};
}
const tiles = [];
let dirIdx = 0;
let cx = canvasW / 2;
let cy = canvasH / 2;
let dirIdx = 0; // start going right
let cx = 0, cy = 0; // build from origin, center later
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;
const piece = chain[i];
const isDouble = piece.left === piece.right;
const dir = DIRS[dirIdx];
const isHorizontal = dir.dx !== 0;
// Tile dimensions based on direction
let w, h, rotation;
if (isDouble) {
// Doubles are perpendicular to chain direction
if (isHorizontal) {
w = DOUBLE_SIZE;
h = TILE_H;
rotation = 0; // vertical (perpendicular to horizontal chain)
} else {
w = TILE_H;
h = DOUBLE_SIZE;
rotation = 90; // horizontal (perpendicular to vertical chain)
}
} else {
cx = nextX;
cy = nextY;
if (isHorizontal) {
w = TILE_H;
h = TILE_W;
rotation = 90; // landscape along horizontal chain
} else {
w = TILE_W;
h = TILE_H;
rotation = 0; // portrait along vertical chain
}
}
tiles.push({ tile: piece, x: cx, y: cy, w, h, rotation, isDouble, dirIdx });
// Advance position for next tile
if (i < chain.length - 1) {
const nextPiece = chain[i + 1];
const nextIsDouble = nextPiece.left === nextPiece.right;
// Current tile's half-extent in chain direction
const curExtent = isDouble
? (isHorizontal ? DOUBLE_SIZE / 2 : DOUBLE_SIZE / 2)
: (isHorizontal ? TILE_H / 2 : TILE_H / 2);
// Next tile's half-extent in chain direction
const nextExtent = nextIsDouble
? (isHorizontal ? DOUBLE_SIZE / 2 : DOUBLE_SIZE / 2)
: (isHorizontal ? TILE_H / 2 : TILE_H / 2);
const advance = curExtent + GAP + nextExtent;
let nextX = cx + dir.dx * advance;
let nextY = cy + dir.dy * advance;
// Check if next position would exceed "virtual" bounds
// Use a generous virtual canvas for the snake path
const virtualW = canvasW * 3;
const virtualH = canvasH * 3;
const halfVW = virtualW / 2;
const halfVH = virtualH / 2;
if (Math.abs(nextX) > halfVW - MARGIN || Math.abs(nextY) > halfVH - MARGIN) {
// Turn clockwise
dirIdx = (dirIdx + 1) % 4;
const newDir = DIRS[dirIdx];
const turnOffset = (isHorizontal ? TILE_W : TILE_W) + GAP * 2;
cx += newDir.dx * turnOffset;
cy += newDir.dy * turnOffset;
} 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;
// Compute bounds
const bounds = computeBounds(tiles);
// Center the chain in the canvas
const chainW = bounds.maxX - bounds.minX;
const chainH = bounds.maxY - bounds.minY;
const offsetX = canvasW / 2 - (bounds.minX + chainW / 2);
const offsetY = canvasH / 2 - (bounds.minY + chainH / 2);
tiles.forEach(t => { t.x += offsetX; t.y += offsetY; });
// Recompute bounds after centering
const finalBounds = computeBounds(tiles);
// Compute endpoints (where next tile can be placed)
const endpoints = {
left: tiles.length > 0 ? getEndpointPos(tiles[0], 'left', chain) : null,
right: tiles.length > 0 ? getEndpointPos(tiles[tiles.length - 1], 'right', chain) : null
left: computeEndpoint(tiles, 0, chain, 'left'),
right: computeEndpoint(tiles, tiles.length - 1, chain, 'right')
};
return { tiles, endpoints };
return { tiles, endpoints, bounds: finalBounds };
}
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) {
function computeBounds(tiles) {
if (tiles.length === 0) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
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);
const hw = t.w / 2, hh = t.h / 2;
minX = Math.min(minX, t.x - hw);
minY = Math.min(minY, t.y - hh);
maxX = Math.max(maxX, t.x + hw);
maxY = Math.max(maxY, t.y + hh);
}
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;
function computeEndpoint(tiles, tileIdx, chain, side) {
const lt = tiles[tileIdx];
if (!lt) return null;
if (rot === 90) {
ex = t.x + dir * (t.w / 2 + 14);
ey = t.y;
const dir = DIRS[lt.dirIdx];
const isHorizontal = dir.dx !== 0;
const sign = side === 'left' ? -1 : 1;
// Endpoint is beyond the tile in the chain direction (or opposite for left end)
const extent = lt.isDouble
? (isHorizontal ? DOUBLE_SIZE / 2 : DOUBLE_SIZE / 2)
: (isHorizontal ? TILE_H / 2 : TILE_H / 2);
// For the left endpoint, we go opposite to the first tile's direction
let ex, ey;
if (side === 'left') {
const firstDir = DIRS[tiles[0].dirIdx];
ex = lt.x - firstDir.dx * (extent + 16);
ey = lt.y - firstDir.dy * (extent + 16);
} else {
ex = t.x;
ey = t.y + dir * (t.h / 2 + 14);
ex = lt.x + dir.dx * (extent + 16);
ey = lt.y + dir.dy * (extent + 16);
}
const endValue = side === 'left' ? chain[0].left : chain[chain.length - 1].right;
return { x: ex, y: ey, value: endValue };
return { x: ex, y: ey, value: endValue, end: side };
}
export function getSnapRadius() { return 55; }
export function getSnapRadius() { return 50; }
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();
const dy = px - endpoint.y;
return (dx * dx + (py - endpoint.y) * (py - endpoint.y)) < getSnapRadius() * getSnapRadius();
}
export { TILE_W, TILE_H, DOUBLE_W, DOUBLE_H, GAP };
export function getAutoZoom(bounds, canvasW, canvasH) {
const chainW = bounds.maxX - bounds.minX + 60;
const chainH = bounds.maxY - bounds.minY + 60;
const scaleX = canvasW / chainW;
const scaleY = canvasH / chainH;
const needed = Math.min(scaleX, scaleY);
return Math.min(1, Math.max(0.3, needed));
}
......@@ -100,20 +100,20 @@ function dealNewRound() {
function buildLayout(mode) {
const isLive = mode === 'live';
return `
<div id="domino-wrap" style="display:flex;flex-direction:column;height:100%;background:#081a0c;position:relative;">
<div id="domino-wrap" style="display:flex;flex-direction:column;height:100%;background:#0a0a14;position:relative;">
<!-- Opponent bar -->
<div id="domino-opp-bar" style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:#061408;border-bottom:1px solid rgba(255,255,255,0.06);">
<div id="domino-opp-bar" style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:#0f0f1e;border-bottom:1px solid rgba(255,255,255,0.06);">
<div style="display:flex;align-items:center;gap:8px;">
<div id="opp-avatar" style="width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,#10b981,#06b6d4);display:flex;align-items:center;justify-content:center;font-size:14px;">${isLive ? '👤' : '🤖'}</div>
<div id="opp-avatar" style="width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,#E4AC38,#F59E0B);display:flex;align-items:center;justify-content:center;font-size:14px;">${isLive ? '👤' : '🤖'}</div>
<div>
<div id="opp-name" style="font-size:13px;font-weight:700;color:#f0fdf4;">${isLive ? 'خصم' : 'بوت'}</div>
<div id="opp-count" style="font-size:11px;color:#86efac;">7 قطع</div>
<div id="opp-name" style="font-size:13px;font-weight:700;color:#f8fafc;">${isLive ? 'خصم' : 'بوت'}</div>
<div id="opp-count" style="font-size:11px;color:#94a3b8;">7 قطع</div>
</div>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<div id="bot-thinking" style="font-size:11px;color:#fbbf24;display:none;">يفكر...</div>
<div id="bot-thinking" style="font-size:11px;color:#E4AC38;display:none;">يفكر...</div>
${isLive ? '<div id="conn-dot" style="width:8px;height:8px;border-radius:50%;background:#4ade80;"></div>' : ''}
<div id="boneyard-count" style="font-size:11px;color:#6ee7b7;background:rgba(16,185,129,0.1);padding:4px 10px;border-radius:8px;">المخزن: 14</div>
<div id="boneyard-count" style="font-size:11px;color:#94a3b8;background:rgba(255,255,255,0.05);padding:4px 10px;border-radius:8px;border:1px solid rgba(255,255,255,0.06);">المخزن: 14</div>
</div>
</div>
......@@ -121,31 +121,31 @@ function buildLayout(mode) {
<div id="emote-display" style="position:absolute;top:56px;left:50%;transform:translateX(-50%);z-index:50;pointer-events:none;"></div>
<!-- Canvas board -->
<div id="domino-board" style="flex:1;min-height:0;padding:6px;"></div>
<div id="domino-board" style="flex:1;min-height:0;padding:4px;"></div>
<!-- Score bar -->
<div id="score-bar" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:6px 12px;background:#061408;">
<span style="font-size:12px;color:#86efac;">أنت: <b id="my-score">0</b></span>
<span style="font-size:11px;color:#475569;">|</span>
<span style="font-size:12px;color:#fca5a5;">خصم: <b id="opp-score">0</b></span>
<span style="font-size:11px;color:#475569;">|</span>
<span style="font-size:11px;color:#fbbf24;">${emoji('target', '🎯', 12)} 100</span>
<span style="font-size:11px;color:#475569;">|</span>
<span style="font-size:11px;color:#94a3b8;">ج<span id="round-num">1</span></span>
<div id="score-bar" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:6px 12px;background:#0f0f1e;border-top:1px solid rgba(255,255,255,0.04);">
<span style="font-size:12px;color:#E4AC38;">أنت: <b id="my-score">0</b></span>
<span style="font-size:11px;color:#334155;">|</span>
<span style="font-size:12px;color:#94a3b8;">خصم: <b id="opp-score">0</b></span>
<span style="font-size:11px;color:#334155;">|</span>
<span style="font-size:11px;color:#64748b;">${emoji('target', '🎯', 12)} 100</span>
<span style="font-size:11px;color:#334155;">|</span>
<span style="font-size:11px;color:#64748b;">ج<span id="round-num">1</span></span>
</div>
<!-- Status -->
<div id="turn-status" style="text-align:center;padding:4px;font-size:13px;font-weight:600;color:#4ade80;">دورك!</div>
<div id="turn-status" style="text-align:center;padding:5px;font-size:13px;font-weight:600;color:#E4AC38;">دورك!</div>
<!-- Hand area -->
<div id="domino-hand-area"></div>
<!-- Controls -->
<div id="domino-controls" style="display:flex;gap:6px;padding:8px 12px;padding-bottom:calc(8px + env(safe-area-inset-bottom,0px));background:#061408;border-top:1px solid rgba(255,255,255,0.06);">
<button class="btn btn-secondary" id="btn-resign" style="flex:0.6;font-size:11px;min-height:44px;background:rgba(239,68,68,0.1);border-color:rgba(239,68,68,0.3);color:#fca5a5;border-radius:12px;">استسلام</button>
<button class="btn btn-secondary" id="btn-emote" style="flex:0.5;font-size:16px;min-height:44px;background:rgba(255,255,255,0.05);border-color:rgba(255,255,255,0.1);border-radius:12px;">😄</button>
<button class="btn btn-secondary" id="btn-draw" style="flex:1;font-size:13px;min-height:44px;background:rgba(16,185,129,0.15);border-color:rgba(16,185,129,0.3);color:#6ee7b7;border-radius:12px;font-weight:700;">سحب</button>
<button class="btn btn-secondary" id="btn-pass" style="flex:0.7;font-size:12px;min-height:44px;background:rgba(251,191,36,0.1);border-color:rgba(251,191,36,0.3);color:#fde68a;border-radius:12px;display:none;">تمرير</button>
<div id="domino-controls" style="display:flex;gap:6px;padding:8px 12px;padding-bottom:calc(8px + env(safe-area-inset-bottom,0px));background:#0f0f1e;border-top:1px solid rgba(255,255,255,0.06);">
<button class="btn btn-secondary" id="btn-resign" style="flex:0.6;font-size:11px;min-height:44px;background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.2);color:#fca5a5;border-radius:12px;">استسلام</button>
<button class="btn btn-secondary" id="btn-emote" style="flex:0.5;font-size:16px;min-height:44px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);border-radius:12px;">😄</button>
<button class="btn btn-secondary" id="btn-draw" style="flex:1;font-size:13px;min-height:44px;background:rgba(228,172,56,0.1);border:1px solid rgba(228,172,56,0.25);color:#E4AC38;border-radius:12px;font-weight:700;">سحب</button>
<button class="btn btn-secondary" id="btn-pass" style="flex:0.7;font-size:12px;min-height:44px;background:rgba(251,191,36,0.08);border:1px solid rgba(251,191,36,0.2);color:#fde68a;border-radius:12px;display:none;">تمرير</button>
</div>
</div>
`;
......@@ -157,6 +157,7 @@ function injectStyles(el) {
@keyframes fadeIn { from{opacity:0} to{opacity:1} }
@keyframes emoteFloat { 0%{transform:translateX(-50%) scale(0);opacity:0} 20%{transform:translateX(-50%) scale(1.2);opacity:1} 80%{opacity:1} 100%{transform:translateX(-50%) translateY(-20px);opacity:0} }
.emote-bubble { animation: emoteFloat 2s ease forwards; position:absolute;left:50%;font-size:36px; }
@keyframes drawPulse { 0%,100%{box-shadow:0 0 4px rgba(228,172,56,0.2)} 50%{box-shadow:0 0 14px rgba(228,172,56,0.5)} }
`;
el.appendChild(style);
}
......@@ -455,8 +456,8 @@ function showEmoteMenu(el) {
menu.style.cssText = `
position:absolute;bottom:110px;left:50%;transform:translateX(-50%);
display:flex;gap:6px;padding:10px 14px;background:#1a1a2e;
border-radius:16px;border:1px solid rgba(255,255,255,0.1);
box-shadow:0 8px 24px rgba(0,0,0,0.5);z-index:60;
border-radius:16px;border:1px solid rgba(228,172,56,0.15);
box-shadow:0 8px 24px rgba(0,0,0,0.6);z-index:60;
animation:fadeIn 0.2s ease;
`;
menu.innerHTML = emotes.map(e => `
......@@ -502,25 +503,38 @@ function handleTileSelect(el, tile) {
const playEnd = rules.determinePlayEnd(tile, state.leftEnd, state.rightEnd);
if (!playEnd) return;
// Deselect if same tile tapped again
if (state.selectedTile?.id === tile.id) {
// Second tap on same tile → place it (right end default for 'both')
executePlacement(el, tile, playEnd === 'left' ? 'left' : 'right');
state.selectedTile = null;
hand.clearSelection();
board.clearActiveEndpoint();
return;
}
// Select the tile and highlight valid placement locations
state.selectedTile = tile;
hand.setSelected(tile.id);
if (playEnd === 'both') {
// First tile on empty board → show center endpoint
if (state.chain.length === 0) {
board.setActiveEndpoint('both');
} else {
executePlacement(el, tile, playEnd);
return;
}
// Show which endpoints this tile can play on
board.setActiveEndpoint(playEnd);
}
function handleEndpointTap(el, hit) {
if (!state.selectedTile) return;
const tile = state.selectedTile;
// First tile on empty board — place on any tap
if (state.chain.length === 0) {
executePlacement(el, tile, 'right');
return;
}
const canPlayOnEnd = tile.left === hit.value || tile.right === hit.value;
if (canPlayOnEnd) {
executePlacement(el, tile, hit.end);
......@@ -552,6 +566,7 @@ function executePlacement(el, tile, end) {
state.moveCount++;
board.clearActiveEndpoint();
hand.clearSelection();
board.animatePlacement(tile, actualEnd, () => {
board.setChain(state.chain);
});
......@@ -833,8 +848,19 @@ function updateUI(el) {
const isMyTurn = state.currentPlayer === state.myPlayerIndex;
const turnEl = el.querySelector('#turn-status');
if (turnEl) {
turnEl.textContent = state.gameOver ? 'انتهت الجولة' : isMyTurn ? 'دورك!' : 'الخصم يلعب...';
turnEl.style.color = state.gameOver ? '#94a3b8' : isMyTurn ? '#4ade80' : '#fbbf24';
if (state.gameOver) {
turnEl.textContent = 'انتهت الجولة';
turnEl.style.color = '#94a3b8';
} else if (isMyTurn && state.selectedTile) {
turnEl.textContent = 'اختر مكان الوضع';
turnEl.style.color = '#E4AC38';
} else if (isMyTurn) {
turnEl.textContent = 'دورك!';
turnEl.style.color = '#E4AC38';
} else {
turnEl.textContent = 'الخصم يلعب...';
turnEl.style.color = '#64748b';
}
}
const oppCountEl = el.querySelector('#opp-count');
......@@ -860,7 +886,7 @@ function updateUI(el) {
const hasMove = rules.hasValidMove(myHand, state.leftEnd, state.rightEnd);
if (isMyTurn && !hasMove && state.boneyard.length > 0 && !state.gameOver) {
if (drawBtn) { drawBtn.style.display = ''; drawBtn.style.animation = 'tileGlow 1.5s ease-in-out infinite'; }
if (drawBtn) { drawBtn.style.display = ''; drawBtn.style.animation = 'drawPulse 1.5s ease-in-out infinite'; }
if (passBtn) passBtn.style.display = 'none';
} else if (isMyTurn && !hasMove && state.boneyard.length === 0 && !state.gameOver) {
if (drawBtn) drawBtn.style.display = 'none';
......
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