强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

Godot 3 GDScript 教程 / Godot 3 GDScript 教程(二十):完整项目 — 2D 平台跳跃游戏

完整项目:2D 平台跳跃游戏

本章从零开始构建一个完整的 2D 平台跳跃游戏,涵盖项目架构、角色控制、关卡设计、敌人 AI、UI 系统、存档和多关卡流程。


项目架构设计

res://
├── scenes/
│   ├── player/     # Player.tscn, Player.gd
│   ├── enemies/    # Slime.tscn, Bat.tscn
│   ├── items/      # Coin.tscn, PowerUp.tscn
│   ├── levels/     # Level1.tscn, Level2.tscn
│   ├── ui/         # HUD, MainMenu, PauseMenu, GameOver
│   └── effects/    # CoinEffect, DeathEffect
├── scripts/
│   ├── GameManager.gd   # Autoload
│   ├── SaveManager.gd   # Autoload
│   ├── AudioManager.gd  # Autoload
│   └── LevelManager.gd  # Autoload
└── assets/
    ├── sprites/, tilesets/, audio/, fonts/

每个关卡场景树:

Level (Node2D)
├── TileMap (地形)
├── Player (KinematicBody2D)
├── Enemies (Node2D)
├── Items (Node2D)
├── Goal (Area2D)
├── Camera2D
└── CanvasLayer → HUD

玩家角色(KinematicBody2D)

Player 场景:
Player (KinematicBody2D)
├── Sprite (AnimatedSprite)
├── CollisionShape2D
├── CoyoteTimer (Timer)
└── JumpBuffer (Timer)

Player.gd

extends KinematicBody2D

export var run_speed: float = 200.0
export var jump_force: float = -400.0
export var gravity: float = 1200.0
export var max_fall_speed: float = 600.0
export var acceleration: float = 1500.0
export var friction: float = 1200.0

var velocity: Vector2 = Vector2.ZERO
var facing_right: bool = true
var can_coyote_jump: bool = false
var jump_buffered: bool = false
var is_dead: bool = false

onready var sprite = $Sprite
onready var collision = $CollisionShape2D
onready var coyote_timer = $CoyoteTimer

func _ready():
    GameManager.player = self
    coyote_timer.wait_time = 0.1
    coyote_timer.one_shot = true

func _physics_process(delta):
    if is_dead:
        return
    velocity.y += gravity * delta
    velocity.y = min(velocity.y, max_fall_speed)

    var input_dir = Input.get_action_strength("move_right") - Input.get_action_strength("move_left")
    if input_dir != 0:
        velocity.x = move_toward(velocity.x, input_dir * run_speed, acceleration * delta)
        facing_right = input_dir > 0
        sprite.flip_h = not facing_right
    else:
        velocity.x = move_toward(velocity.x, 0, friction * delta)

    if is_on_floor():
        can_coyote_jump = true
    elif can_coyote_jump and coyote_timer.is_stopped():
        coyote_timer.start()

    if Input.is_action_just_pressed("jump"):
        jump_buffered = true
    if jump_buffered and (is_on_floor() or can_coyote_jump):
        velocity.y = jump_force
        jump_buffered = false
        can_coyote_jump = false
    if Input.is_action_just_released("jump") and velocity.y < 0:
        velocity.y *= 0.5

    velocity = move_and_slide(velocity, Vector2.UP)
    _update_animation()
    if position.y > 1000:
        die()

func _update_animation():
    if not is_on_floor():
        sprite.play("jump" if velocity.y < 0 else "fall")
    elif abs(velocity.x) > 10:
        sprite.play("run")
    else:
        sprite.play("idle")

func bounce():
    velocity.y = jump_force * 0.7

func die():
    if is_dead: return
    is_dead = true
    GameManager.lose_life()
    if GameManager.lives > 0:
        yield(get_tree().create_timer(0.5), "timeout")
        respawn()
    else:
        LevelManager.game_over()

func respawn():
    is_dead = false
    velocity = Vector2.ZERO
    position = GameManager.last_checkpoint

⚠️ 注意move_and_slideVector2.UP 参数告诉引擎哪个方向是"上",用于判断 is_on_floor()


关卡设计(TileMap)

原则说明
渐进式难度先教会基本操作,再增加复杂度
引导视线使用灯光、颜色引导玩家
安全着陆点跳跃后应有明确的落脚点
视觉层次ParallaxBackground 增加深度

💡 提示:先用简单方块搭建粗略布局测试手感,确认后再用精美素材替换。


敌人 AI

巡逻敌人(Slime)

extends KinematicBody2D

export var speed: float = 60.0
export var gravity: float = 800.0
var velocity: Vector2 = Vector2.ZERO
var direction: int = 1

func _physics_process(delta):
    velocity.y += gravity * delta
    velocity.x = direction * speed
    if not $FloorDetect.is_colliding():
        direction *= -1
    $Sprite.flip_h = direction < 0
    velocity = move_and_slide(velocity, Vector2.UP)

func _on_Hurtbox_body_entered(body):
    if body.is_in_group("player"):
        if body.velocity.y > 0 and body.global_position.y < global_position.y - 10:
            die()
            body.bounce()
        else:
            body.take_damage()

func die():
    var effect = preload("res://scenes/effects/DeathEffect.tscn").instance()
    effect.global_position = global_position
    get_parent().add_child(effect)
    queue_free()

飞行敌人(Bat)

extends KinematicBody2D

enum State { IDLE, CHASE, RETURN }
var state: int = State.IDLE
var home_position: Vector2
export var speed: float = 80.0
export var detection_range: float = 200.0

func _ready():
    home_position = position

func _physics_process(delta):
    var player = GameManager.player
    if player == null:
        return
    var dist = global_position.distance_to(player.global_position)
    var dir = Vector2.ZERO
    match state:
        State.IDLE:
            position.y = home_position.y + sin(OS.get_ticks_msec() * 0.003) * 10
            if dist < detection_range:
                state = State.CHASE
        State.CHASE:
            dir = (player.global_position - global_position).normalized()
            move_and_slide(dir * speed)
            if dist > detection_range * 1.5:
                state = State.RETURN
        State.RETURN:
            dir = (home_position - global_position).normalized()
            move_and_slide(dir * speed)
            if global_position.distance_to(home_position) < 10:
                state = State.IDLE

金币与道具

# Coin.gd
extends Area2D

export var coin_value: int = 1
var start_y: float

func _ready():
    start_y = position.y

func _process(delta):
    position.y = start_y + sin(OS.get_ticks_msec() * 0.003) * 5.0

func _on_Coin_body_entered(body):
    if body.is_in_group("player"):
        GameManager.add_coins(coin_value)
        var effect = preload("res://scenes/effects/CoinEffect.tscn").instance()
        effect.global_position = global_position
        get_parent().add_child(effect)
        queue_free()
# PowerUp.gd
extends Area2D

enum Type { EXTRA_LIFE, SPEED_BOOST, DOUBLE_JUMP }
export(Type) var type = Type.EXTRA_LIFE
export var duration: float = 10.0

func _on_PowerUp_body_entered(body):
    if body.is_in_group("player"):
        match type:
            Type.EXTRA_LIFE:
                GameManager.add_life()
            Type.SPEED_BOOST:
                body.run_speed *= 1.5
                yield(get_tree().create_timer(duration), "timeout")
                body.run_speed /= 1.5
        queue_free()

UI 界面

HUD.gd

extends CanvasLayer

onready var coin_label = $TopBar/CoinLabel
onready var score_label = $TopBar/ScoreLabel
var heart_full = preload("res://assets/sprites/ui/heart_full.png")
var heart_empty = preload("res://assets/sprites/ui/heart_empty.png")

func _ready():
    GameManager.connect("stats_changed", self, "_update_display")
    _update_display()

func _update_display():
    var hearts = $TopBar/LivesContainer.get_children()
    for i in range(hearts.size()):
        hearts[i].texture = heart_full if i < GameManager.lives else heart_empty
    coin_label.text = %d" % GameManager.coins
    score_label.text = "分数: %d" % GameManager.score

主菜单

extends Control

func _ready():
    $VBox/ContinueButton.visible = SaveManager.has_save()

func _on_NewGame_pressed():
    GameManager.reset_game()
    LevelManager.start_level(1)

func _on_Continue_pressed():
    SaveManager.load_game()
    LevelManager.start_level(GameManager.current_level)

音效与音乐

extends Node

var sfx_pool: Array = []
var music_player: AudioStreamPlayer

const SFX = {
    "jump": "res://assets/audio/sfx/jump.wav",
    "coin": "res://assets/audio/sfx/coin.wav",
    "death": "res://assets/audio/sfx/death.wav"
}

func _ready():
    music_player = AudioStreamPlayer.new()
    music_player.bus = "Music"
    add_child(music_player)
    for i in range(8):
        var p = AudioStreamPlayer.new()
        p.bus = "SFX"
        add_child(p)
        sfx_pool.append(p)

func play_sfx(name: String):
    if not name in SFX:
        return
    for p in sfx_pool:
        if not p.playing:
            p.stream = load(SFX[name])
            p.pitch_scale = rand_range(0.9, 1.1)
            p.play()
            return

存档系统

extends Node

const SAVE_PATH = "user://save_data.json"

func save_game():
    var data = {"version": 1, "game": GameManager.get_save_data()}
    var file = File.new()
    file.open(SAVE_PATH, File.WRITE)
    file.store_string(JSON.print(data, "\t"))
    file.close()

func load_game() -> bool:
    var file = File.new()
    if not file.file_exists(SAVE_PATH):
        return false
    file.open(SAVE_PATH, File.READ)
    var result = JSON.parse(file.get_as_text())
    file.close()
    if result.error != OK:
        return false
    GameManager.load_save_data(result.result["game"])
    return true

func has_save() -> bool:
    return File.new().file_exists(SAVE_PATH)

多关卡流程

extends Node

var level_scenes = {
    1: "res://scenes/levels/Level1.tscn",
    2: "res://scenes/levels/Level2.tscn",
    3: "res://scenes/levels/Level3.tscn"
}

func start_level(id: int):
    if id in level_scenes:
        GameManager.current_level = id
        get_tree().change_scene(level_scenes[id])

func next_level():
    var next = GameManager.current_level + 1
    if next in level_scenes:
        GameManager.complete_level()
        start_level(next)
    else:
        get_tree().change_scene("res://scenes/ui/Victory.tscn")

func retry_level():
    start_level(GameManager.current_level)

func go_to_main_menu():
    get_tree().change_scene("res://scenes/ui/MainMenu.tscn")

func game_over():
    get_tree().change_scene("res://scenes/ui/GameOver.tscn")
extends Node

signal stats_changed

var player = null
var lives: int = 3
var coins: int = 0
var score: int = 0
var current_level: int = 1
var last_checkpoint: Vector2 = Vector2.ZERO
var unlocked_levels: Array = [1]

func add_coins(amount: int):
    coins += amount; score += amount * 10
    emit_signal("stats_changed")

func lose_life():
    lives -= 1
    emit_signal("stats_changed")

func add_life():
    lives += 1
    emit_signal("stats_changed")

func complete_level():
    unlocked_levels.append(current_level + 1)
    SaveManager.save_game()

func reset_game():
    lives = 3; coins = 0; score = 0; current_level = 1
    unlocked_levels = [1]
    emit_signal("stats_changed")

func get_save_data() -> Dictionary:
    return {"lives": lives, "coins": coins, "score": score,
            "current_level": current_level, "unlocked_levels": unlocked_levels}

func load_save_data(data: Dictionary):
    lives = data.get("lives", 3)
    coins = data.get("coins", 0)
    score = data.get("score", 0)
    current_level = data.get("current_level", 1)
    unlocked_levels = data.get("unlocked_levels", [1])
    emit_signal("stats_changed")

发布与优化

像素风格设置

Display → Window → Size: 320×180
Display → Window → Stretch → Mode: viewport
Rendering → Textures → Default Filter: Nearest
检查项说明
所有关卡可通关从头到尾完整测试
存档正常保存/加载测试
性能达标目标平台 60 FPS
无崩溃边界情况测试

扩展阅读

💡 总结:完整的 2D 平台跳跃游戏包含:玩家控制器(KinematicBody2D)、关卡系统(TileMap)、敌人 AI(状态机)、收集系统(金币/道具)、UI(HUD + 菜单)、音效和存档(JSON 持久化)。将各系统模块化,通过 Autoload 通信,是构建可维护游戏的最佳实践。