C_W
Contact

◄ ChessSource3 files · 635 lines

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.

app/components/experiments/ChessBoard.vue219 lines

<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>