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:
parent
c6337f52c3
commit
5abe960ffd
2 changed files with 50 additions and 1 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue