Spaces:
Runtime error
Runtime error
| 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<string, Promise<Texture>>() | |
| function loadSymbolTexture(sym: string): Promise<Texture> { | |
| 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<string, Container>() | |
| 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<string>() | |
| 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 | |
| } | |
| } | |