commit fe403c2dd61cc7ae1c66521756bb63eba93f3bb2 Author: iisaev Date: Thu Sep 10 10:59:37 2026 +0300 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c301ca8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Godot 4.x +.godot/ +.idea/ +sdk/ +bin/ +obj/ + +#ai +.omo/ +.opencode/ + +gen/ +obj/ +bin/ + +*.tres~ +*.tscn~ + +# GDScript LSP +.godot/ + +# Export +*.exe +*.dmg +*.apk +*.aab +*.pck +*.zip +*.log + +# OS +.DS_Store +Thumbs.db + +# Editor +*.import +*.godot.uid diff --git a/locale/ui_en.properties b/locale/ui_en.properties new file mode 100644 index 0000000..e38ad1e --- /dev/null +++ b/locale/ui_en.properties @@ -0,0 +1,14 @@ +mod_ff_battle_name = FFBattle +mod_ff_battle_desc = Final Fantasy style battle system + +ff_battle/menu_attack = Attack +ff_battle/menu_items = Items +ff_battle/menu_defend = Defend +ff_battle/menu_escape = Escape +ff_battle/end_turn = End turn +ff_battle/back = Back +ff_battle/no_items = No items +ff_battle/select_target = Select a target +ff_battle/escape_fail = Escape failed! +ff_battle/escape_success = You escaped! +ff_battle/no_actions = No actions diff --git a/locale/ui_ru.properties b/locale/ui_ru.properties new file mode 100644 index 0000000..a3570c6 --- /dev/null +++ b/locale/ui_ru.properties @@ -0,0 +1,14 @@ +mod_ff_battle_name=FF Битва +mod_ff_battle_desc=Боевая система в стиле Final Fantasy + +ff_battle/menu_attack=Атака +ff_battle/menu_items=Предметы +ff_battle/menu_defend=Защита +ff_battle/menu_escape=Побег +ff_battle/end_turn=Конец хода +ff_battle/back=Назад +ff_battle/no_items=Нет предметов +ff_battle/select_target=Выберите цель +ff_battle/escape_fail=Побег не удался! +ff_battle/escape_success=Вы сбежали! +ff_battle/no_actions=Нет действий diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..bbd4fc2 --- /dev/null +++ b/manifest.json @@ -0,0 +1,11 @@ +{ + "id": "ff_battle", + "name": "FFBattle", + "version": "1.0.0", + "author": "ct", + "description": "Final Fantasy style battle system", + "sources": [ + "src/**/*.cs" + ], + "depends": [ "core>=1", "interface>=1" ] +} diff --git a/mod.png b/mod.png new file mode 100644 index 0000000..2edfb90 Binary files /dev/null and b/mod.png differ diff --git a/scenes/ff_character_widget.tscn b/scenes/ff_character_widget.tscn new file mode 100644 index 0000000..49f25bf --- /dev/null +++ b/scenes/ff_character_widget.tscn @@ -0,0 +1,59 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="src/UI/FFCharacterWidget.cs" id="1_script"] + +[node name="FFCharacterWidget" type="Control" script=ExtResource("1_script")] +custom_minimum_size = Vector2(180, 260) +layout_mode = 3 + +[node name="Sprite" type="TextureRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +offset_left = 8.0 +offset_top = 8.0 +offset_right = -8.0 +offset_bottom = -44.0 +mouse_filter = 2 +expand_mode = 1 +stretch_mode = 5 + +[node name="HpBg" type="ColorRect" parent="."] +layout_mode = 0 +offset_left = 4.0 +offset_top = 218.0 +offset_right = 176.0 +offset_bottom = 236.0 +mouse_filter = 2 +color = Color(0.15, 0.15, 0.15, 1) + +[node name="HpFill" type="ColorRect" parent="."] +layout_mode = 0 +offset_left = 4.0 +offset_top = 218.0 +offset_right = 176.0 +offset_bottom = 236.0 +mouse_filter = 2 +color = Color(0.1, 0.85, 0.1, 1) + +[node name="NameLabel" type="Label" parent="."] +layout_mode = 0 +offset_left = 4.0 +offset_top = 238.0 +offset_right = 176.0 +offset_bottom = 256.0 +mouse_filter = 2 +horizontal_alignment = 1 +theme_override_font_sizes/font_size = 13 + +[node name="Selection" type="TextureRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 diff --git a/scenes/ff_menu_panel.tscn b/scenes/ff_menu_panel.tscn new file mode 100644 index 0000000..23979f1 --- /dev/null +++ b/scenes/ff_menu_panel.tscn @@ -0,0 +1,37 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="src/UI/FFMenuPanel.cs" id="1_script"] + +[node name="FFMenuPanel" type="Control" script=ExtResource("1_script")] +mouse_filter = 0 + +[node name="Background" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +color = Color(0.02, 0.05, 0.12, 0.88) + +[node name="Border" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +offset_left = -3.0 +offset_top = -3.0 +offset_right = 3.0 +offset_bottom = 3.0 +mouse_filter = 2 +color = Color(0.3, 0.5, 0.9, 0.9) + +[node name="Cursor" type="Label" parent="."] +text = "►" +mouse_filter = 2 +visible = false +theme_override_font_sizes/font_size = 18 +theme_override_colors/font_color = Color(1, 0.95, 0.7, 1) diff --git a/sounds/battle/ff_defence_empty.ogg b/sounds/battle/ff_defence_empty.ogg new file mode 100644 index 0000000..97c2baf Binary files /dev/null and b/sounds/battle/ff_defence_empty.ogg differ diff --git a/sounds/battle/ff_physics_damage.ogg b/sounds/battle/ff_physics_damage.ogg new file mode 100644 index 0000000..7337e84 Binary files /dev/null and b/sounds/battle/ff_physics_damage.ogg differ diff --git a/sounds/battle/ff_physics_defence.ogg b/sounds/battle/ff_physics_defence.ogg new file mode 100644 index 0000000..e30b214 Binary files /dev/null and b/sounds/battle/ff_physics_defence.ogg differ diff --git a/sounds/battle/ff_show_actions.ogg b/sounds/battle/ff_show_actions.ogg new file mode 100644 index 0000000..dba95d4 Binary files /dev/null and b/sounds/battle/ff_show_actions.ogg differ diff --git a/sounds/battle/ff_stun_electro.ogg b/sounds/battle/ff_stun_electro.ogg new file mode 100644 index 0000000..a018095 Binary files /dev/null and b/sounds/battle/ff_stun_electro.ogg differ diff --git a/src/Actions/FFAbstractAnimation.cs b/src/Actions/FFAbstractAnimation.cs new file mode 100644 index 0000000..3803642 --- /dev/null +++ b/src/Actions/FFAbstractAnimation.cs @@ -0,0 +1,83 @@ +using Cthangover.Core.Battle.Actions; +using Cthangover.Core.Characters; +using Cthangover.FFBattle.UI; +using Godot; + +namespace Cthangover.FFBattle.Actions +{ + /// + /// Abstract base for all FF battle animations. Implements a three-phase lifecycle + /// (, , ) driven + /// by the battle core's per-frame loop. Subclasses implement their animation logic + /// in DoInternalStart/DoInternalAction/DoInternalEnd and are instantiated + /// by when a player or enemy action is executed. + /// The multiplier scales all timing, used e.g. to speed up + /// enemy turn animations (0.8f). + /// + public abstract class FFAbstractAnimation : IBattleAction + { + /// Start time of the current animation phase, in seconds (microsecond precision). + protected double Timestamp { get; set; } = -1; + /// Animation speed multiplier; lower values produce faster animations. + protected float Speed { get; set; } = 1f; + /// The action descriptor being executed (determines which executor runs). + protected ActionCharacter Action { get; set; } + /// The character widget performing the action. + protected FFCharacterWidget Source { get; set; } + /// The character widget receiving the action. + protected FFCharacterWidget Target { get; set; } + /// World position of captured at . + protected Vector2 SourcePos { get; set; } + /// World position of captured at . + protected Vector2 TargetPos { get; set; } + + /// Creates an animation binding a source, target, and action with an optional speed override. + protected FFAbstractAnimation(FFCharacterWidget source, FFCharacterWidget target, ActionCharacter action, float speed = 1f) + { + Source = source; + Target = target; + Action = action; + Speed = speed; + } + + /// + /// Advances the animation by one frame. Returns true when the animation + /// sequence has completed. Called in a tight while loop by + /// each process frame. + /// + public bool DoAction() + { + if (Source == null || Target == null || Action == null) + return true; + return DoInternalAction(); + } + + /// Captures the initial world positions of source and target, then delegates to subclass setup. + public void DoStart() + { + if (Source == null || Target == null || Action == null) + return; + + SourcePos = Source.GlobalPosition; + TargetPos = Target.GlobalPosition; + DoInternalStart(); + } + + /// Restores source to original position and delegates cleanup to subclass. + public void DoEnd() + { + if (Source == null || Target == null || Action == null) + return; + DoInternalEnd(); + } + + protected abstract bool DoInternalAction(); + protected abstract void DoInternalStart(); + protected abstract void DoInternalEnd(); + + /// Quadratic ease-out: fast start, decelerating to the target. Used for return-to-start motion. + protected float EaseOutQuad(float t) => 1f - (1f - t) * (1f - t); + /// Quadratic ease-in-out: slow start and end, fast in the middle. Used for approach motion. + protected float EaseInOutQuad(float t) => t < 0.5f ? 2f * t * t : 1f - Mathf.Pow(-2f * t + 2f, 2f) / 2f; + } +} diff --git a/src/Actions/FFActionProvider.cs b/src/Actions/FFActionProvider.cs new file mode 100644 index 0000000..092cfcc --- /dev/null +++ b/src/Actions/FFActionProvider.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using Cthangover.Core.Battle.Actions; +using Cthangover.Core.Utils; + +namespace Cthangover.FFBattle.Actions +{ + /// + /// Registry mapping action ID strings to their executor implementations. + /// Created by and registered with + /// . Maps "physics/attack" → + /// , "physics/defence" → + /// , "physics/stun" → + /// , and "ff/item" → + /// . The lookup key comes from + /// . + /// + public class FFActionProvider : IActionExecutorProvider + { + private readonly Dictionary _executors; + + /// Constructs the provider and registers the four built-in FF battle executors. + public FFActionProvider() + { + _executors = new Dictionary + { + ["physics/attack"] = new FFDamageExecutor(), + ["physics/defence"] = new FFDefenceExecutor(), + ["physics/stun"] = new FFStunExecutor(), + ["ff/item"] = new FFItemExecutor(), + }; + } + + /// + /// Retrieves the executor registered for . + /// Logs a warning if no executor is found and returns null, + /// which the animation system silently ignores. + /// + /// The string to look up. + public IActionExecutor GetExecutor(string actionId) + { + if (!_executors.TryGetValue(actionId, out var executor)) + GameLogger.Log("FF_BATTLE", $"No executor registered for action '{actionId}'", LogLevel.Warning); + return executor; + } + } +} diff --git a/src/Actions/FFAttackAnimation.cs b/src/Actions/FFAttackAnimation.cs new file mode 100644 index 0000000..e4e39ba --- /dev/null +++ b/src/Actions/FFAttackAnimation.cs @@ -0,0 +1,116 @@ +using Cthangover.Core.Battle; +using Cthangover.Core.Battle.Actions; +using Cthangover.Core.Characters; +using Cthangover.FFBattle.UI; +using Godot; + +namespace Cthangover.FFBattle.Actions +{ + /// + /// Animation for offensive actions against enemies. Implements a three-phase + /// sequence: (1) MoveForward — source character dashes toward the target with + /// ease-in-out interpolation, stopping 30px short, (2) Impact — applies damage + /// via when 30% through the phase, triggering + /// flash, shake, and floating damage text, (3) MoveBack — returns the source + /// to its original position with ease-out. + /// Used for player attacks and enemy attacks alike. + /// + public class FFAttackAnimation : FFAbstractAnimation + { + private enum Phase { MoveForward, Impact, MoveBack, Done } + + private Phase _phase; + private bool _damageApplied; + private Vector2 _midPoint; + + /// Creates an attack animation for source→target with the given action descriptor and speed. + public FFAttackAnimation(FFCharacterWidget source, FFCharacterWidget target, ActionCharacter action, float speed = 1f) + : base(source, target, action, speed) { } + + protected override void DoInternalStart() + { + Timestamp = Time.GetTicksUsec() / 1_000_000.0; + _damageApplied = false; + _phase = Phase.MoveForward; + + var dir = (TargetPos - SourcePos).Normalized(); + _midPoint = TargetPos - dir * 30f; + } + + protected override bool DoInternalAction() + { + var elapsed = (float)(Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Engine.TimeScale; + + switch (_phase) + { + case Phase.MoveForward: + { + var progress = Mathf.Clamp(elapsed / 0.35f, 0f, 1f); + Source.GlobalPosition = SourcePos.Lerp(_midPoint, EaseInOutQuad(progress)); + + if (progress >= 1f) + { + _phase = Phase.Impact; + Timestamp = Time.GetTicksUsec() / 1_000_000.0; + } + break; + } + + case Phase.Impact: + { + var progress = Mathf.Clamp(elapsed / 0.2f, 0f, 1f); + + if (!_damageApplied && progress >= 0.3f) + { + _damageApplied = true; + var result = ActionExecutorHub.Instance.Execute(Action, Source.Card, Target.Card); + Source.UpdateInfo(); + Target.UpdateInfo(); + + if (result.Result) + { + Target.Flash(new Color(1f, 0.3f, 0.3f, 1f), 0.2f); + Target.Shake(4f, 0.25f); + + if (result.Target.Damage > 0) + ShowDamageBehaviour.SpawnDamage(result.Target.Damage, Target, Target.GlobalPosition); + + var defenceLost = -result.Target.Defence; + if (defenceLost > 0) + ShowDamageBehaviour.SpawnDefence(defenceLost, Target, Target.GlobalPosition); + } + } + + if (progress >= 1f) + { + _phase = Phase.MoveBack; + Timestamp = Time.GetTicksUsec() / 1_000_000.0; + } + break; + } + + case Phase.MoveBack: + { + var progress = Mathf.Clamp(elapsed / 0.4f, 0f, 1f); + Source.GlobalPosition = _midPoint.Lerp(SourcePos, EaseOutQuad(progress)); + + if (progress >= 1f) + { + _phase = Phase.Done; + return true; + } + break; + } + } + + return false; + } + + protected override void DoInternalEnd() + { + Source.GlobalPosition = SourcePos; + Source.UpdateInfo(); + Target.UpdateInfo(); + } + } +} diff --git a/src/Actions/FFDamageExecutor.cs b/src/Actions/FFDamageExecutor.cs new file mode 100644 index 0000000..772e446 --- /dev/null +++ b/src/Actions/FFDamageExecutor.cs @@ -0,0 +1,53 @@ +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 + { + public override string ID => "FFDamageExecutor"; + + public override ChangedAttributes Execute(ActionCharacter action, Character user, Character target) + { + if (target == null || user == null || !CheckRequiredAndUsePoint(action, user)) + return new ChangedAttributes { Result = false }; + + var damageDelta = Mathf.RoundToInt(action.GetFloat("Attack", 1f) * user.Attributes.Attack.Value); + target.StatusEffectQueue.OnTakeDamage(user, ref damageDelta); + user.StatusEffectQueue.OnDealDamage(target, ref damageDelta); + + var defenceDelta = 0; + var ignoreArmor = user.HasPassiveEffect("IgnoreArmor"); + if (!ignoreArmor && target.Attributes.Defence.Value > 0) + { + defenceDelta = target.Attributes.Defence.Value >= damageDelta + ? damageDelta + : target.Attributes.Defence.Value; + damageDelta -= defenceDelta; + } + + target.Attributes.Defence.Value -= defenceDelta; + target.Attributes.Health.Value -= damageDelta; + + GameLogger.Log("FF_BATTLE", + $"{user.Name} attacks {target.Name}: damage={damageDelta} defence={defenceDelta}", + LogLevel.Debug); + + return new ChangedAttributes + { + Result = true, + Target = { Damage = damageDelta, Defence = -defenceDelta } + }; + } + } +} diff --git a/src/Actions/FFDefenceExecutor.cs b/src/Actions/FFDefenceExecutor.cs new file mode 100644 index 0000000..b7270a6 --- /dev/null +++ b/src/Actions/FFDefenceExecutor.cs @@ -0,0 +1,32 @@ +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 + { + public override string ID => "FFDefenceExecutor"; + + public override ChangedAttributes Execute(ActionCharacter action, Character user, Character target) + { + if (target == null || user == null || !CheckRequiredAndUsePoint(action, user)) + return new ChangedAttributes { Result = false }; + + var defenceDelta = action.GetInt("Defence", 4); + target.Attributes.Defence.Value += defenceDelta; + + GameLogger.Log("FF_BATTLE", + $"{user.Name} defends → +{defenceDelta} defence for {target?.Name}", + LogLevel.Debug); + + return new ChangedAttributes { Result = true, Target = { Defence = defenceDelta } }; + } + } +} diff --git a/src/Actions/FFDefendAnimation.cs b/src/Actions/FFDefendAnimation.cs new file mode 100644 index 0000000..e2378a6 --- /dev/null +++ b/src/Actions/FFDefendAnimation.cs @@ -0,0 +1,114 @@ +using Cthangover.Core.Battle; +using Cthangover.Core.Battle.Actions; +using Cthangover.Core.Characters; +using Cthangover.FFBattle.UI; +using Godot; + +namespace Cthangover.FFBattle.Actions +{ + /// + /// Animation for defensive/support actions (buff, self-target, ally heal). + /// Phases: (1) Bounce — source character bobs in place with a sine-based + /// vertical oscillation, (2) Glow — applies the effect via + /// at 30% phase progress, triggering a green + /// flash on the target and floating defence/damage indicators, (3) Recover — + /// smoothly returns the source to its original position with ease-out + /// interpolation. Unlike , the source does not + /// move toward the target. + /// + public class FFDefendAnimation : FFAbstractAnimation + { + private enum Phase { Bounce, Glow, Recover, Done } + + private Phase _phase; + private bool _effectApplied; + private Vector2 _bounceOffset; + + /// Creates a defend/buff animation for source→target with the given action descriptor and speed. + public FFDefendAnimation(FFCharacterWidget source, FFCharacterWidget target, ActionCharacter action, float speed = 1f) + : base(source, target, action, speed) { } + + protected override void DoInternalStart() + { + Timestamp = Time.GetTicksUsec() / 1_000_000.0; + _effectApplied = false; + _phase = Phase.Bounce; + _bounceOffset = new Vector2(0, -12f); + } + + protected override bool DoInternalAction() + { + var elapsed = (float)(Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Engine.TimeScale; + + switch (_phase) + { + case Phase.Bounce: + { + var progress = Mathf.Clamp(elapsed / 0.25f, 0f, 1f); + var bounce = Mathf.Sin(progress * Mathf.Pi * 2f) * 8f; + Source.GlobalPosition = SourcePos + new Vector2(0, -Mathf.Abs(bounce)); + + if (progress >= 1f) + { + _phase = Phase.Glow; + Timestamp = Time.GetTicksUsec() / 1_000_000.0; + } + break; + } + + case Phase.Glow: + { + var progress = Mathf.Clamp(elapsed / 0.4f, 0f, 1f); + + if (!_effectApplied && progress >= 0.3f) + { + _effectApplied = true; + var result = ActionExecutorHub.Instance.Execute(Action, Source.Card, Target.Card); + Source.UpdateInfo(); + Target.UpdateInfo(); + + if (result.Result) + { + Target.Flash(new Color(0.3f, 1f, 0.3f, 1f), 0.4f); + + if (result.Target.Defence > 0) + ShowDamageBehaviour.SpawnDefence(result.Target.Defence, Target, Target.GlobalPosition); + + if (result.Target.Damage > 0) + ShowDamageBehaviour.SpawnDamage(result.Target.Damage, Target, Target.GlobalPosition, true); + } + } + + if (progress >= 1f) + { + _phase = Phase.Recover; + Timestamp = Time.GetTicksUsec() / 1_000_000.0; + } + break; + } + + case Phase.Recover: + { + var progress = Mathf.Clamp(elapsed / 0.3f, 0f, 1f); + Source.GlobalPosition = (SourcePos + _bounceOffset).Lerp(SourcePos, EaseOutQuad(progress)); + + if (progress >= 1f) + { + _phase = Phase.Done; + return true; + } + break; + } + } + + return false; + } + + protected override void DoInternalEnd() + { + Source.GlobalPosition = SourcePos; + Source.UpdateInfo(); + Target.UpdateInfo(); + } + } +} diff --git a/src/Actions/FFItemAnimation.cs b/src/Actions/FFItemAnimation.cs new file mode 100644 index 0000000..a5fa81f --- /dev/null +++ b/src/Actions/FFItemAnimation.cs @@ -0,0 +1,141 @@ +using Cthangover.Core.Characters; +using Cthangover.Core.Items; +using Cthangover.FFBattle.UI; +using Godot; + +namespace Cthangover.FFBattle.Actions +{ + /// + /// Animation for item usage in battle. Creates a floating item icon + /// () that rises above the source character and then + /// flies toward the target with fading opacity. Phases: (1) RaiseItem — the + /// item icon floats upward and the source bobs, (2) ApplyEffect — the icon + /// lerps to the target's position and + /// is called at 30% phase, (3) Recover — source returns to position with + /// ease-out. The icon is freed in . + /// + public class FFItemAnimation : FFAbstractAnimation + { + private enum Phase { RaiseItem, ApplyEffect, Recover, Done } + + private Phase _phase; + private IItem _item; + private bool _effectApplied; + private TextureRect _itemIcon; + + /// Creates an item-use animation. is a synthetic "ff/item" descriptor; actual logic runs via . + public FFItemAnimation(FFCharacterWidget source, FFCharacterWidget target, ActionCharacter action, IItem item, float speed = 1f) + : base(source, target, action, speed) + { + _item = item; + } + + protected override void DoInternalStart() + { + Timestamp = Time.GetTicksUsec() / 1_000_000.0; + _effectApplied = false; + _phase = Phase.RaiseItem; + + if (_item?.Sprite != null) + { + _itemIcon = new TextureRect(); + _itemIcon.Texture = _item.Sprite; + _itemIcon.ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize; + _itemIcon.StretchMode = TextureRect.StretchModeEnum.KeepAspect; + _itemIcon.Size = new Vector2(32, 32); + _itemIcon.Position = Source.GlobalPosition - Source.Position + new Vector2(20, -40); + Source.AddChild(_itemIcon); + } + } + + protected override bool DoInternalAction() + { + var elapsed = (float)(Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Engine.TimeScale; + + switch (_phase) + { + case Phase.RaiseItem: + { + var progress = Mathf.Clamp(elapsed / 0.3f, 0f, 1f); + + if (_itemIcon != null) + { + var iconStartPos = Source.GlobalPosition - Source.Position + new Vector2(20, -40); + _itemIcon.Position = iconStartPos + new Vector2(0, -30f * EaseInOutQuad(progress)); + } + + var bounce = Mathf.Sin(progress * Mathf.Pi) * 8f; + Source.GlobalPosition = SourcePos + new Vector2(0, -Mathf.Abs(bounce)); + + if (progress >= 1f) + { + _phase = Phase.ApplyEffect; + Timestamp = Time.GetTicksUsec() / 1_000_000.0; + } + break; + } + + case Phase.ApplyEffect: + { + var progress = Mathf.Clamp(elapsed / 0.35f, 0f, 1f); + + if (_itemIcon != null && Target != null) + { + var localTarget = Target.GlobalPosition - Source.Position; + var iconStart = Source.GlobalPosition - Source.Position + new Vector2(20, -70); + _itemIcon.Position = iconStart.Lerp(localTarget + new Vector2(20, -20), EaseInOutQuad(progress)); + _itemIcon.Modulate = new Color(1, 1, 1, 1f - progress * 0.5f); + } + + if (!_effectApplied && progress >= 0.3f) + { + _effectApplied = true; + + if (_item != null) + FFItemExecutor.TryUseItem(_item, Source.Card, Target.Card); + + Source.UpdateInfo(); + Target.UpdateInfo(); + Target.Flash(new Color(0.5f, 1f, 0.5f, 1f), 0.3f); + } + + if (progress >= 1f) + { + _phase = Phase.Recover; + Timestamp = Time.GetTicksUsec() / 1_000_000.0; + } + break; + } + + case Phase.Recover: + { + var progress = Mathf.Clamp(elapsed / 0.25f, 0f, 1f); + Source.GlobalPosition = Source.GlobalPosition.Lerp(SourcePos, EaseOutQuad(progress)); + + if (progress >= 1f) + { + _phase = Phase.Done; + return true; + } + break; + } + } + + return false; + } + + protected override void DoInternalEnd() + { + Source.GlobalPosition = SourcePos; + + if (_itemIcon != null) + { + _itemIcon.QueueFree(); + _itemIcon = null; + } + + Source.UpdateInfo(); + Target.UpdateInfo(); + } + } +} diff --git a/src/Actions/FFItemExecutor.cs b/src/Actions/FFItemExecutor.cs new file mode 100644 index 0000000..9895e57 --- /dev/null +++ b/src/Actions/FFItemExecutor.cs @@ -0,0 +1,67 @@ +using Cthangover.Core.Battle; +using Cthangover.Core.Battle.Actions; +using Cthangover.Core.Characters; +using Cthangover.Core.Items; +using Cthangover.Core.Settings; +using Cthangover.Core.Utils; + +namespace Cthangover.FFBattle.Actions +{ + /// + /// Handles item usage during battle. Implements + /// but its method is a no-op — actual item logic runs + /// via which is called directly from + /// . This separation exists because items carry + /// their own delegate for effects, unlike + /// standard actions which use the executor pattern. + /// Registered under ID "ff/item". + /// + public class FFItemExecutor : IActionExecutor + { + /// The action ID string that maps to this executor in . + public string ActionId => "ff/item"; + + /// No-op — item effects are applied via . + public ChangedAttributes Execute(ActionCharacter action, Character user, Character target) + { + return new ChangedAttributes { Result = false }; + } + + /// + /// Consumes one action point from , removes the item + /// from the global inventory, and invokes the item's + /// . Returns false if the user lacks + /// points, the item is not in inventory, or preconditions fail. + /// + /// The item to use. + /// The character consuming the item (loses 1 point). + /// The character targeted by the item effect. + public static bool TryUseItem(IItem item, Character user, Character target) + { + if (item == null || user == null) + return false; + + if (user.Attributes.Point.Value < 1) + { + GameLogger.Log("FF_BATTLE", $"Not enough points to use item {item.Name}", LogLevel.Warning); + return false; + } + + var inventory = GameData.Instance.Runtime.Inventory; + if (!inventory.HasItem(item.ID)) + { + GameLogger.Log("FF_BATTLE", $"No {item.ID} in inventory", LogLevel.Warning); + return false; + } + + user.Attributes.Point.Value -= 1; + inventory.Remove(item.ID, 1); + + if (item.ItemAction != null) + item.ItemAction.UseAction(item); + + GameLogger.Log("FF_BATTLE", $"{user.Name} uses item {item.Name}", LogLevel.Debug); + return true; + } + } +} diff --git a/src/Actions/FFStunExecutor.cs b/src/Actions/FFStunExecutor.cs new file mode 100644 index 0000000..44d71cb --- /dev/null +++ b/src/Actions/FFStunExecutor.cs @@ -0,0 +1,35 @@ +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 + { + public override string ID => "FFStunExecutor"; + + public override ChangedAttributes Execute(ActionCharacter action, Character user, Character target) + { + if (target == null || user == null || !CheckRequiredAndUsePoint(action, user)) + return new ChangedAttributes { Result = false }; + + if (target.HasPassiveEffect("StunImmune")) + return new ChangedAttributes { Result = true }; + + var turns = action.GetInt("Turn", 3); + target.StatusEffectQueue.Add("effect/physics/stun", turns); + + GameLogger.Log("FF_BATTLE", + $"{user.Name} stuns {target.Name} for {turns} turns", + LogLevel.Debug); + + return new ChangedAttributes { Result = true }; + } + } +} diff --git a/src/FFBattleCore.cs b/src/FFBattleCore.cs new file mode 100644 index 0000000..25b02cf --- /dev/null +++ b/src/FFBattleCore.cs @@ -0,0 +1,858 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Cthangover.Core.Battle; +using Cthangover.Core.Battle.Actions; +using Cthangover.Core.Characters; +using Cthangover.Core.Items; +using Cthangover.Core.Settings; +using Cthangover.Core.Scenes; +using Cthangover.Core.Utils; +using Cthangover.FFBattle.UI; +using Godot; + +namespace Cthangover.FFBattle +{ + /// + /// 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; + } + } +} diff --git a/src/UI/FFBattleController.cs b/src/UI/FFBattleController.cs new file mode 100644 index 0000000..b134b98 --- /dev/null +++ b/src/UI/FFBattleController.cs @@ -0,0 +1,208 @@ +using Cthangover.Core.UI; +using Godot; + +namespace Cthangover.FFBattle.UI +{ + /// + /// Defines the six states of the battle UI state machine, driving which input + /// handler is active and what the player sees. + /// + public enum FFBattleState + { + /// No menu open; player can select a character to act. + Idle, + /// Main action menu (attack/items/defend/escape) is visible. + MenuOpen, + /// Sub-menu (action list or item list) is visible. + SubMenuOpen, + /// Player must click a valid target character widget. + TargetSelect, + /// An animation is playing; all input is blocked. + AnimationPlaying, + /// Battle has concluded. + Ended + } + + /// + /// Input dispatch layer for the FF battle UI. Extends + /// so that keyboard events from Godot's input system are forwarded to this node + /// even in mod assemblies. Routes arrow keys, Enter, Space, and Escape to the + /// appropriate handler based on current . Also provides + /// mouse-driven character selection via . + /// Events are raised to which wires up the actual logic. + /// + public partial class FFBattleController : InputHandlerNode + { + private FFBattleState _state = FFBattleState.Idle; + + /// Reference to the player-side character panel for idle-state character selection. + public FFPlayerPanel PlayerPanel { get; set; } + /// Reference to the enemy-side panel for visual context (not directly used for input). + public FFEnemyPanel EnemyPanel { get; set; } + /// The main menu panel; delegates up/down/confirm to it. + public FFMenuPanel MenuPanel { get; set; } + /// The sub-menu panel; delegates to it. + public FFMenuPanel SubMenuPanel { get; set; } + + /// Raised when a character widget is selected (via mouse click or keyboard). + public event System.Action OnCharacterSelected; + /// Raised when a menu item is confirmed. The int is the entry index. + public event System.Action OnMenuAction; + /// Raised when a sub-menu item is confirmed. + public event System.Action OnSubMenuAction; + /// Raised when Escape is pressed while the main menu is open. + public event System.Action OnMenuCancel; + /// Raised when Escape is pressed while a sub-menu is open. + public event System.Action OnSubMenuCancel; + /// Raised when a target widget is confirmed in target-select mode. + public event System.Action OnTargetSelected; + + /// + /// Current UI state. Setting this property logs the transition via + /// for debugging battle flow. + /// + public FFBattleState State + { + get => _state; + set + { + _state = value; + Cthangover.Core.Utils.GameLogger.Log("FF_BATTLE", $"Controller state → {value}", Cthangover.Core.Utils.LogLevel.Debug); + } + } + + /// Ensures the controller processes input even when the scene tree is paused. + public override void _Ready() + { + ProcessMode = ProcessModeEnum.Always; + } + + protected override void OnInput(InputEvent @event) + { + if (State == FFBattleState.AnimationPlaying || State == FFBattleState.Ended) + return; + + if (@event is InputEventKey key && key.Pressed) + { + HandleKeyboardInput(key); + if (State != FFBattleState.Idle) + GetViewport().SetInputAsHandled(); + } + } + + private void HandleKeyboardInput(InputEventKey key) + { + switch (State) + { + case FFBattleState.Idle: + HandleIdleInput(key); + break; + + case FFBattleState.MenuOpen: + HandleMenuInput(key); + break; + + case FFBattleState.SubMenuOpen: + HandleSubMenuInput(key); + break; + + case FFBattleState.TargetSelect: + HandleTargetInput(key); + break; + } + } + + private void HandleIdleInput(InputEventKey key) + { + if (key.Keycode == Key.Enter || key.Keycode == Key.Space) + { + SelectFirstAvailableCharacter(); + } + } + + private void HandleMenuInput(InputEventKey key) + { + switch (key.Keycode) + { + case Key.Up: + case Key.W: + MenuPanel?.SelectPrevious(); + break; + + case Key.Down: + case Key.S: + MenuPanel?.SelectNext(); + break; + + case Key.Enter: + case Key.Space: + MenuPanel?.ConfirmSelection(); + break; + + case Key.Escape: + OnMenuCancel?.Invoke(); + break; + } + } + + private void HandleSubMenuInput(InputEventKey key) + { + switch (key.Keycode) + { + case Key.Up: + case Key.W: + SubMenuPanel?.SelectPrevious(); + break; + + case Key.Down: + case Key.S: + SubMenuPanel?.SelectNext(); + break; + + case Key.Enter: + case Key.Space: + SubMenuPanel?.ConfirmSelection(); + break; + + case Key.Escape: + OnSubMenuCancel?.Invoke(); + break; + } + } + + private void HandleTargetInput(InputEventKey key) + { + if (key.Keycode == Key.Escape) + { + OnMenuCancel?.Invoke(); + } + } + + private void SelectFirstAvailableCharacter() + { + if (PlayerPanel == null) + return; + + foreach (var widget in PlayerPanel.Widgets) + { + if (!widget.IsDead && widget.Card?.Attributes?.Point?.Value > 0) + { + OnCharacterSelected?.Invoke(widget); + return; + } + } + } + + /// + /// Selects a character widget, raising . + /// Only valid in state. + /// + public void SelectCharacter(FFCharacterWidget widget) + { + if (State != FFBattleState.Idle) + return; + + OnCharacterSelected?.Invoke(widget); + } + } +} diff --git a/src/UI/FFCharacterWidget.cs b/src/UI/FFCharacterWidget.cs new file mode 100644 index 0000000..bb4b1b7 --- /dev/null +++ b/src/UI/FFCharacterWidget.cs @@ -0,0 +1,214 @@ +using System; +using Cthangover.Core.Mods; +using Cthangover.Core.Mods.Resolvers; +using Cthangover.Core.Characters; +using Cthangover.Core.UI; +using Godot; + +namespace Cthangover.FFBattle.UI +{ + /// + /// Visual representation of a single character (player or enemy) in the FF battle UI. + /// Composite widget containing a sprite, HP bar, name label, and selection overlay. + /// Supports highlight, flash, shake, and dissolve-death animations via Godot tweens. + /// Created per-character by and + /// . Clickable via GuiInput events; + /// embedding panels wire these to for action flow. + /// + public partial class FFCharacterWidget : ModWidget + { + private TextureRect _sprite; + private TextureRect _selection; + private Label _nameLabel; + private ColorRect _hpBg; + private ColorRect _hpFill; + private Tween _animTween; + private static Texture2D _cachedSelectTex; + private static bool _selectTexLoaded; + private static Shader _dissolveShader; + + /// Whether this widget belongs to the player party (affects highlight/targeting logic). + public bool IsPlayer { get; set; } + /// Whether the character is dead; set by . + public bool IsDead { get; set; } + /// The character data model backing this widget: stats, actions, status effects. + public Character Card { get; set; } + /// Original scale captured at construction; used to restore after death animation. + public Vector2 BaseScale { get; private set; } + /// Exposes this widget as a for layout calculations. + public Control ControlNode => this; + + protected override void Construct() + { + _sprite = GetNode("Sprite"); + _hpBg = GetNode("HpBg"); + _hpFill = GetNode("HpFill"); + _nameLabel = GetNode