crafting system

This commit is contained in:
MaksSlyzar 2026-03-29 11:04:42 +03:00
parent aefa586335
commit f41eeb0e08
6 changed files with 250 additions and 133 deletions

View File

@ -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>

View File

@ -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}>
&times;
@ -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>

View File

@ -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
}

View File

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

View File

@ -73,7 +73,22 @@ class DatapackLoader {
`🚀 Registry Ready: ${this.registry.items.size} Items, ${this.registry.dungeons.size} Dungeons, ${this.registry.languages.size} Langs, ${manifestCount} Manifests`,
);
}
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"));

View File

@ -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();
};