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

Godot 4 GDScript 教程 / 30 - 完整项目:3D 第三人称冒险游戏

30 - 完整项目:3D 第三人称冒险游戏

本章将综合前面所学,从零开始构建一个完整的 3D 第三人称冒险游戏。涵盖角色控制、相机系统、关卡设计、敌人 AI、战斗系统、UI 界面、音效、存档集成和多关卡流程。


项目架构设计

目录结构

res://
├── scenes/
│   ├── player/
│   │   ├── player.tscn
│   │   └── player.gd
│   ├── enemies/
│   │   ├── enemy_base.tscn
│   │   ├── skeleton.tscn
│   │   └── dragon.tscn
│   ├── levels/
│   │   ├── level_01.tscn
│   │   ├── level_02.tscn
│   │   └── level_template.tscn
│   ├── ui/
│   │   ├── hud.tscn
│   │   ├── minimap.tscn
│   │   ├── dialog_box.tscn
│   │   └── inventory_ui.tscn
│   └── props/
│       ├── chest.tscn
│       ├── health_potion.tscn
│       └── sword.tscn
├── scripts/
│   ├── autoload/
│   │   ├── game_manager.gd
│   │   ├── audio_manager.gd
│   │   └── save_manager.gd
│   ├── systems/
│   │   ├── combat_system.gd
│   │   ├── inventory_system.gd
│   │   ├── dialog_system.gd
│   │   └── quest_system.gd
│   └── resources/
│       ├── weapon_data.gd
│       ├── enemy_data.gd
│       └── dialog_data.gd
├── assets/
│   ├── models/
│   ├── textures/
│   ├── audio/
│   │   ├── music/
│   │   ├── sfx/
│   │   └── ambient/
│   └── animations/
├── data/
│   ├── weapons/
│   ├── enemies/
│   └── dialogs/
└── project.godot

核心系统架构

# game_manager.gd — 全局游戏管理器 (Autoload)
extends Node

signal game_paused
signal game_resumed
signal player_died
signal level_changed(level_name: String)

var player_name: String = "冒险者"
var player_level: int = 1
var player_exp: int = 0
var player_max_health: int = 100
var player_health: int = 100
var player_max_mana: int = 50
var player_mana: int = 50
var gold: int = 0

var current_level: String = ""
var play_time: float = 0.0
var is_paused: bool = false

var inventory: InventorySystem
var quest_manager: QuestSystem
var combat_system: CombatSystem

func _ready():
    inventory = InventorySystem.new()
    quest_manager = QuestSystem.new()
    combat_system = CombatSystem.new()
    add_child(inventory)
    add_child(quest_manager)
    add_child(combat_system)

func _process(delta):
    if not is_paused:
        play_time += delta

func pause_game():
    is_paused = true
    get_tree().paused = true
    game_paused.emit()

func resume_game():
    is_paused = false
    get_tree().paused = false
    game_resumed.emit()

func change_level(level_path: String):
    current_level = level_path
    level_changed.emit(level_path)
    get_tree().change_scene_to_file(level_path)

func heal(amount: int):
    player_health = mini(player_health + amount, player_max_health)

func take_damage(amount: int):
    player_health = maxi(player_health - amount, 0)
    if player_health <= 0:
        player_died.emit()

3D 角色控制器

CharacterBody3D 实现

# player.gd
extends CharacterBody3D

@export_group("移动参数")
@export var walk_speed: float = 3.0
@export var run_speed: float = 6.0
@export var acceleration: float = 10.0
@export var deceleration: float = 12.0
@export var rotation_speed: float = 10.0

@export_group("跳跃参数")
@export var jump_force: float = 4.5
@export var gravity_multiplier: float = 2.5
@export var coyote_time: float = 0.15
@export var jump_buffer_time: float = 0.1

@export_group("战斗参数")
@export var attack_range: float = 2.0
@export var combo_window: float = 0.8

@onready var model: Node3D = $Model
@onready var anim_player: AnimationPlayer = $Model/AnimationPlayer
@onready var anim_tree: AnimationTree = $Model/AnimationTree
@onready var camera_pivot: Node3D = $CameraPivot
@onready var camera: Camera3D = $CameraPivot/Camera3D
@onready var attack_area: Area3D = $Model/AttackArea
@onready var state_machine: AnimationNodeStateMachinePlayback

enum PlayerState { IDLE, WALKING, RUNNING, JUMPING, FALLING, ATTACKING, HURT, DEAD }

var current_state: PlayerState = PlayerState.IDLE
var gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity")
var coyote_timer: float = 0.0
var jump_buffer_timer: float = 0.0
var combo_count: int = 0
var combo_timer: float = 0.0
var invincible: bool = false

func _ready():
    state_machine = anim_tree.get("parameters/playback")
    attack_area.body_entered.connect(_on_attack_hit)
    GameManager.player_died.connect(_on_player_died)

func _physics_process(delta):
    _handle_gravity(delta)
    _handle_movement(delta)
    _handle_jump(delta)
    _handle_combat(delta)
    _update_animation()

    move_and_slide()

func _handle_gravity(delta):
    if not is_on_floor():
        velocity.y -= gravity * gravity_multiplier * delta

func _handle_movement(delta):
    var input_dir = Input.get_vector("move_left", "move_right", "move_forward", "move_back")
    var direction = Vector3(input_dir.x, 0, input_dir.y)
    direction = (camera.global_transform.basis * direction).normalized()

    var is_running = Input.is_action_pressed("run")
    var target_speed = run_speed if is_running else walk_speed

    if direction.length() > 0.1:
        velocity.x = move_toward(velocity.x, direction.x * target_speed, acceleration * delta)
        velocity.z = move_toward(velocity.z, direction.z * target_speed, acceleration * delta)

        # 角色朝向移动方向
        var target_angle = atan2(direction.x, direction.z)
        model.rotation.y = lerp_angle(model.rotation.y, target_angle, rotation_speed * delta)

        current_state = PlayerState.RUNNING if is_running else PlayerState.WALKING
    else:
        velocity.x = move_toward(velocity.x, 0, deceleration * delta)
        velocity.z = move_toward(velocity.z, 0, deceleration * delta)

        if is_on_floor() and current_state != PlayerState.ATTACKING:
            current_state = PlayerState.IDLE

func _handle_jump(delta):
    if is_on_floor():
        coyote_timer = coyote_time
    else:
        coyote_timer -= delta

    if Input.is_action_just_pressed("jump"):
        jump_buffer_timer = jump_buffer_time
    else:
        jump_buffer_timer -= delta

    if jump_buffer_timer > 0 and coyote_timer > 0:
        velocity.y = jump_force
        jump_buffer_timer = 0
        coyote_timer = 0
        current_state = PlayerState.JUMPING

    if not is_on_floor() and velocity.y < 0:
        current_state = PlayerState.FALLING

func _handle_combat(delta):
    if combo_timer > 0:
        combo_timer -= delta
    else:
        combo_count = 0

    if Input.is_action_just_pressed("attack") and current_state != PlayerState.ATTACKING:
        _start_attack()

func _start_attack():
    current_state = PlayerState.ATTACKING
    combo_count = mini(combo_count + 1, 3)
    combo_timer = combo_window

    match combo_count:
        1: state_machine.travel("attack_1")
        2: state_machine.travel("attack_2")
        3: state_machine.travel("attack_3")

    anim_player.animation_finished.connect(
        func(_anim): current_state = PlayerState.IDLE,
        CONNECT_ONE_SHOT
    )

func _on_attack_hit(body: Node3D):
    if body.is_in_group("enemies") and body.has_method("take_damage"):
        var weapon = GameManager.inventory.get_equipped_weapon()
        var damage = weapon.damage if weapon else 10
        damage += combo_count * 5  # 连击加成
        body.take_damage(damage, global_position)

func _update_animation():
    anim_tree.set("parameters/BlendSpace1D/blend_position", velocity.length() / run_speed)
    anim_tree.set("parameters/conditions/is_on_floor", is_on_floor())
    anim_tree.set("parameters/conditions/is_attacking", current_state == PlayerState.ATTACKING)

func _on_player_died():
    current_state = PlayerState.DEAD
    state_machine.travel("death")
    set_physics_process(false)

相机跟随系统

# camera_controller.gd
extends Node3D

@export var follow_target: Node3D
@export var distance: float = 5.0
@export var height: float = 3.0
@export var mouse_sensitivity: float = 0.003
@export var smooth_speed: float = 10.0
@export var min_pitch: float = -30.0
@export var max_pitch: float = 60.0
@export var collision_offset: float = 0.5

@onready var camera: Camera3D = $Camera3D

var yaw: float = 0.0
var pitch: float = -20.0
var target_position: Vector3

func _ready():
    Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
    _update_camera_transform()

func _input(event):
    if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
        yaw -= event.relative.x * mouse_sensitivity
        pitch = clampf(pitch - event.relative.y * mouse_sensitivity,
            deg_to_rad(min_pitch), deg_to_rad(max_pitch))

    if Input.is_action_just_pressed("ui_cancel"):
        Input.mouse_mode = Input.MOUSE_MODE_VISIBLE if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED else Input.MOUSE_MODE_CAPTURED

func _physics_process(delta):
    if not follow_target:
        return

    target_position = follow_target.global_position + Vector3.UP * height
    global_position = global_position.lerp(target_position, smooth_speed * delta)

    _update_camera_transform()
    _handle_collision()

func _update_camera_transform():
    var offset = Vector3(0, 0, distance)
    offset = offset.rotated(Vector3.RIGHT, pitch)
    offset = offset.rotated(Vector3.UP, yaw)

    camera.global_position = global_position + offset
    camera.look_at(global_position)

func _handle_collision():
    var space_state = get_world_3d().direct_space_state
    var query = PhysicsRayQueryParameters3D.create(
        global_position, camera.global_position
    )
    query.exclude = [self]

    var result = space_state.intersect_ray(query)
    if result:
        camera.global_position = result.position + result.normal * collision_offset
        camera.look_at(global_position)

func get_forward_direction() -> Vector3:
    return -global_transform.basis.z

func get_movement_basis() -> Basis:
    return Basis(Vector3.UP, yaw)

💡 使用 PhysicsRayQueryParameters3D 做相机碰撞检测,防止相机穿入墙壁。exclude 数组排除自身避免自碰撞。


关卡设计

WorldEnvironment 配置

# level_environment.gd
extends WorldEnvironment

@export_group("光照")
@export var sun_energy: float = 1.0
@export var sun_color: Color = Color(1, 0.95, 0.9)
@export var ambient_light_energy: float = 0.3

@export_group("雾效")
@export var fog_enabled: bool = true
@export var fog_density: float = 0.002
@export var fog_light_color: Color = Color(0.7, 0.8, 0.9)

@export_group("天空")
@export var sky_top_color: Color = Color(0.2, 0.4, 0.8)
@export var sky_horizon_color: Color = Color(0.6, 0.7, 0.9)

func _ready():
    _apply_environment()

func _apply_environment():
    var env = environment

    # 天空
    var sky = ProceduralSky.new()
    sky.sky_top_color = sky_top_color
    sky.sky_horizon_color = sky_horizon_color
    env.background_mode = Environment.BG_SKY
    env.sky = sky

    # 环境光
    env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
    env.ambient_light_energy = ambient_light_energy

    # 雾效
    env.fog_enabled = fog_enabled
    env.fog_density = fog_density
    env.fog_light_color = fog_light_color

    # 景深(可选)
    env.dof_blur_far_enabled = true
    env.dof_blur_far_distance = 50
    env.dof_blur_far_transition = 20

简单地形系统

# terrain_generator.gd
extends Node3D

@export var terrain_size: Vector2i = Vector2i(64, 64)
@export var height_scale: float = 10.0
@export var noise_seed: int = 0
@export var noise_frequency: float = 0.02
@export var terrain_material: Material

@onready var mesh_instance: MeshInstance3D = $MeshInstance3D
@onready var static_body: StaticBody3D = $StaticBody3D
@onready var collision_shape: CollisionShape3D = $StaticBody3D/CollisionShape3D

var noise: FastNoiseLite

func _ready():
    noise = FastNoiseLite.new()
    noise.seed = noise_seed
    noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
    noise.frequency = noise_frequency

    _generate_terrain()

func _generate_terrain():
    var surface_tool = SurfaceTool.new()
    surface_tool.begin(Mesh.PRIMITIVE_TRIANGLES)

    # 生成顶点
    for z in range(terrain_size.y + 1):
        for x in range(terrain_size.x + 1):
            var height = noise.get_noise_2d(float(x), float(z)) * height_scale
            var pos = Vector3(x, height, z)

            # 法线
            var normal = Vector3.UP
            surface_tool.set_normal(normal)

            # UV
            var uv = Vector2(
                float(x) / terrain_size.x,
                float(z) / terrain_size.y
            )
            surface_tool.set_uv(uv)

            surface_tool.add_vertex(pos)

    # 生成三角形索引
    for z in range(terrain_size.y):
        for x in range(terrain_size.x):
            var i = z * (terrain_size.x + 1) + x

            # 三角形 1
            surface_tool.add_index(i)
            surface_tool.add_index(i + terrain_size.x + 1)
            surface_tool.add_index(i + 1)

            # 三角形 2
            surface_tool.add_index(i + 1)
            surface_tool.add_index(i + terrain_size.x + 1)
            surface_tool.add_index(i + terrain_size.x + 2)

    surface_tool.generate_normals()
    surface_tool.generate_tangents()

    var mesh = surface_tool.commit()
    mesh_instance.mesh = mesh

    if terrain_material:
        mesh_instance.material_override = terrain_material

    # 生成碰撞体
    var shape = HeightMapShape3D.new()
    var height_data = PackedFloat32Array()
    for z in range(terrain_size.y + 1):
        for x in range(terrain_size.x + 1):
            height_data.append(noise.get_noise_2d(float(x), float(z)) * height_scale)
    shape.map_width = terrain_size.x + 1
    shape.map_depth = terrain_size.y + 1
    shape.update_map_data_from_image(Image.create_from_data(
        terrain_size.x + 1, terrain_size.y + 1,
        false, Image.FORMAT_RF,
        height_data.to_byte_array()
    ), 0, 0)
    collision_shape.shape = shape

    print("地形生成完成: %dx%d 顶点" % [terrain_size.x + 1, terrain_size.y + 1])

敌人 AI(状态机 + NavigationAgent)

状态机实现

# state_machine.gd
class_name StateMachine
extends Node

signal state_changed(old_state: String, new_state: String)

@export var initial_state: Node
var current_state: State
var states: Dictionary = {}

func _ready():
    for child in get_children():
        if child is State:
            states[child.name.to_lower()] = child
            child.state_machine = self
            child.owner = owner  # 敌人节点

    if initial_state:
        current_state = initial_state
        current_state.enter()

func _process(delta):
    if current_state:
        current_state.update(delta)

func _physics_process(delta):
    if current_state:
        current_state.physics_update(delta)

func transition_to(new_state_name: String):
    var new_state = states.get(new_state_name.to_lower())
    if not new_state or new_state == current_state:
        return

    current_state.exit()
    var old_name = current_state.name
    current_state = new_state
    current_state.enter()
    state_changed.emit(old_name, new_state_name)
# state.gd
class_name State
extends Node

var state_machine: StateMachine
var owner_node: Node3D

func _ready():
    owner_node = owner as Node3D

func enter():
    pass

func exit():
    pass

func update(_delta: float):
    pass

func physics_update(_delta: float):
    pass

敌人状态

# enemy_idle_state.gd
extends State

@export var detection_range: float = 15.0
@export var detection_angle: float = 120.0

var player: Node3D

func enter():
    player = get_tree().get_first_node_in_group("player")

func physics_update(delta: float):
    if not player:
        return

    var distance = owner_node.global_position.distance_to(player.global_position)
    if distance > detection_range:
        return

    # 视野检测
    var direction_to_player = (player.global_position - owner_node.global_position).normalized()
    var forward = -owner_node.global_transform.basis.z
    var angle = rad_to_deg(forward.angle_to(direction_to_player))

    if angle < detection_angle / 2:
        state_machine.transition_to("chase")
# enemy_chase_state.gd
extends State

@export var attack_range: float = 2.0
@export var lose_range: float = 20.0

@onready var nav_agent: NavigationAgent3D
var player: Node3D

func enter():
    nav_agent = owner_node.get_node("NavigationAgent3D")
    player = get_tree().get_first_node_in_group("player")

func physics_update(delta: float):
    if not player:
        state_machine.transition_to("idle")
        return

    var distance = owner_node.global_position.distance_to(player.global_position)

    if distance > lose_range:
        state_machine.transition_to("idle")
        return

    if distance <= attack_range:
        state_machine.transition_to("attack")
        return

    # 导航追逐
    nav_agent.target_position = player.global_position
    var next_pos = nav_agent.get_next_path_position()
    var direction = (next_pos - owner_node.global_position).normalized()

    owner_node.velocity = direction * owner_node.move_speed
    owner_node.move_and_slide()

    # 朝向玩家
    var look_dir = (player.global_position - owner_node.global_position).normalized()
    look_dir.y = 0
    if look_dir.length() > 0.01:
        owner_node.model.rotation.y = atan2(look_dir.x, look_dir.z)
# enemy_attack_state.gd
extends State

@export var attack_cooldown: float = 1.5
@export var damage: int = 20

var cooldown_timer: float = 0.0
var player: Node3D
var has_attacked: bool = false

func enter():
    player = get_tree().get_first_node_in_group("player")
    cooldown_timer = 0.0
    has_attacked = false
    owner_node.velocity = Vector3.ZERO
    # 播放攻击动画
    owner_node.anim_player.play("attack")

func physics_update(delta: float):
    cooldown_timer += delta

    if not has_attacked and cooldown_timer > 0.3:
        _try_hit_player()
        has_attacked = true

    if cooldown_timer >= attack_cooldown:
        var distance = owner_node.global_position.distance_to(player.global_position)
        if distance > owner_node.get("attack_range", 2.0):
            state_machine.transition_to("chase")
        else:
            state_machine.transition_to("idle")

func _try_hit_player():
    if not player or not player.has_method("take_damage"):
        return

    var distance = owner_node.global_position.distance_to(player.global_position)
    if distance <= owner_node.get("attack_range", 2.0):
        player.take_damage(damage)

敌人主脚本

# enemy_base.gd
extends CharacterBody3D

@export var max_health: int = 50
@export var move_speed: float = 3.0
@export var attack_range: float = 2.0
@export var exp_reward: int = 20
@export var loot_table: Array[Dictionary] = []

@onready var model: Node3D = $Model
@onready var anim_player: AnimationPlayer = $Model/AnimationPlayer
@onready var state_machine: StateMachine = $StateMachine
@onready var health_bar: ProgressBar = $SubViewport/HealthBar
@onready var nav_agent: NavigationAgent3D = $NavigationAgent3D

var health: int

func _ready():
    health = max_health
    health_bar.max_value = max_health
    health_bar.value = health
    add_to_group("enemies")

func take_damage(amount: int, from_position: Vector3 = Vector3.ZERO):
    health -= amount
    health_bar.value = health

    # 击退效果
    if from_position != Vector3.ZERO:
        var knockback = (global_position - from_position).normalized()
        knockback.y = 0.3
        velocity += knockback * 5.0

    # 受伤闪烁
    _flash_damage()

    if health <= 0:
        die()

func die():
    # 掉落物
    _drop_loot()
    # 奖励经验
    GameManager.add_exp(exp_reward)
    # 死亡动画
    anim_player.play("death")
    set_physics_process(false)
    # 延迟移除
    await anim_player.animation_finished
    queue_free()

func _flash_damage():
    var original_material = model.get_surface_override_material(0)
    var flash_material = StandardMaterial3D.new()
    flash_material.albedo_color = Color.RED
    model.set_surface_override_material(0, flash_material)

    await get_tree().create_timer(0.1).timeout
    model.set_surface_override_material(0, original_material)

func _drop_loot():
    for loot in loot_table:
        var chance = loot.get("chance", 1.0)
        if randf() <= chance:
            var item_scene = load(loot.scene)
            if item_scene:
                var item = item_scene.instantiate()
                item.global_position = global_position + Vector3(randf_range(-1, 1), 0.5, randf_range(-1, 1))
                get_tree().current_scene.add_child(item)

战斗系统

# combat_system.gd
extends Node

signal damage_dealt(attacker: Node, target: Node, amount: int, is_critical: bool)
signal target_killed(target: Node, exp_reward: int)

@export var base_critical_chance: float = 0.1
@export var critical_multiplier: float = 2.0

func calculate_damage(attacker_stats: Dictionary, weapon: Resource) -> int:
    var base_damage = attacker_stats.get("attack", 10)
    var weapon_damage = weapon.damage if weapon else 0

    return base_damage + weapon_damage

func calculate_critical(base_damage: int) -> Dictionary:
    var is_critical = randf() < base_critical_chance
    var final_damage = base_damage * critical_multiplier if is_critical else base_damage
    return {"damage": int(final_damage), "critical": is_critical}

func apply_damage(attacker: Node, target: Node, base_damage: int):
    var crit_result = calculate_critical(base_damage)
    var final_damage = crit_result.damage

    # 护甲减免
    if target.has_method("get_defense"):
        var defense = target.get_defense()
        final_damage = maxi(1, final_damage - defense)

    target.take_damage(final_damage, attacker.global_position)
    damage_dealt.emit(attacker, target, final_damage, crit_result.critical)

    # 伤害数字显示
    _spawn_damage_number(target.global_position, final_damage, crit_result.critical)

func _spawn_damage_number(position: Vector3, amount: int, is_critical: bool):
    # 使用 3D Label 或 SubViewport 显示浮动伤害数字
    var number_scene = preload("res://scenes/ui/damage_number.tscn")
    var number = number_scene.instantiate()
    number.setup(amount, is_critical)
    number.global_position = position + Vector3(0, 2, 0)
    get_tree().current_scene.add_child(number)

道具与背包系统

# inventory_system.gd
extends Node

signal item_added(item: Resource)
signal item_removed(item: Resource)
signal item_used(item: Resource)
signal equipment_changed(slot: StringName, item: Resource)

@export var max_slots: int = 20

var items: Array[Resource] = []
var equipment: Dictionary = {}  # slot -> Resource

func add_item(item: Resource) -> bool:
    if items.size() >= max_slots:
        return false

    items.append(item)
    item_added.emit(item)
    return true

func remove_item(item: Resource) -> bool:
    var index = items.find(item)
    if index == -1:
        return false

    items.remove_at(index)
    item_removed.emit(item)
    return true

func use_item(item: Resource) -> bool:
    if not item in items:
        return false

    if item.has_method("use"):
        var success = item.use()
        if success:
            if item.consumable:
                remove_item(item)
            item_used.emit(item)
            return true

    return false

func equip(item: Resource) -> bool:
    if not item in items:
        return false

    var slot = item.equipment_slot
    var old_item = equipment.get(slot)

    equipment[slot] = item
    items.erase(item)

    if old_item:
        items.append(old_item)

    equipment_changed.emit(slot, item)
    return true

func unequip(slot: StringName) -> bool:
    if not equipment.has(slot):
        return false

    if items.size() >= max_slots:
        return false

    var item = equipment[slot]
    equipment.erase(slot)
    items.append(item)

    equipment_changed.emit(slot, null)
    return true

func get_equipped_weapon() -> Resource:
    return equipment.get(&"weapon")

func get_total_stats() -> Dictionary:
    var stats = {"attack": 0, "defense": 0, "speed": 0, "health": 0}
    for item in equipment.values():
        if item:
            stats.attack += item.get("attack_bonus", 0)
            stats.defense += item.get("defense_bonus", 0)
            stats.speed += item.get("speed_bonus", 0)
            stats.health += item.get("health_bonus", 0)
    return stats

道具资源定义

# item_data.gd
class_name ItemData
extends Resource

@export var item_name: String
@export var description: String
@export var icon: Texture2D
@export var item_type: ItemType
@export var consumable: bool = false
@export var stackable: bool = false
@export var max_stack: int = 1

enum ItemType { CONSUMABLE, WEAPON, ARMOR, KEY_ITEM, MATERIAL }

# health_potion.gd
class_name HealthPotion
extends ItemData

@export var heal_amount: int = 30

func use() -> bool:
    if GameManager.player_health >= GameManager.player_max_health:
        return false
    GameManager.heal(heal_amount)
    return true

UI 界面

生命条 HUD

# hud.gd
extends CanvasLayer

@onready var health_bar: ProgressBar = %HealthBar
@onready var mana_bar: ProgressBar = %ManaBar
@onready var exp_bar: ProgressBar = %ExpBar
@onready var health_label: Label = %HealthLabel
@onready var level_label: Label = %LevelLabel
@onready var gold_label: Label = %GoldLabel

func _ready():
    GameManager.player_died.connect(_on_player_died)
    _update_all()

func _process(_delta):
    _update_bars()

func _update_bars():
    health_bar.max_value = GameManager.player_max_health
    health_bar.value = GameManager.player_health
    health_label.text = "%d / %d" % [GameManager.player_health, GameManager.player_max_health]

    mana_bar.max_value = GameManager.player_max_mana
    mana_bar.value = GameManager.player_mana

    exp_bar.max_value = GameManager.exp_for_next_level()
    exp_bar.value = GameManager.player_exp

    level_label.text = "Lv.%d" % GameManager.player_level
    gold_label.text = "%d G" % GameManager.gold

func _update_all():
    _update_bars()

func _on_player_died():
    # 显示死亡 UI
    var death_screen = preload("res://scenes/ui/death_screen.tscn").instantiate()
    add_child(death_screen)

小地图

# minimap.gd
extends Control

@export var map_range: float = 50.0
@export var player_icon: Texture2D
@export var enemy_icon: Texture2D
@export var objective_icon: Texture2D

@onready var map_viewport: SubViewport = $SubViewport
@onready var map_camera: Camera3D = $SubViewport/Camera3D
@onready var map_texture: TextureRect = $MapTexture

var player: Node3D

func _ready():
    player = get_tree().get_first_node_in_group("player")

    # 设置小地图相机
    map_camera.projection = Camera3D.PROJECTION_ORTHOGONAL
    map_camera.size = map_range * 2
    map_camera.rotation_degrees.x = -90  # 俯视

func _process(_delta):
    if not player:
        return

    # 相机跟随玩家
    map_camera.global_position = player.global_position + Vector3.UP * 100

    # 更新纹理
    map_texture.texture = map_viewport.get_texture()

func _draw():
    # 在小地图上绘制图标
    var center = size / 2

    # 玩家图标(始终在中心)
    if player_icon:
        draw_texture_rect(player_icon, Rect2(center - Vector2(4, 4), Vector2(8, 8)), false)

    # 敌人图标
    for enemy in get_tree().get_nodes_in_group("enemies"):
        var offset = (enemy.global_position - player.global_position)
        var map_pos = center + Vector2(offset.x, offset.z) * (size.x / (map_range * 2))

        if Rect2(Vector2.ZERO, size).has_point(map_pos) and enemy_icon:
            draw_texture_rect(enemy_icon, Rect2(map_pos - Vector2(3, 3), Vector2(6, 6)), false, Color.RED)

对话框系统

# dialog_system.gd
extends Node

signal dialog_started(npc_name: String)
signal dialog_ended
signal choice_made(choice_index: int)

@export var dialog_data: Dictionary = {}

var current_dialog: Array[Dictionary] = []
var current_index: int = 0
var is_active: bool = false

@onready var dialog_box: Control = $DialogBox
@onready var name_label: Label = %NameLabel
@onready var text_label: RichTextLabel = %TextLabel
@onready var choices_container: VBoxContainer = %ChoicesContainer
@onready var continue_indicator: TextureRect = %ContinueIndicator

var text_speed: float = 0.03
var is_text_animating: bool = false

func start_dialog(dialog_id: String):
    if not dialog_data.has(dialog_id):
        push_error("对话不存在: " + dialog_id)
        return

    current_dialog = dialog_data[dialog_id]
    current_index = 0
    is_active = true
    dialog_box.visible = true

    dialog_started.emit(dialog_id)
    _show_current_line()

func _show_current_line():
    if current_index >= current_dialog.size():
        _end_dialog()
        return

    var line = current_dialog[current_index]

    name_label.text = line.get("speaker", "")
    _animate_text(line.get("text", ""))

    # 检查是否有选项
    if line.has("choices"):
        continue_indicator.visible = false
        _show_choices(line.choices)
    else:
        continue_indicator.visible = true

func _animate_text(text: String):
    text_label.text = ""
    is_text_animating = true

    for i in text.length():
        if not is_text_animating:
            text_label.text = text
            return

        text_label.text += text[i]
        await get_tree().create_timer(text_speed).timeout

    is_text_animating = false

func skip_text_animation():
    is_text_animating = false

func _show_choices(choices: Array[Dictionary]):
    # 清除旧选项
    for child in choices_container.get_children():
        child.queue_free()

    for i in range(choices.size()):
        var button = Button.new()
        button.text = choices[i].text
        button.pressed.connect(_on_choice_selected.bind(i, choices[i]))
        choices_container.add_child(button)

    choices_container.visible = true

func _on_choice_selected(index: int, choice: Dictionary):
    choices_container.visible = false
    choice_made.emit(index)

    if choice.has("dialog"):
        start_dialog(choice.dialog)
    else:
        _advance_dialog()

func _advance_dialog():
    current_index += 1
    _show_current_line()

func _input(event):
    if not is_active:
        return

    if event.is_action_pressed("interact"):
        if is_text_animating:
            skip_text_animation()
        else:
            _advance_dialog()

func _end_dialog():
    is_active = false
    dialog_box.visible = false
    dialog_ended.emit()

音效与音乐

# audio_manager.gd
extends Node

@onready var music_player: AudioStreamPlayer = $MusicPlayer
@onready var sfx_pool: Node = $SFXPool
@onready var ambient_player: AudioStreamPlayer = $AmbientPlayer

var music_volume: float = 0.8
var sfx_volume: float = 1.0
var ambient_volume: float = 0.5

var music_tween: Tween

func play_music(track_path: String, fade_time: float = 1.0):
    if music_player.stream and music_player.stream.resource_path == track_path:
        return

    var stream = load(track_path)
    if not stream:
        return

    if music_tween:
        music_tween.kill()

    # 淡出当前音乐
    music_tween = create_tween()
    music_tween.tween_property(music_player, "volume_db", -40, fade_time / 2)
    await music_tween.finished

    music_player.stream = stream
    music_player.play()

    # 淡入新音乐
    music_tween = create_tween()
    music_tween.tween_property(music_player, "volume_db",
        linear_to_db(music_volume), fade_time / 2)

func play_sfx(sfx_path: String, volume_db: float = 0.0):
    var stream = load(sfx_path)
    if not stream:
        return

    # 从池中找一个空闲的播放器
    var player = _get_free_sfx_player()
    player.stream = stream
    player.volume_db = volume_db + linear_to_db(sfx_volume)
    player.play()

func play_3d_sfx(sfx_path: String, position: Vector3, volume_db: float = 0.0):
    var stream = load(sfx_path)
    if not stream:
        return

    var player = AudioStreamPlayer3D.new()
    player.stream = stream
    player.global_position = position
    player.volume_db = volume_db + linear_to_db(sfx_volume)
    player.max_distance = 30
    player.attenuation_model = AudioStreamPlayer3D.ATTENUATION_INVERSE_DISTANCE
    add_child(player)
    player.play()
    player.finished.connect(player.queue_free)

func play_ambient(ambient_path: String):
    var stream = load(ambient_path)
    if ambient_player.stream == stream:
        return
    ambient_player.stream = stream
    ambient_player.volume_db = linear_to_db(ambient_volume)
    ambient_player.play()

func _get_free_sfx_player() -> AudioStreamPlayer:
    for player in sfx_pool.get_children():
        if not player.playing:
            return player

    # 池满了,创建新的
    var new_player = AudioStreamPlayer.new()
    new_player.bus = "SFX"
    sfx_pool.add_child(new_player)
    return new_player

func set_music_volume(value: float):
    music_volume = clampf(value, 0, 1)
    music_player.volume_db = linear_to_db(music_volume)

func set_sfx_volume(value: float):
    sfx_volume = clampf(value, 0, 1)

func set_ambient_volume(value: float):
    ambient_volume = clampf(value, 0, 1)
    ambient_player.volume_db = linear_to_db(ambient_volume)

存档系统集成

# save_manager.gd
extends Node

const SAVE_DIR = "user://saves/"
const MAX_SLOTS = 5

func _ready():
    DirAccess.make_dir_recursive_absolute(SAVE_DIR)

func save_game(slot: int) -> Error:
    var save_data = {
        "version": 1,
        "timestamp": Time.get_unix_time_from_system(),
        "player": {
            "name": GameManager.player_name,
            "level": GameManager.player_level,
            "exp": GameManager.player_exp,
            "health": GameManager.player_health,
            "max_health": GameManager.player_max_health,
            "mana": GameManager.player_mana,
            "gold": GameManager.gold,
        },
        "inventory": GameManager.inventory.serialize(),
        "quests": GameManager.quest_manager.serialize(),
        "world": {
            "current_scene": get_tree().current_scene.scene_file_path,
            "flags": GameManager.world_flags,
        }
    }

    var path = SAVE_DIR + "save_%d.json" % slot
    var file = FileAccess.open(path, FileAccess.WRITE)
    if not file:
        return FileAccess.get_open_error()

    file.store_string(JSON.stringify(save_data, "\t"))
    return OK

func load_game(slot: int) -> bool:
    var path = SAVE_DIR + "save_%d.json" % slot
    if not FileAccess.file_exists(path):
        return false

    var file = FileAccess.open(path, FileAccess.READ)
    if not file:
        return false

    var json = JSON.new()
    var error = json.parse(file.get_as_text())
    if error != OK:
        return false

    var data = json.data

    # 恢复玩家数据
    GameManager.player_name = data.player.name
    GameManager.player_level = data.player.level
    GameManager.player_exp = data.player.exp
    GameManager.player_health = data.player.health
    GameManager.player_max_health = data.player.max_health
    GameManager.player_mana = data.player.mana
    GameManager.gold = data.player.gold

    # 恢复背包
    GameManager.inventory.deserialize(data.inventory)

    # 恢复任务
    GameManager.quest_manager.deserialize(data.quests)

    # 切换场景
    GameManager.world_flags = data.world.get("flags", {})
    var scene_path = data.world.current_scene
    get_tree().change_scene_to_file(scene_path)

    return true

func delete_save(slot: int):
    var path = SAVE_DIR + "save_%d.json" % slot
    DirAccess.remove_absolute(path)

func get_save_info(slot: int) -> Dictionary:
    var path = SAVE_DIR + "save_%d.json" % slot
    if not FileAccess.file_exists(path):
        return {"empty": true}

    var file = FileAccess.open(path, FileAccess.READ)
    var json = JSON.new()
    json.parse(file.get_as_text())

    return {
        "empty": false,
        "player_name": json.data.player.name,
        "player_level": json.data.player.level,
        "timestamp": json.data.timestamp,
    }

多关卡流程

# level_manager.gd
extends Node

signal level_loaded(level_name: String)
signal all_levels_completed

var levels: Array[String] = [
    "res://scenes/levels/level_01.tscn",
    "res://scenes/levels/level_02.tscn",
    "res://scenes/levels/level_03.tscn",
    "res://scenes/levels/boss_level.tscn",
]

var current_level_index: int = 0

func load_next_level():
    current_level_index += 1
    if current_level_index >= levels.size():
        all_levels_completed.emit()
        return

    _load_level(current_level_index)

func load_level_by_index(index: int):
    if index >= 0 and index < levels.size():
        current_level_index = index
        _load_level(index)

func _load_level(index: int):
    var path = levels[index]
    var level_name = path.get_file().get_basename()

    # 淡出
    var fade = get_tree().current_scene.get_node_or_null("FadeLayer")
    if fade:
        fade.fade_out()
        await fade.fade_completed

    get_tree().change_scene_to_file(path)

    # 等待场景加载
    await get_tree().process_frame

    level_loaded.emit(level_name)

    # 淡入
    fade = get_tree().current_scene.get_node_or_null("FadeLayer")
    if fade:
        fade.fade_in()

func get_current_level_name() -> String:
    return levels[current_level_index].get_file().get_basename()

func is_last_level() -> bool:
    return current_level_index >= levels.size() - 1

关卡传送门

# level_portal.gd
extends Area3D

@export var target_level: String = ""
@export var spawn_point_name: String = "SpawnPoint"
@export var requires_key: bool = false
@export var key_item_name: String = ""

@onready var portal_effect: GPUParticles3D = $PortalEffect

func _ready():
    body_entered.connect(_on_body_entered)

func _on_body_entered(body: Node3D):
    if not body.is_in_group("player"):
        return

    if requires_key and not GameManager.inventory.has_item(key_item_name):
        _show_message("需要 %s 才能进入" % key_item_name)
        return

    # 触发传送
    _teleport()

func _teleport():
    # 播放传送效果
    portal_effect.emitting = true
    AudioManager.play_sfx("res://assets/audio/sfx/portal.wav")

    await get_tree().create_timer(0.5).timeout

    GameManager.change_level(target_level)

func _show_message(message: String):
    # 显示提示信息
    var hud = get_tree().current_scene.get_node_or_null("HUD")
    if hud and hud.has_method("show_message"):
        hud.show_message(message)

发布与优化

性能优化检查清单

优化项方法预期效果
DrawCall 合并合并相同材质网格减少 CPU 开销
纹理压缩使用 ASTC/S3TC 压缩减少显存占用
LOD 系统远距离使用低模减少三角形数
遮挡剔除使用 OcclusionCuller不渲染被遮挡物体
粒子优化使用 MultiMesh 替代粒子大量物体性能提升
音频压缩OGG 压缩 + 流式加载减少内存占用

最终检查

# 发布前的自动检查
func _perform_release_checks():
    assert(GameManager.player_health > 0, "玩家生命值异常")
    assert(GameManager.inventory.items.size() <= GameManager.inventory.max_slots, "背包溢出")

    # 检查所有必要资源是否存在
    var required_files = [
        "res://scenes/player/player.tscn",
        "res://scenes/ui/hud.tscn",
        "res://assets/audio/music/main_theme.ogg",
    ]
    for path in required_files:
        assert(ResourceLoader.exists(path), "缺少资源: " + path)

    print("✅ 所有检查通过,可以发布!")

💡 扩展阅读


🎉 恭喜! 如果你完整阅读了本系列全部 30 篇教程,你已经掌握了 Godot 4 GDScript 开发的核心技能。接下来最好的学习方式就是动手做项目——从简单开始,逐步增加复杂度。祝你游戏开发愉快!