Commit 7ef3695b authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix: domino layout — correct tile orientation and chain connectivity

Layout engine:
- Non-doubles are landscape (w=48, h=24) in horizontal chains
- Doubles are portrait (w=24, h=48) perpendicular to horizontal chains
- Tiles positioned touching each other with GAP=3px between
- Properly computes half-extents for advance distance
- Endpoints positioned beyond last tile in chain direction

Tile renderer:
- Uses w>h to detect landscape orientation (no rotation param needed)
- Left/right pips in landscape, top/bottom pips in portrait
- Doubles get accent background

Board:
- Ghost and animate tiles compute orientation from layout dirIdx
- No rotation applied (dimensions are pre-computed correctly)
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 75aa69e3
import { createCanvas, clear } from '../../../core/canvas.js';
import { computeLayout, hitTestEndpoint, getSnapRadius, getAutoZoom } from '../logic/layout.js';
import { drawTile, drawEndpointGlow } from './tile-renderer.js';
import { TILE_W, TILE_H, DOUBLE_SIZE } from '../logic/layout.js';
import { TILE_W, TILE_H } from '../logic/layout.js';
export class DominoBoard {
constructor(container, options = {}) {
......@@ -70,11 +70,22 @@ export class DominoBoard {
const ep = end === 'left' ? this.layout.endpoints.left : this.layout.endpoints.right;
if (!ep) { this.ghost = null; this.draw(); return; }
// Ghost matches the orientation it would have when placed
const dbl = tile.left === tile.right;
const w = dbl ? DOUBLE_SIZE : TILE_H;
const h = dbl ? DOUBLE_SIZE : TILE_W;
const lastTileIdx = end === 'left' ? 0 : this.layout.tiles.length - 1;
const lt = this.layout.tiles[lastTileIdx];
const isHorizontal = lt ? (lt.dirIdx === 0 || lt.dirIdx === 2) : true;
let w, h;
if (isHorizontal) {
w = dbl ? TILE_W : TILE_H;
h = dbl ? TILE_H : TILE_W;
} else {
w = dbl ? TILE_H : TILE_W;
h = dbl ? TILE_W : TILE_H;
}
this.ghost = { tile, x: ep.x, y: ep.y, w, h, rotation: 90, valid };
this.ghost = { tile, x: ep.x, y: ep.y, w, h, rotation: 0, valid };
this.draw();
}
......@@ -136,8 +147,18 @@ export class DominoBoard {
if (!ep) { callback?.(); return; }
const dbl = tile.left === tile.right;
const w = dbl ? DOUBLE_SIZE : TILE_H;
const h = dbl ? DOUBLE_SIZE : TILE_W;
const lastTileIdx = end === 'left' ? 0 : this.layout.tiles.length - 1;
const lt = this.layout.tiles[lastTileIdx];
const isHorizontal = lt ? (lt.dirIdx === 0 || lt.dirIdx === 2) : true;
let w, h;
if (isHorizontal) {
w = dbl ? TILE_W : TILE_H;
h = dbl ? TILE_H : TILE_W;
} else {
w = dbl ? TILE_H : TILE_W;
h = dbl ? TILE_W : TILE_H;
}
const duration = 200;
const startTime = performance.now();
......@@ -147,7 +168,7 @@ export class DominoBoard {
const ease = 1 - Math.pow(1 - t, 3);
const scale = 1.3 + (1 - 1.3) * ease;
this.animatingTile = { tile, x: ep.x, y: ep.y, w, h, rotation: 90, scale };
this.animatingTile = { tile, x: ep.x, y: ep.y, w, h, rotation: 0, scale };
this.draw();
if (t < 1) {
......@@ -193,7 +214,7 @@ export class DominoBoard {
// 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 });
drawTile(ctx, lt.x, lt.y, lt.w, lt.h, lt.tile, { rotation: 0 });
}
// Draw endpoint indicators (when active for tap-to-confirm)
......
......@@ -14,27 +14,27 @@ const PIP_POSITIONS = {
};
export function drawTile(ctx, x, y, w, h, tile, options = {}) {
const { rotation = 0, alpha = 1, glow = false, ghost = false, invalid = false, highlight = false } = options;
const { rotation = 0, alpha = 1, ghost = false, invalid = false, highlight = false } = options;
ctx.save();
ctx.translate(x, y);
ctx.rotate((rotation * Math.PI) / 180);
if (rotation) ctx.rotate((rotation * Math.PI) / 180);
ctx.globalAlpha = ghost ? 0.35 : alpha;
const hw = w / 2;
const hh = h / 2;
const r = 3;
// Shadow for non-ghost tiles
// Shadow
if (!ghost) {
ctx.shadowColor = 'rgba(0,0,0,0.4)';
ctx.shadowBlur = 4;
ctx.shadowOffsetY = 2;
ctx.shadowBlur = 3;
ctx.shadowOffsetY = 1;
}
if (glow || highlight) {
if (highlight) {
ctx.shadowColor = invalid ? 'rgba(239,68,68,0.7)' : 'rgba(228,172,56,0.7)';
ctx.shadowBlur = 14;
ctx.shadowBlur = 12;
}
// Tile body
......@@ -51,7 +51,7 @@ export function drawTile(ctx, x, y, w, h, tile, options = {}) {
if (!ghost) {
const isDouble = tile.left === tile.right;
const isHorizontal = w > h;
const isLandscape = w > h; // wider than tall = landscape orientation
if (isDouble) {
ctx.fillStyle = DOUBLE_ACCENT;
......@@ -65,8 +65,9 @@ export function drawTile(ctx, x, y, w, h, tile, options = {}) {
ctx.stroke();
}
if (isHorizontal) {
// Divider line (vertical center)
if (isLandscape) {
// Tile is wider than tall → left half = tile.left, right half = tile.right
// Divider: vertical line at center
ctx.beginPath();
ctx.moveTo(0, -hh + 2);
ctx.lineTo(0, hh - 2);
......@@ -74,12 +75,13 @@ export function drawTile(ctx, x, y, w, h, tile, options = {}) {
ctx.lineWidth = 0.8;
ctx.stroke();
const spread = Math.min(hw * 0.32, 6);
const pipR = Math.min(hw * 0.07, 2.5);
const spread = Math.min(hh * 0.6, 5.5);
const pipR = Math.min(2.2, hh * 0.12);
drawPips(ctx, -hw / 2, 0, spread, pipR, tile.left);
drawPips(ctx, hw / 2, 0, spread, pipR, tile.right);
} else {
// Divider line (horizontal center)
// Tile is taller than wide → top half = tile.left, bottom half = tile.right
// Divider: horizontal line at center
ctx.beginPath();
ctx.moveTo(-hw + 2, 0);
ctx.lineTo(hw - 2, 0);
......@@ -87,8 +89,8 @@ export function drawTile(ctx, x, y, w, h, tile, options = {}) {
ctx.lineWidth = 0.8;
ctx.stroke();
const spread = Math.min(hh * 0.32, 6);
const pipR = Math.min(hh * 0.07, 2.5);
const spread = Math.min(hw * 0.6, 5.5);
const pipR = Math.min(2.2, hw * 0.12);
drawPips(ctx, 0, -hh / 2, spread, pipR, tile.left);
drawPips(ctx, 0, hh / 2, spread, pipR, tile.right);
}
......@@ -108,10 +110,10 @@ function drawPips(ctx, cx, cy, spread, radius, value) {
}
export function drawEndpointGlow(ctx, x, y, radius, valid) {
const color = valid ? 'rgba(228,172,56,0.5)' : 'rgba(239,68,68,0.4)';
const color = valid ? 'rgba(228,172,56,0.45)' : 'rgba(239,68,68,0.4)';
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
gradient.addColorStop(0, color);
gradient.addColorStop(0.5, valid ? 'rgba(228,172,56,0.15)' : 'rgba(239,68,68,0.1)');
gradient.addColorStop(0.5, valid ? 'rgba(228,172,56,0.12)' : 'rgba(239,68,68,0.08)');
gradient.addColorStop(1, 'transparent');
ctx.beginPath();
......@@ -121,16 +123,16 @@ export function drawEndpointGlow(ctx, x, y, radius, valid) {
// Center dot
ctx.beginPath();
ctx.arc(x, y, 6, 0, Math.PI * 2);
ctx.arc(x, y, 5, 0, Math.PI * 2);
ctx.fillStyle = valid ? '#E4AC38' : '#ef4444';
ctx.globalAlpha = 0.8;
ctx.globalAlpha = 0.85;
ctx.fill();
ctx.globalAlpha = 1;
// Pulsing ring
// Dashed 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.arc(x, y, radius * 0.55, 0, Math.PI * 2);
ctx.strokeStyle = valid ? 'rgba(228,172,56,0.35)' : 'rgba(239,68,68,0.3)';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 4]);
ctx.stroke();
......
// Domino chain layout engine
// Chain starts from center, grows outward. Turns when hitting bounds.
// Doubles are perpendicular (crosswise). Non-doubles follow chain direction.
// Doubles are perpendicular to chain direction.
// Non-doubles align with chain direction (landscape in horizontal chain).
export const TILE_W = 24;
export const TILE_H = 48;
export const DOUBLE_SIZE = 24;
export const GAP = 2;
const MARGIN = 20;
export const TILE_W = 24; // narrow dimension
export const TILE_H = 48; // long dimension
export const GAP = 3;
const MARGIN = 30;
// Direction vectors for chain growth
const DIRS = [
......@@ -33,68 +34,67 @@ export function computeLayout(chain, canvasW, canvasH) {
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;
const isHorizontal = dir.dx !== 0; // chain goes left/right
// Tile rendered dimensions (already in screen orientation, no rotation needed)
let w, h;
if (isHorizontal) {
// Chain goes horizontally
if (isDouble) {
// Doubles perpendicular: narrow along chain, tall across
w = TILE_W;
h = TILE_H;
rotation = 0; // vertical (perpendicular to horizontal chain)
} else {
// Non-doubles aligned: long along chain, narrow across
w = TILE_H;
h = DOUBLE_SIZE;
rotation = 90; // horizontal (perpendicular to vertical chain)
h = TILE_W;
}
} else {
if (isHorizontal) {
// Chain goes vertically
if (isDouble) {
// Doubles perpendicular: wide across chain, narrow along
w = TILE_H;
h = TILE_W;
rotation = 90; // landscape along horizontal chain
} else {
// Non-doubles aligned: narrow across, long along chain
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 });
tiles.push({ tile: piece, x: cx, y: cy, w, h, rotation: 0, isDouble, dirIdx });
// Advance position for next tile
// Calculate advance to next tile position
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);
// Half-extent of current tile in chain direction
const curHalf = isHorizontal ? w / 2 : h / 2;
// Half-extent of next tile in chain direction
let nextHalf;
if (isHorizontal) {
nextHalf = nextIsDouble ? TILE_W / 2 : TILE_H / 2;
} else {
nextHalf = nextIsDouble ? TILE_W / 2 : TILE_H / 2;
}
const advance = curExtent + GAP + nextExtent;
let nextX = cx + dir.dx * advance;
let nextY = cy + dir.dy * advance;
const advance = curHalf + GAP + nextHalf;
const nextX = cx + dir.dx * advance;
const 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;
// Check bounds - use virtual space proportional to canvas
const boundW = canvasW * 2;
const boundH = canvasH * 2;
if (Math.abs(nextX) > halfVW - MARGIN || Math.abs(nextY) > halfVH - MARGIN) {
if (Math.abs(nextX) > boundW / 2 - MARGIN || Math.abs(nextY) > boundH / 2 - 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;
// Offset perpendicular from current position to start new row
const perpOffset = (TILE_H / 2 + GAP + TILE_H / 2);
cx += newDir.dx * perpOffset;
cy += newDir.dy * perpOffset;
} else {
cx = nextX;
cy = nextY;
......@@ -102,23 +102,20 @@ export function computeLayout(chain, canvasW, canvasH) {
}
}
// Compute bounds
// Center the whole chain in canvas
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)
// Compute endpoints
const endpoints = {
left: computeEndpoint(tiles, 0, chain, 'left'),
right: computeEndpoint(tiles, tiles.length - 1, chain, 'right')
left: getEndpoint(tiles, chain, 'left'),
right: getEndpoint(tiles, chain, 'right')
};
return { tiles, endpoints, bounds: finalBounds };
......@@ -128,57 +125,50 @@ 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) {
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);
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 computeEndpoint(tiles, tileIdx, chain, side) {
const lt = tiles[tileIdx];
if (!lt) return null;
function getEndpoint(tiles, chain, side) {
if (tiles.length === 0) return null;
const idx = side === 'left' ? 0 : tiles.length - 1;
const lt = tiles[idx];
const dir = DIRS[lt.dirIdx];
const isHorizontal = dir.dx !== 0;
// The endpoint is beyond the tile in the direction the chain is going
// For 'left' endpoint, it's in the OPPOSITE direction from the first tile
// For 'right' endpoint, it's in the SAME direction from the last tile
const sign = side === 'left' ? -1 : 1;
const halfExtent = isHorizontal ? lt.w / 2 : lt.h / 2;
const offset = halfExtent + 18; // gap for the glow indicator
// 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 = lt.x + dir.dx * (extent + 16);
ey = lt.y + dir.dy * (extent + 16);
}
const ex = lt.x + dir.dx * sign * offset;
const ey = lt.y + dir.dy * sign * offset;
const endValue = side === 'left' ? chain[0].left : chain[chain.length - 1].right;
return { x: ex, y: ey, value: endValue, end: side };
}
export function getSnapRadius() { return 50; }
export function getSnapRadius() { return 45; }
export function hitTestEndpoint(px, py, endpoint) {
if (!endpoint) return false;
const dx = px - endpoint.x;
const dy = px - endpoint.y;
return (dx * dx + (py - endpoint.y) * (py - endpoint.y)) < getSnapRadius() * getSnapRadius();
const dy = py - endpoint.y;
return (dx * dx + dy * dy) < getSnapRadius() * getSnapRadius();
}
export function getAutoZoom(bounds, canvasW, canvasH) {
const chainW = bounds.maxX - bounds.minX + 60;
const chainH = bounds.maxY - bounds.minY + 60;
const chainW = (bounds.maxX - bounds.minX) + 80;
const chainH = (bounds.maxY - bounds.minY) + 80;
const scaleX = canvasW / chainW;
const scaleY = canvasH / chainH;
const needed = Math.min(scaleX, scaleY);
return Math.min(1, Math.max(0.3, needed));
return Math.min(1, Math.max(0.35, needed));
}
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