1import { Container, Graphics, Text, TextStyle } from "pixi.js";2import type { SymbolStyle } from "@spinza/game-core/client";34/**5 * Procedural symbol artwork. Every Spinza symbol is drawn from primitives —6 * no external assets — so each game keeps an original look while sharing7 * one renderer. `size` is the tile edge in px; art is centred on (0,0).8 */910function hex(c: string): number {11 return parseInt(c.replace("#", ""), 16);12}1314function lighten(c: string, amt: number): number {15 const n = hex(c);16 const r = Math.min(255, ((n >> 16) & 255) + Math.round(255 * amt));17 const g = Math.min(255, ((n >> 8) & 255) + Math.round(255 * amt));18 const b = Math.min(255, (n & 255) + Math.round(255 * amt));19 return (r << 16) | (g << 8) | b;20}2122function darken(c: string, amt: number): number {23 const n = hex(c);24 const r = Math.max(0, ((n >> 16) & 255) - Math.round(255 * amt));25 const g = Math.max(0, ((n >> 8) & 255) - Math.round(255 * amt));26 const b = Math.max(0, (n & 255) - Math.round(255 * amt));27 return (r << 16) | (g << 8) | b;28}2930function polygon(g: Graphics, cx: number, cy: number, r: number, sides: number, rot = -Math.PI / 2): Graphics {31 const pts: number[] = [];32 for (let i = 0; i < sides; i++) {33 const a = rot + (i * Math.PI * 2) / sides;34 pts.push(cx + Math.cos(a) * r, cy + Math.sin(a) * r);35 }36 return g.poly(pts);37}3839function star(g: Graphics, cx: number, cy: number, outer: number, inner: number, points = 5): Graphics {40 const pts: number[] = [];41 for (let i = 0; i < points * 2; i++) {42 const r = i % 2 === 0 ? outer : inner;43 const a = -Math.PI / 2 + (i * Math.PI) / points;44 pts.push(cx + Math.cos(a) * r, cy + Math.sin(a) * r);45 }46 return g.poly(pts);47}4849export interface SymbolArtOptions {50 size: number;51 /** Tier drives frame richness. */52 tier: "low" | "mid" | "high" | "premium" | "special";53 kind: string;54}5556export function drawSymbol(style: SymbolStyle, opts: SymbolArtOptions): Container {57 const { size } = opts;58 const c = new Container();59 const main = hex(style.color);60 const accent = style.accent ? hex(style.accent) : lighten(style.color, 0.35);61 const glow = style.glow ?? 0.3;62 const r = size * 0.36; // icon radius63 const g = new Graphics();6465 // Tile plate.66 const plate = new Graphics();67 const pad = size * 0.06;68 plate.roundRect(-size / 2 + pad, -size / 2 + pad, size - pad * 2, size - pad * 2, size * 0.16).fill({ color: 0x0b0d14, alpha: 0.72 });69 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) });70 c.addChild(plate);7172 // Glow halo.73 if (glow > 0) {74 const halo = new Graphics();75 halo.circle(0, 0, r * 1.25).fill({ color: main, alpha: 0.18 * glow });76 halo.circle(0, 0, r * 1.0).fill({ color: main, alpha: 0.16 * glow });77 c.addChild(halo);78 }7980 switch (style.shape) {81 case "gem": {82 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);83 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 });84 g.poly([0, -r * 0.1, r * 0.85, -r * 0.25, r * 0.55, r]).fill({ color: darken(style.color, 0.15) });85 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 });86 break;87 }88 case "hex": {89 polygon(g, 0, 0, r, 6).fill(main);90 polygon(g, 0, 0, r * 0.68, 6).fill({ color: darken(style.color, 0.25) });91 polygon(g, 0, 0, r * 0.42, 6).fill(accent);92 if (style.label) c.addChild(label(style.label, size * 0.26, 0x0b0d14));93 break;94 }95 case "star": {96 star(g, 0, 0, r, r * 0.45).fill(main);97 star(g, 0, 0, r * 0.55, r * 0.25).fill({ color: 0xffffff, alpha: 0.85 });98 break;99 }100 case "circle": {101 g.circle(0, 0, r).fill(main);102 g.circle(-r * 0.3, -r * 0.3, r * 0.35).fill({ color: 0xffffff, alpha: 0.35 });103 g.circle(0, 0, r).stroke({ color: accent, width: size * 0.02 });104 break;105 }106 case "diamond": {107 g.poly([0, -r, r, 0, 0, r, -r, 0]).fill(main);108 g.poly([0, -r, r, 0, 0, 0]).fill({ color: accent, alpha: 0.8 });109 g.poly([0, 0, 0, r, -r, 0]).fill({ color: darken(style.color, 0.2) });110 break;111 }112 case "shield": {113 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);114 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) });115 g.rect(-r * 0.08, -r * 0.5, r * 0.16, r * 0.9).fill(accent);116 g.rect(-r * 0.38, -r * 0.25, r * 0.76, r * 0.16).fill(accent);117 break;118 }119 case "bolt": {120 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);121 g.poly([r * 0.1, -r * 0.8, -r * 0.3, r * 0.0, r * 0.0, 0]).fill({ color: 0xffffff, alpha: 0.5 });122 break;123 }124 case "ring": {125 g.circle(0, 0, r).fill(main);126 g.circle(0, 0, r * 0.62).cut();127 g.circle(0, 0, r * 0.62).stroke({ color: accent, width: size * 0.02, alpha: 0.9 });128 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 });129 break;130 }131 case "chip": {132 g.roundRect(-r, -r * 0.75, r * 2, r * 1.5, r * 0.25).fill(main);133 g.roundRect(-r * 0.75, -r * 0.5, r * 1.5, r, r * 0.15).fill({ color: darken(style.color, 0.35) });134 for (let i = -2; i <= 2; i++) {135 g.rect(-r - r * 0.18, i * r * 0.28 - r * 0.06, r * 0.18, r * 0.12).fill(accent);136 g.rect(r, i * r * 0.28 - r * 0.06, r * 0.18, r * 0.12).fill(accent);137 }138 if (style.label) c.addChild(label(style.label, size * 0.22, accent));139 break;140 }141 case "crystal": {142 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);143 g.poly([0, -r, r * 0.5, -r * 0.3, 0, r * 0.1]).fill({ color: accent, alpha: 0.75 });144 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) });145 break;146 }147 case "letter": {148 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 });149 g.roundRect(-r * 0.95, -r * 0.95, r * 1.9, r * 1.9, r * 0.3).stroke({ color: main, width: size * 0.025 });150 c.addChild(label(style.label ?? "?", size * 0.4, main, true));151 break;152 }153 case "vault": {154 g.circle(0, 0, r).fill(darken(style.color, 0.4));155 g.circle(0, 0, r).stroke({ color: main, width: size * 0.03 });156 g.circle(0, 0, r * 0.7).stroke({ color: main, width: size * 0.02, alpha: 0.7 });157 for (let i = 0; i < 6; i++) {158 const a = (i * Math.PI) / 3;159 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 });160 }161 g.circle(0, 0, r * 0.22).fill(accent);162 break;163 }164 case "orb": {165 g.circle(0, 0, r).fill(main);166 g.circle(0, 0, r * 0.75).fill({ color: accent, alpha: 0.35 });167 g.circle(-r * 0.25, -r * 0.3, r * 0.25).fill({ color: 0xffffff, alpha: 0.5 });168 g.circle(0, 0, r).stroke({ color: accent, width: size * 0.02, alpha: 0.9 });169 if (style.label) c.addChild(label(style.label, size * 0.36, accent, true));170 break;171 }172 case "coin": {173 g.circle(0, 0, r).fill(main);174 g.circle(0, 0, r * 0.8).stroke({ color: darken(style.color, 0.25), width: size * 0.03 });175 g.circle(0, 0, r).stroke({ color: accent, width: size * 0.02 });176 c.addChild(label(style.label ?? "SC", size * 0.24, darken(style.color, 0.5), true));177 break;178 }179 case "skull": {180 g.circle(0, -r * 0.15, r * 0.8).fill(main);181 g.roundRect(-r * 0.45, r * 0.3, r * 0.9, r * 0.55, r * 0.15).fill(main);182 g.circle(-r * 0.3, -r * 0.2, r * 0.22).fill(0x0b0d14);183 g.circle(r * 0.3, -r * 0.2, r * 0.22).fill(0x0b0d14);184 g.poly([0, r * 0.05, r * 0.12, r * 0.3, -r * 0.12, r * 0.3]).fill(0x0b0d14);185 break;186 }187 case "flame": {188 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);189 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);190 break;191 }192 case "snow": {193 for (let i = 0; i < 3; i++) {194 const a = (i * Math.PI) / 3;195 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 });196 for (const s of [1, -1]) {197 const bx = Math.cos(a) * r * 0.6 * s;198 const by = Math.sin(a) * r * 0.6 * s;199 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 });200 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 });201 }202 }203 g.circle(0, 0, r * 0.14).fill(0xffffff);204 break;205 }206 case "eye": {207 g.ellipse(0, 0, r, r * 0.6).fill(main);208 g.circle(0, 0, r * 0.42).fill(darken(style.color, 0.5));209 g.circle(0, 0, r * 0.2).fill(accent);210 g.circle(r * 0.12, -r * 0.14, r * 0.07).fill(0xffffff);211 break;212 }213 case "moon": {214 g.circle(0, 0, r).fill(main);215 g.circle(r * 0.45, -r * 0.2, r * 0.78).fill({ color: 0x0b0d14, alpha: 0.85 });216 g.circle(-r * 0.4, r * 0.3, r * 0.12).fill({ color: darken(style.color, 0.2) });217 break;218 }219 case "leaf": {220 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);221 g.moveTo(0, r * 0.9).lineTo(0, -r * 0.8).stroke({ color: accent, width: size * 0.02 });222 break;223 }224 case "atom": {225 g.circle(0, 0, r * 0.2).fill(accent);226 for (let i = 0; i < 3; i++) {227 const e = new Graphics();228 e.ellipse(0, 0, r, r * 0.38).stroke({ color: main, width: size * 0.025 });229 e.rotation = (i * Math.PI) / 3;230 c.addChild(e);231 }232 break;233 }234 case "crown": {235 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);236 g.rect(-r, r * 0.4, r * 2, r * 0.25).fill(darken(style.color, 0.3));237 g.circle(0, -r * 0.95, r * 0.12).fill(accent);238 g.circle(-r, -r * 0.4, r * 0.1).fill(accent);239 g.circle(r, -r * 0.4, r * 0.1).fill(accent);240 break;241 }242 case "wave": {243 for (let k = -1; k <= 1; k++) {244 const y = k * r * 0.5;245 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 });246 }247 break;248 }249 case "cube": {250 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);251 g.poly([0, -r, r * 0.87, -r * 0.5, 0, 0, -r * 0.87, -r * 0.5]).fill(accent);252 g.poly([0, 0, r * 0.87, -r * 0.5, r * 0.87, r * 0.5, 0, r]).fill(darken(style.color, 0.3));253 break;254 }255 }256 c.addChild(g);257 return c;258}259260function label(text: string, fontSize: number, color: number, bold = true): Text {261 const t = new Text({262 text,263 style: new TextStyle({ fontFamily: "Geist, Inter, system-ui, sans-serif", fontSize, fontWeight: bold ? "800" : "600", fill: color, letterSpacing: 1 }),264 });265 t.anchor.set(0.5);266 return t;267}268269export { hex as hexColor, lighten, darken };270