diff --git a/Assets/GameMain/DataTables/tbenemyconfig.bytes b/Assets/GameMain/DataTables/tbenemyconfig.bytes index 57d115c..287c13b 100644 Binary files a/Assets/GameMain/DataTables/tbenemyconfig.bytes and b/Assets/GameMain/DataTables/tbenemyconfig.bytes differ diff --git a/Assets/GameMain/DataTables/tbenemyconfig.json b/Assets/GameMain/DataTables/tbenemyconfig.json index 84301fd..c5e325c 100644 --- a/Assets/GameMain/DataTables/tbenemyconfig.json +++ b/Assets/GameMain/DataTables/tbenemyconfig.json @@ -12,7 +12,8 @@ "actionIds": [ 201 ], - "dropTableId": 1 + "dropTableId": 1, + "icon": "敌人1" }, { "id": 3002, @@ -27,7 +28,8 @@ "actionIds": [ 201 ], - "dropTableId": 2 + "dropTableId": 2, + "icon": "敌人2" }, { "id": 3003, @@ -43,6 +45,7 @@ 201, 202 ], - "dropTableId": 3 + "dropTableId": 3, + "icon": "敌人3" } ] \ No newline at end of file diff --git a/Assets/GameMain/DataTables/tbspriteconfig.bytes b/Assets/GameMain/DataTables/tbspriteconfig.bytes index 256da9b..e29edcb 100644 Binary files a/Assets/GameMain/DataTables/tbspriteconfig.bytes and b/Assets/GameMain/DataTables/tbspriteconfig.bytes differ diff --git a/Assets/GameMain/DataTables/tbspriteconfig.json b/Assets/GameMain/DataTables/tbspriteconfig.json index 41af409..01a6438 100644 --- a/Assets/GameMain/DataTables/tbspriteconfig.json +++ b/Assets/GameMain/DataTables/tbspriteconfig.json @@ -19,6 +19,21 @@ "atlasPath": "Characters.png", "spriteIndex": 3 }, + { + "key": "敌人1", + "atlasPath": "Monsters.png", + "spriteIndex": 0 + }, + { + "key": "敌人2", + "atlasPath": "Monsters.png", + "spriteIndex": 1 + }, + { + "key": "敌人3", + "atlasPath": "Monsters.png", + "spriteIndex": 2 + }, { "key": "旧硬币", "atlasPath": "Items.png", diff --git a/Assets/GameMain/Scripts/Base/Gen/EnemyConfig.cs b/Assets/GameMain/Scripts/Base/Gen/EnemyConfig.cs index 4266423..95317c2 100644 --- a/Assets/GameMain/Scripts/Base/Gen/EnemyConfig.cs +++ b/Assets/GameMain/Scripts/Base/Gen/EnemyConfig.cs @@ -27,6 +27,8 @@ public sealed partial class EnemyConfig : Luban.BeanBase AiType = (EnemyAiType)_buf.ReadInt(); {int n0 = System.Math.Min(_buf.ReadSize(), _buf.Size);ActionIds = new System.Collections.Generic.List(n0);for(var i0 = 0 ; i0 < n0 ; i0++) { int _e0; _e0 = _buf.ReadInt(); ActionIds.Add(_e0);}} DropTableId = _buf.ReadInt(); + Icon = _buf.ReadString(); + Icon_Ref = null; } public static EnemyConfig DeserializeEnemyConfig(ByteBuf _buf) @@ -78,6 +80,11 @@ public sealed partial class EnemyConfig : Luban.BeanBase /// 独立掉落表ID /// public readonly int DropTableId; + /// + /// 图标资源配置 + /// + public readonly string Icon; + public SpriteConfig Icon_Ref; public const int __ID__ = 1284055242; public override int GetTypeId() => __ID__; @@ -95,6 +102,7 @@ public sealed partial class EnemyConfig : Luban.BeanBase + Icon_Ref = tables.TbSpriteConfig.GetOrDefault(Icon); } public override string ToString() @@ -111,6 +119,7 @@ public sealed partial class EnemyConfig : Luban.BeanBase + "aiType:" + AiType + "," + "actionIds:" + Luban.StringUtil.CollectionToString(ActionIds) + "," + "dropTableId:" + DropTableId + "," + + "icon:" + Icon + "," + "}"; } } diff --git a/Assets/GameMain/Scripts/Editor/BattleUIPrefabBuilder.cs b/Assets/GameMain/Scripts/Editor/BattleUIPrefabBuilder.cs index 9fda1c8..2ab41d1 100644 --- a/Assets/GameMain/Scripts/Editor/BattleUIPrefabBuilder.cs +++ b/Assets/GameMain/Scripts/Editor/BattleUIPrefabBuilder.cs @@ -241,7 +241,6 @@ namespace SepCore.Editor i == 0 ? Ink : MutedWhite, TextAlignmentOptions.Center, FontStyles.Bold); BattleTurnSlotItem slot = slotRect.gameObject.AddComponent(); - slot.ConfigureEditor(marker.gameObject, icon, label); RegisterReference(slot, "turnSlot" + (i + 1)); } } @@ -292,7 +291,6 @@ namespace SepCore.Editor Image mpFill = CreateBar(cardRect, "MP", contentX, 112f, 242f, Blue, mp, maxMp, out TextMeshProUGUI mpText); BattleActorCardItem card = cardRect.gameObject.AddComponent(); - card.ConfigureEditor(marker.gameObject, icon, hpFill, mpFill, name, hpText, mpText); RegisterReference(card, "playerCard" + (index + 1)); } @@ -330,7 +328,6 @@ namespace SepCore.Editor Image hpFill = CreateEnemyBar(slotRect, hp[i], maxHp[i], out TextMeshProUGUI hpText); BattleEnemySlotItem slot = slotRect.gameObject.AddComponent(); - slot.ConfigureEditor(targetButton, selected.gameObject, icon, hpFill, name, hpText); RegisterReference(slot, "enemySlot" + (i + 1)); } } diff --git a/Assets/GameMain/Scripts/Runtime/AssemblyInfo.cs b/Assets/GameMain/Scripts/Runtime/AssemblyInfo.cs new file mode 100644 index 0000000..f825014 --- /dev/null +++ b/Assets/GameMain/Scripts/Runtime/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Tests")] \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Runtime/Battle/BattlePlayerInput.cs.meta b/Assets/GameMain/Scripts/Runtime/AssemblyInfo.cs.meta similarity index 83% rename from Assets/GameMain/Scripts/Runtime/Battle/BattlePlayerInput.cs.meta rename to Assets/GameMain/Scripts/Runtime/AssemblyInfo.cs.meta index 3c73e92..ba5f3f7 100644 --- a/Assets/GameMain/Scripts/Runtime/Battle/BattlePlayerInput.cs.meta +++ b/Assets/GameMain/Scripts/Runtime/AssemblyInfo.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 1c518c47a9854ba19045abd1f211eb7d +guid: cd1500230107441cfa5e86657fa0b9e3 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Assets/GameMain/Scripts/Runtime/Base/GameEntry.Custom.cs b/Assets/GameMain/Scripts/Runtime/Base/GameEntry.Custom.cs index a64d3db..ce5e684 100644 --- a/Assets/GameMain/Scripts/Runtime/Base/GameEntry.Custom.cs +++ b/Assets/GameMain/Scripts/Runtime/Base/GameEntry.Custom.cs @@ -16,11 +16,6 @@ public partial class GameEntry : MonoBehaviour public static TurnBattleComponent TurnBattle { get; private set; } - /// - /// 单局战斗协调器(普通类,非场景组件)。 - /// - public static RunBattleCoordinator RunBattle { get; private set; } - private static void InitCustomComponents() { BuiltinData = UnityGameFramework.Runtime.GameEntry.GetComponent(); @@ -28,6 +23,5 @@ public partial class GameEntry : MonoBehaviour Save = UnityGameFramework.Runtime.GameEntry.GetComponent(); Random = UnityGameFramework.Runtime.GameEntry.GetComponent(); TurnBattle = UnityGameFramework.Runtime.GameEntry.GetComponent(); - RunBattle = new RunBattleCoordinator(); } } diff --git a/Assets/GameMain/Scripts/Runtime/Battle/BattleAdvance.cs b/Assets/GameMain/Scripts/Runtime/Battle/BattleAdvance.cs deleted file mode 100644 index aff07b0..0000000 --- a/Assets/GameMain/Scripts/Runtime/Battle/BattleAdvance.cs +++ /dev/null @@ -1,178 +0,0 @@ -using System.Collections.Generic; -using SepCore.Definition; - -namespace SepCore.Battle -{ - /// - /// 单个效果对单个目标产生的数值或状态变化。 - /// - public sealed class BattleActionEffectRecord - { - /// - /// 目标单位。 - /// - public readonly int TargetUnitId; - - /// - /// 效果结算前的 HP。 - /// - public readonly int BeforeHp; - - /// - /// 效果结算后的 HP。 - /// - public readonly int AfterHp; - - /// - /// 效果结算前的 MP。 - /// - public readonly int BeforeMp; - - /// - /// 效果结算后的 MP。 - /// - public readonly int AfterMp; - - /// - /// 状态变化列表;无状态变化时为空。 - /// - public readonly IReadOnlyList StatusChanges; - - public BattleActionEffectRecord(int targetUnitId, int beforeHp, int afterHp, int beforeMp, int afterMp, - IReadOnlyList statusChanges) - { - TargetUnitId = targetUnitId; - BeforeHp = beforeHp; - AfterHp = afterHp; - BeforeMp = beforeMp; - AfterMp = afterMp; - StatusChanges = statusChanges != null ? Copy(statusChanges) : new BattleStatusChangeRecord[0]; - } - - private static BattleStatusChangeRecord[] Copy(IReadOnlyList source) - { - BattleStatusChangeRecord[] copy = new BattleStatusChangeRecord[source.Count]; - for (int i = 0; i < source.Count; i++) - { - copy[i] = source[i]; - } - - return copy; - } - } - - /// - /// 状态变化记录。 - /// - public sealed class BattleStatusChangeRecord - { - /// - /// 状态类型。 - /// - public readonly BattleStatusType StatusType; - - /// - /// 变化后的剩余持续次数;状态被移除时为 0。 - /// - public readonly int RemainingRounds; - - public BattleStatusChangeRecord(BattleStatusType statusType, int remainingRounds) - { - StatusType = statusType; - RemainingRounds = remainingRounds; - } - } - - /// - /// 一次行动产生的有序行动记录。 - /// 首版 UI 立即消费并显示;未来动画系统可以逐条等待播放,不需要修改战斗规则内核。 - /// - public sealed class BattleActionRecord - { - /// - /// 行动者。 - /// - public readonly int ActorUnitId; - - /// - /// 指令类型,对应配表 BattleActionType。 - /// - public readonly BattleActionType CommandType; - - /// - /// 攻击或技能配置 ID;逃跑为 0。 - /// - public readonly int ActionConfigId; - - /// - /// 按结算顺序排列的效果记录。 - /// - public readonly IReadOnlyList Effects; - - public BattleActionRecord(int actorUnitId, BattleActionType commandType, int actionConfigId, - IReadOnlyList effects) - { - ActorUnitId = actorUnitId; - CommandType = commandType; - ActionConfigId = actionConfigId; - Effects = effects != null ? Copy(effects) : new BattleActionEffectRecord[0]; - } - - private static BattleActionEffectRecord[] Copy(IReadOnlyList source) - { - BattleActionEffectRecord[] copy = new BattleActionEffectRecord[source.Count]; - for (int i = 0; i < source.Count; i++) - { - copy[i] = source[i]; - } - - return copy; - } - } - - /// - /// 战斗推进返回值。 - /// - public sealed class BattleAdvance - { - /// - /// 本次推进产生的有序行动记录列表。 - /// - public readonly IReadOnlyList Records; - - /// - /// 推进结束后的只读战斗快照。 - /// - public readonly BattleSnapshot Snapshot; - - /// - /// 流程状态。 - /// - public readonly BattleFlowState FlowState; - - /// - /// 仅在 Completed 时存在,否则为 null。 - /// - public readonly BattleResult Result; - - public BattleAdvance(IReadOnlyList records, BattleSnapshot snapshot, - BattleFlowState flowState, BattleResult result) - { - Records = records != null ? Copy(records) : new BattleActionRecord[0]; - Snapshot = snapshot; - FlowState = flowState; - Result = result; - } - - private static BattleActionRecord[] Copy(IReadOnlyList source) - { - BattleActionRecord[] copy = new BattleActionRecord[source.Count]; - for (int i = 0; i < source.Count; i++) - { - copy[i] = source[i]; - } - - return copy; - } - } -} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Runtime/Battle/BattleEvent.cs b/Assets/GameMain/Scripts/Runtime/Battle/BattleEvent.cs new file mode 100644 index 0000000..a681b86 --- /dev/null +++ b/Assets/GameMain/Scripts/Runtime/Battle/BattleEvent.cs @@ -0,0 +1,78 @@ +using SepCore.Definition; + +namespace SepCore.Battle +{ + /// + /// 一次效果对单个目标的一层行动记录。 + /// 表达行动者、行动、目标以及数值或状态变化; + /// StatusType 为 None 时表示没有状态变化。 + /// + public sealed class BattleEvent + { + /// + /// 行动者。 + /// + public readonly int ActorUnitId; + + /// + /// 指令类型,对应配表 BattleActionType。 + /// + public readonly BattleActionType CommandType; + + /// + /// 攻击或技能配置 ID;逃跑为 0。 + /// + public readonly int ActionConfigId; + + /// + /// 效果结算的目标单位。 + /// + public readonly int TargetUnitId; + + /// + /// 效果结算前的 HP。 + /// + public readonly int BeforeHp; + + /// + /// 效果结算后的 HP。 + /// + public readonly int AfterHp; + + /// + /// 效果结算前的 MP。 + /// + public readonly int BeforeMp; + + /// + /// 效果结算后的 MP。 + /// + public readonly int AfterMp; + + /// + /// 施加或刷新的状态类型;无状态变化时为 None。 + /// + public readonly BattleStatusType StatusType; + + /// + /// 变化后的剩余持续次数(行动机会);状态被移除时为 0。 + /// + public readonly int StatusRemainingRounds; + + public BattleEvent(int actorUnitId, BattleActionType commandType, int actionConfigId, + int targetUnitId, int beforeHp, int afterHp, int beforeMp, int afterMp, + BattleStatusType statusType, int statusRemainingRounds) + { + ActorUnitId = actorUnitId; + CommandType = commandType; + ActionConfigId = actionConfigId; + TargetUnitId = targetUnitId; + BeforeHp = beforeHp; + AfterHp = afterHp; + BeforeMp = beforeMp; + AfterMp = afterMp; + StatusType = statusType; + StatusRemainingRounds = statusRemainingRounds; + } + } +} diff --git a/Assets/GameMain/Scripts/Runtime/Battle/BattleAdvance.cs.meta b/Assets/GameMain/Scripts/Runtime/Battle/BattleEvent.cs.meta similarity index 83% rename from Assets/GameMain/Scripts/Runtime/Battle/BattleAdvance.cs.meta rename to Assets/GameMain/Scripts/Runtime/Battle/BattleEvent.cs.meta index dc0ad2c..806fb57 100644 --- a/Assets/GameMain/Scripts/Runtime/Battle/BattleAdvance.cs.meta +++ b/Assets/GameMain/Scripts/Runtime/Battle/BattleEvent.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 84f740882a72430ca27c4d77004257d8 +guid: 9bd0c7df032194911a2aee0942338d48 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Assets/GameMain/Scripts/Runtime/Battle/BattleFlowState.cs b/Assets/GameMain/Scripts/Runtime/Battle/BattleFlowState.cs deleted file mode 100644 index af55e9b..0000000 --- a/Assets/GameMain/Scripts/Runtime/Battle/BattleFlowState.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace SepCore.Battle -{ - /// - /// 战斗流程状态。 - /// - public enum BattleFlowState - { - /// - /// 等待当前玩家行动者提交指令。 - /// - AwaitingPlayerCommand, - - /// - /// 战斗完成,结果已产出。 - /// - Completed, - } -} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Runtime/Battle/BattlePlayerInput.cs b/Assets/GameMain/Scripts/Runtime/Battle/BattlePlayerInput.cs deleted file mode 100644 index 09d84ce..0000000 --- a/Assets/GameMain/Scripts/Runtime/Battle/BattlePlayerInput.cs +++ /dev/null @@ -1,80 +0,0 @@ -namespace SepCore.Battle -{ - /// - /// 玩家输入快照,是进入本场战斗时的值快照。 - /// 装备计算由单局角色快照构建器完成;战斗内核不读取角色存档、装备栏或背包。 - /// - public sealed class BattlePlayerInput - { - /// - /// 角色配置标识;同时用于把结果回写到单局角色状态。 - /// - public readonly int CharacterId; - - /// - /// 当前战备中的顺序。 - /// - public readonly int PartyOrder; - - /// - /// 单局临时状态中的当前 HP。 - /// - public readonly int CurrentHp; - - /// - /// 单局临时状态中的当前 MP。 - /// - public readonly int CurrentMp; - - /// - /// 已结算装备加成的最终 HP 上限。 - /// - public readonly int MaxHp; - - /// - /// 已结算装备加成的最终 MP 上限。 - /// - public readonly int MaxMp; - - /// - /// 已结算装备加成的最终攻击。 - /// - public readonly int Atk; - - /// - /// 已结算装备加成的最终魔力。 - /// - public readonly int Mat; - - /// - /// 已结算装备加成的最终速度。 - /// - public readonly int Speed; - - /// - /// 普通攻击行动配置 ID。 - /// - public readonly int AttackActionId; - - /// - /// 该角色首版唯一技能行动配置 ID。 - /// - public readonly int SkillActionId; - - public BattlePlayerInput(int characterId, int partyOrder, int currentHp, int currentMp, - int maxHp, int maxMp, int atk, int mat, int speed, int attackActionId, int skillActionId) - { - CharacterId = characterId; - PartyOrder = partyOrder; - CurrentHp = currentHp; - CurrentMp = currentMp; - MaxHp = maxHp; - MaxMp = maxMp; - Atk = atk; - Mat = mat; - Speed = speed; - AttackActionId = attackActionId; - SkillActionId = skillActionId; - } - } -} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Runtime/Battle/BattleResult.cs b/Assets/GameMain/Scripts/Runtime/Battle/BattleResult.cs index 993eee1..5ae4474 100644 --- a/Assets/GameMain/Scripts/Runtime/Battle/BattleResult.cs +++ b/Assets/GameMain/Scripts/Runtime/Battle/BattleResult.cs @@ -4,7 +4,7 @@ namespace SepCore.Battle { /// /// 每名参战玩家的战后结果。 - /// 保留原始战斗值:阵亡者 HP 为 0;恢复 1/1 的规则由 RunBattleCoordinator 统一应用。 + /// 保留原始战斗值:阵亡者 HP 为 0;恢复 1/1 的规则由 TurnBattleComponent 统一应用。 /// public sealed class BattlePlayerResult { diff --git a/Assets/GameMain/Scripts/Runtime/Battle/BattleReturnPlan.cs b/Assets/GameMain/Scripts/Runtime/Battle/BattleReturnPlan.cs deleted file mode 100644 index 2ae3dc4..0000000 --- a/Assets/GameMain/Scripts/Runtime/Battle/BattleReturnPlan.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System.Collections.Generic; - -namespace SepCore.Battle -{ - /// - /// 探索恢复计划,由 RunBattleCoordinator 根据战斗结果统一生成,探索层只执行计划。 - /// 探索层不得再次根据玩家 HP 或逃跑数量重判战斗结果。 - /// - public sealed class BattleReturnPlan - { - /// - /// 关联原地图敌人实例。 - /// - public readonly int EncounterId; - - /// - /// 战斗结果。 - /// - public readonly BattleOutcome Outcome; - - /// - /// 回写后的玩家临时状态(含 1/1 恢复规则应用后的结果)。 - /// - public readonly IReadOnlyList Players; - - /// - /// 是否移除触发敌人(胜利)。 - /// - public readonly bool RemoveEncounterEnemy; - - /// - /// 是否保留并恢复触发敌人,下次按完整预设重建(逃跑类结果)。 - /// - public readonly bool ResetEncounterEnemy; - - /// - /// 是否使用本局随机源结算掉落(胜利)。 - /// - public readonly bool ShouldRollDrops; - - /// - /// 逃跑类结果的保护时间(毫秒);其他结果为 0。 - /// - public readonly int ProtectionMs; - - /// - /// 是否恢复探索更新。 - /// - public readonly bool ShouldResumeExploration; - - /// - /// 是否以单局失败结束,不返回探索。 - /// - public readonly bool EndsRunAsDefeated; - - public BattleReturnPlan(int encounterId, BattleOutcome outcome, - IReadOnlyList players, bool removeEncounterEnemy, bool resetEncounterEnemy, - bool shouldRollDrops, int protectionMs, bool shouldResumeExploration, bool endsRunAsDefeated) - { - EncounterId = encounterId; - Outcome = outcome; - Players = players != null ? Copy(players) : new BattlePlayerResult[0]; - RemoveEncounterEnemy = removeEncounterEnemy; - ResetEncounterEnemy = resetEncounterEnemy; - ShouldRollDrops = shouldRollDrops; - ProtectionMs = protectionMs; - ShouldResumeExploration = shouldResumeExploration; - EndsRunAsDefeated = endsRunAsDefeated; - } - - private static BattlePlayerResult[] Copy(IReadOnlyList source) - { - BattlePlayerResult[] copy = new BattlePlayerResult[source.Count]; - for (int i = 0; i < source.Count; i++) - { - copy[i] = source[i]; - } - - return copy; - } - } -} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Runtime/Battle/BattleRuntime.cs b/Assets/GameMain/Scripts/Runtime/Battle/BattleRuntime.cs new file mode 100644 index 0000000..c357ba3 --- /dev/null +++ b/Assets/GameMain/Scripts/Runtime/Battle/BattleRuntime.cs @@ -0,0 +1,565 @@ +using System.Collections.Generic; +using SepCore.CustomComponent; +using SepCore.Definition; + +namespace SepCore.Battle +{ + /// + /// 当前战斗的事实唯一来源,仅战斗期间存在。 + /// 调度、指令校验、效果、状态和敌人决策共享同一实例,不复制单位、轮次和状态集合; + /// 内部不为函数调用建立请求/响应 DTO,玩家与敌人的行动都走同一条执行管线并产生同一组 BattleEvent。 + /// M1 只支持 1 名玩家与 1 名敌人的普通攻击闭环。 + /// + internal sealed class BattleRuntime + { + /// + /// 触发本场战斗的遭遇 ID,用于结果回写关联地图敌人。 + /// + public readonly int EncounterId; + + /// + /// 本场全部单位,创建顺序即稳定显示顺序(玩家在前)。 + /// + public readonly BattleUnit[] Units; + + /// + /// 是否为本场先制战斗(只影响第一轮;M1 未启用)。 + /// + public readonly bool IsPreemptive; + + /// + /// 当前轮次,从 1 开始。 + /// + public int RoundNumber = 1; + + /// + /// 当前行动者;无行动者或战斗完成时为 0。 + /// + public int CurrentActorUnitId; + + /// + /// 本轮已获得行动机会的单位 ID。 + /// + public readonly HashSet ActedUnitIds = new HashSet(); + + /// + /// 本次推进产生的待展示行动记录,推进结束时被 BattleStep 取走。 + /// + public readonly List PendingEvents = new List(); + + /// + /// 战斗结束后的结果;非 null 即表示战斗已完成。 + /// + public BattleResult Result; + + private readonly IRunRandomSource _random; + private readonly IBattleConfigProvider _config; + + /// + /// 是否已完成。 + /// + public bool IsCompleted => Result != null; + + /// + /// 当前行动者单位;无行动者时返回 null。 + /// + public BattleUnit CurrentActor => GetUnit(CurrentActorUnitId); + + private BattleRuntime(int encounterId, BattleUnit[] units, bool isPreemptive, + IRunRandomSource random, IBattleConfigProvider config) + { + EncounterId = encounterId; + Units = units; + IsPreemptive = isPreemptive; + _random = random; + _config = config; + } + + /// + /// 从遭遇、单局玩家状态、配置和本局随机源创建唯一战斗运行时。 + /// 任一必要输入缺失或配置缺失时返回 null,表示启动失败且不产生任何副作用。 + /// + public static BattleRuntime Create(BattleEncounter encounter, IReadOnlyList players, + IBattleConfigProvider config, IRunRandomSource random) + { + if (encounter == null || players == null || players.Count == 0 || config == null || random == null) + { + return null; + } + + EnemyPartyConfig party = config.GetEnemyParty(encounter.EnemyPartyConfigId); + if (party == null || party.EnemyIds == null || party.EnemyIds.Count == 0) + { + return null; + } + + List units = new List(players.Count + party.EnemyIds.Count); + int nextId = 1; + + foreach (RunPlayerState state in players) + { + if (state == null) + { + return null; + } + + BattleUnit unit = new BattleUnit(nextId++, BattleFaction.Player, state.CharacterId, state.PartyOrder, + state.CurrentHp, state.MaxHp, state.CurrentMp, state.MaxMp, + state.Atk, state.Mat, state.Speed); + if (state.AttackActionId != 0) + { + unit.ActionIds.Add(state.AttackActionId); + } + + if (state.SkillActionId != 0) + { + unit.ActionIds.Add(state.SkillActionId); + } + + units.Add(unit); + } + + int enemyOrder = 1; + foreach (int enemyId in party.EnemyIds) + { + EnemyConfig enemy = config.GetEnemy(enemyId); + if (enemy == null) + { + return null; + } + + BattleUnit unit = new BattleUnit(nextId++, BattleFaction.Enemy, enemy.Id, enemyOrder++, + enemy.MaxHp, enemy.MaxHp, enemy.MaxMp, enemy.MaxMp, enemy.Atk, enemy.Mat, enemy.Speed); + if (enemy.ActionIds != null) + { + unit.ActionIds.AddRange(enemy.ActionIds); + } + + units.Add(unit); + } + + BattleRuntime runtime = new BattleRuntime(encounter.EncounterId, units.ToArray(), + encounter.IsPreemptive, random, config); + runtime.SelectNextActor(); + return runtime; + } + + /// + /// 按运行时 ID 获取单位;不存在时返回 null。 + /// + public BattleUnit GetUnit(int unitId) + { + foreach (BattleUnit unit in Units) + { + if (unit.UnitId == unitId) + { + return unit; + } + } + + return null; + } + + /// + /// 单位是否仍在战斗中(未阵亡且未逃跑)。 + /// + public static bool IsActive(BattleUnit unit) + { + return unit != null && !unit.IsDefeated && !unit.IsEscaped; + } + + /// + /// 校验并执行当前玩家指令(单次行动)。 + /// 非法指令不消耗 MP、行动机会或随机数;玩家行动后仅选择下一个行动者, + /// 不自动推进敌人回合——间歇节奏由组件在外部编排。 + /// + public BattleStep SubmitCommand(BattleCommand command) + { + if (!ValidatePlayerCommand(command)) + { + return new BattleStep(new BattleEvent[0], BuildViewState(), null); + } + + PendingEvents.Clear(); + + BattleUnit actor = GetUnit(command.ActorUnitId); + ExecuteAction(actor, command.CommandType, command.ActionConfigId, command.TargetUnitIds); + MarkActed(command.ActorUnitId); + CheckBattleEnd(); + + if (!IsCompleted) + { + SelectNextActor(); + } + + return new BattleStep(DrainEvents(), BuildViewState(), Result); + } + + /// + /// 推进当前敌人行动者的自动回合(单次行动)。 + /// 当前行动者不是敌人、没有行动者或战斗已完成时返回无变化步骤; + /// 与玩家指令共用同一执行管线并产生同一组 BattleEvent。 + /// + public BattleStep AdvanceEnemyTurn() + { + BattleUnit actor = CurrentActor; + if (actor == null || actor.Faction != BattleFaction.Enemy || IsCompleted) + { + return new BattleStep(new BattleEvent[0], BuildViewState(), null); + } + + PendingEvents.Clear(); + + ExecuteEnemyTurn(actor); + MarkActed(actor.UnitId); + CheckBattleEnd(); + + if (!IsCompleted) + { + SelectNextActor(); + } + + return new BattleStep(DrainEvents(), BuildViewState(), Result); + } + + /// + /// 构建当前只读视图。 + /// + public BattleViewState BuildViewState() + { + List unitViews = new List(Units.Length); + foreach (BattleUnit unit in Units) + { + List statusViews = new List(unit.Statuses.Count); + foreach (BattleStatus status in unit.Statuses) + { + statusViews.Add(new BattleStatusView(status.Type, status.RemainingRounds)); + } + + unitViews.Add(new BattleUnitView(unit.UnitId, unit.Faction, unit.ConfigId, unit.PartyOrder, + unit.CurrentHp, unit.MaxHp, unit.CurrentMp, unit.MaxMp, unit.Speed, + unit.IsDefeated, unit.IsEscaped, statusViews)); + } + + List remainingOrder = new List(); + if (!IsCompleted) + { + foreach (BattleUnit unit in Units) + { + if (IsActive(unit) && !ActedUnitIds.Contains(unit.UnitId)) + { + remainingOrder.Add(unit.UnitId); + } + } + } + + List availableActions = new List(); + BattleUnit actor = CurrentActor; + if (actor != null && actor.Faction == BattleFaction.Player) + { + availableActions.AddRange(actor.ActionIds); + } + + return new BattleViewState(RoundNumber, CurrentActorUnitId, unitViews, remainingOrder, availableActions); + } + + private bool ValidatePlayerCommand(BattleCommand command) + { + if (command == null || IsCompleted) + { + return false; + } + + if (command.ActorUnitId != CurrentActorUnitId) + { + return false; + } + + BattleUnit actor = GetUnit(command.ActorUnitId); + if (actor == null || actor.Faction != BattleFaction.Player) + { + return false; + } + + if (command.CommandType != BattleActionType.Attack) + { + return false; + } + + BattleActionConfig action = _config.GetAction(command.ActionConfigId); + if (action == null || action.ActionType != BattleActionType.Attack) + { + return false; + } + + if (actor.CurrentMp < action.MpCost) + { + return false; + } + + return TryResolvePlayerTargets(command, actor, action); + } + + private bool TryResolvePlayerTargets(BattleCommand command, BattleUnit actor, BattleActionConfig action) + { + if (action.TargetType != BattleTargetType.SingleEnemy) + { + return false; + } + + if (command.TargetUnitIds == null || command.TargetUnitIds.Count != 1) + { + return false; + } + + BattleUnit target = GetUnit(command.TargetUnitIds[0]); + return target != null && target.Faction != actor.Faction && IsActive(target); + } + + private void ExecuteAction(BattleUnit actor, BattleActionType commandType, int actionConfigId, + IReadOnlyList targetUnitIds) + { + BattleActionConfig action = _config.GetAction(actionConfigId); + if (action == null) + { + return; + } + + if (action.MpCost > 0 && actor.CurrentMp >= action.MpCost) + { + actor.CurrentMp -= action.MpCost; + } + + foreach (int targetUnitId in targetUnitIds) + { + BattleUnit target = GetUnit(targetUnitId); + if (target == null) + { + continue; + } + + foreach (BattleEffect effect in action.Effects) + { + ApplyEffect(actor, target, commandType, actionConfigId, effect); + } + } + } + + private void ApplyEffect(BattleUnit actor, BattleUnit target, BattleActionType commandType, + int actionConfigId, BattleEffect effect) + { + int sourceValue = GetStatValue(actor, effect.SourceStat); + int change = effect.FlatValue + sourceValue * effect.SourceScalePermille / 1000; + + int beforeHp = target.CurrentHp; + int beforeMp = target.CurrentMp; + + switch (effect.TargetStat) + { + case BattleStatType.HP: + target.CurrentHp = Clamp(target.CurrentHp + change, 0, target.MaxHp); + if (target.CurrentHp <= 0) + { + target.CurrentHp = 0; + target.IsDefeated = true; + } + + break; + case BattleStatType.MP: + target.CurrentMp = Clamp(target.CurrentMp + change, 0, target.MaxMp); + break; + default: + // M1 不处理速度/上限等目标属性修改与状态施加 + break; + } + + PendingEvents.Add(new BattleEvent(actor.UnitId, commandType, actionConfigId, target.UnitId, + beforeHp, target.CurrentHp, beforeMp, target.CurrentMp, + BattleStatusType.None, 0)); + } + + private static int GetStatValue(BattleUnit unit, BattleStatType stat) + { + switch (stat) + { + case BattleStatType.HP: + return unit.CurrentHp; + case BattleStatType.MP: + return unit.CurrentMp; + case BattleStatType.MaxHP: + return unit.MaxHp; + case BattleStatType.MaxMP: + return unit.MaxMp; + case BattleStatType.ATK: + return unit.Atk; + case BattleStatType.MAT: + return unit.Mat; + case BattleStatType.Speed: + return unit.Speed; + default: + return 0; + } + } + + private static int Clamp(int value, int min, int max) + { + return value < min ? min : (value > max ? max : value); + } + + private void MarkActed(int unitId) + { + ActedUnitIds.Add(unitId); + } + + /// + /// 选择本轮下一个未行动的活跃单位;全部行动过则进入下一轮。 + /// M1 按单位创建顺序(玩家在前)轮流,每轮每个单位最多行动一次; + /// M2 起由完整调度规则(速度、阵营、队伍顺序、先制)替换。 + /// + private void SelectNextActor() + { + if (IsCompleted) + { + CurrentActorUnitId = 0; + return; + } + + BattleUnit next = FindNextActor(); + if (next == null) + { + RoundNumber++; + ActedUnitIds.Clear(); + next = FindNextActor(); + } + + CurrentActorUnitId = next != null ? next.UnitId : 0; + } + + private BattleUnit FindNextActor() + { + foreach (BattleUnit unit in Units) + { + if (IsActive(unit) && !ActedUnitIds.Contains(unit.UnitId)) + { + return unit; + } + } + + return null; + } + + /// + /// 敌人随机决策并执行:从 MP 足够的行动中等概率选择,单体行动从合法目标中等概率选择; + /// 没有可执行行动或合法目标时跳过本次机会。 + /// + private void ExecuteEnemyTurn(BattleUnit enemy) + { + List usableActions = new List(); + foreach (int actionId in enemy.ActionIds) + { + BattleActionConfig action = _config.GetAction(actionId); + if (action != null && enemy.CurrentMp >= action.MpCost) + { + usableActions.Add(actionId); + } + } + + if (usableActions.Count == 0) + { + return; + } + + int chosenActionId = PickOne(usableActions); + BattleActionConfig chosen = _config.GetAction(chosenActionId); + + List targets = new List(); + foreach (BattleUnit unit in Units) + { + if (unit.Faction != enemy.Faction && IsActive(unit)) + { + targets.Add(unit.UnitId); + } + } + + if (targets.Count == 0) + { + return; + } + + int targetUnitId = PickOne(targets); + ExecuteAction(enemy, chosen.ActionType, chosenActionId, new[] { targetUnitId }); + } + + /// + /// 等概率选一;只有一项时直接返回,不消费随机数。 + /// + private int PickOne(IReadOnlyList candidates) + { + if (candidates.Count == 1) + { + return candidates[0]; + } + + return candidates[_random.NextInt(0, candidates.Count)]; + } + + private void CheckBattleEnd() + { + if (Result != null) + { + return; + } + + bool allEnemiesDefeated = true; + bool allPlayersDefeated = true; + foreach (BattleUnit unit in Units) + { + if (IsActive(unit)) + { + if (unit.Faction == BattleFaction.Enemy) + { + allEnemiesDefeated = false; + } + else + { + allPlayersDefeated = false; + } + } + } + + BattleOutcome outcome; + if (allEnemiesDefeated) + { + outcome = BattleOutcome.Victory; + } + else if (allPlayersDefeated) + { + outcome = BattleOutcome.TotalDefeat; + } + else + { + return; + } + + List players = new List(); + foreach (BattleUnit unit in Units) + { + if (unit.Faction == BattleFaction.Player) + { + players.Add(new BattlePlayerResult(unit.ConfigId, unit.CurrentHp, unit.CurrentMp, + unit.IsDefeated, unit.IsEscaped)); + } + } + + Result = new BattleResult(EncounterId, outcome, players); + CurrentActorUnitId = 0; + } + + private List DrainEvents() + { + List drained = new List(PendingEvents); + PendingEvents.Clear(); + return drained; + } + } +} diff --git a/Assets/GameMain/Scripts/Runtime/Battle/BattleReturnPlan.cs.meta b/Assets/GameMain/Scripts/Runtime/Battle/BattleRuntime.cs.meta similarity index 83% rename from Assets/GameMain/Scripts/Runtime/Battle/BattleReturnPlan.cs.meta rename to Assets/GameMain/Scripts/Runtime/Battle/BattleRuntime.cs.meta index 4259405..c8f2f72 100644 --- a/Assets/GameMain/Scripts/Runtime/Battle/BattleReturnPlan.cs.meta +++ b/Assets/GameMain/Scripts/Runtime/Battle/BattleRuntime.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: ec4ca0d279034f4a939fd35aedab383e +guid: 11f1778a3f4e34da691bcac2656ed3a6 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Assets/GameMain/Scripts/Runtime/Battle/BattleSnapshot.cs.meta b/Assets/GameMain/Scripts/Runtime/Battle/BattleSnapshot.cs.meta deleted file mode 100644 index 7845463..0000000 --- a/Assets/GameMain/Scripts/Runtime/Battle/BattleSnapshot.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: c5bfa0a211d34ba6874d7d0b276192a3 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Runtime/Battle/BattleStartRequest.cs b/Assets/GameMain/Scripts/Runtime/Battle/BattleStartRequest.cs deleted file mode 100644 index 17b1e77..0000000 --- a/Assets/GameMain/Scripts/Runtime/Battle/BattleStartRequest.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Collections.Generic; -using SepCore.CustomComponent; - -namespace SepCore.Battle -{ - /// - /// 战斗启动请求,创建后视为只读。 - /// 战斗开始失败时不得消耗随机数、修改单局状态或暂停状态计数。 - /// - public sealed class BattleStartRequest - { - /// - /// 用于将结果关联回地图敌人实例。 - /// - public readonly int EncounterId; - - /// - /// 1 到 4 个玩家输入,保持战备顺序。 - /// - public readonly IReadOnlyList Players; - - /// - /// 用于按队伍预设创建 1 到 4 个敌人运行时单位。 - /// - public readonly int EnemyPartyConfigId; - - /// - /// 是否启用第一轮玩家先制。 - /// - public readonly bool IsPreemptive; - - /// - /// 当前单局唯一的随机源实例,来自 RandomComponent,不创建战斗私有随机源。 - /// - public readonly IRunRandomSource Random; - - public BattleStartRequest(int encounterId, IReadOnlyList players, - int enemyPartyConfigId, bool isPreemptive, IRunRandomSource random) - { - EncounterId = encounterId; - Players = players != null ? Copy(players) : new BattlePlayerInput[0]; - EnemyPartyConfigId = enemyPartyConfigId; - IsPreemptive = isPreemptive; - Random = random; - } - - private static BattlePlayerInput[] Copy(IReadOnlyList source) - { - BattlePlayerInput[] copy = new BattlePlayerInput[source.Count]; - for (int i = 0; i < source.Count; i++) - { - copy[i] = source[i]; - } - - return copy; - } - } -} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Runtime/Battle/BattleStartRequest.cs.meta b/Assets/GameMain/Scripts/Runtime/Battle/BattleStartRequest.cs.meta deleted file mode 100644 index 54c3ec9..0000000 --- a/Assets/GameMain/Scripts/Runtime/Battle/BattleStartRequest.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 040ffec92b064e3fbd9d9a8db9b39291 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Runtime/Battle/BattleStatus.cs b/Assets/GameMain/Scripts/Runtime/Battle/BattleStatus.cs new file mode 100644 index 0000000..eb24b2f --- /dev/null +++ b/Assets/GameMain/Scripts/Runtime/Battle/BattleStatus.cs @@ -0,0 +1,27 @@ +using SepCore.Definition; + +namespace SepCore.Battle +{ + /// + /// 单场战斗中的状态,只存在于 BattleRuntime。 + /// 同类状态不叠层,重复施加保留较长的剩余行动机会次数。 + /// + internal sealed class BattleStatus + { + /// + /// 状态类型。 + /// + public BattleStatusType Type; + + /// + /// 剩余持续次数(行动机会)。 + /// + public int RemainingRounds; + + public BattleStatus(BattleStatusType type, int remainingRounds) + { + Type = type; + RemainingRounds = remainingRounds; + } + } +} diff --git a/Assets/GameMain/Scripts/Runtime/Battle/BattleFlowState.cs.meta b/Assets/GameMain/Scripts/Runtime/Battle/BattleStatus.cs.meta similarity index 83% rename from Assets/GameMain/Scripts/Runtime/Battle/BattleFlowState.cs.meta rename to Assets/GameMain/Scripts/Runtime/Battle/BattleStatus.cs.meta index 3b3dbef..10356eb 100644 --- a/Assets/GameMain/Scripts/Runtime/Battle/BattleFlowState.cs.meta +++ b/Assets/GameMain/Scripts/Runtime/Battle/BattleStatus.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: bef66af163534a79b4b14ba27ed80665 +guid: 5d551f82833074a5fa669f05ba7249eb MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Assets/GameMain/Scripts/Runtime/Battle/BattleStep.cs b/Assets/GameMain/Scripts/Runtime/Battle/BattleStep.cs new file mode 100644 index 0000000..592811d --- /dev/null +++ b/Assets/GameMain/Scripts/Runtime/Battle/BattleStep.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; + +namespace SepCore.Battle +{ + /// + /// 一次同步推进的返回值,是 UI 读取行动记录与最新视图的唯一来源。 + /// 不保存独立流程状态:Result 非 null 即表示战斗已完成。 + /// + public sealed class BattleStep + { + /// + /// 本次推进产生的有序行动记录列表。 + /// + public readonly IReadOnlyList Events; + + /// + /// 推进结束后的只读战斗视图。 + /// + public readonly BattleViewState View; + + /// + /// 仅在战斗完成时存在,否则为 null。 + /// + public readonly BattleResult Result; + + public BattleStep(IReadOnlyList events, BattleViewState view, BattleResult result) + { + Events = events != null ? Copy(events) : new BattleEvent[0]; + View = view; + Result = result; + } + + private static BattleEvent[] Copy(IReadOnlyList source) + { + BattleEvent[] copy = new BattleEvent[source.Count]; + for (int i = 0; i < source.Count; i++) + { + copy[i] = source[i]; + } + + return copy; + } + } +} diff --git a/Assets/GameMain/Scripts/Runtime/Battle/BattleStep.cs.meta b/Assets/GameMain/Scripts/Runtime/Battle/BattleStep.cs.meta new file mode 100644 index 0000000..8c34b35 --- /dev/null +++ b/Assets/GameMain/Scripts/Runtime/Battle/BattleStep.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 993a7eab17ef14d69b2ca67b770c05da +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Runtime/Battle/BattleUnit.cs b/Assets/GameMain/Scripts/Runtime/Battle/BattleUnit.cs new file mode 100644 index 0000000..923c806 --- /dev/null +++ b/Assets/GameMain/Scripts/Runtime/Battle/BattleUnit.cs @@ -0,0 +1,103 @@ +using System.Collections.Generic; +using SepCore.Definition; + +namespace SepCore.Battle +{ + /// + /// 单场战斗的运行时单位,仅 BattleRuntime 及其协作者读写。 + /// UI 与探索层通过 BattleUnitView 读取,不直接持有本类型。 + /// + internal sealed class BattleUnit + { + /// + /// 本场战斗单位的唯一运行时标识;重复配置的敌人拥有不同 ID。 + /// + public readonly int UnitId; + + /// + /// 阵营。 + /// + public readonly BattleFaction Faction; + + /// + /// 配置标识:玩家为 CharacterId,敌人为 EnemyConfigId。 + /// + public readonly int ConfigId; + + /// + /// 同阵营同速度的最终并列规则顺序。 + /// + public readonly int PartyOrder; + + /// + /// HP 上限。 + /// + public readonly int MaxHp; + + /// + /// MP 上限。 + /// + public readonly int MaxMp; + + /// + /// 攻击。 + /// + public readonly int Atk; + + /// + /// 魔力。 + /// + public readonly int Mat; + + /// + /// 当前 HP。 + /// + public int CurrentHp; + + /// + /// 当前 MP。 + /// + public int CurrentMp; + + /// + /// 当前速度;可被战斗内效果修改。 + /// + public int Speed; + + /// + /// 是否阵亡。 + /// + public bool IsDefeated; + + /// + /// 是否已逃跑。 + /// + public bool IsEscaped; + + /// + /// 当前剩余状态。 + /// + public readonly List Statuses = new List(); + + /// + /// 该单位可用的行动配置 ID。 + /// + public readonly List ActionIds = new List(); + + public BattleUnit(int unitId, BattleFaction faction, int configId, int partyOrder, + int currentHp, int maxHp, int currentMp, int maxMp, int atk, int mat, int speed) + { + UnitId = unitId; + Faction = faction; + ConfigId = configId; + PartyOrder = partyOrder; + CurrentHp = currentHp; + MaxHp = maxHp; + CurrentMp = currentMp; + MaxMp = maxMp; + Atk = atk; + Mat = mat; + Speed = speed; + } + } +} diff --git a/Assets/GameMain/Scripts/Runtime/Battle/BattleUnit.cs.meta b/Assets/GameMain/Scripts/Runtime/Battle/BattleUnit.cs.meta new file mode 100644 index 0000000..f579a06 --- /dev/null +++ b/Assets/GameMain/Scripts/Runtime/Battle/BattleUnit.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 794735c8736d646c19d9275131444dce +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Runtime/Battle/BattleSnapshot.cs b/Assets/GameMain/Scripts/Runtime/Battle/BattleViewState.cs similarity index 64% rename from Assets/GameMain/Scripts/Runtime/Battle/BattleSnapshot.cs rename to Assets/GameMain/Scripts/Runtime/Battle/BattleViewState.cs index bc50092..5aa0151 100644 --- a/Assets/GameMain/Scripts/Runtime/Battle/BattleSnapshot.cs +++ b/Assets/GameMain/Scripts/Runtime/Battle/BattleViewState.cs @@ -4,9 +4,9 @@ using SepCore.Definition; namespace SepCore.Battle { /// - /// 单位剩余状态快照。 + /// 单个战斗状态的只读视图。 /// - public sealed class BattleStatusSnapshot + public sealed class BattleStatusView { /// /// 状态类型。 @@ -18,7 +18,7 @@ namespace SepCore.Battle /// public readonly int RemainingRounds; - public BattleStatusSnapshot(BattleStatusType statusType, int remainingRounds) + public BattleStatusView(BattleStatusType statusType, int remainingRounds) { StatusType = statusType; RemainingRounds = remainingRounds; @@ -27,8 +27,9 @@ namespace SepCore.Battle /// /// 单个战斗单位的只读视图。 + /// 内部调度与效果结算不得读取本视图;视图只在外部边界按需生成。 /// - public sealed class BattleUnitSnapshot + public sealed class BattleUnitView { /// /// 本场战斗单位的唯一运行时标识;重复配置的敌人拥有不同 ID。 @@ -46,7 +47,7 @@ namespace SepCore.Battle public readonly int ConfigId; /// - /// 同阵营同速度的最终并列规则顺序。 + /// 同阵营同速度的最终并列规则顺序,也是显示顺序。 /// public readonly int PartyOrder; @@ -70,16 +71,6 @@ namespace SepCore.Battle /// public readonly int MaxMp; - /// - /// 攻击。 - /// - public readonly int Atk; - - /// - /// 魔力。 - /// - public readonly int Mat; - /// /// 当前速度。 /// @@ -96,19 +87,13 @@ namespace SepCore.Battle public readonly bool IsEscaped; /// - /// 剩余状态列表。 + /// 当前剩余状态列表。 /// - public readonly IReadOnlyList Statuses; + public readonly IReadOnlyList Statuses; - /// - /// 可用行动配置 ID 列表。 - /// - public readonly IReadOnlyList AvailableActionIds; - - public BattleUnitSnapshot(int battleUnitId, BattleFaction faction, int configId, int partyOrder, - int currentHp, int maxHp, int currentMp, int maxMp, int atk, int mat, int speed, - bool isDefeated, bool isEscaped, IReadOnlyList statuses, - IReadOnlyList availableActionIds) + public BattleUnitView(int battleUnitId, BattleFaction faction, int configId, int partyOrder, + int currentHp, int maxHp, int currentMp, int maxMp, int speed, + bool isDefeated, bool isEscaped, IReadOnlyList statuses) { BattleUnitId = battleUnitId; Faction = faction; @@ -118,29 +103,15 @@ namespace SepCore.Battle MaxHp = maxHp; CurrentMp = currentMp; MaxMp = maxMp; - Atk = atk; - Mat = mat; Speed = speed; IsDefeated = isDefeated; IsEscaped = isEscaped; - Statuses = statuses != null ? Copy(statuses) : new BattleStatusSnapshot[0]; - AvailableActionIds = availableActionIds != null ? Copy(availableActionIds) : new int[0]; + Statuses = statuses != null ? Copy(statuses) : new BattleStatusView[0]; } - private static BattleStatusSnapshot[] Copy(IReadOnlyList source) + private static BattleStatusView[] Copy(IReadOnlyList source) { - BattleStatusSnapshot[] copy = new BattleStatusSnapshot[source.Count]; - for (int i = 0; i < source.Count; i++) - { - copy[i] = source[i]; - } - - return copy; - } - - private static int[] Copy(IReadOnlyList source) - { - int[] copy = new int[source.Count]; + BattleStatusView[] copy = new BattleStatusView[source.Count]; for (int i = 0; i < source.Count; i++) { copy[i] = source[i]; @@ -151,21 +122,16 @@ namespace SepCore.Battle } /// - /// 只读战斗快照,是 UI 和敌人策略唯一允许读取的战斗状态。 - /// 每次推进都创建逻辑上的只读快照,不把可修改的内部列表或战斗单位引用暴露给调用方。 + /// 当前战斗的只读视图,是 UI 唯一允许读取的战斗状态。 + /// 每次推进后在外部边界生成,不把内部可变对象暴露给 UI 或探索层。 /// - public sealed class BattleSnapshot + public sealed class BattleViewState { /// /// 当前轮次,从 1 开始。 /// public readonly int RoundNumber; - /// - /// 当前是否为先制战斗第一轮。 - /// - public readonly bool IsPreemptiveRound; - /// /// 当前行动者;战斗完成时为 0。 /// @@ -174,7 +140,7 @@ namespace SepCore.Battle /// /// 全部单位的只读视图,保持稳定显示顺序。 /// - public readonly IReadOnlyList Units; + public readonly IReadOnlyList Units; /// /// 本轮从当前行动者开始的剩余运行时单位 ID;速度变化后重建。 @@ -182,25 +148,24 @@ namespace SepCore.Battle public readonly IReadOnlyList RemainingTurnOrder; /// - /// 流程状态。 + /// 当前玩家可用的行动配置 ID;当前行动者不是玩家或战斗完成时为空。 /// - public readonly BattleFlowState FlowState; + public readonly IReadOnlyList AvailableActionIds; - public BattleSnapshot(int roundNumber, bool isPreemptiveRound, int currentActorUnitId, - IReadOnlyList units, IReadOnlyList remainingTurnOrder, - BattleFlowState flowState) + public BattleViewState(int roundNumber, int currentActorUnitId, + IReadOnlyList units, IReadOnlyList remainingTurnOrder, + IReadOnlyList availableActionIds) { RoundNumber = roundNumber; - IsPreemptiveRound = isPreemptiveRound; CurrentActorUnitId = currentActorUnitId; - Units = units != null ? Copy(units) : new BattleUnitSnapshot[0]; + Units = units != null ? Copy(units) : new BattleUnitView[0]; RemainingTurnOrder = remainingTurnOrder != null ? Copy(remainingTurnOrder) : new int[0]; - FlowState = flowState; + AvailableActionIds = availableActionIds != null ? Copy(availableActionIds) : new int[0]; } - private static BattleUnitSnapshot[] Copy(IReadOnlyList source) + private static BattleUnitView[] Copy(IReadOnlyList source) { - BattleUnitSnapshot[] copy = new BattleUnitSnapshot[source.Count]; + BattleUnitView[] copy = new BattleUnitView[source.Count]; for (int i = 0; i < source.Count; i++) { copy[i] = source[i]; @@ -220,4 +185,4 @@ namespace SepCore.Battle return copy; } } -} \ No newline at end of file +} diff --git a/Assets/GameMain/Scripts/Runtime/Battle/BattleViewState.cs.meta b/Assets/GameMain/Scripts/Runtime/Battle/BattleViewState.cs.meta new file mode 100644 index 0000000..f9d4265 --- /dev/null +++ b/Assets/GameMain/Scripts/Runtime/Battle/BattleViewState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6be1facf77c6042c0be34019eb5f255e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Runtime/Battle/IEnemyBattlePolicy.cs b/Assets/GameMain/Scripts/Runtime/Battle/IEnemyBattlePolicy.cs deleted file mode 100644 index 8406121..0000000 --- a/Assets/GameMain/Scripts/Runtime/Battle/IEnemyBattlePolicy.cs +++ /dev/null @@ -1,20 +0,0 @@ -using SepCore.CustomComponent; - -namespace SepCore.Battle -{ - /// - /// 敌人策略接口。 - /// 策略只读取快照并返回指令,不直接修改 BattleSession。 - /// 当前没有可执行行动时,本次行动机会记为跳过,不能形成无限循环。 - /// - public interface IEnemyBattlePolicy - { - /// - /// 为当前敌人行动者决定指令。 - /// - /// 只读战斗快照。 - /// 当前获得行动机会的敌人单位。 - /// 本局共享随机源。 - BattleCommand Decide(BattleSnapshot snapshot, int actorUnitId, IRunRandomSource random); - } -} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Runtime/Battle/IEnemyBattlePolicy.cs.meta b/Assets/GameMain/Scripts/Runtime/Battle/IEnemyBattlePolicy.cs.meta deleted file mode 100644 index 85f5f15..0000000 --- a/Assets/GameMain/Scripts/Runtime/Battle/IEnemyBattlePolicy.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: e803cf69950a4fb9b0b8d63603f99a4b -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Runtime/Battle/IExplorationBattleBridge.cs b/Assets/GameMain/Scripts/Runtime/Battle/IExplorationBattleBridge.cs deleted file mode 100644 index 274d6af..0000000 --- a/Assets/GameMain/Scripts/Runtime/Battle/IExplorationBattleBridge.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace SepCore.Battle -{ - /// - /// 探索层恢复接口。 - /// 碰撞方主动把 BattleEncounter 交给 RunBattleCoordinator;协调器不扫描场景寻找敌人。 - /// ApplyBattleReturn 必须按 EncounterId 精确操作触发敌人,不能按 EnemyConfigId 批量处理同类敌人。 - /// - public interface IExplorationBattleBridge - { - /// - /// 执行探索恢复计划。 - /// - /// 由 RunBattleCoordinator 生成的恢复计划。 - void ApplyBattleReturn(BattleReturnPlan plan); - } -} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Runtime/Battle/IExplorationBattleBridge.cs.meta b/Assets/GameMain/Scripts/Runtime/Battle/IExplorationBattleBridge.cs.meta deleted file mode 100644 index e846441..0000000 --- a/Assets/GameMain/Scripts/Runtime/Battle/IExplorationBattleBridge.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: cf022ae8498b418784037549ce415e3a -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Runtime/Battle/LubanBattleConfigProvider.cs b/Assets/GameMain/Scripts/Runtime/Battle/LubanBattleConfigProvider.cs new file mode 100644 index 0000000..b11bee8 --- /dev/null +++ b/Assets/GameMain/Scripts/Runtime/Battle/LubanBattleConfigProvider.cs @@ -0,0 +1,45 @@ +using SepCore.CustomComponent; +using SepCore.Definition; + +namespace SepCore.Battle +{ + /// + /// 战斗配置的运行时适配器,隔离战斗规则与 GameEntry.Luban。 + /// 配置缺失返回 null,由启动校验决定失败;正常行动不重复扫描全表完整性。 + /// EditMode 测试使用内存配置,不使用本类。 + /// + internal sealed class LubanBattleConfigProvider : IBattleConfigProvider + { + public EnemyPartyConfig GetEnemyParty(int id) + { + return SafeGet(() => GameEntry.Luban.Get(id)); + } + + public EnemyConfig GetEnemy(int id) + { + return SafeGet(() => GameEntry.Luban.Get(id)); + } + + public BattleActionConfig GetAction(int id) + { + return SafeGet(() => GameEntry.Luban.Get(id)); + } + + public GlobalConfig GetGlobal() + { + return SafeGet(() => GameEntry.Luban.Global != null ? GameEntry.Luban.Global.Data : null); + } + + private static T SafeGet(System.Func getter) where T : class + { + try + { + return GameEntry.Luban != null && GameEntry.Luban.IsReady ? getter() : null; + } + catch (System.Exception) + { + return null; + } + } + } +} diff --git a/Assets/GameMain/Scripts/Runtime/Battle/LubanBattleConfigProvider.cs.meta b/Assets/GameMain/Scripts/Runtime/Battle/LubanBattleConfigProvider.cs.meta new file mode 100644 index 0000000..6eaaf8e --- /dev/null +++ b/Assets/GameMain/Scripts/Runtime/Battle/LubanBattleConfigProvider.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 50fd8ca181e304ffc8bc130beabf9177 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Runtime/CustomComponent/Run/RunBattleCoordinator.cs b/Assets/GameMain/Scripts/Runtime/CustomComponent/Run/RunBattleCoordinator.cs deleted file mode 100644 index 14b16e4..0000000 --- a/Assets/GameMain/Scripts/Runtime/CustomComponent/Run/RunBattleCoordinator.cs +++ /dev/null @@ -1,112 +0,0 @@ -using System.Collections.Generic; -using SepCore.Battle; -using SepCore.Definition; -using SepCore.UI; -using UnityGameFramework.Runtime; - -namespace SepCore.CustomComponent -{ - /// - /// 单局战斗协调器(普通类,不挂场景)。 - /// 负责从单局状态构建请求、预留战斗占用、暂停/恢复探索更新与计时、打开/关闭战斗 UI; - /// 后续里程碑在此创建并校验 BattleController,并消费 BattleResult 生成 BattleReturnPlan。 - /// 不负责回合排序、效果计算和 UI 控件状态。 - /// - public sealed class RunBattleCoordinator - { - /// - /// 尝试开始一场战斗。 - /// 战斗占用已激活时拒绝;请求构建失败时释放占用且不暂停地图。 - /// - /// 探索层创建的遭遇输入。 - /// 是否成功开始。 - public bool TryStartBattle(BattleEncounter encounter) - { - if (encounter == null) - { - Log.Error("Can not start battle with null encounter."); - return false; - } - - TurnBattleComponent runBattle = GameEntry.TurnBattle; - if (runBattle == null || runBattle.IsBattleActive) - { - Log.Warning("Can not start battle because battle occupancy is already active."); - return false; - } - - BattleStartRequest request = BuildStartRequest(encounter); - if (request == null) - { - Log.Warning("Can not start battle because start request is invalid."); - return false; - } - - // 预留战斗占用并暂停探索更新与单局计时; - // 后续里程碑在此创建并校验 BattleController,失败则释放占用且不暂停地图 - runBattle.TryReserveBattle(); - runBattle.SetExplorationPaused(true); - runBattle.SetTimerPaused(true); - - GameEntry.UI.OpenUIForm(UIFormType.BattleForm); - Log.Info("Battle started with encounter '{0}'.", encounter.EncounterId); - return true; - } - - /// - /// 关闭调试壳层并恢复探索更新与单局计时。 - /// M0 调试入口专用;M7 起由 ApplyResult 按战斗结果统一回写与恢复。 - /// - public void EndDebugBattle() - { - TurnBattleComponent runBattle = GameEntry.TurnBattle; - if (runBattle == null) - { - return; - } - - UGuiForm form = GameEntry.UI.GetUIForm(UIFormType.BattleForm); - if (form != null) - { - GameEntry.UI.CloseUIForm(form); - } - - runBattle.SetExplorationPaused(false); - runBattle.SetTimerPaused(false); - runBattle.ReleaseBattle(); - Log.Info("Debug battle closed."); - } - - /// - /// 从单局临时角色状态与共享随机源构建启动请求。 - /// 请求创建后视为只读;构建失败返回 null,不消耗随机数。 - /// - private static BattleStartRequest BuildStartRequest(BattleEncounter encounter) - { - IRunRandomSource random = GameEntry.Random != null ? GameEntry.Random.Random : null; - if (random == null) - { - Log.Warning("Can not start battle because run random source is not initialized."); - return null; - } - - List players = new List(); - foreach (RunPlayerState state in GameEntry.TurnBattle.Players) - { - players.Add(new BattlePlayerInput(state.CharacterId, state.PartyOrder, - state.CurrentHp, state.CurrentMp, state.MaxHp, state.MaxMp, - state.Atk, state.Mat, state.Speed, - state.AttackActionId, state.SkillActionId)); - } - - if (players.Count == 0) - { - Log.Warning("Can not start battle because no run player state exists."); - return null; - } - - return new BattleStartRequest(encounter.EncounterId, players, encounter.EnemyPartyConfigId, - encounter.IsPreemptive, random); - } - } -} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Runtime/CustomComponent/Run/RunBattleCoordinator.cs.meta b/Assets/GameMain/Scripts/Runtime/CustomComponent/Run/RunBattleCoordinator.cs.meta deleted file mode 100644 index af98da7..0000000 --- a/Assets/GameMain/Scripts/Runtime/CustomComponent/Run/RunBattleCoordinator.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 0146e261d98d40109183900a1d033fc9 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Runtime/CustomComponent/Run/TurnBattleComponent.cs b/Assets/GameMain/Scripts/Runtime/CustomComponent/Run/TurnBattleComponent.cs index 99da78d..5cd1d32 100644 --- a/Assets/GameMain/Scripts/Runtime/CustomComponent/Run/TurnBattleComponent.cs +++ b/Assets/GameMain/Scripts/Runtime/CustomComponent/Run/TurnBattleComponent.cs @@ -1,14 +1,20 @@ +using System; using System.Collections.Generic; +using SepCore.Battle; +using SepCore.Definition; +using SepCore.UI; using UnityEngine; using UnityGameFramework.Runtime; namespace SepCore.CustomComponent { /// - /// 单局战斗组件。 - /// 负责本局临时角色状态、计时、探索暂停标志和战斗占用的最小状态。 + /// 单局战斗组件,也是战斗模块的唯一外部入口。 + /// 负责本局临时角色状态、计时、探索暂停标志、战斗占用和当前唯一的 BattleRuntime。 + /// 外部通过 TryStartBattle 启动战斗、CloseBattle 关闭调试战斗; + /// 战斗规则(调度、指令校验、效果、敌人决策)在内部共享同一个 BattleRuntime, + /// 不为模块内部的函数调用建立请求/响应 DTO。 /// 本局种子与共享随机源由独立的 RandomComponent 持有,战斗只沿用,不创建私有随机源。 - /// 战斗协调(构建请求、打开 UI、回写结果)由独立的 RunBattleCoordinator 负责。 /// public class TurnBattleComponent : GameFrameworkComponent { @@ -17,6 +23,16 @@ namespace SepCore.CustomComponent private bool _timerPaused; private bool _explorationPaused; private bool _battleActive; + private BattleRuntime _runtime; + private IBattleConfigProvider _config; + private Action _onCompleted; + private Action _stepListener; + private Coroutine _autoAdvanceRoutine; + + /// + /// 敌人自动行动前的间歇时间(秒),用于让 UI 展示行动过程。 + /// + private const float AutoAdvanceDelaySeconds = 0.6f; /// /// 获取本局临时角色状态列表(只读)。 @@ -45,16 +61,17 @@ namespace SepCore.CustomComponent /// public bool IsBattleActive => _battleActive; + /// + /// 获取当前唯一的战斗运行时;仅战斗期间非 null。 + /// + internal BattleRuntime Runtime => _runtime; + /// /// 开始一局新的单局:重置全部临时状态。 /// public void BeginRun() { - _players.Clear(); - _elapsedMs = 0; - _timerPaused = false; - _explorationPaused = false; - _battleActive = false; + ResetRunState(); } /// @@ -62,11 +79,7 @@ namespace SepCore.CustomComponent /// public void EndRun() { - _players.Clear(); - _elapsedMs = 0; - _timerPaused = false; - _explorationPaused = false; - _battleActive = false; + ResetRunState(); } /// @@ -83,26 +96,142 @@ namespace SepCore.CustomComponent } /// - /// 预留战斗占用,拒绝同一帧或未结束战斗的重复进入。 + /// 尝试开始一场战斗,是战斗模块的唯一外部入口。 + /// 校验通过后预留战斗占用、暂停探索更新与单局计时并打开 BattleForm; + /// 校验失败不消耗随机数、不修改单局状态。 + /// 战斗完成时通过 onCompleted 一次性回调返回 BattleResult;调试入口可不提供回调。 /// - /// 预留成功返回 true;已存在战斗占用时返回 false。 - public bool TryReserveBattle() + /// 探索层创建的遭遇输入。 + /// 战斗结束的一次性完成回调,可为 null。 + /// 是否成功开始。 + public bool TryStartBattle(BattleEncounter encounter, Action onCompleted) { - if (_battleActive) + if (encounter == null) { + Log.Error("Can not start battle with null encounter."); return false; } + if (_battleActive) + { + Log.Warning("Can not start battle because battle occupancy is already active."); + return false; + } + + if (GameEntry.Random == null || GameEntry.Random.Random == null) + { + Log.Warning("Can not start battle because run random source is not initialized."); + return false; + } + + if (_players.Count == 0) + { + Log.Warning("Can not start battle because no run player state exists."); + return false; + } + + if (GameEntry.Luban == null || !GameEntry.Luban.IsReady) + { + Log.Warning("Can not start battle because battle config is not ready."); + return false; + } + + if (_config == null) + { + _config = new LubanBattleConfigProvider(); + } + + BattleRuntime runtime = BattleRuntime.Create(encounter, _players, _config, GameEntry.Random.Random); + if (runtime == null) + { + Log.Warning("Can not start battle because battle runtime can not be created from given encounter/players/config."); + return false; + } + + _runtime = runtime; + _onCompleted = onCompleted; _battleActive = true; + _explorationPaused = true; + _timerPaused = true; + + GameEntry.UI.OpenUIForm(UIFormType.BattleForm); + Log.Info("Battle started with encounter '{0}'.", encounter.EncounterId); return true; } /// - /// 释放战斗占用。 + /// 提交当前玩家指令并同步推进单次行动,返回本次推进的行动记录、最新视图和可能的最终结果。 + /// 非法指令不消耗行动机会;玩家行动后若轮到敌人,由组件按间歇节奏自动推进敌人回合, + /// 每一步通过已注册的推进监听推送给 UI。 + /// 战斗完成后向 onCompleted 一次性回调返回 BattleResult。 /// - public void ReleaseBattle() + /// 当前玩家行动者的指令。 + /// 本次玩家行动的推进结果;当前没有进行中的战斗时返回 null。 + public BattleStep SubmitCommand(BattleCommand command) { + if (_runtime == null || _runtime.IsCompleted) + { + return null; + } + + BattleStep step = _runtime.SubmitCommand(command); + if (step.Result != null) + { + ApplyResultWriteback(step.Result); + Action callback = _onCompleted; + _onCompleted = null; + callback?.Invoke(step.Result); + } + else + { + ScheduleAutoAdvance(); + } + + return step; + } + + /// + /// 注册战斗推进监听;UI 通过它接收组件自动推进(敌人行动)产生的每一步。 + /// + /// 推进监听,传入 null 取消注册。 + public void SetStepListener(Action listener) + { + _stepListener = listener; + } + + /// + /// 获取当前战斗的只读视图;没有进行中的战斗时返回 null。 + /// + public BattleViewState GetViewState() + { + return _runtime != null ? _runtime.BuildViewState() : null; + } + + /// + /// 关闭当前战斗壳层并恢复探索更新与单局计时。 + /// M0 调试入口专用;M5 起由战斗结果统一回写单局状态后关闭。 + /// + public void CloseBattle() + { + if (!_battleActive) + { + return; + } + + UGuiForm form = GameEntry.UI.GetUIForm(UIFormType.BattleForm); + if (form != null) + { + GameEntry.UI.CloseUIForm(form); + } + + _runtime = null; + _onCompleted = null; + StopAutoAdvance(); + _stepListener = null; + _explorationPaused = false; + _timerPaused = false; _battleActive = false; + Log.Info("Battle closed."); } /// @@ -132,5 +261,90 @@ namespace SepCore.CustomComponent _elapsedMs += Time.deltaTime * 1000f; } + + /// + /// 当前行动者是敌人且战斗未结束时,启动带间歇的自动推进协程。 + /// + private void ScheduleAutoAdvance() + { + if (_autoAdvanceRoutine != null) + { + return; + } + + if (_runtime == null || _runtime.IsCompleted || _runtime.CurrentActor == null || + _runtime.CurrentActor.Faction != BattleFaction.Enemy) + { + return; + } + + _autoAdvanceRoutine = StartCoroutine(AutoAdvanceRoutine()); + } + + private System.Collections.IEnumerator AutoAdvanceRoutine() + { + while (_runtime != null && !_runtime.IsCompleted && + _runtime.CurrentActor != null && _runtime.CurrentActor.Faction == BattleFaction.Enemy) + { + yield return new WaitForSeconds(AutoAdvanceDelaySeconds); + + BattleStep step = _runtime.AdvanceEnemyTurn(); + if (step.Result != null) + { + ApplyResultWriteback(step.Result); + Action callback = _onCompleted; + _onCompleted = null; + callback?.Invoke(step.Result); + } + + _stepListener?.Invoke(step); + } + + _autoAdvanceRoutine = null; + } + + private void StopAutoAdvance() + { + if (_autoAdvanceRoutine != null) + { + StopCoroutine(_autoAdvanceRoutine); + _autoAdvanceRoutine = null; + } + } + + /// + /// 非 TotalDefeat 结果把玩家战后 HP/MP 回写到单局临时状态。 + /// 首版保留原始战斗值;M5 按结束矩阵统一应用阵亡恢复 1/1 等规则。 + /// + private void ApplyResultWriteback(BattleResult result) + { + if (result.Outcome == BattleOutcome.TotalDefeat) + { + return; + } + + foreach (BattlePlayerResult playerResult in result.Players) + { + RunPlayerState state = _players.Find(player => player.CharacterId == playerResult.CharacterId); + if (state != null) + { + state.CurrentHp = playerResult.CurrentHp; + state.CurrentMp = playerResult.CurrentMp; + } + } + } + + private void ResetRunState() + { + _players.Clear(); + _elapsedMs = 0; + _timerPaused = false; + _explorationPaused = false; + _battleActive = false; + _runtime = null; + _onCompleted = null; + StopAutoAdvance(); + _stepListener = null; + } } } diff --git a/Assets/GameMain/Scripts/Runtime/Debugger/BattleDebuggerWindow.cs b/Assets/GameMain/Scripts/Runtime/Debugger/BattleDebuggerWindow.cs index c280372..db29bc6 100644 --- a/Assets/GameMain/Scripts/Runtime/Debugger/BattleDebuggerWindow.cs +++ b/Assets/GameMain/Scripts/Runtime/Debugger/BattleDebuggerWindow.cs @@ -11,7 +11,7 @@ namespace SepCore.Debugger /// /// 战斗壳层调试入口(仅供 Editor/Development Build,正式构建不注册)。 /// 模拟探索层:缺少单局状态时用配表角色与固定种子初始化临时状态, - /// 然后通过 RunBattleCoordinator 以固定调试遭遇开始/结束战斗,不依赖尚未实现的地图敌人。 + /// 然后通过 TurnBattleComponent 以固定调试遭遇开始/结束战斗,不依赖尚未实现的地图敌人。 /// public class BattleDebuggerWindow : IDebuggerWindow { @@ -49,7 +49,7 @@ namespace SepCore.Debugger if (GUILayout.Button("Close", GUILayout.Height(30))) { - GameEntry.RunBattle.EndDebugBattle(); + GameEntry.TurnBattle.CloseBattle(); } } GUILayout.EndHorizontal(); @@ -81,8 +81,8 @@ namespace SepCore.Debugger { InitDebugRunStateIfNeeded(); - // 固定调试遭遇:EncounterId=1,敌人队伍预设 1,普通战斗 - bool started = GameEntry.RunBattle.TryStartBattle(new BattleEncounter(1, 1, false)); + // 固定调试遭遇: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."); @@ -112,9 +112,21 @@ 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."); + return players; + } + int order = 1; foreach (int characterId in global.NewGameCharacterIds) { + if (order > 1) + { + break; + } + CharacterConfig config = GameEntry.Luban.Get(characterId); if (config == null) { diff --git a/Assets/GameMain/Scripts/Tests.meta b/Assets/GameMain/Scripts/Tests.meta new file mode 100644 index 0000000..376fcef --- /dev/null +++ b/Assets/GameMain/Scripts/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 68a0e1b7c828a496d858968e77f3826b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Tests/EditMode.meta b/Assets/GameMain/Scripts/Tests/EditMode.meta new file mode 100644 index 0000000..0e66ffc --- /dev/null +++ b/Assets/GameMain/Scripts/Tests/EditMode.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2d55ddba8b14246bcb3c84719d8ef2b1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Tests/EditMode/Battle.meta b/Assets/GameMain/Scripts/Tests/EditMode/Battle.meta new file mode 100644 index 0000000..3b75389 --- /dev/null +++ b/Assets/GameMain/Scripts/Tests/EditMode/Battle.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ff9bdccc0a8fa4a70a750d29c757252a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeAttackLoopTests.cs b/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeAttackLoopTests.cs new file mode 100644 index 0000000..5491147 --- /dev/null +++ b/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeAttackLoopTests.cs @@ -0,0 +1,212 @@ +using System.Collections.Generic; +using NUnit.Framework; +using SepCore.Battle; +using SepCore.CustomComponent; +using SepCore.Definition; + +namespace SepCore.Tests +{ + [TestFixture] + public class BattleRuntimeAttackLoopTests + { + [Test] + public void PlayerAttack_DealsAtkDamageAndHandsTurnToEnemy() + { + BattleRuntime runtime = TestBattle.Create1v1(TestBattle.Player()); + + BattleStep step = runtime.SubmitCommand(TestBattle.Attack(1, 2)); + + Assert.Null(step.Result); + Assert.AreEqual(1, step.Events.Count); + BattleEvent battleEvent = step.Events[0]; + Assert.AreEqual(1, battleEvent.ActorUnitId); + Assert.AreEqual(BattleActionType.Attack, battleEvent.CommandType); + Assert.AreEqual(1, battleEvent.ActionConfigId); + Assert.AreEqual(2, battleEvent.TargetUnitId); + Assert.AreEqual(50, battleEvent.BeforeHp); + Assert.AreEqual(36, battleEvent.AfterHp); + Assert.AreEqual(36, runtime.GetUnit(2).CurrentHp); + Assert.AreEqual(120, runtime.GetUnit(1).CurrentHp); + Assert.AreEqual(2, runtime.CurrentActorUnitId); + Assert.AreEqual(1, runtime.RoundNumber); + } + + [Test] + public void EnemyCounterAttack_DamagesPlayerAndStartsNextRound() + { + BattleRuntime runtime = TestBattle.Create1v1(TestBattle.Player()); + runtime.SubmitCommand(TestBattle.Attack(1, 2)); + + BattleStep step = runtime.AdvanceEnemyTurn(); + + Assert.Null(step.Result); + Assert.AreEqual(1, step.Events.Count); + BattleEvent battleEvent = step.Events[0]; + Assert.AreEqual(2, battleEvent.ActorUnitId); + Assert.AreEqual(1, battleEvent.TargetUnitId); + Assert.AreEqual(120, battleEvent.BeforeHp); + Assert.AreEqual(112, battleEvent.AfterHp); + Assert.AreEqual(112, runtime.GetUnit(1).CurrentHp); + Assert.AreEqual(1, runtime.CurrentActorUnitId); + Assert.AreEqual(2, runtime.RoundNumber); + } + + [Test] + public void AttackLoop_EndsWithVictory() + { + BattleRuntime runtime = TestBattle.Create1v1(TestBattle.Player()); + + BattleStep final = PlayToCompletion(runtime); + + Assert.NotNull(final.Result); + Assert.AreEqual(BattleOutcome.Victory, final.Result.Outcome); + Assert.AreEqual(1, final.Result.EncounterId); + Assert.AreEqual(1, final.Result.Players.Count); + Assert.AreEqual(1001, final.Result.Players[0].CharacterId); + Assert.False(final.Result.Players[0].WasDefeated); + Assert.False(final.Result.Players[0].Escaped); + Assert.Greater(final.Result.Players[0].CurrentHp, 0); + Assert.True(runtime.IsCompleted); + Assert.AreEqual(0, runtime.CurrentActorUnitId); + } + + [Test] + public void AttackLoop_EndsWithTotalDefeat_WhenPlayerDies() + { + BattleRuntime runtime = TestBattle.Create1v1(TestBattle.Player(currentHp: 5, maxHp: 5)); + + BattleStep final = PlayToCompletion(runtime); + + Assert.NotNull(final.Result); + Assert.AreEqual(BattleOutcome.TotalDefeat, final.Result.Outcome); + Assert.AreEqual(0, final.Result.Players[0].CurrentHp); + Assert.True(final.Result.Players[0].WasDefeated); + Assert.True(runtime.IsCompleted); + } + + [Test] + public void PlayerAttack_WithHealEffect_ClampsHpToMax() + { + TestConfigProvider config = TestConfigProvider.Standard1v1(); + config.AddAction(TestConfigs.AttackAction(2, 200, BattleStatType.None, 0)); + BattleRuntime runtime = TestBattle.Create1v1(TestBattle.Player(), config); + + BattleStep step = runtime.SubmitCommand(TestBattle.Attack(1, 2, 2)); + + Assert.AreEqual(1, step.Events.Count); + Assert.AreEqual(50, step.Events[0].AfterHp); + Assert.AreEqual(50, runtime.GetUnit(2).CurrentHp); + } + + [Test] + public void InvalidCommand_WrongActor_IsRejectedWithoutConsumption() + { + BattleRuntime runtime = TestBattle.Create1v1(TestBattle.Player()); + + BattleStep step = runtime.SubmitCommand(TestBattle.Attack(999, 2)); + + Assert.Null(step.Result); + Assert.IsEmpty(step.Events); + Assert.AreEqual(1, runtime.CurrentActorUnitId); + Assert.AreEqual(120, runtime.GetUnit(1).CurrentHp); + Assert.AreEqual(50, runtime.GetUnit(2).CurrentHp); + } + + [Test] + public void InvalidCommand_SkillCommand_IsRejected() + { + BattleRuntime runtime = TestBattle.Create1v1(TestBattle.Player()); + + BattleStep step = runtime.SubmitCommand( + new BattleCommand(1, BattleActionType.Skill, 101, new List { 2 })); + + Assert.Null(step.Result); + Assert.IsEmpty(step.Events); + Assert.AreEqual(1, runtime.CurrentActorUnitId); + } + + [Test] + public void InvalidCommand_ActionConfigTypeMismatch_IsRejected() + { + TestConfigProvider config = TestConfigProvider.Standard1v1(); + BattleRuntime runtime = TestBattle.Create1v1(TestBattle.Player(), config); + + // 行动 1 是攻击,却用 Skill 类型提交 + BattleStep step = runtime.SubmitCommand( + new BattleCommand(1, BattleActionType.Skill, 1, new List { 2 })); + + Assert.Null(step.Result); + Assert.IsEmpty(step.Events); + Assert.AreEqual(1, runtime.CurrentActorUnitId); + } + + [Test] + public void InvalidCommand_AllyTarget_IsRejected() + { + BattleRuntime runtime = TestBattle.Create1v1(TestBattle.Player()); + + BattleStep step = runtime.SubmitCommand(TestBattle.Attack(1, 1)); + + Assert.Null(step.Result); + Assert.IsEmpty(step.Events); + Assert.AreEqual(1, runtime.CurrentActorUnitId); + } + + [Test] + public void InvalidCommand_NoTarget_IsRejected() + { + BattleRuntime runtime = TestBattle.Create1v1(TestBattle.Player()); + + BattleStep step = runtime.SubmitCommand( + new BattleCommand(1, BattleActionType.Attack, 1, new List())); + + Assert.Null(step.Result); + Assert.IsEmpty(step.Events); + Assert.AreEqual(1, runtime.CurrentActorUnitId); + } + + [Test] + public void CompletedBattle_RejectsFurtherCommands() + { + BattleRuntime runtime = TestBattle.Create1v1(TestBattle.Player()); + PlayToCompletion(runtime); + Assert.True(runtime.IsCompleted); + + BattleStep step = runtime.SubmitCommand(TestBattle.Attack(1, 2)); + + Assert.Null(step.Result); + Assert.IsEmpty(step.Events); + Assert.AreEqual(0, runtime.CurrentActorUnitId); + } + + private static BattleStep PlayToCompletion(BattleRuntime runtime) + { + BattleStep step = null; + int guard = 0; + while (runtime.Result == null && guard++ < 200) + { + BattleUnit actor = runtime.CurrentActor; + if (actor != null && actor.Faction == BattleFaction.Player) + { + int targetUnitId = 0; + foreach (BattleUnit unit in runtime.Units) + { + if (unit.Faction == BattleFaction.Enemy && BattleRuntime.IsActive(unit)) + { + targetUnitId = unit.UnitId; + break; + } + } + + step = runtime.SubmitCommand(TestBattle.Attack(actor.UnitId, targetUnitId)); + } + else + { + step = runtime.AdvanceEnemyTurn(); + } + } + + return step; + } + } +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeAttackLoopTests.cs.meta b/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeAttackLoopTests.cs.meta new file mode 100644 index 0000000..77b4227 --- /dev/null +++ b/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeAttackLoopTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5e6abbbfbf1d9431ea5cfe74917ee817 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeCreateTests.cs b/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeCreateTests.cs new file mode 100644 index 0000000..e8f2006 --- /dev/null +++ b/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeCreateTests.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using NUnit.Framework; +using SepCore.Battle; +using SepCore.CustomComponent; + +namespace SepCore.Tests +{ + [TestFixture] + public class BattleRuntimeCreateTests + { + [Test] + public void Create_WithStandard1v1Inputs_BuildsExpectedUnits() + { + BattleRuntime runtime = TestBattle.Create1v1(TestBattle.Player()); + + Assert.NotNull(runtime); + Assert.False(runtime.IsCompleted); + Assert.AreEqual(1, runtime.RoundNumber); + Assert.AreEqual(2, runtime.Units.Length); + + BattleUnit player = runtime.Units[0]; + Assert.AreEqual(1, player.UnitId); + Assert.AreEqual(BattleFaction.Player, player.Faction); + Assert.AreEqual(1001, player.ConfigId); + Assert.AreEqual(1, player.PartyOrder); + Assert.AreEqual(120, player.CurrentHp); + Assert.AreEqual(120, player.MaxHp); + Assert.AreEqual(40, player.CurrentMp); + Assert.AreEqual(14, player.Atk); + Assert.AreEqual(6, player.Mat); + Assert.AreEqual(12, player.Speed); + CollectionAssert.AreEqual(new List { 1, 101 }, player.ActionIds); + + BattleUnit enemy = runtime.Units[1]; + Assert.AreEqual(2, enemy.UnitId); + Assert.AreEqual(BattleFaction.Enemy, enemy.Faction); + Assert.AreEqual(3001, enemy.ConfigId); + Assert.AreEqual(50, enemy.CurrentHp); + Assert.AreEqual(8, enemy.Atk); + CollectionAssert.AreEqual(new List { 201 }, enemy.ActionIds); + + Assert.AreEqual(1, runtime.CurrentActorUnitId); + } + + [Test] + public void Create_WithNullInputs_ReturnsNull() + { + RunPlayerState player = TestBattle.Player(); + BattleEncounter encounter = TestBattle.Encounter(); + TestConfigProvider config = TestConfigProvider.Standard1v1(); + IRunRandomSource random = new TestRandomSource(); + + Assert.Null(BattleRuntime.Create(null, new List { player }, config, random)); + Assert.Null(BattleRuntime.Create(encounter, null, config, random)); + Assert.Null(BattleRuntime.Create(encounter, new List(), config, random)); + Assert.Null(BattleRuntime.Create(encounter, new List { player }, null, random)); + Assert.Null(BattleRuntime.Create(encounter, new List { player }, config, null)); + } + + [Test] + public void Create_WithMissingEnemyParty_ReturnsNull() + { + TestConfigProvider config = TestConfigProvider.Standard1v1(); + + Assert.Null(BattleRuntime.Create(new BattleEncounter(1, 999, false), + new List { TestBattle.Player() }, config, new TestRandomSource())); + } + + [Test] + public void Create_WithMissingEnemyConfig_ReturnsNull() + { + TestConfigProvider config = TestConfigProvider.Standard1v1(); + config.AddParty(TestConfigs.Party(5001, 9999)); + + Assert.Null(BattleRuntime.Create(new BattleEncounter(1, 5001, false), + new List { TestBattle.Player() }, config, new TestRandomSource())); + } + } +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeCreateTests.cs.meta b/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeCreateTests.cs.meta new file mode 100644 index 0000000..6dc92f3 --- /dev/null +++ b/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeCreateTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6f72e4b9e477543aea96022278d94914 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeEnemyTurnTests.cs b/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeEnemyTurnTests.cs new file mode 100644 index 0000000..846f28f --- /dev/null +++ b/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeEnemyTurnTests.cs @@ -0,0 +1,138 @@ +using System.Collections.Generic; +using NUnit.Framework; +using SepCore.Battle; +using SepCore.CustomComponent; +using SepCore.Definition; + +namespace SepCore.Tests +{ + [TestFixture] + public class BattleRuntimeEnemyTurnTests + { + [Test] + public void AdvanceEnemyTurn_WhenPlayerIsCurrent_ReturnsNoOp() + { + BattleRuntime runtime = TestBattle.Create1v1(TestBattle.Player()); + + BattleStep step = runtime.AdvanceEnemyTurn(); + + Assert.Null(step.Result); + Assert.IsEmpty(step.Events); + Assert.AreEqual(1, runtime.CurrentActorUnitId); + Assert.AreEqual(120, runtime.GetUnit(1).CurrentHp); + Assert.AreEqual(50, runtime.GetUnit(2).CurrentHp); + } + + [Test] + public void EnemyWithoutUsableActions_SkipsItsTurn() + { + TestConfigProvider config = TestConfigProvider.Standard1v1(); + // 敌人唯一行动改为 MP 不足(100 > 敌人 MP 0),无可用行动 + config.AddAction(TestConfigs.AttackAction(201, 0, BattleStatType.ATK, -1000, 100)); + BattleRuntime runtime = TestBattle.Create1v1(TestBattle.Player(), config); + + runtime.SubmitCommand(TestBattle.Attack(1, 2)); + BattleStep step = runtime.AdvanceEnemyTurn(); + + Assert.Null(step.Result); + Assert.IsEmpty(step.Events); + Assert.AreEqual(1, runtime.CurrentActorUnitId); + Assert.AreEqual(2, runtime.RoundNumber); + Assert.AreEqual(120, runtime.GetUnit(1).CurrentHp); + } + + [Test] + public void EnemyWithNoActionIds_SkipsItsTurn() + { + TestConfigProvider config = TestConfigProvider.Standard1v1(); + config.AddEnemy(TestConfigs.Enemy(3001, 50, 8)); + BattleRuntime runtime = TestBattle.Create1v1(TestBattle.Player(), config); + + runtime.SubmitCommand(TestBattle.Attack(1, 2)); + BattleStep step = runtime.AdvanceEnemyTurn(); + + Assert.Null(step.Result); + Assert.IsEmpty(step.Events); + Assert.AreEqual(1, runtime.CurrentActorUnitId); + Assert.AreEqual(2, runtime.RoundNumber); + } + + [Test] + public void EnemyWithMultipleActions_ChoosesByRandomSequence() + { + TestConfigProvider config = TestConfigProvider.Standard1v1(); + config.AddAction(TestConfigs.AttackAction(201, 0, BattleStatType.ATK, -1000)); + config.AddAction(TestConfigs.AttackAction(202, -30, BattleStatType.None, 0)); + config.AddEnemy(TestConfigs.Enemy(3001, 50, 8, 201, 202)); + config.AddParty(TestConfigs.Party(4001, 3001)); + + // 随机序列 [0] -> 选 201,伤害 = ATK = 8 + BattleRuntime runtimeA = TestBattle.Create1v1(TestBattle.Player(), config, new TestRandomSource(0)); + runtimeA.SubmitCommand(TestBattle.Attack(1, 2)); + BattleStep stepA = runtimeA.AdvanceEnemyTurn(); + Assert.AreEqual(112, stepA.Events[0].AfterHp); + + // 随机序列 [1] -> 选 202,固定伤害 30 + BattleRuntime runtimeB = TestBattle.Create1v1(TestBattle.Player(), config, new TestRandomSource(1)); + runtimeB.SubmitCommand(TestBattle.Attack(1, 2)); + BattleStep stepB = runtimeB.AdvanceEnemyTurn(); + Assert.AreEqual(90, stepB.Events[0].AfterHp); + } + + [Test] + public void SameInputsAndRandomSequence_ProduceSameRecords() + { + TestConfigProvider config = TestConfigProvider.Standard1v1(); + config.AddAction(TestConfigs.AttackAction(201, 0, BattleStatType.ATK, -1000)); + config.AddAction(TestConfigs.AttackAction(202, -30, BattleStatType.None, 0)); + config.AddEnemy(TestConfigs.Enemy(3001, 50, 8, 201, 202)); + config.AddParty(TestConfigs.Party(4001, 3001)); + + BattleRuntime runtimeA = TestBattle.Create1v1(TestBattle.Player(), config, new TestRandomSource(0, 0, 0, 0)); + BattleRuntime runtimeB = TestBattle.Create1v1(TestBattle.Player(), config, new TestRandomSource(0, 0, 0, 0)); + + List recordsA = CollectRecords(runtimeA); + List recordsB = CollectRecords(runtimeB); + + CollectionAssert.AreEqual(recordsA, recordsB); + } + + 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) + { + int targetUnitId = 0; + foreach (BattleUnit unit in runtime.Units) + { + if (unit.Faction == BattleFaction.Enemy && BattleRuntime.IsActive(unit)) + { + targetUnitId = unit.UnitId; + break; + } + } + + step = runtime.SubmitCommand(TestBattle.Attack(actor.UnitId, targetUnitId)); + } + 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; + } + } +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeEnemyTurnTests.cs.meta b/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeEnemyTurnTests.cs.meta new file mode 100644 index 0000000..4bdee44 --- /dev/null +++ b/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeEnemyTurnTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8d0eb453c4b42453abb2e86fad636fe9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeViewAndLifecycleTests.cs b/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeViewAndLifecycleTests.cs new file mode 100644 index 0000000..8773ccc --- /dev/null +++ b/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeViewAndLifecycleTests.cs @@ -0,0 +1,122 @@ +using System.Collections.Generic; +using NUnit.Framework; +using SepCore.Battle; +using SepCore.CustomComponent; + +namespace SepCore.Tests +{ + [TestFixture] + public class BattleRuntimeViewAndLifecycleTests + { + [Test] + public void BuildViewState_ReflectsUnitsAndTurnOrder() + { + BattleRuntime runtime = TestBattle.Create1v1(TestBattle.Player()); + + BattleViewState view = runtime.BuildViewState(); + + Assert.AreEqual(1, view.RoundNumber); + Assert.AreEqual(1, view.CurrentActorUnitId); + Assert.AreEqual(2, view.Units.Count); + CollectionAssert.AreEqual(new List { 1, 2 }, view.RemainingTurnOrder); + CollectionAssert.AreEqual(new List { 1, 101 }, view.AvailableActionIds); + + BattleUnitView playerView = view.Units[0]; + Assert.AreEqual(BattleFaction.Player, playerView.Faction); + Assert.AreEqual(1001, playerView.ConfigId); + Assert.AreEqual(1, playerView.PartyOrder); + Assert.AreEqual(120, playerView.CurrentHp); + Assert.AreEqual(40, playerView.CurrentMp); + Assert.AreEqual(12, playerView.Speed); + Assert.False(playerView.IsDefeated); + Assert.False(playerView.IsEscaped); + Assert.IsEmpty(playerView.Statuses); + + BattleUnitView enemyView = view.Units[1]; + Assert.AreEqual(BattleFaction.Enemy, enemyView.Faction); + Assert.AreEqual(3001, enemyView.ConfigId); + Assert.AreEqual(50, enemyView.CurrentHp); + Assert.False(enemyView.IsDefeated); + } + + [Test] + public void BuildViewState_AfterPlayerAction_HighlightsEnemyAndKeepsAllUnits() + { + BattleRuntime runtime = TestBattle.Create1v1(TestBattle.Player()); + runtime.SubmitCommand(TestBattle.Attack(1, 2)); + + BattleViewState view = runtime.BuildViewState(); + + Assert.AreEqual(2, view.CurrentActorUnitId); + // 同轮内列表仍包含全部单位(已行动玩家不被隐藏) + CollectionAssert.AreEqual(new List { 2 }, view.RemainingTurnOrder); + Assert.AreEqual(2, view.Units.Count); + Assert.AreEqual(36, view.Units[1].CurrentHp); + } + + [Test] + public void BuildViewState_AfterCompletion_HasNoActor() + { + BattleRuntime runtime = TestBattle.Create1v1(TestBattle.Player()); + int guard = 0; + while (runtime.Result == null && guard++ < 200) + { + BattleUnit actor = runtime.CurrentActor; + if (actor != null && actor.Faction == BattleFaction.Player) + { + int targetUnitId = 0; + foreach (BattleUnit unit in runtime.Units) + { + if (unit.Faction == BattleFaction.Enemy && BattleRuntime.IsActive(unit)) + { + targetUnitId = unit.UnitId; + break; + } + } + + runtime.SubmitCommand(TestBattle.Attack(actor.UnitId, targetUnitId)); + } + else + { + runtime.AdvanceEnemyTurn(); + } + } + + BattleViewState view = runtime.BuildViewState(); + + Assert.AreEqual(0, view.CurrentActorUnitId); + Assert.IsEmpty(view.RemainingTurnOrder); + Assert.IsEmpty(view.AvailableActionIds); + } + + [Test] + public void SequentialBattles_DoNotInheritState() + { + // 第一场打到玩家残血 + BattleRuntime first = TestBattle.Create1v1(TestBattle.Player()); + first.SubmitCommand(TestBattle.Attack(1, 2)); + first.AdvanceEnemyTurn(); + Assert.AreEqual(112, first.GetUnit(1).CurrentHp); + + // 第二场用同一份输入重新创建:应从满值开局 + BattleRuntime second = TestBattle.Create1v1(TestBattle.Player()); + Assert.AreEqual(120, second.GetUnit(1).CurrentHp); + Assert.AreEqual(50, second.GetUnit(2).CurrentHp); + Assert.AreEqual(1, second.RoundNumber); + Assert.AreEqual(1, second.CurrentActorUnitId); + Assert.False(second.IsCompleted); + } + + [Test] + public void SequentialBattles_UseIndependentActedSets() + { + BattleRuntime first = TestBattle.Create1v1(TestBattle.Player()); + first.SubmitCommand(TestBattle.Attack(1, 2)); + Assert.IsTrue(first.ActedUnitIds.Contains(1)); + + BattleRuntime second = TestBattle.Create1v1(TestBattle.Player()); + Assert.IsEmpty(second.ActedUnitIds); + Assert.AreEqual(1, second.CurrentActorUnitId); + } + } +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeViewAndLifecycleTests.cs.meta b/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeViewAndLifecycleTests.cs.meta new file mode 100644 index 0000000..1a733b7 --- /dev/null +++ b/Assets/GameMain/Scripts/Tests/EditMode/Battle/BattleRuntimeViewAndLifecycleTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c78ea02415ec84677ae1925546f846c5 +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 new file mode 100644 index 0000000..7815aea --- /dev/null +++ b/Assets/GameMain/Scripts/Tests/EditMode/Battle/TestBattleSupport.cs @@ -0,0 +1,207 @@ +using System.Collections.Generic; +using System.Reflection; +using System.Runtime.Serialization; +using SepCore.Battle; +using SepCore.CustomComponent; +using SepCore.Definition; + +namespace SepCore.Tests +{ + /// + /// 用反射构造 Luban 生成的只读配置行,供测试的假配置提供者使用。 + /// + internal static class TestConfigFactory + { + public static T Create(params object[] fieldValues) + { + T instance = (T)FormatterServices.GetUninitializedObject(typeof(T)); + for (int i = 0; i < fieldValues.Length; i += 2) + { + FieldInfo field = typeof(T).GetField((string)fieldValues[i]); + field.SetValue(instance, fieldValues[i + 1]); + } + + return instance; + } + } + + /// + /// 与真实配表数值对齐的测试配置构建器。 + /// + internal static class TestConfigs + { + /// + /// 单体敌方攻击行动。 + /// + public static BattleActionConfig AttackAction(int id, int flat, BattleStatType sourceStat, + int sourceScalePermille, int mpCost = 0) + { + return TestConfigFactory.Create( + "Id", id, "Name", "攻击" + id, "ActionType", BattleActionType.Attack, + "TargetType", BattleTargetType.SingleEnemy, "MpCost", mpCost, + "Effects", new List + { + TestConfigFactory.Create( + "TargetStat", BattleStatType.HP, "FlatValue", flat, + "SourceStat", sourceStat, "SourceScalePermille", sourceScalePermille, + "Status", BattleStatusType.None, "DurationRounds", 0) + }); + } + + public static EnemyConfig Enemy(int id, int maxHp, int atk, 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, + "ActionIds", new List(actionIds), "DropTableId", 0); + } + + public static EnemyPartyConfig Party(int id, params int[] enemyIds) + { + return TestConfigFactory.Create( + "Id", id, "Name", "队伍" + id, "EnemyIds", new List(enemyIds)); + } + } + + /// + /// 内存配置提供者:EditMode 使用,不依赖 Luban 与 GameEntry。 + /// + internal sealed class TestConfigProvider : IBattleConfigProvider + { + private readonly Dictionary _parties = new Dictionary(); + private readonly Dictionary _enemies = new Dictionary(); + private readonly Dictionary _actions = new Dictionary(); + private GlobalConfig _global; + + public void AddParty(EnemyPartyConfig party) + { + _parties[party.Id] = party; + } + + public void AddEnemy(EnemyConfig enemy) + { + _enemies[enemy.Id] = enemy; + } + + public void AddAction(BattleActionConfig action) + { + _actions[action.Id] = action; + } + + public void SetGlobal(GlobalConfig global) + { + _global = global; + } + + public EnemyPartyConfig GetEnemyParty(int id) + { + EnemyPartyConfig value; + return _parties.TryGetValue(id, out value) ? value : null; + } + + public EnemyConfig GetEnemy(int id) + { + EnemyConfig value; + return _enemies.TryGetValue(id, out value) ? value : null; + } + + public BattleActionConfig GetAction(int id) + { + BattleActionConfig value; + return _actions.TryGetValue(id, out value) ? value : null; + } + + public GlobalConfig GetGlobal() + { + return _global; + } + + /// + /// 标准 1v1:行动 1(玩家攻击,伤害 = ATK)、行动 201(敌人攻击,伤害 = ATK)、 + /// 敌人 3001(50 HP,ATK 8,行动 201)、队伍 4001(单个 3001)。 + /// + public static TestConfigProvider Standard1v1() + { + TestConfigProvider provider = new TestConfigProvider(); + provider.AddAction(TestConfigs.AttackAction(1, 0, BattleStatType.ATK, -1000)); + provider.AddAction(TestConfigs.AttackAction(201, 0, BattleStatType.ATK, -1000)); + provider.AddEnemy(TestConfigs.Enemy(3001, 50, 8, 201)); + provider.AddParty(TestConfigs.Party(4001, 3001)); + return provider; + } + } + + /// + /// 可注入序列的测试随机源;用尽后返回 minInclusive。 + /// + internal sealed class TestRandomSource : IRunRandomSource + { + private readonly Queue _values = new Queue(); + + public TestRandomSource(params int[] values) + { + foreach (int value in values) + { + _values.Enqueue(value); + } + } + + public int NextInt(int minInclusive, int maxExclusive) + { + return _values.Count > 0 ? _values.Dequeue() : minInclusive; + } + + public bool RollPermille(int successPermille) + { + return NextInt(0, 1000) < successPermille; + } + } + + /// + /// 标准测试输入与指令构造。 + /// + internal static class TestBattle + { + /// + /// 标准玩家 1001:120 HP / 40 MP,ATK 14,攻击行动 1,技能行动 101。 + /// + public static RunPlayerState Player(int currentHp = 120, int maxHp = 120, int atk = 14) + { + return new RunPlayerState + { + CharacterId = 1001, + PartyOrder = 1, + CurrentHp = currentHp, + CurrentMp = 40, + MaxHp = maxHp, + MaxMp = 40, + Atk = atk, + Mat = 6, + Speed = 12, + AttackActionId = 1, + SkillActionId = 101 + }; + } + + public static BattleEncounter Encounter(int encounterId = 1, int enemyPartyConfigId = 4001) + { + return new BattleEncounter(encounterId, enemyPartyConfigId, false); + } + + public static BattleCommand Attack(int actorUnitId, int targetUnitId, int actionId = 1) + { + return new BattleCommand(actorUnitId, BattleActionType.Attack, actionId, + new List { targetUnitId }); + } + + /// + /// 创建标准 1v1 运行时。 + /// + public static BattleRuntime Create1v1(RunPlayerState player, IBattleConfigProvider config = null, + IRunRandomSource random = null) + { + return BattleRuntime.Create(Encounter(), new List { player }, + config ?? TestConfigProvider.Standard1v1(), random ?? new TestRandomSource()); + } + } +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Tests/EditMode/Battle/TestBattleSupport.cs.meta b/Assets/GameMain/Scripts/Tests/EditMode/Battle/TestBattleSupport.cs.meta new file mode 100644 index 0000000..92638d4 --- /dev/null +++ b/Assets/GameMain/Scripts/Tests/EditMode/Battle/TestBattleSupport.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 12a6461a326b54f55bdfa3630c77e175 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Tests/EditMode/EditMode.Tests.asmdef b/Assets/GameMain/Scripts/Tests/EditMode/EditMode.Tests.asmdef new file mode 100644 index 0000000..9e85f92 --- /dev/null +++ b/Assets/GameMain/Scripts/Tests/EditMode/EditMode.Tests.asmdef @@ -0,0 +1,14 @@ +{ + "name": "Tests", + "optionalUnityReferences": [ + "TestAssemblies" + ], + "references": [ + "GUID:11ec41dbc3557e6499b2845ba531a9b0", + "GUID:a04b272484018094ab427253f20c416c", + "GUID:363c5eb08ff8e6a439b85e37b8c20d96" + ], + "includePlatforms": [ + "Editor" + ] +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Tests/EditMode/EditMode.Tests.asmdef.meta b/Assets/GameMain/Scripts/Tests/EditMode/EditMode.Tests.asmdef.meta new file mode 100644 index 0000000..290673c --- /dev/null +++ b/Assets/GameMain/Scripts/Tests/EditMode/EditMode.Tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6ca799cc4b05645b1842927808f15538 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/UI/Battle/BattleActorCardItem.cs b/Assets/GameMain/Scripts/UI/Battle/BattleActorCardItem.cs index a52ec60..ad7cab2 100644 --- a/Assets/GameMain/Scripts/UI/Battle/BattleActorCardItem.cs +++ b/Assets/GameMain/Scripts/UI/Battle/BattleActorCardItem.cs @@ -1,6 +1,11 @@ +using Cysharp.Threading.Tasks; +using SepCore.AsyncTask; +using SepCore.Battle; +using SepCore.Definition; using TMPro; using UnityEngine; using UnityEngine.UI; +using UnityGameFramework.Runtime; namespace SepCore.UI { @@ -15,26 +20,74 @@ namespace SepCore.UI [SerializeField] private TextMeshProUGUI hpText; [SerializeField] private TextMeshProUGUI mpText; - public GameObject ActiveMarker => activeMarker; - public Image Icon => icon; - public Image HpFill => hpFill; - public Image MpFill => mpFill; - public TextMeshProUGUI CharacterName => characterName; - public TextMeshProUGUI HpText => hpText; - public TextMeshProUGUI MpText => mpText; + private int _iconVersion; -#if UNITY_EDITOR - public void ConfigureEditor(GameObject marker, Image iconImage, Image hpFillImage, Image mpFillImage, - TextMeshProUGUI nameLabel, TextMeshProUGUI hpLabel, TextMeshProUGUI mpLabel) + /// + /// 用战斗单位视图填充我方卡片:名称、HP/MP 数值与血条、当前行动者标记和配置图标。 + /// + public void SetUnit(BattleUnitView unit, bool isCurrentActor) { - activeMarker = marker; - icon = iconImage; - hpFill = hpFillImage; - mpFill = mpFillImage; - characterName = nameLabel; - hpText = hpLabel; - mpText = mpLabel; + if (unit == null) + { + return; + } + + if (characterName != null) + { + characterName.text = BattleUnitViewHelper.GetDisplayName(unit); + } + + if (hpText != null) + { + hpText.text = unit.CurrentHp + " / " + unit.MaxHp; + } + + if (mpText != null) + { + mpText.text = unit.CurrentMp + " / " + unit.MaxMp; + } + + SetBar(hpFill, unit.CurrentHp, unit.MaxHp); + SetBar(mpFill, unit.CurrentMp, unit.MaxMp); + + if (activeMarker != null) + { + activeMarker.SetActive(isCurrentActor); + } + + _iconVersion++; + ShowIconAsync(BattleUnitViewHelper.GetPlayerIconConfig(unit.ConfigId), _iconVersion).Forget(); + } + + /// + /// 异步加载角色图标;iconVersion 用于防止复用格子时旧加载结果覆盖新内容。 + /// + private async UniTaskVoid ShowIconAsync(SpriteConfig iconConfig, int iconVersion) + { + if (iconConfig == null || icon == null) + { + return; + } + + Sprite sprite = await SpriteLoader.LoadSpriteAsync(iconConfig); + if (sprite == null || _iconVersion != iconVersion) + { + return; + } + + icon.sprite = sprite; + icon.gameObject.SetActive(true); + } + + private static void SetBar(Image fill, int current, int max) + { + if (fill == null) + { + return; + } + + float ratio = max > 0 ? Mathf.Clamp01((float)current / max) : 0f; + fill.rectTransform.anchorMax = new Vector2(ratio, 1f); } -#endif } -} +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/UI/Battle/BattleEnemySlotItem.cs b/Assets/GameMain/Scripts/UI/Battle/BattleEnemySlotItem.cs index 2c551e7..65ae3cd 100644 --- a/Assets/GameMain/Scripts/UI/Battle/BattleEnemySlotItem.cs +++ b/Assets/GameMain/Scripts/UI/Battle/BattleEnemySlotItem.cs @@ -1,6 +1,11 @@ +using Cysharp.Threading.Tasks; +using SepCore.AsyncTask; +using SepCore.Battle; +using SepCore.Definition; using TMPro; using UnityEngine; using UnityEngine.UI; +using UnityGameFramework.Runtime; namespace SepCore.UI { @@ -14,24 +19,74 @@ namespace SepCore.UI [SerializeField] private TextMeshProUGUI enemyName; [SerializeField] private TextMeshProUGUI hpText; - public Button TargetButton => targetButton; - public GameObject SelectedMarker => selectedMarker; - public Image Icon => icon; - public Image HpFill => hpFill; - public TextMeshProUGUI EnemyName => enemyName; - public TextMeshProUGUI HpText => hpText; + private int _iconVersion; -#if UNITY_EDITOR - public void ConfigureEditor(Button button, GameObject marker, Image iconImage, Image hpFillImage, - TextMeshProUGUI nameLabel, TextMeshProUGUI hpLabel) + /// + /// 用战斗单位视图填充敌人槽:名称、HP 数值与血条、配置图标和当前行动者标记。 + /// 阵亡或已逃跑目标不可选。 + /// + public void SetEnemy(BattleUnitView unit, bool isCurrentActor) { - targetButton = button; - selectedMarker = marker; - icon = iconImage; - hpFill = hpFillImage; - enemyName = nameLabel; - hpText = hpLabel; + if (unit == null) + { + return; + } + + if (enemyName != null) + { + enemyName.text = BattleUnitViewHelper.GetDisplayName(unit); + } + + if (hpText != null) + { + hpText.text = unit.CurrentHp + " / " + unit.MaxHp; + } + + SetBar(hpFill, unit.CurrentHp, unit.MaxHp); + + if (selectedMarker != null) + { + selectedMarker.SetActive(isCurrentActor); + } + + if (targetButton != null) + { + targetButton.interactable = !unit.IsDefeated && !unit.IsEscaped; + } + + _iconVersion++; + ShowIconAsync(BattleUnitViewHelper.GetEnemyIconConfig(unit.ConfigId), _iconVersion).Forget(); + } + + /// + /// 异步加载敌人图标;iconVersion 用于防止复用格子时旧加载结果覆盖新内容。 + /// + private async UniTaskVoid ShowIconAsync(SpriteConfig iconConfig, int iconVersion) + { + if (iconConfig == null || icon == null) + { + return; + } + + Sprite sprite = await SpriteLoader.LoadSpriteAsync(iconConfig); + if (sprite == null || _iconVersion != iconVersion) + { + return; + } + + icon.sprite = sprite; + icon.gameObject.SetActive(true); + } + + private static void SetBar(Image fill, int current, int max) + { + if (fill == null) + { + return; + } + + float ratio = max > 0 ? Mathf.Clamp01((float)current / max) : 0f; + fill.rectTransform.anchorMax = new Vector2(ratio, 1f); } -#endif } -} +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/UI/Battle/BattleForm.Logic.cs b/Assets/GameMain/Scripts/UI/Battle/BattleForm.Logic.cs index e5990a4..2a95672 100644 --- a/Assets/GameMain/Scripts/UI/Battle/BattleForm.Logic.cs +++ b/Assets/GameMain/Scripts/UI/Battle/BattleForm.Logic.cs @@ -1,15 +1,24 @@ +using System.Collections.Generic; +using SepCore.Battle; +using SepCore.Definition; +using UnityEngine; using UnityGameFramework.Runtime; namespace SepCore.UI { /// /// 战斗界面逻辑(手写 partial,与自动生成的 BattleForm.cs 合并)。 - /// 首版为半透明壳层:只负责展示快照、收集指令与占位反馈; - /// 不修改战斗数据、不读取存档、不决定掉落,也不承担暂停地图计时等流程职责。 - /// 暂停地图更新由单局流程(RunBattleCoordinator / 探索层)负责。 + /// UI 只读取 BattleViewState / BattleStep / BattleResult,不持有 BattleRuntime 或 BattleUnit; + /// 玩家指令经 GameEntry.TurnBattle.SubmitCommand 提交,推进结果统一回填界面。 + /// 我方卡片按玩家单位数显示/隐藏;敌人槽和回合顺序槽按当前战局从模板动态实例化。 /// public partial class BattleForm : UGuiForm { + private BattleResult _result; + private int _displayedRound; + private readonly List _turnSlots = new List(); + private readonly List _turnSlotUnitIds = new List(); + protected override void OnInit(object userData) { base.OnInit(userData); @@ -19,20 +28,35 @@ namespace SepCore.UI View.itemButton.onClick.AddListener(OnItemButtonClick); View.escapeButton.onClick.AddListener(OnEscapeButtonClick); - // 道具行动保留但首版禁用,不产生 BattleCommand + // M1 只开放攻击;道具、技能、逃跑在后续里程碑接入 View.itemButton.interactable = false; + View.skillButton.interactable = false; + View.escapeButton.interactable = false; } protected override void OnOpen(object userData) { base.OnOpen(userData); - View.currentActorText.text = string.Empty; - Log.Info("BattleForm opened as empty shell."); + if (GameEntry.TurnBattle != null) + { + GameEntry.TurnBattle.SetStepListener(ApplyStep); + } + + _result = null; + _displayedRound = 0; + _turnSlots.Clear(); + _turnSlotUnitIds.Clear(); + Refresh(GameEntry.TurnBattle != null ? GameEntry.TurnBattle.GetViewState() : null); } protected override void OnClose(bool isShutdown, object userData) { + if (GameEntry.TurnBattle != null) + { + GameEntry.TurnBattle.SetStepListener(null); + } + View.attackButton.onClick.RemoveListener(OnAttackButtonClick); View.skillButton.onClick.RemoveListener(OnSkillButtonClick); View.itemButton.onClick.RemoveListener(OnItemButtonClick); @@ -43,22 +67,285 @@ namespace SepCore.UI private void OnAttackButtonClick() { - Log.Info("BattleForm placeholder: attack clicked."); + if (GameEntry.TurnBattle == null || !GameEntry.TurnBattle.IsBattleActive) + { + return; + } + + BattleViewState view = GameEntry.TurnBattle.GetViewState(); + if (view == null || view.CurrentActorUnitId == 0) + { + return; + } + + BattleUnitView actor = FindUnit(view, view.CurrentActorUnitId); + if (actor == null || actor.Faction != BattleFaction.Player) + { + return; + } + + int attackActionId = FindAttackActionId(view); + int targetUnitId = FindFirstActiveEnemy(view); + if (attackActionId == 0 || targetUnitId == 0) + { + return; + } + + BattleStep step = GameEntry.TurnBattle.SubmitCommand(new BattleCommand( + actor.BattleUnitId, BattleActionType.Attack, attackActionId, + new List { targetUnitId })); + ApplyStep(step); + } + + private void ApplyStep(BattleStep step) + { + if (step == null) + { + return; + } + + if (step.Result != null) + { + _result = step.Result; + } + + Refresh(step.View); + } + + private void Refresh(BattleViewState view) + { + if (view == null) + { + return; + } + + RefreshPlayerCards(view); + RefreshEnemySlots(view); + RefreshTurnSlots(view); + RefreshActionPanel(view); + } + + private void RefreshPlayerCards(BattleViewState view) + { + int index = 0; + foreach (BattleUnitView unit in view.Units) + { + if (unit.Faction != BattleFaction.Player) + { + continue; + } + + BattleActorCardItem card = GetPlayerCard(index); + if (card != null) + { + card.gameObject.SetActive(true); + card.SetUnit(unit, unit.BattleUnitId == view.CurrentActorUnitId); + } + + index++; + } + + for (int i = index; i < 4; i++) + { + BattleActorCardItem card = GetPlayerCard(i); + if (card != null) + { + card.gameObject.SetActive(false); + } + } + } + + private void RefreshEnemySlots(BattleViewState view) + { + BattleEnemySlotItem template = View.battleEnemySlotTemplate; + if (template == null || View.enemySlotsRoot == null) + { + Log.Warning("BattleForm enemy slots are not configured."); + return; + } + + template.gameObject.SetActive(false); + ClearSlots(View.enemySlotsRoot, template.transform); + + foreach (BattleUnitView unit in view.Units) + { + if (unit.Faction != BattleFaction.Enemy) + { + continue; + } + + BattleEnemySlotItem slot = Instantiate(template, View.enemySlotsRoot); + slot.gameObject.SetActive(true); + slot.SetEnemy(unit, unit.BattleUnitId == view.CurrentActorUnitId); + } + } + + private void RefreshTurnSlots(BattleViewState view) + { + BattleTurnSlotItem template = View.battleTurnSlotTemplate; + if (template == null || View.turnSlotsRoot == null) + { + Log.Warning("BattleForm turn slots are not configured."); + return; + } + + if (view.RoundNumber != _displayedRound) + { + RebuildTurnSlots(view, template); + _displayedRound = view.RoundNumber; + return; + } + + // 同轮内只移动当前行动者高亮,不隐藏已行动单位 + for (int i = 0; i < _turnSlots.Count; i++) + { + _turnSlots[i].SetCurrentActor(_turnSlotUnitIds[i] == view.CurrentActorUnitId); + } + } + + /// + /// 新一轮开始时重建本轮完整顺序:全部活跃单位按显示顺序排列, + /// 当前行动者高亮;本轮内已行动单位保持可见。 + /// + 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) + { + if (unit.IsDefeated || unit.IsEscaped) + { + continue; + } + + BattleTurnSlotItem slot = Instantiate(template, View.turnSlotsRoot); + slot.gameObject.SetActive(true); + slot.SetTurnSlot(unit, unit.BattleUnitId == view.CurrentActorUnitId); + _turnSlots.Add(slot); + _turnSlotUnitIds.Add(unit.BattleUnitId); + } + } + + private void RefreshActionPanel(BattleViewState view) + { + if (_result != null) + { + View.currentActorText.text = GetOutcomeText(_result.Outcome); + View.attackButton.interactable = false; + return; + } + + BattleUnitView actor = FindUnit(view, view.CurrentActorUnitId); + bool playerTurn = actor != null && actor.Faction == BattleFaction.Player; + View.currentActorText.text = string.Format("第 {0} 轮 {1} 行动", + view.RoundNumber, actor != null ? BattleUnitViewHelper.GetDisplayName(actor) : string.Empty); + View.attackButton.interactable = playerTurn; + } + + private static string GetOutcomeText(BattleOutcome outcome) + { + switch (outcome) + { + case BattleOutcome.Victory: + return "胜利!"; + case BattleOutcome.AllEscaped: + return "全员逃跑"; + case BattleOutcome.PartialEscapeDefeat: + return "部分逃跑,战斗失败"; + case BattleOutcome.TotalDefeat: + return "全员阵亡,单局失败"; + default: + return string.Empty; + } + } + + private static int FindAttackActionId(BattleViewState view) + { + foreach (int actionId in view.AvailableActionIds) + { + BattleActionConfig action = GameEntry.Luban.Get(actionId); + if (action != null && action.ActionType == BattleActionType.Attack) + { + return actionId; + } + } + + return 0; + } + + private static int FindFirstActiveEnemy(BattleViewState view) + { + foreach (BattleUnitView unit in view.Units) + { + if (unit.Faction == BattleFaction.Enemy && !unit.IsDefeated && !unit.IsEscaped) + { + return unit.BattleUnitId; + } + } + + return 0; + } + +private static BattleUnitView FindUnit(BattleViewState view, int unitId) + { + foreach (BattleUnitView unit in view.Units) + { + if (unit.BattleUnitId == unitId) + { + return unit; + } + } + + return null; + } + + private BattleActorCardItem GetPlayerCard(int index) + { + switch (index) + { + case 0: + return View.playerCard1; + case 1: + return View.playerCard2; + case 2: + return View.playerCard3; + case 3: + return View.playerCard4; + default: + return null; + } + } + + private static void ClearSlots(RectTransform root, Transform template) + { + for (int i = root.childCount - 1; i >= 0; i--) + { + Transform child = root.GetChild(i); + if (child == template) + { + continue; + } + + Destroy(child.gameObject); + } } private void OnSkillButtonClick() { - Log.Info("BattleForm placeholder: skill clicked."); + // M3 接入 } private void OnItemButtonClick() { - Log.Info("BattleForm placeholder: item clicked."); + // 道具首版禁用,不产生指令 } private void OnEscapeButtonClick() { - Log.Info("BattleForm placeholder: escape clicked."); + // M5 接入 } } } \ No newline at end of file diff --git a/Assets/GameMain/Scripts/UI/Battle/BattleTurnSlotItem.cs b/Assets/GameMain/Scripts/UI/Battle/BattleTurnSlotItem.cs index 06483e6..35bc299 100644 --- a/Assets/GameMain/Scripts/UI/Battle/BattleTurnSlotItem.cs +++ b/Assets/GameMain/Scripts/UI/Battle/BattleTurnSlotItem.cs @@ -1,3 +1,7 @@ +using Cysharp.Threading.Tasks; +using SepCore.AsyncTask; +using SepCore.Battle; +using SepCore.Definition; using TMPro; using UnityEngine; using UnityEngine.UI; @@ -11,17 +15,62 @@ namespace SepCore.UI [SerializeField] private Image icon; [SerializeField] private TextMeshProUGUI label; - public GameObject ActiveMarker => activeMarker; - public Image Icon => icon; - public TextMeshProUGUI Label => label; + private int _iconVersion; -#if UNITY_EDITOR - public void ConfigureEditor(GameObject marker, Image iconImage, TextMeshProUGUI slotLabel) + /// + /// 填充一个回合顺序槽:单位显示名、配置图标与当前行动者标记。 + /// + public void SetTurnSlot(BattleUnitView unit, bool isCurrentActor) { - activeMarker = marker; - icon = iconImage; - label = slotLabel; + if (unit == null) + { + return; + } + + if (label != null) + { + label.text = BattleUnitViewHelper.GetDisplayName(unit); + } + + SetCurrentActor(isCurrentActor); + + _iconVersion++; + LoadIconAsync(unit, _iconVersion).Forget(); + } + + /// + /// 只更新当前行动者标记,不改变显示名。 + /// 同轮内单位行动后由 Logic 逐槽移动高亮。 + /// + public void SetCurrentActor(bool isCurrentActor) + { + if (activeMarker != null) + { + activeMarker.SetActive(isCurrentActor); + } + } + + /// + /// 异步加载单位图标(玩家/敌人分别走各自配置);iconVersion 防止旧加载覆盖新内容。 + /// + private async UniTaskVoid LoadIconAsync(BattleUnitView unit, int iconVersion) + { + SpriteConfig iconConfig = unit.Faction == BattleFaction.Player + ? BattleUnitViewHelper.GetPlayerIconConfig(unit.ConfigId) + : BattleUnitViewHelper.GetEnemyIconConfig(unit.ConfigId); + if (iconConfig == null || icon == null) + { + return; + } + + Sprite sprite = await SpriteLoader.LoadSpriteAsync(iconConfig); + if (sprite == null || _iconVersion != iconVersion) + { + return; + } + + icon.sprite = sprite; + icon.gameObject.SetActive(true); } -#endif } -} +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/UI/Battle/BattleUnitViewHelper.cs b/Assets/GameMain/Scripts/UI/Battle/BattleUnitViewHelper.cs new file mode 100644 index 0000000..52eca5d --- /dev/null +++ b/Assets/GameMain/Scripts/UI/Battle/BattleUnitViewHelper.cs @@ -0,0 +1,52 @@ +using SepCore.Battle; +using SepCore.Definition; + +namespace SepCore.UI +{ + /// + /// 战斗单位视图的显示辅助:名称与图标解析。 + /// 玩家图标来自 CharacterConfig.Icon_Ref,敌人图标来自 EnemyConfig.Icon_Ref。 + /// + public static class BattleUnitViewHelper + { + /// + /// 获取单位显示名;配置缺失时回退为配置 ID。 + /// + public static string GetDisplayName(BattleUnitView unit) + { + if (unit == null) + { + return string.Empty; + } + + if (unit.Faction == BattleFaction.Player) + { + CharacterConfig config = GameEntry.Luban.Get(unit.ConfigId); + return config != null ? config.Name : unit.ConfigId.ToString(); + } + else + { + EnemyConfig config = GameEntry.Luban.Get(unit.ConfigId); + return config != null ? config.Name : unit.ConfigId.ToString(); + } + } + + /// + /// 获取玩家单位图标配置;配置缺失时返回 null。 + /// + public static SpriteConfig GetPlayerIconConfig(int characterId) + { + CharacterConfig config = GameEntry.Luban.Get(characterId); + return config != null ? config.Icon_Ref : null; + } + + /// + /// 获取敌人单位图标配置;配置缺失时返回 null。 + /// + public static SpriteConfig GetEnemyIconConfig(int enemyId) + { + EnemyConfig config = GameEntry.Luban.Get(enemyId); + return config != null ? config.Icon_Ref : null; + } + } +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/UI/Battle/BattleUnitViewHelper.cs.meta b/Assets/GameMain/Scripts/UI/Battle/BattleUnitViewHelper.cs.meta new file mode 100644 index 0000000..5bb69c2 --- /dev/null +++ b/Assets/GameMain/Scripts/UI/Battle/BattleUnitViewHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: baad0253f7a15458f818a57c66db4939 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/UI/Battle/BattleView.cs b/Assets/GameMain/Scripts/UI/Battle/BattleView.cs index 72cb2ca..7193b9a 100644 --- a/Assets/GameMain/Scripts/UI/Battle/BattleView.cs +++ b/Assets/GameMain/Scripts/UI/Battle/BattleView.cs @@ -12,7 +12,7 @@ namespace SepCore.UI [SerializeField] public global::SepCore.UI.BattleActorCardItem playerCard2; [SerializeField] public global::SepCore.UI.BattleActorCardItem playerCard3; [SerializeField] public global::SepCore.UI.BattleActorCardItem playerCard4; - [SerializeField] public global::UnityEngine.UI.HorizontalLayoutGroup enemySlotsRoot; + [SerializeField] public global::UnityEngine.RectTransform enemySlotsRoot; [SerializeField] public global::SepCore.UI.BattleEnemySlotItem battleEnemySlotTemplate; [SerializeField] public global::TMPro.TextMeshProUGUI currentActorText; [SerializeField] public global::UnityEngine.UI.Button attackButton; diff --git a/Assets/GameMain/UI/UIForms/BattleForm.prefab b/Assets/GameMain/UI/UIForms/BattleForm.prefab index d08a97b..92130d2 100644 --- a/Assets/GameMain/UI/UIForms/BattleForm.prefab +++ b/Assets/GameMain/UI/UIForms/BattleForm.prefab @@ -267,7 +267,7 @@ MonoBehaviour: playerCard2: {fileID: 17428003479946422} playerCard3: {fileID: 7735077048895435792} playerCard4: {fileID: 20465036694219600} - enemySlotsRoot: {fileID: 2896567169176617828} + enemySlotsRoot: {fileID: 2225454379744901781} battleEnemySlotTemplate: {fileID: 9135779921912607044} currentActorText: {fileID: 3734726615128117434} attackButton: {fileID: 4646156403976603773} @@ -1900,11 +1900,11 @@ MonoBehaviour: m_EditorClassIdentifier: componentReferences: - component: {fileID: 2225454379744901781} - generateReference: 0 - variableName: enemyRootRectTransform - - component: {fileID: 2896567169176617828} generateReference: 1 variableName: enemySlotsRoot + - component: {fileID: 2896567169176617828} + generateReference: 0 + variableName: enemySlotsRoot --- !u!1 &2613939670594043691 GameObject: m_ObjectHideFlags: 0 diff --git a/Docs/Tech/04_TurnBasedCombatDevelopmentTODO.md b/Docs/Tech/04_TurnBasedCombatDevelopmentTODO.md index 14737f8..2751101 100644 --- a/Docs/Tech/04_TurnBasedCombatDevelopmentTODO.md +++ b/Docs/Tech/04_TurnBasedCombatDevelopmentTODO.md @@ -1,22 +1,23 @@ # 回合制战斗开发 TODO -> 状态:进行中(M0 组件已落地,其余里程碑未开始) +> 状态:进行中(M0 已完成,M1 起按本文的新架构收敛) > > 创建日期:2026-08-31 > +> 架构修订:2026-09-01 +> > 相关设计:`Docs/GameDesign/04_TurnBasedCombat.md`、`Docs/GameDesign/03_RunExploration.md`、`Docs/GameDesign/06_PrototypeScope.md` > -> 目标:在不新增 Battle 场景的前提下,实现覆盖在地图上的半透明多对多回合制战斗 UI,以及可独立测试的战斗规则内核。 +> 目标:在 Main 场景地图上完成半透明多对多回合制战斗,由 `TurnBattleComponent` 统一承接战斗启动、运行和结果回写,并保持规则内核可独立测试。 ## 一、完成纪律 -- 里程碑是否完成只看标题中的 `[ ]`;第八节的复选框单独追踪 22 条设计验收,不代替里程碑完成状态。 -- 代码写完、静态检查通过或 Unity 编译通过,都不能单独把里程碑改为 `[x]`。 +- 里程碑是否完成只看标题中的 `[ ]`;第八节的 22 条设计验收不代替里程碑完成状态。 - 只有完成该里程碑列出的 Unity 编译、EditMode 测试和 Play Mode 可见验收,并记录证据后,才能改为 `[x]`。 -- 每次只实现当前里程碑。本里程碑“明确不做”的内容不能因为顺手而提前并入。 +- 每次只实现当前里程碑;后续能力不得以“预留接口”为理由提前进入当前阶段。 - 生成代码不能手改。Luban 结构或数据需要调整时,修改源表后运行 `数据表/gen_cli.sh`。 -- 战斗内核不得直接读写 `SaveData`,不得直接操作地图敌人物体、掉落物或局外结算。 -- 本文档当前所有里程碑均为未完成;本文档落地不代表任何代码或运行时验收已经完成。 +- 战斗内核不直接读写 `SaveData`,不直接查找或操作地图敌人物体、掉落物和局外结算。 +- 静态检查、Unity 编译、EditMode 和 Play Mode 分别记录,不互相代替。 每个里程碑完成后按以下格式补证据: @@ -33,24 +34,22 @@ Play Mode:未验证 / 通过(操作步骤、可见结果、截图或录像 | 里程碑 | 可见结果 | 当前状态 | | --- | --- | --- | -| M0 接口与调试入口 | Main 场景可打开和关闭半透明战斗壳层,地图更新暂停 | 已完成 | -| M1 1v1 攻击闭环 | 玩家攻击、敌人反击并产生胜负 | 未开始 | -| M2 4v4 回合调度 | 多人队伍按实时速度和并列规则行动 | 未开始 | -| M3 技能、目标与效果 | 数值型攻击和技能可合法选目标并结算 HP/MP | 未开始 | -| M4 状态与速度变化 | 眩晕跳过下一次行动,速度变化立即刷新顺序 | 未开始 | -| M5 先制与确定性随机 | 先制首轮和普通次轮正确,同一随机输入可复现 | 未开始 | -| M6 单人逃跑与结束矩阵 | 成功、失败、全逃跑、部分逃跑后阵亡均可见 | 未开始 | -| M7 单局状态回写 | HP/MP、阵亡恢复和战斗状态清理正确回到临时单局状态 | 未开始 | -| M8 探索遭遇接入 | 碰撞进入战斗,胜利移除敌人,逃跑恢复敌人并获得保护 | 未开始 | -| M9 战斗 UI 完成 | 最小信息、输入限制和即时反馈完整 | 未开始 | -| M10 全量回归 | 战斗文档 22 条验收全部留有运行证据 | 未开始 | +| M0 战斗壳层与单局基础状态 | Main 场景可打开和关闭半透明战斗壳层,地图更新暂停 | 已完成 | +| M1 架构收敛与 1v1 攻击闭环 | 玩家攻击、敌人反击并产生胜负 | 已完成 | +| M2 4v4 调度、先制与确定性随机 | 多人队伍按实时速度行动,普通和先制顺序正确 | 未开始 | +| M3 技能、目标、MP 与数值效果 | 攻击和技能可合法选目标并结算 HP/MP | 未开始 | +| M4 状态、眩晕与速度变化 | 眩晕跳过行动,速度变化立即刷新顺序 | 未开始 | +| M5 逃跑、结束矩阵与单局回写 | 四种结果正确,连续战斗继承 HP/MP | 未开始 | +| M6 真实探索遭遇与地图恢复 | 碰撞进入战斗,胜利、逃跑、掉落和保护闭环 | 未开始 | +| M7 战斗 UI 与输入限制完成 | 1 至 4 人战斗信息和操作完整可用 | 未开始 | +| M8 全量回归与原型完成 | 22 条设计验收和最低测试矩阵全部留证 | 未开始 | ### 验证记录 | 里程碑 | 代码 | Unity 编译 | EditMode | Play Mode | 证据 | | --- | --- | --- | --- | --- | --- | | M0 | 已完成 | 通过(2026-08-31,dotnet build SepCore.Runtime.csproj 0 错误;Unity 编辑器内 Play Mode 实测编译通过) | 不适用(M0 无 EditMode 用例) | 通过(2026-08-31,见 M0 证据) | 已记录(M0 小节) | -| M1 | 未开始 | 未验证 | 未验证 | 未验证 | 未记录 | +| M1 | 已完成 | 通过(2026-09-02,dotnet build SepCore.Runtime.csproj 0 错误 0 警告;Unity 编辑器内编译通过) | 通过(2026-09-02,25 个 BattleRuntime 纯逻辑用例全部通过) | 通过(2026-09-02,见 M1 证据) | 已记录(M1 小节) | | M2 | 未开始 | 未验证 | 未验证 | 未验证 | 未记录 | | M3 | 未开始 | 未验证 | 未验证 | 未验证 | 未记录 | | M4 | 未开始 | 未验证 | 未验证 | 未验证 | 未记录 | @@ -58,19 +57,16 @@ Play Mode:未验证 / 通过(操作步骤、可见结果、截图或录像 | M6 | 未开始 | 未验证 | 未验证 | 未验证 | 未记录 | | M7 | 未开始 | 未验证 | 未验证 | 未验证 | 未记录 | | M8 | 未开始 | 未验证 | 未验证 | 未验证 | 未记录 | -| M9 | 未开始 | 未验证 | 未验证 | 未验证 | 未记录 | -| M10 | 未开始 | 未验证 | 未验证 | 未验证 | 未记录 | -## 三、已确认的范围与约定 - -以下决定于 2026-08-31 确认,后续实现不得自行改变: +## 三、已确认的范围与规则 | 项目 | 约定 | | --- | --- | -| 单局基础 | 本 TODO 包含最小单局临时状态及其接口,不包含完整地图生成、搜索和警惕 AI 的实现 | -| 战斗承载 | 不新增 Battle 场景;在 Main 场景地图上覆盖半透明的顶级 `BattleForm` | -| 地图暂停 | 战斗期间暂停地图角色、敌人 AI、碰撞、搜索和单局计时的更新,战斗 UI 继续响应 | -| 模块边界 | 战斗接收值快照和本局随机接口,返回结果;不直接读写存档、地图敌人或掉落 | +| 战斗承载 | 不新增 Battle 场景;在 Main 场景地图上覆盖半透明顶级 `BattleForm` | +| 地图暂停 | 战斗期间暂停地图角色、敌人 AI、碰撞、搜索和单局计时,战斗 UI 继续响应 | +| 模块边界 | 外界通过 `TurnBattleComponent` 启动战斗、提交操作和接收结果;战斗内部不访问场景对象和存档 | +| 内部协作 | 调度、指令校验、效果、状态和敌人决策共享当前 `BattleRuntime`,不为内部函数调用建立请求/响应 DTO | +| 随机数 | 地图生成、敌人决策、逃跑和掉落依次消费同一个本局随机源,战斗不重新播种 | | 眩晕 | `DurationRounds=N` 影响目标未来 N 次行动机会;`Stun(1)` 跳过下一次行动后失效 | | 状态重复施加 | 同类状态不叠层,保留较长的剩余持续次数 | | 友方目标 | `SingleAlly` 包含施法者;`Self` 只能选择施法者自己 | @@ -79,114 +75,221 @@ Play Mode:未验证 / 通过(操作步骤、可见结果、截图或录像 | 操作方式 | 首版使用鼠标点击行动和目标 | | 行动顺序 UI | 显示当前行动者和本轮剩余行动者;速度变化后立即刷新,不预测下一轮 | -## 四、模块所有权 +## 四、架构与数据所有权 -### 4.1 目录与程序集 +### 4.1 唯一外部入口 + +`TurnBattleComponent` 是战斗模块的门面和生命周期所有者。外部模块不直接创建或持有 `BattleRuntime`、`BattleUnit`、调度器和效果结算器。 + +它负责: + +- 持有单局临时玩家状态、计时、探索暂停和战斗占用。 +- 使用 `BattleEncounter`、单局玩家状态、配置和本局随机源创建当前战斗。 +- 接收 UI 的 `BattleCommand` 并同步推进战斗。 +- 向 UI 提供当前只读视图和本次行动记录。 +- 战斗结束时把玩家 HP/MP 写回单局临时状态。 +- 完成单局状态回写后,通过开战方提供的一次性完成回调返回 `BattleResult`;探索层据此处理敌人实例、掉落、保护和单局失败。 +- 关闭战斗时清理当前战斗数据并恢复允许恢复的地图更新。 + +对外最小调用面: + +```csharp +bool TryStartBattle(BattleEncounter encounter, Action onCompleted); +BattleStep SubmitCommand(BattleCommand command); +BattleViewState GetViewState(); +bool IsBattleActive { get; } +``` + +调试入口可以不提供完成回调。具体 C# 返回形式可以随实现调整,但完成通知必须由 Component 统一发出,且不得把内部可变对象暴露给 UI 或探索层。 + +### 4.2 内部共享运行时 + +`TurnBattleComponent` 持有唯一的当前战斗: + +```text +TurnBattleComponent +├─ 单局状态 +│ ├─ RunPlayerState[] +│ ├─ RunElapsedMs +│ ├─ pause state +│ └─ IRunRandomSource +│ +└─ BattleRuntime(仅战斗期间存在) + ├─ BattleUnit[] + ├─ RoundNumber + ├─ CurrentActorUnitId + ├─ ActedUnitIds + ├─ IsPreemptive + ├─ PendingEvents + └─ BattleResult +``` + +内部规则可以由多个普通 C# 类协作完成,例如: + +- `TurnScheduler`:选择下一行动者、开始下一轮。 +- `CommandExecutor`:校验并执行玩家或敌人指令。 +- `EffectResolver`:按顺序结算数值和状态效果。 +- `EnemyDecision`:从当前可用行动和合法目标中选择指令。 + +这些名称不是必须提前建立的类型。只有单个类已经承担独立规则且需要独立测试时才提取;简单逻辑保留在 `BattleRuntime` 内。 + +内部协作规则: + +- 所有协作者操作同一个 `BattleRuntime`,不复制单位、轮次和状态集合。 +- 一次 `StartBattle` 或 `SubmitCommand` 在主线程同步完成状态推进,不能留下多个并行写入者。 +- `BattleRuntime` 是当前战斗事实的唯一来源;UI 视图和行动记录从它生成,不反向驱动规则。 +- 战斗内部不使用全局事件总线传递调度、效果和结束状态。 +- 内部类型优先使用 `internal`;只有外部真实消费的契约才使用 `public`。 + +### 4.3 必要的外部数据 + +| 数据 | 方向 | 作用 | +| --- | --- | --- | +| `BattleEncounter` | 探索层 -> Component | 指定触发敌人实例、敌人队伍预设和是否先制 | +| `BattleCommand` | UI -> Component | 当前玩家的攻击、技能、目标或逃跑选择 | +| `BattleViewState` | Component -> UI | 当前单位数值、行动者、剩余顺序和可用行动 | +| `BattleStep` | Component -> UI | 本次同步推进产生的行动记录、最新视图和可选最终结果 | +| `BattleResult` | Component -> 单局/探索 | 最终 Outcome 和玩家战后 HP/MP | +| `IRunRandomSource` | 单局系统 -> Component | 共享随机序列和 EditMode 可注入随机源 | +| `IBattleConfigProvider` | 配置系统 -> Component | 隔离 Luban 与纯逻辑测试 | + +不建立以下中间结构: + +- 不建立 Component 到内部 Runtime 的 `BattleStartRequest`;Component 直接使用 Encounter 和单局状态初始化 Runtime。 +- 不建立 `BattleController`;Component 是对外控制入口,Runtime 是内部状态和规则核心。 +- 不建立 `BattleReturnPlan`;Component 根据 `BattleResult` 回写单局状态,探索层根据 Outcome 执行地图后果。 +- 不建立 `IExplorationBattleBridge`;真实探索接入使用现有组件的直接调用或一个明确回调。 +- 首版只有随机敌人行为,不建立 `IEnemyBattlePolicy`;出现第二种可替换策略时再提取。 +- 不在 `BattleStep` 和 `BattleViewState` 中重复保存流程状态;`Result != null` 表示战斗完成。 + +### 4.4 必要的内部数据 + +| 数据 | 生命周期 | 说明 | +| --- | --- | --- | +| `RunPlayerState` | 整个单局 | 跨战斗保留玩家当前 HP/MP 和已结算战斗属性 | +| `BattleRuntime` | 单场战斗 | 单位、轮次、行动机会、事件和最终结果的唯一所有者 | +| `BattleUnit` | 单场战斗 | 运行时 ID、阵营、属性、行动、状态、阵亡和逃跑 | +| `BattleStatus` | 单场战斗 | 状态类型和剩余行动机会次数 | +| `BattleEvent` | 单次推进 | UI 需要展示的行动者、目标和数值/状态变化 | + +`BattleViewState` 可以包含单位视图,但内部调度和效果结算不得读取该视图。8 个单位规模下,在外部边界生成只读视图的成本可接受。 + +### 4.5 目录与程序集 | 位置 | 所有权 | | --- | --- | -| `Assets/GameMain/Scripts/Runtime/Battle/` | 战斗接口与 DTO、纯战斗模型、调度、指令校验、效果结算、敌人策略和 `BattleController` | -| `Assets/GameMain/Scripts/Runtime/CustomComponent/Run/` | 最小单局临时状态、探索暂停和战斗协调 | -| `Assets/GameMain/Scripts/Runtime/CustomComponent/Random/` | 本局种子与共享随机源(`RandomComponent`、`IRunRandomSource`、`RunRandomSource`) | -| `Assets/GameMain/Scripts/UI/Battle/` | `BattleForm`、子 Form/View 和手写 `.Logic.cs` 表现逻辑 | -| `Assets/GameMain/Scripts/Base/Event/` | 只有确实需要跨模块广播的 UGF 事件;战斗内部通信不走全局事件总线 | -| `Assets/GameMain/Tests/EditMode/Battle/` | 战斗规则的纯逻辑 EditMode 测试及测试程序集 | +| `Assets/GameMain/Scripts/Runtime/Battle/` | `BattleRuntime`、内部单位和规则、必要的外部数据与配置接口 | +| `Assets/GameMain/Scripts/Runtime/CustomComponent/Run/` | `TurnBattleComponent`、`RunPlayerState` 和单局回写 | +| `Assets/GameMain/Scripts/Runtime/CustomComponent/Random/` | 本局种子与共享随机源 | +| `Assets/GameMain/Scripts/UI/Battle/` | `BattleForm` 和子 View 的表现逻辑 | +| `Assets/GameMain/Tests/EditMode/Battle/` | 直接针对 BattleRuntime 和规则协作者的纯逻辑测试 | -不新增战斗程序集。接口、DTO 和内核均放入现有 `SepCore.Runtime`;`SepCore.Presentation` 已经单向引用 Runtime,Runtime 不得反向引用 Presentation。 +不新增战斗运行时程序集。Runtime 不反向引用 Presentation;`BattleForm` 通过 `GameEntry.TurnBattle` 调用公开战斗入口。EditMode 测试建立独立测试程序集,并通过 `InternalsVisibleTo` 访问内部战斗规则,不为测试把 BattleRuntime 改成公开 API。 -### 4.2 责任划分 - -| 模块 | 负责 | 不负责 | -| --- | --- | --- | -| `RandomComponent` | 本局 seed、共享随机源;地图生成、敌人 AI、逃跑和掉落共用 | 战斗行动规则、地图对象、存档写盘 | -| `TurnBattleComponent` | 临时角色状态、计时、战斗占用、探索暂停标志 | 战斗行动规则、战斗 UI、存档写盘 | -| `RunBattleCoordinator` | 从单局状态构建请求、打开 UI、提交结果、生成地图恢复计划 | 回合排序、效果计算、UI 控件状态 | -| `BattleSession` | 单位、轮次、行动机会、效果、状态、敌人 AI、逃跑和结束判定 | 地图对象、SaveData、UI GameObject | -| `BattleController` | 暴露只读快照、接收玩家指令、推进自动行动、产出行动记录和最终结果 | 直接渲染 UI、直接控制地图 | -| `BattleForm` | 展示快照、收集玩家指令、即时播放行动记录、展示结束结果 | 修改战斗数据、读取存档、决定掉落 | -| 探索适配器 | 碰撞发起遭遇、冻结/恢复地图、敌人移除或复位、掉落、2 秒保护 | 战斗内部规则 | - -### 4.3 调用链 +### 4.6 完整调用链 ```text 队长与地图敌人碰撞 -> 探索层创建 BattleEncounter - -> RunBattleCoordinator.TryStartBattle - -> TurnBattleComponent 预留战斗占用,拒绝重复遭遇 - -> RunBattleCoordinator 构建 BattleStartRequest - -> 创建并校验 BattleController;失败则释放占用且不暂停地图 - -> 校验成功后提交战斗占用并暂停探索更新 - -> 打开 BattleForm,并调用 BattleController.Start - -> BattleForm 展示 BattleSnapshot - -> 玩家提交 BattleCommand / 敌人策略自动提交行动 - -> BattleController 返回 BattleAdvance - -> BattleForm 即时展示 BattleActionRecord - -> BattleSession 产生 BattleResult - -> RunBattleCoordinator.ApplyResult - -> 更新单局临时角色状态并生成 BattleReturnPlan - -> 探索层执行敌人、掉落、保护或单局失败处理 - -> 关闭 BattleForm,并在允许继续单局时恢复探索更新 + -> TurnBattleComponent.TryStartBattle + -> 拒绝重复战斗并校验 Encounter、玩家、配置和随机源 + -> 创建唯一 BattleRuntime + -> 提交战斗占用,暂停探索更新与计时 + -> 打开 BattleForm + -> BattleRuntime 推进到等待玩家或战斗结束 + -> BattleForm 读取 BattleViewState + -> 玩家提交 BattleCommand + -> TurnBattleComponent 同步校验并推进 BattleRuntime + -> 内部直接完成调度、效果、敌人自动行动和结束判定 + -> 返回 BattleStep,UI 展示 BattleEvent 和最新视图 + -> BattleResult 产生后,TurnBattleComponent 回写 RunPlayerState + -> TurnBattleComponent 调用本场战斗的一次性完成回调 + -> 探索层按 BattleResult 的 EncounterId 和 Outcome 处理敌人、掉落、保护或失败 + -> 清理 BattleRuntime,关闭 BattleForm + -> 允许继续单局时恢复探索更新与计时 ``` -## 五、接口契约 +## 五、边界契约 -本节固定接口语义。实现时允许按 C# 规范调整只读集合或构造函数形式,但不得改变所有权和数据流。 - -### 5.1 身份规则 +### 5.1 身份 | 标识 | 用途 | | --- | --- | -| `EncounterId` | 地图敌人实例的单局唯一标识;不能使用敌人配置 ID 代替 | -| `BattleUnitId` | 本场战斗单位的唯一运行时标识;重复配置的敌人必须拥有不同 ID | -| `CharacterId` | 玩家角色配置标识;同时用于把结果回写到单局角色状态 | -| `EnemyConfigId` | 敌人种类配置标识;同一敌人队伍内可以重复 | -| `PartyOrder` | 玩家取战备顺序,敌人取 `EnemyPartyConfig.EnemyIds` 顺序;作为同阵营同速度的最终并列规则 | +| `EncounterId` | 地图敌人实例的单局唯一标识;不能用敌人配置 ID 代替 | +| `BattleUnitId` | 本场战斗单位的唯一运行时标识;重复敌人配置必须拥有不同 ID | +| `CharacterId` | 玩家角色配置标识,也是战斗结果回写键 | +| `EnemyConfigId` | 敌人种类配置标识,同一敌人队伍内可以重复 | +| `PartyOrder` | 同阵营同速度时的最终并列顺序 | -所有 UI 指令和目标选择都使用 `BattleUnitId`,不能使用可能重复的 `EnemyConfigId`。 +所有 UI 指令和目标选择都使用 `BattleUnitId`。 ### 5.2 遭遇输入 -`BattleEncounter` 由探索层创建: +`BattleEncounter` 只包含: | 字段 | 说明 | | --- | --- | | `EncounterId` | 触发碰撞的地图敌人实例 | -| `EnemyPartyConfigId` | 该地图敌人代表的敌人队伍预设 | -| `IsPreemptive` | 碰撞时敌人警惕值未满为 true,否则为 false | +| `EnemyPartyConfigId` | 该实例代表的敌人队伍预设 | +| `IsPreemptive` | 碰撞时敌人警惕值未满为 true | -地图坐标、碰撞器、GameObject 和警惕组件不进入战斗请求,由探索层继续持有。 +地图坐标、碰撞器、GameObject 和警惕组件由探索层持有,不进入战斗。 -### 5.3 玩家输入快照 +### 5.3 单局玩家状态 -`BattlePlayerInput` 是进入本场战斗时的值快照: +`RunPlayerState` 保存 `CharacterId`、`PartyOrder`、当前及上限 HP/MP、最终 ATK/MAT/Speed、普通攻击 ID 和技能 ID。 + +`TurnBattleComponent` 开战时直接从这些状态创建玩家 `BattleUnit`。不再建立字段相同的 `BattlePlayerInput`。战斗只修改 `BattleUnit`,结束时统一回写当前 HP/MP。 + +### 5.4 玩家指令 + +`BattleCommand` 至少包含: | 字段 | 说明 | | --- | --- | -| `CharacterId` | 角色配置 ID | -| `PartyOrder` | 当前战备中的顺序 | -| `CurrentHp` / `CurrentMp` | 单局临时状态中的当前值 | -| `MaxHp` / `MaxMp` | 已结算装备加成的最终上限 | -| `Atk` / `Mat` / `Speed` | 已结算装备加成的最终战斗属性 | -| `AttackActionId` | 普通攻击行动配置 ID | -| `SkillActionId` | 该角色首版唯一技能行动配置 ID | +| `ActorUnitId` | 当前获得行动机会的玩家单位 | +| `CommandType` | `Attack`、`Skill`、`Item` 或 `Escape` | +| `ActionConfigId` | 攻击或技能配置 ID;逃跑为 0 | +| `TargetUnitIds` | 运行时目标 ID 列表 | -装备计算由单局角色快照构建器完成。`BattleSession` 不读取 `CharacterSave`、装备栏或背包。 +- `Item` 按钮可见但首版禁用,不产生指令。 +- 只有当前玩家单位能提交指令。 +- 全体目标由战斗内核展开,UI 不自行拼装。 +- 非法指令不消耗 MP、行动机会或随机数。 -### 5.4 战斗启动请求 +### 5.5 UI 视图与行动记录 -`BattleStartRequest` 至少包含: +`BattleViewState` 是 UI 唯一读取面,至少包含: -| 字段 | 说明 | -| --- | --- | -| `EncounterId` | 用于将结果关联回地图敌人实例 | -| `Players` | 1 到 4 个 `BattlePlayerInput`,保持战备顺序 | -| `EnemyPartyConfigId` | 用于按队伍预设创建 1 到 4 个敌人运行时单位 | -| `IsPreemptive` | 是否启用第一轮玩家先制 | -| `Random` | 当前单局唯一的 `IRunRandomSource` 实例,来自 `RandomComponent`,不创建战斗私有随机源 | +- 当前轮次和当前行动者。 +- 全部单位的 ID、阵营、配置 ID、显示顺序、HP/MP、速度、阵亡、逃跑和状态。 +- 本轮从当前行动者开始的剩余顺序。 +- 当前玩家可用的行动 ID。 -请求在创建后视为只读。战斗开始失败时不得消耗随机数、修改单局状态或暂停状态计数。 +`BattleStep` 至少包含: -### 5.5 随机数接口 +- 本次推进产生的有序 `BattleEvent`。 +- 推进结束后的 `BattleViewState`。 +- 仅在战斗结束时存在的 `BattleResult`。 + +`BattleEvent` 使用一层记录表达行动者、行动、目标以及数值或状态变化。只有出现无法由一层记录清晰表达的真实表现需求时,才增加子记录类型。 + +### 5.6 战斗结果 + +`BattleOutcome` 固定为: + +| 结果 | 条件 | 单局是否继续 | +| --- | --- | --- | +| `Victory` | 所有敌人阵亡 | 是 | +| `AllEscaped` | 所有玩家成功逃跑且没有玩家阵亡 | 是 | +| `PartialEscapeDefeat` | 至少一人逃跑,其余仍在战斗中的玩家全部阵亡 | 是 | +| `TotalDefeat` | 所有玩家阵亡且无人逃跑 | 否 | + +`BattleResult` 包含 `EncounterId`、`Outcome` 和每名玩家的 `CharacterId`、战后 HP/MP、是否阵亡、是否逃跑。 + +战斗结果保留原始值,阵亡者 HP 为 0。`TurnBattleComponent` 在非 `TotalDefeat` 结果中统一应用阵亡角色恢复 1 HP/1 MP 的规则。 + +### 5.7 随机和配置依赖 ```csharp public interface IRunRandomSource @@ -196,212 +299,95 @@ public interface IRunRandomSource } ``` -- 本局种子由局外(战备界面玩家输入)带入,`RandomComponent.BeginRun(seed)` 初始化共享随机源。 -- 同一单局的地图生成、敌人 AI、逃跑和敌人掉落依次消费同一个实例;`RandomComponent` 是唯一持有者。 -- 战斗不能根据 EncounterId、回合数或当前时间重新播种。 +- 本局种子由局外带入,`RandomComponent.BeginRun(seed)` 初始化共享随机源。 +- 地图生成、敌人决策、逃跑和掉落依次消费同一个实例。 +- 战斗不能按 EncounterId、回合数或时间重新播种。 - `RollPermille(0)` 必须失败,`RollPermille(1000)` 必须成功。 -- EditMode 测试使用可注入的序列随机源,不依赖 Unity 全局随机状态。 +- EditMode 使用可注入序列随机源。 -### 5.6 配置访问接口 +`IBattleConfigProvider` 隔离战斗规则与 `GameEntry.Luban`。配置缺失导致启动失败;正常行动不重复扫描全表完整性。 -`IBattleConfigProvider` 隔离 `BattleSession` 与 `GameEntry.Luban`。运行时适配器读取现有 Luban 表,测试使用内存配置。 +### 5.8 探索暂停与返回 -```csharp -public interface IBattleConfigProvider -{ - EnemyPartyConfig GetEnemyParty(int id); - EnemyConfig GetEnemy(int id); - BattleActionConfig GetAction(int id); - GlobalConfig GetGlobal(); -} -``` +- `TurnBattleComponent.IsExplorationPaused` 是地图更新统一门禁。 +- 玩家移动、队员跟随、敌人巡逻/警惕/追击、搜索、地图碰撞和单局计时遵守该门禁。 +- 战斗 UI 和使用 unscaled time 的 UI 反馈不受该门禁影响。 +- 不以 `Time.timeScale = 0` 作为唯一暂停机制。 +- 开战校验全部通过后才能提交暂停;启动失败不消耗随机数、不修改单局状态。 +- `Victory` 返回 EncounterId 并要求探索层移除触发敌人和结算掉落。 +- `AllEscaped`、`PartialEscapeDefeat` 返回 EncounterId 并要求探索层恢复触发敌人和开启保护。 +- `TotalDefeat` 进入单局失败,不短暂恢复探索。 +- 探索层根据 Outcome 执行固定后果,不重新根据玩家 HP 或逃跑数量判定 Outcome。 -配置缺失属于战斗启动失败,不允许用零值单位继续战斗。普通消费路径信任已经导出的合法配表,不在每次行动中重复做全表完整性扫描。 - -### 5.7 玩家指令 - -`BattleCommand` 至少包含: - -| 字段 | 说明 | -| --- | --- | -| `ActorUnitId` | 当前获得行动机会的玩家单位 | -| `CommandType` | `Attack`、`Skill`、`Item` 或 `Escape`;复用配表 `BattleActionType` 枚举,不单独定义 | -| `ActionConfigId` | 攻击或技能对应的配置 ID;逃跑为 0 | -| `TargetUnitIds` | 按目标类型解析后的唯一运行时目标列表 | - -- `Item` 按钮可见但首版禁用,不产生 `BattleCommand`。 -- 只有 `BattleSnapshot` 指示的当前玩家单位能提交指令。 -- MP 不足时技能按钮禁用;控制器仍需拒绝伪造的非法指令。 -- `SingleAlly` 包含自己;`Self` 必须且只能以自己为目标。 -- `AllAllies` 包含自己;全体目标由控制器根据当前合法单位展开,UI 不自行拼装。 -- 阵亡、逃跑和敌对阵营错误的目标均非法。 -- 非法指令不得消耗 MP、行动机会或随机数。 - -### 5.8 只读战斗快照 - -`BattleSnapshot` 是 UI 和敌人策略唯一允许读取的战斗状态。每次推进都创建逻辑上的只读快照,不能把可修改的内部列表或 `BattleUnit` 引用暴露给调用方。 - -| 字段 | 说明 | -| --- | --- | -| `RoundNumber` | 当前轮次,从 1 开始 | -| `IsPreemptiveRound` | 当前是否为先制战斗第一轮 | -| `CurrentActorUnitId` | 当前行动者;战斗完成时为空 | -| `Units` | 全部单位的只读视图,保持稳定显示顺序 | -| `RemainingTurnOrder` | 本轮从当前行动者开始的剩余运行时单位 ID;速度变化后重建 | -| `FlowState` | `AwaitingPlayerCommand` 或 `Completed` | - -每个单位视图至少包含 `BattleUnitId`、阵营、配置 ID、`PartyOrder`、当前及上限 HP/MP、ATK、MAT、Speed、是否阵亡、是否逃跑、剩余状态和可用行动 ID。UI 不根据这些字段自行推演下一轮或判定胜负。 - -### 5.9 战斗推进返回值 - -`BattleController.Start()` 和 `SubmitCommand()` 返回 `BattleAdvance`: - -| 字段 | 说明 | -| --- | --- | -| `Records` | 本次推进产生的有序 `BattleActionRecord` 列表 | -| `Snapshot` | 推进结束后的只读战斗快照 | -| `FlowState` | `AwaitingPlayerCommand` 或 `Completed` | -| `Result` | 仅在 Completed 时存在 | - -`BattleActionRecord` 记录行动者、行动、目标,以及每个效果前后的数值或状态变化。首版 UI 立即消费并显示;未来动画系统可以逐条等待播放,不需要修改战斗规则内核。 - -### 5.10 敌人策略接口 - -```csharp -public interface IEnemyBattlePolicy -{ - BattleCommand Decide( - BattleSnapshot snapshot, - int actorUnitId, - IRunRandomSource random); -} -``` - -- `EnemyAiType.Random` 对当前可用行动做等概率选择,再对合法的单体目标做等概率选择。 -- MP 不足的行动不进入候选集。 -- 全体目标不额外随机目标。 -- 当前没有可执行行动时,本次行动机会记为跳过,不能形成无限循环。 -- 策略只读取快照并返回指令,不直接修改 `BattleSession`。 - -### 5.11 战斗结果 - -`BattleOutcome` 固定为以下四种: - -| 结果 | 条件 | 单局是否继续 | -| --- | --- | --- | -| `Victory` | 所有敌人阵亡 | 是 | -| `AllEscaped` | 所有玩家成功逃跑,且没有玩家阵亡 | 是 | -| `PartialEscapeDefeat` | 至少一名玩家逃跑,其余仍在战斗中的玩家全部阵亡 | 是 | -| `TotalDefeat` | 所有玩家阵亡,没有任何玩家成功逃跑 | 否 | - -`BattleResult` 至少包含: - -| 字段 | 说明 | -| --- | --- | -| `EncounterId` | 关联原地图敌人实例 | -| `Outcome` | 上述四种战斗结果之一 | -| `Players` | 每名参战玩家的原始战后结果 | - -每个玩家结果包含 `CharacterId`、`CurrentHp`、`CurrentMp`、`WasDefeated`、`Escaped`。战斗结果保留原始战斗值:阵亡者 HP 为 0;恢复到 1 HP/1 MP 的规则由 `RunBattleCoordinator` 在非全员阵亡结果中统一应用。 - -### 5.12 探索恢复计划 - -`RunBattleCoordinator` 根据 `BattleResult` 生成 `BattleReturnPlan`,探索层只执行计划: - -`BattleReturnPlan` 至少包含 `EncounterId`、`Outcome`、回写后的玩家临时状态、`RemoveEncounterEnemy`、`ResetEncounterEnemy`、`ShouldRollDrops`、`ProtectionMs`、`ShouldResumeExploration` 和 `EndsRunAsDefeated`。这些布尔值由协调器按下表统一产生,探索层不得再次根据玩家 HP 或逃跑数量重判战斗结果。 - -| 战斗结果 | 玩家状态 | 触发敌人 | 掉落 | 保护时间 | 单局计时 | -| --- | --- | --- | --- | --- | --- | -| `Victory` | 存活者保留 HP/MP,阵亡者恢复配置的 1/1,清除战斗状态 | 移除 | 使用本局随机源结算 | 无 | 恢复 | -| `AllEscaped` | 逃跑时 HP/MP 保留,清除战斗状态 | 保留并恢复地图行为,下次按完整预设重建 | 无 | 配置的 2 秒 | 恢复 | -| `PartialEscapeDefeat` | 逃跑者保留 HP/MP,阵亡者恢复配置的 1/1,清除状态 | 保留并恢复地图行为,下次按完整预设重建 | 无 | 配置的 2 秒 | 恢复 | -| `TotalDefeat` | 不返回探索 | 保持到单局失败清理 | 无 | 无 | 不恢复,进入失败结算 | - -其他地图敌人在战斗期间被冻结;战斗结束后恢复战前的警惕和追击状态,不重新初始化。 - -探索层通过以下最小接口接收恢复计划: - -```csharp -public interface IExplorationBattleBridge -{ - void ApplyBattleReturn(BattleReturnPlan plan); -} -``` - -碰撞方主动把 `BattleEncounter` 交给 `RunBattleCoordinator.TryStartBattle`;协调器不扫描场景寻找敌人。`ApplyBattleReturn` 必须按 `EncounterId` 精确操作触发敌人,不能按 `EnemyConfigId` 批量处理同类敌人。 - -### 5.13 探索暂停接口 - -- `TurnBattleComponent.IsExplorationPaused` 是地图更新的统一门禁。 -- 玩家移动、队员跟随、敌人巡逻/警惕/追击、搜索进度、地图碰撞触发和单局计时都必须遵守该门禁。 -- 战斗 UI、战斗内核和使用 unscaled time 的 UI 反馈不受该门禁影响。 -- 首版不使用切换场景实现暂停。 -- 不把 `Time.timeScale = 0` 作为战斗模块的唯一暂停机制,避免 UI 和后续表现链被一同停止。 -- 战斗重复进入由 `TurnBattleComponent` 的战斗占用状态拒绝;同一碰撞帧不得创建多场战斗。 - -## 六、固定规则 +## 六、固定战斗规则 ### 6.1 下一行动者 -每次行动或跳过结束后,重新从本轮尚未获得行动机会且仍在战斗中的单位中选择: +每次行动或跳过结束后,从本轮尚未获得行动机会且仍在战斗中的单位中选择: -1. 先制战斗第一轮仍有玩家未行动时,只在玩家中选择;玩家全部行动后才允许选择敌人。 +1. 先制第一轮仍有玩家未行动时,只在玩家中选择。 2. 当前速度高者优先。 3. 当前速度相同时,玩家优先于敌人。 4. 同阵营同速度时,`PartyOrder` 小者优先。 -5. 已经获得过本轮行动机会的单位不会因速度变化再次行动。 +5. 已行动单位不会因速度变化再次行动。 6. 候选集为空时开启下一轮;先制限制只适用于第一轮。 -阵亡和逃跑单位不再进入候选集。眩晕单位仍会被选为下一行动者,随后跳过这次机会、标记本轮已行动并消耗一次眩晕持续次数。 +阵亡和逃跑单位不进入候选集。眩晕单位会获得行动机会,随后跳过、标记已行动并消耗一次眩晕持续次数。 ### 6.2 数值与效果 -- 每个 `BattleEffect` 按配置列表顺序结算。 +- 每个 `BattleEffect` 按配置顺序结算。 - 目标变化值为 `FlatValue + SourceStat * SourceScalePermille / 1000`。 -- 使用整数运算;首版不加入暴击、防御、命中、浮动伤害或元素克制。 -- HP 和 MP 始终钳制到 0 与对应上限之间。 -- HP 降到 0 时立即标记阵亡,之后不能成为合法目标或行动者。 -- MP 不足的行动不可执行,不扣除行动机会。 -- 战斗中的属性和状态变化只属于本场战斗;除最终 HP/MP 外不写回探索。 +- 使用整数运算;首版没有暴击、防御、命中、浮动伤害和元素克制。 +- HP/MP 钳制到 0 与对应上限之间。 +- HP 到 0 时立即阵亡,之后不能成为合法目标或行动者。 +- MP 不足的行动不可执行,不消耗行动机会。 +- 战斗中的属性和状态变化不写回探索;只回写最终 HP/MP。 ### 6.3 眩晕 -- `Stun(1)` 表示跳过目标未来一次行动机会。 -- 目标本轮尚未行动时被施加眩晕,会跳过本轮即将到来的行动。 -- 目标本轮已经行动时被施加眩晕,会跳过下一轮的行动。 -- 重复施加时,剩余次数取当前值与新值中的较大值,不相加。 +- `Stun(1)` 跳过目标未来一次行动机会。 +- 目标本轮尚未行动时被眩晕,跳过本轮即将到来的行动。 +- 目标本轮已经行动时被眩晕,跳过下一轮行动。 +- 重复施加时取当前和新持续次数中的较大值,不相加。 - 战斗结束时清除全部战斗状态。 -### 6.4 逃跑 +### 6.4 敌人行动 -- 逃跑只允许当前玩家行动者选择。 +- 敌人从当前 MP 足够的行动中等概率选择。 +- 单体行动从合法目标中等概率选择;全体行动不额外随机目标。 +- 没有可执行行动时跳过本次机会,不能形成无限循环。 +- 首版逻辑直接读取 `BattleRuntime` 并产生内部指令,不建立策略接口。 + +### 6.5 逃跑 + +- 只有当前玩家行动者可以逃跑。 - 每次尝试使用 `GlobalConfig.EscapeSuccessPermille` 和本局随机源独立判定。 -- 成功或失败都消耗当前角色本轮行动机会。 -- 成功后角色立即从当前战斗候选集和合法目标中移除,但不视为阵亡。 -- 只要仍有玩家留在战斗中,单个角色逃跑不结束战斗。 +- 成功和失败都消耗当前角色本轮行动机会。 +- 成功后角色立即离开候选集和合法目标集合,但不视为阵亡。 +- 仍有玩家留在战斗中时,单个角色逃跑不结束战斗。 ## 七、开发里程碑 -### [x] M0 接口、最小单局状态与战斗壳层 +### [x] M0 战斗壳层与单局基础状态 -目标:建立不会被后续规则推翻的数据流,并在 Main 场景得到第一个可见结果。 +目标:在 Main 场景得到第一个可见战斗壳层,并验证暂停和重复进入门禁。 -实现范围: +已完成: -- 建立第五节定义的 DTO、枚举和接口。 -- 新增 `RandomComponent`:本局 seed 与共享随机源,种子由局外带入,地图生成、敌人 AI、逃跑和掉落共用。 -- 新增 `TurnBattleComponent` 最小状态:临时玩家列表、计时、`IsExplorationPaused` 和战斗占用标志。 -- 将 `RandomComponent`、`TurnBattleComponent` 挂入 Launcher 的自定义组件节点,并在 `GameEntry.Custom` 暴露统一访问入口。 -- 新增 `RunBattleCoordinator` 空流程,能用固定调试遭遇构建 `BattleStartRequest`。 -- 新增半透明顶级 `BattleForm`、`UIFormType` 与 `UIFormConfig`,沿用现有 Form/View/`.Logic.cs` 生成和维护规则。 -- 增加仅供 Editor/Development Build 使用的调试入口,不依赖尚未实现的地图敌人。 -- 打开战斗 UI 时暂停调试地图计时,关闭调试壳层后恢复。 +- `RandomComponent`(`GameEntry.Random`,Launcher.unity 的 "Random" GameObject)、`IRunRandomSource` 和 `RunRandomSource`。 +- `TurnBattleComponent`(`GameEntry.TurnBattle`,Launcher.unity 的 "Turn Battle" GameObject)的玩家临时状态、计时、暂停和战斗占用。 +- `RunBattleCoordinator` M0 空流程(`GameEntry.RunBattle`)负责预留占用、构建调试请求、暂停/恢复和打开/关闭 `BattleForm`;M1 将这些职责并回 Component。 +- `BattleForm.Logic.cs` 半透明壳层、按钮绑定、道具禁用和占位反馈。 +- `BattleDebuggerWindow` 调试入口:仅 Editor/Development Build,路径 Battle/Shell,固定 EncounterId=1、敌人队伍预设 1;缺少单局状态时使用配表角色和固定种子初始化。 +- `UIFormType.BattleForm = 103` 和对应 `UIFormConfig` 已导出。 +- M0 同时提前建立了一批后续 DTO 和接口;它们不是新架构的既定合同,M1 按第四、五节删除、合并或改名。 -当前进度(2026-08-31): +已知约束: -- 已完成:`RandomComponent`(`GameEntry.Random`,Launcher.unity "Random" GameObject);`TurnBattleComponent`(`GameEntry.TurnBattle`,Launcher.unity "Turn Battle" GameObject);`IRunRandomSource`、`RunRandomSource`、`RunPlayerState`;第五节全部 DTO/枚举/接口(`Battle/` 目录,命名空间 `SepCore.Battle`);`RunBattleCoordinator`(`GameEntry.RunBattle`,空流程:预留占用、构建 `BattleStartRequest`、暂停/恢复探索与计时、开/关 `BattleForm`,`EndDebugBattle` 为 M0 专用,M7 由 `ApplyResult` 替代);`BattleForm.Logic.cs` 壳层逻辑(按钮绑定、道具禁用、占位反馈,不含暂停职责);`BattleDebuggerWindow` 调试入口(`#if UNITY_EDITOR || DEVELOPMENT_BUILD` 注册于 `ProcedureLaunch`,路径 Battle/Shell,固定遭遇 EncounterId=1/队伍预设 1,缺单局状态时用配表角色与固定种子初始化);配表 `UIFormType.BattleForm = 103` 与 `UIFormConfig` 行已导出。 -- 未完成:`BattleController` 创建与校验(M1 起);暂停联动已由协调器承担,等待 M0 Play Mode 验收。 -- 已知注意点:单局计时暂停来源不止战斗(暂停菜单也会暂停),后续需要多来源组合;`RunRandomSource` 使用 System.Random,跨平台同 seed 序列不保证一致,仅需同构建内复现;`BattleView` 的回合槽与敌人槽为模板 + 运行时实例化(`turnSlotsRoot`/`enemySlotsRoot` + template),数据接入时由 Logic 负责实例化。 +- 单局计时暂停来源不止战斗;暂停菜单接入后必须组合暂停来源,不能由战斗结束无条件解除全部暂停。 +- `RunRandomSource` 使用 `System.Random`;只要求同一构建内以相同输入复现,不承诺跨平台、跨运行时版本产生相同序列。 +- `BattleView` 的回合槽和敌人槽使用运行时实例化模板(`turnSlotsRoot` / `enemySlotsRoot` 和 template),数据接入时由 Logic 创建显示项。 验证证据: @@ -412,250 +398,239 @@ EditMode:不适用(M0 无 EditMode 用例) Play Mode:通过(2026-08-31,Debugger -> Battle/Shell 实测: 1. Main 场景未切换、未卸载,半透明 BattleForm 覆盖在地图上; 2. 调试入口 Open 打开战斗壳层,按钮可点击响应; - 3. Run State 读数:打开后 RunElapsedMs 停止增长、IsTimerPaused=true、IsExplorationPaused=true、IsBattleActive=true,Close 后计时恢复增长、标志复位; - 4. 壳层打开时重复点击 Open 被拒绝(TryStartBattle 占用检查 + allowMultiInstance=false),不会打开第二场。 + 3. 打开后 RunElapsedMs 停止增长,IsTimerPaused、IsExplorationPaused、IsBattleActive 为 true,Close 后计时恢复且标志复位; + 4. 重复点击 Open 被占用检查和 allowMultiInstance=false 拒绝,不会打开第二场。 截图:未提供) -提交或差异:未提交(工作区包含全部 M0 代码、配表导出与 Launcher.unity 挂载) -剩余问题:无 +提交或差异:原验收记录未填写 commit;M0 代码当前已在仓库基线 +剩余问题:M0 的提前契约按 2026-09-01 架构修订在 M1 收敛 ``` -明确不做:回合调度、攻击、敌人 AI、胜负和真实碰撞接入。 +### [x] M1 架构收敛与 1v1 攻击闭环 -Play Mode 验收: - -- Main 场景不切换、不卸载。 -- 调试入口能打开覆盖地图的半透明战斗 UI。 -- 战斗 UI 打开时地图计时或可观察的地图更新停止,UI 按钮仍可响应。 -- 重复触发调试入口不会打开第二场战斗。 - -### [ ] M1 1v1 攻击闭环 - -目标:完成第一场能亲手打完的战斗。 +目标:用最终所有权完成第一场可打完的战斗,不把 M0 的中间契约继续扩散。 实现范围: -- 创建 1 名玩家和 1 名敌人的 `BattleSession`。 -- 支持轮次、当前行动者和每轮一次行动机会。 -- 支持普通攻击、简单 HP 伤害、MP/HP 钳制和阵亡。 -- 实现 `EnemyAiType.Random` 的免费攻击选择和合法目标选择。 -- 实现 `Victory` 与 `TotalDefeat`。 +- `TurnBattleComponent` 成为唯一外部入口,吸收 `RunBattleCoordinator` 的启动、暂停、UI 打开和结束回写职责。 +- 新增内部 `BattleRuntime` 和 `BattleUnit`,Component 持有唯一当前实例。 +- 删除 `BattleController` 规划;删除或停止使用 `BattleStartRequest`、`BattlePlayerInput`、`BattleReturnPlan`、`IExplorationBattleBridge` 和 `IEnemyBattlePolicy`。 +- 将现有 Snapshot/Advance/Record 类型收敛为 `BattleViewState`、`BattleStep` 和一层 `BattleEvent`;不保留重复 FlowState。 +- 支持 1 名玩家和 1 名敌人的轮次、当前行动者和每轮一次行动机会。 +- 支持普通攻击、HP 伤害、HP/MP 钳制、阵亡、`Victory` 和 `TotalDefeat`。 +- 敌人直接使用首版随机决策并自动反击。 - UI 显示双方 HP、当前行动者和攻击按钮。 +- 增加纯逻辑 EditMode 用例,直接验证 BattleRuntime,不依赖 MonoBehaviour 和 UI。 +- 建立 Battle EditMode 测试程序集和 `InternalsVisibleTo`,内部规则不因测试改成 public。 明确不做:多人队伍、技能、状态、先制和逃跑。 Play Mode 验收: - 玩家点击攻击后敌人 HP 立即变化。 -- 未结束时敌人能自动反击,玩家 HP 立即变化。 -- 任一方 HP 归零后战斗停止接受指令并显示对应结果。 -- 行动记录的顺序和最终数值与 UI 一致。 +- 未结束时敌人自动反击,玩家 HP 立即变化。 +- 任一方 HP 归零后停止接受指令并显示结果。 +- UI 结果与 BattleEvent、BattleViewState 一致。 +- 战斗结束后不存在残留 BattleRuntime,能够再次打开调试战斗。 -### [ ] M2 1 至 4 人多对多与实时回合顺序 +验证证据: -目标:完成设计要求的多对多调度骨架。 +```text +代码状态:已完成 +Unity 编译:通过(2026-09-02,dotnet build SepCore.Runtime.csproj 0 错误 0 警告;Unity 编辑器内 Play Mode 实测编译通过) +EditMode:通过(2026-09-02,新建 Tests 程序集,BattleRuntime 纯逻辑用例 25 个全部通过, + 覆盖创建失败零副作用、攻击伤害公式、HP 钳制、阵亡、Victory/TotalDefeat、 + 非法指令不消耗行动、敌人随机决策与固定序列复现、视图字段、连续战斗不继承状态) +Play Mode:通过(2026-09-02,Debugger -> Battle/Shell 实测: + 1. 1v1(角色1 vs 敌人1)开局显示双方 HP、图标与顶部 TurnSlots 一致; + 2. 攻击后敌人 HP 立即变化,0.6s 间歇后敌人自动反击,TurnSlots 高亮逐单位移动且已行动单位不隐藏; + 3. 敌人 HP 归零显示"胜利!",攻击按钮禁用,高亮熄灭; + 4. 关闭后再次开启可重开新战斗,无残留 BattleRuntime; + 5. 连续两场战斗第二场继承第一场回写的 HP/MP(单局内连续战斗设计预期)。 + 截图:未提供) +提交或差异:未提交(working tree 差异:契约收敛、BattleRuntime 内核、组件入口、UI 接线与图标、EditMode 测试) +剩余问题:无阻塞。敌人行动间歇时长暂为组件常量 AutoAdvanceDelaySeconds=0.6f; + 调度仍按单位创建顺序轮流,M2 由完整速度/先制规则替换 +``` + +### [ ] M2 1 至 4 人多对多、先制与确定性调度 + +目标:一次完成完整的行动顺序模型。 实现范围: -- 从 `BattleStartRequest.Players` 和 `EnemyPartyConfig` 创建最多 4v4 的单位。 -- 为重复敌人配置生成不同 `BattleUnitId`。 -- 实现当前速度、阵营优先和 `PartyOrder` 并列规则。 -- 每次行动后重新选择本轮下一行动者。 +- 从 `RunPlayerState` 和 `EnemyPartyConfig` 创建最多 4v4 的 BattleUnit。 +- 重复敌人配置生成不同 `BattleUnitId`。 +- 实现速度、阵营和 `PartyOrder` 并列规则。 +- 每次行动后按当前 BattleRuntime 重新选择本轮下一行动者。 - 阵亡单位在轮到前死亡时不再行动。 +- 实现先制第一轮玩家全部先行,第二轮恢复普通排序。 +- 敌人行动和目标选择只消费注入的本局随机源。 - UI 显示当前行动者和本轮剩余顺序。 +- 增加固定随机序列的 EditMode 回归用例。 -明确不做:速度修改效果、眩晕、技能和先制。 +明确不做:技能、状态、速度修改效果和逃跑。 Play Mode 验收: -- 调试场景可分别启动 1v1、2v2 和 4v4。 -- 同速度时玩家先于敌人,同阵营按队伍顺序行动。 +- 调试入口可启动 1v1、2v2 和 4v4。 +- 普通模式按速度、阵营和队伍顺序行动。 +- 先制第一轮所有玩家先于敌人,第二轮恢复普通规则。 - 每个仍在战斗中的单位每轮最多行动一次。 - 两个相同 EnemyConfig 的敌人可以分别选择、受伤和死亡。 +- 相同初始状态和随机序列产生相同行动记录。 -### [ ] M3 技能、目标选择、MP 与通用效果 +### [ ] M3 技能、目标、MP 与数值效果 -目标:让当前配表中的数值型玩家和敌人行动可用。 +目标:让当前配表中的数值型攻击和技能完整可用。 实现范围: - 支持 `Attack` 和 `Skill` 行动配置。 -- 本阶段只结算数值型 `BattleEffect`;状态型效果由 M4 接入。 - 支持 `Self`、`SingleAlly`、`AllAllies`、`SingleEnemy` 和 `AllEnemies`。 -- 实现 MP 校验和成功执行后的 MP 扣除。 -- 实现 HP 治疗、单体伤害和全体伤害。 -- UI 根据行动配置进入或跳过目标选择,并禁用 MP 不足的技能。 -- 控制器拒绝错误行动者、错误阵营、错误目标数量、阵亡目标和逃跑目标。 +- 实现 MP 校验、成功后的 MP 扣除、伤害和治疗。 +- `EffectResolver` 只在效果逻辑已需要独立测试时从 BattleRuntime 提取;仍直接操作同一 Runtime。 +- UI 根据行动配置进入或跳过目标选择,并禁用 MP 不足技能。 +- 内核拒绝错误行动者、行动、阵营、目标数量、阵亡目标和逃跑目标。 +- BattleEvent 一层记录能够表达当前数值变化;不为未来动画建立额外记录树。 -明确不做:角色 4 的眩晕效果、速度修改、逃跑和道具效果。 +明确不做:眩晕、速度修改、逃跑和道具效果。 Play Mode 验收: -- 角色 1 至 3 的单体伤害、单体治疗和全体伤害技能能够完整结算。 -- `SingleAlly` 可以选择施法者自己。 +- 角色 1 至 3 的单体伤害、单体治疗和全体伤害完整结算。 +- `SingleAlly` 可以选择自己。 - 全体技能一次结算全部合法目标。 -- MP 不足时技能不可点击,伪造非法指令也不会消耗行动机会。 -- 治疗不会超过 MaxHP,伤害不会使 HP 低于 0。 +- MP 不足或伪造非法指令不消耗行动机会。 +- 治疗不超过 MaxHP,伤害不低于 0。 -### [ ] M4 眩晕、无法行动与实时速度变化 +### [ ] M4 状态、眩晕与实时速度变化 -目标:补齐会改变行动机会或下一行动者的战斗内变化。 +目标:补齐会改变行动机会和当前轮次顺序的战斗内状态。 实现范围: -- 实现 `BattleStatusType.Stun` 及已确认的持续次数语义。 -- 实现同类状态刷新而非叠层。 -- 实现对 Speed 的简单数值修改。 -- 速度改变后立即重算本轮剩余顺序。 -- 为 EditMode 和开发调试场景提供可复现的速度变化用例;不要求修改四名正式角色的技能定位。 +- 实现 `BattleStatusType.Stun` 和已确认的行动机会持续语义。 +- 同类状态刷新持续次数,不叠层。 +- 实现 Speed 数值修改并立即重算本轮剩余顺序。 +- 已行动单位不会因加速再次行动。 +- BattleStatus 只存在于 BattleRuntime,不写回 RunPlayerState。 +- BattleViewState 和 BattleEvent 增加当前阶段真实需要的状态字段。 -明确不做:复杂状态、每轮伤害、状态图标动画和永久属性写回。 +明确不做:复杂状态、每轮伤害、状态动画和永久属性写回。 Play Mode 验收: -- 角色 4 的眩晕技能能使尚未行动的目标跳过本次行动。 -- 已经行动的目标被眩晕后,会跳过下一轮的行动。 -- 重复眩晕不会叠加总次数,只保留较长持续次数。 -- 调试行动改变未行动单位速度后,本轮剩余顺序立即更新。 -- 已在本轮行动过的单位不会因速度提高再次行动。 +- 角色 4 的眩晕使尚未行动目标跳过本次行动。 +- 已行动目标被眩晕后跳过下一轮行动。 +- 重复眩晕只保留较长持续次数。 +- 未行动单位速度变化后,剩余顺序立即更新。 +- 已行动单位加速后不会再次行动。 -### [ ] M5 先制首轮与确定性随机 +### [ ] M5 逃跑、结束矩阵与单局回写 -目标:接通普通遭遇和主动接敌的规则差异,并证明战斗随机来自单局。 +目标:完成战斗内部全部结束分支,并把结果正确写回同一局状态。 实现范围: -- 实现 `IsPreemptive`。 -- 第一轮所有尚未行动的玩家先于所有敌人,玩家内部仍按当前速度和队伍顺序选择。 -- 第二轮恢复双方共同按速度选择。 -- 敌人行动和目标选择只消费注入的本局随机源。 -- 增加固定随机序列的 EditMode 回归用例。 - -明确不做:真实警惕碰撞接入和敌人掉落。 - -Play Mode 验收: - -- 同一队伍可分别以普通和先制模式启动。 -- 先制第一轮中,即使敌人速度更高,也要等所有玩家行动后再行动。 -- 第二轮高速敌人恢复正常优先级。 -- 相同初始状态与固定随机序列产生相同敌人行动和目标记录。 - -### [ ] M6 单角色逃跑与结束结果矩阵 - -目标:完成所有战斗结束分支,但暂不操作地图。 - -实现范围: - -- 增加逃跑按钮和 `Escape` 指令。 +- 增加 Escape 指令和按钮。 - 使用 `GlobalConfig.EscapeSuccessPermille` 与本局随机源判定。 -- 成功或失败均消耗当前行动机会。 -- 成功逃跑单位立即离开候选集和合法目标集合。 -- 实现 `AllEscaped` 与 `PartialEscapeDefeat`。 -- 固定四种 `BattleOutcome` 的互斥判定和结果数据。 - -明确不做:2 秒地图保护、地图敌人恢复和 HP/MP 回写。 - -Play Mode 验收: - -- 逃跑失败后角色仍在战斗中,随后敌人正常行动。 -- 单人逃跑成功不会在仍有队友战斗时结束战斗。 -- 所有人成功逃跑得到 `AllEscaped`。 -- 至少一人逃跑、其余人阵亡得到 `PartialEscapeDefeat`,而不是 `TotalDefeat`。 -- 没有人逃跑且全部阵亡得到 `TotalDefeat`。 - -### [ ] M7 单局临时状态回写与战斗关闭 - -目标:让调试战斗能正确返回同一局临时状态。 - -实现范围: - -- `RunBattleCoordinator` 消费 `BattleResult`,不得由 UI 自行回写。 -- 胜利时存活者保留 HP/MP,阵亡者按全局配置恢复到 1/1。 -- 全逃跑时所有角色保留逃跑时 HP/MP。 -- 部分逃跑失败时逃跑者保留值,阵亡者恢复到 1/1。 -- 清除所有战斗状态,不把 Speed 修改或眩晕带回探索。 -- `TotalDefeat` 进入单局失败入口,不恢复探索更新。 -- 非全员阵亡时关闭 `BattleForm` 并恢复探索更新。 +- 成功和失败都消耗行动机会;成功单位离开候选集和合法目标集合。 +- 实现 `Victory`、`AllEscaped`、`PartialEscapeDefeat` 和 `TotalDefeat` 的互斥判定。 +- `TurnBattleComponent` 直接消费 BattleResult 并回写 RunPlayerState,不建立 BattleReturnPlan。 +- 胜利时存活者保留 HP/MP,阵亡者恢复 1/1。 +- 全逃跑保留逃跑时 HP/MP。 +- 部分逃跑失败时逃跑者保留值,阵亡者恢复 1/1。 +- 清除战斗状态和速度修改。 +- `TotalDefeat` 进入单局失败,不恢复探索更新。 +- 非 TotalDefeat 关闭 BattleForm 并恢复探索更新。 明确不做:真实地图敌人、掉落和保护碰撞门禁。 Play Mode 验收: -- 连续打开两场调试战斗时,第二场使用第一场返回的玩家 HP/MP。 -- 第一场阵亡但非全员阵亡的角色,第二场以 1 HP/1 MP 参战。 -- 上一场眩晕或速度修改不会进入下一场。 -- 全员阵亡后不能返回可操作地图状态。 -- 本阶段全过程不修改 `GameEntry.Save.Data` 或写入 `save.json`。 +- 逃跑失败后角色仍在战斗,敌人继续行动。 +- 单人逃跑成功不会在队友仍战斗时结束。 +- 四种 BattleOutcome 都能通过调试输入稳定得到。 +- 连续两场战斗中,第二场使用第一场写回的 HP/MP。 +- 阵亡恢复为 1/1,上一场状态和速度修改不会进入下一场。 +- 全员阵亡后不短暂返回可操作地图。 +- 全过程不修改 `GameEntry.Save.Data` 或写入 `save.json`。 -### [ ] M8 真实探索遭遇、敌人恢复、掉落与保护 +### [ ] M6 真实探索遭遇、地图恢复、掉落与保护 -目标:通过既定接口接入探索层,形成碰撞到返回地图的完整垂直流程。 +目标:接入真实地图,完成碰撞到战斗返回的垂直流程。 -前置依赖:探索层必须提供唯一 `EncounterId`、敌人队伍预设 ID、警惕是否已满、地图暂停门禁和敌人实例生命周期接口。 +前置依赖:探索层提供唯一 EncounterId、敌人队伍预设 ID、警惕状态、地图暂停门禁和敌人实例生命周期操作。 实现范围: -- 队长碰撞调用探索适配器创建 `BattleEncounter`。 -- 搜索被战斗中断时保留搜索进度和已掉落物。 -- 战斗期间冻结所有地图更新,其他敌人保持战前警惕和追击状态。 -- `Victory` 移除触发敌人,并通过掉落服务使用本局随机源结算掉落。 -- `AllEscaped` 和 `PartialEscapeDefeat` 保留触发敌人,恢复地图行为;下次进入战斗重新按完整队伍预设创建敌方。 +- 队长碰撞创建 BattleEncounter 并调用 `GameEntry.TurnBattle.TryStartBattle`。 +- 探索遭遇在启动时提供一次性完成回调,并在回调中消费 BattleResult;不得让 BattleForm 执行地图后果。 +- 搜索被战斗中断时保留进度和已掉落物。 +- 战斗期间冻结所有地图更新,其他敌人保持战前状态。 +- `Victory` 按 EncounterId 移除触发敌人,并使用本局随机源结算掉落。 +- 逃跑类结果保留并恢复触发敌人,下次按完整预设重建。 - 逃跑类结果开启 `GlobalConfig.EscapeProtectionMs` 保护。 -- 保护期间禁止警惕增长、追击和战斗触发;结束后仍重叠可立即再次触发。 +- 保护期间禁止警惕增长、追击和战斗触发;结束后仍重叠可再次触发。 +- 探索层只根据 Component 返回的 EncounterId 和 Outcome 执行固定后果,不读取 BattleRuntime。 -明确不做:地图生成、敌人巡逻算法、警惕值算法、物品背包实现和掉落表设计。 +明确不做:地图生成、巡逻算法、警惕算法、背包实现和掉落表设计。 Play Mode 验收: -- 警惕未满碰撞进入先制战斗,警惕已满碰撞进入普通战斗。 -- 附近其他地图敌人不会加入战斗,战斗后状态保持不变。 -- 胜利后只有触发敌人消失并可能产生掉落。 -- 逃跑后触发敌人仍在;再次碰撞面对完整初始敌人队伍。 -- 2 秒保护内不能立刻重入战斗,保护结束仍重叠时可以重入。 +- 警惕未满进入先制战斗,警惕已满进入普通战斗。 +- 附近其他敌人不参战,战斗后状态保持。 +- 胜利后只有触发敌人消失并可能掉落。 +- 逃跑后触发敌人仍在,再战面对完整初始队伍。 +- 保护期间不能重入,保护结束仍重叠时可以重入。 -### [ ] M9 战斗 UI、输入限制与最小表现完成 +### [ ] M7 战斗 UI、输入限制与最小表现完成 -目标:达到设计文档要求的最小可读、可操作战斗界面。 +目标:达到设计要求的最小可读、可操作战斗界面。 实现范围: - 我方所有角色显示名称、HP、MP、阵亡和逃跑状态。 - 所有敌人显示名称、HP 条和不可选状态。 -- 显示当前行动者和本轮剩余行动顺序。 +- 显示当前行动者和本轮剩余顺序。 - 行动菜单只在等待当前玩家指令时出现。 - 攻击、技能、逃跑可用;道具占位可见但禁用。 -- 单体目标提供明确选中态;取消目标选择返回行动菜单且不消耗行动。 +- 单体目标有明确选中态;取消目标不消耗行动。 - 战斗期间不能打开共享背包、保险箱或调整保险箱内容。 -- UI 文案走现有本地化/格式化文本流程,不在逻辑代码中硬编码。 -- 保持半透明覆盖效果,让玩家能够确认仍在原地图场景。 +- UI 文案走现有本地化和格式化文本流程。 +- 保持半透明覆盖,让玩家确认仍在原地图。 +- UI 只持有 BattleViewState 和当前显示所需 BattleEvent,不持有 BattleRuntime 或 BattleUnit。 -明确不做:角色站位、骨骼或序列帧动画、VFX、Timeline、镜头演出和音效同步。 +明确不做:站位、骨骼或序列帧动画、VFX、Timeline、镜头演出和音效同步。 Play Mode 验收: -- 1 至 4 名玩家和敌人的最长名称、HP/MP 数值不会溢出或遮挡。 +- 1 至 4 名玩家和敌人的最长名称、HP/MP 不溢出或遮挡。 - 速度变化后顺序显示与下一实际行动者一致。 - 非当前玩家不能操作,敌人自动行动期间不能提交玩家指令。 - 背包和保险箱入口在战斗中不可用。 -- 取消目标、MP 不足、阵亡目标和逃跑目标均不会误消耗行动。 +- 取消目标、MP 不足、阵亡目标和逃跑目标不误消耗行动。 -### [ ] M10 全量回归与原型完成 +### [ ] M8 全量回归与原型完成 -目标:关闭战斗模块全部设计验收,清理调试入口和遗留临时代码。 +目标:关闭全部战斗设计验收,移除旧架构残留和临时代码。 实现范围: -- 完成第八节的全部验收映射。 -- 覆盖 EditMode 规则测试和实际探索 Play Mode 流程。 -- 调试入口只在 Editor/Development Build 可用,正式构建不可见。 -- 清理临时日志、硬编码测试数据和无用调试按钮。 +- 完成第八节 22 条验收映射。 +- 完成 EditMode 规则测试和实际探索 Play Mode 流程。 +- 删除 M0 遗留且不再使用的 DTO、接口、Coordinator 和重复状态类型。 +- 检查公开 Battle 类型;没有外部消费者的实现细节改为 internal。 +- 调试入口只在 Editor/Development Build 可用。 +- 清理临时日志、硬编码测试数据和无用按钮。 - 检查生成文件、资源收集、UIForm 配表和 Prefab 引用。 - 更新本文档进度、证据和剩余问题。 Play Mode 验收: -- 从地图碰撞进入战斗、完成任一结果并返回或进入失败结算的流程可重复执行。 -- 普通、先制、胜利、全逃跑、部分逃跑失败和全员阵亡均留有证据。 -- 连续多场战斗没有重复事件订阅、重复 UI、随机源重置或地图暂停泄漏。 +- 地图碰撞、战斗、任一结果返回或失败的流程可重复执行。 +- 普通、先制、胜利、全逃跑、部分逃跑失败和全员阵亡均留证。 +- 连续多场战斗没有重复 UI、随机源重置、地图暂停泄漏或残留 BattleRuntime。 - Unity Console 没有由战斗流程产生的新异常或错误。 ## 八、设计验收映射 @@ -668,22 +643,22 @@ Play Mode 验收: - [ ] 同速玩家优先,速度变化立即影响尚未行动单位。对应 M2、M4。 - [ ] 同阵营同速按队伍顺序行动。对应 M2。 - [ ] 死亡、眩晕或无法行动单位跳过本轮。对应 M2、M4。 -- [ ] 轮到玩家角色时才显示并处理该角色的行动选择。对应 M1、M9。 -- [ ] 先制第一轮所有玩家排在所有敌人之前。对应 M5。 -- [ ] 先制第二轮恢复普通速度规则。对应 M5。 -- [ ] 玩家可选择攻击、技能和逃跑,道具保留但不可用。对应 M3、M6、M9。 -- [ ] 战斗中不能打开共享背包或保险箱。对应 M9。 -- [ ] 敌人随机选择可用行动和合法目标。对应 M1、M5。 -- [ ] 敌人全灭时胜利,玩家全灭时战斗和单局失败。对应 M1、M6、M7。 -- [ ] 单个角色可逃跑;只要有人逃跑,其他人阵亡不导致单局失败。对应 M6。 -- [ ] 逃跑使用配置成功率,失败后继续留在战斗。对应 M6。 -- [ ] 逃跑成功或失败均消耗本轮行动。对应 M6。 -- [ ] 逃跑返回战前位置并获得 2 秒保护。对应 M7、M8。 -- [ ] 逃跑者保留 HP/MP,部分失败中的阵亡者恢复 1/1。对应 M7。 -- [ ] 任何非全员阵亡结果中的阵亡角色恢复 1/1。对应 M7。 -- [ ] 逃跑后地图敌人恢复,重战按完整预设创建。对应 M8。 -- [ ] 保护期间不警惕、不追击、不触发战斗,结束仍重叠时可重入。对应 M8。 -- [ ] 战斗 UI 显示我方 HP/MP、行动顺序和敌人 HP。对应 M9。 +- [ ] 轮到玩家角色时才显示并处理该角色的行动选择。对应 M1、M7。 +- [ ] 先制第一轮所有玩家排在所有敌人之前。对应 M2。 +- [ ] 先制第二轮恢复普通速度规则。对应 M2。 +- [ ] 玩家可选择攻击、技能和逃跑,道具保留但不可用。对应 M3、M5、M7。 +- [ ] 战斗中不能打开共享背包或保险箱。对应 M7。 +- [ ] 敌人随机选择可用行动和合法目标。对应 M1、M2、M3。 +- [ ] 敌人全灭时胜利,玩家全灭时战斗和单局失败。对应 M1、M5。 +- [ ] 单个角色可逃跑;只要有人逃跑,其他人阵亡不导致单局失败。对应 M5。 +- [ ] 逃跑使用配置成功率,失败后继续留在战斗。对应 M5。 +- [ ] 逃跑成功或失败均消耗本轮行动。对应 M5。 +- [ ] 逃跑返回战前位置并获得 2 秒保护。对应 M5、M6。 +- [ ] 逃跑者保留 HP/MP,部分失败中的阵亡者恢复 1/1。对应 M5。 +- [ ] 任何非全员阵亡结果中的阵亡角色恢复 1/1。对应 M5。 +- [ ] 逃跑后地图敌人恢复,重战按完整预设创建。对应 M6。 +- [ ] 保护期间不警惕、不追击、不触发战斗,结束仍重叠时可重入。对应 M6。 +- [ ] 战斗 UI 显示我方 HP/MP、行动顺序和敌人 HP。对应 M7。 ## 九、最低测试矩阵 @@ -698,9 +673,10 @@ Play Mode 验收: | 目标 | Self、SingleAlly 含自己、全体目标、阵营错误、阵亡和逃跑目标 | | MP 与效果 | MP 不足、伤害、治疗、全体效果、HP/MP 钳制、顺序效果 | | 眩晕 | 行动前施加、行动后施加、重复施加、跳过后失效 | -| 敌人 AI | 过滤不可用行动、合法目标、无行动时跳过、固定随机序列 | +| 敌人决策 | 过滤不可用行动、合法目标、无行动时跳过、固定随机序列 | | 逃跑 | 0/1000 边界、成功/失败消耗行动、四种结束结果 | | 回写 | 胜利阵亡恢复、全逃跑保留、部分失败恢复、全灭不恢复探索 | +| 生命周期 | 启动失败无副作用、结束清理 Runtime、连续战斗不继承状态 | ### Play Mode @@ -718,10 +694,12 @@ Play Mode 验收: 只有同时满足以下条件,本文档状态才能改为“完成”: -- M0 至 M10 全部标记为 `[x]`,且每个里程碑都有独立的 Unity/Play Mode 证据。 +- M0 至 M8 全部标记为 `[x]`,且每个里程碑都有要求的运行证据。 - 第八节 22 条设计验收全部通过。 -- EditMode 最低测试矩阵全部通过。 -- 战斗过程中没有直接写 `SaveData`、切换 Battle 场景或重建单局随机源。 -- 胜利、逃跑和失败对地图敌人、玩家临时状态、计时和保护期的处理符合本文档。 +- EditMode 和 Play Mode 最低测试矩阵全部通过。 +- `TurnBattleComponent` 是外界唯一战斗入口,当前战斗只有一个 BattleRuntime 所有者。 +- 战斗内部协作者共享 Runtime,没有内部请求/响应 DTO 链和全局事件总线。 +- UI、探索层和存档不持有或修改 BattleRuntime、BattleUnit。 +- 战斗不直接写 SaveData、不切换 Battle 场景、不重建本局随机源。 +- 胜利、逃跑和失败对地图敌人、玩家临时状态、计时和保护期的处理符合设计。 - 正式构建中没有开发调试入口和测试数据。 -- 静态检查、Unity 编译、EditMode 和 Play Mode 结果分别记录,不用静态检查代替运行验收。 diff --git a/数据表/Datas/EnemyConfig_敌人配置.xlsx b/数据表/Datas/EnemyConfig_敌人配置.xlsx index 61ba020..45c60a3 100644 Binary files a/数据表/Datas/EnemyConfig_敌人配置.xlsx and b/数据表/Datas/EnemyConfig_敌人配置.xlsx differ diff --git a/数据表/Datas/SpriteConfig_图片资源.xlsx b/数据表/Datas/SpriteConfig_图片资源.xlsx index 43f9486..da4829f 100644 Binary files a/数据表/Datas/SpriteConfig_图片资源.xlsx and b/数据表/Datas/SpriteConfig_图片资源.xlsx differ