学 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: 1=player 2=ball 3=brick,调试起来快很多。
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,我依然会选复刻经典这个笨办法——因为一个接一个地把小游戏做出来、跑通、玩到停不下来,是任何教程都给不了的信心。