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,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, ""),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user