diff --git a/client/src/features/characters/components/character-sheet-page.tsx b/client/src/features/characters/components/character-sheet-page.tsx
index 645f2ad..5bc1e95 100644
--- a/client/src/features/characters/components/character-sheet-page.tsx
+++ b/client/src/features/characters/components/character-sheet-page.tsx
@@ -40,6 +40,7 @@ import { FeatDetailModal } from './feat-detail-modal';
import { ActionsTab } from './actions-tab';
import { RestModal } from './rest-modal';
import { AlchemyTab } from './alchemy-tab';
+import { RecoveryCheckModal } from './recovery-check-modal';
import { useCharacterSocket } from '@/shared/hooks/use-character-socket';
import { downloadCharacterHTML } from '../utils/export-character-html';
import type { Character, CharacterItem, CharacterFeat, Campaign } from '@/shared/types';
@@ -128,6 +129,7 @@ export function CharacterSheetPage() {
const [editingCredits, setEditingCredits] = useState(false);
const [creditsInput, setCreditsInput] = useState('');
const [showRestModal, setShowRestModal] = useState(false);
+ const [showRecoveryCheckModal, setShowRecoveryCheckModal] = useState(false);
const isOwner = character?.ownerId === user?.id;
const isGM = campaign?.gmId === user?.id;
@@ -154,7 +156,7 @@ export function CharacterSheetPage() {
}, [campaignId, characterId]);
// WebSocket connection for real-time sync
- useCharacterSocket({
+ const { socket } = useCharacterSocket({
characterId: characterId || '',
onHpUpdate: (data) => {
setCharacter((prev) => prev ? { ...prev, hpCurrent: data.hpCurrent, hpTemp: data.hpTemp, hpMax: data.hpMax } : null);
@@ -303,6 +305,16 @@ export function CharacterSheetPage() {
onAlchemyStateUpdate: (data) => {
setCharacter((prev) => prev ? { ...prev, alchemyState: data } : prev);
},
+ onDyingUpdate: (data) => {
+ setCharacter((prev) => {
+ if (!prev) return null;
+ // Update conditions from the dying update
+ if (data.conditions) {
+ return { ...prev, conditions: data.conditions };
+ }
+ return prev;
+ });
+ },
onFullUpdate: (updatedCharacter) => {
setCharacter(updatedCharacter);
},
@@ -311,8 +323,10 @@ export function CharacterSheetPage() {
const handleHpChange = async (newHp: number) => {
if (!character || !campaignId) return;
const clampedHp = Math.max(0, Math.min(character.hpMax, newHp));
+ // Optimistic update for HP
+ setCharacter((prev) => prev ? { ...prev, hpCurrent: clampedHp } : null);
await api.updateCharacterHp(campaignId, character.id, clampedHp);
- setCharacter({ ...character, hpCurrent: clampedHp });
+ // WebSocket will handle dying/conditions updates
};
const handleDelete = async () => {
@@ -499,6 +513,24 @@ export function CharacterSheetPage() {
);
}
+ // Helper to extract dying-related values from conditions
+ const getDyingState = () => {
+ const dyingCondition = character?.conditions.find(
+ (c) => c.name.toLowerCase() === 'dying' || c.nameGerman?.toLowerCase() === 'sterbend'
+ );
+ const woundedCondition = character?.conditions.find(
+ (c) => c.name.toLowerCase() === 'wounded' || c.nameGerman?.toLowerCase() === 'verwundet'
+ );
+ const doomedCondition = character?.conditions.find(
+ (c) => c.name.toLowerCase() === 'doomed' || c.nameGerman?.toLowerCase() === 'verdammt'
+ );
+ return {
+ dyingValue: dyingCondition?.value || 0,
+ woundedValue: woundedCondition?.value || 0,
+ doomedValue: doomedCondition?.value || 0,
+ };
+ };
+
// Tab Content Renderers
const renderStatusTab = () => {
// AC Berechnung
@@ -601,6 +633,8 @@ export function CharacterSheetPage() {
} | undefined;
const speed = pbSpeed?.build?.attributes?.speed || 25;
+ const { dyingValue, woundedValue, doomedValue } = getDyingState();
+
return (
{/* HP Row - Full width on mobile */}
@@ -608,7 +642,15 @@ export function CharacterSheetPage() {
hpCurrent={character.hpCurrent}
hpMax={character.hpMax}
hpTemp={character.hpTemp}
+ dyingValue={dyingValue}
+ woundedValue={woundedValue}
+ doomedValue={doomedValue}
onHpChange={handleHpChange}
+ onRecoveryCheck={() => setShowRecoveryCheckModal(true)}
+ onRevive={() => {
+ if (!socket) return;
+ socket.emit('revive_character', { characterId: character.id });
+ }}
/>
{/* AC & Speed Row */}
@@ -1670,6 +1712,32 @@ export function CharacterSheetPage() {
}}
/>
)}
+ {showRecoveryCheckModal && character && getDyingState().dyingValue > 0 && (
+ setShowRecoveryCheckModal(false)}
+ onRoll={async (dieRoll) => {
+ return new Promise((resolve, reject) => {
+ if (!socket) {
+ reject(new Error('Not connected'));
+ return;
+ }
+ socket.emit(
+ 'recovery_check',
+ { characterId: character.id, dieRoll },
+ (response: { success: boolean; result?: any; error?: string }) => {
+ if (response.success && response.result) {
+ resolve(response.result);
+ } else {
+ reject(new Error(response.error || 'Recovery check failed'));
+ }
+ }
+ );
+ });
+ }}
+ />
+ )}
);
}
diff --git a/client/src/features/characters/components/dying-indicator.tsx b/client/src/features/characters/components/dying-indicator.tsx
new file mode 100644
index 0000000..5df5935
--- /dev/null
+++ b/client/src/features/characters/components/dying-indicator.tsx
@@ -0,0 +1,105 @@
+import { Skull, Heart, AlertTriangle, Dices } from 'lucide-react';
+
+interface DyingIndicatorProps {
+ dyingValue: number;
+ woundedValue: number;
+ doomedValue: number;
+ onRecoveryCheck?: () => void;
+}
+
+export function DyingIndicator({
+ dyingValue,
+ woundedValue,
+ doomedValue,
+ onRecoveryCheck,
+}: DyingIndicatorProps) {
+ const deathThreshold = 4 - doomedValue;
+ const isDying = dyingValue > 0;
+ const isNearDeath = dyingValue >= deathThreshold - 1;
+
+ if (!isDying && woundedValue === 0 && doomedValue === 0) {
+ return null;
+ }
+
+ return (
+
+ {/* Dying Status */}
+ {isDying && (
+
+ {/* Header with skull and title */}
+
+
+
+ Sterbend
+
+
+
+ {/* Dying value indicators - larger */}
+
+ {Array.from({ length: deathThreshold }).map((_, i) => (
+
+ {i < dyingValue && }
+
+ ))}
+
+
+ {/* Info row */}
+
+
+ Genesungswurf SG {10 + dyingValue}
+
+
+ Tod bei {deathThreshold}
+
+
+
+ {/* Recovery Check Button - Prominent */}
+ {onRecoveryCheck && (
+
+ )}
+
+ )}
+
+ {/* Wounded & Doomed Status Bar */}
+ {(woundedValue > 0 || doomedValue > 0) && (
+
+ {woundedValue > 0 && (
+
+
+ Verwundet {woundedValue}
+
+ )}
+ {doomedValue > 0 && (
+
+
+
Verdammt {doomedValue}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/client/src/features/characters/components/hp-control.tsx b/client/src/features/characters/components/hp-control.tsx
index 11bc301..b1ee704 100644
--- a/client/src/features/characters/components/hp-control.tsx
+++ b/client/src/features/characters/components/hp-control.tsx
@@ -1,17 +1,33 @@
import { useState } from 'react';
-import { Heart, Swords, Sparkles, X } from 'lucide-react';
+import { Heart, Swords, Sparkles, X, Skull, RotateCcw } from 'lucide-react';
import { Button, Card, CardContent, Input } from '@/shared/components/ui';
+import { DyingIndicator } from './dying-indicator';
interface HpControlProps {
hpCurrent: number;
hpMax: number;
hpTemp?: number;
+ dyingValue?: number;
+ woundedValue?: number;
+ doomedValue?: number;
onHpChange: (newHp: number) => Promise;
+ onRecoveryCheck?: () => void;
+ onRevive?: () => void;
}
type Mode = 'view' | 'damage' | 'heal' | 'direct';
-export function HpControl({ hpCurrent, hpMax, hpTemp = 0, onHpChange }: HpControlProps) {
+export function HpControl({
+ hpCurrent,
+ hpMax,
+ hpTemp = 0,
+ dyingValue = 0,
+ woundedValue = 0,
+ doomedValue = 0,
+ onHpChange,
+ onRecoveryCheck,
+ onRevive,
+}: HpControlProps) {
const [mode, setMode] = useState('view');
const [pendingChange, setPendingChange] = useState(0);
const [directValue, setDirectValue] = useState(hpCurrent);
@@ -85,6 +101,73 @@ export function HpControl({ hpCurrent, hpMax, hpTemp = 0, onHpChange }: HpContro
// View Mode - Main Display
if (mode === 'view') {
+ const deathThreshold = 4 - doomedValue;
+ const isDead = dyingValue >= deathThreshold;
+ const isDying = dyingValue > 0 && !isDead;
+
+ // When dead, show death overlay
+ if (isDead) {
+ return (
+
+
+
+ {/* Death Icon */}
+
+
+
+
+ {/* Death Text */}
+
Tot
+
+ Dieser Charakter ist gestorben.
+
+
+ {/* Revive Button */}
+ {onRevive && (
+
+ )}
+
+
+
+ );
+ }
+
+ // When dying, show a completely different view focused on recovery
+ if (isDying) {
+ return (
+
+
+ {/* Dying State - Full Width */}
+
+
+ {/* Heal Button - Primary action when dying */}
+
+
+
+
+
+ );
+ }
+
return (
@@ -113,6 +196,17 @@ export function HpControl({ hpCurrent, hpMax, hpTemp = 0, onHpChange }: HpContro
+ {/* Wounded/Doomed indicators (when not dying) */}
+ {(woundedValue > 0 || doomedValue > 0) && (
+
+
+
+ )}
+
{/* Quick Action Buttons */}