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 { /// /// 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 { /// 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 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 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); _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; } /// /// 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); 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 { 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(); 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(); 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(); } /// /// 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(); _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; } } }