add Nodule Building

This commit is contained in:
2026-06-24 15:49:08 +02:00
parent 390ac95638
commit b593bd64bd
9 changed files with 357 additions and 17 deletions

17
AGENTS.md Normal file
View File

@@ -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<T>()`
- Keep things simple, avoid over-engineering

View File

@@ -87,7 +87,10 @@ public partial class BuildGhost : Node2D
{ {
var nodule = NoduleScene.Instantiate<Nodule>(); var nodule = NoduleScene.Instantiate<Nodule>();
nodule.Position = Position; nodule.Position = Position;
nodule.EnergyBuildCost = 5f; // TODO: make configurable per nodule type
nodule.ConstructionProgress = 0;
GetParent()?.AddChild(nodule); GetParent()?.AddChild(nodule);
NoduleManager.Instance.RequestConstruction(nodule);
CleanupBuildMode(); CleanupBuildMode();
} }
} }

View File

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

View File

@@ -0,0 +1 @@
uid://nk5tjlmcrpn2

View File

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

View File

@@ -16,20 +16,22 @@ public partial class EnergyStorage : Node
get; get;
set set
{ {
if (field + value > MaxEnergy) field = Mathf.Clamp(field + value, 0, MaxEnergy);
{
field = MaxEnergy;
}
else
{
field += value;
}
field = value;
UpdateLabel(); UpdateLabel();
} }
} }
public bool TryConsume(float amount)
{
if (Energy >= amount)
{
Energy = -amount;
return true;
}
return false;
}
public override void _Ready() public override void _Ready()
{ {
UpdateLabel(); UpdateLabel();

View File

@@ -1,4 +1,5 @@
using Godot; using Godot;
using Slimepire.Gameplay.Components;
using Slimepire.Gameplay.Nodules; using Slimepire.Gameplay.Nodules;
namespace Slimepire.Gameplay; namespace Slimepire.Gameplay;
@@ -6,11 +7,16 @@ namespace Slimepire.Gameplay;
[Singleton] [Singleton]
public partial class NoduleManager public partial class NoduleManager
{ {
private const float PackageSendInterval = 0.3f;
private const float ConstructionRetryInterval = 2f;
public bool Building { get; set; } public bool Building { get; set; }
private AStar2D _aStar = new(); private AStar2D _aStar = new();
private readonly Dictionary<long, Nodule> _nodules = []; private readonly Dictionary<long, Nodule> _nodules = [];
private readonly HashSet<NoduleConnection> _connections = []; private readonly HashSet<NoduleConnection> _connections = [];
private readonly List<RootNodule> _rootNodules = [];
private readonly Dictionary<long, ConstructionRequest> _activeConstructions = [];
// GFX // GFX
private readonly Dictionary<NoduleConnection, Tube> _tubeConnections = new(); private readonly Dictionary<NoduleConnection, Tube> _tubeConnections = new();
@@ -20,6 +26,11 @@ public partial class NoduleManager
_nodules.Add(nodule.NoduleId, nodule); _nodules.Add(nodule.NoduleId, nodule);
_aStar.AddPoint(nodule.NoduleId, nodule.Position); _aStar.AddPoint(nodule.NoduleId, nodule.Position);
if (nodule is RootNodule root && !_rootNodules.Contains(root))
{
_rootNodules.Add(root);
}
UpdateNoduleConnections(); UpdateNoduleConnections();
UpdateTubesAndAstar(); UpdateTubesAndAstar();
} }
@@ -30,6 +41,176 @@ public partial class NoduleManager
UpdateTubesAndAstar(); 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>("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<EnergyPackage>();
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() private void UpdateNoduleConnections()
{ {
foreach (var nodule in _nodules.Values) foreach (var nodule in _nodules.Values)
@@ -53,10 +234,19 @@ public partial class NoduleManager
conn.A.AddChild(tube); conn.A.AddChild(tube);
tube.SetTarget(conn.B.GlobalPosition); tube.SetTarget(conn.B.GlobalPosition);
// 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); _aStar.ConnectPoints(conn.A.NoduleId, conn.B.NoduleId);
} }
} }
} }
}
private record NoduleConnection private record NoduleConnection
{ {
@@ -89,4 +279,11 @@ public partial class NoduleManager
return HashCode.Combine(A, B); 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; }
}
} }

View File

@@ -11,17 +11,19 @@ public partial class Nodule : Node2D
[Export] [Export]
private AnimatedSprite2D _gfx = null!; 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<Nodule> Connections { get; } = []; public List<Nodule> Connections { get; } = [];
protected AnimatedSprite2D Gfx => _gfx; protected AnimatedSprite2D Gfx => _gfx;
public long NoduleId => (long)GetInstanceId(); public long NoduleId => (long)GetInstanceId();
public void AttachTube(Tube tube)
{
AddChild(tube);
}
public override void _Ready() public override void _Ready()
{ {
_tubeConnectionArea.AreaEntered += OnAreaEntered; _tubeConnectionArea.AreaEntered += OnAreaEntered;
@@ -32,8 +34,16 @@ public partial class Nodule : Node2D
// render above tubes // render above tubes
ZIndex = 1; ZIndex = 1;
if (IsConstructing)
{
_gfx.Modulate = new Color(1, 1, 1, 0.4f);
}
else
{
_gfx.Play(); _gfx.Play();
} }
}
private void OnAreaEntered(Area2D area) private void OnAreaEntered(Area2D area)
{ {
@@ -57,4 +67,25 @@ public partial class Nodule : Node2D
{ {
_gfx.SelfModulate = Colors.DarkOliveGreen; _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);
}
} }

View File

@@ -40,6 +40,17 @@ public partial class Tube : Node2D
GenerateTube(); 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() public override void _Ready()
{ {
Line2D.Points = []; Line2D.Points = [];