feat: Add EnhancedGridMap for dynamic grid generation, pathfinding, and network synchronization, and introduce core game scripts for Tekton entities and managers.

This commit is contained in:
Yogi Wiguna
2026-02-13 13:14:37 +08:00
parent 0ee5051ebd
commit ad16f5942b
9 changed files with 354 additions and 75 deletions
+36 -13
View File
@@ -393,30 +393,22 @@ func _try_move() -> bool:
# Extract immediate next step from path
next_step = Vector2i(path[1].x, path[1].y)
else:
# Fallback: Pathfinding failed or target is too close?
# Fallback: Pathfinding failed or target is too close?
# Check if target is adjacent and we can move directly
var dist = abs(final_target.x - actor.current_position.x) + abs(final_target.y - actor.current_position.y)
if dist == 1:
next_step = final_target
else:
return false
# Redundant safety check (simple_move_to also checks this)
# Removed to allow PUSHING mechanics (simple_move_to handles occupancy/pushing)
# if actor.is_position_occupied(next_step):
# return false
# PATHFINDING FAILED! (Likely stuck on wall/stand)
# Attempt UNSTUCK move to any adjacent valid tile
print("[BotController] Pathfinding failed for %s. Attempting UNSTUCK move." % actor.name)
return await _try_unstuck_move()
# Execute SINGLE STEP movement using player manager
if actor.movement_manager.simple_move_to(next_step):
_is_processing_action = true
_current_action = "moving"
# Wait for movement to finish or timeout (safety)
# Race: Signal vs Timeout
# Since Godot 4 doesn't support 'await' racing easily without helper,
# we'll just wait for the signal but ensure movement manager emits it.
# safer approach: check if is_moving goes false
# Safety timeout to prevent infinite loop
var max_wait_time = 2.0
var elapsed = 0.0
@@ -435,6 +427,37 @@ func _try_move() -> bool:
return false
func _try_unstuck_move() -> bool:
"""Randomly move to ANY adjacent valid tile to escape sticky situations."""
var neighbors = enhanced_gridmap.get_neighbors(actor.current_position, 0)
neighbors.shuffle() # Randomize to avoid oscillating
for n in neighbors:
if not n.is_walkable: continue
var cell = Vector3i(n.position.x, 0, n.position.y) # Check Floor 0
var item = enhanced_gridmap.get_cell_item(cell)
# Ensure we don't walk into a wall (Item 4) or Void (-1)
# Obstacles should be checked by is_walkable but let's be sure
if item == 4 or item == -1: continue
# Attempt move
if actor.movement_manager.simple_move_to(n.position):
_is_processing_action = true
_current_action = "moving_unstuck"
print("[BotController] Unstuck move to %s" % n.position)
# Wait for move
await _wait_with_variance(action_delay)
if not is_instance_valid(self): return true
_is_processing_action = false
_current_action = "idle"
return true
print("[BotController] %s is TRULY stuck! No valid neighbors." % actor.name)
return false
# =============================================================================
# Put Tiles Back
# =============================================================================
@@ -77,6 +77,27 @@ func simple_move_to(grid_position: Vector2i) -> bool:
if (cell_item == -1 or cell_item in enhanced_gridmap.non_walkable_items) and not is_wall_passable:
return false
# PHYSICS CHECK: Ensure no static obstacles (like Stands) are blocking the path
# GridMap logic handles cells, but Objects/Bodies might be placed on top (like StaticTektonStand)
var space_state = player.get_world_3d().direct_space_state
# RAYCAST HEIGHT: 0.3 (Center of the 0.6m tall stand)
# Check from CENTER using +0.5
var from = Vector3(player.current_position.x + 0.5, 0.3, player.current_position.y + 0.5)
var to = Vector3(grid_position.x + 0.5, 0.3, grid_position.y + 0.5)
# Check center of target tile
var query = PhysicsRayQueryParameters3D.create(from, to)
query.collide_with_areas = false
query.collide_with_bodies = true
# query.collision_mask = 1 # Default mask usually covers static bodies
var result = space_state.intersect_ray(query)
if result:
# If we hit something static that isn't ourselves
if result.collider != player:
print("Movement Blocked by Physics Body: ", result.collider.name)
return false
if player.is_position_occupied(grid_position):
var push_dir = grid_position - player.current_position
if not try_push(grid_position, push_dir):
+86 -15
View File
@@ -20,38 +20,109 @@ func calculate_spawn_points(count: int, gridmap: Node) -> Array:
"""
if count <= 0 or not gridmap: return []
print("[StaticTektonManager] Calculating %d static tekton positions..." % count)
print("[StaticTektonManager] Calculating static tekton positions (Fixed 5-Zone + Center)...")
# 1. Define Zones
# 1. Define Zones (3x3 Grid)
var width = gridmap.columns
var depth = gridmap.rows
# Simple 3x3 grid partition
var zone_w = width / 3
var zone_d = depth / 3
var all_zones = []
var zones = []
for z in range(3):
for x in range(3):
# Create Rect2i for each zone (x, y, w, h)
var zone_rect = Rect2i(x * zone_w, z * zone_d, zone_w, zone_d)
all_zones.append(zone_rect)
zones.append(Rect2i(x * zone_w, z * zone_d, zone_w, zone_d))
# 2. Select Zones (Random Distinct)
all_zones.shuffle()
var selected_zones = all_zones.slice(0, count)
# 2. Select Fixed Targets: TL(0), TR(2), Center(4), BL(6), BR(8)
# This ensures they are never adjacent (always separated by a middle zone)
var target_indices = [0, 2, 6, 8, 4]
# If count < 5, we prioritize corners then center
# If count > 5, we only return 5 because that's the max safe non-adjacent set in 3x3
var spawn_points = []
# 3. Pick Point in each Selected Zone
for i in range(selected_zones.size()):
var zone = selected_zones[i]
var pos = _pick_spot_in_zone(zone, gridmap)
var iterations = min(count, target_indices.size())
for i in range(iterations):
var zone_idx = target_indices[i]
var zone = zones[zone_idx]
# Determine Position Type for Bias
# 0:TL, 1:TR, 2:BL, 3:BR, 4:Center
var pos_type = -1
match zone_idx:
0: pos_type = 0 # TL
2: pos_type = 1 # TR
6: pos_type = 2 # BL
8: pos_type = 3 # BR
4: pos_type = 4 # Center
var pos = _pick_spot_in_zone_biased(zone, gridmap, pos_type)
if pos != Vector2i(-1, -1):
spawn_points.append(pos)
return spawn_points
func _pick_spot_in_zone_biased(zone: Rect2i, gridmap: Node, type: int) -> Vector2i:
# type: 0=TL, 1=TR, 2=BL, 3=BR, 4=Center
# ideal target relative to map
var target = Vector2i.ZERO
match type:
0: target = Vector2i(0, 0)
1: target = Vector2i(gridmap.columns, 0)
2: target = Vector2i(0, gridmap.rows)
3: target = Vector2i(gridmap.columns, gridmap.rows)
4: target = Vector2i(gridmap.columns / 2, gridmap.rows / 2)
# Clamp target to be inside valid area (taking 3x3 margin into account)
# Center of 3x3 must be at least 1 tile from edge
var min_x = max(1, zone.position.x + 1)
var max_x = min(gridmap.columns - 2, zone.position.x + zone.size.x - 2)
var min_y = max(1, zone.position.y + 1)
var max_y = min(gridmap.rows - 2, zone.position.y + zone.size.y - 2)
if min_x > max_x or min_y > max_y:
return Vector2i(-1, -1)
var clamped_target = Vector2i(
clamp(target.x, min_x, max_x),
clamp(target.y, min_y, max_y)
)
# BFS to find nearest valid 3x3 spot to clamped_target
var queue = [clamped_target]
var visited = {clamped_target: true}
# Limit search to avoid hanging
var checks = 0
while not queue.is_empty() and checks < 200:
var current = queue.pop_front()
checks += 1
if _is_valid_3x3(current, gridmap):
return current
var neighbors = [
Vector2i(0, 1), Vector2i(0, -1), Vector2i(1, 0), Vector2i(-1, 0)
]
for n in neighbors:
var next = current + n
if next.x >= min_x and next.x <= max_x and next.y >= min_y and next.y <= max_y:
if not visited.has(next):
visited[next] = true
queue.append(next)
return Vector2i(-1, -1)
func _is_valid_3x3(center: Vector2i, gridmap: Node) -> bool:
for dx in range(-1, 2):
for dy in range(-1, 2):
var check_pos = Vector3i(center.x + dx, 0, center.y + dy)
if gridmap.get_cell_item(check_pos) == -1:
return false
return true
func _pick_spot_in_zone(zone: Rect2i, gridmap: Node) -> Vector2i:
# Find a valid 3x3 spot in the zone
# The returned position is the CENTER of the 3x3 area
+76
View File
@@ -0,0 +1,76 @@
extends StaticBody3D
@onready var mesh_instance: MeshInstance3D = $MeshInstance3D
# Sync the chosen shape so all clients see the same one
@export var shape_index: int = -1:
set(value):
shape_index = value
if is_inside_tree():
_update_mesh_from_index()
func _ready():
if multiplayer.is_server():
# Only randomize if not already set (Main.gd sets it now)
if shape_index == -1:
shape_index = randi() % 4
_update_mesh_from_index()
else:
# Client side:
if shape_index != -1:
_update_mesh_from_index()
else:
# If we spawned but data hasn't arrived (unlikely with spawn=true but possible),
# we wait. The setter will trigger update when data arrives.
# But just in case, we can try to request it or use a default
pass
func _update_mesh_from_index():
if not mesh_instance: return
var shapes = [
_create_cylinder(),
_create_box(),
_create_prism(),
_create_sphere()
]
var idx = shape_index % shapes.size()
var selected_mesh = shapes[idx]
# Apply Material
var mat = StandardMaterial3D.new()
mat.albedo_color = Color(0.2, 0.2, 0.25)
mat.metallic = 0.5
mat.roughness = 0.5
selected_mesh.material = mat
mesh_instance.mesh = selected_mesh
# Deprecated: _randomize_shape (Logic moved to server init and sync)
# func _randomize_shape(): ...
func _create_cylinder() -> CylinderMesh:
var mesh = CylinderMesh.new()
mesh.top_radius = 1.4
mesh.bottom_radius = 1.4
mesh.height = 0.6
return mesh
func _create_box() -> BoxMesh:
var mesh = BoxMesh.new()
mesh.size = Vector3(3.2, 0.6, 3.2)
return mesh
func _create_prism() -> PrismMesh:
var mesh = PrismMesh.new()
mesh.size = Vector3(3.2, 0.6, 3.2)
return mesh
func _create_sphere() -> SphereMesh:
# A flattened sphere acting like a dome stand
var mesh = SphereMesh.new()
mesh.radius = 1.4
mesh.height = 1.0 # Flattened
mesh.is_hemisphere = true
return mesh
+1
View File
@@ -0,0 +1 @@
uid://dny2mjwk1vo0u
+25
View File
@@ -48,8 +48,33 @@ func move_to(target_pos: Vector2i):
if is_moving or is_carried or is_thrown: return
# Validate
# Validate Grid
if not enhanced_gridmap.is_position_valid(target_pos):
return
# Validate Physics (Block on Stands)
var space_state = get_world_3d().direct_space_state
# Tekton is slightly offset, so query center of its current and target tiles
# Just checking TARGET center is usually enough to stop entering a blocked tile
# RAY HEIGHT: 0.3 to hit the 0.6m tall stand
var from = Vector3(target_pos.x + 0.5, 0.3, target_pos.y + 0.5) + Vector3.UP * 5.0
var to = Vector3(target_pos.x + 0.5, 0.3, target_pos.y + 0.5) + Vector3.DOWN * 5.0
# Raycast VERTICALLY at target to see if it's occupied by a Stand
# Actually, vertical ray from high up is fine IF it goes low enough.
# But let's check intersection at body height to be sure.
from = Vector3(target_pos.x + 0.5, 5.0, target_pos.y + 0.5)
to = Vector3(target_pos.x + 0.5, 0.1, target_pos.y + 0.5) # Go almost to floor
var query = PhysicsRayQueryParameters3D.create(from, to)
query.collide_with_areas = false
query.collide_with_bodies = true
var result = space_state.intersect_ray(query)
if result:
# If we hit a StaticTektonStand (or any other static blocking body)
if result.collider != self:
# print("Tekton movement blocked by: ", result.collider.name)
return
is_moving = true