Toroidal world: boundless wrapping globe, 2x2 even town grid

The map is now a torus - walk off any edge and reappear on the opposite
side, no edges or corners. A new Toroidal helper carries all wrap-aware
distance/direction/movement math, and every position comparison in the sim
routes through it: soul pressure, node + structure sensing, gift/teacher/
neighbor scans, bonding, movement, the spatial grids (NPC and node cell
index both wrap their bucket lookups at the seam), and town centroid
(averaged in anchor-relative deltas, since averaging raw coordinates across
the seam is meaningless).

Towns re-laid as a 2x2 even grid on the torus (quarter/three-quarter points,
320 & 960 on a 1280 world): every town sits exactly 640 units from each of
its two axis-neighbours in all directions, wrap included - no more edge-boxed
North/South that could only forage inward. World grew 1152 -> 1280 with
resources scaled to area.

Verified over a 240-day soak: all four towns alive and balanced (pop 23-25),
population stable ~96, 119 bonds (27 cross-town), cultures still diverge, and
wrapped distances correctly cap at the torus half-diagonal (no false
across-seam blowups). 183s headless.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 11:48:40 -04:00
parent e6fae4f305
commit eb4b2fdd19
5 changed files with 140 additions and 69 deletions
+24 -16
View File
@@ -29,10 +29,14 @@ public partial class GameManager : Node2D
public IReadOnlyList<TownDef> Towns => _towns;
private readonly List<TownDef> _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++;