diff --git a/api/game_logic.py b/api/game_logic.py index da5c177..09d7ca0 100644 --- a/api/game_logic.py +++ b/api/game_logic.py @@ -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})" diff --git a/api/routers/combat.py b/api/routers/combat.py index 71502ec..7d3dbbc 100644 --- a/api/routers/combat.py +++ b/api/routers/combat.py @@ -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!" diff --git a/api/services/helpers.py b/api/services/helpers.py index 77e8de1..8395a40 100644 --- a/api/services/helpers.py +++ b/api/services/helpers.py @@ -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 }) diff --git a/pwa/src/components/game/Combat.tsx b/pwa/src/components/game/Combat.tsx index d680b11..7397e1d 100644 --- a/pwa/src/components/game/Combat.tsx +++ b/pwa/src/components/game/Combat.tsx @@ -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([]) const [processing, setProcessing] = useState(false) - const [pvpTimer, setPvpTimer] = useState(null) - const [localEnemyTurnMessage, setLocalEnemyTurnMessage] = useState('') - // Temporary HP state to delay player HP updates during enemy turn + // Timer state + const [pvpTimer, setPvpTimer] = useState(null) + const [turnTimeRemaining, setTurnTimeRemaining] = useState(null) + + // Enemy thinking indicator + const [enemyThinking, setEnemyThinking] = useState(false) + + // Temporary HP to delay updates during enemy turn const [tempPlayerHP, setTempPlayerHP] = useState(null) - // Turn timer state for PvE combat - const [turnTimeRemaining, setTurnTimeRemaining] = useState(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>(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 - // 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} + /> ) } diff --git a/pwa/src/components/game/CombatTypes.ts b/pwa/src/components/game/CombatTypes.ts new file mode 100644 index 0000000..ce9e449 --- /dev/null +++ b/pwa/src/components/game/CombatTypes.ts @@ -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 +} diff --git a/pwa/src/components/game/CombatView.tsx b/pwa/src/components/game/CombatView.tsx index e893b78..77997b4 100644 --- a/pwa/src/components/game/CombatView.tsx +++ b/pwa/src/components/game/CombatView.tsx @@ -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 (
@@ -88,10 +105,12 @@ function CombatView({
{combatState.is_pvp ? ( - /* PvP Combat UI - Unified Layout */ + /* ================================================================ */ + /* PvP Combat UI */ + /* ================================================================ */
- {/* Opponent Display (using same structure as PvE Enemy) */} + {/* Opponent Display */}
{floatingTexts.map(ft => ( @@ -110,14 +129,13 @@ function CombatView({ combatState.pvp_combat.attacker if (!opponent) return
- // 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 (
👤 -
{opponent.username} (Lv. {opponent.level})
+
+ {opponent.username} (Lv. {opponent.level}) +
) })()} @@ -142,7 +160,8 @@ function CombatView({
@@ -163,13 +182,14 @@ function CombatView({
- You: {you.hp} / {you.max_hp} + {t('combat.playerHp')}: {you.hp} / {you.max_hp}
@@ -182,12 +202,16 @@ function CombatView({
{combatState.pvp_combat.combat_over ? ( - {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')}`} ) : combatState.pvp_combat.your_turn ? ( - ✅ Your Turn ({pvpTimeRemaining ?? combatState.pvp_combat.time_remaining}s) + + ✅ {t('combat.yourTurn')} ({formatTimer(pvpTimeRemaining ?? combatState.pvp_combat.time_remaining)}) + ) : ( - ⏳ Opponent's Turn ({pvpTimeRemaining ?? combatState.pvp_combat.time_remaining}s) + + ⏳ {t('combat.waiting')} ({formatTimer(pvpTimeRemaining ?? combatState.pvp_combat.time_remaining)}) + )}
@@ -199,14 +223,14 @@ function CombatView({ onClick={() => onPvPAction('attack')} disabled={!combatState.pvp_combat.your_turn || buttonsDisabled} > - {t('game.attack')} + {t('combat.actions.attack')} ) : ( @@ -241,17 +265,19 @@ function CombatView({
) : ( - /* PvE Combat UI */ + /* ================================================================ */ + /* PvE Combat UI */ + /* ================================================================ */ <>
- {/* Intent Bubble - Moved here to avoid overflow:hidden clipping */} + {/* Enemy Intent Bubble */} {combatState.combat?.npc_intent && !combatState.combat_over && (
{combatState.combat.npc_intent === 'attack' ? '⚔️' : combatState.combat.npc_intent === 'defend' ? '🛡️' : - combatState.combat.npc_intent === 'special' ? '🔥' : '❓'} + combatState.combat.npc_intent === 'special' ? ' 🔥' : '❓'} {combatState.combat.npc_intent}
@@ -271,12 +297,12 @@ function CombatView({ {enemyName
+
@@ -286,7 +312,8 @@ function CombatView({
@@ -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' }} />
@@ -313,13 +341,13 @@ function CombatView({
{!combatState.combat_over ? ( enemyTurnMessage ? ( - 🗡️ Enemy's turn... + {t('combat.thinking')} ) : combatState.combat?.turn === 'player' ? ( <> ✅ {t('combat.yourTurn')} {turnTimeRemaining !== null && ( - ⏱️ {Math.floor(turnTimeRemaining / 60)}:{String(Math.floor(turnTimeRemaining % 60)).padStart(2, '0')} + ⏱️ {formatTimer(turnTimeRemaining)} )} @@ -334,7 +362,6 @@ function CombatView({
{/* PvE Combat Actions */} -
{!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')} ) : ( diff --git a/pwa/src/i18n/locales/en.json b/pwa/src/i18n/locales/en.json index 11c79db..db29cdd 100644 --- a/pwa/src/i18n/locales/en.json +++ b/pwa/src/i18n/locales/en.json @@ -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", diff --git a/pwa/src/i18n/locales/es.json b/pwa/src/i18n/locales/es.json index 183c748..69b2e67 100644 --- a/pwa/src/i18n/locales/es.json +++ b/pwa/src/i18n/locales/es.json @@ -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": {