Commit
This commit is contained in:
345
pwa/src/components/game/Combat.tsx
Normal file
345
pwa/src/components/game/Combat.tsx
Normal 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
|
||||
328
pwa/src/components/game/CombatEffects.css
Normal file
328
pwa/src/components/game/CombatEffects.css
Normal 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;
|
||||
}
|
||||
339
pwa/src/components/game/CombatView.tsx
Normal file
339
pwa/src/components/game/CombatView.tsx
Normal 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
|
||||
759
pwa/src/components/game/InventoryModal.css
Normal file
759
pwa/src/components/game/InventoryModal.css
Normal 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;
|
||||
}
|
||||
402
pwa/src/components/game/InventoryModal.tsx
Normal file
402
pwa/src/components/game/InventoryModal.tsx
Normal 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
|
||||
453
pwa/src/components/game/LocationView.tsx
Normal file
453
pwa/src/components/game/LocationView.tsx
Normal 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
|
||||
255
pwa/src/components/game/MovementControls.tsx
Normal file
255
pwa/src/components/game/MovementControls.tsx
Normal 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
|
||||
327
pwa/src/components/game/PlayerSidebar.tsx
Normal file
327
pwa/src/components/game/PlayerSidebar.tsx
Normal 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
|
||||
262
pwa/src/components/game/PlayerSidebar_OLD.tsx
Normal file
262
pwa/src/components/game/PlayerSidebar_OLD.tsx
Normal 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
|
||||
608
pwa/src/components/game/Workbench.css
Normal file
608
pwa/src/components/game/Workbench.css
Normal 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;
|
||||
}
|
||||
641
pwa/src/components/game/Workbench.tsx
Normal file
641
pwa/src/components/game/Workbench.tsx
Normal 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
|
||||
1117
pwa/src/components/game/hooks/useGameEngine.ts
Normal file
1117
pwa/src/components/game/hooks/useGameEngine.ts
Normal file
File diff suppressed because it is too large
Load Diff
82
pwa/src/components/game/types.ts
Normal file
82
pwa/src/components/game/types.ts
Normal 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'
|
||||
Reference in New Issue
Block a user