Town-colored homes, split diagonally for mixed-culture households

Shelters now wear their town's culture color on the map (North cool-blue,
South green, West gold, East red), dimmed by wear; granaries stay green,
ruins dark. And bonds can now cross town lines: usually you pair within
your own people (that's who you're near), but a wanderer who warms to
someone from another town shacks up across cultures - the first quiet
thread of contact between two peoples. When two towns share a roof, the
house is drawn split diagonally, half each culture, visible across the map.

Mechanics: buildings track their top-two resident home-towns (recomputed
with occupancy, throttled to every 30 ticks since it's map-only); the
bonding rule dropped its same-town requirement. A 240-day run produced
126 bonds, 40 of them cross-town (East+North, West+South, all combos),
with populations still balanced - inter-cultural contact without
destabilizing the towns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 21:38:57 -04:00
parent 36f6ba1f1b
commit 9e8ee1d748
4 changed files with 112 additions and 11 deletions
+7
View File
@@ -99,6 +99,13 @@ public class Building
/// whole population per building (that was the sim's O(n²) hot spot).</summary>
public int Occupancy;
/// <summary>The two most-represented home-towns among this shelter's
/// current sleepers — for the map's household coloring. When two towns
/// share a roof (someone shacked up across cultures), the house is drawn
/// split. Recomputed with Occupancy each tick.</summary>
public string ResidentTownA = "";
public string ResidentTownB = "";
public int RestCapacity =>
Kind == BuildingKind.Shelter && IsComplete && !IsRuined ? 3 + Modules : 0;
+55
View File
@@ -164,6 +164,14 @@ public partial class GameManager : Node2D
return new Vector2(World.GridSize / 2f, World.GridSize / 2f);
}
/// <summary>The soul culture of a named town (for map coloring).</summary>
public SoulType TownSoul(string town)
{
foreach (var t in _towns)
if (t.Name == town) return t.Soul;
return SoulType.Nature;
}
public Vector2 TownCentroid(string town)
{
Vector2 sum = Vector2.Zero;
@@ -325,6 +333,48 @@ public partial class GameManager : Node2D
Npcs.AddRange(newborns);
}
/// <summary>
/// Tally which home-towns sleep under each shelter, storing the top two.
/// A house with residents from two towns is a mixed household — someone
/// shacked up across cultures — and gets drawn split on the map.
/// </summary>
private void RecomputeHouseholds()
{
var counts = new Dictionary<Building, Dictionary<string, int>>();
foreach (var npc in Npcs)
{
if (npc.State != NpcState.Resting) continue;
Building? at = null; float best = 6.25f;
foreach (var b in Buildings)
{
if (b.RestCapacity <= 0) continue;
float d = npc.Position.DistanceSquaredTo(b.Site);
if (d < best) { best = d; at = b; }
}
if (at == null) continue;
if (!counts.TryGetValue(at, out var byTown))
counts[at] = byTown = new Dictionary<string, int>();
byTown[npc.HomeTown] = byTown.GetValueOrDefault(npc.HomeTown) + 1;
}
foreach (var b in Buildings)
{
b.ResidentTownA = "";
b.ResidentTownB = "";
if (!counts.TryGetValue(b, out var byTown)) continue;
string a = "", bTown = ""; int ca = 0, cb = 0;
foreach (var kv in byTown)
{
if (kv.Value > ca) { bTown = a; cb = ca; a = kv.Key; ca = kv.Value; }
else if (kv.Value > cb) { bTown = kv.Key; cb = kv.Value; }
}
b.ResidentTownA = a;
b.ResidentTownB = cb > 0 ? bTown : "";
}
}
/// <summary>Where the community's feet actually are — new communal
/// buildings are founded here, not at a scripted town center.</summary>
public Vector2 CommunityCentroid()
@@ -356,6 +406,11 @@ public partial class GameManager : Node2D
if (at != null) at.Occupancy++;
}
// Household composition (which cultures share each roof) — for the
// map only, so it's cheap and refreshed occasionally, not every tick.
if (TotalTicks % 30 == 0)
RecomputeHouseholds();
TotalTicks++;
foreach (var npc in Npcs)
+9 -4
View File
@@ -950,15 +950,20 @@ public class Npc
nearest = found;
// Consider bonding: an unbonded adult who keeps warm company with a
// compatible, unbonded neighbor may pair with them. Same town, real
// warmth, both grown companionship chosen, not assigned.
// compatible, unbonded neighbor may pair with them — real warmth,
// both grown, companionship chosen not assigned. Town is NOT a
// requirement: usually you bond within your own people (that's who
// you're near), but a wanderer who warms to someone from another
// 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 &&
n.HomeTown == HomeTown && Position.DistanceTo(n.Position) < 4f &&
Position.DistanceTo(n.Position) < 4f &&
GetWarmth(n.Name) > 0.4f && n.GetWarmth(Name) > 0.4f)
{
Partner = n;
n.Partner = this;
Godot.GD.Print($"[Bond] day {gm.Day}: {Name} and {n.Name} ({HomeTown}) pair up.");
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)
{
+41 -7
View File
@@ -83,18 +83,52 @@ public partial class Visualization : Node2D
}
// --- Map: buildings ----------------------------------------------
// Shelters wear their town's culture-color; granaries stay green;
// ruins go dark. A house shared by two towns (a mixed household) is
// split diagonally, half each culture — you can see across the map
// where someone shacked up with a neighbor from another people.
for (int i = 0; i < _gm.Buildings.Count; i++)
{
var b = _gm.Buildings[i];
Color c = b.IsRuined ? new Color(0.25f, 0.23f, 0.20f)
: b.Kind == BuildingKind.Granary
? (b.IsComplete ? new Color(0.55f, 0.70f, 0.40f)
: new Color(0.45f, 0.52f, 0.32f))
: b.IsComplete ? new Color(0.9f, 0.85f, 0.6f).Darkened((1f - b.Condition) * 0.5f)
: new Color(0.6f, 0.5f, 0.3f);
float size = CellPixels + 3f + (b.CurrentStage + b.StageProgress) * 2f + b.Modules * 1.5f;
var p = MapPos(b.Site);
DrawRect(new Rect2(p.X - size / 2f, p.Y - size / 2f, size, size), c);
var rect = new Rect2(p.X - size / 2f, p.Y - size / 2f, size, size);
if (b.IsRuined)
{
DrawRect(rect, new Color(0.25f, 0.23f, 0.20f));
}
else if (b.Kind == BuildingKind.Granary)
{
DrawRect(rect, b.IsComplete ? new Color(0.55f, 0.70f, 0.40f)
: new Color(0.45f, 0.52f, 0.32f));
}
else
{
// Base color = the house's own town culture, dimmed by wear.
float dim = b.IsComplete ? (1f - b.Condition) * 0.5f : 0.35f;
Color own = TownColor(_gm.TownSoul(b.Town)).Darkened(dim);
bool mixed = b.ResidentTownB != "" && b.ResidentTownB != b.ResidentTownA;
if (mixed)
{
// Split diagonally: town A on the upper-left triangle,
// town B on the lower-right.
Color ca = TownColor(_gm.TownSoul(b.ResidentTownA)).Darkened(dim);
Color cb = TownColor(_gm.TownSoul(b.ResidentTownB)).Darkened(dim);
var tl = new Vector2(rect.Position.X, rect.Position.Y);
var tr = new Vector2(rect.End.X, rect.Position.Y);
var br = new Vector2(rect.End.X, rect.End.Y);
var bl = new Vector2(rect.Position.X, rect.End.Y);
DrawColoredPolygon(new[] { tl, tr, bl }, ca); // upper-left ▲
DrawColoredPolygon(new[] { tr, br, bl }, cb); // lower-right ▼
}
else
{
DrawRect(rect, own);
}
}
DrawString(font, p + new Vector2(size / 2f + 2f, 4f), $"{i + 1}",
fontSize: 10, modulate: TextDim);
}