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

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

前五章打下了理论和工具基础。第三部分只做一件事:动手。本章从零搭建一个完整可玩的投壶单关卡,包含:玩家移动、正确的俯视角投壶弧线、敌人受击、得分系统、游戏循环,最后导出为可运行的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 移动的进阶技巧
  • 《独立游戏大电影》------看完本章后再看一遍,感受会不一样
相关推荐
To_OC6 小时前
别再串行写 await 了,Promise.all 才是并行请求的正确打开方式
前端·javascript·promise
vipbic6 小时前
中后台越做越乱后,我用插件化把它救回来了
前端·vue.js
Hyyy6 小时前
Computer Use 适合做什么,不适合做什么——一次真实使用后的思考
前端
小和尚同志7 小时前
前端 AI 单元测试思考与落地
前端·人工智能·aigc
invicinble8 小时前
c端系统,其实更像一个信息展示平台
前端
李姆斯9 小时前
管理是否可以被完全量化
前端·产品经理·团队管理
名字还没想好☜9 小时前
Next.js 中间件实战:鉴权、重定向与 A/B 分流
开发语言·前端·javascript·中间件·react·next.js
广州灵眸科技有限公司9 小时前
瑞芯微RV1126B开发板(EASY-EAI-PI2) INI文件操作
java·前端·javascript·网络·人工智能
kaiking_g10 小时前
vue项目中,静态导入 vs 动态导入 详解
前端·javascript·vue.js
江华森10 小时前
Web 开发基础:HTTP 与 REST
前端·网络协议·http