Skip to content

实战:做一个平台跳跃游戏

本页导读:这一页把前三章的知识全部用起来,从空项目开始,一步步做出一个能跑、能跳、有金币、有敌人、能失败重来的平台跳跃游戏。每一步都附可直接复制的 GDScript 代码,跟着做完大约需要一到两个小时。

这是全站第一个完整项目,也是你游戏开发生涯的“Hello World”。平台跳跃包含了 2D 游戏最核心的要素:移动、物理、碰撞、镜头、收集、失败反馈。做完它,你就不再是“学过引擎的人”,而是“做过游戏的人”。

一、流程总览与项目准备

先看全程地图,心里有数再动手:

步骤目标核心知识点
1建项目、配输入Input Map 输入映射
2角色左右移动与重力CharacterBody2Dvelocitymove_and_slide()
3跳跃is_on_floor()Input.is_action_just_pressed()
4铺设关卡TileMap、TileSet 碰撞
5镜头跟随Camera2D、位置平滑
6金币与计分Area2D、信号、HUD
7敌人与失败重启分组、reload_current_scene()

新建空项目(渲染器选默认即可),先配置输入:菜单 项目项目设置输入映射 标签页,在“添加新动作”输入框分别添加三个动作,并各自绑定按键:

  • move_left:绑定 A 键和左方向键
  • move_right:绑定 D 键和右方向键
  • jump:绑定空格键

添加动作后点加号,在弹窗里按下对应键盘按键即可确认。输入映射的好处:代码里只写动作名,以后想改成手柄摇杆,不用改一行代码。

二、角色:移动与重力

搭场景:新建场景,根节点选 CharacterBody2D(改名为 Player)——这是 Godot 专门给“会自己移动、需要碰撞”的角色准备的节点,内置 velocity(速度向量)属性和贴地移动方法。给它添加两个子节点:Sprite2D(Texture 槽拖入 icon.svg,缩放改小到 0.5 左右)和 CollisionShape2D(Shape 新建 RectangleShape2D,拉到刚好包住精灵)。保存为 player.tscn

写脚本:给 Player 挂脚本 player.gd,先实现左右移动和重力:

gdscript
extends CharacterBody2D

const SPEED: float = 300.0

var gravity: float = ProjectSettings.get_setting("physics/2d/default_gravity")

func _physics_process(delta: float) -> void:
    if not is_on_floor():
        velocity.y += gravity * delta   # 不在地面就持续下坠

    var direction: float = Input.get_axis("move_left", "move_right")
    if direction:
        velocity.x = direction * SPEED  # 按了左右键:全速移动
    else:
        velocity.x = move_toward(velocity.x, 0.0, SPEED)  # 没按:平滑减速到 0

    move_and_slide()   # 按当前 velocity 移动并自动处理碰撞

三处要点:velocityCharacterBody2D 自带的属性,直接读写即可;Input.get_axis() 返回 -1.0 到 1.0 的方向值(左负右正),把两个动作合成一根轴;move_and_slide() 在 Godot 4 里无参数调用,它自动使用并更新 velocity,碰撞后还会沿墙滑动。写到这里先停一下——角色还不能落地,因为我们还没有地面。下一步铺。

三、跳跃与地面判断

跳跃只有两行核心逻辑,加在 _physics_process 里重力代码之后:

gdscript
    if Input.is_action_just_pressed("jump") and is_on_floor():
        velocity.y = -420.0   # y 轴向下为正,负值就是向上蹦

两个 API 的含义:Input.is_action_just_pressed() 只在按下那一帧返回 true(对应地,is_action_pressed() 是按住期间每帧都 true),跳跃这种“一次性触发”必须用它;is_on_floor()CharacterBody2D 的内置方法,报告“此刻是否站在地面/物体上方”——只有在地面才能跳,这条判断防止了空中无限连跳。

一个隐藏依赖必须说清楚:is_on_floor() 的结果来自 move_and_slide() 的碰撞检测。所以代码顺序必须是“先算重力与跳跃、最后调用 move_and_slide()”,并且碰撞体不能漏。临时测试方法:先给 PlayerCollisionShape2D 下方摆一块静态地板——新建 StaticBody2DCollisionShape2D(拉成扁长条),放在角色脚下。按 F5 把 player.tscn 设为主场景运行:现在应该能用 A/D 走动、空格跳跃、站在板子上不掉了。

手感调节口诀:SPEED 越大跑得越快;跳跃力度(那个负数)绝对值越大跳得越高;想要“快落感”,把重力那行改成 velocity.y += gravity * 2.0 * delta 试试。数值手感没有标准答案,调到你自己按着爽为止。

四、用 TileMap 铺设关卡

一块块摆 StaticBody 铺地图太蠢了,正经做法是瓦片地图。流程分四步(以 4.x 的 TileMap 节点为例;如果你的 Godot 是 4.3 或更高版本,推荐直接用新节点 TileMapLayer,创建和绘制方式完全一样,只是把“多个 TileMap”变成了“多层 TileMapLayer”):

  1. 建节点:主场景里新建 TileMap 节点。在检查器里找到 Tile Set 属性,点开选择“新建 TileSet”。
  2. 导入瓦片图:点开底部面板的“TileSet”标签,把你的瓦片图片(没有的话,任何 32×32 或 16×16 的像素图块都行,网上搜“free tileset”很多)拖进左侧空白区,弹窗问是否自动创建图块,点“是”。
  3. 给瓦片加碰撞:还是在“TileSet”标签,确认上方勾选了“物理层 0”;点选你的瓦片,在中间的瓦片预览里选择“添加碰撞多边形”,给要当实体的瓦片画一个盖住整格的矩形。这一步不做,瓦片就只是背景画,角色会穿过去。
  4. 绘制关卡:切回底部面板的“TileMap”标签,选中瓦片后在视口里像画图一样刷出地面、平台、高墙。配合快捷键:拖动框选连铺,右键擦除。

铺完直接 F5:角色能站在瓦片上、撞墙会被挡住,关卡就成立了。检查器里 TileMap 节点的 Physics Layer 0 的 Collision Layer/Mask 保持默认的 1 即可——CharacterBody2D 默认也检测第 1 层,两边都在层 1,碰撞才会发生。以后做“敌人不与玩家互撞但都与地面撞”这类需求,就是靠调整每个节点的碰撞层/掩码实现。

五、镜头与收集品

镜头跟随:给 Player 添加子节点 Camera2D,就这一步,镜头已经开始跟随角色了(Camera2D 挂在谁身上就拍谁)。在检查器里勾选 Position Smoothing(位置平滑),镜头会带一点缓动,跟手不生硬。想让镜头不看到关卡外的黑边,展开 Limit 属性,把 left/right/top/bottom 设成关卡边界的像素坐标即可。

金币收集品:金币要用 Area2D——它专管“范围检测”(谁进来了、谁出去了),不管实体碰撞,正适合收集品。新建场景 coin.tscn:根节点 Area2D,子节点 Sprite2D(放个金币图,没有就继续用 icon.svg 改色)和 CollisionShape2D(CircleShape2D,比贴图略大一点)。挂脚本 coin.gd

gdscript
extends Area2D

signal collected          # 自定义信号:我被收集啦

func _ready() -> void:
    body_entered.connect(_on_body_entered)

func _on_body_entered(body: Node2D) -> void:
    if body.is_in_group("player"):
        collected.emit()
        queue_free()      # 金币消失

body_enteredArea2D 的内置信号:任何物理体进入范围就会触发,参数 body 就是进来那个节点。注意玩家还没入组——回到 player.gd_ready 里加一行:

gdscript
func _ready() -> void:
    add_to_group("player")

计分与显示:回到主场景,添加 CanvasLayer(改名为 HUD),给它加子节点 Label,放到屏幕左上角。主场景根节点挂脚本 level.gd

gdscript
extends Node2D

var score: int = 0

@onready var score_label: Label = $HUD/Label

func _ready() -> void:
    # 把场景里每个金币的 collected 信号都接到计分函数
    for coin in get_tree().get_nodes_in_group("coins"):
        coin.collected.connect(_on_coin_collected)
    _update_label()

func _on_coin_collected() -> void:
    score += 1
    _update_label()

func _update_label() -> void:
    score_label.text = "金币:%d" % score

最后把 coin.tscn 从文件系统拖进主场景摆七八个,每个金币实例在检查器里加入 coins 分组(节点 → 分组 → 添加;玩家同理加 player 组)。F5 运行:跳起来吃金币,左上角数字跳动。看,信号机制和场景嵌套(第四、五节的两个金币是同一个场景的两个实例)全用上了。

六、敌人、失败与重启

游戏需要失败才叫游戏。我们做两件事:碰敌人死、掉出世界死,死了就重来。

巡逻敌人:新建 enemy.tscn,结构照抄金币(Area2D + Sprite2D + CollisionShape2D,贴图换色区分),挂 enemy.gd

gdscript
extends Area2D

@export var speed: float = 60.0       # 巡逻速度,可在检查器里逐个调
@export var distance: float = 120.0   # 来回巡逻的半径

var start_x: float
var direction: float = 1.0

func _ready() -> void:
    start_x = position.x
    body_entered.connect(_on_body_entered)

func _physics_process(delta: float) -> void:
    position.x += direction * speed * delta
    if abs(position.x - start_x) >= distance:
        direction = -direction        # 走够距离就掉头
        scale.x = direction           # 精灵跟着转向(可选)

func _on_body_entered(body: Node2D) -> void:
    if body.is_in_group("player"):
        get_tree().reload_current_scene()   # 玩家碰到敌人:整个关卡重来

坠坑死亡:主场景里再放一个 Area2D,改名为 KillZoneCollisionShape2D 拉成一根横贯整个关卡底部的长条(比地图略宽),挂在关卡外一切能掉下去的位置。给它挂个几行的脚本:

gdscript
extends Area2D

func _ready() -> void:
    body_entered.connect(_on_body_entered)

func _on_body_entered(body: Node2D) -> void:
    if body.is_in_group("player"):
        get_tree().reload_current_scene()

reload_current_scene() 会把当前场景整个推倒重载,金币复原、角色回到出生点——最朴素也最有效的死亡反馈。把敌人拖几个进主场景、给关卡再垫几块平台,一个“躲开敌人收集金币”的完整玩法循环就闭环了。F5,玩玩你自己做的游戏吧。

本页小结

  • CharacterBody2D 是玩家角色的标准答案:读 velocity、判 is_on_floor()、结尾调用无参数的 move_and_slide(),输入走 Input Map 动作名。
  • 跳跃 = 按键那一帧 + 在地面,两个条件同时满足就给 velocity.y 一个负值;手感全靠调那三个数字。
  • TileMap 铺关卡四步:建节点、导图块、给瓦片加物理碰撞、绘制;碰撞层保持在第 1 层才能站上去。
  • 收集品用 Area2D + body_entered + 自定义信号,计分放在场景主脚本里集中管理,UI 用 CanvasLayer 隔离镜头移动。
  • 失败与重启一行代码:get_tree().reload_current_scene();游戏好不好玩,先让“输了能重来”成立再说。

到这里你已经有了第一个可玩 Demo。下一页讨论一个更重要的问题:这个 Demo 之后,怎么决定做什么、砍什么。

请看 原型方法论