From 70a34cf262c50febab6c43f45e0a702d860fa723 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Thu, 18 Jun 2026 22:34:39 +0900 Subject: [PATCH] Add tactic cast motion effects --- README.md | 1 + scripts/core/battle_state.gd | 13 +++++ scripts/scenes/battle_scene.gd | 97 +++++++++++++++++++++++++++++++++- tools/smoke_visual_assets.gd | 58 ++++++++++++++++++++ 4 files changed, 168 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7e6674d..5974ef4 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Floating combat text for damage, recovery, misses, support effects, poison ticks, action locks, level-ups, and promotions. - Short unit slide animation for board movement. - Board attacks now request class-specific motion profiles, with infantry strikes, archer traces, cavalry charges, command flashes, and heavy warrior/bandit impacts drawn by the battle scene. +- Damage, healing, and support tactics now request distinct cast motion profiles with flare, sigil, and banner-style battlefield effects. - Battle maps can use a high-resolution background image with translucent terrain overlays, terrain-specific detail patterns, connected roads, shoreline/castle/forest/hill edge blends, transparent generic-enemy class art, and token marks. - Hover target preview badges for attacks, tactics, and items. - Low-HP board unit warning rings, HP bar color states, and board status pips plus HUD duration summaries for support, debuff, poison, seal, snare, and disarm effects. diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index bc0028d..544db76 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -754,6 +754,7 @@ func try_cast_selected_skill(skill_id: String, target_cell: Vector2i) -> bool: if skill_kind == "support" and not _skill_has_support_effects(skill): return false + _emit_skill_motion(caster, target_cell, skill_kind) caster["mp"] = max(0, int(caster.get("mp", 0)) - int(skill.get("mp_cost", 0))) if skill_kind == "heal": var total_healed := 0 @@ -3153,6 +3154,7 @@ func _execute_ai_skill_action(caster: Dictionary, action: Dictionary) -> bool: if skill_kind == "support" and not _skill_has_support_effects(skill): return false + _emit_skill_motion(caster, target_cell, skill_kind) caster["mp"] = max(0, int(caster.get("mp", 0)) - int(skill.get("mp_cost", 0))) if skill_kind == "heal": var total_healed := 0 @@ -3267,6 +3269,17 @@ func _resolve_skill_damage(caster: Dictionary, target: Dictionary, skill: Dictio return false +func _emit_skill_motion(caster: Dictionary, target_cell: Vector2i, skill_kind: String) -> void: + if caster.is_empty() or not is_inside(target_cell): + return + unit_action_motion_requested.emit( + str(caster.get("id", "")), + caster.get("pos", Vector2i(-1, -1)), + target_cell, + "skill_%s" % skill_kind + ) + + func _handle_unit_defeated(unit: Dictionary) -> void: if unit.is_empty(): return diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 1d54fa8..89919c1 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -52,6 +52,7 @@ const UNIT_CAVALRY_ATTACK_DURATION := 0.42 const UNIT_ARROW_ATTACK_DURATION := 0.36 const UNIT_COMMAND_ATTACK_DURATION := 0.38 const UNIT_HEAVY_ATTACK_DURATION := 0.34 +const UNIT_TACTIC_CAST_DURATION := 0.42 const DIALOGUE_PANEL_POSITION := Vector2(96, 448) const DIALOGUE_PANEL_SIZE := Vector2(1040, 210) const DIALOGUE_PORTRAIT_SIZE := Vector2(164, 186) @@ -2051,6 +2052,12 @@ func _draw_action_effect(profile: String, start: Vector2, end: Vector2, progress _draw_command_attack_effect(start, end, progress, peak, team_color) elif profile == "heavy_strike": _draw_heavy_attack_effect(end, peak, team_color) + elif profile == "tactic_damage": + _draw_tactic_damage_effect(start, end, progress, peak, team_color) + elif profile == "tactic_heal": + _draw_tactic_heal_effect(start, end, progress, peak, team_color) + elif profile == "tactic_support": + _draw_tactic_support_effect(start, end, progress, peak, team_color) else: _draw_slash_attack_effect(end, peak, team_color) @@ -2119,6 +2126,47 @@ func _draw_heavy_attack_effect(end: Vector2, peak: float, team_color: Color) -> draw_arc(end, 10.0 + 18.0 * peak, 0.0, TAU, 48, Color(1.0, 0.68, 0.24, 0.26 * peak), 3.0) +func _draw_tactic_damage_effect(start: Vector2, end: Vector2, progress: float, peak: float, team_color: Color) -> void: + var caster_color := team_color.lightened(0.58) + caster_color.a = 0.34 * peak + var fire_color := Color(1.0, 0.38, 0.18, 0.52 * peak) + draw_arc(start, 12.0 + 7.0 * peak, progress * TAU, progress * TAU + PI * 1.55, 36, caster_color, 2.0) + draw_line(start, end, Color(1.0, 0.62, 0.28, 0.30 * peak), 2.0) + for index in range(3): + var angle := progress * TAU + float(index) * TAU / 3.0 + var spark := Vector2(cos(angle), sin(angle)) + draw_line(end - spark * (5.0 + 4.0 * peak), end + spark * (12.0 + 6.0 * peak), fire_color, 2.0) + draw_circle(end, 6.0 + 10.0 * peak, Color(1.0, 0.50, 0.18, 0.18 * peak)) + + +func _draw_tactic_heal_effect(start: Vector2, end: Vector2, progress: float, peak: float, team_color: Color) -> void: + var line_color := Color(0.48, 1.0, 0.60, 0.24 * peak) + if start.distance_to(end) > 0.01: + draw_line(start, end, line_color, 2.0) + var ring_color := Color(0.42, 1.0, 0.54, 0.42 * peak) + draw_arc(end, 9.0 + 12.0 * peak, -progress * TAU, -progress * TAU + PI * 1.70, 44, ring_color, 2.0) + draw_arc(end, 19.0 - 5.0 * peak, progress * TAU, progress * TAU + PI * 1.25, 40, Color(0.88, 1.0, 0.72, 0.32 * peak), 2.0) + draw_line(end + Vector2(-7, 0), end + Vector2(7, 0), Color(0.80, 1.0, 0.78, 0.54 * peak), 2.5) + draw_line(end + Vector2(0, -7), end + Vector2(0, 7), Color(0.80, 1.0, 0.78, 0.54 * peak), 2.5) + var caster_glow := team_color.lightened(0.62) + caster_glow.a = 0.20 * peak + draw_circle(start, 6.0 + 4.0 * peak, caster_glow) + + +func _draw_tactic_support_effect(start: Vector2, end: Vector2, progress: float, peak: float, team_color: Color) -> void: + var banner := team_color.lightened(0.48) + banner.a = 0.46 * peak + if start.distance_to(end) > 0.01: + draw_line(start, end, Color(1.0, 0.86, 0.30, 0.20 * peak), 1.5) + draw_line(end + Vector2(-10, -16), end + Vector2(-10, 12), banner, 2.5) + draw_rect(Rect2(end + Vector2(-8, -15), Vector2(20, 12)), Color(1.0, 0.82, 0.22, 0.28 * peak)) + draw_arc(end, 12.0 + 8.0 * peak, progress * TAU, progress * TAU + PI * 1.45, 36, Color(1.0, 0.92, 0.38, 0.36 * peak), 2.0) + for index in range(3): + var angle := -PI * 0.5 + float(index - 1) * 0.52 + var ray := Vector2(cos(angle), sin(angle)) + draw_line(end + ray * 7.0, end + ray * (18.0 + 5.0 * peak), Color(1.0, 0.96, 0.62, 0.25 * peak), 1.5) + + func _draw_slash_attack_effect(end: Vector2, peak: float, team_color: Color) -> void: var flash := team_color.lightened(0.45) flash.a = 0.55 * peak @@ -2164,6 +2212,15 @@ func _draw_action_impact_cue(cue: String, start: Vector2, end: Vector2, progress var angle := float(index) * PI * 0.5 + progress * PI * 0.25 var crack := Vector2(cos(angle), sin(angle)) draw_line(end + crack * radius * 0.16, end + crack * radius * (0.54 + 0.16 * peak), Color(0.96, 0.82, 0.42, 0.34 * peak), 2.0) + elif cue == "tactic_damage": + draw_arc(end, radius * (0.35 + peak * 0.38), 0.0, TAU, 44, Color(1.0, 0.34, 0.18, 0.28 * peak), 3.0) + draw_arc(end, radius * 0.62, progress * TAU, progress * TAU + PI * 1.20, 36, Color(1.0, 0.82, 0.32, 0.34 * peak), 2.0) + elif cue == "tactic_heal": + draw_arc(end, radius * (0.40 + peak * 0.24), 0.0, TAU, 48, Color(0.48, 1.0, 0.54, 0.30 * peak), 2.5) + draw_circle(end, radius * 0.14 + radius * 0.10 * peak, Color(0.78, 1.0, 0.74, 0.22 * peak)) + elif cue == "tactic_support": + draw_arc(end, radius * (0.48 + peak * 0.18), -PI * 0.10, PI * 1.10, 40, Color(1.0, 0.88, 0.28, 0.30 * peak), 2.5) + draw_line(end + Vector2(-radius * 0.38, radius * 0.26), end + Vector2(radius * 0.38, radius * 0.26), Color(1.0, 0.94, 0.50, 0.30 * peak), 2.0) else: draw_line(end - side * radius * 0.48, end + side * radius * 0.48, hot, 2.0) draw_circle(end, radius * 0.32 + radius * 0.18 * peak, Color(1.0, 0.82, 0.34, 0.18 * peak)) @@ -3219,10 +3276,12 @@ func _on_unit_motion_requested(unit_id: String, from_cell: Vector2i, to_cell: Ve func _on_unit_action_motion_requested(unit_id: String, from_cell: Vector2i, to_cell: Vector2i, action_kind: String) -> void: - if unit_id.is_empty() or from_cell == to_cell: + if unit_id.is_empty(): return var unit := state.get_unit(unit_id) var profile := _unit_action_motion_profile(unit, from_cell, to_cell, action_kind) + if from_cell == to_cell and not _action_motion_allows_same_cell(profile): + return unit_action_motion_by_unit[unit_id] = { "from": from_cell, "to": to_cell, @@ -3242,6 +3301,12 @@ func _on_unit_action_motion_requested(unit_id: String, from_cell: Vector2i, to_c func _unit_action_motion_profile(unit: Dictionary, from_cell: Vector2i, to_cell: Vector2i, _action_kind: String) -> String: + if _action_kind == "skill_heal": + return "tactic_heal" + if _action_kind == "skill_support": + return "tactic_support" + if _action_kind.begins_with("skill_"): + return "tactic_damage" var class_id := str(unit.get("class_id", "")) var distance := absi(from_cell.x - to_cell.x) + absi(from_cell.y - to_cell.y) if class_id.contains("archer") or class_id.contains("marksman") or (int(unit.get("range", 1)) > 1 and distance > 1): @@ -3260,6 +3325,8 @@ func _unit_action_motion_profile(unit: Dictionary, from_cell: Vector2i, to_cell: func _action_motion_style(profile: String) -> String: if profile == "arrow": return "ranged" + if profile.begins_with("tactic_"): + return "tactic" return "melee" @@ -3274,6 +3341,8 @@ func _action_motion_duration(profile: String) -> float: return UNIT_COMMAND_ATTACK_DURATION if profile == "heavy_strike": return UNIT_HEAVY_ATTACK_DURATION + if profile.begins_with("tactic_"): + return UNIT_TACTIC_CAST_DURATION return UNIT_ATTACK_ANIMATION_DURATION @@ -3288,9 +3357,15 @@ func _action_motion_lunge_scale(profile: String) -> float: return 0.20 if profile == "heavy_strike": return 1.15 + if profile.begins_with("tactic_"): + return 0.12 return 1.0 +func _action_motion_allows_same_cell(profile: String) -> bool: + return profile.begins_with("tactic_") + + func _action_motion_vfx_signature(profile: String) -> String: if profile == "arrow": return "piercing_trace" @@ -3302,6 +3377,12 @@ func _action_motion_vfx_signature(profile: String) -> String: return "command_ring" if profile == "heavy_strike": return "shock_crack" + if profile == "tactic_damage": + return "tactic_flare" + if profile == "tactic_heal": + return "healing_sigil" + if profile == "tactic_support": + return "order_banner" return "slash_flash" @@ -3316,6 +3397,12 @@ func _action_motion_impact_cue(profile: String) -> String: return "command" if profile == "heavy_strike": return "crush" + if profile == "tactic_damage": + return "tactic_damage" + if profile == "tactic_heal": + return "tactic_heal" + if profile == "tactic_support": + return "tactic_support" return "slash" @@ -3330,6 +3417,12 @@ func _action_motion_impact_radius(profile: String) -> float: return 24.0 if profile == "heavy_strike": return 28.0 + if profile == "tactic_damage": + return 26.0 + if profile == "tactic_heal": + return 23.0 + if profile == "tactic_support": + return 24.0 return 16.0 @@ -3344,6 +3437,8 @@ func _action_motion_sfx(profile: String) -> String: return "skill_cast" if profile == "heavy_strike": return "slash" + if profile.begins_with("tactic_"): + return "skill_cast" return "slash" diff --git a/tools/smoke_visual_assets.gd b/tools/smoke_visual_assets.gd index 879d127..6b77e32 100644 --- a/tools/smoke_visual_assets.gd +++ b/tools/smoke_visual_assets.gd @@ -449,20 +449,34 @@ func _check_attack_motion_profiles(failures: Array[String]) -> void: _check_synthetic_attack_profile(failures, scene, "commander", "command_strike") _check_synthetic_attack_profile(failures, scene, "champion", "heavy_strike") _check_synthetic_attack_profile(failures, scene, "bandit", "heavy_strike") + _check_skill_action_profile(failures, scene, "skill_damage", "tactic_damage", "tactic_flare") + _check_skill_action_profile(failures, scene, "skill_heal", "tactic_heal", "healing_sigil") + _check_skill_action_profile(failures, scene, "skill_support", "tactic_support", "order_banner") scene._on_unit_action_motion_requested("xiahou_dun", Vector2i(1, 4), Vector2i(2, 4), "attack") _check_stored_attack_motion(failures, scene, "xiahou_dun", "cavalry_charge", "melee", "attack", "charge_dust") scene._on_unit_action_motion_requested("yellow_turban_3", Vector2i(7, 6), Vector2i(7, 4), "attack") _check_stored_attack_motion(failures, scene, "yellow_turban_3", "arrow", "ranged", "attack", "piercing_trace") + scene._on_unit_action_motion_requested("cao_cao", Vector2i(1, 3), Vector2i(3, 3), "skill_damage") + _check_stored_attack_motion(failures, scene, "cao_cao", "tactic_damage", "tactic", "skill_damage", "tactic_flare") + scene._on_unit_action_motion_requested("cao_cao", Vector2i(1, 3), Vector2i(1, 3), "skill_heal") + _check_stored_attack_motion(failures, scene, "cao_cao", "tactic_heal", "tactic", "skill_heal", "healing_sigil") _check_attack_vfx_signature(failures, scene, "arrow", "piercing_trace") _check_attack_vfx_signature(failures, scene, "cavalry_charge", "charge_dust") _check_attack_vfx_signature(failures, scene, "infantry_strike", "shield_spark") _check_attack_vfx_signature(failures, scene, "command_strike", "command_ring") _check_attack_vfx_signature(failures, scene, "heavy_strike", "shock_crack") + _check_attack_vfx_signature(failures, scene, "tactic_damage", "tactic_flare") + _check_attack_vfx_signature(failures, scene, "tactic_heal", "healing_sigil") + _check_attack_vfx_signature(failures, scene, "tactic_support", "order_banner") _check_unique_attack_vfx_signatures(failures, scene) if scene._action_motion_duration("cavalry_charge") <= scene._action_motion_duration("slash"): failures.append("cavalry charge animation should last longer than default slash") + if scene._action_motion_duration("tactic_damage") <= scene._action_motion_duration("command_strike"): + failures.append("tactic cast animation should linger longer than command physical strike") if scene._action_motion_lunge_scale("arrow") >= scene._action_motion_lunge_scale("slash"): failures.append("arrow animation should keep the archer mostly planted") + if scene._action_motion_lunge_scale("tactic_damage") >= scene._action_motion_lunge_scale("arrow"): + failures.append("tactic animation should keep the caster planted like a spell cast") if scene._action_motion_impact_radius("heavy_strike") <= scene._action_motion_impact_radius("infantry_strike"): failures.append("heavy strike impact radius should exceed infantry strike") if scene._action_motion_impact_radius("cavalry_charge") <= scene._action_motion_impact_radius("arrow"): @@ -473,6 +487,8 @@ func _check_attack_motion_profiles(failures: Array[String]) -> void: failures.append("infantry animation should use slash SFX") if scene._action_motion_sfx("heavy_strike") != "slash": 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") scene.free() @@ -496,6 +512,16 @@ func _check_synthetic_attack_profile(failures: Array[String], scene, class_id: S failures.append("%s synthetic attack profile mismatch: expected %s got %s" % [class_id, expected_profile, profile]) +func _check_skill_action_profile(failures: Array[String], scene, action_kind: String, expected_profile: String, expected_signature: String) -> void: + var profile: String = scene._unit_action_motion_profile({"class_id": "hero", "range": 1}, Vector2i(0, 0), Vector2i(0, 0), action_kind) + if profile != expected_profile: + failures.append("%s action profile mismatch: expected %s got %s" % [action_kind, expected_profile, profile]) + if not scene._action_motion_allows_same_cell(profile): + failures.append("%s should allow same-cell tactic motion" % profile) + if scene._action_motion_vfx_signature(profile) != expected_signature: + failures.append("%s action VFX mismatch: expected %s got %s" % [action_kind, expected_signature, scene._action_motion_vfx_signature(profile)]) + + func _check_attack_vfx_signature(failures: Array[String], scene, profile: String, expected_signature: String) -> void: var signature: String = scene._action_motion_vfx_signature(profile) if signature != expected_signature: @@ -569,6 +595,38 @@ func _check_attack_motion_signal(failures: Array[String]) -> void: return if not motions.has("cao_cao|attack|6,2|7,2"): failures.append("attack motion signal missing expected Cao Cao attack: %s" % str(motions)) + _check_skill_motion_signal(failures, "spark", "skill_damage", Vector2i(3, 3), func(skill_state) -> void: + var enemy: Dictionary = skill_state.get_unit("yellow_turban_1") + enemy["pos"] = Vector2i(3, 3) + ) + _check_skill_motion_signal(failures, "mend", "skill_heal", Vector2i(1, 3), func(skill_state) -> void: + var caster: Dictionary = skill_state.get_unit("cao_cao") + caster["hp"] = max(1, int(caster.get("hp", 1)) - 10) + ) + _check_skill_motion_signal(failures, "guard_order", "skill_support", Vector2i(1, 3), func(_skill_state) -> void: + pass + ) + + +func _check_skill_motion_signal(failures: Array[String], skill_id: String, expected_kind: String, target_cell: Vector2i, setup: Callable) -> void: + var state = BattleStateScript.new() + if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("could not load battle for %s motion" % skill_id) + return + setup.call(state) + var motions: Array[String] = [] + state.unit_action_motion_requested.connect(func(unit_id: String, from_cell: Vector2i, to_cell: Vector2i, action_kind: String) -> void: + motions.append("%s|%s|%d,%d|%d,%d" % [unit_id, action_kind, from_cell.x, from_cell.y, to_cell.x, to_cell.y]) + ) + if not state.select_unit("cao_cao"): + failures.append("could not select Cao Cao for %s motion" % skill_id) + return + if not state.try_cast_selected_skill(skill_id, target_cell): + failures.append("%s should cast successfully for motion check" % skill_id) + return + var expected := "cao_cao|%s|1,3|%d,%d" % [expected_kind, target_cell.x, target_cell.y] + if not motions.has(expected): + failures.append("%s motion signal missing `%s`: %s" % [skill_id, expected, str(motions)]) func _check_camp_conversation_effect(failures: Array[String], conversation: Dictionary, expected_item_id: String, expected_count: int) -> void: