414 lines
13 KiB
TypeScript
414 lines
13 KiB
TypeScript
|
|
import React from 'react';
|
||
|
|
import type { Player } from '../types';
|
||
|
|
|
||
|
|
export type PieceType = 'knight' | 'warrior';
|
||
|
|
|
||
|
|
// 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
|
||
|
|
];
|
||
|
|
|
||
|
|
// 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;
|
||
|
|
}): PieceType {
|
||
|
|
// 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;
|
||
|
|
} 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 pieceType = getPlayerPieceType(player);
|
||
|
|
const isLeader = player.is_party_leader;
|
||
|
|
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
|
||
|
|
if (isCurrentTurn) {
|
||
|
|
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 = '#38bdf8';
|
||
|
|
ctx.lineWidth = 2;
|
||
|
|
ctx.setLineDash([3, 3]);
|
||
|
|
ctx.shadowColor = '#38bdf8';
|
||
|
|
ctx.shadowBlur = 6;
|
||
|
|
ctx.stroke();
|
||
|
|
ctx.restore();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Draw the crisp pixelated Knight or Warrior figurine
|
||
|
|
ctx.imageSmoothingEnabled = false;
|
||
|
|
ctx.drawImage(spriteCanvas, destX, destY, spriteSize, spriteSize);
|
||
|
|
|
||
|
|
// Crown symbol above party leader
|
||
|
|
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 || isSelected;
|
||
|
|
ctx.globalAlpha = isHighlighted ? 1.0 : 0.5;
|
||
|
|
|
||
|
|
ctx.font = `bold ${Math.max(10, Math.min(12, cellSize * 0.42))}px Inter, sans-serif`;
|
||
|
|
ctx.textAlign = 'center';
|
||
|
|
ctx.textBaseline = 'middle';
|
||
|
|
|
||
|
|
const roleIcon = isLeader ? '👑' : pieceType === 'knight' ? '⚔️' : '🪓';
|
||
|
|
const text = `${roleIcon} ${player.name} [⚡${player.strength.toFixed(1)}]`;
|
||
|
|
const textMetrics = ctx.measureText(text);
|
||
|
|
const bgWidth = textMetrics.width + 12;
|
||
|
|
const bgHeight = 16;
|
||
|
|
const labelY = isLeader ? destY - 14 : destY - 8;
|
||
|
|
|
||
|
|
ctx.fillStyle = 'rgba(15, 23, 42, 0.92)';
|
||
|
|
ctx.strokeStyle = isLeader ? '#fbbf24' : isCurrentTurn ? '#f59e0b' : player.color;
|
||
|
|
ctx.lineWidth = 1;
|
||
|
|
ctx.beginPath();
|
||
|
|
ctx.roundRect(px - bgWidth / 2, labelY - 12, bgWidth, bgHeight, 4);
|
||
|
|
ctx.fill();
|
||
|
|
ctx.stroke();
|
||
|
|
|
||
|
|
ctx.fillStyle = isLeader ? '#fef08a' : isCurrentTurn ? '#fbbf24' : '#f8fafc';
|
||
|
|
ctx.fillText(text, px, labelY - 4);
|
||
|
|
ctx.restore();
|
||
|
|
|
||
|
|
ctx.restore();
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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 dataUrl = getSpriteDataUrl(pieceType, color, isLeader);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<img
|
||
|
|
src={dataUrl}
|
||
|
|
alt={`${isLeader ? 'Leader ' : ''}${pieceType}`}
|
||
|
|
title={title || `${isLeader ? 'Leader ' : ''}${pieceType} (${color})`}
|
||
|
|
className={`inline-block ${className}`}
|
||
|
|
style={{
|
||
|
|
width: size,
|
||
|
|
height: size,
|
||
|
|
imageRendering: 'pixelated',
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
};
|