This commit is contained in:
Your Name 2026-03-29 14:21:28 -05:00
commit bb8192697b
62 changed files with 2502 additions and 559 deletions

105
Admin Panel.md Normal file
View File

@ -0,0 +1,105 @@
# Admin Panel
## - Items List
### Panel Setup
- Tags (Dynamic)
- Default (all)
- Alloys
- Circuits
- Customizables
- Ingots
- Materials
- Ores
- Personal
- Shop
- Space Ships
- Shields
- Weapons
## - Hostiles List
### Panel Setup
- Tags (Dynamic)
- Default (all)
- Ground Units
- Space Ships
## - Player List
- Tags (Dynamic)
- Default (all)
- Members
- Moderators
- Admins
## - Permissions
### Panel hierarchy
- Roles (Static)
- Edit
- Role Tag
- Role Name
- Permission Nodes
- Chat Font Color
- New
- Role Tag
- Role Name
- Permission Nodes
- Chat Font Color
- Users (Static)
- Edit
- Permission Nodes
### Default Roles
- Members Permission Nodes
- permission.node.player.text.chat
- permission.node.player.text.chat.message
- permission.node.player.text.chat.message.alliance
- permission.node.player.text.chat.message.direct
- permission.node.player.text.chat.message.fleet
- permission.node.player.text.color
- Moderator Permission Nodes
- roles.members.*
- permission.node.player.server.ban
- permission.node.player.server.timeout
- permission.node.player.server.unban
- permission.node.player.text.ban
- permission.node.player.text.timeout.["time_in_seconds"]
- permission.node.player.text.unban
- Admin Permission Nodes
- roles.moderators.*
- permission.node.player.clean.database.all
- permission.node.player.clean.database.item
- permission.node.player.clean.inventory.all
- permission.node.player.clean.inventory.item
- permission.node.player.console
- permission.node.player.give.exp
- permission.node.player.give.item
- permission.node.player.give.skills
- permission.node.player.permission.add.["permission.node.*"]
- permission.node.player.permission.edit.["permission.node.*"]
- permission.node.player.permission.remove.["permission.node.*"]
- permission.node.player.role.add.["permission.node.*"]
- permission.node.player.role.edit
- permission.node.player.role.give
- permission.node.player.role.hierarchy
- permission.node.player.role.new
- permission.node.player.role.remove
- permission.node.player.role.remove.["permission.node.*"]
- Super Admin Permission Nodes (First Person On Server)
- permission.node.player.bypass
### Extra Nodes
- permission.node.tab.*.(allow/deny)
- permission.node.tab.dashboard.(allow/deny)
- permission.node.tab.dungeons.*
- ["permission.node.tab.dungeons.{datapackId}.{dungeonId}.(allow/deny)"]
- permission.node.tab.skills.*
- ["permission.node.tab.skills.{datapackId}.{skillsId}.(allow/deny)"]
- permission.node.tab.inventory.*
- ["permission.node.tab.inventory.{datapackId}.{core_systemId}.(allow/deny)"]
- permission.node.tab.shop.*
- ["permission.node.tab.shop.{datapackId}.{shopId}.(allow/deny)"]
- permission.node.tab.crafting.*
- ["permission.node.tab.crafting.{datapackId}.{craftingId}.(allow/deny)"]
- permission.node.tab.admin.*
- ["permission.node.tab.admin.{datapackId}.{adminId}.(allow/deny)"]
- permission.node.tab.chat.*
- ["permission.node.tab.chat.{datapackId}.{chatId}.(allow/deny)"]
- permission.node.tab.alerts.(allow/deny)
- permission.node.generate.credits.online.*.(allow/deny)
- permission.node.generate.credits.offline.*.(allow/deny)
- permission.node.generate.datacores.online.*.(allow/deny)
- permission.node.generate.datacores.offline.*.(allow/deny)
- permission.node.player.nickname.(allow/deny)

View File

@ -2,8 +2,9 @@ import React, { useState, useEffect, useRef } from "react";
import "./Console.css"; import "./Console.css";
import { useSocket } from "../../hooks/useSocket"; import { useSocket } from "../../hooks/useSocket";
import ConsoleManager from "../../services/ConsoleManager"; import ConsoleManager from "../../services/ConsoleManager";
import PlayerManager from "../../services/PlayerManager"; // Імпортуємо менеджер гравців
const Console = ({ players = [] }) => { const Console = () => {
const { socket } = useSocket(); const { socket } = useSocket();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
@ -11,6 +12,8 @@ const Console = ({ players = [] }) => {
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const [logs, setLogs] = useState(ConsoleManager.logs); const [logs, setLogs] = useState(ConsoleManager.logs);
const [playerNames, setPlayerNames] = useState([]);
const inputRef = useRef(null); const inputRef = useRef(null);
const scrollRef = useRef(null); const scrollRef = useRef(null);
@ -18,6 +21,21 @@ const Console = ({ players = [] }) => {
ConsoleManager.init(socket, setLogs); ConsoleManager.init(socket, setLogs);
}, [socket]); }, [socket]);
useEffect(() => {
const updatePlayers = (data) => {
setPlayerNames([...data.online, ...data.offline]);
};
const unsubscribe = PlayerManager.subscribe(updatePlayers);
setPlayerNames([
...PlayerManager.onlinePlayers,
...PlayerManager.offlinePlayers,
]);
return () => unsubscribe();
}, []);
useEffect(() => { useEffect(() => {
if (scrollRef.current) { if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
@ -36,12 +54,11 @@ const Console = ({ players = [] }) => {
}, []); }, []);
useEffect(() => { useEffect(() => {
setSuggestions(ConsoleManager.getSuggestions(input, players)); setSuggestions(ConsoleManager.getSuggestions(input, playerNames));
setSelectedIndex(0); setSelectedIndex(0);
}, [input, players]); }, [input, playerNames]);
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
// 1. Tab для автозаповнення
if (e.key === "Tab" && suggestions.length > 0) { if (e.key === "Tab" && suggestions.length > 0) {
e.preventDefault(); e.preventDefault();
const parts = input.split(" "); const parts = input.split(" ");
@ -74,6 +91,7 @@ const Console = ({ players = [] }) => {
const handleSubmit = (e) => { const handleSubmit = (e) => {
e.preventDefault(); e.preventDefault();
if (!input.trim()) return;
ConsoleManager.execute(input); ConsoleManager.execute(input);
setInput(""); setInput("");
}; };

20
client/src/config/api.js Normal file
View File

@ -0,0 +1,20 @@
const getActiveServer = () => {
const saved = localStorage.getItem("activeServer");
if (!saved) return null;
try {
return JSON.parse(saved);
} catch (e) {
return null;
}
};
export const getServerUrl = () => {
const server = getActiveServer();
return server ? server.connectUrl : null;
};
export const config = {
get serverUrl() {
return getServerUrl();
},
};

View File

@ -6,6 +6,7 @@ import React, {
useEffect, useEffect,
} from "react"; } from "react";
import { io } from "socket.io-client"; import { io } from "socket.io-client";
import PlayerManager from "../services/PlayerManager"; // Імпортуємо менеджер
export const SocketContext = createContext(null); export const SocketContext = createContext(null);
@ -19,12 +20,12 @@ export const SocketProvider = ({ children }) => {
if (socketRef.current?.connected) { if (socketRef.current?.connected) {
return socketRef.current; return socketRef.current;
} }
const userInfo = JSON.parse(localStorage.getItem("user")); const userInfo = JSON.parse(localStorage.getItem("user"));
const newSocket = io(url, { const newSocket = io(url, {
auth: { token, username: userInfo.username }, auth: { token, username: userInfo?.username },
transports: ["websocket"], transports: ["websocket"],
upgrade: true,
withCredentials: true,
reconnectionAttempts: 5, reconnectionAttempts: 5,
}); });
@ -33,8 +34,19 @@ export const SocketProvider = ({ children }) => {
setIsConnected(true); setIsConnected(true);
}); });
newSocket.on("session:ready", (data) => {
PlayerManager.setInitialState(data.onlinePlayers, data.offlinePlayers);
});
newSocket.on("player:joined", (data) => {
PlayerManager.handlePlayerJoined(data.username);
});
newSocket.on("player:left", (data) => {
PlayerManager.handlePlayerLeft(data.username);
});
newSocket.on("disconnect", (reason) => { newSocket.on("disconnect", (reason) => {
console.log("❌ Disconnected:", reason);
setIsConnected(false); setIsConnected(false);
}); });

View File

@ -2,7 +2,6 @@ import GameDataManager from "./GameDataManager";
class ConsoleManager { class ConsoleManager {
constructor() { constructor() {
// Залишили тільки вказані команди
this.commands = ["/clear", "/give", "/set_exp"]; this.commands = ["/clear", "/give", "/set_exp"];
this.logs = ["[System] Console Manager Ready. Press F9 to hide."]; this.logs = ["[System] Console Manager Ready. Press F9 to hide."];
this.history = []; this.history = [];
@ -27,46 +26,45 @@ class ConsoleManager {
} }
getSuggestions(input, players = []) { getSuggestions(input, players = []) {
const safePlayers = (players || []).filter((p) => typeof p === "string");
const parts = input.split(" "); const parts = input.split(" ");
if (parts.length === 0) return []; if (parts.length === 0) return [];
const cmd = parts[0].toLowerCase(); const cmd = parts[0].toLowerCase();
// 1. Пропозиції команд
if (parts.length === 1 && input.startsWith("/")) { if (parts.length === 1 && input.startsWith("/")) {
return this.commands.filter((c) => c.startsWith(cmd)); return this.commands.filter((c) => c.startsWith(cmd));
} }
// 2. Логіка для /give (Гравець -> Предмет)
if (cmd === "/give") { if (cmd === "/give") {
if (parts.length === 2) { if (parts.length === 2) {
const search = parts[1].toLowerCase(); const search = parts[1].toLowerCase();
return players.filter((p) => p.toLowerCase().startsWith(search)); return safePlayers.filter((p) => p.toLowerCase().startsWith(search));
} }
if (parts.length === 3) { if (parts.length === 3) {
const search = parts[2].toLowerCase(); const search = parts[2].toLowerCase();
const itemIds = Array.from(GameDataManager.items.keys()); const itemIds = Array.from(GameDataManager.items.keys());
return itemIds.filter((id) => id.includes(search)).slice(0, 15); return itemIds
.filter((id) => id.toLowerCase().includes(search))
.slice(0, 15);
} }
} }
// 3. Логіка для /set_exp (Гравець -> Кількість)
if (cmd === "/set_exp") { if (cmd === "/set_exp") {
if (parts.length === 2) { if (parts.length === 2) {
const search = parts[1].toLowerCase(); const search = parts[1].toLowerCase();
return players.filter((p) => p.toLowerCase().startsWith(search)); return safePlayers.filter((p) => p.toLowerCase().startsWith(search));
} }
} }
// 4. Логіка для /clear (Гравець)
if (cmd === "/clear" && parts.length === 2) { if (cmd === "/clear" && parts.length === 2) {
const search = parts[1].toLowerCase(); const search = parts[1].toLowerCase();
return players.filter((p) => p.toLowerCase().startsWith(search)); return safePlayers.filter((p) => p.toLowerCase().startsWith(search));
} }
return []; return [];
} }
execute(input) { execute(input) {
const trimmed = input.trim(); const trimmed = input.trim();
if (!trimmed) return; if (!trimmed) return;

View File

@ -9,7 +9,7 @@ class GameDataManager {
this.enemies = new Map(); this.enemies = new Map();
this.translations = {}; this.translations = {};
this.manifest = {}; this.manifest = {};
this.currentLang = localStorage.getItem("selected_lang") || "en_us"; this.currentLang = localStorage.getItem("selected_lang") || "en_US";
this.isLoaded = false; this.isLoaded = false;
} }

View File

@ -0,0 +1,46 @@
class PlayerManager {
constructor() {
this.onlinePlayers = [];
this.offlinePlayers = [];
this.listeners = new Set();
}
setInitialState(online, offline) {
this.onlinePlayers = online || [];
this.offlinePlayers = offline || [];
this.notify();
}
handlePlayerJoined(username) {
if (!this.onlinePlayers.includes(username)) {
this.onlinePlayers.push(username);
this.offlinePlayers = this.offlinePlayers.filter((u) => u !== username);
this.notify();
}
}
handlePlayerLeft(username) {
this.onlinePlayers = this.onlinePlayers.filter((u) => u !== username);
if (!this.offlinePlayers.includes(username)) {
this.offlinePlayers.push(username);
}
this.notify();
}
subscribe(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
notify() {
this.listeners.forEach((listener) =>
listener({
online: this.onlinePlayers,
offline: this.offlinePlayers,
}),
);
}
}
export default new PlayerManager();

View File

@ -12,27 +12,25 @@ import BaseTab from "./tabs/BaseTab";
import QuestsTab from "./tabs/QuestsTab"; import QuestsTab from "./tabs/QuestsTab";
import ShopTab from "./tabs/ShopTab"; import ShopTab from "./tabs/ShopTab";
import CraftingTab from "./tabs/CraftingTab"; import CraftingTab from "./tabs/CraftingTab";
import ChatTab from "./tabs/ChatTab";
import ItemListTab from "./tabs/ItemListTab.jsx";
import Notification from "./tabs/NotificationTab.jsx";
import DungeonScreen from "./components/DungeonScreen"; import DungeonScreen from "./components/DungeonScreen";
import { useSocket } from "../../hooks/useSocket.js"; import { useSocket } from "../../hooks/useSocket.js";
import ItemListTab from "./tabs/ItemListTab.jsx";
const GameInterface = ({ onExit }) => { const GameInterface = ({ onExit }) => {
const [activeTab, setActiveTab] = useState("dashboard"); const [activeTab, setActiveTab] = useState("dashboard");
const [activeDungeonSession, setActiveDungeonSession] = useState(null); const [activeDungeonSession, setActiveDungeonSession] = useState(null);
const [onlinePlayers, setOnlinePlayers] = useState([]);
const { socket } = useSocket(); const { socket } = useSocket();
useEffect(() => { useEffect(() => {
if (!socket) return; if (!socket) return;
socket.on("dungeon:started", (sessionData) => { socket.on("dungeon:started", (sessionData) => {
console.log("Deployment initiated:", sessionData);
setActiveDungeonSession(sessionData); setActiveDungeonSession(sessionData);
}); });
socket.on("dungeon:completed", (results) => { socket.on("dungeon:completed", (results) => {
console.log("Mission accomplished:", results);
setActiveDungeonSession(null); setActiveDungeonSession(null);
}); });
@ -81,19 +79,18 @@ const GameInterface = ({ onExit }) => {
shop: <ShopTab />, shop: <ShopTab />,
crafting: <CraftingTab />, crafting: <CraftingTab />,
itemlist: <ItemListTab />, itemlist: <ItemListTab />,
chat: <ChatTab />,
notifications: <Notification />,
}; };
return ( return (
<div id="gameInterface" className="game-interface"> <div id="gameInterface" className="game-interface">
<GameHeader onReturn={onExit} /> <GameHeader onReturn={onExit} />
<Navigation activeTab={activeTab} onTabChange={setActiveTab} /> <Navigation activeTab={activeTab} onTabChange={setActiveTab} />
<main className="main-content"> <main className="main-content">
{tabs[activeTab] || <DashboardTab />} {tabs[activeTab] || <DashboardTab />}
</main> </main>
<Console />
<Console players={onlinePlayers} />
</div> </div>
); );
}; };

View File

@ -16,7 +16,7 @@
} }
.logo { .logo {
font-family: 'Orbitron', sans-serif; font-family: "Orbitron", sans-serif;
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 900; font-weight: 900;
color: var(--primary-color); color: var(--primary-color);
@ -74,3 +74,37 @@
gap: 0.5rem; gap: 0.5rem;
} }
.header-right {
display: flex;
gap: 0.75rem;
align-items: center;
}
.header-icon-btn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-color);
color: var(--text-secondary);
width: 40px;
height: 40px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
font-size: 1.1rem;
}
.header-icon-btn:hover {
background: rgba(0, 212, 255, 0.1);
border-color: var(--primary-color);
color: var(--primary-color);
box-shadow: 0 0 15px rgba(0, 212, 255, 0.2);
}
.header-icon-btn.exit-btn:hover {
background: rgba(255, 87, 34, 0.1);
border-color: #ff5722;
color: #ff5722;
box-shadow: 0 0 15px rgba(255, 87, 34, 0.2);
}

View File

@ -25,7 +25,6 @@ const GameHeader = ({ onReturn }) => {
<span className="player-level">Lv. 1</span> <span className="player-level">Lv. 1</span>
</div> </div>
</div> </div>
<div className="header-center"> <div className="header-center">
<div className="resources"> <div className="resources">
<div className="resource"> <div className="resource">
@ -34,17 +33,18 @@ const GameHeader = ({ onReturn }) => {
</div> </div>
</div> </div>
</div> </div>
<div className="header-right"> <div className="header-right">
<button <button
className="btn btn-secondary" className="header-icon-btn"
onClick={() => setIsSettingsOpen(true)} onClick={() => setIsSettingsOpen(true)}
style={{ marginRight: "10px" }}
> >
<i className="fas fa-cog"></i> <i className="fas fa-cog"></i>
</button> </button>
<button className="btn btn-warning" onClick={handleHomeClick}> <button
className="header-icon-btn exit-btn"
onClick={handleHomeClick}
>
<i className="fas fa-home"></i> <i className="fas fa-home"></i>
</button> </button>
</div> </div>

View File

@ -1,5 +1,4 @@
.main-nav { .main-nav {
/* Зменшуємо загальну висоту з 60px до 45px */
height: 45px; height: 45px;
background: #0a0f18; background: #0a0f18;
border-bottom: 1px solid #1a2638; border-bottom: 1px solid #1a2638;
@ -19,7 +18,7 @@
.nav-container { .nav-container {
display: flex; display: flex;
padding: 0 5px; padding: 0 5px;
gap: 2px; /* Мінімальний зазор між кнопками */ gap: 2px;
} }
.nav-btn { .nav-btn {
@ -27,7 +26,6 @@
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
/* Зменшуємо горизонтальні відступи */
padding: 0 12px; padding: 0 12px;
background: transparent; background: transparent;
border: none; border: none;
@ -42,21 +40,20 @@
.nav-btn-content { .nav-btn-content {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; /* Менша відстань між іконкою та текстом */ gap: 6px;
} }
.nav-btn i { .nav-btn i {
font-size: 0.9rem; /* Зменшили розмір іконок */ font-size: 0.9rem;
} }
.nav-label { .nav-label {
font-size: 9px; /* Ультра-компактний шрифт */ font-size: 9px;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
/* Активний стан */
.nav-btn.active { .nav-btn.active {
color: #00d4ff; color: #00d4ff;
background: linear-gradient(to bottom, rgba(0, 212, 255, 0.08), transparent); background: linear-gradient(to bottom, rgba(0, 212, 255, 0.08), transparent);
@ -67,7 +64,7 @@
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
height: 2px; /* Тонша лінія */ height: 2px;
background: #00d4ff; background: #00d4ff;
box-shadow: 0 0 8px #00d4ff; box-shadow: 0 0 8px #00d4ff;
transform: scaleX(0); transform: scaleX(0);
@ -78,21 +75,66 @@
transform: scaleX(1); transform: scaleX(1);
} }
/* Адаптивність для мобілок (ще компактніше) */
@media (max-width: 768px) { @media (max-width: 768px) {
.main-nav { .main-nav {
height: 42px; /* Мінімум для зручного натискання пальцем */ height: 42px;
} }
.nav-label { .nav-label {
display: none; /* Тільки іконки на мобілках */ display: none;
} }
.nav-btn { .nav-btn {
padding: 0 18px; /* Більше місця для пальця, але без тексту */ padding: 0 18px;
} }
.nav-btn i { .nav-btn i {
font-size: 1.1rem; font-size: 1.1rem;
} }
} }
.icon-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.nav-badge {
position: absolute;
top: -8px;
right: -8px;
background: #ff3e3e;
color: white;
font-size: 10px;
font-weight: bold;
min-width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
box-shadow: 0 0 10px rgba(255, 62, 62, 0.5);
border: 1px solid #1a1a1a;
animation: pulse-red 2s infinite;
}
@keyframes pulse-red {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(255, 62, 62, 0.7);
}
70% {
transform: scale(1.1);
box-shadow: 0 0 0 5px rgba(255, 62, 62, 0);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(255, 62, 62, 0);
}
}
.nav-btn.notifications.active i {
color: #ffd700;
}

View File

@ -1,8 +1,12 @@
import React from "react"; import React, { useState, useEffect } from "react";
import GameDataManager from "../../../services/GameDataManager.js"; import GameDataManager from "../../../services/GameDataManager.js";
import { useSocket } from "../../../hooks/useSocket";
import "./Navigation.css"; import "./Navigation.css";
const Navigation = ({ activeTab, onTabChange }) => { const Navigation = ({ activeTab, onTabChange }) => {
const { socket } = useSocket();
const [unreadCount, setUnreadCount] = useState(0);
const tabs = [ const tabs = [
{ id: "dashboard", icon: "fa-tachometer-alt" }, { id: "dashboard", icon: "fa-tachometer-alt" },
{ id: "dungeons", icon: "fa-dungeon" }, { id: "dungeons", icon: "fa-dungeon" },
@ -11,10 +15,40 @@ const Navigation = ({ activeTab, onTabChange }) => {
{ id: "shop", icon: "fa-store" }, { id: "shop", icon: "fa-store" },
{ id: "crafting", icon: "fa-hammer" }, { id: "crafting", icon: "fa-hammer" },
{ id: "itemlist", icon: "fa-list-ul" }, { id: "itemlist", icon: "fa-list-ul" },
{ id: "chat", icon: "fa-comments" },
{ id: "notifications", icon: "fa-bell" },
]; ];
useEffect(() => {
if (!socket) return;
const handleNotifyUpdate = (count) => {
setUnreadCount(count);
};
socket.on("notifications:unread_count", handleNotifyUpdate);
socket.on("notification:new", () => {
if (activeTab !== "notifications") {
setUnreadCount((prev) => prev + 1);
}
});
return () => {
socket.off("notifications:unread_count", handleNotifyUpdate);
socket.off("notification:new");
};
}, [socket, activeTab]);
const handleTabClick = (id) => {
if (id === "notifications") setUnreadCount(0);
onTabChange(id);
};
const getLabel = (id) => { const getLabel = (id) => {
if (id === "itemlist") return "ITEM_LIST"; if (id === "itemlist") return "ITEM_LIST";
if (id === "chat") return "CHAT";
if (id === "notifications") return "ALERTS";
return GameDataManager.t(`category.tabs.original.${id}`); return GameDataManager.t(`category.tabs.original.${id}`);
}; };
@ -24,11 +58,16 @@ const Navigation = ({ activeTab, onTabChange }) => {
{tabs.map((tab) => ( {tabs.map((tab) => (
<button <button
key={tab.id} key={tab.id}
className={`nav-btn ${activeTab === tab.id ? "active" : ""}`} className={`nav-btn ${activeTab === tab.id ? "active" : ""} ${tab.id}`}
onClick={() => onTabChange(tab.id)} onClick={() => handleTabClick(tab.id)}
> >
<div className="nav-btn-content"> <div className="nav-btn-content">
<div className="icon-wrapper">
<i className={`fas ${tab.icon}`}></i> <i className={`fas ${tab.icon}`}></i>
{tab.id === "notifications" && unreadCount > 0 && (
<span className="nav-badge">{unreadCount}</span>
)}
</div>
<span className="nav-label">{getLabel(tab.id)}</span> <span className="nav-label">{getLabel(tab.id)}</span>
</div> </div>
<div className="nav-active-indicator"></div> <div className="nav-active-indicator"></div>

View File

@ -0,0 +1,232 @@
import React, { useState, useEffect, useRef } from "react";
import { useSocket } from "../../../hooks/useSocket";
import "./styles/ChatTab.css";
const ChatTab = () => {
const { socket } = useSocket();
const [activeChat, setActiveChat] = useState("global");
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState([]);
const [messages, setMessages] = useState([]);
const [friends, setFriends] = useState([]);
const [inputValue, setInputValue] = useState("");
const [showSidebar, setShowSidebar] = useState(true);
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
const messagesEndRef = useRef(null);
useEffect(() => {
const handleResize = () => {
const mobile = window.innerWidth <= 768;
setIsMobile(mobile);
if (!mobile) setShowSidebar(true);
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
useEffect(() => {
if (!socket) return;
// Початкові запити
socket.emit("friend:get_list");
if (activeChat === "global") {
socket.emit("chat:get_global_history");
}
const handleNewMessage = (msg) => {
if (
(activeChat === "global" && msg.type === "global") ||
(activeChat !== "global" &&
(msg.senderId === activeChat || msg.receiverId === activeChat))
) {
setMessages((prev) => [...prev, msg]);
}
};
const handleHistory = (history) => setMessages(history);
const handleSearchResults = (results) => setSearchResults(results);
const handleFriendList = (list) => setFriends(list);
socket.on("chat:new_message", handleNewMessage);
socket.on("chat:global_history", handleHistory);
socket.on("player:search_results", handleSearchResults);
socket.on("friend:list", handleFriendList);
return () => {
socket.off("chat:new_message", handleNewMessage);
socket.off("chat:global_history", handleHistory);
socket.off("player:search_results", handleSearchResults);
socket.off("friend:list", handleFriendList);
};
}, [socket, activeChat]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSearch = (e) => {
const query = e.target.value;
setSearchQuery(query);
if (query.length > 1) {
socket.emit("player:search", { query });
} else {
setSearchResults([]);
}
};
const addFriend = (player) => {
socket.emit("friend:add", { friendId: player.id });
setSearchQuery("");
setSearchResults([]);
};
const sendMessage = () => {
if (!inputValue.trim() || !socket) return;
socket.emit("chat:send_message", {
content: inputValue,
type: activeChat === "global" ? "global" : "private",
receiverId: activeChat === "global" ? null : activeChat,
});
setInputValue("");
};
const selectChat = (id) => {
setActiveChat(id);
if (isMobile) setShowSidebar(false);
};
const getChatName = () => {
if (activeChat === "global") return "GLOBAL_SYSTEM_CHAT";
const friend = friends.find((f) => f.id === activeChat);
return friend ? friend.username : "UNKNOWN_PILOT";
};
return (
<div className={`chat-container ${isMobile ? "mobile" : ""}`}>
<aside
className={`chat-sidebar ${isMobile && !showSidebar ? "hidden" : ""}`}
>
<div className="search-section">
<div className="card-tag">USER_SEARCH</div>
<div className="search-input-wrapper">
<input
type="text"
placeholder="SEARCH_PILOTS..."
value={searchQuery}
onChange={handleSearch}
/>
</div>
{searchResults.length > 0 && (
<div className="search-results-dropdown">
{searchResults.map((player) => (
<div
key={player.id}
className="search-result-item"
onClick={() => addFriend(player)}
>
<span>
{player.username} (LVL {player.level})
</span>
<i className="fas fa-plus"></i>
</div>
))}
</div>
)}
</div>
<div className="chats-list">
<div
className={`chat-item ${activeChat === "global" ? "active" : ""}`}
onClick={() => selectChat("global")}
>
<i className="fas fa-globe"></i>
<span>GLOBAL_CHANNEL</span>
</div>
<div className="friends-section-label">
<span className="label-text">CONTACTS</span>
<span className="label-line"></span>
</div>
<div className="friends-list">
{friends.map((friend) => (
<div
key={friend.id}
className={`chat-item ${activeChat === friend.id ? "active" : ""}`}
onClick={() => selectChat(friend.id)}
>
<div
className={`status-dot ${friend.online ? "online" : "offline"}`}
></div>
<span>{friend.username}</span>
</div>
))}
</div>
</div>
</aside>
<main className={`chat-main ${isMobile && showSidebar ? "hidden" : ""}`}>
<div className="chat-header">
{isMobile && (
<button className="back-btn" onClick={() => setShowSidebar(true)}>
<i className="fas fa-chevron-left"></i>
</button>
)}
<div className="active-chat-info">
<i
className={
activeChat === "global" ? "fas fa-globe" : "fas fa-user"
}
></i>
<h3>{getChatName()}</h3>
</div>
</div>
<div className="chat-messages">
{messages.length === 0 && (
<div className="message system">
<span className="msg-text">
NO_LOGS_FOUND. SECURE_LINE_READY...
</span>
</div>
)}
{messages.map((msg, index) => (
<div
key={msg.id || index}
className={`message ${msg.senderName === "System" ? "system" : ""}`}
>
<span className="msg-time">
[
{new Date(msg.createdAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
]
</span>
<span className="msg-author">{msg.senderName}:</span>
<span className="msg-text">{msg.content}</span>
</div>
))}
<div ref={messagesEndRef} />
</div>
<div className="chat-input-area">
<input
type="text"
placeholder="TYPE_MESSAGE..."
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && sendMessage()}
/>
<button className="send-btn" onClick={sendMessage}>
<i className="fas fa-paper-plane"></i>
</button>
</div>
</main>
</div>
);
};
export default ChatTab;

View File

@ -4,6 +4,7 @@ import GameDataManager from "../../../services/GameDataManager";
import "./styles/CraftingTab.css"; 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";
const CraftingTab = () => { const CraftingTab = () => {
const { socket } = useSocket(); const { socket } = useSocket();
@ -11,7 +12,6 @@ const CraftingTab = () => {
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [recipes, setRecipes] = useState([]); const [recipes, setRecipes] = useState([]);
const [userInventory, setUserInventory] = useState([]); const [userInventory, setUserInventory] = useState([]);
const [activeCategory, setActiveCategory] = useState(""); const [activeCategory, setActiveCategory] = useState("");
const [selectedRecipe, setSelectedRecipe] = useState(null); const [selectedRecipe, setSelectedRecipe] = useState(null);
const [activeCraft, setActiveCraft] = useState(null); const [activeCraft, setActiveCraft] = useState(null);
@ -19,7 +19,6 @@ const CraftingTab = () => {
useEffect(() => { useEffect(() => {
const manifestCategories = GameDataManager.getRecipeCategories(); const manifestCategories = GameDataManager.getRecipeCategories();
setCategories(manifestCategories); setCategories(manifestCategories);
if (manifestCategories.length > 0 && !activeCategory) { if (manifestCategories.length > 0 && !activeCategory) {
setActiveCategory(manifestCategories[0].id); setActiveCategory(manifestCategories[0].id);
} }
@ -29,7 +28,6 @@ const CraftingTab = () => {
if (activeCategory) { if (activeCategory) {
const filteredRecipes = const filteredRecipes =
GameDataManager.getRecipesByCategory(activeCategory); GameDataManager.getRecipesByCategory(activeCategory);
console.log(filteredRecipes);
setRecipes(filteredRecipes); setRecipes(filteredRecipes);
} }
}, [activeCategory]); }, [activeCategory]);
@ -38,30 +36,67 @@ const CraftingTab = () => {
if (!socket) return; if (!socket) return;
socket.emit("player:get_inventory"); socket.emit("player:get_inventory");
socket.emit("player:check_active_craft");
const handleInventory = (data) => setUserInventory(data); const handleInventory = (data) => setUserInventory(data);
const handleCraftStarted = (data) => {
const recipeData = GameDataManager.getRecipe(data.recipeId);
const now = Date.now();
const diff = (data.finishAt - now) / 1000;
if (diff <= 0) {
setActiveCraft(null);
return;
}
setActiveCraft({
recipeId: data.recipeId,
name: recipeData?.displayName || data.recipeId,
finishAt: data.finishAt,
totalTime: data.totalTime || recipeData?.time_seconds || diff,
timeLeft: Math.max(0, Math.ceil(diff)),
});
if (recipeData) {
setSelectedRecipe(recipeData);
}
};
const handleCraftSuccess = () => {
setActiveCraft(null);
setSelectedRecipe(null);
socket.emit("player:get_inventory");
};
socket.on("player:inventory_data", handleInventory); socket.on("player:inventory_data", handleInventory);
socket.on("player:craft_started", handleCraftStarted);
socket.on("player:craft_success", handleCraftSuccess);
return () => { return () => {
socket.off("player:inventory_data", handleInventory); socket.off("player:inventory_data", handleInventory);
socket.off("player:craft_started", handleCraftStarted);
socket.off("player:craft_success", handleCraftSuccess);
}; };
}, [socket]); }, [socket]);
useEffect(() => { useEffect(() => {
let timer; if (!activeCraft) return;
if (activeCraft && activeCraft.timeLeft > 0) {
timer = setInterval(() => { const timer = setInterval(() => {
setActiveCraft((prev) => ({ const now = Date.now();
...prev, const diff = Math.max(0, Math.ceil((activeCraft.finishAt - now) / 1000));
timeLeft: Math.max(0, prev.timeLeft - 1),
})); if (diff <= 0) {
}, 1000); clearInterval(timer);
} else if (activeCraft && activeCraft.timeLeft === 0) {
setActiveCraft(null); setActiveCraft(null);
socket.emit("player:get_inventory"); } else {
setActiveCraft((prev) => (prev ? { ...prev, timeLeft: diff } : null));
} }
}, 1000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [activeCraft, socket]); }, [activeCraft?.finishAt]);
const getOwnedAmount = (itemId) => { const getOwnedAmount = (itemId) => {
const item = userInventory.find((i) => (i.itemId || i.id) === itemId); const item = userInventory.find((i) => (i.itemId || i.id) === itemId);
@ -70,19 +105,7 @@ const CraftingTab = () => {
const handleStartCrafting = (recipe) => { const handleStartCrafting = (recipe) => {
if (activeCraft) return; if (activeCraft) return;
socket.emit("player:craft_item", { recipeId: recipe.id });
socket.emit("player:craft_item", {
recipeId: recipe.id,
category: activeCategory,
});
setActiveCraft({
name: recipe.displayName,
timeLeft: recipe.constructionTime,
totalTime: recipe.constructionTime,
});
setSelectedRecipe(null);
}; };
return ( return (
@ -101,7 +124,7 @@ const CraftingTab = () => {
<div <div
className="progress-bar-fill" className="progress-bar-fill"
style={{ style={{
width: `${((activeCraft.totalTime - activeCraft.timeLeft) / activeCraft.totalTime) * 100}%`, width: `${Math.min(100, ((activeCraft.totalTime - activeCraft.timeLeft) / activeCraft.totalTime) * 100)}%`,
}} }}
></div> ></div>
</div> </div>
@ -122,6 +145,7 @@ const CraftingTab = () => {
<div className="crafting-grid"> <div className="crafting-grid">
{recipes.map((recipe) => { {recipes.map((recipe) => {
const isThisRecipeCrafting = activeCraft?.recipeId === recipe.id;
const canCraft = recipe.ingredients.every( const canCraft = recipe.ingredients.every(
(ing) => getOwnedAmount(ing.itemId) >= ing.quantity, (ing) => getOwnedAmount(ing.itemId) >= ing.quantity,
); );
@ -129,51 +153,44 @@ const CraftingTab = () => {
return ( return (
<div <div
key={recipe.id} key={recipe.id}
className={`recipe-card ${!canCraft ? "insufficient-resources" : ""}`} className={`recipe-card ${!canCraft && !isThisRecipeCrafting ? "insufficient-resources" : ""} ${isThisRecipeCrafting ? "crafting-active" : ""}`}
onClick={() => setSelectedRecipe(recipe)} onClick={() => setSelectedRecipe(recipe)}
> >
<div className="recipe-icon"> <div className="recipe-icon">
{recipe.texture ? ( {recipe.texture ? (
<img src={recipe.texture} alt={recipe.displayName} /> <img
width={64}
src={`${config.serverUrl}/static/${recipe.texture}`}
alt={recipe.displayName}
/>
) : ( ) : (
<div className="fallback-icon"> <div className="fallback-icon">{recipe.displayName[0]}</div>
{recipe.id[0].toUpperCase()}
</div>
)} )}
</div> </div>
<div className="recipe-info-main"> <div className="recipe-info-main">
<span className="recipe-name">{recipe.displayName}</span> <span className="recipe-name">{recipe.displayName}</span>
<div className="recipe-badges"> <div className="recipe-badges">
<span className="badge-time"> <span className="badge-time">
<i className="fas fa-clock"></i> {recipe.constructionTime} <i className="fas fa-clock"></i> {recipe.time_seconds}s
s
</span> </span>
</div> </div>
</div> </div>
{isThisRecipeCrafting && (
{!canCraft && ( <div className="craft-overlay-mini">
<div className="lock-overlay"> <i className="fas fa-sync fa-spin"></i>
<i className="fas fa-lock"></i>
</div> </div>
)} )}
</div> </div>
); );
})} })}
{recipes.length === 0 && (
<div className="empty-category">
No blueprints available in this sector.
</div>
)}
</div> </div>
</div> </div>
<CraftModal <CraftModal
recipe={selectedRecipe} recipe={selectedRecipe}
onClose={() => setSelectedRecipe(null)} onClose={() => !activeCraft && setSelectedRecipe(null)}
onStartCraft={handleStartCrafting} onStartCraft={handleStartCrafting}
isBusy={!!activeCraft} activeCraft={activeCraft}
getOwnedAmount={getOwnedAmount} getOwnedAmount={getOwnedAmount}
/> />
</div> </div>

View File

@ -1,6 +1,7 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import Card from "../../../components/ui/Card"; import Card from "../../../components/ui/Card";
import { useSocket } from "../../../hooks/useSocket"; import { useSocket } from "../../../hooks/useSocket";
import playerManager from "../../../services/PlayerManager";
import "./styles/DashboardTab.css"; import "./styles/DashboardTab.css";
const DashboardTab = () => { const DashboardTab = () => {
@ -8,10 +9,18 @@ const DashboardTab = () => {
const [playerData, setPlayerData] = useState(null); const [playerData, setPlayerData] = useState(null);
const [earnedPopup, setEarnedPopup] = useState(false); const [earnedPopup, setEarnedPopup] = useState(false);
const [credits, setCredits] = useState(0); const [credits, setCredits] = useState(0);
const [onlineCount, setOnlineCount] = useState(
playerManager.onlinePlayers.length,
);
const savedUser = JSON.parse(localStorage.getItem("user")); const savedUser = JSON.parse(localStorage.getItem("user"));
const localUsername = savedUser?.username || "Unknown Pilot"; const localUsername = savedUser?.username || "Unknown Pilot";
useEffect(() => { useEffect(() => {
const unsubscribe = playerManager.subscribe(({ online }) => {
setOnlineCount(online.length);
});
if (!socket) return; if (!socket) return;
socket.emit("player:get_dashboard"); socket.emit("player:get_dashboard");
@ -36,6 +45,7 @@ const DashboardTab = () => {
socket.on("player:offline_report", handleOfflineReport); socket.on("player:offline_report", handleOfflineReport);
return () => { return () => {
unsubscribe();
socket.off("player:dashboard_data", handleData); socket.off("player:dashboard_data", handleData);
socket.off("player:credits_update", handleCreditsUpdate); socket.off("player:credits_update", handleCreditsUpdate);
socket.off("player:offline_report", handleOfflineReport); socket.off("player:offline_report", handleOfflineReport);
@ -124,7 +134,14 @@ const DashboardTab = () => {
<span <span
className={`diag-status ${isConnected ? "online" : "offline"}`} className={`diag-status ${isConnected ? "online" : "offline"}`}
> >
{isConnected ? "CONNECTED" : "DISCONNECTED"} {isConnected ? (
<span className="online-info">
<span className="online-dot"></span>
LIVE: {onlineCount}
</span>
) : (
"DISCONNECTED"
)}
</span> </span>
</div> </div>
</div> </div>

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect } from "react"; 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";
const ItemListTab = () => { const ItemListTab = () => {
const [allItems, setAllItems] = useState([]); const [allItems, setAllItems] = useState([]);
@ -8,26 +9,30 @@ const ItemListTab = () => {
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState("all"); const [selectedCategory, setSelectedCategory] = useState("all");
const [selectedItem, setSelectedItem] = useState(null); const [selectedItem, setSelectedItem] = useState(null);
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
const ASSET_BASE_URL = "http://localhost:5003/static/"; const ASSET_BASE_URL = `${config.serverUrl}/static/`;
useEffect(() => { useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth <= 768);
window.addEventListener("resize", handleResize);
const itemsArray = Array.from(GameDataManager.items.keys()).map((id) => const itemsArray = Array.from(GameDataManager.items.keys()).map((id) =>
GameDataManager.getItem(id), GameDataManager.getItem(id),
); );
setAllItems(itemsArray); setAllItems(itemsArray);
setFilteredItems(itemsArray); setFilteredItems(itemsArray);
return () => window.removeEventListener("resize", handleResize);
}, []); }, []);
useEffect(() => { useEffect(() => {
let result = allItems; let result = allItems;
if (selectedCategory !== "all") { if (selectedCategory !== "all") {
result = result.filter( result = result.filter(
(item) => item.meta?.category === selectedCategory, (item) => item.meta?.category === selectedCategory,
); );
} }
if (searchQuery) { if (searchQuery) {
const q = searchQuery.toLowerCase(); const q = searchQuery.toLowerCase();
result = result.filter( result = result.filter(
@ -36,7 +41,6 @@ const ItemListTab = () => {
item.id.toLowerCase().includes(q), item.id.toLowerCase().includes(q),
); );
} }
setFilteredItems(result); setFilteredItems(result);
}, [searchQuery, selectedCategory, allItems]); }, [searchQuery, selectedCategory, allItems]);
@ -50,19 +54,72 @@ const ItemListTab = () => {
...new Set(allItems.map((i) => i.meta?.category).filter(Boolean)), ...new Set(allItems.map((i) => i.meta?.category).filter(Boolean)),
]; ];
const renderInspector = () => (
<div
className={`item-inspector ${isMobile && selectedItem ? "mobile-modal" : ""}`}
onClick={() => isMobile && setSelectedItem(null)}
>
{selectedItem && (
<div className="inspector-content" onClick={(e) => e.stopPropagation()}>
{isMobile && (
<button
className="close-inspector"
onClick={() => setSelectedItem(null)}
>
&times;
</button>
)}
<div className={`inspector-header ${selectedItem.meta?.rarity}`}>
<div className="header-visual">
<img src={getFullTextureUrl(selectedItem.texture)} alt="" />
</div>
<div className="header-info">
<div className="id-tag">{selectedItem.id}</div>
<h1>{selectedItem.displayName}</h1>
<span className="rarity-label">
{selectedItem.meta?.rarity?.toUpperCase()}
</span>
</div>
</div>
<div className="inspector-grid">
<div className="inspector-section">
<div className="section-title">DATA_DESCRIPTION</div>
<p>{selectedItem.description}</p>
</div>
{selectedItem.stats &&
Object.keys(selectedItem.stats).length > 0 && (
<div className="inspector-section">
<div className="section-title">PARAMETER_READOUT</div>
<div className="stat-pills">
{Object.entries(selectedItem.stats).map(([k, v]) => (
<div key={k} className="stat-pill">
<span>{GameDataManager.getStatName(k)}</span>
<span>+{v}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
</div>
);
return ( return (
<div className="item-list-container"> <div className="item-list-container">
<div className="item-list-sidebar"> <div className="item-list-sidebar">
<div className="search-box"> <div className="search-box">
<i className="fas fa-search"></i>
<input <input
type="text" type="text"
placeholder="FILTER_BY_DATABASE_ID..." placeholder="SEARCH_ID_OR_NAME..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
</div> </div>
<div className="category-filters"> <div className="category-filters">
{categories.map((cat) => ( {categories.map((cat) => (
<button <button
@ -74,7 +131,6 @@ const ItemListTab = () => {
</button> </button>
))} ))}
</div> </div>
<div className="items-list-scroll"> <div className="items-list-scroll">
{filteredItems.map((item) => ( {filteredItems.map((item) => (
<div <div
@ -89,80 +145,11 @@ const ItemListTab = () => {
<div className="item-row-title">{item.displayName}</div> <div className="item-row-title">{item.displayName}</div>
<div className="item-row-subtitle">{item.id}</div> <div className="item-row-subtitle">{item.id}</div>
</div> </div>
<div className="item-row-rarity-indicator"></div>
</div>
))}
{filteredItems.length === 0 && (
<div className="no-results">NO_RECORDS_FOUND</div>
)}
</div>
</div>
<div className="item-inspector">
{selectedItem ? (
<div className="inspector-content">
<div className={`inspector-header ${selectedItem.meta?.rarity}`}>
<div className="header-visual">
<img src={getFullTextureUrl(selectedItem.texture)} alt="" />
</div>
<div className="header-info">
<div className="id-tag">{selectedItem.id}</div>
<h1>{selectedItem.displayName}</h1>
<div className="rarity-label">
{selectedItem.meta?.rarity?.toUpperCase()}
</div>
</div>
</div>
<div className="inspector-grid">
<div className="inspector-section main-desc">
<div className="section-title">DATA_DESCRIPTION</div>
<p>{selectedItem.description}</p>
</div>
{selectedItem.stats &&
Object.keys(selectedItem.stats).length > 0 && (
<div className="inspector-section stats">
<div className="section-title">PARAMETER_READOUT</div>
<div className="stat-pills">
{Object.entries(selectedItem.stats).map(([k, v]) => (
<div key={k} className="stat-pill">
<span className="p-label">
{GameDataManager.getStatName(k)}
</span>
<span className="p-value">+{v}</span>
</div> </div>
))} ))}
</div> </div>
</div> </div>
)} {(!isMobile || (isMobile && selectedItem)) && renderInspector()}
<div className="inspector-section meta">
<div className="section-title">OBJECT_METADATA</div>
<div className="meta-list">
<div className="meta-item">
<span>CATEGORY</span>
<span>{selectedItem.meta?.category || "general"}</span>
</div>
<div className="meta-item">
<span>EQUIP_SLOT</span>
<span>{selectedItem.meta?.equipmentSlot || "none"}</span>
</div>
<div className="meta-item">
<span>STACK_LIMIT</span>
<span>{selectedItem.meta?.stackable ? "64" : "1"}</span>
</div>
</div>
</div>
</div>
</div>
) : (
<div className="inspector-placeholder">
<div className="scanner-line"></div>
<p>AWAITING_OBJECT_SELECTION...</p>
</div>
)}
</div>
</div> </div>
); );
}; };

View File

@ -0,0 +1,108 @@
import React, { useState, useEffect } from "react";
import { useSocket } from "../../../hooks/useSocket";
import "./styles/NotificationsTab.css";
const NotificationsTab = () => {
const { socket } = useSocket();
const [notifications, setNotifications] = useState([]);
useEffect(() => {
if (!socket) return;
socket.emit("notifications:get_all");
const handleNewNotify = (notify) => {
console.log(notify);
setNotifications((prev) => [notify, ...prev]);
};
const handleInitialNotify = (data) => {
setNotifications(data);
};
socket.on("notification:new", handleNewNotify);
socket.on("notifications:list", handleInitialNotify);
return () => {
socket.off("notification:new", handleNewNotify);
socket.off("notifications:list", handleInitialNotify);
};
}, [socket]);
const handleAction = (id, action, data) => {
if (action === "accept_friend") {
socket.emit("friend:add", { friendId: data.fromId });
}
socket.emit("notification:read", { id });
setNotifications((prev) => prev.filter((n) => n.id !== id));
};
const getIcon = (type) => {
switch (type) {
case "friend_request":
return "fas fa-user-plus";
case "crafting":
return "fas fa-hammer";
case "system":
return "fas fa-robot";
default:
return "fas fa-bell";
}
};
return (
<div className="notifications-container">
<div className="notifications-header">
<div className="card-tag">SYSTEM_ALERTS</div>
<h2>NOTIFICATIONS</h2>
</div>
<div className="notifications-list">
{notifications.length === 0 && (
<div className="no-notifications">
<i className="fas fa-satellite-dish"></i>
<span>NO_ACTIVE_ALERTS_FOUND</span>
</div>
)}
{notifications.map((n) => (
<div key={n.id} className={`notify-card ${n.type}`}>
<div className="notify-icon">
<i className={getIcon(n.type)}></i>
</div>
<div className="notify-content">
<div className="notify-title-row">
<h4>{n.title}</h4>
<span className="notify-time">
{new Date(n.createdAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
<p>{n.message}</p>
</div>
<div className="notify-actions">
{n.type === "friend_request" && (
<button
className="action-btn accept"
onClick={() => handleAction(n.id, "accept_friend", n.data)}
>
ACCEPT
</button>
)}
<button
className="action-btn dismiss"
onClick={() => handleAction(n.id, "dismiss")}
>
<i className="fas fa-times"></i>
</button>
</div>
</div>
))}
</div>
</div>
);
};
export default NotificationsTab;

View File

@ -5,15 +5,13 @@ const CraftModal = ({
recipe, recipe,
onClose, onClose,
onStartCraft, onStartCraft,
isBusy, activeCraft,
getOwnedAmount, getOwnedAmount,
}) => { }) => {
if (!recipe) return null; if (!recipe) return null;
const displayName = recipe.resultItem?.name || recipe.name || recipe.id; const isBusy = !!activeCraft;
const craftTime = recipe.constructionTime || recipe.time || 0; const outputQty = Object.values(recipe.output || {})[0] || 1;
// Перевірка, чи вистачає всіх ресурсів для крафту
const canAfford = recipe.ingredients?.every( const canAfford = recipe.ingredients?.every(
(ing) => getOwnedAmount(ing.itemId) >= ing.quantity, (ing) => getOwnedAmount(ing.itemId) >= ing.quantity,
); );
@ -23,7 +21,7 @@ const CraftModal = ({
<div className="craft-modal" onClick={(e) => e.stopPropagation()}> <div className="craft-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header"> <div className="modal-header">
<h3> <h3>
<i className="fas fa-tools"></i> Construction: {displayName} <i className="fas fa-tools"></i> Construction: {recipe.displayName}
</h3> </h3>
<button className="close-x" onClick={onClose}> <button className="close-x" onClick={onClose}>
&times; &times;
@ -36,8 +34,7 @@ const CraftModal = ({
<i className="fas fa-list-ul"></i> Required Resources <i className="fas fa-list-ul"></i> Required Resources
</h4> </h4>
<div className="res-grid"> <div className="res-grid">
{recipe.ingredients && {recipe.ingredients?.map((ing) => {
recipe.ingredients.map((ing) => {
const owned = getOwnedAmount(ing.itemId); const owned = getOwnedAmount(ing.itemId);
const hasEnough = owned >= ing.quantity; const hasEnough = owned >= ing.quantity;
@ -50,9 +47,7 @@ const CraftModal = ({
<i <i
className={`fas fa-cube ${hasEnough ? "icon-green" : "icon-red"}`} className={`fas fa-cube ${hasEnough ? "icon-green" : "icon-red"}`}
></i> ></i>
<span className="res-name"> <span className="res-name">{ing.displayName}</span>
{ing.name || ing.itemId.replace("_", " ")}
</span>
</div> </div>
<div className="res-quantity-info"> <div className="res-quantity-info">
<span <span
@ -76,18 +71,34 @@ const CraftModal = ({
<div className="outcome-row"> <div className="outcome-row">
<span>Result:</span> <span>Result:</span>
<strong> <strong>
{displayName} x{recipe.result?.quantity || 1} {recipe.displayName} x{outputQty}
</strong> </strong>
</div> </div>
<div className="outcome-row"> <div className="outcome-row">
<span>Time:</span> <span>Time:</span>
<strong>{craftTime}s</strong> <strong>{recipe.time_seconds}s</strong>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="modal-footer"> <div className="modal-footer">
{activeCraft && activeCraft.recipeId === recipe.id ? (
<div className="modal-progress-container">
<div className="progress-text">
Processing... {activeCraft.timeLeft}s
</div>
<div className="progress-bar-bg">
<div
className="progress-bar-fill"
style={{
width: `${((activeCraft.totalTime - activeCraft.timeLeft) / activeCraft.totalTime) * 100}%`,
}}
></div>
</div>
</div>
) : (
<>
<button <button
className={`btn-start-craft ${!canAfford || isBusy ? "disabled" : ""}`} className={`btn-start-craft ${!canAfford || isBusy ? "disabled" : ""}`}
onClick={() => canAfford && !isBusy && onStartCraft(recipe)} onClick={() => canAfford && !isBusy && onStartCraft(recipe)}
@ -97,11 +108,13 @@ const CraftModal = ({
? "System Busy..." ? "System Busy..."
: !canAfford : !canAfford
? "Insufficient Resources" ? "Insufficient Resources"
: `Start Construction (${craftTime}s)`} : `Start Construction (${recipe.time_seconds}s)`}
</button> </button>
<button className="btn-cancel" onClick={onClose}> <button className="btn-cancel" onClick={onClose}>
Close Close
</button> </button>
</>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,182 @@
.chat-container {
display: flex;
height: 100%;
background: rgba(5, 8, 12, 0.9);
border: 1px solid var(--border-color);
overflow: hidden;
position: relative;
}
.chat-sidebar {
width: 300px;
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
transition: all 0.3s ease;
z-index: 2;
}
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
background: rgba(0, 0, 0, 0.2);
}
/* МОБІЛЬНА ВЕРСІЯ */
@media (max-width: 768px) {
.chat-sidebar {
width: 100%;
position: absolute;
height: 100%;
background: #0a0f18;
}
.chat-sidebar.hidden {
transform: translateX(-100%);
}
.chat-main {
width: 100%;
position: absolute;
height: 100%;
z-index: 1;
}
.chat-main.hidden {
transform: translateX(100%);
}
.back-btn {
background: transparent;
border: none;
color: var(--primary-color);
font-size: 1.2rem;
padding: 0 15px 0 0;
cursor: pointer;
}
}
/* Стилі заголовків та вводу */
.search-section {
padding: 20px 15px 15px;
border-bottom: 1px solid var(--border-color);
}
.search-input-wrapper {
display: flex;
gap: 5px;
margin-top: 10px;
}
.search-input-wrapper input {
flex: 1;
background: #05080c;
border: 1px solid var(--border-color);
color: #fff;
padding: 8px;
font-size: 12px;
}
.add-friend-btn {
background: var(--primary-color);
border: none;
width: 35px;
cursor: pointer;
}
.chat-item {
padding: 15px 20px;
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
border-bottom: 1px solid rgba(255, 255, 255, 0.02);
}
.chat-item.active {
background: rgba(0, 212, 255, 0.1);
box-shadow: inset 4px 0 0 var(--primary-color);
}
.chat-header {
padding: 15px;
display: flex;
align-items: center;
border-bottom: 1px solid var(--border-color);
background: rgba(10, 15, 24, 0.8);
}
.chat-messages {
flex: 1;
padding: 15px;
overflow-y: auto;
}
.chat-input-area {
padding: 15px;
background: #05080c;
display: flex;
gap: 10px;
}
.chat-input-area input {
flex: 1;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-color);
color: #fff;
padding: 10px;
}
.send-btn {
background: transparent;
border: 1px solid var(--primary-color);
color: var(--primary-color);
padding: 0 15px;
}
.search-section {
position: relative;
z-index: 10;
}
.search-results-dropdown {
position: absolute;
top: 100%;
left: 15px;
right: 15px;
background: #0a0f18;
border: 1px solid var(--primary-color);
border-top: none;
max-height: 200px;
overflow-y: auto;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);
}
.search-result-item {
padding: 10px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
font-size: 12px;
color: #fff;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.search-result-item:hover {
background: rgba(0, 212, 255, 0.1);
}
.search-result-item i {
color: var(--primary-color);
}
.message.system .msg-author {
color: #ff3e3e;
}
.message.system .msg-text {
color: #aaa;
font-style: italic;
}

View File

@ -216,7 +216,40 @@
.diag-status.online { .diag-status.online {
color: #00ff88; color: #00ff88;
text-shadow: 0 0 5px #00ff88; display: flex;
align-items: center;
gap: 8px;
text-shadow: 0 0 8px rgba(0, 255, 136, 0.4);
}
.online-info {
display: flex;
align-items: center;
gap: 8px;
}
.online-dot {
width: 6px;
height: 6px;
background-color: #00ff88;
border-radius: 50%;
display: inline-block;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.4;
transform: scale(1.2);
}
100% {
opacity: 1;
transform: scale(1);
}
} }
@media (min-width: 600px) { @media (min-width: 600px) {

View File

@ -6,9 +6,9 @@
font-family: "Rajdhani", "Segoe UI", sans-serif; font-family: "Rajdhani", "Segoe UI", sans-serif;
border-top: 1px solid rgba(0, 255, 255, 0.1); border-top: 1px solid rgba(0, 255, 255, 0.1);
overflow: hidden; overflow: hidden;
position: relative;
} }
/* SIDEBAR & SEARCH */
.item-list-sidebar { .item-list-sidebar {
width: 380px; width: 380px;
border-right: 1px solid rgba(0, 255, 255, 0.1); border-right: 1px solid rgba(0, 255, 255, 0.1);
@ -19,7 +19,6 @@
.search-box { .search-box {
padding: 20px; padding: 20px;
position: relative;
} }
.search-box input { .search-box input {
@ -31,16 +30,8 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 1px; letter-spacing: 1px;
font-size: 14px; font-size: 14px;
transition: all 0.3s;
} }
.search-box input:focus {
outline: none;
border-color: #00ffff;
box-shadow: 0 0 10px rgba(0, 255, 255, 0.2);
}
/* CATEGORY FILTERS */
.category-filters { .category-filters {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -55,17 +46,14 @@
padding: 4px 10px; padding: 4px 10px;
font-size: 11px; font-size: 11px;
cursor: pointer; cursor: pointer;
transition: 0.2s;
} }
.filter-btn.active, .filter-btn.active {
.filter-btn:hover {
background: rgba(0, 255, 255, 0.1); background: rgba(0, 255, 255, 0.1);
color: #00ffff; color: #00ffff;
border-color: #00ffff; border-color: #00ffff;
} }
/* ITEM LIST ROWS */
.items-list-scroll { .items-list-scroll {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@ -78,60 +66,108 @@
padding: 10px; padding: 10px;
margin-bottom: 8px; margin-bottom: 8px;
background: rgba(255, 255, 255, 0.02); background: rgba(255, 255, 255, 0.02);
border-left: 3px solid transparent; border-left: 3px solid #9d9d9d;
cursor: pointer; cursor: pointer;
transition: all 0.2s;
position: relative;
}
.item-row:hover {
background: rgba(255, 255, 255, 0.05);
transform: translateX(5px);
} }
.item-row.selected { .item-row.selected {
background: rgba(0, 255, 255, 0.08); background: rgba(0, 255, 255, 0.08);
border-left-color: #00ffff;
} }
.item-row-icon { .item-row-icon {
width: 44px; width: 44px;
height: 44px; height: 44px;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.1); margin-right: 15px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-right: 15px;
} }
.item-row-icon img { .item-row-icon img {
width: 32px; width: 32px;
height: 32px; height: 32px;
object-fit: contain; image-rendering: pixelated;
image-rendering: pixelated; /* Щоб іконки були чіткими */
} }
.item-row-content { /* Inspector Base */
.item-inspector {
flex: 1; flex: 1;
padding: 40px;
overflow-y: auto;
}
.inspector-header {
display: flex; display: flex;
flex-direction: column; gap: 25px;
margin-bottom: 30px;
} }
.item-row-title { .header-visual {
font-size: 15px; width: 100px;
font-weight: 600; height: 100px;
color: #fff; background: rgba(0, 0, 0, 0.4);
text-transform: uppercase; border: 1px solid rgba(0, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
} }
.item-row-subtitle { .header-visual img {
font-size: 11px; width: 64px;
color: #666; height: 64px;
font-family: "Courier New", monospace;
} }
/* RARITY COLORS */ /* Mobile Modal Logic */
@media (max-width: 768px) {
.item-list-sidebar {
width: 100%;
border: none;
}
.item-inspector.mobile-modal {
display: flex;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 3000;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(6px);
align-items: center;
justify-content: center;
padding: 20px;
}
.inspector-content {
background: #0d121d;
border: 1px solid #00ffff44;
width: 100%;
max-height: 120vh;
overflow-y: auto;
position: relative;
padding: 20px;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.5);
}
.close-inspector {
position: absolute;
top: 10px;
right: 15px;
background: none;
border: none;
color: #00ffff;
font-size: 30px;
cursor: pointer;
}
.item-inspector:not(.mobile-modal) {
display: none;
}
}
/* Rarity */
.item-row.legendary { .item-row.legendary {
border-left-color: #ff8000; border-left-color: #ff8000;
} }
@ -141,67 +177,11 @@
.item-row.rare { .item-row.rare {
border-left-color: #0070dd; border-left-color: #0070dd;
} }
.item-row.common {
border-left-color: #9d9d9d;
}
/* INSPECTOR PANEL */ @media screen and (max-width: 600px) {
.item-inspector { .section-title {
flex: 1;
padding: 40px;
background: radial-gradient(
circle at top right,
rgba(0, 255, 255, 0.03),
transparent
);
overflow-y: auto;
}
.inspector-header {
display: flex;
gap: 30px;
margin-bottom: 40px;
align-items: center;
}
.header-visual {
width: 120px;
height: 120px;
background: rgba(0, 0, 0, 0.3);
border: 2px solid rgba(0, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
}
.header-visual img {
width: 80px;
height: 80px;
}
.header-info h1 {
font-size: 32px;
margin: 5px 0;
text-transform: uppercase;
letter-spacing: 2px;
}
.id-tag {
font-family: monospace;
color: #00ffff;
opacity: 0.6;
font-size: 14px;
}
/* SCROLLBAR CUSTOMIZATION */
.items-list-scroll::-webkit-scrollbar {
width: 4px;
}
.items-list-scroll::-webkit-scrollbar-thumb {
background: rgba(0, 255, 255, 0.2);
}
.section-title {
font-size: 16px; font-size: 16px;
padding: 0;
margin: 10px;
}
} }

View File

@ -0,0 +1,148 @@
.notifications-container {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
background: rgba(10, 15, 24, 0.6);
}
.notifications-header {
margin-bottom: 20px;
}
.notifications-header h2 {
font-family: "Orbitron", sans-serif;
color: #fff;
letter-spacing: 2px;
margin-top: 5px;
}
.notifications-list {
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
padding-right: 5px;
}
.no-notifications {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 15px;
padding: 100px 0;
color: #4a5d75;
font-family: "Orbitron", sans-serif;
font-size: 12px;
}
.no-notifications i {
font-size: 2rem;
opacity: 0.3;
}
.notify-card {
display: flex;
align-items: center;
background: rgba(26, 38, 56, 0.5);
border-left: 3px solid #00d4ff;
padding: 15px;
gap: 15px;
backdrop-filter: blur(5px);
border-radius: 0 4px 4px 0;
animation: notify-slide-in 0.3s ease-out;
}
.notify-card.friend_request {
border-left-color: #00ff88;
}
.notify-card.crafting {
border-left-color: #ffd700;
}
.notify-card.system {
border-left-color: #ff3e3e;
}
.notify-icon {
width: 35px;
height: 35px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
border-radius: 50%;
color: #fff;
}
.notify-content {
flex: 1;
}
.notify-title-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.notify-content h4 {
margin: 0;
font-family: "Orbitron", sans-serif;
font-size: 12px;
color: #fff;
}
.notify-time {
font-size: 10px;
color: #4a5d75;
}
.notify-content p {
margin: 0;
font-size: 13px;
color: #a0aec0;
}
.notify-actions {
display: flex;
gap: 8px;
}
.action-btn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #fff;
padding: 6px 12px;
cursor: pointer;
font-family: "Orbitron", sans-serif;
font-size: 10px;
transition: all 0.2s;
}
.action-btn.accept {
background: #00ff88;
color: #000;
border: none;
font-weight: bold;
}
.action-btn.dismiss {
width: 28px;
padding: 6px 0;
}
.action-btn:hover {
filter: brightness(1.2);
}
@keyframes notify-slide-in {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}

View File

@ -1,110 +1,157 @@
{ {
"_comment_Dungeons": "", "_comment_Admin" : "",
"dungeons.original.pirate.pirates_outpost": "Pirate Outpost", "admin.category.original.hostile_list" : "Hostile List",
"dungeons.original.pirate.pirates_outpost.desc": "A hidden supply station belonging to the Black Mark syndicate.", "admin.category.original.item_list" : "Item List",
"dungeons.original.tutorial.pirates_outpost": "Tutorial", "admin.category.original.player_list" : "Player List",
"dungeons.original.tutorial.pirates_outpost.desc": "A one time dungeon.", "admin.category.original.item_list.all" : "All",
"_comment_Enemies": "", "admin.category.original.item_list.alloys" : "Alloys",
"enemies.original.pirate.black_mark_heavy_cruiser": "Black Mark Heavy Cruiser", "admin.category.original.item_list.circuits" : "Circuits",
"enemies.original.pirate.raider_frigate": "Raider Frigate", "admin.category.original.item_list.customizables" : "Customizables",
"enemies.original.pirate.scout_drone": "Scout Drone", "admin.category.original.item_list.ingots" : "Ingots",
"enemies.original.tutorial.tutorial_hostile": "Tutorial hostile", "admin.category.original.item_list.maerials" : "Materials",
"enemies.original.tutorial.tutorial_boss_hostile": "Tutorial Boss", "admin.category.original.item_list.ores" : "Ores",
"_comment_Equipment": "", "admin.category.original.item_list.personal" : "Personal",
"items.materials.original.backpack_basic": "Basic Backpack", "admin.category.original.item_list.ships" : "Ships",
"items.materials.original.backpack_basic.desc": "Basics of the suits.", "admin.category.original.item_list.shields" : "Shields",
"items.materials.original.backpack_advanced": "Advanced Backpack", "admin.category.original.item_list.weapons" : "Weapons",
"items.materials.original.backpack_advanced.desc": "Trying out a better suit.", "admin.category.original.hostile_list.all" : "All",
"items.materials.original.backpack_elite": "Elite Backpack", "admin.category.original.hostile_list.ground" : "Ground Units",
"items.materials.original.backpack_elite.desc": "Now the best the world has to offer.", "admin.category.original.hostile_list.ships" : "Ships",
"_comment_Materials": "", "admin.category.original.player_list.all" : "All",
"items.materials.original.bio.bio_pulp": "Bio Pulp", "admin.category.original.player_list.members" : "Members",
"items.materials.original.bio.bio_pulp.desc": "A pile of biological material.", "admin.category.original.player_list.moderators" : "Moderators",
"items.materials.original.alloys.steel": "Steel Ingot", "admin.category.original.player_list.admins" : "Admins",
"items.materials.original.alloys.steel.desc": "A steel ingot.", "_comment_Core_Systems" : "",
"items.materials.original.ingots.aluminum": "Aluminum Ingot", "core_systems.category.original.person.helmet" : "Personal Helmet",
"items.materials.original.ingots.aluminum.desc": "An aluminum ingot.", "core_systems.category.original.person.suit" : "Personal Suit",
"items.materials.original.ingots.chronite": "Chronite Ingot", "core_systems.category.original.person.gloves" : "Personal Gloves",
"items.materials.original.ingots.chronite.desc": "A chronite ingot.", "core_systems.category.original.person.boots" : "Personal Boots",
"items.materials.original.ingots.copper": "Copper Ingot", "core_systems.category.original.person.accessory_1" : "Personal Accessory 1",
"items.materials.original.ingots.copper.desc": "A copper ingot.", "core_systems.category.original.person.accessory_2" : "Personal Accessory 2",
"items.materials.original.ingots.gold": "Gold Ingot", "core_systems.category.original.person.accessory_3" : "Personal Accessory 3",
"items.materials.original.ingots.gold.desc": "A gold ingot.", "core_systems.category.original.person.accessory_4" : "Personal Accessory 4",
"items.materials.original.ingots.iron": "Iron Ingot", "core_systems.category.original.person.weapon" : "Personal Weapon",
"items.materials.original.ingots.iron.desc": "A iron ingot.", "core_systems.category.original.ship.hull" : "Ship Hull",
"items.materials.original.ingots.titanium": "Titanium Ingot", "core_systems.category.original.ship.shields" : "Ship Shield",
"items.materials.original.ingots.titanium.desc": "A titanium ingot.", "core_systems.category.original.ship.engines" : "Ship Engine",
"items.materials.original.ingots.tungsten": "Tungsten Ingot", "core_systems.category.original.ship.weapon_1" : "Ship Weapon 1",
"items.materials.original.ingots.tungsten.desc": "A tungsten ingot.", "core_systems.category.original.ship.weapon_2" : "Ship Weapon 2",
"items.materials.original.ores.bauxite": "Bauxite Ore", "core_systems.category.original.ship.thruster_1" : "Ship Thruster 1",
"items.materials.original.ores.bauxite.desc": "A pile of bauxite ore.", "core_systems.category.original.ship.thruster_2" : "Ship Thruster 2",
"items.materials.original.ores.chronite": "Chronium Ore", "core_systems.category.original.ship.thruster_3" : "Ship Thruster 3",
"items.materials.original.ores.chronite.desc": "A pile of chronium ore.", "core_systems.category.original.ship.thruster_4" : "Ship Thruster 4",
"items.materials.original.ores.coal": "Coal Ore", "_comment_Dungeons" : "",
"items.materials.original.ores.coal.desc": "A pile of coal ore.", "dungeons.original.pirate.pirates_outpost" : "Pirate Outpost",
"items.materials.original.ores.copper": "Copper Ore", "dungeons.original.pirate.pirates_outpost.desc" : "A hidden supply station belonging to the Black Mark syndicate.",
"items.materials.original.ores.copper.desc": "A pile of copper ore.", "dungeons.original.tutorial.tutorial" : "Tutorial",
"items.materials.original.ores.gold": "Gold Ore", "dungeons.original.tutorial.tutorial.desc" : "A one time dungeon.",
"items.materials.original.ores.gold.desc": "A pile of gold ore.", "_comment_Enemies" : "",
"items.materials.original.ores.ilmenite": "Ilmenite Ore", "enemies.original.pirate.black_mark_heavy_cruiser" : "Black Mark Heavy Cruiser",
"items.materials.original.ores.ilmenite.desc": "A pile of ilmenite ore.", "enemies.original.pirate.raider_frigate" : "Raider Frigate",
"items.materials.original.ores.iron": "Iron Ore", "enemies.original.pirate.scout_drone" : "Scout Drone",
"items.materials.original.ores.iron.desc": "A pile of iron ore.", "enemies.original.tutorial.tutorial_hostile" : "Tutorial hostile",
"items.materials.original.ores.wolfrinite": "Wolfrinite Ore", "enemies.original.tutorial.tutorial_boss_hostile" : "Tutorial Boss",
"items.materials.original.ores.wolfrinite.desc": "A pile of wolfrinite ore.", "_comment_Equipment" : "",
"items.materials.original.plating.basic_ship_plating": "Ship Plating", "items.materials.original.backpack_basic" : "Basic Backpack",
"items.materials.original.plating.basic_ship_plating.desc": "Just basic ship plating.", "items.materials.original.backpack_basic.desc" : "Basics of the suits.",
"_comment_Recipes": "", "items.materials.original.backpack_advanced" : "Advanced Backpack",
"recipes.category.original.alloys": "Alloys", "items.materials.original.backpack_advanced.desc" : "Trying out a better suit.",
"recipes.category.original.circuits": "Circuits", "items.materials.original.backpack_elite" : "Elite Backpack",
"recipes.category.original.food": "Food", "items.materials.original.backpack_elite.desc" : "Now the best the world has to offer.",
"recipes.category.original.forging": "Forging", "_comment_Materials" : "",
"recipes.category.original.hull_sections": "Hull Sections", "items.materials.original.bio.bio_pulp" : "Bio Pulp",
"recipes.category.original.hulls": "Hulls", "items.materials.original.bio.bio_pulp.desc" : "A pile of biological material.",
"recipes.category.original.organics": "Organics", "items.materials.original.alloys.steel" : "Steel Ingot",
"recipes.category.original.spacesuit_parts": "Spacesuit Parts", "items.materials.original.alloys.steel.desc" : "A steel ingot.",
"_comment_Shop": "", "items.materials.original.ingots.aluminum" : "Aluminum Ingot",
"shop.category.original.materials": "Materials", "items.materials.original.ingots.aluminum.desc" : "An aluminum ingot.",
"_comment_Skills": "", "items.materials.original.ingots.chronite" : "Chronite Ingot",
"skills.category.original.combat": "Combat", "items.materials.original.ingots.chronite.desc" : "A chronite ingot.",
"skills.category.original.combat.weapon_effiency": "Weapon Effiency", "items.materials.original.ingots.copper" : "Copper Ingot",
"skills.category.original.combat.weapon_effiency.desc": "Let's get those weapons better!", "items.materials.original.ingots.copper.desc" : "A copper ingot.",
"skills.category.original.crafting": "Crafting", "items.materials.original.ingots.gold" : "Gold Ingot",
"skills.category.original.crafting.blacksmithing": "Blacksmithing", "items.materials.original.ingots.gold.desc" : "A gold ingot.",
"skills.category.original.crafting.blacksmithing.desc": "To forge the basics.", "items.materials.original.ingots.iron" : "Iron Ingot",
"skills.category.original.crafting.alloying": "Alloying", "items.materials.original.ingots.iron.desc" : "A iron ingot.",
"skills.category.original.crafting.alloying.desc": "Let's start alloy making.", "items.materials.original.ingots.titanium" : "Titanium Ingot",
"skills.category.original.science": "Science", "items.materials.original.ingots.titanium.desc" : "A titanium ingot.",
"skills.category.original.science.alien_technology": "Alien Technology", "items.materials.original.ingots.tungsten" : "Tungsten Ingot",
"skills.category.original.science.alien_technology.desc": "Unknown Mysterious Tech", "items.materials.original.ingots.tungsten.desc" : "A tungsten ingot.",
"skills.category.original.science.biology_engineering": "Biology Engineering", "items.materials.original.ores.bauxite" : "Bauxite Ore",
"skills.category.original.science.biology_engineering.desc": "Maybe we will unlock bio-tech?", "items.materials.original.ores.bauxite.desc" : "A pile of bauxite ore.",
"_comment_Stats": "", "items.materials.original.ores.chronite" : "Chronium Ore",
"stats.category.original.attack.base": "Attack", "items.materials.original.ores.chronite.desc" : "A pile of chronium ore.",
"stats.category.original.attack.chance": "Attack Chance", "items.materials.original.ores.coal" : "Coal Ore",
"stats.category.original.attack.rating": "Attack Rating", "items.materials.original.ores.coal.desc" : "A pile of coal ore.",
"stats.category.original.defence.base": "Defense", "items.materials.original.ores.copper" : "Copper Ore",
"stats.category.original.defence.chance": "Defense Chance", "items.materials.original.ores.copper.desc" : "A pile of copper ore.",
"stats.category.original.defence.rating": "Defense Rating", "items.materials.original.ores.gold" : "Gold Ore",
"stats.category.original.health": "Health", "items.materials.original.ores.gold.desc" : "A pile of gold ore.",
"stats.category.original.penetration.base": "Penetration", "items.materials.original.ores.ilunite" : "Ilunite Ore",
"stats.category.original.penetration.chance": "Penetration Chance", "items.materials.original.ores.ilunite.desc" : "A pile of ilunite ore.",
"stats.category.original.penetration.rating": "Penetration Rating", "items.materials.original.ores.iron" : "Iron Ore",
"stats.category.original.reflect.base": "Reflect", "items.materials.original.ores.iron.desc" : "A pile of iron ore.",
"stats.category.original.reflect.chance": "Reflection Chance", "items.materials.original.ores.wolfrinite" : "Wolfrinite Ore",
"stats.category.original.reflect.rating": "Reflection Rating", "items.materials.original.ores.wolfrinite.desc" : "A pile of wolfrinite ore.",
"stats.category.original.resistance.base": "Resistance", "items.materials.original.plating.basic_ship_plating" : "Ship Plating",
"stats.category.original.resistance.cold": "Cold Resistance", "items.materials.original.plating.basic_ship_plating.desc" : "Just basic ship plating.",
"stats.category.original.resistance.gamma": "Gamma Resistance", "_comment_Recipes" : "",
"stats.category.original.resistance.heat": "Heat Resistance", "recipes.category.original.alloys" : "Alloys",
"stats.category.original.resistance.ion": "Ion Resistance", "recipes.category.original.circuits" : "Circuits",
"stats.category.original.resistance.physical": "Physical Resistance", "recipes.category.original.food" : "Food",
"stats.category.original.resistance.plasma": "Plasma Resistance", "recipes.category.original.forging" : "Forging",
"_comment_Tabs": "", "recipes.category.original.hull_sections" : "Hull Sections",
"category.tabs.original.crafting": "Crafting", "recipes.category.original.hulls" : "Hulls",
"category.tabs.original.dashboard": "Dashboard", "recipes.category.original.organics" : "Organics",
"category.tabs.original.dungeons": "Dungeons", "recipes.category.original.spacesuit_parts" : "Spacesuit Parts",
"category.tabs.original.inventory": "Inventory", "_comment_Shop" : "",
"category.tabs.original.shop": "Shop", "shop.category.original.consumables" : "Consumables",
"category.tabs.original.skills": "Skills" "shop.category.original.defence" : "Defence",
"shop.category.original.featured" : "Featured",
"shop.category.original.materials" : "Materials",
"shop.category.original.premium" : "Premium",
"shop.category.original.ships" : "Ships",
"shop.category.original.weapons" : "Weapons",
"_comment_Skills" : "",
"skills.category.original.combat" : "Combat",
"skills.category.original.combat.weapon_effiency" : "Weapon Effiency",
"skills.category.original.combat.weapon_effiency.desc" : "Let's get those weapons better!",
"skills.category.original.crafting" : "Crafting",
"skills.category.original.crafting.blacksmithing" : "Blacksmithing",
"skills.category.original.crafting.blacksmithing.desc" : "To forge the basics.",
"skills.category.original.crafting.alloying" : "Alloying",
"skills.category.original.crafting.alloying.desc" : "Lets start alloy making.",
"skills.category.original.science" : "Science",
"skills.category.original.science.alien_technology" : "Alien Technology",
"skills.category.original.science.alien_technology.desc" : "Unknown Mysterious Tech",
"skills.category.original.science.biology_engineering" : "Biology Engineering",
"skills.category.original.science.biology_engineering.desc" : "Maybe we will unlock bio-tech?",
"_comment_Stats" : "",
"stats.category.original.attack.base" : "Attack",
"stats.category.original.attack.chance" : "Attack Chance",
"stats.category.original.attack.rating" : "Attack Rating",
"stats.category.original.defence.base" : "Defence",
"stats.category.original.defence.chance" : "Defence Chance",
"stats.category.original.defence.rating" : "Defence Rating",
"stats.category.original.health" : "Health",
"stats.category.original.penetration.base" : "Penetration",
"stats.category.original.penetration.chance" : "Penetration Chance",
"stats.category.original.penetration.rating" : "Penetration Rating",
"stats.category.original.reflect.base" : "Reflect",
"stats.category.original.reflect.chance" : "Reflection Chance",
"stats.category.original.reflect.rating" : "Reflection Rating",
"stats.category.original.resistance.base" : "Resistance",
"stats.category.original.resistance.cold" : "Cold Resistance",
"stats.category.original.resistance.gamma" : "Gamma Resistance",
"stats.category.original.resistance.heat" : "Heat Resistance",
"stats.category.original.resistance.ion" : "Ion Resistance",
"stats.category.original.resistance.physical" : "Physical Resistance",
"stats.category.original.resistance.plasma" : "Plasma Resistance",
"_comment_Tabs" : "",
"category.tabs.original.crafting" : "Crafting",
"category.tabs.original.dashboard" : "Dashboard",
"category.tabs.original.dungeons" : "Dungeons",
"category.tabs.original.inventory" : "Inventory",
"category.tabs.original.shop" : "Shop",
"category.tabs.original.skills" : "Skills"
} }

View File

@ -1,9 +1,50 @@
{ {
"_comment_Admin" : "",
"admin.category.original.hostile_list" : "Hostile List",
"admin.category.original.item_list" : "Item List",
"admin.category.original.player_list" : "Player List",
"admin.category.original.item_list.all" : "All",
"admin.category.original.item_list.alloys" : "Alloys",
"admin.category.original.item_list.circuits" : "Circuits",
"admin.category.original.item_list.customizables" : "Customizables",
"admin.category.original.item_list.ingots" : "Ingots",
"admin.category.original.item_list.maerials" : "Materials",
"admin.category.original.item_list.ores" : "Ores",
"admin.category.original.item_list.personal" : "Personal",
"admin.category.original.item_list.ships" : "Ships",
"admin.category.original.item_list.shields" : "Shields",
"admin.category.original.item_list.weapons" : "Weapons",
"admin.category.original.hostile_list.all" : "All",
"admin.category.original.hostile_list.ground" : "Ground Units",
"admin.category.original.hostile_list.ships" : "Ships",
"admin.category.original.player_list.all" : "All",
"admin.category.original.player_list.members" : "Members",
"admin.category.original.player_list.moderators" : "Moderators",
"admin.category.original.player_list.admins" : "Admins",
"_comment_Core_Systems" : "",
"core_systems.category.original.person.helmet" : "Personal Helmet",
"core_systems.category.original.person.suit" : "Personal Suit",
"core_systems.category.original.person.gloves" : "Personal Gloves",
"core_systems.category.original.person.boots" : "Personal Boots",
"core_systems.category.original.person.accessory_1" : "Personal Accessory 1",
"core_systems.category.original.person.accessory_2" : "Personal Accessory 2",
"core_systems.category.original.person.accessory_3" : "Personal Accessory 3",
"core_systems.category.original.person.accessory_4" : "Personal Accessory 4",
"core_systems.category.original.person.weapon" : "Personal Weapon",
"core_systems.category.original.ship.hull" : "Ship Hull",
"core_systems.category.original.ship.shields" : "Ship Shield",
"core_systems.category.original.ship.engines" : "Ship Engine",
"core_systems.category.original.ship.weapon_1" : "Ship Weapon 1",
"core_systems.category.original.ship.weapon_2" : "Ship Weapon 2",
"core_systems.category.original.ship.thruster_1" : "Ship Thruster 1",
"core_systems.category.original.ship.thruster_2" : "Ship Thruster 2",
"core_systems.category.original.ship.thruster_3" : "Ship Thruster 3",
"core_systems.category.original.ship.thruster_4" : "Ship Thruster 4",
"_comment_Dungeons": "", "_comment_Dungeons": "",
"dungeons.original.pirate.pirates_outpost": "Avant-Poste Pirate", "dungeons.original.pirate.pirates_outpost": "Avant-Poste Pirate",
"dungeons.original.pirate.pirates_outpost.desc": "Une station de ravitaillement caché appartenant au syndicat de la Marque Noire.", "dungeons.original.pirate.pirates_outpost.desc": "Une station de ravitaillement caché appartenant au syndicat de la Marque Noire.",
"dungeons.original.tutorial.pirates_outpost": "Tutoriel", "dungeons.original.tutorial.tutorial": "Tutoriel",
"dungeons.original.tutorial.pirates_outpost.desc": "Un donjon unique.", "dungeons.original.tutorial.tutorial.desc": "Un donjon unique.",
"_comment_Enemies": "", "_comment_Enemies": "",
"enemies.original.pirate.black_mark_heavy_cruiser": "Croiseur Lourd Marque Noire", "enemies.original.pirate.black_mark_heavy_cruiser": "Croiseur Lourd Marque Noire",
"enemies.original.pirate.raider_frigate": "Frégate d'Assaillant", "enemies.original.pirate.raider_frigate": "Frégate d'Assaillant",
@ -46,8 +87,8 @@
"items.materials.original.ores.copper.desc": "Une pile de minerai de cuivre.", "items.materials.original.ores.copper.desc": "Une pile de minerai de cuivre.",
"items.materials.original.ores.gold": "Minerai d'or", "items.materials.original.ores.gold": "Minerai d'or",
"items.materials.original.ores.gold.desc": "Une pile de minerai d'or.", "items.materials.original.ores.gold.desc": "Une pile de minerai d'or.",
"items.materials.original.ores.ilmenite": "Minerai d'Ilménite", "items.materials.original.ores.ilunite": "Minerai d'Ilménite",
"items.materials.original.ores.ilmenite.desc": "Une pile de minerai d'ilménite.", "items.materials.original.ores.ilunite.desc": "Une pile de minerai d'ilménite.",
"items.materials.original.ores.iron": "Minerai de fer", "items.materials.original.ores.iron": "Minerai de fer",
"items.materials.original.ores.iron.desc": "Une pile de minerai de fer.", "items.materials.original.ores.iron.desc": "Une pile de minerai de fer.",
"items.materials.original.ores.wolfrinite": "Minerai de Loufrinite", "items.materials.original.ores.wolfrinite": "Minerai de Loufrinite",
@ -63,8 +104,14 @@
"recipes.category.original.hulls": "Coques", "recipes.category.original.hulls": "Coques",
"recipes.category.original.organics": "Organismes", "recipes.category.original.organics": "Organismes",
"recipes.category.original.spacesuit_parts": "Pièces spatiales", "recipes.category.original.spacesuit_parts": "Pièces spatiales",
"_comment_Shop": "", "_comment_Shop" : "",
"shop.category.original.materials": "Matériaux", "shop.category.original.consumables" : "Consumables",
"shop.category.original.defence" : "Defence",
"shop.category.original.featured" : "Featured",
"shop.category.original.materials" : "Materials",
"shop.category.original.premium" : "Premium",
"shop.category.original.ships" : "Ships",
"shop.category.original.weapons" : "Weapons",
"_comment_Skills": "", "_comment_Skills": "",
"skills.category.original.combat": "Guerre", "skills.category.original.combat": "Guerre",
"skills.category.original.combat.weapon_effiency": "Efficacité de l'arme", "skills.category.original.combat.weapon_effiency": "Efficacité de l'arme",
@ -101,6 +148,7 @@
"stats.category.original.resistance.physical": "Résistance physique", "stats.category.original.resistance.physical": "Résistance physique",
"stats.category.original.resistance.plasma": "Résistance au plasma", "stats.category.original.resistance.plasma": "Résistance au plasma",
"_comment_Tabs": "", "_comment_Tabs": "",
"category.tabs.original.admin_panel" : "Admin",
"category.tabs.original.crafting": "Fabriquer", "category.tabs.original.crafting": "Fabriquer",
"category.tabs.original.dashboard": "Tableaux de bord", "category.tabs.original.dashboard": "Tableaux de bord",
"category.tabs.original.dungeons": "Les donjons", "category.tabs.original.dungeons": "Les donjons",

View File

@ -1,9 +1,50 @@
{ {
"_comment_Admin" : "",
"admin.category.original.hostile_list" : "Hostile List",
"admin.category.original.item_list" : "Item List",
"admin.category.original.player_list" : "Player List",
"admin.category.original.item_list.all" : "All",
"admin.category.original.item_list.alloys" : "Alloys",
"admin.category.original.item_list.circuits" : "Circuits",
"admin.category.original.item_list.customizables" : "Customizables",
"admin.category.original.item_list.ingots" : "Ingots",
"admin.category.original.item_list.maerials" : "Materials",
"admin.category.original.item_list.ores" : "Ores",
"admin.category.original.item_list.personal" : "Personal",
"admin.category.original.item_list.ships" : "Ships",
"admin.category.original.item_list.shields" : "Shields",
"admin.category.original.item_list.weapons" : "Weapons",
"admin.category.original.hostile_list.all" : "All",
"admin.category.original.hostile_list.ground" : "Ground Units",
"admin.category.original.hostile_list.ships" : "Ships",
"admin.category.original.player_list.all" : "All",
"admin.category.original.player_list.members" : "Members",
"admin.category.original.player_list.moderators" : "Moderators",
"admin.category.original.player_list.admins" : "Admins",
"_comment_Core_Systems" : "",
"core_systems.category.original.person.helmet" : "Personal Helmet",
"core_systems.category.original.person.suit" : "Personal Suit",
"core_systems.category.original.person.gloves" : "Personal Gloves",
"core_systems.category.original.person.boots" : "Personal Boots",
"core_systems.category.original.person.accessory_1" : "Personal Accessory 1",
"core_systems.category.original.person.accessory_2" : "Personal Accessory 2",
"core_systems.category.original.person.accessory_3" : "Personal Accessory 3",
"core_systems.category.original.person.accessory_4" : "Personal Accessory 4",
"core_systems.category.original.person.weapon" : "Personal Weapon",
"core_systems.category.original.ship.hull" : "Ship Hull",
"core_systems.category.original.ship.shields" : "Ship Shield",
"core_systems.category.original.ship.engines" : "Ship Engine",
"core_systems.category.original.ship.weapon_1" : "Ship Weapon 1",
"core_systems.category.original.ship.weapon_2" : "Ship Weapon 2",
"core_systems.category.original.ship.thruster_1" : "Ship Thruster 1",
"core_systems.category.original.ship.thruster_2" : "Ship Thruster 2",
"core_systems.category.original.ship.thruster_3" : "Ship Thruster 3",
"core_systems.category.original.ship.thruster_4" : "Ship Thruster 4",
"_comment_Dungeons": "", "_comment_Dungeons": "",
"dungeons.original.pirate.pirates_outpost": "Аванпост піратів", "dungeons.original.pirate.pirates_outpost": "Аванпост піратів",
"dungeons.original.pirate.pirates_outpost.desc": "Прихована станція постачання, що належить синдикату «Чорна Мітка».", "dungeons.original.pirate.pirates_outpost.desc": "Прихована станція постачання, що належить синдикату «Чорна Мітка».",
"dungeons.original.tutorial.pirates_outpost": "Навчання", "dungeons.original.tutorial.tutorial": "Навчання",
"dungeons.original.tutorial.pirates_outpost.desc": "Одноразове підземелля для освоєння азів.", "dungeons.original.tutorial.tutorial.desc": "Одноразове підземелля для освоєння азів.",
"_comment_Enemies": "", "_comment_Enemies": "",
"enemies.original.pirate.black_mark_heavy_cruiser": "Важкий крейсер «Чорної Мітки»", "enemies.original.pirate.black_mark_heavy_cruiser": "Важкий крейсер «Чорної Мітки»",
"enemies.original.pirate.raider_frigate": "Фрегат рейдерів", "enemies.original.pirate.raider_frigate": "Фрегат рейдерів",
@ -46,8 +87,8 @@
"items.materials.original.ores.copper.desc": "Купа мідної руди.", "items.materials.original.ores.copper.desc": "Купа мідної руди.",
"items.materials.original.ores.gold": "Золота руда", "items.materials.original.ores.gold": "Золота руда",
"items.materials.original.ores.gold.desc": "Купа золотоносної руди.", "items.materials.original.ores.gold.desc": "Купа золотоносної руди.",
"items.materials.original.ores.ilmenite": "Ільменітова руда", "items.materials.original.ores.ilunite": "Ільменітова руда",
"items.materials.original.ores.ilmenite.desc": "Купа ільменітової руди.", "items.materials.original.ores.ilunite.desc": "Купа ільменітової руди.",
"items.materials.original.ores.iron": "Залізна руда", "items.materials.original.ores.iron": "Залізна руда",
"items.materials.original.ores.iron.desc": "Купа залізної руди.", "items.materials.original.ores.iron.desc": "Купа залізної руди.",
"items.materials.original.ores.wolfrinite": "Вольфрамова руда", "items.materials.original.ores.wolfrinite": "Вольфрамова руда",
@ -64,7 +105,13 @@
"recipes.category.original.organics": "Органіка", "recipes.category.original.organics": "Органіка",
"recipes.category.original.spacesuit_parts": "Деталі скафандра", "recipes.category.original.spacesuit_parts": "Деталі скафандра",
"_comment_Shop": "", "_comment_Shop": "",
"shop.category.original.consumables" : "Consumables",
"shop.category.original.defence" : "Defence",
"shop.category.original.featured" : "Featured",
"shop.category.original.materials": "Матеріали", "shop.category.original.materials": "Матеріали",
"shop.category.original.premium" : "Premium",
"shop.category.original.ships" : "Ships",
"shop.category.original.weapons" : "Weapons",
"_comment_Skills": "", "_comment_Skills": "",
"skills.category.original.combat": "Бойові навички", "skills.category.original.combat": "Бойові навички",
"skills.category.original.combat.weapon_effiency": "Ефективність зброї", "skills.category.original.combat.weapon_effiency": "Ефективність зброї",
@ -101,6 +148,7 @@
"stats.category.original.resistance.physical": "Фізичний опір", "stats.category.original.resistance.physical": "Фізичний опір",
"stats.category.original.resistance.plasma": "Плазмовий опір", "stats.category.original.resistance.plasma": "Плазмовий опір",
"_comment_Tabs": "", "_comment_Tabs": "",
"category.tabs.original.admin_panel" : "Admin",
"category.tabs.original.crafting": "Крафт", "category.tabs.original.crafting": "Крафт",
"category.tabs.original.dashboard": "Головна", "category.tabs.original.dashboard": "Головна",
"category.tabs.original.dungeons": "Підземелля", "category.tabs.original.dungeons": "Підземелля",

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -1,8 +1,8 @@
{ {
"dungeon": { "dungeon": {
"id": "original:tutorial/tutorial_dungeon", "id": "original:tutorial/tutorial_dungeon",
"displayName": "dungeons.original.tutorial.pirates_outpost", "displayName": "dungeons.original.tutorial.tutorial",
"description": "dungeons.original.tutorial.pirates_outpost.desc", "description": "dungeons.original.tutorial.tutorial.desc",
"energyCost": 0, "energyCost": 0,
"repeatable": false, "repeatable": false,
"rooms": [ "rooms": [

View File

@ -1,11 +0,0 @@
{
"materials": {
"id": "original:ore_ilmenite",
"texture": "original/assets/textures/materials/ore/ilmenite.png",
"displayName": "items.materials.original.ores.ilmenite",
"description": "items.materials.original.ores.ilmenite.desc",
"meta": {
"storeCategory": "original:materials"
}
}
}

View File

@ -0,0 +1,11 @@
{
"materials": {
"id": "original:ore_ilunite",
"texture": "original/assets/textures/materials/ore/ilunite.png",
"displayName": "items.materials.original.ores.ilunite",
"description": "items.materials.original.ores.ilunite.desc",
"meta": {
"storeCategory": "original:materials"
}
}
}

View File

@ -1,13 +1,10 @@
{ {
"recipe": { "recipe": {
"inputs": [ "inputs": [{ "original:ingot_iron": 1 }, { "original:ore_coal": 5 }],
{ "original:ingot_iron": 1 },
{ "original:ore_coal": 5 }
],
"output": { "output": {
"original:alloy_steel": 1 "original:alloy_steel": 1
}, },
"time_seconds": 180, "time_seconds": 10,
"requires": { "requires": {
"original:alloying": 0 "original:alloying": 0
} }

View File

@ -1,6 +1,159 @@
{ {
"name": "original", "name": "original",
"version": "0.0.1", "version": "0.0.1",
"admin_item_list": {
"categories": {
"original:all": {
"displayName": "admin.category.original.item_list.all"
},
"original:alloys": {
"displayName": "admin.category.original.item_list.alloys"
},
"original:circuits": {
"displayName": "admin.category.original.item_list.circuits"
},
"original:customizables": {
"displayName": "admin.category.original.item_list.customizables"
},
"original:ingots": {
"displayName": "admin.category.original.item_list.ingots"
},
"original:materials": {
"displayName": "admin.category.original.item_list.materials"
},
"original:ores": {
"displayName": "admin.category.original.item_list.ores"
},
"original:personal": {
"displayName": "admin.category.original.item_list.personal"
},
"original:ships": {
"displayName": "admin.category.original.item_list.ships"
},
"original:shields": {
"displayName": "admin.category.original.item_list.shields"
},
"original:weapons": {
"displayName": "admin.category.original.item_list.weapons"
}
}
},
"admin_hostiles_list": {
"categories": {
"original:all": {
"displayName": "admin.category.original.hostile_list.all"
},
"original:ground": {
"displayName": "admin.category.original.hostile_list.ground"
},
"original:ships": {
"displayName": "admin.category.original.hostile_list.ship"
}
}
},
"admin_panel": {
"categories": {
"original:admin_hostiles_list": {
"displayName": "admin.category.original.hostile_list"
},
"original:admin_item_list": {
"displayName": "admin.category.original.item_list"
},
"original:admin_player_list": {
"displayName": "admin.category.original.player_list"
}
}
},
"admin_player_list": {
"categories": {
"original:all": {
"displayName": "admin.category.original.player_list.all"
},
"original:members": {
"displayName": "admin.category.original.player_list.members"
},
"original:moderators": {
"displayName": "admin.category.original.player_list.moderators"
},
"original:admins": {
"displayName": "admin.category.original.player_list.admins"
}
}
},
"admin_role": {
"categories": {
"original:all": {
"displayName": "admin.category.original.player_list.all"
},
"original:members": {
"displayName": "admin.category.original.player_list.members"
},
"original:moderators": {
"displayName": "admin.category.original.player_list.moderators"
},
"original:admins": {
"displayName": "admin.category.original.player_list.admins"
}
}
},
"core_systems": {
"categories": {
"original:person_helmet": {
"displayName": "core_systems.category.original.person.helmet"
},
"original:person_suit": {
"displayName": "core_systems.category.original.person.suit"
},
"original:person_gloves": {
"displayName": "core_systems.category.original.person.gloves"
},
"original:person_boots": {
"displayName": "core_systems.category.original.person.boots"
},
"original:person_accessory_1": {
"displayName": "core_systems.category.original.person.accessory_1"
},
"original:person_accessory_2": {
"displayName": "core_systems.category.original.person.accessory_2"
},
"original:person_accessory_3": {
"displayName": "core_systems.category.original.person.accessory_3"
},
"original:person_accessory_4": {
"displayName": "core_systems.category.original.person.accessory_4"
},
"original:person_weapons": {
"displayName": "core_systems.category.original.person.weapon"
},
"original:ship_hull": {
"displayName": "core_systems.category.original.ship.hull"
},
"original:ship_shields": {
"displayName": "core_systems.category.original.ship.shields"
},
"original:ship_engines": {
"displayName": "core_systems.category.original.ship.engines"
},
"original:ship_weapon_1": {
"displayName": "core_systems.category.original.ship.weapon_1"
},
"original:ship_weapon_2": {
"displayName": "core_systems.category.original.ship.weapon_2"
},
"original:ship_thruster_1": {
"displayName": "core_systems.category.original.ship.thruster_1"
},
"original:ship_thruster_2": {
"displayName": "core_systems.category.original.ship.thruster_2"
},
"original:ship_thruster_3": {
"displayName": "core_systems.category.original.ship.thruster_3"
},
"original:ship_thruster_4": {
"displayName": "core_systems.category.original.ship.thruster_4"
}
}
},
"recipes": { "recipes": {
"categories": { "categories": {
"original:alloys": { "original:alloys": {
@ -31,8 +184,26 @@
}, },
"shop": { "shop": {
"categories": { "categories": {
"original:featured": {
"displayName": "shop.category.original.featured"
},
"original:ships": {
"displayName": "shop.category.original.ships"
},
"original:weapons": {
"displayName": "shop.category.original.weapons"
},
"original:defence": {
"displayName": "shop.category.original.defence"
},
"original:consumables": {
"displayName": "shop.category.original.consumables"
},
"original:materials": { "original:materials": {
"displayName": "shop.category.original.materials" "displayName": "shop.category.original.materials"
},
"original:premium": {
"displayName": "shop.category.original.premium"
} }
} }
}, },

View File

@ -18,6 +18,7 @@
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"express": "^5.2.1", "express": "^5.2.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"pg": "^8.20.0",
"sequelize": "^6.37.8", "sequelize": "^6.37.8",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"sqlite3": "^6.0.1" "sqlite3": "^6.0.1"

View File

@ -7,6 +7,7 @@ const config = {
serverSecret: process.env.SERVER_SECRET, serverSecret: process.env.SERVER_SECRET,
serverDescription: process.env.DESCRIPTION, serverDescription: process.env.DESCRIPTION,
serverRegion: process.env.REGION, serverRegion: process.env.REGION,
dbUri: process.env.DB_URI || "local",
}; };
module.exports = config; module.exports = config;

View File

@ -1,30 +1,44 @@
const { Sequelize } = require("sequelize"); const { Sequelize } = require("sequelize");
const config = require("./config"); // Шлях до твого файлу з конфігом
const sequelize = new Sequelize({ const isLocal = config.dbUri === "local";
const sequelize = isLocal
? new Sequelize({
dialect: "sqlite", dialect: "sqlite",
storage: "./database.sqlite", storage: "./database.sqlite",
logging: false, logging: false,
dialectOptions: { timeout: 20000 },
pool: { max: 1, min: 1, idle: 10000, acquire: 30000 },
})
: new Sequelize(config.dbUri, {
dialect: "postgres",
logging: false,
dialectOptions: { dialectOptions: {
timeout: 20000, ssl: {
require: true,
rejectUnauthorized: false,
}, },
pool: {
max: 1,
min: 1,
idle: 10000,
acquire: 30000,
}, },
}); pool: { max: 5, min: 0, idle: 10000, acquire: 30000 },
});
sequelize.initDatabase = async () => { sequelize.initDatabase = async () => {
try { try {
await sequelize.authenticate(); await sequelize.authenticate();
if (isLocal) {
await sequelize.query("PRAGMA journal_mode=WAL;"); await sequelize.query("PRAGMA journal_mode=WAL;");
await sequelize.query("PRAGMA foreign_keys = OFF;"); await sequelize.query("PRAGMA foreign_keys = OFF;");
}
await sequelize.sync({ alter: true }); await sequelize.sync({ alter: true });
if (isLocal) {
await sequelize.query("PRAGMA foreign_keys = ON;"); await sequelize.query("PRAGMA foreign_keys = ON;");
}
console.log(`✅ Database connected (${isLocal ? "SQLite" : "Postgres"})`);
return true; return true;
} catch (error) { } catch (error) {
console.error("❌ Database Init Error:", error); console.error("❌ Database Init Error:", error);

View File

@ -0,0 +1,70 @@
const Player = require("../models/Player");
const { Op } = require("sequelize");
const Message = require("../models/Message.js");
class ChatManager {
async getGlobalHistory(limit = 50) {
try {
return await Message.findAll({
where: { type: "global" },
limit,
order: [["createdAt", "ASC"]],
include: [
{
model: Player,
as: "sender",
attributes: ["username"],
},
],
});
} catch (error) {
console.error("History Error:", error);
return [];
}
}
async saveMessage({ content, type, senderId, receiverId = null }) {
try {
const newMessage = await Message.create({
content,
type,
senderId,
receiverId,
});
const sender = await Player.findByPk(senderId, {
attributes: ["username"],
});
return {
id: newMessage.id,
content: newMessage.content,
type: newMessage.type,
senderId: newMessage.senderId,
senderName: sender ? sender.username : "Unknown",
receiverId: newMessage.receiverId,
createdAt: newMessage.createdAt,
};
} catch (error) {
console.error("Save Message Error:", error);
throw error;
}
}
async searchPlayers(query, excludeId) {
try {
return await Player.findAll({
where: {
username: { [Op.like]: `%${query}%` },
id: { [Op.ne]: excludeId },
},
attributes: ["id", "username", "level"],
limit: 10,
});
} catch (error) {
console.error("Search Error:", error);
return [];
}
}
}
module.exports = new ChatManager();

View File

@ -0,0 +1,71 @@
const datapackLoader = require("../game/DatapackLoader");
const { Inventory } = require("../models");
class CraftManager {
constructor() {
this.activeCrafts = new Map();
}
async startCraft(userId, recipeId, socket) {
if (this.activeCrafts.has(userId)) return { error: "Already crafting" };
const recipe = datapackLoader.getRecipe(recipeId);
if (!recipe) return { error: "Recipe not found" };
const craftTimeMs = (recipe.time_seconds || 0) * 1000;
const finishAt = Date.now() + craftTimeMs;
const craftData = {
recipeId,
finishAt,
totalTime: recipe.time_seconds,
timer: setTimeout(
() => this.completeCraft(userId, recipeId, socket),
craftTimeMs,
),
};
this.activeCrafts.set(userId, craftData);
return { recipeId, finishAt, totalTime: recipe.time_seconds };
}
async completeCraft(userId, recipeId, socket) {
try {
const recipe = datapackLoader.getRecipe(recipeId);
const outputItemId = Object.keys(recipe.output)[0];
const outputQuantity = recipe.output[outputItemId];
const [newItem, created] = await Inventory.findOrCreate({
where: { playerId: userId, itemId: outputItemId },
defaults: { quantity: outputQuantity },
});
if (!created) {
await newItem.increment("quantity", { by: outputQuantity });
}
this.activeCrafts.delete(userId);
if (socket && socket.connected) {
socket.emit("player:craft_success", { recipeId });
socket.emit("player:get_inventory");
}
} catch (err) {
console.error("Complete craft error:", err);
}
}
getExistingCraft(userId) {
const craft = this.activeCrafts.get(userId);
if (craft && craft.finishAt > Date.now()) {
return {
recipeId: craft.recipeId,
finishAt: craft.finishAt,
totalTime: craft.totalTime,
};
}
return null;
}
}
module.exports = new CraftManager();

View File

@ -73,7 +73,22 @@ class DatapackLoader {
`🚀 Registry Ready: ${this.registry.items.size} Items, ${this.registry.dungeons.size} Dungeons, ${this.registry.languages.size} Langs, ${manifestCount} Manifests`, `🚀 Registry Ready: ${this.registry.items.size} Items, ${this.registry.dungeons.size} Dungeons, ${this.registry.languages.size} Langs, ${manifestCount} Manifests`,
); );
} }
getRecipe(id) {
return this.registry.recipes.get(id);
}
getRecipesByCategory(category) {
const allRecipes = Array.from(this.registry.recipes.values());
return allRecipes.filter((r) => r.category === category);
}
getRecipeCategories() {
const allRecipes = Array.from(this.registry.recipes.values());
const categories = new Set(
allRecipes.map((r) => r.category).filter(Boolean),
);
return Array.from(categories);
}
loadLanguages(langPath) { loadLanguages(langPath) {
try { try {
const files = fs.readdirSync(langPath).filter((f) => f.endsWith(".json")); const files = fs.readdirSync(langPath).filter((f) => f.endsWith(".json"));

View File

@ -0,0 +1,34 @@
const Notification = require("../models/Notification");
class NotificationManager {
async createNotification({ playerId, type, title, message, data = {} }) {
try {
return await Notification.create({
playerId,
type,
title,
message,
data,
});
} catch (error) {
console.error("Notify Error:", error);
}
}
async getPlayerNotifications(playerId) {
return await Notification.findAll({
where: { playerId },
order: [["createdAt", "DESC"]],
limit: 20,
});
}
async markAsRead(notificationId) {
return await Notification.update(
{ isRead: true },
{ where: { id: notificationId } },
);
}
}
module.exports = new NotificationManager();

View File

@ -6,7 +6,7 @@ class SessionManager {
addPlayer(socketId, playerRaw) { addPlayer(socketId, playerRaw) {
this.sessions.set(socketId, { this.sessions.set(socketId, {
id: playerRaw.id, id: playerRaw.id,
nickname: playerRaw.username, username: playerRaw.username,
level: playerRaw.level, level: playerRaw.level,
energy: playerRaw.energy, energy: playerRaw.energy,
maxEnergy: playerRaw.maxEnergy, maxEnergy: playerRaw.maxEnergy,

View File

@ -0,0 +1,16 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../config/db");
const Friend = sequelize.define("Friend", {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
status: {
type: DataTypes.ENUM("pending", "accepted"),
defaultValue: "pending",
},
});
module.exports = Friend;

View File

@ -0,0 +1,23 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../config/db");
const Message = sequelize.define("Message", {
content: {
type: DataTypes.TEXT,
allowNull: false,
},
type: {
type: DataTypes.ENUM("global", "private"),
defaultValue: "global",
},
senderId: {
type: DataTypes.STRING,
allowNull: false,
},
receiverId: {
type: DataTypes.STRING,
allowNull: true,
},
});
module.exports = Message;

View File

@ -0,0 +1,29 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../config/db");
const Notification = sequelize.define("Notification", {
playerId: {
type: DataTypes.STRING,
allowNull: false,
},
type: {
type: DataTypes.ENUM("friend_request", "crafting", "system", "trade"),
allowNull: false,
},
title: {
type: DataTypes.STRING,
allowNull: false,
},
message: {
type: DataTypes.TEXT,
},
data: {
type: DataTypes.JSON,
},
isRead: {
type: DataTypes.BOOLEAN,
defaultValue: false,
},
});
module.exports = Notification;

View File

@ -0,0 +1,27 @@
const Player = require("./Player");
const Message = require("./Message.js");
const Friend = require("./Friend");
const setupAssociations = () => {
Message.belongsTo(Player, {
as: "sender",
foreignKey: "senderId",
});
Message.belongsTo(Player, {
as: "receiver",
foreignKey: "receiverId",
});
Player.hasMany(Message, {
foreignKey: "senderId",
});
Player.belongsToMany(Player, {
through: Friend,
as: "Friends",
foreignKey: "playerId",
otherKey: "friendId",
});
};
module.exports = setupAssociations;

View File

@ -1,12 +1,16 @@
const sequelize = require("../config/db"); const sequelize = require("../config/db");
const Player = require("./Player"); const Player = require("./Player");
const Inventory = require("./Inventory"); const Inventory = require("./Inventory");
const setupAssociations = require("./associations");
const Notification = require("./Notification");
Player.hasMany(Inventory, { foreignKey: "playerId", as: "inventory" }); Player.hasMany(Inventory, { foreignKey: "playerId", as: "inventory" });
Inventory.belongsTo(Player, { foreignKey: "playerId" }); Inventory.belongsTo(Player, { foreignKey: "playerId" });
setupAssociations();
module.exports = { module.exports = {
sequelize, sequelize,
Player, Player,
Inventory, Inventory,
Notification,
}; };

View File

@ -61,7 +61,7 @@ module.exports = (io, socket) => {
case "/clear": { case "/clear": {
const [_, targetName] = args; const [_, targetName] = args;
const targetPlayer = await Player.findOne({ const targetPlayer = await Player.findOne({
where: { name: targetName }, where: { username: targetName },
}); });
if (!targetPlayer) throw new Error("Player not found."); if (!targetPlayer) throw new Error("Player not found.");

View File

@ -0,0 +1,63 @@
const chatManager = require("../../game/ChatManager");
module.exports = (io, socket) => {
socket.on("chat:get_global_history", async () => {
const history = await chatManager.getGlobalHistory();
const formattedHistory = history.map((m) => ({
id: m.id,
content: m.content,
senderName: m.sender?.username || "Unknown",
senderId: m.senderId,
createdAt: m.createdAt,
type: m.type,
}));
socket.emit("chat:global_history", formattedHistory);
});
socket.on("chat:get_global_history", async () => {
console.log(`Player ${socket.player?.id} requested chat history`);
const history = await chatManager.getGlobalHistory();
const formattedHistory = history.map((m) => ({
id: m.id,
content: m.content,
senderName: m.sender?.username || "Unknown",
senderId: m.senderId,
createdAt: m.createdAt,
type: m.type,
}));
socket.emit("chat:global_history", formattedHistory);
});
socket.on("chat:send_message", async (payload) => {
try {
const senderId = socket.user?.id;
if (!senderId) return;
const messageData = await chatManager.saveMessage({
content: payload.content,
type: payload.type || "global",
senderId: senderId,
receiverId: payload.receiverId,
});
if (messageData.type === "global") {
io.emit("chat:new_message", messageData);
} else {
socket
.to(`user_${payload.receiverId}`)
.emit("chat:new_message", messageData);
socket.emit("chat:new_message", messageData);
}
} catch (err) {
console.log(err);
socket.emit("error", { message: "CHAT_SEND_ERROR" });
}
});
socket.on("player:search", async ({ query }) => {
const senderId = socket.player?.id;
if (!query || !senderId) return;
const results = await chatManager.searchPlayers(query, senderId);
socket.emit("player:search_results", results);
});
};

View File

@ -1,3 +1,4 @@
const { Op } = require("sequelize");
const Player = require("../../models/Player"); const Player = require("../../models/Player");
const sessionManager = require("../../game/SessionManager"); const sessionManager = require("../../game/SessionManager");
const economyService = require("../../game/EconomyService.js"); const economyService = require("../../game/EconomyService.js");
@ -8,7 +9,6 @@ module.exports = async (io, socket) => {
const sid = socket.id.substring(0, 5); const sid = socket.id.substring(0, 5);
if (!userId) { if (!userId) {
console.log(`⚠️ [${sid}] Anonymous connection rejected`);
return socket.disconnect(); return socket.disconnect();
} }
@ -21,7 +21,6 @@ module.exports = async (io, socket) => {
id: userId, id: userId,
username: username, username: username,
}); });
console.log(`🆕 [${sid}] New player registered: ${username}`);
} catch (createErr) { } catch (createErr) {
player = await Player.findByPk(userId); player = await Player.findByPk(userId);
} }
@ -35,6 +34,20 @@ module.exports = async (io, socket) => {
const playerRaw = player.get({ plain: true }); const playerRaw = player.get({ plain: true });
sessionManager.addPlayer(socket.id, playerRaw); sessionManager.addPlayer(socket.id, playerRaw);
const onlinePlayersData = sessionManager.getAllOnline();
const onlineUsernames = onlinePlayersData.map((p) => p.username);
const onlineIds = onlinePlayersData.map((p) => p.id);
const offlinePlayersModels = await Player.findAll({
where: {
id: { [Op.notIn]: onlineIds },
},
attributes: ["username"],
raw: true,
});
const offlineUsernames = offlinePlayersModels.map((p) => p.username);
socket.emit("session:ready", { socket.emit("session:ready", {
player: { player: {
@ -45,10 +58,12 @@ module.exports = async (io, socket) => {
experience: playerRaw.experience, experience: playerRaw.experience,
}, },
offlineEarned: offlineCredits, offlineEarned: offlineCredits,
onlineCount: sessionManager.getAllOnline().length, onlinePlayers: onlineUsernames,
offlinePlayers: offlineUsernames,
}); });
socket.broadcast.emit("player:joined", { username: playerRaw.username }); socket.broadcast.emit("player:joined", { username: playerRaw.username });
socket.on("player:get_dashboard", async () => { socket.on("player:get_dashboard", async () => {
try { try {
const p = await Player.findByPk(userId); const p = await Player.findByPk(userId);
@ -57,6 +72,7 @@ module.exports = async (io, socket) => {
console.error(`❌ [${sid}] Dashboard error:`, err.message); console.error(`❌ [${sid}] Dashboard error:`, err.message);
} }
}); });
socket.on("disconnect", async () => { socket.on("disconnect", async () => {
try { try {
await Player.update( await Player.update(
@ -67,7 +83,7 @@ module.exports = async (io, socket) => {
economyService.removePlayer(userId); economyService.removePlayer(userId);
sessionManager.removePlayer(socket.id); sessionManager.removePlayer(socket.id);
console.log(`🔌 [${sid}] Player disconnected: ${userId}`); socket.broadcast.emit("player:left", { username: playerRaw.username });
}); });
} catch (err) { } catch (err) {
console.error(`❌ [${sid}] Connection Error:`, err.message); console.error(`❌ [${sid}] Connection Error:`, err.message);

View File

@ -1,80 +1,89 @@
const datapackLoader = require("../../game/DatapackLoader"); const datapackLoader = require("../../game/DatapackLoader");
const { Inventory } = require("../../models"); const { Inventory } = require("../../models");
const craftManager = require("../../game/CraftManager");
module.exports = (io, socket) => { module.exports = (io, socket) => {
const userId = socket.user?.id; const userId = socket.user?.id;
const sendStatus = () => {
const existing = craftManager.getExistingCraft(userId);
if (existing) {
socket.emit("player:craft_started", existing);
}
};
socket.on("player:check_active_craft", () => {
sendStatus();
});
socket.on("player:get_recipe_categories", () => { socket.on("player:get_recipe_categories", () => {
try { try {
const categories = datapackLoader.getRecipeCategories(); const categories = datapackLoader.getRecipeCategories();
socket.emit("player:recipe_categories_data", categories); socket.emit("player:recipe_categories_data", categories);
} catch (err) { } catch (err) {
console.error("Error getting categories:", err.message); console.error(err.message);
} }
}); });
socket.on("player:get_recipes", ({ category }) => { socket.on("player:get_recipes", ({ category }) => {
try { try {
if (!category) return; if (!category) return;
const rawRecipes = datapackLoader.getRecipesByCategory(category); const rawRecipes = datapackLoader.getRecipesByCategory(category);
const recipeIds = rawRecipes.map((r) => r.id); const recipeIds = rawRecipes.map((r) => r.id);
socket.emit("player:recipes_data", { category, recipeIds });
socket.emit("player:recipes_data", {
category,
recipeIds: recipeIds,
});
} catch (err) { } catch (err) {
console.error("Error fetching recipes:", err.message); console.error(err.message);
} }
}); });
socket.on("player:craft_item", async ({ recipeId, category }) => { socket.on("player:craft_item", async ({ recipeId }) => {
try { try {
const recipe = datapackLoader.getRecipe(category, recipeId); if (craftManager.getExistingCraft(userId)) {
return socket.emit("error", { message: "Already crafting" });
}
const recipe = datapackLoader.getRecipe(recipeId);
if (!recipe) return socket.emit("error", { message: "Recipe not found" }); if (!recipe) return socket.emit("error", { message: "Recipe not found" });
for (const ing of recipe.ingredients) { for (const ing of recipe.inputs) {
const itemId = Object.keys(ing)[0];
const quantity = ing[itemId];
const invItem = await Inventory.findOne({ const invItem = await Inventory.findOne({
where: { playerId: userId, itemId: ing.itemId }, where: { playerId: userId, itemId: itemId },
}); });
if (!invItem || invItem.quantity < ing.quantity) { if (!invItem || invItem.quantity < quantity) {
return socket.emit("error", { return socket.emit("error", { message: `Not enough resources` });
message: `Недостатньо ресурсів для ${recipeId}`,
});
} }
} }
for (const ing of recipe.ingredients) { for (const ing of recipe.inputs) {
const itemId = Object.keys(ing)[0];
const quantity = ing[itemId];
const invItem = await Inventory.findOne({ const invItem = await Inventory.findOne({
where: { playerId: userId, itemId: ing.itemId }, where: { playerId: userId, itemId: itemId },
}); });
if (invItem.quantity === ing.quantity) { if (invItem.quantity === quantity) {
await invItem.destroy(); await invItem.destroy();
} else { } else {
await invItem.decrement("quantity", { by: ing.quantity }); await invItem.decrement("quantity", { by: quantity });
} }
} }
const [newItem, created] = await Inventory.findOrCreate({ const result = await craftManager.startCraft(userId, recipeId, socket);
where: { playerId: userId, itemId: recipe.id },
defaults: { quantity: 1 },
});
if (!created) { if (result.error) {
await newItem.increment("quantity", { by: 1 }); return socket.emit("error", { message: result.error });
} }
socket.emit("player:craft_success", { recipeId }); socket.emit("player:craft_started", result);
socket.emit("player:get_inventory"); socket.emit("player:get_inventory");
} catch (err) { } catch (err) {
console.error("Crafting error:", err.message); console.error(err.message);
socket.emit("error", { message: "Internal Crafting Error" }); socket.emit("error", { message: "Internal Crafting Error" });
} }
}); });
sendStatus();
}; };

View File

@ -0,0 +1,114 @@
const Player = require("../../models/Player");
const Friend = require("../../models/Friend");
const NotificationManager = require("../../game/NotificationManager");
const Notification = require("../../models/Notification");
module.exports = (io, socket) => {
socket.on("player:search", async ({ query }) => {
try {
const players = await Player.findAll({
where: {
username: { [require("sequelize").Op.like]: `%${query}%` },
id: { [require("sequelize").Op.ne]: socket.user.id },
},
limit: 5,
attributes: ["id", "username", "level"],
});
socket.emit("player:search_results", players);
} catch (e) {
console.error(e);
}
});
socket.on("friend:add", async ({ friendId }) => {
try {
const player = socket.user;
await NotificationManager.createNotification({
playerId: friendId,
type: "friend_request",
title: "NEW FRIEND REQUEST",
message: `${player.username} wants to add you as a friend.`,
data: { fromId: player.id },
});
io.to(friendId).emit("notification:new", {
type: "friend_request",
title: "NEW FRIEND REQUEST",
message: `${player.username} wants to add you as a friend.`,
data: { fromId: player.id },
createdAt: new Date(),
});
} catch (e) {
socket.emit("error", { message: "FAILED_TO_SEND_REQUEST" });
}
});
socket.on("friend:accept", async ({ friendId, notificationId }) => {
try {
const myId = socket.user.id;
const exists = await Friend.findOne({
where: { playerId: myId, friendId: friendId },
});
if (!exists) {
await Friend.bulkCreate([
{ playerId: myId, friendId: friendId },
{ playerId: friendId, friendId: myId },
]);
await Notification.destroy({
where: { id: notificationId, playerId: myId },
});
const myUpdated = await Player.findByPk(myId, {
include: [
{
model: Player,
as: "Friends",
attributes: ["id", "username", "level"],
},
],
});
socket.emit("friend:list", myUpdated.Friends || []);
const friendUpdated = await Player.findByPk(friendId, {
include: [
{
model: Player,
as: "Friends",
attributes: ["id", "username", "level"],
},
],
});
io.to(friendId).emit("friend:list", friendUpdated.Friends || []);
const unreadCount = await Notification.count({
where: { playerId: myId, isRead: false },
});
socket.emit("notifications:unread_count", unreadCount);
}
} catch (e) {
console.error(e);
socket.emit("error", { message: "FAILED_TO_ACCEPT_FRIEND" });
}
});
socket.on("friend:get_list", async () => {
try {
const player = await Player.findByPk(socket.user.id, {
include: [
{
model: Player,
as: "Friends",
attributes: ["id", "username", "level"],
},
],
});
socket.emit("friend:list", player.Friends || []);
} catch (e) {
console.error(e);
}
});
};

View File

@ -0,0 +1,51 @@
const Notification = require("../../models/Notification");
module.exports = (io, socket) => {
socket.on("notifications:get_all", async () => {
try {
const list = await Notification.findAll({
where: { playerId: socket.user.id },
order: [["createdAt", "DESC"]],
limit: 50,
});
socket.emit("notifications:list", list);
const unreadCount = list.filter((n) => !n.isRead).length;
socket.emit("notifications:unread_count", unreadCount);
} catch (e) {
console.error("Fetch notifications error:", e);
}
});
socket.on("notification:read", async ({ id }) => {
try {
await Notification.update(
{ isRead: true },
{ where: { id, playerId: socket.user.id } },
);
const unreadCount = await Notification.count({
where: { playerId: socket.user.id, isRead: false },
});
socket.emit("notifications:unread_count", unreadCount);
} catch (e) {
console.error("Read notification error:", e);
}
});
socket.on("notification:dismiss", async ({ id }) => {
try {
await Notification.destroy({
where: { id, playerId: socket.player.id },
});
const unreadCount = await Notification.count({
where: { playerId: socket.player.id, isRead: false },
});
socket.emit("notifications:unread_count", unreadCount);
} catch (e) {
console.error("Dismiss notification error:", e);
}
});
};

View File

@ -4,6 +4,9 @@ const inventoryHandler = require("./handlers/inventoryHandler");
const craftingHandler = require("./handlers/craftingHandler"); const craftingHandler = require("./handlers/craftingHandler");
const adminHandler = require("./handlers/adminHandler"); const adminHandler = require("./handlers/adminHandler");
const dungeonHandler = require("./handlers/dungeonHandler"); const dungeonHandler = require("./handlers/dungeonHandler");
const chatHandler = require("./handlers/chatHandler");
const friendHandler = require("./handlers/friendHandler");
const notificationHandler = require("./handlers/notificationHandler");
const initSockets = (io) => { const initSockets = (io) => {
io.use(socketAuth); io.use(socketAuth);
@ -14,6 +17,9 @@ const initSockets = (io) => {
craftingHandler(io, socket); craftingHandler(io, socket);
adminHandler(io, socket); adminHandler(io, socket);
dungeonHandler(io, socket); dungeonHandler(io, socket);
chatHandler(io, socket);
friendHandler(io, socket);
notificationHandler(io, socket);
}); });
}; };

19
package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "gso-project-manager",
"version": "1.0.0",
"scripts": {
"install-all": "cd api && npm install && cd ../game-server && npm install && cd ../client && npm install",
"dev": "npx concurrently \"npm run dev-api\" \"npm run dev-server\" \"npm run dev-client\"",
"dev-api": "cd api && npm run start",
"dev-server": "cd game-server && npm run start",
"dev-server-extras": "npx concurrently \"npm run dev-server-extra-proxima-centauri\" \"npm run dev-server-extra-ran\" \"npm run dev-server-extra-sol\" \"npm run dev-server-extra-tau-ceti\"",
"dev-server-extra-proxima-centauri": "cd game-server-proxima-centauri && npm run start",
"dev-server-extra-ran": "cd game-server-ran && npm run start",
"dev-server-extra-sol": "cd game-server-sol && npm run start",
"dev-server-extra-tau-ceti": "cd game-server-tau-ceti && npm run start",
"dev-client": "cd client && npm run dev"
},
"dependencies": {
"concurrently": "^8.2.2"
}
}

21
readme.md Normal file
View File

@ -0,0 +1,21 @@
# GSO Project
Multiplayer space-themed game built with React, Socket.io.
## ⚠️ Requirements
- **Node.js**: Version **22.x** or higher is strictly required (recommended for ESM support and optimal performance).
- **npm**: Included with Node.js.
> **Note**: You can check your currently installed version by running `node -v` in your terminal.
## 🚀 Getting Started
### 1. Unified Dependencies Installation
To automatically install all dependencies in the correct sub-projects (`api`, `game-server`, `client`) at once, run:
```bash
npm run install-all
npm run dev
```