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:
Binary file not shown.
@@ -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"
|
||||
}
|
||||
]
|
||||
Binary file not shown.
@@ -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",
|
||||
|
||||
@@ -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<int>(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
|
||||
/// </summary>
|
||||
public readonly int DropTableId;
|
||||
/// <summary>
|
||||
/// 图标资源配置
|
||||
/// </summary>
|
||||
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 + ","
|
||||
+ "}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +241,6 @@ namespace SepCore.Editor
|
||||
i == 0 ? Ink : MutedWhite, TextAlignmentOptions.Center, FontStyles.Bold);
|
||||
|
||||
BattleTurnSlotItem slot = slotRect.gameObject.AddComponent<BattleTurnSlotItem>();
|
||||
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<BattleActorCardItem>();
|
||||
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<BattleEnemySlotItem>();
|
||||
slot.ConfigureEditor(targetButton, selected.gameObject, icon, hpFill, name, hpText);
|
||||
RegisterReference(slot, "enemySlot" + (i + 1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Tests")]
|
||||
+1
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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:
|
||||
+28
-63
@@ -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)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 68a0e1b7c828a496d858968e77f3826b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2d55ddba8b14246bcb3c84719d8ef2b1
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ff9bdccc0a8fa4a70a750d29c757252a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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<int> { 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<int> { 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<int>()));
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5e6abbbfbf1d9431ea5cfe74917ee817
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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<int> { 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<int> { 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<RunPlayerState> { player }, config, random));
|
||||
Assert.Null(BattleRuntime.Create(encounter, null, config, random));
|
||||
Assert.Null(BattleRuntime.Create(encounter, new List<RunPlayerState>(), config, random));
|
||||
Assert.Null(BattleRuntime.Create(encounter, new List<RunPlayerState> { player }, null, random));
|
||||
Assert.Null(BattleRuntime.Create(encounter, new List<RunPlayerState> { player }, config, null));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Create_WithMissingEnemyParty_ReturnsNull()
|
||||
{
|
||||
TestConfigProvider config = TestConfigProvider.Standard1v1();
|
||||
|
||||
Assert.Null(BattleRuntime.Create(new BattleEncounter(1, 999, false),
|
||||
new List<RunPlayerState> { 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<RunPlayerState> { TestBattle.Player() }, config, new TestRandomSource()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6f72e4b9e477543aea96022278d94914
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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<string> recordsA = CollectRecords(runtimeA);
|
||||
List<string> recordsB = CollectRecords(runtimeB);
|
||||
|
||||
CollectionAssert.AreEqual(recordsA, recordsB);
|
||||
}
|
||||
|
||||
private static List<string> CollectRecords(BattleRuntime runtime)
|
||||
{
|
||||
List<string> records = new List<string>();
|
||||
int guard = 0;
|
||||
while (runtime.Result == null && guard++ < 200)
|
||||
{
|
||||
BattleUnit actor = runtime.CurrentActor;
|
||||
BattleStep step;
|
||||
if (actor != null && actor.Faction == BattleFaction.Player)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8d0eb453c4b42453abb2e86fad636fe9
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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<int> { 1, 2 }, view.RemainingTurnOrder);
|
||||
CollectionAssert.AreEqual(new List<int> { 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<int> { 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c78ea02415ec84677ae1925546f846c5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 用反射构造 Luban 生成的只读配置行,供测试的假配置提供者使用。
|
||||
/// </summary>
|
||||
internal static class TestConfigFactory
|
||||
{
|
||||
public static T Create<T>(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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 与真实配表数值对齐的测试配置构建器。
|
||||
/// </summary>
|
||||
internal static class TestConfigs
|
||||
{
|
||||
/// <summary>
|
||||
/// 单体敌方攻击行动。
|
||||
/// </summary>
|
||||
public static BattleActionConfig AttackAction(int id, int flat, BattleStatType sourceStat,
|
||||
int sourceScalePermille, int mpCost = 0)
|
||||
{
|
||||
return TestConfigFactory.Create<BattleActionConfig>(
|
||||
"Id", id, "Name", "攻击" + id, "ActionType", BattleActionType.Attack,
|
||||
"TargetType", BattleTargetType.SingleEnemy, "MpCost", mpCost,
|
||||
"Effects", new List<BattleEffect>
|
||||
{
|
||||
TestConfigFactory.Create<BattleEffect>(
|
||||
"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<EnemyConfig>(
|
||||
"Id", id, "Name", "敌人" + id, "MaxHp", maxHp, "MaxMp", 0, "Atk", atk, "Mat", 0,
|
||||
"Speed", 8, "ThreatLevelId", 1, "AiType", EnemyAiType.Random,
|
||||
"ActionIds", new List<int>(actionIds), "DropTableId", 0);
|
||||
}
|
||||
|
||||
public static EnemyPartyConfig Party(int id, params int[] enemyIds)
|
||||
{
|
||||
return TestConfigFactory.Create<EnemyPartyConfig>(
|
||||
"Id", id, "Name", "队伍" + id, "EnemyIds", new List<int>(enemyIds));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 内存配置提供者:EditMode 使用,不依赖 Luban 与 GameEntry。
|
||||
/// </summary>
|
||||
internal sealed class TestConfigProvider : IBattleConfigProvider
|
||||
{
|
||||
private readonly Dictionary<int, EnemyPartyConfig> _parties = new Dictionary<int, EnemyPartyConfig>();
|
||||
private readonly Dictionary<int, EnemyConfig> _enemies = new Dictionary<int, EnemyConfig>();
|
||||
private readonly Dictionary<int, BattleActionConfig> _actions = new Dictionary<int, BattleActionConfig>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 标准 1v1:行动 1(玩家攻击,伤害 = ATK)、行动 201(敌人攻击,伤害 = ATK)、
|
||||
/// 敌人 3001(50 HP,ATK 8,行动 201)、队伍 4001(单个 3001)。
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 可注入序列的测试随机源;用尽后返回 minInclusive。
|
||||
/// </summary>
|
||||
internal sealed class TestRandomSource : IRunRandomSource
|
||||
{
|
||||
private readonly Queue<int> _values = new Queue<int>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 标准测试输入与指令构造。
|
||||
/// </summary>
|
||||
internal static class TestBattle
|
||||
{
|
||||
/// <summary>
|
||||
/// 标准玩家 1001:120 HP / 40 MP,ATK 14,攻击行动 1,技能行动 101。
|
||||
/// </summary>
|
||||
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<int> { targetUnitId });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建标准 1v1 运行时。
|
||||
/// </summary>
|
||||
public static BattleRuntime Create1v1(RunPlayerState player, IBattleConfigProvider config = null,
|
||||
IRunRandomSource random = null)
|
||||
{
|
||||
return BattleRuntime.Create(Encounter(), new List<RunPlayerState> { player },
|
||||
config ?? TestConfigProvider.Standard1v1(), random ?? new TestRandomSource());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 12a6461a326b54f55bdfa3630c77e175
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Tests",
|
||||
"optionalUnityReferences": [
|
||||
"TestAssemblies"
|
||||
],
|
||||
"references": [
|
||||
"GUID:11ec41dbc3557e6499b2845ba531a9b0",
|
||||
"GUID:a04b272484018094ab427253f20c416c",
|
||||
"GUID:363c5eb08ff8e6a439b85e37b8c20d96"
|
||||
],
|
||||
"includePlatforms": [
|
||||
"Editor"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6ca799cc4b05645b1842927808f15538
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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)
|
||||
/// <summary>
|
||||
/// 用战斗单位视图填充我方卡片:名称、HP/MP 数值与血条、当前行动者标记和配置图标。
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步加载角色图标;iconVersion 用于防止复用格子时旧加载结果覆盖新内容。
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
/// <summary>
|
||||
/// 用战斗单位视图填充敌人槽:名称、HP 数值与血条、配置图标和当前行动者标记。
|
||||
/// 阵亡或已逃跑目标不可选。
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步加载敌人图标;iconVersion 用于防止复用格子时旧加载结果覆盖新内容。
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,24 @@
|
||||
using System.Collections.Generic;
|
||||
using SepCore.Battle;
|
||||
using SepCore.Definition;
|
||||
using UnityEngine;
|
||||
using UnityGameFramework.Runtime;
|
||||
|
||||
namespace SepCore.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 战斗界面逻辑(手写 partial,与自动生成的 BattleForm.cs 合并)。
|
||||
/// 首版为半透明壳层:只负责展示快照、收集指令与占位反馈;
|
||||
/// 不修改战斗数据、不读取存档、不决定掉落,也不承担暂停地图计时等流程职责。
|
||||
/// 暂停地图更新由单局流程(RunBattleCoordinator / 探索层)负责。
|
||||
/// UI 只读取 BattleViewState / BattleStep / BattleResult,不持有 BattleRuntime 或 BattleUnit;
|
||||
/// 玩家指令经 GameEntry.TurnBattle.SubmitCommand 提交,推进结果统一回填界面。
|
||||
/// 我方卡片按玩家单位数显示/隐藏;敌人槽和回合顺序槽按当前战局从模板动态实例化。
|
||||
/// </summary>
|
||||
public partial class BattleForm : UGuiForm
|
||||
{
|
||||
private BattleResult _result;
|
||||
private int _displayedRound;
|
||||
private readonly List<BattleTurnSlotItem> _turnSlots = new List<BattleTurnSlotItem>();
|
||||
private readonly List<int> _turnSlotUnitIds = new List<int>();
|
||||
|
||||
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<int> { 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新一轮开始时重建本轮完整顺序:全部活跃单位按显示顺序排列,
|
||||
/// 当前行动者高亮;本轮内已行动单位保持可见。
|
||||
/// </summary>
|
||||
private void RebuildTurnSlots(BattleViewState view, BattleTurnSlotItem template)
|
||||
{
|
||||
template.gameObject.SetActive(false);
|
||||
ClearSlots(View.turnSlotsRoot, template.transform);
|
||||
_turnSlots.Clear();
|
||||
_turnSlotUnitIds.Clear();
|
||||
|
||||
foreach (BattleUnitView unit in view.Units)
|
||||
{
|
||||
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<BattleActionConfig>(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 接入
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
/// <summary>
|
||||
/// 填充一个回合顺序槽:单位显示名、配置图标与当前行动者标记。
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 只更新当前行动者标记,不改变显示名。
|
||||
/// 同轮内单位行动后由 Logic 逐槽移动高亮。
|
||||
/// </summary>
|
||||
public void SetCurrentActor(bool isCurrentActor)
|
||||
{
|
||||
if (activeMarker != null)
|
||||
{
|
||||
activeMarker.SetActive(isCurrentActor);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步加载单位图标(玩家/敌人分别走各自配置);iconVersion 防止旧加载覆盖新内容。
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using SepCore.Battle;
|
||||
using SepCore.Definition;
|
||||
|
||||
namespace SepCore.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 战斗单位视图的显示辅助:名称与图标解析。
|
||||
/// 玩家图标来自 CharacterConfig.Icon_Ref,敌人图标来自 EnemyConfig.Icon_Ref。
|
||||
/// </summary>
|
||||
public static class BattleUnitViewHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取单位显示名;配置缺失时回退为配置 ID。
|
||||
/// </summary>
|
||||
public static string GetDisplayName(BattleUnitView unit)
|
||||
{
|
||||
if (unit == null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (unit.Faction == BattleFaction.Player)
|
||||
{
|
||||
CharacterConfig config = GameEntry.Luban.Get<CharacterConfig>(unit.ConfigId);
|
||||
return config != null ? config.Name : unit.ConfigId.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
EnemyConfig config = GameEntry.Luban.Get<EnemyConfig>(unit.ConfigId);
|
||||
return config != null ? config.Name : unit.ConfigId.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取玩家单位图标配置;配置缺失时返回 null。
|
||||
/// </summary>
|
||||
public static SpriteConfig GetPlayerIconConfig(int characterId)
|
||||
{
|
||||
CharacterConfig config = GameEntry.Luban.Get<CharacterConfig>(characterId);
|
||||
return config != null ? config.Icon_Ref : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取敌人单位图标配置;配置缺失时返回 null。
|
||||
/// </summary>
|
||||
public static SpriteConfig GetEnemyIconConfig(int enemyId)
|
||||
{
|
||||
EnemyConfig config = GameEntry.Luban.Get<EnemyConfig>(enemyId);
|
||||
return config != null ? config.Icon_Ref : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: baad0253f7a15458f818a57c66db4939
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user