boardcast
Version:
Animation library for tabletop game rules on hex boards with CLI tools and game extensions
500 lines (499 loc) • 20.7 kB
JavaScript
import * as A from "d3";
var u = /* @__PURE__ */ ((f) => (f.ALL = "ALL", f.HIGHLIGHT = "HIGHLIGHT", f.BLINK = "BLINK", f.PULSE = "PULSE", f.POINT = "POINT", f.TOKEN = "TOKEN", f.CAPTION = "CAPTION", f.DICE = "DICE", f))(u || {});
const E = {
// Primary palette - optimized for dark backgrounds
BLUE: "#4FC3F7",
// Bright cyan-blue (primary highlight)
RED: "#FF6B6B",
// Soft red (danger/enemy)
GREEN: "#4CAF50",
// Material green (ally/success)
YELLOW: "#FFD54F",
// Warm yellow (attention/movement)
PURPLE: "#BA68C8",
// Light purple (special effects)
ORANGE: "#FF9800",
// Bright orange (warning/boss)
CYAN: "#4DD0E1",
// Light cyan (water/ice)
PINK: "#F48FB1",
// Light pink (charm/healing)
// Secondary palette - darker variants
DARK_BLUE: "#1976D2",
// Darker blue
DARK_RED: "#D32F2F",
// Darker red
DARK_GREEN: "#388E3C",
// Darker green
DARK_YELLOW: "#F57C00",
// Darker yellow/amber
DARK_PURPLE: "#7B1FA2",
// Darker purple
DARK_ORANGE: "#E65100",
// Darker orange
DARK_CYAN: "#00838F",
// Darker cyan
DARK_PINK: "#C2185B",
// Darker pink
// Grays - neutral colors
WHITE: "#FFFFFF",
// Pure white (contrast text)
LIGHT_GRAY: "#BDBDBD",
// Light gray (disabled/secondary)
GRAY: "#757575",
// Medium gray (borders/lines)
DARK_GRAY: "#424242",
// Dark gray (backgrounds)
BLACK: "#000000",
// Pure black
// Special game colors - semantic meanings
ALLY: "#4CAF50",
// Green for allies/friendly
ENEMY: "#FF6B6B",
// Red for enemies/hostile
NEUTRAL: "#FFD54F",
// Yellow for neutral/movement
HIGHLIGHT: "#4FC3F7",
// Blue for highlights/selection
DANGER: "#FF5722",
// Orange-red for dangerous terrain
DIFFICULT: "#8D6E63",
// Brown for difficult terrain
ENGAGEMENT: "#FFEB3B",
// Bright yellow for engagement zones
// Legacy colors (maintained for compatibility)
DEFAULT_HEX: "#2a2a2a",
HIGHLIGHT_BLUE: "#4FC3F7",
ENGAGEMENT_YELLOW: "#FFFF00"
}, T = {
// Common hex positions
CENTER: [0, 0],
NORTH: [0, -1],
NORTHEAST: [1, -1],
SOUTHEAST: [1, 0],
SOUTH: [0, 1],
SOUTHWEST: [-1, 1],
NORTHWEST: [-1, 0],
// Ring 2 positions
NORTH_2: [0, -2],
NORTHEAST_2: [1, -2],
EAST_2: [2, -1],
SOUTHEAST_2: [2, 0],
SOUTH_2: [0, 2],
SOUTHWEST_2: [-1, 2],
WEST_2: [-2, 1],
NORTHWEST_2: [-2, 0]
}, P = class P {
constructor(t, i = {}) {
this.allHexCells = /* @__PURE__ */ new Map(), this.gamePieces = [], this.gamePointers = [], this.gameCaptions = [], this.gameDice = [], this.tokenRegistry = /* @__PURE__ */ new Map(), this.width = 1e3, this.height = 700, this.hexRadius = 25, this.gridRadius = 3, this.coordinatesVisible = !0, this.isAnimating = !1, this.time = 0, this.svg = A.select(t), this.gridRadius = i.gridRadius ?? 3, this.hexRadius = i.hexRadius ?? this.calculateOptimalHexSize(i.gridRadius ?? 3), this.width = i.width ?? 1e3, this.height = i.height ?? 700, this.onGridExpansion = i.onGridExpansion, (i.width || i.height) && this.svg.attr("width", this.width).attr("height", this.height), this.initializeBoard(), this.startAnimationLoop(), this.render();
}
/**
* Resolves color constants (e.g., "Colors.BLUE") to hex color codes.
* If the input is already a hex color or not a color constant, returns it unchanged.
*/
resolveColor(t) {
if (t.startsWith("Colors.")) {
const i = t.slice(7);
return E[i] || t;
}
return t in E ? E[t] : t;
}
axialToPixel(t, i) {
const a = this.hexRadius, s = a * (3 / 2 * t), e = a * (Math.sqrt(3) / 2 * t + Math.sqrt(3) * i);
return { x: s + this.width / 2, y: e + this.height / 2 };
}
initializeBoard() {
this.allHexCells.size === 0 && this.createFixedCoordinateSpace(), this.updateHexPixelPositions();
}
createFixedCoordinateSpace() {
const t = P.MAX_COORDINATE_RANGE;
for (let i = -t; i <= t; i++) {
const a = Math.max(-t, -i - t), s = Math.min(t, -i + t);
for (let e = a; e <= s; e++) {
const r = `hex-${i}-${e}`, h = this.axialToPixel(i, e), n = {
q: i,
r: e,
x: h.x,
y: h.y,
id: r,
highlighted: !1,
isBlinking: !1,
blinkPhase: 0,
isPulsing: !1,
pulsePhase: 0,
originalColor: "#2a2a2a"
};
this.allHexCells.set(r, n);
}
}
}
updateHexPixelPositions() {
this.allHexCells.forEach((t) => {
const i = this.axialToPixel(t.q, t.r);
t.x = i.x, t.y = i.y;
}), this.gamePieces.forEach((t) => {
const i = this.axialToPixel(t.currentHex.q, t.currentHex.r);
t.x = i.x, t.y = i.y;
}), this.gamePointers.forEach((t) => {
const i = this.axialToPixel(t.targetQ, t.targetR), a = { x: this.width / 2, y: this.height / 2 }, s = Math.sqrt(Math.pow(i.x - a.x, 2) + Math.pow(i.y - a.y, 2)), e = Math.min(s * 0.4, 100), r = Math.atan2(i.y - a.y, i.x - a.x);
t.x = i.x, t.y = i.y, t.startX = i.x - Math.cos(r) * e, t.startY = i.y - Math.sin(r) * e;
});
}
getVisibleHexCells() {
const t = [];
return this.allHexCells.forEach((i) => {
Math.max(Math.abs(i.q), Math.abs(i.r), Math.abs(-i.q - i.r)) <= this.gridRadius && i.x >= 50 && i.x <= this.width - 50 && i.y >= 50 && i.y <= this.height - 50 && t.push(i);
}), t;
}
isCoordinateInFixedSpace(t, i) {
return Math.max(Math.abs(t), Math.abs(i), Math.abs(-t - i)) <= P.MAX_COORDINATE_RANGE;
}
calculateRequiredGridRadius(t) {
let i = this.gridRadius;
return t.forEach((a) => {
const s = Math.max(Math.abs(a.q), Math.abs(a.r), Math.abs(-a.q - a.r));
i = Math.max(i, s);
}), i;
}
ensureCoordinatesVisible(t) {
const i = t.filter((s) => !this.isCoordinateInFixedSpace(s.q, s.r));
if (i.length > 0)
return console.warn(`Coordinates outside fixed coordinate space (±${P.MAX_COORDINATE_RANGE}):`, i), !1;
const a = this.calculateRequiredGridRadius(t);
if (a > this.gridRadius) {
const s = this.gridRadius;
this.gridRadius = a;
const e = t.filter((h) => Math.max(Math.abs(h.q), Math.abs(h.r), Math.abs(-h.q - h.r)) > s);
this.onGridExpansion ? this.onGridExpansion(s, a, e) : console.log(`Auto-expanded grid from ${s} to ${a} to show coordinates:`, e);
const r = this.calculateOptimalHexSize(a);
return r !== this.hexRadius && (this.hexRadius = r, this.updateHexPixelPositions()), !0;
}
return !1;
}
createHexagonPath(t) {
const i = [];
for (let a = 0; a < 6; a++) {
const s = Math.PI / 3 * a, e = t * Math.cos(s), r = t * Math.sin(s);
i.push([e, r]);
}
return `M ${i[0][0]},${i[0][1]} ` + i.slice(1).map((a) => `L ${a[0]},${a[1]}`).join(" ") + " Z";
}
createRectPath(t) {
const i = t * 0.8;
return `M ${-i},${-i} L ${i},${-i} L ${i},${i} L ${-i},${i} Z`;
}
createTrianglePath(t) {
const i = t * 1.2, a = t;
return `M 0,${-i / 2} L ${a / 2},${i / 2} L ${-a / 2},${i / 2} Z`;
}
createStarPath(t) {
const i = t, a = t * 0.4, s = [];
for (let e = 0; e < 10; e++) {
const r = e * Math.PI / 5, h = e % 2 === 0 ? i : a, n = h * Math.cos(r - Math.PI / 2), l = h * Math.sin(r - Math.PI / 2);
s.push(e === 0 ? `M ${n},${l}` : `L ${n},${l}`);
}
return s.join(" ") + " Z";
}
createArrowPath(t, i, a, s) {
const h = a - t, n = s - i, l = Math.sqrt(h * h + n * n);
if (l === 0)
return { line: "", head: "" };
const d = h / l, o = n / l, c = a - 15 * d, x = s - 15 * o, g = -o * 8, m = d * 8, C = c + g, p = x + m, R = c - g, H = x - m, k = a - 15 * 0.7 * d, $ = s - 15 * 0.7 * o, M = `M ${t},${i} L ${k},${$}`, w = `M ${a},${s} L ${C},${p} L ${R},${H} Z`;
return { line: M, head: w };
}
render() {
this.svg.selectAll("*").remove();
const t = this.getVisibleHexCells();
this.svg.selectAll("path.hex").data(t, (s) => s.id).enter().append("path").attr("class", "hex").attr("d", this.createHexagonPath(this.hexRadius)).attr("transform", (s) => `translate(${s.x},${s.y})`).attr("fill", (s) => this.getHexFillColor(s)).attr("stroke", "#666").attr("stroke-width", 1).attr("opacity", 0.8), this.coordinatesVisible && this.svg.selectAll("text.coordinate").data(t, (e) => e.id).enter().append("text").attr("class", "coordinate").attr("x", (e) => e.x).attr("y", (e) => e.y).attr("text-anchor", "middle").attr("fill", "#fff").attr("font-size", "10px").attr("font-family", "monospace").text((e) => `${e.q},${e.r}`), this.gamePieces.forEach((s) => {
if (s.shape === "circle")
this.svg.append("circle").attr("class", "token").attr("cx", s.x).attr("cy", s.y).attr("r", s.size).attr("fill", s.color).attr("stroke", "#fff").attr("stroke-width", 2);
else {
const e = this.getShapePath(s.shape, s.size);
this.svg.append("path").attr("class", "token").attr("d", e).attr("transform", `translate(${s.x},${s.y})`).attr("fill", s.color).attr("stroke", "#fff").attr("stroke-width", 2);
}
s.label && this.svg.append("text").attr("class", "token-label").attr("x", s.x).attr("y", s.y + s.size + 18).attr("text-anchor", "middle").attr("fill", "#fff").attr("font-size", "12px").attr("font-family", "sans-serif").attr("font-weight", "bold").attr("stroke", "#000").attr("stroke-width", 0.5).text(s.label);
}), this.gamePointers.forEach((s) => {
const e = this.createArrowPath(s.startX, s.startY, s.x, s.y);
if (e.line && this.svg.append("path").attr("class", "pointer-line").attr("d", e.line).attr("stroke", s.color).attr("stroke-width", 3).attr("fill", "none").attr("stroke-linecap", "round"), e.head && this.svg.append("path").attr("class", "pointer-head").attr("d", e.head).attr("fill", s.color).attr("stroke", s.color).attr("stroke-width", 1), s.label) {
const r = s.startX + (s.x - s.startX) * 0.2, h = s.startY + (s.y - s.startY) * 0.2 - 10;
this.svg.append("text").attr("class", "pointer-label").attr("x", r).attr("y", h).attr("text-anchor", "middle").attr("fill", s.color).attr("font-size", "14px").attr("font-family", "sans-serif").attr("font-weight", "bold").attr("stroke", "#000").attr("stroke-width", 0.5).text(s.label);
}
}), this.gameCaptions.forEach((s) => {
if (s.visible) {
let e, r;
s.position === "bottom" ? (e = this.height - 80, r = this.height - 40) : (e = this.height / 2 - 40, r = this.height / 2), this.svg.append("rect").attr("class", "caption-background").attr("x", 0).attr("y", e).attr("width", this.width).attr("height", 80).attr("fill", "rgba(0, 0, 0, 0.7)").attr("stroke", "none"), this.svg.append("text").attr("class", "caption-text").attr("x", this.width / 2).attr("y", r).attr("text-anchor", "middle").attr("dominant-baseline", "middle").attr("fill", "#ffffff").attr("font-size", "28px").attr("font-family", "sans-serif").attr("font-weight", "bold").attr("stroke", "#000").attr("stroke-width", 1).text(s.text);
}
});
const a = this.gameDice.filter((s) => s.visible);
if (a.length > 0) {
const r = a.length * 80 + (a.length - 1) * 20, h = this.width / 2 - r / 2, n = this.height / 2;
a.forEach((l, d) => {
const o = h + d * 100 + 40, c = l.color || "#f0f0f0";
if (l.dieType === "d6")
this.svg.append("rect").attr("class", "dice").attr("x", o - 80 / 2).attr("y", n - 80 / 2).attr("width", 80).attr("height", 80).attr("rx", 8).attr("ry", 8).attr("fill", c).attr("stroke", "#333333").attr("stroke-width", 3);
else {
const x = [
[o, n - 40],
[o + 40, n],
[o, n + 40],
[o - 40, n]
];
this.svg.append("polygon").attr("class", "dice").attr("points", x.map((g) => `${g[0]},${g[1]}`).join(" ")).attr("fill", c).attr("stroke", "#333333").attr("stroke-width", 3);
}
this.svg.append("text").attr("class", "dice-number").attr("x", o).attr("y", n).attr("text-anchor", "middle").attr("dominant-baseline", "middle").attr("fill", "#333333").attr("font-size", "36px").attr("font-family", "sans-serif").attr("font-weight", "bold").text(l.displayedNumber.toString());
});
}
}
getHexFillColor(t) {
if (t.isPulsing && t.pulseColor) {
const i = (Math.sin(t.pulsePhase) + 1) / 2;
return this.interpolateColors(t.originalColor, t.pulseColor, i);
}
return t.isBlinking && t.blinkColor ? (Math.sin(t.blinkPhase) + 1) / 2 > 0.5 ? t.blinkColor : t.originalColor : t.highlighted && t.highlightColor ? t.highlightColor : t.originalColor;
}
interpolateColors(t, i, a) {
const s = (o) => {
const c = parseInt(o.slice(1, 3), 16), x = parseInt(o.slice(3, 5), 16), g = parseInt(o.slice(5, 7), 16);
return { r: c, g: x, b: g };
}, e = s(t), r = s(i), h = Math.round(e.r + (r.r - e.r) * a), n = Math.round(e.g + (r.g - e.g) * a), l = Math.round(e.b + (r.b - e.b) * a), d = (o) => o.toString(16).padStart(2, "0");
return `#${d(h)}${d(n)}${d(l)}`;
}
getShapePath(t, i) {
switch (t) {
case "rect":
return this.createRectPath(i);
case "triangle":
return this.createTrianglePath(i);
case "star":
return this.createStarPath(i);
default:
return "";
}
}
startAnimationLoop() {
const t = () => {
this.time += 0.05, this.allHexCells.forEach((i) => {
i.isBlinking && (i.blinkPhase = this.time * 3), i.isPulsing && (i.pulsePhase = this.time * 0.8);
}), this.render(), requestAnimationFrame(t);
};
t();
}
showCoordinates() {
this.coordinatesVisible = !0, this.render();
}
hideCoordinates() {
this.coordinatesVisible = !1, this.render();
}
// Public API methods from README.md
highlight(t, i, a = "HIGHLIGHT") {
const s = this.ensureCoordinatesVisible([{ q: t, r: i }]), e = this.resolveColor(a), r = this.allHexCells.get(`hex-${t}-${i}`);
r && (r.highlighted = !0, r.highlightColor = e, r.isBlinking = !1, r.isPulsing = !1, s && this.render());
}
blink(t, i, a = "HIGHLIGHT") {
const s = this.ensureCoordinatesVisible([{ q: t, r: i }]), e = this.resolveColor(a), r = this.allHexCells.get(`hex-${t}-${i}`);
r && (r.isBlinking = !0, r.blinkColor = e, r.highlighted = !1, r.isPulsing = !1, r.blinkPhase = this.time * 3, s && this.render());
}
pulse(t, i, a = "ENGAGEMENT") {
const s = this.ensureCoordinatesVisible([{ q: t, r: i }]), e = this.resolveColor(a), r = this.allHexCells.get(`hex-${t}-${i}`);
r && (r.isPulsing = !0, r.pulseColor = e, r.highlighted = !1, r.isBlinking = !1, r.pulsePhase = this.time * 0.8, s && this.render());
}
point(t, i, a) {
const s = this.ensureCoordinatesVisible([{ q: t, r: i }]), e = this.allHexCells.get(`hex-${t}-${i}`);
if (!e) return;
const r = this.width / 2, h = this.height / 2, n = e.x - r, l = e.y - h, d = Math.sqrt(n * n + l * l);
if (d === 0) {
const o = r, c = h - 80, x = e.x, g = e.y;
this.gamePointers.push({
id: `pointer-${Date.now()}-${Math.random()}`,
targetQ: t,
targetR: i,
x,
y: g,
startX: o,
startY: c,
label: a,
color: E.RED
});
} else {
const o = n / d, c = l / d, g = d + 100, m = r + o * g, C = h + c * g, p = e.x, R = e.y;
this.gamePointers.push({
id: `pointer-${Date.now()}-${Math.random()}`,
targetQ: t,
targetR: i,
x: p,
y: R,
startX: m,
startY: C,
label: a,
color: E.RED
});
}
s && this.render();
}
async caption(t, i = 2e3, a = "bottom") {
const s = {
id: `caption-${Date.now()}-${Math.random()}`,
text: t,
startTime: Date.now(),
duration: i,
visible: !0,
position: a
};
return this.gameCaptions.push(s), new Promise((e) => {
setTimeout(() => {
const r = this.gameCaptions.findIndex((h) => h.id === s.id);
r !== -1 && this.gameCaptions.splice(r, 1), setTimeout(() => {
e();
}, 1e3);
}, i);
});
}
dice(t, i, a = "LIGHT_GRAY") {
const s = t === "d6" ? 6 : 20;
if (i < 1 || i > s) {
console.warn(`Invalid number ${i} for ${t}. Must be between 1 and ${s}.`);
return;
}
const e = this.resolveColor(a), r = {
id: `dice-${Date.now()}-${Math.random()}`,
dieType: t,
displayedNumber: i,
visible: !0,
color: e
};
this.gameDice.push(r), this.render();
}
clear(t = u.ALL) {
switch (t) {
case u.ALL:
this.clearHighlights(), this.clearBlinks(), this.clearPulses(), this.clearPointers(), this.clearTokens(), this.clearCaptions(), this.clearDice();
break;
case u.HIGHLIGHT:
this.clearHighlights();
break;
case u.BLINK:
this.clearBlinks();
break;
case u.PULSE:
this.clearPulses();
break;
case u.POINT:
this.clearPointers();
break;
case u.TOKEN:
this.clearTokens();
break;
case u.CAPTION:
this.clearCaptions();
break;
case u.DICE:
this.clearDice();
break;
}
this.render();
}
clearHighlights() {
this.allHexCells.forEach((t) => {
t.highlighted = !1, t.highlightColor = void 0;
});
}
clearBlinks() {
this.allHexCells.forEach((t) => {
t.isBlinking = !1, t.blinkColor = void 0, t.blinkPhase = 0;
});
}
clearPulses() {
this.allHexCells.forEach((t) => {
t.isPulsing = !1, t.pulseColor = void 0, t.pulsePhase = 0;
});
}
clearPointers() {
this.gamePointers = [];
}
clearTokens() {
this.gamePieces = [], this.tokenRegistry.clear();
}
clearCaptions() {
this.gameCaptions = [];
}
clearDice() {
this.gameDice = [];
}
token(t, i, a, s, e, r) {
const h = this.ensureCoordinatesVisible([{ q: t, r: i }]), n = this.resolveColor(e), l = this.allHexCells.get(`hex-${t}-${i}`);
if (!l) return;
if (this.tokenRegistry.has(a)) {
const o = this.tokenRegistry.get(a), c = this.gamePieces.findIndex((x) => x.id === o.id);
c !== -1 && this.gamePieces.splice(c, 1);
}
const d = {
id: `token-${Date.now()}-${Math.random()}`,
tokenName: a,
x: l.x,
y: l.y,
color: n,
size: 12,
shape: s,
currentHex: { q: t, r: i },
label: r
};
this.gamePieces.push(d), this.tokenRegistry.set(a, d), h && this.render();
}
async move(t, i, a) {
const s = this.tokenRegistry.get(t);
if (!s || this.isAnimating) return;
this.ensureCoordinatesVisible([{ q: i, r: a }]) && this.render();
const r = this.allHexCells.get(`hex-${i}-${a}`);
if (!r) return;
this.isAnimating = !0;
const h = r.highlighted, n = r.highlightColor;
r.highlighted = !0, r.highlightColor = "#4fc3f7", await new Promise((l) => {
const d = s.x, o = s.y, c = 1e3, x = Date.now(), g = () => {
const m = Date.now() - x, C = Math.min(m / c, 1), p = 1 - Math.pow(1 - C, 3);
s.x = d + (r.x - d) * p, s.y = o + (r.y - o) * p, C < 1 ? requestAnimationFrame(g) : (s.currentHex = { q: i, r: a }, r.highlighted = h, r.highlightColor = n, this.isAnimating = !1, l());
};
g();
});
}
resetBoard() {
this.allHexCells.forEach((t) => {
t.highlighted = !1, t.isBlinking = !1, t.isPulsing = !1, t.highlightColor = void 0, t.blinkColor = void 0, t.pulseColor = void 0;
}), this.gamePieces = [], this.gamePointers = [], this.gameCaptions = [], this.gameDice = [], this.tokenRegistry.clear(), this.render();
}
setGridSize(t) {
this.gridRadius = t, this.render();
}
setGridSizeWithScaling(t) {
this.gridRadius = t, this.hexRadius = this.calculateOptimalHexSize(t), this.updateHexPixelPositions(), this.render();
}
calculateOptimalHexSize(t) {
const a = this.width - 120, s = this.height - 120, e = a / (t * 3), r = s / (t * 2 * Math.sqrt(3)), h = Math.min(e, r);
return Math.max(8, Math.min(40, h));
}
setHexSize(t) {
this.hexRadius = t, this.initializeBoard(), this.render();
}
configure(t) {
t.gridRadius !== void 0 && (this.gridRadius = t.gridRadius), t.hexRadius !== void 0 && (this.hexRadius = t.hexRadius), t.width !== void 0 && (this.width = t.width, this.svg.attr("width", this.width)), t.height !== void 0 && (this.height = t.height, this.svg.attr("height", this.height)), this.initializeBoard(), this.render();
}
getGridConfig() {
return {
gridRadius: this.gridRadius,
hexRadius: this.hexRadius,
width: this.width,
height: this.height
};
}
setGridExpansionCallback(t) {
this.onGridExpansion = t;
}
};
P.MAX_COORDINATE_RANGE = 20;
let b = P;
export {
b as BoardcastHexBoard,
u as ClearType,
E as Colors,
T as Coords
};
//# sourceMappingURL=index.js.map