feat(frontend): UI polishing, Character Sheet redesign, and translation updates
This commit is contained in:
412
pwa/src/components/game/CharacterSheet.tsx
Normal file
412
pwa/src/components/game/CharacterSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user