C_W

◄ ChessSource4 files · 1222 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.vue211 lines

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