Warhammer Character Sheet

import { useState, useEffect, useCallback, useMemo, useRef } from “react”; const CHAR_NAMES = [“WS”,”BS”,”S”,”T”,”I”,”Ag”,”Dex”,”Int”,”WP”,”Fel”]; const CHAR_KEYS = [“ws”,”bs”,”s”,”t”,”i”,”ag”,”dex”,”int”,”wp”,”fel”]; const CHAR_MAP = { ws:”WS”, bs:”BS”, s:”S”, t:”T”, i:”I”, ag:”Ag”, dex:”Dex”, int:”Int”, wp:”WP”, fel:”Fel” }; const BASIC_SKILLS = [ { name:”Art”, char:”dex” }, { name:”Athletics”, char:”ag” }, { name:”Bribery”, char:”fel” }, { name:”Charm”, char:”fel” }, { name:”Charm Animal”, char:”wp” }, { name:”Climb”, char:”s” }, { name:”Cool”, char:”wp” }, { name:”Consume Alcohol”, char:”t” }, { name:”Dodge”, char:”ag” }, { name:”Drive”, char:”ag” }, { name:”Endurance”, char:”t” }, { name:”Entertain”, char:”fel” }, { name:”Gamble”, char:”int” }, { name:”Gossip”, char:”fel” }, { name:”Haggle”, char:”fel” }, { name:”Intimidate”, char:”s” }, { name:”Intuition”, char:”i” }, { name:”Leadership”, char:”fel” }, { name:”Melee (Basic)”, char:”ws” }, { name:”Navigation”, char:”i” }, { name:”Outdoor Survival”, char:”int” }, { name:”Perception”, char:”i” }, { name:”Ride”, char:”ag” }, { name:”Row”, char:”s” }, { name:”Stealth”, char:”ag” } ]; const HIT_LOCATIONS = [ { name:”Head”, range:”01-09″ }, { name:”Left Arm”, range:”10-24″ }, { name:”Right Arm”, range:”25-44″ }, { name:”Body”, range:”45-79″ }, { name:”Left Leg”, range:”80-89″ }, { name:”Right Leg”, range:”90-99″ } ]; const SPECIES_WOUNDS = { “Human”: (sb,tb,wpb) => sb + 2*tb + wpb, “Dwarf”: (sb,tb,wpb) => sb + 2*tb + wpb, “Halfling”: (sb,tb,wpb) => sb + 2*tb + wpb, “High Elf”: (sb,tb,wpb) => sb + 2*tb + wpb, “Wood Elf”: (sb,tb,wpb) => sb + 2*tb + wpb }; const XP_CHAR_COSTS = [25,30,40,50,70,90,120,150,190,230]; const XP_SKILL_COSTS = [10,15,20,30,40,60,80,110,140,180]; const emptyChar = () => ({ initial: 0, advances: 0 }); const emptyWeapon = () => ({ name:””, group:””, damage:””, range:””, qualities:””, enc:0, equipped:true }); const emptyArmour = () => ({ name:””, ap:0, qualities:””, enc:0, penalty:””, equipped:true }); const emptyTalent = () => ({ name:””, description:””, maxRank:””, currentRank:1 }); const emptyTrapping = () => ({ name:””, enc:0, carried:true }); const emptyAdvSkill = () => ({ name:””, char:”ws”, advances:0 }); const defaultState = () => ({ // Character Info charName:””, species:”Human”, className:””, career:””, level:””, careerPath:””, age:””, height:””, hair:””, eyes:””, trait:””, motivation:””, socialTier:”Bronze”, socialStanding:0, shortAmbition:””, longAmbition:””, // Fate & Resilience fate:0, fortune:0, resilience:0, resolve:0, // Health currentWounds:0, hardy:0, // Corruption corruption:0, maxCorruption:0, // XP xpCurrent:0, xpTotal:0, // Characteristics chars: Object.fromEntries(CHAR_KEYS.map(k => [k, emptyChar()])), // Movement movement:4, // Basic skill advances basicAdv: Object.fromEntries(BASIC_SKILLS.map(s => [s.name, 0])), // Advanced skills advancedSkills: Array.from({length:12}, emptyAdvSkill), // Weapons (5 slots) weapons: Array.from({length:5}, emptyWeapon), // Shield shield: { name:””, ap:0, qualities:””, enc:0 }, // Armour per location armour: Object.fromEntries(HIT_LOCATIONS.map(h => [h.name, emptyArmour()])), // Talents talents: Array.from({length:16}, emptyTalent), // Trappings trappings: Array.from({length:20}, emptyTrapping), // Wealth brass:0, silver:0, gold:0, // Mutations & Psychology mutations:””, // Notes notes:””, // Tab activeTab: 0, }); // Styles const colors = { bg: “#1a1410”, bgCard: “#231e18”, bgInput: “#1a1410”, border: “#5a4a3a”, borderLight: “#8a7a6a”, borderGold: “#c9a84c”, text: “#d4c4a8”, textDim: “#8a7a6a”, textBright: “#f0e6d0”, accent: “#c9a84c”, accentDark: “#8b6914”, danger: “#8b2020”, success: “#2d5a1e”, highlight: “#3a2a18”, }; const baseInput = { background: colors.bgInput, color: colors.textBright, border: `1px solid ${colors.border}`, padding: “4px 6px”, fontSize: “13px”, fontFamily: “‘Crimson Text’, Georgia, serif”, borderRadius: “2px”, outline: “none”, width: “100%”, boxSizing: “border-box”, }; const numInput = { …baseInput, width: “44px”, textAlign: “center”, padding: “4px 2px” }; const labelStyle = { color: colors.textDim, fontSize: “11px”, textTransform: “uppercase”, letterSpacing: “1px”, fontFamily: “‘Crimson Text’, Georgia, serif” }; const sectionTitle = { color: colors.accent, fontSize: “16px”, fontFamily: “‘Cinzel’, ‘Times New Roman’, serif”, fontWeight: “700”, borderBottom: `2px solid ${colors.borderGold}`, paddingBottom: “4px”, marginBottom: “10px”, textTransform: “uppercase”, letterSpacing: “2px”, }; const calcValue = { background: colors.highlight, color: colors.accent, border: `1px solid ${colors.borderGold}`, padding: “4px 6px”, fontSize: “14px”, fontWeight: “700”, textAlign: “center”, borderRadius: “2px”, minWidth: “40px”, fontFamily: “‘Cinzel’, serif”, }; const toolbarBtn = { background: “transparent”, border: `1px solid ${colors.border}`, color: colors.textBright, padding: “5px 12px”, fontSize: “12px”, cursor: “pointer”, borderRadius: “3px”, fontFamily: “‘Crimson Text’, serif”, whiteSpace: “nowrap”, transition: “all 0.15s”, }; function NumberField({ value, onChange, style, min=0, max=999, readOnly=false }) { return onChange(parseInt(e.target.value)||0)} style={{ …numInput, …style, …(readOnly ? { background: colors.highlight, color: colors.accent, borderColor: colors.borderGold, fontWeight:”700″ } : {}) }} />; } function TextField({ value, onChange, style, placeholder=”” }) { return onChange(e.target.value)} placeholder={placeholder} style={{ …baseInput, …style }} />; } function CalcBox({ value, label, style }) { return (
{label &&
{label}
}
{value}
); } function Section({ title, children, style }) { return (
{title}
{children}
); } export default function WFRP4eSheet() { const [state, setState] = useState(defaultState); const [loaded, setLoaded] = useState(false); const [slots, setSlots] = useState([]); const [activeSlot, setActiveSlot] = useState(“default”); const [showSlotMgr, setShowSlotMgr] = useState(false); const [toastMsg, setToastMsg] = useState(“”); const fileInputRef = useRef(null); const toast = (msg) => { setToastMsg(msg); setTimeout(() => setToastMsg(“”), 2500); }; // Merge saved data with defaults, ensuring arrays are the right length const mergeWithDefaults = (saved) => { const merged = { …defaultState(), …saved }; const ensureArr = (arr, min, factory) => { if (!arr || arr.length < min) return [...(arr||[]), ...Array.from({length: min - (arr?.length||0)}, factory)]; return arr; }; merged.advancedSkills = ensureArr(merged.advancedSkills, 12, emptyAdvSkill); merged.weapons = ensureArr(merged.weapons, 5, emptyWeapon); merged.talents = ensureArr(merged.talents, 16, emptyTalent); merged.trappings = ensureArr(merged.trappings, 20, emptyTrapping); return merged; }; // Load slot index + active character useEffect(() => { (async () => { try { const slotData = await window.storage.get(“wfrp4e-slots”); if (slotData?.value) setSlots(JSON.parse(slotData.value)); const lastSlot = await window.storage.get(“wfrp4e-active-slot”); const slotKey = lastSlot?.value || “default”; setActiveSlot(slotKey); const r = await window.storage.get(`wfrp4e-sheet-${slotKey}`); if (r?.value) setState(mergeWithDefaults(JSON.parse(r.value))); } catch(e) { console.log(“No saved data”); } setLoaded(true); })(); }, []); // Save to active slot (debounced) useEffect(() => { if (!loaded) return; const t = setTimeout(async () => { try { await window.storage.set(`wfrp4e-sheet-${activeSlot}`, JSON.stringify(state)); await window.storage.set(“wfrp4e-active-slot”, activeSlot); // Update slot index with name const name = state.charName || “Unnamed”; const updated = slots.some(s => s.key === activeSlot) ? slots.map(s => s.key === activeSlot ? { …s, name } : s) : […slots, { key: activeSlot, name }]; setSlots(updated); await window.storage.set(“wfrp4e-slots”, JSON.stringify(updated)); } catch(e) {} }, 800); return () => clearTimeout(t); }, [state, loaded]); // Switch character slot const switchSlot = async (key) => { try { // Save current first await window.storage.set(`wfrp4e-sheet-${activeSlot}`, JSON.stringify(state)); // Load new const r = await window.storage.get(`wfrp4e-sheet-${key}`); setState(r?.value ? mergeWithDefaults(JSON.parse(r.value)) : defaultState()); setActiveSlot(key); await window.storage.set(“wfrp4e-active-slot”, key); toast(`Loaded character slot`); } catch(e) { toast(“Error switching slot”); } }; const newSlot = async () => { const key = `char-${Date.now()}`; const newSlots = […slots, { key, name: “New Character” }]; setSlots(newSlots); await window.storage.set(“wfrp4e-slots”, JSON.stringify(newSlots)); // Save current first await window.storage.set(`wfrp4e-sheet-${activeSlot}`, JSON.stringify(state)); setState(defaultState()); setActiveSlot(key); await window.storage.set(“wfrp4e-active-slot”, key); toast(“New character created”); }; const deleteSlot = async (key) => { if (slots.length <= 1 && key === activeSlot) { toast("Can't delete last character"); return; } if (!confirm("Delete this character permanently?")) return; const newSlots = slots.filter(s => s.key !== key); setSlots(newSlots); await window.storage.set(“wfrp4e-slots”, JSON.stringify(newSlots)); try { await window.storage.delete(`wfrp4e-sheet-${key}`); } catch(e) {} if (key === activeSlot && newSlots.length > 0) { await switchSlot(newSlots[0].key); } toast(“Character deleted”); }; // ===== EXPORT / IMPORT ===== const exportJSON = () => { const exportData = { …state, _format: “wfrp4e-sheet”, _version: 1, _exported: new Date().toISOString() }; delete exportData.activeTab; const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: “application/json” }); const url = URL.createObjectURL(blob); const a = document.createElement(“a”); a.href = url; a.download = `${(state.charName || “character”).replace(/[^a-zA-Z0-9]/g, “_”)}_wfrp4e.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); toast(“Character exported as JSON file”); }; const importJSON = (e) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { try { const data = JSON.parse(ev.target.result); if (!data.chars && !data.charName && !data._format) { toast(“Invalid character file”); return; } setState(mergeWithDefaults(data)); toast(`Imported: ${data.charName || “character”}`); } catch(err) { toast(“Failed to read file — is it valid JSON?”); } }; reader.readAsText(file); e.target.value = “”; }; const copyToClipboard = async () => { const exportData = { …state, _format: “wfrp4e-sheet”, _version: 1 }; delete exportData.activeTab; try { await navigator.clipboard.writeText(JSON.stringify(exportData)); toast(“Character data copied to clipboard”); } catch(e) { toast(“Clipboard copy failed — use Export instead”); } }; const pasteFromClipboard = async () => { try { const text = await navigator.clipboard.readText(); const data = JSON.parse(text); if (!data.chars && !data.charName) { toast(“Clipboard doesn’t contain valid character data”); return; } if (!confirm(`Import “${data.charName || “character”}” from clipboard? This replaces current data.`)) return; setState(mergeWithDefaults(data)); toast(`Imported: ${data.charName || “character”}`); } catch(e) { toast(“Failed to read clipboard”); } }; const set = useCallback((key, val) => setState(prev => ({ …prev, [key]: val })), []); const setChar = useCallback((key, field, val) => setState(prev => ({ …prev, chars: { …prev.chars, [key]: { …prev.chars[key], [field]: val } } })), []); // ===== AUTO-CALCULATIONS ===== const charTotals = useMemo(() => Object.fromEntries(CHAR_KEYS.map(k => [k, (state.chars[k]?.initial||0) + (state.chars[k]?.advances||0)])) , [state.chars]); const charBonuses = useMemo(() => Object.fromEntries(CHAR_KEYS.map(k => [k, Math.floor((charTotals[k]||0) / 10)])) , [charTotals]); const sb = charBonuses.s, tb = charBonuses.t, wpb = charBonuses.wp; const ib = charBonuses.i, agb = charBonuses.ag; // Wounds = SB + 2×TB + WPB + Hardy bonus const maxWounds = useMemo(() => { const calc = SPECIES_WOUNDS[state.species] || SPECIES_WOUNDS[“Human”]; return calc(sb, tb, wpb) + (state.hardy || 0) * tb; }, [sb, tb, wpb, state.species, state.hardy]); // Movement const walkMove = state.movement; const runMove = state.movement * 2; // Initiative = Ag bonus + I bonus (in 4e, Initiative is I characteristic based) const initiative = charTotals.i; // Dodge skill total const dodgeTotal = charTotals.ag + (state.basicAdv[“Dodge”]||0); // Basic skill totals const basicSkillTotals = useMemo(() => Object.fromEntries(BASIC_SKILLS.map(s => [s.name, (charTotals[s.char]||0) + (state.basicAdv[s.name]||0)])) , [charTotals, state.basicAdv]); // Advanced skill totals (only if advances > 0) const advSkillTotals = useMemo(() => state.advancedSkills.map(s => s.advances > 0 ? (charTotals[s.char]||0) + s.advances : 0) , [charTotals, state.advancedSkills]); // Weapon damage auto-calc: weapon damage in 4e = SB + Damage value const weaponDamage = useMemo(() => state.weapons.map(w => { const dmgVal = parseInt(w.damage)||0; // Check if it’s a ranged weapon (BS group) – ranged weapons don’t add SB typically const isRanged = (w.group||””).toLowerCase().includes(“ranged”) || (w.group||””).toLowerCase().includes(“bow”) || (w.group||””).toLowerCase().includes(“crossbow”) || (w.group||””).toLowerCase().includes(“blackpowder”) || (w.group||””).toLowerCase().includes(“engineering”) || (w.group||””).toLowerCase().includes(“sling”) || (w.group||””).toLowerCase().includes(“thrown”); return isRanged ? dmgVal : sb + dmgVal; }) , [state.weapons, sb]); // Encumbrance calculations const encumbrance = useMemo(() => { const weaponEnc = state.weapons.reduce((sum, w) => sum + (w.equipped ? (parseInt(w.enc)||0) : 0), 0); // Worn armour: enc is reduced. In 4e, worn armour counts as 0 enc for the first set, extras count const armourEnc = Object.values(state.armour).reduce((sum, a) => { if (!a.equipped || !a.name) return sum; return sum; // Worn armour = 0 enc in 4e }, 0); // Non-worn armour pieces const armourCarriedEnc = Object.values(state.armour).reduce((sum, a) => { if (a.equipped || !a.name) return sum; return sum + (parseInt(a.enc)||0); }, 0); const shieldEnc = state.shield.name ? 0 : 0; // Shield in hand = 0 enc const trappingEnc = state.trappings.reduce((sum, t) => sum + (t.carried ? (parseInt(t.enc)||0) : 0), 0); // Strong Back talent: check talents for it const strongBackRanks = state.talents.reduce((r, t) => t.name.toLowerCase().includes(“strong back”) ? r + (t.currentRank||1) : r, 0); // Sturdy talent const sturdyRanks = state.talents.reduce((r, t) => t.name.toLowerCase().includes(“sturdy”) ? r + (t.currentRank||1) : r, 0); const maxEnc = sb + tb + strongBackRanks + sturdyRanks; const totalEnc = weaponEnc + armourCarriedEnc + trappingEnc; return { weapon: weaponEnc, armour: armourCarriedEnc, trappings: trappingEnc, total: totalEnc, max: maxEnc }; }, [state.weapons, state.armour, state.shield, state.trappings, state.talents, sb, tb]); // Total armour AP per location const armourAP = useMemo(() => Object.fromEntries(HIT_LOCATIONS.map(h => [h.name, state.armour[h.name]?.equipped ? (parseInt(state.armour[h.name]?.ap)||0) : 0])) , [state.armour]); // XP cost helper const xpCostForChar = (advances) => { let total = 0; for (let i = 0; i < Math.min(advances, XP_CHAR_COSTS.length * 5); i++) { total += XP_CHAR_COSTS[Math.min(Math.floor(i/5), XP_CHAR_COSTS.length-1)]; } return total; }; const xpCostForSkill = (advances) => { let total = 0; for (let i = 0; i < Math.min(advances, XP_SKILL_COSTS.length * 5); i++) { total += XP_SKILL_COSTS[Math.min(Math.floor(i/5), XP_SKILL_COSTS.length-1)]; } return total; }; const tabs = ["Character", "Characteristics & Skills", "Combat & Equipment", "Talents & Trappings"]; const gridRow = { display:"flex", gap:"8px", alignItems:"end", marginBottom:"6px", flexWrap:"wrap" }; const gridCell = (flex=1) => ({ flex, minWidth: 0 }); // ===== RENDER ===== return (
{/* Hidden file input */} {/* Toast notification */} {toastMsg && (
{toastMsg}
)} {/* Header */}
WARHAMMER FANTASY
ROLEPLAY — 4TH EDITION
{state.charName || “Unnamed Character”}
{/* Toolbar */}
{/* Character switcher */}
{/* Export/Import */}
Auto-saves • Share .json files between players
{/* Slot manager dropdown */} {showSlotMgr && (
Saved Characters
{(slots.length === 0 ? [{ key: “default”, name: state.charName || “Default” }] : slots).map(slot => (
slot.key !== activeSlot && switchSlot(slot.key)}> {slot.key === activeSlot ? “▸ ” : “”}{slot.name || “Unnamed”} {slots.length > 1 && ( )}
))}
)}
{/* Tabs */}
{tabs.map((t, i) => ( ))}
{/* ==================== TAB 0: CHARACTER ==================== */} {state.activeTab === 0 && (
Character Name
set(“charName”, v)} />
Species
Class
set(“className”, v)} />
Career
set(“career”, v)} />
Level
set(“level”, v)} />
Career Path
set(“careerPath”, v)} />
Age
set(“age”, v)} />
Height
set(“height”, v)} />
Hair
set(“hair”, v)} />
Eyes
set(“eyes”, v)} />
Distinguishing Trait
set(“trait”, v)} />
Motivation
set(“motivation”, v)} />
Tier
Standing
set(“socialStanding”, v)} max={7} />
Short Ambition
set(“shortAmbition”, v)} />
Long Ambition
set(“longAmbition”, v)} />
Fate
set(“fate”, v)} style={{width:”100%”}} />
Fortune (current)
set(“fortune”, v)} style={{width:”100%”}} />
Spend Fortune to: Re-roll a failed test • Add +1 SL • Change Initiative
Spend Fate to: Survive death or critical injury
Resilience
set(“resilience”, v)} style={{width:”100%”}} />
Resolve (current)
set(“resolve”, v)} style={{width:”100%”}} />
Spend Resolve to: Become immune to Psychology (1 round) • Eliminate 1 condition • Ignore modifiers from critical injury
Spend Resilience to: Avoid 1 Corruption • Choose result of a roll
Hardy Ranks
set(“hardy”, v)} style={{width:”100%”}} />
Current Wounds
set(“currentWounds”, parseInt(e.target.value))} style={{ flex:1, accentColor: colors.accent }} /> set(“currentWounds”, v)} max={maxWounds} style={{width:”50px”}} /> / {maxWounds}
0 ? (Math.min(state.currentWounds, maxWounds)/maxWounds)*100 : 0}%`, background: state.currentWounds / maxWounds > 0.5 ? colors.success : state.currentWounds / maxWounds > 0.25 ? colors.accentDark : colors.danger, }} />
Formula: SB({sb}) + 2×TB({2*tb}) + WPB({wpb}){state.hardy > 0 ? ` + Hardy(${state.hardy}×TB=${state.hardy*tb})` : “”} = {maxWounds}
Corruption
set(“corruption”, v)} style={{width:”100%”}} />
Max Corruption
set(“maxCorruption”, v)} style={{width:”100%”}} />
XP Current
set(“xpCurrent”, v)} style={{width:”100%”}} />
XP Total
set(“xpTotal”, v)} style={{width:”100%”}} />
Mutations & Psychology