Room system added

This commit is contained in:
gamer147
2026-04-05 22:58:30 -04:00
parent b485e11a5a
commit 3a8e3edc03
15 changed files with 615 additions and 13 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b20mhn7ca5xyo"
path="res://.godot/imported/aux_terrain.BMP-15c2f0fd910deee8ff95cb1125e18906.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/aux_terrain.BMP"
dest_files=["res://.godot/imported/aux_terrain.BMP-15c2f0fd910deee8ff95cb1125e18906.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View File

@@ -0,0 +1,320 @@
# 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

View File

@@ -0,0 +1,56 @@
# Room System Design
## Overview
A room system for battle maps where rooms are defined as groups of tiles, walls exist on tile borders (not as tiles), and openings are explicitly marked doorways between rooms. Tiles outside rooms are impassable void.
## Data Model
### Room (Resource)
- `id: int` — unique room identifier
- `tiles: Array[Vector2i]` — tile coordinates belonging to this room
### MapLayout (Resource)
- `rooms: Array[Room]` — all rooms on the map
- `openings: Array` — list of tile-coordinate pairs (`[Vector2i, Vector2i]`) representing bidirectional doorways between adjacent tiles in different rooms
### Derived Wall Computation
A wall exists on a tile edge when:
1. One side is a room tile and the other is void (not in any room), OR
2. The two sides belong to different rooms AND the edge is not in the openings list
### MapLayout API
- `get_walls() -> Array` — computes all wall edge segments from room and opening data
- `is_passable(from: Vector2i, to: Vector2i) -> bool` — returns whether movement is allowed between two adjacent tiles (false if wall, false if either tile is void)
- `is_tile_valid(tile: Vector2i) -> bool` — returns whether a tile belongs to any room
- `get_room_at(tile: Vector2i) -> Room` — returns the room a tile belongs to, or null
## Integration with Movement
- `CombatMap` holds a reference to the `MapLayout`
- Before allowing a unit to move from tile A to tile B, the movement system calls `MapLayout.is_passable(a, b)`
- If there is a wall on that edge (and no opening), movement is blocked
- Tiles not belonging to any room are impassable — `is_tile_valid()` returns false for void tiles
- Future pathfinding uses the same `is_passable` check as its neighbor filter
## Wall Rendering
- `CombatMap` iterates over computed wall edges from `MapLayout.get_walls()`
- Each wall edge is defined by two adjacent tile coordinates — the wall is drawn on the border between them
- Openings have no wall drawn
- Initial implementation uses simple `Line2D` segments or sprite strips on tile edges
- Can be replaced with proper art assets later
## Design Decisions
- **Rooms are the primary data, walls are derived** — single source of truth, no sync issues
- **Openings are explicit** — all room boundaries are walls by default; doorways are punched explicitly as tile-coordinate pairs
- **Openings are always bidirectional**
- **No room metadata beyond ID** — tile ownership stays tile-level per existing system
- **Void tiles are impassable** — no hallway/corridor concept; all walkable tiles belong to a room
- **Room data is independent of rendering** — clean for future map editor serialization

View File

@@ -1,7 +1,6 @@
* Debug menu on F1, right now just include quick scene swapping
* Finish buttons on dialogue scene
* Reogranize files
* Singletons named 'XXXServer'
* Dialogue scene command system (ShowText, ShowSprite, MoveSprite, PlaySound, ChangeBackground, etc)
* Finish Dialogue Scene (fast forward, auto, history functionality, etc)
* Setup room system (everything is unpassable, carve out rooms, walls automatic, specify connections between rooms on tiles)
* Basic map editor (test map data will be harder to craft the more we add)

View File

@@ -2,6 +2,7 @@ class_name CombatMap
extends Node2D
@export var tile_set: DLTileset
@export var map_layout: MapLayout
@onready var tile_map: TileMapLayer = %TerrainLayer
@onready var highlight_map: GridOverlay = %OverlayLayer
@@ -18,6 +19,8 @@ func _ready() -> void:
for entry in _pending_units:
_apply_deploy(entry.unit, entry.coords)
_pending_units.clear()
if map_layout:
apply_layout(map_layout)
func snap_to_grid(pos: Vector2) -> Vector2:
@@ -79,3 +82,52 @@ func remove_unit(unit: Unit) -> void:
func target_tile(coords: Vector2i) -> void:
highlight_map.target_tile(coords)
func apply_layout(layout: MapLayout) -> void:
map_layout = layout
map_layout.initialize()
load_from_layout()
draw_room_walls()
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)
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)
func load_from_layout() -> void:
if not map_layout:
return
for room in map_layout.rooms:
for tile in room.tiles:
draw_floor(tile)

View File

@@ -118,7 +118,8 @@ func _physics_process(delta: float) -> void:
var next_pos := _selected_unit.position + dir * dl_map.TILE_SIZE
var grid_coords := dl_map.world_to_coords(next_pos)
if dl_map.is_wall(grid_coords):
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
@@ -139,7 +140,7 @@ func _handle_left_click(screen_pos: Vector2) -> void:
elif _selected_unit:
var snapped_pos := dl_map.snap_to_grid(world_pos)
var grid_coords := dl_map.world_to_coords(world_pos)
if dl_map.is_wall(grid_coords):
if not dl_map.is_tile_valid(grid_coords):
return
_goal_pos = snapped_pos
get_viewport().set_input_as_handled()

View File

@@ -7,6 +7,31 @@ class_name StrategyPhase extends Node2D
@onready var camera: CameraController = $Camera2D
func _ready() -> void:
# -- 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.apply_layout(layout)
# -- End test room layout --
player_controller.combat_requested.connect(_on_combat_requested)
player_controller.mouse_grid_changed.connect(_on_mouse_grid_changed)
player_controller.camera_drag.connect(camera.apply_drag)

View File

@@ -0,0 +1,72 @@
class_name MapLayout extends Resource
@export var rooms: Array[Room]
## 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.
@export var openings: Array[Vector2i]
var _tile_room_map: Dictionary = {}
var _opening_set: Dictionary = {}
func initialize() -> void:
assert(openings.size() % 2 == 0, "Openings must be provided as pairs of Vector2i")
_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]
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: Vector2i = 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

View File

@@ -0,0 +1 @@
uid://dj7qfdelq4ja4

View File

@@ -0,0 +1,4 @@
class_name Room extends Resource
@export var id: int
@export var tiles: Array[Vector2i]

View File

@@ -0,0 +1 @@
uid://ja34p4vpwamd

BIN
room_wall_example.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://2d7pwqqr03ra"
path="res://.godot/imported/room_wall_example.webp-46656a4dae88a645a1bd5646f3349f4d.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://room_wall_example.webp"
dest_files=["res://.godot/imported/room_wall_example.webp-46656a4dae88a645a1bd5646f3349f4d.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View File

@@ -80,20 +80,11 @@ const UNIT_SCENE = preload(\"res://prefabs/unit.tscn\")
const PLAYER_ALLEGIANCE = preload(\"res://resources/allegiance_types/player_allegiance.tres\")
const ENEMY_ALLEGIANCE = preload(\"res://resources/allegiance_types/enemy_allegiance.tres\")
const MAP_LAYOUT := \"\"\"\\
#####
#...#
#...#
#...#
#####\"\"\"
func _pressed() -> void:
await get_tree().create_timer(0.2).timeout
var combat_instance := COMBAT_SCENE.instantiate()
var combat_map: CombatMap = combat_instance.find_child(\"CombatMap\")
combat_map.load_map(MAP_LAYOUT)
var player_unit: Unit = UNIT_SCENE.instantiate()
player_unit.stat_template = UnitStats.new(50)
player_unit.info_template = UnitInfo.new()