@anweather/plugin-board-kit

中文文档
1.34.0发布于 2026/11/03MITTypeScriptES Module零依赖近 30 天下载 2183

Board-UI primitives for a web-boardgame-platform game board: the mount/update/unmount handle a remote plugin's bundle exports, BoardShell/TurnIdentity layout, turn/spectator/team helpers, the TurnClock display half, and the board camera (pinch, drag, whee

原始使用文档
文档目录4 节
  1. Contents
  2. The board camera
  3. The board frame
  4. Developing

@anweather/plugin-board-kit

Board-UI primitives for any game board on web-boardgame-platform: a remote plugin's frontend bundle (boardgame-plugins, tactics-game-plugin, the platform's own plugins/tic-tac-toe-remote) and the native boards still in this repo's frontend/. One implementation, one package, on the public npm registry.

Published with the SDK, at the SDK's version. publish-plugin-sdk.yml releases this package together with @anweather/game-plugin-ssdk and the Kotlin runtime from one sdk-v* tag, so a plugin's board kit, SSDK and runtime pin all name the same release. See plugin-protocol/CLAUDE.md, "Publishing for cross-repo plugins".

Why a package and not a copy. A plugin's frontend bundles its own React (nothing marked external). react and react-dom are peer dependencies here, so the consumer's own node_modules/react is the one this code binds to: exactly one React instance per bundle. The shell-script vendoring this replaced existed to dodge the two-React failure a plain cross-directory import produced ("Cannot read properties of null (reading 'useState')" at runtime), and left eight of ten copies stale.

Contents

  • mountReactBoard(Component, { onError?, fallback? }) returns the { mount, update, unmount } handle a remote bundle exports across the ShadowMount boundary. It wraps the board in an error boundary: a render error shows a message instead of a blank rectangle, is reported through onError, and clears on the next update().
  • BoardShell (centered-column layout) and TurnIdentity (the "You are X" row, with a spectator variant). Inline styles throughout; TurnIdentity takes a CSS color, never a class name, so it renders the same inside a shadow root with no Tailwind in reach.
  • computeIsMyTurn / computeIsAwaiting / computeIsSpectator over a minimal structural TurnState (anything with lifecycle, currentTurnParticipantId, participants and optionally awaitingParticipantIds, GameView included). computeIsAwaiting is what "waiting on me" means during a simultaneous phase.
  • teamOf / teamMembers for a 4-seat, 2-team, alternating-seat game (mirrors platform-domain's TeamAssignment).
  • The display half of platform-domain's TurnClock: useClockNow, useClockTimeout, formatClock, remainingMs, clockReading, and the ClockBadge / StartTurnGate components. What running out does stays the engine's call.
  • The board camera, useCamera (see "The board camera" below): pinch, drag, wheel and keyboard zoom/pan over a <canvas>, an <svg> viewBox or a positioned <div>, plus FitButton / FitIcon, mergeHandlers, the pure math under it (paddedWorld, fitWorld, canvasTransform, ...) and createCamera, the machine with no React. useCanvasCamera is its deprecated predecessor, now a wrapper over it.
  • The board frame, useBoardFrame (see "The board frame" below): whether the board is in the platform's full-screen box, its size, the menu corner to keep clear and a wide/tall layout, plus the platform's full-screen geometry as constants (MENU_RESERVE, ...).
  • boardTelemetry(props): the host's BoardProps.telemetry reporter (event(name, attrs), error(err, context), into the platform's Faro, rate-limited and stamped with game type/id/participant by the platform), or NOOP_BOARD_TELEMETRY on a host without one. Never throws.

Not here on purpose: nameplateAccent.ts (this repo's frontend/src/plugins/) hardcodes per-gameType colours across the native catalogue, which is platform logic, not a board primitive.

The board camera

One camera for every board. It converged in boardgame-plugins' @boardgame-plugins/game-ui from four boards' glue over useCanvasCamera (spirit-island, catan, viticulture and monopoly, svg and div boards), then moved here so the canvas boards (risk, zephyrs, pocket-tactics) run the same one. Three layers, each usable alone: camera.ts (pure math: a world, a zoom, an offset clamped inside the world), cameraController.ts (createCamera: the pointer machine, no React, no DOM beyond a window it is handed) and useCamera.tsx (the hook, FitButton).

// An svg: the drawing is lw x lh; the world is it padded to the
// element's shape, so the svg's `meet` and the pointer mapping agree.
const cam = useCamera({ world: paddedWorld(lw, lh, elW, elH), label: 'Map' })
<svg ref={cam.ref} {...cam.props} viewBox={cam.viewBoxAttr} style={{ touchAction: 'none' }}>...</svg>
{cam.zoomed && <FitButton onFit={cam.fit} />}

// A canvas: map the world at zoom 1 onto the canvas, then the camera,
// then draw in drawing coordinates.
const cam = useCamera<HTMLCanvasElement>({ world: paddedWorld(w, h), onTap: (p) => select(cam.toLayout(p.x, p.y)) })
ctx.setTransform(canvas.width / cam.world.w, 0, 0, canvas.height / cam.world.h, 0, 0)
cam.applyTransform(ctx)
<canvas ref={cam.ref} {...cam.props} style={{ touchAction: 'none' }} />

Options (CameraOptions), all but world optional:

  • world (paddedWorld, or fitWorld to keep the drawing clear of what floats over the board), origin (where the drawing's box starts), minZoom / maxZoom (1 / 4).
  • opening(): a zoom and a world point to centre on, framed at mount and whenever frameKey changes; whenever settleKey changes too, until the player first touches the camera. While zoomed in a new world is not taken up (it would move the board under the fingers) unless the element changed shape.
  • Taps: by default an unmoved press's click reaches the element under it. onTap(clientPoint) (a board that hit-tests itself: a canvas) gets it instead, with tapSlop (6px), tapMaxMs and onDoubleTap (320ms, 30px). A press that moved past the slop, grew a second finger or was claimed swallows its click.
  • singlePointerPan (default true): false for a board whose own one-finger gesture is a drag; then two fingers move the camera and onDrag (start / move / end / cancel, drawing coordinates) gets the drag, cancelled by a second finger. Read at every press.
  • keyboard (default on: the surface is a focusable role="group" named by label; arrows pan an eighth of the view, + / - zoom x1.25, 0 fits).

What it returns (Camera): view (zoom, offsetX, offsetY), world, viewBox / viewBoxAttr, transform / applyTransform(ctx), zoomed, pressing, fit, zoomTo, zoomBy, centerOn, toWorld, toLayout, toClient, claim and the controller. useCameraOn(ref, opts) is the same hook on a ref the caller already has.

No pointer is captured: moves are heard as they bubble to the window (something that stops one owns that gesture) and lifts on the way down, so a pan that leaves the board keeps going and a lift anywhere ends the pointer; PointerBook repairs lifts that never arrive. A secondary mouse button never pans; a sideways scroll never zooms. A long-press that explains dispatches CAMERA_CLAIM_EVENT on its subject, and the camera under it gives up the rest of that press (no pan, no tap).

@anweather/plugin-board-kit/testing's rigCamera(config) drives the machine on a fake window in node:test (this package's test/cameraController.test.ts).

useCanvasCamera (deprecated) keeps its signature and its contract for the game's own onPointerDown/Move/Up/Cancel (world coordinates, React events, a pinch claiming the rest of the sequence) as a wrapper over useCamera. What changed is the camera's own behaviour: the fixes above, and a wheel step out is exactly the inverse of one in (1/1.1, not 0.9).

The board frame

Full screen is the canonical game layout. A registry entry with fullScreenBoard: true gets FullScreenBoard instead of GamePage's column: a 100dvh felt root padded once by the safe-area insets, a box marked data-fullscreen-board that is the board's whole area and its scroll container, the ☰ BoardCornerMenu floating over that box's top-left, notices along the top between 56px side insets and the result card at the bottom. useBoardFrame is how a board reads that contract (it replaces spirit-island's useFrame and its copies in catan and viticulture, and tactics' fullScreen.ts):

const [el, ref] = useState<HTMLDivElement | null>(null)
const frame = useBoardFrame(el, { wideAspect: 1.15, minHeight: 360 })
<div ref={ref} style={{ height: frame.height ?? 560, flexDirection: frame.wide ? 'row' : 'column' }}>
  <Header style={{ paddingLeft: frame.menuReserve }} />
</div>

Pass the element itself (a callback ref into state), not a ref object, so the hook runs once it exists. It returns (BoardFrame):

  • full: inside the platform's box. Then height is exactly the box's (a ResizeObserver keeps it current: a phone turning, a toolbar collapsing), the box scrolls, and the insets are already applied: never pad for them again.
  • menuReserve: MENU_RESERVE (48) while the platform's ☰ floats over the box's top-left, else 0. Keep that 48px square (MENU_CORNER_RECT, overlapsMenuCorner(rect)) free of anything tappable, long-pressable or that must be read. By default the ☰ sits in the platform's thin top bar above the box (the box says so: data-fullscreen-menu="bar", menuInBar(box)), and the reserve is 0; only a game that opts out of the bar (registry fullScreenTopBar: false) gets the floating corner. Read frame.menuReserve rather than MENU_RESERVE itself, so a board follows whichever it is in.
  • width, height: the element's width; on the ordinary page the height from its top edge to the viewport's bottom (the platform's chrome above it counted up to topCap, 40% of the viewport), at least minHeight (a number, or { tall, wide }).
  • layout / wide: wide when width / height > wideAspect (default 1.2) and width >= minWideWidth (default 0). spirit-island 1.2, catan 1.15, viticulture 1.05 with 900px.
  • insets: only with viewportFallback: true, a host fixed over the bare viewport with no platform box -- full screen, and the board pads SAFE_AREA itself. No platform layout produces one.
  • box: the box element, once found.

Before it measures (a static render, the first paint) it is UNMEASURED_FRAME: not full, tall, no size. Inside the box it turns a ShadowMount host's overflow-x: hidden into clip while mounted (clipHost, default on), so a sticky header sticks to the box and not to a host that never scrolls; a registry entry passing hostOverflowX="clip" already has that.

The pure half is boardFrame.ts (computeFrame, frameLayout, findFullScreenBox, the constants), all testable in node. The constants are the platform's own numbers: MENU_BUTTON_INSET 8, MENU_BUTTON_SIZE 40 (BoardCornerMenu), NOTICE_SIDE_INSET 56 and RESULT_CARD_INSET 12 (FullScreenBoard). test/boardFrame.test.ts reads those two platform files and fails if either stops matching, so moving the menu means changing both in one PR.

Not here (yet): one "is this a phone" breakpoint. The other boards' checks are different questions (risk's map container under 520px and (pointer: coarse), triple-triad's window under 760px, monopoly's container under 900px, queens-blood's continuous scale); folding them into one helper would change what each does, so they stay with their boards until each moves to full screen. frameLayout(w, h, { wideAspect: 0, minWideWidth: 900 }) already says monopoly's.

Developing

npm ci && npm run build && npm test && npm run typecheck

npm test covers the pure helpers (turn, team and clock arithmetic), the camera's math and pointer machine, the board frame's arithmetic (and its constants against the platform's own FullScreenBoard and BoardCornerMenu), and the hooks rendered to static markup, with node:test; the components are typechecked and exercised by their consumers' own Playwright suites. To try an unpublished change in a consumer, npm pack here and install the tarball there; the consumer's package.json should never point at this directory by path, since a file: link resolves react through this directory's own node_modules and reintroduces the two-React bundle.

版本与发布记录

@anweather/plugin-board-kit 共发布 33 个版本,首次发布于 2026/09/29;过去 12 个月发布了 33 个版本。

版本发布日期距今
1.34.0latest2026/11/034 天前
1.33.02026/10/2513 天前
1.32.02026/10/2513 天前
1.31.02026/10/2513 天前
1.30.02026/10/2414 天前
1.29.22026/10/2414 天前

关于 @anweather/plugin-board-kit 的常见问题

@anweather/plugin-board-kit 是什么?

Board-UI primitives for a web-boardgame-platform game board: the mount/update/unmount handle a remote plugin's bundle exports, BoardShell/TurnIdentity layout, turn/spectator/team helpers, the TurnClock display half, and the board camera (pinch, drag, whee

如何安装 @anweather/plugin-board-kit?

在项目目录运行 npm install @anweather/plugin-board-kit,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:react(^19.0.0)、react-dom(^19.0.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@anweather/plugin-board-kit 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@anweather/plugin-board-kit 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@anweather/plugin-board-kit 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=18.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@anweather/plugin-board-kit 可以商用吗?

@anweather/plugin-board-kit 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@anweather/plugin-board-kit 的下载量有多少?

根据 npm 官方下载统计,@anweather/plugin-board-kit 在 2026/10/06 – 2026/11/04 这 30 天内被下载 2,183 次,日均约 73 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@anweather/plugin-board-kit 还在维护吗?

最新版本 1.34.0 发布于 2026/11/03(4 天前);至今共发布 33 个版本,其中过去 12 个月发布了 33 个;首次发布于 2026/09/29。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。