Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ over the world (MoonBase#1502); `GolfTable`, `CastleTable` and `RummyTable` (Moo
are the tables, `useGolfTable`, `useCastleTable` and `useRummyTable` their state over the
room stream's game envelopes. Castle and rummy share the felt and the card faces
(`castle/components/Cards.tsx`, `CastleTable.module.css`). `ChessTable` and `useChessTable`
are chess's: two seats, the board from the viewer's side, the moves the hub lists for the
are chess's: two seats, the board from the viewer's side (tap a piece then its square, or drag it), the moves the hub lists for the
side to move (the client holds no rules), the clocks counted down from each view, and a
score notepad as the table plays on: Next game starts another there, sides swapped. Share links are
`/games/room/:roomId` and `/games/room/:roomId/table/:gameId`; the old `/golf` and `/castle`
Expand Down
21 changes: 21 additions & 0 deletions src/apps/chess/components/ChessTable.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -288,3 +288,24 @@
font-style: italic;
opacity: 0.7;
}

/* A drag: the viewer's movable pieces take the touch, so the page does not
scroll under it; the piece rides the pointer over the page, a size up,
and its square holds a faint copy until it lands. */
.square[data-grab='true'] {
touch-action: none;
cursor: grab;
}

.square[data-dragging='true'] > span:last-child {
opacity: 0.3;
}

.ghost {
position: fixed;
z-index: 1000;
line-height: 1;
transform: translate(-50%, -50%);
pointer-events: none;
cursor: grabbing;
}
99 changes: 94 additions & 5 deletions src/apps/chess/components/ChessTable.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { useEffect, useId, useRef, useState } from 'react'
import type { PointerEvent as ReactPointerEvent } from 'react'
import { createPortal } from 'react-dom'
import type { ChessTableActions } from '@/hooks/useChessTable'
import felt from '@/apps/castle/components/CastleTable.module.css'
import type { ChessColor, ChessView } from '../wire'
Expand Down Expand Up @@ -37,6 +39,9 @@ const PROMOTIONS: Array<{ letter: string; name: string }> = [
// How often a running clock repaints: fine enough for its tenths.
const TICK_MS = 100

// How far a press travels before it is a drag rather than a tap.
const DRAG_SLOP_PX = 6

const colorOfPiece = (piece: string): ChessColor => (piece === piece.toUpperCase() ? 'white' : 'black')
const other = (color: ChessColor): ChessColor => (color === 'white' ? 'black' : 'white')

Expand Down Expand Up @@ -115,16 +120,26 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => {
// The king of the side to move — or, once mated, of the side that was.
const checkedKing = view.inCheck ? (view.fen?.split(' ')[1] === 'b' ? 'k' : 'K') : null

// The move from `start` to `square`: played, or the promotion asked.
const land = (start: string, square: string) => {
const moves = movesTo(view.legalMoves, start, square)
setPicked(null)
if (moves.length === 1) table.play(moves[0])
else setPromoting({ view, from: start, moves })
}

const tap = (square: string) => {
// The click a release fires after a drag is the drag's, already done.
if (dragged.current) {
dragged.current = false
return
}
if (!myTurn || !connected) return
// A tap on the board is a new gesture: a promotion still asking is
// abandoned, never left up to send a second move for this turn.
setPromoting(null)
if (from !== null && targets.includes(square)) {
const moves = movesTo(view.legalMoves, from, square)
setPicked(null)
if (moves.length === 1) table.play(moves[0])
else setPromoting({ view, from, moves })
land(from, square)
return
}
// Another of the viewer's pieces with a move picks it up; anything
Expand All @@ -133,6 +148,54 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => {
setPicked(movable && square !== from ? { view, square } : null)
}

// A drag: a press on a piece with moves that travels past the slop. The
// piece follows the pointer and its squares show; the release lands it on
// the square under the pointer — not the event's target, which for a
// touch stays the square the press began on. A drop anywhere else leaves
// the piece picked up, for a tap to finish.
const press = useRef<{ id: number; square: string; x: number; y: number; moving: boolean } | null>(null)
const dragged = useRef(false)
const [ghost, setGhost] = useState<{ piece: string; x: number; y: number; size: number } | null>(null)
const squareAt = (x: number, y: number) =>
document.elementFromPoint(x, y)?.closest<HTMLElement>('[data-square]')?.dataset.square ?? null

const onPointerDown = (event: ReactPointerEvent, square: string) => {
dragged.current = false
if (!myTurn || !connected || event.button !== 0) return
if (targetsFrom(view.legalMoves, square).length === 0) return
press.current = { id: event.pointerId, square, x: event.clientX, y: event.clientY, moving: false }
}
const onPointerMove = (event: ReactPointerEvent) => {
const held = press.current
if (held === null || held.id !== event.pointerId) return
if (!held.moving && Math.hypot(event.clientX - held.x, event.clientY - held.y) < DRAG_SLOP_PX) return
if (!held.moving) {
held.moving = true
setPromoting(null)
setPicked({ view, square: held.square })
// Past the slop, not on the press: captured at once, a tap's click
// would land on the board rather than its square.
boardRef.current?.setPointerCapture?.(event.pointerId)
}
// A size up from the board's own pieces, which are 9% of its width.
const width = boardRef.current?.getBoundingClientRect().width ?? 0
setGhost({ piece: board.get(held.square) ?? '', x: event.clientX, y: event.clientY, size: width * 0.11 })
}
const onPointerUp = (event: ReactPointerEvent) => {
const held = press.current
if (held === null || held.id !== event.pointerId) return
press.current = null
setGhost(null)
if (!held.moving) return
dragged.current = true
const square = squareAt(event.clientX, event.clientY)
if (square !== null && targetsFrom(view.legalMoves, held.square).includes(square)) land(held.square, square)
}
const onPointerCancel = () => {
press.current = null
setGhost(null)
}

const cancelPromotion = () => {
if (pendingPromotion !== null) refocus.current = { square: pendingPromotion.from }
setPromoting(null)
Expand Down Expand Up @@ -213,13 +276,22 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => {
<span id={targetNote} className={felt.srOnly}>
a move
</span>
<div ref={boardRef} className={styles.board} role="group" aria-label="board">
<div
ref={boardRef}
className={styles.board}
role="group"
aria-label="board"
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
>
{squares.map((square, index) => {
const piece = board.get(square)
const mine = piece !== undefined && colorOfPiece(piece) === myColor
// a1 dark: a square is dark where its file and rank index sum even.
const light = (square.charCodeAt(0) - 97 + Number(square[1])) % 2 === 0
const target = targets.includes(square)
const grab = mine && myTurn && connected && targetsFrom(view.legalMoves, square).length > 0
return (
<button
key={square}
Expand All @@ -233,6 +305,9 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => {
aria-describedby={target ? targetNote : undefined}
data-last={last.includes(square) ? 'true' : undefined}
data-check={piece !== undefined && piece === checkedKing ? 'true' : undefined}
data-grab={grab ? 'true' : undefined}
data-dragging={ghost !== null && from === square ? 'true' : undefined}
onPointerDown={event => onPointerDown(event, square)}
onClick={() => tap(square)}
>
{index % 8 === 0 && (
Expand All @@ -255,6 +330,20 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => {
})}
</div>
{me?.color !== undefined && <ClockRow view={view} seatId={me.playerId} color={me.color} you />}
{/* On the page, not the board: the board clips its overflow, and a
captured drag may stray past its edge. */}
{ghost !== null &&
createPortal(
<span
className={`${styles.ghost} ${colorOfPiece(ghost.piece) === 'white' ? styles.whitePiece : styles.blackPiece}`}
style={{ left: ghost.x, top: ghost.y, fontSize: ghost.size }}
aria-hidden="true"
data-testid="drag-ghost"
>
{glyph(ghost.piece)}
</span>,
document.body
)}

{pendingPromotion !== null && (
<div
Expand Down
116 changes: 116 additions & 0 deletions src/apps/chess/components/__tests__/ChessTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,122 @@ describe('ChessTable', () => {
})
})

// A drag is a press that moves: the piece follows the pointer, the
// squares it reaches show, and letting go on one plays it. Mouse and touch
// alike are pointer events; where it lands is the square under the
// pointer, since a touch keeps every event on the square it began on.
describe('dragging', () => {
// jsdom does no layout: the square under the pointer is the test's to say.
const under = (name: string | null) => {
const element = name === null ? null : square(name)
document.elementFromPoint = () => element
}
const drag = (fromName: string, toName: string | null) => {
const from = square(fromName)
fireEvent.pointerDown(from, { pointerId: 1, clientX: 10, clientY: 10, button: 0 })
fireEvent.pointerMove(from, { pointerId: 1, clientX: 40, clientY: 40 })
under(toName)
fireEvent.pointerUp(from, { pointerId: 1, clientX: 40, clientY: 40 })
// The click a touch's release fires on the square it began on.
fireEvent.click(from)
}

it('a piece dropped on a square it reaches plays the move, in one gesture', () => {
const { t } = mountWith(view())
drag('g6', 'f7')
expect(t.play).toHaveBeenCalledTimes(1)
expect(t.play).toHaveBeenCalledWith('g6f7')
})

it('while it moves, the piece follows the pointer and its squares show', () => {
mountWith(view())
fireEvent.pointerDown(square('g6'), { pointerId: 1, clientX: 10, clientY: 10, button: 0 })
expect(screen.queryByTestId('drag-ghost')).toBeNull() // a press is not yet a drag
fireEvent.pointerMove(square('g6'), { pointerId: 1, clientX: 40, clientY: 40 })
expect(screen.getByTestId('drag-ghost')).toBeInTheDocument()
expect(square('f7')).toHaveAccessibleDescription('a move')
fireEvent.pointerCancel(square('g6'), { pointerId: 1 })
expect(screen.queryByTestId('drag-ghost')).toBeNull()
})

// A mouse is not held by the square it pressed: past the slop the board
// takes the pointer, so a release anywhere — off the board, off the
// page — still ends the drag.
it('once a drag, the board holds the pointer, and a release off it ends the drag', () => {
mountWith(view())
const board = screen.getByRole('group', { name: 'board' })
const capture = vi.fn()
board.setPointerCapture = capture
fireEvent.pointerDown(square('g6'), { pointerId: 7, clientX: 10, clientY: 10, button: 0 })
expect(capture).not.toHaveBeenCalled() // a tap keeps its click
fireEvent.pointerMove(square('g6'), { pointerId: 7, clientX: 40, clientY: 40 })
expect(capture).toHaveBeenCalledWith(7)
// Captured, the release comes to the board wherever it happens.
under(null)
fireEvent.pointerUp(board, { pointerId: 7, clientX: 900, clientY: 900 })
expect(screen.queryByTestId('drag-ghost')).toBeNull()
expect(square('g6')).toHaveAttribute('aria-pressed', 'true')
})

it('the dragged piece is drawn outside the board, so its edge cannot clip it', () => {
mountWith(view())
fireEvent.pointerDown(square('g6'), { pointerId: 1, clientX: 10, clientY: 10, button: 0 })
fireEvent.pointerMove(square('g6'), { pointerId: 1, clientX: 40, clientY: 40 })
const ghost = screen.getByTestId('drag-ghost')
expect(screen.getByRole('group', { name: 'board' }).contains(ghost)).toBe(false)
expect(ghost.style.left).toBe('40px')
expect(ghost.style.top).toBe('40px')
})

// A finger never lands still: a press that barely moves is a tap.
it('a press that wobbles a few pixels is still a tap', () => {
const { t } = mountWith(view())
fireEvent.pointerDown(square('g6'), { pointerId: 1, clientX: 10, clientY: 10, button: 0 })
fireEvent.pointerMove(square('g6'), { pointerId: 1, clientX: 13, clientY: 12 })
expect(screen.queryByTestId('drag-ghost')).toBeNull()
fireEvent.pointerUp(square('g6'), { pointerId: 1, clientX: 13, clientY: 12 })
fireEvent.click(square('g6'))
expect(square('g6')).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(square('f7'))
expect(t.play).toHaveBeenCalledWith('g6f7')
})

it('a promotion dropped asks for its piece', () => {
const { t } = mountWith(view())
drag('e7', 'e8')
expect(t.play).not.toHaveBeenCalled()
expect(screen.getByRole('group', { name: 'promote to' })).toBeInTheDocument()
})

it('dropped where it cannot go, nothing is played and the piece stays picked up', () => {
const { t } = mountWith(view())
drag('g6', 'a1')
drag('g6', null)
expect(t.play).not.toHaveBeenCalled()
expect(square('g6')).toHaveAttribute('aria-pressed', 'true')
// and a tap finishes it
fireEvent.click(square('f7'))
expect(t.play).toHaveBeenCalledWith('g6f7')
})

it('off turn, or off the hub, a drag moves nothing', () => {
const { t, unmount } = mountWith(view(), {}, 'bob')
drag('g6', 'f7')
expect(screen.queryByTestId('drag-ghost')).toBeNull()
unmount()
mountWith(view(), t, 'alice', false)
drag('g6', 'f7')
expect(t.play).not.toHaveBeenCalled()
})

it('the viewer’s movable pieces take the touch from the page; nothing else does', () => {
mountWith(view())
expect(square('g6')).toHaveAttribute('data-grab', 'true')
expect(square('h8')).not.toHaveAttribute('data-grab')
expect(square('a1')).not.toHaveAttribute('data-grab')
})
})

describe('what the table says', () => {
it('names the opponent’s last move before the viewer’s turn', () => {
mountWith(view({ moves: ['a1a2', 'h7h8'] }))
Expand Down
Loading