init
This commit is contained in:
commit
27f38c6c1f
50 files changed
+3403
No files matched your search
@@ -0,0 +1,87 @@
|
||||
using Cthangover.CardBattle.UI;
|
||||
using Cthangover.Core.Battle.Actions;
|
||||
using Cthangover.Core.Characters;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.CardBattle.Actions
|
||||
{
|
||||
/// <summary>
|
||||
/// Base class for animated battle actions that move, rotate, and scale card controls
|
||||
/// through a sequence of visual phases. Subclasses implement <c>DoInternalAction</c> to advance
|
||||
/// a frame-by-frame animation state machine driven by <see cref="CardBattleCore"/>'s
|
||||
/// per-frame polling loop (<c>while (!DoAction()) await frame;</c>).
|
||||
/// Captures source/target positions and rotations in <see cref="DoStart"/> so that
|
||||
/// <see cref="DoEnd"/> can restore them regardless of what the animation did.
|
||||
/// </summary>
|
||||
public abstract class AbstractBattleAction : IBattleAction
|
||||
{
|
||||
protected double Timestamp { get; set; } = -1;
|
||||
protected float Speed { get; set; }
|
||||
protected ActionCharacter Action { get; set; }
|
||||
protected CharacterCardNode Source { get; set; }
|
||||
protected CharacterCardNode Target { get; set; }
|
||||
protected Control SourceControl { get; set; }
|
||||
protected Vector2 SourcePos { get; set; }
|
||||
protected float SourceRot { get; set; }
|
||||
protected Control TargetControl { get; set; }
|
||||
protected Vector2 TargetPos { get; set; }
|
||||
protected float TargetRot { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Advances the animation by one frame. Called in a loop by <see cref="CardBattleCore.RunEnemyTurn"/>
|
||||
/// with <c>await root.ToSignal(frame)</c> between calls. Returns <c>true</c> when the animation
|
||||
/// has completed all phases and the action result has been applied.
|
||||
/// Returns <c>true</c> early if any required node became null (card was destroyed mid-animation).
|
||||
/// </summary>
|
||||
/// <returns><c>true</c> when the animation is finished or cannot proceed.</returns>
|
||||
public bool DoAction()
|
||||
{
|
||||
if (Source == null || Target == null || Action == null || SourceControl == null || TargetControl == null)
|
||||
return true;
|
||||
return DoInternalAction();
|
||||
}
|
||||
|
||||
protected abstract bool DoInternalAction();
|
||||
protected abstract void DoInternalStart();
|
||||
protected abstract void DoInternalEnd();
|
||||
|
||||
protected AbstractBattleAction(CharacterCardNode source, CharacterCardNode target, ActionCharacter action, float speed = 1)
|
||||
{
|
||||
Source = source;
|
||||
Target = target;
|
||||
Action = action;
|
||||
Speed = speed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the current positions, rotations, and <see cref="Control"/> references of both source
|
||||
/// and target cards before the animation begins, then delegates to the subclass
|
||||
/// <see cref="DoInternalStart"/> for any additional setup (e.g. resetting timestamps).
|
||||
/// </summary>
|
||||
public void DoStart()
|
||||
{
|
||||
if (Source == null || Target == null || Action == null)
|
||||
return;
|
||||
|
||||
SourceControl = Source.GetControlNode();
|
||||
SourcePos = SourceControl.GlobalPosition;
|
||||
SourceRot = SourceControl.Rotation;
|
||||
TargetControl = Target.GetControlNode();
|
||||
TargetPos = TargetControl.GlobalPosition;
|
||||
TargetRot = TargetControl.Rotation;
|
||||
|
||||
DoInternalStart();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores source and target card controls to their original positions, rotations, and visual state
|
||||
/// captured during <see cref="DoStart"/>. Delegates specific cleanup to <see cref="DoInternalEnd"/>.
|
||||
/// </summary>
|
||||
public void DoEnd()
|
||||
{
|
||||
if (Source == null || Target == null || Action == null)
|
||||
return;
|
||||
DoInternalEnd();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Cthangover.CardBattle.Actions
|
||||
{
|
||||
/// <summary>
|
||||
/// Phases of the animated battle action state machine used by <see cref="PhysicsAttackAction"/>
|
||||
/// and <see cref="PhysicsDefenceAction"/>. Each tick, the action evaluates the current phase,
|
||||
/// interpolates card positions/rotations/scales, and transitions to the next phase when its
|
||||
/// duration elapses. The <c>Recover</c> phase signals completion by returning <c>true</c> from <c>DoInternalAction</c>.
|
||||
/// </summary>
|
||||
public enum ActionPhase
|
||||
{
|
||||
/// <summary>Initial delay before any movement.</summary>
|
||||
Wait,
|
||||
/// <summary>Source card rotates toward target and begins slight movement.</summary>
|
||||
Prepare,
|
||||
/// <summary>Source card travels from its position to the target along a movement pattern.</summary>
|
||||
MoveForAttack,
|
||||
/// <summary>Damage/effect is applied to the target card at the point of impact.</summary>
|
||||
AttackImpact,
|
||||
/// <summary>Target card shows knockback, squashing, and shaking reaction.</summary>
|
||||
TargetReaction,
|
||||
/// <summary>Source card returns toward its original position.</summary>
|
||||
MoveForBack,
|
||||
/// <summary>All cards smoothly return to their original transforms.</summary>
|
||||
Recover
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Battle.Actions;
|
||||
using Cthangover.Core.Utils;
|
||||
|
||||
namespace Cthangover.CardBattle.Actions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registry that maps action type IDs (<c>"physics/attack"</c>, <c>"physics/defence"</c>, <c>"physics/stun"</c>)
|
||||
/// to their respective <see cref="IActionExecutor"/> implementations. Created by <see cref="CardBattleCore"/>
|
||||
/// and registered with <see cref="ActionExecutorHub"/> at battle startup so that actions resolved
|
||||
/// during drag-and-drop or enemy AI turns go through the correct damage/defence/stun logic.
|
||||
/// </summary>
|
||||
public class CardBattleActionProvider : IActionExecutorProvider
|
||||
{
|
||||
private readonly Dictionary<string, IActionExecutor> _executors;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the provider with the three card-battle action executors:
|
||||
/// <see cref="PhysicsDamageActionCard"/> for attacks, <see cref="PhysicsDefenceActionCard"/> for defence buffs,
|
||||
/// and <see cref="PhysicsStunActionCard"/> for stun effects.
|
||||
/// </summary>
|
||||
public CardBattleActionProvider()
|
||||
{
|
||||
_executors = new Dictionary<string, IActionExecutor>
|
||||
{
|
||||
["physics/attack"] = new PhysicsDamageActionCard(),
|
||||
["physics/defence"] = new PhysicsDefenceActionCard(),
|
||||
["physics/stun"] = new PhysicsStunActionCard(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up the <see cref="IActionExecutor"/> for the given <paramref name="actionId"/>.
|
||||
/// Logs a warning and returns <c>null</c> if no executor is registered, allowing the caller
|
||||
/// to gracefully skip unrecognized actions.
|
||||
/// </summary>
|
||||
public IActionExecutor GetExecutor(string actionId)
|
||||
{
|
||||
if (!_executors.TryGetValue(actionId, out var executor))
|
||||
GameLogger.Log("CARD_BATTLE", $"No executor registered for action '{actionId}'", LogLevel.Warning);
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
using Cthangover.Core.Battle;
|
||||
using Cthangover.Core.Battle.Actions;
|
||||
using Cthangover.Core.Utils;
|
||||
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);
|
||||
}
|
||||
|
||||
protected override void DoInternalStart()
|
||||
{
|
||||
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
|
||||
damageApplied = false;
|
||||
stunEffectSpawned = false;
|
||||
phase = ActionPhase.Wait;
|
||||
targetOriginalScale = TargetControl.Scale;
|
||||
}
|
||||
|
||||
private float GetPhaseProgress()
|
||||
{
|
||||
float elapsed = (float)(Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)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 = Time.GetTicksUsec() / 1_000_000.0;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (result.Target.Damage > 0)
|
||||
ShowDamageBehaviour.SpawnDamage(result.Target.Damage, TargetControl, TargetControl.GlobalPosition);
|
||||
|
||||
var defenceLost = -result.Target.Defence;
|
||||
if (defenceLost > 0)
|
||||
ShowDamageBehaviour.SpawnDefence(defenceLost, TargetControl, TargetControl.GlobalPosition);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Cthangover.Core.Characters;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.CardBattle.Actions
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Executor that applies physical damage from an attack card to a target.
|
||||
/// Calculates damage as <c>card_attack_multiplier × user_attack_stat</c>, reduces it by
|
||||
/// the target's current defence value (which is consumed in the process), and applies the
|
||||
/// remainder to the target's health. Invokes <c>OnTakeDamage</c>/<c>OnDealDamage</c> hooks
|
||||
/// on both characters' <see cref="StatusEffectQueue"/> so that damage-modifying status effects
|
||||
/// can intercept and adjust the values before they are applied.
|
||||
/// Registered in <see cref="CardBattleActionProvider"/> under <c>"physics/attack"</c>.
|
||||
/// </summary>
|
||||
public class PhysicsDamageActionCard : ActionBase
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier registered in <see cref="CardBattleActionProvider"/> as <c>"physics/attack"</c>.
|
||||
/// </summary>
|
||||
public override string ID => "PhysicsDamageActionCard";
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the attack: deducts action points from <paramref name="user"/> via <c>CheckRequiredAndUsePoint</c>,
|
||||
/// computes raw damage from the card's attack multiplier and the user's attack stat,
|
||||
/// applies status-effect damage hooks, consumes <paramref name="target"/>'s defence shield first,
|
||||
/// then subtracts remaining damage from health. Returns a <see cref="ChangedAttributes"/> with
|
||||
/// the actual damage and defence-loss deltas for the floating text display.
|
||||
/// </summary>
|
||||
public override ChangedAttributes Execute(ActionCharacter actionCard, Character user, Character target)
|
||||
{
|
||||
if (target == null || user == null || !CheckRequiredAndUsePoint(actionCard, user))
|
||||
return new ChangedAttributes { Result = false };
|
||||
|
||||
var damageDelta = Mathf.RoundToInt(actionCard.GetFloat("Attack", 1f) * user.Attributes.Attack.Value);
|
||||
target.StatusEffectQueue.OnTakeDamage(user, ref damageDelta);
|
||||
user.StatusEffectQueue.OnDealDamage(target, ref damageDelta);
|
||||
|
||||
var defenceDelta = 0;
|
||||
var ignoreArmor = user.HasPassiveEffect("IgnoreArmor");
|
||||
if (!ignoreArmor && target.Attributes.Defence.Value > 0)
|
||||
{
|
||||
defenceDelta = target.Attributes.Defence.Value >= damageDelta ? damageDelta : target.Attributes.Defence.Value;
|
||||
damageDelta -= defenceDelta;
|
||||
}
|
||||
target.Attributes.Defence.Value -= defenceDelta;
|
||||
target.Attributes.Health.Value -= damageDelta;
|
||||
|
||||
return new ChangedAttributes { Result = true, Target = { Damage = damageDelta, Defence = -defenceDelta}};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
using Cthangover.Core.Battle;
|
||||
using Cthangover.Core.Battle.Actions;
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.CardBattle.UI;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.CardBattle.Actions
|
||||
{
|
||||
/// <summary>
|
||||
/// Animated battle action that plays a self-buff or ally-support animation: the source card
|
||||
/// briefly rotates toward the target, applies a defence boost via <see cref="ActionExecutorHub"/>,
|
||||
/// and shows a subtle block/stagger reaction on the target. Used for both <c>ToSelf</c> and
|
||||
/// <c>ToAlias</c> action types. This is the visual counterpart of <see cref="PhysicsDefenceActionCard"/>.
|
||||
/// Created by <see cref="CardBattleCore.CreateAnimatedAction"/> for enemy self-buff/support turns.
|
||||
/// </summary>
|
||||
public class PhysicsDefenceAction : AbstractBattleAction
|
||||
{
|
||||
private ActionPhase phase = ActionPhase.Prepare;
|
||||
private float defendRotation;
|
||||
private bool effectApplied;
|
||||
|
||||
private Vector2 targetOriginalScale;
|
||||
private Color targetOriginalColor;
|
||||
|
||||
private Vector2 blockScale;
|
||||
private Vector2 blockKnockback;
|
||||
private Color blockColor;
|
||||
|
||||
private float prepareDuration = 0.3f;
|
||||
private float attackImpactDuration = 0.4f;
|
||||
private float targetReactionDuration = 0.3f;
|
||||
private float moveForBackDuration = 0.4f;
|
||||
private float recoverDuration = 0.3f;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a defence/support animation. Both <paramref name="source"/> and <paramref name="target"/>
|
||||
/// can be the same card (for self-buffs) or different (for ally support).
|
||||
/// <paramref name="speed"/> multiplies all phase durations.
|
||||
/// </summary>
|
||||
public PhysicsDefenceAction(CharacterCardNode source, CharacterCardNode target, ActionCharacter action, float speed = 1)
|
||||
: base(source, target, action, speed) { }
|
||||
|
||||
protected override void DoInternalStart()
|
||||
{
|
||||
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
|
||||
effectApplied = false;
|
||||
phase = ActionPhase.Prepare;
|
||||
targetOriginalScale = TargetControl.Scale;
|
||||
targetOriginalColor = TargetControl.Modulate;
|
||||
}
|
||||
|
||||
private float GetPhaseProgress()
|
||||
{
|
||||
float elapsed = (float)(Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Engine.TimeScale;
|
||||
float duration = GetCurrentPhaseDuration();
|
||||
return Mathf.Clamp(elapsed / duration, 0f, 1f);
|
||||
}
|
||||
|
||||
private float GetCurrentPhaseDuration()
|
||||
{
|
||||
switch (phase)
|
||||
{
|
||||
case ActionPhase.Prepare: return prepareDuration;
|
||||
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 = Time.GetTicksUsec() / 1_000_000.0;
|
||||
}
|
||||
|
||||
protected override bool DoInternalAction()
|
||||
{
|
||||
float progress = GetPhaseProgress();
|
||||
|
||||
switch (phase)
|
||||
{
|
||||
case ActionPhase.Prepare:
|
||||
{
|
||||
float prepareProgress = EaseOutQuad(Mathf.Min(progress, 1f));
|
||||
SourceControl.Rotation = Mathf.Lerp(SourceRot, TargetRot, prepareProgress);
|
||||
|
||||
if (progress >= 1f)
|
||||
{
|
||||
phase = ActionPhase.AttackImpact;
|
||||
ResetTimestamp();
|
||||
defendRotation = (float)GD.RandRange(-15f, 15f) * Mathf.Pi / 180f;
|
||||
|
||||
targetOriginalScale = TargetControl.Scale;
|
||||
targetOriginalColor = TargetControl.Modulate;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case ActionPhase.AttackImpact:
|
||||
{
|
||||
float impactProgress = EaseInOutQuad(Mathf.Min(progress * 2f, 1f));
|
||||
SourceControl.Rotation = Mathf.Lerp(TargetRot, defendRotation, impactProgress);
|
||||
|
||||
blockScale = targetOriginalScale * (1f + impactProgress * 0.1f);
|
||||
TargetControl.Scale = blockScale;
|
||||
|
||||
blockColor = targetOriginalColor;
|
||||
blockColor.G = Mathf.Min(1f, blockColor.G + impactProgress * 0.3f);
|
||||
blockColor.B = Mathf.Min(1f, blockColor.B + impactProgress * 0.2f);
|
||||
TargetControl.Modulate = blockColor;
|
||||
|
||||
if (!effectApplied && progress >= 0.4f)
|
||||
{
|
||||
effectApplied = true;
|
||||
var result = ActionExecutorHub.Instance.Execute(Action, Source.Card, Target.Card);
|
||||
Source.UpdateInfo();
|
||||
Target.UpdateInfo();
|
||||
|
||||
if (result.Result && result.Target.Defence > 0)
|
||||
ShowDamageBehaviour.SpawnDefence(result.Target.Defence, TargetControl, TargetControl.GlobalPosition);
|
||||
}
|
||||
|
||||
if (progress >= 1f)
|
||||
{
|
||||
phase = ActionPhase.TargetReaction;
|
||||
ResetTimestamp();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case ActionPhase.TargetReaction:
|
||||
{
|
||||
float reactionProgress = EaseInOutQuad(Mathf.Min(progress * 1.5f, 1f));
|
||||
|
||||
blockKnockback = TargetPos + (TargetPos - SourcePos).Normalized() * 0.1f;
|
||||
TargetControl.GlobalPosition = TargetPos.Lerp(blockKnockback, reactionProgress * 0.5f);
|
||||
|
||||
TargetControl.Scale = blockScale.Lerp(targetOriginalScale, reactionProgress);
|
||||
|
||||
if (progress >= 1f)
|
||||
{
|
||||
phase = ActionPhase.MoveForBack;
|
||||
ResetTimestamp();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case ActionPhase.MoveForBack:
|
||||
{
|
||||
float backProgress = EaseOutQuad(Mathf.Min(progress, 1f));
|
||||
SourceControl.GlobalPosition = TargetPos.Lerp(SourcePos, backProgress);
|
||||
SourceControl.Rotation = Mathf.Lerp(defendRotation, SourceRot, backProgress);
|
||||
|
||||
TargetControl.GlobalPosition = TargetControl.GlobalPosition.Lerp(TargetPos, backProgress);
|
||||
TargetControl.Scale = TargetControl.Scale.Lerp(targetOriginalScale, backProgress);
|
||||
|
||||
Color currentColor = TargetControl.Modulate;
|
||||
TargetControl.Modulate = currentColor.Lerp(targetOriginalColor, 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.Rotation = Mathf.Lerp(TargetControl.Rotation, TargetRot, recoverProgress);
|
||||
|
||||
if (progress >= 1f)
|
||||
return true;
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
TargetControl.Modulate = targetOriginalColor;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Cthangover.Core.Characters;
|
||||
|
||||
namespace Cthangover.CardBattle.Actions
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Executor that adds a fixed amount of defence to the target character.
|
||||
/// Defence acts as a damage-absorbing shield that is consumed before health when the
|
||||
/// character takes damage (see <see cref="PhysicsDamageActionCard"/>). The defence delta
|
||||
/// comes from the <c>"Defence"</c> property on the action card.
|
||||
/// Registered in <see cref="CardBattleActionProvider"/> under <c>"physics/defence"</c>.
|
||||
/// </summary>
|
||||
public class PhysicsDefenceActionCard : ActionBase
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier registered in <see cref="CardBattleActionProvider"/> as <c>"physics/defence"</c>.
|
||||
/// </summary>
|
||||
public override string ID => "PhysicsDefenceActionCard";
|
||||
|
||||
/// <summary>
|
||||
/// Adds a flat defence value to <paramref name="target"/> after deducting action points
|
||||
/// from <paramref name="user"/>. The returned <see cref="ChangedAttributes"/> carries the
|
||||
/// defence delta for floating text display.
|
||||
/// </summary>
|
||||
public override ChangedAttributes Execute(ActionCharacter actionCard, Character user, Character target)
|
||||
{
|
||||
if (target == null || user == null || !CheckRequiredAndUsePoint(actionCard, user))
|
||||
return new ChangedAttributes { Result = false };
|
||||
|
||||
var defenceDelta = actionCard.GetInt("Defence");
|
||||
target.Attributes.Defence.Value += defenceDelta;
|
||||
|
||||
return new ChangedAttributes { Result = true, Target = { Defence = defenceDelta}};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Cthangover.Core.Characters;
|
||||
|
||||
namespace Cthangover.CardBattle.Actions
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Executor that applies a stun status effect to the target character.
|
||||
/// The stun duration (number of turns) is read from the <c>"Turn"</c> property on the action card.
|
||||
/// During a stunned character's turn, <see cref="StatusEffectQueue.HasStun"/> returns <c>true</c>,
|
||||
/// causing <see cref="CardBattleCore.RunEnemyTurn"/> to skip that character's actions entirely.
|
||||
/// Registered in <see cref="CardBattleActionProvider"/> under <c>"physics/stun"</c>.
|
||||
/// </summary>
|
||||
public class PhysicsStunActionCard : ActionBase
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier registered in <see cref="CardBattleActionProvider"/> as <c>"physics/stun"</c>.
|
||||
/// </summary>
|
||||
public override string ID => "PhysicsStunActionCard";
|
||||
|
||||
/// <summary>
|
||||
/// Adds a <see cref="StunStatusEffect"/> with the configured turn count to <paramref name="target"/>'s
|
||||
/// <see cref="StatusEffectQueue"/> after deducting action points from <paramref name="user"/>.
|
||||
/// </summary>
|
||||
public override ChangedAttributes Execute(ActionCharacter actionCard, Character user, Character target)
|
||||
{
|
||||
if (target == null || user == null || !CheckRequiredAndUsePoint(actionCard, user))
|
||||
return new ChangedAttributes { Result = false };
|
||||
|
||||
if (target.HasPassiveEffect("StunImmune"))
|
||||
return new ChangedAttributes { Result = true };
|
||||
|
||||
target.StatusEffectQueue.Add("effect/physics/stun", actionCard.GetInt("Turn", 3));
|
||||
|
||||
return new ChangedAttributes { Result = true };
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Cthangover.Core.Battle;
|
||||
using Cthangover.Core.Battle.Actions;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Core.Utils;
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.CardBattle.Player;
|
||||
using Cthangover.CardBattle.UI;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.CardBattle
|
||||
{
|
||||
/// <summary>
|
||||
/// Core orchestrator for the card-based turn battle system.
|
||||
/// Manages the full battle lifecycle: initializing UI panels (<see cref="BattleCardPanel"/> and <see cref="CardController"/>),
|
||||
/// cycling between player and enemy turns, spawning animated actions via <see cref="AbstractBattleAction"/>,
|
||||
/// applying status effects each turn, and detecting win/loss conditions when all cards on one side are dead.
|
||||
/// Implements <see cref="IBattleCore"/> so it can be registered as a battle type provider in the core battle engine.
|
||||
/// </summary>
|
||||
public class CardBattleCore : IBattleCore
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this battle core, used by the battle engine to select the card battle mod.
|
||||
/// </summary>
|
||||
public string Id => "card_battle";
|
||||
|
||||
/// <summary>
|
||||
/// Provides <see cref="IActionExecutor"/> instances for the three card action types
|
||||
/// (physics/attack, physics/defence, physics/stun) used during action resolution.
|
||||
/// </summary>
|
||||
public IActionExecutorProvider ActionProvider { get; } = new Actions.CardBattleActionProvider();
|
||||
|
||||
private Character[] _playerChars;
|
||||
private Character[] _enemyChars;
|
||||
private IBattleContext _ctx;
|
||||
|
||||
private BattleCardPanel _playerPanel;
|
||||
private BattleCardPanel _enemyPanel;
|
||||
private CardController _cardController;
|
||||
private Control _actionPanel;
|
||||
private Control _toolPanel;
|
||||
private EndTurnButton _endTurnButton;
|
||||
|
||||
private BattleSide _currentOrder;
|
||||
|
||||
private const float CELL_OFFSET = 10f;
|
||||
private const int PLAYER_CARD_LIMIT = 4;
|
||||
private const float BASE_CARD_WIDTH = 230f;
|
||||
private const float BASE_CARD_HEIGHT = 420f;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the card battle by constructing the player and enemy <see cref="BattleCardPanel"/> instances,
|
||||
/// the <see cref="CardController"/> for drag-and-drop input, and the <see cref="EndTurnButton"/>.
|
||||
/// Enemy characters are deep-copied so defeats are tracked independently per battle instance.
|
||||
/// The <see cref="CardBattleActionProvider"/> is registered with <see cref="ActionExecutorHub"/>.
|
||||
/// </summary>
|
||||
public void Init(Character[] playerChars, Character[] enemyChars, IBattleContext ctx)
|
||||
{
|
||||
_playerChars = playerChars;
|
||||
_ctx = ctx;
|
||||
|
||||
_enemyChars = new Character[enemyChars.Length];
|
||||
for (int i = 0; i < enemyChars.Length; i++)
|
||||
_enemyChars[i] = enemyChars[i]?.Copy() ?? enemyChars[i];
|
||||
|
||||
ActionExecutorHub.Instance.SetActiveProvider(ActionProvider);
|
||||
|
||||
var root = ctx.RootNode as Node;
|
||||
|
||||
var panel = root.GetNodeOrNull<Control>("Panel") ?? root;
|
||||
|
||||
_playerPanel = (BattleCardPanel)TscnScenes.LoadAndBuild("scenes/battle_card_panel.tscn");
|
||||
_playerPanel.Name = "PlayerPanel";
|
||||
_playerPanel.EnsureConstructed();
|
||||
panel.AddChild(_playerPanel);
|
||||
|
||||
_enemyPanel = (BattleCardPanel)TscnScenes.LoadAndBuild("scenes/battle_card_panel.tscn");
|
||||
_enemyPanel.Name = "EnemyPanel";
|
||||
_enemyPanel.AlignType = "Right";
|
||||
_enemyPanel.EnsureConstructed();
|
||||
_enemyPanel.MouseFilter = Control.MouseFilterEnum.Ignore;
|
||||
panel.AddChild(_enemyPanel);
|
||||
|
||||
_actionPanel = new Control { Name = "ActionPanel" };
|
||||
panel.AddChild(_actionPanel);
|
||||
|
||||
_toolPanel = root.GetNodeOrNull<Control>("ToolPanel");
|
||||
|
||||
_endTurnButton = (EndTurnButton)TscnScenes.LoadAndBuild("scenes/end_turn_button.tscn");
|
||||
_endTurnButton.Name = "EndTurnButton";
|
||||
_endTurnButton.EnsureConstructed();
|
||||
_endTurnButton.SetVisible(false);
|
||||
_endTurnButton.OnPressed += OnEndTurnPressed;
|
||||
_toolPanel?.AddChild(_endTurnButton);
|
||||
|
||||
_cardController = new CardController { Name = "CardController" };
|
||||
panel.AddChild(_cardController);
|
||||
_cardController.FindPanels();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Positions the UI panels relative to the viewport, calculates a unified <c>cardScale</c> that
|
||||
/// fits both player and enemy cards on screen, initializes all character cards, and starts the first player turn.
|
||||
/// Subscribes to <c>OnCardDead</c> to record defeated enemies and to <c>OnActionExecuted</c> to detect
|
||||
/// automatic turn completion when all player cards exhaust their action points.
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
var root = _ctx.RootNode as Node;
|
||||
|
||||
var panelSize = root.GetViewport().GetVisibleRect().Size;
|
||||
var enemyCount = _enemyChars?.Length ?? 1;
|
||||
|
||||
_enemyPanel.UseRows = enemyCount > 6;
|
||||
|
||||
_playerPanel.Position = new Vector2(0, panelSize.Y * 0.55f);
|
||||
_playerPanel.Size = new Vector2(panelSize.X * 0.55f, panelSize.Y * 0.35f);
|
||||
|
||||
_enemyPanel.Position = new Vector2(panelSize.X * 0.02f, panelSize.Y * 0.022f);
|
||||
_enemyPanel.Size = new Vector2(panelSize.X * 0.96f, panelSize.Y * 0.446f);
|
||||
|
||||
_actionPanel.Position = new Vector2(panelSize.X * 0.53f, panelSize.Y * 0.55f);
|
||||
_actionPanel.Size = new Vector2(panelSize.X * 0.46f, panelSize.Y * 0.35f);
|
||||
|
||||
if (_endTurnButton != null)
|
||||
_endTurnButton.Position = new Vector2(panelSize.X - 180f, (panelSize.Y * 0.1f - 50f) / 2);
|
||||
|
||||
var cardScale = CalculateCardScale(enemyCount);
|
||||
_cardController.CardScale = cardScale;
|
||||
|
||||
_playerPanel.Init(_playerChars, true, cardScale);
|
||||
_enemyPanel.Init(_enemyChars, false, cardScale);
|
||||
|
||||
_enemyPanel.OnCardDead += OnEnemyCardDead;
|
||||
_cardController.OnActionExecuted += CheckPlayerTurnEnd;
|
||||
|
||||
BattleSceneContext.Instance.OnBattleCleared += OnBattleCleared;
|
||||
|
||||
_currentOrder = BattleSide.Player;
|
||||
StartPlayerTurn();
|
||||
}
|
||||
|
||||
private float CalculateCardScale(int enemyCount)
|
||||
{
|
||||
var viewport = ((Node)_ctx.RootNode).GetViewport().GetVisibleRect().Size;
|
||||
float enemyPanelWidth = viewport.X * 0.96f;
|
||||
float playerPanelWidth = viewport.X * 0.55f;
|
||||
float enemyPanelHeight = viewport.Y * 0.446f;
|
||||
|
||||
float maxCardScale = 1f;
|
||||
|
||||
if (_enemyPanel.UseRows)
|
||||
{
|
||||
int maxPerRow = _enemyPanel.GetMaxCardsPerRow();
|
||||
int rows = _enemyPanel.GetCurrentRowCount(enemyCount);
|
||||
|
||||
float widthScale = (enemyPanelWidth - (maxPerRow - 1) * CELL_OFFSET) / (maxPerRow * BASE_CARD_WIDTH);
|
||||
float heightScale = (enemyPanelHeight - (rows - 1) * CELL_OFFSET) / (rows * BASE_CARD_HEIGHT);
|
||||
maxCardScale = Mathf.Max(0.1f, Mathf.Min(widthScale, heightScale));
|
||||
}
|
||||
else
|
||||
{
|
||||
int effectiveCount = Mathf.Max(enemyCount, 1);
|
||||
maxCardScale = Mathf.Max(0.1f, (enemyPanelWidth - (effectiveCount - 1) * CELL_OFFSET) / (effectiveCount * BASE_CARD_WIDTH));
|
||||
}
|
||||
|
||||
float playerFitScale = Mathf.Max(0.1f, (playerPanelWidth - (PLAYER_CARD_LIMIT - 1) * CELL_OFFSET) / (PLAYER_CARD_LIMIT * BASE_CARD_WIDTH));
|
||||
|
||||
float cardScale = Mathf.Min(maxCardScale, playerFitScale);
|
||||
return Mathf.Min(cardScale, 1f);
|
||||
}
|
||||
|
||||
private void OnEnemyCardDead(CharacterCardNode card)
|
||||
{
|
||||
if (card?.Card == null)
|
||||
return;
|
||||
|
||||
var ctx = BattleSceneContext.Instance;
|
||||
if (ctx != null)
|
||||
{
|
||||
ctx.RecordEnemyDefeated(card.Card);
|
||||
ctx.NotifyCharacterDied(card.Card);
|
||||
}
|
||||
}
|
||||
|
||||
private void StartPlayerTurn()
|
||||
{
|
||||
GameLogger.Log("CARD_BATTLE", "Player turn started", LogLevel.Debug);
|
||||
|
||||
_currentOrder = BattleSide.Player;
|
||||
BattleSceneContext.Instance.IsWait = false;
|
||||
|
||||
_cardController.ClearSelections();
|
||||
_cardController.ClearActions();
|
||||
|
||||
_endTurnButton?.SetVisible(true);
|
||||
|
||||
foreach (var card in _playerPanel.CardList)
|
||||
{
|
||||
card.Card.StatusEffectQueue.OnTurnStart();
|
||||
card.Card.Attributes.Point.Value = card.Card.Attributes.Point.BaseValue;
|
||||
card.UpdateInfo();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEndTurnPressed()
|
||||
{
|
||||
GameLogger.Log("CARD_BATTLE", "End turn button pressed", LogLevel.Debug);
|
||||
OnPlayerTurnEnd();
|
||||
}
|
||||
|
||||
private void CheckPlayerTurnEnd()
|
||||
{
|
||||
if (_currentOrder != BattleSide.Player)
|
||||
return;
|
||||
|
||||
if (!HasAliveCards(_enemyPanel))
|
||||
{
|
||||
_ctx.EndBattle(BattleSide.Player);
|
||||
return;
|
||||
}
|
||||
|
||||
bool allOutOfPoints = true;
|
||||
foreach (var card in _playerPanel.CardList)
|
||||
{
|
||||
if (!card.IsDead && card.Card?.Attributes?.Point?.Value > 0)
|
||||
{
|
||||
allOutOfPoints = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (allOutOfPoints)
|
||||
OnPlayerTurnEnd();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ends the player's turn by hiding the end-turn button, setting the battle to <c>IsWait</c>,
|
||||
/// and yielding two frames for UI to settle before running the enemy AI turn via <c>RunEnemyTurn</c>.
|
||||
/// If all enemies are already dead, the battle ends immediately with the player as winner.
|
||||
/// This method is <c>async void</c> so it can await frames without blocking the game loop.
|
||||
/// </summary>
|
||||
public async void OnPlayerTurnEnd()
|
||||
{
|
||||
GameLogger.Log("CARD_BATTLE", "Player turn ended", LogLevel.Debug);
|
||||
|
||||
_endTurnButton?.SetVisible(false);
|
||||
BattleSceneContext.Instance.IsWait = true;
|
||||
|
||||
if (!HasAliveCards(_enemyPanel))
|
||||
{
|
||||
_ctx.EndBattle(BattleSide.Player);
|
||||
return;
|
||||
}
|
||||
|
||||
var root = (Node)_ctx.RootNode;
|
||||
await root.ToSignal(root.GetTree(), "process_frame");
|
||||
await root.ToSignal(root.GetTree().CreateTimer(0.5f), "timeout");
|
||||
|
||||
await RunEnemyTurn();
|
||||
}
|
||||
|
||||
private async Task RunEnemyTurn()
|
||||
{
|
||||
GameLogger.Log("CARD_BATTLE", "Enemy turn started", LogLevel.Debug);
|
||||
|
||||
_currentOrder = BattleSide.Enemy;
|
||||
_endTurnButton?.SetVisible(false);
|
||||
|
||||
var root = (Node)_ctx.RootNode;
|
||||
|
||||
foreach (var card in _enemyPanel.CardList.ToList())
|
||||
{
|
||||
if (card.IsDead || card.Card?.Actions == null || card.Card.Actions.Count == 0)
|
||||
continue;
|
||||
|
||||
card.Card.StatusEffectQueue.OnTurnStart();
|
||||
|
||||
if (card.Card.StatusEffectQueue.HasStun())
|
||||
{
|
||||
GameLogger.Log("CARD_BATTLE", $"{card.Card.Name} is stunned, skipping", LogLevel.Debug);
|
||||
continue;
|
||||
}
|
||||
|
||||
card.Card.Attributes.Point.Value = card.Card.Attributes.Point.BaseValue;
|
||||
|
||||
var action = PickEnemyAction(card);
|
||||
if (action == null)
|
||||
continue;
|
||||
|
||||
var target = PickEnemyTarget(card, action);
|
||||
if (target == null)
|
||||
continue;
|
||||
|
||||
GameLogger.Log("CARD_BATTLE",
|
||||
$"Enemy action: {card.Card.Name} -> {action.Name} -> {target.Card.Name}",
|
||||
LogLevel.Message);
|
||||
|
||||
var animatedAction = CreateAnimatedAction(card, target, action);
|
||||
if (animatedAction != null)
|
||||
{
|
||||
animatedAction.DoStart();
|
||||
while (!animatedAction.DoAction())
|
||||
await root.ToSignal(root.GetTree(), "process_frame");
|
||||
animatedAction.DoEnd();
|
||||
}
|
||||
|
||||
await root.ToSignal(root.GetTree().CreateTimer(0.2f), "timeout");
|
||||
|
||||
if (!HasAliveCards(_playerPanel))
|
||||
{
|
||||
GameLogger.Log("CARD_BATTLE", "All players dead — enemy wins", LogLevel.Message);
|
||||
_ctx.EndBattle(BattleSide.Enemy);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!HasAliveCards(_playerPanel))
|
||||
{
|
||||
_ctx.EndBattle(BattleSide.Enemy);
|
||||
return;
|
||||
}
|
||||
|
||||
StartPlayerTurn();
|
||||
}
|
||||
|
||||
private ActionCharacter PickEnemyAction(CharacterCardNode card)
|
||||
{
|
||||
var actions = card.Card.Actions;
|
||||
if (actions == null || actions.Count == 0)
|
||||
return null;
|
||||
|
||||
var random = new Random();
|
||||
return actions[random.Next(actions.Count)];
|
||||
}
|
||||
|
||||
private CharacterCardNode PickEnemyTarget(CharacterCardNode source, ActionCharacter action)
|
||||
{
|
||||
if (action.Type == ActionCharacterType.ToEnemy)
|
||||
{
|
||||
var alivePlayers = _playerPanel.CardList
|
||||
.Where(c => !c.IsDead && c.Card?.Attributes?.Health?.Value > 0)
|
||||
.ToList();
|
||||
|
||||
if (alivePlayers.Count == 0)
|
||||
return null;
|
||||
return alivePlayers[new Random().Next(alivePlayers.Count)];
|
||||
}
|
||||
|
||||
if (action.Type == ActionCharacterType.ToAlias || action.Type == ActionCharacterType.ToSelf)
|
||||
return source;
|
||||
|
||||
GameLogger.Log("CARD_BATTLE", $"PickEnemyTarget: unsupported action type {action.Type}", LogLevel.Warning);
|
||||
return null;
|
||||
}
|
||||
|
||||
private Actions.AbstractBattleAction CreateAnimatedAction(CharacterCardNode source, CharacterCardNode target, ActionCharacter action)
|
||||
{
|
||||
if (action.Type == ActionCharacterType.ToEnemy)
|
||||
return new Actions.PhysicsAttackAction(source, target, action);
|
||||
|
||||
if (action.Type == ActionCharacterType.ToAlias || action.Type == ActionCharacterType.ToSelf)
|
||||
return new Actions.PhysicsDefenceAction(source, target, action);
|
||||
|
||||
GameLogger.Log("CARD_BATTLE", $"CreateAnimatedAction: unsupported action type {action.Type}", LogLevel.Warning);
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool HasAliveCards(BattleCardPanel panel)
|
||||
{
|
||||
foreach (var card in panel.CardList)
|
||||
{
|
||||
if (!card.IsDead && card.Card?.Attributes?.Health?.Value > 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void OnBattleCleared()
|
||||
{
|
||||
_playerPanel?.ClearAll();
|
||||
_enemyPanel?.ClearAll();
|
||||
_cardController?.ClearActions();
|
||||
_endTurnButton?.SetVisible(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.CardBattle
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Common interface for all card UI nodes in the card battle system.
|
||||
/// Exposes the <c>Frame</c> and <c>Image</c> <see cref="TextureRect"/> controls so that
|
||||
/// <see cref="CardController"/> can perform hit-testing during drag-and-drop targeting
|
||||
/// without knowing the concrete card type (<see cref="CharacterCardNode"/> or <see cref="ActionCardNode"/>).
|
||||
/// </summary>
|
||||
public interface ICard
|
||||
{
|
||||
/// <summary>
|
||||
/// The frame <see cref="TextureRect"/> used for hit-testing whether a pointer is over this card.
|
||||
/// </summary>
|
||||
TextureRect Frame { get; }
|
||||
/// <summary>
|
||||
/// The portrait/image <see cref="TextureRect"/> of the card, used for dissolve shader effects on death.
|
||||
/// </summary>
|
||||
TextureRect Image { get; }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Cthangover.CardBattle.UI;
|
||||
using Cthangover.Core.Battle;
|
||||
using Cthangover.Core.Battle.Actions;
|
||||
using Cthangover.Core.Characters;
|
||||
|
||||
namespace Cthangover.CardBattle.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// Strategy for <c>ToEnemy</c> attack actions. Validates that the target is an enemy
|
||||
/// (non-player card), highlights both cards with a red attack outline, and executes
|
||||
/// damage via <see cref="ActionExecutorHub"/> with floating damage/defence numbers.
|
||||
/// This is the standard strategy for all offensive cards in the player's deck.
|
||||
/// </summary>
|
||||
public class AttackToEnemyStrategy : ICardActionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> if the action type is <c>ToEnemy</c> and the target is an enemy card.
|
||||
/// </summary>
|
||||
public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target)
|
||||
{
|
||||
return action.Type == ActionCharacterType.ToEnemy && target != null && !target.IsPlayer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows a red attack overlay on both the dragged action card and the target enemy card.
|
||||
/// </summary>
|
||||
public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target)
|
||||
{
|
||||
actionCard.Attack();
|
||||
target.Attack();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the attack via <see cref="ActionExecutorHub"/> and spawns floating damage
|
||||
/// and defence-loss numbers above the <paramref name="target"/> card.
|
||||
/// </summary>
|
||||
public void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action)
|
||||
{
|
||||
if (source?.Card == null || target?.Card == null || action == null)
|
||||
return;
|
||||
|
||||
var result = ActionExecutorHub.Instance.Execute(action, source.Card, target.Card);
|
||||
source.UpdateInfo();
|
||||
target.UpdateInfo();
|
||||
|
||||
if (result.Result)
|
||||
{
|
||||
if (result.Target.Damage > 0)
|
||||
ShowDamageBehaviour.SpawnDamage(result.Target.Damage, target, target.GlobalPosition);
|
||||
|
||||
var defenceLost = -result.Target.Defence;
|
||||
if (defenceLost > 0)
|
||||
ShowDamageBehaviour.SpawnDefence(defenceLost, target, target.GlobalPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Cthangover.CardBattle.UI;
|
||||
using Cthangover.Core.Characters;
|
||||
|
||||
namespace Cthangover.CardBattle.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// Factory that returns the appropriate <see cref="ICardActionStrategy"/> implementation
|
||||
/// for a given <see cref="ActionCharacter"/>. Dispatches based on <see cref="ActionCharacterType"/>:
|
||||
/// <c>ToEnemy</c> → <see cref="AttackToEnemyStrategy"/>,
|
||||
/// <c>ToAlias</c> → <see cref="SupportStrategy"/>,
|
||||
/// <c>ToSelf</c> → <see cref="SelfStrategy"/>.
|
||||
/// Used by <see cref="CardController"/> during drag-and-drop to determine valid targeting behavior
|
||||
/// and by <see cref="CanTarget"/> for pre-validation.
|
||||
/// </summary>
|
||||
public static class CardActionStrategyFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the matching strategy for the action's type, or <c>null</c> if any argument is null
|
||||
/// or the type is unrecognized.
|
||||
/// </summary>
|
||||
public static ICardActionStrategy Get(ActionCharacter action, CharacterCardNode source, CharacterCardNode target)
|
||||
{
|
||||
if (action == null || source == null || target == null)
|
||||
return null;
|
||||
|
||||
switch (action.Type)
|
||||
{
|
||||
case ActionCharacterType.ToEnemy:
|
||||
return new AttackToEnemyStrategy();
|
||||
case ActionCharacterType.ToAlias:
|
||||
return new SupportStrategy();
|
||||
case ActionCharacterType.ToSelf:
|
||||
return new SelfStrategy();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience method that gets the strategy via <see cref="Get"/> and immediately calls
|
||||
/// <see cref="ICardActionStrategy.Check"/> on it. Returns <c>false</c> if no strategy matches
|
||||
/// or any argument is null.
|
||||
/// </summary>
|
||||
public static bool CanTarget(ActionCharacter action, CharacterCardNode source, CharacterCardNode target)
|
||||
{
|
||||
if (action == null || source == null || target == null)
|
||||
return false;
|
||||
|
||||
return Get(action, source, target)?.Check(action, source, target) ?? false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Cthangover.CardBattle.UI;
|
||||
using Cthangover.Core.Characters;
|
||||
|
||||
namespace Cthangover.CardBattle.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// Strategy interface that defines validation, visual highlighting, and execution for
|
||||
/// a specific type of card action during the player's drag-and-drop targeting flow.
|
||||
/// The <see cref="CardActionStrategyFactory"/> dispatches to the correct strategy based on
|
||||
/// the action's <see cref="ActionCharacterType"/>. <see cref="CardController"/> calls
|
||||
/// <see cref="Check"/> during drag to validate targets, <see cref="HighlightTarget"/> to show
|
||||
/// valid/invalid visual feedback, and <see cref="Execute"/> when the card is dropped on a valid target.
|
||||
/// </summary>
|
||||
public interface ICardActionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates whether <paramref name="action"/> can be performed by <paramref name="source"/>
|
||||
/// on <paramref name="target"/>. Called each frame during drag to determine visual feedback.
|
||||
/// </summary>
|
||||
bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target);
|
||||
/// <summary>
|
||||
/// Applies visual highlighting to both the <paramref name="actionCard"/> being dragged
|
||||
/// and the <paramref name="target"/> character card (e.g. red outline for attack, green for support).
|
||||
/// </summary>
|
||||
void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target);
|
||||
/// <summary>
|
||||
/// Executes the action via <see cref="ActionExecutorHub"/> and spawns floating damage/defence text.
|
||||
/// Called on successful card drop by <see cref="CardController.OnEndDrag"/>.
|
||||
/// </summary>
|
||||
void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Cthangover.CardBattle.UI;
|
||||
using Cthangover.Core.Battle;
|
||||
using Cthangover.Core.Battle.Actions;
|
||||
using Cthangover.Core.Characters;
|
||||
|
||||
namespace Cthangover.CardBattle.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// Strategy for self-targeting actions (<c>ToSelf</c>). Validates that source and target
|
||||
/// are the same card, highlights with a green selection outline, and applies the action
|
||||
/// to the source card itself via <see cref="ActionExecutorHub"/>. Used for self-buffs like
|
||||
/// defence boosts. Damage and defence floating text spawn above the source card.
|
||||
/// </summary>
|
||||
public class SelfStrategy : ICardActionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> only when <paramref name="source"/> and <paramref name="target"/> are the same card.
|
||||
/// </summary>
|
||||
public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target)
|
||||
{
|
||||
return source != null && source == target;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows a green selection overlay on both the dragged action card and the source card.
|
||||
/// </summary>
|
||||
public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target)
|
||||
{
|
||||
actionCard.Select();
|
||||
target.Select();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the self-targeted action and spawns floating defence/damage numbers
|
||||
/// above the <paramref name="source"/> card. Damage numbers are marked with the
|
||||
/// <c>isHeal</c> flag for green coloring via <see cref="ShowDamageBehaviour"/>.
|
||||
/// </summary>
|
||||
public void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action)
|
||||
{
|
||||
if (source?.Card == null || action == null)
|
||||
return;
|
||||
|
||||
var result = ActionExecutorHub.Instance.Execute(action, source.Card, source.Card);
|
||||
source.UpdateInfo();
|
||||
|
||||
if (result.Result)
|
||||
{
|
||||
if (result.Target.Defence > 0)
|
||||
ShowDamageBehaviour.SpawnDefence(result.Target.Defence, source, source.GlobalPosition);
|
||||
|
||||
if (result.Target.Damage > 0)
|
||||
ShowDamageBehaviour.SpawnDamage(result.Target.Damage, source, source.GlobalPosition, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Cthangover.CardBattle.UI;
|
||||
using Cthangover.Core.Battle.Actions;
|
||||
using Cthangover.Core.Characters;
|
||||
|
||||
namespace Cthangover.CardBattle.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// Strategy for stun actions targeting enemies. Extends the standard <c>ToEnemy</c> check
|
||||
/// by also verifying that the action has at least 2 turns of stun duration
|
||||
/// (<c>"Turn" > 1</c>). Highlights with red attack overlays and applies
|
||||
/// the stun effect via <see cref="ActionExecutorHub"/> without spawning damage text
|
||||
/// (stun has no numerical effect to display).
|
||||
/// Not currently returned by <see cref="CardActionStrategyFactory"/> — available for
|
||||
/// future use when stun cards need distinct drag-and-drop behavior from attack cards.
|
||||
/// </summary>
|
||||
public class StunToEnemyStrategy : ICardActionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> when the action targets an enemy and has a turn count greater than 1.
|
||||
/// </summary>
|
||||
public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target)
|
||||
{
|
||||
return action.Type == ActionCharacterType.ToEnemy && target != null && !target.IsPlayer
|
||||
&& action.GetInt("Turn", 0) > 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows a red attack overlay on both cards, same as <see cref="AttackToEnemyStrategy"/>.
|
||||
/// </summary>
|
||||
public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target)
|
||||
{
|
||||
actionCard.Attack();
|
||||
target.Attack();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the stun via <see cref="ActionExecutorHub"/>. Does not spawn damage/defence text.
|
||||
/// </summary>
|
||||
public void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action)
|
||||
{
|
||||
if (source?.Card == null || target?.Card == null || action == null)
|
||||
return;
|
||||
|
||||
ActionExecutorHub.Instance.Execute(action, source.Card, target.Card);
|
||||
source.UpdateInfo();
|
||||
target.UpdateInfo();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Cthangover.CardBattle.UI;
|
||||
using Cthangover.Core.Battle;
|
||||
using Cthangover.Core.Battle.Actions;
|
||||
using Cthangover.Core.Characters;
|
||||
|
||||
namespace Cthangover.CardBattle.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// Strategy for ally-support actions (<c>ToAlias</c>). Validates that the target is a friendly
|
||||
/// player card, highlights with green selection overlays, and applies defence/healing effects
|
||||
/// via <see cref="ActionExecutorHub"/>. Allows one player card to buff another during the player's turn.
|
||||
/// </summary>
|
||||
public class SupportStrategy : ICardActionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> when the action type is <c>ToAlias</c> and the target is a player card.
|
||||
/// </summary>
|
||||
public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target)
|
||||
{
|
||||
return action.Type == ActionCharacterType.ToAlias && target != null && target.IsPlayer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows a green selection overlay on both the dragged action card and the target ally card.
|
||||
/// </summary>
|
||||
public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target)
|
||||
{
|
||||
actionCard.Select();
|
||||
target.Select();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the support action and spawns floating defence and heal text above
|
||||
/// the <paramref name="target"/> ally. Damage numbers are shown with the <c>isHeal</c>
|
||||
/// flag for green coloring.
|
||||
/// </summary>
|
||||
public void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action)
|
||||
{
|
||||
if (source?.Card == null || target?.Card == null || action == null)
|
||||
return;
|
||||
|
||||
var result = ActionExecutorHub.Instance.Execute(action, source.Card, target.Card);
|
||||
source.UpdateInfo();
|
||||
target.UpdateInfo();
|
||||
|
||||
if (result.Result)
|
||||
{
|
||||
if (result.Target.Defence > 0)
|
||||
ShowDamageBehaviour.SpawnDefence(result.Target.Defence, target, target.GlobalPosition);
|
||||
|
||||
if (result.Target.Damage > 0)
|
||||
ShowDamageBehaviour.SpawnDamage(result.Target.Damage, target, target.GlobalPosition, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Cthangover.CardBattle.StatusEffect
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface exposed by <see cref="StatusEffectQueue"/> to <see cref="IStatusEffect"/> instances
|
||||
/// so that status effects can communicate back to the queue during turn lifecycle callbacks.
|
||||
/// <c>SkipTurn</c> allows an effect (e.g. stun) to mark the character's entire turn as skipped.
|
||||
/// <c>RemoveStatus</c> allows an effect to self-remove from the queue.
|
||||
/// </summary>
|
||||
public interface IStatusActions
|
||||
{
|
||||
/// <summary>
|
||||
/// Marks the character's current turn as skipped. Called by <see cref="StunStatusEffect.OnTurnStart"/>.
|
||||
/// </summary>
|
||||
void SkipTurn();
|
||||
/// <summary>
|
||||
/// Removes the specified status effect from the queue. Called by effects that want to
|
||||
/// self-terminate before their turn count expires.
|
||||
/// </summary>
|
||||
void RemoveStatus(IStatusEffect status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Cthangover.Core.Characters;
|
||||
|
||||
namespace Cthangover.CardBattle.StatusEffect
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines a status effect that is applied to a <see cref="Character"/> during battle
|
||||
/// and processed each turn by <see cref="StatusEffectQueue"/>. Effects receive callbacks
|
||||
/// at turn start and turn end, and self-report expiration via <see cref="IsExpired"/>
|
||||
/// when their <see cref="Turns"/> counter reaches zero. The <see cref="IStatusActions"/>
|
||||
/// callback parameter allows effects to influence the character's turn (e.g. skip it).
|
||||
/// </summary>
|
||||
public interface IStatusEffect
|
||||
{
|
||||
/// <summary>Integer identifier for the effect type (1 = stun). Used for serialization.</summary>
|
||||
int Type { get; }
|
||||
/// <summary>Remaining turn count before expiry. Decremented by <see cref="StatusEffectQueue"/>.</summary>
|
||||
int Turns { get; set; }
|
||||
/// <summary>Returns <c>true</c> when <see cref="Turns"/> ≤ 0, signalling removal from the queue.</summary>
|
||||
bool IsExpired { get; }
|
||||
|
||||
/// <summary>Called by <see cref="StatusEffectQueue.OnTurnStart"/> before the turn counter is decremented.</summary>
|
||||
void OnTurnStart(Character character, IStatusActions actions);
|
||||
/// <summary>Called by <see cref="StatusEffectQueue.OnTurnEnd"/> after the character's actions have resolved.</summary>
|
||||
void OnTurnEnd(Character character, IStatusActions actions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Cthangover.CardBattle.StatusEffect
|
||||
{
|
||||
/// <summary>
|
||||
/// Data-only container for a status effect's serializable fields (<see cref="Type"/> and <see cref="Turns"/>).
|
||||
/// Used in <see cref="StatusEffectQueue.Copy"/> to deep-clone status effect state when a character
|
||||
/// is copied for battle. Cast to <see cref="IStatusEffect"/> when added back to a queue.
|
||||
/// </summary>
|
||||
public class StatusEffectItem
|
||||
{
|
||||
/// <summary>Effect type identifier (matches <see cref="IStatusEffect.Type"/>).</summary>
|
||||
public int Type { get; set; }
|
||||
/// <summary>Remaining turns before expiry.</summary>
|
||||
public int Turns { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a field-by-field copy for deep-cloning during character duplication.
|
||||
/// </summary>
|
||||
public StatusEffectItem Copy()
|
||||
{
|
||||
return new StatusEffectItem
|
||||
{
|
||||
Type = Type,
|
||||
Turns = Turns
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Characters;
|
||||
|
||||
namespace Cthangover.CardBattle.StatusEffect
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages the collection of <see cref="IStatusEffect"/> instances attached to a single <see cref="Character"/>.
|
||||
/// Implements <see cref="IStatusActions"/> so that individual effects can call back into the queue
|
||||
/// (e.g. to skip the turn). During <see cref="OnTurnStart"/>, each effect's callback is invoked and
|
||||
/// its turn counter is decremented; expired effects are removed. <see cref="SkipTurn"/> is checked by
|
||||
/// <see cref="CardBattleCore.RunEnemyTurn"/> to skip stunned enemies.
|
||||
/// Supports deep copying for character duplication via <see cref="Copy"/>.
|
||||
/// </summary>
|
||||
public class StatusEffectQueue : IStatusActions
|
||||
{
|
||||
private readonly Character _character;
|
||||
private readonly List<IStatusEffect> _effects = new();
|
||||
|
||||
/// <summary>
|
||||
/// Set to <c>true</c> when an effect calls <see cref="IStatusActions.SkipTurn"/> during <see cref="OnTurnStart"/>.
|
||||
/// Reset to <c>false</c> at the start of each turn before effects are processed.
|
||||
/// </summary>
|
||||
public bool SkipTurn { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new queue bound to the given <paramref name="character"/>.
|
||||
/// </summary>
|
||||
public StatusEffectQueue(Character character)
|
||||
{
|
||||
_character = character;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a deep copy of this queue bound to <paramref name="newCharacter"/>.
|
||||
/// <see cref="StatusEffectItem"/> effects are field-copied; other effect types are shared by reference.
|
||||
/// </summary>
|
||||
public StatusEffectQueue Copy(Character newCharacter)
|
||||
{
|
||||
var clone = new StatusEffectQueue(newCharacter);
|
||||
foreach (var effect in _effects)
|
||||
{
|
||||
if (effect is StatusEffectItem item)
|
||||
clone._effects.Add(item.Copy() as IStatusEffect);
|
||||
else
|
||||
clone._effects.Add(effect);
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new status effect to the end of the processing list. Effects are processed
|
||||
/// in reverse order during turn callbacks (last-added first).
|
||||
/// </summary>
|
||||
public void AddEffect(IStatusEffect effect)
|
||||
{
|
||||
_effects.Add(effect);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes all effects at the start of the character's turn: resets <see cref="SkipTurn"/>,
|
||||
/// calls <see cref="IStatusEffect.OnTurnStart"/> for each effect in reverse order,
|
||||
/// decrements the turn counter, and removes expired effects.
|
||||
/// Called by <see cref="CardBattleCore.StartPlayerTurn"/> and <see cref="CardBattleCore.RunEnemyTurn"/>.
|
||||
/// </summary>
|
||||
public void OnTurnStart()
|
||||
{
|
||||
SkipTurn = false;
|
||||
for (int i = _effects.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var effect = _effects[i];
|
||||
effect.OnTurnStart(_character, this);
|
||||
effect.Turns--;
|
||||
if (effect.IsExpired)
|
||||
_effects.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes all effects at the end of the character's turn: calls
|
||||
/// <see cref="IStatusEffect.OnTurnEnd"/> in reverse order and removes expired effects.
|
||||
/// </summary>
|
||||
public void OnTurnEnd()
|
||||
{
|
||||
for (int i = _effects.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var effect = _effects[i];
|
||||
effect.OnTurnEnd(_character, this);
|
||||
if (effect.IsExpired)
|
||||
_effects.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
void IStatusActions.SkipTurn()
|
||||
{
|
||||
SkipTurn = true;
|
||||
}
|
||||
|
||||
void IStatusActions.RemoveStatus(IStatusEffect status)
|
||||
{
|
||||
_effects.Remove(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Cthangover.Core.Characters;
|
||||
|
||||
namespace Cthangover.CardBattle.StatusEffect
|
||||
{
|
||||
/// <summary>
|
||||
/// Status effect that causes a character to skip their entire turn.
|
||||
/// On <see cref="OnTurnStart"/>, calls <see cref="IStatusActions.SkipTurn"/> which sets
|
||||
/// <see cref="StatusEffectQueue.SkipTurn"/> to <c>true</c>. <see cref="CardBattleCore.RunEnemyTurn"/>
|
||||
/// checks this flag and skips the character's action selection and resolution entirely.
|
||||
/// Created by <see cref="PhysicsStunActionCard"/> when a stun card is played,
|
||||
/// and added to the target's <see cref="StatusEffectQueue"/> via <c>Add("effect/physics/stun", turns)</c>.
|
||||
/// </summary>
|
||||
public class StunStatusEffect : IStatusEffect
|
||||
{
|
||||
/// <summary>Type identifier for stun (1).</summary>
|
||||
public int Type => 1;
|
||||
/// <summary>Number of turns this stun remains active.</summary>
|
||||
public int Turns { get; set; }
|
||||
/// <summary>Expired when the turn counter reaches zero or below.</summary>
|
||||
public bool IsExpired => Turns <= 0;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a stun effect lasting for <paramref name="turns"/> turns.
|
||||
/// </summary>
|
||||
public StunStatusEffect(int turns)
|
||||
{
|
||||
Turns = turns;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parameterless constructor for deserialization. Sets <see cref="Turns"/> to 0 (expired immediately).
|
||||
/// </summary>
|
||||
public StunStatusEffect()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls <see cref="IStatusActions.SkipTurn"/> to mark the character's turn as skipped.
|
||||
/// </summary>
|
||||
public void OnTurnStart(Character character, IStatusActions actions)
|
||||
{
|
||||
actions.SkipTurn();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stun has no end-of-turn effect.
|
||||
/// </summary>
|
||||
public void OnTurnEnd(Character character, IStatusActions actions)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using Cthangover.Core.Cards;
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.Core.UI;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.CardBattle.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// UI node representing a single action card that the player can drag onto a target.
|
||||
/// Implements <see cref="ICard"/> so <see cref="CardController"/> can hit-test drag targets.
|
||||
/// Displayed in the <see cref="CardController"/>'s action panel below the character cards.
|
||||
/// Supports hover scale-up animation, selection highlighting (<see cref="Select"/>, <see cref="Attack"/>, <see cref="Invalid"/>),
|
||||
/// and info updates from the underlying <see cref="ActionCharacter"/> data model.
|
||||
/// </summary>
|
||||
public partial class ActionCardNode : ModWidget, ICard
|
||||
{
|
||||
private TextureRect frame;
|
||||
private TextureRect image;
|
||||
private Label nameLabel;
|
||||
|
||||
/// <inheritdoc />
|
||||
public TextureRect Frame => frame;
|
||||
/// <inheritdoc />
|
||||
public TextureRect Image => image;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="ActionCharacter"/> data model backing this card. Contains the action's
|
||||
/// name, image, type, and stat modifiers used during execution.
|
||||
/// </summary>
|
||||
public ActionCharacter Card { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>this</c> — action cards are <see cref="Control"/> nodes themselves,
|
||||
/// unlike <see cref="CharacterCardNode"/> which wraps a separate control.
|
||||
/// </summary>
|
||||
public Control GetControlNode() => this;
|
||||
|
||||
private Tween hoverTween;
|
||||
private float baseY;
|
||||
private bool isHovered;
|
||||
private Vector2 _baseScale;
|
||||
|
||||
protected override void Construct()
|
||||
{
|
||||
image = GetNode<TextureRect>("Image");
|
||||
frame = GetNode<TextureRect>("Frame");
|
||||
nameLabel = GetNode<Label>("NameLabel");
|
||||
|
||||
Size = CustomMinimumSize;
|
||||
|
||||
MouseEntered += OnMouseEntered;
|
||||
MouseExited += OnMouseExited;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder for selection highlight. Currently unused — action cards use
|
||||
/// <see cref="Attack"/> and <see cref="Invalid"/> for drag-over feedback instead.
|
||||
/// </summary>
|
||||
public void Select()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder for attack-mode highlight. Currently unused — target highlighting
|
||||
/// is applied by <see cref="ICardActionStrategy.HighlightTarget"/> instead.
|
||||
/// </summary>
|
||||
public void Attack()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder for invalid-target highlight. Currently unused — invalidity is
|
||||
/// indicated by <see cref="ICardActionStrategy.HighlightTarget"/> calling
|
||||
/// <see cref="CharacterCardNode.Invalid"/> on the target card instead.
|
||||
/// </summary>
|
||||
public void Invalid()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the Z-index to 0 when the card is no longer being dragged.
|
||||
/// </summary>
|
||||
public void Unselect()
|
||||
{
|
||||
ZIndex = 0;
|
||||
}
|
||||
|
||||
private void OnMouseEntered()
|
||||
{
|
||||
if (isHovered) return;
|
||||
isHovered = true;
|
||||
baseY = Position.Y;
|
||||
hoverTween?.Kill();
|
||||
hoverTween = CreateTween();
|
||||
hoverTween.SetParallel(true);
|
||||
hoverTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
|
||||
hoverTween.TweenProperty(this, "position:y", baseY - 10f, 0.12f);
|
||||
hoverTween.TweenProperty(this, "scale", _baseScale * 1.15f, 0.12f);
|
||||
}
|
||||
|
||||
private void OnMouseExited()
|
||||
{
|
||||
if (!isHovered) return;
|
||||
isHovered = false;
|
||||
hoverTween?.Kill();
|
||||
hoverTween = CreateTween();
|
||||
hoverTween.SetParallel(true);
|
||||
hoverTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
|
||||
hoverTween.TweenProperty(this, "position:y", baseY, 0.12f);
|
||||
hoverTween.TweenProperty(this, "scale", _baseScale, 0.12f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the card with data from <paramref name="card"/>, caches the base scale
|
||||
/// for hover animation, sets the card image texture, and refreshes displayed text.
|
||||
/// Called by <see cref="CardController.CreateAction"/>.
|
||||
/// </summary>
|
||||
public void Init(ActionCharacter card)
|
||||
{
|
||||
Card = card;
|
||||
_baseScale = Scale;
|
||||
|
||||
if (image != null)
|
||||
image.Texture = card.Image;
|
||||
UpdateInfo();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the displayed name label from the translated <see cref="ActionCharacter.Name"/>.
|
||||
/// </summary>
|
||||
public void UpdateInfo()
|
||||
{
|
||||
if (nameLabel != null && Card != null)
|
||||
nameLabel.Text = TranslationServer.Translate(Card.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Core.UI;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.CardBattle.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Container panel that manages a list of <see cref="CharacterCardNode"/> instances for one side
|
||||
/// of the battle (player or enemy). Handles card creation, multi-row layout when enemy count exceeds
|
||||
/// <c>MAX_CARDS_ROW</c> (10 per row), smooth animated repositioning, health-triggered death,
|
||||
/// and the <see cref="CardDeathAnimation"/> dissolve sequence.
|
||||
/// Two instances are created by <see cref="CardBattleCore.Init"/>: a left-aligned player panel
|
||||
/// and a right-aligned enemy panel.
|
||||
/// </summary>
|
||||
public partial class BattleCardPanel : ModWidget
|
||||
{
|
||||
private const int MAX_CARDS_ROW = 10;
|
||||
private const float BASE_CARD_HEIGHT = 280f;
|
||||
|
||||
[Export] private Control content;
|
||||
/// <summary>
|
||||
/// Controls card alignment within the panel. <c>"Left"</c> for the player side,
|
||||
/// <c>"Right"</c> for the enemy side. Affects <see cref="CalcTargetPosition"/>.
|
||||
/// </summary>
|
||||
[Export] public string AlignType { get; set; } = "Left";
|
||||
[Export] private float cellOffset = 10f;
|
||||
|
||||
/// <summary>
|
||||
/// All character cards currently managed by this panel. Populated by <see cref="Init"/>
|
||||
/// and modified by <see cref="Dead"/> (removal) and <see cref="ClearAll"/> (full clear).
|
||||
/// </summary>
|
||||
public List<CharacterCardNode> CardList { get; } = new();
|
||||
private Dictionary<CharacterCardNode, Tween> activeTweens = new();
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, cards are laid out in multiple rows (up to 3) instead of a single row.
|
||||
/// Set by <see cref="CardBattleCore.Start"/> when enemy count exceeds 6.
|
||||
/// </summary>
|
||||
public bool UseRows { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fired when a card's death animation completes. <see cref="CardBattleCore"/> subscribes
|
||||
/// to record the defeated character in <see cref="BattleSceneContext"/>.
|
||||
/// </summary>
|
||||
public event System.Action<CharacterCardNode> OnCardDead;
|
||||
|
||||
protected override void Construct()
|
||||
{
|
||||
content ??= GetNodeOrNull<Control>("Content");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the current card list and creates new <see cref="CharacterCardNode"/> instances
|
||||
/// for each <paramref name="cards"/> entry. Sets player/enemy team colors and subscribes
|
||||
/// the health-change handler. Returns the created card list.
|
||||
/// Called by <see cref="CardBattleCore.Start"/> with the full character roster for each side.
|
||||
/// </summary>
|
||||
public List<CharacterCardNode> Init(IEnumerable<Character> cards, bool isPlayer, float cardScale)
|
||||
{
|
||||
CardList.Clear();
|
||||
activeTweens.Clear();
|
||||
|
||||
int idx = 0;
|
||||
foreach (var card in cards)
|
||||
{
|
||||
CardList.Add(Create(card, isPlayer, cardScale));
|
||||
idx++;
|
||||
}
|
||||
Redraw(animate: false);
|
||||
|
||||
return CardList;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Instantiates a single <see cref="CharacterCardNode"/>, adds it to the content panel,
|
||||
/// sets its scale, initializes it with card data, applies team colors, and hooks
|
||||
/// the health-change event. Called by <see cref="Init"/> for each character.
|
||||
/// </summary>
|
||||
public CharacterCardNode Create(Character card, bool isPlayer, float cardScale)
|
||||
{
|
||||
var behaviour = (CharacterCardNode)TscnScenes.LoadAndBuild("scenes/character_card_node.tscn");
|
||||
behaviour.EnsureConstructed();
|
||||
|
||||
if (content != null)
|
||||
content.AddChild(behaviour);
|
||||
else
|
||||
AddChild(behaviour);
|
||||
|
||||
behaviour.Scale = new Vector2(cardScale, cardScale);
|
||||
|
||||
behaviour.Init(card);
|
||||
behaviour.IsPlayer = isPlayer;
|
||||
behaviour.SetTeamColors();
|
||||
|
||||
if (card != null)
|
||||
card.Attributes.Health.OnChange += CheckHealthAttribute;
|
||||
|
||||
return behaviour;
|
||||
}
|
||||
|
||||
private void CheckHealthAttribute(float value, float basevalue)
|
||||
{
|
||||
if (value > 0)
|
||||
return;
|
||||
|
||||
foreach (var cardItem in CardList.ToList())
|
||||
{
|
||||
if (cardItem.Card.Attributes.Health.Value <= 0)
|
||||
Dead(cardItem);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frees all card nodes from the scene tree, clears the card list, and triggers a redraw.
|
||||
/// Called by <see cref="CardBattleCore.OnBattleCleared"/> when the battle scene is cleaned up.
|
||||
/// </summary>
|
||||
public void ClearAll()
|
||||
{
|
||||
foreach (var card in CardList.ToList())
|
||||
{
|
||||
if (GodotObject.IsInstanceValid(card))
|
||||
card.QueueFree();
|
||||
}
|
||||
CardList.Clear();
|
||||
activeTweens.Clear();
|
||||
Redraw(animate: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recalculates and applies positions for all cards in the list. When <paramref name="animate"/>
|
||||
/// is <c>true</c>, cards tween to their new positions with a cubic ease-out; otherwise they
|
||||
/// snap instantly. Called after cards are added, removed, or when a selection/unselection
|
||||
/// changes the sort order.
|
||||
/// </summary>
|
||||
public void Redraw(bool animate = true)
|
||||
{
|
||||
for (int i = 0; i < CardList.Count; i++)
|
||||
{
|
||||
var card = CardList[i];
|
||||
if (card == null)
|
||||
continue;
|
||||
|
||||
var (row, col) = GetRowCol(i);
|
||||
SetCardPosition(card, i, row, col, animate);
|
||||
}
|
||||
}
|
||||
|
||||
private (int row, int col) GetRowCol(int index)
|
||||
{
|
||||
if (!UseRows || index < MAX_CARDS_ROW)
|
||||
return (0, index);
|
||||
|
||||
if (index < MAX_CARDS_ROW * 2)
|
||||
return (1, index - MAX_CARDS_ROW);
|
||||
|
||||
return (2, index - MAX_CARDS_ROW * 2);
|
||||
}
|
||||
|
||||
private int CalcBaseZIndex(int row, int col)
|
||||
{
|
||||
return row * 20 + col;
|
||||
}
|
||||
|
||||
private Vector2 CalcTargetPosition(CharacterCardNode card, int row, int col)
|
||||
{
|
||||
var panelSize = Size;
|
||||
float effectiveWidth = card.Size.X * card.Scale.X;
|
||||
float effectiveHeight = card.Size.Y * card.Scale.Y;
|
||||
|
||||
float y = row * (effectiveHeight + cellOffset);
|
||||
|
||||
if (AlignType == "Left")
|
||||
return new Vector2((cellOffset + effectiveWidth) * col, y);
|
||||
else
|
||||
return new Vector2(panelSize.X - (cellOffset + effectiveWidth) * (col + 1), y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates how many rows are needed to display <paramref name="enemyCount"/> enemies
|
||||
/// when <see cref="UseRows"/> is enabled. Returns 1, 2, or 3.
|
||||
/// Called by <see cref="CardBattleCore.CalculateCardScale"/> to determine maximum card scale.
|
||||
/// </summary>
|
||||
public int GetCurrentRowCount(int enemyCount)
|
||||
{
|
||||
if (!UseRows)
|
||||
return 1;
|
||||
|
||||
if (enemyCount <= MAX_CARDS_ROW)
|
||||
return 1;
|
||||
if (enemyCount <= MAX_CARDS_ROW * 2)
|
||||
return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the maximum cards per row constant (<c>MAX_CARDS_ROW = 10</c>).
|
||||
/// Used by <see cref="CardBattleCore.CalculateCardScale"/> for scale calculations.
|
||||
/// </summary>
|
||||
public int GetMaxCardsPerRow()
|
||||
{
|
||||
return MAX_CARDS_ROW;
|
||||
}
|
||||
|
||||
private void SetCardPosition(CharacterCardNode card, int index, int row, int col, bool animate)
|
||||
{
|
||||
if (card == null)
|
||||
return;
|
||||
|
||||
var targetPos = CalcTargetPosition(card, row, col);
|
||||
|
||||
if (activeTweens.TryGetValue(card, out var prevTween) && prevTween != null && prevTween.IsValid())
|
||||
prevTween.Kill();
|
||||
|
||||
if (animate)
|
||||
{
|
||||
var tween = CreateTween();
|
||||
tween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
|
||||
tween.TweenProperty(card, "position", targetPos, 0.3f);
|
||||
activeTweens[card] = tween;
|
||||
}
|
||||
else
|
||||
{
|
||||
card.Position = targetPos;
|
||||
activeTweens.Remove(card);
|
||||
}
|
||||
|
||||
var baseZ = CalcBaseZIndex(row, col);
|
||||
card.SetBaseZIndex(baseZ);
|
||||
|
||||
if (content != null)
|
||||
content.MoveChild(card, index);
|
||||
else
|
||||
MoveChild(card, index);
|
||||
card.UpdateInfo();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the card as dead and starts the <see cref="CardDeathAnimation"/> dissolve sequence.
|
||||
/// Once the animation completes, the card is removed from <see cref="CardList"/>,
|
||||
/// <see cref="OnCardDead"/> is fired, and the remaining cards are redrawn.
|
||||
/// Triggered by the health-change handler when a character's health reaches zero.
|
||||
/// </summary>
|
||||
public void Dead(CharacterCardNode characterCardNode)
|
||||
{
|
||||
if (characterCardNode == null || characterCardNode.IsDead)
|
||||
return;
|
||||
|
||||
characterCardNode.IsDead = true;
|
||||
var animator = new Cthangover.CardBattle.UI.CardDeathAnimation();
|
||||
characterCardNode.AddChild(animator);
|
||||
animator.StartAnimation(characterCardNode, () =>
|
||||
{
|
||||
OnCardDead?.Invoke(characterCardNode);
|
||||
CardList.Remove(characterCardNode);
|
||||
activeTweens.Remove(characterCardNode);
|
||||
Redraw();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Cards;
|
||||
using Cthangover.Core.Audio;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Core.Utils;
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.Core.UI;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.CardBattle.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Central input handler for the card battle UI. Processes mouse clicks and drags to implement
|
||||
/// the full player interaction flow: click a character card to show its action cards in the
|
||||
/// action panel, drag an action card onto a valid target (enemy for attacks, ally for support,
|
||||
/// self for self-buffs), and execute the action on drop. Valid targets are determined by
|
||||
/// <see cref="CardActionStrategyFactory.Get"/> dispatching the appropriate <see cref="ICardActionStrategy"/>.
|
||||
/// Fires <see cref="OnActionExecuted"/> after each action, which <see cref="CardBattleCore"/>
|
||||
/// uses to detect automatic turn end when all points are exhausted.
|
||||
/// </summary>
|
||||
public partial class CardController : InputHandlerNode
|
||||
{
|
||||
private Control actionPanel;
|
||||
private BattleCardPanel[] panels;
|
||||
|
||||
private List<CharacterCardNode> characterCards = new();
|
||||
private List<ActionCardNode> actionCards = new();
|
||||
|
||||
private CharacterCardNode selectedCharacter;
|
||||
private CharacterCardNode targetCharacter;
|
||||
private ActionCardNode currentActionCharacter;
|
||||
|
||||
private bool isDragging;
|
||||
private bool dragValid;
|
||||
private float dragThreshold = 15f;
|
||||
private Vector2 pressPosition;
|
||||
private Node _originalActionCardParent;
|
||||
|
||||
/// <summary>
|
||||
/// Locates the action panel and both battle card panels (<see cref="BattleCardPanel"/>) in the
|
||||
/// scene tree relative to this node. Called once by <see cref="CardBattleCore.Init"/>
|
||||
/// after the controller is added to the scene.
|
||||
/// </summary>
|
||||
public void FindPanels()
|
||||
{
|
||||
actionPanel = GetNodeOrNull<Control>("../ActionPanel");
|
||||
panels = new[]
|
||||
{
|
||||
GetNodeOrNull<BattleCardPanel>("../PlayerPanel"),
|
||||
GetNodeOrNull<BattleCardPanel>("../EnemyPanel"),
|
||||
};
|
||||
}
|
||||
|
||||
protected override void OnInput(InputEvent @event)
|
||||
{
|
||||
if (@event is InputEventMouseButton mb1 && mb1.Pressed && mb1.ButtonIndex == MouseButton.Left)
|
||||
GameLogger.Log("CARD_CTRL", "_Input received mouse press", LogLevel.Debug);
|
||||
|
||||
if (Cthangover.Core.Battle.BattleSceneContext.Instance != null &&
|
||||
(Cthangover.Core.Battle.BattleSceneContext.Instance.IsWait ||
|
||||
Cthangover.Core.Battle.BattleSceneContext.Instance.IsDestroyed))
|
||||
{
|
||||
if (@event is InputEventMouseButton mb2 && !mb2.Pressed)
|
||||
GameLogger.Log("CARD_CTRL",
|
||||
$"Input blocked: IsWait={Cthangover.Core.Battle.BattleSceneContext.Instance.IsWait} IsDestroyed={Cthangover.Core.Battle.BattleSceneContext.Instance.IsDestroyed}",
|
||||
LogLevel.Debug);
|
||||
return;
|
||||
}
|
||||
|
||||
if (@event is InputEventMouseButton mouseButton)
|
||||
{
|
||||
if (mouseButton.ButtonIndex == MouseButton.Left)
|
||||
{
|
||||
if (mouseButton.Pressed)
|
||||
{
|
||||
pressPosition = mouseButton.GlobalPosition;
|
||||
isDragging = false;
|
||||
dragValid = false;
|
||||
OnBeginDrag(mouseButton.GlobalPosition);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (isDragging && dragValid)
|
||||
OnEndDrag(mouseButton.GlobalPosition);
|
||||
else
|
||||
OnPointerClick(mouseButton.GlobalPosition);
|
||||
isDragging = false;
|
||||
dragValid = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (@event is InputEventMouseMotion mouseMotion)
|
||||
{
|
||||
if (mouseMotion.ButtonMask.HasFlag(MouseButtonMask.Left))
|
||||
{
|
||||
if (dragValid && !isDragging && (mouseMotion.GlobalPosition - pressPosition).Length() > dragThreshold)
|
||||
isDragging = true;
|
||||
if (isDragging && dragValid)
|
||||
OnDrag(mouseMotion.GlobalPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private float cardScale = 1f;
|
||||
|
||||
/// <summary>
|
||||
/// Uniform scale factor applied to all action cards created by this controller.
|
||||
/// Set by <see cref="CardBattleCore.Start"/> based on viewport fitting calculations.
|
||||
/// </summary>
|
||||
public float CardScale
|
||||
{
|
||||
get => cardScale;
|
||||
set => cardScale = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fired after a successful drag-and-drop action execution. Subscribed by
|
||||
/// <see cref="CardBattleCore.CheckPlayerTurnEnd"/> to auto-end the player's turn
|
||||
/// when all action points are spent.
|
||||
/// </summary>
|
||||
public event System.Action OnActionExecuted;
|
||||
|
||||
/// <summary>
|
||||
/// Clears previous action cards and creates new <see cref="ActionCardNode"/> instances
|
||||
/// for every action in <paramref name="character"/>'s deck. Action cards fade in and are
|
||||
/// laid out horizontally in the action panel. Called when the player clicks a character card.
|
||||
/// </summary>
|
||||
public void ShowActions(CharacterCardNode character)
|
||||
{
|
||||
if (character?.Card?.Actions == null)
|
||||
{
|
||||
GameLogger.Log("CARD_CTRL",
|
||||
$"ShowActions: NULL actions for character {(character?.Card?.Name ?? "null")}",
|
||||
LogLevel.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
GameLogger.Log("CARD_CTRL",
|
||||
$"ShowActions: {character.Card.Name} ({character.Card.Actions.Count} actions)",
|
||||
LogLevel.Debug);
|
||||
|
||||
DiscardActionCharacters();
|
||||
|
||||
var originPos = character.GlobalPosition * actionPanel.GetGlobalTransformWithCanvas().AffineInverse();
|
||||
var count = character.Card.Actions.Count;
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var action = character.Card.Actions[i];
|
||||
if (!action.Type.UseInBattle())
|
||||
continue;
|
||||
var cardNode = CreateAction(action, cardScale);
|
||||
cardNode.Modulate = new Color(1, 1, 1, 0);
|
||||
cardNode.Position = originPos - cardNode.Size / 2;
|
||||
actionCards.Add(cardNode);
|
||||
|
||||
var targetPos = new Vector2(cardNode.Size.X * i, 0);
|
||||
|
||||
var tween = CreateTween();
|
||||
tween.SetParallel(true);
|
||||
tween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
|
||||
tween.TweenProperty(cardNode, "modulate:a", 1f, 0.5f);
|
||||
tween.TweenProperty(cardNode, "position", targetPos, 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Animates existing action cards fading out and dropping down, then frees them.
|
||||
/// Called before showing new action cards for a different character.
|
||||
/// </summary>
|
||||
public void DiscardActionCharacters()
|
||||
{
|
||||
if (actionCards.Count == 0)
|
||||
return;
|
||||
|
||||
var oldCards = new List<ActionCardNode>(actionCards);
|
||||
actionCards.Clear();
|
||||
|
||||
foreach (var card in oldCards)
|
||||
{
|
||||
if (!GodotObject.IsInstanceValid(card))
|
||||
continue;
|
||||
|
||||
card.MouseFilter = Control.MouseFilterEnum.Ignore;
|
||||
var tween = CreateTween();
|
||||
tween.SetParallel(true);
|
||||
tween.SetEase(Tween.EaseType.In).SetTrans(Tween.TransitionType.Cubic);
|
||||
tween.TweenProperty(card, "modulate:a", 0f, 0.15f);
|
||||
tween.TweenProperty(card, "position:y", card.Position.Y + 40f, 0.15f);
|
||||
tween.Chain().TweenCallback(Callable.From(card.QueueFree));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deselects the current action card, source character, and target character, clearing
|
||||
/// their visual highlights and optionally discarding action cards. When
|
||||
/// <paramref name="clearSource"/> is <c>true</c>, the action panel is also cleared. Triggers
|
||||
/// a redraw of all battle panels to restore normal Z-ordering.
|
||||
/// </summary>
|
||||
public void ClearSelections(bool clearSource = true)
|
||||
{
|
||||
if (currentActionCharacter != null)
|
||||
{
|
||||
currentActionCharacter.Unselect();
|
||||
currentActionCharacter = null;
|
||||
}
|
||||
if (selectedCharacter != null && clearSource)
|
||||
{
|
||||
selectedCharacter.Unselect();
|
||||
selectedCharacter = null;
|
||||
DiscardActionCharacters();
|
||||
}
|
||||
if (targetCharacter != null)
|
||||
{
|
||||
targetCharacter.Unselect();
|
||||
targetCharacter = null;
|
||||
}
|
||||
|
||||
if (panels != null)
|
||||
{
|
||||
foreach (var panel in panels)
|
||||
panel?.Redraw();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immediately frees all action card nodes without animation. Called at battle cleanup
|
||||
/// by <see cref="CardBattleCore.OnBattleCleared"/>.
|
||||
/// </summary>
|
||||
public void ClearActions()
|
||||
{
|
||||
if (actionCards.Count > 0)
|
||||
{
|
||||
foreach (var card in actionCards)
|
||||
card.QueueFree();
|
||||
actionCards.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-positions all action cards in a horizontal row within the action panel.
|
||||
/// When <paramref name="animate"/> is <c>true</c>, cards tween smoothly to their targets.
|
||||
/// </summary>
|
||||
public void Redraw(bool animate = true)
|
||||
{
|
||||
var size = actionCards.Count;
|
||||
for (int i = 0; i < size; i++)
|
||||
SetActionCharacterPosition(actionCards[i], i, size, animate);
|
||||
}
|
||||
|
||||
private void SetActionCharacterPosition(ActionCardNode card, int index, int count, bool animate)
|
||||
{
|
||||
var rect = card.GetControlNode();
|
||||
var targetPos = new Vector2(rect.Size.X * index, 0);
|
||||
|
||||
if (animate)
|
||||
{
|
||||
var tween = CreateTween();
|
||||
tween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
|
||||
tween.TweenProperty(rect, "position", targetPos, 0.25f);
|
||||
}
|
||||
else
|
||||
{
|
||||
rect.Position = targetPos;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Instantiates an <see cref="ActionCardNode"/>, adds it to the action panel, applies
|
||||
/// the given <paramref name="cardScale"/>, and initializes it with <paramref name="actionCard"/> data.
|
||||
/// Called by <see cref="ShowActions"/> for each action in a character's deck.
|
||||
/// </summary>
|
||||
public ActionCardNode CreateAction(ActionCharacter actionCard, float cardScale)
|
||||
{
|
||||
var card = (ActionCardNode)TscnScenes.LoadAndBuild("scenes/action_card_node.tscn");
|
||||
card.EnsureConstructed();
|
||||
actionPanel.AddChild(card);
|
||||
card.Scale = new Vector2(cardScale, cardScale);
|
||||
card.Init(actionCard);
|
||||
return card;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the currently selected action against the selected target using the
|
||||
/// appropriate <see cref="ICardActionStrategy"/>. Called by <see cref="OnEndDrag"/>
|
||||
/// when a valid drop is confirmed.
|
||||
/// </summary>
|
||||
public void DoActionToTarget()
|
||||
{
|
||||
var strategy = Cthangover.CardBattle.Player.CardActionStrategyFactory.Get(
|
||||
currentActionCharacter.Card, selectedCharacter, targetCharacter);
|
||||
strategy?.Execute(selectedCharacter, targetCharacter, currentActionCharacter.Card);
|
||||
}
|
||||
|
||||
private void OnPointerClick(Vector2 screenPosition)
|
||||
{
|
||||
characterCards = GetAllCharacters();
|
||||
var source = TryFindCardByPosition(screenPosition, characterCards);
|
||||
|
||||
if (source != null)
|
||||
GameLogger.Log("CARD_CTRL",
|
||||
$"Click on card: {source.Card.Name} isPlayer={source.IsPlayer} actionsCount={source.Card.Actions?.Count ?? 0}",
|
||||
LogLevel.Debug);
|
||||
else
|
||||
GameLogger.Log("CARD_CTRL", "Click on empty space", LogLevel.Debug);
|
||||
|
||||
if (source != selectedCharacter && selectedCharacter != null)
|
||||
selectedCharacter.Unselect();
|
||||
|
||||
if (source != null && source.IsPlayer)
|
||||
{
|
||||
var audioService = SceneContextNode.Instance?.GetSceneRoot<AudioService>("AudioService");
|
||||
audioService?.PlaySound("battle/card_show_actions", Cthangover.Core.Audio.SoundType.CardEffect);
|
||||
selectedCharacter = source;
|
||||
ShowActions(selectedCharacter);
|
||||
selectedCharacter.Select();
|
||||
}
|
||||
else
|
||||
{
|
||||
ClearSelections();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnBeginDrag(Vector2 screenPosition)
|
||||
{
|
||||
var source = TryFindCardByPosition(screenPosition, actionCards);
|
||||
if (currentActionCharacter != null && source != currentActionCharacter)
|
||||
currentActionCharacter.Unselect();
|
||||
|
||||
if (source != null)
|
||||
{
|
||||
dragValid = true;
|
||||
var audioService = SceneContextNode.Instance?.GetSceneRoot<AudioService>("AudioService");
|
||||
audioService?.PlaySound("battle/card_pick", 4, Cthangover.Core.Audio.SoundType.CardEffect);
|
||||
|
||||
GameLogger.Log("CARD_CTRL",
|
||||
$"Begin drag: {source.Card.Name}",
|
||||
LogLevel.Debug);
|
||||
|
||||
currentActionCharacter = source;
|
||||
var control = currentActionCharacter.GetControlNode();
|
||||
_originalActionCardParent = control.GetParent();
|
||||
var globalPos = control.GlobalPosition;
|
||||
control.Reparent(GetTree().Root);
|
||||
control.GlobalPosition = globalPos;
|
||||
control.ZIndex = 4096;
|
||||
var pickTween = CreateTween();
|
||||
pickTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Back);
|
||||
pickTween.TweenProperty(control, "scale", new Vector2(1.1f, 1.1f), 0.1f);
|
||||
|
||||
foreach (var card in actionCards)
|
||||
{
|
||||
if (card != currentActionCharacter && GodotObject.IsInstanceValid(card))
|
||||
card.MouseFilter = Control.MouseFilterEnum.Ignore;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDrag(Vector2 screenPosition)
|
||||
{
|
||||
if (currentActionCharacter == null)
|
||||
return;
|
||||
|
||||
var cardControl = currentActionCharacter.GetControlNode();
|
||||
cardControl.GlobalPosition = screenPosition - cardControl.Size / 2;
|
||||
|
||||
var target = TryFindCardByRect(cardControl, characterCards);
|
||||
|
||||
if (target != targetCharacter && targetCharacter != null)
|
||||
targetCharacter.Unselect();
|
||||
|
||||
if (target != null)
|
||||
{
|
||||
targetCharacter = target;
|
||||
var strategy = Cthangover.CardBattle.Player.CardActionStrategyFactory.Get(
|
||||
currentActionCharacter.Card, selectedCharacter, target);
|
||||
|
||||
var valid = strategy != null && strategy.Check(currentActionCharacter.Card, selectedCharacter, target);
|
||||
|
||||
GameLogger.Log("CARD_CTRL",
|
||||
$"Drag over: {target.Card.Name} valid={valid}",
|
||||
LogLevel.Debug);
|
||||
|
||||
if (valid)
|
||||
{
|
||||
strategy.HighlightTarget(currentActionCharacter, targetCharacter);
|
||||
}
|
||||
else
|
||||
{
|
||||
targetCharacter.Invalid();
|
||||
currentActionCharacter.Invalid();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
targetCharacter = null;
|
||||
currentActionCharacter.Unselect();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEndDrag(Vector2 screenPosition)
|
||||
{
|
||||
foreach (var card in actionCards)
|
||||
{
|
||||
if (card != null && GodotObject.IsInstanceValid(card))
|
||||
card.MouseFilter = Control.MouseFilterEnum.Stop;
|
||||
}
|
||||
|
||||
if (selectedCharacter != null && currentActionCharacter != null && targetCharacter != null)
|
||||
{
|
||||
var strategy = Cthangover.CardBattle.Player.CardActionStrategyFactory.Get(
|
||||
currentActionCharacter.Card, selectedCharacter, targetCharacter);
|
||||
|
||||
var valid = strategy != null && strategy.Check(currentActionCharacter.Card, selectedCharacter, targetCharacter);
|
||||
|
||||
GameLogger.Log("CARD_CTRL",
|
||||
$"End drag: {currentActionCharacter.Card.Name} from {selectedCharacter.Card.Name} to {targetCharacter.Card.Name} valid={valid}",
|
||||
LogLevel.Debug);
|
||||
|
||||
if (valid)
|
||||
{
|
||||
if (_originalActionCardParent != null)
|
||||
{
|
||||
currentActionCharacter.GetControlNode().Reparent(_originalActionCardParent);
|
||||
_originalActionCardParent = null;
|
||||
}
|
||||
|
||||
var audioService = SceneContextNode.Instance?.GetSceneRoot<AudioService>("AudioService");
|
||||
audioService?.PlaySound("battle/card_drop", 4, Cthangover.Core.Audio.SoundType.CardEffect);
|
||||
|
||||
DoActionToTarget();
|
||||
|
||||
if (panels != null)
|
||||
{
|
||||
foreach (var panel in panels)
|
||||
panel?.Redraw();
|
||||
}
|
||||
|
||||
ClearSelections();
|
||||
OnActionExecuted?.Invoke();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentActionCharacter != null)
|
||||
{
|
||||
var control = currentActionCharacter.GetControlNode();
|
||||
if (_originalActionCardParent != null)
|
||||
{
|
||||
control.Reparent(_originalActionCardParent);
|
||||
_originalActionCardParent = null;
|
||||
}
|
||||
var resetTween = CreateTween();
|
||||
resetTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
|
||||
resetTween.TweenProperty(control, "scale", Vector2.One, 0.15f);
|
||||
control.ZIndex = 0;
|
||||
}
|
||||
|
||||
Redraw();
|
||||
ClearSelections(false);
|
||||
}
|
||||
|
||||
private T TryFindCardByPosition<T>(Vector2 screenPosition, IList<T> cards, ICard exclude = null) where T : class, ICard
|
||||
{
|
||||
if (cards == null)
|
||||
return null;
|
||||
foreach (var card in cards)
|
||||
{
|
||||
if (card == exclude || card == null)
|
||||
continue;
|
||||
if (card.Frame != null && card.Frame.GetGlobalRect().HasPoint(screenPosition))
|
||||
return card;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private T TryFindCardByRect<T>(Control draggingRect, IList<T> cards, ICard exclude = null) where T : class, ICard
|
||||
{
|
||||
Vector2 screenPoint = draggingRect.GlobalPosition + draggingRect.Size / 2;
|
||||
foreach (var card in cards)
|
||||
{
|
||||
if (card == exclude || card == null)
|
||||
continue;
|
||||
if (card.Frame != null && card.Frame.GetGlobalRect().HasPoint(screenPoint))
|
||||
return card;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<CharacterCardNode> GetAllCharacters()
|
||||
{
|
||||
var list = new List<CharacterCardNode>();
|
||||
var root = GetParent();
|
||||
if (root != null)
|
||||
CollectNodes<CharacterCardNode>(root, list);
|
||||
return list;
|
||||
}
|
||||
|
||||
private static void CollectNodes<T>(Node parent, List<T> results) where T : Node
|
||||
{
|
||||
foreach (Node child in parent.GetChildren())
|
||||
{
|
||||
if (child is T t)
|
||||
results.Add(t);
|
||||
CollectNodes(child, results);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Mods.Resolvers;
|
||||
using Cthangover.Core.UI;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.CardBattle.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Plays a death animation on a <see cref="CharacterCardNode"/> when its health reaches zero.
|
||||
/// The card simultaneously fades out, shrinks toward the screen center, spins 360 degrees,
|
||||
/// and applies a dissolve shader to all its <see cref="CharacterCardNode.AllImages"/> textures.
|
||||
/// The dissolve shader (<c>"scene_transition"</c>) is resolved once via <see cref="ModManager"/>
|
||||
/// and cached statically. Created and started by <see cref="BattleCardPanel.Dead"/>.
|
||||
/// </summary>
|
||||
public partial class CardDeathAnimation : Node
|
||||
{
|
||||
private CharacterCardNode card;
|
||||
private Vector2 center;
|
||||
private Action onComplete;
|
||||
|
||||
private static Shader dissolveShader;
|
||||
|
||||
/// <summary>
|
||||
/// Begins the death animation sequence. Calculates a target position at the viewport center
|
||||
/// (relative to the card's parent), starts fading all child <see cref="Label"/> nodes,
|
||||
/// applies the dissolve shader material to all card images, and tweens position/rotation/alpha.
|
||||
/// On completion, invokes <paramref name="onComplete"/> and frees this node.
|
||||
/// </summary>
|
||||
public void StartAnimation(CharacterCardNode card, Action onComplete)
|
||||
{
|
||||
this.card = card;
|
||||
this.onComplete = onComplete;
|
||||
|
||||
var viewportCenter = card.GetViewport().GetVisibleRect().Size / 2;
|
||||
center = card.GetParent<Control>() is Control parent
|
||||
? viewportCenter - parent.GlobalPosition
|
||||
: viewportCenter - card.GlobalPosition + card.Position;
|
||||
|
||||
RunAnimation();
|
||||
}
|
||||
|
||||
private void RunAnimation()
|
||||
{
|
||||
float duration = 0.8f;
|
||||
|
||||
Vector2 endPos = center;
|
||||
|
||||
StartFadeOutRecursive(card, duration);
|
||||
|
||||
if (dissolveShader == null)
|
||||
dissolveShader = Shaders.Resolve("scene_transition");
|
||||
|
||||
if (dissolveShader != null)
|
||||
{
|
||||
foreach (var img in card.AllImages)
|
||||
{
|
||||
var mat = new ShaderMaterial { Shader = dissolveShader };
|
||||
mat.SetShaderParameter("noise_scale", 5f);
|
||||
mat.SetShaderParameter("smoothness", 0.25f);
|
||||
mat.SetShaderParameter("distortion", 0.092f);
|
||||
mat.SetShaderParameter("glow_intensity", 0f);
|
||||
mat.SetShaderParameter("hue_shift", 0f);
|
||||
mat.SetShaderParameter("invert_direction", false);
|
||||
mat.SetShaderParameter("progress", 0f);
|
||||
img.Material = mat;
|
||||
|
||||
var matTween = CreateTween();
|
||||
matTween.TweenMethod(
|
||||
Callable.From<float>(t => mat.SetShaderParameter("progress", t)),
|
||||
0f, 1f, duration);
|
||||
}
|
||||
}
|
||||
|
||||
var tween = CreateTween();
|
||||
tween.SetParallel(true);
|
||||
tween.TweenProperty(card, "position", endPos, duration);
|
||||
tween.TweenProperty(card, "rotation", Mathf.DegToRad(360f), duration);
|
||||
tween.TweenProperty(card, "modulate:a", 0f, duration);
|
||||
|
||||
tween.Finished += () =>
|
||||
{
|
||||
onComplete?.Invoke();
|
||||
QueueFree();
|
||||
};
|
||||
}
|
||||
|
||||
private static void StartFadeOutRecursive(Node node, float duration)
|
||||
{
|
||||
foreach (Node child in node.GetChildren())
|
||||
{
|
||||
if (child is Label label)
|
||||
{
|
||||
var t = child.CreateTween();
|
||||
t.TweenProperty(label, "modulate:a", 0f, duration);
|
||||
}
|
||||
StartFadeOutRecursive(child, duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
using Cthangover.Core.Cards;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Mods.Resolvers;
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.Core.UI;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.CardBattle.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// UI node representing a single character (player or enemy) on the battle field.
|
||||
/// Displays the character portrait, name, health bar, and stat labels (defence S, attack A, action points P).
|
||||
/// Implements <see cref="ICard"/> for hit-testing during drag-and-drop targeting.
|
||||
/// Supports team-colored outlines (blue for player, yellow for enemy), hover scale-up animation,
|
||||
/// and multiple selection highlight modes (<see cref="Select"/>, <see cref="Attack"/>, <see cref="Invalid"/>)
|
||||
/// driven by the <see cref="ICardActionStrategy"/> flow. Manages Z-index layering for selected,
|
||||
/// hovered, and normal states.
|
||||
/// </summary>
|
||||
public partial class CharacterCardNode : ModWidget, ICard
|
||||
{
|
||||
private ColorRect teamOutline;
|
||||
private TextureRect frame;
|
||||
private TextureRect image;
|
||||
private TextureRect selection;
|
||||
|
||||
private ColorRect healthRect;
|
||||
private Label nameLabel;
|
||||
private Label defenceLabel;
|
||||
private Label attackLabel;
|
||||
private Label pointsLabel;
|
||||
|
||||
private Color selectionState;
|
||||
private float defaultWidth;
|
||||
private float baseY;
|
||||
private bool isHovered;
|
||||
private Tween hoverTween;
|
||||
private Tween selectionTween;
|
||||
private static Texture2D cachedSelectTex;
|
||||
private static bool selectTexLoaded;
|
||||
|
||||
/// <summary>
|
||||
/// Distinguishes player-owned cards from enemy cards. Affects team outline color in
|
||||
/// <see cref="SetTeamColors"/> and determines whether the card is clickable in
|
||||
/// <see cref="CardController.OnPointerClick"/>.
|
||||
/// </summary>
|
||||
public bool IsPlayer { get; set; }
|
||||
/// <summary>
|
||||
/// Marks the card as dead. Set by <see cref="BattleCardPanel.Dead"/>, checked by
|
||||
/// <see cref="CardBattleCore"/> when counting alive cards and by
|
||||
/// <see cref="CardBattleCore.RunEnemyTurn"/> when iterating for enemy actions.
|
||||
/// </summary>
|
||||
public bool IsDead { get; set; }
|
||||
|
||||
private int _baseZIndex;
|
||||
private bool _selected;
|
||||
private Vector2 _baseScale;
|
||||
|
||||
/// <inheritdoc />
|
||||
public TextureRect Frame => frame;
|
||||
/// <inheritdoc />
|
||||
public TextureRect Image => image;
|
||||
/// <summary>
|
||||
/// All <see cref="TextureRect"/> nodes on this card (frame, image, selection).
|
||||
/// Consumed by <see cref="CardDeathAnimation"/> to apply the dissolve shader to every visual element.
|
||||
/// </summary>
|
||||
public TextureRect[] AllImages => _allImages ??= new[] { frame, image, selection };
|
||||
private TextureRect[] _allImages;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="Character"/> data model backing this card. Contains attributes
|
||||
/// (health, attack, defence, points) and the action deck.
|
||||
/// </summary>
|
||||
public Character Card { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>this</c> — character cards are self-contained <see cref="Control"/> nodes.
|
||||
/// </summary>
|
||||
public Control GetControlNode() => this;
|
||||
|
||||
protected override void Construct()
|
||||
{
|
||||
teamOutline = GetNode<ColorRect>("TeamOutline");
|
||||
image = GetNode<TextureRect>("Image");
|
||||
frame = GetNode<TextureRect>("Frame");
|
||||
selection = GetNode<TextureRect>("Selection");
|
||||
nameLabel = GetNode<Label>("InfoPanel/NameLabel");
|
||||
healthRect = GetNode<ColorRect>("InfoPanel/HealthBar/HealthRect");
|
||||
defenceLabel = GetNode<Label>("InfoPanel/Labels/DefenceLabel");
|
||||
attackLabel = GetNode<Label>("InfoPanel/Labels/AttackLabel");
|
||||
pointsLabel = GetNode<Label>("InfoPanel/Labels/PointsLabel");
|
||||
|
||||
Size = CustomMinimumSize;
|
||||
|
||||
defaultWidth = 230f;
|
||||
selectionState = new Color(0, 0, 0, 0);
|
||||
|
||||
if (!selectTexLoaded)
|
||||
{
|
||||
cachedSelectTex = Textures.Resolve("select");
|
||||
selectTexLoaded = true;
|
||||
}
|
||||
if (cachedSelectTex != null)
|
||||
{
|
||||
selection.Texture = cachedSelectTex;
|
||||
selection.ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize;
|
||||
selection.StretchMode = TextureRect.StretchModeEnum.Scale;
|
||||
}
|
||||
|
||||
MouseEntered += OnMouseEntered;
|
||||
MouseExited += OnMouseExited;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the card with the given <paramref name="characterCard"/> data, sets the
|
||||
/// portrait and frame textures, resets selection visuals, and refreshes stat labels.
|
||||
/// Called by <see cref="BattleCardPanel.Create"/>.
|
||||
/// </summary>
|
||||
public void Init(Character characterCard)
|
||||
{
|
||||
Card = characterCard;
|
||||
_baseScale = Scale;
|
||||
|
||||
if (image != null)
|
||||
image.Texture = characterCard.Image;
|
||||
|
||||
if (frame != null && characterCard?.Image != null)
|
||||
frame.Texture = characterCard.Image;
|
||||
|
||||
if (selection != null)
|
||||
selection.Modulate = new Color(0, 0, 0, 0);
|
||||
|
||||
selectionState = new Color(0, 0, 0, 0);
|
||||
|
||||
UpdateInfo();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a team-colored outline: blue for player cards (<see cref="IsPlayer"/> = <c>true</c>),
|
||||
/// yellow for enemy cards. Called by <see cref="BattleCardPanel.Create"/> after initialization.
|
||||
/// </summary>
|
||||
public void SetTeamColors()
|
||||
{
|
||||
if (teamOutline != null)
|
||||
teamOutline.Color = IsPlayer
|
||||
? new Color(0.2f, 0.4f, 1f, 0.5f)
|
||||
: new Color(1f, 0.8f, 0.1f, 0.5f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the base Z-index used for layering cards. When not selected, the card renders at
|
||||
/// this Z-index. Selected cards jump to Z-index 4096 to render above all others.
|
||||
/// Called by <see cref="BattleCardPanel.SetCardPosition"/> during layout.
|
||||
/// </summary>
|
||||
public void SetBaseZIndex(int z)
|
||||
{
|
||||
_baseZIndex = z;
|
||||
if (!_selected)
|
||||
ZIndex = z;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a green selection highlight, raises Z-index to 4096 to ensure the card
|
||||
/// is clickable above other cards during drag-and-drop targeting, and animates
|
||||
/// the selection overlay to green. Called when the player clicks a character card
|
||||
/// or when a valid support/self target is dragged over.
|
||||
/// </summary>
|
||||
public void Select()
|
||||
{
|
||||
_selected = true;
|
||||
selectionState = new Color(0, 1, 0, 1);
|
||||
AnimateSelection(selectionState);
|
||||
if (selection != null) selection.ZIndex = 1;
|
||||
ZIndex = 4096;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the selection highlight, restores Z-index to <see cref="SetBaseZIndex"/> value,
|
||||
/// and fades the selection overlay to transparent.
|
||||
/// </summary>
|
||||
public void Unselect()
|
||||
{
|
||||
_selected = false;
|
||||
selectionState = new Color(0, 0, 0, 0);
|
||||
AnimateSelection(selectionState);
|
||||
if (selection != null) selection.ZIndex = 0;
|
||||
ZIndex = _baseZIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a red highlight indicating this card is being targeted for an attack.
|
||||
/// Called by <see cref="ICardActionStrategy.HighlightTarget"/> during drag-over.
|
||||
/// </summary>
|
||||
public void Attack()
|
||||
{
|
||||
selectionState = new Color(1, 0, 0, 1);
|
||||
AnimateSelection(selectionState);
|
||||
if (selection != null) selection.ZIndex = 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a gray dimming highlight indicating this card is an invalid target
|
||||
/// for the currently dragged action card. Called by <see cref="CardController.OnDrag"/>
|
||||
/// when the strategy's <c>Check</c> returns <c>false</c>.
|
||||
/// </summary>
|
||||
public void Invalid()
|
||||
{
|
||||
selectionState = new Color(0.4f, 0.4f, 0.4f, 0.8f);
|
||||
AnimateSelection(selectionState);
|
||||
if (selection != null) selection.ZIndex = 1;
|
||||
}
|
||||
|
||||
private void AnimateSelection(Color target)
|
||||
{
|
||||
if (selection == null) return;
|
||||
selectionTween?.Kill();
|
||||
selectionTween = CreateTween();
|
||||
selectionTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Quad);
|
||||
selectionTween.TweenProperty(selection, "modulate", target, 0.15f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes all displayed stats from the underlying <see cref="Character"/> data:
|
||||
/// name, defence, attack, action points, and health bar width (based on health percent).
|
||||
/// Called after every action execution and during initial setup.
|
||||
/// </summary>
|
||||
public void UpdateInfo()
|
||||
{
|
||||
if (healthRect == null || Card == null) return;
|
||||
|
||||
if (nameLabel != null)
|
||||
nameLabel.Text = TranslationServer.Translate(Card.Name);
|
||||
if (defenceLabel != null)
|
||||
defenceLabel.Text = Card.Attributes.Defence.Value.ToString();
|
||||
if (attackLabel != null)
|
||||
attackLabel.Text = Card.Attributes.Attack.Value.ToString();
|
||||
if (pointsLabel != null)
|
||||
pointsLabel.Text = Card.Attributes.Point.Value.ToString();
|
||||
|
||||
var sizeDelta = healthRect.Size;
|
||||
sizeDelta.X = Card.Attributes.Health.Percent * defaultWidth;
|
||||
healthRect.SetDeferred(Control.PropertyName.Size, sizeDelta);
|
||||
}
|
||||
|
||||
private void OnMouseEntered()
|
||||
{
|
||||
if (isHovered) return;
|
||||
isHovered = true;
|
||||
baseY = Position.Y;
|
||||
|
||||
if (!_selected)
|
||||
ZIndex = _baseZIndex + 100;
|
||||
|
||||
hoverTween?.Kill();
|
||||
hoverTween = CreateTween();
|
||||
hoverTween.SetParallel(true);
|
||||
hoverTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
|
||||
hoverTween.TweenProperty(this, "position:y", baseY - 10f, 0.15f);
|
||||
hoverTween.TweenProperty(this, "scale", _baseScale * 1.15f, 0.15f);
|
||||
}
|
||||
|
||||
private void OnMouseExited()
|
||||
{
|
||||
if (!isHovered) return;
|
||||
isHovered = false;
|
||||
|
||||
if (!_selected)
|
||||
ZIndex = _baseZIndex;
|
||||
|
||||
hoverTween?.Kill();
|
||||
hoverTween = CreateTween();
|
||||
hoverTween.SetParallel(true);
|
||||
hoverTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
|
||||
hoverTween.TweenProperty(this, "position:y", baseY, 0.15f);
|
||||
hoverTween.TweenProperty(this, "scale", _baseScale, 0.15f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Cthangover.Core.UI;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.CardBattle.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// A styled button widget that allows the player to manually end their turn.
|
||||
/// Displayed during the player's turn and hidden during enemy turns and battle cleanup.
|
||||
/// Fires <see cref="OnPressed"/> when clicked, which <see cref="CardBattleCore"/> binds
|
||||
/// to <c>OnPlayerTurnEnd</c>. The button text is translated via the <c>"battle/end_turn"</c> key.
|
||||
/// </summary>
|
||||
public partial class EndTurnButton : ModWidget
|
||||
{
|
||||
private Button _button;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the button is pressed. Subscribed by <see cref="CardBattleCore.OnEndTurnPressed"/>.
|
||||
/// </summary>
|
||||
public event System.Action OnPressed;
|
||||
|
||||
protected override void Construct()
|
||||
{
|
||||
_button = GetNode<Button>("BtnEndTurn");
|
||||
|
||||
Size = CustomMinimumSize;
|
||||
|
||||
_button.Text = TranslationServer.Translate("battle/end_turn");
|
||||
_button.Pressed += () => OnPressed?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows or hides the underlying <see cref="Button"/> control.
|
||||
/// Called by <see cref="CardBattleCore"/> during turn transitions and battle cleanup.
|
||||
/// </summary>
|
||||
public void SetVisible(bool visible)
|
||||
{
|
||||
if (_button != null)
|
||||
_button.Visible = visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user