b451f83174
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>
185 lines
7.9 KiB
C#
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;
|
|
}
|
|
}
|