In Module 3 you built a state machine where ENEMY_TURN did enemy-things in a single synchronous call. That works for one enemy with one action. When an enemy plays a card, triggers a status effect, deals damage over time, and draws a reply โ all with visible animations โ you need an action queue. Actions are data. They're queued, resolved one at a time, animated between.
Because animations take time. Consider:
func enemy_turn() -> void:
enemy.play_card(card_a) # should animate for 0.4s
enemy.play_card(card_b) # should NOT start until card_a finishes
player.take_damage(3)
apply_bleed()
end_turn()
Without awaits, these all fire in one frame โ animations overlap, state gets confused, the player sees garbage. You can sprinkle awaits everywhere, but then every new action has to know about timing. Cleaner: make actions a queue, let a single worker drain them.
# scripts/action.gd โ base class
class_name Action
extends RefCounted
func execute() -> void:
push_error("Action subclass did not override execute()")
# Override this if you need post-animation async.
# Return true to indicate "wait until some signal fires".
# Or just call await inside execute() and the queue will wait.
# scripts/actions/damage_action.gd
class_name DamageAction
extends Action
var target: Node
var amount: int
var source_name: String
func _init(t: Node, a: int, src: String = "") -> void:
target = t
amount = a
source_name = src
func execute() -> void:
target.take_damage(amount)
Events.damage_dealt.emit(target, amount)
# take_damage internally plays the hit animation and awaits it
if target.has_method("await_hit_animation"):
await target.await_hit_animation()
# scripts/actions/draw_action.gd
class_name DrawAction
extends Action
var player: Node
var count: int
func _init(p: Node, c: int) -> void:
player = p; count = c
func execute() -> void:
for i in count:
player.draw_one()
await player.get_tree().create_timer(0.08).timeout # staggered visual draw
# scripts/action_queue.gd โ the worker
class_name ActionQueue
extends RefCounted
signal idle
var _queue: Array[Action] = []
var _running := false
func enqueue(a: Action) -> void:
_queue.append(a)
if not _running:
_run_next()
func _run_next() -> void:
if _queue.is_empty():
_running = false
idle.emit()
return
_running = true
var action := _queue.pop_front() as Action
await action.execute()
_run_next()
Usage:
var queue := ActionQueue.new()
queue.enqueue(DamageAction.new(player, 3, "Enemy"))
queue.enqueue(DrawAction.new(player, 2))
queue.enqueue(DamageAction.new(player, 1, "Bleed"))
await queue.idle # wait until all resolved
Every action resolves in order. Each can await anything it needs. The rest of the system only needs to wait for the idle signal.
Promise.resolve().then(a).then(b).then(c), but with the ability to enqueue new items mid-flight โ more like a RabbitMQ worker on a single thread.An enemy's "Chain Lightning" deals damage, then triggers an extra strike. The damage action knows the extra strike:
class_name ChainLightningAction
extends Action
var targets: Array[Node]
var amount: int
func execute() -> void:
for t in targets:
await DamageAction.new(t, amount).execute()
amount = max(1, amount - 1) # falloff
Or, more idiomatically, the parent action enqueues children on the same queue:
func execute() -> void:
for t in targets:
queue.enqueue(DamageAction.new(t, amount))
amount = max(1, amount - 1)
Both are fine. The inline version is easier to reason about; the enqueue version lets interrupts slip in between sub-actions. For Lexicon Duel, inline is plenty.
"Bleed 2" means: at the start of the target's turn, take 2 damage and decrement stacks. Trivially modeled:
# On turn start
for effect in target.status_effects:
queue.enqueue(effect.on_turn_start(target))
class_name BleedEffect
extends StatusEffect
var stacks: int
func on_turn_start(target: Node) -> Action:
var action := DamageAction.new(target, stacks, "Bleed")
stacks -= 1 # decay one stack per turn
return action
Status effects are Resources (remember Module 3 โ data-driven design). The gameplay system iterates them and enqueues the actions they return.
"When you play a word, enemy takes 1 extra damage from Weakness." This is a reaction โ the enemy-hp-mod happens right after the main damage, before anything else in the queue.
# When an action completes, let observers inject
signal action_finished(a: Action)
func _run_next() -> void:
if _queue.is_empty(): _running = false; idle.emit(); return
_running = true
var action := _queue.pop_front() as Action
await action.execute()
action_finished.emit(action)
_run_next()
A reaction system listens to action_finished, checks whether it matches a trigger, and enqueues the reaction at the front of the queue (_queue.push_front) so it resolves before the next pending action.
This gets complicated fast. If you find yourself building a full Magic: the Gathering stack for Lexicon Duel, stop โ you're over-engineering. For v1, status effects that tick on turn-start are enough depth.
Minimal turn structure:
Every bullet above is one Action. Your DuelController stays simple โ it enqueues actions and waits for idle before transitioning.
scripts/action.gd, scripts/action_queue.gd per the patterns above.DamageAction, DrawAction subclasses.DuelController._on_state_entered(ENEMY_TURN) to enqueue a DamageAction instead of directly mutating player_hp. Await queue.idle, then transition.