9.0 KiB
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
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
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
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
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:
@export var map_layout: MapLayout
Add at the end of the existing _ready() function:
if map_layout:
map_layout.initialize()
- Step 2: Add passability and validity methods that delegate to MapLayout
Add these methods to combat_map.gd:
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:
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:
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:
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):
if dl_map.is_wall(grid_coords):
_goal_pos = _selected_unit.position
return
Replace with:
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):
if dl_map.is_wall(grid_coords):
return
Replace with:
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:
# -- 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:
Roomresource hasidandtilesMapLayoutresource hasrooms,openings,initialize(),is_passable(),is_tile_valid(),get_room_at(),get_walls()CombatMaphasmap_layoutexport,is_tile_passable(),is_tile_valid(),draw_room_walls(),load_from_layout()PlayerControllerusesis_tile_passable()for movement andis_tile_valid()for click validation- Legacy
is_wall()still works as fallback when nomap_layoutis set