312 lines
11 KiB
GDScript
312 lines
11 KiB
GDScript
class_name ABGFG2EventPresenter
|
|
extends RefCounted
|
|
|
|
const MANIFEST_PATH := "res://assets/fg2/motion-manifest.json"
|
|
|
|
var manifest: Dictionary = {}
|
|
var error_code := ""
|
|
var actor_identities: Dictionary = {}
|
|
var actor_slots: Dictionary = {}
|
|
var actor_cues: Dictionary = {}
|
|
var pending_actor_cues: Dictionary = {}
|
|
var transients: Array[Dictionary] = []
|
|
var seen_event_ids: Dictionary = {}
|
|
var event_counts: Dictionary = {}
|
|
var hit_pause_remaining := 0.0
|
|
var shake_remaining := 0.0
|
|
var elapsed := 0.0
|
|
var reduced_motion := false
|
|
var reduced_flashes := false
|
|
var low_power := false
|
|
|
|
|
|
func _init(manifest_path := MANIFEST_PATH, expected_kind := "abg-fg2-motion-manifest") -> void:
|
|
var file := FileAccess.open(manifest_path, FileAccess.READ)
|
|
if file == null:
|
|
error_code = "motion_manifest_missing"
|
|
return
|
|
var parsed = JSON.parse_string(file.get_as_text())
|
|
if not parsed is Dictionary or String(parsed.get("kind", "")) != expected_kind:
|
|
error_code = "motion_manifest_invalid"
|
|
return
|
|
manifest = parsed
|
|
|
|
|
|
func is_valid() -> bool:
|
|
return error_code.is_empty() and not manifest.is_empty()
|
|
|
|
|
|
func reset(view: Dictionary, reduced: bool, flashes: bool, power: bool) -> void:
|
|
actor_cues.clear()
|
|
pending_actor_cues.clear()
|
|
actor_identities.clear()
|
|
actor_slots.clear()
|
|
transients.clear()
|
|
seen_event_ids.clear()
|
|
event_counts.clear()
|
|
hit_pause_remaining = 0.0
|
|
shake_remaining = 0.0
|
|
elapsed = 0.0
|
|
reduced_motion = reduced
|
|
reduced_flashes = flashes
|
|
low_power = power
|
|
sync_view(view)
|
|
|
|
|
|
func sync_view(view: Dictionary) -> void:
|
|
for actor in view.get("company", []):
|
|
actor_identities[String(actor.get("recruit_id", ""))] = String(actor.get("profession_id", ""))
|
|
var battle: Dictionary = view.get("battle", {})
|
|
var allies: Array = battle.get("allies", [])
|
|
var enemies: Array = battle.get("enemies", [])
|
|
for index in range(allies.size()):
|
|
var actor: Dictionary = allies[index]
|
|
actor_identities[String(actor.get("actor_id", ""))] = String(actor.get("identity_id", ""))
|
|
actor_slots[String(actor.get("actor_id", ""))] = {"side": "ally", "index": index}
|
|
for index in range(enemies.size()):
|
|
var actor: Dictionary = enemies[index]
|
|
actor_identities[String(actor.get("actor_id", ""))] = String(actor.get("identity_id", ""))
|
|
actor_slots[String(actor.get("actor_id", ""))] = {"side": "enemy", "index": index}
|
|
|
|
|
|
func begin_battle(view: Dictionary) -> void:
|
|
sync_view(view)
|
|
for actor in view.get("battle", {}).get("allies", []):
|
|
_start_motion(String(actor.get("actor_id", "")), "entry", "battle-entry")
|
|
|
|
|
|
func consume(events: Array) -> void:
|
|
for raw_event in events:
|
|
if not raw_event is Dictionary:
|
|
continue
|
|
var event: Dictionary = raw_event
|
|
var event_id := String(event.get("event_id", ""))
|
|
if event_id.is_empty() or seen_event_ids.has(event_id):
|
|
continue
|
|
seen_event_ids[event_id] = true
|
|
var kind := String(event.get("kind", ""))
|
|
event_counts[kind] = int(event_counts.get(kind, 0)) + 1
|
|
var rule: Dictionary = manifest.get("events", {}).get(kind, {})
|
|
if rule.is_empty():
|
|
continue
|
|
var source_id := String(event.get("source_id", ""))
|
|
var target_id := String(event.get("target_id", ""))
|
|
if not String(rule.get("source_motion", "")).is_empty():
|
|
_start_motion(source_id, String(rule.source_motion), event_id)
|
|
if not String(rule.get("target_motion", "")).is_empty():
|
|
_start_motion(target_id, String(rule.target_motion), event_id)
|
|
if kind == "damage":
|
|
var detail: Dictionary = event.get("detail", {})
|
|
var blocked := maxi(0, int(detail.get("raw", 0)) - int(detail.get("effective", 0)))
|
|
if int(detail.get("guard_before", 0)) > 0 and blocked > 0:
|
|
_start_motion(target_id, "guard", event_id)
|
|
_add_mitigation_transient(event, blocked)
|
|
var pause := minf(float(rule.get("hit_pause", 0.0)), float(manifest.get("limits", {}).get("hit_pause_max", 0.07)))
|
|
if not reduced_motion and not low_power:
|
|
hit_pause_remaining = maxf(hit_pause_remaining, pause)
|
|
if pause > 0.0:
|
|
shake_remaining = maxf(shake_remaining, 0.16)
|
|
_add_transient(event, rule)
|
|
|
|
|
|
func advance(delta: float) -> void:
|
|
var bounded_delta := maxf(delta, 0.0)
|
|
elapsed += bounded_delta
|
|
hit_pause_remaining = maxf(0.0, hit_pause_remaining - bounded_delta)
|
|
shake_remaining = maxf(0.0, shake_remaining - bounded_delta)
|
|
for actor_id in actor_cues.keys():
|
|
_advance_actor_cue(String(actor_id), bounded_delta)
|
|
for index in range(transients.size() - 1, -1, -1):
|
|
transients[index].age = float(transients[index].get("age", 0.0)) + bounded_delta
|
|
if float(transients[index].age) >= float(transients[index].duration):
|
|
transients.remove_at(index)
|
|
|
|
|
|
func blocks_auto_advance() -> bool:
|
|
return hit_pause_remaining > 0.0 and not reduced_motion and not low_power
|
|
|
|
|
|
func shake_offset() -> Vector2i:
|
|
if shake_remaining <= 0.0 or reduced_motion or low_power:
|
|
return Vector2i.ZERO
|
|
var amplitude := int(manifest.get("limits", {}).get("shake_pixels", 2))
|
|
var phase := int(floor(elapsed * 120.0))
|
|
return Vector2i(amplitude if phase % 2 == 0 else -amplitude, -1 if phase % 3 == 0 else 1)
|
|
|
|
|
|
func snapshot() -> Dictionary:
|
|
var rendered_cues: Dictionary = {}
|
|
for actor_id in actor_cues:
|
|
var cue: Dictionary = actor_cues[actor_id].duplicate(true)
|
|
var frames: Array = cue.get("frames", [])
|
|
var progress := clampf(float(cue.get("elapsed", 0.0)) / maxf(0.001, float(cue.get("duration", 0.001))), 0.0, 1.0)
|
|
if reduced_motion or low_power:
|
|
cue.frame_index = int(frames[-1]) if not frames.is_empty() else int(cue.get("idle_frame", 0))
|
|
else:
|
|
var index := mini(frames.size() - 1, int(floor(progress * float(frames.size())))) if not frames.is_empty() else -1
|
|
cue.frame_index = int(frames[index]) if index >= 0 else int(cue.get("idle_frame", 0))
|
|
cue.progress = progress
|
|
rendered_cues[actor_id] = cue
|
|
var active_transients: Array[Dictionary] = []
|
|
for transient in transients:
|
|
var rendered := transient.duplicate(true)
|
|
rendered.progress = clampf(float(rendered.age) / maxf(0.001, float(rendered.duration)), 0.0, 1.0)
|
|
active_transients.append(rendered)
|
|
return {
|
|
"actor_cues": rendered_cues,
|
|
"pending_actor_cues": pending_actor_cues.duplicate(true),
|
|
"transients": active_transients,
|
|
"event_counts": event_counts.duplicate(true),
|
|
"seen_event_count": seen_event_ids.size(),
|
|
"hit_pause": hit_pause_remaining,
|
|
"shake": shake_offset(),
|
|
"reduced_motion": reduced_motion,
|
|
"reduced_flashes": reduced_flashes,
|
|
"low_power": low_power,
|
|
}
|
|
|
|
|
|
func _start_motion(actor_id: String, motion: String, event_id: String) -> void:
|
|
if actor_id.is_empty() or not actor_identities.has(actor_id):
|
|
return
|
|
var identity_id := String(actor_identities[actor_id])
|
|
var actor_rule: Dictionary = manifest.get("actors", {}).get(identity_id, {})
|
|
if actor_rule.is_empty():
|
|
actor_rule = manifest.get("static_actor", {})
|
|
var motion_rule: Dictionary = actor_rule.get("motions", {}).get(motion, {})
|
|
if motion_rule.is_empty():
|
|
return
|
|
var duration := float(motion_rule.get("duration", 0.3))
|
|
if reduced_motion or low_power:
|
|
duration = minf(duration, 0.18)
|
|
var next_cue := {
|
|
"event_id": event_id,
|
|
"identity_id": identity_id,
|
|
"motion": motion,
|
|
"elapsed": 0.0,
|
|
"duration": duration,
|
|
"frames": Array(motion_rule.get("frames", [])).duplicate(),
|
|
"idle_frame": int(actor_rule.get("idle_frame", 0)),
|
|
"side": String(actor_slots.get(actor_id, {}).get("side", "")),
|
|
"slot_index": int(actor_slots.get(actor_id, {}).get("index", 0)),
|
|
}
|
|
if not actor_cues.has(actor_id):
|
|
actor_cues[actor_id] = next_cue
|
|
return
|
|
var current: Dictionary = actor_cues[actor_id]
|
|
if String(current.get("event_id", "")) == event_id and String(current.get("motion", "")) == motion:
|
|
return
|
|
if motion == "fallen":
|
|
if String(current.get("motion", "")) == "fallen":
|
|
return
|
|
pending_actor_cues[actor_id] = [next_cue]
|
|
var casualty_delay := float(manifest.get("limits", {}).get("casualty_delay_max", 0.12))
|
|
current.duration = minf(float(current.get("duration", 0.3)), float(current.get("elapsed", 0.0)) + casualty_delay)
|
|
actor_cues[actor_id] = current
|
|
return
|
|
if pending_actor_cues.has(actor_id):
|
|
return
|
|
if _motion_priority(motion) > _motion_priority(String(current.get("motion", ""))):
|
|
actor_cues[actor_id] = next_cue
|
|
|
|
|
|
func _advance_actor_cue(actor_id: String, delta: float) -> void:
|
|
var remaining := delta
|
|
while remaining > 0.0 and actor_cues.has(actor_id):
|
|
var cue: Dictionary = actor_cues[actor_id]
|
|
var available := maxf(0.0, float(cue.get("duration", 0.0)) - float(cue.get("elapsed", 0.0)))
|
|
if remaining < available:
|
|
cue.elapsed = float(cue.get("elapsed", 0.0)) + remaining
|
|
actor_cues[actor_id] = cue
|
|
return
|
|
remaining = maxf(0.0, remaining - available)
|
|
var pending: Array = pending_actor_cues.get(actor_id, [])
|
|
if pending.is_empty():
|
|
pending_actor_cues.erase(actor_id)
|
|
actor_cues.erase(actor_id)
|
|
return
|
|
var next_cue: Dictionary = pending.pop_front()
|
|
if pending.is_empty():
|
|
pending_actor_cues.erase(actor_id)
|
|
else:
|
|
pending_actor_cues[actor_id] = pending
|
|
actor_cues[actor_id] = next_cue
|
|
|
|
|
|
func _motion_priority(motion: String) -> int:
|
|
match motion:
|
|
"fallen":
|
|
return 5
|
|
"guard":
|
|
return 4
|
|
"attack":
|
|
return 3
|
|
"hit":
|
|
return 2
|
|
"entry":
|
|
return 1
|
|
return 0
|
|
|
|
|
|
func _add_mitigation_transient(event: Dictionary, amount: int) -> void:
|
|
transients.append({
|
|
"event_id": "%s-guard" % String(event.get("event_id", "")),
|
|
"kind": "mitigation",
|
|
"vfx": "guard",
|
|
"source_id": String(event.get("source_id", "")),
|
|
"target_id": String(event.get("target_id", "")),
|
|
"text": "%d BLOCKED" % amount,
|
|
"value": amount,
|
|
"age": 0.0,
|
|
"duration": 0.6,
|
|
"flash": not reduced_flashes and not low_power,
|
|
})
|
|
|
|
|
|
func _add_transient(event: Dictionary, rule: Dictionary) -> void:
|
|
var kind := String(event.get("kind", ""))
|
|
var vfx := String(rule.get("vfx", ""))
|
|
if vfx.is_empty():
|
|
return
|
|
var detail: Dictionary = event.get("detail", {})
|
|
var value := 0
|
|
var value_field := String(rule.get("value_field", ""))
|
|
if not value_field.is_empty():
|
|
value = int(detail.get(value_field, 0))
|
|
var text := ""
|
|
match kind:
|
|
"damage":
|
|
text = "-%d" % value
|
|
"heal":
|
|
text = "+%d" % value
|
|
"guard":
|
|
text = "+%d GUARD" % value
|
|
"intercept", "item_block", "push_resisted":
|
|
text = "BLOCK"
|
|
"hinder":
|
|
text = "-%d READY" % value
|
|
"fallen":
|
|
text = "FALLEN"
|
|
"outcome":
|
|
text = String(detail.get("outcome", "")).to_upper()
|
|
"recovery":
|
|
text = "RECOVERED"
|
|
var duration := 0.9 if kind in ["fallen", "outcome"] else 0.6
|
|
if low_power:
|
|
duration = minf(duration, 0.5)
|
|
transients.append({
|
|
"event_id": String(event.get("event_id", "")),
|
|
"kind": kind,
|
|
"vfx": vfx,
|
|
"source_id": String(event.get("source_id", "")),
|
|
"target_id": String(event.get("target_id", "")),
|
|
"text": text,
|
|
"value": value,
|
|
"age": 0.0,
|
|
"duration": duration,
|
|
"flash": not reduced_flashes and not low_power,
|
|
})
|
|
var max_transients := int(manifest.get("limits", {}).get("max_transients", 8))
|
|
while transients.size() > max_transients:
|
|
transients.pop_front()
|