Combat frontend rewrite: Clean architecture with structured messages, animations, and i18n
This commit is contained in:
@@ -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)
|
||||
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:
|
||||
message += f" (Armor absorbed {armor_absorbed})"
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ async def initiate_combat(
|
||||
await manager.send_personal_message(current_user['id'], {
|
||||
"type": "combat_started",
|
||||
"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": {
|
||||
"npc_id": enemy.npc_id,
|
||||
"npc_name": npc_def.name,
|
||||
@@ -178,7 +178,7 @@ async def initiate_combat(
|
||||
|
||||
return {
|
||||
"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": {
|
||||
"npc_id": enemy.npc_id,
|
||||
"npc_name": npc_def.name,
|
||||
@@ -283,7 +283,7 @@ async def combat_action(
|
||||
else:
|
||||
# Apply damage to NPC
|
||||
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
|
||||
if weapon_effects and 'bleeding' in weapon_effects:
|
||||
@@ -304,7 +304,7 @@ async def combat_action(
|
||||
|
||||
if new_npc_hp <= 0:
|
||||
# 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
|
||||
player_won = True
|
||||
|
||||
@@ -435,7 +435,7 @@ async def combat_action(
|
||||
# Failed to flee, NPC attacks
|
||||
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
|
||||
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:
|
||||
result_message += "\nYou have been defeated!"
|
||||
|
||||
@@ -39,18 +39,20 @@ def translate_travel_message(direction: str, location_name: str, lang: str = 'en
|
||||
|
||||
import json
|
||||
|
||||
def create_combat_message(message_type: str, **data) -> str:
|
||||
"""Create a structured combat message with type and data.
|
||||
def create_combat_message(message_type: str, origin: str = "neutral", **data) -> str:
|
||||
"""Create a structured combat message with type, origin, and data.
|
||||
|
||||
Args:
|
||||
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.)
|
||||
|
||||
Returns:
|
||||
Dictionary with 'type' and 'data' fields
|
||||
JSON string with 'type', 'origin', and 'data' fields
|
||||
"""
|
||||
return json.dumps({
|
||||
"type": message_type,
|
||||
"origin": origin,
|
||||
"data": data
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
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 { getTranslatedText } from '../../utils/i18nUtils'
|
||||
import './CombatEffects.css'
|
||||
@@ -34,95 +35,94 @@ const Combat = ({
|
||||
updatePlayerState,
|
||||
updateCombatState
|
||||
}: CombatProps) => {
|
||||
// Local state for visual effects and logic
|
||||
// Visual effects state
|
||||
const [shake, setShake] = 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 [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)
|
||||
|
||||
// Turn timer state for PvE combat
|
||||
const [turnTimeRemaining, setTurnTimeRemaining] = useState<number | null>(null)
|
||||
// Refs for cleanup
|
||||
const isMounted = useRef(true)
|
||||
|
||||
// Floating text ID counter to ensure unique IDs
|
||||
const floatingTextIdCounter = useRef(0)
|
||||
|
||||
// Track all timeout IDs for cleanup
|
||||
const floatingTextTimeouts = useRef<Set<NodeJS.Timeout>>(new Set())
|
||||
|
||||
// ============================================================================
|
||||
// Cleanup Effects
|
||||
// ============================================================================
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMounted.current = false
|
||||
// Cancel all pending floating text timeouts to prevent DOM manipulation errors
|
||||
floatingTextTimeouts.current.forEach(timeout => clearTimeout(timeout))
|
||||
floatingTextTimeouts.current.clear()
|
||||
// Clear all floating texts on unmount to prevent DOM manipulation errors
|
||||
setFloatingTexts([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Clean up floating texts when combat ends
|
||||
useEffect(() => {
|
||||
if (combatState.combat_over) {
|
||||
// Cancel all pending timeouts immediately when combat ends
|
||||
floatingTextTimeouts.current.forEach(timeout => clearTimeout(timeout))
|
||||
floatingTextTimeouts.current.clear()
|
||||
// Clear all floating texts
|
||||
setFloatingTexts([])
|
||||
}
|
||||
}, [combatState.combat_over])
|
||||
|
||||
// PvP Timer Effect
|
||||
// ============================================================================
|
||||
// Timer Effects
|
||||
// ============================================================================
|
||||
|
||||
// PvP Timer
|
||||
useEffect(() => {
|
||||
if (combatState.is_pvp && combatState.pvp_combat) {
|
||||
// Always set timer from server value
|
||||
setPvpTimer(combatState.pvp_combat.time_remaining)
|
||||
|
||||
// Run countdown locally for smooth UI
|
||||
const interval = setInterval(() => {
|
||||
setPvpTimer(prev => (prev && prev > 0 ? prev - 1 : 0))
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
} else {
|
||||
setPvpTimer(null)
|
||||
}
|
||||
}, [combatState.is_pvp, combatState.pvp_combat])
|
||||
|
||||
// PvE Timer Effect - Update from server-calculated time
|
||||
// Reset timer whenever turn_time_remaining changes from server
|
||||
// PvE Timer - Update from server
|
||||
useEffect(() => {
|
||||
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)
|
||||
} else {
|
||||
setTurnTimeRemaining(null)
|
||||
}
|
||||
}, [combatState.is_pvp, combatState.combat?.turn, combatState.combat?.turn_time_remaining])
|
||||
|
||||
// PvE Timer Countdown Effect - Decrement locally for smooth UI
|
||||
// PvE Timer - Countdown
|
||||
useEffect(() => {
|
||||
if (turnTimeRemaining !== null && turnTimeRemaining > 0) {
|
||||
const interval = setInterval(() => {
|
||||
setTurnTimeRemaining(prev => prev !== null && prev > 0 ? prev - 1 : 0)
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [turnTimeRemaining])
|
||||
|
||||
// PvE Polling Effect - Poll when timeout is imminent (< 30s) to catch background task
|
||||
// PvE Polling when timeout is imminent
|
||||
useEffect(() => {
|
||||
if (!combatState.is_pvp && turnTimeRemaining !== null && turnTimeRemaining < 30 && turnTimeRemaining >= 0) {
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
// Fetch updated combat state from API
|
||||
const response = await api.get('/api/game/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) {
|
||||
updateCombatState({
|
||||
...combatState,
|
||||
@@ -133,26 +133,46 @@ const Combat = ({
|
||||
} catch (error) {
|
||||
console.error('Failed to poll combat state:', error)
|
||||
}
|
||||
}, 10000) // Poll every 10 seconds
|
||||
}, 10000)
|
||||
|
||||
return () => clearInterval(pollInterval)
|
||||
}
|
||||
}, [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
|
||||
setFloatingTexts(prev => [...prev, { id, text, x, y, type }])
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (isMounted.current) {
|
||||
setFloatingTexts(prev => prev.filter(ft => ft.id !== id))
|
||||
// Remove this timeout from the tracking set
|
||||
floatingTextTimeouts.current.delete(timeout)
|
||||
}
|
||||
}, 2500)
|
||||
// Track this timeout for cleanup
|
||||
|
||||
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) => {
|
||||
if (processing) return
|
||||
setProcessing(true)
|
||||
@@ -162,84 +182,50 @@ const Combat = ({
|
||||
const now = new Date()
|
||||
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
// Parse messages
|
||||
const messages = data.message.split('\n').filter((m: string) => m.trim())
|
||||
// Parse message into structured parts
|
||||
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) => {
|
||||
// Try to parse as JSON first (for structured messages)
|
||||
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
|
||||
}
|
||||
const parsed = parseMessage(msg)
|
||||
|
||||
if (parsed) {
|
||||
// Structured message - use origin field
|
||||
if (parsed.origin === 'player') {
|
||||
playerMessages.push(parsed)
|
||||
} else if (parsed.origin === 'enemy') {
|
||||
enemyMessages.push(parsed)
|
||||
} else {
|
||||
// Neutral messages (victory, combat start) go to player
|
||||
playerMessages.push(parsed)
|
||||
}
|
||||
} catch (e) {
|
||||
// Not valid JSON, treat as string
|
||||
}
|
||||
|
||||
// Check if message contains both flee failure and enemy attack
|
||||
const fleeFailMatch = msg.match(/^(Failed to flee!)\s+(.+)$/)
|
||||
if (fleeFailMatch) {
|
||||
processedMessages.push(fleeFailMatch[1]) // "Failed to flee!"
|
||||
|
||||
// The second part might be a JSON string too
|
||||
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 {
|
||||
processedMessages.push(msg)
|
||||
// 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) => {
|
||||
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
|
||||
// 1. Process player messages immediately
|
||||
playerMessages.forEach((msg: any) => {
|
||||
const logId = `player-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
addCombatLogEntry({ id: logId, time: timeStr, message: msg, isPlayer: true })
|
||||
|
||||
// Only show attack animations for actual attacks, not flee failures
|
||||
if (msg !== 'Failed to flee!' && (typeof msg !== 'object' || msg.type === 'player_attack')) {
|
||||
const damage = typeof msg === 'object' ? msg.data.damage : (msg.match(/(\d+) damage/) || [])[1]
|
||||
if (damage) {
|
||||
addFloatingText(damage.toString(), 50, 30, 'damage-player-dealt') // White text on enemy
|
||||
setFlash(true)
|
||||
setTimeout(() => setFlash(false), 300)
|
||||
}
|
||||
// Extract damage if present
|
||||
const parsed = parseMessage(msg)
|
||||
if (parsed && parsed.type === 'player_attack' && parsed.data.damage) {
|
||||
addFloatingText(parsed.data.damage.toString(), 50, 30, 'damage-player-dealt')
|
||||
setFlash(true)
|
||||
setTimeout(() => setFlash(false), 300)
|
||||
}
|
||||
})
|
||||
|
||||
// Update Enemy HP immediately
|
||||
// Update enemy HP immediately
|
||||
if (data.combat && !data.combat_over) {
|
||||
updateCombatState({
|
||||
...combatState,
|
||||
@@ -248,39 +234,39 @@ const Combat = ({
|
||||
npc_hp: data.combat.npc_hp,
|
||||
turn: data.combat.turn,
|
||||
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
|
||||
if (data.player && playerState) {
|
||||
// Store current player HP
|
||||
if (playerState) {
|
||||
setTempPlayerHP(playerState.health)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Enemy Turn Delay (including failed flee)
|
||||
if ((enemyMessages.length > 0 || isFailedFlee) && !data.combat_over) {
|
||||
setLocalEnemyTurnMessage(isFailedFlee ? "🗡️ Enemy's turn..." : "🗡️ Enemy's turn...")
|
||||
|
||||
// 2. Enemy turn with delay
|
||||
if (enemyMessages.length > 0 && !data.combat_over) {
|
||||
setEnemyThinking(true)
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
setEnemyThinking(false)
|
||||
|
||||
enemyMessages.forEach((msg: any) => {
|
||||
const logId = `enemy-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
addCombatLogEntry({ id: logId, time: timeStr, message: msg, isPlayer: false })
|
||||
|
||||
const damage = typeof msg === 'object' ? msg.data.damage : (msg.match(/(\d+) damage/) || [])[1]
|
||||
if (damage) {
|
||||
addFloatingText(damage.toString(), 50, 50, 'damage-player') // Red text over player position
|
||||
// Extract damage if present
|
||||
const parsed = parseMessage(msg)
|
||||
if (parsed && (parsed.type === 'enemy_attack' || parsed.type === 'flee_fail') && parsed.data.damage) {
|
||||
addFloatingText(parsed.data.damage.toString(), 50, 50, 'damage-player')
|
||||
setShake(true)
|
||||
setTimeout(() => setShake(false), 500)
|
||||
}
|
||||
})
|
||||
|
||||
setLocalEnemyTurnMessage('')
|
||||
|
||||
// Update Player HP after delay completes
|
||||
// Update player HP after delay
|
||||
if (data.player && playerState) {
|
||||
setTempPlayerHP(null) // Clear temp HP
|
||||
setTempPlayerHP(null)
|
||||
updatePlayerState({
|
||||
...playerState,
|
||||
health: data.player.hp,
|
||||
@@ -288,10 +274,8 @@ const Combat = ({
|
||||
})
|
||||
}
|
||||
} else if (data.combat_over) {
|
||||
// Combat ended (e.g. player won or fled)
|
||||
const playerFled = data.message.toLowerCase().includes('fled') ||
|
||||
data.message.toLowerCase().includes('escape') ||
|
||||
data.player_fled === true
|
||||
// Combat ended
|
||||
const playerFled = data.message.toLowerCase().includes('fled') || data.message.toLowerCase().includes('escape')
|
||||
|
||||
updateCombatState({
|
||||
...combatState,
|
||||
@@ -303,8 +287,8 @@ const Combat = ({
|
||||
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) {
|
||||
updatePlayerState({
|
||||
...playerState,
|
||||
@@ -321,62 +305,32 @@ const Combat = ({
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PvP Combat Actions
|
||||
// ============================================================================
|
||||
|
||||
const handlePvPActionLocal = async (action: string) => {
|
||||
if (processing) return
|
||||
setProcessing(true)
|
||||
|
||||
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)
|
||||
|
||||
if (data) {
|
||||
const now = new Date()
|
||||
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 logId = `pvp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
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) {
|
||||
addFloatingText(damageMatch[1], 50, 30, 'damage-player-dealt')
|
||||
setFlash(true)
|
||||
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) {
|
||||
@@ -399,7 +353,7 @@ const Combat = ({
|
||||
equipment={equipment}
|
||||
enemyName={getTranslatedText(combatState.combat?.npc_name) || 'Enemy'}
|
||||
enemyImage={combatState.combat?.npc_image || combatState.combat_image || ''}
|
||||
enemyTurnMessage={localEnemyTurnMessage}
|
||||
enemyTurnMessage={enemyThinking ? '🗡️ Enemy is thinking...' : ''}
|
||||
pvpTimeRemaining={pvpTimer}
|
||||
turnTimeRemaining={turnTimeRemaining}
|
||||
onCombatAction={handlePvEAction}
|
||||
@@ -407,10 +361,10 @@ const Combat = ({
|
||||
onPvPAction={handlePvPActionLocal}
|
||||
onExitCombat={onExitCombat}
|
||||
onExitPvPCombat={onExitPvPCombat}
|
||||
flashEnemy={flash}
|
||||
buttonsDisabled={processing}
|
||||
floatingTexts={floatingTexts}
|
||||
/>
|
||||
flashEnemy={flash}
|
||||
buttonsDisabled={processing || enemyThinking}
|
||||
floatingTexts={floatingTexts}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
154
pwa/src/components/game/CombatTypes.ts
Normal file
154
pwa/src/components/game/CombatTypes.ts
Normal 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
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { CombatState, CombatLogEntry, Profile, Equipment, PlayerState } from './types'
|
||||
import type { FloatingText, CombatMessage } from './CombatTypes'
|
||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
||||
|
||||
interface CombatViewProps {
|
||||
@@ -20,7 +21,7 @@ interface CombatViewProps {
|
||||
onExitPvPCombat: () => void
|
||||
flashEnemy?: 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({
|
||||
@@ -44,18 +45,23 @@ function CombatView({
|
||||
const { t } = useTranslation()
|
||||
const displayEnemyName = typeof enemyName === 'object' ? getTranslatedText(enemyName) : (enemyName || 'Enemy')
|
||||
|
||||
// Render structured combat messages
|
||||
// ============================================================================
|
||||
// Message Rendering
|
||||
// ============================================================================
|
||||
|
||||
const renderCombatMessage = (msg: any) => {
|
||||
// Support both old string format and new structured format
|
||||
// Handle string messages
|
||||
if (typeof msg === 'string') {
|
||||
return msg // Legacy format
|
||||
return msg
|
||||
}
|
||||
|
||||
// Handle legacy formatted messages
|
||||
if (!msg || !msg.type) {
|
||||
return String(msg)
|
||||
}
|
||||
|
||||
const { type, data } = msg
|
||||
const message = msg as CombatMessage
|
||||
const { type, data } = message
|
||||
|
||||
switch (type) {
|
||||
case 'combat_start':
|
||||
@@ -75,10 +81,21 @@ function CombatView({
|
||||
damage: data.damage
|
||||
})
|
||||
default:
|
||||
// Fallback to JSON string for unknown types
|
||||
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 (
|
||||
<div className="combat-view">
|
||||
<div className="combat-header-inline">
|
||||
@@ -88,10 +105,12 @@ function CombatView({
|
||||
</div>
|
||||
|
||||
{combatState.is_pvp ? (
|
||||
/* PvP Combat UI - Unified Layout */
|
||||
/* ================================================================ */
|
||||
/* PvP Combat UI */
|
||||
/* ================================================================ */
|
||||
<div className="combat-content-wrapper">
|
||||
<div className="combat-enemy-display-inline">
|
||||
{/* Opponent Display (using same structure as PvE Enemy) */}
|
||||
{/* Opponent Display */}
|
||||
<div className="combat-enemy-image-large">
|
||||
<div className="floating-texts-container">
|
||||
{floatingTexts.map(ft => (
|
||||
@@ -110,14 +129,13 @@ function CombatView({
|
||||
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 style={{ fontSize: '1rem', marginTop: '0.5rem' }}>
|
||||
{opponent.username} (Lv. {opponent.level})
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
@@ -142,7 +160,8 @@ function CombatView({
|
||||
<div
|
||||
className="combat-hp-fill-inline"
|
||||
style={{
|
||||
width: `${(opponent.hp / opponent.max_hp) * 100}%`
|
||||
width: `${(opponent.hp / opponent.max_hp) * 100}%`,
|
||||
transition: 'width 0.5s ease-in-out'
|
||||
}}
|
||||
/>
|
||||
</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-inline" style={{ background: 'rgba(255, 107, 107, 0.2)' }}>
|
||||
<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
|
||||
className="combat-hp-fill-inline"
|
||||
style={{
|
||||
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>
|
||||
@@ -182,12 +202,16 @@ function CombatView({
|
||||
<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"}
|
||||
{combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? `🏃 ${t('combat.fleeSuccess')}` : `💀 ${t('combat.defeat')}`}
|
||||
</span>
|
||||
) : 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>
|
||||
|
||||
@@ -199,14 +223,14 @@ function CombatView({
|
||||
onClick={() => onPvPAction('attack')}
|
||||
disabled={!combatState.pvp_combat.your_turn || buttonsDisabled}
|
||||
>
|
||||
{t('game.attack')}
|
||||
{t('combat.actions.attack')}
|
||||
</button>
|
||||
<button
|
||||
className="combat-action-btn flee-btn"
|
||||
onClick={() => onPvPAction('flee')}
|
||||
disabled={!combatState.pvp_combat.your_turn || buttonsDisabled}
|
||||
>
|
||||
{t('game.flee')}
|
||||
{t('combat.actions.flee')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
@@ -241,17 +265,19 @@ function CombatView({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* PvE Combat UI */
|
||||
/* ================================================================ */
|
||||
/* PvE Combat UI */
|
||||
/* ================================================================ */
|
||||
<>
|
||||
<div className="combat-content-wrapper">
|
||||
<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 && (
|
||||
<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' ? '🔥' : '❓'}
|
||||
combatState.combat.npc_intent === 'special' ? ' 🔥' : '❓'}
|
||||
</span>
|
||||
<span className="intent-desc">{combatState.combat.npc_intent}</span>
|
||||
</div>
|
||||
@@ -271,12 +297,12 @@ function CombatView({
|
||||
<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' : ''
|
||||
}`}
|
||||
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">
|
||||
@@ -286,7 +312,8 @@ function CombatView({
|
||||
<div
|
||||
className="combat-hp-fill-inline"
|
||||
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>
|
||||
@@ -301,7 +328,8 @@ function CombatView({
|
||||
className="combat-hp-fill-inline"
|
||||
style={{
|
||||
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>
|
||||
@@ -313,13 +341,13 @@ function CombatView({
|
||||
<div className={`combat-turn-indicator-inline ${enemyTurnMessage ? 'enemy-turn-message' : ''}`}>
|
||||
{!combatState.combat_over ? (
|
||||
enemyTurnMessage ? (
|
||||
<span className="enemy-turn">🗡️ Enemy's turn...</span>
|
||||
<span className="enemy-turn">{t('combat.thinking')}</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')}
|
||||
⏱️ {formatTimer(turnTimeRemaining)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
@@ -334,7 +362,6 @@ function CombatView({
|
||||
</div>
|
||||
|
||||
{/* PvE Combat Actions */}
|
||||
|
||||
<div className="combat-actions-inline">
|
||||
{!combatState.combat_over ? (
|
||||
<>
|
||||
@@ -343,14 +370,14 @@ function CombatView({
|
||||
onClick={() => onCombatAction('attack')}
|
||||
disabled={combatState.combat?.turn !== 'player' || !!enemyTurnMessage || buttonsDisabled}
|
||||
>
|
||||
{t('game.attack')}
|
||||
{t('combat.actions.attack')}
|
||||
</button>
|
||||
<button
|
||||
className="combat-action-btn flee-btn"
|
||||
onClick={() => onCombatAction('flee')}
|
||||
disabled={combatState.combat?.turn !== 'player' || !!enemyTurnMessage || buttonsDisabled}
|
||||
>
|
||||
{t('game.flee')}
|
||||
{t('combat.actions.flee')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -155,6 +155,10 @@
|
||||
"combatLog": "Combat Log",
|
||||
"attacking": "Attacking",
|
||||
"defending": "Defending",
|
||||
"thinking": "Enemy is thinking...",
|
||||
"yourTurnTimer": "Your Turn ({{time}})",
|
||||
"enemyTurnTimer": "Enemy Turn",
|
||||
"waiting": "Waiting for opponent...",
|
||||
"messages": {
|
||||
"combat_start": "Combat started with {{enemy}}!",
|
||||
"player_attack": "You attack for {{damage}} damage!",
|
||||
@@ -162,7 +166,25 @@
|
||||
"victory": "Victory! Defeated {{enemy}}",
|
||||
"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": {
|
||||
"head": "Head",
|
||||
|
||||
@@ -156,12 +156,34 @@
|
||||
"turnTimer": "Temporizador de Turno",
|
||||
"attacking": "Atacando",
|
||||
"defending": "Defendiendo",
|
||||
"thinking": "El enemigo está pensando...",
|
||||
"yourTurnTimer": "Tu Turno ({{time}})",
|
||||
"enemyTurnTimer": "Turno del Enemigo",
|
||||
"waiting": "Esperando al oponente...",
|
||||
"messages": {
|
||||
"combat_start": "¡Combate iniciado con {{enemy}}!",
|
||||
"player_attack": "¡Atacas por {{damage}} de daño!",
|
||||
"enemy_attack": "{{enemy}} ataca por {{damage}} de daño!",
|
||||
"victory": "¡Victoria! Derrotaste a {{enemy}}",
|
||||
"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": {
|
||||
|
||||
Reference in New Issue
Block a user