diff --git a/scripts/GameManager.cs b/scripts/GameManager.cs index 69789a0..81b29ef 100644 --- a/scripts/GameManager.cs +++ b/scripts/GameManager.cs @@ -29,10 +29,14 @@ public partial class GameManager : Node2D public IReadOnlyList Towns => _towns; private readonly List _towns = new() { - new("North", new Vector2(576f, 190f), SoulType.Generational), - new("South", new Vector2(576f, 962f), SoulType.Nature), - new("West", new Vector2(190f, 576f), SoulType.Materialist), - new("East", new Vector2(962f, 576f), SoulType.Selfish), + // 2×2 even grid on the torus: towns at the quarter/three-quarter + // points of each axis (320 and 960 on a 1280 world). Every town is + // exactly 640 units from each of its two axis-neighbours in all + // directions, wrap included — no edges, no corners, no town boxed in. + new("North", new Vector2(320f, 320f), SoulType.Generational), + new("East", new Vector2(960f, 320f), SoulType.Selfish), + new("West", new Vector2(320f, 960f), SoulType.Materialist), + new("South", new Vector2(960f, 960f), SoulType.Nature), }; private static SoulProfile ProfileFor(SoulType t) => t switch @@ -116,7 +120,7 @@ public partial class GameManager : Node2D HomeTown = town, Soul = soul, PersonalityModifier = (float)(rng.NextDouble() * 0.4 - 0.2), - Position = center + new Vector2(rng.Next(-12, 13), rng.Next(-12, 13)), + Position = Toroidal.Wrap(center + new Vector2(rng.Next(-12, 13), rng.Next(-12, 13))), // Staggered ages so the founding generation doesn't die // in one terrible week. AgeDays = rng.Next(0, 100), @@ -142,7 +146,7 @@ public partial class GameManager : Node2D int count = 0; foreach (var npc in Npcs) if (npc != except && npc.State == NpcState.Resting && - npc.Position.DistanceTo(b.Site) <= 2.5f) + Toroidal.Distance(npc.Position, b.Site) <= 2.5f) count++; return count; } @@ -174,21 +178,25 @@ public partial class GameManager : Node2D public Vector2 TownCentroid(string town) { + // Averaged in ANCHOR-RELATIVE torus deltas, not raw positions — + // averaging absolute coordinates across the wrap seam gives garbage + // (two people on opposite edges are neighbours, but their coordinates + // average to the far side of the world). Deltas keep the seam honest. + Vector2 anchor = TownAnchor(town); Vector2 sum = Vector2.Zero; int count = 0; foreach (var npc in Npcs) { if (npc.HomeTown != town) continue; - sum += npc.Position; + sum += Toroidal.Delta(anchor, npc.Position); count++; } - if (count == 0) return TownAnchor(town); + if (count == 0) return anchor; - // Anchor-weighted: the settlement's build-center stays near its - // cardinal seed even as villagers roam outward to distant resources. - // Towns keep their place on the compass instead of drifting to the - // middle of the map where everyone's foraging paths overlap. - return (sum / count).Lerp(TownAnchor(town), 0.7f); + // Anchor-weighted (0.7 toward the seed) so a settlement keeps its + // place as villagers roam, then wrapped back into the world. + Vector2 mean = sum / count; + return Toroidal.Wrap(anchor + mean * 0.3f); } public override void _Process(double delta) @@ -221,7 +229,7 @@ public partial class GameManager : Node2D Grid.ForEachNear(a.Position, radius, b => { if (b == a) return; - float dist = a.Position.DistanceTo(b.Position); + float dist = Toroidal.Distance(a.Position, b.Position); if (dist > radius) return; float falloff = 1f - dist / radius; float fromA = Mathf.Lerp(-strength, strength, a.Imprint.Total) * falloff; @@ -353,7 +361,7 @@ public partial class GameManager : Node2D foreach (var b in Buildings) { if (b.RestCapacity <= 0) continue; - float d = npc.Position.DistanceSquaredTo(b.Site); + float d = Toroidal.DistanceSquared(npc.Position, b.Site); if (d < best) { best = d; at = b; } } if (at == null) continue; @@ -404,7 +412,7 @@ public partial class GameManager : Node2D foreach (var b in Buildings) { if (b.RestCapacity <= 0) continue; - float d = npc.Position.DistanceSquaredTo(b.Site); + float d = Toroidal.DistanceSquared(npc.Position, b.Site); if (d < best) { best = d; at = b; } } if (at != null) at.Occupancy++; diff --git a/scripts/NPC.cs b/scripts/NPC.cs index 84794ad..f46502a 100644 --- a/scripts/NPC.cs +++ b/scripts/NPC.cs @@ -61,7 +61,7 @@ public class Npc public void SenseStructures(GameManager gm) { foreach (var b in gm.Buildings) - if (Position.DistanceSquaredTo(b.Site) <= SensingRadius * SensingRadius) + if (Toroidal.DistanceSquared(Position, b.Site) <= SensingRadius * SensingRadius) KnownStructures.Add(b); } @@ -97,18 +97,14 @@ public class Npc private void Explore(GameManager gm) { Activity = "exploring for new ground"; - Vector2 anchor = gm.TownAnchor(HomeTown); - Vector2 fromHome = Position - anchor; - float distHome = fromHome.Length(); - - // Venture outward, unbounded — a town that must range far to eat - // genuinely relocates over time (the South drifting inland after a - // hard winter, an East scout wandering into a neighbor). Direction is - // outward from home, or — when right at the anchor — a per-soul angle - // from identity (NOT a fixed vector; the old default leaned hard +Y, - // i.e. south, which funnelled every town's explorers into one corner). + // Outward from home along the torus-shortest direction, unbounded — a + // town that must range far to eat genuinely relocates over time, and + // on a boundless globe an explorer can circle the whole world. When + // right at the anchor there's no outward vector, so pick a per-soul + // angle from identity (not a fixed vector — that once leaned south). + Vector2 fromHome = Toroidal.Delta(gm.TownAnchor(HomeTown), Position); Vector2 dir; - if (distHome > 1f) + if (fromHome.LengthSquared() > 1f) dir = fromHome.Normalized(); else { @@ -116,7 +112,7 @@ public class Npc dir = new Vector2(Mathf.Cos(ang), Mathf.Sin(ang)); } dir = dir.Rotated(PersonalityModifier * 1.2f); // personality veer, so explorers fan out - Position += dir * MoveSpeed; + Position = Toroidal.Wrap(Position + dir * MoveSpeed); SenseNodes(gm.World); } @@ -135,7 +131,7 @@ public class Npc if (node.Kind != MaterialKind.Water && node.RegenPerDay > 0f && node.Amount <= node.MaxAmount * minFraction) continue; - float d = Position.DistanceSquaredTo(node.Cell); + float d = Toroidal.DistanceSquared(Position, node.Cell); if (d < bestDist) { bestDist = d; best = node; } } return best; @@ -380,7 +376,7 @@ public class Npc } private void MoveToward(Vector2 target) => - Position = Position.MoveToward(target, MoveSpeed); + Position = Toroidal.MoveToward(Position, target, MoveSpeed); // --- Demand ---------------------------------------------------------- @@ -523,7 +519,7 @@ public class Npc { Activity = "fetching food from the granary"; MoveToward(_foodSource.Site); - if (Position.DistanceTo(_foodSource.Site) < ArriveDist) + if (Toroidal.Distance(Position, _foodSource.Site) < ArriveDist) { float got = _foodSource.WithdrawFood(8f); Inventory.TryGetValue(MaterialKind.Food, out float haveF); @@ -536,7 +532,7 @@ public class Npc { Activity = $"asking {_giftSource.Name} for food"; MoveToward(_giftSource.Position); - if (Position.DistanceTo(_giftSource.Position) < ArriveDist) + if (Toroidal.Distance(Position, _giftSource.Position) < ArriveDist) { TradeSystem.RequestGift(_giftSource, this); _giftSource = null; @@ -608,7 +604,7 @@ public class Npc float warmth = GetWarmth(other.Name); if (warmth < -0.3f) return; if (!other.Inventory.TryGetValue(MaterialKind.Food, out float f) || f <= 3f) return; - float score = warmth * 40f - Position.DistanceTo(other.Position); + float score = warmth * 40f - Toroidal.Distance(Position, other.Position); if (score > bestScore) { bestScore = score; giftFound = other; } }); _giftSource = giftFound; @@ -626,7 +622,7 @@ public class Npc } MoveToward(_targetNode.Cell); - if (Position.DistanceTo(_targetNode.Cell) >= ArriveDist) + if (Toroidal.Distance(Position, _targetNode.Cell) >= ArriveDist) { Activity = $"walking to {_targetNode.Kind.ToString().ToLower()}"; } @@ -665,7 +661,7 @@ public class Npc { if (other == this) return; if (!TradeSystem.CanTrade(this, other, out _)) return; - float d = Position.DistanceSquaredTo(other.Position); + float d = Toroidal.DistanceSquared(Position, other.Position); if (d < bestDist) { bestDist = d; partner = other; } }); _targetNpc = partner; @@ -674,7 +670,7 @@ public class Npc Activity = $"bringing goods to {_targetNpc.Name}"; MoveToward(_targetNpc.Position); - if (Position.DistanceTo(_targetNpc.Position) < ArriveDist) + if (Toroidal.Distance(Position, _targetNpc.Position) < ArriveDist) { if (TradeSystem.CanTrade(this, _targetNpc, out var material)) TradeSystem.Execute(this, _targetNpc, material); @@ -704,7 +700,7 @@ public class Npc { if (b.Town != HomeTown || b.IsComplete || b.IsRuined || b.Kind != BuildingKind.Shelter) continue; - float d = Position.DistanceSquaredTo(b.Site); + float d = Toroidal.DistanceSquared(Position, b.Site); if (d < best) { best = d; mine = b; } } return mine; @@ -751,7 +747,7 @@ public class Npc Activity = "building my own home"; MoveToward(mine.Site); - if (Position.DistanceTo(mine.Site) < ArriveDist) + if (Toroidal.Distance(Position, mine.Site) < ArriveDist) { mine.Deliver(this); if (mine.IsComplete) @@ -791,7 +787,7 @@ public class Npc foreach (var b in gm.Buildings) { if (b.Town != HomeTown || b.IsComplete) continue; - float d = Position.DistanceSquaredTo(b.Site); + float d = Toroidal.DistanceSquared(Position, b.Site); if (d < bestDist) { bestDist = d; site = b; } } if (site == null) @@ -799,7 +795,7 @@ public class Npc foreach (var b in gm.Buildings) { if (b.Town != HomeTown || !b.WantsUpkeepWood) continue; - float d = Position.DistanceSquaredTo(b.Site); + float d = Toroidal.DistanceSquared(Position, b.Site); if (d < bestDist) { bestDist = d; site = b; } } } @@ -812,7 +808,7 @@ public class Npc foreach (var b in gm.Buildings) { if (b.Town != HomeTown || !b.WantsFood) continue; - float d = Position.DistanceSquaredTo(b.Site); + float d = Toroidal.DistanceSquared(Position, b.Site); if (d < bestDist) { bestDist = d; site = b; } } } @@ -822,7 +818,7 @@ public class Npc foreach (var b in gm.Buildings) { if (b.Town != HomeTown || !b.WantsExpansion) continue; - float d = Position.DistanceSquaredTo(b.Site); + float d = Toroidal.DistanceSquared(Position, b.Site); if (d < bestDist) { bestDist = d; site = b; } } } @@ -875,7 +871,7 @@ public class Npc if (quarry == null) { Enter(NpcState.Socializing); return; } Activity = "returning stone to the quarry"; MoveToward(quarry.Cell); - if (Position.DistanceTo(quarry.Cell) < ArriveDist) + if (Toroidal.Distance(Position, quarry.Cell) < ArriveDist) { quarry.Amount = Mathf.Min(quarry.MaxAmount, quarry.Amount + stoneCarried); Inventory[MaterialKind.Stone] = 0f; @@ -897,7 +893,7 @@ public class Npc : $"expanding {kindName}"; MoveToward(site.Site); - if (Position.DistanceTo(site.Site) < ArriveDist) + if (Toroidal.Distance(Position, site.Site) < ArriveDist) { bool delivered = site.Deliver(this); if (!delivered || site.IsComplete) Enter(NpcState.Socializing); @@ -915,10 +911,10 @@ public class Npc if (b.RestCapacity <= 0) continue; // Read the cached per-tick occupancy; +1 to leave room for us if // we're not already counted there. - bool alreadyHere = Position.DistanceSquaredTo(b.Site) < 6.25f; + bool alreadyHere = Toroidal.DistanceSquared(Position, b.Site) < 6.25f; int taken = alreadyHere ? b.Occupancy - 1 : b.Occupancy; if (taken >= b.RestCapacity) continue; - float d = Position.DistanceSquaredTo(b.Site); + float d = Toroidal.DistanceSquared(Position, b.Site); if (d < bestDist) { bestDist = d; shelter = b; } } @@ -927,7 +923,7 @@ public class Npc bool sheltered = false; if (shelter != null) { - if (Position.DistanceTo(shelter.Site) > 2.5f) MoveToward(shelter.Site); + if (Toroidal.Distance(Position, shelter.Site) > 2.5f) MoveToward(shelter.Site); else sheltered = true; // we selected a shelter with a free bed } Activity = sheltered ? "resting at shelter" @@ -982,7 +978,7 @@ public class Npc }); if (teacher != null) { - if (Position.DistanceTo(teacher.Position) > 3f) + if (Toroidal.Distance(Position, teacher.Position) > 3f) { Activity = $"seeking out {teacher.Name} to learn"; MoveToward(teacher.Position); @@ -1001,7 +997,7 @@ public class Npc // relationships are a pull the work queue doesn't override. if (IsBonded && Partner!.Health > 0f) { - if (Position.DistanceTo(Partner.Position) > 3f) + if (Toroidal.Distance(Position, Partner.Position) > 3f) { Activity = $"with {Partner.Name}"; MoveToward(Partner.Position); @@ -1022,7 +1018,7 @@ public class Npc gm.Grid.ForEachNear(Position, 30f, other => { if (other == this) return; - float d = Position.DistanceSquaredTo(other.Position); + float d = Toroidal.DistanceSquared(Position, other.Position); if (d < fBest) { fBest = d; found = other; } }); nearest = found; @@ -1035,7 +1031,7 @@ public class Npc // town can shack up across cultures — the first quiet thread of // contact between two peoples. if (!IsBonded && IsAdult && nearest is { IsBonded: false, IsAdult: true } n && - Position.DistanceTo(n.Position) < 4f && + Toroidal.Distance(Position, n.Position) < 4f && GetWarmth(n.Name) > 0.4f && n.GetWarmth(Name) > 0.4f) { Partner = n; @@ -1043,7 +1039,7 @@ public class Npc string kind = n.HomeTown == HomeTown ? HomeTown : $"{HomeTown}+{n.HomeTown}"; Godot.GD.Print($"[Bond] day {gm.Day}: {Name} and {n.Name} ({kind}) pair up."); } - if (nearest != null && Position.DistanceTo(nearest.Position) > 3f) + if (nearest != null && Toroidal.Distance(Position, nearest.Position) > 3f) { Activity = $"walking over to {nearest.Name}"; MoveToward(nearest.Position); diff --git a/scripts/SpatialGrid.cs b/scripts/SpatialGrid.cs index 91b79b0..58f7a1d 100644 --- a/scripts/SpatialGrid.cs +++ b/scripts/SpatialGrid.cs @@ -41,11 +41,16 @@ public class SpatialGrid public void ForEachNear(Vector2 center, float radius, System.Action action) { int reach = Mathf.CeilToInt(radius / _cell); + int cols = Mathf.CeilToInt(Toroidal.Size / _cell); // buckets per axis var (cx, cy) = Key(center); + // Wrap bucket coordinates so a search near the seam also scans the + // buckets on the far edge — the world is a torus. for (int dx = -reach; dx <= reach; dx++) for (int dy = -reach; dy <= reach; dy++) { - if (_buckets.TryGetValue((cx + dx, cy + dy), out var list)) + int gx = ((cx + dx) % cols + cols) % cols; + int gy = ((cy + dy) % cols + cols) % cols; + if (_buckets.TryGetValue((gx, gy), out var list)) foreach (var npc in list) action(npc); } } diff --git a/scripts/Toroidal.cs b/scripts/Toroidal.cs new file mode 100644 index 0000000..b074d18 --- /dev/null +++ b/scripts/Toroidal.cs @@ -0,0 +1,59 @@ +using Godot; + +namespace WorldSim; + +/// +/// The world is a torus — a boundless globe with no edges or corners. Walk +/// off the right and you reappear on the left; off the bottom, the top. Every +/// position comparison in the sim must account for the seam: the shortest path +/// between two points may cross an edge. All distance/direction/movement math +/// routes through here so the whole world agrees the map wraps. +/// +/// The wrap size is World.GridSize (positions live in world units, same space +/// as node cells and NPC positions). +/// +public static class Toroidal +{ + public static float Size => World.GridSize; + + /// Wrap a scalar coordinate into [0, Size). + public static float WrapCoord(float v) + { + float s = Size; + v %= s; + return v < 0f ? v + s : v; + } + + /// Wrap a position so it lives inside the world. + public static Vector2 Wrap(Vector2 p) => new(WrapCoord(p.X), WrapCoord(p.Y)); + + /// + /// The shortest displacement FROM a TO b across the torus — each axis takes + /// whichever way (direct or across the seam) is nearer. Result components + /// are in [-Size/2, Size/2]. Use this for direction and distance. + /// + public static Vector2 Delta(Vector2 a, Vector2 b) + { + float s = Size, half = s * 0.5f; + float dx = b.X - a.X, dy = b.Y - a.Y; + if (dx > half) dx -= s; else if (dx < -half) dx += s; + if (dy > half) dy -= s; else if (dy < -half) dy += s; + return new Vector2(dx, dy); + } + + /// Shortest distance between two points on the torus. + public static float Distance(Vector2 a, Vector2 b) => Delta(a, b).Length(); + + /// Shortest squared distance — for cheap radius comparisons. + public static float DistanceSquared(Vector2 a, Vector2 b) => Delta(a, b).LengthSquared(); + + /// Move `from` toward `target` by `step`, taking the seam-shortest + /// path and wrapping the result back into the world. + public static Vector2 MoveToward(Vector2 from, Vector2 target, float step) + { + Vector2 d = Delta(from, target); + float len = d.Length(); + if (len <= step || len < 0.0001f) return Wrap(target); + return Wrap(from + d / len * step); + } +} diff --git a/scripts/World.cs b/scripts/World.cs index 2a07b79..58697ef 100644 --- a/scripts/World.cs +++ b/scripts/World.cs @@ -28,7 +28,7 @@ public class ResourceNode /// public class World { - public const int GridSize = 1152; + public const int GridSize = 1280; public List Nodes { get; } = new(); @@ -63,15 +63,15 @@ public class World /// public void Generate() { - // Resource counts scale with area (1152² ≈ 2.25× the 768² world) so - // density holds as the towns spread to wider cardinal points. - PlaceMany(MaterialKind.Water, count: 90, amount: float.PositiveInfinity, regen: 0f); - PlaceMany(MaterialKind.Clay, count: 180, amount: 40f, regen: 0.5f); - PlaceMany(MaterialKind.Wood, count: 1080, amount: 30f, regen: 2f); - PlaceMany(MaterialKind.Stone, count: 315, amount: 80f, regen: 0f); - PlaceMany(MaterialKind.Food, count: 810, amount: 20f, regen: 4f); - PlaceMany(MaterialKind.Ore, count: 90, amount: 60f, regen: 0f); - PlaceMany(MaterialKind.Herb, count: 270, amount: 15f, regen: 3f); + // Resource counts scale with area (1280² ≈ 1.23× the 1152² world) so + // density holds across the boundless globe. + PlaceMany(MaterialKind.Water, count: 110, amount: float.PositiveInfinity, regen: 0f); + PlaceMany(MaterialKind.Clay, count: 220, amount: 40f, regen: 0.5f); + PlaceMany(MaterialKind.Wood, count: 1330, amount: 30f, regen: 2f); + PlaceMany(MaterialKind.Stone, count: 390, amount: 80f, regen: 0f); + PlaceMany(MaterialKind.Food, count: 1000, amount: 20f, regen: 4f); + PlaceMany(MaterialKind.Ore, count: 110, amount: 60f, regen: 0f); + PlaceMany(MaterialKind.Herb, count: 330, amount: 15f, regen: 3f); } private void PlaceMany(MaterialKind kind, int count, float amount, float regen) @@ -137,14 +137,17 @@ public class World { float r2 = radius * radius; int reach = (int)(radius / NodeCell) + 1; + int cols = (GridSize + NodeCell - 1) / NodeCell; // cells per axis int cx = (int)(position.X / NodeCell); int cy = (int)(position.Y / NodeCell); for (int dx = -reach; dx <= reach; dx++) for (int dy = -reach; dy <= reach; dy++) { - if (!_nodeCells.TryGetValue((cx + dx, cy + dy), out var list)) continue; + int gx = ((cx + dx) % cols + cols) % cols; // wrap at the seam + int gy = ((cy + dy) % cols + cols) % cols; + if (!_nodeCells.TryGetValue((gx, gy), out var list)) continue; foreach (var node in list) - if (position.DistanceSquaredTo(node.Cell) <= r2) + if (Toroidal.DistanceSquared(position, node.Cell) <= r2) action(node); } } @@ -155,7 +158,7 @@ public class World { float r2 = radius * radius; foreach (var node in Nodes) - if (center.DistanceSquaredTo(node.Cell) <= r2) + if (Toroidal.DistanceSquared(center, node.Cell) <= r2) yield return node; } @@ -168,7 +171,7 @@ public class World foreach (var node in Nodes) { if (node.Kind != kind) continue; - float d = position.DistanceSquaredTo(node.Cell); + float d = Toroidal.DistanceSquared(position, node.Cell); if (d < bestDist) { best = node; bestDist = d; } } return best; @@ -225,7 +228,7 @@ public class World if (node.Kind != MaterialKind.Water && node.RegenPerDay > 0f && node.Amount <= node.MaxAmount * minFraction) continue; - float d = position.DistanceSquaredTo(node.Cell); + float d = Toroidal.DistanceSquared(position, node.Cell); if (d < bestDist) { best = node; bestDist = d; } } return best;