ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Godot实战:复刻10款经典小游戏,彻底搞懂2D游戏开发

Godot实战:复刻10款经典小游戏,彻底搞懂2D游戏开发 学 Godot 的时候我一度卡在同一个问题上教程看了一堆“节点”“信号”“场景树”这些概念听上去都懂可真要自己从零做一个完整游戏手还是不知道往哪儿放。后来我换了个思路——不按功能学而是直接做十款经典小游戏复刻。打砖块、贪吃蛇、扫雷、俄罗斯方块……每款都是单一玩法的极致代码量小、逻辑完整恰好能把 Godot 2D 游戏开发里最常用的一套东西全过一遍。这篇实践笔记就是那份复刻记录的浓缩版用的语言是 GDScript全部源码都按游戏分目录整理好可以直接打开工程跑起来改。适合刚看完官方文档、正缺一个完整实战项目的人也适合想从零弄清 2D 游戏核心机制、但不想一上来就碰大项目的同学。1. 为什么选 Godot 来复刻这 10 款经典游戏1.1 从“看教程”到“做游戏”中间缺一次完整复刻很多新人学游戏开发都会遇到一种假性理解看别人的代码能看懂觉得“这不难”但轮到自己动手连一个“球撞砖块然后反弹”都写不顺畅。问题在于教程是按知识模块切的而游戏开发是反过来的——一个 10 秒的关卡里输入、碰撞、动画、音效、分数切换会同时发生。复刻经典小游戏刚好能填平这条沟。每款经典游戏的规则早就被玩家验证过不存在“设计不知好坏”的干扰项你只需要把规则翻译成代码。翻译的过程天然覆盖了输入检测键盘/鼠标、碰撞响应Area2D/CharacterBody2D、界面切换CanvasLayer/Control、数据状态数组、字典、网格、随机生成、音效播放、对象生命周期管理。做完十款这些知识点就不是背下来的而是身体记忆。1.2 Godot 的 2D 管线确实更省事做这个系列之前我对比过 Unity 和其他引擎。Unity 的 2D 玩法不是不能做而是它本质上是一套 3D 引擎套 2D 模式场景里总会多出相机缩放、像素单位、物理材质这些需要额外打理的细节。Godot 不一样它是专门为 2D 设计的渲染与坐标体系Node2D、Sprite2D、Camera2D、TileMapLayer 直接就是为 2D 项目准备的。更关键的是 Godot 的场景树机制。一个游戏关卡不是一个空空的“游戏对象集合”而是一棵有层级的节点树。父节点的变换会应用到子节点逻辑上天然适合“玩家节点下面挂着血条、子弹挂在枪口位置这种从属关系”。当你需要做一个跟随玩家的血条时不用写一堆屏幕坐标同步代码直接把血条挂到玩家节点的子节点里即可。GDScript 与引擎的深度集成也拉高了开发效率。语法上接近 Python不需要编译保存脚本后切回引擎改动随时生效。对于“改一个参数体验一下手感”这种高频操作热重载真的能省出大量时间。1.3 那个绕不开的问题选 GDScript 还是 C#Godot 4.x 也支持 C#但我的建议是如果你不是特别熟练 C# 且准备长期依赖 .NET 生态直接 GDScript 就够了。GDScript 的优势不是性能而是写起来和引擎 API 的思维一致。查文档的时候你看到的所有示例、所有信号回调签名都是 GDScript 优先复制粘贴就能跑试错成本低。性能方面2D 小游戏根本触不到 GDScript 的瓶颈。真正耗性能的渲染、物理计算都在 C 底层完成GDScript 只是负责“编剧本”。十款复刻游戏加起来哪怕逻辑最重的俄罗斯方块帧率也稳定在 60 FPS 以上。等到你做出需要大量 CPU 密集计算的玩法时再考虑把热点逻辑挪到 C# 或 GDExtension 也不迟。2. 动手前的脚手架项目结构、场景树与 2D 核心机制2.1 一个工程装十个游戏还是一个游戏一个工程我试过两种方式最后选择了“一个工程装十个游戏”。原因是独立建十个 project.godot 会导致大量重复劳动——每个项目都要重新设置输入映射、窗口尺寸、默认字体、自动加载脚本维护成本偏高。而且复刻的意义在于互相参照放在同一个工程里随时可以打开另一个游戏看它是怎么写碰撞的。目录结构上我给每个游戏单独建一个目录里面再分 scenes、scripts、assets 三个子目录。这样既能共用工程的全局设置又不会让某个游戏的同名脚本互相干扰。所有游戏共享一个主入口场景主入口里放一个简单的菜单界面用按钮切到对应游戏场景。窗口设为 1280x720像素游戏如果需要点阵效果可以在项目设置里把 stretch mode 设为 canvas_items、stretch aspect 设为 keep再配合 viewport 缩放。这个设置对后续导出到不同分辨率屏幕很有帮助。2.2 节点、场景、信号用乐高积木来理解刚开始被“场景”和“节点”绕晕的朋友可以想成乐高积木节点是单个积木块也自带很多类型有负责显示的 Sprite2D有负责接收物理反馈的 CharacterBody2D有负责画 UI 的 Control。一个积木块做不了什么事但把它和别的积木块拼在一起就形成了一个能独立运行的组合体这个组合体就叫场景。信号是这套组合体之间“说悄悄话”的机制。A 节点被子弹打中了不需要自己去找到 B 节点然后调用 B 的方法这样耦合太重。它只需要发出一个信号hit谁关心这个事件谁就去连接。在编辑器里可以把信号连接到任意场景里的其他节点方法也可以在代码里用connect手动连。我写复刻项目时凡是“某个东西发生了另一处要响应”的场景全都优先用信号而不是直接调用后面的维护会舒服得多。2.3 2D 坐标系、Collision Layer 与 Mask 的坑Godot 2D 坐标系里左上角是原点x 轴向右y 轴向下。新手最容易栽的第一个跟头就是搞混方向把物体往上移动应该减 y而不是加 y。另外 Godot 的角度默认是弧度制但提供了一个很贴心的属性rotation_degrees在需要直观角度时直接用度数避免反复转换。碰撞层是另一个必须先弄懂的机制。每个物理体节点的 Collision Layer 表示“我在哪一层”Mask 表示“我检测哪一层”。比如打砖块里球应该检测砖块和挡板那么球的 Mask 设为砖块层和挡板层的编号砖块之间的碰撞则把 Layer 设成互不干扰的层。如果不设置默认都是第 1 层就会出现“砖块把球卡住”这类莫名奇妙的事。后来我把每个游戏的层位以注释形式写在脚本头部比如# Layers: 1player 2ball 3brick调试起来快很多。3. 率先拆解的轻量级三件套打砖块、贪吃蛇、记忆翻牌3.1 打砖块Area2D 反弹与速度递增的自我实现第一款选打砖块是刻意的它虽然简单但把 2D 碰撞、输入控制、分数反馈、UI 更新全串起来了。节点树大致是Breakout/ ├── Game (Node2D) │ ├── Paddle (CharacterBody2D) │ │ └── CollisionShape2D │ ├── Ball (Area2D) │ │ └── CollisionShape2D │ ├── Bricks (Node2D) │ └── UI (CanvasLayer) │ └── ScoreLabel (Label)关键点在于球的反弹控制。很多人会直接把球做成 RigidBody2D结果发现球撞到砖块后经常沿着奇怪的角度飞出去因为物理引擎是按质量和冲量算反弹的而经典打砖块需要的是“按入射角度反弹”的确定性手感。我的实现是让球用 Area2D 自己驱动移动每帧根据当前方向乘以速度计算出新位置方向变化统一在碰撞回调里处理。逻辑上等价于光线反射# Ball.gd extends Area2D var direction Vector2(0.6, -0.8).normalized() var speed : 400.0 func _physics_process(delta: float) - void: position direction * speed * delta func _on_body_entered(body: Node2D) - void: if body.name Paddle: # 根据碰到挡板的横向位置决定反弹角度 var rel: float (position.x - body.position.x) / body.get_node(CollisionShape2D).shape.size.x direction Vector2(rel * 1.5, -abs(direction.y)).normalized() elif body.is_in_group(brick): direction.y -direction.y speed min(speed * 1.03, 800.0) body.queue_free()刚开始我只做了direction.y -direction.y这一行结果发现球在水平方向几乎不动玩家很快就腻了。后来改成根据挡板击打位置调整反射角手感立刻不一样。这也算复刻经典的一个经验规则机制可以简单但“手感”往往来自几个参数的小调整。3.2 贪吃蛇数组当蛇身Timer 当心跳贪吃蛇的核心数据结构不是图形而是一个数组。我一开始走了弯路试图给每节蛇身单独建一个 Sprite2D 节点然后逐个移动代码越写越乱。后来想明白一件事蛇身本身只是一串连续的网格坐标渲染只是把这段坐标画出来。于是实现变成用一个 Vector2i 数组snake存储蛇身每一节的网格坐标。移动时把当前头部前进一格得到的新坐标push_front然后pop_back去掉尾巴。这样蛇身整体向前走完全不用管中间每一节。# Snake.gd 核心移动逻辑 func _on_tick() - void: var new_head: Vector2i snake[0] current_direction # 检查撞墙/撞自身 if not can_move_to(new_head): game_over() return snake.push_front(new_head) if new_head food_position: score 1 spawn_food() else: snake.pop_back() # 用 queue_redraw() 或更新 Sprite2D 位置节奏控制刚开始我用的是_physics_process结果蛇跑得飞快根本没法玩。后来单独用了一个 Timer 节点把 wait_time 设为 0.15 秒每触发一次timeout信号就移动一格。难度提升只需要把 wait_time 减小。这里还踩了一个方向反转的坑如果玩家连续两个输入方向相反比如先按上再按左蛇会瞬间回头撞死自己。简单的防御方式是在方向入队前判断一下不允许与当前方向相反。3.3 记忆翻牌状态机藏在 Button 数组里记忆翻牌是最纯正的 UI 玩法很适合用来练 State 管理和界面交互。我做了 8 张卡片4 对每一张是一个 TextureButton背景用一张统一图案点击后翻出对应图标。核心逻辑其实是一个小状态机点击任意一张卡片如果当前没有翻开任何卡片就记录它如果已经翻开一张就把当前这张和上一张对比相同则标记为已消除不同则短暂等待后把两张都翻回去。# MemoryGame.gd func _on_card_pressed(card: CardButton) - void: if card.is_matched or card.is_flipped: return card.flip() if first_card null: first_card card return if first_card.card_id card.card_id: first_card.set_matched() card.set_matched() matched_pairs 1 first_card null else: # 先禁止所有点击等动画结束后再恢复 await get_tree().create_timer(0.6).timeout first_card.flip_back() card.flip_back() first_card null游戏设计上有一点很关键卡片初始顺序必须打乱。我的做法是把四对卡片的 id 放进数组用 Fisher-Yates 洗牌算法打乱再根据顺序分配给按钮。如果没有这一步每次玩都是同样的布局一分钟就腻了。做完这个游戏之后你会对 UI 事件、字典、变量状态有很直观的理解。4. 物理反馈的三款太空侵略者、飞机躲避、平台跳跃4.1 太空侵略者整群敌人一起动边界转向得有统一调度太空侵略者看起来简单但敌人不是单个移动的而是一整群形成编队集体往右走碰右边界全体下移并反向。如果你让每个敌人自己判断边界很快会出现队形散乱的问题。正确的做法是让一个管理者节点统一计算整群敌人的位置。我的 EnemyManager 节点持有所有敌人节点的引用每帧更新一个global_offset再让每个敌人把自己的位置设为初始位置 global_offset。当某个敌人到达边界EnemyManager 统一改变方向和步进 y。# EnemyManager.gd func _physics_process(delta: float) - void: x_offset direction * move_speed * delta for enemy in enemies: if enemy.is_dead: continue enemy.position enemy.home_position Vector2(x_offset, y_offset) # 边界检测 var leftmost: float INF var rightmost: float -INF for enemy in enemies: if enemy.is_dead: continue leftmost min(leftmost, enemy.position.x) rightmost max(rightmost, enemy.position.x) if rightmost screen_width - margin and direction 0: direction -1 y_offset 16 elif leftmost margin and direction 0: direction 1 y_offset 16子弹方面建议统一使用一个 BulletManager 而不是让每个敌人独立实例化子弹方便做子弹数量限制和回收。当一个敌人死亡直接queue_free()并把它在 enemies 数组里标记为 dead不要立刻从数组里移除否则遍历时容易出错。4.2 飞机躲避对象池与难度曲线怎么配合飞机躲避的核心是不断产生障碍物让玩家操作飞机避开。新手最自然的写法是每到一个时间点就instantiate()一个新障碍物但这样地图上障碍物一多节点增删频繁帧率会掉还会出现 GC 卡顿。正确姿势是对象池。先预先创建 20 个障碍物节点全部隐藏待命。需要生成时从池里取一个放到指定位置并显示飞出屏幕后立刻隐藏并归还池子。所有障碍物是同一个节点下的一批子节点复用率极高。# ObstacleSpawner.gd func _on_spawn_timer_timeout() - void: var obstacle get_from_pool() obstacle.position Vector2(screen_width 50, randf_range(100, 500)) obstacle.show() func get_from_pool() - Obstacle: for ob in obstacle_pool: if not ob.visible: return ob return null难度曲线方面我实测下来不能用“突然变快”式跳跃非常劝退玩家。比较平滑的做法是每 10 秒把生成间隔从 1.2 秒下调 0.05 秒同时把障碍物的基础速度提升 2%。这样玩家能隐约感到压力在上升但又不会突然崩盘。设置一个下界比如生成间隔最低 0.35 秒避免后期生成率过高导致游戏变成纯运气。4.3 平台跳跃重力、双段跳和 TileMap 的关卡设计平台跳跃的物理核心就是重力和跳跃速度的相互作用。CharacterBody2D 自带move_and_slide()我们只需要在_physics_process里让 y 方向速度不断增加# Player.gd extends CharacterBody2D var gravity : 980.0 var jump_velocity : -480.0 var jump_count : 0 var max_jumps : 2 func _physics_process(delta: float) - void: if not is_on_floor(): velocity.y gravity * delta else: jump_count 0 if Input.is_action_just_pressed(jump) and jump_count max_jumps: velocity.y jump_velocity jump_count 1 var input_dir : Input.get_axis(move_left, move_right) velocity.x input_dir * 300.0 move_and_slide()双段跳的实现并不复杂关键在于is_on_floor()的判定。Godot 的move_and_slide()只在物理帧内更新地面的接触状态所以跳跃计数重置要在落地那次回调里做。如果放在_process里判断可能会因为一帧提前重置导致玩家在空中跳不出第二段。关卡部分我用 TileMap 画了几个测试场景后来踩到一个关于单向平台的坑如果玩家从下方跳上去会被平台挡住弹回来。解决方法是给平台单独设碰撞层为“单向平台”然后检测玩家是否从下方进入或者直接使用OneWayCollision相关的物理属性设置。不同版本 Godot 的操作路径有差异但思路是一致的这类平台只阻挡从上往下落的物体不阻挡从下往上穿过的运动。5. 逻辑密度更高的四款2048、俄罗斯方块、扫雷、跑酷5.1 2048二维数组与合并算法是核心2048 表面是个 UI 动画游戏核心其实是二维数组的状态计算。我用一个 4x4 二维数组存储格子值所有滑动操作都是在数组上做变换然后再把结果渲染到画面上。向左滑动是最基础的操作其他三个方向的关键都是坐标旋转。向左滑动的合并逻辑可以抽象成“取一行去掉 0合并相邻相同数字再补回 0”。# Grid.gd 向左合并一行 func merge_line(line: Array) - Array: var nums line.filter(func(v): return v ! 0) var merged: Array [] var i : 0 while i nums.size(): if i 1 nums.size() and nums[i] nums[i 1]: merged.append(nums[i] * 2) score nums[i] * 2 i 2 else: merged.append(nums[i]) i 1 while merged.size() 4: merged.append(0) return merged向右滑动就是把行数组反转后调用 merge_line再反转回来。向上向下的处理方式是先把二维数组转置执行同样的操作再转置回来。用转换思路能避免在四个方向写四套逻辑。这里有个很经典的坑一个数字同一轮滑动中只能被合并一次。比如[2, 2, 2, 2]正确结果是[4, 4, 0, 0]而不是[4, 2, 0, 0]或者[8, 0, 0, 0]。如果用简单的双指针合并很容易一个数连续加两次。上面的while循环通过合并后直接跳过两个元素绕开了这个问题。5.2 俄罗斯方块旋转矩阵和消行判定俄罗斯方块是十款里最容易写崩的一个问题几乎都出在旋转和碰撞判定顺序上。七种方块我用 4x4 布尔数组表示旋转操作就是对矩阵做变换# Tetromino.gd func rotate_cw() - void: var size : shape.size() var rotated : [] for x in range(size): var new_row : [] for y in range(size - 1, -1, -1): new_row.append(shape[y][x]) rotated.append(new_row) shape rotated坐标系统一使用网格坐标一个 10x20 的二维数组表示主游戏区方块对象有自己的相对坐标合成时加上偏移就是绝对网格坐标。判定能不能下落、能不能旋转都要先尝试“如果移动/旋转后会不会出界或与已有方块重叠”尝试成功才真正提交。消行检测不难遍历每一行如果全部非空删除该行并在数组顶部补一个空行。性能上 10 列的规模怎么遍历都没问题。真正让我踩坑的是“下落到底后立即生成新方块”的时机。如果新方块生成后马上做碰撞检测有可能因为上一行没清干净导致游戏瞬间结束。正确做法是先检测消行等所有消行动作完成再生成新方块。5.3 扫雷生成、数字计算与递归翻开扫雷里最有意思的部分是翻开空白格时自动展开周围一整片区域。初学者可能会写一个循环遍历所有格子但其实这里标准的做法是递归或者利用栈/队列做广度优先。地雷生成我用的是“洗完牌后取前 N 个格子”的方式把全部格子编号放进数组随机打乱然后取前雷数作为地雷位置。这样比一次次 random 并判断重复来得稳定而且复杂度低。翻开格子时的处理是关键# MineSweeper.gd func reveal_cell(x: int, y: int) - void: if out_of_bounds(x, y): return if cell[x][y].is_mine: game_over() return set_revealed(x, y) if cell[x][y].adjacent_mines 0: return for dx in [-1, 0, 1]: for dy in [-1, 0, 1]: if dx 0 and dy 0: continue if not cell[x dx][y dy].is_revealed: reveal_cell(x dx, y dy)这个递归有一个潜在问题大量相邻空格同时展开时递归深度可能比较大但通常 10x10 棋盘完全没事。你只需要给每个格子加一个is_revealed标记防止重复进入死循环。胜利判定我是每次翻开或标记后都调用一次检查所有非雷格子都被翻开就弹出胜利界面。5.4 跑酷视差滚动与无限生成的节奏跑酷类游戏的效果主要靠视觉欺骗。玩家停在原地不动但背景、地面、障碍物都在向左移动形成“向前跑”的错觉。地面是一块长方形的 StaticBody2D重复铺几段并循环移动位置背景树、云用 ParallaxBackground 节点做视差滚动不同层设置不同 scroll_scale制造远近距离感。障碍物依然用对象池池里准备 15 个左右每隔一段时间取一个放到右侧随机高度。这里我加了一个“高度区间渐变”的设计前期障碍物基本贴近地面方便玩家熟悉跳跃后期才开始出现需要连续跳跃的高低组合。跑酷类游戏玩到后半段如果机制没变化就会无聊所以我还把得分设计成随时间增长每满 100 分播放一次加速音效让玩家明确感受到关卡在推进。关于手感这里有个容易忽略的细节跳跃的前摇和落地的缓冲。光是把跳跃速度调成和平台跳跃一样玩家会觉得跑酷特别僵硬。我最后把上升阶段加速度设得略小于下降阶段配合快速落地的 squash 动画手感才自然起来。这个调参过程没办法一步到位只能反复试但带来的差异非常明显。6. 源码目录与运行指南拿到手怎么跑、怎么改6.1 十个游戏的目录组织最终源码目录是这样的godot-classic-games/ ├── project.godot ├── assets/ │ ├── fonts/ │ ├── audio/ │ └── icons/ ├── autoload/ │ └── game_router.gd ├── games/ │ ├── breakout/ │ │ ├── scenes/ │ │ ├── scripts/ │ │ └── assets/ │ ├── snake/ │ ├── memory/ │ ├── invaders/ │ ├── dodge/ │ ├── platformer/ │ ├── 2048/ │ ├── tetris/ │ ├── minesweeper/ │ └── runner/ └── main_menu/每个游戏目录下的 scenes 和 scripts 单独维护互不依赖。如果需要共用一些工具函数比如洗牌、随机数、通用按钮样式我放在 autoload 的 GameRouter 里。这个 Autoload 同时负责菜单跳转类似全局单例。6.2 运行步骤与版本要求源码基于 Godot 4.2 编写理论上 4.0 以上的版本都能打开但底层 API 在 4.0 到 4.2 之间有小幅调整建议直接用 4.2 或更高版本。具体操作下载 Godot 标准版不需要 .NET 版本。打开 Godot点击“导入”选择project.godot所在目录。等待资源导入完成后打开main_menu.tscn。按 F5 运行主菜单会显示十个游戏的入口按钮。有些场景如果打开时显示找不到脚本大概率是自动加载脚本没设置好。检查“项目设置 - Autoload”里是否有一个名为GameRouter的全局脚本指向autoload/game_router.gd。这个脚本是唯一的全局入口依赖缺少它会导致菜单跳转失效。6.3 从复刻到原创的扩展思路十个游戏都跑通之后下一步不是做更大的项目而是先给它们换皮和加功能。我建议按这个顺序来先换素材美术、音效、字体再改参数速度、数量、生成间隔最后改机制加道具、加关卡、加排行榜。换美术和改参数是最容易获得成就感的因为改动小、立刻能看到效果。改机制时优先考虑“在一个玩法内加一个垂直系统”比如给打砖块加一个“接住道具后变成三球”的机制给贪吃蛇加一个“吃到金色食物后穿墙”的机制。这种方式既能锻炼系统设计能力又不至于一上来就陷入开放世界的复杂度里。核心代码结构为了教学方便刻意没有抽得很抽象每个游戏都有一定程度的重复代码。这是有意为之。等到你复刻完再回头看时会发现“哪段代码可以提取出来当一个通用工具”那时候的抽象才是有依据的抽象。而如果一开始就封装好反而失去了这个学习过程的意义。7. 十个项目踩出来的坑我整理成了五条7.1 信号与节点生命周期的问题Godot 的信号很便捷但配合queue_free()时容易踩坑。比如太空侵略者的敌人死亡后queue_free()如果这个敌人的某个 Timer 还在运行Timer 的timeout回调可能在被释放的节点上触发导致“Attempt to call function on a previously freed instance”错误。解决办法有两个一是敌人死亡时先停止它所有子节点的 Timer 再释放二是在连接信号时把这个连接绑定到某个“活的”节点上敌人死亡时连接自动断开。我大部分场景用了第一种虽然多几行代码但逻辑一目了然。7.2 物理回调里改位置别忘了 call_deferred_physics_process里所有物理状态更新是有顺序的。你在这个回调里改了position但碰撞系统可能还没计算完导致你设置的坐标下一帧又被覆盖。遇到这种情况最简单的处理是把坐标修改放到call_deferred()或_process的延迟处理里等物理世界稳定了再赋值。这个坑在平台跳跃里最容易遇到明明代码里写着velocity.y -480但第一帧跳跃手感特别怪。排查后发现是物理体在生成时自动进行的形状重建覆盖了初始速度。用call_deferred(setup_velocity)延迟一帧赋值就正常了。7.3 坐标转换TileMap 里的 local_to_map用 TileMap 做关卡的平台跳跃玩家掉落检测、怪物巡逻点都要读取“玩家在第几行第几列”。如果直接用玩家世界坐标去比对会被瓦片原点偏移整得头大。正确做法是用 TileMap 提供的转换方法var tile_pos: Vector2i tile_map.local_to_map(player.global_position)类似地如果你要从网格坐标拿回世界坐标用map_to_local。这两个方法内部已经处理了瓦片大小和原点偏移不要自己去算。7.4 音效节点复用与性能扫雷和打砖块这类游戏里音效触发频率特别高翻开一个格子、消行、弹球。如果每触发一次就新建一个 AudioStreamPlayer 节点很快就会出现同一时间几十个音效节点同时播放不仅听起来糊成一团还容易造成短暂卡顿。更好的做法是维护少量 AudioStreamPlayer 节点每次播放时找一个当前没在播放的找不到就复用最久没用的那个。Godot 4.2 还支持AudioStreamPolyphonic可以在一个流里塞多个声音样本玩 2D 小游戏确实没必要走到那一步但了解一下也不亏。7.5 场景热重载有时候会骗过你GDScript 的热重载很强大但也有一个反直觉的坑修改脚本后引擎默认会在下一次运行时用旧场景实例继续跑而不是重新加载场景。如果在热重载后运行游戏看到的变化和预期不一致先手动切回主菜单再重新进游戏。这个坑我遇到过不下三次前期还以为是代码逻辑错了后来发现只是缓存没刷新。调试建议是在每个游戏脚本入口处加一行print([game] , self.name, started)这样运行后看输出窗口能立刻确认场景确实重新加载了。十款游戏全部完成之后回头再去看 Godot 官方文档和别人的项目理解完全不一样了。以前觉得“场景”“信号”只是两个术语现在能分清哪些场景适合做成独立节点、哪些信号该用编辑器连接、哪些延迟一帧调用只是为了视觉效果。如果让我重新学一遍 Godot我依然会选复刻经典这个笨办法——因为一个接一个地把小游戏做出来、跑通、玩到停不下来是任何教程都给不了的信心。
返回列表