Files
echoes-of-the-ash/pwa/src/components/game/CharacterSheet.tsx

413 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { getTranslatedText } from '../../utils/i18nUtils';
import api from '../../services/api';
import { GameModal } from './GameModal';
import { GameProgressBar } from '../common/GameProgressBar';
import { GameButton } from '../common/GameButton';
import './CharacterSheet.css';
interface CharacterSheetProps {
onClose: () => void;
onSpendPoint: (stat: string) => void;
}
interface DerivedStats {
attack_power: number;
crit_chance: number;
crit_damage: number;
dodge_chance: number;
flee_chance_base: number;
max_hp: number;
max_stamina: number;
total_armor: number;
armor_reduction: number;
block_chance: number;
status_resistance: number;
item_effectiveness: number;
xp_bonus: number;
loot_quality: number;
crafting_bonus: number;
carry_weight: number;
weapon_damage_min: number;
weapon_damage_max: number;
has_shield: boolean;
}
interface SkillData {
id: string;
name: any;
description: any;
icon: string;
stat_requirement: string;
stat_threshold: number;
level_requirement: number;
cooldown: number;
stamina_cost: number;
unlocked: boolean;
}
interface PerkData {
id: string;
name: any;
description: any;
icon: string;
requirements: Record<string, number>;
effects: Record<string, any>;
meets_requirements: boolean;
owned: boolean;
}
interface CharacterSheetData {
base_stats: {
strength: number;
agility: number;
endurance: number;
intellect: number;
unspent_points: number;
stat_cap: number;
};
derived_stats: DerivedStats;
skills: SkillData[];
perks: {
available_points: number;
total_points: number;
used_points: number;
all_perks: PerkData[];
};
character: {
name: string;
level: number;
xp: number;
hp: number;
max_hp: number;
stamina: number;
max_stamina: number;
avatar_data?: string;
};
}
const STAT_ICONS: Record<string, string> = {
strength: '💪',
agility: '🏃',
endurance: '🫀',
intellect: '🧠',
};
const STAT_COLORS: Record<string, string> = {
strength: '#e74c3c',
agility: '#2ecc71',
endurance: '#f39c12',
intellect: '#3498db',
};
export function CharacterSheet({ onClose, onSpendPoint }: CharacterSheetProps) {
const { t } = useTranslation();
const [data, setData] = useState<CharacterSheetData | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'stats' | 'skills' | 'perks'>('stats');
const [selectingPerk, setSelectingPerk] = useState(false);
const fetchSheet = async () => {
try {
const res = await api.get('/api/game/character-sheet');
setData(res.data);
} catch (err) {
console.error('Failed to fetch character sheet:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchSheet();
}, []);
const handleSpendPoint = async (stat: string) => {
onSpendPoint(stat);
// Refetch after a short delay to get updated derived stats
setTimeout(fetchSheet, 500);
};
const handleSelectPerk = async (perkId: string) => {
setSelectingPerk(true);
try {
await api.post(`/api/game/select_perk?perk_id=${perkId}`);
await fetchSheet();
} catch (err: any) {
console.error('Failed to select perk:', err.response?.data?.detail || err.message);
} finally {
setSelectingPerk(false);
}
};
if (loading || !data) {
return (
<GameModal title={t('characterSheet.title', 'Character Sheet')} onClose={onClose} className="character-sheet-modal">
<div className="cs-loading"><span></span> {t('common.loading', 'Loading...')}</div>
</GameModal>
);
}
const { base_stats, derived_stats, skills, perks, character } = data;
const renderStatsTab = () => (
<div className="cs-stats-tab">
{/* Base Stats Section */}
<div className="cs-stats-base-col">
<div className="cs-section">
<h4 className="cs-section-title">{t('characterSheet.baseStats', 'Base Stats')}</h4>
{/* Vitals Moved Here */}
<div className="cs-vitals" style={{ marginBottom: '1.5rem', display: 'flex', flexDirection: 'column', gap: '0.8rem' }}>
<GameProgressBar
value={character.hp}
max={character.max_hp}
type="health"
showText={true}
height="12px"
label={t('stats.hpMax')}
/>
<GameProgressBar
value={character.stamina}
max={character.max_stamina}
type="stamina"
showText={true}
height="12px"
label={t('stats.stmMax')}
/>
<GameProgressBar
value={character.xp}
max={character.level * 100}
type="xp"
showText={true}
height="12px"
label={t('stats.xp')}
/>
</div>
{base_stats.unspent_points > 0 && (
<div className="cs-unspent-badge">
<span></span> {base_stats.unspent_points} {t('characterSheet.pointsAvailable', 'points available')}
</div>
)}
<div className="cs-base-stats-grid">
{(['strength', 'agility', 'endurance', 'intellect'] as const).map(stat => (
<div key={stat} className="cs-stat-row">
<span className="cs-stat-icon"><span>{STAT_ICONS[stat]}</span></span>
<span className="cs-stat-name">{t(`stats.${stat}Full`)}</span>
<div className="cs-stat-bar-wrap">
<GameProgressBar
value={base_stats[stat]}
max={base_stats.stat_cap}
type="durability"
customColor={STAT_COLORS[stat]}
showText={true}
height="10px"
/>
</div>
{base_stats.unspent_points > 0 && base_stats[stat] < base_stats.stat_cap && (
<button className="cs-plus-btn" onClick={() => handleSpendPoint(stat)}>+</button>
)}
</div>
))}
</div>
</div>
</div>
{/* Derived Stats Section */}
<div className="cs-stats-derived-col">
<div className="cs-section">
<h4 className="cs-section-title">{t('characterSheet.derivedStats', 'Derived Stats')}</h4>
<div className="cs-derived-grid">
<DerivedStatRow icon="⚔️" label={t('characterSheet.attackPower', 'Attack Power')} value={derived_stats.attack_power} />
<DerivedStatRow icon="🎯" label={t('characterSheet.critChance', 'Crit Chance')} value={`${(derived_stats.crit_chance * 100).toFixed(1)}%`} />
<DerivedStatRow icon="💥" label={t('characterSheet.critDamage', 'Crit Damage')} value={`${derived_stats.crit_damage}x`} />
<DerivedStatRow icon="🏃" label={t('characterSheet.dodgeChance', 'Dodge Chance')} value={`${(derived_stats.dodge_chance * 100).toFixed(1)}%`} />
<DerivedStatRow icon="💨" label={t('characterSheet.fleeChance', 'Flee Chance')} value={`${(derived_stats.flee_chance_base * 100).toFixed(0)}%`} />
<DerivedStatRow icon="❤️" label={t('characterSheet.maxHp', 'Max HP')} value={derived_stats.max_hp} />
<DerivedStatRow icon="⚡" label={t('characterSheet.maxStamina', 'Max Stamina')} value={derived_stats.max_stamina} />
<DerivedStatRow icon="🛡️" label={t('characterSheet.armor', 'Armor')} value={`${derived_stats.total_armor} (${(derived_stats.armor_reduction * 100).toFixed(1)}%)`} />
<DerivedStatRow icon="🧱" label={t('characterSheet.blockChance', 'Block Chance')} value={`${(derived_stats.block_chance * 100).toFixed(1)}%`} />
<DerivedStatRow icon="🧬" label={t('characterSheet.statusResist', 'Status Resist')} value={`${(derived_stats.status_resistance * 100).toFixed(0)}%`} />
<DerivedStatRow icon="💊" label={t('characterSheet.itemEffect', 'Item Effectiveness')} value={`${(derived_stats.item_effectiveness * 100).toFixed(0)}%`} />
<DerivedStatRow icon="📈" label={t('characterSheet.xpBonus', 'XP Bonus')} value={`${(derived_stats.xp_bonus * 100).toFixed(0)}%`} />
<DerivedStatRow icon="🎲" label={t('characterSheet.lootQuality', 'Loot Quality')} value={`${(derived_stats.loot_quality * 100).toFixed(1)}%`} />
<DerivedStatRow icon="🔨" label={t('characterSheet.craftBonus', 'Craft Bonus')} value={`${(derived_stats.crafting_bonus * 100).toFixed(0)}%`} />
<DerivedStatRow icon="🎒" label={t('characterSheet.carryWeight', 'Carry Weight')} value={`${derived_stats.carry_weight} kg`} />
</div>
</div>
</div>
</div>
);
const renderSkillsTab = () => {
const grouped: Record<string, SkillData[]> = {
strength: [],
agility: [],
endurance: [],
intellect: [],
};
skills.forEach(s => {
if (grouped[s.stat_requirement]) {
grouped[s.stat_requirement].push(s);
}
});
return (
<div className="cs-skills-tab">
{Object.entries(grouped).map(([stat, statSkills]) => (
<div key={stat} className="cs-skill-group">
<h4 className="cs-skill-group-title" style={{ color: STAT_COLORS[stat] }}>
<span>{STAT_ICONS[stat]}</span> {t(`stats.${stat}Full`)}
</h4>
<div className="cs-skill-list">
{statSkills.map(skill => (
<div key={skill.id} className={`cs-skill-card ${skill.unlocked ? 'unlocked' : 'locked'}`}>
<div className="cs-skill-header">
<span className="cs-skill-icon">{skill.icon}</span>
<span className="cs-skill-name">{getTranslatedText(skill.name)}</span>
{skill.unlocked ? (
<span className="cs-skill-badge unlocked"><span></span></span>
) : (
<span className="cs-skill-badge locked"><span>🔒</span></span>
)}
</div>
<p className="cs-skill-desc">{getTranslatedText(skill.description)}</p>
<div className="cs-skill-meta">
<span className="cs-skill-tag"><span></span> {skill.stamina_cost}</span>
<span className="cs-skill-tag"><span>🔄</span> {skill.cooldown}t</span>
<span className="cs-skill-tag req">
{t(`stats.${skill.stat_requirement}`)}: {skill.stat_threshold}
</span>
<span className="cs-skill-tag req">
{t('stats.level')}: {skill.level_requirement}
</span>
</div>
</div>
))}
</div>
</div>
))}
</div>
);
};
const renderPerksTab = () => (
<div className="cs-perks-tab">
<div className="cs-perk-points">
<span className="cs-perk-points-label"><span></span> {t('characterSheet.perkPoints', 'Perk Points')}:</span>
<span className="cs-perk-points-value">
{perks.available_points} / {perks.total_points}
</span>
<span className="cs-perk-points-hint">
({t('characterSheet.nextPerkAt', 'Next at Lv')} {((perks.used_points + perks.available_points + 1) * 5)})
</span>
</div>
<div className="cs-perk-list">
{perks.all_perks.map(perk => {
const canSelect = perk.meets_requirements && !perk.owned && perks.available_points > 0;
return (
<div key={perk.id} className={`cs-perk-card ${perk.owned ? 'owned' : ''} ${!perk.meets_requirements ? 'locked' : ''}`}>
<div className="cs-perk-header">
<span className="cs-perk-icon">{perk.icon}</span>
<div className="cs-perk-title-block">
<span className="cs-perk-name">{getTranslatedText(perk.name)}</span>
<p className="cs-perk-desc">{getTranslatedText(perk.description)}</p>
</div>
{perk.owned ? (
<span className="cs-perk-status owned"><span></span> {t('characterSheet.owned', 'Owned')}</span>
) : canSelect ? (
<GameButton
variant="primary"
size="sm"
onClick={() => handleSelectPerk(perk.id)}
disabled={selectingPerk}
>
{t('characterSheet.select', 'Select')}
</GameButton>
) : (
<span className="cs-perk-status locked"><span>🔒</span></span>
)}
</div>
<div className="cs-perk-reqs">
{Object.entries(perk.requirements).map(([key, val]) => {
const isMax = key.endsWith('_max');
const baseKey = isMax ? key.replace('_max', '') : key;
const displayKey = ['strength', 'agility', 'endurance', 'intellect', 'level'].includes(baseKey)
? t(`stats.${baseKey}`)
: baseKey;
const currentVal = baseKey === 'level' ? character.level : ((data.base_stats as any)[baseKey] || 0);
const isMet = isMax ? currentVal <= val : currentVal >= val;
return (
<span key={key} className={`cs-perk-req ${isMet ? 'met' : 'unmet'}`}>
{displayKey} {isMax ? '≤' : '≥'} {val}
</span>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
const modalTitle = (
<div className="cs-header-content">
<span className="cs-header-title">{character.name} Lv. {character.level}</span>
</div>
);
return (
<GameModal
title={modalTitle}
onClose={onClose}
className="character-sheet-modal"
>
{/* Tab Navigation */}
<div className="cs-tabs">
<button
className={`cs-tab ${activeTab === 'stats' ? 'active' : ''}`}
onClick={() => setActiveTab('stats')}
>
<span>📊</span> {t('characterSheet.statsTab', 'Stats')}
{base_stats.unspent_points > 0 && <span className="cs-tab-badge">{base_stats.unspent_points}</span>}
</button>
<button
className={`cs-tab ${activeTab === 'skills' ? 'active' : ''}`}
onClick={() => setActiveTab('skills')}
>
<span></span> {t('characterSheet.skillsTab', 'Skills')}
</button>
<button
className={`cs-tab ${activeTab === 'perks' ? 'active' : ''}`}
onClick={() => setActiveTab('perks')}
>
<span></span> {t('characterSheet.perksTab', 'Perks')}
{perks.available_points > 0 && <span className="cs-tab-badge">{perks.available_points}</span>}
</button>
</div>
{/* Tab Content */}
<div className="cs-tab-content">
{activeTab === 'stats' && renderStatsTab()}
{activeTab === 'skills' && renderSkillsTab()}
{activeTab === 'perks' && renderPerksTab()}
</div>
</GameModal>
);
}
function DerivedStatRow({ icon, label, value }: { icon: string; label: string; value: any }) {
return (
<div className="cs-derived-row">
<span className="cs-derived-icon"><span>{icon}</span></span>
<span className="cs-derived-label">{label}</span>
<span className="cs-derived-value">{value}</span>
</div>
);
}