Added Dungeon Manager.

This commit is contained in:
MaksSlyzar 2026-04-04 20:04:45 +03:00
parent 25090a5316
commit e74a209bb8
10 changed files with 294 additions and 125 deletions

View File

@ -1,5 +1,3 @@
import React from "react";
class GameDataManager {
constructor() {
this.items = new Map();
@ -7,6 +5,7 @@ class GameDataManager {
this.skills = new Map();
this.dungeons = new Map();
this.hostiles = new Map();
this.rooms = new Map();
this.translations = {};
this.manifest = {};
this.currentLang = localStorage.getItem("selected_lang") || "en_US";
@ -29,6 +28,9 @@ class GameDataManager {
if (Array.isArray(data.skills)) {
data.skills.forEach((s) => this.skills.set(s.id, s));
}
if (Array.isArray(data.rooms)) {
data.rooms.forEach((r) => this.rooms.set(r.id, r));
}
if (data.languages) {
this.translations = data.languages;
@ -122,6 +124,10 @@ class GameDataManager {
};
}
getEnemy(id) {
return this.getHostile(id);
}
getItem(id) {
const item = this.items.get(id);
@ -176,6 +182,16 @@ class GameDataManager {
};
}
getRoom(id) {
const room = this.rooms.get(id);
if (!room) return null;
return {
...room,
displayName: this.t(room.displayName),
description: this.t(room.description),
};
}
setLanguage(langCode) {
if (this.translations[langCode]) {
this.currentLang = langCode;

View File

@ -8,9 +8,9 @@
font-family: "Space Mono", monospace;
color: #e0e6ed;
box-sizing: border-box;
overflow: hidden;
}
/* HEADER */
.dungeon-header {
display: flex;
justify-content: space-between;
@ -18,6 +18,7 @@
border-bottom: 1px solid rgba(0, 212, 255, 0.3);
padding-bottom: 15px;
position: relative;
flex-shrink: 0;
}
.dungeon-header::after {
@ -73,19 +74,18 @@
animation: blink 1.5s infinite;
}
/* LAYOUT */
.battle-layout {
display: grid;
grid-template-columns: 1.2fr 1fr;
flex: 1;
gap: 30px;
min-height: 0; /* Важливо для overflow лога */
min-height: 0;
}
/* ENEMY CARD */
.enemy-display {
position: relative;
height: 100%;
min-height: 0;
}
.enemy-card {
@ -152,7 +152,6 @@
text-transform: uppercase;
}
/* HP BAR MINI */
.enemy-hp-container {
width: 60%;
margin-bottom: 30px;
@ -187,12 +186,14 @@
color: rgba(160, 172, 186, 0.7);
}
/* COMBAT LOG */
.combat-log-wrapper {
display: flex;
flex-direction: column;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(26, 38, 56, 0.8);
min-height: 0;
max-height: 100%;
overflow: hidden;
}
.log-header {
@ -203,6 +204,7 @@
letter-spacing: 1px;
color: #00d4ff;
border-bottom: 1px solid rgba(26, 38, 56, 0.8);
flex-shrink: 0;
}
.combat-log {
@ -215,12 +217,14 @@
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: #1a2638 transparent;
min-height: 0;
}
.log-entry {
line-height: 1.4;
color: #a0acba;
animation: slideIn 0.2s ease-out;
word-break: break-all;
}
.log-arrow {
@ -229,11 +233,11 @@
font-weight: bold;
}
/* CONTROLS */
.dungeon-controls {
display: flex;
gap: 20px;
height: 70px;
flex-shrink: 0;
}
.ctrl-btn {
@ -277,7 +281,6 @@
transform: scale(0.98);
}
/* UTILS */
.empty-room {
height: 100%;
display: flex;
@ -325,19 +328,17 @@
border-radius: 2px;
}
/* Вирівнювання футера картки */
.enemy-info-footer {
display: flex;
justify-content: space-between; /* Розносимо LVL та ID по боках */
width: 80%; /* Щоб не були впритул до країв */
margin-top: auto; /* Притискаємо до низу */
justify-content: space-between;
width: 80%;
margin-top: auto;
padding-bottom: 20px;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 1px;
}
/* Ефект трясіння при атаці */
.enemy-card.taking-damage {
animation: shake 0.2s ease-in-out;
border-color: #ffffff;
@ -362,7 +363,60 @@
}
}
/* Покращення читабельності лога */
.combat-log-wrapper {
min-width: 300px; /* Щоб лог не стискався занадто сильно */
min-width: 300px;
}
.environment-panel {
background: rgba(0, 20, 40, 0.6);
border: 1px solid rgba(0, 255, 255, 0.2);
margin-bottom: 15px;
padding: 12px;
display: flex;
flex-direction: column;
position: relative;
flex-shrink: 0;
}
.env-header {
font-size: 0.65rem;
color: #00ffff;
opacity: 0.7;
letter-spacing: 1.5px;
margin-bottom: 8px;
}
.env-info {
display: flex;
align-items: center;
gap: 12px;
}
.env-icon {
width: 36px;
height: 36px;
background: rgba(0, 255, 255, 0.1);
border: 1px solid rgba(0, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
color: #00ffff;
font-size: 1.1rem;
}
.env-details .env-id {
font-family: "Orbitron", sans-serif;
font-size: 0.8rem;
color: #fff;
}
.env-details .env-type {
font-size: 0.6rem;
color: #888;
margin-top: 2px;
}
.card-id {
opacity: 0.4;
font-size: 0.6rem;
}

View File

@ -4,6 +4,7 @@ import "./DungeonScreen.css";
const DungeonScreen = ({ session, socket }) => {
const [roomData, setRoomData] = useState(session.room);
const [hostiles, setHostiles] = useState(session.hostiles || []);
const [roomIndex, setRoomIndex] = useState(session.roomIndex);
const [enemyHp, setEnemyHp] = useState(null);
const [isEnemyDefeated, setIsEnemyDefeated] = useState(false);
@ -14,31 +15,43 @@ const DungeonScreen = ({ session, socket }) => {
const logEndRef = useRef(null);
// Отримуємо дані про ворога та данж через GameDataManager
const rawEnemyId = roomData?.hostiles?.[0] || null;
const enemyData = rawEnemyId ? GameDataManager.getEnemy(rawEnemyId) : null;
const currentEnemy = hostiles.length > 0 ? hostiles[0] : null;
const dungeonData = GameDataManager.getDungeon(session.dungeonId);
// Авто-скрол лога до останнього запису
const getEnemyDisplayName = (enemy) => {
if (!enemy) return "UNKNOWN_ENTITY";
const data = GameDataManager.getEnemy(enemy.id);
return data?.displayName || enemy.displayName || enemy.id;
};
const getRoomDisplayName = (room) => {
if (!room) return "UNKNOWN_LOCATION";
const data = GameDataManager.getRoom(room.id);
return data?.displayName || room.displayName || room.id;
};
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [log]);
useEffect(() => {
// Слухаємо оновлення кімнати (перехід на наступну)
socket.on("dungeon:room_update", (data) => {
setRoomData(data.room);
setHostiles(data.hostiles || []);
setRoomIndex(data.roomIndex);
setIsEnemyDefeated(false);
setIsLooted(false);
setEnemyHp(null); // Скидаємо HP для нового ворога
setEnemyHp(null);
addLog(`--- ENTERING SECTOR ${data.roomIndex + 1} ---`);
});
// Слухаємо результати бою від сервера
socket.on("dungeon:combat_result", (data) => {
if (data.message) addLog(data.message);
if (data.enemyHp !== undefined) setEnemyHp(data.enemyHp);
if (data.enemyHp !== undefined) {
const maxHp = currentEnemy?.stats?.health || 100;
const hpPercent = (data.enemyHp / maxHp) * 100;
setEnemyHp(hpPercent);
}
if (data.targetDefeated) {
setIsEnemyDefeated(true);
addLog("TARGET_NEUTRALIZED: Threat eliminated.");
@ -49,7 +62,7 @@ const DungeonScreen = ({ session, socket }) => {
socket.off("dungeon:room_update");
socket.off("dungeon:combat_result");
};
}, [socket]);
}, [socket, currentEnemy]);
const addLog = (text) => {
const time = new Date().toLocaleTimeString([], {
@ -62,15 +75,12 @@ const DungeonScreen = ({ session, socket }) => {
};
const handleCombat = () => {
if (isEnemyDefeated) return;
socket.emit("dungeon:combat_step", { enemyId: rawEnemyId });
addLog(
`Initiating strike sequence on ${enemyData?.displayName || "Target"}...`,
);
if (isEnemyDefeated || !currentEnemy) return;
socket.emit("dungeon:combat_step", { enemyId: currentEnemy.id });
addLog(`Initiating strike sequence...`);
};
const handleLoot = () => {
socket.emit("dungeon:get_loot");
setIsLooted(true);
addLog("Loot encryption bypassed. Resources transferred.");
};
@ -103,11 +113,34 @@ const DungeonScreen = ({ session, socket }) => {
</div>
</div>
<div className="environment-panel">
<div className="env-header">ENVIRONMENT_SCAN</div>
<div className="env-info">
<div className="env-icon">
<i
className={`fas ${
roomData?.id?.includes("boss")
? "fa-skull"
: roomData?.id?.includes("loot")
? "fa-box-open"
: "fa-microchip"
}`}
></i>
</div>
<div className="env-details">
<div className="env-id">{getRoomDisplayName(roomData)}</div>
<div className="env-type">
TYPE: {hostiles.length > 0 ? "COMBAT_ZONE" : "SECURE_AREA"}
</div>
</div>
</div>
</div>
<div className="battle-layout">
<div
className={`enemy-display ${isEnemyDefeated ? "target-lost" : ""}`}
>
{enemyData ? (
{currentEnemy ? (
<div className={`enemy-card ${isEnemyDefeated ? "defeated" : ""}`}>
<div className="threat-tag">
{isEnemyDefeated ? "SIGNAL_LOST" : "HOSTILE_DETECTED"}
@ -117,7 +150,9 @@ const DungeonScreen = ({ session, socket }) => {
className={`fas ${isEnemyDefeated ? "fa-skull-crossbones" : "fa-robot"}`}
></i>
</div>
<h3 className="enemy-name">{enemyData.displayName}</h3>
<h3 className="enemy-name">
{getEnemyDisplayName(currentEnemy)}
</h3>
<div className="enemy-hp-container">
<div className="hp-label">STRUCTURE INTEGRITY</div>
<div className="hp-bar-mini">
@ -132,8 +167,8 @@ const DungeonScreen = ({ session, socket }) => {
</div>
</div>
<div className="enemy-info-footer">
<span>LVL: {enemyData.level || 1}</span>
<span>ID: {GameDataManager._cleanId(rawEnemyId)}</span>
<span>LVL: {currentEnemy.level || 1}</span>
<span className="card-id">{currentEnemy.id}</span>
</div>
</div>
) : (
@ -158,7 +193,7 @@ const DungeonScreen = ({ session, socket }) => {
</div>
<div className="dungeon-controls">
{!isEnemyDefeated && enemyData && (
{!isEnemyDefeated && currentEnemy && (
<button className="ctrl-btn combat" onClick={handleCombat}>
<i className="fas fa-bolt"></i> ENGAGE
</button>
@ -170,7 +205,7 @@ const DungeonScreen = ({ session, socket }) => {
</button>
)}
{(isLooted || !enemyData) && (
{(isLooted || !currentEnemy) && (
<button className="ctrl-btn next" onClick={handleNextRoom}>
PROCEED <i className="fas fa-chevron-right"></i>
</button>

View File

@ -1,6 +1,6 @@
{
"rooms": {
"id": "original:pirate_ambush_zone",
"id": "original:pirate/pirate_ambush_zone",
"hostiles": [
"original:pirates/scout_drone",
"original:pirates/raider_frigate",

View File

@ -1,6 +1,6 @@
{
"rooms": {
"id": "original:pirate_boss_bridge",
"id": "original:pirate/pirate_boss_bridge",
"hostiles": ["original:pirates/black_mark_cruiser"],
"gainXp": 100,
"credits": 2500

View File

@ -1,6 +1,6 @@
{
"rooms": {
"id": "original:pirate_patrol_room",
"id": "original:pirate/pirate_patrol_room",
"hostiles": [
"original:pirates/scout_drone",
"original:pirates/scout_drone"

View File

@ -1,6 +1,6 @@
{
"rooms": {
"id": "original:pirate_supply_bay",
"id": "original:pirate/pirate_supply_bay",
"hostiles": [],
"gainXp": 5,
"credits": 800,

View File

@ -1,6 +1,6 @@
{
"rooms": {
"id": "original:tutorial/tutorial_boss",
"id": "original:tutorial/tutorial_boss_room",
"hostiles": ["original:tutorial/tutorial_boss_hostile"],
"gainXp": 4,
"credits": 200

View File

@ -0,0 +1,86 @@
const DatapackLoader = require("./DatapackLoader");
class DungeonManager {
constructor() {
this.activeSessions = new Map();
}
startDungeon(playerId, dungeonId) {
const dungeon = DatapackLoader.getDungeon(dungeonId);
if (!dungeon || !dungeon.rooms?.length) return null;
const session = {
dungeonId,
currentRoomIndex: 0,
isFinished: false,
currentEnemyHp: undefined,
rewards: { xp: 0, credits: 0, items: [] },
};
this.activeSessions.set(playerId, session);
return this.getCurrentRoomData(playerId);
}
getCurrentRoomData(playerId) {
const session = this.activeSessions.get(playerId);
if (!session) return null;
const dungeon = DatapackLoader.getDungeon(session.dungeonId);
const roomRef = dungeon.rooms[session.currentRoomIndex];
const roomData = DatapackLoader.getRoom(roomRef.id);
if (!roomData) return null;
const hostiles = (roomData.hostiles || [])
.map((hId) => {
const hostile = DatapackLoader.getEnemy(hId);
return hostile ? { ...hostile } : null;
})
.filter(Boolean);
return {
roomIndex: session.currentRoomIndex,
totalRooms: dungeon.rooms.length,
config: roomData,
hostiles,
};
}
moveToNextRoom(playerId) {
const session = this.activeSessions.get(playerId);
if (!session || session.isFinished) return null;
const dungeon = DatapackLoader.getDungeon(session.dungeonId);
const roomRef = dungeon.rooms[session.currentRoomIndex];
const currentRoom = DatapackLoader.getRoom(roomRef.id);
if (currentRoom) {
session.rewards.xp += currentRoom.gainXp || 0;
session.rewards.credits += currentRoom.credits || 0;
if (currentRoom.loot && Array.isArray(currentRoom.loot)) {
currentRoom.loot.forEach((item) => {
session.rewards.items.push({ ...item });
});
}
}
session.currentEnemyHp = undefined;
if (session.currentRoomIndex < dungeon.rooms.length - 1) {
session.currentRoomIndex++;
return this.getCurrentRoomData(playerId);
}
session.isFinished = true;
return {
status: "completed",
rewards: session.rewards,
};
}
leaveDungeon(playerId) {
this.activeSessions.delete(playerId);
}
}
module.exports = new DungeonManager();

View File

@ -1,6 +1,6 @@
const { Player, Inventory } = require("../../models");
const sessionManager = require("../../game/SessionManager");
const DatapackLoader = require("../../game/DatapackLoader");
const dungeonManager = require("../../game/DungeonManager");
module.exports = (io, socket) => {
const userId = socket.user?.id;
@ -8,80 +8,71 @@ module.exports = (io, socket) => {
socket.on("dungeon:start", async ({ dungeonId }) => {
try {
if (!userId) return;
const dungeon = DatapackLoader.getDungeon(dungeonId);
if (!dungeon) {
return socket.emit("error", { message: "Dungeon coordinates invalid" });
return socket.emit("error", { message: "Dungeon not found" });
}
const player = await Player.findByPk(userId);
if (player.energy < dungeon.energyCost) {
return socket.emit("error", {
message: "Insufficient energy for deployment",
});
const energyCost = dungeon.meta?.energyCost || 0;
if (player.energy < energyCost) {
return socket.emit("error", { message: "Insufficient energy" });
}
await player.decrement("energy", { by: dungeon.energyCost });
await player.decrement("energy", { by: energyCost });
const dungeonSession = {
dungeonId: dungeon.id,
currentRoomIndex: 0,
isCompleted: false,
startTime: Date.now(),
rewards: [],
};
sessionManager.setPlayerScene(socket.id, "dungeon", dungeonSession);
const firstRoomId = dungeon.rooms[0].id;
const firstRoomData = DatapackLoader.getRoom(firstRoomId);
const firstRoom = dungeonManager.startDungeon(userId, dungeonId);
if (!firstRoom) {
return socket.emit("error", {
message: "Failed to initialize dungeon",
});
}
socket.emit("dungeon:started", {
dungeonId: dungeon.id,
room: firstRoomData,
roomIndex: 0,
totalRooms: dungeon.rooms.length,
remainingEnergy: player.energy - dungeon.energyCost,
room: firstRoom.config,
hostiles: firstRoom.hostiles,
roomIndex: firstRoom.roomIndex,
totalRooms: firstRoom.totalRooms,
remainingEnergy: player.energy - energyCost,
});
console.log(`[Dungeon] Player ${userId} started ${dungeonId}`);
} catch (err) {
console.error("Dungeon Start Error:", err);
socket.emit("error", { message: "Failed to initiate deployment" });
socket.emit("error", { message: "Critical deployment failure" });
}
});
socket.on("dungeon:combat_step", async (data) => {
socket.on("dungeon:combat_step", async ({ enemyId }) => {
try {
const session = sessionManager.getPlayerSession(socket.id);
if (!session || session.scene !== "dungeon" || !session.sceneData) return;
if (!userId) return;
const session = dungeonManager.activeSessions.get(userId);
if (!session || session.isFinished) return;
const dungeonSession = session.sceneData;
const enemyId = sessionManager._cleanId(data.enemyId);
const enemyTemplate = DatapackLoader.getEnemy(enemyId);
if (!enemyTemplate) {
return socket.emit("error", { message: "Target data corrupted" });
}
if (dungeonSession.currentEnemyHp === undefined) {
dungeonSession.currentEnemyHp = enemyTemplate.stats?.hp || 100;
if (session.currentEnemyHp === undefined) {
session.currentEnemyHp = enemyTemplate.stats?.health || 100;
}
const damage = Math.floor(Math.random() * 11) + 15;
dungeonSession.currentEnemyHp -= damage;
const damage = Math.floor(Math.random() * 10) + 20;
session.currentEnemyHp -= damage;
const isDefeated = dungeonSession.currentEnemyHp <= 0;
const isDefeated = session.currentEnemyHp <= 0;
socket.emit("dungeon:combat_result", {
damageDealt: damage,
enemyHp: Math.max(0, dungeonSession.currentEnemyHp),
enemyHp: Math.max(0, session.currentEnemyHp),
targetDefeated: isDefeated,
message: `Strike successful. Dealt ${damage} damage to units.`,
message: `Strike successful. Dealt ${damage} damage.`,
});
if (isDefeated) {
console.log(`[Dungeon] Enemy ${enemyId} defeated by ${session.id}`);
session.currentEnemyHp = undefined;
}
} catch (err) {
console.error("Combat Error:", err);
@ -90,62 +81,49 @@ module.exports = (io, socket) => {
socket.on("dungeon:next_room", async () => {
try {
const session = sessionManager.getPlayerSession(socket.id);
if (!userId) return;
if (!session || session.scene !== "dungeon" || !session.sceneData) {
console.error(
`[Dungeon Error] Invalid session for socket ${socket.id}`,
);
return;
const nextRoom = dungeonManager.moveToNextRoom(userId);
if (!nextRoom) {
return socket.emit("error", {
message: "Could not proceed to next room",
});
}
const dungeonId = session.sceneData.dungeonId; // ТУТ була помилка (читання з undefined)
const dungeon = DatapackLoader.getDungeon(dungeonId);
const nextIndex = session.sceneData.currentRoomIndex + 1;
if (nextIndex < dungeon.rooms.length) {
session.sceneData.currentRoomIndex = nextIndex;
const nextRoomId = dungeon.rooms[nextIndex].id;
const nextRoomData = DatapackLoader.getRoom(nextRoomId);
socket.emit("dungeon:room_update", {
room: nextRoomData,
roomIndex: nextIndex,
});
if (nextRoom.status === "completed") {
await finalizeDungeon(socket, nextRoom.rewards);
} else {
await finalizeDungeon(socket, session.sceneData, dungeon);
socket.emit("dungeon:room_update", {
room: nextRoom.config,
hostiles: nextRoom.hostiles,
roomIndex: nextRoom.roomIndex,
});
}
} catch (err) {
console.error("Dungeon Progress Error:", err);
socket.emit("error", { message: "Failed to process next room" });
socket.emit("error", { message: "Navigation system error" });
}
});
socket.on("dungeon:leave", () => {
if (userId) dungeonManager.leaveDungeon(userId);
});
};
async function finalizeDungeon(socket, sessionData, dungeon) {
async function finalizeDungeon(socket, sessionRewards) {
const userId = socket.user.id;
const earnedLoot = [];
try {
const player = await Player.findByPk(userId);
dungeon.lootTable.forEach((entry) => {
if (Math.random() * 100 <= entry.chance) {
earnedLoot.push({ itemId: entry.itemId, quantity: 1 });
if (sessionRewards.credits > 0) {
await player.increment("credits", { by: sessionRewards.credits });
}
});
for (const item of earnedLoot) {
const [invItem, created] = await Inventory.findOrCreate({
where: { playerId: userId, itemId: item.itemId },
defaults: { quantity: 0 },
});
await invItem.increment("quantity", { by: item.quantity });
}
sessionManager.setPlayerScene(socket.id, "world", null);
socket.emit("dungeon:completed", {
rewards: earnedLoot,
rewards: sessionRewards,
message: "Mission successful. All objectives secured.",
});
} finally {
dungeonManager.leaveDungeon(userId);
}
}