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 5244055..df08c72 100644 Binary files a/数据表/Datas/EnemyPartyConfig_敌人队伍配置.xlsx and b/数据表/Datas/EnemyPartyConfig_敌人队伍配置.xlsx differ