Files
aetherbound-guild/runtime/entry/battle_live_art.gd
T

698 lines
30 KiB
GDScript

class_name ABGBattleLiveArt
extends Control
## Engine-drawn battle chrome reconstructed from the approved BAT-002 master.
## Runtime labels, values and hit targets remain separate overlay nodes.
var live_state := "waiting_for_next_event"
var event_kind := ""
var source_side := ""
var target_side := ""
var source_anchor := Vector2(0.46, 0.60)
var target_anchor := Vector2(0.67, 0.58)
var ally_anchors: Array[Vector2] = []
var enemy_anchors: Array[Vector2] = []
var party_count := 0
var enemy_count := 0
var playback_paused := true
var playback_speed := 1
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
queue_redraw()
func _notification(what: int) -> void:
if what == NOTIFICATION_RESIZED:
queue_redraw()
func set_event_state(state: String, kind: String, source: String, target: String) -> void:
live_state = state
event_kind = kind
source_side = source
target_side = target
queue_redraw()
func set_presentation_state(
paused: bool,
speed: float,
allies: Array[Vector2],
enemies: Array[Vector2],
event_source: Vector2,
event_target: Vector2
) -> void:
playback_paused = paused
playback_speed = int(speed)
ally_anchors = allies.duplicate()
enemy_anchors = enemies.duplicate()
party_count = ally_anchors.size()
enemy_count = enemy_anchors.size()
source_anchor = event_source
target_anchor = event_target
queue_redraw()
func _draw() -> void:
if size.x <= 0.0 or size.y <= 0.0:
return
_draw_top_rails()
_draw_field_language()
_draw_beat()
_draw_bottom_rail()
func _draw_top_rails() -> void:
var w := size.x
var h := size.y
var gold := Color("#d6ad58", 0.98)
var gold_dim := Color("#78633f", 0.92)
var navy := Color("#101b2a", 0.96)
var navy_inner := Color("#18293b", 0.96)
var blue := Color("#315a87", 0.98)
var red := Color("#8b3d47", 0.98)
var ivory := Color("#f1e4c2", 0.98)
# Long asymmetric faction rails with pointed inner shoulders. The master is
# deliberately quiet here: the rail carries count/pips, while all readable
# values remain engine-owned overlay text.
var left_outer := PackedVector2Array([
Vector2(w * 0.050, h * 0.016), Vector2(w * 0.325, h * 0.016),
Vector2(w * 0.344, h * 0.090), Vector2(w * 0.337, h * 0.108),
Vector2(w * 0.318, h * 0.122), Vector2(w * 0.060, h * 0.108),
])
draw_colored_polygon(left_outer, gold_dim)
var left_inner := PackedVector2Array([
Vector2(w * 0.058, h * 0.024), Vector2(w * 0.319, h * 0.024),
Vector2(w * 0.335, h * 0.087), Vector2(w * 0.328, h * 0.100),
Vector2(w * 0.316, h * 0.111), Vector2(w * 0.058, h * 0.099),
])
draw_colored_polygon(left_inner, navy)
draw_polyline(left_outer + PackedVector2Array([left_outer[0]]), gold, 2.0)
draw_line(Vector2(w * 0.060, h * 0.091), Vector2(w * 0.321, h * 0.102), Color(gold, 0.42), 1.0)
draw_line(Vector2(w * 0.070, h * 0.032), Vector2(w * 0.314, h * 0.032), Color(gold, 0.22), 1.0)
draw_line(Vector2(w * 0.060, h * 0.100), Vector2(w * 0.305, h * 0.111), Color(gold, 0.18), 1.0)
draw_line(Vector2(w * 0.082, h * 0.043), Vector2(w * 0.314, h * 0.043), Color("#e8c878", 0.11), 1.0)
draw_circle(Vector2(w * 0.307, h * 0.101), maxf(1.5, w * 0.0022), Color("#efcf83", 0.72))
var right_outer := PackedVector2Array([
Vector2(w * 0.675, h * 0.016), Vector2(w * 0.950, h * 0.016),
Vector2(w * 0.950, h * 0.108), Vector2(w * 0.682, h * 0.122),
Vector2(w * 0.663, h * 0.108), Vector2(w * 0.656, h * 0.090),
])
draw_colored_polygon(right_outer, gold_dim)
var right_inner := PackedVector2Array([
Vector2(w * 0.681, h * 0.024), Vector2(w * 0.943, h * 0.024),
Vector2(w * 0.943, h * 0.099), Vector2(w * 0.678, h * 0.111),
Vector2(w * 0.666, h * 0.100), Vector2(w * 0.662, h * 0.087),
])
draw_colored_polygon(right_inner, navy)
draw_polyline(right_outer + PackedVector2Array([right_outer[0]]), gold, 2.0)
draw_line(Vector2(w * 0.679, h * 0.102), Vector2(w * 0.940, h * 0.091), Color(gold, 0.42), 1.0)
draw_line(Vector2(w * 0.686, h * 0.032), Vector2(w * 0.936, h * 0.032), Color(gold, 0.22), 1.0)
draw_line(Vector2(w * 0.695, h * 0.111), Vector2(w * 0.940, h * 0.100), Color(gold, 0.18), 1.0)
draw_line(Vector2(w * 0.686, h * 0.043), Vector2(w * 0.918, h * 0.043), Color("#e8c878", 0.11), 1.0)
draw_circle(Vector2(w * 0.693, h * 0.101), maxf(1.5, w * 0.0022), Color("#efcf83", 0.72))
# Hanging crest endcaps preserve the approved blue/red silhouette.
var left_cap := PackedVector2Array([
Vector2(w * 0.014, 0.0), Vector2(w * 0.073, 0.0),
Vector2(w * 0.073, h * 0.105), Vector2(w * 0.052, h * 0.144),
Vector2(w * 0.015, h * 0.112),
])
draw_colored_polygon(left_cap, blue)
draw_polyline(left_cap + PackedVector2Array([left_cap[0]]), gold, 2.0)
var right_cap := PackedVector2Array([
Vector2(w * 0.927, 0.0), Vector2(w * 0.986, 0.0),
Vector2(w * 0.985, h * 0.112), Vector2(w * 0.948, h * 0.144),
Vector2(w * 0.927, h * 0.105),
])
draw_colored_polygon(right_cap, red)
draw_polyline(right_cap + PackedVector2Array([right_cap[0]]), gold, 2.0)
draw_line(Vector2(w * 0.933, h * 0.014), Vector2(w * 0.933, h * 0.099), Color("#e8a26e", 0.24), 1.0)
_draw_crest(Vector2(w * 0.044, h * 0.057), minf(w, h) * 0.026, Color("#edf1ec"))
_draw_crest(Vector2(w * 0.956, h * 0.057), minf(w, h) * 0.026, Color("#f3e8dc"))
# Status pips remain inside the long rails instead of becoming cards. Keep
# the spacing stable for the small BAT-002 fixtures while allowing larger
# committed lines to use the same authored rail.
var ally_pip_count := mini(party_count, 10)
var enemy_pip_count := mini(enemy_count, 10)
for index in range(ally_pip_count):
_draw_diamond(Vector2(w * (0.145 + index * 0.034), h * 0.064), w * 0.0075, Color("#68b8e9"))
for index in range(enemy_pip_count):
_draw_diamond(Vector2(w * (0.855 - index * 0.034), h * 0.064), w * 0.0075, Color("#e05c5c"))
# The small ivory markers on the lower rail line are part of the approved
# rhythm and make the opposing tracks read as one family.
_draw_diamond(Vector2(w * 0.119, h * 0.096), w * 0.0065, Color("#f3e7c8"))
_draw_diamond(Vector2(w * 0.821, h * 0.096), w * 0.0065, Color("#f3e7c8"))
# One continuous ivory playback ribbon. Logical buttons sit transparently on top.
var ribbon_outer := PackedVector2Array([
Vector2(w * 0.390, 0.0), Vector2(w * 0.610, 0.0),
Vector2(w * 0.598, h * 0.103), Vector2(w * 0.402, h * 0.103),
])
draw_colored_polygon(ribbon_outer, gold_dim)
var ribbon_inner := PackedVector2Array([
Vector2(w * 0.394, 0.0), Vector2(w * 0.606, 0.0),
Vector2(w * 0.594, h * 0.093), Vector2(w * 0.406, h * 0.093),
])
draw_colored_polygon(ribbon_inner, ivory)
draw_polyline(ribbon_outer + PackedVector2Array([ribbon_outer[0]]), Color("#b99a59"), 2.0)
for split_x in [0.465, 0.535]:
draw_line(Vector2(w * split_x, 0.0), Vector2(w * split_x, h * 0.093), Color("#b59d6c", 0.62), 1.0)
_draw_pause_icon(Vector2(w * 0.428, h * 0.045), h * 0.025, navy_inner)
_draw_speed_icon(Vector2(w * 0.492, h * 0.045), h * 0.023, navy_inner)
_draw_magnifier(Vector2(w * 0.570, h * 0.042), h * 0.020, navy_inner)
# Settings is an invisible hit target in the approved corner. The master has
# no competing visible gear beside the enemy count.
func _draw_field_language() -> void:
var w := size.x
var h := size.y
var gold := Color("#e0bd69", 0.92)
var ivory := Color("#f5ead0", 0.94)
var path_color := _event_color()
# A narrow field-attached objective bracket keeps the painted world open.
var objective := PackedVector2Array([
Vector2(w * 0.018, h * 0.142), Vector2(w * 0.278, h * 0.142),
Vector2(w * 0.294, h * 0.157), Vector2(w * 0.282, h * 0.238),
Vector2(w * 0.018, h * 0.238),
])
# The approved frame uses a field-attached bracket, not an objective card.
# Leave the painted ruins unobscured; only the corner lines remain visible.
draw_line(objective[0], objective[1], Color(gold, 0.78), 1.5)
draw_line(objective[1], objective[2], Color(gold, 0.46), 1.0)
draw_line(objective[4], objective[3], Color(gold, 0.46), 1.0)
draw_line(objective[0], objective[4], Color(gold, 0.66), 1.5)
draw_line(objective[2], objective[3], Color(gold, 0.30), 1.0)
# Small role/status markers remain attached to the actors.
for index in range(ally_anchors.size()):
_draw_actor_state_marker(_to_px(ally_anchors[index]), Color("#6fc0e7"), index == ally_anchors.size() - 1)
for index in range(enemy_anchors.size()):
_draw_actor_state_marker(_to_px(enemy_anchors[index]), Color("#dc6662"), index == 0)
if event_kind.is_empty() or source_side.is_empty() or target_side.is_empty():
if live_state == "waiting_for_next_event":
var waiting_center := Vector2(w * 0.50, h * 0.345)
draw_arc(waiting_center, w * 0.014, 0.0, TAU, 24, Color(ivory, 0.78), 2.0)
_draw_diamond(waiting_center, w * 0.0045, gold)
elif event_kind == "opening":
var opening_center := Vector2(w * 0.50, h * 0.365)
draw_arc(opening_center, w * 0.020, 0.0, TAU, 28, Color(gold, 0.68), 2.0)
_draw_diamond(opening_center, w * 0.006, ivory)
return
var source := _to_px(source_anchor)
var target := _to_px(target_anchor)
var bend := -h * 0.095 if source.x < target.x else h * 0.075
var path := _quadratic_path(source, target, bend)
# M110 A5 (Owner-authorized bounded repair): dark twin underlay beneath the
# bright ladder so the rendered carrier immediately outside each bright
# stroke is the existing dark ink palette. Event hues, bright widths, shapes
# and non-color cues stay unchanged.
var underlay := Color("#101b2a", 0.94)
var underlay_deep := Color("#071015", 0.94)
for offset in [-4.0, 0.0, 4.0]:
var underlay_path := PackedVector2Array()
for point in path:
underlay_path.append(point + Vector2(0.0, offset))
draw_polyline(underlay_path, underlay_deep if offset == 0.0 else underlay, 6.0 if offset == 0.0 else 4.0)
for offset in [-4.0, 0.0, 4.0]:
var shifted := PackedVector2Array()
for point in path:
shifted.append(point + Vector2(0.0, offset))
draw_polyline(shifted, Color(path_color, 0.52 if offset != 0.0 else 0.98), 1.6 if offset != 0.0 else 3.0)
var direction := (path[-1] - path[-2]).normalized()
var perpendicular := Vector2(-direction.y, direction.x)
draw_colored_polygon(PackedVector2Array([
target,
target - direction * w * 0.018 + perpendicular * w * 0.008,
target - direction * w * 0.018 - perpendicular * w * 0.008,
]), path_color)
# A second small arrow in the corridor echoes the approved multi-stroke
# projection without introducing a separate event panel.
var mid_index := 11
var mid := path[mid_index]
var mid_direction := (path[mid_index + 1] - path[mid_index - 1]).normalized()
var mid_perpendicular := Vector2(-mid_direction.y, mid_direction.x)
draw_colored_polygon(PackedVector2Array([
mid,
mid - mid_direction * w * 0.012 + mid_perpendicular * w * 0.006,
mid - mid_direction * w * 0.012 - mid_perpendicular * w * 0.006,
]), Color(path_color, 0.82))
# The approved master uses a small cluster of parallel flight marks. Keep
# the extra strokes inside the action gap, without adding a diagnostic band.
var flight_mid := path[7]
var flight_direction := (path[8] - path[6]).normalized()
var flight_perpendicular := Vector2(-flight_direction.y, flight_direction.x)
for lane in [-1.0, 1.0]:
var lane_mid: Vector2 = flight_mid + flight_perpendicular * lane * w * 0.010
draw_line(lane_mid - flight_direction * w * 0.028, lane_mid + flight_direction * w * 0.028, Color(path_color, 0.62), 1.4)
draw_arc(source, w * 0.012, 0.0, TAU, 28, underlay, 5.5)
draw_arc(source, w * 0.012, 0.0, TAU, 28, Color(path_color, 0.92), 2.0)
draw_circle(source, w * 0.005, Color(path_color, 0.55))
draw_circle(target, w * 0.029, Color(path_color, 0.13))
draw_arc(target, w * 0.029, 0.0, TAU, 36, underlay, 5.5)
draw_arc(target, w * 0.029, 0.0, TAU, 36, Color(path_color, 0.92), 2.0)
# The urgent marker is tied to the target endpoint, never a central debug band.
var urgent := target - Vector2(0.0, h * 0.205)
draw_line(urgent + Vector2(0.0, h * 0.030), target - Vector2(0.0, h * 0.025), underlay, 5.0)
draw_line(urgent + Vector2(0.0, h * 0.030), target - Vector2(0.0, h * 0.025), Color(path_color, 0.45), 3.0)
var urgent_shape := PackedVector2Array([
urgent + Vector2(0.0, -h * 0.036), urgent + Vector2(w * 0.020, 0.0),
urgent + Vector2(0.0, h * 0.036), urgent + Vector2(-w * 0.020, 0.0),
])
draw_polyline(urgent_shape + PackedVector2Array([urgent_shape[0]]), underlay_deep, 4.0)
draw_colored_polygon(urgent_shape, Color(path_color, 0.94))
draw_polyline(urgent_shape + PackedVector2Array([urgent_shape[0]]), Color("#fff0d8"), 2.0)
draw_line(urgent - Vector2(0.0, h * 0.014), urgent + Vector2(0.0, h * 0.009), ivory, 3.0)
draw_circle(urgent + Vector2(0.0, h * 0.020), maxf(2.0, w * 0.0016), ivory)
func _draw_bottom_rail() -> void:
var w := size.x
var h := size.y
var top := h * 0.80
var gold := Color("#d0aa58", 0.96)
var rail := Color("#0e1723", 0.97)
var rail_inner := Color("#172434", 0.98)
var plate := Color("#1a2836", 0.84)
draw_rect(Rect2(0.0, top, w, h - top), rail)
draw_line(Vector2(0.0, top), Vector2(w, top), gold, 2.0)
draw_line(Vector2(0.0, top + h * 0.009), Vector2(w, top + h * 0.009), Color(gold, 0.28), 1.0)
draw_line(Vector2(0.0, h * 0.985), Vector2(w, h * 0.985), Color("#5b513c", 0.42), 1.0)
draw_rect(Rect2(w * 0.012, h * 0.835, w * 0.142, h * 0.132), rail_inner)
draw_style_box(_plate(Color("#131b25", 0.98), Color(gold, 0.78), 1, 3), Rect2(w * 0.018, h * 0.857, w * 0.125, h * 0.095))
_draw_hourglass(Vector2(w * 0.042, h * 0.905), h * 0.023, Color("#e6d5a8"))
var card_count := maxi(1, party_count)
var card_left := 0.170
var card_right := 0.885
var card_gap := clampf(0.012 - float(card_count) * 0.0008, 0.006, 0.012)
var card_width := (card_right - card_left - card_gap * float(card_count - 1)) / float(card_count)
for index in range(card_count):
var rect := Rect2(w * (card_left + index * (card_width + card_gap)), h * 0.812, w * card_width, h * 0.175)
var cut := clampf(rect.size.x * 0.055, 2.0, 7.0)
var card_shape := PackedVector2Array([
rect.position + Vector2(cut, 0.0), rect.position + Vector2(rect.size.x - cut, 0.0),
rect.position + Vector2(rect.size.x, cut), rect.position + Vector2(rect.size.x, rect.size.y - cut),
rect.position + Vector2(rect.size.x - cut, rect.size.y), rect.position + Vector2(cut, rect.size.y),
rect.position + Vector2(0.0, rect.size.y - cut), rect.position + Vector2(0.0, cut),
])
draw_colored_polygon(card_shape, plate)
draw_polyline(card_shape + PackedVector2Array([card_shape[0]]), Color(gold, 0.88), 1.2)
draw_polyline(PackedVector2Array([
rect.position + Vector2(cut + 2.0, 2.0),
rect.position + Vector2(rect.size.x - cut - 2.0, 2.0),
rect.position + Vector2(rect.size.x - 2.0, cut + 2.0),
rect.position + Vector2(rect.size.x - 2.0, rect.size.y - cut - 2.0),
rect.position + Vector2(rect.size.x - cut - 2.0, rect.size.y - 2.0),
rect.position + Vector2(cut + 2.0, rect.size.y - 2.0),
rect.position + Vector2(2.0, rect.size.y - cut - 2.0),
rect.position + Vector2(2.0, cut + 2.0),
rect.position + Vector2(cut + 2.0, 2.0),
]), Color("#f0d17d", 0.24), 1.0)
draw_line(rect.position + Vector2(rect.size.x * 0.06, rect.size.y * 0.71), rect.position + Vector2(rect.size.x * 0.94, rect.size.y * 0.71), Color(gold, 0.20), 1.0)
draw_rect(Rect2(rect.position + Vector2(rect.size.x * 0.055, rect.size.y * 0.745), Vector2(rect.size.x * 0.89, rect.size.y * 0.070)), Color("#071015", 0.92))
draw_rect(Rect2(rect.position + Vector2(rect.size.x * 0.055, rect.size.y * 0.862), Vector2(rect.size.x * 0.89, rect.size.y * 0.070)), Color("#071015", 0.92))
var badge_radius := clampf(rect.size.x * 0.105, h * 0.012, h * 0.028)
var badge := rect.position + Vector2(rect.size.x * 0.105, rect.size.y * 0.115)
_draw_diamond(badge, badge_radius, Color("#30291e"))
draw_arc(badge, badge_radius, 0.0, TAU, 4, gold, 1.5)
# The boundary command remains a compact rail glyph inside a generous hit area.
var command_center := Vector2(w * 0.943, h * 0.895)
draw_circle(command_center, h * 0.027, Color("#18283a", 0.84))
draw_arc(command_center, h * 0.027, 0.0, TAU, 28, Color(gold, 0.84), 1.5)
draw_line(command_center - Vector2(h * 0.010, 0.0), command_center + Vector2(h * 0.010, 0.0), Color("#e9dcb9"), 2.0)
draw_line(command_center + Vector2(h * 0.003, -h * 0.008), command_center + Vector2(h * 0.010, 0.0), Color("#e9dcb9"), 2.0)
draw_line(command_center + Vector2(h * 0.003, h * 0.008), command_center + Vector2(h * 0.010, 0.0), Color("#e9dcb9"), 2.0)
func _event_color() -> Color:
match event_kind:
"healing": return Color("#70c59b", 0.96)
"guard", "guarded_recovery": return Color("#73b8dc", 0.96)
"magic": return Color("#b58bd6", 0.96)
"knockout": return Color("#e16c67", 0.98)
"result": return Color("#dfbd68", 0.98)
return Color("#e2b75f", 0.96)
func _draw_actor_state_marker(center: Vector2, color: Color, emphasized: bool) -> void:
var radius := size.x * (0.0048 if not emphasized else 0.0062)
draw_circle(center, radius * 1.25, Color("#13202a", 0.62))
_draw_diamond(center, radius, color)
draw_arc(center, radius * 1.25, 0.0, TAU, 20, Color("#f4e6c3", 0.72), 1.0)
func _draw_crest(center: Vector2, radius: float, color: Color) -> void:
# Four tapered leaves read closer to the approved faction emblems than a
# generic gear/cross while remaining an engine-drawn, non-baked mark.
var leaf := func(angle: float) -> PackedVector2Array:
var direction := Vector2(cos(angle), sin(angle))
var side := Vector2(-direction.y, direction.x)
return PackedVector2Array([
center + direction * radius * 0.95,
center + direction * radius * 0.16 + side * radius * 0.34,
center - direction * radius * 0.08,
center + direction * radius * 0.16 - side * radius * 0.34,
])
for angle in [(-PI * 0.5), 0.0, (PI * 0.5), PI]:
draw_colored_polygon(leaf.call(angle), Color(color, 0.92))
draw_circle(center, radius * 0.16, Color("#29445c", 0.96))
draw_arc(center, radius * 0.68, 0.0, TAU, 16, Color(color, 0.78), 1.0)
func _draw_pause_icon(center: Vector2, radius: float, color: Color) -> void:
if playback_paused:
var triangle := PackedVector2Array([
center + Vector2(-radius * 0.42, -radius * 0.62),
center + Vector2(radius * 0.62, 0.0),
center + Vector2(-radius * 0.42, radius * 0.62),
])
draw_colored_polygon(triangle, color)
else:
draw_rect(Rect2(center - Vector2(radius * 0.48, radius * 0.62), Vector2(radius * 0.32, radius * 1.24)), color)
draw_rect(Rect2(center + Vector2(radius * 0.16, -radius * 0.62), Vector2(radius * 0.32, radius * 1.24)), color)
func _draw_speed_icon(center: Vector2, radius: float, color: Color) -> void:
var chevrons := 1 if playback_speed <= 1 else (2 if playback_speed <= 2 else 3)
for index in range(chevrons):
var offset := (float(index) - float(chevrons - 1) * 0.5) * radius * 0.72
var c := center + Vector2(offset, 0.0)
draw_colored_polygon(PackedVector2Array([
c + Vector2(-radius * 0.38, -radius * 0.62),
c + Vector2(radius * 0.42, 0.0),
c + Vector2(-radius * 0.38, radius * 0.62),
]), color)
func _draw_magnifier(center: Vector2, radius: float, color: Color) -> void:
draw_arc(center - Vector2(radius * 0.18, radius * 0.18), radius * 0.68, 0.0, TAU, 24, color, 3.0)
draw_line(center + Vector2(radius * 0.34, radius * 0.34), center + Vector2(radius * 0.95, radius * 0.95), color, 3.0)
func _draw_cog(center: Vector2, radius: float, color: Color, fill: Color) -> void:
draw_circle(center, radius * 1.12, Color(fill, 0.94))
for index in range(8):
var angle := TAU * float(index) / 8.0
var direction := Vector2(cos(angle), sin(angle))
draw_line(center + direction * radius * 0.82, center + direction * radius * 1.24, color, 2.0)
draw_arc(center, radius * 0.72, 0.0, TAU, 24, color, 2.0)
draw_circle(center, radius * 0.22, color)
func _draw_hourglass(center: Vector2, radius: float, color: Color) -> void:
var top_left := center + Vector2(-radius * 0.70, -radius)
var top_right := center + Vector2(radius * 0.70, -radius)
var bottom_left := center + Vector2(-radius * 0.70, radius)
var bottom_right := center + Vector2(radius * 0.70, radius)
draw_line(top_left, top_right, color, 2.0)
draw_line(bottom_left, bottom_right, color, 2.0)
draw_line(top_left, center + Vector2(radius * 0.18, 0.0), color, 2.0)
draw_line(top_right, center + Vector2(-radius * 0.18, 0.0), color, 2.0)
draw_line(center + Vector2(radius * 0.18, 0.0), bottom_left, color, 2.0)
draw_line(center + Vector2(-radius * 0.18, 0.0), bottom_right, color, 2.0)
draw_colored_polygon(PackedVector2Array([
center + Vector2(-radius * 0.16, radius * 0.12),
center + Vector2(radius * 0.16, radius * 0.12),
center + Vector2(radius * 0.30, radius * 0.58),
center + Vector2(-radius * 0.30, radius * 0.58),
]), Color(color, 0.72))
func _quadratic_path(source: Vector2, target: Vector2, bend: float) -> PackedVector2Array:
var points := PackedVector2Array()
var midpoint := (source + target) * 0.5 + Vector2(0.0, bend)
for index in range(21):
var t := float(index) / 20.0
var point := source * pow(1.0 - t, 2.0) + midpoint * 2.0 * (1.0 - t) * t + target * pow(t, 2.0)
points.append(point)
return points
func _to_px(point: Vector2) -> Vector2:
return Vector2(point.x * size.x, point.y * size.y)
func _draw_diamond(center: Vector2, radius: float, color: Color) -> void:
var points := PackedVector2Array([
center + Vector2(0.0, -radius),
center + Vector2(radius, 0.0),
center + Vector2(0.0, radius),
center + Vector2(-radius, 0.0),
])
draw_colored_polygon(points, color)
func _plate(background: Color, border: Color, border_width: int, radius: int) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = background
style.border_color = border
style.set_border_width_all(border_width)
style.set_corner_radius_all(radius)
return style
# --- M77-B event beats -------------------------------------------------------
# The approved field language (path, rings, urgent marker) stays; these add the
# readable beat: telegraph pulses, projectile travel, impact bursts, guard arc,
# healing motes, knockout mark. Sprites stay with the shell actor nodes (pose
# and fade are set there); the art owns the temporal VFX and its own clock, so
# no Tween lifecycle exists to leak.
const BEAT_DURATIONS := {
"opening": 1.2,
"telegraph": 1.2,
"guard": 0.7,
"ranged": 0.9,
"magic": 1.0,
"healing": 1.0,
"damage": 0.9,
"knockout": 1.4,
"guarded_recovery": 1.0,
}
const BEAT_IMPACT := {
"ranged": 0.40,
"magic": 0.45,
"damage": 0.32,
"knockout": 0.34,
"guard": 0.15,
"healing": 0.20,
}
var beat_kind := ""
var beat_key := ""
var beat_time := 0.0
var beat_duration := 0.0
var beat_amount := 0
var beat_source := Vector2.ZERO
var beat_target := Vector2.ZERO
var beat_played := 0
var beat_kinds_seen: Dictionary = {}
var reduce_motion := false
var reduce_flashes := false
var color_cues := true
var hit_pause_enabled := true
var _pause_consumed := true
func set_accessibility(presentation: Dictionary) -> void:
reduce_motion = bool(presentation.get("reduced_motion", false))
reduce_flashes = bool(presentation.get("reduced_flashes", false))
color_cues = bool(presentation.get("color_cues", true))
hit_pause_enabled = bool(presentation.get("hit_pause", true))
queue_redraw()
func set_beat(key: String, kind: String, source: Vector2, target: Vector2, amount := 0) -> void:
if key == beat_key and kind == beat_kind:
return
beat_key = key
beat_kind = kind
beat_source = source
beat_target = target
beat_amount = amount
beat_time = 0.0
beat_duration = float(BEAT_DURATIONS.get(kind, 0.9))
_pause_consumed = not hit_pause_enabled
beat_played += 1
beat_kinds_seen[kind] = int(beat_kinds_seen.get(kind, 0)) + 1
set_process(true)
queue_redraw()
func beat_progress() -> float:
if beat_duration <= 0.0:
return 1.0
return clampf(beat_time / beat_duration, 0.0, 1.0)
func _process(delta: float) -> void:
if beat_kind.is_empty():
set_process(false)
return
beat_time += delta
var impact := float(BEAT_IMPACT.get(beat_kind, 1.0))
if not _pause_consumed and beat_time >= beat_duration * impact:
# A short impact freeze; the flight already happened before it.
_pause_consumed = true
beat_time -= 0.10
queue_redraw()
if beat_time > beat_duration * 1.4:
set_process(false)
func _draw_beat() -> void:
if beat_kind.is_empty() or size.x <= 0.0:
return
var progress := beat_progress()
var source := _to_px(beat_source)
var target := _to_px(beat_target)
if not color_cues:
_draw_beat_monochrome(progress, source, target)
return
match beat_kind:
"opening":
_draw_opening_beat(progress)
"telegraph":
_draw_telegraph_beat(progress, target)
"guard":
_draw_guard_beat(progress, target)
"ranged":
_draw_projectile_beat(progress, source, target, Color("#f2d27a"))
"magic":
_draw_magic_beat(progress, source, target)
"healing":
_draw_healing_beat(progress, target)
"damage":
_draw_impact_beat(progress, target, Color("#ffd9a0"))
"knockout":
_draw_impact_beat(progress, target, Color("#e0695f"))
_draw_knockout_mark(progress, target)
"guarded_recovery":
_draw_recovery_beat(progress, target)
func _draw_beat_monochrome(progress: float, source: Vector2, target: Vector2) -> void:
# Colour-blind safe mode: identical shapes, one ink palette, no hue channel.
var ink := Color("#f0e2c0")
var alpha := 1.0 - progress
draw_arc(target, lerpf(size.x * 0.012, size.x * 0.032, progress), 0.0, TAU, 30, Color(ink, alpha * 0.85), 2.5)
if beat_kind in ["ranged", "magic", "damage", "knockout"]:
for index in 8:
var angle := TAU * float(index) / 8.0
var direction := Vector2(cos(angle), sin(angle))
draw_line(target + direction * size.x * 0.012, target + direction * size.x * lerpf(0.020, 0.038, progress), Color(ink, alpha * 0.7), 2.0)
if beat_kind in ["ranged", "magic"] and progress < float(BEAT_IMPACT.get(beat_kind, 0.4)):
draw_line(source, source.lerp(target, progress / maxf(0.001, float(BEAT_IMPACT.get(beat_kind, 0.4)))), Color(ink, 0.65), 2.0)
if beat_kind in ["healing", "guarded_recovery"]:
for index in 4:
var mote := target + Vector2((float(index) - 1.5) * size.x * 0.012, -progress * size.y * 0.055)
draw_circle(mote, maxf(1.6, size.x * 0.0022), Color(ink, alpha * 0.8))
func _draw_opening_beat(progress: float) -> void:
var center := Vector2(size.x * 0.50, size.y * 0.345)
var alpha := 0.35 + 0.45 * sin(progress * PI)
draw_arc(center, size.x * 0.022, 0.0, TAU, 30, Color("#efd694", alpha), 2.0)
draw_line(Vector2(size.x * 0.34, size.y * 0.56), Vector2(size.x * 0.66, size.y * 0.56), Color("#e8d39d", alpha * 0.5), 1.5)
func _draw_telegraph_beat(progress: float, target: Vector2) -> void:
var base := maxf(14.0, size.x * 0.014)
for index in 3:
var local := fmod(progress * 2.2 + float(index) * 0.33, 1.0)
var radius := lerpf(base, base * 5.0, local)
draw_arc(target, radius, 0.0, TAU, 40, Color("#e0575f", (1.0 - local) * 0.55), 2.5)
draw_circle(target, base * 0.55, Color("#e0575f", 0.30 * (1.0 - progress)))
func _draw_guard_beat(progress: float, target: Vector2) -> void:
var alpha := sin(progress * PI)
var center := target - Vector2(0.0, size.y * 0.045)
var radius := maxf(18.0, size.x * 0.018)
draw_arc(center, radius, PI, TAU, 26, Color("#84d7e6", 0.90 * alpha), 4.0)
draw_arc(center, radius * 0.78, PI, TAU, 22, Color("#d8f3f8", 0.55 * alpha), 1.5)
func _draw_projectile_beat(progress: float, source: Vector2, target: Vector2, tint: Color) -> void:
var impact := float(BEAT_IMPACT.get("ranged", 0.4))
if progress < impact:
var local := progress / impact
var position := source.lerp(target, local)
var direction := (target - source).normalized()
for index in 4:
var trail := position - direction * float(index) * size.x * 0.006
draw_circle(trail, maxf(1.5, size.x * 0.0022) * (1.0 - float(index) * 0.2), Color(tint, 0.55 - float(index) * 0.12))
draw_circle(position, maxf(3.0, size.x * 0.0045), tint)
return
_draw_impact_beat((progress - impact) / maxf(0.001, 1.0 - impact), target, tint)
func _draw_magic_beat(progress: float, source: Vector2, target: Vector2) -> void:
var impact := float(BEAT_IMPACT.get("magic", 0.45))
var violet := Color("#b98ce0")
if progress < impact:
var local := progress / impact
draw_arc(source, lerpf(size.x * 0.012, size.x * 0.030, local), 0.0, TAU, 30, Color(violet, 0.35 + 0.5 * local), 2.5)
draw_arc(source, lerpf(size.x * 0.006, size.x * 0.018, local), 0.0, TAU, 22, Color("#e8d6f6", 0.5 * local), 1.5)
return
var local := (progress - impact) / maxf(0.001, 1.0 - impact)
draw_line(source, target, Color(violet, 0.55 * (1.0 - local)), maxf(2.0, size.x * 0.004))
_draw_impact_beat(local, target, violet)
func _draw_healing_beat(progress: float, target: Vector2) -> void:
var green := Color("#95d9a0")
draw_arc(target, lerpf(size.x * 0.010, size.x * 0.030, progress), 0.0, TAU, 30, Color(green, (1.0 - progress) * 0.7), 2.0)
for index in 5:
var offset := (float(index) - 2.0) * size.x * 0.010
var rise := progress * size.y * 0.065
var mote := target + Vector2(offset, -size.y * 0.010 - rise)
draw_circle(mote, maxf(1.6, size.x * 0.0024), Color(green, (1.0 - progress) * 0.85))
func _draw_impact_beat(progress: float, target: Vector2, tint: Color) -> void:
if progress <= 0.0 or progress >= 1.0:
return
var fade := 1.0 - progress
if reduce_flashes:
draw_arc(target, lerpf(size.x * 0.012, size.x * 0.034, progress), 0.0, TAU, 30, Color(tint, fade * 0.85), 3.0)
else:
draw_circle(target, lerpf(size.x * 0.012, size.x * 0.026, progress), Color(tint, fade * 0.5))
for index in 8:
var angle := TAU * float(index) / 8.0
var direction := Vector2(cos(angle), sin(angle))
var inner := target + direction * size.x * 0.010
var outer := target + direction * size.x * lerpf(0.020, 0.040, progress)
draw_line(inner, outer, Color(tint, fade * 0.8), 2.0)
func _draw_knockout_mark(progress: float, target: Vector2) -> void:
var fade := clampf(1.0 - progress * 0.6, 0.0, 1.0)
draw_arc(target, size.x * 0.036, PI * 0.15, PI * 0.85, 24, Color("#8c3f47", fade), 3.0)
var glyph := target - Vector2(0.0, size.y * 0.075)
draw_line(glyph + Vector2(-size.x * 0.008, -size.x * 0.008), glyph + Vector2(size.x * 0.008, size.x * 0.008), Color("#f2c7c1", fade), 3.0)
draw_line(glyph + Vector2(size.x * 0.008, -size.x * 0.008), glyph + Vector2(-size.x * 0.008, size.x * 0.008), Color("#f2c7c1", fade), 3.0)
func _draw_recovery_beat(progress: float, target: Vector2) -> void:
var gold := Color("#f0cd83")
draw_arc(target, lerpf(size.x * 0.012, size.x * 0.032, progress), 0.0, TAU, 30, Color(gold, (1.0 - progress) * 0.75), 2.5)
for index in 4:
var offset := (float(index) - 1.5) * size.x * 0.012
var mote := target + Vector2(offset, -progress * size.y * 0.055)
draw_circle(mote, maxf(1.6, size.x * 0.0022), Color(gold, (1.0 - progress) * 0.8))