4.4 Godot Version
Question
` i tried making a flowfield in godot but its a bit jittery and enemys dont always stop if there are others in front of them and since this is my first time trying something like this i was wondering if i could get some feedback what i did wrong to make this better since this is a very important part of my game.
- enemy script
extends CharacterBody3D
class_name Enemy
@export var resource :EnemyResource
#states
@onready var state_chart: StateChart = $StateChart
@onready var standing_still: AtomicState = %"Standing Still"
@onready var moving: AtomicState = %Moving
#animation
@onready var animation_player: AnimationPlayer = $Model/AnimationPlayer
@onready var animation_tree: AnimationTree = $Model/AnimationTree
@onready var state_machine = animation_tree["parameters/StateMachine/playback"]
var current_cell := Vector2i.ZERO
var flow_vec := Vector3.ZERO
var smoothed_vec := Vector3.ZERO #for blending between dirctions
var has_aggro:bool=false
var enemy_penalty:float=20
func _ready() -> void:
if resource == null:
push_error("🚨 EnemyResource not assigned!")
return
# Prevent stacking — random push on XZ plane
var push_dir = Vector3(
randf_range(-1.0, 1.0),
0,
randf_range(-1.0, 1.0)
).normalized()
var push_strength = 0.25 # Tune this based on your grid size
translate(push_dir * push_strength)
func _physics_process(delta):
if not EnemyFlowFieldAutoload.flowfield_ready:
return
var new_cell:Vector2i = EnemyFlowFieldAutoload.world_to_cell(global_position)
set_penalty(new_cell)
get_flow_vector()
var speed_threshold := 0.1 # Adjust as needed
if velocity.length_squared() < speed_threshold and not standing_still.active:
state_chart.send_event("movement_stopped")
return
if velocity.length_squared() >= speed_threshold and not moving.active:
state_chart.send_event("movement_started")
smooth_movement(delta)
rotate_enemy(delta)
velocity = smoothed_vec
if is_on_floor()==false:
velocity.y=-2
if velocity.length() > 0.1:
move_and_slide()
func _on_standing_still_state_entered() -> void:
flow_vec = Vector3.ZERO
smoothed_vec = Vector3.ZERO # ← stop residual movement!
animation_tree.call_deferred("set", "parameters/AnimationTimeScale/scale", 1)
state_machine.call_deferred("travel", "Standing Still")
func _on_moving_state_entered() -> void:
animation_tree.call_deferred("set", "parameters/AnimationTimeScale/scale", resource.move_speed_animation_multiplier)
state_machine.call_deferred("travel", "Moving")
func _on_visible_on_screen_enabler_3d_screen_exited() -> void:
has_aggro=false
func set_penalty(new_cell:Vector2i)-> void:
if new_cell != current_cell:
# Leaving old cell → remove penalty
if EnemyFlowFieldAutoload.full_grid.has(current_cell):
EnemyFlowFieldAutoload.full_grid[current_cell].penalty = 0.0
# Entering new cell → apply penalty
if EnemyFlowFieldAutoload.full_grid.has(new_cell):
EnemyFlowFieldAutoload.full_grid[new_cell].penalty = enemy_penalty
current_cell = new_cell
func get_flow_vector():
if EnemyFlowFieldAutoload.full_grid.has(current_cell):
var cost :float= EnemyFlowFieldAutoload.full_grid[current_cell].cost
var distance:float = EnemyFlowFieldAutoload.full_grid[current_cell].distance
#check if the cell has a cost
if cost==null:
return
#check aggro range
if distance >= resource.aggro_range and has_aggro==false:
if standing_still.active==false:
state_chart.send_event("movement_stopped")
return
#
#check attack range
if distance <= resource.attack_range:
if standing_still.active==false:
state_chart.send_event("movement_stopped")
return
has_aggro=true
#get vector
flow_vec = EnemyFlowFieldAutoload.full_grid[current_cell].flow_vec
func rotate_enemy(delta):
# Rotate toward smoothed movement direction
if smoothed_vec.length_squared() > 0.01:
var facing_dir :Vector3= -transform.basis.z.normalized()
var move_dir :Vector3= smoothed_vec.normalized()
var new_facing :Vector3= facing_dir.lerp(move_dir, delta * 5.0).normalized()
look_at(global_position + new_facing, Vector3.UP)
rotation.x=0
rotation.z=0
func smooth_movement(delta):
# Smooth movement direction
if flow_vec.length_squared() > 0.01:
var current_dir :Vector3= smoothed_vec.normalized()
var target_dir :Vector3= flow_vec.normalized()
var smoothed_dir:Vector3 = current_dir.lerp(target_dir, delta * 5.0).normalized()
var target_speed:float = clamp(resource.move_speed, 1, 10)
smoothed_vec = smoothed_dir * target_speed
else:
smoothed_vec = Vector3.ZERO
- flowfield script
extends Node
var debug_enabled := false
var grid_size: float = 1
var grid_radius: int = 15
@onready var player: Player = get_node("/root/Main/Player")
@onready var nav_region: NavigationRegion3D = get_node("/root/Main/CurrentLevel").get_child(0).get_node("NavigationRegion3D")
@onready var nav_map: RID = nav_region.get_navigation_map()
@onready var flow_field_debug_folder: Node3D = get_node("/root/Main/FlowFieldDebugFolder")
var full_grid: Dictionary = {} # Vector2i -> Dictionary { pos, cost, penalty, etc. }
var last_player_cell: Vector2i = Vector2i.ZERO
var penalized_cells: = {} # Set for uniqueness
var update_timer := 0.0
const UPDATE_INTERVAL := 0.1 # 2 seconds
var space_state: PhysicsDirectSpaceState3D
var flowfield_ready:bool=false
class FlowFieldCell:
var pos: Vector3
var cost: float = INF
var penalty: float = 0.0
var distance: float = INF
var flow_vec: Vector3 = Vector3.ZERO
var label: Label3D = null
func _init(pos: Vector3):
self.pos = pos
func _physics_process(delta: float) -> void:
update_timer += delta
var current_player_cell = world_to_cell(player.global_position)
if current_player_cell != last_player_cell:
last_player_cell = current_player_cell
if update_timer >= UPDATE_INTERVAL:
update_timer = 0.0
generate_flow_field()
#region Build grid
func set_space_state(state: PhysicsDirectSpaceState3D) -> void:
space_state = state
func _ready():
await get_tree().create_timer(1).timeout
build_full_grid_from_navmesh()
generate_flow_field()
flowfield_ready=true
func build_full_grid_from_navmesh():
full_grid.clear()
var nav_mesh = nav_region.navigation_mesh
if nav_mesh == null:
push_error("❌ No NavigationMesh found!")
return
var verts = nav_mesh.get_vertices()
if verts.is_empty():
push_error("❌ NavigationMesh has no vertices!")
return
var min_v = verts[0]
var max_v = verts[0]
for vert in verts:
min_v = min_v.min(vert)
max_v = max_v.max(vert)
var min_v_x = int(floor(min_v.x / grid_size))
var min_v_z = int(floor(min_v.z / grid_size))
var max_v_x = int(ceil(max_v.x / grid_size))
var max_v_z = int(ceil(max_v.z / grid_size))
for child in flow_field_debug_folder.get_children():
child.queue_free()
for x in range(min_v_x, max_v_x):
for z in range(min_v_z, max_v_z):
var cell = Vector2i(x, z)
var raw_pos = Vector3(x * grid_size + grid_size * 0.5,0,z * grid_size + grid_size * 0.5)
var floor_y = get_floor_y(raw_pos)
var world_pos = Vector3(raw_pos.x, floor_y, raw_pos.z)
if is_cell_walkable(world_pos):
var label: Label3D = null
if debug_enabled:
label = Label3D.new()
label.text = "?"
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
label.position = world_pos + Vector3(0, 3, 0)
label.font_size = 50
label.modulate = Color.RED
flow_field_debug_folder.add_child(label)
var cell_data := FlowFieldCell.new(world_pos)
cell_data.label = label
full_grid[cell] = cell_data
#var keys = full_grid.keys()
#for i in range(min(5, keys.size())):
#print(keys[i]," ",full_grid[keys[i]])
func is_cell_walkable(world_pos: Vector3) -> bool:
var closest_point = NavigationServer3D.map_get_closest_point(nav_map, world_pos)
var dist_2d = Vector2(world_pos.x, world_pos.z).distance_to(Vector2(closest_point.x, closest_point.z))
return dist_2d < grid_size * 1
func get_floor_y(world_pos: Vector3, max_distance := 10.0) -> float:
if space_state == null:
push_error("🚨 space_state not initialized.")
return world_pos.y
var from = world_pos + Vector3.UP * max_distance
var to = world_pos + Vector3.DOWN * max_distance
var query := PhysicsRayQueryParameters3D.create(from, to)
query.collide_with_areas = false # Optional: only collide with physics bodies
query.collide_with_bodies = true
var result = space_state.intersect_ray(query)
if result:
return result.position.y
return world_pos.y
#endregion
#region Flowfield
# Flow field generation
func generate_flow_field() -> void:
var target_cell :Vector2i = world_to_cell(player.global_position)
if not full_grid.has(target_cell):
push_warning("Target cell not in grid.")
return
for cell in full_grid:
if cell.distance_to(target_cell) <= grid_radius:
full_grid[cell].cost = INF
full_grid[target_cell].cost = 0
full_grid[target_cell].distance=0
full_grid[target_cell].flow_vec = Vector3.ZERO # 👈 flow direction for target
var queue: Array[Vector2i] = [target_cell]
while not queue.is_empty():
var current:Vector2i = queue.pop_front()
var current_cost:float = full_grid[current].cost
if current.distance_to(target_cell) > grid_radius:
continue
for neighbor in get_neighbors(current):
if not full_grid.has(neighbor):
continue
if neighbor.distance_to(target_cell) > grid_radius:
continue
var step_cost:float = 1.0 * grid_size
if abs(neighbor.x - current.x) == 1 and abs(neighbor.y - current.y) == 1:
step_cost = 1.4 * grid_size
var penalty:float = full_grid[neighbor].penalty
var new_cost :float= current_cost + step_cost + penalty
var new_distance:float = current_cost + step_cost
if full_grid[neighbor].cost <= new_cost:
continue
full_grid[neighbor].distance=new_distance
full_grid[neighbor].cost = new_cost
full_grid[neighbor].flow_vec = (full_grid[current].pos - full_grid[neighbor].pos).normalized() # ← added line
queue.append(neighbor)
if debug_enabled:
for cell in full_grid:
if cell.distance_to(target_cell) > grid_radius:
continue
if full_grid[cell].label == null:
continue
var label = full_grid[cell].label
var cost =str(full_grid[cell].cost)
label.text = cost
# Helpers
func world_to_cell(pos: Vector3) -> Vector2i:
return Vector2i(
int(floor(pos.x / grid_size)),
int(floor(pos.z / grid_size))
)
func get_neighbors(cell: Vector2i) -> Array[Vector2i]:
return [
cell + Vector2i(1, 0),
cell + Vector2i(-1, 0),
cell + Vector2i(0, 1),
cell + Vector2i(0, -1),
cell + Vector2i(1, 1),
cell + Vector2i(1, -1),
cell + Vector2i(-1, 1),
cell + Vector2i(-1, -1),
]
#endregion