Combat frontend rewrite: Clean architecture with structured messages, animations, and i18n

This commit is contained in:
Joan
2026-01-09 11:14:40 +01:00
parent 2875e72b20
commit f986fa18a0
8 changed files with 381 additions and 200 deletions

View File

@@ -600,7 +600,7 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
actual_damage = max(1, npc_damage - armor_absorbed) actual_damage = max(1, npc_damage - armor_absorbed)
new_player_hp = max(0, player['hp'] - actual_damage) new_player_hp = max(0, player['hp'] - actual_damage)
message += create_combat_message("enemy_attack", npc_name=npc_def.name, damage=npc_damage, armor_absorbed=armor_absorbed) message += create_combat_message("enemy_attack", origin="enemy", npc_name=npc_def.name, damage=npc_damage, armor_absorbed=armor_absorbed)
if armor_absorbed > 0: if armor_absorbed > 0:
message += f" (Armor absorbed {armor_absorbed})" message += f" (Armor absorbed {armor_absorbed})"

View File

@@ -147,7 +147,7 @@ async def initiate_combat(
await manager.send_personal_message(current_user['id'], { await manager.send_personal_message(current_user['id'], {
"type": "combat_started", "type": "combat_started",
"data": { "data": {
"message": create_combat_message("combat_start", npc_name=npc_def.name), "message": create_combat_message("combat_start", origin="neutral", npc_name=npc_def.name),
"combat": { "combat": {
"npc_id": enemy.npc_id, "npc_id": enemy.npc_id,
"npc_name": npc_def.name, "npc_name": npc_def.name,
@@ -178,7 +178,7 @@ async def initiate_combat(
return { return {
"success": True, "success": True,
"message": create_combat_message("combat_start", npc_name=npc_def.name), "message": create_combat_message("combat_start", origin="neutral", npc_name=npc_def.name),
"combat": { "combat": {
"npc_id": enemy.npc_id, "npc_id": enemy.npc_id,
"npc_name": npc_def.name, "npc_name": npc_def.name,
@@ -283,7 +283,7 @@ async def combat_action(
else: else:
# Apply damage to NPC # Apply damage to NPC
new_npc_hp = max(0, combat['npc_hp'] - damage) new_npc_hp = max(0, combat['npc_hp'] - damage)
result_message = f"You attack for {damage} damage! " result_message = create_combat_message("player_attack", origin="player", damage=damage)
# Apply weapon effects # Apply weapon effects
if weapon_effects and 'bleeding' in weapon_effects: if weapon_effects and 'bleeding' in weapon_effects:
@@ -304,7 +304,7 @@ async def combat_action(
if new_npc_hp <= 0: if new_npc_hp <= 0:
# NPC defeated # NPC defeated
result_message += create_combat_message("victory", npc_name=npc_def.name) result_message += "\n" + create_combat_message("victory", origin="neutral", npc_name=npc_def.name)
combat_over = True combat_over = True
player_won = True player_won = True
@@ -435,7 +435,7 @@ async def combat_action(
# Failed to flee, NPC attacks # Failed to flee, NPC attacks
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max) npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
new_player_hp = max(0, player['hp'] - npc_damage) new_player_hp = max(0, player['hp'] - npc_damage)
result_message = create_combat_message("flee_fail", npc_name=npc_def.name, damage=npc_damage) result_message = create_combat_message("flee_fail", origin="enemy", npc_name=npc_def.name, damage=npc_damage)
if new_player_hp <= 0: if new_player_hp <= 0:
result_message += "\nYou have been defeated!" result_message += "\nYou have been defeated!"

View File

@@ -39,18 +39,20 @@ def translate_travel_message(direction: str, location_name: str, lang: str = 'en
import json import json
def create_combat_message(message_type: str, **data) -> str: def create_combat_message(message_type: str, origin: str = "neutral", **data) -> str:
"""Create a structured combat message with type and data. """Create a structured combat message with type, origin, and data.
Args: Args:
message_type: Type of combat message (combat_start, player_attack, etc.) message_type: Type of combat message (combat_start, player_attack, etc.)
origin: Origin of the event - "player", "enemy", or "neutral"
**data: Dynamic data for the message (damage, npc_name, etc.) **data: Dynamic data for the message (damage, npc_name, etc.)
Returns: Returns:
Dictionary with 'type' and 'data' fields JSON string with 'type', 'origin', and 'data' fields
""" """
return json.dumps({ return json.dumps({
"type": message_type, "type": message_type,
"origin": origin,
"data": data "data": data
}) })

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import CombatView from './CombatView' import CombatView from './CombatView'
import { CombatState, CombatLogEntry, Profile, Equipment, PlayerState } from './types' import type { CombatState, CombatLogEntry, Profile, Equipment, PlayerState } from './types'
import type { FloatingText, CombatMessage } from './CombatTypes'
import api from '../../services/api' import api from '../../services/api'
import { getTranslatedText } from '../../utils/i18nUtils' import { getTranslatedText } from '../../utils/i18nUtils'
import './CombatEffects.css' import './CombatEffects.css'
@@ -34,95 +35,94 @@ const Combat = ({
updatePlayerState, updatePlayerState,
updateCombatState updateCombatState
}: CombatProps) => { }: CombatProps) => {
// Local state for visual effects and logic // Visual effects state
const [shake, setShake] = useState(false) const [shake, setShake] = useState(false)
const [flash, setFlash] = useState(false) const [flash, setFlash] = useState(false)
const [floatingTexts, setFloatingTexts] = useState<{ id: number, text: string, x: number, y: number, type: 'damage-player' | 'damage-enemy' | 'damage-player-dealt' | 'heal' }[]>([]) const [floatingTexts, setFloatingTexts] = useState<FloatingText[]>([])
const [processing, setProcessing] = useState(false) const [processing, setProcessing] = useState(false)
const [pvpTimer, setPvpTimer] = useState<number | null>(null)
const [localEnemyTurnMessage, setLocalEnemyTurnMessage] = useState('')
// Temporary HP state to delay player HP updates during enemy turn // Timer state
const [pvpTimer, setPvpTimer] = useState<number | null>(null)
const [turnTimeRemaining, setTurnTimeRemaining] = useState<number | null>(null)
// Enemy thinking indicator
const [enemyThinking, setEnemyThinking] = useState(false)
// Temporary HP to delay updates during enemy turn
const [tempPlayerHP, setTempPlayerHP] = useState<number | null>(null) const [tempPlayerHP, setTempPlayerHP] = useState<number | null>(null)
// Turn timer state for PvE combat // Refs for cleanup
const [turnTimeRemaining, setTurnTimeRemaining] = useState<number | null>(null)
const isMounted = useRef(true) const isMounted = useRef(true)
// Floating text ID counter to ensure unique IDs
const floatingTextIdCounter = useRef(0) const floatingTextIdCounter = useRef(0)
// Track all timeout IDs for cleanup
const floatingTextTimeouts = useRef<Set<NodeJS.Timeout>>(new Set()) const floatingTextTimeouts = useRef<Set<NodeJS.Timeout>>(new Set())
// ============================================================================
// Cleanup Effects
// ============================================================================
useEffect(() => { useEffect(() => {
return () => { return () => {
isMounted.current = false isMounted.current = false
// Cancel all pending floating text timeouts to prevent DOM manipulation errors
floatingTextTimeouts.current.forEach(timeout => clearTimeout(timeout)) floatingTextTimeouts.current.forEach(timeout => clearTimeout(timeout))
floatingTextTimeouts.current.clear() floatingTextTimeouts.current.clear()
// Clear all floating texts on unmount to prevent DOM manipulation errors
setFloatingTexts([]) setFloatingTexts([])
} }
}, []) }, [])
// Clean up floating texts when combat ends
useEffect(() => { useEffect(() => {
if (combatState.combat_over) { if (combatState.combat_over) {
// Cancel all pending timeouts immediately when combat ends
floatingTextTimeouts.current.forEach(timeout => clearTimeout(timeout)) floatingTextTimeouts.current.forEach(timeout => clearTimeout(timeout))
floatingTextTimeouts.current.clear() floatingTextTimeouts.current.clear()
// Clear all floating texts
setFloatingTexts([]) setFloatingTexts([])
} }
}, [combatState.combat_over]) }, [combatState.combat_over])
// PvP Timer Effect // ============================================================================
// Timer Effects
// ============================================================================
// PvP Timer
useEffect(() => { useEffect(() => {
if (combatState.is_pvp && combatState.pvp_combat) { if (combatState.is_pvp && combatState.pvp_combat) {
// Always set timer from server value
setPvpTimer(combatState.pvp_combat.time_remaining) setPvpTimer(combatState.pvp_combat.time_remaining)
// Run countdown locally for smooth UI
const interval = setInterval(() => { const interval = setInterval(() => {
setPvpTimer(prev => (prev && prev > 0 ? prev - 1 : 0)) setPvpTimer(prev => (prev && prev > 0 ? prev - 1 : 0))
}, 1000) }, 1000)
return () => clearInterval(interval) return () => clearInterval(interval)
} else { } else {
setPvpTimer(null) setPvpTimer(null)
} }
}, [combatState.is_pvp, combatState.pvp_combat]) }, [combatState.is_pvp, combatState.pvp_combat])
// PvE Timer Effect - Update from server-calculated time // PvE Timer - Update from server
// Reset timer whenever turn_time_remaining changes from server
useEffect(() => { useEffect(() => {
if (!combatState.is_pvp && combatState.combat?.turn === 'player' && combatState.combat?.turn_time_remaining !== undefined) { if (!combatState.is_pvp && combatState.combat?.turn === 'player' && combatState.combat?.turn_time_remaining !== undefined) {
// Always set the timer from server value to ensure it resets after each turn
setTurnTimeRemaining(combatState.combat.turn_time_remaining) setTurnTimeRemaining(combatState.combat.turn_time_remaining)
} else { } else {
setTurnTimeRemaining(null) setTurnTimeRemaining(null)
} }
}, [combatState.is_pvp, combatState.combat?.turn, combatState.combat?.turn_time_remaining]) }, [combatState.is_pvp, combatState.combat?.turn, combatState.combat?.turn_time_remaining])
// PvE Timer Countdown Effect - Decrement locally for smooth UI // PvE Timer - Countdown
useEffect(() => { useEffect(() => {
if (turnTimeRemaining !== null && turnTimeRemaining > 0) { if (turnTimeRemaining !== null && turnTimeRemaining > 0) {
const interval = setInterval(() => { const interval = setInterval(() => {
setTurnTimeRemaining(prev => prev !== null && prev > 0 ? prev - 1 : 0) setTurnTimeRemaining(prev => prev !== null && prev > 0 ? prev - 1 : 0)
}, 1000) }, 1000)
return () => clearInterval(interval) return () => clearInterval(interval)
} }
}, [turnTimeRemaining]) }, [turnTimeRemaining])
// PvE Polling Effect - Poll when timeout is imminent (< 30s) to catch background task // PvE Polling when timeout is imminent
useEffect(() => { useEffect(() => {
if (!combatState.is_pvp && turnTimeRemaining !== null && turnTimeRemaining < 30 && turnTimeRemaining >= 0) { if (!combatState.is_pvp && turnTimeRemaining !== null && turnTimeRemaining < 30 && turnTimeRemaining >= 0) {
const pollInterval = setInterval(async () => { const pollInterval = setInterval(async () => {
try { try {
// Fetch updated combat state from API
const response = await api.get('/api/game/combat') const response = await api.get('/api/game/combat')
if (response.data.in_combat && response.data.combat) { if (response.data.in_combat && response.data.combat) {
// Update combat state if turn changed (background task processed timeout)
if (response.data.combat.turn !== combatState.combat?.turn) { if (response.data.combat.turn !== combatState.combat?.turn) {
updateCombatState({ updateCombatState({
...combatState, ...combatState,
@@ -133,26 +133,46 @@ const Combat = ({
} catch (error) { } catch (error) {
console.error('Failed to poll combat state:', error) console.error('Failed to poll combat state:', error)
} }
}, 10000) // Poll every 10 seconds }, 10000)
return () => clearInterval(pollInterval) return () => clearInterval(pollInterval)
} }
}, [turnTimeRemaining, combatState, updateCombatState]) }, [turnTimeRemaining, combatState, updateCombatState])
const addFloatingText = (text: string, x: number, y: number, type: 'damage-player' | 'damage-enemy' | 'damage-player-dealt' | 'heal') => { // ============================================================================
// Helper Functions
// ============================================================================
const addFloatingText = (text: string, x: number, y: number, type: FloatingText['type']) => {
const id = ++floatingTextIdCounter.current const id = ++floatingTextIdCounter.current
setFloatingTexts(prev => [...prev, { id, text, x, y, type }]) setFloatingTexts(prev => [...prev, { id, text, x, y, type }])
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
if (isMounted.current) { if (isMounted.current) {
setFloatingTexts(prev => prev.filter(ft => ft.id !== id)) setFloatingTexts(prev => prev.filter(ft => ft.id !== id))
// Remove this timeout from the tracking set
floatingTextTimeouts.current.delete(timeout) floatingTextTimeouts.current.delete(timeout)
} }
}, 2500) }, 2500)
// Track this timeout for cleanup
floatingTextTimeouts.current.add(timeout) floatingTextTimeouts.current.add(timeout)
} }
const parseMessage = (msg: any): CombatMessage | null => {
if (typeof msg === 'string') {
try {
return JSON.parse(msg) as CombatMessage
} catch {
// Not a JSON message, return null to use as plain text
return null
}
}
return msg as CombatMessage
}
// ============================================================================
// PvE Combat Actions
// ============================================================================
const handlePvEAction = async (action: string) => { const handlePvEAction = async (action: string) => {
if (processing) return if (processing) return
setProcessing(true) setProcessing(true)
@@ -162,84 +182,50 @@ const Combat = ({
const now = new Date() const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
// Parse messages // Parse message into structured parts
const messages = data.message.split('\n').filter((m: string) => m.trim()) const messages = data.message.split('\\n').filter((m: string) => m.trim())
const playerMessages: any[] = []
const enemyMessages: any[] = []
// Handle failed flee special case - split combined message
const processedMessages: any[] = []
messages.forEach((msg: string) => { messages.forEach((msg: string) => {
// Try to parse as JSON first (for structured messages) const parsed = parseMessage(msg)
try {
// Check if it looks like a JSON object before trying to parse
if (msg.trim().startsWith('{')) {
const parsed = JSON.parse(msg)
if (parsed.type && parsed.data) {
processedMessages.push(parsed) // Push object directly
return
}
}
} catch (e) {
// Not valid JSON, treat as string
}
// Check if message contains both flee failure and enemy attack if (parsed) {
const fleeFailMatch = msg.match(/^(Failed to flee!)\s+(.+)$/) // Structured message - use origin field
if (fleeFailMatch) { if (parsed.origin === 'player') {
processedMessages.push(fleeFailMatch[1]) // "Failed to flee!" playerMessages.push(parsed)
} else if (parsed.origin === 'enemy') {
// The second part might be a JSON string too enemyMessages.push(parsed)
const secondPart = fleeFailMatch[2]
try {
if (secondPart.trim().startsWith('{')) {
const parsed = JSON.parse(secondPart)
if (parsed.type && parsed.data) {
processedMessages.push(parsed)
return
}
}
} catch (e) { }
processedMessages.push(secondPart) // Enemy attack message (string fallback)
} else { } else {
processedMessages.push(msg) // Neutral messages (victory, combat start) go to player
playerMessages.push(parsed)
}
} else {
// Legacy string message - fallback to text parsing
if (msg.includes('You ') || msg.includes('Your ') || msg === 'Failed to flee!') {
playerMessages.push(msg)
} else if (msg.includes('attacks') || msg.includes('hits') || msg.includes('misses')) {
enemyMessages.push(msg)
}
} }
}) })
const playerMessages = processedMessages.filter((msg: any) => { // 1. Process player messages immediately
if (typeof msg === 'object') {
return msg.type === 'player_attack' || msg.type === 'victory' || msg.type === 'combat_start'
}
return msg.includes('You ') || msg.includes('Your ') || msg === 'Failed to flee!'
})
const enemyMessages = processedMessages.filter((msg: any) => {
if (typeof msg === 'object') {
return msg.type === 'enemy_attack' || msg.type === 'flee_fail'
}
return msg !== 'Failed to flee!' &&
(msg.includes('attacks') || msg.includes('hits') || msg.includes('misses') || msg.includes('The '))
})
// Check if this is a failed flee attempt
const isFailedFlee = playerMessages.some(msg => msg === 'Failed to flee!')
// 1. Immediate Player Feedback
playerMessages.forEach((msg: any) => { playerMessages.forEach((msg: any) => {
const logId = `player-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` const logId = `player-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
addCombatLogEntry({ id: logId, time: timeStr, message: msg, isPlayer: true }) addCombatLogEntry({ id: logId, time: timeStr, message: msg, isPlayer: true })
// Only show attack animations for actual attacks, not flee failures // Extract damage if present
if (msg !== 'Failed to flee!' && (typeof msg !== 'object' || msg.type === 'player_attack')) { const parsed = parseMessage(msg)
const damage = typeof msg === 'object' ? msg.data.damage : (msg.match(/(\d+) damage/) || [])[1] if (parsed && parsed.type === 'player_attack' && parsed.data.damage) {
if (damage) { addFloatingText(parsed.data.damage.toString(), 50, 30, 'damage-player-dealt')
addFloatingText(damage.toString(), 50, 30, 'damage-player-dealt') // White text on enemy
setFlash(true) setFlash(true)
setTimeout(() => setFlash(false), 300) setTimeout(() => setFlash(false), 300)
} }
}
}) })
// Update Enemy HP immediately // Update enemy HP immediately
if (data.combat && !data.combat_over) { if (data.combat && !data.combat_over) {
updateCombatState({ updateCombatState({
...combatState, ...combatState,
@@ -248,39 +234,39 @@ const Combat = ({
npc_hp: data.combat.npc_hp, npc_hp: data.combat.npc_hp,
turn: data.combat.turn, turn: data.combat.turn,
turn_time_remaining: data.combat.turn_time_remaining, turn_time_remaining: data.combat.turn_time_remaining,
round: data.combat.round round: data.combat.round,
npc_intent: data.combat.npc_intent
} }
}) })
// Store current player HP to prevent it from updating during enemy turn // Store current player HP
if (data.player && playerState) { if (playerState) {
setTempPlayerHP(playerState.health) setTempPlayerHP(playerState.health)
} }
} }
// 2. Enemy Turn Delay (including failed flee) // 2. Enemy turn with delay
if ((enemyMessages.length > 0 || isFailedFlee) && !data.combat_over) { if (enemyMessages.length > 0 && !data.combat_over) {
setLocalEnemyTurnMessage(isFailedFlee ? "🗡️ Enemy's turn..." : "🗡️ Enemy's turn...") setEnemyThinking(true)
await new Promise(resolve => setTimeout(resolve, 2000)) await new Promise(resolve => setTimeout(resolve, 2000))
setEnemyThinking(false)
enemyMessages.forEach((msg: any) => { enemyMessages.forEach((msg: any) => {
const logId = `enemy-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` const logId = `enemy-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
addCombatLogEntry({ id: logId, time: timeStr, message: msg, isPlayer: false }) addCombatLogEntry({ id: logId, time: timeStr, message: msg, isPlayer: false })
const damage = typeof msg === 'object' ? msg.data.damage : (msg.match(/(\d+) damage/) || [])[1] // Extract damage if present
if (damage) { const parsed = parseMessage(msg)
addFloatingText(damage.toString(), 50, 50, 'damage-player') // Red text over player position if (parsed && (parsed.type === 'enemy_attack' || parsed.type === 'flee_fail') && parsed.data.damage) {
addFloatingText(parsed.data.damage.toString(), 50, 50, 'damage-player')
setShake(true) setShake(true)
setTimeout(() => setShake(false), 500) setTimeout(() => setShake(false), 500)
} }
}) })
setLocalEnemyTurnMessage('') // Update player HP after delay
// Update Player HP after delay completes
if (data.player && playerState) { if (data.player && playerState) {
setTempPlayerHP(null) // Clear temp HP setTempPlayerHP(null)
updatePlayerState({ updatePlayerState({
...playerState, ...playerState,
health: data.player.hp, health: data.player.hp,
@@ -288,10 +274,8 @@ const Combat = ({
}) })
} }
} else if (data.combat_over) { } else if (data.combat_over) {
// Combat ended (e.g. player won or fled) // Combat ended
const playerFled = data.message.toLowerCase().includes('fled') || const playerFled = data.message.toLowerCase().includes('fled') || data.message.toLowerCase().includes('escape')
data.message.toLowerCase().includes('escape') ||
data.player_fled === true
updateCombatState({ updateCombatState({
...combatState, ...combatState,
@@ -303,8 +287,8 @@ const Combat = ({
npc_hp: data.player_won ? 0 : (data.combat?.npc_hp ?? combatState.combat.npc_hp) npc_hp: data.player_won ? 0 : (data.combat?.npc_hp ?? combatState.combat.npc_hp)
} }
}) })
// Update player state immediately if combat is over
setTempPlayerHP(null) // Clear temp HP setTempPlayerHP(null)
if (data.player && playerState) { if (data.player && playerState) {
updatePlayerState({ updatePlayerState({
...playerState, ...playerState,
@@ -321,62 +305,32 @@ const Combat = ({
} }
} }
// ============================================================================
// PvP Combat Actions
// ============================================================================
const handlePvPActionLocal = async (action: string) => { const handlePvPActionLocal = async (action: string) => {
if (processing) return if (processing) return
setProcessing(true) setProcessing(true)
try { try {
// Call the parent handler (which calls API)
// Note: onPvPAction in Game.tsx currently returns void, but we might need the response
// We'll modify onPvPAction to return the response or we'll rely on the websocket update for state
// BUT for animations we need the immediate response if possible, OR we parse the websocket message?
// The user request says "The timer is also not updating correctly, it should grab the latest data from the websocket update or from the action call."
// So let's assume onPvPAction CAN return data if we await it.
// Checking Game.tsx: onPvPAction calls api.post and sets message. It doesn't return data.
// We need to modify Game.tsx to return the data too?
// Actually, let's just trigger the action and let the websocket handle the state update,
// BUT for "floating text for damage", we usually get that from the immediate response in PvE.
// In PvP, the response might contain the damage info.
// Let's assume onPvPAction returns the response data now (we'll fix Game.tsx if needed, or just use what we have)
// Wait, Game.tsx onPvPAction is:
// onPvPAction={async (action: string) => {
// try {
// const response = await api.post('/api/game/pvp/action', { action })
// actions.setMessage(response.data.message || 'Action performed!')
// await actions.fetchGameData()
// } ...
// }}
// It doesn't return the data to the caller.
// We will modify Combat.tsx to accept a promise that returns data, OR we modify Game.tsx to return it.
// For now, let's just call it and see if we can parse the message from the state update?
// No, animations need to happen NOW.
// Let's change onPvPAction prop signature in Combat.tsx to return Promise<any>
// and update Game.tsx to return the response.data.
const data = await onPvPAction(action) const data = await onPvPAction(action)
if (data) { if (data) {
const now = new Date() const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
// Parse message for damage
// Example: "You attacked X for 10 damage!"
const msg = data.message || '' const msg = data.message || ''
const logId = `pvp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` const logId = `pvp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
addCombatLogEntry({ id: logId, time: timeStr, message: msg, isPlayer: true }) addCombatLogEntry({ id: logId, time: timeStr, message: msg, isPlayer: true })
const damageMatch = msg.match(/(\d+) damage/) // Extract damage
const damageMatch = msg.match(/(\\d+) damage/)
if (damageMatch) { if (damageMatch) {
addFloatingText(damageMatch[1], 50, 30, 'damage-player-dealt') addFloatingText(damageMatch[1], 50, 30, 'damage-player-dealt')
setFlash(true) setFlash(true)
setTimeout(() => setFlash(false), 300) setTimeout(() => setFlash(false), 300)
} }
// If we got hit back immediately (e.g. recoil? or just turn end?)
// Usually PvP is turn based, so we wait for opponent.
} }
} catch (error) { } catch (error) {
@@ -399,7 +353,7 @@ const Combat = ({
equipment={equipment} equipment={equipment}
enemyName={getTranslatedText(combatState.combat?.npc_name) || 'Enemy'} enemyName={getTranslatedText(combatState.combat?.npc_name) || 'Enemy'}
enemyImage={combatState.combat?.npc_image || combatState.combat_image || ''} enemyImage={combatState.combat?.npc_image || combatState.combat_image || ''}
enemyTurnMessage={localEnemyTurnMessage} enemyTurnMessage={enemyThinking ? '🗡️ Enemy is thinking...' : ''}
pvpTimeRemaining={pvpTimer} pvpTimeRemaining={pvpTimer}
turnTimeRemaining={turnTimeRemaining} turnTimeRemaining={turnTimeRemaining}
onCombatAction={handlePvEAction} onCombatAction={handlePvEAction}
@@ -408,7 +362,7 @@ const Combat = ({
onExitCombat={onExitCombat} onExitCombat={onExitCombat}
onExitPvPCombat={onExitPvPCombat} onExitPvPCombat={onExitPvPCombat}
flashEnemy={flash} flashEnemy={flash}
buttonsDisabled={processing} buttonsDisabled={processing || enemyThinking}
floatingTexts={floatingTexts} floatingTexts={floatingTexts}
/> />
</div> </div>

View File

@@ -0,0 +1,154 @@
/**
* Combat Types
* TypeScript type definitions for the combat system
*/
// ============================================================================
// Combat Message Types
// ============================================================================
/**
* Structured combat message from the server
*/
export interface CombatMessage {
type: 'combat_start' | 'player_attack' | 'enemy_attack' | 'victory' | 'flee_fail' | string
origin: 'player' | 'enemy' | 'neutral'
data: {
damage?: number
npc_name?: string | { en: string; es: string }
armor_absorbed?: number
[key: string]: any
}
}
/**
* Combat log entry displayed in the UI
*/
export interface CombatLogEntry {
id: string
time: string
message: string | CombatMessage
isPlayer: boolean
}
// ============================================================================
// Animation Types
// ============================================================================
/**
* Floating damage text animation
*/
export interface FloatingText {
id: number
text: string
x: number
y: number
type: 'damage-player' | 'damage-enemy' | 'damage-player-dealt' | 'heal'
}
/**
* Animation state for combat effects
*/
export interface CombatAnimationState {
shake: boolean
flash: boolean
enemyThinking: boolean
}
// ============================================================================
// Combat State Types
// ============================================================================
/**
* PvE Combat Data
*/
export interface PvECombat {
npc_id: string
npc_name: string | { en: string; es: string }
npc_hp: number
npc_max_hp: number
npc_image: string
turn: 'player' | 'enemy'
round: number
turn_time_remaining?: number
npc_intent?: 'attack' | 'defend' | 'special'
}
/**
* PvP Combat Player Info
*/
export interface PvPCombatPlayer {
id: number
username: string
level: number
hp: number
max_hp: number
}
/**
* PvP Combat Data
*/
export interface PvPCombat {
id: number
attacker: PvPCombatPlayer
defender: PvPCombatPlayer
is_attacker: boolean
your_turn: boolean
current_turn: 'attacker' | 'defender'
time_remaining: number
location_id: string
last_action?: string
combat_over: boolean
attacker_fled: boolean
defender_fled: boolean
}
/**
* Main Combat State
*/
export interface CombatState {
// Common fields
in_combat: boolean
combat_over: boolean
player_won?: boolean
player_fled?: boolean
// PvE fields
is_pvp: boolean
combat?: PvECombat
combat_image?: string
// PvP fields
in_pvp_combat?: boolean
pvp_combat?: PvPCombat
}
// ============================================================================
// Combat Action Types
// ============================================================================
/**
* Combat action response from server
*/
export interface CombatActionResponse {
success: boolean
message: string
combat_over: boolean
player_won?: boolean
combat?: PvECombat
player?: {
hp: number
max_hp: number
xp: number
level: number
}
}
/**
* PvP Combat action response from server
*/
export interface PvPCombatActionResponse {
success: boolean
message: string
combat?: PvPCombat
}

View File

@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { CombatState, CombatLogEntry, Profile, Equipment, PlayerState } from './types' import type { CombatState, CombatLogEntry, Profile, Equipment, PlayerState } from './types'
import type { FloatingText, CombatMessage } from './CombatTypes'
import { getTranslatedText } from '../../utils/i18nUtils' import { getTranslatedText } from '../../utils/i18nUtils'
interface CombatViewProps { interface CombatViewProps {
@@ -20,7 +21,7 @@ interface CombatViewProps {
onExitPvPCombat: () => void onExitPvPCombat: () => void
flashEnemy?: boolean flashEnemy?: boolean
buttonsDisabled?: boolean buttonsDisabled?: boolean
floatingTexts?: { id: number, text: string, x: number, y: number, type: 'damage-player' | 'damage-enemy' | 'damage-player-dealt' | 'heal' }[] floatingTexts?: FloatingText[]
} }
function CombatView({ function CombatView({
@@ -44,18 +45,23 @@ function CombatView({
const { t } = useTranslation() const { t } = useTranslation()
const displayEnemyName = typeof enemyName === 'object' ? getTranslatedText(enemyName) : (enemyName || 'Enemy') const displayEnemyName = typeof enemyName === 'object' ? getTranslatedText(enemyName) : (enemyName || 'Enemy')
// Render structured combat messages // ============================================================================
// Message Rendering
// ============================================================================
const renderCombatMessage = (msg: any) => { const renderCombatMessage = (msg: any) => {
// Support both old string format and new structured format // Handle string messages
if (typeof msg === 'string') { if (typeof msg === 'string') {
return msg // Legacy format return msg
} }
// Handle legacy formatted messages
if (!msg || !msg.type) { if (!msg || !msg.type) {
return String(msg) return String(msg)
} }
const { type, data } = msg const message = msg as CombatMessage
const { type, data } = message
switch (type) { switch (type) {
case 'combat_start': case 'combat_start':
@@ -75,10 +81,21 @@ function CombatView({
damage: data.damage damage: data.damage
}) })
default: default:
// Fallback to JSON string for unknown types
return JSON.stringify(msg) return JSON.stringify(msg)
} }
} }
// ============================================================================
// Format Timer Display
// ============================================================================
const formatTimer = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${String(secs).padStart(2, '0')}`
}
return ( return (
<div className="combat-view"> <div className="combat-view">
<div className="combat-header-inline"> <div className="combat-header-inline">
@@ -88,10 +105,12 @@ function CombatView({
</div> </div>
{combatState.is_pvp ? ( {combatState.is_pvp ? (
/* PvP Combat UI - Unified Layout */ /* ================================================================ */
/* PvP Combat UI */
/* ================================================================ */
<div className="combat-content-wrapper"> <div className="combat-content-wrapper">
<div className="combat-enemy-display-inline"> <div className="combat-enemy-display-inline">
{/* Opponent Display (using same structure as PvE Enemy) */} {/* Opponent Display */}
<div className="combat-enemy-image-large"> <div className="combat-enemy-image-large">
<div className="floating-texts-container"> <div className="floating-texts-container">
{floatingTexts.map(ft => ( {floatingTexts.map(ft => (
@@ -110,14 +129,13 @@ function CombatView({
combatState.pvp_combat.attacker combatState.pvp_combat.attacker
if (!opponent) return <div className="pvp-opponent-avatar"></div> 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 ( return (
<div className="pvp-opponent-avatar" style={{ fontSize: '4rem', textAlign: 'center' }}> <div className="pvp-opponent-avatar" style={{ fontSize: '4rem', textAlign: 'center' }}>
👤 👤
<div style={{ fontSize: '1rem', marginTop: '0.5rem' }}>{opponent.username} (Lv. {opponent.level})</div> <div style={{ fontSize: '1rem', marginTop: '0.5rem' }}>
{opponent.username} (Lv. {opponent.level})
</div>
</div> </div>
) )
})()} })()}
@@ -142,7 +160,8 @@ function CombatView({
<div <div
className="combat-hp-fill-inline" className="combat-hp-fill-inline"
style={{ style={{
width: `${(opponent.hp / opponent.max_hp) * 100}%` width: `${(opponent.hp / opponent.max_hp) * 100}%`,
transition: 'width 0.5s ease-in-out'
}} }}
/> />
</div> </div>
@@ -163,13 +182,14 @@ function CombatView({
<div className="combat-hp-bar-container-inline player-hp-bar" style={{ marginTop: '0.75rem' }}> <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-hp-bar-inline" style={{ background: 'rgba(255, 107, 107, 0.2)' }}>
<div className="combat-stat-label-inline" style={{ color: '#ff6b6b' }}> <div className="combat-stat-label-inline" style={{ color: '#ff6b6b' }}>
You: {you.hp} / {you.max_hp} {t('combat.playerHp')}: {you.hp} / {you.max_hp}
</div> </div>
<div <div
className="combat-hp-fill-inline" className="combat-hp-fill-inline"
style={{ style={{
width: `${(you.hp / you.max_hp) * 100}%`, width: `${(you.hp / you.max_hp) * 100}%`,
background: 'linear-gradient(90deg, #f44336, #ff6b6b)' background: 'linear-gradient(90deg, #f44336, #ff6b6b)',
transition: 'width 0.5s ease-in-out'
}} }}
/> />
</div> </div>
@@ -182,12 +202,16 @@ function CombatView({
<div className="combat-turn-indicator-inline"> <div className="combat-turn-indicator-inline">
{combatState.pvp_combat.combat_over ? ( {combatState.pvp_combat.combat_over ? (
<span className={combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? "your-turn" : "enemy-turn"}> <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"} {combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? `🏃 ${t('combat.fleeSuccess')}` : `💀 ${t('combat.defeat')}`}
</span> </span>
) : combatState.pvp_combat.your_turn ? ( ) : combatState.pvp_combat.your_turn ? (
<span className="your-turn"> Your Turn ({pvpTimeRemaining ?? combatState.pvp_combat.time_remaining}s)</span> <span className="your-turn">
{t('combat.yourTurn')} ({formatTimer(pvpTimeRemaining ?? combatState.pvp_combat.time_remaining)})
</span>
) : ( ) : (
<span className="enemy-turn"> Opponent's Turn ({pvpTimeRemaining ?? combatState.pvp_combat.time_remaining}s)</span> <span className="enemy-turn">
{t('combat.waiting')} ({formatTimer(pvpTimeRemaining ?? combatState.pvp_combat.time_remaining)})
</span>
)} )}
</div> </div>
@@ -199,14 +223,14 @@ function CombatView({
onClick={() => onPvPAction('attack')} onClick={() => onPvPAction('attack')}
disabled={!combatState.pvp_combat.your_turn || buttonsDisabled} disabled={!combatState.pvp_combat.your_turn || buttonsDisabled}
> >
{t('game.attack')} {t('combat.actions.attack')}
</button> </button>
<button <button
className="combat-action-btn flee-btn" className="combat-action-btn flee-btn"
onClick={() => onPvPAction('flee')} onClick={() => onPvPAction('flee')}
disabled={!combatState.pvp_combat.your_turn || buttonsDisabled} disabled={!combatState.pvp_combat.your_turn || buttonsDisabled}
> >
{t('game.flee')} {t('combat.actions.flee')}
</button> </button>
</> </>
) : ( ) : (
@@ -241,17 +265,19 @@ function CombatView({
</div> </div>
</div> </div>
) : ( ) : (
/* ================================================================ */
/* PvE Combat UI */ /* PvE Combat UI */
/* ================================================================ */
<> <>
<div className="combat-content-wrapper"> <div className="combat-content-wrapper">
<div className="combat-enemy-display-inline"> <div className="combat-enemy-display-inline">
{/* Intent Bubble - Moved here to avoid overflow:hidden clipping */} {/* Enemy Intent Bubble */}
{combatState.combat?.npc_intent && !combatState.combat_over && ( {combatState.combat?.npc_intent && !combatState.combat_over && (
<div className={`intent-bubble intent-${combatState.combat.npc_intent}`}> <div className={`intent-bubble intent-${combatState.combat.npc_intent}`}>
<span className="intent-icon"> <span className="intent-icon">
{combatState.combat.npc_intent === 'attack' ? '⚔️' : {combatState.combat.npc_intent === 'attack' ? '⚔️' :
combatState.combat.npc_intent === 'defend' ? '🛡️' : combatState.combat.npc_intent === 'defend' ? '🛡️' :
combatState.combat.npc_intent === 'special' ? '🔥' : ''} combatState.combat.npc_intent === 'special' ? ' 🔥' : '❓'}
</span> </span>
<span className="intent-desc">{combatState.combat.npc_intent}</span> <span className="intent-desc">{combatState.combat.npc_intent}</span>
</div> </div>
@@ -271,12 +297,12 @@ function CombatView({
<img <img
src={enemyImage || combatState.combat?.npc_image || combatState.combat_image} src={enemyImage || combatState.combat?.npc_image || combatState.combat_image}
alt={enemyName || combatState.combat?.npc_name || 'Enemy'} alt={enemyName || combatState.combat?.npc_name || 'Enemy'}
className={`${flashEnemy ? 'flash-hit' : '' className={`${flashEnemy ? 'flash-hit' : ''}
} ${combatState.combat_over && combatState.player_won ? 'enemy-dead' : '' ${combatState.combat_over && combatState.player_won ? 'enemy-dead' : ''}
} ${combatState.combat_over && combatState.player_fled ? 'enemy-fled' : '' ${combatState.combat_over && combatState.player_fled ? 'enemy-fled' : ''}`}
}`}
/> />
</div> </div>
<div className="combat-enemy-info-inline"> <div className="combat-enemy-info-inline">
<div className="combat-hp-bar-container-inline enemy-hp-bar"> <div className="combat-hp-bar-container-inline enemy-hp-bar">
<div className="combat-hp-bar-inline"> <div className="combat-hp-bar-inline">
@@ -286,7 +312,8 @@ function CombatView({
<div <div
className="combat-hp-fill-inline" className="combat-hp-fill-inline"
style={{ style={{
width: `${((combatState.combat?.npc_hp || 0) / (combatState.combat?.npc_max_hp || 100)) * 100}%` width: `${((combatState.combat?.npc_hp || 0) / (combatState.combat?.npc_max_hp || 100)) * 100}%`,
transition: 'width 0.5s ease-in-out'
}} }}
/> />
</div> </div>
@@ -301,7 +328,8 @@ function CombatView({
className="combat-hp-fill-inline" className="combat-hp-fill-inline"
style={{ style={{
width: `${(playerState.health / playerState.max_health) * 100}%`, width: `${(playerState.health / playerState.max_health) * 100}%`,
background: 'linear-gradient(90deg, #f44336, #ff6b6b)' background: 'linear-gradient(90deg, #f44336, #ff6b6b)',
transition: 'width 0.5s ease-in-out'
}} }}
/> />
</div> </div>
@@ -313,13 +341,13 @@ function CombatView({
<div className={`combat-turn-indicator-inline ${enemyTurnMessage ? 'enemy-turn-message' : ''}`}> <div className={`combat-turn-indicator-inline ${enemyTurnMessage ? 'enemy-turn-message' : ''}`}>
{!combatState.combat_over ? ( {!combatState.combat_over ? (
enemyTurnMessage ? ( enemyTurnMessage ? (
<span className="enemy-turn">🗡️ Enemy's turn...</span> <span className="enemy-turn">{t('combat.thinking')}</span>
) : combatState.combat?.turn === 'player' ? ( ) : combatState.combat?.turn === 'player' ? (
<> <>
<span className="your-turn"> {t('combat.yourTurn')}</span> <span className="your-turn"> {t('combat.yourTurn')}</span>
{turnTimeRemaining !== null && ( {turnTimeRemaining !== null && (
<span className="turn-timer" style={{ marginLeft: '1rem', fontSize: '0.9em', opacity: 0.8 }}> <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')} {formatTimer(turnTimeRemaining)}
</span> </span>
)} )}
</> </>
@@ -334,7 +362,6 @@ function CombatView({
</div> </div>
{/* PvE Combat Actions */} {/* PvE Combat Actions */}
<div className="combat-actions-inline"> <div className="combat-actions-inline">
{!combatState.combat_over ? ( {!combatState.combat_over ? (
<> <>
@@ -343,14 +370,14 @@ function CombatView({
onClick={() => onCombatAction('attack')} onClick={() => onCombatAction('attack')}
disabled={combatState.combat?.turn !== 'player' || !!enemyTurnMessage || buttonsDisabled} disabled={combatState.combat?.turn !== 'player' || !!enemyTurnMessage || buttonsDisabled}
> >
{t('game.attack')} {t('combat.actions.attack')}
</button> </button>
<button <button
className="combat-action-btn flee-btn" className="combat-action-btn flee-btn"
onClick={() => onCombatAction('flee')} onClick={() => onCombatAction('flee')}
disabled={combatState.combat?.turn !== 'player' || !!enemyTurnMessage || buttonsDisabled} disabled={combatState.combat?.turn !== 'player' || !!enemyTurnMessage || buttonsDisabled}
> >
{t('game.flee')} {t('combat.actions.flee')}
</button> </button>
</> </>
) : ( ) : (

View File

@@ -155,6 +155,10 @@
"combatLog": "Combat Log", "combatLog": "Combat Log",
"attacking": "Attacking", "attacking": "Attacking",
"defending": "Defending", "defending": "Defending",
"thinking": "Enemy is thinking...",
"yourTurnTimer": "Your Turn ({{time}})",
"enemyTurnTimer": "Enemy Turn",
"waiting": "Waiting for opponent...",
"messages": { "messages": {
"combat_start": "Combat started with {{enemy}}!", "combat_start": "Combat started with {{enemy}}!",
"player_attack": "You attack for {{damage}} damage!", "player_attack": "You attack for {{damage}} damage!",
@@ -162,7 +166,25 @@
"victory": "Victory! Defeated {{enemy}}", "victory": "Victory! Defeated {{enemy}}",
"flee_fail": "Failed to flee! {{enemy}} attacks for {{damage}} damage!" "flee_fail": "Failed to flee! {{enemy}} attacks for {{damage}} damage!"
}, },
"turnTimer": "Turn Timer" "turnTimer": "Turn Timer",
"actions": {
"attack": "Attack",
"flee": "Flee",
"useItem": "Use Item"
},
"status": {
"attacking": "Attacking...",
"fleeing": "Fleeing...",
"waiting": "Waiting for opponent..."
},
"events": {
"playerDamage": "You dealt {{damage}} damage!",
"enemyDamage": "Enemy dealt {{damage}} damage!",
"playerMiss": "You missed!",
"enemyMiss": "Enemy missed!",
"armorAbsorbed": "Armor absorbed {{armor}} damage",
"itemBroke": "{{item}} broke!"
}
}, },
"equipment": { "equipment": {
"head": "Head", "head": "Head",

View File

@@ -156,12 +156,34 @@
"turnTimer": "Temporizador de Turno", "turnTimer": "Temporizador de Turno",
"attacking": "Atacando", "attacking": "Atacando",
"defending": "Defendiendo", "defending": "Defendiendo",
"thinking": "El enemigo está pensando...",
"yourTurnTimer": "Tu Turno ({{time}})",
"enemyTurnTimer": "Turno del Enemigo",
"waiting": "Esperando al oponente...",
"messages": { "messages": {
"combat_start": "¡Combate iniciado con {{enemy}}!", "combat_start": "¡Combate iniciado con {{enemy}}!",
"player_attack": "¡Atacas por {{damage}} de daño!", "player_attack": "¡Atacas por {{damage}} de daño!",
"enemy_attack": "{{enemy}} ataca por {{damage}} de daño!", "enemy_attack": "{{enemy}} ataca por {{damage}} de daño!",
"victory": "¡Victoria! Derrotaste a {{enemy}}", "victory": "¡Victoria! Derrotaste a {{enemy}}",
"flee_fail": "¡Fallaste al huir! {{enemy}} ataca por {{damage}} de daño!" "flee_fail": "¡Fallaste al huir! {{enemy}} ataca por {{damage}} de daño!"
},
"actions": {
"attack": "Atacar",
"flee": "Huir",
"useItem": "Usar Objeto"
},
"status": {
"attacking": "Atacando...",
"fleeing": "Huyendo...",
"waiting": "Esperando al oponente..."
},
"events": {
"playerDamage": "¡Infligiste {{damage}} de daño!",
"enemyDamage": "¡El enemigo infligió {{damage}} de daño!",
"playerMiss": "¡Fallaste!",
"enemyMiss": "¡El enemigo falló!",
"armorAbsorbed": "La armadura absorbió {{armor}} de daño",
"itemBroke": "¡{{item}} se rompió!"
} }
}, },
"equipment": { "equipment": {