教程一 · 第 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_up | W |
player1_down | S |
player2_up | ↑ |
player2_down | ↓ |
restart | R |
每个动作:输入映射里点”+“加按键。
3.2 做挡板场景 paddle.tscn
- 场景 → 新建场景,根节点点”+“搜 CharacterBody2D,命名
Paddle - 加子节点 CollisionShape2D,检查器 Shape 选
RectangleShape2D,Size 设 (20, 120) - 再加子节点 ColorRect?——不,2D 世界用 Polygon2D 或直接用 ColorRect 的 2D 亲戚。最简单:给 CharacterBody2D 加子节点 Sprite2D 太麻烦,用
ColorRect是 UI 节点不适合——我们用 Polygon2D:四点填 (-10,-60)(10,-60)(10,60)(-10,60),颜色白- 更省事的正规做法:加子节点
CollisionShape2D后,编辑器视口上方”调试”菜单可以显示碰撞形状;美术占位推荐 Polygon2D
- 更省事的正规做法:加子节点
- 保存为
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
- 新建场景,根 CharacterBody2D 命名
Ball - CollisionShape2D:Shape 选
CircleShape2D,Radius 10 - Polygon2D 画个 20×20 白方块(Pong 经典风),或用正圆
- 保存
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
- 新建场景根 Node2D 命名
Main - 实例化:把文件系统里的
paddle.tscn拖进场景,拖两次——实例名分别设Player1(位置 (40,270),UpAction=player1_up)、Player2(位置 (920,270),UpAction=player2_up、DownAction=player2_down、Side=-1)- ⚠️ 选中实例后在检查器改脚本导出属性,改的是实例覆盖,不影响原场景
- 拖入
ball.tscn,命名Ball,位置 (480,270) - 加上下墙 StaticBody2D
- 加 CanvasLayer(让 UI 不受摄像机/坐标影响),其下加:
Label命名ScoreLeft,位置 (300, 30),字体大小在检查器 Theme Overrides → Font Sizes 设 48Label命名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 给游戏加三样东西(建议自己动手)
- 最高分/先到 5 分胜利:Main 里加判断,胜利时 Hint 显示 “Player X Wins! Press R”,胜利后让球停下(给 Ball 加
SetPhysicsProcess(false)或_running布尔) - 球离挡板太近的”穿模”修正:球碰挡板后把 X 位置推到挡板外侧,避免连续碰撞
- 开始倒计时:进球后 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 平台跳跃