Merge branch 'main' of https://github.com/Korvarix/Galaxy-Strike-Online
This commit is contained in:
commit
7257b1cd2f
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 228 KiB |
@ -6,6 +6,7 @@ class GameDataManager {
|
|||||||
this.dungeons = new Map();
|
this.dungeons = new Map();
|
||||||
this.hostiles = new Map();
|
this.hostiles = new Map();
|
||||||
this.rooms = new Map();
|
this.rooms = new Map();
|
||||||
|
this.quests = 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";
|
||||||
@ -31,10 +32,14 @@ class GameDataManager {
|
|||||||
if (Array.isArray(data.rooms)) {
|
if (Array.isArray(data.rooms)) {
|
||||||
data.rooms.forEach((r) => this.rooms.set(r.id, r));
|
data.rooms.forEach((r) => this.rooms.set(r.id, r));
|
||||||
}
|
}
|
||||||
|
if (Array.isArray(data.quests)) {
|
||||||
|
data.quests.forEach((q) => this.quests.set(q.id, q));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(this.quests);
|
||||||
if (data.languages) {
|
if (data.languages) {
|
||||||
this.translations = data.languages;
|
this.translations = data.languages;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.manifest) {
|
if (data.manifest) {
|
||||||
this.manifest = data.manifest;
|
this.manifest = data.manifest;
|
||||||
}
|
}
|
||||||
@ -191,6 +196,24 @@ class GameDataManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getQuest(id) {
|
||||||
|
const quest = this.quests.get(id);
|
||||||
|
if (!quest) return null;
|
||||||
|
return {
|
||||||
|
...quest,
|
||||||
|
displayName: this.t(quest.displayName),
|
||||||
|
description: this.t(quest.description),
|
||||||
|
objectives: (quest.objectives || []).map((obj) => ({
|
||||||
|
...obj,
|
||||||
|
description: obj.description ? this.t(obj.description) : "",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllQuests() {
|
||||||
|
return Array.from(this.quests.values()).map((q) => this.getQuest(q.id));
|
||||||
|
}
|
||||||
|
|
||||||
setLanguage(langCode) {
|
setLanguage(langCode) {
|
||||||
if (this.translations[langCode]) {
|
if (this.translations[langCode]) {
|
||||||
this.currentLang = langCode;
|
this.currentLang = langCode;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -5,68 +5,89 @@ import DungeonFinish from "../tabs/components/DungeonFinish.jsx";
|
|||||||
|
|
||||||
const DungeonScreen = ({ session, socket }) => {
|
const DungeonScreen = ({ session, socket }) => {
|
||||||
const [roomData, setRoomData] = useState(session.room);
|
const [roomData, setRoomData] = useState(session.room);
|
||||||
const [hostiles, setHostiles] = useState(session.hostiles || []);
|
|
||||||
const [roomIndex, setRoomIndex] = useState(session.roomIndex);
|
const [roomIndex, setRoomIndex] = useState(session.roomIndex);
|
||||||
const [enemyHp, setEnemyHp] = useState(null);
|
const [battle, setBattle] = useState(session.battle || null);
|
||||||
const [isEnemyDefeated, setIsEnemyDefeated] = useState(false);
|
const [timeLeft, setTimeLeft] = useState(10);
|
||||||
const [isLooted, setIsLooted] = useState(false);
|
|
||||||
const [summary, setSummary] = useState(null);
|
const [summary, setSummary] = useState(null);
|
||||||
|
const [activeAttacker, setActiveAttacker] = useState(null);
|
||||||
|
|
||||||
|
const [selectedTarget, setSelectedTarget] = useState(null);
|
||||||
|
|
||||||
const [log, setLog] = useState([
|
const [log, setLog] = useState([
|
||||||
"SYSTEM: Neural link established. Scanning sector...",
|
"SYSTEM: Neural link established. Scanning sector...",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const logEndRef = useRef(null);
|
const logEndRef = useRef(null);
|
||||||
|
const timerRef = useRef(null);
|
||||||
const currentEnemy = hostiles.length > 0 ? hostiles[0] : null;
|
|
||||||
const dungeonData = GameDataManager.getDungeon(session.dungeonId);
|
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(() => {
|
useEffect(() => {
|
||||||
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
}, [log]);
|
}, [log]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedTarget(null);
|
||||||
|
}, [battle?.currentTurnIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!battle || battle.isOver || activeAttacker) return;
|
||||||
|
|
||||||
|
const isPlayer = battle.turnOrder[battle.currentTurnIndex] === "player";
|
||||||
|
const maxTime = isPlayer ? 10 : 4;
|
||||||
|
setTimeLeft(maxTime);
|
||||||
|
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setTimeLeft((prev) => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
if (isPlayer) handleCombatAction();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(timerRef.current);
|
||||||
|
}, [battle?.currentTurnIndex, battle?.isOver, activeAttacker]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
socket.on("dungeon:room_update", (data) => {
|
socket.on("dungeon:room_update", (data) => {
|
||||||
setRoomData(data.room);
|
setRoomData(data.room);
|
||||||
setHostiles(data.hostiles || []);
|
|
||||||
setRoomIndex(data.roomIndex);
|
setRoomIndex(data.roomIndex);
|
||||||
setIsEnemyDefeated(false);
|
setBattle(data.battle);
|
||||||
setIsLooted(false);
|
|
||||||
setEnemyHp(null);
|
|
||||||
addLog(`--- ENTERING SECTOR ${data.roomIndex + 1} ---`);
|
addLog(`--- ENTERING SECTOR ${data.roomIndex + 1} ---`);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("dungeon:combat_result", (data) => {
|
socket.on("dungeon:failed", (data) => {
|
||||||
if (data.message) addLog(data.message);
|
addLog(`--- TERMINAL ERROR: ${data.message} ---`);
|
||||||
|
setTimeout(() => window.location.reload(), 3000);
|
||||||
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.");
|
|
||||||
|
|
||||||
if (data.loot && data.loot.length > 0) {
|
|
||||||
addLog("SCANNING FOR DROPPED ASSETS...");
|
|
||||||
data.loot.forEach((item) => {
|
|
||||||
const itemData = GameDataManager.getItem(item.id);
|
|
||||||
const itemName = itemData?.displayName || item.id;
|
|
||||||
addLog(`RECOVERED: ${itemName} x${item.count}`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on("dungeon:battle_update", async (data) => {
|
||||||
|
const turnOrder = data.battle.turnOrder;
|
||||||
|
const lastIndex =
|
||||||
|
(data.battle.currentTurnIndex - 1 + turnOrder.length) %
|
||||||
|
turnOrder.length;
|
||||||
|
const lastActorId = turnOrder[lastIndex];
|
||||||
|
|
||||||
|
if (lastActorId !== "player" && !data.battle.isOver) {
|
||||||
|
setActiveAttacker(lastActorId);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
setBattle(data.battle);
|
||||||
|
if (data.log) data.log.forEach((msg) => addLog(msg));
|
||||||
|
setActiveAttacker(null);
|
||||||
|
} else {
|
||||||
|
setBattle(data.battle);
|
||||||
|
if (data.log) data.log.forEach((msg) => addLog(msg));
|
||||||
|
setActiveAttacker(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.status === "victory")
|
||||||
|
addLog("MISSION_OBJECTIVE: Threats neutralized.");
|
||||||
|
if (data.status === "defeat") {
|
||||||
|
addLog("CRITICAL_ERROR: Bio-sign lost.");
|
||||||
|
setTimeout(() => window.location.reload(), 3000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -77,10 +98,11 @@ const DungeonScreen = ({ session, socket }) => {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off("dungeon:room_update");
|
socket.off("dungeon:room_update");
|
||||||
socket.off("dungeon:combat_result");
|
socket.off("dungeon:battle_update");
|
||||||
socket.off("dungeon:completed");
|
socket.off("dungeon:completed");
|
||||||
|
socket.off("dungeon:failed");
|
||||||
};
|
};
|
||||||
}, [socket, currentEnemy]);
|
}, [socket]);
|
||||||
|
|
||||||
const addLog = (text) => {
|
const addLog = (text) => {
|
||||||
const time = new Date().toLocaleTimeString([], {
|
const time = new Date().toLocaleTimeString([], {
|
||||||
@ -92,21 +114,22 @@ const DungeonScreen = ({ session, socket }) => {
|
|||||||
setLog((prev) => [...prev, `[${time}] ${text}`]);
|
setLog((prev) => [...prev, `[${time}] ${text}`]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCombat = () => {
|
const handleCombatAction = () => {
|
||||||
if (isEnemyDefeated || !currentEnemy) return;
|
const targetId = selectedTarget;
|
||||||
socket.emit("dungeon:combat_step", { enemyId: currentEnemy.id });
|
if (!battle || battle.isOver || activeAttacker || !targetId) return;
|
||||||
addLog(`Initiating strike sequence...`);
|
if (battle.turnOrder[battle.currentTurnIndex] !== "player") return;
|
||||||
};
|
|
||||||
|
|
||||||
const handleLoot = () => {
|
socket.emit("dungeon:combat_action", { targetInstanceId: targetId });
|
||||||
setIsLooted(true);
|
addLog(`Initiating strike sequence...`);
|
||||||
addLog("Loot encryption bypassed. Resources transferred.");
|
setSelectedTarget(null); // Скидаємо вибір після атаки
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNextRoom = () => {
|
const handleNextRoom = () => {
|
||||||
socket.emit("dungeon:next_room");
|
socket.emit("dungeon:next_room");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isPlayerTurn = battle?.turnOrder[battle?.currentTurnIndex] === "player";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dungeon-active-screen">
|
<div className="dungeon-active-screen">
|
||||||
{summary && (
|
{summary && (
|
||||||
@ -116,6 +139,7 @@ const DungeonScreen = ({ session, socket }) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Header section remains the same */}
|
||||||
<div className="dungeon-header">
|
<div className="dungeon-header">
|
||||||
<div className="room-progress">
|
<div className="room-progress">
|
||||||
<div className="progress-text">
|
<div className="progress-text">
|
||||||
@ -130,82 +154,102 @@ const DungeonScreen = ({ session, socket }) => {
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="dungeon-title-area">
|
|
||||||
<div className="dungeon-name">
|
|
||||||
{dungeonData?.displayName || "MISSION_ACTIVE"}
|
|
||||||
</div>
|
|
||||||
<div className="dungeon-status-tag">LIVE_FEED</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="environment-panel">
|
{battle && (
|
||||||
<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
|
<div
|
||||||
className={`enemy-display ${isEnemyDefeated ? "target-lost" : ""}`}
|
className={`turn-progress-container ${isPlayerTurn ? "player-phase" : "enemy-phase"}`}
|
||||||
>
|
>
|
||||||
{currentEnemy ? (
|
<div className="turn-label">
|
||||||
<div className={`enemy-card ${isEnemyDefeated ? "defeated" : ""}`}>
|
{isPlayerTurn ? "YOUR TURN" : "ENEMY ACTION"}
|
||||||
<div className="threat-tag">
|
|
||||||
{isEnemyDefeated ? "SIGNAL_LOST" : "HOSTILE_DETECTED"}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="enemy-icon">
|
<div className="turn-timer-bar">
|
||||||
<i
|
|
||||||
className={`fas ${isEnemyDefeated ? "fa-skull-crossbones" : "fa-robot"}`}
|
|
||||||
></i>
|
|
||||||
</div>
|
|
||||||
<h3 className="enemy-name">
|
|
||||||
{getEnemyDisplayName(currentEnemy)}
|
|
||||||
</h3>
|
|
||||||
<div className="enemy-hp-container">
|
|
||||||
<div className="hp-label">STRUCTURE INTEGRITY</div>
|
|
||||||
<div className="hp-bar-mini">
|
|
||||||
<div
|
<div
|
||||||
className="hp-fill-mini"
|
className="turn-timer-fill"
|
||||||
style={{
|
style={{
|
||||||
width: isEnemyDefeated
|
width: `${(timeLeft / (isPlayerTurn ? 10 : 4)) * 100}%`,
|
||||||
? "0%"
|
transition:
|
||||||
: `${enemyHp !== null ? enemyHp : 100}%`,
|
timeLeft === 10 || timeLeft === 4
|
||||||
|
? "none"
|
||||||
|
: "width 1s linear",
|
||||||
}}
|
}}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="enemy-info-footer">
|
)}
|
||||||
<span>LVL: {currentEnemy.level || 1}</span>
|
|
||||||
<span className="card-id">{currentEnemy.id}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="battle-arena">
|
||||||
|
{battle ? (
|
||||||
|
<div className="mobs-grid">
|
||||||
|
{battle.enemies.map((mob) => (
|
||||||
|
<div
|
||||||
|
key={mob.instanceId}
|
||||||
|
className={`enemy-card
|
||||||
|
${mob.isDead ? "defeated" : ""}
|
||||||
|
${selectedTarget === mob.instanceId ? "selected" : ""}
|
||||||
|
${isPlayerTurn && !mob.isDead ? "selectable" : ""}
|
||||||
|
${activeAttacker === mob.instanceId ? "attacking" : ""}
|
||||||
|
`}
|
||||||
|
onClick={() =>
|
||||||
|
!mob.isDead &&
|
||||||
|
isPlayerTurn &&
|
||||||
|
setSelectedTarget(mob.instanceId)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedTarget === mob.instanceId && (
|
||||||
|
<div className="target-aim">
|
||||||
|
<i className="fas fa-crosshairs"></i>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="enemy-hp-mini">
|
||||||
|
<div
|
||||||
|
className="fill"
|
||||||
|
style={{ width: `${(mob.hp / mob.maxHp) * 100}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div className="enemy-icon">
|
||||||
|
<i
|
||||||
|
className={`fas ${mob.isDead ? "fa-skull-crossbones" : "fa-robot"}`}
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
<span className="mob-name">{GameDataManager.t(mob.name)}</span>
|
||||||
|
{!mob.isDead && <span className="mob-atk">ATK: {mob.atk}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="empty-room">
|
<div className="empty-room">
|
||||||
<i className="fas fa-satellite-dish"></i>
|
<i className="fas fa-satellite-dish"></i>
|
||||||
<p>NO HOSTILES IN RANGE</p>
|
<p>AREA SECURE</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="player-section">
|
||||||
|
{battle && (
|
||||||
|
<div
|
||||||
|
className={`player-hp-main ${activeAttacker ? "taking-damage" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="hp-header">
|
||||||
|
<span>COMMANDER_INTEGRITY</span>
|
||||||
|
<span>
|
||||||
|
{battle.player.hp} / {battle.player.maxHp}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="hp-bar-large">
|
||||||
|
<div
|
||||||
|
className="fill"
|
||||||
|
style={{
|
||||||
|
width: `${(battle.player.hp / battle.player.maxHp) * 100}%`,
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="combat-interface-row">
|
||||||
<div className="combat-log-wrapper">
|
<div className="combat-log-wrapper">
|
||||||
<div className="log-header">COMBAT_LOG_V3.0</div>
|
|
||||||
<div className="combat-log custom-scroll">
|
<div className="combat-log custom-scroll">
|
||||||
{log.map((entry, i) => (
|
{log.map((entry, i) => (
|
||||||
<div key={i} className="log-entry">
|
<div key={i} className="log-entry">
|
||||||
@ -215,22 +259,24 @@ const DungeonScreen = ({ session, socket }) => {
|
|||||||
<div ref={logEndRef} />
|
<div ref={logEndRef} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{battle && !battle.isOver && (
|
||||||
|
<button
|
||||||
|
className={`btn-execute-combat ${!selectedTarget || !isPlayerTurn ? "disabled" : ""}`}
|
||||||
|
disabled={!selectedTarget || !isPlayerTurn}
|
||||||
|
onClick={handleCombatAction}
|
||||||
|
>
|
||||||
|
<div className="btn-glitch-content">EXECUTE_STRIKE</div>
|
||||||
|
<div className="btn-sub-text">
|
||||||
|
{selectedTarget ? "TARGET_LOCKED" : "SELECT_TARGET"}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dungeon-controls">
|
<div className="dungeon-controls">
|
||||||
{!isEnemyDefeated && currentEnemy && (
|
{((battle?.isOver && battle.player.hp > 0) || !battle) && (
|
||||||
<button className="ctrl-btn combat" onClick={handleCombat}>
|
|
||||||
<i className="fas fa-bolt"></i> ENGAGE
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isEnemyDefeated && !isLooted && (
|
|
||||||
<button className="ctrl-btn loot" onClick={handleLoot}>
|
|
||||||
<i className="fas fa-download"></i> COLLECT_ASSETS
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(isLooted || !currentEnemy) && (
|
|
||||||
<button className="ctrl-btn next" onClick={handleNextRoom}>
|
<button className="ctrl-btn next" onClick={handleNextRoom}>
|
||||||
PROCEED <i className="fas fa-chevron-right"></i>
|
PROCEED <i className="fas fa-chevron-right"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
|
margin-right: 10px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: #4a5d75;
|
color: #4a5d75;
|
||||||
|
|||||||
@ -11,6 +11,7 @@ const Navigation = ({ activeTab, onTabChange }) => {
|
|||||||
{ id: "dashboard", icon: "fa-tachometer-alt" },
|
{ id: "dashboard", icon: "fa-tachometer-alt" },
|
||||||
{ id: "dungeons", icon: "fa-dungeon" },
|
{ id: "dungeons", icon: "fa-dungeon" },
|
||||||
{ id: "skills", icon: "fa-graduation-cap" },
|
{ id: "skills", icon: "fa-graduation-cap" },
|
||||||
|
{ id: "quests", icon: "fa-store" },
|
||||||
{ id: "inventory", icon: "fa-archive" },
|
{ id: "inventory", icon: "fa-archive" },
|
||||||
{ id: "shop", icon: "fa-store" },
|
{ id: "shop", icon: "fa-store" },
|
||||||
{ id: "crafting", icon: "fa-hammer" },
|
{ id: "crafting", icon: "fa-hammer" },
|
||||||
|
|||||||
@ -1,34 +1,172 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState, useMemo } from "react";
|
||||||
|
import Card from "../../../components/ui/Card";
|
||||||
|
import { useSocket } from "../../../hooks/useSocket";
|
||||||
|
import gameDataManager from "../../../services/GameDataManager";
|
||||||
import "./styles/QuestsTab.css";
|
import "./styles/QuestsTab.css";
|
||||||
|
|
||||||
const QuestsTab = () => {
|
const QuestsTab = () => {
|
||||||
const [type, setType] = useState('main');
|
const { socket } = useSocket();
|
||||||
|
const [quests, setQuests] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [activeTab, setActiveTab] = useState("ACTIVE");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
socket.emit("quest:get_list");
|
||||||
|
|
||||||
|
const localize = (q) => {
|
||||||
|
const staticData = gameDataManager.getQuest(q.id);
|
||||||
|
return {
|
||||||
|
...q,
|
||||||
|
displayName: staticData?.displayName || q.id,
|
||||||
|
description: staticData?.description || "",
|
||||||
|
objectives: q.objectives.map((obj, idx) => ({
|
||||||
|
...obj,
|
||||||
|
description: staticData?.objectives[idx]?.description || obj.type,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuestData = (data) => {
|
||||||
|
const uniqueQuests = new Map();
|
||||||
|
data.forEach((q) => {
|
||||||
|
uniqueQuests.set(q.id, localize(q));
|
||||||
|
});
|
||||||
|
|
||||||
|
setQuests(Array.from(uniqueQuests.values()));
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuestUpdate = (updatedQuest) => {
|
||||||
|
setQuests((prev) => {
|
||||||
|
const localized = localize(updatedQuest);
|
||||||
|
const questMap = new Map(prev.map((q) => [q.id, q]));
|
||||||
|
questMap.set(localized.id, localized);
|
||||||
|
return Array.from(questMap.values());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on("quest:list_data", handleQuestData);
|
||||||
|
socket.on("quest:update", handleQuestUpdate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off("quest:list_data", handleQuestData);
|
||||||
|
socket.off("quest:update", handleQuestUpdate);
|
||||||
|
};
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
|
const filteredQuests = useMemo(() => {
|
||||||
|
return quests.filter((q) =>
|
||||||
|
activeTab === "ACTIVE"
|
||||||
|
? q.status === "active" || q.status === "ready"
|
||||||
|
: q.status === "completed",
|
||||||
|
);
|
||||||
|
}, [quests, activeTab]);
|
||||||
|
|
||||||
|
const renderObjective = (obj, index) => {
|
||||||
|
const progress = Math.min(
|
||||||
|
(obj.currentAmount / obj.requiredAmount) * 100,
|
||||||
|
100,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div className="tab-content active">
|
<div key={index} className="objective-item">
|
||||||
<div className="quests-container">
|
<div className="objective-info">
|
||||||
<div className="quest-tabs">
|
<span className="objective-desc">{obj.description || obj.type}</span>
|
||||||
{['main', 'daily', 'weekly', 'completed'].map(t => (
|
<span className="objective-count">
|
||||||
<button
|
{obj.currentAmount} / {obj.requiredAmount}
|
||||||
key={t}
|
</span>
|
||||||
className={`quest-tab-btn ${type === t ? 'active' : ''}`}
|
|
||||||
onClick={() => setType(t)}
|
|
||||||
>
|
|
||||||
{t === 'main' ? 'Main Story' : t.charAt(0).toUpperCase() + t.slice(1)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="daily-countdown">Daily quests reset in: 00:00:00</div>
|
|
||||||
|
|
||||||
<div className="quest-list">
|
|
||||||
<div className="empty-quests">
|
|
||||||
<p>No {type} quests available at the moment.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="objective-progress-track">
|
||||||
|
<div
|
||||||
|
className="objective-progress-fill"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="quests-container">
|
||||||
|
<div className="dash-scanline"></div>
|
||||||
|
<div className="quests-header">
|
||||||
|
<h2 className="glitch-text" data-text="MISSION_LOG">
|
||||||
|
MISSION_LOG
|
||||||
|
</h2>
|
||||||
|
<div className="quest-tabs-nav">
|
||||||
|
<button
|
||||||
|
className={`nav-btn ${activeTab === "ACTIVE" ? "active" : ""}`}
|
||||||
|
onClick={() => setActiveTab("ACTIVE")}
|
||||||
|
>
|
||||||
|
ACTIVE_OPERATIONS
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`nav-btn ${activeTab === "COMPLETED" ? "active" : ""}`}
|
||||||
|
onClick={() => setActiveTab("COMPLETED")}
|
||||||
|
>
|
||||||
|
ARCHIVED_DATA
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="header-line"></div>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="loading-status">SCANNING_NEURAL_NETWORK...</div>
|
||||||
|
) : (
|
||||||
|
<div className="quests-grid">
|
||||||
|
{filteredQuests.length > 0 ? (
|
||||||
|
filteredQuests.map((quest) => (
|
||||||
|
<Card key={quest.id} className={`quest-card ${quest.status}`}>
|
||||||
|
<div className="card-tag">{quest.category || "MISSION"}</div>
|
||||||
|
<div className="quest-main">
|
||||||
|
<h3 className="quest-title">{quest.displayName}</h3>
|
||||||
|
<p className="quest-description">{quest.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="quest-objectives">
|
||||||
|
<div className="section-label">OBJECTIVES</div>
|
||||||
|
{quest.objectives.map((obj, idx) =>
|
||||||
|
renderObjective(obj, idx),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="quest-rewards">
|
||||||
|
<div className="section-label">REWARDS</div>
|
||||||
|
<div className="rewards-row">
|
||||||
|
{quest.rewards?.credits > 0 && (
|
||||||
|
<span className="reward-pill credits">
|
||||||
|
+{quest.rewards.credits} CR
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{quest.rewards?.xp > 0 && (
|
||||||
|
<span className="reward-pill xp">
|
||||||
|
+{quest.rewards.xp} XP
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{quest.status === "ready" && (
|
||||||
|
<button
|
||||||
|
className="claim-btn"
|
||||||
|
onClick={() =>
|
||||||
|
socket.emit("quest:claim_reward", { questId: quest.id })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
COMPLETE_MISSION
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{quest.status === "completed" && (
|
||||||
|
<div className="completed-stamp">MISSION_ACCOMPLISHED</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="no-quests">
|
||||||
|
<p>NO_{activeTab}_SIGNALS_FOUND</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default QuestsTab;
|
export default QuestsTab;
|
||||||
|
|||||||
@ -2,256 +2,197 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100vw;
|
||||||
height: 100%;
|
height: 100vh;
|
||||||
background: rgba(0, 0, 0, 0.85);
|
background: rgba(0, 0, 0, 0.85);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
z-index: 2000;
|
z-index: 9999;
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(6px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.craft-modal {
|
.craft-modal {
|
||||||
background: #151921;
|
background: #0f1115;
|
||||||
border: 1px solid #00ccff;
|
border: 1px solid rgba(0, 210, 255, 0.3);
|
||||||
border-radius: 12px;
|
|
||||||
width: 90%;
|
width: 90%;
|
||||||
max-width: 450px;
|
max-width: 400px; /* Трохи вужча для компактності */
|
||||||
box-shadow: 0 0 30px rgba(0, 204, 255, 0.15);
|
border-radius: 12px;
|
||||||
}
|
position: relative;
|
||||||
|
padding: 20px;
|
||||||
.modal-headerr {
|
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.9);
|
||||||
display: flex;
|
animation: modalSlideUp 0.3s ease-out;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px;
|
|
||||||
text-align: center;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
margin: 0;
|
|
||||||
color: #00ccff;
|
|
||||||
font-family: "Orbitron", sans-serif;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-x {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 1.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.requirements-section,
|
/* Header Section */
|
||||||
.outcome-section {
|
.modal-header-compact {
|
||||||
margin-bottom: 25px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.res-grid {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 15px;
|
||||||
background: rgba(255, 255, 255, 0.02);
|
margin-bottom: 18px;
|
||||||
padding: 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.res-item {
|
.item-icon-box {
|
||||||
display: flex;
|
width: 70px;
|
||||||
justify-content: space-between;
|
height: 70px;
|
||||||
color: #e0e0e0;
|
background: rgba(0, 0, 0, 0.5);
|
||||||
}
|
border: 1px solid rgba(0, 210, 255, 0.4);
|
||||||
|
|
||||||
.btn-start-craft {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px;
|
|
||||||
background: #00ccff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #000;
|
|
||||||
font-weight: bold;
|
|
||||||
text-transform: uppercase;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
transition: 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-start-craft:hover {
|
|
||||||
background: #0099cc;
|
|
||||||
box-shadow: 0 0 10px rgba(0, 204, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-cancel {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
color: #888;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.active-craft-panel {
|
|
||||||
background: rgba(0, 204, 255, 0.1);
|
|
||||||
border: 1px solid var(--primary-color);
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 20px;
|
|
||||||
animation: pulse 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.craft-info {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
align-items: center;
|
||||||
margin-bottom: 8px;
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: inset 0 0 10px rgba(0, 210, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-icon-box img {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-info-title h3 {
|
||||||
|
margin: 0;
|
||||||
font-family: "Orbitron", sans-serif;
|
font-family: "Orbitron", sans-serif;
|
||||||
font-size: 0.9rem;
|
font-size: 1.1rem;
|
||||||
color: var(--primary-color);
|
color: #00d2ff;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar-bg {
|
.item-tag {
|
||||||
width: 100%;
|
font-size: 0.65rem;
|
||||||
height: 10px;
|
color: #888;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
letter-spacing: 1px;
|
||||||
border-radius: 5px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar-fill {
|
/* Sections */
|
||||||
height: 100%;
|
.details-section {
|
||||||
background: var(--primary-color);
|
margin-bottom: 15px;
|
||||||
box-shadow: 0 0 10px var(--primary-color);
|
|
||||||
transition: width 1s linear;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
.section-label {
|
||||||
0% {
|
font-size: 0.75rem;
|
||||||
box-shadow: 0 0 5px rgba(0, 204, 255, 0.2);
|
text-transform: uppercase;
|
||||||
}
|
color: #00d2ff;
|
||||||
50% {
|
margin-bottom: 8px;
|
||||||
box-shadow: 0 0 15px rgba(0, 204, 255, 0.4);
|
opacity: 0.8;
|
||||||
}
|
display: block;
|
||||||
100% {
|
|
||||||
box-shadow: 0 0 5px rgba(0, 204, 255, 0.2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.res-item {
|
.description-text {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #aaa;
|
||||||
|
font-style: italic;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resource List */
|
||||||
|
.res-container {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.res-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px 12px;
|
padding: 6px 0;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
border-left: 4px solid transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.res-item.enough {
|
.res-row:last-child {
|
||||||
border-left-color: #44ff44;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.res-item.not-enough {
|
.res-name {
|
||||||
border-left-color: #ff4444;
|
font-size: 0.9rem;
|
||||||
background: rgba(255, 68, 68, 0.1);
|
color: #ccc;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.val-red {
|
.res-amount {
|
||||||
color: #ff4444;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.val-green {
|
|
||||||
color: #44ff44;
|
|
||||||
}
|
|
||||||
.icon-red {
|
|
||||||
color: #ff4444;
|
|
||||||
}
|
|
||||||
.icon-green {
|
|
||||||
color: #44ff44;
|
|
||||||
}
|
|
||||||
|
|
||||||
.res-quantity-info {
|
|
||||||
font-family: "Courier New", monospace;
|
font-family: "Courier New", monospace;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-start-craft {
|
.val-bad {
|
||||||
background: #28a745;
|
color: #ff4444;
|
||||||
color: white;
|
}
|
||||||
border: none;
|
.val-good {
|
||||||
padding: 10px 20px;
|
color: #00ff88;
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: bold;
|
|
||||||
transition: 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-start-craft.disabled {
|
/* Progress & Outcome */
|
||||||
background: #444 !important;
|
.outcome-bar {
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-start-craft:not(.disabled):hover {
|
|
||||||
background: #218838;
|
|
||||||
box-shadow: 0 0 10px rgba(40, 167, 69, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-preview-header {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
justify-content: space-between;
|
||||||
padding: 15px;
|
font-size: 0.85rem;
|
||||||
background: rgba(0, 212, 255, 0.05);
|
margin-top: 10px;
|
||||||
border: 1px solid rgba(0, 212, 255, 0.1);
|
color: #888;
|
||||||
margin-bottom: 20px;
|
}
|
||||||
|
|
||||||
|
.outcome-bar strong {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-craft-action {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
font-family: "Orbitron", sans-serif;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-icon-container {
|
.btn-primary-craft {
|
||||||
position: relative;
|
background: rgba(0, 210, 255, 0.1);
|
||||||
width: 90px;
|
border: 1px solid #00d2ff;
|
||||||
height: 90px;
|
color: #00d2ff;
|
||||||
background: #000;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-display-icon {
|
.btn-primary-craft:hover:not(:disabled) {
|
||||||
max-width: 80%;
|
background: #00d2ff;
|
||||||
max-height: 80%;
|
|
||||||
object-fit: contain;
|
|
||||||
filter: drop-shadow(0 0 5px var(--primary-color));
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-qty-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: -8px;
|
|
||||||
right: -8px;
|
|
||||||
background: var(--primary-color);
|
|
||||||
color: #000;
|
color: #000;
|
||||||
padding: 2px 8px;
|
box-shadow: 0 0 15px rgba(0, 210, 255, 0.3);
|
||||||
font-size: 11px;
|
|
||||||
font-weight: bold;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-type-tag {
|
.btn-primary-craft:disabled {
|
||||||
display: block;
|
border-color: #444;
|
||||||
font-size: 10px;
|
color: #444;
|
||||||
color: var(--primary-color);
|
cursor: not-allowed;
|
||||||
letter-spacing: 1px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-description {
|
.close-btn-top {
|
||||||
font-size: 13px;
|
position: absolute;
|
||||||
color: #ccc;
|
top: 12px;
|
||||||
line-height: 1.4;
|
right: 12px;
|
||||||
margin: 0;
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #555;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn-top:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalSlideUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(15px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,8 +16,7 @@ const CraftModal = ({
|
|||||||
|
|
||||||
const getFullTextureUrl = (path) => {
|
const getFullTextureUrl = (path) => {
|
||||||
if (!path) return "/assets/no-image.png";
|
if (!path) return "/assets/no-image.png";
|
||||||
if (path.startsWith("http")) return path;
|
return path.startsWith("http") ? path : `${ASSET_BASE_URL}${path}`;
|
||||||
return `${ASSET_BASE_URL}${path}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isBusy = !!activeCraft;
|
const isBusy = !!activeCraft;
|
||||||
@ -29,120 +28,131 @@ const CraftModal = ({
|
|||||||
return (
|
return (
|
||||||
<div className="craft-modal-overlay" onClick={onClose}>
|
<div className="craft-modal-overlay" onClick={onClose}>
|
||||||
<div className="craft-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="craft-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="modal-headerr">
|
<button className="close-btn-top" onClick={onClose}>
|
||||||
<h3>
|
|
||||||
<i className="fas fa-tools"></i> Construction: {recipe.displayName}
|
|
||||||
</h3>
|
|
||||||
<button className="close-x" onClick={onClose}>
|
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="modal-body">
|
{/* Header: Icon + Title */}
|
||||||
{/* Секція з картинкою предмета */}
|
<div className="modal-header-compact">
|
||||||
<div className="item-preview-header">
|
<div className="item-icon-box">
|
||||||
<div className="item-icon-container">
|
|
||||||
<img
|
<img
|
||||||
src={getFullTextureUrl(recipe.texture)}
|
src={getFullTextureUrl(recipe.texture)}
|
||||||
alt={recipe.displayName}
|
alt={recipe.displayName}
|
||||||
className="item-display-icon"
|
|
||||||
/>
|
/>
|
||||||
<div className="item-qty-badge">x{outputQty}</div>
|
<div
|
||||||
|
className="item-qty-badge"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: "-5px",
|
||||||
|
right: "-5px",
|
||||||
|
background: "#00d2ff",
|
||||||
|
color: "#000",
|
||||||
|
fontSize: "10px",
|
||||||
|
padding: "2px 5px",
|
||||||
|
borderRadius: "3px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
x{outputQty}
|
||||||
</div>
|
</div>
|
||||||
<div className="item-header-info">
|
</div>
|
||||||
<span className="item-type-tag">PROTOTYPE_UNIT</span>
|
<div className="item-info-title">
|
||||||
<p className="item-description">
|
<span className="item-tag">PROTOTYPE_UNIT</span>
|
||||||
{recipe.description ||
|
<h3>{recipe.displayName}</h3>
|
||||||
"Technical data encrypted or unavailable."}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="requirements-section">
|
{/* Description */}
|
||||||
<h4>
|
<div className="details-section">
|
||||||
<i className="fas fa-list-ul"></i> Required Resources
|
<p className="description-text">
|
||||||
</h4>
|
{recipe.description ||
|
||||||
<div className="res-grid">
|
"Advanced composite material for high-tier construction."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resources */}
|
||||||
|
<div className="details-section">
|
||||||
|
<span className="section-label">Required Materials</span>
|
||||||
|
<div className="res-container">
|
||||||
{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;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={ing.itemId} className="res-row">
|
||||||
key={ing.itemId}
|
<span className="res-name">
|
||||||
className={`res-item ${hasEnough ? "enough" : "not-enough"}`}
|
|
||||||
>
|
|
||||||
<div className="res-main-info">
|
|
||||||
<i
|
<i
|
||||||
className={`fas fa-cube ${hasEnough ? "icon-green" : "icon-red"}`}
|
className={`fas fa-square`}
|
||||||
|
style={{
|
||||||
|
fontSize: "8px",
|
||||||
|
color: hasEnough ? "#00ff88" : "#ff4444",
|
||||||
|
}}
|
||||||
></i>
|
></i>
|
||||||
<span className="res-name">{ing.displayName}</span>
|
{ing.displayName}
|
||||||
</div>
|
</span>
|
||||||
<div className="res-quantity-info">
|
<span
|
||||||
<span
|
className={`res-amount ${hasEnough ? "val-good" : "val-bad"}`}
|
||||||
className={`owned-val ${hasEnough ? "val-green" : "val-red"}`}
|
>
|
||||||
>
|
{owned} / {ing.quantity}
|
||||||
{owned}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="required-val"> / {ing.quantity}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="outcome-section">
|
{/* Outcome Info */}
|
||||||
<h4>
|
<div className="outcome-bar">
|
||||||
<i className="fas fa-box-open"></i> Outcome
|
<span>
|
||||||
</h4>
|
Production Time: <strong>{recipe.time_seconds}s</strong>
|
||||||
<div className="outcome-info">
|
</span>
|
||||||
<div className="outcome-row">
|
|
||||||
<span>Result:</span>
|
|
||||||
<strong>
|
|
||||||
{recipe.displayName} x{outputQty}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
<div className="outcome-row">
|
|
||||||
<span>Time:</span>
|
|
||||||
<strong>{recipe.time_seconds}s</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="modal-footer">
|
{/* Action Button */}
|
||||||
|
<div className="modal-footer-minimal">
|
||||||
{activeCraft && activeCraft.recipeId === recipe.id ? (
|
{activeCraft && activeCraft.recipeId === recipe.id ? (
|
||||||
<div className="modal-progress-container">
|
<div style={{ marginTop: "15px" }}>
|
||||||
<div className="progress-text">
|
<div
|
||||||
Processing... {activeCraft.timeLeft}s
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#00d2ff",
|
||||||
|
marginBottom: "5px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Constructing... {activeCraft.timeLeft}s
|
||||||
</div>
|
</div>
|
||||||
<div className="progress-bar-bg">
|
<div
|
||||||
|
className="progress-bar-bg"
|
||||||
|
style={{
|
||||||
|
height: "4px",
|
||||||
|
background: "rgba(255,255,255,0.1)",
|
||||||
|
borderRadius: "2px",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="progress-bar-fill"
|
className="progress-bar-fill"
|
||||||
style={{
|
style={{
|
||||||
width: `${((activeCraft.totalTime - activeCraft.timeLeft) / activeCraft.totalTime) * 100}%`,
|
width: `${((activeCraft.totalTime - activeCraft.timeLeft) / activeCraft.totalTime) * 100}%`,
|
||||||
|
height: "100%",
|
||||||
|
background: "#00d2ff",
|
||||||
|
boxShadow: "0 0 10px #00d2ff",
|
||||||
}}
|
}}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
|
||||||
<button
|
<button
|
||||||
className={`btn-start-craft ${!canAfford || isBusy ? "disabled" : ""}`}
|
className="btn-craft-action btn-primary-craft"
|
||||||
onClick={() => canAfford && !isBusy && onStartCraft(recipe)}
|
|
||||||
disabled={!canAfford || isBusy}
|
disabled={!canAfford || isBusy}
|
||||||
|
onClick={() => canAfford && !isBusy && onStartCraft(recipe)}
|
||||||
>
|
>
|
||||||
{isBusy
|
{isBusy
|
||||||
? "System Busy..."
|
? "System Busy"
|
||||||
: !canAfford
|
: !canAfford
|
||||||
? "Insufficient Resources"
|
? "Low Resources"
|
||||||
: `Start Construction (${recipe.time_seconds}s)`}
|
: "Begin Construction"}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn-cancel" onClick={onClose}>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -193,3 +193,22 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.item-qty-tag {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -5px;
|
||||||
|
right: -5px;
|
||||||
|
background: #00d2ff;
|
||||||
|
color: #000;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: monospace;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 210, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-equip {
|
||||||
|
letter-spacing: 2px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import React, { useEffect } from "react";
|
||||||
import "./ItemModal.css";
|
import "./ItemModal.css";
|
||||||
import { getServerUrl } from "../../../../config/api";
|
import { getServerUrl } from "../../../../config/api";
|
||||||
|
|
||||||
@ -11,6 +11,25 @@ const ItemModal = ({
|
|||||||
getStatIcon,
|
getStatIcon,
|
||||||
formatStatName,
|
formatStatName,
|
||||||
}) => {
|
}) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event) => {
|
||||||
|
if (event.keyCode === 69) {
|
||||||
|
if (isEquipped) {
|
||||||
|
onUnequip(item.currentSlot);
|
||||||
|
} else if (item && item.canEquip) {
|
||||||
|
onEquip(item);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [item, isEquipped, onEquip, onUnequip, onClose]);
|
||||||
|
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
|
|
||||||
const CONNECT_URL = getServerUrl();
|
const CONNECT_URL = getServerUrl();
|
||||||
@ -77,7 +96,7 @@ const ItemModal = ({
|
|||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
TERMINATE_CONNECTION
|
TERMINATE_CONNECTION (E)
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
item.canEquip && (
|
item.canEquip && (
|
||||||
@ -88,7 +107,7 @@ const ItemModal = ({
|
|||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
INITIALIZE_EQUIP
|
INITIALIZE_EQUIP (E)
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -0,0 +1,211 @@
|
|||||||
|
.quests-container {
|
||||||
|
padding: 15px;
|
||||||
|
position: relative;
|
||||||
|
overflow-y: auto;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quests-header {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
border-left: 3px solid #00d4ff;
|
||||||
|
padding-left: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glitch-text {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: #fff;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-line {
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, #00d4ff, transparent);
|
||||||
|
margin-top: 5px;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quests-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quest-card {
|
||||||
|
background: rgba(10, 15, 24, 0.95) !important;
|
||||||
|
border: 1px solid #1a2638 !important;
|
||||||
|
border-radius: 2px !important;
|
||||||
|
padding: 20px !important;
|
||||||
|
transition: border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стан готовності - міняємо бордер на Cyan замість фіолетового */
|
||||||
|
.quest-card.ready {
|
||||||
|
border-color: #00ff88 !important; /* Зеленуватий акцент для готових квестів */
|
||||||
|
box-shadow: inset 0 0 10px rgba(0, 255, 136, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quest-main h3 {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #00d4ff;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quest-description {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #4a5d75;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-size: 8px;
|
||||||
|
color: #4a5d75;
|
||||||
|
margin: 15px 0 8px 0;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.objective-item {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.objective-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.objective-progress-track {
|
||||||
|
height: 3px;
|
||||||
|
background: #05080c;
|
||||||
|
border: 1px solid #1a2638;
|
||||||
|
}
|
||||||
|
|
||||||
|
.objective-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: #00d4ff;
|
||||||
|
box-shadow: 0 0 5px rgba(0, 212, 255, 0.3);
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewards-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-pill {
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
border-left: 2px solid #00d4ff;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #fff;
|
||||||
|
font-family: "Space Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-pill.credits {
|
||||||
|
border-left-color: #00ff88;
|
||||||
|
color: #00ff88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-pill.xp {
|
||||||
|
border-left-color: #00d4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claim-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 20px;
|
||||||
|
background: #00ff88;
|
||||||
|
border: none;
|
||||||
|
color: #000;
|
||||||
|
padding: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claim-btn:hover {
|
||||||
|
background: #00cc6e;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 255, 136, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-quests {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #4a5d75;
|
||||||
|
border: 1px dashed #1a2638;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-quests i {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quest-tabs-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #4a5d75;
|
||||||
|
font-family: "Space Mono", monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px 0;
|
||||||
|
position: relative;
|
||||||
|
transition: color 0.3s;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn.active {
|
||||||
|
color: #00d4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn.active::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: #00d4ff;
|
||||||
|
box-shadow: 0 0 8px #00d4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completed-stamp {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #00ff88;
|
||||||
|
color: #00ff88;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
background: rgba(0, 255, 136, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quest-card.completed {
|
||||||
|
opacity: 0.7;
|
||||||
|
border-color: #1a2638 !important;
|
||||||
|
}
|
||||||
@ -38,3 +38,4 @@
|
|||||||
"category.tabs.core.shop": "Shop",
|
"category.tabs.core.shop": "Shop",
|
||||||
"category.tabs.core.skills": "Skills"
|
"category.tabs.core.skills": "Skills"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,8 +21,6 @@
|
|||||||
"admin.category.original.player_list.members": "Members",
|
"admin.category.original.player_list.members": "Members",
|
||||||
"admin.category.original.player_list.moderators": "Moderators",
|
"admin.category.original.player_list.moderators": "Moderators",
|
||||||
"admin.category.original.player_list.admins": "Admins",
|
"admin.category.original.player_list.admins": "Admins",
|
||||||
"admin.category.original.item_list.materials" : "",
|
|
||||||
"admin.category.original.hostile_list.ship" : "",
|
|
||||||
"_comment_Core_Systems": "",
|
"_comment_Core_Systems": "",
|
||||||
"core_systems.category.original.person.backpack": "Personal Backpack",
|
"core_systems.category.original.person.backpack": "Personal Backpack",
|
||||||
"core_systems.category.original.person.helmet": "Personal Helmet",
|
"core_systems.category.original.person.helmet": "Personal Helmet",
|
||||||
@ -61,8 +59,6 @@
|
|||||||
"items.materials.original.personal.accessory.basic_personal_accessory.desc": "Test accessory",
|
"items.materials.original.personal.accessory.basic_personal_accessory.desc": "Test accessory",
|
||||||
"items.materials.original.personal.backpack.basic_personal_backpack": "Personal backpack",
|
"items.materials.original.personal.backpack.basic_personal_backpack": "Personal backpack",
|
||||||
"items.materials.original.personal.backpack.basic_personal_backpack.desc": "Test backpack",
|
"items.materials.original.personal.backpack.basic_personal_backpack.desc": "Test backpack",
|
||||||
"items.materials.original.personal.backpack.personal_shield." : "Personal Shield",
|
|
||||||
"items.materials.original.personal.backpack.personal_shield.desc" : "Provides a lot of protection but lacks sufficent storage space",
|
|
||||||
"items.materials.original.personal.armor.boots.basic_personal_boots": "Personal boots",
|
"items.materials.original.personal.armor.boots.basic_personal_boots": "Personal boots",
|
||||||
"items.materials.original.personal.armor.boots.basic_personal_boots.desc": "Test boots",
|
"items.materials.original.personal.armor.boots.basic_personal_boots.desc": "Test boots",
|
||||||
"items.materials.original.personal.armor.gloves.basic_personal_gloves": "Personal gloves",
|
"items.materials.original.personal.armor.gloves.basic_personal_gloves": "Personal gloves",
|
||||||
@ -71,11 +67,13 @@
|
|||||||
"items.materials.original.personal.suit.basic_personal_suit.desc": "Test suit",
|
"items.materials.original.personal.suit.basic_personal_suit.desc": "Test suit",
|
||||||
"items.materials.original.personal.weapon.basic_personal_weapon": "Personal weapon",
|
"items.materials.original.personal.weapon.basic_personal_weapon": "Personal weapon",
|
||||||
"items.materials.original.personal.weapon.basic_personal_weapon.desc": "Test weapon",
|
"items.materials.original.personal.weapon.basic_personal_weapon.desc": "Test weapon",
|
||||||
|
"items.materials.original.personal.backpack.personal_shield." : "Personal Shield",
|
||||||
|
"items.materials.original.personal.backpack.personal_shield.desc" : "Provides a lot of protection but lacks sufficent storage space",
|
||||||
"_comment_Equipment_Ship": "",
|
"_comment_Equipment_Ship": "",
|
||||||
"items.materials.original.ship.engine.basic_ship_engines": "Ship engines",
|
"items.materials.original.ship.engine.basic_ship_engines": "Ship engines",
|
||||||
"items.materials.original.ship.engine.basic_ship_engines.desc": "Test engines",
|
"items.materials.original.ship.engine.basic_ship_engines.desc": "Test engines",
|
||||||
"items.materials.original.ship.engine.rtg.": "RTG",
|
"items.materials.original.ship.engine.rtg.": "RTG",
|
||||||
"items.materials.original.ship.engine.rtg.desc" : "very basic and low power genarator with a long track record of reliability",
|
"items.materials.original.ship.engine.rtg.desc": "very baisic and low power genarator with a long track record of reliability",
|
||||||
"items.materials.original.ship.engine.gen1_fission_reactor": "Gen 1 nuclear reactor",
|
"items.materials.original.ship.engine.gen1_fission_reactor": "Gen 1 nuclear reactor",
|
||||||
"items.materials.original.ship.engine.gen1_fission_reactor.desc": "A boiling water reactor. Little more than a pile of glowing rocks in some hot water",
|
"items.materials.original.ship.engine.gen1_fission_reactor.desc": "A boiling water reactor. Little more than a pile of glowing rocks in some hot water",
|
||||||
"items.materials.original.ship.engine.gen2_fission_reactor": "Gen 2 nuclear reactor",
|
"items.materials.original.ship.engine.gen2_fission_reactor": "Gen 2 nuclear reactor",
|
||||||
@ -179,6 +177,10 @@
|
|||||||
"quests.category.original.weekly": "Weekly",
|
"quests.category.original.weekly": "Weekly",
|
||||||
"quests.category.original.monthly": "Monthly",
|
"quests.category.original.monthly": "Monthly",
|
||||||
"quests.category.original.seasonal": "Seasons",
|
"quests.category.original.seasonal": "Seasons",
|
||||||
|
"quests.original.tutorial.starter_kit": "Starter Kit: Neural Link",
|
||||||
|
"quests.tutorial.slay_boss.name": "Trial by Fire",
|
||||||
|
"quests.tutorial.slay_boss.desc": "Prove your combat capabilities by neutralizing the Tutorial Boss unit.",
|
||||||
|
"quests.tutorial.slay_boss.obj1": "Defeat the Tutorial Boss",
|
||||||
"_comment_Recipes": "",
|
"_comment_Recipes": "",
|
||||||
"recipes.category.original.alloys": "Alloys",
|
"recipes.category.original.alloys": "Alloys",
|
||||||
"recipes.category.original.circuits": "Circuits",
|
"recipes.category.original.circuits": "Circuits",
|
||||||
@ -204,8 +206,8 @@
|
|||||||
"skills.category.original.combat.weapon_effiency": "Weapon Effiency",
|
"skills.category.original.combat.weapon_effiency": "Weapon Effiency",
|
||||||
"skills.category.original.combat.weapon_effiency.desc": "Let's get those weapons better!",
|
"skills.category.original.combat.weapon_effiency.desc": "Let's get those weapons better!",
|
||||||
"skills.category.original.crafting": "Crafting",
|
"skills.category.original.crafting": "Crafting",
|
||||||
"skills.category.original.crafting.forging" : "Forging",
|
"skills.category.original.crafting.blacksmithing": "Blacksmithing",
|
||||||
"skills.category.original.crafting.forging.desc" : "To forge the basics.",
|
"skills.category.original.crafting.blacksmithing.desc": "To forge the basics.",
|
||||||
"skills.category.original.crafting.alloying": "Alloying",
|
"skills.category.original.crafting.alloying": "Alloying",
|
||||||
"skills.category.original.crafting.alloying.desc": "Lets start alloy making.",
|
"skills.category.original.crafting.alloying.desc": "Lets start alloy making.",
|
||||||
"skills.category.original.science": "Science",
|
"skills.category.original.science": "Science",
|
||||||
@ -245,5 +247,7 @@
|
|||||||
"category.tabs.original.dungeons": "Dungeons",
|
"category.tabs.original.dungeons": "Dungeons",
|
||||||
"category.tabs.original.inventory": "Inventory",
|
"category.tabs.original.inventory": "Inventory",
|
||||||
"category.tabs.original.shop": "Shop",
|
"category.tabs.original.shop": "Shop",
|
||||||
"category.tabs.original.skills" : "Skills"
|
"category.tabs.original.skills": "Skills",
|
||||||
|
"category.tabs.original.quests": "Quests"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
"id": "original:tutorial/tutorial_boss_hostile",
|
"id": "original:tutorial/tutorial_boss_hostile",
|
||||||
"displayName": "enemies.original.tutorial.tutorial_boss_hostile",
|
"displayName": "enemies.original.tutorial.tutorial_boss_hostile",
|
||||||
"stats": {
|
"stats": {
|
||||||
"health": 90,
|
"health": 40,
|
||||||
"defense": 1.3,
|
"defense": 1.3,
|
||||||
"damage": 4,
|
"damage": 4,
|
||||||
"critical.chance": 0.3,
|
"critical.chance": 0.3,
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
"loot": [
|
"loot": [
|
||||||
{
|
{
|
||||||
"id": "original:alloy_steel",
|
"id": "original:alloy_steel",
|
||||||
"chance": 0.4,
|
"chance": 0.8,
|
||||||
"count": {
|
"count": {
|
||||||
"min": 1,
|
"min": 1,
|
||||||
"max": 2
|
"max": 2
|
||||||
|
|||||||
@ -3,7 +3,10 @@
|
|||||||
"id": "original:tutorial/tutorial_enemy_room",
|
"id": "original:tutorial/tutorial_enemy_room",
|
||||||
"displayName": "rooms.original.tutorial.tutorial_enemy_room.name",
|
"displayName": "rooms.original.tutorial.tutorial_enemy_room.name",
|
||||||
"description": "rooms.original.tutorial.tutorial_enemy_room.desc",
|
"description": "rooms.original.tutorial.tutorial_enemy_room.desc",
|
||||||
"hostiles": ["original:tutorial/tutorial_hostile"],
|
"hostiles": [
|
||||||
|
"original:tutorial/tutorial_hostile",
|
||||||
|
"original:tutorial/tutorial_hostile"
|
||||||
|
],
|
||||||
"gainXp": 3,
|
"gainXp": 3,
|
||||||
"credits": 30,
|
"credits": 30,
|
||||||
"loot": [],
|
"loot": [],
|
||||||
|
|||||||
@ -1,18 +1,15 @@
|
|||||||
{
|
{
|
||||||
"plating": {
|
"weapons": {
|
||||||
"id": "original:basic_personal_weapon",
|
"id": "original:basic_personal_weapon",
|
||||||
"displayName": "items.materials.original.personal.weapon.basic_personal_weapon",
|
"displayName": "items.materials.original.personal.weapon.basic_personal_weapon",
|
||||||
"description": "items.materials.original.personal.weapon.basic_personal_weapon.desc",
|
"description": "items.materials.original.personal.weapon.basic_personal_weapon.desc",
|
||||||
"texture": "original/assets/textures/equipment/personal/weapon/basic_weapon.png",
|
"texture": "original/assets/textures/equipment/personal/weapon/basic_weapon.png",
|
||||||
"stats": {
|
"stats": {
|
||||||
"health": 15,
|
"attack.base": 20
|
||||||
"resistance.base": 0.125,
|
|
||||||
"defence.rating": 0.9,
|
|
||||||
"reflect.chance": 0.0125
|
|
||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
"rarity": "common",
|
"rarity": "common",
|
||||||
"equipmentSlot": "original:personal_suit",
|
"equipmentSlot": "original:personal_weapons",
|
||||||
"storeCategory": "original:personal",
|
"storeCategory": "original:personal",
|
||||||
"dungeon": "ground"
|
"dungeon": "ground"
|
||||||
}
|
}
|
||||||
55
game-server/datapacks/original/data/quests/starter_kit.json
Normal file
55
game-server/datapacks/original/data/quests/starter_kit.json
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"quest": {
|
||||||
|
"id": "original:tutorial/starter_kit",
|
||||||
|
"displayName": "quests.original.tutorial.starter_kit",
|
||||||
|
"description": "Welcome, Commander. Your neural link is active. Initial equipment has been authorized.",
|
||||||
|
"category": "STORY",
|
||||||
|
"minLevel": 1,
|
||||||
|
|
||||||
|
"objectives": [
|
||||||
|
{
|
||||||
|
"type": "LOGIN",
|
||||||
|
"requiredAmount": 1,
|
||||||
|
"currentAmount": 0,
|
||||||
|
"description": "Initialize system uplink"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"rewards": {
|
||||||
|
"xp": 50,
|
||||||
|
"credits": 500,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "original:basic_personal_accessory",
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "original:basic_personal_backpack",
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "original:basic_personal_boots",
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "original:basic_personal_gloves",
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "original:basic_personal_suit",
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "original:basic_personal_weapon",
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"meta": {
|
||||||
|
"autoAccept": true,
|
||||||
|
"autoComplete": false,
|
||||||
|
"priority": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"quest": {
|
||||||
|
"id": "original:tutorial/slay_tutorial_boss",
|
||||||
|
"displayName": "quests.tutorial.slay_boss.name",
|
||||||
|
"description": "quests.tutorial.slay_boss.desc",
|
||||||
|
"category": "STORY",
|
||||||
|
"meta": {
|
||||||
|
"autoAccept": true,
|
||||||
|
"priority": 10
|
||||||
|
},
|
||||||
|
"objectives": [
|
||||||
|
{
|
||||||
|
"type": "KILL_ENEMY",
|
||||||
|
"targetId": "original:tutorial/tutorial_boss_hostile",
|
||||||
|
"requiredAmount": 1,
|
||||||
|
"description": "quests.tutorial.slay_boss.obj1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rewards": {
|
||||||
|
"credits": 500,
|
||||||
|
"xp": 1000,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "original:materials/data_core",
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -32,7 +32,7 @@ sequelize.initDatabase = async () => {
|
|||||||
await sequelize.query("PRAGMA foreign_keys = OFF;");
|
await sequelize.query("PRAGMA foreign_keys = OFF;");
|
||||||
}
|
}
|
||||||
|
|
||||||
await sequelize.sync({ alter: true });
|
await sequelize.sync();
|
||||||
|
|
||||||
if (isLocal) {
|
if (isLocal) {
|
||||||
await sequelize.query("PRAGMA foreign_keys = ON;");
|
await sequelize.query("PRAGMA foreign_keys = ON;");
|
||||||
|
|||||||
125
game-server/src/game/CombatService.js
Normal file
125
game-server/src/game/CombatService.js
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
const DatapackLoader = require("./DatapackLoader");
|
||||||
|
|
||||||
|
class CombatService {
|
||||||
|
initializeBattle(player, hostiles) {
|
||||||
|
const equipmentStats = this.calculateEquipmentStats(player.equipment);
|
||||||
|
|
||||||
|
const maxHp = 100 + (equipmentStats.health || 0);
|
||||||
|
const atk = 25 + (equipmentStats.attack || 0);
|
||||||
|
|
||||||
|
const battle = {
|
||||||
|
player: {
|
||||||
|
id: player.id,
|
||||||
|
name: player.username || "Commander",
|
||||||
|
hp: maxHp,
|
||||||
|
maxHp: maxHp,
|
||||||
|
atk: atk,
|
||||||
|
stats: equipmentStats,
|
||||||
|
},
|
||||||
|
enemies: hostiles.map((h, index) => ({
|
||||||
|
...h,
|
||||||
|
instanceId: `mob_${index}`,
|
||||||
|
id: h.id,
|
||||||
|
name: h.displayName || h.name || `Hostile ${index + 1}`,
|
||||||
|
hp: h.stats?.health || 50,
|
||||||
|
maxHp: h.stats?.health || 50,
|
||||||
|
atk: h.stats?.attack || 10,
|
||||||
|
isDead: false,
|
||||||
|
})),
|
||||||
|
turnOrder: [],
|
||||||
|
currentTurnIndex: 0,
|
||||||
|
turnStartTime: Date.now(),
|
||||||
|
isOver: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
battle.turnOrder = ["player", ...battle.enemies.map((e) => e.instanceId)];
|
||||||
|
return battle;
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateEquipmentStats(equipment) {
|
||||||
|
const totals = {
|
||||||
|
health: 0,
|
||||||
|
attack: 0,
|
||||||
|
defence: 0,
|
||||||
|
resistance: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!equipment) return totals;
|
||||||
|
|
||||||
|
Object.values(equipment).forEach((itemId) => {
|
||||||
|
if (!itemId) return;
|
||||||
|
|
||||||
|
const itemData = DatapackLoader.getItem(itemId);
|
||||||
|
|
||||||
|
if (itemData && itemData.stats) {
|
||||||
|
Object.entries(itemData.stats).forEach(([key, value]) => {
|
||||||
|
const lowerKey = key.toLowerCase();
|
||||||
|
|
||||||
|
if (lowerKey.includes("health") || lowerKey === "hp") {
|
||||||
|
totals.health += value;
|
||||||
|
} else if (
|
||||||
|
lowerKey.includes("attack") ||
|
||||||
|
lowerKey.includes("damage") ||
|
||||||
|
lowerKey.includes("atk")
|
||||||
|
) {
|
||||||
|
totals.attack += value;
|
||||||
|
} else if (
|
||||||
|
lowerKey.includes("defence") ||
|
||||||
|
lowerKey.includes("armor") ||
|
||||||
|
lowerKey.includes("defense")
|
||||||
|
) {
|
||||||
|
totals.defence += value;
|
||||||
|
} else if (lowerKey.includes("resistance")) {
|
||||||
|
totals.resistance += value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAttack(battle, targetInstanceId) {
|
||||||
|
const attackerId = battle.turnOrder[battle.currentTurnIndex];
|
||||||
|
const log = [];
|
||||||
|
|
||||||
|
if (attackerId === "player") {
|
||||||
|
const target = battle.enemies.find(
|
||||||
|
(e) => e.instanceId === targetInstanceId,
|
||||||
|
);
|
||||||
|
if (target && !target.isDead) {
|
||||||
|
const damage = battle.player.atk;
|
||||||
|
target.hp -= damage;
|
||||||
|
|
||||||
|
log.push(`Player dealt ${damage} damage to ${target.name}`);
|
||||||
|
|
||||||
|
if (target.hp <= 0) {
|
||||||
|
target.hp = 0;
|
||||||
|
target.isDead = true;
|
||||||
|
log.push(`${target.name} destroyed!`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const enemy = battle.enemies.find((e) => e.instanceId === attackerId);
|
||||||
|
if (enemy && !enemy.isDead) {
|
||||||
|
const playerDef = battle.player.stats.defence || 0;
|
||||||
|
const finalDamage = Math.max(
|
||||||
|
1,
|
||||||
|
Math.round(enemy.atk - playerDef * 0.5),
|
||||||
|
);
|
||||||
|
|
||||||
|
battle.player.hp -= finalDamage;
|
||||||
|
log.push(`${enemy.name} deals ${finalDamage} damage to Player`);
|
||||||
|
|
||||||
|
if (battle.player.hp <= 0) {
|
||||||
|
battle.player.hp = 0;
|
||||||
|
battle.isOver = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new CombatService();
|
||||||
@ -11,6 +11,7 @@ class DatapackLoader {
|
|||||||
dungeons: new Map(),
|
dungeons: new Map(),
|
||||||
enemies: new Map(),
|
enemies: new Map(),
|
||||||
rooms: new Map(),
|
rooms: new Map(),
|
||||||
|
quests: new Map(),
|
||||||
languages: new Map(),
|
languages: new Map(),
|
||||||
manifest: {},
|
manifest: {},
|
||||||
};
|
};
|
||||||
@ -70,9 +71,10 @@ class DatapackLoader {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`🚀 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.quests.size} Quests, ${this.registry.languages.size} Langs, ${manifestCount} Manifests`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getRecipe(id) {
|
getRecipe(id) {
|
||||||
return this.registry.recipes.get(id);
|
return this.registry.recipes.get(id);
|
||||||
}
|
}
|
||||||
@ -89,6 +91,7 @@ class DatapackLoader {
|
|||||||
);
|
);
|
||||||
return Array.from(categories);
|
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"));
|
||||||
@ -156,7 +159,6 @@ class DatapackLoader {
|
|||||||
case "weapons":
|
case "weapons":
|
||||||
data.type = typeKey;
|
data.type = typeKey;
|
||||||
this.registry.items.set(fullId, data);
|
this.registry.items.set(fullId, data);
|
||||||
this.registry.items.set(data.id, data);
|
|
||||||
break;
|
break;
|
||||||
case "recipe":
|
case "recipe":
|
||||||
const recipeId = json.craft?.id || data.id;
|
const recipeId = json.craft?.id || data.id;
|
||||||
@ -164,19 +166,18 @@ class DatapackLoader {
|
|||||||
break;
|
break;
|
||||||
case "skills":
|
case "skills":
|
||||||
this.registry.skills.set(fullId, data);
|
this.registry.skills.set(fullId, data);
|
||||||
this.registry.skills.set(data.id, data);
|
|
||||||
break;
|
break;
|
||||||
case "dungeon":
|
case "dungeon":
|
||||||
this.registry.dungeons.set(fullId, data);
|
this.registry.dungeons.set(fullId, data);
|
||||||
this.registry.dungeons.set(data.id, data);
|
|
||||||
break;
|
break;
|
||||||
case "hostile":
|
case "hostile":
|
||||||
this.registry.enemies.set(fullId, data);
|
this.registry.enemies.set(fullId, data);
|
||||||
this.registry.enemies.set(data.id, data);
|
|
||||||
break;
|
break;
|
||||||
case "rooms":
|
case "rooms":
|
||||||
this.registry.rooms.set(fullId, data);
|
this.registry.rooms.set(fullId, data);
|
||||||
this.registry.rooms.set(data.id, data);
|
break;
|
||||||
|
case "quest":
|
||||||
|
this.registry.quests.set(fullId, data);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -187,15 +188,29 @@ class DatapackLoader {
|
|||||||
getItem(id) {
|
getItem(id) {
|
||||||
return this.registry.items.get(id);
|
return this.registry.items.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
getEnemy(id) {
|
getEnemy(id) {
|
||||||
return this.registry.enemies.get(id);
|
return this.registry.enemies.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
getDungeon(id) {
|
getDungeon(id) {
|
||||||
return this.registry.dungeons.get(id);
|
return this.registry.dungeons.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
getRoom(id) {
|
getRoom(id) {
|
||||||
return this.registry.rooms.get(id);
|
return this.registry.rooms.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getQuest(id) {
|
||||||
|
return this.registry.quests.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAutoStartQuests() {
|
||||||
|
return Array.from(this.registry.quests.values()).filter(
|
||||||
|
(q) => q.meta?.autoAccept,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
getRecipes() {
|
getRecipes() {
|
||||||
return Array.from(this.registry.recipes.values());
|
return Array.from(this.registry.recipes.values());
|
||||||
}
|
}
|
||||||
@ -208,6 +223,7 @@ class DatapackLoader {
|
|||||||
dungeons: Array.from(this.registry.dungeons.values()),
|
dungeons: Array.from(this.registry.dungeons.values()),
|
||||||
enemies: Array.from(this.registry.enemies.values()),
|
enemies: Array.from(this.registry.enemies.values()),
|
||||||
rooms: Array.from(this.registry.rooms.values()),
|
rooms: Array.from(this.registry.rooms.values()),
|
||||||
|
quests: Array.from(this.registry.quests.values()),
|
||||||
languages: Object.fromEntries(this.registry.languages),
|
languages: Object.fromEntries(this.registry.languages),
|
||||||
manifest: this.registry.manifest,
|
manifest: this.registry.manifest,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,23 +1,49 @@
|
|||||||
const DatapackLoader = require("./DatapackLoader");
|
const DatapackLoader = require("./DatapackLoader");
|
||||||
|
const CombatService = require("./CombatService");
|
||||||
|
const QuestsManager = require("./QuestsManager");
|
||||||
|
const { Player } = require("../models");
|
||||||
|
|
||||||
class DungeonManager {
|
class DungeonManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.activeSessions = new Map();
|
this.activeSessions = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
startDungeon(playerId, dungeonId) {
|
async startDungeon(playerId, dungeonId) {
|
||||||
const dungeon = DatapackLoader.getDungeon(dungeonId);
|
const dungeon = DatapackLoader.getDungeon(dungeonId);
|
||||||
if (!dungeon || !dungeon.rooms?.length) return null;
|
if (!dungeon || !dungeon.rooms?.length) return null;
|
||||||
|
|
||||||
|
const player = await Player.findByPk(playerId);
|
||||||
|
if (!player) return null;
|
||||||
|
|
||||||
const session = {
|
const session = {
|
||||||
|
playerId,
|
||||||
dungeonId,
|
dungeonId,
|
||||||
currentRoomIndex: 0,
|
currentRoomIndex: 0,
|
||||||
isFinished: false,
|
isFinished: false,
|
||||||
currentEnemyHp: undefined,
|
battle: null,
|
||||||
rewards: { xp: 0, credits: 0, items: [] },
|
rewards: { xp: 0, credits: 0, items: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
this.activeSessions.set(playerId, session);
|
this.activeSessions.set(playerId, session);
|
||||||
return this.getCurrentRoomData(playerId);
|
return this.initRoom(playerId, player);
|
||||||
|
}
|
||||||
|
|
||||||
|
async initRoom(playerId, playerInstance = null) {
|
||||||
|
const session = this.activeSessions.get(playerId);
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
const roomData = this.getCurrentRoomData(playerId);
|
||||||
|
const player = playerInstance || (await Player.findByPk(playerId));
|
||||||
|
|
||||||
|
if (roomData.hostiles.length > 0) {
|
||||||
|
session.battle = CombatService.initializeBattle(
|
||||||
|
player,
|
||||||
|
roomData.hostiles,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
session.battle = null;
|
||||||
|
}
|
||||||
|
return { ...roomData, battle: session.battle };
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrentRoomData(playerId) {
|
getCurrentRoomData(playerId) {
|
||||||
@ -28,8 +54,6 @@ class DungeonManager {
|
|||||||
const roomRef = dungeon.rooms[session.currentRoomIndex];
|
const roomRef = dungeon.rooms[session.currentRoomIndex];
|
||||||
const rawRoom = DatapackLoader.getRoom(roomRef.id);
|
const rawRoom = DatapackLoader.getRoom(roomRef.id);
|
||||||
|
|
||||||
if (!rawRoom) return null;
|
|
||||||
|
|
||||||
const hostiles = (rawRoom.hostiles || [])
|
const hostiles = (rawRoom.hostiles || [])
|
||||||
.map((hId) => DatapackLoader.getEnemy(hId))
|
.map((hId) => DatapackLoader.getEnemy(hId))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
@ -42,89 +66,141 @@ class DungeonManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
processCombatStep(playerId, enemyId) {
|
processCombatAction(playerId, targetInstanceId, socket = null) {
|
||||||
const session = this.activeSessions.get(playerId);
|
const session = this.activeSessions.get(playerId);
|
||||||
if (!session || session.isFinished) return null;
|
if (!session || !session.battle || session.battle.isOver) return null;
|
||||||
|
|
||||||
const enemy = DatapackLoader.getEnemy(enemyId);
|
const battle = session.battle;
|
||||||
if (!enemy) return null;
|
const log = CombatService.handleAttack(battle, targetInstanceId);
|
||||||
|
|
||||||
if (session.currentEnemyHp === undefined) {
|
const allEnemiesDead = battle.enemies.every((e) => e.isDead);
|
||||||
session.currentEnemyHp = enemy.stats?.health || 100;
|
const playerDead = battle.player.hp <= 0;
|
||||||
}
|
|
||||||
|
|
||||||
const damage = Math.floor(Math.random() * 10) + 20;
|
|
||||||
session.currentEnemyHp -= damage;
|
|
||||||
|
|
||||||
const isDefeated = session.currentEnemyHp <= 0;
|
|
||||||
let lootDropped = [];
|
|
||||||
|
|
||||||
if (isDefeated) {
|
|
||||||
if (enemy.loot) {
|
|
||||||
lootDropped = this._generateLoot(enemy.loot);
|
|
||||||
session.rewards.items.push(...lootDropped);
|
|
||||||
}
|
|
||||||
session.currentEnemyHp = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (playerDead) {
|
||||||
|
battle.isOver = true;
|
||||||
|
battle.player.hp = 0;
|
||||||
return {
|
return {
|
||||||
damageDealt: damage,
|
battle,
|
||||||
enemyHp: Math.max(0, session.currentEnemyHp || 0),
|
log: [...log, "CRITICAL_FAILURE: Mission terminated."],
|
||||||
targetDefeated: isDefeated,
|
status: "defeat",
|
||||||
loot: lootDropped,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
moveToNextRoom(playerId) {
|
if (allEnemiesDead) {
|
||||||
|
battle.isOver = true;
|
||||||
|
|
||||||
|
const roomData = this.getCurrentRoomData(playerId);
|
||||||
|
const roomConfig = roomData.config;
|
||||||
|
|
||||||
|
session.rewards.xp += roomConfig.gainXp || 0;
|
||||||
|
session.rewards.credits += roomConfig.credits || 0;
|
||||||
|
|
||||||
|
if (roomConfig.loot && Array.isArray(roomConfig.loot)) {
|
||||||
|
roomConfig.loot.forEach((l) => {
|
||||||
|
if (Math.random() <= (l.chance || 1)) {
|
||||||
|
let finalCount = 1;
|
||||||
|
if (typeof l.count === "object") {
|
||||||
|
finalCount =
|
||||||
|
Math.floor(Math.random() * (l.count.max - l.count.min + 1)) +
|
||||||
|
l.count.min;
|
||||||
|
} else if (typeof l.count === "number") {
|
||||||
|
finalCount = l.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingItem = session.rewards.items.find(
|
||||||
|
(i) => i.id === l.id,
|
||||||
|
);
|
||||||
|
if (existingItem) {
|
||||||
|
existingItem.count += finalCount;
|
||||||
|
} else {
|
||||||
|
session.rewards.items.push({ id: l.id, count: finalCount });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
battle.enemies.forEach((enemy) => {
|
||||||
|
session.rewards.xp += enemy.gainXp || 0;
|
||||||
|
session.rewards.credits += enemy.credits || 0;
|
||||||
|
|
||||||
|
QuestsManager.trackProgress(
|
||||||
|
playerId,
|
||||||
|
"KILL_ENEMY",
|
||||||
|
enemy.id,
|
||||||
|
1,
|
||||||
|
socket,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (enemy.loot && Array.isArray(enemy.loot)) {
|
||||||
|
enemy.loot.forEach((l) => {
|
||||||
|
if (Math.random() <= (l.chance || 1)) {
|
||||||
|
let finalCount = 1;
|
||||||
|
if (typeof l.count === "object") {
|
||||||
|
finalCount =
|
||||||
|
Math.floor(Math.random() * (l.count.max - l.count.min + 1)) +
|
||||||
|
l.count.min;
|
||||||
|
} else if (typeof l.count === "number") {
|
||||||
|
finalCount = l.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingItem = session.rewards.items.find(
|
||||||
|
(i) => i.id === l.id,
|
||||||
|
);
|
||||||
|
if (existingItem) {
|
||||||
|
existingItem.count += finalCount;
|
||||||
|
} else {
|
||||||
|
session.rewards.items.push({ id: l.id, count: finalCount });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { battle, log, status: "victory" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._nextTurn(session, log);
|
||||||
|
}
|
||||||
|
|
||||||
|
_nextTurn(session, lastLog = []) {
|
||||||
|
const battle = session.battle;
|
||||||
|
battle.currentTurnIndex =
|
||||||
|
(battle.currentTurnIndex + 1) % battle.turnOrder.length;
|
||||||
|
|
||||||
|
const currentEntityId = battle.turnOrder[battle.currentTurnIndex];
|
||||||
|
if (currentEntityId !== "player") {
|
||||||
|
const enemy = battle.enemies.find(
|
||||||
|
(e) => e.instanceId === currentEntityId,
|
||||||
|
);
|
||||||
|
if (!enemy || enemy.isDead) return this._nextTurn(session, lastLog);
|
||||||
|
|
||||||
|
const enemyLog = CombatService.handleAttack(battle, null);
|
||||||
|
|
||||||
|
if (battle.player.hp <= 0) {
|
||||||
|
battle.isOver = true;
|
||||||
|
return { battle, log: [...lastLog, ...enemyLog], status: "defeat" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._nextTurn(session, [...lastLog, ...enemyLog]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { battle, log: lastLog };
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveToNextRoom(playerId) {
|
||||||
const session = this.activeSessions.get(playerId);
|
const session = this.activeSessions.get(playerId);
|
||||||
if (!session || session.isFinished) return null;
|
if (!session || session.isFinished) return null;
|
||||||
|
|
||||||
const dungeon = DatapackLoader.getDungeon(session.dungeonId);
|
const dungeon = DatapackLoader.getDungeon(session.dungeonId);
|
||||||
const roomRef = dungeon.rooms[session.currentRoomIndex];
|
|
||||||
const rawRoom = DatapackLoader.getRoom(roomRef.id);
|
|
||||||
|
|
||||||
if (rawRoom) {
|
|
||||||
if (rawRoom.hostiles) {
|
|
||||||
rawRoom.hostiles.forEach((hId) => {
|
|
||||||
const enemy = DatapackLoader.getEnemy(hId);
|
|
||||||
if (enemy) {
|
|
||||||
session.rewards.xp += enemy.gainXp || 0;
|
|
||||||
session.rewards.credits += enemy.credits || 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
session.rewards.xp += rawRoom.gainXp || 0;
|
|
||||||
session.rewards.credits += rawRoom.credits || 0;
|
|
||||||
if (rawRoom.loot) {
|
|
||||||
session.rewards.items.push(...this._generateLoot(rawRoom.loot));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.currentRoomIndex < dungeon.rooms.length - 1) {
|
if (session.currentRoomIndex < dungeon.rooms.length - 1) {
|
||||||
session.currentRoomIndex++;
|
session.currentRoomIndex++;
|
||||||
return this.getCurrentRoomData(playerId);
|
return this.initRoom(playerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
session.isFinished = true;
|
session.isFinished = true;
|
||||||
return { status: "completed", rewards: session.rewards };
|
return { status: "completed", rewards: session.rewards };
|
||||||
}
|
}
|
||||||
|
|
||||||
_generateLoot(lootTable) {
|
|
||||||
const dropped = [];
|
|
||||||
lootTable.forEach((entry) => {
|
|
||||||
if (Math.random() <= (entry.chance || 1.0)) {
|
|
||||||
const count =
|
|
||||||
typeof entry.count === "object"
|
|
||||||
? Math.floor(
|
|
||||||
Math.random() * (entry.count.max - entry.count.min + 1),
|
|
||||||
) + entry.count.min
|
|
||||||
: entry.count || 1;
|
|
||||||
dropped.push({ id: entry.id, count });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return dropped;
|
|
||||||
}
|
|
||||||
|
|
||||||
leaveDungeon(playerId) {
|
leaveDungeon(playerId) {
|
||||||
this.activeSessions.delete(playerId);
|
this.activeSessions.delete(playerId);
|
||||||
}
|
}
|
||||||
|
|||||||
163
game-server/src/game/QuestsManager.js
Normal file
163
game-server/src/game/QuestsManager.js
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
const DatapackLoader = require("./DatapackLoader");
|
||||||
|
const { PlayerQuest, Player, Inventory, sequelize } = require("../models");
|
||||||
|
|
||||||
|
class QuestsManager {
|
||||||
|
async onPlayerLogin(playerId, socket = null) {
|
||||||
|
try {
|
||||||
|
await this.checkAutoQuests(playerId, socket);
|
||||||
|
await this.trackProgress(playerId, "LOGIN", null, 1, socket);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkAutoQuests(playerId, socket = null) {
|
||||||
|
const allQuests = Array.from(DatapackLoader.registry.quests);
|
||||||
|
const player = await Player.findByPk(playerId);
|
||||||
|
|
||||||
|
for (const quest of allQuests) {
|
||||||
|
if (quest.meta?.autoAccept && player.level >= (quest.minLevel || 0)) {
|
||||||
|
const [pq, created] = await PlayerQuest.findOrCreate({
|
||||||
|
where: { playerId, questId: quest.id },
|
||||||
|
defaults: {
|
||||||
|
status: "active",
|
||||||
|
progress: quest.objectives.map((obj) => ({
|
||||||
|
...obj,
|
||||||
|
currentAmount: 0,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (created && socket) {
|
||||||
|
socket.emit("quest:new", {
|
||||||
|
id: pq.questId,
|
||||||
|
status: pq.status,
|
||||||
|
objectives: pq.progress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async trackProgress(playerId, type, targetId, amount = 1, socket = null) {
|
||||||
|
try {
|
||||||
|
const activeQuests = await PlayerQuest.findAll({
|
||||||
|
where: { playerId, status: "active" },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const pq of activeQuests) {
|
||||||
|
const staticData = DatapackLoader.getQuest(pq.questId);
|
||||||
|
if (!staticData) continue;
|
||||||
|
|
||||||
|
let isChanged = false;
|
||||||
|
const currentProgress = pq.progress;
|
||||||
|
|
||||||
|
const updatedProgress = currentProgress.map((obj) => {
|
||||||
|
if (
|
||||||
|
obj.type === type &&
|
||||||
|
(obj.targetId === targetId || type === "LOGIN")
|
||||||
|
) {
|
||||||
|
if (obj.currentAmount < obj.requiredAmount) {
|
||||||
|
obj.currentAmount = Math.min(
|
||||||
|
obj.currentAmount + amount,
|
||||||
|
obj.requiredAmount,
|
||||||
|
);
|
||||||
|
isChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isChanged) {
|
||||||
|
const isReady = updatedProgress.every(
|
||||||
|
(obj) => obj.currentAmount >= obj.requiredAmount,
|
||||||
|
);
|
||||||
|
|
||||||
|
await pq.update({
|
||||||
|
progress: updatedProgress,
|
||||||
|
status: isReady ? "ready" : "active",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (socket) {
|
||||||
|
socket.emit("quest:update", {
|
||||||
|
id: pq.questId,
|
||||||
|
status: isReady ? "ready" : "active",
|
||||||
|
objectives: updatedProgress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReady && staticData.meta?.autoComplete) {
|
||||||
|
await this.claimRewards(playerId, pq.questId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async claimRewards(playerId, questId) {
|
||||||
|
const t = await sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pq = await PlayerQuest.findOne({
|
||||||
|
where: { playerId, questId, status: "ready" },
|
||||||
|
transaction: t,
|
||||||
|
lock: t.LOCK.UPDATE,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!pq) {
|
||||||
|
await t.rollback();
|
||||||
|
throw new Error("QUEST_NOT_READY_OR_CLAIMED");
|
||||||
|
}
|
||||||
|
|
||||||
|
const staticData = DatapackLoader.getQuest(questId);
|
||||||
|
const player = await Player.findByPk(playerId, { transaction: t });
|
||||||
|
const rewards = staticData.rewards;
|
||||||
|
|
||||||
|
await pq.update({ status: "completed" }, { transaction: t });
|
||||||
|
|
||||||
|
if (rewards.credits) {
|
||||||
|
await player.increment("credits", {
|
||||||
|
by: rewards.credits,
|
||||||
|
transaction: t,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rewards.xp) {
|
||||||
|
await player.increment("experience", {
|
||||||
|
by: rewards.xp,
|
||||||
|
transaction: t,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rewards.items?.length > 0) {
|
||||||
|
for (const item of rewards.items) {
|
||||||
|
const [invItem] = await Inventory.findOrCreate({
|
||||||
|
where: { playerId, itemId: item.id },
|
||||||
|
defaults: { quantity: 0 },
|
||||||
|
transaction: t,
|
||||||
|
});
|
||||||
|
await invItem.increment("quantity", {
|
||||||
|
by: item.count,
|
||||||
|
transaction: t,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await t.commit();
|
||||||
|
const updatedPlayer = await player.reload();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
rewards,
|
||||||
|
newTotalCredits: updatedPlayer.credits,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (t) await t.rollback();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new QuestsManager();
|
||||||
41
game-server/src/models/PlayerQuest.js
Normal file
41
game-server/src/models/PlayerQuest.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
const { DataTypes } = require("sequelize");
|
||||||
|
const sequelize = require("../config/db");
|
||||||
|
|
||||||
|
const PlayerQuest = sequelize.define(
|
||||||
|
"PlayerQuest",
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true,
|
||||||
|
},
|
||||||
|
playerId: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
questId: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM("active", "ready", "completed"),
|
||||||
|
defaultValue: "active",
|
||||||
|
},
|
||||||
|
progress: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
tableName: "player_quests",
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ["playerId", "questId"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = PlayerQuest;
|
||||||
@ -4,9 +4,14 @@ const Inventory = require("./Inventory");
|
|||||||
const setupAssociations = require("./associations");
|
const setupAssociations = require("./associations");
|
||||||
const Notification = require("./Notification");
|
const Notification = require("./Notification");
|
||||||
const Friend = require("./Friend.js");
|
const Friend = require("./Friend.js");
|
||||||
|
const PlayerQuest = require("./PlayerQuest.js");
|
||||||
|
|
||||||
Player.hasMany(Inventory, { foreignKey: "playerId", as: "inventory" });
|
Player.hasMany(Inventory, { foreignKey: "playerId", as: "inventory" });
|
||||||
Inventory.belongsTo(Player, { foreignKey: "playerId" });
|
Inventory.belongsTo(Player, { foreignKey: "playerId" });
|
||||||
|
|
||||||
|
Player.hasMany(PlayerQuest, { foreignKey: "playerId", as: "quests" });
|
||||||
|
PlayerQuest.belongsTo(Player, { foreignKey: "playerId" });
|
||||||
|
|
||||||
setupAssociations();
|
setupAssociations();
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
@ -15,4 +20,5 @@ module.exports = {
|
|||||||
Inventory,
|
Inventory,
|
||||||
Notification,
|
Notification,
|
||||||
Friend,
|
Friend,
|
||||||
|
PlayerQuest,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,6 +2,7 @@ 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");
|
||||||
|
const QuestsManager = require("../../game/QuestsManager");
|
||||||
|
|
||||||
module.exports = async (io, socket) => {
|
module.exports = async (io, socket) => {
|
||||||
const userId = socket.user?.id;
|
const userId = socket.user?.id;
|
||||||
@ -26,6 +27,9 @@ module.exports = async (io, socket) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await QuestsManager.onPlayerLogin(userId, socket);
|
||||||
|
await player.reload();
|
||||||
|
|
||||||
const offlineCredits = await economyService.handleOfflineEarnings(player);
|
const offlineCredits = await economyService.handleOfflineEarnings(player);
|
||||||
|
|
||||||
socket.playerId = userId;
|
socket.playerId = userId;
|
||||||
@ -34,9 +38,9 @@ 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 onlinePlayersData = sessionManager.getAllOnline();
|
||||||
const onlineUsernames = onlinePlayersData.map((p) => p.username);
|
const onlineUsernames = onlinePlayersData.map((p) => p.username);
|
||||||
|
|
||||||
const onlineIds = onlinePlayersData.map((p) => p.id);
|
const onlineIds = onlinePlayersData.map((p) => p.id);
|
||||||
|
|
||||||
const offlinePlayersModels = await Player.findAll({
|
const offlinePlayersModels = await Player.findAll({
|
||||||
@ -64,6 +68,7 @@ module.exports = async (io, socket) => {
|
|||||||
|
|
||||||
socket.broadcast.emit("player:joined", { username: playerRaw.username });
|
socket.broadcast.emit("player:joined", { username: playerRaw.username });
|
||||||
socket.join(`user_${socket.user.id}`);
|
socket.join(`user_${socket.user.id}`);
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@ -18,36 +18,49 @@ module.exports = (io, socket) => {
|
|||||||
return socket.emit("error", { message: "Insufficient energy" });
|
return socket.emit("error", { message: "Insufficient energy" });
|
||||||
|
|
||||||
await player.decrement("energy", { by: energyCost });
|
await player.decrement("energy", { by: energyCost });
|
||||||
const firstRoom = dungeonManager.startDungeon(userId, dungeonId);
|
const startData = await dungeonManager.startDungeon(userId, dungeonId);
|
||||||
|
|
||||||
socket.emit("dungeon:started", {
|
socket.emit("dungeon:started", {
|
||||||
dungeonId: dungeon.id,
|
dungeonId: dungeon.id,
|
||||||
room: firstRoom.config,
|
room: startData.config,
|
||||||
hostiles: firstRoom.hostiles,
|
hostiles: startData.hostiles,
|
||||||
roomIndex: firstRoom.roomIndex,
|
battle: startData.battle,
|
||||||
totalRooms: firstRoom.totalRooms,
|
roomIndex: startData.roomIndex,
|
||||||
|
totalRooms: startData.totalRooms,
|
||||||
remainingEnergy: player.energy - energyCost,
|
remainingEnergy: player.energy - energyCost,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
socket.emit("error", { message: "Critical deployment failure" });
|
socket.emit("error", { message: "Deployment failure" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("dungeon:combat_step", async ({ enemyId }) => {
|
socket.on("dungeon:combat_action", async ({ targetInstanceId }) => {
|
||||||
const result = dungeonManager.processCombatStep(userId, enemyId);
|
try {
|
||||||
|
if (!userId) return;
|
||||||
|
const result = dungeonManager.processCombatAction(
|
||||||
|
userId,
|
||||||
|
targetInstanceId,
|
||||||
|
);
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
|
|
||||||
socket.emit("dungeon:combat_result", {
|
socket.emit("dungeon:battle_update", {
|
||||||
...result,
|
battle: result.battle,
|
||||||
message: result.targetDefeated
|
log: result.log,
|
||||||
? "Enemy eliminated!"
|
status: result.status,
|
||||||
: `Strike successful. Dealt ${result.damageDealt} damage.`,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (result.status === "defeat") {
|
||||||
|
dungeonManager.leaveDungeon(userId);
|
||||||
|
socket.emit("dungeon:failed", { message: "Neural link severed." });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
socket.emit("error", { message: "Synchronization error" });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("dungeon:next_room", async () => {
|
socket.on("dungeon:next_room", async () => {
|
||||||
try {
|
try {
|
||||||
const nextRoom = dungeonManager.moveToNextRoom(userId);
|
if (!userId) return;
|
||||||
|
const nextRoom = await dungeonManager.moveToNextRoom(userId);
|
||||||
if (!nextRoom)
|
if (!nextRoom)
|
||||||
return socket.emit("error", { message: "Navigation error" });
|
return socket.emit("error", { message: "Navigation error" });
|
||||||
|
|
||||||
@ -57,7 +70,7 @@ module.exports = (io, socket) => {
|
|||||||
socket.emit("dungeon:room_update", nextRoom);
|
socket.emit("dungeon:room_update", nextRoom);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
socket.emit("error", { message: "Navigation system error" });
|
socket.emit("error", { message: "Navigation error" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -90,13 +103,16 @@ async function finalizeDungeon(socket, sessionRewards) {
|
|||||||
await invItem.increment("quantity", { by: totalCount });
|
await invItem.increment("quantity", { by: totalCount });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Оновлюємо масив для фронтенда, щоб не було дублікатів у списку
|
||||||
sessionRewards.items = Object.entries(consolidated).map(
|
sessionRewards.items = Object.entries(consolidated).map(
|
||||||
([id, count]) => ({ id, count }),
|
([id, count]) => ({ id, count }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("FINAL REWARDS SAVED:", sessionRewards);
|
||||||
socket.emit("dungeon:completed", { rewards: sessionRewards });
|
socket.emit("dungeon:completed", { rewards: sessionRewards });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
socket.emit("error", { message: "Failed to save rewards" });
|
socket.emit("error", { message: "Failed to save rewards" });
|
||||||
} finally {
|
} finally {
|
||||||
dungeonManager.leaveDungeon(userId);
|
dungeonManager.leaveDungeon(userId);
|
||||||
|
|||||||
89
game-server/src/sockets/handlers/questsHandler.js
Normal file
89
game-server/src/sockets/handlers/questsHandler.js
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
const questsManager = require("../../game/QuestsManager");
|
||||||
|
const DatapackLoader = require("../../game/DatapackLoader");
|
||||||
|
const { PlayerQuest } = require("../../models");
|
||||||
|
|
||||||
|
module.exports = (io, socket) => {
|
||||||
|
const playerId = socket.user?.id;
|
||||||
|
|
||||||
|
socket.on("quest:get_list", async () => {
|
||||||
|
if (!playerId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const autoQuests = DatapackLoader.getAutoStartQuests();
|
||||||
|
const existingQuests = await PlayerQuest.findAll({
|
||||||
|
where: { playerId },
|
||||||
|
attributes: ["questId"],
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingIds = existingQuests.map((q) => q.questId);
|
||||||
|
const missingQuests = autoQuests.filter(
|
||||||
|
(aq) => !existingIds.includes(aq.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missingQuests.length > 0) {
|
||||||
|
const toCreate = missingQuests.map((aq) => ({
|
||||||
|
playerId,
|
||||||
|
questId: aq.id,
|
||||||
|
status: "active",
|
||||||
|
progress: aq.objectives.map((obj) => ({ ...obj, currentAmount: 0 })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await PlayerQuest.bulkCreate(toCreate, { ignoreDuplicates: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = await PlayerQuest.findAll({
|
||||||
|
where: { playerId },
|
||||||
|
order: [["updatedAt", "DESC"]],
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.emit(
|
||||||
|
"quest:list_data",
|
||||||
|
all.map((q) => ({
|
||||||
|
id: q.questId,
|
||||||
|
status: q.status,
|
||||||
|
objectives: q.progress,
|
||||||
|
rewards: DatapackLoader.getQuest(q.questId)?.rewards,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Quest sync error:", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("quest:claim_reward", async ({ questId }) => {
|
||||||
|
try {
|
||||||
|
const result = await questsManager.claimRewards(playerId, questId);
|
||||||
|
|
||||||
|
socket.emit("player:credits_update", {
|
||||||
|
totalCredits: result.newTotalCredits,
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.emit("quest:reward_claimed", {
|
||||||
|
questId,
|
||||||
|
rewards: result.rewards,
|
||||||
|
});
|
||||||
|
|
||||||
|
const all = await PlayerQuest.findAll({
|
||||||
|
where: { playerId },
|
||||||
|
order: [["updatedAt", "DESC"]],
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.emit(
|
||||||
|
"quest:list_data",
|
||||||
|
all.map((q) => ({
|
||||||
|
id: q.questId,
|
||||||
|
status: q.status,
|
||||||
|
objectives: q.progress,
|
||||||
|
rewards: DatapackLoader.getQuest(q.questId)?.rewards,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const msg =
|
||||||
|
err.message === "QUEST_NOT_READY_OR_CLAIMED"
|
||||||
|
? "Reward already claimed or objective not met."
|
||||||
|
: "Failed to claim reward.";
|
||||||
|
socket.emit("error", { message: msg });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -7,6 +7,7 @@ const dungeonHandler = require("./handlers/dungeonHandler");
|
|||||||
const chatHandler = require("./handlers/chatHandler");
|
const chatHandler = require("./handlers/chatHandler");
|
||||||
const notificationHandler = require("./handlers/notificationHandler");
|
const notificationHandler = require("./handlers/notificationHandler");
|
||||||
const socialHandler = require("./handlers/socialHandler");
|
const socialHandler = require("./handlers/socialHandler");
|
||||||
|
const questsHandler = require("./handlers/questsHandler");
|
||||||
|
|
||||||
const initSockets = (io) => {
|
const initSockets = (io) => {
|
||||||
io.use(socketAuth);
|
io.use(socketAuth);
|
||||||
@ -20,6 +21,7 @@ const initSockets = (io) => {
|
|||||||
chatHandler(io, socket);
|
chatHandler(io, socket);
|
||||||
socialHandler(io, socket);
|
socialHandler(io, socket);
|
||||||
notificationHandler(io, socket);
|
notificationHandler(io, socket);
|
||||||
|
questsHandler(io, socket);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user