diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 89919c1..4ece2ff 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -2003,8 +2003,9 @@ func _visual_rect_for_unit(unit: Dictionary) -> Rect2: var action_duration: float = maxf(0.01, float(action_motion.get("duration", UNIT_ATTACK_ANIMATION_DURATION))) var action_age := float(action_motion.get("age", 0.0)) var action_progress := clampf(action_age / action_duration, 0.0, 1.0) - var lunge_scale := _action_motion_lunge_scale(str(action_motion.get("profile", "slash"))) - rect.position += direction * sin(action_progress * PI) * UNIT_ATTACK_LUNGE_DISTANCE * lunge_scale + var profile := str(action_motion.get("profile", "slash")) + var lunge_scale := _action_motion_lunge_scale(profile) + rect.position += direction * _action_motion_lunge_curve(profile, action_progress) * UNIT_ATTACK_LUNGE_DISTANCE * lunge_scale return rect @@ -2021,7 +2022,8 @@ func _draw_attack_effects() -> void: var duration: float = maxf(0.01, float(motion.get("duration", UNIT_ATTACK_ANIMATION_DURATION))) var age := float(motion.get("age", 0.0)) var progress := clampf(age / duration, 0.0, 1.0) - var peak := sin(progress * PI) + var profile := str(motion.get("profile", "slash")) + var peak := _action_motion_effect_peak(profile, progress) if peak <= 0.0: continue var from_cell: Vector2i = motion.get("from", Vector2i(-1, -1)) @@ -2034,7 +2036,6 @@ func _draw_attack_effects() -> void: var unit := state.get_unit(str(unit_id)) if not unit.is_empty() and unit.get("team", "") == BattleState.TEAM_ENEMY: team_color = ENEMY_COLOR - var profile := str(motion.get("profile", "slash")) var impact_cue := str(motion.get("impact_cue", _action_motion_impact_cue(profile))) var impact_radius := float(motion.get("impact_radius", _action_motion_impact_radius(profile))) _draw_action_effect(profile, start, end, progress, peak, team_color) @@ -3362,6 +3363,51 @@ func _action_motion_lunge_scale(profile: String) -> float: return 1.0 +func _action_motion_lunge_curve(profile: String, progress: float) -> float: + var p := clampf(progress, 0.0, 1.0) + if profile == "arrow": + return sin(p * PI) * 0.32 + if profile == "cavalry_charge": + if p < 0.75: + return _action_motion_smoothstep(p / 0.75) + return 1.0 - _action_motion_smoothstep((p - 0.75) / 0.25) + if profile == "infantry_strike": + return sin(p * PI) * (0.72 + 0.28 * (1.0 - p)) + if profile == "command_strike" or profile.begins_with("tactic_"): + return sin(p * PI) * 0.30 + if profile == "heavy_strike": + var late := _action_motion_smoothstep(clampf((p - 0.18) / 0.72, 0.0, 1.0)) + var recovery := 1.0 - _action_motion_smoothstep(clampf((p - 0.72) / 0.28, 0.0, 1.0)) + return late * recovery + return sin(p * PI) + + +func _action_motion_effect_peak(profile: String, progress: float) -> float: + var p := clampf(progress, 0.0, 1.0) + if profile == "arrow": + return pow(1.0 - abs(p - 0.34) / 0.34, 2.0) if p <= 0.68 else 0.0 + if profile == "cavalry_charge": + return _action_motion_smoothstep(p) * (1.0 - _action_motion_smoothstep(clampf((p - 0.82) / 0.18, 0.0, 1.0)) * 0.22) + if profile == "infantry_strike": + return sin(p * PI) + if profile == "command_strike": + return 0.42 + 0.58 * sin(p * PI) + if profile == "heavy_strike": + return pow(maxf(0.0, sin(p * PI)), 0.62) * _action_motion_smoothstep(clampf((p - 0.18) / 0.40, 0.0, 1.0)) + if profile == "tactic_damage": + return 0.28 + 0.72 * sin(p * PI) + if profile == "tactic_heal": + return 0.36 + 0.64 * sin(p * PI) + if profile == "tactic_support": + return 0.32 + 0.68 * sin(p * PI) + return sin(p * PI) + + +func _action_motion_smoothstep(value: float) -> float: + var p := clampf(value, 0.0, 1.0) + return p * p * (3.0 - 2.0 * p) + + func _action_motion_allows_same_cell(profile: String) -> bool: return profile.begins_with("tactic_") diff --git a/tools/smoke_visual_assets.gd b/tools/smoke_visual_assets.gd index 6b77e32..5088ee5 100644 --- a/tools/smoke_visual_assets.gd +++ b/tools/smoke_visual_assets.gd @@ -489,6 +489,7 @@ func _check_attack_motion_profiles(failures: Array[String]) -> void: failures.append("heavy animation should use swing SFX before hit resolution") if scene._action_motion_sfx("tactic_damage") != "skill_cast": failures.append("tactic animation should use skill cast SFX") + _check_attack_motion_curves(failures, scene) scene.free() @@ -569,6 +570,46 @@ func _check_stored_attack_motion(failures: Array[String], scene, unit_id: String failures.append("%s stored motion duration should be positive: %s" % [unit_id, str(motion)]) +func _check_attack_motion_curves(failures: Array[String], scene) -> void: + var profiles := ["arrow", "cavalry_charge", "infantry_strike", "command_strike", "heavy_strike", "tactic_damage", "tactic_heal", "tactic_support", "slash"] + for profile in profiles: + for sample in [0.0, 0.25, 0.5, 0.75, 1.0]: + var lunge: float = scene._action_motion_lunge_curve(profile, sample) + var peak: float = scene._action_motion_effect_peak(profile, sample) + if lunge < -0.001 or lunge > 1.001: + failures.append("%s lunge curve out of bounds at %.2f: %.3f" % [profile, sample, lunge]) + if peak < -0.001 or peak > 1.001: + failures.append("%s effect peak out of bounds at %.2f: %.3f" % [profile, sample, peak]) + if absf(scene._action_motion_lunge_curve(profile, 0.0)) > 0.001: + failures.append("%s lunge should start near zero" % profile) + if absf(scene._action_motion_lunge_curve(profile, 1.0)) > 0.001: + failures.append("%s lunge should recover near zero" % profile) + + var arrow_mid: float = scene._action_motion_lunge_curve("arrow", 0.5) * scene._action_motion_lunge_scale("arrow") + var infantry_mid: float = scene._action_motion_lunge_curve("infantry_strike", 0.5) * scene._action_motion_lunge_scale("infantry_strike") + var cavalry_mid: float = scene._action_motion_lunge_curve("cavalry_charge", 0.5) * scene._action_motion_lunge_scale("cavalry_charge") + var heavy_early: float = scene._action_motion_lunge_curve("heavy_strike", 0.25) * scene._action_motion_lunge_scale("heavy_strike") + var heavy_late: float = scene._action_motion_lunge_curve("heavy_strike", 0.72) * scene._action_motion_lunge_scale("heavy_strike") + var tactic_mid: float = scene._action_motion_lunge_curve("tactic_damage", 0.5) * scene._action_motion_lunge_scale("tactic_damage") + if not (arrow_mid < infantry_mid and tactic_mid < infantry_mid): + failures.append("lunge curves should keep arrow and tactic planted below infantry: arrow %.3f tactic %.3f infantry %.3f" % [arrow_mid, tactic_mid, infantry_mid]) + if cavalry_mid <= infantry_mid: + failures.append("cavalry mid-lunge should exceed infantry: cavalry %.3f infantry %.3f" % [cavalry_mid, infantry_mid]) + if heavy_late <= heavy_early: + failures.append("heavy strike should build toward a later lunge: early %.3f late %.3f" % [heavy_early, heavy_late]) + + if scene._action_motion_effect_peak("arrow", 0.34) <= scene._action_motion_effect_peak("arrow", 0.82): + failures.append("arrow effect should peak early and fade after flight") + if scene._action_motion_effect_peak("heavy_strike", 0.62) <= scene._action_motion_effect_peak("heavy_strike", 0.25): + failures.append("heavy effect should peak later than its wind-up") + if scene._action_motion_effect_peak("heavy_strike", 1.0) > 0.001: + failures.append("heavy effect should fade near zero by the end") + if scene._action_motion_effect_peak("command_strike", 0.0) <= 0.0: + failures.append("command effect should keep a visible ring during wind-up") + if scene._action_motion_effect_peak("tactic_heal", 0.0) <= 0.0: + failures.append("tactic heal effect should keep a visible sigil during wind-up") + + func _check_attack_motion_signal(failures: Array[String]) -> void: var state = BattleStateScript.new() if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"):