WIP: i18n implementation - fix items.json syntax, add useTranslation hooks to components
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { HashRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { BrowserRouter, HashRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { useAuth } from './hooks/useAuth'
|
||||
import LandingPage from './components/LandingPage'
|
||||
@@ -13,6 +13,10 @@ import GameLayout from './components/GameLayout'
|
||||
import AccountPage from './components/AccountPage'
|
||||
import './App.css'
|
||||
|
||||
// Use HashRouter for Electron (file:// protocol), BrowserRouter for web
|
||||
const isElectron = window.location.protocol === 'file:'
|
||||
const Router = isElectron ? HashRouter : BrowserRouter
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, loading } = useAuth()
|
||||
|
||||
|
||||
@@ -289,7 +289,6 @@ function Game() {
|
||||
<div className="center-area">
|
||||
{/* Combat view (when in combat) */}
|
||||
{state.combatState && state.playerState && (
|
||||
console.log('Rendering Combat component', state.combatState),
|
||||
<Combat
|
||||
combatState={state.combatState}
|
||||
combatLog={state.combatLog}
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
import type { CombatState, CombatLogEntry, Profile, Equipment, PlayerState } from './types'
|
||||
|
||||
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) {
|
||||
return (
|
||||
<div className="combat-view">
|
||||
<div className="combat-header-inline">
|
||||
<h2>
|
||||
{combatState.is_pvp ? '⚔️ PvP Combat' : `⚔️ Combat - ${enemyName || combatState.combat?.npc_name || 'Enemy'}`}
|
||||
</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">
|
||||
{floatingTexts.map(ft => (
|
||||
<div
|
||||
key={ft.id}
|
||||
className={`floating-text ${ft.type}`}
|
||||
style={{ left: `${ft.x}%`, top: `${ft.y}%` }}>
|
||||
{ft.text}
|
||||
</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}
|
||||
>
|
||||
⚔️ Attack
|
||||
</button>
|
||||
<button
|
||||
className="combat-action-btn flee-btn"
|
||||
onClick={() => onPvPAction('flee')}
|
||||
disabled={!combatState.pvp_combat.your_turn || buttonsDisabled}
|
||||
>
|
||||
🏃 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">Combat Log</h3>
|
||||
<div className="combat-log-inline">
|
||||
<div className="log-entries">
|
||||
{combatLog.map((entry: any, i: number) => (
|
||||
<div key={i} className={`log-entry ${entry.isPlayer ? 'player-log' : 'enemy-log'}`}>
|
||||
<span className="log-time">[{entry.time}]</span>
|
||||
<span className="log-message">{entry.message}</span>
|
||||
</div>
|
||||
))}
|
||||
{combatLog.length === 0 && <div className="log-entry"><span className="log-message">PvP Combat started...</span></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">
|
||||
{floatingTexts.map(ft => (
|
||||
<div
|
||||
key={ft.id}
|
||||
className={`floating-text ${ft.type}`}
|
||||
style={{ left: `${ft.x}%`, top: `${ft.y}%` }}>
|
||||
{ft.text}
|
||||
</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">
|
||||
Enemy HP: {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' }}>
|
||||
Your HP: {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">✅ Your Turn</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">⚠️ Enemy Turn</span>
|
||||
)
|
||||
) : (
|
||||
<span className={combatState.player_won ? "your-turn" : combatState.player_fled ? "your-turn" : "enemy-turn"}>
|
||||
{combatState.player_won ? "✅ Victory!" : combatState.player_fled ? "🏃 Escaped!" : "💀 Defeated"}
|
||||
</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}
|
||||
>
|
||||
⚔️ Attack
|
||||
</button>
|
||||
<button
|
||||
className="combat-action-btn flee-btn"
|
||||
onClick={() => onCombatAction('flee')}
|
||||
disabled={combatState.combat?.turn !== 'player' || !!enemyTurnMessage || buttonsDisabled}
|
||||
>
|
||||
🏃 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">Combat Log</h3>
|
||||
<div className="combat-log-inline">
|
||||
<div className="log-entries">
|
||||
{combatLog.map((entry: any, i: number) => (
|
||||
<div key={i} className={`log-entry ${entry.isPlayer ? 'player-log' : 'enemy-log'}`}>
|
||||
<span className="log-time">[{entry.time}]</span>
|
||||
<span className="log-message">{entry.message}</span>
|
||||
</div>
|
||||
))}
|
||||
{combatLog.length === 0 && <div className="log-entry"><span className="log-message">Combat started...</span></div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CombatView
|
||||
@@ -180,14 +180,14 @@ function LocationView({
|
||||
{enemy.id && (
|
||||
<div className="entity-image">
|
||||
<img
|
||||
src={getAssetPath(enemy.image_path || `images/npcs/${enemy.name.toLowerCase().replace(/ /g, '_')}.webp`)}
|
||||
alt={enemy.name}
|
||||
src={getAssetPath(enemy.image_path || `images/npcs/${(typeof enemy.name === 'string' ? enemy.name : enemy.name?.en || '').toLowerCase().replace(/ /g, '_')}.webp`)}
|
||||
alt={getTranslatedText(enemy.name)}
|
||||
onError={(e: any) => { e.currentTarget.style.display = 'none' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="entity-info">
|
||||
<div className="entity-name enemy-name">{enemy.name}</div>
|
||||
<div className="entity-name enemy-name">{getTranslatedText(enemy.name)}</div>
|
||||
{enemy.level && <div className="entity-level">Lv. {enemy.level}</div>}
|
||||
</div>
|
||||
<button
|
||||
@@ -211,7 +211,7 @@ function LocationView({
|
||||
<div key={corpse.id} className="corpse-container">
|
||||
<div className="entity-card corpse-card">
|
||||
<div className="entity-info">
|
||||
<div className="entity-name">{corpse.emoji} {corpse.name}</div>
|
||||
<div className="entity-name">{corpse.emoji} {getTranslatedText(corpse.name)}</div>
|
||||
<div className="corpse-loot-count">{corpse.loot_count} item(s)</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -241,14 +241,14 @@ function LocationView({
|
||||
<div key={item.index} className={`corpse-item ${!item.can_loot ? 'locked' : ''}`}>
|
||||
<div className="corpse-item-info">
|
||||
<div className="corpse-item-name">
|
||||
{item.emoji} {item.item_name}
|
||||
{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}` : ''}
|
||||
</div>
|
||||
{item.required_tool && (
|
||||
<div className={`corpse-item-tool ${item.has_tool ? 'has-tool' : 'needs-tool'}`}>
|
||||
🔧 {item.required_tool_name} {item.has_tool ? '✓' : '✗'}
|
||||
🔧 {getTranslatedText(item.required_tool_name)} {item.has_tool ? '✓' : '✗'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -256,7 +256,7 @@ function LocationView({
|
||||
className="corpse-item-loot-btn"
|
||||
onClick={() => onLootCorpseItem(String(corpse.id), item.index)}
|
||||
disabled={!item.can_loot}
|
||||
title={!item.can_loot ? `Requires ${item.required_tool_name}` : 'Loot this item'}
|
||||
title={!item.can_loot ? `Requires ${getTranslatedText(item.required_tool_name)}` : 'Loot this item'}
|
||||
>
|
||||
{item.can_loot ? '📦 Loot' : '🔒'}
|
||||
</button>
|
||||
@@ -286,7 +286,7 @@ function LocationView({
|
||||
<div key={i} className="entity-card npc-card">
|
||||
<span className="entity-icon">🧑</span>
|
||||
<div className="entity-info">
|
||||
<div className="entity-name">{npc.name}</div>
|
||||
<div className="entity-name">{getTranslatedText(npc.name)}</div>
|
||||
{npc.level && <div className="entity-level">Lv. {npc.level}</div>}
|
||||
</div>
|
||||
<button className="entity-action-btn">Talk</button>
|
||||
@@ -306,7 +306,7 @@ function LocationView({
|
||||
{item.image_path ? (
|
||||
<img
|
||||
src={getAssetPath(item.image_path)}
|
||||
alt={item.name}
|
||||
alt={getTranslatedText(item.name)}
|
||||
className="entity-icon"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
@@ -318,14 +318,14 @@ function LocationView({
|
||||
<span className={`entity-icon ${item.image_path ? 'hidden' : ''}`}>{item.emoji || '📦'}</span>
|
||||
<div className="entity-info">
|
||||
<div className={`entity-name ${item.tier ? `tier-${item.tier}` : ''}`}>
|
||||
{item.name || 'Unknown Item'}
|
||||
{getTranslatedText(item.name) || 'Unknown Item'}
|
||||
</div>
|
||||
{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>
|
||||
<div className="item-info-tooltip">
|
||||
{item.description && <div className="item-tooltip-desc">{item.description}</div>}
|
||||
{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)`}
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
* Asset Path Utility
|
||||
*
|
||||
* Resolves asset paths based on runtime environment:
|
||||
* - Electron: Returns local path (assets bundled with app)
|
||||
* - Electron: Returns relative path (assets bundled with app, using ./)
|
||||
* - Browser: Returns full server URL
|
||||
*/
|
||||
|
||||
// Detect if running in Electron
|
||||
const isElectron = !!(window as any).electronAPI?.isElectron
|
||||
// Detect if running in Electron - check at runtime, not module load time
|
||||
function checkIsElectron(): boolean {
|
||||
return !!(window as any).electronAPI?.isElectron ||
|
||||
window.location.protocol === 'file:'
|
||||
}
|
||||
|
||||
// Base URL for remote assets (browser mode)
|
||||
const ASSET_BASE_URL = import.meta.env.VITE_ASSET_URL ||
|
||||
@@ -21,21 +24,21 @@ const ASSET_BASE_URL = import.meta.env.VITE_ASSET_URL ||
|
||||
export function getAssetPath(path: string): string {
|
||||
if (!path) return ''
|
||||
|
||||
// Normalize path (ensure leading slash)
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||
// Normalize path (remove leading slash for Electron compatibility)
|
||||
const cleanPath = path.startsWith('/') ? path.slice(1) : path
|
||||
|
||||
if (isElectron) {
|
||||
// In Electron, assets are served relative to the app
|
||||
return normalizedPath
|
||||
if (checkIsElectron()) {
|
||||
// In Electron with base: './', use relative paths
|
||||
return `./${cleanPath}`
|
||||
}
|
||||
|
||||
// In browser, prepend the server URL
|
||||
return `${ASSET_BASE_URL}${normalizedPath}`
|
||||
return `${ASSET_BASE_URL}/${cleanPath}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're running in Electron
|
||||
*/
|
||||
export function isElectronApp(): boolean {
|
||||
return isElectron
|
||||
return checkIsElectron()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user