Files
aetherbound-guild/runtime/p10_1/motion_presenter.gd
T

346 lines
13 KiB
GDScript

class_name P101MotionPresenter
extends RefCounted
const MAX_EFFECTS := 8
const ACTION_DURATION := 0.66
const HIT_DURATION := 0.46
const PULL_DURATION := 0.58
const TIDE_DURATION := 0.72
const WAVE_DURATION := 0.70
const EFFECT_DURATION := 0.78
const HIT_STOP_DURATION := 0.07
var actor_order: Array = []
var current_safe_index := 0
var initial_safe_index := 0
var visible_enemy_ids: Array[String] = []
var actor_actions: Dictionary = {}
var enemy_actions: Dictionary = {}
var fallen_ids: Dictionary = {}
var effects: Array[Dictionary] = []
var pull_from_slots: Dictionary = {}
var pull_started := -100.0
var tide_offset_from := 0.0
var tide_offset_to := 0.0
var tide_started := -100.0
var healing_denied_target := ""
var healing_denial_source := ""
var hindrance_target := ""
var mud_index := -1
var terminal_outcome := ""
var hit_stop_until := -100.0
var trauma_started := -100.0
var trauma_strength := 0.0
func reset(initial_order: Array, safe_index: int) -> void:
actor_order = initial_order.duplicate()
current_safe_index = safe_index
initial_safe_index = safe_index
visible_enemy_ids.clear()
actor_actions.clear()
enemy_actions.clear()
fallen_ids.clear()
effects.clear()
pull_from_slots.clear()
pull_started = -100.0
tide_offset_from = 0.0
tide_offset_to = 0.0
tide_started = -100.0
healing_denied_target = ""
healing_denial_source = ""
hindrance_target = ""
mud_index = -1
terminal_outcome = ""
hit_stop_until = -100.0
trauma_started = -100.0
trauma_strength = 0.0
func consume(event: Dictionary, now: float) -> Array:
var kind := String(event.get("kind", ""))
_add_effect(event, now)
match kind:
"wave_entered":
for enemy_id in event.get("enemy_ids", []):
var value := String(enemy_id)
if value not in visible_enemy_ids:
visible_enemy_ids.append(value)
enemy_actions[value] = {"kind": "wave_enter", "started": now, "duration": WAVE_DURATION}
"healing_denial_attached":
healing_denied_target = String(event.get("target_id", ""))
healing_denial_source = String(event.get("enemy_id", ""))
_set_enemy_action(healing_denial_source, "telegraph", now, ACTION_DURATION)
"healing_converted_to_enemy_guard", "healing_applied":
_set_actor_action(String(event.get("source_id", "")), "heal", now, ACTION_DURATION)
_set_actor_action(String(event.get("target_id", "")), "blocked" if kind == "healing_converted_to_enemy_guard" else "healed", now, HIT_DURATION)
"timed_hindrance_applied":
hindrance_target = String(event.get("target_id", ""))
_set_enemy_action(String(event.get("enemy_id", "")), "telegraph", now, ACTION_DURATION)
"hindrance_expired":
hindrance_target = ""
"pull_resisted":
_set_actor_action(String(event.get("target_id", "")), "resist", now, HIT_DURATION)
_set_enemy_action(String(event.get("enemy_id", "")), "attack", now, ACTION_DURATION)
_add_trauma(now, 0.22)
"pull_committed":
pull_from_slots = _slot_map(actor_order)
actor_order = Array(event.get("order_after", actor_order)).duplicate()
pull_started = now
_set_actor_action(String(event.get("target_id", "")), "pulled", now, PULL_DURATION)
_set_enemy_action(String(event.get("enemy_id", "")), "attack", now, ACTION_DURATION)
_add_trauma(now, 0.36)
"indexed_bank_marked":
mud_index = int(event.get("position_index", -1))
"indexed_mud_created":
mud_index = int(event.get("crossed_index", -1))
_set_actor_action(String(event.get("target_id", "")), "hindered", now, HIT_DURATION)
"tide_shift_warning":
_add_trauma(now, 0.12)
"tide_followed":
tide_offset_from = tide_offset_to
var next_safe := int(event.get("safe_index", current_safe_index))
tide_offset_to += float(next_safe - current_safe_index) * 38.0
current_safe_index = next_safe
tide_started = now
_add_trauma(now, 0.20)
"tide_shifted_while_holding":
current_safe_index = int(event.get("safe_index", current_safe_index))
for actor_id in event.get("wet_targets", []):
_set_actor_action(String(actor_id), "wet", now, HIT_DURATION)
_add_trauma(now, 0.52)
"allied_basic":
var source_id := String(event.get("actor_id", ""))
_set_actor_action(source_id, "attack", now, ACTION_DURATION)
var enemy_id := front_visible_enemy()
_set_enemy_action(enemy_id, "hit", now + 0.22, HIT_DURATION)
"enemy_basic_damage":
_set_enemy_action(front_visible_enemy(), "attack", now, ACTION_DURATION)
_set_actor_action(String(event.get("target_id", "")), "hit", now + 0.20, HIT_DURATION)
"authoritative_damage":
_set_actor_action(String(event.get("target_id", "")), "hit", now, HIT_DURATION)
hit_stop_until = maxf(hit_stop_until, now + HIT_STOP_DURATION)
_add_trauma(now, 0.44)
"recruit_fallen":
var recruit_id := String(event.get("recruit_id", ""))
fallen_ids[recruit_id] = now
actor_actions.erase(recruit_id)
_add_trauma(now, 0.68)
"battle_terminal":
terminal_outcome = String(event.get("outcome", ""))
healing_denied_target = ""
healing_denial_source = ""
hindrance_target = ""
mud_index = -1
if terminal_outcome == "victory":
for enemy_id in visible_enemy_ids:
enemy_actions[enemy_id] = {"kind": "death", "started": now, "duration": 0.9}
_add_trauma(now, 0.30 if terminal_outcome == "victory" else 0.58)
prune(now)
return actor_order.duplicate()
func actor_visual(actor_id: String, current_slot: int, slot_step: float, now: float, reduced_motion: bool, low_power: bool) -> Dictionary:
var offset := Vector2.ZERO
var scale := Vector2.ONE
var rotation := 0.0
var alpha := 1.0
var state := "idle"
var animation_time := now
if not low_power:
offset.y += sin(now * 4.4 + float(abs(actor_id.hash()) % 9)) * (0.8 if reduced_motion else 2.0)
if now - pull_started < PULL_DURATION and pull_from_slots.has(actor_id):
var from_slot := int(pull_from_slots[actor_id])
var progress := _smoothstep(clampf((now - pull_started) / PULL_DURATION, 0.0, 1.0))
offset.x += lerpf(float(from_slot - current_slot) * slot_step, 0.0, progress)
offset.y -= sin(progress * PI) * (3.0 if reduced_motion else 14.0)
var tide_progress := clampf((now - tide_started) / TIDE_DURATION, 0.0, 1.0)
var tide_offset := lerpf(tide_offset_from, tide_offset_to, _smoothstep(tide_progress))
offset.x += tide_offset * (0.35 if reduced_motion else 1.0)
if fallen_ids.has(actor_id):
var fallen_progress := clampf((now - float(fallen_ids[actor_id])) / 0.62, 0.0, 1.0)
rotation = lerpf(0.0, -1.18, _smoothstep(fallen_progress)) if not reduced_motion else 0.0
offset.y += lerpf(0.0, 22.0, _smoothstep(fallen_progress))
alpha = lerpf(1.0, 0.24, fallen_progress)
state = "fallen"
return _visual(offset, scale, rotation, alpha, state, fallen_progress)
var action: Dictionary = actor_actions.get(actor_id, {})
if not action.is_empty():
var started := float(action.started)
var duration := float(action.duration)
var progress := clampf((now - started) / duration, 0.0, 1.0)
if now >= started and progress < 1.0:
var kind := String(action.kind)
animation_time = maxf(0.0, now - started)
match kind:
"attack":
state = "attack"
offset.x += _contact_curve(progress, 4.0, 24.0, reduced_motion)
rotation = sin(progress * PI) * (0.0 if reduced_motion else 0.08)
"heal", "healed":
state = "heal"
scale = Vector2.ONE * (1.0 + sin(progress * PI) * (0.03 if reduced_motion else 0.10))
offset.y -= sin(progress * PI) * (2.0 if reduced_motion else 8.0)
"hit", "blocked", "wet", "hindered":
state = "hit"
offset.x -= sin(progress * PI) * (3.0 if reduced_motion else 11.0)
offset.y += sin(progress * PI * 3.0) * (1.0 if reduced_motion else 3.0)
scale = Vector2(1.0 + 0.06 * sin(progress * PI), 1.0 - 0.05 * sin(progress * PI))
"resist":
state = "guard"
offset.x -= sin(progress * PI) * (2.0 if reduced_motion else 7.0)
"pulled":
state = "hit"
if terminal_outcome == "victory" and state == "idle":
state = "victory"
scale = Vector2.ONE * 1.04
if not low_power:
offset.y -= abs(sin(now * 3.4 + float(abs(actor_id.hash()) % 7))) * (1.0 if reduced_motion else 5.0)
return _visual(offset, scale, rotation, alpha, state, animation_time)
func enemy_visual(enemy_id: String, now: float, reduced_motion: bool, low_power: bool) -> Dictionary:
if enemy_id not in visible_enemy_ids:
return _visual(Vector2(90, 0), Vector2.ONE, 0.0, 0.0, "idle", now)
var offset := Vector2.ZERO
var scale := Vector2.ONE
var alpha := 1.0
var state := "idle"
var animation_time := now
if not low_power:
offset.y += sin(now * 4.0 + float(abs(enemy_id.hash()) % 11)) * (0.8 if reduced_motion else 2.2)
var action: Dictionary = enemy_actions.get(enemy_id, {})
if not action.is_empty():
var started := float(action.started)
var duration := float(action.duration)
var progress := clampf((now - started) / duration, 0.0, 1.0)
if now >= started and (progress < 1.0 or String(action.kind) == "death"):
animation_time = maxf(0.0, now - started)
match String(action.kind):
"wave_enter":
state = "advance"
offset.x += lerpf(90.0 if not reduced_motion else 16.0, 0.0, _smoothstep(progress))
alpha = clampf(progress * 1.8, 0.0, 1.0)
"telegraph":
state = "telegraph"
scale = Vector2.ONE * (1.0 + sin(progress * PI) * (0.03 if reduced_motion else 0.09))
"attack":
state = "attack"
offset.x -= _contact_curve(progress, 4.0, 22.0, reduced_motion)
"hit":
state = "hit"
offset.x += sin(progress * PI) * (3.0 if reduced_motion else 10.0)
"death":
state = "death"
alpha = lerpf(1.0, 0.16, _smoothstep(progress))
offset.y += lerpf(0.0, 18.0, _smoothstep(progress))
return _visual(offset, scale, 0.0, alpha, state, animation_time)
func front_visible_enemy() -> String:
return "" if visible_enemy_ids.is_empty() else visible_enemy_ids[0]
func active_effects(now: float) -> Array[Dictionary]:
prune(now)
return effects.duplicate(true)
func playback_held(now: float, reduced_motion: bool) -> bool:
return not reduced_motion and now < hit_stop_until
func camera_offset(now: float, reduced_motion: bool, low_power: bool) -> Vector2:
if low_power or reduced_motion or trauma_strength <= 0.0:
return Vector2.ZERO
var age := now - trauma_started
if age < 0.0 or age >= 0.42:
return Vector2.ZERO
var strength := trauma_strength * pow(1.0 - age / 0.42, 2.0)
return Vector2(sin(age * 71.0) * 7.0 * strength, sin(age * 53.0) * 4.0 * strength)
func transient_resting(now: float) -> bool:
prune(now)
if not effects.is_empty() or now < hit_stop_until:
return false
for action in actor_actions.values():
if now < float(action.started) + float(action.duration):
return false
for action in enemy_actions.values():
if String(action.kind) != "death" and now < float(action.started) + float(action.duration):
return false
return true
func prune(now: float) -> void:
effects = effects.filter(func(effect: Dictionary): return now - float(effect.started) < float(effect.duration))
for actor_id in actor_actions.keys():
var action: Dictionary = actor_actions[actor_id]
if now >= float(action.started) + float(action.duration):
actor_actions.erase(actor_id)
for enemy_id in enemy_actions.keys():
var action: Dictionary = enemy_actions[enemy_id]
if String(action.kind) != "death" and now >= float(action.started) + float(action.duration):
enemy_actions.erase(enemy_id)
func _set_actor_action(actor_id: String, kind: String, now: float, duration: float) -> void:
if actor_id.is_empty() or fallen_ids.has(actor_id):
return
actor_actions[actor_id] = {"kind": kind, "started": now, "duration": duration}
func _set_enemy_action(enemy_id: String, kind: String, now: float, duration: float) -> void:
if enemy_id.is_empty():
return
enemy_actions[enemy_id] = {"kind": kind, "started": now, "duration": duration}
func _add_effect(event: Dictionary, now: float) -> void:
var kind := String(event.get("kind", ""))
if kind in ["battle_started", "wave_entered", "allied_basic", "enemy_basic_damage", "healing_denial_attached", "healing_converted_to_enemy_guard", "healing_applied", "pull_committed", "pull_resisted", "indexed_mud_created", "tide_shift_warning", "tide_followed", "tide_shifted_while_holding", "authoritative_damage", "recruit_fallen", "battle_terminal"]:
var effect := event.duplicate(true)
effect["started"] = now
effect["duration"] = 1.05 if kind in ["tide_shift_warning", "battle_terminal"] else EFFECT_DURATION
effects.append(effect)
while effects.size() > MAX_EFFECTS:
effects.pop_front()
func _add_trauma(now: float, amount: float) -> void:
trauma_started = now
trauma_strength = clampf(maxf(trauma_strength * 0.45, amount), 0.0, 1.0)
func _slot_map(order: Array) -> Dictionary:
var result := {}
for index in range(order.size()):
result[String(order[index])] = index
return result
func _contact_curve(progress: float, anticipation: float, contact: float, reduced_motion: bool) -> float:
var scale := 0.30 if reduced_motion else 1.0
if progress < 0.18:
return lerpf(0.0, -anticipation * scale, _smoothstep(progress / 0.18))
if progress < 0.56:
return lerpf(-anticipation * scale, contact * scale, _smoothstep((progress - 0.18) / 0.38))
return lerpf(contact * scale, 0.0, _smoothstep((progress - 0.56) / 0.44))
func _smoothstep(value: float) -> float:
var bounded := clampf(value, 0.0, 1.0)
return bounded * bounded * (3.0 - 2.0 * bounded)
func _visual(offset: Vector2, scale: Vector2, rotation: float, alpha: float, state: String, animation_time: float) -> Dictionary:
return {
"offset": offset,
"scale": scale,
"rotation": rotation,
"alpha": alpha,
"state": state,
"animation_time": animation_time,
}