5. 瞄准与力度控制
瞄准与力度控制
5.1 瞄准系统概述
瞄准是桌球游戏中玩家与游戏交互的核心环节。一个良好的瞄准系统能让玩家直观地选择击球方向,同时提供辅助信息帮助判断击球效果。
瞄准系统的组成
一个完整的瞄准系统通常包含以下几个部分:
| 组件 | 说明 | 必要性 |
|---|---|---|
| 瞄准线 | 从母球到鼠标方向的射线 | 必须 |
| 预测路径 | 母球碰到目标球后的运动方向 | 推荐 |
| 力度条 | 显示当前击球力度 | 必须 |
| 球杆模型 | 可视化的球杆(可选) | 可选 |
生活化比喻
瞄准系统就像射击游戏里的"准星"——它帮助你看清"子弹"(母球)会往哪个方向飞。不同的是,桌球的准星是一条线,而且还需要考虑碰撞后的弹道变化。
鼠标射线投射
在2.5D桌球中,玩家通过移动鼠标来选择瞄准方向。我们需要把屏幕上的鼠标位置"投射"到3D世界中,找到鼠标指向的桌面位置。
什么是射线投射(Raycasting)?
想象你从摄像机位置向鼠标所在的位置射出一束"看不见的激光",这束激光碰到桌面时会在桌面上留下一个点。这个点就是鼠标在3D世界中的位置。
射线投射就是把2D屏幕坐标转换成3D世界坐标的技术。
射线投射原理
摄像机 ──────→ 鼠标屏幕位置
\ |
\ 射线 |
\ ↓
\ 桌面上的点
↘ (3D世界坐标)5.2 瞄准线实现
瞄准线是连接母球和鼠标指向位置的直线。它告诉玩家"母球会往这个方向飞"。
瞄准线的实现方式
| 方式 | 说明 | 优缺点 |
|---|---|---|
| Line2D叠加 | 用CanvasLayer上的Line2D绘制 | 简单,但需要坐标转换 |
| MeshInstance3D | 用3D网格画一条线 | 直接在3D空间,不需要坐标转换 |
| ImmediateMesh | 用代码动态绘制线段 | 最灵活,性能好 |
| DrawLine3D | 用DebugDraw画线 | 仅用于调试 |
本教程选择ImmediateMesh
我们使用 ImmediateMesh 配合 MeshInstance3D 来绘制瞄准线。这种方式直接在3D空间中绘制,不需要额外的坐标转换,而且性能很好。
瞄准控制器代码
using Godot;
/// <summary>
/// 瞄准控制器 - 处理瞄准线和辅助预测
/// </summary>
public partial class AimingController : Node3D
{
/// <summary>摄像机引用</summary>
[Export] public Camera3D Camera { get; set; }
/// <summary>母球引用</summary>
[Export] public RigidBody3D CueBall { get; set; }
/// <summary>瞄准线的网格实例</summary>
private MeshInstance3D _aimLineMesh;
/// <summary>预测线的网格实例</summary>
private MeshInstance3D _predictLineMesh;
/// <summary>射线投射的碰撞掩码(只检测桌面和球)</summary>
private const uint RaycastMask = 2 | 1; // 桌面(层2) + 球(层1)
/// <summary>瞄准线长度</summary>
[Export] public float AimLineLength { get; set; } = 3.0f;
/// <summary>预测线长度</summary>
[Export] public float PredictLineLength { get; set; } = 1.0f;
/// <summary>当前瞄准方向</summary>
public Vector3 AimDirection { get; private set; } = Vector3.Forward;
/// <summary>瞄准方向改变时发出的事件</summary>
[Signal] public delegate void AimDirectionChangedEventHandler(Vector3 direction);
/// <summary>是否正在瞄准</summary>
public bool IsAiming { get; private set; } = false;
public override void _Ready()
{
// 创建瞄准线网格
_aimLineMesh = new MeshInstance3D();
_aimLineMesh.Name = "AimLine";
AddChild(_aimLineMesh);
// 创建预测线网格
_predictLineMesh = new MeshInstance3D();
_predictLineMesh.Name = "PredictLine";
AddChild(_predictLineMesh);
GD.Print("瞄准控制器初始化完成");
}
public override void _Process(double delta)
{
if (!IsAiming || CueBall == null || Camera == null) return;
UpdateAimLine();
}
/// <summary>
/// 开始瞄准
/// </summary>
public void StartAiming()
{
IsAiming = true;
_aimLineMesh.Visible = true;
GD.Print("开始瞄准");
}
/// <summary>
/// 停止瞄准
/// </summary>
public void StopAiming()
{
IsAiming = false;
_aimLineMesh.Visible = false;
_predictLineMesh.Visible = false;
GD.Print("停止瞄准");
}
/// <summary>
/// 更新瞄准线
/// </summary>
private void UpdateAimLine()
{
// 获取鼠标在2D屏幕上的位置
var mousePos = GetViewport().GetMousePosition();
// 创建从摄像机到鼠标位置的射线
var from = Camera.ProjectRayOrigin(mousePos);
var dir = Camera.ProjectRayNormal(mousePos);
// 射线投射到桌面平面(Y = 桌面高度)
var tableY = CueBall.Position.Y;
var plane = new Plane(new Vector3(0, 1, 0), -tableY);
var intersect = plane.IntersectsRay(from, dir);
if (!intersect.HasValue) return;
var targetPos = intersect.Value;
// 计算瞄准方向(从母球指向鼠标位置)
var newDirection = (targetPos - CueBall.Position);
newDirection.Y = 0; // 只在水平面
newDirection = newDirection.Normalized();
// 如果方向有变化,更新瞄准线
if (newDirection.Length() > 0.01f)
{
AimDirection = newDirection;
EmitSignal(SignalName.AimDirectionChanged, AimDirection);
}
// 绘制瞄准线
DrawAimLine();
// 尝试绘制预测线(碰撞预测)
DrawPredictionLine();
}
/// <summary>
/// 绘制瞄准线(从母球出发的直线)
/// </summary>
private void DrawAimLine()
{
var mesh = new ImmediateMesh();
mesh.SurfaceBegin(Mesh.PrimitiveType.Lines);
// 起点:母球位置
var start = CueBall.Position;
// 终点:沿瞄准方向延伸
var end = start + AimDirection * AimLineLength;
// 画线(白色)
mesh.SurfaceSetColor(Colors.White);
mesh.SurfaceAddVertex(start);
mesh.SurfaceAddVertex(end);
// 画虚线效果(每隔一小段画一个短线段)
float dashLength = 0.05f;
float gapLength = 0.05f;
float currentDist = 0;
bool drawing = true;
while (currentDist < AimLineLength)
{
if (drawing)
{
var segStart = start + AimDirection * currentDist;
var segEnd = start + AimDirection * Mathf.Min(currentDist + dashLength, AimLineLength);
mesh.SurfaceSetColor(new Color(1, 1, 1, 0.6f));
mesh.SurfaceAddVertex(segStart);
mesh.SurfaceAddVertex(segEnd);
}
currentDist += drawing ? dashLength : gapLength;
drawing = !drawing;
}
mesh.SurfaceEnd();
_aimLineMesh.Mesh = mesh;
// 使用半透明材质
var material = new StandardMaterial3D();
material.Transparency = BaseMaterial3D.TransparencyEnum.Alpha;
material.AlbedoColor = new Color(1, 1, 1, 0.7f);
material.VertexColorUseAsAlbedo = true;
material.Unshaded = true;
_aimLineMesh.MaterialOverride = material;
}
/// <summary>
/// 绘制预测线(碰撞后的路径)
/// </summary>
private void DrawPredictionLine()
{
// 从母球位置发射物理射线
var spaceState = GetWorld3D().DirectSpaceState;
var from = CueBall.Position;
var to = from + AimDirection * AimLineLength;
var query = PhysicsRayQuery3D.Create(from, to, RaycastMask);
var result = spaceState.IntersectRay(query);
if (result.Count == 0)
{
_predictLineMesh.Visible = false;
return;
}
_predictLineMesh.Visible = true;
// 碰撞点
var hitPos = (Vector3)result["position"];
var hitNormal = (Vector3)result["normal"];
var hitObject = (GodotObject)result["collider"];
// 画碰撞点标记
var mesh = new ImmediateMesh();
mesh.SurfaceBegin(Mesh.PrimitiveType.Lines);
// 如果碰到的是球,显示预测方向
if (hitObject is Node hitNode && hitNode.IsInGroup("balls"))
{
// 碰撞后目标球的运动方向
var ballCenter = ((Node3D)hitNode).GlobalPosition;
var targetDir = (ballCenter - hitPos).Normalized();
targetDir.Y = 0;
// 母球碰撞后的偏转方向
var cueBallReflect = AimDirection - 2 * AimDirection.Dot(hitNormal) * hitNormal;
cueBallReflect.Y = 0;
// 画目标球预测线(黄色)
mesh.SurfaceSetColor(Colors.Yellow);
mesh.SurfaceAddVertex(ballCenter);
mesh.SurfaceAddVertex(ballCenter + targetDir * PredictLineLength);
// 画母球碰撞后的预测线(蓝色)
mesh.SurfaceSetColor(Colors.Cyan);
mesh.SurfaceAddVertex(hitPos);
mesh.SurfaceAddVertex(hitPos + cueBallReflect.Normalized() * PredictLineLength * 0.5f);
}
else
{
// 碰到边框,画反弹预测(灰色)
var reflectDir = AimDirection - 2 * AimDirection.Dot(hitNormal) * hitNormal;
reflectDir.Y = 0;
mesh.SurfaceSetColor(new Color(0.5f, 0.5f, 0.5f, 0.5f));
mesh.SurfaceAddVertex(hitPos);
mesh.SurfaceAddVertex(hitPos + reflectDir.Normalized() * PredictLineLength);
}
mesh.SurfaceEnd();
_predictLineMesh.Mesh = mesh;
var material = new StandardMaterial3D();
material.Transparency = BaseMaterial3D.TransparencyEnum.Alpha;
material.AlbedoColor = new Color(1, 1, 0, 0.5f);
material.VertexColorUseAsAlbedo = true;
material.Unshaded = true;
_predictLineMesh.MaterialOverride = material;
}
/// <summary>
/// 获取当前瞄准方向的2D角度(用于UI显示)
/// </summary>
public float GetAimAngle()
{
return Mathf.Atan2(AimDirection.X, AimDirection.Z);
}
}## 瞄准控制器 - 处理瞄准线和辅助预测
extends Node3D
## 摄像机引用
@export var camera: Camera3D
## 母球引用
@export var cue_ball: RigidBody3D
## 瞄准线的网格实例
var _aim_line_mesh: MeshInstance3D
## 预测线的网格实例
var _predict_line_mesh: MeshInstance3D
## 射线投射的碰撞掩码
const RAYCAST_MASK: int = 2 | 1 # 桌面(层2) + 球(层1)
## 瞄准线长度
@export var aim_line_length: float = 3.0
## 预测线长度
@export var predict_line_length: float = 1.0
## 当前瞄准方向
var aim_direction: Vector3 = Vector3.FORWARD
## 瞄准方向改变时发出的事件
signal aim_direction_changed(direction: Vector3)
## 是否正在瞄准
var is_aiming: bool = false
func _ready() -> void:
# 创建瞄准线网格
_aim_line_mesh = MeshInstance3D.new()
_aim_line_mesh.name = "AimLine"
add_child(_aim_line_mesh)
# 创建预测线网格
_predict_line_mesh = MeshInstance3D.new()
_predict_line_mesh.name = "PredictLine"
add_child(_predict_line_mesh)
print("瞄准控制器初始化完成")
func _process(_delta: float) -> void:
if not is_aiming or not cue_ball or not camera:
return
_update_aim_line()
## 开始瞄准
func start_aiming() -> void:
is_aiming = true
_aim_line_mesh.visible = true
print("开始瞄准")
## 停止瞄准
func stop_aiming() -> void:
is_aiming = false
_aim_line_mesh.visible = false
_predict_line_mesh.visible = false
print("停止瞄准")
## 更新瞄准线
func _update_aim_line() -> void:
# 获取鼠标在2D屏幕上的位置
var mouse_pos = get_viewport().get_mouse_position()
# 创建从摄像机到鼠标位置的射线
var from = camera.project_ray_origin(mouse_pos)
var dir = camera.project_ray_normal(mouse_pos)
# 射线投射到桌面平面
var table_y = cue_ball.position.y
var plane = Plane(Vector3.UP, -table_y)
var intersect = plane.intersects_ray(from, dir)
if not intersect:
return
var target_pos = intersect
# 计算瞄准方向
var new_direction = target_pos - cue_ball.position
new_direction.y = 0
new_direction = new_direction.normalized()
if new_direction.length() > 0.01:
aim_direction = new_direction
aim_direction_changed.emit(aim_direction)
# 绘制瞄准线和预测线
_draw_aim_line()
_draw_prediction_line()
## 绘制瞄准线
func _draw_aim_line() -> void:
var mesh = ImmediateMesh.new()
mesh.surface_begin(Mesh.PRIMITIVE_LINES)
var start = cue_ball.position
var end = start + aim_direction * aim_line_length
# 画虚线效果
var dash_length = 0.05
var gap_length = 0.05
var current_dist = 0.0
var drawing = true
while current_dist < aim_line_length:
if drawing:
var seg_start = start + aim_direction * current_dist
var seg_end = start + aim_direction * minf(current_dist + dash_length, aim_line_length)
mesh.surface_set_color(Color(1, 1, 1, 0.6))
mesh.surface_add_vertex(seg_start)
mesh.surface_add_vertex(seg_end)
current_dist += dash_length if drawing else gap_length
drawing = not drawing
mesh.surface_end()
_aim_line_mesh.mesh = mesh
var material = StandardMaterial3D.new()
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.albedo_color = Color(1, 1, 1, 0.7)
material.vertex_color_use_as_albedo = true
material.unshaded = true
_aim_line_mesh.material_override = material
## 绘制预测线
func _draw_prediction_line() -> void:
var space_state = get_world_3d().direct_space_state
var from = cue_ball.position
var to = from + aim_direction * aim_line_length
var query = PhysicsRayQuery3D.create(from, to, RAYCAST_MASK)
var result = space_state.intersect_ray(query)
if result.is_empty():
_predict_line_mesh.visible = false
return
_predict_line_mesh.visible = true
var hit_pos = result["position"]
var hit_normal = result["normal"]
var hit_object = result["collider"]
var mesh = ImmediateMesh.new()
mesh.surface_begin(Mesh.PRIMITIVE_LINES)
if hit_object is Node and (hit_object as Node).is_in_group("balls"):
var ball_center = (hit_object as Node3D).global_position
var target_dir = (ball_center - hit_pos).normalized()
target_dir.y = 0
# 目标球预测线(黄色)
mesh.surface_set_color(Color.YELLOW)
mesh.surface_add_vertex(ball_center)
mesh.surface_add_vertex(ball_center + target_dir * predict_line_length)
# 母球碰撞后预测线(青色)
var cue_reflect = aim_direction - 2 * aim_direction.dot(hit_normal) * hit_normal
cue_reflect.y = 0
mesh.surface_set_color(Color.CYAN)
mesh.surface_add_vertex(hit_pos)
mesh.surface_add_vertex(hit_pos + cue_reflect.normalized() * predict_line_length * 0.5)
else:
# 边框反弹预测
var reflect_dir = aim_direction - 2 * aim_direction.dot(hit_normal) * hit_normal
reflect_dir.y = 0
mesh.surface_set_color(Color(0.5, 0.5, 0.5, 0.5))
mesh.surface_add_vertex(hit_pos)
mesh.surface_add_vertex(hit_pos + reflect_dir.normalized() * predict_line_length)
mesh.surface_end()
_predict_line_mesh.mesh = mesh
var material = StandardMaterial3D.new()
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.albedo_color = Color(1, 1, 0, 0.5)
material.vertex_color_use_as_albedo = true
material.unshaded = true
_predict_line_mesh.material_override = material
## 获取当前瞄准方向的2D角度
func get_aim_angle() -> float:
return atan2(aim_direction.x, aim_direction.z)5.3 辅助线预测
辅助预测线可以帮助玩家判断母球碰撞后的运动方向,以及目标球被撞后的运动方向。这是提升游戏体验的重要功能。
预测线的类型
| 预测线 | 颜色 | 说明 |
|---|---|---|
| 主瞄准线 | 白色虚线 | 母球运动方向 |
| 目标球预测线 | 黄色 | 目标球被撞后的运动方向 |
| 母球偏转预测线 | 青色 | 母球碰撞后偏转的方向 |
| 反弹预测线 | 灰色 | 母球撞到边框后的反弹方向 |
预测线的精度
预测线只是近似值,因为真实的物理碰撞还涉及到旋转、摩擦等因素。所以预测线应该用半透明的虚线来表示,暗示"这只是参考,实际结果可能有偏差"。
5.4 力度条
力度条是一个UI元素,显示当前击球力度的大小。力度越大,母球飞得越快、越远。
力度条的设计方案
| 方案 | 操作方式 | 优缺点 |
|---|---|---|
| 拖拽式 | 按住鼠标向后拖动,拖动距离=力度 | 直观,但需要3D空间交互 |
| 来回摆动式 | 力度条自动来回摆动,点击确认 | 经典,容易实现 |
| 蓄力式 | 按住鼠标不放,力度逐渐增加 | 简单,但不好控制精确值 |
本教程使用"蓄力式"力度条
按住鼠标左键不放,力度从0逐渐增加到最大值。松开鼠标时,以当前力度击球。这种方案简单直观,适合初学者。
力度条UI实现
力度条使用Godot的UI系统(Control节点)来实现:
PowerBarUI (Control/Panel)
├── Background (ColorRect) ← 力度条背景
├── Fill (ColorRect) ← 力度条填充
└── Label (Label) ← 力度数值显示力度条控制器代码
using Godot;
/// <summary>
/// 力度条控制器 - 管理击球力度的UI和逻辑
/// </summary>
public partial class PowerBarController : Control
{
/// <summary>力度条背景</summary>
[Export] public ColorRect Background { get; set; }
/// <summary>力度条填充</summary>
[Export] public ColorRect Fill { get; set; }
/// <summary>力度数值标签</summary>
[Export] public Label PowerLabel { get; set; }
/// <summary>力度增长速度(每秒增长多少)</summary>
[Export] public float ChargeSpeed { get; set; } = 0.8f;
/// <summary>当前力度(0到1之间)</summary>
public float CurrentPower { get; private set; } = 0.0f;
/// <summary>是否正在蓄力</summary>
public bool IsCharging { get; private set; } = false;
/// <summary>蓄力方向(增加或减少)</summary>
private bool _chargingUp = true;
/// <summary>力度确认时发出的事件</summary>
[Signal] public delegate void PowerConfirmedEventHandler(float power);
/// <summary>力度更新时发出的事件</summary>
[Signal] public delegate void PowerChangedEventHandler(float power);
public override void _Ready()
{
// 初始化力度条UI
UpdatePowerUI(0.0f);
Visible = false;
GD.Print("力度条控制器初始化完成");
}
public override void _Process(double delta)
{
if (!IsCharging) return;
// 更新力度
float change = (float)delta * ChargeSpeed;
if (_chargingUp)
{
CurrentPower += change;
if (CurrentPower >= 1.0f)
{
CurrentPower = 1.0f;
_chargingUp = false; // 到最大值后开始减小
}
}
else
{
CurrentPower -= change;
if (CurrentPower <= 0.0f)
{
CurrentPower = 0.0f;
_chargingUp = true; // 到最小值后开始增加
}
}
// 更新UI
UpdatePowerUI(CurrentPower);
// 发出力度变化事件
EmitSignal(SignalName.PowerChanged, CurrentPower);
}
/// <summary>
/// 开始蓄力
/// </summary>
public void StartCharging()
{
if (IsCharging) return;
IsCharging = true;
CurrentPower = 0.0f;
_chargingUp = true;
Visible = true;
GD.Print("开始蓄力");
}
/// <summary>
/// 确认力度(松开鼠标时调用)
/// </summary>
public void ConfirmPower()
{
if (!IsCharging) return;
IsCharging = false;
GD.Print($"力度确认: {CurrentPower:P0}");
// 发出确认事件
EmitSignal(SignalName.PowerConfirmed, CurrentPower);
// 延迟隐藏力度条
var timer = GetTree().CreateTimer(0.5);
timer.Timeout += () => Visible = false;
}
/// <summary>
/// 取消蓄力
/// </summary>
public void CancelCharging()
{
IsCharging = false;
CurrentPower = 0.0f;
UpdatePowerUI(0.0f);
Visible = false;
GD.Print("取消蓄力");
}
/// <summary>
/// 更新力度条UI
/// </summary>
private void UpdatePowerUI(float power)
{
if (Fill != null)
{
// 根据力度改变填充宽度
Fill.Size = new Vector2(
Background.Size.X * power,
Background.Size.Y
);
// 根据力度改变颜色(绿→黄→红)
Fill.Color = GetPowerColor(power);
}
if (PowerLabel != null)
{
PowerLabel.Text = $"力度: {(int)(power * 100)}%";
}
}
/// <summary>
/// 根据力度返回对应颜色
/// </summary>
private Color GetPowerColor(float power)
{
if (power < 0.5f)
{
// 绿色到黄色
float t = power * 2;
return new Color(t, 1.0f, 0.0f);
}
else
{
// 黄色到红色
float t = (power - 0.5f) * 2;
return new Color(1.0f, 1.0f - t, 0.0f);
}
}
/// <summary>
/// 重置力度条
/// </summary>
public void Reset()
{
CurrentPower = 0.0f;
IsCharging = false;
_chargingUp = true;
UpdatePowerUI(0.0f);
Visible = false;
}
}## 力度条控制器 - 管理击球力度的UI和逻辑
extends Control
## 力度条背景
@export var background: ColorRect
## 力度条填充
@export var fill: ColorRect
## 力度数值标签
@export var power_label: Label
## 力度增长速度(每秒增长多少)
@export var charge_speed: float = 0.8
## 当前力度(0到1之间)
var current_power: float = 0.0
## 是否正在蓄力
var is_charging: bool = false
## 蓄力方向
var _charging_up: bool = true
## 力度确认时发出的事件
signal power_confirmed(power: float)
## 力度更新时发出的事件
signal power_changed(power: float)
func _ready() -> void:
# 初始化力度条UI
_update_power_ui(0.0)
visible = false
print("力度条控制器初始化完成")
func _process(delta: float) -> void:
if not is_charging:
return
# 更新力度
var change = delta * charge_speed
if _charging_up:
current_power += change
if current_power >= 1.0:
current_power = 1.0
_charging_up = false
else:
current_power -= change
if current_power <= 0.0:
current_power = 0.0
_charging_up = true
# 更新UI
_update_power_ui(current_power)
power_changed.emit(current_power)
## 开始蓄力
func start_charging() -> void:
if is_charging:
return
is_charging = true
current_power = 0.0
_charging_up = true
visible = true
print("开始蓄力")
## 确认力度
func confirm_power() -> void:
if not is_charging:
return
is_charging = false
print("力度确认: %d%%" % int(current_power * 100))
power_confirmed.emit(current_power)
# 延迟隐藏力度条
var timer = get_tree().create_timer(0.5)
timer.timeout.connect(func(): visible = false)
## 取消蓄力
func cancel_charging() -> void:
is_charging = false
current_power = 0.0
_update_power_ui(0.0)
visible = false
print("取消蓄力")
## 更新力度条UI
func _update_power_ui(power: float) -> void:
if fill and background:
fill.size = Vector2(background.size.x * power, background.size.y)
fill.color = _get_power_color(power)
if power_label:
power_label.text = "力度: %d%%" % int(power * 100)
## 根据力度返回对应颜色
func _get_power_color(power: float) -> Color:
if power < 0.5:
var t = power * 2
return Color(t, 1.0, 0.0)
else:
var t = (power - 0.5) * 2
return Color(1.0, 1.0 - t, 0.0)
## 重置力度条
func reset() -> void:
current_power = 0.0
is_charging = false
_charging_up = true
_update_power_ui(0.0)
visible = false5.5 击球执行
当玩家确认了瞄准方向和力度后,执行击球动作——给母球施加一个力。
击球操作流程
鼠标按下 → 开始蓄力(力度条开始摆动)
↓
鼠标移动 → 调整瞄准方向(瞄准线实时更新)
↓
鼠标松开 → 确认力度 → 执行击球
↓
母球受力 → 球在运动 → 等待所有球停下击球输入处理
using Godot;
/// <summary>
/// 击球输入处理器 - 处理玩家的瞄准和击球操作
/// </summary>
public partial class ShotInputHandler : Node
{
/// <summary>瞄准控制器引用</summary>
[Export] public AimingController AimingController { get; set; }
/// <summary>力度条控制器引用</summary>
[Export] public PowerBarController PowerBarController { get; set; }
/// <summary>母球引用</summary>
[Export] public BallPhysics CueBall { get; set; }
/// <summary>游戏管理器引用</summary>
[Export] public Node GameManager { get; set; }
/// <summary>击球完成事件</summary>
[Signal] public delegate void ShotExecutedEventHandler(Vector3 direction, float power);
/// <summary>是否可以操作</summary>
private bool _canInteract = true;
/// <summary>输入状态</summary>
private enum InputState { Idle, Aiming, Charging }
private InputState _currentState = InputState.Idle;
public override void _Ready()
{
// 连接力度条确认事件
if (PowerBarController != null)
{
PowerBarController.PowerConfirmed += OnPowerConfirmed;
}
GD.Print("击球输入处理器初始化完成");
}
public override void _Input(InputEvent @event)
{
if (!_canInteract) return;
// 鼠标左键按下 → 开始瞄准
if (@event is InputEventMouseButton mouseDown &&
mouseDown.ButtonIndex == MouseButton.Left &&
mouseDown.Pressed)
{
OnMouseDown();
}
// 鼠标左键松开 → 确认力度/击球
if (@event is InputEventMouseButton mouseUp &&
mouseUp.ButtonIndex == MouseButton.Left &&
!mouseUp.Pressed)
{
OnMouseUp();
}
// ESC键 → 取消操作
if (@event is InputEventKey key &&
key.Keycode == Key.Escape &&
key.Pressed)
{
CancelShot();
}
}
/// <summary>
/// 鼠标按下处理
/// </summary>
private void OnMouseDown()
{
if (_currentState == InputState.Idle)
{
// 进入瞄准状态
_currentState = InputState.Aiming;
AimingController?.StartAiming();
GD.Print("进入瞄准状态");
}
else if (_currentState == InputState.Aiming)
{
// 开始蓄力
_currentState = InputState.Charging;
PowerBarController?.StartCharging();
GD.Print("开始蓄力");
}
}
/// <summary>
/// 鼠标松开处理
/// </summary>
private void OnMouseUp()
{
if (_currentState == InputState.Aiming)
{
// 轻点一下就取消
CancelShot();
}
else if (_currentState == InputState.Charging)
{
// 确认力度,执行击球
PowerBarController?.ConfirmPower();
}
}
/// <summary>
/// 力度确认后的处理
/// </summary>
private void OnPowerConfirmed(float power)
{
if (power < 0.01f)
{
GD.Print("力度太小,取消击球");
CancelShot();
return;
}
// 获取瞄准方向
var direction = AimingController.AimDirection;
// 执行击球
ExecuteShot(direction, power);
}
/// <summary>
/// 执行击球
/// </summary>
private void ExecuteShot(Vector3 direction, float power)
{
GD.Print($"执行击球!方向: {direction}, 力度: {power:P0}");
// 停止瞄准
AimingController?.StopAiming();
// 给母球施加力
CueBall?.HitBall(direction, power);
// 切换到"等待"状态
_currentState = InputState.Idle;
_canInteract = false;
// 发出击球完成事件
EmitSignal(SignalName.ShotExecuted, direction, power);
}
/// <summary>
/// 取消击球
/// </summary>
private void CancelShot()
{
_currentState = InputState.Idle;
AimingController?.StopAiming();
PowerBarController?.CancelCharging();
GD.Print("取消击球");
}
/// <summary>
/// 允许玩家操作(所有球停下后调用)
/// </summary>
public void EnableInteraction()
{
_canInteract = true;
_currentState = InputState.Idle;
GD.Print("玩家可以操作了");
}
/// <summary>
/// 禁止玩家操作
/// </summary>
public void DisableInteraction()
{
_canInteract = false;
if (_currentState != InputState.Idle)
{
CancelShot();
}
GD.Print("禁止玩家操作");
}
}## 击球输入处理器 - 处理玩家的瞄准和击球操作
extends Node
## 瞄准控制器引用
@export var aiming_controller: AimingController
## 力度条控制器引用
@export var power_bar_controller: PowerBarController
## 母球引用
@export var cue_ball: BallPhysics
## 游戏管理器引用
@export var game_manager: Node
## 击球完成事件
signal shot_executed(direction: Vector3, power: float)
## 是否可以操作
var _can_interact: bool = true
## 输入状态
enum InputState { IDLE, AIMING, CHARGING }
var _current_state: int = InputState.IDLE
func _ready() -> void:
# 连接力度条确认事件
if power_bar_controller:
power_bar_controller.power_confirmed.connect(_on_power_confirmed)
print("击球输入处理器初始化完成")
func _input(event: InputEvent) -> void:
if not _can_interact:
return
# 鼠标左键按下
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
_on_mouse_down()
# 鼠标左键松开
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and not event.pressed:
_on_mouse_up()
# ESC键取消
if event is InputEventKey and event.keycode == KEY_ESCAPE and event.pressed:
_cancel_shot()
## 鼠标按下
func _on_mouse_down() -> void:
if _current_state == InputState.IDLE:
_current_state = InputState.AIMING
if aiming_controller:
aiming_controller.start_aiming()
print("进入瞄准状态")
elif _current_state == InputState.AIMING:
_current_state = InputState.CHARGING
if power_bar_controller:
power_bar_controller.start_charging()
print("开始蓄力")
## 鼠标松开
func _on_mouse_up() -> void:
if _current_state == InputState.AIMING:
_cancel_shot()
elif _current_state == InputState.CHARGING:
if power_bar_controller:
power_bar_controller.confirm_power()
## 力度确认后
func _on_power_confirmed(power: float) -> void:
if power < 0.01:
print("力度太小,取消击球")
_cancel_shot()
return
var direction = aiming_controller.aim_direction
_execute_shot(direction, power)
## 执行击球
func _execute_shot(direction: Vector3, power: float) -> void:
print("执行击球!力度: %d%%" % int(power * 100))
if aiming_controller:
aiming_controller.stop_aiming()
if cue_ball:
cue_ball.hit_ball(direction, power)
_current_state = InputState.IDLE
_can_interact = false
shot_executed.emit(direction, power)
## 取消击球
func _cancel_shot() -> void:
_current_state = InputState.IDLE
if aiming_controller:
aiming_controller.stop_aiming()
if power_bar_controller:
power_bar_controller.cancel_charging()
print("取消击球")
## 允许玩家操作
func enable_interaction() -> void:
_can_interact = true
_current_state = InputState.IDLE
print("玩家可以操作了")
## 禁止玩家操作
func disable_interaction() -> void:
_can_interact = false
if _current_state != InputState.IDLE:
_cancel_shot()
print("禁止玩家操作")5.6 角度微调
有时候玩家需要精确地调整瞄准角度,仅仅靠鼠标移动可能不够精确。我们可以添加键盘微调功能:
| 按键 | 功能 | 每次调整角度 |
|---|---|---|
| 左箭头/A | 向左微调 | 0.5度 |
| 右箭头/D | 向右微调 | 0.5度 |
| 上箭头/W | 微调距离 | 0.01 |
| 下箭头/S | 微调距离 | 0.01 |
微调的实用场景
当两个球几乎在一条直线上时,微调功能特别有用。鼠标移动0.5像素可能看不出区别,但按一下方向键就能精确调整0.5度,这可能是"进"和"不进"的区别。
5.7 小结
在本章中,我们实现了完整的瞄准和力度控制系统:
- 瞄准线:从母球到鼠标方向的虚线,帮助玩家选择击球方向
- 预测线:显示碰撞后球和母球的运动方向
- 力度条:蓄力式力度控制,绿→黄→红的颜色变化
- 击球执行:鼠标操作流程,确认方向和力度后执行击球
- 角度微调:键盘方向键精确调整瞄准角度
瞄准系统是玩家与游戏互动的核心界面。一个好的瞄准系统应该让玩家感觉"指哪打哪",同时又提供足够的信息帮助决策。
→ 6. 规则判定
