321 lines
9.0 KiB
Markdown
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
|