diff --git a/README.md b/README.md index fee125e..20057c3 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ |:--------:|:----:| |![](./gifs/hexchess-perf.gif)|![](./gifs/hexchess-resize-perf.gif)| -2. Completely customizable - every single color you see can be changed to whatever fits your style and liking! Soon, we will support [slots](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/slot), which will also allow you to customize the pieces on the board. +2. Completely customizable - every single color you see can be changed to whatever fits your style and liking! Provide your own artwork via [slots](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/slot) (for example, `piece-white-queen`) to override the built-in piece set. 3. Can be standalone without a server! `hexchess-board` ships with a complete game engine that can detect illegal moves, checkmate, stalemate, and more! You can play with two players on the same laptop without any other dependencies, should you choose. @@ -62,6 +62,29 @@ startButton.addEventListener('click', () => { If you want to reuse the shipped cues from JS you can import `DEFAULT_SOUND_PACK` from the package and merge it with your overrides. +## Custom pieces + +Slots expose each piece so you can bring your own set without forking the component. Drop `` tags inside the element with the matching slot name and the board, captured piece tray, and promotion dialog will automatically switch to your artwork. + +```html + + + + + +``` + +| Slot name | Piece | +| --------- | ----- | +| `piece-white-king` / `piece-black-king` | King (`K`/`k`) | +| `piece-white-queen` / `piece-black-queen` | Queen (`Q`/`q`) | +| `piece-white-bishop` / `piece-black-bishop` | Bishop (`B`/`b`) | +| `piece-white-knight` / `piece-black-knight` | Knight (`N`/`n`) | +| `piece-white-rook` / `piece-black-rook` | Rook (`R`/`r`) | +| `piece-white-pawn` / `piece-black-pawn` | Pawn (`P`/`p`) | + +Only the slots you fill are overridden—the rest fall back to the bundled Wikimedia set—so you can replace one piece or the entire collection. Since the board draws onto a canvas, use `` elements (any format the browser can load works, including SVG and PNG). The component scales everything to the current board size, so ship high-resolution art for the cleanest result. + ## Installing `hexchess-board` is packaged as a [Web Component](https://developer.mozilla.org/en-US/docs/Web/Web_Components) and should be usable directly in most modern browsers. It bundles its own (configurable) styles, inline assets (for chess pieces), and code. diff --git a/docs-src/examples/custom-pieces.md b/docs-src/examples/custom-pieces.md new file mode 100644 index 0000000..907eaa9 --- /dev/null +++ b/docs-src/examples/custom-pieces.md @@ -0,0 +1,34 @@ +--- +layout: example.11ty.cjs +title: ⌲ Examples ⌲ Custom pieces +tags: example +name: Custom pieces +description: Override the built-in set with slots +--- + + + + +
+ + White king trophy + +
+ +

HTML

+ +```html + + White king trophy + +``` + +Add `` tags for any other slot (`piece-white-queen`, `piece-black-pawn`, etc.) to override more pieces. Every surface of the component (canvas, captured pieces, promotions) swaps to the provided art automatically. diff --git a/docs-src/index.md b/docs-src/index.md index 46eb864..eeb9262 100644 --- a/docs-src/index.md +++ b/docs-src/index.md @@ -82,3 +82,26 @@ Built-in cues (move, capture, check, checkmate, victory, defeat, and draw) reuse ``` The `prepareAudio()` helper lets you unlock and preload sounds during your own UI gesture (to satisfy autoplay restrictions) if your players won't click on the board directly. + +## Custom pieces + +Bring your own artwork by filling the hidden slots for each piece. Add `` elements with slot names like `piece-white-queen` inside the component and every rendering surface (board, captured pieces, promotions) swaps to your graphics. Leave any slot empty to keep the default set. + +```html + + + + + +``` + +Available slots: + +- `piece-white-king` / `piece-black-king` +- `piece-white-queen` / `piece-black-queen` +- `piece-white-bishop` / `piece-black-bishop` +- `piece-white-knight` / `piece-black-knight` +- `piece-white-rook` / `piece-black-rook` +- `piece-white-pawn` / `piece-black-pawn` + +Any image format the browser supports works (PNG, SVG, AVIF, etc.). The board scales assets to match the current hex size, so ship large, crisp art for the sharpest results. diff --git a/docs/examples/change-look/index.html b/docs/examples/change-look/index.html index a99805d..5dfd8b7 100644 --- a/docs/examples/change-look/index.html +++ b/docs/examples/change-look/index.html @@ -38,6 +38,14 @@

Example: Customizing colors

Changing the look and feel of the board +
  • + Override the built-in set with slots +
  • + +
  • + Dark and light modes are both supported. +
  • +
  • Customizing the board layout with Hex-FEN
  • @@ -54,10 +62,6 @@

    Example: Customizing colors

    Analyzing a game that has a specific set of moves played. -
  • - Dark and light modes are both supported. -
  • -
    diff --git a/docs/examples/custom-pieces/index.html b/docs/examples/custom-pieces/index.html new file mode 100644 index 0000000..47ee634 --- /dev/null +++ b/docs/examples/custom-pieces/index.html @@ -0,0 +1,96 @@ + + + + + + + + <hexchess-board> ⌲ Examples ⌲ Custom pieces + + + + + + + + + +
    +

    <hexchess-board>

    +

    Chess is better with hexagons

    +
    + + +
    +
    + +

    Example: Custom pieces

    +
    + +
    + + +
    + + White king trophy + +
    +

    HTML

    +
    <hexchess-board board="start">
    <img
    slot="piece-white-king"
    src="https://raw.githubusercontent.com/clarkerubber/Staunton-Pieces/master/Trophies/Big-Gold-Cup.png"
    alt="White king trophy"
    />

    </hexchess-board>
    +

    Add <img> tags for any other slot (piece-white-queen, piece-black-pawn, etc.) to override more pieces. Every surface of the component (canvas, captured pieces, promotions) swaps to the provided art automatically.

    + +
    +
    + +
    +
    + + + + \ No newline at end of file diff --git a/docs/examples/darkmode/index.html b/docs/examples/darkmode/index.html index d9582da..66d3d93 100644 --- a/docs/examples/darkmode/index.html +++ b/docs/examples/darkmode/index.html @@ -38,6 +38,14 @@

    Example: Dark Mode

    Changing the look and feel of the board +
  • + Override the built-in set with slots +
  • + +
  • + Dark and light modes are both supported. +
  • +
  • Customizing the board layout with Hex-FEN
  • @@ -54,10 +62,6 @@

    Example: Dark Mode

    Analyzing a game that has a specific set of moves played. -
  • - Dark and light modes are both supported. -
  • -
    diff --git a/docs/examples/hexfen/index.html b/docs/examples/hexfen/index.html index 397f591..bf4f181 100644 --- a/docs/examples/hexfen/index.html +++ b/docs/examples/hexfen/index.html @@ -38,6 +38,14 @@

    Example: Hex-FEN Notation

    Changing the look and feel of the board +
  • + Override the built-in set with slots +
  • + +
  • + Dark and light modes are both supported. +
  • +
  • Customizing the board layout with Hex-FEN
  • @@ -54,10 +62,6 @@

    Example: Hex-FEN Notation

    Analyzing a game that has a specific set of moves played. -
  • - Dark and light modes are both supported. -
  • -
    diff --git a/docs/examples/index.html b/docs/examples/index.html index 11b491c..15351c5 100644 --- a/docs/examples/index.html +++ b/docs/examples/index.html @@ -38,6 +38,14 @@

    Example: Keyboard input

    Changing the look and feel of the board +
  • + Override the built-in set with slots +
  • + +
  • + Dark and light modes are both supported. +
  • +
  • Customizing the board layout with Hex-FEN
  • @@ -54,10 +62,6 @@

    Example: Keyboard input

    Analyzing a game that has a specific set of moves played. -
  • - Dark and light modes are both supported. -
  • -
    diff --git a/docs/examples/preset-moves/index.html b/docs/examples/preset-moves/index.html index 38809a2..0c524a1 100644 --- a/docs/examples/preset-moves/index.html +++ b/docs/examples/preset-moves/index.html @@ -38,6 +38,14 @@

    Example: Preset Moves

    Changing the look and feel of the board +
  • + Override the built-in set with slots +
  • + +
  • + Dark and light modes are both supported. +
  • +
  • Customizing the board layout with Hex-FEN
  • @@ -54,10 +62,6 @@

    Example: Preset Moves

    Analyzing a game that has a specific set of moves played. -
  • - Dark and light modes are both supported. -
  • -
    diff --git a/docs/index.html b/docs/index.html index a7b8bf7..86ca0b7 100644 --- a/docs/index.html +++ b/docs/index.html @@ -60,6 +60,19 @@

    Sound effects

    Built-in cues (move, capture, check, checkmate, victory, defeat, and draw) reuse Lichess's standard sound pack and are preloaded as soon as the element is connected. Toggle them with the boolean muted attribute or swap individual files by setting the audio property from JavaScript. Defaults stream from the mirrored copies at https://hexagonchess.github.io/hexchess-board/assets/audio/*.mp3 (see docs/assets/audio/LICENSE for AGPL terms), so host your own files if you need different URLs or offline access.

    <hexchess-board id="audio-board" board="start"></hexchess-board>
    <button id="prime-audio">Enable audio</button>
    <script type="module">
    const board = document.querySelector('#audio-board');
    primeAudio.addEventListener('click', async () => {
    await board.prepareAudio();
    board.audio = {
    move: '/assets/sounds/move.mp3',
    capture: null, // disable capture cue
    };
    });
    </script>

    The prepareAudio() helper lets you unlock and preload sounds during your own UI gesture (to satisfy autoplay restrictions) if your players won't click on the board directly.

    +

    Custom pieces

    +

    Bring your own artwork by filling the hidden slots for each piece. Add <img> elements with slot names like piece-white-queen inside the component and every rendering surface (board, captured pieces, promotions) swaps to your graphics. Leave any slot empty to keep the default set.

    +
    <hexchess-board board="start">
    <img slot="piece-white-queen" src="/pieces/white-queen.svg" />
    <img slot="piece-black-queen" src="/pieces/black-queen.svg" />
    <img slot="piece-black-pawn" src="/pieces/black-pawn.svg" />
    </hexchess-board>
    +

    Available slots:

    +
      +
    • piece-white-king / piece-black-king
    • +
    • piece-white-queen / piece-black-queen
    • +
    • piece-white-bishop / piece-black-bishop
    • +
    • piece-white-knight / piece-black-knight
    • +
    • piece-white-rook / piece-black-rook
    • +
    • piece-white-pawn / piece-black-pawn
    • +
    +

    Any image format the browser supports works (PNG, SVG, AVIF, etc.). The board scales assets to match the current hex size, so ship large, crisp art for the sharpest results.

    diff --git a/rollup.config.js b/rollup.config.js index e8e6578..bcee38f 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -9,6 +9,35 @@ import replace from '@rollup/plugin-replace'; import summary from 'rollup-plugin-summary'; import { terser } from 'rollup-plugin-terser'; +const disableTerser = process.env.HEXCHESS_DISABLE_TERSER === 'true'; +const workerOverride = Number(process.env.TERSER_NUM_WORKERS ?? ''); +const terserOptions = { + ecma: 2021, + module: true, + warnings: true, + mangle: { + properties: { + regex: /^__/, + }, + }, +}; + +if (!Number.isNaN(workerOverride) && workerOverride > 0) { + terserOptions.numWorkers = workerOverride; +} + +const plugins = [replace({ 'Reflect.decorate': 'undefined' }), resolve()]; + +if (!disableTerser) { + /** + * This minification setup serves the static site generation. + * For bundling and minification, check the README.md file. + */ + plugins.push(terser(terserOptions)); +} + +plugins.push(summary()); + export default { input: 'hexchess-board.js', output: { @@ -20,23 +49,5 @@ export default { console.error(`(!) ${warning.message}`); } }, - plugins: [ - replace({ 'Reflect.decorate': 'undefined' }), - resolve(), - /** - * This minification setup serves the static site generation. - * For bundling and minification, check the README.md file. - */ - terser({ - ecma: 2021, - module: true, - warnings: true, - mangle: { - properties: { - regex: /^__/, - }, - }, - }), - summary(), - ], + plugins, }; diff --git a/src/hexchess-board.ts b/src/hexchess-board.ts index 6d33f2f..0744269 100644 --- a/src/hexchess-board.ts +++ b/src/hexchess-board.ts @@ -129,6 +129,8 @@ export class HexchessBoard extends HTMLElement { private _rootElement: HTMLDivElement | null = null; private _gameInfoContainer: HTMLDivElement | null = null; private _promotionHost: HTMLDivElement | null = null; + private _pieceSlots: Partial> = {}; + private _customPieceAssets: Partial> = {}; private _styleElement: HTMLStyleElement | null = null; private _renderPending = false; private _needsRender = false; @@ -670,7 +672,13 @@ export class HexchessBoard extends HTMLElement { overlayHost.classList.add('promotion-host'); overlayHost.addEventListener('click', this._boundPromotionClick); - wrapper.append(canvas, gameInfo, overlayHost); + const slotHost = document.createElement('div'); + slotHost.classList.add('piece-slot-host'); + slotHost.style.display = 'none'; + slotHost.setAttribute('aria-hidden', 'true'); + this._initializePieceSlots(slotHost); + + wrapper.append(canvas, gameInfo, overlayHost, slotHost); root.append(wrapper); this._rootElement = wrapper; @@ -688,6 +696,64 @@ export class HexchessBoard extends HTMLElement { } } + private _initializePieceSlots(container: HTMLDivElement): void { + const pieces = Object.keys(PIECE_ASSET_IDS) as Piece[]; + for (const entry of pieces) { + if (this._pieceSlots[entry]) { + continue; + } + const slot = document.createElement('slot'); + slot.name = this._getPieceSlotName(entry); + const pieceType = entry; + slot.addEventListener('slotchange', () => + this._handlePieceSlotChange(pieceType), + ); + container.appendChild(slot); + this._pieceSlots[pieceType] = slot; + } + queueMicrotask(() => { + for (const piece of pieces) { + this._handlePieceSlotChange(piece); + } + }); + } + + private _getPieceSlotName(piece: Piece): string { + const assetId = PIECE_ASSET_IDS[piece]; + return assetId ? `piece-${assetId}` : ''; + } + + private _handlePieceSlotChange(piece: Piece) { + const slot = this._pieceSlots[piece]; + if (!slot) { + return; + } + const assigned = slot.assignedElements({ flatten: true }); + const image = assigned.find( + (node): node is HTMLImageElement => node instanceof HTMLImageElement, + ); + if (image?.src) { + const nextSrc = image.currentSrc || image.src; + if (nextSrc) { + if (!image.complete) { + image.addEventListener('load', () => this._scheduleRedraw(), { + once: true, + }); + } + if (this._customPieceAssets[piece] !== nextSrc) { + this._customPieceAssets[piece] = nextSrc; + delete this._pieceImages[piece]; + } + } + } else if (this._customPieceAssets[piece]) { + delete this._customPieceAssets[piece]; + delete this._pieceImages[piece]; + } + this._updateGameInfo(); + this._updatePromotionOverlay(); + this._scheduleRedraw(); + } + private _updateCanvasCursor(): void { if (!this._canvas) { return; @@ -1404,7 +1470,12 @@ export class HexchessBoard extends HTMLElement { this._polygonHeight }px; background-color: var(--hexchess-board-bg, #fcfaf2);" > - ${renderPiece(option, this._pieceSize, false)} + ${renderPiece( + option, + this._pieceSize, + false, + this._getPieceAssetUrl(option) ?? undefined, + )}
    `; }) .join(''); @@ -1434,7 +1505,12 @@ export class HexchessBoard extends HTMLElement { const padding = numPiece * capturedPieceSize * this._capturedPiecePadding; return `
    - ${renderPiece(piece, capturedPieceSize)} + ${renderPiece( + piece, + capturedPieceSize, + true, + this._getPieceAssetUrl(piece) ?? undefined, + )}
    `; }) .join(''); @@ -2182,18 +2258,33 @@ export class HexchessBoard extends HTMLElement { return typeof performance !== 'undefined' ? performance.now() : Date.now(); } - private _loadPieceImage(piece: Piece): HTMLImageElement | null { + private _getPieceAssetUrl(piece: Piece): string | null { + const custom = this._customPieceAssets[piece]; + if (custom) { + return custom; + } const assetId = PIECE_ASSET_IDS[piece]; if (!assetId) { return null; } + return PIECE_ASSET_URLS[assetId]; + } + + private _loadPieceImage(piece: Piece): HTMLImageElement | null { + const src = this._getPieceAssetUrl(piece); + if (!src) { + return null; + } const cached = this._pieceImages[piece]; - if (cached) { + if (cached && cached.src === src) { return cached.complete ? cached : null; } + if (cached && cached.src !== src) { + delete this._pieceImages[piece]; + } const image = new Image(); image.crossOrigin = 'anonymous'; - image.src = PIECE_ASSET_URLS[assetId]; + image.src = src; image.onload = () => this._scheduleRedraw(); this._pieceImages[piece] = image; return image.complete ? image : null; diff --git a/src/piece.ts b/src/piece.ts index a908391..8c5cc71 100644 --- a/src/piece.ts +++ b/src/piece.ts @@ -36,6 +36,7 @@ export const renderPiece = ( piece: Piece, size: number = DEFAULT_PIECE_SIZE, translate = true, + srcOverride?: string, ): string => { if (piece === null) { return ''; @@ -44,11 +45,15 @@ export const renderPiece = ( if (!id) { return ''; } + const src = srcOverride ?? pieceUrls[id]; + if (!src) { + return ''; + } const style = translate ? `transform: translate(-${size / 2}px, -${size / 2}px)` : ''; return `