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

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:

  • 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