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

View File

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

View File

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

View File

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

View File

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

View File

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