15.5 Godot 游戏场景构建

配套代码:code/chapter15

为什么选择 Godot 作为游戏引擎?

在众多游戏引擎中,我们选择 Godot 4.5 作为前端引擎,主要基于以下几个考虑:

(1)Godot 在 2D 游戏开发上有着天然的优势。赛博小镇是一个俯视角的 2D 像素风格游戏,Godot 的 2D 引擎非常成熟,提供了 TileMap、AnimatedSprite2D、CharacterBody2D 等专门为 2D 游戏设计的节点类型,开发效率远高于 Unity 等引擎。Godot 的场景系统(Scene System)让我们可以将玩家、NPC、UI 等元素封装成独立的场景,然后在主场景中实例化,这种组件化的设计非常适合我们的需求。

(2)Godot 是完全开源且免费的。Godot 使用 MIT 许可证,没有任何版权费用或收入分成,这对于教学项目和开源项目非常友好。你可以自由地修改引擎源码,也可以将游戏商业化而不用担心授权问题。相比之下,Unity 虽然功能强大,但在 2024 年引入了运行时费用政策,引发了开发者社区的广泛争议。

(3)Godot 的学习成本极低。Godot 使用 GDScript 作为主要脚本语言,这是一种类似 Python 的动态类型语言,语法简洁易懂,学习曲线非常平缓。对于已经熟悉 Python 的读者来说,学习 GDScript 几乎没有门槛——变量声明、函数定义、控制流程等语法都与 Python 高度相似,你甚至可以在几小时内就上手编写游戏脚本。Godot 的节点树结构也非常直观,你可以在编辑器中直观地看到场景的层级关系,这对于初学者非常友好。

(4)Godot 与 Python 后端的集成非常简单。Godot 内置了 HTTPRequest 节点,可以轻松地与 FastAPI 后端进行 HTTP 通信。我们只需要创建一个 API 客户端脚本,封装所有的 API 调用,就可以在游戏中调用后端的 AI 能力。这种前后端分离的架构让我们可以独立开发和测试游戏逻辑和 AI 逻辑,大大提高了开发效率。

当然,Godot 也有一些局限性。比如,Godot 的 3D 能力相比 Unreal Engine 和 Unity 还有差距,如果你要开发大型 3D 游戏,可能需要考虑其他引擎。但对于 2D 游戏、独立游戏和教学项目,Godot 是一个非常优秀的选择。

15.5.1 场景设计与资源组织

理解了 Godot 的场景系统后,我们来看看赛博小镇的场景设计。整个游戏由四个核心场景组成:Main(主场景)、Player(玩家)、NPC(非玩家角色)和 DialogueUI(对话界面)。每个场景都是一个独立的模块,可以单独编辑和测试,然后组合在一起形成完整的游戏。

赛博小镇的场景组织采用了模块化设计。我们首先创建三个基础场景:Player(玩家)、NPC(非玩家角色)和 DialogueUI(对话界面)。然后在 Main(主场景)中将这些场景实例化并组合起来。特别值得注意的是,三个 NPC(张三、李四、王五)都是同一个 NPC 场景的实例,只是通过脚本参数设置了不同的角色信息。

让我们先看看四个核心场景的结构,如图 15.12 所示:

图 15.12 赛博小镇的四个核心场景

这个图展示了四个独立的场景及其内部结构。场景 1(Main)是主场景,它包含了背景图片(Sprite2D)、玩家实例、NPCs 组织节点(下面有三个 NPC 实例)、对话界面实例、墙体组织节点和背景音乐。注意,这里的 Player、NPC_Zhang、NPC_Li、NPC_Wang 和 DialogueUI 都是场景实例,不是普通节点。场景 2(Player)定义了玩家角色的结构,包含动画、碰撞、摄像机和两个音效节点。场景 3(NPC)是一个通用模板,张三、李四、王五都是这个场景的实例,包含碰撞、动画、交互区域和两个标签。场景 4(DialogueUI)是一个 CanvasLayer 节点,包含 Panel 和各种 UI 元素。

场景实例化的过程可以这样理解:我们在 Godot 编辑器中创建了 NPC.tscn 这个场景文件,定义了 NPC 的节点结构。然后在 Main 场景中,我们三次"实例化"这个 NPC 场景,创建了三个独立的副本,分别命名为 NPC_Zhang、NPC_Li 和 NPC_Wang。每个副本都有自己的位置和状态,但它们共享相同的节点结构。如果我们修改 NPC.tscn,比如给 NPC 添加一个新的音效节点,那么所有三个实例都会自动获得这个音效。

在 Godot 中创建这些场景的步骤如下:

  1. 创建 Player 场景:新建场景,选择 CharacterBody2D 作为根节点,添加 AnimatedSprite2D、CollisionShape2D、Camera2D、InteractSound 和 RunningSound 子节点,保存为 Player.tscn。

  2. 创建 NPC 场景:新建场景,选择 CharacterBody2D 作为根节点,添加 CollisionShape2D、AnimatedSprite2D、InteractionArea(Area2D,下面有 CollisionShape2D)、NameLabel 和 DialogueLabel 子节点,保存为 NPC.tscn。

  3. 创建 DialogueUI 场景:新建场景,选择 CanvasLayer 作为根节点,添加 Panel 子节点,在 Panel 下添加 NPCName、NPCTitle、DialogueText(RichTextLabel)、PlayerInput(LineEdit)、SendButton 和 CloseButton,保存为 DialogueUI.tscn。

  4. 创建 Main 场景:新建场景,选择 Node2D 作为根节点,添加 Background(Sprite2D)作为背景图,在 Background 下添加小鲸鱼装饰,然后实例化 Player 场景,创建 NPCs 节点并在其下三次实例化 NPC 场景,实例化 DialogueUI 场景,创建 Walls 节点用于组织墙体碰撞,最后添加 AudioStreamPlayer 播放背景音乐。

这种场景组织方式的优势在于:每个场景都是独立的,可以单独测试;NPC 使用同一个场景的实例,修改一次就能影响所有 NPC;场景之间通过信号通信,耦合度低,易于维护和扩展。

15.5.2 玩家控制实现

玩家角色是游戏中最重要的元素之一。我们需要实现 WASD 移动控制、动画切换、碰撞检测、与 NPC 的交互,以及音效系统。

玩家场景的结构包括:一个 CharacterBody2D 作为根节点,负责物理移动和碰撞;一个 AnimatedSprite2D 显示角色动画;一个 CollisionShape2D 定义碰撞形状;一个 Camera2D 跟随玩家;两个 AudioStreamPlayer 分别播放交互音效和走路音效。

玩家控制脚本player.gd实现了移动、交互和音效逻辑:

extends CharacterBody2D

# 移动速度
@export var speed: float = 200.0

# 当前可交互的NPC
var nearby_npc: Node = null

# 交互状态(交互时禁用移动)
var is_interacting: bool = false

# 节点引用
@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D
@onready var camera: Camera2D = $Camera2D

# 音效引用
@onready var interact_sound: AudioStreamPlayer = null
@onready var running_sound: AudioStreamPlayer = null

# 走路音效状态
var is_playing_running_sound: bool = false

func _ready():
    # 添加到player组(重要!NPC需要通过这个组来识别玩家)
    add_to_group("player")

    # 获取音效节点(可选,如果不存在也不会报错)
    interact_sound = get_node_or_null("InteractSound")
    running_sound = get_node_or_null("RunningSound")

    # 启用相机
    camera.enabled = true

    # 播放默认动画
    if animated_sprite.sprite_frames != null and animated_sprite.sprite_frames.has_animation("idle"):
        animated_sprite.play("idle")

func _physics_process(_delta: float):
    # 如果正在交互,禁用移动
    if is_interacting:
        velocity = Vector2.ZERO
        move_and_slide()
        # 播放idle动画
        if animated_sprite.sprite_frames != null and animated_sprite.sprite_frames.has_animation("idle"):
            animated_sprite.play("idle")
        # 停止走路音效
        stop_running_sound()
        return

    # 获取输入方向
    var input_direction = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")

    # 设置速度
    velocity = input_direction * speed

    # 移动
    move_and_slide()

    # 更新动画和朝向
    update_animation(input_direction)

    # 更新走路音效
    update_running_sound(input_direction)

func update_animation(direction: Vector2):
    """更新角色动画(支持4方向)"""
    if animated_sprite.sprite_frames == null:
        return

    # 根据移动方向播放动画
    if direction.length() > 0:
        # 移动中 - 判断主要方向
        if abs(direction.x) > abs(direction.y):
            # 左右移动
            if direction.x > 0:
                # 向右
                if animated_sprite.sprite_frames.has_animation("walk_right"):
                    animated_sprite.play("walk_right")
                    animated_sprite.flip_h = false
                elif animated_sprite.sprite_frames.has_animation("walk"):
                    animated_sprite.play("walk")
                    animated_sprite.flip_h = false
            else:
                # 向左
                if animated_sprite.sprite_frames.has_animation("walk_left"):
                    animated_sprite.play("walk_left")
                    animated_sprite.flip_h = false
                elif animated_sprite.sprite_frames.has_animation("walk"):
                    animated_sprite.play("walk")
                    animated_sprite.flip_h = true
        else:
            # 上下移动
            if direction.y > 0:
                # 向下
                if animated_sprite.sprite_frames.has_animation("walk_down"):
                    animated_sprite.play("walk_down")
                elif animated_sprite.sprite_frames.has_animation("walk"):
                    animated_sprite.play("walk")
            else:
                # 向上
                if animated_sprite.sprite_frames.has_animation("walk_up"):
                    animated_sprite.play("walk_up")
                elif animated_sprite.sprite_frames.has_animation("walk"):
                    animated_sprite.play("walk")
    else:
        # 静止
        if animated_sprite.sprite_frames.has_animation("idle"):
            animated_sprite.play("idle")

func _input(event: InputEvent):
    # 按E键与NPC交互
    if event is InputEventKey:
        if event.pressed and not event.echo:
            if event.keycode == KEY_E or event.keycode == KEY_ENTER:
                if nearby_npc != null:
                    interact_with_npc()

func interact_with_npc():
    """与附近的NPC交互"""
    if nearby_npc != null:
        # 播放交互音效
        if interact_sound:
            interact_sound.play()

        # 发送信号给对话系统
        get_tree().call_group("dialogue_system", "start_dialogue", nearby_npc.npc_name)

func set_nearby_npc(npc: Node):
    """设置附近的NPC"""
    nearby_npc = npc

func set_interacting(interacting: bool):
    """设置交互状态"""
    is_interacting = interacting
    if interacting:
        # 停止走路音效
        stop_running_sound()

func update_running_sound(direction: Vector2):
    """更新走路音效"""
    if running_sound == null:
        return

    # 如果正在移动
    if direction.length() > 0:
        # 如果音效还没播放,开始播放
        if not is_playing_running_sound:
            running_sound.play()
            is_playing_running_sound = true
    else:
        # 如果停止移动,停止音效
        stop_running_sound()

func stop_running_sound():
    """停止走路音效"""
    if running_sound and is_playing_running_sound:
        running_sound.stop()
        is_playing_running_sound = false

这个脚本实现了完整的玩家控制。玩家使用 WASD 键(或方向键)移动,角色会根据移动方向播放相应的 4 方向动画(walk_up/down/left/right)。当玩家靠近 NPC 时,NPC 会调用set_nearby_npc()设置自己为可交互对象,玩家按 E 键即可触发交互。交互时会播放音效,并通过call_group()通知对话系统开始对话。对话期间,set_interacting(true)会禁用玩家移动,对话结束后恢复移动。走路音效会在玩家移动时自动播放,停止时自动停止。

15.5.3 NPC 行为与交互

NPC 需要实现三个核心功能:在场景中随机巡逻游走、响应玩家的交互、显示对话气泡。我们使用 Area2D 来检测玩家是否靠近 NPC,当玩家进入交互范围时通知玩家,玩家按 E 键即可开始对话。

NPC 场景的结构包括:CharacterBody2D 作为根节点;CollisionShape2D 定义 NPC 的碰撞形状;AnimatedSprite2D 显示 NPC 动画;InteractionArea(Area2D)检测玩家进入交互范围,下面有 CollisionShape2D 定义交互范围;NameLabel 显示 NPC 名字;DialogueLabel 显示对话气泡。

NPC 脚本npc.gd实现了巡逻、交互和对话气泡逻辑:

extends CharacterBody2D

# NPC信息
@export var npc_name: String = "张三"
@export var npc_title: String = "Python工程师"

# NPC外观配置
@export var sprite_frames: SpriteFrames = null  # 自定义精灵帧资源

# NPC移动配置
@export var move_speed: float = 50.0  # 移动速度
@export var wander_enabled: bool = true  # 是否启用巡逻
@export var wander_range: float = 200.0  # 巡逻范围
@export var wander_interval_min: float = 3.0  # 最小巡逻间隔(秒)
@export var wander_interval_max: float = 8.0  # 最大巡逻间隔(秒)

# 当前对话内容(从后端获取)
var current_dialogue: String = ""

# 节点引用
@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D
@onready var interaction_area: Area2D = $InteractionArea
@onready var name_label: Label = $NameLabel
@onready var dialogue_label: Label = $DialogueLabel

# 玩家引用
var player: Node = null

# 巡逻相关变量
var wander_target: Vector2 = Vector2.ZERO  # 巡逻目标位置
var wander_timer: float = 0.0  # 巡逻计时器
var is_wandering: bool = false  # 是否正在巡逻
var is_interacting: bool = false  # 是否正在与玩家交互
var spawn_position: Vector2 = Vector2.ZERO  # 出生位置

func _ready():
    # 添加到npcs组
    add_to_group("npcs")

    # 设置NPC名字
    name_label.text = npc_name

    # 连接交互区域信号
    interaction_area.body_entered.connect(_on_body_entered)
    interaction_area.body_exited.connect(_on_body_exited)

    # 初始化对话标签
    dialogue_label.text = ""
    dialogue_label.visible = false

    # 设置自定义精灵帧(如果有)
    if sprite_frames != null:
        animated_sprite.sprite_frames = sprite_frames

    # 播放默认动画
    if animated_sprite.sprite_frames != null and animated_sprite.sprite_frames.has_animation("idle"):
        animated_sprite.play("idle")

    # 记录出生位置
    spawn_position = global_position

    # 初始化巡逻计时器
    if wander_enabled:
        wander_timer = randf_range(wander_interval_min, wander_interval_max)
        choose_new_wander_target()

func _on_body_entered(body: Node2D):
    """玩家进入交互范围"""
    if body.is_in_group("player"):
        player = body

        if player.has_method("set_nearby_npc"):
            player.set_nearby_npc(self)

func _on_body_exited(body: Node2D):
    """玩家离开交互范围"""
    if body.is_in_group("player"):
        if player != null and player.has_method("set_nearby_npc"):
            player.set_nearby_npc(null)
        player = null

func update_dialogue(dialogue: String):
    """更新NPC对话内容"""
    current_dialogue = dialogue
    dialogue_label.text = dialogue
    dialogue_label.visible = true

    # 10秒后隐藏对话
    await get_tree().create_timer(10.0).timeout
    dialogue_label.visible = false

func _physics_process(delta: float):
    """物理更新 - 处理移动"""
    # 如果正在与玩家交互,停止移动
    if is_interacting:
        velocity = Vector2.ZERO
        move_and_slide()
        # 播放idle动画
        if animated_sprite.sprite_frames != null and animated_sprite.sprite_frames.has_animation("idle"):
            animated_sprite.play("idle")
        return

    # 如果未启用巡逻,不移动
    if not wander_enabled:
        return

    # 更新巡逻计时器
    wander_timer -= delta

    # 如果计时器结束,选择新目标并开始移动
    if wander_timer <= 0:
        choose_new_wander_target()
        wander_timer = randf_range(wander_interval_min, wander_interval_max)

    # 如果正在巡逻,移动到目标
    if is_wandering:
        # 检查是否到达目标
        if global_position.distance_to(wander_target) < 10:
            # 到达目标,停止移动
            is_wandering = false
            velocity = Vector2.ZERO
            move_and_slide()
            # 播放idle动画
            if animated_sprite.sprite_frames != null and animated_sprite.sprite_frames.has_animation("idle"):
                animated_sprite.play("idle")
        else:
            # 继续移动到目标
            var direction = (wander_target - global_position).normalized()
            velocity = direction * move_speed
            move_and_slide()
            # 更新动画
            update_animation(direction)
    else:
        # 停止移动
        velocity = Vector2.ZERO
        move_and_slide()
        # 播放idle动画
        if animated_sprite.sprite_frames != null and animated_sprite.sprite_frames.has_animation("idle"):
            animated_sprite.play("idle")

func choose_new_wander_target():
    """选择新的巡逻目标"""
    # 在出生位置附近随机选择一个点
    var offset = Vector2(
        randf_range(-wander_range, wander_range),
        randf_range(-wander_range, wander_range)
    )
    wander_target = spawn_position + offset
    is_wandering = true

func update_animation(direction: Vector2):
    """更新动画"""
    if animated_sprite.sprite_frames == null:
        return

    if direction.length() > 0:
        # 移动动画
        if abs(direction.x) > abs(direction.y):
            # 左右移动
            if direction.x > 0:
                if animated_sprite.sprite_frames.has_animation("walk_right"):
                    animated_sprite.play("walk_right")
                elif animated_sprite.sprite_frames.has_animation("walk"):
                    animated_sprite.play("walk")
                    animated_sprite.flip_h = false
            else:
                if animated_sprite.sprite_frames.has_animation("walk_left"):
                    animated_sprite.play("walk_left")
                elif animated_sprite.sprite_frames.has_animation("walk"):
                    animated_sprite.play("walk")
                    animated_sprite.flip_h = true
        else:
            # 上下移动
            if direction.y > 0:
                if animated_sprite.sprite_frames.has_animation("walk_down"):
                    animated_sprite.play("walk_down")
                elif animated_sprite.sprite_frames.has_animation("walk"):
                    animated_sprite.play("walk")
            else:
                if animated_sprite.sprite_frames.has_animation("walk_up"):
                    animated_sprite.play("walk_up")
                elif animated_sprite.sprite_frames.has_animation("walk"):
                    animated_sprite.play("walk")
    else:
        # 静止动画
        if animated_sprite.sprite_frames.has_animation("idle"):
            animated_sprite.play("idle")

func set_interacting(interacting: bool):
    """设置交互状态"""
    is_interacting = interacting

这个脚本实现了 NPC 的完整行为。NPC 会在出生位置附近的wander_range范围内随机巡逻,每隔wander_interval_min到wander_interval_max秒选择一个新的目标点并移动过去。移动时会播放 4 方向动画(walk_up/down/left/right),到达目标后停止并播放 idle 动画。当玩家进入 InteractionArea 时,NPC 会调用玩家的set_nearby_npc(self)方法,将自己设置为可交互对象。玩家按 E 键后,对话系统会调用 NPC 的set_interacting(true)方法,NPC 停止移动。对话结束后调用set_interacting(false),NPC 恢复巡逻。主场景会定时调用update_dialogue()方法更新 NPC 的对话气泡,显示 NPC 之间的自主对话内容。