关于Phigros判定
Terrysquid
编辑于 2026年08月27日 01:30
游戏里的冷知识

#游戏#​ #音乐游戏#​ #Phigros#​ #判定#​

说在前面

强烈建议用电脑观看,可以在左侧目录直接跳转

白字干货,黄字易懂绿字结论

建议新手直接跳转到专栏末尾看实际图解 有一定了解可以只看黄字+绿字 喜欢看干货的大佬们可以直接看全文[脱单doge]

有用的话点个赞吧()

持续施工中(最后更新于2026/8/24 1:00)

更新了late Bad与帧内延迟,并给出了真实图解(翻到最下面) 更新了特殊情况·垂直判定 更新了特殊情况·蹭键

时间范围

Phigros判定范围分为“时间范围”和“空间范围”

其中,“时间范围”是一个阈值,其具体使用逻辑由后续“判定流程”决定;但是,可以先默认“时间范围为t”代表时间范围可取(-t%2C%20t),单位s,开区间

蓝键 Tap

标准判定(宽判)

很多人错以为宽判蓝键时间范围是80/160/180ms,实际如下:

Perfect时间范围:P%20%3D%200.08(80ms) Good时间范围:G%20%3D%200.18(180ms) Bad时间范围:B%20%3D%200.22(220ms)

代码块
Python
自动换行
复制代码
perfectTimeRange = 0.08
goodTimeRange = 0.18
badTimeRange = 0.22
复制成功

课题模式(严判)

很多人错以为严判蓝键时间范围是40/75/140ms,实际如下:

游戏会计算最近最多10帧的平均帧时间%5Coverline%7Bt%7D,再取其一半作为补偿

Perfect时间范围:P%20%3D%200.04%20%2B%200.5%5Coverline%7Bt%7D Good时间范围:G%20%3D%200.09%20%2B%200.5%5Coverline%7Bt%7D Bad时间范围:B%20%3D%200.14%20%2B%200.5%5Coverline%7Bt%7D

如果帧率fps稳定,%5Coverline%7Bt%7D可以近似为%5Cfrac%7B1%7D%7Bfps%7D

在稳定60帧时:

Perfect时间范围:P%20%5Capprox%200.0483(48.3ms) Good时间范围:G%20%5Capprox%200.0983(98.3ms) Bad时间范围:B%20%5Capprox%200.1483(148.3ms)

在稳定120帧时:

Perfect时间范围:P%20%5Capprox%200.0442(44.2ms) Good时间范围:G%20%5Capprox%200.0942(94.2ms)可见严判FC比宽判AP简单 Bad时间范围:B%20%5Capprox%200.1442(144.2ms)

代码块
Python
自动换行
复制代码
# t 是这一帧的时间
# T 用来保存最近最多10帧的时间
# count 初始为0

T[count % 10] = t
count += 1
s = 0
n = min(count, 10)
for i in range(n):
    s += T[i]
t_pad = s / n * 0.5

perfectTimeRange = 0.04 + t_pad
goodTimeRange = 0.09 + t_pad
badTimeRange = 0.14 + t_pad
复制成功

长条 Hold

长条头部与蓝键的时间范围一致

长条不会评为Bad*,详情见后文“评分逻辑·长条”

*长条仍可以在early Bad时间内被提前标记,继续按住会被评为Good

黄键 Drag

黄键的宽判与严判时间范围一致

Drag时间范围*:D%20%3D%200.1(100ms)

*黄键的时间范围可以取到100ms(闭区间)

红键 Flick

红键的时间范围是蓝键Perfect的1.75倍,即F%20%3D%201.75%20%5Ctimes%20P

标准判定(宽判)

Flick时间范围:F%20%3D%200.14(140ms)

课题模式(严判)

在稳定60帧时:

Flick时间范围:F%20%5Capprox%200.0846(84.6ms)

在稳定120帧时:

Flick时间范围:F%20%5Capprox%200.0773(77.3ms)

判定流程

Phigros的判定流程分三步:触发、匹配、评分

  1. 触发:触发某一手势,如点击或划动屏幕

  2. 匹配:根据手势的位置,选中并标记某一note

  3. 评分:根据时间差给出Perfect/Good/Bad/Miss

每个note的判定范围由其判定流程决定

判定流程,很直观吧()

点击触发

手指点击屏幕(刚按下)即为有效点击手势

按住触发

手指按在屏幕上即为有效按住手势

划动触发

finger.isNewFlick

不同于简单的“点击触发”与“按住触发”,“划动触发”有一套更复杂的逻辑

“划动触发”决定了怎样的划动手势算作有效,每一个有效的划动手势最多可以判定一个红键

其基于手指两帧内的位移,如下:

d_0d_1表示上一帧和这一帧的位移向量 (注:位移向量的单位是world unit,1 world unit = 屏幕高度 / 10) t表示这一帧的时间%5Capprox%20%5Cfrac%7B1%7D%7Bfps%7D dpi表示屏幕dpi u%20%3D%200.06%20%5Ccdot%20%5Cfrac%7Bdpi%7D%7B380%7D表示速度阈值

游戏会计算d_1d_0方向上的投影(若%5Cvert%20d_0%20%5Cvert%20%20%3E%200.1),也就是

%5Cfrac%7Bd_0%20%5Ccdot%20d_1%7D%7B%5Cvert%20d_0%20%5Cvert%20%7D%20%3D%20%5Cvert%20d_1%20%5Cvert%20%20%5Ccos%7B%5Ctheta%7D

并通过它除以时间,计算相对速度

v_%7Brel%7D%20%3D%20%5Cfrac%7Bd_0%20%5Ccdot%20d_1%7D%7B%5Cvert%20d_0%20%5Cvert%20%7D%20%5Ccdot%20%5Cfrac%7B1%7D%7B60%20%5Ccdot%20t%7D

如果相对速度很大,游戏会认为这是同一次划动,不算作新的划动手势 如果相对速度足够小(v_%7Brel%7D%20%3C%20u),或者stopped为true:

  • 计算这一帧的速度

v%20%3D%20%5Cvert%20d_1%20%5Cvert%20%5Ccdot%20%5Cfrac%7B1%7D%7B60%20%5Ccdot%20t%7D

  • 如果v%20%5Cgeq%205u

    • 触发新的划动手势(标记isNewFlick为true),标记stopped为false

  • 否则:

    • 不触发新的划动手势(标记isNewFlick为false),标记stopped为true

(每个手指都有一个独立的isNewFlick和stopped值,初始值分别为false和true)

所以说,触发新的划动手势大致有两种方法:

  1. 两帧内方向变化足够大,使得夹角%5Ctheta很大,v_%7Brel%7D足够小,而v足够快

  2. 手指先被游戏标记为stopped,再加速使v足够快

很精妙的是,反复←→划动几乎一定能算作多个划动手势:划得很快可以触发条件1,划得较慢可以触发条件2

画圈圈则不一定,若圆太大(足够平滑),两帧位移向量之间角度%5Ctheta太小,使得相对速度仍然较大(v_%7Brel%7D%20%5Cgeq%20u),游戏会认为这还是同一次划动。这会导致第一个划动手势被触发后,stopped一直为false,后续不会再触发新的划动手势,也就不会再判定新的红键了

图解

想自己试试的可以访问 https://www.desmos.com/calculator/jjeaucerat?lang=zh-CN

简单起见,令上一帧d_0指向正下方,且满足%5Cvert%20d_0%20%5Cvert%20%20%3E%200.1

蓝色区域:v_%7Brel%7D%20%3C%20u 绿色区域:v_%7Brel%7D%20%3C%20uv%20%5Cgeq%205u

(我的板子)可触发划动手势的位移向量(单位:像素)

这一帧的位移向量如果落在绿色区域: 可以触发一个新的划动手势

这一帧的位移向量如果落在蓝色区域,但在绿色区域之外: 不会立刻触发新的划动手势,但是会标记stopped为true 下一帧只要向任何方向划得足够快(v%20%5Cgeq%205u)就能触发一个新的划动手势

特殊情况·红键Phira化

低dpi/高帧率下,一次不改变方向的划动可以判到多个红键

当dpi很低 / fps很高时(红圈半径为0.1 world unit)

当dpi很低/帧率很高时(%5Cfrac%7Bdpi%7D%7Bfps%7D%20%3C%20%5Cfrac%7B19%7D%7B9%7D*),0.1 world unit可能大于绿色区域的边缘

如果上一帧v%20%5Cgeq%205u,触发了一个划动手势,但是%5Cvert%20d_0%20%5Cvert%20%20%5Cleq%200.1,则这一帧会跳过投影计算,直接令v_%7Brel%7D%20%3D%200 因为0%20%3C%20u一定成立,v_%7Brel%7D%20%3C%20u自动成立,变为下图

这一帧的速度只要继续满足v%20%5Cgeq%205u,位移向量可以依旧指向正下方,即使落在图中约 (0, -15) 处的绿色区域,也能触发一个新的划动手势

理论上,在这种低dpi/高帧率的特殊情况下,后续每一帧如果保持这个速度(位移向量不变),即使不改变方向,也能一帧触发一个新的划动手势,即可判到一连串红键

垂直向上划判到一连串红键(200dpi,120fps)

*推导:

%5Cbegin%7Balign%7D%0A5u%20%20%26%3C%200.1%20%5Ccdot%20%5Cfrac%7B1%7D%7B60%20%5Ccdot%20t%7D%20%5C%5C%0A5u%20%5Ccdot%2060t%20%26%3C%200.1%20%5C%5C%0A5%20%5Ccdot%200.06%20%5Ccdot%20%5Cfrac%7Bdpi%7D%7B380%7D%20%5Ccdot%2060%20%5Ccdot%20%5Cfrac%7B1%7D%7Bfps%7D%20%26%3C%200.1%20%5C%5C%0A%5Cfrac%7Bdpi%7D%7Bfps%7D%20%26%3C%200.1%20%5Ccdot%20%5Cfrac%7B380%7D%7B5%20%5Ccdot%200.06%20%5Ccdot%2060%7D%20%5C%5C%0A%5Cfrac%7Bdpi%7D%7Bfps%7D%20%26%3C%20%5Cfrac%7B19%7D%7B9%7D%0A%5Cend%7Balign%7D

伪代码

代码块
Python
自动换行
复制代码
# d0, d1 是上一帧和这一帧的位移向量,单位是 world unit
# t 是这一帧的时间
# dpi 是屏幕 dpi

u = 0.06 / 380 * dpi
for finger in fingers:
    v_rel = 0
    if |d0| > 0.1:
        v_rel = d0 · d1 / |d0|
    v_rel = v_rel / 60 / t
    if v_rel < u or finger.stopped:
        v = |d1| / 60 / t
        if v >= u * 5:
            finger.isNewFlick = True
            finger.stopped = False
        else:
            finger.isNewFlick = False
            finger.stopped = True
复制成功

点击匹配

CheckNote()

时间范围GB的值来自前文“时间范围”

对于每个点击手势,游戏会在已经按时间排序的note列表中截取一段区间

%5CDelta%20t表示一个note与现在的时间差

%5Cbegin%7Bcases%7D%0A%5CDelta%20t%20%3E%200%20%26%20%5Ctext%7B%E6%89%93%E6%97%A9%E4%BA%86%EF%BC%8Cnote%E5%AE%9E%E9%99%85%E6%9B%B4%E6%99%9A%7D%20%5C%5C%20%0A%5CDelta%20t%20%3C%200%20%26%20%5Ctext%7B%E6%89%93%E6%99%9A%E4%BA%86%EF%BC%8Cnote%E5%AE%9E%E9%99%85%E6%9B%B4%E6%97%A9%7D%0A%5Cend%7Bcases%7D

区间的末尾end:最晚的%5CDelta%20t%20%3C%20B的note

区间的开头start:在end及其之前,最早的%5CDelta%20t%20%3E%20-G的note (若不存在,则start=end)(“late Bad”的主要原因

然后,游戏按时间顺序遍历区间内的所有note,并在时间和位置上都接近的note中匹配至多一个note,标记isJudged

用best表示目前找到的最佳note %5Cvert%20%5CDelta%20t_%7Bbest%7D%20%5Cvert%20表示它与现在的绝对时间差

对于遍历中每一个note:

  • 如果note已经被标记isJudged,则跳过

  • 计算note与现在的时间差%5CDelta%20t

  • 计算手指相对于note所在判定线的坐标%5Cleft(x%2Cy%5Cright)(单位:world unit)

  • 计算note与手指在x方向的绝对距离差%5Cvert%20%5CDelta%20x%20%5Cvert%20

  • 如果%5Cvert%20%5CDelta%20x%20%5Cvert%20%5Cgeq%201.9,则跳过

  • 如果%5CDelta%20t%20%5Cgeq%20%5Cvert%20%5CDelta%20t_%7Bbest%7D%20%5Cvert%20%2B%200.01,则跳过

  • 对于点得不准的note,降低Bad范围,令

B'%20%3D%20%5Cbegin%7Bcases%7D%0AB%20%26%20%5Cvert%20%5CDelta%20x%20%5Cvert%20%5Cleq%200.9%20%5C%5C%0AB%20-%20%5Cleft(%5Cvert%20%5CDelta%20x%20%5Cvert%20%20-%200.9%5Cright)%20%5Ccdot%20P%20%5Ccdot%200.5%20%26%20%5Cvert%20%5CDelta%20x%20%5Cvert%20%3E%200.9%0A%5Cend%7Bcases%7D

  • 如果%5CDelta%20t%20%3E%20B',则跳过

  • 如果目前还没有best,或best是黄键或红键,则用当前note替换best

  • 如果目前的best和note都是蓝键或长条,且二者时间差不超过0.01s:

    • 计算note与手指的加权曼哈顿距离(y轴方向有%5Cfrac%7B1%7D%7B2.2%7D的权重) m%20%3D%20%5Cvert%20%5CDelta%20x%20%5Cvert%20%2B%20%5Cleft%5Cvert%20%5Cfrac%7By%7D%7B2.2%7D%20%5Cright%5Cvert%20

    • 对于best进行相同的计算,并与m进行比较

    • 如果当前note的m更小,则用当前note替换best

遍历结束后,如果best存在且是蓝键/长条/黄键,则标记它isJudged为true

(游戏在谱面开始前,会先把所有判定线上的note收集起来,并按真实时间顺序排序;“点击匹配”和“红键匹配”都会用到这个列表) (best和%5Cvert%20%5CDelta%20t_%7Bbest%7D%20%5Cvert%20初始值分别为空和10000)

图解

“点击匹配”的空间范围是宽度为±1.9world unit(开区间)的带状区域,也就是“垂直判定”1 world unit = 屏幕高度 / 10

蓝键垂直判定区域

时间范围上最早到B,最晚到-G*(开区间) 所以理论上只存在early Bad,不存在late Bad*

*“late Bad”原因详情见后文“判定范围·特殊情况·late Bad”

对于点得不准的note,Bad范围会缩减,如下图 所以点击垂直判定的边缘可以降低Bad的可能性

判定范围(横轴为x距离,纵轴为时间差Δt;红线为B,蓝线为-G)

按顺序遍历note时:

  • 跳过已被标记isJudged的note

  • 如果当前note还在early侧,并且它离当前时间比best还远至少0.01s,则不会替换best(“黄键保护”的原因

  • 如果没有达成以上条件,需要根据以下替换规则决定当前note是否替换best

    • 黄键/红键best 可以替换为 蓝键/长条/黄键/红键note

    • 蓝键/长条best 不能替换为 黄键/红键note

    • 蓝键/长条best 在特定情况下可以替换为 蓝键/长条note

  • 也就是说,时间差小于0.01s的两个键匹配优先级:蓝键/长条 > 黄键/红键

替换规则(左:best;右:当前note)
  • 特定情况:当前note与best时间差不超过0.01s,且当前note与手指的加权曼哈顿距离(%5Cvert%20%5CDelta%20x%20%5Cvert%20%2B%20%5Cleft%5Cvert%20%5Cfrac%7By%7D%7B2.2%7D%20%5Cright%5Cvert%20)更小

  • 也就是说,时间差大于0.01s的两个蓝键/长条,时间更早的优先级更高

  • 时间差小于0.01s的两个蓝键/长条,距离更短的优先级更高

  • 时间差正好等于0.01s的两个蓝键/长条,在过线之前时间更早的优先级更高(”保护“),过线之后距离更短的优先级更高

实线:判定线;虚线区域:空间范围

蓝色区域表示下面蓝键的加权距离更小,白色区域表示右面蓝键的加权距离更小;因此点击相应区域时,会优先匹配相应的蓝键

当然,双指同时点击交叉部分,两个肯定都能被匹配

想自己试试的可以访问 https://www.desmos.com/calculator/igniatrsg2?lang=zh-CN(并开启反色)

附加一个类似Voronoi Diagram的动图方便理解:

遍历结束后,最终选中的best(非红键)会被标记isJudged为true

特殊情况·垂直判定

对于点击,“垂直判定”区域的宽正比于屏幕高度(注意是屏幕高度,不是屏幕宽度),而note大小却正比于屏幕宽度

所以对于不同设备,“垂直判定”的宽度体感不同:

  • 手机:note大、“垂直判定”窄

  • 平板:note小、“垂直判定”宽

手机:note大、“垂直判定”窄(1920x1080px)
平板:note小、“垂直判定”宽(2420x1668px)

这也就是为什么白复生AT和RadianceAT的四长条段,手机更容易断

手机(1920x1080px)
平板(2420x1668px)

特殊情况·黄键保护

当黄键提前蓝键至少0.01s,就有可能触发“黄键保护”,导致点击会被算在黄键,而非蓝键上;如果时间差不到0.01s,会直接点到蓝键

“黄键保护”触发条件:

  1. 蓝键在early侧

  2. 黄键比蓝键更靠近当前时间至少0.01s

“黄键保护”后,这个黄键就会被标记isJudged;第二次点击时,会因为存在isJudged标记被跳过,导致“黄键保护”只能触发一次

第一次点击是否能直接点到蓝键

如果黄键提前蓝键50ms,需要等到黄键过了判定线30ms才能直接点到蓝键;因为30ms-20ms正好是0.01s的边界情况,过了边界情况就没有“保护”了

特殊情况·红键保护

“红键保护”与“黄键保护”的触发条件一致

因为红键不会被“点击匹配”标记isJudged,“红键保护”可以触发若干次,且点击不能真正判定到红键,导致红键+蓝键的键型会很难打

特殊情况·糊蓝键

在宽判下,因为Bad区间范围在垂直判定的最边缘(±1.9world unit)刚好能缩减为0(即B'%20%3D%20G),所以可以在靠近边缘位置快速连点,而不会Bad

在课题模式下,Bad区间范围则不会缩减为0,所以同样的连点不能避免Bad

连点位置(红圈处)(使用连点器)

特殊情况·加权曼哈顿距离

对于两个几乎同时的蓝键/长条,空间范围由加权曼哈顿距离决定;所以表面上更靠近一个note的点击,也可能会匹配另一个note

点击红圈位置会匹配下面的蓝键

附加一个类似Voronoi Diagram的动图方便理解:

特殊情况·蹭键

“蹭键”指本来想点一个note却点到了另一个note的情况

即使是在同一帧内的两个点击,体感上几乎没区别,也存在先后处理顺序

--这个顺序是很多蹭键的关键原因,后面要用到--

蓝键双押蹭键

操作:在红圈同时点击

预期结果:判定三个蓝键

实际结果:有概率Miss下方的蓝键

右上方的点击不影响蹭键,可以忽略,不参与分类讨论

若处理顺序为:右侧点击 -> 左侧点击(不会蹭键):

  • 右侧的点击匹配到右侧的蓝键

  • 左侧的点击匹配到下方的蓝键

若处理顺序为:左侧点击 -> 右侧点击(会蹭键):

  • 因为左侧的点击到右侧的蓝键的加权曼哈顿距离更短,匹配到右侧的蓝键

  • 因为右侧的蓝键已经被匹配,右侧的点击匹配到空

左侧的点击到右侧的蓝键的距离更短

蓝红双押蹭键

操作:在红圈同时点击并划动

预期结果:判定蓝键与红键

实际结果:有概率蹭到左上方的蓝键(Bad)

若处理顺序为:左侧点击 -> 右侧点击(不会蹭键):

  • 左侧的点击匹配到蓝键

  • 右侧的点击匹配到空,划动时再匹配到红键

若处理顺序为:右侧点击 -> 左侧点击(会蹭键):

  • 对于同时的蓝键和红键,“点击匹配”会优先匹配蓝键

  • 右侧的点击在蓝键的“垂直判定”内,匹配到蓝键

  • 左侧的点击因为下方蓝键已经被匹配,所以匹配到它左上方的蓝键

右侧的点击在蓝键的“垂直判定”内

需要注意的是,如果右侧的红键换成蓝键,相同的操作则不会蹭键,因为加权曼哈顿距离一定会让两个点击与两个蓝键一一对应

蓝键楼梯蹭键

操作:在红圈同时点击

预期结果:判定两个蓝键

实际结果:有概率右侧的蓝键不被判定,并蹭到第一个红圈上方的蓝键(Bad)

若处理顺序为:左侧点击 -> 右侧点击(不会蹭键):

  • 左侧的点击匹配到左侧的蓝键

  • 右侧的点击匹配到右侧的蓝键

若处理顺序为:右侧点击 -> 左侧点击(会蹭键):

  • 时间差大于0.01s的两个蓝键,时间更早的优先级更高

  • 右侧的点击在左侧蓝键的“垂直判定”内,匹配到左侧蓝键

  • 左侧的点击因为左侧蓝键已经被匹配,所以匹配到上方的蓝键

右侧的点击在左侧蓝键的“垂直判定”内

伪代码

代码块
Python
自动换行
复制代码
notes = []
for judgeLine in judgeLines:
    for note in judgeLine.notes:
        note.realTime = note.time * 1.875 / judgeLine.bpm
        notes.append(note)
chartNoteSortByTime = sorted(notes, key=note.realTime)

def CheckNote(finger):
    best = None
    bestAbsDt = 10000

    end = -1
    while end + 1 < len(chartNoteSortByTime):
        note = chartNoteSortByTime[end + 1]
        if note.realTime >= nowTime + badTimeRange:
            break
        end += 1

    start = end
    while start > 0:
        note = chartNoteSortByTime[start - 1]
        if note.realTime <= nowTime - goodTimeRange:
            break
        start -= 1

    for i in range(start, end + 1):
        note = chartNoteSortByTime[i]
        
        if note.isJudged:
            continue

        dt = note.realTime - nowTime

        x = relativeX(finger, note.line)
        y = relativeY(finger, note.line)
        dx = abs(note.positionX - x)
        if dx >= 1.9:
            continue
        if dt >= bestAbsDt + 0.01:
            continue

        bad_limit = badTimeRange
        if dx > 0.9:
            bad_limit = badTimeRange - (dx - 0.9) * perfectTimeRange * 0.5
        if dt > bad_limit:
            continue

        if best is not None:
            if best.type not in [DRAG, FLICK]:
                if note.type not in [TAP, HOLD]:
                    continue
                if abs(best.realTime - note.realTime) > 0.01:
                    continue
                bestX = relativeX(finger, best.line)
                bestY = relativeY(finger, best.line)
                noteMetric = abs(note.positionX - x) + abs(y / 2.2)
                bestMetric = abs(best.positionX - bestX) + abs(bestY / 2.2)
                if noteMetric >= bestMetric:
                    continue

        best = note
        bestAbsDt = abs(dt)

    if best is None:
        return
    if best.type == FLICK:
        return best
    if best.type in [TAP, HOLD, DRAG]:
        best.isJudged = True
        return best
复制成功

红键匹配

CheckFlick()

时间范围F的值来自前文“时间范围”

对于每个有效的划动手势(即带有isNewFlick标记的手指,由“划动触发”决定),游戏会在已经按时间排序的note列表中截取一段区间

%5CDelta%20t表示一个note与现在的时间差

%5Cbegin%7Bcases%7D%0A%5CDelta%20t%20%3E%200%20%26%20%5Ctext%7B%E6%89%93%E6%97%A9%E4%BA%86%EF%BC%8Cnote%E5%AE%9E%E9%99%85%E6%9B%B4%E6%99%9A%7D%20%5C%5C%20%0A%5CDelta%20t%20%3C%200%20%26%20%5Ctext%7B%E6%89%93%E6%99%9A%E4%BA%86%EF%BC%8Cnote%E5%AE%9E%E9%99%85%E6%9B%B4%E6%97%A9%7D%0A%5Cend%7Bcases%7D

区间的末尾end:最晚的%5CDelta%20t%20%3C%20F的note

区间的开头start:在end及其之前,最早的%5CDelta%20t%20%3E%20-F的note (若不存在,则start=end)(“late Bad”的主要原因

然后,游戏按时间顺序遍历区间内的所有Flick,并在时间和位置上都接近的Flick中匹配至多一个Flick,标记isJudgedForFlick

用best表示目前找到的最佳Flick %5Cvert%20%5CDelta%20t_%7Bbest%7D%20%5Cvert%20表示它与现在的绝对时间差

对于遍历中每一个Flick:

  • 如果Flick已经被标记isJudgedForFlick,则跳过

  • 计算Flick与现在的时间差%5CDelta%20t

  • 如果%5CDelta%20t%20%5Cgeq%20%5Cvert%20%5CDelta%20t_%7Bbest%7D%20%5Cvert%20%2B%200.01,则跳过

  • 计算手指相对于Flick所在判定线的坐标%5Cleft(x%2Cy%5Cright)(单位:world unit)

  • 计算Flick与手指在x方向的绝对距离差%5Cvert%20%5CDelta%20x%20%5Cvert%20

  • 如果%5Cvert%20%5CDelta%20x%20%5Cvert%20%5Cgeq%202.1,则跳过

  • 如果目前还没有best,则用当前Flick替换best

  • 如果目前已经有best,且当前Flick与best的时间差不超过0.01s:

    • 计算Flick与手指的加权曼哈顿距离(y轴方向有%5Cfrac%7B1%7D%7B2.2%7D的权重) m%20%3D%20%5Cvert%20%5CDelta%20x%20%5Cvert%20%2B%20%5Cleft%5Cvert%20%5Cfrac%7By%7D%7B2.2%7D%20%5Cright%5Cvert%20

    • 对于best进行相同的计算,并与m进行比较

    • 如果当前Flick的m更小,则用当前Flick替换best

遍历结束后,如果best存在,则标记它isJudgedForFlick为true,并令当前手指的isNewFlick为false

(游戏在谱面开始前,会先把所有判定线上的note收集起来,并按真实时间顺序排序;“点击匹配”和“红键匹配”都会用到这个列表) (best和%5Cvert%20%5CDelta%20t_%7Bbest%7D%20%5Cvert%20初始值分别为空和10000)

图解

“红键匹配”与“点击匹配”很类似,但是只匹配Flick

“红键匹配”使用更宽的“垂直判定”,宽度为±2.1world unit(开区间)

红键垂直判定区域

时间范围上最早到F,最晚到-F*(开区间)

*红键也存在类似“late Bad”的情况,原因详情见后文“判定范围·特殊情况·late Bad”

没有Bad范围缩减

判定范围(横轴为x距离,纵轴为时间差Δt;蓝框为“点击匹配”,红框为“红键匹配”)

对于多个可匹配的红键,“红键匹配”与“点击匹配”类似(详情见“点击匹配”

如果“红键匹配”选中了红键,会清除手指上来自“划动触发”的isNewFlick标记,所以一个划动手势只能匹配一个红键

特殊情况·位移终点

划动手势需要手指有一帧位移终点在“垂直判定”范围内,才会匹配到红键

不同的划动能否匹配红键(一个箭头代表一帧内手指的位移向量)

即“红键匹配”只看每一帧的终点,不看路径;所以,一帧内从“垂直判定”范围内划出去,落点在外面,是判不到红键的(这种情况帧数很低会更明显)

正在施工中……(手指的速度还不够就划出“垂直判定”范围,划不到红键)

伪代码

代码块
Python
自动换行
复制代码
notes = []
for judgeLine in judgeLines:
    for note in judgeLine.notes:
        note.realTime = note.time * 1.875 / judgeLine.bpm
        notes.append(note)
chartNoteSortByTime = sorted(notes, key=note.realTime)

def CheckFlick(finger):
    best = None
    bestAbsDt = 10000

    end = -1
    while end + 1 < len(chartNoteSortByTime):
        note = chartNoteSortByTime[end + 1]
        if note.realTime >= nowTime + 1.75 * perfectTimeRange:
            break
        end += 1

    start = end
    while start > 0:
        note = chartNoteSortByTime[start - 1]
        if note.realTime <= nowTime - 1.75 * perfectTimeRange:
            break
        start -= 1

    for i in range(start, end + 1):
        note = chartNoteSortByTime[i]

        if note.type != FLICK:
            continue
        if note.isJudgedForFlick:
            continue

        dt = note.realTime - nowTime
        if dt >= bestAbsDt + 0.01:
            continue

        x = relativeX(finger, note.line)
        y = relativeY(finger, note.line)
        dx = abs(note.positionX - x)
        if dx >= 2.1:
            continue

        if best is not None:
            if abs(best.realTime - note.realTime) > 0.01:
                continue
            bestX = relativeX(finger, best.line)
            bestY = relativeY(finger, best.line)
            noteMetric = abs(note.positionX - x) + abs(y / 2.2)
            bestMetric = abs(best.positionX - bestX) + abs(bestY / 2.2)
            if noteMetric >= bestMetric:
                continue

        best = note
        bestAbsDt = abs(dt)

    if best is None:
        return
    best.isJudgedForFlick = True
    finger.isNewFlick = False
复制成功

评分逻辑

游戏在谱面开始前,会给每个note创建一个相应类型的Control,用于控制note的移动和评分:

蓝键 Tap -> ClickControl 长条 Hold -> HoldControl 黄键 Drag -> DragControl 红键 Flick -> FlickControl

每一帧,游戏会遍历每个Control并调用相应的Judge()

Judge()会根据以下状态:

  • isJudged标记(来自“点击匹配”)

  • isJudgedForFlick(来自“红键匹配”)

并结合note与现在的时间差%5CDelta%20t所在时间范围,决定是否给出评分

%5Cbegin%7Bcases%7D%0A%5CDelta%20t%20%3E%200%20%26%20%5Ctext%7B%E6%89%93%E6%97%A9%E4%BA%86%EF%BC%8Cnote%E5%AE%9E%E9%99%85%E6%9B%B4%E6%99%9A%7D%20%5C%5C%20%0A%5CDelta%20t%20%3C%200%20%26%20%5Ctext%7B%E6%89%93%E6%99%9A%E4%BA%86%EF%BC%8Cnote%E5%AE%9E%E9%99%85%E6%9B%B4%E6%97%A9%7D%0A%5Cend%7Bcases%7D

时间范围PGBDF的值来自前文“时间范围”

后文中出现的%5Ctext%7BPerfect%7D%5Ctext%7BGood%7D%5Ctext%7BBad%7D%5Ctext%7BMiss%7D都代表评分结束,只有评分结束才会增加或清零Combo,并增加相应的分数(按住的长条要快到长条尾部,已经匹配的黄/红键要快到判定线,评分才会结束)

(Control处理完会返回true,并被从遍历列表中删除)

蓝键 Tap

ClickControl.Judge()

蓝键有Perfect/Good/Bad/Miss 时间范围受严判影响

若没有isJudged标记:%5Cbegin%7Bcases%7D%0A%5CDelta%20t%20%3C%20-G%20%26%20%5Ctext%7BMiss%7D%20%5C%5C%0A%5CDelta%20t%20%5Cgeq%20-G%20%26%20%5Ctext%7B%E7%AD%89%E5%BE%85%7D%0A%5Cend%7Bcases%7D

若有isJudged标记:%5Cbegin%7Bcases%7D%0A%5Cvert%20%5CDelta%20t%20%5Cvert%20%3C%20P%20%26%20%5Ctext%7BPerfect%7D%20%5C%5C%0AP%20%5Cleq%20%5Cvert%20%5CDelta%20t%20%5Cvert%20%3C%20G%20%26%20%5Ctext%7BGood%7D%20%5C%5C%0AG%20%5Cleq%20%5Cvert%20%5CDelta%20t%20%5Cvert%20%26%20%5Ctext%7BBad%7D%0A%5Cend%7Bcases%7D

伪代码

代码块
Python
自动换行
复制代码
class ClickControl:
    def __init__(self, note):
        self.note = note
        self.isJudged = False

    def Judge(self):
        dt = self.note.realTime - nowTime
        self.isJudged = self.isJudged or self.note.isJudged

        if not self.isJudged:
            if dt < -goodTimeRange:
                Miss(self.note)
                return True
            return False

        if abs(dt) < perfectTimeRange:
            Perfect(self.note)
        elif abs(dt) < goodTimeRange:
            Good(self.note)
        else:
            Bad(self.note)
        return True
复制成功

长条 Hold

HoldControl.Judge()

“长条头部被击打”指长条头部标记为Perfect/Good(与isJudged标记不同);“长条结束”指长条获得了Perfect/Good/Miss评分

长条有Perfect/Good/Miss,没有Bad 头部时间范围受严判影响,尾部不受其影响

长条初始有2层“抬手保护”:S%20%3D%202

若长条头部未被击打且未Miss:

  • 若没有isJudged标记:%5Cbegin%7Bcases%7D%0A%5CDelta%20t%20%3C%20-G%20%26%20%5Ctext%7BMiss%7D%20%5C%5C%0A%5CDelta%20t%20%5Cgeq%20-G%20%26%20%5Ctext%7B%E7%AD%89%E5%BE%85%7D%0A%5Cend%7Bcases%7D

  • 若有isJudged标记:%5Cbegin%7Bcases%7D%0A%5Cvert%20%5CDelta%20t%20%5Cvert%20%3C%20P%20%26%20%5Ctext%7B%E5%A4%B4%E9%83%A8%20Perfect%7D%20%5C%5C%0AP%20%5Cleq%20%5Cvert%20%5CDelta%20t%20%5Cvert%20%3C%20G%20%26%20%5Ctext%7B%E5%A4%B4%E9%83%A8%20Good%7D%20%5C%5C%0AG%20%5Cleq%20%5Cvert%20%5CDelta%20t%20%5Cvert%20%26%20%5Ctext%7B%E7%AD%89%E5%BE%85%7D%0A%5Cend%7Bcases%7D

若长条头部已被击打且未结束:

  • 若“垂直判定”区域内存在手指:

    • 重置“抬手保护”为2层,S%20%3D%202

  • 若“垂直判定”区域内不存在手指:%5Cbegin%7Bcases%7D%0AS%20%3C%200%20%26%20%5Ctext%7BMiss%7D%20%5C%5C%0AS%20%5Cgeq%200%20%26%20S%20%3D%20S%20-%201%0A%5Cend%7Bcases%7D

若当前时间进入长条尾部前0.22s内(220ms):

  • 若长条头部已被击打且未结束:%5Cbegin%7Bcases%7D%0A%5Ctext%7B%E5%A4%B4%E9%83%A8%20Perfect%7D%20%26%20%5Ctext%7BPerfect%7D%20%5C%5C%0A%5Ctext%7B%E5%A4%B4%E9%83%A8%20Good%7D%20%26%20%5Ctext%7BGood%7D%0A%5Cend%7Bcases%7D

若当前时间超过长条尾部后0.25s(250ms):

  • 若长条头部被击打且未结束:%5Ctext%7BMiss%7D

特殊情况·糊长条

由于长条没有Bad,在early Bad时间范围点击并按住长条,会等到Good时间范围再判定头部为Good,并显示Good打击特效

特殊情况·抬手保护

短暂将手指离开长条再按住不会Miss

2层“抬手保护”实际上最多允许手指离开长条3帧

  • 在稳定60帧时:手指可以离开长条0.05s(50ms)

  • 在稳定120帧时:手指可以离开长条0.025s(25ms)

%5Cbegin%7Bcases%7D%0AS%20%3D%202%20%26%20S%20%5Cto%201%20%5C%5C%0AS%20%3D%201%20%26%20S%20%5Cto%200%20%5C%5C%0AS%20%3D%200%20%26%20S%20%5Cto%20-1%20%5C%5C%0AS%20%3D%20-1%20%26%20%5Ctext%7BMiss%7D%0A%5Cend%7Bcases%7D

特殊情况·提前松手

被击打的长条会在时间进入长条尾部前0.22s(220ms)内结算(不受严判影响),所以长条可以提前一点松手,足够短的长条甚至可以只点击一下

特殊情况·延迟Miss

如果长条有了isJudged标记,然而处理时已经%5CDelta%20t%20%5Cleq%20-G(“late Bad”),这时长条头部未被击打也未Miss,只有在时间超过长条尾部后0.25s(250ms)才会判为Miss

这个状态下长条不会变暗(未Miss)也不会显示打击特效(头部未被击打)

“点击匹配”通常只会匹配%5CDelta%20t%20%3E%20-G的note;上述情况的出现源于特殊情况,详情见后文“判定范围·特殊情况·late Bad”

伪代码

代码块
Python
自动换行
复制代码
class HoldControl:
    def __init__(self, note):
        self.note = note
        self.isJudged = False
        
        self.missed = False
        self.judged = False # 头部是否被击打
        self.judgeOver = False # 长条判定是否结束
        self.isPerfect = False # 头部击打是否为 Perfect
        self._safeFrame = 2 # “抬手保护”

    def Judge(self):
        dt = self.note.realTime - nowTime
        tailTime = self.note.realTime + self.note.holdTime

        # 长条头部
        if not self.judged:
            if not self.missed:
                self.isJudged = self.isJudged or self.note.isJudged
                if not self.isJudged:
                    if dt < -goodTimeRange:
                        Miss(self.note)
                        self.missed = True
                else:
                    if abs(dt) < perfectTimeRange:
                        self.judged = True
                        self.isPerfect = True
                    elif abs(dt) < goodTimeRange:
                        self.judged = True
                        self.isPerfect = False

        # 长条中部
        if self.judged and not self.missed and not self.judgeOver:
            self.missed = True
            for finger in fingers:
                x = relativeX(finger, self.note.line)
                if abs(x - self.note.positionX) < 1.9:
                    self.missed = False
                    self._safeFrame = 2
                    break
            if self.missed:
                if self._safeFrame < 0:
                    Miss(self.note)
                    self.judgeOver = True
                else:
                    self._safeFrame -= 1
                    self.missed = False

        # 长条尾部前
        if nowTime > tailTime - 0.22:
            if self.judged and not self.missed and not self.judgeOver:
                if self.isPerfect:
                    Perfect(self.note)
                else:
                    Good(self.note)
                self.judgeOver = True

        # 长条尾部后
        if nowTime > tailTime + 0.25:
            if not self.judged and not self.missed and not self.judgeOver:
                Miss(self.note)
            return True
        return False
复制成功

黄键 Drag

DragControl.Judge()

这部分包括了“判定流程”中“黄键匹配”与“黄键评分”两个步骤

这部分的isJudgedForDrag是为了方便理解起的名字,对应源码内的DragControl.isJudged,与“点击匹配”的isJudged标记不同

对于每个Drag,游戏会检查附近是否存在按住的手指;若存在,标记isJudgedForDrag(即“黄键匹配”)

黄键只有Perfect/Miss,没有Good/Bad 时间范围不受严判影响

若没有isJudgedForDrag标记且%5Cvert%20%5CDelta%20t%20%5Cvert%20%5Cleq%20D

  • 计算手指相对于Drag所在判定线的x坐标(单位:world unit)

  • 计算Drag与手指在x方向的绝对距离差%5Cvert%20%5CDelta%20x%20%5Cvert%20

  • 如果%5Cvert%20%5CDelta%20x%20%5Cvert%20%3C%202.1,则标记isJudgedForDrag为true

若没有isJudgedForDrag标记:%5Cbegin%7Bcases%7D%0A%5CDelta%20t%20%3C%20-D%20%26%20%5Ctext%7BMiss%7D%20%5C%5C%0A%5CDelta%20t%20%5Cgeq%20-D%20%26%20%5Ctext%7B%E7%AD%89%E5%BE%85%7D%0A%5Cend%7Bcases%7D

若有isJudgedForDrag标记:%5Cbegin%7Bcases%7D%0A%5CDelta%20t%20%3C%200.005%20%26%20%5Ctext%7BPerfect%7D%20%5C%5C%0A%5CDelta%20t%20%5Cgeq%200.005%20%26%20%5Ctext%7B%E7%AD%89%E5%BE%85%7D%0A%5Cend%7Bcases%7D

图解

“黄键匹配”使用更宽的“垂直判定”,宽度为±2.1world unit(开区间)

黄键垂直判定区域

时间范围上最早到D,最晚到-D(比较特殊,为闭区间)

判定范围(横轴为x距离,纵轴为时间差Δt;蓝框为“点击匹配”,橘框为“黄键匹配”)
  • 一根按住的手指可以通过“黄键匹配”标记任意多个黄键

  • “黄键评分”只看“黄键匹配”的isJudgedForDrag标记,不看“点击匹配”的isJudged标记

即使很早就标记到了黄键,也要在靠近/过了判定线时才会评分

黄键在宽判下比蓝键Miss得更早

伪代码

代码块
Python
自动换行
复制代码
class DragControl:
    def __init__(self, note):
        self.note = note
        self.isJudged = False

    def Judge(self):
        dt = self.note.realTime - nowTime
        # 注意这里并没有使用来自“点击匹配”的 self.note.isJudged 更新 self.isJudged

        if abs(dt) <= 0.1 and not self.isJudged:
            for finger in fingers:
                x = relativeX(finger, self.note.line)
                if abs(x - self.note.positionX) < 2.1:
                    self.isJudged = True

        if not self.isJudged:
            if dt < -0.1:
                Miss(self.note)
                self.note.isJudged = True
                return True

        if self.isJudged:
            if dt < 0.005:
                Perfect(self.note)
                self.note.isJudged = True
                return True

        return False
复制成功

红键 Flick

FlickControl.Judge()

红键只有Perfect/Miss,没有Good/Bad 时间范围与蓝键时间范围成比例,受严判影响

若没有isJudgedForFlick标记:%5Cbegin%7Bcases%7D%0A%5CDelta%20t%20%3C%20-F%20%26%20%5Ctext%7BMiss%7D%20%5C%5C%0A%5CDelta%20t%20%5Cgeq%20-F%20%26%20%5Ctext%7B%E7%AD%89%E5%BE%85%7D%0A%5Cend%7Bcases%7D

若有isJudgedForFlick标记:%5Cbegin%7Bcases%7D%0A%5CDelta%20t%20%3C%200.005%20%26%20%5Ctext%7BPerfect%7D%20%5C%5C%0A%5CDelta%20t%20%5Cgeq%200.005%20%26%20%5Ctext%7B%E7%AD%89%E5%BE%85%7D%0A%5Cend%7Bcases%7D

  • “红键评分”只看“红键匹配”的isJudgedForFlick标记,不看“点击匹配”的isJudged标记

即使很早就标记到了红键,也要在靠近/过了判定线时才会评分

红键在宽判和严判下,都比蓝键Miss得更早

伪代码

代码块
Python
自动换行
复制代码
class FlickControl:
    def __init__(self, note):
        self.note = note
        self.isJudged = False

    def Judge(self):
        dt = self.note.realTime - nowTime
        self.isJudged = self.isJudged or self.note.isJudgedForFlick

        if not self.isJudged:
            if dt < -1.75 * perfectTimeRange:
                Miss(self.note)
                self.note.isJudged = True
                return True

        if self.isJudged:
            if dt < 0.005:
                Perfect(self.note)
                self.note.isJudged = True
                self.note.isJudgedForFlick = True
                return True

        return False
复制成功

判定范围

结合“判定流程”中的每一个步骤,可以得出理论上的总判定范围

判定流程(再放送)

图解(理论)

以下分别为宽判与严判的判定范围,横轴为x距离,纵轴为时间差Δt

黄色区域:Perfect 蓝色区域:Good 红色区域:Bad

虚线表示开区间,实线表示闭区间

标准判定(宽判)

宽判判定范围【1:2px】

课题模式(严判)

严判判定范围(60帧)【1:2px】
严判判定范围(120帧)【1:2px】

特殊情况·late Bad

以下现象均称为late Bad:

  1. 蓝键可以在late Good(-G)范围过后一帧内被匹配,并评为Bad

  2. 长条可以在late Good(-G)范围过后一帧内被匹配,并“延迟Miss”见“评分逻辑·长条·特殊情况·延迟Miss”

  3. 红键可以在late Perfect(-F)范围过后一帧内被匹配,并评为Perfect

late Bad的原因结合了匹配和评分两部分代码的bug


在“点击匹配”和“红键匹配”中,会先在已经按时间排序的note列表中截取一段区间,并在这个区间内匹配至多一个note

就拿“点击匹配”为例:建议大佬们去“点击匹配·伪代码”,看一眼就懂了

区间的末尾end:最晚的%5CDelta%20t%20%3C%20B的note

区间的开头start:在end及其之前,最早的%5CDelta%20t%20%3E%20-G的note (若不存在,则start=end)

代码上的逻辑确实可以保证区间的末尾在B之前,但是不能保证在-G之后

如果(-G%2C%20B)内不存在note,且-G之前存在note,区间的末尾就会选中一个-G之前的note;因为这个note之前不存在满足%5CDelta%20t%20%3E%20-G的note,所以区间的开头也会选中同一个note,即区间内有且仅有这一个note(甚至已经Miss掉的也能选中,所以只要前面有note,这个区间就不为空)

所以late Bad一般只存在在比较稀疏的谱面,因为(-G%2C%20B)内必须没有note

这个过晚的note被算进区间内后,只要没有isJudged标记,且点击位置在“垂直判定”内,它就会被成功匹配,标记isJudged,并交给评分逻辑进行评分

“红键匹配”有同样的bug,原理几乎一致,就不多赘述了


被匹配的蓝键、长条、和红键会被各自的评分逻辑评分

理论上,评分逻辑会让蓝键和长条过了late Good(-G)就Miss、红键过了late Perfect(-F)就Miss;但是一帧内的执行顺序是先匹配、再评分,导致刚过一帧的note被匹配时还没来得及评为Miss;再到评分逻辑处理时,因为有isJudged标记不走Miss分支,于是蓝键评为Bad、长条进入“延迟Miss”、红键评为Perfect

  • 对于蓝键:因为%5Cvert%20%5CDelta%20t%20%5Cvert%20%20%5Cgeq%20G,被评为Bad

  • 对于长条:因为%5Cvert%20%5CDelta%20t%20%5Cvert%20%20%5Cgeq%20G,长条头部视为未被击打,一直按住可以暂时不Miss(不变暗、不断连);但是到了长条尾部后0.25s,就会被评为Miss

  • 对于红键:因为%5CDelta%20t%20%3C%200.005,被评为Perfect

特殊情况·帧内延迟

因为游戏在每一帧的Update中统一处理触发、匹配、与评分,所以一次点击被处理的时间取决于它发生在一帧内的哪个位置:

  • 如果刚好在Update前:几乎立即处理

  • 如果刚好在Update后:需要等到下一帧才能处理

因此被处理的时间可能比点击时间晚0~一帧时间,可以近似均匀分布

在稳定60帧时:

帧内延迟:0%20%5Csim%200.0167(16.7ms),所以帧数低会因为延迟很难受

在稳定120帧时:

帧内延迟:0%20%5Csim%200.0083(8.3ms)

这个延迟会让点击结果整体偏晚:可能让early Good变成Perfect,也可能让late Perfect变成Good

图解(实际)

结合“late Bad”和“帧内延迟”,可以得出更贴近实际的判定范围

横轴为x距离,纵轴为时间差Δt

黄色区域:Perfect 蓝色区域:Good 红色区域:Bad

  • 在蓝键的late方向增加了一帧的Bad

  • 在长条的late方向增加了一帧的Miss(虽然完全看不出来)

  • 在红键的late方向增加了一帧的Perfect

  • 通过颜色的渐变表示Perfect/Good/Bad/Miss的概率

标准判定(宽判)

宽判实际判定范围(60帧)【1:2px】
宽判实际判定范围(120帧)【1:2px】

课题模式(严判)

严判实际判定范围(60帧)【1:2px】
严判实际判定范围(120帧)【1:2px】