GGJ项目复盘

绪论

此文章用于记录及复盘2026年GGJ比赛。 主题为“面具”。 时间限制48小时。

玩法及核心设计

拿到此次GGJ比赛"面具"主题后,我认为聚焦于面具的不同种类以及面具的身份象征有些俗套,没有新意,所以我将重心聚焦于面具摘下和戴上这个瞬时动作。带上和摘下面具往往意味着一个身份的切换,外界对我们的观测改变了,那反过来想,带上和摘下面具,我们对世界的观测是否改变了呢?或许我们可以这样设计,带上面具,我们会来到一个不同的世界。引伸出我们游戏的主题"Mask" —— 面具不仅是装饰,更是连接两个维度的钥匙。

所以我们现在的玩法重心落到了“切换”上,我想到了泰坦陨落2中时空切换这一关。两种时空里的障碍物位置和类型不一样,玩家可以通过切换不同时空来解密,跑酷。所以我参考这一关设计了此游戏的核心玩法:

表里世界 表里世界 以“戴上/摘下面具切换表里世界”为核心的 2D 平台跳跃游戏,玩家需要在跑跳过程中利用两个世界中平台、障碍和通路的互补差异来前进。简单说,就是一边平台跳跃,一边抓准时机切换世界,用“这里能走、那里能穿、另一边才看得见”的机制完成解谜与闯关。

核心移动方式及表里世界切换

这是一个 2D 平台跳跃游戏,所以角色的基础移动采用左右移动与跳跃的标准方案。考虑到 GGJ 开发时间非常紧,我没有自己从零实现角色控制器,而是直接接入了开源的 Tarodev 2D Controller,用它快速完成角色移动与跳跃的基础手感。

本作真正的核心玩法是“表里世界切换”。我参考了泰坦陨落2的部分实现思路,在同一场景中搭建上下两套对应地形,并通过修改玩家纵向位置来完成世界切换。这样做的优点是实现成本低、逻辑直观,缺点是两个世界的地形和交互需要始终保持对应关系,否则很容易出现穿帮或同步问题。

我的实现如下: 读取当前世界状态 切换到另一个世界 计算对应的上下位移量 同步更新相关系统状态 将玩家沿 y 轴移动到另一层世界 表里世界

流程与基础设施

这一部分对应的不是具体玩法,而是整个项目能稳定运行的底层支撑。对 GGJ 这种 48 小时项目来说,场景切换、UI、输入和常驻对象如果处理不好,往往比玩法本身更消耗时间。所以我做这部分时的目标不是追求完整,而是尽量把常见问题前置处理掉,减少重复排错。

流程控制这一块,我把场景切换统一收口到 SceneLoader。它负责淡入淡出、异步加载、简单加载提示,以及切场景前后的状态清理。这样做的好处是所有切场景逻辑都走同一个入口,不用在按钮、触发器和关卡终点里各写一套代码,同时也顺手解决了遮罩卡住 UI、场景切换闪白、菜单状态没回收导致新场景输入失效等问题。

为了让关卡之间的切换尽量“无感”,我又把玩家、部分 UI 和音频对象做成了常驻对象。PersistentPlayer 负责保留玩家实例,并在新场景加载后自动传送到出生点、重新绑定 Cinemachine;PersistentCanvasEventSystemBootstrapper 则保证 UI 跨场景仍然可用,并避免出现多个 EventSystem 冲突。这里比较关键的是唯一性控制,也就是同类常驻对象只保留一个实例,否则切几次场景之后就很容易出现重复 UI、重复音频源或重复输入响应。

输入与 UI 这一层,我额外做了 InputPriorityGate 来统一处理“菜单、对话、玩法输入”之间的优先级。这样暂停、背包、对话、世界切换等系统不会在同一帧里互相抢输入,具体功能脚本也只需要在入口处做一次判断,就能共享同一套规则。

音频和运行时自举这部分,我采取的是“尽量少配场景,尽量自动启动”的思路。AudioManagerBGMPlayer 都支持运行时自动创建并常驻,背景音乐也约定从 Resources/Audio 下读取,减少场景配置成本;BuildSettingsSceneSync 这类小工具则负责把一些容易漏配的编辑器步骤收口成统一入口。现在回头看,这部分基础设施最大的作用不是功能本身,而是让我在有限时间里少花精力处理工程细节,把更多时间留给关卡和核心玩法。

交互物设计与死亡

这一部分主要对应关卡里的功能性交互物,例如墙体、移动平台、风场、地刺等。我的思路不是做一套很复杂的机关系统,而是让每种交互物都承担非常明确的功能:墙体和平台限制路线,移动平台改变站位节奏,风场改变速度与跳跃轨迹,地刺负责失败判定。对 GGJ 这种短周期项目来说,这种拆法更容易调试,也更容易快速迭代。

从实现方式上看,我把交互物分成两类。第一类是纯配置型交互物,例如墙体、地面和普通障碍,主要依赖 Tilemap、Collider2D 和场景摆放完成;第二类是行为型交互物,这部分才通过脚本补充动态规则。例如移动平台使用 PlatformMover2D 做往返运动,底层通过 Mathf.PingPong 计算路径,并在 FixedUpdate 中更新位置。为了避免玩家站在平台上时被甩下去,我又额外做了 carry 机制:记录站在平台上的对象,再把平台本帧位移同步给这些对象,同时在进入和离开平台时补偿一部分速度。这一套做法不算特别“物理真实”,但对于平台跳跃的手感来说更稳定。

另一个比较典型的交互物是风场。我给风场单独写了 WindArea2D,核心逻辑是在 OnTriggerStay2D 中持续对玩家施加沿指定方向的速度增量或力。因为角色控制器本身会频繁改写速度,所以我没有完全依赖 AddForce,而是额外提供了直接叠加速度和强制最小风向速度的做法,保证角色进入风场后一定能被“吹起来”或“吹走”。

死亡逻辑则是另一条单独的系统线。地刺、尖刺球等危险物基本都可以复用同一个 KillPlayerOnTouch:只要玩家碰到对应的 Trigger 或 Collision,就调用 PlayerDeathHandler.Die()。真正复杂的部分不在“怎么死”,而在“死完之后怎么稳定回场”。PlayerDeathHandler 除了坠落死亡判定之外,还处理了死亡时清空速度、禁用移动和世界切换、打开死亡界面、回到最近复活点、短暂无敌和碰撞穿透修正等问题。因为本作还有表里世界切换,所以复活时我还强制把玩家切回表世界,并同步修正摄像机与其他系统状态,避免在错误层级复活。

整体来看,这一部分实现的重点并不是让单个机关变得多复杂,而是让这些机关能在同一个平台跳跃控制框架里稳定共存。墙体、平台、风场、地刺各自的逻辑都不复杂,但一旦和移动控制、世界切换、复活系统叠在一起,就很容易出现穿透、误判和复活即死之类的问题。所以我最后采取的策略,是尽量把每种交互物做成单一职责的小组件,再在死亡与复活这一层做统一兜底。这种实现方式非常符合 Jam 项目的节奏:扩展性一般,但落地快、调试快。

对话与剧情交互

对话遇到的最大的问题是策划使用twine写对话,需要使用一个转接逻辑。

这一部分的目标是用尽量低的接入成本,把剧情文本、NPC 交互和对话 UI 串成一条完整链路。相比自己在 Unity Inspector 里一条条手填对白,我最后采用的是 Twine -> Twee 文本 -> 运行时导入 的方式,让剧情内容和程序逻辑尽量解耦。这样做的好处是文本编辑效率更高,分支结构也更直观;代价则是我需要额外写一层导入和转换逻辑,把 Twine 的 passage 和链接关系转成游戏内部可用的数据结构。

具体实现上,TwineTweeImporter 会先解析 Twee 文本中的 passage,再把 [[文本|目标]] 这种链接转换成对话分支,并建立一套稳定的 name -> id 映射,最后交给 DialogueDatabaseDialogueManager 使用。DialogueManager 负责真正的对话流程控制,包括加载数据、按 passage 或 id 启动对话、推进到下一句、处理分支跳转,以及在对话开始和结束时维护全局状态。这样做之后,对话系统的核心逻辑就和具体 UI 分开了,后面如果要换输入方式或者改显示样式,不需要重写整条对话链路。

交互层这一块,我主要通过 NpcDialogueInteractor2D 来完成。玩家进入 NPC 的触发范围后,会显示提示文本,按 F 后按指定的 Twine passage 名启动对话;如果需要,也可以改成进入范围自动触发。这里我额外处理了输入优先级的问题,也就是当菜单已经打开时,不允许再触发新的对话,避免多个系统同时抢输入。这个处理虽然简单,但对实际体验很重要,否则剧情交互很容易和背包、暂停等系统打架。

UI 表现则由 DialogueUI 负责,主要功能包括头像、说话者名字、逐字显示、多页文本和分支选项展示。整体上这套对话系统的实现思路并不复杂,重点在于把“文本数据”“流程控制”“交互触发”“界面显示”分开处理。现在回头看,这种结构非常适合 GGJ 这种短周期项目,因为它不一定最完美,但足够清晰,也给后续继续补剧情和分支留下了空间。

视差背景

这一部分我主要实现的是 ParallaxInfiniteLayer,也就是无限循环的横向视差背景。它的核心思路不是手动摆很多张背景图,而是在运行时根据相机宽度和精灵尺寸自动生成足够多的瓦片,把整条背景先铺满。这样做的好处是接入简单,只要给一张背景图,就可以自动扩展成可循环的远景层。

运行时逻辑主要分成两步。第一步是在 LateUpdate 中根据相机位移和视差系数计算背景层的位置,让背景以比相机更慢的速度移动,从而形成远景效果。第二步是检查当前最左和最右瓦片是否还能覆盖相机视野,如果右边不够,就把最左边那张搬到最右边;如果左边不够,就把最右边那张搬到最左边。这样背景会一直循环,但玩家看到的始终是一条连续、不会露边的背景。

这一部分真正比较关键的问题,是它还要和表里世界切换联动。因为切换世界时玩家和相机会整体瞬移,如果背景还沿用原来的参考坐标,就会出现背景留在原位的问题。为了解决这个问题,我让 ParallaxInfiniteLayer 实现了 IDimensionJumpListener,在收到瞬移位移后同步修正自己的起始位置和相机参考位置。这样视差背景既能无限循环,也能在表里世界切换时保持正确。

工程实现与技术债

这一部分更像是对整个工程实现方式的回看。GGJ 的时间限制决定了我当时很多选择都优先服务于“先跑起来”,而不是“长期最优”。比如角色控制直接接入现成的 Tarodev 2D Controller,表里世界切换直接通过上下两层地形和修改 y 轴位置完成,对话系统则通过自写的 TwineTweeImporter 把策划文本快速接进游戏。这些方案都有效降低了开发门槛,让核心玩法能在很短时间内落地,但也决定了后续维护成本不会太低。

现在回头看,这个项目的第一类技术债是“约定驱动过多”。很多系统为了快速接入,都依赖固定命名、固定路径和固定场景结构才能正常工作,例如 Resources/Audio 下的资源命名、Twine passage 的命名方式、出生点和常驻对象的约定、以及表里世界对应层级的摆放关系。这样做在比赛期间很高效,但一旦项目继续扩大,或者由其他人接手,就很容易因为配置不一致而出现隐性问题。

第二类技术债是“系统之间存在比较强的隐式耦合”。例如死亡与复活不仅要处理玩家状态,还要同时考虑世界切换、摄像机、背景和输入门禁;对话系统也和 UI、输入状态、资源加载路径有直接联系。单看每个脚本都不算复杂,但组合起来之后,很多行为正确与否依赖的是多个系统是否同时满足预期。这样的实现方式非常适合 Jam 节奏,因为它改起来快、能快速闭环,但长期来看会让扩展和重构变得更困难。

第三类技术债则来自我对“自动兜底”的大量使用。为了减少场景配置成本,我让很多管理器和 UI 系统都支持运行时自动创建、自动查找和自动补齐。这确实降低了接入门槛,但也让部分依赖关系变得不够显式,出了问题时往往需要先搞清楚对象到底是场景里原本就有的,还是运行时临时生成的。换句话说,这套工程结构更偏向“为了开发效率牺牲一部分清晰度”。

不过从 GGJ 的目标来看,我并不觉得这些选择是错误的。相反,正是因为接受了这些工程上的折中,我才有足够的时间把核心玩法、关卡流程和对话体验真正做出来。如果后续继续迭代,我会优先处理几类问题:减少对 Resources 和命名约定的依赖、降低系统之间的隐式耦合、把常驻对象和场景对象的职责边界划分得更清楚。也就是说,这个项目的技术债并不是“写坏了”,而是非常典型的 Jam 式技术债:为了快速交付而有意识留下的结构性欠账。

演示视频:

【ELLAMASK】https://www.bilibili.com/video/BV1BH6jBCEdf?vd_source=67687a0367273ec1b681e8be4c09c0da

游戏链接:

https://yyw123456.itch.io/ellasmask


GGJ项目复盘
https://yaoyablog.xyz/2026/03/28/unity/2026GGJ/GGJ项目复盘/
作者
Yaoyawen
发布于
2026年3月28日
许可协议