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

394 lines
16 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 { useTranslation } from 'react-i18next'
import type { CombatState, CombatLogEntry, Profile, Equipment, PlayerState } from './types'
import { getTranslatedText } from '../../utils/i18nUtils'
interface CombatViewProps {
combatState: CombatState
combatLog: CombatLogEntry[]
profile: Profile | null
playerState: PlayerState | null
equipment: Equipment
enemyName: string
enemyImage: string
enemyTurnMessage: string
pvpTimeRemaining: number | null
turnTimeRemaining: number | null
onCombatAction: (action: string) => void
onFlee: () => void
onPvPAction: (action: string) => void
onExitCombat: () => void
onExitPvPCombat: () => void
flashEnemy?: boolean
buttonsDisabled?: boolean
floatingTexts?: { id: number, text: string, x: number, y: number, type: 'damage-player' | 'damage-enemy' | 'damage-player-dealt' | 'heal' }[]
}
function CombatView({
combatState,
combatLog,
profile: _profile,
playerState,
enemyName,
enemyImage,
enemyTurnMessage,
pvpTimeRemaining,
turnTimeRemaining,
onCombatAction,
onPvPAction,
onExitCombat,
onExitPvPCombat,
flashEnemy,
buttonsDisabled,
floatingTexts = []
}: CombatViewProps) {
const { t } = useTranslation()
const displayEnemyName = typeof enemyName === 'object' ? getTranslatedText(enemyName) : (enemyName || 'Enemy')
// Render structured combat messages
const renderCombatMessage = (msg: any) => {
// Support both old string format and new structured format
if (typeof msg === 'string') {
return msg // Legacy format
}
if (!msg || !msg.type) {
return String(msg)
}
const { type, data } = msg
switch (type) {
case 'combat_start':
return t('combat.messages.combat_start', { enemy: getTranslatedText(data.npc_name) })
case 'player_attack':
return t('combat.messages.player_attack', { damage: data.damage })
case 'enemy_attack':
return t('combat.messages.enemy_attack', {
enemy: getTranslatedText(data.npc_name),
damage: data.damage
})
case 'victory':
return t('combat.messages.victory', { enemy: getTranslatedText(data.npc_name) })
case 'flee_fail':
return t('combat.messages.flee_fail', {
enemy: getTranslatedText(data.npc_name),
damage: data.damage
})
default:
return JSON.stringify(msg)
}
}
return (
<div className="combat-view">
<div className="combat-header-inline">
<h2>
{combatState.is_pvp ? `⚔️ ${t('combat.title')} - PvP` : `⚔️ ${t('combat.title')} - ${displayEnemyName}`}
</h2>
</div>
{combatState.is_pvp ? (
/* PvP Combat UI - Unified Layout */
<div className="combat-content-wrapper">
<div className="combat-enemy-display-inline">
{/* Opponent Display (using same structure as PvE Enemy) */}
<div className="combat-enemy-image-large">
<div className="floating-texts-container">
{floatingTexts.map(ft => (
<div
key={ft.id}
className={`floating-text ${ft.type}`}
style={{ left: `${ft.x}%`, top: `${ft.y}%` }}>
{ft.text}
</div>
))}
</div>
{(() => {
if (!combatState.pvp_combat) return null
const opponent = combatState.pvp_combat.is_attacker ?
combatState.pvp_combat.defender :
combatState.pvp_combat.attacker
if (!opponent) return <div className="pvp-opponent-avatar"></div>
// Use a default avatar if no image, or maybe the class image if available?
// For now, let's use a placeholder or try to get it from profile if passed?
// The opponent object has: username, level, hp, max_hp.
// It might not have an image url.
return (
<div className="pvp-opponent-avatar" style={{ fontSize: '4rem', textAlign: 'center' }}>
👤
<div style={{ fontSize: '1rem', marginTop: '0.5rem' }}>{opponent.username} (Lv. {opponent.level})</div>
</div>
)
})()}
</div>
<div className="combat-enemy-info-inline">
{/* Opponent HP Bar */}
{(() => {
if (!combatState.pvp_combat) return null
const opponent = combatState.pvp_combat.is_attacker ?
combatState.pvp_combat.defender :
combatState.pvp_combat.attacker
if (!opponent) return null
return (
<div className="combat-hp-bar-container-inline enemy-hp-bar">
<div className="combat-hp-bar-inline">
<div className="combat-stat-label-inline">
{opponent.username}: {opponent.hp} / {opponent.max_hp}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${(opponent.hp / opponent.max_hp) * 100}%`
}}
/>
</div>
</div>
)
})()}
{/* Player HP Bar */}
{(() => {
if (!combatState.pvp_combat) return null
const you = combatState.pvp_combat.is_attacker ?
combatState.pvp_combat.attacker :
combatState.pvp_combat.defender
if (!you) return null
return (
<div className="combat-hp-bar-container-inline player-hp-bar" style={{ marginTop: '0.75rem' }}>
<div className="combat-hp-bar-inline" style={{ background: 'rgba(255, 107, 107, 0.2)' }}>
<div className="combat-stat-label-inline" style={{ color: '#ff6b6b' }}>
You: {you.hp} / {you.max_hp}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${(you.hp / you.max_hp) * 100}%`,
background: 'linear-gradient(90deg, #f44336, #ff6b6b)'
}}
/>
</div>
</div>
)
})()}
</div>
</div>
<div className="combat-turn-indicator-inline">
{combatState.pvp_combat.combat_over ? (
<span className={combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? "your-turn" : "enemy-turn"}>
{combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? "🏃 Combat Ended" : "💀 Combat Over"}
</span>
) : combatState.pvp_combat.your_turn ? (
<span className="your-turn"> Your Turn ({pvpTimeRemaining ?? combatState.pvp_combat.time_remaining}s)</span>
) : (
<span className="enemy-turn"> Opponent's Turn ({pvpTimeRemaining ?? combatState.pvp_combat.time_remaining}s)</span>
)}
</div>
<div className="combat-actions-inline">
{!combatState.pvp_combat.combat_over ? (
<>
<button
className="combat-action-btn attack-btn"
onClick={() => onPvPAction('attack')}
disabled={!combatState.pvp_combat.your_turn || buttonsDisabled}
>
{t('game.attack')}
</button>
<button
className="combat-action-btn flee-btn"
onClick={() => onPvPAction('flee')}
disabled={!combatState.pvp_combat.your_turn || buttonsDisabled}
>
{t('game.flee')}
</button>
</>
) : (
<button
className="combat-action-btn exit-btn"
onClick={onExitPvPCombat}
>
{combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? ' Continue' : '💀 Return'}
</button>
)}
</div>
{/* Combat Log */}
<div className="combat-log-wrapper">
<h3 className="combat-log-title">{t('combat.combatLog')}</h3>
<div className="combat-log-inline">
<div className="log-entries">
<div className="log-list">
{combatLog.length > 0 ? (
combatLog.map((entry: any) => (
<div key={entry.id} className={`log-entry ${entry.isPlayer ? 'player-log' : 'enemy-log'}`}>
<span className="log-time">[{entry.time}]</span>
<span className="log-message">{renderCombatMessage(entry.message)}</span>
</div>
))
) : (
<div className="log-entry"><span className="log-message">PvP Combat started...</span></div>
)}
</div>
</div>
</div>
</div>
</div>
) : (
/* PvE Combat UI */
<>
<div className="combat-content-wrapper">
<div className="combat-enemy-display-inline">
{/* Intent Bubble - Moved here to avoid overflow:hidden clipping */}
{combatState.combat?.npc_intent && !combatState.combat_over && (
<div className={`intent-bubble intent-${combatState.combat.npc_intent}`}>
<span className="intent-icon">
{combatState.combat.npc_intent === 'attack' ? '' :
combatState.combat.npc_intent === 'defend' ? '🛡' :
combatState.combat.npc_intent === 'special' ? '🔥' : ''}
</span>
<span className="intent-desc">{combatState.combat.npc_intent}</span>
</div>
)}
<div className="combat-enemy-image-large">
<div className="floating-texts-container">
{floatingTexts.map(ft => (
<div
key={ft.id}
className={`floating-text ${ft.type}`}
style={{ left: `${ft.x}%`, top: `${ft.y}%` }}>
{ft.text}
</div>
))}
</div>
<img
src={enemyImage || combatState.combat?.npc_image || combatState.combat_image}
alt={enemyName || combatState.combat?.npc_name || 'Enemy'}
className={`${flashEnemy ? 'flash-hit' : ''
} ${combatState.combat_over && combatState.player_won ? 'enemy-dead' : ''
} ${combatState.combat_over && combatState.player_fled ? 'enemy-fled' : ''
}`}
/>
</div>
<div className="combat-enemy-info-inline">
<div className="combat-hp-bar-container-inline enemy-hp-bar">
<div className="combat-hp-bar-inline">
<div className="combat-stat-label-inline">
{t('combat.enemyHp')}: {combatState.combat?.npc_hp || 0} / {combatState.combat?.npc_max_hp || 100}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${((combatState.combat?.npc_hp || 0) / (combatState.combat?.npc_max_hp || 100)) * 100}%`
}}
/>
</div>
</div>
{playerState && (
<div className="combat-hp-bar-container-inline player-hp-bar" style={{ marginTop: '0.75rem' }}>
<div className="combat-hp-bar-inline" style={{ background: 'rgba(255, 107, 107, 0.2)' }}>
<div className="combat-stat-label-inline" style={{ color: '#ff6b6b' }}>
{t('combat.playerHp')}: {playerState.health} / {playerState.max_health}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${(playerState.health / playerState.max_health) * 100}%`,
background: 'linear-gradient(90deg, #f44336, #ff6b6b)'
}}
/>
</div>
</div>
)}
</div>
</div>
<div className={`combat-turn-indicator-inline ${enemyTurnMessage ? 'enemy-turn-message' : ''}`}>
{!combatState.combat_over ? (
enemyTurnMessage ? (
<span className="enemy-turn">🗡️ Enemy's turn...</span>
) : combatState.combat?.turn === 'player' ? (
<>
<span className="your-turn"> {t('combat.yourTurn')}</span>
{turnTimeRemaining !== null && (
<span className="turn-timer" style={{ marginLeft: '1rem', fontSize: '0.9em', opacity: 0.8 }}>
{Math.floor(turnTimeRemaining / 60)}:{String(Math.floor(turnTimeRemaining % 60)).padStart(2, '0')}
</span>
)}
</>
) : (
<span className="enemy-turn"> {t('combat.enemyTurn')}</span>
)
) : (
<span className={combatState.player_won ? "your-turn" : combatState.player_fled ? "your-turn" : "enemy-turn"}>
{combatState.player_won ? `${t('combat.victory')}` : combatState.player_fled ? `🏃 ${t('combat.fleeSuccess')}` : `💀 ${t('combat.defeat')}`}
</span>
)}
</div>
{/* PvE Combat Actions */}
<div className="combat-actions-inline">
{!combatState.combat_over ? (
<>
<button
className="combat-action-btn attack-btn"
onClick={() => onCombatAction('attack')}
disabled={combatState.combat?.turn !== 'player' || !!enemyTurnMessage || buttonsDisabled}
>
{t('game.attack')}
</button>
<button
className="combat-action-btn flee-btn"
onClick={() => onCombatAction('flee')}
disabled={combatState.combat?.turn !== 'player' || !!enemyTurnMessage || buttonsDisabled}
>
{t('game.flee')}
</button>
</>
) : (
<button
className="combat-action-btn exit-btn"
onClick={onExitCombat}
>
{combatState.player_won ? '✅ Continue' : combatState.player_fled ? '✅ Continue' : '💀 Return'}
</button>
)}
</div>
{/* Combat Log */}
<div className="combat-log-wrapper">
<h3 className="combat-log-title">{t('combat.combatLog')}</h3>
<div className="combat-log-inline">
<div className="log-entries">
<div className="log-list">
{combatLog.length > 0 ? (
combatLog.map((entry: any) => (
<div key={entry.id} className={`log-entry ${entry.isPlayer ? 'player-log' : 'enemy-log'}`}>
<span className="log-time">[{entry.time}]</span>
<span className="log-message">{renderCombatMessage(entry.message)}</span>
</div>
))
) : (
<div className="log-entry"><span className="log-message">Combat started...</span></div>
)}
</div>
</div>
</div>
</div>
</div>
</>
)}
</div>
)
}
export default CombatView