This commit is contained in:
Joan
2025-11-27 16:27:01 +01:00
parent 33cc9586c2
commit 81f8912059
304 changed files with 56149 additions and 10122 deletions

View File

@@ -0,0 +1,345 @@
import { useState, useEffect } from 'react'
import CombatView from './CombatView'
import { CombatState, CombatLogEntry, Profile, Equipment, PlayerState } from './types'
import api from '../../services/api'
import './CombatEffects.css'
interface CombatProps {
combatState: CombatState
profile: Profile | null
playerState: PlayerState | null
equipment: Equipment
onCombatAction: (action: string) => Promise<any>
onExitCombat: () => void
onPvPAction: (action: string) => Promise<any>
onExitPvPCombat: () => void
combatLog: CombatLogEntry[]
addCombatLogEntry: (entry: CombatLogEntry) => void
updatePlayerState: (state: PlayerState) => void
updateCombatState: (state: CombatState) => void
}
const Combat = ({
combatState,
profile,
playerState,
equipment,
onCombatAction,
onExitCombat,
onPvPAction,
onExitPvPCombat,
combatLog,
addCombatLogEntry,
updatePlayerState,
updateCombatState
}: CombatProps) => {
// Local state for visual effects and logic
const [shake, setShake] = useState(false)
const [flash, setFlash] = useState(false)
const [floatingTexts, setFloatingTexts] = useState<{ id: number, text: string, x: number, y: number, type: 'damage-player' | 'damage-enemy' | 'damage-player-dealt' | 'heal' }[]>([])
const [processing, setProcessing] = useState(false)
const [pvpTimer, setPvpTimer] = useState<number | null>(null)
const [localEnemyTurnMessage, setLocalEnemyTurnMessage] = useState('')
// Temporary HP state to delay player HP updates during enemy turn
const [tempPlayerHP, setTempPlayerHP] = useState<number | null>(null)
// Turn timer state for PvE combat
const [turnTimeRemaining, setTurnTimeRemaining] = useState<number | null>(null)
// PvP Timer Effect
useEffect(() => {
if (combatState.is_pvp && combatState.pvp_combat) {
// Always set timer from server value
setPvpTimer(combatState.pvp_combat.time_remaining)
// Run countdown locally for smooth UI
const interval = setInterval(() => {
setPvpTimer(prev => (prev && prev > 0 ? prev - 1 : 0))
}, 1000)
return () => clearInterval(interval)
} else {
setPvpTimer(null)
}
}, [combatState.is_pvp, combatState.pvp_combat])
// PvE Timer Effect - Update from server-calculated time
// Reset timer whenever turn_time_remaining changes from server
useEffect(() => {
if (!combatState.is_pvp && combatState.combat?.turn === 'player' && combatState.combat?.turn_time_remaining !== undefined) {
// Always set the timer from server value to ensure it resets after each turn
setTurnTimeRemaining(combatState.combat.turn_time_remaining)
} else {
setTurnTimeRemaining(null)
}
}, [combatState.is_pvp, combatState.combat?.turn, combatState.combat?.turn_time_remaining])
// PvE Timer Countdown Effect - Decrement locally for smooth UI
useEffect(() => {
if (turnTimeRemaining !== null && turnTimeRemaining > 0) {
const interval = setInterval(() => {
setTurnTimeRemaining(prev => prev !== null && prev > 0 ? prev - 1 : 0)
}, 1000)
return () => clearInterval(interval)
}
}, [turnTimeRemaining])
// PvE Polling Effect - Poll when timeout is imminent (< 30s) to catch background task
useEffect(() => {
if (!combatState.is_pvp && turnTimeRemaining !== null && turnTimeRemaining < 30 && turnTimeRemaining >= 0) {
const pollInterval = setInterval(async () => {
try {
// Fetch updated combat state from API
const response = await api.get('/api/game/combat')
if (response.data.in_combat && response.data.combat) {
// Update combat state if turn changed (background task processed timeout)
if (response.data.combat.turn !== combatState.combat?.turn) {
updateCombatState({
...combatState,
combat: response.data.combat
})
}
}
} catch (error) {
console.error('Failed to poll combat state:', error)
}
}, 10000) // Poll every 10 seconds
return () => clearInterval(pollInterval)
}
}, [turnTimeRemaining, combatState, updateCombatState])
const addFloatingText = (text: string, x: number, y: number, type: 'damage-player' | 'damage-enemy' | 'damage-player-dealt' | 'heal') => {
const id = Date.now() + Math.random()
setFloatingTexts(prev => [...prev, { id, text, x, y, type }])
setTimeout(() => {
setFloatingTexts(prev => prev.filter(ft => ft.id !== id))
}, 2500)
}
const handlePvEAction = async (action: string) => {
if (processing) return
setProcessing(true)
try {
const data = await onCombatAction(action)
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
// Parse messages
const messages = data.message.split('\n').filter((m: string) => m.trim())
// Handle failed flee special case - split combined message
const processedMessages: string[] = []
messages.forEach((msg: string) => {
// Check if message contains both flee failure and enemy attack
const fleeFailMatch = msg.match(/^(Failed to flee!)\s+(.+)$/)
if (fleeFailMatch) {
processedMessages.push(fleeFailMatch[1]) // "Failed to flee!"
processedMessages.push(fleeFailMatch[2]) // Enemy attack message
} else {
processedMessages.push(msg)
}
})
const playerMessages = processedMessages.filter((msg: string) =>
msg.includes('You ') || msg.includes('Your ') || msg === 'Failed to flee!'
)
const enemyMessages = processedMessages.filter((msg: string) =>
msg !== 'Failed to flee!' &&
(msg.includes('attacks') || msg.includes('hits') || msg.includes('misses') || msg.includes('The '))
)
// Check if this is a failed flee attempt
const isFailedFlee = playerMessages.some(msg => msg === 'Failed to flee!')
// 1. Immediate Player Feedback
playerMessages.forEach((msg: string) => {
addCombatLogEntry({ time: timeStr, message: msg, isPlayer: true })
// Only show attack animations for actual attacks, not flee failures
if (msg !== 'Failed to flee!') {
const damageMatch = msg.match(/(\d+) damage/)
if (damageMatch) {
addFloatingText(damageMatch[1], 50, 30, 'damage-player-dealt') // White text on enemy
setFlash(true)
setTimeout(() => setFlash(false), 300)
}
}
})
// Update Enemy HP immediately
if (data.combat && !data.combat_over) {
updateCombatState({
...combatState,
combat: {
...combatState.combat,
npc_hp: data.combat.npc_hp,
turn: data.combat.turn,
turn_time_remaining: data.combat.turn_time_remaining,
round: data.combat.round
}
})
// Store current player HP to prevent it from updating during enemy turn
if (data.player && playerState) {
setTempPlayerHP(playerState.health)
}
}
// 2. Enemy Turn Delay (including failed flee)
if ((enemyMessages.length > 0 || isFailedFlee) && !data.combat_over) {
setLocalEnemyTurnMessage(isFailedFlee ? "🗡️ Enemy's turn..." : "🗡️ Enemy's turn...")
await new Promise(resolve => setTimeout(resolve, 2000))
enemyMessages.forEach((msg: string) => {
addCombatLogEntry({ time: timeStr, message: msg, isPlayer: false })
const damageMatch = msg.match(/(\d+) damage/)
if (damageMatch) {
addFloatingText(damageMatch[1], 50, 50, 'damage-player') // Red text over player position
setShake(true)
setTimeout(() => setShake(false), 500)
}
})
setLocalEnemyTurnMessage('')
// Update Player HP after delay completes
if (data.player && playerState) {
setTempPlayerHP(null) // Clear temp HP
updatePlayerState({
...playerState,
health: data.player.hp,
max_health: data.player.max_hp ?? playerState.max_health
})
}
} else if (data.combat_over) {
// Combat ended (e.g. player won or fled)
const playerFled = data.message.toLowerCase().includes('fled') ||
data.message.toLowerCase().includes('escape') ||
data.player_fled === true
updateCombatState({
...combatState,
combat_over: true,
player_won: data.player_won || false,
player_fled: playerFled,
combat: {
...combatState.combat,
npc_hp: data.player_won ? 0 : (data.combat?.npc_hp ?? combatState.combat.npc_hp)
}
})
// Update player state immediately if combat is over
setTempPlayerHP(null) // Clear temp HP
if (data.player && playerState) {
updatePlayerState({
...playerState,
health: data.player.hp,
max_health: data.player.max_hp ?? playerState.max_health
})
}
}
} catch (error) {
console.error('Combat action failed:', error)
} finally {
setProcessing(false)
}
}
const handlePvPActionLocal = async (action: string) => {
if (processing) return
setProcessing(true)
try {
// Call the parent handler (which calls API)
// Note: onPvPAction in Game.tsx currently returns void, but we might need the response
// We'll modify onPvPAction to return the response or we'll rely on the websocket update for state
// BUT for animations we need the immediate response if possible, OR we parse the websocket message?
// The user request says "The timer is also not updating correctly, it should grab the latest data from the websocket update or from the action call."
// So let's assume onPvPAction CAN return data if we await it.
// Checking Game.tsx: onPvPAction calls api.post and sets message. It doesn't return data.
// We need to modify Game.tsx to return the data too?
// Actually, let's just trigger the action and let the websocket handle the state update,
// BUT for "floating text for damage", we usually get that from the immediate response in PvE.
// In PvP, the response might contain the damage info.
// Let's assume onPvPAction returns the response data now (we'll fix Game.tsx if needed, or just use what we have)
// Wait, Game.tsx onPvPAction is:
// onPvPAction={async (action: string) => {
// try {
// const response = await api.post('/api/game/pvp/action', { action })
// actions.setMessage(response.data.message || 'Action performed!')
// await actions.fetchGameData()
// } ...
// }}
// It doesn't return the data to the caller.
// We will modify Combat.tsx to accept a promise that returns data, OR we modify Game.tsx to return it.
// For now, let's just call it and see if we can parse the message from the state update?
// No, animations need to happen NOW.
// Let's change onPvPAction prop signature in Combat.tsx to return Promise<any>
// and update Game.tsx to return the response.data.
const data = await onPvPAction(action)
if (data) {
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
// Parse message for damage
// Example: "You attacked X for 10 damage!"
const msg = data.message || ''
addCombatLogEntry({ time: timeStr, message: msg, isPlayer: true })
const damageMatch = msg.match(/(\d+) damage/)
if (damageMatch) {
addFloatingText(damageMatch[1], 50, 30, 'damage-player-dealt')
setFlash(true)
setTimeout(() => setFlash(false), 300)
}
// If we got hit back immediately (e.g. recoil? or just turn end?)
// Usually PvP is turn based, so we wait for opponent.
}
} catch (error) {
console.error('PvP action failed:', error)
} finally {
setProcessing(false)
}
}
return (
<div className={`combat-container ${shake ? 'shake-effect' : ''}`}>
<CombatView
combatState={combatState}
combatLog={combatLog}
profile={profile}
playerState={tempPlayerHP !== null && playerState ? {
...playerState,
health: tempPlayerHP
} : playerState}
equipment={equipment}
enemyName={combatState.combat?.npc_name || 'Enemy'}
enemyImage={combatState.combat?.npc_image || combatState.combat_image || ''}
enemyTurnMessage={localEnemyTurnMessage}
pvpTimeRemaining={pvpTimer}
turnTimeRemaining={turnTimeRemaining}
onCombatAction={handlePvEAction}
onFlee={async () => handlePvEAction('flee')}
onPvPAction={handlePvPActionLocal}
onExitCombat={onExitCombat}
onExitPvPCombat={onExitPvPCombat}
flashEnemy={flash}
buttonsDisabled={processing}
floatingTexts={floatingTexts}
/>
</div>
)
}
export default Combat

View File

@@ -0,0 +1,328 @@
/* Combat Visual Effects */
/* Screen Shake */
@keyframes shake {
0% {
transform: translate(1px, 1px) rotate(0deg);
}
10% {
transform: translate(-1px, -2px) rotate(-1deg);
}
20% {
transform: translate(-3px, 0px) rotate(1deg);
}
30% {
transform: translate(3px, 2px) rotate(0deg);
}
40% {
transform: translate(1px, -1px) rotate(1deg);
}
50% {
transform: translate(-1px, 2px) rotate(-1deg);
}
60% {
transform: translate(-3px, 1px) rotate(0deg);
}
70% {
transform: translate(3px, 1px) rotate(-1deg);
}
80% {
transform: translate(-1px, -1px) rotate(1deg);
}
90% {
transform: translate(1px, 2px) rotate(0deg);
}
100% {
transform: translate(1px, -2px) rotate(-1deg);
}
}
.shake-effect {
animation: shake 0.5s;
animation-iteration-count: 1;
}
/* Hit Flash */
@keyframes flash-red {
0% {
filter: brightness(1) sepia(0) hue-rotate(0deg) saturate(1);
}
50% {
filter: brightness(0.5) sepia(1) hue-rotate(-50deg) saturate(5);
}
/* Red tint */
100% {
filter: brightness(1) sepia(0) hue-rotate(0deg) saturate(1);
}
}
.flash-hit {
animation: flash-red 0.3s ease-out;
}
/* Dead Enemy Grayscale */
.enemy-dead {
filter: grayscale(100%);
transition: filter 0.5s ease-out;
}
/* Fled Enemy Blueish Tint */
.enemy-fled {
filter: sepia(1) saturate(3) hue-rotate(180deg) brightness(0.8);
transition: filter 0.5s ease-out;
}
/* Floating Damage Numbers */
@keyframes float-up {
0% {
opacity: 1;
transform: translateY(0) scale(1);
}
50% {
opacity: 1;
transform: translateY(-30px) scale(1.3);
}
100% {
opacity: 0;
transform: translateY(-60px) scale(1.5);
}
}
.floating-text-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: hidden;
z-index: 100;
}
.floating-text {
position: absolute;
font-weight: bold;
font-size: 2.5rem;
text-shadow: 3px 3px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000;
animation: float-up 2.5s ease-out forwards;
white-space: nowrap;
pointer-events: none;
z-index: 1000;
}
.floating-text.damage-player {
color: #ff4444;
}
.floating-text.damage-enemy {
color: #ff4444;
}
.floating-text.damage-player-dealt {
color: #ffffff;
}
.floating-text.heal {
color: #44ff44;
}
/* Intent Bubble */
.intent-bubble {
position: absolute;
top: -40px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
border: 2px solid #fff;
border-radius: 20px;
padding: 5px 15px;
color: #fff;
font-weight: bold;
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
z-index: 10;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5);
animation: pop-in 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes pop-in {
0% {
transform: translateX(-50%) scale(0);
}
100% {
transform: translateX(-50%) scale(1);
}
}
.intent-icon {
font-size: 1.2em;
}
.intent-desc {
font-size: 0.9em;
text-transform: uppercase;
letter-spacing: 1px;
}
/* Intent Types */
.intent-attack {
border-color: #ff4444;
}
.intent-defend {
border-color: #4488ff;
}
.intent-special {
border-color: #ffaa00;
}
/* Container relative positioning for absolute children */
.combat-enemy-display-inline {
position: relative;
}
.combat-enemy-image-large {
position: relative;
display: inline-block;
max-width: 100%;
}
.combat-enemy-image-large img {
max-width: 100%;
height: auto;
display: block;
}
.combat-view {
position: relative;
/* For screen shake scope if applied here */
}
/* Combat Container */
.combat-container {
position: relative;
width: 100%;
}
/* Combat Content Wrapper - Groups enemy display, turn indicator, and combat log */
.combat-content-wrapper {
display: inline-flex;
flex-direction: column;
align-items: stretch;
gap: 1rem;
max-width: 800px;
margin: 0 auto;
}
/* Turn Indicator - Match Enemy Image Width */
.combat-turn-indicator-inline {
width: 100%;
display: flex;
justify-content: center;
}
/* Combat Log Styles */
.combat-log-wrapper {
width: 100%;
}
.combat-log-title {
margin: 0 0 10px 0;
font-size: 1.1em;
color: #aaa;
text-align: left;
}
.combat-log-inline {
background: rgba(0, 0, 0, 0.3);
padding: 15px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.log-entries {
max-height: 200px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
/* Custom scrollbar for combat log */
.log-entries::-webkit-scrollbar {
width: 8px;
}
.log-entries::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
.log-entries::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
.log-entries::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
.log-entry {
font-size: 0.9em;
padding: 6px 8px;
line-height: 1.5;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
border-left: 3px solid transparent;
transition: background 0.2s ease;
display: flex;
align-items: flex-start;
gap: 8px;
width: 100%;
}
.log-entry:hover {
background: rgba(0, 0, 0, 0.35);
}
.log-time {
color: #888;
font-size: 0.85em;
font-family: monospace;
flex-shrink: 0;
white-space: nowrap;
}
.log-message {
flex: 1;
word-wrap: break-word;
}
.player-log {
color: #aaddff;
border-left-color: #4488ff;
}
.enemy-log {
color: #ffaaaa;
border-left-color: #ff4444;
}

View File

@@ -0,0 +1,339 @@
import type { CombatState, CombatLogEntry, Profile, Equipment, PlayerState } from './types'
interface CombatViewProps {
combatState: CombatState
combatLog: CombatLogEntry[]
profile: Profile | null
playerState: PlayerState | null
equipment: Equipment
enemyName: string
enemyImage: string
enemyTurnMessage: string
pvpTimeRemaining: number | null
turnTimeRemaining: number | null
onCombatAction: (action: string) => void
onFlee: () => void
onPvPAction: (action: string) => void
onExitCombat: () => void
onExitPvPCombat: () => void
flashEnemy?: boolean
buttonsDisabled?: boolean
floatingTexts?: { id: number, text: string, x: number, y: number, type: 'damage-player' | 'damage-enemy' | 'damage-player-dealt' | 'heal' }[]
}
function CombatView({
combatState,
combatLog,
profile: _profile,
playerState,
enemyName,
enemyImage,
enemyTurnMessage,
pvpTimeRemaining,
turnTimeRemaining,
onCombatAction,
onPvPAction,
onExitCombat,
onExitPvPCombat,
flashEnemy,
buttonsDisabled,
floatingTexts = []
}: CombatViewProps) {
return (
<div className="combat-view">
<div className="combat-header-inline">
<h2>
{combatState.is_pvp ? '⚔️ PvP Combat' : `⚔️ Combat - ${enemyName || combatState.combat?.npc_name || 'Enemy'}`}
</h2>
</div>
{combatState.is_pvp ? (
/* PvP Combat UI - Unified Layout */
<div className="combat-content-wrapper">
<div className="combat-enemy-display-inline">
{/* Opponent Display (using same structure as PvE Enemy) */}
<div className="combat-enemy-image-large">
{floatingTexts.map(ft => (
<div
key={ft.id}
className={`floating-text ${ft.type}`}
style={{ left: `${ft.x}%`, top: `${ft.y}%` }}>
{ft.text}
</div>
))}
{(() => {
if (!combatState.pvp_combat) return null
const opponent = combatState.pvp_combat.is_attacker ?
combatState.pvp_combat.defender :
combatState.pvp_combat.attacker
if (!opponent) return <div className="pvp-opponent-avatar"></div>
// Use a default avatar if no image, or maybe the class image if available?
// For now, let's use a placeholder or try to get it from profile if passed?
// The opponent object has: username, level, hp, max_hp.
// It might not have an image url.
return (
<div className="pvp-opponent-avatar" style={{ fontSize: '4rem', textAlign: 'center' }}>
👤
<div style={{ fontSize: '1rem', marginTop: '0.5rem' }}>{opponent.username} (Lv. {opponent.level})</div>
</div>
)
})()}
</div>
<div className="combat-enemy-info-inline">
{/* Opponent HP Bar */}
{(() => {
if (!combatState.pvp_combat) return null
const opponent = combatState.pvp_combat.is_attacker ?
combatState.pvp_combat.defender :
combatState.pvp_combat.attacker
if (!opponent) return null
return (
<div className="combat-hp-bar-container-inline enemy-hp-bar">
<div className="combat-hp-bar-inline">
<div className="combat-stat-label-inline">
{opponent.username}: {opponent.hp} / {opponent.max_hp}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${(opponent.hp / opponent.max_hp) * 100}%`
}}
/>
</div>
</div>
)
})()}
{/* Player HP Bar */}
{(() => {
if (!combatState.pvp_combat) return null
const you = combatState.pvp_combat.is_attacker ?
combatState.pvp_combat.attacker :
combatState.pvp_combat.defender
if (!you) return null
return (
<div className="combat-hp-bar-container-inline player-hp-bar" style={{ marginTop: '0.75rem' }}>
<div className="combat-hp-bar-inline" style={{ background: 'rgba(255, 107, 107, 0.2)' }}>
<div className="combat-stat-label-inline" style={{ color: '#ff6b6b' }}>
You: {you.hp} / {you.max_hp}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${(you.hp / you.max_hp) * 100}%`,
background: 'linear-gradient(90deg, #f44336, #ff6b6b)'
}}
/>
</div>
</div>
)
})()}
</div>
</div>
<div className="combat-turn-indicator-inline">
{combatState.pvp_combat.combat_over ? (
<span className={combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? "your-turn" : "enemy-turn"}>
{combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? "🏃 Combat Ended" : "💀 Combat Over"}
</span>
) : combatState.pvp_combat.your_turn ? (
<span className="your-turn"> Your Turn ({pvpTimeRemaining ?? combatState.pvp_combat.time_remaining}s)</span>
) : (
<span className="enemy-turn"> Opponent's Turn ({pvpTimeRemaining ?? combatState.pvp_combat.time_remaining}s)</span>
)}
</div>
<div className="combat-actions-inline">
{!combatState.pvp_combat.combat_over ? (
<>
<button
className="combat-action-btn attack-btn"
onClick={() => onPvPAction('attack')}
disabled={!combatState.pvp_combat.your_turn || buttonsDisabled}
>
⚔️ Attack
</button>
<button
className="combat-action-btn flee-btn"
onClick={() => onPvPAction('flee')}
disabled={!combatState.pvp_combat.your_turn || buttonsDisabled}
>
🏃 Flee
</button>
</>
) : (
<button
className="combat-action-btn exit-btn"
onClick={onExitPvPCombat}
>
{combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? ' Continue' : '💀 Return'}
</button>
)}
</div>
{/* Combat Log */}
<div className="combat-log-wrapper">
<h3 className="combat-log-title">Combat Log</h3>
<div className="combat-log-inline">
<div className="log-entries">
{combatLog.map((entry: any, i: number) => (
<div key={i} className={`log-entry ${entry.isPlayer ? 'player-log' : 'enemy-log'}`}>
<span className="log-time">[{entry.time}]</span>
<span className="log-message">{entry.message}</span>
</div>
))}
{combatLog.length === 0 && <div className="log-entry"><span className="log-message">PvP Combat started...</span></div>}
</div>
</div>
</div>
</div>
) : (
/* PvE Combat UI */
<>
<div className="combat-content-wrapper">
<div className="combat-enemy-display-inline">
{/* Intent Bubble - Moved here to avoid overflow:hidden clipping */}
{combatState.combat?.npc_intent && !combatState.combat_over && (
<div className={`intent-bubble intent-${combatState.combat.npc_intent}`}>
<span className="intent-icon">
{combatState.combat.npc_intent === 'attack' ? '' :
combatState.combat.npc_intent === 'defend' ? '🛡' :
combatState.combat.npc_intent === 'special' ? '🔥' : ''}
</span>
<span className="intent-desc">{combatState.combat.npc_intent}</span>
</div>
)}
<div className="combat-enemy-image-large">
{floatingTexts.map(ft => (
<div
key={ft.id}
className={`floating-text ${ft.type}`}
style={{ left: `${ft.x}%`, top: `${ft.y}%` }}>
{ft.text}
</div>
))}
<img
src={enemyImage || combatState.combat?.npc_image || combatState.combat_image}
alt={enemyName || combatState.combat?.npc_name || 'Enemy'}
className={`${flashEnemy ? 'flash-hit' : ''
} ${combatState.combat_over && combatState.player_won ? 'enemy-dead' : ''
} ${combatState.combat_over && combatState.player_fled ? 'enemy-fled' : ''
}`}
/>
</div>
<div className="combat-enemy-info-inline">
<div className="combat-hp-bar-container-inline enemy-hp-bar">
<div className="combat-hp-bar-inline">
<div className="combat-stat-label-inline">
Enemy HP: {combatState.combat?.npc_hp || 0} / {combatState.combat?.npc_max_hp || 100}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${((combatState.combat?.npc_hp || 0) / (combatState.combat?.npc_max_hp || 100)) * 100}%`
}}
/>
</div>
</div>
{playerState && (
<div className="combat-hp-bar-container-inline player-hp-bar" style={{ marginTop: '0.75rem' }}>
<div className="combat-hp-bar-inline" style={{ background: 'rgba(255, 107, 107, 0.2)' }}>
<div className="combat-stat-label-inline" style={{ color: '#ff6b6b' }}>
Your HP: {playerState.health} / {playerState.max_health}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${(playerState.health / playerState.max_health) * 100}%`,
background: 'linear-gradient(90deg, #f44336, #ff6b6b)'
}}
/>
</div>
</div>
)}
</div>
</div>
<div className={`combat-turn-indicator-inline ${enemyTurnMessage ? 'enemy-turn-message' : ''}`}>
{!combatState.combat_over ? (
enemyTurnMessage ? (
<span className="enemy-turn">🗡️ Enemy's turn...</span>
) : combatState.combat?.turn === 'player' ? (
<>
<span className="your-turn"> Your Turn</span>
{turnTimeRemaining !== null && (
<span className="turn-timer" style={{ marginLeft: '1rem', fontSize: '0.9em', opacity: 0.8 }}>
{Math.floor(turnTimeRemaining / 60)}:{String(Math.floor(turnTimeRemaining % 60)).padStart(2, '0')}
</span>
)}
</>
) : (
<span className="enemy-turn"> Enemy Turn</span>
)
) : (
<span className={combatState.player_won ? "your-turn" : combatState.player_fled ? "your-turn" : "enemy-turn"}>
{combatState.player_won ? "✅ Victory!" : combatState.player_fled ? "🏃 Escaped!" : "💀 Defeated"}
</span>
)}
</div>
{/* PvE Combat Actions */}
<div className="combat-actions-inline">
{!combatState.combat_over ? (
<>
<button
className="combat-action-btn attack-btn"
onClick={() => onCombatAction('attack')}
disabled={combatState.combat?.turn !== 'player' || !!enemyTurnMessage || buttonsDisabled}
>
Attack
</button>
<button
className="combat-action-btn flee-btn"
onClick={() => onCombatAction('flee')}
disabled={combatState.combat?.turn !== 'player' || !!enemyTurnMessage || buttonsDisabled}
>
🏃 Flee
</button>
</>
) : (
<button
className="combat-action-btn exit-btn"
onClick={onExitCombat}
>
{combatState.player_won ? '✅ Continue' : combatState.player_fled ? '✅ Continue' : '💀 Return'}
</button>
)}
</div>
{/* Combat Log */}
<div className="combat-log-wrapper">
<h3 className="combat-log-title">Combat Log</h3>
<div className="combat-log-inline">
<div className="log-entries">
{combatLog.map((entry: any, i: number) => (
<div key={i} className={`log-entry ${entry.isPlayer ? 'player-log' : 'enemy-log'}`}>
<span className="log-time">[{entry.time}]</span>
<span className="log-message">{entry.message}</span>
</div>
))}
{combatLog.length === 0 && <div className="log-entry"><span className="log-message">Combat started...</span></div>}
</div>
</div>
</div>
</div>
</>
)}
</div>
)
}
export default CombatView

View File

@@ -0,0 +1,759 @@
/* Weight and Volume Progress Bars */
.sidebar-progress-fill.weight {
background: linear-gradient(90deg, #ff9800, #f57c00);
}
.sidebar-progress-fill.volume {
background: linear-gradient(90deg, #9c27b0, #7b1fa2);
}
/* Inventory Tab - Full View */
.inventory-tab {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.inventory-tab h2 {
color: #6bb9f0;
margin-bottom: 1.5rem;
}
/* Modal Overlay */
.inventory-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
/* --- Redesigned Inventory Modal --- */
.inventory-modal-redesign {
display: flex;
flex-direction: column;
height: 85vh;
width: 95vw;
max-width: 1400px;
/* Match Workbench width */
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;
}
/* Top Bar */
.inventory-top-bar {
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;
flex-shrink: 0;
}
.inventory-capacity-summary {
display: flex;
gap: 2rem;
flex: 1;
}
.capacity-metric {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
max-width: 300px;
}
.metric-icon {
font-size: 1.5rem;
}
.metric-bar-container {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.metric-text {
font-size: 0.85rem;
color: #a0aec0;
}
.metric-bar {
height: 8px;
background: #2d3748;
border-radius: 4px;
overflow: hidden;
}
.metric-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.metric-fill.weight {
background: linear-gradient(90deg, #48bb78, #38a169);
}
.metric-fill.volume {
background: linear-gradient(90deg, #4299e1, #3182ce);
}
.inventory-backpack-info {
display: flex;
align-items: center;
gap: 1.5rem;
}
.backpack-status {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 1rem;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
}
.backpack-status.active {
border: 1px solid #48bb78;
color: #48bb78;
}
.backpack-status.inactive {
border: 1px solid #e53e3e;
color: #e53e3e;
}
.backpack-name {
font-weight: 600;
color: #fff;
}
.backpack-stats {
font-size: 0.85rem;
color: #a0aec0;
}
.close-btn {
background: none;
border: none;
color: #a0aec0;
font-size: 1.5rem;
cursor: pointer;
transition: color 0.2s;
}
.close-btn:hover {
color: #fff;
}
/* Main Layout */
.inventory-main-layout {
display: flex;
flex: 1;
overflow: hidden;
}
/* Sidebar Filters */
.inventory-sidebar-filters {
width: 220px;
background: rgba(0, 0, 0, 0.2);
border-right: 1px solid #3a4b5c;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow-y: auto;
}
.category-btn {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
color: #a0aec0;
cursor: pointer;
transition: all 0.2s;
text-align: left;
}
.category-btn:hover {
background: rgba(255, 255, 255, 0.05);
color: #fff;
}
.category-btn.active {
background: rgba(66, 153, 225, 0.15);
border-color: #4299e1;
color: #63b3ed;
}
.cat-icon {
font-size: 1.2rem;
width: 1.5rem;
display: flex;
justify-content: center;
align-items: center;
}
.cat-label {
font-weight: 500;
}
/* Content Area */
.inventory-content-area {
flex: 1;
display: flex;
flex-direction: column;
padding: 1.5rem;
overflow: hidden;
}
.inventory-search-bar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: rgba(0, 0, 0, 0.2);
border: 1px solid #3a4b5c;
border-radius: 8px;
margin-bottom: 1.5rem;
}
.inventory-search-bar input {
background: transparent;
border: none;
color: #fff;
font-size: 1rem;
flex: 1;
outline: none;
}
.inventory-items-grid {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding-right: 0.5rem;
}
/* Compact Item Card */
.inventory-item-card.compact {
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;
margin-bottom: 0.75rem;
/* Add separation between cards */
}
.inventory-item-card.compact:hover {
border-color: #63b3ed;
background: rgba(255, 255, 255, 0.06);
transform: translateY(-1px);
}
.item-image-section.small {
width: 100px;
height: 100px;
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;
margin: auto;
}
.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;
}
.item-info-section {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.4rem;
min-width: 0;
/* Prevent flex overflow */
}
.item-header-compact {
display: flex;
align-items: center;
gap: 0.5rem;
}
.item-emoji-inline {
font-size: 1.2rem;
}
.item-name-compact {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-description-compact {
margin: 0;
font-size: 0.85rem;
color: #a0aec0;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.item-tier-badge {
font-size: 0.7rem;
padding: 2px 6px;
border-radius: 4px;
background: #4a5568;
color: #e2e8f0;
font-weight: bold;
margin-left: auto;
}
/* Tier Colors */
.text-tier-0 {
color: #a0aec0;
}
/* Common - Gray */
.text-tier-1 {
color: #ffffff;
}
/* Uncommon - White */
.text-tier-2 {
color: #68d391;
}
/* Rare - Green */
.text-tier-3 {
color: #63b3ed;
}
/* Epic - Blue */
.text-tier-4 {
color: #9f7aea;
}
/* Legendary - Purple */
.text-tier-5 {
color: #ed8936;
}
/* Mythic - Orange */
.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);
}
.item-stats-row {
display: flex;
align-items: stretch;
/* Ensure separators stretch full height */
gap: 1rem;
margin-top: 0.25rem;
flex-wrap: nowrap;
/* Prevent wrapping to keep columns consistent */
}
.stat-group-fixed {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 140px;
border-right: 1px solid #4a5568;
padding-right: 1rem;
justify-content: center;
/* Center content vertically */
}
.stat-text {
font-size: 0.8rem;
color: #cbd5e0;
}
.stat-row-compact {
display: grid;
grid-template-columns: 20px 60px 1fr;
align-items: center;
font-size: 0.8rem;
color: #cbd5e0;
width: 100%;
}
.stat-row-compact .text-muted {
color: #718096;
font-size: 0.75rem;
text-align: left;
}
.item-description-compact {
font-size: 0.85rem;
color: #a0aec0;
margin-bottom: 0.5rem;
line-height: 1.4;
white-space: normal;
/* Ensure text wraps */
overflow-wrap: break-word;
/* Break long words if needed */
}
.stats-durability-column {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.25rem;
flex: 1;
min-width: 0;
justify-content: center;
/* Center content vertically */
}
.stat-badges-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.75rem;
}
.stat-badge {
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid;
display: flex;
align-items: center;
gap: 0.375rem;
font-weight: 600;
background-color: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
color: #e2e8f0;
}
/* Variant Colors */
.stat-badge.capacity,
.stat-badge.endurance,
.stat-badge.health {
background-color: rgba(16, 185, 129, 0.2);
color: #6ee7b7;
border-color: rgba(16, 185, 129, 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.crit,
.stat-badge.stamina {
background-color: rgba(234, 179, 8, 0.2);
color: #fde047;
border-color: rgba(234, 179, 8, 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);
}
/* Durability Bar Styles */
.durability-container {
width: 100%;
margin-top: 0.5rem;
}
.durability-header {
display: flex;
justify-content: space-between;
font-size: 0.65rem;
margin-bottom: 0.25rem;
color: rgba(255, 255, 255, 0.6);
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 500;
}
.durability-text-low {
color: #f87171;
}
.durability-track {
height: 0.5rem;
background-color: #374151;
border-radius: 9999px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.durability-fill {
height: 100%;
transition: width 0.3s ease, background-color 0.3s ease;
}
.durability-fill.high {
background-color: #10b981;
}
.durability-fill.medium {
background-color: #eab308;
}
.durability-fill.low {
background-color: #ef4444;
}
/* Actions Section */
.item-actions-section {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
border-left: 1px solid #4a5568;
padding-left: 1rem;
align-self: stretch;
flex-direction: column;
justify-content: flex-end;
width: 180px;
/* Fixed width for consistency */
min-width: 180px;
/* Ensure it doesn't shrink */
flex-shrink: 0;
}
.category-header {
grid-column: 1 / -1;
padding: 0.5rem 0;
color: #a0aec0;
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid #4a5568;
margin: 0.5rem 0;
}
.item-actions-section.bottom-right {
/* Deprecated class, keeping for safety but resetting styles if needed */
margin-top: 0;
align-self: center;
}
.action-btn {
padding: 0.4rem 0.8rem;
border: none;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.action-btn.use {
background: rgba(72, 187, 120, 0.2);
color: #48bb78;
border: 1px solid rgba(72, 187, 120, 0.4);
}
.action-btn.use:hover {
background: rgba(72, 187, 120, 0.3);
transform: translateY(-1px);
}
.action-btn.equip {
background: rgba(66, 153, 225, 0.2);
color: #4299e1;
border: 1px solid rgba(66, 153, 225, 0.4);
}
.action-btn.equip:hover {
background: rgba(66, 153, 225, 0.3);
transform: translateY(-1px);
}
.action-btn.unequip {
background: rgba(237, 137, 54, 0.2);
color: #ed8936;
border: 1px solid rgba(237, 137, 54, 0.4);
}
.action-btn.unequip:hover {
background: rgba(237, 137, 54, 0.3);
transform: translateY(-1px);
}
.drop-actions-group {
display: flex;
gap: 2px;
background: rgba(0, 0, 0, 0.2);
padding: 2px;
border-radius: 6px;
border: 1px solid rgba(245, 101, 101, 0.3);
}
.action-btn.drop {
background: transparent;
color: #f56565;
border: none;
padding: 0.3rem 0.6rem;
font-size: 0.75rem;
border-radius: 4px;
}
.action-btn.drop:hover {
background: rgba(245, 101, 101, 0.2);
}
.action-btn.drop.single {
/* Style for single drop button */
padding: 0.4rem 0.8rem;
font-size: 0.85rem;
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #718096;
gap: 1rem;
}
.empty-icon {
font-size: 3rem;
opacity: 0.5;
}
.item-card-equipped {
font-size: 0.7rem;
padding: 2px 6px;
border-radius: 4px;
background: rgba(66, 153, 225, 0.2);
color: #63b3ed;
border: 1px solid rgba(66, 153, 225, 0.4);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.item-stats-grid {
display: grid;
grid-template-columns: repeat(2, auto);
gap: 0.5rem 1rem;
align-items: center;
}

View File

@@ -0,0 +1,402 @@
import { MouseEvent, ChangeEvent } from 'react'
import { PlayerState, Profile, Equipment } from './types'
import './InventoryModal.css'
interface InventoryModalProps {
playerState: PlayerState
profile: Profile
equipment?: Equipment
inventoryFilter: string
inventoryCategoryFilter: string
onClose: () => void
onSetInventoryFilter: (filter: string) => void
onSetInventoryCategoryFilter: (category: string) => void
onUseItem: (itemId: number, invId: number) => void
onEquipItem: (invId: number) => void
onUnequipItem: (slot: string) => void
onDropItem: (itemId: number, invId: number, quantity: number) => void
}
function InventoryModal({
playerState,
profile,
equipment,
inventoryFilter,
inventoryCategoryFilter,
onClose,
onSetInventoryFilter,
onSetInventoryCategoryFilter,
onUseItem,
onEquipItem,
onUnequipItem,
onDropItem
}: InventoryModalProps) {
// Categories for the sidebar
const categories = [
{ id: 'all', label: 'All Items', icon: '🎒' },
{ id: 'weapon', label: 'Weapons', icon: '⚔️' },
{ id: 'armor', label: 'Armor', icon: '🛡️' },
{ id: 'clothing', label: 'Clothing', icon: '👕' },
{ id: 'backpack', label: 'Backpacks', icon: '🎒' },
{ id: 'tool', label: 'Tools', icon: '🛠️' },
{ id: 'consumable', label: 'Consumables', icon: '🍖' },
{ id: 'resource', label: 'Resources', icon: '📦' },
{ id: 'quest', label: 'Quest', icon: '📜' },
{ id: 'misc', label: 'Misc', icon: '📦' }
]
// Use inventory directly as it now includes equipped items
const allItems = playerState.inventory;
// Filter items based on search and category
const filteredItems = allItems
.filter((item: any) => {
const itemName = item.name || 'Unknown Item';
const matchesSearch = itemName.toLowerCase().includes(inventoryFilter.toLowerCase())
const matchesCategory = inventoryCategoryFilter === 'all' || item.type === inventoryCategoryFilter
return matchesSearch && matchesCategory
})
.sort((a: any, b: any) => {
// Equipped items first
if (a.is_equipped && !b.is_equipped) return -1;
if (!a.is_equipped && b.is_equipped) return 1;
return (a.name || '').localeCompare(b.name || '');
})
const renderItemCard = (item: any, i: number) => {
const maxDurability = item.max_durability;
const currentDurability = item.durability;
const hasDurability = maxDurability && maxDurability > 0;
return (
<div key={i} className={`inventory-item-card compact ${item.is_equipped ? 'equipped' : ''}`}>
{/* Left: Image/Icon */}
<div className="item-image-section small">
{item.image_path ? (
<img
src={item.image_path}
alt={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}` : ''} ${item.image_path ? 'hidden' : ''}`}>
{item.emoji || '📦'}
</div>
{item.quantity > 1 && <div className="item-quantity-badge">x{item.quantity}</div>}
</div>
{/* Center: Info & Stats */}
<div className="item-info-section">
<div className="item-header-compact">
<span className="item-emoji-inline">{item.emoji}</span>
<h4 className={`item-name-compact text-tier-${item.tier || 0}`}>{item.name}</h4>
{item.is_equipped && <span className="item-card-equipped">Equipped</span>}
</div>
<div className="item-stats-row">
{/* Fixed Weight/Volume Column */}
<div className="stat-group-fixed">
<div className="stat-row-compact">
<span></span>
<span>{item.weight}kg</span>
{item.quantity > 1 && <span className="text-muted">| {(item.weight * item.quantity).toFixed(1)}kg</span>}
</div>
<div className="stat-row-compact">
<span>📦</span>
<span>{item.volume}L</span>
{item.quantity > 1 && <span className="text-muted">| {(item.volume * item.quantity).toFixed(1)}L</span>}
</div>
</div>
{/* Stats & Durability */}
<div className="stats-durability-column">
{item.description && <p className="item-description-compact">{item.description}</p>}
{/* Stats Row - Button-like Badges */}
<div className="stat-badges-container">
{/* Capacity */}
{(item.unique_stats?.weight_capacity || item.stats?.weight_capacity) && (
<span className="stat-badge capacity">
+{item.unique_stats?.weight_capacity || item.stats?.weight_capacity}kg
</span>
)}
{(item.unique_stats?.volume_capacity || item.stats?.volume_capacity) && (
<span className="stat-badge capacity">
📦 +{item.unique_stats?.volume_capacity || item.stats?.volume_capacity}L
</span>
)}
{/* Combat */}
{(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} Pen
</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)}% Crit
</span>
)}
{(item.unique_stats?.accuracy || item.stats?.accuracy) && (
<span className="stat-badge accuracy">
👁 +{Math.round((item.unique_stats?.accuracy || item.stats?.accuracy) * 100)}% Acc
</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)}% Life
</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} STR
</span>
)}
{(item.unique_stats?.agility_bonus || item.stats?.agility_bonus) && (
<span className="stat-badge agility">
🏃 +{item.unique_stats?.agility_bonus || item.stats?.agility_bonus} AGI
</span>
)}
{(item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus) && (
<span className="stat-badge endurance">
🏋 +{item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus} END
</span>
)}
{(item.unique_stats?.hp_bonus || item.stats?.hp_bonus) && (
<span className="stat-badge health">
+{item.unique_stats?.hp_bonus || item.stats?.hp_bonus} HP max
</span>
)}
{(item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus) && (
<span className="stat-badge stamina">
+{item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus} Stm max
</span>
)}
{/* Consumables */}
{item.hp_restore && (
<span className="stat-badge health">
+{item.hp_restore} HP
</span>
)}
{item.stamina_restore && (
<span className="stat-badge stamina">
+{item.stamina_restore} Stm
</span>
)}
</div>
{/* Durability Bar */}
{hasDurability && (
<div className="durability-container">
<div className="durability-header">
<span>Durability</span>
<span className={
currentDurability < maxDurability * 0.2
? "durability-text-low"
: ""
}>
{currentDurability} / {maxDurability}
</span>
</div>
<div className="durability-track">
<div
className={`durability-fill ${currentDurability < maxDurability * 0.2
? "low"
: currentDurability < maxDurability * 0.5
? "medium"
: "high"
}`}
style={{
width: `${Math.min(100, Math.max(0, (currentDurability / maxDurability) * 100))}%`
}}
/>
</div>
</div>
)}
</div>
{/* Right: Actions */}
<div className="item-actions-section">
{item.consumable && (
<button className="action-btn use" onClick={() => onUseItem(item.item_id, item.id)}>Use</button>
)}
{item.equippable && !item.is_equipped && (
<button className="action-btn equip" onClick={() => onEquipItem(item.id)}>Equip</button>
)}
{item.is_equipped && (
<button className="action-btn unequip" onClick={() => onUnequipItem(item.slot)}>Unequip</button>
)}
<div className="drop-actions-group">
{item.quantity > 1 && (
<button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, 1)}>x1</button>
)}
{item.quantity >= 5 && (
<button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, 5)}>x5</button>
)}
{item.quantity >= 10 && (
<button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, 10)}>x10</button>
)}
<button className={`action-btn drop ${item.quantity === 1 ? 'single' : ''}`} onClick={() => onDropItem(item.item_id, item.id, item.quantity === 1 ? 1 : item.quantity)}>
{item.quantity === 1 ? 'Drop' : 'All'}
</button>
</div>
</div>
</div>
</div>
</div>
)
}
return (
<div className="workbench-overlay" onClick={(e: MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) onClose()
}}>
<div className="workbench-menu inventory-modal-redesign">
{/* Top Bar: Capacity & Backpack Info */}
<div className="inventory-top-bar">
<div className="inventory-capacity-summary">
<div className="capacity-metric">
<span className="metric-icon"></span>
<div className="metric-bar-container">
<div className="metric-text">
Weight: {(profile.current_weight || 0).toFixed(1)} / {profile.max_weight || 0} kg
</div>
<div className="metric-bar">
<div
className="metric-fill weight"
style={{ width: `${Math.min(100, ((profile.current_weight || 0) / (profile.max_weight || 1)) * 100)}%` }}
></div>
</div>
</div>
</div>
<div className="capacity-metric">
<span className="metric-icon">📦</span>
<div className="metric-bar-container">
<div className="metric-text">
Volume: {(profile.current_volume || 0).toFixed(1)} / {profile.max_volume || 0} L
</div>
<div className="metric-bar">
<div
className="metric-fill volume"
style={{ width: `${Math.min(100, ((profile.current_volume || 0) / (profile.max_volume || 1)) * 100)}%` }}
></div>
</div>
</div>
</div>
</div>
<div className="inventory-backpack-info">
{equipment?.backpack ? (
<div className="backpack-status active">
<span className="backpack-icon">🎒</span>
<span className="backpack-name">{equipment.backpack.name}</span>
<span className="backpack-stats">
(+{equipment.backpack.unique_stats?.weight_capacity || equipment.backpack.stats?.weight_capacity || 0}kg /
+{equipment.backpack.unique_stats?.volume_capacity || equipment.backpack.stats?.volume_capacity || 0}L)
</span>
</div>
) : (
<div className="backpack-status inactive">
<span className="backpack-icon">🚫</span>
<span>No Backpack Equipped</span>
</div>
)}
<button className="close-btn" onClick={onClose}></button>
</div>
</div>
<div className="inventory-main-layout">
{/* Left Sidebar: Categories */}
<div className="inventory-sidebar-filters">
{categories.map(cat => (
<button
key={cat.id}
className={`category-btn ${inventoryCategoryFilter === cat.id ? 'active' : ''}`}
onClick={() => onSetInventoryCategoryFilter(cat.id)}
>
<span className="cat-icon">{cat.icon}</span>
<span className="cat-label">{cat.label}</span>
</button>
))}
</div>
{/* Right Content: Search & List */}
<div className="inventory-content-area">
<div className="inventory-search-bar">
<span className="search-icon">🔍</span>
<input
type="text"
placeholder="Search items..."
value={inventoryFilter}
onChange={(e: ChangeEvent<HTMLInputElement>) => onSetInventoryFilter(e.target.value)}
/>
</div>
<div className="inventory-items-grid">
{filteredItems.length === 0 ? (
<div className="empty-state">
<span className="empty-icon">📦</span>
<p>No items found in this category</p>
</div>
) : (
inventoryCategoryFilter === 'all' ? (
<>
{/* Equipped */}
{filteredItems.some((i: any) => i.is_equipped) && (
<>
<div className="category-header"> Equipped</div>
{filteredItems.filter((i: any) => i.is_equipped).map((item: any, i: number) => renderItemCard(item, i))}
</>
)}
{/* Categories */}
{categories.filter(c => c.id !== 'all').map(cat => {
const categoryItems = filteredItems.filter((i: any) => !i.is_equipped && i.type === cat.id);
if (categoryItems.length === 0) return null;
return (
<div key={cat.id}>
<div className="category-header">{cat.icon} {cat.label}</div>
{categoryItems.map((item: any, i: number) => renderItemCard(item, i))}
</div>
);
})}
</>
) : (
filteredItems.map((item: any, i: number) => renderItemCard(item, i))
)
)}
</div>
</div>
</div>
</div >
</div >
)
}
export default InventoryModal

View File

@@ -0,0 +1,453 @@
import type { Location, PlayerState, CombatState, Profile, WorkbenchTab } from './types'
import Workbench from './Workbench'
interface LocationViewProps {
location: Location
playerState: PlayerState | null
combatState: CombatState | null
message: string
locationMessages: Array<{ time: string; message: string }>
expandedCorpse: string | null
corpseDetails: any
mobileMenuOpen: string
showCraftingMenu: boolean
showRepairMenu: boolean
workbenchTab: WorkbenchTab
craftableItems: any[]
repairableItems: any[]
uncraftableItems: any[]
craftFilter: string
repairFilter: string
uncraftFilter: string
craftCategoryFilter: string
profile: Profile | null
onSetMessage: (msg: string) => void
onInitiateCombat: (npcId: number) => void
onInitiatePvP: (playerId: number) => void
onPickup: (itemId: number, quantity: number) => void
onLootCorpse: (corpseId: string) => void
onLootCorpseItem: (corpseId: string, itemIndex: number | null) => void
onSetExpandedCorpse: (corpseId: string | null) => void
onOpenCrafting?: () => void
onOpenRepair?: () => void
onCloseCrafting: () => void
onSwitchWorkbenchTab: (tab: WorkbenchTab) => void
onSetCraftFilter: (filter: string) => void
onSetRepairFilter: (filter: string) => void
onSetUncraftFilter: (filter: string) => void
onSetCraftCategoryFilter: (category: string) => void
onCraft: (itemId: number) => void
onRepair: (uniqueItemId: string, inventoryId: number) => void
onUncraft: (uniqueItemId: string, inventoryId: number) => void
}
function LocationView({
location,
message,
locationMessages,
expandedCorpse,
corpseDetails,
mobileMenuOpen,
showCraftingMenu,
showRepairMenu,
workbenchTab,
craftableItems,
repairableItems,
uncraftableItems,
craftFilter,
repairFilter,
uncraftFilter,
craftCategoryFilter,
profile,
onSetMessage,
onInitiateCombat,
onInitiatePvP,
onPickup,
onLootCorpse,
onLootCorpseItem,
onSetExpandedCorpse,
onOpenCrafting,
onOpenRepair,
onCloseCrafting,
onSwitchWorkbenchTab,
onSetCraftFilter,
onSetRepairFilter,
onSetUncraftFilter,
onSetCraftCategoryFilter,
onCraft,
onRepair,
onUncraft
}: LocationViewProps) {
return (
<div className="location-view">
<div className="location-info">
<h2 className="centered-heading">
{location.name}
{location.danger_level !== undefined && location.danger_level === 0 && (
<span className="danger-badge danger-safe" title="Safe Zone"> Safe</span>
)}
{location.danger_level !== undefined && location.danger_level > 0 && (
<span className={`danger-badge danger-${location.danger_level}`} title={`Danger Level: ${location.danger_level}`}>
{location.danger_level}
</span>
)}
</h2>
{location.tags && location.tags.length > 0 && (
<div className="location-tags">
{location.tags.map((tag: string, i: number) => {
const isClickable = tag === 'workbench' || tag === 'repair_station'
const handleClick = () => {
if (tag === 'workbench' && onOpenCrafting) onOpenCrafting()
else if (tag === 'repair_station' && onOpenRepair) onOpenRepair()
}
return (
<span
key={i}
className={`location-tag tag-${tag} ${isClickable ? 'clickable' : ''}`}
title={isClickable ? `Click to ${tag === 'workbench' ? 'craft items' : 'repair items'}` : `This location has: ${tag}`}
onClick={isClickable ? handleClick : undefined}
style={isClickable ? { cursor: 'pointer' } : undefined}
>
{tag === 'workbench' && '🔧 Workbench'}
{tag === 'repair_station' && '🛠️ Repair Station'}
{tag === 'safe_zone' && '🛡️ Safe Zone'}
{tag === 'shop' && '🏪 Shop'}
{tag === 'shelter' && '🏠 Shelter'}
{tag === 'medical' && '⚕️ Medical'}
{tag === 'storage' && '📦 Storage'}
{tag === 'water_source' && '💧 Water'}
{tag === 'food_source' && '🍎 Food'}
{tag !== 'workbench' && tag !== 'repair_station' && tag !== 'safe_zone' && tag !== 'shop' && tag !== 'shelter' && tag !== 'medical' && tag !== 'storage' && tag !== 'water_source' && tag !== 'food_source' && `🏷️ ${tag}`}
</span>
)
})}
</div>
)}
{location.image_url && (
<div className="location-image-container">
<img
src={location.image_url}
alt={location.name}
className="location-image"
onError={(e: any) => (e.currentTarget.style.display = 'none')}
/>
</div>
)}
<div className="location-description-box">
<p className="location-description">{location.description}</p>
</div>
</div>
{message && (
<div className="message-box" onClick={() => onSetMessage('')}>
{message}
</div>
)}
{locationMessages.length > 0 && (
<div className="location-messages-log">
<h4>📜 Recent Activity</h4>
<div className="messages-scroll">
{locationMessages.slice(-10).reverse().map((msg, idx) => (
<div key={idx} className="location-message-item">
<span className="message-time">{msg.time}</span>
<span className="message-text">{msg.message}</span>
</div>
))}
</div>
</div>
)}
<div className={`ground-entities mobile-menu-panel bottom ${mobileMenuOpen === 'bottom' ? 'open' : ''}`}>
{/* Enemies */}
{location.npcs.filter((npc: any) => npc.type === 'enemy').length > 0 && (
<div className="entity-section enemies-section">
<h3> Enemies</h3>
<div className="entity-list">
{location.npcs.filter((npc: any) => npc.type === 'enemy').map((enemy: any, i: number) => (
<div key={i} className="entity-card enemy-card">
{enemy.id && (
<div className="entity-image">
<img
src={enemy.image_path ? `/${enemy.image_path}` : `/images/npcs/${enemy.name.toLowerCase().replace(/ /g, '_')}.webp`}
alt={enemy.name}
onError={(e: any) => { e.currentTarget.style.display = 'none' }}
/>
</div>
)}
<div className="entity-info">
<div className="entity-name enemy-name">{enemy.name}</div>
{enemy.level && <div className="entity-level">Lv. {enemy.level}</div>}
</div>
<button
className="entity-action-btn combat-btn"
onClick={() => onInitiateCombat(enemy.id)}
>
Fight
</button>
</div>
))}
</div>
</div>
)}
{/* Corpses */}
{location.corpses && location.corpses.length > 0 && (
<div className="entity-section corpses-section">
<h3>💀 Corpses</h3>
<div className="entity-list">
{location.corpses.map((corpse: any) => (
<div key={corpse.id} className="corpse-container">
<div className="entity-card corpse-card">
<div className="entity-info">
<div className="entity-name">{corpse.emoji} {corpse.name}</div>
<div className="corpse-loot-count">{corpse.loot_count} item(s)</div>
</div>
<button
className="entity-action-btn loot-btn"
onClick={() => onLootCorpse(String(corpse.id))}
disabled={corpse.loot_count === 0}
>
🔍 Examine
</button>
</div>
{expandedCorpse === String(corpse.id) && corpseDetails && corpseDetails.loot_items && (
<div className="corpse-details">
<div className="corpse-details-header">
<h4>Lootable Items:</h4>
<button
className="close-btn"
onClick={() => {
onSetExpandedCorpse(null)
}}
>
</button>
</div>
<div className="corpse-items-list">
{corpseDetails.loot_items.map((item: any) => (
<div key={item.index} className={`corpse-item ${!item.can_loot ? 'locked' : ''}`}>
<div className="corpse-item-info">
<div className="corpse-item-name">
{item.emoji} {item.item_name}
</div>
<div className="corpse-item-qty">
Qty: {item.quantity_min}{item.quantity_min !== item.quantity_max ? `-${item.quantity_max}` : ''}
</div>
{item.required_tool && (
<div className={`corpse-item-tool ${item.has_tool ? 'has-tool' : 'needs-tool'}`}>
🔧 {item.required_tool_name} {item.has_tool ? '✓' : '✗'}
</div>
)}
</div>
<button
className="corpse-item-loot-btn"
onClick={() => onLootCorpseItem(String(corpse.id), item.index)}
disabled={!item.can_loot}
title={!item.can_loot ? `Requires ${item.required_tool_name}` : 'Loot this item'}
>
{item.can_loot ? '📦 Loot' : '🔒'}
</button>
</div>
))}
</div>
<button
className="loot-all-btn"
onClick={() => onLootCorpseItem(String(corpse.id), null)}
>
📦 Loot All Available
</button>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Friendly NPCs */}
{location.npcs.filter((npc: any) => npc.type !== 'enemy').length > 0 && (
<div className="entity-section npcs-section">
<h3>👥 NPCs</h3>
<div className="entity-list">
{location.npcs.filter((npc: any) => npc.type !== 'enemy').map((npc: any, i: number) => (
<div key={i} className="entity-card npc-card">
<span className="entity-icon">🧑</span>
<div className="entity-info">
<div className="entity-name">{npc.name}</div>
{npc.level && <div className="entity-level">Lv. {npc.level}</div>}
</div>
<button className="entity-action-btn">Talk</button>
</div>
))}
</div>
</div>
)}
{/* Items on Ground */}
{location.items.length > 0 && (
<div className="entity-section items-section">
<h3>📦 Items on Ground</h3>
<div className="entity-list">
{location.items.map((item: any, i: number) => (
<div key={i} className="entity-card item-card">
{item.image_path ? (
<img
src={item.image_path}
alt={item.name}
className="entity-icon"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const icon = (e.target as HTMLImageElement).nextElementSibling;
if (icon) icon.classList.remove('hidden');
}}
/>
) : null}
<span className={`entity-icon ${item.image_path ? 'hidden' : ''}`}>{item.emoji || '📦'}</span>
<div className="entity-info">
<div className={`entity-name ${item.tier ? `tier-${item.tier}` : ''}`}>
{item.name || 'Unknown Item'}
</div>
{item.quantity > 1 && <div className="entity-quantity">×{item.quantity}</div>}
</div>
<div className="item-info-btn-container">
<button className="entity-action-btn info" title="Item Info">Info</button>
<div className="item-info-tooltip">
{item.description && <div className="item-tooltip-desc">{item.description}</div>}
{item.weight !== undefined && item.weight > 0 && (
<div className="item-tooltip-stat">
Weight: {item.weight}kg {item.quantity > 1 && `(Total: ${(item.weight * item.quantity).toFixed(2)}kg)`}
</div>
)}
{item.volume !== undefined && item.volume > 0 && (
<div className="item-tooltip-stat">
📦 Volume: {item.volume}L {item.quantity > 1 && `(Total: ${(item.volume * item.quantity).toFixed(2)}L)`}
</div>
)}
{item.hp_restore && item.hp_restore > 0 && (
<div className="item-tooltip-stat"> HP Restore: +{item.hp_restore}</div>
)}
{item.stamina_restore && item.stamina_restore > 0 && (
<div className="item-tooltip-stat"> Stamina Restore: +{item.stamina_restore}</div>
)}
{item.damage_min !== undefined && item.damage_max !== undefined && (item.damage_min > 0 || item.damage_max > 0) && (
<div className="item-tooltip-stat">
Damage: {item.damage_min}-{item.damage_max}
</div>
)}
{item.durability !== undefined && item.durability !== null && item.max_durability !== undefined && item.max_durability !== null && (
<div className="item-tooltip-stat">
🔧 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>
)}
</div>
</div>
{item.quantity === 1 ? (
<button
className="entity-action-btn pickup"
onClick={() => onPickup(item.id, 1)}
>
Pick Up
</button>
) : (
<div className="item-pickup-btn-container">
<button className="entity-action-btn pickup">Pick Up </button>
<div className="item-pickup-menu">
<button className="item-pickup-option" onClick={() => onPickup(item.id, 1)}>Pick Up 1</button>
{item.quantity >= 5 && (
<button className="item-pickup-option" onClick={() => onPickup(item.id, 5)}>Pick Up 5</button>
)}
{item.quantity >= 10 && (
<button className="item-pickup-option" onClick={() => onPickup(item.id, 10)}>Pick Up 10</button>
)}
<button className="item-pickup-option" onClick={() => onPickup(item.id, item.quantity)}>
Pick Up All ({item.quantity})
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Other Players */}
{location.other_players && location.other_players.length > 0 && (
<div className="entity-section players-section">
<h3>👥 Other Players</h3>
<div className="entity-list">
{location.other_players.map((player: any, i: number) => (
<div key={i} className="entity-card player-card">
<span className="entity-icon">🧍</span>
<div className="entity-info">
<div className="entity-name">{player.name || player.username}</div>
<div className="entity-level">Lv. {player.level}</div>
{player.level_diff !== undefined && (
<div className="level-diff">
{player.level_diff > 0 ? `+${player.level_diff}` : player.level_diff} levels
</div>
)}
</div>
{player.can_pvp && (
<button
className="pvp-btn"
onClick={() => onInitiatePvP(player.id)}
title={`Attack ${player.name || player.username}`}
>
Attack
</button>
)}
{!player.can_pvp && player.level_diff !== undefined && Math.abs(player.level_diff) > 3 && (
<div className="pvp-disabled-reason">Level difference too high</div>
)}
{!player.can_pvp && location.danger_level !== undefined && location.danger_level < 3 && (
<div className="pvp-disabled-reason">Area too safe for PvP</div>
)}
</div>
))}
</div>
</div>
)}
</div>
{(showCraftingMenu || showRepairMenu) && (
<Workbench
showCraftingMenu={showCraftingMenu}
showRepairMenu={showRepairMenu}
workbenchTab={workbenchTab}
craftableItems={craftableItems}
repairableItems={repairableItems}
uncraftableItems={uncraftableItems}
craftFilter={craftFilter}
repairFilter={repairFilter}
uncraftFilter={uncraftFilter}
craftCategoryFilter={craftCategoryFilter}
profile={profile}
onCloseCrafting={onCloseCrafting}
onSwitchTab={onSwitchWorkbenchTab}
onSetCraftFilter={onSetCraftFilter}
onSetRepairFilter={onSetRepairFilter}
onSetUncraftFilter={onSetUncraftFilter}
onSetCraftCategoryFilter={onSetCraftCategoryFilter}
onCraft={onCraft}
onRepair={onRepair}
onUncraft={onUncraft}
/>
)}
</div>
)
}
export default LocationView

View File

@@ -0,0 +1,255 @@
import type { Location, Profile, CombatState } from './types'
import { useState, useEffect } from 'react'
interface MovementControlsProps {
location: Location
profile: Profile
combatState: CombatState | null
movementCooldown: number
interactableCooldowns: Record<string, number>
onMove: (direction: string) => void
onInteract?: (interactableId: string, actionId: string) => void
}
function MovementControls({
location,
profile,
combatState,
movementCooldown,
interactableCooldowns,
onMove,
onInteract
}: MovementControlsProps) {
// Force re-render every second to update cooldown timers
const [, forceUpdate] = useState(0)
useEffect(() => {
const timer = setInterval(() => {
forceUpdate(prev => prev + 1)
}, 1000)
return () => clearInterval(timer)
}, [])
// Helper function to get direction details
const getDirectionDetail = (direction: string) => {
if (!location.directions_detailed) return null
return location.directions_detailed.find(d => d.direction === direction)
}
// Helper function to get stamina cost for a direction
const getStaminaCost = (direction: string): number => {
const detail = getDirectionDetail(direction)
return detail ? detail.stamina_cost : 5
}
// Helper function to get destination name for a direction
const getDestinationName = (direction: string): string => {
const detail = getDirectionDetail(direction)
return detail ? (detail.destination_name || detail.destination) : ''
}
// Helper function to get distance for a direction
const getDistance = (direction: string): number => {
const detail = getDirectionDetail(direction)
return detail ? detail.distance : 0
}
// Helper function to check if direction is available
const hasDirection = (direction: string): boolean => {
return location.directions.includes(direction)
}
// Helper function to render compass button
const renderCompassButton = (direction: string, arrow: string, className: string) => {
const available = hasDirection(direction)
const stamina = getStaminaCost(direction)
const destination = getDestinationName(direction)
const distance = getDistance(direction)
const insufficientStamina = profile ? profile.stamina < stamina : false
const disabled = !available || !!combatState || movementCooldown > 0 || insufficientStamina || (profile?.is_dead ?? false)
// Build detailed tooltip text
const tooltipText = profile?.is_dead ? 'You are dead' :
movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` :
combatState ? 'Cannot travel during combat' :
insufficientStamina ? `Not enough stamina (need ${stamina}, have ${profile?.stamina ?? 0})` :
available ? `${destination}\nDistance: ${distance}m\nStamina: ${stamina}` :
`Cannot go ${direction}`
return (
<button
onClick={() => onMove(direction)}
disabled={disabled}
className={`compass-btn ${className} ${disabled ? 'disabled' : ''}`}
title={tooltipText}
>
<span className="compass-arrow">{arrow}</span>
{available && movementCooldown > 0 ? (
<span className="compass-cost">{movementCooldown}s</span>
) : available && (
<span className="compass-cost">{stamina}</span>
)}
</button>
)
}
return (
<>
<div className="movement-controls">
<h3>🧭 Travel</h3>
<div className="compass-grid">
{/* Top row */}
{renderCompassButton('northwest', '↖️', 'nw')}
{renderCompassButton('north', '⬆️', 'n')}
{renderCompassButton('northeast', '↗️', 'ne')}
{/* Middle row */}
{renderCompassButton('west', '⬅️', 'w')}
<div className="compass-center">
<div className="compass-icon">🧭</div>
</div>
{renderCompassButton('east', '➡️', 'e')}
{/* Bottom row */}
{renderCompassButton('southwest', '↙️', 'sw')}
{renderCompassButton('south', '⬇️', 's')}
{renderCompassButton('southeast', '↘️', 'se')}
</div>
{/* Cooldown indicator */}
{movementCooldown > 0 && (
<div className="cooldown-indicator">
Wait {movementCooldown}s before moving
</div>
)}
{/* Special movements */}
<div className="special-moves">
{location.directions.includes('up') && (
<button
onClick={() => onMove('up')}
className="special-btn"
disabled={!!combatState || movementCooldown > 0}
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go up\nStamina: ${getStaminaCost('up')}`}
>
Up <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('up')}`}</span>
</button>
)}
{location.directions.includes('down') && (
<button
onClick={() => onMove('down')}
className="special-btn"
disabled={!!combatState || movementCooldown > 0}
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go down\nStamina: ${getStaminaCost('down')}`}
>
Down <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('down')}`}</span>
</button>
)}
{location.directions.includes('enter') && (
<button
onClick={() => onMove('enter')}
className="special-btn"
disabled={!!combatState || movementCooldown > 0}
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Enter\nStamina: ${getStaminaCost('enter')}`}
>
🚪 Enter <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('enter')}`}</span>
</button>
)}
{location.directions.includes('inside') && (
<button
onClick={() => onMove('inside')}
className="special-btn"
disabled={!!combatState || movementCooldown > 0}
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go inside\nStamina: ${getStaminaCost('inside')}`}
>
🚪 Inside <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('inside')}`}</span>
</button>
)}
{location.directions.includes('exit') && (
<button
onClick={() => onMove('exit')}
className="special-btn"
disabled={!!combatState || movementCooldown > 0}
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : 'Exit'}
>
🚪 Exit
</button>
)}
{location.directions.includes('outside') && (
<button
onClick={() => onMove('outside')}
className="special-btn"
disabled={!!combatState || movementCooldown > 0}
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go outside\nStamina: ${getStaminaCost('outside')}`}
>
🚪 Outside <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('outside')}`}</span>
</button>
)}
</div>
</div>
{/* Surroundings - outside movement controls */}
{location.interactables && location.interactables.length > 0 && (
<div className="interactables-section">
<h3>🌿 Surroundings</h3>
{location.interactables.map((interactable: any) => (
<div key={interactable.instance_id} className="interactable-card">
{interactable.image_path && (
<div className="interactable-image-container">
<img
src={`/${interactable.image_path}`}
alt={interactable.name}
className="interactable-image"
onError={(e: any) => {
e.currentTarget.style.display = 'none'
}}
/>
</div>
)}
<div className="interactable-content">
<div className="interactable-header">
<span className="interactable-name">{interactable.name}</span>
</div>
{interactable.actions && interactable.actions.length > 0 && (
<div className="interactable-actions">
{interactable.actions.map((action: any) => {
const cooldownKey = `${interactable.instance_id}:${action.id}`
const cooldownExpiry = interactableCooldowns[cooldownKey]
const now = Date.now() / 1000
const cooldownRemaining = cooldownExpiry && cooldownExpiry > now
? Math.ceil(cooldownExpiry - now)
: 0
return (
<button
key={action.id}
className="interact-btn"
disabled={!!combatState || cooldownRemaining > 0}
onClick={() => onInteract && onInteract(interactable.instance_id, action.id)}
title={
combatState
? 'Cannot interact during combat'
: cooldownRemaining > 0
? `Wait ${cooldownRemaining}s`
: action.description
}
>
{action.name}
<span className="stamina-cost">
{cooldownRemaining > 0 ? `${cooldownRemaining}s` : `${action.stamina_cost}`}
</span>
</button>
)
})}
</div>
)}
</div>
</div>
))}
</div>
)}
</>
)
}
export default MovementControls

View File

@@ -0,0 +1,327 @@
import { useState } from 'react'
import type { PlayerState, Profile, Equipment } from './types'
import InventoryModal from './InventoryModal'
interface PlayerSidebarProps {
playerState: PlayerState
profile: Profile | null
equipment: Equipment
inventoryFilter: string
inventoryCategoryFilter: string
mobileMenuOpen: string
onSetInventoryFilter: (filter: string) => void
onSetInventoryCategoryFilter: (category: string) => void
onUseItem: (itemId: number, invId: number) => void
onEquipItem: (invId: number) => void
onUnequipItem: (slot: string) => void
onDropItem: (itemId: number, invId: number, quantity: number) => void
onSpendPoint: (stat: string) => void
}
function PlayerSidebar({
playerState,
profile,
equipment,
inventoryFilter,
inventoryCategoryFilter,
mobileMenuOpen,
onSetInventoryFilter,
onSetInventoryCategoryFilter,
onUseItem,
onEquipItem,
onUnequipItem,
onDropItem,
onSpendPoint
}: PlayerSidebarProps) {
const [showInventory, setShowInventory] = useState(false)
const renderEquipmentSlot = (slot: string, item: any, emoji: string, label: string) => (
<div className={`equipment-slot ${item ? 'filled' : 'empty'}`}>
{item ? (
<>
<button className="equipment-unequip-btn" onClick={() => onUnequipItem(slot)} title="Unequip"></button>
<div className="equipment-item-content">
{item.image_path ? (
<img
src={item.image_path}
alt={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');
}}
/>
) : null}
<span className={`equipment-emoji ${item.image_path ? 'hidden' : ''}`}>{item.emoji}</span>
<span className={`equipment-name ${item.tier ? `tier-${item.tier}` : ''}`}>{item.name}</span>
{item.durability && item.durability !== null && (
<span className="equipment-durability">{item.durability}/{item.max_durability}</span>
)}
</div>
<div className="equipment-tooltip">
{item.description && <div className="item-tooltip-desc">{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) && (
<div className="item-tooltip-stat">
🛡 Armor: +{item.unique_stats?.armor || item.stats?.armor}
</div>
)}
{(item.unique_stats?.hp_max || item.stats?.hp_max) && (
<div className="item-tooltip-stat">
Max HP: +{item.unique_stats?.hp_max || item.stats?.hp_max}
</div>
)}
{(item.unique_stats?.stamina_max || item.stats?.stamina_max) && (
<div className="item-tooltip-stat">
Max Stamina: +{item.unique_stats?.stamina_max || item.stats?.stamina_max}
</div>
)}
{(item.unique_stats?.damage_min !== undefined || item.stats?.damage_min !== undefined) &&
(item.unique_stats?.damage_max !== undefined || item.stats?.damage_max !== undefined) && (
<div className="item-tooltip-stat">
Damage: {item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max}
</div>
)}
{(item.unique_stats?.weight_capacity || item.stats?.weight_capacity) && (
<div className="item-tooltip-stat">
Weight: +{item.unique_stats?.weight_capacity || item.stats?.weight_capacity}kg
</div>
)}
{(item.unique_stats?.volume_capacity || item.stats?.volume_capacity) && (
<div className="item-tooltip-stat">
📦 Volume: +{item.unique_stats?.volume_capacity || item.stats?.volume_capacity}L
</div>
)}
</>
)}
{item.durability !== undefined && item.durability !== null && (
<div className="item-tooltip-stat">
🔧 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>
)}
</div>
</>
) : (
<>
<span className="equipment-emoji">{emoji}</span>
<span className="equipment-slot-label">{label}</span>
</>
)}
</div>
)
return (
<div className={`right-sidebar mobile-menu-panel ${mobileMenuOpen === 'right' ? 'open' : ''}`}>
{/* Profile Stats */}
<div className="profile-sidebar">
<h3>👤 Character</h3>
<div className="sidebar-stat-bars">
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label"> 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>
</div>
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label"> 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>
</div>
</div>
</div>
{profile && (
<div className="sidebar-stats">
<div className="sidebar-stat-row">
<span className="sidebar-label">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"> 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"> Unspent:</span>
<span className="sidebar-value">{profile.unspent_points}</span>
</div>
)}
<div className="sidebar-divider"></div>
{/* Compact 2x2 Stats Grid */}
<div className="stats-grid">
<div className="sidebar-stat-row compact">
<span className="sidebar-label">💪 STR:</span>
<span className="sidebar-value">{profile.strength}</span>
{profile.unspent_points > 0 && (
<button className="stat-plus-btn" onClick={() => onSpendPoint('strength')}>+</button>
)}
</div>
<div className="sidebar-stat-row compact">
<span className="sidebar-label">🏃 AGI:</span>
<span className="sidebar-value">{profile.agility}</span>
{profile.unspent_points > 0 && (
<button className="stat-plus-btn" onClick={() => onSpendPoint('agility')}>+</button>
)}
</div>
<div className="sidebar-stat-row compact">
<span className="sidebar-label">🛡 END:</span>
<span className="sidebar-value">{profile.endurance}</span>
{profile.unspent_points > 0 && (
<button className="stat-plus-btn" onClick={() => onSpendPoint('endurance')}>+</button>
)}
</div>
<div className="sidebar-stat-row compact">
<span className="sidebar-label">🧠 INT:</span>
<span className="sidebar-value">{profile.intellect}</span>
{profile.unspent_points > 0 && (
<button className="stat-plus-btn" onClick={() => onSpendPoint('intellect')}>+</button>
)}
</div>
</div>
<div className="sidebar-divider"></div>
{/* Inventory Capacity - matching HP/Stamina/XP style */}
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label"> 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>
</div>
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label">📦 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>
</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'
}}
>
🎒 Open Inventory
</button>
</div>
)}
</div>
{/* Equipment Display - Proper Grid Layout */}
<div className="equipment-sidebar">
<h3> Equipment</h3>
<div className="equipment-grid">
{/* Row 1: Head */}
<div className="equipment-row">
{renderEquipmentSlot('head', equipment.head, '🪖', 'Head')}
</div>
{/* Row 2: Weapon, Torso, Backpack */}
<div className="equipment-row three-cols">
{renderEquipmentSlot('weapon', equipment.weapon, '⚔️', 'Weapon')}
{renderEquipmentSlot('torso', equipment.torso, '👕', 'Torso')}
{renderEquipmentSlot('backpack', equipment.backpack, '🎒', 'Backpack')}
</div>
{/* Row 3: Legs & Feet */}
<div className="equipment-row two-cols">
{renderEquipmentSlot('legs', equipment.legs, '👖', 'Legs')}
{renderEquipmentSlot('feet', equipment.feet, '👟', 'Feet')}
</div>
</div>
</div>
{/* Inventory Modal */}
{showInventory && profile && (
<InventoryModal
playerState={playerState}
profile={profile}
equipment={equipment}
inventoryFilter={inventoryFilter}
inventoryCategoryFilter={inventoryCategoryFilter}
onClose={() => setShowInventory(false)}
onSetInventoryFilter={onSetInventoryFilter}
onSetInventoryCategoryFilter={onSetInventoryCategoryFilter}
onUseItem={onUseItem}
onEquipItem={onEquipItem}
onUnequipItem={onUnequipItem}
onDropItem={onDropItem}
/>
)}
</div>
)
}
export default PlayerSidebar

View File

@@ -0,0 +1,262 @@
import type { PlayerState, Profile, Equipment } from './types'
interface PlayerSidebarProps {
playerState: PlayerState
profile: Profile | null
equipment: Equipment
inventoryFilter: string
inventoryCategoryFilter: string
mobileMenuOpen: string
onSetInventoryFilter: (filter: string) => void
onSetInventoryCategoryFilter: (category: string) => void
onUseItem: (itemId: number, invId: number) => void
onEquipItem: (itemId: number, invId: number) => void
onUnequipItem: (slot: string) => void
onDropItem: (itemId: number, invId: number, quantity: number) => void
onSpendPoint: (stat: string) => void
}
function PlayerSidebar({
playerState,
profile,
equipment,
inventoryFilter,
inventoryCategoryFilter,
mobileMenuOpen,
onSetInventoryFilter,
onSetInventoryCategoryFilter,
onUseItem,
onEquipItem,
onUnequipItem,
onDropItem,
onSpendPoint
}: PlayerSidebarProps) {
return (
<div className={`right-sidebar mobile-menu-panel ${mobileMenuOpen === 'right' ? 'open' : ''}`}>
{/* Profile Stats */}
<div className="profile-sidebar">
<h3>👤 Character</h3>
<div className="sidebar-stat-bars">
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label"> 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>
</div>
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label"> 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>
</div>
</div>
</div>
{profile && (
<div className="sidebar-stats">
<div className="sidebar-stat-row">
<span className="sidebar-label">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"> 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"> Unspent:</span>
<span className="sidebar-value">{profile.unspent_points}</span>
</div>
)}
<div className="sidebar-divider"></div>
<div className="sidebar-stat-row">
<span className="sidebar-label">💪 STR:</span>
<span className="sidebar-value">{profile.strength}</span>
{profile.unspent_points > 0 && (
<button className="stat-plus-btn" onClick={() => onSpendPoint('strength')}>+</button>
)}
</div>
<div className="sidebar-stat-row">
<span className="sidebar-label">🏃 AGI:</span>
<span className="sidebar-value">{profile.agility}</span>
{profile.unspent_points > 0 && (
<button className="stat-plus-btn" onClick={() => onSpendPoint('agility')}>+</button>
)}
</div>
<div className="sidebar-stat-row">
<span className="sidebar-label">🛡 END:</span>
<span className="sidebar-value">{profile.endurance}</span>
{profile.unspent_points > 0 && (
<button className="stat-plus-btn" onClick={() => onSpendPoint('endurance')}>+</button>
)}
</div>
<div className="sidebar-stat-row">
<span className="sidebar-label">🧠 INT:</span>
<span className="sidebar-value">{profile.intellect}</span>
{profile.unspent_points > 0 && (
<button className="stat-plus-btn" onClick={() => onSpendPoint('intellect')}>+</button>
)}
</div>
</div>
)}
</div>
{/* Equipment Display */}
<div className="equipment-sidebar">
<h3> Equipment</h3>
<div className="equipment-grid">
{Object.entries(equipment).map(([slot, item]: [string, any]) => (
<div key={slot} className="equipment-row">
<div className={`equipment-slot ${item ? 'filled' : 'empty'}`}>
{item ? (
<>
<button className="equipment-unequip-btn" onClick={() => onUnequipItem(slot)} title="Unequip"></button>
<div className="equipment-item-content">
<span className="equipment-emoji">{item.emoji}</span>
<span className={`equipment-name ${item.tier ? `tier-${item.tier}` : ''}`}>{item.name}</span>
{item.durability && item.durability !== null && (
<span className="equipment-durability">{item.durability}/{item.max_durability}</span>
)}
</div>
</>
) : (
<div className="equipment-slot-label">{slot}</div>
)}
</div>
</div>
))}
</div>
</div>
{/* Inventory */}
<div className="inventory-sidebar">
<h3>🎒 Inventory</h3>
{profile && (
<div className="inventory-capacity">
<div className="capacity-info">
<span> {(profile.current_weight || 0).toFixed(1)}/{profile.max_weight}kg</span>
<span>📦 {(profile.current_volume || 0).toFixed(1)}/{profile.max_volume}L</span>
</div>
</div>
)}
<div className="filter-box">
<input
type="text"
placeholder="🔍 Filter items..."
value={inventoryFilter}
onChange={(e) => onSetInventoryFilter(e.target.value)}
className="filter-input"
/>
<select
value={inventoryCategoryFilter}
onChange={(e) => onSetInventoryCategoryFilter(e.target.value)}
className="filter-select"
>
<option value="all">All</option>
<option value="weapon"> Weapons</option>
<option value="armor">🛡 Armor</option>
<option value="consumable">🍎 Consumables</option>
<option value="material">🪵 Materials</option>
<option value="tool">🔨 Tools</option>
<option value="other">📦 Other</option>
</select>
</div>
<div className="inventory-list">
{playerState.inventory
.filter((item: any) =>
item.name.toLowerCase().includes(inventoryFilter.toLowerCase()) &&
(inventoryCategoryFilter === 'all' || item.category === inventoryCategoryFilter)
)
.map((item: any, idx: number) => (
<div key={idx} className="inventory-item">
<div className="inventory-item-header">
<span className={`item-name ${item.tier ? `tier-${item.tier}` : ''}`}>
{item.emoji} {item.name}
</span>
{item.quantity > 1 && <span className="item-quantity">×{item.quantity}</span>}
</div>
{item.description && <p className="item-description">{item.description}</p>}
{item.durability !== undefined && item.durability !== null && (
<div className="durability-display">
<div className="durability-bar">
<div
className={`durability-fill ${(item.durability / item.max_durability * 100) === 100 ? 'full' : ''}`}
style={{ width: `${(item.durability / item.max_durability) * 100}%` }}
></div>
<span className="durability-text">{item.durability}/{item.max_durability}</span>
</div>
</div>
)}
<div className="inventory-item-actions">
{item.category === 'consumable' && (
<button
className="item-action-btn use-btn"
onClick={() => onUseItem(item.item_id, item.inventory_id)}
>
Use
</button>
)}
{item.slot && !item.is_equipped && (
<button
className="item-action-btn equip-btn"
onClick={() => onEquipItem(item.item_id, item.inventory_id)}
>
Equip
</button>
)}
<button
className="item-action-btn drop-btn"
onClick={() => {
const qty = item.quantity > 1
? parseInt(prompt(`Drop how many? (1-${item.quantity})`, '1') || '1')
: 1
if (qty > 0 && qty <= item.quantity) {
onDropItem(item.item_id, item.inventory_id, qty)
}
}}
>
Drop
</button>
</div>
</div>
))}
</div>
</div>
</div>
)
}
export default PlayerSidebar

View File

@@ -0,0 +1,608 @@
/* Workbench Overlay */
.workbench-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.workbench-menu {
width: 95vw;
max-width: 1400px;
height: 85vh;
background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
border: 1px solid #4a5568;
border-radius: 12px;
display: flex;
flex-direction: column;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
.workbench-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid #4a5568;
}
.workbench-header h3 {
margin: 0;
font-size: 1.5rem;
color: #e2e8f0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.workbench-tabs {
display: flex;
gap: 0.5rem;
}
.tab-btn {
background: transparent;
border: 1px solid transparent;
color: #a0aec0;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
}
.tab-btn:hover {
color: #fff;
background: rgba(255, 255, 255, 0.05);
}
.tab-btn.active {
background: #3182ce;
color: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.close-btn {
background: transparent;
border: none;
color: #a0aec0;
font-size: 1.5rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 4px;
transition: all 0.2s;
}
.close-btn:hover {
background: rgba(245, 101, 101, 0.2);
color: #f56565;
}
/* Workbench Layout */
.workbench-content-grid {
display: grid;
grid-template-columns: 220px 350px 1fr;
height: 100%;
overflow: hidden;
background: rgba(0, 0, 0, 0.2);
}
/* Column 1: Sidebar */
.workbench-sidebar {
background: rgba(0, 0, 0, 0.2);
border-right: 1px solid #3a4b5c;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow-y: auto;
}
.sidebar-title {
color: #a0aec0;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 0.5rem;
padding-left: 0.5rem;
}
.category-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.workbench-sidebar .category-btn {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
color: #a0aec0;
cursor: pointer;
transition: all 0.2s;
text-align: left;
}
.workbench-sidebar .category-btn:hover {
background: rgba(255, 255, 255, 0.05);
color: #fff;
}
.workbench-sidebar .category-btn.active {
background: rgba(66, 153, 225, 0.15);
border-color: #4299e1;
color: #63b3ed;
}
.workbench-sidebar .cat-icon {
font-size: 1.2rem;
width: 1.5rem;
display: flex;
justify-content: center;
align-items: center;
}
.workbench-sidebar .cat-label {
font-weight: 500;
}
/* Column 2: Items List */
.workbench-items-column {
display: flex;
flex-direction: column;
border-right: 1px solid #3a4b5c;
background: rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.workbench-filters {
padding: 1rem;
border-bottom: 1px solid #3a4b5c;
background: rgba(0, 0, 0, 0.1);
}
.workbench-items-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.workbench-item-card {
display: flex;
align-items: center;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
gap: 0.5rem;
}
.workbench-item-card:hover {
background: rgba(255, 255, 255, 0.06);
transform: translateX(2px);
}
.workbench-item-card.selected {
background: rgba(66, 153, 225, 0.1);
border-color: #4299e1;
}
.workbench-item-card.craftable {
border-left: 3px solid #4caf50;
}
.workbench-item-card.repairable {
border-left: 3px solid #ff9800;
}
.workbench-item-card.salvageable {
border-left: 3px solid #9c27b0;
}
.item-card-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.item-header-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.item-emoji {
font-size: 1.2rem;
}
.item-name {
font-weight: 600;
color: #e2e8f0;
font-size: 0.95rem;
}
.item-meta-row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
}
.tier-badge {
background: #2d3748;
padding: 1px 4px;
border-radius: 3px;
color: #a0aec0;
font-weight: bold;
}
.tier-badge.tier-1 {
color: #fff;
}
.tier-badge.tier-2 {
color: #68d391;
}
.tier-badge.tier-3 {
color: #63b3ed;
}
.tier-badge.tier-4 {
color: #9f7aea;
}
.tier-badge.tier-5 {
color: #ed8936;
}
.equipped-badge {
color: #48bb78;
font-weight: bold;
background: rgba(72, 187, 120, 0.1);
padding: 1px 4px;
border-radius: 3px;
}
.item-stats-mini {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-top: 0.25rem;
}
.stat-mini {
font-size: 0.7rem;
padding: 2px 6px;
background: rgba(0, 0, 0, 0.3);
border-radius: 3px;
color: #cbd5e0;
white-space: nowrap;
}
.stat-mini.durability {
color: #fbbf24;
}
.status-icon {
font-size: 1.2rem;
margin-left: 0.5rem;
}
/* Item Image Thumbnail */
.item-image-thumb {
width: 50px;
height: 50px;
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;
margin-right: 0.75rem;
}
.item-thumb-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.item-thumb-emoji {
font-size: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.item-thumb-emoji.hidden {
display: none;
}
/* Tier Colors for Item Names */
.item-name.text-tier-0 {
color: #a0aec0;
}
.item-name.text-tier-1 {
color: #ffffff;
}
.item-name.text-tier-2 {
color: #68d391;
}
.item-name.text-tier-3 {
color: #63b3ed;
}
.item-name.text-tier-4 {
color: #9f7aea;
}
.item-name.text-tier-5 {
color: #ed8936;
}
/* Condition Text for Salvage Tab */
.condition-text {
font-size: 0.75rem;
color: #a0aec0;
font-weight: 500;
}
/* Mini Progress Bar */
.mini-progress-bar {
height: 3px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
overflow: hidden;
margin-top: 0.25rem;
width: 100%;
}
.mini-progress-fill {
height: 100%;
border-radius: 2px;
}
.mini-progress-fill.good {
background: #48bb78;
}
.mini-progress-fill.warning {
background: #ed8936;
}
.mini-progress-fill.critical {
background: #f56565;
}
/* Repair Preview - Dual Color Durability Bar */
.repair-preview-bar {
height: 12px;
background: rgba(255, 255, 255, 0.1);
border-radius: 6px;
overflow: hidden;
position: relative;
margin-bottom: 0.5rem;
}
.repair-preview-current {
position: absolute;
height: 100%;
background: #ed8936;
transition: width 0.3s ease;
}
.repair-preview-restored {
position: absolute;
height: 100%;
background: #48bb78;
transition: width 0.3s ease, left 0.3s ease;
}
.repair-preview-text {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: #a0aec0;
margin-bottom: 0.5rem;
}
.repair-preview-text .current {
color: #ed8936;
}
.repair-preview-text .restored {
color: #48bb78;
}
/* Column 3: Details */
.workbench-details-column {
padding: 2rem;
overflow-y: auto;
display: flex;
flex-direction: column;
align-items: center;
background: rgba(0, 0, 0, 0.2);
}
.detail-header {
text-align: center;
margin-bottom: 2rem;
width: 100%;
max-width: 600px;
}
.detail-image-container {
width: 120px;
height: 120px;
margin: 0 auto 1.5rem auto;
border-radius: 12px;
overflow: hidden;
border: 2px solid #4a5568;
background: rgba(0, 0, 0, 0.3);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}
.detail-image {
width: 100%;
height: 100%;
object-fit: contain;
padding: 10px;
}
.detail-title {
font-size: 1.8rem;
color: #fff;
margin-bottom: 0.5rem;
}
.detail-description {
color: #a0aec0;
font-style: italic;
line-height: 1.5;
}
.detail-requirements {
width: 100%;
max-width: 600px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.detail-requirements h4 {
color: #63b3ed;
margin-bottom: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 0.5rem;
}
.requirement-item {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
font-size: 0.95rem;
}
.requirement-item.met {
color: #48bb78;
}
.requirement-item.missing {
color: #f56565;
}
.detail-actions {
width: 100%;
max-width: 600px;
margin-top: auto;
}
.craft-btn,
.repair-btn,
.uncraft-btn {
width: 100%;
padding: 1rem;
border-radius: 8px;
border: none;
font-weight: bold;
cursor: pointer;
transition: all 0.2s;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.craft-btn {
background: linear-gradient(135deg, #ecc94b 0%, #d69e2e 100%);
color: #1a202c;
}
.craft-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(236, 201, 75, 0.3);
}
.repair-btn {
background: linear-gradient(135deg, #48bb78 0%, #38a169 100%);
color: #fff;
}
.repair-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(72, 187, 120, 0.3);
}
.uncraft-btn {
background: linear-gradient(135deg, #f56565 0%, #c53030 100%);
color: #fff;
}
.uncraft-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(245, 101, 101, 0.3);
}
.craft-btn:disabled,
.repair-btn:disabled,
.uncraft-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
background: #4a5568;
color: #a0aec0;
}
.workbench-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #718096;
text-align: center;
}
/* Equipped Badge - Matches InventoryModal */
.item-card-equipped {
font-size: 0.7rem;
padding: 2px 6px;
border-radius: 4px;
background: rgba(66, 153, 225, 0.2);
color: #63b3ed;
border: 1px solid rgba(66, 153, 225, 0.4);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
display: inline-block;
vertical-align: middle;
}

View File

@@ -0,0 +1,641 @@
import { useState, useEffect, type MouseEvent, type ChangeEvent } from 'react'
import type { Profile, WorkbenchTab } from './types'
import './Workbench.css'
interface WorkbenchProps {
showCraftingMenu: boolean
showRepairMenu: boolean
workbenchTab: WorkbenchTab
craftableItems: any[]
repairableItems: any[]
uncraftableItems: any[]
craftFilter: string
repairFilter: string
uncraftFilter: string
craftCategoryFilter: string
profile: Profile | null
onCloseCrafting: () => void
onSwitchTab: (tab: WorkbenchTab) => void
onSetCraftFilter: (filter: string) => void
onSetRepairFilter: (filter: string) => void
onSetUncraftFilter: (filter: string) => void
onSetCraftCategoryFilter: (category: string) => void
onCraft: (itemId: number) => void
onRepair: (uniqueItemId: string, inventoryId: number) => void
onUncraft: (uniqueItemId: string, inventoryId: number) => void
}
function Workbench({
showCraftingMenu,
showRepairMenu,
workbenchTab,
craftableItems,
repairableItems,
uncraftableItems,
craftFilter,
repairFilter,
uncraftFilter,
craftCategoryFilter,
profile,
onCloseCrafting,
onSwitchTab,
onSetCraftFilter,
onSetRepairFilter,
onSetUncraftFilter,
onSetCraftCategoryFilter,
onCraft,
onRepair,
onUncraft
}: WorkbenchProps) {
const [selectedItem, setSelectedItem] = useState<any>(null)
// Reset selection when tab changes
useEffect(() => {
setSelectedItem(null)
}, [workbenchTab])
// Update selectedItem when items list changes (after repair/craft/salvage)
useEffect(() => {
if (selectedItem) {
const items = getItems()
// Find the updated item by unique_item_id or inventory_id
const updatedItem = items.find(item => {
if (selectedItem.unique_item_id && item.unique_item_id) {
return item.unique_item_id === selectedItem.unique_item_id
}
if (selectedItem.inventory_id && item.inventory_id) {
return item.inventory_id === selectedItem.inventory_id
}
return item.item_id === selectedItem.item_id
})
if (updatedItem) {
setSelectedItem(updatedItem)
} else {
// Item no longer exists (e.g., was salvaged)
setSelectedItem(null)
}
}
}, [craftableItems, repairableItems, uncraftableItems])
if (!showCraftingMenu && !showRepairMenu) return null
const getItems = () => {
switch (workbenchTab) {
case 'craft':
return craftableItems.filter(item =>
item.name.toLowerCase().includes(craftFilter.toLowerCase()) &&
(craftCategoryFilter === 'all' || item.category === craftCategoryFilter)
)
case 'repair':
return repairableItems
.filter(item => item.name.toLowerCase().includes(repairFilter.toLowerCase()))
.sort((a, b) => {
if (a.needs_repair && !b.needs_repair) return -1
if (!a.needs_repair && b.needs_repair) return 1
return 0
})
case 'uncraft':
return uncraftableItems.filter(item =>
item.name.toLowerCase().includes(uncraftFilter.toLowerCase())
)
default:
return []
}
}
const items = getItems()
const renderItemDetails = () => {
if (!selectedItem) {
return (
<div className="workbench-empty-state">
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🔧</div>
<h3>Select an item to view details</h3>
<p>Choose an item from the list on the left</p>
</div>
)
}
const item = selectedItem
const imagePath = item.image_path || `/images/items/${item.item_id || item.id}.webp`
return (
<>
<div className="detail-header">
<div className="detail-image-container">
{imagePath ? (
<img
src={imagePath}
alt={item.name}
className="detail-image"
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={`detail-image-fallback ${imagePath ? 'hidden' : ''}`} style={{ fontSize: '4rem' }}>
{item.emoji || '📦'}
</div>
</div>
<h2 className="detail-title">{item.emoji} {item.name}</h2>
{item.description && <p className="detail-description">{item.description}</p>}
{/* Base Stats Display for Crafting */}
{workbenchTab === 'craft' && (item.base_stats || item.stats) && (
<div style={{ marginTop: '1rem' }}>
<div className="item-stats" style={{ display: 'flex', gap: '1rem', justifyContent: 'center', flexWrap: 'wrap' }}>
{Object.entries(item.base_stats || item.stats).map(([key, value]) => {
const icons: Record<string, string> = {
weight_capacity: '⚖️ Weight',
volume_capacity: '📦 Volume',
armor: '🛡️ Armor',
hp_max: '❤️ Max HP',
stamina_max: '⚡ Max Stamina',
damage_min: '⚔️ Damage Min',
damage_max: '⚔️ Damage Max'
}
const label = icons[key] || key.replace('_', ' ')
const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : ''
return (
<div key={key} className="stat-badge" style={{ background: 'rgba(0,0,0,0.3)', padding: '0.3rem 0.6rem', borderRadius: '4px', fontSize: '0.9rem', color: '#ccc' }}>
<span style={{ color: '#aaa' }}>{label}:</span> <span style={{ color: '#fff', fontWeight: 'bold' }}>+{Math.round(Number(value))}{unit}</span>
</div>
)
})}
</div>
<p style={{ fontSize: '0.8rem', color: '#888', fontStyle: 'italic', marginTop: '0.5rem', textAlign: 'center' }}>
* Potential base stats. Actual stats may vary.
</p>
</div>
)}
{/* Stats Display for Repair/Salvage */}
{workbenchTab !== 'craft' && (item.unique_item_data?.unique_stats || item.unique_item_data || item.base_stats || item.stats) && (
<div className="item-stats" style={{ display: 'flex', gap: '1rem', justifyContent: 'center', marginTop: '1rem', flexWrap: 'wrap' }}>
{Object.entries(item.unique_item_data?.unique_stats ?? item.unique_item_data ?? item.base_stats ?? item.stats ?? {}).filter(([k]) => !['id', 'item_id', 'durability', 'max_durability', 'created_at', 'tier'].includes(k)).map(([key, value]) => {
const icons: Record<string, string> = {
weight_capacity: '⚖️ Weight',
volume_capacity: '📦 Volume',
armor: '🛡️ Armor',
hp_max: '❤️ Max HP',
stamina_max: '⚡ Max Stamina',
damage_min: '⚔️ Damage Min',
damage_max: '⚔️ Damage Max'
}
const label = icons[key] || key.replace('_', ' ')
const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : ''
return (
<div key={key} className="stat-badge" style={{ background: 'rgba(0,0,0,0.3)', padding: '0.3rem 0.6rem', borderRadius: '4px', fontSize: '0.9rem', color: '#ccc' }}>
<span style={{ color: '#aaa' }}>{label}:</span> <span style={{ color: '#fff', fontWeight: 'bold' }}>+{Math.round(Number(value))}{unit}</span>
</div>
)
})}
</div>
)}
</div>
{workbenchTab === 'craft' && (
<>
<div className="detail-requirements">
<h4>📊 Requirements</h4>
{item.craft_level && item.craft_level > 1 && (
<div className={`requirement-item ${item.meets_level ? 'met' : 'missing'}`}>
<span>Level {item.craft_level} Required</span>
<span>{item.meets_level ? '✅' : `❌ (Lv. ${profile?.level || 1})`}</span>
</div>
)}
{item.tools && item.tools.length > 0 && (
<>
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>Tools</h5>
{item.tools.map((tool: any, i: number) => (
<div key={i} className={`requirement-item ${tool.has_tool ? 'met' : 'missing'}`}>
<span>{tool.emoji} {tool.name}</span>
<span>
{tool.has_tool ? `${tool.tool_durability}/${tool.max_durability} (Cost: ${tool.durability_cost})` : `❌ Missing (Cost: ${tool.durability_cost})`}
</span>
</div>
))}
</>
)}
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>Materials</h5>
{item.materials && item.materials.length > 0 ? (
item.materials.map((mat: any, i: number) => (
<div key={i} className={`requirement-item ${mat.has_enough ? 'met' : 'missing'}`}>
<span>{mat.emoji} {mat.name}</span>
<span>{mat.available} / {mat.required}</span>
</div>
))
) : (
<div className="requirement-item met">
<span>No materials required</span>
</div>
)}
</div>
<div className="detail-actions">
<button
className="craft-btn"
disabled={!item.can_craft || (profile?.stamina || 0) < (item.stamina_cost || 1)}
onClick={() => onCraft(item.item_id)}
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
>
<span>
{!item.meets_level ? `Need Level ${item.craft_level}` :
!item.can_craft ? 'Missing Requirements' : '🔨 Craft Item'}
</span>
{item.can_craft && (
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{item.stamina_cost || 5} Stamina
</span>
)}
</button>
</div>
</>
)}
{workbenchTab === 'repair' && (
<>
<div className="detail-requirements">
<h4>🔧 Repair Status</h4>
{!item.needs_repair ? (
<p className="repair-info full-durability" style={{ textAlign: 'center', marginBottom: '1rem' }}> Item is in perfect condition</p>
) : (
<>
<div className="repair-preview-text">
<span className="current">Current: {item.durability_percent}%</span>
<span className="restored">After Repair: {Math.min(100, item.durability_percent + (item.repair_percentage || 0))}%</span>
</div>
<div className="repair-preview-bar">
<div
className="repair-preview-current"
style={{ width: `${item.durability_percent}%` }}
></div>
<div
className="repair-preview-restored"
style={{
left: `${item.durability_percent}%`,
width: `${Math.min(100 - item.durability_percent, item.repair_percentage || 0)}%`
}}
></div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem', fontSize: '0.85rem', color: '#aaa' }}>
<span>{item.current_durability}/{item.max_durability}</span>
<span>+{Math.round((item.max_durability || 0) * ((item.repair_percentage || 0) / 100))} durability</span>
</div>
</>
)}
{item.needs_repair && (
<>
{item.tools && item.tools.length > 0 && (
<>
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>Tools</h5>
{item.tools.map((tool: any, i: number) => (
<div key={i} className={`requirement-item ${tool.has_tool ? 'met' : 'missing'}`}>
<span>{tool.emoji} {tool.name}</span>
<span>
{tool.has_tool ? `${tool.tool_durability}/${tool.max_durability} (Cost: ${tool.durability_cost})` : `❌ Missing (Cost: ${tool.durability_cost})`}
</span>
</div>
))}
</>
)}
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>Materials</h5>
{item.materials.map((mat: any, i: number) => (
<div key={i} className={`requirement-item ${mat.has_enough ? 'met' : 'missing'}`}>
<span>{mat.emoji} {mat.name}</span>
<span>{mat.available} / {mat.quantity}</span>
</div>
))}
</>
)}
</div>
<div className="detail-actions">
<button
className="repair-btn"
disabled={!item.can_repair || (profile?.stamina || 0) < (item.stamina_cost || 1)}
onClick={() => onRepair(item.unique_item_id, item.inventory_id)}
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
>
<span>
{!item.needs_repair ? 'Already Full' :
!item.can_repair ? 'Missing Requirements' : '🛠️ Repair Item'}
</span>
{item.needs_repair && item.can_repair && (
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{item.stamina_cost || 3} Stamina
</span>
)}
</button>
</div>
</>
)}
{workbenchTab === 'uncraft' && (
<>
<div className="detail-requirements">
<h4> Salvage Preview</h4>
{/* Show durability bar if we have durability data */}
{(item.unique_item_data || item.durability_percent !== undefined) && (
<div className="durability-display" style={{ marginBottom: '1rem' }}>
<div className="durability-bar" style={{ height: '8px' }}>
<div
className={`durability-fill ${(item.unique_item_data?.durability_percent || item.durability_percent) === 100 ? 'full' : ''}`}
style={{ width: `${item.unique_item_data?.durability_percent || item.durability_percent || 0}%` }}
></div>
</div>
<div style={{ textAlign: 'right', fontSize: '0.8rem', marginTop: '0.2rem', color: '#aaa' }}>
Condition: {item.unique_item_data?.durability_percent || item.durability_percent || 0}%
</div>
</div>
)}
<div className="materials-list">
{(() => {
const durabilityRatio = item.unique_item_data?.durability_percent !== undefined
? (item.unique_item_data.durability_percent || 0) / 100
: item.durability_percent !== undefined
? (item.durability_percent || 0) / 100
: 1.0
const adjustedYield = (item.uncraft_yield || item.base_yield || []).map((mat: any) => ({
...mat,
adjusted_quantity: Math.round((mat.quantity || 0) * durabilityRatio)
}))
return (
<>
{durabilityRatio < 1.0 && (
<div className="uncraft-warning" style={{ marginBottom: '1rem', color: '#ffc107' }}>
Yield reduced by {Math.round((1 - durabilityRatio) * 100)}% due to damage
</div>
)}
{item.loss_chance && (
<div className="uncraft-warning" style={{ marginBottom: '1rem', color: '#ff9800' }}>
{Math.round(item.loss_chance * 100)}% chance to lose each material
</div>
)}
{adjustedYield.map((mat: any, i: number) => (
<div key={i} className="requirement-item met">
<span>{mat.emoji} {mat.name}</span>
<span>x{mat.adjusted_quantity}</span>
</div>
))}
</>
)
})()}
</div>
</div>
<div className="detail-actions">
<button
className="uncraft-btn"
disabled={(profile?.stamina || 0) < (item.stamina_cost || 1)}
onClick={() => {
if (window.confirm(`Are you sure you want to salvage ${item.name}? This cannot be undone.`)) {
onUncraft(item.unique_item_id, item.inventory_id)
}
}}
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', backgroundColor: '#d32f2f', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
>
<span> Salvage Item</span>
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{item.stamina_cost || 2} Stamina
</span>
</button>
</div>
</>
)}
</>
)
}
const categories = [
{ id: 'all', label: 'All', icon: '🎒' },
{ id: 'weapon', label: 'Weapons', icon: '⚔️' },
{ id: 'armor', label: 'Armor', icon: '🛡️' },
{ id: 'clothing', label: 'Clothing', icon: '👕' },
{ id: 'tool', label: 'Tools', icon: '🛠️' },
{ id: 'consumable', label: 'Consumables', icon: '🍖' },
{ id: 'resource', label: 'Resources', icon: '📦' },
{ id: 'misc', label: 'Misc', icon: '📦' }
]
return (
<div className="workbench-overlay" onClick={(e: MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) onCloseCrafting()
}}>
<div className="workbench-menu">
<div className="workbench-header">
<h3>🔧 Workbench</h3>
<div className="workbench-tabs">
<button
className={`tab-btn ${workbenchTab === 'craft' ? 'active' : ''}`}
onClick={() => onSwitchTab('craft')}
>
🔨 Craft
</button>
<button
className={`tab-btn ${workbenchTab === 'repair' ? 'active' : ''}`}
onClick={() => onSwitchTab('repair')}
>
🛠 Repair
</button>
<button
className={`tab-btn ${workbenchTab === 'uncraft' ? 'active' : ''}`}
onClick={() => onSwitchTab('uncraft')}
>
Salvage
</button>
</div>
<button className="close-btn" onClick={onCloseCrafting}></button>
</div>
<div className="workbench-content-grid">
{/* Column 1: Categories Sidebar */}
<div className="workbench-sidebar">
<h4 className="sidebar-title">Categories</h4>
<div className="category-list">
{categories.map(cat => (
<button
key={cat.id}
className={`category-btn ${craftCategoryFilter === cat.id ? 'active' : ''}`}
onClick={() => onSetCraftCategoryFilter(cat.id)}
>
<span className="cat-icon">{cat.icon}</span>
<span className="cat-label">{cat.label}</span>
</button>
))}
</div>
</div>
{/* Column 2: Items List */}
<div className="workbench-items-column">
<div className="workbench-filters">
<input
type="text"
placeholder="🔍 Filter items..."
value={workbenchTab === 'craft' ? craftFilter : workbenchTab === 'repair' ? repairFilter : uncraftFilter}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
if (workbenchTab === 'craft') onSetCraftFilter(e.target.value)
else if (workbenchTab === 'repair') onSetRepairFilter(e.target.value)
else onSetUncraftFilter(e.target.value)
}}
className="filter-input"
/>
</div>
<div className="workbench-items-list">
{items.filter(item => {
// Text search filter
const searchFilter = workbenchTab === 'craft' ? craftFilter : workbenchTab === 'repair' ? repairFilter : uncraftFilter
const matchesSearch = !searchFilter || item.name.toLowerCase().includes(searchFilter.toLowerCase())
// Category filter (apply to all tabs)
let matchesCategory = true
if (craftCategoryFilter !== 'all') {
// Assuming item has a 'type' property that matches category IDs
matchesCategory = item.type === craftCategoryFilter
}
return matchesSearch && matchesCategory
}).length === 0 ? (
<div className="empty-state">
{workbenchTab === 'craft' ? 'No craftable items found.' :
workbenchTab === 'repair' ? 'No repairable items found.' :
'No salvageable items found.'}
</div>
) : (
items
.filter(item => {
// Text search filter
const searchFilter = workbenchTab === 'craft' ? craftFilter : workbenchTab === 'repair' ? repairFilter : uncraftFilter
const matchesSearch = !searchFilter || item.name.toLowerCase().includes(searchFilter.toLowerCase())
// Category filter (apply to all tabs)
let matchesCategory = true
if (craftCategoryFilter !== 'all') {
// Assuming item has a 'type' property that matches category IDs
matchesCategory = item.type === craftCategoryFilter
}
return matchesSearch && matchesCategory
})
.map((item: any, idx: number) => {
const imagePath = item.image_path || `/images/items/${item.item_id || item.id}.webp`
return (
<div
key={item.unique_item_id || item.item_id || idx}
className={`workbench-item-card ${selectedItem === item ? 'selected' : ''} ${workbenchTab === 'craft' && item.can_craft ? 'craftable' : ''} ${workbenchTab === 'repair' && item.needs_repair ? 'repairable' : ''} ${workbenchTab === 'uncraft' && item.can_uncraft ? 'salvageable' : ''}`}
onClick={() => setSelectedItem(item)}
>
{/* Item Image/Icon */}
<div className="item-image-thumb">
{imagePath ? (
<img
src={imagePath}
alt={item.name}
className="item-thumb-img"
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-thumb-emoji ${imagePath ? 'hidden' : ''}`}>
{item.emoji || '📦'}
</div>
</div>
<div className="item-card-content">
<div className="item-header-row">
<span
className={`item-name ${(workbenchTab === 'repair' || workbenchTab === 'uncraft') && item.tier ? `text-tier-${item.tier}` : ''}`}
>
{item.name}
</span>
{item.location === 'equipped' && <span className="item-card-equipped" style={{ marginLeft: '0.5rem' }}>Equipped</span>}
</div>
<div className="item-meta-row">
</div>
{/* Stats display for repair/salvage items */}
{(workbenchTab === 'repair' || workbenchTab === 'uncraft') && (() => {
const statsSource = item.unique_item_data?.unique_stats ?? item.unique_item_data ?? item.base_stats ?? item.stats ?? {};
const damage_min = statsSource.damage_min;
const damage_max = statsSource.damage_max;
const armor = statsSource.armor;
return (damage_min || armor) ? (
<div className="item-stats-mini">
{damage_min && (
<span className="stat-mini"> {damage_min}-{damage_max}</span>
)}
{armor && (
<span className="stat-mini">🛡 {armor}</span>
)}
</div>
) : null;
})()}
{/* Condition bar for Salvage tab */}
{workbenchTab === 'uncraft' && item.durability_percent !== undefined && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div className="mini-progress-bar" style={{ flex: 1 }}>
<div
className={`mini-progress-fill ${item.durability_percent < 30 ? 'critical' : item.durability_percent < 70 ? 'warning' : 'good'}`}
style={{ width: `${item.durability_percent}%` }}
></div>
</div>
{(item.current_durability !== undefined && item.current_durability !== null) && (
<span className="stat-mini durability" style={{ flexShrink: 0 }}>🔧 {item.current_durability}/{item.max_durability}</span>
)}
</div>
)}
{/* Progress Bar for Repair tab */}
{workbenchTab === 'repair' && item.durability_percent !== undefined && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div className="mini-progress-bar" style={{ flex: 1 }}>
<div
className={`mini-progress-fill ${item.durability_percent < 30 ? 'critical' : item.durability_percent < 70 ? 'warning' : 'good'}`}
style={{ width: `${item.durability_percent}%` }}
></div>
</div>
{(item.current_durability !== undefined && item.current_durability !== null) && (
<span className="stat-mini durability" style={{ flexShrink: 0 }}>🔧 {item.current_durability}/{item.max_durability}</span>
)}
</div>
)}
</div>
</div>
)
})
)}
</div>
</div>
{/* Column 3: Details */}
<div className="workbench-details-column">
{renderItemDetails()}
</div>
</div>
</div>
</div>
)
}
export default Workbench

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
// Game-related TypeScript interfaces and types
export interface PlayerState {
location_id: string
location_name: string
health: number
max_health: number
stamina: number
max_stamina: number
inventory: any[]
status_effects: any[]
}
export interface DirectionDetail {
direction: string
stamina_cost: number
distance: number
destination: string
destination_name?: string
}
export interface Location {
id: string
name: string
description: string
directions: string[]
directions_detailed?: DirectionDetail[]
danger_level?: number
npcs: any[]
items: any[]
image_url?: string
interactables?: any[]
other_players?: any[]
corpses?: any[]
tags?: string[]
}
export interface Profile {
name: string
level: number
xp: number
hp: number
max_hp: number
stamina: number
max_stamina: number
strength: number
agility: number
endurance: number
intellect: number
unspent_points: number
is_dead: boolean
max_weight?: number
current_weight?: number
max_volume?: number
current_volume?: number
}
export interface CombatLogEntry {
time: string
message: string
isPlayer: boolean
}
export interface LocationMessage {
time: string
message: string
}
export interface Equipment {
[slot: string]: any
}
export interface CombatState {
is_pvp?: boolean
in_pvp_combat?: boolean
pvp_combat?: any
combat_over?: boolean
[key: string]: any
}
export type WorkbenchTab = 'craft' | 'repair' | 'uncraft'
export type MobileMenuState = 'none' | 'left' | 'right' | 'bottom'