394 lines
16 KiB
TypeScript
394 lines
16 KiB
TypeScript
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
|