diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..3030c1a
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,30 @@
+# Line endings: LF everywhere, on every OS, whatever core.autocrlf says.
+* text=auto eol=lf
+
+# cmd.exe misreads labels and goto in LF batch files, so they stay CRLF.
+*.bat text eol=crlf
+*.cmd text eol=crlf
+
+# Binaries: never converted, never diffed as text.
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.webp binary
+*.svg text
+*.ogg binary
+*.wav binary
+*.mp3 binary
+*.ttf binary
+*.otf binary
+*.woff binary
+*.woff2 binary
+*.dll binary
+*.so binary
+*.dylib binary
+*.pdb binary
+*.exe binary
+*.zip binary
+*.pck binary
+*.moc3 binary
+*.res binary
+*.scn binary
diff --git a/.gitignore b/.gitignore
index bb88804..c301ca8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,37 +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
+# 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
diff --git a/src/Actions/FFDamageExecutor.cs b/src/Actions/FFDamageExecutor.cs
index d0afa02..ac3a6b7 100644
--- a/src/Actions/FFDamageExecutor.cs
+++ b/src/Actions/FFDamageExecutor.cs
@@ -1,56 +1,56 @@
-using Cthangover.Core.Cards.StatusEffect;
-using Cthangover.Core.Characters;
-using Cthangover.Core.Utils;
-using Godot;
-
-namespace Cthangover.FFBattle.Actions
-{
- ///
- /// Executes a physical attack action. Calculates damage as
- /// action.Attack × user.Attack, then routes through
- /// and
- /// for status-effect
- /// hooks. Subtraction is applied defence-first:
- /// absorbs damage point-for-point before health is reduced.
- /// Registered under ID "physics/attack".
- ///
- public class FFDamageExecutor : ActionBase
- {
- /// Hub registry key for this executor.
- public override string ID => "FFDamageExecutor";
-
- /// Applies attack damage with status hooks and defence absorption; consumes the action's required points.
- 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);
- StatusEffectService.Of(target).OnTakeDamage(user, ref damageDelta);
- StatusEffectService.Of(user).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 }
- };
- }
- }
-}
+using Cthangover.Core.Cards.StatusEffect;
+using Cthangover.Core.Characters;
+using Cthangover.Core.Utils;
+using Godot;
+
+namespace Cthangover.FFBattle.Actions
+{
+ ///
+ /// Executes a physical attack action. Calculates damage as
+ /// action.Attack × user.Attack, then routes through
+ /// and
+ /// for status-effect
+ /// hooks. Subtraction is applied defence-first:
+ /// absorbs damage point-for-point before health is reduced.
+ /// Registered under ID "physics/attack".
+ ///
+ public class FFDamageExecutor : ActionBase
+ {
+ /// Hub registry key for this executor.
+ public override string ID => "FFDamageExecutor";
+
+ /// Applies attack damage with status hooks and defence absorption; consumes the action's required points.
+ 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);
+ StatusEffectService.Of(target).OnTakeDamage(user, ref damageDelta);
+ StatusEffectService.Of(user).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 }
+ };
+ }
+ }
+}
diff --git a/src/Actions/FFDefenceExecutor.cs b/src/Actions/FFDefenceExecutor.cs
index 1717388..d4e1007 100644
--- a/src/Actions/FFDefenceExecutor.cs
+++ b/src/Actions/FFDefenceExecutor.cs
@@ -1,34 +1,34 @@
-using Cthangover.Core.Characters;
-using Cthangover.Core.Utils;
-
-namespace Cthangover.FFBattle.Actions
-{
- ///
- /// Executes a defensive action (buff/barrier). Increases the target's
- /// by the value stored in
- /// the "Defence" property on the action (default 4).
- /// Defence acts as a shield against incoming physical damage in the
- /// . Registered under ID "physics/defence".
- ///
- public class FFDefenceExecutor : ActionBase
- {
- /// Hub registry key for this executor.
- public override string ID => "FFDefenceExecutor";
-
- /// Adds the action's "Defence" value (default 4) to the target's defence shield.
- 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 } };
- }
- }
-}
+using Cthangover.Core.Characters;
+using Cthangover.Core.Utils;
+
+namespace Cthangover.FFBattle.Actions
+{
+ ///
+ /// Executes a defensive action (buff/barrier). Increases the target's
+ /// by the value stored in
+ /// the "Defence" property on the action (default 4).
+ /// Defence acts as a shield against incoming physical damage in the
+ /// . Registered under ID "physics/defence".
+ ///
+ public class FFDefenceExecutor : ActionBase
+ {
+ /// Hub registry key for this executor.
+ public override string ID => "FFDefenceExecutor";
+
+ /// Adds the action's "Defence" value (default 4) to the target's defence shield.
+ 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 } };
+ }
+ }
+}
diff --git a/src/Actions/FFStunExecutor.cs b/src/Actions/FFStunExecutor.cs
index 4f0eab3..f746b13 100644
--- a/src/Actions/FFStunExecutor.cs
+++ b/src/Actions/FFStunExecutor.cs
@@ -1,37 +1,37 @@
-using Cthangover.Core.Cards.StatusEffect;
-using Cthangover.Core.Characters;
-using Cthangover.Core.Utils;
-
-namespace Cthangover.FFBattle.Actions
-{
- ///
- /// Applies a stun status effect to the target for a configurable number of turns.
- /// Reads the turn count from the "Turn" property on the action (default 3).
- /// Stunned characters are skipped during turn iteration in
- /// and
- /// . Registered under ID "physics/stun".
- ///
- public class FFStunExecutor : ActionBase
- {
- /// Hub registry key for this executor.
- public override string ID => "FFStunExecutor";
-
- /// Adds the stun effect for the action's "Turn" count (default 3); stun-immune targets consume the points but stay un-stunned.
- 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);
- StatusEffectService.Of(target).Add("effect/physics/stun", turns);
- GameLogger.Log("FF_BATTLE",
- $"{user.Name} stuns {target.Name} for {turns} turns",
- LogLevel.Debug);
-
- return new ChangedAttributes { Result = true };
- }
- }
-}
+using Cthangover.Core.Cards.StatusEffect;
+using Cthangover.Core.Characters;
+using Cthangover.Core.Utils;
+
+namespace Cthangover.FFBattle.Actions
+{
+ ///
+ /// Applies a stun status effect to the target for a configurable number of turns.
+ /// Reads the turn count from the "Turn" property on the action (default 3).
+ /// Stunned characters are skipped during turn iteration in
+ /// and
+ /// . Registered under ID "physics/stun".
+ ///
+ public class FFStunExecutor : ActionBase
+ {
+ /// Hub registry key for this executor.
+ public override string ID => "FFStunExecutor";
+
+ /// Adds the stun effect for the action's "Turn" count (default 3); stun-immune targets consume the points but stay un-stunned.
+ 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);
+ StatusEffectService.Of(target).Add("effect/physics/stun", turns);
+ GameLogger.Log("FF_BATTLE",
+ $"{user.Name} stuns {target.Name} for {turns} turns",
+ LogLevel.Debug);
+
+ return new ChangedAttributes { Result = true };
+ }
+ }
+}
diff --git a/src/FFBattleCore.cs b/src/FFBattleCore.cs
index 8d01ede..d451014 100644
--- a/src/FFBattleCore.cs
+++ b/src/FFBattleCore.cs
@@ -1,1151 +1,1151 @@
-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.Cards.StatusEffect;
-using Cthangover.Core.Characters;
-using Cthangover.Core.Items;
-using Cthangover.Core.Settings;
-using Cthangover.Core.Scenes;
-using Cthangover.Core.UI;
-using Cthangover.Core.UI.CharacterPanel;
-using Cthangover.Core.Utils;
-using Cthangover.FFBattle.UI;
-using Cthangover.Core.UI.Theming;
-using Godot;
-
-namespace Cthangover.FFBattle
-{
- ///
- /// 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 ,
- /// , , and
- /// for UI interaction. Registered via
- /// as "ff_battle".
- ///
- public class FFBattleCore : IBattleCore, IBattleAutoPlayable
- {
- /// Unique identifier for this battle system implementation, used by the core engine to locate it.
- public string Id => "ff_battle";
- /// Provides the set of action executors (damage, defence, stun, item) registered for this battle system.
- 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 FFActionDetailPanel _detailPanel;
- private FFBattleHud _hud;
- private FFBattleLogPanel _logPanel;
- private FFBattleBanner _banner;
- private FFBattleTargetHint _targetHint;
- 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 _onTargetSelected;
- private int _round;
- private int _detailKind;
-
- private const int DetailMain = 0;
- private const int DetailActions = 1;
- private const int DetailItems = 2;
-
- private const float PLAYER_SCALE = 0.85f;
-
- ///
- /// Initialises the battle UI panels, controller, and event wiring. Copies enemy
- /// instances via Copy() so mutations during battle
- /// do not affect the original data. Registers the
- /// with and constructs all UI widgets inside
- /// 's root node.
- ///
- /// The player party characters, consumed directly (not copied).
- /// Enemy characters — each is shallow-copied for battle isolation.
- /// Battle context providing root node and end-battle callback.
- 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("Panel") ?? root;
- _toolPanel = root.GetNodeOrNull("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);
-
- _detailPanel = (FFActionDetailPanel)TscnScenes.LoadAndBuild("scenes/ff_action_detail_panel.tscn");
- _detailPanel.Name = "FFActionDetailPanel";
- _detailPanel.EnsureConstructed();
- _detailPanel.HidePassport();
- panel.AddChild(_detailPanel);
-
- _hud = (FFBattleHud)TscnScenes.LoadAndBuild("scenes/ff_battle_hud.tscn");
- _hud.Name = "FFBattleHud";
- _hud.EnsureConstructed();
- panel.AddChild(_hud);
-
- _logPanel = (FFBattleLogPanel)TscnScenes.LoadAndBuild("scenes/ff_battle_log_panel.tscn");
- _logPanel.Name = "FFBattleLogPanel";
- _logPanel.EnsureConstructed();
- panel.AddChild(_logPanel);
-
- _banner = (FFBattleBanner)TscnScenes.LoadAndBuild("scenes/ff_battle_banner.tscn");
- _banner.Name = "FFBattleBanner";
- _banner.EnsureConstructed();
- panel.AddChild(_banner);
-
- _targetHint = (FFBattleTargetHint)TscnScenes.LoadAndBuild("scenes/ff_battle_target_hint.tscn");
- _targetHint.Name = "FFBattleTargetHint";
- _targetHint.EnsureConstructed();
- _targetHint.HideHint();
- panel.AddChild(_targetHint);
-
- _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.OnSelectionChanged += OnMenuSelectionChanged;
- _menuPanel.OnCancelled += OnMenuCancel;
- }
-
- ///
- /// Begins the battle sequence. Calculates layout positions and scales for
- /// player/enemy panels based on viewport size. Subscribes to
- /// for cleanup.
- /// Immediately transitions into the first player turn.
- ///
- 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);
-
- _detailPanel.Position = new Vector2(viewportSize.X * 0.66f - 320f, viewportSize.Y * 0.58f);
- _detailPanel.Size = new Vector2(300f, 190f);
-
- _hud.Position = new Vector2(26f, 20f);
-
- _logPanel.Position = new Vector2(viewportSize.X - 466f, 64f);
- _logPanel.Size = new Vector2(440f, 440f);
-
- _banner.Position = new Vector2(0f, viewportSize.Y * 0.43f);
- _banner.Size = new Vector2(viewportSize.X, 70f);
-
- _targetHint.Position = new Vector2(0f, viewportSize.Y * 0.48f);
- _targetHint.Size = new Vector2(viewportSize.X, 40f);
-
- 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;
- _round = 0;
- UpdateHud();
- _logPanel.AddEntry(Translate("ff_battle/log_battle_start"), FFBattleLogKind.Turn);
- 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();
- _detailPanel.HidePassport();
- _targetHint.HideHint();
- BattleSceneContext.Instance.ActiveCharacter = null;
-
- foreach (var widget in _playerPanel.Widgets)
- {
- if (!widget.IsDead)
- {
- StatusEffectService.Of(widget.Card).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();
-
- _round += 1;
- UpdateHud();
- _hud.SetPhase(true);
- _banner.Show(Translate("ff_battle/banner_player"));
- _logPanel.SetRound(_round);
- _logPanel.AddEntry(string.Format(Translate("ff_battle/log_turn_player"), _round), FFBattleLogKind.Turn);
- }
-
- 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();
- _targetHint.HideHint();
-
- 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
- {
- 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;
- _detailKind = DetailMain;
- _menuPanel.ShowMenu(entries, string.Format(Translate("ff_battle/menu_caption"),
- ActorName(), _currentCharacter.Card.Attributes.Point.Value, _currentCharacter.Card.Attributes.Point.BaseValue));
- _controller.State = FFBattleState.MenuOpen;
-
- foreach (var widget in _playerPanel.Widgets)
- widget.ClearHighlight();
- _currentCharacter?.Highlight(UiTokens.Accent);
- }
-
- 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();
- 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
- });
-
- _detailKind = DetailActions;
- _menuPanel.ShowMenu(entries, ActorName() + " · " + Translate("ff_battle/menu_actions"));
- _controller.State = FFBattleState.SubMenuOpen;
- }
-
- private void ShowItemSubMenu()
- {
- var inventory = GameData.Instance.Runtime.Inventory;
- var entries = new List();
-
- 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
- });
-
- _detailKind = DetailItems;
- _menuPanel.ShowMenu(entries, ActorName() + " · " + Translate("ff_battle/menu_items"));
- _controller.State = FFBattleState.SubMenuOpen;
- }
-
- private void StartTargetSelect(bool targetAlly)
- {
- _targetSelectMode = true;
- _targetAlly = targetAlly;
- _menuPanel.HideMenu();
- _controller.State = FFBattleState.TargetSelect;
- _targetHint.ShowHint(Translate(targetAlly ? "ff_battle/hint_ally" : "ff_battle/hint_target"));
-
- 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(UiTokens.Success);
- }
- else
- {
- foreach (var widget in _enemyPanel.Widgets)
- if (!widget.IsDead)
- widget.Highlight(UiTokens.Danger);
- }
-
- _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();
- _targetHint.HideHint();
-
- 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();
- _targetHint.HideHint();
-
- ExecuteMenuAction(widget);
- }
-
- private async void ExecuteMenuAction(FFCharacterWidget target)
- {
- if (_currentMenuAction == null)
- return;
-
- _controller.State = FFBattleState.AnimationPlaying;
- _endTurnButton.Visible = false;
- _detailPanel.HidePassport();
- _targetHint.HideHint();
-
- var source = _currentCharacter;
- var actionName = string.Empty;
- var hpBefore = target.Card.Attributes.Health.Value;
- var defBefore = target.Card.Attributes.Defence.Value;
- var stunBefore = CountStunTurns(target.Card);
-
- switch (_currentMenuAction.Key)
- {
- case "action":
- {
- var action = _currentMenuAction.Data as ActionCharacter;
- actionName = TranslationServer.Translate(action.Name).ToString();
- LogActionDeclared(source, actionName, target);
- 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;
- actionName = TranslationServer.Translate(item.Name).ToString();
- LogActionDeclared(source, actionName, target);
- var dummyAction = new ActionCharacter { ID = "ff/item" };
- var anim = new Actions.FFItemAnimation(_currentCharacter, target, dummyAction, item);
- await RunAnimation(anim);
- break;
- }
- }
-
- LogOutcome(source, target, actionName, hpBefore, defBefore, stunBefore);
-
- _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();
- _detailPanel.HidePassport();
- _controller.State = FFBattleState.Idle;
-
- var escapeRoll = (float)GD.RandRange(0, 100);
- if (escapeRoll < 70f)
- {
- GameLogger.Log("FF_BATTLE", "Escape succeeded", LogLevel.Debug);
- _banner.Show(Translate("ff_battle/escape_success"));
- _logPanel.AddEntry(Translate("ff_battle/log_escape_success"), FFBattleLogKind.Turn);
- _ctx.EndBattle(BattleSide.Player);
- }
- else
- {
- GameLogger.Log("FF_BATTLE", "Escape failed", LogLevel.Debug);
- _currentCharacter.Card.Attributes.Point.Value -= 1;
- _currentCharacter.UpdateInfo();
- _banner.Show(Translate("ff_battle/escape_fail"));
- _logPanel.AddEntry(string.Format(Translate("ff_battle/log_escape_failed"), NameOf(_currentCharacter)), FFBattleLogKind.Status);
- _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();
- _targetHint.HideHint();
- ShowMainMenu();
- return;
- }
-
- if (_controller.State == FFBattleState.SubMenuOpen)
- {
- ShowMainMenu();
- return;
- }
-
- _menuPanel.HideMenu();
- _detailPanel.HidePassport();
- _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 (StatusEffectService.Of(widget.Card).HasStun())
- {
- GameLogger.Log("FF_BATTLE", $"{widget.Card.Name} is stunned, skipping", LogLevel.Debug);
- widget.Card.Attributes.Point.Value = 0;
- widget.UpdateInfo();
- _logPanel.AddEntry(string.Format(Translate("ff_battle/log_stun_skip"), NameOf(widget)), FFBattleLogKind.Status);
- continue;
- }
-
- _currentCharacter = widget;
- BattleSceneContext.Instance.ActiveCharacter = widget.Card;
- ShowMainMenu();
- return;
- }
-
- _logPanel.AddEntry(Translate("ff_battle/log_no_points"), FFBattleLogKind.Info);
- OnPlayerTurnEnd();
- }
-
- ///
- /// Ends the player's turn, hides all UI, and transitions to the enemy turn.
- /// Runs a brief delay before invoking RunEnemyTurn() which iterates
- /// over all alive enemies, each picking a random
- /// 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 .
- /// Otherwise starts a new player turn.
- ///
- public async void OnPlayerTurnEnd()
- {
- GameLogger.Log("FF_BATTLE", "Player turn ended", LogLevel.Debug);
-
- _isPlayerTurn = false;
- _endTurnButton.Visible = false;
- _menuPanel.HideMenu();
- _detailPanel.HidePassport();
- _targetHint.HideHint();
- _controller.State = FFBattleState.AnimationPlaying;
- BattleSceneContext.Instance.IsWait = true;
-
- foreach (var widget in _playerPanel.Widgets)
- widget.ClearHighlight();
- foreach (var widget in _enemyPanel.Widgets)
- widget.ClearHighlight();
-
- _hud.SetPhase(false);
- _banner.Show(Translate("ff_battle/banner_enemy"));
- _logPanel.AddEntry(string.Format(Translate("ff_battle/log_turn_enemy"), _round), FFBattleLogKind.Turn);
-
- 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;
-
- StatusEffectService.Of(enemyWidget.Card).OnTurnStart();
- enemyWidget.UpdateInfo();
-
- if (StatusEffectService.Of(enemyWidget.Card).HasStun())
- {
- GameLogger.Log("FF_BATTLE", $"{enemyWidget.Card.Name} is stunned, skipping", LogLevel.Debug);
- _logPanel.AddEntry(string.Format(Translate("ff_battle/log_stun_skip"), NameOf(enemyWidget)), FFBattleLogKind.Status);
- 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 actionName = TranslationServer.Translate(action.Name).ToString();
- LogActionDeclared(enemyWidget, actionName, target);
- var hpBefore = target.Card.Attributes.Health.Value;
- var defBefore = target.Card.Attributes.Defence.Value;
- var stunBefore = CountStunTurns(target.Card);
-
- var anim = CreateEnemyAnimation(enemyWidget, target, action);
- if (anim != null)
- {
- anim.DoStart();
- while (!anim.DoAction())
- await root.ToSignal(root.GetTree(), "process_frame");
- anim.DoEnd();
- }
-
- LogOutcome(enemyWidget, target, actionName, hpBefore, defBefore, stunBefore);
-
- 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);
- }
-
- UpdateHudCounts();
-
- 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();
- _detailPanel?.HidePassport();
- _targetHint?.HideHint();
- _logPanel?.Clear();
- if (_endTurnButton != null)
- _endTurnButton.Visible = false;
- }
-
- private void OnMenuSelectionChanged(int index)
- {
- if (_menuPanel.Entries == null || index < 0 || index >= _menuPanel.Entries.Count)
- {
- _detailPanel.HidePassport();
- return;
- }
-
- var entry = _menuPanel.Entries[index];
- _detailPanel.ShowPassport();
-
- if (_detailKind == DetailActions)
- {
- if (entry.Data is ActionCharacter action)
- _detailPanel.ShowAction(action, BuildActionNote(action));
- else
- _detailPanel.ShowHint(Translate("ff_battle/detail_back"));
- return;
- }
-
- if (_detailKind == DetailItems)
- {
- if (entry.Data is IItem item)
- _detailPanel.ShowItem(item, item.ItemType.HasFlag(ItemType.TargetUsed), BuildItemNote(item));
- else
- _detailPanel.ShowHint(Translate("ff_battle/detail_back"));
- return;
- }
-
- _detailPanel.ShowHint(MainHintFor(entry.Key));
- }
-
- private string BuildActionNote(ActionCharacter action)
- {
- if (action.Properties == null)
- return TranslationServer.Translate("ff_battle/detail_action").ToString();
-
- var cost = action.GetInt("RequiredPoint", 1);
- var attack = action.GetFloat("Attack", 0f);
- if (attack > 0f)
- {
- var attacker = _currentCharacter?.Card?.Attributes?.Attack?.Value ?? 0;
- return string.Format(Translate("ff_battle/detail_damage"),
- Mathf.RoundToInt(attack * attacker), attacker, attack.ToString("0.0"), cost);
- }
-
- var turns = action.GetInt("Turn", 0);
- if (turns > 0)
- return string.Format(Translate("ff_battle/detail_stun"), turns, cost);
-
- var defence = action.GetInt("Defence", 0);
- if (defence > 0)
- return string.Format(Translate("ff_battle/detail_defence"), defence, cost);
-
- var description = ActionCharacterUi.StripMarkup(ActionCharacterUi.Translate(action.Description));
- return !string.IsNullOrEmpty(description) ? description : Translate("ff_battle/detail_action");
- }
-
- private static string BuildItemNote(IItem item)
- {
- var description = ActionCharacterUi.StripMarkup(ActionCharacterUi.Translate(item.Description));
- return !string.IsNullOrEmpty(description)
- ? description
- : TranslationServer.Translate("ff_battle/detail_item").ToString();
- }
-
- private static string MainHintFor(string key)
- {
- switch (key)
- {
- case "attack": return TranslationServer.Translate("ff_battle/detail_attack").ToString();
- case "items": return TranslationServer.Translate("ff_battle/detail_items").ToString();
- case "defend": return TranslationServer.Translate("ff_battle/detail_defend").ToString();
- case "escape": return TranslationServer.Translate("ff_battle/detail_escape").ToString();
- default: return TranslationServer.Translate("ff_battle/detail_back").ToString();
- }
- }
-
- private void LogActionDeclared(FFCharacterWidget source, string actionName, FFCharacterWidget target)
- {
- var entry = source == target
- ? string.Format(Translate("ff_battle/log_action_self"), NameOf(source), actionName)
- : string.Format(Translate("ff_battle/log_action"), NameOf(source), actionName, NameOf(target));
- _logPanel.AddEntry(entry, FFBattleLogKind.Info);
- }
-
- private void LogOutcome(FFCharacterWidget source, FFCharacterWidget target, string actionName,
- int hpBefore, int defBefore, int stunBefore)
- {
- var hpAfter = target.Card.Attributes.Health.Value;
- var defAfter = target.Card.Attributes.Defence.Value;
- var stunAfter = CountStunTurns(target.Card);
-
- if (defAfter > defBefore)
- _logPanel.AddEntry(string.Format(Translate("ff_battle/log_defence"),
- NameOf(target), defAfter - defBefore, defAfter), FFBattleLogKind.Buff);
-
- var shieldLost = defBefore - defAfter;
- if (shieldLost > 0)
- _logPanel.AddEntry(string.Format(Translate("ff_battle/log_shield"),
- NameOf(target), shieldLost, defAfter), FFBattleLogKind.Shield);
-
- if (stunAfter > stunBefore)
- _logPanel.AddEntry(string.Format(Translate("ff_battle/log_stun"),
- NameOf(target), stunAfter), FFBattleLogKind.Status);
-
- if (hpAfter > hpBefore)
- _logPanel.AddEntry(string.Format(Translate("ff_battle/log_heal"),
- actionName, NameOf(target), hpAfter - hpBefore), FFBattleLogKind.Buff);
-
- if (hpAfter < hpBefore)
- _logPanel.AddEntry(string.Format(Translate("ff_battle/log_damage"),
- actionName, NameOf(source), NameOf(target), hpBefore - hpAfter), FFBattleLogKind.Damage);
-
- if (hpAfter <= 0 && hpBefore > 0)
- _logPanel.AddEntry(string.Format(Translate("ff_battle/log_death"), NameOf(target)), FFBattleLogKind.Dead);
-
- UpdateHudCounts();
- }
-
- private static int CountStunTurns(Character character)
- {
- var stun = StatusEffectService.Of(character)?.ActiveStatusEffects?
- .FirstOrDefault(effect => effect.EffectType == StatusEffectType.Stun);
- return stun?.RemainingTurns ?? 0;
- }
-
- private string ActorName()
- => _currentCharacter?.Card == null
- ? string.Empty
- : TranslationServer.Translate(_currentCharacter.Card.Name).ToString();
-
- private static string NameOf(FFCharacterWidget widget)
- => widget?.Card == null
- ? string.Empty
- : TranslationServer.Translate(widget.Card.Name).ToString();
-
- private void UpdateHud()
- {
- if (_hud == null)
- return;
- _hud.SetRound(_round);
- UpdateHudCounts();
- }
-
- private void UpdateHudCounts()
- {
- if (_hud == null)
- return;
- _hud.SetCounts(
- _enemyPanel.Widgets.Count(w => !w.IsDead),
- _playerPanel.Widgets.Count(w => !w.IsDead));
- }
-
- private static string Translate(string key)
- => TranslationServer.Translate(key).ToString();
-
- ///
- /// true while the core accepts a player turn advance; flips to
- /// false synchronously at the start of .
- ///
- public bool IsWaitingForPlayerInput => _isPlayerTurn;
-
- ///
- /// Automation hook: ends the player's turn through the same path the
- /// end-turn button uses.
- ///
- public void AdvancePlayerTurn() => OnPlayerTurnEnd();
- }
-}
-
-
-
+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.Cards.StatusEffect;
+using Cthangover.Core.Characters;
+using Cthangover.Core.Items;
+using Cthangover.Core.Settings;
+using Cthangover.Core.Scenes;
+using Cthangover.Core.UI;
+using Cthangover.Core.UI.CharacterPanel;
+using Cthangover.Core.Utils;
+using Cthangover.FFBattle.UI;
+using Cthangover.Core.UI.Theming;
+using Godot;
+
+namespace Cthangover.FFBattle
+{
+ ///
+ /// 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 ,
+ /// , , and
+ /// for UI interaction. Registered via
+ /// as "ff_battle".
+ ///
+ public class FFBattleCore : IBattleCore, IBattleAutoPlayable
+ {
+ /// Unique identifier for this battle system implementation, used by the core engine to locate it.
+ public string Id => "ff_battle";
+ /// Provides the set of action executors (damage, defence, stun, item) registered for this battle system.
+ 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 FFActionDetailPanel _detailPanel;
+ private FFBattleHud _hud;
+ private FFBattleLogPanel _logPanel;
+ private FFBattleBanner _banner;
+ private FFBattleTargetHint _targetHint;
+ 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 _onTargetSelected;
+ private int _round;
+ private int _detailKind;
+
+ private const int DetailMain = 0;
+ private const int DetailActions = 1;
+ private const int DetailItems = 2;
+
+ private const float PLAYER_SCALE = 0.85f;
+
+ ///
+ /// Initialises the battle UI panels, controller, and event wiring. Copies enemy
+ /// instances via Copy() so mutations during battle
+ /// do not affect the original data. Registers the
+ /// with and constructs all UI widgets inside
+ /// 's root node.
+ ///
+ /// The player party characters, consumed directly (not copied).
+ /// Enemy characters — each is shallow-copied for battle isolation.
+ /// Battle context providing root node and end-battle callback.
+ 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("Panel") ?? root;
+ _toolPanel = root.GetNodeOrNull("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);
+
+ _detailPanel = (FFActionDetailPanel)TscnScenes.LoadAndBuild("scenes/ff_action_detail_panel.tscn");
+ _detailPanel.Name = "FFActionDetailPanel";
+ _detailPanel.EnsureConstructed();
+ _detailPanel.HidePassport();
+ panel.AddChild(_detailPanel);
+
+ _hud = (FFBattleHud)TscnScenes.LoadAndBuild("scenes/ff_battle_hud.tscn");
+ _hud.Name = "FFBattleHud";
+ _hud.EnsureConstructed();
+ panel.AddChild(_hud);
+
+ _logPanel = (FFBattleLogPanel)TscnScenes.LoadAndBuild("scenes/ff_battle_log_panel.tscn");
+ _logPanel.Name = "FFBattleLogPanel";
+ _logPanel.EnsureConstructed();
+ panel.AddChild(_logPanel);
+
+ _banner = (FFBattleBanner)TscnScenes.LoadAndBuild("scenes/ff_battle_banner.tscn");
+ _banner.Name = "FFBattleBanner";
+ _banner.EnsureConstructed();
+ panel.AddChild(_banner);
+
+ _targetHint = (FFBattleTargetHint)TscnScenes.LoadAndBuild("scenes/ff_battle_target_hint.tscn");
+ _targetHint.Name = "FFBattleTargetHint";
+ _targetHint.EnsureConstructed();
+ _targetHint.HideHint();
+ panel.AddChild(_targetHint);
+
+ _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.OnSelectionChanged += OnMenuSelectionChanged;
+ _menuPanel.OnCancelled += OnMenuCancel;
+ }
+
+ ///
+ /// Begins the battle sequence. Calculates layout positions and scales for
+ /// player/enemy panels based on viewport size. Subscribes to
+ /// for cleanup.
+ /// Immediately transitions into the first player turn.
+ ///
+ 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);
+
+ _detailPanel.Position = new Vector2(viewportSize.X * 0.66f - 320f, viewportSize.Y * 0.58f);
+ _detailPanel.Size = new Vector2(300f, 190f);
+
+ _hud.Position = new Vector2(26f, 20f);
+
+ _logPanel.Position = new Vector2(viewportSize.X - 466f, 64f);
+ _logPanel.Size = new Vector2(440f, 440f);
+
+ _banner.Position = new Vector2(0f, viewportSize.Y * 0.43f);
+ _banner.Size = new Vector2(viewportSize.X, 70f);
+
+ _targetHint.Position = new Vector2(0f, viewportSize.Y * 0.48f);
+ _targetHint.Size = new Vector2(viewportSize.X, 40f);
+
+ 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;
+ _round = 0;
+ UpdateHud();
+ _logPanel.AddEntry(Translate("ff_battle/log_battle_start"), FFBattleLogKind.Turn);
+ 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();
+ _detailPanel.HidePassport();
+ _targetHint.HideHint();
+ BattleSceneContext.Instance.ActiveCharacter = null;
+
+ foreach (var widget in _playerPanel.Widgets)
+ {
+ if (!widget.IsDead)
+ {
+ StatusEffectService.Of(widget.Card).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();
+
+ _round += 1;
+ UpdateHud();
+ _hud.SetPhase(true);
+ _banner.Show(Translate("ff_battle/banner_player"));
+ _logPanel.SetRound(_round);
+ _logPanel.AddEntry(string.Format(Translate("ff_battle/log_turn_player"), _round), FFBattleLogKind.Turn);
+ }
+
+ 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();
+ _targetHint.HideHint();
+
+ 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
+ {
+ 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;
+ _detailKind = DetailMain;
+ _menuPanel.ShowMenu(entries, string.Format(Translate("ff_battle/menu_caption"),
+ ActorName(), _currentCharacter.Card.Attributes.Point.Value, _currentCharacter.Card.Attributes.Point.BaseValue));
+ _controller.State = FFBattleState.MenuOpen;
+
+ foreach (var widget in _playerPanel.Widgets)
+ widget.ClearHighlight();
+ _currentCharacter?.Highlight(UiTokens.Accent);
+ }
+
+ 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();
+ 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
+ });
+
+ _detailKind = DetailActions;
+ _menuPanel.ShowMenu(entries, ActorName() + " · " + Translate("ff_battle/menu_actions"));
+ _controller.State = FFBattleState.SubMenuOpen;
+ }
+
+ private void ShowItemSubMenu()
+ {
+ var inventory = GameData.Instance.Runtime.Inventory;
+ var entries = new List();
+
+ 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
+ });
+
+ _detailKind = DetailItems;
+ _menuPanel.ShowMenu(entries, ActorName() + " · " + Translate("ff_battle/menu_items"));
+ _controller.State = FFBattleState.SubMenuOpen;
+ }
+
+ private void StartTargetSelect(bool targetAlly)
+ {
+ _targetSelectMode = true;
+ _targetAlly = targetAlly;
+ _menuPanel.HideMenu();
+ _controller.State = FFBattleState.TargetSelect;
+ _targetHint.ShowHint(Translate(targetAlly ? "ff_battle/hint_ally" : "ff_battle/hint_target"));
+
+ 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(UiTokens.Success);
+ }
+ else
+ {
+ foreach (var widget in _enemyPanel.Widgets)
+ if (!widget.IsDead)
+ widget.Highlight(UiTokens.Danger);
+ }
+
+ _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();
+ _targetHint.HideHint();
+
+ 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();
+ _targetHint.HideHint();
+
+ ExecuteMenuAction(widget);
+ }
+
+ private async void ExecuteMenuAction(FFCharacterWidget target)
+ {
+ if (_currentMenuAction == null)
+ return;
+
+ _controller.State = FFBattleState.AnimationPlaying;
+ _endTurnButton.Visible = false;
+ _detailPanel.HidePassport();
+ _targetHint.HideHint();
+
+ var source = _currentCharacter;
+ var actionName = string.Empty;
+ var hpBefore = target.Card.Attributes.Health.Value;
+ var defBefore = target.Card.Attributes.Defence.Value;
+ var stunBefore = CountStunTurns(target.Card);
+
+ switch (_currentMenuAction.Key)
+ {
+ case "action":
+ {
+ var action = _currentMenuAction.Data as ActionCharacter;
+ actionName = TranslationServer.Translate(action.Name).ToString();
+ LogActionDeclared(source, actionName, target);
+ 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;
+ actionName = TranslationServer.Translate(item.Name).ToString();
+ LogActionDeclared(source, actionName, target);
+ var dummyAction = new ActionCharacter { ID = "ff/item" };
+ var anim = new Actions.FFItemAnimation(_currentCharacter, target, dummyAction, item);
+ await RunAnimation(anim);
+ break;
+ }
+ }
+
+ LogOutcome(source, target, actionName, hpBefore, defBefore, stunBefore);
+
+ _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();
+ _detailPanel.HidePassport();
+ _controller.State = FFBattleState.Idle;
+
+ var escapeRoll = (float)GD.RandRange(0, 100);
+ if (escapeRoll < 70f)
+ {
+ GameLogger.Log("FF_BATTLE", "Escape succeeded", LogLevel.Debug);
+ _banner.Show(Translate("ff_battle/escape_success"));
+ _logPanel.AddEntry(Translate("ff_battle/log_escape_success"), FFBattleLogKind.Turn);
+ _ctx.EndBattle(BattleSide.Player);
+ }
+ else
+ {
+ GameLogger.Log("FF_BATTLE", "Escape failed", LogLevel.Debug);
+ _currentCharacter.Card.Attributes.Point.Value -= 1;
+ _currentCharacter.UpdateInfo();
+ _banner.Show(Translate("ff_battle/escape_fail"));
+ _logPanel.AddEntry(string.Format(Translate("ff_battle/log_escape_failed"), NameOf(_currentCharacter)), FFBattleLogKind.Status);
+ _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();
+ _targetHint.HideHint();
+ ShowMainMenu();
+ return;
+ }
+
+ if (_controller.State == FFBattleState.SubMenuOpen)
+ {
+ ShowMainMenu();
+ return;
+ }
+
+ _menuPanel.HideMenu();
+ _detailPanel.HidePassport();
+ _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 (StatusEffectService.Of(widget.Card).HasStun())
+ {
+ GameLogger.Log("FF_BATTLE", $"{widget.Card.Name} is stunned, skipping", LogLevel.Debug);
+ widget.Card.Attributes.Point.Value = 0;
+ widget.UpdateInfo();
+ _logPanel.AddEntry(string.Format(Translate("ff_battle/log_stun_skip"), NameOf(widget)), FFBattleLogKind.Status);
+ continue;
+ }
+
+ _currentCharacter = widget;
+ BattleSceneContext.Instance.ActiveCharacter = widget.Card;
+ ShowMainMenu();
+ return;
+ }
+
+ _logPanel.AddEntry(Translate("ff_battle/log_no_points"), FFBattleLogKind.Info);
+ OnPlayerTurnEnd();
+ }
+
+ ///
+ /// Ends the player's turn, hides all UI, and transitions to the enemy turn.
+ /// Runs a brief delay before invoking RunEnemyTurn() which iterates
+ /// over all alive enemies, each picking a random
+ /// 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 .
+ /// Otherwise starts a new player turn.
+ ///
+ public async void OnPlayerTurnEnd()
+ {
+ GameLogger.Log("FF_BATTLE", "Player turn ended", LogLevel.Debug);
+
+ _isPlayerTurn = false;
+ _endTurnButton.Visible = false;
+ _menuPanel.HideMenu();
+ _detailPanel.HidePassport();
+ _targetHint.HideHint();
+ _controller.State = FFBattleState.AnimationPlaying;
+ BattleSceneContext.Instance.IsWait = true;
+
+ foreach (var widget in _playerPanel.Widgets)
+ widget.ClearHighlight();
+ foreach (var widget in _enemyPanel.Widgets)
+ widget.ClearHighlight();
+
+ _hud.SetPhase(false);
+ _banner.Show(Translate("ff_battle/banner_enemy"));
+ _logPanel.AddEntry(string.Format(Translate("ff_battle/log_turn_enemy"), _round), FFBattleLogKind.Turn);
+
+ 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;
+
+ StatusEffectService.Of(enemyWidget.Card).OnTurnStart();
+ enemyWidget.UpdateInfo();
+
+ if (StatusEffectService.Of(enemyWidget.Card).HasStun())
+ {
+ GameLogger.Log("FF_BATTLE", $"{enemyWidget.Card.Name} is stunned, skipping", LogLevel.Debug);
+ _logPanel.AddEntry(string.Format(Translate("ff_battle/log_stun_skip"), NameOf(enemyWidget)), FFBattleLogKind.Status);
+ 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 actionName = TranslationServer.Translate(action.Name).ToString();
+ LogActionDeclared(enemyWidget, actionName, target);
+ var hpBefore = target.Card.Attributes.Health.Value;
+ var defBefore = target.Card.Attributes.Defence.Value;
+ var stunBefore = CountStunTurns(target.Card);
+
+ var anim = CreateEnemyAnimation(enemyWidget, target, action);
+ if (anim != null)
+ {
+ anim.DoStart();
+ while (!anim.DoAction())
+ await root.ToSignal(root.GetTree(), "process_frame");
+ anim.DoEnd();
+ }
+
+ LogOutcome(enemyWidget, target, actionName, hpBefore, defBefore, stunBefore);
+
+ 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);
+ }
+
+ UpdateHudCounts();
+
+ 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();
+ _detailPanel?.HidePassport();
+ _targetHint?.HideHint();
+ _logPanel?.Clear();
+ if (_endTurnButton != null)
+ _endTurnButton.Visible = false;
+ }
+
+ private void OnMenuSelectionChanged(int index)
+ {
+ if (_menuPanel.Entries == null || index < 0 || index >= _menuPanel.Entries.Count)
+ {
+ _detailPanel.HidePassport();
+ return;
+ }
+
+ var entry = _menuPanel.Entries[index];
+ _detailPanel.ShowPassport();
+
+ if (_detailKind == DetailActions)
+ {
+ if (entry.Data is ActionCharacter action)
+ _detailPanel.ShowAction(action, BuildActionNote(action));
+ else
+ _detailPanel.ShowHint(Translate("ff_battle/detail_back"));
+ return;
+ }
+
+ if (_detailKind == DetailItems)
+ {
+ if (entry.Data is IItem item)
+ _detailPanel.ShowItem(item, item.ItemType.HasFlag(ItemType.TargetUsed), BuildItemNote(item));
+ else
+ _detailPanel.ShowHint(Translate("ff_battle/detail_back"));
+ return;
+ }
+
+ _detailPanel.ShowHint(MainHintFor(entry.Key));
+ }
+
+ private string BuildActionNote(ActionCharacter action)
+ {
+ if (action.Properties == null)
+ return TranslationServer.Translate("ff_battle/detail_action").ToString();
+
+ var cost = action.GetInt("RequiredPoint", 1);
+ var attack = action.GetFloat("Attack", 0f);
+ if (attack > 0f)
+ {
+ var attacker = _currentCharacter?.Card?.Attributes?.Attack?.Value ?? 0;
+ return string.Format(Translate("ff_battle/detail_damage"),
+ Mathf.RoundToInt(attack * attacker), attacker, attack.ToString("0.0"), cost);
+ }
+
+ var turns = action.GetInt("Turn", 0);
+ if (turns > 0)
+ return string.Format(Translate("ff_battle/detail_stun"), turns, cost);
+
+ var defence = action.GetInt("Defence", 0);
+ if (defence > 0)
+ return string.Format(Translate("ff_battle/detail_defence"), defence, cost);
+
+ var description = ActionCharacterUi.StripMarkup(ActionCharacterUi.Translate(action.Description));
+ return !string.IsNullOrEmpty(description) ? description : Translate("ff_battle/detail_action");
+ }
+
+ private static string BuildItemNote(IItem item)
+ {
+ var description = ActionCharacterUi.StripMarkup(ActionCharacterUi.Translate(item.Description));
+ return !string.IsNullOrEmpty(description)
+ ? description
+ : TranslationServer.Translate("ff_battle/detail_item").ToString();
+ }
+
+ private static string MainHintFor(string key)
+ {
+ switch (key)
+ {
+ case "attack": return TranslationServer.Translate("ff_battle/detail_attack").ToString();
+ case "items": return TranslationServer.Translate("ff_battle/detail_items").ToString();
+ case "defend": return TranslationServer.Translate("ff_battle/detail_defend").ToString();
+ case "escape": return TranslationServer.Translate("ff_battle/detail_escape").ToString();
+ default: return TranslationServer.Translate("ff_battle/detail_back").ToString();
+ }
+ }
+
+ private void LogActionDeclared(FFCharacterWidget source, string actionName, FFCharacterWidget target)
+ {
+ var entry = source == target
+ ? string.Format(Translate("ff_battle/log_action_self"), NameOf(source), actionName)
+ : string.Format(Translate("ff_battle/log_action"), NameOf(source), actionName, NameOf(target));
+ _logPanel.AddEntry(entry, FFBattleLogKind.Info);
+ }
+
+ private void LogOutcome(FFCharacterWidget source, FFCharacterWidget target, string actionName,
+ int hpBefore, int defBefore, int stunBefore)
+ {
+ var hpAfter = target.Card.Attributes.Health.Value;
+ var defAfter = target.Card.Attributes.Defence.Value;
+ var stunAfter = CountStunTurns(target.Card);
+
+ if (defAfter > defBefore)
+ _logPanel.AddEntry(string.Format(Translate("ff_battle/log_defence"),
+ NameOf(target), defAfter - defBefore, defAfter), FFBattleLogKind.Buff);
+
+ var shieldLost = defBefore - defAfter;
+ if (shieldLost > 0)
+ _logPanel.AddEntry(string.Format(Translate("ff_battle/log_shield"),
+ NameOf(target), shieldLost, defAfter), FFBattleLogKind.Shield);
+
+ if (stunAfter > stunBefore)
+ _logPanel.AddEntry(string.Format(Translate("ff_battle/log_stun"),
+ NameOf(target), stunAfter), FFBattleLogKind.Status);
+
+ if (hpAfter > hpBefore)
+ _logPanel.AddEntry(string.Format(Translate("ff_battle/log_heal"),
+ actionName, NameOf(target), hpAfter - hpBefore), FFBattleLogKind.Buff);
+
+ if (hpAfter < hpBefore)
+ _logPanel.AddEntry(string.Format(Translate("ff_battle/log_damage"),
+ actionName, NameOf(source), NameOf(target), hpBefore - hpAfter), FFBattleLogKind.Damage);
+
+ if (hpAfter <= 0 && hpBefore > 0)
+ _logPanel.AddEntry(string.Format(Translate("ff_battle/log_death"), NameOf(target)), FFBattleLogKind.Dead);
+
+ UpdateHudCounts();
+ }
+
+ private static int CountStunTurns(Character character)
+ {
+ var stun = StatusEffectService.Of(character)?.ActiveStatusEffects?
+ .FirstOrDefault(effect => effect.EffectType == StatusEffectType.Stun);
+ return stun?.RemainingTurns ?? 0;
+ }
+
+ private string ActorName()
+ => _currentCharacter?.Card == null
+ ? string.Empty
+ : TranslationServer.Translate(_currentCharacter.Card.Name).ToString();
+
+ private static string NameOf(FFCharacterWidget widget)
+ => widget?.Card == null
+ ? string.Empty
+ : TranslationServer.Translate(widget.Card.Name).ToString();
+
+ private void UpdateHud()
+ {
+ if (_hud == null)
+ return;
+ _hud.SetRound(_round);
+ UpdateHudCounts();
+ }
+
+ private void UpdateHudCounts()
+ {
+ if (_hud == null)
+ return;
+ _hud.SetCounts(
+ _enemyPanel.Widgets.Count(w => !w.IsDead),
+ _playerPanel.Widgets.Count(w => !w.IsDead));
+ }
+
+ private static string Translate(string key)
+ => TranslationServer.Translate(key).ToString();
+
+ ///
+ /// true while the core accepts a player turn advance; flips to
+ /// false synchronously at the start of .
+ ///
+ public bool IsWaitingForPlayerInput => _isPlayerTurn;
+
+ ///
+ /// Automation hook: ends the player's turn through the same path the
+ /// end-turn button uses.
+ ///
+ public void AdvancePlayerTurn() => OnPlayerTurnEnd();
+ }
+}
+
+
+