Godot Version
Hello, I’m working on a group project in Godot 4.5.1 for a 2D tactical RPG game. My part of work focuses on units movements and enemy A.I.
Explanation
During the player phase, when we select one of the available player units, it affects the tiles around the selected unit, depending on his movements:
- the tiles where he can move, or wherer there is another player unit, are painted in blue
- the tiles where enemy units are present, or which represent the possible attack range of the player after any movement, are painted in red
- the tiles which correspond to pure obstacles, that cannot be moved or destroyed, are not colored
I want to deal with the tiles involving pure obstacles.
Goal
My goal is to change the way how the astar pathfinding, used by both players and enemy units, detect the pure obstacles. Originally, the program have an array of BLOCKED_Tiles, containing many Vector2i, that correspond to the atlas coordinates of each tile, from the selected tileset, that is considered an obstacle.
But now, I plan to use several tilesets in the future, and have different types of tiles (plain, obstacle, water, etc…). I want to use custom data layer for the tileset, and set the ones I want to obstacles.
Here is a photo:
All the obstacle tiles should be obstacle.
And here is the code I’m using for handling the pathfinding:
# File: res://scripts/pathfinding.gd
extends Node
class_name newPathfinding
# A* object and map properties
var player_astar := AStarGrid2D.new()
var enemy_astar := AStarGrid2D.new()
var map_width: int
var map_height: int
var move_amount: int
var tilemap_layer: TileMapLayer
var all_Positions: Array[Vector2i] = []
var current_unit: Unit
var BLOCKED_TILES: Array[Vector2i] = []
#
#const BLOCKED_TILES: Array[Vector2i] = [
#Vector2i(7, 0),
#Vector2i(7, 1),
#Vector2i(7, 2),
#Vector2i(7, 3),
#Vector2i(8, 0),
#Vector2i(8, 1),
#Vector2i(8, 2),
#Vector2i(8, 3),
#Vector2i(9, 0),
#Vector2i(9, 1),
#Vector2i(9, 2),
#Vector2i(9, 3),
#Vector2i(10, 0),
#Vector2i(10, 1),
#Vector2i(10, 2),
#Vector2i(11, 1),
#Vector2i(11, 2),
#Vector2i(12, 0),
#Vector2i(12, 1),
#Vector2i(12, 2),
#Vector2i(13, 0),
#Vector2i(13, 1),
#Vector2i(13, 2),
#Vector2i(13, 3),
#Vector2i(13, 4),
#Vector2i(14, 0),
#Vector2i(14, 1),
#Vector2i(14, 2),
#Vector2i(14, 3),
#Vector2i(14, 4),
#]
# SET UP
# Must be called first to set up references and grid
func newSetup(tilemap: TileMapLayer, width: int, height: int, tile_size: int) -> void:
#print("Pathfinding setup called with tilemap:", tilemap)
tilemap_layer = tilemap
map_width = width
map_height = height
move_amount = tile_size
# NOUVEAU : détecter les obstacles dans le TileSet
scan_tileset_obstacles()
new_init_astar(player_astar)
new_init_astar(enemy_astar)
# Sets up the A* grid for pathfinding
func new_init_astar(astar: AStarGrid2D) -> void:
astar.region = Rect2i(0, 0, map_width, map_height)
astar.cell_size = Vector2(move_amount, move_amount)
#print("INITIATION ASTAR",move_amount, " ", astar.cell_size)
astar.diagonal_mode = AStarGrid2D.DIAGONAL_MODE_NEVER
astar.default_compute_heuristic = AStarGrid2D.HEURISTIC_MANHATTAN
astar.default_estimate_heuristic = AStarGrid2D.HEURISTIC_MANHATTAN
astar.update()
for x in range(map_width):
for y in range(map_height):
var cell := Vector2i(x, y)
# Mark only terrain-blocked cells as solid
var atlas: Vector2i = tilemap_layer.get_cell_atlas_coords(cell)
if BLOCKED_TILES.has(atlas):
astar.set_point_solid(cell, true)
func scan_tileset_obstacles():
BLOCKED_TILES.clear()
var tileset := tilemap_layer.tile_set
for source_id in tileset.get_source_count():
var source := tileset.get_source(source_id)
if source is TileSetAtlasSource:
for tile_id in source.get_tiles_ids():
var tile_data : TileData = source.get_tile_data(tile_id, 0)
print("tilemap_layer:", tilemap_layer)
print("tile_data:", tile_data)
if tile_data != null:
var category : String = tile_data.get_custom_data("category")
if category == "obstacle":
var atlas_coords : Vector2i = source.get_tile_atlas_coords(tile_id)
BLOCKED_TILES.append(atlas_coords)
print("Tiles obstacles détectées :", BLOCKED_TILES)
func isTileType(tile: Vector2i) -> bool:
var tile_data = tilemap_layer.get_cell_tile_data(tile)
print("tilemap_layer:", tilemap_layer)
print("tile_data:", tile_data)
if tile_data == null:
print(tile, " → aucune tile")
return false
var category = tile_data.get_custom_data("category")
print(tile, " → category:", category)
return category == "obstacle"
# TEAM FILTERING
# Returns true if the unit at tile_pos is an ally of `unit`
# Returns false if it's an enemy
# Returns null if no unit is present
func is_enemy(unit: Unit, tile_pos: Vector2i) -> bool:
var other_unit: Unit = tilemap_layer.get_unit_at(tile_pos)
if other_unit == null:
return false # treat empty as "not ally"
return not are_allied(unit.team, other_unit.team)
# Helper: define what "allied" means
func are_allied(team_a: int, team_b: int) -> bool:
if team_a == null or team_b == null:
return false # or push_warning("Null team passed to are_allied")
if team_a == team_b:
return true
if team_a == Unit.TEAM.PLAYER and team_b == Unit.TEAM.ALLY:
return true
if team_a == Unit.TEAM.ALLY and team_b == Unit.TEAM.PLAYER:
return true
return false
# OBSTACLES CHECK
# Returns true if a tile is an obstacle, whether an enemy or a pure obstacle
func is_occupied(tile_pos: Vector2i) -> bool:
if tilemap_layer == null:
return false
# We retrieve the data of the checked tile
var atlas: Vector2i = tilemap_layer.get_cell_atlas_coords(tile_pos)
var unit = tilemap_layer.get_unit_at(tile_pos)
# Terrain obstacles, checking if the tile is amonged the obstacles one
var terrain_blocked: bool = BLOCKED_TILES.has(atlas)
# Enemies block movement
var unit_blocked = tilemap_layer.is_tile_free(tile_pos)
return terrain_blocked or unit_blocked
# Returns true if a tile is an obstacle, whether an enemy or a pure obstacle
func is_obstacle_for(unit: Unit, tile_pos: Vector2i) -> bool:
if unit == null:
return false
if tilemap_layer == null:
push_error("TILEMAP_LAYER IS NULL in is_obstacle_for")
var atlas: Vector2i = tilemap_layer.get_cell_atlas_coords(tile_pos)
var other_unit: Unit = tilemap_layer.get_unit_at(tile_pos)
if other_unit == unit:
return false
var terrain_blocked = BLOCKED_TILES.has(atlas)
if other_unit == null:
#print("Tile ", tile_pos, " has no unit → terrain_blocked: ", terrain_blocked)
return terrain_blocked
# Block if other unit is not allied
var enemy_blocked = not are_allied(unit.team, other_unit.team)
#print("Tile ", tile_pos, " has unit ", other_unit.get_character_name(), " → enemy_blocked: ", enemy_blocked)
return enemy_blocked
# Mark obstacles as solid in the A* pathfinding
func mark_player_obstacle(tile_pos: Vector2i, blocked: bool) -> void:
#print("Marking obstacle at ", tile_pos, " → blocked: ", blocked)
player_astar.set_point_solid(tile_pos, blocked)
# Mark obstacles as solid in the A* pathfinding
func mark_enemy_obstacle(tile_pos: Vector2i, blocked: bool) -> void:
#print("Marking obstacle at ", tile_pos, " → blocked: ", blocked)
enemy_astar.set_point_solid(tile_pos, blocked)
func refresh_dynamic_obstacles():
# 1) RESET BOTH GRIDS TO TERRAIN‑ONLY BLOCKING
for x in range(map_width):
for y in range(map_height):
var cell := Vector2i(x, y)
# Your existing terrain logic (atlas‑based)
var atlas := tilemap_layer.get_cell_atlas_coords(cell)
var terrain_blocked := BLOCKED_TILES.has(atlas)
# Apply to BOTH grids
player_astar.set_point_solid(cell, terrain_blocked)
enemy_astar.set_point_solid(cell, terrain_blocked)
# 2) APPLY UNIT‑BASED BLOCKING RULES
# (This is where player/enemy rules differ)
for pos in tilemap_layer.units.keys():
var unit : Unit = tilemap_layer.units[pos]
match unit.team:
# PLAYER + ALLY UNITS
Unit.TEAM.PLAYER, Unit.TEAM.ALLY:
# Player grid: CAN pass through → do nothing
# Enemy grid: CANNOT pass through → block
enemy_astar.set_point_solid(pos, true)
# ENEMY UNITS
Unit.TEAM.ENEMY:
# Player grid: CANNOT pass through → block
player_astar.set_point_solid(pos, true)
# Enemy grid: CAN pass through → do nothing
# PATH CALCULATION
func _get_astar_for(unit: Unit) -> AStarGrid2D:
if unit.team == Unit.TEAM.ENEMY:
return enemy_astar
return player_astar
# Compute a valid path within move cost
func new_compute_path(start: Vector2i, end: Vector2i, move_cost: int, unit: Unit) -> Array[Vector2i]:
current_unit = unit
var astar = _get_astar_for(unit)
# 1. If the destination is an obstacle, no path is possible → return empty.
if is_obstacle_for(unit, end):
#print("Tile:", end, " is obstacle")
return []
# 2. Ask A* for a path.
var raw_path: PackedVector2Array = astar.get_id_path(start, end)
#print("raw_path:", raw_path)
if raw_path.is_empty():
return []
# 3. Convert to Array[Vector2i]
var path: Array[Vector2i] = []
for p in raw_path:
path.append(Vector2i(p))
# 4. Remove start
if not path.is_empty() and path[0] == start:
path.pop_front()
# 5. Clamp by movement
if path.size() > move_cost:
return []
#print("Path:", path)
return path
# Returns all grid positions reachable from 'start' within 'max_steps'
# Uses a breadth‑first search (BFS) to explore the grid
func get_reachable_positions(start: Vector2i, max_steps: int, unit: Unit) -> Array[Vector2i]:
var result: Array[Vector2i] = [] # Stores all reachable positions
var frontier: Array[Vector2i] = [start] # Queue of positions to explore (BFS frontier)
var dist := { start: 0 } # Dictionary: position -> distance (steps taken from start)
var astar = _get_astar_for(unit)
while frontier.size() > 0: # While there are still positions to explore
var cur: Vector2i = frontier.pop_front() # Take the next position from the frontier
result.append(cur) # Mark it as reachable
# Explore the 4 orthogonal neighbors (up, down, left, right)
for dir in [Vector2i(1,0), Vector2i(-1,0), Vector2i(0,1), Vector2i(0,-1)]:
var nxt: Vector2i = cur + dir # Candidate next position
# Skip if outside the map boundaries
if nxt.x < 0 or nxt.y < 0 or nxt.x >= map_width or nxt.y >= map_height:
continue
# Skip if blocked (solid tile)
#print("Checking:", nxt, "solid:", astar.is_point_solid(nxt))
if astar.is_point_solid(nxt):
continue
# --- KEY PART: calculate the "cost" (steps taken so far) ---
var nd: int = dist[cur] + 1 # Distance to neighbor = distance to current + 1 step
# --- THIS IS WHERE max_steps IS ENFORCED ---
# Only add this neighbor if:
# 1. We haven't visited it yet (not in dist)
# 2. The total steps so far (nd) do not exceed max_steps
if nd <= max_steps and not dist.has(nxt):
dist[nxt] = nd # Record the distance (cost) to reach this tile
frontier.append(nxt) # Add it to the BFS frontier for further exploration
all_Positions.append(nxt)
# Include the player's actual location
all_Positions.append(start)
# Return all reachable positions
return result
func printPath(path: Array[Vector2i]) -> void:
print("\n=== PATH DEBUG ===")
if path.is_empty():
print("Path is empty.\n")
return
for tile in path:
var unit : Unit = tilemap_layer.get_unit_at(tile)
if unit:
print("Tile:", tile, " → OCCUPIED by ", unit.get_character_name(),
" (team:", unit.team, ", pos:", unit.currentPosition, ")")
else:
print("Tile:", tile, " → empty")
print("===================\n")
Problem
The problem, is that when I test my game, and I click on a player unit:
The pure obstacles tiles (the house for exemple) are painted in blue, while they should be not colored.
and also, here is my print text:
Tiles obstacles détectées :
[TurnManager] Player Turn begins.
I also had another kind of issue where I had a reverse problem, where there was almost no painted tiles.
It seems I have trouble to retrieve the custom data layer from a particular layer.
Can someone help me please ?
Thanks in advance
Paul NOWAK

