This commit is contained in:
2026-09-10 10:59:37 +03:00
commit fe403c2dd6
27 changed files with 2675 additions and 0 deletions
+37
View File
@@ -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
+14
View File
@@ -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
+14
View File
@@ -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=Нет действий
+11
View File
@@ -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" ]
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+59
View File
@@ -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
+37
View File
@@ -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.
+83
View File
@@ -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;
}
}
+46
View File
@@ -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;
}
}
}
+116
View File
@@ -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();
}
}
}
+53
View File
@@ -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 }
};
}
}
}
+32
View File
@@ -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 } };
}
}
}
+114
View File
@@ -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();
}
}
}
+141
View File
@@ -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();
}
}
}
+67
View File
@@ -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;
}
}
}
+35
View File
@@ -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 };
}
}
}
+858
View File
@@ -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;
}
}
}
+208
View File
@@ -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);
}
}
}
+214
View File
@@ -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();
};
}
}
}
+196
View File
@@ -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: 14 → single row,
/// 510 → 2 rows, 1116 → 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);
}
}
}
+225
View File
@@ -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();
}
}
}
+115
View File
@@ -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();
}
}
}