caro5 / client /src /game /SymbolRenderer.ts
Pedro de Carvalho
Add dragon icons
0a5fd27
Raw
History Blame Contribute Delete
7.41 kB
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
}
}