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

245 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Godot;
namespace WorldSim;
/// <summary>
/// Top-down 2D view + debug roster (SPEC §9).
/// Draws straight from simulation state every frame — no per-NPC nodes,
/// no camera: fixed screen-space layout, map on the left, roster on the
/// right. The player-facing game has no meters; the designer sees everything.
/// </summary>
public partial class Visualization : Node2D
{
private GameManager _gm = null!;
// Layout: 1600×900 window — map fills the left square, panel the right.
private const float MapOffset = 10f;
private const float MapPixels = 1060f; // fills most of the 1600px window
private const float CellPixels = MapPixels / World.GridSize;
private const float PanelX = 1085f;
private const float PanelWidth = 505f;
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 TextDim = new(0.65f, 0.65f, 0.70f);
private static readonly Color TextMain = new(0.92f, 0.92f, 0.95f);
public override void _Ready()
{
_gm = GetParent<GameManager>();
}
public override void _Process(double delta) => QueueRedraw();
private static Color StateColor(NpcState state) => state switch
{
NpcState.Gathering => Colors.White,
NpcState.Building => Colors.Orange,
NpcState.Trading => Colors.Yellow,
NpcState.Resting => new Color(0.45f, 0.45f, 0.95f),
NpcState.Socializing => new Color(0.9f, 0.4f, 0.9f),
_ => Colors.Red,
};
private static Vector2 MapPos(Vector2 gridPos) =>
new(MapOffset + gridPos.X * CellPixels, MapOffset + gridPos.Y * CellPixels);
public override void _Draw()
{
if (_gm.World == null) return;
var font = ThemeDB.FallbackFont;
// --- Backgrounds --------------------------------------------------
DrawRect(new Rect2(MapOffset, MapOffset, MapPixels, MapPixels), MapBg);
DrawRect(new Rect2(PanelX, 0, PanelWidth, 900), PanelBg);
// --- Map: resource nodes -----------------------------------------
foreach (var node in _gm.World.Nodes)
{
Color c = node.Kind switch
{
MaterialKind.Wood => new Color(0.20f, 0.55f, 0.20f),
MaterialKind.Stone => new Color(0.55f, 0.55f, 0.55f),
MaterialKind.Clay => new Color(0.75f, 0.45f, 0.25f),
MaterialKind.Water => new Color(0.25f, 0.45f, 0.85f),
MaterialKind.Food => new Color(0.80f, 0.30f, 0.40f),
MaterialKind.Ore => new Color(0.65f, 0.55f, 0.75f),
MaterialKind.Herb => new Color(0.45f, 0.80f, 0.55f),
_ => Colors.Magenta,
};
// Fade toward depletion so dying groves read at a glance
if (node.Kind != MaterialKind.Water)
c = c.Darkened(0.65f * (1f - node.Amount / node.MaxAmount));
var p = MapPos(node.Cell);
DrawRect(new Rect2(p.X, p.Y, CellPixels, CellPixels), c);
}
// --- Map: town labels --------------------------------------------
foreach (var town in _gm.Towns)
{
var tp = MapPos(town.Center);
DrawString(font, tp + new Vector2(-20f, -14f), town.Name.ToUpper(),
fontSize: 13, modulate: TextDim);
}
// --- 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];
float size = CellPixels + 3f + (b.CurrentStage + b.StageProgress) * 2f + b.Modules * 1.5f;
var p = MapPos(b.Site);
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);
}
// --- Map: NPCs with roster numbers -------------------------------
for (int i = 0; i < _gm.Npcs.Count; i++)
{
var npc = _gm.Npcs[i];
var p = MapPos(npc.Position);
// Selfish souls get a dark red ring — same dot, different halo.
if (npc.Soul.Type == SoulType.Selfish)
DrawCircle(p, 5.5f, new Color(0.75f, 0.15f, 0.15f));
// Debris dims the dot — encasement made visible.
DrawCircle(p, 3.5f, StateColor(npc.State).Darkened(npc.Imprint.Total * 0.7f));
DrawString(font, p + new Vector2(5f, -4f), $"{i + 1}",
fontSize: 11, modulate: TextMain);
}
// --- Panel: clock header -----------------------------------------
DrawString(font, new Vector2(PanelX + 16, 30),
$"Day {_gm.Day} · {_gm.SeasonName} {_gm.MinuteOfDay / 60:00}:{_gm.MinuteOfDay % 60:00} " +
$"{(_gm.IsNight ? "· night" : "· day")} {_gm.TicksPerRealSecond:0}x",
fontSize: 18, modulate: TextMain);
// --- Panel: per-town summary (scales to any population) ----------
float y = 74f;
foreach (var town in _gm.Towns)
{
// Aggregate this town's people.
int pop = 0; float nour = 0f, know = 0f, debris = 0f;
int gathering = 0, building = 0, resting = 0, social = 0, other = 0;
foreach (var npc in _gm.Npcs)
{
if (npc.HomeTown != town.Name) continue;
pop++; nour += npc.Nourishment; know += npc.Know.Total; debris += npc.Imprint.Total;
switch (npc.State)
{
case NpcState.Gathering: gathering++; break;
case NpcState.Building: building++; break;
case NpcState.Resting: resting++; break;
case NpcState.Socializing: social++; break;
default: other++; break;
}
}
// Aggregate this town's buildings.
int shelters = 0, ruins = 0; float granary = 0f;
foreach (var b in _gm.Buildings)
{
if (b.Town != town.Name) continue;
if (b.IsRuined) ruins++;
else if (b.Kind == BuildingKind.Granary) granary += b.FoodStock;
else if (b.IsComplete) shelters++;
}
var head = TownColor(town.Soul);
DrawString(font, new Vector2(PanelX + 16, y),
$"{town.Name.ToUpper()} · {town.Soul.ToString().ToLower()}",
fontSize: 15, modulate: pop > 0 ? head : TextDim);
DrawString(font, new Vector2(PanelX + 250, y),
pop > 0 ? $"pop {pop}" : "— extinct —",
fontSize: 15, modulate: pop > 0 ? TextMain : new Color(0.8f, 0.4f, 0.4f));
y += 22f;
if (pop > 0)
{
float inv = 1f / pop;
DrawString(font, new Vector2(PanelX + 28, y),
$"fed {nour * inv * 100,3:0}% know {know * inv,4:0.00} debris {debris * inv * 100,2:0}%",
fontSize: 13, modulate: TextDim);
DrawString(font, new Vector2(PanelX + 320, y),
$"⌂{shelters} ▦{granary:0}" + (ruins > 0 ? $" ✗{ruins}" : ""),
fontSize: 13, modulate: TextDim);
y += 19f;
DrawString(font, new Vector2(PanelX + 28, y),
$"gather {gathering} build {building} rest {resting} social {social}" +
(other > 0 ? $" other {other}" : ""),
fontSize: 12, modulate: TextDim);
y += 19f;
}
y += 12f;
}
// --- Panel: world vitals (bottom) --------------------------------
y += 8f;
float wood = 0f, stone = 0f, food = 0f;
foreach (var n in _gm.World.Nodes)
{
switch (n.Kind)
{
case MaterialKind.Wood: wood += n.Amount; break;
case MaterialKind.Stone: stone += n.Amount; break;
case MaterialKind.Food: food += n.Amount; break;
}
}
DrawString(font, new Vector2(PanelX + 16, y),
$"WORLD pop {_gm.Npcs.Count} +{_gm.BirthsToday}/{_gm.DeathsToday} today",
fontSize: 14, modulate: TextMain);
y += 20f;
DrawString(font, new Vector2(PanelX + 16, y),
$"land wood {wood:0} stone {stone:0} food {food:0}",
fontSize: 13, modulate: TextDim);
}
private static Color TownColor(SoulType t) => t switch
{
SoulType.Generational => new Color(0.55f, 0.75f, 0.95f), // North — cool
SoulType.Nature => new Color(0.55f, 0.85f, 0.55f), // South — green
SoulType.Materialist => new Color(0.90f, 0.80f, 0.45f), // West — gold
_ => new Color(0.90f, 0.45f, 0.45f), // East — red
};
}