The code behind the board.
The chess experiment plays by chessops, which is licensed under the GNU GPL-3.0, so the code in your browser that builds on it is published here under the same licence: every file that imports chessops, collected from the site’s repository each time the site is built, so what you read is what runs.
<script setup lang="ts">
import type { NormalMove, Role } from 'chessops/types'
import { BOARD_THEMES, isDarkSquare, NAMES, squareGlyph, squareName, type BoardTheme, type ChessFont } from '#core/domain/chess960'
import type { Ply } from '~/composables/useChessGame'
// The playable board: 64 squares in the chess font, each glyph a piece (or empty) already shaded for its square.
// Click, drag, or use the keyboard (arrows move a roving focus; Enter or Space picks and places).
const props = defineProps<{
squares: string[]
font: ChessFont
theme: BoardTheme
flipped: boolean
interactive: boolean
targets: (from: number) => Map<number, NormalMove>
needsPromotion: (move: NormalMove) => boolean
check?: number
last?: Ply
plyCount: number
/** While an engine thinks, the person's colour: their pieces stay movable, and a move becomes a premove. */
premoveColor?: 'white' | 'black'
premove?: { from: number; to: number }
/** Moves to draw as arrows (Jev's candidates while it decides), each with its weight 0–1. */
arrows?: { from: number; to: number; p: number }[]
}>()
const emit = defineEmits<{ move: [NormalMove]; premove: [{ from: number; to: number }]; cancelPremove: [] }>()
const family = computed(() => `'Chess ${props.font}', var(--font-mono)`)
const themeVars = computed(() => {
const t = BOARD_THEMES[props.theme]
return { '--board-ground': `var(--color-${t.ground})`, '--board-ink': `var(--color-${t.ink})`, '--board-mark': `var(--color-${t.mark})` }
})
// an arrow runs between square centres on an 8×8 grid laid over the squares; its width and ink follow its weight
const centre = (sq: number) => { const f = sq & 7, r = sq >> 3; return props.flipped ? [7 - f + 0.5, r + 0.5] : [f + 0.5, 7 - r + 0.5] }
const arrowLines = computed(() => (props.arrows ?? []).filter((a) => a.p >= 0.02).map((a) => {
const [x1, y1] = centre(a.from), [x2, y2] = centre(a.to)
// stop short of the centre, so the head (0.42 wide, its tip 0.3 past the line's end) points at the square
const len = Math.hypot(x2! - x1!, y2! - y1!), cut = 0.35 / len
return { key: `${a.from}-${a.to}`, x1, y1, x2: x2! - (x2! - x1!) * cut, y2: y2! - (y2! - y1!) * cut, w: 0.07 + a.p * 0.13, o: 0.35 + a.p * 0.6 }
}))
const order = computed(() => Array.from({ length: 64 }, (_, i) => {
const row = i >> 3, col = i & 7
return props.flipped ? row * 8 + (7 - col) : (7 - row) * 8 + col
}))
const selected = ref<number>()
const legal = computed(() => (selected.value === undefined ? new Map<number, NormalMove>() : props.targets(selected.value)))
const promoting = ref<NormalMove>()
const hidden = ref(new Set<number>())
const focus = ref(12)
const board = ref<HTMLElement>()
const ownPiece = (sq: number, white: boolean) => { const p = props.squares[sq]; return !!p && (p === p.toUpperCase()) === white }
const whiteToMove = computed(() => props.plyCount % 2 === 0)
const premoving = computed(() => !props.interactive && !!props.premoveColor)
const mover = computed(() => (props.interactive ? (whiteToMove.value ? 'white' : 'black') : props.premoveColor))
function pick(sq: number) {
if (premoving.value) {
// any square will do for a premove; it is checked once the engine has moved
if (selected.value !== undefined && sq !== selected.value) { emit('premove', { from: selected.value, to: sq }); selected.value = undefined; return }
if (selected.value === undefined && !ownPiece(sq, mover.value === 'white')) { emit('cancelPremove'); return }
selected.value = selected.value === sq ? undefined : sq
return
}
if (!props.interactive) return
const move = legal.value.get(sq)
if (move) {
selected.value = undefined
if (props.needsPromotion(move)) { promoting.value = move; return }
emit('move', move)
return
}
selected.value = selected.value !== sq && ownPiece(sq, mover.value === 'white') && props.targets(sq).size ? sq : undefined
}
function promote(role: Role) {
if (!promoting.value) return
emit('move', { ...promoting.value, promotion: role })
promoting.value = undefined
}
watch(() => props.plyCount, () => { if (!premoving.value) selected.value = undefined; promoting.value = undefined })
function label(sq: number) {
const p = props.squares[sq]
const piece = p ? `${p === p.toUpperCase() ? 'white' : 'black'} ${NAMES[p.toUpperCase()]!.toLowerCase()}` : 'empty'
const extra = sq === selected.value ? ', selected' : legal.value.has(sq) ? ', move here' : ''
return `${squareName(sq)}, ${piece}${extra}`
}
function key(e: KeyboardEvent) {
const step: Record<string, [number, number]> = { ArrowUp: [0, 1], ArrowDown: [0, -1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] }
const d = step[e.key]
if (!d) return
e.preventDefault()
const [df, dr] = props.flipped ? [-d[0], -d[1]] : d
const f = Math.min(7, Math.max(0, (focus.value & 7) + df)), r = Math.min(7, Math.max(0, (focus.value >> 3) + dr))
focus.value = r * 8 + f
board.value?.querySelector<HTMLElement>(`[data-sq="${focus.value}"]`)?.focus()
}
// A piece in flight (animation and drag). A White glyph is only an outline, so the board would show through it:
// the same piece's solid Black glyph goes underneath in Paper to fill the body.
function makeTile(piece: string, size: number) {
const tile = document.createElement('span')
tile.className = 'board__tile'
tile.append(
Object.assign(document.createElement('span'), { className: 'board__fill', textContent: squareGlyph(piece.toLowerCase(), false, props.font) }),
Object.assign(document.createElement('span'), { textContent: squareGlyph(piece, false, props.font) }),
)
Object.assign(tile.style, { width: `${size}px`, height: `${size}px`, fontFamily: family.value })
board.value!.append(tile)
return tile
}
// A drag starts only after a few pixels of movement, so a press without movement stays a click.
let drag: { from: number; x: number; y: number; tile?: HTMLElement; size: number } | undefined
let dropped = false
let swallowClick = false
const movable = (sq: number) => (props.interactive || premoving.value) && !!mover.value && ownPiece(sq, mover.value === 'white')
function down(sq: number, e: PointerEvent) {
if (!movable(sq) || e.button !== 0) return
const size = board.value!.querySelector<HTMLElement>(`[data-sq="${sq}"]`)!.getBoundingClientRect().width
drag = { from: sq, x: e.clientX, y: e.clientY, size }
window.addEventListener('pointermove', moveDrag)
window.addEventListener('pointerup', endDrag, { once: true })
window.addEventListener('pointercancel', cancelDrag, { once: true })
}
function place(tile: HTMLElement, x: number, y: number, size: number) {
const o = board.value!.getBoundingClientRect()
tile.style.left = `${x - o.left - size / 2}px`
tile.style.top = `${y - o.top - size / 2}px`
}
function moveDrag(e: PointerEvent) {
if (!drag) return
if (!drag.tile) {
if (Math.hypot(e.clientX - drag.x, e.clientY - drag.y) < 5) return
selected.value = drag.from
hidden.value = new Set([drag.from])
drag.tile = makeTile(props.squares[drag.from]!, drag.size)
drag.tile.classList.add('is-dragging')
}
place(drag.tile, e.clientX, e.clientY, drag.size)
}
function cleanup() {
window.removeEventListener('pointermove', moveDrag)
drag?.tile?.remove()
hidden.value = new Set()
drag = undefined
}
function endDrag(e: PointerEvent) {
window.removeEventListener('pointercancel', cancelDrag)
if (!drag?.tile) return cleanup()
swallowClick = true
const target = document.elementFromPoint(e.clientX, e.clientY)?.closest<HTMLElement>('[data-sq]')
const to = target ? Number(target.dataset.sq) : undefined
const from = drag.from
cleanup()
if (premoving.value) { selected.value = undefined; if (to !== undefined && to !== from) emit('premove', { from, to }); return }
if (to !== undefined && legal.value.has(to)) { dropped = true; pick(to) } else selected.value = undefined
}
function cancelDrag() { window.removeEventListener('pointerup', endDrag); cleanup() }
function clicked(sq: number) {
if (swallowClick) { swallowClick = false; return }
focus.value = sq
pick(sq)
}
onBeforeUnmount(cleanup)
// Pieces are hidden on their new squares while their glyphs slide in from the old ones.
async function animate(ply: Ply) {
if (dropped) { dropped = false; return }
if (useReducedMotion() || !board.value) return
const paths: [number, number][] = ply.castle
? [[ply.move.from, ply.castle.kingTo], [ply.castle.rookFrom, ply.castle.rookTo]]
: [[ply.move.from, ply.move.to]]
hidden.value = new Set(paths.map(([, to]) => to))
await nextTick()
const rect = (sq: number) => board.value!.querySelector<HTMLElement>(`[data-sq="${sq}"]`)!.getBoundingClientRect()
const origin = board.value.getBoundingClientRect()
await Promise.all(paths.map(([from, to]) => {
const a = rect(from), b = rect(to)
const tile = makeTile(props.squares[to]!, a.width)
Object.assign(tile.style, { left: `${a.left - origin.left}px`, top: `${a.top - origin.top}px` })
const dx = b.left - a.left, dy = b.top - a.top
return tile.animate(
[{ transform: 'translate(0, 0)' }, { transform: `translate(${dx}px, ${dy}px)` }],
{ duration: 240, easing: 'cubic-bezier(.2, .7, .1, 1)' },
).finished.finally(() => tile.remove())
}))
hidden.value = new Set()
}
watch(() => props.last, (ply, prev) => { if (ply && ply !== prev && props.plyCount > 0) void animate(ply) })
</script>
<template>
<div ref="board" class="board__grid" role="grid" aria-label="Chess board" :style="{ fontFamily: family, ...themeVars }" @keydown="key">
<button
v-for="sq in order" :key="sq" type="button" class="board__sq" :data-sq="sq"
:class="{
'is-selected': sq === selected,
'is-target': legal.has(sq),
'is-capture': legal.has(sq) && !!squares[sq],
'is-last': last && (sq === last.move.from || sq === (last.castle?.kingTo ?? last.move.to)),
'is-check': sq === check,
'is-movable': movable(sq),
'is-premove': premove && (sq === premove.from || sq === premove.to),
}"
:tabindex="sq === focus ? 0 : -1" :aria-label="label(sq)" :aria-disabled="!interactive"
@click="clicked(sq)" @pointerdown="down(sq, $event)"
>{{ squareGlyph(hidden.has(sq) ? '' : squares[sq]!, isDarkSquare(sq), font) }}</button>
<svg v-if="arrowLines.length" class="board__arrows" viewBox="0 0 8 8" aria-hidden="true">
<defs><marker id="board-arrow" viewBox="0 0 4 4" refX="1" refY="2" markerUnits="userSpaceOnUse" markerWidth="0.42" markerHeight="0.42" orient="auto"><path d="M0,0 L4,2 L0,4 z" fill="context-stroke" /></marker></defs>
<line v-for="a in arrowLines" :key="a.key" :x1="a.x1" :y1="a.y1" :x2="a.x2" :y2="a.y2" :stroke-width="a.w" :opacity="a.o" marker-end="url(#board-arrow)" />
</svg>
<div v-if="promoting" class="board__promote" role="dialog" aria-label="Promote to">
<button v-for="r in (['queen', 'rook', 'bishop', 'knight'] as Role[])" :key="r" type="button" :aria-label="`Promote to ${r}`" @click="promote(r)">
{{ squareGlyph(({ queen: 'Q', rook: 'R', bishop: 'B', knight: 'N' } as Record<string, string>)[r]![whiteToMove ? 'toUpperCase' : 'toLowerCase'](), false, font) }}
</button>
</div>
</div>
</template>