Pre-combat-rewrite: Backup current state before comprehensive combat frontend rewrite

This commit is contained in:
Joan
2026-01-09 11:07:37 +01:00
parent dc438ae4c1
commit 2875e72b20
29 changed files with 1827 additions and 332 deletions

View File

@@ -1,17 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1a1a1a" />
<meta name="description" content="A post-apocalyptic survival RPG" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Echoes of the Ash</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1a1a1a" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Saira+Condensed:wght@400;500;600;700;800&display=swap"
rel="stylesheet">
<meta name="description" content="A post-apocalyptic survival RPG" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Echoes of the Ash</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
// Game.tsx - Main game orchestrator (refactored from 3,315 lines to ~350 lines)
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import api from '../services/api'
import { useGameEngine } from './game/hooks/useGameEngine'
import Combat from './game/Combat'
@@ -9,6 +10,7 @@ import PlayerSidebar from './game/PlayerSidebar'
import './Game.css'
function Game() {
const { t, i18n } = useTranslation()
const [token] = useState(() => localStorage.getItem('token'))
// Handle WebSocket messages
@@ -23,11 +25,29 @@ function Game() {
case 'location_update':
// General location updates - update state directly from message data when possible
console.log('🗺️ Location update:', message.data?.action, message.data?.message)
if (message.data?.message) {
actions.addLocationMessage(message.data.message)
let displayMessage = message.data?.message
const action = message.data?.action
// Handle translations for specific actions
if (action === 'enemy_spawned' && message.data.npc_data) {
const npcData = message.data.npc_data
let npcName = npcData.name
if (typeof npcName === 'object' && npcName !== null) {
npcName = npcName[i18n.language] || npcName['en'] || npcName['es']
}
displayMessage = t('messages.enemyAppeared', { name: npcName })
} else if (action === 'enemy_despawned') {
displayMessage = t('messages.enemyDespawned')
} else if (action === 'corpses_decayed' && message.data.count) {
displayMessage = t('messages.corpsesDecayed', { count: message.data.count })
} else if (action === 'items_decayed' && message.data.count) {
displayMessage = t('messages.itemsDecayed', { count: message.data.count })
}
const action = message.data?.action
if (displayMessage) {
actions.addLocationMessage(displayMessage)
}
if (action === 'player_arrived' && message.data.player_id) {
// Add player to location directly without API call
actions.addPlayerToLocation({
@@ -326,6 +346,7 @@ function Game() {
{/* Location view (when not in combat) */}
{!state.combatState && state.location && state.playerState && (
<LocationView
key={state.location.id}
location={state.location}
playerState={state.playerState}
combatState={state.combatState || null}

View File

@@ -11,6 +11,9 @@ function LanguageSelector() {
const changeLanguage = (langCode: string) => {
i18n.changeLanguage(langCode)
// Reload page to ensure all components refresh with new language
// This is necessary because some data comes from API and won't update without refetch
window.location.reload()
}
const currentLang = languages.find(l => l.code === i18n.language) || languages[0]

View File

@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import CombatView from './CombatView'
import { CombatState, CombatLogEntry, Profile, Equipment, PlayerState } from './types'
import api from '../../services/api'
import { getTranslatedText } from '../../utils/i18nUtils'
import './CombatEffects.css'
interface CombatProps {
@@ -46,6 +47,35 @@ const Combat = ({
// Turn timer state for PvE combat
const [turnTimeRemaining, setTurnTimeRemaining] = useState<number | null>(null)
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())
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
useEffect(() => {
@@ -110,11 +140,17 @@ const Combat = ({
}, [turnTimeRemaining, combatState, updateCombatState])
const addFloatingText = (text: string, x: number, y: number, type: 'damage-player' | 'damage-enemy' | 'damage-player-dealt' | 'heal') => {
const id = Date.now() + Math.random()
const id = ++floatingTextIdCounter.current
setFloatingTexts(prev => [...prev, { id, text, x, y, type }])
setTimeout(() => {
setFloatingTexts(prev => prev.filter(ft => ft.id !== id))
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 handlePvEAction = async (action: string) => {
@@ -130,38 +166,73 @@ const Combat = ({
const messages = data.message.split('\n').filter((m: string) => m.trim())
// Handle failed flee special case - split combined message
const processedMessages: string[] = []
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
}
}
} 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!"
processedMessages.push(fleeFailMatch[2]) // Enemy attack message
// 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)
}
})
const playerMessages = processedMessages.filter((msg: string) =>
msg.includes('You ') || msg.includes('Your ') || msg === 'Failed to flee!'
)
const enemyMessages = processedMessages.filter((msg: string) =>
msg !== 'Failed to flee!' &&
(msg.includes('attacks') || msg.includes('hits') || msg.includes('misses') || msg.includes('The '))
)
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
playerMessages.forEach((msg: string) => {
addCombatLogEntry({ time: timeStr, message: msg, isPlayer: true })
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!') {
const damageMatch = msg.match(/(\d+) damage/)
if (damageMatch) {
addFloatingText(damageMatch[1], 50, 30, 'damage-player-dealt') // White text on enemy
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)
}
@@ -193,12 +264,13 @@ const Combat = ({
await new Promise(resolve => setTimeout(resolve, 2000))
enemyMessages.forEach((msg: string) => {
addCombatLogEntry({ time: timeStr, message: msg, isPlayer: 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 damageMatch = msg.match(/(\d+) damage/)
if (damageMatch) {
addFloatingText(damageMatch[1], 50, 50, 'damage-player') // Red text over player position
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
setShake(true)
setTimeout(() => setShake(false), 500)
}
@@ -293,7 +365,8 @@ const Combat = ({
// Parse message for damage
// Example: "You attacked X for 10 damage!"
const msg = data.message || ''
addCombatLogEntry({ time: timeStr, message: msg, isPlayer: true })
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/)
if (damageMatch) {
@@ -324,7 +397,7 @@ const Combat = ({
health: tempPlayerHP
} : playerState}
equipment={equipment}
enemyName={combatState.combat?.npc_name || 'Enemy'}
enemyName={getTranslatedText(combatState.combat?.npc_name) || 'Enemy'}
enemyImage={combatState.combat?.npc_image || combatState.combat_image || ''}
enemyTurnMessage={localEnemyTurnMessage}
pvpTimeRemaining={pvpTimer}

View File

@@ -0,0 +1,393 @@
import { useTranslation } from 'react-i18next'
import type { CombatState, CombatLogEntry, Profile, Equipment, PlayerState } from './types'
import { getTranslatedText } from '../../utils/i18nUtils'
interface CombatViewProps {
combatState: CombatState
combatLog: CombatLogEntry[]
profile: Profile | null
playerState: PlayerState | null
equipment: Equipment
enemyName: string
enemyImage: string
enemyTurnMessage: string
pvpTimeRemaining: number | null
turnTimeRemaining: number | null
onCombatAction: (action: string) => void
onFlee: () => void
onPvPAction: (action: string) => void
onExitCombat: () => void
onExitPvPCombat: () => void
flashEnemy?: boolean
buttonsDisabled?: boolean
floatingTexts?: { id: number, text: string, x: number, y: number, type: 'damage-player' | 'damage-enemy' | 'damage-player-dealt' | 'heal' }[]
}
function CombatView({
combatState,
combatLog,
profile: _profile,
playerState,
enemyName,
enemyImage,
enemyTurnMessage,
pvpTimeRemaining,
turnTimeRemaining,
onCombatAction,
onPvPAction,
onExitCombat,
onExitPvPCombat,
flashEnemy,
buttonsDisabled,
floatingTexts = []
}: CombatViewProps) {
const { t } = useTranslation()
const displayEnemyName = typeof enemyName === 'object' ? getTranslatedText(enemyName) : (enemyName || 'Enemy')
// Render structured combat messages
const renderCombatMessage = (msg: any) => {
// Support both old string format and new structured format
if (typeof msg === 'string') {
return msg // Legacy format
}
if (!msg || !msg.type) {
return String(msg)
}
const { type, data } = msg
switch (type) {
case 'combat_start':
return t('combat.messages.combat_start', { enemy: getTranslatedText(data.npc_name) })
case 'player_attack':
return t('combat.messages.player_attack', { damage: data.damage })
case 'enemy_attack':
return t('combat.messages.enemy_attack', {
enemy: getTranslatedText(data.npc_name),
damage: data.damage
})
case 'victory':
return t('combat.messages.victory', { enemy: getTranslatedText(data.npc_name) })
case 'flee_fail':
return t('combat.messages.flee_fail', {
enemy: getTranslatedText(data.npc_name),
damage: data.damage
})
default:
return JSON.stringify(msg)
}
}
return (
<div className="combat-view">
<div className="combat-header-inline">
<h2>
{combatState.is_pvp ? `⚔️ ${t('combat.title')} - PvP` : `⚔️ ${t('combat.title')} - ${displayEnemyName}`}
</h2>
</div>
{combatState.is_pvp ? (
/* PvP Combat UI - Unified Layout */
<div className="combat-content-wrapper">
<div className="combat-enemy-display-inline">
{/* Opponent Display (using same structure as PvE Enemy) */}
<div className="combat-enemy-image-large">
<div className="floating-texts-container">
{floatingTexts.map(ft => (
<div
key={ft.id}
className={`floating-text ${ft.type}`}
style={{ left: `${ft.x}%`, top: `${ft.y}%` }}>
{ft.text}
</div>
))}
</div>
{(() => {
if (!combatState.pvp_combat) return null
const opponent = combatState.pvp_combat.is_attacker ?
combatState.pvp_combat.defender :
combatState.pvp_combat.attacker
if (!opponent) return <div className="pvp-opponent-avatar"></div>
// Use a default avatar if no image, or maybe the class image if available?
// For now, let's use a placeholder or try to get it from profile if passed?
// The opponent object has: username, level, hp, max_hp.
// It might not have an image url.
return (
<div className="pvp-opponent-avatar" style={{ fontSize: '4rem', textAlign: 'center' }}>
👤
<div style={{ fontSize: '1rem', marginTop: '0.5rem' }}>{opponent.username} (Lv. {opponent.level})</div>
</div>
)
})()}
</div>
<div className="combat-enemy-info-inline">
{/* Opponent HP Bar */}
{(() => {
if (!combatState.pvp_combat) return null
const opponent = combatState.pvp_combat.is_attacker ?
combatState.pvp_combat.defender :
combatState.pvp_combat.attacker
if (!opponent) return null
return (
<div className="combat-hp-bar-container-inline enemy-hp-bar">
<div className="combat-hp-bar-inline">
<div className="combat-stat-label-inline">
{opponent.username}: {opponent.hp} / {opponent.max_hp}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${(opponent.hp / opponent.max_hp) * 100}%`
}}
/>
</div>
</div>
)
})()}
{/* Player HP Bar */}
{(() => {
if (!combatState.pvp_combat) return null
const you = combatState.pvp_combat.is_attacker ?
combatState.pvp_combat.attacker :
combatState.pvp_combat.defender
if (!you) return null
return (
<div className="combat-hp-bar-container-inline player-hp-bar" style={{ marginTop: '0.75rem' }}>
<div className="combat-hp-bar-inline" style={{ background: 'rgba(255, 107, 107, 0.2)' }}>
<div className="combat-stat-label-inline" style={{ color: '#ff6b6b' }}>
You: {you.hp} / {you.max_hp}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${(you.hp / you.max_hp) * 100}%`,
background: 'linear-gradient(90deg, #f44336, #ff6b6b)'
}}
/>
</div>
</div>
)
})()}
</div>
</div>
<div className="combat-turn-indicator-inline">
{combatState.pvp_combat.combat_over ? (
<span className={combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? "your-turn" : "enemy-turn"}>
{combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? "🏃 Combat Ended" : "💀 Combat Over"}
</span>
) : combatState.pvp_combat.your_turn ? (
<span className="your-turn"> Your Turn ({pvpTimeRemaining ?? combatState.pvp_combat.time_remaining}s)</span>
) : (
<span className="enemy-turn"> Opponent's Turn ({pvpTimeRemaining ?? combatState.pvp_combat.time_remaining}s)</span>
)}
</div>
<div className="combat-actions-inline">
{!combatState.pvp_combat.combat_over ? (
<>
<button
className="combat-action-btn attack-btn"
onClick={() => onPvPAction('attack')}
disabled={!combatState.pvp_combat.your_turn || buttonsDisabled}
>
{t('game.attack')}
</button>
<button
className="combat-action-btn flee-btn"
onClick={() => onPvPAction('flee')}
disabled={!combatState.pvp_combat.your_turn || buttonsDisabled}
>
{t('game.flee')}
</button>
</>
) : (
<button
className="combat-action-btn exit-btn"
onClick={onExitPvPCombat}
>
{combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? ' Continue' : '💀 Return'}
</button>
)}
</div>
{/* Combat Log */}
<div className="combat-log-wrapper">
<h3 className="combat-log-title">{t('combat.combatLog')}</h3>
<div className="combat-log-inline">
<div className="log-entries">
<div className="log-list">
{combatLog.length > 0 ? (
combatLog.map((entry: any) => (
<div key={entry.id} className={`log-entry ${entry.isPlayer ? 'player-log' : 'enemy-log'}`}>
<span className="log-time">[{entry.time}]</span>
<span className="log-message">{renderCombatMessage(entry.message)}</span>
</div>
))
) : (
<div className="log-entry"><span className="log-message">PvP Combat started...</span></div>
)}
</div>
</div>
</div>
</div>
</div>
) : (
/* PvE Combat UI */
<>
<div className="combat-content-wrapper">
<div className="combat-enemy-display-inline">
{/* Intent Bubble - Moved here to avoid overflow:hidden clipping */}
{combatState.combat?.npc_intent && !combatState.combat_over && (
<div className={`intent-bubble intent-${combatState.combat.npc_intent}`}>
<span className="intent-icon">
{combatState.combat.npc_intent === 'attack' ? '' :
combatState.combat.npc_intent === 'defend' ? '🛡' :
combatState.combat.npc_intent === 'special' ? '🔥' : ''}
</span>
<span className="intent-desc">{combatState.combat.npc_intent}</span>
</div>
)}
<div className="combat-enemy-image-large">
<div className="floating-texts-container">
{floatingTexts.map(ft => (
<div
key={ft.id}
className={`floating-text ${ft.type}`}
style={{ left: `${ft.x}%`, top: `${ft.y}%` }}>
{ft.text}
</div>
))}
</div>
<img
src={enemyImage || combatState.combat?.npc_image || combatState.combat_image}
alt={enemyName || combatState.combat?.npc_name || 'Enemy'}
className={`${flashEnemy ? 'flash-hit' : ''
} ${combatState.combat_over && combatState.player_won ? 'enemy-dead' : ''
} ${combatState.combat_over && combatState.player_fled ? 'enemy-fled' : ''
}`}
/>
</div>
<div className="combat-enemy-info-inline">
<div className="combat-hp-bar-container-inline enemy-hp-bar">
<div className="combat-hp-bar-inline">
<div className="combat-stat-label-inline">
{t('combat.enemyHp')}: {combatState.combat?.npc_hp || 0} / {combatState.combat?.npc_max_hp || 100}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${((combatState.combat?.npc_hp || 0) / (combatState.combat?.npc_max_hp || 100)) * 100}%`
}}
/>
</div>
</div>
{playerState && (
<div className="combat-hp-bar-container-inline player-hp-bar" style={{ marginTop: '0.75rem' }}>
<div className="combat-hp-bar-inline" style={{ background: 'rgba(255, 107, 107, 0.2)' }}>
<div className="combat-stat-label-inline" style={{ color: '#ff6b6b' }}>
{t('combat.playerHp')}: {playerState.health} / {playerState.max_health}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${(playerState.health / playerState.max_health) * 100}%`,
background: 'linear-gradient(90deg, #f44336, #ff6b6b)'
}}
/>
</div>
</div>
)}
</div>
</div>
<div className={`combat-turn-indicator-inline ${enemyTurnMessage ? 'enemy-turn-message' : ''}`}>
{!combatState.combat_over ? (
enemyTurnMessage ? (
<span className="enemy-turn">🗡️ Enemy's turn...</span>
) : combatState.combat?.turn === 'player' ? (
<>
<span className="your-turn"> {t('combat.yourTurn')}</span>
{turnTimeRemaining !== null && (
<span className="turn-timer" style={{ marginLeft: '1rem', fontSize: '0.9em', opacity: 0.8 }}>
{Math.floor(turnTimeRemaining / 60)}:{String(Math.floor(turnTimeRemaining % 60)).padStart(2, '0')}
</span>
)}
</>
) : (
<span className="enemy-turn"> {t('combat.enemyTurn')}</span>
)
) : (
<span className={combatState.player_won ? "your-turn" : combatState.player_fled ? "your-turn" : "enemy-turn"}>
{combatState.player_won ? `${t('combat.victory')}` : combatState.player_fled ? `🏃 ${t('combat.fleeSuccess')}` : `💀 ${t('combat.defeat')}`}
</span>
)}
</div>
{/* PvE Combat Actions */}
<div className="combat-actions-inline">
{!combatState.combat_over ? (
<>
<button
className="combat-action-btn attack-btn"
onClick={() => onCombatAction('attack')}
disabled={combatState.combat?.turn !== 'player' || !!enemyTurnMessage || buttonsDisabled}
>
{t('game.attack')}
</button>
<button
className="combat-action-btn flee-btn"
onClick={() => onCombatAction('flee')}
disabled={combatState.combat?.turn !== 'player' || !!enemyTurnMessage || buttonsDisabled}
>
{t('game.flee')}
</button>
</>
) : (
<button
className="combat-action-btn exit-btn"
onClick={onExitCombat}
>
{combatState.player_won ? '✅ Continue' : combatState.player_fled ? '✅ Continue' : '💀 Return'}
</button>
)}
</div>
{/* Combat Log */}
<div className="combat-log-wrapper">
<h3 className="combat-log-title">{t('combat.combatLog')}</h3>
<div className="combat-log-inline">
<div className="log-entries">
<div className="log-list">
{combatLog.length > 0 ? (
combatLog.map((entry: any) => (
<div key={entry.id} className={`log-entry ${entry.isPlayer ? 'player-log' : 'enemy-log'}`}>
<span className="log-time">[{entry.time}]</span>
<span className="log-message">{renderCombatMessage(entry.message)}</span>
</div>
))
) : (
<div className="log-entry"><span className="log-message">Combat started...</span></div>
)}
</div>
</div>
</div>
</div>
</div>
</>
)}
</div>
)
}
export default CombatView

View File

@@ -34,19 +34,19 @@ function InventoryModal({
onUnequipItem,
onDropItem
}: InventoryModalProps) {
useTranslation()
const { t } = useTranslation()
// Categories for the sidebar
const categories = [
{ id: 'all', label: 'All Items', icon: '🎒' },
{ id: 'weapon', label: 'Weapons', icon: '⚔️' },
{ id: 'armor', label: 'Armor', icon: '🛡️' },
{ id: 'clothing', label: 'Clothing', icon: '👕' },
{ id: 'backpack', label: 'Backpacks', icon: '🎒' },
{ id: 'tool', label: 'Tools', icon: '🛠️' },
{ id: 'consumable', label: 'Consumables', icon: '🍖' },
{ id: 'resource', label: 'Resources', icon: '📦' },
{ id: 'quest', label: 'Quest', icon: '📜' },
{ id: 'misc', label: 'Misc', icon: '📦' }
{ id: 'all', label: t('categories.all'), icon: '🎒' },
{ id: 'weapon', label: t('categories.weapon'), icon: '⚔️' },
{ id: 'armor', label: t('categories.armor'), icon: '🛡️' },
{ id: 'clothing', label: t('categories.clothing'), icon: '👕' },
{ id: 'backpack', label: t('categories.backpack'), icon: '🎒' },
{ id: 'tool', label: t('categories.tool'), icon: '🛠️' },
{ id: 'consumable', label: t('categories.consumable'), icon: '🍖' },
{ id: 'resource', label: t('categories.resource'), icon: '📦' },
{ id: 'quest', label: t('categories.quest'), icon: '📜' },
{ id: 'misc', label: t('categories.misc'), icon: '📦' }
]
// Use inventory directly as it now includes equipped items
@@ -100,7 +100,7 @@ function InventoryModal({
<div className="item-header-compact">
<span className="item-emoji-inline">{item.emoji}</span>
<h4 className={`item-name-compact text-tier-${item.tier || 0}`}>{getTranslatedText(item.name)}</h4>
{item.is_equipped && <span className="item-card-equipped">Equipped</span>}
{item.is_equipped && <span className="item-card-equipped">{t('game.equipped')}</span>}
</div>
<div className="item-stats-row">
@@ -149,17 +149,17 @@ function InventoryModal({
)}
{(item.unique_stats?.armor_penetration || item.stats?.armor_penetration) && (
<span className="stat-badge penetration">
💔 +{item.unique_stats?.armor_penetration || item.stats?.armor_penetration} Pen
💔 +{item.unique_stats?.armor_penetration || item.stats?.armor_penetration} {t('stats.pen')}
</span>
)}
{(item.unique_stats?.crit_chance || item.stats?.crit_chance) && (
<span className="stat-badge crit">
🎯 +{Math.round((item.unique_stats?.crit_chance || item.stats?.crit_chance) * 100)}% Crit
🎯 +{Math.round((item.unique_stats?.crit_chance || item.stats?.crit_chance) * 100)}% {t('stats.crit')}
</span>
)}
{(item.unique_stats?.accuracy || item.stats?.accuracy) && (
<span className="stat-badge accuracy">
👁 +{Math.round((item.unique_stats?.accuracy || item.stats?.accuracy) * 100)}% Acc
👁 +{Math.round((item.unique_stats?.accuracy || item.stats?.accuracy) * 100)}% {t('stats.acc')}
</span>
)}
{(item.unique_stats?.dodge_chance || item.stats?.dodge_chance) && (
@@ -169,34 +169,34 @@ function InventoryModal({
)}
{(item.unique_stats?.lifesteal || item.stats?.lifesteal) && (
<span className="stat-badge lifesteal">
🧛 +{Math.round((item.unique_stats?.lifesteal || item.stats?.lifesteal) * 100)}% Life
🧛 +{Math.round((item.unique_stats?.lifesteal || item.stats?.lifesteal) * 100)}% {t('stats.life')}
</span>
)}
{/* Attributes */}
{(item.unique_stats?.strength_bonus || item.stats?.strength_bonus) && (
<span className="stat-badge strength">
💪 +{item.unique_stats?.strength_bonus || item.stats?.strength_bonus} STR
💪 +{item.unique_stats?.strength_bonus || item.stats?.strength_bonus} {t('stats.str')}
</span>
)}
{(item.unique_stats?.agility_bonus || item.stats?.agility_bonus) && (
<span className="stat-badge agility">
🏃 +{item.unique_stats?.agility_bonus || item.stats?.agility_bonus} AGI
🏃 +{item.unique_stats?.agility_bonus || item.stats?.agility_bonus} {t('stats.agi')}
</span>
)}
{(item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus) && (
<span className="stat-badge endurance">
🏋 +{item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus} END
🏋 +{item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus} {t('stats.end')}
</span>
)}
{(item.unique_stats?.hp_bonus || item.stats?.hp_bonus) && (
<span className="stat-badge health">
+{item.unique_stats?.hp_bonus || item.stats?.hp_bonus} HP max
+{item.unique_stats?.hp_bonus || item.stats?.hp_bonus} {t('stats.hpMax')}
</span>
)}
{(item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus) && (
<span className="stat-badge stamina">
+{item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus} Stm max
+{item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus} {t('stats.stmMax')}
</span>
)}
@@ -217,7 +217,7 @@ function InventoryModal({
{hasDurability && (
<div className="durability-container">
<div className="durability-header">
<span>Durability</span>
<span>{t('game.durability')}</span>
<span className={
currentDurability < maxDurability * 0.2
? "durability-text-low"
@@ -246,18 +246,18 @@ function InventoryModal({
{/* Right: Actions */}
<div className="item-actions-section">
{item.consumable && (
<button className="action-btn use" onClick={() => onUseItem(item.item_id, item.id)}>Use</button>
<button className="action-btn use" onClick={() => onUseItem(item.item_id, item.id)}>{t('game.use')}</button>
)}
{item.equippable && !item.is_equipped && (
<button className="action-btn equip" onClick={() => onEquipItem(item.id)}>Equip</button>
<button className="action-btn equip" onClick={() => onEquipItem(item.id)}>{t('game.equip')}</button>
)}
{item.is_equipped && (
<button className="action-btn unequip" onClick={() => onUnequipItem(item.slot)}>Unequip</button>
<button className="action-btn unequip" onClick={() => onUnequipItem(item.slot)}>{t('game.unequip')}</button>
)}
<div className="drop-actions-group">
{item.quantity > 1 && (
<button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, 1)}>x1</button>
<button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, item.quantity)}>{t('game.dropAll')}</button>
)}
{item.quantity >= 5 && (
<button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, 5)}>x5</button>
@@ -266,7 +266,7 @@ function InventoryModal({
<button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, 10)}>x10</button>
)}
<button className={`action-btn drop ${item.quantity === 1 ? 'single' : ''}`} onClick={() => onDropItem(item.item_id, item.id, item.quantity === 1 ? 1 : item.quantity)}>
{item.quantity === 1 ? 'Drop' : 'All'}
{item.quantity === 1 ? t('game.drop') : t('game.dropAll')}
</button>
</div>
</div>
@@ -288,7 +288,7 @@ function InventoryModal({
<span className="metric-icon"></span>
<div className="metric-bar-container">
<div className="metric-text">
Weight: {(profile.current_weight || 0).toFixed(1)} / {profile.max_weight || 0} kg
{t('game.weight')}: {(profile.current_weight || 0).toFixed(1)} / {profile.max_weight || 0} kg
</div>
<div className="metric-bar">
<div
@@ -303,7 +303,7 @@ function InventoryModal({
<span className="metric-icon">📦</span>
<div className="metric-bar-container">
<div className="metric-text">
Volume: {(profile.current_volume || 0).toFixed(1)} / {profile.max_volume || 0} L
{t('game.volume')}: {(profile.current_volume || 0).toFixed(1)} / {profile.max_volume || 0} L
</div>
<div className="metric-bar">
<div
@@ -328,7 +328,7 @@ function InventoryModal({
) : (
<div className="backpack-status inactive">
<span className="backpack-icon">🚫</span>
<span>No Backpack Equipped</span>
<span>{t('game.noBackpack')}</span>
</div>
)}
<button className="close-btn" onClick={onClose}></button>
@@ -356,7 +356,7 @@ function InventoryModal({
<span className="search-icon">🔍</span>
<input
type="text"
placeholder="Search items..."
placeholder={t('game.searchItems')}
value={inventoryFilter}
onChange={(e: ChangeEvent<HTMLInputElement>) => onSetInventoryFilter(e.target.value)}
/>
@@ -366,32 +366,29 @@ function InventoryModal({
{filteredItems.length === 0 ? (
<div className="empty-state">
<span className="empty-icon">📦</span>
<p>No items found in this category</p>
<p>{t('game.noItemsFound')}</p>
</div>
) : (
inventoryCategoryFilter === 'all' ? (
<>
{/* Equipped */}
{filteredItems.some((i: any) => i.is_equipped) && (
{filteredItems.some((item: any) => item.is_equipped) && (
<>
<div className="category-header"> Equipped</div>
{filteredItems.filter((i: any) => i.is_equipped).map((item: any, i: number) => renderItemCard(item, i))}
<div className="category-header"> {t('game.equipped')}</div>
{filteredItems.filter((item: any) => item.is_equipped).map((item: any, i: number) => renderItemCard(item, i))}
</>
)}
{/* Categories */}
{categories.filter(c => c.id !== 'all').map(cat => {
const categoryItems = filteredItems.filter((i: any) => !i.is_equipped && i.type === cat.id);
if (categoryItems.length === 0) return null;
return (
<div key={cat.id}>
<div className="category-header">{cat.icon} {cat.label}</div>
{categoryItems.map((item: any, i: number) => renderItemCard(item, i))}
</div>
);
})}
{/* Backpack */}
{filteredItems.some((item: any) => !item.is_equipped) && (
<>
<div className="category-header">🎒 {t('game.backpack')}</div>
{filteredItems.filter((item: any) => !item.is_equipped).map((item: any, i: number) => renderItemCard(item, i))}
</>
)}
</>
) : (
/* Single category */
filteredItems.map((item: any, i: number) => renderItemCard(item, i))
)
)}

View File

@@ -82,7 +82,7 @@ function LocationView({
onRepair,
onUncraft
}: LocationViewProps) {
useTranslation()
const { t } = useTranslation()
return (
<div className="location-view">
<div className="location-info">
@@ -115,15 +115,15 @@ function LocationView({
onClick={isClickable ? handleClick : undefined}
style={isClickable ? { cursor: 'pointer' } : undefined}
>
{tag === 'workbench' && '🔧 Workbench'}
{tag === 'repair_station' && '🛠️ Repair Station'}
{tag === 'safe_zone' && '🛡️ Safe Zone'}
{tag === 'shop' && '🏪 Shop'}
{tag === 'shelter' && '🏠 Shelter'}
{tag === 'medical' && '⚕️ Medical'}
{tag === 'storage' && '📦 Storage'}
{tag === 'water_source' && '💧 Water'}
{tag === 'food_source' && '🍎 Food'}
{tag === 'workbench' && t('tags.workbench')}
{tag === 'repair_station' && t('tags.repairStation')}
{tag === 'safe_zone' && t('tags.safeZone')}
{tag === 'shop' && t('tags.shop')}
{tag === 'shelter' && t('tags.shelter')}
{tag === 'medical' && t('tags.medical')}
{tag === 'storage' && t('tags.storage')}
{tag === 'water_source' && t('tags.water')}
{tag === 'food_source' && t('tags.food')}
{tag !== 'workbench' && tag !== 'repair_station' && tag !== 'safe_zone' && tag !== 'shop' && tag !== 'shelter' && tag !== 'medical' && tag !== 'storage' && tag !== 'water_source' && tag !== 'food_source' && `🏷️ ${tag}`}
</span>
)
@@ -157,7 +157,7 @@ function LocationView({
{locationMessages.length > 0 && (
<div className="location-messages-log">
<h4>📜 Recent Activity</h4>
<h4>{t('location.recentActivity')}</h4>
<div className="messages-scroll">
{locationMessages.slice(-10).reverse().map((msg, idx) => (
<div key={idx} className="location-message-item">
@@ -173,7 +173,7 @@ function LocationView({
{/* Enemies */}
{location.npcs.filter((npc: any) => npc.type === 'enemy').length > 0 && (
<div className="entity-section enemies-section">
<h3> Enemies</h3>
<h3>{t('location.enemies')}</h3>
<div className="entity-list">
{location.npcs.filter((npc: any) => npc.type === 'enemy').map((enemy: any, i: number) => (
<div key={i} className="entity-card enemy-card">
@@ -188,13 +188,13 @@ function LocationView({
)}
<div className="entity-info">
<div className="entity-name enemy-name">{getTranslatedText(enemy.name)}</div>
{enemy.level && <div className="entity-level">Lv. {enemy.level}</div>}
{enemy.level && <div className="entity-level">{t('location.level')} {enemy.level}</div>}
</div>
<button
className="entity-action-btn combat-btn"
onClick={() => onInitiateCombat(enemy.id)}
>
Fight
{t('common.fight')}
</button>
</div>
))}
@@ -205,28 +205,28 @@ function LocationView({
{/* Corpses */}
{location.corpses && location.corpses.length > 0 && (
<div className="entity-section corpses-section">
<h3>💀 Corpses</h3>
<h3>{t('location.corpses')}</h3>
<div className="entity-list">
{location.corpses.map((corpse: any) => (
<div key={corpse.id} className="corpse-container">
<div className="entity-card corpse-card">
<div className="entity-info">
<div className="entity-name">{corpse.emoji} {getTranslatedText(corpse.name)}</div>
<div className="corpse-loot-count">{corpse.loot_count} item(s)</div>
<div className="corpse-loot-count">{corpse.loot_count} {t('location.items')}</div>
</div>
<button
className="entity-action-btn loot-btn"
onClick={() => onLootCorpse(String(corpse.id))}
disabled={corpse.loot_count === 0}
>
🔍 Examine
🔍 {t('common.examine')}
</button>
</div>
{expandedCorpse === String(corpse.id) && corpseDetails && corpseDetails.loot_items && (
<div className="corpse-details">
<div className="corpse-details-header">
<h4>Lootable Items:</h4>
<h4>{t('location.lootableItems')}</h4>
<button
className="close-btn"
onClick={() => {
@@ -244,7 +244,7 @@ function LocationView({
{item.emoji} {getTranslatedText(item.item_name)}
</div>
<div className="corpse-item-qty">
Qty: {item.quantity_min}{item.quantity_min !== item.quantity_max ? `-${item.quantity_max}` : ''}
{t('common.qty')}: {item.quantity_min}{item.quantity_min !== item.quantity_max ? `-${item.quantity_max}` : ''}
</div>
{item.required_tool && (
<div className={`corpse-item-tool ${item.has_tool ? 'has-tool' : 'needs-tool'}`}>
@@ -258,7 +258,7 @@ function LocationView({
disabled={!item.can_loot}
title={!item.can_loot ? `Requires ${getTranslatedText(item.required_tool_name)}` : 'Loot this item'}
>
{item.can_loot ? '📦 Loot' : '🔒'}
{item.can_loot ? `📦 ${t('common.loot')}` : '🔒'}
</button>
</div>
))}
@@ -267,7 +267,7 @@ function LocationView({
className="loot-all-btn"
onClick={() => onLootCorpseItem(String(corpse.id), null)}
>
📦 Loot All Available
📦 {t('common.lootAll')}
</button>
</div>
)}
@@ -280,16 +280,16 @@ function LocationView({
{/* Friendly NPCs */}
{location.npcs.filter((npc: any) => npc.type !== 'enemy').length > 0 && (
<div className="entity-section npcs-section">
<h3>👥 NPCs</h3>
<h3>{t('location.npcs')}</h3>
<div className="entity-list">
{location.npcs.filter((npc: any) => npc.type !== 'enemy').map((npc: any, i: number) => (
<div key={i} className="entity-card npc-card">
<span className="entity-icon">🧑</span>
<div className="entity-info">
<div className="entity-name">{getTranslatedText(npc.name)}</div>
{npc.level && <div className="entity-level">Lv. {npc.level}</div>}
{npc.level && <div className="entity-level">{t('location.level')} {npc.level}</div>}
</div>
<button className="entity-action-btn">Talk</button>
<button className="entity-action-btn">{t('common.talk')}</button>
</div>
))}
</div>
@@ -299,7 +299,7 @@ function LocationView({
{/* Items on Ground */}
{location.items.length > 0 && (
<div className="entity-section items-section">
<h3>📦 Items on Ground</h3>
<h3>{t('location.itemsOnGround')}</h3>
<div className="entity-list">
{location.items.map((item: any, i: number) => (
<div key={i} className="entity-card item-card">
@@ -323,37 +323,37 @@ function LocationView({
{item.quantity > 1 && <div className="entity-quantity">×{item.quantity}</div>}
</div>
<div className="item-info-btn-container">
<button className="entity-action-btn info" title="Item Info">Info</button>
<button className="entity-action-btn info" title="Item Info">{t('common.info')}</button>
<div className="item-info-tooltip">
{item.description && <div className="item-tooltip-desc">{getTranslatedText(item.description)}</div>}
{item.weight !== undefined && item.weight > 0 && (
<div className="item-tooltip-stat">
Weight: {item.weight}kg {item.quantity > 1 && `(Total: ${(item.weight * item.quantity).toFixed(2)}kg)`}
{t('stats.weight')}: {item.weight}kg {item.quantity > 1 && `(Total: ${(item.weight * item.quantity).toFixed(2)}kg)`}
</div>
)}
{item.volume !== undefined && item.volume > 0 && (
<div className="item-tooltip-stat">
📦 Volume: {item.volume}L {item.quantity > 1 && `(Total: ${(item.volume * item.quantity).toFixed(2)}L)`}
📦 {t('stats.volume')}: {item.volume}L {item.quantity > 1 && `(Total: ${(item.volume * item.quantity).toFixed(2)}L)`}
</div>
)}
{item.hp_restore && item.hp_restore > 0 && (
<div className="item-tooltip-stat"> HP Restore: +{item.hp_restore}</div>
<div className="item-tooltip-stat"> {t('stats.hpRestore')}: +{item.hp_restore}</div>
)}
{item.stamina_restore && item.stamina_restore > 0 && (
<div className="item-tooltip-stat"> Stamina Restore: +{item.stamina_restore}</div>
<div className="item-tooltip-stat"> {t('stats.staminaRestore')}: +{item.stamina_restore}</div>
)}
{item.damage_min !== undefined && item.damage_max !== undefined && (item.damage_min > 0 || item.damage_max > 0) && (
<div className="item-tooltip-stat">
Damage: {item.damage_min}-{item.damage_max}
{t('stats.damage')}: {item.damage_min}-{item.damage_max}
</div>
)}
{item.durability !== undefined && item.durability !== null && item.max_durability !== undefined && item.max_durability !== null && (
<div className="item-tooltip-stat">
🔧 Durability: {item.durability}/{item.max_durability}
🔧 {t('stats.durability')}: {item.durability}/{item.max_durability}
</div>
)}
{item.tier !== undefined && item.tier !== null && item.tier > 0 && (
<div className="item-tooltip-stat"> Tier: {item.tier}</div>
<div className="item-tooltip-stat"> {t('stats.tier')}: {item.tier}</div>
)}
</div>
</div>
@@ -362,21 +362,21 @@ function LocationView({
className="entity-action-btn pickup"
onClick={() => onPickup(item.id, 1)}
>
Pick Up
{t('common.pickUp')}
</button>
) : (
<div className="item-pickup-btn-container">
<button className="entity-action-btn pickup">Pick Up </button>
<button className="entity-action-btn pickup">{t('common.pickUp')} </button>
<div className="item-pickup-menu">
<button className="item-pickup-option" onClick={() => onPickup(item.id, 1)}>Pick Up 1</button>
<button className="item-pickup-option" onClick={() => onPickup(item.id, 1)}>{t('common.pickUp')} 1</button>
{item.quantity >= 5 && (
<button className="item-pickup-option" onClick={() => onPickup(item.id, 5)}>Pick Up 5</button>
<button className="item-pickup-option" onClick={() => onPickup(item.id, 5)}>{t('common.pickUp')} 5</button>
)}
{item.quantity >= 10 && (
<button className="item-pickup-option" onClick={() => onPickup(item.id, 10)}>Pick Up 10</button>
<button className="item-pickup-option" onClick={() => onPickup(item.id, 10)}>{t('common.pickUp')} 10</button>
)}
<button className="item-pickup-option" onClick={() => onPickup(item.id, item.quantity)}>
Pick Up All ({item.quantity})
{t('common.pickUpAll')} ({item.quantity})
</button>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import type { Location, Profile, CombatState } from './types'
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { getAssetPath } from '../../utils/assetPath'
import { getTranslatedText } from '../../utils/i18nUtils'
@@ -22,6 +23,7 @@ function MovementControls({
onMove,
onInteract
}: MovementControlsProps) {
const { t } = useTranslation()
// Force re-render every second to update cooldown timers
const [, forceUpdate] = useState(0)
@@ -71,23 +73,24 @@ function MovementControls({
const disabled = !available || !!combatState || movementCooldown > 0 || insufficientStamina || (profile?.is_dead ?? false)
// Build detailed tooltip text
const tooltipText = profile?.is_dead ? 'You are dead' :
movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` :
combatState ? 'Cannot travel during combat' :
insufficientStamina ? `Not enough stamina (need ${stamina}, have ${profile?.stamina ?? 0})` :
available ? `${destination}\nDistance: ${distance}m\nStamina: ${stamina}` :
`Cannot go ${direction}`
const tooltipText = profile?.is_dead ? t('messages.youAreDead') :
movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) :
combatState ? t('messages.cannotTravelCombat') :
insufficientStamina ? t('messages.notEnoughStamina', { need: stamina, have: profile?.stamina ?? 0 }) :
available ? `${destination}\n${t('game.distance')}: ${distance}m\n${t('game.stamina')}: ${stamina}` :
t('messages.cannotGo', { direction: t('directions.' + direction) })
return (
<button
key={direction}
className={`compass-btn ${className} ${disabled ? 'disabled' : ''} ${combatState ? 'in-combat' : ''}`}
onClick={() => onMove(direction)}
disabled={disabled}
className={`compass-btn ${className} ${disabled ? 'disabled' : ''}`}
title={tooltipText}
>
<span className="compass-arrow">{arrow}</span>
{available && movementCooldown > 0 ? (
<span className="compass-cost">{movementCooldown}s</span>
<span className="compass-cost" title={t('messages.waitBeforeMoving', { seconds: movementCooldown })}>{movementCooldown}s</span>
) : available && (
<span className="compass-cost">{stamina}</span>
)}
@@ -98,7 +101,7 @@ function MovementControls({
return (
<>
<div className="movement-controls">
<h3>🧭 Travel</h3>
<h3>{t('game.travel')}</h3>
<div className="compass-grid">
{/* Top row */}
{renderCompassButton('northwest', '↖️', 'nw')}
@@ -121,7 +124,7 @@ function MovementControls({
{/* Cooldown indicator */}
{movementCooldown > 0 && (
<div className="cooldown-indicator">
Wait {movementCooldown}s before moving
{t('messages.waitBeforeMovingSimple', { seconds: movementCooldown })}
</div>
)}
@@ -132,9 +135,9 @@ function MovementControls({
onClick={() => onMove('up')}
className="special-btn"
disabled={!!combatState || movementCooldown > 0}
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go up\nStamina: ${getStaminaCost('up')}`}
title={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : `${t('directions.up')}\n${t('game.stamina')}: ${getStaminaCost('up')}`}
>
Up <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('up')}`}</span>
{t('directions.up')} <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('up')}`}</span>
</button>
)}
{location.directions.includes('down') && (
@@ -142,9 +145,9 @@ function MovementControls({
onClick={() => onMove('down')}
className="special-btn"
disabled={!!combatState || movementCooldown > 0}
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go down\nStamina: ${getStaminaCost('down')}`}
title={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : `${t('directions.down')}\n${t('game.stamina')}: ${getStaminaCost('down')}`}
>
Down <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('down')}`}</span>
{t('directions.down')} <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('down')}`}</span>
</button>
)}
{location.directions.includes('enter') && (
@@ -152,9 +155,9 @@ function MovementControls({
onClick={() => onMove('enter')}
className="special-btn"
disabled={!!combatState || movementCooldown > 0}
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Enter\nStamina: ${getStaminaCost('enter')}`}
title={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : `${t('directions.enter')}\n${t('game.stamina')}: ${getStaminaCost('enter')}`}
>
🚪 Enter <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('enter')}`}</span>
🚪 {t('directions.enter')} <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('enter')}`}</span>
</button>
)}
{location.directions.includes('inside') && (
@@ -162,9 +165,9 @@ function MovementControls({
onClick={() => onMove('inside')}
className="special-btn"
disabled={!!combatState || movementCooldown > 0}
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go inside\nStamina: ${getStaminaCost('inside')}`}
title={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : `${t('directions.inside')}\n${t('game.stamina')}: ${getStaminaCost('inside')}`}
>
🚪 Inside <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('inside')}`}</span>
🚪 {t('directions.inside')} <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('inside')}`}</span>
</button>
)}
{location.directions.includes('exit') && (
@@ -172,9 +175,9 @@ function MovementControls({
onClick={() => onMove('exit')}
className="special-btn"
disabled={!!combatState || movementCooldown > 0}
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : 'Exit'}
title={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : t('directions.exit')}
>
🚪 Exit
🚪 {t('directions.exit')}
</button>
)}
{location.directions.includes('outside') && (
@@ -182,9 +185,9 @@ function MovementControls({
onClick={() => onMove('outside')}
className="special-btn"
disabled={!!combatState || movementCooldown > 0}
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go outside\nStamina: ${getStaminaCost('outside')}`}
title={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : `${t('directions.outside')}\n${t('game.stamina')}: ${getStaminaCost('outside')}`}
>
🚪 Outside <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('outside')}`}</span>
🚪 {t('directions.outside')} <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('outside')}`}</span>
</button>
)}
</div>
@@ -193,7 +196,7 @@ function MovementControls({
{/* Surroundings - outside movement controls */}
{location.interactables && location.interactables.length > 0 && (
<div className="interactables-section">
<h3>🌿 Surroundings</h3>
<h3>{t('game.surroundings')}</h3>
{location.interactables.map((interactable: any) => (
<div key={interactable.instance_id} className="interactable-card">
{interactable.image_path && (

View File

@@ -50,7 +50,7 @@ function Workbench({
onRepair,
onUncraft
}: WorkbenchProps) {
useTranslation()
const { t } = useTranslation()
const [selectedItem, setSelectedItem] = useState<any>(null)
@@ -116,8 +116,8 @@ function Workbench({
return (
<div className="workbench-empty-state">
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🔧</div>
<h3>Select an item to view details</h3>
<p>Choose an item from the list on the left</p>
<h3>{t('crafting.selectItem')}</h3>
<p>{t('crafting.chooseFromList')}</p>
</div>
)
}
@@ -155,13 +155,13 @@ function Workbench({
<div className="item-stats" style={{ display: 'flex', gap: '1rem', justifyContent: 'center', flexWrap: 'wrap' }}>
{Object.entries(item.base_stats || item.stats).map(([key, value]) => {
const icons: Record<string, string> = {
weight_capacity: '⚖️ Weight',
volume_capacity: '📦 Volume',
armor: '🛡️ Armor',
hp_max: '❤️ Max HP',
stamina_max: '⚡ Max Stamina',
damage_min: '⚔️ Damage Min',
damage_max: '⚔️ Damage Max'
weight_capacity: `⚖️ ${t('game.weight')}`,
volume_capacity: `📦 ${t('game.volume')}`,
armor: `🛡️ ${t('stats.armor')}`,
hp_max: `❤️ ${t('stats.maxHp')}`,
stamina_max: `${t('stats.maxStamina')}`,
damage_min: `⚔️ ${t('stats.damage')} Min`,
damage_max: `⚔️ ${t('stats.damage')} Max`
}
const label = icons[key] || key.replace('_', ' ')
const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : ''
@@ -173,7 +173,7 @@ function Workbench({
})}
</div>
<p style={{ fontSize: '0.8rem', color: '#888', fontStyle: 'italic', marginTop: '0.5rem', textAlign: 'center' }}>
* Potential base stats. Actual stats may vary.
* {t('crafting.potentialBaseStats')}
</p>
</div>
)}
@@ -183,13 +183,13 @@ function Workbench({
<div className="item-stats" style={{ display: 'flex', gap: '1rem', justifyContent: 'center', marginTop: '1rem', flexWrap: 'wrap' }}>
{Object.entries(item.unique_item_data?.unique_stats ?? item.unique_item_data ?? item.base_stats ?? item.stats ?? {}).filter(([k]) => !['id', 'item_id', 'durability', 'max_durability', 'created_at', 'tier'].includes(k)).map(([key, value]) => {
const icons: Record<string, string> = {
weight_capacity: '⚖️ Weight',
volume_capacity: '📦 Volume',
armor: '🛡️ Armor',
hp_max: '❤️ Max HP',
stamina_max: '⚡ Max Stamina',
damage_min: '⚔️ Damage Min',
damage_max: '⚔️ Damage Max'
weight_capacity: `⚖️ ${t('game.weight')}`,
volume_capacity: `📦 ${t('game.volume')}`,
armor: `🛡️ ${t('stats.armor')}`,
hp_max: `❤️ ${t('stats.maxHp')}`,
stamina_max: `${t('stats.maxStamina')}`,
damage_min: `⚔️ ${t('stats.damage')} Min`,
damage_max: `⚔️ ${t('stats.damage')} Max`
}
const label = icons[key] || key.replace('_', ' ')
const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : ''
@@ -206,30 +206,28 @@ function Workbench({
{workbenchTab === 'craft' && (
<>
<div className="detail-requirements">
<h4>📊 Requirements</h4>
<h4>{t('crafting.requirements')}</h4>
{item.craft_level && item.craft_level > 1 && (
<div className={`requirement-item ${item.meets_level ? 'met' : 'missing'}`}>
<span>Level {item.craft_level} Required</span>
<span>{item.meets_level ? '✅' : `❌ (Lv. ${profile?.level || 1})`}</span>
</div>
)}
<div className={`requirement-item ${item.meets_level ? 'met' : 'missing'}`}>
<span>{t('crafting.levelRequired', { level: item.craft_level })}</span>
<span>{item.meets_level ? '✅' : `❌ (Lv. ${profile?.level || 1})`}</span>
</div>
{item.tools && item.tools.length > 0 && (
<>
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>Tools</h5>
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>{t('crafting.tools')}</h5>
{item.tools.map((tool: any, i: number) => (
<div key={i} className={`requirement-item ${tool.has_tool ? 'met' : 'missing'}`}>
<span>{tool.emoji} {getTranslatedText(tool.name)}</span>
<span>
{tool.has_tool ? `${tool.tool_durability}/${tool.max_durability} (Cost: ${tool.durability_cost})` : `Missing (Cost: ${tool.durability_cost})`}
{tool.has_tool ? `${tool.tool_durability}/${tool.max_durability} (${t('crafting.cost')}: ${tool.durability_cost})` : `${t('crafting.missing')} (${t('crafting.cost')}: ${tool.durability_cost})`}
</span>
</div>
))}
</>
)}
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>Materials</h5>
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>{t('crafting.materials')}</h5>
{item.materials && item.materials.length > 0 ? (
item.materials.map((mat: any, i: number) => (
<div key={i} className={`requirement-item ${mat.has_enough ? 'met' : 'missing'}`}>
@@ -239,7 +237,7 @@ function Workbench({
))
) : (
<div className="requirement-item met">
<span>No materials required</span>
<span>{t('crafting.noMaterialsRequired')}</span>
</div>
)}
</div>
@@ -252,12 +250,12 @@ function Workbench({
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
>
<span>
{!item.meets_level ? `Need Level ${item.craft_level}` :
!item.can_craft ? 'Missing Requirements' : '🔨 Craft Item'}
{!item.meets_level ? t('crafting.levelRequired', { level: item.craft_level }) :
!item.can_craft ? t('crafting.missingRequirements') : t('crafting.craftItem')}
</span>
{item.can_craft && (
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{item.stamina_cost || 5} Stamina
{t('crafting.staminaCost', { cost: item.stamina_cost || 5 })}
</span>
)}
</button>
@@ -268,10 +266,10 @@ function Workbench({
{workbenchTab === 'repair' && (
<>
<div className="detail-requirements">
<h4>🔧 Repair Status</h4>
<h4>🔧 {workbenchTab === 'repair' ? t('game.repair') : t('game.salvage')}</h4>
{!item.needs_repair ? (
<p className="repair-info full-durability" style={{ textAlign: 'center', marginBottom: '1rem' }}> Item is in perfect condition</p>
<p className="repair-info full-durability" style={{ textAlign: 'center', marginBottom: '1rem' }}>{t('crafting.perfectCondition')}</p>
) : (
<>
<div className="repair-preview-text">
@@ -333,12 +331,12 @@ function Workbench({
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
>
<span>
{!item.needs_repair ? 'Already Full' :
!item.can_repair ? 'Missing Requirements' : '🛠️ Repair Item'}
{!item.needs_repair ? t('crafting.alreadyFull') :
!item.can_repair ? t('crafting.missingRequirements') : t('crafting.repairItem')}
</span>
{item.needs_repair && item.can_repair && (
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{item.stamina_cost || 3} Stamina
{t('crafting.staminaCost', { cost: item.stamina_cost || 3 })}
</span>
)}
</button>
@@ -349,7 +347,7 @@ function Workbench({
{workbenchTab === 'uncraft' && (
<>
<div className="detail-requirements">
<h4> Salvage Preview</h4>
<h4> {t('game.salvage')}</h4>
{/* Show durability bar if we have durability data */}
{(item.unique_item_data || item.durability_percent !== undefined) && (
@@ -382,7 +380,7 @@ function Workbench({
<>
{durabilityRatio < 1.0 && (
<div className="uncraft-warning" style={{ marginBottom: '1rem', color: '#ffc107' }}>
Yield reduced by {Math.round((1 - durabilityRatio) * 100)}% due to damage
{t('crafting.yieldReduced', { percent: Math.round((1 - durabilityRatio) * 100) })}
</div>
)}
@@ -409,15 +407,15 @@ function Workbench({
className="uncraft-btn"
disabled={(profile?.stamina || 0) < (item.stamina_cost || 1)}
onClick={() => {
if (window.confirm(`Are you sure you want to salvage ${getTranslatedText(item.name)}? This cannot be undone.`)) {
if (window.confirm(t('crafting.confirmSalvage', { name: getTranslatedText(item.name) }))) {
onUncraft(item.unique_item_id, item.inventory_id)
}
}}
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', backgroundColor: '#d32f2f', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
>
<span> Salvage Item</span>
<span> {t('game.salvage')}</span>
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{item.stamina_cost || 2} Stamina
{t('crafting.staminaCost', { cost: item.stamina_cost || 2 })}
</span>
</button>
</div>
@@ -429,14 +427,14 @@ function Workbench({
}
const categories = [
{ id: 'all', label: 'All', icon: '🎒' },
{ id: 'weapon', label: 'Weapons', icon: '⚔️' },
{ id: 'armor', label: 'Armor', icon: '🛡️' },
{ id: 'clothing', label: 'Clothing', icon: '👕' },
{ id: 'tool', label: 'Tools', icon: '🛠️' },
{ id: 'consumable', label: 'Consumables', icon: '🍖' },
{ id: 'resource', label: 'Resources', icon: '📦' },
{ id: 'misc', label: 'Misc', icon: '📦' }
{ id: 'all', label: t('categories.all'), icon: '🎒' },
{ id: 'weapon', label: t('categories.weapon'), icon: '⚔️' },
{ id: 'armor', label: t('categories.armor'), icon: '🛡️' },
{ id: 'clothing', label: t('categories.clothing'), icon: '👕' },
{ id: 'tool', label: t('categories.tool'), icon: '🛠️' },
{ id: 'consumable', label: t('categories.consumable'), icon: '🍖' },
{ id: 'resource', label: t('categories.resource'), icon: '📦' },
{ id: 'misc', label: t('categories.misc'), icon: '📦' }
]
return (
@@ -445,25 +443,25 @@ function Workbench({
}}>
<div className="workbench-menu">
<div className="workbench-header">
<h3>🔧 Workbench</h3>
<h3>{t('game.workbench')}</h3>
<div className="workbench-tabs">
<button
className={`tab-btn ${workbenchTab === 'craft' ? 'active' : ''}`}
onClick={() => onSwitchTab('craft')}
>
🔨 Craft
{t('game.craft')}
</button>
<button
className={`tab-btn ${workbenchTab === 'repair' ? 'active' : ''}`}
onClick={() => onSwitchTab('repair')}
>
🛠 Repair
{t('game.repair')}
</button>
<button
className={`tab-btn ${workbenchTab === 'uncraft' ? 'active' : ''}`}
onClick={() => onSwitchTab('uncraft')}
>
Salvage
{t('game.salvage')}
</button>
</div>
<button className="close-btn" onClick={onCloseCrafting}></button>
@@ -472,7 +470,7 @@ function Workbench({
<div className="workbench-content-grid">
{/* Column 1: Categories Sidebar */}
<div className="workbench-sidebar">
<h4 className="sidebar-title">Categories</h4>
<h4 className="sidebar-title">{t('location.lootableItems').replace(':', '')}</h4>
<div className="category-list">
{categories.map(cat => (
<button
@@ -492,7 +490,7 @@ function Workbench({
<div className="workbench-filters">
<input
type="text"
placeholder="🔍 Filter items..."
placeholder={t('game.searchItems')}
value={workbenchTab === 'craft' ? craftFilter : workbenchTab === 'repair' ? repairFilter : uncraftFilter}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
if (workbenchTab === 'craft') onSetCraftFilter(e.target.value)
@@ -519,9 +517,7 @@ function Workbench({
return matchesSearch && matchesCategory
}).length === 0 ? (
<div className="empty-state">
{workbenchTab === 'craft' ? 'No craftable items found.' :
workbenchTab === 'repair' ? 'No repairable items found.' :
'No salvageable items found.'}
{t('game.noItemsFound')}
</div>
) : (
items
@@ -573,7 +569,7 @@ function Workbench({
>
{getTranslatedText(item.name)}
</span>
{item.location === 'equipped' && <span className="item-card-equipped" style={{ marginLeft: '0.5rem' }}>Equipped</span>}
{item.location === 'equipped' && <span className="item-card-equipped" style={{ marginLeft: '0.5rem' }}>{t('game.equipped')}</span>}
</div>
<div className="item-meta-row">

View File

@@ -217,7 +217,7 @@ export function useGameEngine(
}, [])
const addCombatLogEntry = useCallback((entry: CombatLogEntry) => {
setCombatLog((prev: CombatLogEntry[]) => [entry, ...prev])
setCombatLog((prev: CombatLogEntry[]) => [{ ...entry, id: entry.id || Date.now() + Math.random() }, ...prev])
}, [])
// Fetch functions
@@ -337,6 +337,7 @@ export function useGameEngine(
pvpRes.data.pvp_combat.defender :
pvpRes.data.pvp_combat.attacker
setCombatLog([{
id: 'pvp-combat-init',
time: timeStr,
message: `PvP combat with ${opponent.username} (Lv. ${opponent.level})!`,
isPlayer: true
@@ -351,6 +352,7 @@ export function useGameEngine(
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
setCombatLog([{
id: 'combat-in-progress',
time: timeStr,
message: 'Combat in progress...',
isPlayer: true
@@ -402,8 +404,9 @@ export function useGameEngine(
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
setCombatLog([{
id: Date.now() + Math.random(),
time: timeStr,
message: `⚠️ ${encounter.combat.npc_name} ambushes you!`,
message: { type: 'combat_start', data: { npc_name: encounter.combat.npc_name } },
isPlayer: false
}])
@@ -503,10 +506,23 @@ export function useGameEngine(
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
const messages = data.message.split('\n').filter((m: string) => m.trim())
const newEntries = messages.map((msg: string) => ({
const parsedMessages = messages.map((msg: string) => {
try {
if (msg.trim().startsWith('{')) {
const parsed = JSON.parse(msg)
if (parsed.type && parsed.data) return parsed
}
} catch (e) { }
return msg
})
const newEntries = parsedMessages.map((msg: any) => ({
id: `item-use-${Date.now()}-${Math.random()}`,
time: timeStr,
message: msg,
isPlayer: !msg.includes('attacks')
isPlayer: typeof msg === 'object'
? msg.type !== 'enemy_attack' && msg.type !== 'flee_fail'
: !msg.includes('attacks') && !msg.includes('hits')
}))
setCombatLog((prev: any) => [...newEntries, ...prev])
@@ -688,8 +704,9 @@ export function useGameEngine(
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
setCombatLog([{
id: Date.now() + Math.random(),
time: timeStr,
message: `Combat started with ${response.data.combat.npc_name}!`,
message: { type: 'combat_start', data: { npc_name: response.data.combat.npc_name } },
isPlayer: true
}])

View File

@@ -56,8 +56,9 @@ export interface Profile {
}
export interface CombatLogEntry {
id: string | number
time: string
message: string
message: string | { type: string; data: any }
isPlayer: boolean
}

View File

@@ -10,7 +10,16 @@
"no": "No",
"game": "Game",
"leaderboards": "Leaderboards",
"account": "Account"
"account": "Account",
"info": "Info",
"talk": "Talk",
"loot": "Loot",
"lootAll": "Loot All Available",
"examine": "Examine",
"fight": "Fight",
"pickUp": "Pick Up",
"pickUpAll": "Pick Up All",
"qty": "Qty"
},
"auth": {
"login": "Login",
@@ -22,7 +31,23 @@
"forgotPassword": "Forgot Password?",
"createAccount": "Create Account",
"alreadyHaveAccount": "Already have an account?",
"dontHaveAccount": "Don't have an account?"
"dontHaveAccount": "Don't have an account?",
"rememberMe": "Remember me",
"loginTitle": "Welcome Back",
"registerTitle": "Create Account",
"loginSubtitle": "Sign in to continue your journey",
"registerSubtitle": "Join the survivors"
},
"characters": {
"title": "Select Character",
"createNew": "Create New Character",
"play": "Play",
"delete": "Delete",
"noCharacters": "No characters yet",
"createFirst": "Create your first character to begin",
"name": "Character Name",
"class": "Class",
"level": "Level"
},
"game": {
"travel": "🧭 Travel",
@@ -40,10 +65,41 @@
"use": "Use",
"equip": "Equip",
"unequip": "Unequip",
"attack": "Attack",
"flee": "Flee",
"attack": "⚔️ Attack",
"flee": "🏃 Flee",
"rest": "Rest",
"onlineCount": "{{count}} Online"
"onlineCount": "{{count}} Online",
"searchItems": "Search items...",
"equipped": "Equipped",
"backpack": "Backpack",
"noBackpack": "No Backpack Equipped",
"distance": "Distance",
"stamina": "Stamina",
"weight": "Weight",
"volume": "Volume",
"durability": "Durability",
"noItemsFound": "No items found in this category"
},
"location": {
"recentActivity": "📜 Recent Activity",
"enemies": "⚔️ Enemies",
"corpses": "💀 Corpses",
"npcs": "👥 NPCs",
"itemsOnGround": "📦 Items on Ground",
"lootableItems": "Lootable Items:",
"items": "item(s)",
"level": "Lv."
},
"tags": {
"workbench": "🔧 Workbench",
"repairStation": "🛠️ Repair Station",
"safeZone": "🛡️ Safe Zone",
"shop": "🏪 Shop",
"shelter": "🏠 Shelter",
"medical": "⚕️ Medical",
"storage": "📦 Storage",
"water": "💧 Water",
"food": "🍎 Food"
},
"stats": {
"hp": "❤️ HP",
@@ -53,8 +109,8 @@
"xp": "⭐ XP",
"level": "Level",
"unspentPoints": "⭐ Unspent",
"weight": "⚖️ Weight",
"volume": "📦 Volume",
"weight": "Weight",
"volume": "Volume",
"strength": "💪 STR",
"strengthFull": "Strength",
"strengthDesc": "Increases melee damage and carry capacity",
@@ -68,10 +124,23 @@
"intellectFull": "Intellect",
"intellectDesc": "Enhances crafting and resource gathering",
"armor": "🛡️ Armor",
"damage": "⚔️ Damage",
"durability": "Durability"
"damage": "Damage",
"durability": "Durability",
"tier": "Tier",
"hpRestore": "HP Restore",
"staminaRestore": "Stamina Restore",
"pen": "Pen",
"crit": "Crit",
"acc": "Acc",
"life": "Life",
"str": "STR",
"agi": "AGI",
"end": "END",
"hpMax": "HP max",
"stmMax": "Stm max"
},
"combat": {
"title": "Combat",
"inCombat": "In Combat",
"yourTurn": "Your Turn",
"enemyTurn": "Enemy's Turn",
@@ -80,7 +149,20 @@
"youDied": "You Died",
"respawn": "Respawn",
"fleeSuccess": "You escaped!",
"fleeFailed": "Failed to escape!"
"fleeFailed": "Failed to escape!",
"enemyHp": "Enemy HP",
"playerHp": "Your HP",
"combatLog": "Combat Log",
"attacking": "Attacking",
"defending": "Defending",
"messages": {
"combat_start": "Combat started with {{enemy}}!",
"player_attack": "You attack for {{damage}} damage!",
"enemy_attack": "{{enemy}} attacks for {{damage}} damage!",
"victory": "Victory! Defeated {{enemy}}",
"flee_fail": "Failed to flee! {{enemy}} attacks for {{damage}} damage!"
},
"turnTimer": "Turn Timer"
},
"equipment": {
"head": "Head",
@@ -104,7 +186,16 @@
"staminaCost": "⚡ {{cost}} Stamina",
"alreadyFull": "Already Full",
"perfectCondition": "✅ Item is in perfect condition",
"yieldReduced": "⚠️ Yield reduced by {{percent}}% due to damage"
"yieldReduced": "⚠️ Yield reduced by {{percent}}% due to damage",
"selectItem": "Select an item to view details",
"chooseFromList": "Choose an item from the list on the left",
"yield": "Yield",
"repairCost": "Repair Cost",
"noMaterialsRequired": "No materials required",
"missing": "Missing",
"cost": "Cost",
"potentialBaseStats": "Potential base stats. Actual stats may vary.",
"confirmSalvage": "Are you sure you want to salvage {{name}}? This cannot be undone."
},
"categories": {
"all": "All Items",
@@ -119,13 +210,38 @@
"misc": "Misc"
},
"messages": {
"notEnoughStamina": "Not enough stamina",
"notEnoughStamina": "Not enough stamina (need {{need}}, have {{have}})",
"inventoryFull": "Inventory full",
"itemDropped": "Item dropped",
"itemPickedUp": "Item picked up",
"waitBeforeMoving": "Wait {{seconds}}s before moving",
"cannotTravelInCombat": "Cannot travel during combat",
"cannotInteractInCombat": "Cannot interact during combat"
"cannotInteractInCombat": "Cannot interact during combat",
"interactionCooldown": "Wait {{seconds}}s before interacting again",
"youAreDead": "You are dead",
"cannotTravelCombat": "Cannot travel during combat",
"cannotGo": "Cannot go {{direction}}",
"enemyAppeared": "A {{name}} has appeared!",
"enemyDespawned": "A wandering enemy has left the area",
"corpsesDecayed": "{{count}} corpses have decayed",
"itemsDecayed": "{{count}} dropped items have decayed",
"waitBeforeMovingSimple": "Wait {{seconds}}s before moving"
},
"directions": {
"north": "North",
"south": "South",
"east": "East",
"west": "West",
"northeast": "Northeast",
"northwest": "Northwest",
"southeast": "Southeast",
"southwest": "Southwest",
"up": "Up",
"down": "Down",
"inside": "Inside",
"outside": "Outside",
"enter": "Enter",
"exit": "Exit"
},
"landing": {
"heroTitle": "Echoes of the Ash",

View File

@@ -10,19 +10,44 @@
"no": "No",
"game": "Juego",
"leaderboards": "Clasificación",
"account": "Cuenta"
"account": "Cuenta",
"info": "Info",
"talk": "Hablar",
"loot": "Saquear",
"lootAll": "Saquear Todo",
"examine": "Examinar",
"fight": "Luchar",
"pickUp": "Recoger",
"pickUpAll": "Recoger Todo",
"qty": "Cant"
},
"auth": {
"login": "Iniciar Sesión",
"logout": "Cerrar Sesión",
"login": "Iniciar sesión",
"logout": "Cerrar sesión",
"register": "Registrarse",
"username": "Usuario",
"password": "Contraseña",
"email": "Correo",
"email": "Correo electrónico",
"forgotPassword": "¿Olvidaste tu contraseña?",
"createAccount": "Crear Cuenta",
"createAccount": "Crear cuenta",
"alreadyHaveAccount": "¿Ya tienes una cuenta?",
"dontHaveAccount": "¿No tienes cuenta?"
"dontHaveAccount": "¿No tienes una cuenta?",
"rememberMe": "Recordarme",
"loginTitle": "Bienvenido de nuevo",
"registerTitle": "Crear cuenta",
"loginSubtitle": "Inicia sesión para continuar tu viaje",
"registerSubtitle": "Únete a los supervivientes"
},
"characters": {
"title": "Seleccionar Personaje",
"createNew": "Crear Nuevo Personaje",
"play": "Jugar",
"delete": "Eliminar",
"noCharacters": "Aún no hay personajes",
"createFirst": "Crea tu primer personaje para comenzar",
"name": "Nombre del Personaje",
"class": "Clase",
"level": "Nivel"
},
"game": {
"travel": "🧭 Viajar",
@@ -33,17 +58,48 @@
"workbench": "🔧 Banco de Trabajo",
"craft": "🔨 Fabricar",
"repair": "🛠️ Reparar",
"salvage": "♻️ Desmontar",
"salvage": "♻️ Desguazar",
"pickUp": "Recoger",
"drop": "Soltar",
"dropAll": "Todo",
"use": "Usar",
"equip": "Equipar",
"unequip": "Desequipar",
"attack": "Atacar",
"flee": "Huir",
"attack": "⚔️ Atacar",
"flee": "🏃 Huir",
"rest": "Descansar",
"onlineCount": "{{count}} En línea"
"onlineCount": "{{count}} En línea",
"searchItems": "Buscar objetos...",
"equipped": "Equipado",
"backpack": "Mochila",
"noBackpack": "Sin mochila equipada",
"distance": "Distancia",
"stamina": "Aguante",
"weight": "Peso",
"volume": "Volumen",
"durability": "Durabilidad",
"noItemsFound": "No se encontraron objetos en esta categoría"
},
"location": {
"recentActivity": "📜 Actividad Reciente",
"enemies": "⚔️ Enemigos",
"corpses": "💀 Cadáveres",
"npcs": "👥 NPCs",
"itemsOnGround": "📦 Objetos en el Suelo",
"lootableItems": "Objetos Saqueables:",
"items": "objeto(s)",
"level": "Nv."
},
"tags": {
"workbench": "🔧 Banco de Trabajo",
"repairStation": "🛠️ Estación de Reparación",
"safeZone": "🛡️ Zona Segura",
"shop": "🏪 Tienda",
"shelter": "🏠 Refugio",
"medical": "⚕️ Médico",
"storage": "📦 Almacén",
"water": "💧 Agua",
"food": "🍎 Comida"
},
"stats": {
"hp": "❤️ Vida",
@@ -53,34 +109,60 @@
"xp": "⭐ XP",
"level": "Nivel",
"unspentPoints": "⭐ Sin gastar",
"weight": "⚖️ Peso",
"volume": "📦 Volumen",
"weight": "Peso",
"volume": "Volumen",
"strength": "💪 FUE",
"strengthFull": "Fuerza",
"strengthDesc": "Aumenta el daño cuerpo a cuerpo y capacidad de carga",
"strengthDesc": "Aumenta el daño cuerpo a cuerpo y la capacidad de carga",
"agility": "🏃 AGI",
"agilityFull": "Agilidad",
"agilityDesc": "Mejora la esquiva y golpes críticos",
"agilityDesc": "Mejora la probabilidad de esquivar y los golpes críticos",
"endurance": "🛡️ RES",
"enduranceFull": "Resistencia",
"enduranceDesc": "Aumenta la vida y energía",
"enduranceDesc": "Aumenta la vida y el aguante",
"intellect": "🧠 INT",
"intellectFull": "Intelecto",
"intellectDesc": "Mejora la fabricación y recolección",
"intellectDesc": "Mejora la fabricación y recolección de recursos",
"armor": "🛡️ Armadura",
"damage": "⚔️ Daño",
"durability": "Durabilidad"
"damage": "Daño",
"durability": "Durabilidad",
"tier": "Nivel",
"hpRestore": "Restaura Vida",
"staminaRestore": "Restaura Aguante",
"pen": "Pen",
"crit": "Crit",
"acc": "Prec",
"life": "Vida",
"str": "FUE",
"agi": "AGI",
"end": "RES",
"hpMax": "Vida máx",
"stmMax": "Agua. máx"
},
"combat": {
"title": "Combate",
"inCombat": "En Combate",
"yourTurn": "Tu Turno",
"enemyTurn": "Turno del Enemigo",
"victory": "¡Victoria!",
"defeat": "Derrota",
"youDied": "Has Muerto",
"respawn": "Revivir",
"fleeSuccess": Escapaste!",
"fleeFailed": "¡No pudiste escapar!"
"respawn": "Reaparecer",
"fleeSuccess": Has escapado!",
"fleeFailed": "¡No has podido escapar!",
"enemyHp": "Vida del Enemigo",
"playerHp": "Tu Vida",
"combatLog": "Registro de Combate",
"turnTimer": "Temporizador de Turno",
"attacking": "Atacando",
"defending": "Defendiendo",
"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!"
}
},
"equipment": {
"head": "Cabeza",
@@ -96,20 +178,29 @@
"requirements": "📊 Requisitos",
"materials": "Materiales",
"tools": "Herramientas",
"levelRequired": "Nivel {{level}} Requerido",
"levelRequired": "Requiere Nivel {{level}}",
"missingRequirements": "Faltan Requisitos",
"craftItem": "🔨 Fabricar",
"repairItem": "🛠️ Reparar",
"salvageItem": "♻️ Desmontar",
"staminaCost": "⚡ {{cost}} Energía",
"alreadyFull": "Ya está Completo",
"perfectCondition": "✅ El objeto está en perfecto estado",
"yieldReduced": "⚠️ Rendimiento reducido {{percent}}% por daño"
"salvageItem": "♻️ Desguazar",
"staminaCost": "⚡ {{cost}} Aguante",
"alreadyFull": "Ya está completo",
"perfectCondition": "✅ El objeto está en perfectas condiciones",
"yieldReduced": "⚠️ Rendimiento reducido un {{percent}}% por daño",
"selectItem": "Selecciona un objeto para ver detalles",
"chooseFromList": "Elige un objeto de la lista de la izquierda",
"yield": "Rendimiento",
"repairCost": "Coste de Reparación",
"noMaterialsRequired": "No requiere materiales",
"missing": "Falta",
"cost": "Coste",
"potentialBaseStats": "Estadísticas base potenciales. Las estadísticas reales pueden variar.",
"confirmSalvage": "¿Estás seguro de que quieres desguazar {{name}}? Esto no se puede deshacer."
},
"categories": {
"all": "Todos",
"all": "Todos los Objetos",
"weapon": "Armas",
"armor": "Armadura",
"armor": "Armaduras",
"clothing": "Ropa",
"backpack": "Mochilas",
"tool": "Herramientas",
@@ -119,16 +210,41 @@
"misc": "Varios"
},
"messages": {
"notEnoughStamina": "No tienes suficiente energía",
"notEnoughStamina": "No tienes suficiente aguante (necesitas {{need}}, tienes {{have}})",
"inventoryFull": "Inventario lleno",
"itemDropped": "Objeto soltado",
"itemPickedUp": "Objeto recogido",
"waitBeforeMoving": "Espera {{seconds}}s antes de moverte",
"cannotTravelInCombat": "No puedes viajar en combate",
"cannotInteractInCombat": "No puedes interactuar en combate"
"cannotTravelInCombat": "No puedes viajar durante el combate",
"cannotInteractInCombat": "No puedes interactuar durante el combate",
"interactionCooldown": "Espera {{seconds}}s antes de interactuar de nuevo",
"youAreDead": "Estás muerto",
"cannotTravelCombat": "No puedes viajar durante el combate",
"cannotGo": "No puedes ir al {{direction}}",
"enemyAppeared": "¡Un {{name}} ha aparecido!",
"enemyDespawned": "Un enemigo errante ha abandonado el área",
"corpsesDecayed": "{{count}} cadáveres se han descompuesto",
"itemsDecayed": "{{count}} objetos caídos se han descompuesto",
"waitBeforeMovingSimple": "Espera {{seconds}}s antes de moverte"
},
"directions": {
"north": "Norte",
"south": "Sur",
"east": "Este",
"west": "Oeste",
"northeast": "Noreste",
"northwest": "Noroeste",
"southeast": "Sureste",
"southwest": "Suroeste",
"up": "Arriba",
"down": "Abajo",
"inside": "Adentro",
"outside": "Afuera",
"enter": "Entrar",
"exit": "Salir"
},
"landing": {
"heroTitle": "Ecos de la Ceniza",
"heroTitle": "Ecos de las Cenizas",
"heroSubtitle": "Un RPG de supervivencia post-apocalíptico",
"playNow": "Jugar Ahora",
"features": "Características"

View File

@@ -1,5 +1,5 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
font-family: 'Saira Condensed', system-ui, sans-serif;
line-height: 1.5;
font-weight: 400;

View File

@@ -13,6 +13,13 @@ const api = axios.create({
},
})
// Add request interceptor to include language preference
api.interceptors.request.use((config) => {
const language = localStorage.getItem('i18nextLng') || 'en'
config.headers['Accept-Language'] = language
return config
})
// Add token to requests if it exists
const token = localStorage.getItem('token')
if (token) {

View File

@@ -4,11 +4,12 @@ import { VitePWA } from 'vite-plugin-pwa'
// https://vitejs.dev/config/
export default defineConfig({
base: './', // Use relative paths for Electron file:// protocol
base: '/', // Changed from ./ to / for better PWA absolute path resolution
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
injectRegister: 'auto',
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
manifest: {
name: 'Echoes of the Ash',
@@ -40,6 +41,9 @@ export default defineConfig({
]
},
workbox: {
cleanupOutdatedCaches: true,
skipWaiting: true,
clientsClaim: true,
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
runtimeCaching: [
{