diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..87b189a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,17 @@ +# Slimepire Project — Agent Rules + +## Before editing ANY file: ALWAYS re-read it first + +I often edit code manually while the agent is running. If you edit a file based on +stale content from a previous read, you will overwrite my manual changes. + +**Rule:** Before calling `edit` or `write` on any existing file, read it again with +`read` to get the current state. Never rely on file contents from earlier turns or +from a previous compaction/context window. + +## Code style + +- Godot 4.7 C# project targeting .NET 10.0 +- Uses `GodotSharp.SourceGenerators` — provides `[Singleton]`, `[SceneTree]`, + `[ResourceTree]`, and `Instantiator.Instantiate()` +- Keep things simple, avoid over-engineering diff --git a/src/Gameplay/BuildGhost.cs b/src/Gameplay/BuildGhost.cs index 726ca46..3baf22b 100644 --- a/src/Gameplay/BuildGhost.cs +++ b/src/Gameplay/BuildGhost.cs @@ -87,7 +87,10 @@ public partial class BuildGhost : Node2D { var nodule = NoduleScene.Instantiate(); nodule.Position = Position; + nodule.EnergyBuildCost = 5f; // TODO: make configurable per nodule type + nodule.ConstructionProgress = 0; GetParent()?.AddChild(nodule); + NoduleManager.Instance.RequestConstruction(nodule); CleanupBuildMode(); } } diff --git a/src/Gameplay/Components/Energy/EnergyPackage.cs b/src/Gameplay/Components/Energy/EnergyPackage.cs new file mode 100644 index 0000000..cf51a80 --- /dev/null +++ b/src/Gameplay/Components/Energy/EnergyPackage.cs @@ -0,0 +1,49 @@ +using Godot; + +namespace Slimepire.Gameplay.Components; + +[SceneTree] +public partial class EnergyPackage : Node2D +{ + [Export] + public float TravelSpeed { get; set; } = 200f; + + private Vector2[] _waypoints = []; + private int _currentWaypointIndex; + + public event Action? Arrived; + + public void SetWaypoints(Vector2[] waypoints) + { + _waypoints = waypoints; + if (_waypoints.Length > 0) + { + GlobalPosition = _waypoints[0]; + } + } + + public override void _Process(double delta) + { + if (_waypoints.Length == 0 || _currentWaypointIndex >= _waypoints.Length - 1) + { + Arrived?.Invoke(); + QueueFree(); + return; + } + + var target = _waypoints[_currentWaypointIndex + 1]; + var direction = target - GlobalPosition; + var distance = direction.Length(); + var step = TravelSpeed * (float)delta; + + if (step >= distance) + { + GlobalPosition = target; + _currentWaypointIndex++; + } + else + { + GlobalPosition += direction.Normalized() * step; + } + } +} diff --git a/src/Gameplay/Components/Energy/EnergyPackage.cs.uid b/src/Gameplay/Components/Energy/EnergyPackage.cs.uid new file mode 100644 index 0000000..3f97dd7 --- /dev/null +++ b/src/Gameplay/Components/Energy/EnergyPackage.cs.uid @@ -0,0 +1 @@ +uid://nk5tjlmcrpn2 diff --git a/src/Gameplay/Components/Energy/EnergyPackage.tscn b/src/Gameplay/Components/Energy/EnergyPackage.tscn new file mode 100644 index 0000000..c18eb90 --- /dev/null +++ b/src/Gameplay/Components/Energy/EnergyPackage.tscn @@ -0,0 +1,29 @@ +[gd_scene format=3 uid="uid://dwdbqktsl34sm"] + +[ext_resource type="Script" uid="uid://nk5tjlmcrpn2" path="res://src/Gameplay/Components/Energy/EnergyPackage.cs" id="1_kmtmh"] +[ext_resource type="Texture2D" uid="uid://bjl6gqj0bvhhp" path="res://assets/energy_package.png" id="2_psum7"] + +[sub_resource type="Curve" id="Curve_psum7"] +_data = [Vector2(0, 1), 0.0, 0.0, 0, 0, Vector2(1e-05, 1), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0] +point_count = 3 + +[sub_resource type="CurveTexture" id="CurveTexture_psum7"] +curve = SubResource("Curve_psum7") + +[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_psum7"] +particle_flag_disable_z = true +inherit_velocity_ratio = 0.625 +gravity = Vector3(0, 10, 0) +scale_curve = SubResource("CurveTexture_psum7") + +[node name="EnergyPackage" type="Node2D" unique_id=869544034] +z_index = 2 +script = ExtResource("1_kmtmh") + +[node name="GPUParticles2D" type="GPUParticles2D" parent="." unique_id=388560926] +modulate = Color(1, 0.7764706, 0, 1) +texture = ExtResource("2_psum7") +lifetime = 0.3 +use_fixed_seed = true +seed = 874795105 +process_material = SubResource("ParticleProcessMaterial_psum7") diff --git a/src/Gameplay/Components/Energy/EnergyStorage.cs b/src/Gameplay/Components/Energy/EnergyStorage.cs index 67ba8e9..c9fdd2c 100644 --- a/src/Gameplay/Components/Energy/EnergyStorage.cs +++ b/src/Gameplay/Components/Energy/EnergyStorage.cs @@ -16,20 +16,22 @@ public partial class EnergyStorage : Node get; set { - if (field + value > MaxEnergy) - { - field = MaxEnergy; - } - else - { - field += value; - } - - field = value; + field = Mathf.Clamp(field + value, 0, MaxEnergy); UpdateLabel(); } } + public bool TryConsume(float amount) + { + if (Energy >= amount) + { + Energy = -amount; + return true; + } + + return false; + } + public override void _Ready() { UpdateLabel(); diff --git a/src/Gameplay/NoduleManager.cs b/src/Gameplay/NoduleManager.cs index 110f809..bebea10 100644 --- a/src/Gameplay/NoduleManager.cs +++ b/src/Gameplay/NoduleManager.cs @@ -1,4 +1,5 @@ using Godot; +using Slimepire.Gameplay.Components; using Slimepire.Gameplay.Nodules; namespace Slimepire.Gameplay; @@ -6,11 +7,16 @@ namespace Slimepire.Gameplay; [Singleton] public partial class NoduleManager { + private const float PackageSendInterval = 0.3f; + private const float ConstructionRetryInterval = 2f; + public bool Building { get; set; } private AStar2D _aStar = new(); private readonly Dictionary _nodules = []; private readonly HashSet _connections = []; + private readonly List _rootNodules = []; + private readonly Dictionary _activeConstructions = []; // GFX private readonly Dictionary _tubeConnections = new(); @@ -20,6 +26,11 @@ public partial class NoduleManager _nodules.Add(nodule.NoduleId, nodule); _aStar.AddPoint(nodule.NoduleId, nodule.Position); + if (nodule is RootNodule root && !_rootNodules.Contains(root)) + { + _rootNodules.Add(root); + } + UpdateNoduleConnections(); UpdateTubesAndAstar(); } @@ -30,6 +41,176 @@ public partial class NoduleManager UpdateTubesAndAstar(); } + public void OnNoduleConstructed(Nodule nodule) + { + // Add all AStar connections that were skipped during construction + foreach (var neighbor in nodule.Connections) + { + _aStar.ConnectPoints(nodule.NoduleId, neighbor.NoduleId); + } + + // Convert any ghost tubes connected to this nodule to real tubes. + // Tubes are not always children of this nodule (depends on hash ordering in NoduleConnection), + // so iterating children alone isn't enough. + foreach (var (conn, tube) in _tubeConnections) + { + if (tube.IsGhost && (ReferenceEquals(conn.A, nodule) || ReferenceEquals(conn.B, nodule))) + { + tube.SetGhost(false); + } + } + } + + public void RequestConstruction(Nodule target) + { + if (!target.IsConstructing) + return; + + // Defer to allow connections to be established (physics frame overlap detection) + var tree = target.GetTree(); + tree.CreateTimer(0.1f).Timeout += () => TryStartConstruction(target); + } + + private void TryStartConstruction(Nodule target) + { + // Constructing nodule has no AStar connections yet — route via neighbors + var (sourceRoot, waypoints) = FindPathViaNeighbors(target); + if (sourceRoot == null || waypoints.Length == 0) + { + // No path yet — retry periodically until a RootNodule becomes reachable + // TODO: ist das hier richtig? + var tree = target.GetTree(); + tree.CreateTimer(ConstructionRetryInterval).Timeout += () => + { + if (target.IsConstructing && !_activeConstructions.ContainsKey(target.NoduleId)) + TryStartConstruction(target); + }; + return; + } + + var request = new ConstructionRequest + { + Target = target, + SourceRoot = sourceRoot, + Waypoints = waypoints, + }; + + _activeConstructions[target.NoduleId] = request; + SendNextPackage(request); + } + + private void SendNextPackage(ConstructionRequest request) + { + var target = request.Target; + + if (!target.IsConstructing) + { + _activeConstructions.Remove(target.NoduleId); + return; + } + + var energyStorage = request.SourceRoot.GetNode("EnergyStorage"); + if (energyStorage == null || !energyStorage.TryConsume(1)) + { + // Source empty — retry after a delay + var tree = request.SourceRoot.GetTree(); + tree.CreateTimer(PackageSendInterval).Timeout += () => + { + if (_activeConstructions.ContainsKey(target.NoduleId)) + SendNextPackage(request); + }; + return; + } + + var package = Instantiator.Instantiate(); + package.SetWaypoints(request.Waypoints); + + // Add to tree as sibling of source root so it's visible + var parent = request.SourceRoot.GetParent(); + parent?.AddChild(package); + package.GlobalPosition = request.Waypoints[0]; + + package.Arrived += () => + { + target.ReceiveConstructionEnergy(1); + + if (!target.IsConstructing) + { + _activeConstructions.Remove(target.NoduleId); + return; + } + + // Send next package after a short interval + var tree = target.GetTree(); + tree.CreateTimer(PackageSendInterval).Timeout += () => + { + if (_activeConstructions.TryGetValue(target.NoduleId, out var req)) + SendNextPackage(req); + }; + }; + } + + private (RootNodule? source, Vector2[] waypoints) FindPathViaNeighbors(Nodule target) + { + RootNodule? bestRoot = null; + Vector2[] bestWaypoints = []; + float bestDist = float.MaxValue; + + foreach (var neighbor in target.Connections) + { + foreach (var root in _rootNodules) + { + var pointPath = _aStar.GetPointPath(root.NoduleId, neighbor.NoduleId); + if (pointPath.Length == 0) + continue; + + var dist = 0f; + for (var i = 0; i < pointPath.Length - 1; i++) + dist += pointPath[i].DistanceTo(pointPath[i + 1]); + dist += pointPath[^1].DistanceTo(target.GlobalPosition); + + if (dist < bestDist) + { + bestDist = dist; + bestRoot = root; + bestWaypoints = [.. pointPath, target.GlobalPosition]; + } + } + } + + return (bestRoot, bestWaypoints); + } + + private (RootNodule? source, long[] path) FindNearestRootNodule(Nodule target) + { + RootNodule? nearest = null; + float shortestDist = float.MaxValue; + long[] shortestIds = []; + + foreach (var root in _rootNodules) + { + var ids = _aStar.GetIdPath(root.NoduleId, target.NoduleId); + if (ids.Length == 0) + continue; + + var path = _aStar.GetPointPath(root.NoduleId, target.NoduleId); + var dist = 0f; + for (var i = 0; i < path.Length - 1; i++) + { + dist += path[i].DistanceTo(path[i + 1]); + } + + if (dist < shortestDist) + { + shortestDist = dist; + nearest = root; + shortestIds = [.. ids]; + } + } + + return (nearest, shortestIds); + } + private void UpdateNoduleConnections() { foreach (var nodule in _nodules.Values) @@ -53,7 +234,16 @@ public partial class NoduleManager conn.A.AddChild(tube); tube.SetTarget(conn.B.GlobalPosition); - _aStar.ConnectPoints(conn.A.NoduleId, conn.B.NoduleId); + // Ghost tubes for constructing nodules — visual only, no AStar + var eitherConstructing = conn.A.IsConstructing || conn.B.IsConstructing; + if (eitherConstructing) + { + tube.SetGhost(true); + } + else + { + _aStar.ConnectPoints(conn.A.NoduleId, conn.B.NoduleId); + } } } } @@ -89,4 +279,11 @@ public partial class NoduleManager return HashCode.Combine(A, B); } } + + private class ConstructionRequest + { + public required Nodule Target { get; init; } + public required RootNodule SourceRoot { get; init; } + public required Vector2[] Waypoints { get; init; } + } } diff --git a/src/Gameplay/Nodules/Nodule.cs b/src/Gameplay/Nodules/Nodule.cs index ed95375..a8e5f9d 100644 --- a/src/Gameplay/Nodules/Nodule.cs +++ b/src/Gameplay/Nodules/Nodule.cs @@ -11,17 +11,19 @@ public partial class Nodule : Node2D [Export] private AnimatedSprite2D _gfx = null!; + [Export] + public float EnergyBuildCost { get; set; } = 0f; + + public float ConstructionProgress { get; set; } + + public bool IsConstructing => EnergyBuildCost > 0 && ConstructionProgress < EnergyBuildCost; + public List Connections { get; } = []; protected AnimatedSprite2D Gfx => _gfx; public long NoduleId => (long)GetInstanceId(); - public void AttachTube(Tube tube) - { - AddChild(tube); - } - public override void _Ready() { _tubeConnectionArea.AreaEntered += OnAreaEntered; @@ -32,7 +34,15 @@ public partial class Nodule : Node2D // render above tubes ZIndex = 1; - _gfx.Play(); + + if (IsConstructing) + { + _gfx.Modulate = new Color(1, 1, 1, 0.4f); + } + else + { + _gfx.Play(); + } } private void OnAreaEntered(Area2D area) @@ -57,4 +67,25 @@ public partial class Nodule : Node2D { _gfx.SelfModulate = Colors.DarkOliveGreen; } + + public void ReceiveConstructionEnergy(float amount) + { + if (!IsConstructing) + return; + + ConstructionProgress = Mathf.Min(ConstructionProgress + amount, EnergyBuildCost); + + if (ConstructionProgress >= EnergyBuildCost) + { + OnConstructionComplete(); + } + } + + protected virtual void OnConstructionComplete() + { + _gfx.Modulate = Colors.White; + _gfx.Play(); + + NoduleManager.Instance.OnNoduleConstructed(this); + } } diff --git a/src/Gameplay/Tube.cs b/src/Gameplay/Tube.cs index 5c75208..1555d1f 100644 --- a/src/Gameplay/Tube.cs +++ b/src/Gameplay/Tube.cs @@ -40,6 +40,17 @@ public partial class Tube : Node2D GenerateTube(); } + private bool _isGhost; + public bool IsGhost => _isGhost; + + public void SetGhost(bool ghost) + { + _isGhost = ghost; + Line2D.DefaultColor = ghost + ? new Color(0.3f, 0.49f, 0.24f, 0.3f) + : new Color(0.30588236f, 0.4862745f, 0.23921569f, 1f); + } + public override void _Ready() { Line2D.Points = [];