UNPKG

boardcast

Version:

Animation library for tabletop game rules on hex boards with CLI tools and game extensions

500 lines (499 loc) 20.7 kB
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