Pre-combat-improvements: Combat animations, flee fixes, corpse logic updates
This commit is contained in:
27
pwa/public/audio/audios.txt
Normal file
27
pwa/public/audio/audios.txt
Normal file
@@ -0,0 +1,27 @@
|
||||
Inventory & Interaction
|
||||
pickup.wav (Picking up an item)
|
||||
drop.wav (Dropping an item)
|
||||
equip.wav (Equipping an item)
|
||||
unequip.wav (Unequipping an item)
|
||||
use.wav (Using a consumable like food/potion)
|
||||
interact.wav (Looting a corpse)
|
||||
Combat - General
|
||||
hit.wav (When anyone takes damage)
|
||||
victory.wav (Combat won)
|
||||
defeat.wav (Combat lost)
|
||||
flee.wav (Successfully ran away)
|
||||
Combat - Player Weapons
|
||||
The system detects keywords in the weapon name to pick the sound. If no match is found, it plays the default.
|
||||
|
||||
attack_sword.wav (Swords, Blades)
|
||||
attack_axe.wav (Axes)
|
||||
attack_bow.wav (Bows)
|
||||
attack_dagger.wav (Daggers)
|
||||
attack_blunt.wav (Hammers, Maces)
|
||||
attack_punch.wav (Unarmed/Fists)
|
||||
attack_default.wav (Required fallback)
|
||||
Combat - Enemies
|
||||
The system tries to find a specific sound for the NPC ID first.
|
||||
|
||||
attack_enemy_default.wav (Required fallback)
|
||||
attack_enemy_<ID>.wav (Optional specific sounds, e.g., attack_enemy_1.wav)
|
||||
BIN
pwa/public/audio/sfx/attack_punch.wav
Normal file
BIN
pwa/public/audio/sfx/attack_punch.wav
Normal file
Binary file not shown.
BIN
pwa/public/audio/sfx/defeat.wav
Normal file
BIN
pwa/public/audio/sfx/defeat.wav
Normal file
Binary file not shown.
BIN
pwa/public/audio/sfx/equip.wav
Normal file
BIN
pwa/public/audio/sfx/equip.wav
Normal file
Binary file not shown.
BIN
pwa/public/audio/sfx/flee.wav
Normal file
BIN
pwa/public/audio/sfx/flee.wav
Normal file
Binary file not shown.
BIN
pwa/public/audio/sfx/unequip.wav
Normal file
BIN
pwa/public/audio/sfx/unequip.wav
Normal file
Binary file not shown.
BIN
pwa/public/audio/sfx/use.wav
Normal file
BIN
pwa/public/audio/sfx/use.wav
Normal file
Binary file not shown.
BIN
pwa/public/audio/sfx/victory.wav
Normal file
BIN
pwa/public/audio/sfx/victory.wav
Normal file
Binary file not shown.
@@ -4123,4 +4123,9 @@ body.no-scroll {
|
||||
transform: translateX(-50%) translateY(-20px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.text-danger {
|
||||
color: #ff4444 !important;
|
||||
}
|
||||
@@ -119,7 +119,8 @@ function Game() {
|
||||
is_pvp: true,
|
||||
in_combat: true,
|
||||
combat_over: message.data.combat_over || false,
|
||||
pvp_combat: message.data.pvp_combat
|
||||
pvp_combat: message.data.pvp_combat,
|
||||
messages: message.data.messages
|
||||
})
|
||||
}
|
||||
if (message.data?.player) {
|
||||
@@ -382,7 +383,7 @@ function Game() {
|
||||
onLootCorpseItem={actions.handleLootCorpseItem}
|
||||
onSetExpandedCorpse={(corpseId: string | null) => {
|
||||
if (corpseId === null) {
|
||||
actions.setSelectedItem(null)
|
||||
actions.handleCloseCorpseDetails()
|
||||
} else {
|
||||
actions.handleViewCorpseDetails(corpseId)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ interface CombatProps {
|
||||
playerState: any;
|
||||
equipment: any;
|
||||
onCombatAction: (action: string) => Promise<any>;
|
||||
onPvPAction: (action: string, targetId: number) => Promise<void>;
|
||||
onPvPAction: (action: string, targetId: number) => Promise<any>;
|
||||
onExitCombat: () => void;
|
||||
onExitPvPCombat: () => Promise<void>;
|
||||
addCombatLogEntry: (entry: any) => void;
|
||||
@@ -91,11 +91,24 @@ export const Combat: React.FC<CombatProps> = ({
|
||||
}];
|
||||
};
|
||||
|
||||
// Calculate if it's your turn for PVP
|
||||
const computeYourTurn = () => {
|
||||
if (!isPvP) return initialCombatData?.turn === 'player';
|
||||
const pvp = initialCombatData?.pvp_combat;
|
||||
if (!pvp) return false;
|
||||
// your_turn comes directly from API, or we calculate it
|
||||
if (pvp.your_turn !== undefined) return pvp.your_turn;
|
||||
const isAttacker = pvp.is_attacker;
|
||||
const currentTurn = pvp.current_turn || pvp.turn;
|
||||
return (isAttacker && currentTurn === 'attacker') || (!isAttacker && currentTurn === 'defender');
|
||||
};
|
||||
|
||||
// --- State Management ---
|
||||
// We synchronize local state with props, but manage animations locally
|
||||
const [localCombatState, setLocalCombatState] = useState<CombatState>({
|
||||
inCombat: true,
|
||||
turn: initialCombatData?.turn || 'player',
|
||||
turn: initialCombatData?.turn || initialCombatData?.pvp_combat?.current_turn || 'player',
|
||||
yourTurn: computeYourTurn(),
|
||||
npcId: initialCombatData?.combat?.npc_id || initialCombatData?.pvp_combat?.defender?.id,
|
||||
npcName: resolveName(initialCombatData?.combat?.npc_name) ||
|
||||
(initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.username : initialCombatData?.pvp_combat?.attacker?.username),
|
||||
@@ -103,7 +116,8 @@ export const Combat: React.FC<CombatProps> = ({
|
||||
(initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.hp : initialCombatData?.pvp_combat?.attacker?.hp) || 100,
|
||||
npcMaxHp: initialCombatData?.combat?.npc_max_hp ||
|
||||
(initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.max_hp : initialCombatData?.pvp_combat?.attacker?.max_hp) || 100,
|
||||
npcImage: initialCombatData?.combat?.npc_image,
|
||||
npcImage: initialCombatData?.combat?.npc_image ||
|
||||
(initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.image : initialCombatData?.pvp_combat?.attacker?.image),
|
||||
playerHp: playerState?.health || profile?.hp || 100,
|
||||
playerMaxHp: playerState?.max_health || profile?.max_hp || 100,
|
||||
messages: getInitialLogMessage(),
|
||||
@@ -112,7 +126,7 @@ export const Combat: React.FC<CombatProps> = ({
|
||||
opponentName: isPvP
|
||||
? (initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.username : initialCombatData?.pvp_combat?.attacker?.username)
|
||||
: undefined,
|
||||
turnTimeRemaining: initialCombatData?.turn_time_remaining
|
||||
turnTimeRemaining: initialCombatData?.pvp_combat?.time_remaining ?? initialCombatData?.turn_time_remaining
|
||||
});
|
||||
|
||||
const [animState, setAnimState] = useState<AnimationState>({
|
||||
@@ -144,19 +158,131 @@ export const Combat: React.FC<CombatProps> = ({
|
||||
}, [messageQueue]);
|
||||
|
||||
// Update local state when props change (especially for PvP live updates)
|
||||
// IMPORTANT: We preserve existing messages to avoid wiping the initial log
|
||||
// NOTE: HP values are NOT synced here - they are managed through processMessage for proper animation timing
|
||||
// This handles both initial state and WebSocket updates (for the passive player)
|
||||
useEffect(() => {
|
||||
if (initialCombatData) {
|
||||
// Get time remaining from multiple possible paths
|
||||
const newTimeRemaining =
|
||||
initialCombatData?.pvp_combat?.time_remaining ??
|
||||
initialCombatData?.turn_time_remaining ??
|
||||
undefined;
|
||||
|
||||
// Calculate yourTurn for PVP
|
||||
let newYourTurn: boolean | undefined;
|
||||
if (isPvP) {
|
||||
const pvp = initialCombatData?.pvp_combat;
|
||||
if (pvp?.your_turn !== undefined) {
|
||||
newYourTurn = pvp.your_turn;
|
||||
} else if (pvp) {
|
||||
const isAttacker = pvp.is_attacker;
|
||||
const currentTurn = pvp.current_turn || pvp.turn;
|
||||
newYourTurn = (isAttacker && currentTurn === 'attacker') || (!isAttacker && currentTurn === 'defender');
|
||||
}
|
||||
} else {
|
||||
newYourTurn = initialCombatData.turn === 'player' || initialCombatData.combat?.turn === 'player';
|
||||
}
|
||||
|
||||
// For PVP: sync HP from WebSocket update for passive player
|
||||
let newPlayerHp: number | undefined;
|
||||
let newNpcHp: number | undefined;
|
||||
if (isPvP && initialCombatData?.pvp_combat) {
|
||||
const pvp = initialCombatData.pvp_combat;
|
||||
const isAttacker = pvp.is_attacker;
|
||||
|
||||
// My HP vs opponent HP based on role
|
||||
if (isAttacker) {
|
||||
newPlayerHp = pvp.attacker?.hp;
|
||||
newNpcHp = pvp.defender?.hp;
|
||||
} else {
|
||||
newPlayerHp = pvp.defender?.hp;
|
||||
newNpcHp = pvp.attacker?.hp;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle incoming messages from WebSocket (for passive player)
|
||||
if (isPvP && initialCombatData?.messages && Array.isArray(initialCombatData.messages)) {
|
||||
const timestamp = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
|
||||
for (const msg of initialCombatData.messages) {
|
||||
// Skip messages originating from 'player' (active user) as they are handled by the immediate API response
|
||||
if (msg.origin === 'player') continue;
|
||||
|
||||
// Add message to combat log (only for non-player origin)
|
||||
setLocalCombatState(prev => ({
|
||||
...prev,
|
||||
messages: [...prev.messages, { ...msg, timestamp }]
|
||||
}));
|
||||
|
||||
// Trigger animations via WebSocket message (passive player)
|
||||
if ((msg.type === 'damage' || msg.type === 'player_attack') && msg.origin === 'enemy') {
|
||||
// Enemy dealt damage to me
|
||||
triggerAnim('enemyAttacking', 400);
|
||||
setTimeout(() => {
|
||||
addFloatingText(`-${msg.data?.damage || 0}`, 'damage', 'player');
|
||||
triggerAnim('playerHit', 300);
|
||||
}, 200);
|
||||
} else if (msg.type === 'miss' && msg.origin === 'enemy') {
|
||||
// Enemy missed me
|
||||
triggerAnim('enemyAttacking', 400);
|
||||
setTimeout(() => {
|
||||
addFloatingText('Miss!', 'miss', 'player');
|
||||
}, 200);
|
||||
} else if (msg.type === 'flee_success') {
|
||||
// Opponent fled -> I win (msg origin is 'enemy' after swap? NO wait.)
|
||||
// Backend: attacker_fled=True.
|
||||
// Message generated: "player fled". Origin="player".
|
||||
// Backend swaps origin to "enemy" for me.
|
||||
// So I receive: msg.type='flee_success', origin='enemy'.
|
||||
setCombatResult('victory');
|
||||
} else if (msg.type === 'victory' || msg.type === 'player_defeated') {
|
||||
// If I receive 'victory', and origin='player' (skipped).
|
||||
// If origin='enemy' -> Enemy Won -> I Defeat.
|
||||
if (msg.origin === 'player') {
|
||||
setCombatResult('victory');
|
||||
} else {
|
||||
setCombatResult('defeat');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle combat_over from WebSocket
|
||||
if (initialCombatData?.combat_over) {
|
||||
const pvp = initialCombatData?.pvp_combat;
|
||||
const myId = pvp?.is_attacker
|
||||
? pvp?.attacker?.id
|
||||
: pvp?.defender?.id;
|
||||
|
||||
// Check if someone fled
|
||||
const iAmAttacker = pvp?.is_attacker;
|
||||
const opponentFled = iAmAttacker ? pvp?.defender_fled : pvp?.attacker_fled;
|
||||
const iFled = iAmAttacker ? pvp?.attacker_fled : pvp?.defender_fled;
|
||||
|
||||
if (opponentFled) {
|
||||
// Opponent fled - I "win" by default
|
||||
setCombatResult('victory');
|
||||
} else if (iFled) {
|
||||
// I fled successfully
|
||||
setCombatResult('fled');
|
||||
} else if (initialCombatData?.winner_id === myId) {
|
||||
setCombatResult('victory');
|
||||
} else if (initialCombatData?.winner_id) {
|
||||
setCombatResult('defeat');
|
||||
}
|
||||
}
|
||||
|
||||
setLocalCombatState(prev => ({
|
||||
...prev,
|
||||
turn: initialCombatData.turn || initialCombatData.combat?.turn || prev.turn,
|
||||
turn: initialCombatData.turn || initialCombatData.combat?.turn || initialCombatData.pvp_combat?.current_turn || prev.turn,
|
||||
yourTurn: newYourTurn !== undefined ? newYourTurn : prev.yourTurn,
|
||||
round: initialCombatData?.combat?.round ?? prev.round,
|
||||
turnTimeRemaining: initialCombatData?.turn_time_remaining
|
||||
// Do NOT overwrite messages or HP here - HP is managed by processMessage
|
||||
turnTimeRemaining: newTimeRemaining !== undefined ? newTimeRemaining : prev.turnTimeRemaining,
|
||||
// Sync HP for PVP from WebSocket updates
|
||||
...(isPvP && newPlayerHp !== undefined ? { playerHp: newPlayerHp } : {}),
|
||||
...(isPvP && newNpcHp !== undefined ? { npcHp: newNpcHp } : {})
|
||||
}));
|
||||
}
|
||||
}, [initialCombatData]);
|
||||
}, [initialCombatData, isPvP]);
|
||||
|
||||
|
||||
// --- Handlers ---
|
||||
@@ -186,6 +312,41 @@ export const Combat: React.FC<CombatProps> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Timer countdown effect for PVP
|
||||
useEffect(() => {
|
||||
if (!isPvP || combatResult) return; // Only for active PVP combat
|
||||
|
||||
const timerInterval = setInterval(() => {
|
||||
setLocalCombatState(prev => {
|
||||
if (prev.turnTimeRemaining !== undefined && prev.turnTimeRemaining > 0) {
|
||||
const newTime = prev.turnTimeRemaining - 1;
|
||||
|
||||
// If timer just hit 0 and it was your turn, switch turn and log it
|
||||
if (newTime === 0 && prev.yourTurn) {
|
||||
// Add timeout message to log
|
||||
const timestamp = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
return {
|
||||
...prev,
|
||||
turnTimeRemaining: newTime,
|
||||
yourTurn: false, // Pass turn to opponent
|
||||
messages: [...prev.messages, {
|
||||
type: 'text',
|
||||
origin: 'system' as const,
|
||||
data: { text: t('combat.turn_timeout') },
|
||||
timestamp
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
return { ...prev, turnTimeRemaining: newTime };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timerInterval);
|
||||
}, [isPvP, combatResult, t]);
|
||||
|
||||
const triggerAnim = (anim: keyof AnimationState, duration: number = 500) => {
|
||||
setAnimState(prev => ({ ...prev, [anim]: true }));
|
||||
setTimeout(() => {
|
||||
@@ -235,6 +396,7 @@ export const Combat: React.FC<CombatProps> = ({
|
||||
|
||||
case 'enemy_attack':
|
||||
case 'monster_attack':
|
||||
case 'flee_fail': // Failed flee results in enemy counter-attack
|
||||
triggerAnim('enemyAttacking');
|
||||
triggerAnim('playerHit', 300); // Player takes damage
|
||||
if (data.damage) {
|
||||
@@ -391,8 +553,84 @@ export const Combat: React.FC<CombatProps> = ({
|
||||
|
||||
const handlePvPActionWrapper = async (action: string) => {
|
||||
if (isProcessingQueue) return;
|
||||
// Clean up targetId - standard action doesn't need it usually, or use 0
|
||||
await onPvPAction(action, 0);
|
||||
try {
|
||||
// Call PVP action and process response for animations
|
||||
const response = await onPvPAction(action, 0);
|
||||
|
||||
if (response) {
|
||||
// Determine if this is an attack action
|
||||
const isAttack = action === 'attack';
|
||||
|
||||
// Trigger player attack animation for attacks
|
||||
if (isAttack) {
|
||||
triggerAnim('playerAttacking', 400);
|
||||
}
|
||||
|
||||
// Process messages for animations and add to combat log
|
||||
if (response.messages && Array.isArray(response.messages)) {
|
||||
const timestamp = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
|
||||
for (const msg of response.messages) {
|
||||
// Add message to combat log
|
||||
setLocalCombatState(prev => ({
|
||||
...prev,
|
||||
messages: [...prev.messages, { ...msg, timestamp }]
|
||||
}));
|
||||
|
||||
// Trigger animations based on message type
|
||||
if ((msg.type === 'damage' || msg.type === 'player_attack') && msg.origin === 'player') {
|
||||
// Player dealt damage - show damage on enemy
|
||||
setTimeout(() => {
|
||||
addFloatingText(`-${msg.data?.damage || 0}`, 'damage', 'enemy');
|
||||
triggerAnim('npcHit', 300);
|
||||
}, 200);
|
||||
} else if (msg.type === 'miss' && msg.origin === 'player') {
|
||||
setTimeout(() => {
|
||||
addFloatingText('Miss!', 'miss', 'enemy');
|
||||
}, 200);
|
||||
} else if (msg.type === 'flee_success') {
|
||||
// Successfully fled - trigger combat result
|
||||
setCombatResult('fled');
|
||||
} else if (msg.type === 'flee_fail') {
|
||||
// Failed to flee - just the log message was added
|
||||
} else if (msg.type === 'victory') {
|
||||
setCombatResult('victory');
|
||||
} else if (msg.type === 'player_defeated') {
|
||||
setCombatResult('defeat');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update HP from response if available
|
||||
const isAttacker = initialCombatData?.pvp_combat?.is_attacker;
|
||||
if (response.attacker_hp !== undefined && response.defender_hp !== undefined) {
|
||||
const myHp = isAttacker ? response.attacker_hp : response.defender_hp;
|
||||
const opponentHp = isAttacker ? response.defender_hp : response.attacker_hp;
|
||||
|
||||
setLocalCombatState(prev => ({
|
||||
...prev,
|
||||
npcHp: opponentHp,
|
||||
playerHp: myHp
|
||||
}));
|
||||
}
|
||||
|
||||
// Handle combat over state
|
||||
if (response.combat_over) {
|
||||
if (response.winner_id === initialCombatData?.pvp_combat?.id) {
|
||||
// Not used - winner_id is player id, not combat id
|
||||
}
|
||||
// Combat result will be set by message type above
|
||||
}
|
||||
|
||||
// Update turn state
|
||||
setLocalCombatState(prev => ({
|
||||
...prev,
|
||||
yourTurn: false // After action, it's always the other player's turn
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('PvP action error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
||||
@@ -17,7 +17,8 @@ export interface FloatingText {
|
||||
|
||||
export interface CombatState {
|
||||
inCombat: boolean;
|
||||
turn: 'player' | 'enemy';
|
||||
turn: 'player' | 'enemy' | 'attacker' | 'defender';
|
||||
yourTurn?: boolean; // True when it's the current player's turn (works for both PvE and PvP)
|
||||
npcId?: string;
|
||||
npcName?: string;
|
||||
npcHp: number;
|
||||
|
||||
@@ -71,8 +71,8 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
|
||||
// Enemy Attack Sound
|
||||
if (animState.enemyAttacking) {
|
||||
// We can use state.npcId to get specific enemy sounds
|
||||
if (state.npcId) {
|
||||
// We can use state.npcId to get specific enemy sounds (only for PvE)
|
||||
if (state.npcId && !state.isPvP) {
|
||||
playSfx(`/audio/sfx/attack_enemy_${state.npcId}.wav`, '/audio/sfx/attack_enemy_default.wav');
|
||||
} else {
|
||||
playSfx('/audio/sfx/attack_enemy_default.wav', '/audio/sfx/attack_default.wav');
|
||||
@@ -125,7 +125,15 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
|
||||
{state.turnTimeRemaining !== undefined && (
|
||||
<span className="danger-badge danger-2" style={{ fontSize: '0.8rem', marginLeft: '0.5rem' }}>
|
||||
⏳ {state.turnTimeRemaining}s
|
||||
⏳ {state.turnTimeRemaining} s
|
||||
</span>
|
||||
)}
|
||||
{state.isPvP && (
|
||||
<span
|
||||
className={`danger-badge ${state.yourTurn ? 'danger-1' : 'danger-3'}`}
|
||||
style={{ fontSize: '0.8rem', marginLeft: '0.5rem', fontWeight: 'bold' }}
|
||||
>
|
||||
{state.yourTurn ? '🎯 ' + t('combat.your_turn') : '⏳ ' + t('combat.opponent_turn')}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
@@ -169,7 +177,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Player HP (Right) */}
|
||||
<div className={`stat-block player ${animState.playerAttacking ? 'attacking' : ''} ${animState.playerHit ? 'shake-effect flash-hit' : ''}`}>
|
||||
<div className={`stat-block player ${animState.playerHit ? 'shake-effect flash-hit' : ''}`}>
|
||||
<div className="floating-text-layer" style={{ height: '0', overflow: 'visible' }}>
|
||||
{floatingTexts.filter(ft => ft.origin === 'player').map(ft => (
|
||||
<div key={ft.id} className={`floating-text type-${ft.type}`} style={{ left: `${ft.x}%`, top: `${ft.y - 50}%` }}>
|
||||
@@ -202,7 +210,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
<button
|
||||
className="btn btn-attack"
|
||||
onClick={() => onAction('attack')}
|
||||
disabled={isProcessing || state.turn !== 'player'}
|
||||
disabled={isProcessing || !state.yourTurn}
|
||||
>
|
||||
👊 {t('combat.actions.attack')}
|
||||
</button>
|
||||
@@ -210,7 +218,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
<button
|
||||
className="btn btn-flee"
|
||||
onClick={() => onAction('flee')}
|
||||
disabled={isProcessing || state.turn !== 'player'}
|
||||
disabled={isProcessing || !state.yourTurn}
|
||||
>
|
||||
🏃 {t('combat.actions.flee')}
|
||||
</button>
|
||||
@@ -230,7 +238,14 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
} else {
|
||||
switch (msg.type) {
|
||||
case 'combat_start': text = t('combat.start'); break;
|
||||
case 'player_attack': text = t('combat.log.player_attack', { damage: msg.data?.damage || 0 }); break;
|
||||
case 'player_attack':
|
||||
if (msg.origin === 'enemy') {
|
||||
text = t('combat.log.enemy_attack', { damage: msg.data?.damage || 0 });
|
||||
className += " text-danger";
|
||||
} else {
|
||||
text = t('combat.log.player_attack', { damage: msg.data?.damage || 0 });
|
||||
}
|
||||
break;
|
||||
case 'enemy_attack':
|
||||
text = t('combat.log.enemy_attack', { damage: msg.data?.damage || 0 });
|
||||
className += " text-danger";
|
||||
@@ -243,6 +258,14 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
case 'flee_fail': text = t('combat.flee.fail'); break;
|
||||
case 'item_broken': text = t('combat.item_broken', { item: msg.data?.item_name }); break;
|
||||
case 'xp_gain': text = t('combat.log.xp_gain', { xp: msg.data?.xp }); className += " text-warning"; break;
|
||||
case 'damage':
|
||||
if (msg.origin === 'enemy') {
|
||||
text = t('combat.log.enemy_attack', { damage: msg.data?.damage || 0 });
|
||||
className += " text-danger";
|
||||
} else {
|
||||
text = t('combat.log.player_attack', { damage: msg.data?.damage || 0 });
|
||||
}
|
||||
break;
|
||||
case 'text': text = msg.data?.text || ""; break;
|
||||
default: text = msg.type;
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ export interface GameEngineActions {
|
||||
// Interactions
|
||||
handleInteract: (interactableId: string, actionId: string) => Promise<void>
|
||||
handleViewCorpseDetails: (corpseId: string) => Promise<void>
|
||||
handleCloseCorpseDetails: () => void
|
||||
handleLootCorpse: (corpseId: string) => Promise<void>
|
||||
handleLootCorpseItem: (corpseId: string, itemIndex: number | null) => Promise<void>
|
||||
|
||||
@@ -1046,6 +1047,10 @@ export function useGameEngine(
|
||||
handleFlee,
|
||||
handleInteract,
|
||||
handleViewCorpseDetails,
|
||||
handleCloseCorpseDetails: () => {
|
||||
setExpandedCorpse(null)
|
||||
setCorpseDetails(null)
|
||||
},
|
||||
handleLootCorpse,
|
||||
handleLootCorpseItem,
|
||||
handleSpendPoint,
|
||||
@@ -1084,6 +1089,31 @@ export function useGameEngine(
|
||||
}
|
||||
}
|
||||
|
||||
// Polling fallback for PvP Combat reliability
|
||||
// Polling fallback for PvP Combat reliability
|
||||
// optimized: poll less frequently (15s) and rely on WS reconnect event
|
||||
useEffect(() => {
|
||||
// 1. Listen for WebSocket reconnection to fetch immediately
|
||||
const handleReconnect = () => {
|
||||
console.log("[PvP] WebSocket reconnected, fetching fresh state...");
|
||||
fetchGameData(true);
|
||||
};
|
||||
window.addEventListener('game-ws-connected', handleReconnect);
|
||||
|
||||
// 2. Slow polling as safety net
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
if (combatState?.is_pvp && !combatState?.combat_over) {
|
||||
interval = setInterval(() => {
|
||||
fetchGameData(true);
|
||||
}, 15000); // Poll every 15s instead of 3s
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('game-ws-connected', handleReconnect);
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [combatState?.is_pvp, combatState?.combat_over, fetchGameData]);
|
||||
|
||||
// Initial data load
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
@@ -1091,51 +1121,22 @@ export function useGameEngine(
|
||||
}
|
||||
}, [token])
|
||||
|
||||
// WebSocket connection
|
||||
// WebSocket Event Bus Listener
|
||||
// Instead of maintaining a second connection, we listen to the global connection managed by GameHeader
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
|
||||
// Get WebSocket URL based on environment (same logic as api.ts)
|
||||
const API_BASE = import.meta.env.VITE_API_URL || (
|
||||
import.meta.env.PROD
|
||||
? 'https://api-staging.echoesoftheash.com'
|
||||
: 'http://localhost:8000'
|
||||
)
|
||||
const wsBase = API_BASE.replace(/^http/, 'ws')
|
||||
const wsUrl = `${wsBase}/ws/game/${token}`
|
||||
console.log('🔌 Connecting to WebSocket:', wsUrl)
|
||||
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('✅ WebSocket connection established')
|
||||
setWebSocket(ws)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data)
|
||||
_handleWebSocketMessage(message)
|
||||
} catch (err) {
|
||||
console.error('Failed to parse WebSocket message:', err)
|
||||
const handleGameMessage = (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
if (customEvent.detail) {
|
||||
_handleWebSocketMessage(customEvent.detail);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('❌ WebSocket error:', error)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('🔌 WebSocket disconnected')
|
||||
setWebSocket(null)
|
||||
}
|
||||
window.addEventListener('game-ws-message', handleGameMessage);
|
||||
|
||||
return () => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.close()
|
||||
}
|
||||
}
|
||||
}, [token]) // Removed _handleWebSocketMessage from dependencies
|
||||
window.removeEventListener('game-ws-message', handleGameMessage);
|
||||
};
|
||||
}, [_handleWebSocketMessage]);
|
||||
|
||||
return [state, actions]
|
||||
}
|
||||
|
||||
@@ -74,6 +74,9 @@ export const useGameWebSocket = ({
|
||||
setIsConnected(true);
|
||||
reconnectAttemptsRef.current = 0;
|
||||
|
||||
// Dispatch global event for other components to react (e.g., fetch fresh state)
|
||||
window.dispatchEvent(new Event('game-ws-connected'));
|
||||
|
||||
// Start heartbeat interval (every 30 seconds)
|
||||
heartbeatIntervalRef.current = setInterval(sendHeartbeat, 30000);
|
||||
};
|
||||
@@ -87,6 +90,16 @@ export const useGameWebSocket = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.id) {
|
||||
// console.log(`📩 Received msg ${message.id} type=${message.type}`);
|
||||
// Send ACK
|
||||
wsRef.current?.send(JSON.stringify({ type: "ack", reply_to: message.id }));
|
||||
}
|
||||
|
||||
// Dispatch to global event bus so other components (like Game.tsx) can react
|
||||
// without needing their own WebSocket connection
|
||||
window.dispatchEvent(new CustomEvent('game-ws-message', { detail: message }));
|
||||
|
||||
// Pass message to handler
|
||||
onMessage(message);
|
||||
} catch (error) {
|
||||
|
||||
@@ -151,6 +151,9 @@
|
||||
"inCombat": "In Combat",
|
||||
"yourTurn": "Your Turn",
|
||||
"enemyTurn": "Enemy's Turn",
|
||||
"your_turn": "Your Turn",
|
||||
"opponent_turn": "Waiting",
|
||||
"turn_timeout": "Time ran out! Turn passed.",
|
||||
"victory": "Victory!",
|
||||
"defeat": "Defeat",
|
||||
"youDied": "You Died",
|
||||
|
||||
@@ -149,6 +149,9 @@
|
||||
"inCombat": "En Combate",
|
||||
"yourTurn": "Tu Turno",
|
||||
"enemyTurn": "Turno del Enemigo",
|
||||
"your_turn": "Tu Turno",
|
||||
"opponent_turn": "Esperando",
|
||||
"turn_timeout": "¡Se acabó el tiempo! Turno pasado.",
|
||||
"victory": "¡Victoria!",
|
||||
"defeat": "Derrota",
|
||||
"youDied": "Has Muerto",
|
||||
|
||||
Reference in New Issue
Block a user