Files
MaidEngine/docs/superpowers/plans/2026-04-05-room-system.md
2026-04-05 22:58:30 -04:00

321 lines
9.0 KiB
Markdown

# Room System Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a room/wall system where rooms are tile groups, walls are derived from room boundaries, openings are explicit doorways, and movement is blocked by walls.
**Architecture:** Two new Resources (`Room`, `MapLayout`) define the room data. `MapLayout` computes walls from room boundaries and exposes passability queries. `CombatMap` renders walls on tile edges and uses `MapLayout` for movement checks. `PlayerController` is updated to check edge passability instead of tile-level wall checks.
**Tech Stack:** Godot 4.6 / GDScript
---
### Task 1: Create Room Resource
**Files:**
- Create: `resources/resource_definitions/room.gd`
- [ ] **Step 1: Create the Room resource**
```gdscript
class_name Room extends Resource
@export var id: int
@export var tiles: Array[Vector2i]
```
- [ ] **Step 2: Verify it loads in the editor**
Open Godot, create a new Resource, confirm `Room` appears as a type and `id`/`tiles` fields are visible in the inspector.
---
### Task 2: Create MapLayout Resource
**Files:**
- Create: `resources/resource_definitions/map_layout.gd`
- [ ] **Step 1: Create the MapLayout resource with room data and opening storage**
```gdscript
class_name MapLayout extends Resource
@export var rooms: Array[Room]
@export var openings: Array[Vector2i]
## Openings are stored as a flat array of pairs: [from1, to1, from2, to2, ...].
## Each consecutive pair of Vector2i values represents a bidirectional doorway
## between two adjacent tiles in different rooms.
```
- [ ] **Step 2: Add the tile-to-room lookup cache and initialization**
```gdscript
var _tile_room_map: Dictionary = {}
var _opening_set: Dictionary = {}
func initialize() -> void:
_tile_room_map.clear()
_opening_set.clear()
for room in rooms:
for tile in room.tiles:
_tile_room_map[tile] = room
for i in range(0, openings.size(), 2):
var a := openings[i]
var b := openings[i + 1]
_opening_set[_edge_key(a, b)] = true
static func _edge_key(a: Vector2i, b: Vector2i) -> String:
if a < b:
return "%d,%d-%d,%d" % [a.x, a.y, b.x, b.y]
return "%d,%d-%d,%d" % [b.x, b.y, a.x, a.y]
```
- [ ] **Step 3: Add the public API methods**
```gdscript
func is_tile_valid(tile: Vector2i) -> bool:
return _tile_room_map.has(tile)
func get_room_at(tile: Vector2i) -> Room:
return _tile_room_map.get(tile, null)
func is_passable(from: Vector2i, to: Vector2i) -> bool:
if not is_tile_valid(from) or not is_tile_valid(to):
return false
var room_from: Room = _tile_room_map[from]
var room_to: Room = _tile_room_map[to]
if room_from == room_to:
return true
return _opening_set.has(_edge_key(from, to))
func get_walls() -> Array:
## Returns an array of [Vector2i, Vector2i] pairs representing wall edges.
## A wall exists where a room tile borders void or a different room (without an opening).
var walls: Array = []
var directions := [Vector2i.RIGHT, Vector2i.DOWN, Vector2i.LEFT, Vector2i.UP]
var visited_edges: Dictionary = {}
for room in rooms:
for tile in room.tiles:
for dir in directions:
var neighbor := tile + dir
var key := _edge_key(tile, neighbor)
if visited_edges.has(key):
continue
visited_edges[key] = true
var neighbor_room: Room = _tile_room_map.get(neighbor, null)
if neighbor_room == room:
continue
# Neighbor is void or different room — wall unless opening
if not _opening_set.has(key):
walls.append([tile, neighbor])
return walls
```
- [ ] **Step 4: Verify it loads in the editor**
Open Godot, create a new Resource, confirm `MapLayout` appears as a type and `rooms`/`openings` fields are visible.
---
### Task 3: Integrate MapLayout into CombatMap
**Files:**
- Modify: `nodes/combat_map.gd`
- [ ] **Step 1: Add MapLayout export and initialization**
Add after the existing `@export var tile_set: DLTileset` line in `combat_map.gd`:
```gdscript
@export var map_layout: MapLayout
```
Add at the end of the existing `_ready()` function:
```gdscript
if map_layout:
map_layout.initialize()
```
- [ ] **Step 2: Add passability and validity methods that delegate to MapLayout**
Add these methods to `combat_map.gd`:
```gdscript
func is_tile_passable(from: Vector2i, to: Vector2i) -> bool:
if map_layout:
return map_layout.is_passable(from, to)
# Fallback: no room system, use legacy wall check
return not is_wall(to)
func is_tile_valid(coords: Vector2i) -> bool:
if map_layout:
return map_layout.is_tile_valid(coords)
# Fallback: no room system, any non-wall tile is valid
return not is_wall(coords)
```
- [ ] **Step 3: Add wall rendering from MapLayout**
Add this method to `combat_map.gd`:
```gdscript
func draw_room_walls() -> void:
if not map_layout:
return
var walls := map_layout.get_walls()
for wall in walls:
var from_world := coords_to_world(wall[0]) + Vector2(TILE_SIZE / 2, TILE_SIZE / 2)
var to_world := coords_to_world(wall[1]) + Vector2(TILE_SIZE / 2, TILE_SIZE / 2)
var midpoint := (from_world + to_world) / 2
var diff := to_world - from_world
var wall_dir := Vector2(-diff.y, diff.x).normalized()
var half_len := TILE_SIZE / 2
var line := Line2D.new()
line.add_point(midpoint - wall_dir * half_len)
line.add_point(midpoint + wall_dir * half_len)
line.width = 4.0
line.default_color = Color(0.6, 0.5, 0.4)
add_child(line)
```
This draws a line segment on the border between each pair of tiles that has a wall. The line is perpendicular to the direction between the two tiles and spans the full tile edge.
- [ ] **Step 4: Add floor rendering from MapLayout and call everything from _ready()**
Add this method to `combat_map.gd`:
```gdscript
func load_from_layout() -> void:
if not map_layout:
return
for room in map_layout.rooms:
for tile in room.tiles:
draw_floor(tile)
```
Update the `_ready()` addition so it renders floors then walls:
```gdscript
if map_layout:
map_layout.initialize()
load_from_layout()
draw_room_walls()
```
---
### Task 4: Update PlayerController to Use Edge-Based Passability
**Files:**
- Modify: `nodes/player_controller.gd`
- [ ] **Step 1: Update movement check in `_physics_process`**
In `player_controller.gd`, find the movement blocking check in `_physics_process` (around line 121-123):
```gdscript
if dl_map.is_wall(grid_coords):
_goal_pos = _selected_unit.position
return
```
Replace with:
```gdscript
var current_coords := dl_map.world_to_coords(_selected_unit.position)
if not dl_map.is_tile_passable(current_coords, grid_coords):
_goal_pos = _selected_unit.position
return
```
- [ ] **Step 2: Update click destination check in `_handle_left_click`**
In `player_controller.gd`, find the wall check in `_handle_left_click` (around line 142-143):
```gdscript
if dl_map.is_wall(grid_coords):
return
```
Replace with:
```gdscript
if not dl_map.is_tile_valid(grid_coords):
return
```
This prevents clicking on void tiles as a destination. The step-by-step movement in `_physics_process` handles wall/opening checks as the unit walks.
---
### Task 5: Create a Test Map Layout
**Files:**
- Modify: `nodes/strategy_phase.gd`
- [ ] **Step 1: Add test layout setup in strategy_phase.gd**
Add at the top of `_ready()` in `strategy_phase.gd`, before existing code:
```gdscript
# -- Test room layout (remove once map editor exists) --
var room_a := Room.new()
room_a.id = 0
room_a.tiles = [
Vector2i(0, 0), Vector2i(1, 0), Vector2i(2, 0),
Vector2i(0, 1), Vector2i(1, 1), Vector2i(2, 1),
Vector2i(0, 2), Vector2i(1, 2), Vector2i(2, 2),
]
var room_b := Room.new()
room_b.id = 1
room_b.tiles = [
Vector2i(3, 0), Vector2i(4, 0), Vector2i(5, 0),
Vector2i(3, 1), Vector2i(4, 1), Vector2i(5, 1),
Vector2i(3, 2), Vector2i(4, 2), Vector2i(5, 2),
]
var layout := MapLayout.new()
layout.rooms = [room_a, room_b]
# Opening between (2,1) in room_a and (3,1) in room_b
layout.openings = [Vector2i(2, 1), Vector2i(3, 1)]
combat_map.map_layout = layout
# -- End test room layout --
```
- [ ] **Step 2: Run the game and verify**
Run the strategy phase scene. Expected:
- Two 3x3 rooms appear side by side
- Walls are drawn on all outer edges and on the room-to-room border
- The middle row (y=1) has no wall between tiles (2,1) and (3,1) — that's the opening
- A unit can walk through the opening but not through the walls
- Clicking on void tiles (outside rooms) does nothing
---
### Task 6: Review All Changes
**Files:**
- All modified files
- [ ] **Step 1: Verify completeness**
Confirm:
- `Room` resource has `id` and `tiles`
- `MapLayout` resource has `rooms`, `openings`, `initialize()`, `is_passable()`, `is_tile_valid()`, `get_room_at()`, `get_walls()`
- `CombatMap` has `map_layout` export, `is_tile_passable()`, `is_tile_valid()`, `draw_room_walls()`, `load_from_layout()`
- `PlayerController` uses `is_tile_passable()` for movement and `is_tile_valid()` for click validation
- Legacy `is_wall()` still works as fallback when no `map_layout` is set