6. 3D摄像机表现
2026/4/13大约 9 分钟
6. 3D摄像机表现:打造电影级观战视角
6.1 为什么 3D 摄像机很重要?
在传统象棋游戏中,摄像机永远是固定的俯视角度。但在"战魂版"中,摄像机就是你的"导演"——它决定了玩家从什么角度看棋局、什么时候给特写、什么时候拉远看全局。
电影导演思维
想象你在拍一部象棋电影:
- 开局:远景,看到整个棋盘
- 走子:中景,跟随棋子移动
- 吃子:特写!摄像机拉近,看清战斗细节
- 回合切换:摄像机旋转到对方视角
6.2 摄像机控制架构
我们使用 Pivot(旋转中心) 模式来控制摄像机:
CameraPivot (Node3D) ← 控制摄像机的位置和旋转
└── Camera3D ← 实际的摄像机,相对于 Pivot 有固定偏移为什么用 Pivot?因为这样旋转摄像机就是旋转 Pivot,摄像机围绕 Pivot 转圈,而不是绕自己转。
摄像机状态机
| 状态 | 说明 | 触发时机 |
|---|---|---|
| Overview | 俯瞰全局 | 游戏开始、菜单 |
| FollowMove | 跟随走子 | 棋子移动时 |
| BattleCloseup | 战斗特写 | 吃子时 |
| TurnTransition | 回合切换 | 换人走棋时 |
| FreeLook | 自由观看 | 玩家手动拖动 |
C#
using Godot;
public partial class CameraController : Node3D
{
// 摄像机状态
public enum CameraState
{
Overview, // 俯瞰全局
FollowMove, // 跟随走子
BattleCloseup, // 战斗特写
TurnTransition, // 回合切换
FreeLook // 自由观看
}
[Export] public float OverviewHeight { get; set; } = 15.0f;
[Export] public float OverviewDistance { get; set; } = 12.0f;
[Export] public float CloseupDistance { get; set; } = 5.0f;
[Export] public float TransitionSpeed { get; set; } = 2.0f;
private Camera3D _camera;
private CameraState _currentState = CameraState.Overview;
private Vector3 _targetPosition;
private Vector3 _targetRotation;
public override void _Ready()
{
_camera = GetNode<Camera3D>("Camera3D");
SetOverviewCamera();
}
public override void _Process(double delta)
{
switch (_currentState)
{
case CameraState.Overview:
// 全局俯瞰,不需要持续更新
break;
case CameraState.FollowMove:
SmoothMoveTo(_targetPosition, (float)delta);
break;
case CameraState.BattleCloseup:
// 战斗特写期间摄像机固定
break;
case CameraState.TurnTransition:
SmoothRotateTo(_targetRotation, (float)delta);
break;
case CameraState.FreeLook:
HandleFreeLookInput();
break;
}
}
/// <summary>
/// 设置为俯瞰全局视角
/// </summary>
public void SetOverviewCamera()
{
_currentState = CameraState.Overview;
Position = new Vector3(0, OverviewHeight, 0);
RotationDegrees = new Vector3(-90, 0, 0);
}
/// <summary>
/// 跟随走子:摄像机移向棋子目标位置
/// </summary>
public void FollowMove(Vector3 fromPos, Vector3 toPos)
{
_currentState = CameraState.FollowMove;
// 摄像机看向移动起点和终点的中间位置
_targetPosition = new Vector3(
(fromPos.X + toPos.X) / 2,
OverviewHeight * 0.7f,
(fromPos.Z + toPos.Z) / 2
);
}
/// <summary>
/// 战斗特写:摄像机拉近到吃子位置
/// </summary>
public void BattleCloseup(Vector3 battlePos)
{
_currentState = CameraState.BattleCloseup;
var tween = CreateTween();
// 拉近摄像机
Vector3 closePos = new Vector3(
battlePos.X - 2.0f,
5.0f,
battlePos.Z + 3.0f
);
tween.TweenProperty(this, "position", closePos, 0.3f)
.SetTrans(Tween.TransitionType.Sine)
.SetEase(Tween.EaseType.Out);
// 同时调整角度
Vector3 closeRot = new Vector3(-35, -20, 0);
tween.Parallel().TweenProperty(this, "rotation_degrees", closeRot, 0.3f)
.SetTrans(Tween.TransitionType.Sine)
.SetEase(Tween.EaseType.Out);
// 1.5秒后恢复俯瞰
tween.TweenInterval(1.5);
tween.TweenCallback(Callable.From(() =>
{
ReturnToOverview();
}));
}
/// <summary>
/// 回合切换:摄像机旋转180度到对方视角
/// </summary>
public void TurnTransition(bool isRedTurn)
{
_currentState = CameraState.TurnTransition;
_targetPosition = new Vector3(0, OverviewHeight, 0);
float targetY = isRedTurn ? 0 : 180;
_targetRotation = new Vector3(-90, targetY, 0);
var tween = CreateTween();
tween.TweenProperty(this, "position", _targetPosition, 0.8f)
.SetTrans(Tween.TransitionType.Sine)
.SetEase(Tween.EaseType.InOut);
tween.Parallel().TweenProperty(this, "rotation_degrees", _targetRotation, 0.8f)
.SetTrans(Tween.TransitionType.Sine)
.SetEase(Tween.EaseType.InOut);
tween.TweenCallback(Callable.From(() =>
{
_currentState = CameraState.Overview;
}));
}
/// <summary>
/// 平滑移动到目标位置
/// </summary>
private void SmoothMoveTo(Vector3 target, float delta)
{
Position = Position.Lerp(target, TransitionSpeed * delta);
// 接近目标时停止
if (Position.DistanceTo(target) < 0.1f)
{
Position = target;
}
}
/// <summary>
/// 平滑旋转到目标角度
/// </summary>
private void SmoothRotateTo(Vector3 target, float delta)
{
RotationDegrees = RotationDegrees.Lerp(target, TransitionSpeed * delta);
}
/// <summary>
/// 恢复到俯瞰视角
/// </summary>
public void ReturnToOverview()
{
var tween = CreateTween();
tween.TweenProperty(this, "position",
new Vector3(0, OverviewHeight, 0), 0.5f)
.SetTrans(Tween.TransitionType.Sine)
.SetEase(Tween.EaseType.InOut);
tween.Parallel().TweenProperty(this, "rotation_degrees",
new Vector3(-90, 0, 0), 0.5f)
.SetTrans(Tween.TransitionType.Sine)
.SetEase(Tween.EaseType.InOut);
tween.TweenCallback(Callable.From(() =>
{
_currentState = CameraState.Overview;
}));
}
/// <summary>
/// 处理自由观看模式的输入
/// </summary>
private void HandleFreeLookInput()
{
// 鼠标右键拖动旋转
if (Input.IsMouseButtonPressed(MouseButton.Right))
{
Vector2 mouseDelta = Input.GetLastMouseVelocity() * 0.002f;
RotateY(-mouseDelta.X);
}
// 鼠标滚轮缩放
if (Input.IsMouseButtonPressed(MouseButton.WheelUp))
{
_camera.Position += new Vector3(0, 0, -0.5f);
}
if (Input.IsMouseButtonPressed(MouseButton.WheelDown))
{
_camera.Position += new Vector3(0, 0, 0.5f);
}
}
}GDScript
extends Node3D
## 摄像机状态枚举
enum CameraState {
OVERVIEW, # 俯瞰全局
FOLLOW_MOVE, # 跟随走子
BATTLE_CLOSEUP, # 战斗特写
TURN_TRANSITION, # 回合切换
FREE_LOOK # 自由观看
}
@export var overview_height: float = 15.0
@export var overview_distance: float = 12.0
@export var closeup_distance: float = 5.0
@export var transition_speed: float = 2.0
var _camera: Camera3D
var _current_state: CameraState = CameraState.OVERVIEW
var _target_position: Vector3
var _target_rotation: Vector3
func _ready():
_camera = $Camera3D
set_overview_camera()
func _process(delta: float):
match _current_state:
CameraState.OVERVIEW:
pass
CameraState.FOLLOW_MOVE:
smooth_move_to(_target_position, delta)
CameraState.BATTLE_CLOSEUP:
pass
CameraState.TURN_TRANSITION:
smooth_rotate_to(_target_rotation, delta)
CameraState.FREE_LOOK:
handle_free_look_input()
## 设置为俯瞰全局视角
func set_overview_camera():
_current_state = CameraState.OVERVIEW
position = Vector3(0, overview_height, 0)
rotation_degrees = Vector3(-90, 0, 0)
## 跟随走子
func follow_move(from_pos: Vector3, to_pos: Vector3):
_current_state = CameraState.FOLLOW_MOVE
_target_position = Vector3(
(from_pos.x + to_pos.x) / 2.0,
overview_height * 0.7,
(from_pos.z + to_pos.z) / 2.0
)
## 战斗特写
func battle_closeup(battle_pos: Vector3):
_current_state = CameraState.BATTLE_CLOSEUP
var tween := create_tween()
var close_pos := Vector3(battle_pos.x - 2.0, 5.0, battle_pos.z + 3.0)
tween.tween_property(self, "position", close_pos, 0.3) \
.set_trans(Tween.TRANS_SINE) \
.set_ease(Tween.EASE_OUT)
var close_rot := Vector3(-35, -20, 0)
tween.parallel().tween_property(self, "rotation_degrees", close_rot, 0.3) \
.set_trans(Tween.TRANS_SINE) \
.set_ease(Tween.EASE_OUT)
tween.tween_interval(1.5)
tween.tween_callback(func(): return_to_overview())
## 回合切换旋转
func turn_transition(is_red_turn: bool):
_current_state = CameraState.TURN_TRANSITION
_target_position = Vector3(0, overview_height, 0)
var target_y: float = 0.0 if is_red_turn else 180.0
_target_rotation = Vector3(-90, target_y, 0)
var tween := create_tween()
tween.tween_property(self, "position", _target_position, 0.8) \
.set_trans(Tween.TRANS_SINE) \
.set_ease(Tween.EASE_IN_OUT)
tween.parallel().tween_property(self, "rotation_degrees", _target_rotation, 0.8) \
.set_trans(Tween.TRANS_SINE) \
.set_ease(Tween.EASE_IN_OUT)
tween.tween_callback(func(): _current_state = CameraState.OVERVIEW)
## 平滑移动
func smooth_move_to(target: Vector3, delta: float):
position = position.lerp(target, transition_speed * delta)
if position.distance_to(target) < 0.1:
position = target
## 平滑旋转
func smooth_rotate_to(target: Vector3, delta: float):
rotation_degrees = rotation_degrees.lerp(target, transition_speed * delta)
## 恢复俯瞰
func return_to_overview():
var tween := create_tween()
tween.tween_property(self, "position",
Vector3(0, overview_height, 0), 0.5) \
.set_trans(Tween.TRANS_SINE) \
.set_ease(Tween.EASE_IN_OUT)
tween.parallel().tween_property(self, "rotation_degrees",
Vector3(-90, 0, 0), 0.5) \
.set_trans(Tween.TRANS_SINE) \
.set_ease(Tween.EASE_IN_OUT)
tween.tween_callback(func(): _current_state = CameraState.OVERVIEW)
## 自由观看输入处理
func handle_free_look_input():
if Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT):
var mouse_delta := Input.get_last_mouse_velocity() * 0.002
rotate_y(-mouse_delta.x)6.3 走子追踪摄像机
走子追踪就是摄像机"跟着"棋子一起移动,让玩家清楚地看到棋子从哪里来、到哪里去。
追踪策略
| 策略 | 说明 | 什么时候用 |
|---|---|---|
| 居中追踪 | 摄像机看向起止点的中间 | 普通走子 |
| 终点聚焦 | 摄像机直接看终点 | 短距离移动 |
| 路径追踪 | 摄像机跟着棋子走 | 长距离移动(车、炮) |
C#
/// <summary>
/// 走子追踪摄像机的智能选择
/// </summary>
public void SmartFollowMove(Vector3 fromPos, Vector3 toPos)
{
float distance = fromPos.DistanceTo(toPos);
if (distance < 2.0f)
{
// 短距离:直接看终点
FocusOnPoint(toPos, 0.3f);
}
else if (distance < 5.0f)
{
// 中距离:看中间
Vector3 midPoint = (fromPos + toPos) / 2;
FocusOnPoint(midPoint, 0.4f);
}
else
{
// 长距离:追踪路径
PathFollow(fromPos, toPos);
}
}
/// <summary>
/// 聚焦到某个点
/// </summary>
private void FocusOnPoint(Vector3 point, float duration)
{
Vector3 cameraTarget = new Vector3(
point.X,
Position.Y,
point.Z
);
var tween = CreateTween();
tween.TweenProperty(this, "position", cameraTarget, duration)
.SetTrans(Tween.TransitionType.Sine)
.SetEase(Tween.EaseType.Out);
}
/// <summary>
/// 路径追踪(先看起点,再看终点)
/// </summary>
private void PathFollow(Vector3 from, Vector3 to)
{
var tween = CreateTween();
// 先看向起点
Vector3 fromTarget = new Vector3(from.X, Position.Y, from.Z);
tween.TweenProperty(this, "position", fromTarget, 0.2f)
.SetTrans(Tween.TransitionType.Sine);
// 再平滑移向终点
Vector3 toTarget = new Vector3(to.X, Position.Y, to.Z);
tween.TweenProperty(this, "position", toTarget, 0.5f)
.SetTrans(Tween.TransitionType.Sine)
.SetEase(Tween.EaseType.InOut);
}GDScript
## 走子追踪摄像机的智能选择
func smart_follow_move(from_pos: Vector3, to_pos: Vector3):
var dist := from_pos.distance_to(to_pos)
if dist < 2.0:
focus_on_point(to_pos, 0.3)
elif dist < 5.0:
var mid_point := (from_pos + to_pos) / 2.0
focus_on_point(mid_point, 0.4)
else:
path_follow(from_pos, to_pos)
## 聚焦到某个点
func focus_on_point(point: Vector3, duration: float):
var camera_target := Vector3(point.x, position.y, point.z)
var tween := create_tween()
tween.tween_property(self, "position", camera_target, duration) \
.set_trans(Tween.TRANS_SINE) \
.set_ease(Tween.EASE_OUT)
## 路径追踪
func path_follow(from: Vector3, to: Vector3):
var tween := create_tween()
var from_target := Vector3(from.x, position.y, from.z)
tween.tween_property(self, "position", from_target, 0.2) \
.set_trans(Tween.TRANS_SINE)
var to_target := Vector3(to.x, position.y, to.z)
tween.tween_property(self, "position", to_target, 0.5) \
.set_trans(Tween.TRANS_SINE) \
.set_ease(Tween.EASE_IN_OUT)6.4 战斗特写摄像机
吃子是战魂版最精彩的时刻。摄像机需要在极短时间内完成:拉近 → 对准 → 稳住 → 拉远。
特写角度选择
| 角度 | 描述 | 效果 |
|---|---|---|
| 正面特写 | 从攻击方身后看 | "冲锋"感 |
| 侧面特写 | 侧面45度 | 电影感 |
| 俯冲特写 | 从上方俯冲 | 史诗感 |
| 低角度 | 从下往上看 | 震撼感 |
C#
/// <summary>
/// 战斗特写:选择最佳拍摄角度
/// </summary>
public void CinematicBattleCloseup(Vector3 attackerPos, Vector3 targetPos)
{
_currentState = CameraState.BattleCloseup;
// 计算攻击方向
Vector3 attackDir = (targetPos - attackerPos).Normalized();
// 摄像机放在攻击方身后(正面特写)
Vector3 cameraPos = attackerPos - attackDir * 3.0f + Vector3.Up * 2.0f;
Vector3 lookAt = targetPos + Vector3.Up * 0.5f;
var tween = CreateTween();
// 快速拉近
tween.TweenProperty(this, "position", cameraPos, 0.25f)
.SetTrans(Tween.TransitionType.Quad)
.SetEase(Tween.EaseType.Out);
// 让摄像机看向战斗点
tween.Parallel().TweenProperty(this, "rotation_degrees",
CalculateLookAtRotation(cameraPos, lookAt), 0.25f)
.SetTrans(Tween.TransitionType.Quad)
.SetEase(Tween.EaseType.Out);
// 战斗结束后稍微晃动(冲击感)
tween.TweenCallback(Callable.From(() =>
{
MicroShake(0.2f, 0.05f);
}));
// 1.5秒后恢复
tween.TweenInterval(1.3);
tween.TweenCallback(Callable.From(() =>
{
ReturnToOverview();
}));
}
/// <summary>
/// 计算让摄像机看向目标所需的旋转角度
/// </summary>
private Vector3 CalculateLookAtRotation(Vector3 from, Vector3 to)
{
var direction = (to - from).Normalized();
float pitch = Mathf.Atan2(-direction.Y,
new Vector2(direction.X, direction.Z).Length()) * Mathf.RadToDeg(1.0f);
float yaw = Mathf.Atan2(direction.X, direction.Z) * Mathf.RadToDeg(1.0f);
return new Vector3(pitch, yaw, 0);
}
/// <summary>
/// 微震(比普通震动更轻微)
/// </summary>
private void MicroShake(float duration, float intensity)
{
var camera = GetNode<Camera3D>("Camera3D");
var originalHOffset = camera.HOffset;
var originalVOffset = camera.VOffset;
var tween = CreateTween();
for (int i = 0; i < 4; i++)
{
var offset = new Vector2(
(float)GD.RandRange(-intensity, intensity),
(float)GD.RandRange(-intensity, intensity)
);
tween.TweenProperty(camera, "h_offset", originalHOffset + offset.X, duration / 8);
tween.TweenProperty(camera, "v_offset", originalVOffset + offset.Y, duration / 8);
}
tween.TweenProperty(camera, "h_offset", originalHOffset, duration / 8);
tween.TweenProperty(camera, "v_offset", originalVOffset, duration / 8);
}GDScript
## 战斗特写:电影级拍摄角度
func cinematic_battle_closeup(attacker_pos: Vector3, target_pos: Vector3):
_current_state = CameraState.BATTLE_CLOSEUP
var attack_dir := (target_pos - attacker_pos).normalized()
var camera_pos := attacker_pos - attack_dir * 3.0 + Vector3.UP * 2.0
var look_at := target_pos + Vector3.UP * 0.5
var tween := create_tween()
# 快速拉近
tween.tween_property(self, "position", camera_pos, 0.25) \
.set_trans(Tween.TRANS_QUAD) \
.set_ease(Tween.EASE_OUT)
tween.parallel().tween_property(self, "rotation_degrees",
calculate_look_at_rotation(camera_pos, look_at), 0.25) \
.set_trans(Tween.TRANS_QUAD) \
.set_ease(Tween.EASE_OUT)
# 冲击微震
tween.tween_callback(func(): micro_shake(0.2, 0.05))
# 恢复
tween.tween_interval(1.3)
tween.tween_callback(func(): return_to_overview())
## 计算看向目标的旋转角度
func calculate_look_at_rotation(from: Vector3, to: Vector3) -> Vector3:
var direction := (to - from).normalized()
var pitch: float = atan2(-direction.y,
Vector2(direction.x, direction.z).length()) * rad_to_deg(1.0)
var yaw: float = atan2(direction.x, direction.z) * rad_to_deg(1.0)
return Vector3(pitch, yaw, 0)
## 微震
func micro_shake(duration: float, intensity: float):
var camera := $Camera3D as Camera3D
var original_h_offset := camera.h_offset
var original_v_offset := camera.v_offset
var tween := create_tween()
for i in range(4):
var offset := Vector2(
randf_range(-intensity, intensity),
randf_range(-intensity, intensity)
)
tween.tween_property(camera, "h_offset", original_h_offset + offset.x, duration / 8.0)
tween.tween_property(camera, "v_offset", original_v_offset + offset.y, duration / 8.0)
tween.tween_property(camera, "h_offset", original_h_offset, duration / 8.0)
tween.tween_property(camera, "v_offset", original_v_offset, duration / 8.0)6.5 摄像机控制参数一览
| 参数 | 默认值 | 说明 | 调整建议 |
|---|---|---|---|
| OverviewHeight | 15.0 | 俯瞰高度 | 越高看越全,但棋子越小 |
| OverviewDistance | 12.0 | 俯瞰距离 | 影响棋盘在画面中的大小 |
| CloseupDistance | 5.0 | 特写距离 | 越近越震撼,但看不到周围 |
| TransitionSpeed | 2.0 | 过渡速度 | 越大越快,越小越平滑 |
| ShakeIntensity | 0.15 | 震动强度 | 太大会头晕 |
| ShakeDuration | 0.3 | 震动时长 | 0.2-0.5 比较合适 |
6.6 摄像机动画时序图
一个完整回合的摄像机运动:
时间轴:0s 0.3s 0.5s 0.8s 1.3s 1.6s 2.0s 2.8s
| | | | | | | |
状态: 俯瞰──→追踪──→特写──→碰撞──→消散──→拉远──→旋转──→俯瞰
走子 冲刺 爆炸 粒子 恢复 换方 等待
摄像机:远─────→中────→近─────晃动──→近────→远────→旋转──→远本章小结
| 要点 | 说明 |
|---|---|
| Pivot 模式 | 通过旋转父节点实现摄像机环绕 |
| 状态机 | 5种摄像机状态,根据游戏事件切换 |
| 走子追踪 | 根据移动距离智能选择追踪策略 |
| 战斗特写 | 吃子时快速拉近,从攻击方身后拍摄 |
| 回合切换 | 摄像机旋转180度到对方视角 |
| 参数调节 | 所有参数都可以通过 Export 在编辑器中调整 |
→ 7. AI对手
