「纸上得来终觉浅,绝知此事要躬行。」 ------陆游《冬夜读书示子聿》
前五章打下了理论和工具基础。第三部分只做一件事:动手。本章从零搭建一个完整可玩的投壶单关卡,包含:玩家移动、正确的俯视角投壶弧线、敌人受击、得分系统、游戏循环,最后导出为可运行的exe。
⏱️ 本章目标 完成时间目标:跟着本章一步一步做,首次完成约需 2-3 小时。如果只想先跑通再理解:直接从配套仓库 clone 本章代码,按F5 运行,然后再回来对照代码阅读。两种方式都有效,关键是:一定要让游戏在你自己电脑上跑起来。
6.1 目标设定:做一个「够玩」的原型
在动手写代码、搭场景之前,我们先把本章的目标说清楚,避免初学者盲目开发,越做越乱。这里的「够玩」是一个精确的词,它的意思是:做出一个能正常玩、有反馈、有结束逻辑的简单原型,不追求复杂功能,先把核心玩法跑通。
✅ 本章必须实现的目标(初学者必完成)
- 玩家能通过键盘控制四个方向移动,能通过鼠标瞄准、点击投壶,有基础的手感反馈(比如投壶有冷却时间,不能一直扔);
- 壶飞出去会沿抛物线运动(先上升、再下落),而不是直线飞行,视觉上能明显看到"飘起来"的效果;
- 有敌人角色,敌人能被壶打到,打到后有明显的反馈(比如闪白、掉血);
- 有得分系统,打到敌人能加分,屏幕上能看到当前得分和剩余投壶数量;
- 有完整的游戏循环:点击"开始游戏"进入游戏,投壶用完后游戏结束,能点击"再来一次"重新开始;
- 能让一个从没玩过这个游戏的人,5分钟内就明白"游戏在干什么"(比如:控制蓝色方块,用鼠标瞄准,扔壶打红色方块,得分越多越好)。
❌ 本章不实现的目标(初学者不用纠结)
很多初学者容易犯的错误是:一开始就想做精美的美术、复杂的系统,结果核心玩法都没跑通,最后半途而废。本章我们只聚焦"核心玩法",以下内容暂时不做,后续章节会逐步补充:
- 美术:全部用颜色方块代替(蓝色方块是玩家,红色方块是敌人,灰色方块是壶),不影响核心手感验证,等核心玩法跑通后,再替换成精美的贴图;
- 音效:暂时静音,后续章节会教大家添加投壶音效、敌人受击音效、游戏结束音效;
- 存档:单关卡原型不需要存档,后续做多关卡时再添加;
- 醉意系统、技能系统:这是《酒魂》完整游戏的核心内容,我们会在第16章专门实现,本章先不考虑。
6.2 创建主场景
场景是游戏的"舞台",所有的角色、UI、逻辑都要放在场景里。我们将按照以下步骤,在第4章建立的项目骨架里,创建迷你项目的场景结构。初学者一定要按照步骤来,每一步都要保存场景,避免操作失误导致前面的工作白费。主场景是整个游戏的核心容器,所有的其他节点(玩家、敌人、UI)都要放在这个场景里。
6.2.1 创建场景并设置为主场景
创建步骤如下:
- 打开Godot引擎,找到我们第4章创建的项目;
- 在左侧的"文件系统"面板中,找到 scenes/maps/ 目录;
- 右键点击 maps 目录,选择"新建场景",此时会弹出一个新的场景窗口,根节点类型选择「Node2D」(在搜索框里输入Node2D,点击选中),然后将这个根节点命名为「MiniGame」(双击节点名称,输入即可);
4.点击场景窗口左上角的"保存"按钮(或按Ctrl+S),保存路径选择 res://scenes/maps/ ,文件名输入「mini_game.tscn」,点击"保存"(注意:路径和文件名不要改,后续脚本会用到这个路径);
5.设置主场景:点击顶部菜单栏的「项目」→「项目设置」,在弹出的窗口中,找到「自动加载」选项卡,点击「主场景」后面的"选择"按钮,找到我们刚刚保存的
mini_game.tscn 场景,选中后点击"确定"。这样设置后,游戏运行时会自动加载这个主场景,不用我们手动加载。
6.2.2 添加节点结构并设置属性
接下来,我们在 MiniGame 根节点下,依次添加以下子节点,每个节点的类型、名称和用途都有详细说明,初学者按照下面的结构,一个一个添加,不要跳步:
MiniGame (Node2D) # 根节点,管理整个游戏场景
├── Background (ColorRect) # 游戏背景,深棕色地面,让场景有底色
├── Player (CharacterBody2D) ★ # 玩家节点,后续会导入我们创建的Player场景
├── EnemySpawner (Node2D) # 敌人生成器,管理敌人生成的位置
│ ├── SpawnPoint1 (Marker2D) # 敌人生成点1
│ ├── SpawnPoint2 (Marker2D) # 敌人生成点2
│ └── SpawnPoint3 (Marker2D) # 敌人生成点3
├── UI (CanvasLayer) # UI容器,所有UI元素都放在这里(始终显示在屏幕上方)
│ ├── ScoreLabel (Label) # 得分显示标签,显示当前得分
│ ├── LivesLabel (Label) # 剩余投壶显示标签,显示还能投几次壶
│ ├── GameOverPanel (Panel) # 游戏结束面板,默认隐藏,游戏结束时显示
│ └── StartPanel (Panel) # 开始游戏面板,默认显示,点击开始后隐藏
└── GameLogic (Node) # 游戏逻辑核心,管理得分、游戏循环、敌人生成
└── SpawnTimer (Timer) # 计时器,控制敌人生成的间隔时间
每个节点的详细设置
•Background(ColorRect):选中这个节点,在右侧的"检查器"面板中,找到「Color」属性,点击颜色框,选择「#FEDF99」(浅黄色,也可以自己选一个喜欢的地面颜色);然后找到「Size」属性,设置为(960, 540)(和游戏窗口大小一致,避免出现空白)。
•EnemySpawner(Node2D):这个节点本身不需要特殊设置,只需要添加3个Marker2D子节点(SpawnPoint1、SpawnPoint2、SpawnPoint3),然后将这3个SpawnPoint拖动到场景的不同位置(比如场景的上方、左右两侧),作为敌人生成的位置(敌人会随机在这3个点生成)。
•UI(CanvasLayer):这个节点的作用是让UI元素始终显示在屏幕上方,不受游戏画面移动的影响(比如玩家移动时,UI不会跟着移动)。后续我们会在这个节点下添加具体的UI元素,现在先创建这个节点即可。
•GameLogic(Node):这个节点是游戏的"大脑",后续会挂载脚本,管理游戏的核心逻辑。现在先创建它,然后添加一个子节点「Timer」,命名为SpawnTimer(用来控制敌人生成的间隔时间)。

6.3 创建玩家
玩家是游戏的核心角色,我们将玩家做成一个独立的场景,这样以后在其他地图(比如后续的多关卡)中,直接拖拽这个场景就能使用,不用重复创建。
6.3.1 玩家的场景结构
创建步骤如下:
1.在左侧"文件系统"面板中,找到 scenes/player/ 目录;
2.右键点击 player 目录,选择"新建场景",根节点类型选择「CharacterBody2D」(这个节点适合做可移动的角色,自带移动相关的方法),将根节点命名为「Player」;
3.在Player根节点下,依次添加以下4个子节点,每个节点的类型、名称和设置如下:
Player (CharacterBody2D) # 玩家根节点
├── Body (ColorRect) # 玩家的视觉表现,用蓝色方块占位
├── AimIndicator (Line2D) # 瞄准线,显示玩家的瞄准方向
├── ThrowPoint (Marker2D) # 投壶起点,壶从这个位置扔出去(偏右上)
└── CollisionShape2D # 玩家的碰撞体,用来检测碰撞(比如撞到敌人)

玩家场景各节点的详细设置
- Body(ColorRect):选中这个节点,在右侧"检查器"中,设置「Size」为(32, 48)(宽度32像素,高度48像素,一个长方形的蓝色方块);设置「Color」为蓝色(#2196F3),这样玩家就能明显区分出来;然后设置锚点预设成居中,让方块居中显示在Player根节点的位置。
- AimIndicator(Line2D):这是瞄准线,用来显示玩家瞄准的方向。选中这个节点,在右侧"检查器"中,找到「Width」属性,设置为2(瞄准线的粗细);设置「Color」为白色(#FFFFFF),让瞄准线清晰可见;默认的points(点)设置为两个:(0,0)和(80,0),后续脚本会控制瞄准线指向鼠标方向。

- ThrowPoint(Marker2D):这是投壶的起点,壶会从这个位置扔出去。选中这个节点,在右侧"检查器"中,设置「Position」为(16, -24)(相对于Player根节点的偏移量,偏右上位置,符合投壶的逻辑)。
- CollisionShape2D:玩家的碰撞体,用来检测碰撞。选中这个节点,在右侧"检查器"中,找到「Shape」属性,点击"新建 RectangleShape",设置「size」为(32, 48)(和Body的大小一致,确保碰撞范围和视觉范围一致)。
6.3.2 玩家脚本
场景搭建完成后,我们开始写脚本,实现玩家的移动、瞄准和投壶功能。这是本章代码量最大的一个文件,但每一行都有详细的注释,初学者可以逐行看,理解每一行代码的作用,不要直接复制粘贴就完事------只有理解了,后续才能修改和优化。
步骤1:添加脚本到Player节点
- 打开Player场景(在文件系统中找到player.tscn,双击打开);
- 选中Player根节点,在右侧"检查器"面板的最上方,点击「附加脚本」按钮(图标是一个纸张+铅笔);
- 在弹出的"附加脚本"窗口中,设置「路径」为 res://scenes/player/player.gd (自动生成,不用改);「语言」选择 GDScript;「模板」选择"空脚本";点击「创建」,此时会自动打开脚本编辑器。
步骤2:编写玩家脚本(完整代码+逐行注释)
每一行都有详细的注释,初学者可以对照注释,理解代码的作用(注释是"#"后面的内容,不会被执行):
# 脚本继承自CharacterBody2D,因为Player根节点是CharacterBody2D
extends CharacterBody2D
# 可导出参数:玩家移动速度(初学者可以后续调整,默认200.0合适)
@export var move_speed: float = 200.0
# 可导出参数:投壶的场景(后续会将jug.tscn拖入这个参数)
@export var jug_scene: PackedScene
# 可导出参数:最大投壶数量(默认10个,用完游戏结束)
@export var max_jugs: int = 10
# 自定义信号:投壶时发出的信号(传递投壶的位置和方向,供其他脚本使用)
signal jug_thrown(position: Vector2, direction: Vector2)
# 自定义信号:投壶数量耗尽时发出的信号(通知游戏结束)
signal jugs_depleted
# 变量:剩余投壶数量(初始值等于最大投壶数量)
var jugs_remaining: int = 10
# 变量:投壶冷却标记(防止连续投壶,提升手感)
var _can_throw: bool = true
# 预加载节点:提前获取瞄准线节点,避免每次使用都要查找
@onready var aim_indicator: Line2D = $AimIndicator
# 预加载节点:提前获取投壶起点节点
@onready var throw_point: Marker2D = $ThrowPoint
# 物理帧更新函数(每帧都会执行,用来处理物理相关的逻辑,比如移动)
func _physics_process(_delta: float) -> void:
# 计算移动方向:通过输入映射的动作,获取玩家的移动方向
var dir = Input.get_vector("move_left","move_right","move_up","move_down").normalized()
# normalized() 确保移动速度不会因为斜向移动而变快(斜向移动速度和正向一致)
# 设置玩家的移动速度(方向 × 移动速度)
velocity = dir * move_speed
# 执行移动(CharacterBody2D的内置方法,处理碰撞后的移动)
move_and_slide()
# 瞄准线逻辑:让瞄准线从玩家指向鼠标位置
# 计算瞄准方向:鼠标的全局位置 - 玩家的全局位置,得到方向向量
var aim_dir = (get_global_mouse_position() - global_position).normalized()
# 瞄准线的第一个点:玩家自身位置(相对于Player节点的局部坐标,所以是Vector2.ZERO)
aim_indicator.points[0] = Vector2.ZERO
# 瞄准线的第二个点:瞄准方向 × 80像素(瞄准线长度为80像素,清晰可见)
aim_indicator.points[1] = aim_dir * 80.0
# 输入监听函数(监听玩家的输入,比如点击鼠标、按下键盘)
func _input(event: InputEvent) -> void:
# 判断:如果玩家按下了"throw"动作(后续设置为鼠标左键),就执行投壶函数
if event.is_action_pressed("throw"): _throw_jug()
# 投壶核心函数(实现投壶的所有逻辑)
func _throw_jug() -> void:
# 校验:如果不能投壶(冷却中)或者剩余壶数为0,就直接返回,不执行后续逻辑
if not _can_throw or jugs_remaining <= 0: return
# 剩余壶数减1
jugs_remaining -= 1
# 开启投壶冷却(设置为false,不能再次投壶)
_can_throw = false
# 计算投壶方向:从投壶起点(ThrowPoint)指向鼠标位置
var throw_dir = (get_global_mouse_position() - throw_point.global_position).normalized()
# 实例化投壶对象(从jug_scene场景创建一个壶的实例)
var jug: Node2D = jug_scene.instantiate()
# 将投壶对象添加到场景根节点(让壶在场景中显示)
get_tree().root.add_child(jug)
# 设置壶的初始位置为投壶起点的全局位置
jug.global_position = throw_point.global_position
# 调用壶的launch方法,传递投壶方向(让壶飞出去)
jug.launch(throw_dir)
# 发出投壶信号,通知其他脚本(比如UI脚本,更新剩余壶数)
jug_thrown.emit(throw_point.global_position, throw_dir)
# 如果剩余壶数为0,发出投壶耗尽信号(通知游戏结束)
if jugs_remaining <= 0: jugs_depleted.emit()
# 投壶冷却:等待0.3秒后,解除冷却(可以再次投壶)
# await 表示等待计时器结束后,再执行后面的代码
await get_tree().create_timer(0.3).timeout
_can_throw = true
步骤3:设置输入映射(让键盘/鼠标控制生效)
脚本中用到了"move_up""move_down""throw"等动作,这些动作需要我们手动绑定到对应的键盘和鼠标按键上,否则玩家按键盘、点击鼠标时,游戏不会有反应。步骤如下:
- 点击顶部菜单栏的「项目」→「项目设置」,在弹出的窗口中,找到「输入映射」选项卡;
- 在「输入映射」窗口中,点击「添加动作」按钮,依次添加以下5个动作,每个动作绑定对应的按键:
| 动作名称 | 绑定的按键 |
|---|---|
| move_up | W 键 / 上方向键 |
| move_down | S 键 / 下方向键 |
| move_left | A 键 / 左方向键 |
| move_right | D 键 / 右方向键 |
| throw | 鼠标左键(Mouse Button Left) |
绑定方法:添加动作后,选中动作名称,点击「添加事件」→「键盘」,按下对应的键盘键;再点击「添加事件」→「鼠标」,选择对应的鼠标按键(比如throw动作绑定鼠标左键)。绑定完成后,点击「确定」保存设置。

6.4 创建投壶
投壶的手感是《酒魂》战斗系统的灵魂。壶必须走抛物线,而不是直线飞行------这才有「瞄准、预判落点、调整角度」的乐趣。
6.4.1 俯视角下的物理坐标
《酒魂》游戏是「俯视角游戏(Top-Down)」------简单说,就是摄像机从正上方往下看,玩家角色在一个水平的二维平面里移动(就像你从天花板上往下看地面上的人一样)。这个视角看起来简单,但有一个非常关键的问题,必须在写代码前就想清楚,否则后面会踩很多坑,甚至写出来的游戏逻辑完全错误。
俯视角的重力陷阱: Godot 的 RigidBody2D 有内置重力,方向是「屏幕 Y 轴正方向」(屏幕下方)。在横版游戏里,屏幕下方 = 地面方向,RigidBody2D 的重力完全合理。但在俯视角游戏里,屏幕下方 = 地图南边,不是地面。如果直接用 RigidBody2D,壶会一直往地图南边飘------不是往地上掉,而是往南飘,物理上完全说不通。
俯视角游戏的标准解决方案: 俯视角游戏里,"壶飞出去落回地面"这个过程,其实发生在两个独立的维度里,我们把它们分开处理,就能完美解决重力的问题。初学者不用记复杂的理论,记住下面这3个维度的说明和实现方法,就能做出正确的投壶效果:
| 维度 | 说明 & 实现 |
|---|---|
| XY 平面(屏幕坐标) | 壶在地图上的水平位置。匀速直线运动,不受「地面重力」影响 |
| 高度 height(独立变量) | 壶离地面的高度,受重力影响先升后降。归零时落地。不直接影响屏幕 XY |
| 视觉表现 | 用 height 让 JugSprite 贴图在 Y 轴偏移(越高越靠近屏幕上方),同时 Shadow(阴影)随 height 缩放 |
**重点提醒:**这个方案是所有俯视角游戏的标准操作------《塞尔达传说》系列、《星露谷物语》、《哈迪斯》里的抛物线投掷物(比如炸弹、弓箭),全部采用的是这个原理。它实现起来并不复杂,核心就是:不用RigidBody2D 节点,用普通的 Node2D 节点,用几行代码自己管理 height 这个变量,就能做出正确的抛物线效果。
6.4.2 壶的场景结构
在左侧"文件系统"面板中,右键点击 scenes 目录,选择"新建场景",根节点类型选择「Node2D」,将根节点命名为「Jug」;在Jug根节点下,依次添加以下3个子节点,每个节点的类型、名称和设置如下:
Jug (Node2D) # 壶的根节点,管理壶的世界坐标(水平位置)
├── Shadow (ColorRect) # 壶的地面阴影,始终贴地,随高度缩放
├── JugSprite (ColorRect) # 壶的本体贴图,随高度在Y轴偏移(显示升空效果)
└── HitArea (Area2D) # 命中判定区域,用来检测是否击中敌人
└── CollisionShape2D # 命中碰撞体,定义命中范围
壶场景各节点的详细设置
- Shadow(ColorRect):壶的阴影。选中这个节点,设置「Size」为(20,10)(长方形阴影,符合现实);设置「Color」为深灰色(#333333);设置「Modulate」的Alpha值为0.5(半透明,更自然);选中"Shadow"节点,在场景编辑器的顶部工具栏上,找到并点击锚点预设按钮。在下拉菜单中,选择"左上",始终贴地。

- JugSprite(ColorRect):壶的本体。选中这个节点,设置「Size」为(16,24)(长方形,模拟壶的形状);设置「Color」为灰色(#9E9E9E);在场景编辑器的顶部工具栏上,找到并点击锚点预设按钮。在下拉菜单中,选择"底部居中"。
- HitArea(Area2D):命中判定区域。这个节点用来检测壶是否击中敌人(Area2D适合做触发式碰撞,比如子弹命中敌人)。选中它,添加子节点「CollisionShape2D」,设置「Shape」为"新建 RectangleShape",「Size」为(16, 24)(和JugSprite大小一致,确保命中范围和视觉范围一致)。

6.4.3 「局部坐标」vs「世界坐标」------最关键的概念
在编写脚本之前,必须先搞清楚这个概念,否则后面写代码时,你会困惑"为什么壶的位置不对""为什么壶不落地"------这是初学者最容易混淆的知识点,一定要耐心看。
1. 世界坐标(global_position)
Jug节点(根节点)的「global_position」,负责管理壶在整个游戏世界(地图)中的位置,这就是「世界坐标」。这个坐标始终对应"地面"的位置------也就是说,不管壶飞多高,Jug节点的global_position都在地面上,不会变(它只负责壶的水平位置,左右、前后移动)。
2. 局部坐标(position)
JugSprite是Jug的子节点,它的「position」是相对于父节点Jug的「局部坐标」------也就是偏移量,和整个游戏世界(地图)毫无关系。简单说,JugSprite的position,是"相对于Jug节点的位置",而不是"相对于地图的位置"。
┌────┐ ← JugSprite
│ 壶 │
└────┘
──●──────── ← 地面
(● = Jug.global_position)
① 落地状态(壶在地面上):此时我们设置 jug_sprite.position.y = 0.0 ------ 意思是,JugSprite相对于父节点Jug的Y轴偏移量为0,也就是JugSprite和Jug节点重合,所以壶的贴图会显示在地面上(因为Jug节点的global_position在地面)。
┌────┐ ← JugSprite
│ 壶 │
└────┘
┆ ← 70px
┆
──●──────── ← 地面
(● = Jug.global_position)
② 飞行中状态(壶在空中):此时我们设置 jug_sprite.position.y = -70 ------ 意思是,JugSprite相对于父节点Jug的Y轴偏移量为-70像素(Y轴负方向是屏幕上方),所以壶的贴图会往屏幕上方移动70像素,看起来就像飘在空中。
💡jug_sprite.position.y = 0.0 的意思 这行代码的含义:把 JugSprite 的局部偏移量归零,让它与父节点 Jug 重合。不是「移动到屏幕左上角」,而是「回到父节点的位置」,也就是「贴地落定」。 落地时需要显式写这行,是因为 _process 在height ≤ 0 时提前 return,跳过了最后一次 _update_visuals(),如果不手动归零,height的浮点穿透(-0.几像素)会让贴图略微低于地面。
6.4.4 壶的脚本
现在我们来写壶的脚本,实现抛物线运动、视觉效果、命中检测,重点关注高度变量的变化逻辑和视觉效果的绑定,搞懂每一步代码对应的实际效果,就能轻松掌握俯视角抛物线的实现技巧,后续也能根据自己的需求调整壶的飞行手感(比如调整飞行速度、升空高度等)。下面就是jug.gd的完整代码,保存在res://scenes/jug.gd。
extends Node2D
# 导出变量,可在编辑器的检查面板中调整
@export var launch_speed:float = 480.0 # XY 平面飞行速度
@export var initial_height_vel:float = 380.0 # 初始向上速度(高度/秒)
@export var gravity:float = 750.0 # 高度方向重力(高度/秒)
@export var damage:float = 30.0 # 壶命中敌人时造成的伤害值
@export var height_visual_scale:float = 0.6 # height转Y偏移的比例 就是阴影的缩放比例
# 内部状态变量
var _velocity: Vector2 = Vector2.ZERO # XY 平面飞行速度
var _height: float = 0.0 # 当前离地高度
var _height_vel: float = 0.0 # 高度方向速度(正值向上,负值向下)
var _landed: bool = false # 是否已落地,落地后不再更新飞行逻辑
# 获取子节点引用,使用 @onready 确保在 _ready 前获取
@onready var jug_sprite: ColorRect = $JugSprite # 壶的视觉精灵(ColorRect)
@onready var shadow: ColorRect = $Shadow # 阴影节点
@onready var hit_area: Area2D = $HitArea # 用于检测碰撞的区域
# 当节点进入场景树时自动调用
func _ready() -> void:
hit_area.body_entered.connect(_on_hit_area_body_entered) # 将 hit_area 的 body_entered 信号连接到自定义的处理函数
get_tree().create_timer(4.0).timeout.connect(queue_free) # 设置一个 4 秒的定时器,超时后自动销毁壶(防止无限飞行)
# 外部调用的发射函数,由投掷者调用
func launch(direction: Vector2) -> void:
_velocity = direction * launch_speed # 设置 XY 平面速度:方向 × 发射速度
_height = 0.0 # 初始化高度为 0(贴地)
_height_vel = initial_height_vel # 设置初始向上速度,使壶跃起
func _process(delta: float) -> void:
if _landed: return # 如果已经落地,不再更新飞行物理
#1.XY 平面:匀速飞行,没有重力干预
global_position += _velocity * delta
# 2. 高度维度:受重力拉扯
_height_vel -= gravity * delta # 重力减速(向下加速度)
_height += _height_vel * delta # 更新当前高度
# 3. 落地检测 当高度 ≤ 0 且正在下降时,触发落地
if _height <= 0.0 and _height_vel < 0.0:
_height = 0.0
_on_land() # 调用落地处理函数
return
# 4. 视觉同步
_update_visuals()
# 更新视觉效果(高度对应的位移和阴影缩放)
func _update_visuals() -> void:
# jug_sprite 的 position 是相对于父节点(壶节点)的局部坐标
# 高度越高,Y 坐标越向上(负值),实现升起的视觉
jug_sprite.position.y = -_height * height_visual_scale
# 阴影随高度变化:越高阴影越小越淡
var s = max(0.3, 1.0 - _height / 300.0) # 缩放因子,最小 0.3
shadow.scale = Vector2(s, s * 0.5) # X 轴 s,Y 轴更扁
shadow.modulate.a = s # 透明度随高度降低
# HitArea 跟着 JugSprite 走(判定在壶的视觉位置)
hit_area.position = jug_sprite.position
# 落地处理函数
func _on_land() -> void:
_landed = true # 标记已落地,停止运动
_velocity = Vector2.ZERO # 水平速度归零
jug_sprite.position.y = 0.0 # 归零局部偏移,贴图回到父节点位置(贴地)
shadow.scale = Vector2.ONE # 恢复阴影大小和透明度
shadow.position.y =24
shadow.modulate.a = 1.0
hit_area.position = Vector2.ZERO # 碰撞区域归零(跟随壶位置)
# 等待 0.4 秒后销毁壶(模拟停留时间)
await get_tree().create_timer(0.4).timeout
queue_free()
# 当 hit_area 的 body_entered 信号触发时调用(检测到其他物理体)
func _on_hit_area_body_entered(body: Node) -> void:
# 只对分组为 "enemies" 的物体生效
if not body.is_in_group("enemies"): return
# 如果该物体有 take_damage 方法,则调用它传递伤害
if body.has_method("take_damage"): body.take_damage(damage)
queue_free() # 命中后立即销毁壶
6.4.5 参数调整与视觉验证
| 参数 | 调大 / 调小的效果 |
|---|---|
| launch_speed | 调大:壶飞得快难预判;调小:慢而易瞄准 |
| initial_height_vel | 调大:弧线更高更明显;调小:弧线低平近乎直线 |
| gravity | 调大:落得快飞行距离短;调小:飞得远弧线优美 |
| height_visual_scale | 调大:视觉高度感更强;调小:视觉弧线不明显 |
运行游戏后用此清单验证效果是否正确:
- 壶沿鼠标方向直线飞行(XY 平面匀速)
- JugSprite 贴图先往屏幕上方偏移,再回落(视觉弧线)
- Shadow 始终在 Jug.global_position 处,先缩小变透明,落地恢复原大小
- 落地时 JugSprite 和 Shadow 位置重合(height = 0,局部偏移归零)
- 打到敌人时壶立即消失,不会先落地再消失
**关联壶场景到玩家:**打开Player场景,选中Player根节点,在右侧"检查器"面板中,找到「jug_scene」参数(我们在player.gd脚本中定义的可导出参数),点击参数后面的"选择"按钮,找到 jug.tscn ,选中后点击"确定"。这样玩家投壶时,就能实例化壶的场景了。
6.5 创建敌人
敌人是玩家投壶的目标,我们同样将敌人做成独立场景,方便后续复用和修改。
6.5.1 敌人的场景结构
步骤如下:
1.在左侧"文件系统"面板中,找到 scenes/enemies/ 目录(如果没有,就右键点击scenes文件夹,新建文件夹,命名为enemies);
2.右键点击 enemies 目录,选择"新建场景",根节点类型选择「CharacterBody2D」,将根节点命名为「Enemy」;
3.在Enemy根节点下,依次添加以下4个子节点,每个节点的类型、名称和设置如下:
Enemy (CharacterBody2D) # 敌人根节点
├── Body (ColorRect) # 敌人的视觉表现,用红色方块占位
├── HealthBar (ProgressBar) # 敌人的血条,显示当前血量(头顶显示)
├── HitFlash (ColorRect) # 敌人受击时的闪白效果,默认隐藏
└── CollisionShape2D # 敌人的碰撞体,用来检测被壶击中

敌人场景各节点的详细设置
- Body(ColorRect):选中这个节点,设置「Size」为(28, 40)(比玩家小一点,区分玩家和敌人);设置「Color」为红色(#F44336);居中显示。
- HealthBar(ProgressBar):敌人的血条,显示在敌人头顶。选中这个节点,设置「Position」为(0, -45)(在敌人头顶上方);设置「Size」为(40, 5)(宽度40像素,高度5像素,小巧不遮挡视线);设置「Max Value」为60(后续脚本会设置敌人最大血量为60);设置「Value」为60(初始血量满);在"检查器"中找到"CanvasItem"部分,展开"Visual"。点击 Self Modulate 旁边的颜色块,在弹出的颜色选择器中选一个颜色(绿色 #4CAF50)。
- HitFlash(ColorRect):敌人受击时的闪白效果。选中这个节点,设置「Size」为(28, 40)(和敌人Body大小一致);设置「Color」为白色(#FFFFFF);设置「Modulate」的Alpha值为0.7(半透明,闪白效果更自然);在"检查器"的最上方,取消勾选「Visible」(默认隐藏,受击时再显示)。
- CollisionShape2D:敌人的碰撞体。选中这个节点,设置「Shape」为"新建 RectangleShape",「Size」为(28, 40)(和敌人Body大小一致)。
6.5.2 敌人脚本
选中Enemy根节点,点击「附加脚本」,设置路径为res://scenes/enemies/enemy.gd,语言选择GDScript,模板为空脚本,创建后复制以下代码,逐行注释帮你理解:
extends CharacterBody2D # 继承 CharacterBody2D,用于物理运动
@export var max_health: float = 60.0 # 最大生命值,可在编辑器中调整
@export var move_speed: float = 60.0 # 移动速度
@export var score_value: int = 100 # 敌人被击败后提供的分数
signal died(score: int) # 敌人死亡时发出的信号,携带分数
@onready var health_bar: ProgressBar = $HealthBar # 获取生命条节点(进度条)
@onready var hit_flash: ColorRect = $HitFlash # 获取受击闪烁的矩形节点
var current_health: float # 当前生命值
var _player: Node = null # 玩家节点的引用
func _ready() -> void: # 节点进入场景树时调用
current_health = max_health # 初始化当前生命值为最大生命值
health_bar.max_value = max_health # 设置生命条的最大值
health_bar.value = max_health # 设置生命条的当前值
add_to_group("enemies") # 将自身加入 "enemies" 组,便于查找
_player = get_tree().get_first_node_in_group("player") # 从组中获取玩家节点
func _physics_process(_delta: float) -> void: # 每帧物理处理
if not is_instance_valid(_player): return # 如果玩家节点无效则跳过移动
velocity = (_player.global_position - global_position).normalized() * move_speed # 计算指向玩家的单位方向向量,乘以速度
move_and_slide() # 移动并处理滑动碰撞
func take_damage(amount: float) -> void: # 受到伤害时调用
current_health -= amount # 减少当前生命值
health_bar.value = current_health # 更新生命条显示
hit_flash.visible = true # 显示受击闪烁效果
await get_tree().create_timer(0.1).timeout # 等待 0.1 秒
hit_flash.visible = false # 隐藏受击闪烁效果
if current_health <= 0: # 如果生命值归零或以下
died.emit(score_value) # 发出死亡信号,传递分数
queue_free() # 将自身从场景树中删除
返回到MiniGame场景,在根目录下实例化子场景,选择player,这样就可以将player场景实例化到主场景,操作如下:

选择Player节点,在右侧分组的标签下单击添加新分组按钮(是个加号),输入Player分组名称,操作如下,这样代码中 _player = get_tree().get_first_node_in_group("player") 就可以获取到数据了。

6.6 UI:血条与分数显示
UI 使用 CanvasLayer 节点作为容器------CanvasLayer 的子节点会始终渲染在游戏画面上方,不受游戏摄像机影响,所以 UI 不会随地图滚动而移动。
6.6.1 UI 节点布局
在 MiniGame 场景里,展开 UI(CanvasLayer)节点,按以下方式设置各子节点:
ScoreLabel(Label) 锚点:左上角。文字:「得分:0」。字号 28,颜色白色
LivesLabel(Label) 锚点:左上角,ScoreLabel 下方。文字:「剩余:10壶」
GameOverPanel(Panel) 锚点:居中,铺满全屏,默认 visible=false
└── GameOverLabel(Lable) 子节点,文字:「游戏结束!」,字号 48
└── FinalScoreLabel(Lable):「最终得分:0」,字号 32
└── RestartButton(Button) 文字:「再来一次」,居中
StartPanel(Panel) 铺满全屏,默认 visible=true
└── TitleLabel(Label) 文字:「投壶」,字号 64,居中
└── StartButton(Button) 文字:「开始游戏」,居中
6.6.2 UI 逻辑脚本(挂在 UI 节点上)
存储在res://scenes/ui/game_ui.gd。代码如下:
extends CanvasLayer # 继承 CanvasLayer,用于 UI 层,保证独立于游戏世界
@onready var score_label: Label = $ScoreLabel # 获取得分显示标签节点
@onready var lives_label: Label = $LivesLabel # 获取剩余壶数显示标签节点
@onready var game_over_panel = $GameOverPanel # 获取游戏结束面板节点
@onready var final_score_label: Label = $GameOverPanel/FinalScoreLabel # 获取游戏结束面板中的最终得分标签
@onready var start_panel = $StartPanel # 获取开始面板节点
# ── 由 GameLogic 调用的更新函数 ────────────── # 分隔线注释,说明以下函数供 GameLogic 调用
func update_score(score: int) -> void: # 更新得分显示的函数,参数为新得分
score_label.text = "得分:%d" % score # 设置得分标签文本,格式化整数得分
func update_lives(jugs_left: int) -> void: # 更新剩余壶数的函数,参数为剩余壶数
lives_label.text = "剩余:%d壶" % jugs_left # 设置剩余壶数标签文本
func show_game_over(final_score: int) -> void: # 显示游戏结束界面,参数为最终得分
final_score_label.text = "最终得分:%d" % final_score # 设置最终得分标签文本
game_over_panel.visible = true # 显示游戏结束面板
func hide_game_over() -> void: # 隐藏游戏结束界面
game_over_panel.visible = false # 隐藏游戏结束面板
func show_start() -> void: # 显示开始界面
start_panel.visible = true # 显示开始面板
func hide_start() -> void: # 隐藏开始界面
start_panel.visible = false # 隐藏开始面板
6.7 游戏循环:开始 / 结束 / 重来
GameLogic 是整个迷你游戏的大脑------它监听玩家和敌人的信号,更新分数,判断游戏结束,处理重新开始。给其添加代码,并把代码储存在res://scenes/systems/game_logic.gd,具体如下:
extends Node # 继承 Node,作为游戏逻辑控制器
@export var enemy_scene: PackedScene # 敌人场景资源,可在编辑器中指定
@export var spawn_interval: float = 3.0 # 敌人生成间隔时间(秒)
var score: int = 0 # 当前游戏得分
var game_active: bool = false # 游戏是否进行中
@onready var ui: CanvasLayer = $"../UI" # 获取 UI 节点(CanvasLayer)
@onready var player: CharacterBody2D = $"../Player"# 获取玩家节点
# 获取敌人生成点节点
@onready var enemy_spawner: Node2D = $"../EnemySpawner"
# 获取生成计时器节点
@onready var spawn_timer: Timer = $SpawnTimer
func _ready() -> void:
# 连接开始面板中开始按钮的 pressed 信号到 _start_game 方法
ui.get_node("StartPanel/StartButton").pressed.connect(_start_game)
# 连接游戏结束面板中重启按钮的 pressed 信号到 _start_game 方法
ui.get_node("GameOverPanel/RestartButton").pressed.connect(_start_game)
# 连接玩家的壶被抛掷信号,更新 UI 中剩余壶数显示(参数 _p 为位置,_d 为方向,未使用)
player.jug_thrown.connect(func(_p,_d): ui.update_lives(player.jugs_remaining))
# 连接玩家的壶耗尽信号到 _on_jugs_depleted 方法
player.jugs_depleted.connect(_on_jugs_depleted)
# 设置生成计时器的等待时间
spawn_timer.wait_time = spawn_interval
# 连接生成计时器的 timeout 信号到 _spawn_enemy 方法
spawn_timer.timeout.connect(_spawn_enemy)
func _start_game() -> void: # 开始游戏(开始或重启)
score = 0 # 重置得分为 0
game_active = true # 设置游戏状态为进行中
player.jugs_remaining = player.max_jugs # 重置玩家的剩余壶数
ui.update_score(0) # 更新 UI 显示得分为 0
ui.update_lives(player.max_jugs) # 更新 UI 显示剩余壶数为最大壶数
ui.hide_start()# 隐藏开始面板
ui.hide_game_over()# 隐藏游戏结束面板
# 遍历并删除场景中所有敌人组内的节点(清除残留敌人)
for e in get_tree().get_nodes_in_group("enemies"):
e.queue_free()
spawn_timer.start() # 启动生成计时器
_spawn_enemy() # 立即生成第一个敌人
_spawn_enemy() # 立即生成第二个敌人
func _spawn_enemy() -> void: # 生成一个敌人
if not game_active: return # 如果游戏未进行中,则返回不生成
var pts = enemy_spawner.get_children() # 获取生成点节点的所有子节点(生成点位置)
var enemy = enemy_scene.instantiate() # 实例化一个敌人场
get_tree().root.add_child(enemy) # 将敌人添加到场景树的根节点下
# 随机选择一个生成点,设置敌人的全局位置
enemy.global_position = pts[randi() % pts.size()].global_position
# 连接敌人的死亡信号,增加得分并更新 UI 显示
enemy.died.connect(func(v): score += v; ui.update_score(score))
func _on_jugs_depleted() -> void: # 当玩家壶耗尽时调用(游戏失败)
await get_tree().create_timer(1.5).timeout # 等待 1.5 秒(播放失败动画等)
game_active = false # 设置游戏状态为未进行
spawn_timer.stop() # 停止生成计时器
#遍历并删除场景中所有敌人组内的节点
for e in get_tree().get_nodes_in_group("enemies"): e.queue_free()
ui.show_game_over(score)# 显示游戏结束面板
选中GameLogic,在检查其中将Enemy Scene中设置为enemy场景,这样就可以在指定地点生成敌人了,操作如下:

🎯运行验证清单 按 F5,依次确认:
● 点「开始游戏」→ 出现红色方块向玩家移动
● WASD 移动,鼠标瞄准,左键投壶
● 壶的JugSprite 先往上偏移再落下,Shadow 先缩小再恢复
● 壶打中红色方块 → 扣血、闪白、死亡加分
● 10 壶用完 → 1.5 秒后出现「游戏结束」面板
6.8 导出为可运行 .exe
游戏能运行之后,我们把它导出为独立的 .exe 文件------完整走一遍发布流程,这样以后面对真正的《酒魂》发布时,不会被这个环节卡住。
6.8.1 下载导出模板
1.在 Godot 里点击「编辑器 → 管理导出模板」
2.点击「下载并安装」按钮,等待下载
3.下载完成后关闭对话框

6.8.2 创建导出预设
4.点击「项目 → 导出」
5.点击「添加」→ 选择「Windows Desktop」(如果你在 Windows 上开发)
6.在导出预设里,修改以下设置:
设置项 推荐值
导出路径 选择一个输出文件夹,如 export/windows/JiuHun_Mini.exe
应用名称 投壶 - 《酒魂》原型
版本号 0.1.0
嵌入PCK 勾选(把资源嵌入 exe,发给别人时只需要一个文件)

7.点击「导出项目」,等待打包完成(通常 10-30 秒)
8.进入 export/windows/ 目录,双击 JiuHun_Mini.exe,游戏启动!
💡分享给朋友测试 把这个 .exe 发给 3 个朋友,让他们玩 5 分钟,然后问他们:
- 你在第几秒明白游戏要干什么?
- 哪里觉得不好玩或者困惑?
- 你有没有想「再玩一局」? 这是你第一次真正的 Beta 测试。记录反馈,
本章完成进度
✅ 步骤 1 场景搭建完成 MiniGame / Player / Enemy / Jug 四个场景创建并正确嵌套
✅ 步骤 2 玩家移动可用 WASD 移动,鼠标瞄准,左键投壶(含冷却)
✅ 步骤 3 投壶弧线正确 XY 平面匀速飞行 + height 独立抛物线 + JugSprite 局部偏移
✅ 步骤 4 坐标概念清晰 position.y = 0.0 = 局部归零 = 贴地,不是世界原点
✅ 步骤 5 敌人 AI 运行 向玩家追击,受击闪白,死亡信号加分
✅ 步骤 6 游戏循环完整 开始 → 游玩 → 壶尽 → 结束 → 重来
✅ 步骤 7 导出 .exe 成功 可独立运行的发布文件
本章小结
你刚刚做完了《酒魂》的第一个可玩原型。这个投壶小游戏虽然没有美术、没有音效、没有完整系统,但它具备了一个游戏最核心的要素:玩家能做有意义的事,有反馈,有结果,有重来的冲动。
回顾一下本章用到的技术:
- CharacterBody2D + move_and_slide():玩家和敌人的物理移动
- RigidBody2D + linear_velocity:壶的抛物线物理
- Area2D + body_entered 信号:碰撞检测
- 信号链:壶命中 → enemy.died → GameLogic 加分 → UI 更新
- await get_tree().create_timer():优雅的延时处理(投壶冷却、受击闪白)
- CanvasLayer:UI 不随游戏移动的正确做法
- PckedScene + instantiate():动态生成敌人和壶
🚀下一章 第7章用这个迷你项目作为案例,回顾并直觉化理解两个设计模式:节点组合(组合模式)和信号(观察者模式)。你会发现,本章写的每一段代码背后,都有一个有名字的设计思想。
延伸阅读
- Godot 官方文档「CharacterBody2D」------move_and_slide() 的所有参数说明
- Godot 官方文档「RigidBody2D」------物理属性(重力、弹力、摩擦力)的配置
- GDQuest「2D 平台游戏」教程(YouTube)------CharacterBody2D 移动的进阶技巧
- 《独立游戏大电影》------看完本章后再看一遍,感受会不一样