RapidGameFramework
Reusable Godot managers for data-driven small games
Loading...
Searching...
No Matches
ai_controller.gd
Go to the documentation of this file.
1extends RefCounted
2
3
7
8const DataCacheManager = preload("res://scripts/systems/dataCacheManager/data_cache_manager.gd")
9
10var profiles:Dictionary = {}
11var data_cache_manager = DataCacheManager.new()
12
13
14
15func load_profiles(file_path:String) -> void:
16 profiles.clear()
17 var data = data_cache_manager.load_json(file_path)
18 if not data is Dictionary:
19 return
20 for profile in data.get("profiles", []):
21 if not profile is Dictionary:
22 continue
23 var id:String = str(profile.get("id", ""))
24 if id != "":
25 profiles[id] = profile.duplicate(true)
26
27
28
29func configure_profiles(next_profiles:Array) -> void:
30 profiles.clear()
31 for profile in next_profiles:
32 if profile is Dictionary:
33 var id:String = str(profile.get("id", ""))
34 if id != "":
35 profiles[id] = profile.duplicate(true)
36
37
38
39func get_cache_stats() -> Dictionary:
40 return data_cache_manager.get_cache_stats()
41
42
43
44func get_profile(profile_id:String, overrides:Dictionary = {}) -> Dictionary:
45 var profile:Dictionary = profiles.get(profile_id, {}).duplicate(true)
46 for key in overrides.keys():
47 profile[key] = overrides[key]
48 return profile
49
50
51
52func nearest_target(actor:Dictionary, targets:Array, options:Dictionary = {}) -> Dictionary:
53 var origin:Vector2 = _position(actor)
54 var best:Dictionary = {}
55 var best_distance:float = INF
56 for target in targets:
57 if not target is Dictionary:
58 continue
59 if bool(options.get("alive_only", true)) and not bool(target.get("alive", true)):
60 continue
61 var distance:float = origin.distance_to(_position(target))
62 if distance < best_distance:
63 best_distance = distance
64 best = target
65 return best.duplicate(true)
66
67
68
69func choose_paddle_action(paddle:Dictionary, balls:Array, profile:Dictionary = {}) -> Dictionary:
70 var incoming_only:bool = bool(profile.get("incoming_only", true))
71 var side:String = str(profile.get("side", paddle.get("side", "right")))
72 var candidates:Array = []
73 for ball in balls:
74 if not ball is Dictionary:
75 continue
76 var velocity:Vector2 = _vector(ball.get("velocity", Vector2.ZERO))
77 if incoming_only:
78 if side == "right" and velocity.x <= 0.0:
79 continue
80 if side == "left" and velocity.x >= 0.0:
81 continue
82 candidates.append(ball)
83 if candidates.is_empty():
84 candidates = balls.duplicate(true)
85 var target:Dictionary = nearest_target(paddle, candidates, {"alive_only": false})
86 var paddle_y:float = _position(paddle).y
87 var target_y:float = _position(target).y if not target.is_empty() else paddle_y
88 var deadzone:float = float(profile.get("deadzone", 6.0))
89 var direction:int = 0
90 if target_y < paddle_y - deadzone:
91 direction = -1
92 elif target_y > paddle_y + deadzone:
93 direction = 1
94 return {
95 "type": "move_axis",
96 "axis": "vertical",
97 "direction": direction,
98 "target_id": str(target.get("id", "")),
99 "confidence": float(profile.get("difficulty", 0.6))
100 }
101
102
103
104func choose_pong_paddle_decision(paddle:Dictionary, balls:Array, playfield:Rect2, profile:Dictionary = {}) -> Dictionary:
105 var difficulty:float = clampf(float(profile.get("difficulty", 0.6)), 0.0, 1.0)
106 var target:Dictionary = _nearest_arcade_ball(paddle, balls, profile)
107 var paddle_position:Vector2 = _position(paddle)
108 var velocity:Vector2 = _vector(target.get("velocity", Vector2.ZERO)) if not target.is_empty() else Vector2.ZERO
109 var ball_position:Vector2 = _position(target) if not target.is_empty() else playfield.get_center()
110 var side:String = str(profile.get("side", paddle.get("side", "right")))
111 var approaching:bool = (side == "right" and velocity.x > 0.0) or (side == "left" and velocity.x < 0.0)
112 var vertical_margin:float = float(profile.get("vertical_margin", 38.0))
113 var target_y:float = playfield.get_center().y
114 var update_delay:float = float(profile.get("idle_update_delay", 0.42))
115 if approaching:
116 var lead_min:float = float(profile.get("lead_time_min", 0.1))
117 var lead_max:float = float(profile.get("lead_time_max", 0.24))
118 var lead_time:float = lerpf(lead_min, lead_max, difficulty)
119 var low_error:float = float(profile.get("tracking_error_low", -34.0))
120 var high_error:float = float(profile.get("tracking_error_high", 24.0))
121 if difficulty < float(profile.get("wide_error_below", 0.4)):
122 low_error = float(profile.get("easy_tracking_error_low", -44.0))
123 high_error = float(profile.get("easy_tracking_error_high", 10.0))
124 var error_scale:float = lerpf(float(profile.get("error_scale_min_difficulty", 1.25)), float(profile.get("error_scale_max_difficulty", 0.35)), difficulty)
125 var tracking_error:float = randf_range(low_error, high_error) * error_scale
126 target_y = ball_position.y + velocity.y * lead_time + tracking_error
127 update_delay = lerpf(float(profile.get("approach_update_delay_slow", 0.24)), float(profile.get("approach_update_delay_fast", 0.11)), difficulty)
128 else:
129 var idle_wander:float = float(profile.get("idle_wander", 18.0))
130 target_y = playfield.get_center().y + randf_range(-idle_wander, idle_wander)
131 target_y = clampf(target_y, playfield.position.y + vertical_margin, playfield.end.y - vertical_margin)
132 var deadzone:float = lerpf(float(profile.get("approach_deadzone_low", 18.0)), float(profile.get("approach_deadzone_high", 6.0)), difficulty) if approaching else float(profile.get("idle_deadzone", 42.0))
133 var delta_to_target:float = target_y - paddle_position.y
134 var direction:float = 0.0 if abs(delta_to_target) < deadzone else sign(delta_to_target)
135 return {
136 "type": "move_axis",
137 "axis": "vertical",
138 "direction": direction,
139 "target_y": target_y,
140 "target_id": str(target.get("id", "")),
141 "approaching": approaching,
142 "update_delay": update_delay,
143 "deadzone": deadzone,
144 "confidence": difficulty
145 }
146
147
148
149func should_use_pong_powerup(actor:Dictionary, ball:Dictionary, playfield:Rect2, profile:Dictionary = {}) -> bool:
150 if ball.is_empty():
151 return false
152 var side:String = str(profile.get("side", actor.get("side", "right")))
153 var owner_required:String = str(profile.get("powerup_owner", actor.get("id", "computer")))
154 if str(ball.get("owner", "")) != owner_required:
155 return false
156 var velocity:Vector2 = _vector(ball.get("velocity", Vector2.ZERO))
157 if side == "right" and velocity.x >= 0.0:
158 return false
159 if side == "left" and velocity.x <= 0.0:
160 return false
161 var ball_position:Vector2 = _position(ball)
162 var progress:float = _arcade_ball_progress(ball_position, playfield, side)
163 var action_profile:Dictionary = profile.duplicate(true)
164 action_profile["require_after_hit"] = bool(profile.get("require_after_hit", true))
165 action_profile["min_progress"] = float(profile.get("min_progress", 0.45))
166 return should_use_action(actor, {"after_hit": true, "progress": progress}, action_profile)
167
168
169
170func choose_grid_action(actor:Dictionary, targets:Array, world:Dictionary = {}, profile:Dictionary = {}) -> Dictionary:
171 var target:Dictionary = nearest_target(actor, targets)
172 if target.is_empty():
173 return {"type": "wait", "reason": "no_target"}
174 var actor_cell:Vector2i = _cell(actor.get("cell", [0, 0]))
175 var target_cell:Vector2i = _cell(target.get("cell", [0, 0]))
176 var distance:int = abs(actor_cell.x - target_cell.x) + abs(actor_cell.y - target_cell.y)
177 var attack_range:int = int(profile.get("attack_range", actor.get("attack_range", 1)))
178 if distance <= attack_range:
179 return {
180 "type": "attack",
181 "target_id": str(target.get("id", "")),
182 "from": actor_cell,
183 "target": target_cell,
184 "damage": int(profile.get("damage", actor.get("attack", 1)))
185 }
186 var behavior:String = str(profile.get("behavior", actor.get("behavior", "chase")))
187 if behavior == "flee":
188 return _move_action(actor_cell, _step_away(actor_cell, target_cell, world), "flee", target)
189 if behavior == "patrol":
190 var patrol = profile.get("patrol", actor.get("patrol", []))
191 if patrol is Array and not patrol.is_empty():
192 return _move_action(actor_cell, _next_patrol_cell(actor_cell, patrol, world), "patrol", target)
193 if behavior == "wander":
194 return _move_action(actor_cell, _first_open_neighbor(actor_cell, world), "wander", target)
195 return _move_action(actor_cell, _step_toward(actor_cell, target_cell, world), "chase", target)
196
197
198
199func should_use_action(actor:Dictionary, context:Dictionary = {}, profile:Dictionary = {}) -> bool:
200 var chance:float = float(profile.get("use_chance", actor.get("use_chance", 0.0)))
201 if bool(profile.get("require_after_hit", false)) and not bool(context.get("after_hit", false)):
202 return false
203 if profile.has("min_progress") and float(context.get("progress", 0.0)) < float(profile.get("min_progress", 0.0)):
204 return false
205 return randf() <= clamp(chance, 0.0, 1.0)
206
207
208func _move_action(from_cell:Vector2i, to_cell:Vector2i, reason:String, target:Dictionary) -> Dictionary:
209 if to_cell == from_cell:
210 return {"type": "wait", "reason": reason, "target_id": str(target.get("id", ""))}
211 return {
212 "type": "move",
213 "from": from_cell,
214 "to": to_cell,
215 "reason": reason,
216 "target_id": str(target.get("id", ""))
217 }
218
219
220func _step_toward(from_cell:Vector2i, target_cell:Vector2i, world:Dictionary) -> Vector2i:
221 var options:Array = []
222 if target_cell.x != from_cell.x:
223 options.append(from_cell + Vector2i(sign(target_cell.x - from_cell.x), 0))
224 if target_cell.y != from_cell.y:
225 options.append(from_cell + Vector2i(0, sign(target_cell.y - from_cell.y)))
226 return _first_open(options, from_cell, world)
227
228
229func _step_away(from_cell:Vector2i, target_cell:Vector2i, world:Dictionary) -> Vector2i:
230 var options:Array = []
231 if target_cell.x != from_cell.x:
232 options.append(from_cell - Vector2i(sign(target_cell.x - from_cell.x), 0))
233 if target_cell.y != from_cell.y:
234 options.append(from_cell - Vector2i(0, sign(target_cell.y - from_cell.y)))
235 return _first_open(options, from_cell, world)
236
237
238func _next_patrol_cell(from_cell:Vector2i, patrol:Array, world:Dictionary) -> Vector2i:
239 for raw_cell in patrol:
240 var next:Vector2i = _cell(raw_cell)
241 if next != from_cell and _cell_open(next, world):
242 return next
243 return from_cell
244
245
246func _first_open_neighbor(from_cell:Vector2i, world:Dictionary) -> Vector2i:
247 return _first_open([
248 from_cell + Vector2i.RIGHT,
249 from_cell + Vector2i.LEFT,
250 from_cell + Vector2i.DOWN,
251 from_cell + Vector2i.UP
252 ], from_cell, world)
253
254
255func _first_open(options:Array, fallback:Vector2i, world:Dictionary) -> Vector2i:
256 for cell in options:
257 if _cell_open(cell, world):
258 return cell
259 return fallback
260
261
262func _cell_open(cell:Vector2i, world:Dictionary) -> bool:
263 var bounds = world.get("bounds", Rect2(Vector2.ZERO, Vector2(99999, 99999)))
264 if bounds is Rect2:
265 var tile_size:float = float(world.get("tile_size", 1.0))
266 var center:Vector2 = Vector2(float(cell.x) * tile_size + tile_size * 0.5, float(cell.y) * tile_size + tile_size * 0.5)
267 if not bounds.has_point(center):
268 return false
269 for blocked in world.get("blocked_cells", []):
270 if _cell(blocked) == cell:
271 return false
272 return true
273
274
275func _position(value) -> Vector2:
276 if value is Dictionary:
277 if value.has("position"):
278 return _vector(value.get("position"))
279 if value.has("cell"):
280 var cell:Vector2i = _cell(value.get("cell"))
281 return Vector2(cell.x, cell.y)
282 return _vector(value)
283
284
285func _nearest_arcade_ball(paddle:Dictionary, balls:Array, profile:Dictionary = {}) -> Dictionary:
286 var candidates:Array = []
287 var incoming_only:bool = bool(profile.get("incoming_only", true))
288 var side:String = str(profile.get("side", paddle.get("side", "right")))
289 for ball in balls:
290 if not ball is Dictionary:
291 continue
292 var velocity:Vector2 = _vector(ball.get("velocity", Vector2.ZERO))
293 if incoming_only:
294 if side == "right" and velocity.x <= 0.0:
295 continue
296 if side == "left" and velocity.x >= 0.0:
297 continue
298 candidates.append(ball)
299 if candidates.is_empty():
300 candidates = balls.duplicate(true)
301 var best:Dictionary = {}
302 var best_score:float = INF
303 var paddle_position:Vector2 = _position(paddle)
304 for candidate in candidates:
305 if not candidate is Dictionary:
306 continue
307 var position:Vector2 = _position(candidate)
308 var velocity:Vector2 = _vector(candidate.get("velocity", Vector2.ZERO))
309 var approaching_penalty:float = 0.0
310 if side == "right" and velocity.x <= 0.0:
311 approaching_penalty = 9999.0
312 if side == "left" and velocity.x >= 0.0:
313 approaching_penalty = 9999.0
314 var score:float = abs(paddle_position.x - position.x) + approaching_penalty
315 if score < best_score:
316 best_score = score
317 best = candidate
318 return best.duplicate(true)
319
320
321func _arcade_ball_progress(ball_position:Vector2, playfield:Rect2, side:String) -> float:
322 if playfield.size.x <= 0.0:
323 return 0.0
324 var normalized:float = clampf((ball_position.x - playfield.position.x) / playfield.size.x, 0.0, 1.0)
325 if side == "right":
326 return 1.0 - normalized
327 return normalized
328
329
330func _vector(value) -> Vector2:
331 if value is Vector2:
332 return value
333 if value is Vector2i:
334 return Vector2(value.x, value.y)
335 if value is Array and value.size() >= 2:
336 return Vector2(float(value[0]), float(value[1]))
337 if value is Dictionary and value.has("x") and value.has("y"):
338 return Vector2(float(value.get("x", 0.0)), float(value.get("y", 0.0)))
339 return Vector2.ZERO
340
341
342func _cell(value) -> Vector2i:
343 if value is Vector2i:
344 return value
345 if value is Vector2:
346 return Vector2i(int(value.x), int(value.y))
347 if value is Array and value.size() >= 2:
348 return Vector2i(int(value[0]), int(value[1]))
349 if value is Dictionary and value.has("cell"):
350 return _cell(value.get("cell"))
351 if value is Dictionary and value.has("x") and value.has("y"):
352 return Vector2i(int(value.get("x", 0)), int(value.get("y", 0)))
353 return Vector2i.ZERO