feat: Add HTML export, perception, speed and improve Status tab layout

- Add HTML character sheet export function
- Add Perception calculation to Status tab with correct PF2e rules
- Add Speed display to Status tab
- Redesign AC/Speed to use Card components matching tab design
- Simplify Rest button to match card-based design pattern

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alexander Zielonka
2026-01-20 15:44:28 +01:00
parent 618de7b21d
commit 8eb5ef01de
2 changed files with 1042 additions and 96 deletions

View File

@@ -16,6 +16,9 @@ import {
Star,
Coins,
Moon,
Download,
Eye,
Footprints,
} from 'lucide-react';
import {
Button,
@@ -38,6 +41,7 @@ import { ActionsTab } from './actions-tab';
import { RestModal } from './rest-modal';
import { AlchemyTab } from './alchemy-tab';
import { useCharacterSocket } from '@/shared/hooks/use-character-socket';
import { downloadCharacterHTML } from '../utils/export-character-html';
import type { Character, CharacterItem, CharacterFeat, Campaign, RestResult, CharacterAlchemyState, CharacterFormula, CharacterPreparedItem } from '@/shared/types';
type TabType = 'status' | 'skills' | 'inventory' | 'feats' | 'spells' | 'alchemy' | 'actions';
@@ -600,49 +604,54 @@ export function CharacterSheetPage() {
const acData = calculateAC();
return (
<div className="space-y-6">
{/* HP & AC Row - 75/25 split */}
<div className="flex gap-3">
{/* HP Control - 75% */}
<div className="flex-[3]">
<HpControl
hpCurrent={character.hpCurrent}
hpMax={character.hpMax}
hpTemp={character.hpTemp}
onHpChange={handleHpChange}
/>
</div>
// Get speed from pathbuilder data
const pbSpeed = character.pathbuilderData as {
build?: { attributes?: { speed?: number } };
} | undefined;
const speed = pbSpeed?.build?.attributes?.speed || 25;
{/* AC Display - Shield Shape */}
<div className="flex-1 flex flex-col items-center justify-center gap-1">
<div className="relative">
{/* Shield SVG */}
<svg viewBox="0 0 80 96" className="w-16 h-20 sm:w-20 sm:h-24">
<path
d="M40 4 L76 16 L76 44 C76 68 56 88 40 94 C24 88 4 68 4 44 L4 16 Z"
className={`stroke-2 ${acData.hasProficiency ? 'fill-blue-500/20 stroke-blue-500/50' : 'fill-red-500/20 stroke-red-500/50'}`}
/>
</svg>
{/* AC Number centered on shield */}
<div className="absolute inset-0 flex flex-col items-center justify-center pt-1">
<span className={`text-[10px] font-medium ${acData.hasProficiency ? 'text-blue-400/70' : 'text-red-400/70'}`}>RK</span>
<span className={`text-2xl sm:text-3xl font-bold -mt-1 ${acData.hasProficiency ? 'text-blue-400' : 'text-red-400'}`}>{acData.total}</span>
return (
<div className="space-y-4">
{/* HP Row - Full width on mobile */}
<HpControl
hpCurrent={character.hpCurrent}
hpMax={character.hpMax}
hpTemp={character.hpTemp}
onHpChange={handleHpChange}
/>
{/* AC & Speed Row */}
<div className="grid grid-cols-2 gap-3">
{/* AC Card */}
<Card>
<CardContent className="p-4 flex flex-col items-center justify-center">
<div className="flex items-center gap-2 mb-2">
<Shield className={`h-4 w-4 ${acData.hasProficiency ? 'text-blue-400' : 'text-red-400'}`} />
<span className={`text-xs font-medium ${acData.hasProficiency ? 'text-blue-400' : 'text-red-400'}`}>Rüstungsklasse</span>
</div>
<div className="flex items-baseline gap-1">
<p className={`text-3xl font-bold ${acData.hasProficiency ? 'text-blue-400' : 'text-red-400'}`}>{acData.total}</p>
{acData.shieldBonus > 0 && (
<span className="text-xs font-semibold text-cyan-400 -mt-1">+{acData.shieldBonus}</span>
<span className="text-sm font-semibold text-cyan-400">+{acData.shieldBonus}</span>
)}
</div>
</div>
{/* Proficiency Info */}
<div className="text-center">
<p className={`text-[10px] font-medium ${acData.hasProficiency ? 'text-text-secondary' : 'text-red-400'}`}>
{acData.profName}
</p>
<p className={`text-[10px] ${acData.hasProficiency ? 'text-text-muted' : 'text-red-400/70'}`}>
<p className={`text-xs mt-1 ${acData.hasProficiency ? 'text-text-secondary' : 'text-red-400'}`}>
{acData.profRank}
</p>
</div>
</div>
</CardContent>
</Card>
{/* Speed Card */}
<Card>
<CardContent className="p-4 flex flex-col items-center justify-center">
<div className="flex items-center gap-2 mb-2">
<Footprints className="h-4 w-4 text-emerald-400" />
<span className="text-xs font-medium text-emerald-400">Geschwindigkeit</span>
</div>
<p className="text-3xl font-bold text-emerald-400">{speed}</p>
<p className="text-xs text-text-secondary mt-1">ft / Aktion</p>
</CardContent>
</Card>
</div>
{/* Abilities */}
@@ -670,77 +679,134 @@ export function CharacterSheetPage() {
})}
</div>
{/* Saving Throws */}
{/* Saving Throws & Perception */}
{(() => {
const getAbilityMod = (abilityType: 'STR' | 'DEX' | 'CON' | 'INT' | 'WIS' | 'CHA') => {
const ability = character.abilities.find(a => a.ability === abilityType);
return ability ? Math.floor((ability.score - 10) / 2) : 0;
};
// TODO: Get actual proficiency from Pathbuilder data when available
// For now, assume trained (+2) as baseline for all saves
const baseProficiency = 2;
const level = character.level;
// Get proficiency values from Pathbuilder data
const pb = character.pathbuilderData as {
build?: {
proficiencies?: Record<string, number>;
};
} | undefined;
const proficiencies = pb?.build?.proficiencies || {};
// Calculate save totals using actual proficiency data
// Proficiency values: 0 = untrained, 2 = trained, 4 = expert, 6 = master, 8 = legendary
const getSaveTotal = (saveName: string, abilityMod: number) => {
const prof = proficiencies[saveName] || 2; // Default to trained if not found
const levelBonus = prof > 0 ? level : 0;
return abilityMod + levelBonus + prof;
};
const getSaveProfName = (saveName: string) => {
const prof = proficiencies[saveName] || 2;
if (prof === 0) return 'Ungeübt';
if (prof === 2) return 'Geübt';
if (prof === 4) return 'Experte';
if (prof === 6) return 'Meister';
if (prof === 8) return 'Legendär';
return 'Geübt';
};
const saves = [
{
name: 'Zähigkeit',
shortName: 'ZÄH',
key: 'fortitude',
ability: 'CON' as const,
color: 'text-red-400',
bgColor: 'bg-red-500/20',
},
{
name: 'Reflex',
shortName: 'REF',
key: 'reflex',
ability: 'DEX' as const,
color: 'text-green-400',
bgColor: 'bg-green-500/20',
},
{
name: 'Willen',
shortName: 'WIL',
key: 'will',
ability: 'WIS' as const,
color: 'text-blue-400',
bgColor: 'bg-blue-500/20',
},
];
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Rettungswürfe
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-3">
{saves.map((save) => {
const abilityMod = getAbilityMod(save.ability);
const total = abilityMod + level + baseProficiency;
const bonusString = total >= 0 ? `+${total}` : `${total}`;
// Calculate Perception
// Perception = WIS mod + proficiency bonus + level (if trained+)
const perceptionSkill = character.skills.find(s => s.skillName === 'Perception');
const perceptionProf = perceptionSkill
? PROFICIENCY_BONUS[perceptionSkill.proficiency] || 0
: (proficiencies['perception'] || 2);
const perceptionProfName = perceptionSkill
? PROFICIENCY_NAMES[perceptionSkill.proficiency] || 'Geübt'
: getSaveProfName('perception');
const wisdomMod = getAbilityMod('WIS');
const perceptionLevelBonus = perceptionProf > 0 ? level : 0;
const perceptionTotal = wisdomMod + perceptionLevelBonus + perceptionProf;
const perceptionString = perceptionTotal >= 0 ? `+${perceptionTotal}` : `${perceptionTotal}`;
return (
<div
key={save.name}
className={`text-center p-3 rounded-lg ${save.bgColor}`}
>
<p className={`text-xs font-medium mb-1 ${save.color}`}>
{save.name}
</p>
<p className={`text-2xl font-bold ${save.color}`}>
{bonusString}
</p>
<p className="text-xs text-text-secondary">
{ABILITY_NAMES[save.ability].slice(0, 3).toUpperCase()}
</p>
</div>
);
})}
</div>
</CardContent>
</Card>
return (
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
{/* Perception */}
<div className="sm:col-span-1">
<Card className="h-full">
<CardContent className="p-4 flex flex-col items-center justify-center h-full">
<div className="flex items-center gap-2 mb-2">
<Eye className="h-4 w-4 text-amber-400" />
<span className="text-xs font-medium text-amber-400">Wahrnehmung</span>
</div>
<p className="text-3xl font-bold text-amber-400">{perceptionString}</p>
<p className="text-xs text-text-secondary mt-1">{perceptionProfName}</p>
</CardContent>
</Card>
</div>
{/* Saving Throws */}
<div className="sm:col-span-3">
<Card className="h-full">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Rettungswürfe
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-3">
{saves.map((save) => {
const abilityMod = getAbilityMod(save.ability);
const total = getSaveTotal(save.key, abilityMod);
const bonusString = total >= 0 ? `+${total}` : `${total}`;
const profName = getSaveProfName(save.key);
return (
<div
key={save.name}
className={`text-center p-3 rounded-lg ${save.bgColor}`}
>
<p className={`text-xs font-medium mb-1 ${save.color}`}>
{save.name}
</p>
<p className={`text-2xl font-bold ${save.color}`}>
{bonusString}
</p>
<p className="text-xs text-text-secondary">
{profName}
</p>
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
</div>
);
})()}
@@ -779,14 +845,17 @@ export function CharacterSheetPage() {
</div>
{/* Rest Button */}
<Button
variant="outline"
className="w-full mt-4 border-indigo-500/30 text-indigo-400 hover:bg-indigo-500/10 hover:text-indigo-300"
onClick={() => setShowRestModal(true)}
>
<Moon className="h-4 w-4 mr-2" />
Rasten (8 Stunden)
</Button>
<Card className="hover:border-indigo-500/50 transition-colors cursor-pointer" onClick={() => setShowRestModal(true)}>
<CardContent className="p-4 flex items-center justify-center gap-3">
<div className="w-10 h-10 rounded-full bg-indigo-500/20 flex items-center justify-center">
<Moon className="h-5 w-5 text-indigo-400" />
</div>
<div className="text-left">
<p className="font-semibold text-indigo-400">Rasten</p>
<p className="text-xs text-text-secondary">8 Stunden Ruhe + Tägliche Vorbereitung</p>
</div>
</CardContent>
</Card>
</div>
);
};
@@ -1502,16 +1571,26 @@ export function CharacterSheetPage() {
</div>
</div>
</div>
{isOwner && (
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setShowEditCharacter(true)}>
<Edit2 className="h-4 w-4" />
</Button>
<Button variant="destructive" size="sm" onClick={handleDelete}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => downloadCharacterHTML(character)}
title="Als HTML exportieren"
>
<Download className="h-4 w-4" />
</Button>
{isOwner && (
<>
<Button variant="outline" size="sm" onClick={() => setShowEditCharacter(true)}>
<Edit2 className="h-4 w-4" />
</Button>
<Button variant="destructive" size="sm" onClick={handleDelete}>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</div>
</div>
{/* Tab Navigation */}

View File

@@ -0,0 +1,867 @@
import type { Character, Proficiency } from '@/shared/types';
// Pathbuilder data structure
interface PathbuilderData {
build?: {
name?: string;
class?: string;
ancestry?: string;
heritage?: string;
background?: string;
alignment?: string;
gender?: string;
age?: string;
deity?: string;
sizeName?: string;
languages?: string[];
attributes?: {
speed?: number;
classDC?: number;
};
acTotal?: {
acTotal?: number;
};
proficiencies?: Record<string, number>;
perception?: number;
};
translations?: {
class?: { germanName?: string };
ancestry?: { germanName?: string };
heritage?: { germanName?: string };
background?: { germanName?: string };
};
}
// Skill data with German names and linked abilities
const SKILL_DATA: Record<string, { german: string; ability: 'STR' | 'DEX' | 'CON' | 'INT' | 'WIS' | 'CHA' }> = {
'Acrobatics': { german: 'Akrobatik', ability: 'DEX' },
'Arcana': { german: 'Arkane Künste', ability: 'INT' },
'Athletics': { german: 'Athletik', ability: 'STR' },
'Crafting': { german: 'Handwerkskunst', ability: 'INT' },
'Deception': { german: 'Täuschung', ability: 'CHA' },
'Diplomacy': { german: 'Diplomatie', ability: 'CHA' },
'Intimidation': { german: 'Einschüchtern', ability: 'CHA' },
'Medicine': { german: 'Medizin', ability: 'WIS' },
'Nature': { german: 'Naturkunde', ability: 'WIS' },
'Occultism': { german: 'Okkultismus', ability: 'INT' },
'Performance': { german: 'Darbietung', ability: 'CHA' },
'Religion': { german: 'Religionskunde', ability: 'WIS' },
'Society': { german: 'Gesellschaftskunde', ability: 'INT' },
'Stealth': { german: 'Heimlichkeit', ability: 'DEX' },
'Survival': { german: 'Überleben', ability: 'WIS' },
'Thievery': { german: 'Diebeskunst', ability: 'DEX' },
};
// Proficiency bonus values
const PROFICIENCY_BONUS: Record<Proficiency, number> = {
'UNTRAINED': 0,
'TRAINED': 2,
'EXPERT': 4,
'MASTER': 6,
'LEGENDARY': 8,
};
// Helper to format modifier with +/- sign
function formatMod(value: number): string {
return value >= 0 ? `+${value}` : `${value}`;
}
// Helper to get ability modifier
function getAbilityMod(score: number): number {
return Math.floor((score - 10) / 2);
}
// Helper to format bulk
function formatBulk(bulk: string | undefined): string {
if (!bulk) return '-';
if (bulk === 'L') return 'L';
return bulk;
}
// Get proficiency label
function getProficiencyLabel(prof: Proficiency): string {
const labels: Record<Proficiency, string> = {
'UNTRAINED': 'Ungeübt',
'TRAINED': 'Geübt',
'EXPERT': 'Experte',
'MASTER': 'Meister',
'LEGENDARY': 'Legendär',
};
return labels[prof] || 'Ungeübt';
}
// Group feats by source
function groupFeatsBySource(feats: Character['feats']) {
const groups: Record<string, typeof feats> = {};
for (const feat of feats) {
const source = feat.source || 'BONUS';
if (!groups[source]) groups[source] = [];
groups[source].push(feat);
}
return groups;
}
// Translate feat source
function translateFeatSource(source: string): string {
const translations: Record<string, string> = {
'CLASS': 'Klasse',
'ANCESTRY': 'Abstammung',
'GENERAL': 'Allgemein',
'SKILL': 'Fertigkeit',
'BONUS': 'Bonus',
'ARCHETYPE': 'Archetyp',
};
return translations[source] || source;
}
export function generateCharacterHTML(character: Character): string {
const abilities = character.abilities || [];
const getAbility = (name: string) => abilities.find(a => a.ability === name)?.score || 10;
const STR = getAbility('STR');
const DEX = getAbility('DEX');
const CON = getAbility('CON');
const INT = getAbility('INT');
const WIS = getAbility('WIS');
const CHA = getAbility('CHA');
// Get pathbuilder data and translations
const pb = character.pathbuilderData as PathbuilderData | undefined;
const translations = pb?.translations;
const build = pb?.build;
// Get translated or original names
const ancestry = translations?.ancestry?.germanName || build?.ancestry || character.ancestryId || '-';
const heritage = translations?.heritage?.germanName || build?.heritage || character.heritageId || '-';
const background = translations?.background?.germanName || build?.background || character.backgroundId || '-';
const charClass = translations?.class?.germanName || build?.class || character.classId || '-';
const alignment = build?.alignment || '-';
// Get AC from pathbuilder
const ac = build?.acTotal?.acTotal || 10;
// Get speed from pathbuilder
const speed = build?.attributes?.speed || 25;
// Calculate Perception correctly
// Perception = WIS mod + proficiency bonus + level (if trained+)
const proficiencies = build?.proficiencies || {};
const perceptionProf = proficiencies['perception'] || 2; // Default to trained
const perceptionLevelBonus = perceptionProf > 0 ? character.level : 0;
const perception = getAbilityMod(WIS) + perceptionLevelBonus + perceptionProf;
const perceptionProfName = perceptionProf === 0 ? 'Ungeübt' :
perceptionProf === 2 ? 'Geübt' :
perceptionProf === 4 ? 'Experte' :
perceptionProf === 6 ? 'Meister' :
perceptionProf === 8 ? 'Legendär' : 'Geübt';
// Check if tabs have content
const hasSpells = character.spells && character.spells.length > 0;
const hasAlchemy = character.alchemyState && (character.formulas?.length || 0) > 0;
const hasPreparedItems = character.preparedItems && character.preparedItems.length > 0;
// Group items by equipped status
const equippedItems = character.items?.filter(i => i.isEquipped) || [];
const carriedItems = character.items?.filter(i => !i.isEquipped) || [];
// Calculate total bulk
const totalBulk = character.items?.reduce((sum, item) => {
const bulk = item.equipment?.bulk;
if (!bulk) return sum;
if (bulk === 'L') return sum + 0.1;
const num = parseFloat(bulk);
return isNaN(num) ? sum : sum + num * item.quantity;
}, 0) || 0;
// Filter skills (exclude saves and perception)
const EXCLUDED_SKILLS = ['Fortitude', 'Reflex', 'Will', 'Perception'];
const filteredSkills = (character.skills || []).filter(
(skill) => !EXCLUDED_SKILLS.includes(skill.skillName)
);
// Calculate skill modifiers
const skillsWithMods = filteredSkills.map(skill => {
const skillData = SKILL_DATA[skill.skillName];
const germanName = skillData?.german || skill.skillName;
const linkedAbility = skillData?.ability || 'INT';
const abilityScore = getAbility(linkedAbility);
const abilityMod = getAbilityMod(abilityScore);
const profBonus = PROFICIENCY_BONUS[skill.proficiency] || 0;
const levelBonus = skill.proficiency !== 'UNTRAINED' ? character.level : 0;
const totalBonus = abilityMod + levelBonus + profBonus;
const isLore = skill.skillName.toLowerCase().includes('lore');
const displayName = isLore ? skill.skillName.replace(' Lore', '') + ' (Wissen)' : germanName;
return {
name: displayName,
modifier: totalBonus,
proficiency: skill.proficiency,
};
});
// Get save values from pathbuilder proficiencies
const getSaveTotal = (saveName: string, abilityMod: number) => {
const prof = proficiencies[saveName] || 2; // Default to trained
const levelBonus = prof > 0 ? character.level : 0;
return abilityMod + levelBonus + prof;
};
const getSaveProfName = (saveName: string) => {
const prof = proficiencies[saveName] || 2;
if (prof === 0) return 'Ungeübt';
if (prof === 2) return 'Geübt';
if (prof === 4) return 'Experte';
if (prof === 6) return 'Meister';
if (prof === 8) return 'Legendär';
return 'Geübt';
};
const saveReflex = getSaveTotal('reflex', getAbilityMod(DEX));
const saveFortitude = getSaveTotal('fortitude', getAbilityMod(CON));
const saveWill = getSaveTotal('will', getAbilityMod(WIS));
const reflexProfName = getSaveProfName('reflex');
const fortitudeProfName = getSaveProfName('fortitude');
const willProfName = getSaveProfName('will');
const html = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${character.name} - Charakterbogen</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 11pt;
line-height: 1.4;
color: #1a1a1a;
background: #fff;
padding: 20px;
max-width: 210mm;
margin: 0 auto;
}
@media print {
body {
padding: 0;
font-size: 10pt;
}
.page-break {
page-break-before: always;
}
}
h1 {
font-size: 24pt;
border-bottom: 3px solid #7c3aed;
padding-bottom: 8px;
margin-bottom: 16px;
color: #7c3aed;
}
h2 {
font-size: 14pt;
background: #7c3aed;
color: white;
padding: 6px 12px;
margin: 16px 0 8px 0;
border-radius: 4px;
}
h3 {
font-size: 12pt;
color: #7c3aed;
border-bottom: 1px solid #ddd;
padding-bottom: 4px;
margin: 12px 0 8px 0;
}
.header-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 8px;
margin-bottom: 16px;
padding: 12px;
background: #f8f5ff;
border-radius: 8px;
border: 1px solid #e9d5ff;
}
.header-info div {
display: flex;
flex-direction: column;
}
.header-info .label {
font-size: 9pt;
color: #666;
text-transform: uppercase;
}
.header-info .value {
font-weight: 600;
font-size: 12pt;
}
.abilities {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
margin-bottom: 16px;
}
.ability-box {
text-align: center;
padding: 8px;
background: #f8f5ff;
border: 2px solid #7c3aed;
border-radius: 8px;
}
.ability-box .name {
font-size: 10pt;
font-weight: 600;
color: #7c3aed;
}
.ability-box .mod {
font-size: 18pt;
font-weight: bold;
}
.ability-box .score {
font-size: 9pt;
color: #666;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.stat-block {
padding: 12px;
background: #fafafa;
border: 1px solid #ddd;
border-radius: 8px;
}
.stat-block .title {
font-weight: 600;
color: #7c3aed;
margin-bottom: 4px;
}
.stat-block .big-value {
font-size: 24pt;
font-weight: bold;
}
.stat-block .detail {
font-size: 9pt;
color: #666;
}
.saves-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 16px;
}
.save-box {
text-align: center;
padding: 10px;
background: #f0fdf4;
border: 2px solid #22c55e;
border-radius: 8px;
}
.save-box .name {
font-size: 10pt;
font-weight: 600;
color: #16a34a;
}
.save-box .value {
font-size: 16pt;
font-weight: bold;
}
.save-box .prof {
font-size: 8pt;
color: #666;
}
.skills-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 4px;
}
.skill-row {
display: flex;
justify-content: space-between;
padding: 4px 8px;
background: #fafafa;
border-radius: 4px;
}
.skill-row:nth-child(odd) {
background: #f0f0f0;
}
.skill-row .name {
font-weight: 500;
}
.skill-row .value {
font-weight: 600;
color: #7c3aed;
}
.skill-row .prof {
font-size: 8pt;
color: #666;
margin-left: 4px;
}
.feats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 8px;
}
.feat-item {
padding: 8px;
background: #fafafa;
border: 1px solid #ddd;
border-radius: 4px;
}
.feat-item .name {
font-weight: 600;
}
.feat-item .level {
font-size: 9pt;
color: #666;
}
.items-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 12px;
}
.items-table th {
text-align: left;
padding: 6px 8px;
background: #f0f0f0;
border-bottom: 2px solid #ddd;
font-size: 9pt;
text-transform: uppercase;
}
.items-table td {
padding: 6px 8px;
border-bottom: 1px solid #eee;
}
.items-table tr:nth-child(even) {
background: #fafafa;
}
.spells-section {
margin-bottom: 16px;
}
.spell-level-group {
margin-bottom: 12px;
}
.spell-level-title {
font-weight: 600;
color: #7c3aed;
margin-bottom: 4px;
padding: 4px 8px;
background: #f8f5ff;
border-radius: 4px;
}
.spells-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 4px;
padding-left: 8px;
}
.spell-item {
padding: 4px 8px;
background: #fafafa;
border-radius: 4px;
}
.alchemy-section {
margin-bottom: 16px;
}
.formula-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 4px;
}
.formula-item {
padding: 6px 8px;
background: #fdf4ff;
border: 1px solid #e9d5ff;
border-radius: 4px;
}
.formula-item .name {
font-weight: 500;
}
.formula-item .level {
font-size: 9pt;
color: #666;
}
.conditions-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.condition-badge {
padding: 4px 12px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 16px;
color: #dc2626;
font-weight: 500;
}
.notes-box {
padding: 12px;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 8px;
white-space: pre-wrap;
}
.footer {
margin-top: 24px;
padding-top: 12px;
border-top: 1px solid #ddd;
font-size: 9pt;
color: #666;
text-align: center;
}
</style>
</head>
<body>
<h1>${character.name}</h1>
<div class="header-info">
<div>
<span class="label">Abstammung</span>
<span class="value">${ancestry}</span>
</div>
<div>
<span class="label">Erbe</span>
<span class="value">${heritage}</span>
</div>
<div>
<span class="label">Hintergrund</span>
<span class="value">${background}</span>
</div>
<div>
<span class="label">Klasse</span>
<span class="value">${charClass}</span>
</div>
<div>
<span class="label">Stufe</span>
<span class="value">${character.level}</span>
</div>
<div>
<span class="label">Gesinnung</span>
<span class="value">${alignment}</span>
</div>
</div>
<h2>Attribute</h2>
<div class="abilities">
<div class="ability-box">
<div class="name">STR</div>
<div class="mod">${formatMod(getAbilityMod(STR))}</div>
<div class="score">${STR}</div>
</div>
<div class="ability-box">
<div class="name">DEX</div>
<div class="mod">${formatMod(getAbilityMod(DEX))}</div>
<div class="score">${DEX}</div>
</div>
<div class="ability-box">
<div class="name">CON</div>
<div class="mod">${formatMod(getAbilityMod(CON))}</div>
<div class="score">${CON}</div>
</div>
<div class="ability-box">
<div class="name">INT</div>
<div class="mod">${formatMod(getAbilityMod(INT))}</div>
<div class="score">${INT}</div>
</div>
<div class="ability-box">
<div class="name">WIS</div>
<div class="mod">${formatMod(getAbilityMod(WIS))}</div>
<div class="score">${WIS}</div>
</div>
<div class="ability-box">
<div class="name">CHA</div>
<div class="mod">${formatMod(getAbilityMod(CHA))}</div>
<div class="score">${CHA}</div>
</div>
</div>
<div class="stats-grid">
<div class="stat-block">
<div class="title">Trefferpunkte</div>
<div class="big-value">${character.hpCurrent} / ${character.hpMax}</div>
${character.hpTemp ? `<div class="detail">Temporär: ${character.hpTemp}</div>` : ''}
</div>
<div class="stat-block">
<div class="title">Rüstungsklasse</div>
<div class="big-value">${ac}</div>
</div>
<div class="stat-block">
<div class="title">Bewegung</div>
<div class="big-value">${speed} ft</div>
</div>
<div class="stat-block">
<div class="title">Wahrnehmung</div>
<div class="big-value">${formatMod(perception)}</div>
<div class="detail">${perceptionProfName}</div>
</div>
</div>
<h2>Rettungswürfe</h2>
<div class="saves-grid">
<div class="save-box">
<div class="name">Zähigkeit</div>
<div class="value">${formatMod(saveFortitude)}</div>
<div class="prof">${fortitudeProfName}</div>
</div>
<div class="save-box">
<div class="name">Reflex</div>
<div class="value">${formatMod(saveReflex)}</div>
<div class="prof">${reflexProfName}</div>
</div>
<div class="save-box">
<div class="name">Wille</div>
<div class="value">${formatMod(saveWill)}</div>
<div class="prof">${willProfName}</div>
</div>
</div>
${character.conditions && character.conditions.length > 0 ? `
<h2>Zustände</h2>
<div class="conditions-list">
${character.conditions.map(c => `
<span class="condition-badge">${c.nameGerman || c.name}${c.value ? ` ${c.value}` : ''}</span>
`).join('')}
</div>
` : ''}
<h2>Fertigkeiten</h2>
<div class="skills-list">
${skillsWithMods.map(skill => `
<div class="skill-row">
<span class="name">${skill.name}</span>
<span>
<span class="value">${formatMod(skill.modifier)}</span>
<span class="prof">(${getProficiencyLabel(skill.proficiency)})</span>
</span>
</div>
`).join('')}
</div>
<h2>Talente</h2>
${Object.entries(groupFeatsBySource(character.feats || [])).map(([source, feats]) => `
<h3>${translateFeatSource(source)}</h3>
<div class="feats-grid">
${feats.map(feat => `
<div class="feat-item">
<span class="name">${feat.nameGerman || feat.name}</span>
<span class="level">Stufe ${feat.level || 1}</span>
</div>
`).join('')}
</div>
`).join('')}
<div class="page-break"></div>
<h2>Ausrüstung</h2>
<p style="margin-bottom: 8px; color: #666;">Traglast: ${totalBulk.toFixed(1)} Bulk</p>
${equippedItems.length > 0 ? `
<h3>Angelegt</h3>
<table class="items-table">
<thead>
<tr>
<th>Gegenstand</th>
<th>Menge</th>
<th>Bulk</th>
</tr>
</thead>
<tbody>
${equippedItems.map(item => `
<tr>
<td>${item.equipment?.nameGerman || item.equipment?.name || item.customName || '-'}</td>
<td>${item.quantity}</td>
<td>${formatBulk(item.equipment?.bulk)}</td>
</tr>
`).join('')}
</tbody>
</table>
` : ''}
${carriedItems.length > 0 ? `
<h3>Mitgeführt</h3>
<table class="items-table">
<thead>
<tr>
<th>Gegenstand</th>
<th>Menge</th>
<th>Bulk</th>
</tr>
</thead>
<tbody>
${carriedItems.map(item => `
<tr>
<td>${item.equipment?.nameGerman || item.equipment?.name || item.customName || '-'}</td>
<td>${item.quantity}</td>
<td>${formatBulk(item.equipment?.bulk)}</td>
</tr>
`).join('')}
</tbody>
</table>
` : ''}
${hasSpells ? `
<h2>Zauber</h2>
<div class="spells-section">
${Object.entries(
(character.spells || []).reduce((acc, spell) => {
const level = spell.spellLevel || 0;
if (!acc[level]) acc[level] = [];
acc[level].push(spell);
return acc;
}, {} as Record<number, typeof character.spells>)
).sort(([a], [b]) => Number(a) - Number(b)).map(([level, spells]) => `
<div class="spell-level-group">
<div class="spell-level-title">${level === '0' ? 'Zaubertricks' : `Grad ${level}`}</div>
<div class="spells-list">
${spells!.map(spell => `
<div class="spell-item">${spell.nameGerman || spell.name}</div>
`).join('')}
</div>
</div>
`).join('')}
</div>
` : ''}
${hasAlchemy || hasPreparedItems ? `
<h2>Alchemie</h2>
<div class="alchemy-section">
${character.alchemyState ? `
<div class="stats-grid" style="margin-bottom: 12px;">
<div class="stat-block">
<div class="title">Vielseitige Phiolen</div>
<div class="big-value">${character.alchemyState.versatileVialsCurrent} / ${character.alchemyState.versatileVialsMax}</div>
</div>
<div class="stat-block">
<div class="title">Fortgeschrittene Alchemie</div>
<div class="big-value">${character.alchemyState.advancedAlchemyBatch} / ${character.alchemyState.advancedAlchemyMax}</div>
<div class="detail">Chargen verwendet</div>
</div>
</div>
` : ''}
${(character.formulas?.length || 0) > 0 ? `
<h3>Formelbuch</h3>
<div class="formula-grid">
${character.formulas!.map(formula => `
<div class="formula-item">
<span class="name">${formula.equipment?.nameGerman || formula.nameGerman || formula.name}</span>
${formula.equipment?.level !== undefined ? `<span class="level"> (Stufe ${formula.equipment.level})</span>` : ''}
</div>
`).join('')}
</div>
` : ''}
${hasPreparedItems ? `
<h3>Vorbereitete Gegenstände</h3>
<table class="items-table">
<thead>
<tr>
<th>Gegenstand</th>
<th>Menge</th>
<th>Typ</th>
</tr>
</thead>
<tbody>
${character.preparedItems!.map(item => `
<tr>
<td>${item.equipment?.nameGerman || item.nameGerman || item.name}</td>
<td>${item.quantity}</td>
<td>${item.isInfused ? 'Infundiert' : 'Hergestellt'}</td>
</tr>
`).join('')}
</tbody>
</table>
` : ''}
</div>
` : ''}
<div class="footer">
Exportiert am ${new Date().toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})} • Dimension 47
</div>
</body>
</html>`;
return html;
}
export function downloadCharacterHTML(character: Character): void {
const html = generateCharacterHTML(character);
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${character.name.replace(/[^a-zA-Z0-9äöüÄÖÜß\s-]/g, '')}_Charakterbogen.html`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}