C_W

◄ ChessSource4 files · 1275 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 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.

app/components/lab/ChessBoard.vue215 lines

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