第六章:迷你项目:「投壶」单关卡小游戏

「纸上得来终觉浅,绝知此事要躬行。」 ------陆游《冬夜读书示子聿》

前五章打下了理论和工具基础。第三部分只做一件事:动手。本章从零搭建一个完整可玩的投壶单关卡,包含:玩家移动、正确的俯视角投壶弧线、敌人受击、得分系统、游戏循环,最后导出为可运行的exe。

⏱️ 本章目标 完成时间目标:跟着本章一步一步做,首次完成约需 2-3 小时。如果只想先跑通再理解:直接从配套仓库 clone 本章代码,按F5 运行,然后再回来对照代码阅读。两种方式都有效,关键是:一定要让游戏在你自己电脑上跑起来。

6.1 目标设定:做一个「够玩」的原型

在动手写代码、搭场景之前,我们先把本章的目标说清楚,避免初学者盲目开发,越做越乱。这里的「够玩」是一个精确的词,它的意思是:做出一个能正常玩、有反馈、有结束逻辑的简单原型,不追求复杂功能,先把核心玩法跑通。

✅ 本章必须实现的目标(初学者必完成)

  1. 玩家能通过键盘控制四个方向移动,能通过鼠标瞄准、点击投壶,有基础的手感反馈(比如投壶有冷却时间,不能一直扔);
  2. 壶飞出去会沿抛物线运动(先上升、再下落),而不是直线飞行,视觉上能明显看到"飘起来"的效果;
  3. 有敌人角色,敌人能被壶打到,打到后有明显的反馈(比如闪白、掉血);
  4. 有得分系统,打到敌人能加分,屏幕上能看到当前得分和剩余投壶数量;
  5. 有完整的游戏循环:点击"开始游戏"进入游戏,投壶用完后游戏结束,能点击"再来一次"重新开始;
  6. 能让一个从没玩过这个游戏的人,5分钟内就明白"游戏在干什么"(比如:控制蓝色方块,用鼠标瞄准,扔壶打红色方块,得分越多越好)。

❌ 本章不实现的目标(初学者不用纠结)

很多初学者容易犯的错误是:一开始就想做精美的美术、复杂的系统,结果核心玩法都没跑通,最后半途而废。本章我们只聚焦"核心玩法",以下内容暂时不做,后续章节会逐步补充:

  1. 美术:全部用颜色方块代替(蓝色方块是玩家,红色方块是敌人,灰色方块是壶),不影响核心手感验证,等核心玩法跑通后,再替换成精美的贴图;
  2. 音效:暂时静音,后续章节会教大家添加投壶音效、敌人受击音效、游戏结束音效;
  3. 存档:单关卡原型不需要存档,后续做多关卡时再添加;
  4. 醉意系统、技能系统:这是《酒魂》完整游戏的核心内容,我们会在第16章专门实现,本章先不考虑。

6.2 创建主场景

场景是游戏的"舞台",所有的角色、UI、逻辑都要放在场景里。我们将按照以下步骤,在第4章建立的项目骨架里,创建迷你项目的场景结构。初学者一定要按照步骤来,每一步都要保存场景,避免操作失误导致前面的工作白费。主场景是整个游戏的核心容器,所有的其他节点(玩家、敌人、UI)都要放在这个场景里。

6.2.1 创建场景并设置为主场景

创建步骤如下:

  1. 打开Godot引擎,找到我们第4章创建的项目;
  2. 在左侧的"文件系统"面板中,找到 scenes/maps/ 目录;
  3. 右键点击 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),后续脚本会控制瞄准线指向鼠标方向。
  1. ThrowPoint(Marker2D):这是投壶的起点,壶会从这个位置扔出去。选中这个节点,在右侧"检查器"中,设置「Position」为(16, -24)(相对于Player根节点的偏移量,偏右上位置,符合投壶的逻辑)。
  2. CollisionShape2D:玩家的碰撞体,用来检测碰撞。选中这个节点,在右侧"检查器"中,找到「Shape」属性,点击"新建 RectangleShape",设置「size」为(32, 48)(和Body的大小一致,确保碰撞范围和视觉范围一致)。

6.3.2 玩家脚本

场景搭建完成后,我们开始写脚本,实现玩家的移动、瞄准和投壶功能。这是本章代码量最大的一个文件,但每一行都有详细的注释,初学者可以逐行看,理解每一行代码的作用,不要直接复制粘贴就完事------只有理解了,后续才能修改和优化。
步骤1:添加脚本到Player节点

  1. 打开Player场景(在文件系统中找到player.tscn,双击打开);
  2. 选中Player根节点,在右侧"检查器"面板的最上方,点击「附加脚本」按钮(图标是一个纸张+铅笔);
  3. 在弹出的"附加脚本"窗口中,设置「路径」为 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"等动作,这些动作需要我们手动绑定到对应的键盘和鼠标按键上,否则玩家按键盘、点击鼠标时,游戏不会有反应。步骤如下:

  1. 点击顶部菜单栏的「项目」→「项目设置」,在弹出的窗口中,找到「输入映射」选项卡;
  2. 在「输入映射」窗口中,点击「添加动作」按钮,依次添加以下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 # 命中碰撞体,定义命中范围


壶场景各节点的详细设置

  1. Shadow(ColorRect):壶的阴影。选中这个节点,设置「Size」为(20,10)(长方形阴影,符合现实);设置「Color」为深灰色(#333333);设置「Modulate」的Alpha值为0.5(半透明,更自然);选中"Shadow"节点,在场景编辑器的顶部工具栏上,找到并点击锚点预设按钮。在下拉菜单中,选择"左上",始终贴地。
  2. JugSprite(ColorRect):壶的本体。选中这个节点,设置「Size」为(16,24)(长方形,模拟壶的形状);设置「Color」为灰色(#9E9E9E);在场景编辑器的顶部工具栏上,找到并点击锚点预设按钮。在下拉菜单中,选择"底部居中"。
  3. 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 分钟,然后问他们:

  1. 你在第几秒明白游戏要干什么?
  2. 哪里觉得不好玩或者困惑?
  3. 你有没有想「再玩一局」? 这是你第一次真正的 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 移动的进阶技巧
  • 《独立游戏大电影》------看完本章后再看一遍,感受会不一样
相关推荐
晴天丨2 小时前
🔔 如何实现一个优雅的通知中心?(Vue 3 + 消息队列实战)
前端·vue.js
冰凌时空2 小时前
30 Apps 第 1 天:待办清单 App —— 数据层完整设计
前端·ios
不思进取的程序猿2 小时前
前端性能调优实战指南 — 22 条优化策略
前端
yuki_uix2 小时前
HTTP 缓存策略:新鲜度与速度的权衡艺术
前端·面试
哈撒Ki2 小时前
快速入门 Dart 语言
前端·flutter·dart
ZC跨境爬虫2 小时前
3D 地球卫星轨道可视化平台开发 Day5(简介接口对接+规划AI自动化卫星数据生成工作流)
前端·人工智能·3d·ai·自动化
毛骗导演2 小时前
Claude Code Agent 实现原理深度剖析
前端·架构
星晨雪海2 小时前
若依框架原有页面功能进行了点位管理模块完整改造(3)
开发语言·前端·javascript
morethanilove2 小时前
新建vue3 + ts +vite 项目
前端·javascript·vue.js