第二十二章:2D游戏实战案例

第二十二章: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平台跳跃游戏案例,我们综合运用了:

  1. 场景与节点:场景组织、节点层级
  2. GDScript编程:类、信号、协程
  3. 物理系统:CharacterBody2D、碰撞检测
  4. 动画系统:AnimatedSprite2D、Tween
  5. TileMap:关卡地图设计
  6. 相机系统:跟随、边界、特效
  7. UI系统:HUD、菜单
  8. 游戏管理:状态管理、存档

这个项目为后续学习3D游戏开发、网络功能等高级主题打下了坚实基础。


第三部分完结

恭喜你完成了2D游戏开发的全部内容!你已经掌握了:

  • Godot场景与节点架构
  • 精灵、动画、物理系统
  • TileMap关卡设计
  • 相机控制
  • 完整游戏开发流程

下一部分将进入3D游戏开发的学习。


上一章:2D相机系统

下一部分:3D游戏开发

← 返回目录