工程日志
← 返回全部日志
日志标识: flipped-sky-postmortem
[ SYS.LOG // ENTRY-FLIPPED-SKY-POSTMORTEM ]
· 32 分钟阅读

Flipped Sky 复盘:做对的、做砸的,和一台被激光定住还会原地转身的机器人

GMTK 2026 交卷两个月后回头翻代码:重力写成向量是对的,三天搭系统、一天多堆关卡是错的;标题的彩虹色被整数除法吃掉了,激光死锁的真凶是漏登记的五台激光,和“半空中被击中”无关。

先交代一下案发现场

GMTK Game Jam 2026 结束两个月了。这两天重新打开 D:\Godot\flipped-sky,第一件事是看了一眼 git log:

42fe296 1
90ba9b3 关卡脚本类名修正
e1fa641 关卡脚本合并
0fb1131 第一次保存全部

一共四个提交。第一个提交在 7 月 31 日早上 8 点 09 分,这时 jam 已经交卷四天了。提交说明叫“第一次保存全部”,一口气塞进 160 个文件、8463 行。最后一个提交的说明是一个“1”。

比赛那 96 小时靠什么保命?靠 D:\Godot 下面一个 15 MB 的 flipped-sky.7z,时间戳是 7 月 27 日 00:13。

所以这篇复盘的证据主要来自三个地方:这个 7z 里的比赛原版工程、文件系统上的时间戳,以及 itch 上的留言(18 位评分者里有 7 位留了言)。成绩先摆出来,那一届一共 10,544 部作品:

维度排名分数
Creativity#28683.611
Enjoyment#41773.000
Audio#50532.667
Artwork#52982.889
Narrative#90981.611

创意排在前 27% 左右,叙事 1.6 分。叙事为什么这么低,后面有一份文档可以解释。

按文件时间戳还原出来的时间线大致如下(北京时间,场景和脚本按文件创建时间):

时间发生了什么
7/23 08:29建工程
7/23 08:33player.gd 诞生
7/24 14:03countdown_manager.gd 诞生
7/25 中午两份加起来 292 行的“使用指南”写完
7/25 16:40第一个正式关卡 level_1.tscn 诞生
7/25 23:53激光发射器诞生
7/26 11:18level_2.tscn 诞生
7/26 13:18level_3.tscn 诞生
7/26 13:45 – 20:53背景音乐、开始菜单、设置页、死亡测试脚本、胜利画面相继诞生
7/27 00:12开始菜单最后一次修改
7/27 00:13打 7z 包
itch 记录截止前 1 小时 31 分提交

下面按“做对的、做得好的、做得差的、做烂的、好笑的”分拣。每一条尽量附上文件、数字或者玩家的反馈,找不到证据的不写。

做对的

重力是一个向量,不是四个 if

player.gd 在建工程 4 分钟后就出现了。平台跳跃先把角色手感跑起来,这个顺序没问题。更对的是重力的写法:它是一个 Vector2,“上下左右”四种状态没有分开处理。

# player.gd(节选)
var _gravity_dir: Vector2 = Vector2.DOWN

# 跳跃:先去掉重力方向上的速度,再朝重力反方向加冲量
velocity = velocity - velocity.project(_gravity_dir)
velocity += -_gravity_dir * -jump_velocity

# “水平”轴 = 重力方向转 90°
var right_vec = Vector2(_gravity_dir.y, -_gravity_dir.x)

# 让 is_on_floor() 跟着重力走
up_direction = -_gravity_dir

跳跃和左右移动,连同终端速度和落地判定,全部相对 _gravity_dir 来算。这个选择在第三关兑现了:第三关用上了 Vector2(1, -1)、Vector2(1, 1)、Vector2(-1, 1) 三种斜向重力,玩家脚本一行没改。itch 上 python-b5 专门说喜欢第三关意料之外的斜角和来回快速翻转,这是整个游戏被夸得最具体的一处,底子就是这个向量。

倒计时做成数据,关卡里没有专门的代码

每一段倒计时是一个 CountDownPhase 资源,只有 duration 和 direction 两个字段。一个 CountDownManager 挂一串阶段,一个 TriggerZone 负责在玩家走进来时启动它。三个关卡一共配了 32 个阶段,全在检查器里点出来,没有为哪个关卡单独写一行逻辑。

显示那头还有一个好决定:如果下一次翻转是斜向,TimerDisplay 不画箭头,换成一个红色问号。斜向重力本来就很难用一个像素箭头说清楚,问号至少诚实:要出事了,自己小心。

第三关出生点:下一次重力是斜向,指示牌直接给了一个红问号

没有卡着最后一秒交

itch 记录是截止前 1 小时 31 分提交,另外还打了一个 7z 包。在没有版本控制的前提下,这已经是这支队伍能做到的最大程度的谨慎了。这句不是夸奖。

做得好的

激光发射器是全场做工最好的东西

laser_emitter.gd 280 行,是整个项目里花心思最多的脚本:

  • 光束用四层 draw_line 叠出来:最外层 6 倍线宽、12% 不透明度的辉光,一层中间辉光,一层带 sin 闪烁的核心,最里面一条白色高光;
  • RayCast2D 每帧测距,打到墙就截短,末端再画一个白色冲击点;
  • 开火前先闪三下预警。

伤害检测旁边的注释也写得清楚:一开始用的是 body_entered,结果玩家如果在冷却期就站进了光束区域,开火时不会再触发“进入”事件,于是改成每个物理帧轮询 get_overlapping_bodies()。踩了坑,知道为什么踩,还把原因写在旁边,这是对的。

至于这台激光同时也是全场 bug 最密集的东西,后面再说。

死亡重载有防重入

SceneTransition 是一个全局的 CanvasLayer。玩家死亡时先冻结,淡出 0.5 秒,黑屏时重载场景,新场景 _ready 里再淡入。_is_transitioning 挡住了“同一帧碰到两个锯片,重载两次”这种情况。半秒黑场让“死了重来”不那么刺眼,这一块的体验是完整的。

时钟越到最后越急

TimerDisplay 按剩余时间的比例调时钟音效的 pitch_scale,时间越少嘀嗒越快。这个想法是对的,压迫感也确实出来了。只是参数拧过了头,放到“做得差的”里讲。

标题画面

标题画面。云是好看的,标题的颜色另有故事

Switch 的评论里专门夸了标题画面的云。Artwork 2.889 分不算高,但像素风的整体统一感是有的,天空背景和机器人放在一起不打架。

做得差的

这一部分主要是玩家替我们说的。7 条玩家评论里,有 4 条在讲同一件事。

倒计时和重力在互相拖后腿

  • Switch 认为“倒计时”这个主题是硬贴上去的(原话 “tacked on”),建议把重力翻转交给玩家自己控制;
  • python-b5 同意 Switch,并且说游戏里最难的部分是纯粹的平台跳跃,和重力没什么关系;
  • BlackShinobi956 说计时和重力这两个机制在这个实现里是互相别着的;
  • WirelessNine 说玩的时候有一部分时间是在等倒计时走完。

这个“等”可以量化。按玩家参数算(跳跃初速 350,重力 980 × 0.7,下落时再乘 1.5),原地跳一次的滞空时间大约 0.93 秒。第一关有五段倒计时是 6 秒,其中一个区域是 4 段 6 秒无限循环。一段 6 秒够原地跳六次。玩家看懂了指示牌、站好了位置,接下来就只剩下等。

设计大纲里写的“机制 C”是:向无底洞跳跃,半空中重力向右反转,从而“坠落”进右侧的安全通道。这是整个点子里最有意思的一句,也是实际关卡里最少出现的一类设计。第二关放了 15 个锯片和 7 个尖刺,难度大部分来自障碍物本身,这正是 python-b5 说的“难的地方和重力没关系”。

三天搭系统,一天多堆关卡

再看一遍时间线:倒计时系统 7/24 就有了,7/25 中午写完两份指南,第一个正式关卡的场景文件 7/25 下午 16:40 才出现,距离建工程 56 个小时。第二、第三关都是最后一天建的。第三关从创建到打包,中间不到 11 个小时(13:18 到次日 00:13),这 11 个小时里还夹着背景音乐和开始菜单,设置页和胜利画面也是这段时间做的。

关卡是这个游戏里唯一能把机制变好玩的地方,分到的时间却最少。上一条“倒计时在拖后腿”,很大程度上是没有时间在关卡里反复试节奏的结果。

手感:滑,而且不讲情面

Switch 和 python-b5 都说操作偏滑(slippery)。

  • 最高速度 300,松手后的减速度 1000,停下来要 0.3 秒,滑行 45 像素。机器人履带的碰撞框宽 29 像素,也就是松手之后还要再滑出一个半车身;
  • 空中没有单独的减速参数,松手减速和地面一样;
  • 起跳条件是 is_action_just_pressed("move_jump") and is_on_floor(),没有土狼时间(离开平台后的几帧宽限),也没有跳跃缓冲(落地前提前按下的跳跃)。在一个地板每隔几秒就换方向的游戏里,这两个宽限恰恰最该有。

Shrug 还提到,如果翻转时没站稳,重力切换会有点飘。动量叠加是有意为之的(大纲原话:“玩家的动量与新重力方向叠加”),但速度大、减速软,叠出来的轨迹就不太好控制。

判定框偏大

python-b5 建议把障碍物的判定框缩小一些。

  • 锯片贴图 32×32,碰撞圆半径 16.03,比贴图还大一丁点,齿尖擦一下就算死;
  • 尖刺贴图下方 20 像素是一排高低不齐的尖刺,碰撞体是一个 32×17 的矩形,尖刺之间的空隙也算刺。

平台跳跃的惯例是障碍物的判定比画面小一圈。

倒计时牌挂在世界里,不在屏幕上

WirelessNine 的建议是倒计时应该一直显示在屏幕上。TimerDisplay 是摆在关卡里的一块牌子,人走远了就看不见。第三关激光区是个典型:那时生效的是 D 区的倒计时,它的牌子在 (137, -524),激光区从 x = 700 附近开始往右,而相机在 1.85 倍缩放下,屏幕半宽只有 346 像素。在最需要看倒计时的地方,恰好看不见它,这时唯一的信息来源是时钟声。

时钟声:两根滑杆都拉到了头

## 时钟音效在倒计时开始时(剩余时间最多)的最低播放速度倍率
## 值为 1.0 表示正常速度,可在检查器中调整
@export_range(0.5, 3.0, 0.05) var pitch_min: float = 3.0

## 时钟音效在倒计时归零瞬间的最高播放速度倍率
## 值越大表示最后时刻的时钟声越急促(推荐 2.0~3.0)
@export_range(1.0, 5.0, 0.05) var pitch_max: float = 5

注释说 1.0 是正常速度,最大值推荐 2 到 3。实际默认值是最低 3 倍速、最高 5 倍速,两个都顶在各自滑杆的最右端,三个关卡没有一个去改它。clock.wav 本来是 3.66 秒一圈的嘀嗒声,在游戏里最慢也是 1.2 秒一圈,而且音调跟着一起升高。Audio 这一项 2.667 分,Switch 还补了一句,背景音乐拿来一直循环有点太闹。

Narrative 1.611 分的来历

7 月 25 日下午的设计大纲里,有一节叫“最终目标:终端(The Terminal)”:

终局挑战:终端内部包含整个游戏最密集、最高难度的重力反转关卡。通过该终极测试,即宣告游戏胜利。

7 月 27 日下午 14:13,jam 已经结束,这份大纲被改过一次:终端那一节、开发阶段规划那一节整段删掉,文末加了一行:

最后一共做了3个关卡

游戏里实际的“终端”,是第三关尽头的一扇传送门,门后是一行 “Victory!” 和一句 “Thank you for playing my game.”。叙事给到 1.6 分,评分者们已经手下留情了。

做烂的

整场 jam 没有版本控制

开头已经说过:jam 期间零提交,第一次 git commit 在交卷四天后。直接后果就是,今天想回答“某个 bug 是什么时候引入的”,只能去翻文件时间戳和一个 7z 包。

更讽刺的是下面这个 bug 的修复:level_3.tscn 里补上漏掉的激光,这一改动到今天还躺在工作区里,没有提交。

激光死锁:真凶不是“半空中”

这是 Flipped Sky 最有名的 bug。玩家 Shrug 的描述是:在第三关半空中被激光打中,关卡没有重开,除了关掉游戏什么都做不了。我当时回复说是截止前刚发现、来不及修。项目页里也照着写成了“如果角色正好在半空中被激光命中”。

从代码看,“半空中”和这个 bug 没有关系。第三关一共放了 6 台激光,关卡脚本的 hostile_list 里只登记了第一台:

# level_3.tscn(比赛版)
hostile_list = [saw, saw2, saw3, saw4, saw5, LaserEmitter,
                DeathCrash, DeathCrash2, ... DeathCrash6]
# LaserEmitter2 ~ LaserEmitter6:不在名单上

死亡流程被拆成了两半:

  1. 障碍物直接调用 body.die(),冻结玩家(速度清零,停止物理更新);
  2. 障碍物再发出自己的 player_died 信号,关卡脚本监听到之后,才去淡出和重载。

没登记的激光只完成了第一步。于是机器人被冻在光束里,激光照常一开一关,倒计时照常走。最有喜感的是倒计时归零的那一刻,冻住的机器人还会乖乖原地转个身,因为重力翻转走的是信号回调,冻结只拦住了 _physics_process。玩家能做的只有关网页,而游戏没有重开键、没有暂停菜单、没有选关,关掉网页就意味着从第一关重新打。

下面这段是用比赛版工程录的:把机器人放进 2 号激光的光束里,然后什么也不做。

同样的操作换成登记过的 1 号激光,0.5 秒后正常黑屏重开。所以决定结果的是撞上了哪一台激光,在不在半空中都一样。从坐标看,2 到 6 号是复制 1 号再转了方向得到的,1 号和 3 号的位置完全相同,一个朝左下,一个朝右上。复制一个节点,不会顺便把它登记进另一个节点的数组里。

最冤的地方在 player.gd 第 17 行:

## 当玩家死亡时触发(供死亡粒子效果等系统使用)
signal player_died

玩家自己就有 player_died 信号,每次 die() 都会发。关卡脚本手里本来就握着 player 的引用,只要一行:

player.player_died.connect(_on_player_died)

第三关那 17 个障碍物的名单就全都不需要了。为了“解耦”,我们让每个障碍物各发一个信号,再让关卡手动收集十几个信号源;真正该连的那一个信号,从头到尾没人听。

设置页:写完了,编译不过,按钮删了,引用还在

settings.gd 140 行,音量滑杆和静音按钮都有,还带 JSON 存档。问题一:第 29 行 JSON.new().parse(...).result 是 Godot 3 的写法。Godot 4 里 parse() 返回的是错误码,这一行直接解析失败:

SCRIPT ERROR: Parse Error: Cannot get property from enum value.
   at: GDScript::reload (res://scenes/ui/settings.gd:29)

问题二:它调用的 GlobalAudio.set_volume() 和 get_volume(),在 global_audio.gd 里根本不存在。

结局是开始菜单里没有设置按钮,但 start.gd 第 4 行还留着对它的引用:

@onready var settings_button: Button = $VBoxContainer/SettingsButton

所以每次进标题画面,控制台都会报一次 Node not found: "VBoxContainer/SettingsButton"。开始菜单场景的最后修改时间是 7 月 27 日 00:12,7z 包是 00:13。

顺带一提,静音按钮两种状态的文字分别是“🔊 静音”和“🔊 取消静音”,用的是同一个喇叭。

同一份关卡脚本抄了四遍

比赛版里 level_1.gd、level_2.gd、level_3.gd、test_level.gd 一共 776 行,彼此只差 class_name、一行注释和一个编辑器自动生成的空函数。7 月 31 日它们被合并成一个 level.gd,但合并后的报错信息里还写着 [TestLevel],这是它们共同的祖先留下的胎记。

注释比代码勤快,但不总说真话

比赛版的 GDScript 一共 2702 行:以 # 开头的注释 1024 行,空行 540 行,真正的代码 1138 行。注释多可以接受,可不少注释描述的是一个已经不存在的项目:

  • player.gd 写着“由关卡脚本将 GravityManager.gravity_changed 信号连接到此方法”,项目里从来没有 GravityManager;
  • TimerDisplay.gd 说自己的接口“与 CountDownDisplay 一致”,countdown_display.gd 早就删了,集成指南的文件清单里还列着它;
  • 指南里写关卡脚本在 scenes/level/test_level.gd,实际在 scenes/level/Test/ 下面,两份指南的标题还都带着“(最新版)”;
  • walk_dust_particles.gd 的注释在讲 GPUParticles2D,脚本本身是 extends CPUParticles2D。

锯片、尖刺和边界死亡区三个脚本的开头都有一句“本节点不感知关卡或玩家的任何具体逻辑”,往下翻几十行就是 body.die()。

那两份加起来 292 行的使用指南,一份的副标题叫“关卡设计师使用指南”,服务对象是一支两个人的队伍,写于第三天中午,那时一个正式关卡都还没有。

调试残留

  • 全项目 36 个 print(),其中 global_audio.gd 每次启动都会打印 7 行“=== GlobalAudio 初始化 ===”之类的日志;
  • 比赛版 player.gd 的 debug_mode 默认是 true,它接的四个调试动作在输入映射里一个键都没绑。一个开着的调试开关,连着四根空线。

好笑的

这一节的共同点,是代码自己就把笑话讲完了,不需要加工。

彩虹标题只有紫色

标题画面和胜利画面各有一段三十多行的手写 HSV 转 RGB,注释说是“Web端兼容的简单实现”(Godot 自带的 Color.from_hsv() 在 Web 上一样能用)。

if hue < 1 / 6:
    r = c; g = x; b = 0
elif hue < 2 / 6:
    r = x; g = c; b = 0
# ……中间省略……
elif hue < 5 / 6:
    r = x; g = 0; b = c
else:
    r = c; g = 0; b = x

GDScript 里 1 / 6 是整数除法,结果是 0,后面的 2 / 6 到 5 / 6 也全是 0。于是前五个分支都在判断 hue < 0,永远不成立,每一帧都走 else:红色拉满,绿色固定 0.2,只有蓝色在来回变。这个颜色再乘上标签本身的字体颜色,一个紫色 (0.55, 0, 1),绿色通道彻底归零。设计中的彩虹标题,实际效果是在酒红和紫色之间呼吸。这段代码被原样复制到了胜利画面,bug 也一起复制了过去。

真正好笑的部分在另一个文件里。TimerDisplay.gd 第 150 行:

@warning_ignore("integer_division") # 消除91行中的警告(代码能跑就不要动)
var tens: int = (total_int / 10) % 10

Godot 遇到整数除法是会报警告的。倒计时这里的整数除法是故意的,警告被手动压掉,注释里的“91 行”早就对不上了。而在标题那里,同一个警告说的恰好是真 bug。

被灭口的第二个警告

脚下的扬尘粒子有一个函数,负责按重力方向转动发射角度:

func _apply_gravity_to_particles(_gravity: Vector2) -> void:
    var emit_angle_rad = gravity.angle() + PI
    rotation = emit_angle_rad - PI / 2.0

参数名前面加了下划线,意思是告诉 Godot“这个参数我不用,别报未使用警告”。函数体里的 gravity 没有下划线,它指向的是 CPUParticles2D 自己的 gravity 属性,也就是粒子的重力 (0, 98)。所以不管玩家的重力朝哪边,这个函数算出来的旋转永远是 π。本该从履带往上扬起的尘土,被整个翻了过来,往地板里喷。

机器人在第三关出生平台上跑动(相机临时放大到 6 倍)。白色的尘土颗粒钻进了钢梁里

同一个脚本还按重力方向设置粒子在玩家本地坐标里的位置,可玩家节点本身也会跟着重力旋转,等于转了两遍。重力一旦不朝下,尘土就改从机器人头顶那一侧冒出来。

两个警告,一个被 @warning_ignore 压掉,一个被下划线压掉,压掉的都是真话。

预警本身就能杀人

激光切换状态是这么写的:

func _set_state(new_state: LaserState) -> void:
    _state = new_state
    # ……
    match _state:
        LaserState.FIRING:
            _enter_firing()   # 先闪三下预警,再完全显现

伤害检测只看 _state 是不是 FIRING。状态一切过去,预警动画才开始播,于是三次预警闪烁的 0.36 秒,加上显现的 0.1 秒,全程都带伤害。反过来,关闭时状态先切到 COOLING,光束还要再淡出 0.18 秒,这段时间看得见,但碰不死人。一台激光“看起来危险”和“实际危险”之间,错开了将近半秒。

还有一个小细节:登记过的激光杀死玩家以后,淡出的那 0.5 秒里玩家还站在光束中,_physics_process 每个物理帧都会再调用一次 body.die()。按默认每秒 60 个物理帧算,每次死亡最多要被判死 30 次左右。好在人已经冻住了,死几次都一样。

最终版死亡测试

项目根目录下有一个 test_death_system_final.gd,文件名里带着 final。

  • 开头注释写着“按 T 键测试”,代码监听的是 ui_up(方向键上),旁边还标注了一句 # T 键;
  • 用 get_first_node_in_group("player") 找玩家,玩家不在任何组里;
  • 它要检查的 DeathParticles 节点,在 player.tscn 里不存在(所以 die() 里那十来行“随机黄色或蓝色死亡粒子”也从来没执行过);
  • 第 36 行写的是 print(f"死亡粒子节点存在: {death_particles}")。GDScript 没有 f-string,脚本直接解析失败:
SCRIPT ERROR: Parse Error: Expected closing ")" after call arguments.
   at: GDScript::reload (res://test_death_system_final.gd:36)

这个文件 7 月 26 日 20:41 创建,20:41 最后一次修改。它确实是 final 的,这一点没人能否认。

填 0,被纠正成 1

CountDownManager 对阶段时长有一段防御代码:

if duration <= 0.0:
    push_warning("[CountDownManager] 阶段 %d 的 duration 无效 (%f),已修正为 1.0 秒。" % [_current_index, duration])
    duration = 1.0

第一关还真有一个阶段填了 duration = 0.0,看意思是想让玩家一走进区域就立刻翻转。防御代码尽职地把它改成了 1 秒,并在控制台留了一条警告。到了第三关,最后一个阶段填的是 0.1,看起来是吸取了教训。

离背景音乐断气还差 700 像素

全局背景音乐挂在自动加载的 GlobalAudio 上,用的播放器是 AudioStreamPlayer2D,也就是带位置、会随距离衰减的 2D 音源,位置就在世界原点。为了让它听起来“不衰减”,衰减系数被调成了 1.3751867e-05,最大听音距离从默认的 2000 改成了 4096。

三个关卡里,玩家能走到的最远处在第三关最右边,离原点 3300 多像素,离 4096 的上限只剩 700 像素左右。关卡再往外多做一截,走到那里背景音乐就会直接消失。正确做法是换成不带位置的 AudioStreamPlayer。这里没出事,只是因为关卡恰好没做大。

名字

  • 传送门的目录叫 Potals;
  • 标题画面上那个显示 “Flipped Sky” 的标签,节点名叫 FilippedSky;
  • 三个关卡的传送门节点都叫 Potals_blue,包括通往第二关、第三关的那两扇。

下次 jam 的清单

  1. 第 0 分钟 git init,每个能跑起来的节点提交一次。7z 不是版本控制。
  2. 死亡只走一条路:玩家发 player_died,关卡只监听玩家。障碍物用组来识别,不维护手工名单。
  3. 第一天就做 R 键重开和 Esc 菜单。任何卡死都不该让玩家从第一关重来。
  4. 第二天结束前,要有一个能从头玩到尾的粗糙关卡。系统跟着关卡的需要长。
  5. 土狼时间、跳跃缓冲、障碍判定缩一圈,这三件是平台跳跃的基本礼貌。
  6. 核心信息放在 HUD 上。世界里的倒计时牌可以留着当装饰,但不能是唯一的信息源。
  7. 警告当 bug 看。每一个 @warning_ignore、每一个加了下划线的参数,都要说得出理由。
  8. 注释写“为什么”,改代码时顺手改注释。写不准的注释,不如不写。

还有一个没想明白的问题,是 python-b5 留下的:倒计时拖慢了节奏,可“玩家控制不了翻转”又恰恰是这个设计的核心,两者之间有没有平衡点?这个没有标准答案,只能下次在第一天就拿灰盒关卡去试。

最后

回头看,Flipped Sky 最值钱的是 Creativity 那一栏的 3.611 分和第三关的斜向重力,最贵的是多出来的五台激光。前者来自一个写对了的 Vector2,后者来自一个漏了五项的数组。

标题还是紫色的。工作区里补激光的那行修改,也还在等一次正经的提交。

如果这篇工程日志对你有启发:

技术讨论与评论

0 条讨论
登录后即可发表评论、探讨架构与技术细节
正在拉取讨论记录...