Backup before cleanup

This commit is contained in:
Joan
2026-02-05 15:00:49 +01:00
parent e6747b1d05
commit 1b7ffd614d
60 changed files with 3013 additions and 460 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -56,7 +56,8 @@
border-color: #535bf2;
}
input, textarea {
input,
textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #3a3a3a;
@@ -66,7 +67,8 @@ input, textarea {
font-size: 1rem;
}
input:focus, textarea:focus {
input:focus,
textarea:focus {
outline: none;
border-color: #646cff;
}
@@ -85,8 +87,48 @@ input:focus, textarea:focus {
.container {
padding: 0.5rem;
}
.card {
padding: 1rem;
}
}
/* Status Effects */
.status-effects-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 15px;
}
.status-effect-badge {
display: flex;
align-items: center;
gap: 5px;
padding: 4px 8px;
background-color: #333;
border-radius: 4px;
font-size: 0.85rem;
border: 1px solid #444;
}
.status-effect-badge.damage {
background-color: rgba(231, 76, 60, 0.2);
border-color: #e74c3c;
color: #ffdce0;
}
.status-effect-badge.buff {
background-color: rgba(46, 204, 113, 0.2);
border-color: #2ecc71;
color: #d4efdf;
}
.effect-icon {
font-size: 1.1em;
}
.effect-timer {
font-family: monospace;
opacity: 0.8;
}

View File

@@ -1,16 +1,23 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, useCallback } 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 { audioContext, masterVolume, musicVolume, isMuted, getAudioBuffer } = useAudio();
// We only need refs for the source (track) and the gain (volume)
// The context is now shared.
const sourceNodeRef = useRef<AudioBufferSourceNode | null>(null);
const musicGainNodeRef = useRef<GainNode | null>(null);
const [audioBuffer, setAudioBuffer] = useState<AudioBuffer | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [playbackError, setPlaybackError] = useState(false);
// Routes where music should play
const shouldPlayMusic = () => {
const shouldPlayMusic = useCallback(() => {
// Game main view
if (pathname === '/game') return true;
// Leaderboards
@@ -21,73 +28,142 @@ export default function BackgroundMusic() {
if (pathname.startsWith('/profile/')) return true;
return false;
};
}, [pathname]);
// Calculate effective volume
const effectiveVolume = isMuted ? 0 : masterVolume * musicVolume;
// Load Audio Buffer (using shared cache)
useEffect(() => {
if (!audioRef.current) {
// For static assets in public folder:
// Browser: use absolute path from root
// Electron: use relative path
const loadAudio = async () => {
setIsLoading(true);
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);
const buffer = await getAudioBuffer(src);
if (buffer) {
setAudioBuffer(buffer);
} else {
console.error('Failed to load background music buffer');
setPlaybackError(true);
}
setIsLoading(false);
};
handlePlay();
if (audioContext) {
loadAudio();
}
}, [audioContext, getAudioBuffer]);
// Attempts to resume audio if the user interacts with the page
const retryPlay = () => {
if (shouldPlayMusic() && audio.paused) {
handlePlay();
// Setup Gain Node
useEffect(() => {
if (audioContext && !musicGainNodeRef.current) {
const gain = audioContext.createGain();
gain.connect(audioContext.destination);
musicGainNodeRef.current = gain;
}
}, [audioContext]);
// Playback Logic
const playMusic = useCallback(() => {
if (!audioContext || !audioBuffer || !musicGainNodeRef.current) return;
// If already playing, do nothing
if (sourceNodeRef.current) return;
try {
// Ensure context is running (handled globally but good to check)
if (audioContext.state === 'suspended') {
audioContext.resume().catch(e => console.warn(e));
}
const source = audioContext.createBufferSource();
source.buffer = audioBuffer;
source.loop = true;
source.connect(musicGainNodeRef.current);
source.start(0);
sourceNodeRef.current = source;
setPlaybackError(false);
// Cleanup on end (though looping, so only if loop=false or stopped)
source.onended = () => {
if (sourceNodeRef.current === source) {
sourceNodeRef.current = null;
}
};
} catch (error) {
console.error('Start playback failed:', error);
setPlaybackError(true);
}
}, [audioContext, audioBuffer]);
const stopMusic = useCallback(() => {
if (sourceNodeRef.current) {
try {
sourceNodeRef.current.stop();
sourceNodeRef.current.disconnect();
} catch (e) {
// ignore
}
sourceNodeRef.current = null;
}
}, []);
// Handle Volume Changes
useEffect(() => {
if (musicGainNodeRef.current && audioContext) {
const currentTime = audioContext.currentTime;
musicGainNodeRef.current.gain.setTargetAtTime(effectiveVolume, currentTime, 0.1);
}
}, [effectiveVolume, audioContext]);
// Control Play/Stop based on Route and Readiness
useEffect(() => {
if (isLoading || !audioContext) return;
const handleAudioLogic = () => {
if (shouldPlayMusic()) {
if (!sourceNodeRef.current) {
playMusic();
}
} else {
stopMusic();
}
};
if (playbackError) {
document.addEventListener('click', retryPlay, { once: true });
}
handleAudioLogic();
}, [shouldPlayMusic, isLoading, audioContext, playMusic, stopMusic]);
// Cleanup on unmount
useEffect(() => {
return () => {
stopMusic();
// Don't close context, it's shared
};
}, [stopMusic]);
// Monitor state for overlay
const [isSuspended, setIsSuspended] = useState(false);
useEffect(() => {
if (!audioContext) return;
const updateState = () => setIsSuspended(audioContext.state === 'suspended');
updateState();
const interval = setInterval(updateState, 1000);
audioContext.addEventListener('statechange', updateState);
return () => {
document.removeEventListener('click', retryPlay);
clearInterval(interval);
audioContext.removeEventListener('statechange', updateState);
};
}, [audioContext]);
}, [pathname, effectiveVolume, playbackError]);
// Render overlay if music should play but is blocked
if (!shouldPlayMusic()) return null;
// 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;
// If not suspended and no error, don't show overlay
if (!isSuspended && !playbackError) return null;
return (
<div
@@ -96,7 +172,7 @@ export default function BackgroundMusic() {
bottom: '20px',
right: '20px',
zIndex: 9999,
background: 'rgba(74, 158, 255, 0.9)',
background: 'rgba(255, 74, 74, 0.9)',
color: 'white',
padding: '10px 20px',
borderRadius: '8px',
@@ -106,14 +182,15 @@ export default function BackgroundMusic() {
animation: 'pulse 2s infinite'
}}
onClick={() => {
if (audioRef.current) {
audioRef.current.play()
.then(() => setPlaybackError(false))
.catch(e => console.error(e));
if (audioContext) {
audioContext.resume().then(() => {
// Attempt to play again
playMusic();
});
}
}}
>
🎵 Click to Enable Audio
{playbackError ? '⚠️ Audio Error' : '🎵 Click to Enable Audio'}
</div>
);
}

View File

@@ -1909,7 +1909,6 @@ body.no-scroll {
align-items: center;
gap: 0.25rem;
width: 100%;
max-width: 50px;
flex: 1;
/* Allow content to grow */
justify-content: space-between;
@@ -2047,15 +2046,11 @@ body.no-scroll {
}
.equipment-emoji {
max-width: 50px;
max-height: 50px;
font-size: 1.2rem;
/* Reduced for better fit */
line-height: 1;
/* Prevent clipping */
margin-top: 0.25rem;
/* Add small margin */
width: 100%;
height: 100%;
object-fit: contain;
margin: 0;
line-height: 1;
}
.equipment-emoji.hidden {

View File

@@ -219,6 +219,64 @@ function Game() {
}
break
case 'interactable_ready':
// Interactable cooldown finished
if (message.data?.action_name && message.data?.name) {
actions.addLocationMessage(t('messages.interactableReady', {
action: message.data.action_name,
name: message.data.name
}))
} else if (message.data?.message) {
actions.addLocationMessage(message.data.message)
}
break
case 'status_effect_damage':
if (message.data?.damage) {
actions.addLocationMessage(t('messages.statusDamage', { damage: message.data.damage }))
actions.updatePlayerState({ hp: message.data.hp })
if (message.data.effects && Array.isArray(message.data.effects)) {
message.data.effects.forEach((e: any) => {
actions.updateStatusEffect(e.name, e.ticks_remaining)
})
} else if (message.data.name && message.data.ticks_remaining !== undefined) {
actions.updateStatusEffect(message.data.name, message.data.ticks_remaining)
}
}
break
case 'status_effect_heal':
if (message.data?.heal) {
actions.addLocationMessage(t('messages.statusHeal', { heal: message.data.heal }))
actions.updatePlayerState({ hp: message.data.hp })
if (message.data.effects && Array.isArray(message.data.effects)) {
message.data.effects.forEach((e: any) => {
actions.updateStatusEffect(e.name, e.ticks_remaining)
})
} else if (message.data.name && message.data.ticks_remaining !== undefined) {
actions.updateStatusEffect(message.data.name, message.data.ticks_remaining)
}
}
break
case 'player_died':
if (message.data?.is_dead) {
actions.addLocationMessage(t('messages.diedStatus'))
actions.updatePlayerState({ hp: 0, is_dead: true })
}
break
case 'stamina_update':
if (message.data?.stamina) {
// Only show message if significant change or if it's the regeneration event
// actions.addLocationMessage(t('messages.staminaRegenerated'))
// (commented out to avoid spam, usually stamina update is silent or subtle)
actions.updatePlayerState({ stamina: message.data.stamina })
}
break
case 'player_count_update':
// Handled by GameHeader, ignore here
break

View File

@@ -0,0 +1,102 @@
import React from 'react';
import '../game/InventoryModal.css'; // Reusing existing styles for now, or ensure classes are global
interface GameProgressBarProps {
value: number;
max: number;
type?: 'weight' | 'volume' | 'health' | 'enemy_health' | 'stamina' | 'xp' | 'durability'; // types map to colors
showText?: boolean;
label?: React.ReactNode;
unit?: string;
height?: string;
align?: 'left' | 'right';
labelAlignment?: 'left' | 'right';
}
export const GameProgressBar: React.FC<GameProgressBarProps> = ({
value,
max,
type = 'weight',
showText = false,
label,
unit = '',
height = '8px',
align = 'left',
labelAlignment
}) => {
const percentage = Math.min(100, Math.max(0, (value / (max || 1)) * 100));
// Map types to CSS classes used in InventoryModal.css or inline styles
const getFillClass = () => {
switch (type) {
case 'weight': return 'metric-fill weight';
case 'volume': return 'metric-fill volume';
case 'health': return 'durability-fill high'; // borrowing green
case 'enemy_health': return 'durability-fill low'; // borrowing red
case 'stamina': return 'durability-fill medium'; // borrowing yellow
case 'xp': return 'durability-fill medium'; // XP usually gold/yellow
case 'durability': return 'metric-fill'; // Use inline gradient
default: return 'metric-fill';
}
};
// Custom coloring for health/stamina if not using classes matching InventoryModal exactly
const getGradient = () => {
switch (type) {
// InventoryModal.css defines .weight and .volume gradients
// We can rely on classes if we import the CSS in parent or here
case 'health': return 'linear-gradient(90deg, #10b981, #059669)';
case 'enemy_health': return 'linear-gradient(90deg, #ef4444, #b91c1c)'; // Red
case 'stamina': return 'linear-gradient(90deg, #eab308, #ca8a04)';
case 'xp': return 'linear-gradient(90deg, #8b5cf6, #7c3aed)'; // Purple for XP?
case 'durability':
if (percentage < 15) return 'linear-gradient(90deg, #ef4444, #b91c1c)'; // Red
if (percentage < 50) return 'linear-gradient(90deg, #eab308, #ca8a04)'; // Yellow
return 'linear-gradient(90deg, #10b981, #059669)'; // Green
default: return undefined;
}
};
const displayValue = Number.isInteger(value) ? value : value.toFixed(1);
const displayMax = Number.isInteger(max) ? max : max.toFixed(1);
const effectiveLabelAlign = labelAlignment || align;
return (
<div className="game-progress-container" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: '2px' }}>
{showText && (
<div className="progress-text" style={{ fontSize: '0.8rem', color: '#cbd5e0', display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
{effectiveLabelAlign === 'left' ? (
<>
{label && <span style={{ fontWeight: 500 }}>{label}</span>}
<span style={{ fontFamily: 'monospace' }}>{displayValue}/{displayMax}{unit ? ` ${unit}` : ''}</span>
</>
) : (
<>
<span style={{ fontFamily: 'monospace' }}>{displayValue}/{displayMax}{unit ? ` ${unit}` : ''}</span>
{label && <span style={{ fontWeight: 500 }}>{label}</span>}
</>
)}
</div>
)}
<div className="progress-track" style={{
height,
backgroundColor: '#2d3748',
borderRadius: '4px',
overflow: 'hidden',
display: 'flex',
justifyContent: align === 'right' ? 'flex-end' : 'flex-start'
}}>
<div
className={getFillClass()}
style={{
width: `${percentage}%`,
height: '100%',
background: getGradient(),
transition: 'width 0.3s ease'
}}
/>
</div>
</div>
);
};

View File

@@ -2,6 +2,7 @@ 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 { CombatInventoryModal } from './CombatInventoryModal';
import { CombatState, CombatMessage, FloatingText, AnimationState, CombatActionResponse } from './CombatTypes';
import { useTranslation } from 'react-i18next';
@@ -143,6 +144,7 @@ export const Combat: React.FC<CombatProps> = ({
const [messageQueue, setMessageQueue] = useState<CombatMessage[]>([]);
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
const [combatResult, setCombatResult] = useState<'victory' | 'defeat' | 'fled' | null>(null);
const [showSuppliesModal, setShowSuppliesModal] = useState(false);
// --- Refs ---
const processingRef = useRef(false);
@@ -465,6 +467,19 @@ export const Combat: React.FC<CombatProps> = ({
}, 2000);
break;
case 'item_used':
if (data.hp_restore) {
setTimeout(() => addFloatingText(`+${data.hp_restore}`, 'heal', 'player'), 200);
}
if (data.stamina_restore) {
setTimeout(() => addFloatingText(`+${data.stamina_restore}`, 'stamina', 'player'), 400);
}
break;
case 'effect_applied':
addFloatingText(`${data.effect_icon || ''} ${data.effect_name}`, 'info', data.target === 'enemy' ? 'enemy' : 'player');
break;
case 'flee_success':
if (cleanupIntervalRef.current) clearInterval(cleanupIntervalRef.current);
setTimeout(() => {
@@ -655,17 +670,78 @@ export const Combat: React.FC<CombatProps> = ({
}, 50);
};
const handleUseItem = async (itemId: string) => {
// Close modal and use item in combat
setShowSuppliesModal(false);
if (isPvP) {
await handlePvPActionWrapper('use_item');
} else {
await handlePvEActionWithItem('use_item', itemId);
}
};
const handlePvEActionWithItem = async (action: string, itemId?: string) => {
if (isProcessingQueue) return;
try {
if (localCombatState.turn !== 'player') return;
// Build action payload
const actionPayload = itemId ? `${action}:${itemId}` : action;
const data: CombatActionResponse = await onCombatAction(actionPayload);
if (data && data.success && data.messages) {
setMessageQueue(data.messages);
if (data.combat) {
setLocalCombatState(prev => ({
...prev,
npcHp: data.combat.npc_hp,
npcMaxHp: data.combat.npc_max_hp,
turn: data.combat.turn,
round: data.combat.round,
npcName: resolveName(data.combat.npc_name) || prev.npcName
}));
} else if (data.combat_over && data.player_won) {
setLocalCombatState(prev => ({
...prev,
npcHp: 0
}));
}
if (data.player) {
pendingPlayerHpRef.current = { hp: data.player.hp, max_hp: data.player.max_hp };
pendingPlayerXpRef.current = { xp: data.player.xp, level: data.player.level };
refreshCharacters();
}
}
} catch (err) {
console.error(err);
}
};
return (
<CombatView
state={localCombatState}
animState={animState}
floatingTexts={floatingTexts}
onAction={isPvP ? handlePvPActionWrapper : handlePvEAction}
onClose={handleCloseWrapper}
isProcessing={isProcessingQueue}
combatResult={combatResult}
equipment={_equipment}
/>
<>
<CombatView
state={localCombatState}
animState={animState}
floatingTexts={floatingTexts}
onAction={isPvP ? handlePvPActionWrapper : handlePvEAction}
onClose={handleCloseWrapper}
onShowSupplies={() => setShowSuppliesModal(true)}
isProcessing={isProcessingQueue}
combatResult={combatResult}
equipment={_equipment}
playerName={profile?.name}
/>
{/* Supplies modal */}
<CombatInventoryModal
isOpen={showSuppliesModal}
onClose={() => setShowSuppliesModal(false)}
onUseItem={handleUseItem}
inventory={playerState?.inventory || []}
/>
</>
);
};

View File

@@ -266,7 +266,11 @@
}
.type-info {
color: #ffff44;
color: #44aaff;
}
.type-stamina {
color: #ffd700;
}
@keyframes float-up {

View File

@@ -0,0 +1,369 @@
/* Shared Backdrop (Refined) */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.85);
display: flex;
justify-content: center;
align-items: center;
z-index: 2000;
backdrop-filter: blur(4px);
}
/* Combat Modal Container - Matches Inventory Redesign */
.combat-inventory-modal {
width: 90%;
max-width: 600px;
/* Slightly wider for better card display */
max-height: 80vh;
display: flex;
flex-direction: column;
background: linear-gradient(135deg, #1e2a38 0%, #121820 100%);
border: 1px solid #3a4b5c;
border-radius: 12px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.8);
overflow: hidden;
color: #e0e6ed;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
z-index: 2001;
}
/* Header */
.combat-inventory-modal .modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
background: rgba(0, 0, 0, 0.3);
border-bottom: 1px solid #3a4b5c;
}
.combat-inventory-modal .modal-header h3 {
margin: 0;
color: #ff6b6b;
/* Reddish for combat focus */
font-size: 1.25rem;
letter-spacing: 0.5px;
}
.combat-inventory-modal .close-btn {
background: none;
border: none;
color: #a0aec0;
font-size: 1.5rem;
cursor: pointer;
transition: color 0.2s;
}
.combat-inventory-modal .close-btn:hover {
color: #fff;
}
.modal-body {
padding: 1.5rem;
display: flex;
flex-direction: column;
overflow: hidden;
/* Flex container for scrollable list */
flex: 1;
}
/* Search Input */
.search-input {
width: 100%;
padding: 0.75rem 1rem;
margin-bottom: 1.5rem;
background: rgba(0, 0, 0, 0.2);
border: 1px solid #3a4b5c;
border-radius: 8px;
color: #fff;
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
.search-input:focus {
border-color: #ff6b6b;
}
/* Items List */
.items-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding-right: 0.5rem;
}
.no-items {
text-align: center;
color: #718096;
padding: 2rem;
font-style: italic;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
font-size: 1.1rem;
}
/* Item Card - Matching Inventory Compact Style */
.combat-item-card {
display: flex;
flex-direction: row;
background-color: rgba(26, 32, 44, 0.8);
border: 1px solid #2d3748;
border-radius: 0.5rem;
padding: 0.75rem;
gap: 1rem;
align-items: stretch;
transition: all 0.2s ease;
cursor: pointer;
}
.combat-item-card:hover {
border-color: #ff6b6b;
background: rgba(255, 255, 255, 0.06);
transform: translateY(-1px);
}
/* Image Section */
.item-image-section {
width: 80px;
height: 80px;
flex-shrink: 0;
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
border-radius: 6px;
border: 1px solid #4a5568;
}
.item-img-thumb {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.item-icon-large {
font-size: 2rem;
display: flex;
align-items: center;
justify-content: center;
}
.item-icon-large.hidden {
display: none;
}
.item-quantity-badge {
position: absolute;
bottom: -5px;
right: -5px;
background: #2d3748;
border: 1px solid #4a5568;
color: #fff;
font-size: 0.75rem;
padding: 2px 6px;
border-radius: 10px;
font-weight: bold;
}
/* Info Section */
.item-details {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.25rem;
min-width: 0;
}
.item-name {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-description {
font-size: 0.85rem;
color: #a0aec0;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 0.25rem;
}
/* Stat Badges */
.item-effects {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.stat-badge {
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid;
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
background-color: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
color: #e2e8f0;
}
/* Badge Colors */
.stat-badge.healing,
.stat-badge.health {
background-color: rgba(16, 185, 129, 0.2);
color: #6ee7b7;
border-color: rgba(16, 185, 129, 0.4);
}
.stat-badge.stamina,
.stat-badge.crit {
background-color: rgba(234, 179, 8, 0.2);
color: #fde047;
border-color: rgba(234, 179, 8, 0.4);
}
.stat-badge.damage,
.stat-badge.penetration {
background-color: rgba(239, 68, 68, 0.2);
color: #fca5a5;
border-color: rgba(239, 68, 68, 0.4);
}
.stat-badge.armor {
background-color: rgba(59, 130, 246, 0.2);
color: #93c5fd;
border-color: rgba(59, 130, 246, 0.4);
}
.stat-badge.accuracy {
background-color: rgba(20, 184, 166, 0.2);
color: #5eead4;
border-color: rgba(20, 184, 166, 0.4);
}
.stat-badge.dodge {
background-color: rgba(99, 102, 241, 0.2);
color: #a5b4fc;
border-color: rgba(99, 102, 241, 0.4);
}
.stat-badge.lifesteal {
background-color: rgba(236, 72, 153, 0.2);
color: #f9a8d4;
border-color: rgba(236, 72, 153, 0.4);
}
.stat-badge.strength {
background-color: rgba(249, 115, 22, 0.2);
color: #fdba74;
border-color: rgba(249, 115, 22, 0.4);
}
.stat-badge.agility {
background-color: rgba(6, 182, 212, 0.2);
color: #67e8f9;
border-color: rgba(6, 182, 212, 0.4);
}
.stat-badge.endurance {
background-color: rgba(16, 185, 129, 0.2);
color: #6ee7b7;
border-color: rgba(16, 185, 129, 0.4);
}
.stat-badge.capacity {
background-color: rgba(16, 185, 129, 0.2);
color: #6ee7b7;
border-color: rgba(16, 185, 129, 0.4);
}
/* Use Button (Embedded in card) */
.btn-use {
background: rgba(72, 187, 120, 0.2);
color: #48bb78;
border: 1px solid rgba(72, 187, 120, 0.4);
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
margin-left: 0.5rem;
height: fit-content;
align-self: center;
transition: all 0.2s;
}
.btn-use:hover {
background: rgba(72, 187, 120, 0.3);
transform: translateY(-1px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
}
/* Tier Colors for Names/Icons */
.text-tier-0 {
color: #a0aec0;
}
.text-tier-1 {
color: #ffffff;
}
.text-tier-2 {
color: #68d391;
}
.text-tier-3 {
color: #63b3ed;
}
.text-tier-4 {
color: #9f7aea;
}
.text-tier-5 {
color: #ed8936;
}
.item-icon-large.tier-0 {
text-shadow: 0 0 10px rgba(160, 174, 192, 0.3);
}
.item-icon-large.tier-1 {
text-shadow: 0 0 10px rgba(255, 255, 255, 0.3);
}
.item-icon-large.tier-2 {
text-shadow: 0 0 10px rgba(104, 211, 145, 0.3);
}
.item-icon-large.tier-3 {
text-shadow: 0 0 10px rgba(99, 179, 237, 0.3);
}
.item-icon-large.tier-4 {
text-shadow: 0 0 10px rgba(159, 122, 234, 0.3);
}
.item-icon-large.tier-5 {
text-shadow: 0 0 10px rgba(237, 137, 54, 0.3);
}

View File

@@ -0,0 +1,212 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { getAssetPath } from '../../utils/assetPath';
import { getTranslatedText } from '../../utils/i18nUtils';
import './CombatInventoryModal.css';
import { EffectBadge } from './EffectBadge';
interface CombatInventoryModalProps {
isOpen: boolean;
onClose: () => void;
onUseItem: (itemId: string) => void;
inventory: any[];
}
export const CombatInventoryModal: React.FC<CombatInventoryModalProps> = ({
isOpen,
onClose,
onUseItem,
inventory
}) => {
const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState('');
const combatItems = useMemo(() => {
if (!inventory) return [];
return inventory.filter(item => {
// Check if item is usable in combat
// If explicit combat_usable flag is present, respect it.
// If not, fallback to 'consumable' type check, but ideally we want explicit flags.
// Some items might be consumable but not combat usable (e.g. quest items, or long-cast items)
// For now, checks: combat_usable OR (consumable AND has effects)
const isCombatUsable = item.combat_usable === true;
const isConsumable = item.type === 'consumable' || item.category === 'consumable' || item.consumable === true;
// Allow if strictly combat_usable, or if consumable and not explicitly restricted
const allowed = isCombatUsable || (isConsumable && item.combat_only !== false);
const itemName = getTranslatedText(item.name).toLowerCase();
const matchesSearch = itemName.includes(searchTerm.toLowerCase());
return allowed && matchesSearch && item.quantity > 0;
});
}, [inventory, searchTerm]);
if (!isOpen) return null;
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="combat-inventory-modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3>{t('combat.modal.supplies_title')}</h3>
<button className="close-btn" onClick={onClose}>&times;</button>
</div>
<div className="modal-body">
<input
type="text"
className="search-input"
placeholder={t('combat.modal.search_items')}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
autoFocus
/>
<div className="items-list">
{combatItems.length === 0 ? (
<div className="no-items">
<span style={{ fontSize: '3rem', opacity: 0.5 }}>📦</span>
{t('combat.modal.no_combat_items')}
</div>
) : (
combatItems.map((item, index) => (
<div key={`${item.item_id}-${index}`} className="combat-item-card" onClick={() => onUseItem(item.item_id)}>
{/* Image Section */}
<div className="item-image-section">
{item.image_path ? (
<img
src={getAssetPath(item.image_path)}
alt={getTranslatedText(item.name)}
className="item-img-thumb"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const icon = (e.target as HTMLImageElement).nextElementSibling;
if (icon) icon.classList.remove('hidden');
}}
/>
) : null}
<div className={`item-icon-large ${item.tier ? `tier-${item.tier}` : 'tier-0'} ${item.image_path ? 'hidden' : ''}`}>
{item.emoji || '📦'}
</div>
{item.quantity > 1 && <div className="item-quantity-badge">x{item.quantity}</div>}
</div>
{/* Info Section */}
<div className="item-details">
<h4 className={`item-name text-tier-${item.tier || 0}`}>
{getTranslatedText(item.name)}
</h4>
{item.description && (
<p className="item-description">{getTranslatedText(item.description)}</p>
)}
<div className="item-effects">
{/* Logic adapted from InventoryModal to show all relevant stats */}
{/* Consumables (Priority for combat) */}
{(item.effects?.hp_restore || item.hp_restore) && (
<span className="stat-badge healing">
+{item.effects?.hp_restore || item.hp_restore} HP
</span>
)}
{(item.effects?.stamina_restore || item.stamina_restore) && (
<span className="stat-badge stamina">
+{item.effects?.stamina_restore || item.stamina_restore} Stm
</span>
)}
{/* Status Effects & Cures */}
{item.effects?.status_effect && (
<EffectBadge effect={item.effects.status_effect} />
)}
{item.effects?.cures && item.effects.cures.length > 0 && (
<span className="stat-badge cure">
💊 {t('game.cures')}: {item.effects.cures.map((c: string) => getTranslatedText(c)).join(', ')}
</span>
)}
{/* Combat Effects (Throwables, etc) */}
{item.combat_effects?.damage_min && (
<span className="stat-badge damage">
💥 {item.combat_effects.damage_min}-{item.combat_effects.damage_max} Dmg
</span>
)}
{item.combat_effects?.status && (
<span className="stat-badge damage">
{t(`effects.${item.combat_effects.status.name}`, item.combat_effects.status.name) as string}
</span>
)}
{/* Stats & Unique Stats (If applicable) */}
{(item.unique_stats?.damage_min || item.stats?.damage_min) && (
<span className="stat-badge damage">
{item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max}
</span>
)}
{(item.unique_stats?.armor || item.stats?.armor) && (
<span className="stat-badge armor">
🛡 +{item.unique_stats?.armor || item.stats?.armor}
</span>
)}
{(item.unique_stats?.armor_penetration || item.stats?.armor_penetration) && (
<span className="stat-badge penetration">
💔 +{item.unique_stats?.armor_penetration || item.stats?.armor_penetration} {t('stats.pen') as string}
</span>
)}
{(item.unique_stats?.crit_chance || item.stats?.crit_chance) && (
<span className="stat-badge crit">
🎯 +{Math.round((item.unique_stats?.crit_chance || item.stats?.crit_chance) * 100)}% {t('stats.crit') as string}
</span>
)}
{(item.unique_stats?.accuracy || item.stats?.accuracy) && (
<span className="stat-badge accuracy">
👁 +{Math.round((item.unique_stats?.accuracy || item.stats?.accuracy) * 100)}% {t('stats.acc') as string}
</span>
)}
{(item.unique_stats?.dodge_chance || item.stats?.dodge_chance) && (
<span className="stat-badge dodge">
💨 +{Math.round((item.unique_stats?.dodge_chance || item.stats?.dodge_chance) * 100)}% Dodge
</span>
)}
{(item.unique_stats?.lifesteal || item.stats?.lifesteal) && (
<span className="stat-badge lifesteal">
🧛 +{Math.round((item.unique_stats?.lifesteal || item.stats?.lifesteal) * 100)}% {t('stats.life') as string}
</span>
)}
{/* Attributes */}
{(item.unique_stats?.strength_bonus || item.stats?.strength_bonus) && (
<span className="stat-badge strength">
💪 +{item.unique_stats?.strength_bonus || item.stats?.strength_bonus} {t('stats.str') as string}
</span>
)}
{(item.unique_stats?.agility_bonus || item.stats?.agility_bonus) && (
<span className="stat-badge agility">
🏃 +{item.unique_stats?.agility_bonus || item.stats?.agility_bonus} {t('stats.agi') as string}
</span>
)}
{(item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus) && (
<span className="stat-badge endurance">
🏋 +{item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus} {t('stats.end') as string}
</span>
)}
</div>
</div>
{/* Action Button */}
<button className="btn-use">
{t('game.use')}
</button>
</div>
))
)}
</div>
</div>
</div>
</div>
);
};

View File

@@ -8,7 +8,7 @@ export interface CombatMessage {
export interface FloatingText {
id: string;
text: string;
type: 'damage' | 'heal' | 'info' | 'crit' | 'miss' | 'xp';
type: 'damage' | 'heal' | 'info' | 'crit' | 'miss' | 'xp' | 'stamina';
x: number; // Percentage 0-100
y: number; // Percentage 0-100
origin: 'player' | 'enemy';

View File

@@ -4,16 +4,19 @@ import { useAudio } from '../../contexts/AudioContext';
import { CombatState, AnimationState, FloatingText } from './CombatTypes';
import { Equipment } from './types';
import './CombatEffects.css';
import { GameProgressBar } from '../common/GameProgressBar';
interface CombatViewProps {
state: CombatState;
animState: AnimationState;
floatingTexts: FloatingText[];
onAction: (action: string) => void;
onAction: (action: string, itemId?: string) => void;
onClose: () => void;
onShowSupplies: () => void;
isProcessing: boolean;
combatResult: 'victory' | 'defeat' | 'fled' | null;
equipment?: Equipment | any;
playerName?: string;
}
export const CombatView: React.FC<CombatViewProps> = ({
@@ -22,9 +25,11 @@ export const CombatView: React.FC<CombatViewProps> = ({
floatingTexts,
onAction,
onClose,
onShowSupplies,
isProcessing,
combatResult,
equipment
equipment,
playerName
}) => {
const { t } = useTranslation();
const { playSfx } = useAudio();
@@ -109,10 +114,6 @@ export const CombatView: React.FC<CombatViewProps> = ({
}
}, [state.messages]);
const getHealthPercent = (current: number, max: number) => {
return Math.max(0, Math.min(100, (current / max) * 100));
};
return (
<div className="combat-container">
@@ -158,7 +159,6 @@ export const CombatView: React.FC<CombatViewProps> = ({
<div className="combat-stats-container">
{/* 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 => (
@@ -167,13 +167,15 @@ export const CombatView: React.FC<CombatViewProps> = ({
</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>
<GameProgressBar
label={state.npcName || t('common.enemy')}
value={state.npcHp}
max={state.npcMaxHp}
type="enemy_health"
showText={true}
height="10px"
labelAlignment="right"
/>
</div>
{/* Player HP (Right) */}
@@ -185,14 +187,16 @@ export const CombatView: React.FC<CombatViewProps> = ({
</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>
<GameProgressBar
label={playerName || t('common.you')}
value={state.playerHp}
max={state.playerMaxHp}
type="health"
showText={true}
height="10px"
align="right"
labelAlignment="left"
/>
</div>
</div>
@@ -206,7 +210,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
{t('common.close')}
</button>
<div className="combat-actions-group" style={{ display: !combatResult ? 'flex' : 'none', gap: '1rem', width: '100%', justifyContent: 'center' }}>
<div className="combat-actions-group" style={{ display: !combatResult ? 'grid' : 'none', gridTemplateColumns: '1fr 1fr', gap: '0.75rem', width: '100%', maxWidth: '400px', margin: '0 auto' }}>
<button
className="btn btn-attack"
onClick={() => onAction('attack')}
@@ -215,6 +219,22 @@ export const CombatView: React.FC<CombatViewProps> = ({
👊 {t('combat.actions.attack')}
</button>
<button
className="btn btn-defend"
onClick={() => onAction('defend')}
disabled={isProcessing || !state.yourTurn}
>
🛡 {t('combat.actions.defend')}
</button>
<button
className="btn btn-supplies"
onClick={onShowSupplies}
disabled={isProcessing || !state.yourTurn}
>
🎒 {t('combat.actions.supplies')}
</button>
<button
className="btn btn-flee"
onClick={() => onAction('flee')}
@@ -238,6 +258,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
} else {
switch (msg.type) {
case 'combat_start': text = t('combat.start'); break;
case 'combat_timeout': text = t('combat.turn_timeout'); className += " text-warning"; break;
case 'player_attack':
if (msg.origin === 'enemy') {
text = t('combat.log.enemy_attack', { damage: msg.data?.damage || 0 });
@@ -267,6 +288,18 @@ export const CombatView: React.FC<CombatViewProps> = ({
}
break;
case 'text': text = msg.data?.text || ""; break;
case 'item_used':
text = t('combat.log.item_used', { item: msg.data?.item_name || '' });
if (msg.data?.effects) text += msg.data.effects; // Append effects string if backend still sends it
className += " text-info";
break;
case 'effect_applied':
text = t('combat.log.effect_applied', {
effect: msg.data?.effect_name,
target: msg.data?.target === 'enemy' ? t('common.enemy') : t('common.you')
});
className += " text-warning";
break;
default: text = msg.type;
}
}
@@ -286,12 +319,13 @@ export const CombatView: React.FC<CombatViewProps> = ({
</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>
{
isProcessing && !combatResult && state.turn === 'enemy' && (
<div className="turn-overlay">
{t('combat.enemy_turn')}
</div>
)
}
</div >
);
};

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { getTranslatedText } from '../../utils/i18nUtils';
interface EffectBadgeProps {
effect: {
name: string | any;
icon?: string;
type?: 'buff' | 'debuff' | 'damage';
damage_per_tick?: number;
ticks?: number;
};
}
export const EffectBadge: React.FC<EffectBadgeProps> = ({ effect }) => {
const { t } = useTranslation();
// Determine class based on type or fallback to damage logic
const badgeClass = effect.type === 'buff' ? 'buff' : 'damage';
// For translation of effect name
const effectName = typeof effect.name === 'string'
? t(`game.effects.${effect.name}`, effect.name)
: getTranslatedText(effect.name);
return (
<span className={`stat-badge ${badgeClass}`}>
{effect.icon}
{effect.damage_per_tick ? (
<>
{effect.damage_per_tick < 0 ?
`+${Math.abs(effect.damage_per_tick)}` :
`-${effect.damage_per_tick}`} HP
{effect.ticks && ` (${effect.ticks})`}
</>
) : (
effectName
)}
</span>
);
};

View File

@@ -356,6 +356,7 @@
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@@ -513,12 +514,19 @@
/* Variant Colors */
.stat-badge.capacity,
.stat-badge.endurance,
.stat-badge.health {
.stat-badge.health,
.stat-badge.buff {
background-color: rgba(16, 185, 129, 0.2);
color: #6ee7b7;
border-color: rgba(16, 185, 129, 0.4);
}
.stat-badge.cure {
background-color: rgba(45, 212, 191, 0.2);
color: #5eead4;
border-color: rgba(45, 212, 191, 0.4);
}
.stat-badge.damage,
.stat-badge.penetration {
background-color: rgba(239, 68, 68, 0.2);
@@ -662,6 +670,18 @@
white-space: nowrap;
}
.action-btn:disabled,
.action-btn.disabled {
opacity: 0.5;
cursor: not-allowed;
filter: grayscale(100%);
pointer-events: none;
background: rgba(144, 144, 144, 0.2) !important;
color: #a0aec0 !important;
border-color: rgba(160, 174, 192, 0.4) !important;
transform: none !important;
}
.action-btn.use {
background: rgba(72, 187, 120, 0.2);
color: #48bb78;

View File

@@ -1,10 +1,11 @@
import { MouseEvent, ChangeEvent } from 'react'
import { MouseEvent, ChangeEvent, useEffect } 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'
import './InventoryModal.css'
import { EffectBadge } from './EffectBadge'
interface InventoryModalProps {
playerState: PlayerState
@@ -37,7 +38,22 @@ function InventoryModal({
}: InventoryModalProps) {
const { t } = useTranslation()
const { playSfx } = useAudio()
// Categories for the sidebar
// Play sound on mount
useEffect(() => {
playSfx('/audio/sfx/inventory_open.wav')
// Return cleanup/close sound? usage of "onClose" typically handles it.
// We can't easily do it on unmount if the parent unmounts it instantly.
// But for "close" button click we can play it.
}, [])
const handleClose = () => {
playSfx('/audio/sfx/inventory_close.wav')
onClose()
}
// ... existing categories ...
const categories = [
{ id: 'all', label: t('categories.all'), icon: '🎒' },
{ id: 'weapon', label: t('categories.weapon'), icon: '⚔️' },
@@ -213,6 +229,18 @@ function InventoryModal({
+{item.stamina_restore} Stm
</span>
)}
{/* Status Effects */}
{item.effects?.status_effect && (
<EffectBadge effect={item.effects.status_effect} />
)}
{item.effects?.cures && item.effects.cures.length > 0 && (
<span className="stat-badge cure">
💊 {t('game.cures')}: {item.effects.cures.map((c: string) => t(`game.effects.${c}`, c)).join(', ')}
</span>
)}
</div>
{/* Durability Bar */}
@@ -248,10 +276,30 @@ function InventoryModal({
{/* Right: Actions */}
<div className="item-actions-section">
{item.consumable && (
<button className="action-btn use" onClick={() => {
playSfx('/audio/sfx/use.wav')
onUseItem(item.item_id, item.id)
}}>{t('game.use')}</button>
(() => {
const statusEffect = item.effects?.status_effect;
const isEffectActive = statusEffect && playerState.status_effects.some((e: any) => {
const effectName = typeof e.effect_name === 'string' ? e.effect_name : e.effect_name['en'];
const itemName = typeof statusEffect.name === 'string' ? statusEffect.name : statusEffect.name['en'];
return effectName === itemName;
});
return (
<button
className={`action-btn use ${isEffectActive ? 'disabled' : ''}`}
disabled={isEffectActive}
title={isEffectActive ? t('game.effectAlreadyActive') : ''}
onClick={() => {
if (!isEffectActive) {
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={() => {
@@ -271,7 +319,7 @@ function InventoryModal({
playSfx('/audio/sfx/drop.wav')
onDropItem(item.item_id, item.id, 1)
}}>
{item.quantity === 1 ? t('game.drop') : 'x1' }
{item.quantity === 1 ? t('game.drop') : 'x1'}
</button>
{item.quantity >= 5 && (
<button className="action-btn drop" onClick={() => {
@@ -301,7 +349,7 @@ function InventoryModal({
return (
<div className="workbench-overlay" onClick={(e: MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) onClose()
if (e.target === e.currentTarget) handleClose()
}}>
<div className="workbench-menu inventory-modal-redesign">
{/* Top Bar: Capacity & Backpack Info */}
@@ -354,12 +402,13 @@ function InventoryModal({
<span>{t('game.noBackpack')}</span>
</div>
)}
<button className="close-btn" onClick={onClose}></button>
<button className="close-btn" onClick={handleClose}></button>
</div>
</div>
<div className="inventory-main-layout">
{/* Left Sidebar: Categories */}
<div className="inventory-sidebar-filters">
{categories.map(cat => (
<button

View File

@@ -4,6 +4,7 @@ import type { PlayerState, Profile, Equipment } from './types'
import { getAssetPath } from '../../utils/assetPath'
import { getTranslatedText } from '../../utils/i18nUtils'
import InventoryModal from './InventoryModal'
import { GameProgressBar } from '../common/GameProgressBar'
interface PlayerSidebarProps {
playerState: PlayerState
@@ -37,13 +38,10 @@ function PlayerSidebar({
onSpendPoint
}: PlayerSidebarProps) {
const [showInventory, setShowInventory] = useState(false)
const { t } = useTranslation()
const renderEquipmentSlot = (slot: string, item: any, emoji: string, label: string) => (
<div className={`equipment-slot ${item ? 'filled' : 'empty'}`}>
const renderEquipmentSlot = (slot: string, item: any, label: string) => (
<div className={`equipment-slot ${item ? 'filled' : 'empty'}`} title={!item ? label : ''}>
{item ? (
<>
<button className="equipment-unequip-btn" onClick={() => onUnequipItem(slot)} title={t('game.unequip')}></button>
@@ -53,22 +51,31 @@ function PlayerSidebar({
src={getAssetPath(item.image_path)}
alt={getTranslatedText(item.name)}
className="equipment-emoji"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const icon = (e.target as HTMLImageElement).nextElementSibling;
if (icon) icon.classList.remove('hidden');
}}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
) : null}
<span className={`equipment-emoji ${item.image_path ? 'hidden' : ''}`}>{item.emoji}</span>
<span className={`equipment-name ${item.tier ? `tier-${item.tier}` : ''}`}>{getTranslatedText(item.name)}</span>
{item.durability && item.durability !== null && (
<span className="equipment-durability">{item.durability}/{item.max_durability}</span>
) : (
<span className="equipment-emoji" style={{ fontSize: '2rem' }}>{item.emoji}</span>
)}
{item.durability !== undefined && item.durability !== null && (
<div className="equipment-durability-bar-container" style={{ width: '90%', marginTop: 'auto', marginBottom: '4px' }}>
<GameProgressBar
value={item.durability}
max={item.max_durability}
type="durability"
height="4px"
showText={false}
/>
</div>
)}
</div>
<div className="equipment-tooltip">
<div className="item-tooltip-name">{getTranslatedText(item.name)}</div>
{item.tier !== undefined && item.tier !== null && item.tier > 0 && (
<div className="item-tooltip-stat" style={{ color: '#fbbf24' }}>
Tier: {item.tier}
</div>
)}
{item.description && <div className="item-tooltip-desc">{getTranslatedText(item.description)}</div>}
{/* Use unique_stats if available, otherwise fall back to base stats */}
{(item.unique_stats || item.stats) && Object.keys(item.unique_stats || item.stats).length > 0 && (
<>
{(item.unique_stats?.armor || item.stats?.armor) && (
@@ -106,84 +113,97 @@ function PlayerSidebar({
)}
{item.durability !== undefined && item.durability !== null && (
<div className="item-tooltip-stat">
{t('stats.durability')}: {item.durability}/{item.max_durability}
</div>
)}
{item.tier !== undefined && item.tier !== null && item.tier > 0 && (
<div className="item-tooltip-stat">
Tier: {item.tier}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
<span>{t('stats.durability')}:</span>
<span>{item.durability}/{item.max_durability}</span>
</div>
<GameProgressBar
value={item.durability}
max={item.max_durability}
type="durability"
height="6px"
showText={false}
/>
</div>
)}
</div>
</>
) : (
<>
<span className="equipment-emoji">{emoji}</span>
<span className="equipment-slot-label">{label}</span>
<img
src={getAssetPath(`/images/placeholder/${slot}_placeholder.webp`)}
alt={label}
className="equipment-placeholder-img"
style={{ width: '100%', height: '100%', objectFit: 'contain', opacity: 0.5 }}
/>
</>
)}
</div>
)
return (
<div className={`right-sidebar mobile-menu-panel ${mobileMenuOpen === 'right' ? 'open' : ''}`}>
{/* Profile Stats */}
<div className="profile-sidebar">
<h3>{t('game.character')}</h3>
<h3 className="sidebar-title">
{profile?.name || 'Character'} <span className="title-level">(Lv. {profile?.level || 1})</span>
</h3>
<div className="sidebar-stat-bars">
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label">{t('stats.hp')}</span>
<span className="sidebar-stat-numbers">{playerState.health}/{playerState.max_health}</span>
</div>
<div className="sidebar-progress-bar">
<div
className="sidebar-progress-fill health"
style={{ width: `${(playerState.health / playerState.max_health) * 100}%` }}
></div>
<span className="progress-percentage">{Math.round((playerState.health / playerState.max_health) * 100)}%</span>
</div>
<GameProgressBar
value={playerState.health}
max={playerState.max_health}
type="health"
showText={true}
height="10px"
label={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{t('stats.hp')}
<div className="status-indicators" style={{ display: 'flex', gap: '5px' }}>
{playerState.status_effects?.filter((e: any) => e.damage_per_tick !== 0).map((e: any) => (
<span key={e.id} className={`stat-indicator ${e.damage_per_tick > 0 ? 'negative' : 'positive'}`} style={{
color: e.damage_per_tick > 0 ? '#ff6b6b' : '#4caf50',
fontSize: '0.85rem',
fontWeight: 'bold'
}}>
{e.damage_per_tick > 0 ? `-${e.damage_per_tick}` : `+${Math.abs(e.damage_per_tick)}`}/t ({e.ticks_remaining})
</span>
))}
</div>
</div>
}
/>
</div>
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label">{t('stats.stamina')}</span>
<span className="sidebar-stat-numbers">{playerState.stamina}/{playerState.max_stamina}</span>
</div>
<div className="sidebar-progress-bar">
<div
className="sidebar-progress-fill stamina"
style={{ width: `${(playerState.stamina / playerState.max_stamina) * 100}%` }}
></div>
<span className="progress-percentage">{Math.round((playerState.stamina / playerState.max_stamina) * 100)}%</span>
<GameProgressBar
value={playerState.stamina}
max={playerState.max_stamina}
type="stamina"
showText={true}
height="10px"
label={t('stats.stamina')}
/>
</div>
<div className="sidebar-stat-bar">
<GameProgressBar
value={profile?.xp || 0}
max={(profile?.level || 1) * 100}
type="xp"
showText={true}
height="10px"
label={t('stats.xp')}
/>
<div className="xp-text-detail" style={{ fontSize: '0.7rem', color: '#718096', textAlign: 'right', marginTop: '2px' }}>
{Math.floor(((profile?.level || 1) * 100) - (profile?.xp || 0))} XP to next level
</div>
</div>
</div>
{profile && (
<div className="sidebar-stats">
<div className="sidebar-stat-row">
<span className="sidebar-label">{t('stats.level')}:</span>
<span className="sidebar-value">{profile.level}</span>
</div>
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label">{t('stats.xp')}</span>
<span className="sidebar-stat-numbers">{profile.xp} / {(profile.level * 100)}</span>
</div>
<div className="sidebar-progress-bar">
<div
className="sidebar-progress-fill xp"
style={{ width: `${(profile.xp / (profile.level * 100)) * 100}%` }}
></div>
<span className="progress-percentage">{Math.round((profile.xp / (profile.level * 100)) * 100)}%</span>
</div>
</div>
{profile.unspent_points > 0 && (
<div className="sidebar-stat-row highlight">
<span className="sidebar-label">{t('stats.unspentPoints')}:</span>
@@ -229,86 +249,78 @@ function PlayerSidebar({
{/* Inventory Capacity - matching HP/Stamina/XP style */}
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label">{t('stats.weight')}</span>
<span className="sidebar-stat-numbers">{(profile.current_weight || 0).toFixed(1)}/{profile.max_weight || 0}kg</span>
</div>
<div className="sidebar-progress-bar">
<div
className="sidebar-progress-fill weight"
style={{ width: `${Math.min(100, ((profile.current_weight || 0) / (profile.max_weight || 1)) * 100)}%` }}
></div>
<span className="progress-percentage">{Math.round(Math.min(100, ((profile.current_weight || 0) / (profile.max_weight || 1)) * 100))}%</span>
</div>
<GameProgressBar
value={profile.current_weight || 0}
max={profile.max_weight || 0}
type="weight"
showText={true}
label={t('stats.weight')}
unit="kg"
height="10px"
/>
</div>
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label">{t('stats.volume')}</span>
<span className="sidebar-stat-numbers">{(profile.current_volume || 0).toFixed(1)}/{profile.max_volume || 0}L</span>
</div>
<div className="sidebar-progress-bar">
<div
className="sidebar-progress-fill volume"
style={{ width: `${Math.min(100, ((profile.current_volume || 0) / (profile.max_volume || 1)) * 100)}%` }}
></div>
<span className="progress-percentage">{Math.round(Math.min(100, ((profile.current_volume || 0) / (profile.max_volume || 1)) * 100))}%</span>
</div>
<GameProgressBar
value={profile.current_volume || 0}
max={profile.max_volume || 0}
type="volume"
showText={true}
label={t('stats.volume')}
unit="L"
height="10px"
/>
</div>
<button
className="open-inventory-btn"
onClick={() => setShowInventory(true)}
style={{
width: '100%',
padding: '1rem',
marginTop: '1rem',
backgroundColor: '#2c3e50',
border: '1px solid #34495e',
borderRadius: '8px',
color: '#ecf0f1',
fontSize: '1.1rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
transition: 'all 0.2s'
}}
>
{t('game.inventory')}
</button>
</div>
)}
<button
className="open-inventory-btn"
onClick={() => setShowInventory(true)}
style={{
width: '100%',
padding: '0.5rem',
marginTop: '1rem',
backgroundColor: '#2c3e50',
border: '1px solid #34495e',
borderRadius: '8px',
color: '#ecf0f1',
fontSize: '1rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
transition: 'all 0.2s'
}}
>
{t('game.inventory')}
</button>
</div>
{/* Equipment Display - Proper Grid Layout */}
<div className="equipment-sidebar">
<h3>{t('game.equipment')}</h3>
<div className="equipment-grid">
{/* Row 1: Head */}
<div className="equipment-row">
{renderEquipmentSlot('head', equipment.head, '🪖', t('equipment.head'))}
{renderEquipmentSlot('head', equipment.head, t('equipment.head'))}
</div>
{/* Row 2: Weapon, Torso, Backpack */}
<div className="equipment-row three-cols">
{renderEquipmentSlot('weapon', equipment.weapon, '⚔️', t('equipment.weapon'))}
{renderEquipmentSlot('torso', equipment.torso, '👕', t('equipment.torso'))}
{renderEquipmentSlot('backpack', equipment.backpack, '🎒', t('equipment.backpack'))}
{renderEquipmentSlot('weapon', equipment.weapon, t('equipment.weapon'))}
{renderEquipmentSlot('torso', equipment.torso, t('equipment.torso'))}
{renderEquipmentSlot('backpack', equipment.backpack, t('equipment.backpack'))}
</div>
{/* Row 3: Legs & Feet */}
<div className="equipment-row two-cols">
{renderEquipmentSlot('legs', equipment.legs, '👖', t('equipment.legs'))}
{renderEquipmentSlot('feet', equipment.feet, '👟', t('equipment.feet'))}
{renderEquipmentSlot('legs', equipment.legs, t('equipment.legs'))}
{renderEquipmentSlot('feet', equipment.feet, t('equipment.feet'))}
</div>
</div>
</div>
{/* Inventory Modal */}
{showInventory && profile && (
<InventoryModal
playerState={playerState}

View File

@@ -305,7 +305,7 @@ function Workbench({
<div key={i} className={`requirement-item ${tool.has_tool ? 'met' : 'missing'}`}>
<span>{tool.emoji} {getTranslatedText(tool.name)}</span>
<span>
{tool.has_tool ? `${tool.tool_durability}/${tool.max_durability} (Cost: ${tool.durability_cost})` : `❌ Missing (Cost: ${tool.durability_cost})`}
{tool.has_tool ? `${tool.tool_durability}/${tool.tool_max_durability} (Cost: ${tool.durability_cost})` : `❌ Missing (Cost: ${tool.durability_cost})`}
</span>
</div>
))}

View File

@@ -136,6 +136,7 @@ export interface GameEngineActions {
removePlayerFromLocation: (playerId: number) => void
addNPCToLocation: (npc: any) => void
removeNPCFromLocation: (enemyId: string) => void
updateStatusEffect: (effectName: string | any, remainingTicks: number) => void
}
export function useGameEngine(
@@ -243,7 +244,7 @@ export function useGameEngine(
stamina: gameState.player.stamina,
max_stamina: gameState.player.max_stamina,
inventory: gameState.inventory || [],
status_effects: []
status_effects: gameState.player.status_effects || []
})
setEquipment(gameState.equipment || {})
@@ -275,7 +276,7 @@ export function useGameEngine(
stamina: gameState.player.stamina,
max_stamina: gameState.player.max_stamina,
inventory: gameState.inventory || [],
status_effects: []
status_effects: gameState.player.status_effects || []
})
setLocation(locationRes.data)
@@ -458,6 +459,42 @@ export function useGameEngine(
setLoadedTabs(new Set())
}
const updateStatusEffect = useCallback((effectName: string | any, remainingTicks: number) => {
setPlayerState((prev: PlayerState | null) => {
if (!prev) return null
if (!prev) return null
const target = typeof effectName === 'object'
? (effectName.en || Object.values(effectName)[0])
: effectName
if (remainingTicks <= 0) {
return {
...prev,
status_effects: prev.status_effects.filter(e => {
const current = typeof e.effect_name === 'object'
? (e.effect_name.en || Object.values(e.effect_name)[0])
: e.effect_name
return current !== target
})
}
}
return {
...prev,
status_effects: prev.status_effects.map(e => {
const current = typeof e.effect_name === 'object'
? (e.effect_name.en || Object.values(e.effect_name)[0])
: e.effect_name
if (current === target) {
return { ...e, ticks_remaining: remainingTicks }
}
return e
})
}
})
}, [])
// State object
const state: GameEngineState = {
playerState,
@@ -720,8 +757,13 @@ export function useGameEngine(
const handleCombatAction = async (action: string) => {
try {
// setEnemyTurnMessage('Processing...') // Handled by Combat.tsx now
const response = await api.post('/api/game/combat/action', { action })
let payload: any = { action }
if (action.includes(':')) {
const [act, itemId] = action.split(':')
payload = { action: act, item_id: itemId }
}
const response = await api.post('/api/game/combat/action', payload)
return response.data
} catch (error: any) {
setMessage(error.response?.data?.detail || 'Combat action failed')
@@ -754,11 +796,19 @@ export function useGameEngine(
const handlePvPAction = async (action: string, _targetId: number) => {
try {
const response = await api.post('/api/game/pvp/action', { action })
let payload: any = { action }
if (action.includes(':')) {
const [act, itemId] = action.split(':')
payload = { action: act, item_id: itemId }
}
const response = await api.post('/api/game/pvp/action', payload)
setMessage(response.data.message || 'Action performed!')
await fetchGameData()
return response.data // Return data so caller can use it
} catch (error: any) {
setMessage(error.response?.data?.detail || 'PvP action failed')
throw error // Re-throw so caller knows it failed
}
}
@@ -1086,7 +1136,8 @@ export function useGameEngine(
}
return newSet
})
}
},
updateStatusEffect
}
// Polling fallback for PvP Combat reliability

View File

@@ -8,7 +8,17 @@ export interface PlayerState {
stamina: number
max_stamina: number
inventory: any[]
status_effects: any[]
status_effects: StatusEffect[]
}
export interface StatusEffect {
id: number
effect_name: string | any
effect_icon: string
effect_type: string
damage_per_tick: number
value: number
ticks_remaining: number
}
export interface DirectionDetail {
@@ -53,6 +63,8 @@ export interface Profile {
current_weight?: number
max_volume?: number
current_volume?: number
status_effects?: StatusEffect[]
}
export interface CombatLogEntry {

View File

@@ -1,4 +1,4 @@
import React, { createContext, useContext, useState } from 'react';
import React, { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
import { isElectronApp } from '../utils/assetPath';
interface AudioContextType {
@@ -11,12 +11,17 @@ interface AudioContextType {
setSfxVolume: (val: number) => void;
setIsMuted: (val: boolean) => void;
playSfx: (path: string, fallbackPath?: string) => void;
audioContext: AudioContext | null;
getAudioBuffer: (path: string) => Promise<AudioBuffer | null>;
}
const AudioContext = createContext<AudioContextType | undefined>(undefined);
// Cache for decoded audio buffers to prevent re-fetching/re-decoding
const bufferCache: Record<string, AudioBuffer> = {};
export const AudioProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// Initialize state from localStorage or defaults
// Volume State
const [masterVolume, setMasterVolumeState] = useState(() => {
const saved = localStorage.getItem('audio_masterVolume');
return saved ? parseFloat(saved) : 1.0;
@@ -34,7 +39,62 @@ export const AudioProvider: React.FC<{ children: React.ReactNode }> = ({ childre
return saved ? JSON.parse(saved) : false;
});
// Persistence wrappers
// Web Audio API State
const [audioContext, setAudioContext] = useState<AudioContext | null>(null);
const audioContextRef = useRef<AudioContext | null>(null); // Ref for immediate access in loops/events
const sfxGainNodeRef = useRef<GainNode | null>(null);
// Initialize AudioContext on mount
useEffect(() => {
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
const ctx = new AudioContextClass();
audioContextRef.current = ctx;
setAudioContext(ctx);
// Create a dedicated GainNode for SFX
const sfxGain = ctx.createGain();
sfxGain.connect(ctx.destination);
sfxGainNodeRef.current = sfxGain;
return () => {
if (ctx.state !== 'closed') {
ctx.close();
}
};
}, []);
// Global "Unlock" Listener for Autoplay Policy
useEffect(() => {
const unlockAudio = () => {
const ctx = audioContextRef.current;
if (ctx && ctx.state === 'suspended') {
ctx.resume().then(() => {
console.log('AudioContext resumed via user interaction');
}).catch(e => console.error('Failed to resume AudioContext:', e));
}
// Once resumed, we can generally remove these listeners,
// but Chrome sometimes needs multiple checks if it suspends again.
// Usually one accepted interaction is enough for the session.
};
const events = ['click', 'touchstart', 'keydown', 'mousedown'];
events.forEach(e => document.addEventListener(e, unlockAudio, { passive: true }));
return () => {
events.forEach(e => document.removeEventListener(e, unlockAudio));
};
}, []);
// Update SFX Gain when volumes change
useEffect(() => {
if (sfxGainNodeRef.current && audioContextRef.current) {
const effectiveSfxVol = isMuted ? 0 : masterVolume * sfxVolume;
const currentTime = audioContextRef.current.currentTime;
sfxGainNodeRef.current.gain.setTargetAtTime(effectiveSfxVol, currentTime, 0.1);
}
}, [masterVolume, sfxVolume, isMuted]);
// Volume Setters with Persistence
const setMasterVolume = (val: number) => {
setMasterVolumeState(val);
localStorage.setItem('audio_masterVolume', val.toString());
@@ -55,39 +115,75 @@ export const AudioProvider: React.FC<{ children: React.ReactNode }> = ({ childre
localStorage.setItem('audio_isMuted', JSON.stringify(val));
};
const playSfx = (path: string, fallbackPath?: string) => {
if (isMuted) return;
// Helper: Resolve Path
const resolvePath = useCallback((p: string) => {
if (p.startsWith('http') || p.startsWith('file')) return p;
const cleanPath = p.startsWith('/') ? p.slice(1) : p;
return isElectronApp() ? `./${cleanPath}` : `/${cleanPath}`;
}, []);
// Calculate effective volume
const effectiveVolume = masterVolume * sfxVolume;
if (effectiveVolume <= 0) return;
// Helper: Fetch and Decode Audio
const getAudioBuffer = useCallback(async (path: string): Promise<AudioBuffer | null> => {
const ctx = audioContextRef.current;
if (!ctx) return null;
// 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 resolvedPath = resolvePath(path);
const primarySrc = resolvePath(path);
const audio = new Audio(primarySrc);
audio.volume = effectiveVolume;
// Check cache
if (bufferCache[resolvedPath]) {
return bufferCache[resolvedPath];
}
const playPromise = audio.play();
try {
const response = await fetch(resolvedPath);
if (!response.ok) throw new Error(`HTTP error ${response.status}`);
const arrayBuffer = await response.arrayBuffer();
const decodedBuffer = await ctx.decodeAudioData(arrayBuffer);
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));
// Store in cache
bufferCache[resolvedPath] = decodedBuffer;
return decodedBuffer;
} catch (error) {
console.error(`Failed to load audio: ${path}`, error);
return null;
}
}, [resolvePath]);
// Play SFX
const playSfx = useCallback(async (path: string, fallbackPath?: string) => {
// Early exit if essentially muted
if (isMuted || (masterVolume * sfxVolume) <= 0) return;
const ctx = audioContextRef.current;
const sfxGain = sfxGainNodeRef.current;
if (!ctx || !sfxGain) return;
// Ensure context is running
if (ctx.state === 'suspended') {
try {
await ctx.resume();
} catch (e) {
// If this fails (e.g. no user gesture yet), we can't play
console.warn('AudioContext suspended, cannot play SFX');
return;
}
});
};
}
let buffer = await getAudioBuffer(path);
if (!buffer && fallbackPath) {
console.warn(`Primary SFX failed: ${path}, trying fallback: ${fallbackPath}`);
buffer = await getAudioBuffer(fallbackPath);
}
if (buffer) {
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(sfxGain);
source.start(0);
}
}, [isMuted, masterVolume, sfxVolume, getAudioBuffer]);
return (
<AudioContext.Provider value={{
@@ -99,7 +195,9 @@ export const AudioProvider: React.FC<{ children: React.ReactNode }> = ({ childre
setMusicVolume,
setSfxVolume,
setIsMuted,
playSfx
playSfx,
audioContext,
getAudioBuffer
}}>
{children}
</AudioContext.Provider>

View File

@@ -82,7 +82,15 @@
"durability": "Durability",
"noItemsFound": "No items found in this category",
"levelDifferenceTooHigh": "Level difference too high",
"areaTooSafeForPvP": "Area too safe for PvP"
"areaTooSafeForPvP": "Area too safe for PvP",
"cures": "Cures",
"effects": {
"regeneration": "Regeneration",
"bleeding": "Bleeding",
"burning": "Burning",
"poisoned": "Poisoned"
},
"effectAlreadyActive": "Effect already active"
},
"location": {
"recentActivity": "📜 Recent Activity",
@@ -179,12 +187,16 @@
"turnTimer": "Turn Timer",
"actions": {
"attack": "Attack",
"defend": "Defend",
"flee": "Flee",
"supplies": "Supplies",
"useItem": "Use Item"
},
"status": {
"attacking": "Attacking...",
"defending": "Bracing for impact...",
"fleeing": "Fleeing...",
"usingItem": "Using item...",
"waiting": "Waiting for opponent..."
},
"events": {
@@ -193,7 +205,11 @@
"playerMiss": "You missed!",
"enemyMiss": "Enemy missed!",
"armorAbsorbed": "Armor absorbed {{armor}} damage",
"itemBroke": "{{item}} broke!"
"itemBroke": "{{item}} broke!",
"defendSuccess": "You brace yourself, reducing incoming damage!",
"damageReduced": "Defending! Damage reduced by {{reduction}}%",
"itemUsed": "Used {{item}}{{effects}}",
"itemDamage": "{{item}} deals {{damage}} damage!"
},
"log": {
"combat_start": "Combat started!",
@@ -207,7 +223,17 @@
"enemy_miss": "Enemy missed!",
"item_broken": "Your {{item}} broke!",
"xp_gain": "You gained {{xp}} XP!",
"flee_success": "You managed to escape!"
"flee_success": "You managed to escape!",
"defend": "You brace for impact!",
"item_used": "Used {{item}}",
"effect_applied": "Applied {{effect}} to {{target}}",
"item_damage": "{{item}} deals {{damage}} damage!",
"damage_reduced": "Damage reduced by {{reduction}}%"
},
"modal": {
"supplies_title": "Combat Supplies",
"no_combat_items": "No combat items available",
"search_items": "Search items..."
}
},
"equipment": {
@@ -271,6 +297,13 @@
"enemyDespawned": "A wandering enemy has left the area",
"corpsesDecayed": "{{count}} corpses have decayed",
"itemsDecayed": "{{count}} dropped items have decayed",
"statusDamage": "You took {{damage}} damage from status effects",
"statusHeal": "You recovered {{heal}} HP from status effects",
"diedStatus": "You died from status effects",
"wanderingEnemyAppeared": "A wandering enemy left the area",
"staminaRegenerated": "Stamina regenerated",
"combatTimeout": "⏱️ Turn skipped due to timeout!",
"interactableReady": "{{action}} is ready on {{name}}",
"waitBeforeMovingSimple": "Wait {{seconds}}s before moving"
},
"directions": {

View File

@@ -80,7 +80,15 @@
"durability": "Durabilidad",
"noItemsFound": "No se encontraron objetos en esta categoría",
"levelDifferenceTooHigh": "Nivel demasiado alto",
"areaTooSafeForPvP": "Área demasiado segura para PvP"
"areaTooSafeForPvP": "Área demasiado segura para PvP",
"cures": "Cura",
"effects": {
"regeneration": "Regeneración",
"bleeding": "Sangrado",
"burning": "Quemadura",
"poisoned": "Envenenamiento"
},
"effectAlreadyActive": "Efecto ya activo"
},
"location": {
"recentActivity": "📜 Actividad Reciente",
@@ -177,12 +185,16 @@
},
"actions": {
"attack": "Atacar",
"defend": "Defender",
"flee": "Huir",
"supplies": "Suministros",
"useItem": "Usar Objeto"
},
"status": {
"attacking": "Atacando...",
"defending": "Preparándose...",
"fleeing": "Huyendo...",
"usingItem": "Usando objeto...",
"waiting": "Esperando al oponente..."
},
"events": {
@@ -191,7 +203,11 @@
"playerMiss": "¡Fallaste!",
"enemyMiss": "¡El enemigo falló!",
"armorAbsorbed": "La armadura absorbió {{armor}} de daño",
"itemBroke": "¡{{item}} se rompió!"
"itemBroke": "¡{{item}} se rompió!",
"defendSuccess": "¡Te preparas para resistir, reduciendo el daño recibido!",
"damageReduced": "¡Defendiendo! Daño reducido en {{reduction}}%",
"itemUsed": "Usaste {{item}}{{effects}}",
"itemDamage": "{{item}} inflige {{damage}} de daño!"
},
"log": {
"combat_start": "¡Combate iniciado!",
@@ -205,7 +221,17 @@
"enemy_miss": "¡El enemigo falló!",
"item_broken": "¡Tu {{item}} se rompió!",
"flee_success": "¡Lograste escapar!",
"flee_fail": "¡No pudiste escapar!"
"flee_fail": "¡No pudiste escapar!",
"defend": "¡Te preparas para el impacto!",
"item_used": "Usaste {{item}}",
"effect_applied": "Aplicado {{effect}} a {{target}}",
"item_damage": "{{item}} inflige {{damage}} de daño!",
"damage_reduced": "Daño reducido en {{reduction}}%"
},
"modal": {
"supplies_title": "Suministros de Combate",
"no_combat_items": "No hay objetos de combate disponibles",
"search_items": "Buscar objetos..."
}
},
"equipment": {
@@ -268,7 +294,14 @@
"enemyAppeared": "¡Un {{name}} ha aparecido!",
"enemyDespawned": "Un enemigo errante ha abandonado el área",
"corpsesDecayed": "{{count}} cadáveres se han descompuesto",
"itemsDecayed": "{{count}} objetos caídos se han descompuesto",
"itemsDecayed": "{{count}} objeto(s) tirado(s) se han descompuesto",
"statusDamage": "Has recibido {{damage}} de daño por efectos de estado",
"statusHeal": "Has recuperado {{heal}} PS por efectos de estado",
"diedStatus": "Has muerto debido a efectos de estado",
"wanderingEnemyAppeared": "¡Un enemigo errante abandonó el área",
"staminaRegenerated": "Estamina regenerada",
"combatTimeout": "⏱️ ¡Turno saltado por tiempo agotado!",
"interactableReady": "{{action}} está listo en {{name}}",
"waitBeforeMovingSimple": "Espera {{seconds}}s antes de moverte"
},
"directions": {