This commit is contained in:
Robert MacRae 2026-04-03 18:07:30 -03:00
commit 4f31d18cf2
15 changed files with 762 additions and 564 deletions

View File

@ -0,0 +1,53 @@
.meteor-region-wrapper {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
}
.meteor-region-content {
flex: 1;
overflow-y: auto;
padding-right: 15px;
scrollbar-width: none;
-ms-overflow-style: none;
}
.meteor-region-content::-webkit-scrollbar {
display: none;
}
.meteor-track-local {
position: absolute;
right: 6px;
top: 10px;
bottom: 10px;
width: 1px;
background: rgba(0, 210, 255, 0.1);
pointer-events: none;
}
.meteor-slider-local {
position: absolute;
top: 0;
left: 50%;
margin-left: -1px;
width: 2px;
height: 60px;
background: linear-gradient(to bottom, transparent, #00d2ff, #fff);
transition: transform 0.1s linear;
will-change: transform;
}
.meteor-glow-local {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 6px;
height: 6px;
background: #fff;
border-radius: 50%;
box-shadow: 0 0 10px #00d2ff;
}

View File

@ -0,0 +1,58 @@
import React, { useState, useRef, useEffect, useCallback } from "react";
import "./MeteorRegion.css";
const MeteorRegion = ({ children, className = "", maxHeight }) => {
const [scrollProgress, setScrollProgress] = useState(0);
const [isVisible, setIsVisible] = useState(false);
const containerRef = useRef(null);
const updateScroll = useCallback(() => {
const el = containerRef.current;
if (!el) return;
const { scrollTop, scrollHeight, clientHeight } = el;
const progress = scrollTop / (scrollHeight - clientHeight);
setScrollProgress(isNaN(progress) ? 0 : progress);
setIsVisible(scrollHeight > clientHeight);
}, []);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const resizeObserver = new ResizeObserver(updateScroll);
resizeObserver.observe(el);
el.addEventListener("scroll", updateScroll);
updateScroll();
return () => {
resizeObserver.disconnect();
el.removeEventListener("scroll", updateScroll);
};
}, [updateScroll]);
return (
<div className={`meteor-region-wrapper ${className}`} style={{ maxHeight }}>
<div className="meteor-region-content" ref={containerRef}>
{children}
</div>
{isVisible && (
<div className="meteor-track-local">
<div
className="meteor-slider-local"
style={{
transform: `translateY(${scrollProgress * (containerRef.current?.clientHeight - 70)}px)`,
}}
>
<div className="meteor-glow-local" />
</div>
</div>
)}
</div>
);
};
export default MeteorRegion;

View File

@ -312,3 +312,13 @@
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
} }
*::-webkit-scrollbar {
width: 0px;
background: transparent;
}
* {
scrollbar-width: none;
-ms-overflow-style: none;
}

View File

@ -40,3 +40,13 @@ body {
transparent 50% transparent 50%
); );
} }
*::-webkit-scrollbar {
width: 0px;
background: transparent;
}
* {
scrollbar-width: none;
-ms-overflow-style: none;
}

View File

@ -1,137 +1,161 @@
/* Galaxy Strike Online - Main Styles */ /* Galaxy Strike Online - Main Styles */
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
:root { :root {
--primary-color: #00d4ff; --primary-color: #00d4ff;
--secondary-color: #ff6b35; --secondary-color: #ff6b35;
--accent-color: #ff00ff; --accent-color: #ff00ff;
--bg-primary: #0a0e1a; --bg-primary: #0a0e1a;
--bg-secondary: #151923; --bg-secondary: #151923;
--bg-tertiary: #1e2433; --bg-tertiary: #1e2433;
--text-primary: #ffffff; --text-primary: #ffffff;
--text-secondary: #b8c5d6; --text-secondary: #b8c5d6;
--text-muted: #6b7c93; --text-muted: #6b7c93;
--border-color: #2a3241; --border-color: #2a3241;
--success-color: #00ff88; --success-color: #00ff88;
--warning-color: #ffaa00; --warning-color: #ffaa00;
--error-color: #ff3366; --error-color: #ff3366;
--card-bg: rgba(30, 36, 51, 0.8); --card-bg: rgba(30, 36, 51, 0.8);
--hover-bg: rgba(0, 212, 255, 0.1); --hover-bg: rgba(0, 212, 255, 0.1);
--gradient-primary: linear-gradient(135deg, #00d4ff, #0099cc); --gradient-primary: linear-gradient(135deg, #00d4ff, #0099cc);
--gradient-secondary: linear-gradient(135deg, #ff6b35, #ff4500); --gradient-secondary: linear-gradient(135deg, #ff6b35, #ff4500);
} }
body { body {
font-family: 'Space Mono', monospace; font-family: "Space Mono", monospace;
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
overflow: hidden; overflow: hidden;
background-image: background-image:
radial-gradient(circle at 20% 50%, rgba(0, 212, 255, 0.1) 0%, transparent 50%), radial-gradient(
radial-gradient(circle at 80% 80%, rgba(255, 107, 53, 0.1) 0%, transparent 50%), circle at 20% 50%,
radial-gradient(circle at 40% 20%, rgba(255, 0, 255, 0.05) 0%, transparent 50%); rgba(0, 212, 255, 0.1) 0%,
transparent 50%
),
radial-gradient(
circle at 80% 80%,
rgba(255, 107, 53, 0.1) 0%,
transparent 50%
),
radial-gradient(
circle at 40% 20%,
rgba(255, 0, 255, 0.05) 0%,
transparent 50%
);
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.server-controls { .server-controls {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
.server-filters { .server-filters {
justify-content: center; justify-content: center;
} }
.server-confirmation { .server-confirmation {
flex-direction: column; flex-direction: column;
gap: 20px; gap: 20px;
} }
.confirm-actions-left, .confirm-actions-right { .confirm-actions-left,
width: 100%; .confirm-actions-right {
max-width: 300px; width: 100%;
} max-width: 300px;
}
.server-details { .server-details {
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
} }
} }
/* Animations */ /* Animations */
@keyframes fadeInUp { @keyframes fadeInUp {
from { from {
opacity: 0; opacity: 0;
transform: translateY(30px); transform: translateY(30px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
} }
@keyframes pulse { @keyframes pulse {
0%, 100% { 0%,
opacity: 1; 100% {
} opacity: 1;
50% { }
opacity: 0.5; 50% {
} opacity: 0.5;
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.dashboard-grid { .dashboard-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.dungeons-container { .dungeons-container {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.base-container { .base-container {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.inventory-container { .inventory-container {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.main-nav { .main-nav {
overflow-x: scroll; overflow-x: scroll;
} }
.resources { .resources {
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
} }
.resource { .resource {
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
font-size: 0.8rem; font-size: 0.8rem;
} }
.game-title { .game-title {
font-size: 2rem; font-size: 2rem;
} }
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.header-center { .header-center {
display: none; display: none;
} }
.nav-btn span { .nav-btn span {
display: none; display: none;
} }
.nav-btn { .nav-btn {
padding: 0.5rem; padding: 0.5rem;
width: 100px; width: 100px;
justify-content: center; justify-content: center;
} }
}
*::-webkit-scrollbar {
width: 0px;
background: transparent;
}
* {
scrollbar-width: none;
-ms-overflow-style: none;
} }

View File

@ -5,6 +5,7 @@ import "./styles/CraftingTab.css";
import CategorySelector from "../components/CategorySelector"; import CategorySelector from "../components/CategorySelector";
import CraftModal from "./components/CraftModal"; import CraftModal from "./components/CraftModal";
import { config } from "../../../config/api"; import { config } from "../../../config/api";
import MeteorRegion from "../../../components/Meteor/MeteorRegion.jsx";
const CraftingTab = () => { const CraftingTab = () => {
const { socket } = useSocket(); const { socket } = useSocket();
@ -109,8 +110,11 @@ const CraftingTab = () => {
}; };
return ( return (
<div className="tab-content active"> <div
<div className="crafting-container"> className="tab-content active"
style={{ height: "100%", display: "flex", flexDirection: "column" }}
>
<MeteorRegion className="crafting-container">
{activeCraft && ( {activeCraft && (
<div className="active-craft-panel"> <div className="active-craft-panel">
<div className="craft-info"> <div className="craft-info">
@ -184,7 +188,7 @@ const CraftingTab = () => {
); );
})} })}
</div> </div>
</div> </MeteorRegion>
<CraftModal <CraftModal
recipe={selectedRecipe} recipe={selectedRecipe}

View File

@ -1,9 +1,11 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useRef } from "react";
import ReactDOM from "react-dom";
import { useSocket } from "../../../hooks/useSocket"; import { useSocket } from "../../../hooks/useSocket";
import GameDataManager from "../../../services/GameDataManager.js"; import GameDataManager from "../../../services/GameDataManager.js";
import Button from "../../../components/ui/Button"; import ItemModal from "./components/ItemModal";
import "./styles/InventoryTab.css"; import "./styles/InventoryTab.css";
import { getServerUrl } from "../../../config/api.js"; import { getServerUrl } from "../../../config/api.js";
import MeteorRegion from "../../../components/Meteor/MeteorRegion.jsx";
const InventoryTab = () => { const InventoryTab = () => {
const { socket } = useSocket(); const { socket } = useSocket();
@ -11,24 +13,12 @@ const InventoryTab = () => {
const [equipment, setEquipment] = useState({}); const [equipment, setEquipment] = useState({});
const [selectedItem, setSelectedItem] = useState(null); const [selectedItem, setSelectedItem] = useState(null);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const CONNECT_URL = getServerUrl(); const CONNECT_URL = getServerUrl();
const ASSET_BASE_URL = `${CONNECT_URL}/static/`; const ASSET_BASE_URL = `${CONNECT_URL}/static/`;
const equipmentSlots = { const manifest = GameDataManager.manifest || {};
personal: [ const coreSystems = manifest.core_systems?.categories || {};
{ id: "personal_helmet", label: "HELMET" },
{ id: "personal_suit", label: "SUIT" },
{ id: "personal_gloves", label: "GLOVES" },
{ id: "personal_boots", label: "BOOTS" },
{ id: "personal_backpack", label: "BACKPACK" },
{ id: "personal_weapons", label: "WEAPON" },
],
ship: [
{ id: "ship_hull", label: "HULL" },
{ id: "ship_shields", label: "SHIELDS" },
{ id: "ship_engines", label: "ENGINES" },
],
};
const getFullTextureUrl = (path) => { const getFullTextureUrl = (path) => {
if (!path) return "/assets/no-image.png"; if (!path) return "/assets/no-image.png";
@ -36,11 +26,10 @@ const InventoryTab = () => {
return `${ASSET_BASE_URL}${path}`; return `${ASSET_BASE_URL}${path}`;
}; };
const enrichItemData = (serverItem) => { const enrichItemData = (serverItem, currentSlot = null) => {
if (!serverItem || (!serverItem.itemId && !serverItem.id)) return null; if (!serverItem || (!serverItem.itemId && !serverItem.id)) return null;
const id = serverItem.itemId || serverItem.id; const id = serverItem.itemId || serverItem.id;
const staticData = GameDataManager.getItem(id); const staticData = GameDataManager.getItem(id);
return { return {
...serverItem, ...serverItem,
...staticData, ...staticData,
@ -48,28 +37,29 @@ const InventoryTab = () => {
textureUrl: getFullTextureUrl(staticData?.texture), textureUrl: getFullTextureUrl(staticData?.texture),
canEquip: !!staticData?.meta?.equipmentSlot, canEquip: !!staticData?.meta?.equipmentSlot,
rarity: staticData?.meta?.rarity || "common", rarity: staticData?.meta?.rarity || "common",
currentSlot: currentSlot,
}; };
}; };
useEffect(() => { useEffect(() => {
if (!socket) return; if (!socket) return;
const refresh = () => { const refreshData = () => {
socket.emit("player:get_inventory"); socket.emit("player:get_inventory");
socket.emit("player:get_equipment"); socket.emit("player:get_equipment");
}; };
refresh(); refreshData();
const handleInventory = (rawItems) => { const handleInventory = (rawItems) => {
setItems(rawItems.map(enrichItemData).filter(Boolean)); setItems(rawItems.map((item) => enrichItemData(item)).filter(Boolean));
}; };
const handleEquipment = (rawEquip) => { const handleEquipment = (rawEquip) => {
const mapped = {}; const mapped = {};
Object.keys(rawEquip).forEach((slot) => { Object.keys(rawEquip).forEach((slot) => {
if (rawEquip[slot]) { if (rawEquip[slot]) {
mapped[slot] = enrichItemData({ itemId: rawEquip[slot] }); mapped[slot] = enrichItemData({ itemId: rawEquip[slot] }, slot);
} }
}); });
setEquipment(mapped); setEquipment(mapped);
@ -77,39 +67,70 @@ const InventoryTab = () => {
socket.on("player:inventory_data", handleInventory); socket.on("player:inventory_data", handleInventory);
socket.on("player:equipment_data", handleEquipment); socket.on("player:equipment_data", handleEquipment);
socket.on("player:item_equipped", refreshData);
socket.on("player:item_unequipped", refreshData);
return () => { return () => {
socket.off("player:inventory_data", handleInventory); socket.off("player:inventory_data", handleInventory);
socket.off("player:equipment_data", handleEquipment); socket.off("player:equipment_data", handleEquipment);
socket.off("player:item_equipped", refreshData);
socket.off("player:item_unequipped", refreshData);
}; };
}, [socket]); }, [socket]);
const handleItemClick = (item) => {
setSelectedItem(item);
setShowModal(true);
};
const equipItem = (item) => { const equipItem = (item) => {
const slot = item.meta?.equipmentSlot; const slot = item.meta?.equipmentSlot;
if (slot) { if (slot) socket.emit("player:equip_item", { itemId: item.id, slot });
socket.emit("player:equip_item", { itemId: item.id, slot });
setShowModal(false);
}
}; };
const unequipItem = (slot) => { const unequipItem = (slot) => {
socket.emit("player:unequip_item", { slot }); socket.emit("player:unequip_item", { slot });
}; };
const equipmentSlots = {
personal: Object.keys(coreSystems)
.filter(
(k) =>
k.startsWith("original:personal_") &&
!k.includes("accessory") &&
k !== "original:personal_weapons",
)
.map((k) => ({
id: k,
label: GameDataManager.t(coreSystems[k].displayName),
})),
weapons: Object.keys(coreSystems)
.filter((k) => k === "original:personal_weapons")
.map((k) => ({
id: k,
label: GameDataManager.t(coreSystems[k].displayName),
})),
accessories: Object.keys(coreSystems)
.filter((k) => k.includes("personal_accessory"))
.map((k) => ({
id: k,
label: GameDataManager.t(coreSystems[k].displayName),
})),
ship: Object.keys(coreSystems)
.filter((k) => k.startsWith("original:ship_"))
.map((k) => ({
id: k,
label: GameDataManager.t(coreSystems[k].displayName),
})),
};
const renderSlotGroup = (title, groupSlots) => ( const renderSlotGroup = (title, groupSlots) => (
<div className="equip-group"> <div className="equip-group">
<div className="group-label">{title}</div> <div className="group-label">{title}</div>
<div className="equip-list-compact"> <div className="equip-list-compact grid-config">
{groupSlots.map((slot) => ( {groupSlots.map((slot) => (
<div <div
key={slot.id} key={slot.id}
className={`equip-row-mini ${equipment[slot.id] ? "occupied" : ""}`} className={`equip-row-mini ${equipment[slot.id] ? "occupied" : ""}`}
onClick={() => equipment[slot.id] && unequipItem(slot.id)} onClick={() =>
equipment[slot.id] &&
(setSelectedItem(equipment[slot.id]), setShowModal(true))
}
> >
<span className="slot-name-tiny">{slot.label}</span> <span className="slot-name-tiny">{slot.label}</span>
<div <div
@ -118,7 +139,6 @@ const InventoryTab = () => {
{equipment[slot.id] ? ( {equipment[slot.id] ? (
<img <img
src={equipment[slot.id].textureUrl} src={equipment[slot.id].textureUrl}
alt={slot.id}
className="item-img-mini" className="item-img-mini"
/> />
) : ( ) : (
@ -133,88 +153,77 @@ const InventoryTab = () => {
return ( return (
<div className="inv-adaptive-container"> <div className="inv-adaptive-container">
<div className="inv-header-compact">
<h2 className="inv-logo">TACTICAL_INVENTORY_V2</h2>
<div className="inv-stats-bar">
<span>
LOAD: <span className="text-cyan">{items.length}/50</span>
</span>
</div>
</div>
<div className="inv-layout-wrapper"> <div className="inv-layout-wrapper">
<section className="inv-panel loadout"> <section className="inv-panel loadout">
<div className="panel-label">CORE_SYSTEMS</div> <MeteorRegion>
{renderSlotGroup("SUIT_GEAR", equipmentSlots.personal)}
{renderSlotGroup("PERSON", equipmentSlots.personal)} {renderSlotGroup("WEAPONRY", equipmentSlots.weapons)}
{renderSlotGroup("SHIP", equipmentSlots.ship)} {renderSlotGroup("ACCESSORIES", equipmentSlots.accessories)}
<div className="separator" />
{renderSlotGroup("SHIP_MODULES", equipmentSlots.ship)}
</MeteorRegion>
</section> </section>
<section className="inv-panel cargo"> <section className="inv-panel cargo">
<div className="panel-label">STORAGE_UNITS</div> <MeteorRegion>
<div className="cargo-grid-v2"> <div className="cargo-grid-v2">
{items.map((item, idx) => ( {items.map((item, idx) => {
<div const isEquipped = Object.values(equipment).some(
key={`${item.id}-${idx}`} (e) => e?.id === item.id,
className={`item-slot ${item.rarity} ${selectedItem?.id === item.id ? "active" : ""}`} );
onClick={() => handleItemClick(item)} return (
> <div
<img key={`${item.id}-${idx}`}
src={item.textureUrl} className={`item-slot ${item.rarity} ${isEquipped ? "equipped-in-storage" : ""}`}
alt="item" onClick={() => {
width={62} setSelectedItem(item);
className="item-img-grid" setShowModal(true);
/> }}
{item.quantity > 1 && ( >
<span className="qty-label">{item.quantity}</span> <img
)} src={item.textureUrl}
</div> width={62}
))} className="item-img-grid"
</div> />
{isEquipped && <div className="equipped-tag">E</div>}
{item.quantity > 1 && (
<span className="qty-label">{item.quantity}</span>
)}
</div>
);
})}
</div>
</MeteorRegion>
</section> </section>
</div> </div>
{showModal && selectedItem && ( {showModal &&
<div className="inv-modal-overlay" onClick={() => setShowModal(false)}> selectedItem &&
<div className="inv-modal-box" onClick={(e) => e.stopPropagation()}> ReactDOM.createPortal(
<div className={`modal-header-scan ${selectedItem.rarity}`}> <ItemModal
<div className="modal-preview-icon"> item={selectedItem}
<img src={selectedItem.textureUrl} alt="preview" width={32} /> isEquipped={
</div> !!selectedItem.currentSlot ||
<h3>{selectedItem.displayName || selectedItem.id}</h3> Object.values(equipment).some((e) => e?.id === selectedItem.id)
</div> }
onClose={() => {
<div className="modal-body-scan"> setShowModal(false);
<p className="description-text"> setSelectedItem(null);
{selectedItem.description || "No data available."} }}
</p> onEquip={equipItem}
onUnequip={(slot) => {
<div className="stats-scanner"> const actualSlot =
{selectedItem.stats && slot ||
Object.entries(selectedItem.stats).map(([k, v]) => ( Object.keys(equipment).find(
<div key={k} className="stat-line"> (k) => equipment[k].id === selectedItem.id,
<span className="label"> );
{GameDataManager.getStatName?.(k)?.toUpperCase() || unequipItem(actualSlot);
k.toUpperCase()} }}
</span> formatStatName={(n) => GameDataManager.getStatName(n).toUpperCase()}
<span className="value">+{v}</span> getStatIcon={(n) => GameDataManager.getStatIcon?.(n)}
</div> />,
))} document.body,
</div> )}
{selectedItem.canEquip && (
<Button
variant="primary"
className="modal-equip-btn"
onClick={() => equipItem(selectedItem)}
>
INITIALIZE_EQUIP
</Button>
)}
</div>
</div>
</div>
)}
</div> </div>
); );
}; };

View File

@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
import GameDataManager from "../../../services/GameDataManager.js"; import GameDataManager from "../../../services/GameDataManager.js";
import "./styles/ItemListTab.css"; import "./styles/ItemListTab.css";
import { config } from "../../../config/api.js"; import { config } from "../../../config/api.js";
import MeteorRegion from "../../../components/Meteor/MeteorRegion.jsx";
const ItemListTab = () => { const ItemListTab = () => {
const [allItems, setAllItems] = useState([]); const [allItems, setAllItems] = useState([]);
@ -83,7 +84,7 @@ const ItemListTab = () => {
</div> </div>
</div> </div>
<div className="inspector-grid"> <MeteorRegion className="inspector-grid">
<div className="inspector-section"> <div className="inspector-section">
<div className="section-title">DATA_DESCRIPTION</div> <div className="section-title">DATA_DESCRIPTION</div>
<p>{selectedItem.description}</p> <p>{selectedItem.description}</p>
@ -103,7 +104,7 @@ const ItemListTab = () => {
</div> </div>
</div> </div>
)} )}
</div> </MeteorRegion>
</div> </div>
)} )}
</div> </div>
@ -131,7 +132,8 @@ const ItemListTab = () => {
</button> </button>
))} ))}
</div> </div>
<div className="items-list-scroll">
<MeteorRegion className="items-list-scroll">
{filteredItems.map((item) => ( {filteredItems.map((item) => (
<div <div
key={item.id} key={item.id}
@ -147,7 +149,7 @@ const ItemListTab = () => {
</div> </div>
</div> </div>
))} ))}
</div> </MeteorRegion>
</div> </div>
{(!isMobile || (isMobile && selectedItem)) && renderInspector()} {(!isMobile || (isMobile && selectedItem)) && renderInspector()}
</div> </div>

View File

@ -1,36 +1,28 @@
/* Ховаємо праву панель на мобілках */
@media (max-width: 768px) {
.inventory-container {
grid-template-columns: 1fr; /* Тільки одна колонка (сітка) */
}
.item-details {
display: none; /* Панель справа зникає */
}
}
/* Стилі модального вікна */
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
height: 100%; width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.85); background: rgba(0, 0, 0, 0.85);
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
z-index: 1000; z-index: 9999;
padding: 20px; backdrop-filter: blur(4px);
width: 100%;
} }
.modal-content { .modal-content {
background: var(--card-bg); background: #12151a;
border: 1px solid var(--primary-color); border: 1px solid #00d2ff;
border-radius: 12px; border-radius: 8px;
width: 100%; width: 100%;
padding: 40px; max-width: 400px;
padding: 25px;
position: relative; position: relative;
box-shadow: 0 0 30px rgba(0, 210, 255, 0.2); box-shadow: 0 0 30px rgba(0, 210, 255, 0.2);
color: #fff;
font-family: "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
} }
.modal-close { .modal-close {
@ -39,7 +31,114 @@
right: 15px; right: 15px;
background: none; background: none;
border: none; border: none;
color: #fff; color: #888;
font-size: 24px; font-size: 28px;
cursor: pointer; cursor: pointer;
transition: color 0.2s;
}
.modal-close:hover {
color: #fff;
}
.details-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 10px;
}
.item-name {
margin: 0;
font-size: 1.2rem;
text-transform: uppercase;
letter-spacing: 1px;
}
.rarity-badge {
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 4px;
text-transform: uppercase;
background: rgba(255, 255, 255, 0.1);
}
.item-name.common {
color: #ffffff;
}
.item-name.uncommon {
color: #1eff00;
}
.item-name.rare {
color: #0070dd;
}
.item-name.epic {
color: #a335ee;
}
.item-name.legendary {
color: #ff8000;
}
.item-description {
font-size: 0.9rem;
color: #aaa;
line-height: 1.4;
margin-bottom: 20px;
}
.item-stats-container {
background: rgba(0, 0, 0, 0.3);
padding: 10px;
border-radius: 4px;
margin-bottom: 25px;
}
.stat-row {
display: flex;
justify-content: space-between;
padding: 5px 0;
font-size: 0.85rem;
}
.stat-label {
color: #00d2ff;
display: flex;
align-items: center;
gap: 8px;
}
.stat-value {
color: #fff;
font-weight: bold;
}
.btn-equip {
width: 100%;
padding: 12px;
background: none;
border: 1px solid #00d2ff;
color: #00d2ff;
cursor: pointer;
text-transform: uppercase;
font-weight: bold;
letter-spacing: 1px;
transition: all 0.2s;
}
.btn-equip:hover {
background: #00d2ff;
color: #000;
box-shadow: 0 0 15px rgba(0, 210, 255, 0.4);
}
.btn-equip.unequip {
border-color: #ff4444;
color: #ff4444;
}
.btn-equip.unequip:hover {
background: #ff4444;
color: #fff;
} }

View File

@ -1,109 +1,145 @@
.crafting-container { .crafting-container {
padding: 20px; padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
} }
.crafting-header { .crafting-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 20px; margin-bottom: 20px;
}
.crafting-header h2 {
font-size: 1.2rem;
} }
.crafting-grid { .crafting-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 15px; gap: 15px;
margin-top: 20px; margin-top: 20px;
} }
.recipe-card { .recipe-card {
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
padding: 20px; padding: 15px;
border-radius: 8px; border-radius: 8px;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
} }
.recipe-card:hover { .recipe-card:hover {
transform: translateY(-5px); transform: translateY(-5px);
border-color: var(--primary-color); border-color: var(--primary-color);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
} }
.recipe-icon { .recipe-icon img {
font-size: 1.5rem; max-width: 50px;
color: var(--primary-color); height: auto;
} }
.recipe-name { .recipe-name {
font-size: 0.9rem; font-size: 0.85rem;
color: #fff; line-height: 1.2;
color: #fff;
font-weight: 500;
}
.badge-time {
font-size: 0.75rem;
color: var(--text-secondary);
} }
/* Категорії */
.crafting-categories { .crafting-categories {
display: flex; display: flex;
gap: 12px; gap: 10px;
margin-bottom: 20px; margin-bottom: 15px;
padding: 10px 5px; padding: 5px 0;
overflow-x: auto; overflow-x: auto;
scrollbar-width: none; scrollbar-width: none;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
.crafting-categories::-webkit-scrollbar { .crafting-categories::-webkit-scrollbar {
display: none; display: none;
} }
.crafting-cat-btn { .crafting-cat-btn {
flex: 0 0 auto; flex: 0 0 auto;
padding: 10px 22px; padding: 8px 16px;
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 6px; border-radius: 4px;
color: var(--text-secondary); color: var(--text-secondary);
font-family: 'Orbitron', sans-serif; font-family: "Orbitron", sans-serif;
font-size: 0.85rem; font-size: 0.75rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 1px; letter-spacing: 0.5px;
white-space: nowrap; white-space: nowrap;
cursor: pointer; cursor: pointer;
transition: all 0.25s ease; transition: all 0.2s ease;
}
.crafting-cat-btn:hover {
background: rgba(var(--primary-rgb), 0.08);
border-color: var(--primary-color);
color: #fff;
} }
.crafting-cat-btn.active { .crafting-cat-btn.active {
background: var(--primary-color); background: var(--primary-color);
border-color: var(--primary-color); border-color: var(--primary-color);
color: #000; color: #000;
font-weight: 700; font-weight: 700;
box-shadow: 0 0 15px rgba(var(--primary-rgb), 0.3);
} }
@media (max-width: 768px) { @media (max-width: 600px) {
.crafting-categories { .crafting-container {
gap: 8px; padding: 12px;
margin-bottom: 15px; }
}
.crafting-cat-btn { .crafting-header {
padding: 8px 16px; margin-bottom: 12px;
font-size: 0.75rem; }
}
.crafting-grid { .crafting-header h2 {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); font-size: 1rem;
gap: 10px; }
}
.crafting-grid {
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-top: 10px;
}
.recipe-card {
padding: 10px;
gap: 6px;
}
.recipe-icon img {
max-width: 40px;
}
.recipe-name {
font-size: 0.75rem;
}
.crafting-cat-btn {
padding: 6px 12px;
font-size: 0.7rem;
}
}
.tab-content.active {
height: 100%;
overflow: hidden;
}
.meteor-region-content {
width: 100%;
} }

View File

@ -1,4 +1,3 @@
/* Базовий контейнер з обмеженням висоти */
.inv-adaptive-container { .inv-adaptive-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -10,7 +9,6 @@
overflow: hidden; overflow: hidden;
} }
/* Header */
.inv-header-compact { .inv-header-compact {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -18,6 +16,7 @@
border-bottom: 1px solid #1a2638; border-bottom: 1px solid #1a2638;
padding-bottom: 10px; padding-bottom: 10px;
margin-bottom: 5px; margin-bottom: 5px;
flex-shrink: 0;
} }
.inv-logo { .inv-logo {
@ -26,16 +25,17 @@
letter-spacing: 3px; letter-spacing: 3px;
margin: 0; margin: 0;
} }
.inv-stats-bar { .inv-stats-bar {
font-size: 11px; font-size: 11px;
color: #4a5d75; color: #4a5d75;
} }
.text-cyan { .text-cyan {
color: #00d4ff; color: #00d4ff;
font-weight: bold; font-weight: bold;
} }
/* Layout Wrapper */
.inv-layout-wrapper { .inv-layout-wrapper {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -44,13 +44,13 @@
min-height: 0; min-height: 0;
} }
/* Спільні стилі панелей */
.inv-panel { .inv-panel {
background: rgba(10, 15, 24, 0.85); background: rgba(10, 15, 24, 0.85);
border: 1px solid #1a2638; border: 1px solid #1a2638;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.5); box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.5);
min-height: 0;
} }
.panel-label { .panel-label {
@ -61,26 +61,55 @@
letter-spacing: 2px; letter-spacing: 2px;
font-weight: 800; font-weight: 800;
border-bottom: 1px solid #1a2638; border-bottom: 1px solid #1a2638;
flex-shrink: 0;
} }
/* Loadout (L) */
.loadout { .loadout {
width: 240px; width: 240px;
flex-shrink: 0; flex-shrink: 0;
overflow-y: auto;
} }
.loadout::-webkit-scrollbar,
.cargo-grid-v2::-webkit-scrollbar {
width: 4px;
}
.loadout::-webkit-scrollbar-thumb,
.cargo-grid-v2::-webkit-scrollbar-thumb {
background: #1a2638;
border-radius: 2px;
}
.loadout::-webkit-scrollbar-thumb:hover,
.cargo-grid-v2::-webkit-scrollbar-thumb:hover {
background: #00d4ff;
}
.equip-list-compact { .equip-list-compact {
padding: 10px; padding: 10px;
overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
} }
.equip-group {
margin-bottom: 15px;
}
.group-label {
font-size: 10px;
color: #4a5d75;
margin-bottom: 5px;
padding-left: 5px;
text-transform: uppercase;
}
.equip-row-mini { .equip-row-mini {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 8px 12px; padding: 6px 10px;
background: rgba(255, 255, 255, 0.02); background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(26, 38, 56, 0.8); border: 1px solid rgba(26, 38, 56, 0.8);
transition: 0.2s; transition: 0.2s;
@ -91,37 +120,46 @@
border-color: #00d4ff; border-color: #00d4ff;
background: rgba(0, 212, 255, 0.05); background: rgba(0, 212, 255, 0.05);
} }
.slot-name-tiny { .slot-name-tiny {
font-size: 9px; font-size: 9px;
color: #4a5d75; color: #4a5d75;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.equip-box-mini { .equip-box-mini {
width: 36px; width: 32px;
height: 36px; height: 32px;
background: #000; background: #000;
border: 1px solid #1a2638; border: 1px solid #1a2638;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #00d4ff; color: #00d4ff;
font-size: 1.1rem; font-size: 0.9rem;
flex-shrink: 0;
} }
/* Cargo (R) */
.cargo { .cargo {
flex: 1; flex: 1;
min-width: 0;
} }
.cargo-grid-v2 { .cargo-grid-v2 {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(65px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
grid-auto-rows: 65px; gap: 8px;
gap: 10px;
padding: 15px; padding: 15px;
overflow-y: auto; overflow-y: auto;
flex: 1;
} }
.item-slot { .item-slot {
width: 60px;
height: 60px;
background: rgba(5, 8, 12, 0.9); background: rgba(5, 8, 12, 0.9);
border: 1px solid #1a2638; border: 1px solid #1a2638;
display: flex; display: flex;
@ -130,183 +168,68 @@
position: relative; position: relative;
cursor: pointer; cursor: pointer;
transition: 0.2s; transition: 0.2s;
overflow: hidden;
}
.item-img-grid,
.item-img-mini {
max-width: 90%;
max-height: 90%;
object-fit: contain;
} }
.item-slot:hover { .item-slot:hover {
border-color: #00d4ff; border-color: #00d4ff;
background: rgba(0, 212, 255, 0.1); background: rgba(0, 212, 255, 0.1);
} }
.item-slot.active { .item-slot.active {
border-color: #00d4ff; border-color: #00d4ff;
box-shadow: inset 0 0 10px rgba(0, 212, 255, 0.3); box-shadow: inset 0 0 10px rgba(0, 212, 255, 0.3);
} }
/* Rarity Colors */ .separator {
.item-slot.rare, height: 1px;
.equip-box-mini.rare { background: #1a2638;
border-bottom: 2px solid #00d4ff; margin: 10px 5px;
}
.item-slot.epic,
.equip-box-mini.epic {
border-bottom: 2px solid #a335ee;
}
.item-slot.legendary,
.equip-box-mini.legendary {
border-bottom: 2px solid #ffaa00;
} }
.qty-label {
position: absolute;
bottom: 2px;
right: 5px;
font-size: 10px;
color: #00d4ff;
font-family: monospace;
}
.item-slot.empty {
opacity: 0.15;
cursor: default;
}
/* Modal Styles */
.inv-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.inv-modal-box {
background: #0a0f18;
border: 1px solid #1a2638;
width: 90%;
max-width: 380px;
box-shadow: 0 0 40px rgba(0, 0, 0, 0.8);
}
.modal-header-scan {
padding: 20px;
border-bottom: 1px solid #1a2638;
position: relative;
}
.modal-header-scan.rare {
border-left: 5px solid #00d4ff;
}
.modal-header-scan.legendary {
border-left: 5px solid #ffaa00;
}
.rarity-text {
font-size: 10px;
color: #4a5d75;
text-transform: uppercase;
margin-bottom: 5px;
}
.modal-header-scan h3 {
margin: 0;
font-size: 1.2rem;
color: #fff;
}
.close-x {
position: absolute;
top: 15px;
right: 15px;
background: none;
border: none;
color: #4a5d75;
font-size: 24px;
cursor: pointer;
}
.modal-body-scan {
padding: 20px;
}
.description-text {
font-size: 12px;
color: #8a9cb3;
line-height: 1.5;
margin-bottom: 20px;
}
.stats-scanner {
background: rgba(0, 0, 0, 0.4);
padding: 12px;
margin-bottom: 20px;
}
.stat-line {
display: flex;
justify-content: space-between;
font-size: 12px;
padding: 6px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.stat-line .value {
color: #00ff88;
}
.modal-equip-btn {
width: 100%;
letter-spacing: 2px;
font-weight: 900;
}
/* Адаптивність під мобільні пристрої */
@media (max-width: 768px) { @media (max-width: 768px) {
.inv-adaptive-container {
height: auto;
overflow-y: visible;
}
.inv-layout-wrapper { .inv-layout-wrapper {
flex-direction: column; flex-direction: column;
overflow-y: auto; height: auto;
} }
.loadout { .loadout {
width: 100%; width: 100%;
flex: 0 0 auto; max-height: 300px;
}
.equip-list-compact {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
} }
.cargo { .cargo {
min-height: 450px; height: 400px;
flex: 0 0 auto;
}
.inv-adaptive-container {
height: 100%;
overflow-y: auto;
padding: 10px;
} }
} }
.cargo-grid-v2 { .item-slot.equipped-in-storage {
display: grid; border: 2px solid #00d2ff;
grid-template-columns: repeat( box-shadow: inset 0 0 10px rgba(0, 210, 255, 0.3);
auto-fill, opacity: 0.8;
minmax(60px, 1fr)
); /* Кожен слот мінімум 60px */
gap: 8px;
padding: 10px;
} }
.item-slot { .equipped-tag {
width: 60px; /* Жорсткий розмір слота */ position: absolute;
height: 60px; /* Жорсткий розмір слота */ top: -5px;
position: relative; right: -5px;
display: flex; background: #00d2ff;
align-items: center; color: #000;
justify-content: center; font-size: 10px;
background: rgba(0, 0, 0, 0.5); font-weight: bold;
border: 1px solid #333; padding: 2px 5px;
overflow: hidden; /* Обрізаємо все, що виходить за межі 60x60 */ border-radius: 3px;
z-index: 2;
} }

View File

@ -11,25 +11,7 @@ class InventoryManager {
async getEquipment(playerId) { async getEquipment(playerId) {
const player = await Player.findByPk(playerId); const player = await Player.findByPk(playerId);
if (!player) return {}; return player ? player.equipment : {};
const slots = [
"personal_helmet",
"personal_suit",
"personal_gloves",
"personal_backpack",
"personal_boots",
"personal_weapons",
"ship_hull",
"ship_shields",
"ship_engines",
];
const equipment = {};
slots.forEach((slot) => {
equipment[slot] = player[`equipped_${slot}`] || null;
});
return equipment;
} }
async equipItem(playerId, itemId, slot) { async equipItem(playerId, itemId, slot) {
@ -44,14 +26,31 @@ class InventoryManager {
throw new Error("INVALID_SLOT_FOR_ITEM"); throw new Error("INVALID_SLOT_FOR_ITEM");
} }
const dbField = `equipped_${slot}`.replace("original:", ""); const player = await Player.findByPk(playerId);
await Player.update({ [dbField]: itemId }, { where: { id: playerId } }); if (!player) throw new Error("PLAYER_NOT_FOUND");
const currentEquip = player.equipment;
currentEquip[slot] = itemId;
player.equipment = currentEquip;
player.changed("equipment", true);
await player.save();
return itemInfo; return itemInfo;
} }
async unequipItem(playerId, slot) { async unequipItem(playerId, slot) {
const dbField = `equipped_${slot}`; const player = await Player.findByPk(playerId);
await Player.update({ [dbField]: null }, { where: { id: playerId } }); if (!player) throw new Error("PLAYER_NOT_FOUND");
const currentEquip = player.equipment;
if (currentEquip[slot]) {
delete currentEquip[slot];
player.equipment = currentEquip;
player.changed("equipment", true);
await player.save();
}
return true; return true;
} }
} }

View File

@ -1,6 +1,6 @@
class SessionManager { class SessionManager {
constructor() { constructor() {
this.sessions = new Map(); // socket.id => session data this.sessions = new Map();
} }
addPlayer(socketId, playerRaw) { addPlayer(socketId, playerRaw) {
@ -15,29 +15,18 @@ class SessionManager {
scene: "world", scene: "world",
sceneData: null, sceneData: null,
joinedAt: Date.now(), joinedAt: Date.now(),
equipment: { equipment: playerRaw.equipment || {},
personal_helmet: playerRaw.equipped_personal_helmet,
personal_suit: playerRaw.equipped_personal_suit,
personal_gloves: playerRaw.equipped_personal_gloves,
personal_backpack: playerRaw.equipped_personal_backpack,
personal_boots: playerRaw.equipped_personal_boots,
personal_weapons: playerRaw.equipped_personal_weapons,
ship_hull: playerRaw.equipped_ship_hull,
ship_shields: playerRaw.equipped_ship_shields,
ship_engines: playerRaw.equipped_ship_engines,
ship_weapon_1: playerRaw.equipped_ship_weapon_1,
ship_weapon_2: playerRaw.equipped_ship_weapon_2,
},
}); });
} }
updateEquipment(socketId, slot, itemId) { updateEquipment(socketId, slot, itemId) {
const session = this.sessions.get(socketId); const session = this.sessions.get(socketId);
if (session && session.equipment.hasOwnProperty(slot)) { if (session) {
session.equipment[slot] = itemId; if (itemId === null) {
} else if (session) { delete session.equipment[slot];
session.equipment[slot] = itemId; } else {
session.equipment[slot] = itemId;
}
} }
} }
@ -47,7 +36,6 @@ class SessionManager {
session.scene = sceneName; session.scene = sceneName;
session.sceneData = data; session.sceneData = data;
session.status = sceneName === "dungeon" ? "in_mission" : "active"; session.status = sceneName === "dungeon" ? "in_mission" : "active";
console.log(`[Session] Player ${session.id} switched to ${sceneName}`);
} }
} }

View File

@ -42,22 +42,17 @@ const Player = sequelize.define("Player", {
type: DataTypes.DATE, type: DataTypes.DATE,
defaultValue: DataTypes.NOW, defaultValue: DataTypes.NOW,
}, },
equipment: {
equipped_personal_helmet: { type: DataTypes.STRING }, type: DataTypes.TEXT,
equipped_personal_suit: { type: DataTypes.STRING }, defaultValue: "{}",
equipped_personal_gloves: { type: DataTypes.STRING }, get() {
equipped_personal_backpack: { type: DataTypes.STRING }, const val = this.getDataValue("equipment");
equipped_personal_boots: { type: DataTypes.STRING }, return val ? JSON.parse(val) : {};
equipped_personal_weapons: { type: DataTypes.STRING }, },
set(val) {
equipped_personal_accessory_1: { type: DataTypes.STRING }, this.setDataValue("equipment", JSON.stringify(val));
equipped_personal_accessory_2: { type: DataTypes.STRING }, },
},
equipped_ship_hull: { type: DataTypes.STRING },
equipped_ship_shields: { type: DataTypes.STRING },
equipped_ship_engines: { type: DataTypes.STRING },
equipped_ship_weapon_1: { type: DataTypes.STRING },
equipped_ship_weapon_2: { type: DataTypes.STRING },
}); });
module.exports = Player; module.exports = Player;

View File

@ -6,31 +6,20 @@ module.exports = (io, socket) => {
if (!userId) return; if (!userId) return;
socket.on("player:get_inventory", async () => { socket.on("player:get_inventory", async () => {
try { const items = await inventoryManager.getInventory(userId);
const items = await inventoryManager.getInventory(userId); socket.emit("player:inventory_data", items);
socket.emit("player:inventory_data", items);
} catch (err) {
socket.emit("error", { message: "LOAD_INVENTORY_FAILED" });
}
}); });
socket.on("player:get_equipment", async () => { socket.on("player:get_equipment", async () => {
try { const equipment = await inventoryManager.getEquipment(userId);
const equipment = await inventoryManager.getEquipment(userId); socket.emit("player:equipment_data", equipment);
socket.emit("player:equipment_data", equipment);
} catch (err) {
console.error(err);
}
}); });
socket.on("player:equip_item", async ({ itemId, slot }) => { socket.on("player:equip_item", async ({ itemId, slot }) => {
try { try {
const itemInfo = await inventoryManager.equipItem(userId, itemId, slot); const itemInfo = await inventoryManager.equipItem(userId, itemId, slot);
sessionManager.updateEquipment(socket.id, slot, itemId); sessionManager.updateEquipment(socket.id, slot, itemId);
socket.emit("player:item_equipped", { slot, itemId }); socket.emit("player:item_equipped", { slot, itemId });
socket.broadcast.emit("player:visible_changed", { socket.broadcast.emit("player:visible_changed", {
playerId: userId, playerId: userId,
slot, slot,
@ -45,7 +34,6 @@ module.exports = (io, socket) => {
try { try {
await inventoryManager.unequipItem(userId, slot); await inventoryManager.unequipItem(userId, slot);
sessionManager.updateEquipment(socket.id, slot, null); sessionManager.updateEquipment(socket.id, slot, null);
socket.emit("player:item_unequipped", { slot }); socket.emit("player:item_unequipped", { slot });
socket.broadcast.emit("player:visible_changed", { socket.broadcast.emit("player:visible_changed", {
playerId: userId, playerId: userId,
@ -53,7 +41,7 @@ module.exports = (io, socket) => {
texturePath: null, texturePath: null,
}); });
} catch (err) { } catch (err) {
console.error(err); socket.emit("error", { message: "UNEQUIP_FAILED" });
} }
}); });
}; };