Files
mmcghen eb4b2fdd19 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>
2026-07-23 11:48:40 -04:00

60 lines
2.3 KiB
C#

using Godot;
namespace WorldSim;
/// <summary>
/// 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).
/// </summary>
public static class Toroidal
{
public static float Size => World.GridSize;
/// <summary>Wrap a scalar coordinate into [0, Size).</summary>
public static float WrapCoord(float v)
{
float s = Size;
v %= s;
return v < 0f ? v + s : v;
}
/// <summary>Wrap a position so it lives inside the world.</summary>
public static Vector2 Wrap(Vector2 p) => new(WrapCoord(p.X), WrapCoord(p.Y));
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>Shortest distance between two points on the torus.</summary>
public static float Distance(Vector2 a, Vector2 b) => Delta(a, b).Length();
/// <summary>Shortest squared distance — for cheap radius comparisons.</summary>
public static float DistanceSquared(Vector2 a, Vector2 b) => Delta(a, b).LengthSquared();
/// <summary>Move `from` toward `target` by `step`, taking the seam-shortest
/// path and wrapping the result back into the world.</summary>
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);
}
}