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,106 @@
|
||||
using Theriapolis.Core.Rules.Character;
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
|
||||
namespace Theriapolis.Core.Dungeons;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M2 — clade-responsive dungeon movement cost. Per <c>procgen.md</c>
|
||||
/// Layer 5 final paragraph and Phase 7 plan §5.4: a Large PC squeezing
|
||||
/// through a Mustelid tunnel takes 2× movement points per tile; a Small
|
||||
/// PC in an Ursid hall is exposed (×1.5); etc.
|
||||
///
|
||||
/// Cost multiplier applies to tactical-tile movement budget per turn —
|
||||
/// combat reach + LOS unchanged; <em>only</em> movement budget. The
|
||||
/// caller (<see cref="Rules.Combat.TacticalMovementRules"/> or equivalent)
|
||||
/// looks up the room the actor is currently in and consults
|
||||
/// <see cref="GetCostMultiplier"/>.
|
||||
///
|
||||
/// Hybrid PCs use their <em>dominant lineage</em>'s clade-implied size
|
||||
/// for the lookup — matches the Phase 6.5 hybrid passing / presenting-clade
|
||||
/// contract. A Wolf-Folk × Hare-Folk hybrid with <c>DominantParent: Sire</c>
|
||||
/// reads as Wolf-Folk (MediumLarge); with <c>DominantParent: Dam</c> reads
|
||||
/// as Hare-Folk (Medium). Outside dungeons the multiplier is always 1.0.
|
||||
///
|
||||
/// Reference table (Phase 7 plan §5.4):
|
||||
/// Player size | Built by Mustelid | Ursid | Cervid | Bovid | Imperium/None
|
||||
/// Small | 1.0 | 1.5 | 1.0 | 1.2 | 1.0
|
||||
/// Medium | 1.2 | 1.0 | 1.0 | 1.0 | 1.0
|
||||
/// MediumLarge | 1.5 | 1.0 | 1.0 | 1.0 | 1.0
|
||||
/// Large | 2.0 | 1.0 | 1.2 | 1.0 | 1.0
|
||||
/// </summary>
|
||||
public static class ClademorphicMovement
|
||||
{
|
||||
/// <summary>
|
||||
/// Multiplier on movement-cost-per-tile for a player of the given size
|
||||
/// in a room built by the given clade. Returns 1.0 when no mismatch
|
||||
/// applies. Unknown <paramref name="builtBy"/> values default to 1.0
|
||||
/// (no penalty).
|
||||
/// </summary>
|
||||
public static float GetCostMultiplier(SizeCategory playerSize, string builtBy)
|
||||
{
|
||||
if (string.IsNullOrEmpty(builtBy)) return 1.0f;
|
||||
// Normalise — JSON ships lowercase tags.
|
||||
return builtBy.ToLowerInvariant() switch
|
||||
{
|
||||
"mustelid" => playerSize switch
|
||||
{
|
||||
SizeCategory.Small => 1.0f,
|
||||
SizeCategory.Medium => C.MOVE_COST_MISMATCH_LIGHT, // 1.2 — slight squeeze
|
||||
SizeCategory.MediumLarge => C.MOVE_COST_MISMATCH_MED, // 1.5
|
||||
SizeCategory.Large => C.MOVE_COST_MISMATCH_HEAVY, // 2.0 — squeezing
|
||||
_ => 1.0f,
|
||||
},
|
||||
"ursid" => playerSize switch
|
||||
{
|
||||
SizeCategory.Small => C.MOVE_COST_MISMATCH_MED, // exposed in cavernous halls
|
||||
_ => 1.0f,
|
||||
},
|
||||
"cervid" => playerSize switch
|
||||
{
|
||||
SizeCategory.Large => C.MOVE_COST_MISMATCH_LIGHT, // antler clearance
|
||||
_ => 1.0f,
|
||||
},
|
||||
"bovid" => playerSize switch
|
||||
{
|
||||
SizeCategory.Small => C.MOVE_COST_MISMATCH_LIGHT,
|
||||
_ => 1.0f,
|
||||
},
|
||||
// Canid / Felid / Leporid / Imperium / "none" / unknown:
|
||||
_ => 1.0f,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience wrapper that resolves a character's effective size for
|
||||
/// the lookup (handles Phase 6.5 hybrid dominant-lineage rules).
|
||||
/// </summary>
|
||||
public static float GetCostMultiplier(Character character, string builtBy)
|
||||
{
|
||||
if (character is null) return 1.0f;
|
||||
var effectiveSize = EffectiveSize(character);
|
||||
return GetCostMultiplier(effectiveSize, builtBy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the size category that drives the clade-responsive lookup.
|
||||
/// For purebred PCs, this is just <see cref="Character.Size"/>. For
|
||||
/// hybrid PCs, it's the size implied by the dominant-lineage species
|
||||
/// — and we expose this as a separate helper so callers (e.g. NPC
|
||||
/// mechanics that *also* need the presenting size) can reuse it.
|
||||
/// </summary>
|
||||
public static SizeCategory EffectiveSize(Character character)
|
||||
{
|
||||
if (character is null) throw new System.ArgumentNullException(nameof(character));
|
||||
if (!character.IsHybrid) return character.Size;
|
||||
// Hybrid: pick the size implied by the dominant parent's species.
|
||||
// The Hybrid record carries the species name only (string); the
|
||||
// species-to-size mapping lives on Character.Species (the
|
||||
// *presenting* species set at character creation per the dominant
|
||||
// lineage). So for a hybrid PC the simplest (and load-bearing-
|
||||
// correct) answer is the presenting species — which is exactly
|
||||
// what <c>character.Size</c> already returns. Documented here so
|
||||
// future agents don't replace this with a parent-species lookup
|
||||
// and break the contract.
|
||||
return character.Size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Theriapolis.Core.Tactical;
|
||||
using Theriapolis.Core.World;
|
||||
|
||||
namespace Theriapolis.Core.Dungeons;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M1 — runtime view of an interior dungeon. Generated lazily on
|
||||
/// the player's first entry to a PoI; persisted modifications live on
|
||||
/// <see cref="Theriapolis.Core.Persistence.DungeonStateSnapshot"/> and
|
||||
/// re-apply on reload.
|
||||
///
|
||||
/// The dungeon owns its own bounded tactical-tile array (the scene-swap
|
||||
/// model from Phase 7 plan §4.2): movement, combat, dialogue, and save/load
|
||||
/// all work the same as the surface, but the renderer reads tiles from
|
||||
/// <see cref="Tiles"/> instead of the chunk streamer.
|
||||
///
|
||||
/// Coordinate space: <c>Tiles[x, y]</c> where <c>x ∈ [0, W)</c> and
|
||||
/// <c>y ∈ [0, H)</c>. Every <see cref="Room"/>'s AABB falls within these
|
||||
/// bounds; corridor tiles between rooms are also in this array.
|
||||
/// </summary>
|
||||
public sealed class Dungeon
|
||||
{
|
||||
/// <summary>Source PoI id. Identity for save lookups.</summary>
|
||||
public int PoiId { get; }
|
||||
|
||||
/// <summary>Dungeon type — drives art family, default loot tier, etc.</summary>
|
||||
public PoiType Type { get; }
|
||||
|
||||
/// <summary>The tactical-tile grid in dungeon-local coordinates.</summary>
|
||||
public TacticalTile[,] Tiles { get; }
|
||||
|
||||
/// <summary>Every room in layout order. <c>Rooms[i].Id == i</c>.</summary>
|
||||
public Room[] Rooms { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Door-anchored connections between rooms. Authoritative for
|
||||
/// reachability — the graph is undirected (a connection from A→B is
|
||||
/// implicit B→A; do not double-store).
|
||||
/// </summary>
|
||||
public RoomConnection[] Connections { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Dungeon-local tile coords of the entrance. The player spawns here
|
||||
/// on enter and exits when they cross this tile inbound from inside.
|
||||
/// </summary>
|
||||
public (int X, int Y) EntranceTile { get; }
|
||||
|
||||
/// <summary>Width of the tile array (dungeon-local tiles).</summary>
|
||||
public int W => Tiles.GetLength(0);
|
||||
/// <summary>Height of the tile array (dungeon-local tiles).</summary>
|
||||
public int H => Tiles.GetLength(1);
|
||||
|
||||
public Dungeon(
|
||||
int poiId,
|
||||
PoiType type,
|
||||
TacticalTile[,] tiles,
|
||||
Room[] rooms,
|
||||
RoomConnection[] connections,
|
||||
(int X, int Y) entranceTile)
|
||||
{
|
||||
PoiId = poiId;
|
||||
Type = type;
|
||||
Tiles = tiles;
|
||||
Rooms = rooms;
|
||||
Connections = connections;
|
||||
EntranceTile = entranceTile;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.World;
|
||||
|
||||
namespace Theriapolis.Core.Dungeons;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M1 — top-level deterministic entry point for generating the
|
||||
/// interior of a PoI on first visit.
|
||||
///
|
||||
/// Determinism contract: <c>(worldSeed, poiId)</c> → byte-identical
|
||||
/// <see cref="Dungeon"/> across runs. Internally:
|
||||
/// <c>dungeonLayoutSeed = worldSeed ^ C.RNG_DUNGEON_LAYOUT ^ poiId</c>
|
||||
///
|
||||
/// Anchor-locked PoIs (Old Howl mine, Imperium Ruin showcase, Phase 7 M5
|
||||
/// content) bypass the procedural pipeline by routing to a pinned-rooms
|
||||
/// layout JSON. The pinned layout names the exact templates to use, in
|
||||
/// order; the assembler's branching policy still applies (typically
|
||||
/// linear). M1 does NOT pin any specific anchor PoI to a layout — that
|
||||
/// wiring lands in M5 alongside <c>side_act_i_old_howl.json</c> and the
|
||||
/// showcase rebuild. M1 ships the routing infrastructure.
|
||||
/// </summary>
|
||||
public static class DungeonGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure deterministic generator. Caller supplies the PoI's id (used as
|
||||
/// the per-dungeon sub-seed nonce), the dungeon type (drives layout
|
||||
/// selection + tile family), and the resolved content.
|
||||
///
|
||||
/// <paramref name="anchorOverride"/> — optional anchor id; when set,
|
||||
/// the generator looks up
|
||||
/// <see cref="ContentResolver.DungeonLayoutsByAnchor"/> first. M1
|
||||
/// callers leave it null; M5+ wires the Old Howl + Imperium showcase
|
||||
/// anchor routing.
|
||||
/// </summary>
|
||||
public static Dungeon Generate(
|
||||
ulong worldSeed,
|
||||
int poiId,
|
||||
PoiType type,
|
||||
ContentResolver content,
|
||||
string? anchorOverride = null)
|
||||
{
|
||||
if (content is null) throw new ArgumentNullException(nameof(content));
|
||||
|
||||
ulong layoutSeed = worldSeed ^ C.RNG_DUNGEON_LAYOUT ^ (ulong)poiId;
|
||||
|
||||
DungeonLayoutDef layout = ResolveLayout(type, content, anchorOverride);
|
||||
|
||||
// Build the room-graph plan.
|
||||
RoomGraphAssembler.Plan plan;
|
||||
if (layout.PinnedRooms.Length > 0)
|
||||
{
|
||||
plan = AssemblePinnedLayout(layout, content, layoutSeed);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Procedural layout — pick from typed templates.
|
||||
string typeKey = TypeKeyFor(type);
|
||||
if (!content.RoomTemplatesByType.TryGetValue(typeKey, out var typeTemplates) || typeTemplates.Count == 0)
|
||||
throw new InvalidOperationException(
|
||||
$"No room templates for dungeon type '{typeKey}' (PoiType.{type}). " +
|
||||
"Did you author Content/Data/room_templates/{type}/*.json?");
|
||||
|
||||
plan = DungeonLayoutBuilder.Build(layout, typeTemplates, layoutSeed);
|
||||
}
|
||||
|
||||
// Paint the tiles.
|
||||
var tiles = RoomTilePainter.Paint(plan, content.RoomTemplates, type);
|
||||
|
||||
return new Dungeon(
|
||||
poiId: poiId,
|
||||
type: type,
|
||||
tiles: tiles,
|
||||
rooms: plan.Rooms,
|
||||
connections: plan.Connections,
|
||||
entranceTile: plan.EntranceTile);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find a procedural layout for the given type. M1 picks the first
|
||||
/// matching layout deterministically (room-count band tied to layout
|
||||
/// id); M2+ may add LevelBand-driven small/medium/large selection.
|
||||
/// </summary>
|
||||
private static DungeonLayoutDef ResolveLayout(PoiType type, ContentResolver content, string? anchorOverride)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(anchorOverride)
|
||||
&& content.DungeonLayoutsByAnchor.TryGetValue(anchorOverride, out var pinned))
|
||||
return pinned;
|
||||
|
||||
// Find the first non-anchor layout for this type. Stable order:
|
||||
// dictionary iteration is unordered in C# but DungeonLayouts is built
|
||||
// from a sorted file list (LoadDungeonLayouts orders by file path),
|
||||
// so iteration follows that order on .NET 8.
|
||||
foreach (var l in content.DungeonLayouts.Values)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(l.Anchor)) continue;
|
||||
if (string.Equals(l.DungeonType, type.ToString(), StringComparison.OrdinalIgnoreCase))
|
||||
return l;
|
||||
}
|
||||
throw new InvalidOperationException(
|
||||
$"No dungeon layout found for PoiType.{type}. " +
|
||||
"Author Content/Data/dungeon_layouts/<type>_<size>.json.");
|
||||
}
|
||||
|
||||
private static string TypeKeyFor(PoiType type) => type switch
|
||||
{
|
||||
PoiType.ImperiumRuin => "imperium",
|
||||
PoiType.AbandonedMine => "mine",
|
||||
PoiType.CultDen => "cult",
|
||||
PoiType.NaturalCave => "cave",
|
||||
PoiType.OvergrownSettlement => "overgrown",
|
||||
_ => "imperium",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Build a plan from a pinned-rooms layout. Pinned layouts always use
|
||||
/// linear branching (the canonical Old Howl + Imperium showcase shape)
|
||||
/// — no retry, no fallback, no random pick.
|
||||
/// </summary>
|
||||
private static RoomGraphAssembler.Plan AssemblePinnedLayout(
|
||||
DungeonLayoutDef layout, ContentResolver content, ulong layoutSeed)
|
||||
{
|
||||
var picks = new List<RoomTemplateDef>(layout.PinnedRooms.Length);
|
||||
var roles = new List<RoomRole>(layout.PinnedRooms.Length);
|
||||
foreach (var pin in layout.PinnedRooms)
|
||||
{
|
||||
if (!content.RoomTemplates.TryGetValue(pin.Template, out var def))
|
||||
throw new InvalidOperationException(
|
||||
$"Pinned layout '{layout.Id}' references unknown template '{pin.Template}'. " +
|
||||
"ContentLoader should have caught this — re-run content-validate.");
|
||||
picks.Add(def);
|
||||
roles.Add(RoomRoleExtensions.Parse(pin.Role));
|
||||
}
|
||||
var rng = new Util.SeededRng(layoutSeed);
|
||||
var plan = RoomGraphAssembler.TryAssemble(picks, roles, "linear", rng);
|
||||
if (plan is null)
|
||||
throw new InvalidOperationException(
|
||||
$"Pinned layout '{layout.Id}' failed to assemble. Pinned layouts must be hand-validated.");
|
||||
return plan;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.Dungeons;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M1 — picks room templates per layout, then hands them to
|
||||
/// <see cref="RoomGraphAssembler"/>. Pure deterministic given the seed
|
||||
/// and layout.
|
||||
///
|
||||
/// Algorithm:
|
||||
/// 1. Roll <c>roomCount</c> in [<c>RoomCountMin</c>, <c>RoomCountMax</c>].
|
||||
/// 2. Pick the entry-room template (filtered to <c>role: entry</c>).
|
||||
/// 3. Reserve slots for required roles (boss, narrative, etc.). Insert
|
||||
/// them last so they don't get crowded out by transit picks.
|
||||
/// 4. Fill remaining slots with transit / loot / dead-end picks.
|
||||
/// 5. Hand the template list + role list to the assembler.
|
||||
/// 6. On assembly failure, retry up to
|
||||
/// <c>C.DUNGEON_LAYOUT_MAX_ATTEMPTS</c> times. If all retries fail,
|
||||
/// fall back to a guaranteed-valid linear chain of N transit rooms.
|
||||
///
|
||||
/// Anchor-locked layouts (Old Howl mine, Imperium showcase) bypass this
|
||||
/// pipeline — <see cref="DungeonGenerator"/> short-circuits to the pinned
|
||||
/// template list directly.
|
||||
/// </summary>
|
||||
internal static class DungeonLayoutBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Build a fully-assembled <see cref="RoomGraphAssembler.Plan"/> for
|
||||
/// the given layout + content. Returns the plan from the first
|
||||
/// successful attempt; falls back to the linear-chain plan if every
|
||||
/// attempt fails.
|
||||
/// </summary>
|
||||
public static RoomGraphAssembler.Plan Build(
|
||||
DungeonLayoutDef layout,
|
||||
IReadOnlyList<RoomTemplateDef> typeTemplates,
|
||||
ulong layoutSeed)
|
||||
{
|
||||
if (layout is null) throw new ArgumentNullException(nameof(layout));
|
||||
if (typeTemplates is null || typeTemplates.Count == 0)
|
||||
throw new ArgumentException("no templates available for this dungeon type", nameof(typeTemplates));
|
||||
|
||||
var rng = new SeededRng(layoutSeed);
|
||||
|
||||
for (int attempt = 0; attempt < C.DUNGEON_LAYOUT_MAX_ATTEMPTS; attempt++)
|
||||
{
|
||||
int roomCount = rng.NextInt(layout.RoomCountMin, layout.RoomCountMax + 1);
|
||||
var (picks, roles) = PickTemplates(layout, typeTemplates, roomCount, rng);
|
||||
if (picks is null) continue;
|
||||
|
||||
var plan = RoomGraphAssembler.TryAssemble(picks, roles!, layout.Branching, rng);
|
||||
if (plan is not null) return plan;
|
||||
}
|
||||
|
||||
// Fallback: guaranteed-valid linear chain.
|
||||
return BuildLinearFallback(layout, typeTemplates, rng);
|
||||
}
|
||||
|
||||
private static (RoomTemplateDef[]? picks, RoomRole[]? roles) PickTemplates(
|
||||
DungeonLayoutDef layout,
|
||||
IReadOnlyList<RoomTemplateDef> typeTemplates,
|
||||
int roomCount,
|
||||
SeededRng rng)
|
||||
{
|
||||
// Group templates by eligible role for fast picking.
|
||||
var byRole = new Dictionary<RoomRole, List<RoomTemplateDef>>();
|
||||
foreach (var t in typeTemplates)
|
||||
foreach (var roleTag in t.RolesEligible)
|
||||
{
|
||||
RoomRole role;
|
||||
try { role = RoomRoleExtensions.Parse(roleTag); } catch { continue; }
|
||||
if (!byRole.TryGetValue(role, out var list))
|
||||
byRole[role] = list = new List<RoomTemplateDef>();
|
||||
list.Add(t);
|
||||
}
|
||||
|
||||
// Required roles must each be satisfiable.
|
||||
var requiredRoles = new List<RoomRole>();
|
||||
foreach (var raw in layout.RequiredRoles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var r = RoomRoleExtensions.Parse(raw);
|
||||
if (!byRole.ContainsKey(r) || byRole[r].Count == 0) return (null, null);
|
||||
requiredRoles.Add(r);
|
||||
}
|
||||
catch { return (null, null); }
|
||||
}
|
||||
|
||||
// 1. Entry: pick from "entry" pool (always required).
|
||||
if (!byRole.TryGetValue(RoomRole.Entry, out var entryPool) || entryPool.Count == 0) return (null, null);
|
||||
var entryPick = PickWeighted(entryPool, rng);
|
||||
|
||||
var picks = new List<RoomTemplateDef> { entryPick };
|
||||
var roles = new List<RoomRole> { RoomRole.Entry };
|
||||
|
||||
// 2. Reserve required-role slots (excluding entry, which is implicit).
|
||||
var deferred = new List<RoomRole>();
|
||||
foreach (var r in requiredRoles)
|
||||
{
|
||||
if (r == RoomRole.Entry) continue; // already placed
|
||||
deferred.Add(r);
|
||||
}
|
||||
|
||||
// 3. Fill remaining slots with transit / loot / dead-end.
|
||||
var optionalRoles = new List<RoomRole>();
|
||||
foreach (var raw in layout.OptionalRoles)
|
||||
{
|
||||
try { optionalRoles.Add(RoomRoleExtensions.Parse(raw)); } catch { /* ignore */ }
|
||||
}
|
||||
if (optionalRoles.Count == 0) optionalRoles.Add(RoomRole.Transit);
|
||||
|
||||
int slotsLeft = roomCount - 1 - deferred.Count;
|
||||
if (slotsLeft < 0) return (null, null); // required-role count exceeds room count
|
||||
|
||||
for (int i = 0; i < slotsLeft; i++)
|
||||
{
|
||||
var role = optionalRoles[rng.NextInt(0, optionalRoles.Count)];
|
||||
if (!byRole.TryGetValue(role, out var pool) || pool.Count == 0)
|
||||
role = RoomRole.Transit;
|
||||
// Fall back to typeTemplates if the role pool is empty.
|
||||
var fromPool = byRole.TryGetValue(role, out var p) && p.Count > 0
|
||||
? PickWeighted(p, rng)
|
||||
: PickWeighted(typeTemplates, rng);
|
||||
picks.Add(fromPool);
|
||||
roles.Add(role);
|
||||
}
|
||||
|
||||
// 4. Append deferred required-role picks (boss last so it ends the layout).
|
||||
deferred.Sort((a, b) => RolePriority(a).CompareTo(RolePriority(b)));
|
||||
foreach (var r in deferred)
|
||||
{
|
||||
picks.Add(PickWeighted(byRole[r], rng));
|
||||
roles.Add(r);
|
||||
}
|
||||
|
||||
return (picks.ToArray(), roles.ToArray());
|
||||
}
|
||||
|
||||
private static int RolePriority(RoomRole r) => r switch
|
||||
{
|
||||
RoomRole.Narrative => 0,
|
||||
RoomRole.Loot => 1,
|
||||
RoomRole.DeadEnd => 2,
|
||||
RoomRole.Boss => 9, // boss room last
|
||||
_ => 5,
|
||||
};
|
||||
|
||||
private static RoomTemplateDef PickWeighted(IReadOnlyList<RoomTemplateDef> pool, SeededRng rng)
|
||||
{
|
||||
if (pool.Count == 1) return pool[0];
|
||||
float total = 0f;
|
||||
foreach (var t in pool) total += t.Weight > 0 ? t.Weight : 1f;
|
||||
float roll = rng.NextFloat() * total;
|
||||
float acc = 0f;
|
||||
foreach (var t in pool)
|
||||
{
|
||||
acc += t.Weight > 0 ? t.Weight : 1f;
|
||||
if (roll <= acc) return t;
|
||||
}
|
||||
return pool[pool.Count - 1];
|
||||
}
|
||||
|
||||
private static RoomGraphAssembler.Plan BuildLinearFallback(
|
||||
DungeonLayoutDef layout,
|
||||
IReadOnlyList<RoomTemplateDef> typeTemplates,
|
||||
SeededRng rng)
|
||||
{
|
||||
// Linear chain: entry → N transits → boss (if required). The pure
|
||||
// last-resort path; any layout reaches it via a deterministic-but-
|
||||
// logged rng state.
|
||||
var entryPool = typeTemplates.Where(t => t.RolesEligible.Contains("entry", StringComparer.OrdinalIgnoreCase)).ToList();
|
||||
if (entryPool.Count == 0) entryPool = typeTemplates.ToList();
|
||||
var transitPool = typeTemplates.Where(t => t.RolesEligible.Contains("transit", StringComparer.OrdinalIgnoreCase)).ToList();
|
||||
if (transitPool.Count == 0) transitPool = typeTemplates.ToList();
|
||||
var bossPool = typeTemplates.Where(t => t.RolesEligible.Contains("boss", StringComparer.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
bool wantsBoss = layout.RequiredRoles.Contains("boss", StringComparer.OrdinalIgnoreCase) && bossPool.Count > 0;
|
||||
|
||||
int roomCount = layout.RoomCountMin;
|
||||
var picks = new List<RoomTemplateDef> { PickWeighted(entryPool, rng) };
|
||||
var roles = new List<RoomRole> { RoomRole.Entry };
|
||||
|
||||
int transitCount = wantsBoss ? roomCount - 2 : roomCount - 1;
|
||||
for (int i = 0; i < transitCount; i++)
|
||||
{
|
||||
picks.Add(PickWeighted(transitPool, rng));
|
||||
roles.Add(RoomRole.Transit);
|
||||
}
|
||||
if (wantsBoss)
|
||||
{
|
||||
picks.Add(PickWeighted(bossPool, rng));
|
||||
roles.Add(RoomRole.Boss);
|
||||
}
|
||||
|
||||
// Force the linear branching policy here regardless of layout; the
|
||||
// fallback exists exactly because branching/loop assembly failed.
|
||||
var plan = RoomGraphAssembler.TryAssemble(picks, roles, "linear", rng);
|
||||
if (plan is null)
|
||||
{
|
||||
// Truly degenerate — single-room dungeon (entry only).
|
||||
var only = picks[0];
|
||||
int pad = C.DUNGEON_AABB_PADDING;
|
||||
var rooms = new[]
|
||||
{
|
||||
new Room
|
||||
{
|
||||
Id = 0, TemplateId = only.Id,
|
||||
AabbX = pad, AabbY = pad,
|
||||
AabbW = only.FootprintWTiles, AabbH = only.FootprintHTiles,
|
||||
BuiltBy = only.BuiltBy, Role = RoomRole.Entry,
|
||||
NarrativeText = only.NarrativeText,
|
||||
},
|
||||
};
|
||||
(int X, int Y) entrance = only.Doors.Length > 0
|
||||
? (pad + only.Doors[0].X, pad + only.Doors[0].Y)
|
||||
: (pad + only.FootprintWTiles / 2, pad);
|
||||
return new RoomGraphAssembler.Plan(
|
||||
rooms, Array.Empty<RoomConnection>(),
|
||||
only.FootprintWTiles + 2 * pad,
|
||||
only.FootprintHTiles + 2 * pad,
|
||||
entrance);
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Items;
|
||||
|
||||
namespace Theriapolis.Core.Dungeons;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M2 — pre-instantiated population of a generated dungeon. Holds:
|
||||
/// - The list of <see cref="DungeonSpawn"/> entries (one per encounter
|
||||
/// slot in any room) — coordinates + template + role tag.
|
||||
/// - The list of <see cref="DungeonContainer"/> entries (one per
|
||||
/// container slot) — coordinates + table id + pre-rolled item drops.
|
||||
///
|
||||
/// Generated alongside (and from) a <see cref="Dungeon"/> via
|
||||
/// <see cref="DungeonPopulator.Populate"/>. Living combatants and item
|
||||
/// pickups derive from these records on first dungeon entry; the
|
||||
/// <see cref="Theriapolis.Core.Persistence.DungeonStateSnapshot"/>
|
||||
/// persists which entries have been resolved (cleared / looted) so
|
||||
/// re-entry doesn't re-spawn them.
|
||||
/// </summary>
|
||||
public sealed class DungeonPopulation
|
||||
{
|
||||
public DungeonSpawn[] Spawns { get; }
|
||||
public DungeonContainer[] Containers { get; }
|
||||
|
||||
public DungeonPopulation(DungeonSpawn[] spawns, DungeonContainer[] containers)
|
||||
{
|
||||
Spawns = spawns;
|
||||
Containers = containers;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One <c>@</c> encounter slot in a generated dungeon — resolved to the
|
||||
/// concrete NPC template that fills it. The dungeon coords are absolute
|
||||
/// (within the dungeon's tile array, not template-local).
|
||||
/// </summary>
|
||||
public readonly record struct DungeonSpawn(
|
||||
/// <summary>Index into <see cref="Dungeon.Rooms"/> the spawn lives in.</summary>
|
||||
int RoomId,
|
||||
/// <summary>Dungeon-local tile-X.</summary>
|
||||
int X,
|
||||
/// <summary>Dungeon-local tile-Y.</summary>
|
||||
int Y,
|
||||
/// <summary>The chosen NPC template. Caller instantiates from this.</summary>
|
||||
NpcTemplateDef Template,
|
||||
/// <summary>Spawn-kind tag the slot declared (PoiGuard / WildAnimal / Brigand / Boss).</summary>
|
||||
string Kind);
|
||||
|
||||
/// <summary>
|
||||
/// One <c>C</c> container slot in a generated dungeon — resolved to the
|
||||
/// concrete loot drop. Populated at generation time so the same
|
||||
/// <c>(worldSeed, poiId, slotIdx)</c> always rolls identical items.
|
||||
/// </summary>
|
||||
public readonly record struct DungeonContainer(
|
||||
/// <summary>Index into <see cref="Dungeon.Rooms"/> the container is in.</summary>
|
||||
int RoomId,
|
||||
/// <summary>Dungeon-local tile-X.</summary>
|
||||
int X,
|
||||
/// <summary>Dungeon-local tile-Y.</summary>
|
||||
int Y,
|
||||
/// <summary>Loot-table id consulted at populate time.</summary>
|
||||
string TableId,
|
||||
/// <summary>Pre-rolled item drops, ready to transfer on player loot.</summary>
|
||||
ItemInstance[] Drops,
|
||||
/// <summary>True when the slot's grid char declared <c>locked</c>.</summary>
|
||||
bool Locked,
|
||||
/// <summary>Lock difficulty tier ("trivial"/"easy"/"medium"/"hard"; empty when unlocked).</summary>
|
||||
string LockTier);
|
||||
@@ -0,0 +1,195 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Items;
|
||||
using Theriapolis.Core.Loot;
|
||||
using Theriapolis.Core.World;
|
||||
|
||||
namespace Theriapolis.Core.Dungeons;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M2 — populate a generated <see cref="Dungeon"/>'s encounter
|
||||
/// and container slots. Pure deterministic given the same inputs:
|
||||
/// <c>(worldSeed, poiId, dungeon, content, levelBand)</c>.
|
||||
///
|
||||
/// For each room:
|
||||
/// - Walk the room's source <see cref="RoomTemplateDef.EncounterSlots"/>
|
||||
/// and resolve each slot to a concrete <see cref="NpcTemplateDef"/> via
|
||||
/// <see cref="NpcTemplateContent.SpawnKindToTemplateByDungeonType"/>
|
||||
/// (with a fallback to <see cref="NpcTemplateContent.SpawnKindToTemplateByZone"/>
|
||||
/// middle tier if the per-dungeon-type table doesn't list that kind).
|
||||
/// - Walk the source <see cref="RoomTemplateDef.ContainerSlots"/> and
|
||||
/// pre-roll each container's loot via
|
||||
/// <see cref="LootGenerator.RollContainer"/>, mapping the container's
|
||||
/// <c>loot_table_band</c> through the layout's
|
||||
/// <c>loot_table_per_band</c> to a concrete table id.
|
||||
///
|
||||
/// Boss-room encounter slots use the dungeon-type's <c>"Boss"</c>
|
||||
/// template if available, otherwise fall back to the <c>"PoiGuard"</c>
|
||||
/// template — no boss → strong-guard graceful degradation.
|
||||
///
|
||||
/// Per-NPC-spawn RNG sub-seed:
|
||||
/// <c>populateSeed = dungeonLayoutSeed ^ C.RNG_DUNGEON_POPULATE ^ roomId ^ slotIdx</c>
|
||||
/// — present so future variant rolls (e.g. random which-of-N-equivalent-
|
||||
/// templates) stay deterministic. M2 doesn't read it (per-kind template
|
||||
/// is fixed by the override map) but the helper is wired up so M3+
|
||||
/// content can use it without a contract change.
|
||||
/// </summary>
|
||||
public static class DungeonPopulator
|
||||
{
|
||||
/// <summary>
|
||||
/// Populate a freshly-generated dungeon. <paramref name="layoutId"/>
|
||||
/// is the dungeon-layout id (from <see cref="DungeonLayoutDef.Id"/>);
|
||||
/// the populator looks it up via <paramref name="content"/> to read
|
||||
/// loot-band → table mappings. <paramref name="levelBand"/> is the
|
||||
/// PoI's authored level band (0..3) and selects which loot tier
|
||||
/// each container slot rolls.
|
||||
/// </summary>
|
||||
public static DungeonPopulation Populate(
|
||||
Dungeon dungeon,
|
||||
DungeonLayoutDef layout,
|
||||
ContentResolver content,
|
||||
int levelBand,
|
||||
ulong worldSeed)
|
||||
{
|
||||
if (dungeon is null) throw new System.ArgumentNullException(nameof(dungeon));
|
||||
if (layout is null) throw new System.ArgumentNullException(nameof(layout));
|
||||
if (content is null) throw new System.ArgumentNullException(nameof(content));
|
||||
|
||||
ulong dungeonLayoutSeed = worldSeed ^ C.RNG_DUNGEON_LAYOUT ^ (ulong)dungeon.PoiId;
|
||||
|
||||
// Resolve loot-band → table-id mapping for this layout + level band.
|
||||
string lootBand = ResolveLootBand(layout, levelBand);
|
||||
layout.LootTablePerBand.TryGetValue(lootBand, out var lootTableForBand);
|
||||
|
||||
var spawns = new List<DungeonSpawn>();
|
||||
var containers = new List<DungeonContainer>();
|
||||
|
||||
// Per-dungeon spawn-kind resolver lookup.
|
||||
string typeKey = dungeon.Type.ToString();
|
||||
content.Npcs.SpawnKindToTemplateByDungeonType.TryGetValue(typeKey, out var kindMap);
|
||||
|
||||
int globalContainerIdx = 0;
|
||||
int globalSpawnIdx = 0;
|
||||
|
||||
foreach (var room in dungeon.Rooms)
|
||||
{
|
||||
if (!content.RoomTemplates.TryGetValue(room.TemplateId, out var def))
|
||||
continue;
|
||||
|
||||
// Encounter slots → concrete spawns.
|
||||
foreach (var slot in def.EncounterSlots)
|
||||
{
|
||||
var template = ResolveSpawnTemplate(slot.Kind, room.Role, kindMap, content.Npcs, dungeon.Type);
|
||||
if (template is null) { globalSpawnIdx++; continue; }
|
||||
|
||||
int absX = room.AabbX + slot.X;
|
||||
int absY = room.AabbY + slot.Y;
|
||||
spawns.Add(new DungeonSpawn(
|
||||
RoomId: room.Id,
|
||||
X: absX,
|
||||
Y: absY,
|
||||
Template: template,
|
||||
Kind: slot.Kind));
|
||||
globalSpawnIdx++;
|
||||
}
|
||||
|
||||
// Container slots → pre-rolled loot.
|
||||
foreach (var slot in def.ContainerSlots)
|
||||
{
|
||||
int absX = room.AabbX + slot.X;
|
||||
int absY = room.AabbY + slot.Y;
|
||||
// Per-container band: room's container slot may declare its
|
||||
// own band (e.g. boss room slot says "t3"); otherwise we
|
||||
// use the layout's level-band → loot-band lookup.
|
||||
string slotBand = !string.IsNullOrEmpty(slot.LootTableBand) ? slot.LootTableBand : lootBand;
|
||||
layout.LootTablePerBand.TryGetValue(slotBand, out var slotTableId);
|
||||
slotTableId ??= lootTableForBand ?? "";
|
||||
|
||||
var drops = string.IsNullOrEmpty(slotTableId)
|
||||
? System.Array.Empty<ItemInstance>()
|
||||
: LootGenerator.RollContainer(
|
||||
tableId: slotTableId,
|
||||
dungeonLayoutSeed: dungeonLayoutSeed,
|
||||
slotIdx: globalContainerIdx,
|
||||
tables: content.LootTables,
|
||||
items: content.Items);
|
||||
|
||||
containers.Add(new DungeonContainer(
|
||||
RoomId: room.Id,
|
||||
X: absX,
|
||||
Y: absY,
|
||||
TableId: slotTableId ?? "",
|
||||
Drops: drops,
|
||||
Locked: slot.Locked,
|
||||
LockTier: slot.Lock));
|
||||
globalContainerIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
return new DungeonPopulation(spawns.ToArray(), containers.ToArray());
|
||||
}
|
||||
|
||||
private static string ResolveLootBand(DungeonLayoutDef layout, int levelBand)
|
||||
{
|
||||
var key = levelBand.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
if (layout.LevelBandToLootBand.TryGetValue(key, out var band) && !string.IsNullOrEmpty(band))
|
||||
return band;
|
||||
// Default per the Phase 7 plan §5.5 thresholds:
|
||||
// levelBand 0..1 → t1, 2 → t2, 3+ → t3.
|
||||
return levelBand switch
|
||||
{
|
||||
<= 1 => "t1",
|
||||
2 => "t2",
|
||||
_ => "t3",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a slot's spawn-kind tag to a concrete NPC template. Boss
|
||||
/// slots get the dungeon-type's "Boss" template if listed; otherwise
|
||||
/// the slot's own kind, with a graceful fall-through to the
|
||||
/// existing per-zone table at the mid tier.
|
||||
/// </summary>
|
||||
private static NpcTemplateDef? ResolveSpawnTemplate(
|
||||
string slotKind,
|
||||
RoomRole roomRole,
|
||||
IReadOnlyDictionary<string, string>? kindMap,
|
||||
NpcTemplateContent npcs,
|
||||
PoiType dungeonType)
|
||||
{
|
||||
// Boss-role rooms with a boss kind: prefer the per-dungeon-type
|
||||
// "Boss" entry. If neither the slot says "Boss" nor the room is
|
||||
// a boss room, this branch doesn't fire.
|
||||
string effectiveKind = slotKind;
|
||||
if (roomRole == RoomRole.Boss && string.Equals(slotKind, "Boss", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
effectiveKind = "Boss";
|
||||
}
|
||||
|
||||
// 1. Per-dungeon-type override.
|
||||
if (kindMap is not null && kindMap.TryGetValue(effectiveKind, out var tplId))
|
||||
{
|
||||
foreach (var t in npcs.Templates)
|
||||
if (string.Equals(t.Id, tplId, System.StringComparison.OrdinalIgnoreCase))
|
||||
return t;
|
||||
}
|
||||
|
||||
// 2. Boss kind unmapped → fall back to PoiGuard for the same dungeon type.
|
||||
if (effectiveKind == "Boss" && kindMap is not null && kindMap.TryGetValue("PoiGuard", out var guardId))
|
||||
{
|
||||
foreach (var t in npcs.Templates)
|
||||
if (string.Equals(t.Id, guardId, System.StringComparison.OrdinalIgnoreCase))
|
||||
return t;
|
||||
}
|
||||
|
||||
// 3. Final fallback: the per-zone table at zone 2 (mid).
|
||||
if (npcs.SpawnKindToTemplateByZone.TryGetValue(slotKind, out var byZone) && byZone.Length > 0)
|
||||
{
|
||||
int z = System.Math.Min(2, byZone.Length - 1);
|
||||
string id = byZone[z];
|
||||
foreach (var t in npcs.Templates)
|
||||
if (string.Equals(t.Id, id, System.StringComparison.OrdinalIgnoreCase))
|
||||
return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace Theriapolis.Core.Dungeons;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M1 — runtime state for a single room inside a generated dungeon.
|
||||
///
|
||||
/// A Room records its template id, its dungeon-local axis-aligned bounding
|
||||
/// box (top-left corner + dimensions, in tactical tiles), the clade that
|
||||
/// built it (drives clade-responsive movement cost per Phase 7 plan §5.4),
|
||||
/// and the role it occupies in the dungeon's layout. Mutable runtime state
|
||||
/// (cleared / looted flags) lives on the <see cref="DungeonState"/>
|
||||
/// snapshot, not here, so this record stays a deterministic baseline view.
|
||||
/// </summary>
|
||||
public sealed class Room
|
||||
{
|
||||
/// <summary>Stable id within a dungeon (0..Dungeon.Rooms.Length-1).</summary>
|
||||
public int Id { get; init; }
|
||||
|
||||
/// <summary>Reference back to the source <see cref="Theriapolis.Core.Data.RoomTemplateDef.Id"/>.</summary>
|
||||
public string TemplateId { get; init; } = "";
|
||||
|
||||
/// <summary>Dungeon-local AABB top-left X (tile units). Inclusive.</summary>
|
||||
public int AabbX { get; init; }
|
||||
/// <summary>Dungeon-local AABB top-left Y (tile units). Inclusive.</summary>
|
||||
public int AabbY { get; init; }
|
||||
/// <summary>AABB width in tactical tiles (matches the source template's <c>footprint_w_tiles</c>).</summary>
|
||||
public int AabbW { get; init; }
|
||||
/// <summary>AABB height in tactical tiles (matches the source template's <c>footprint_h_tiles</c>).</summary>
|
||||
public int AabbH { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Builder clade — drives the clade-responsive movement multiplier
|
||||
/// (<c>ClademorphicMovement.GetCostMultiplier</c>) the player pays
|
||||
/// while moving through this room. Empty / "none" means no penalty.
|
||||
/// </summary>
|
||||
public string BuiltBy { get; init; } = "none";
|
||||
|
||||
/// <summary>Role assigned at layout-build time.</summary>
|
||||
public RoomRole Role { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional environmental-storytelling prose surfaced by the
|
||||
/// InteractionScreen scent-overlay panel (Phase 6.5 M1) and the
|
||||
/// dungeon-clear coda. Null when the source template doesn't carry one.
|
||||
/// </summary>
|
||||
public string? NarrativeText { get; init; }
|
||||
|
||||
public override string ToString()
|
||||
=> $"Room[id={Id} role={Role} aabb=({AabbX},{AabbY},{AabbW}x{AabbH}) tpl={TemplateId}]";
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Theriapolis.Core.Dungeons;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M1 — a connection between two rooms in a dungeon, anchored by
|
||||
/// the door tile on each side. Connections are created by
|
||||
/// <see cref="RoomGraphAssembler"/> when matching one room's outgoing door
|
||||
/// to another's incoming door; they're authoritative for reachability
|
||||
/// (BFS over connections, not over the painted tile array).
|
||||
///
|
||||
/// Door coordinates are in dungeon-local tile space and address the
|
||||
/// *door tile itself* (carved out of the perimeter wall). The door state
|
||||
/// (open / closed / locked) lives in <see cref="Theriapolis.Core.Persistence.DungeonStateSnapshot"/>
|
||||
/// — this record carries only the deterministic baseline.
|
||||
/// </summary>
|
||||
public readonly record struct RoomConnection(
|
||||
int RoomA,
|
||||
int DoorAx,
|
||||
int DoorAy,
|
||||
int RoomB,
|
||||
int DoorBx,
|
||||
int DoorBy,
|
||||
/// <summary>Lock difficulty tier or "" for unlocked. Matches RoomDoor.Lock values.</summary>
|
||||
string Lock = "");
|
||||
@@ -0,0 +1,376 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.Dungeons;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M1 — pure deterministic room-graph assembly. Given a list of
|
||||
/// picked templates (in order: entry first, others in pick order) and a
|
||||
/// branching policy, returns:
|
||||
/// - Per-room placement (AABB top-left + Role)
|
||||
/// - List of <see cref="RoomConnection"/> binding rooms via matched
|
||||
/// door tiles
|
||||
///
|
||||
/// The assembler does NOT paint tiles — it only decides geometry. Tile
|
||||
/// painting + corridor stamping is <see cref="RoomTilePainter"/>'s job.
|
||||
///
|
||||
/// Placement algorithm: rooms snap to a 16-tile grid, placed left-to-right
|
||||
/// in chains; each non-entry room picks an existing-room neighbour from
|
||||
/// the eligible set (linear → previous; branching → uniform-random prior;
|
||||
/// loop → branching + one extra closing connection). The neighbour's
|
||||
/// available-side pool determines which AABB slot the new room takes.
|
||||
///
|
||||
/// Returns null on failure (overlap, unreachable). Caller retries with a
|
||||
/// fresh seed up to <see cref="C.DUNGEON_LAYOUT_MAX_ATTEMPTS"/> times then
|
||||
/// falls back to the linear policy.
|
||||
/// </summary>
|
||||
internal static class RoomGraphAssembler
|
||||
{
|
||||
public sealed class Plan
|
||||
{
|
||||
public Room[] Rooms;
|
||||
public RoomConnection[] Connections;
|
||||
public int DungeonW;
|
||||
public int DungeonH;
|
||||
public (int X, int Y) EntranceTile;
|
||||
|
||||
public Plan(Room[] rooms, RoomConnection[] connections, int w, int h, (int, int) entranceTile)
|
||||
{
|
||||
Rooms = rooms;
|
||||
Connections = connections;
|
||||
DungeonW = w;
|
||||
DungeonH = h;
|
||||
EntranceTile = entranceTile;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to assemble. <paramref name="branching"/> is one of
|
||||
/// <c>linear</c> / <c>branching</c> / <c>loop</c>. Returns null on
|
||||
/// any geometric failure; caller retries.
|
||||
/// </summary>
|
||||
public static Plan? TryAssemble(
|
||||
IReadOnlyList<RoomTemplateDef> picks,
|
||||
IReadOnlyList<RoomRole> roles,
|
||||
string branching,
|
||||
SeededRng rng)
|
||||
{
|
||||
if (picks.Count == 0) return null;
|
||||
if (picks.Count != roles.Count) throw new ArgumentException("picks and roles length mismatch");
|
||||
|
||||
// Step 1: place the entry room at the origin, padded by AABB padding.
|
||||
int pad = C.DUNGEON_AABB_PADDING;
|
||||
int gap = C.ROOM_INTER_ROOM_GAP_TILES;
|
||||
|
||||
var rooms = new Room[picks.Count];
|
||||
// Track absolute AABBs for overlap testing.
|
||||
var bounds = new (int x, int y, int w, int h)[picks.Count];
|
||||
|
||||
var entry = picks[0];
|
||||
rooms[0] = new Room
|
||||
{
|
||||
Id = 0,
|
||||
TemplateId = entry.Id,
|
||||
AabbX = pad,
|
||||
AabbY = pad,
|
||||
AabbW = entry.FootprintWTiles,
|
||||
AabbH = entry.FootprintHTiles,
|
||||
BuiltBy = entry.BuiltBy,
|
||||
Role = roles[0],
|
||||
NarrativeText = entry.NarrativeText,
|
||||
};
|
||||
bounds[0] = (pad, pad, entry.FootprintWTiles, entry.FootprintHTiles);
|
||||
|
||||
var connections = new List<RoomConnection>(picks.Count);
|
||||
|
||||
// Step 2: place each subsequent room next to a chosen prior room.
|
||||
for (int i = 1; i < picks.Count; i++)
|
||||
{
|
||||
int parentIdx = ChooseParent(branching, i, rng);
|
||||
var parent = bounds[parentIdx];
|
||||
var tpl = picks[i];
|
||||
|
||||
// Try each cardinal direction in a deterministic order; pick the
|
||||
// first that doesn't overlap. Order rotates per `i` so different
|
||||
// seeds produce different-looking layouts.
|
||||
int[] dirOrder = RotateDirOrder(i, rng);
|
||||
(int x, int y, int dir)? placement = null;
|
||||
foreach (int d in dirOrder)
|
||||
{
|
||||
var topLeft = TryPlaceAdjacent(parent, tpl.FootprintWTiles, tpl.FootprintHTiles, d, gap);
|
||||
if (topLeft is null) continue;
|
||||
var candBounds = (topLeft.Value.x, topLeft.Value.y, tpl.FootprintWTiles, tpl.FootprintHTiles);
|
||||
if (Overlaps(candBounds, bounds, i)) continue;
|
||||
placement = (topLeft.Value.x, topLeft.Value.y, d);
|
||||
break;
|
||||
}
|
||||
if (placement is null) return null; // ran out of room sides
|
||||
|
||||
rooms[i] = new Room
|
||||
{
|
||||
Id = i,
|
||||
TemplateId = tpl.Id,
|
||||
AabbX = placement.Value.x,
|
||||
AabbY = placement.Value.y,
|
||||
AabbW = tpl.FootprintWTiles,
|
||||
AabbH = tpl.FootprintHTiles,
|
||||
BuiltBy = tpl.BuiltBy,
|
||||
Role = roles[i],
|
||||
NarrativeText = tpl.NarrativeText,
|
||||
};
|
||||
bounds[i] = (placement.Value.x, placement.Value.y, tpl.FootprintWTiles, tpl.FootprintHTiles);
|
||||
|
||||
// Pick the door pair: parent's near-side door, this room's
|
||||
// far-side door. If neither template has a matching door we
|
||||
// synthesize a door at the AABB midpoint of the touching edge —
|
||||
// the painter will carve it through the wall.
|
||||
var conn = MatchDoors(rooms[parentIdx], picks[parentIdx], rooms[i], tpl, placement.Value.dir);
|
||||
connections.Add(conn);
|
||||
}
|
||||
|
||||
// Step 3 (loop policy only): add one extra closing connection if a
|
||||
// suitable pair exists. The closing connection must not duplicate
|
||||
// an existing edge.
|
||||
if (branching == "loop" && rooms.Length >= 4)
|
||||
{
|
||||
// Pick room i (not 0) and j > 1, j != i, with no existing edge.
|
||||
int triesLeft = 8;
|
||||
while (triesLeft-- > 0)
|
||||
{
|
||||
int i = rng.NextInt(2, rooms.Length);
|
||||
int j = rng.NextInt(2, rooms.Length);
|
||||
if (i == j) continue;
|
||||
if (HasEdge(connections, i, j)) continue;
|
||||
var dir = AdjacentDirection(bounds[i], bounds[j], gap + 2);
|
||||
if (dir is null) continue;
|
||||
connections.Add(MatchDoors(rooms[i], picks[i], rooms[j], picks[j], dir.Value));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: BFS reachability — every room must be reachable from room 0.
|
||||
if (!IsReachable(rooms.Length, connections)) return null;
|
||||
|
||||
// Step 5: compute dungeon bounds.
|
||||
int minX = int.MaxValue, minY = int.MaxValue, maxX = 0, maxY = 0;
|
||||
foreach (var b in bounds)
|
||||
{
|
||||
if (b.x < minX) minX = b.x;
|
||||
if (b.y < minY) minY = b.y;
|
||||
if (b.x + b.w > maxX) maxX = b.x + b.w;
|
||||
if (b.y + b.h > maxY) maxY = b.y + b.h;
|
||||
}
|
||||
// Translate everything so origin is (pad, pad).
|
||||
int dx = pad - minX;
|
||||
int dy = pad - minY;
|
||||
if (dx != 0 || dy != 0)
|
||||
{
|
||||
for (int i = 0; i < rooms.Length; i++)
|
||||
{
|
||||
rooms[i] = new Room
|
||||
{
|
||||
Id = rooms[i].Id,
|
||||
TemplateId = rooms[i].TemplateId,
|
||||
AabbX = rooms[i].AabbX + dx,
|
||||
AabbY = rooms[i].AabbY + dy,
|
||||
AabbW = rooms[i].AabbW,
|
||||
AabbH = rooms[i].AabbH,
|
||||
BuiltBy = rooms[i].BuiltBy,
|
||||
Role = rooms[i].Role,
|
||||
NarrativeText = rooms[i].NarrativeText,
|
||||
};
|
||||
}
|
||||
for (int k = 0; k < connections.Count; k++)
|
||||
{
|
||||
var c = connections[k];
|
||||
connections[k] = c with
|
||||
{
|
||||
DoorAx = c.DoorAx + dx,
|
||||
DoorAy = c.DoorAy + dy,
|
||||
DoorBx = c.DoorBx + dx,
|
||||
DoorBy = c.DoorBy + dy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
int dungeonW = maxX - minX + 2 * pad;
|
||||
int dungeonH = maxY - minY + 2 * pad;
|
||||
|
||||
// Entrance tile — pick the entry room's first declared door if any,
|
||||
// otherwise the centre of its top edge. (M1 always has a door because
|
||||
// every authored entry template declares at least one.)
|
||||
var entryDoor = picks[0].Doors.Length > 0 ? picks[0].Doors[0] : null;
|
||||
(int X, int Y) entranceTile;
|
||||
if (entryDoor is not null)
|
||||
entranceTile = (rooms[0].AabbX + entryDoor.X, rooms[0].AabbY + entryDoor.Y);
|
||||
else
|
||||
entranceTile = (rooms[0].AabbX + rooms[0].AabbW / 2, rooms[0].AabbY);
|
||||
|
||||
return new Plan(rooms, connections.ToArray(), dungeonW, dungeonH, entranceTile);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private static int ChooseParent(string branching, int childIdx, SeededRng rng) => branching switch
|
||||
{
|
||||
"linear" => childIdx - 1,
|
||||
"branching" => rng.NextInt(0, childIdx),
|
||||
"loop" => rng.NextInt(0, childIdx),
|
||||
_ => childIdx - 1,
|
||||
};
|
||||
|
||||
private static int[] RotateDirOrder(int i, SeededRng rng)
|
||||
{
|
||||
// Rotate base order by `i % 4` so adjacent rooms don't pile up on
|
||||
// the same axis. Add a small RNG-driven secondary shuffle so seeds
|
||||
// diverge.
|
||||
int[] baseOrder = new[] { 0, 1, 2, 3 }; // 0=E, 1=S, 2=W, 3=N
|
||||
int rot = i % 4;
|
||||
var rotated = new int[4];
|
||||
for (int k = 0; k < 4; k++) rotated[k] = baseOrder[(k + rot) % 4];
|
||||
// 50% chance to swap pairs (small variation)
|
||||
if ((rng.NextUInt64() & 1) == 1)
|
||||
{
|
||||
(rotated[0], rotated[2]) = (rotated[2], rotated[0]);
|
||||
}
|
||||
return rotated;
|
||||
}
|
||||
|
||||
private static (int x, int y)? TryPlaceAdjacent(
|
||||
(int x, int y, int w, int h) parent,
|
||||
int childW, int childH, int direction, int gap)
|
||||
{
|
||||
// direction: 0=E, 1=S, 2=W, 3=N. Child’s top-left is offset from
|
||||
// parent's outer edge by `gap` tiles so a corridor segment fits.
|
||||
return direction switch
|
||||
{
|
||||
0 => (parent.x + parent.w + gap, parent.y), // east
|
||||
1 => (parent.x, parent.y + parent.h + gap), // south
|
||||
2 => (parent.x - childW - gap, parent.y), // west
|
||||
3 => (parent.x, parent.y - childH - gap), // north
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool Overlaps(
|
||||
(int x, int y, int w, int h) cand,
|
||||
(int x, int y, int w, int h)[] bounds,
|
||||
int countSoFar)
|
||||
{
|
||||
for (int k = 0; k < countSoFar; k++)
|
||||
{
|
||||
var b = bounds[k];
|
||||
// AABB overlap: not (cand right of b OR cand left of b OR cand below b OR cand above b)
|
||||
bool noOverlap = cand.x + cand.w <= b.x
|
||||
|| b.x + b.w <= cand.x
|
||||
|| cand.y + cand.h <= b.y
|
||||
|| b.y + b.h <= cand.y;
|
||||
if (!noOverlap) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int? AdjacentDirection(
|
||||
(int x, int y, int w, int h) a,
|
||||
(int x, int y, int w, int h) b, int slack)
|
||||
{
|
||||
// Returns a direction code (0=E,1=S,2=W,3=N) for "b is to the X of
|
||||
// a within `slack` tiles" — used for loop-policy closing edges.
|
||||
if (Math.Abs((a.x + a.w) - b.x) <= slack && OverlapsRange(a.y, a.h, b.y, b.h))
|
||||
return 0;
|
||||
if (Math.Abs((a.y + a.h) - b.y) <= slack && OverlapsRange(a.x, a.w, b.x, b.w))
|
||||
return 1;
|
||||
if (Math.Abs(a.x - (b.x + b.w)) <= slack && OverlapsRange(a.y, a.h, b.y, b.h))
|
||||
return 2;
|
||||
if (Math.Abs(a.y - (b.y + b.h)) <= slack && OverlapsRange(a.x, a.w, b.x, b.w))
|
||||
return 3;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool OverlapsRange(int a0, int aLen, int b0, int bLen)
|
||||
=> !(a0 + aLen <= b0 || b0 + bLen <= a0);
|
||||
|
||||
private static bool HasEdge(List<RoomConnection> conns, int a, int b)
|
||||
{
|
||||
foreach (var c in conns)
|
||||
if ((c.RoomA == a && c.RoomB == b) || (c.RoomA == b && c.RoomB == a))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsReachable(int roomCount, List<RoomConnection> connections)
|
||||
{
|
||||
if (roomCount == 0) return true;
|
||||
var adj = new List<int>[roomCount];
|
||||
for (int i = 0; i < roomCount; i++) adj[i] = new List<int>();
|
||||
foreach (var c in connections)
|
||||
{
|
||||
adj[c.RoomA].Add(c.RoomB);
|
||||
adj[c.RoomB].Add(c.RoomA);
|
||||
}
|
||||
var visited = new bool[roomCount];
|
||||
var queue = new Queue<int>();
|
||||
queue.Enqueue(0);
|
||||
visited[0] = true;
|
||||
int reached = 1;
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
int n = queue.Dequeue();
|
||||
foreach (int m in adj[n])
|
||||
{
|
||||
if (visited[m]) continue;
|
||||
visited[m] = true;
|
||||
reached++;
|
||||
queue.Enqueue(m);
|
||||
}
|
||||
}
|
||||
return reached == roomCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Match door tiles between two rooms placed adjacent in
|
||||
/// <paramref name="direction"/>. Returns the connection record with
|
||||
/// dungeon-local door coords on each side. Falls back to the AABB
|
||||
/// midpoint of the touching edge when neither template declares a door
|
||||
/// on the relevant side.
|
||||
/// </summary>
|
||||
private static RoomConnection MatchDoors(
|
||||
Room a, RoomTemplateDef aDef,
|
||||
Room b, RoomTemplateDef bDef,
|
||||
int direction)
|
||||
{
|
||||
// Direction is from A's perspective: 0=B east of A; 1=B south; 2=B west; 3=B north.
|
||||
// Pick A's door on the matching side; pick B's door on the opposite side.
|
||||
string aFacing = direction switch { 0 => "E", 1 => "S", 2 => "W", 3 => "N", _ => "E" };
|
||||
string bFacing = direction switch { 0 => "W", 1 => "N", 2 => "E", 3 => "S", _ => "W" };
|
||||
|
||||
var aDoor = FindDoorByFacing(aDef, aFacing) ?? AabbEdgeMidpoint(aDef, aFacing);
|
||||
var bDoor = FindDoorByFacing(bDef, bFacing) ?? AabbEdgeMidpoint(bDef, bFacing);
|
||||
|
||||
return new RoomConnection(
|
||||
RoomA: a.Id, DoorAx: a.AabbX + aDoor.x, DoorAy: a.AabbY + aDoor.y,
|
||||
RoomB: b.Id, DoorBx: b.AabbX + bDoor.x, DoorBy: b.AabbY + bDoor.y,
|
||||
Lock: aDoor.lockTier);
|
||||
}
|
||||
|
||||
private static (int x, int y, string lockTier)? FindDoorByFacing(RoomTemplateDef def, string facing)
|
||||
{
|
||||
foreach (var d in def.Doors)
|
||||
if (string.Equals(d.Facing, facing, StringComparison.OrdinalIgnoreCase))
|
||||
return (d.X, d.Y, d.Lock);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static (int x, int y, string lockTier) AabbEdgeMidpoint(RoomTemplateDef def, string facing)
|
||||
{
|
||||
int w = def.FootprintWTiles, h = def.FootprintHTiles;
|
||||
return facing switch
|
||||
{
|
||||
"E" => (w - 1, h / 2, ""),
|
||||
"W" => (0, h / 2, ""),
|
||||
"N" => (w / 2, 0, ""),
|
||||
"S" => (w / 2, h - 1, ""),
|
||||
_ => (w / 2, h / 2, ""),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace Theriapolis.Core.Dungeons;
|
||||
|
||||
/// <summary>
|
||||
/// The slot a room occupies in a dungeon's role mix.
|
||||
///
|
||||
/// Each <see cref="Theriapolis.Core.Data.RoomTemplateDef"/> declares a list
|
||||
/// of role-eligibility tags via <c>roles_eligible</c>. The layout assembler
|
||||
/// pairs templates to roles when filling a dungeon's required + optional
|
||||
/// role slots; the resulting <see cref="Room"/> records its assigned role
|
||||
/// for content-distribution decisions (loot tier, encounter density, etc.).
|
||||
/// </summary>
|
||||
public enum RoomRole : byte
|
||||
{
|
||||
/// <summary>The dungeon's surface entrance. Always one per dungeon.</summary>
|
||||
Entry,
|
||||
/// <summary>Generic in-between room — most rooms in a typical dungeon are transit.</summary>
|
||||
Transit,
|
||||
/// <summary>Carries environmental-storytelling prose; usually no encounter, often unique decor.</summary>
|
||||
Narrative,
|
||||
/// <summary>Optional reward room with a container slot (sometimes locked).</summary>
|
||||
Loot,
|
||||
/// <summary>The dungeon's set-piece final room. Always one in dungeons that declare it.</summary>
|
||||
Boss,
|
||||
/// <summary>A side room off the critical path — exists for exploration reward.</summary>
|
||||
DeadEnd,
|
||||
}
|
||||
|
||||
internal static class RoomRoleExtensions
|
||||
{
|
||||
public static RoomRole Parse(string raw) => raw switch
|
||||
{
|
||||
"entry" => RoomRole.Entry,
|
||||
"transit" => RoomRole.Transit,
|
||||
"narrative" => RoomRole.Narrative,
|
||||
"loot" => RoomRole.Loot,
|
||||
"boss" => RoomRole.Boss,
|
||||
"dead-end" => RoomRole.DeadEnd,
|
||||
_ => throw new System.ArgumentException($"Unknown room role: '{raw}'"),
|
||||
};
|
||||
|
||||
public static string ToTag(this RoomRole r) => r switch
|
||||
{
|
||||
RoomRole.Entry => "entry",
|
||||
RoomRole.Transit => "transit",
|
||||
RoomRole.Narrative => "narrative",
|
||||
RoomRole.Loot => "loot",
|
||||
RoomRole.Boss => "boss",
|
||||
RoomRole.DeadEnd => "dead-end",
|
||||
_ => "transit",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Tactical;
|
||||
using Theriapolis.Core.World;
|
||||
|
||||
namespace Theriapolis.Core.Dungeons;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M1 — paints the rooms + corridors of a planned dungeon into a
|
||||
/// tactical-tile array. Pure, deterministic given the same plan input.
|
||||
///
|
||||
/// The painter:
|
||||
/// 1. Allocates a <see cref="TacticalTile"/>[w, h] array of the planned
|
||||
/// dungeon size.
|
||||
/// 2. For each room, copies its <see cref="RoomTemplateDef.Grid"/> char
|
||||
/// by char into the array at the room's AABB top-left, mapping each
|
||||
/// char to a <see cref="TacticalSurface"/> + <see cref="TacticalDeco"/>
|
||||
/// pair via <see cref="MapChar"/>.
|
||||
/// 3. For each connection, runs a Manhattan-path corridor between the
|
||||
/// two door tiles, writing <see cref="TacticalSurface.DungeonFloor"/>
|
||||
/// and ensuring the door tiles themselves are walkable
|
||||
/// (<see cref="TacticalDeco.DungeonDoor"/>).
|
||||
///
|
||||
/// The dungeon's surface family (DungeonFloor vs Cave vs MineFloor) is
|
||||
/// chosen by dungeon type so different dungeons feel visually distinct
|
||||
/// even before art lands.
|
||||
/// </summary>
|
||||
internal static class RoomTilePainter
|
||||
{
|
||||
public static TacticalTile[,] Paint(
|
||||
RoomGraphAssembler.Plan plan,
|
||||
IReadOnlyDictionary<string, RoomTemplateDef> templatesById,
|
||||
PoiType dungeonType)
|
||||
{
|
||||
var tiles = new TacticalTile[plan.DungeonW, plan.DungeonH];
|
||||
|
||||
// Default fill: solid wall (so any unpainted gap is automatically
|
||||
// impassable). The painter then carves rooms + corridors out of it.
|
||||
for (int y = 0; y < plan.DungeonH; y++)
|
||||
for (int x = 0; x < plan.DungeonW; x++)
|
||||
{
|
||||
tiles[x, y] = new TacticalTile
|
||||
{
|
||||
Surface = TacticalSurface.Wall,
|
||||
Deco = TacticalDeco.None,
|
||||
Variant = 0,
|
||||
Flags = 0,
|
||||
};
|
||||
}
|
||||
|
||||
TacticalSurface defaultFloor = SurfaceForDungeonType(dungeonType);
|
||||
|
||||
// Paint each room's grid.
|
||||
foreach (var room in plan.Rooms)
|
||||
{
|
||||
if (!templatesById.TryGetValue(room.TemplateId, out var def))
|
||||
continue; // shouldn't happen — ContentLoader validates these
|
||||
for (int gy = 0; gy < def.FootprintHTiles; gy++)
|
||||
{
|
||||
for (int gx = 0; gx < def.FootprintWTiles; gx++)
|
||||
{
|
||||
int dx = room.AabbX + gx;
|
||||
int dy = room.AabbY + gy;
|
||||
if (dx < 0 || dy < 0 || dx >= plan.DungeonW || dy >= plan.DungeonH)
|
||||
continue;
|
||||
char ch = def.Grid[gy][gx];
|
||||
var (surface, deco) = MapChar(ch, defaultFloor);
|
||||
tiles[dx, dy].Surface = surface;
|
||||
tiles[dx, dy].Deco = deco;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Carve corridors between door tiles.
|
||||
foreach (var conn in plan.Connections)
|
||||
{
|
||||
CarveCorridor(tiles, plan, conn, defaultFloor);
|
||||
}
|
||||
|
||||
// Mark the entrance tile so the renderer can highlight it. Stairs
|
||||
// is the canonical "interactable enter / exit" deco.
|
||||
if (plan.EntranceTile.X >= 0 && plan.EntranceTile.X < plan.DungeonW
|
||||
&& plan.EntranceTile.Y >= 0 && plan.EntranceTile.Y < plan.DungeonH)
|
||||
{
|
||||
tiles[plan.EntranceTile.X, plan.EntranceTile.Y].Surface = defaultFloor;
|
||||
tiles[plan.EntranceTile.X, plan.EntranceTile.Y].Deco = TacticalDeco.Stairs;
|
||||
}
|
||||
|
||||
return tiles;
|
||||
}
|
||||
|
||||
private static (TacticalSurface surface, TacticalDeco deco) MapChar(char ch, TacticalSurface defaultFloor) => ch switch
|
||||
{
|
||||
'#' => (TacticalSurface.Wall, TacticalDeco.None),
|
||||
'.' => (defaultFloor, TacticalDeco.None),
|
||||
',' => (TacticalSurface.DungeonRubble, TacticalDeco.None),
|
||||
'D' => (defaultFloor, TacticalDeco.DungeonDoor),
|
||||
'@' => (defaultFloor, TacticalDeco.None), // encounter slot — spawn placed by populator
|
||||
'C' => (defaultFloor, TacticalDeco.Container),
|
||||
'T' => (defaultFloor, TacticalDeco.Trap),
|
||||
'P' => (defaultFloor, TacticalDeco.Pillar),
|
||||
'B' => (defaultFloor, TacticalDeco.Brazier),
|
||||
'M' => (TacticalSurface.DungeonTile, TacticalDeco.None), // mosaic / narrative inlay
|
||||
'S' => (defaultFloor, TacticalDeco.Stairs),
|
||||
' ' => (TacticalSurface.None, TacticalDeco.None),
|
||||
_ => (defaultFloor, TacticalDeco.None), // unknown → walkable floor
|
||||
};
|
||||
|
||||
private static TacticalSurface SurfaceForDungeonType(PoiType type) => type switch
|
||||
{
|
||||
PoiType.AbandonedMine => TacticalSurface.MineFloor,
|
||||
PoiType.NaturalCave => TacticalSurface.Cave,
|
||||
PoiType.CultDen => TacticalSurface.Cave,
|
||||
PoiType.OvergrownSettlement => TacticalSurface.DungeonFloor,
|
||||
PoiType.ImperiumRuin => TacticalSurface.DungeonFloor,
|
||||
_ => TacticalSurface.DungeonFloor,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Manhattan corridor from door A to door B. Picks one of the two L-bends
|
||||
/// deterministically (vertical-first if the connection is "more vertical"
|
||||
/// than horizontal; horizontal-first otherwise) so two seeds don't
|
||||
/// produce different routes through the same plan.
|
||||
/// </summary>
|
||||
private static void CarveCorridor(
|
||||
TacticalTile[,] tiles,
|
||||
RoomGraphAssembler.Plan plan,
|
||||
RoomConnection conn,
|
||||
TacticalSurface defaultFloor)
|
||||
{
|
||||
int x0 = conn.DoorAx, y0 = conn.DoorAy;
|
||||
int x1 = conn.DoorBx, y1 = conn.DoorBy;
|
||||
|
||||
// Ensure the door tiles themselves are walkable.
|
||||
SafeSet(tiles, plan, x0, y0, defaultFloor, TacticalDeco.DungeonDoor);
|
||||
SafeSet(tiles, plan, x1, y1, defaultFloor, TacticalDeco.DungeonDoor);
|
||||
|
||||
// Decide bend axis.
|
||||
int dx = Math.Abs(x1 - x0);
|
||||
int dy = Math.Abs(y1 - y0);
|
||||
bool horizontalFirst = dx >= dy;
|
||||
|
||||
if (horizontalFirst)
|
||||
{
|
||||
int xa = Math.Min(x0, x1);
|
||||
int xb = Math.Max(x0, x1);
|
||||
for (int x = xa; x <= xb; x++)
|
||||
SafeSet(tiles, plan, x, y0, defaultFloor, TacticalDeco.None, preserveDoor: true);
|
||||
int ya = Math.Min(y0, y1);
|
||||
int yb = Math.Max(y0, y1);
|
||||
for (int y = ya; y <= yb; y++)
|
||||
SafeSet(tiles, plan, x1, y, defaultFloor, TacticalDeco.None, preserveDoor: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
int ya = Math.Min(y0, y1);
|
||||
int yb = Math.Max(y0, y1);
|
||||
for (int y = ya; y <= yb; y++)
|
||||
SafeSet(tiles, plan, x0, y, defaultFloor, TacticalDeco.None, preserveDoor: true);
|
||||
int xa = Math.Min(x0, x1);
|
||||
int xb = Math.Max(x0, x1);
|
||||
for (int x = xa; x <= xb; x++)
|
||||
SafeSet(tiles, plan, x, y1, defaultFloor, TacticalDeco.None, preserveDoor: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void SafeSet(
|
||||
TacticalTile[,] tiles, RoomGraphAssembler.Plan plan,
|
||||
int x, int y, TacticalSurface surface, TacticalDeco deco, bool preserveDoor = false)
|
||||
{
|
||||
if (x < 0 || y < 0 || x >= plan.DungeonW || y >= plan.DungeonH) return;
|
||||
// Don't bulldoze a room's interior decoration when the corridor
|
||||
// happens to clip through (carry-over from straightline paths
|
||||
// that cross a room edge). The painter already laid the room
|
||||
// first, so corridor only needs to convert Wall/None → Floor.
|
||||
var existing = tiles[x, y];
|
||||
if (existing.Surface != TacticalSurface.Wall
|
||||
&& existing.Surface != TacticalSurface.None
|
||||
&& !(preserveDoor && existing.Deco == TacticalDeco.DungeonDoor))
|
||||
return;
|
||||
tiles[x, y].Surface = surface;
|
||||
if (!preserveDoor || existing.Deco != TacticalDeco.DungeonDoor)
|
||||
tiles[x, y].Deco = deco;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user