i tried making a flowfield

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.

  1. 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
  1. 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

If you could edit your post and put the code in backticks:

```gdscript
code
code
code
```

It would be far easier to read…

thx sry im new to this ^^

Are you using a tilemap? TileMapLayer has local_to_map() and map_to_local() functions for converting between world and grid coords.

You’re not using lerp() correctly, at least if you want actual framerate independent linear interpolation. You’ve essentially got:

vec = vec.lerp(target, (delta * 0.5))

Which is going to give you exponential interpolation; each iteration of that is jumping by some fraction of half the difference, so as the difference changes, the size of the jump changes too.

If you actually want linear interpolation (which, IMO, will look better, particularly with some easing), you want to do something more like:

turn_time += delta * turn_speed
vec = vec.lerp(target, turn_time)

You might also consider blending your flow vectors; your unit may be within a cell, but it’s really at a point between the centers of four cells; if you did a weighted blend of the four vectors you’d probably get more smoothed motion from your units.

I’ll also point out here that GPUs are pretty good at doing that, and call it “linear filtering” when they do it to textures. Which are also regular grids…