This commit is contained in:
2026-09-10 10:59:31 +03:00
commit 6c5c717e32
28 changed files with 1059 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
# Godot 4.x
.godot/
.idea/
sdk/
bin/
obj/
#ai
.omo/
.opencode/
gen/
obj/
bin/
*.tres~
*.tscn~
# GDScript LSP
.godot/
# Export
*.exe
*.dmg
*.apk
*.aab
*.pck
*.zip
*.log
# OS
.DS_Store
Thumbs.db
# Editor
*.import
*.godot.uid
Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

+5
View File
@@ -0,0 +1,5 @@
{
"Items": [
${recipe/wolf_meat_to_ration}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+9
View File
@@ -0,0 +1,9 @@
{
"Id": "recipe/wolf_meat_to_ration",
"Name": "items/recipe/wolf_meat_to_ration/name",
"Description": "items/recipe/wolf_meat_to_ration/desc",
"Cost": 1,
"Sprite": "recipe/cooking_recipe",
"ItemType": "Recipe,Used",
"ItemAction": "action/recipe/wolf_meat_to_ration"
}
+20
View File
@@ -0,0 +1,20 @@
mod_cooking_name = Cooking
mod_cooking_desc = Cooking mechanic — full autonomous mod
ui/cook/cook = Cook
ui/cook/close = Close
ui/cook/title = Food preparation
ui/cook/recipe_time = {0} minutes
ui/cook/recipe_out = Cooking result: {0}
ui/diner/rationcount = Rations: {0}
ui/diner/rationday = Daily consumption: {0}
ui/diner/rationmax = Supplies will last {0} days
items/food/ration/name = Ration
items/food/ration/desc = A daily ration unit for one person
items/food/wolf_meat/name = Wolf cutlet
items/food/wolf_meat/desc = Raw wolf meat
items/resource/branches/name = Branches
items/resource/branches/desc = Fuel for a campfire
recipe/wolf_meat_to_ration/name = Ration from wolf meat
recipe/wolf_meat_to_ration/desc = A recipe describing how to properly cook wolf meat to make it safe to eat
mod_cooking/rations_per_character = Rations per person per day
+20
View File
@@ -0,0 +1,20 @@
mod_cooking_name = Готовка
mod_cooking_desc = Механика готовки — полностью автономный мод
ui/cook/cook = Готовить
ui/cook/close = Выйти
ui/cook/title = Приготовление еды
ui/cook/recipe_time = {0} минут
ui/cook/recipe_out = Результат приготовления: {0}
ui/diner/rationcount = Пайков: {0}
ui/diner/rationday = Потребление в сутки: {0}
ui/diner/rationmax = Запасов хватит на {0} дней
items/food/ration/name = Рацион
items/food/ration/desc = Единица суточного рациона для одного человека
items/food/wolf_meat/name = Волчья вырезка
items/food/wolf_meat/desc = Сырое мясо волка
items/resource/branches/name = Ветки
items/resource/branches/desc = Топливо для костра
recipe/wolf_meat_to_ration/name = Рацион из волчьего мяса
recipe/wolf_meat_to_ration/desc = Рецепт описывает как правильно приготовить волчатину, чтобы безопасно употребить её в пищу
mod_cooking/rations_per_character = Рационов на персонажа в сутки
+3
View File
@@ -0,0 +1,3 @@
rooms/kitchen/prompt = A room equipped as a kitchen
rooms/kitchen/opt_exit = Leave
rooms/kitchen/opt_cook = Cook
+34
View File
@@ -0,0 +1,34 @@
{
"id": "cooking",
"name": "Cooking",
"version": "1.0.0",
"author": "ct",
"description": "Cooking mechanic - full autonomous mod",
"sources": [
"src/*.cs"
],
"depends": [ "core>=1", "interface>=1" ],
"subscriptions": [
{
"scene": "home_kitchen",
"template": "default",
"code": "cooking_panel_init",
"trigger": "on_enter",
"priority": 10
},
{
"scene": "home_kitchen",
"template": "default",
"code": "dinner_tools_show",
"trigger": "on_enter",
"priority": 20
},
{
"scene": "home_kitchen",
"template": "default",
"code": "dinner_tools_hide",
"trigger": "on_exit",
"priority": 10
}
]
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+27
View File
@@ -0,0 +1,27 @@
{
"Items": [
{
"Id": "recipe/wolf_meat_to_ration",
"Name": "recipe/wolf_meat_to_ration/name",
"Description": "recipe/wolf_meat_to_ration/desc",
"Type": "Cooking",
"Time": 15,
"Input": [
{
"Id": "food/wolf_meat",
"Count": 1
},
{
"Id": "resource/branches",
"Count": 2
}
],
"Output": [
{
"Id": "food/ration",
"Count": 1
}
]
}
]
}
@@ -0,0 +1,15 @@
scene: home_kitchen
priority: 5
---
light_set [{"x":0.43052286,"y":0.5760054,"radius":500,"influence":1,"color":"#ffff00"},{"x":0.103702,"y":0.26946074,"radius":500,"influence":1,"color":"#ffff00"}]
select "Комната оборудованная под кухню" key=rooms/kitchen/prompt
option "Выйти" key=rooms/kitchen/opt_exit -> :exit
option "Готовить" key=rooms/kitchen/opt_cook -> :cook
:exit
switch_scene home_vestibule
end
:cook
action toggle_cooking_workbench
end
+69
View File
@@ -0,0 +1,69 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/ui/workbench/WorkbenchPanel.cs" id="1_panel"]
[ext_resource type="Texture2D" path="res://backgrounds/cook_table.png" id="2_bg"]
[node name="CookingWorkbenchPanel" type="Control" script=ExtResource("1_panel")]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="BgImage" type="TextureRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
offset_right = 0.0
mouse_filter = 2
expand_mode = 1
stretch_mode = 4
texture = ExtResource("2_bg")
[node name="HBox" type="HBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
theme_override_constants/separation = 10
[node name="LeftPanel" type="VBoxContainer" parent="HBox"]
layout_mode = 2
size_flags_horizontal = 3
custom_minimum_size = Vector2(400, 0)
[node name="LeftTitle" type="Label" parent="HBox/LeftPanel"]
layout_mode = 2
[node name="Scroll" type="ScrollContainer" parent="HBox/LeftPanel"]
layout_mode = 2
size_flags_vertical = 3
horizontal_scroll_mode = 3
[node name="Content" type="VBoxContainer" parent="HBox/LeftPanel/Scroll"]
layout_mode = 2
[node name="RightPanel" type="VBoxContainer" parent="HBox"]
layout_mode = 2
size_flags_horizontal = 3
custom_minimum_size = Vector2(300, 0)
[node name="TxtDescription" type="RichTextLabel" parent="HBox/RightPanel"]
layout_mode = 2
mouse_filter = 2
bbcode_enabled = true
scroll_active = true
custom_minimum_size = Vector2(0, 100)
size_flags_vertical = 3
[node name="BtnCook" type="Button" parent="HBox/RightPanel"]
layout_mode = 2
[node name="BtnClose" type="Button" parent="HBox/RightPanel"]
layout_mode = 2
+18
View File
@@ -0,0 +1,18 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/ui/RationStatisticsPanel.cs" id="1_script"]
[node name="RationStatisticsPanel" type="Control" script=ExtResource("1_script")]
[node name="RationMargin" type="MarginContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/margin_left = 50
theme_override_constants/margin_top = 100
theme_override_constants/margin_bottom = 100
[node name="RationLabel" type="RichTextLabel" parent="RationMargin"]
+18
View File
@@ -0,0 +1,18 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/ui/workbench/RecipeIconItemBehaviour.cs" id="1_script"]
[node name="RecipeIconItem" type="Control" script=ExtResource("1_script")]
[node name="Img" type="TextureRect" parent="."]
custom_minimum_size = Vector2(32, 32)
layout_mode = 0
expand_mode = 1
stretch_mode = 5
[node name="Txt" type="Label" parent="."]
layout_mode = 0
position = Vector2(24, 20)
horizontal_alignment = 2
vertical_alignment = 2
theme_override_font_sizes/font_size = 10
+38
View File
@@ -0,0 +1,38 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/ui/workbench/RecipeItemBehaviour.cs" id="1_script"]
[node name="RecipeItem" type="Control" script=ExtResource("1_script")]
custom_minimum_size = Vector2(0, 30)
[node name="Background" type="Control" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="HBox" type="HBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="RecipeContent" type="Control" parent="HBox"]
custom_minimum_size = Vector2(100, 30)
[node name="RecipeContentHbox" type="HBoxContainer" parent="HBox/RecipeContent"]
[node name="TxtName" type="Label" parent="HBox"]
size_flags_horizontal = 3
theme_override_colors/font_color = Color(1, 1, 0, 1)
theme_override_font_sizes/font_size = 12
[node name="TxtTime" type="Label" parent="HBox"]
custom_minimum_size = Vector2(40, 30)
theme_override_colors/font_color = Color(1, 0, 0, 1)
theme_override_font_sizes/font_size = 12
Binary file not shown.
+56
View File
@@ -0,0 +1,56 @@
using System.Collections.Generic;
using Cthangover.Core.Mods;
using Cthangover.Core.Settings;
namespace Cthangover.Cooking
{
/// <summary>
/// Exposes a user-configurable setting for the number of rations
/// consumed per character per in-game day. Registered automatically
/// via <see cref="ModSettingsRegistry"/> when the cooking mod
/// assembly loads.
///
/// The static <see cref="RationsPerCharacter"/> property is the
/// canonical source of truth shared with <c>RationStatisticsPanel</c>
/// and any future consumption logic. It is updated by the core on
/// boot (from persisted JSON) and on every UI change.
/// </summary>
public class CookingSettings : IModSettings
{
/// <summary>
/// Current value for rations consumed per character per day.
/// Default is 1. Updated by <see cref="ReadValues"/> on boot
/// and whenever the player changes the setting in the menu.
/// </summary>
public static int RationsPerCharacter { get; private set; } = 1;
public IReadOnlyList<ModSettingDefinition> GetDefinitions()
{
return new List<ModSettingDefinition>
{
new()
{
Key = "rations_per_character",
Name = "mod_cooking/rations_per_character",
Type = SettingType.Slider,
DefaultValue = "1",
Min = 1f,
Max = 10f,
Step = 1f,
},
};
}
public void WriteValues(DataBlob blob)
{
blob.SetInt("rations_per_character", RationsPerCharacter);
}
public void ReadValues(DataBlob blob)
{
RationsPerCharacter = blob.GetInt("rations_per_character", 1);
if (RationsPerCharacter < 1)
RationsPerCharacter = 1;
}
}
}
+64
View File
@@ -0,0 +1,64 @@
using Cthangover.Core.Relationship;
using Cthangover.Core.Settings;
namespace Cthangover.Core.Relationship
{
/// <summary>
/// Tracks nutrition and starvation for party recruits.
/// Works by recording the tick when a recruit last ate (<c>FullnessTime</c>),
/// then each tick computes the elapsed time as a percentage of a 24-hour window.
/// Once the fullness window expires (100%+), the recruit loses 1 HP per tick
/// until fed again via a ration or meal action.
/// Registered with the <see cref="Recruit"/> behaviour pipeline and called
/// by the time system on each global tick.
/// </summary>
public class NutritionBehaviour : IRecruitBehaviour
{
/// <summary>
/// Unique behaviour identifier used to look up and configure this
/// behaviour from <see cref="Recruit"/>'s behaviour collection.
/// </summary>
public string Id => "nutrition";
/// <summary>
/// Called when the behaviour is first attached to a recruit.
/// Seeds <c>FullnessTime</c> with the current global tick so that
/// starve-damage begins counting from this point.
/// </summary>
public void ConfigureRecruit(Recruit recruit, RuntimeData runtime)
{
recruit.Properties.SetLong("FullnessTime", runtime.Time.Tick);
}
/// <summary>
/// Evaluates whether the recruit has gone beyond the fullness window
/// (24 in-game hours, represented as <c>24 * 4 * 60</c> ticks) since
/// <c>FullnessTime</c>. If the window is exceeded, reduces the
/// recruit's <c>PROP_HEALTH</c> by 1 per tick until they are fed.
/// Tick-driven starvation allows gradual death when rations run out.
/// </summary>
public void OnTick(Recruit recruit, RuntimeData runtime, long currentTick)
{
var fullnessTime = recruit.Properties.GetLong("FullnessTime");
if (fullnessTime == 0)
return;
var windowSize = 24 * 4 * 60L;
var deltaTime = currentTick - fullnessTime;
var fullness = (float)deltaTime / windowSize * 100f;
if (fullness > 100)
{
var hp = recruit.Properties.GetInt(Recruit.PROP_HEALTH);
if (hp > 0)
recruit.Properties.SetInt(Recruit.PROP_HEALTH, hp - 1);
}
}
/// <summary>
/// Cleanup hook invoked when the behaviour is detached from a recruit.
/// No persistent state requires teardown; exists for interface contract.
/// </summary>
public void OnRemove(Recruit recruit, RuntimeData runtime)
{
}
}
}
+37
View File
@@ -0,0 +1,37 @@
using Cthangover.Core.Settings;
namespace Cthangover.Core.Items.Actions
{
/// <summary>
/// Performs the "Wolf Meat to Ration" recipe-unlock action.
/// When a player uses raw wolf meat, this action checks whether the
/// <c>recipe/wolf_meat_to_ration</c> flag is already present in
/// <see cref="RecipeData"/>; if not, it adds it, permanently unlocking
/// the recipe in the <see cref="Mods.Cooking.Workbench.WorkbenchPanel"/>.
/// Returns <c>false</c> when already unlocked (no-op) and <c>true</c>
/// on first unlock.
/// </summary>
public class WolfMeatToRation : IItemAction
{
/// <summary>
/// Globally unique action identifier, matched by the item system
/// to trigger this action when an item with this ID is used.
/// </summary>
public string ID => "action/recipe/wolf_meat_to_ration";
/// <summary>
/// Idempotently unlocks the wolf-meat-to-ration recipe.
/// Adds <c>recipe/wolf_meat_to_ration</c> to <see cref="RecipeData"/>
/// so that the cooking workbench can list it.
/// </summary>
/// <returns><c>true</c> if the recipe was newly unlocked; <c>false</c> if already known.</returns>
public bool UseAction(IItem item)
{
var data = GameData.Instance.Runtime.RecipeData;
if (data.Has("recipe/wolf_meat_to_ration"))
return false;
data.Add("recipe/wolf_meat_to_ration");
return true;
}
}
}
+64
View File
@@ -0,0 +1,64 @@
using Cthangover.Cooking;
using System;
using Cthangover.Core.Settings;
using Cthangover.Core.UI;
using Godot;
namespace Mods.Cooking.Rations
{
/// <summary>
/// Displays live ration statistics on the dinner/ration UI screen.
/// Shows the player the current ration count, how many rations are
/// consumed daily (one per character), and the maximum number of days
/// the party can survive on available rations. Listens to
/// <see cref="Core.Items.InventoryBag.Change"/> events to refresh
/// automatically whenever the inventory is modified.
/// </summary>
public class RationStatisticsPanel : Widget
{
private RichTextLabel label;
private Action<string, int> _onInventoryChange;
protected override void OnceConstruct()
{
label = GetNode<RichTextLabel>("RationMargin/RationLabel");
_onInventoryChange = (_, _) => UpdateInfo();
GameData.Instance.Runtime.Inventory.Change += _onInventoryChange;
}
protected override void ShowConstruct()
{
UpdateInfo();
}
protected override void OnceDestruct()
{
GameData.Instance.Runtime.Inventory.Change -= _onInventoryChange;
}
/// <summary>
/// Recomputes and refreshes the ration-statistics text.
/// Reads current ration inventory via <see cref="Core.Items.InventoryBag.CheckCount"/>
/// for <c>"food/ration"</c> and the total character count from
/// <see cref="CharacterData"/>. Formats three localized lines:
/// total rations, daily consumption, and remaining days
/// (rations divided by character count, clamped to 1 minimum).
/// </summary>
public void UpdateInfo()
{
if (label == null)
return;
var rationCount = GameData.Instance.Runtime.Inventory.CheckCount("food/ration");
var characterCount = GameData.Instance.Runtime.CharacterData.Characters.Count;
var perChar = CookingSettings.RationsPerCharacter;
var dailyConsumption = characterCount * perChar;
label.Text = string.Format(TranslationServer.Translate("ui/diner/rationcount"), rationCount) +
"\n" + string.Format(TranslationServer.Translate("ui/diner/rationday"), dailyConsumption) +
"\n" + string.Format(TranslationServer.Translate("ui/diner/rationmax"), rationCount / Mathf.Max(1, dailyConsumption));
}
}
}
@@ -0,0 +1,69 @@
using Cthangover.Core.Items;
using Cthangover.Core.Settings;
using Cthangover.Core.UI;
using Cthangover.Core.Utils;
using Godot;
namespace Mods.Cooking.Workbench
{
/// <summary>
/// Renders a single ingredient row inside a <see cref="RecipeItemBehaviour"/>
/// on the cooking workbench. Displays the ingredient's item icon and the
/// required count. When <see cref="CheckAndUpdate"/> is called, compares
/// inventory count against the recipe requirement and tints the icon and
/// count text grey if the player lacks enough items.
/// </summary>
public class RecipeIconItemBehaviour : Widget
{
private static readonly Color NormalColor = Colors.White;
private static readonly Color NotItemsColor = Colors.Gray;
private TextureRect img;
private Label txt;
private IIngredient ingredient;
protected override void OnceConstruct()
{
img = GetNode<TextureRect>("Img");
txt = GetNode<Label>("Txt");
}
/// <summary>
/// Binds this icon widget to a recipe ingredient definition.
/// Sets the icon texture from <c>ingredient.Item.Sprite</c> (logs an error
/// if the item reference is null) and displays the required count as text.
/// The <paramref name="hasItems"/> flag is currently unused but reserved
/// for future visual pre-initialization.
/// </summary>
public void Init(IIngredient ingredient, bool hasItems)
{
EnsureConstructed();
this.ingredient = ingredient;
if (ingredient.Item != null)
{
img.Texture = ingredient.Item.Sprite;
}
else
{
GameLogger.Log("MOD_TEST", $"RecipeIconItemBehaviour::Init ingredient.Item is NULL", LogLevel.Error);
}
txt.Text = ingredient.Count.ToString();
}
/// <summary>
/// Evaluates whether the player's inventory contains enough of the
/// required ingredient. Tints the icon and count text to
/// <c>NormalColor</c> (white) when satisfied or <c>NotItemsColor</c>
/// (grey) when insufficient. Called by the parent
/// <see cref="RecipeItemBehaviour"/> during state refresh.
/// </summary>
/// <returns><c>true</c> if inventory meets or exceeds the required count.</returns>
public bool CheckAndUpdate()
{
var result = GameData.Instance.Runtime.Inventory.CheckCount(ingredient.Item) >= ingredient.Count;
txt.AddThemeColorOverride("font_color", result ? NormalColor : NotItemsColor);
img.Modulate = result ? Colors.White : NotItemsColor;
return result;
}
}
}
+185
View File
@@ -0,0 +1,185 @@
using System.Collections.Generic;
using Cthangover.Core.Items;
using Cthangover.Core.Scenes;
using Cthangover.Core.Settings;
using Cthangover.Core.UI;
using Godot;
namespace Mods.Cooking.Workbench
{
/// <summary>
/// Represents a single recipe entry in the <see cref="WorkbenchPanel"/> list.
/// Displays the recipe name, preparation time, and a horizontal row of
/// ingredient icons. Supports click-to-select with visual highlight
/// (tracked statically so only one recipe is selected at a time).
/// Fires <see cref="RecipeClicked"/> to notify the parent panel of
/// selection changes. Ingredient availability is checked via child
/// <see cref="RecipeIconItemBehaviour"/> instances, greying out the
/// entire row when any ingredient is missing.
/// </summary>
public class RecipeItemBehaviour : Widget
{
private Control background;
private Control recipeContent;
private Label txtName;
private Label txtTime;
private HBoxContainer recipeContentHbox;
private static readonly Color normalColor = new(1f, 1f, 1f, 1f);
private static readonly Color selectedColor = new(0.8f, 0.8f, 0.8f, 1f);
private static readonly Color disableColor = new(0.5f, 0.5f, 0.5f, 1f);
private IRecipe recipe;
private Node panel;
private static RecipeItemBehaviour selected;
/// <summary>
/// The recipe currently selected across all <see cref="RecipeItemBehaviour"/>
/// instances. Only one recipe can be selected at a time because the
/// selection is tracked via a static backing field. Returns <c>null</c>
/// when no recipe is selected.
/// </summary>
public static IRecipe SelectedRecipe => selected?.recipe;
private readonly List<Node> items = new();
private readonly List<RecipeIconItemBehaviour> ingredients = new();
/// <summary>
/// Fired when this recipe entry is clicked, passing the recipe data
/// and this widget so that <see cref="WorkbenchPanel"/> can update
/// the description and output preview panel.
/// </summary>
public event System.Action<IRecipe, RecipeItemBehaviour> RecipeClicked;
protected override void OnceConstruct()
{
background = GetNode<Control>("Background");
recipeContentHbox = GetNode<HBoxContainer>("HBox/RecipeContent/RecipeContentHbox");
recipeContent = recipeContentHbox.GetParent() as Control;
txtName = GetNode<Label>("HBox/TxtName");
txtTime = GetNode<Label>("HBox/TxtTime");
GuiInput += OnGuiInput;
}
protected override void OnceDestruct()
{
GuiInput -= OnGuiInput;
}
private void OnGuiInput(InputEvent @event)
{
if (@event is InputEventMouseButton mouseButton && mouseButton.Pressed && mouseButton.ButtonIndex == MouseButton.Left)
{
OnClick();
}
}
/// <summary>
/// Refreshes the visual availability state of this recipe.
/// Iterates all child <see cref="RecipeIconItemBehaviour"/> widgets,
/// calling <c>CheckAndUpdate</c> on each. If any ingredient is missing,
/// tints the name and time labels with <c>disableColor</c> (greyed out);
/// otherwise restores the default yellow/red colour scheme.
/// Call after inventory changes or cooking operations.
/// </summary>
public void UpdateState()
{
var hasItems = true;
foreach (var ingredient in ingredients)
if (!ingredient.CheckAndUpdate())
hasItems = false;
if (!hasItems)
{
txtName.AddThemeColorOverride("font_color", disableColor);
txtTime.AddThemeColorOverride("font_color", disableColor);
}
else
{
txtName.AddThemeColorOverride("font_color", Colors.Yellow);
txtTime.AddThemeColorOverride("font_color", Colors.Red);
}
}
/// <summary>
/// Initialises the recipe row with recipe data and the parent panel
/// reference. Spawns <see cref="RecipeIconItemBehaviour"/> children
/// for each ingredient, with plus-sign separators between them.
/// Sets the translated recipe name and time, then calls
/// <see cref="UpdateState"/> to set initial colour state.
/// </summary>
public void Init(IRecipe recipe, Node panel)
{
EnsureConstructed();
this.recipe = recipe;
this.panel = panel;
for (int i = 0; i < recipe.Input.Count; i++)
{
var ingredient = recipe.Input[i];
AddIngredient(ingredient, i != recipe.Input.Count - 1);
}
txtName.Text = TranslationServer.Translate(recipe.Name);
txtTime.Text = string.Format(TranslationServer.Translate("ui/cook/recipe_time"), recipe.Time);
UpdateState();
}
private void AddIngredient(IIngredient ingredient, bool addPlus)
{
var inventory = GameData.Instance.Runtime.Inventory;
var item = (RecipeIconItemBehaviour)TscnScenes.LoadAndBuild("scenes/recipe_icon_item.tscn");
recipeContentHbox.AddChild(item);
var localHasItems = inventory.CheckCount(ingredient.Item) < ingredient.Count;
item.Init(ingredient, localHasItems);
items.Add(item);
ingredients.Add(item);
if (addPlus)
{
var plus = new Label();
plus.Text = "+";
plus.AddThemeFontSizeOverride("font_size", 12);
plus.VerticalAlignment = VerticalAlignment.Center;
recipeContentHbox.AddChild(plus);
items.Add(plus);
}
}
/// <summary>
/// Tears down this recipe row by queue-freeing all child nodes
/// (ingredient icons, plus labels, and this widget itself),
/// then clears the internal ingredient/item lists.
/// Called by <see cref="WorkbenchPanel"/> when the panel is hidden.
/// </summary>
public void Destroy()
{
foreach (var item in items)
item.QueueFree();
QueueFree();
ingredients.Clear();
items.Clear();
}
/// <summary>
/// Handles left-click selection of this recipe entry.
/// Deselects the previously-selected entry (if any) by restoring its
/// background to normal colour, highlights the background of this
/// entry, updates the static <c>selected</c> tracker, and invokes
/// <see cref="RecipeClicked"/> so the parent can update the
/// description and output panel.
/// </summary>
public void OnClick()
{
if (selected != null)
selected.background.Modulate = normalColor;
background.Modulate = selectedColor;
selected = this;
RecipeClicked?.Invoke(recipe, this);
}
}
}
@@ -0,0 +1,64 @@
using Cthangover.Core.Scenes;
using Cthangover.Core.Actions.Atomic;
using Cthangover.Core.Actions;
using Cthangover.Core.Utils;
using Godot;
namespace Mods.Cooking.Workbench
{
/// <summary>
/// Scenario action registered under <c>toggle_cooking_workbench</c>
/// that toggles the cooking workbench UI panel on the scenario screen.
/// Called from scenario script directives via the action system.
/// Locates the <c>ModLastPanel</c> root node in the scene tree,
/// finds the <c>CookingWorkbenchPanel</c> child (a <see cref="WorkbenchPanel"/>),
/// and calls <c>Switch()</c> to show or hide it. No-op if the root
/// or panel node is missing.
/// </summary>
public class ToggleCookingPanelAction : IScenarioAction
{
/// <summary>
/// Unique action name used in scenario script commands
/// (e.g. <c>action toggle_cooking_workbench</c>).
/// </summary>
public string Name => "toggle_cooking_workbench";
/// <summary>
/// Executes the toggle: finds the <c>CookingWorkbenchPanel</c>
/// under <c>ModLastPanel</c> and calls <c>Switch()</c> to
/// alternate its visibility state.
/// </summary>
public void Run(IActionContext ctx)
{
GameLogger.Log("COOKING", "ToggleCookingPanelAction.Run: looking for CookingWorkbenchPanel...");
var root = SceneContextNode.FindNode<Control>("ModLastPanel");
if (root == null)
{
GameLogger.Log("COOKING", "ToggleCookingPanelAction.Run: ModLastPanel NOT FOUND", LogLevel.Error);
return;
}
var panel = root.GetNodeOrNull<WorkbenchPanel>("CookingWorkbenchPanel");
if (panel == null)
{
GameLogger.Log("COOKING", "ToggleCookingPanelAction.Run: creating panel via TscnScenes.LoadAndBuild...");
var loaded = TscnScenes.LoadAndBuild("scenes/cooking_panel.tscn");
if (loaded == null)
{
GameLogger.Log("COOKING", "ToggleCookingPanelAction.Run: LoadAndBuild returned NULL", LogLevel.Error);
return;
}
var control = loaded as Control;
if (control == null) return;
control.Name = "CookingWorkbenchPanel";
control.Visible = false;
control.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
root.AddChild(control);
panel = control as WorkbenchPanel;
}
GameLogger.Log("COOKING", $"ToggleCookingPanelAction.Run: switching panel (currently Visible={panel?.Visible})");
panel?.Switch();
}
}
}
+169
View File
@@ -0,0 +1,169 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Audio;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Items;
using Cthangover.Core.Scenes;
using Cthangover.Core.Settings;
using Cthangover.Core.UI;
using Cthangover.Core.UI.Tool;
using Cthangover.Core.UI.Inventory;
using Godot;
namespace Mods.Cooking.Workbench
{
/// <summary>
/// Main cooking workbench UI panel that lists available recipes,
/// shows descriptions and output previews, and performs the actual
/// cooking operation. Recipes are loaded from <see cref="RecipeData"/>
/// filtered by <see cref="WorkbenchType.Cooking"/> and sorted so that
/// craftable recipes (all ingredients present) appear before
/// un-craftable ones. Selecting a recipe displays its translated
/// description and output items via an <see cref="InventoryBagBehaviour"/>.
/// Cooking consumes ingredients, produces output items, advances
/// in-game time, and plays a sound effect.
/// </summary>
public class WorkbenchPanel : Widget
{
private Control content;
private RichTextLabel txtDescription;
private InventoryBagBehaviour outputContainer;
private List<RecipeItemBehaviour> recipes = new();
protected override void OnceConstruct()
{
MouseFilter = MouseFilterEnum.Ignore;
var leftTitle = GetNode<Label>("HBox/LeftPanel/LeftTitle");
leftTitle.Text = TranslationServer.Translate("ui/cook/title");
leftTitle.AddThemeFontSizeOverride("font_size", 16);
content = GetNode<VBoxContainer>("HBox/LeftPanel/Scroll/Content");
txtDescription = GetNode<RichTextLabel>("HBox/RightPanel/TxtDescription");
outputContainer = new InventoryBagBehaviour();
outputContainer.SizeFlagsVertical = SizeFlags.ExpandFill;
outputContainer.Visible = false;
GetNode("HBox/RightPanel").AddChild(outputContainer);
var btnCook = GetNode<Button>("HBox/RightPanel/BtnCook");
btnCook.Text = TranslationServer.Translate("ui/cook/cook");
btnCook.Pressed += OnCookClick;
var btnClose = GetNode<Button>("HBox/RightPanel/BtnClose");
btnClose.Text = TranslationServer.Translate("ui/cook/close");
btnClose.Pressed += OnCloseClick;
}
protected override void ShowConstruct()
{
foreach (var recipe in GetSortedList())
AddItem(recipe);
}
protected override void HideDestruct()
{
foreach (var recipeItem in recipes)
if (recipeItem != null)
recipeItem.QueueFree();
recipes.Clear();
}
private void AddItem(IRecipe recipe)
{
var item = (RecipeItemBehaviour)TscnScenes.LoadAndBuild("scenes/recipe_item.tscn");
if (item != null && content != null)
{
content.AddChild(item);
item.Init(recipe, this);
item.RecipeClicked += ClickRecipe;
recipes.Add(item);
}
}
private List<IRecipe> GetSortedList()
{
var list = GameData.Instance.Runtime.RecipeData.GetRecipesByType(WorkbenchType.Cooking);
var inventory = GameData.Instance.Runtime.Inventory;
list.Sort((o1, o2) =>
{
var hasNotItems1 = o1.Input.Any(o => inventory.CheckCount(o.Item) < o.Count);
var hasNotItems2 = o2.Input.Any(o => inventory.CheckCount(o.Item) < o.Count);
if (hasNotItems1 == hasNotItems2)
return string.Compare(o1.ID, o2.ID, StringComparison.Ordinal);
return hasNotItems1 ? 1 : -1;
});
return list;
}
/// <summary>
/// Responds to a recipe being selected in the list.
/// Updates the description area with the recipe's translated name
/// (styled bold/yellow via BBCode) and description, and populates
/// the output preview panel with items the recipe produces by
/// wrapping each output into an <see cref="ItemContainer"/>.
/// </summary>
public void ClickRecipe(IRecipe recipe, RecipeItemBehaviour recipeItemBehaviour)
{
if (txtDescription != null)
txtDescription.Text = "[b][color=yellow]" + TranslationServer.Translate(recipe.Name) + "[/color][/b]\n\n" +
TranslationServer.Translate(recipe.Description);
outputContainer.List = recipe.Output.Select(o => (IItemContainer)new ItemContainer
{
Item = o.Item,
Count = o.Count,
}).ToList();
}
/// <summary>
/// Hides the workbench panel and plays a UI close-click sound
/// via the scene's <see cref="AudioService"/> node.
/// </summary>
public void OnCloseClick()
{
Hide();
var audioService = SceneContextNode.FindNode<AudioService>("AudioService");
audioService?.PlaySound("ui/close_click", SoundType.UI);
}
/// <summary>
/// Executes the cooking operation for the currently selected recipe.
/// Validates that the player has enough ingredients in inventory
/// (short-circuits if not). Deducts input items, adds output items,
/// advances in-game time by the recipe's duration, updates the
/// on-screen time display, plays the cooking sound effect,
/// and refreshes all recipe rows so their availability colours
/// reflect the new inventory state.
/// </summary>
public void OnCookClick()
{
var inventory = GameData.Instance.Runtime.Inventory;
var receipt = RecipeItemBehaviour.SelectedRecipe;
if (receipt == null)
return;
var hasItems = !receipt.Input.Any(o => inventory.CheckCount(o.Item) < o.Count);
if (!hasItems)
return;
foreach (var item in receipt.Input)
inventory.Remove(item.Item, item.Count);
foreach (var item in receipt.Output)
inventory.Add(item.Item, item.Count);
GameData.Instance.Runtime.Time.AddTime(0, 0, 0, 0, receipt.Time);
var timer = SceneContextNode.FindNode<TimeController>("TimeController");
timer?.UpdateRenderedTime();
var audioService = SceneContextNode.FindNode<AudioService>("AudioService");
audioService?.PlaySound("ui/cook", SoundType.UI);
foreach (var recipe in recipes)
recipe.UpdateState();
}
}
}
+15
View File
@@ -0,0 +1,15 @@
var modPanel = FindNode<Control>("ModLastPanel");
if (modPanel == null)
return;
if (modPanel.GetNodeOrNull("CookingWorkbenchPanel") != null)
return;
var panel = Cthangover.Core.Scenes.TscnScenes.LoadAndBuild("scenes/cooking_panel.tscn");
if (panel is Control control)
{
control.Name = "CookingWorkbenchPanel";
control.Visible = false;
control.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
modPanel.AddChild(control);
}
+7
View File
@@ -0,0 +1,7 @@
var modPanel = FindNode<Control>("Lastground");
if (modPanel == null)
return;
var dinner = modPanel.GetNodeOrNull<Mods.Cooking.Rations.RationStatisticsPanel>("RationStatistics");
if (dinner != null)
dinner.QueueFree();
+16
View File
@@ -0,0 +1,16 @@
var modPanel = FindNode<Control>("Lastground");
if (modPanel == null)
return;
var dinner = modPanel.GetNodeOrNull<Mods.Cooking.Rations.RationStatisticsPanel>("RationStatistics");
if (dinner == null)
{
dinner = (Mods.Cooking.Rations.RationStatisticsPanel)Cthangover.Core.Scenes.TscnScenes.LoadAndBuild("scenes/ration_statistics_panel.tscn");
dinner.Name = "RationStatistics";
dinner.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
dinner.CustomMinimumSize = new Vector2(300, 300);
modPanel.AddChild(dinner);
dinner.Show();
modPanel.UpdateMinimumSize();
}