第二十二章:2D游戏实战案例
"理论与实践相结合,让我们用一个完整的游戏项目巩固所学知识。"
本章将通过开发一个完整的2D平台跳跃游戏,综合运用前面章节学到的所有知识。我们将实现角色控制、敌人AI、关卡设计、UI系统等完整的游戏功能。
22.1 项目概述
22.1.1 游戏设计
游戏名称:《像素冒险》(Pixel Adventure)
类型:2D平台跳跃
目标:收集金币,击败敌人,到达终点
核心玩法:
├── 角色移动(走、跑、跳)
├── 攻击系统
├── 敌人AI
├── 收集物品
├── 关卡设计
└── UI界面
22.1.2 项目结构
pixel_adventure/
├── scenes/
│ ├── main.tscn
│ ├── player/
│ │ └── player.tscn
│ ├── enemies/
│ │ ├── slime.tscn
│ │ └── bat.tscn
│ ├── objects/
│ │ ├── coin.tscn
│ │ ├── health_pickup.tscn
│ │ └── checkpoint.tscn
│ ├── levels/
│ │ ├── level_01.tscn
│ │ └── level_02.tscn
│ └── ui/
│ ├── hud.tscn
│ ├── pause_menu.tscn
│ └── game_over.tscn
├── scripts/
│ ├── player/
│ │ ├── player.gd
│ │ └── player_states.gd
│ ├── enemies/
│ │ ├── enemy_base.gd
│ │ └── slime.gd
│ ├── managers/
│ │ ├── game_manager.gd
│ │ └── audio_manager.gd
│ └── components/
│ ├── health_component.gd
│ └── hitbox.gd
├── assets/
│ ├── sprites/
│ ├── audio/
│ └── fonts/
└── project.godot
22.2 角色系统
22.2.1 玩家控制器
# player.gd
class_name Player
extends CharacterBody2D
signal died
signal health_changed(current: int, maximum: int)
signal coin_collected(total: int)
# 移动参数
@export_group("Movement")
@export var max_speed: float = 200.0
@export var acceleration: float = 1500.0
@export var friction: float = 1200.0
@export var air_friction: float = 400.0
# 跳跃参数
@export_group("Jump")
@export var jump_velocity: float = -350.0
@export var jump_cut_multiplier: float = 0.4
@export var coyote_time: float = 0.12
@export var jump_buffer_time: float = 0.15
@export var max_fall_speed: float = 500.0
# 攻击参数
@export_group("Combat")
@export var attack_damage: int = 10
@export var knockback_force: float = 200.0
# 组件引用
@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D
@onready var collision_shape: CollisionShape2D = $CollisionShape2D
@onready var hitbox: Area2D = $Hitbox
@onready var hurtbox: Area2D = $Hurtbox
@onready var camera: Camera2D = $Camera2D
@onready var coyote_timer: Timer = $CoyoteTimer
@onready var jump_buffer_timer: Timer = $JumpBufferTimer
# 状态变量
var coins: int = 0
var max_health: int = 100
var current_health: int = 100
var is_attacking: bool = false
var is_invincible: bool = false
var facing_direction: int = 1
# 重力
var gravity: float = ProjectSettings.get_setting("physics/2d/default_gravity")
func _ready():
hitbox.body_entered.connect(_on_hitbox_body_entered)
hurtbox.area_entered.connect(_on_hurtbox_area_entered)
coyote_timer.wait_time = coyote_time
jump_buffer_timer.wait_time = jump_buffer_time
func _physics_process(delta: float):
_apply_gravity(delta)
_handle_jump()
_handle_movement(delta)
_update_animation()
move_and_slide()
func _apply_gravity(delta: float):
if not is_on_floor():
velocity.y = min(velocity.y + gravity * delta, max_fall_speed)
else:
coyote_timer.start()
func _handle_jump():
# 跳跃缓冲
if Input.is_action_just_pressed("jump"):
jump_buffer_timer.start()
# 执行跳跃
var can_jump = is_on_floor() or not coyote_timer.is_stopped()
if can_jump and not jump_buffer_timer.is_stopped():
velocity.y = jump_velocity
coyote_timer.stop()
jump_buffer_timer.stop()
# 跳跃高度控制
if Input.is_action_just_released("jump") and velocity.y < 0:
velocity.y *= jump_cut_multiplier
func _handle_movement(delta: float):
if is_attacking:
return
var input_dir = Input.get_axis("move_left", "move_right")
if input_dir != 0:
velocity.x = move_toward(velocity.x, input_dir * max_speed, acceleration * delta)
facing_direction = sign(input_dir)
else:
var fric = friction if is_on_floor() else air_friction
velocity.x = move_toward(velocity.x, 0, fric * delta)
func _update_animation():
animated_sprite.flip_h = facing_direction < 0
if is_attacking:
return
if not is_on_floor():
animated_sprite.play("jump" if velocity.y < 0 else "fall")
elif abs(velocity.x) > 10:
animated_sprite.play("run")
else:
animated_sprite.play("idle")
func _unhandled_input(event: InputEvent):
if event.is_action_pressed("attack") and not is_attacking:
attack()
func attack():
is_attacking = true
animated_sprite.play("attack")
hitbox.monitoring = true
await animated_sprite.animation_finished
hitbox.monitoring = false
is_attacking = false
func take_damage(amount: int, knockback_dir: Vector2 = Vector2.ZERO):
if is_invincible:
return
current_health = max(0, current_health - amount)
health_changed.emit(current_health, max_health)
# 击退
if knockback_dir != Vector2.ZERO:
velocity = knockback_dir * knockback_force
# 无敌帧
_start_invincibility()
# 相机震动
camera.shake(8.0)
if current_health <= 0:
die()
func _start_invincibility(duration: float = 1.0):
is_invincible = true
# 闪烁效果
var tween = create_tween().set_loops(int(duration / 0.1))
tween.tween_property(animated_sprite, "modulate:a", 0.3, 0.05)
tween.tween_property(animated_sprite, "modulate:a", 1.0, 0.05)
await get_tree().create_timer(duration).timeout
is_invincible = false
animated_sprite.modulate.a = 1.0
func die():
set_physics_process(false)
animated_sprite.play("death")
died.emit()
func collect_coin():
coins += 1
coin_collected.emit(coins)
func heal(amount: int):
current_health = min(max_health, current_health + amount)
health_changed.emit(current_health, max_health)
func _on_hitbox_body_entered(body: Node2D):
if body.has_method("take_damage"):
var dir = (body.global_position - global_position).normalized()
body.take_damage(attack_damage, dir)
func _on_hurtbox_area_entered(area: Area2D):
if area.is_in_group("enemy_attack"):
var damage = area.get("damage") if area.get("damage") else 10
var dir = (global_position - area.global_position).normalized()
take_damage(damage, dir)
22.3 敌人系统
22.3.1 敌人基类
# enemy_base.gd
class_name EnemyBase
extends CharacterBody2D
signal died
@export var max_health: int = 30
@export var damage: int = 10
@export var move_speed: float = 50.0
@export var detection_range: float = 200.0
@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D
@onready var hitbox: Area2D = $Hitbox
@onready var detection_area: Area2D = $DetectionArea
var current_health: int
var target: Node2D = null
var gravity: float = ProjectSettings.get_setting("physics/2d/default_gravity")
func _ready():
current_health = max_health
add_to_group("enemies")
hitbox.get_child(0).shape.radius = detection_range
detection_area.body_entered.connect(_on_detection_area_body_entered)
detection_area.body_exited.connect(_on_detection_area_body_exited)
func _physics_process(delta: float):
if not is_on_floor():
velocity.y += gravity * delta
_update_behavior(delta)
move_and_slide()
func _update_behavior(delta: float):
# 由子类实现
pass
func take_damage(amount: int, knockback_dir: Vector2 = Vector2.ZERO):
current_health -= amount
# 受击效果
_flash_white()
if knockback_dir != Vector2.ZERO:
velocity = knockback_dir * 150
if current_health <= 0:
die()
func _flash_white():
var tween = create_tween()
tween.tween_property(animated_sprite, "modulate", Color.WHITE * 2, 0.05)
tween.tween_property(animated_sprite, "modulate", Color.WHITE, 0.1)
func die():
died.emit()
# 播放死亡动画
animated_sprite.play("death")
set_physics_process(false)
# 可选:掉落物品
_spawn_drops()
await animated_sprite.animation_finished
queue_free()
func _spawn_drops():
# 由子类实现
pass
func _on_detection_area_body_entered(body: Node2D):
if body is Player:
target = body
func _on_detection_area_body_exited(body: Node2D):
if body == target:
target = null
22.3.2 史莱姆敌人
# slime.gd
extends EnemyBase
enum State { IDLE, PATROL, CHASE, ATTACK }
@export var patrol_distance: float = 100.0
@export var attack_range: float = 40.0
@export var attack_cooldown: float = 1.0
var current_state: State = State.PATROL
var patrol_start: Vector2
var patrol_direction: int = 1
var can_attack: bool = true
func _ready():
super._ready()
patrol_start = global_position
func _update_behavior(delta: float):
match current_state:
State.IDLE:
_idle_behavior(delta)
State.PATROL:
_patrol_behavior(delta)
State.CHASE:
_chase_behavior(delta)
State.ATTACK:
_attack_behavior(delta)
func _idle_behavior(delta: float):
velocity.x = 0
animated_sprite.play("idle")
if target:
current_state = State.CHASE
func _patrol_behavior(delta: float):
animated_sprite.play("walk")
velocity.x = patrol_direction * move_speed
animated_sprite.flip_h = patrol_direction < 0
# 巡逻范围检查
if abs(global_position.x - patrol_start.x) > patrol_distance:
patrol_direction *= -1
# 检测悬崖
if is_on_floor() and not _check_floor_ahead():
patrol_direction *= -1
# 发现玩家
if target and global_position.distance_to(target.global_position) < detection_range:
current_state = State.CHASE
func _chase_behavior(delta: float):
if not target:
current_state = State.PATROL
return
animated_sprite.play("walk")
var distance = global_position.distance_to(target.global_position)
var direction = sign(target.global_position.x - global_position.x)
animated_sprite.flip_h = direction < 0
if distance < attack_range and can_attack:
current_state = State.ATTACK
elif distance > detection_range * 1.5:
target = null
current_state = State.PATROL
else:
velocity.x = direction * move_speed * 1.5
func _attack_behavior(delta: float):
velocity.x = 0
if not can_attack:
current_state = State.CHASE
return
can_attack = false
animated_sprite.play("attack")
await animated_sprite.animation_finished
# 攻击冷却
await get_tree().create_timer(attack_cooldown).timeout
can_attack = true
current_state = State.CHASE
func _check_floor_ahead() -> bool:
var space = get_world_2d().direct_space_state
var start = global_position + Vector2(patrol_direction * 16, 0)
var end = start + Vector2(0, 20)
var query = PhysicsRayQueryParameters2D.create(start, end)
var result = space.intersect_ray(query)
return not result.is_empty()
func _spawn_drops():
# 30%几率掉落金币
if randf() < 0.3:
var coin = preload("res://scenes/objects/coin.tscn").instantiate()
coin.global_position = global_position
get_parent().add_child(coin)
22.4 收集物品
22.4.1 金币
# coin.gd
extends Area2D
@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D
func _ready():
animated_sprite.play("spin")
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node2D):
if body is Player:
body.collect_coin()
_collect_effect()
func _collect_effect():
# 禁用碰撞
set_deferred("monitoring", false)
# 收集动画
var tween = create_tween()
tween.set_parallel(true)
tween.tween_property(self, "position:y", position.y - 30, 0.2)
tween.tween_property(self, "modulate:a", 0.0, 0.2)
tween.set_parallel(false)
tween.tween_callback(queue_free)
22.4.2 检查点
# checkpoint.gd
extends Area2D
signal activated
@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D
var is_active: bool = false
func _ready():
animated_sprite.play("inactive")
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node2D):
if body is Player and not is_active:
activate()
func activate():
is_active = true
animated_sprite.play("active")
activated.emit()
# 保存检查点位置
GameManager.set_checkpoint(global_position)
22.5 UI系统
22.5.1 HUD
# hud.gd
extends CanvasLayer
@onready var health_bar: ProgressBar = $MarginContainer/VBoxContainer/HealthBar
@onready var coin_label: Label = $MarginContainer/VBoxContainer/CoinContainer/CoinLabel
func _ready():
var player = get_tree().get_first_node_in_group("player")
if player:
player.health_changed.connect(_on_health_changed)
player.coin_collected.connect(_on_coin_collected)
_on_health_changed(player.current_health, player.max_health)
func _on_health_changed(current: int, maximum: int):
health_bar.max_value = maximum
health_bar.value = current
func _on_coin_collected(total: int):
coin_label.text = str(total)
# 收集动画
var tween = create_tween()
tween.tween_property(coin_label, "scale", Vector2(1.3, 1.3), 0.1)
tween.tween_property(coin_label, "scale", Vector2.ONE, 0.1)
22.5.2 暂停菜单
# pause_menu.gd
extends CanvasLayer
@onready var container: Control = $CenterContainer
func _ready():
visible = false
process_mode = Node.PROCESS_MODE_ALWAYS
func _unhandled_input(event: InputEvent):
if event.is_action_pressed("pause"):
toggle_pause()
func toggle_pause():
visible = not visible
get_tree().paused = visible
func _on_resume_pressed():
toggle_pause()
func _on_settings_pressed():
# 打开设置界面
pass
func _on_quit_pressed():
get_tree().paused = false
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
22.6 游戏管理
22.6.1 游戏管理器
# game_manager.gd (Autoload)
extends Node
signal game_over
signal level_completed
var current_level: int = 1
var total_coins: int = 0
var checkpoint_position: Vector2 = Vector2.ZERO
var player_health: int = 100
func _ready():
process_mode = Node.PROCESS_MODE_ALWAYS
func set_checkpoint(pos: Vector2):
checkpoint_position = pos
func get_spawn_position() -> Vector2:
if checkpoint_position != Vector2.ZERO:
return checkpoint_position
return Vector2(100, 100) # 默认出生点
func respawn_player():
# 重置玩家位置
var player = get_tree().get_first_node_in_group("player")
if player:
player.global_position = get_spawn_position()
player.current_health = player.max_health
player.health_changed.emit(player.current_health, player.max_health)
func complete_level():
current_level += 1
checkpoint_position = Vector2.ZERO
level_completed.emit()
# 加载下一关
var next_level = "res://scenes/levels/level_%02d.tscn" % current_level
if ResourceLoader.exists(next_level):
get_tree().change_scene_to_file(next_level)
else:
# 游戏通关
get_tree().change_scene_to_file("res://scenes/ui/victory.tscn")
func trigger_game_over():
game_over.emit()
await get_tree().create_timer(2.0).timeout
get_tree().change_scene_to_file("res://scenes/ui/game_over.tscn")
func restart_level():
checkpoint_position = Vector2.ZERO
get_tree().reload_current_scene()
22.7 关卡设计
22.7.1 关卡场景结构
Level_01 (Node2D)
├── TileMap
├── Background
│ ├── ParallaxBackground
│ └── ParallaxLayers...
├── Player
├── Enemies
│ ├── Slime
│ └── Bat
├── Collectibles
│ ├── Coins...
│ └── HealthPickups...
├── Objects
│ ├── Checkpoints...
│ ├── Platforms...
│ └── Hazards...
├── LevelBounds (Area2D)
└── UI
├── HUD
└── PauseMenu
22.7.2 关卡脚本
# level.gd
extends Node2D
@export var level_name: String = "Level 1"
@export var next_level: String = "res://scenes/levels/level_02.tscn"
@onready var player: Player = $Player
@onready var level_bounds: Area2D = $LevelBounds
@onready var goal: Area2D = $Goal
func _ready():
# 连接玩家信号
player.died.connect(_on_player_died)
# 设置相机边界
_setup_camera_limits()
# 连接终点
goal.body_entered.connect(_on_goal_reached)
# 死亡区域
level_bounds.body_exited.connect(_on_body_exited_bounds)
func _setup_camera_limits():
var tilemap = $TileMap
var rect = tilemap.get_used_rect()
var tile_size = tilemap.tile_set.tile_size
player.camera.limit_left = rect.position.x * tile_size.x
player.camera.limit_top = rect.position.y * tile_size.y
player.camera.limit_right = rect.end.x * tile_size.x
player.camera.limit_bottom = rect.end.y * tile_size.y
func _on_player_died():
await get_tree().create_timer(1.5).timeout
GameManager.respawn_player()
func _on_goal_reached(body: Node2D):
if body is Player:
GameManager.complete_level()
func _on_body_exited_bounds(body: Node2D):
if body is Player:
body.take_damage(body.current_health) # 即死
22.8 完整项目清单
22.8.1 项目配置
# project.godot 关键设置
[application]
config/name="Pixel Adventure"
run/main_scene="res://scenes/main_menu.tscn"
config/features=PackedStringArray("4.2")
[autoload]
GameManager="*res://scripts/managers/game_manager.gd"
AudioManager="*res://scripts/managers/audio_manager.gd"
[input]
move_left={
"deadzone": 0.5,
"events": [Object(InputEventKey, "keycode": KEY_A), Object(InputEventKey, "keycode": KEY_LEFT)]
}
move_right={
"events": [Object(InputEventKey, "keycode": KEY_D), Object(InputEventKey, "keycode": KEY_RIGHT)]
}
jump={
"events": [Object(InputEventKey, "keycode": KEY_SPACE), Object(InputEventKey, "keycode": KEY_W)]
}
attack={
"events": [Object(InputEventMouseButton, "button_index": MOUSE_BUTTON_LEFT)]
}
pause={
"events": [Object(InputEventKey, "keycode": KEY_ESCAPE)]
}
[layer_names]
2d_physics/layer_1="player"
2d_physics/layer_2="enemy"
2d_physics/layer_3="terrain"
2d_physics/layer_4="player_attack"
2d_physics/layer_5="enemy_attack"
2d_physics/layer_6="collectible"
[physics]
common/physics_ticks_per_second=60
2d/default_gravity=980
22.8.2 开发流程总结
1. 项目初始化
- 创建项目结构
- 配置输入映射
- 设置碰撞层
- 导入素材
2. 核心系统
- 玩家控制器
- 基础物理
- 相机跟随
3. 游戏玩法
- 敌人AI
- 战斗系统
- 收集系统
4. 关卡设计
- TileMap设置
- 关卡布局
- 检查点系统
5. UI系统
- HUD
- 菜单系统
- 游戏流程
6. 打磨优化
- 音效音乐
- 特效粒子
- 性能优化
本章小结
通过这个完整的2D平台跳跃游戏案例,我们综合运用了:
- 场景与节点:场景组织、节点层级
- GDScript编程:类、信号、协程
- 物理系统:CharacterBody2D、碰撞检测
- 动画系统:AnimatedSprite2D、Tween
- TileMap:关卡地图设计
- 相机系统:跟随、边界、特效
- UI系统:HUD、菜单
- 游戏管理:状态管理、存档
这个项目为后续学习3D游戏开发、网络功能等高级主题打下了坚实基础。
第三部分完结
恭喜你完成了2D游戏开发的全部内容!你已经掌握了:
- Godot场景与节点架构
- 精灵、动画、物理系统
- TileMap关卡设计
- 相机控制
- 完整游戏开发流程
下一部分将进入3D游戏开发的学习。
上一章:2D相机系统
下一部分:3D游戏开发