From 5abe960ffd8cd2b173c65515a43cc04531fc61a7 Mon Sep 17 00:00:00 2001 From: maverick Date: Thu, 23 Jul 2026 22:00:02 +1000 Subject: [PATCH] Add last-move arrow + destination pulse so opponent moves are obvious Draws a lichess-style arrow from the last move's origin to its destination (persists until you reply) and pulses the destination square when a new move lands, so the opponent's move can't be missed. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/app.js | 37 +++++++++++++++++++++++++++++++++++++ public/style.css | 14 +++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/client/app.js b/client/app.js index d1af83d..a90e19a 100644 --- a/client/app.js +++ b/client/app.js @@ -171,7 +171,10 @@ class BoardUI { setOrientation(o) { this.orient = o; this.render(); } load(fen, lastMove) { try { this.chess.load(fen); } catch {} + const prev = this.lastMove; this.lastMove = lastMove || null; + // flag a genuinely new move so render() can pulse the destination square + this._pulse = !!(this.lastMove && (!prev || prev.from !== this.lastMove.from || prev.to !== this.lastMove.to)); this.selected = null; this.render(); } @@ -195,6 +198,7 @@ class BoardUI { const dark = (FILES.indexOf(file) + rank) % 2 === 0; const cell = el(`
`); if (this.lastMove && (sq === this.lastMove.from || sq === this.lastMove.to)) cell.classList.add('last'); + if (this.lastMove && sq === this.lastMove.to) { cell.classList.add('lastto'); if (this._pulse) cell.classList.add('pulse'); } if (sq === this.selected) cell.classList.add('sel'); if (sq === kingSq) cell.classList.add('check'); @@ -211,8 +215,41 @@ class BoardUI { if (legalTo.has(sq)) cell.appendChild(el(`
`)); board.appendChild(cell); }); + this._drawLastArrow(); + this._pulse = false; if (this.pendingPromo) this._renderPromo(); } + // column/row (0..7) of a square in the CURRENT board orientation + _cellPos(sq) { + const f = FILES.indexOf(sq[0]); + const r = +sq[1]; + return this.orient === 'w' + ? { col: f, row: 8 - r } + : { col: 7 - f, row: r - 1 }; + } + // draw a lichess-style arrow from the last move's origin to its destination + _drawLastArrow() { + const m = this.lastMove; + if (!m || !m.from || !m.to) return; + const a = this._cellPos(m.from), b = this._cellPos(m.to); + const x1 = a.col + 0.5, y1 = a.row + 0.5; + const x2 = b.col + 0.5, y2 = b.row + 0.5; + const dx = x2 - x1, dy = y2 - y1; + const len = Math.hypot(dx, dy) || 1; + const ux = dx / len, uy = dy / len; + const head = 0.42; // arrowhead length (in square units) + const start = 0.34; // pull the tail out from the piece center + const sx = x1 + ux * start, sy = y1 + uy * start; + const tipX = x2 - ux * 0.06, tipY = y2 - uy * 0.06; + const baseX = tipX - ux * head, baseY = tipY - uy * head; + const px = -uy, py = ux; // perpendicular + const hw = 0.24; // half-width of arrowhead base + const svg = el(``); + svg.innerHTML = + `` + + ``; + this.board.appendChild(svg); + } _kingSquare(color) { for (const sq of this.squares()) { const p = this.chess.get(sq); if (p && p.type === 'k' && p.color === color) return sq; } return null; diff --git a/public/style.css b/public/style.css index 6e2a627..4c8f8b4 100644 --- a/public/style.css +++ b/public/style.css @@ -116,11 +116,23 @@ main { flex: 1; width: 100%; } /* board */ .board { - width: min(72vh, 100%); aspect-ratio: 1; margin: 0 auto; + width: min(72vh, 100%); aspect-ratio: 1; margin: 0 auto; position: relative; display: grid; grid-template-columns: repeat(8, 1fr); grid-template-rows: repeat(8, 1fr); border-radius: 10px; overflow: hidden; border: 1px solid var(--line); box-shadow: 0 18px 60px rgba(0,0,0,.5); touch-action: none; user-select: none; } +/* last-move arrow overlay */ +.arrows { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 4; } +.arrow-line { stroke: var(--red); stroke-width: .17; stroke-linecap: round; opacity: .82; } +.arrow-head { fill: var(--red); opacity: .82; } +/* destination-square emphasis + one-shot pulse when a new move lands */ +.sq.lastto::before { content: ''; position: absolute; inset: 0; box-shadow: inset 0 0 0 3px var(--red); opacity: .55; z-index: 1; } +.sq.pulse::before { animation: sqpulse 1.1s ease-out 2; } +@keyframes sqpulse { + 0% { box-shadow: inset 0 0 0 3px var(--red); opacity: 1; } + 60% { box-shadow: inset 0 0 0 7px var(--red); opacity: .2; } + 100% { box-shadow: inset 0 0 0 3px var(--red); opacity: .55; } +} .sq { position: relative; display: flex; align-items: center; justify-content: center; } .sq.light { background: var(--light-sq); } .sq.dark { background: var(--dark-sq); }