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)
|
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})"
|
||||||
|
|
||||||
|
|||||||
@@ -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!"
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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 { 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,11 +265,13 @@ 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">
|
||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user