Godot引擎入门:从零构建2D躲避游戏Dodge the Creeps
1. 项目概述从零开始用Godot制作你的第一款2D游戏如果你和我一样是个对游戏开发充满好奇但又觉得Unity、Unreal Engine这些“巨无霸”有点让人望而生畏的独立开发者或爱好者那么Godot引擎绝对是你不能错过的宝藏。它开源、免费、轻量最关键的是它的设计哲学——基于场景和节点的组合——让入门变得异常直观。今天我就以“Dodge the Creeps!”躲避小怪这个经典的2D躲避游戏为例记录下我完整的自学入门过程。这不是一个简单的“点击下一步”的教程而是我会把每一步为什么这么做、踩过哪些坑、有哪些小技巧都掰开揉碎了讲清楚。目标是让你看完后不仅能复现这个游戏更能理解Godot制作2D游戏的核心工作流和思维方式。这个项目麻雀虽小五脏俱全。你将亲手搭建一个玩家控制的角色在屏幕上移动同时会有随机生成的敌人从四面八方袭来。你需要躲避它们坚持得越久分数越高。整个过程会涉及场景Scene与节点Node的组织、精灵Sprite与动画、玩家输入处理、物理碰撞检测、随机数生成、UI界面分数和按钮的创建与连接以及简单的游戏状态管理。别被这些术语吓到我会用最直白的方式带你一步步走通。2. 核心概念与项目结构设计在动手写第一行代码或摆第一个节点之前我们必须先理解Godot最核心的两个概念场景Scene和节点Node。你可以把整个游戏世界想象成一棵大树场景树每个节点就是树上的一个枝杈或树叶。一个“场景”就是一棵定义好的小树它可以被反复实例化复制到更大的树中。对于我们的“Dodge the Creeps”游戏我会这样规划项目结构Player玩家一个独立的场景包含精灵、碰撞体、移动逻辑。Mob敌人/小怪另一个独立的场景包含精灵、碰撞体、随机移动和生成逻辑。Main主场景游戏的“舞台”负责生成敌人、管理游戏流程开始、结束、连接所有部分。HUD游戏界面显示分数、游戏提示和开始按钮的UI层。全局资源比如游戏字体、音效、背景图等。这种模块化的设计好处巨大。比如你想修改玩家的外观或行为只需要打开Player.tscn场景进行编辑完全不会影响到敌人或主逻辑。这种“高内聚、低耦合”的思想是使用Godot乃至任何游戏引擎进行高效开发的关键。实操心得在Godot中我强烈建议为每一个逻辑上独立的“游戏对象”都创建一个场景。哪怕它最初只有一个节点。这会让你的项目结构无比清晰后期维护和扩展成本大大降低。不要把所有东西都堆在一个场景里那会是灾难的开始。3. 环境准备与第一个场景玩家Player3.1 创建项目与资源导入首先打开Godot创建一个新项目。我选择的是Godot 4.x稳定版本。渲染器选择Forward兼容性最好项目路径不要有中文和空格。创建好后在文件系统中新建几个文件夹来保持整洁scenes/存放所有场景、scripts/存放所有GDScript脚本、assets/存放图片、音效等。将你下载的素材包包含玩家、敌人精灵图等解压把art/和audio/文件夹拖入assets/目录下。3.2 构建玩家场景新建场景点击“场景”面板的“”号选择“其他节点”我们从一个CharacterBody2D节点开始。为什么是CharacterBody2D而不是RigidBody2D因为玩家需要精确的、由代码控制的移动比如八方向行走并且碰撞后我们希望有确定的反应比如游戏结束而不是模拟真实的物理反弹。CharacterBody2D就是为这种“受代码控制的、与环境交互的物体”设计的。重命名与保存将节点重命名为Player然后按CtrlS将场景保存为scenes/player.tscn。添加视觉表现选中Player节点为其添加一个子节点Sprite2D。在检查器面板中点击Texture属性旁边的[空]选择assets/art/player.png。你可能会发现图片太大。别急在Sprite2D节点的属性中找到Scale将其设置为(0.5, 0.5)缩小一半。添加碰撞形状为了让游戏能检测到玩家被敌人碰到我们需要一个碰撞体。为Player节点添加一个子节点CollisionShape2D。在它的Shape属性中新建一个RectangleShape2D。你会在2D视图中看到一个绿色的矩形框。使用检查器中的Size属性或者直接在2D视图中拖动框上的小橙点调整这个矩形的大小使其大致贴合玩家精灵的轮廓。碰撞体宁小勿大给玩家一点“容错”空间体验会更好。添加脚本最后选中根节点Player点击检查器顶部的“添加脚本”按钮。语言选择GDScript保存为scripts/player.gd。这样玩家的逻辑就与这个场景绑定在一起了。至此玩家的“躯壳”已经搭建完毕。它现在有样子Sprite2D有物理边界CollisionShape2D就等着我们注入“灵魂”脚本让它动起来了。4. 编写玩家移动与控制逻辑双击打开scripts/player.gd我们将在这里编写控制代码。Godot的脚本是附着在节点上的脚本中可以使用$符号快速获取场景中的子节点。4.1 定义移动参数与初始化extends CharacterBody2D # 定义玩家移动速度单位像素/秒 export var speed: int 400 # 获取屏幕尺寸用于限制玩家移动范围 var screen_size: Vector2 func _ready(): # 当节点首次进入场景树时调用进行初始化 screen_size get_viewport_rect().sizeexport关键字是个神器。它将变量暴露在编辑器的检查器中这样我们无需修改代码直接在编辑器里拖拽滑块就能调整玩家速度非常方便迭代和平衡。4.2 处理输入与每帧更新玩家的移动逻辑写在_physics_process(delta)函数中。这个函数在每一个物理帧被调用delta是上一帧到这一帧的时间间隔秒。使用它来计算移动能使游戏速度与帧率无关。func _physics_process(delta): # 初始化一个零向量表示本帧的移动方向 var velocity Vector2.ZERO # 检测键盘输入 if Input.is_action_pressed(move_right): velocity.x 1 if Input.is_action_pressed(move_left): velocity.x - 1 if Input.is_action_pressed(move_down): velocity.y 1 if Input.is_action_pressed(move_up): velocity.y - 1 # 处理移动 if velocity.length() 0: # 如果按下了方向键将速度向量标准化长度变为1然后乘以速度值 velocity velocity.normalized() * speed # 这里可以添加行走动画后面再说 else: # 如果没有按键速度为零 velocity Vector2.ZERO # 这里可以添加 idle待机动画 # 调用父类方法进行移动和碰撞检测 move_and_collide(velocity * delta) # 限制玩家不要跑出屏幕 position position.clamp(Vector2.ZERO, screen_size)代码逻辑很清晰获取输入 - 计算速度向量 - 移动并检测碰撞 - 限制位置。这里用到了move_and_collide()它会根据速度移动玩家并在发生碰撞时立即停止。对于我们的游戏玩家碰到边界就停下这很合适。4.3 配置输入映射你可能注意到了代码里检测的是“move_right”这样的动作名而不是具体的按键。这是Godot输入系统的优秀设计输入抽象。我们可以在项目设置里定义“动作”并为其绑定多个输入源如键盘WASD、方向键、手柄摇杆。打开项目 - 项目设置 - 输入映射。在“动作”栏输入move_right点击“添加”。点击“”号添加事件选择“键盘”然后按下键盘的D键或右方向键。同理为move_left、move_up、move_down分别绑定A/左、W/上、S/下。这样无论玩家使用什么设备我们只需在代码中关心“动作”极大提高了可访问性和可维护性。避坑指南move_and_collide()返回一个KinematicCollision2D对象如果碰撞发生这个对象会包含碰撞信息。目前我们没用到它但如果你想在碰撞时触发一些效果比如播放音效可以这样写var collision move_and_collide(...)然后判断if collision:。5. 创建敌人Mob场景与随机行为敌人和玩家类似也是一个会移动的物体但它的行为是自动的、随机的。5.1 搭建敌人场景新建场景根节点选择RigidBody2D命名为Mob保存为scenes/mob.tscn。为什么用RigidBody2D因为敌人我们不需要精细控制让物理引擎给它一个随机的初速度它自己飞出去就行更简单。添加Sprite2D子节点纹理选择assets/art/mob.png同样调整Scale为(0.5, 0.5)。添加CollisionShape2D子节点形状选择CircleShape2D并调整半径匹配精灵。圆形碰撞体对于这类自由移动的物体通常更合适。添加VisibleOnScreenNotifier2D节点。这个节点非常有用当它的矩形框完全离开屏幕视口时会发出一个screen_exited信号。我们可以利用这个信号来销毁离开屏幕的敌人防止它们无限累积消耗性能。5.2 编写敌人脚本为Mob根节点创建脚本scripts/mob.gd。extends RigidBody2D # 定义敌人移动的速度范围export方便在编辑器中调整 export var min_speed: float 150.0 export var max_speed: float 250.0 func _ready(): # 连接 VisibleOnScreenNotifier2D 的信号 # 当敌人离开屏幕时自动销毁自己避免内存泄漏 $VisibleOnScreenNotifier2D.screen_exited.connect(queue_free) func initialize(start_position: Vector2, player_position: Vector2): # 这个函数由生成它的脚本调用用于设置初始状态 global_position start_position # 计算朝向玩家的方向并加上一个随机偏角让运动不那么直来直去 var direction (player_position - start_position).normalized() # 随机一个角度范围在 -PI/4 到 PI/4 弧度之间即-45度到45度 direction direction.rotated(randf_range(-PI / 4, PI / 4)) # 计算一个随机速度大小 var speed randf_range(min_speed, max_speed) # 设置线性速度让敌人飞出去 linear_velocity direction * speed # 给一个随机旋转速度让敌人看起来在翻滚 angular_velocity randf_range(-PI, PI) # 随机旋转一个初始角度 rotation randf_range(0, 2 * PI)关键点在于initialize函数。它接收一个生成位置和一个目标位置玩家位置。敌人会大致朝玩家方向飞去但加上随机偏角后轨迹变得不可预测增加了游戏难度和趣味性。queue_free()是Godot中安全销毁节点的方法。6. 构建游戏主舞台Main Scene主场景是游戏的导演它负责生成敌人、计时、连接HUD、管理游戏状态开始、进行中、结束。6.1 搭建主场景节点树新建场景根节点选择Node2D命名为Main保存为scenes/main.tscn。用Node2D作为根节点是因为它只是一个简单的容器。添加子节点Timer节点命名为MobTimer。用来定期生成敌人。Timer节点命名为ScoreTimer。用来定期增加分数比如每秒加1分。Marker2D节点命名为StartPosition。放在屏幕左侧某处作为玩家的初始出生点。Path2D节点命名为MobPath。我们将用它来定义敌人的生成路径。选中它在2D视图中使用工具栏的“在路径上添加点”工具画一条从屏幕左侧蜿蜒到右侧的曲线。这条路径上的点将作为敌人随机出现的位置。6.2 编写主场景逻辑为Main节点创建脚本scripts/main.gd。extends Node2D # 使用export将敌人场景预加载并拖入编辑器。这样比在代码里硬编码路径更灵活。 export var mob_scene: PackedScene # 同样预加载玩家场景 export var player_scene: PackedScene var score: int 0 var game_running: bool false func _ready(): # 初始状态不生成敌人也不计分 $MobTimer.stop() $ScoreTimer.stop() # 可以在这里直接调用 new_game() 开始或者等HUD的按钮信号 # new_game() func new_game(): # 重置分数和状态 score 0 game_running true # 如果已有玩家先清除 if has_node(Player): $Player.queue_free() # 生成玩家实例并放置在 StartPosition 的位置 var player player_scene.instantiate() player.position $StartPosition.position add_child(player) # 清除所有旧的敌人Mob get_tree().call_group(mobs, queue_free) # 开始计时器 $MobTimer.start() $ScoreTimer.start() # 更新HUD这里假设HUD已经连接并有一个 update_score 函数 if has_node(HUD): $HUD.update_score(score) $HUD.show_message(Get Ready!) func game_over(): game_running false $MobTimer.stop() $ScoreTimer.stop() if has_node(HUD): $HUD.show_game_over() func _on_mob_timer_timeout(): # 这个函数连接到 MobTimer 的 timeout 信号 if not game_running: return # 1. 在路径上随机选取一个生成点 var mob_spawn_location $MobPath/MobSpawnLocation # MobSpawnLocation 是一个 PathFollow2D 节点我们稍后添加 mob_spawn_location.progress_ratio randf() # 设置其在路径上的位置0到1之间 # 2. 实例化一个敌人 var mob mob_scene.instantiate() # 3. 设置敌人的位置和方向 add_child(mob) mob.position mob_spawn_location.global_position # 4. 初始化敌人传入生成点和玩家位置 var player_node get_node_or_null(Player) if player_node: mob.initialize(mob.position, player_node.position) func _on_score_timer_timeout(): # 每秒增加分数 if game_running: score 1 if has_node(HUD): $HUD.update_score(score)这里有几个关键技巧PackedScene类型export var mob_scene: PackedScene允许我们在编辑器中直接将mob.tscn文件拖拽到这个属性栏里。这是一种非常优雅的资源引用方式。instantiate()方法这是从PackedScene打包场景创建实际场景实例的方法。你可以把它理解为“克隆”或“生成”。节点组Groupget_tree().call_group(mobs, queue_free)这行代码会调用所有属于“mobs”组的节点的queue_free方法。我们需要在敌人被创建时将它们添加到“mobs”组。回到mob.gd的_ready()函数加上一句add_to_group(mobs)。PathFollow2D节点我们需要在主场景的MobPath下添加一个PathFollow2D子节点命名为MobSpawnLocation。这个节点会沿着父路径移动我们通过设置它的progress_ratio0到1来获取路径上任意一点的世界坐标完美解决了随机生成点的问题。6.3 连接信号Godot采用信号Signal机制进行节点间通信这是一种松耦合的观察者模式非常好用。选中MobTimer节点在检查器的“节点”标签页找到timeout()信号双击它连接到Main节点选择_on_mob_timer_timeout函数。这样每当计时器到点就会自动调用这个函数。同理将ScoreTimer的timeout信号连接到_on_score_timer_timeout。我们还需要检测玩家与敌人的碰撞。回到player.gd脚本我们需要定义一个信号并在碰撞时发出它。# 在 player.gd 顶部extends 下一行添加 signal hit func _physics_process(delta): # ... 原有的移动代码 ... # 在 move_and_collide 之后检查碰撞 var collision move_and_collide(velocity * delta) if collision: # 如果发生碰撞发出 hit 信号 hit.emit() # 碰撞后玩家应该暂时“消失”或进入无敌状态这里我们先简单处理禁用碰撞 $CollisionShape2D.set_deferred(disabled, true)在主场景main.gd中我们需要在生成玩家后连接它的hit信号。func new_game(): # ... 生成玩家代码 ... var player player_scene.instantiate() player.position $StartPosition.position add_child(player) # 连接玩家的 hit 信号到 main 的 game_over 函数 player.hit.connect(game_over)这样当玩家碰到任何敌人因为敌人是RigidBody2D会发生碰撞就会触发hit信号进而调用game_over()函数停止游戏。7. 创建游戏界面HUDHUDHeads-Up Display是玩家直接看到的信息层包括分数、提示信息和开始按钮。7.1 搭建HUD场景新建场景根节点选择CanvasLayer保存为scenes/hud.tscn。CanvasLayer是一个特殊的节点它保证其子节点永远绘制在最上层不受游戏世界坐标影响。为CanvasLayer添加一个Control子节点命名为HUD。在HUD下添加Label节点命名为ScoreLabel。用于显示分数。在检查器中调整其Anchor将其定位到左上角例如Top Left并设置合适的边距Layout - Top和Left。选择一个醒目的字体和颜色。Label节点命名为MessageLabel。用于显示“Get Ready!”或“Game Over”等消息。将其锚定在屏幕中央字体可以大一些。Button节点命名为StartButton。将其锚定在屏幕中央偏下的位置。在检查器的Text属性中输入“Start Game”。7.2 编写HUD脚本与信号连接为HUD节点Control创建脚本scripts/hud.gd。extends Control # 定义信号当开始按钮被按下时发出 signal start_game func update_score(new_score: int): $ScoreLabel.text Score: %s % new_score func show_message(text: String): $MessageLabel.text text $MessageLabel.show() # 使用一个一次性计时器来隐藏消息 $MessageTimer.start() func show_game_over(): show_message(Game Over) # 游戏结束后等待一秒再显示开始按钮 await get_tree().create_timer(1.0).timeout $StartButton.show() $MessageLabel.text Dodge the Creeps! $MessageLabel.show() func _on_start_button_pressed(): # 按钮按下时隐藏按钮和消息并发出开始信号 $StartButton.hide() $MessageLabel.hide() start_game.emit() func _on_message_timer_timeout(): $MessageLabel.hide()脚本逻辑很清晰提供更新分数、显示消息、显示游戏结束界面的方法。show_game_over函数里用到了await关键字这是GDScript 2.0的协程语法可以让代码等待一段时间再继续执行非常方便。信号连接在HUD场景中选中StartButton将其pressed()信号连接到HUD节点的_on_start_button_pressed函数。选中HUD下的一个Timer节点需要你添加一个命名为MessageTimer将其timeout信号连接到_on_message_timer_timeout。7.3 将HUD集成到主场景回到主场景main.tscn。实例化HUD场景从文件系统面板将hud.tscn拖拽到主场景的节点树中作为Main的子节点。连接HUD的信号选中HUD节点在检查器节点标签页将其start_game信号连接到Main节点的new_game函数。修改main.gd的new_game和game_over函数调用HUD的方法来更新界面。现在游戏的核心循环就完整了点击HUD的Start按钮 - 触发new_game()- 生成玩家和敌人 - 玩家移动躲避 - 碰撞后触发game_over()- 显示结束界面和按钮。8. 打磨与优化动画、音效与发布8.1 为玩家添加动画让玩家动起来能极大提升游戏感。我们为玩家精灵准备行走动画。在player.tscn中选中Sprite2D节点在检查器中将Texture替换为一个AnimatedSprite2D节点它会自动成为Sprite2D的子节点记得把Sprite2D的纹理清空或删除Sprite2D直接用AnimatedSprite2D作为视觉节点。在AnimatedSprite2D的检查器中点击SpriteFrames属性旁边的[空]-新建 SpriteFrames。点击下方出现的“SpriteFrames”面板点击“添加动画”命名为walk。将assets/art/player_walk_1.png和player_walk_2.png拖入动画帧区域。调整“速度(FPS)”为10左右。再添加一个动画命名为idle只放入assets/art/player.png这一帧。修改player.gd脚本在移动时播放对应动画func _physics_process(delta): # ... 输入检测和速度计算 ... if velocity.length() 0: velocity velocity.normalized() * speed $AnimatedSprite2D.play(walk) # 根据移动方向翻转精灵让面朝移动方向 if velocity.x ! 0: $AnimatedSprite2D.flip_h velocity.x 0 else: velocity Vector2.ZERO $AnimatedSprite2D.play(idle) # ... 移动和限制代码 ...8.2 添加音效和背景音乐音效是游戏的灵魂。Godot的音频系统非常易用。在主场景Main节点下添加一个AudioStreamPlayer节点命名为MusicPlayer。将assets/audio/background_music.ogg拖拽到它的Stream属性中。勾选Autoplay和Loop。在Main节点下再添加一个AudioStreamPlayer节点命名为GameOverSound将assets/audio/gameover.wav拖入。在main.gd的game_over()函数中添加$GameOverSound.play()。你还可以为玩家碰撞、得分等事件添加音效方法类似。8.3 项目设置与导出游戏基本完成最后进行一些收尾工作。窗口设置打开项目 - 项目设置 - 显示 - 窗口。设置大小为你期望的游戏分辨率如 1152x648。在拉伸 - 模式中选择canvas_items缩放模式选择viewport。这能确保游戏在不同分辨率下都能正确缩放。设置主场景在文件系统中右键点击scenes/main.tscn选择“设为主场景”。这样运行项目时就会直接进入游戏。测试点击编辑器顶部的“运行当前场景”按钮或按F6尽情测试你的游戏吧导出当你对游戏满意后可以将其导出为可执行文件。点击项目 - 导出。你需要先为你的目标平台如Windows下载一个“导出模板”。然后添加一个预设选择好输出路径和可执行文件名称点击“导出项目”即可。Godot的导出包非常小巧这个游戏可能只有几MB。9. 常见问题与进阶思考在整个开发过程中你可能会遇到以下问题这里是我的排查思路敌人不生成检查Main场景中MobTimer的Wait Time是否设置合理如1秒。检查MobTimer的timeout信号是否正确连接到Main脚本的函数。在_on_mob_timer_timeout函数开始加一句print(“生成敌人”)看控制台是否有输出。检查mob_scene导出变量是否在编辑器中正确赋值拖入了mob.tscn。碰撞检测失灵确保玩家和敌人都有CollisionShape2D并且形状大小合适在2D视图中可见绿色/红色轮廓。检查玩家和敌人的根节点类型是否正确CharacterBody2D和RigidBody2D。检查碰撞层Layer和掩码Mask。默认情况下所有物体都在第1层且掩码也是第1层应该能互相碰撞。如果不确定可以在项目设置的“层名称”中为“2D物理”定义好层如“player”, “mob”然后在各自的Collision属性中精细设置。游戏卡顿使用Godot内置的分析器调试器 - 分析器标签页查看物理过程和空闲过程的耗时。如果敌人数量很多时卡顿可能是因为RigidBody2D的物理计算开销大。对于大量简单运动的敌人可以考虑使用CharacterBody2D并自己控制移动或者使用MultiMeshInstance2D进行合批渲染。如何让游戏更好玩进阶方向难度曲线让MobTimer的等待时间随着分数增加而减少$MobTimer.wait_time max(0.3, 1.0 - score * 0.01)并逐渐提高敌人速度。多种敌人创建不同的Mob场景如快慢不同、大小不同在生成时随机选择。道具系统创建Item场景玩家拾取后可以获得临时加速、无敌等效果。关卡设计使用TileMap节点来搭建复杂的静态关卡地形而不仅仅是空旷的场地。存档系统使用ResourceSaver和ResourceLoader来保存和读取最高分。自学Godot制作这个2D小游戏的过程就像在搭一个精致的乐高模型。每一个节点都是一块积木脚本则是拼装说明书。从最初面对空白场景的茫然到最终看到自己创造的角色在屏幕上灵活动作躲避着由自己代码生成的敌人这种成就感是无与伦比的。Godot的简洁与强大在于它用最直观的“场景树”概念掩盖了背后复杂的游戏引擎架构让你能专注于游戏创意本身。希望这篇记录能成为你Godot之旅的一块坚实垫脚石。接下来试着修改参数添加你自己的创意把它变成属于你的独一无二的游戏吧。