botWebWars/frontend/src/utils/pixelAvatars.tsx

549 lines
18 KiB
TypeScript
Raw Normal View History

2026-09-09 20:41:06 +00:00
import React from 'react';
import type { Player } from '../types';
export type PieceType = 'knight' | 'warrior' | 'wizard' | 'gravestone';
2026-09-09 20:41:06 +00:00
// Hex color parser and manipulator
function parseHex(hex: string): [number, number, number] {
let c = hex.replace('#', '').trim();
if (c.length === 3) {
c = c[0] + c[0] + c[1] + c[1] + c[2] + c[2];
}
const num = parseInt(c, 16);
if (isNaN(num)) {
return [56, 189, 248]; // default sky blue
}
return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
}
function rgbToHex(r: number, g: number, b: number): string {
const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v)));
return '#' + [clamp(r), clamp(g), clamp(b)].map((x) => x.toString(16).padStart(2, '0')).join('');
}
function adjustColor(hex: string, percent: number): string {
const [r, g, b] = parseHex(hex);
if (percent > 0) {
return rgbToHex(
r + (255 - r) * percent,
g + (255 - g) * percent,
b + (255 - b) * percent
);
} else {
const factor = 1 + percent;
return rgbToHex(r * factor, g * factor, b * factor);
}
}
// 16x16 Pixel Sprite Matrices
// Characters:
// . : transparent
// _ : miniature drop shadow
// K : dark iron armor outline (#0f172a)
// C : faction color main
// L : faction color light (+35%)
// D : faction color dark (-30%)
// M : polished plate steel (#94a3b8)
// m : steel highlight (#e2e8f0)
// S : steel shadow (#475569)
// G : gold main (#eab308)
// g : gold highlight (#fef08a)
// d : gold shadow (#a16207)
// W : white shine/glint (#ffffff)
// H : brown wood/leather haft (#78350f)
// B : miniature pedestal top (#334155)
// b : miniature pedestal rim (#1e293b)
// R : ruby gem or glowing battle eye (#ef4444)
const KNIGHT_SPRITE: string[] = [
'......LCD.......', // Row 0: Plume crest tip in faction color
'.....LCCD.......', // Row 1: Plume feather body
'....KmMSK.......', // Row 2: Steel knight helm apex
'...KmMSSSK......', // Row 3: Helm brow
'...KMKWKSK...mW.', // Row 4: Eye slit gleam + Sword tip
'...KSKKSSK...Mm.', // Row 5: Lower helm + Sword blade
'.LCKMmMSKK...Mm.', // Row 6: Paired pauldron + breastplate + blade
'LCCKmSSKGGGGGMm.', // Row 7: Shield top in color + sword crossguard
'LCCKMSKK.KHK....', // Row 8: Shield face + sword hilt
'DCDKMSKK.KGK....', // Row 9: Shield face + golden pommel
'.DDK.SS.K.......', // Row 10: Shield tip + armored legs
'..K..SS..K......', // Row 11: Iron sabatons
'...KKKKKKKKK....', // Row 12: Pedestal top bevel
'..KBBBBBBBBBBK..', // Row 13: Pedestal stone base
'.KbbbbbbbbbbbbK.', // Row 14: Pedestal stone bevel rim
'..____________..', // Row 15: Miniature drop shadow
];
const LEADER_KNIGHT_SPRITE: string[] = [
'....g.g.g.......', // Row 0: 3-point Golden Royal Crown
'....GgGgG.......', // Row 1: Crown band with jewels
'....KGRGK.......', // Row 2: Crown base with Ruby gem
'...KmMSSSK......', // Row 3: Knight visor brow
'...KMKWKSK...mW.', // Row 4: Eye slit gleam + Sword tip
'...KSKKSSK...Mm.', // Row 5: Lower helm + Sword blade
'.GGKMmMSKK...Mm.', // Row 6: Golden cape clasps + breastplate + blade
'GLCKmSSKGGGGGMm.', // Row 7: Gold-trimmed Shield + sword crossguard
'GCCKMSKK.KHK....', // Row 8: Shield in faction color + sword hilt
'GDDKMSKK.KGK....', // Row 9: Shield in faction color + gold pommel
'.GGK.SS.K.......', // Row 10: Golden shield tip + armored legs
'..K..SS..K......', // Row 11: Iron sabatons
'...KGGGGGGGK....', // Row 12: Golden pedestal top
'..KBBBBBBBBBBK..', // Row 13: Pedestal stone base
'.KGGGGGGGGGGGGK.', // Row 14: Golden pedestal rim
'..____________..', // Row 15: Miniature drop shadow
];
const WARRIOR_SPRITE: string[] = [
'.mW..........Wm.', // Row 0: Curved battle horns
'..Mm...KK...mM..', // Row 1: Horn bodies + helmet crest
'...MKKmMSKKM....', // Row 2: Horn bases on Spiked Iron Helm
'.Wm.KMMSSK.mW...', // Row 3: Dual battle-axe blades + brow
'WmM.KKRRKK.MmW..', // Row 4: Axe blades + Fierce red eyes
'WMMMKSSSKMMMW...', // Row 5: Full axe blades + lower iron plate
'.KMMKMmMSKMMK...', // Row 6: Axe blade curves + iron breastplate
'..KKKKHHKKKK....', // Row 7: Axe haft collar + spiked pauldrons
'...KLCCCCDK.....', // Row 8: Warrior war-tunic in faction color
'...KCCCCCCK.....', // Row 9: Faction color tunic
'...KGKKKKGK.....', // Row 10: Spiked warrior war belt with gold rivets
'...KHS..SHK.....', // Row 11: Leather greaves & combat boots
'...KKKKKKKKK....', // Row 12: Pedestal top bevel
'..KBBBBBBBBBBK..', // Row 13: Pedestal stone base
'.KbbbbbbbbbbbbK.', // Row 14: Pedestal stone bevel rim
'..____________..', // Row 15: Miniature drop shadow
];
const LEADER_WARRIOR_SPRITE: string[] = [
'.g.g.g....g.g.g.', // Row 0: Golden crown horn tips
'..GgG..KK..GgG..', // Row 1: Golden horn bodies
'...GKKmMSKKG....', // Row 2: Golden horn bases on helmet
'.Wm.KMMSSK.mW...', // Row 3: Battle-axe blades + brow
'WmM.KKRRKK.MmW..', // Row 4: Axe blades + Fierce red eyes
'WMMMKSSSKMMMW...', // Row 5: Axe blades + lower helm
'.KMMKMmMSKMMK...', // Row 6: Axe blade curves + breastplate
'..KKKKHHKKKK....', // Row 7: Pauldrons + haft collar
'...KLCCCCDK.....', // Row 8: Tunic in faction color
'...KCCCCCCK.....', // Row 9: Faction color tunic
'...GGGGGGGG.....', // Row 10: Golden warrior war belt
'...KHS..SHK.....', // Row 11: Leather boots
'...KGGGGGGGK....', // Row 12: Golden pedestal top
'..KBBBBBBBBBBK..', // Row 13: Pedestal stone base
'.KGGGGGGGGGGGGK.', // Row 14: Golden pedestal rim
'..____________..', // Row 15: Miniature drop shadow
];
2026-09-09 22:11:54 +00:00
const WIZARD_SPRITE: string[] = [
'......CC........', // Row 0: Pointed wizard hat tip
'.....LCCD.......', // Row 1: Hat cone
'....LLCCDD......', // Row 2: Hat cone body
'...GLLCCDDG.gW..', // Row 3: Golden hat buckle + Staff crystal shine
'..KCCCCCCCCK.Gg.', // Row 4: Wide hat brim + Staff orb
'...KWWWWWWK..KH.', // Row 5: Face & white flowing beard + wood staff
'...KWBRBWK...KH.', // Row 6: Glowing mystic eyes + beard + staff
'.LCKWWWWWWK..KH.', // Row 7: Robe shoulders + beard + staff
'LCCKLLCCDDG..KH.', // Row 8: Robe body with golden clasp + staff
'DCDKLLCCDDK..KH.', // Row 9: Flowing robe + staff
'.DDKLLCCDDK..KH.', // Row 10: Robe lower + staff
'..K.KBBBBK.K.KH.', // Row 11: Mystic boots + staff base
'...KKKKKKKKK....', // Row 12: Pedestal top bevel
'..KBBBBBBBBBBK..', // Row 13: Pedestal stone base
'.KCCCCCCCCCCCCK.', // Row 14: Pedestal rim in robe color
'..____________..', // Row 15: Miniature drop shadow
];
const GRAVESTONE_SPRITE: string[] = [
'.....KKKKKK.....', // Row 0: Arched stone tombstone top outline
'...KKmmmmMMKK...', // Row 1: Stone bevel
'..KmmmMMMMMSSK..', // Row 2: Stone face with highlight and shadow
'..KmMMMMMMMSSK..', // Row 3: Stone face
'..KmMMMKSMMMSSK.', // Row 4: Carved cross top
'..KmMKKKKKKMSSK.', // Row 5: Carved cross bar
'..KmMMMKSMMMSSK.', // Row 6: Carved cross vertical stem
'..KmMMMKSMMMSSK.', // Row 7: Carved cross vertical stem
'..KmMMMMMMMSSK..', // Row 8: Stone slab
'..KmK.K.K.KMSSK.', // Row 9: Carved epitaph "R I P"
'..KmMMMMMMMSSK..', // Row 10: Lower stone
'.KKKKKKKKKKKKKK.', // Row 11: Base stone top
'.KBBBBBBBBBBBBK.', // Row 12: Stone pedestal base
'KHHHHHHKLDKKHHHK', // Row 13: Dirt mound with faction flower
'KhhhhhhhhhhhhhhK', // Row 14: Dark earth base
'..____________..', // Row 15: Drop shadow
];
// Death status helper
export function isPlayerDead(player: { is_alive?: boolean; health?: number }): boolean {
if (player.is_alive === false) return true;
if (player.health !== undefined && player.health <= 0) return true;
return false;
}
2026-09-09 20:41:06 +00:00
// Palette generation
function getPalette(color: string): Record<string, string> {
const cMain = color.trim().startsWith('#') ? color.trim() : `#${color.trim()}`;
const cLight = adjustColor(cMain, 0.40);
const cDark = adjustColor(cMain, -0.32);
return {
'.': '', // transparent
'_': 'rgba(0, 0, 0, 0.45)', // drop shadow
'K': '#0f172a', // dark outline
'C': cMain,
'L': cLight,
'D': cDark,
'M': '#94a3b8', // plate steel
'm': '#e2e8f0', // steel highlight
'S': '#475569', // steel shadow
'G': '#eab308', // gold main
'g': '#fef08a', // gold light
'd': '#a16207', // gold dark
'W': '#ffffff', // white glint
'H': '#78350f', // wood / leather
'h': '#451a03', // wood dark
'B': '#334155', // pedestal stone
'b': '#1e293b', // pedestal rim
'R': '#ef4444', // ruby / battle eyes
};
}
// In-memory cache for pre-rendered 16x16 canvases and data URLs
const spriteCanvasCache = new Map<string, HTMLCanvasElement>();
const spriteDataUrlCache = new Map<string, string>();
export function getPlayerPieceType(player: {
name: string;
id?: string;
piece_type?: string;
is_party_leader?: boolean;
party_id?: string | null;
is_alive?: boolean;
health?: number;
2026-09-09 20:41:06 +00:00
}): PieceType {
// Fallen / deceased players are represented by gravestones
if (isPlayerDead(player)) {
return 'gravestone';
}
2026-09-09 20:41:06 +00:00
// Party leaders are always Knights commanding the squad
if (player.is_party_leader) {
return 'knight';
}
// Party squad followers are loyal Warriors
if (player.party_id && !player.is_party_leader) {
return 'warrior';
}
// Explicit piece type if present
if (player.piece_type === 'warrior' || player.piece_type === 'knight') {
return player.piece_type;
}
// Check name indicators
const lower = player.name.toLowerCase();
if (
lower.includes('knight') ||
lower.includes('tank') ||
lower.includes('titan') ||
lower.includes('paladin') ||
lower.includes('gear') ||
lower.includes('iron') ||
lower.includes('guard')
) {
return 'knight';
}
if (
lower.includes('warrior') ||
lower.includes('viper') ||
lower.includes('striker') ||
lower.includes('scout') ||
lower.includes('ranger') ||
lower.includes('axe') ||
lower.includes('blade')
) {
return 'warrior';
}
// Deterministic stable hash for variety
let hash = 0;
const str = player.id || player.name;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash) % 2 === 0 ? 'knight' : 'warrior';
}
export function getSpriteCanvas(
pieceType: PieceType,
color: string,
isLeader: boolean = false
): HTMLCanvasElement {
const normColor = (color || '#38bdf8').toLowerCase();
const cacheKey = `${pieceType}_${normColor}_${isLeader ? '1' : '0'}`;
const cached = spriteCanvasCache.get(cacheKey);
if (cached) {
return cached;
}
const canvas = document.createElement('canvas');
canvas.width = 16;
canvas.height = 16;
const ctx = canvas.getContext('2d');
if (ctx) {
let spriteMatrix: string[];
if (pieceType === 'knight') {
spriteMatrix = isLeader ? LEADER_KNIGHT_SPRITE : KNIGHT_SPRITE;
2026-09-09 22:11:54 +00:00
} else if (pieceType === 'wizard') {
spriteMatrix = WIZARD_SPRITE;
} else if (pieceType === 'gravestone') {
spriteMatrix = GRAVESTONE_SPRITE;
2026-09-09 20:41:06 +00:00
} else {
spriteMatrix = isLeader ? LEADER_WARRIOR_SPRITE : WARRIOR_SPRITE;
}
const palette = getPalette(normColor);
for (let y = 0; y < 16; y++) {
const row = spriteMatrix[y] || '................';
for (let x = 0; x < 16; x++) {
const char = row[x] || '.';
const col = palette[char];
if (col) {
ctx.fillStyle = col;
ctx.fillRect(x, y, 1, 1);
}
}
}
}
spriteCanvasCache.set(cacheKey, canvas);
return canvas;
}
export function getSpriteDataUrl(
pieceType: PieceType,
color: string,
isLeader: boolean = false
): string {
const normColor = (color || '#38bdf8').toLowerCase();
const cacheKey = `${pieceType}_${normColor}_${isLeader ? '1' : '0'}`;
const cached = spriteDataUrlCache.get(cacheKey);
if (cached) {
return cached;
}
const canvas = getSpriteCanvas(pieceType, normColor, isLeader);
const dataUrl = canvas.toDataURL('image/png');
spriteDataUrlCache.set(cacheKey, dataUrl);
return dataUrl;
}
// Canvas Drawing Helper for BoardCanvas
export function drawPlayerPiece(
ctx: CanvasRenderingContext2D,
player: Player,
px: number,
py: number,
cellSize: number,
isSelected: boolean,
isCurrentTurn: boolean
): void {
const dead = isPlayerDead(player);
const pieceType = dead ? 'gravestone' : getPlayerPieceType(player);
const isLeader = !dead && player.is_party_leader;
2026-09-09 20:41:06 +00:00
const spriteCanvas = getSpriteCanvas(pieceType, player.color, isLeader);
// Scaled miniature size: board game miniature looks best at ~1.35x cellSize
const spriteSize = Math.max(cellSize * 1.35, 14);
const destX = Math.round(px - spriteSize / 2);
// Center the pedestal base right on (px, py)
const destY = Math.round(py - spriteSize * 0.62);
ctx.save();
// Active turn indicator glowing ring around the pedestal (only if alive)
if (isCurrentTurn && !dead) {
2026-09-09 20:41:06 +00:00
ctx.save();
ctx.beginPath();
ctx.ellipse(px, py + spriteSize * 0.18, spriteSize * 0.55, spriteSize * 0.28, 0, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(245, 158, 11, 0.28)';
ctx.fill();
ctx.strokeStyle = '#f59e0b';
ctx.lineWidth = 1.8;
ctx.shadowColor = '#f59e0b';
ctx.shadowBlur = 8;
ctx.stroke();
ctx.restore();
}
// Selected player dashed ring around the pedestal
if (isSelected) {
ctx.save();
ctx.beginPath();
ctx.ellipse(px, py + spriteSize * 0.18, spriteSize * 0.62, spriteSize * 0.32, 0, 0, Math.PI * 2);
ctx.strokeStyle = dead ? '#94a3b8' : '#38bdf8';
2026-09-09 20:41:06 +00:00
ctx.lineWidth = 2;
ctx.setLineDash([3, 3]);
ctx.shadowColor = dead ? '#64748b' : '#38bdf8';
2026-09-09 20:41:06 +00:00
ctx.shadowBlur = 6;
ctx.stroke();
ctx.restore();
}
// Draw the crisp pixelated figurine or gravestone
2026-09-09 20:41:06 +00:00
ctx.imageSmoothingEnabled = false;
if (dead) {
ctx.globalAlpha = 0.85;
}
2026-09-09 20:41:06 +00:00
ctx.drawImage(spriteCanvas, destX, destY, spriteSize, spriteSize);
// Crown symbol above party leader (only alive)
2026-09-09 20:41:06 +00:00
if (isLeader) {
ctx.save();
ctx.fillStyle = '#fbbf24';
ctx.font = `${Math.max(spriteSize * 0.45, 10)}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('👑', px, destY - 4);
ctx.restore();
}
// Player Name and Strength Badge (Title Bar)
ctx.save();
const isHighlighted = (isCurrentTurn && !dead) || isSelected;
ctx.globalAlpha = dead ? (isSelected ? 0.9 : 0.65) : (isHighlighted ? 1.0 : 0.5);
2026-09-09 20:41:06 +00:00
ctx.font = `bold ${Math.max(10, Math.min(12, cellSize * 0.42))}px Inter, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const roleIcon = dead ? '🪦' : isLeader ? '👑' : pieceType === 'knight' ? '⚔️' : '🪓';
const text = dead
? `🪦 ${player.name} [DEAD]`
: `${roleIcon} ${player.name} [⚡${player.strength.toFixed(1)}${player.health !== undefined ? ` ❤️${player.health}` : ''}]`;
2026-09-09 20:41:06 +00:00
const textMetrics = ctx.measureText(text);
const bgWidth = textMetrics.width + 12;
const bgHeight = 16;
const labelY = isLeader ? destY - 14 : destY - 8;
ctx.fillStyle = dead ? 'rgba(30, 41, 59, 0.92)' : 'rgba(15, 23, 42, 0.92)';
ctx.strokeStyle = dead ? '#64748b' : isLeader ? '#fbbf24' : isCurrentTurn ? '#f59e0b' : player.color;
2026-09-09 20:41:06 +00:00
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(px - bgWidth / 2, labelY - 12, bgWidth, bgHeight, 4);
ctx.fill();
ctx.stroke();
ctx.fillStyle = dead ? '#94a3b8' : isLeader ? '#fef08a' : isCurrentTurn ? '#fbbf24' : '#f8fafc';
2026-09-09 20:41:06 +00:00
ctx.fillText(text, px, labelY - 4);
ctx.restore();
ctx.restore();
}
2026-09-09 22:11:54 +00:00
// Canvas Drawing Helper for the Grand Wizard NPC
export function drawWizardPiece(
ctx: CanvasRenderingContext2D,
wizard: { x: number; y: number; name: string; strength: number; color?: string },
px: number,
py: number,
cellSize: number
): void {
const color = wizard.color || '#A855F7';
const spriteCanvas = getSpriteCanvas('wizard', color, false);
const spriteSize = Math.max(cellSize * 1.45, 16);
const destX = Math.round(px - spriteSize / 2);
const destY = Math.round(py - spriteSize * 0.62);
ctx.save();
// Glowing mystic arcane aura
ctx.save();
ctx.beginPath();
ctx.ellipse(px, py + spriteSize * 0.18, spriteSize * 0.65, spriteSize * 0.32, 0, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(168, 85, 247, 0.25)';
ctx.fill();
ctx.strokeStyle = '#c084fc';
ctx.lineWidth = 2;
ctx.shadowColor = '#c084fc';
ctx.shadowBlur = 12;
ctx.stroke();
ctx.restore();
// Draw the pixelated Wizard
ctx.imageSmoothingEnabled = false;
ctx.drawImage(spriteCanvas, destX, destY, spriteSize, spriteSize);
// Floating magic sparkle above wizard hat
ctx.save();
ctx.fillStyle = '#fef08a';
ctx.font = `${Math.max(spriteSize * 0.4, 10)}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('✨', px, destY - 6);
ctx.restore();
// Wizard Name & Strength Badge
ctx.save();
ctx.font = `bold ${Math.max(10, Math.min(12, cellSize * 0.42))}px Inter, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const text = `🧙 ${wizard.name} [⚡${wizard.strength.toFixed(1)}]`;
const textMetrics = ctx.measureText(text);
const bgWidth = textMetrics.width + 12;
const bgHeight = 16;
const labelY = destY - 16;
ctx.fillStyle = 'rgba(15, 23, 42, 0.94)';
ctx.strokeStyle = '#c084fc';
ctx.lineWidth = 1.2;
ctx.shadowColor = '#c084fc';
ctx.shadowBlur = 8;
ctx.beginPath();
ctx.roundRect(px - bgWidth / 2, labelY - 12, bgWidth, bgHeight, 4);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#e9d5ff';
ctx.fillText(text, px, labelY - 4);
ctx.restore();
ctx.restore();
}
2026-09-09 20:41:06 +00:00
// React Component for displaying pixel avatar in UI
interface PixelAvatarProps {
pieceType: PieceType;
color: string;
isLeader?: boolean;
size?: number;
className?: string;
title?: string;
}
export const PixelAvatar: React.FC<PixelAvatarProps> = ({
pieceType,
color,
isLeader = false,
size = 32,
className = '',
title,
}) => {
const actualLeader = pieceType === 'gravestone' ? false : isLeader;
const dataUrl = getSpriteDataUrl(pieceType, color, actualLeader);
const label = pieceType === 'gravestone' ? 'Gravestone' : `${actualLeader ? 'Leader ' : ''}${pieceType}`;
2026-09-09 20:41:06 +00:00
return (
<img
src={dataUrl}
alt={label}
title={title || `${label} (${color})`}
2026-09-09 20:41:06 +00:00
className={`inline-block ${className}`}
style={{
width: size,
height: size,
imageRendering: 'pixelated',
}}
/>
);
};