South town MVP: world sim, seasons, granary, contrast test + findings
Godot 4.7 C# world simulation per SPEC.md (with 2026-07-21 amendments). 15 NPCs, 5-state machine, demand-driven work, soul-imprint triad data model, seasons with winter frost, granary, need-based gift economy, selfish-soul contrast wiring, daily/per-villager/gift CSV logging. FINDINGS.md documents the four soak experiments: the dead paradise, the buffers winning, the emergent hungry gap, and the contrast test proving the four-town thesis (and that selfishness wins until the soul-consequence layer exists). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
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 = 880f;
|
||||
private const float CellPixels = MapPixels / World.GridSize; // ~3.44
|
||||
private const float PanelX = 910f;
|
||||
private const float PanelWidth = 680f;
|
||||
|
||||
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),
|
||||
_ => 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: buildings ----------------------------------------------
|
||||
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);
|
||||
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));
|
||||
DrawCircle(p, 3.5f, StateColor(npc.State));
|
||||
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: villager roster --------------------------------------
|
||||
float y = 66f;
|
||||
DrawString(font, new Vector2(PanelX + 16, y), "VILLAGERS",
|
||||
fontSize: 13, modulate: TextDim);
|
||||
y += 10f;
|
||||
|
||||
for (int i = 0; i < _gm.Npcs.Count; i++)
|
||||
{
|
||||
var npc = _gm.Npcs[i];
|
||||
y += 22f;
|
||||
|
||||
if (npc.Soul.Type == SoulType.Selfish)
|
||||
DrawCircle(new Vector2(PanelX + 24, y - 5f), 6f, new Color(0.75f, 0.15f, 0.15f));
|
||||
DrawCircle(new Vector2(PanelX + 24, y - 5f), 4.5f, StateColor(npc.State));
|
||||
DrawString(font, new Vector2(PanelX + 36, y),
|
||||
$"{i + 1,2} {npc.State,-11} {npc.Activity}",
|
||||
fontSize: 13, modulate: TextMain);
|
||||
DrawString(font, new Vector2(PanelX + 420, y),
|
||||
$"F {npc.Fatigue,3:0} N {npc.Nourishment * 100,3:0} C {npc.TotalCarried(),4:0.0}" +
|
||||
$" mostly {npc.DominantStateToday().ToString().ToLower()[..4]}",
|
||||
fontSize: 12, modulate: TextDim);
|
||||
}
|
||||
|
||||
// --- Panel: buildings --------------------------------------------
|
||||
y += 34f;
|
||||
DrawString(font, new Vector2(PanelX + 16, y), "BUILDINGS",
|
||||
fontSize: 13, modulate: TextDim);
|
||||
y += 10f;
|
||||
|
||||
for (int i = 0; i < _gm.Buildings.Count; i++)
|
||||
{
|
||||
var b = _gm.Buildings[i];
|
||||
y += 20f;
|
||||
string status = b.IsRuined ? "RUIN"
|
||||
: !b.IsComplete
|
||||
? $"building: {b.Stages[b.CurrentStage].Name} {b.StageProgress * 100,3:0}%"
|
||||
: b.Kind == BuildingKind.Granary
|
||||
? $"granary food {b.FoodStock,5:0.0}/{Building.FoodStockCap:0}"
|
||||
: $"cond {b.Condition * 100,3:0}% wood {b.UpkeepStock,4:0.0}/{Building.UpkeepStockCap:0}" +
|
||||
$" mods {b.Modules}/{Building.ModuleCap} burn {b.DailyBurn:0.0}/day";
|
||||
DrawString(font, new Vector2(PanelX + 36, y),
|
||||
$"{i + 1} {status}", fontSize: 13,
|
||||
modulate: b.IsRuined ? TextDim : TextMain);
|
||||
}
|
||||
|
||||
// --- Panel: world totals -----------------------------------------
|
||||
y += 34f;
|
||||
float wood = 0f, stone = 0f, clay = 0f, foodTotal = 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.Clay: clay += n.Amount; break;
|
||||
case MaterialKind.Food: foodTotal += n.Amount; break;
|
||||
}
|
||||
}
|
||||
DrawString(font, new Vector2(PanelX + 16, y),
|
||||
$"WORLD wood {wood:0} stone {stone:0} clay {clay:0} food {foodTotal:0}",
|
||||
fontSize: 13, modulate: TextDim);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user