Commit 31a8e2dc authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: redesign domino UX — premium hand tiles, smart auto-fit, cohesive screens

Hand tiles (components/hand.js):
- Proper PIP_POSITIONS matching board tiles (position-based, not flex-wrap)
- Dynamic sizing: tiles scale to always fit viewport width
- Fan layout for 5+ tiles (card-like spread with arc lift)
- Slide-in animation for newly drawn tiles
- Premium domino look: gradient bg, 3D pips, edge shine, realistic border

Board (canvas/board.js):
- Smooth animated auto-pan + auto-zoom when chain exceeds viewport
- Lerp-based transitions (no jarring jumps on new tiles)
- Vignette overlay for depth
- Manual pan updates target state for smooth resume

Tile renderer (canvas/tile-renderer.js):
- Gradient fill (cream to warm), thicker border, top-edge shine
- 3D pips with specular highlights
- Better shadow depth and double-tile accent

Drag proxy (components/drag.js):
- Matches hand tile visual style (position-based pips)
- Valid/invalid state with colored borders + glow
- Spring animation on snap/bounce-back

Room screens (scenes/room.js):
- Unified design language across menu/bot-picker/target-picker/lobby
- Floating icon animation, consistent card styles
- Touch-responsive buttons with spring scale

Game layout (scenes/game.js):
- Polished opponent bar with avatar border glow
- Score bar with colored indicators
- Control buttons with active states and pulse animation
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 9964877a
......@@ -23,11 +23,13 @@ export class DominoBoard {
this.activeEndpoint = null;
this.animatingTile = null;
// Pan & zoom state
// Pan & zoom state — smooth animated transitions
this.pan = { x: 0, y: 0 };
this.targetPan = { x: 0, y: 0 };
this.zoom = 1;
this.targetZoom = 1;
this._zoomAnimFrame = null;
this._animFrame = null;
this._isAnimating = false;
this._setupInteraction();
}
......@@ -35,34 +37,60 @@ export class DominoBoard {
setChain(chain) {
this.chain = chain;
this.layout = computeLayout(chain, this.width, this.height);
this._autoZoom();
this._autoFit();
this.draw();
}
_autoZoom() {
if (this.chain.length < 4) {
_autoFit() {
if (this.chain.length < 2) {
this.targetZoom = 1;
this.targetPan = { x: 0, y: 0 };
} else {
const bounds = this.layout.bounds;
this.targetZoom = getAutoZoom(bounds, this.width, this.height);
// Auto-center on the chain
const chainCenterX = (bounds.minX + bounds.maxX) / 2;
const chainCenterY = (bounds.minY + bounds.maxY) / 2;
this.targetPan = {
x: chainCenterX - this.width / 2,
y: chainCenterY - this.height / 2
};
}
this._animateZoom();
this._startSmoothTransition();
}
_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;
_startSmoothTransition() {
if (this._isAnimating) return;
this._isAnimating = true;
this._animate();
}
_animate() {
const panDiffX = this.targetPan.x - this.pan.x;
const panDiffY = this.targetPan.y - this.pan.y;
const zoomDiff = this.targetZoom - this.zoom;
const threshold = 0.003;
const settled = Math.abs(panDiffX) < 0.5 && Math.abs(panDiffY) < 0.5 && Math.abs(zoomDiff) < threshold;
if (settled) {
this.pan.x = this.targetPan.x;
this.pan.y = this.targetPan.y;
this.zoom = this.targetZoom;
this._isAnimating = false;
this.draw();
this._zoomAnimFrame = requestAnimationFrame(animate);
};
animate();
return;
}
// Smooth lerp (ease-out feel)
const speed = 0.1;
this.pan.x += panDiffX * speed;
this.pan.y += panDiffY * speed;
this.zoom += zoomDiff * speed;
this.draw();
this._animFrame = requestAnimationFrame(() => this._animate());
}
setGhost(tile, end, valid) {
......@@ -70,7 +98,6 @@ 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 lastTileIdx = end === 'left' ? 0 : this.layout.tiles.length - 1;
const lt = this.layout.tiles[lastTileIdx];
......@@ -124,7 +151,6 @@ 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;
......@@ -134,12 +160,10 @@ export class DominoBoard {
const rightEp = this.layout.endpoints.right;
const radius = getSnapRadius();
// Empty board: tap anywhere on canvas = center placement
if (!leftEp && !rightEp) {
return { end: 'right', value: null };
}
// Check which endpoints are active
const checkLeft = this.activeEndpoint === 'left' || this.activeEndpoint === 'both';
const checkRight = this.activeEndpoint === 'right' || this.activeEndpoint === 'both';
......@@ -172,18 +196,20 @@ export class DominoBoard {
const startX = this.width / 2;
const startY = this.height + 40;
const duration = 350;
const duration = 320;
const startTime = performance.now();
const animate = (now) => {
const t = Math.min((now - startTime) / duration, 1);
// Ease out cubic
const ease = 1 - Math.pow(1 - t, 3);
const x = startX + (ep.x - startX) * ease;
const y = startY + (ep.y - startY) * ease;
const scale = 1.4 + (1 - 1.4) * ease;
const scale = 1.3 + (1 - 1.3) * ease;
const alpha = 0.4 + 0.6 * ease;
this.animatingTile = { tile, x, y, w, h, rotation: 0, scale };
this.animatingTile = { tile, x, y, w, h, rotation: 0, scale, alpha };
this.draw();
if (t < 1) {
......@@ -200,8 +226,18 @@ export class DominoBoard {
const ctx = this.ctx;
clear(ctx, this.width, this.height);
// Dark background matching app theme
ctx.fillStyle = '#0f1623';
// Board background with subtle felt texture
ctx.fillStyle = '#0b1520';
ctx.fillRect(0, 0, this.width, this.height);
// Vignette
const vg = ctx.createRadialGradient(
this.width/2, this.height/2, this.height * 0.3,
this.width/2, this.height/2, this.height * 0.7
);
vg.addColorStop(0, 'transparent');
vg.addColorStop(1, 'rgba(0,0,0,0.3)');
ctx.fillStyle = vg;
ctx.fillRect(0, 0, this.width, this.height);
// Subtle pattern
......@@ -216,8 +252,7 @@ export class DominoBoard {
ctx.translate(-cx - this.pan.x, -cy - this.pan.y);
if (this.chain.length === 0) {
// Empty board indicator
ctx.fillStyle = 'rgba(255,255,255,0.08)';
ctx.fillStyle = 'rgba(255,255,255,0.06)';
ctx.font = '600 13px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
......@@ -232,10 +267,10 @@ export class DominoBoard {
drawTile(ctx, lt.x, lt.y, lt.w, lt.h, lt.tile, { rotation: 0 });
}
// Draw endpoint indicators (when active for tap-to-confirm)
// Endpoint indicators
this._drawEndpointIndicators(ctx);
// Ghost tile (during drag)
// Ghost tile
if (this.ghost) {
drawTile(ctx, this.ghost.x, this.ghost.y, this.ghost.w, this.ghost.h,
this.ghost.tile, { rotation: this.ghost.rotation, ghost: true, invalid: !this.ghost.valid });
......@@ -245,6 +280,7 @@ export class DominoBoard {
if (this.animatingTile) {
const at = this.animatingTile;
ctx.save();
ctx.globalAlpha = at.alpha ?? 1;
ctx.translate(at.x, at.y);
ctx.scale(at.scale, at.scale);
ctx.translate(-at.x, -at.y);
......@@ -256,12 +292,11 @@ export class DominoBoard {
}
_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.fillStyle = 'rgba(255,255,255,0.015)';
for (let i = 0; i < this.width; i += 24) {
for (let j = 0; j < this.height; j += 24) {
ctx.beginPath();
ctx.arc(i, j, 1, 0, Math.PI * 2);
ctx.arc(i, j, 0.8, 0, Math.PI * 2);
ctx.fill();
}
}
......@@ -305,6 +340,8 @@ export class DominoBoard {
isPanning = true;
this.pan.x = lastPanX - dx / this.zoom;
this.pan.y = lastPanY - dy / this.zoom;
this.targetPan.x = this.pan.x;
this.targetPan.y = this.pan.y;
this.draw();
}
});
......@@ -325,7 +362,7 @@ export class DominoBoard {
}
destroy() {
if (this._zoomAnimFrame) cancelAnimationFrame(this._zoomAnimFrame);
if (this._animFrame) cancelAnimationFrame(this._animFrame);
this.canvas.remove();
}
}
......
const TILE_BG = '#F5F0E8';
const TILE_BORDER = '#8B7355';
const TILE_BG = '#FFFDF5';
const TILE_BG_END = '#F2ECE0';
const TILE_BORDER = '#9B8360';
const PIP_COLOR = '#1a1a1a';
const DOUBLE_ACCENT = '#EDE5D4';
const DOUBLE_ACCENT = '#F5EFE0';
const PIP_POSITIONS = {
0: [],
......@@ -23,74 +24,95 @@ export function drawTile(ctx, x, y, w, h, tile, options = {}) {
const hw = w / 2;
const hh = h / 2;
const r = 3;
const r = 3.5;
// Shadow
if (!ghost) {
ctx.shadowColor = 'rgba(0,0,0,0.4)';
ctx.shadowBlur = 3;
ctx.shadowOffsetY = 1;
ctx.shadowColor = 'rgba(0,0,0,0.45)';
ctx.shadowBlur = 4;
ctx.shadowOffsetY = 2;
ctx.shadowOffsetX = 1;
}
if (highlight) {
ctx.shadowColor = invalid ? 'rgba(239,68,68,0.7)' : 'rgba(228,172,56,0.7)';
ctx.shadowBlur = 12;
ctx.shadowBlur = 14;
}
// Tile body
// Tile body with gradient
ctx.beginPath();
ctx.roundRect(-hw, -hh, w, h, r);
ctx.fillStyle = ghost ? (invalid ? 'rgba(239,68,68,0.15)' : 'rgba(228,172,56,0.2)') : TILE_BG;
if (ghost) {
ctx.fillStyle = invalid ? 'rgba(239,68,68,0.15)' : 'rgba(228,172,56,0.2)';
} else {
const grad = ctx.createLinearGradient(-hw, -hh, hw, hh);
grad.addColorStop(0, TILE_BG);
grad.addColorStop(1, TILE_BG_END);
ctx.fillStyle = grad;
}
ctx.fill();
// Reset shadow before stroke
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
ctx.shadowOffsetY = 0;
ctx.shadowOffsetX = 0;
// Border
ctx.strokeStyle = ghost ? (invalid ? '#ef4444' : '#E4AC38') : TILE_BORDER;
ctx.lineWidth = ghost ? 1.5 : 1;
ctx.lineWidth = ghost ? 1.5 : 1.2;
ctx.stroke();
// Inner highlight (top edge shine)
if (!ghost) {
ctx.beginPath();
ctx.moveTo(-hw + r + 1, -hh + 1.2);
ctx.lineTo(hw - r - 1, -hh + 1.2);
ctx.strokeStyle = 'rgba(255,255,255,0.6)';
ctx.lineWidth = 0.8;
ctx.stroke();
}
if (!ghost) {
const isDouble = tile.left === tile.right;
const isLandscape = w > h; // wider than tall = landscape orientation
const isLandscape = w > h;
if (isDouble) {
ctx.fillStyle = DOUBLE_ACCENT;
ctx.beginPath();
ctx.roundRect(-hw + 1, -hh + 1, w - 2, h - 2, r - 1);
ctx.roundRect(-hw + 1.5, -hh + 1.5, w - 3, h - 3, r - 1);
ctx.fill();
ctx.strokeStyle = TILE_BORDER;
ctx.lineWidth = 1;
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.roundRect(-hw, -hh, w, h, r);
ctx.stroke();
}
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);
ctx.strokeStyle = '#B8A88A';
ctx.moveTo(0, -hh + 3);
ctx.lineTo(0, hh - 3);
ctx.strokeStyle = 'rgba(155,131,96,0.5)';
ctx.lineWidth = 0.8;
ctx.stroke();
const spread = Math.min(hh * 0.6, 5.5);
const pipR = Math.min(2.2, hh * 0.12);
const spread = Math.min(hh * 0.55, 6);
const pipR = Math.min(2.5, hh * 0.14);
drawPips(ctx, -hw / 2, 0, spread, pipR, tile.left);
drawPips(ctx, hw / 2, 0, spread, pipR, tile.right);
} else {
// 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);
ctx.strokeStyle = '#B8A88A';
ctx.moveTo(-hw + 3, 0);
ctx.lineTo(hw - 3, 0);
ctx.strokeStyle = 'rgba(155,131,96,0.5)';
ctx.lineWidth = 0.8;
ctx.stroke();
const spread = Math.min(hw * 0.6, 5.5);
const pipR = Math.min(2.2, hw * 0.12);
const spread = Math.min(hw * 0.55, 6);
const pipR = Math.min(2.5, hw * 0.14);
drawPips(ctx, 0, -hh / 2, spread, pipR, tile.left);
drawPips(ctx, 0, hh / 2, spread, pipR, tile.right);
}
......@@ -101,19 +123,29 @@ export function drawTile(ctx, x, y, w, h, tile, options = {}) {
function drawPips(ctx, cx, cy, spread, radius, value) {
const positions = PIP_POSITIONS[value] || [];
ctx.fillStyle = PIP_COLOR;
for (const [px, py] of positions) {
const x = cx + px * spread;
const y = cy + py * spread;
// Pip with subtle 3D effect
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = PIP_COLOR;
ctx.fill();
// Specular highlight
ctx.beginPath();
ctx.arc(cx + px * spread, cy + py * spread, radius, 0, Math.PI * 2);
ctx.arc(x - radius * 0.25, y - radius * 0.25, radius * 0.35, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,0.18)';
ctx.fill();
}
}
export function drawEndpointGlow(ctx, x, y, radius, valid) {
const color = valid ? 'rgba(228,172,56,0.45)' : 'rgba(239,68,68,0.4)';
const color = valid ? 'rgba(228,172,56,0.4)' : 'rgba(239,68,68,0.35)';
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
gradient.addColorStop(0, color);
gradient.addColorStop(0.5, valid ? 'rgba(228,172,56,0.12)' : 'rgba(239,68,68,0.08)');
gradient.addColorStop(0.4, valid ? 'rgba(228,172,56,0.12)' : 'rgba(239,68,68,0.08)');
gradient.addColorStop(1, 'transparent');
ctx.beginPath();
......@@ -125,16 +157,16 @@ export function drawEndpointGlow(ctx, x, y, radius, valid) {
ctx.beginPath();
ctx.arc(x, y, 5, 0, Math.PI * 2);
ctx.fillStyle = valid ? '#E4AC38' : '#ef4444';
ctx.globalAlpha = 0.85;
ctx.globalAlpha = 0.8;
ctx.fill();
ctx.globalAlpha = 1;
// Dashed ring
// Animated-feel dashed ring
ctx.beginPath();
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.arc(x, y, radius * 0.5, 0, Math.PI * 2);
ctx.strokeStyle = valid ? 'rgba(228,172,56,0.3)' : 'rgba(239,68,68,0.25)';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 4]);
ctx.setLineDash([5, 5]);
ctx.stroke();
ctx.setLineDash([]);
}
......@@ -2,6 +2,16 @@ import * as audio from '../../../core/audio.js';
import * as juice from '../../../core/juice.js';
import { canPlay } from '../logic/rules.js';
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 class DominoDrag {
constructor(options = {}) {
this.board = options.board;
......@@ -40,33 +50,29 @@ export class DominoDrag {
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 class="drag-tile">
<div class="drag-half">${this._renderPips(tile.left)}</div>
<div class="drag-divider"></div>
<div class="drag-half">${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;
left:${x - 24}px;top:${y - 42}px;
`;
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;
const positions = PIP_POSITIONS[value] || [];
const spread = 7;
const pipSize = 4.5;
return positions.map(([px, py]) => {
const cx = 20 + px * spread;
const cy = 18 + py * spread;
return `<span class="drag-pip" style="left:${cx - pipSize/2}px;top:${cy - pipSize/2}px;width:${pipSize}px;height:${pipSize}px;"></span>`;
}).join('');
}
_onMove(e) {
......@@ -74,36 +80,36 @@ export class DominoDrag {
const x = e.clientX;
const y = e.clientY;
this.proxyEl.style.left = (x - 20) + 'px';
this.proxyEl.style.top = (y - 35) + 'px';
this.proxyEl.style.left = (x - 24) + 'px';
this.proxyEl.style.top = (y - 42) + '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;
const tileEl = this.proxyEl.querySelector('.drag-tile');
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';
tileEl.classList.add('drag-valid');
tileEl.classList.remove('drag-invalid');
} 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';
tileEl.classList.remove('drag-valid');
tileEl.classList.add('drag-invalid');
}
} 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';
const tileEl = this.proxyEl.querySelector('.drag-tile');
tileEl.classList.remove('drag-valid', 'drag-invalid');
}
}
}
......@@ -117,11 +123,10 @@ export class DominoDrag {
document.removeEventListener('pointercancel', this._onUp);
document.removeEventListener('touchend', this._onUp);
// Re-check hit from proxy position (touchend may not update pointer coords)
if (!this.nearEnd && this.proxyEl) {
const left = parseInt(this.proxyEl.style.left) || 0;
const top = parseInt(this.proxyEl.style.top) || 0;
const hit = this.board.hitTestEndpoints(left + 20, top + 35);
const hit = this.board.hitTestEndpoints(left + 24, top + 42);
if (hit) {
const tileMatches = this.tile.left === hit.value || this.tile.right === hit.value;
if (tileMatches) {
......@@ -155,7 +160,7 @@ export class DominoDrag {
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)';
this.proxyEl.querySelector('.drag-tile').style.transform = 'scale(0.5)';
setTimeout(callback, 150);
}
......@@ -163,7 +168,7 @@ export class DominoDrag {
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)';
this.proxyEl.querySelector('.drag-tile').style.transform = 'translateY(40px) scale(0.7)';
setTimeout(callback, 250);
}
......@@ -178,7 +183,51 @@ export class DominoDrag {
}
getStyle() {
return `.drag-proxy { will-change: transform, left, top; }`;
return `
.drag-proxy { will-change: transform, left, top; }
.drag-tile {
width: 48px;
height: 84px;
background: linear-gradient(160deg, #FFFDF5 0%, #F5EFE0 100%);
border: 2px solid #E4AC38;
border-radius: 7px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
box-shadow: 0 10px 30px rgba(0,0,0,0.5), 0 0 20px rgba(228,172,56,0.3);
transform: scale(1.15);
transition: transform 0.15s cubic-bezier(0.34,1.56,0.64,1),
border-color 0.15s, box-shadow 0.15s;
}
.drag-tile.drag-valid {
border-color: #10b981;
transform: scale(1.2);
box-shadow: 0 10px 30px rgba(0,0,0,0.5), 0 0 20px rgba(16,185,129,0.4);
}
.drag-tile.drag-invalid {
border-color: #ef4444;
transform: scale(0.95);
box-shadow: 0 6px 20px rgba(0,0,0,0.5), 0 0 12px rgba(239,68,68,0.3);
}
.drag-half {
position: relative;
width: 100%;
flex: 1;
}
.drag-divider {
width: 78%;
height: 1.5px;
background: linear-gradient(90deg, transparent, #B8A080, transparent);
flex-shrink: 0;
}
.drag-pip {
position: absolute;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #3a3a3a, #0a0a0a);
box-shadow: inset 0 1px 1px rgba(255,255,255,0.15);
}
`;
}
destroy() {
......
import * as audio from '../../../core/audio.js';
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 class DominoHand {
constructor(container, options = {}) {
this.container = container;
......@@ -10,14 +20,14 @@ export class DominoHand {
this.validTileIds = new Set();
this.selectedTileId = null;
this.disabled = false;
this._prevTileIds = [];
container.style.cssText = `
display:flex;gap:8px;padding:10px 12px;overflow-x:auto;
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;
display:flex;gap:0;padding:8px 6px;
background:linear-gradient(180deg, #0c1a2a 0%, #0a1420 100%);
border-top:1px solid rgba(228,172,56,0.1);
min-height:100px;align-items:flex-end;justify-content:center;
overflow:visible;position:relative;
`;
}
......@@ -44,30 +54,79 @@ export class DominoHand {
render() {
const hand = this.tiles;
const count = hand.length;
// Smart sizing: scale tiles to always fit viewport
const containerW = this.container.clientWidth || 360;
const maxTileW = 44;
const minTileW = 30;
const idealGap = 6;
const maxHandW = containerW - 16;
// Calculate tile width to fit all tiles with gaps
let tileW = Math.min(maxTileW, (maxHandW - idealGap * (count - 1)) / count);
tileW = Math.max(minTileW, tileW);
const tileH = tileW * 1.8;
const gap = Math.min(idealGap, (maxHandW - tileW * count) / Math.max(count - 1, 1));
// Fan angle for many tiles (like holding cards)
const useFan = count > 5;
const maxFanAngle = 2.5; // degrees per tile from center
const maxLift = 4; // px arc lift at center
const newIds = hand.map(t => t.id);
const addedIds = new Set(newIds.filter(id => !this._prevTileIds.includes(id)));
this._prevTileIds = newIds;
this.container.innerHTML = hand.map((tile, i) => {
const playable = this.validTileIds.has(tile.id);
const selected = this.selectedTileId === tile.id;
const isNew = addedIds.has(tile.id);
// Fan geometry
let angle = 0, lift = 0;
if (useFan) {
const center = (count - 1) / 2;
const offset = i - center;
angle = offset * maxFanAngle;
lift = -Math.abs(offset) * (maxLift / (count / 2));
}
const transform = selected
? `translateY(-16px) scale(1.12)`
: `rotate(${angle}deg) translateY(${lift}px)`;
const animDelay = isNew ? `animation:tileSlideIn 0.3s cubic-bezier(0.34,1.56,0.64,1) both;` : '';
return `
<div class="dh-tile ${playable ? 'dh-playable' : ''} ${selected ? 'dh-selected' : ''}"
data-id="${tile.id}" data-idx="${i}"
style="scroll-snap-align:center;${this.disabled ? 'pointer-events:none;' : ''}">
<div class="dh-top">${this._renderPipGrid(tile.left)}</div>
style="width:${tileW}px;height:${tileH}px;transform:${transform};margin:0 ${gap/2}px;${this.disabled ? 'pointer-events:none;' : ''}${animDelay}">
<div class="dh-half dh-top">
${this._renderPips(tile.left, tileW)}
</div>
<div class="dh-divider"></div>
<div class="dh-bottom">${this._renderPipGrid(tile.right)}</div>
<div class="dh-half dh-bottom">
${this._renderPips(tile.right, tileW)}
</div>
</div>`;
}).join('');
this._bindEvents();
}
_renderPipGrid(value) {
_renderPips(value, tileW) {
if (value === 0) return '';
let dots = '';
for (let i = 0; i < value; i++) {
dots += '<span class="dh-pip"></span>';
}
return dots;
const positions = PIP_POSITIONS[value] || [];
const halfW = tileW / 2;
const spread = halfW * 0.45;
const pipSize = Math.max(3.5, tileW * 0.1);
return positions.map(([px, py]) => {
const cx = halfW + px * spread;
const cy = halfW * 0.82 + py * spread;
return `<span class="dh-pip" style="left:${cx - pipSize/2}px;top:${cy - pipSize/2}px;width:${pipSize}px;height:${pipSize}px;"></span>`;
}).join('');
}
_bindEvents() {
......@@ -118,51 +177,74 @@ export class DominoHand {
getStyle() {
return `
.dh-tile {
width:34px;height:68px;
aspect-ratio:1/2;
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;
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.4;
user-select:none;
touch-action:none;
flex-shrink:0;
box-shadow:0 2px 6px rgba(0,0,0,0.3);
background: linear-gradient(160deg, #FFFDF5 0%, #F5EFE0 100%);
border: 1.5px solid #C4AD82;
border-radius: 6px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
padding: 0;
cursor: pointer;
transition: transform 0.25s cubic-bezier(0.34,1.56,0.64,1),
border-color 0.2s, opacity 0.2s, box-shadow 0.2s,
filter 0.2s;
opacity: 0.45;
user-select: none;
touch-action: none;
flex-shrink: 0;
box-shadow: 0 3px 8px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.7);
position: relative;
transform-origin: center bottom;
filter: brightness(0.7);
}
.dh-tile.dh-playable {
opacity:1;
border-color:#E4AC38;
box-shadow:0 0 8px rgba(228,172,56,0.25);
opacity: 1;
border-color: #E4AC38;
box-shadow: 0 3px 8px rgba(0,0,0,0.5), 0 0 12px rgba(228,172,56,0.3),
inset 0 1px 0 rgba(255,255,255,0.7);
filter: brightness(1);
}
.dh-tile.dh-playable:active {
transform: translateY(-4px) scale(0.96) !important;
}
.dh-tile.dh-playable:active { transform:scale(0.94); }
.dh-tile.dh-selected {
transform:translateY(-12px) scale(1.08);
border-color:#E4AC38;
box-shadow:0 6px 20px rgba(228,172,56,0.5);
transform: translateY(-16px) scale(1.12) !important;
border-color: #E4AC38;
box-shadow: 0 8px 24px rgba(228,172,56,0.5), 0 0 20px rgba(228,172,56,0.3),
inset 0 1px 0 rgba(255,255,255,0.7);
z-index: 10;
filter: brightness(1.05);
}
@keyframes tileGlow {
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); }
0%, 100% { box-shadow: 0 3px 8px rgba(0,0,0,0.5), 0 0 8px rgba(228,172,56,0.2), inset 0 1px 0 rgba(255,255,255,0.7); }
50% { box-shadow: 0 3px 8px rgba(0,0,0,0.5), 0 0 16px rgba(228,172,56,0.4), inset 0 1px 0 rgba(255,255,255,0.7); }
}
.dh-tile.dh-playable:not(.dh-selected) { animation: tileGlow 2s ease-in-out infinite; }
.dh-tile.dh-selected { animation: none; }
@keyframes tileSlideIn {
from { transform: translateY(40px) scale(0.6); opacity: 0; }
to { opacity: 1; }
}
.dh-half {
position: relative;
width: 100%;
flex: 1;
}
.dh-tile.dh-playable { animation:tileGlow 2s ease-in-out infinite; }
.dh-tile.dh-selected { animation:none; }
.dh-divider { width:80%;height:1px;background:#B8A88A;flex-shrink:0; }
.dh-top, .dh-bottom {
display:flex;flex-wrap:wrap;
width:24px;height:28px;
align-items:center;justify-content:center;
gap:1px;
.dh-divider {
width: 78%;
height: 1.5px;
background: linear-gradient(90deg, transparent, #B8A080, transparent);
flex-shrink: 0;
margin: 0 auto;
}
.dh-pip {
width:5px;height:5px;
border-radius:50%;
background:#1a1a1a;
position: absolute;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #3a3a3a, #0a0a0a);
box-shadow: inset 0 1px 1px rgba(255,255,255,0.15), 0 0.5px 1px rgba(0,0,0,0.3);
}
`;
}
......
This diff is collapsed.
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