From 7e70471227475566c82a80873cc6448ba9a37228 Mon Sep 17 00:00:00 2001 From: Nicola Date: Sat, 9 May 2026 21:25:36 +0200 Subject: [PATCH] Added testing and save/load mechanic to the game. Game is now entering final phase. --- Scenes/Game.tscn | 10 + Scenes/MainMenu.tscn | 7 + Scenes/TestRunner.tscn | 6 + Scripts/Core/GameData.cs | 33 ++ Scripts/Core/ResourceLoader.cs | 16 +- Scripts/Core/SaveGameData.cs | 87 +++++ Scripts/Core/SaveGameData.cs.uid | 1 + Scripts/Core/SaveGameManager.cs | 357 ++++++++++++++++++++ Scripts/Core/SaveGameManager.cs.uid | 1 + Scripts/Gameplay/Crafting/GameResource.cs | 24 ++ Scripts/Gameplay/Crafting/Inventory.cs | 61 +++- Scripts/Gameplay/Robots/Robot.cs | 33 +- Scripts/Gameplay/Robots/RobotStats.cs | 14 +- Scripts/Tests/TestRunner.cs | 383 ++++++++++++++++++++++ Scripts/Tests/TestRunner.cs.uid | 1 + Scripts/UI/Common/UIHandler.cs | 13 + Scripts/UI/Menus/MainMenu.cs | 9 + Scripts/World/World.cs | 63 +++- 18 files changed, 1073 insertions(+), 46 deletions(-) create mode 100644 Scenes/TestRunner.tscn create mode 100644 Scripts/Core/SaveGameData.cs create mode 100644 Scripts/Core/SaveGameData.cs.uid create mode 100644 Scripts/Core/SaveGameManager.cs create mode 100644 Scripts/Core/SaveGameManager.cs.uid create mode 100644 Scripts/Tests/TestRunner.cs create mode 100644 Scripts/Tests/TestRunner.cs.uid diff --git a/Scenes/Game.tscn b/Scenes/Game.tscn index 8779244..5081da7 100644 --- a/Scenes/Game.tscn +++ b/Scenes/Game.tscn @@ -427,6 +427,14 @@ text = "Continue" layout_mode = 2 text = "Options" +[node name="SaveGame" type="Button" parent="CanvasLayer/UIHandler/MainUI/Content/Menu/VBoxContainer"] +layout_mode = 2 +text = "Save Game" + +[node name="LoadGame" type="Button" parent="CanvasLayer/UIHandler/MainUI/Content/Menu/VBoxContainer"] +layout_mode = 2 +text = "Load Game" + [node name="Button3" type="Button" parent="CanvasLayer/UIHandler/MainUI/Content/Menu/VBoxContainer" unique_id=2028306785] layout_mode = 2 text = "Exit" @@ -600,6 +608,8 @@ texture_normal = ExtResource("12_3so38") [connection signal="pressed" from="CanvasLayer/UIHandler/MainUI/Content/RobotList/VBoxContainer/Spawning/Button" to="CanvasLayer/UIHandler/MainUI/Content/RobotList" method="SpawnRobot"] [connection signal="pressed" from="CanvasLayer/UIHandler/MainUI/Content/Menu/VBoxContainer/Button" to="CanvasLayer/UIHandler" method="HandleMenu"] [connection signal="pressed" from="CanvasLayer/UIHandler/MainUI/Content/Menu/VBoxContainer/Button2" to="CanvasLayer/UIHandler" method="ShowOptions"] +[connection signal="pressed" from="CanvasLayer/UIHandler/MainUI/Content/Menu/VBoxContainer/SaveGame" to="CanvasLayer/UIHandler" method="SaveGame"] +[connection signal="pressed" from="CanvasLayer/UIHandler/MainUI/Content/Menu/VBoxContainer/LoadGame" to="CanvasLayer/UIHandler" method="LoadGame"] [connection signal="pressed" from="CanvasLayer/UIHandler/MainUI/Content/Menu/VBoxContainer/Button3" to="CanvasLayer/UIHandler" method="ExitGame"] [connection signal="pressed" from="CanvasLayer/UIHandler/MainUI/Content/GameOver/VBoxContainer/HBoxContainer/Negative" to="CanvasLayer/UIHandler" method="ExitGame"] [connection signal="pressed" from="CanvasLayer/UIHandler/MainUI/Content/GameOver/VBoxContainer/HBoxContainer/Positive" to="CanvasLayer/UIHandler" method="HideGameOver"] diff --git a/Scenes/MainMenu.tscn b/Scenes/MainMenu.tscn index 0e5e1bc..e876fd4 100644 --- a/Scenes/MainMenu.tscn +++ b/Scenes/MainMenu.tscn @@ -123,6 +123,12 @@ theme_override_styles/normal = SubResource("StyleBoxFlat_bnhvo") theme_override_styles/hover = SubResource("StyleBoxFlat_tt5f1") text = "Start Game" +[node name="btnLoad" type="Button" parent="CenterContainer/VBoxContainer"] +layout_mode = 2 +theme_override_styles/normal = SubResource("StyleBoxFlat_bnhvo") +theme_override_styles/hover = SubResource("StyleBoxFlat_tt5f1") +text = "Load Game" + [node name="btnOptions" type="Button" parent="CenterContainer/VBoxContainer" unique_id=891656915] layout_mode = 2 theme_override_styles/normal = SubResource("StyleBoxFlat_bnhvo") @@ -136,4 +142,5 @@ theme_override_styles/hover = SubResource("StyleBoxFlat_tt5f1") text = "Exit Game" [connection signal="button_up" from="CenterContainer/VBoxContainer/btnPlay" to="." method="OnPlayPressed"] +[connection signal="button_up" from="CenterContainer/VBoxContainer/btnLoad" to="." method="OnLoadPressed"] [connection signal="button_up" from="CenterContainer/VBoxContainer/btnExit" to="." method="OnQuitPressed"] diff --git a/Scenes/TestRunner.tscn b/Scenes/TestRunner.tscn new file mode 100644 index 0000000..67ff59e --- /dev/null +++ b/Scenes/TestRunner.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://Scripts/Tests/TestRunner.cs" id="1_test_runner"] + +[node name="TestRunner" type="Node"] +script = ExtResource("1_test_runner") diff --git a/Scripts/Core/GameData.cs b/Scripts/Core/GameData.cs index 7775937..f48420e 100644 --- a/Scripts/Core/GameData.cs +++ b/Scripts/Core/GameData.cs @@ -23,6 +23,7 @@ public partial class GameData public static SurvivalState survival = new SurvivalState(); public static RobotStats robotStats = new RobotStats(); public static Dictionary> gateUnlocks; + public static bool loadSaveOnStart = false; public static Color primaryColor = new Color("#276ac2"); public static Color lightColor = new Color("#7efff5"); @@ -32,4 +33,36 @@ public partial class GameData public static int seed = 12345; public static Inventory inventory = new Inventory(); + + public static void ResetRunState() + { + seed = 12345; + ruinSize = 10; + layerSize = 20; + rand = new Random(seed); + survival = new SurvivalState(); + robotStats = new RobotStats(); + inventory = new Inventory(); + availableResearch = ResourceLoader.LoadResearch(); + robots.Clear(); + currentLayer = 0; + visibleLayer = 0; + lowestLayer = 0; + maxRobotCount = 10; + canMove = true; + } + + public static void RebuildRobotStatsFromResearch() + { + robotStats = new RobotStats(); + maxRobotCount = 10; + + foreach (Research research in availableResearch.Values) + { + if (research.state == ResearchState.RESEARCHED) + { + robotStats.Apply(research.data.Effects); + } + } + } } diff --git a/Scripts/Core/ResourceLoader.cs b/Scripts/Core/ResourceLoader.cs index 650430b..32b8799 100644 --- a/Scripts/Core/ResourceLoader.cs +++ b/Scripts/Core/ResourceLoader.cs @@ -99,14 +99,14 @@ public partial class ResourceLoader { Dictionary weights = new Dictionary() { - { "iron_ore", [0.05f,1] }, - { "tin_ore", [0.3f,0.7f] }, - { "copper_ore", [0.3f,0.7f] }, - { "mushroom", [0.3f,0.1f] }, - { "spider_silk", [0.8f,0.4f] }, - { "coal", [1,0.3f] }, - { "water", [0.4f,0.2f] }, - { "stone", [1,0.5f] }, + { "iron_ore", new float[] { 0.05f, 1f } }, + { "tin_ore", new float[] { 0.3f, 0.7f } }, + { "copper_ore", new float[] { 0.3f, 0.7f } }, + { "mushroom", new float[] { 0.3f, 0.1f } }, + { "spider_silk", new float[] { 0.8f, 0.4f } }, + { "coal", new float[] { 1f, 0.3f } }, + { "water", new float[] { 0.4f, 0.2f } }, + { "stone", new float[] { 1f, 0.5f } }, }; return weights; } diff --git a/Scripts/Core/SaveGameData.cs b/Scripts/Core/SaveGameData.cs new file mode 100644 index 0000000..1ea0d79 --- /dev/null +++ b/Scripts/Core/SaveGameData.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; + +public class SaveGameData +{ + public int Seed { get; set; } + public int CurrentLayer { get; set; } + public int VisibleLayer { get; set; } + public int LowestLayer { get; set; } + public int MaxRobotCount { get; set; } + public bool CanMove { get; set; } + public SurvivalSaveData Survival { get; set; } + public List Inventory { get; set; } + public List Research { get; set; } + public List Layers { get; set; } + public List Robots { get; set; } +} + +public class SurvivalSaveData +{ + public float Hunger { get; set; } + public float Thirst { get; set; } + public float Energy { get; set; } + public bool IsDead { get; set; } + public string DeathReason { get; set; } + public string CurrentStatus { get; set; } +} + +public class ItemSaveData +{ + public string Id { get; set; } + public int Amount { get; set; } +} + +public class ResearchSaveData +{ + public string Id { get; set; } + public ResearchState State { get; set; } + public double ElapsedResearchTime { get; set; } + public bool PaidResources { get; set; } +} + +public class LayerSaveData +{ + public int Level { get; set; } + public bool IsGateOpen { get; set; } + public bool HasContentGenerated { get; set; } + public List GateIngredients { get; set; } + public List CurrentResources { get; set; } + public List Tiles { get; set; } +} + +public class TileSaveData +{ + public int X { get; set; } + public int Y { get; set; } + public string CollapsedMesh { get; set; } + public bool ContainsLight { get; set; } + public bool ContainsDecoration { get; set; } + public bool ContainsResource { get; set; } + public bool WasVisited { get; set; } + public ResourceSaveData Resource { get; set; } +} + +public class ResourceSaveData +{ + public string Name { get; set; } + public int CurrentAmount { get; set; } + public int MaxAmount { get; set; } + public bool IsEndless { get; set; } + public float ExtractionSpeed { get; set; } + public double TimeSinceLastExtraction { get; set; } +} + +public class RobotSaveData +{ + public string Name { get; set; } + public string CurrentProgram { get; set; } + public string CurrentMessage { get; set; } + public string RobotType { get; set; } + public float X { get; set; } + public float Y { get; set; } + public float Z { get; set; } + public float Heat { get; set; } + public float Maintenance { get; set; } + public bool IsCoolingDown { get; set; } + public bool IsBroken { get; set; } +} diff --git a/Scripts/Core/SaveGameData.cs.uid b/Scripts/Core/SaveGameData.cs.uid new file mode 100644 index 0000000..db093d8 --- /dev/null +++ b/Scripts/Core/SaveGameData.cs.uid @@ -0,0 +1 @@ +uid://k530yuk4xt1x diff --git a/Scripts/Core/SaveGameManager.cs b/Scripts/Core/SaveGameManager.cs new file mode 100644 index 0000000..3a7b155 --- /dev/null +++ b/Scripts/Core/SaveGameManager.cs @@ -0,0 +1,357 @@ +using Godot; +using System.Collections.Generic; +using System.Text.Json; + +public static class SaveGameManager +{ + private const string SaveDirectory = "user://savegame"; + private const string GameDataPath = SaveDirectory + "/gamedata.json"; + private const string RobotsPath = SaveDirectory + "/robots.json"; + private const string ResearchPath = SaveDirectory + "/research.json"; + private const string LayerPrefix = SaveDirectory + "/layer_"; + private const string JsonExtension = ".json"; + + private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + { + WriteIndented = true + }; + + public static bool SaveExists() + { + return FileAccess.FileExists(GameDataPath); + } + + public static void SaveGame() + { + SaveGameData saveGame = CreateSaveData(); + CreateSaveDirectory(); + ClearOldLayerFiles(); + + SaveJson(GameDataPath, CreateCoreSaveData(saveGame)); + SaveJson(RobotsPath, saveGame.Robots); + SaveJson(ResearchPath, saveGame.Research); + + foreach (LayerSaveData layer in saveGame.Layers) + { + SaveJson(GetLayerPath(layer.Level), layer); + } + } + + public static SaveGameData LoadSaveData() + { + if (!SaveExists()) return null; + + SaveGameData saveGame = LoadJson(GameDataPath); + if (saveGame == null) return null; + + saveGame.Robots = LoadJson>(RobotsPath) ?? new List(); + saveGame.Research = LoadJson>(ResearchPath) ?? new List(); + saveGame.Layers = LoadLayerSaveData(); + + return saveGame; + } + + public static SaveGameData CreateSaveData() + { + return new SaveGameData + { + Seed = GameData.seed, + CurrentLayer = GameData.currentLayer, + VisibleLayer = GameData.visibleLayer, + LowestLayer = GameData.lowestLayer, + MaxRobotCount = GameData.maxRobotCount, + CanMove = GameData.canMove, + Survival = CreateSurvivalSaveData(), + Inventory = CreateInventorySaveData(), + Research = CreateResearchSaveData(), + Layers = CreateLayerSaveData(), + Robots = CreateRobotSaveData() + }; + } + + public static void ApplyWorldData(SaveGameData saveGame) + { + if (saveGame == null) return; + + GameData.seed = saveGame.Seed; + GameData.currentLayer = saveGame.CurrentLayer; + GameData.visibleLayer = saveGame.VisibleLayer; + GameData.lowestLayer = saveGame.LowestLayer; + GameData.maxRobotCount = saveGame.MaxRobotCount; + GameData.canMove = saveGame.CanMove; + + ApplySurvivalData(saveGame.Survival); + ApplyInventoryData(saveGame.Inventory); + ApplyResearchData(saveGame.Research); + ApplyLayerData(saveGame.Layers); + } + + private static SurvivalSaveData CreateSurvivalSaveData() + { + return new SurvivalSaveData + { + Hunger = GameData.survival.hunger, + Thirst = GameData.survival.thirst, + Energy = GameData.survival.energy, + IsDead = GameData.survival.isDead, + DeathReason = GameData.survival.deathReason, + CurrentStatus = GameData.survival.currentStatus + }; + } + + private static List CreateInventorySaveData() + { + List result = new List(); + + foreach (Item item in GameData.inventory.items) + { + result.Add(new ItemSaveData + { + Id = item.data.Id, + Amount = item.currentAmount + }); + } + + return result; + } + + private static List CreateResearchSaveData() + { + List result = new List(); + + foreach (Research research in GameData.availableResearch.Values) + { + result.Add(new ResearchSaveData + { + Id = research.data.Id, + State = research.state, + ElapsedResearchTime = research.elapsedResearchTime, + PaidResources = research.paidResources + }); + } + + return result; + } + + private static List CreateLayerSaveData() + { + List result = new List(); + if (GameData.map == null) return result; + + foreach (Layer layer in GameData.map) + { + if (layer == null) continue; + + result.Add(new LayerSaveData + { + Level = layer.level, + IsGateOpen = layer.isGateOpen, + HasContentGenerated = layer.hasContentGenerated, + GateIngredients = new List(layer.gateIngredients), + CurrentResources = new List(layer.currentResources), + Tiles = CreateTileSaveData(layer) + }); + } + + return result; + } + + private static List CreateTileSaveData(Layer layer) + { + List result = new List(); + + foreach (Tile tile in layer.tiles) + { + result.Add(new TileSaveData + { + X = tile.GridPosition.X, + Y = tile.GridPosition.Y, + CollapsedMesh = tile.collapsedMesh, + ContainsLight = tile.containsLight, + ContainsDecoration = tile.containsDecoration, + ContainsResource = tile.containsResource, + WasVisited = tile.wasVisited, + Resource = tile.resource == null ? null : tile.resource.CreateSaveData() + }); + } + + return result; + } + + private static List CreateRobotSaveData() + { + List result = new List(); + + foreach (Robot robot in GameData.robots) + { + result.Add(robot.CreateSaveData()); + } + + return result; + } + + private static SaveGameData CreateCoreSaveData(SaveGameData saveGame) + { + return new SaveGameData + { + Seed = saveGame.Seed, + CurrentLayer = saveGame.CurrentLayer, + VisibleLayer = saveGame.VisibleLayer, + LowestLayer = saveGame.LowestLayer, + MaxRobotCount = saveGame.MaxRobotCount, + CanMove = saveGame.CanMove, + Survival = saveGame.Survival, + Inventory = saveGame.Inventory, + Research = new List(), + Layers = new List(), + Robots = new List() + }; + } + + private static List LoadLayerSaveData() + { + List result = new List(); + + for (int i = 0; i < GameData.ruinSize; i++) + { + string path = GetLayerPath(i); + if (!FileAccess.FileExists(path)) continue; + + LayerSaveData layer = LoadJson(path); + if (layer != null) + { + result.Add(layer); + } + } + + return result; + } + + private static string GetLayerPath(int level) + { + return LayerPrefix + level + JsonExtension; + } + + private static void CreateSaveDirectory() + { + DirAccess.MakeDirRecursiveAbsolute(SaveDirectory); + } + + private static void ClearOldLayerFiles() + { + DirAccess directory = DirAccess.Open(SaveDirectory); + if (directory == null) return; + + directory.ListDirBegin(); + while (true) + { + string fileName = directory.GetNext(); + if (fileName == "") break; + if (directory.CurrentIsDir()) continue; + if (!fileName.StartsWith("layer_") || !fileName.EndsWith(JsonExtension)) continue; + + directory.Remove(fileName); + } + directory.ListDirEnd(); + } + + private static void SaveJson(string path, T data) + { + string json = JsonSerializer.Serialize(data, JsonOptions); + FileAccess file = FileAccess.Open(path, FileAccess.ModeFlags.Write); + file.StoreString(json); + file.Flush(); + } + + private static T LoadJson(string path) + { + if (!FileAccess.FileExists(path)) return default; + + FileAccess file = FileAccess.Open(path, FileAccess.ModeFlags.Read); + string json = file.GetAsText(); + return JsonSerializer.Deserialize(json); + } + + private static void ApplySurvivalData(SurvivalSaveData survival) + { + if (survival == null) return; + + GameData.survival.hunger = survival.Hunger; + GameData.survival.thirst = survival.Thirst; + GameData.survival.energy = survival.Energy; + GameData.survival.isDead = survival.IsDead; + GameData.survival.deathReason = survival.DeathReason ?? ""; + GameData.survival.currentStatus = survival.CurrentStatus ?? ""; + } + + private static void ApplyInventoryData(List savedItems) + { + GameData.inventory = new Inventory(); + if (savedItems == null) return; + + foreach (ItemSaveData savedItem in savedItems) + { + if (!GameData.availableItems.ContainsKey(savedItem.Id)) continue; + + GameData.inventory.AddItem( + new Item { data = GameData.availableItems[savedItem.Id] }, + savedItem.Amount + ); + } + } + + private static void ApplyResearchData(List savedResearch) + { + if (savedResearch == null) return; + + foreach (ResearchSaveData savedState in savedResearch) + { + if (!GameData.availableResearch.ContainsKey(savedState.Id)) continue; + + Research research = GameData.availableResearch[savedState.Id]; + research.state = savedState.State; + research.elapsedResearchTime = savedState.ElapsedResearchTime; + research.paidResources = savedState.PaidResources; + } + + GameData.RebuildRobotStatsFromResearch(); + } + + private static void ApplyLayerData(List savedLayers) + { + if (savedLayers == null || GameData.map == null) return; + + foreach (LayerSaveData savedLayer in savedLayers) + { + if (savedLayer.Level < 0 || savedLayer.Level >= GameData.map.Length) continue; + + Layer layer = GameData.map[savedLayer.Level]; + layer.isGateOpen = savedLayer.IsGateOpen; + layer.hasContentGenerated = savedLayer.HasContentGenerated; + layer.gateIngredients = savedLayer.GateIngredients ?? new List(); + layer.currentResources = savedLayer.CurrentResources ?? new List(); + + ApplyTileData(layer, savedLayer.Tiles); + } + } + + private static void ApplyTileData(Layer layer, List savedTiles) + { + if (savedTiles == null) return; + + foreach (TileSaveData savedTile in savedTiles) + { + if (savedTile.X < 0 || savedTile.X >= GameData.layerSize) continue; + if (savedTile.Y < 0 || savedTile.Y >= GameData.layerSize) continue; + + Tile tile = layer.tiles[savedTile.X, savedTile.Y]; + tile.collapsedMesh = savedTile.CollapsedMesh; + tile.containsLight = savedTile.ContainsLight; + tile.containsDecoration = savedTile.ContainsDecoration; + tile.containsResource = savedTile.ContainsResource; + tile.wasVisited = savedTile.WasVisited; + tile.resource = savedTile.Resource == null ? null : GameResource.FromSaveData(savedTile.Resource); + tile.ContentNode.Visible = savedTile.WasVisited || tile.collapsedMesh == "gate"; + } + } +} diff --git a/Scripts/Core/SaveGameManager.cs.uid b/Scripts/Core/SaveGameManager.cs.uid new file mode 100644 index 0000000..2058ee2 --- /dev/null +++ b/Scripts/Core/SaveGameManager.cs.uid @@ -0,0 +1 @@ +uid://quury78jfutk diff --git a/Scripts/Gameplay/Crafting/GameResource.cs b/Scripts/Gameplay/Crafting/GameResource.cs index 1059ab8..a2f6618 100644 --- a/Scripts/Gameplay/Crafting/GameResource.cs +++ b/Scripts/Gameplay/Crafting/GameResource.cs @@ -19,6 +19,30 @@ public class GameResource item = GameData.availableItems[name]; } + public static GameResource FromSaveData(ResourceSaveData saveData) + { + GameResource resource = new GameResource(saveData.Name); + resource.currentAmount = saveData.CurrentAmount; + resource.maxAmount = saveData.MaxAmount; + resource.isEndless = saveData.IsEndless; + resource.extractionSpeed = saveData.ExtractionSpeed; + resource.timeSinceLastExtraction = saveData.TimeSinceLastExtraction; + return resource; + } + + public ResourceSaveData CreateSaveData() + { + return new ResourceSaveData + { + Name = name, + CurrentAmount = currentAmount, + MaxAmount = maxAmount, + IsEndless = isEndless, + ExtractionSpeed = extractionSpeed, + TimeSinceLastExtraction = timeSinceLastExtraction + }; + } + public bool Extract(double delta) { timeSinceLastExtraction += delta; diff --git a/Scripts/Gameplay/Crafting/Inventory.cs b/Scripts/Gameplay/Crafting/Inventory.cs index aa472d9..f9b7864 100644 --- a/Scripts/Gameplay/Crafting/Inventory.cs +++ b/Scripts/Gameplay/Crafting/Inventory.cs @@ -10,31 +10,49 @@ public class Inventory public bool AddItem(Item item, int amount) { - Item inventoryItem = items.Find(x => x.data.Id == item.data.Id && x.currentAmount + amount <= x.data.StackSize); - if (inventoryItem != null) + if (GetFreeCapacity(item.data.Id, item.data.StackSize) < amount) { - inventoryItem.currentAmount += amount; - NotifyInventoryChanged(); - return true; + return false; } - if (items.Count < maxInventorySize * GameData.maxRobotCount) + int remainingAmount = amount; + + foreach (Item inventoryItem in items) { - items.Add(item); - items[items.Count - 1].currentAmount += amount; - NotifyInventoryChanged(); - return true; + if (inventoryItem.data.Id != item.data.Id) continue; + if (inventoryItem.currentAmount >= inventoryItem.data.StackSize) continue; + + int amountToAdd = Math.Min( + remainingAmount, + inventoryItem.data.StackSize - inventoryItem.currentAmount + ); + + inventoryItem.currentAmount += amountToAdd; + remainingAmount -= amountToAdd; + + if (remainingAmount <= 0) + { + NotifyInventoryChanged(); + return true; + } } - return false; + while (remainingAmount > 0 && items.Count < maxInventorySize * GameData.maxRobotCount) + { + int amountToAdd = Math.Min(remainingAmount, item.data.StackSize); + items.Add(new Item { data = item.data, currentAmount = amountToAdd }); + remainingAmount -= amountToAdd; + } + + NotifyInventoryChanged(); + return remainingAmount <= 0; } public bool CanCraft(List neededIngredients, int amount) { foreach (Ingredient ingredient in neededIngredients) { - Item item = items.Find(x => x.data.Id == ingredient.Item && x.currentAmount >= ingredient.Amount * amount); - if (item == null) + if (GetItemAmount(ingredient.Item) < ingredient.Amount * amount) { return false; } @@ -93,4 +111,21 @@ public class Inventory { OnInventoryUpdate?.Invoke(this, EventArgs.Empty); } + + private int GetFreeCapacity(string id, int stackSize) + { + int freeCapacity = 0; + + foreach (Item item in items) + { + if (item.data.Id == id) + { + freeCapacity += stackSize - item.currentAmount; + } + } + + int freeSlots = maxInventorySize * GameData.maxRobotCount - items.Count; + freeCapacity += freeSlots * stackSize; + return freeCapacity; + } } diff --git a/Scripts/Gameplay/Robots/Robot.cs b/Scripts/Gameplay/Robots/Robot.cs index 8a5999f..280337d 100644 --- a/Scripts/Gameplay/Robots/Robot.cs +++ b/Scripts/Gameplay/Robots/Robot.cs @@ -155,6 +155,37 @@ public partial class Robot : Node3D currentMessage = ""; } + public RobotSaveData CreateSaveData() + { + return new RobotSaveData + { + Name = Name, + CurrentProgram = currentProgram, + CurrentMessage = currentMessage, + RobotType = robotType, + X = Position.X, + Y = Position.Y, + Z = Position.Z, + Heat = heat, + Maintenance = maintenance, + IsCoolingDown = isCoolingDown, + IsBroken = isBroken + }; + } + + public void LoadSaveData(RobotSaveData saveData) + { + Name = saveData.Name; + currentProgram = saveData.CurrentProgram; + currentMessage = saveData.CurrentMessage ?? ""; + robotType = saveData.RobotType ?? "stone_robot"; + Position = new Vector3(saveData.X, saveData.Y, saveData.Z); + heat = saveData.Heat; + maintenance = saveData.Maintenance; + isCoolingDown = saveData.IsCoolingDown; + isBroken = saveData.IsBroken; + } + private bool CanExecute(double delta) { if (GameData.survival.isDead) @@ -238,4 +269,4 @@ public partial class Robot : Node3D isCoolingDown = false; } } -} \ No newline at end of file +} diff --git a/Scripts/Gameplay/Robots/RobotStats.cs b/Scripts/Gameplay/Robots/RobotStats.cs index c0c5279..f4c1f1f 100644 --- a/Scripts/Gameplay/Robots/RobotStats.cs +++ b/Scripts/Gameplay/Robots/RobotStats.cs @@ -3,13 +3,13 @@ using System.Collections.Generic; public class RobotStats { - public readonly Dictionary RobotTypes = new() + public readonly Dictionary RobotTypes = new Dictionary { - ["stone_robot"] = new RobotTypeStats(0.75f, 0.60f, 0.80f, 0.80f, 1.40f, -0.10f), - ["copper_robot"] = new RobotTypeStats(1.00f, 1.00f, 1.00f, 1.00f, 1.00f, 0.00f), - ["tin_robot"] = new RobotTypeStats(1.00f, 1.00f, 1.00f, 1.00f, 1.00f, 0.00f), - ["bronze_robot"] = new RobotTypeStats(1.15f, 1.10f, 0.90f, 1.10f, 0.80f, 0.05f), - ["iron_robot"] = new RobotTypeStats(1.35f, 1.25f, 1.15f, 1.20f, 0.65f, 0.10f) + { "stone_robot", new RobotTypeStats(0.75f, 0.60f, 0.80f, 0.80f, 1.40f, -0.10f) }, + { "copper_robot", new RobotTypeStats(1.00f, 1.00f, 1.00f, 1.00f, 1.00f, 0.00f) }, + { "tin_robot", new RobotTypeStats(1.00f, 1.00f, 1.00f, 1.00f, 1.00f, 0.00f) }, + { "bronze_robot", new RobotTypeStats(1.15f, 1.10f, 0.90f, 1.10f, 0.80f, 0.05f) }, + { "iron_robot", new RobotTypeStats(1.35f, 1.25f, 1.15f, 1.20f, 0.65f, 0.10f) } }; private const float BaseMinimumEfficiency = 0.35f; @@ -86,7 +86,7 @@ public class RobotStats break; case "robot_count_increase": GameData.maxRobotCount += (int)effect.Value; - break; + break; } } } diff --git a/Scripts/Tests/TestRunner.cs b/Scripts/Tests/TestRunner.cs new file mode 100644 index 0000000..4b796e3 --- /dev/null +++ b/Scripts/Tests/TestRunner.cs @@ -0,0 +1,383 @@ +using Godot; +using System; +using System.Collections.Generic; + +public partial class TestRunner : Node +{ + private int passedTests = 0; + private int failedTests = 0; + + public override void _Ready() + { + Run("Inventory adds, stacks and removes items", TestInventoryStacksAndRemovesItems); + Run("Inventory crafting checks stacked totals", TestInventoryCanCraftAcrossStacks); + Run("Survival consumes stored food and water", TestSurvivalConsumesFoodAndWater); + Run("Survival death disables movement", TestSurvivalDeathDisablesMovement); + Run("Robot research effects change robot stats", TestRobotResearchEffects); + Run("Research completion applies effects once", TestResearchCompletionAppliesEffectsOnce); + Run("Research execution pays resources and finishes", TestResearchExecutionPaysResourcesAndFinishes); + Run("Inventory add failure keeps inventory unchanged", TestInventoryAddFailureKeepsInventoryUnchanged); + Run("Resource extraction and save data roundtrip", TestResourceSaveRoundtrip); + Run("Robot save data roundtrip keeps robot state", TestRobotSaveRoundtrip); + Run("Save data captures and restores global state", TestSaveDataRestoresGlobalState); + Run("Split save files store and load data", TestSplitSaveFilesRoundtrip); + Run("Split save files include one file per saved layer", TestSplitSaveFilesIncludeLayerFiles); + Run("If node evaluates inventory comparisons", TestIfNodeEvaluatesInventoryComparisons); + Run("While node reports false conditions", TestWhileNodeReportsFalseConditions); + Run("For node stops after configured amount", TestForNodeStopsAfterConfiguredAmount); + Run("Item data readable names are stable", TestItemDataReadableNames); + Run("Resource files load core game data", TestResourceFilesLoadCoreData); + + GD.Print($"Tests passed: {passedTests}, failed: {failedTests}"); + GetTree().Quit(failedTests == 0 ? 0 : 1); + } + + private void Run(string name, Action test) + { + try + { + GameData.ResetRunState(); + test(); + passedTests++; + GD.Print("[PASS] " + name); + } + catch (Exception exception) + { + failedTests++; + GD.PrintErr("[FAIL] " + name + ": " + exception.Message); + } + } + + private void TestInventoryStacksAndRemovesItems() + { + ItemData stone = GameData.availableItems["stone"]; + GameData.inventory.AddItem(new Item { data = stone }, stone.StackSize + 5); + + AssertEqual(stone.StackSize + 5, GameData.inventory.GetItemAmount("stone"), "stone amount"); + AssertEqual(2, GameData.inventory.items.Count, "stone stack count"); + + bool removed = GameData.inventory.TryRemoveItem("stone", stone.StackSize); + + AssertTrue(removed, "remove should succeed"); + AssertEqual(5, GameData.inventory.GetItemAmount("stone"), "remaining stone amount"); + } + + private void TestInventoryCanCraftAcrossStacks() + { + ItemData stone = GameData.availableItems["stone"]; + stone.StackSize = 5; + GameData.inventory.AddItem(new Item { data = stone }, 8); + + List ingredients = new List + { + new Ingredient { Item = "stone", Amount = 8 } + }; + + AssertTrue(GameData.inventory.CanCraft(ingredients, 1), "crafting should see both stacks"); + } + + private void TestSurvivalConsumesFoodAndWater() + { + GameData.inventory.AddItem(new Item { data = GameData.availableItems["mushroom"] }, 1); + GameData.inventory.AddItem(new Item { data = GameData.availableItems["water"] }, 1); + GameData.survival.hunger = 30f; + GameData.survival.thirst = 30f; + + GameData.survival.Update(0.1); + + AssertTrue(GameData.survival.hunger > 60f, "hunger should recover"); + AssertTrue(GameData.survival.thirst > 65f, "thirst should recover"); + AssertEqual(0, GameData.inventory.GetItemAmount("mushroom"), "mushroom consumed"); + AssertEqual(0, GameData.inventory.GetItemAmount("water"), "water consumed"); + } + + private void TestSurvivalDeathDisablesMovement() + { + GameData.canMove = true; + GameData.survival.energy = 0.01f; + + GameData.survival.Update(2.0); + + AssertTrue(GameData.survival.isDead, "survival should be dead"); + AssertFalse(GameData.canMove, "movement should be disabled"); + } + + private void TestRobotResearchEffects() + { + RobotStats stats = new RobotStats(); + List effects = new List + { + new ResearchEffect { Stat = "robot_speed_bonus", Value = 0.25f }, + new ResearchEffect { Stat = "robot_energy_use_reduction", Value = 0.20f }, + new ResearchEffect { Stat = "robot_cooling_bonus", Value = 0.50f } + }; + + stats.Apply(effects); + + AssertClose(12.5f, stats.GetMovementSpeed(10f), 0.001f, "movement speed"); + AssertClose(8f, stats.GetEnergyUse(10f), 0.001f, "energy use"); + AssertClose(15f, stats.GetCoolingRate(10f), 0.001f, "cooling"); + } + + private void TestResearchCompletionAppliesEffectsOnce() + { + ResearchData data = new ResearchData + { + Id = "test_research", + Inputs = new List(), + Research = "basics", + CraftTime = 1.0, + Texture = "", + Effects = new List + { + new ResearchEffect { Stat = "robot_speed_bonus", Value = 0.10f } + } + }; + + Research research = new Research(data); + research.Complete(); + research.Complete(); + + AssertClose(11f, GameData.robotStats.GetMovementSpeed(10f), 0.001f, "research effect applied once"); + } + + private void TestResourceSaveRoundtrip() + { + GameResource resource = new GameResource("stone"); + resource.Extract(2.0); + + ResourceSaveData saveData = resource.CreateSaveData(); + GameResource loadedResource = GameResource.FromSaveData(saveData); + + AssertEqual(saveData.Name, loadedResource.CreateSaveData().Name, "resource name"); + AssertEqual(saveData.CurrentAmount, loadedResource.CreateSaveData().CurrentAmount, "resource amount"); + } + + private void TestRobotSaveRoundtrip() + { + Robot robot = new Robot + { + Name = "Ada", + Position = new Vector3(1f, 2f, 3f), + heat = 44f, + maintenance = 55f, + isBroken = true, + isCoolingDown = true, + currentProgram = "Mining", + currentMessage = "Needs care", + robotType = "bronze_robot" + }; + + RobotSaveData saveData = robot.CreateSaveData(); + Robot loadedRobot = new Robot(); + loadedRobot.LoadSaveData(saveData); + + AssertEqual("Ada", loadedRobot.Name.ToString(), "robot name"); + AssertEqual("Mining", loadedRobot.currentProgram, "robot program"); + AssertEqual("bronze_robot", loadedRobot.robotType, "robot type"); + AssertClose(44f, loadedRobot.heat, 0.001f, "robot heat"); + AssertClose(55f, loadedRobot.maintenance, 0.001f, "robot maintenance"); + AssertTrue(loadedRobot.isBroken, "robot broken state"); + AssertTrue(loadedRobot.isCoolingDown, "robot cooling state"); + } + + private void TestSaveDataRestoresGlobalState() + { + GameData.inventory.AddItem(new Item { data = GameData.availableItems["stone"] }, 12); + GameData.survival.hunger = 42f; + GameData.survival.thirst = 43f; + GameData.survival.energy = 44f; + GameData.lowestLayer = 3; + GameData.availableResearch["stoneage"].Complete(); + + SaveGameData saveData = SaveGameManager.CreateSaveData(); + + GameData.ResetRunState(); + SaveGameManager.ApplyWorldData(saveData); + + AssertEqual(12, GameData.inventory.GetItemAmount("stone"), "saved inventory"); + AssertClose(42f, GameData.survival.hunger, 0.001f, "saved hunger"); + AssertEqual(3, GameData.lowestLayer, "saved layer"); + AssertEqual(ResearchState.RESEARCHED, GameData.availableResearch["stoneage"].state, "saved research"); + } + + private void TestResearchExecutionPaysResourcesAndFinishes() + { + GameData.inventory.AddItem(new Item { data = GameData.availableItems["stone"] }, 5); + ResearchData researchData = new ResearchData + { + Id = "stone_counterweight", + Inputs = new List + { + new Ingredient { Item = "stone", Amount = 3 } + }, + Research = "basics", + CraftTime = 1.0, + Texture = "", + Effects = new List() + }; + + Research research = new Research(researchData); + ResearchResult result = research.Execute(1.0); + + AssertEqual(ResearchResult.FINISHED, result, "research result"); + AssertEqual(2, GameData.inventory.GetItemAmount("stone"), "research cost"); + AssertEqual(ResearchState.RESEARCHED, research.state, "research state"); + } + + private void TestInventoryAddFailureKeepsInventoryUnchanged() + { + GameData.maxRobotCount = 1; + GameData.inventory.maxInventorySize = 1; + ItemData stone = GameData.availableItems["stone"]; + + bool result = GameData.inventory.AddItem(new Item { data = stone }, stone.StackSize + 1); + + AssertFalse(result, "add should fail"); + AssertEqual(0, GameData.inventory.GetItemAmount("stone"), "failed add should be atomic"); + AssertEqual(0, GameData.inventory.items.Count, "failed add should not create stacks"); + } + + private void TestSplitSaveFilesRoundtrip() + { + GameData.inventory.AddItem(new Item { data = GameData.availableItems["water"] }, 7); + GameData.survival.energy = 77f; + + SaveGameManager.SaveGame(); + SaveGameData saveData = SaveGameManager.LoadSaveData(); + + AssertTrue(SaveGameManager.SaveExists(), "save folder should exist"); + AssertTrue(FileAccess.FileExists("user://savegame/gamedata.json"), "gamedata file"); + AssertTrue(FileAccess.FileExists("user://savegame/robots.json"), "robots file"); + AssertTrue(FileAccess.FileExists("user://savegame/research.json"), "research file"); + AssertEqual(7, saveData.Inventory[0].Amount, "saved file inventory"); + AssertClose(77f, saveData.Survival.Energy, 0.001f, "saved file energy"); + } + + private void TestSplitSaveFilesIncludeLayerFiles() + { + GameData.ruinSize = 2; + GameData.map = new Layer[2]; + GameData.map[0] = CreateTestLayer(0, "spawn"); + GameData.map[1] = CreateTestLayer(1, "gate"); + + SaveGameManager.SaveGame(); + SaveGameData saveData = SaveGameManager.LoadSaveData(); + + AssertTrue(FileAccess.FileExists("user://savegame/layer_0.json"), "layer 0 file"); + AssertTrue(FileAccess.FileExists("user://savegame/layer_1.json"), "layer 1 file"); + AssertEqual(2, saveData.Layers.Count, "loaded layer count"); + AssertEqual("spawn", saveData.Layers[0].Tiles[0].CollapsedMesh, "layer 0 tile"); + AssertEqual("gate", saveData.Layers[1].Tiles[0].CollapsedMesh, "layer 1 tile"); + } + + private void TestIfNodeEvaluatesInventoryComparisons() + { + GameData.inventory.AddItem(new Item { data = GameData.availableItems["stone"] }, 5); + IfNode node = new IfNode + { + selectedItem = new Item { data = GameData.availableItems["stone"] }, + amount = 3, + comparator = "is bigger than" + }; + + AssertEqual(NodeResult.SUCCESS, node.Execute(null, 0), "if condition true"); + + node.amount = 8; + AssertEqual(NodeResult.CONDITIONFALSE, node.Execute(null, 0), "if condition false"); + } + + private void TestWhileNodeReportsFalseConditions() + { + GameData.inventory.AddItem(new Item { data = GameData.availableItems["water"] }, 2); + WhileNode node = new WhileNode + { + selectedItem = new Item { data = GameData.availableItems["water"] }, + amount = 5, + comparator = "is bigger than or equal to" + }; + + AssertEqual(NodeResult.CONDITIONFALSE, node.Execute(null, 0), "while condition false"); + } + + private void TestForNodeStopsAfterConfiguredAmount() + { + ForNode node = new ForNode + { + amount = 2 + }; + + AssertEqual(NodeResult.SUCCESS, node.Execute(null, 0), "first iteration"); + AssertEqual(NodeResult.SUCCESS, node.Execute(null, 0), "second iteration"); + AssertEqual(NodeResult.CONDITIONFALSE, node.Execute(null, 0), "loop finished"); + } + + private void TestItemDataReadableNames() + { + AssertEqual("Iron gear", ItemData.GetReadableName("iron_gear"), "readable name"); + AssertEqual("iron_gear", ItemData.GetIndex("Iron Gear"), "index name"); + } + + private void TestResourceFilesLoadCoreData() + { + AssertTrue(GameData.availableItems.ContainsKey("stone"), "stone item loaded"); + AssertTrue(GameData.availableItems.ContainsKey("water"), "water item loaded"); + AssertTrue(GameData.availableResearch.ContainsKey("basics"), "basics research loaded"); + AssertTrue(GameData.availableResearch.ContainsKey("iron_robotics"), "iron robotics research loaded"); + } + + private Layer CreateTestLayer(int level, string collapsedMesh) + { + Layer layer = new Layer + { + level = level, + currentResources = new List { "stone" }, + gateIngredients = new List(), + tiles = new Tile[1, 1] + }; + + Tile tile = new Tile + { + GridPosition = new Vector2I(0, 0), + Position = Vector3.Zero, + collapsedMesh = collapsedMesh, + containsResource = true, + resource = new GameResource("stone") + }; + + layer.tiles[0, 0] = tile; + return layer; + } + + private void AssertTrue(bool value, string message) + { + if (!value) + { + throw new Exception(message); + } + } + + private void AssertFalse(bool value, string message) + { + if (value) + { + throw new Exception(message); + } + } + + private void AssertEqual(T expected, T actual, string message) + { + if (!EqualityComparer.Default.Equals(expected, actual)) + { + throw new Exception($"{message}: expected {expected}, got {actual}"); + } + } + + private void AssertClose(float expected, float actual, float tolerance, string message) + { + if (Math.Abs(expected - actual) > tolerance) + { + throw new Exception($"{message}: expected {expected}, got {actual}"); + } + } +} diff --git a/Scripts/Tests/TestRunner.cs.uid b/Scripts/Tests/TestRunner.cs.uid new file mode 100644 index 0000000..5db3160 --- /dev/null +++ b/Scripts/Tests/TestRunner.cs.uid @@ -0,0 +1 @@ +uid://bm6a1hivjtc8e diff --git a/Scripts/UI/Common/UIHandler.cs b/Scripts/UI/Common/UIHandler.cs index e492b60..726f1df 100644 --- a/Scripts/UI/Common/UIHandler.cs +++ b/Scripts/UI/Common/UIHandler.cs @@ -107,6 +107,19 @@ public partial class UIHandler : Control GetTree().ChangeSceneToFile("res://Scenes/MainMenu.tscn"); } + public void SaveGame() + { + SaveGameManager.SaveGame(); + } + + public void LoadGame() + { + if (!SaveGameManager.SaveExists()) return; + + GameData.loadSaveOnStart = true; + GetTree().ChangeSceneToFile("res://Scenes/Game.tscn"); + } + public void OpenUIElement(Control element) { if (element.Visible) diff --git a/Scripts/UI/Menus/MainMenu.cs b/Scripts/UI/Menus/MainMenu.cs index 3ea3b20..b6f3c3a 100644 --- a/Scripts/UI/Menus/MainMenu.cs +++ b/Scripts/UI/Menus/MainMenu.cs @@ -4,6 +4,15 @@ public partial class MainMenu : Control { public void OnPlayPressed() { + GameData.loadSaveOnStart = false; + GetTree().ChangeSceneToFile("res://Scenes/Game.tscn"); + } + + public void OnLoadPressed() + { + if (!SaveGameManager.SaveExists()) return; + + GameData.loadSaveOnStart = true; GetTree().ChangeSceneToFile("res://Scenes/Game.tscn"); } diff --git a/Scripts/World/World.cs b/Scripts/World/World.cs index 236bb62..e100b71 100644 --- a/Scripts/World/World.cs +++ b/Scripts/World/World.cs @@ -16,6 +16,13 @@ public partial class World : Node3D public override void _Ready() { + bool shouldLoadSave = loadSaveOnStart && SaveGameManager.SaveExists(); + SaveGameData saveGame = shouldLoadSave ? SaveGameManager.LoadSaveData() : null; + if (saveGame != null) + { + seed = saveGame.Seed; + } + ResetRunState(); WFC.FillAdjacencies(); @@ -41,18 +48,27 @@ public partial class World : Node3D map = new Layer[ruinSize]; GenerateWorld(); + SetGateRequirements(); + + if (shouldLoadSave && saveGame != null) + { + SaveGameManager.ApplyWorldData(saveGame); + } Pathfinding.BuildAStarGraph(); - HandleRenderData(BuildRenderData(0)); + HandleRenderData(BuildRenderData(visibleLayer)); - Robot robot = ResourceLoader.LoadRobotPrefab().Instantiate(); - robot.Name = "Bob"; - robot.Position = map[0].tiles[0, 0].Position; - AddChild(robot); - robots.Add(robot); + if (shouldLoadSave && saveGame != null) + { + SpawnSavedRobots(saveGame.Robots); + } + else + { + SpawnDefaultRobot(); + } - SetGateRequirements(); + loadSaveOnStart = false; } private Dictionary CreateMultiMeshes(Dictionary meshLibrary) @@ -94,17 +110,30 @@ public partial class World : Node3D } } - private void ResetRunState() + private void SpawnDefaultRobot() { - survival = new SurvivalState(); - robotStats = new RobotStats(); - inventory = new Inventory(); - availableResearch = ResourceLoader.LoadResearch(); - robots.Clear(); - currentLayer = 0; - visibleLayer = 0; - lowestLayer = 0; - canMove = true; + Robot robot = ResourceLoader.LoadRobotPrefab().Instantiate(); + robot.Name = "Bob"; + robot.Position = map[0].tiles[0, 0].Position; + AddChild(robot); + robots.Add(robot); + } + + private void SpawnSavedRobots(List savedRobots) + { + if (savedRobots == null || savedRobots.Count <= 0) + { + SpawnDefaultRobot(); + return; + } + + foreach (RobotSaveData savedRobot in savedRobots) + { + Robot robot = ResourceLoader.LoadRobotPrefab().Instantiate(); + robot.LoadSaveData(savedRobot); + AddChild(robot); + robots.Add(robot); + } } private void GenerateWorld()