From d61f5b1cc3f113a326935cef8d3e18d824564df6 Mon Sep 17 00:00:00 2001 From: Dragomir Tsanev <2397279+hackthecode@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:16:12 +0200 Subject: [PATCH] Backgammon and Ludo: realistic wooden boards, pieces and dice - Backgammon: walnut case, recessed oak fields with inlaid maple/walnut points, hinged bar, trays; checkers have thickness and a softer shadow - Ludo: birch board in a walnut frame, painted yards, dimpled fields joined by the printed track line, lacquered wooden pawns, felt-lined dice tray - Dice: shared resting die (js/games/dieface.js) that matches the 3D die; 3D die has rounder edges and brighter ivory Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_017LFkv4o73x1oHvhwAn1kwv --- css/ludo.css | 10 +-- css/tabla.css | 8 +- js/games/dieface.js | 33 +++++++++ js/games/ludo/ludo.js | 140 +++++++++++++++++++++-------------- js/games/tabla/dice3d.js | 8 +- js/games/tabla/tabla.js | 156 ++++++++++++++++++++++++++------------- sw.js | 2 +- 7 files changed, 236 insertions(+), 121 deletions(-) create mode 100644 js/games/dieface.js diff --git a/css/ludo.css b/css/ludo.css index ac48855..f1d8202 100644 --- a/css/ludo.css +++ b/css/ludo.css @@ -4,7 +4,7 @@ .ludo-wrap { flex: none; display: grid; place-items: center; } .ludo-svg { display: block; width: min(calc(100cqh - 24px), calc(100cqw - 330px), 820px); height: auto; aspect-ratio: 1; - user-select: none; -webkit-user-select: none; touch-action: manipulation; filter: drop-shadow(0 30px 50px rgba(0,0,0,.7)); + user-select: none; -webkit-user-select: none; touch-action: manipulation; filter: drop-shadow(0 26px 40px rgba(0,0,0,.75)) drop-shadow(0 6px 10px rgba(0,0,0,.55)); } .ludo-side { width: 270px; display: flex; flex-direction: column; gap: 14px; } @@ -23,13 +23,13 @@ .lp-home i.home { background: var(--c); border-color: var(--c); } /* the die tray (tap it to roll) and the status */ -.ludo-dock { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 16px 12px; border-radius: 18px; background: radial-gradient(ellipse at 50% 30%, #1d3a2b, #0b1912); border: 1px solid rgba(255,255,255,.08); box-shadow: inset 0 0 30px rgba(0,0,0,.6); } +.ludo-dock { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 16px 12px; border-radius: 18px; background: radial-gradient(ellipse at 50% 30%, #23533a, #0d2318); border: 5px solid #4a2913; outline: 1px solid rgba(255,214,160,.22); outline-offset: -5px; box-shadow: inset 0 0 0 1px rgba(0,0,0,.7), inset 0 6px 26px rgba(0,0,0,.75), 0 10px 22px -8px rgba(0,0,0,.8); } .ludo-tray { --c: #fff; display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 6px; border: 0; background: none; color: var(--text); font: inherit; cursor: default; border-radius: 16px; } .ludo-tray.ready { cursor: pointer; } .ludo-die { width: 84px; aspect-ratio: 1; border-radius: 16px; transition: box-shadow .3s; } -.ludo-die svg { display: block; width: 100%; height: 100%; filter: drop-shadow(0 8px 12px rgba(0,0,0,.6)); } -.ludo-die .ld-face { fill: #fbf7ee; stroke: var(--c); stroke-width: 3.5; } -.ludo-die circle { fill: #2a2018; } +.ludo-die svg { display: block; width: 100%; height: 100%; filter: drop-shadow(0 7px 8px rgba(0,0,0,.65)); overflow: visible; } +/* a ring in the colour of the player whose turn it is */ +.ludo-die .ld-ring { fill: none; stroke: var(--c); stroke-width: 2.5; opacity: .9; } .ludo-die.rolling { visibility: hidden; } .ludo-tray.ready .ludo-die { animation: ludo-ready 1.4s ease-in-out infinite; } @keyframes ludo-ready { 50% { box-shadow: 0 0 0 8px color-mix(in srgb, var(--c) 0%, transparent), 0 0 30px color-mix(in srgb, var(--c) 70%, transparent); transform: translateY(-3px); } } diff --git a/css/tabla.css b/css/tabla.css index e9aa672..716825d 100644 --- a/css/tabla.css +++ b/css/tabla.css @@ -11,7 +11,7 @@ width: min(100%, calc((100cqh - 128px) * 980 / 760), 1100px); height: auto; max-height: 100%; border-radius: 18px; - box-shadow: 0 30px 60px -20px rgba(0, 0, 0, .8), 0 0 0 1px rgba(255,255,255,.06); + box-shadow: 0 34px 70px -18px rgba(0, 0, 0, .9), 0 10px 22px -8px rgba(0, 0, 0, .7), 0 0 0 1px rgba(0, 0, 0, .6); touch-action: manipulation; user-select: none; -webkit-user-select: none; } .tb-svg.rot { width: min(100%, calc((100cqh - 128px) * 760 / 980), 640px); } @@ -19,13 +19,13 @@ /* black sees the board flipped vertically – the labels are flipped back */ .tb-svg.flip .tb-num, .tb-svg.flip .tb-count { transform-box: fill-box; transform-origin: center; transform: scale(1, -1); } .tb-svg.rot.flip .tb-num, .tb-svg.rot.flip .tb-count { transform: scale(1, -1) rotate(-90deg); } -.tb-num { font-size: 12px; font-weight: 700; fill: rgba(255, 236, 200, .45); text-anchor: middle; font-family: var(--font); } -.tb-count { font-size: 22px; font-weight: 800; text-anchor: middle; font-family: var(--font); pointer-events: none; } +.tb-num { font-size: 11px; font-weight: 700; fill: rgba(255, 226, 180, .55); text-anchor: middle; font-family: var(--font); } +.tb-count { font-size: 22px; font-weight: 800; text-anchor: middle; font-family: var(--font); pointer-events: none; paint-order: stroke; stroke: rgba(0, 0, 0, .18); stroke-width: 3px; } .tb-ck { filter: url(#sh); } .tb-src { fill: rgba(255, 217, 120, .18); stroke: #ffd978; stroke-width: 5; stroke-dasharray: 10 6; animation: tb-spin 6s linear infinite; transform-box: fill-box; transform-origin: center; pointer-events: none; } .tb-src.sel { stroke: #ffd978; stroke-width: 6; stroke-dasharray: none; filter: drop-shadow(0 0 8px rgba(255, 217, 120, .9)); } .tb-dst { fill: rgba(255, 217, 120, .45); stroke: #ffd978; stroke-width: 5; animation: tb-glow 1.2s ease-in-out infinite alternate; } -.tb-die { transition: opacity .25s; filter: url(#sh); transform-box: fill-box; transform-origin: center; } +.tb-die { transition: opacity .25s; filter: drop-shadow(0 3px 2.5px rgba(20, 8, 0, .6)); transform-box: fill-box; transform-origin: center; } .tb-die.used { opacity: .3; } .tb-die.pop { animation: tb-pop .35s cubic-bezier(.3,1.5,.5,1) both; } @keyframes tb-pop { from { transform: scale(0); opacity: 0; } } diff --git a/js/games/dieface.js b/js/games/dieface.js new file mode 100644 index 0000000..94ae97b --- /dev/null +++ b/js/games/dieface.js @@ -0,0 +1,33 @@ +// The resting die (SVG, 58×58 box): the same look as the 3D die that lands on it – +// rounded ivory body, bevelled edge, engraved pips. +export const DIE_PIPS = { + 1: [[29, 29]], + 2: [[16, 16], [42, 42]], + 3: [[15, 15], [29, 29], [43, 43]], + 4: [[16, 16], [42, 16], [16, 42], [42, 42]], + 5: [[16, 16], [42, 16], [29, 29], [16, 42], [42, 42]], + 6: [[16, 15], [42, 15], [16, 29], [42, 29], [16, 43], [42, 43]], +}; + +// Gradients for the dice; id – a prefix that keeps the ids unique per . +export function dieDefs(id = 'die') { + return ` + + + + + + + + `; +} + +// SVG markup of one die face showing `value` (0 – a blank face). +export function dieMarkup(value, dark = false, id = 'die') { + const k = dark ? 'D' : 'L'; + const pips = (DIE_PIPS[value] || []).map(([x, y]) => + ``).join(''); + return ` + + ${pips}`; +} diff --git a/js/games/ludo/ludo.js b/js/games/ludo/ludo.js index a4938e7..8c9ad00 100644 --- a/js/games/ludo/ludo.js +++ b/js/games/ludo/ludo.js @@ -5,6 +5,7 @@ import { setupScreen, showResult, confirmDialog, sound, muteButton } from '../co import { rollDice3d, preloadDice } from '../tabla/dice3d.js'; import { START, TRACK, LANES, YARDS, ZONES, newGame, cell, cellAt, moves, apply, won, onTrack, aiChoose } from './engine.js'; import { txt } from '../../i18n.js'; +import { dieDefs, dieMarkup } from '../dieface.js'; const COLORS = [ { name: txt('red'), main: '#e23b4e', light: '#ff8a98', dark: '#7a0f1e' }, @@ -16,7 +17,6 @@ const CS = 60; // cell size const C = (x) => x * CS + CS / 2; const BOT_NAMES = [txt('You'), txt('Georgi'), txt('Maria'), txt('Petar')]; const LEVELS = { easy: txt('Easy'), normal: txt('Normal') }; -const PIP = { 1: [[29, 29]], 2: [[16, 16], [42, 42]], 3: [[15, 15], [29, 29], [43, 43]], 4: [[16, 16], [42, 16], [16, 42], [42, 42]], 5: [[16, 16], [42, 16], [29, 29], [16, 42], [42, 42]], 6: [[16, 15], [42, 15], [16, 29], [42, 29], [16, 43], [42, 43]] }; const still = () => motion.fast || matchMedia('(prefers-reduced-motion: reduce)').matches; // t from 0 to 1 over ms, fn(t) every frame @@ -99,7 +99,7 @@ export async function mount(root, ctx) { // token: position on the board (board units) + an upright part (counter-rotated) // with a shadow that stays on the board and a body that hops. const tokens = active.map((p) => [0, 1, 2, 3].map((t) => { - const shadow = s('ellipse', { cx: 0, cy: 16, rx: 19, ry: 7, fill: 'rgba(0,0,0,.4)' }); + const shadow = s('ellipse', { cx: 2, cy: 17, rx: 20, ry: 7.5, fill: 'url(#lshadow)' }); const body = s('g', null, pawn(p)); const g = s('g', { class: 'ludo-tok', 'data-p': p, 'data-t': t }, s('g', { transform: `rotate(${-angle})` }, @@ -117,8 +117,8 @@ export async function mount(root, ctx) { g.setAttribute('transform', `translate(${x} ${y})`); g.body.setAttribute('transform', `translate(0 ${-lift}) scale(${1 + squash * 0.12} ${1 - squash * 0.14})`); const k = 1 - Math.min(lift, 60) / 110; - g.shadow.setAttribute('rx', 19 * k); - g.shadow.setAttribute('ry', 7 * k); + g.shadow.setAttribute('rx', 20 * k); + g.shadow.setAttribute('ry', 7.5 * k); g.shadow.setAttribute('opacity', k); } const placeAt = (p, t, [x, y]) => put(tokEl(p, t), C(x), C(y)); @@ -160,7 +160,7 @@ export async function mount(root, ctx) { function drawDie(val, p) { dieEl.style.setProperty('--c', COLORS[p].main); - dieEl.innerHTML = ``; + dieEl.innerHTML = ``; dieEl.dataset.v = val || ''; } @@ -416,85 +416,113 @@ export async function mount(root, ctx) { } // ---------- drawing ---------- +// a mix of two #rrggbb colours: t = 0 → a, t = 1 → b +function mix(a, b, t) { + const ch = (c, i) => parseInt(c.slice(1 + i * 2, 3 + i * 2), 16); + return '#' + [0, 1, 2].map((i) => Math.round(ch(a, i) + (ch(b, i) - ch(a, i)) * t).toString(16).padStart(2, '0')).join(''); +} function defs() { const d = s('defs'); d.innerHTML = COLORS.map((c, i) => ` - - - `).join('') + ` - - - - - - - - `; + + + + `).join('') + ` + + + + + + + + + + + + + + `; return d; } -// A rounded tile on the square (with volume: shadow, top highlight, bottom edge). -function tile(g, x, y, fill, stroke = 'rgba(0,0,0,.25)') { - const t = s('g', { filter: 'url(#ltile)' }, - s('rect', { x: C(x) - 26, y: C(y) - 26, width: 52, height: 52, rx: 13, fill, stroke, 'stroke-width': 1.5 }), - s('rect', { x: C(x) - 22, y: C(y) - 23, width: 44, height: 16, rx: 8, fill: '#fff', opacity: 0.28 }), - ); - g.append(t); - return t; +// A field: a shallow dimple in the board with a printed ring around it. +function dimple(g, cx, cy, r, fill, ring) { + g.append(s('circle', { cx, cy, r: r + 3.2, fill: 'none', stroke: ring, 'stroke-width': 2.2 })); + g.append(s('circle', { cx, cy, r, fill })); + g.append(s('circle', { cx, cy, r, fill: 'url(#ldimS)' })); + g.append(s('circle', { cx, cy, r: r - 0.6, fill: 'none', stroke: 'url(#ldimR)', 'stroke-width': 1.5 })); } +// The board: birch plywood in a walnut frame; the yards and the home rows are painted on, +// every field is a dimple the pawn stands in, joined by the printed track line. function staticBoard() { const g = s('g'); - // base with a brass frame, texture and highlight - g.append(s('rect', { x: -22, y: -22, width: 704, height: 704, rx: 40, fill: 'url(#lbrass)' })); - g.append(s('rect', { x: -14, y: -14, width: 688, height: 688, rx: 34, fill: 'url(#lboard)' })); - g.append(s('rect', { x: -14, y: -14, width: 688, height: 688, rx: 34, filter: 'url(#lnoise)', 'pointer-events': 'none' })); + const np = { 'pointer-events': 'none' }; + const INK = '#4b2c12'; + g.append(s('rect', { x: -24, y: -24, width: 708, height: 708, rx: 38, fill: 'url(#lframe)' })); + g.append(s('rect', { x: -24, y: -24, width: 708, height: 708, rx: 38, filter: 'url(#lgrainD)', opacity: 0.5, ...np })); + g.append(s('rect', { x: -24, y: -24, width: 708, height: 708, rx: 38, fill: 'url(#lframeEdge)', ...np })); + g.append(s('rect', { x: -22.5, y: -22.5, width: 705, height: 705, rx: 36.5, fill: 'none', stroke: 'rgba(255,214,160,.3)', 'stroke-width': 1.5 })); + const b = s('g', { 'clip-path': 'url(#lclip)' }); + g.append(b); + b.append(s('rect', { x: -10, y: -10, width: 680, height: 680, fill: 'url(#lboard)' })); + b.append(s('rect', { x: -10, y: -10, width: 680, height: 680, filter: 'url(#lgrain)', opacity: 0.6, ...np })); + // the corner zones with the yards (the one whose turn it is glows) ZONES.forEach(([zx, zy], p) => { - const x = zx * CS + 6, y = zy * CS + 6, w = 4 * CS - 12; - g.append(s('rect', { x: x - 4, y: y - 4, width: w + 8, height: w + 8, rx: 34, class: 'ludo-zone', 'data-p': p, fill: 'none', stroke: COLORS[p].light, 'stroke-width': 5 })); - g.append(s('rect', { x, y, width: w, height: w, rx: 30, fill: `url(#lz${p})`, stroke: COLORS[p].light, 'stroke-width': 2 })); - g.append(s('rect', { x: x + 22, y: y + 22, width: w - 44, height: w - 44, rx: 24, fill: 'rgba(255,255,255,.9)' })); - g.append(s('rect', { x: x + 22, y: y + 22, width: w - 44, height: w - 44, rx: 24, fill: 'none', stroke: COLORS[p].dark, 'stroke-opacity': 0.35, 'stroke-width': 3 })); - for (const [cx, cy] of YARDS[p]) { - g.append(s('circle', { cx: C(cx), cy: C(cy), r: 24, fill: `url(#lz${p})`, opacity: 0.35 })); - g.append(s('circle', { cx: C(cx), cy: C(cy), r: 20, fill: 'url(#lhole)', stroke: COLORS[p].main, 'stroke-width': 2.5 })); - } + const x = zx * CS + 8, y = zy * CS + 8, w = 4 * CS - 16; + b.append(s('rect', { x: x - 5, y: y - 5, width: w + 10, height: w + 10, rx: 30, class: 'ludo-zone', 'data-p': p, fill: 'none', stroke: COLORS[p].light, 'stroke-width': 5 })); + b.append(s('rect', { x, y, width: w, height: w, rx: 26, fill: `url(#lz${p})` })); + b.append(s('rect', { x, y, width: w, height: w, rx: 26, filter: 'url(#lpaint)', opacity: 0.5, ...np })); + b.append(s('rect', { x, y, width: w, height: w, rx: 26, fill: 'none', stroke: COLORS[p].dark, 'stroke-opacity': 0.7, 'stroke-width': 2 })); + b.append(s('rect', { x: x + 9, y: y + 9, width: w - 18, height: w - 18, rx: 19, fill: 'none', stroke: 'rgba(255,255,255,.55)', 'stroke-width': 2 })); + for (const [cx, cy] of YARDS[p]) dimple(b, C(cx), C(cy), 22, 'url(#lcell)', 'rgba(255,255,255,.85)'); }); - // the track: light tiles; the start ones are in the player's colour with an arrow in the direction + + // the printed line that joins the track + const pts = TRACK.map(([x, y]) => `${C(x)},${C(y)}`).join(' '); + b.append(s('polygon', { points: pts, fill: 'none', stroke: INK, 'stroke-width': 5, 'stroke-linejoin': 'round', opacity: 0.55 })); + // the track: plain dimples; each start field is in the player's colour with an arrow TRACK.forEach(([x, y], i) => { const owner = START.indexOf(i); - tile(g, x, y, owner >= 0 ? `url(#lc${owner})` : 'url(#lcell)', owner >= 0 ? COLORS[owner].dark : 'rgba(0,0,0,.25)'); + dimple(b, C(x), C(y), 21, owner >= 0 ? `url(#lc${owner})` : 'url(#lcell)', owner >= 0 ? COLORS[owner].dark : INK); if (owner >= 0) { const [nx, ny] = TRACK[(i + 1) % TRACK.length]; const a = (Math.atan2(ny - y, nx - x) * 180) / Math.PI; - g.append(s('path', { d: 'M -9 -10 L 11 0 L -9 10 Z', fill: '#fff', opacity: 0.9, transform: `translate(${C(x)} ${C(y)}) rotate(${a})` })); + b.append(s('path', { d: 'M -8 -9 L 10 0 L -8 9 Z', fill: '#fff', opacity: 0.92, transform: `translate(${C(x)} ${C(y)}) rotate(${a})` })); } }); // the home rows - LANES.forEach((cells, p) => cells.forEach(([x, y]) => tile(g, x, y, `url(#lc${p})`, COLORS[p].dark))); - // centre: a four-colour star towards the home rows - const c = 330, r = 30, k = 10; - const tri = (pts, p) => g.append(s('polygon', { points: pts, fill: `url(#lz${p})`, stroke: 'rgba(0,0,0,.35)', 'stroke-width': 1.5 })); + LANES.forEach((cells, p) => cells.forEach(([x, y]) => dimple(b, C(x), C(y), 21, `url(#lc${p})`, COLORS[p].dark))); + // centre: a four-colour rosette pointing at the home rows + const c = 330, r = 27, k = 6; + const tri = (pp, p) => b.append(s('polygon', { points: pp, fill: `url(#lz${p})`, stroke: INK, 'stroke-width': 1.6, 'stroke-linejoin': 'round' })); tri(`${c - r - k},${c - r} ${c},${c} ${c - r - k},${c + r}`, 0); tri(`${c - r},${c - r - k} ${c},${c} ${c + r},${c - r - k}`, 1); tri(`${c + r + k},${c - r} ${c},${c} ${c + r + k},${c + r}`, 2); tri(`${c - r},${c + r + k} ${c},${c} ${c + r},${c + r + k}`, 3); - g.append(s('circle', { cx: c, cy: c, r: 9, fill: '#fff', opacity: 0.85 })); - g.append(s('rect', { x: -14, y: -14, width: 688, height: 688, rx: 34, fill: 'url(#lgloss)', 'pointer-events': 'none' })); + b.append(s('circle', { cx: c, cy: c, r: 7, fill: '#f6e7c4', stroke: INK, 'stroke-width': 1.6 })); + // the board sits a little below the frame + b.append(s('rect', { x: -10, y: -10, width: 680, height: 680, fill: 'url(#lvig)', ...np })); + b.append(s('rect', { x: -10, y: -10, width: 680, height: 680, fill: 'url(#lgloss)', ...np })); + g.append(s('rect', { x: -10, y: -10, width: 680, height: 680, rx: 26, fill: 'none', stroke: 'rgba(14,5,0,.8)', 'stroke-width': 3 })); + g.append(s('rect', { x: -8, y: -7, width: 676, height: 676, rx: 24, fill: 'none', stroke: 'rgba(0,0,0,.25)', 'stroke-width': 5, ...np })); return g; } -// A pawn with volume: base with a rim, body with a highlight and a head (the shadow is separate). +// A lacquered wooden pawn: a turned base, a cone, a collar and a ball head (the shadow is separate). function pawn(p) { - const f = `url(#lp${p})`, d = COLORS[p].dark; - return s('g', { filter: 'url(#lsh)', transform: 'scale(1.22)' }, - s('ellipse', { cx: 0, cy: 12, rx: 16, ry: 6.5, fill: f, stroke: d, 'stroke-width': 1.2 }), - s('ellipse', { cx: 0, cy: 9.5, rx: 13, ry: 4.5, fill: f, stroke: d, 'stroke-width': .8 }), - s('path', { d: 'M -10.5 10 Q -8 -1 -5.5 -8 L 5.5 -8 Q 8 -1 10.5 10 Z', fill: f, stroke: d, 'stroke-width': 1.2 }), - s('ellipse', { cx: 0, cy: -8, rx: 7.5, ry: 2.6, fill: f, stroke: d, 'stroke-width': 1 }), - s('circle', { cx: 0, cy: -15, r: 9, fill: f, stroke: d, 'stroke-width': 1.2 }), - s('path', { d: 'M -6 8 Q -5 -1 -3 -6', stroke: '#fff', 'stroke-opacity': .45, 'stroke-width': 2.2, fill: 'none', 'stroke-linecap': 'round' }), - s('ellipse', { cx: -3.2, cy: -18.5, rx: 3.4, ry: 2.2, fill: 'rgba(255,255,255,.8)' }), + const body = `url(#lb${p})`, head = `url(#lp${p})`, d = COLORS[p].dark; + return s('g', { transform: 'scale(1.24)' }, + s('path', { d: 'M -15.5 11 A 15.5 6 0 0 0 15.5 11 L 15.5 13.2 A 15.5 6 0 0 1 -15.5 13.2 Z', fill: d }), + s('ellipse', { cx: 0, cy: 11, rx: 15.5, ry: 6, fill: body, stroke: d, 'stroke-width': 0.9 }), + s('path', { d: 'M -12 9.5 C -9.5 2 -6.6 -3 -5.2 -9 L 5.2 -9 C 6.6 -3 9.5 2 12 9.5 A 12 4.4 0 0 1 -12 9.5 Z', fill: body, stroke: d, 'stroke-width': 0.9 }), + s('ellipse', { cx: 0, cy: -8.6, rx: 7.4, ry: 2.5, fill: body, stroke: d, 'stroke-width': 0.9 }), + s('circle', { cx: 0, cy: -16, r: 8.8, fill: head, stroke: d, 'stroke-width': 0.9 }), + // lacquer highlights + s('path', { d: 'M -6.6 6.5 C -5.4 1 -4 -3 -3 -7', stroke: '#fff', 'stroke-opacity': 0.55, 'stroke-width': 2, fill: 'none', 'stroke-linecap': 'round' }), + s('path', { d: 'M -11.5 10.6 A 13 4.6 0 0 1 -2 6.8', stroke: '#fff', 'stroke-opacity': 0.4, 'stroke-width': 1.3, fill: 'none', 'stroke-linecap': 'round' }), + s('ellipse', { cx: -3.2, cy: -19.6, rx: 3.3, ry: 2, fill: '#fff', opacity: 0.85, transform: 'rotate(-25 -3.2 -19.6)' }), + s('path', { d: 'M 4 -9.8 A 8.8 8.8 0 0 0 7.6 -20.5', stroke: '#fff', 'stroke-opacity': 0.22, 'stroke-width': 1.2, fill: 'none', 'stroke-linecap': 'round' }), ); } diff --git a/js/games/tabla/dice3d.js b/js/games/tabla/dice3d.js index 02edcb2..cae9375 100644 --- a/js/games/tabla/dice3d.js +++ b/js/games/tabla/dice3d.js @@ -73,7 +73,7 @@ function faceCanvas(v, dark, bump) { } else { const grd = g.createRadialGradient(N * 0.32, N * 0.28, N * 0.05, N * 0.5, N * 0.5, N * 0.78); if (dark) { grd.addColorStop(0, '#4a2a30'); grd.addColorStop(0.55, '#2a1418'); grd.addColorStop(1, '#14080a'); } - else { grd.addColorStop(0, '#fffdf7'); grd.addColorStop(0.55, '#f7f0e0'); grd.addColorStop(1, '#e6d9bc'); } + else { grd.addColorStop(0, '#ffffff'); grd.addColorStop(0.55, '#faf4e6'); grd.addColorStop(1, '#eadfc6'); } g.fillStyle = grd; g.fillRect(0, 0, N, N); // fine grain of the material @@ -101,7 +101,7 @@ function faceCanvas(v, dark, bump) { // an engraved pip: a dark hollow + a light edge at the bottom right const hole = g.createRadialGradient(x - r * 0.25, y - r * 0.25, r * 0.1, x, y, r); if (dark) { hole.addColorStop(0, '#fffaf0'); hole.addColorStop(0.7, '#efe3c6'); hole.addColorStop(1, '#bfae88'); } - else { hole.addColorStop(0, '#0e0a08'); hole.addColorStop(0.7, '#2a1f18'); hole.addColorStop(1, '#4a3a2c'); } + else { hole.addColorStop(0, '#050302'); hole.addColorStop(0.65, '#231912'); hole.addColorStop(1, '#4d3c2e'); } g.fillStyle = hole; g.beginPath(); g.arc(x, y, r, 0, Math.PI * 2); @@ -116,7 +116,7 @@ function faceCanvas(v, dark, bump) { } // A cube with rounded edges: the vertices of the segmented cube "bulge" towards the corners. -function roundedBox(THREE, radius = 0.12, seg = 6) { +function roundedBox(THREE, radius = 0.14, seg = 8) { const geo = new THREE.BoxGeometry(1, 1, 1, seg, seg, seg); const pos = geo.attributes.position, nor = geo.attributes.normal; const inner = 0.5 - radius; @@ -141,7 +141,7 @@ function setup({ THREE }) { renderer.setPixelRatio(Math.min(2, devicePixelRatio || 1)); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; - renderer.toneMappingExposure = 1.1; + renderer.toneMappingExposure = 1.22; renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; const scene = new THREE.Scene(); diff --git a/js/games/tabla/tabla.js b/js/games/tabla/tabla.js index dbc68b4..dfd6493 100644 --- a/js/games/tabla/tabla.js +++ b/js/games/tabla/tabla.js @@ -3,6 +3,7 @@ import { icon } from '../../icons.js'; import { setupScreen, showResult, confirmDialog, sound, muteButton, loadPrefs } from '../common.js'; import { variantEngine } from './variants.js'; import { rollDice3d, preloadDice } from './dice3d.js'; +import { dieDefs, dieMarkup } from '../dieface.js'; import { offlineNet } from '../../net/room.js'; import { txt } from '../../i18n.js'; @@ -164,7 +165,9 @@ export async function mount(root, ctx) { for (const p of [0, 1]) { for (let k = 0; k < st.off[p]; k++) { const [x, y] = offXY(p, k); - const g = s('rect', { x: x + 6, y, width: 78, height: 13, rx: 4, fill: p === 0 ? 'url(#ckLight)' : 'url(#ckDark)', stroke: p === 0 ? '#a99a7c' : '#000' }); + const g = s('g', { filter: 'url(#shOff)' }, + s('rect', { x: x + 6, y, width: 78, height: 13, rx: 4.5, fill: p === 0 ? 'url(#ckLight)' : 'url(#ckDark)', stroke: p === 0 ? '#9c8c6c' : '#000', 'stroke-width': 0.8 }), + s('rect', { x: x + 10, y: y + 1.6, width: 70, height: 2.6, rx: 1.3, fill: '#fff', opacity: p === 0 ? 0.75 : 0.22 })); checkerLayer.append(g); (els['off' + p] ||= []).push(g); } @@ -213,10 +216,8 @@ export async function mount(root, ctx) { list.forEach((d, i) => { const [x, y] = diePos(i, list.length, turn); const dark = openingColors ? i === 1 : turn === 1; - const g = s('g', { class: `tb-die ${used[i] ? 'used' : ''} ${pop && (i === 0 || i === 3) ? 'pop' : ''}` }, - s('rect', { width: 58, height: 58, rx: 12, fill: dark ? '#2b1e22' : '#fbf7ee', stroke: dark ? '#000' : '#c9b994', 'stroke-width': 2 }), - ...PIP_POS[d].map(([px, py]) => s('circle', { cx: px, cy: py, r: 5.6, fill: dark ? '#f3e6c8' : '#2a2018' })), - ); + const g = s('g', { class: `tb-die ${used[i] ? 'used' : ''} ${pop && (i === 0 || i === 3) ? 'pop' : ''}` }); + g.innerHTML = dieMarkup(d, dark, 'tbd'); diceLayer.append(s('g', { transform: `translate(${x} ${y})` }, g)); }); } @@ -660,24 +661,19 @@ export async function mount(root, ctx) { return { destroy() { destroyed = true; offEvents(); resizeObs?.disconnect(); } }; } -const PIP_POS = { - 1: [[29, 29]], - 2: [[16, 16], [42, 42]], - 3: [[15, 15], [29, 29], [43, 43]], - 4: [[16, 16], [42, 16], [16, 42], [42, 42]], - 5: [[16, 16], [42, 16], [29, 29], [16, 42], [42, 42]], - 6: [[16, 15], [42, 15], [16, 29], [42, 29], [16, 43], [42, 43]], -}; -// Checker: domed (radial gradient), bevelled rim, two engraved rings (dark line + -// a light one beneath it) and a highlight at the top left. -function pulSymbol(id, body, rim, groove, lite, hi) { +// Checker: a turned disc with thickness (the side wall shows below the face), a bevelled +// rim, two engraved rings (dark line + a light one beneath it) and a highlight at the top left. +function pulSymbol(id, body, rim, side, groove, lite, hi) { const ring = (r, w) => ``; return ` - - - ${ring(21, 1.6)}${ring(12.5, 1.2)} - + + + + + + ${ring(20, 1.6)}${ring(12, 1.2)} + `; } @@ -685,47 +681,105 @@ function pulSymbol(id, body, rim, groove, lite, hi) { function buildDefs() { const defs = s('defs'); defs.innerHTML = ` - - - - - - - - + + + + + + + + + + + + + + + + + + - - + + + - ${pulSymbol('pulL', 'pulLb', 'pulLr', 'rgba(110,85,50,.5)', 'rgba(255,255,255,.75)', 0.8)} - ${pulSymbol('pulD', 'pulDb', 'pulDr', 'rgba(0,0,0,.7)', 'rgba(255,130,150,.28)', 0.35)} + ${pulSymbol('pulL', 'pulLb', 'pulLr', '#8f7f60', 'rgba(110,85,50,.5)', 'rgba(255,255,255,.75)', 0.8)} + ${pulSymbol('pulD', 'pulDb', 'pulDr', '#0d0206', 'rgba(0,0,0,.7)', 'rgba(255,130,150,.28)', 0.4)} + ${dieDefs('tbd')} - `; + + + + `; return defs; } +// The board: a walnut case, two recessed oak fields with inlaid maple/walnut points, +// a hinged bar in the middle and a tray for the borne-off checkers. function drawStatic(g, me = 0) { + const np = { 'pointer-events': 'none' }; + // case g.append(s('rect', { x: 0, y: 0, width: W, height: H, rx: 22, fill: 'url(#wood)' })); - // wood grain - g.append(s('rect', { x: 0, y: 0, width: W, height: H, rx: 22, filter: 'url(#grain)', opacity: 0.35, 'pointer-events': 'none' })); - g.append(s('rect', { x: 2, y: 2, width: W - 4, height: H - 4, rx: 20, fill: 'none', stroke: 'rgba(255,220,170,.18)', 'stroke-width': 2 })); - for (const x of [LEFT_X, RIGHT_X]) { - g.append(s('rect', { x, y: F, width: 6 * PW, height: H - 2 * F, rx: 6, fill: 'url(#felt)' })); - g.append(s('rect', { x, y: F, width: 6 * PW, height: H - 2 * F, rx: 6, filter: 'url(#fuzz)', 'pointer-events': 'none' })); - g.append(s('rect', { x, y: F, width: 6 * PW, height: H - 2 * F, rx: 6, fill: 'none', stroke: 'rgba(0,0,0,.45)', 'stroke-width': 3 })); + g.append(s('rect', { x: 0, y: 0, width: W, height: H, rx: 22, filter: 'url(#grain)', opacity: 0.5, ...np })); + g.append(s('rect', { x: 0, y: 0, width: W, height: H, rx: 22, fill: 'url(#woodEdge)', ...np })); + g.append(s('rect', { x: 1.5, y: 1.5, width: W - 3, height: H - 3, rx: 21, fill: 'none', stroke: 'rgba(255,214,160,.28)', 'stroke-width': 1.5 })); + // brass inlay line around the case + g.append(s('rect', { x: 7, y: 7, width: W - 14, height: H - 14, rx: 16, fill: 'none', stroke: 'url(#brass)', 'stroke-width': 1.6, opacity: 0.75 })); + + const tri = (i) => { + const x = pointX(i), b = isTop(i) ? F : H - F, tip = isTop(i) ? F + TRI_H : H - F - TRI_H; + return `M${x + 3} ${b} L${x + PW / 2} ${tip} L${x + PW - 3} ${b} Z`; + }; + [[LEFT_X, 'clipL'], [RIGHT_X, 'clipR']].forEach(([x, clip]) => { + const w = 6 * PW, hgt = H - 2 * F; + const f = s('g', { 'clip-path': `url(#${clip})` }); + f.append(s('rect', { x, y: F, width: w, height: hgt, fill: 'url(#field)' })); + f.append(s('rect', { x, y: F, width: w, height: hgt, filter: 'url(#grainV)', opacity: 0.55, ...np })); + // the points are veneer set into the field: fill, grain, a fine dark joint + for (let i = 0; i < 24; i++) { + const px = pointX(i); + if (px < x || px >= x + w) continue; + const dark = i % 2 === 0; + f.append(s('path', { d: tri(i), fill: dark ? 'url(#triB)' : 'url(#triA)' })); + f.append(s('path', { d: tri(i), fill: 'none', stroke: dark ? 'rgba(20,8,2,.8)' : 'rgba(92,58,22,.75)', 'stroke-width': 1.1, 'stroke-linejoin': 'round' })); + // a light chamfer on the left side of the point + const b = isTop(i) ? F : H - F, tip = isTop(i) ? F + TRI_H : H - F - TRI_H; + f.append(s('path', { d: `M${px + 4.6} ${b} L${px + PW / 2} ${tip - (isTop(i) ? 6 : -6)}`, stroke: dark ? 'rgba(255,190,130,.2)' : 'rgba(255,255,255,.55)', 'stroke-width': 1.2, fill: 'none' })); + } + f.append(s('rect', { x, y: F, width: w, height: hgt, filter: 'url(#pores)', opacity: 0.35, ...np })); + // the field is sunk into the case: shade under the top and left walls, vignette + f.append(s('rect', { x, y: F, width: w, height: hgt, fill: 'url(#fieldVig)', ...np })); + f.append(s('rect', { x, y: F, width: w, height: 16, fill: 'url(#fieldDrop)', ...np })); + f.append(s('rect', { x, y: F, width: 11, height: hgt, fill: 'url(#fieldDropX)', ...np })); + g.append(f); + g.append(s('rect', { x: x - 0.5, y: F - 0.5, width: w + 1, height: hgt + 1, rx: 5.5, fill: 'none', stroke: 'rgba(12,4,0,.75)', 'stroke-width': 2 })); + g.append(s('path', { d: `M${x + 4} ${H - F + 1.2} H${x + w - 4} M${x + w + 1.2} ${F + 4} V${H - F - 4}`, stroke: 'rgba(255,215,160,.3)', 'stroke-width': 1.2, fill: 'none' })); + }); + + // the bar with two brass hinges + g.append(s('rect', { x: BAR_X + 6, y: F, width: 48, height: H - 2 * F, rx: 6, fill: 'url(#slot)', stroke: 'rgba(0,0,0,.6)', 'stroke-width': 1.5 })); + g.append(s('line', { x1: BAR_X + 30, y1: F + 3, x2: BAR_X + 30, y2: H - F - 3, stroke: 'rgba(0,0,0,.75)', 'stroke-width': 2 })); + g.append(s('line', { x1: BAR_X + 31.4, y1: F + 3, x2: BAR_X + 31.4, y2: H - F - 3, stroke: 'rgba(255,200,140,.16)', 'stroke-width': 1 })); + for (const hy of [H / 2 - 58, H / 2 + 34]) { + const hg = s('g', { filter: 'url(#shOff)' }); + hg.append(s('rect', { x: BAR_X + 12, y: hy, width: 36, height: 24, rx: 2.5, fill: 'url(#brass)', stroke: '#5e4012', 'stroke-width': 0.8 })); + hg.append(s('rect', { x: BAR_X + 27.5, y: hy - 2, width: 5, height: 28, rx: 2.5, fill: 'url(#brass)', stroke: '#5e4012', 'stroke-width': 0.7 })); + for (const [sx, sy] of [[18.5, 6], [18.5, 18], [41.5, 6], [41.5, 18]]) { + hg.append(s('circle', { cx: BAR_X + sx, cy: hy + sy, r: 2.3, fill: '#8a6420', stroke: '#4a300a', 'stroke-width': 0.6 })); + hg.append(s('line', { x1: BAR_X + sx - 1.5, y1: hy + sy - 1, x2: BAR_X + sx + 1.5, y2: hy + sy + 1, stroke: '#3a2406', 'stroke-width': 0.8 })); + } + g.append(hg); + } + // trays for the borne-off checkers + for (const y of [F, H / 2 + 8]) { + const th = H / 2 - F - 8; + g.append(s('rect', { x: TRAY_X, y, width: 90, height: th, rx: 8, fill: 'url(#slot)', stroke: 'rgba(0,0,0,.65)', 'stroke-width': 1.5 })); + g.append(s('rect', { x: TRAY_X + 1, y: y + 1, width: 88, height: 14, rx: 7, fill: 'url(#fieldDrop)', ...np })); + g.append(s('path', { d: `M${TRAY_X + 6} ${y + th + 1} H${TRAY_X + 84}`, stroke: 'rgba(255,215,160,.25)', 'stroke-width': 1.2 })); } - g.append(s('rect', { x: BAR_X + 6, y: F, width: 48, height: H - 2 * F, rx: 6, fill: '#2e180a' })); - g.append(s('rect', { x: TRAY_X, y: F, width: 90, height: H / 2 - F - 8, rx: 8, fill: '#2e180a' })); - g.append(s('rect', { x: TRAY_X, y: H / 2 + 8, width: 90, height: H / 2 - F - 8, rx: 8, fill: '#2e180a' })); for (let i = 0; i < 24; i++) { - const x = pointX(i); - const fill = i % 2 === 0 ? 'url(#triB)' : 'url(#triA)'; - const d = isTop(i) - ? `M${x + 2} ${F} L${x + PW / 2} ${F + TRI_H} L${x + PW - 2} ${F} Z` - : `M${x + 2} ${H - F} L${x + PW / 2} ${H - F - TRI_H} L${x + PW - 2} ${H - F} Z`; - g.append(s('path', { d, fill, opacity: 0.95 })); // the numbering is from the player's point of view - const label = s('text', { x: x + PW / 2, y: isTop(i) ? 14 : H - 5, class: 'tb-num' }, String(me === 0 ? i + 1 : 24 - i)); - g.append(label); + g.append(s('text', { x: pointX(i) + PW / 2, y: isTop(i) ? 14 : H - 5, class: 'tb-num' }, String(me === 0 ? i + 1 : 24 - i))); } } diff --git a/sw.js b/sw.js index 7c9f414..dd742d1 100644 --- a/sw.js +++ b/sw.js @@ -1,6 +1,6 @@ // Service worker: offline support and fast loading. // When files change, bump VERSION so the cache is refreshed. -const VERSION = 'evil-v40'; +const VERSION = 'evil-v41'; self.addEventListener('install', (e) => { self.skipWaiting();