init
This commit is contained in:
+37
@@ -0,0 +1,37 @@
|
||||
# Godot 4.x
|
||||
.godot/
|
||||
.idea/
|
||||
sdk/
|
||||
bin/
|
||||
obj/
|
||||
|
||||
#ai
|
||||
.omo/
|
||||
.opencode/
|
||||
|
||||
gen/
|
||||
obj/
|
||||
bin/
|
||||
|
||||
*.tres~
|
||||
*.tscn~
|
||||
|
||||
# GDScript LSP
|
||||
.godot/
|
||||
|
||||
# Export
|
||||
*.exe
|
||||
*.dmg
|
||||
*.apk
|
||||
*.aab
|
||||
*.pck
|
||||
*.zip
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor
|
||||
*.import
|
||||
*.godot.uid
|
||||
@@ -0,0 +1,14 @@
|
||||
mod_ff_battle_name = FFBattle
|
||||
mod_ff_battle_desc = Final Fantasy style battle system
|
||||
|
||||
ff_battle/menu_attack = Attack
|
||||
ff_battle/menu_items = Items
|
||||
ff_battle/menu_defend = Defend
|
||||
ff_battle/menu_escape = Escape
|
||||
ff_battle/end_turn = End turn
|
||||
ff_battle/back = Back
|
||||
ff_battle/no_items = No items
|
||||
ff_battle/select_target = Select a target
|
||||
ff_battle/escape_fail = Escape failed!
|
||||
ff_battle/escape_success = You escaped!
|
||||
ff_battle/no_actions = No actions
|
||||
@@ -0,0 +1,14 @@
|
||||
mod_ff_battle_name=FF Битва
|
||||
mod_ff_battle_desc=Боевая система в стиле Final Fantasy
|
||||
|
||||
ff_battle/menu_attack=Атака
|
||||
ff_battle/menu_items=Предметы
|
||||
ff_battle/menu_defend=Защита
|
||||
ff_battle/menu_escape=Побег
|
||||
ff_battle/end_turn=Конец хода
|
||||
ff_battle/back=Назад
|
||||
ff_battle/no_items=Нет предметов
|
||||
ff_battle/select_target=Выберите цель
|
||||
ff_battle/escape_fail=Побег не удался!
|
||||
ff_battle/escape_success=Вы сбежали!
|
||||
ff_battle/no_actions=Нет действий
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "ff_battle",
|
||||
"name": "FFBattle",
|
||||
"version": "1.0.0",
|
||||
"author": "ct",
|
||||
"description": "Final Fantasy style battle system",
|
||||
"sources": [
|
||||
"src/**/*.cs"
|
||||
],
|
||||
"depends": [ "core>=1", "interface>=1" ]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="src/UI/FFCharacterWidget.cs" id="1_script"]
|
||||
|
||||
[node name="FFCharacterWidget" type="Control" script=ExtResource("1_script")]
|
||||
custom_minimum_size = Vector2(180, 260)
|
||||
layout_mode = 3
|
||||
|
||||
[node name="Sprite" type="TextureRect" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
offset_left = 8.0
|
||||
offset_top = 8.0
|
||||
offset_right = -8.0
|
||||
offset_bottom = -44.0
|
||||
mouse_filter = 2
|
||||
expand_mode = 1
|
||||
stretch_mode = 5
|
||||
|
||||
[node name="HpBg" type="ColorRect" parent="."]
|
||||
layout_mode = 0
|
||||
offset_left = 4.0
|
||||
offset_top = 218.0
|
||||
offset_right = 176.0
|
||||
offset_bottom = 236.0
|
||||
mouse_filter = 2
|
||||
color = Color(0.15, 0.15, 0.15, 1)
|
||||
|
||||
[node name="HpFill" type="ColorRect" parent="."]
|
||||
layout_mode = 0
|
||||
offset_left = 4.0
|
||||
offset_top = 218.0
|
||||
offset_right = 176.0
|
||||
offset_bottom = 236.0
|
||||
mouse_filter = 2
|
||||
color = Color(0.1, 0.85, 0.1, 1)
|
||||
|
||||
[node name="NameLabel" type="Label" parent="."]
|
||||
layout_mode = 0
|
||||
offset_left = 4.0
|
||||
offset_top = 238.0
|
||||
offset_right = 176.0
|
||||
offset_bottom = 256.0
|
||||
mouse_filter = 2
|
||||
horizontal_alignment = 1
|
||||
theme_override_font_sizes/font_size = 13
|
||||
|
||||
[node name="Selection" type="TextureRect" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
@@ -0,0 +1,37 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="src/UI/FFMenuPanel.cs" id="1_script"]
|
||||
|
||||
[node name="FFMenuPanel" type="Control" script=ExtResource("1_script")]
|
||||
mouse_filter = 0
|
||||
|
||||
[node name="Background" type="ColorRect" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
color = Color(0.02, 0.05, 0.12, 0.88)
|
||||
|
||||
[node name="Border" type="ColorRect" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
offset_left = -3.0
|
||||
offset_top = -3.0
|
||||
offset_right = 3.0
|
||||
offset_bottom = 3.0
|
||||
mouse_filter = 2
|
||||
color = Color(0.3, 0.5, 0.9, 0.9)
|
||||
|
||||
[node name="Cursor" type="Label" parent="."]
|
||||
text = "►"
|
||||
mouse_filter = 2
|
||||
visible = false
|
||||
theme_override_font_sizes/font_size = 18
|
||||
theme_override_colors/font_color = Color(1, 0.95, 0.7, 1)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,83 @@
|
||||
using Cthangover.Core.Battle.Actions;
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.FFBattle.UI;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.FFBattle.Actions
|
||||
{
|
||||
/// <summary>
|
||||
/// Abstract base for all FF battle animations. Implements a three-phase lifecycle
|
||||
/// (<see cref="DoStart"/>, <see cref="DoAction"/>, <see cref="DoEnd"/>) driven
|
||||
/// by the battle core's per-frame loop. Subclasses implement their animation logic
|
||||
/// in <c>DoInternalStart/DoInternalAction/DoInternalEnd</c> and are instantiated
|
||||
/// by <see cref="FFBattleCore"/> when a player or enemy action is executed.
|
||||
/// The <see cref="Speed"/> multiplier scales all timing, used e.g. to speed up
|
||||
/// enemy turn animations (<c>0.8f</c>).
|
||||
/// </summary>
|
||||
public abstract class FFAbstractAnimation : IBattleAction
|
||||
{
|
||||
/// <summary>Start time of the current animation phase, in seconds (microsecond precision).</summary>
|
||||
protected double Timestamp { get; set; } = -1;
|
||||
/// <summary>Animation speed multiplier; lower values produce faster animations.</summary>
|
||||
protected float Speed { get; set; } = 1f;
|
||||
/// <summary>The action descriptor being executed (determines which executor runs).</summary>
|
||||
protected ActionCharacter Action { get; set; }
|
||||
/// <summary>The character widget performing the action.</summary>
|
||||
protected FFCharacterWidget Source { get; set; }
|
||||
/// <summary>The character widget receiving the action.</summary>
|
||||
protected FFCharacterWidget Target { get; set; }
|
||||
/// <summary>World position of <see cref="Source"/> captured at <see cref="DoStart"/>.</summary>
|
||||
protected Vector2 SourcePos { get; set; }
|
||||
/// <summary>World position of <see cref="Target"/> captured at <see cref="DoStart"/>.</summary>
|
||||
protected Vector2 TargetPos { get; set; }
|
||||
|
||||
/// <summary>Creates an animation binding a source, target, and action with an optional speed override.</summary>
|
||||
protected FFAbstractAnimation(FFCharacterWidget source, FFCharacterWidget target, ActionCharacter action, float speed = 1f)
|
||||
{
|
||||
Source = source;
|
||||
Target = target;
|
||||
Action = action;
|
||||
Speed = speed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances the animation by one frame. Returns <c>true</c> when the animation
|
||||
/// sequence has completed. Called in a tight <c>while</c> loop by
|
||||
/// <see cref="FFBattleCore.RunAnimation"/> each process frame.
|
||||
/// </summary>
|
||||
public bool DoAction()
|
||||
{
|
||||
if (Source == null || Target == null || Action == null)
|
||||
return true;
|
||||
return DoInternalAction();
|
||||
}
|
||||
|
||||
/// <summary>Captures the initial world positions of source and target, then delegates to subclass setup.</summary>
|
||||
public void DoStart()
|
||||
{
|
||||
if (Source == null || Target == null || Action == null)
|
||||
return;
|
||||
|
||||
SourcePos = Source.GlobalPosition;
|
||||
TargetPos = Target.GlobalPosition;
|
||||
DoInternalStart();
|
||||
}
|
||||
|
||||
/// <summary>Restores source to original position and delegates cleanup to subclass.</summary>
|
||||
public void DoEnd()
|
||||
{
|
||||
if (Source == null || Target == null || Action == null)
|
||||
return;
|
||||
DoInternalEnd();
|
||||
}
|
||||
|
||||
protected abstract bool DoInternalAction();
|
||||
protected abstract void DoInternalStart();
|
||||
protected abstract void DoInternalEnd();
|
||||
|
||||
/// <summary>Quadratic ease-out: fast start, decelerating to the target. Used for return-to-start motion.</summary>
|
||||
protected float EaseOutQuad(float t) => 1f - (1f - t) * (1f - t);
|
||||
/// <summary>Quadratic ease-in-out: slow start and end, fast in the middle. Used for approach motion.</summary>
|
||||
protected float EaseInOutQuad(float t) => t < 0.5f ? 2f * t * t : 1f - Mathf.Pow(-2f * t + 2f, 2f) / 2f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Battle.Actions;
|
||||
using Cthangover.Core.Utils;
|
||||
|
||||
namespace Cthangover.FFBattle.Actions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registry mapping action ID strings to their executor implementations.
|
||||
/// Created by <see cref="FFBattleCore"/> and registered with
|
||||
/// <see cref="ActionExecutorHub"/>. Maps <c>"physics/attack"</c> →
|
||||
/// <see cref="FFDamageExecutor"/>, <c>"physics/defence"</c> →
|
||||
/// <see cref="FFDefenceExecutor"/>, <c>"physics/stun"</c> →
|
||||
/// <see cref="FFStunExecutor"/>, and <c>"ff/item"</c> →
|
||||
/// <see cref="FFItemExecutor"/>. The lookup key comes from
|
||||
/// <see cref="ActionCharacter.ID"/>.
|
||||
/// </summary>
|
||||
public class FFActionProvider : IActionExecutorProvider
|
||||
{
|
||||
private readonly Dictionary<string, IActionExecutor> _executors;
|
||||
|
||||
/// <summary>Constructs the provider and registers the four built-in FF battle executors.</summary>
|
||||
public FFActionProvider()
|
||||
{
|
||||
_executors = new Dictionary<string, IActionExecutor>
|
||||
{
|
||||
["physics/attack"] = new FFDamageExecutor(),
|
||||
["physics/defence"] = new FFDefenceExecutor(),
|
||||
["physics/stun"] = new FFStunExecutor(),
|
||||
["ff/item"] = new FFItemExecutor(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the executor registered for <paramref name="actionId"/>.
|
||||
/// Logs a warning if no executor is found and returns <c>null</c>,
|
||||
/// which the animation system silently ignores.
|
||||
/// </summary>
|
||||
/// <param name="actionId">The <see cref="ActionCharacter.ID"/> string to look up.</param>
|
||||
public IActionExecutor GetExecutor(string actionId)
|
||||
{
|
||||
if (!_executors.TryGetValue(actionId, out var executor))
|
||||
GameLogger.Log("FF_BATTLE", $"No executor registered for action '{actionId}'", LogLevel.Warning);
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using Cthangover.Core.Battle;
|
||||
using Cthangover.Core.Battle.Actions;
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.FFBattle.UI;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.FFBattle.Actions
|
||||
{
|
||||
/// <summary>
|
||||
/// Animation for offensive actions against enemies. Implements a three-phase
|
||||
/// sequence: (1) MoveForward — source character dashes toward the target with
|
||||
/// ease-in-out interpolation, stopping 30px short, (2) Impact — applies damage
|
||||
/// via <see cref="ActionExecutorHub"/> when 30% through the phase, triggering
|
||||
/// flash, shake, and floating damage text, (3) MoveBack — returns the source
|
||||
/// to its original position with ease-out.
|
||||
/// Used for player attacks and enemy attacks alike.
|
||||
/// </summary>
|
||||
public class FFAttackAnimation : FFAbstractAnimation
|
||||
{
|
||||
private enum Phase { MoveForward, Impact, MoveBack, Done }
|
||||
|
||||
private Phase _phase;
|
||||
private bool _damageApplied;
|
||||
private Vector2 _midPoint;
|
||||
|
||||
/// <summary>Creates an attack animation for source→target with the given action descriptor and speed.</summary>
|
||||
public FFAttackAnimation(FFCharacterWidget source, FFCharacterWidget target, ActionCharacter action, float speed = 1f)
|
||||
: base(source, target, action, speed) { }
|
||||
|
||||
protected override void DoInternalStart()
|
||||
{
|
||||
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
|
||||
_damageApplied = false;
|
||||
_phase = Phase.MoveForward;
|
||||
|
||||
var dir = (TargetPos - SourcePos).Normalized();
|
||||
_midPoint = TargetPos - dir * 30f;
|
||||
}
|
||||
|
||||
protected override bool DoInternalAction()
|
||||
{
|
||||
var elapsed = (float)(Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Engine.TimeScale;
|
||||
|
||||
switch (_phase)
|
||||
{
|
||||
case Phase.MoveForward:
|
||||
{
|
||||
var progress = Mathf.Clamp(elapsed / 0.35f, 0f, 1f);
|
||||
Source.GlobalPosition = SourcePos.Lerp(_midPoint, EaseInOutQuad(progress));
|
||||
|
||||
if (progress >= 1f)
|
||||
{
|
||||
_phase = Phase.Impact;
|
||||
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case Phase.Impact:
|
||||
{
|
||||
var progress = Mathf.Clamp(elapsed / 0.2f, 0f, 1f);
|
||||
|
||||
if (!_damageApplied && progress >= 0.3f)
|
||||
{
|
||||
_damageApplied = true;
|
||||
var result = ActionExecutorHub.Instance.Execute(Action, Source.Card, Target.Card);
|
||||
Source.UpdateInfo();
|
||||
Target.UpdateInfo();
|
||||
|
||||
if (result.Result)
|
||||
{
|
||||
Target.Flash(new Color(1f, 0.3f, 0.3f, 1f), 0.2f);
|
||||
Target.Shake(4f, 0.25f);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (progress >= 1f)
|
||||
{
|
||||
_phase = Phase.MoveBack;
|
||||
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case Phase.MoveBack:
|
||||
{
|
||||
var progress = Mathf.Clamp(elapsed / 0.4f, 0f, 1f);
|
||||
Source.GlobalPosition = _midPoint.Lerp(SourcePos, EaseOutQuad(progress));
|
||||
|
||||
if (progress >= 1f)
|
||||
{
|
||||
_phase = Phase.Done;
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected override void DoInternalEnd()
|
||||
{
|
||||
Source.GlobalPosition = SourcePos;
|
||||
Source.UpdateInfo();
|
||||
Target.UpdateInfo();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.FFBattle.Actions
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes a physical attack action. Calculates damage as
|
||||
/// <c>action.Attack × user.Attack</c>, then routes through
|
||||
/// <see cref="StatusEffectQueue.OnDealDamage"/> and
|
||||
/// <see cref="StatusEffectQueue.OnTakeDamage"/> for status-effect
|
||||
/// hooks. Subtraction is applied defence-first: <see cref="CharacterAttributes.Defence"/>
|
||||
/// absorbs damage point-for-point before health is reduced.
|
||||
/// Registered under ID <c>"physics/attack"</c>.
|
||||
/// </summary>
|
||||
public class FFDamageExecutor : ActionBase
|
||||
{
|
||||
public override string ID => "FFDamageExecutor";
|
||||
|
||||
public override ChangedAttributes Execute(ActionCharacter action, Character user, Character target)
|
||||
{
|
||||
if (target == null || user == null || !CheckRequiredAndUsePoint(action, user))
|
||||
return new ChangedAttributes { Result = false };
|
||||
|
||||
var damageDelta = Mathf.RoundToInt(action.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;
|
||||
|
||||
GameLogger.Log("FF_BATTLE",
|
||||
$"{user.Name} attacks {target.Name}: damage={damageDelta} defence={defenceDelta}",
|
||||
LogLevel.Debug);
|
||||
|
||||
return new ChangedAttributes
|
||||
{
|
||||
Result = true,
|
||||
Target = { Damage = damageDelta, Defence = -defenceDelta }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.Core.Utils;
|
||||
|
||||
namespace Cthangover.FFBattle.Actions
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes a defensive action (buff/barrier). Increases the target's
|
||||
/// <see cref="CharacterAttributes.Defence"/> by the value stored in
|
||||
/// the <c>"Defence"</c> property on the action (default 4).
|
||||
/// Defence acts as a shield against incoming physical damage in the
|
||||
/// <see cref="FFDamageExecutor"/>. Registered under ID <c>"physics/defence"</c>.
|
||||
/// </summary>
|
||||
public class FFDefenceExecutor : ActionBase
|
||||
{
|
||||
public override string ID => "FFDefenceExecutor";
|
||||
|
||||
public override ChangedAttributes Execute(ActionCharacter action, Character user, Character target)
|
||||
{
|
||||
if (target == null || user == null || !CheckRequiredAndUsePoint(action, user))
|
||||
return new ChangedAttributes { Result = false };
|
||||
|
||||
var defenceDelta = action.GetInt("Defence", 4);
|
||||
target.Attributes.Defence.Value += defenceDelta;
|
||||
|
||||
GameLogger.Log("FF_BATTLE",
|
||||
$"{user.Name} defends → +{defenceDelta} defence for {target?.Name}",
|
||||
LogLevel.Debug);
|
||||
|
||||
return new ChangedAttributes { Result = true, Target = { Defence = defenceDelta } };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using Cthangover.Core.Battle;
|
||||
using Cthangover.Core.Battle.Actions;
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.FFBattle.UI;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.FFBattle.Actions
|
||||
{
|
||||
/// <summary>
|
||||
/// Animation for defensive/support actions (buff, self-target, ally heal).
|
||||
/// Phases: (1) Bounce — source character bobs in place with a sine-based
|
||||
/// vertical oscillation, (2) Glow — applies the effect via
|
||||
/// <see cref="ActionExecutorHub"/> at 30% phase progress, triggering a green
|
||||
/// flash on the target and floating defence/damage indicators, (3) Recover —
|
||||
/// smoothly returns the source to its original position with ease-out
|
||||
/// interpolation. Unlike <see cref="FFAttackAnimation"/>, the source does not
|
||||
/// move toward the target.
|
||||
/// </summary>
|
||||
public class FFDefendAnimation : FFAbstractAnimation
|
||||
{
|
||||
private enum Phase { Bounce, Glow, Recover, Done }
|
||||
|
||||
private Phase _phase;
|
||||
private bool _effectApplied;
|
||||
private Vector2 _bounceOffset;
|
||||
|
||||
/// <summary>Creates a defend/buff animation for source→target with the given action descriptor and speed.</summary>
|
||||
public FFDefendAnimation(FFCharacterWidget source, FFCharacterWidget target, ActionCharacter action, float speed = 1f)
|
||||
: base(source, target, action, speed) { }
|
||||
|
||||
protected override void DoInternalStart()
|
||||
{
|
||||
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
|
||||
_effectApplied = false;
|
||||
_phase = Phase.Bounce;
|
||||
_bounceOffset = new Vector2(0, -12f);
|
||||
}
|
||||
|
||||
protected override bool DoInternalAction()
|
||||
{
|
||||
var elapsed = (float)(Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Engine.TimeScale;
|
||||
|
||||
switch (_phase)
|
||||
{
|
||||
case Phase.Bounce:
|
||||
{
|
||||
var progress = Mathf.Clamp(elapsed / 0.25f, 0f, 1f);
|
||||
var bounce = Mathf.Sin(progress * Mathf.Pi * 2f) * 8f;
|
||||
Source.GlobalPosition = SourcePos + new Vector2(0, -Mathf.Abs(bounce));
|
||||
|
||||
if (progress >= 1f)
|
||||
{
|
||||
_phase = Phase.Glow;
|
||||
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case Phase.Glow:
|
||||
{
|
||||
var progress = Mathf.Clamp(elapsed / 0.4f, 0f, 1f);
|
||||
|
||||
if (!_effectApplied && progress >= 0.3f)
|
||||
{
|
||||
_effectApplied = true;
|
||||
var result = ActionExecutorHub.Instance.Execute(Action, Source.Card, Target.Card);
|
||||
Source.UpdateInfo();
|
||||
Target.UpdateInfo();
|
||||
|
||||
if (result.Result)
|
||||
{
|
||||
Target.Flash(new Color(0.3f, 1f, 0.3f, 1f), 0.4f);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (progress >= 1f)
|
||||
{
|
||||
_phase = Phase.Recover;
|
||||
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case Phase.Recover:
|
||||
{
|
||||
var progress = Mathf.Clamp(elapsed / 0.3f, 0f, 1f);
|
||||
Source.GlobalPosition = (SourcePos + _bounceOffset).Lerp(SourcePos, EaseOutQuad(progress));
|
||||
|
||||
if (progress >= 1f)
|
||||
{
|
||||
_phase = Phase.Done;
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected override void DoInternalEnd()
|
||||
{
|
||||
Source.GlobalPosition = SourcePos;
|
||||
Source.UpdateInfo();
|
||||
Target.UpdateInfo();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.Core.Items;
|
||||
using Cthangover.FFBattle.UI;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.FFBattle.Actions
|
||||
{
|
||||
/// <summary>
|
||||
/// Animation for item usage in battle. Creates a floating item icon
|
||||
/// (<see cref="TextureRect"/>) that rises above the source character and then
|
||||
/// flies toward the target with fading opacity. Phases: (1) RaiseItem — the
|
||||
/// item icon floats upward and the source bobs, (2) ApplyEffect — the icon
|
||||
/// lerps to the target's position and <see cref="FFItemExecutor.TryUseItem"/>
|
||||
/// is called at 30% phase, (3) Recover — source returns to position with
|
||||
/// ease-out. The icon is freed in <see cref="DoInternalEnd"/>.
|
||||
/// </summary>
|
||||
public class FFItemAnimation : FFAbstractAnimation
|
||||
{
|
||||
private enum Phase { RaiseItem, ApplyEffect, Recover, Done }
|
||||
|
||||
private Phase _phase;
|
||||
private IItem _item;
|
||||
private bool _effectApplied;
|
||||
private TextureRect _itemIcon;
|
||||
|
||||
/// <summary>Creates an item-use animation. <paramref name="action"/> is a synthetic <c>"ff/item"</c> descriptor; actual logic runs via <paramref name="item"/>.</summary>
|
||||
public FFItemAnimation(FFCharacterWidget source, FFCharacterWidget target, ActionCharacter action, IItem item, float speed = 1f)
|
||||
: base(source, target, action, speed)
|
||||
{
|
||||
_item = item;
|
||||
}
|
||||
|
||||
protected override void DoInternalStart()
|
||||
{
|
||||
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
|
||||
_effectApplied = false;
|
||||
_phase = Phase.RaiseItem;
|
||||
|
||||
if (_item?.Sprite != null)
|
||||
{
|
||||
_itemIcon = new TextureRect();
|
||||
_itemIcon.Texture = _item.Sprite;
|
||||
_itemIcon.ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize;
|
||||
_itemIcon.StretchMode = TextureRect.StretchModeEnum.KeepAspect;
|
||||
_itemIcon.Size = new Vector2(32, 32);
|
||||
_itemIcon.Position = Source.GlobalPosition - Source.Position + new Vector2(20, -40);
|
||||
Source.AddChild(_itemIcon);
|
||||
}
|
||||
}
|
||||
|
||||
protected override bool DoInternalAction()
|
||||
{
|
||||
var elapsed = (float)(Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Engine.TimeScale;
|
||||
|
||||
switch (_phase)
|
||||
{
|
||||
case Phase.RaiseItem:
|
||||
{
|
||||
var progress = Mathf.Clamp(elapsed / 0.3f, 0f, 1f);
|
||||
|
||||
if (_itemIcon != null)
|
||||
{
|
||||
var iconStartPos = Source.GlobalPosition - Source.Position + new Vector2(20, -40);
|
||||
_itemIcon.Position = iconStartPos + new Vector2(0, -30f * EaseInOutQuad(progress));
|
||||
}
|
||||
|
||||
var bounce = Mathf.Sin(progress * Mathf.Pi) * 8f;
|
||||
Source.GlobalPosition = SourcePos + new Vector2(0, -Mathf.Abs(bounce));
|
||||
|
||||
if (progress >= 1f)
|
||||
{
|
||||
_phase = Phase.ApplyEffect;
|
||||
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case Phase.ApplyEffect:
|
||||
{
|
||||
var progress = Mathf.Clamp(elapsed / 0.35f, 0f, 1f);
|
||||
|
||||
if (_itemIcon != null && Target != null)
|
||||
{
|
||||
var localTarget = Target.GlobalPosition - Source.Position;
|
||||
var iconStart = Source.GlobalPosition - Source.Position + new Vector2(20, -70);
|
||||
_itemIcon.Position = iconStart.Lerp(localTarget + new Vector2(20, -20), EaseInOutQuad(progress));
|
||||
_itemIcon.Modulate = new Color(1, 1, 1, 1f - progress * 0.5f);
|
||||
}
|
||||
|
||||
if (!_effectApplied && progress >= 0.3f)
|
||||
{
|
||||
_effectApplied = true;
|
||||
|
||||
if (_item != null)
|
||||
FFItemExecutor.TryUseItem(_item, Source.Card, Target.Card);
|
||||
|
||||
Source.UpdateInfo();
|
||||
Target.UpdateInfo();
|
||||
Target.Flash(new Color(0.5f, 1f, 0.5f, 1f), 0.3f);
|
||||
}
|
||||
|
||||
if (progress >= 1f)
|
||||
{
|
||||
_phase = Phase.Recover;
|
||||
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case Phase.Recover:
|
||||
{
|
||||
var progress = Mathf.Clamp(elapsed / 0.25f, 0f, 1f);
|
||||
Source.GlobalPosition = Source.GlobalPosition.Lerp(SourcePos, EaseOutQuad(progress));
|
||||
|
||||
if (progress >= 1f)
|
||||
{
|
||||
_phase = Phase.Done;
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected override void DoInternalEnd()
|
||||
{
|
||||
Source.GlobalPosition = SourcePos;
|
||||
|
||||
if (_itemIcon != null)
|
||||
{
|
||||
_itemIcon.QueueFree();
|
||||
_itemIcon = null;
|
||||
}
|
||||
|
||||
Source.UpdateInfo();
|
||||
Target.UpdateInfo();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Cthangover.Core.Battle;
|
||||
using Cthangover.Core.Battle.Actions;
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.Core.Items;
|
||||
using Cthangover.Core.Settings;
|
||||
using Cthangover.Core.Utils;
|
||||
|
||||
namespace Cthangover.FFBattle.Actions
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles item usage during battle. Implements <see cref="IActionExecutor"/>
|
||||
/// but its <see cref="Execute"/> method is a no-op — actual item logic runs
|
||||
/// via <see cref="TryUseItem"/> which is called directly from
|
||||
/// <see cref="FFItemAnimation"/>. This separation exists because items carry
|
||||
/// their own <see cref="IItem.ItemAction"/> delegate for effects, unlike
|
||||
/// standard actions which use the executor pattern.
|
||||
/// Registered under ID <c>"ff/item"</c>.
|
||||
/// </summary>
|
||||
public class FFItemExecutor : IActionExecutor
|
||||
{
|
||||
/// <summary>The action ID string that maps to this executor in <see cref="FFActionProvider"/>.</summary>
|
||||
public string ActionId => "ff/item";
|
||||
|
||||
/// <summary>No-op — item effects are applied via <see cref="TryUseItem"/>.</summary>
|
||||
public ChangedAttributes Execute(ActionCharacter action, Character user, Character target)
|
||||
{
|
||||
return new ChangedAttributes { Result = false };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Consumes one action point from <paramref name="user"/>, removes the item
|
||||
/// from the global inventory, and invokes the item's
|
||||
/// <see cref="IItem.ItemAction"/>. Returns <c>false</c> if the user lacks
|
||||
/// points, the item is not in inventory, or preconditions fail.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to use.</param>
|
||||
/// <param name="user">The character consuming the item (loses 1 point).</param>
|
||||
/// <param name="target">The character targeted by the item effect.</param>
|
||||
public static bool TryUseItem(IItem item, Character user, Character target)
|
||||
{
|
||||
if (item == null || user == null)
|
||||
return false;
|
||||
|
||||
if (user.Attributes.Point.Value < 1)
|
||||
{
|
||||
GameLogger.Log("FF_BATTLE", $"Not enough points to use item {item.Name}", LogLevel.Warning);
|
||||
return false;
|
||||
}
|
||||
|
||||
var inventory = GameData.Instance.Runtime.Inventory;
|
||||
if (!inventory.HasItem(item.ID))
|
||||
{
|
||||
GameLogger.Log("FF_BATTLE", $"No {item.ID} in inventory", LogLevel.Warning);
|
||||
return false;
|
||||
}
|
||||
|
||||
user.Attributes.Point.Value -= 1;
|
||||
inventory.Remove(item.ID, 1);
|
||||
|
||||
if (item.ItemAction != null)
|
||||
item.ItemAction.UseAction(item);
|
||||
|
||||
GameLogger.Log("FF_BATTLE", $"{user.Name} uses item {item.Name}", LogLevel.Debug);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.Core.Utils;
|
||||
|
||||
namespace Cthangover.FFBattle.Actions
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies a stun status effect to the target for a configurable number of turns.
|
||||
/// Reads the turn count from the <c>"Turn"</c> property on the action (default 3).
|
||||
/// Stunned characters are skipped during turn iteration in
|
||||
/// <see cref="FFBattleCore.TryNextCharacterOrEndTurn"/> and
|
||||
/// <see cref="FFBattleCore.RunEnemyTurn"/>. Registered under ID <c>"physics/stun"</c>.
|
||||
/// </summary>
|
||||
public class FFStunExecutor : ActionBase
|
||||
{
|
||||
public override string ID => "FFStunExecutor";
|
||||
|
||||
public override ChangedAttributes Execute(ActionCharacter action, Character user, Character target)
|
||||
{
|
||||
if (target == null || user == null || !CheckRequiredAndUsePoint(action, user))
|
||||
return new ChangedAttributes { Result = false };
|
||||
|
||||
if (target.HasPassiveEffect("StunImmune"))
|
||||
return new ChangedAttributes { Result = true };
|
||||
|
||||
var turns = action.GetInt("Turn", 3);
|
||||
target.StatusEffectQueue.Add("effect/physics/stun", turns);
|
||||
|
||||
GameLogger.Log("FF_BATTLE",
|
||||
$"{user.Name} stuns {target.Name} for {turns} turns",
|
||||
LogLevel.Debug);
|
||||
|
||||
return new ChangedAttributes { Result = true };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,858 @@
|
||||
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.Characters;
|
||||
using Cthangover.Core.Items;
|
||||
using Cthangover.Core.Settings;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Core.Utils;
|
||||
using Cthangover.FFBattle.UI;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.FFBattle
|
||||
{
|
||||
/// <summary>
|
||||
/// Core orchestration class for the Final Fantasy-style turn-based battle system.
|
||||
/// Manages the full battle lifecycle: player turn with menu-driven action selection,
|
||||
/// enemy AI turn execution, target selection mode, escape mechanics, and win/loss
|
||||
/// condition checks. Coordinates between <see cref="FFPlayerPanel"/>,
|
||||
/// <see cref="FFEnemyPanel"/>, <see cref="FFMenuPanel"/>, and
|
||||
/// <see cref="FFBattleController"/> for UI interaction. Registered via
|
||||
/// <see cref="IBattleCore.Id"/> as <c>"ff_battle"</c>.
|
||||
/// </summary>
|
||||
public class FFBattleCore : IBattleCore
|
||||
{
|
||||
/// <summary>Unique identifier for this battle system implementation, used by the core engine to locate it.</summary>
|
||||
public string Id => "ff_battle";
|
||||
/// <summary>Provides the set of action executors (damage, defence, stun, item) registered for this battle system.</summary>
|
||||
public IActionExecutorProvider ActionProvider { get; } = new Actions.FFActionProvider();
|
||||
|
||||
private Character[] _playerChars;
|
||||
private Character[] _enemyChars;
|
||||
private IBattleContext _ctx;
|
||||
|
||||
private FFPlayerPanel _playerPanel;
|
||||
private FFEnemyPanel _enemyPanel;
|
||||
private FFMenuPanel _menuPanel;
|
||||
private FFBattleController _controller;
|
||||
private Control _toolPanel;
|
||||
|
||||
private Button _endTurnButton;
|
||||
|
||||
private FFCharacterWidget _currentCharacter;
|
||||
private FFMenuEntry _currentMenuAction;
|
||||
private bool _isPlayerTurn;
|
||||
private bool _targetSelectMode;
|
||||
private bool _targetAlly;
|
||||
private System.Action<FFCharacterWidget> _onTargetSelected;
|
||||
|
||||
private const float PLAYER_SCALE = 0.85f;
|
||||
|
||||
/// <summary>
|
||||
/// Initialises the battle UI panels, controller, and event wiring. Copies enemy
|
||||
/// <see cref="Character"/> instances via <c>Copy()</c> so mutations during battle
|
||||
/// do not affect the original data. Registers the <see cref="ActionProvider"/>
|
||||
/// with <see cref="ActionExecutorHub"/> and constructs all UI widgets inside
|
||||
/// <paramref name="ctx"/>'s root node.
|
||||
/// </summary>
|
||||
/// <param name="playerChars">The player party characters, consumed directly (not copied).</param>
|
||||
/// <param name="enemyChars">Enemy characters — each is shallow-copied for battle isolation.</param>
|
||||
/// <param name="ctx">Battle context providing root node and end-battle callback.</param>
|
||||
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;
|
||||
_toolPanel = root.GetNodeOrNull<Control>("ToolPanel");
|
||||
|
||||
_playerPanel = new FFPlayerPanel { Name = "FFPlayerPanel" };
|
||||
_playerPanel.EnsureConstructed();
|
||||
panel.AddChild(_playerPanel);
|
||||
|
||||
_enemyPanel = new FFEnemyPanel { Name = "FFEnemyPanel" };
|
||||
_enemyPanel.EnsureConstructed();
|
||||
panel.AddChild(_enemyPanel);
|
||||
|
||||
_menuPanel = (FFMenuPanel)TscnScenes.LoadAndBuild("scenes/ff_menu_panel.tscn");
|
||||
_menuPanel.Name = "FFMenuPanel";
|
||||
_menuPanel.EnsureConstructed();
|
||||
_menuPanel.HideMenu();
|
||||
panel.AddChild(_menuPanel);
|
||||
|
||||
_endTurnButton = new Button();
|
||||
_endTurnButton.Text = TranslationServer.Translate("ff_battle/end_turn");
|
||||
_endTurnButton.Pressed += OnEndTurnPressed;
|
||||
_endTurnButton.Visible = false;
|
||||
_endTurnButton.FocusMode = Control.FocusModeEnum.None;
|
||||
_toolPanel?.AddChild(_endTurnButton);
|
||||
|
||||
_controller = new FFBattleController { Name = "FFBattleController" };
|
||||
panel.AddChild(_controller);
|
||||
_controller.PlayerPanel = _playerPanel;
|
||||
_controller.EnemyPanel = _enemyPanel;
|
||||
_controller.MenuPanel = _menuPanel;
|
||||
_controller.SubMenuPanel = _menuPanel;
|
||||
_controller.OnCharacterSelected += OnCharacterSelected;
|
||||
_controller.OnMenuCancel += OnMenuCancel;
|
||||
_controller.OnTargetSelected += OnTargetSelected;
|
||||
|
||||
_playerPanel.OnWidgetClicked += OnWidgetClicked;
|
||||
_enemyPanel.OnEnemyClicked += OnEnemyClicked;
|
||||
_enemyPanel.OnEnemyDead += OnEnemyDied;
|
||||
_menuPanel.OnItemSelected += OnMenuItemSelected;
|
||||
_menuPanel.OnCancelled += OnMenuCancel;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Begins the battle sequence. Calculates layout positions and scales for
|
||||
/// player/enemy panels based on viewport size. Subscribes to
|
||||
/// <see cref="BattleSceneContext.OnBattleCleared"/> for cleanup.
|
||||
/// Immediately transitions into the first player turn.
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
var root = _ctx.RootNode as Node;
|
||||
var viewportSize = root.GetViewport().GetVisibleRect().Size;
|
||||
|
||||
var enemyScale = FFEnemyPanel.CalculateScale(_enemyChars.Length,
|
||||
new Vector2(viewportSize.X * 0.9f, viewportSize.Y * 0.5f));
|
||||
|
||||
_enemyPanel.Position = new Vector2(viewportSize.X * 0.05f, 0);
|
||||
_enemyPanel.Size = new Vector2(viewportSize.X * 0.9f, viewportSize.Y * 0.5f);
|
||||
|
||||
_playerPanel.Position = new Vector2(0, viewportSize.Y * 0.55f);
|
||||
_playerPanel.Size = new Vector2(viewportSize.X * 0.65f, viewportSize.Y * 0.45f);
|
||||
|
||||
_menuPanel.Position = new Vector2(viewportSize.X * 0.66f, viewportSize.Y * 0.58f);
|
||||
_menuPanel.Size = new Vector2(viewportSize.X * 0.3f, 160f);
|
||||
|
||||
if (_endTurnButton != null)
|
||||
_endTurnButton.Position = new Vector2(viewportSize.X - 180f, viewportSize.Y * 0.92f);
|
||||
|
||||
_playerPanel.Init(_playerChars, PLAYER_SCALE);
|
||||
_enemyPanel.Init(_enemyChars, enemyScale);
|
||||
|
||||
BattleSceneContext.Instance.OnBattleCleared += OnBattleCleared;
|
||||
|
||||
_isPlayerTurn = true;
|
||||
StartPlayerTurn();
|
||||
}
|
||||
|
||||
private void StartPlayerTurn()
|
||||
{
|
||||
GameLogger.Log("FF_BATTLE", "Player turn started", LogLevel.Debug);
|
||||
|
||||
_isPlayerTurn = true;
|
||||
BattleSceneContext.Instance.IsWait = false;
|
||||
|
||||
_currentCharacter = null;
|
||||
_targetSelectMode = false;
|
||||
_menuPanel.HideMenu();
|
||||
BattleSceneContext.Instance.ActiveCharacter = null;
|
||||
|
||||
foreach (var widget in _playerPanel.Widgets)
|
||||
{
|
||||
if (!widget.IsDead)
|
||||
{
|
||||
widget.Card.StatusEffectQueue.OnTurnStart();
|
||||
widget.Card.Attributes.Point.Value = widget.Card.Attributes.Point.BaseValue;
|
||||
}
|
||||
widget.UpdateInfo();
|
||||
}
|
||||
|
||||
_endTurnButton.Visible = true;
|
||||
_controller.State = FFBattleState.Idle;
|
||||
|
||||
foreach (var widget in _enemyPanel.Widgets)
|
||||
widget.ClearHighlight();
|
||||
|
||||
foreach (var widget in _playerPanel.Widgets)
|
||||
widget.ClearHighlight();
|
||||
}
|
||||
|
||||
private void OnWidgetClicked(FFCharacterWidget widget)
|
||||
{
|
||||
if (!_isPlayerTurn)
|
||||
return;
|
||||
|
||||
if (_targetSelectMode)
|
||||
{
|
||||
if (widget.IsDead)
|
||||
return;
|
||||
|
||||
if (_targetAlly && !_playerPanel.Widgets.Contains(widget))
|
||||
return;
|
||||
|
||||
if (!_targetAlly && _playerPanel.Widgets.Contains(widget))
|
||||
return;
|
||||
|
||||
_targetSelectMode = false;
|
||||
foreach (var w in _enemyPanel.Widgets) w.ClearHighlight();
|
||||
foreach (var w in _playerPanel.Widgets) w.ClearHighlight();
|
||||
|
||||
ExecuteMenuAction(widget);
|
||||
return;
|
||||
}
|
||||
|
||||
_controller.SelectCharacter(widget);
|
||||
}
|
||||
|
||||
private void OnCharacterSelected(FFCharacterWidget widget)
|
||||
{
|
||||
if (widget.IsDead || widget.Card?.Attributes?.Point?.Value <= 0)
|
||||
return;
|
||||
|
||||
_currentCharacter = widget;
|
||||
BattleSceneContext.Instance.ActiveCharacter = widget.Card;
|
||||
ShowMainMenu();
|
||||
}
|
||||
|
||||
private void ShowMainMenu()
|
||||
{
|
||||
var entries = new List<FFMenuEntry>
|
||||
{
|
||||
new FFMenuEntry
|
||||
{
|
||||
Key = "attack",
|
||||
Label = TranslationServer.Translate("ff_battle/menu_attack"),
|
||||
Enabled = HasAvailableActions()
|
||||
},
|
||||
new FFMenuEntry
|
||||
{
|
||||
Key = "items",
|
||||
Label = TranslationServer.Translate("ff_battle/menu_items"),
|
||||
Enabled = HasUsableItems()
|
||||
},
|
||||
new FFMenuEntry
|
||||
{
|
||||
Key = "defend",
|
||||
Label = TranslationServer.Translate("ff_battle/menu_defend"),
|
||||
Enabled = HasDefendAction()
|
||||
},
|
||||
new FFMenuEntry
|
||||
{
|
||||
Key = "escape",
|
||||
Label = TranslationServer.Translate("ff_battle/menu_escape"),
|
||||
Enabled = true
|
||||
}
|
||||
};
|
||||
|
||||
_currentMenuAction = null;
|
||||
_menuPanel.ShowMenu(entries);
|
||||
_controller.State = FFBattleState.MenuOpen;
|
||||
|
||||
foreach (var widget in _playerPanel.Widgets)
|
||||
widget.ClearHighlight();
|
||||
_currentCharacter?.Highlight(new Color(0, 0.6f, 1f, 0.6f));
|
||||
}
|
||||
|
||||
private void OnMenuItemSelected(int index)
|
||||
{
|
||||
if (_menuPanel.Entries == null || index >= _menuPanel.Entries.Count)
|
||||
return;
|
||||
|
||||
var entry = _menuPanel.Entries[index];
|
||||
if (!entry.Enabled)
|
||||
return;
|
||||
|
||||
MatchMenuAction(entry);
|
||||
}
|
||||
|
||||
private void MatchMenuAction(FFMenuEntry entry)
|
||||
{
|
||||
switch (entry.Key)
|
||||
{
|
||||
case "attack":
|
||||
ShowActionSubMenu();
|
||||
break;
|
||||
|
||||
case "items":
|
||||
ShowItemSubMenu();
|
||||
break;
|
||||
|
||||
case "defend":
|
||||
DoDefendAction();
|
||||
break;
|
||||
|
||||
case "escape":
|
||||
DoEscapeAction();
|
||||
break;
|
||||
|
||||
case "action":
|
||||
_currentMenuAction = entry;
|
||||
var action = entry.Data as ActionCharacter;
|
||||
if (action != null)
|
||||
{
|
||||
if (action.Type == ActionCharacterType.ToEnemy)
|
||||
StartTargetSelect(false);
|
||||
else if (action.Type == ActionCharacterType.ToAlias)
|
||||
StartTargetSelect(true);
|
||||
else if (action.Type == ActionCharacterType.ToSelf)
|
||||
{
|
||||
_targetSelectMode = false;
|
||||
ExecuteMenuAction(_currentCharacter);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "item":
|
||||
_currentMenuAction = entry;
|
||||
var item = entry.Data as IItem;
|
||||
if (item != null)
|
||||
{
|
||||
if (item.ItemType.HasFlag(ItemType.TargetUsed))
|
||||
StartTargetSelect(true);
|
||||
else
|
||||
DoItemAction(item);
|
||||
}
|
||||
break;
|
||||
|
||||
case "back":
|
||||
ShowMainMenu();
|
||||
break;
|
||||
|
||||
case "_empty":
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowActionSubMenu()
|
||||
{
|
||||
if (_currentCharacter?.Card?.Actions == null)
|
||||
return;
|
||||
|
||||
var entries = new List<FFMenuEntry>();
|
||||
foreach (var action in _currentCharacter.Card.Actions)
|
||||
{
|
||||
if (!action.Type.UseInBattle())
|
||||
continue;
|
||||
var cost = action.GetInt("RequiredPoint", 1);
|
||||
var canUse = _currentCharacter.Card.Attributes.Point.Value >= cost;
|
||||
|
||||
entries.Add(new FFMenuEntry
|
||||
{
|
||||
Key = "action",
|
||||
Label = $"{TranslationServer.Translate(action.Name)} ({cost}P)",
|
||||
Enabled = canUse,
|
||||
Data = action
|
||||
});
|
||||
}
|
||||
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
entries.Add(new FFMenuEntry
|
||||
{
|
||||
Key = "_empty",
|
||||
Label = TranslationServer.Translate("ff_battle/no_actions"),
|
||||
Enabled = false
|
||||
});
|
||||
}
|
||||
|
||||
entries.Add(new FFMenuEntry
|
||||
{
|
||||
Key = "back",
|
||||
Label = TranslationServer.Translate("ff_battle/back"),
|
||||
Enabled = true
|
||||
});
|
||||
|
||||
_menuPanel.ShowMenu(entries);
|
||||
_controller.State = FFBattleState.SubMenuOpen;
|
||||
}
|
||||
|
||||
private void ShowItemSubMenu()
|
||||
{
|
||||
var inventory = GameData.Instance.Runtime.Inventory;
|
||||
var entries = new List<FFMenuEntry>();
|
||||
|
||||
foreach (var container in inventory.Items)
|
||||
{
|
||||
var item = container.Item;
|
||||
if (item.ItemType.HasFlag(ItemType.Used) && container.Count > 0)
|
||||
{
|
||||
entries.Add(new FFMenuEntry
|
||||
{
|
||||
Key = "item",
|
||||
Label = $"{TranslationServer.Translate(item.Name)} x{container.Count}",
|
||||
Enabled = _currentCharacter.Card.Attributes.Point.Value >= 1,
|
||||
Data = item
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
entries.Add(new FFMenuEntry
|
||||
{
|
||||
Key = "_empty",
|
||||
Label = TranslationServer.Translate("ff_battle/no_items"),
|
||||
Enabled = false
|
||||
});
|
||||
}
|
||||
|
||||
entries.Add(new FFMenuEntry
|
||||
{
|
||||
Key = "back",
|
||||
Label = TranslationServer.Translate("ff_battle/back"),
|
||||
Enabled = true
|
||||
});
|
||||
|
||||
_menuPanel.ShowMenu(entries);
|
||||
_controller.State = FFBattleState.SubMenuOpen;
|
||||
}
|
||||
|
||||
private void StartTargetSelect(bool targetAlly)
|
||||
{
|
||||
_targetSelectMode = true;
|
||||
_targetAlly = targetAlly;
|
||||
_menuPanel.HideMenu();
|
||||
_controller.State = FFBattleState.TargetSelect;
|
||||
|
||||
foreach (var widget in _playerPanel.Widgets)
|
||||
widget.ClearHighlight();
|
||||
foreach (var widget in _enemyPanel.Widgets)
|
||||
widget.ClearHighlight();
|
||||
|
||||
if (targetAlly)
|
||||
{
|
||||
foreach (var widget in _playerPanel.Widgets)
|
||||
if (!widget.IsDead)
|
||||
widget.Highlight(new Color(0.3f, 1f, 0.3f, 0.5f));
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var widget in _enemyPanel.Widgets)
|
||||
if (!widget.IsDead)
|
||||
widget.Highlight(new Color(1f, 0.3f, 0.3f, 0.5f));
|
||||
}
|
||||
|
||||
_onTargetSelected = null;
|
||||
}
|
||||
|
||||
private void OnEnemyClicked(FFCharacterWidget widget)
|
||||
{
|
||||
if (!_isPlayerTurn || !_targetSelectMode || widget.IsDead)
|
||||
return;
|
||||
|
||||
if (_targetAlly)
|
||||
return;
|
||||
|
||||
_targetSelectMode = false;
|
||||
foreach (var w in _enemyPanel.Widgets) w.ClearHighlight();
|
||||
foreach (var w in _playerPanel.Widgets) w.ClearHighlight();
|
||||
|
||||
ExecuteMenuAction(widget);
|
||||
}
|
||||
|
||||
private void OnTargetSelected(FFCharacterWidget widget)
|
||||
{
|
||||
if (!_isPlayerTurn || !_targetSelectMode || widget.IsDead)
|
||||
return;
|
||||
|
||||
if (_targetAlly && _enemyPanel.Widgets.Contains(widget))
|
||||
return;
|
||||
|
||||
if (!_targetAlly && _playerPanel.Widgets.Contains(widget))
|
||||
return;
|
||||
|
||||
_targetSelectMode = false;
|
||||
foreach (var w in _enemyPanel.Widgets) w.ClearHighlight();
|
||||
foreach (var w in _playerPanel.Widgets) w.ClearHighlight();
|
||||
|
||||
ExecuteMenuAction(widget);
|
||||
}
|
||||
|
||||
private async void ExecuteMenuAction(FFCharacterWidget target)
|
||||
{
|
||||
if (_currentMenuAction == null)
|
||||
return;
|
||||
|
||||
_controller.State = FFBattleState.AnimationPlaying;
|
||||
_endTurnButton.Visible = false;
|
||||
|
||||
switch (_currentMenuAction.Key)
|
||||
{
|
||||
case "action":
|
||||
{
|
||||
var action = _currentMenuAction.Data as ActionCharacter;
|
||||
IBattleAction anim = null;
|
||||
|
||||
if (action.Type == ActionCharacterType.ToEnemy)
|
||||
anim = new Actions.FFAttackAnimation(_currentCharacter, target, action);
|
||||
else if (action.Type == ActionCharacterType.ToAlias)
|
||||
anim = new Actions.FFDefendAnimation(_currentCharacter, target, action);
|
||||
else if (action.Type == ActionCharacterType.ToSelf)
|
||||
anim = new Actions.FFDefendAnimation(_currentCharacter, _currentCharacter, action);
|
||||
else
|
||||
GameLogger.Log("FF_BATTLE", $"Unknown action type: {action.Type}", LogLevel.Warning);
|
||||
|
||||
await RunAnimation(anim);
|
||||
break;
|
||||
}
|
||||
|
||||
case "item":
|
||||
{
|
||||
var item = _currentMenuAction.Data as IItem;
|
||||
var dummyAction = new ActionCharacter { ID = "ff/item" };
|
||||
var anim = new Actions.FFItemAnimation(_currentCharacter, target, dummyAction, item);
|
||||
await RunAnimation(anim);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_currentMenuAction = null;
|
||||
_endTurnButton.Visible = true;
|
||||
|
||||
CheckEndCondition();
|
||||
}
|
||||
|
||||
private void DoDefendAction()
|
||||
{
|
||||
var defendAction = _currentCharacter?.Card?.Actions
|
||||
?.FirstOrDefault(a => a.Type == ActionCharacterType.ToAlias);
|
||||
if (defendAction == null)
|
||||
return;
|
||||
|
||||
_currentMenuAction = new FFMenuEntry
|
||||
{
|
||||
Key = "action",
|
||||
Data = defendAction
|
||||
};
|
||||
|
||||
_targetSelectMode = false;
|
||||
ExecuteMenuAction(_currentCharacter);
|
||||
}
|
||||
|
||||
private void DoEscapeAction()
|
||||
{
|
||||
_menuPanel.HideMenu();
|
||||
_controller.State = FFBattleState.Idle;
|
||||
|
||||
var escapeRoll = (float)GD.RandRange(0, 100);
|
||||
if (escapeRoll < 70f)
|
||||
{
|
||||
GameLogger.Log("FF_BATTLE", "Escape succeeded", LogLevel.Debug);
|
||||
_ctx.EndBattle(BattleSide.Player);
|
||||
}
|
||||
else
|
||||
{
|
||||
GameLogger.Log("FF_BATTLE", "Escape failed", LogLevel.Debug);
|
||||
_currentCharacter.Card.Attributes.Point.Value -= 1;
|
||||
_currentCharacter.UpdateInfo();
|
||||
_currentCharacter = null;
|
||||
BattleSceneContext.Instance.ActiveCharacter = null;
|
||||
TryNextCharacterOrEndTurn();
|
||||
}
|
||||
}
|
||||
|
||||
private void DoItemAction(IItem item)
|
||||
{
|
||||
_currentMenuAction = new FFMenuEntry
|
||||
{
|
||||
Key = "item",
|
||||
Data = item
|
||||
};
|
||||
|
||||
_targetSelectMode = false;
|
||||
ExecuteMenuAction(_currentCharacter);
|
||||
}
|
||||
|
||||
private async Task RunAnimation(IBattleAction anim)
|
||||
{
|
||||
if (anim == null)
|
||||
return;
|
||||
|
||||
var root = (Node)_ctx.RootNode;
|
||||
|
||||
anim.DoStart();
|
||||
while (!anim.DoAction())
|
||||
await root.ToSignal(root.GetTree(), "process_frame");
|
||||
anim.DoEnd();
|
||||
}
|
||||
|
||||
private void OnMenuCancel()
|
||||
{
|
||||
if (_targetSelectMode)
|
||||
{
|
||||
_targetSelectMode = false;
|
||||
foreach (var w in _enemyPanel.Widgets) w.ClearHighlight();
|
||||
foreach (var w in _playerPanel.Widgets) w.ClearHighlight();
|
||||
ShowMainMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_controller.State == FFBattleState.SubMenuOpen)
|
||||
{
|
||||
ShowMainMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
_menuPanel.HideMenu();
|
||||
_controller.State = FFBattleState.Idle;
|
||||
_currentCharacter = null;
|
||||
_currentMenuAction = null;
|
||||
BattleSceneContext.Instance.ActiveCharacter = null;
|
||||
foreach (var w in _playerPanel.Widgets) w.ClearHighlight();
|
||||
}
|
||||
|
||||
private void CheckEndCondition()
|
||||
{
|
||||
if (!_isPlayerTurn)
|
||||
return;
|
||||
|
||||
if (!_enemyPanel.HasAlive())
|
||||
{
|
||||
GameLogger.Log("FF_BATTLE", "All enemies dead — player wins", LogLevel.Debug);
|
||||
_ctx.EndBattle(BattleSide.Player);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_playerPanel.HasAlive())
|
||||
{
|
||||
GameLogger.Log("FF_BATTLE", "All players dead — enemy wins", LogLevel.Debug);
|
||||
_ctx.EndBattle(BattleSide.Enemy);
|
||||
return;
|
||||
}
|
||||
|
||||
TryNextCharacterOrEndTurn();
|
||||
}
|
||||
|
||||
private void TryNextCharacterOrEndTurn()
|
||||
{
|
||||
foreach (var widget in _playerPanel.Widgets)
|
||||
{
|
||||
if (widget.IsDead || widget.Card?.Attributes?.Point?.Value <= 0)
|
||||
continue;
|
||||
|
||||
if (widget.Card.StatusEffectQueue.HasStun())
|
||||
{
|
||||
GameLogger.Log("FF_BATTLE", $"{widget.Card.Name} is stunned, skipping", LogLevel.Debug);
|
||||
widget.Card.Attributes.Point.Value = 0;
|
||||
widget.UpdateInfo();
|
||||
continue;
|
||||
}
|
||||
|
||||
_currentCharacter = widget;
|
||||
BattleSceneContext.Instance.ActiveCharacter = widget.Card;
|
||||
ShowMainMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
OnPlayerTurnEnd();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ends the player's turn, hides all UI, and transitions to the enemy turn.
|
||||
/// Runs a brief delay before invoking <c>RunEnemyTurn()</c> which iterates
|
||||
/// over all alive enemies, each picking a random <see cref="ActionCharacter"/>
|
||||
/// and a random valid target, executing the action via the appropriate
|
||||
/// animation subclass. Checks for player defeat after each enemy action.
|
||||
/// If all enemies are dead, calls <see cref="IBattleContext.EndBattle"/>.
|
||||
/// Otherwise starts a new player turn.
|
||||
/// </summary>
|
||||
public async void OnPlayerTurnEnd()
|
||||
{
|
||||
GameLogger.Log("FF_BATTLE", "Player turn ended", LogLevel.Debug);
|
||||
|
||||
_isPlayerTurn = false;
|
||||
_endTurnButton.Visible = false;
|
||||
_menuPanel.HideMenu();
|
||||
_controller.State = FFBattleState.AnimationPlaying;
|
||||
BattleSceneContext.Instance.IsWait = true;
|
||||
|
||||
foreach (var widget in _playerPanel.Widgets)
|
||||
widget.ClearHighlight();
|
||||
foreach (var widget in _enemyPanel.Widgets)
|
||||
widget.ClearHighlight();
|
||||
|
||||
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("FF_BATTLE", "Enemy turn started", LogLevel.Debug);
|
||||
|
||||
var root = (Node)_ctx.RootNode;
|
||||
|
||||
foreach (var enemyWidget in _enemyPanel.Widgets.ToList())
|
||||
{
|
||||
if (enemyWidget.IsDead || enemyWidget.Card?.Actions == null || enemyWidget.Card.Actions.Count == 0)
|
||||
continue;
|
||||
|
||||
enemyWidget.Card.StatusEffectQueue.OnTurnStart();
|
||||
|
||||
if (enemyWidget.Card.StatusEffectQueue.HasStun())
|
||||
{
|
||||
GameLogger.Log("FF_BATTLE", $"{enemyWidget.Card.Name} is stunned, skipping", LogLevel.Debug);
|
||||
continue;
|
||||
}
|
||||
|
||||
enemyWidget.Card.Attributes.Point.Value = enemyWidget.Card.Attributes.Point.BaseValue;
|
||||
|
||||
var action = PickEnemyAction(enemyWidget);
|
||||
if (action == null)
|
||||
continue;
|
||||
|
||||
var target = PickEnemyTarget(enemyWidget, action);
|
||||
if (target == null)
|
||||
continue;
|
||||
|
||||
GameLogger.Log("FF_BATTLE",
|
||||
$"Enemy: {enemyWidget.Card.Name} → {action.Name} → {target.Card.Name}",
|
||||
LogLevel.Debug);
|
||||
|
||||
var anim = CreateEnemyAnimation(enemyWidget, target, action);
|
||||
if (anim != null)
|
||||
{
|
||||
anim.DoStart();
|
||||
while (!anim.DoAction())
|
||||
await root.ToSignal(root.GetTree(), "process_frame");
|
||||
anim.DoEnd();
|
||||
}
|
||||
|
||||
await root.ToSignal(root.GetTree().CreateTimer(0.2f), "timeout");
|
||||
|
||||
if (!_playerPanel.HasAlive())
|
||||
{
|
||||
GameLogger.Log("FF_BATTLE", "All players dead — enemy wins", LogLevel.Debug);
|
||||
_ctx.EndBattle(BattleSide.Enemy);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!_playerPanel.HasAlive())
|
||||
{
|
||||
_ctx.EndBattle(BattleSide.Enemy);
|
||||
return;
|
||||
}
|
||||
|
||||
StartPlayerTurn();
|
||||
}
|
||||
|
||||
private ActionCharacter PickEnemyAction(FFCharacterWidget widget)
|
||||
{
|
||||
var actions = widget.Card.Actions;
|
||||
if (actions == null || actions.Count == 0)
|
||||
return null;
|
||||
|
||||
var random = new Random();
|
||||
return actions[random.Next(actions.Count)];
|
||||
}
|
||||
|
||||
private FFCharacterWidget PickEnemyTarget(FFCharacterWidget source, ActionCharacter action)
|
||||
{
|
||||
if (action.Type == ActionCharacterType.ToEnemy)
|
||||
{
|
||||
var alivePlayers = _playerPanel.Widgets
|
||||
.Where(w => !w.IsDead && w.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;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private Actions.FFAbstractAnimation CreateEnemyAnimation(
|
||||
FFCharacterWidget source, FFCharacterWidget target, ActionCharacter action)
|
||||
{
|
||||
if (action.Type == ActionCharacterType.ToEnemy)
|
||||
return new Actions.FFAttackAnimation(source, target, action, 0.8f);
|
||||
|
||||
if (action.Type == ActionCharacterType.ToAlias || action.Type == ActionCharacterType.ToSelf)
|
||||
return new Actions.FFDefendAnimation(source, target, action, 0.8f);
|
||||
|
||||
GameLogger.Log("FF_BATTLE", $"Unknown action type for enemy: {action.Type}", LogLevel.Warning);
|
||||
return null;
|
||||
}
|
||||
|
||||
private void OnEndTurnPressed()
|
||||
{
|
||||
if (!_isPlayerTurn)
|
||||
return;
|
||||
|
||||
OnPlayerTurnEnd();
|
||||
}
|
||||
|
||||
private void OnEnemyDied(FFCharacterWidget widget)
|
||||
{
|
||||
var ctx = BattleSceneContext.Instance;
|
||||
if (ctx != null && widget?.Card != null)
|
||||
{
|
||||
ctx.RecordEnemyDefeated(widget.Card);
|
||||
ctx.NotifyCharacterDied(widget.Card);
|
||||
}
|
||||
|
||||
if (!_enemyPanel.HasAlive())
|
||||
{
|
||||
GameLogger.Log("FF_BATTLE", "All enemies dead — player wins", LogLevel.Debug);
|
||||
_ctx.EndBattle(BattleSide.Player);
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasAvailableActions()
|
||||
{
|
||||
if (_currentCharacter?.Card?.Actions == null)
|
||||
return false;
|
||||
|
||||
foreach (var action in _currentCharacter.Card.Actions)
|
||||
{
|
||||
if (!action.Type.UseInBattle())
|
||||
continue;
|
||||
var cost = action.GetInt("RequiredPoint", 1);
|
||||
if (_currentCharacter.Card.Attributes.Point.Value >= cost)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool HasUsableItems()
|
||||
{
|
||||
var inventory = GameData.Instance.Runtime.Inventory;
|
||||
foreach (var container in inventory.Items)
|
||||
{
|
||||
if (container.Item.ItemType.HasFlag(ItemType.Used) && container.Count > 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool HasDefendAction()
|
||||
{
|
||||
if (_currentCharacter?.Card?.Actions == null)
|
||||
return false;
|
||||
|
||||
return _currentCharacter.Card.Actions.Any(a =>
|
||||
a.Type == ActionCharacterType.ToAlias
|
||||
&& _currentCharacter.Card.Attributes.Point.Value >= a.GetInt("RequiredPoint", 1));
|
||||
}
|
||||
|
||||
private void OnBattleCleared()
|
||||
{
|
||||
_playerPanel?.ClearAll();
|
||||
_enemyPanel?.ClearAll();
|
||||
_menuPanel?.HideMenu();
|
||||
if (_endTurnButton != null)
|
||||
_endTurnButton.Visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using Cthangover.Core.UI;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.FFBattle.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the six states of the battle UI state machine, driving which input
|
||||
/// handler is active and what the player sees.
|
||||
/// </summary>
|
||||
public enum FFBattleState
|
||||
{
|
||||
/// <summary>No menu open; player can select a character to act.</summary>
|
||||
Idle,
|
||||
/// <summary>Main action menu (attack/items/defend/escape) is visible.</summary>
|
||||
MenuOpen,
|
||||
/// <summary>Sub-menu (action list or item list) is visible.</summary>
|
||||
SubMenuOpen,
|
||||
/// <summary>Player must click a valid target character widget.</summary>
|
||||
TargetSelect,
|
||||
/// <summary>An animation is playing; all input is blocked.</summary>
|
||||
AnimationPlaying,
|
||||
/// <summary>Battle has concluded.</summary>
|
||||
Ended
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input dispatch layer for the FF battle UI. Extends <see cref="InputHandlerNode"/>
|
||||
/// so that keyboard events from Godot's input system are forwarded to this node
|
||||
/// even in mod assemblies. Routes arrow keys, Enter, Space, and Escape to the
|
||||
/// appropriate handler based on current <see cref="State"/>. Also provides
|
||||
/// mouse-driven character selection via <see cref="SelectCharacter"/>.
|
||||
/// Events are raised to <see cref="FFBattleCore"/> which wires up the actual logic.
|
||||
/// </summary>
|
||||
public partial class FFBattleController : InputHandlerNode
|
||||
{
|
||||
private FFBattleState _state = FFBattleState.Idle;
|
||||
|
||||
/// <summary>Reference to the player-side character panel for idle-state character selection.</summary>
|
||||
public FFPlayerPanel PlayerPanel { get; set; }
|
||||
/// <summary>Reference to the enemy-side panel for visual context (not directly used for input).</summary>
|
||||
public FFEnemyPanel EnemyPanel { get; set; }
|
||||
/// <summary>The main menu panel; <see cref="HandleMenuInput"/> delegates up/down/confirm to it.</summary>
|
||||
public FFMenuPanel MenuPanel { get; set; }
|
||||
/// <summary>The sub-menu panel; <see cref="HandleSubMenuInput"/> delegates to it.</summary>
|
||||
public FFMenuPanel SubMenuPanel { get; set; }
|
||||
|
||||
/// <summary>Raised when a character widget is selected (via mouse click or keyboard).</summary>
|
||||
public event System.Action<FFCharacterWidget> OnCharacterSelected;
|
||||
/// <summary>Raised when a menu item is confirmed. The int is the entry index.</summary>
|
||||
public event System.Action<int> OnMenuAction;
|
||||
/// <summary>Raised when a sub-menu item is confirmed.</summary>
|
||||
public event System.Action<int> OnSubMenuAction;
|
||||
/// <summary>Raised when Escape is pressed while the main menu is open.</summary>
|
||||
public event System.Action OnMenuCancel;
|
||||
/// <summary>Raised when Escape is pressed while a sub-menu is open.</summary>
|
||||
public event System.Action OnSubMenuCancel;
|
||||
/// <summary>Raised when a target widget is confirmed in target-select mode.</summary>
|
||||
public event System.Action<FFCharacterWidget> OnTargetSelected;
|
||||
|
||||
/// <summary>
|
||||
/// Current UI state. Setting this property logs the transition via
|
||||
/// <see cref="GameLogger"/> for debugging battle flow.
|
||||
/// </summary>
|
||||
public FFBattleState State
|
||||
{
|
||||
get => _state;
|
||||
set
|
||||
{
|
||||
_state = value;
|
||||
Cthangover.Core.Utils.GameLogger.Log("FF_BATTLE", $"Controller state → {value}", Cthangover.Core.Utils.LogLevel.Debug);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ensures the controller processes input even when the scene tree is paused.</summary>
|
||||
public override void _Ready()
|
||||
{
|
||||
ProcessMode = ProcessModeEnum.Always;
|
||||
}
|
||||
|
||||
protected override void OnInput(InputEvent @event)
|
||||
{
|
||||
if (State == FFBattleState.AnimationPlaying || State == FFBattleState.Ended)
|
||||
return;
|
||||
|
||||
if (@event is InputEventKey key && key.Pressed)
|
||||
{
|
||||
HandleKeyboardInput(key);
|
||||
if (State != FFBattleState.Idle)
|
||||
GetViewport().SetInputAsHandled();
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleKeyboardInput(InputEventKey key)
|
||||
{
|
||||
switch (State)
|
||||
{
|
||||
case FFBattleState.Idle:
|
||||
HandleIdleInput(key);
|
||||
break;
|
||||
|
||||
case FFBattleState.MenuOpen:
|
||||
HandleMenuInput(key);
|
||||
break;
|
||||
|
||||
case FFBattleState.SubMenuOpen:
|
||||
HandleSubMenuInput(key);
|
||||
break;
|
||||
|
||||
case FFBattleState.TargetSelect:
|
||||
HandleTargetInput(key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleIdleInput(InputEventKey key)
|
||||
{
|
||||
if (key.Keycode == Key.Enter || key.Keycode == Key.Space)
|
||||
{
|
||||
SelectFirstAvailableCharacter();
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleMenuInput(InputEventKey key)
|
||||
{
|
||||
switch (key.Keycode)
|
||||
{
|
||||
case Key.Up:
|
||||
case Key.W:
|
||||
MenuPanel?.SelectPrevious();
|
||||
break;
|
||||
|
||||
case Key.Down:
|
||||
case Key.S:
|
||||
MenuPanel?.SelectNext();
|
||||
break;
|
||||
|
||||
case Key.Enter:
|
||||
case Key.Space:
|
||||
MenuPanel?.ConfirmSelection();
|
||||
break;
|
||||
|
||||
case Key.Escape:
|
||||
OnMenuCancel?.Invoke();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleSubMenuInput(InputEventKey key)
|
||||
{
|
||||
switch (key.Keycode)
|
||||
{
|
||||
case Key.Up:
|
||||
case Key.W:
|
||||
SubMenuPanel?.SelectPrevious();
|
||||
break;
|
||||
|
||||
case Key.Down:
|
||||
case Key.S:
|
||||
SubMenuPanel?.SelectNext();
|
||||
break;
|
||||
|
||||
case Key.Enter:
|
||||
case Key.Space:
|
||||
SubMenuPanel?.ConfirmSelection();
|
||||
break;
|
||||
|
||||
case Key.Escape:
|
||||
OnSubMenuCancel?.Invoke();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleTargetInput(InputEventKey key)
|
||||
{
|
||||
if (key.Keycode == Key.Escape)
|
||||
{
|
||||
OnMenuCancel?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectFirstAvailableCharacter()
|
||||
{
|
||||
if (PlayerPanel == null)
|
||||
return;
|
||||
|
||||
foreach (var widget in PlayerPanel.Widgets)
|
||||
{
|
||||
if (!widget.IsDead && widget.Card?.Attributes?.Point?.Value > 0)
|
||||
{
|
||||
OnCharacterSelected?.Invoke(widget);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects a character widget, raising <see cref="OnCharacterSelected"/>.
|
||||
/// Only valid in <see cref="FFBattleState.Idle"/> state.
|
||||
/// </summary>
|
||||
public void SelectCharacter(FFCharacterWidget widget)
|
||||
{
|
||||
if (State != FFBattleState.Idle)
|
||||
return;
|
||||
|
||||
OnCharacterSelected?.Invoke(widget);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using System;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Mods.Resolvers;
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.Core.UI;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.FFBattle.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Visual representation of a single character (player or enemy) in the FF battle UI.
|
||||
/// Composite widget containing a sprite, HP bar, name label, and selection overlay.
|
||||
/// Supports highlight, flash, shake, and dissolve-death animations via Godot tweens.
|
||||
/// Created per-character by <see cref="FFPlayerPanel.Init"/> and
|
||||
/// <see cref="FFEnemyPanel.Init"/>. Clickable via <c>GuiInput</c> events;
|
||||
/// embedding panels wire these to <see cref="FFBattleCore"/> for action flow.
|
||||
/// </summary>
|
||||
public partial class FFCharacterWidget : ModWidget
|
||||
{
|
||||
private TextureRect _sprite;
|
||||
private TextureRect _selection;
|
||||
private Label _nameLabel;
|
||||
private ColorRect _hpBg;
|
||||
private ColorRect _hpFill;
|
||||
private Tween _animTween;
|
||||
private static Texture2D _cachedSelectTex;
|
||||
private static bool _selectTexLoaded;
|
||||
private static Shader _dissolveShader;
|
||||
|
||||
/// <summary>Whether this widget belongs to the player party (affects highlight/targeting logic).</summary>
|
||||
public bool IsPlayer { get; set; }
|
||||
/// <summary>Whether the character is dead; set by <see cref="PlayDeathAnimation"/>.</summary>
|
||||
public bool IsDead { get; set; }
|
||||
/// <summary>The character data model backing this widget: stats, actions, status effects.</summary>
|
||||
public Character Card { get; set; }
|
||||
/// <summary>Original scale captured at construction; used to restore after death animation.</summary>
|
||||
public Vector2 BaseScale { get; private set; }
|
||||
/// <summary>Exposes this widget as a <see cref="Godot.Control"/> for layout calculations.</summary>
|
||||
public Control ControlNode => this;
|
||||
|
||||
protected override void Construct()
|
||||
{
|
||||
_sprite = GetNode<TextureRect>("Sprite");
|
||||
_hpBg = GetNode<ColorRect>("HpBg");
|
||||
_hpFill = GetNode<ColorRect>("HpFill");
|
||||
_nameLabel = GetNode<Label>("NameLabel");
|
||||
_selection = GetNode<TextureRect>("Selection");
|
||||
|
||||
Size = CustomMinimumSize;
|
||||
|
||||
if (!_selectTexLoaded)
|
||||
{
|
||||
_cachedSelectTex = Textures.Resolve("select");
|
||||
_selectTexLoaded = true;
|
||||
}
|
||||
if (_cachedSelectTex != null)
|
||||
{
|
||||
_selection.Texture = _cachedSelectTex;
|
||||
_selection.ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize;
|
||||
_selection.StretchMode = TextureRect.StretchModeEnum.Scale;
|
||||
_selection.Modulate = new Color(0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binds this widget to a <see cref="Character"/> model. Sets the sprite from
|
||||
/// <see cref="Character.Image"/>, localises the name via
|
||||
/// <see cref="TranslationServer"/>, and calls <see cref="UpdateInfo"/>
|
||||
/// to refresh the HP bar.
|
||||
/// </summary>
|
||||
public void Init(Character character)
|
||||
{
|
||||
Card = character;
|
||||
BaseScale = Scale;
|
||||
IsDead = false;
|
||||
|
||||
if (_sprite != null && character.Image != null)
|
||||
_sprite.Texture = character.Image;
|
||||
|
||||
if (_nameLabel != null)
|
||||
_nameLabel.Text = TranslationServer.Translate(character.Name);
|
||||
|
||||
UpdateInfo();
|
||||
}
|
||||
|
||||
/// <summary>Refreshes the HP bar fill width and colour gradient based on current health percentage.</summary>
|
||||
public void UpdateInfo()
|
||||
{
|
||||
if (_hpFill == null || Card == null)
|
||||
return;
|
||||
|
||||
var hpPercent = Card.Attributes.Health.Percent;
|
||||
_hpFill.Size = new Vector2((Size.X - 8) * hpPercent, 18);
|
||||
|
||||
if (hpPercent > 0.5f)
|
||||
_hpFill.Color = new Color(0.1f, 0.85f, 0.1f, 1f);
|
||||
else if (hpPercent > 0.25f)
|
||||
_hpFill.Color = new Color(0.85f, 0.85f, 0.1f, 1f);
|
||||
else
|
||||
_hpFill.Color = new Color(0.85f, 0.1f, 0.1f, 1f);
|
||||
}
|
||||
|
||||
/// <summary>Shows a coloured selection overlay with a 0.15s tween fade-in. Used for target highlighting and selection indication.</summary>
|
||||
public void Highlight(Color color)
|
||||
{
|
||||
if (_selection == null)
|
||||
return;
|
||||
|
||||
_animTween?.Kill();
|
||||
_animTween = CreateTween();
|
||||
_animTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Quad);
|
||||
_animTween.TweenProperty(_selection, "modulate", color, 0.15f);
|
||||
}
|
||||
|
||||
/// <summary>Fades out the selection overlay to transparent via a 0.15s tween.</summary>
|
||||
public void ClearHighlight()
|
||||
{
|
||||
if (_selection == null)
|
||||
return;
|
||||
|
||||
_animTween?.Kill();
|
||||
_animTween = CreateTween();
|
||||
_animTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Quad);
|
||||
_animTween.TweenProperty(_selection, "modulate", new Color(0, 0, 0, 0), 0.15f);
|
||||
}
|
||||
|
||||
/// <summary>Rapidly oscillates the widget's position with random offsets (6 iterations) to simulate impact feedback.</summary>
|
||||
public void Shake(float intensity, float duration)
|
||||
{
|
||||
var originalPos = Position;
|
||||
_animTween?.Kill();
|
||||
_animTween = CreateTween();
|
||||
|
||||
for (int i = 0; i < 6; i++)
|
||||
{
|
||||
var offset = new Vector2(
|
||||
(float)GD.RandRange(-intensity, intensity),
|
||||
(float)GD.RandRange(-intensity, intensity)
|
||||
);
|
||||
_animTween.TweenProperty(this, "position", originalPos + offset, duration / 12f);
|
||||
_animTween.TweenProperty(this, "position", originalPos, duration / 12f);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Briefly tints the entire widget with a colour pulse (30% rise, 70% fall) for damage/heal feedback.</summary>
|
||||
public void Flash(Color color, float duration)
|
||||
{
|
||||
var originalModulate = Modulate;
|
||||
_animTween?.Kill();
|
||||
_animTween = CreateTween();
|
||||
_animTween.TweenProperty(this, "modulate", color, duration * 0.3f);
|
||||
_animTween.TweenProperty(this, "modulate", originalModulate, duration * 0.7f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plays a dissolve-and-shrink death sequence. If a <c>"scene_transition"</c>
|
||||
/// shader is available, applies a dissolve material to the sprite. Fades the
|
||||
/// widget alpha, scales it to zero, and hides the HP bar and name label.
|
||||
/// Invokes <paramref name="onComplete"/> when finished so the parent panel
|
||||
/// can remove the widget from the grid.
|
||||
/// </summary>
|
||||
public void PlayDeathAnimation(Action onComplete)
|
||||
{
|
||||
if (IsDead)
|
||||
{
|
||||
onComplete?.Invoke();
|
||||
return;
|
||||
}
|
||||
|
||||
IsDead = true;
|
||||
MouseFilter = MouseFilterEnum.Ignore;
|
||||
ClearHighlight();
|
||||
|
||||
_animTween?.Kill();
|
||||
var duration = 0.7f;
|
||||
|
||||
if (_dissolveShader == null)
|
||||
_dissolveShader = Shaders.Resolve("scene_transition");
|
||||
|
||||
if (_dissolveShader != null && _sprite?.Texture != null)
|
||||
{
|
||||
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);
|
||||
_sprite.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(this, "modulate:a", 0f, duration * 0.5f);
|
||||
tween.TweenProperty(this, "scale", BaseScale * new Vector2(0.1f, 1.2f), duration);
|
||||
tween.TweenProperty(_hpBg, "modulate:a", 0f, duration);
|
||||
tween.TweenProperty(_hpFill, "modulate:a", 0f, duration);
|
||||
tween.TweenProperty(_nameLabel, "modulate:a", 0f, duration);
|
||||
|
||||
tween.Chain().TweenProperty(this, "scale", Vector2.Zero, 0.15f);
|
||||
|
||||
tween.Finished += () =>
|
||||
{
|
||||
onComplete?.Invoke();
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Core.UI;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.FFBattle.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Panel that arranges enemy character widgets in a grid layout and handles their
|
||||
/// lifecycle. Calculates a uniform scale for all enemy widgets based on the panel
|
||||
/// dimensions and enemy count, centering rows horizontally. Listens to each
|
||||
/// widget's health change and triggers <see cref="FFCharacterWidget.PlayDeathAnimation"/>
|
||||
/// when HP reaches zero, then removes the dead widget and re-grids the survivors.
|
||||
/// Raises <see cref="OnEnemyDead"/> so <see cref="FFBattleCore"/> can check win conditions.
|
||||
/// </summary>
|
||||
public partial class FFEnemyPanel : ModWidget
|
||||
{
|
||||
private const float BASE_W = 180f;
|
||||
private const float BASE_H = 260f;
|
||||
private const float MIN_SCALE = 0.25f;
|
||||
private const float MAX_SCALE = 1.0f;
|
||||
private const float CELL_PADDING = 10f;
|
||||
|
||||
/// <summary>All enemy character widgets currently in the panel, including dead ones until removed.</summary>
|
||||
public List<FFCharacterWidget> Widgets { get; } = new();
|
||||
|
||||
/// <summary>Raised when a widget's death animation completes and the widget is removed.</summary>
|
||||
public event System.Action<FFCharacterWidget> OnEnemyDead;
|
||||
/// <summary>Raised when an enemy widget is clicked (used for target selection).</summary>
|
||||
public event System.Action<FFCharacterWidget> OnEnemyClicked;
|
||||
|
||||
protected override void Construct() { }
|
||||
|
||||
/// <summary>Number of enemy widget instances currently managed (including dead).</summary>
|
||||
public int EnemyCount => Widgets.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Creates <see cref="FFCharacterWidget"/> instances for each enemy, scales
|
||||
/// them uniformly, wires click and health-change handlers, and calls
|
||||
/// <see cref="GridLayout"/> to arrange them.
|
||||
/// </summary>
|
||||
public void Init(Character[] enemies, float scale)
|
||||
{
|
||||
foreach (var child in Widgets)
|
||||
child.QueueFree();
|
||||
Widgets.Clear();
|
||||
|
||||
for (int i = 0; i < enemies.Length; i++)
|
||||
{
|
||||
var widget = (FFCharacterWidget)TscnScenes.LoadAndBuild("scenes/ff_character_widget.tscn");
|
||||
widget.EnsureConstructed();
|
||||
widget.Scale = new Vector2(scale, scale);
|
||||
widget.Init(enemies[i]);
|
||||
widget.IsPlayer = false;
|
||||
widget.MouseFilter = MouseFilterEnum.Stop;
|
||||
widget.GuiInput += (evt) =>
|
||||
{
|
||||
if (evt is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left)
|
||||
OnEnemyClicked?.Invoke(widget);
|
||||
};
|
||||
AddChild(widget);
|
||||
Widgets.Add(widget);
|
||||
|
||||
enemies[i].Attributes.Health.OnChange += (value, baseValue) =>
|
||||
{
|
||||
if (value <= 0 && !widget.IsDead)
|
||||
HandleDeath(widget);
|
||||
};
|
||||
}
|
||||
|
||||
GridLayout();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a uniform scale factor so all enemies fit within
|
||||
/// <paramref name="panelSize"/> with padding. Uses
|
||||
/// <see cref="GetGridDimensions"/> to determine layout, then chooses
|
||||
/// the tighter of width-based and height-based scales, clamped to
|
||||
/// [<c>MIN_SCALE</c>, <c>MAX_SCALE</c>].
|
||||
/// </summary>
|
||||
public static float CalculateScale(int enemyCount, Vector2 panelSize)
|
||||
{
|
||||
var (cols, rows) = GetGridDimensions(enemyCount);
|
||||
|
||||
float availW = panelSize.X - CELL_PADDING * 2;
|
||||
float availH = panelSize.Y - CELL_PADDING * 2;
|
||||
|
||||
float scaleByW = (availW - (cols - 1) * CELL_PADDING) / (cols * BASE_W);
|
||||
float scaleByH = (availH - (rows - 1) * CELL_PADDING) / (rows * BASE_H);
|
||||
|
||||
float scale = Mathf.Min(scaleByW, scaleByH);
|
||||
scale = Mathf.Clamp(scale, MIN_SCALE, MAX_SCALE);
|
||||
|
||||
return scale;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines grid dimensions for a given count: 1–4 → single row,
|
||||
/// 5–10 → 2 rows, 11–16 → 3 rows, 17+ → 4 rows.
|
||||
/// </summary>
|
||||
public static (int cols, int rows) GetGridDimensions(int count)
|
||||
{
|
||||
if (count <= 4) return (count, 1);
|
||||
if (count <= 10) return ((count + 1) / 2, 2);
|
||||
if (count <= 16) return ((count + 2) / 3, 3);
|
||||
return ((count + 3) / 4, 4);
|
||||
}
|
||||
|
||||
/// <summary>Repositions all widgets into the calculated grid, with optional 0.3s cubic tween animation.</summary>
|
||||
public void GridLayout(bool animate = true)
|
||||
{
|
||||
var (cols, rows) = GetGridDimensions(Widgets.Count);
|
||||
|
||||
for (int i = 0; i < Widgets.Count; i++)
|
||||
{
|
||||
var widget = Widgets[i];
|
||||
if (widget == null)
|
||||
continue;
|
||||
|
||||
int row = i / cols;
|
||||
int col = i % cols;
|
||||
|
||||
var totalColsInRow = row < rows - 1 ? cols : (Widgets.Count - row * cols);
|
||||
if (totalColsInRow <= 0)
|
||||
totalColsInRow = 1;
|
||||
|
||||
var effW = widget.Size.X * widget.Scale.X;
|
||||
var effH = widget.Size.Y * widget.Scale.Y;
|
||||
|
||||
float startX = (Size.X - (totalColsInRow * effW + (totalColsInRow - 1) * CELL_PADDING)) / 2f;
|
||||
float startY = CELL_PADDING;
|
||||
|
||||
var targetPos = new Vector2(
|
||||
startX + col * (effW + CELL_PADDING),
|
||||
startY + row * (effH + CELL_PADDING)
|
||||
);
|
||||
|
||||
if (animate)
|
||||
{
|
||||
var tween = CreateTween();
|
||||
tween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
|
||||
tween.TweenProperty(widget, "position", targetPos, 0.3f);
|
||||
}
|
||||
else
|
||||
{
|
||||
widget.Position = targetPos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleDeath(FFCharacterWidget widget)
|
||||
{
|
||||
if (widget.IsDead)
|
||||
return;
|
||||
|
||||
widget.PlayDeathAnimation(() =>
|
||||
{
|
||||
RemoveWidget(widget);
|
||||
GridLayout(true);
|
||||
OnEnemyDead?.Invoke(widget);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Removes a widget from the panel, frees it, and re-grids the remaining widgets.</summary>
|
||||
public void RemoveWidget(FFCharacterWidget widget)
|
||||
{
|
||||
Widgets.Remove(widget);
|
||||
widget.QueueFree();
|
||||
GridLayout();
|
||||
}
|
||||
|
||||
/// <summary>Returns <c>true</c> if any widget in the panel is not marked dead.</summary>
|
||||
public bool HasAlive()
|
||||
{
|
||||
foreach (var w in Widgets)
|
||||
if (!w.IsDead)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Frees all widget instances and clears the widget list. Called on battle cleanup.</summary>
|
||||
public void ClearAll()
|
||||
{
|
||||
foreach (var widget in Widgets.ToList())
|
||||
{
|
||||
if (GodotObject.IsInstanceValid(widget))
|
||||
widget.QueueFree();
|
||||
}
|
||||
Widgets.Clear();
|
||||
GridLayout(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.UI;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.FFBattle.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Data object for a single menu entry. <see cref="Key"/> drives the action
|
||||
/// dispatch in <see cref="FFBattleCore.MatchMenuAction"/> (values like
|
||||
/// <c>"attack"</c>, <c>"action"</c>, <c>"item"</c>, <c>"back"</c>).
|
||||
/// <see cref="Data"/> carries arbitrary payload: an
|
||||
/// <see cref="ActionCharacter"/> for action entries or an
|
||||
/// <see cref="IItem"/> for item entries.
|
||||
/// </summary>
|
||||
public class FFMenuEntry
|
||||
{
|
||||
/// <summary>Dispatch key used by <see cref="FFBattleCore"/> to determine the next step.</summary>
|
||||
public string Key;
|
||||
/// <summary>Display label shown in the menu.</summary>
|
||||
public string Label;
|
||||
/// <summary>Whether this entry can be selected (greyed out if false).</summary>
|
||||
public bool Enabled = true;
|
||||
/// <summary>Payload data: <see cref="ActionCharacter"/> for actions, <see cref="IItem"/> for items.</summary>
|
||||
public object Data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vertical scrolling menu panel with a blinking cursor, keyboard navigation,
|
||||
/// and mouse hover/click support. Used as both the main battle menu and the
|
||||
/// action/item sub-menu (a separate instance). Each menu is populated via
|
||||
/// <see cref="ShowMenu"/> with a list of <see cref="FFMenuEntry"/> objects.
|
||||
/// Selection wraps around but skips disabled entries. Raises
|
||||
/// <see cref="OnItemSelected"/> on confirm, <see cref="OnCancelled"/> on
|
||||
/// right-click or Escape (handled by <see cref="FFBattleController"/>).
|
||||
/// </summary>
|
||||
public partial class FFMenuPanel : ModWidget
|
||||
{
|
||||
private ColorRect _background;
|
||||
private ColorRect _border;
|
||||
private Label _cursorLabel;
|
||||
private List<Label> _itemLabels = new();
|
||||
private Tween _cursorTween;
|
||||
private int _selectedIndex;
|
||||
private bool _visible;
|
||||
|
||||
private const float ITEM_HEIGHT = 34f;
|
||||
private const float PADDING = 18f;
|
||||
private const float CURSOR_OFFSET_X = 6f;
|
||||
private const float CURSOR_OFFSET_Y = 7f;
|
||||
|
||||
/// <summary>The entries currently displayed in the menu.</summary>
|
||||
public List<FFMenuEntry> Entries { get; private set; } = new();
|
||||
/// <summary>
|
||||
/// 0-based index of the highlighted entry. Setting it moves the
|
||||
/// cursor and clamps to the valid range.
|
||||
/// </summary>
|
||||
public int SelectedIndex
|
||||
{
|
||||
get => _selectedIndex;
|
||||
set
|
||||
{
|
||||
var clamped = Mathf.Clamp(value, 0, Entries.Count - 1);
|
||||
if (clamped == _selectedIndex)
|
||||
return;
|
||||
_selectedIndex = clamped;
|
||||
MoveCursor(_selectedIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Raised when an entry is confirmed (Enter/Space or left click).</summary>
|
||||
public event System.Action<int> OnItemSelected;
|
||||
/// <summary>Raised when the menu is cancelled (right click, or Escape handled externally).</summary>
|
||||
public event System.Action OnCancelled;
|
||||
|
||||
protected override void Construct()
|
||||
{
|
||||
_background = GetNode<ColorRect>("Background");
|
||||
_border = GetNode<ColorRect>("Border");
|
||||
_cursorLabel = GetNode<Label>("Cursor");
|
||||
|
||||
GuiInput += OnPanelGuiInput;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the menu with the given entries. Creates labels, wires hover/click
|
||||
/// handlers, sizes the panel to fit, positions the cursor at index 0, and
|
||||
/// makes the panel visible.
|
||||
/// </summary>
|
||||
public void ShowMenu(List<FFMenuEntry> entries, string title = null)
|
||||
{
|
||||
ClearEntries();
|
||||
Entries = entries;
|
||||
|
||||
float width = 220f;
|
||||
float totalHeight = PADDING * 2 + ITEM_HEIGHT * entries.Count;
|
||||
Size = new Vector2(width, totalHeight);
|
||||
|
||||
for (int i = 0; i < entries.Count; i++)
|
||||
{
|
||||
var label = new Label();
|
||||
label.Text = entries[i].Label;
|
||||
label.AddThemeFontSizeOverride("font_size", 17);
|
||||
label.AddThemeColorOverride("font_color", entries[i].Enabled
|
||||
? new Color(1f, 1f, 1f, 1f)
|
||||
: new Color(0.5f, 0.5f, 0.5f, 1f));
|
||||
label.MouseFilter = MouseFilterEnum.Stop;
|
||||
label.Position = new Vector2(PADDING + 24, PADDING + i * ITEM_HEIGHT + CURSOR_OFFSET_Y);
|
||||
label.Size = new Vector2(width - PADDING * 2 - 24, ITEM_HEIGHT);
|
||||
label.ClipText = true;
|
||||
|
||||
int idx = i;
|
||||
label.GuiInput += (evt) =>
|
||||
{
|
||||
if (evt is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left)
|
||||
{
|
||||
if (entries[idx].Enabled)
|
||||
{
|
||||
_selectedIndex = idx;
|
||||
MoveCursor(idx);
|
||||
OnItemSelected?.Invoke(idx);
|
||||
}
|
||||
}
|
||||
};
|
||||
label.MouseEntered += () =>
|
||||
{
|
||||
if (entries[idx].Enabled)
|
||||
{
|
||||
SelectedIndex = idx;
|
||||
}
|
||||
};
|
||||
|
||||
AddChild(label);
|
||||
_itemLabels.Add(label);
|
||||
}
|
||||
|
||||
_selectedIndex = 0;
|
||||
MoveCursor(0);
|
||||
_cursorLabel.Visible = true;
|
||||
_visible = true;
|
||||
Visible = true;
|
||||
}
|
||||
|
||||
/// <summary>Hides the menu and cursor. Does not clear entries — call <see cref="ShowMenu"/> to repopulate.</summary>
|
||||
public void HideMenu()
|
||||
{
|
||||
_visible = false;
|
||||
Visible = false;
|
||||
_cursorLabel.Visible = false;
|
||||
}
|
||||
|
||||
/// <summary>Moves selection down, wrapping around and skipping disabled entries.</summary>
|
||||
public void SelectNext()
|
||||
{
|
||||
if (!_visible || Entries.Count == 0)
|
||||
return;
|
||||
|
||||
var next = _selectedIndex;
|
||||
for (int i = 0; i < Entries.Count; i++)
|
||||
{
|
||||
next = (next + 1) % Entries.Count;
|
||||
if (Entries[next].Enabled)
|
||||
break;
|
||||
}
|
||||
SelectedIndex = next;
|
||||
}
|
||||
|
||||
/// <summary>Moves selection up, wrapping around and skipping disabled entries.</summary>
|
||||
public void SelectPrevious()
|
||||
{
|
||||
if (!_visible || Entries.Count == 0)
|
||||
return;
|
||||
|
||||
var prev = _selectedIndex;
|
||||
for (int i = 0; i < Entries.Count; i++)
|
||||
{
|
||||
prev = (prev - 1 + Entries.Count) % Entries.Count;
|
||||
if (Entries[prev].Enabled)
|
||||
break;
|
||||
}
|
||||
SelectedIndex = prev;
|
||||
}
|
||||
|
||||
/// <summary>Invokes <see cref="OnItemSelected"/> for the currently highlighted entry if it is enabled.</summary>
|
||||
public void ConfirmSelection()
|
||||
{
|
||||
if (!_visible || Entries.Count == 0)
|
||||
return;
|
||||
|
||||
if (_selectedIndex >= 0 && _selectedIndex < Entries.Count && Entries[_selectedIndex].Enabled)
|
||||
OnItemSelected?.Invoke(_selectedIndex);
|
||||
}
|
||||
|
||||
private void MoveCursor(int index)
|
||||
{
|
||||
_cursorTween?.Kill();
|
||||
_cursorLabel.Position = new Vector2(CURSOR_OFFSET_X, PADDING + index * ITEM_HEIGHT + CURSOR_OFFSET_Y);
|
||||
|
||||
_cursorTween = CreateTween();
|
||||
_cursorTween.SetLoops();
|
||||
_cursorTween.TweenProperty(_cursorLabel, "position:x", CURSOR_OFFSET_X + 3f, 0.3f);
|
||||
_cursorTween.TweenProperty(_cursorLabel, "position:x", CURSOR_OFFSET_X, 0.3f);
|
||||
}
|
||||
|
||||
private void OnPanelGuiInput(InputEvent evt)
|
||||
{
|
||||
if (evt is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Right)
|
||||
{
|
||||
OnCancelled?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearEntries()
|
||||
{
|
||||
foreach (var label in _itemLabels)
|
||||
label.QueueFree();
|
||||
_itemLabels.Clear();
|
||||
}
|
||||
|
||||
protected override void Destruct()
|
||||
{
|
||||
ClearEntries();
|
||||
_cursorTween?.Kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Core.UI;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.FFBattle.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Panel that arranges player character widgets horizontally at the bottom of
|
||||
/// the battle screen. Creates one <see cref="FFCharacterWidget"/> per player
|
||||
/// character, wires click and health-change handlers, and positions them using
|
||||
/// fixed horizontal spacing via <see cref="Redraw"/>. When a player's HP drops
|
||||
/// to zero, the widget plays its death animation and is removed; the remaining
|
||||
/// widgets are repositioned. Exposes <see cref="HasAlive"/> for win/loss checks.
|
||||
/// </summary>
|
||||
public partial class FFPlayerPanel : ModWidget
|
||||
{
|
||||
private const float CELL_SPACING = 20f;
|
||||
|
||||
/// <summary>All player character widgets, including dead ones until their death animation completes.</summary>
|
||||
public List<FFCharacterWidget> Widgets { get; } = new();
|
||||
|
||||
/// <summary>Raised when a player widget is clicked — routed to <see cref="FFBattleCore"/> for character selection.</summary>
|
||||
public event System.Action<FFCharacterWidget> OnWidgetClicked;
|
||||
|
||||
protected override void Construct() { }
|
||||
|
||||
/// <summary>
|
||||
/// Creates <see cref="FFCharacterWidget"/> instances for each player, scales
|
||||
/// them uniformly, wires click and health-change handlers, and calls
|
||||
/// <see cref="Redraw"/> to position them horizontally.
|
||||
/// </summary>
|
||||
public void Init(Character[] players, float scale)
|
||||
{
|
||||
foreach (var child in Widgets)
|
||||
child.QueueFree();
|
||||
Widgets.Clear();
|
||||
|
||||
for (int i = 0; i < players.Length; i++)
|
||||
{
|
||||
var widget = (FFCharacterWidget)TscnScenes.LoadAndBuild("scenes/ff_character_widget.tscn");
|
||||
widget.EnsureConstructed();
|
||||
widget.Scale = new Vector2(scale, scale);
|
||||
widget.Init(players[i]);
|
||||
widget.IsPlayer = true;
|
||||
widget.MouseFilter = MouseFilterEnum.Stop;
|
||||
widget.GuiInput += (evt) =>
|
||||
{
|
||||
if (evt is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left)
|
||||
OnWidgetClicked?.Invoke(widget);
|
||||
};
|
||||
AddChild(widget);
|
||||
Widgets.Add(widget);
|
||||
|
||||
players[i].Attributes.Health.OnChange += (value, baseValue) =>
|
||||
{
|
||||
widget.UpdateInfo();
|
||||
if (value <= 0 && !widget.IsDead)
|
||||
{
|
||||
widget.PlayDeathAnimation(() =>
|
||||
{
|
||||
RemoveWidget(widget);
|
||||
Redraw();
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Redraw();
|
||||
}
|
||||
|
||||
/// <summary>Repositions all widgets in a horizontal row with fixed <c>CELL_SPACING</c> between them.</summary>
|
||||
public void Redraw()
|
||||
{
|
||||
for (int i = 0; i < Widgets.Count; i++)
|
||||
{
|
||||
var widget = Widgets[i];
|
||||
if (widget == null)
|
||||
continue;
|
||||
|
||||
var effWidth = widget.Size.X * widget.Scale.X;
|
||||
var effHeight = widget.Size.Y * widget.Scale.Y;
|
||||
widget.Position = new Vector2(i * (effWidth + CELL_SPACING) + CELL_SPACING, Size.Y - effHeight - CELL_SPACING);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns <c>true</c> if any widget is alive and has positive health.</summary>
|
||||
public bool HasAlive()
|
||||
{
|
||||
return Widgets.Any(w => !w.IsDead && w.Card?.Attributes?.Health?.Value > 0);
|
||||
}
|
||||
|
||||
/// <summary>Frees all widget instances and clears the widget list. Called on battle cleanup.</summary>
|
||||
public void ClearAll()
|
||||
{
|
||||
foreach (var widget in Widgets.ToList())
|
||||
{
|
||||
if (GodotObject.IsInstanceValid(widget))
|
||||
widget.QueueFree();
|
||||
}
|
||||
Widgets.Clear();
|
||||
Redraw();
|
||||
}
|
||||
|
||||
/// <summary>Removes a widget from the panel, frees it, and repositions remaining widgets.</summary>
|
||||
public void RemoveWidget(FFCharacterWidget widget)
|
||||
{
|
||||
Widgets.Remove(widget);
|
||||
widget.QueueFree();
|
||||
Redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user