From 202ab337f949433718cb58701dfe095fd744eb1c Mon Sep 17 00:00:00 2001 From: huangjun Date: Wed, 2 Sep 2026 10:12:30 +0800 Subject: [PATCH] =?UTF-8?q?M2=20=E5=9B=9E=E5=90=88=E5=88=B6=E8=B0=83?= =?UTF-8?q?=E5=BA=A6=EF=BC=9A=E9=80=9F=E5=BA=A6/=E9=98=B5=E8=90=A5/?= =?UTF-8?q?=E9=98=9F=E4=BC=8D=E9=A1=BA=E5=BA=8F=E8=A7=84=E5=88=99=E3=80=81?= =?UTF-8?q?=E5=85=88=E5=88=B6=E7=AC=AC=E4=B8=80=E8=BD=AE=E3=80=814v4=20?= =?UTF-8?q?=E8=B0=83=E8=AF=95=E5=85=A5=E5=8F=A3=E4=B8=8E=E8=B0=83=E5=BA=A6?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BattleRuntime 调度替换为完整规则:速度高优先、同速玩家优先、同阵营同速 PartyOrder 小优先;先制第一轮玩家全先行,第二轮恢复普通排序;RemainingTurnOrder 同步按调度序输出 - TurnBattleComponent 开局行动者为敌人时自动推进,协程等待 UI 监听注册避免先手事件丢失 - 调试入口支持 1v1 / 2v2 / 4v4 与先制开关;新增敌人队伍预设 4005(四普通敌人) - UI:修复 UIForm 复用导致第二次开战按钮监听丢失;EnemySlots/TurnSlots 改为按数量复用 item,图标同一单位不重复加载 - EditMode 新增 10 个调度用例(共 35 个):排序规则、先制两轮、重复敌人分别击杀、4v4 创建、死亡移出候选、固定随机序列复现 --- .../DataTables/tbenemypartyconfig.bytes | 2 +- .../DataTables/tbenemypartyconfig.json | 10 + .../Scripts/Runtime/Battle/BattleRuntime.cs | 100 +++++- .../Run/TurnBattleComponent.cs | 10 + .../Runtime/Debugger/BattleDebuggerWindow.cs | 90 ++++-- .../Battle/BattleRuntimeSchedulingTests.cs | 299 ++++++++++++++++++ .../BattleRuntimeSchedulingTests.cs.meta | 11 + .../EditMode/Battle/TestBattleSupport.cs | 21 +- .../Scripts/UI/Battle/BattleActorCardItem.cs | 10 +- .../Scripts/UI/Battle/BattleEnemySlotItem.cs | 11 +- .../Scripts/UI/Battle/BattleForm.Logic.cs | 88 ++++-- .../Scripts/UI/Battle/BattleTurnSlotItem.cs | 10 +- .../Tech/04_TurnBasedCombatDevelopmentTODO.md | 28 +- .../Datas/EnemyPartyConfig_敌人队伍配置.xlsx | Bin 10000 -> 9975 bytes 14 files changed, 605 insertions(+), 85 deletions(-) create mode 100644 Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeSchedulingTests.cs create mode 100644 Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeSchedulingTests.cs.meta diff --git a/Assets/GameMain/DataTables/tbenemypartyconfig.bytes b/Assets/GameMain/DataTables/tbenemypartyconfig.bytes index 6f0d2cc..36116d5 100644 --- a/Assets/GameMain/DataTables/tbenemypartyconfig.bytes +++ b/Assets/GameMain/DataTables/tbenemypartyconfig.bytes @@ -1 +1 @@ -�¡ 单敌人‹¹�¢双普通敌人‹¹‹¹�£ 混合队伍‹¹‹º�¤高威胁队伍‹º‹» \ No newline at end of file +�¡ 单敌人‹¹�¢双普通敌人‹¹‹¹�£ 混合队伍‹¹‹º�¤高威胁队伍‹º‹»�¥四普通敌人‹¹‹¹‹¹‹¹ \ No newline at end of file diff --git a/Assets/GameMain/DataTables/tbenemypartyconfig.json b/Assets/GameMain/DataTables/tbenemypartyconfig.json index 1423885..ff68463 100644 --- a/Assets/GameMain/DataTables/tbenemypartyconfig.json +++ b/Assets/GameMain/DataTables/tbenemypartyconfig.json @@ -29,5 +29,15 @@ 3002, 3003 ] + }, + { + "id": 4005, + "name": "四普通敌人", + "enemyIds": [ + 3001, + 3001, + 3001, + 3001 + ] } ] \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Runtime/Battle/BattleRuntime.cs b/Assets/GameMain/Scripts/Runtime/Battle/BattleRuntime.cs index c357ba3..15470f9 100644 --- a/Assets/GameMain/Scripts/Runtime/Battle/BattleRuntime.cs +++ b/Assets/GameMain/Scripts/Runtime/Battle/BattleRuntime.cs @@ -244,12 +244,9 @@ namespace SepCore.Battle List remainingOrder = new List(); if (!IsCompleted) { - foreach (BattleUnit unit in Units) + foreach (BattleUnit unit in GetRemainingCandidates()) { - if (IsActive(unit) && !ActedUnitIds.Contains(unit.UnitId)) - { - remainingOrder.Add(unit.UnitId); - } + remainingOrder.Add(unit.UnitId); } } @@ -412,9 +409,14 @@ namespace SepCore.Battle } /// - /// 选择本轮下一个未行动的活跃单位;全部行动过则进入下一轮。 - /// M1 按单位创建顺序(玩家在前)轮流,每轮每个单位最多行动一次; - /// M2 起由完整调度规则(速度、阵营、队伍顺序、先制)替换。 + /// 选择本轮下一个未行动的活跃单位;候选集为空时开启下一轮。 + /// 调度规则(文档 6.1): + /// 1. 先制第一轮仍有玩家未行动时只在玩家中选择; + /// 2. 当前速度高者优先; + /// 3. 同速时玩家优先于敌人; + /// 4. 同阵营同速时 PartyOrder 小者优先; + /// 5. 已行动单位不因速度变化再次行动(M2 无速度变化效果); + /// 6. 候选集为空时开启下一轮,先制限制只适用于第一轮。 /// private void SelectNextActor() { @@ -435,17 +437,93 @@ namespace SepCore.Battle CurrentActorUnitId = next != null ? next.UnitId : 0; } + /// + /// 按调度规则选本轮优先级最高的候选;没有候选时返回 null。 + /// private BattleUnit FindNextActor() { + BattleUnit best = null; foreach (BattleUnit unit in Units) { - if (IsActive(unit) && !ActedUnitIds.Contains(unit.UnitId)) + if (!IsActive(unit) || ActedUnitIds.Contains(unit.UnitId)) { - return unit; + continue; + } + + if (IsPreemptiveRound && unit.Faction == BattleFaction.Enemy && HasUnactedPlayer()) + { + continue; + } + + if (best == null || CompareActors(unit, best) < 0) + { + best = unit; } } - return null; + return best; + } + + /// + /// 先制限制只适用于第一轮。 + /// + private bool IsPreemptiveRound => IsPreemptive && RoundNumber == 1; + + private bool HasUnactedPlayer() + { + foreach (BattleUnit unit in Units) + { + if (unit.Faction == BattleFaction.Player && IsActive(unit) && !ActedUnitIds.Contains(unit.UnitId)) + { + return true; + } + } + + return false; + } + + /// + /// 速度高优先;同速玩家优先;同阵营同速 PartyOrder 小优先。返回负值表示 a 优先于 b。 + /// + private static int CompareActors(BattleUnit a, BattleUnit b) + { + if (a.Speed != b.Speed) + { + return b.Speed - a.Speed; + } + + if (a.Faction != b.Faction) + { + return a.Faction == BattleFaction.Player ? -1 : 1; + } + + return a.PartyOrder - b.PartyOrder; + } + + /// + /// 本轮尚未行动的候选单位,按调度优先级排序;当前行动者是首个。 + /// 先制第一轮只返回玩家候选(敌人等玩家全部行动后进入候选集)。 + /// + private List GetRemainingCandidates() + { + List candidates = new List(); + foreach (BattleUnit unit in Units) + { + if (!IsActive(unit) || ActedUnitIds.Contains(unit.UnitId)) + { + continue; + } + + if (IsPreemptiveRound && unit.Faction == BattleFaction.Enemy && HasUnactedPlayer()) + { + continue; + } + + candidates.Add(unit); + } + + candidates.Sort(CompareActors); + return candidates; } /// diff --git a/Assets/GameMain/Scripts/Runtime/CustomComponent/Run/TurnBattleComponent.cs b/Assets/GameMain/Scripts/Runtime/CustomComponent/Run/TurnBattleComponent.cs index 5cd1d32..74e974f 100644 --- a/Assets/GameMain/Scripts/Runtime/CustomComponent/Run/TurnBattleComponent.cs +++ b/Assets/GameMain/Scripts/Runtime/CustomComponent/Run/TurnBattleComponent.cs @@ -156,6 +156,9 @@ namespace SepCore.CustomComponent GameEntry.UI.OpenUIForm(UIFormType.BattleForm); Log.Info("Battle started with encounter '{0}'.", encounter.EncounterId); + + // M2:敌人速度更高时开局行动者为敌人,同样按间歇节奏自动推进 + ScheduleAutoAdvance(); return true; } @@ -283,6 +286,13 @@ namespace SepCore.CustomComponent private System.Collections.IEnumerator AutoAdvanceRoutine() { + // 战斗 UI 打开是异步资源加载,等推进监听注册后再推第一步,避免敌人先手事件丢失 + int waitFrames = 0; + while (_stepListener == null && _runtime != null && !_runtime.IsCompleted && waitFrames++ < 600) + { + yield return null; + } + while (_runtime != null && !_runtime.IsCompleted && _runtime.CurrentActor != null && _runtime.CurrentActor.Faction == BattleFaction.Enemy) { diff --git a/Assets/GameMain/Scripts/Runtime/Debugger/BattleDebuggerWindow.cs b/Assets/GameMain/Scripts/Runtime/Debugger/BattleDebuggerWindow.cs index db29bc6..9bc1501 100644 --- a/Assets/GameMain/Scripts/Runtime/Debugger/BattleDebuggerWindow.cs +++ b/Assets/GameMain/Scripts/Runtime/Debugger/BattleDebuggerWindow.cs @@ -11,11 +11,18 @@ namespace SepCore.Debugger /// /// 战斗壳层调试入口(仅供 Editor/Development Build,正式构建不注册)。 /// 模拟探索层:缺少单局状态时用配表角色与固定种子初始化临时状态, - /// 然后通过 TurnBattleComponent 以固定调试遭遇开始/结束战斗,不依赖尚未实现的地图敌人。 + /// 然后通过 TurnBattleComponent 以调试遭遇开始/结束战斗,不依赖尚未实现的地图敌人。 + /// 支持 1v1 / 2v2 / 4v4 与先制开关。 /// public class BattleDebuggerWindow : IDebuggerWindow { private const int DebugRunSeed = 240829; + private const int DebugEncounterId = 1; + + private int _playerCount = 1; + private int _enemyPartyConfigId = 4001; + private bool _preemptive; + private int _builtPlayerCount = -1; public void Initialize(params object[] args) { @@ -40,19 +47,50 @@ namespace SepCore.Debugger public void OnDraw() { GUILayout.Label("Battle Shell"); - GUILayout.BeginHorizontal("box"); - { - if (GUILayout.Button("Open", GUILayout.Height(30))) - { - OpenShell(); - } - if (GUILayout.Button("Close", GUILayout.Height(30))) + GUILayout.BeginVertical("box"); + { + GUILayout.Label("队伍规模(玩家数/敌人队伍):"); + GUILayout.BeginHorizontal(); { - GameEntry.TurnBattle.CloseBattle(); + if (GUILayout.Button("1v1", GUILayout.Height(26))) + { + _playerCount = 1; + _enemyPartyConfigId = 4001; + } + + if (GUILayout.Button("2v2", GUILayout.Height(26))) + { + _playerCount = 2; + _enemyPartyConfigId = 4002; + } + + if (GUILayout.Button("4v4", GUILayout.Height(26))) + { + _playerCount = 4; + _enemyPartyConfigId = 4005; + } } + GUILayout.EndHorizontal(); + GUILayout.Label("当前选择:" + _playerCount + " 玩家 / 敌人队伍 " + _enemyPartyConfigId); + + _preemptive = GUILayout.Toggle(_preemptive, "先制(第一轮玩家全先行)"); + + GUILayout.BeginHorizontal(); + { + if (GUILayout.Button("Open", GUILayout.Height(30))) + { + OpenShell(); + } + + if (GUILayout.Button("Close", GUILayout.Height(30))) + { + GameEntry.TurnBattle.CloseBattle(); + } + } + GUILayout.EndHorizontal(); } - GUILayout.EndHorizontal(); + GUILayout.EndVertical(); DrawRunState(); } @@ -77,32 +115,29 @@ namespace SepCore.Debugger GUILayout.EndVertical(); } - private static void OpenShell() - { - InitDebugRunStateIfNeeded(); - - // 固定调试遭遇:EncounterId=1,敌人队伍预设 4001(单敌人),普通战斗 - bool started = GameEntry.TurnBattle.TryStartBattle(new BattleEncounter(1, 4001, false), null); - if (!started) - { - Log.Warning("Battle debug shell can not start, maybe a battle is already active."); - } - } - - private static void InitDebugRunStateIfNeeded() + private void OpenShell() { if (GameEntry.Random.Random == null) { GameEntry.Random.BeginRun(DebugRunSeed); } - if (GameEntry.TurnBattle.Players.Count == 0) + // 规模变化时重建玩家列表(重置为满状态);同规模重开保留上次战斗回写的 HP/MP + if (_builtPlayerCount != _playerCount) { - GameEntry.TurnBattle.ReplacePlayers(BuildDebugPlayers()); + GameEntry.TurnBattle.ReplacePlayers(BuildDebugPlayers(_playerCount)); + _builtPlayerCount = _playerCount; + } + + BattleEncounter encounter = new BattleEncounter(DebugEncounterId, _enemyPartyConfigId, _preemptive); + bool started = GameEntry.TurnBattle.TryStartBattle(encounter, null); + if (!started) + { + Log.Warning("Battle debug shell can not start, maybe a battle is already active."); } } - private static List BuildDebugPlayers() + private static List BuildDebugPlayers(int count) { List players = new List(); GlobalConfig global = GameEntry.Luban.Global != null ? GameEntry.Luban.Global.Data : null; @@ -112,7 +147,6 @@ namespace SepCore.Debugger return players; } - // M1 为 1v1:只取第一个新存档角色作为我方单位 if (global.NewGameCharacterIds == null || global.NewGameCharacterIds.Count == 0) { Log.Warning("Can not build debug players without new game character ids."); @@ -122,7 +156,7 @@ namespace SepCore.Debugger int order = 1; foreach (int characterId in global.NewGameCharacterIds) { - if (order > 1) + if (order > count) { break; } diff --git a/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeSchedulingTests.cs b/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeSchedulingTests.cs new file mode 100644 index 0000000..13f9aa4 --- /dev/null +++ b/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeSchedulingTests.cs @@ -0,0 +1,299 @@ +using System.Collections.Generic; +using NUnit.Framework; +using SepCore.Battle; +using SepCore.CustomComponent; +using SepCore.Definition; + +namespace SepCore.Tests +{ + [TestFixture] + public class BattleRuntimeSchedulingTests + { + [Test] + public void FasterEnemy_ActsBeforeSlowerPlayer() + { + TestConfigProvider config = TestConfigProvider.Standard1v1(); + config.AddEnemy(TestConfigs.EnemyWithSpeed(3001, 50, 8, 20, 201)); + BattleRuntime runtime = BattleRuntime.Create(TestBattle.Encounter(), + new List { TestBattle.Player(speed: 8) }, config, new TestRandomSource()); + + Assert.AreEqual(2, runtime.CurrentActorUnitId); + BattleStep step = runtime.AdvanceEnemyTurn(); + Assert.AreEqual(1, step.Events[0].TargetUnitId); + Assert.AreEqual(1, runtime.CurrentActorUnitId); + } + + [Test] + public void SameSpeed_PlayerActsBeforeEnemy() + { + TestConfigProvider config = TestConfigProvider.Standard1v1(); + config.AddEnemy(TestConfigs.EnemyWithSpeed(3001, 50, 8, 12, 201)); + BattleRuntime runtime = BattleRuntime.Create(TestBattle.Encounter(), + new List { TestBattle.Player(speed: 12) }, config, new TestRandomSource()); + + Assert.AreEqual(1, runtime.CurrentActorUnitId); + runtime.SubmitCommand(TestBattle.Attack(1, 2)); + Assert.AreEqual(2, runtime.CurrentActorUnitId); + } + + [Test] + public void SameFactionSameSpeed_PartyOrderDecides() + { + TestConfigProvider config = TestConfigProvider.Standard1v1(); + config.AddEnemy(TestConfigs.EnemyWithSpeed(3001, 500, 8, 5, 201)); + List players = new List + { + TestBattle.Player(speed: 10, partyOrder: 2, characterId: 1002), + TestBattle.Player(speed: 10, partyOrder: 1, characterId: 1001) + }; + + BattleRuntime runtime = BattleRuntime.Create(TestBattle.Encounter(), players, config, new TestRandomSource()); + + // 玩家同速:PartyOrder 1 的角色先行动(1001 是第二个单位) + Assert.AreEqual(2, runtime.CurrentActorUnitId); + Assert.AreEqual(1001, runtime.GetUnit(2).ConfigId); + } + + [Test] + public void EachUnitActsOncePerRound_2v2() + { + TestConfigProvider config = TestConfigProvider.Standard1v1(); + config.AddEnemy(TestConfigs.EnemyWithSpeed(3001, 500, 8, 20, 201)); + config.AddEnemy(TestConfigs.EnemyWithSpeed(3002, 500, 8, 5, 201)); + config.AddParty(TestConfigs.Party(4001, 3001, 3002)); + List players = new List + { + TestBattle.Player(speed: 15, partyOrder: 1), + TestBattle.Player(speed: 10, partyOrder: 2, characterId: 1002) + }; + + BattleRuntime runtime = BattleRuntime.Create(TestBattle.Encounter(), players, config, new TestRandomSource()); + + List round1 = CollectActorsInRound(runtime); + CollectionAssert.AreEqual(new List { 3, 1, 2, 4 }, round1); + Assert.AreEqual(2, runtime.RoundNumber); + } + + [Test] + public void PreemptiveFirstRound_AllPlayersActBeforeEnemies() + { + TestConfigProvider config = BuildTankyConfig(); + List players = BuildFourPlayers(speed: 8); + + BattleRuntime runtime = BattleRuntime.Create( + new BattleEncounter(1, 4001, true), players, config, new TestRandomSource()); + + List round1 = CollectActorsInRound(runtime); + CollectionAssert.AreEqual(new List { 1, 2, 3, 4, 5, 6, 7, 8 }, round1); + + List round2 = CollectActorsInRound(runtime); + CollectionAssert.AreEqual(new List { 5, 6, 7, 8, 1, 2, 3, 4 }, round2); + } + + [Test] + public void NonPreemptive_RoundOne_UsesSpeedOrder() + { + TestConfigProvider config = BuildTankyConfig(); + List players = BuildFourPlayers(speed: 8); + + BattleRuntime runtime = BattleRuntime.Create( + new BattleEncounter(1, 4001, false), players, config, new TestRandomSource()); + + List round1 = CollectActorsInRound(runtime); + // 敌人速度 20 高于玩家 8:第一轮就按速度排 + CollectionAssert.AreEqual(new List { 5, 6, 7, 8, 1, 2, 3, 4 }, round1); + } + + [Test] + public void RepeatedEnemyConfig_HasDistinctIds_AndCanDieSeparately() + { + TestConfigProvider config = TestConfigProvider.Standard1v1(); + config.AddAction(TestConfigs.AttackAction(2, 0, BattleStatType.ATK, -10000)); + config.AddParty(TestConfigs.Party(4001, 3001, 3001)); + + BattleRuntime runtime = BattleRuntime.Create(TestBattle.Encounter(), + new List { TestBattle.Player() }, config, new TestRandomSource()); + + Assert.AreEqual(3, runtime.Units.Length); + Assert.AreEqual(3001, runtime.GetUnit(2).ConfigId); + Assert.AreEqual(3001, runtime.GetUnit(3).ConfigId); + Assert.AreNotEqual(2, 3); + + // 只击杀 2 号敌人:3 号保持存活,战斗继续 + runtime.SubmitCommand(TestBattle.Attack(1, 2, 2)); + Assert.True(runtime.GetUnit(2).IsDefeated); + Assert.False(runtime.GetUnit(3).IsDefeated); + Assert.False(runtime.IsCompleted); + + // 敌人 3 反击后轮到玩家 + runtime.AdvanceEnemyTurn(); + Assert.AreEqual(1, runtime.CurrentActorUnitId); + + // 击杀 3 号敌人:胜利 + runtime.SubmitCommand(TestBattle.Attack(1, 3, 2)); + Assert.True(runtime.GetUnit(3).IsDefeated); + Assert.AreEqual(BattleOutcome.Victory, runtime.Result.Outcome); + } + + [Test] + public void Create_With4Players4Enemies_Succeeds() + { + TestConfigProvider config = TestConfigProvider.Standard1v1(); + config.AddParty(TestConfigs.Party(4005, 3001, 3001, 3001, 3001)); + + BattleRuntime runtime = BattleRuntime.Create(new BattleEncounter(1, 4005, false), + BuildFourPlayers(speed: 12), config, new TestRandomSource()); + + Assert.NotNull(runtime); + Assert.AreEqual(8, runtime.Units.Length); + HashSet unitIds = new HashSet(); + for (int i = 1; i <= 8; i++) + { + Assert.AreEqual(i, runtime.GetUnit(i).UnitId); + Assert.True(unitIds.Add(i)); + } + + Assert.AreEqual(1, runtime.CurrentActorUnitId); + } + + [Test] + public void UnitThatDiedBeforeItsTurn_IsSkipped() + { + TestConfigProvider config = TestConfigProvider.Standard1v1(); + config.AddAction(TestConfigs.AttackAction(2, 0, BattleStatType.ATK, -10000)); + config.AddEnemy(TestConfigs.EnemyWithSpeed(3001, 50, 8, 20, 201)); + config.AddEnemy(TestConfigs.EnemyWithSpeed(3002, 50, 8, 1, 201)); + config.AddParty(TestConfigs.Party(4001, 3001, 3002)); + + BattleRuntime runtime = BattleRuntime.Create(TestBattle.Encounter(), + new List { TestBattle.Player(speed: 10) }, config, new TestRandomSource()); + + // 第 1 轮:敌人 3001(速度 20)先行动 + Assert.AreEqual(2, runtime.CurrentActorUnitId); + runtime.AdvanceEnemyTurn(); + + // 玩家(速度 10)直接击杀 3002(速度 1,尚未行动) + Assert.AreEqual(1, runtime.CurrentActorUnitId); + runtime.SubmitCommand(TestBattle.Attack(1, 3, 2)); + + // 3002 阵亡被移出候选,第 2 轮由 3001 开始 + Assert.AreEqual(2, runtime.RoundNumber); + Assert.AreEqual(2, runtime.CurrentActorUnitId); + Assert.False(runtime.ActedUnitIds.Contains(3)); + } + + [Test] + public void SameInputsAndRandom_4v4_ProduceSameRecords() + { + TestConfigProvider config = TestConfigProvider.Standard1v1(); + config.AddAction(TestConfigs.AttackAction(202, -30, BattleStatType.None, 0)); + config.AddEnemy(TestConfigs.EnemyWithSpeed(3001, 500, 8, 20, 201, 202)); + config.AddEnemy(TestConfigs.EnemyWithSpeed(3002, 500, 8, 20, 201, 202)); + config.AddParty(TestConfigs.Party(4001, 3001, 3002)); + + List players = new List + { + TestBattle.Player(speed: 15, partyOrder: 1), + TestBattle.Player(speed: 10, partyOrder: 2, characterId: 1002) + }; + TestRandomSource randomA = new TestRandomSource(0, 0, 0, 0, 0, 0, 0, 0); + TestRandomSource randomB = new TestRandomSource(0, 0, 0, 0, 0, 0, 0, 0); + + BattleRuntime runtimeA = BattleRuntime.Create(TestBattle.Encounter(), players, config, randomA); + BattleRuntime runtimeB = BattleRuntime.Create(TestBattle.Encounter(), players, config, randomB); + + CollectionAssert.AreEqual(CollectRecords(runtimeA), CollectRecords(runtimeB)); + } + + /// + /// 防御性敌人配置:500 HP 保证两轮内无人阵亡,用于纯顺序断言。 + /// + private static TestConfigProvider BuildTankyConfig() + { + TestConfigProvider config = TestConfigProvider.Standard1v1(); + config.AddEnemy(TestConfigs.EnemyWithSpeed(3001, 500, 8, 20, 201)); + config.AddEnemy(TestConfigs.EnemyWithSpeed(3002, 500, 8, 20, 201)); + config.AddEnemy(TestConfigs.EnemyWithSpeed(3003, 500, 8, 20, 201)); + config.AddEnemy(TestConfigs.EnemyWithSpeed(3004, 500, 8, 20, 201)); + config.AddParty(TestConfigs.Party(4001, 3001, 3002, 3003, 3004)); + return config; + } + + private static List BuildFourPlayers(int speed) + { + return new List + { + TestBattle.Player(speed: speed, partyOrder: 1, characterId: 1001), + TestBattle.Player(speed: speed, partyOrder: 2, characterId: 1002), + TestBattle.Player(speed: speed, partyOrder: 3, characterId: 1003), + TestBattle.Player(speed: speed, partyOrder: 4, characterId: 1004) + }; + } + + /// + /// 驱动当前一轮全部行动,返回行动者 ID 序列;战斗提前结束则立即返回。 + /// + private static List CollectActorsInRound(BattleRuntime runtime) + { + List actors = new List(); + int startRound = runtime.RoundNumber; + while (runtime.Result == null && runtime.RoundNumber == startRound) + { + BattleUnit actor = runtime.CurrentActor; + actors.Add(actor.UnitId); + if (actor.Faction == BattleFaction.Player) + { + int targetUnitId = FirstActiveEnemy(runtime); + runtime.SubmitCommand(TestBattle.Attack(actor.UnitId, targetUnitId)); + } + else + { + runtime.AdvanceEnemyTurn(); + } + } + + return actors; + } + + private static List CollectRecords(BattleRuntime runtime) + { + List records = new List(); + int guard = 0; + while (runtime.Result == null && guard++ < 200) + { + BattleUnit actor = runtime.CurrentActor; + BattleStep step; + if (actor != null && actor.Faction == BattleFaction.Player) + { + step = runtime.SubmitCommand(TestBattle.Attack(actor.UnitId, FirstActiveEnemy(runtime))); + } + else + { + step = runtime.AdvanceEnemyTurn(); + } + + foreach (BattleEvent battleEvent in step.Events) + { + records.Add(string.Format("{0}:{1}:{2}:{3}:{4}", + battleEvent.ActorUnitId, battleEvent.ActionConfigId, battleEvent.TargetUnitId, + battleEvent.AfterHp, battleEvent.AfterMp)); + } + } + + return records; + } + + private static int FirstActiveEnemy(BattleRuntime runtime) + { + foreach (BattleUnit unit in runtime.Units) + { + if (unit.Faction == BattleFaction.Enemy && BattleRuntime.IsActive(unit)) + { + return unit.UnitId; + } + } + + return 0; + } + } +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeSchedulingTests.cs.meta b/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeSchedulingTests.cs.meta new file mode 100644 index 0000000..2b8b367 --- /dev/null +++ b/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeSchedulingTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8b0e9537105684f5684b057ae8067677 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Tests/EditMode/Battle/TestBattleSupport.cs b/Assets/GameMain/Scripts/Tests/EditMode/Battle/TestBattleSupport.cs index 7815aea..17e1a0c 100644 --- a/Assets/GameMain/Scripts/Tests/EditMode/Battle/TestBattleSupport.cs +++ b/Assets/GameMain/Scripts/Tests/EditMode/Battle/TestBattleSupport.cs @@ -49,10 +49,18 @@ namespace SepCore.Tests } public static EnemyConfig Enemy(int id, int maxHp, int atk, params int[] actionIds) + { + return EnemyWithSpeed(id, maxHp, atk, 8, actionIds); + } + + /// + /// 指定速度的敌人;独立方法名避免与上面 params 重载产生歧义。 + /// + public static EnemyConfig EnemyWithSpeed(int id, int maxHp, int atk, int speed, params int[] actionIds) { return TestConfigFactory.Create( "Id", id, "Name", "敌人" + id, "MaxHp", maxHp, "MaxMp", 0, "Atk", atk, "Mat", 0, - "Speed", 8, "ThreatLevelId", 1, "AiType", EnemyAiType.Random, + "Speed", speed, "ThreatLevelId", 1, "AiType", EnemyAiType.Random, "ActionIds", new List(actionIds), "DropTableId", 0); } @@ -163,21 +171,22 @@ namespace SepCore.Tests internal static class TestBattle { /// - /// 标准玩家 1001:120 HP / 40 MP,ATK 14,攻击行动 1,技能行动 101。 + /// 标准玩家:120 HP / 40 MP,ATK 14,攻击行动 1,技能行动 101。 /// - public static RunPlayerState Player(int currentHp = 120, int maxHp = 120, int atk = 14) + public static RunPlayerState Player(int currentHp = 120, int maxHp = 120, int atk = 14, + int speed = 12, int partyOrder = 1, int characterId = 1001) { return new RunPlayerState { - CharacterId = 1001, - PartyOrder = 1, + CharacterId = characterId, + PartyOrder = partyOrder, CurrentHp = currentHp, CurrentMp = 40, MaxHp = maxHp, MaxMp = 40, Atk = atk, Mat = 6, - Speed = 12, + Speed = speed, AttackActionId = 1, SkillActionId = 101 }; diff --git a/Assets/GameMain/Scripts/UI/Battle/BattleActorCardItem.cs b/Assets/GameMain/Scripts/UI/Battle/BattleActorCardItem.cs index ad7cab2..55f8a74 100644 --- a/Assets/GameMain/Scripts/UI/Battle/BattleActorCardItem.cs +++ b/Assets/GameMain/Scripts/UI/Battle/BattleActorCardItem.cs @@ -21,9 +21,11 @@ namespace SepCore.UI [SerializeField] private TextMeshProUGUI mpText; private int _iconVersion; + private int _currentUnitId; /// /// 用战斗单位视图填充我方卡片:名称、HP/MP 数值与血条、当前行动者标记和配置图标。 + /// 同一单位复用时不重复加载图标。 /// public void SetUnit(BattleUnitView unit, bool isCurrentActor) { @@ -55,8 +57,12 @@ namespace SepCore.UI activeMarker.SetActive(isCurrentActor); } - _iconVersion++; - ShowIconAsync(BattleUnitViewHelper.GetPlayerIconConfig(unit.ConfigId), _iconVersion).Forget(); + if (_currentUnitId != unit.BattleUnitId) + { + _currentUnitId = unit.BattleUnitId; + _iconVersion++; + ShowIconAsync(BattleUnitViewHelper.GetPlayerIconConfig(unit.ConfigId), _iconVersion).Forget(); + } } /// diff --git a/Assets/GameMain/Scripts/UI/Battle/BattleEnemySlotItem.cs b/Assets/GameMain/Scripts/UI/Battle/BattleEnemySlotItem.cs index 65ae3cd..4e5fad0 100644 --- a/Assets/GameMain/Scripts/UI/Battle/BattleEnemySlotItem.cs +++ b/Assets/GameMain/Scripts/UI/Battle/BattleEnemySlotItem.cs @@ -20,10 +20,11 @@ namespace SepCore.UI [SerializeField] private TextMeshProUGUI hpText; private int _iconVersion; + private int _currentUnitId; /// /// 用战斗单位视图填充敌人槽:名称、HP 数值与血条、配置图标和当前行动者标记。 - /// 阵亡或已逃跑目标不可选。 + /// 阵亡或已逃跑目标不可选;同一单位复用时不重复加载图标。 /// public void SetEnemy(BattleUnitView unit, bool isCurrentActor) { @@ -54,8 +55,12 @@ namespace SepCore.UI targetButton.interactable = !unit.IsDefeated && !unit.IsEscaped; } - _iconVersion++; - ShowIconAsync(BattleUnitViewHelper.GetEnemyIconConfig(unit.ConfigId), _iconVersion).Forget(); + if (_currentUnitId != unit.BattleUnitId) + { + _currentUnitId = unit.BattleUnitId; + _iconVersion++; + ShowIconAsync(BattleUnitViewHelper.GetEnemyIconConfig(unit.ConfigId), _iconVersion).Forget(); + } } /// diff --git a/Assets/GameMain/Scripts/UI/Battle/BattleForm.Logic.cs b/Assets/GameMain/Scripts/UI/Battle/BattleForm.Logic.cs index 2a95672..b4f632e 100644 --- a/Assets/GameMain/Scripts/UI/Battle/BattleForm.Logic.cs +++ b/Assets/GameMain/Scripts/UI/Battle/BattleForm.Logic.cs @@ -18,16 +18,12 @@ namespace SepCore.UI private int _displayedRound; private readonly List _turnSlots = new List(); private readonly List _turnSlotUnitIds = new List(); + private readonly List _enemySlots = new List(); protected override void OnInit(object userData) { base.OnInit(userData); - View.attackButton.onClick.AddListener(OnAttackButtonClick); - View.skillButton.onClick.AddListener(OnSkillButtonClick); - View.itemButton.onClick.AddListener(OnItemButtonClick); - View.escapeButton.onClick.AddListener(OnEscapeButtonClick); - // M1 只开放攻击;道具、技能、逃跑在后续里程碑接入 View.itemButton.interactable = false; View.skillButton.interactable = false; @@ -38,6 +34,12 @@ namespace SepCore.UI { base.OnOpen(userData); + // UIForm 实例复用:每次打开都重新注册按钮监听(OnClose 会移除) + View.attackButton.onClick.AddListener(OnAttackButtonClick); + View.skillButton.onClick.AddListener(OnSkillButtonClick); + View.itemButton.onClick.AddListener(OnItemButtonClick); + View.escapeButton.onClick.AddListener(OnEscapeButtonClick); + if (GameEntry.TurnBattle != null) { GameEntry.TurnBattle.SetStepListener(ApplyStep); @@ -47,6 +49,7 @@ namespace SepCore.UI _displayedRound = 0; _turnSlots.Clear(); _turnSlotUnitIds.Clear(); + _enemySlots.Clear(); Refresh(GameEntry.TurnBattle != null ? GameEntry.TurnBattle.GetViewState() : null); } @@ -164,19 +167,32 @@ namespace SepCore.UI return; } - template.gameObject.SetActive(false); - ClearSlots(View.enemySlotsRoot, template.transform); - + List enemies = new List(); foreach (BattleUnitView unit in view.Units) { - if (unit.Faction != BattleFaction.Enemy) + if (unit.Faction == BattleFaction.Enemy) { - continue; + enemies.Add(unit); } + } - BattleEnemySlotItem slot = Instantiate(template, View.enemySlotsRoot); - slot.gameObject.SetActive(true); - slot.SetEnemy(unit, unit.BattleUnitId == view.CurrentActorUnitId); + // 敌人数量在一场战斗内固定:数量变化(新开战斗/规模不同)时才重建,之后原地复用 + if (_enemySlots.Count != enemies.Count) + { + template.gameObject.SetActive(false); + ClearSlots(View.enemySlotsRoot, template.transform); + _enemySlots.Clear(); + foreach (BattleUnitView unit in enemies) + { + BattleEnemySlotItem slot = Instantiate(template, View.enemySlotsRoot); + slot.gameObject.SetActive(true); + _enemySlots.Add(slot); + } + } + + for (int i = 0; i < _enemySlots.Count; i++) + { + _enemySlots[i].SetEnemy(enemies[i], enemies[i].BattleUnitId == view.CurrentActorUnitId); } } @@ -189,7 +205,8 @@ namespace SepCore.UI return; } - if (view.RoundNumber != _displayedRound) + if (view.RoundNumber != _displayedRound || + (view.CurrentActorUnitId != 0 && !_turnSlotUnitIds.Contains(view.CurrentActorUnitId))) { RebuildTurnSlots(view, template); _displayedRound = view.RoundNumber; @@ -204,28 +221,41 @@ namespace SepCore.UI } /// - /// 新一轮开始时重建本轮完整顺序:全部活跃单位按显示顺序排列, + /// 新一轮开始时刷新本轮顺序:从当前行动者开始的剩余单位按调度优先级排列, /// 当前行动者高亮;本轮内已行动单位保持可见。 + /// 数量与上一轮相同时复用槽对象,只重建单位映射与内容; + /// 先制第一轮只有玩家候选,敌人进入行动阶段时(当前行动者不在列表)自动重建为剩余敌人。 /// private void RebuildTurnSlots(BattleViewState view, BattleTurnSlotItem template) { - template.gameObject.SetActive(false); - ClearSlots(View.turnSlotsRoot, template.transform); - _turnSlots.Clear(); - _turnSlotUnitIds.Clear(); - - foreach (BattleUnitView unit in view.Units) + List units = new List(); + foreach (int unitId in view.RemainingTurnOrder) { - if (unit.IsDefeated || unit.IsEscaped) + BattleUnitView unit = FindUnit(view, unitId); + if (unit != null) { - continue; + units.Add(unit); } + } - BattleTurnSlotItem slot = Instantiate(template, View.turnSlotsRoot); - slot.gameObject.SetActive(true); - slot.SetTurnSlot(unit, unit.BattleUnitId == view.CurrentActorUnitId); - _turnSlots.Add(slot); - _turnSlotUnitIds.Add(unit.BattleUnitId); + if (_turnSlots.Count != units.Count) + { + template.gameObject.SetActive(false); + ClearSlots(View.turnSlotsRoot, template.transform); + _turnSlots.Clear(); + foreach (BattleUnitView unit in units) + { + BattleTurnSlotItem slot = Instantiate(template, View.turnSlotsRoot); + slot.gameObject.SetActive(true); + _turnSlots.Add(slot); + } + } + + _turnSlotUnitIds.Clear(); + for (int i = 0; i < _turnSlots.Count; i++) + { + _turnSlotUnitIds.Add(units[i].BattleUnitId); + _turnSlots[i].SetTurnSlot(units[i], units[i].BattleUnitId == view.CurrentActorUnitId); } } @@ -289,7 +319,7 @@ namespace SepCore.UI return 0; } -private static BattleUnitView FindUnit(BattleViewState view, int unitId) + private static BattleUnitView FindUnit(BattleViewState view, int unitId) { foreach (BattleUnitView unit in view.Units) { diff --git a/Assets/GameMain/Scripts/UI/Battle/BattleTurnSlotItem.cs b/Assets/GameMain/Scripts/UI/Battle/BattleTurnSlotItem.cs index 35bc299..ea8f8cc 100644 --- a/Assets/GameMain/Scripts/UI/Battle/BattleTurnSlotItem.cs +++ b/Assets/GameMain/Scripts/UI/Battle/BattleTurnSlotItem.cs @@ -16,9 +16,11 @@ namespace SepCore.UI [SerializeField] private TextMeshProUGUI label; private int _iconVersion; + private int _currentUnitId; /// /// 填充一个回合顺序槽:单位显示名、配置图标与当前行动者标记。 + /// 槽被复用到同一单位时不重复加载图标。 /// public void SetTurnSlot(BattleUnitView unit, bool isCurrentActor) { @@ -34,8 +36,12 @@ namespace SepCore.UI SetCurrentActor(isCurrentActor); - _iconVersion++; - LoadIconAsync(unit, _iconVersion).Forget(); + if (_currentUnitId != unit.BattleUnitId) + { + _currentUnitId = unit.BattleUnitId; + _iconVersion++; + LoadIconAsync(unit, _iconVersion).Forget(); + } } /// diff --git a/Docs/Tech/04_TurnBasedCombatDevelopmentTODO.md b/Docs/Tech/04_TurnBasedCombatDevelopmentTODO.md index 2751101..02de4b2 100644 --- a/Docs/Tech/04_TurnBasedCombatDevelopmentTODO.md +++ b/Docs/Tech/04_TurnBasedCombatDevelopmentTODO.md @@ -36,7 +36,7 @@ Play Mode:未验证 / 通过(操作步骤、可见结果、截图或录像 | --- | --- | --- | | M0 战斗壳层与单局基础状态 | Main 场景可打开和关闭半透明战斗壳层,地图更新暂停 | 已完成 | | M1 架构收敛与 1v1 攻击闭环 | 玩家攻击、敌人反击并产生胜负 | 已完成 | -| M2 4v4 调度、先制与确定性随机 | 多人队伍按实时速度行动,普通和先制顺序正确 | 未开始 | +| M2 4v4 调度、先制与确定性随机 | 多人队伍按实时速度行动,普通和先制顺序正确 | 已完成 | | M3 技能、目标、MP 与数值效果 | 攻击和技能可合法选目标并结算 HP/MP | 未开始 | | M4 状态、眩晕与速度变化 | 眩晕跳过行动,速度变化立即刷新顺序 | 未开始 | | M5 逃跑、结束矩阵与单局回写 | 四种结果正确,连续战斗继承 HP/MP | 未开始 | @@ -50,7 +50,7 @@ Play Mode:未验证 / 通过(操作步骤、可见结果、截图或录像 | --- | --- | --- | --- | --- | --- | | M0 | 已完成 | 通过(2026-08-31,dotnet build SepCore.Runtime.csproj 0 错误;Unity 编辑器内 Play Mode 实测编译通过) | 不适用(M0 无 EditMode 用例) | 通过(2026-08-31,见 M0 证据) | 已记录(M0 小节) | | M1 | 已完成 | 通过(2026-09-02,dotnet build SepCore.Runtime.csproj 0 错误 0 警告;Unity 编辑器内编译通过) | 通过(2026-09-02,25 个 BattleRuntime 纯逻辑用例全部通过) | 通过(2026-09-02,见 M1 证据) | 已记录(M1 小节) | -| M2 | 未开始 | 未验证 | 未验证 | 未验证 | 未记录 | +| M2 | 已完成 | 通过(2026-09-02,dotnet build SepCore.Runtime.csproj 0 错误 0 警告;Unity 编辑器内编译通过) | 通过(2026-09-02,35 个 BattleRuntime 纯逻辑用例全部通过) | 通过(2026-09-02,见 M2 证据) | 已记录(M2 小节) | | M3 | 未开始 | 未验证 | 未验证 | 未验证 | 未记录 | | M4 | 未开始 | 未验证 | 未验证 | 未验证 | 未记录 | | M5 | 未开始 | 未验证 | 未验证 | 未验证 | 未记录 | @@ -452,7 +452,7 @@ Play Mode:通过(2026-09-02,Debugger -> Battle/Shell 实测: 调度仍按单位创建顺序轮流,M2 由完整速度/先制规则替换 ``` -### [ ] M2 1 至 4 人多对多、先制与确定性调度 +### [x] M2 1 至 4 人多对多、先制与确定性调度 目标:一次完成完整的行动顺序模型。 @@ -479,6 +479,28 @@ Play Mode 验收: - 两个相同 EnemyConfig 的敌人可以分别选择、受伤和死亡。 - 相同初始状态和随机序列产生相同行动记录。 +验证证据: + +```text +代码状态:已完成 +Unity 编译:通过(2026-09-02,dotnet build SepCore.Runtime.csproj 0 错误 0 警告;Unity 编辑器内编译通过) +EditMode:通过(2026-09-02,BattleRuntime 纯逻辑用例 35 个全部通过;M2 新增调度用例 10 个: + 速度排序、跨阵营同速玩家优先、同阵营同速 PartyOrder、2v2 每轮各行动一次、 + 先制第一轮玩家全先行且第二轮恢复速度序、非先制首轮直接速度序、 + 重复敌人配置分别击杀、4v4 创建 8 单位、轮到前阵亡移出候选集、4v4 固定随机序列复现) +Play Mode:通过(2026-09-02,Debugger -> Battle/Shell 实测: + 1. 调试入口 1v1 / 2v2 / 4v4 与先制开关可用; + 2. 普通模式按速度、阵营和队伍顺序行动,先制第一轮玩家全先行、第二轮恢复; + 3. 2v2 两个相同敌人可分别受伤和死亡; + 4. 每轮各单位最多行动一次,TurnSlots 按调度顺序显示; + 5. 完成一场后 Close 再 Open 可正常重开并继续行动(修复 UIForm 复用导致按钮监听丢失); + 6. 敌人数量固定时 EnemySlots/TurnSlots 复用 item,不再逐次销毁实例化。 + 截图:未提供) +提交或差异:未提交(working tree 差异:调度规则与先制、调试入口扩展、TurnSlots/EnemySlots 复用、 + 敌人图标数据、新增队伍预设 4005、EditMode 调度用例) +剩余问题:无阻塞。行动间歇时长仍为组件常量 0.6f;敌人目标选择(选中态视觉)按文档留到 M3/M7 +``` + ### [ ] M3 技能、目标、MP 与数值效果 目标:让当前配表中的数值型攻击和技能完整可用。 diff --git a/数据表/Datas/EnemyPartyConfig_敌人队伍配置.xlsx b/数据表/Datas/EnemyPartyConfig_敌人队伍配置.xlsx index 52440557881ee702e5783b3b42c1f535d2d92cf0..df08c722420c03d7b1f3c739d3f050badfd4c52d 100644 GIT binary patch delta 6535 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 H|6>0G`NuFP delta 6577 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 NK5F_mm*js}{s-V-V3Pm<