Files
Soul_Game/scripts/SpatialGrid.cs
T
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

58 lines
2.1 KiB
C#

using System.Collections.Generic;
using Godot;
namespace WorldSim;
/// <summary>
/// A coarse spatial hash over NPC positions so proximity queries stop being
/// O(n²). Rebuilt once per tick from the live NPC list; queries return only
/// the buckets within range. At ~80 NPCs this turns every soul-pressure,
/// neighbor, and teacher scan from "look at everyone" into "look at the few
/// nearby" — the difference between a live view that stutters and one that
/// doesn't.
/// </summary>
public class SpatialGrid
{
private readonly float _cell;
private readonly Dictionary<(int, int), List<Npc>> _buckets = new();
public SpatialGrid(float cellSize) => _cell = cellSize;
private (int, int) Key(Vector2 p) =>
(Mathf.FloorToInt(p.X / _cell), Mathf.FloorToInt(p.Y / _cell));
public void Rebuild(IReadOnlyList<Npc> npcs)
{
foreach (var list in _buckets.Values) list.Clear();
foreach (var npc in npcs)
{
var key = Key(npc.Position);
if (!_buckets.TryGetValue(key, out var list))
_buckets[key] = list = new List<Npc>();
list.Add(npc);
}
}
/// <summary>
/// Invoke <paramref name="action"/> for every NPC within <paramref
/// name="radius"/> of <paramref name="center"/> (excluding none — caller
/// filters). Scans only the buckets the radius touches.
/// </summary>
public void ForEachNear(Vector2 center, float radius, System.Action<Npc> 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++)
{
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);
}
}
}