Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md). All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
using Theriapolis.Core.Entities;
|
||||
|
||||
namespace Theriapolis.Core.World.Settlements;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M1 — runtime map between symbolic ids used by quest scripts /
|
||||
/// dialogue conditions and live world entities. Quest scripts never embed
|
||||
/// world coordinates; they reference NPCs by role tag and locations by
|
||||
/// anchor id (per master plan §8.4):
|
||||
///
|
||||
/// <code>
|
||||
/// anchor:millhaven → the live Settlement
|
||||
/// role:millhaven.innkeeper → the live NpcActor for that named role
|
||||
/// </code>
|
||||
///
|
||||
/// Built lazily as chunks stream in: when a settlement's buildings resolve
|
||||
/// (and any named NPC instantiates), the entry registers here. Phase 6 M2
|
||||
/// persists the registry; M1 rebuilds it on every load from the live
|
||||
/// settlement list and active NpcActors.
|
||||
/// </summary>
|
||||
public sealed class AnchorRegistry
|
||||
{
|
||||
private readonly Dictionary<string, int> _anchorToSettlementId = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, int> _roleToNpcId = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Register a settlement under its anchor id (e.g. "anchor:millhaven").</summary>
|
||||
public void RegisterAnchor(NarrativeAnchor anchor, int settlementId)
|
||||
{
|
||||
string key = $"anchor:{anchor.ToString().ToLowerInvariant()}";
|
||||
_anchorToSettlementId[key] = settlementId;
|
||||
}
|
||||
|
||||
/// <summary>Register an NpcActor under its named role tag (e.g. "role:millhaven.innkeeper").</summary>
|
||||
public void RegisterRole(string roleTag, int npcId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(roleTag)) return;
|
||||
if (!roleTag.Contains('.')) return; // generic role; only named (anchor.role) roles register
|
||||
_roleToNpcId[$"role:{roleTag.ToLowerInvariant()}"] = npcId;
|
||||
}
|
||||
|
||||
/// <summary>Forget the role mapping (called on chunk evict / NPC despawn).</summary>
|
||||
public void UnregisterRole(string roleTag)
|
||||
{
|
||||
if (string.IsNullOrEmpty(roleTag)) return;
|
||||
_roleToNpcId.Remove($"role:{roleTag.ToLowerInvariant()}");
|
||||
}
|
||||
|
||||
/// <summary>Resolve "anchor:millhaven" → SettlementId (or null when not registered yet).</summary>
|
||||
public int? ResolveAnchor(string id)
|
||||
{
|
||||
return _anchorToSettlementId.TryGetValue(id, out int sid) ? sid : null;
|
||||
}
|
||||
|
||||
/// <summary>Resolve "role:millhaven.innkeeper" → NpcId (or null when not loaded / not yet streamed).</summary>
|
||||
public int? ResolveRole(string id)
|
||||
{
|
||||
return _roleToNpcId.TryGetValue(id, out int nid) ? nid : null;
|
||||
}
|
||||
|
||||
/// <summary>Bulk re-register every settlement's anchor (e.g. after world load).</summary>
|
||||
public void RegisterAllAnchors(WorldState world)
|
||||
{
|
||||
foreach (var s in world.Settlements)
|
||||
if (s.Anchor is { } a)
|
||||
RegisterAnchor(a, s.Id);
|
||||
}
|
||||
|
||||
/// <summary>For diagnostics: every (id → entityId) mapping currently held.</summary>
|
||||
public IReadOnlyDictionary<string, int> AllAnchors => _anchorToSettlementId;
|
||||
public IReadOnlyDictionary<string, int> AllRoles => _roleToNpcId;
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_anchorToSettlementId.Clear();
|
||||
_roleToNpcId.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace Theriapolis.Core.World.Settlements;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M0 — runtime record of a single stamped building inside a
|
||||
/// settlement. Created by <see cref="SettlementStamper"/> at chunk-gen time
|
||||
/// and attached to the parent <see cref="World.Settlement"/>.
|
||||
///
|
||||
/// Buildings can straddle chunk boundaries; the footprint is in
|
||||
/// world-pixel (= tactical-tile) coordinates so cross-chunk lookups
|
||||
/// (e.g. "is the player inside the Millhaven inn?") work without per-chunk
|
||||
/// reconstruction.
|
||||
/// </summary>
|
||||
public sealed class BuildingFootprint
|
||||
{
|
||||
/// <summary>Unique id within the parent settlement (sequential, 0-based).</summary>
|
||||
public int Id { get; init; }
|
||||
|
||||
/// <summary>Building template id (e.g. "inn_small").</summary>
|
||||
public string TemplateId { get; init; } = "";
|
||||
|
||||
/// <summary>Inclusive minimum X in world-pixel space.</summary>
|
||||
public int MinX { get; init; }
|
||||
|
||||
/// <summary>Inclusive minimum Y in world-pixel space.</summary>
|
||||
public int MinY { get; init; }
|
||||
|
||||
/// <summary>Inclusive maximum X in world-pixel space.</summary>
|
||||
public int MaxX { get; init; }
|
||||
|
||||
/// <summary>Inclusive maximum Y in world-pixel space.</summary>
|
||||
public int MaxY { get; init; }
|
||||
|
||||
/// <summary>Door positions in world-pixel space (one entry per door).</summary>
|
||||
public (int X, int Y)[] Doors { get; init; } = Array.Empty<(int, int)>();
|
||||
|
||||
/// <summary>Resident slots: role tag (possibly anchor-prefixed) → spawn position in world-pixel space.</summary>
|
||||
public BuildingResidentSlot[] Residents { get; init; } = Array.Empty<BuildingResidentSlot>();
|
||||
|
||||
public bool ContainsTile(int worldPxX, int worldPxY)
|
||||
=> worldPxX >= MinX && worldPxX <= MaxX
|
||||
&& worldPxY >= MinY && worldPxY <= MaxY;
|
||||
}
|
||||
|
||||
/// <summary>One resident slot inside a building.</summary>
|
||||
public readonly struct BuildingResidentSlot
|
||||
{
|
||||
/// <summary>Role tag — either generic ("innkeeper") or anchor-qualified ("millhaven.innkeeper").</summary>
|
||||
public readonly string RoleTag;
|
||||
|
||||
/// <summary>Spawn point in world-pixel (tactical-tile) coordinates.</summary>
|
||||
public readonly int SpawnX;
|
||||
public readonly int SpawnY;
|
||||
|
||||
/// <summary>Optional category match for procedural residents — passed through from BuildingTemplateDef.Category.</summary>
|
||||
public readonly string Category;
|
||||
|
||||
public BuildingResidentSlot(string roleTag, int spawnX, int spawnY, string category)
|
||||
{
|
||||
RoleTag = roleTag;
|
||||
SpawnX = spawnX;
|
||||
SpawnY = spawnY;
|
||||
Category = category;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Tactical;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.World.Settlements;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M0 — drives building stamping for tactical chunks.
|
||||
///
|
||||
/// The original Phase 4 plan promised template-driven building burn-in
|
||||
/// (<c>theriapolis-rpg-implementation-plan-phase4.md §3.4 step 3</c>) but
|
||||
/// only a placeholder cobble plaza + outer wall ring shipped. This module
|
||||
/// catches that up.
|
||||
///
|
||||
/// Two paths:
|
||||
/// 1. **Content-driven** — when a <see cref="SettlementContent"/> is
|
||||
/// available the stamper resolves a <see cref="SettlementLayoutDef"/>
|
||||
/// for each settlement (preset by anchor, else procedural by tier),
|
||||
/// materialises buildings on the settlement (lazily, once), and stamps
|
||||
/// walls/floors/doors/decos for any building intersecting the chunk.
|
||||
/// Per-resident <see cref="TacticalSpawn"/>s are emitted for Phase 6 M1
|
||||
/// instantiation.
|
||||
/// 2. **Fallback** — when content is unavailable (e.g. headless tools,
|
||||
/// early-stage tests) the stamper writes the original cobble plaza +
|
||||
/// outer wall ring so the existing Phase 5 visual baseline holds.
|
||||
///
|
||||
/// Determinism:
|
||||
/// - Building list per settlement is resolved with a SeededRng keyed by
|
||||
/// <c>worldSeed ^ RNG_BUILDING_LAYOUT ^ settlementId</c>; identical across
|
||||
/// reloads.
|
||||
/// - Procedural Tier 2–5 layouts roll templates and offsets from this RNG;
|
||||
/// preset (Tier 1 / anchor) layouts are entirely data-driven.
|
||||
/// </summary>
|
||||
public static class SettlementStamper
|
||||
{
|
||||
/// <summary>
|
||||
/// Half-width (in tactical tiles) of the gateway carved through a
|
||||
/// settlement's wall ring wherever a road passes. Sized to the widest
|
||||
/// road the world can produce plus 1-tile slop on each side. Mirrors
|
||||
/// the Phase-4 placeholder constant.
|
||||
/// </summary>
|
||||
private const int GatewayHaloTiles = 3;
|
||||
|
||||
public static void Stamp(
|
||||
ulong worldSeed,
|
||||
TacticalChunk chunk,
|
||||
WorldState world,
|
||||
SettlementContent? content)
|
||||
{
|
||||
int x0 = chunk.OriginX;
|
||||
int y0 = chunk.OriginY;
|
||||
int x1 = x0 + C.TACTICAL_CHUNK_SIZE;
|
||||
int y1 = y0 + C.TACTICAL_CHUNK_SIZE;
|
||||
|
||||
foreach (var s in world.Settlements)
|
||||
{
|
||||
// Cheap chunk-vs-settlement overlap reject — covers both the
|
||||
// plaza ring (Phase 5 placeholder behaviour) and any building
|
||||
// footprints we may have stamped on the settlement.
|
||||
if (!OverlapsChunk(s, content, x0, y0, x1, y1)) continue;
|
||||
|
||||
// Always lay the plaza + ring first; buildings stamp on top.
|
||||
StampPlazaAndWallRing(chunk, s, x0, y0, x1, y1);
|
||||
|
||||
if (content is not null)
|
||||
StampBuildings(worldSeed, chunk, s, content, x0, y0, x1, y1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lazy resolution of per-settlement building list ─────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Build the settlement's <see cref="Settlement.Buildings"/> list from
|
||||
/// its matched layout. Idempotent — only runs once per settlement.
|
||||
/// </summary>
|
||||
public static void EnsureBuildingsResolved(ulong worldSeed, Settlement s, SettlementContent content)
|
||||
{
|
||||
if (s.BuildingsResolved) return;
|
||||
var layout = content.ResolveFor(s);
|
||||
if (layout is null)
|
||||
{
|
||||
s.BuildingsResolved = true; // nothing to stamp — bail with empty list
|
||||
return;
|
||||
}
|
||||
|
||||
var rng = SeededRng.ForSubsystem(worldSeed, C.RNG_BUILDING_LAYOUT ^ (ulong)s.Id);
|
||||
var buildings = layout.Kind == "preset"
|
||||
? ResolvePreset(s, layout, content)
|
||||
: ResolveProcedural(s, layout, content, rng);
|
||||
|
||||
s.Buildings.Clear();
|
||||
s.Buildings.AddRange(buildings);
|
||||
s.BuildingsResolved = true;
|
||||
}
|
||||
|
||||
private static IEnumerable<BuildingFootprint> ResolvePreset(
|
||||
Settlement s,
|
||||
SettlementLayoutDef layout,
|
||||
SettlementContent content)
|
||||
{
|
||||
int cxPx = (int)s.WorldPixelX;
|
||||
int cyPx = (int)s.WorldPixelY;
|
||||
int next = 0;
|
||||
|
||||
foreach (var p in layout.Buildings)
|
||||
{
|
||||
if (!content.Buildings.TryGetValue(p.Template, out var def)) continue;
|
||||
int ox = p.Offset.Length >= 1 ? p.Offset[0] : 0;
|
||||
int oy = p.Offset.Length >= 2 ? p.Offset[1] : 0;
|
||||
|
||||
int minX = cxPx + ox - def.FootprintWTiles / 2;
|
||||
int minY = cyPx + oy - def.FootprintHTiles / 2;
|
||||
int maxX = minX + def.FootprintWTiles - 1;
|
||||
int maxY = minY + def.FootprintHTiles - 1;
|
||||
|
||||
yield return BuildFootprint(next++, def, minX, minY, maxX, maxY, p.RoleOverrides);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<BuildingFootprint> ResolveProcedural(
|
||||
Settlement s,
|
||||
SettlementLayoutDef layout,
|
||||
SettlementContent content,
|
||||
SeededRng rng)
|
||||
{
|
||||
// Pull eligible templates. Tier numbering is inverted: Tier 1 is the
|
||||
// capital (largest), Tier 5 is the hamlet (smallest). "Min tier
|
||||
// eligible" is the *smallest settlement* where this template fits;
|
||||
// a magistrate with min_tier_eligible=2 belongs in Tier 1 capitals
|
||||
// and Tier 2 cities only — so the predicate is s.Tier <= MinTier.
|
||||
var eligible = content.Buildings.Values
|
||||
.Where(b => s.Tier <= b.MinTierEligible)
|
||||
.Where(b => layout.CategoryWeights.ContainsKey(b.Category))
|
||||
.OrderBy(b => b.Id, System.StringComparer.Ordinal) // stable order before RNG roll
|
||||
.ToArray();
|
||||
if (eligible.Length == 0) yield break;
|
||||
|
||||
int cxPx = (int)s.WorldPixelX;
|
||||
int cyPx = (int)s.WorldPixelY;
|
||||
int plazaR = layout.PlazaRadiusTiles > 0
|
||||
? layout.PlazaRadiusTiles
|
||||
: DefaultPlazaRadiusTiles(s.Tier);
|
||||
|
||||
// Slot grid: try concentric rings inside the plaza for placement.
|
||||
var placed = new List<(int minX, int minY, int maxX, int maxY)>();
|
||||
int placedCount = 0;
|
||||
int attempts = 0;
|
||||
int next = 0;
|
||||
|
||||
while (placedCount < layout.TargetBuildingCount && attempts < layout.TargetBuildingCount * 8)
|
||||
{
|
||||
attempts++;
|
||||
// Weighted roll by category, then template.
|
||||
string category = WeightedPick(layout.CategoryWeights, rng);
|
||||
var inCategory = eligible.Where(b => string.Equals(b.Category, category, System.StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
if (inCategory.Length == 0) continue;
|
||||
var def = WeightedPickByTemplateWeight(inCategory, rng);
|
||||
|
||||
// Pick a candidate offset anywhere inside the plaza, biased toward outer rings to leave a centre.
|
||||
int rx = rng.NextInt(-plazaR + def.FootprintWTiles / 2 + 2,
|
||||
plazaR - def.FootprintWTiles / 2 - 1);
|
||||
int ry = rng.NextInt(-plazaR + def.FootprintHTiles / 2 + 2,
|
||||
plazaR - def.FootprintHTiles / 2 - 1);
|
||||
|
||||
int minX = cxPx + rx - def.FootprintWTiles / 2;
|
||||
int minY = cyPx + ry - def.FootprintHTiles / 2;
|
||||
int maxX = minX + def.FootprintWTiles - 1;
|
||||
int maxY = minY + def.FootprintHTiles - 1;
|
||||
|
||||
// Reject if it overlaps another placed building (with the gap padding).
|
||||
bool collide = false;
|
||||
foreach (var p in placed)
|
||||
{
|
||||
if (RectsOverlap(
|
||||
minX - C.SETTLEMENT_BUILDING_GAP_MIN, minY - C.SETTLEMENT_BUILDING_GAP_MIN,
|
||||
maxX + C.SETTLEMENT_BUILDING_GAP_MIN, maxY + C.SETTLEMENT_BUILDING_GAP_MIN,
|
||||
p.minX, p.minY, p.maxX, p.maxY))
|
||||
{
|
||||
collide = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (collide) continue;
|
||||
|
||||
placed.Add((minX, minY, maxX, maxY));
|
||||
placedCount++;
|
||||
yield return BuildFootprint(next++, def, minX, minY, maxX, maxY, roleOverrides: null);
|
||||
}
|
||||
}
|
||||
|
||||
private static BuildingFootprint BuildFootprint(
|
||||
int id, BuildingTemplateDef def,
|
||||
int minX, int minY, int maxX, int maxY,
|
||||
Dictionary<string, string>? roleOverrides)
|
||||
{
|
||||
var doors = new (int X, int Y)[def.Doors.Length];
|
||||
for (int i = 0; i < def.Doors.Length; i++)
|
||||
doors[i] = (minX + def.Doors[i].X, minY + def.Doors[i].Y);
|
||||
|
||||
var residents = new BuildingResidentSlot[def.Roles.Length];
|
||||
for (int i = 0; i < def.Roles.Length; i++)
|
||||
{
|
||||
var r = def.Roles[i];
|
||||
string role = r.Tag;
|
||||
if (roleOverrides is not null && roleOverrides.TryGetValue(r.Tag, out var named))
|
||||
role = named;
|
||||
residents[i] = new BuildingResidentSlot(
|
||||
role,
|
||||
minX + r.SpawnAt[0],
|
||||
minY + r.SpawnAt[1],
|
||||
def.Category);
|
||||
}
|
||||
|
||||
return new BuildingFootprint
|
||||
{
|
||||
Id = id,
|
||||
TemplateId = def.Id,
|
||||
MinX = minX,
|
||||
MinY = minY,
|
||||
MaxX = maxX,
|
||||
MaxY = maxY,
|
||||
Doors = doors,
|
||||
Residents = residents,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tile stamping ────────────────────────────────────────────────────
|
||||
|
||||
private static void StampPlazaAndWallRing(
|
||||
TacticalChunk chunk, Settlement s,
|
||||
int x0, int y0, int x1, int y1)
|
||||
{
|
||||
// Replicates the original Phase 5 placeholder behaviour. Buildings
|
||||
// (if any) stamp on top.
|
||||
int tilesRadius = s.Tier switch { 1 => 2, <= 4 => 1, _ => 0 };
|
||||
int cxw = s.TileX, cyw = s.TileY;
|
||||
int minWX = Math.Max(0, cxw - tilesRadius);
|
||||
int minWY = Math.Max(0, cyw - tilesRadius);
|
||||
int maxWX = Math.Min(C.WORLD_WIDTH_TILES - 1, cxw + tilesRadius);
|
||||
int maxWY = Math.Min(C.WORLD_HEIGHT_TILES - 1, cyw + tilesRadius);
|
||||
|
||||
int fx0 = minWX * C.TACTICAL_PER_WORLD_TILE;
|
||||
int fy0 = minWY * C.TACTICAL_PER_WORLD_TILE;
|
||||
int fx1 = (maxWX + 1) * C.TACTICAL_PER_WORLD_TILE;
|
||||
int fy1 = (maxWY + 1) * C.TACTICAL_PER_WORLD_TILE;
|
||||
|
||||
int sx = Math.Max(x0, fx0);
|
||||
int sy = Math.Max(y0, fy0);
|
||||
int ex = Math.Min(x1, fx1);
|
||||
int ey = Math.Min(y1, fy1);
|
||||
if (sx >= ex || sy >= ey) return;
|
||||
|
||||
int cxPx = (int)s.WorldPixelX;
|
||||
int cyPx = (int)s.WorldPixelY;
|
||||
int plazaR = s.Tier switch { 1 => 24, 2 => 18, 3 => 14, 4 => 10, _ => 6 };
|
||||
int wallR = plazaR + 2;
|
||||
|
||||
for (int ty = sy; ty < ey; ty++)
|
||||
for (int tx = sx; tx < ex; tx++)
|
||||
{
|
||||
int dx = tx - cxPx;
|
||||
int dy = ty - cyPx;
|
||||
int dist = Math.Max(Math.Abs(dx), Math.Abs(dy));
|
||||
int lx = tx - chunk.OriginX;
|
||||
int ly = ty - chunk.OriginY;
|
||||
ref var dst = ref chunk.Tiles[lx, ly];
|
||||
|
||||
if (dist <= plazaR)
|
||||
{
|
||||
if ((dst.Flags & (byte)TacticalFlags.River) == 0)
|
||||
dst.Surface = TacticalSurface.Cobble;
|
||||
dst.Flags |= (byte)TacticalFlags.Settlement;
|
||||
dst.Deco = TacticalDeco.None;
|
||||
}
|
||||
else if (dist == wallR && !s.IsPoi)
|
||||
{
|
||||
if ((dst.Flags & (byte)TacticalFlags.River) == 0 &&
|
||||
!HasRoadInHalo(chunk, lx, ly, GatewayHaloTiles))
|
||||
{
|
||||
dst.Surface = TacticalSurface.Wall;
|
||||
dst.Flags |= (byte)TacticalFlags.Settlement | (byte)TacticalFlags.Impassable;
|
||||
dst.Deco = TacticalDeco.None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void StampBuildings(
|
||||
ulong worldSeed,
|
||||
TacticalChunk chunk,
|
||||
Settlement s,
|
||||
SettlementContent content,
|
||||
int x0, int y0, int x1, int y1)
|
||||
{
|
||||
EnsureBuildingsResolved(worldSeed, s, content);
|
||||
|
||||
foreach (var b in s.Buildings)
|
||||
{
|
||||
// Quick AABB intersect with this chunk's tactical window.
|
||||
if (b.MaxX < x0 || b.MinX >= x1 || b.MaxY < y0 || b.MinY >= y1) continue;
|
||||
|
||||
int sx = Math.Max(x0, b.MinX);
|
||||
int sy = Math.Max(y0, b.MinY);
|
||||
int ex = Math.Min(x1, b.MaxX + 1);
|
||||
int ey = Math.Min(y1, b.MaxY + 1);
|
||||
if (sx >= ex || sy >= ey) continue;
|
||||
|
||||
for (int ty = sy; ty < ey; ty++)
|
||||
for (int tx = sx; tx < ex; tx++)
|
||||
{
|
||||
int lx = tx - chunk.OriginX;
|
||||
int ly = ty - chunk.OriginY;
|
||||
ref var dst = ref chunk.Tiles[lx, ly];
|
||||
|
||||
bool perimeter = (tx == b.MinX || tx == b.MaxX || ty == b.MinY || ty == b.MaxY);
|
||||
if (perimeter)
|
||||
{
|
||||
dst.Surface = TacticalSurface.Wall;
|
||||
// Walls block but doorways don't — we patch doors next.
|
||||
dst.Flags |= (byte)(TacticalFlags.Settlement | TacticalFlags.Building | TacticalFlags.Impassable);
|
||||
dst.Deco = TacticalDeco.None;
|
||||
}
|
||||
else
|
||||
{
|
||||
dst.Surface = TacticalSurface.Floor;
|
||||
dst.Flags |= (byte)(TacticalFlags.Settlement | TacticalFlags.Building);
|
||||
// Clear Impassable for floors (a wall may have been stamped previously).
|
||||
dst.Flags &= unchecked((byte)~(byte)TacticalFlags.Impassable);
|
||||
dst.Deco = TacticalDeco.None;
|
||||
}
|
||||
}
|
||||
|
||||
// Doors override perimeter walls so the building is enterable.
|
||||
foreach (var (dx, dy) in b.Doors)
|
||||
{
|
||||
if (dx < x0 || dx >= x1 || dy < y0 || dy >= y1) continue;
|
||||
int lx = dx - chunk.OriginX;
|
||||
int ly = dy - chunk.OriginY;
|
||||
ref var dst = ref chunk.Tiles[lx, ly];
|
||||
dst.Surface = TacticalSurface.Floor;
|
||||
dst.Deco = TacticalDeco.Door;
|
||||
dst.Flags |= (byte)(TacticalFlags.Settlement | TacticalFlags.Building | TacticalFlags.Doorway);
|
||||
dst.Flags &= unchecked((byte)~(byte)TacticalFlags.Impassable);
|
||||
}
|
||||
|
||||
// Interior decos.
|
||||
if (content.Buildings.TryGetValue(b.TemplateId, out var def))
|
||||
{
|
||||
foreach (var deco in def.Decos)
|
||||
{
|
||||
int dx = b.MinX + deco.X;
|
||||
int dy = b.MinY + deco.Y;
|
||||
if (dx < x0 || dx >= x1 || dy < y0 || dy >= y1) continue;
|
||||
int lx = dx - chunk.OriginX;
|
||||
int ly = dy - chunk.OriginY;
|
||||
ref var dst = ref chunk.Tiles[lx, ly];
|
||||
dst.Deco = ParseDeco(deco.Deco);
|
||||
}
|
||||
}
|
||||
|
||||
// Resident spawn records.
|
||||
foreach (var r in b.Residents)
|
||||
{
|
||||
if (r.SpawnX < x0 || r.SpawnX >= x1 || r.SpawnY < y0 || r.SpawnY >= y1) continue;
|
||||
int lx = r.SpawnX - chunk.OriginX;
|
||||
int ly = r.SpawnY - chunk.OriginY;
|
||||
chunk.Spawns.Add(new TacticalSpawn(SpawnKind.Resident, lx, ly));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private static bool OverlapsChunk(Settlement s, SettlementContent? content, int x0, int y0, int x1, int y1)
|
||||
{
|
||||
// If buildings have already been resolved on this settlement, use
|
||||
// their union AABB; otherwise fall back to the plaza+wall radius
|
||||
// we'd stamp anyway.
|
||||
int cxPx = (int)s.WorldPixelX;
|
||||
int cyPx = (int)s.WorldPixelY;
|
||||
int plazaR = s.Tier switch { 1 => 28, 2 => 22, 3 => 18, 4 => 14, _ => 10 };
|
||||
int extentMinX = cxPx - plazaR, extentMinY = cyPx - plazaR;
|
||||
int extentMaxX = cxPx + plazaR, extentMaxY = cyPx + plazaR;
|
||||
|
||||
if (s.BuildingsResolved && s.Buildings.Count > 0)
|
||||
{
|
||||
foreach (var b in s.Buildings)
|
||||
{
|
||||
extentMinX = Math.Min(extentMinX, b.MinX);
|
||||
extentMinY = Math.Min(extentMinY, b.MinY);
|
||||
extentMaxX = Math.Max(extentMaxX, b.MaxX);
|
||||
extentMaxY = Math.Max(extentMaxY, b.MaxY);
|
||||
}
|
||||
}
|
||||
return !(extentMaxX < x0 || extentMinX >= x1 || extentMaxY < y0 || extentMinY >= y1);
|
||||
}
|
||||
|
||||
private static bool HasRoadInHalo(TacticalChunk chunk, int lx, int ly, int halo)
|
||||
{
|
||||
const byte ROAD = (byte)TacticalFlags.Road;
|
||||
int sx = Math.Max(0, lx - halo);
|
||||
int sy = Math.Max(0, ly - halo);
|
||||
int ex = Math.Min(C.TACTICAL_CHUNK_SIZE - 1, lx + halo);
|
||||
int ey = Math.Min(C.TACTICAL_CHUNK_SIZE - 1, ly + halo);
|
||||
for (int qy = sy; qy <= ey; qy++)
|
||||
for (int qx = sx; qx <= ex; qx++)
|
||||
if ((chunk.Tiles[qx, qy].Flags & ROAD) != 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool RectsOverlap(int aMinX, int aMinY, int aMaxX, int aMaxY,
|
||||
int bMinX, int bMinY, int bMaxX, int bMaxY)
|
||||
=> !(aMaxX < bMinX || bMaxX < aMinX || aMaxY < bMinY || bMaxY < aMinY);
|
||||
|
||||
private static int DefaultPlazaRadiusTiles(int tier) => tier switch
|
||||
{
|
||||
1 => 24,
|
||||
2 => 18,
|
||||
3 => 14,
|
||||
4 => 10,
|
||||
_ => 6,
|
||||
};
|
||||
|
||||
private static TacticalDeco ParseDeco(string raw) => raw.ToLowerInvariant() switch
|
||||
{
|
||||
"counter" => TacticalDeco.Counter,
|
||||
"bed" => TacticalDeco.Bed,
|
||||
"hearth" => TacticalDeco.Hearth,
|
||||
"sign" => TacticalDeco.Sign,
|
||||
_ => TacticalDeco.None,
|
||||
};
|
||||
|
||||
private static string WeightedPick(IReadOnlyDictionary<string, float> weights, SeededRng rng)
|
||||
{
|
||||
// Stable order — keys sorted ascending — so identical seeds always
|
||||
// pick identically regardless of dictionary insertion order.
|
||||
var keys = weights.Keys.OrderBy(k => k, System.StringComparer.Ordinal).ToArray();
|
||||
float total = 0f;
|
||||
foreach (var k in keys) total += System.Math.Max(0f, weights[k]);
|
||||
if (total <= 0f) return keys[0];
|
||||
float roll = rng.NextFloat() * total;
|
||||
float acc = 0f;
|
||||
foreach (var k in keys)
|
||||
{
|
||||
acc += System.Math.Max(0f, weights[k]);
|
||||
if (roll <= acc) return k;
|
||||
}
|
||||
return keys[^1];
|
||||
}
|
||||
|
||||
private static BuildingTemplateDef WeightedPickByTemplateWeight(BuildingTemplateDef[] templates, SeededRng rng)
|
||||
{
|
||||
float total = 0f;
|
||||
foreach (var t in templates) total += System.Math.Max(0f, t.Weight);
|
||||
if (total <= 0f) return templates[0];
|
||||
float roll = rng.NextFloat() * total;
|
||||
float acc = 0f;
|
||||
foreach (var t in templates)
|
||||
{
|
||||
acc += System.Math.Max(0f, t.Weight);
|
||||
if (roll <= acc) return t;
|
||||
}
|
||||
return templates[^1];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user