You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
217 lines
7.1 KiB
TypeScript
217 lines
7.1 KiB
TypeScript
import { type CSSProperties } from 'react';
|
|
import type { HeroState } from '../game/types';
|
|
import { BuffType, DebuffType, type ActiveBuff, type ActiveDebuff } from '../game/types';
|
|
import { DEBUFF_COLORS } from '../shared/constants';
|
|
import { HPBar } from './HPBar';
|
|
import { useT, type Translations } from '../i18n';
|
|
|
|
function buffLabel(tr: Translations, type: BuffType): string {
|
|
const map: Record<BuffType, string> = {
|
|
[BuffType.Rush]: tr.buffRush,
|
|
[BuffType.Rage]: tr.buffRage,
|
|
[BuffType.Shield]: tr.buffShield,
|
|
[BuffType.Luck]: tr.buffLuck,
|
|
[BuffType.Resurrection]: tr.buffResurrection,
|
|
[BuffType.Heal]: tr.buffHeal,
|
|
[BuffType.PowerPotion]: tr.buffPowerPotion,
|
|
[BuffType.WarCry]: tr.buffWarCry,
|
|
};
|
|
return map[type] ?? type;
|
|
}
|
|
|
|
function debuffLabel(tr: Translations, type: DebuffType): string {
|
|
const map: Record<DebuffType, string> = {
|
|
[DebuffType.Poison]: tr.debuffPoison,
|
|
[DebuffType.Freeze]: tr.debuffFreeze,
|
|
[DebuffType.Burn]: tr.debuffBurn,
|
|
[DebuffType.Stun]: tr.debuffStun,
|
|
[DebuffType.Slow]: tr.debuffSlow,
|
|
[DebuffType.Weaken]: tr.debuffWeaken,
|
|
[DebuffType.IceSlow]: tr.debuffIceSlow,
|
|
};
|
|
return map[type] ?? type;
|
|
}
|
|
|
|
const bodyStyle: CSSProperties = {
|
|
padding: '8px 10px 10px',
|
|
fontSize: 11,
|
|
color: '#ccc',
|
|
};
|
|
|
|
const statRow: CSSProperties = {
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
padding: '2px 0',
|
|
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
|
};
|
|
|
|
const sectionTitle: CSSProperties = {
|
|
fontSize: 10,
|
|
fontWeight: 700,
|
|
color: '#888',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 0.5,
|
|
marginTop: 10,
|
|
marginBottom: 4,
|
|
};
|
|
|
|
const fxChip: CSSProperties = {
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: 4,
|
|
padding: '3px 8px',
|
|
borderRadius: 6,
|
|
fontSize: 10,
|
|
fontWeight: 600,
|
|
marginRight: 4,
|
|
marginBottom: 4,
|
|
color: '#fff',
|
|
textShadow: '0 1px 2px rgba(0,0,0,0.7)',
|
|
};
|
|
|
|
|
|
function liveBuffRemaining(b: ActiveBuff, nowMs: number): number {
|
|
if (b.expiresAtMs != null) return Math.max(0, b.expiresAtMs - nowMs);
|
|
return b.remainingMs;
|
|
}
|
|
|
|
function liveDebuffRemaining(d: ActiveDebuff, nowMs: number): number {
|
|
if (d.expiresAtMs != null) return Math.max(0, d.expiresAtMs - nowMs);
|
|
return d.remainingMs;
|
|
}
|
|
|
|
interface HeroPanelProps {
|
|
hero: HeroState;
|
|
nowMs: number;
|
|
}
|
|
|
|
function hasActiveBuff(buffs: ActiveBuff[], type: BuffType, nowMs: number): boolean {
|
|
return buffs.some((b) => b.type === type && liveBuffRemaining(b, nowMs) > 0);
|
|
}
|
|
|
|
function hasActiveDebuff(debuffs: ActiveDebuff[], type: DebuffType, nowMs: number): boolean {
|
|
return debuffs.some((d) => d.type === type && liveDebuffRemaining(d, nowMs) > 0);
|
|
}
|
|
|
|
const buffedColor = '#44ff88';
|
|
const nerfedColor = '#ff6666';
|
|
|
|
function StatValue({ value, label, buffed, nerfed }: { value: string; label: string; buffed: boolean; nerfed: boolean }) {
|
|
const color = buffed ? buffedColor : nerfed ? nerfedColor : '#ccc';
|
|
const arrow = buffed ? ' \u25B2' : nerfed ? ' \u25BC' : '';
|
|
return (
|
|
<div style={statRow}>
|
|
<span>{label}</span>
|
|
<span style={{ color, transition: 'color 200ms ease' }}>
|
|
{value}{arrow}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
/** Full hero stats block (Stats sheet tab). */
|
|
export function HeroStatsContent({ hero, nowMs }: HeroPanelProps) {
|
|
const tr = useT();
|
|
const buffsLive = hero.activeBuffs
|
|
.map((b) => ({
|
|
...b,
|
|
remainingMs: liveBuffRemaining(b, nowMs),
|
|
}))
|
|
.filter((b) => b.remainingMs > 0);
|
|
|
|
const debuffsLive = hero.debuffs
|
|
.map((d) => ({
|
|
...d,
|
|
remainingMs: liveDebuffRemaining(d, nowMs),
|
|
}))
|
|
.filter((d) => d.remainingMs > 0);
|
|
|
|
const atkBuffed = hasActiveBuff(hero.activeBuffs, BuffType.Rage, nowMs)
|
|
|| hasActiveBuff(hero.activeBuffs, BuffType.PowerPotion, nowMs);
|
|
const atkNerfed = hasActiveDebuff(hero.debuffs, DebuffType.Weaken, nowMs);
|
|
const spdBuffed = hasActiveBuff(hero.activeBuffs, BuffType.WarCry, nowMs);
|
|
const spdNerfed = hasActiveDebuff(hero.debuffs, DebuffType.Freeze, nowMs)
|
|
|| hasActiveDebuff(hero.debuffs, DebuffType.IceSlow, nowMs);
|
|
const defBuffed = hasActiveBuff(hero.activeBuffs, BuffType.Shield, nowMs);
|
|
|
|
return (
|
|
<div style={bodyStyle}>
|
|
{/* Combat Stats */}
|
|
<StatValue label={tr.atk} value={String(hero.damage)} buffed={atkBuffed} nerfed={atkNerfed} />
|
|
<StatValue label={tr.def} value={String(hero.defense)} buffed={defBuffed} nerfed={false} />
|
|
<StatValue label={tr.spd} value={`${hero.attackSpeed.toFixed(2)}/s`} buffed={spdBuffed} nerfed={spdNerfed} />
|
|
<StatValue
|
|
label={tr.moveSpd}
|
|
value={`${(hero.moveSpeed ?? 1).toFixed(2)}x`}
|
|
buffed={hasActiveBuff(hero.activeBuffs, BuffType.Rush, nowMs)}
|
|
nerfed={hasActiveDebuff(hero.debuffs, DebuffType.Slow, nowMs)}
|
|
/>
|
|
<div style={statRow}><span>{tr.str}</span><span>{hero.strength}</span></div>
|
|
<div style={statRow}><span>{tr.con}</span><span>{hero.constitution}</span></div>
|
|
<div style={statRow}><span>{tr.agi}</span><span>{hero.agility}</span></div>
|
|
<div style={statRow}><span>{tr.luck}</span><span>{hero.luck}</span></div>
|
|
|
|
{/* XP */}
|
|
<div style={{ ...sectionTitle, marginTop: 12 }}>{tr.experience}</div>
|
|
<div style={{ marginBottom: 2 }}>
|
|
<HPBar
|
|
current={hero.xp}
|
|
max={Math.max(1, hero.xpToNext)}
|
|
color="#44aaff"
|
|
height={12}
|
|
showText
|
|
label={tr.xp}
|
|
/>
|
|
</div>
|
|
|
|
{/* Buffs */}
|
|
<div style={sectionTitle}>{tr.activeBuffs}</div>
|
|
{buffsLive.length === 0 ? (
|
|
<div style={{ opacity: 0.55, fontStyle: 'italic' }}>{tr.none}</div>
|
|
) : (
|
|
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
|
|
{buffsLive.map((b) => (
|
|
<span
|
|
key={b.type}
|
|
style={{
|
|
...fxChip,
|
|
backgroundColor: 'rgba(68, 170, 255, 0.35)',
|
|
border: '1px solid rgba(68, 170, 255, 0.6)',
|
|
}}
|
|
>
|
|
{buffLabel(tr, b.type)}
|
|
<span style={{ opacity: 0.9 }}>{Math.ceil(b.remainingMs / 1000)}s</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Debuffs */}
|
|
<div style={sectionTitle}>{tr.activeDebuffs}</div>
|
|
{debuffsLive.length === 0 ? (
|
|
<div style={{ opacity: 0.55, fontStyle: 'italic' }}>{tr.none}</div>
|
|
) : (
|
|
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
|
|
{debuffsLive.map((d) => {
|
|
const color = DEBUFF_COLORS[d.type] ?? '#999';
|
|
return (
|
|
<span
|
|
key={d.type}
|
|
style={{
|
|
...fxChip,
|
|
backgroundColor: `${color}44`,
|
|
border: `1px solid ${color}99`,
|
|
}}
|
|
>
|
|
{debuffLabel(tr, d.type)}
|
|
<span style={{ opacity: 0.9 }}>{Math.ceil(d.remainingMs / 1000)}s</span>
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|