import { Container, Graphics, Text, TextStyle } from "pixi.js"; import type { SymbolStyle } from "@spinza/game-core/client"; /** * Procedural symbol artwork. Every Spinza symbol is drawn from primitives — * no external assets — so each game keeps an original look while sharing * one renderer. `size` is the tile edge in px; art is centred on (0,0). */ function hex(c: string): number { return parseInt(c.replace("#", ""), 16); } function lighten(c: string, amt: number): number { const n = hex(c); const r = Math.min(255, ((n >> 16) & 255) + Math.round(255 * amt)); const g = Math.min(255, ((n >> 8) & 255) + Math.round(255 * amt)); const b = Math.min(255, (n & 255) + Math.round(255 * amt)); return (r << 16) | (g << 8) | b; } function darken(c: string, amt: number): number { const n = hex(c); const r = Math.max(0, ((n >> 16) & 255) - Math.round(255 * amt)); const g = Math.max(0, ((n >> 8) & 255) - Math.round(255 * amt)); const b = Math.max(0, (n & 255) - Math.round(255 * amt)); return (r << 16) | (g << 8) | b; } function polygon(g: Graphics, cx: number, cy: number, r: number, sides: number, rot = -Math.PI / 2): Graphics { const pts: number[] = []; for (let i = 0; i < sides; i++) { const a = rot + (i * Math.PI * 2) / sides; pts.push(cx + Math.cos(a) * r, cy + Math.sin(a) * r); } return g.poly(pts); } function star(g: Graphics, cx: number, cy: number, outer: number, inner: number, points = 5): Graphics { const pts: number[] = []; for (let i = 0; i < points * 2; i++) { const r = i % 2 === 0 ? outer : inner; const a = -Math.PI / 2 + (i * Math.PI) / points; pts.push(cx + Math.cos(a) * r, cy + Math.sin(a) * r); } return g.poly(pts); } export interface SymbolArtOptions { size: number; /** Tier drives frame richness. */ tier: "low" | "mid" | "high" | "premium" | "special"; kind: string; } export function drawSymbol(style: SymbolStyle, opts: SymbolArtOptions): Container { const { size } = opts; const c = new Container(); const main = hex(style.color); const accent = style.accent ? hex(style.accent) : lighten(style.color, 0.35); const glow = style.glow ?? 0.3; const r = size * 0.36; // icon radius const g = new Graphics(); // Tile plate. const plate = new Graphics(); const pad = size * 0.06; plate.roundRect(-size / 2 + pad, -size / 2 + pad, size - pad * 2, size - pad * 2, size * 0.16).fill({ color: 0x0b0d14, alpha: 0.72 }); plate.roundRect(-size / 2 + pad, -size / 2 + pad, size - pad * 2, size - pad * 2, size * 0.16).stroke({ color: opts.tier === "premium" || opts.tier === "special" ? accent : 0xffffff, alpha: opts.tier === "premium" || opts.tier === "special" ? 0.45 : 0.08, width: Math.max(1, size * 0.012) }); c.addChild(plate); // Glow halo. if (glow > 0) { const halo = new Graphics(); halo.circle(0, 0, r * 1.25).fill({ color: main, alpha: 0.18 * glow }); halo.circle(0, 0, r * 1.0).fill({ color: main, alpha: 0.16 * glow }); c.addChild(halo); } switch (style.shape) { case "gem": { g.poly([0, -r, r * 0.85, -r * 0.25, r * 0.55, r, -r * 0.55, r, -r * 0.85, -r * 0.25]).fill(main); g.poly([0, -r, r * 0.85, -r * 0.25, 0, -r * 0.1, -r * 0.85, -r * 0.25]).fill({ color: accent, alpha: 0.85 }); g.poly([0, -r * 0.1, r * 0.85, -r * 0.25, r * 0.55, r]).fill({ color: darken(style.color, 0.15) }); g.poly([-r * 0.2, -r * 0.7, r * 0.15, -r * 0.55, -r * 0.35, -r * 0.35]).fill({ color: 0xffffff, alpha: 0.65 }); break; } case "hex": { polygon(g, 0, 0, r, 6).fill(main); polygon(g, 0, 0, r * 0.68, 6).fill({ color: darken(style.color, 0.25) }); polygon(g, 0, 0, r * 0.42, 6).fill(accent); if (style.label) c.addChild(label(style.label, size * 0.26, 0x0b0d14)); break; } case "star": { star(g, 0, 0, r, r * 0.45).fill(main); star(g, 0, 0, r * 0.55, r * 0.25).fill({ color: 0xffffff, alpha: 0.85 }); break; } case "circle": { g.circle(0, 0, r).fill(main); g.circle(-r * 0.3, -r * 0.3, r * 0.35).fill({ color: 0xffffff, alpha: 0.35 }); g.circle(0, 0, r).stroke({ color: accent, width: size * 0.02 }); break; } case "diamond": { g.poly([0, -r, r, 0, 0, r, -r, 0]).fill(main); g.poly([0, -r, r, 0, 0, 0]).fill({ color: accent, alpha: 0.8 }); g.poly([0, 0, 0, r, -r, 0]).fill({ color: darken(style.color, 0.2) }); break; } case "shield": { g.poly([0, -r, r * 0.85, -r * 0.65, r * 0.75, r * 0.15, 0, r, -r * 0.75, r * 0.15, -r * 0.85, -r * 0.65]).fill(main); g.poly([0, -r * 0.7, r * 0.5, -r * 0.5, r * 0.45, r * 0.05, 0, r * 0.6, -r * 0.45, r * 0.05, -r * 0.5, -r * 0.5]).fill({ color: darken(style.color, 0.28) }); g.rect(-r * 0.08, -r * 0.5, r * 0.16, r * 0.9).fill(accent); g.rect(-r * 0.38, -r * 0.25, r * 0.76, r * 0.16).fill(accent); break; } case "bolt": { g.poly([r * 0.15, -r, -r * 0.55, r * 0.1, -r * 0.05, r * 0.1, -r * 0.25, r, r * 0.6, -r * 0.15, r * 0.05, -r * 0.15]).fill(main); g.poly([r * 0.1, -r * 0.8, -r * 0.3, r * 0.0, r * 0.0, 0]).fill({ color: 0xffffff, alpha: 0.5 }); break; } case "ring": { g.circle(0, 0, r).fill(main); g.circle(0, 0, r * 0.62).cut(); g.circle(0, 0, r * 0.62).stroke({ color: accent, width: size * 0.02, alpha: 0.9 }); g.arc(0, 0, r * 0.81, -Math.PI * 0.9, -Math.PI * 0.3).stroke({ color: 0xffffff, width: size * 0.04, alpha: 0.55 }); break; } case "chip": { g.roundRect(-r, -r * 0.75, r * 2, r * 1.5, r * 0.25).fill(main); g.roundRect(-r * 0.75, -r * 0.5, r * 1.5, r, r * 0.15).fill({ color: darken(style.color, 0.35) }); for (let i = -2; i <= 2; i++) { g.rect(-r - r * 0.18, i * r * 0.28 - r * 0.06, r * 0.18, r * 0.12).fill(accent); g.rect(r, i * r * 0.28 - r * 0.06, r * 0.18, r * 0.12).fill(accent); } if (style.label) c.addChild(label(style.label, size * 0.22, accent)); break; } case "crystal": { g.poly([0, -r, r * 0.5, -r * 0.3, r * 0.35, r * 0.8, -r * 0.35, r * 0.8, -r * 0.5, -r * 0.3]).fill(main); g.poly([0, -r, r * 0.5, -r * 0.3, 0, r * 0.1]).fill({ color: accent, alpha: 0.75 }); g.poly([0, r * 0.1, r * 0.35, r * 0.8, -r * 0.35, r * 0.8]).fill({ color: darken(style.color, 0.2) }); break; } case "letter": { g.roundRect(-r * 0.95, -r * 0.95, r * 1.9, r * 1.9, r * 0.3).fill({ color: darken(style.color, 0.45), alpha: 0.9 }); g.roundRect(-r * 0.95, -r * 0.95, r * 1.9, r * 1.9, r * 0.3).stroke({ color: main, width: size * 0.025 }); c.addChild(label(style.label ?? "?", size * 0.4, main, true)); break; } case "vault": { g.circle(0, 0, r).fill(darken(style.color, 0.4)); g.circle(0, 0, r).stroke({ color: main, width: size * 0.03 }); g.circle(0, 0, r * 0.7).stroke({ color: main, width: size * 0.02, alpha: 0.7 }); for (let i = 0; i < 6; i++) { const a = (i * Math.PI) / 3; g.moveTo(Math.cos(a) * r * 0.25, Math.sin(a) * r * 0.25).lineTo(Math.cos(a) * r * 0.62, Math.sin(a) * r * 0.62).stroke({ color: accent, width: size * 0.035 }); } g.circle(0, 0, r * 0.22).fill(accent); break; } case "orb": { g.circle(0, 0, r).fill(main); g.circle(0, 0, r * 0.75).fill({ color: accent, alpha: 0.35 }); g.circle(-r * 0.25, -r * 0.3, r * 0.25).fill({ color: 0xffffff, alpha: 0.5 }); g.circle(0, 0, r).stroke({ color: accent, width: size * 0.02, alpha: 0.9 }); if (style.label) c.addChild(label(style.label, size * 0.36, accent, true)); break; } case "coin": { g.circle(0, 0, r).fill(main); g.circle(0, 0, r * 0.8).stroke({ color: darken(style.color, 0.25), width: size * 0.03 }); g.circle(0, 0, r).stroke({ color: accent, width: size * 0.02 }); c.addChild(label(style.label ?? "SC", size * 0.24, darken(style.color, 0.5), true)); break; } case "skull": { g.circle(0, -r * 0.15, r * 0.8).fill(main); g.roundRect(-r * 0.45, r * 0.3, r * 0.9, r * 0.55, r * 0.15).fill(main); g.circle(-r * 0.3, -r * 0.2, r * 0.22).fill(0x0b0d14); g.circle(r * 0.3, -r * 0.2, r * 0.22).fill(0x0b0d14); g.poly([0, r * 0.05, r * 0.12, r * 0.3, -r * 0.12, r * 0.3]).fill(0x0b0d14); break; } case "flame": { g.poly([0, -r, r * 0.55, -r * 0.2, r * 0.7, r * 0.45, r * 0.3, r, -r * 0.3, r, -r * 0.7, r * 0.45, -r * 0.55, -r * 0.2]).fill(main); g.poly([0, -r * 0.4, r * 0.3, r * 0.1, r * 0.25, r * 0.6, -r * 0.25, r * 0.6, -r * 0.3, r * 0.1]).fill(accent); break; } case "snow": { for (let i = 0; i < 3; i++) { const a = (i * Math.PI) / 3; g.moveTo(Math.cos(a) * r, Math.sin(a) * r).lineTo(-Math.cos(a) * r, -Math.sin(a) * r).stroke({ color: main, width: size * 0.035 }); for (const s of [1, -1]) { const bx = Math.cos(a) * r * 0.6 * s; const by = Math.sin(a) * r * 0.6 * s; g.moveTo(bx, by).lineTo(bx + Math.cos(a + 0.6) * r * 0.3 * s, by + Math.sin(a + 0.6) * r * 0.3 * s).stroke({ color: accent, width: size * 0.025 }); g.moveTo(bx, by).lineTo(bx + Math.cos(a - 0.6) * r * 0.3 * s, by + Math.sin(a - 0.6) * r * 0.3 * s).stroke({ color: accent, width: size * 0.025 }); } } g.circle(0, 0, r * 0.14).fill(0xffffff); break; } case "eye": { g.ellipse(0, 0, r, r * 0.6).fill(main); g.circle(0, 0, r * 0.42).fill(darken(style.color, 0.5)); g.circle(0, 0, r * 0.2).fill(accent); g.circle(r * 0.12, -r * 0.14, r * 0.07).fill(0xffffff); break; } case "moon": { g.circle(0, 0, r).fill(main); g.circle(r * 0.45, -r * 0.2, r * 0.78).fill({ color: 0x0b0d14, alpha: 0.85 }); g.circle(-r * 0.4, r * 0.3, r * 0.12).fill({ color: darken(style.color, 0.2) }); break; } case "leaf": { g.moveTo(0, r).bezierCurveTo(-r * 1.1, r * 0.2, -r * 0.6, -r * 0.9, 0, -r).bezierCurveTo(r * 0.6, -r * 0.9, r * 1.1, r * 0.2, 0, r).fill(main); g.moveTo(0, r * 0.9).lineTo(0, -r * 0.8).stroke({ color: accent, width: size * 0.02 }); break; } case "atom": { g.circle(0, 0, r * 0.2).fill(accent); for (let i = 0; i < 3; i++) { const e = new Graphics(); e.ellipse(0, 0, r, r * 0.38).stroke({ color: main, width: size * 0.025 }); e.rotation = (i * Math.PI) / 3; c.addChild(e); } break; } case "crown": { g.poly([-r, r * 0.6, -r, -r * 0.4, -r * 0.5, r * 0.05, 0, -r, r * 0.5, r * 0.05, r, -r * 0.4, r, r * 0.6]).fill(main); g.rect(-r, r * 0.4, r * 2, r * 0.25).fill(darken(style.color, 0.3)); g.circle(0, -r * 0.95, r * 0.12).fill(accent); g.circle(-r, -r * 0.4, r * 0.1).fill(accent); g.circle(r, -r * 0.4, r * 0.1).fill(accent); break; } case "wave": { for (let k = -1; k <= 1; k++) { const y = k * r * 0.5; g.moveTo(-r, y).bezierCurveTo(-r * 0.5, y - r * 0.4, -r * 0.2, y + r * 0.4, 0, y).bezierCurveTo(r * 0.2, y - r * 0.4, r * 0.5, y + r * 0.4, r, y).stroke({ color: k === 0 ? accent : main, width: size * 0.04, alpha: k === 0 ? 1 : 0.8 }); } break; } case "cube": { g.poly([0, -r, r * 0.87, -r * 0.5, r * 0.87, r * 0.5, 0, r, -r * 0.87, r * 0.5, -r * 0.87, -r * 0.5]).fill(main); g.poly([0, -r, r * 0.87, -r * 0.5, 0, 0, -r * 0.87, -r * 0.5]).fill(accent); g.poly([0, 0, r * 0.87, -r * 0.5, r * 0.87, r * 0.5, 0, r]).fill(darken(style.color, 0.3)); break; } } c.addChild(g); return c; } function label(text: string, fontSize: number, color: number, bold = true): Text { const t = new Text({ text, style: new TextStyle({ fontFamily: "Geist, Inter, system-ui, sans-serif", fontSize, fontWeight: bold ? "800" : "600", fill: color, letterSpacing: 1 }), }); t.anchor.set(0.5); return t; } export { hex as hexColor, lighten, darken };