Files
mod_card_battle/src/Actions/PhysicsAttackAction.cs
T
2026-10-16 16:18:12 +00:00

404 lines
17 KiB
C#

using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Cthangover.TurnBattle;
using Cthangover.Core.Characters;
using Cthangover.CardBattle.UI;
using Godot;
namespace Cthangover.CardBattle.Actions
{
/// <summary>
/// Animated battle action that moves the source card toward an enemy target, applies damage/defence
/// via <see cref="ActionExecutorHub"/>, and animates the target's knockback/squash reaction.
/// The source card follows one of five randomly-selected <see cref="MovementPattern"/> trajectories
/// (direct, arc, zigzag, spiral, dash). This is the visual counterpart of <see cref="PhysicsDamageActionCard"/>
/// — it handles the presentation while the executor handles the stat changes.
/// Created by <see cref="CardBattleCore.CreateAnimatedAction"/> for enemy turns.
/// </summary>
public class PhysicsAttackAction : AbstractBattleAction
{
/// <summary>
/// Pre-defined movement trajectory patterns for the source card's approach to the target.
/// Selected randomly at construction to add visual variety to enemy attack animations.
/// </summary>
public enum MovementPattern
{
Direct,
Arc,
Zigzag,
Spiral,
Dash
}
private ActionPhase phase = ActionPhase.Wait;
private float attackRotation;
private bool damageApplied;
private bool stunEffectSpawned;
private Vector2 targetOriginalPos;
private Vector2 targetOriginalScale;
private Vector2 knockbackDirection;
private float targetReactionIntensity = 0.3f;
private Vector2 knockbackPos;
private float shake;
private Vector2 squashScale;
private float waitDuration = 0.2f;
private float prepareDuration = 0.3f;
private float moveForAttackDuration = 0.4f;
private float attackImpactDuration = 0.3f;
private float targetReactionDuration = 0.4f;
private float moveForBackDuration = 0.5f;
private float recoverDuration = 0.3f;
private MovementPattern movementPattern = MovementPattern.Direct;
private Vector2 movementControlPoint;
/// <summary>
/// Creates an attack animation from <paramref name="source"/> to <paramref name="target"/>
/// using the stats defined in <paramref name="action"/>. The <see cref="MovementPattern"/> is
/// randomly selected on construction to make enemy attacks visually distinct.
/// <paramref name="speed"/> acts as a multiplier on all phase durations.
/// </summary>
public PhysicsAttackAction(CharacterCardNode source, CharacterCardNode target, ActionCharacter action, float speed = 1)
: base(source, target, action, speed)
{
movementPattern = (MovementPattern)GD.RandRange(0, 5);
}
/// <summary>Resets the phase clock and starts the attack sequence from the Wait phase.</summary>
protected override void DoInternalStart()
{
Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
damageApplied = false;
stunEffectSpawned = false;
phase = ActionPhase.Wait;
targetOriginalScale = TargetControl.Scale;
}
private float GetPhaseProgress()
{
float elapsed = (float)(Godot.Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Godot.Engine.TimeScale;
float duration = GetCurrentPhaseDuration();
return Mathf.Clamp(elapsed / duration, 0f, 1f);
}
private float GetCurrentPhaseDuration()
{
switch (phase)
{
case ActionPhase.Wait: return waitDuration;
case ActionPhase.Prepare: return prepareDuration;
case ActionPhase.MoveForAttack: return moveForAttackDuration;
case ActionPhase.AttackImpact: return attackImpactDuration;
case ActionPhase.TargetReaction: return targetReactionDuration;
case ActionPhase.MoveForBack: return moveForBackDuration;
case ActionPhase.Recover: return recoverDuration;
default: return 1f;
}
}
private void ResetTimestamp()
{
Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
}
/// <summary>Advances the multi-phase attack: dash in, impact, damage/stun application, return and recover.</summary>
protected override bool DoInternalAction()
{
float progress = GetPhaseProgress();
switch (phase)
{
case ActionPhase.Wait:
if (progress >= 1f)
{
phase = ActionPhase.Prepare;
ResetTimestamp();
targetOriginalPos = TargetControl.GlobalPosition;
targetOriginalScale = TargetControl.Scale;
knockbackDirection = (TargetControl.GlobalPosition - SourceControl.GlobalPosition).Normalized();
InitializeMovementPattern();
}
break;
case ActionPhase.Prepare:
{
float prepareProgress = EaseOutQuad(Mathf.Min(progress, 1f));
Vector2 preparePosition = GetMovementPosition(prepareProgress * 0.1f);
SourceControl.GlobalPosition = SourcePos.Lerp(preparePosition, prepareProgress);
SourceControl.Rotation = Mathf.Lerp(SourceRot, TargetRot, prepareProgress * 0.3f);
if (progress >= 1f)
{
phase = ActionPhase.MoveForAttack;
ResetTimestamp();
}
break;
}
case ActionPhase.MoveForAttack:
{
float attackProgress = EaseInOutQuad(Mathf.Min(progress, 1f));
Vector2 targetPosition = GetMovementPosition(attackProgress);
SourceControl.GlobalPosition = targetPosition;
SourceControl.Rotation = Mathf.Lerp(SourceRot, TargetRot, attackProgress);
if (progress >= 1f)
{
phase = ActionPhase.AttackImpact;
ResetTimestamp();
attackRotation = (float)GD.RandRange(-35f, 35f) * Mathf.Pi / 180f;
}
break;
}
case ActionPhase.AttackImpact:
{
float impactProgress = EaseInOutQuad(Mathf.Min(progress * 3f, 1f));
SourceControl.Rotation = Mathf.Lerp(TargetRot, attackRotation, impactProgress);
if (!damageApplied && progress >= 0.3f)
{
damageApplied = true;
var result = ActionExecutorHub.Instance.Execute(Action, Source.Card, Target.Card);
Source.UpdateInfo();
Target.UpdateInfo();
if (result.Result)
{
LogAttackResult(result);
}
else
{
GameLogger.Log("CARD_ACT",
$"PhysicsAttackAction: failed to execute action {Action.Name} (source={Source.Card.Name} target={Target.Card.Name})",
LogLevel.Error);
}
}
if (progress >= 1f)
{
phase = ActionPhase.TargetReaction;
ResetTimestamp();
}
break;
}
case ActionPhase.TargetReaction:
{
float reactionProgress = EaseInOutQuad(Mathf.Min(progress, 1f));
bool isStun = Action != null && Action.GetInt("Turn", 0) > 1;
float currentKnockback = targetReactionIntensity;
if (isStun)
currentKnockback *= 2.5f;
knockbackPos = targetOriginalPos + knockbackDirection * currentKnockback;
TargetControl.GlobalPosition = targetOriginalPos.Lerp(knockbackPos, reactionProgress);
squashScale = targetOriginalScale;
float currentSquash = isStun ? 0.35f : 0.2f;
squashScale.X *= 1f - reactionProgress * currentSquash;
squashScale.Y *= 1f + reactionProgress * (currentSquash * 0.5f);
TargetControl.Scale = squashScale;
float currentShakeMultiplier = isStun ? 1.8f : 1f;
shake = Mathf.Sin(reactionProgress * Mathf.Pi * (isStun ? 6f : 4f)) * (1f - reactionProgress) * 0.05f * currentShakeMultiplier;
TargetControl.Rotation = shake * 30f * Mathf.Pi / 180f;
if (progress >= 1f)
{
phase = ActionPhase.MoveForBack;
ResetTimestamp();
}
break;
}
case ActionPhase.MoveForBack:
{
float backProgress = EaseOutQuad(Mathf.Min(progress, 1f));
Vector2 backPosition = GetMovementPosition(1f - progress);
SourceControl.GlobalPosition = backPosition.Lerp(SourcePos, backProgress);
SourceControl.Rotation = Mathf.Lerp(attackRotation, SourceRot, backProgress);
TargetControl.GlobalPosition = knockbackPos.Lerp(targetOriginalPos, backProgress);
TargetControl.Scale = squashScale.Lerp(targetOriginalScale, backProgress);
float currentShake = Mathf.Sin(backProgress * Mathf.Pi) * shake * (1f - backProgress);
TargetControl.Rotation = Mathf.Lerp(currentShake * 30f * Mathf.Pi / 180f, TargetRot, backProgress);
if (progress >= 1f)
{
phase = ActionPhase.Recover;
ResetTimestamp();
}
break;
}
case ActionPhase.Recover:
{
float recoverProgress = EaseOutQuad(Mathf.Min(progress, 1f));
SourceControl.GlobalPosition = SourceControl.GlobalPosition.Lerp(SourcePos, recoverProgress);
SourceControl.Rotation = Mathf.Lerp(SourceControl.Rotation, SourceRot, recoverProgress);
TargetControl.GlobalPosition = TargetControl.GlobalPosition.Lerp(TargetPos, recoverProgress);
TargetControl.Rotation = Mathf.Lerp(TargetControl.Rotation, TargetRot, recoverProgress);
TargetControl.Scale = TargetControl.Scale.Lerp(targetOriginalScale, recoverProgress);
if (progress >= 1f)
return true;
break;
}
default:
return true;
}
return false;
}
/// <summary>Restores the target's captured position, scale and rotation after the attack animation.</summary>
protected override void DoInternalEnd()
{
if (SourceControl != null)
{
SourceControl.GlobalPosition = SourcePos;
SourceControl.Rotation = SourceRot;
}
if (TargetControl != null)
{
TargetControl.GlobalPosition = TargetPos;
TargetControl.Rotation = TargetRot;
TargetControl.Scale = targetOriginalScale;
}
}
/// <summary>
/// Mirrors the resolved action onto the UI: floating damage / absorbed
/// shield numbers, a colour pulse on the target card and journal rows, so
/// an AI attack reads the same as the player's own attack. Stun actions
/// (a "Turn" above 1) flash the electric zap instead of damage numbers.
/// </summary>
private void LogAttackResult(ChangedAttributes result)
{
var targetName = TranslationServer.Translate(Target.Card.Name).ToString();
var isStun = Action != null && Action.GetInt("Turn", 0) > 1;
var defenceLost = -result.Target.Defence;
if (defenceLost > 0)
{
ShowDamageBehaviour.SpawnDefence(defenceLost, TargetControl, TargetControl.GlobalPosition);
BattleLogHub.Log(
string.Format(Translate("battle/log_shield_absorb"), targetName, defenceLost),
UI.BattleLogKind.Shield);
}
if (isStun)
{
Target.PlayZap();
BattleLogHub.Log(
string.Format(Translate("battle/log_stun"), targetName, Action.GetInt("Turn", 0)),
UI.BattleLogKind.Status);
return;
}
if (result.Target.Damage > 0)
{
ShowDamageBehaviour.SpawnDamage(result.Target.Damage, TargetControl, TargetControl.GlobalPosition);
Target.Flash(UiTokens.DangerText);
BattleLogHub.Log(
string.Format(Translate("battle/log_damage"),
Translate(Action.Name),
TranslationServer.Translate(Source.Card.Name).ToString(),
targetName,
result.Target.Damage),
UI.BattleLogKind.Damage);
}
}
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
private void InitializeMovementPattern()
{
Vector2 direction = (TargetPos - SourcePos).Normalized();
Vector2 perpendicular = new Vector2(-direction.Y, direction.X);
switch (movementPattern)
{
case MovementPattern.Arc:
movementControlPoint = SourcePos + direction * 0.5f + perpendicular * 2f;
break;
case MovementPattern.Zigzag:
movementControlPoint = SourcePos + direction * 0.5f + perpendicular * 1.5f;
break;
case MovementPattern.Spiral:
movementControlPoint = TargetPos + perpendicular * 1.5f;
break;
case MovementPattern.Dash:
movementControlPoint = SourcePos + direction * 0.3f;
break;
default:
movementControlPoint = TargetPos;
break;
}
}
private Vector2 GetMovementPosition(float progress)
{
switch (movementPattern)
{
case MovementPattern.Direct:
return SourcePos.Lerp(TargetPos, progress);
case MovementPattern.Arc:
return SourcePos.Lerp(movementControlPoint, progress)
.Lerp(movementControlPoint.Lerp(TargetPos, progress), progress);
case MovementPattern.Zigzag:
if (progress < 0.5f)
return SourcePos.Lerp(movementControlPoint, progress * 2f);
else
return movementControlPoint.Lerp(TargetPos, (progress - 0.5f) * 2f);
case MovementPattern.Spiral:
{
Vector2 toTarget = TargetPos - SourcePos;
float angle = progress * Mathf.Pi * 2f;
float radius = (1f - progress) * 1.5f;
Vector2 spiralOffset = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius;
return SourcePos.Lerp(TargetPos, progress) + spiralOffset;
}
case MovementPattern.Dash:
if (progress < 0.3f)
return SourcePos.Lerp(movementControlPoint, progress / 0.3f);
else if (progress < 0.7f)
return movementControlPoint;
else
return movementControlPoint.Lerp(TargetPos, (progress - 0.7f) / 0.3f);
default:
return SourcePos.Lerp(TargetPos, progress);
}
}
private float EaseOutQuad(float t)
{
return 1f - (1f - t) * (1f - t);
}
private float EaseInOutQuad(float t)
{
return t < 0.5f ? 2f * t * t : 1f - Mathf.Pow(-2f * t + 2f, 2f) / 2f;
}
}
}