Compare commits

...

3 Commits

Author SHA1 Message Date
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
mmcghen e6fae4f305 Unbound exploration: let towns genuinely migrate
Reverts the home-range bound on exploration and the homestead anchor-clamp.
A town that must range far to eat now relocates for real - the South
drifting inland after a hard winter, an East scout wandering across the
map and homesteading where they land (some homes now 648 units from the
anchor). Kept the identity-hash explore direction (no southward bias) and
the centroid-relative communal cohesion clamp (that follows migration, it
doesn't fight it). Towns still hold distinct cultures and stable
populations - migration is emergent, not collapse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 11:34:23 -04:00
mmcghen f4e55feff7 Fix the southward migration: explore direction had a hardcoded +Y bias
Root cause of towns (East worst) drifting south: when an exploring soul was
at its anchor with no outward vector, the fallback direction was
Vector2(PersonalityModifier, 1 - Abs(PersonalityModifier)) - and with
PersonalityModifier tiny (+-0.2) that Y-component was always ~+0.9. +Y is
south, so every town's explorers got shoved into the southern corner. East
showed it worst (selfish souls strip resources fastest, so they explore
most). Now the at-anchor direction comes from the soul's identity hash,
spread around the full compass.

Supporting fixes: exploration is bounded to a ~140-unit home range (forage
out and back, don't march to the horizon); private homesteads clamp to
within 120 units of the town anchor (selfish souls sprawl on their OWN
land, not into a neighbor's). Result: every East home now founds near the
east anchor (x~870 vs the anchor's 962), none in South's territory, and
East is the healthiest town.

Also widened the map view (880 -> 1060px) to use more of the window - the
world grew to 1152 but the render was a fixed square, so it looked no bigger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:29:50 -04:00
6 changed files with 158 additions and 70 deletions
+24 -16
View File
@@ -29,10 +29,14 @@ public partial class GameManager : Node2D
public IReadOnlyList<TownDef> Towns => _towns; public IReadOnlyList<TownDef> Towns => _towns;
private readonly List<TownDef> _towns = new() private readonly List<TownDef> _towns = new()
{ {
new("North", new Vector2(576f, 190f), SoulType.Generational), // 2×2 even grid on the torus: towns at the quarter/three-quarter
new("South", new Vector2(576f, 962f), SoulType.Nature), // points of each axis (320 and 960 on a 1280 world). Every town is
new("West", new Vector2(190f, 576f), SoulType.Materialist), // exactly 640 units from each of its two axis-neighbours in all
new("East", new Vector2(962f, 576f), SoulType.Selfish), // 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 private static SoulProfile ProfileFor(SoulType t) => t switch
@@ -116,7 +120,7 @@ public partial class GameManager : Node2D
HomeTown = town, HomeTown = town,
Soul = soul, Soul = soul,
PersonalityModifier = (float)(rng.NextDouble() * 0.4 - 0.2), 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 // Staggered ages so the founding generation doesn't die
// in one terrible week. // in one terrible week.
AgeDays = rng.Next(0, 100), AgeDays = rng.Next(0, 100),
@@ -142,7 +146,7 @@ public partial class GameManager : Node2D
int count = 0; int count = 0;
foreach (var npc in Npcs) foreach (var npc in Npcs)
if (npc != except && npc.State == NpcState.Resting && if (npc != except && npc.State == NpcState.Resting &&
npc.Position.DistanceTo(b.Site) <= 2.5f) Toroidal.Distance(npc.Position, b.Site) <= 2.5f)
count++; count++;
return count; return count;
} }
@@ -174,21 +178,25 @@ public partial class GameManager : Node2D
public Vector2 TownCentroid(string town) 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; Vector2 sum = Vector2.Zero;
int count = 0; int count = 0;
foreach (var npc in Npcs) foreach (var npc in Npcs)
{ {
if (npc.HomeTown != town) continue; if (npc.HomeTown != town) continue;
sum += npc.Position; sum += Toroidal.Delta(anchor, npc.Position);
count++; count++;
} }
if (count == 0) return TownAnchor(town); if (count == 0) return anchor;
// Anchor-weighted: the settlement's build-center stays near its // Anchor-weighted (0.7 toward the seed) so a settlement keeps its
// cardinal seed even as villagers roam outward to distant resources. // place as villagers roam, then wrapped back into the world.
// Towns keep their place on the compass instead of drifting to the Vector2 mean = sum / count;
// middle of the map where everyone's foraging paths overlap. return Toroidal.Wrap(anchor + mean * 0.3f);
return (sum / count).Lerp(TownAnchor(town), 0.7f);
} }
public override void _Process(double delta) public override void _Process(double delta)
@@ -221,7 +229,7 @@ public partial class GameManager : Node2D
Grid.ForEachNear(a.Position, radius, b => Grid.ForEachNear(a.Position, radius, b =>
{ {
if (b == a) return; if (b == a) return;
float dist = a.Position.DistanceTo(b.Position); float dist = Toroidal.Distance(a.Position, b.Position);
if (dist > radius) return; if (dist > radius) return;
float falloff = 1f - dist / radius; float falloff = 1f - dist / radius;
float fromA = Mathf.Lerp(-strength, strength, a.Imprint.Total) * falloff; float fromA = Mathf.Lerp(-strength, strength, a.Imprint.Total) * falloff;
@@ -353,7 +361,7 @@ public partial class GameManager : Node2D
foreach (var b in Buildings) foreach (var b in Buildings)
{ {
if (b.RestCapacity <= 0) continue; 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 (d < best) { best = d; at = b; }
} }
if (at == null) continue; if (at == null) continue;
@@ -404,7 +412,7 @@ public partial class GameManager : Node2D
foreach (var b in Buildings) foreach (var b in Buildings)
{ {
if (b.RestCapacity <= 0) continue; 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 (d < best) { best = d; at = b; }
} }
if (at != null) at.Occupancy++; if (at != null) at.Occupancy++;
+47 -34
View File
@@ -61,7 +61,7 @@ public class Npc
public void SenseStructures(GameManager gm) public void SenseStructures(GameManager gm)
{ {
foreach (var b in gm.Buildings) foreach (var b in gm.Buildings)
if (Position.DistanceSquaredTo(b.Site) <= SensingRadius * SensingRadius) if (Toroidal.DistanceSquared(Position, b.Site) <= SensingRadius * SensingRadius)
KnownStructures.Add(b); KnownStructures.Add(b);
} }
@@ -97,14 +97,22 @@ public class Npc
private void Explore(GameManager gm) private void Explore(GameManager gm)
{ {
Activity = "exploring for new ground"; Activity = "exploring for new ground";
Vector2 anchor = gm.TownAnchor(HomeTown); // Outward from home along the torus-shortest direction, unbounded — a
Vector2 outward = (Position - anchor); // town that must range far to eat genuinely relocates over time, and
if (outward.LengthSquared() < 1f) // on a boundless globe an explorer can circle the whole world. When
outward = new Vector2(PersonalityModifier, 1f - Mathf.Abs(PersonalityModifier)); // right at the anchor there's no outward vector, so pick a per-soul
outward = outward.Normalized(); // angle from identity (not a fixed vector — that once leaned south).
// A little personality-driven veer so explorers fan out, not conga-line. Vector2 fromHome = Toroidal.Delta(gm.TownAnchor(HomeTown), Position);
outward = outward.Rotated(PersonalityModifier * 1.2f); Vector2 dir;
Position += outward * MoveSpeed; if (fromHome.LengthSquared() > 1f)
dir = fromHome.Normalized();
else
{
float ang = (Name.GetHashCode() & 0xFFFF) / 65535f * Mathf.Tau;
dir = new Vector2(Mathf.Cos(ang), Mathf.Sin(ang));
}
dir = dir.Rotated(PersonalityModifier * 1.2f); // personality veer, so explorers fan out
Position = Toroidal.Wrap(Position + dir * MoveSpeed);
SenseNodes(gm.World); SenseNodes(gm.World);
} }
@@ -123,7 +131,7 @@ public class Npc
if (node.Kind != MaterialKind.Water && node.RegenPerDay > 0f && if (node.Kind != MaterialKind.Water && node.RegenPerDay > 0f &&
node.Amount <= node.MaxAmount * minFraction) node.Amount <= node.MaxAmount * minFraction)
continue; continue;
float d = Position.DistanceSquaredTo(node.Cell); float d = Toroidal.DistanceSquared(Position, node.Cell);
if (d < bestDist) { bestDist = d; best = node; } if (d < bestDist) { bestDist = d; best = node; }
} }
return best; return best;
@@ -368,7 +376,7 @@ public class Npc
} }
private void MoveToward(Vector2 target) => private void MoveToward(Vector2 target) =>
Position = Position.MoveToward(target, MoveSpeed); Position = Toroidal.MoveToward(Position, target, MoveSpeed);
// --- Demand ---------------------------------------------------------- // --- Demand ----------------------------------------------------------
@@ -511,7 +519,7 @@ public class Npc
{ {
Activity = "fetching food from the granary"; Activity = "fetching food from the granary";
MoveToward(_foodSource.Site); MoveToward(_foodSource.Site);
if (Position.DistanceTo(_foodSource.Site) < ArriveDist) if (Toroidal.Distance(Position, _foodSource.Site) < ArriveDist)
{ {
float got = _foodSource.WithdrawFood(8f); float got = _foodSource.WithdrawFood(8f);
Inventory.TryGetValue(MaterialKind.Food, out float haveF); Inventory.TryGetValue(MaterialKind.Food, out float haveF);
@@ -524,7 +532,7 @@ public class Npc
{ {
Activity = $"asking {_giftSource.Name} for food"; Activity = $"asking {_giftSource.Name} for food";
MoveToward(_giftSource.Position); MoveToward(_giftSource.Position);
if (Position.DistanceTo(_giftSource.Position) < ArriveDist) if (Toroidal.Distance(Position, _giftSource.Position) < ArriveDist)
{ {
TradeSystem.RequestGift(_giftSource, this); TradeSystem.RequestGift(_giftSource, this);
_giftSource = null; _giftSource = null;
@@ -596,7 +604,7 @@ public class Npc
float warmth = GetWarmth(other.Name); float warmth = GetWarmth(other.Name);
if (warmth < -0.3f) return; if (warmth < -0.3f) return;
if (!other.Inventory.TryGetValue(MaterialKind.Food, out float f) || f <= 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; } if (score > bestScore) { bestScore = score; giftFound = other; }
}); });
_giftSource = giftFound; _giftSource = giftFound;
@@ -614,7 +622,7 @@ public class Npc
} }
MoveToward(_targetNode.Cell); MoveToward(_targetNode.Cell);
if (Position.DistanceTo(_targetNode.Cell) >= ArriveDist) if (Toroidal.Distance(Position, _targetNode.Cell) >= ArriveDist)
{ {
Activity = $"walking to {_targetNode.Kind.ToString().ToLower()}"; Activity = $"walking to {_targetNode.Kind.ToString().ToLower()}";
} }
@@ -653,7 +661,7 @@ public class Npc
{ {
if (other == this) return; if (other == this) return;
if (!TradeSystem.CanTrade(this, other, out _)) 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; } if (d < bestDist) { bestDist = d; partner = other; }
}); });
_targetNpc = partner; _targetNpc = partner;
@@ -662,7 +670,7 @@ public class Npc
Activity = $"bringing goods to {_targetNpc.Name}"; Activity = $"bringing goods to {_targetNpc.Name}";
MoveToward(_targetNpc.Position); MoveToward(_targetNpc.Position);
if (Position.DistanceTo(_targetNpc.Position) < ArriveDist) if (Toroidal.Distance(Position, _targetNpc.Position) < ArriveDist)
{ {
if (TradeSystem.CanTrade(this, _targetNpc, out var material)) if (TradeSystem.CanTrade(this, _targetNpc, out var material))
TradeSystem.Execute(this, _targetNpc, material); TradeSystem.Execute(this, _targetNpc, material);
@@ -692,7 +700,7 @@ public class Npc
{ {
if (b.Town != HomeTown || b.IsComplete || b.IsRuined || if (b.Town != HomeTown || b.IsComplete || b.IsRuined ||
b.Kind != BuildingKind.Shelter) continue; 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; } if (d < best) { best = d; mine = b; }
} }
return mine; return mine;
@@ -721,6 +729,10 @@ public class Npc
Enter(NpcState.Gathering); // no stone yet — go get it Enter(NpcState.Gathering); // no stone yet — go get it
return; return;
} }
// Homestead where you actually live — no snapping back to the
// town anchor. If a people have migrated (following food inland
// after a hard winter), their homes rise where they now are; the
// town genuinely moves.
Vector2 spot = _hasHomeSpot ? _homeSpot : Position; Vector2 spot = _hasHomeSpot ? _homeSpot : Position;
mine = Building.NewShelter(spot); mine = Building.NewShelter(spot);
mine.Town = HomeTown; mine.Town = HomeTown;
@@ -729,12 +741,13 @@ public class Npc
// Don't clear the homeless drive yet — a foundation isn't a home. // Don't clear the homeless drive yet — a foundation isn't a home.
// It resets only once the roof is on (below), so the builder keeps // It resets only once the roof is on (below), so the builder keeps
// supplying walls and roof instead of stalling at the foundation. // supplying walls and roof instead of stalling at the foundation.
Godot.GD.Print($"[Homestead] day {gm.Day}: {Name} ({HomeTown}) breaks ground on a private home."); Godot.GD.Print($"[Homestead] day {gm.Day}: {Name} ({HomeTown}) breaks ground at " +
$"({mine.Site.X:0},{mine.Site.Y:0}) — {(mine.Site - gm.TownAnchor(HomeTown)).Length():0} from home.");
} }
Activity = "building my own home"; Activity = "building my own home";
MoveToward(mine.Site); MoveToward(mine.Site);
if (Position.DistanceTo(mine.Site) < ArriveDist) if (Toroidal.Distance(Position, mine.Site) < ArriveDist)
{ {
mine.Deliver(this); mine.Deliver(this);
if (mine.IsComplete) if (mine.IsComplete)
@@ -774,7 +787,7 @@ public class Npc
foreach (var b in gm.Buildings) foreach (var b in gm.Buildings)
{ {
if (b.Town != HomeTown || b.IsComplete) continue; 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 (d < bestDist) { bestDist = d; site = b; }
} }
if (site == null) if (site == null)
@@ -782,7 +795,7 @@ public class Npc
foreach (var b in gm.Buildings) foreach (var b in gm.Buildings)
{ {
if (b.Town != HomeTown || !b.WantsUpkeepWood) continue; 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; } if (d < bestDist) { bestDist = d; site = b; }
} }
} }
@@ -795,7 +808,7 @@ public class Npc
foreach (var b in gm.Buildings) foreach (var b in gm.Buildings)
{ {
if (b.Town != HomeTown || !b.WantsFood) continue; 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; } if (d < bestDist) { bestDist = d; site = b; }
} }
} }
@@ -805,7 +818,7 @@ public class Npc
foreach (var b in gm.Buildings) foreach (var b in gm.Buildings)
{ {
if (b.Town != HomeTown || !b.WantsExpansion) continue; 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; } if (d < bestDist) { bestDist = d; site = b; }
} }
} }
@@ -858,7 +871,7 @@ public class Npc
if (quarry == null) { Enter(NpcState.Socializing); return; } if (quarry == null) { Enter(NpcState.Socializing); return; }
Activity = "returning stone to the quarry"; Activity = "returning stone to the quarry";
MoveToward(quarry.Cell); 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); quarry.Amount = Mathf.Min(quarry.MaxAmount, quarry.Amount + stoneCarried);
Inventory[MaterialKind.Stone] = 0f; Inventory[MaterialKind.Stone] = 0f;
@@ -880,7 +893,7 @@ public class Npc
: $"expanding {kindName}"; : $"expanding {kindName}";
MoveToward(site.Site); MoveToward(site.Site);
if (Position.DistanceTo(site.Site) < ArriveDist) if (Toroidal.Distance(Position, site.Site) < ArriveDist)
{ {
bool delivered = site.Deliver(this); bool delivered = site.Deliver(this);
if (!delivered || site.IsComplete) Enter(NpcState.Socializing); if (!delivered || site.IsComplete) Enter(NpcState.Socializing);
@@ -898,10 +911,10 @@ public class Npc
if (b.RestCapacity <= 0) continue; if (b.RestCapacity <= 0) continue;
// Read the cached per-tick occupancy; +1 to leave room for us if // Read the cached per-tick occupancy; +1 to leave room for us if
// we're not already counted there. // 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; int taken = alreadyHere ? b.Occupancy - 1 : b.Occupancy;
if (taken >= b.RestCapacity) continue; 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; } if (d < bestDist) { bestDist = d; shelter = b; }
} }
@@ -910,7 +923,7 @@ public class Npc
bool sheltered = false; bool sheltered = false;
if (shelter != null) 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 else sheltered = true; // we selected a shelter with a free bed
} }
Activity = sheltered ? "resting at shelter" Activity = sheltered ? "resting at shelter"
@@ -965,7 +978,7 @@ public class Npc
}); });
if (teacher != null) if (teacher != null)
{ {
if (Position.DistanceTo(teacher.Position) > 3f) if (Toroidal.Distance(Position, teacher.Position) > 3f)
{ {
Activity = $"seeking out {teacher.Name} to learn"; Activity = $"seeking out {teacher.Name} to learn";
MoveToward(teacher.Position); MoveToward(teacher.Position);
@@ -984,7 +997,7 @@ public class Npc
// relationships are a pull the work queue doesn't override. // relationships are a pull the work queue doesn't override.
if (IsBonded && Partner!.Health > 0f) if (IsBonded && Partner!.Health > 0f)
{ {
if (Position.DistanceTo(Partner.Position) > 3f) if (Toroidal.Distance(Position, Partner.Position) > 3f)
{ {
Activity = $"with {Partner.Name}"; Activity = $"with {Partner.Name}";
MoveToward(Partner.Position); MoveToward(Partner.Position);
@@ -1005,7 +1018,7 @@ public class Npc
gm.Grid.ForEachNear(Position, 30f, other => gm.Grid.ForEachNear(Position, 30f, other =>
{ {
if (other == this) return; if (other == this) return;
float d = Position.DistanceSquaredTo(other.Position); float d = Toroidal.DistanceSquared(Position, other.Position);
if (d < fBest) { fBest = d; found = other; } if (d < fBest) { fBest = d; found = other; }
}); });
nearest = found; nearest = found;
@@ -1018,7 +1031,7 @@ public class Npc
// town can shack up across cultures — the first quiet thread of // town can shack up across cultures — the first quiet thread of
// contact between two peoples. // contact between two peoples.
if (!IsBonded && IsAdult && nearest is { IsBonded: false, IsAdult: true } n && 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) GetWarmth(n.Name) > 0.4f && n.GetWarmth(Name) > 0.4f)
{ {
Partner = n; Partner = n;
@@ -1026,7 +1039,7 @@ public class Npc
string kind = n.HomeTown == HomeTown ? HomeTown : $"{HomeTown}+{n.HomeTown}"; string kind = n.HomeTown == HomeTown ? HomeTown : $"{HomeTown}+{n.HomeTown}";
Godot.GD.Print($"[Bond] day {gm.Day}: {Name} and {n.Name} ({kind}) pair up."); 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}"; Activity = $"walking over to {nearest.Name}";
MoveToward(nearest.Position); MoveToward(nearest.Position);
+6 -1
View File
@@ -41,11 +41,16 @@ public class SpatialGrid
public void ForEachNear(Vector2 center, float radius, System.Action<Npc> action) public void ForEachNear(Vector2 center, float radius, System.Action<Npc> action)
{ {
int reach = Mathf.CeilToInt(radius / _cell); int reach = Mathf.CeilToInt(radius / _cell);
int cols = Mathf.CeilToInt(Toroidal.Size / _cell); // buckets per axis
var (cx, cy) = Key(center); 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 dx = -reach; dx <= reach; dx++)
for (int dy = -reach; dy <= reach; dy++) 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); foreach (var npc in list) action(npc);
} }
} }
+59
View File
@@ -0,0 +1,59 @@
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);
}
}
+4 -4
View File
@@ -14,10 +14,10 @@ public partial class Visualization : Node2D
// Layout: 1600×900 window — map fills the left square, panel the right. // Layout: 1600×900 window — map fills the left square, panel the right.
private const float MapOffset = 10f; private const float MapOffset = 10f;
private const float MapPixels = 880f; private const float MapPixels = 1060f; // fills most of the 1600px window
private const float CellPixels = MapPixels / World.GridSize; // ~3.44 private const float CellPixels = MapPixels / World.GridSize;
private const float PanelX = 910f; private const float PanelX = 1085f;
private const float PanelWidth = 680f; private const float PanelWidth = 505f;
private static readonly Color PanelBg = new(0.10f, 0.10f, 0.12f); private static readonly Color PanelBg = new(0.10f, 0.10f, 0.12f);
private static readonly Color MapBg = new(0.13f, 0.13f, 0.15f); private static readonly Color MapBg = new(0.13f, 0.13f, 0.15f);
+18 -15
View File
@@ -28,7 +28,7 @@ public class ResourceNode
/// </summary> /// </summary>
public class World public class World
{ {
public const int GridSize = 1152; public const int GridSize = 1280;
public List<ResourceNode> Nodes { get; } = new(); public List<ResourceNode> Nodes { get; } = new();
@@ -63,15 +63,15 @@ public class World
/// </summary> /// </summary>
public void Generate() public void Generate()
{ {
// Resource counts scale with area (1152² ≈ 2.25× the 768² world) so // Resource counts scale with area (1280² ≈ 1.23× the 1152² world) so
// density holds as the towns spread to wider cardinal points. // density holds across the boundless globe.
PlaceMany(MaterialKind.Water, count: 90, amount: float.PositiveInfinity, regen: 0f); PlaceMany(MaterialKind.Water, count: 110, amount: float.PositiveInfinity, regen: 0f);
PlaceMany(MaterialKind.Clay, count: 180, amount: 40f, regen: 0.5f); PlaceMany(MaterialKind.Clay, count: 220, amount: 40f, regen: 0.5f);
PlaceMany(MaterialKind.Wood, count: 1080, amount: 30f, regen: 2f); PlaceMany(MaterialKind.Wood, count: 1330, amount: 30f, regen: 2f);
PlaceMany(MaterialKind.Stone, count: 315, amount: 80f, regen: 0f); PlaceMany(MaterialKind.Stone, count: 390, amount: 80f, regen: 0f);
PlaceMany(MaterialKind.Food, count: 810, amount: 20f, regen: 4f); PlaceMany(MaterialKind.Food, count: 1000, amount: 20f, regen: 4f);
PlaceMany(MaterialKind.Ore, count: 90, amount: 60f, regen: 0f); PlaceMany(MaterialKind.Ore, count: 110, amount: 60f, regen: 0f);
PlaceMany(MaterialKind.Herb, count: 270, amount: 15f, regen: 3f); PlaceMany(MaterialKind.Herb, count: 330, amount: 15f, regen: 3f);
} }
private void PlaceMany(MaterialKind kind, int count, float amount, float regen) private void PlaceMany(MaterialKind kind, int count, float amount, float regen)
@@ -137,14 +137,17 @@ public class World
{ {
float r2 = radius * radius; float r2 = radius * radius;
int reach = (int)(radius / NodeCell) + 1; int reach = (int)(radius / NodeCell) + 1;
int cols = (GridSize + NodeCell - 1) / NodeCell; // cells per axis
int cx = (int)(position.X / NodeCell); int cx = (int)(position.X / NodeCell);
int cy = (int)(position.Y / NodeCell); int cy = (int)(position.Y / NodeCell);
for (int dx = -reach; dx <= reach; dx++) for (int dx = -reach; dx <= reach; dx++)
for (int dy = -reach; dy <= reach; dy++) 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) foreach (var node in list)
if (position.DistanceSquaredTo(node.Cell) <= r2) if (Toroidal.DistanceSquared(position, node.Cell) <= r2)
action(node); action(node);
} }
} }
@@ -155,7 +158,7 @@ public class World
{ {
float r2 = radius * radius; float r2 = radius * radius;
foreach (var node in Nodes) foreach (var node in Nodes)
if (center.DistanceSquaredTo(node.Cell) <= r2) if (Toroidal.DistanceSquared(center, node.Cell) <= r2)
yield return node; yield return node;
} }
@@ -168,7 +171,7 @@ public class World
foreach (var node in Nodes) foreach (var node in Nodes)
{ {
if (node.Kind != kind) continue; 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; } if (d < bestDist) { best = node; bestDist = d; }
} }
return best; return best;
@@ -225,7 +228,7 @@ public class World
if (node.Kind != MaterialKind.Water && node.RegenPerDay > 0f && if (node.Kind != MaterialKind.Water && node.RegenPerDay > 0f &&
node.Amount <= node.MaxAmount * minFraction) node.Amount <= node.MaxAmount * minFraction)
continue; continue;
float d = position.DistanceSquaredTo(node.Cell); float d = Toroidal.DistanceSquared(position, node.Cell);
if (d < bestDist) { best = node; bestDist = d; } if (d < bestDist) { best = node; bestDist = d; }
} }
return best; return best;