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

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": {