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