feat(frontend): UI polishing, Character Sheet redesign, and translation updates

This commit is contained in:
Joan
2026-02-25 10:05:14 +01:00
parent fd94387d54
commit 6f9ce8b448
30 changed files with 2151 additions and 34 deletions

View File

@@ -0,0 +1,412 @@
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>
);
}