2026-09-09 20:41:06 +00:00
|
|
|
import React from 'react';
|
|
|
|
|
import type { Player } from '../types';
|
|
|
|
|
|
2026-09-10 00:32:22 +00:00
|
|
|
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
|
|
|
|
|
];
|
|
|
|
|
|
2026-09-10 00:32:22 +00:00
|
|
|
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;
|
2026-09-10 00:32:22 +00:00
|
|
|
is_alive?: boolean;
|
|
|
|
|
health?: number;
|
2026-09-09 20:41:06 +00:00
|
|
|
}): PieceType {
|
2026-09-10 00:32:22 +00:00
|
|
|
// 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;
|
2026-09-10 00:32:22 +00:00
|
|
|
} 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 {
|
2026-09-10 00:32:22 +00:00
|
|
|
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();
|
|
|
|
|
|
2026-09-10 00:32:22 +00:00
|
|
|
// 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);
|
2026-09-10 00:32:22 +00:00
|
|
|
ctx.strokeStyle = dead ? '#94a3b8' : '#38bdf8';
|
2026-09-09 20:41:06 +00:00
|
|
|
ctx.lineWidth = 2;
|
|
|
|
|
ctx.setLineDash([3, 3]);
|
2026-09-10 00:32:22 +00:00
|
|
|
ctx.shadowColor = dead ? '#64748b' : '#38bdf8';
|
2026-09-09 20:41:06 +00:00
|
|
|
ctx.shadowBlur = 6;
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
ctx.restore();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 00:32:22 +00:00
|
|
|
// Draw the crisp pixelated figurine or gravestone
|
2026-09-09 20:41:06 +00:00
|
|
|
ctx.imageSmoothingEnabled = false;
|
2026-09-10 00:32:22 +00:00
|
|
|
if (dead) {
|
|
|
|
|
ctx.globalAlpha = 0.85;
|
|
|
|
|
}
|
2026-09-09 20:41:06 +00:00
|
|
|
ctx.drawImage(spriteCanvas, destX, destY, spriteSize, spriteSize);
|
|
|
|
|
|
2026-09-10 00:32:22 +00:00
|
|
|
// 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();
|
2026-09-10 00:32:22 +00:00
|
|
|
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';
|
|
|
|
|
|
2026-09-10 00:32:22 +00:00
|
|
|
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;
|
|
|
|
|
|
2026-09-10 00:32:22 +00:00
|
|
|
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();
|
|
|
|
|
|
2026-09-10 00:32:22 +00:00
|
|
|
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,
|
|
|
|
|
}) => {
|
2026-09-10 00:32:22 +00:00
|
|
|
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}
|
2026-09-10 00:32:22 +00:00
|
|
|
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',
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
};
|