WIP: Current state before PVP combat investigation

This commit is contained in:
Joan
2026-02-03 12:19:28 +01:00
parent 7f42fd6b7f
commit 0b0a23f500
36 changed files with 2423 additions and 1472 deletions

BIN
pwa/public/audio/bgm.wav Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,6 +1,8 @@
import { BrowserRouter, HashRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider } from './contexts/AuthContext'
import { useAuth } from './hooks/useAuth'
import { AudioProvider } from './contexts/AudioContext'
import BackgroundMusic from './components/BackgroundMusic'
import LandingPage from './components/LandingPage'
import Login from './components/Login'
import Register from './components/Register'
@@ -48,71 +50,74 @@ function CharacterRoute({ children }: { children: React.ReactNode }) {
function App() {
return (
<AuthProvider>
<Router>
<div className="app">
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route
path="/characters"
element={
<PrivateRoute>
<CharacterSelection />
</PrivateRoute>
}
/>
<Route
path="/create-character"
element={
<PrivateRoute>
<CharacterCreation />
</PrivateRoute>
}
/>
<Route
path="/account"
element={
<PrivateRoute>
<AccountPage />
</PrivateRoute>
}
/>
<Route element={<GameLayout />}>
<Route
path="/game"
element={
<CharacterRoute>
<Game />
</CharacterRoute>
}
/>
<AudioProvider>
<Router>
<BackgroundMusic />
<div className="app">
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route
path="/profile/:playerId"
path="/characters"
element={
<PrivateRoute>
<Profile />
<CharacterSelection />
</PrivateRoute>
}
/>
<Route
path="/leaderboards"
path="/create-character"
element={
<PrivateRoute>
<Leaderboards />
<CharacterCreation />
</PrivateRoute>
}
/>
</Route>
</Routes>
</div>
</Router>
<Route
path="/account"
element={
<PrivateRoute>
<AccountPage />
</PrivateRoute>
}
/>
<Route element={<GameLayout />}>
<Route
path="/game"
element={
<CharacterRoute>
<Game />
</CharacterRoute>
}
/>
<Route
path="/profile/:playerId"
element={
<PrivateRoute>
<Profile />
</PrivateRoute>
}
/>
<Route
path="/leaderboards"
element={
<PrivateRoute>
<Leaderboards />
</PrivateRoute>
}
/>
</Route>
</Routes>
</div>
</Router>
</AudioProvider>
</AuthProvider>
)
}

View File

@@ -1,56 +1,47 @@
.account-page {
min-height: 100vh;
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%);
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
color: #fff;
}
.account-container {
max-width: 1000px;
margin: 0 auto;
background: rgba(0, 0, 0, 0.8);
border-radius: 8px;
padding: 2rem;
backdrop-filter: blur(10px);
}
.account-title {
font-size: 2.5rem;
color: #646cff;
margin-bottom: 2rem;
text-align: center;
color: #e0e0e0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
}
.account-loading,
.account-error {
text-align: center;
padding: 3rem;
color: #fff;
}
.account-error h2 {
color: #ff6b6b;
margin-bottom: 1rem;
}
/* Account Sections */
.account-section {
background: rgba(42, 42, 42, 0.6);
backdrop-filter: blur(10px);
border: 1px solid rgba(100, 108, 255, 0.2);
border-radius: 12px;
padding: 2rem;
margin-bottom: 2rem;
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.account-section:last-child {
border-bottom: none;
}
.section-title {
font-size: 1.5rem;
color: #646cff;
margin-bottom: 1.5rem;
border-bottom: 1px solid rgba(100, 108, 255, 0.2);
padding-bottom: 0.5rem;
color: #bbb;
border-left: 4px solid #4a9eff;
padding-left: 1rem;
}
/* Account Information Grid */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
gap: 1.5rem;
}
.info-item {
@@ -60,41 +51,38 @@
}
.info-label {
font-size: 0.9rem;
color: #888;
font-weight: 600;
font-size: 0.9rem;
}
.info-value {
font-size: 1.1rem;
color: #fff;
font-weight: 500;
}
.info-value.premium {
color: #ffd93d;
font-weight: 600;
color: #ffd700;
text-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
}
/* Characters Grid */
.characters-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.character-card {
background: rgba(26, 26, 26, 0.8);
border: 1px solid rgba(100, 108, 255, 0.3);
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
padding: 1.5rem;
transition: all 0.3s ease;
transition: transform 0.2s, background 0.2s;
}
.character-card:hover {
transform: translateY(-4px);
border-color: rgba(100, 108, 255, 0.6);
box-shadow: 0 8px 20px rgba(100, 108, 255, 0.2);
transform: translateY(-2px);
background: rgba(255, 255, 255, 0.1);
}
.character-header {
@@ -102,21 +90,21 @@
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.character-header h3 {
font-size: 1.3rem;
color: #fff;
margin: 0;
color: #fff;
}
.character-level {
background: linear-gradient(135deg, #646cff 0%, #8b5cf6 100%);
color: #fff;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 600;
background: #4a9eff;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: bold;
}
.character-stats {
@@ -127,135 +115,219 @@
.stat {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.stat-label {
font-size: 0.8rem;
color: #888;
}
.stat-value {
font-size: 1rem;
color: #fff;
font-weight: 600;
gap: 0.5rem;
color: #aba;
}
.character-attributes {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
font-size: 0.9rem;
color: #aaa;
color: #888;
margin-bottom: 1rem;
}
.no-characters {
color: #888;
text-align: center;
color: #888;
padding: 2rem;
font-style: italic;
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
margin-bottom: 1.5rem;
}
/* Settings */
.setting-item {
margin-bottom: 2rem;
padding-bottom: 2rem;
border-bottom: 1px solid rgba(100, 108, 255, 0.1);
}
.setting-item:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
background: rgba(255, 255, 255, 0.05);
padding: 1.5rem;
border-radius: 6px;
margin-bottom: 1.5rem;
}
.setting-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
margin-bottom: 1.5rem;
}
.setting-header h3 {
font-size: 1.2rem;
color: #fff;
margin: 0;
font-size: 1.2rem;
}
.setting-form {
background: rgba(26, 26, 26, 0.6);
border: 1px solid rgba(100, 108, 255, 0.2);
border-radius: 8px;
background: rgba(0, 0, 0, 0.2);
padding: 1.5rem;
border-radius: 4px;
margin-top: 1rem;
}
.setting-form .form-group {
.form-group {
margin-bottom: 1rem;
}
.setting-form .form-group:last-of-type {
margin-bottom: 1.5rem;
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #bbb;
}
.form-group input {
width: 100%;
padding: 0.8rem;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: #fff;
}
.form-group input:focus {
border-color: #4a9eff;
outline: none;
}
/* Audio Settings */
.audio-settings {
display: flex;
flex-direction: column;
gap: 1rem;
}
.mute-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
}
.mute-toggle input {
width: 1.2rem;
height: 1.2rem;
cursor: pointer;
}
.volume-sliders {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 2rem;
}
.slider-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.slider-group label {
font-size: 0.9rem;
color: #bbb;
}
.slider-group input[type="range"] {
width: 100%;
cursor: pointer;
height: 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
appearance: none;
}
.slider-group input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
background: #4a9eff;
border-radius: 50%;
cursor: pointer;
transition: transform 0.1s;
}
.slider-group input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
/* Actions */
.account-actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
justify-content: space-between;
margin-top: 2rem;
}
.button-danger {
background-color: #ff6b6b;
color: white;
/* Buttons */
.button-primary,
.button-secondary,
.button-danger,
.button-link {
padding: 0.8rem 1.5rem;
border-radius: 4px;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.25s;
font-weight: 500;
transition: all 0.2s;
}
.button-primary {
background: #4a9eff;
color: #fff;
}
.button-primary:hover {
background: #3a8eef;
}
.button-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.button-secondary {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.button-secondary:hover {
background: rgba(255, 255, 255, 0.2);
}
.button-danger {
background: rgba(220, 53, 69, 0.2);
color: #ff6b6b;
border: 1px solid rgba(220, 53, 69, 0.3);
}
.button-danger:hover {
background-color: #ff5252;
background: rgba(220, 53, 69, 0.3);
}
/* Responsive Design */
@media (max-width: 768px) {
.account-page {
padding: 1rem;
}
.button-link {
background: none;
color: #4a9eff;
padding: 0;
text-decoration: underline;
}
.account-title {
font-size: 2rem;
}
.button-link:hover {
text-decoration: none;
}
.account-section {
padding: 1.5rem;
}
/* Notifications */
.error {
background: rgba(220, 53, 69, 0.1);
color: #ff6b6b;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
text-align: center;
}
.info-grid {
grid-template-columns: 1fr;
}
.characters-grid {
grid-template-columns: 1fr;
}
.setting-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.account-actions {
flex-direction: column;
}
.account-actions button {
width: 100%;
}
.success {
background: rgba(40, 167, 69, 0.1);
color: #5ddc6c;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
text-align: center;
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../hooks/useAuth'
import { useAudio } from '../contexts/AudioContext'
import { authApi, Account, Character } from '../services/api'
import './AccountPage.css'
@@ -29,6 +30,14 @@ function AccountPage() {
const [passwordError, setPasswordError] = useState('')
const [passwordSuccess, setPasswordSuccess] = useState('')
// Audio state
const {
masterVolume, setMasterVolume,
musicVolume, setMusicVolume,
sfxVolume, setSfxVolume,
isMuted, setIsMuted
} = useAudio()
useEffect(() => {
fetchAccountData()
}, [])
@@ -227,6 +236,63 @@ function AccountPage() {
</section>
{/* Settings Section */}
<section className="account-section">
<h2 className="section-title">Audio Settings</h2>
<div className="audio-settings">
<div className="setting-item">
<div className="setting-header">
<h3>Volume Controls</h3>
<label className="mute-toggle">
<input
type="checkbox"
checked={isMuted}
onChange={(e) => setIsMuted(e.target.checked)}
/>
<span>Mute All</span>
</label>
</div>
<div className="volume-sliders">
<div className="slider-group">
<label>Master Volume: {Math.round(masterVolume * 100)}%</label>
<input
type="range"
min="0"
max="1"
step="0.01"
value={masterVolume}
onChange={(e) => setMasterVolume(parseFloat(e.target.value))}
disabled={isMuted}
/>
</div>
<div className="slider-group">
<label>Music Volume: {Math.round(musicVolume * 100)}%</label>
<input
type="range"
min="0"
max="1"
step="0.01"
value={musicVolume}
onChange={(e) => setMusicVolume(parseFloat(e.target.value))}
disabled={isMuted}
/>
</div>
<div className="slider-group">
<label>SFX Volume: {Math.round(sfxVolume * 100)}%</label>
<input
type="range"
min="0"
max="1"
step="0.01"
value={sfxVolume}
onChange={(e) => setSfxVolume(parseFloat(e.target.value))}
disabled={isMuted}
/>
</div>
</div>
</div>
</div>
</section>
<section className="account-section">
<h2 className="section-title">Account Settings</h2>

View File

@@ -0,0 +1,119 @@
import { useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useAudio } from '../contexts/AudioContext';
import { isElectronApp } from '../utils/assetPath';
export default function BackgroundMusic() {
const { pathname } = useLocation();
const { masterVolume, musicVolume, isMuted } = useAudio();
const audioRef = useRef<HTMLAudioElement | null>(null);
const [playbackError, setPlaybackError] = useState(false);
// Routes where music should play
const shouldPlayMusic = () => {
// Game main view
if (pathname === '/game') return true;
// Leaderboards
if (pathname === '/leaderboards') return true;
// Account management
if (pathname === '/account') return true;
// Profile views
if (pathname.startsWith('/profile/')) return true;
return false;
};
// Calculate effective volume
const effectiveVolume = isMuted ? 0 : masterVolume * musicVolume;
useEffect(() => {
if (!audioRef.current) {
// For static assets in public folder:
// Browser: use absolute path from root
// Electron: use relative path
const src = isElectronApp() ? './audio/bgm.wav' : '/audio/bgm.wav';
audioRef.current = new Audio(src);
audioRef.current.loop = true;
}
const audio = audioRef.current;
// Update volume in real-time
audio.volume = effectiveVolume;
const handlePlay = async () => {
try {
if (shouldPlayMusic()) {
if (audio.paused) {
await audio.play();
setPlaybackError(false);
}
} else {
if (!audio.paused) {
audio.pause();
audio.currentTime = 0; // Reset track when stopping
}
}
} catch (err) {
console.log('Audio playback failed:', err);
setPlaybackError(true);
}
};
handlePlay();
// Attempts to resume audio if the user interacts with the page
const retryPlay = () => {
if (shouldPlayMusic() && audio.paused) {
handlePlay();
}
};
if (playbackError) {
document.addEventListener('click', retryPlay, { once: true });
}
return () => {
document.removeEventListener('click', retryPlay);
};
}, [pathname, effectiveVolume, playbackError]);
// Handle volume changes specifically if they happen while playing
useEffect(() => {
if (audioRef.current) {
audioRef.current.volume = effectiveVolume;
}
}, [effectiveVolume]);
// Render a small overlay if autoplay is blocked
if (!playbackError || !shouldPlayMusic()) return null;
return (
<div
style={{
position: 'fixed',
bottom: '20px',
right: '20px',
zIndex: 9999,
background: 'rgba(74, 158, 255, 0.9)',
color: 'white',
padding: '10px 20px',
borderRadius: '8px',
cursor: 'pointer',
boxShadow: '0 4px 6px rgba(0,0,0,0.3)',
fontWeight: 'bold',
animation: 'pulse 2s infinite'
}}
onClick={() => {
if (audioRef.current) {
audioRef.current.play()
.then(() => setPlaybackError(false))
.catch(e => console.error(e));
}
}}
>
🎵 Click to Enable Audio
</div>
);
}

View File

@@ -3074,12 +3074,7 @@ body.no-scroll {
color: #f44336;
}
.combat-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-top: 1.5rem;
}
.combat-action-btn {
padding: 1rem 2rem;

View File

@@ -3,7 +3,7 @@ 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'
import { Combat } from './game/Combat'
import LocationView from './game/LocationView'
import MovementControls from './game/MovementControls'
import PlayerSidebar from './game/PlayerSidebar'

View File

@@ -1,372 +1,433 @@
import { useState, useEffect, useRef } from 'react'
import CombatView from './CombatView'
import type { CombatState, CombatLogEntry, Profile, Equipment, PlayerState } from './types'
import type { FloatingText, CombatMessage } from './CombatTypes'
import api from '../../services/api'
import { getTranslatedText } from '../../utils/i18nUtils'
import './CombatEffects.css'
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useAuth } from '../../contexts/AuthContext';
// import { useGame } from '../../contexts/GameContext'; // Removed invalid import
import { CombatView } from './CombatView';
import { CombatState, CombatMessage, FloatingText, AnimationState, CombatActionResponse } from './CombatTypes';
import { useTranslation } from 'react-i18next';
// Updated props interface to match Game.tsx
interface CombatProps {
combatState: CombatState
profile: Profile | null
playerState: PlayerState | null
equipment: Equipment
onCombatAction: (action: string) => Promise<any>
onExitCombat: () => void
onPvPAction: (action: string) => Promise<any>
onExitPvPCombat: () => void
combatLog: CombatLogEntry[]
addCombatLogEntry: (entry: CombatLogEntry) => void
updatePlayerState: (state: PlayerState) => void
updateCombatState: (state: CombatState) => void
combatState: any; // Using any for now to be flexible with backend response
combatLog: any[];
profile: any;
playerState: any;
equipment: any;
onCombatAction: (action: string) => Promise<any>;
onPvPAction: (action: string, targetId: number) => Promise<void>;
onExitCombat: () => void;
onExitPvPCombat: () => Promise<void>;
addCombatLogEntry: (entry: any) => void;
updatePlayerState: (data: any) => void;
updateCombatState: (data: any) => void;
// Kept for compatibility if passed
onClose?: () => void;
}
const Combat = ({
combatState,
export const Combat: React.FC<CombatProps> = ({
combatState: initialCombatData,
combatLog: _combatLog,
profile,
playerState,
equipment,
equipment: _equipment,
onCombatAction,
onExitCombat,
onPvPAction,
onExitCombat,
onExitPvPCombat,
combatLog,
addCombatLogEntry,
addCombatLogEntry: _addCombatLogEntry,
updatePlayerState,
updateCombatState
}: CombatProps) => {
// Visual effects state
const [shake, setShake] = useState(false)
const [flash, setFlash] = useState(false)
const [floatingTexts, setFloatingTexts] = useState<FloatingText[]>([])
const [processing, setProcessing] = useState(false)
updateCombatState: _updateCombatState,
onClose
}) => {
const { currentCharacter: _currentCharacter, refreshCharacters } = useAuth();
const { t, i18n } = useTranslation();
// Timer state
const [pvpTimer, setPvpTimer] = useState<number | null>(null)
const [turnTimeRemaining, setTurnTimeRemaining] = useState<number | null>(null)
const isPvP = initialCombatData?.is_pvp || false;
// Enemy thinking indicator
const [enemyThinking, setEnemyThinking] = useState(false)
// Helper to resolve localized names safely
const resolveName = useCallback((name: any) => {
if (!name) return '';
if (typeof name === 'string') return name;
if (typeof name === 'object') {
return name[i18n.language] || name['en'] || name['es'] || 'Unknown';
}
return 'Unknown';
}, [i18n.language]);
// Temporary HP to delay updates during enemy turn
const [tempPlayerHP, setTempPlayerHP] = useState<number | null>(null)
// Helper to determine initial combat message
const getInitialLogMessage = (): CombatMessage[] => {
const timestamp = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
// Refs for cleanup
const isMounted = useRef(true)
const floatingTextIdCounter = useRef(0)
const floatingTextTimeouts = useRef<Set<NodeJS.Timeout>>(new Set())
if (isPvP) {
const isAttacker = initialCombatData?.pvp_combat?.is_attacker;
return [{
type: 'text',
origin: 'system',
timestamp,
data: { text: isAttacker ? t('combat.log.pvp_attack') : t('combat.log.pvp_defense') }
}];
}
// ============================================================================
// Cleanup Effects
// ============================================================================
// PvE Logic
// If it's round 1 and enemy turn, likely an ambush or high initiative enemy
// We don't have explicit 'ambush' flag, but we can infer or just use generic start
// User requested 'getting ambushed when travelling', usually implies enemy starts
const isAmbush = initialCombatData?.combat?.turn === 'enemy' && initialCombatData?.combat?.round === 1;
if (isAmbush) {
return [{
type: 'text',
origin: 'system',
timestamp,
data: { text: t('combat.log.ambush') }
}];
}
return [{
type: 'combat_start',
origin: 'system',
timestamp,
data: { message: t('combat.log.combat_start') } // Fallback if 'combat_start' type isn't fully handled text-wise in View
}];
};
// --- State Management ---
// We synchronize local state with props, but manage animations locally
const [localCombatState, setLocalCombatState] = useState<CombatState>({
inCombat: true,
turn: initialCombatData?.turn || 'player',
npcId: initialCombatData?.combat?.npc_id || initialCombatData?.pvp_combat?.defender?.id,
npcName: resolveName(initialCombatData?.combat?.npc_name) ||
(initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.username : initialCombatData?.pvp_combat?.attacker?.username),
npcHp: initialCombatData?.combat?.npc_hp ||
(initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.hp : initialCombatData?.pvp_combat?.attacker?.hp) || 100,
npcMaxHp: initialCombatData?.combat?.npc_max_hp ||
(initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.max_hp : initialCombatData?.pvp_combat?.attacker?.max_hp) || 100,
npcImage: initialCombatData?.combat?.npc_image,
playerHp: playerState?.health || profile?.hp || 100,
playerMaxHp: playerState?.max_health || profile?.max_hp || 100,
messages: getInitialLogMessage(),
round: initialCombatData?.combat?.round || 1,
isPvP: isPvP,
opponentName: isPvP
? (initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.username : initialCombatData?.pvp_combat?.attacker?.username)
: undefined,
turnTimeRemaining: initialCombatData?.turn_time_remaining
});
const [animState, setAnimState] = useState<AnimationState>({
shaking: false, // Deprecated, but kept for safe removal if needed
flashing: false, // Deprecated
enemyAttacking: false,
playerAttacking: false,
playerHit: false,
npcHit: false
});
const [floatingTexts, setFloatingTexts] = useState<FloatingText[]>([]);
// const [isThinking, setIsThinking] = useState(false); // Unused for now
const [messageQueue, setMessageQueue] = useState<CombatMessage[]>([]);
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
const [combatResult, setCombatResult] = useState<'victory' | 'defeat' | 'fled' | null>(null);
// --- Refs ---
const processingRef = useRef(false);
const queueRef = useRef<CombatMessage[]>([]);
// Store server player HP to apply when damage floating text appears
const pendingPlayerHpRef = useRef<{ hp: number; max_hp: number } | null>(null);
// Store server player XP to apply when XP floating text appears
const pendingPlayerXpRef = useRef<{ xp: number; level: number } | null>(null);
// Update queueRef
useEffect(() => {
queueRef.current = messageQueue;
}, [messageQueue]);
// Update local state when props change (especially for PvP live updates)
// IMPORTANT: We preserve existing messages to avoid wiping the initial log
// NOTE: HP values are NOT synced here - they are managed through processMessage for proper animation timing
useEffect(() => {
if (initialCombatData) {
setLocalCombatState(prev => ({
...prev,
turn: initialCombatData.turn || initialCombatData.combat?.turn || prev.turn,
round: initialCombatData?.combat?.round ?? prev.round,
turnTimeRemaining: initialCombatData?.turn_time_remaining
// Do NOT overwrite messages or HP here - HP is managed by processMessage
}));
}
}, [initialCombatData]);
// --- Handlers ---
// Move ref to component scope
const cleanupIntervalRef = useRef<NodeJS.Timeout | null>(null);
const addFloatingText = (text: string, type: FloatingText['type'], origin: 'player' | 'enemy') => {
const id = Math.random().toString(36).substr(2, 9);
// Fixed position at center - no random offset for turn-based combat
const x = 50;
const y = 50;
setFloatingTexts(prev => [...prev, { id, text, type, x, y, origin, timestamp: Date.now() }]);
};
// Clean up floats
useEffect(() => {
cleanupIntervalRef.current = setInterval(() => {
// Only clean up if we are NOT in a result state (victory/defeat) to prevent race conditions
setFloatingTexts(prev => {
if (prev.length === 0) return prev;
return prev.filter(ft => Date.now() - ft.timestamp < 5000);
});
}, 500);
return () => {
isMounted.current = false
floatingTextTimeouts.current.forEach(timeout => clearTimeout(timeout))
floatingTextTimeouts.current.clear()
setFloatingTexts([])
if (cleanupIntervalRef.current) clearInterval(cleanupIntervalRef.current);
};
}, []);
const triggerAnim = (anim: keyof AnimationState, duration: number = 500) => {
setAnimState(prev => ({ ...prev, [anim]: true }));
setTimeout(() => {
setAnimState(prev => ({ ...prev, [anim]: false }));
}, duration);
};
// --- Message Processing ---
const processMessage = useCallback((msg: CombatMessage) => {
const { type, origin, data } = msg;
// Force NPC HP to 0 on victory to ensure bar is empty, as backend might report pre-death HP
if (type === 'victory') {
setLocalCombatState(prev => ({
...prev,
npcHp: 0
}));
}
}, [])
useEffect(() => {
if (combatState.combat_over) {
floatingTextTimeouts.current.forEach(timeout => clearTimeout(timeout))
floatingTextTimeouts.current.clear()
setFloatingTexts([])
}
}, [combatState.combat_over])
const msgWithTimestamp = {
...msg,
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
};
// ============================================================================
// Timer Effects
// ============================================================================
setLocalCombatState(prev => ({
...prev,
messages: [...prev.messages, msgWithTimestamp]
}));
// PvP Timer
useEffect(() => {
if (combatState.is_pvp && combatState.pvp_combat) {
setPvpTimer(combatState.pvp_combat.time_remaining)
switch (type) {
case 'combat_start':
break;
const interval = setInterval(() => {
setPvpTimer(prev => (prev && prev > 0 ? prev - 1 : 0))
}, 1000)
return () => clearInterval(interval)
} else {
setPvpTimer(null)
}
}, [combatState.is_pvp, combatState.pvp_combat])
// PvE Timer - Update from server
useEffect(() => {
if (!combatState.is_pvp && combatState.combat?.turn === 'player' && combatState.combat?.turn_time_remaining !== undefined) {
setTurnTimeRemaining(combatState.combat.turn_time_remaining)
} else {
setTurnTimeRemaining(null)
}
}, [combatState.is_pvp, combatState.combat?.turn, combatState.combat?.turn_time_remaining])
// PvE Timer - Countdown
useEffect(() => {
if (turnTimeRemaining !== null && turnTimeRemaining > 0) {
const interval = setInterval(() => {
setTurnTimeRemaining(prev => prev !== null && prev > 0 ? prev - 1 : 0)
}, 1000)
return () => clearInterval(interval)
}
}, [turnTimeRemaining])
// PvE Polling when timeout is imminent
useEffect(() => {
if (!combatState.is_pvp && turnTimeRemaining !== null && turnTimeRemaining < 30 && turnTimeRemaining >= 0) {
const pollInterval = setInterval(async () => {
try {
const response = await api.get('/api/game/combat')
if (response.data.in_combat && response.data.combat) {
if (response.data.combat.turn !== combatState.combat?.turn) {
updateCombatState({
...combatState,
combat: response.data.combat
})
}
}
} catch (error) {
console.error('Failed to poll combat state:', error)
case 'player_attack':
triggerAnim('playerAttacking');
triggerAnim('npcHit', 300); // Enemy takes damage
if (data.damage) {
addFloatingText(`-${data.damage}`, 'damage', 'enemy');
// HP is updated via server value in handlePvEAction
}
}, 10000)
break;
return () => clearInterval(pollInterval)
case 'player_miss':
addFloatingText(t('combat.miss'), 'miss', 'enemy');
break;
case 'enemy_attack':
case 'monster_attack':
triggerAnim('enemyAttacking');
triggerAnim('playerHit', 300); // Player takes damage
if (data.damage) {
addFloatingText(`-${data.damage}`, 'damage', 'player');
// Apply server player HP when floating text appears
if (pendingPlayerHpRef.current) {
const { hp, max_hp } = pendingPlayerHpRef.current;
setLocalCombatState(prev => ({
...prev,
playerHp: hp,
playerMaxHp: max_hp
}));
updatePlayerState({ hp, max_hp });
pendingPlayerHpRef.current = null;
}
}
break;
case 'enemy_miss':
addFloatingText(t('combat.miss'), 'miss', 'player');
break;
case 'enemy_defend':
addFloatingText(`+${data.heal}`, 'heal', 'enemy');
break;
case 'enemy_special':
triggerAnim('enemyAttacking');
triggerAnim('flashing', 500);
triggerAnim('shaking', 500);
if (data.damage) {
addFloatingText(`-${data.damage}!`, 'crit', 'player');
}
break;
case 'effect_bleeding':
addFloatingText(`-${data.damage}`, 'damage', origin === 'player' ? 'enemy' : 'player');
break;
case 'xp_gain':
addFloatingText(`+${data.amount} XP`, 'info', 'player');
// Sync XP bar with floating text using server value
if (pendingPlayerXpRef.current) {
updatePlayerState({ xp: pendingPlayerXpRef.current.xp, level: pendingPlayerXpRef.current.level });
pendingPlayerXpRef.current = null;
} else if (data.xp !== undefined) {
// Fallback to message data if ref is missing (shouldn't happen usually)
updatePlayerState({ xp: data.xp, level: data.level });
}
break;
case 'victory':
// Stop cleanup interval to freeze the DOM state regarding floating texts
if (cleanupIntervalRef.current) clearInterval(cleanupIntervalRef.current);
// Delay victory state to allow final animations (floating text) to persist
setTimeout(() => {
setCombatResult('victory');
}, 1000);
break;
case 'player_defeated':
if (cleanupIntervalRef.current) clearInterval(cleanupIntervalRef.current);
setTimeout(() => {
setCombatResult('defeat');
}, 2000);
break;
case 'flee_success':
if (cleanupIntervalRef.current) clearInterval(cleanupIntervalRef.current);
setTimeout(() => {
setCombatResult('fled');
}, 500);
break;
}
}, [turnTimeRemaining, combatState, updateCombatState])
}, [t]);
// ============================================================================
// Helper Functions
// ============================================================================
const processQueue = useCallback(async () => {
if (processingRef.current || queueRef.current.length === 0) return;
const addFloatingText = (text: string, x: number, y: number, type: FloatingText['type']) => {
const id = ++floatingTextIdCounter.current
setFloatingTexts(prev => [...prev, { id, text, x, y, type }])
processingRef.current = true;
setIsProcessingQueue(true);
const timeout = setTimeout(() => {
if (isMounted.current) {
setFloatingTexts(prev => prev.filter(ft => ft.id !== id))
floatingTextTimeouts.current.delete(timeout)
}
}, 2500)
const msg = queueRef.current[0];
floatingTextTimeouts.current.add(timeout)
}
processMessage(msg);
const parseMessage = (msg: any): CombatMessage | null => {
if (typeof msg === 'string') {
try {
return JSON.parse(msg) as CombatMessage
} catch {
// Not a JSON message, return null to use as plain text
return null
}
// Determine delay based on message type
let delay = 600;
if (msg.type === 'enemy_attack' || msg.type === 'enemy_special') delay = 1200;
// Increase victory delay in queue processing so the UI doesn't rush
if (msg.type === 'victory') delay = 2000;
if (msg.origin === 'enemy' && msg.type !== 'flee_fail') delay = 1000;
await new Promise(resolve => setTimeout(resolve, delay));
setMessageQueue(prev => prev.slice(1));
processingRef.current = false;
}, [processMessage]);
useEffect(() => {
if (messageQueue.length > 0 && !processingRef.current) {
processQueue();
} else if (messageQueue.length === 0 && isProcessingQueue) {
// Queue just finished processing
setIsProcessingQueue(false);
}
return msg as CombatMessage
}
}, [messageQueue, processQueue, isProcessingQueue]);
// ============================================================================
// PvE Combat Actions
// ============================================================================
const handlePvEAction = async (action: string) => {
if (processing) return
setProcessing(true)
if (isProcessingQueue) return;
try {
const data = await onCombatAction(action)
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
if (localCombatState.turn !== 'player') return;
// Parse message into structured parts
const messages = data.message.split('\n').filter((m: string) => m.trim())
// Use the prop function instead of direct fetch
const data: CombatActionResponse = await onCombatAction(action);
const playerMessages: any[] = []
const enemyMessages: any[] = []
if (data && data.success && data.messages) {
setMessageQueue(data.messages);
messages.forEach((msg: string) => {
const parsed = parseMessage(msg)
if (parsed) {
// Structured message - use origin field
if (parsed.origin === 'player') {
playerMessages.push(parsed)
} else if (parsed.origin === 'enemy') {
enemyMessages.push(parsed)
} else {
// Neutral messages (victory, combat start) go to player
playerMessages.push(parsed)
}
} else {
// Legacy string message - fallback to text parsing
if (msg.includes('You ') || msg.includes('Your ') || msg === 'Failed to flee!') {
playerMessages.push(msg)
} else if (msg.includes('attacks') || msg.includes('hits') || msg.includes('misses')) {
enemyMessages.push(msg)
}
}
})
// 1. Process player messages immediately
playerMessages.forEach((msg: any) => {
const logId = `player-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
addCombatLogEntry({ id: logId, time: timeStr, message: msg, isPlayer: true })
// Extract damage if present
const parsed = parseMessage(msg)
if (parsed && parsed.type === 'player_attack' && parsed.data.damage) {
addFloatingText(parsed.data.damage.toString(), 50, 30, 'damage-player-dealt')
setFlash(true)
setTimeout(() => setFlash(false), 300)
}
})
// Update enemy HP immediately
if (data.combat && !data.combat_over) {
updateCombatState({
...combatState,
combat: {
...combatState.combat,
npc_hp: data.combat.npc_hp,
// Apply server HP values IMMEDIATELY
if (data.combat) {
setLocalCombatState(prev => ({
...prev,
npcHp: data.combat.npc_hp,
npcMaxHp: data.combat.npc_max_hp,
turn: data.combat.turn,
turn_time_remaining: data.combat.turn_time_remaining,
round: data.combat.round,
npc_intent: data.combat.npc_intent
}
})
npcName: resolveName(data.combat.npc_name) || prev.npcName
}));
} else if (data.combat_over && data.player_won) {
// Combat ended with victory but data.combat is null - set enemy HP to 0
setLocalCombatState(prev => ({
...prev,
npcHp: 0
}));
}
// Store current player HP
if (playerState) {
setTempPlayerHP(playerState.health)
if (data.player) {
// Store player HP to apply when enemy_attack message is processed
pendingPlayerHpRef.current = { hp: data.player.hp, max_hp: data.player.max_hp };
// Store player XP to apply when xp_gain message is processed
pendingPlayerXpRef.current = { xp: data.player.xp, level: data.player.level };
refreshCharacters();
}
}
// 2. Enemy turn with delay
if (enemyMessages.length > 0 && !data.combat_over) {
setEnemyThinking(true)
await new Promise(resolve => setTimeout(resolve, 2000))
setEnemyThinking(false)
enemyMessages.forEach((msg: any) => {
const logId = `enemy-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
addCombatLogEntry({ id: logId, time: timeStr, message: msg, isPlayer: false })
// Extract damage if present
const parsed = parseMessage(msg)
if (parsed && (parsed.type === 'enemy_attack' || parsed.type === 'flee_fail') && parsed.data.damage) {
addFloatingText(parsed.data.damage.toString(), 50, 50, 'damage-player')
setShake(true)
setTimeout(() => setShake(false), 500)
}
})
// Update player HP after delay
if (data.player && playerState) {
setTempPlayerHP(null)
updatePlayerState({
...playerState,
health: data.player.hp,
max_health: data.player.max_hp ?? playerState.max_health
})
}
} else if (data.combat_over) {
// Combat ended
const playerFled = data.message.toLowerCase().includes('fled') || data.message.toLowerCase().includes('escape')
updateCombatState({
...combatState,
combat_over: true,
player_won: data.player_won || false,
player_fled: playerFled,
combat: {
...combatState.combat,
npc_hp: data.player_won ? 0 : (data.combat?.npc_hp ?? combatState.combat.npc_hp)
}
})
setTempPlayerHP(null)
if (data.player && playerState) {
updatePlayerState({
...playerState,
health: data.player.hp,
max_health: data.player.max_hp ?? playerState.max_health
})
}
}
} catch (error) {
console.error('Combat action failed:', error)
} finally {
setProcessing(false)
} catch (err) {
console.error(err);
}
}
};
// ============================================================================
// PvP Combat Actions
// ============================================================================
const handlePvPActionWrapper = async (action: string) => {
if (isProcessingQueue) return;
// Clean up targetId - standard action doesn't need it usually, or use 0
await onPvPAction(action, 0);
};
const handlePvPActionLocal = async (action: string) => {
if (processing) return
setProcessing(true)
const [isClosing, setIsClosing] = useState(false);
try {
const data = await onPvPAction(action)
const handleCloseWrapper = () => {
if (isClosing) return;
setIsClosing(true);
if (data) {
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
// Clear all dynamic elements to allow React to reconcile locally before unmounting
setFloatingTexts([]);
setAnimState({ shaking: false, flashing: false, enemyAttacking: false, playerAttacking: false });
setMessageQueue([]);
const msg = data.message || ''
const logId = `pvp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
addCombatLogEntry({ id: logId, time: timeStr, message: msg, isPlayer: true })
// Extract damage
const damageMatch = msg.match(/(\\d+) damage/)
if (damageMatch) {
addFloatingText(damageMatch[1], 50, 30, 'damage-player-dealt')
setFlash(true)
setTimeout(() => setFlash(false), 300)
}
// Small delay to ensure the DOM is clear of floating texts before unmounting the component
setTimeout(() => {
if (isPvP) {
onExitPvPCombat();
} else {
onExitCombat();
}
if (onClose) onClose();
}, 50);
};
} catch (error) {
console.error('PvP action failed:', error)
} finally {
setProcessing(false)
}
}
return (
<div className={`combat-container ${shake ? 'shake-effect' : ''}`}>
<CombatView
combatState={combatState}
combatLog={combatLog}
profile={profile}
playerState={tempPlayerHP !== null && playerState ? {
...playerState,
health: tempPlayerHP
} : playerState}
equipment={equipment}
enemyName={getTranslatedText(combatState.combat?.npc_name) || 'Enemy'}
enemyImage={combatState.combat?.npc_image || combatState.combat_image || ''}
enemyTurnMessage={enemyThinking ? '🗡️ Enemy is thinking...' : ''}
pvpTimeRemaining={pvpTimer}
turnTimeRemaining={turnTimeRemaining}
onCombatAction={handlePvEAction}
onFlee={async () => handlePvEAction('flee')}
onPvPAction={handlePvPActionLocal}
onExitCombat={onExitCombat}
onExitPvPCombat={onExitPvPCombat}
flashEnemy={flash}
buttonsDisabled={processing || enemyThinking}
<CombatView
state={localCombatState}
animState={animState}
floatingTexts={floatingTexts}
/>
</div>
)
}
export default Combat
onAction={isPvP ? handlePvPActionWrapper : handlePvEAction}
onClose={handleCloseWrapper}
isProcessing={isProcessingQueue}
combatResult={combatResult}
equipment={_equipment}
/>
);
};

View File

@@ -1,328 +1,471 @@
/* Combat Visual Effects */
/* Screen Shake */
@keyframes shake {
0% {
transform: translate(1px, 1px) rotate(0deg);
}
10% {
transform: translate(-1px, -2px) rotate(-1deg);
}
20% {
transform: translate(-3px, 0px) rotate(1deg);
}
30% {
transform: translate(3px, 2px) rotate(0deg);
}
40% {
transform: translate(1px, -1px) rotate(1deg);
}
50% {
transform: translate(-1px, 2px) rotate(-1deg);
}
60% {
transform: translate(-3px, 1px) rotate(0deg);
}
70% {
transform: translate(3px, 1px) rotate(-1deg);
}
80% {
transform: translate(-1px, -1px) rotate(1deg);
}
90% {
transform: translate(1px, 2px) rotate(0deg);
}
100% {
transform: translate(1px, -2px) rotate(-1deg);
}
/* Combat Layout */
.combat-container {
display: flex;
flex-direction: column;
width: 100%;
margin: 0 auto;
/* More transparent/themed background */
background: rgba(20, 20, 20, 0.6);
backdrop-filter: blur(5px);
border-radius: 12px;
padding: 1rem;
color: white;
position: relative;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.shake-effect {
animation: shake 0.5s;
animation-iteration-count: 1;
.glow-effect {
box-shadow: 0 0 10px #ff4444, 0 0 20px #ff4444;
transition: box-shadow 0.3s ease-in-out;
}
/* Hit Flash */
@keyframes flash-red {
0% {
filter: brightness(1) sepia(0) hue-rotate(0deg) saturate(1);
}
50% {
filter: brightness(0.5) sepia(1) hue-rotate(-50deg) saturate(5);
}
/* Red tint */
100% {
filter: brightness(1) sepia(0) hue-rotate(0deg) saturate(1);
}
}
.flash-hit {
animation: flash-red 0.3s ease-out;
}
/* Dead Enemy Grayscale */
.enemy-dead {
.dead .location-image {
filter: grayscale(100%);
transition: filter 0.5s ease-out;
transition: filter 1s ease;
}
/* Fled Enemy Blueish Tint */
.enemy-fled {
filter: sepia(1) saturate(3) hue-rotate(180deg) brightness(0.8);
transition: filter 0.5s ease-out;
/* Enemy avatar now uses shared .location-image styles from Game.css */
/* ... existing code ... */
/* Action Buttons Center */
.combat-actions {
display: flex;
flex-direction: column;
align-items: center;
/* Center horizontally */
padding: 1rem 0;
width: 100%;
}
/* Floating Damage Numbers */
@keyframes float-up {
0% {
opacity: 1;
transform: translateY(0) scale(1);
}
50% {
opacity: 1;
transform: translateY(-30px) scale(1.3);
}
100% {
opacity: 0;
transform: translateY(-60px) scale(1.5);
}
.combat-header {
display: flex;
justify-content: center;
align-items: center;
margin: 0 0 1rem 0;
padding: 0;
border: none;
background: transparent;
}
.floating-text-container {
.battle-arena {
display: flex;
justify-content: space-between;
align-items: center;
padding: 2rem 1rem;
position: relative;
min-height: 250px;
}
/* Combatants */
.combatant {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
position: relative;
}
.combatant.enemy {
color: #ffaaaa;
}
.combatant.player {
color: #aaddff;
}
.combatant.dead .enemy-avatar {
filter: grayscale(100%) brightness(0.5);
transition: filter 1s ease-out;
}
.avatar-container {
position: relative;
width: 100px;
height: 100px;
margin-bottom: 1rem;
display: flex;
align-items: center;
justify-content: center;
}
/* Cleaned up old styles */
.player-placeholder {
font-size: 5rem;
color: #ff4444;
}
.player-placeholder {
color: #4488ff;
}
.vs-divider {
font-size: 1.5rem;
font-weight: bold;
color: #666;
margin: 0 1rem;
}
/* Health Bars */
.stats-container {
width: 100%;
max-width: 200px;
text-align: center;
}
.health-bar-container {
width: 100%;
height: 16px;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
margin-top: 5px;
position: relative;
overflow: hidden;
}
.health-bar-fill {
height: 100%;
transition: width 0.3s ease-out;
}
.enemy-fill {
background: linear-gradient(90deg, #ff4444, #cc0000);
}
.player-fill {
background: linear-gradient(90deg, #4488ff, #0044cc);
}
.health-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 0.7rem;
font-weight: bold;
text-shadow: 1px 1px 1px black;
z-index: 2;
}
/* Action Buttons */
.combat-actions {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1rem 0;
width: 100%;
/* Ensure buttons stack properly */
gap: 0.5rem;
}
.combat-actions-group {
display: flex;
gap: 1rem;
width: 100%;
justify-content: center;
max-width: 400px;
}
.combat-actions-group {
max-width: 400px;
/* Limit width of attack/flee buttons */
}
.btn.full-width {
width: 100%;
max-width: 300px;
/* Don't let close button get too wide */
}
.btn-attack {
background: #ff4444;
color: white;
}
.btn-flee {
background: #666;
color: white;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Combat Log */
.combat-log-container {
background: rgba(0, 0, 0, 0.5);
border-radius: 8px;
padding: 0.5rem;
margin-top: 1rem;
height: 150px;
overflow-y: auto;
font-size: 0.9rem;
}
.log-message {
padding: 2px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.log-player_attack {
color: #aaddff;
}
.log-enemy_attack {
color: #ffaaaa;
}
.log-victory {
color: #44ff44;
font-weight: bold;
}
.log-defeat {
color: #ff4444;
font-weight: bold;
}
/* Animations & Floats */
.floating-text-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: hidden;
z-index: 100;
z-index: 10;
}
.floating-text {
position: absolute;
font-weight: bold;
font-size: 2.5rem;
text-shadow: 3px 3px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000;
animation: float-up 2.5s ease-out forwards;
white-space: nowrap;
font-size: 1.5rem;
animation: float-up 5s forwards;
pointer-events: none;
z-index: 1000;
text-shadow: 2px 2px 0 #000;
}
.floating-text.damage-player {
.type-damage {
color: #ff4444;
}
.floating-text.damage-enemy {
color: #ff4444;
.type-crit {
color: #ffaa00;
font-size: 2rem;
}
.floating-text.damage-player-dealt {
color: #ffffff;
}
.floating-text.heal {
.type-heal {
color: #44ff44;
}
/* Intent Bubble */
.intent-bubble {
position: absolute;
top: -40px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
border: 2px solid #fff;
border-radius: 20px;
padding: 5px 15px;
color: #fff;
font-weight: bold;
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
z-index: 10;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5);
animation: pop-in 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
.type-miss {
color: #aaa;
}
@keyframes pop-in {
.type-info {
color: #ffff44;
}
@keyframes float-up {
0% {
transform: translateX(-50%) scale(0);
transform: translateY(0) scale(1);
opacity: 1;
}
100% {
transform: translateX(-50%) scale(1);
transform: translateY(-50px) scale(1.2);
opacity: 0;
}
}
.intent-icon {
font-size: 1.2em;
.type-xp {
color: #ffd700;
font-size: 1.2rem;
/* User wants it lower, so we can adjust top via inline style in TSX or here */
/* text-shadow: 1px 1px 0 #000; */
}
.intent-desc {
font-size: 0.9em;
text-transform: uppercase;
letter-spacing: 1px;
.shake-effect {
animation: shake 0.5s cubic-bezier(.36, .07, .19, .97) both;
}
/* Intent Types */
.intent-attack {
border-color: #ff4444;
@keyframes shake {
10%,
90% {
transform: translate3d(-1px, 0, 0);
}
20%,
80% {
transform: translate3d(2px, 0, 0);
}
30%,
50%,
70% {
transform: translate3d(-4px, 0, 0);
}
40%,
60% {
transform: translate3d(4px, 0, 0);
}
}
.intent-defend {
border-color: #4488ff;
.flash-hit {
animation: flash 0.3s;
}
.intent-special {
border-color: #ffaa00;
@keyframes flash {
0% {
filter: brightness(1);
}
50% {
filter: brightness(2) sepia(1) hue-rotate(-50deg) saturate(5);
}
/* Red flash */
100% {
filter: brightness(1);
}
}
/* Container relative positioning for absolute children */
.combat-enemy-display-inline {
position: relative;
.turn-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.8);
padding: 1rem 2rem;
border-radius: 20px;
font-size: 1.5rem;
animation: pulse 1s infinite;
z-index: 20;
}
.combat-enemy-image-large {
position: relative;
display: inline-block;
max-width: 100%;
@keyframes pulse {
0% {
opacity: 0.7;
}
50% {
opacity: 1;
}
100% {
opacity: 0.7;
}
}
.combat-enemy-image-large img {
max-width: 100%;
height: auto;
display: block;
/* Attacking Animation */
.attacking {
animation: lunge 0.3s;
}
.combat-view {
position: relative;
/* For screen shake scope if applied here */
@keyframes lunge {
0% {
transform: translateX(0);
}
50% {
transform: translateX(20px);
}
/* Assuming LTR, for enemy use -20px via modifier if needed */
100% {
transform: translateX(0);
}
}
/* Combat Container */
.combat-container {
position: relative;
width: 100%;
.enemy.attacking {
animation: lunge-left 0.3s;
}
/* Combat Content Wrapper - Groups enemy display, turn indicator, and combat log */
.combat-content-wrapper {
display: inline-flex;
flex-direction: column;
align-items: stretch;
gap: 1rem;
max-width: 800px;
margin: 0 auto;
@keyframes lunge-left {
0% {
transform: translateX(0);
}
50% {
transform: translateX(-20px);
}
100% {
transform: translateX(0);
}
}
/* Turn Indicator - Match Enemy Image Width */
.combat-turn-indicator-inline {
width: 100%;
/* Combat Stats Layout - Staggered HP Bars */
.combat-stats-container {
display: flex;
justify-content: center;
}
/* Combat Log Styles */
.combat-log-wrapper {
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
width: 100%;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.combat-log-title {
margin: 0 0 10px 0;
font-size: 1.1em;
color: #aaa;
text-align: left;
}
.combat-log-inline {
background: rgba(0, 0, 0, 0.3);
padding: 15px;
.stat-block {
background: rgba(0, 0, 0, 0.4);
padding: 0.5rem 1rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
position: relative;
}
.log-entries {
max-height: 200px;
overflow-y: auto;
.stat-block.enemy {
width: 60%;
align-self: flex-start;
border-left: 3px solid #dc3545;
}
.stat-block.player {
width: 60%;
align-self: flex-end;
border-right: 3px solid #4caf50;
}
.stat-block .stat-header {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
justify-content: space-between;
margin-bottom: 0.25rem;
color: #fff;
}
/* Custom scrollbar for combat log */
.log-entries::-webkit-scrollbar {
width: 8px;
.stat-block .stat-label {
font-weight: 600;
}
.log-entries::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
.stat-block .stat-numbers {
color: #ddd;
}
.log-entries::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
.log-entries::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
.log-entry {
font-size: 0.9em;
padding: 6px 8px;
line-height: 1.5;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
border-left: 3px solid transparent;
transition: background 0.2s ease;
.stat-block.player .progress-bar {
display: flex;
align-items: flex-start;
gap: 8px;
justify-content: flex-end;
}
/* Ensure progress bars look like GameHeader */
.progress-bar {
width: 100%;
height: 12px;
/* Slightly thinner than header */
background: rgba(0, 0, 0, 0.6);
border-radius: 6px;
overflow: hidden;
position: relative;
}
.log-entry:hover {
background: rgba(0, 0, 0, 0.35);
}
.log-time {
color: #888;
font-size: 0.85em;
font-family: monospace;
flex-shrink: 0;
white-space: nowrap;
}
.log-message {
flex: 1;
word-wrap: break-word;
}
.player-log {
color: #aaddff;
border-left-color: #4488ff;
}
.enemy-log {
color: #ffaaaa;
border-left-color: #ff4444;
.progress-fill {
height: 100%;
border-radius: 6px;
transition: width 0.3s ease-out;
}

View File

@@ -1,154 +1,58 @@
/**
* Combat Types
* TypeScript type definitions for the combat system
*/
// ============================================================================
// Combat Message Types
// ============================================================================
/**
* Structured combat message from the server
*/
export interface CombatMessage {
type: 'combat_start' | 'player_attack' | 'enemy_attack' | 'victory' | 'flee_fail' | string
origin: 'player' | 'enemy' | 'neutral'
data: {
damage?: number
npc_name?: string | { en: string; es: string }
armor_absorbed?: number
[key: string]: any
}
type: string;
origin: 'player' | 'enemy' | 'neutral' | 'system';
data: Record<string, any>;
timestamp?: string;
}
/**
* Combat log entry displayed in the UI
*/
export interface CombatLogEntry {
id: string
time: string
message: string | CombatMessage
isPlayer: boolean
}
// ============================================================================
// Animation Types
// ============================================================================
/**
* Floating damage text animation
*/
export interface FloatingText {
id: number
text: string
x: number
y: number
type: 'damage-player' | 'damage-enemy' | 'damage-player-dealt' | 'heal'
id: string;
text: string;
type: 'damage' | 'heal' | 'info' | 'crit' | 'miss' | 'xp';
x: number; // Percentage 0-100
y: number; // Percentage 0-100
origin: 'player' | 'enemy';
timestamp: number;
}
/**
* Animation state for combat effects
*/
export interface CombatAnimationState {
shake: boolean
flash: boolean
enemyThinking: boolean
}
// ============================================================================
// Combat State Types
// ============================================================================
/**
* PvE Combat Data
*/
export interface PvECombat {
npc_id: string
npc_name: string | { en: string; es: string }
npc_hp: number
npc_max_hp: number
npc_image: string
turn: 'player' | 'enemy'
round: number
turn_time_remaining?: number
npc_intent?: 'attack' | 'defend' | 'special'
}
/**
* PvP Combat Player Info
*/
export interface PvPCombatPlayer {
id: number
username: string
level: number
hp: number
max_hp: number
}
/**
* PvP Combat Data
*/
export interface PvPCombat {
id: number
attacker: PvPCombatPlayer
defender: PvPCombatPlayer
is_attacker: boolean
your_turn: boolean
current_turn: 'attacker' | 'defender'
time_remaining: number
location_id: string
last_action?: string
combat_over: boolean
attacker_fled: boolean
defender_fled: boolean
}
/**
* Main Combat State
*/
export interface CombatState {
// Common fields
in_combat: boolean
combat_over: boolean
player_won?: boolean
player_fled?: boolean
// PvE fields
is_pvp: boolean
combat?: PvECombat
combat_image?: string
// PvP fields
in_pvp_combat?: boolean
pvp_combat?: PvPCombat
inCombat: boolean;
turn: 'player' | 'enemy';
npcId?: string;
npcName?: string;
npcHp: number;
npcMaxHp: number;
npcImage?: string;
playerHp: number;
playerMaxHp: number;
messages: CombatMessage[]; // History of messages
turnTimeRemaining?: number;
round: number;
isPvP?: boolean;
opponentName?: string;
}
// ============================================================================
// Combat Action Types
// ============================================================================
/**
* Combat action response from server
*/
export interface CombatActionResponse {
success: boolean
message: string
combat_over: boolean
player_won?: boolean
combat?: PvECombat
success: boolean;
messages: CombatMessage[]; // The structured messages from this action
combat_over: boolean;
player_won?: boolean;
combat?: any; // Updated combat state from API
pvp_combat?: any; // Updated PvP combat state from API
player?: {
hp: number
max_hp: number
xp: number
level: number
}
hp: number;
max_hp: number;
xp: number;
level: number;
};
winner_id?: string;
}
/**
* PvP Combat action response from server
*/
export interface PvPCombatActionResponse {
success: boolean
message: string
combat?: PvPCombat
export interface AnimationState {
shaking: boolean;
flashing: boolean;
enemyAttacking: boolean;
playerAttacking: boolean;
playerHit?: boolean; // New: Player taking damage
npcHit?: boolean; // New: NPC taking damage
}

View File

@@ -1,420 +1,274 @@
import { useTranslation } from 'react-i18next'
import type { CombatState, CombatLogEntry, Profile, Equipment, PlayerState } from './types'
import type { FloatingText, CombatMessage } from './CombatTypes'
import { getTranslatedText } from '../../utils/i18nUtils'
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useAudio } from '../../contexts/AudioContext';
import { CombatState, AnimationState, FloatingText } from './CombatTypes';
import { Equipment } from './types';
import './CombatEffects.css';
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?: FloatingText[]
state: CombatState;
animState: AnimationState;
floatingTexts: FloatingText[];
onAction: (action: string) => void;
onClose: () => void;
isProcessing: boolean;
combatResult: 'victory' | 'defeat' | 'fled' | null;
equipment?: Equipment | any;
}
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')
export const CombatView: React.FC<CombatViewProps> = ({
state,
animState,
floatingTexts,
onAction,
onClose,
isProcessing,
combatResult,
equipment
}) => {
const { t } = useTranslation();
const { playSfx } = useAudio();
// ============================================================================
// Message Rendering
// ============================================================================
// SFX Logic triggered by state or anim states
useEffect(() => {
// Check for combat completion sounds
if (combatResult === 'victory') {
playSfx('/audio/sfx/victory.wav');
} else if (combatResult === 'defeat') {
playSfx('/audio/sfx/defeat.wav');
} else if (combatResult === 'fled') {
playSfx('/audio/sfx/flee.wav');
}
}, [combatResult, playSfx]);
const renderCombatMessage = (msg: any) => {
// Handle string messages
if (typeof msg === 'string') {
return msg
// Track animation states to trigger attack/hit sounds
useEffect(() => {
// Player Attack Sound
if (animState.playerAttacking) {
if (equipment && equipment.main_hand) {
// Try to derive weapon type from name or properties
// This is a naive check; ideally the backend sends weapon type.
// We'll check for common keywords in the icon or name.
let weaponType = 'default';
const weaponName = (typeof equipment.main_hand.name === 'string'
? equipment.main_hand.name
: (equipment.main_hand.name?.en || '')
).toLowerCase();
if (weaponName.includes('sword') || weaponName.includes('blade')) weaponType = 'sword';
else if (weaponName.includes('axe')) weaponType = 'axe';
else if (weaponName.includes('bow')) weaponType = 'bow';
else if (weaponName.includes('hammer') || weaponName.includes('mace')) weaponType = 'blunt';
else if (weaponName.includes('dagger')) weaponType = 'dagger';
else if (weaponName.includes('fist') || !equipment.main_hand) weaponType = 'punch';
playSfx(`/audio/sfx/attack_${weaponType}.wav`, '/audio/sfx/attack_default.wav');
} else {
// Unarmed
playSfx('/audio/sfx/attack_punch.wav', '/audio/sfx/attack_default.wav');
}
}
// Handle legacy formatted messages
if (!msg || !msg.type) {
return String(msg)
// Enemy Attack Sound
if (animState.enemyAttacking) {
// We can use state.npcId to get specific enemy sounds
if (state.npcId) {
playSfx(`/audio/sfx/attack_enemy_${state.npcId}.wav`, '/audio/sfx/attack_enemy_default.wav');
} else {
playSfx('/audio/sfx/attack_enemy_default.wav', '/audio/sfx/attack_default.wav');
}
}
const message = msg as CombatMessage
const { type, data } = message
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:
// Fallback to JSON string for unknown types
return JSON.stringify(msg)
// Hit reaction (when shaking) - distinguishing origin would be better
// but animState.shaking is general.
// However, we know who is attacking from the other flags, so the *other* valid one is getting hit.
// Simpler: just play a generic hit sound when someone gets hit.
if (animState.shaking && !animState.playerAttacking && !animState.enemyAttacking) {
// This case might not happen often if shaking is coupled with attacking in parent.
// Actually Combat.tsx triggers 'shaking' ON attack for impact effect.
// So we might play 'hit' sound alongside attack sound?
// Or let's trigger hit sound specifically when damage numbers appear.
// Since we can't easily hook into floating text creation here without prop drill or context,
// we'll rely on the visual 'flashing' which usually implies taking damage.
}
}
// ============================================================================
// Format Timer Display
// ============================================================================
if (animState.flashing) {
// Someone took damage
playSfx('/audio/sfx/hit.wav');
}
const formatTimer = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${String(secs).padStart(2, '0')}`
}
}, [animState.playerAttacking, animState.enemyAttacking, animState.flashing, equipment, state.npcId, playSfx]);
// Auto-scroll log is less critical for table but good to keep if we can target the container
// For the table, we might just rely on normal scroll or add ref to the container
// Auto-scroll log to top on new entry
useEffect(() => {
const container = document.querySelector('.combat-log-container');
if (container) {
container.scrollTop = 0;
}
}, [state.messages]);
const getHealthPercent = (current: number, max: number) => {
return Math.max(0, Math.min(100, (current / max) * 100));
};
return (
<div className="combat-view">
<div className="combat-header-inline">
<h2 style={{ background: 'linear-gradient(90deg, #4CAF50, #2196F3)', padding: '0.5rem', borderRadius: '8px' }}>
🆕 NEW COMBAT - {combatState.is_pvp ? `⚔️ ${t('combat.title')} - PvP` : `⚔️ ${t('combat.title')} - ${displayEnemyName}`}
<div className="combat-container">
{/* Header (Location View Style) */}
<div className="combat-header">
<h2 className="centered-heading">
{state.isPvP ? t('combat.pvp_title') : t('combat.title')}
<span style={{ margin: '0 0.5rem', color: '#aaa', fontSize: '0.9em' }}>vs</span>
{state.npcName || t('combat.unknown_enemy')}
{state.turnTimeRemaining !== undefined && (
<span className="danger-badge danger-2" style={{ fontSize: '0.8rem', marginLeft: '0.5rem' }}>
{state.turnTimeRemaining}s
</span>
)}
</h2>
</div>
{combatState.is_pvp ? (
/* ================================================================ */
/* PvP Combat UI */
/* ================================================================ */
<div className="combat-content-wrapper">
<div className="combat-enemy-display-inline">
{/* Opponent Display */}
<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
{/* Main Content Vertical Stack */}
<div className="combat-main-content">
if (!opponent) return <div className="pvp-opponent-avatar"></div>
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}%`,
transition: 'width 0.5s ease-in-out'
}}
/>
</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' }}>
{t('combat.playerHp')}: {you.hp} / {you.max_hp}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${(you.hp / you.max_hp) * 100}%`,
background: 'linear-gradient(90deg, #f44336, #ff6b6b)',
transition: 'width 0.5s ease-in-out'
}}
/>
</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 ? `🏃 ${t('combat.fleeSuccess')}` : `💀 ${t('combat.defeat')}`}
</span>
) : combatState.pvp_combat.your_turn ? (
<span className="your-turn">
{t('combat.yourTurn')} ({formatTimer(pvpTimeRemaining ?? combatState.pvp_combat.time_remaining)})
</span>
{/* 1. Enemy Avatar (Location Image Style) */}
{/* Shake on npcHit, Attack on enemyAttacking, Dead on victory */}
<div className={`enemy-display ${animState.enemyAttacking ? 'attacking' : ''} ${animState.npcHit ? 'shake-effect flash-hit' : ''} ${combatResult === 'victory' ? 'dead' : ''}`}>
<div className="location-image-container">
{state.npcImage ? (
<img src={state.npcImage} alt={state.npcName} className="location-image" />
) : (
<span className="enemy-turn">
{t('combat.waiting')} ({formatTimer(pvpTimeRemaining ?? combatState.pvp_combat.time_remaining)})
</span>
<div className="enemy-placeholder">💀</div>
)}
</div>
</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('combat.actions.attack')}
</button>
<button
className="combat-action-btn flee-btn"
onClick={() => onPvPAction('flee')}
disabled={!combatState.pvp_combat.your_turn || buttonsDisabled}
>
{t('combat.actions.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>
{/* 2. HP Bars (Character Sheet Style) - Staggered Lines */}
<div className="combat-stats-container">
{/* 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>
)}
{/* Enemy HP (Left) */}
{/* Also shake the stat block on npcHit if desired, or just avatar. User said "both image and health bar should shake" */}
<div className={`stat-block enemy ${animState.npcHit ? 'shake-effect' : ''}`}>
<div className="floating-text-layer" style={{ height: '0', overflow: 'visible' }}>
{floatingTexts.filter(ft => ft.origin === 'enemy').map(ft => (
<div key={ft.id} className={`floating-text type-${ft.type}`} style={{ left: `${ft.x}%`, top: `${ft.y - 50}%` }}>
{ft.text}
</div>
</div>
))}
</div>
<div className="stat-header">
<span className="stat-label">{t('common.enemy')}</span>
<span className="stat-numbers">{state.npcHp} / {state.npcMaxHp}</span>
</div>
<div className="progress-bar">
<div className="progress-fill health" style={{ width: `${getHealthPercent(state.npcHp, state.npcMaxHp)}%`, background: 'linear-gradient(90deg, #dc3545 0%, #ff6b6b 100%)' }}></div>
</div>
</div>
{/* Player HP (Right) */}
<div className={`stat-block player ${animState.playerAttacking ? 'attacking' : ''} ${animState.playerHit ? 'shake-effect flash-hit' : ''}`}>
<div className="floating-text-layer" style={{ height: '0', overflow: 'visible' }}>
{floatingTexts.filter(ft => ft.origin === 'player').map(ft => (
<div key={ft.id} className={`floating-text type-${ft.type}`} style={{ left: `${ft.x}%`, top: `${ft.y - 50}%` }}>
{ft.text}
</div>
))}
</div>
<div className="stat-header">
<span className="stat-label">{t('common.you')}</span>
<span className="stat-numbers">{state.playerHp} / {state.playerMaxHp}</span>
</div>
<div className="progress-bar">
<div className="progress-fill health" style={{ width: `${getHealthPercent(state.playerHp, state.playerMaxHp)}%`, background: 'linear-gradient(90deg, #4caf50 0%, #8bc34a 100%)' }}></div>
</div>
</div>
</div>
) : (
/* ================================================================ */
/* PvE Combat UI */
/* ================================================================ */
<>
<div className="combat-content-wrapper">
<div className="combat-enemy-display-inline">
{/* Enemy Intent Bubble */}
{combatState.combat?.npc_intent && !combatState.combat_over && (
<div className={`intent-bubble intent-${combatState.combat.npc_intent}`}>
<span className="intent-icon">
{combatState.combat.npc_intent === 'attack' ? '⚔️' :
combatState.combat.npc_intent === 'defend' ? '🛡️' :
combatState.combat.npc_intent === 'special' ? ' 🔥' : '❓'}
</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>
{/* 3. Actions */}
<div className="combat-actions">
<button
className="btn btn-primary full-width glow-effect"
onClick={onClose}
style={{ display: combatResult ? 'block' : 'none', margin: '0 auto' }}
>
{t('common.close')}
</button>
<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}%`,
transition: 'width 0.5s ease-in-out'
}}
/>
</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)',
transition: 'width 0.5s ease-in-out'
}}
/>
</div>
</div>
)}
</div>
</div>
<div className="combat-actions-group" style={{ display: !combatResult ? 'flex' : 'none', gap: '1rem', width: '100%', justifyContent: 'center' }}>
<button
className="btn btn-attack"
onClick={() => onAction('attack')}
disabled={isProcessing || state.turn !== 'player'}
>
👊 {t('combat.actions.attack')}
</button>
<div className={`combat-turn-indicator-inline ${enemyTurnMessage ? 'enemy-turn-message' : ''}`}>
{!combatState.combat_over ? (
enemyTurnMessage ? (
<span className="enemy-turn">{t('combat.thinking')}</span>
) : combatState.combat?.turn === 'player' ? (
<>
<span className="your-turn"> {t('combat.yourTurn')}</span>
{turnTimeRemaining !== null && (
<span className="turn-timer" style={{ marginLeft: '1rem', fontSize: '0.9em', opacity: 0.8 }}>
{formatTimer(turnTimeRemaining)}
</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('combat.actions.attack')}
</button>
<button
className="combat-action-btn flee-btn"
onClick={() => onCombatAction('flee')}
disabled={combatState.combat?.turn !== 'player' || !!enemyTurnMessage || buttonsDisabled}
>
{t('combat.actions.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>
<button
className="btn btn-flee"
onClick={() => onAction('flee')}
disabled={isProcessing || state.turn !== 'player'}
>
🏃 {t('combat.actions.flee')}
</button>
</div>
</>
</div>
{/* 4. Log (Table) */}
<div className="combat-log-container">
<table className="combat-log-table">
<tbody>
{[...state.messages].reverse().map((msg, index) => {
let text = "";
let className = `log-row log-${msg.type}`;
if (msg.data && msg.data.message) {
text = msg.data.message;
} else {
switch (msg.type) {
case 'combat_start': text = t('combat.start'); break;
case 'player_attack': text = t('combat.log.player_attack', { damage: msg.data?.damage || 0 }); break;
case 'enemy_attack':
text = t('combat.log.enemy_attack', { damage: msg.data?.damage || 0 });
className += " text-danger";
break;
case 'player_miss': text = t('combat.log.player_miss'); break;
case 'enemy_miss': text = t('combat.log.enemy_miss'); break;
case 'victory': text = t('combat.victory'); className += " text-success bold"; break;
case 'player_defeated': text = t('combat.defeat'); className += " text-danger bold"; break;
case 'flee_success': text = t('combat.flee.success'); break;
case 'flee_fail': text = t('combat.flee.fail'); break;
case 'item_broken': text = t('combat.item_broken', { item: msg.data?.item_name }); break;
case 'xp_gain': text = t('combat.log.xp_gain', { xp: msg.data?.xp }); className += " text-warning"; break;
case 'text': text = msg.data?.text || ""; break;
default: text = msg.type;
}
}
const time = msg.timestamp || new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
return (
<tr key={index} className={className}>
<td className="log-time">[{time}]</td>
<td className="log-event">{text}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Overlay for Enemy Turn / Processing */}
{/* Overlay for Enemy Turn / Processing */}
{isProcessing && !combatResult && state.turn === 'enemy' && (
<div className="turn-overlay">
{t('combat.enemy_turn')}
</div>
)}
</div>
)
}
export default CombatView
);
};

View File

@@ -756,4 +756,38 @@
grid-template-columns: repeat(2, auto);
gap: 0.5rem 1rem;
align-items: center;
}
/* Backpack Category Sections */
.backpack-category-section {
margin-bottom: 0.5rem;
}
.subcategory-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
background: rgba(255, 255, 255, 0.03);
border-left: 3px solid #4299e1;
margin: 0.5rem 0;
border-radius: 0 4px 4px 0;
}
.subcat-icon {
font-size: 1rem;
}
.subcat-label {
font-size: 0.8rem;
font-weight: 500;
color: #a0aec0;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.subcat-count {
font-size: 0.75rem;
color: #718096;
margin-left: auto;
}

View File

@@ -1,5 +1,6 @@
import { MouseEvent, ChangeEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { useAudio } from '../../contexts/AudioContext'
import { PlayerState, Profile, Equipment } from './types'
import { getAssetPath } from '../../utils/assetPath'
import { getTranslatedText } from '../../utils/i18nUtils'
@@ -35,6 +36,7 @@ function InventoryModal({
onDropItem
}: InventoryModalProps) {
const { t } = useTranslation()
const { playSfx } = useAudio()
// Categories for the sidebar
const categories = [
{ id: 'all', label: t('categories.all'), icon: '🎒' },
@@ -246,28 +248,49 @@ function InventoryModal({
{/* Right: Actions */}
<div className="item-actions-section">
{item.consumable && (
<button className="action-btn use" onClick={() => onUseItem(item.item_id, item.id)}>{t('game.use')}</button>
<button className="action-btn use" onClick={() => {
playSfx('/audio/sfx/use.wav')
onUseItem(item.item_id, item.id)
}}>{t('game.use')}</button>
)}
{item.equippable && !item.is_equipped && (
<button className="action-btn equip" onClick={() => onEquipItem(item.id)}>{t('game.equip')}</button>
<button className="action-btn equip" onClick={() => {
playSfx('/audio/sfx/equip.wav')
onEquipItem(item.id)
}}>{t('game.equip')}</button>
)}
{item.is_equipped && (
<button className="action-btn unequip" onClick={() => onUnequipItem(item.slot)}>{t('game.unequip')}</button>
<button className="action-btn unequip" onClick={() => {
playSfx('/audio/sfx/unequip.wav')
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, item.quantity)}>{t('game.dropAll')}</button>
)}
<button className={`action-btn drop single`} onClick={() => {
playSfx('/audio/sfx/drop.wav')
onDropItem(item.item_id, item.id, 1)
}}>
{item.quantity === 1 ? t('game.drop') : 'x1' }
</button>
{item.quantity >= 5 && (
<button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, 5)}>x5</button>
<button className="action-btn drop" onClick={() => {
playSfx('/audio/sfx/drop.wav')
onDropItem(item.item_id, item.id, 5)
}}>x5</button>
)}
{item.quantity >= 10 && (
<button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, 10)}>x10</button>
<button className="action-btn drop" onClick={() => {
playSfx('/audio/sfx/drop.wav')
onDropItem(item.item_id, item.id, 10)
}}>x10</button>
)}
{item.quantity > 1 && (
<button className="action-btn drop" onClick={() => {
playSfx('/audio/sfx/drop.wav')
onDropItem(item.item_id, item.id, item.quantity)
}}>{t('game.dropAll')}</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 ? t('game.drop') : t('game.dropAll')}
</button>
</div>
</div>
</div>
@@ -379,11 +402,29 @@ function InventoryModal({
</>
)}
{/* Backpack */}
{/* Backpack - grouped by categories */}
{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))}
{/* Group backpack items by category */}
{categories
.filter(cat => cat.id !== 'all') // Exclude 'all' from subcategories
.map(cat => {
const categoryItems = filteredItems.filter(
(item: any) => !item.is_equipped && item.type === cat.id
);
if (categoryItems.length === 0) return null;
return (
<div key={cat.id} className="backpack-category-section">
<div className="subcategory-header">
<span className="subcat-icon">{cat.icon}</span>
<span className="subcat-label">{cat.label}</span>
<span className="subcat-count">({categoryItems.length})</span>
</div>
{categoryItems.map((item: any, i: number) => renderItemCard(item, i))}
</div>
);
})}
</>
)}
</>

View File

@@ -1,6 +1,7 @@
import type { Location, PlayerState, CombatState, Profile, WorkbenchTab } from './types'
import { useTranslation } from 'react-i18next'
import { useAudio } from '../../contexts/AudioContext'
import Workbench from './Workbench'
import { getAssetPath } from '../../utils/assetPath'
import { getTranslatedText } from '../../utils/i18nUtils'
@@ -83,6 +84,7 @@ function LocationView({
onUncraft
}: LocationViewProps) {
const { t } = useTranslation()
const { playSfx } = useAudio()
return (
<div className="location-view">
<div className="location-info">
@@ -216,7 +218,10 @@ function LocationView({
</div>
<button
className="entity-action-btn loot-btn"
onClick={() => onLootCorpse(String(corpse.id))}
onClick={() => {
playSfx('/audio/sfx/interact.wav')
onLootCorpse(String(corpse.id))
}}
disabled={corpse.loot_count === 0}
>
🔍 {t('common.examine')}
@@ -360,7 +365,10 @@ function LocationView({
{item.quantity === 1 ? (
<button
className="entity-action-btn pickup"
onClick={() => onPickup(item.id, 1)}
onClick={() => {
playSfx('/audio/sfx/pickup.wav')
onPickup(item.id, 1)
}}
>
{t('common.pickUp')}
</button>
@@ -368,14 +376,26 @@ function LocationView({
<div className="item-pickup-btn-container">
<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)}>{t('common.pickUp')} 1</button>
<button className="item-pickup-option" onClick={() => {
playSfx('/audio/sfx/pickup.wav')
onPickup(item.id, 1)
}}>{t('common.pickUp')} 1</button>
{item.quantity >= 5 && (
<button className="item-pickup-option" onClick={() => onPickup(item.id, 5)}>{t('common.pickUp')} 5</button>
<button className="item-pickup-option" onClick={() => {
playSfx('/audio/sfx/pickup.wav')
onPickup(item.id, 5)
}}>{t('common.pickUp')} 5</button>
)}
{item.quantity >= 10 && (
<button className="item-pickup-option" onClick={() => onPickup(item.id, 10)}>{t('common.pickUp')} 10</button>
<button className="item-pickup-option" onClick={() => {
playSfx('/audio/sfx/pickup.wav')
onPickup(item.id, 10)
}}>{t('common.pickUp')} 10</button>
)}
<button className="item-pickup-option" onClick={() => onPickup(item.id, item.quantity)}>
<button className="item-pickup-option" onClick={() => {
playSfx('/audio/sfx/pickup.wav')
onPickup(item.id, item.quantity)
}}>
{t('common.pickUpAll')} ({item.quantity})
</button>
</div>
@@ -410,14 +430,14 @@ function LocationView({
onClick={() => onInitiatePvP(player.id)}
title={`Attack ${player.name || player.username}`}
>
Attack
{t('game.attack')}
</button>
)}
{!player.can_pvp && player.level_diff !== undefined && Math.abs(player.level_diff) > 3 && (
<div className="pvp-disabled-reason">Level difference too high</div>
<div className="pvp-disabled-reason">{t('game.levelDifferenceTooHigh')}</div>
)}
{!player.can_pvp && location.danger_level !== undefined && location.danger_level < 3 && (
<div className="pvp-disabled-reason">Area too safe for PvP</div>
<div className="pvp-disabled-reason">{t('game.areaTooSafeForPvP')}</div>
)}
</div>
))}

View File

@@ -224,24 +224,30 @@ function MovementControls({
const cooldownRemaining = cooldownExpiry && cooldownExpiry > now
? Math.ceil(cooldownExpiry - now)
: 0
const staminaCost = action.stamina_cost || 1
const insufficientStamina = profile ? profile.stamina < staminaCost : false
return (
<button
key={action.id}
className="interact-btn"
disabled={!!combatState || cooldownRemaining > 0}
className={`interact-btn ${insufficientStamina ? 'disabled' : ''}`}
disabled={!!combatState || cooldownRemaining > 0 || insufficientStamina || (profile?.is_dead ?? false)}
onClick={() => onInteract && onInteract(interactable.instance_id, action.id)}
title={
combatState
? 'Cannot interact during combat'
: cooldownRemaining > 0
? `Wait ${cooldownRemaining}s`
: getTranslatedText(action.description)
profile?.is_dead
? t('messages.youAreDead')
: combatState
? t('messages.cannotInteractInCombat')
: insufficientStamina
? t('messages.notEnoughStamina', { need: staminaCost, have: profile?.stamina ?? 0 })
: cooldownRemaining > 0
? t('messages.interactionCooldown', { seconds: cooldownRemaining })
: getTranslatedText(action.description)
}
>
{getTranslatedText(action.name)}
<span className="stamina-cost">
{cooldownRemaining > 0 ? `${cooldownRemaining}s` : `${action.stamina_cost}`}
{cooldownRemaining > 0 ? `${cooldownRemaining}s` : `${staminaCost}`}
</span>
</button>
)

View File

@@ -910,8 +910,8 @@ export function useGameEngine(
// Map API field names to playerState field names
const mappedData: any = {}
// Skip HP updates if in combat (Combat.tsx handles HP timing)
if (playerData.hp !== undefined && !combatState) {
// HP updates are now controlled by Combat.tsx - it calls updatePlayerState at the right time
if (playerData.hp !== undefined) {
mappedData.health = playerData.hp
}
if (playerData.max_hp !== undefined) {
@@ -929,8 +929,8 @@ export function useGameEngine(
setPlayerState((prev: any) => prev ? { ...prev, ...mappedData } : null)
}
// Also update profile for consistency (skip HP if in combat)
if (playerData.hp !== undefined && profile && !combatState) {
// Also update profile for consistency
if (playerData.hp !== undefined && profile) {
setProfile((prev: any) => prev ? { ...prev, hp: playerData.hp } : null)
}
if (playerData.xp !== undefined && profile) {

View File

@@ -0,0 +1,115 @@
import React, { createContext, useContext, useState } from 'react';
import { isElectronApp } from '../utils/assetPath';
interface AudioContextType {
masterVolume: number;
musicVolume: number;
sfxVolume: number;
isMuted: boolean;
setMasterVolume: (val: number) => void;
setMusicVolume: (val: number) => void;
setSfxVolume: (val: number) => void;
setIsMuted: (val: boolean) => void;
playSfx: (path: string, fallbackPath?: string) => void;
}
const AudioContext = createContext<AudioContextType | undefined>(undefined);
export const AudioProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// Initialize state from localStorage or defaults
const [masterVolume, setMasterVolumeState] = useState(() => {
const saved = localStorage.getItem('audio_masterVolume');
return saved ? parseFloat(saved) : 1.0;
});
const [musicVolume, setMusicVolumeState] = useState(() => {
const saved = localStorage.getItem('audio_musicVolume');
return saved ? parseFloat(saved) : 0.5;
});
const [sfxVolume, setSfxVolumeState] = useState(() => {
const saved = localStorage.getItem('audio_sfxVolume');
return saved ? parseFloat(saved) : 0.8;
});
const [isMuted, setIsMutedState] = useState(() => {
const saved = localStorage.getItem('audio_isMuted');
return saved ? JSON.parse(saved) : false;
});
// Persistence wrappers
const setMasterVolume = (val: number) => {
setMasterVolumeState(val);
localStorage.setItem('audio_masterVolume', val.toString());
};
const setMusicVolume = (val: number) => {
setMusicVolumeState(val);
localStorage.setItem('audio_musicVolume', val.toString());
};
const setSfxVolume = (val: number) => {
setSfxVolumeState(val);
localStorage.setItem('audio_sfxVolume', val.toString());
};
const setIsMuted = (val: boolean) => {
setIsMutedState(val);
localStorage.setItem('audio_isMuted', JSON.stringify(val));
};
const playSfx = (path: string, fallbackPath?: string) => {
if (isMuted) return;
// Calculate effective volume
const effectiveVolume = masterVolume * sfxVolume;
if (effectiveVolume <= 0) return;
// Handle path correction for Electron vs Browser
const resolvePath = (p: string) => {
if (p.startsWith('http') || p.startsWith('file')) return p;
// Ensure leading slash for browser, dot slash for electron relative
const cleanPath = p.startsWith('/') ? p.slice(1) : p;
return isElectronApp() ? `./${cleanPath}` : `/${cleanPath}`;
};
const primarySrc = resolvePath(path);
const audio = new Audio(primarySrc);
audio.volume = effectiveVolume;
const playPromise = audio.play();
playPromise.catch((error) => {
// If primary fails (e.g. 404 or format issue), try fallback
console.warn(`SFX failed: ${path}`, error);
if (fallbackPath) {
const fallbackSrc = resolvePath(fallbackPath);
console.log(`Trying fallback SFX: ${fallbackPath}`);
const fallbackAudio = new Audio(fallbackSrc);
fallbackAudio.volume = effectiveVolume;
fallbackAudio.play().catch(e => console.error(`Fallback SFX failed: ${fallbackPath}`, e));
}
});
};
return (
<AudioContext.Provider value={{
masterVolume,
musicVolume,
sfxVolume,
isMuted,
setMasterVolume,
setMusicVolume,
setSfxVolume,
setIsMuted,
playSfx
}}>
{children}
</AudioContext.Provider>
);
};
export const useAudio = () => {
const context = useContext(AudioContext);
if (context === undefined) {
throw new Error('useAudio must be used within an AudioProvider');
}
return context;
};

View File

@@ -1,6 +1,11 @@
import { createContext, useState, useEffect, ReactNode } from 'react'
import { createContext, useState, useEffect, ReactNode, useContext } from 'react'
import api, { authApi, characterApi, Account, Character } from '../services/api'
// ... (interface remains same) ...
export const useAuth = () => useContext(AuthContext)
interface AuthContextType {
isAuthenticated: boolean
loading: boolean

View File

@@ -19,7 +19,9 @@
"fight": "Fight",
"pickUp": "Pick Up",
"pickUpAll": "Pick Up All",
"qty": "Qty"
"qty": "Qty",
"enemy": "Enemy",
"you": "You"
},
"auth": {
"login": "Login",
@@ -78,7 +80,9 @@
"weight": "Weight",
"volume": "Volume",
"durability": "Durability",
"noItemsFound": "No items found in this category"
"noItemsFound": "No items found in this category",
"levelDifferenceTooHigh": "Level difference too high",
"areaTooSafeForPvP": "Area too safe for PvP"
},
"location": {
"recentActivity": "📜 Recent Activity",
@@ -141,6 +145,9 @@
},
"combat": {
"title": "Combat",
"pvp_title": "Duel",
"unknown_enemy": "Unknown Enemy",
"start": "Combat started!",
"inCombat": "In Combat",
"yourTurn": "Your Turn",
"enemyTurn": "Enemy's Turn",
@@ -184,6 +191,20 @@
"enemyMiss": "Enemy missed!",
"armorAbsorbed": "Armor absorbed {{armor}} damage",
"itemBroke": "{{item}} broke!"
},
"log": {
"combat_start": "Combat started!",
"combat_initiation": "Combat initiated!",
"ambush": "You were ambushed!",
"pvp_attack": "You attacked another player!",
"pvp_defense": "You are under attack by another player!",
"player_attack": "You hit for {{damage}} damage",
"enemy_attack": "Enemy hits for {{damage}} damage",
"player_miss": "You missed!",
"enemy_miss": "Enemy missed!",
"item_broken": "Your {{item}} broke!",
"xp_gain": "You gained {{xp}} XP!",
"flee_success": "You managed to escape!"
}
},
"equipment": {

View File

@@ -78,7 +78,9 @@
"weight": "Peso",
"volume": "Volumen",
"durability": "Durabilidad",
"noItemsFound": "No se encontraron objetos en esta categoría"
"noItemsFound": "No se encontraron objetos en esta categoría",
"levelDifferenceTooHigh": "Nivel demasiado alto",
"areaTooSafeForPvP": "Área demasiado segura para PvP"
},
"location": {
"recentActivity": "📜 Actividad Reciente",
@@ -141,6 +143,9 @@
},
"combat": {
"title": "Combate",
"pvp_title": "Duelo",
"unknown_enemy": "Enemigo Desconocido",
"start": "¡Combate iniciado!",
"inCombat": "En Combate",
"yourTurn": "Tu Turno",
"enemyTurn": "Turno del Enemigo",
@@ -184,6 +189,20 @@
"enemyMiss": "¡El enemigo falló!",
"armorAbsorbed": "La armadura absorbió {{armor}} de daño",
"itemBroke": "¡{{item}} se rompió!"
},
"log": {
"combat_start": "¡Combate iniciado!",
"combat_initiation": "¡Combate iniciado!",
"ambush": "¡Te emboscaron!",
"pvp_attack": "¡Atacaste a otro jugador!",
"pvp_defense": "¡Estás bajo ataque de otro jugador!",
"player_attack": "Golpeas por {{damage}} de daño",
"enemy_attack": "El enemigo golpea por {{damage}} de daño",
"player_miss": "¡Fallaste!",
"enemy_miss": "¡El enemigo falló!",
"item_broken": "¡Tu {{item}} se rompió!",
"flee_success": "¡Lograste escapar!",
"flee_fail": "¡No pudiste escapar!"
}
},
"equipment": {

View File

@@ -44,7 +44,9 @@ export default defineConfig({
cleanupOutdatedCaches: true,
skipWaiting: true,
clientsClaim: true,
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
// Exclude images from precache manifest to avoid 404s on build
globPatterns: ['**/*.{js,css,html,ico,svg,woff,woff2}'],
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // 5MB
runtimeCaching: [
{
urlPattern: /^https:\/\/staging\.echoesoftheash\.com\/api\/.*/i,