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