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_slide 的 Vector2.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 通信,是构建可维护游戏的最佳实践。