404 lines
17 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|