路线阶段:Unity 入门实战第 4 章。
本章目标:让玩家在场景里稳定完成“选目标 -> 施法 -> 结算 -> 反馈”完整链路。
学习目标
完成本章后,你应该能做到:
- 将键位输入转换为技能释放命令,而不是直接写死逻辑。
- 构建目标锁定与校验流程,避免“看起来放了,实际没生效”。
- 把施法结果反馈到 UI(冷却、失败原因、命中提示)。
- 保持输入、施法、结算三层解耦,便于后续接 WebGL 与 AI。
架构目标
本章链路固定为:
CombatInputSampler采样按键SkillCastController生成CastSkillCommandCommandBus执行命令SkillComponent完成校验与施法DamagePipeline结算CombatHud刷新反馈
输入映射(Q/W/E/R)
public struct SkillInput
{
public bool CastQ;
public bool CastW;
public bool CastE;
public bool CastR;
public bool LockTarget;
}
public sealed class CombatInputSampler
{
public SkillInput Sample()
{
SkillInput input;
input.CastQ = Input.GetKeyDown(KeyCode.Q);
input.CastW = Input.GetKeyDown(KeyCode.W);
input.CastE = Input.GetKeyDown(KeyCode.E);
input.CastR = Input.GetKeyDown(KeyCode.R);
input.LockTarget = Input.GetMouseButtonDown(1);
return input;
}
}
目标锁定系统
public sealed class TargetLockSystem
{
private readonly Camera _camera;
private readonly LayerMask _enemyMask;
private Actor _current;
public TargetLockSystem(Camera camera, LayerMask enemyMask)
{
_camera = camera;
_enemyMask = enemyMask;
}
public Actor CurrentTarget
{
get { return _current; }
}
public void TryLock(GameWorld world)
{
var ray = _camera.ScreenPointToRay(new Vector3(Screen.width * 0.5f, Screen.height * 0.5f, 0f));
RaycastHit hit;
if (!Physics.Raycast(ray, out hit, 30f, _enemyMask))
{
return;
}
var actorView = hit.collider.GetComponent<ActorView>();
if (actorView == null)
{
return;
}
var actor = world.GetActor(actorView.ActorId);
if (actor == null || actor.Hp <= 0f)
{
return;
}
_current = actor;
FoundationLog.Info("Target", "locked id=" + actor.Id);
}
public void Validate()
{
if (_current == null)
{
return;
}
if (_current.Hp <= 0f)
{
_current = null;
}
}
}
施法控制器(命令驱动)
public sealed class SkillCastController : IUpdatable
{
public int Order { get { return 120; } }
private readonly CombatInputSampler _input;
private readonly TargetLockSystem _targetLock;
private readonly CommandBus _commandBus;
private readonly CommandFactory _factory;
private readonly GameWorld _world;
public SkillCastController(
CombatInputSampler input,
TargetLockSystem targetLock,
CommandBus commandBus,
CommandFactory factory,
GameWorld world)
{
_input = input;
_targetLock = targetLock;
_commandBus = commandBus;
_factory = factory;
_world = world;
}
public void Tick(float dt, float unscaledDt)
{
_targetLock.Validate();
var sampled = _input.Sample();
if (sampled.LockTarget)
{
_targetLock.TryLock(_world);
}
var target = _targetLock.CurrentTarget;
var targetId = target == null ? 0 : target.Id;
if (sampled.CastQ)
{
EnqueueCast(1001, targetId);
}
if (sampled.CastW)
{
EnqueueCast(1002, targetId);
}
if (sampled.CastE)
{
EnqueueCast(1003, targetId);
}
if (sampled.CastR)
{
EnqueueCast(1004, targetId);
}
}
private void EnqueueCast(int skillId, int targetId)
{
var cmd = _factory.GetCastSkill(skillId, targetId);
_commandBus.Enqueue(cmd, _world.Time.Now);
}
}
CastSkillCommand(Unity 场景落地版)
public sealed class CastSkillCommand : ICommand
{
public int Sequence { get; set; }
public float Timestamp { get; set; }
public string Name { get { return "CastSkill"; } }
public int SkillId;
public int TargetId;
public bool Execute(CommandContext context)
{
var world = (GameWorld)context.Host;
var caster = world.Player;
var target = TargetId <= 0 ? null : world.GetActor(TargetId);
string reason;
var ok = caster.Skills.TryCast(caster, target, SkillId, Timestamp, out reason);
if (!ok)
{
world.EventBus.Publish("SkillCastRejected", reason);
FoundationLog.Warn("Skill", "cast reject skill=" + SkillId + " reason=" + reason);
}
else
{
world.EventBus.Publish("SkillCastAccepted", SkillId);
}
return ok;
}
public void Reset()
{
Sequence = 0;
Timestamp = 0f;
SkillId = 0;
TargetId = 0;
}
}
冷却与反馈 UI
public sealed class CombatHud : MonoBehaviour
{
[SerializeField] private SkillSlotView _q;
[SerializeField] private SkillSlotView _w;
[SerializeField] private SkillSlotView _e;
[SerializeField] private SkillSlotView _r;
[SerializeField] private TMPro.TextMeshProUGUI _message;
private SkillComponent _skills;
public void Bind(SkillComponent skills, EventBus eventBus)
{
_skills = skills;
eventBus.Subscribe("SkillCastRejected", OnCastRejected);
eventBus.Subscribe("SkillCastAccepted", OnCastAccepted);
}
private void Update()
{
if (_skills == null)
{
return;
}
_q.SetCooldown(_skills.GetCooldownRemain(1001));
_w.SetCooldown(_skills.GetCooldownRemain(1002));
_e.SetCooldown(_skills.GetCooldownRemain(1003));
_r.SetCooldown(_skills.GetCooldownRemain(1004));
}
private void OnCastRejected(object payload)
{
_message.text = "施法失败:" + payload;
}
private void OnCastAccepted(object payload)
{
_message.text = "施法成功";
}
}
与前面系统联动
- 命令系统:所有施法都走命令队列,可回放可排障。
- 技能系统:统一处理冷却/蓝耗/距离,不在输入层重复写规则。
- 伤害管线:技能生效后交给结算链,保证公式统一。
- 仇恨系统:伤害结果反哺仇恨增长。
- 状态机:
Hit/Dead状态可直接禁用施法输入。
WebGL 兼容要点
- 输入采样与施法执行解耦,方便接移动端虚拟按钮。
- HUD 更新避免每帧字符串拼接,失败消息可做节流。
- 技能图标资源统一预加载,避免 WebGL 首次施法卡顿。
验收清单
Q/W/E/R可稳定释放对应技能。- 无目标或超距时给出明确失败原因。
- 冷却 UI 与逻辑层时间一致。
- 回放同一输入序列,技能命中与失败结果一致。
常见坑
坑 1:输入层直接扣蓝和改冷却
会出现 UI 成功但逻辑失败。所有规则必须在技能层。
坑 2:目标锁定不校验死亡状态
会导致对无效目标施法失败率异常高。
坑 3:命令执行异常未反馈到 UI
玩家只看到“按了没反应”。至少要给失败原因文本或图标闪烁。
本月作业
实现“智能施法提示”功能:
- 技能可释放时边框高亮,不可释放时显示原因图标(蓝量/冷却/超距)。
- 锁定目标超距时在地面显示范围圈。
- 回放模式下 HUD 同步重现释放与失败提示。
下一章进入 Unity 入门实战 05:敌人生成与波次系统(支撑可持续战斗场景)。