feat: Add core map generation and management system

- Introduced MapData, MapGenerator, and MapPainter classes for procedural map generation.
- Implemented LevelManager to handle level initialization and navigation.
- Created TurnManager for managing turn order in a turn-based system.
- Added Player and Enemy character classes with movement and turn-taking capabilities.
- Established pathfinding using AStar2D in PathfindingHandler.
- Integrated new scenes for player and enemy characters, along with world setup.
- Included a new tileset for futuristic Zelda-themed assets.
This commit is contained in:
2026-03-16 16:24:32 +11:00
parent f5e33fb8ea
commit bb1c9f9284
39 changed files with 2341 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
## MapData.gd
# Resource class that stores all data for a generated map, including tile types, rooms, and spawn points.
class_name MapData
extends Resource
# Enum for all possible tile types in the map grid.
# Used to distinguish between empty space, walkable floor, walls, doors, and stairs.
enum TileType {
VOID, # Unused/empty space
FLOOR, # Walkable floor tile
WALL, # Solid wall tile
DOOR, # Door tile (can be opened/closed)
STAIRS # Stairs tile (for level transitions)
}
# The main grid: Dictionary mapping Vector2i coordinates to TileType values.
# This is the authoritative source for the map's layout.
@export var grid: Dictionary = {}
# The number of rooms generated for this map (set during generation).
@export var room_count: int = 0
# List of all room rectangles (Rect2i) in the map, used for spawning and logic.
@export var rooms: Array[Rect2i] = []
# The starting position for the player (in grid coordinates).
@export var spawn_point: Vector2i = Vector2i.ZERO
# Returns true if the tile at 'coords' is of the given TileType.
# Used for pathfinding and logic checks.
func is_tile_type(coords: Vector2i, type: TileType) -> bool:
return grid.get(coords, TileType.VOID) == type
# Clears all map data so this resource can be reused for a new map.
func clear() -> void:
grid.clear()
rooms.clear()
room_count = 0
spawn_point = Vector2i.ZERO
+163
View File
@@ -0,0 +1,163 @@
## MapGenerator.gd
# Responsible for procedural generation of dungeon maps, including rooms, corridors, and walls.
class_name MapGenerator
extends RefCounted
# Main entry point: generates a new MapData resource based on the provided settings.
# Handles room placement, corridor carving, wall painting, and spawn point selection.
func generate(settings: MapSettings) -> MapData:
var map_data: MapData = MapData.new()
# Randomly choose how many rooms to generate within the allowed range.
map_data.room_count = randi_range(settings.min_room_count, settings.max_room_count)
_generate_rooms(map_data, settings)
_generate_corridors(map_data, settings)
_generate_walls(map_data)
_pick_spawn_point(map_data)
return map_data
# Places non-overlapping rectangular rooms in the map grid.
# Each room is checked for overlap and only added if it fits.
func _generate_rooms(data: MapData, settings: MapSettings) -> void:
for _room_idx in range(data.room_count):
for _try in range(settings.max_tries):
var w: int = randi_range(settings.min_room_size, settings.max_room_size)
var h: int = randi_range(settings.min_room_size, settings.max_room_size)
var x: int = randi_range(1, settings.width - w - 1)
var y: int = randi_range(1, settings.height - h - 1)
var new_room: Rect2i = Rect2i(x, y, w, h)
var overlaps: bool = false
for existing_room in data.rooms:
if not (new_room.position.x + new_room.size.x + settings.room_padding <= existing_room.position.x or
new_room.position.x >= existing_room.position.x + existing_room.size.x + settings.room_padding or
new_room.position.y + new_room.size.y + settings.room_padding <= existing_room.position.y or
new_room.position.y >= existing_room.position.y + existing_room.size.y + settings.room_padding):
overlaps = true
break
if not overlaps:
data.rooms.append(new_room)
for rx in range(new_room.position.x, new_room.end.x):
for ry in range(new_room.position.y, new_room.end.y):
data.grid[Vector2i(rx, ry)] = MapData.TileType.FLOOR
break
# Connects all rooms with corridors using Manhattan paths (L-shaped).
# Also adds a few extra loops for map variety.
func _generate_corridors(data: MapData, _settings: MapSettings) -> void:
var room_centers: Array[Vector2i] = []
for room in data.rooms:
room_centers.append(room.get_center())
if room_centers.is_empty(): return
var connected: Array[Vector2i] = []
var unconnected: Array[Vector2i] = room_centers.duplicate()
var potential_loops: Array[Array] = []
connected.append(unconnected.pop_front())
while unconnected.size() > 0:
var best_distance: int = 999999
var best_pair: Array[Vector2i] = [Vector2i.ZERO, Vector2i.ZERO]
var best_un_idx: int = -1
for i in range(connected.size()):
for j in range(unconnected.size()):
var p1: Vector2i = connected[i]
var p2: Vector2i = unconnected[j]
var dist: int = abs(p1.x - p2.x) + abs(p1.y - p2.y)
if dist < best_distance:
best_distance = dist
best_pair = [p1, p2]
best_un_idx = j
else:
potential_loops.append([p1, p2])
_carve_manhattan_corridor(data, best_pair[0], best_pair[1])
connected.append(unconnected[best_un_idx])
unconnected.remove_at(best_un_idx)
# Add extra loops (~15% of room count) for more interesting layouts
potential_loops.shuffle()
var loop_count: int = int(data.rooms.size() * 0.15)
for i in range(min(loop_count, potential_loops.size())):
_carve_manhattan_corridor(data, potential_loops[i][0], potential_loops[i][1])
# Helper to carve a corridor between two points using Manhattan (L-shaped) path.
# Chooses the best valid path and writes it to the grid.
func _carve_manhattan_corridor(data: MapData, start: Vector2i, end: Vector2i) -> void:
var path_hv: Array[Vector2i] = _get_manhattan_points(start, end, false)
var path_vh: Array[Vector2i] = _get_manhattan_points(start, end, true)
# Determine which room indices we are connecting for validation
var ri_a: int = -1
var ri_b: int = -1
for i in range(data.rooms.size()):
if data.rooms[i].has_point(start): ri_a = i
if data.rooms[i].has_point(end): ri_b = i
var chosen: Array[Vector2i] = path_hv
if _is_corridor_valid(data, path_hv, ri_a, ri_b):
chosen = path_hv
elif _is_corridor_valid(data, path_vh, ri_a, ri_b):
chosen = path_vh
for pt in chosen:
data.grid[pt] = MapData.TileType.FLOOR
# Returns an array of points representing a Manhattan (L-shaped) path between two points.
# v_first determines whether to go vertical then horizontal, or vice versa.
func _get_manhattan_points(start: Vector2i, end: Vector2i, v_first: bool) -> Array[Vector2i]:
var pts: Array[Vector2i] = []
var x_step: int = sign(end.x - start.x) if end.x != start.x else 1
var y_step: int = sign(end.y - start.y) if end.y != start.y else 1
if v_first:
for y in range(start.y, end.y + y_step, y_step):
pts.append(Vector2i(start.x, y))
for x in range(start.x + x_step, end.x + x_step, x_step):
pts.append(Vector2i(x, end.y))
else:
for x in range(start.x, end.x + x_step, x_step):
pts.append(Vector2i(x, start.y))
for y in range(start.y + y_step, end.y + y_step, y_step):
pts.append(Vector2i(end.x, y))
return pts
# Returns true if the corridor path does not violate the 2-tile gap rule between rooms.
# Ensures corridors do not pass too close to other rooms.
func _is_corridor_valid(data: MapData, points: Array[Vector2i], ri_a: int, ri_b: int) -> bool:
for pt in points:
var inside_any_room: bool = false
for r in data.rooms:
if r.has_point(pt):
inside_any_room = true
break
if inside_any_room: continue
# Check gap from other rooms
for i in range(data.rooms.size()):
if i == ri_a or i == ri_b: continue
if data.rooms[i].grow(2).has_point(pt):
return false
return true
# Paints wall tiles around all floor tiles in the grid.
# Any empty neighbor of a floor tile becomes a wall.
func _generate_walls(data: MapData) -> void:
for tile: Vector2i in data.grid.keys():
if data.grid[tile] == MapData.TileType.FLOOR:
for dx in range(-1, 2):
for dy in range(-1, 2):
var neighbor: Vector2i = tile + Vector2i(dx, dy)
if not data.grid.has(neighbor):
data.grid[neighbor] = MapData.TileType.WALL
# Picks a spawn point for the player (center of the first room).
func _pick_spawn_point(data: MapData) -> void:
if not data.rooms.is_empty():
data.spawn_point = data.rooms[0].get_center()
+35
View File
@@ -0,0 +1,35 @@
## MapPainter.gd
# Responsible for painting the generated map data onto a TileMapLayer in the scene.
# Handles both floor and wall tiles, using atlas coordinates for floor variety and terrain peering for walls.
class_name MapPainter
extends RefCounted
# The source ID for the floor tile in the TileSet atlas.
const FLOOR_SOURCE_ID: int = 0
# Two different atlas coordinates for floor tiles, used to create a checkerboard effect for visual variety.
const FLOOR_ATLAS_COORDS_1: Vector2i = Vector2i(3, 2)
const FLOOR_ATLAS_COORDS_2: Vector2i = Vector2i(4, 2)
# Terrain set and terrain index for wall tiles, used for Godot's terrain peering system.
const WALL_TERRAIN_SET: int = 0
const WALL_TERRAIN: int = 0
# Paints all tiles from the given MapData onto the provided TileMapLayer.
# Floors are painted with alternating atlas tiles for variety; walls are collected and painted with terrain peering.
func paint(data: MapData, layer: TileMapLayer) -> void:
layer.clear() # Remove any existing tiles from the layer.
var wall_cells: Array[Vector2i] = [] # Collect wall tile positions for batch painting.
for coords: Vector2i in data.grid.keys():
match data.grid[coords]:
MapData.TileType.FLOOR:
# Alternate between two floor atlas tiles for a checkerboard effect.
var use_first: bool = ((coords.x + coords.y) % 2) == 0
var atlas_coords: Vector2i = FLOOR_ATLAS_COORDS_1 if use_first else FLOOR_ATLAS_COORDS_2
layer.set_cell(coords, FLOOR_SOURCE_ID, atlas_coords)
MapData.TileType.WALL:
# Collect wall positions for terrain peering.
wall_cells.append(coords)
# Use Godot's terrain peering to auto-connect wall tiles for seamless edges/corners.
layer.set_cells_terrain_connect(wall_cells, WALL_TERRAIN_SET, WALL_TERRAIN, false)
+24
View File
@@ -0,0 +1,24 @@
## MapSettings.gd
# Resource for storing all configurable map generation settings.
# Used by MapGenerator to control map size, room count, and room properties.
class_name MapSettings
extends Resource
# Level size in tiles (width × height).
@export var width: int = 50
@export var height: int = 50
# Minimum and maximum number of rooms to generate.
@export var min_room_count: int = 5
@export var max_room_count: int = 15
# Room size constraints (in tiles).
@export var min_room_size: int = 4
@export var max_room_size: int = 10
# Minimum number of tiles between rooms (prevents overlap).
@export var room_padding:int = 5
# Maximum number of attempts to place a room before giving up.
@export var max_tries: int = 20
# The actual number of rooms generated (set by MapGenerator).
var room_count: int
+48
View File
@@ -0,0 +1,48 @@
## PathfindingHandler.gd
# Handles A* pathfinding for the map using Godot's AStar2D.
# Builds a navigation graph from walkable tiles and provides path queries.
class_name PathfindingHandler
extends RefCounted
# All 8 possible neighbor offsets (cardinal and diagonal directions).
const NEIGHBORS: Array[Vector2i] = [
Vector2i.RIGHT, Vector2i.LEFT, Vector2i.DOWN, Vector2i.UP,
Vector2i(1, 1), Vector2i(1, -1), Vector2i(-1, 1), Vector2i(-1, -1),
]
# The underlying AStar2D instance for pathfinding.
var _astar: AStar2D = AStar2D.new()
# Maps grid coordinates (Vector2i) to AStar2D point IDs.
var _coord_to_id: Dictionary = {}
# Builds the AStar2D graph from all walkable tiles in MapData.
# Safe to call again for a new level — clears all previous state first.
func build(data: MapData) -> void:
_astar.clear()
_coord_to_id.clear()
# Register a point for every walkable tile (FLOOR).
for coords: Vector2i in data.grid.keys():
if data.grid[coords] == MapData.TileType.FLOOR:
var id: int = _astar.get_available_point_id()
_astar.add_point(id, Vector2(coords.x, coords.y))
_coord_to_id[coords] = id
# Connect each point to its walkable neighbors (cardinal and diagonal).
for coords: Vector2i in _coord_to_id.keys():
for offset: Vector2i in NEIGHBORS:
var neighbor: Vector2i = coords + offset
if _coord_to_id.has(neighbor):
_astar.connect_points(_coord_to_id[coords], _coord_to_id[neighbor], false)
# Returns the path as grid coordinates, or an empty array if no path exists.
# Used by LevelManager and Player for navigation.
func find_path(from: Vector2i, to: Vector2i) -> Array[Vector2i]:
if not _coord_to_id.has(from) or not _coord_to_id.has(to):
return []
var raw: PackedVector2Array = _astar.get_point_path(_coord_to_id[from], _coord_to_id[to])
var result: Array[Vector2i] = []
for point in raw:
result.append(Vector2i(point))
return result
+10
View File
@@ -0,0 +1,10 @@
## Room.gd
# Resource representing a single room in the map.
# Stores the room's position and size in grid coordinates.
class_name Room
extends Resource
# Top-left position of the room in grid coordinates.
var room_pos: Vector2i
# Size of the room (width, height) in tiles.
var room_size: Vector2i
+8
View File
@@ -0,0 +1,8 @@
## Tile.gd
# Resource representing a single tile in the map.
# Used for storing tile properties such as walkability.
class_name Tile
extends Resource
# Whether this tile can be walked on (used for pathfinding).
var is_walkable: bool = false