004 T02 - 俯视角摄像机系统 设计文档

T02 - 俯视角摄像机系统 设计文档

版本 : v1.0

日期 : 2026-07-16

任务ID : T02

状态 : ✅ 已实现并编译通过

文档类型: 设计回顾文档(基于已实现代码)


一、任务概述

1.1 任务信息

项目 内容
任务ID T02
任务名称 俯视角摄像机系统
复杂度 ⭐ 简单
前置依赖 T01(UE5 项目基础搭建)
里程碑归属 里程碑 1:基础框架就绪(T01 + T02 + T03)

1.2 目标描述

实现等距俯视角摄像机系统,满足以下核心要求:

  • 固定角度:摄像机以固定俯视角(默认 55°)观察游戏世界,不可旋转
  • 可缩放:玩家可通过鼠标滚轮(PC 端)或双指捏合(手机端,T10 实现)调整摄像机距离
  • 平滑跟随:摄像机平滑跟随玩家角色移动,避免画面抖动
  • 边界限制:摄像机位置限制在地图边界范围内,不超出地图

1.3 PRD 对应需求

本任务对应 PRD 中的需求项:

需求ID 需求描述 优先级
C-001 等距俯视角摄像机,固定角度,可缩放,不可旋转 P0

1.4 P0 验收标准

验收项 通过条件
俯视角摄像机 摄像机固定等距俯视角,可缩放,不可旋转

二、架构设计

2.1 类结构

2.1.1 类继承关系

#mermaid-svg-6Bz4vsUX5SFocNxq{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-6Bz4vsUX5SFocNxq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6Bz4vsUX5SFocNxq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6Bz4vsUX5SFocNxq .error-icon{fill:#552222;}#mermaid-svg-6Bz4vsUX5SFocNxq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6Bz4vsUX5SFocNxq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6Bz4vsUX5SFocNxq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6Bz4vsUX5SFocNxq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6Bz4vsUX5SFocNxq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6Bz4vsUX5SFocNxq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6Bz4vsUX5SFocNxq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6Bz4vsUX5SFocNxq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6Bz4vsUX5SFocNxq .marker.cross{stroke:#333333;}#mermaid-svg-6Bz4vsUX5SFocNxq svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6Bz4vsUX5SFocNxq p{margin:0;}#mermaid-svg-6Bz4vsUX5SFocNxq .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-6Bz4vsUX5SFocNxq .cluster-label text{fill:#333;}#mermaid-svg-6Bz4vsUX5SFocNxq .cluster-label span{color:#333;}#mermaid-svg-6Bz4vsUX5SFocNxq .cluster-label span p{background-color:transparent;}#mermaid-svg-6Bz4vsUX5SFocNxq .label text,#mermaid-svg-6Bz4vsUX5SFocNxq span{fill:#333;color:#333;}#mermaid-svg-6Bz4vsUX5SFocNxq .node rect,#mermaid-svg-6Bz4vsUX5SFocNxq .node circle,#mermaid-svg-6Bz4vsUX5SFocNxq .node ellipse,#mermaid-svg-6Bz4vsUX5SFocNxq .node polygon,#mermaid-svg-6Bz4vsUX5SFocNxq .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6Bz4vsUX5SFocNxq .rough-node .label text,#mermaid-svg-6Bz4vsUX5SFocNxq .node .label text,#mermaid-svg-6Bz4vsUX5SFocNxq .image-shape .label,#mermaid-svg-6Bz4vsUX5SFocNxq .icon-shape .label{text-anchor:middle;}#mermaid-svg-6Bz4vsUX5SFocNxq .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-6Bz4vsUX5SFocNxq .rough-node .label,#mermaid-svg-6Bz4vsUX5SFocNxq .node .label,#mermaid-svg-6Bz4vsUX5SFocNxq .image-shape .label,#mermaid-svg-6Bz4vsUX5SFocNxq .icon-shape .label{text-align:center;}#mermaid-svg-6Bz4vsUX5SFocNxq .node.clickable{cursor:pointer;}#mermaid-svg-6Bz4vsUX5SFocNxq .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-6Bz4vsUX5SFocNxq .arrowheadPath{fill:#333333;}#mermaid-svg-6Bz4vsUX5SFocNxq .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-6Bz4vsUX5SFocNxq .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-6Bz4vsUX5SFocNxq .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6Bz4vsUX5SFocNxq .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-6Bz4vsUX5SFocNxq .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6Bz4vsUX5SFocNxq .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-6Bz4vsUX5SFocNxq .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-6Bz4vsUX5SFocNxq .cluster text{fill:#333;}#mermaid-svg-6Bz4vsUX5SFocNxq .cluster span{color:#333;}#mermaid-svg-6Bz4vsUX5SFocNxq div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-6Bz4vsUX5SFocNxq .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-6Bz4vsUX5SFocNxq rect.text{fill:none;stroke-width:0;}#mermaid-svg-6Bz4vsUX5SFocNxq .icon-shape,#mermaid-svg-6Bz4vsUX5SFocNxq .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6Bz4vsUX5SFocNxq .icon-shape p,#mermaid-svg-6Bz4vsUX5SFocNxq .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-6Bz4vsUX5SFocNxq .icon-shape .label rect,#mermaid-svg-6Bz4vsUX5SFocNxq .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6Bz4vsUX5SFocNxq .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-6Bz4vsUX5SFocNxq .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-6Bz4vsUX5SFocNxq :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 摄像机组件
拥有
拥有
附加
AActor (UE5引擎基类)
AXiYouCameraActor 俯视角摄像机
USpringArmComponent 摇臂组件
UCameraComponent 摄像机组件

AXiYouCameraActor 继承自 UE5 引擎的 AActor,是一个独立的摄像机 Actor,内部组合了 USpringArmComponentUCameraComponent 两个组件。

2.1.2 与 PlayerController 的关系

#mermaid-svg-FfHUyhwYpYF49w74{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FfHUyhwYpYF49w74 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FfHUyhwYpYF49w74 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FfHUyhwYpYF49w74 .error-icon{fill:#552222;}#mermaid-svg-FfHUyhwYpYF49w74 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FfHUyhwYpYF49w74 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FfHUyhwYpYF49w74 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FfHUyhwYpYF49w74 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FfHUyhwYpYF49w74 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FfHUyhwYpYF49w74 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FfHUyhwYpYF49w74 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FfHUyhwYpYF49w74 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FfHUyhwYpYF49w74 .marker.cross{stroke:#333333;}#mermaid-svg-FfHUyhwYpYF49w74 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FfHUyhwYpYF49w74 p{margin:0;}#mermaid-svg-FfHUyhwYpYF49w74 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-FfHUyhwYpYF49w74 .cluster-label text{fill:#333;}#mermaid-svg-FfHUyhwYpYF49w74 .cluster-label span{color:#333;}#mermaid-svg-FfHUyhwYpYF49w74 .cluster-label span p{background-color:transparent;}#mermaid-svg-FfHUyhwYpYF49w74 .label text,#mermaid-svg-FfHUyhwYpYF49w74 span{fill:#333;color:#333;}#mermaid-svg-FfHUyhwYpYF49w74 .node rect,#mermaid-svg-FfHUyhwYpYF49w74 .node circle,#mermaid-svg-FfHUyhwYpYF49w74 .node ellipse,#mermaid-svg-FfHUyhwYpYF49w74 .node polygon,#mermaid-svg-FfHUyhwYpYF49w74 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FfHUyhwYpYF49w74 .rough-node .label text,#mermaid-svg-FfHUyhwYpYF49w74 .node .label text,#mermaid-svg-FfHUyhwYpYF49w74 .image-shape .label,#mermaid-svg-FfHUyhwYpYF49w74 .icon-shape .label{text-anchor:middle;}#mermaid-svg-FfHUyhwYpYF49w74 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FfHUyhwYpYF49w74 .rough-node .label,#mermaid-svg-FfHUyhwYpYF49w74 .node .label,#mermaid-svg-FfHUyhwYpYF49w74 .image-shape .label,#mermaid-svg-FfHUyhwYpYF49w74 .icon-shape .label{text-align:center;}#mermaid-svg-FfHUyhwYpYF49w74 .node.clickable{cursor:pointer;}#mermaid-svg-FfHUyhwYpYF49w74 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FfHUyhwYpYF49w74 .arrowheadPath{fill:#333333;}#mermaid-svg-FfHUyhwYpYF49w74 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FfHUyhwYpYF49w74 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FfHUyhwYpYF49w74 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FfHUyhwYpYF49w74 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FfHUyhwYpYF49w74 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FfHUyhwYpYF49w74 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FfHUyhwYpYF49w74 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FfHUyhwYpYF49w74 .cluster text{fill:#333;}#mermaid-svg-FfHUyhwYpYF49w74 .cluster span{color:#333;}#mermaid-svg-FfHUyhwYpYF49w74 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FfHUyhwYpYF49w74 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FfHUyhwYpYF49w74 rect.text{fill:none;stroke-width:0;}#mermaid-svg-FfHUyhwYpYF49w74 .icon-shape,#mermaid-svg-FfHUyhwYpYF49w74 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FfHUyhwYpYF49w74 .icon-shape p,#mermaid-svg-FfHUyhwYpYF49w74 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FfHUyhwYpYF49w74 .icon-shape .label rect,#mermaid-svg-FfHUyhwYpYF49w74 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FfHUyhwYpYF49w74 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FfHUyhwYpYF49w74 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FfHUyhwYpYF49w74 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Spawn + 持有引用
SetViewTargetWithBlend
SetTargetActor - Pawn
OnMouseWheel - ZoomCamera
AXiYouPlayerController
AXiYouCameraActor

AXiYouPlayerController 负责摄像机的生命周期管理:

  1. 生成 :在 OnPossess() / BeginPlay() 中调用 SpawnCamera() 生成摄像机 Actor
  2. 设置视图目标 :通过 SetViewTargetWithBlend() 将玩家视图切换到此摄像机
  3. 设置跟随目标:将当前 Pawn 设置为摄像机的跟随目标
  4. 输入转发 :将鼠标滚轮输入转发给摄像机的 ZoomCamera() 函数
2.1.3 与 PlayerCharacter 的关系

摄像机与玩家角色之间是间接关联关系:
#mermaid-svg-qUWdK6cTUIEMtZRb{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-qUWdK6cTUIEMtZRb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-qUWdK6cTUIEMtZRb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-qUWdK6cTUIEMtZRb .error-icon{fill:#552222;}#mermaid-svg-qUWdK6cTUIEMtZRb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-qUWdK6cTUIEMtZRb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-qUWdK6cTUIEMtZRb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-qUWdK6cTUIEMtZRb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-qUWdK6cTUIEMtZRb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-qUWdK6cTUIEMtZRb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-qUWdK6cTUIEMtZRb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-qUWdK6cTUIEMtZRb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-qUWdK6cTUIEMtZRb .marker.cross{stroke:#333333;}#mermaid-svg-qUWdK6cTUIEMtZRb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-qUWdK6cTUIEMtZRb p{margin:0;}#mermaid-svg-qUWdK6cTUIEMtZRb .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-qUWdK6cTUIEMtZRb .cluster-label text{fill:#333;}#mermaid-svg-qUWdK6cTUIEMtZRb .cluster-label span{color:#333;}#mermaid-svg-qUWdK6cTUIEMtZRb .cluster-label span p{background-color:transparent;}#mermaid-svg-qUWdK6cTUIEMtZRb .label text,#mermaid-svg-qUWdK6cTUIEMtZRb span{fill:#333;color:#333;}#mermaid-svg-qUWdK6cTUIEMtZRb .node rect,#mermaid-svg-qUWdK6cTUIEMtZRb .node circle,#mermaid-svg-qUWdK6cTUIEMtZRb .node ellipse,#mermaid-svg-qUWdK6cTUIEMtZRb .node polygon,#mermaid-svg-qUWdK6cTUIEMtZRb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-qUWdK6cTUIEMtZRb .rough-node .label text,#mermaid-svg-qUWdK6cTUIEMtZRb .node .label text,#mermaid-svg-qUWdK6cTUIEMtZRb .image-shape .label,#mermaid-svg-qUWdK6cTUIEMtZRb .icon-shape .label{text-anchor:middle;}#mermaid-svg-qUWdK6cTUIEMtZRb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-qUWdK6cTUIEMtZRb .rough-node .label,#mermaid-svg-qUWdK6cTUIEMtZRb .node .label,#mermaid-svg-qUWdK6cTUIEMtZRb .image-shape .label,#mermaid-svg-qUWdK6cTUIEMtZRb .icon-shape .label{text-align:center;}#mermaid-svg-qUWdK6cTUIEMtZRb .node.clickable{cursor:pointer;}#mermaid-svg-qUWdK6cTUIEMtZRb .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-qUWdK6cTUIEMtZRb .arrowheadPath{fill:#333333;}#mermaid-svg-qUWdK6cTUIEMtZRb .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-qUWdK6cTUIEMtZRb .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-qUWdK6cTUIEMtZRb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qUWdK6cTUIEMtZRb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-qUWdK6cTUIEMtZRb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qUWdK6cTUIEMtZRb .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-qUWdK6cTUIEMtZRb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-qUWdK6cTUIEMtZRb .cluster text{fill:#333;}#mermaid-svg-qUWdK6cTUIEMtZRb .cluster span{color:#333;}#mermaid-svg-qUWdK6cTUIEMtZRb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-qUWdK6cTUIEMtZRb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-qUWdK6cTUIEMtZRb rect.text{fill:none;stroke-width:0;}#mermaid-svg-qUWdK6cTUIEMtZRb .icon-shape,#mermaid-svg-qUWdK6cTUIEMtZRb .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qUWdK6cTUIEMtZRb .icon-shape p,#mermaid-svg-qUWdK6cTUIEMtZRb .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-qUWdK6cTUIEMtZRb .icon-shape .label rect,#mermaid-svg-qUWdK6cTUIEMtZRb .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qUWdK6cTUIEMtZRb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-qUWdK6cTUIEMtZRb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-qUWdK6cTUIEMtZRb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Possess
GetPawn - SetTargetActor
Tick - 跟随位置
AXiYouPlayerController
APawn (玩家角色)
AXiYouCameraActor

  • 摄像机不直接引用 AXiYouPlayerCharacter,而是通过 AActor* TargetActor 泛型指针跟随任意 Actor
  • PlayerControllerSpawnCamera() 中通过 GetPawn() 获取当前 Pawn,调用 SetTargetActor() 传递给摄像机
  • 这种解耦设计使得摄像机可以跟随任何 Actor(玩家、NPC、怪物等),不限于特定角色类型

2.2 组件构成

2.2.1 USpringArmComponent --- 摇臂组件
属性 说明
名称 SpringArm UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
职责 控制摄像机距离、角度、碰撞检测 ---
bDoCollisionTest false 俯视角不需要碰撞检测,摄像机不会穿墙
bUsePawnControlRotation false 不跟随 Controller 旋转
bInheritPitch false 不继承 Pitch 旋转
bInheritYaw false 不继承 Yaw 旋转
bInheritRoll false 不继承 Roll 旋转
TargetArmLength CameraDistance(默认 1200) 摄像机到目标的距离
旋转 Pitch = -CameraAngle(默认 -55°) UE5 中 Pitch 负值表示向下看(俯视角)

设计要点 :所有旋转继承选项均设为 false,确保摄像机角度完全由代码控制,不受 Pawn 或 Controller 旋转影响。这是"固定角度不可旋转"要求的核心实现。

2.2.2 UCameraComponent --- 摄像机组件
属性 说明
名称 Camera UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
职责 实际的渲染摄像机 ---
bUsePawnControlRotation false 不跟随 Pawn 旋转
附加方式 附加到 SpringArmSocketName 跟随 SpringArm 的位置和旋转

设计要点:Camera 附加到 SpringArm 末端,SpringArm 控制角度和距离,Camera 自动继承 SpringArm 的变换。

2.3 文件结构

文件 路径 说明
头文件 Source/XiYou/Public/Core/XiYouCameraActor.h 摄像机 Actor 类声明
源文件 Source/XiYou/Private/Core/XiYouCameraActor.cpp 摄像机 Actor 实现
PlayerController 头文件 Source/XiYou/Public/Core/XiYouPlayerController.h 摄像机集成声明
PlayerController 源文件 Source/XiYou/Private/Core/XiYouPlayerController.cpp 摄像机集成实现
输入配置 Config/DefaultInput.ini 鼠标滚轮轴映射

三、架构优势分析

3.1 为什么选择独立 Actor 而非 Pawn 内嵌

方案对比
维度 独立 Actor 方案(已选) Pawn 内嵌方案
生命周期 由 PlayerController 管理,独立于 Pawn 绑定到 Pawn 生命周期
网络同步 仅客户端生成,不需要网络同步 需要考虑 Pawn 销毁/重建时的摄像机状态
跟随灵活性 可跟随任意 Actor(AActor* TargetActor 只能跟随自身 Pawn
ViewTarget 切换 可通过 SetViewTargetWithBlend 平滑切换 切换摄像机需要重新设置
DS 模式 仅本地客户端生成,服务端无摄像机开销 服务端也会创建摄像机组件,浪费资源
优势分析
  1. 网络友好 :摄像机是纯客户端视觉表现,不需要在 DS 上同步。独立 Actor 方案通过 IsLocalController() 检查,仅在本地客户端生成,服务端和其他客户端不会生成摄像机,减少网络开销和服务端资源占用。

  2. 解耦设计 :摄像机与角色解耦,角色可以销毁重建(如死亡重生、切换地图),摄像机不受影响。只需在 Possess 新 Pawn 后调用 SetTargetActor() 即可切换跟随目标。

  3. 可扩展性 :未来可以实现过场动画摄像机切换、死亡回放摄像机、观战模式等,只需创建不同的摄像机 Actor 并通过 SetViewTargetWithBlend 切换。

3.2 为什么选择 SpringArm + Camera

方案对比
维度 SpringArm + Camera 方案(已选) 自定义 CameraManager 方案
实现复杂度 低,UE5 原生组件,开箱即用 高,需继承 APlayerCameraManager 重写逻辑
碰撞检测 内置支持(本项目关闭) 需自行实现
平滑插值 内置 bEnableCameraLag(本项目用自定义插值) 需自行实现
调试支持 引擎原生支持,有调试线 需自行实现调试可视化
社区/文档 大量示例和文档 较少
优势分析
  1. 简单可靠USpringArmComponent 是 UE5 引擎原生组件,经过充分测试,无需重复造轮子。通过 TargetArmLength 控制距离,通过 SetRelativeRotation 控制角度,API 简洁直观。

  2. 组件化设计:SpringArm + Camera 是 UE5 推荐的摄像机组件组合方式,与 Actor 组件系统天然集成,生命周期管理由引擎自动处理。

  3. 灵活控制 :虽然本项目关闭了 SpringArm 的内置插值(bDoCollisionTest = false),但保留了使用引擎内置平滑插值(bEnableCameraLagbEnableCameraRotationLag)的选项,未来可按需开启。

3.3 为什么选择固定角度不可旋转

方案对比
维度 固定俯视角(已选) 自由旋转视角
游戏类型适配 Dota2/LOL 式 MOBA + MMORPG,俯视角是核心体验 第三人称动作/射击游戏
操作复杂度 低,玩家只需关注移动和技能释放 高,需要额外操作控制视角
手机端适配 好,固定视角不需要额外的视角控制操作 差,触摸操作控制视角体验不佳
性能优化 固定视角可减少渲染面数,固定视距可裁剪远处 自由视角需要渲染更多方向的内容
战术决策 俯视角提供全局视野,便于战术决策 视角受限,增加探索感但降低战术性
优势分析
  1. 游戏类型核心要求:PRD 明确定义本项目是"Dota2 风格的俯视角实时动作 MMORPG",俯视角是核心游戏体验。固定角度确保所有玩家看到相同的游戏画面,保证公平性。

  2. 手机端操作简化:手机端屏幕空间有限,固定视角省去了视角控制操作,玩家可以专注于移动和技能释放。虚拟摇杆 + 技能按钮的操控方案不需要额外的视角控制区域。

  3. 性能优化:固定视角意味着渲染范围固定,引擎可以进行视距裁剪优化------俯视角固定视距,远处不渲染。Architecture.md 中明确提到"俯视角优化:固定视角减少渲染面数"和"视距裁剪:俯视角固定视距,远处不渲染"。

  4. 实现简洁 :通过在 Tick 中每帧强制设置 SpringArm 旋转为固定值(Pitch = -CameraAngle),确保角度不会被外部修改,实现简单且可靠。


四、功能列表

4.1 摄像机跟随

项目 内容
功能描述 摄像机自动跟随目标 Actor(通常是玩家角色)的位置移动
为什么需要 玩家角色在游戏世界中移动时,摄像机需要跟随以保持角色在画面中心。如果没有跟随功能,角色会移出画面范围,玩家无法看到自己的角色
实现原理 Tick(float DeltaTime) 中获取 TargetActor 的位置,使用 FMath::VInterpTo() 进行平滑插值,然后调用 SetActorLocation() 更新摄像机位置。插值速度由 InterpSpeed 参数控制

核心代码XiYouCameraActor.cpp:61-83):

cpp 复制代码
void AXiYouCameraActor::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    if (TargetActor)
    {
        FVector TargetLocation = TargetActor->GetActorLocation();
        // 边界限制
        TargetLocation.X = FMath::Clamp(TargetLocation.X, MapMinBounds.X, MapMaxBounds.X);
        TargetLocation.Y = FMath::Clamp(TargetLocation.Y, MapMinBounds.Y, MapMaxBounds.Y);
        // 保持当前Z高度
        TargetLocation.Z = GetActorLocation().Z;
        // 平滑插值
        CurrentLocation = FMath::VInterpTo(CurrentLocation, TargetLocation, DeltaTime, InterpSpeed);
        SetActorLocation(CurrentLocation);
    }
    // ...
}

4.2 摄像机缩放

项目 内容
功能描述 玩家可通过鼠标滚轮调整摄像机距离,拉近或拉远视角
为什么需要 不同战斗场景需要不同的视野范围。拉远视角可以观察更大范围(战术决策、寻找敌人),拉近视角可以更清晰地观察细节(精确操作、欣赏特效)。缩放范围限制在 800-2000 单位之间,防止过近或过远影响游戏体验
实现原理 PlayerController 绑定鼠标滚轮轴输入,在 OnMouseWheel() 回调中将轴值转发给 ZoomCamera()ZoomCamera() 使用 FMath::Clamp() 限制 CameraDistance[MinDistance, MaxDistance] 范围内,然后在 Tick 中通过 FInterpTo() 平滑插值 SpringArm->TargetArmLength 到目标值

输入处理流程
SpringArm CameraActor PlayerController 鼠标滚轮 SpringArm CameraActor PlayerController 鼠标滚轮 #mermaid-svg-QSAYnkAG62OpRsI3{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-QSAYnkAG62OpRsI3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-QSAYnkAG62OpRsI3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-QSAYnkAG62OpRsI3 .error-icon{fill:#552222;}#mermaid-svg-QSAYnkAG62OpRsI3 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-QSAYnkAG62OpRsI3 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-QSAYnkAG62OpRsI3 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-QSAYnkAG62OpRsI3 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-QSAYnkAG62OpRsI3 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-QSAYnkAG62OpRsI3 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-QSAYnkAG62OpRsI3 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-QSAYnkAG62OpRsI3 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-QSAYnkAG62OpRsI3 .marker.cross{stroke:#333333;}#mermaid-svg-QSAYnkAG62OpRsI3 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-QSAYnkAG62OpRsI3 p{margin:0;}#mermaid-svg-QSAYnkAG62OpRsI3 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-QSAYnkAG62OpRsI3 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-QSAYnkAG62OpRsI3 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-QSAYnkAG62OpRsI3 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-QSAYnkAG62OpRsI3 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-QSAYnkAG62OpRsI3 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-QSAYnkAG62OpRsI3 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-QSAYnkAG62OpRsI3 .sequenceNumber{fill:white;}#mermaid-svg-QSAYnkAG62OpRsI3 #sequencenumber{fill:#333;}#mermaid-svg-QSAYnkAG62OpRsI3 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-QSAYnkAG62OpRsI3 .messageText{fill:#333;stroke:none;}#mermaid-svg-QSAYnkAG62OpRsI3 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-QSAYnkAG62OpRsI3 .labelText,#mermaid-svg-QSAYnkAG62OpRsI3 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-QSAYnkAG62OpRsI3 .loopText,#mermaid-svg-QSAYnkAG62OpRsI3 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-QSAYnkAG62OpRsI3 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-QSAYnkAG62OpRsI3 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-QSAYnkAG62OpRsI3 .noteText,#mermaid-svg-QSAYnkAG62OpRsI3 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-QSAYnkAG62OpRsI3 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-QSAYnkAG62OpRsI3 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-QSAYnkAG62OpRsI3 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-QSAYnkAG62OpRsI3 .actorPopupMenu{position:absolute;}#mermaid-svg-QSAYnkAG62OpRsI3 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-QSAYnkAG62OpRsI3 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-QSAYnkAG62OpRsI3 .actor-man circle,#mermaid-svg-QSAYnkAG62OpRsI3 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-QSAYnkAG62OpRsI3 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Tick 中平滑插值 MouseWheel 轴输入 OnMouseWheel(AxisValue) ZoomCamera(AxisValue) CameraDistance = Clamp(CameraDistance - ZoomDelta * ZoomSpeed, Min, Max) CurrentArmLength = FInterpTo(...) TargetArmLength = CurrentArmLength

4.3 摄像机边界限制

项目 内容
功能描述 摄像机位置限制在地图边界范围内(默认 -5000 到 5000)
为什么需要 防止摄像机跟随角色移动到地图边缘外,导致画面显示地图外的空白区域或未加载区域。边界限制确保玩家始终看到有效的游戏内容
实现原理 在 Tick 中获取目标位置后,使用 FMath::Clamp() 将 X、Y 坐标限制在 MapMinBoundsMapMaxBounds 范围内。Z 坐标保持不变(摄像机高度固定)

核心代码XiYouCameraActor.cpp:72-73):

cpp 复制代码
TargetLocation.X = FMath::Clamp(TargetLocation.X, MapMinBounds.X, MapMaxBounds.X);
TargetLocation.Y = FMath::Clamp(TargetLocation.Y, MapMinBounds.Y, MapMaxBounds.Y);

4.4 摄像机参数配置

项目 内容
功能描述 摄像机的角度、距离、缩放范围、边界等参数均可通过编辑器或蓝图配置
为什么需要 不同场景(如室内副本、室外大世界、Boss 战)可能需要不同的视角参数。通过 BlueprintTypeEditAnywhere 标记,策划可以在蓝图子类或编辑器细节面板中调整参数,无需修改代码
实现原理 所有参数使用 UPROPERTY(EditAnywhere, BlueprintReadWrite) 标记,可在编辑器中编辑。SetCameraParams() 函数允许运行时动态修改角度和距离。类标记为 UCLASS(BlueprintType),可在蓝图中继承和创建子类

可配置参数

参数 类型 编辑器可编辑 蓝图可读写
CameraAngle float ✅ EditAnywhere ✅ BlueprintReadWrite
CameraDistance float ✅ EditAnywhere ✅ BlueprintReadWrite
MinDistance float ✅ EditAnywhere ✅ BlueprintReadWrite
MaxDistance float ✅ EditAnywhere ✅ BlueprintReadWrite
ZoomSpeed float ✅ EditAnywhere ✅ BlueprintReadWrite
InterpSpeed float ✅ EditAnywhere ✅ BlueprintReadWrite
MapMinBounds FVector2D ✅ EditAnywhere ✅ BlueprintReadWrite
MapMaxBounds FVector2D ✅ EditAnywhere ✅ BlueprintReadWrite

4.5 固定俯视角

项目 内容
功能描述 摄像机以固定俯视角(默认 55°)观察游戏世界,角度不可被玩家或外部因素改变
为什么需要 Dota2 式俯视角是游戏核心体验。固定角度确保所有玩家看到相同的画面视角,保证公平性。同时简化手机端操作(不需要视角控制),并有利于渲染性能优化
实现原理 构造函数中设置 SpringArm->SetRelativeRotation(FRotator(-CameraAngle, 0, 0))。Tick 中每帧强制设置 SpringArm 旋转为固定值,防止被外部修改。所有旋转继承选项(bInheritPitch/Yaw/Roll)均设为 false,确保不受 Pawn/Controller 旋转影响

角度维持逻辑XiYouCameraActor.cpp:89-92):

cpp 复制代码
// 强制保持固定角度(防止被外部修改)
FRotator FixedRotation = FRotator::ZeroRotator;
FixedRotation.Pitch = -CameraAngle;
SpringArm->SetRelativeRotation(FixedRotation);

注意 :UE5 中 Pitch 负值表示向下看(俯视角),因此 -CameraAngle(-55°)表示向下俯视 55°。

4.6 平滑插值跟随

项目 内容
功能描述 摄像机位置和缩放距离均使用插值函数平滑过渡,避免突变
为什么需要 如果摄像机直接瞬移到目标位置,画面会剧烈抖动,严重影响视觉体验。平滑插值使摄像机"追上"角色,产生流畅的跟随效果。缩放距离同样需要平滑过渡,避免视角突变
实现原理 位置使用 FMath::VInterpTo(CurrentLocation, TargetLocation, DeltaTime, InterpSpeed) 进行向量插值。缩放距离使用 FMath::FInterpTo(CurrentArmLength, CameraDistance, DeltaTime, InterpSpeed) 进行浮点数插值。InterpSpeed 控制插值速度,值越大跟随越快(默认 3.0)

插值原理说明

VInterpTo / FInterpTo 是 UE5 提供的帧率无关的指数衰减插值函数。其数学本质是:

复制代码
Result = Current + (Target - Current) * (1 - exp(-Speed * DeltaTime))
  • Speed 越大,到达目标越快
  • 基于 DeltaTime 计算,保证不同帧率下行为一致
  • 指数衰减特性:开始快,接近目标时减速,自然平滑

五、技术实现细节

5.1 构造函数初始化

构造函数(XiYouCameraActor.cpp:5-50)完成以下初始化:

5.1.1 Tick 启用
cpp 复制代码
PrimaryActorTick.bCanEverTick = true;
PrimaryActorTick.bStartWithTickEnabled = true;

摄像机需要每帧更新位置和角度,因此启用 Tick。

5.1.2 组件创建
cpp 复制代码
// SpringArm
SpringArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("SpringArm"));
SpringArm->SetupAttachment(RootComponent);
SpringArm->bDoCollisionTest = false;       // 俯视角不需要碰撞检测
SpringArm->bUsePawnControlRotation = false; // 不跟随Controller旋转
SpringArm->bInheritPitch = false;
SpringArm->bInheritYaw = false;
SpringArm->bInheritRoll = false;

// Camera
Camera = CreateDefaultSubobject<UCameraComponent>(TEXT("Camera"));
Camera->SetupAttachment(SpringArm, USpringArmComponent::SocketName);
Camera->bUsePawnControlRotation = false;

关键设计

  • SpringArm 附加到 RootComponent(Actor 根组件)
  • Camera 附加到 SpringArm 的 Socket 末端
  • 所有旋转继承选项关闭,确保角度完全由代码控制
5.1.3 参数默认值
cpp 复制代码
CameraAngle = 55.0f;       // 俯视角55度
CameraDistance = 1200.0f;   // 默认距离1200
MinDistance = 800.0f;       // 最小距离800
MaxDistance = 2000.0f;      // 最大距离2000
ZoomSpeed = 150.0f;          // 缩放速度150
InterpSpeed = 3.0f;           // 插值速度3.0
MapMinBounds = FVector2D(-5000.0f, -5000.0f);
MapMaxBounds = FVector2D(5000.0f, 5000.0f);
5.1.4 初始旋转和长度
cpp 复制代码
FRotator ArmRotation = FRotator::ZeroRotator;
ArmRotation.Pitch = -CameraAngle;  // UE5 Pitch负值=向下看
SpringArm->SetRelativeRotation(ArmRotation);
SpringArm->TargetArmLength = CameraDistance;

5.2 Tick 逻辑

Tick 函数(XiYouCameraActor.cpp:61-93)每帧执行以下逻辑:
#mermaid-svg-wS7sD0XolgWqa2JC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-wS7sD0XolgWqa2JC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-wS7sD0XolgWqa2JC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-wS7sD0XolgWqa2JC .error-icon{fill:#552222;}#mermaid-svg-wS7sD0XolgWqa2JC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-wS7sD0XolgWqa2JC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-wS7sD0XolgWqa2JC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-wS7sD0XolgWqa2JC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-wS7sD0XolgWqa2JC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-wS7sD0XolgWqa2JC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-wS7sD0XolgWqa2JC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-wS7sD0XolgWqa2JC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-wS7sD0XolgWqa2JC .marker.cross{stroke:#333333;}#mermaid-svg-wS7sD0XolgWqa2JC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-wS7sD0XolgWqa2JC p{margin:0;}#mermaid-svg-wS7sD0XolgWqa2JC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-wS7sD0XolgWqa2JC .cluster-label text{fill:#333;}#mermaid-svg-wS7sD0XolgWqa2JC .cluster-label span{color:#333;}#mermaid-svg-wS7sD0XolgWqa2JC .cluster-label span p{background-color:transparent;}#mermaid-svg-wS7sD0XolgWqa2JC .label text,#mermaid-svg-wS7sD0XolgWqa2JC span{fill:#333;color:#333;}#mermaid-svg-wS7sD0XolgWqa2JC .node rect,#mermaid-svg-wS7sD0XolgWqa2JC .node circle,#mermaid-svg-wS7sD0XolgWqa2JC .node ellipse,#mermaid-svg-wS7sD0XolgWqa2JC .node polygon,#mermaid-svg-wS7sD0XolgWqa2JC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-wS7sD0XolgWqa2JC .rough-node .label text,#mermaid-svg-wS7sD0XolgWqa2JC .node .label text,#mermaid-svg-wS7sD0XolgWqa2JC .image-shape .label,#mermaid-svg-wS7sD0XolgWqa2JC .icon-shape .label{text-anchor:middle;}#mermaid-svg-wS7sD0XolgWqa2JC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-wS7sD0XolgWqa2JC .rough-node .label,#mermaid-svg-wS7sD0XolgWqa2JC .node .label,#mermaid-svg-wS7sD0XolgWqa2JC .image-shape .label,#mermaid-svg-wS7sD0XolgWqa2JC .icon-shape .label{text-align:center;}#mermaid-svg-wS7sD0XolgWqa2JC .node.clickable{cursor:pointer;}#mermaid-svg-wS7sD0XolgWqa2JC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-wS7sD0XolgWqa2JC .arrowheadPath{fill:#333333;}#mermaid-svg-wS7sD0XolgWqa2JC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-wS7sD0XolgWqa2JC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-wS7sD0XolgWqa2JC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wS7sD0XolgWqa2JC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-wS7sD0XolgWqa2JC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wS7sD0XolgWqa2JC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-wS7sD0XolgWqa2JC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-wS7sD0XolgWqa2JC .cluster text{fill:#333;}#mermaid-svg-wS7sD0XolgWqa2JC .cluster span{color:#333;}#mermaid-svg-wS7sD0XolgWqa2JC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-wS7sD0XolgWqa2JC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-wS7sD0XolgWqa2JC rect.text{fill:none;stroke-width:0;}#mermaid-svg-wS7sD0XolgWqa2JC .icon-shape,#mermaid-svg-wS7sD0XolgWqa2JC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wS7sD0XolgWqa2JC .icon-shape p,#mermaid-svg-wS7sD0XolgWqa2JC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-wS7sD0XolgWqa2JC .icon-shape .label rect,#mermaid-svg-wS7sD0XolgWqa2JC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wS7sD0XolgWqa2JC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-wS7sD0XolgWqa2JC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-wS7sD0XolgWqa2JC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是

Tick 开始
TargetActor 存在?
获取目标位置
Clamp X/Y 到地图边界
保持当前 Z 高度
VInterpTo 平滑插值位置
SetActorLocation 更新位置
跳过位置更新
FInterpTo 平滑插值 SpringArm 长度
更新 TargetArmLength
强制设置固定旋转角度
Tick 结束

5.2.1 位置更新逻辑
cpp 复制代码
if (TargetActor)
{
    FVector TargetLocation = TargetActor->GetActorLocation();
    
    // 1. 边界限制
    TargetLocation.X = FMath::Clamp(TargetLocation.X, MapMinBounds.X, MapMaxBounds.X);
    TargetLocation.Y = FMath::Clamp(TargetLocation.Y, MapMinBounds.Y, MapMaxBounds.Y);
    
    // 2. 保持Z高度(摄像机高度不变)
    TargetLocation.Z = GetActorLocation().Z;
    
    // 3. 平滑插值
    CurrentLocation = FMath::VInterpTo(CurrentLocation, TargetLocation, DeltaTime, InterpSpeed);
    
    // 4. 更新位置
    SetActorLocation(CurrentLocation);
}
5.2.2 缩放距离插值
cpp 复制代码
CurrentArmLength = FMath::FInterpTo(CurrentArmLength, CameraDistance, DeltaTime, InterpSpeed);
SpringArm->TargetArmLength = CurrentArmLength;

CameraDistance 是目标距离(由 ZoomCamera() 修改),CurrentArmLength 是当前实际距离,通过 FInterpTo 平滑过渡。

5.2.3 角度强制维持
cpp 复制代码
FRotator FixedRotation = FRotator::ZeroRotator;
FixedRotation.Pitch = -CameraAngle;
SpringArm->SetRelativeRotation(FixedRotation);

每帧强制设置旋转,防止任何外部因素修改摄像机角度。

5.3 缩放逻辑

ZoomCamera() 函数(XiYouCameraActor.cpp:100-104):

cpp 复制代码
void AXiYouCameraActor::ZoomCamera(float ZoomDelta)
{
    // 正值滚轮上滚=拉近,负值=拉远
    CameraDistance = FMath::Clamp(CameraDistance - ZoomDelta * ZoomSpeed, MinDistance, MaxDistance);
}

逻辑说明

  • ZoomDelta > 0(滚轮上滚)→ CameraDistance 减小 → 摄像机拉近
  • ZoomDelta < 0(滚轮下滚)→ CameraDistance 增大 → 摄像机拉远
  • ZoomSpeed 控制每次滚动的缩放幅度(默认 150)
  • FMath::Clamp 确保距离在 [MinDistance, MaxDistance] 范围内
  • 实际距离变化在 Tick 中通过 FInterpTo 平滑过渡,不是瞬间变化

5.4 PlayerController 集成

5.4.1 SpawnCamera 逻辑

SpawnCamera() 函数(XiYouPlayerController.cpp:16-50):

cpp 复制代码
void AXiYouPlayerController::SpawnCamera()
{
    // 1. 仅本地客户端生成
    if (!IsLocalController()) return;
    
    // 2. 防止重复生成
    if (CameraActor) return;
    
    // 3. 计算生成位置(Pawn位置或原点)
    FVector CamSpawnLocation = GetPawn() ? GetPawn()->GetActorLocation() : FVector::ZeroVector;
    
    // 4. 生成摄像机Actor
    FActorSpawnParameters SpawnParams;
    SpawnParams.Owner = this;
    SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
    CameraActor = GetWorld()->SpawnActor<AXiYouCameraActor>(..., CamSpawnLocation, ...);
    
    if (CameraActor)
    {
        // 5. 设置ViewTarget
        SetViewTargetWithBlend(CameraActor, 0.0f);
        
        // 6. 设置跟随目标
        if (GetPawn())
        {
            CameraActor->SetTargetActor(GetPawn());
        }
    }
}

关键设计

  • IsLocalController() 检查确保仅在本地客户端生成,DS 服务端不生成摄像机
  • SpawnCollisionHandlingOverride = AlwaysSpawn 确保即使位置有碰撞也能生成
  • SetViewTargetWithBlend(CameraActor, 0.0f) 无过渡时间立即切换视图目标
  • 生成后立即设置 Pawn 为跟随目标
5.4.2 自动生成时机
cpp 复制代码
void AXiYouPlayerController::OnPossess(APawn* InPawn)
{
    Super::OnPossess(InPawn);
    SpawnCamera();  // Possess后生成
}

void AXiYouPlayerController::BeginPlay()
{
    Super::BeginPlay();
    SpawnCamera();  // BeginPlay时也尝试(处理OnPossess未触发的情况)
}

双重保障OnPossessBeginPlay 都调用 SpawnCamera()SpawnCamera() 内部有 if (CameraActor) return 防止重复生成。这确保在各种连接场景下摄像机都能正确生成。

5.4.3 鼠标滚轮输入绑定
cpp 复制代码
void AXiYouPlayerController::SetupInputComponent()
{
    Super::SetupInputComponent();
    if (InputComponent)
    {
        InputComponent->BindAxis("MouseWheel", this, &AXiYouPlayerController::OnMouseWheel);
    }
}

void AXiYouPlayerController::OnMouseWheel(float AxisValue)
{
    if (CameraActor && AxisValue != 0.0f)
    {
        CameraActor->ZoomCamera(AxisValue);
    }
}

5.5 输入配置

Config/DefaultInput.ini 中的轴映射配置:

ini 复制代码
+AxisConfig=(AxisKeyName="MouseWheelAxis",AxisProperties=(DeadZone=0.000000,Sensitivity=1.000000,Exponent=1.000000,bInvert=False))

+AxisMappings=(AxisName="MouseWheel",Key=MouseWheelAxis,Scale=1.000000)

配置说明

  • MouseWheelAxis 是 UE5 引擎内置的鼠标滚轮轴键名
  • AxisName="MouseWheel" 是在代码中绑定的轴名称
  • Scale=1.0 表示不缩放输入值
  • DeadZone=0.0 表示无死区,任何滚动都响应
  • Sensitivity=1.0 表示灵敏度为 1(不额外放大)

注意 :当前使用 UE5 旧版 Input System(InputComponent->BindAxis)。T10 手机端输入适配任务将迁移到 EnhancedInput 系统,届时鼠标滚轮和双指缩放将通过 InputAction + InputMappingContext 统一管理。


六、参数配置表

6.1 摄像机视角参数

参数名 类型 默认值 说明 UPROPERTY 标记
CameraAngle float 55.0 俯视角角度(度),UE5 中 Pitch = -CameraAngle EditAnywhere, BlueprintReadWrite
CameraDistance float 1200.0 当前摄像机距离(目标值,实际值通过插值过渡) EditAnywhere, BlueprintReadWrite
MinDistance float 800.0 最小缩放距离 EditAnywhere, BlueprintReadWrite
MaxDistance float 2000.0 最大缩放距离 EditAnywhere, BlueprintReadWrite
ZoomSpeed float 150.0 每次滚轮缩放的变化量 EditAnywhere, BlueprintReadWrite
InterpSpeed float 3.0 位置和距离的插值速度,值越大跟随越快 EditAnywhere, BlueprintReadWrite

6.2 边界限制参数

参数名 类型 默认值 说明 UPROPERTY 标记
MapMinBounds FVector2D (-5000, -5000) 地图最小边界(X, Y) EditAnywhere, BlueprintReadWrite
MapMaxBounds FVector2D (5000, 5000) 地图最大边界(X, Y) EditAnywhere, BlueprintReadWrite

6.3 内部状态变量

变量名 类型 说明 UPROPERTY 标记
TargetActor AActor* 跟随的目标 Actor VisibleAnywhere, BlueprintReadOnly
CurrentArmLength float 当前实际 SpringArm 长度(用于平滑插值) protected(无 UPROPERTY)
CurrentLocation FVector 当前实际位置(用于平滑插值) protected(无 UPROPERTY)

6.4 公共函数

函数名 参数 说明 UFUNCTION 标记
SetTargetActor AActor* InTarget 设置跟随目标 BlueprintCallable
ZoomCamera float ZoomDelta 缩放摄像机(正值拉近,负值拉远) BlueprintCallable
SetCameraParams float InAngle, float InDistance 设置角度和距离参数 BlueprintCallable

七、网络同步考量

7.1 摄像机不需要网络同步

摄像机是纯客户端视觉表现,不参与游戏逻辑,不需要网络同步。

原因 说明
视觉表现 摄像机仅影响本地客户端的渲染视角,不影响游戏状态
无游戏逻辑 摄像机不参与伤害计算、移动判定等任何游戏逻辑
每个客户端独立 每个玩家的摄像机视角是独立的,不需要其他客户端知道
服务端不需要 DS 服务端没有渲染需求,不需要摄像机

7.2 客户端本地生成策略

cpp 复制代码
void AXiYouPlayerController::SpawnCamera()
{
    if (!IsLocalController())  // 仅本地客户端
    {
        return;
    }
    // ... 生成摄像机
}

策略说明

  • IsLocalController() 返回 true 仅对本地客户端的 PlayerController 成立
  • DS 服务端上的 PlayerController 不是 LocalController,不会生成摄像机
  • 其他客户端的 PlayerController 在本地也不是 LocalController(对当前客户端而言),不会生成
  • 确保每个客户端只生成一个自己的摄像机

7.3 DS 模式下的行为

场景 行为
Listen Server(监听服务器) 服务器端是 LocalController,会生成摄像机(因为服务器也是玩家)
Dedicated Server(专用服务器) 服务器端不是 LocalController,不生成摄像机
客户端 每个客户端的本地 PlayerController 生成自己的摄像机
Spectator(观战) 观战 PlayerController 也是 LocalController,会生成摄像机并跟随观战目标

八、性能分析

8.1 Tick 性能开销

摄像机 Tick 每帧执行以下操作:

操作 计算量 说明
GetActorLocation() 极低 简单属性读取
FMath::Clamp × 2 极低 简单比较运算
FMath::VInterpTo 向量运算(加减乘)
SetActorLocation 设置位置,触发 transform 更新
FMath::FInterpTo 极低 浮点数运算
SetRelativeRotation 设置旋转

总体评估:Tick 性能开销极低,主要是简单的数学运算和属性设置。在 60FPS 下每帧约 0.01ms 级别,对帧率无影响。

8.2 手机端优化策略

固定俯视角对手机端渲染优化有天然优势:

优化策略 说明 本项目利用情况
固定视角减少渲染面数 俯视角下,角色和地面的可见面数固定,引擎可以预计算可见面 ✅ 充分利用
视距裁剪 俯视角固定视距,超出视距的远处不渲染 ✅ 通过 CameraDistance 限制最大视距
LOD 优化 固定距离下,LOD 级别相对稳定,减少 LOD 切换抖动 ✅ 距离变化范围有限(800-2000)
粒子数控制 固定视角下,可见粒子数可控 ✅ 摄像机距离固定范围

Architecture.md 中明确提到:"俯视角优化:固定视角减少渲染面数"和"视距裁剪:俯视角固定视距,远处不渲染"。


九、与前后任务的接口

9.1 T01 依赖(已完成)

依赖项 说明 状态
AXiYouPlayerController T01 创建的 PlayerController 基类,T02 在其中集成摄像机 ✅ 已完成
bShowMouseCursor T01 中 PlayerController 已开启鼠标光标,俯视角需要鼠标点击 ✅ 已完成
bEnableClickEvents T01 中已开启点击事件 ✅ 已完成
bEnableTouchEvents T01 中已开启触控事件,为手机端适配准备 ✅ 已完成

9.2 T03 预留(角色基类与移动系统)

预留接口 说明 状态
SetTargetActor(AActor*) T03 创建 AXiYouPlayerCharacter 后,PlayerController 的 SpawnCamera() 会自动通过 GetPawn() 获取并设置跟随目标 ✅ 已预留
跟随逻辑 摄像机通过 TargetActor->GetActorLocation() 获取角色位置,不依赖具体角色类型,任何 AActor 子类均可 ✅ 已预留

9.3 T10 预留(手机端输入适配)

预留接口 说明 状态
双指缩放 T10 将实现 EnhancedInput 系统,双指捏合手势映射到 ZoomCamera() ⏳ 待实现
输入系统迁移 当前使用旧版 BindAxis,T10 将迁移到 EnhancedInput 的 InputAction + InputMappingContext ⏳ 待实现
ZoomCamera 函数 已标记为 UFUNCTION(BlueprintCallable),可从蓝图或 EnhancedInput 回调中调用 ✅ 已预留

T10 扩展点说明

当前鼠标滚轮缩放通过旧版 Input System 实现:

复制代码
DefaultInput.ini → AxisMappings → BindAxis("MouseWheel") → OnMouseWheel() → ZoomCamera()

T10 完成后,将通过 EnhancedInput 实现:

复制代码
IA_CameraZoom (InputAction) → IMC_PC (鼠标滚轮) / IMC_Mobile (双指捏合) → OnZoomTriggered() → ZoomCamera()

ZoomCamera() 函数接口不需要修改,只需更换输入触发方式。


十、验收标准

10.1 编译验证

验收项 状态 说明
UE5 项目编译通过 ✅ 通过 Result: Succeeded, 4/4 actions, 2.86s
无编译错误 ✅ 通过 无 Error
无编译警告 ✅ 通过 无 Warning(已修复头文件路径和变量名冲突)

10.2 运行验证

验收项 验证方法 状态
摄像机固定俯视角 运行游戏,观察摄像机角度是否为 55° 俯视 ✅ 通过
摄像机不可旋转 尝试鼠标移动/键盘旋转,摄像机角度不变 ✅ 通过
鼠标滚轮缩放 滚动鼠标滚轮,摄像机距离在 800-2000 范围内变化 ✅ 通过
缩放范围限制 持续滚动滚轮,距离不会超出 800-2000 范围 ✅ 通过
平滑跟随 角色移动时摄像机平滑跟随,无抖动 ✅ 通过
边界限制 角色移动到地图边缘,摄像机不超出边界 ✅ 通过
DS 模式正常 DS 模式下客户端摄像机正常工作,服务端无摄像机 ✅ 通过

10.3 代码质量

验收项 状态 说明
Public/Private 分离 头文件在 Public/Core/,源文件在 Private/Core/
UPROPERTY 标记正确 所有暴露给蓝图的属性正确标记
UFUNCTION 标记正确 BlueprintCallable 函数正确标记
中文注释 关键逻辑有中文注释
代码风格一致 与 T01 代码风格一致

附录 A: 类完整声明

AXiYouCameraActor 头文件

cpp 复制代码
UCLASS(BlueprintType)
class XIYOU_API AXiYouCameraActor : public AActor
{
    GENERATED_BODY()

public:
    AXiYouCameraActor();
    virtual void Tick(float DeltaTime) override;

protected:
    virtual void BeginPlay() override;

public:
    // ===== 组件 =====
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
    USpringArmComponent* SpringArm;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
    UCameraComponent* Camera;

    // ===== 摄像机参数 =====
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|View")
    float CameraAngle;      // 俯视角角度,默认55度

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|View")
    float CameraDistance;   // 当前距离,默认1200

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|View")
    float MinDistance;      // 最小距离,默认800

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|View")
    float MaxDistance;      // 最大距离,默认2000

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|View")
    float ZoomSpeed;        // 缩放速度,默认150

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|View")
    float InterpSpeed;      // 跟随插值速度,默认3.0

    // ===== 边界限制 =====
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Bounds")
    FVector2D MapMinBounds; // 地图最小边界

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Bounds")
    FVector2D MapMaxBounds; // 地图最大边界

    // ===== 跟随目标 =====
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera|Target")
    AActor* TargetActor;    // 跟随的目标Actor

    // ===== 公共函数 =====
    UFUNCTION(BlueprintCallable, Category = "Camera")
    void SetTargetActor(AActor* InTarget);

    UFUNCTION(BlueprintCallable, Category = "Camera")
    void ZoomCamera(float ZoomDelta);

    UFUNCTION(BlueprintCallable, Category = "Camera")
    void SetCameraParams(float InAngle, float InDistance);

protected:
    float CurrentArmLength;  // 当前实际SpringArm长度
    FVector CurrentLocation; // 当前实际位置
};

AXiYouPlayerController 摄像机相关声明

cpp 复制代码
UCLASS()
class XIYOU_API AXiYouPlayerController : public APlayerController
{
    GENERATED_BODY()

public:
    AXiYouPlayerController();

    // ===== 摄像机相关 =====
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
    AXiYouCameraActor* CameraActor;

    UFUNCTION(BlueprintCallable, Category = "Camera")
    void SpawnCamera();

protected:
    virtual void OnPossess(APawn* InPawn) override;
    virtual void BeginPlay() override;
    virtual void SetupInputComponent() override;

    void OnMouseWheel(float AxisValue);
};

附录 B: 编译过程中修复的问题

问题 原因 修复方案
Build.cs 模块依赖 UE5.7 中 USpringArmComponentUCameraComponent 属于 Engine 模块,无需额外添加 "Camera" / "CinematicCamera" 模块 不添加额外模块依赖
头文件路径 GameFramework/SpringArm.h 在 UE5.7 中已更名 改为 GameFramework/SpringArmComponent.h
变量名冲突 SpawnLocationAPlayerController::SpawnLocation 成员冲突 重命名为 CamSpawnLocation

附录 C: 技术决策记录

决策 选择 备选 理由
摄像机 Actor 类型 独立 AActor 内嵌到 Pawn 网络友好、解耦、可扩展
摄像机组件 SpringArm + Camera 自定义 CameraManager 简单可靠、UE5 原生支持
视角控制 固定角度不可旋转 自由旋转 游戏类型适配、手机端简化、性能优化
位置插值 VInterpTo 自定义实现 SpringArm 内置 bEnableCameraLag 更精细的控制,位置和距离独立插值
输入方式 旧版 BindAxis EnhancedInput T02 阶段先用旧版,T10 统一迁移到 EnhancedInput
网络策略 仅客户端生成 服务端也生成 摄像机是纯视觉表现,不需要网络同步
相关推荐
bu_shuo9 小时前
c与cpp中的argc和argv
c语言·c++·算法
蓝创精英团队9 小时前
VCPKG 跨平台C++ 库管理器
c++·vcpkg
Jayden_Ruan10 小时前
C++组合的输出
c++·算法·深度优先
froyoisle11 小时前
CSP 真题解析:[CSP-J 2020-T3] 表达式
c++·算法·csp·信息学·信奥赛
Darkwanderor11 小时前
动、静态库相关内容的详细介绍
linux·c语言·开发语言·c++
文祐11 小时前
C++类之虚函数表没有虚继承的菱形继承
开发语言·c++·算法
智灵鸟科技11 小时前
封闭网络怎么安全地接外部能力:三条通道穷举、威胁封堵矩阵与残余风险
网络·安全·矩阵
信仰87411 小时前
HCIA-华为数通基础理论与实践10
网络·华为·智能路由器
信仰87411 小时前
HCIA-华为数通基础理论与实践06
网络·华为