Files
TheriapolisV3/Theriapolis.Core/Dungeons/RoomTilePainter.cs
T
Christopher Wiebe b451f83174 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>
2026-04-30 20:40:51 -07:00

185 lines
7.9 KiB
C#

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;
}
}