import { Container, Graphics, Text, TextStyle, Sprite, Assets, Texture } from 'pixi.js' import { CELL_SIZE } from 'shared/board.ts' import { theme } from '../theme.ts' import type { Cell } from 'shared/types.ts' import { isImageSymbol, imageSymbolUrl } from '../binAssets' const symbolTextureCache = new Map>() function loadSymbolTexture(sym: string): Promise { let pending = symbolTextureCache.get(sym) if (!pending) { pending = Assets.load(imageSymbolUrl(sym)) symbolTextureCache.set(sym, pending) } return pending } export class SymbolRenderer { container: Container private symbols = new Map() customColors: number[] = [] drawTowers: boolean[] = [true, true] constructor() { this.container = new Container() this.container.sortableChildren = true } update( cells: [string, Cell][], _minCellX: number, _minCellY: number, _maxCellX: number, _maxCellY: number, ): Container[] { const keepKeys = new Set() const added: Container[] = [] for (const [key, cell] of cells) { keepKeys.add(key) if (this.symbols.has(key)) continue const [x, y] = key.split(':').map(Number) const s = this.createSymbol(cell, x, y) this.symbols.set(key, s) this.container.addChild(s) added.push(s) } let removed = 0 for (const [key, symbol] of this.symbols) { if (!keepKeys.has(key)) { this.container.removeChild(symbol) symbol.destroy({ children: true }) this.symbols.delete(key) removed++ } } return added } clear(): void { for (const [, symbol] of this.symbols) { this.container.removeChild(symbol) symbol.destroy({ children: true }) } this.symbols.clear() } getSymbol(key: string): Container | undefined { return this.symbols.get(key) } private isEmoji(sym: string): boolean { const code = sym.codePointAt(0) ?? 0 return sym.length > 1 || code > 0xFFFF } private adjustBrightness(color: number, factor: number): number { const r = Math.min(255, Math.max(0, Math.round(((color >> 16) & 0xff) * factor))) const g = Math.min(255, Math.max(0, Math.round(((color >> 8) & 0xff) * factor))) const b = Math.min(255, Math.max(0, Math.round((color & 0xff) * factor))) return (r << 16) | (g << 8) | b } private createSymbol(cell: Cell, gx: number, gy: number): Container { const playerColors = this.customColors.length >= 2 ? this.customColors : [theme.player1, theme.player2] const color = playerColors[Number(cell.playerId) % 2] ?? 0xffffff const sym = cell.symbol || (Number(cell.playerId) === 0 ? 'X' : 'O') const drawTower = this.drawTowers[Number(cell.playerId) % 2] !== false const lx = gx * CELL_SIZE + CELL_SIZE / 2 const ly = gy * CELL_SIZE + CELL_SIZE / 2 const r = CELL_SIZE * 0.38 // slightly larger to fill the grid beautifully const g = new Graphics() // 2 — Cylinder Side Wall (creates the "tower" extrusion with volumetric shading) const wallH = drawTower ? 4 : 0 // Shading calculations const darkColor = this.adjustBrightness(color, 0.7) const lightColor = this.adjustBrightness(color, 1.25) if (drawTower) { // 1 — Deep multi-layered 3D capsule drop shadow (proper cylinder shadow projection) const drawCapsuleShadow = (dx: number, dy: number, shadowR: number, alpha: number) => { g.circle(dx, dy - wallH, shadowR) g.circle(dx, dy, shadowR) g.rect(dx - shadowR, dy - wallH, shadowR * 2, wallH) g.fill({ color: 0x000000, alpha }) } // Draw outer ambient shadow layer (tighter, soft) drawCapsuleShadow(3, 4, r * 1.0, 0.22) // Draw middle cast shadow layer drawCapsuleShadow(2, 2.5, r * 0.95, 0.40) // Draw inner tight contact shadow (very dark, grounded) drawCapsuleShadow(1, 1, r * 0.9, 0.65) // Bottom cap of the cylinder g.circle(0, 0, r) g.fill({ color: darkColor }) // Mid connecting rectangle body g.rect(-r, -wallH, r * 2, wallH) g.fill({ color: darkColor }) // Specular highlight on the left side of the cylinder wall (simulating light from top-left) g.rect(-r, -wallH, r * 0.4, wallH) g.fill({ color: color, alpha: 0.15 }) // Diffuse shadow on the right side of the cylinder wall g.rect(r * 0.6, -wallH, r * 0.4, wallH) g.fill({ color: 0x000000, alpha: 0.22 }) // 3 — Cylinder Top Surface (lighter and polished) g.circle(0, -wallH, r) g.fill({ color: color }) // Top face highlight rim (glossy edge) g.circle(0, -wallH, r) g.stroke({ width: 1.5, color: lightColor, alpha: 0.6 }) // Inner decorative groove (tactile go-stone/chip details) g.circle(0, -wallH, r * 0.78) g.stroke({ width: 1.0, color: darkColor, alpha: 0.25 }) const isEmoji = this.isEmoji(sym) // Grounding contact shadow directly on the piece surface under the symbol (only for non-emojis) if (!isEmoji) { g.circle(0, -wallH + 0.5, r * 0.5) g.fill({ color: 0x000000, alpha: 0.15 }) } } const isEmoji = this.isEmoji(sym) const wrapper = new Container() wrapper.addChild(g) if (isImageSymbol(sym)) { const spriteSize = r * 1.7 const placeholder = new Sprite() placeholder.anchor.set(0.5) placeholder.width = spriteSize placeholder.height = spriteSize placeholder.position.set(0, -wallH) wrapper.addChild(placeholder) loadSymbolTexture(sym).then(tex => { if (placeholder.destroyed) return placeholder.texture = tex placeholder.width = spriteSize placeholder.height = spriteSize }).catch(() => {}) } else { // Dynamic High-Contrast Symbol Text let textFill: number if (drawTower) { const rComponent = (color >> 16) & 0xff const gComponent = (color >> 8) & 0xff const bComponent = color & 0xff const brightness = (rComponent * 299 + gComponent * 587 + bComponent * 114) / 1000 // If the top color is very bright, use a dark theme text color. Otherwise white text. textFill = brightness > 175 ? 0x1a1b2e : 0xffffff } else { // Just the icon: draw it directly in the player's color so it is recognizable textFill = color } const textSize = r * 1.25 const text = new Text({ text: sym, style: new TextStyle({ fontSize: isEmoji ? textSize * 1.05 : textSize, fill: textFill, fontFamily: 'system-ui, sans-serif', fontWeight: 'bold', dropShadow: { color: 0x000000, alpha: isEmoji ? 0.75 : 0.45, angle: Math.PI / 4, distance: isEmoji ? 2.0 : 1.2, blur: isEmoji ? 2.5 : 1.0, }, }), resolution: 4, }) // Center it visually using its local bounds to correct baseline offsets of emojis const bounds = text.getLocalBounds() text.pivot.set( bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 ) text.position.set(0, -wallH) wrapper.addChild(text) } wrapper.position.set(lx, ly) // Depth sorting — higher Y = closer to viewer wrapper.zIndex = ly return wrapper } }