1080p layout fixes: responsive location sizing, dynamic entity limits, compass cleanup

This commit is contained in:
Joan
2026-02-10 17:43:09 +01:00
parent bba5d1d9dd
commit a725ae5836
32 changed files with 861 additions and 441 deletions

View File

@@ -54,8 +54,15 @@ for category in items locations npcs interactables characters placeholder static
rm "$tmp" rm "$tmp"
else else
# Standard conversion for other categories # 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" echo " ➜ Converting: $filename"
cwebp "$img" -q 85 -o "$out_file" >/dev/null cwebp "$tmp" -q 85 -o "$out_file" >/dev/null
fi fi
done done
done done

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 154 KiB

View File

@@ -1,16 +1,17 @@
html { html {
overflow-y: scroll; overflow: hidden;
/* Always show scrollbar to prevent layout shift */ /* Lock the page — no document-level scrollbar */
} }
.game-container { .game-container {
min-height: 100vh; height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--game-bg-app); background: var(--game-bg-app);
color: var(--game-text-primary); color: var(--game-text-primary);
position: relative; position: relative;
font-family: var(--game-font-main); font-family: var(--game-font-main);
overflow: hidden;
} }
/* Death Overlay */ /* Death Overlay */
@@ -317,7 +318,8 @@ html {
.game-main { .game-main {
flex: 1; flex: 1;
padding: 1.5rem; padding: 1.5rem;
overflow-y: auto; overflow: hidden;
min-height: 0;
} }
/* Unified Search Bar */ /* Unified Search Bar */
@@ -365,7 +367,6 @@ html {
.explore-tab-desktop { .explore-tab-desktop {
display: grid; display: grid;
grid-template-columns: 380px 1fr 380px; grid-template-columns: 380px 1fr 380px;
gap: 1.5rem;
height: 100%; height: 100%;
padding: 0; padding: 0;
} }
@@ -374,9 +375,9 @@ html {
.left-sidebar { .left-sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: var(--game-gap-md);
overflow-y: auto; overflow-y: auto;
padding-right: 0.5rem; padding-right: var(--game-padding-md);
} }
/* Center Content */ /* Center Content */
@@ -387,13 +388,21 @@ html {
overflow-y: auto; overflow-y: auto;
} }
/* Center Area (combat/location container) */
.center-area {
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
/* Right Sidebar */ /* Right Sidebar */
.right-sidebar { .right-sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: var(--game-gap-md);
overflow-y: auto; overflow-y: auto;
padding-left: 0.5rem; padding-left: var(--game-padding-md);
} }
/* Mobile fallback */ /* Mobile fallback */
@@ -411,11 +420,14 @@ html {
.location-info { .location-info {
background: var(--game-bg-panel); background: var(--game-bg-panel);
padding: 1.5rem; padding: var(--game-padding-md);
margin-bottom: 1.5rem; gap: var(--game-gap-md);
margin-bottom: 0;
border: 1px solid var(--game-border-color); border: 1px solid var(--game-border-color);
box-shadow: var(--game-shadow-card); box-shadow: var(--game-shadow-card);
clip-path: var(--game-clip-path); clip-path: var(--game-clip-path);
display: flex;
flex-direction: column;
} }
.location-info h2 { .location-info h2 {
@@ -425,7 +437,7 @@ html {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.75rem; gap: var(--game-gap-md);
flex-wrap: wrap; flex-wrap: wrap;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 1px; letter-spacing: 1px;
@@ -683,18 +695,19 @@ html {
.location-image-container { .location-image-container {
width: 100%; width: 100%;
max-width: 800px; max-width: var(--location-content-width);
margin: 1rem auto; margin: 0 auto;
aspect-ratio: 10 / 7; aspect-ratio: 16 / 9;
overflow: hidden; overflow: hidden;
border: 2px solid rgba(255, 107, 107, 0.3); border: 2px solid rgba(255, 107, 107, 0.3);
clip-path: var(--game-clip-path); clip-path: var(--game-clip-path);
flex-shrink: 0;
} }
.location-image { .location-image {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; object-fit: cover;
} }
.message-box { .message-box {
@@ -709,8 +722,8 @@ html {
.location-messages-log { .location-messages-log {
background: var(--game-bg-panel); background: var(--game-bg-panel);
border: 1px solid var(--game-border-color); border: 1px solid var(--game-border-color);
padding: 0.8rem; padding: 0.5rem 0.8rem;
margin-top: 1rem; margin-top: 0;
max-width: 100%; max-width: 100%;
clip-path: var(--game-clip-path); clip-path: var(--game-clip-path);
} }
@@ -761,7 +774,7 @@ html {
.movement-controls { .movement-controls {
background: var(--game-bg-panel); background: var(--game-bg-panel);
padding: 1.5rem; padding: var(--game-padding-md);
border: 1px solid var(--game-border-color); border: 1px solid var(--game-border-color);
box-shadow: var(--game-shadow-card); box-shadow: var(--game-shadow-card);
clip-path: var(--game-clip-path); clip-path: var(--game-clip-path);
@@ -781,7 +794,7 @@ html {
grid-template-columns: repeat(3, 80px); grid-template-columns: repeat(3, 80px);
gap: 0.6rem; gap: 0.6rem;
max-width: 260px; max-width: 260px;
margin: 0 auto 1rem auto; margin: 0 auto 0 auto;
justify-content: center; justify-content: center;
} }
@@ -1035,14 +1048,18 @@ html {
/* Interactables Section */ /* Interactables Section */
.interactables-section { .interactables-section {
background: var(--game-bg-panel); background: var(--game-bg-panel);
padding: 1.5rem; padding: var(--game-padding-md);
border: 1px solid var(--game-border-color); border: 1px solid var(--game-border-color);
clip-path: var(--game-clip-path); clip-path: var(--game-clip-path);
display: flex;
flex-direction: column;
gap: var(--game-gap-md);
} }
.interactables-section h3 { .interactables-section h3 {
margin: 0 0 1rem 0; margin: 0 0 0.3rem 0;
color: #ff6b6b; color: #ff6b6b;
font-size: 0.9rem;
} }
body.no-scroll { body.no-scroll {
@@ -1052,7 +1069,6 @@ body.no-scroll {
/* Interactable Card with Image */ /* Interactable Card with Image */
.interactable-card { .interactable-card {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
margin-bottom: 1rem;
border: 1px solid rgba(255, 193, 7, 0.4); border: 1px solid rgba(255, 193, 7, 0.4);
overflow: hidden; overflow: hidden;
display: flex; display: flex;
@@ -1069,7 +1085,7 @@ body.no-scroll {
.interactable-image-container { .interactable-image-container {
width: 100%; width: 100%;
aspect-ratio: 10 / 7; aspect-ratio: 16 / 9;
overflow: hidden; overflow: hidden;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
display: flex; display: flex;
@@ -1080,7 +1096,7 @@ body.no-scroll {
.interactable-image { .interactable-image {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; object-fit: cover;
transition: transform 0.3s; transition: transform 0.3s;
} }
@@ -1140,39 +1156,32 @@ body.no-scroll {
/* Ground Entities - NPCs and Items */ /* Ground Entities - NPCs and Items */
.ground-entities { .ground-entities {
margin-top: 1.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: var(--game-gap-md);
flex: 1;
overflow: hidden;
min-height: 0;
} }
.entity-section { .entity-section {
background: var(--game-bg-panel); background: var(--game-bg-panel);
padding: 1.5rem; padding: 0.5rem 0.75rem;
border: 1px solid var(--game-border-color); border: 1px solid var(--game-border-color);
clip-path: var(--game-clip-path); clip-path: var(--game-clip-path);
} }
.entity-section h3 { .entity-section h3 {
margin: 0 0 1rem 0; margin: 0 0 0.3rem 0;
color: #ff6b6b; color: #ff6b6b;
font-size: 1.2rem; font-size: 0.9rem;
} }
.entity-list { .entity-list:not(.grid-view) {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem; gap: 1rem;
max-height: 400px; overflow: hidden;
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 */
} }
/* Ensure cards handle their own stacking context */ /* Ensure cards handle their own stacking context */
@@ -1735,20 +1744,20 @@ body.no-scroll {
/* Profile Sidebar */ /* Profile Sidebar */
.profile-sidebar { .profile-sidebar {
background: var(--game-bg-panel); background: var(--game-bg-panel);
padding: 1rem; padding: 0.5rem 0.75rem;
border: 1px solid var(--game-border-color); border: 1px solid var(--game-border-color);
clip-path: var(--game-clip-path); clip-path: var(--game-clip-path);
} }
.profile-sidebar h3 { .profile-sidebar h3 {
margin: 0 0 1rem 0; margin: 0 0 0.3rem 0;
color: #ff6b6b; color: #ff6b6b;
font-size: 1.1rem; font-size: 1rem;
text-align: center; text-align: center;
} }
.sidebar-stat-bars { .sidebar-stat-bars {
margin-bottom: 1rem; margin-bottom: 0.3rem;
} }
.sidebar-stat-bar { .sidebar-stat-bar {
@@ -1960,17 +1969,16 @@ body.no-scroll {
/* Equipment Sidebar */ /* Equipment Sidebar */
.equipment-sidebar { .equipment-sidebar {
background: var(--game-bg-panel); background: var(--game-bg-panel);
padding: 1rem; padding: 0.5rem 0.75rem;
border: 1px solid var(--game-border-color); border: 1px solid var(--game-border-color);
overflow: visible; overflow: visible;
clip-path: var(--game-clip-path); clip-path: var(--game-clip-path);
/* Allow tooltips to overflow */
} }
.equipment-sidebar h3 { .equipment-sidebar h3 {
margin: 0 0 1rem 0; margin: 0 0 0.3rem 0;
color: #ff6b6b; color: #ff6b6b;
font-size: 1.1rem; font-size: 1rem;
text-align: center; text-align: center;
} }
@@ -3516,14 +3524,13 @@ body.no-scroll {
/* Combat Log Styles */ /* Combat Log Styles */
.combat-log-container { .combat-log-container {
margin-top: 2rem;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
border: 2px solid rgba(244, 67, 54, 0.3); border: 1px solid rgba(244, 67, 54, 0.3);
padding: 1rem; padding: var(--game-padding-md);
max-width: 800px; width: var(--location-content-width);
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
clip-path: var(--game-clip-path-sm); clip-path: var(--game-clip-path);
} }
.combat-log-container h4 { .combat-log-container h4 {
@@ -3657,10 +3664,9 @@ body.no-scroll {
.location-description-box { .location-description-box {
background: rgba(25, 26, 31, 0.6); background: rgba(25, 26, 31, 0.6);
border: 1px solid rgba(107, 185, 240, 0.3); border: 1px solid rgba(107, 185, 240, 0.3);
padding: 1rem; padding: var(--game-padding-md);
margin-top: 1rem;
width: 100%; width: 100%;
max-width: 800px; max-width: var(--location-content-width);
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
box-sizing: border-box; box-sizing: border-box;
@@ -4320,7 +4326,7 @@ body.no-scroll {
.combat-actions { .combat-actions {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: var(--game-gap-md);
/* min-height removed to fit content */ /* min-height removed to fit content */
justify-content: center; justify-content: center;
/* Center content vertically */ /* Center content vertically */

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useCallback } from 'react'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { useAuth } from '../hooks/useAuth' import { useAuth } from '../hooks/useAuth'
import { useGameWebSocket } from '../hooks/useGameWebSocket' import { useGameWebSocket } from '../hooks/useGameWebSocket'
@@ -34,6 +34,28 @@ export default function GameHeader({
const { currentCharacter, logout } = useAuth() const { currentCharacter, logout } = useAuth()
const { t } = useTranslation() const { t } = useTranslation()
const [playerCount, setPlayerCount] = useState<number>(0) 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) // Get game state from context (undefined when outside GameProvider)
const gameContext = useOptionalGame() const gameContext = useOptionalGame()
@@ -149,6 +171,16 @@ export default function GameHeader({
<LanguageSelector /> <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 <button
onClick={() => navigate(`/profile/${currentCharacter?.id}`)} onClick={() => navigate(`/profile/${currentCharacter?.id}`)}
className={`username-link ${isOnOwnProfile ? 'active' : ''}`} className={`username-link ${isOnOwnProfile ? 'active' : ''}`}

View File

@@ -1,19 +1,4 @@
/* Combat Layout */ /* 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 { .glow-effect {
box-shadow: 0 0 10px #ff4444, 0 0 20px #ff4444; box-shadow: 0 0 10px #ff4444, 0 0 20px #ff4444;
@@ -25,17 +10,30 @@
transition: filter 1s ease; 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: Location Background with NPC Overlay */
.combat-scene-container { .combat-scene-container {
position: relative; position: relative;
width: 100%; width: 100%;
max-width: 800px; max-width: var(--location-content-width);
margin: 1rem auto; margin: 0 auto;
aspect-ratio: 10 / 7; aspect-ratio: 16 / 9;
overflow: hidden; overflow: hidden;
clip-path: var(--game-clip-path); 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 { .combat-location-bg {
width: 100%; width: 100%;
@@ -497,10 +495,9 @@
.combat-stats-container { .combat-stats-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: var(--game-gap-md);
margin-bottom: 1.5rem;
width: 100%; width: 100%;
max-width: 800px; max-width: var(--location-content-width);
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }

View File

@@ -99,4 +99,9 @@
transform: translateY(0); transform: translateY(0);
opacity: 1; opacity: 1;
} }
}
/* Entity "Show All" modal - wider like inventory */
.game-modal-container.entity-show-all-modal {
max-width: 900px;
} }

View File

@@ -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 */ /* Grid View Styles */
.entities-container { .entities-container {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 1.5rem; gap: 0.5rem;
align-items: flex-start; align-items: flex-start;
} }
.entity-section { .entity-section {
flex: 1 1 300px; flex: 1 1 calc(50% - 0.25rem);
min-width: calc(50% - 0.25rem);
max-width: 100%; max-width: 100%;
min-width: 0; box-sizing: border-box;
} }
/* Ground item images */ /* Ground item images */
@@ -28,12 +97,23 @@
} }
.entity-list.grid-view { .entity-list.grid-view {
display: grid; display: flex;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); flex-wrap: nowrap;
gap: 1rem; gap: 0.4rem;
align-items: start; align-items: start;
padding: 0.5rem; padding: 0.25rem;
/* Ensure padding inside container */ 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 */ /* Padded Image for enemies */
@@ -110,21 +190,25 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
aspect-ratio: 1;
padding: 0; padding: 0;
/* Remove padding to let image fill */
text-align: center; text-align: center;
background: rgba(0, 0, 0, 0.6); background: rgba(0, 0, 0, 0.6);
border: 1px solid var(--game-border-color); border: 1px solid var(--game-border-color);
border-radius: 0; border-radius: 0;
/* User requested NO radius */
transition: all 0.2s; transition: all 0.2s;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
height: auto;
min-width: 0; 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 { .entity-card.grid-card:hover {

View File

@@ -1,5 +1,5 @@
import type { Location, PlayerState, CombatState, Profile, WorkbenchTab } from './types' 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 { useTranslation } from 'react-i18next'
import { useAudio } from '../../contexts/AudioContext' import { useAudio } from '../../contexts/AudioContext'
import Workbench from './Workbench' import Workbench from './Workbench'
@@ -11,8 +11,23 @@ import { getTranslatedText } from '../../utils/i18nUtils'
import { DialogModal } from './DialogModal' import { DialogModal } from './DialogModal'
import { TradeModal } from './TradeModal' import { TradeModal } from './TradeModal'
import { ItemTooltipContent } from '../common/ItemTooltipContent' import { ItemTooltipContent } from '../common/ItemTooltipContent'
import { GameModal } from './GameModal'
import './LocationView.css' 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 { interface LocationViewProps {
location: Location location: Location
playerState: PlayerState | null playerState: PlayerState | null
@@ -101,6 +116,36 @@ function LocationView({
// Dropdown State // Dropdown State
const [activeDropdown, setActiveDropdown] = useState<string | null>(null) 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 // NPC Interaction State
const [activeDialogNpc, setActiveDialogNpc] = useState<string | null>(null) const [activeDialogNpc, setActiveDialogNpc] = useState<string | null>(null)
const [showTradeModal, setShowTradeModal] = useState<boolean>(false) const [showTradeModal, setShowTradeModal] = useState<boolean>(false)
@@ -299,359 +344,424 @@ function LocationView({
{/* Combined Entities Container for Grid Layout */} {/* Combined Entities Container for Grid Layout */}
<div className="entities-container"> <div className="entities-container">
{/* Enemies */} {/* Enemies */}
{location.npcs.filter((npc: any) => npc.type === 'enemy').length > 0 && ( {(() => {
<div className="entity-section enemies-section"> const enemies = location.npcs.filter((npc: any) => npc.type === 'enemy');
<h3>{t('location.enemies')}</h3> if (enemies.length === 0) return null;
<div className="entity-list grid-view"> const limit = getFitCount('enemies');
{location.npcs.filter((npc: any) => npc.type === 'enemy').map((enemy: any) => { const visibleEnemies = enemies.length > limit ? enemies.slice(0, limit - 1) : enemies;
const isShaking = failedActionItemId == enemy.id; const hasMore = enemies.length > limit;
const id = `enemy-${enemy.id}`;
// Only render if valid return (
if (!enemy || !enemy.id) return null; <div className="entity-section enemies-section">
<h3>{t('location.enemies')}</h3>
return ( <div className="entity-list grid-view" ref={getSectionRef('enemies')}>
<div key={enemy.id} className={`entity-card enemy-card grid-card ${isShaking ? 'shake-animation' : ''}`} {visibleEnemies.map((enemy: any) => {
onClick={(e) => handleDropdownClick(e, id)} const isShaking = failedActionItemId == enemy.id;
> const id = `enemy-${enemy.id}`;
{/* Enemy Image */} if (!enemy || !enemy.id) return null;
{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 ( return (
<div key={itemId} className={`entity-card item-card grid-card ${isShaking ? 'shake-animation' : ''}`} <div key={enemy.id} className={`entity-card enemy-card grid-card ${isShaking ? 'shake-animation' : ''}`}
onClick={(e) => handleDropdownClick(e, itemId)} 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={ <GameTooltip content={
<> <div>
<ItemTooltipContent item={item} /> <div className="tooltip-title">{corpse.emoji} {getTranslatedText(corpse.name)}</div>
<div style={{ color: '#4caf50', fontSize: '0.8rem', marginTop: '0.5rem', textAlign: 'center' }}>Click to Interact</div> <div>{corpse.loot_count} {t('location.items')}</div>
</> </div>
}> }>
<div className="grid-corpse-content"> <div className="grid-corpse-content">
{item.image_path ? ( {corpse.image_path ? (
<img <img
src={getAssetPath(item.image_path)} src={getAssetPath(corpse.image_path)}
alt={getTranslatedText(item.name)} alt={getTranslatedText(corpse.name)}
className="ground-item-image" className="corpse-image"
onError={(e) => { onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden'); (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
}} }}
/> />
) : null} ) : null}
<div style={{ fontSize: '2rem', display: item.image_path ? 'none' : 'block' }} className={item.image_path ? 'hidden' : ''}> <div style={{ fontSize: '2rem', display: corpse.image_path ? 'none' : 'block' }} className={corpse.image_path ? 'hidden' : ''}>
{item.emoji} {corpse.emoji}
</div> </div>
{item.quantity > 1 && ( <div className="corpse-loot-count">{corpse.loot_count} items</div>
<div className="grid-quantity">x{item.quantity}</div>
)}
</div> </div>
</GameTooltip> </GameTooltip>
{activeDropdown === itemId && ( {activeDropdown === `corpse-${corpse.id}` && (
<GameDropdown <GameDropdown
isOpen={true} isOpen={true}
onClose={() => setActiveDropdown(null)} 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> <div className="game-dropdown-header">{getTranslatedText(corpse.name)}</div>
{/* Primary Action: Pick Up 1 */}
<GameButton <GameButton
variant="success" variant="secondary"
size="sm" size="sm"
className="pickup-main-btn"
onClick={() => { onClick={() => {
playSfx('/audio/sfx/pickup.wav'); playSfx('/audio/sfx/interact.wav')
onPickup(Number(item.id), 1); onLootCorpse(String(corpse.id))
setActiveDropdown(null); 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> </GameButton>
{/* Quantity Options if > 1 */}
{item.quantity > 1 && (
<>
<div className="game-dropdown-divider" style={{ margin: '8px 0' }} />
{renderPickupOptions()}
</>
)}
</GameDropdown> </GameDropdown>
)} )}
</div> </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>
</div> );
)} })()}
{/* Other Players */} {/* Friendly NPCs */}
{location.other_players && location.other_players.length > 0 && ( {(() => {
<div className="entity-section players-section"> const friendlyNpcs = location.npcs.filter((npc: any) => npc.type !== 'enemy');
<h3>👥 {t('location.otherPlayers', 'Other Players')}</h3> if (friendlyNpcs.length === 0) return null;
<div className="entity-list grid-view"> const limit = getFitCount('npcs');
{location.other_players.map((player: any, i: number) => { const visibleNpcs = friendlyNpcs.length > limit ? friendlyNpcs.slice(0, limit - 1) : friendlyNpcs;
const playerId = `player-${player.id}-${i}`; const hasMore = friendlyNpcs.length > limit;
const canPvP = player.can_pvp;
return ( return (
<div key={i} className="entity-card player-card grid-card" <div className="entity-section npcs-section">
onClick={(e) => handleDropdownClick(e, playerId)} <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={ <GameTooltip content={
<div> <div>
<div className="tooltip-title">{player.name || player.username}</div> <div className="tooltip-title">{getTranslatedText(npc.name)}</div>
<div>{t('location.level', 'Level')} {player.level}</div> <div style={{ color: '#ff9800', fontSize: '0.8rem' }}>Click to Interact</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>
}> }>
<div className="grid-corpse-content"> <div className="grid-overlay"></div>
{/* 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>
</GameTooltip> </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 && ( {/* Items on Ground - Stable Sort */}
<GameDropdown {(() => {
isOpen={true} const sortedItems = [...location.items].sort((a: any, b: any) => (a.id || 0) - (b.id || 0));
onClose={() => setActiveDropdown(null)} if (sortedItems.length === 0) return null;
width="180px" const limit = getFitCount('items');
> const visibleItems = sortedItems.length > limit ? sortedItems.slice(0, limit - 1) : sortedItems;
<div className="game-dropdown-header">{player.name || player.username}</div> const hasMore = sortedItems.length > limit;
{canPvP ? ( const renderItemCard = (item: any, i: number) => {
<GameButton const isShaking = failedActionItemId == item.id;
variant="danger" const itemId = `item-${item.id}-${i}`;
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" /> const renderPickupOptions = () => {
<div style={{ padding: '0.5rem', fontSize: '0.8rem', color: '#a0aec0' }}> const options = [];
Level {player.level} options.push({ label: `${t('common.pickUp')} (x1)`, qty: 1 });
</div> if (item.quantity >= 5) options.push({ label: `${t('common.pickUp')} (x5)`, qty: 5 });
</GameDropdown> 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> </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>
</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>
</div> </div>
{/* Corpse Loot Overlay Modal */} {/* Corpse Loot Overlay Modal */}
{ {
expandedCorpse && corpseDetails && corpseDetails.loot_items && ( 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 > </div >
) )
} }

View File

@@ -173,13 +173,7 @@ function MovementControls({
return ( return (
<> <>
<div className="movement-controls"> <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"> <div className="compass-grid">
{/* Top row */} {/* Top row */}
{renderCompassButton('northwest', '↖️', 'nw')} {renderCompassButton('northwest', '↖️', 'nw')}
@@ -197,9 +191,8 @@ function MovementControls({
{renderCompassButton('southeast', '↘️', 'se')} {renderCompassButton('southeast', '↘️', 'se')}
</div> </div>
{/* Special movements (Vertical only now, since Enter/Exit are in center) */}
{(location.directions.includes('up') || location.directions.includes('down')) && ( {(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') && ( {location.directions.includes('up') && (
<GameTooltip content={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : ( <GameTooltip content={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : (
<div className="movement-tooltip"> <div className="movement-tooltip">
@@ -209,8 +202,9 @@ function MovementControls({
)}> )}>
<button <button
onClick={() => onMove('up')} onClick={() => onMove('up')}
className="special-btn" className="compass-center-btn"
disabled={!!combatState || movementCooldown > 0} disabled={!!combatState || movementCooldown > 0}
style={{ padding: '0.3rem 1rem' }}
> >
{t('directions.up')} <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('up')}`}</span> {t('directions.up')} <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('up')}`}</span>
</button> </button>
@@ -225,8 +219,9 @@ function MovementControls({
)}> )}>
<button <button
onClick={() => onMove('down')} onClick={() => onMove('down')}
className="special-btn" className="compass-center-btn"
disabled={!!combatState || movementCooldown > 0} disabled={!!combatState || movementCooldown > 0}
style={{ padding: '0.3rem 1rem' }}
> >
{t('directions.down')} <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('down')}`}</span> {t('directions.down')} <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('down')}`}</span>
</button> </button>
@@ -239,7 +234,6 @@ function MovementControls({
{/* Surroundings - outside movement controls */} {/* Surroundings - outside movement controls */}
{location.interactables && location.interactables.length > 0 && ( {location.interactables && location.interactables.length > 0 && (
<div className="interactables-section"> <div className="interactables-section">
<h3>{t('game.surroundings')}</h3>
{location.interactables.map((interactable: any) => ( {location.interactables.map((interactable: any) => (
<div key={interactable.instance_id} className="interactable-card"> <div key={interactable.instance_id} className="interactable-card">
{interactable.image_path && ( {interactable.image_path && (

View File

@@ -229,7 +229,7 @@ function PlayerSidebar({
</div> </div>
)} )}
<div className="sidebar-divider"></div>
{/* Compact 2x2 Stats Grid */} {/* Compact 2x2 Stats Grid */}
<div className="stats-grid"> <div className="stats-grid">
@@ -263,52 +263,53 @@ function PlayerSidebar({
</div> </div>
</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"> {/* Inventory Capacity - Weight & Volume side-by-side */}
<GameProgressBar <div className="sidebar-capacity-row">
value={profile.current_volume || 0} <div className="sidebar-stat-bar" style={{ flex: 1 }}>
max={profile.max_volume || 0} <GameProgressBar
type="volume" value={profile.current_weight || 0}
showText={true} max={profile.max_weight || 0}
label={t('stats.volume')} type="weight"
unit="L" showText={true}
height="10px" 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> </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 <GameButton
className="open-inventory-btn" className="open-inventory-btn"
variant="primary" variant="primary"
size="sm" size="sm"
onClick={() => setShowInventory(true)} onClick={() => setShowInventory(true)}
style={{ width: '100%', justifyContent: 'center' }} style={{ flex: 1, justifyContent: 'center' }}
> >
{t('game.inventory')} {t('game.inventory')}
</GameButton> </GameButton>
<GameButton <GameButton
className="quest-journal-btn" className="quest-journal-btn"
variant="secondary" // Different color as requested variant="secondary"
size="sm" size="sm"
onClick={onOpenQuestJournal} onClick={onOpenQuestJournal}
style={{ width: '100%', justifyContent: 'center' }} style={{ flex: 1, justifyContent: 'center' }}
> >
📜 {t('common.quests')} 📜 {t('common.quests')}
</GameButton> </GameButton>

View File

@@ -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-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); --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 --- */ /* --- Typography --- */
--game-font-main: 'Saira Condensed', system-ui, sans-serif; --game-font-main: 'Saira Condensed', system-ui, sans-serif;
--game-text-primary: #e0e0e0; --game-text-primary: #e0e0e0;
@@ -68,6 +81,24 @@
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -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 --- */ /* --- Reusable Game Classes --- */