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 { clamp } from '@vueuse/core'
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
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})`,
'--board-dark': t.dark && `var(--color-${t.dark})`,
}
})
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)
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) {
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 = clamp((focus.value & 7) + df, 0, 7), r = clamp((focus.value >> 3) + dr, 0, 7)
focus.value = r * 8 + f
board.value?.querySelector<HTMLElement>(`[data-sq="${focus.value}"]`)?.focus()
}
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: 'steps(4, jump-end)' },
).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>