M2 回合制调度:速度/阵营/队伍顺序规则、先制第一轮、4v4 调试入口与调度测试

- BattleRuntime 调度替换为完整规则:速度高优先、同速玩家优先、同阵营同速 PartyOrder 小优先;先制第一轮玩家全先行,第二轮恢复普通排序;RemainingTurnOrder 同步按调度序输出
- TurnBattleComponent 开局行动者为敌人时自动推进,协程等待 UI 监听注册避免先手事件丢失
- 调试入口支持 1v1 / 2v2 / 4v4 与先制开关;新增敌人队伍预设 4005(四普通敌人)
- UI:修复 UIForm 复用导致第二次开战按钮监听丢失;EnemySlots/TurnSlots 改为按数量复用 item,图标同一单位不重复加载
- EditMode 新增 10 个调度用例(共 35 个):排序规则、先制两轮、重复敌人分别击杀、4v4 创建、死亡移出候选、固定随机序列复现
This commit is contained in:
huangjun
2026-09-02 10:12:30 +08:00
parent 7dcac0ac87
commit 202ab337f9
14 changed files with 605 additions and 85 deletions
@@ -1 +1 @@
▐║ Е█∙Ф∙▄Д╨╨▀╧▐╒Е▐▄Ф≥╝И─ Ф∙▄Д╨╨▀╧▀╧▐ё Ф╥╥Е░┬И≤÷Д╪█▀╧▀╨▐╓И╚≤Е╗│Х┐│И≤÷Д╪█▀╨▀╩
▐║ Е█∙Ф∙▄Д╨╨▀╧▐╒Е▐▄Ф≥╝И─ Ф∙▄Д╨╨▀╧▀╧▐ё Ф╥╥Е░┬И≤÷Д╪█▀╧▀╨▐╓И╚≤Е╗│Х┐│И≤÷Д╪█▀╨▀╩▐╔Е⌡⌡Ф≥╝И─ Ф∙▄Д╨╨▀╧▀╧▀╧▀╧
@@ -29,5 +29,15 @@
3002,
3003
]
},
{
"id": 4005,
"name": "四普通敌人",
"enemyIds": [
3001,
3001,
3001,
3001
]
}
]
@@ -244,12 +244,9 @@ namespace SepCore.Battle
List<int> remainingOrder = new List<int>();
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
}
/// <summary>
/// 选择本轮下一个未行动的活跃单位;全部行动过则进入下一轮。
/// M1 按单位创建顺序(玩家在前)轮流,每轮每个单位最多行动一次;
/// M2 起由完整调度规则(速度、阵营、队伍顺序、先制)替换。
/// 选择本轮下一个未行动的活跃单位;候选集为空时开启下一轮。
/// 调度规则(文档 6.1):
/// 1. 先制第一轮仍有玩家未行动时只在玩家中选择;
/// 2. 当前速度高者优先;
/// 3. 同速时玩家优先于敌人;
/// 4. 同阵营同速时 PartyOrder 小者优先;
/// 5. 已行动单位不因速度变化再次行动(M2 无速度变化效果);
/// 6. 候选集为空时开启下一轮,先制限制只适用于第一轮。
/// </summary>
private void SelectNextActor()
{
@@ -435,17 +437,93 @@ namespace SepCore.Battle
CurrentActorUnitId = next != null ? next.UnitId : 0;
}
/// <summary>
/// 按调度规则选本轮优先级最高的候选;没有候选时返回 null。
/// </summary>
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;
}
/// <summary>
/// 先制限制只适用于第一轮。
/// </summary>
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;
}
/// <summary>
/// 速度高优先;同速玩家优先;同阵营同速 PartyOrder 小优先。返回负值表示 a 优先于 b。
/// </summary>
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;
}
/// <summary>
/// 本轮尚未行动的候选单位,按调度优先级排序;当前行动者是首个。
/// 先制第一轮只返回玩家候选(敌人等玩家全部行动后进入候选集)。
/// </summary>
private List<BattleUnit> GetRemainingCandidates()
{
List<BattleUnit> candidates = new List<BattleUnit>();
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;
}
/// <summary>
@@ -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)
{
@@ -11,11 +11,18 @@ namespace SepCore.Debugger
/// <summary>
/// 战斗壳层调试入口(仅供 Editor/Development Build,正式构建不注册)。
/// 模拟探索层:缺少单局状态时用配表角色与固定种子初始化临时状态,
/// 然后通过 TurnBattleComponent 以固定调试遭遇开始/结束战斗,不依赖尚未实现的地图敌人。
/// 然后通过 TurnBattleComponent 以调试遭遇开始/结束战斗,不依赖尚未实现的地图敌人。
/// 支持 1v1 / 2v2 / 4v4 与先制开关。
/// </summary>
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("<b>Battle Shell</b>");
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<RunPlayerState> BuildDebugPlayers()
private static List<RunPlayerState> BuildDebugPlayers(int count)
{
List<RunPlayerState> players = new List<RunPlayerState>();
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;
}
@@ -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<RunPlayerState> { 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<RunPlayerState> { 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<RunPlayerState> players = new List<RunPlayerState>
{
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<RunPlayerState> players = new List<RunPlayerState>
{
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<int> round1 = CollectActorsInRound(runtime);
CollectionAssert.AreEqual(new List<int> { 3, 1, 2, 4 }, round1);
Assert.AreEqual(2, runtime.RoundNumber);
}
[Test]
public void PreemptiveFirstRound_AllPlayersActBeforeEnemies()
{
TestConfigProvider config = BuildTankyConfig();
List<RunPlayerState> players = BuildFourPlayers(speed: 8);
BattleRuntime runtime = BattleRuntime.Create(
new BattleEncounter(1, 4001, true), players, config, new TestRandomSource());
List<int> round1 = CollectActorsInRound(runtime);
CollectionAssert.AreEqual(new List<int> { 1, 2, 3, 4, 5, 6, 7, 8 }, round1);
List<int> round2 = CollectActorsInRound(runtime);
CollectionAssert.AreEqual(new List<int> { 5, 6, 7, 8, 1, 2, 3, 4 }, round2);
}
[Test]
public void NonPreemptive_RoundOne_UsesSpeedOrder()
{
TestConfigProvider config = BuildTankyConfig();
List<RunPlayerState> players = BuildFourPlayers(speed: 8);
BattleRuntime runtime = BattleRuntime.Create(
new BattleEncounter(1, 4001, false), players, config, new TestRandomSource());
List<int> round1 = CollectActorsInRound(runtime);
// 敌人速度 20 高于玩家 8:第一轮就按速度排
CollectionAssert.AreEqual(new List<int> { 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<RunPlayerState> { 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<int> unitIds = new HashSet<int>();
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<RunPlayerState> { 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<RunPlayerState> players = new List<RunPlayerState>
{
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));
}
/// <summary>
/// 防御性敌人配置:500 HP 保证两轮内无人阵亡,用于纯顺序断言。
/// </summary>
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<RunPlayerState> BuildFourPlayers(int speed)
{
return new List<RunPlayerState>
{
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)
};
}
/// <summary>
/// 驱动当前一轮全部行动,返回行动者 ID 序列;战斗提前结束则立即返回。
/// </summary>
private static List<int> CollectActorsInRound(BattleRuntime runtime)
{
List<int> actors = new List<int>();
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<string> CollectRecords(BattleRuntime runtime)
{
List<string> records = new List<string>();
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;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8b0e9537105684f5684b057ae8067677
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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);
}
/// <summary>
/// 指定速度的敌人;独立方法名避免与上面 params 重载产生歧义。
/// </summary>
public static EnemyConfig EnemyWithSpeed(int id, int maxHp, int atk, int speed, params int[] actionIds)
{
return TestConfigFactory.Create<EnemyConfig>(
"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<int>(actionIds), "DropTableId", 0);
}
@@ -163,21 +171,22 @@ namespace SepCore.Tests
internal static class TestBattle
{
/// <summary>
/// 标准玩家 1001:120 HP / 40 MP,ATK 14,攻击行动 1,技能行动 101。
/// 标准玩家:120 HP / 40 MP,ATK 14,攻击行动 1,技能行动 101。
/// </summary>
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
};
@@ -21,9 +21,11 @@ namespace SepCore.UI
[SerializeField] private TextMeshProUGUI mpText;
private int _iconVersion;
private int _currentUnitId;
/// <summary>
/// 用战斗单位视图填充我方卡片:名称、HP/MP 数值与血条、当前行动者标记和配置图标。
/// 同一单位复用时不重复加载图标。
/// </summary>
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();
}
}
/// <summary>
@@ -20,10 +20,11 @@ namespace SepCore.UI
[SerializeField] private TextMeshProUGUI hpText;
private int _iconVersion;
private int _currentUnitId;
/// <summary>
/// 用战斗单位视图填充敌人槽:名称、HP 数值与血条、配置图标和当前行动者标记。
/// 阵亡或已逃跑目标不可选。
/// 阵亡或已逃跑目标不可选;同一单位复用时不重复加载图标。
/// </summary>
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();
}
}
/// <summary>
@@ -18,16 +18,12 @@ namespace SepCore.UI
private int _displayedRound;
private readonly List<BattleTurnSlotItem> _turnSlots = new List<BattleTurnSlotItem>();
private readonly List<int> _turnSlotUnitIds = new List<int>();
private readonly List<BattleEnemySlotItem> _enemySlots = new List<BattleEnemySlotItem>();
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<BattleUnitView> enemies = new List<BattleUnitView>();
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
}
/// <summary>
/// 新一轮开始时重建本轮完整顺序:全部活跃单位按显示顺序排列,
/// 新一轮开始时刷新本轮顺序:从当前行动者开始的剩余单位按调度优先级排列,
/// 当前行动者高亮;本轮内已行动单位保持可见。
/// 数量与上一轮相同时复用槽对象,只重建单位映射与内容;
/// 先制第一轮只有玩家候选,敌人进入行动阶段时(当前行动者不在列表)自动重建为剩余敌人。
/// </summary>
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<BattleUnitView> units = new List<BattleUnitView>();
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)
{
@@ -16,9 +16,11 @@ namespace SepCore.UI
[SerializeField] private TextMeshProUGUI label;
private int _iconVersion;
private int _currentUnitId;
/// <summary>
/// 填充一个回合顺序槽:单位显示名、配置图标与当前行动者标记。
/// 槽被复用到同一单位时不重复加载图标。
/// </summary>
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();
}
}
/// <summary>
+25 -3
View File
@@ -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 与数值效果
目标:让当前配表中的数值型攻击和技能完整可用。