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 @@
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,
};
});
</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 `