第6章 画出江南小镇:TileMap 地图与摄像机
「不下了一番水磨工夫,如何做得成器?」——《朱子语类》
第 5 章结束时,主角已经能在一片虚空里八方向行走。但一款 RPG 的「角色」,得有世界可以走才成立。本章我们给《酒魂》铺上第一张地图:江南小镇「回忆巷」。地面、河道、石板路用瓦片拼出来;民居、大树、酒旗用大瓦片摆上去;摄像机带着平滑与边界跟着主角走;GameManager 把「换地图」这件迟早要做的事提前做成机制;最后解决俯视角游戏最经典的两个体验问题——谁该挡住谁(Y-Sort)和被挡住时怎么办(遮挡透视,进阶·可选)。
本章要解决什么问题: 把第 5 章「临时塞进主场景」的主角请回正确的位置;创建第一张独立关卡场景
level_town.tscn;把随书瓦片素材做成标准TileSet资源(地形小瓦片 + 道具大瓦片 + 碰撞);分层绘制约 4 屏大小的江南小镇;给 5.1.1 装好的那台相机加平滑跟随与地图边界锁定;实现 GameManager 的关卡动态加载与内存释放;用 Y-Sort 修复深度遮挡。本章你将得到: 一个可以自由探索的江南小镇——主角在里面行走、被墙挡、被树遮、镜头丝滑跟随。以及三件日后反复使用的基建:三层瓦片分层规范、五层物理层命名(第 8 章战斗判定直接沿用)、关卡动态加载机制(第 21 章存档、第 25 章优化的地基)。项目完成度到达 15%。
本章的学习顺序依然是「先看见,再讲究」:先让地图显示出来(6.2~6.5),再让镜头舒服(6.6)、让加载正确(6.7)、让遮挡合理(6.10)。中间穿插的两个机制节(6.7 关卡加载、6.9 交互点位)看起来像「提前干后面的活」,其实它们是地图结构的一部分——点位留给谁、地图怎么被加载,都是在画地图之前就该定下来的骨架。这个思路来自第 3 章的五维蓝图:结构先于细节。
6.1 重构主场景:把「临时试放」的主角请回关卡
6.1.1 第 5 章留下的临时状态
第 5 章 5.3.5 为了测试,我们把 player.tscn 直接拖进了主场景的 CurrentLevel 下面。当时说过那是权宜之计——现在账要算了。打开 scenes/main.tscn,你看到的应该是:
Main (Node2D)
├── CurrentLevel (Node2D)
│ └── Player ← 第 5 章临时拖进来的,先记住它
└── UI (CanvasLayer)
问题在哪?主角不该住在主场景里,她应该住在「关卡」里。 第 4 章 4.2.2 的蓝图写得很清楚:CurrentLevel 是「当前关卡容器」,将来切换地图时整体卸载——主角是关卡的一部分(每张地图有自己的出生点),跟着关卡一起换。她要是住在 CurrentLevel 的直接子级,换地图时就得在代码里把她搬来搬去;她要是住在关卡场景内部,换地图时她自动跟着走,一行代码都不用多写。
6.1.2 三个容器各司其职
先明确本章结束时主场景的样子,再动手:
Main (Node2D) 游戏唯一根节点,全程不销毁
├── CurrentLevel (Node2D) 当前关卡容器:只挂「正在玩的这张地图」
└── UI (CanvasLayer) 全局界面层:永远浮在游戏世界之上
| 容器 | 职责 | 谁会往里放东西 |
|---|---|---|
Main |
根节点 + 游戏入口(6.7 会给它挂一个 main.gd) |
第 7 章的投壶叠加层 |
CurrentLevel |
只装当前关卡,换图时整体 queue_free() |
本章的小镇、以后的酒坊/室内 |
UI |
血条、醉意条、对话框(第 9/14 章起) | 各 UI 章节 |
为什么不趁现在多建几个空容器(比如 World、BattleArena)? 因为空壳节点是负债不是资产:它们不干活的每一天都在暗示读者「这里面是不是漏了什么」,而真正需要的那一天,加一个节点只要五秒钟。第 2 章 2.1.2 说过「用再想想代替动手做」是完美主义陷阱——它的镜像陷阱是「用提前搭架子代替真实需求」。第 7 章做投壶时,会往
Main下加战斗叠加层;那时候加,理由才站得住。
6.1.3 动手:把主角请出去
- 打开
scenes/main.tscn,选中CurrentLevel下的Player,按 Delete 删除(放心,player.tscn文件还在,6.2 会把她请进关卡场景); - 保存
main.tscn。现在按 F5 运行,画面是空白的——正常,CurrentLevel空了,6.7 节会让它重新有东西。这个空白会陪你到 6.7 为止,期间所有测试用 F6(单跑关卡场景)或按 6.5.5 的临时办法。

6.2 创建独立关卡目录与小镇关卡场景
6.2.1 目录早就留好了
第 4 章 4.1.2 建文件夹时,scenes/levels/ 就是为今天准备的。所有城镇、室内地图统一放进这里,与角色预制件(scenes/characters/)、主场景分类管理——以后有十几张地图,也不会乱。
6.2.2 创建小镇关卡场景
- Ctrl+N 新建场景,根节点选 Node2D,重命名为
TownRoot; - Ctrl+S 保存到
scenes/levels/,文件名level_town.tscn(全小写,沿用 4.3.2 的约定); - 按下表把子节点一个一个建出来:
TownRoot (Node2D) 小镇关卡根节点,换图时整体销毁
├── GroundLayer (TileMapLayer) 地面层:青石板、泥地、草地、水面
├── DecoLayer (TileMapLayer) 装饰层:草丛、落叶、花瓣、地面花纹
├── YSortWorld (Node2D) Y 排序容器:所有「有身高」的东西(6.10 详讲)
│ ├── WallLayer (TileMapLayer) 障碍层:民居、大树、院墙、摊位
│ ├── NPCs (Node2D) 本关 NPC 容器(第 18 章用,先建空壳)
│ └── Player (player.tscn 实例) ← 从文件系统面板把 player.tscn 拖进来
└── Markers (Node2D) 不可见点位:出生点、交互触发区(6.9 详讲)
├── SpawnPoint_Default (Marker2D) 主角出生点
└── TriggerZone_Brewery (Area2D) 酒坊门口触发区(第 15 章用)
建完先把两件事做掉:
Player的实例化:从文件系统面板把scenes/characters/player.tscn拖到YSortWorld节点上松手;SpawnPoint_Default的位置:选中它,在视口里把它挪到小镇中央偏下、主角「该出场」的地方(比如石板广场边上)。Marker2D是纯坐标标记,运行时不可见。
为什么 NPC 容器在
YSortWorld里、点位容器却在外面?NPCs里装的是「有身高、会被树挡住」的角色,必须参与 Y 排序;Markers里装的是看不见的坐标,排序对它们毫无意义——少一层计算,也少一层困惑。看得见的进排序容器,看不见的不进,这个原则 6.10 还会再用一次。
为什么现在就建
Markers和空的NPCs? 因为 6.9 会讲清楚:地图是一次性画完的「不动产」,交互点位是地图的一部分。画图时顺手摆好点位,以后每章只需往点上挂逻辑,不用回来改地图。这是「先占位、后开发」——第 4 章 4.2 就在用的思路。
6.3 瓦片地图核心原理与分层设计规范
6.3.1 为什么需要瓦片地图
一张 40×30 格的小镇地图,地面就有 1200 块。如果每块地砖都是一张独立图片,哪怕只有 10KB,也是 12MB 的重复浪费——而且 1199 块长得一模一样。瓦片地图(TileMap)的思路是反过来记账:图片只存一份(图集),地图上记的是「第几行第几列放了图集里的第几块」。
三个直接好处:
- 内存极低:一整张图集只加载一次,地图多大都不增加贴图开销;
- 绘制极快:整个图层合并成极少的绘制批次(第 25 章讲性能时会回到这一点);
- 碰撞可批量:给某一类瓦片配一次碰撞形状,这类瓦片在地图上摆多少个都自动生效。
6.3.2 三层分层规范:本项目从头到尾只用三层
Godot 4.3 起废弃旧的 TileMap 单节点,改用 TileMapLayer 图层节点(4.6 / 4.7 行为一致),每层可独立控制可见性、渲染顺序和碰撞。《酒魂》固定三层,渲染顺序自下而上:
| 图层 | 装什么 | 碰撞 | 参与 Y 排序 |
|---|---|---|---|
GroundLayer |
青石板、泥地、草地、水面 | ❌ | ❌ |
DecoLayer |
草丛、落叶、花瓣、地面花纹 | ❌ | ❌ |
WallLayer |
民居、大树、院墙、摊位、太湖石 | ✅ | ✅ |
分层的本质是把「物理规则不同」的东西分开:地面和贴地装饰不该挡人(不配碰撞),障碍物必须挡人(配碰撞)。混在一层里,你就得给每块草丛单独关碰撞——三个图层把这件事在结构上一次解决。
6.3.3 TileSet 与 TileMapLayer 的分工
初学者最容易混的一对概念,用一张表说清:
TileSet(资源) |
TileMapLayer(节点) |
|
|---|---|---|
| 是什么 | 一本「瓦片图鉴」:图集长什么样、每块瓦片多大、有没有碰撞 | 一张「施工记录」:地图的每个格子放了图鉴里的哪块 |
| 存在哪 | resources/ 文件夹的 .tres 文件 |
场景里的节点 |
| 数量 | 全项目一套 | 每个图层一个 |
TileMapLayer 只记坐标不存图片;视觉、碰撞全部在 TileSet 里。所以一套 jiangnan_tileset.tres 可以同时喂给小镇、酒坊、室内所有地图的三层——改一次图鉴,全部地图生效。
6.4 国风瓦片素材导入与 TileSet 标准化制作
6.4.1 先看清素材:这是一张「拼贴板」
随书素材包的 sucai/tileset_jiangnan.png 已经实测过(数据见附录 B.3),先看懂它,才能正确地切它:
| 实测项 | 结果 |
|---|---|
| 整图尺寸 | 896 × 896,按 32×32 可划分为 28×28 格 |
| 地形区 | 6 个 6×6 格(192×192)的网格化地形块:草地水塘、石板广场 ×2、泥地、泥地水塘、草地泥路——正好都压在 32 格线上,可直接切 |
| 道具区 | 民居、酒楼、水井、柳树、樱花树、竹丛、栅栏、摊位、灯柱等约 20 组散件 |
| 关键事实 | 散件的位置不在 32 网格上(比如马头墙民居左边缘在 x=390,落在第 12 格中间) |
这张图的正确理解是:上半部分是「瓷砖仓库」,下半部分是「家具仓库」。前者可以按 32×32 批量切片;后者每件尺寸不一,必须单独框选。
6.4.2 三条铁律:切素材前先立规矩
沿用第 5 章定下的纪律(5.2.1 的整数倍原则),瓦片素材同样适用:
- 尺寸必须是 32 的倍数——1×1 格是小草小花,2×2 是石柱,10×8 是民居;绝不允许出现 96×100 这种非整格尺寸,否则瓦片接缝必错位;
- 不够的地方用透明留白补齐——民居左边缘在 x=390?那就从 x=384(第 12 格的起点)开始框,左边多出来的 6px 是透明留白,不影响任何东西;
- 只框选、不缩放——素材是 32 基准画的就按 32 用,任何「放大 1.1 倍试试」都会毁掉像素网格。
为什么「留白」是合法的? 瓦片在地图上占的是一个格,不是一堆像素。格子里画了什么(实心图案 or 图案 + 四周透明)引擎不关心。留白只是让「道具的格子边界」和「地图的格子边界」说同一种语言——对齐的是账本,不是图案。
6.4.3 导入素材并创建 TileSet 资源
- 把
tileset_jiangnan.png拖入编辑器文件面板的assets/tilesets/文件夹(4.1.2 建好的);选中它,检查导入设置里Filter是否为 Nearest(4.4.1 设过全局默认,确认一下即可); - 右键
resources/文件夹 → 新建资源 → 搜索并选择 TileSet,保存为jiangnan_tileset.tres; - 双击打开它,底部出现 TileSet 编辑器。在左侧面板把 Tile Size(瓦片尺寸) 设为 32 × 32;
- 在「图块源」面板点 +,选择
tileset_jiangnan.png,弹出「是否自动创建瓦片」时选 是。
6.4.4 理解「自动创建」的结果,然后收拾它
选「是」之后,编辑器会把所有有内容的 32×32 格都生成瓦片——地形区每格都是规整的 1×1 瓦片(正合心意);道具区则被切成了几百块碎片(房顶一角一块、墙皮一块……)。
处理办法很简单:
- 地形区不动——那些 1×1 瓦片就是我们要的(水面、石板、泥地、草地);
- 道具区先删后建——选择“选择”图块卡后,在图块源预览里拖动框选道具区域的碎片,按 Delete 删除,然后切换到“设置”,按住“Shift”重新框选一个完整道具的格子范围(比如民居 10×8 格),松手即生成一块「大瓦片」;
- 重复第 2 步,把下表里的主要道具都做成大瓦片(完整清单见附录 B.3,此处是本章画图必用的最小集):
| 道具 | 大致占格 | 说明 |
|---|---|---|
| 马头墙民居 | 10 × 8 | ⚠ 顶部与石柱间距不足一格,框选可能带入几像素邻件,介意可先在 Aseprite 里裁开 |
| 二层酒楼 | 6 × 6 | 酒旗是酒红色——全图仅有的酒红点缀之一 |
| 蓝顶货摊 | 4 × 3 | 广场摊位区用 |
| 大樱花树 | 5 × 6 | 地标树,Y-Sort 的主角(6.10) |
| 柳树 / 绿荫树 / 竹丛 | 各 3 × 3 | 河岸绿化 |
| 水井 | 2 × 2 | 古井地标(E.3.1 的「回忆巷三景」之一) |
| 木栅栏 | 按 1×2 小段切 | 建议切成小段组合,方便围出任意长度的院墙 |
| 灯柱 / 酒坛 / 木雕人 | 各 1×4 / 1×1 / 2×4 | 街景点缀 |

为什么道具要做成「大瓦片」而不是把图切碎? 三个理由。① 摆放快:画笔一 stamp 就是一整栋房子,不用对十几块碎片;② 碰撞一次配好:大瓦片只需一个「脚部」碰撞矩形(马上讲),碎片方案要配十几个;③ Y-Sort 一个原点:整栋房子作为一个单位参与排序,树冠不会和屋顶互相错乱(6.10.4)。
为什么地面反而要切碎成 1×1? 因为地面的本质是重复——用 3 种石板随机铺 200 格才自然。道具的本质是唯一——一栋房子就是一栋。重复的东西切碎,唯一的东西整拿,这是瓦片使用的总原则。
6.4.5 统一物理层命名:五层定案
现在把第 5 章 5.3.4 留的「第 3 层起以后再命名」的账结了。打开「项目 → 项目设置 → 通用 → 层名称 → 2D 物理」,把五层一次定死,全书不再改:
| 层号 | 名称 | 谁用它 | 谁的 Mask 指向它 |
|---|---|---|---|
| 1 | world |
地图静态障碍(瓦片碰撞、树木民居) | player、enemy |
| 2 | player |
主角身体 | enemy 的攻击判定 |
| 3 | enemy |
敌人身体(第 11 章起) | player 的攻击判定 |
| 4 | hitbox |
攻击判定区(主角与敌人共用一层) | ——(被检测方不检测别人) |
| 5 | item |
可拾取物、交互触发区(第 10/15 章起) | player |
本章先只用到 1 和 2(5.3.4 已配好);3~5 先把名字写上,名字就是文档——将来第 8 章 8.3 的 Hitbox/Hurtbox、第 11 章的敌人,都直接对号入座。
为什么没有「受击判定(Hurtbox)」层? 一个常见的设计是给受击区单独留一层,但那不是必需的:身体层本身就是靶子。主角的刀(Area2D,第 4 层)把 Mask 指向
enemy(第 3 层),就能扫到敌人身体;敌人的爪子把 Mask 指向player(第 2 层),就能打到主角。双方用各自的 Mask 区分敌我,同一层 hitbox 不会误伤自己人。五层够用且不膨胀——层是稀缺资源,每加一层,将来排查碰撞问题就多查一层。
6.4.6 给障碍瓦片配碰撞
- 在 TileSet 编辑器右侧检查器找到 Physics Layers,点 Add Element 添加一条;
- 这条物理层的参数:Collision Layer 只勾 1 (world);Collision Mask 留空——静态障碍只被动阻挡,不需要主动检测任何东西(主角撞它,不是它撞主角);
- 底部面板切换到 绘制 标签,属性下拉选刚建的物理层,选中矩形工具,在瓦片上拖出贴合的碰撞矩形;
- 逐块配置:WallLayer 要用的每一块瓦片(民居、树、栅栏段、石柱……)都要画碰撞;GroundLayer/DecoLayer 的瓦片一块都不要画。
大瓦片的碰撞只画「脚」。 还记得 5.3.4 的道理吗——俯视角里真正挡路的只有物体的底部。民居 10×8 格,碰撞矩形只画最下面 1~2 行的墙体落地部分;大樱花树只画树干底部一小块。否则主角会被「看不见的树冠」挡在两格之外,还找不到原因。5.3.4 的「碰撞只包脚底」,这里是它在地图上的镜像。

6.5 小镇关卡分层绘制流程
6.5.1 三层绑定同一套 TileSet
打开 level_town.tscn,依次选中 GroundLayer、DecoLayer、WallLayer,在检查器的 Tile Set 属性里都加载 resources/jiangnan_tileset.tres。三层共用一套图鉴,贴图只加载一次。
6.5.2 TileMap 编辑器的常用工具
选中任意 TileMapLayer 节点,底部自动出现 TileMap 编辑器,视口里出现网格画布。最常用的五个工具:
| 工具 | 用途 | 典型场景 |
|---|---|---|
| 画笔 | 单格绘制 | 点缀装饰、修边角 |
| 矩形填充 | 批量铺砖 | 大片地面、成排院墙 |
| 油漆桶 | 泛洪填充 | 一键铺满连通区域 |
| 橡皮擦 | 擦除 | 挖门洞、开小路 |
| 随机瓦片 | 随机取同类瓦片 | 石板路、草丛自然化,去除复制粘贴感 |
「随机瓦片」是最容易被忽略的效率神器:选中 2~3 种相近的草地瓦片后用它涂大面积地面,引擎每格随机取一种——比手动换瓦片快十倍,效果自然得多。重复感是瓦片地图的第一天敌。
6.5.3 小镇规划:40×30 格,正好四屏
回忆巷第一版地图:40 × 30 格(1280 × 960 像素)。
为什么是这个尺寸?算一笔账就明白:视口是 640×480,所以 40×30 格恰好是 2×2 = 四个屏——镜头有真实的移动空间,6.6 的边界锁定才有意义;反过来说,如果只画 20×15 格(640×480),地图和一屏一样大,摄像机从开局到结局一动不动,6.6 整节就白写了。另外,主角单帧 128px 高(4 格),15 格高的地图只等于 3.75 个身位,走两步就穿帮;30 格 = 7.5 个身位,才像一个「镇子」。
┌──────────────────────────────────────────────┐
│ ████████████ 北侧院墙(围住地图) ████████████ │
│ █ [竹林] █ 酒坊主建筑█二层酒楼 █ [古井] █ │
│ █ █ ★主角出生点 █ █ │
│ ███[石桥]███ 河道水域 ████[樱花树]█████ │
│ █ 青石板主街 中央广场(投壶预留) [摊位] █ │
│ █ [茶肆] █████████ [竹林] █ │
│ ████████████ 南侧院墙 ████████████████████ │
└──────────────────────────────────────────────┘
设计提示:「够玩」比「够美」重要。 第一张地图只需要三样东西:清晰的可行走区、会挡人的障碍物、一两个有辨识度的地标(酒坊门口、古井、樱花树)。美化和丰富是所有系统跑通之后的事——第 3 章 3.2 说的「先垂直切片,再横向加宽」,地图也一样。
6.5.4 自下而上,三层依次绘制
第一步 GroundLayer(地面):矩形填充铺满基础地面(石板或泥地);用油漆桶把河道区域替换成水面瓦片;主街用「随机瓦片」混铺 23 种相近石板。河道横穿小镇中上部,宽 34 格——别窄于 3 格,否则 4 格高的主角站进去视觉上就「填满」了河。
第二步 DecoLayer(装饰):草坪上零散点草丛与落叶;河岸撒花瓣(贴合 E.3.3 的秋景设定);装饰层一律不配碰撞——6.4.6 已经保证了这一点。
第三步 WallLayer(障碍):四周边缘围院墙锁定地图边界;酒坊、酒楼的民居大瓦片沿街摆放,留出 2 格宽的门洞(碰撞画在瓦片脚部,门洞处不摆瓦片即可通行);河岸点缀柳树;广场边摆摊位与灯柱。
摆完按 F6(单跑当前场景)检查:能看到完整小镇、主角站在出生点、能八方向行走、撞民居会被挡住。
6.5.5 F5 暂时还是空屏?正常
按 F5 运行主场景,你会发现还是一片空白——因为 CurrentLevel 是空的,关卡场景 level_town.tscn 还没有被任何代码加载进来。这正是 6.1 埋下的伏笔:「加载哪张地图」必须由代码说了算,否则换地图就得改场景文件。6.7 节由 GameManager 收尾;在那之前,测试一律用 F6。
一个容易被忽略的细节: F6 单跑
level_town.tscn时,相机正常、地图正常,但GameManager的三个单例也在(自动加载不依赖主场景)——所以 6.7 之前你就可以在关卡里调试任何逻辑,这单跑调试的习惯第 2 章 2.3 讲过。
6.6 摄像机进阶:平滑跟随与地图边界锁定
6.6.1 相机已经在主角身上了
第 5 章 5.1.1 我们给主角装了 Camera2D(Position (0, -64)),当时它的任务是「让你能看见主角」。本节不新建相机,就在那台上调优——场景树不用动,player.tscn 的结构保持:
Player (CharacterBody2D)
├── AnimatedSprite2D
├── CollisionShape2D
└── Camera2D ← 今天只调它的属性
6.6.2 位置平滑:消除「镜头黏死」的僵硬感
选中 Camera2D,勾选 Position Smoothing → Enabled,Speed 设为 8.0:
| Speed | 表现 | 适用 |
|---|---|---|
| 3.0 ~ 5.0 | 明显滞后,镜头慢吞吞追 | 剧情特写、氛围镜头 |
| 6.0 ~ 10.0 | 轻微滞后、流畅不晕 | 日常探索(本项目用 8.0) |
| 15.0 以上 | 几乎同步 | 高速战斗(第 11 章再回头调) |
为什么速度值和第 5 章的
move_speed要配套看? 镜头延迟的「体感」取决于两个速度的比值:主角越快,同值 smoothing 的拖拽感越明显。本书主角move_speed = 280(5.3.3 定案),8.0 的平滑是配套调出来的;将来你若把移速调到 350,就回这里把 Speed 提到 10 左右。镜头参数不是独立存在的,它是「手感」的一半。
6.6.3 地图边界锁定:四条 Limit
不设边界的后果:主角走到地图边缘,镜头跟着出界,画面外露出大片背景色,「世界到头了」的幻觉立刻破碎。Camera2D 的 Limit 四个属性就是四堵看不见的墙(单位:世界像素):
| 属性 | 值 | 怎么算的 |
|---|---|---|
| Limit Left | 0 | 地图左边缘 |
| Limit Top | 0 | 地图上边缘 |
| Limit Right | 1280 | 40 格 × 32px |
| Limit Bottom | 960 | 30 格 × 32px |
设完运行:主角走到四角,镜头稳稳停在地图边缘,画面永不露馅。
进阶·可选:让 Limit 自己算。 手填数字在「全项目地图尺寸统一」时没问题,但将来地图大小不一(酒坊室内可能只有 20×15),每张图手算一遍容易错。可以给 Camera2D 挂几行代码,按当前关卡的
GroundLayer实际尺寸设置limit_right/bottom。本书把它留到真正需要多尺寸地图时再写(第 21 章数据驱动关卡时顺手实现),本章手填即可——为一个还没出现的问题写通用代码,是提前负债。
6.6.4 碰撞层复核
顺手复核主角的身份(5.3.4 配过):Layer 只勾 2 (player),Mask 只勾 1 (world)。现在地图上有真墙了,这套配置马上接受检验:主角被民居挡住 = player 身体撞上 world 瓦片碰撞,全程没写一行碰撞代码。
Layer / Mask 一句话复习: Layer 是「我是谁」,Mask 是「我在乎谁」。6.4.5 的五层表里,每个实体该勾什么、谁的 Mask 该指向谁,都写清楚了——第 8 章战斗判定直接照表施工。
6.7 GameManager:关卡动态加载与内存释放
6.7.1 为什么「加载地图」要做成机制
现在小镇是唯一的地图,直接在编辑器里摆进 CurrentLevel 也能玩。但《酒魂》迟早有:小镇 → 酒坊室内 → 酒窖 → 战斗场地……如果每张地图都常驻场景,游戏运行越久、内存越高,手机端(第 26 章)直接爆掉。
正确机制:任何时刻内存里只有一张活动地图。切换时销毁旧的(queue_free() 连带释放贴图)、实例化新的。这件事属于「全局流程」,所以归 GameManager 管(第 4 章 4.4.3 它的职责就是「游戏正在做什么」)。
6.7.2 先解决一个时序坑:自动加载醒得比主场景早
动手前必须先讲一个 Godot 的时序事实,否则代码会莫名报错:
三个自动加载单例(GameManager 等)的
_ready()比main.tscn的_ready()更早执行——引擎先创建单例,再创建主场景。所以在 GameManager 的_ready()里去拿CurrentLevel节点,拿到的是 null:那一刻主场景还没出生。
解决办法很朴素:让主场景自己来当「开机按钮」——新建 scripts/main.gd,挂到 main.tscn 的根节点 Main 上:
extends Node2D
# main.gd —— 游戏总入口:开机第一件事,把小镇装进 CurrentLevel
# 主场景的 _ready 在所有子节点就绪后触发,此时关卡容器一定存在
func _ready() -> void:
GameManager.switch_level(GameManager.TOWN_SCENE)
6.7.3 改造 game_manager.gd
打开第 4 章 4.4.3 创建的 scripts/game_manager.gd,在保留原有 GameState / change_state() 的基础上,新增关卡加载部分(完整文件如下):
extends Node
# game_manager.gd —— 全局流程管理(自动加载单例)
# 管「游戏正在做什么」(宏观状态)与「当前是哪张地图」(关卡容器)
# 注意区分:第 8 章的主角状态机管「主角正在做什么动作」(微观),两者不混淆
# ---- 游戏状态枚举 ----
enum GameState { EXPLORE, BATTLE, BREWING, DIALOGUE, PAUSED }
# ---- 全局状态 ----
var current_state: GameState = GameState.EXPLORE
var player_drunk_level: float = 0.0 # 全局醉意(第 7 章投壶使用,第 12 章扩展)
# ---- 关卡管理 ----
const TOWN_SCENE: PackedScene = preload("res://scenes/levels/TownRoot.tscn")
var active_level: Node = null # 当前活动关卡(同一时刻只有一个)
var level_container: Node = null # CurrentLevel 容器(惰性获取,见 switch_level)
func _ready() -> void:
print("[GameManager] 游戏启动,当前状态: ", GameState.keys()[current_state])
# 注意:这里不加载关卡!自动加载的 _ready 跑在主场景之前,
# 那时 CurrentLevel 还不存在。「开机加载」由 main.gd 负责(见 6.7.2)
# ---- 关卡切换:先销毁旧关卡,再实例化新关卡,最后安放主角 ----
func switch_level(level_scene: PackedScene, spawn_name: String = "SpawnPoint_Default") -> void:
# 1) 惰性获取关卡容器:第一次调用时主场景已就绪
if level_container == null:
level_container = get_tree().current_scene.get_node("CurrentLevel")
# 2) 销毁旧关卡:queue_free 在本帧末尾执行,其下所有贴图随之释放
if active_level != null:
active_level.queue_free()
active_level = null
# 3) 实例化新关卡并挂进容器
active_level = level_scene.instantiate()
level_container.add_child(active_level)
# 4) 把主角安放到出生点(出生点是关卡的一部分,见 6.9)
var player := active_level.get_node_or_null("YSortWorld/Player") as CharacterBody2D
if player == null:
push_warning("[GameManager] 关卡里没找到主角(YSortWorld/Player)")
return
var spawn := active_level.get_node_or_null("Markers/" + spawn_name) as Marker2D
if spawn != null:
player.position = spawn.position
player.last_direction = Vector2.DOWN
player.update_animation("idle")
# ---- 全局状态切换(第 4 章已有,原样保留) ----
func change_state(new_state: GameState) -> void:
if current_state == new_state:
return
print("[GameManager] 状态变更: ", GameState.keys()[current_state], " -> ", GameState.keys()[new_state])
current_state = new_state
match new_state:
GameState.EXPLORE:
get_tree().paused = false
GameState.PAUSED, GameState.BREWING, GameState.DIALOGUE:
get_tree().paused = true # 弹面板时暂停世界(UI 响应见第 9 章)
GameState.BATTLE:
pass # 动作战斗不暂停世界
F5 运行:小镇被自动装进 CurrentLevel,主角站在 SpawnPoint_Default 的位置,镜头就位。从这一刻起,「换地图」= 一行 switch_level(某场景)——第 21 章的读档换图、第 25 章的内存优化,都建立在这二十行上。
6.7.4 内存逻辑说明
queue_free()是「帧末安全销毁」:不是立刻拆,而是等本帧渲染完毕再拆,避免撕裂;节点树销毁时,其引用的贴图纹理若无其他使用者,显存随之释放;active_level始终只指向一个关卡,赋新值前必先销毁旧值——这个「先还旧债再借新债」的顺序不能反,反了就是内存泄漏;spawn_name参数是给未来留的:从酒坊走回小镇时,主角应该出现在酒坊门口而不是出生点——那时只需switch_level(TOWN_SCENE, "SpawnPoint_FromBrewery")。
6.8 运行验收清单与常见故障排查
F5 运行,逐项打勾:
- 小镇自动加载,主角出现在出生点,无报错;
- 主角可行走于地面/石板路,院墙、民居、树、栅栏全部挡人;
- 民居门洞(2 格宽)可通过,主角能「走进」建筑留出的空当;
- 装饰层草丛不挡人、地面不挡人;
- 镜头平滑跟随无抖动,走到四角镜头锁定边界、画面外无露底;
- 行走/待机动画正常(第 5 章逻辑在关卡里依然工作)。
常见故障排查:
| 现象 | 根因 | 解决方法 |
|---|---|---|
| F5 空白屏,输出面板无报错 | main.gd 没挂上,或挂错节点 |
选中 Main 根节点,确认右上角挂着 main.gd |
报错 Node not found: "CurrentLevel" |
在单例的 _ready 里拿了主场景节点 |
时序坑:加载动作必须由 main.gd 触发(6.7.2) |
报错 Node not found: "YSortWorld/Player" |
Player 没拖进 YSortWorld,或路径不符 | 核对 6.2.2 场景树,注意大小写 |
| 主角穿墙 | WallLayer 瓦片没画碰撞,或 Physics Layer 没勾 layer 1 | 回 6.4.6 给漏配的瓦片补碰撞 |
| 被看不见的东西挡住 | 大瓦片碰撞画满了整块(含树冠/屋顶) | 碰撞只画「脚部」矩形(6.4.6 末尾的方框) |
| 草丛挡人 | 给 DecoLayer 的瓦片误配了碰撞 | TileSet 里删掉装饰瓦片的碰撞形状 |
| 瓦片有白色细缝 | 过滤不是 Nearest,或视口缩放非整数倍 | 核对 4.4.1 全局过滤;窗口缩放保持整数倍(4.4.1) |
| 道具摆放后错位/穿帮 | 框选时没按 32 网格对齐 | 回 6.4.2 三条铁律:32 的倍数 + 留白补齐 |
| 镜头出界露底 | Limit 四个值没填或算错 | 右 = 横向格数 × 32,下 = 纵向格数 × 32 |
| 镜头抖动 | Smoothing 没开,或 Speed 过低 | 勾选 Position Smoothing,Speed 用 8.0 |
排查方法论还是第 2 章那套:先分清输入 → 逻辑 → 显示哪一段断了。地图类问题八成出在「资源配置」(TileSet、碰撞、Limit),用编辑器肉眼核对往往比读代码快。
6.9 地图预留交互点位
6.9.1 先占位、后开发
地图是「不动产」:画完之后,每次为了加一个功能回去搬瓦片,都是双倍工作量(搬地图 + 重新验收)。所以画图时就把未来所有章节要用的点位一次性占好——位置钉死,逻辑后补。
本章 Markers 容器里已有两个成员,按同样的规范补齐 third:
| 点位 | 节点 | 摆放位置 | 服务的章节 |
|---|---|---|---|
| 主角出生点 | SpawnPoint_Default (Marker2D) |
石板广场边 | 本章 6.7 |
| 酒坊门口 | TriggerZone_Brewery (Area2D + CollisionShape2D) |
酒坊民居门洞前,覆盖门口 2×1 格 | 第 15 章(酿造系统:进门触发酿造面板) |
| 李白出生点 | 第 18 章加入 Markers/SpawnPoint_LiBai |
樱花树下 | 第 18~19 章(NPC 与对话) |
| 投壶场地 | 不需要节点 | 中央广场预留 10×8 格无障碍空地 | 第 7 章(投壶)+ 第 11 章(遭遇战) |
TriggerZone_Brewery 的配置:Area2D 的 Collision Layer 勾 5 (item),Mask 勾 2 (player)——「我是可交互物,我在乎玩家碰到我」。本章只摆位置不写脚本,第 15 章来接 body_entered 信号。
注意第 15 章,不是「酿造」想当然的第 13 章——第 13 章是酒魂技。拿不准章号就回目录,引用章号前先对照目录,这是全书纪律(第 3 章 3.1 的教训)。
6.9.2 点位命名的三段式
SpawnPoint_Default、TriggerZone_Brewery——名字统一为「类型_归属」:前半段说它是什么(SpawnPoint / TriggerZone),后半段说它属于谁(Default / Brewery / LiBai)。将来点位多了(每张地图十几个),一眼可辨;这也和 4.5 的命名纪律一脉相承。
6.10 修复 2D 场景深度遮挡 BUG(Y-Sort 动态排序)
6.10.1 现象与成因
F6 运行,操纵主角走到大樱花树上方(俯视角里「树的后侧」)——按理说她应该被树冠挡住半截身子,实际上她整个人叠在树上面,像贴了一张贴纸。反过来站到树下方又正常。纵深感的错乱,一眼假。
成因:Godot 2D 默认按场景树顺序绘制——同一父节点下,排后面的后画、后画的盖住先画的。我们的树在 WallLayer(排在 Player 前面),所以主角永远盖住树,和坐标无关。

6.10.2 解决方案:按 Y 坐标动态排序
正确规则应该是:谁的「脚」更靠下(y 更大),谁画在前面——角色站在树下方(y 大)遮住树根,站在树后方(y 小)被树冠遮挡。这就是 Y-Sort。Godot 里它是 Node2D 的一个开关:Y Sort Enabled。
开启后有一个连锁规则要记牢:父容器开了 Y-Sort,它按「子节点的原点 y」排序;而 TileMapLayer 还有第二级开关——图层自己也要开,瓦片才以「每块瓦片的 Y Sort Origin」为单位参与排序,否则整层瓦片被打包成一个绘制单位,只能和主角比一次。两级都要开。
6.10.3 动手:接入 YSortWorld
6.2.2 建场景时已经把结构摆好了,现在做三步:
- 选中
YSortWorld,检查器勾选 Ordering → Y Sort Enabled; - 选中它下面的
WallLayer,同样勾选 Y Sort Enabled; - 确认
GroundLayer、DecoLayer留在YSortWorld外面——地面和贴地装饰不参与排序(参与也是白算,还可能因为瓦片原点问题把地砖画到主角前面)。
TownRoot (Node2D)
├── GroundLayer (TileMapLayer) 不参与排序
├── DecoLayer (TileMapLayer) 不参与排序
└── YSortWorld (Node2D, Y Sort = √)
├── WallLayer (TileMapLayer, Y Sort = √)
├── NPCs (Node2D) 第 18 章的 NPC 实例自动参与排序
└── Player 原点在脚底(5.3.4 埋的钉子)
6.10.4 校准瓦片的 Y Sort Origin
还差最后一步:「脚」到底定在瓦片的哪条线上?
主角的原点在脚底(5.3.4 特意埋的),所以她的排序基准就是脚。但民居大瓦片的原点默认在整块瓦片的左上角(屋顶尖上)——按屋顶排序,主角站到屋檐下会被「屋顶」盖住,明明她站在房子前面。修法:给每块大瓦片指定 Y Sort Origin(落点相对瓦片原点的 y 偏移):
- 双击打开
jiangnan_tileset.tres,选中民居 / 酒楼 / 大樱花树这类高个子瓦片; - 检查器找到 Y Sort Origin,把它下移到「树干落地说在地面上的那一圈」(大樱花树约在第 5
6 行,民居约在最后 12 行); - 矮小的瓦片(栅栏、酒坛、石柱)保持默认 0 即可;
- 保存,F6 运行,绕着樱花树走一圈:树前露脸、树后藏身,穿插正确。
这一节是 5.3.4 那颗钉子的兑现时刻。 「原点在脚底」+「瓦片 Y Sort Origin 在落地线」= 两边的排序基准都说「脚」的语言,遮挡才天然正确。任何一边偷懒(比如碰撞画满全身、或瓦片 origin 留在屋顶),错误都会在 Y-Sort 开启的瞬间集中爆发——地基打得好不好,验收见真章。
6.11 (进阶·可选) 遮挡轮廓透视:屏幕采样 Shader
本节可整节跳过,不影响主线。它解决的是「遮挡正确了,但主角被挡住时完全看不见」的体验问题。如果你正跟着第一遍学习,建议先跳到 6.12,二刷时回来。
Y-Sort 让遮挡正确了,但主角走进树冠后面就彻底消失,操作上很难受。本节实现:被遮挡的部分自动变成半透明轮廓——没被挡时一切如常,被挡时像隔着毛玻璃看到她。
6.11.1 原理:逐像素颜色比对
思路是「作弊式」的聪明:
- 造一个和主角动作完全同步的「残影」节点,用 Z Index 强制它最后绘制(永远在最上层);
- 残影的 Shader 读取「画到我之前」的屏幕画面(
hint_screen_texture); - 逐像素比对:屏幕上这个位置的颜色 == 本体该有的颜色 → 说明没被挡(本体自己画的)→ 残影透明;颜色不同 → 说明被挡(屏幕上是树)→ 残影画半透明轮廓。
遮挡物零配置:不用给每棵树挂脚本,任何东西挡住主角都自动生效。
6.11.2 创建 PlayerGhost 节点
打开 player.tscn:
- 给
Player再添加一个子节点 AnimatedSprite2D(必须和本体同类型,残影要播同样的动画),重命名PlayerGhost; PlayerGhost的属性:Offset 同样设(0, -64)(和本体一致,否则残影和本体错位半个身位);Z Index = 100、Z As Relative = 关(脱离父层级、永远最后画);Y Sort Enabled = 关(不参与排序);- 在
res://下新建shaders/文件夹(与 scenes、scripts 同级)。
6.11.3 编写遮挡透视 Shader
选中 PlayerGhost → 检查器 CanvasItem → Material → 新建 ShaderMaterial → 展开后 Shader 属性 → 新建 Shader,类型 CanvasItem,保存为 res://shaders/occluder_reveal.gdshader,粘贴:
shader_type canvas_item;
// 读取「画到本节点之前」的屏幕画面(本书渲染器为 Compatibility,天然支持)
uniform sampler2D screen_texture : hint_screen_texture;
// 被遮挡时显示的轮廓颜色(可在检查器调色)
uniform vec4 silhouette_color : source_color = vec4(0.95, 0.85, 0.65, 0.45);
void fragment() {
vec4 sprite_color = texture(TEXTURE, UV); // 本体该有的像素
vec4 screen_color = texture(screen_texture, SCREEN_UV); // 屏幕实际像素
if (sprite_color.a < 0.1) {
COLOR = vec4(0.0); // 透明区域:不要方块底板
} else if (distance(sprite_color.rgb, screen_color.rgb) < 0.02) {
COLOR = vec4(0.0); // 颜色一致 = 没被挡 = 隐形
} else {
COLOR = vec4(silhouette_color.rgb, silhouette_color.a); // 被挡 = 半透明轮廓
}
}
ShaderMaterial 和 Shader 的关系: Shader 是「渲染规则代码」,ShaderMaterial 是「装着规则的容器」——容器才能赋给节点,还能存可调参数(比如
silhouette_color会出现在检查器里,改颜色不用改代码)。这个「代码与参数分离」的思路,和 4.6 的数据驱动一脉相承。
6.11.4 残影同步脚本
新建 scripts/player_ghost.gd,挂到 PlayerGhost 上:
extends AnimatedSprite2D
# player_ghost.gd —— 遮挡透视残影:只负责「和本体长得一样、动得一样」,
# 「该不该显形」完全交给 Shader 逐像素判断
@onready var player_anim: AnimatedSprite2D = get_parent().get_node("AnimatedSprite2D")
func _ready() -> void:
z_index = 100 # 与编辑器设置一致(双保险)
z_as_relative = false
y_sort_enabled = false
#sprite_frames = player_anim.sprite_frames # 直接复用本体的动画库,零拷贝
# ⚠ 这里不要拷贝 sprite_frames:Godot 的 _ready() 是「子先父后」,
# 本节点是 Player 的子节点,_ready() 比 Player 先执行 ——
# 那一刻 Player._build_animations() 还没跑,player_anim.sprite_frames 还是 null。
# 动画库的同步交给 _process 惰性完成(见下),谁先谁后都不怕。
func _process(_delta: float) -> void:
var lib: SpriteFrames = player_anim.sprite_frames
if lib == null:
return # 本体还没切好帧:本帧先不画
if sprite_frames != lib:
sprite_frames = lib # 只在动画库换了时同步一次(零拷贝)
animation = player_anim.animation
frame = player_anim.frame
flip_h = player_anim.flip_h
F5 运行,走进樱花树冠:主角被挡的部分浮现出暖色半透明轮廓,走出即消失。
为什么必须是两个节点,而不是把本体直接挪到最上层? 因为本体必须留在原地参与 Y-Sort(她跟树的真实前后关系全靠它),残影必须脱离排序永远最上层——两者的绘制策略互斥,所以一个负责「真身」,一个负责「替身」,每帧同步动作。职责分离又一次出现了:这一次是在渲染层。
6.12 本章产出:《酒魂》的第一张地图(完成度:15%)
本章你得到了什么
level_town.tscn:40×30 格(四屏)的江南小镇,三层瓦片分层 + 五层物理层 + 预留交互点位;jiangnan_tileset.tres:全项目共用的瓦片图鉴(地形 1×1 + 道具大瓦片 + 脚部碰撞 + Y Sort Origin);- 跟随相机的完全体:平滑跟随 + 地图边界锁定(在 5.1.1 那台上调优,零新建);
- 关卡动态加载机制:
main.gd+GameManager.switch_level(),换地图从此是一行代码; - Y-Sort 遮挡体系:两级开关 + 瓦片落点校准,俯视角纵深正确;
- (可选)遮挡透视残影:屏幕采样 Shader + 同步脚本。
这些设施会被谁复用?
| 本章产出 | 建立位置 | 被后续哪些章节复用 |
|---|---|---|
| 三层瓦片规范 | 6.3.2 | 全部地图章节(酒坊 15、室内 21、战斗场地 11) |
| 五层物理层命名 | 6.4.5 | 第 8 章(Hitbox/Hurtbox)、第 10 章(item 拾取)、第 11 章(enemy) |
| 大瓦片 + 脚部碰撞 + Y Sort Origin | 6.4 / 6.10 | 第 6 章之后所有地图的障碍物 |
| 随机瓦片自然化 | 6.5.2 | 所有地面绘制 |
switch_level() |
6.7 | 第 7 章(投壶场地叠加)、第 15 章(进酒坊)、第 21 章(读档换图)、第 25 章(内存优化) |
| Marker 点位 + 命名规范 | 6.9 | 第 7 章(场地)、第 15 章(酒坊触发)、第 18 章(NPC 出生) |
| Y-Sort 两级开关 | 6.10 | 第 11 章(敌人)、第 18 章(NPC) |
| 遮挡透视 Shader | 6.11 | 第 11 章(敌人被遮挡时同样适用) |
收尾:打上本章的存档点
git add .
git commit -m "第6章完成:江南小镇地图、摄像机进阶、关卡动态加载与Y-Sort"
git push origin master
git tag ch06
git push origin ch06
下一步: 舞台有了,该开戏了。第 7 章我们做《酒魂》的第一个可玩原型——投壶:蓄力条、抛物线弹道、命中判定,全是「看得见的物理」。你将在 6.9 预留的中央广场上,投出游戏的第一支箭。
延伸阅读
以下资源不是必读,卡住或想深入时再看:
-
Godot 官方文档(中文):瓦片地图使用教程:https://docs.godotengine.org/zh_CN/stable/tutorials/2d/using_tilemaps.html —— 地形自动拼接(Terrain)、瓦片动画等进阶功能;
-
Godot 官方文档(中文):Camera2D:https://docs.godotengine.org/zh_CN/stable/classes/class_camera2d.html —— 镜头震动、拖拽偏移、区域限制的全部参数;
-
Godot 官方文档(中文):CanvasItem 着色器:https://docs.godotengine.org/zh_CN/stable/tutorials/shaders/canvas_item_shaders.html —— 6.11 用到的屏幕采样(
hint_screen_texture)官方说明。


评论0
暂时没有评论