2270 字
11 分钟
Godot C# 零基础教程 03:第一个完整游戏 Pong

教程一 · 第 3 章:第一个完整游戏——Pong#

本章我们做出一个真能两个人玩的 Pong:两个挡板、一个反弹球、计分、重新开始。做完你会掌握 Godot 80% 的日常操作。

成品结构:

main.tscn(Main: Node2D)
├── 场地边框(StaticBody2D × 上下墙 + 左右得分线)
├── Player(CharacterBody2D 挡板,W/S)
├── Enemy(CharacterBody2D 挡板,↑/↓)
├── Ball(CharacterBody2D 球)
└── HUD(CanvasLayer + Label:比分、提示)

3.1 项目设置:分辨率、输入、主场景#

新建项目(带 C# 解决方案),先做三件配置。

分辨率:项目 → 项目设置 → 窗口 → 视口宽度 960、高度 540。拉伸(窗口下面的 Stretch 部分):Mode 选 canvas_items,Aspect 选 keep。这样画面按 960×540 设计,窗口缩放时等比放大。

自定义输入动作:项目设置 → 输入映射,添加四个动作(注意 Godot 内置已有 ui_up 等,我们建语义更清晰的):

动作名按键
player1_upW
player1_downS
player2_up↑
player2_down↓
restartR

每个动作:输入映射里点”+“加按键。

3.2 做挡板场景 paddle.tscn#

  1. 场景 → 新建场景,根节点点”+“搜 CharacterBody2D,命名 Paddle
  2. 加子节点 CollisionShape2D,检查器 Shape 选 RectangleShape2D,Size 设 (20, 120)
  3. 再加子节点 ColorRect?——不,2D 世界用 Polygon2D 或直接用 ColorRect 的 2D 亲戚。最简单:给 CharacterBody2D 加子节点 Sprite2D 太麻烦,用 ColorRect 是 UI 节点不适合——我们用 Polygon2D:四点填 (-10,-60)(10,-60)(10,60)(-10,60),颜色白
    • 更省事的正规做法:加子节点 CollisionShape2D 后,编辑器视口上方”调试”菜单可以显示碰撞形状;美术占位推荐 Polygon2D
  4. 保存为 scenes/paddle.tscn

小结:CharacterBody2D(管移动和碰撞)+ CollisionShape2D(碰撞形状)+ 可见物(Polygon2D/Sprite2D)是 2D 角色三件套,后面球、平台游戏都一样。

挂脚本 scripts/Paddle.cs:

using Godot;
public partial class Paddle : CharacterBody2D
{
[Export] public float Speed = 500f;
[Export] public string UpAction = "player1_up";
[Export] public string DownAction = "player1_down";
[Export] public float TopBound = 60f; // 挡板中心能到达的最上/最下 y
[Export] public float BottomBound = 480f;
public override void _PhysicsProcess(double delta)
{
float dir = Input.GetAxis(UpAction, DownAction); // -1, 0, 1
Velocity = new Vector2(0f, dir * Speed);
MoveAndSlide(); // CharacterBody2D 的移动方式:自动按速度移动并处理碰撞滑动
// 夹在场地内
Position = new Vector2(
Position.X,
Mathf.Clamp(Position.Y, TopBound, BottomBound)
);
}
}

要点:

  • Input.GetAxis(负向动作, 正向动作) 返回 -1/0/1,比手写两个布尔值简洁
  • CharacterBody2D 不会自己动:你设 Velocity 再调 MoveAndSlide(),它才移动并处理碰撞(撞墙会沿墙滑)
  • Mathf.Clamp(v, min, max) 是 Godot 的夹值工具
  • 上下边界做成 [Export],左右两个挡板实例可以分别配(本场景对称,值相同)

3.3 做球场景 ball.tscn#

  1. 新建场景,根 CharacterBody2D 命名 Ball
  2. CollisionShape2D:Shape 选 CircleShape2D,Radius 10
  3. Polygon2D 画个 20×20 白方块(Pong 经典风),或用正圆
  4. 保存 scenes/ball.tscn,挂脚本 scripts/Ball.cs
using Godot;
public partial class Ball : CharacterBody2D
{
[Signal] public delegate void ScoredEventHandler(StringName side); // "left" 或 "right"
[Export] public float StartSpeed = 400f;
[Export] public float SpeedGainPerHit = 30f;
[Export] public float MaxSpeed = 1100f;
[Export] public float BoundY = 530f; // 撞到上下墙的 y 边界(球半径 10,场地高 540)
private float _speed;
private Vector2 _direction;
private RandomNumberGenerator _rng = new();
public override void _Ready()
{
_rng.Randomize(); // 不调用的话随机序列固定
ResetBall();
}
public void ResetBall()
{
Position = Vector2.Zero;
_speed = StartSpeed;
// 水平朝左或右,垂直给个随机角度(避免完全水平的无聊球路)
float angle = _rng.RandfRange(-Mathf.Pi / 4f, Mathf.Pi / 4f);
_direction = new Vector2(_rng.Randf() < 0.5f ? -1f : 1f, 0f)
.Rotated(angle).Normalized();
}
public override void _PhysicsProcess(double delta)
{
Velocity = _direction * _speed;
var collision = MoveAndSlideCollide(); // 手动版移动,返回碰撞信息
for (int i = 0; i < collision.Count; i++)
{
var collisionInfo = collision[i];
var collider = collisionInfo.GetCollider();
if (collider is Paddle paddle)
{
BounceFromPaddle(paddle);
}
else
{
// 撞墙:竖直方向反弹
_direction.Y = -_direction.Y;
}
}
// 出左右边界 = 得分
if (Position.X < -20f)
{
EmitSignal(SignalName.Scored, "right"); // 球从左边出去,右边玩家得分
ResetBall();
}
else if (Position.X > 980f)
{
EmitSignal(SignalName.Scored, "left");
ResetBall();
}
}
private void BounceFromPaddle(Paddle paddle)
{
// 击球点偏离挡板中心越远,反弹角度越刁
float offset = (Position.Y - paddle.Position.Y) / 60f; // -1..1
offset = Mathf.Clamp(offset, -1f, 1f);
float dirX = paddle.Name == "Player1" ? 1f : -1f;
float angle = offset * Mathf.Pi / 3f; // 最大 60°
_direction = new Vector2(dirX, Mathf.Sin(angle)).Normalized();
_speed = Mathf.Min(_speed + SpeedGainPerHit, MaxSpeed);
}
}

逐点解释:

  • MoveAndSlideCollide() 比 MoveAndSlide() 多返回一份碰撞列表,可以查到撞到了谁。判断 collider is Paddle 是 C# 的类型模式匹配(教程二细讲),撞到挡板和撞墙反弹逻辑不同
  • 信号 Scored 带一个字符串参数 "left"/"right",Main 场景订阅它来计分——球不关心分数,只喊”进球了”
  • RandomNumberGenerator 是 Godot 随机类,Randomize() 用系统时间播种;也可以直接用 C# 的 System.Random,都行
  • Mathf 是 Godot 数学静态类(Pi、Sin、Clamp、Lerp…),和 System.Math 的区别:Mathf 针对 float,不产生 double 转换噪音

一个小修正:上面用 paddle.Name 判断左右有点脆,给 Paddle 加个导出属性更干净:

// Paddle.cs 里加
[Export] public int Side = 1; // 1 = 左侧挡板(把球向右打),-1 = 右侧

BounceFromPaddle 改成 float dirX = paddle.Side;。实例配置时右挡板 Side 设 -1。

3.4 场地边框#

新建场景 scenes/arena.tscn 或直接在 main 里摆。需要四个 StaticBody2D:

  • 上墙:位置 (480, 0),CollisionShape2D RectangleShape2D Size (960, 20)
  • 下墙:(480, 540),同样
  • 左右不用墙(要漏球得分),但可以放两个 Area2D 作为得分传感器——本例直接在球脚本里判断 X 坐标,更简单,先不做

StaticBody2D 不需要脚本,球撞到它们时 MoveAndSlideCollide 会返回 collider(不是 Paddle),走 else 竖直反弹。给它们配 Polygon2D 子节点显示成灰色。

3.5 组装主场景 main.tscn#

  1. 新建场景根 Node2D 命名 Main
  2. 实例化:把文件系统里的 paddle.tscn 拖进场景,拖两次——实例名分别设 Player1(位置 (40,270),UpAction=player1_up)、Player2(位置 (920,270),UpAction=player2_up、DownAction=player2_down、Side=-1)
    • ⚠️ 选中实例后在检查器改脚本导出属性,改的是实例覆盖,不影响原场景
  3. 拖入 ball.tscn,命名 Ball,位置 (480,270)
  4. 加上下墙 StaticBody2D
  5. 加 CanvasLayer(让 UI 不受摄像机/坐标影响),其下加:
    • Label 命名 ScoreLeft,位置 (300, 30),字体大小在检查器 Theme Overrides → Font Sizes 设 48
    • Label 命名 ScoreRight,位置 (620, 30)
    • Label 命名 Hint,位置 (360, 480),Text 填 “W/S vs ↑/↓ R 重新开始”

主脚本 scripts/Main.cs:

using Godot;
public partial class Main : Node2D
{
private int _leftScore;
private int _rightScore;
private Label _scoreLeftLabel = null!;
private Label _scoreRightLabel = null!;
private Ball _ball = null!;
public override void _Ready()
{
_scoreLeftLabel = GetNode<Label>("CanvasLayer/ScoreLeft");
_scoreRightLabel = GetNode<Label>("CanvasLayer/ScoreRight");
_ball = GetNode<Ball>("Ball");
// 订阅球的得分信号
_ball.Scored += OnScored;
UpdateScore();
}
public override void _UnhandledInput(InputEvent @event)
{
if (Input.IsActionJustPressed("restart"))
{
_leftScore = 0;
_rightScore = 0;
_ball.ResetBall();
UpdateScore();
}
}
private void OnScored(StringName side)
{
if (side == "left") _leftScore++;
else _rightScore++;
UpdateScore();
}
private void UpdateScore()
{
_scoreLeftLabel.Text = _leftScore.ToString();
_scoreRightLabel.Text = _rightScore.ToString();
}
}

新知识点:

  • _UnhandledInput:输入事件没被 UI 等拦截时调用。配 Input.IsActionJustPressed 处理”按下瞬间”(跳跃、菜单、重开都用它,不要在 _Process 里轮询 IsActionPressed)
  • = null!:C# 的”null 包容运算符”,告诉编译器”我保证 _Ready 时会赋值,别报可空警告”。Godot 节点字段惯例写法(教程二第 3 章讲可空性)
  • 信号处理方法签名必须和信号参数匹配:Scored(StringName side) 对应 OnScored(StringName side)
  • "left" 字符串与 StringName 比较能直接用 ==,Godot 做了隐式转换

3.6 中线、微调与试玩#

  • 中线:Main 下加 10 个白色 ColorRect?ColorRect 是 UI——2D 里用一排 Polygon2D,或一个虚线纹理。简单起见加一根细 Polygon2D 白条 (2,540) 放 (479,0),视觉效果够用
  • F5 试玩。检查清单:
    • 两个挡板能动且不出界
    • 球撞上下墙反弹
    • 球撞挡板反弹,打到挡板边缘角度变刁
    • 球漏出左右边界后归位、比分 +1
    • R 清零重开

常见 bug 自查表:

现象排查
挡板穿墙/球不反弹CollisionShape2D 配了吗?两个碰撞层默认都在 layer 1 能撞
信号没触发事件名拼错?订阅的节点路径对吗?Build 了吗
进球后计分反了左边漏球应该右边得分,检查 OnScored 逻辑
改代码没用没 Build
GetNode 路径报错路径区分大小写,且必须和场景树完全一致

3.7 给游戏加三样东西(建议自己动手)#

  1. 最高分/先到 5 分胜利:Main 里加判断,胜利时 Hint 显示 “Player X Wins! Press R”,胜利后让球停下(给 Ball 加 SetPhysicsProcess(false) 或 _running 布尔)
  2. 球离挡板太近的”穿模”修正:球碰挡板后把 X 位置推到挡板外侧,避免连续碰撞
  3. 开始倒计时:进球后 1 秒再发球(用 GetTree().CreateTimer(1.0),await 写法见第 5 章;现在可以先用简单的累加 delta)

3.8 本章你实际掌握了什么#

  • CharacterBody2D 三件套:身体 + CollisionShape2D + 显示节点
  • Velocity + MoveAndSlide() / MoveAndSlideCollide() 的物理移动模式
  • 输入映射 + GetAxis / IsActionJustPressed
  • [Export] 同一脚本实例差异化配置
  • 自定义信号带参数、跨场景订阅
  • 场景实例化(拖两次 paddle.tscn)
  • StaticBody2D 做不动的墙

下一章升级到 2D 平台跳跃:重力、跳跃、地形、单向平台、动画、摄像机,这是做银河城/平台类游戏的地基。


《Godot C# 零基础》系列导航

⬅️ 上一篇:Godot C# 零基础教程 02:节点、场景与脚本 📑 返回系列总目录(第 01 篇) ➡️ 下一篇:Godot C# 零基础教程 04:2D 平台跳跃

Godot C# 零基础教程 03:第一个完整游戏 Pong
https://021028.xyz/posts/default/163/
作者
021028
发布于
2026-10-14
许可协议
CC BY-NC-SA 4.0