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"
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

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 {
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 */

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 { 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' : ''}`}

View File

@@ -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;
}

View File

@@ -100,3 +100,8 @@
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 */
.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 {

View File

@@ -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 >
)
}

View File

@@ -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 && (

View File

@@ -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>

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-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 --- */