M1 回合制战斗架构收敛与 1v1 攻击闭环:BattleRuntime 内核、组件唯一入口、UI 数据驱动与 EditMode 测试

- 删除中间契约(BattleStartRequest/BattlePlayerInput/BattleReturnPlan/桥接与策略接口/FlowState),收敛为 BattleViewState/BattleStep/一层 BattleEvent
- 新增内部 BattleRuntime/BattleUnit/BattleStatus 共享上下文;TurnBattleComponent 成为唯一外部入口,吸收启动/暂停/回写职责
- 内核支持 1v1 攻击闭环:效果公式、HP/MP 钳制、阵亡、Victory/TotalDefeat、敌人随机决策;敌人行动间歇 0.6s 由组件协程编排并经 StepListener 推送
- UI:玩家卡片/敌人槽/回合槽按战局重建,图标配置驱动(含新增 EnemyConfig.Icon),每轮刷新一次 TurnSlots 且轮内只移动高亮
- 新增 Tests 程序集 + InternalsVisibleTo,25 个 BattleRuntime 纯逻辑用例全部通过
This commit is contained in:
huangjun
2026-09-02 08:42:04 +08:00
parent 8413c2688b
commit 7dcac0ac87
65 changed files with 3067 additions and 1225 deletions
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Tests")]
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 1c518c47a9854ba19045abd1f211eb7d
guid: cd1500230107441cfa5e86657fa0b9e3
MonoImporter:
externalObjects: {}
serializedVersion: 2
@@ -16,11 +16,6 @@ public partial class GameEntry : MonoBehaviour
public static TurnBattleComponent TurnBattle { get; private set; }
/// <summary>
/// 单局战斗协调器(普通类,非场景组件)。
/// </summary>
public static RunBattleCoordinator RunBattle { get; private set; }
private static void InitCustomComponents()
{
BuiltinData = UnityGameFramework.Runtime.GameEntry.GetComponent<BuiltinDataComponent>();
@@ -28,6 +23,5 @@ public partial class GameEntry : MonoBehaviour
Save = UnityGameFramework.Runtime.GameEntry.GetComponent<SaveComponent>();
Random = UnityGameFramework.Runtime.GameEntry.GetComponent<RandomComponent>();
TurnBattle = UnityGameFramework.Runtime.GameEntry.GetComponent<TurnBattleComponent>();
RunBattle = new RunBattleCoordinator();
}
}
@@ -1,178 +0,0 @@
using System.Collections.Generic;
using SepCore.Definition;
namespace SepCore.Battle
{
/// <summary>
/// 单个效果对单个目标产生的数值或状态变化。
/// </summary>
public sealed class BattleActionEffectRecord
{
/// <summary>
/// 目标单位。
/// </summary>
public readonly int TargetUnitId;
/// <summary>
/// 效果结算前的 HP。
/// </summary>
public readonly int BeforeHp;
/// <summary>
/// 效果结算后的 HP。
/// </summary>
public readonly int AfterHp;
/// <summary>
/// 效果结算前的 MP。
/// </summary>
public readonly int BeforeMp;
/// <summary>
/// 效果结算后的 MP。
/// </summary>
public readonly int AfterMp;
/// <summary>
/// 状态变化列表;无状态变化时为空。
/// </summary>
public readonly IReadOnlyList<BattleStatusChangeRecord> StatusChanges;
public BattleActionEffectRecord(int targetUnitId, int beforeHp, int afterHp, int beforeMp, int afterMp,
IReadOnlyList<BattleStatusChangeRecord> statusChanges)
{
TargetUnitId = targetUnitId;
BeforeHp = beforeHp;
AfterHp = afterHp;
BeforeMp = beforeMp;
AfterMp = afterMp;
StatusChanges = statusChanges != null ? Copy(statusChanges) : new BattleStatusChangeRecord[0];
}
private static BattleStatusChangeRecord[] Copy(IReadOnlyList<BattleStatusChangeRecord> source)
{
BattleStatusChangeRecord[] copy = new BattleStatusChangeRecord[source.Count];
for (int i = 0; i < source.Count; i++)
{
copy[i] = source[i];
}
return copy;
}
}
/// <summary>
/// 状态变化记录。
/// </summary>
public sealed class BattleStatusChangeRecord
{
/// <summary>
/// 状态类型。
/// </summary>
public readonly BattleStatusType StatusType;
/// <summary>
/// 变化后的剩余持续次数;状态被移除时为 0。
/// </summary>
public readonly int RemainingRounds;
public BattleStatusChangeRecord(BattleStatusType statusType, int remainingRounds)
{
StatusType = statusType;
RemainingRounds = remainingRounds;
}
}
/// <summary>
/// 一次行动产生的有序行动记录。
/// 首版 UI 立即消费并显示;未来动画系统可以逐条等待播放,不需要修改战斗规则内核。
/// </summary>
public sealed class BattleActionRecord
{
/// <summary>
/// 行动者。
/// </summary>
public readonly int ActorUnitId;
/// <summary>
/// 指令类型,对应配表 BattleActionType。
/// </summary>
public readonly BattleActionType CommandType;
/// <summary>
/// 攻击或技能配置 ID;逃跑为 0。
/// </summary>
public readonly int ActionConfigId;
/// <summary>
/// 按结算顺序排列的效果记录。
/// </summary>
public readonly IReadOnlyList<BattleActionEffectRecord> Effects;
public BattleActionRecord(int actorUnitId, BattleActionType commandType, int actionConfigId,
IReadOnlyList<BattleActionEffectRecord> effects)
{
ActorUnitId = actorUnitId;
CommandType = commandType;
ActionConfigId = actionConfigId;
Effects = effects != null ? Copy(effects) : new BattleActionEffectRecord[0];
}
private static BattleActionEffectRecord[] Copy(IReadOnlyList<BattleActionEffectRecord> source)
{
BattleActionEffectRecord[] copy = new BattleActionEffectRecord[source.Count];
for (int i = 0; i < source.Count; i++)
{
copy[i] = source[i];
}
return copy;
}
}
/// <summary>
/// 战斗推进返回值。
/// </summary>
public sealed class BattleAdvance
{
/// <summary>
/// 本次推进产生的有序行动记录列表。
/// </summary>
public readonly IReadOnlyList<BattleActionRecord> Records;
/// <summary>
/// 推进结束后的只读战斗快照。
/// </summary>
public readonly BattleSnapshot Snapshot;
/// <summary>
/// 流程状态。
/// </summary>
public readonly BattleFlowState FlowState;
/// <summary>
/// 仅在 Completed 时存在,否则为 null。
/// </summary>
public readonly BattleResult Result;
public BattleAdvance(IReadOnlyList<BattleActionRecord> 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<BattleActionRecord> source)
{
BattleActionRecord[] copy = new BattleActionRecord[source.Count];
for (int i = 0; i < source.Count; i++)
{
copy[i] = source[i];
}
return copy;
}
}
}
@@ -0,0 +1,78 @@
using SepCore.Definition;
namespace SepCore.Battle
{
/// <summary>
/// 一次效果对单个目标的一层行动记录。
/// 表达行动者、行动、目标以及数值或状态变化;
/// StatusType 为 None 时表示没有状态变化。
/// </summary>
public sealed class BattleEvent
{
/// <summary>
/// 行动者。
/// </summary>
public readonly int ActorUnitId;
/// <summary>
/// 指令类型,对应配表 BattleActionType。
/// </summary>
public readonly BattleActionType CommandType;
/// <summary>
/// 攻击或技能配置 ID;逃跑为 0。
/// </summary>
public readonly int ActionConfigId;
/// <summary>
/// 效果结算的目标单位。
/// </summary>
public readonly int TargetUnitId;
/// <summary>
/// 效果结算前的 HP。
/// </summary>
public readonly int BeforeHp;
/// <summary>
/// 效果结算后的 HP。
/// </summary>
public readonly int AfterHp;
/// <summary>
/// 效果结算前的 MP。
/// </summary>
public readonly int BeforeMp;
/// <summary>
/// 效果结算后的 MP。
/// </summary>
public readonly int AfterMp;
/// <summary>
/// 施加或刷新的状态类型;无状态变化时为 None。
/// </summary>
public readonly BattleStatusType StatusType;
/// <summary>
/// 变化后的剩余持续次数(行动机会);状态被移除时为 0。
/// </summary>
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;
}
}
}
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 84f740882a72430ca27c4d77004257d8
guid: 9bd0c7df032194911a2aee0942338d48
MonoImporter:
externalObjects: {}
serializedVersion: 2
@@ -1,18 +0,0 @@
namespace SepCore.Battle
{
/// <summary>
/// 战斗流程状态。
/// </summary>
public enum BattleFlowState
{
/// <summary>
/// 等待当前玩家行动者提交指令。
/// </summary>
AwaitingPlayerCommand,
/// <summary>
/// 战斗完成,结果已产出。
/// </summary>
Completed,
}
}
@@ -1,80 +0,0 @@
namespace SepCore.Battle
{
/// <summary>
/// 玩家输入快照,是进入本场战斗时的值快照。
/// 装备计算由单局角色快照构建器完成;战斗内核不读取角色存档、装备栏或背包。
/// </summary>
public sealed class BattlePlayerInput
{
/// <summary>
/// 角色配置标识;同时用于把结果回写到单局角色状态。
/// </summary>
public readonly int CharacterId;
/// <summary>
/// 当前战备中的顺序。
/// </summary>
public readonly int PartyOrder;
/// <summary>
/// 单局临时状态中的当前 HP。
/// </summary>
public readonly int CurrentHp;
/// <summary>
/// 单局临时状态中的当前 MP。
/// </summary>
public readonly int CurrentMp;
/// <summary>
/// 已结算装备加成的最终 HP 上限。
/// </summary>
public readonly int MaxHp;
/// <summary>
/// 已结算装备加成的最终 MP 上限。
/// </summary>
public readonly int MaxMp;
/// <summary>
/// 已结算装备加成的最终攻击。
/// </summary>
public readonly int Atk;
/// <summary>
/// 已结算装备加成的最终魔力。
/// </summary>
public readonly int Mat;
/// <summary>
/// 已结算装备加成的最终速度。
/// </summary>
public readonly int Speed;
/// <summary>
/// 普通攻击行动配置 ID。
/// </summary>
public readonly int AttackActionId;
/// <summary>
/// 该角色首版唯一技能行动配置 ID。
/// </summary>
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;
}
}
}
@@ -4,7 +4,7 @@ namespace SepCore.Battle
{
/// <summary>
/// 每名参战玩家的战后结果。
/// 保留原始战斗值:阵亡者 HP 为 0;恢复 1/1 的规则由 RunBattleCoordinator 统一应用。
/// 保留原始战斗值:阵亡者 HP 为 0;恢复 1/1 的规则由 TurnBattleComponent 统一应用。
/// </summary>
public sealed class BattlePlayerResult
{
@@ -1,82 +0,0 @@
using System.Collections.Generic;
namespace SepCore.Battle
{
/// <summary>
/// 探索恢复计划,由 RunBattleCoordinator 根据战斗结果统一生成,探索层只执行计划。
/// 探索层不得再次根据玩家 HP 或逃跑数量重判战斗结果。
/// </summary>
public sealed class BattleReturnPlan
{
/// <summary>
/// 关联原地图敌人实例。
/// </summary>
public readonly int EncounterId;
/// <summary>
/// 战斗结果。
/// </summary>
public readonly BattleOutcome Outcome;
/// <summary>
/// 回写后的玩家临时状态(含 1/1 恢复规则应用后的结果)。
/// </summary>
public readonly IReadOnlyList<BattlePlayerResult> Players;
/// <summary>
/// 是否移除触发敌人(胜利)。
/// </summary>
public readonly bool RemoveEncounterEnemy;
/// <summary>
/// 是否保留并恢复触发敌人,下次按完整预设重建(逃跑类结果)。
/// </summary>
public readonly bool ResetEncounterEnemy;
/// <summary>
/// 是否使用本局随机源结算掉落(胜利)。
/// </summary>
public readonly bool ShouldRollDrops;
/// <summary>
/// 逃跑类结果的保护时间(毫秒);其他结果为 0。
/// </summary>
public readonly int ProtectionMs;
/// <summary>
/// 是否恢复探索更新。
/// </summary>
public readonly bool ShouldResumeExploration;
/// <summary>
/// 是否以单局失败结束,不返回探索。
/// </summary>
public readonly bool EndsRunAsDefeated;
public BattleReturnPlan(int encounterId, BattleOutcome outcome,
IReadOnlyList<BattlePlayerResult> 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<BattlePlayerResult> source)
{
BattlePlayerResult[] copy = new BattlePlayerResult[source.Count];
for (int i = 0; i < source.Count; i++)
{
copy[i] = source[i];
}
return copy;
}
}
}
@@ -0,0 +1,565 @@
using System.Collections.Generic;
using SepCore.CustomComponent;
using SepCore.Definition;
namespace SepCore.Battle
{
/// <summary>
/// 当前战斗的事实唯一来源,仅战斗期间存在。
/// 调度、指令校验、效果、状态和敌人决策共享同一实例,不复制单位、轮次和状态集合;
/// 内部不为函数调用建立请求/响应 DTO,玩家与敌人的行动都走同一条执行管线并产生同一组 BattleEvent。
/// M1 只支持 1 名玩家与 1 名敌人的普通攻击闭环。
/// </summary>
internal sealed class BattleRuntime
{
/// <summary>
/// 触发本场战斗的遭遇 ID,用于结果回写关联地图敌人。
/// </summary>
public readonly int EncounterId;
/// <summary>
/// 本场全部单位,创建顺序即稳定显示顺序(玩家在前)。
/// </summary>
public readonly BattleUnit[] Units;
/// <summary>
/// 是否为本场先制战斗(只影响第一轮;M1 未启用)。
/// </summary>
public readonly bool IsPreemptive;
/// <summary>
/// 当前轮次,从 1 开始。
/// </summary>
public int RoundNumber = 1;
/// <summary>
/// 当前行动者;无行动者或战斗完成时为 0。
/// </summary>
public int CurrentActorUnitId;
/// <summary>
/// 本轮已获得行动机会的单位 ID。
/// </summary>
public readonly HashSet<int> ActedUnitIds = new HashSet<int>();
/// <summary>
/// 本次推进产生的待展示行动记录,推进结束时被 BattleStep 取走。
/// </summary>
public readonly List<BattleEvent> PendingEvents = new List<BattleEvent>();
/// <summary>
/// 战斗结束后的结果;非 null 即表示战斗已完成。
/// </summary>
public BattleResult Result;
private readonly IRunRandomSource _random;
private readonly IBattleConfigProvider _config;
/// <summary>
/// 是否已完成。
/// </summary>
public bool IsCompleted => Result != null;
/// <summary>
/// 当前行动者单位;无行动者时返回 null。
/// </summary>
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;
}
/// <summary>
/// 从遭遇、单局玩家状态、配置和本局随机源创建唯一战斗运行时。
/// 任一必要输入缺失或配置缺失时返回 null,表示启动失败且不产生任何副作用。
/// </summary>
public static BattleRuntime Create(BattleEncounter encounter, IReadOnlyList<RunPlayerState> 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<BattleUnit> units = new List<BattleUnit>(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;
}
/// <summary>
/// 按运行时 ID 获取单位;不存在时返回 null。
/// </summary>
public BattleUnit GetUnit(int unitId)
{
foreach (BattleUnit unit in Units)
{
if (unit.UnitId == unitId)
{
return unit;
}
}
return null;
}
/// <summary>
/// 单位是否仍在战斗中(未阵亡且未逃跑)。
/// </summary>
public static bool IsActive(BattleUnit unit)
{
return unit != null && !unit.IsDefeated && !unit.IsEscaped;
}
/// <summary>
/// 校验并执行当前玩家指令(单次行动)。
/// 非法指令不消耗 MP、行动机会或随机数;玩家行动后仅选择下一个行动者,
/// 不自动推进敌人回合——间歇节奏由组件在外部编排。
/// </summary>
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);
}
/// <summary>
/// 推进当前敌人行动者的自动回合(单次行动)。
/// 当前行动者不是敌人、没有行动者或战斗已完成时返回无变化步骤;
/// 与玩家指令共用同一执行管线并产生同一组 BattleEvent。
/// </summary>
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);
}
/// <summary>
/// 构建当前只读视图。
/// </summary>
public BattleViewState BuildViewState()
{
List<BattleUnitView> unitViews = new List<BattleUnitView>(Units.Length);
foreach (BattleUnit unit in Units)
{
List<BattleStatusView> statusViews = new List<BattleStatusView>(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<int> remainingOrder = new List<int>();
if (!IsCompleted)
{
foreach (BattleUnit unit in Units)
{
if (IsActive(unit) && !ActedUnitIds.Contains(unit.UnitId))
{
remainingOrder.Add(unit.UnitId);
}
}
}
List<int> availableActions = new List<int>();
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<int> 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);
}
/// <summary>
/// 选择本轮下一个未行动的活跃单位;全部行动过则进入下一轮。
/// M1 按单位创建顺序(玩家在前)轮流,每轮每个单位最多行动一次;
/// M2 起由完整调度规则(速度、阵营、队伍顺序、先制)替换。
/// </summary>
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;
}
/// <summary>
/// 敌人随机决策并执行:从 MP 足够的行动中等概率选择,单体行动从合法目标中等概率选择;
/// 没有可执行行动或合法目标时跳过本次机会。
/// </summary>
private void ExecuteEnemyTurn(BattleUnit enemy)
{
List<int> usableActions = new List<int>();
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<int> targets = new List<int>();
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 });
}
/// <summary>
/// 等概率选一;只有一项时直接返回,不消费随机数。
/// </summary>
private int PickOne(IReadOnlyList<int> 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<BattlePlayerResult> players = new List<BattlePlayerResult>();
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<BattleEvent> DrainEvents()
{
List<BattleEvent> drained = new List<BattleEvent>(PendingEvents);
PendingEvents.Clear();
return drained;
}
}
}
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: ec4ca0d279034f4a939fd35aedab383e
guid: 11f1778a3f4e34da691bcac2656ed3a6
MonoImporter:
externalObjects: {}
serializedVersion: 2
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: c5bfa0a211d34ba6874d7d0b276192a3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,58 +0,0 @@
using System.Collections.Generic;
using SepCore.CustomComponent;
namespace SepCore.Battle
{
/// <summary>
/// 战斗启动请求,创建后视为只读。
/// 战斗开始失败时不得消耗随机数、修改单局状态或暂停状态计数。
/// </summary>
public sealed class BattleStartRequest
{
/// <summary>
/// 用于将结果关联回地图敌人实例。
/// </summary>
public readonly int EncounterId;
/// <summary>
/// 1 到 4 个玩家输入,保持战备顺序。
/// </summary>
public readonly IReadOnlyList<BattlePlayerInput> Players;
/// <summary>
/// 用于按队伍预设创建 1 到 4 个敌人运行时单位。
/// </summary>
public readonly int EnemyPartyConfigId;
/// <summary>
/// 是否启用第一轮玩家先制。
/// </summary>
public readonly bool IsPreemptive;
/// <summary>
/// 当前单局唯一的随机源实例,来自 RandomComponent,不创建战斗私有随机源。
/// </summary>
public readonly IRunRandomSource Random;
public BattleStartRequest(int encounterId, IReadOnlyList<BattlePlayerInput> 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<BattlePlayerInput> source)
{
BattlePlayerInput[] copy = new BattlePlayerInput[source.Count];
for (int i = 0; i < source.Count; i++)
{
copy[i] = source[i];
}
return copy;
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 040ffec92b064e3fbd9d9a8db9b39291
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,27 @@
using SepCore.Definition;
namespace SepCore.Battle
{
/// <summary>
/// 单场战斗中的状态,只存在于 BattleRuntime。
/// 同类状态不叠层,重复施加保留较长的剩余行动机会次数。
/// </summary>
internal sealed class BattleStatus
{
/// <summary>
/// 状态类型。
/// </summary>
public BattleStatusType Type;
/// <summary>
/// 剩余持续次数(行动机会)。
/// </summary>
public int RemainingRounds;
public BattleStatus(BattleStatusType type, int remainingRounds)
{
Type = type;
RemainingRounds = remainingRounds;
}
}
}
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: bef66af163534a79b4b14ba27ed80665
guid: 5d551f82833074a5fa669f05ba7249eb
MonoImporter:
externalObjects: {}
serializedVersion: 2
@@ -0,0 +1,44 @@
using System.Collections.Generic;
namespace SepCore.Battle
{
/// <summary>
/// 一次同步推进的返回值,是 UI 读取行动记录与最新视图的唯一来源。
/// 不保存独立流程状态:Result 非 null 即表示战斗已完成。
/// </summary>
public sealed class BattleStep
{
/// <summary>
/// 本次推进产生的有序行动记录列表。
/// </summary>
public readonly IReadOnlyList<BattleEvent> Events;
/// <summary>
/// 推进结束后的只读战斗视图。
/// </summary>
public readonly BattleViewState View;
/// <summary>
/// 仅在战斗完成时存在,否则为 null。
/// </summary>
public readonly BattleResult Result;
public BattleStep(IReadOnlyList<BattleEvent> events, BattleViewState view, BattleResult result)
{
Events = events != null ? Copy(events) : new BattleEvent[0];
View = view;
Result = result;
}
private static BattleEvent[] Copy(IReadOnlyList<BattleEvent> source)
{
BattleEvent[] copy = new BattleEvent[source.Count];
for (int i = 0; i < source.Count; i++)
{
copy[i] = source[i];
}
return copy;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 993a7eab17ef14d69b2ca67b770c05da
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,103 @@
using System.Collections.Generic;
using SepCore.Definition;
namespace SepCore.Battle
{
/// <summary>
/// 单场战斗的运行时单位,仅 BattleRuntime 及其协作者读写。
/// UI 与探索层通过 BattleUnitView 读取,不直接持有本类型。
/// </summary>
internal sealed class BattleUnit
{
/// <summary>
/// 本场战斗单位的唯一运行时标识;重复配置的敌人拥有不同 ID。
/// </summary>
public readonly int UnitId;
/// <summary>
/// 阵营。
/// </summary>
public readonly BattleFaction Faction;
/// <summary>
/// 配置标识:玩家为 CharacterId,敌人为 EnemyConfigId。
/// </summary>
public readonly int ConfigId;
/// <summary>
/// 同阵营同速度的最终并列规则顺序。
/// </summary>
public readonly int PartyOrder;
/// <summary>
/// HP 上限。
/// </summary>
public readonly int MaxHp;
/// <summary>
/// MP 上限。
/// </summary>
public readonly int MaxMp;
/// <summary>
/// 攻击。
/// </summary>
public readonly int Atk;
/// <summary>
/// 魔力。
/// </summary>
public readonly int Mat;
/// <summary>
/// 当前 HP。
/// </summary>
public int CurrentHp;
/// <summary>
/// 当前 MP。
/// </summary>
public int CurrentMp;
/// <summary>
/// 当前速度;可被战斗内效果修改。
/// </summary>
public int Speed;
/// <summary>
/// 是否阵亡。
/// </summary>
public bool IsDefeated;
/// <summary>
/// 是否已逃跑。
/// </summary>
public bool IsEscaped;
/// <summary>
/// 当前剩余状态。
/// </summary>
public readonly List<BattleStatus> Statuses = new List<BattleStatus>();
/// <summary>
/// 该单位可用的行动配置 ID。
/// </summary>
public readonly List<int> ActionIds = new List<int>();
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;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 794735c8736d646c19d9275131444dce
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -4,9 +4,9 @@ using SepCore.Definition;
namespace SepCore.Battle
{
/// <summary>
/// 单位剩余状态快照。
/// 单个战斗状态的只读视图。
/// </summary>
public sealed class BattleStatusSnapshot
public sealed class BattleStatusView
{
/// <summary>
/// 状态类型。
@@ -18,7 +18,7 @@ namespace SepCore.Battle
/// </summary>
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
/// <summary>
/// 单个战斗单位的只读视图。
/// 内部调度与效果结算不得读取本视图;视图只在外部边界按需生成。
/// </summary>
public sealed class BattleUnitSnapshot
public sealed class BattleUnitView
{
/// <summary>
/// 本场战斗单位的唯一运行时标识;重复配置的敌人拥有不同 ID。
@@ -46,7 +47,7 @@ namespace SepCore.Battle
public readonly int ConfigId;
/// <summary>
/// 同阵营同速度的最终并列规则顺序。
/// 同阵营同速度的最终并列规则顺序,也是显示顺序。
/// </summary>
public readonly int PartyOrder;
@@ -70,16 +71,6 @@ namespace SepCore.Battle
/// </summary>
public readonly int MaxMp;
/// <summary>
/// 攻击。
/// </summary>
public readonly int Atk;
/// <summary>
/// 魔力。
/// </summary>
public readonly int Mat;
/// <summary>
/// 当前速度。
/// </summary>
@@ -96,19 +87,13 @@ namespace SepCore.Battle
public readonly bool IsEscaped;
/// <summary>
/// 剩余状态列表。
/// 当前剩余状态列表。
/// </summary>
public readonly IReadOnlyList<BattleStatusSnapshot> Statuses;
public readonly IReadOnlyList<BattleStatusView> Statuses;
/// <summary>
/// 可用行动配置 ID 列表。
/// </summary>
public readonly IReadOnlyList<int> 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<BattleStatusSnapshot> statuses,
IReadOnlyList<int> 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<BattleStatusView> 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<BattleStatusSnapshot> source)
private static BattleStatusView[] Copy(IReadOnlyList<BattleStatusView> 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<int> 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
}
/// <summary>
/// 只读战斗快照,是 UI 和敌人策略唯一允许读取的战斗状态。
/// 每次推进都创建逻辑上的只读快照,不把可修改的内部列表或战斗单位引用暴露给调用方。
/// 当前战斗的只读视图,是 UI 唯一允许读取的战斗状态。
/// 每次推进后在外部边界生成,不把内部可变对象暴露给 UI 或探索层。
/// </summary>
public sealed class BattleSnapshot
public sealed class BattleViewState
{
/// <summary>
/// 当前轮次,从 1 开始。
/// </summary>
public readonly int RoundNumber;
/// <summary>
/// 当前是否为先制战斗第一轮。
/// </summary>
public readonly bool IsPreemptiveRound;
/// <summary>
/// 当前行动者;战斗完成时为 0。
/// </summary>
@@ -174,7 +140,7 @@ namespace SepCore.Battle
/// <summary>
/// 全部单位的只读视图,保持稳定显示顺序。
/// </summary>
public readonly IReadOnlyList<BattleUnitSnapshot> Units;
public readonly IReadOnlyList<BattleUnitView> Units;
/// <summary>
/// 本轮从当前行动者开始的剩余运行时单位 ID;速度变化后重建。
@@ -182,25 +148,24 @@ namespace SepCore.Battle
public readonly IReadOnlyList<int> RemainingTurnOrder;
/// <summary>
/// 流程状态。
/// 当前玩家可用的行动配置 ID;当前行动者不是玩家或战斗完成时为空。
/// </summary>
public readonly BattleFlowState FlowState;
public readonly IReadOnlyList<int> AvailableActionIds;
public BattleSnapshot(int roundNumber, bool isPreemptiveRound, int currentActorUnitId,
IReadOnlyList<BattleUnitSnapshot> units, IReadOnlyList<int> remainingTurnOrder,
BattleFlowState flowState)
public BattleViewState(int roundNumber, int currentActorUnitId,
IReadOnlyList<BattleUnitView> units, IReadOnlyList<int> remainingTurnOrder,
IReadOnlyList<int> 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<BattleUnitSnapshot> source)
private static BattleUnitView[] Copy(IReadOnlyList<BattleUnitView> 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;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6be1facf77c6042c0be34019eb5f255e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,20 +0,0 @@
using SepCore.CustomComponent;
namespace SepCore.Battle
{
/// <summary>
/// 敌人策略接口。
/// 策略只读取快照并返回指令,不直接修改 BattleSession。
/// 当前没有可执行行动时,本次行动机会记为跳过,不能形成无限循环。
/// </summary>
public interface IEnemyBattlePolicy
{
/// <summary>
/// 为当前敌人行动者决定指令。
/// </summary>
/// <param name="snapshot">只读战斗快照。</param>
/// <param name="actorUnitId">当前获得行动机会的敌人单位。</param>
/// <param name="random">本局共享随机源。</param>
BattleCommand Decide(BattleSnapshot snapshot, int actorUnitId, IRunRandomSource random);
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: e803cf69950a4fb9b0b8d63603f99a4b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,16 +0,0 @@
namespace SepCore.Battle
{
/// <summary>
/// 探索层恢复接口。
/// 碰撞方主动把 BattleEncounter 交给 RunBattleCoordinator;协调器不扫描场景寻找敌人。
/// ApplyBattleReturn 必须按 EncounterId 精确操作触发敌人,不能按 EnemyConfigId 批量处理同类敌人。
/// </summary>
public interface IExplorationBattleBridge
{
/// <summary>
/// 执行探索恢复计划。
/// </summary>
/// <param name="plan">由 RunBattleCoordinator 生成的恢复计划。</param>
void ApplyBattleReturn(BattleReturnPlan plan);
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: cf022ae8498b418784037549ce415e3a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,45 @@
using SepCore.CustomComponent;
using SepCore.Definition;
namespace SepCore.Battle
{
/// <summary>
/// 战斗配置的运行时适配器,隔离战斗规则与 GameEntry.Luban。
/// 配置缺失返回 null,由启动校验决定失败;正常行动不重复扫描全表完整性。
/// EditMode 测试使用内存配置,不使用本类。
/// </summary>
internal sealed class LubanBattleConfigProvider : IBattleConfigProvider
{
public EnemyPartyConfig GetEnemyParty(int id)
{
return SafeGet(() => GameEntry.Luban.Get<EnemyPartyConfig>(id));
}
public EnemyConfig GetEnemy(int id)
{
return SafeGet(() => GameEntry.Luban.Get<EnemyConfig>(id));
}
public BattleActionConfig GetAction(int id)
{
return SafeGet(() => GameEntry.Luban.Get<BattleActionConfig>(id));
}
public GlobalConfig GetGlobal()
{
return SafeGet(() => GameEntry.Luban.Global != null ? GameEntry.Luban.Global.Data : null);
}
private static T SafeGet<T>(System.Func<T> getter) where T : class
{
try
{
return GameEntry.Luban != null && GameEntry.Luban.IsReady ? getter() : null;
}
catch (System.Exception)
{
return null;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 50fd8ca181e304ffc8bc130beabf9177
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,112 +0,0 @@
using System.Collections.Generic;
using SepCore.Battle;
using SepCore.Definition;
using SepCore.UI;
using UnityGameFramework.Runtime;
namespace SepCore.CustomComponent
{
/// <summary>
/// 单局战斗协调器(普通类,不挂场景)。
/// 负责从单局状态构建请求、预留战斗占用、暂停/恢复探索更新与计时、打开/关闭战斗 UI;
/// 后续里程碑在此创建并校验 BattleController,并消费 BattleResult 生成 BattleReturnPlan。
/// 不负责回合排序、效果计算和 UI 控件状态。
/// </summary>
public sealed class RunBattleCoordinator
{
/// <summary>
/// 尝试开始一场战斗。
/// 战斗占用已激活时拒绝;请求构建失败时释放占用且不暂停地图。
/// </summary>
/// <param name="encounter">探索层创建的遭遇输入。</param>
/// <returns>是否成功开始。</returns>
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;
}
/// <summary>
/// 关闭调试壳层并恢复探索更新与单局计时。
/// M0 调试入口专用;M7 起由 ApplyResult 按战斗结果统一回写与恢复。
/// </summary>
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.");
}
/// <summary>
/// 从单局临时角色状态与共享随机源构建启动请求。
/// 请求创建后视为只读;构建失败返回 null,不消耗随机数。
/// </summary>
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<BattlePlayerInput> players = new List<BattlePlayerInput>();
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);
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 0146e261d98d40109183900a1d033fc9
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
{
/// <summary>
/// 单局战斗组件。
/// 负责本局临时角色状态、计时、探索暂停标志和战斗占用的最小状态。
/// 单局战斗组件,也是战斗模块的唯一外部入口。
/// 负责本局临时角色状态、计时、探索暂停标志、战斗占用和当前唯一的 BattleRuntime。
/// 外部通过 TryStartBattle 启动战斗、CloseBattle 关闭调试战斗;
/// 战斗规则(调度、指令校验、效果、敌人决策)在内部共享同一个 BattleRuntime,
/// 不为模块内部的函数调用建立请求/响应 DTO。
/// 本局种子与共享随机源由独立的 RandomComponent 持有,战斗只沿用,不创建私有随机源。
/// 战斗协调(构建请求、打开 UI、回写结果)由独立的 RunBattleCoordinator 负责。
/// </summary>
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<BattleResult> _onCompleted;
private Action<BattleStep> _stepListener;
private Coroutine _autoAdvanceRoutine;
/// <summary>
/// 敌人自动行动前的间歇时间(秒),用于让 UI 展示行动过程。
/// </summary>
private const float AutoAdvanceDelaySeconds = 0.6f;
/// <summary>
/// 获取本局临时角色状态列表(只读)。
@@ -45,16 +61,17 @@ namespace SepCore.CustomComponent
/// </summary>
public bool IsBattleActive => _battleActive;
/// <summary>
/// 获取当前唯一的战斗运行时;仅战斗期间非 null。
/// </summary>
internal BattleRuntime Runtime => _runtime;
/// <summary>
/// 开始一局新的单局:重置全部临时状态。
/// </summary>
public void BeginRun()
{
_players.Clear();
_elapsedMs = 0;
_timerPaused = false;
_explorationPaused = false;
_battleActive = false;
ResetRunState();
}
/// <summary>
@@ -62,11 +79,7 @@ namespace SepCore.CustomComponent
/// </summary>
public void EndRun()
{
_players.Clear();
_elapsedMs = 0;
_timerPaused = false;
_explorationPaused = false;
_battleActive = false;
ResetRunState();
}
/// <summary>
@@ -83,26 +96,142 @@ namespace SepCore.CustomComponent
}
/// <summary>
/// 预留战斗占用,拒绝同一帧或未结束战斗的重复进入。
/// 尝试开始一场战斗,是战斗模块的唯一外部入口。
/// 校验通过后预留战斗占用、暂停探索更新与单局计时并打开 BattleForm;
/// 校验失败不消耗随机数、不修改单局状态。
/// 战斗完成时通过 onCompleted 一次性回调返回 BattleResult;调试入口可不提供回调。
/// </summary>
/// <returns>预留成功返回 true;已存在战斗占用时返回 false。</returns>
public bool TryReserveBattle()
/// <param name="encounter">探索层创建的遭遇输入。</param>
/// <param name="onCompleted">战斗结束的一次性完成回调,可为 null。</param>
/// <returns>是否成功开始。</returns>
public bool TryStartBattle(BattleEncounter encounter, Action<BattleResult> 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;
}
/// <summary>
/// 释放战斗占用。
/// 提交当前玩家指令并同步推进单次行动,返回本次推进的行动记录、最新视图和可能的最终结果。
/// 非法指令不消耗行动机会;玩家行动后若轮到敌人,由组件按间歇节奏自动推进敌人回合,
/// 每一步通过已注册的推进监听推送给 UI。
/// 战斗完成后向 onCompleted 一次性回调返回 BattleResult。
/// </summary>
public void ReleaseBattle()
/// <param name="command">当前玩家行动者的指令。</param>
/// <returns>本次玩家行动的推进结果;当前没有进行中的战斗时返回 null。</returns>
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<BattleResult> callback = _onCompleted;
_onCompleted = null;
callback?.Invoke(step.Result);
}
else
{
ScheduleAutoAdvance();
}
return step;
}
/// <summary>
/// 注册战斗推进监听;UI 通过它接收组件自动推进(敌人行动)产生的每一步。
/// </summary>
/// <param name="listener">推进监听,传入 null 取消注册。</param>
public void SetStepListener(Action<BattleStep> listener)
{
_stepListener = listener;
}
/// <summary>
/// 获取当前战斗的只读视图;没有进行中的战斗时返回 null。
/// </summary>
public BattleViewState GetViewState()
{
return _runtime != null ? _runtime.BuildViewState() : null;
}
/// <summary>
/// 关闭当前战斗壳层并恢复探索更新与单局计时。
/// M0 调试入口专用;M5 起由战斗结果统一回写单局状态后关闭。
/// </summary>
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.");
}
/// <summary>
@@ -132,5 +261,90 @@ namespace SepCore.CustomComponent
_elapsedMs += Time.deltaTime * 1000f;
}
/// <summary>
/// 当前行动者是敌人且战斗未结束时,启动带间歇的自动推进协程。
/// </summary>
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<BattleResult> callback = _onCompleted;
_onCompleted = null;
callback?.Invoke(step.Result);
}
_stepListener?.Invoke(step);
}
_autoAdvanceRoutine = null;
}
private void StopAutoAdvance()
{
if (_autoAdvanceRoutine != null)
{
StopCoroutine(_autoAdvanceRoutine);
_autoAdvanceRoutine = null;
}
}
/// <summary>
/// 非 TotalDefeat 结果把玩家战后 HP/MP 回写到单局临时状态。
/// 首版保留原始战斗值;M5 按结束矩阵统一应用阵亡恢复 1/1 等规则。
/// </summary>
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;
}
}
}
@@ -11,7 +11,7 @@ namespace SepCore.Debugger
/// <summary>
/// 战斗壳层调试入口(仅供 Editor/Development Build,正式构建不注册)。
/// 模拟探索层:缺少单局状态时用配表角色与固定种子初始化临时状态,
/// 然后通过 RunBattleCoordinator 以固定调试遭遇开始/结束战斗,不依赖尚未实现的地图敌人。
/// 然后通过 TurnBattleComponent 以固定调试遭遇开始/结束战斗,不依赖尚未实现的地图敌人。
/// </summary>
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<CharacterConfig>(characterId);
if (config == null)
{