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 builds on chessops, collected from the site’s repository each time the site is built, so what you read is what runs. The chess engine itself, the move facts and Jev’s move, is in cwdx/chess-with-jev.
<script setup lang="ts">
import type { NormalMove, Role } from 'chessops/types'
import { isDarkSquare, NAMES, squareName } from '@cw/chess/rules'
import { BOARD_THEMES, PLAIN_FONTS, squareGlyph, type BoardTheme, type ChessFont } from '#core/domain/chess-board'
import type { Ply } from '~/composables/useChessGame'
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: a move becomes a premove. */
premoveColor?: 'white' | 'black'
premove?: { from: number; to: number }
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: { ground: string; ink: string; mark: string; dark?: string } = BOARD_THEMES[props.theme]
return {
'--board-ground': `var(--color-${t.ground})`, '--board-ink': `var(--color-${t.ink})`, '--board-mark': `var(--color-${t.mark})`,
// a font that draws no squares gets them from the board: the theme's own dark, else a tint of its ink
'--board-dark': t.dark ? `var(--color-${t.dark})` : 'color-mix(in srgb, var(--board-ink) 16%, var(--board-ground))',
}
})
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 White glyph is only an outline, so the solid Black glyph goes underneath in Paper to fill it
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
}
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')
let unlisten: (() => void)[] = []
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 }
unlisten = [useEventListener(window, 'pointermove', moveDrag), useEventListener(window, 'pointerup', endDrag), useEventListener(window, 'pointercancel', cleanup)]
}
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() {
unlisten.forEach((u) => u())
drag?.tile?.remove()
hidden.value = new Set()
drag = undefined
}
function endDrag(e: PointerEvent) {
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 clicked(sq: number) {
if (swallowClick) { swallowClick = false; return }
focus.value = sq
pick(sq)
}
onBeforeUnmount(cleanup)
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" :class="{ 'board__grid--plain': PLAIN_FONTS.has(font) }" 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-dark': isDarkSquare(sq),
'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>