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) <noreply@anthropic.com>
This commit is contained in:
maverick 2026-07-23 22:00:02 +10:00
parent c6337f52c3
commit 5abe960ffd
2 changed files with 50 additions and 1 deletions

View file

@ -171,7 +171,10 @@ class BoardUI {
setOrientation(o) { this.orient = o; this.render(); } setOrientation(o) { this.orient = o; this.render(); }
load(fen, lastMove) { load(fen, lastMove) {
try { this.chess.load(fen); } catch {} try { this.chess.load(fen); } catch {}
const prev = this.lastMove;
this.lastMove = lastMove || null; 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.selected = null;
this.render(); this.render();
} }
@ -195,6 +198,7 @@ class BoardUI {
const dark = (FILES.indexOf(file) + rank) % 2 === 0; const dark = (FILES.indexOf(file) + rank) % 2 === 0;
const cell = el(`<div class="sq ${dark ? 'dark' : 'light'}" data-sq="${sq}"></div>`); const cell = el(`<div class="sq ${dark ? 'dark' : 'light'}" data-sq="${sq}"></div>`);
if (this.lastMove && (sq === this.lastMove.from || sq === this.lastMove.to)) cell.classList.add('last'); 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 === this.selected) cell.classList.add('sel');
if (sq === kingSq) cell.classList.add('check'); if (sq === kingSq) cell.classList.add('check');
@ -211,8 +215,41 @@ class BoardUI {
if (legalTo.has(sq)) cell.appendChild(el(`<div class="hint ${p ? 'cap' : ''}"></div>`)); if (legalTo.has(sq)) cell.appendChild(el(`<div class="hint ${p ? 'cap' : ''}"></div>`));
board.appendChild(cell); board.appendChild(cell);
}); });
this._drawLastArrow();
this._pulse = false;
if (this.pendingPromo) this._renderPromo(); 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 class="arrows" viewBox="0 0 8 8" preserveAspectRatio="none"></svg>`);
svg.innerHTML =
`<line x1="${sx}" y1="${sy}" x2="${baseX}" y2="${baseY}" class="arrow-line"/>` +
`<polygon points="${tipX},${tipY} ${baseX + px * hw},${baseY + py * hw} ${baseX - px * hw},${baseY - py * hw}" class="arrow-head"/>`;
this.board.appendChild(svg);
}
_kingSquare(color) { _kingSquare(color) {
for (const sq of this.squares()) { const p = this.chess.get(sq); if (p && p.type === 'k' && p.color === color) return sq; } for (const sq of this.squares()) { const p = this.chess.get(sq); if (p && p.type === 'k' && p.color === color) return sq; }
return null; return null;

View file

@ -116,11 +116,23 @@ main { flex: 1; width: 100%; }
/* board */ /* board */
.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); display: grid; grid-template-columns: repeat(8, 1fr); grid-template-rows: repeat(8, 1fr);
border-radius: 10px; overflow: hidden; border: 1px solid var(--line); 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; 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 { position: relative; display: flex; align-items: center; justify-content: center; }
.sq.light { background: var(--light-sq); } .sq.light { background: var(--light-sq); }
.sq.dark { background: var(--dark-sq); } .sq.dark { background: var(--dark-sq); }