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
+4
View File
@@ -0,0 +1,4 @@
root = true
[*]
charset = utf-8
+5
View File
@@ -0,0 +1,5 @@
# Godot 4+ specific ignores
.godot/
/android/
*.uid
*.import
+3
View File
@@ -0,0 +1,3 @@
{
"godotTools.editorPath.godot4": "/home/stefwill/Applications/Godot/Godot_v4.6.1-stable_linux.x86_64"
}
+53
View File
@@ -0,0 +1,53 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
**Shadow Pixel Run** — a 2D pixel art game built with Godot 4.6 (Forward Plus renderer).
## Running the Game
```bash
# Launch the game
/home/stefwill/Applications/Godot/Godot_v4.6-stable_linux.x86_64 --path .
# Run headless (e.g. for script testing)
/home/stefwill/Applications/Godot/Godot_v4.6-stable_linux.x86_64 --path . --headless
```
There is no build step — Godot runs the project directly from source.
## Architecture
### Scene Structure
- **Main scene:** `scenes/world.tscn` — a `Node2D` containing two `TileMapLayer` nodes (`FloorLayer`, `WallLayer`) and a `Player` node.
- **Player:** `CharacterBody2D` at `scenes/world.tscn > Player`, driven by `scripts/actors/player.gd`.
### Scripts
All scripts live under `scripts/` organized by category:
- `scripts/actors/` — character scripts (`player.gd` defines `class_name Player extends CharacterBody2D`)
### Assets
All assets live under `assets/sprites/`. Godot auto-generates `.import` files alongside source assets — these are gitignored and should not be manually edited.
### Tilemaps
The world uses two `TileMapLayer` nodes with 32×32 tile atlases:
- `FloorLayer``office_floor.png`, simple single-tile floor atlas
- `WallLayer``office_walls.png`, terrain tileset with corner/edge peering bits for seamless auto-connected walls
### Input Map
Defined in `project.godot`:
- `move_left` → A / Left Arrow
- `move_right` → D / Right Arrow
- `move_up` → W / Up Arrow
- `move_down` → S / Down Arrow
### Viewport
640×360 with 4× window scale — targets a retro pixel art look. Default texture filter is **Linear** (set per-sprite if pixelated filtering is needed).
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2016-2023 The Godot Engine community
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,10 @@
[configuration]
entry_symbol = "git_plugin_init"
compatibility_minimum = "4.2.0"
[libraries]
linux.editor.x86_64 = "linux/libgit_plugin.linux.editor.x86_64.so"
macos.editor = "macos/libgit_plugin.macos.editor.universal.dylib"
windows.editor.x86_64 = "windows/libgit_plugin.windows.editor.x86_64.dll"
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 737 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="124" height="124" x="2" y="2" fill="#363d52" stroke="#212532" stroke-width="4" rx="14"/><g fill="#fff" transform="translate(12.322 12.322)scale(.101)"><path d="M105 673v33q407 354 814 0v-33z"/><path fill="#478cbf" d="m105 673 152 14q12 1 15 14l4 67 132 10 8-61q2-11 15-15h162q13 4 15 15l8 61 132-10 4-67q3-13 15-14l152-14V427q30-39 56-81-35-59-83-108-43 20-82 47-40-37-88-64 7-51 8-102-59-28-123-42-26 43-46 89-49-7-98 0-20-46-46-89-64 14-123 42 1 51 8 102-48 27-88 64-39-27-82-47-48 49-83 108 26 42 56 81zm0 33v39c0 276 813 276 814 0v-39l-134 12-5 69q-2 10-14 13l-162 11q-12 0-16-11l-10-65H446l-10 65q-4 11-16 11l-162-11q-12-3-14-13l-5-69z"/><path d="M483 600c0 34 58 34 58 0v-86c0-34-58-34-58 0z"/><circle cx="725" cy="526" r="90"/><circle cx="299" cy="526" r="90"/></g><g fill="#414042" transform="translate(12.322 12.322)scale(.101)"><circle cx="307" cy="532" r="60"/><circle cx="717" cy="532" r="60"/></g></svg>

After

Width:  |  Height:  |  Size: 995 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

+80
View File
@@ -0,0 +1,80 @@
[gd_resource type="TileSet" format=3 uid="uid://142kl47o7n2m"]
[ext_resource type="Texture2D" uid="uid://c55vbj78e0yrl" path="res://assets/sprites/tileset_office_walls.png" id="1"]
[sub_resource type="TileSetAtlasSource" id="TileSetAtlasSource_t1ruc"]
texture = ExtResource("1")
texture_region_size = Vector2i(32, 32)
0:0/0 = 0
0:0/0/terrain_set = 0
0:0/0/terrain = 0
0:0/0/terrains_peering_bit/bottom_left_corner = 0
0:0/0/terrains_peering_bit/top_left_corner = 0
0:0/0/terrains_peering_bit/top_right_corner = 0
1:0/0 = 0
1:0/0/terrain_set = 0
1:0/0/terrain = 0
1:0/0/terrains_peering_bit/top_left_corner = 0
1:0/0/terrains_peering_bit/top_right_corner = 0
1:1/0 = 0
1:1/0/terrain_set = 0
2:1/0 = 0
2:1/0/terrain_set = 0
2:1/0/terrain = 0
2:1/0/terrains_peering_bit/bottom_right_corner = 0
2:1/0/terrains_peering_bit/top_right_corner = 0
2:2/0 = 0
2:2/0/terrain_set = 0
2:2/0/terrain = 0
2:2/0/terrains_peering_bit/bottom_right_corner = 0
2:2/0/terrains_peering_bit/bottom_left_corner = 0
2:2/0/terrains_peering_bit/top_right_corner = 0
1:2/0 = 0
1:2/0/terrain_set = 0
1:2/0/terrain = 0
1:2/0/terrains_peering_bit/bottom_right_corner = 0
1:2/0/terrains_peering_bit/bottom_left_corner = 0
0:1/0 = 0
0:1/0/terrain_set = 0
0:1/0/terrain = 0
0:1/0/terrains_peering_bit/bottom_left_corner = 0
0:1/0/terrains_peering_bit/top_left_corner = 0
0:2/0 = 0
0:2/0/terrain_set = 0
0:2/0/terrain = 0
0:2/0/terrains_peering_bit/bottom_right_corner = 0
0:2/0/terrains_peering_bit/bottom_left_corner = 0
0:2/0/terrains_peering_bit/top_left_corner = 0
2:0/0 = 0
2:0/0/terrain_set = 0
2:0/0/terrain = 0
2:0/0/terrains_peering_bit/bottom_right_corner = 0
2:0/0/terrains_peering_bit/top_left_corner = 0
2:0/0/terrains_peering_bit/top_right_corner = 0
3:0/0 = 0
3:0/0/terrain_set = 0
3:0/0/terrains_peering_bit/bottom_right_corner = 0
4:0/0 = 0
4:0/0/terrain_set = 0
4:0/0/terrains_peering_bit/bottom_left_corner = 0
4:1/0 = 0
4:1/0/terrain_set = 0
4:1/0/terrains_peering_bit/top_left_corner = 0
4:2/0 = 0
4:2/0/terrain_set = 0
4:2/0/terrains_peering_bit/bottom_right_corner = 0
4:2/0/terrains_peering_bit/top_left_corner = 0
3:2/0 = 0
3:2/0/terrain_set = 0
3:2/0/terrains_peering_bit/bottom_left_corner = 0
3:2/0/terrains_peering_bit/top_right_corner = 0
3:1/0 = 0
3:1/0/terrain_set = 0
3:1/0/terrains_peering_bit/top_right_corner = 0
[resource]
tile_size = Vector2i(32, 32)
terrain_set_0/mode = 1
terrain_set_0/terrain_0/name = "office_walls"
terrain_set_0/terrain_0/color = Color(0.5, 0.34375, 0.25, 1)
sources/0 = SubResource("TileSetAtlasSource_t1ruc")
+151
View File
@@ -0,0 +1,151 @@
[gd_resource type="TileSet" format=3 uid="uid://cmycomep4ap8y"]
[ext_resource type="Texture2D" uid="uid://c55vbj78e0yrl" path="res://assets/sprites/tileset_office_walls.png" id="1_cyef1"]
[sub_resource type="TileSetAtlasSource" id="TileSetAtlasSource_t1ruc"]
texture = ExtResource("1_cyef1")
texture_region_size = Vector2i(32, 32)
0:0/0 = 0
0:0/0/terrain_set = 0
0:0/0/terrain = 0
0:0/0/physics_layer_0/polygon_0/points = PackedVector2Array(-16, -16, 16, -16, 16, -7.3285065, -7.965769, -7.0098763, -7.6471386, 16, -16, 16)
0:0/0/terrains_peering_bit/right_side = 0
0:0/0/terrains_peering_bit/bottom_right_corner = 0
0:0/0/terrains_peering_bit/bottom_side = 0
1:0/0 = 0
1:0/0/terrain_set = 0
1:0/0/terrain = 0
1:0/0/physics_layer_0/polygon_0/points = PackedVector2Array(16, -16, 16, -7.3285065, -16, -7.6471386, -16, -16)
1:0/0/terrains_peering_bit/right_side = 0
1:0/0/terrains_peering_bit/bottom_right_corner = 0
1:0/0/terrains_peering_bit/bottom_side = 0
1:0/0/terrains_peering_bit/bottom_left_corner = 0
1:0/0/terrains_peering_bit/left_side = 0
2:1/0 = 0
2:1/0/terrain_set = 0
2:1/0/terrain = 0
2:1/0/physics_layer_0/polygon_0/points = PackedVector2Array(16, 16, 7.3285065, 16, 7.6471386, -16, 16, -16)
2:1/0/terrains_peering_bit/bottom_side = 0
2:1/0/terrains_peering_bit/bottom_left_corner = 0
2:1/0/terrains_peering_bit/left_side = 0
2:1/0/terrains_peering_bit/top_left_corner = 0
2:1/0/terrains_peering_bit/top_side = 0
2:2/0 = 0
2:2/0/terrain_set = 0
2:2/0/terrain = 0
2:2/0/physics_layer_0/polygon_0/points = PackedVector2Array(16, 16, -16, 16, -16, 7.3285065, 7.965769, 7.0098763, 7.6471386, -16, 16, -16)
2:2/0/terrains_peering_bit/left_side = 0
2:2/0/terrains_peering_bit/top_left_corner = 0
2:2/0/terrains_peering_bit/top_side = 0
1:2/0 = 0
1:2/0/terrain_set = 0
1:2/0/terrain = 0
1:2/0/physics_layer_0/polygon_0/points = PackedVector2Array(-16, 16, -16, 7.3285065, 16, 7.6471386, 16, 16)
1:2/0/terrains_peering_bit/right_side = 0
1:2/0/terrains_peering_bit/left_side = 0
1:2/0/terrains_peering_bit/top_left_corner = 0
1:2/0/terrains_peering_bit/top_side = 0
1:2/0/terrains_peering_bit/top_right_corner = 0
0:1/0 = 0
0:1/0/terrain_set = 0
0:1/0/terrain = 0
0:1/0/physics_layer_0/polygon_0/points = PackedVector2Array(-16, -16, -7.3285065, -16, -7.6471386, 16, -16, 16)
0:1/0/terrains_peering_bit/right_side = 0
0:1/0/terrains_peering_bit/bottom_right_corner = 0
0:1/0/terrains_peering_bit/bottom_side = 0
0:1/0/terrains_peering_bit/top_side = 0
0:1/0/terrains_peering_bit/top_right_corner = 0
0:2/0 = 0
0:2/0/terrain_set = 0
0:2/0/terrain = 0
0:2/0/physics_layer_0/polygon_0/points = PackedVector2Array(-16, 16, -16, -16, -7.3285065, -16, -7.0098763, 7.965769, 16, 7.6471386, 16, 16)
0:2/0/terrains_peering_bit/right_side = 0
0:2/0/terrains_peering_bit/top_side = 0
0:2/0/terrains_peering_bit/top_right_corner = 0
2:0/0 = 0
2:0/0/terrain_set = 0
2:0/0/terrain = 0
2:0/0/physics_layer_0/polygon_0/points = PackedVector2Array(16, -16, 16, 16, 7.3285065, 16, 7.0098763, -7.965769, -16, -7.6471386, -16, -16)
2:0/0/terrains_peering_bit/bottom_side = 0
2:0/0/terrains_peering_bit/bottom_left_corner = 0
2:0/0/terrains_peering_bit/left_side = 0
3:0/0 = 0
3:0/0/terrain_set = 0
3:0/0/terrain = 0
3:0/0/physics_layer_0/polygon_0/points = PackedVector2Array(16, 16, 6.654772, 16, 6.4749146, 7.014491, 16, 6.834633)
3:0/0/terrains_peering_bit/right_side = 0
3:0/0/terrains_peering_bit/bottom_side = 0
3:0/0/terrains_peering_bit/bottom_left_corner = 0
3:0/0/terrains_peering_bit/left_side = 0
3:0/0/terrains_peering_bit/top_left_corner = 0
3:0/0/terrains_peering_bit/top_side = 0
3:0/0/terrains_peering_bit/top_right_corner = 0
4:0/0 = 0
4:0/0/terrain_set = 0
4:0/0/terrain = 0
4:0/0/physics_layer_0/polygon_0/points = PackedVector2Array(-16, 16, -16, 6.654772, -7.014491, 6.4749146, -6.834633, 16)
4:0/0/terrains_peering_bit/right_side = 0
4:0/0/terrains_peering_bit/bottom_right_corner = 0
4:0/0/terrains_peering_bit/bottom_side = 0
4:0/0/terrains_peering_bit/left_side = 0
4:0/0/terrains_peering_bit/top_left_corner = 0
4:0/0/terrains_peering_bit/top_side = 0
4:0/0/terrains_peering_bit/top_right_corner = 0
4:1/0 = 0
4:1/0/terrain_set = 0
4:1/0/terrain = 0
4:1/0/physics_layer_0/polygon_0/points = PackedVector2Array(-16, -16, -6.654772, -16, -6.4749146, -7.014491, -16, -6.834633)
4:1/0/terrains_peering_bit/right_side = 0
4:1/0/terrains_peering_bit/bottom_right_corner = 0
4:1/0/terrains_peering_bit/bottom_side = 0
4:1/0/terrains_peering_bit/bottom_left_corner = 0
4:1/0/terrains_peering_bit/left_side = 0
4:1/0/terrains_peering_bit/top_side = 0
4:1/0/terrains_peering_bit/top_right_corner = 0
4:2/0 = 0
4:2/0/terrain_set = 0
4:2/0/terrain = 0
4:2/0/terrains_peering_bit/right_side = 0
4:2/0/terrains_peering_bit/bottom_right_corner = 0
4:2/0/terrains_peering_bit/bottom_side = 0
4:2/0/terrains_peering_bit/bottom_left_corner = 0
4:2/0/terrains_peering_bit/left_side = 0
4:2/0/terrains_peering_bit/top_left_corner = 0
4:2/0/terrains_peering_bit/top_side = 0
4:2/0/terrains_peering_bit/top_right_corner = 0
3:2/0 = 0
3:2/0/terrain_set = 0
3:2/0/terrain = 0
3:2/0/terrains_peering_bit/right_side = 0
3:2/0/terrains_peering_bit/bottom_right_corner = 0
3:2/0/terrains_peering_bit/bottom_side = 0
3:2/0/terrains_peering_bit/bottom_left_corner = 0
3:2/0/terrains_peering_bit/left_side = 0
3:2/0/terrains_peering_bit/top_left_corner = 0
3:2/0/terrains_peering_bit/top_side = 0
3:2/0/terrains_peering_bit/top_right_corner = 0
3:1/0 = 0
3:1/0/terrain_set = 0
3:1/0/terrain = 0
3:1/0/physics_layer_0/polygon_0/points = PackedVector2Array(16, -16, 16, -6.654772, 7.014491, -6.4749146, 6.834633, -16)
3:1/0/terrains_peering_bit/right_side = 0
3:1/0/terrains_peering_bit/bottom_right_corner = 0
3:1/0/terrains_peering_bit/bottom_side = 0
3:1/0/terrains_peering_bit/bottom_left_corner = 0
3:1/0/terrains_peering_bit/left_side = 0
3:1/0/terrains_peering_bit/top_left_corner = 0
3:1/0/terrains_peering_bit/top_side = 0
1:1/next_alternative_id = 3
1:1/0 = 0
1:1/0/terrain_set = 0
1:1/1 = 1
1:1/1/transpose = true
1:1/1/terrain_set = 0
[resource]
tile_size = Vector2i(32, 32)
physics_layer_0/collision_layer = 1
terrain_set_0/mode = 0
terrain_set_0/terrain_0/name = "office_walls"
terrain_set_0/terrain_0/color = Color(0.7063632, 0, 0.35112187, 1)
sources/0 = SubResource("TileSetAtlasSource_t1ruc")
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

+7
View File
@@ -0,0 +1,7 @@
Thank you for purchasing my assets! You can find others or games on my itch.io:
https://deakcor.itch.io/
If you want to contact me or follow my works, you can by following my twitter!
https://twitter.com/deakcor
+51
View File
@@ -0,0 +1,51 @@
## LevelManager.gd
# Manages the current level's data, visual representation, and navigation graph.
# Responsible for generating, painting, and initializing the level, as well as providing pathfinding and coordinate helpers.
extends Node
# The source of truth for the current level's layout (MapData resource).
var _data: MapData
# The TileMapLayer node where the map is visually rendered.
var _layer: TileMapLayer
# Service objects for map generation, painting, and pathfinding.
# MapGenerator and MapPainter are stateless; PathfindingHandler manages the navigation graph.
var _generator: MapGenerator = MapGenerator.new()
var _painter: MapPainter = MapPainter.new()
var _pathfinding: PathfindingHandler = PathfindingHandler.new()
# Orchestrates the creation of a new level.
# 1. Generates the map data.
# 2. Paints the map visually.
# 3. Builds the navigation graph for AI/pathfinding.
# 4. Spawns the player at the correct location.
func initialize(settings: MapSettings, layer: TileMapLayer, player: CharacterBody2D) -> void:
_layer = layer
# 1. Generate the abstract map data (rooms, corridors, etc.).
# This avoids side effects on the settings resource.
_data = _generator.generate(settings)
# 2. Paint the visual layer for the player to see.
# This does not modify the underlying map data.
_painter.paint(_data, _layer)
# 3. Build the AStar graph for AI and navigation.
# Reads directly from the grid stored in _data.
_pathfinding.build(_data)
# 4. Finalize world state by spawning the player at the spawn point.
# Uses the TileMapLayer to convert grid coordinates to world pixel positions.
player.position = _layer.map_to_local(_data.spawn_point)
# Public API for other systems (like AI) to query the navigation grid.
# Encapsulates the PathfindingHandler so callers don't need to know about it directly.
func find_path(from: Vector2i, to: Vector2i) -> Array[Vector2i]:
return _pathfinding.find_path(from, to)
# Coordinate conversion helpers — lets the player and other systems convert between world and tile coordinates without touching the TileMapLayer directly.
func world_to_tile(world_pos: Vector2) -> Vector2i:
return _layer.local_to_map(world_pos)
func tile_to_world(tile: Vector2i) -> Vector2:
return _layer.map_to_local(tile)
+61
View File
@@ -0,0 +1,61 @@
## TurnManager.gd
# Autoload singleton for managing turn order in a turn-based game.
# Handles registration, sequencing, and signaling for all turn-taking entities.
extends Node
# List of entities that participate in the turn order (e.g., Player, Enemies).
var turn_entities: Array[Node] = []
# Index of the entity whose turn is currently active.
var current_index: int = 0
# Whether the turn system is currently running.
var active: bool = false
# Registers an entity to participate in the turn order.
# Ensures no duplicates.
func register_entity(entity: Node) -> void:
if entity not in turn_entities:
turn_entities.append(entity)
# Unregisters an entity (e.g., when it is removed or defeated).
func unregister_entity(entity: Node) -> void:
if entity in turn_entities:
turn_entities.erase(entity)
# Starts the turn sequence for all registered entities.
# Resets the index and begins the first turn.
func start_turns() -> void:
if turn_entities.size() == 0:
return
active = true
current_index = 0
_next_turn()
# Advances to the next entity's turn.
# Connects to the entity's turn_finished signal and calls its take_turn() method.
func _next_turn() -> void:
if not active or turn_entities.size() == 0:
return
if current_index >= turn_entities.size():
current_index = 0 # New round
# Get the next entity in the list.
var entity: Node = turn_entities[current_index]
# Connect to the entity's turn_finished signal if not already connected.
if not entity.is_connected("turn_finished", Callable(self, "_on_entity_turn_finished")):
entity.connect("turn_finished", Callable(self, "_on_entity_turn_finished"))
# Notify the entity to take its turn.
entity.take_turn()
# Called when an entity finishes its turn (emits turn_finished).
# Disconnects the signal and advances to the next entity.
func _on_entity_turn_finished() -> void:
# Disconnect to avoid duplicate connections.
var entity: Node = turn_entities[current_index]
if entity.is_connected("turn_finished", Callable(self, "_on_entity_turn_finished")):
entity.disconnect("turn_finished", Callable(self, "_on_entity_turn_finished"))
current_index += 1
_next_turn()
# Optionally, stops the turn sequence and resets state.
func stop_turns() -> void:
active = false
current_index = 0
+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
+73
View File
@@ -0,0 +1,73 @@
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
; [section] ; section goes between []
; param=value ; assign values to parameters
config_version=5
[application]
config/name="Shadow Pixel Run"
run/main_scene="uid://cnb0ykbiglgo6"
config/features=PackedStringArray("4.6", "Forward Plus")
config/icon="res://assets/sprites/icon.svg"
[autoload]
LevelManager="*uid://c2kvqdy85klps"
TurnManager="*uid://qaa5cunrehr0"
[debug]
gdscript/warnings/untyped_declaration=1
[display]
window/size/viewport_width=640
window/size/viewport_height=320
[editor]
version_control/plugin_name="GitPlugin"
version_control/autoload_on_startup=true
[input]
move_left={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194319,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
move_right={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194321,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
move_up={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194320,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
move_down={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194322,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
[physics]
3d/physics_engine="Jolt Physics"
[rendering]
textures/canvas_textures/default_texture_filter=0
rendering_device/driver.windows="d3d12"
renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"
+19
View File
@@ -0,0 +1,19 @@
[gd_scene format=3 uid="uid://hwbs3q16spqx"]
[ext_resource type="Script" uid="uid://dh5hx2lospkug" path="res://scripts/actors/character.gd" id="1_4i4j6"]
[ext_resource type="Texture2D" uid="uid://b7s06o6gm42rn" path="res://assets/sprites/icon.svg" id="2_qkj82"]
[sub_resource type="RectangleShape2D" id="RectangleShape2D_35asm"]
size = Vector2(32, 32)
[node name="Enemy" type="CharacterBody2D" unique_id=1059490557]
script = ExtResource("1_4i4j6")
metadata/_custom_type_script = "uid://dh5hx2lospkug"
[node name="Sprite2D" type="Sprite2D" parent="." unique_id=569576189]
self_modulate = Color(1, 0, 0, 1)
scale = Vector2(0.25, 0.25)
texture = ExtResource("2_qkj82")
[node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=328269646]
shape = SubResource("RectangleShape2D_35asm")
+22
View File
@@ -0,0 +1,22 @@
[gd_scene format=3 uid="uid://1ls2pjitfl5m"]
[ext_resource type="Script" uid="uid://ciusoegxdh344" path="res://scripts/actors/player.gd" id="1_ru62r"]
[ext_resource type="Texture2D" uid="uid://b7s06o6gm42rn" path="res://assets/sprites/icon.svg" id="2_ktjr4"]
[sub_resource type="RectangleShape2D" id="RectangleShape2D_qfnet"]
size = Vector2(32, 32)
[node name="Player" type="CharacterBody2D" unique_id=179780959]
script = ExtResource("1_ru62r")
[node name="Sprite2D" type="Sprite2D" parent="." unique_id=29528982]
scale = Vector2(0.25, 0.25)
texture = ExtResource("2_ktjr4")
[node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=272748828]
shape = SubResource("RectangleShape2D_qfnet")
[node name="Camera2D" type="Camera2D" parent="." unique_id=2033102226]
position_smoothing_enabled = true
drag_horizontal_enabled = true
drag_vertical_enabled = true
+10
View File
@@ -0,0 +1,10 @@
[gd_scene format=3 uid="uid://cnb0ykbiglgo6"]
[ext_resource type="Script" uid="uid://6xvdnyxsb2uq" path="res://scripts/world.gd" id="1_71j4m"]
[ext_resource type="TileSet" uid="uid://cmycomep4ap8y" path="res://assets/tilesets/office_02.tres" id="4_qfnet"]
[node name="World" type="Node2D" unique_id=1504396068]
script = ExtResource("1_71j4m")
[node name="MapLayer" type="TileMapLayer" parent="." unique_id=2055425416]
tile_set = ExtResource("4_qfnet")
+26
View File
@@ -0,0 +1,26 @@
## Character.gd
# Base class for all turn-based characters (player, enemies, etc.)
# Handles registration with TurnManager and provides a turn interface.
class_name Character
extends CharacterBody2D
# Signal emitted when this character's turn is finished.
signal turn_finished
# Called by TurnManager to let this character take its turn.
# Subclasses should override this to implement their own turn logic.
func take_turn() -> void:
# Virtual method to be overridden by subclasses
pass
# Called when the node enters the scene tree.
# Registers this character with the TurnManager for turn order.
func _ready() -> void:
if Engine.is_editor_hint():
return
TurnManager.register_entity(self)
# Call this to remove the character from the turn system and free it from the scene.
func die() -> void:
TurnManager.unregister_entity(self)
queue_free()
+41
View File
@@ -0,0 +1,41 @@
## player.gd
# Player character script. Handles input, pathfinding, and movement.
class_name Player
extends Character
# Movement speed in pixels per second.
const SPEED: float = 250.0
# Distance threshold for snapping to a tile (prevents jitter).
const ARRIVAL_THRESHOLD: float = 4.0
# The current path (as a list of tile coordinates) the player is following.
var _path: Array[Vector2i] = []
# Handles mouse input for movement. Calculates a path to the clicked tile.
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
var clicked_tile: Vector2i = LevelManager.world_to_tile(get_global_mouse_position())
var current_tile: Vector2i = LevelManager.world_to_tile(position)
_path = LevelManager.find_path(current_tile, clicked_tile)
# Drop the first entry — it's the tile the player is already on.
if not _path.is_empty():
_path.remove_at(0)
# Moves the player along the path, one tile at a time, using velocity.
func _physics_process(_delta: float) -> void:
if _path.is_empty():
velocity = Vector2.ZERO
move_and_slide()
return
var target_world: Vector2 = LevelManager.tile_to_world(_path[0])
var to_target: Vector2 = target_world - position
if to_target.length() < ARRIVAL_THRESHOLD:
position = target_world
_path.remove_at(0)
else:
velocity = to_target.normalized() * SPEED
move_and_slide()
+30
View File
@@ -0,0 +1,30 @@
## world.gd
# Main world node script. Sets up the map, player, and level on scene ready.
extends Node
# Reference to the TileMapLayer node for rendering the map.
# Reference to the TileMapLayer node for rendering the map.
var map_layer: TileMapLayer
# Path to the Player scene to instance.
const PLAYER_SCENE_PATH: String = "res://scenes/actors/player.tscn"
# Reference to the Player node.
var player: Player
# Map generation settings (size, room count, etc.).
var settings: MapSettings = MapSettings.new()
# Called when the node is added to the scene tree.
# Initializes references and triggers map setup.
func _ready() -> void:
map_layer = $MapLayer
# Do not assume Player is already in the scene tree; instance it after map generation.
call_deferred("_initialize_map")
# Initializes the map and level using LevelManager.
# Ensures the map is generated and the player is placed.
func _initialize_map() -> void:
# Instance the Player after the map is generated and add to the scene tree.
var player_scene: PackedScene = load(PLAYER_SCENE_PATH)
player = player_scene.instantiate() as Player
add_child(player)
LevelManager.initialize(settings, map_layer, player)