1080p layout fixes: responsive location sizing, dynamic entity limits, compass cleanup
@@ -54,8 +54,15 @@ for category in items locations npcs interactables characters placeholder static
|
||||
rm "$tmp"
|
||||
else
|
||||
# Standard conversion for other categories
|
||||
# If locations or interactables, crop to 16:9
|
||||
tmp="/tmp/${base}_clean.png"
|
||||
if [[ "$category" == "locations" || "$category" == "interactables" ]]; then
|
||||
convert "$img" -resize "16:9" "$tmp"
|
||||
else
|
||||
convert "$img" "$tmp"
|
||||
fi
|
||||
echo " ➜ Converting: $filename"
|
||||
cwebp "$img" -q 85 -o "$out_file" >/dev/null
|
||||
cwebp "$tmp" -q 85 -o "$out_file" >/dev/null
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 257 KiB After Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 262 KiB After Width: | Height: | Size: 223 KiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 205 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 154 KiB |
@@ -1,16 +1,17 @@
|
||||
html {
|
||||
overflow-y: scroll;
|
||||
/* Always show scrollbar to prevent layout shift */
|
||||
overflow: hidden;
|
||||
/* Lock the page — no document-level scrollbar */
|
||||
}
|
||||
|
||||
.game-container {
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--game-bg-app);
|
||||
color: var(--game-text-primary);
|
||||
position: relative;
|
||||
font-family: var(--game-font-main);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Death Overlay */
|
||||
@@ -317,7 +318,8 @@ html {
|
||||
.game-main {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Unified Search Bar */
|
||||
@@ -365,7 +367,6 @@ html {
|
||||
.explore-tab-desktop {
|
||||
display: grid;
|
||||
grid-template-columns: 380px 1fr 380px;
|
||||
gap: 1.5rem;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -374,9 +375,9 @@ html {
|
||||
.left-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
gap: var(--game-gap-md);
|
||||
overflow-y: auto;
|
||||
padding-right: 0.5rem;
|
||||
padding-right: var(--game-padding-md);
|
||||
}
|
||||
|
||||
/* Center Content */
|
||||
@@ -387,13 +388,21 @@ html {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Center Area (combat/location container) */
|
||||
.center-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Right Sidebar */
|
||||
.right-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
gap: var(--game-gap-md);
|
||||
overflow-y: auto;
|
||||
padding-left: 0.5rem;
|
||||
padding-left: var(--game-padding-md);
|
||||
}
|
||||
|
||||
/* Mobile fallback */
|
||||
@@ -411,11 +420,14 @@ html {
|
||||
|
||||
.location-info {
|
||||
background: var(--game-bg-panel);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: var(--game-padding-md);
|
||||
gap: var(--game-gap-md);
|
||||
margin-bottom: 0;
|
||||
border: 1px solid var(--game-border-color);
|
||||
box-shadow: var(--game-shadow-card);
|
||||
clip-path: var(--game-clip-path);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.location-info h2 {
|
||||
@@ -425,7 +437,7 @@ html {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
gap: var(--game-gap-md);
|
||||
flex-wrap: wrap;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
@@ -683,18 +695,19 @@ html {
|
||||
|
||||
.location-image-container {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 1rem auto;
|
||||
aspect-ratio: 10 / 7;
|
||||
max-width: var(--location-content-width);
|
||||
margin: 0 auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border: 2px solid rgba(255, 107, 107, 0.3);
|
||||
clip-path: var(--game-clip-path);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.location-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.message-box {
|
||||
@@ -709,8 +722,8 @@ html {
|
||||
.location-messages-log {
|
||||
background: var(--game-bg-panel);
|
||||
border: 1px solid var(--game-border-color);
|
||||
padding: 0.8rem;
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem 0.8rem;
|
||||
margin-top: 0;
|
||||
max-width: 100%;
|
||||
clip-path: var(--game-clip-path);
|
||||
}
|
||||
@@ -761,7 +774,7 @@ html {
|
||||
|
||||
.movement-controls {
|
||||
background: var(--game-bg-panel);
|
||||
padding: 1.5rem;
|
||||
padding: var(--game-padding-md);
|
||||
border: 1px solid var(--game-border-color);
|
||||
box-shadow: var(--game-shadow-card);
|
||||
clip-path: var(--game-clip-path);
|
||||
@@ -781,7 +794,7 @@ html {
|
||||
grid-template-columns: repeat(3, 80px);
|
||||
gap: 0.6rem;
|
||||
max-width: 260px;
|
||||
margin: 0 auto 1rem auto;
|
||||
margin: 0 auto 0 auto;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -1035,14 +1048,18 @@ html {
|
||||
/* Interactables Section */
|
||||
.interactables-section {
|
||||
background: var(--game-bg-panel);
|
||||
padding: 1.5rem;
|
||||
padding: var(--game-padding-md);
|
||||
border: 1px solid var(--game-border-color);
|
||||
clip-path: var(--game-clip-path);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--game-gap-md);
|
||||
}
|
||||
|
||||
.interactables-section h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
margin: 0 0 0.3rem 0;
|
||||
color: #ff6b6b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
body.no-scroll {
|
||||
@@ -1052,7 +1069,6 @@ body.no-scroll {
|
||||
/* Interactable Card with Image */
|
||||
.interactable-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid rgba(255, 193, 7, 0.4);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
@@ -1069,7 +1085,7 @@ body.no-scroll {
|
||||
|
||||
.interactable-image-container {
|
||||
width: 100%;
|
||||
aspect-ratio: 10 / 7;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
@@ -1080,7 +1096,7 @@ body.no-scroll {
|
||||
.interactable-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
@@ -1140,39 +1156,32 @@ body.no-scroll {
|
||||
|
||||
/* Ground Entities - NPCs and Items */
|
||||
.ground-entities {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
gap: var(--game-gap-md);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.entity-section {
|
||||
background: var(--game-bg-panel);
|
||||
padding: 1.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--game-border-color);
|
||||
clip-path: var(--game-clip-path);
|
||||
}
|
||||
|
||||
.entity-section h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
margin: 0 0 0.3rem 0;
|
||||
color: #ff6b6b;
|
||||
font-size: 1.2rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.entity-list {
|
||||
.entity-list:not(.grid-view) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding-right: 5px;
|
||||
/* Custom scrollbar */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--game-border-active) rgba(0, 0, 0, 0.3);
|
||||
/* Ensure overflow doesn't clip dropdowns if possible,
|
||||
but for scrolling lists we need overflow-y: auto.
|
||||
Dropdowns must use fixed position or Portal if they need to escape.
|
||||
However, we can try to make sure cards have z-index context */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Ensure cards handle their own stacking context */
|
||||
@@ -1735,20 +1744,20 @@ body.no-scroll {
|
||||
/* Profile Sidebar */
|
||||
.profile-sidebar {
|
||||
background: var(--game-bg-panel);
|
||||
padding: 1rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--game-border-color);
|
||||
clip-path: var(--game-clip-path);
|
||||
}
|
||||
|
||||
.profile-sidebar h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
margin: 0 0 0.3rem 0;
|
||||
color: #ff6b6b;
|
||||
font-size: 1.1rem;
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-stat-bars {
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.sidebar-stat-bar {
|
||||
@@ -1960,17 +1969,16 @@ body.no-scroll {
|
||||
/* Equipment Sidebar */
|
||||
.equipment-sidebar {
|
||||
background: var(--game-bg-panel);
|
||||
padding: 1rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--game-border-color);
|
||||
overflow: visible;
|
||||
clip-path: var(--game-clip-path);
|
||||
/* Allow tooltips to overflow */
|
||||
}
|
||||
|
||||
.equipment-sidebar h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
margin: 0 0 0.3rem 0;
|
||||
color: #ff6b6b;
|
||||
font-size: 1.1rem;
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -3516,14 +3524,13 @@ body.no-scroll {
|
||||
|
||||
/* Combat Log Styles */
|
||||
.combat-log-container {
|
||||
margin-top: 2rem;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 2px solid rgba(244, 67, 54, 0.3);
|
||||
padding: 1rem;
|
||||
max-width: 800px;
|
||||
border: 1px solid rgba(244, 67, 54, 0.3);
|
||||
padding: var(--game-padding-md);
|
||||
width: var(--location-content-width);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
clip-path: var(--game-clip-path-sm);
|
||||
clip-path: var(--game-clip-path);
|
||||
}
|
||||
|
||||
.combat-log-container h4 {
|
||||
@@ -3657,10 +3664,9 @@ body.no-scroll {
|
||||
.location-description-box {
|
||||
background: rgba(25, 26, 31, 0.6);
|
||||
border: 1px solid rgba(107, 185, 240, 0.3);
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
padding: var(--game-padding-md);
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
max-width: var(--location-content-width);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
box-sizing: border-box;
|
||||
@@ -4320,7 +4326,7 @@ body.no-scroll {
|
||||
.combat-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: var(--game-gap-md);
|
||||
/* min-height removed to fit content */
|
||||
justify-content: center;
|
||||
/* Center content vertically */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { useGameWebSocket } from '../hooks/useGameWebSocket'
|
||||
@@ -34,6 +34,28 @@ export default function GameHeader({
|
||||
const { currentCharacter, logout } = useAuth()
|
||||
const { t } = useTranslation()
|
||||
const [playerCount, setPlayerCount] = useState<number>(0)
|
||||
const [isFullscreen, setIsFullscreen] = useState<boolean>(!!document.fullscreenElement)
|
||||
|
||||
// Fullscreen toggle
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen().catch((err) => {
|
||||
console.error('Failed to enter fullscreen:', err)
|
||||
})
|
||||
} else {
|
||||
document.exitFullscreen().catch((err) => {
|
||||
console.error('Failed to exit fullscreen:', err)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement)
|
||||
}
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
||||
}, [])
|
||||
|
||||
// Get game state from context (undefined when outside GameProvider)
|
||||
const gameContext = useOptionalGame()
|
||||
@@ -149,6 +171,16 @@ export default function GameHeader({
|
||||
|
||||
<LanguageSelector />
|
||||
|
||||
<GameTooltip content={isFullscreen ? t('common.exitFullscreen', 'Exit Fullscreen') : t('common.fullscreen', 'Fullscreen')}>
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="header-icon-btn"
|
||||
aria-label={isFullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen'}
|
||||
>
|
||||
{isFullscreen ? '🗗' : '🗖'}
|
||||
</button>
|
||||
</GameTooltip>
|
||||
|
||||
<button
|
||||
onClick={() => navigate(`/profile/${currentCharacter?.id}`)}
|
||||
className={`username-link ${isOnOwnProfile ? 'active' : ''}`}
|
||||
|
||||
@@ -1,19 +1,4 @@
|
||||
/* Combat Layout */
|
||||
.combat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
/* More transparent/themed background */
|
||||
background: rgba(20, 20, 20, 0.6);
|
||||
backdrop-filter: blur(5px);
|
||||
padding: 1rem;
|
||||
color: white;
|
||||
position: relative;
|
||||
/* overflow: hidden; Removed to allow floating text to be seen */
|
||||
clip-path: var(--game-clip-path);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.glow-effect {
|
||||
box-shadow: 0 0 10px #ff4444, 0 0 20px #ff4444;
|
||||
@@ -25,16 +10,29 @@
|
||||
transition: filter 1s ease;
|
||||
}
|
||||
|
||||
.combat-main-content {
|
||||
background: var(--game-bg-panel);
|
||||
padding: var(--game-padding-md);
|
||||
gap: var(--game-gap-md);
|
||||
margin-bottom: 0;
|
||||
border: 1px solid var(--game-border-color);
|
||||
box-shadow: var(--game-shadow-card);
|
||||
clip-path: var(--game-clip-path);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Combat Scene: Location Background with NPC Overlay */
|
||||
.combat-scene-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 1rem auto;
|
||||
aspect-ratio: 10 / 7;
|
||||
max-width: var(--location-content-width);
|
||||
margin: 0 auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
clip-path: var(--game-clip-path);
|
||||
border: 2px solid rgba(255, 107, 107, 0.3);
|
||||
border: 1px solid rgba(255, 107, 107, 0.3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.combat-location-bg {
|
||||
@@ -497,10 +495,9 @@
|
||||
.combat-stats-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
gap: var(--game-gap-md);
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
max-width: var(--location-content-width);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
@@ -100,3 +100,8 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Entity "Show All" modal - wider like inventory */
|
||||
.game-modal-container.entity-show-all-modal {
|
||||
max-width: 900px;
|
||||
}
|
||||
@@ -1,15 +1,84 @@
|
||||
/* Location View Layout */
|
||||
.location-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
gap: var(--game-gap-md);
|
||||
}
|
||||
|
||||
.location-view .location-info {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.location-view .location-description-box {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.location-view .location-messages-log {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* "Show All" card for overflowing entity sections */
|
||||
.entity-show-more-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
aspect-ratio: 1;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px dashed var(--game-border-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.85rem;
|
||||
color: var(--game-text-secondary);
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.entity-show-more-card:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: var(--game-text-highlight);
|
||||
color: var(--game-text-highlight);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.entity-show-more-card .show-more-count {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.entity-show-more-card .show-more-label {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Entity modal - scrollable grid */
|
||||
.entity-modal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||
grid-auto-rows: max-content;
|
||||
align-content: start;
|
||||
gap: var(--game-gap-md);
|
||||
padding: var(--game-gap-md);
|
||||
}
|
||||
|
||||
/* Grid View Styles */
|
||||
|
||||
.entities-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.entity-section {
|
||||
flex: 1 1 300px;
|
||||
flex: 1 1 calc(50% - 0.25rem);
|
||||
min-width: calc(50% - 0.25rem);
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Ground item images */
|
||||
@@ -28,12 +97,23 @@
|
||||
}
|
||||
|
||||
.entity-list.grid-view {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.4rem;
|
||||
align-items: start;
|
||||
padding: 0.5rem;
|
||||
/* Ensure padding inside container */
|
||||
padding: 0.25rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.entity-list.grid-view>* {
|
||||
flex: 0 0 auto;
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
}
|
||||
|
||||
.entity-list.grid-view>.entity-show-more-card {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
}
|
||||
|
||||
/* Padded Image for enemies */
|
||||
@@ -110,21 +190,25 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
aspect-ratio: 1;
|
||||
padding: 0;
|
||||
/* Remove padding to let image fill */
|
||||
text-align: center;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid var(--game-border-color);
|
||||
border-radius: 0;
|
||||
/* User requested NO radius */
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Sidebar capacity row */
|
||||
.sidebar-capacity-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.entity-card.grid-card:hover {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Location, PlayerState, CombatState, Profile, WorkbenchTab } from './types'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAudio } from '../../contexts/AudioContext'
|
||||
import Workbench from './Workbench'
|
||||
@@ -11,8 +11,23 @@ import { getTranslatedText } from '../../utils/i18nUtils'
|
||||
import { DialogModal } from './DialogModal'
|
||||
import { TradeModal } from './TradeModal'
|
||||
import { ItemTooltipContent } from '../common/ItemTooltipContent'
|
||||
import { GameModal } from './GameModal'
|
||||
import './LocationView.css'
|
||||
|
||||
const CARD_SIZE = 90; // px - must match CSS
|
||||
const CARD_GAP = 6.4; // px - 0.4rem at default 16px font
|
||||
|
||||
/**
|
||||
* Calculate how many cards fit in a container of given width.
|
||||
* Returns the max number that fit in one row.
|
||||
*/
|
||||
function calcFitCount(containerWidth: number): number {
|
||||
if (containerWidth <= 0) return 3; // fallback
|
||||
// First card doesn't need a gap before it
|
||||
return Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_SIZE + CARD_GAP)));
|
||||
}
|
||||
|
||||
|
||||
interface LocationViewProps {
|
||||
location: Location
|
||||
playerState: PlayerState | null
|
||||
@@ -101,6 +116,36 @@ function LocationView({
|
||||
// Dropdown State
|
||||
const [activeDropdown, setActiveDropdown] = useState<string | null>(null)
|
||||
|
||||
// Entity "Show All" Modal State
|
||||
const [entityModal, setEntityModal] = useState<{ type: string; title: string } | null>(null)
|
||||
|
||||
// Measure entity-list container widths for dynamic fit
|
||||
const [sectionWidths, setSectionWidths] = useState<Record<string, number>>({});
|
||||
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
const measureSections = useCallback(() => {
|
||||
const newWidths: Record<string, number> = {};
|
||||
Object.entries(sectionRefs.current).forEach(([key, el]) => {
|
||||
if (el) newWidths[key] = el.clientWidth;
|
||||
});
|
||||
setSectionWidths(newWidths);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
measureSections();
|
||||
const observer = new ResizeObserver(() => measureSections());
|
||||
Object.values(sectionRefs.current).forEach(el => {
|
||||
if (el) observer.observe(el);
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, [measureSections, location]);
|
||||
|
||||
const getSectionRef = useCallback((key: string) => (el: HTMLDivElement | null) => {
|
||||
sectionRefs.current[key] = el;
|
||||
}, []);
|
||||
|
||||
const getFitCount = (sectionKey: string) => calcFitCount(sectionWidths[sectionKey] || 0);
|
||||
|
||||
// NPC Interaction State
|
||||
const [activeDialogNpc, setActiveDialogNpc] = useState<string | null>(null)
|
||||
const [showTradeModal, setShowTradeModal] = useState<boolean>(false)
|
||||
@@ -299,359 +344,424 @@ function LocationView({
|
||||
{/* Combined Entities Container for Grid Layout */}
|
||||
<div className="entities-container">
|
||||
{/* Enemies */}
|
||||
{location.npcs.filter((npc: any) => npc.type === 'enemy').length > 0 && (
|
||||
<div className="entity-section enemies-section">
|
||||
<h3>{t('location.enemies')}</h3>
|
||||
<div className="entity-list grid-view">
|
||||
{location.npcs.filter((npc: any) => npc.type === 'enemy').map((enemy: any) => {
|
||||
const isShaking = failedActionItemId == enemy.id;
|
||||
const id = `enemy-${enemy.id}`;
|
||||
{(() => {
|
||||
const enemies = location.npcs.filter((npc: any) => npc.type === 'enemy');
|
||||
if (enemies.length === 0) return null;
|
||||
const limit = getFitCount('enemies');
|
||||
const visibleEnemies = enemies.length > limit ? enemies.slice(0, limit - 1) : enemies;
|
||||
const hasMore = enemies.length > limit;
|
||||
|
||||
// Only render if valid
|
||||
if (!enemy || !enemy.id) return null;
|
||||
|
||||
return (
|
||||
<div key={enemy.id} className={`entity-card enemy-card grid-card ${isShaking ? 'shake-animation' : ''}`}
|
||||
onClick={(e) => handleDropdownClick(e, id)}
|
||||
>
|
||||
{/* Enemy Image */}
|
||||
{enemy.id && (
|
||||
<div className="entity-image padded-image">
|
||||
<img
|
||||
src={getAssetPath(enemy.image_path || `images/npcs/${(typeof enemy.name === 'string' ? enemy.name : enemy.name?.en || '').toLowerCase().replace(/ /g, '_')}.webp`)}
|
||||
alt={getTranslatedText(enemy.name)}
|
||||
onError={(e: any) => { e.currentTarget.style.display = 'none' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<GameTooltip content={
|
||||
<div>
|
||||
<div className="tooltip-title">{getTranslatedText(enemy.name)}</div>
|
||||
<div>{t('location.level')} {enemy.level}</div>
|
||||
<div style={{ color: '#f56565', fontSize: '0.8rem' }}>Click for actions</div>
|
||||
</div>
|
||||
}>
|
||||
<div className="grid-overlay"></div>
|
||||
</GameTooltip>
|
||||
|
||||
{/* Dropdown for Grid View */}
|
||||
{activeDropdown === id && (
|
||||
<GameDropdown
|
||||
isOpen={true}
|
||||
onClose={() => setActiveDropdown(null)}
|
||||
width="160px"
|
||||
>
|
||||
<div className="game-dropdown-header">{getTranslatedText(enemy.name)}</div>
|
||||
<GameButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => { onInitiateCombat(enemy.id); setActiveDropdown(null); }}
|
||||
style={{ width: '100%', justifyContent: 'flex-start' }}
|
||||
>
|
||||
⚔️ {t('common.fight')}
|
||||
</GameButton>
|
||||
<div className="game-dropdown-divider" />
|
||||
<div style={{ padding: '0.5rem', fontSize: '0.8rem', color: '#a0aec0' }}>
|
||||
{t('location.level')} {enemy.level}
|
||||
</div>
|
||||
</GameDropdown>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Corpses */}
|
||||
{location.corpses && location.corpses.length > 0 && (
|
||||
<div className="entity-section corpses-section">
|
||||
<h3>{t('location.corpses')}</h3>
|
||||
<div className="entity-list grid-view">
|
||||
{location.corpses.map((corpse: any) => (
|
||||
<div key={corpse.id} className="corpse-container">
|
||||
<div className="entity-card corpse-card grid-card"
|
||||
onClick={(e) => handleDropdownClick(e, `corpse-${corpse.id}`)}
|
||||
>
|
||||
<GameTooltip content={
|
||||
<div>
|
||||
<div className="tooltip-title">{corpse.emoji} {getTranslatedText(corpse.name)}</div>
|
||||
<div>{corpse.loot_count} {t('location.items')}</div>
|
||||
</div>
|
||||
}>
|
||||
<div className="grid-corpse-content">
|
||||
{corpse.image_path ? (
|
||||
<img
|
||||
src={getAssetPath(corpse.image_path)}
|
||||
alt={getTranslatedText(corpse.name)}
|
||||
className="corpse-image"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ fontSize: '2rem', display: corpse.image_path ? 'none' : 'block' }} className={corpse.image_path ? 'hidden' : ''}>
|
||||
{corpse.emoji}
|
||||
</div>
|
||||
<div className="corpse-loot-count">{corpse.loot_count} items</div>
|
||||
</div>
|
||||
</GameTooltip>
|
||||
|
||||
{activeDropdown === `corpse-${corpse.id}` && (
|
||||
<GameDropdown
|
||||
isOpen={true}
|
||||
onClose={() => setActiveDropdown(null)}
|
||||
width="160px"
|
||||
>
|
||||
<div className="game-dropdown-header">{getTranslatedText(corpse.name)}</div>
|
||||
<GameButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
playSfx('/audio/sfx/interact.wav')
|
||||
onLootCorpse(String(corpse.id))
|
||||
setActiveDropdown(null)
|
||||
}}
|
||||
disabled={corpse.loot_count === 0}
|
||||
style={{ width: '100%', justifyContent: 'flex-start' }}
|
||||
>
|
||||
🔍 {t('common.examine')}
|
||||
</GameButton>
|
||||
</GameDropdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Friendly NPCs */}
|
||||
{location.npcs.filter((npc: any) => npc.type !== 'enemy').length > 0 && (
|
||||
<div className="entity-section npcs-section">
|
||||
<h3>{t('location.npcs')}</h3>
|
||||
<div className="entity-list grid-view">
|
||||
{location.npcs.filter((npc: any) => npc.type !== 'enemy').map((npc: any, i: number) => (
|
||||
<div key={i} className="entity-card npc-card grid-card"
|
||||
onClick={() => handleNpcClick(npc)}
|
||||
style={{ cursor: 'pointer', position: 'relative' }}
|
||||
>
|
||||
{npc.image_path ? (
|
||||
<img src={getAssetPath(npc.image_path)} alt={getTranslatedText(npc.name)} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<span className="entity-icon" style={{ fontSize: '2.5rem' }}>🧑</span>
|
||||
)}
|
||||
|
||||
{renderIndicator(npc.id)}
|
||||
|
||||
<GameTooltip content={
|
||||
<div>
|
||||
<div className="tooltip-title">{getTranslatedText(npc.name)}</div>
|
||||
<div style={{ color: '#ff9800', fontSize: '0.8rem' }}>Click to Interact</div>
|
||||
</div>
|
||||
}>
|
||||
<div className="grid-overlay"></div>
|
||||
</GameTooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Items on Ground - Stable Sort */}
|
||||
{location.items.length > 0 && (
|
||||
<div className="entity-section items-section">
|
||||
<h3>{t('location.itemsOnGround')}</h3>
|
||||
<div className="entity-list grid-view">
|
||||
{[...location.items]
|
||||
.sort((a: any, b: any) => (a.id || 0) - (b.id || 0))
|
||||
.map((item: any, i: number) => {
|
||||
const isShaking = failedActionItemId == item.id;
|
||||
const itemId = `item-${item.id}-${i}`;
|
||||
|
||||
// Pickup Options Helper - Vertical Layout
|
||||
const renderPickupOptions = () => {
|
||||
const options = [];
|
||||
options.push({ label: `${t('common.pickUp')} (x1)`, qty: 1 });
|
||||
if (item.quantity >= 5) options.push({ label: `${t('common.pickUp')} (x5)`, qty: 5 });
|
||||
if (item.quantity >= 10) options.push({ label: `${t('common.pickUp')} (x10)`, qty: 10 });
|
||||
if (item.quantity > 1) options.push({ label: t('common.pickUpAll'), qty: item.quantity });
|
||||
|
||||
return (
|
||||
<div className="pickup-options-vertical">
|
||||
{options.map((opt) => (
|
||||
<GameButton
|
||||
key={opt.label}
|
||||
variant="success"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playSfx('/audio/sfx/pickup.wav');
|
||||
onPickup(Number(item.id), opt.qty);
|
||||
setActiveDropdown(null);
|
||||
}}
|
||||
style={{ width: '100%', justifyContent: 'center' }}
|
||||
>
|
||||
{opt.label}
|
||||
</GameButton>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="entity-section enemies-section">
|
||||
<h3>{t('location.enemies')}</h3>
|
||||
<div className="entity-list grid-view" ref={getSectionRef('enemies')}>
|
||||
{visibleEnemies.map((enemy: any) => {
|
||||
const isShaking = failedActionItemId == enemy.id;
|
||||
const id = `enemy-${enemy.id}`;
|
||||
if (!enemy || !enemy.id) return null;
|
||||
|
||||
return (
|
||||
<div key={itemId} className={`entity-card item-card grid-card ${isShaking ? 'shake-animation' : ''}`}
|
||||
onClick={(e) => handleDropdownClick(e, itemId)}
|
||||
<div key={enemy.id} className={`entity-card enemy-card grid-card ${isShaking ? 'shake-animation' : ''}`}
|
||||
onClick={(e) => handleDropdownClick(e, id)}
|
||||
>
|
||||
{enemy.id && (
|
||||
<div className="entity-image padded-image">
|
||||
<img
|
||||
src={getAssetPath(enemy.image_path || `images/npcs/${(typeof enemy.name === 'string' ? enemy.name : enemy.name?.en || '').toLowerCase().replace(/ /g, '_')}.webp`)}
|
||||
alt={getTranslatedText(enemy.name)}
|
||||
onError={(e: any) => { e.currentTarget.style.display = 'none' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<GameTooltip content={
|
||||
<div>
|
||||
<div className="tooltip-title">{getTranslatedText(enemy.name)}</div>
|
||||
<div>{t('location.level')} {enemy.level}</div>
|
||||
<div style={{ color: '#f56565', fontSize: '0.8rem' }}>Click for actions</div>
|
||||
</div>
|
||||
}>
|
||||
<div className="grid-overlay"></div>
|
||||
</GameTooltip>
|
||||
|
||||
{activeDropdown === id && (
|
||||
<GameDropdown
|
||||
isOpen={true}
|
||||
onClose={() => setActiveDropdown(null)}
|
||||
width="160px"
|
||||
>
|
||||
<div className="game-dropdown-header">{getTranslatedText(enemy.name)}</div>
|
||||
<GameButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => { onInitiateCombat(enemy.id); setActiveDropdown(null); }}
|
||||
style={{ width: '100%', justifyContent: 'flex-start' }}
|
||||
>
|
||||
⚔️ {t('common.fight')}
|
||||
</GameButton>
|
||||
<div className="game-dropdown-divider" />
|
||||
<div style={{ padding: '0.5rem', fontSize: '0.8rem', color: '#a0aec0' }}>
|
||||
{t('location.level')} {enemy.level}
|
||||
</div>
|
||||
</GameDropdown>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{hasMore && (
|
||||
<div className="entity-show-more-card" onClick={() => setEntityModal({ type: 'enemies', title: t('location.enemies') })}>
|
||||
<span className="show-more-count">+{enemies.length - (limit - 1)}</span>
|
||||
<span className="show-more-label">{t('common.showAll', 'Show All')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Corpses */}
|
||||
{(() => {
|
||||
const corpses = location.corpses || [];
|
||||
if (corpses.length === 0) return null;
|
||||
const limit = getFitCount('corpses');
|
||||
const visibleCorpses = corpses.length > limit ? corpses.slice(0, limit - 1) : corpses;
|
||||
const hasMore = corpses.length > limit;
|
||||
|
||||
return (
|
||||
<div className="entity-section corpses-section">
|
||||
<h3>{t('location.corpses')}</h3>
|
||||
<div className="entity-list grid-view" ref={getSectionRef('corpses')}>
|
||||
{visibleCorpses.map((corpse: any) => (
|
||||
<div key={corpse.id} className="corpse-container">
|
||||
<div className="entity-card corpse-card grid-card"
|
||||
onClick={(e) => handleDropdownClick(e, `corpse-${corpse.id}`)}
|
||||
>
|
||||
<GameTooltip content={
|
||||
<>
|
||||
<ItemTooltipContent item={item} />
|
||||
<div style={{ color: '#4caf50', fontSize: '0.8rem', marginTop: '0.5rem', textAlign: 'center' }}>Click to Interact</div>
|
||||
</>
|
||||
<div>
|
||||
<div className="tooltip-title">{corpse.emoji} {getTranslatedText(corpse.name)}</div>
|
||||
<div>{corpse.loot_count} {t('location.items')}</div>
|
||||
</div>
|
||||
}>
|
||||
<div className="grid-corpse-content">
|
||||
{item.image_path ? (
|
||||
{corpse.image_path ? (
|
||||
<img
|
||||
src={getAssetPath(item.image_path)}
|
||||
alt={getTranslatedText(item.name)}
|
||||
className="ground-item-image"
|
||||
src={getAssetPath(corpse.image_path)}
|
||||
alt={getTranslatedText(corpse.name)}
|
||||
className="corpse-image"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ fontSize: '2rem', display: item.image_path ? 'none' : 'block' }} className={item.image_path ? 'hidden' : ''}>
|
||||
{item.emoji}
|
||||
<div style={{ fontSize: '2rem', display: corpse.image_path ? 'none' : 'block' }} className={corpse.image_path ? 'hidden' : ''}>
|
||||
{corpse.emoji}
|
||||
</div>
|
||||
{item.quantity > 1 && (
|
||||
<div className="grid-quantity">x{item.quantity}</div>
|
||||
)}
|
||||
<div className="corpse-loot-count">{corpse.loot_count} items</div>
|
||||
</div>
|
||||
</GameTooltip>
|
||||
|
||||
{activeDropdown === itemId && (
|
||||
{activeDropdown === `corpse-${corpse.id}` && (
|
||||
<GameDropdown
|
||||
isOpen={true}
|
||||
onClose={() => setActiveDropdown(null)}
|
||||
width="200px" // Wider for split buttons
|
||||
width="160px"
|
||||
>
|
||||
<div className="game-dropdown-header">{getTranslatedText(item.name)} {item.quantity > 1 ? `(x${item.quantity})` : ''}</div>
|
||||
|
||||
{/* Primary Action: Pick Up 1 */}
|
||||
<div className="game-dropdown-header">{getTranslatedText(corpse.name)}</div>
|
||||
<GameButton
|
||||
variant="success"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="pickup-main-btn"
|
||||
onClick={() => {
|
||||
playSfx('/audio/sfx/pickup.wav');
|
||||
onPickup(Number(item.id), 1);
|
||||
setActiveDropdown(null);
|
||||
playSfx('/audio/sfx/interact.wav')
|
||||
onLootCorpse(String(corpse.id))
|
||||
setActiveDropdown(null)
|
||||
}}
|
||||
style={{ width: '100%', justifyContent: 'center', marginBottom: '8px' }}
|
||||
disabled={corpse.loot_count === 0}
|
||||
style={{ width: '100%', justifyContent: 'flex-start' }}
|
||||
>
|
||||
✋ {t('common.pickUp')}
|
||||
🔍 {t('common.examine')}
|
||||
</GameButton>
|
||||
|
||||
{/* Quantity Options if > 1 */}
|
||||
{item.quantity > 1 && (
|
||||
<>
|
||||
<div className="game-dropdown-divider" style={{ margin: '8px 0' }} />
|
||||
{renderPickupOptions()}
|
||||
</>
|
||||
)}
|
||||
</GameDropdown>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className="entity-show-more-card" onClick={() => setEntityModal({ type: 'corpses', title: t('location.corpses') })}>
|
||||
<span className="show-more-count">+{corpses.length - (limit - 1)}</span>
|
||||
<span className="show-more-label">{t('common.showAll', 'Show All')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Other Players */}
|
||||
{location.other_players && location.other_players.length > 0 && (
|
||||
<div className="entity-section players-section">
|
||||
<h3>👥 {t('location.otherPlayers', 'Other Players')}</h3>
|
||||
<div className="entity-list grid-view">
|
||||
{location.other_players.map((player: any, i: number) => {
|
||||
const playerId = `player-${player.id}-${i}`;
|
||||
const canPvP = player.can_pvp;
|
||||
{/* Friendly NPCs */}
|
||||
{(() => {
|
||||
const friendlyNpcs = location.npcs.filter((npc: any) => npc.type !== 'enemy');
|
||||
if (friendlyNpcs.length === 0) return null;
|
||||
const limit = getFitCount('npcs');
|
||||
const visibleNpcs = friendlyNpcs.length > limit ? friendlyNpcs.slice(0, limit - 1) : friendlyNpcs;
|
||||
const hasMore = friendlyNpcs.length > limit;
|
||||
|
||||
return (
|
||||
<div key={i} className="entity-card player-card grid-card"
|
||||
onClick={(e) => handleDropdownClick(e, playerId)}
|
||||
return (
|
||||
<div className="entity-section npcs-section">
|
||||
<h3>{t('location.npcs')}</h3>
|
||||
<div className="entity-list grid-view" ref={getSectionRef('npcs')}>
|
||||
{visibleNpcs.map((npc: any, i: number) => (
|
||||
<div key={i} className="entity-card npc-card grid-card"
|
||||
onClick={() => handleNpcClick(npc)}
|
||||
style={{ cursor: 'pointer', position: 'relative' }}
|
||||
>
|
||||
{npc.image_path ? (
|
||||
<img src={getAssetPath(npc.image_path)} alt={getTranslatedText(npc.name)} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<span className="entity-icon" style={{ fontSize: '2.5rem' }}>🧑</span>
|
||||
)}
|
||||
|
||||
{renderIndicator(npc.id)}
|
||||
|
||||
<GameTooltip content={
|
||||
<div>
|
||||
<div className="tooltip-title">{player.name || player.username}</div>
|
||||
<div>{t('location.level', 'Level')} {player.level}</div>
|
||||
{player.level_diff !== undefined && (
|
||||
<div style={{ fontSize: '0.8rem', color: player.level_diff > 0 ? '#f56565' : '#48bb78' }}>
|
||||
{player.level_diff > 0 ? `+${player.level_diff}` : player.level_diff} levels
|
||||
</div>
|
||||
)}
|
||||
<div style={{ color: '#ebf8ff', fontSize: '0.8rem', marginTop: '0.5rem' }}>Click for actions</div>
|
||||
<div className="tooltip-title">{getTranslatedText(npc.name)}</div>
|
||||
<div style={{ color: '#ff9800', fontSize: '0.8rem' }}>Click to Interact</div>
|
||||
</div>
|
||||
}>
|
||||
<div className="grid-corpse-content">
|
||||
{/* Placeholder for player image or avatar */}
|
||||
<div style={{ fontSize: '2.5rem' }}>
|
||||
🧍
|
||||
</div>
|
||||
<div className="grid-quantity" style={{ top: '2px', right: '2px', bottom: 'auto', background: 'rgba(49, 130, 206, 0.8)', borderColor: 'rgba(99, 179, 237, 0.4)' }}>
|
||||
Lv.{player.level}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid-overlay"></div>
|
||||
</GameTooltip>
|
||||
</div>
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className="entity-show-more-card" onClick={() => setEntityModal({ type: 'npcs', title: t('location.npcs') })}>
|
||||
<span className="show-more-count">+{friendlyNpcs.length - (limit - 1)}</span>
|
||||
<span className="show-more-label">{t('common.showAll', 'Show All')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{activeDropdown === playerId && (
|
||||
<GameDropdown
|
||||
isOpen={true}
|
||||
onClose={() => setActiveDropdown(null)}
|
||||
width="180px"
|
||||
>
|
||||
<div className="game-dropdown-header">{player.name || player.username}</div>
|
||||
{/* Items on Ground - Stable Sort */}
|
||||
{(() => {
|
||||
const sortedItems = [...location.items].sort((a: any, b: any) => (a.id || 0) - (b.id || 0));
|
||||
if (sortedItems.length === 0) return null;
|
||||
const limit = getFitCount('items');
|
||||
const visibleItems = sortedItems.length > limit ? sortedItems.slice(0, limit - 1) : sortedItems;
|
||||
const hasMore = sortedItems.length > limit;
|
||||
|
||||
{canPvP ? (
|
||||
<GameButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onInitiatePvP(player.id);
|
||||
setActiveDropdown(null);
|
||||
}}
|
||||
style={{ width: '100%', justifyContent: 'flex-start' }}
|
||||
>
|
||||
⚔️ {t('game.attack')}
|
||||
</GameButton>
|
||||
) : (
|
||||
<div style={{ padding: '0.5rem', fontSize: '0.8rem', color: '#a0aec0', fontStyle: 'italic' }}>
|
||||
{location.danger_level !== undefined && location.danger_level < 3
|
||||
? t('game.areaTooSafeForPvP')
|
||||
: (player.level_diff !== undefined && Math.abs(player.level_diff) > 3)
|
||||
? t('game.levelDifferenceTooHigh')
|
||||
: "PvP Unavailable"}
|
||||
</div>
|
||||
)}
|
||||
const renderItemCard = (item: any, i: number) => {
|
||||
const isShaking = failedActionItemId == item.id;
|
||||
const itemId = `item-${item.id}-${i}`;
|
||||
|
||||
<div className="game-dropdown-divider" />
|
||||
<div style={{ padding: '0.5rem', fontSize: '0.8rem', color: '#a0aec0' }}>
|
||||
Level {player.level}
|
||||
</div>
|
||||
</GameDropdown>
|
||||
const renderPickupOptions = () => {
|
||||
const options = [];
|
||||
options.push({ label: `${t('common.pickUp')} (x1)`, qty: 1 });
|
||||
if (item.quantity >= 5) options.push({ label: `${t('common.pickUp')} (x5)`, qty: 5 });
|
||||
if (item.quantity >= 10) options.push({ label: `${t('common.pickUp')} (x10)`, qty: 10 });
|
||||
if (item.quantity > 1) options.push({ label: t('common.pickUpAll'), qty: item.quantity });
|
||||
|
||||
return (
|
||||
<div className="pickup-options-vertical">
|
||||
{options.map((opt) => (
|
||||
<GameButton
|
||||
key={opt.label}
|
||||
variant="success"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playSfx('/audio/sfx/pickup.wav');
|
||||
onPickup(Number(item.id), opt.qty);
|
||||
setActiveDropdown(null);
|
||||
}}
|
||||
style={{ width: '100%', justifyContent: 'center' }}
|
||||
>
|
||||
{opt.label}
|
||||
</GameButton>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={itemId} className={`entity-card item-card grid-card ${isShaking ? 'shake-animation' : ''}`}
|
||||
onClick={(e) => handleDropdownClick(e, itemId)}
|
||||
>
|
||||
<GameTooltip content={
|
||||
<>
|
||||
<ItemTooltipContent item={item} />
|
||||
<div style={{ color: '#4caf50', fontSize: '0.8rem', marginTop: '0.5rem', textAlign: 'center' }}>Click to Interact</div>
|
||||
</>
|
||||
}>
|
||||
<div className="grid-corpse-content">
|
||||
{item.image_path ? (
|
||||
<img
|
||||
src={getAssetPath(item.image_path)}
|
||||
alt={getTranslatedText(item.name)}
|
||||
className="ground-item-image"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ fontSize: '2rem', display: item.image_path ? 'none' : 'block' }} className={item.image_path ? 'hidden' : ''}>
|
||||
{item.emoji}
|
||||
</div>
|
||||
{item.quantity > 1 && (
|
||||
<div className="grid-quantity">x{item.quantity}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</GameTooltip>
|
||||
|
||||
{activeDropdown === itemId && (
|
||||
<GameDropdown
|
||||
isOpen={true}
|
||||
onClose={() => setActiveDropdown(null)}
|
||||
width="200px"
|
||||
>
|
||||
<div className="game-dropdown-header">{getTranslatedText(item.name)} {item.quantity > 1 ? `(x${item.quantity})` : ''}</div>
|
||||
|
||||
<GameButton
|
||||
variant="success"
|
||||
size="sm"
|
||||
className="pickup-main-btn"
|
||||
onClick={() => {
|
||||
playSfx('/audio/sfx/pickup.wav');
|
||||
onPickup(Number(item.id), 1);
|
||||
setActiveDropdown(null);
|
||||
}}
|
||||
style={{ width: '100%', justifyContent: 'center', marginBottom: '8px' }}
|
||||
>
|
||||
✋ {t('common.pickUp')}
|
||||
</GameButton>
|
||||
|
||||
{item.quantity > 1 && (
|
||||
<>
|
||||
<div className="game-dropdown-divider" style={{ margin: '8px 0' }} />
|
||||
{renderPickupOptions()}
|
||||
</>
|
||||
)}
|
||||
</GameDropdown>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="entity-section items-section">
|
||||
<h3>{t('location.items')}</h3>
|
||||
<div className="entity-list grid-view" ref={getSectionRef('items')}>
|
||||
{visibleItems.map((item: any, i: number) => renderItemCard(item, i))}
|
||||
{hasMore && (
|
||||
<div className="entity-show-more-card" onClick={() => setEntityModal({ type: 'items', title: t('location.items') })}>
|
||||
<span className="show-more-count">+{sortedItems.length - (limit - 1)}</span>
|
||||
<span className="show-more-label">{t('common.showAll', 'Show All')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Other Players */}
|
||||
{(() => {
|
||||
const players = location.other_players || [];
|
||||
if (players.length === 0) return null;
|
||||
const limit = getFitCount('players');
|
||||
const visiblePlayers = players.length > limit ? players.slice(0, limit - 1) : players;
|
||||
const hasMore = players.length > limit;
|
||||
|
||||
const renderPlayerCard = (player: any, i: number) => {
|
||||
const playerId = `player-${player.id}-${i}`;
|
||||
const canPvP = player.can_pvp;
|
||||
|
||||
return (
|
||||
<div key={i} className="entity-card player-card grid-card"
|
||||
onClick={(e) => handleDropdownClick(e, playerId)}
|
||||
>
|
||||
<GameTooltip content={
|
||||
<div>
|
||||
<div className="tooltip-title">{player.name || player.username}</div>
|
||||
<div>{t('location.level', 'Level')} {player.level}</div>
|
||||
{player.level_diff !== undefined && (
|
||||
<div style={{ fontSize: '0.8rem', color: player.level_diff > 0 ? '#f56565' : '#48bb78' }}>
|
||||
{player.level_diff > 0 ? `+${player.level_diff}` : player.level_diff} levels
|
||||
</div>
|
||||
)}
|
||||
<div style={{ color: '#ebf8ff', fontSize: '0.8rem', marginTop: '0.5rem' }}>Click for actions</div>
|
||||
</div>
|
||||
}>
|
||||
<div className="grid-corpse-content">
|
||||
<div style={{ fontSize: '2.5rem' }}>
|
||||
🧍
|
||||
</div>
|
||||
<div className="grid-quantity" style={{ top: '2px', right: '2px', bottom: 'auto', background: 'rgba(49, 130, 206, 0.8)', borderColor: 'rgba(99, 179, 237, 0.4)' }}>
|
||||
Lv.{player.level}
|
||||
</div>
|
||||
</div>
|
||||
</GameTooltip>
|
||||
|
||||
{activeDropdown === playerId && (
|
||||
<GameDropdown
|
||||
isOpen={true}
|
||||
onClose={() => setActiveDropdown(null)}
|
||||
width="180px"
|
||||
>
|
||||
<div className="game-dropdown-header">{player.name || player.username}</div>
|
||||
|
||||
{canPvP ? (
|
||||
<GameButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onInitiatePvP(player.id);
|
||||
setActiveDropdown(null);
|
||||
}}
|
||||
style={{ width: '100%', justifyContent: 'flex-start' }}
|
||||
>
|
||||
⚔️ {t('game.attack')}
|
||||
</GameButton>
|
||||
) : (
|
||||
<div style={{ padding: '0.5rem', fontSize: '0.8rem', color: '#a0aec0', fontStyle: 'italic' }}>
|
||||
{location.danger_level !== undefined && location.danger_level < 3
|
||||
? t('game.areaTooSafeForPvP')
|
||||
: (player.level_diff !== undefined && Math.abs(player.level_diff) > 3)
|
||||
? t('game.levelDifferenceTooHigh')
|
||||
: "PvP Unavailable"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="game-dropdown-divider" />
|
||||
<div style={{ padding: '0.5rem', fontSize: '0.8rem', color: '#a0aec0' }}>
|
||||
Level {player.level}
|
||||
</div>
|
||||
</GameDropdown>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="entity-section players-section">
|
||||
<h3>👥 {t('location.otherPlayers', 'Other Players')}</h3>
|
||||
<div className="entity-list grid-view" ref={getSectionRef('players')}>
|
||||
{visiblePlayers.map((player: any, i: number) => renderPlayerCard(player, i))}
|
||||
{hasMore && (
|
||||
<div className="entity-show-more-card" onClick={() => setEntityModal({ type: 'players', title: t('location.players') })}>
|
||||
<span className="show-more-count">+{players.length - (limit - 1)}</span>
|
||||
<span className="show-more-label">{t('common.showAll', 'Show All')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Corpse Loot Overlay Modal */}
|
||||
{
|
||||
expandedCorpse && corpseDetails && corpseDetails.loot_items && (
|
||||
@@ -774,6 +884,159 @@ function LocationView({
|
||||
/>
|
||||
)
|
||||
}
|
||||
{/* Entity "Show All" Modal */}
|
||||
{entityModal && (
|
||||
<GameModal
|
||||
title={entityModal.title}
|
||||
onClose={() => setEntityModal(null)}
|
||||
className="entity-show-all-modal"
|
||||
>
|
||||
<div className="entity-modal-grid">
|
||||
{entityModal.type === 'enemies' && location.npcs
|
||||
.filter((npc: any) => npc.type === 'enemy')
|
||||
.map((enemy: any) => {
|
||||
const id = `modal-enemy-${enemy.id}`;
|
||||
return (
|
||||
<div key={enemy.id} className="entity-card enemy-card grid-card"
|
||||
onClick={(e) => handleDropdownClick(e, id)}
|
||||
>
|
||||
{enemy.id && (
|
||||
<div className="entity-image padded-image">
|
||||
<img
|
||||
src={getAssetPath(enemy.image_path || `images/npcs/${(typeof enemy.name === 'string' ? enemy.name : enemy.name?.en || '').toLowerCase().replace(/ /g, '_')}.webp`)}
|
||||
alt={getTranslatedText(enemy.name)}
|
||||
onError={(e: any) => { e.currentTarget.style.display = 'none' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<GameTooltip content={
|
||||
<div>
|
||||
<div className="tooltip-title">{getTranslatedText(enemy.name)}</div>
|
||||
<div>{t('location.level')} {enemy.level}</div>
|
||||
</div>
|
||||
}>
|
||||
<div className="grid-overlay"></div>
|
||||
</GameTooltip>
|
||||
{activeDropdown === id && (
|
||||
<GameDropdown isOpen={true} onClose={() => setActiveDropdown(null)} width="160px">
|
||||
<div className="game-dropdown-header">{getTranslatedText(enemy.name)}</div>
|
||||
<GameButton variant="danger" size="sm"
|
||||
onClick={() => { onInitiateCombat(enemy.id); setActiveDropdown(null); setEntityModal(null); }}
|
||||
style={{ width: '100%', justifyContent: 'flex-start' }}
|
||||
>
|
||||
⚔️ {t('common.fight')}
|
||||
</GameButton>
|
||||
</GameDropdown>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{entityModal.type === 'corpses' && (location.corpses || []).map((corpse: any) => (
|
||||
<div key={corpse.id} className="entity-card corpse-card grid-card"
|
||||
onClick={(e) => handleDropdownClick(e, `modal-corpse-${corpse.id}`)}
|
||||
>
|
||||
<div className="grid-corpse-content">
|
||||
{corpse.image_path ? (
|
||||
<img src={getAssetPath(corpse.image_path)} alt={getTranslatedText(corpse.name)} className="corpse-image"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||
) : <div style={{ fontSize: '2rem' }}>{corpse.emoji}</div>}
|
||||
<div className="corpse-loot-count">{corpse.loot_count} items</div>
|
||||
</div>
|
||||
{activeDropdown === `modal-corpse-${corpse.id}` && (
|
||||
<GameDropdown isOpen={true} onClose={() => setActiveDropdown(null)} width="160px">
|
||||
<div className="game-dropdown-header">{getTranslatedText(corpse.name)}</div>
|
||||
<GameButton variant="secondary" size="sm"
|
||||
onClick={() => { playSfx('/audio/sfx/interact.wav'); onLootCorpse(String(corpse.id)); setActiveDropdown(null); setEntityModal(null); }}
|
||||
disabled={corpse.loot_count === 0}
|
||||
style={{ width: '100%', justifyContent: 'flex-start' }}
|
||||
>
|
||||
🔍 {t('common.examine')}
|
||||
</GameButton>
|
||||
</GameDropdown>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{entityModal.type === 'npcs' && location.npcs
|
||||
.filter((npc: any) => npc.type !== 'enemy')
|
||||
.map((npc: any, i: number) => (
|
||||
<div key={i} className="entity-card npc-card grid-card"
|
||||
onClick={() => { handleNpcClick(npc); setEntityModal(null); }}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{npc.image_path ? (
|
||||
<img src={getAssetPath(npc.image_path)} alt={getTranslatedText(npc.name)} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : <span className="entity-icon" style={{ fontSize: '2.5rem' }}>🧑</span>}
|
||||
<div className="grid-overlay"></div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{entityModal.type === 'items' && [...location.items]
|
||||
.sort((a: any, b: any) => (a.id || 0) - (b.id || 0))
|
||||
.map((item: any, i: number) => {
|
||||
const itemId = `modal-item-${item.id}-${i}`;
|
||||
return (
|
||||
<div key={itemId} className="entity-card item-card grid-card"
|
||||
onClick={(e) => handleDropdownClick(e, itemId)}
|
||||
>
|
||||
<GameTooltip content={<ItemTooltipContent item={item} />}>
|
||||
<div className="grid-corpse-content">
|
||||
{item.image_path ? (
|
||||
<img src={getAssetPath(item.image_path)} alt={getTranslatedText(item.name)} className="ground-item-image"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||
) : <div style={{ fontSize: '2rem' }}>{item.emoji}</div>}
|
||||
{item.quantity > 1 && <div className="grid-quantity">x{item.quantity}</div>}
|
||||
</div>
|
||||
</GameTooltip>
|
||||
{activeDropdown === itemId && (
|
||||
<GameDropdown isOpen={true} onClose={() => setActiveDropdown(null)} width="200px">
|
||||
<div className="game-dropdown-header">{getTranslatedText(item.name)}</div>
|
||||
<GameButton variant="success" size="sm"
|
||||
onClick={() => { playSfx('/audio/sfx/pickup.wav'); onPickup(Number(item.id), 1); setActiveDropdown(null); setEntityModal(null); }}
|
||||
style={{ width: '100%', justifyContent: 'center' }}
|
||||
>
|
||||
✋ {t('common.pickUp')}
|
||||
</GameButton>
|
||||
</GameDropdown>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{entityModal.type === 'players' && (location.other_players || []).map((player: any, i: number) => {
|
||||
const playerId = `modal-player-${player.id}-${i}`;
|
||||
return (
|
||||
<div key={i} className="entity-card player-card grid-card"
|
||||
onClick={(e) => handleDropdownClick(e, playerId)}
|
||||
>
|
||||
<div className="grid-corpse-content">
|
||||
<div style={{ fontSize: '2.5rem' }}>🧍</div>
|
||||
<div className="grid-quantity" style={{ top: '2px', right: '2px', bottom: 'auto', background: 'rgba(49, 130, 206, 0.8)', borderColor: 'rgba(99, 179, 237, 0.4)' }}>
|
||||
Lv.{player.level}
|
||||
</div>
|
||||
</div>
|
||||
{activeDropdown === playerId && (
|
||||
<GameDropdown isOpen={true} onClose={() => setActiveDropdown(null)} width="180px">
|
||||
<div className="game-dropdown-header">{player.name || player.username}</div>
|
||||
{player.can_pvp ? (
|
||||
<GameButton variant="danger" size="sm"
|
||||
onClick={() => { onInitiatePvP(player.id); setActiveDropdown(null); setEntityModal(null); }}
|
||||
style={{ width: '100%', justifyContent: 'flex-start' }}
|
||||
>
|
||||
⚔️ {t('game.attack')}
|
||||
</GameButton>
|
||||
) : (
|
||||
<div style={{ padding: '0.5rem', fontSize: '0.8rem', color: '#a0aec0' }}>PvP Unavailable</div>
|
||||
)}
|
||||
</GameDropdown>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</GameModal>
|
||||
)}
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
@@ -173,13 +173,7 @@ function MovementControls({
|
||||
return (
|
||||
<>
|
||||
<div className="movement-controls">
|
||||
<h3 className={movementCooldown > 0 ? 'cooldown-active' : ''}>
|
||||
{movementCooldown > 0 ? (
|
||||
<span key="timer">⏳ {t('messages.waitBeforeMovingSimple', { seconds: movementCooldown })}</span>
|
||||
) : (
|
||||
<span key="title">{t('game.travel')}</span>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<div className="compass-grid">
|
||||
{/* Top row */}
|
||||
{renderCompassButton('northwest', '↖️', 'nw')}
|
||||
@@ -197,9 +191,8 @@ function MovementControls({
|
||||
{renderCompassButton('southeast', '↘️', 'se')}
|
||||
</div>
|
||||
|
||||
{/* Special movements (Vertical only now, since Enter/Exit are in center) */}
|
||||
{(location.directions.includes('up') || location.directions.includes('down')) && (
|
||||
<div className="special-moves">
|
||||
<div className="special-moves" style={{ display: 'flex', justifyContent: 'center', gap: '0.5rem' }}>
|
||||
{location.directions.includes('up') && (
|
||||
<GameTooltip content={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : (
|
||||
<div className="movement-tooltip">
|
||||
@@ -209,8 +202,9 @@ function MovementControls({
|
||||
)}>
|
||||
<button
|
||||
onClick={() => onMove('up')}
|
||||
className="special-btn"
|
||||
className="compass-center-btn"
|
||||
disabled={!!combatState || movementCooldown > 0}
|
||||
style={{ padding: '0.3rem 1rem' }}
|
||||
>
|
||||
⬆️ {t('directions.up')} <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('up')}`}</span>
|
||||
</button>
|
||||
@@ -225,8 +219,9 @@ function MovementControls({
|
||||
)}>
|
||||
<button
|
||||
onClick={() => onMove('down')}
|
||||
className="special-btn"
|
||||
className="compass-center-btn"
|
||||
disabled={!!combatState || movementCooldown > 0}
|
||||
style={{ padding: '0.3rem 1rem' }}
|
||||
>
|
||||
⬇️ {t('directions.down')} <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('down')}`}</span>
|
||||
</button>
|
||||
@@ -239,7 +234,6 @@ function MovementControls({
|
||||
{/* Surroundings - outside movement controls */}
|
||||
{location.interactables && location.interactables.length > 0 && (
|
||||
<div className="interactables-section">
|
||||
<h3>{t('game.surroundings')}</h3>
|
||||
{location.interactables.map((interactable: any) => (
|
||||
<div key={interactable.instance_id} className="interactable-card">
|
||||
{interactable.image_path && (
|
||||
|
||||
@@ -229,7 +229,7 @@ function PlayerSidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="sidebar-divider"></div>
|
||||
|
||||
|
||||
{/* Compact 2x2 Stats Grid */}
|
||||
<div className="stats-grid">
|
||||
@@ -263,52 +263,53 @@ function PlayerSidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-divider"></div>
|
||||
|
||||
{/* Inventory Capacity - matching HP/Stamina/XP style */}
|
||||
<div className="sidebar-stat-bar">
|
||||
<GameProgressBar
|
||||
value={profile.current_weight || 0}
|
||||
max={profile.max_weight || 0}
|
||||
type="weight"
|
||||
showText={true}
|
||||
label={t('stats.weight')}
|
||||
unit="kg"
|
||||
height="10px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-stat-bar">
|
||||
<GameProgressBar
|
||||
value={profile.current_volume || 0}
|
||||
max={profile.max_volume || 0}
|
||||
type="volume"
|
||||
showText={true}
|
||||
label={t('stats.volume')}
|
||||
unit="L"
|
||||
height="10px"
|
||||
/>
|
||||
{/* Inventory Capacity - Weight & Volume side-by-side */}
|
||||
<div className="sidebar-capacity-row">
|
||||
<div className="sidebar-stat-bar" style={{ flex: 1 }}>
|
||||
<GameProgressBar
|
||||
value={profile.current_weight || 0}
|
||||
max={profile.max_weight || 0}
|
||||
type="weight"
|
||||
showText={true}
|
||||
label={t('stats.weight')}
|
||||
unit="kg"
|
||||
height="10px"
|
||||
/>
|
||||
</div>
|
||||
<div className="sidebar-stat-bar" style={{ flex: 1 }}>
|
||||
<GameProgressBar
|
||||
value={profile.current_volume || 0}
|
||||
max={profile.max_volume || 0}
|
||||
type="volume"
|
||||
showText={true}
|
||||
label={t('stats.volume')}
|
||||
unit="L"
|
||||
height="10px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="sidebar-buttons" style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '1rem' }}>
|
||||
<div className="sidebar-buttons" style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
|
||||
<GameButton
|
||||
className="open-inventory-btn"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setShowInventory(true)}
|
||||
style={{ width: '100%', justifyContent: 'center' }}
|
||||
style={{ flex: 1, justifyContent: 'center' }}
|
||||
>
|
||||
{t('game.inventory')}
|
||||
</GameButton>
|
||||
|
||||
<GameButton
|
||||
className="quest-journal-btn"
|
||||
variant="secondary" // Different color as requested
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onOpenQuestJournal}
|
||||
style={{ width: '100%', justifyContent: 'center' }}
|
||||
style={{ flex: 1, justifyContent: 'center' }}
|
||||
>
|
||||
📜 {t('common.quests')}
|
||||
</GameButton>
|
||||
|
||||
@@ -26,6 +26,19 @@
|
||||
--game-clip-path-sm: polygon(4px 0, 100% 0, 100% calc(100% - 4px), calc(100% - 4px) 100%, 0 100%, 0 4px);
|
||||
--game-clip-path-no-br: polygon(10px 0, 100% 0, 100% 100%, 0 100%, 0 10px);
|
||||
|
||||
/* --- Gaps and paddings --- */
|
||||
--game-padding-xs: 0.25rem;
|
||||
--game-padding-sm: 0.5rem;
|
||||
--game-padding-md: 0.75rem;
|
||||
--game-padding-lg: 1rem;
|
||||
--game-padding-xl: 1.5rem;
|
||||
|
||||
--game-gap-xs: 0.25rem;
|
||||
--game-gap-sm: 0.5rem;
|
||||
--game-gap-md: 0.75rem;
|
||||
--game-gap-lg: 1rem;
|
||||
--game-gap-xl: 1.5rem;
|
||||
|
||||
/* --- Typography --- */
|
||||
--game-font-main: 'Saira Condensed', system-ui, sans-serif;
|
||||
--game-text-primary: #e0e0e0;
|
||||
@@ -68,6 +81,24 @@
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
/* Responsive Location Widths */
|
||||
--location-content-width: 600px;
|
||||
/* Default (1080p and below) */
|
||||
}
|
||||
|
||||
@media (min-width: 2200px) {
|
||||
:root {
|
||||
--location-content-width: 1000px;
|
||||
/* 1440p */
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 3400px) {
|
||||
:root {
|
||||
--location-content-width: 1400px;
|
||||
/* 4K */
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Reusable Game Classes --- */
|
||||
|
||||