RapidGameFramework
Reusable Godot managers for data-driven small games
Loading...
Searching...
No Matches
grid_combat_manager.gd
Go to the documentation of this file.
1extends RefCounted
2
3
6
7const DataCacheManager = preload("res://scripts/systems/dataCacheManager/data_cache_manager.gd")
8
9var enemies := []
10var damage_log := []
11var archetypes := {}
12var active_projectiles := []
13var floating_numbers := []
14var action_log := []
15var data_cache_manager = DataCacheManager.new()
16
17
18func load_archetypes(file_path:String) -> void:
19 archetypes.clear()
20 load_archetype_data(data_cache_manager.load_json(file_path))
21
22
23func get_cache_stats() -> Dictionary:
24 return data_cache_manager.get_cache_stats()
25
26
27func load_archetype_data(data) -> void:
28 archetypes.clear()
29 if not data is Dictionary:
30 return
31 for archetype in data.get("archetypes", []):
32 if not archetype is Dictionary:
33 continue
34 var id = str(archetype.get("id", ""))
35 if id != "":
36 archetypes[id] = archetype.duplicate(true)
37
38
39func configure(next_enemies:Array) -> void:
40 enemies.clear()
41 damage_log.clear()
42 action_log.clear()
43 active_projectiles.clear()
44 floating_numbers.clear()
45 for enemy in next_enemies:
46 if not enemy is Dictionary:
47 continue
48 var entry = _with_archetype(enemy)
49 entry["hp"] = int(entry.get("hp", 1))
50 entry["max_hp"] = int(entry.get("max_hp", entry.get("hp", 1)))
51 entry["alive"] = int(entry.get("hp", 1)) > 0
52 entry["cooldown_remaining"] = int(entry.get("cooldown_remaining", 0))
53 entry["alerted"] = bool(entry.get("alerted", false))
54 enemies.append(entry)
55
56
57func step(delta:float) -> void:
58 _tick_timed(active_projectiles, delta)
59 _tick_timed(floating_numbers, delta)
60
61
62func resolve_card(card:Dictionary, affected_cells:Array, origin_cell := Vector2i.ZERO, direction := Vector2i.ZERO) -> Dictionary:
63 var damage = int(card.get("damage", 0))
64 var hits := []
65 for index in range(enemies.size()):
66 var enemy = enemies[index]
67 if not bool(enemy.get("alive", true)):
68 continue
69 var enemy_cell = _cell_from_value(enemy.get("cell", [0, 0]))
70 if affected_cells.has(enemy_cell):
71 var dealt = max(0, damage - int(enemy.get("defense", 0)))
72 enemy["hp"] = max(0, int(enemy.get("hp", 0)) - dealt)
73 enemy["alive"] = int(enemy.get("hp", 0)) > 0
74 enemy["alerted"] = true
75 enemies[index] = enemy
76 var hit = {
77 "enemy_id": str(enemy.get("id", "")),
78 "name": str(enemy.get("name", "Enemy")),
79 "damage": dealt,
80 "remaining_hp": int(enemy.get("hp", 0)),
81 "defeated": not bool(enemy.get("alive", true)),
82 "cell": enemy_cell
83 }
84 hits.append(hit)
85 damage_log.append(hit.duplicate(true))
86 floating_numbers.append({
87 "text": "-%d" % dealt,
88 "cell": enemy_cell,
89 "age": 0.0,
90 "duration": 0.9,
91 "color": "#ffdf6b"
92 })
93 _spawn_projectile(card, affected_cells, origin_cell, direction, hits)
94 return {
95 "card_id": str(card.get("id", "")),
96 "hits": hits,
97 "hit": not hits.is_empty(),
98 "affected_cells": affected_cells.duplicate()
99 }
100
101
102func take_enemy_turn(player_cell:Vector2i, bounds:Rect2, blocked_cells:Array, config := {}) -> Dictionary:
103 var actions := []
104 var total_damage := 0
105 for index in range(enemies.size()):
106 var enemy = enemies[index]
107 if not bool(enemy.get("alive", true)):
108 continue
109 if int(enemy.get("cooldown_remaining", 0)) > 0:
110 enemy["cooldown_remaining"] = int(enemy.get("cooldown_remaining", 0)) - 1
111 enemies[index] = enemy
112 continue
113 var action = _enemy_action(enemy, player_cell, bounds, blocked_cells, config)
114 if action.is_empty():
115 continue
116 if str(action.get("type", "")) == "move":
117 enemy["cell"] = action.get("to", enemy.get("cell", [0, 0]))
118 enemies[index] = enemy
119 elif str(action.get("type", "")) == "attack":
120 total_damage += int(action.get("damage", 0))
121 floating_numbers.append({
122 "text": "-%d" % int(action.get("damage", 0)),
123 "cell": player_cell,
124 "age": 0.0,
125 "duration": 0.9,
126 "color": "#ff7777"
127 })
128 active_projectiles.append({
129 "card_id": "enemy_attack",
130 "type": str(action.get("attack_type", "melee")),
131 "shape": str(action.get("attack_type", "melee")),
132 "color": str(action.get("color", "#ff7777")),
133 "origin_cell": action.get("from", player_cell),
134 "end_cell": player_cell,
135 "direction": Vector2i.ZERO,
136 "affected_cells": [player_cell],
137 "age": 0.0,
138 "duration": 0.28
139 })
140 enemy["cooldown_remaining"] = _cooldown_for(enemy)
141 enemies[index] = enemy
142 actions.append(action)
143 action_log.append(action.duplicate(true))
144 return {
145 "actions": actions,
146 "damage": total_damage
147 }
148
149
150func get_enemies() -> Array:
151 return enemies.duplicate(true)
152
153
154func get_alive_enemies() -> Array:
155 var alive := []
156 for enemy in enemies:
157 if bool(enemy.get("alive", true)):
158 alive.append(enemy.duplicate(true))
159 return alive
160
161
162func get_active_projectiles() -> Array:
163 return active_projectiles.duplicate(true)
164
165
166func get_floating_numbers() -> Array:
167 return floating_numbers.duplicate(true)
168
169
170func get_damage_log() -> Array:
171 return damage_log.duplicate(true)
172
173
174func get_action_log() -> Array:
175 return action_log.duplicate(true)
176
177
178func get_primary_enemy() -> Dictionary:
179 for enemy in enemies:
180 if bool(enemy.get("alive", true)):
181 return enemy.duplicate(true)
182 if enemies.is_empty():
183 return {}
184 return enemies[0].duplicate(true)
185
186
187func get_summary() -> Dictionary:
188 var alive := 0
189 var defeated := 0
190 for enemy in enemies:
191 if bool(enemy.get("alive", true)):
192 alive += 1
193 else:
194 defeated += 1
195 return {
196 "enemy_count": enemies.size(),
197 "alive": alive,
198 "defeated": defeated,
199 "damage_events": damage_log.size(),
200 "actions": action_log.size()
201 }
202
203
204
205func collect_defeat_rewards(hits:Array) -> Dictionary:
206 var gained := {}
207 var enemy_lookup := {}
208 for enemy in enemies:
209 if enemy is Dictionary:
210 enemy_lookup[str(enemy.get("id", ""))] = enemy
211 for hit in hits:
212 if not hit is Dictionary or not bool(hit.get("defeated", false)):
213 continue
214 var enemy_id = str(hit.get("enemy_id", ""))
215 var enemy = enemy_lookup.get(enemy_id, {})
216 for drop in enemy.get("drops", []):
217 if not drop is Dictionary:
218 continue
219 var chance = float(drop.get("chance", 1.0))
220 if chance < 1.0 and randf() > chance:
221 continue
222 var id = str(drop.get("id", ""))
223 var amount = int(drop.get("amount", 1))
224 if id == "" or amount <= 0:
225 continue
226 gained[id] = int(gained.get(id, 0)) + amount
227 return gained
228
229
230func get_state() -> Dictionary:
231 return {
232 "enemies": enemies.duplicate(true),
233 "damage_log": damage_log.duplicate(true),
234 "action_log": action_log.duplicate(true),
235 "active_projectiles": active_projectiles.duplicate(true),
236 "floating_numbers": floating_numbers.duplicate(true)
237 }
238
239
240func apply_state(state:Dictionary) -> void:
241 enemies = state.get("enemies", []).duplicate(true)
242 damage_log = state.get("damage_log", []).duplicate(true)
243 action_log = state.get("action_log", []).duplicate(true)
244 active_projectiles = state.get("active_projectiles", []).duplicate(true)
245 floating_numbers = state.get("floating_numbers", []).duplicate(true)
246
247
248func _with_archetype(enemy:Dictionary) -> Dictionary:
249 var archetype_id = str(enemy.get("archetype", ""))
250 var entry := {}
251 if archetypes.has(archetype_id):
252 entry = archetypes[archetype_id].duplicate(true)
253 for key in enemy.keys():
254 entry[key] = enemy[key]
255 if not entry.has("max_hp") and entry.has("hp"):
256 entry["max_hp"] = entry["hp"]
257 return entry
258
259
260func _spawn_projectile(card:Dictionary, affected_cells:Array, origin_cell:Vector2i, direction:Vector2i, hits:Array) -> void:
261 var targeting = card.get("targeting", {})
262 var visual = card.get("visual", {})
263 var type = str(targeting.get("type", "projectile"))
264 var duration = float(visual.get("duration", 0.25))
265 var end_cell = origin_cell
266 if not hits.is_empty():
267 end_cell = _cell_from_value(hits[0].get("cell", origin_cell))
268 elif not affected_cells.is_empty():
269 end_cell = _cell_from_value(affected_cells[affected_cells.size() - 1])
270 active_projectiles.append({
271 "card_id": str(card.get("id", "")),
272 "type": type,
273 "shape": str(visual.get("shape", type)),
274 "color": str(visual.get("color", "#ffffff")),
275 "origin_cell": origin_cell,
276 "end_cell": end_cell,
277 "direction": direction,
278 "affected_cells": affected_cells.duplicate(),
279 "age": 0.0,
280 "duration": max(0.05, duration)
281 })
282
283
284func _enemy_action(enemy:Dictionary, player_cell:Vector2i, bounds:Rect2, blocked_cells:Array, config:Dictionary) -> Dictionary:
285 var enemy_cell = _cell_from_value(enemy.get("cell", [0, 0]))
286 var behavior = enemy.get("behavior", {})
287 var behavior_type = str(behavior.get("type", "guard"))
288 var distance = _grid_distance(enemy_cell, player_cell)
289 var attack_range = int(behavior.get("range", 1))
290 var aggro_range = int(behavior.get("aggro_range", attack_range))
291 var alerted = bool(enemy.get("alerted", false)) or distance <= aggro_range
292 if alerted and not bool(enemy.get("alerted", false)):
293 enemy["alerted"] = true
294 if not alerted:
295 return _wander_action(enemy, enemy_cell, bounds, blocked_cells, config)
296 if behavior_type == "ranged" and _has_line(enemy_cell, player_cell, attack_range, blocked_cells):
297 return _attack_action(enemy, enemy_cell, player_cell, "beam")
298 if distance <= attack_range and behavior_type != "ranged":
299 return _attack_action(enemy, enemy_cell, player_cell, "melee")
300 if behavior_type == "chase" or behavior_type == "ranged":
301 var move_distance = int(behavior.get("move_distance", 1))
302 if move_distance <= 0:
303 return {}
304 var next_cell = _step_toward(enemy_cell, player_cell, bounds, blocked_cells, config)
305 if next_cell != enemy_cell:
306 return {
307 "type": "move",
308 "enemy_id": str(enemy.get("id", "")),
309 "name": str(enemy.get("name", "Enemy")),
310 "from": enemy_cell,
311 "to": next_cell
312 }
313 return {}
314
315
316func _wander_action(enemy:Dictionary, enemy_cell:Vector2i, bounds:Rect2, blocked_cells:Array, config:Dictionary) -> Dictionary:
317 var behavior = enemy.get("behavior", {})
318 if not bool(behavior.get("wander", true)):
319 return {}
320 var move_distance = int(behavior.get("move_distance", 1))
321 if move_distance <= 0:
322 return {}
323 var directions = [Vector2i.LEFT, Vector2i.RIGHT, Vector2i.UP, Vector2i.DOWN]
324 var start_index = randi() % directions.size()
325 for offset in range(directions.size()):
326 var direction = directions[(start_index + offset) % directions.size()]
327 var candidate = enemy_cell + direction
328 if not _cell_blocked(candidate, bounds, blocked_cells, config, enemy_cell):
329 return {
330 "type": "move",
331 "mode": "wander",
332 "enemy_id": str(enemy.get("id", "")),
333 "name": str(enemy.get("name", "Enemy")),
334 "from": enemy_cell,
335 "to": candidate
336 }
337 return {}
338
339
340func _attack_action(enemy:Dictionary, enemy_cell:Vector2i, player_cell:Vector2i, attack_type:String) -> Dictionary:
341 return {
342 "type": "attack",
343 "attack_type": attack_type,
344 "enemy_id": str(enemy.get("id", "")),
345 "name": str(enemy.get("name", "Enemy")),
346 "from": enemy_cell,
347 "target": player_cell,
348 "damage": int(enemy.get("attack", 1)),
349 "color": str(enemy.get("color", "#ff7777"))
350 }
351
352
353func _cooldown_for(enemy:Dictionary) -> int:
354 var behavior = enemy.get("behavior", {})
355 if behavior is Dictionary:
356 return max(0, int(behavior.get("cooldown_turns", 0)))
357 return 0
358
359
360func _step_toward(from_cell:Vector2i, target_cell:Vector2i, bounds:Rect2, blocked_cells:Array, config:Dictionary) -> Vector2i:
361 var candidates := []
362 var delta = target_cell - from_cell
363 if abs(delta.x) >= abs(delta.y):
364 candidates.append(Vector2i(sign(delta.x), 0))
365 candidates.append(Vector2i(0, sign(delta.y)))
366 else:
367 candidates.append(Vector2i(0, sign(delta.y)))
368 candidates.append(Vector2i(sign(delta.x), 0))
369 for fallback in [Vector2i.LEFT, Vector2i.RIGHT, Vector2i.UP, Vector2i.DOWN]:
370 if not candidates.has(fallback):
371 candidates.append(fallback)
372 for direction in candidates:
373 if direction == Vector2i.ZERO:
374 continue
375 var candidate = from_cell + direction
376 if candidate == target_cell:
377 continue
378 if not _cell_blocked(candidate, bounds, blocked_cells, config, from_cell):
379 return candidate
380 return from_cell
381
382
383func _cell_blocked(cell:Vector2i, bounds:Rect2, blocked_cells:Array, config:Dictionary, moving_from:Vector2i) -> bool:
384 var tile_size = max(1.0, float(config.get("tile_size", 40.0)))
385 var world_position = Vector2(float(cell.x) * tile_size + tile_size * 0.5, float(cell.y) * tile_size + tile_size * 0.5)
386 if not bounds.has_point(world_position):
387 return true
388 for blocked in blocked_cells:
389 if _cell_from_value(blocked) == cell:
390 return true
391 for enemy in enemies:
392 if not bool(enemy.get("alive", true)):
393 continue
394 var enemy_cell = _cell_from_value(enemy.get("cell", [0, 0]))
395 if enemy_cell != moving_from and enemy_cell == cell:
396 return true
397 return false
398
399
400func _has_line(from_cell:Vector2i, target_cell:Vector2i, max_range:int, blocked_cells:Array) -> bool:
401 var delta = target_cell - from_cell
402 if delta.x != 0 and delta.y != 0:
403 return false
404 var distance = _grid_distance(from_cell, target_cell)
405 if distance > max_range:
406 return false
407 var direction = Vector2i(sign(delta.x), sign(delta.y))
408 var current = from_cell
409 for _index in range(distance - 1):
410 current += direction
411 if blocked_cells.has(current):
412 return false
413 return true
414
415
416func _grid_distance(a:Vector2i, b:Vector2i) -> int:
417 return abs(a.x - b.x) + abs(a.y - b.y)
418
419
420func _tick_timed(items:Array, delta:float) -> void:
421 for index in range(items.size() - 1, -1, -1):
422 var item = items[index]
423 item["age"] = float(item.get("age", 0.0)) + delta
424 items[index] = item
425 if float(item.get("age", 0.0)) >= float(item.get("duration", 0.1)):
426 items.remove_at(index)
427
428
429func _cell_from_value(value) -> Vector2i:
430 if value is Vector2i:
431 return value
432 if value is Vector2:
433 return Vector2i(int(value.x), int(value.y))
434 if value is Array and value.size() >= 2:
435 return Vector2i(int(value[0]), int(value[1]))
436 return Vector2i.ZERO
437
438