crafting system
This commit is contained in:
parent
aefa586335
commit
f41eeb0e08
@ -11,7 +11,6 @@ const CraftingTab = () => {
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [recipes, setRecipes] = useState([]);
|
||||
const [userInventory, setUserInventory] = useState([]);
|
||||
|
||||
const [activeCategory, setActiveCategory] = useState("");
|
||||
const [selectedRecipe, setSelectedRecipe] = useState(null);
|
||||
const [activeCraft, setActiveCraft] = useState(null);
|
||||
@ -19,7 +18,6 @@ const CraftingTab = () => {
|
||||
useEffect(() => {
|
||||
const manifestCategories = GameDataManager.getRecipeCategories();
|
||||
setCategories(manifestCategories);
|
||||
|
||||
if (manifestCategories.length > 0 && !activeCategory) {
|
||||
setActiveCategory(manifestCategories[0].id);
|
||||
}
|
||||
@ -29,7 +27,6 @@ const CraftingTab = () => {
|
||||
if (activeCategory) {
|
||||
const filteredRecipes =
|
||||
GameDataManager.getRecipesByCategory(activeCategory);
|
||||
console.log(filteredRecipes);
|
||||
setRecipes(filteredRecipes);
|
||||
}
|
||||
}, [activeCategory]);
|
||||
@ -38,30 +35,67 @@ const CraftingTab = () => {
|
||||
if (!socket) return;
|
||||
|
||||
socket.emit("player:get_inventory");
|
||||
socket.emit("player:check_active_craft");
|
||||
|
||||
const handleInventory = (data) => setUserInventory(data);
|
||||
|
||||
const handleCraftStarted = (data) => {
|
||||
const recipeData = GameDataManager.getRecipe(data.recipeId);
|
||||
const now = Date.now();
|
||||
const diff = (data.finishAt - now) / 1000;
|
||||
|
||||
if (diff <= 0) {
|
||||
setActiveCraft(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveCraft({
|
||||
recipeId: data.recipeId,
|
||||
name: recipeData?.displayName || data.recipeId,
|
||||
finishAt: data.finishAt,
|
||||
totalTime: data.totalTime || recipeData?.time_seconds || diff,
|
||||
timeLeft: Math.max(0, Math.ceil(diff)),
|
||||
});
|
||||
|
||||
if (recipeData) {
|
||||
setSelectedRecipe(recipeData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCraftSuccess = () => {
|
||||
setActiveCraft(null);
|
||||
setSelectedRecipe(null);
|
||||
socket.emit("player:get_inventory");
|
||||
};
|
||||
|
||||
socket.on("player:inventory_data", handleInventory);
|
||||
socket.on("player:craft_started", handleCraftStarted);
|
||||
socket.on("player:craft_success", handleCraftSuccess);
|
||||
|
||||
return () => {
|
||||
socket.off("player:inventory_data", handleInventory);
|
||||
socket.off("player:craft_started", handleCraftStarted);
|
||||
socket.off("player:craft_success", handleCraftSuccess);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
useEffect(() => {
|
||||
let timer;
|
||||
if (activeCraft && activeCraft.timeLeft > 0) {
|
||||
timer = setInterval(() => {
|
||||
setActiveCraft((prev) => ({
|
||||
...prev,
|
||||
timeLeft: Math.max(0, prev.timeLeft - 1),
|
||||
}));
|
||||
}, 1000);
|
||||
} else if (activeCraft && activeCraft.timeLeft === 0) {
|
||||
setActiveCraft(null);
|
||||
socket.emit("player:get_inventory");
|
||||
}
|
||||
if (!activeCraft) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const diff = Math.max(0, Math.ceil((activeCraft.finishAt - now) / 1000));
|
||||
|
||||
if (diff <= 0) {
|
||||
clearInterval(timer);
|
||||
setActiveCraft(null);
|
||||
} else {
|
||||
setActiveCraft((prev) => (prev ? { ...prev, timeLeft: diff } : null));
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [activeCraft, socket]);
|
||||
}, [activeCraft?.finishAt]);
|
||||
|
||||
const getOwnedAmount = (itemId) => {
|
||||
const item = userInventory.find((i) => (i.itemId || i.id) === itemId);
|
||||
@ -70,19 +104,7 @@ const CraftingTab = () => {
|
||||
|
||||
const handleStartCrafting = (recipe) => {
|
||||
if (activeCraft) return;
|
||||
|
||||
socket.emit("player:craft_item", {
|
||||
recipeId: recipe.id,
|
||||
category: activeCategory,
|
||||
});
|
||||
|
||||
setActiveCraft({
|
||||
name: recipe.displayName,
|
||||
timeLeft: recipe.constructionTime,
|
||||
totalTime: recipe.constructionTime,
|
||||
});
|
||||
|
||||
setSelectedRecipe(null);
|
||||
socket.emit("player:craft_item", { recipeId: recipe.id });
|
||||
};
|
||||
|
||||
return (
|
||||
@ -101,7 +123,7 @@ const CraftingTab = () => {
|
||||
<div
|
||||
className="progress-bar-fill"
|
||||
style={{
|
||||
width: `${((activeCraft.totalTime - activeCraft.timeLeft) / activeCraft.totalTime) * 100}%`,
|
||||
width: `${Math.min(100, ((activeCraft.totalTime - activeCraft.timeLeft) / activeCraft.totalTime) * 100)}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
@ -122,6 +144,7 @@ const CraftingTab = () => {
|
||||
|
||||
<div className="crafting-grid">
|
||||
{recipes.map((recipe) => {
|
||||
const isThisRecipeCrafting = activeCraft?.recipeId === recipe.id;
|
||||
const canCraft = recipe.ingredients.every(
|
||||
(ing) => getOwnedAmount(ing.itemId) >= ing.quantity,
|
||||
);
|
||||
@ -129,51 +152,40 @@ const CraftingTab = () => {
|
||||
return (
|
||||
<div
|
||||
key={recipe.id}
|
||||
className={`recipe-card ${!canCraft ? "insufficient-resources" : ""}`}
|
||||
className={`recipe-card ${!canCraft && !isThisRecipeCrafting ? "insufficient-resources" : ""} ${isThisRecipeCrafting ? "crafting-active" : ""}`}
|
||||
onClick={() => setSelectedRecipe(recipe)}
|
||||
>
|
||||
<div className="recipe-icon">
|
||||
{recipe.texture ? (
|
||||
<img src={recipe.texture} alt={recipe.displayName} />
|
||||
) : (
|
||||
<div className="fallback-icon">
|
||||
{recipe.id[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="fallback-icon">{recipe.displayName[0]}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="recipe-info-main">
|
||||
<span className="recipe-name">{recipe.displayName}</span>
|
||||
<div className="recipe-badges">
|
||||
<span className="badge-time">
|
||||
<i className="fas fa-clock"></i> {recipe.constructionTime}
|
||||
s
|
||||
<i className="fas fa-clock"></i> {recipe.time_seconds}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canCraft && (
|
||||
<div className="lock-overlay">
|
||||
<i className="fas fa-lock"></i>
|
||||
{isThisRecipeCrafting && (
|
||||
<div className="craft-overlay-mini">
|
||||
<i className="fas fa-sync fa-spin"></i>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{recipes.length === 0 && (
|
||||
<div className="empty-category">
|
||||
No blueprints available in this sector.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CraftModal
|
||||
recipe={selectedRecipe}
|
||||
onClose={() => setSelectedRecipe(null)}
|
||||
onClose={() => !activeCraft && setSelectedRecipe(null)}
|
||||
onStartCraft={handleStartCrafting}
|
||||
isBusy={!!activeCraft}
|
||||
activeCraft={activeCraft}
|
||||
getOwnedAmount={getOwnedAmount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -5,15 +5,13 @@ const CraftModal = ({
|
||||
recipe,
|
||||
onClose,
|
||||
onStartCraft,
|
||||
isBusy,
|
||||
activeCraft,
|
||||
getOwnedAmount,
|
||||
}) => {
|
||||
if (!recipe) return null;
|
||||
|
||||
const displayName = recipe.resultItem?.name || recipe.name || recipe.id;
|
||||
const craftTime = recipe.constructionTime || recipe.time || 0;
|
||||
|
||||
// Перевірка, чи вистачає всіх ресурсів для крафту
|
||||
const isBusy = !!activeCraft;
|
||||
const outputQty = Object.values(recipe.output || {})[0] || 1;
|
||||
const canAfford = recipe.ingredients?.every(
|
||||
(ing) => getOwnedAmount(ing.itemId) >= ing.quantity,
|
||||
);
|
||||
@ -23,7 +21,7 @@ const CraftModal = ({
|
||||
<div className="craft-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>
|
||||
<i className="fas fa-tools"></i> Construction: {displayName}
|
||||
<i className="fas fa-tools"></i> Construction: {recipe.displayName}
|
||||
</h3>
|
||||
<button className="close-x" onClick={onClose}>
|
||||
×
|
||||
@ -36,35 +34,32 @@ const CraftModal = ({
|
||||
<i className="fas fa-list-ul"></i> Required Resources
|
||||
</h4>
|
||||
<div className="res-grid">
|
||||
{recipe.ingredients &&
|
||||
recipe.ingredients.map((ing) => {
|
||||
const owned = getOwnedAmount(ing.itemId);
|
||||
const hasEnough = owned >= ing.quantity;
|
||||
{recipe.ingredients?.map((ing) => {
|
||||
const owned = getOwnedAmount(ing.itemId);
|
||||
const hasEnough = owned >= ing.quantity;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ing.itemId}
|
||||
className={`res-item ${hasEnough ? "enough" : "not-enough"}`}
|
||||
>
|
||||
<div className="res-main-info">
|
||||
<i
|
||||
className={`fas fa-cube ${hasEnough ? "icon-green" : "icon-red"}`}
|
||||
></i>
|
||||
<span className="res-name">
|
||||
{ing.name || ing.itemId.replace("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="res-quantity-info">
|
||||
<span
|
||||
className={`owned-val ${hasEnough ? "val-green" : "val-red"}`}
|
||||
>
|
||||
{owned}
|
||||
</span>
|
||||
<span className="required-val"> / {ing.quantity}</span>
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
key={ing.itemId}
|
||||
className={`res-item ${hasEnough ? "enough" : "not-enough"}`}
|
||||
>
|
||||
<div className="res-main-info">
|
||||
<i
|
||||
className={`fas fa-cube ${hasEnough ? "icon-green" : "icon-red"}`}
|
||||
></i>
|
||||
<span className="res-name">{ing.displayName}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="res-quantity-info">
|
||||
<span
|
||||
className={`owned-val ${hasEnough ? "val-green" : "val-red"}`}
|
||||
>
|
||||
{owned}
|
||||
</span>
|
||||
<span className="required-val"> / {ing.quantity}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -76,32 +71,50 @@ const CraftModal = ({
|
||||
<div className="outcome-row">
|
||||
<span>Result:</span>
|
||||
<strong>
|
||||
{displayName} x{recipe.result?.quantity || 1}
|
||||
{recipe.displayName} x{outputQty}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="outcome-row">
|
||||
<span>Time:</span>
|
||||
<strong>{craftTime}s</strong>
|
||||
<strong>{recipe.time_seconds}s</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
className={`btn-start-craft ${!canAfford || isBusy ? "disabled" : ""}`}
|
||||
onClick={() => canAfford && !isBusy && onStartCraft(recipe)}
|
||||
disabled={!canAfford || isBusy}
|
||||
>
|
||||
{isBusy
|
||||
? "System Busy..."
|
||||
: !canAfford
|
||||
? "Insufficient Resources"
|
||||
: `Start Construction (${craftTime}s)`}
|
||||
</button>
|
||||
<button className="btn-cancel" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
{activeCraft && activeCraft.recipeId === recipe.id ? (
|
||||
<div className="modal-progress-container">
|
||||
<div className="progress-text">
|
||||
Processing... {activeCraft.timeLeft}s
|
||||
</div>
|
||||
<div className="progress-bar-bg">
|
||||
<div
|
||||
className="progress-bar-fill"
|
||||
style={{
|
||||
width: `${((activeCraft.totalTime - activeCraft.timeLeft) / activeCraft.totalTime) * 100}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className={`btn-start-craft ${!canAfford || isBusy ? "disabled" : ""}`}
|
||||
onClick={() => canAfford && !isBusy && onStartCraft(recipe)}
|
||||
disabled={!canAfford || isBusy}
|
||||
>
|
||||
{isBusy
|
||||
? "System Busy..."
|
||||
: !canAfford
|
||||
? "Insufficient Resources"
|
||||
: `Start Construction (${recipe.time_seconds}s)`}
|
||||
</button>
|
||||
<button className="btn-cancel" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,13 +1,10 @@
|
||||
{
|
||||
"recipe": {
|
||||
"inputs": [
|
||||
{ "original:ingot_iron": 1 },
|
||||
{ "original:ore_coal": 5 }
|
||||
],
|
||||
"inputs": [{ "original:ingot_iron": 1 }, { "original:ore_coal": 5 }],
|
||||
"output": {
|
||||
"original:alloy_steel": 1
|
||||
},
|
||||
"time_seconds": 180,
|
||||
"time_seconds": 10,
|
||||
"requires": {
|
||||
"original:alloying": 0
|
||||
}
|
||||
|
||||
71
game-server/src/game/CraftManager.js
Normal file
71
game-server/src/game/CraftManager.js
Normal file
@ -0,0 +1,71 @@
|
||||
const datapackLoader = require("../game/DatapackLoader");
|
||||
const { Inventory } = require("../models");
|
||||
|
||||
class CraftManager {
|
||||
constructor() {
|
||||
this.activeCrafts = new Map();
|
||||
}
|
||||
|
||||
async startCraft(userId, recipeId, socket) {
|
||||
if (this.activeCrafts.has(userId)) return { error: "Already crafting" };
|
||||
|
||||
const recipe = datapackLoader.getRecipe(recipeId);
|
||||
if (!recipe) return { error: "Recipe not found" };
|
||||
|
||||
const craftTimeMs = (recipe.time_seconds || 0) * 1000;
|
||||
const finishAt = Date.now() + craftTimeMs;
|
||||
|
||||
const craftData = {
|
||||
recipeId,
|
||||
finishAt,
|
||||
totalTime: recipe.time_seconds,
|
||||
timer: setTimeout(
|
||||
() => this.completeCraft(userId, recipeId, socket),
|
||||
craftTimeMs,
|
||||
),
|
||||
};
|
||||
|
||||
this.activeCrafts.set(userId, craftData);
|
||||
return { recipeId, finishAt, totalTime: recipe.time_seconds };
|
||||
}
|
||||
|
||||
async completeCraft(userId, recipeId, socket) {
|
||||
try {
|
||||
const recipe = datapackLoader.getRecipe(recipeId);
|
||||
const outputItemId = Object.keys(recipe.output)[0];
|
||||
const outputQuantity = recipe.output[outputItemId];
|
||||
|
||||
const [newItem, created] = await Inventory.findOrCreate({
|
||||
where: { playerId: userId, itemId: outputItemId },
|
||||
defaults: { quantity: outputQuantity },
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
await newItem.increment("quantity", { by: outputQuantity });
|
||||
}
|
||||
|
||||
this.activeCrafts.delete(userId);
|
||||
|
||||
if (socket && socket.connected) {
|
||||
socket.emit("player:craft_success", { recipeId });
|
||||
socket.emit("player:get_inventory");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Complete craft error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
getExistingCraft(userId) {
|
||||
const craft = this.activeCrafts.get(userId);
|
||||
if (craft && craft.finishAt > Date.now()) {
|
||||
return {
|
||||
recipeId: craft.recipeId,
|
||||
finishAt: craft.finishAt,
|
||||
totalTime: craft.totalTime,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CraftManager();
|
||||
@ -73,7 +73,22 @@ class DatapackLoader {
|
||||
`🚀 Registry Ready: ${this.registry.items.size} Items, ${this.registry.dungeons.size} Dungeons, ${this.registry.languages.size} Langs, ${manifestCount} Manifests`,
|
||||
);
|
||||
}
|
||||
getRecipe(id) {
|
||||
return this.registry.recipes.get(id);
|
||||
}
|
||||
|
||||
getRecipesByCategory(category) {
|
||||
const allRecipes = Array.from(this.registry.recipes.values());
|
||||
return allRecipes.filter((r) => r.category === category);
|
||||
}
|
||||
|
||||
getRecipeCategories() {
|
||||
const allRecipes = Array.from(this.registry.recipes.values());
|
||||
const categories = new Set(
|
||||
allRecipes.map((r) => r.category).filter(Boolean),
|
||||
);
|
||||
return Array.from(categories);
|
||||
}
|
||||
loadLanguages(langPath) {
|
||||
try {
|
||||
const files = fs.readdirSync(langPath).filter((f) => f.endsWith(".json"));
|
||||
|
||||
@ -1,80 +1,89 @@
|
||||
const datapackLoader = require("../../game/DatapackLoader");
|
||||
const { Inventory } = require("../../models");
|
||||
const craftManager = require("../../game/CraftManager");
|
||||
|
||||
module.exports = (io, socket) => {
|
||||
const userId = socket.user?.id;
|
||||
|
||||
const sendStatus = () => {
|
||||
const existing = craftManager.getExistingCraft(userId);
|
||||
if (existing) {
|
||||
socket.emit("player:craft_started", existing);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on("player:check_active_craft", () => {
|
||||
sendStatus();
|
||||
});
|
||||
|
||||
socket.on("player:get_recipe_categories", () => {
|
||||
try {
|
||||
const categories = datapackLoader.getRecipeCategories();
|
||||
socket.emit("player:recipe_categories_data", categories);
|
||||
} catch (err) {
|
||||
console.error("Error getting categories:", err.message);
|
||||
console.error(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("player:get_recipes", ({ category }) => {
|
||||
try {
|
||||
if (!category) return;
|
||||
|
||||
const rawRecipes = datapackLoader.getRecipesByCategory(category);
|
||||
|
||||
const recipeIds = rawRecipes.map((r) => r.id);
|
||||
|
||||
socket.emit("player:recipes_data", {
|
||||
category,
|
||||
recipeIds: recipeIds,
|
||||
});
|
||||
socket.emit("player:recipes_data", { category, recipeIds });
|
||||
} catch (err) {
|
||||
console.error("Error fetching recipes:", err.message);
|
||||
console.error(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("player:craft_item", async ({ recipeId, category }) => {
|
||||
socket.on("player:craft_item", async ({ recipeId }) => {
|
||||
try {
|
||||
const recipe = datapackLoader.getRecipe(category, recipeId);
|
||||
if (craftManager.getExistingCraft(userId)) {
|
||||
return socket.emit("error", { message: "Already crafting" });
|
||||
}
|
||||
|
||||
const recipe = datapackLoader.getRecipe(recipeId);
|
||||
if (!recipe) return socket.emit("error", { message: "Recipe not found" });
|
||||
|
||||
for (const ing of recipe.ingredients) {
|
||||
for (const ing of recipe.inputs) {
|
||||
const itemId = Object.keys(ing)[0];
|
||||
const quantity = ing[itemId];
|
||||
const invItem = await Inventory.findOne({
|
||||
where: { playerId: userId, itemId: ing.itemId },
|
||||
where: { playerId: userId, itemId: itemId },
|
||||
});
|
||||
|
||||
if (!invItem || invItem.quantity < ing.quantity) {
|
||||
return socket.emit("error", {
|
||||
message: `Недостатньо ресурсів для ${recipeId}`,
|
||||
});
|
||||
if (!invItem || invItem.quantity < quantity) {
|
||||
return socket.emit("error", { message: `Not enough resources` });
|
||||
}
|
||||
}
|
||||
|
||||
for (const ing of recipe.ingredients) {
|
||||
for (const ing of recipe.inputs) {
|
||||
const itemId = Object.keys(ing)[0];
|
||||
const quantity = ing[itemId];
|
||||
const invItem = await Inventory.findOne({
|
||||
where: { playerId: userId, itemId: ing.itemId },
|
||||
where: { playerId: userId, itemId: itemId },
|
||||
});
|
||||
|
||||
if (invItem.quantity === ing.quantity) {
|
||||
if (invItem.quantity === quantity) {
|
||||
await invItem.destroy();
|
||||
} else {
|
||||
await invItem.decrement("quantity", { by: ing.quantity });
|
||||
await invItem.decrement("quantity", { by: quantity });
|
||||
}
|
||||
}
|
||||
|
||||
const [newItem, created] = await Inventory.findOrCreate({
|
||||
where: { playerId: userId, itemId: recipe.id },
|
||||
defaults: { quantity: 1 },
|
||||
});
|
||||
const result = await craftManager.startCraft(userId, recipeId, socket);
|
||||
|
||||
if (!created) {
|
||||
await newItem.increment("quantity", { by: 1 });
|
||||
if (result.error) {
|
||||
return socket.emit("error", { message: result.error });
|
||||
}
|
||||
|
||||
socket.emit("player:craft_success", { recipeId });
|
||||
|
||||
socket.emit("player:craft_started", result);
|
||||
socket.emit("player:get_inventory");
|
||||
} catch (err) {
|
||||
console.error("Crafting error:", err.message);
|
||||
console.error(err.message);
|
||||
socket.emit("error", { message: "Internal Crafting Error" });
|
||||
}
|
||||
});
|
||||
|
||||
sendStatus();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user