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