413 lines
18 KiB
TypeScript
413 lines
18 KiB
TypeScript
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>
|
||
);
|
||
}
|