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 开发的核心技能。接下来最好的学习方式就是动手做项目——从简单开始,逐步增加复杂度。祝你游戏开发愉快!