fix: repair parallax collisions and add play guide

This commit is contained in:
wxm
2026-07-06 23:51:59 -07:00
parent 8cca00d0da
commit af7b580691
12 changed files with 921 additions and 618 deletions
-11
View File
@@ -1,11 +0,0 @@
节奏输入有效范围规则
节奏条的中心位置为核心判定锚点。
在核心判定锚点左右两侧,各存在一段有效输入范围。该范围可以理解为“中心判定区”。
只有当普通节奏点或时间锚点进入这个中心判定区时,玩家按键才会被视为有效输入,并进入后续的节奏判定流程。
如果当前中心判定区内没有任何普通节奏点或时间锚点,则玩家按键视为无效输入。
无效输入不会触发动作,不会进入 BAD / MISS / GOOD / PERFECT 判定流程,也不会消耗节奏点。
-7
View File
@@ -1,7 +0,0 @@
开始界面标题固定
* 开始界面的背景可以进行循环切换。
* 但游戏标题不参与切换,不跟随背景变化。
* 标题位置始终固定,避免背景切换时导致标题位置发生变化。
3. 玩家初始生成位置
* 游戏正式开始后,玩家角色应生成在地图最左侧。
* 该位置作为关卡起点,玩家从左向右推进关卡。
-569
View File
@@ -1,569 +0,0 @@
怪物与 Boss 行为设定方案
1. 核心原则
* 近战怪:近距离、高伤害、主动压迫玩家。
* 远程怪:远距离、低伤害、保持距离输出。
* Boss:根据相位切换近战 / 远程行为组。
* 强势相位怪物的攻击可以打断玩家动作。
* 弱势相位怪物的攻击不能打断玩家动作。
* 玩家动作带霸体时,不会被怪物攻击打断。
2. 通用行为状态
所有敌人使用以下基础状态:
待机
追踪 / 调整距离
攻击预备
攻击触发
攻击后摇
受击
脱离 / 后撤
死亡
所有攻击必须有三段:
攻击预备 → 攻击触发 → 攻击后摇
状态切换尽量按节拍发生,避免怪物连续无间隔攻击。
3. 伤害设定
3.1 近战怪伤害
近战怪攻击伤害:高
推荐值:20
特点:
* 攻击距离短。
* 命中代价高。
* 强势相位下可打断玩家。
* 弱势相位下只造成伤害,不打断玩家。
3.2 远程怪伤害
远程怪攻击伤害:低
推荐值:8
特点:
* 攻击距离远。
* 命中频率较高。
* 主要制造站位压力。
* 强势相位下可打断玩家。
* 弱势相位下只造成伤害,不打断玩家。
3.3 Boss 伤害
Boss 根据当前相位切换攻击倾向:
近战相位:攻击力高
远程相位:攻击力低
推荐值:
Boss 近战攻击伤害:25
Boss 远程攻击伤害:12
4. 近战怪行为逻辑
4.1 行为定位
近战怪负责贴身压迫玩家。
它不需要频繁后撤,也不需要复杂逃跑逻辑。
4.2 距离规则
警戒距离:360 px
追踪停止距离:120 px
攻击距离:120 px
攻击判定范围:前方 140 px
4.3 行为流程
玩家进入警戒距离
近战怪向玩家移动
进入攻击距离
停止移动
攻击预备
攻击触发
攻击后摇
重新判断距离
4.4 受击逻辑
弱势相位
受到高伤害
可被普通攻击打出硬直
不具备明显霸体
可以被玩家快速击败
强势相位
受到伤害降低
硬直减弱
普通攻击不一定打断动作
攻击触发阶段具备霸体
4.5 墙角逻辑
近战怪被逼到墙角时,不后撤。
处理方式:
如果玩家距离 < 120 px
近战怪优先攻击
强势相位下攻击可打断玩家
命中后对玩家造成小幅击退
推荐击退距离:
玩家被击退:80120 px
5. 远程怪行为逻辑
5.1 行为定位
远程怪负责远距离压制玩家。
它不能被玩家贴身无限连死,所以需要后撤和脱离机制。
5.2 距离规则
危险距离:0220 px
理想距离:320480 px
最大攻击距离:720 px
5.3 基础行为流程
玩家进入攻击范围
远程怪寻找理想距离
保持 320480 px 距离
攻击预备
发射远程攻击
攻击冷却
重新判断距离
5.4 玩家过近时的后撤逻辑
触发条件:
玩家距离远程怪 < 220 px
执行流程:
停止攻击
进入短暂脱离保护
向远离玩家方向后撤
恢复到 320420 px 距离
短暂停顿
重新进入远程攻击循环
推荐数值:
单次后撤距离:220 px
最小后撤后距离:320 px
最大后撤后距离:420 px
后撤后停顿:0.4 秒
5.5 被攻击后的脱离逻辑
触发条件满足任一即可:
远程怪被玩家攻击命中
玩家距离远程怪 < 160 px
远程怪在 1 秒内连续受击 2 次
执行流程:
受击
短暂无敌
后撤 / 脱离
恢复到安全距离
取消无敌
重新进入攻击循环
推荐数值:
短暂无敌时间:0.35 秒
后撤距离:220260 px
脱离冷却:3 秒
限制:
后撤过程中不攻击
无敌时间只用于脱离
不能连续触发无敌后撤
5.6 墙角脱离逻辑
触发条件:
玩家距离远程怪 < 220 px
远程怪背后距离墙体 < 120 px
脱离冷却已结束
执行流程:
短暂无敌
穿越玩家 / 闪避到玩家另一侧
落点与玩家保持约 320 px
短暂停顿
恢复攻击循环
推荐数值:
穿越距离:玩家身后 320 px
无敌时间:0.35 秒
墙角脱离冷却:4 秒
落地停顿:0.5 秒
限制:
墙角脱离不造成伤害
不能连续触发
不能直接接攻击
6. Boss 行为逻辑
6.1 Boss 核心设定
Boss 拥有两套行为组:
近战相位:近战技能组
远程相位:远程技能组
Boss 不采用小怪的弱势相位秒杀逻辑。
Boss 的相位切换主要改变:
攻击方式
攻击距离
攻击力
追踪策略
霸体强度
6.2 Boss 近战相位
定位
主动靠近玩家
使用高伤害近战攻击
压迫玩家站位
目标距离
目标距离:100160 px
攻击距离:180 px
行为流程
判断玩家距离
如果距离 > 180 px,向玩家靠近
如果距离 ≤ 180 px,进入近战攻击预备
攻击触发
攻击后摇
重新判断距离
伤害
Boss 近战伤害:25
打断
近战相位攻击可打断玩家
玩家霸体动作除外
6.3 Boss 远程相位
定位
主动拉开距离
使用低伤害远程攻击
制造空间压制
目标距离
目标距离:360520 px
最小安全距离:240 px
最大攻击距离:760 px
行为流程
判断玩家距离
如果玩家距离 < 240 pxBoss 后撤
如果玩家距离在 360–520 px,使用远程攻击
如果玩家距离 > 760 pxBoss 小幅靠近
重新判断距离
伤害
Boss 远程伤害:12
打断
远程相位攻击可打断玩家
玩家霸体动作除外
6.4 Boss 相位切换规则
当前为近战相位
切换到远程相位
Boss 拉开距离
使用远程攻击组
当前为远程相位
切换到近战相位
Boss 主动靠近玩家
使用近战攻击组
相位切换时不立刻攻击。
推荐流程:
相位切换提示
短暂停顿
调整距离
进入新相位攻击循环
推荐数值:
相位切换停顿:0.5 秒
6.5 Boss 受击逻辑
Boss 不应被普通连招无限压制。
普通受击
正常掉血
播放受击反馈
不进入完整硬直
韧性机制
推荐设定:
Boss 韧性值:100
普通攻击削韧:10
技能攻击削韧:20
时间锚点强化攻击削韧:30
韧性归零时:
Boss 进入短暂硬直
硬直后恢复韧性
推荐数值:
Boss 硬直时间:1.2 秒
韧性恢复值:100
6.6 Boss 霸体逻辑
普通移动:轻霸体
攻击预备:中霸体
攻击触发:强霸体
攻击后摇:霸体降低
硬直状态:无霸体
玩家主要输出窗口:
Boss 攻击后摇
Boss 韧性破防硬直
Boss 相位切换后的短暂停顿
6.7 Boss 后撤逻辑
只在远程相位下启用。
触发条件:
Boss 当前为远程相位
玩家距离 Boss < 240 px
执行流程:
停止攻击
后撤
恢复到 360480 px 距离
短暂停顿
继续远程攻击
推荐数值:
Boss 后撤距离:280 px
后撤后目标距离:360480 px
后撤冷却:4 秒
后撤后停顿:0.5 秒
限制:
Boss 后撤过程中不攻击
Boss 后撤不造成伤害
Boss 后撤不能连续触发
6.8 Boss 墙角处理
触发条件:
Boss 当前为远程相位
玩家距离 Boss < 240 px
Boss 背后距离墙体 < 160 px
后撤冷却已结束
处理方式:
Boss 短暂无敌
穿越玩家 / 跳到另一侧
与玩家重新拉开 400 px 左右距离
短暂停顿
恢复远程攻击循环
推荐数值:
Boss 墙角脱离冷却:6 秒
短暂无敌时间:0.4 秒
落点距离玩家:400 px
落地停顿:0.6 秒
限制:
墙角脱离不造成伤害
不能连续触发
不能直接接攻击
7. 最终规则汇总
近战怪
短距离
高伤害
主动靠近
强势相位可打断玩家
弱势相位容易被击败
被逼墙角时不逃跑,使用攻击反压
远程怪
长距离
低伤害
保持 320480 px 距离
被近身后后撤
被连续攻击后短暂无敌脱离
被逼墙角后穿越玩家脱离
脱离动作不造成伤害
Boss
拥有近战 / 远程两套行为组
随相位切换行为
近战相位高伤害,主动压迫
远程相位低伤害,主动拉开距离
Boss 有韧性和阶段性霸体
Boss 远程相位被近身时后撤
Boss 被逼墙角时可脱离
Boss 脱离动作不造成伤害
+663
View File
@@ -0,0 +1,663 @@
# 《Anchor》试玩说明
这是一款节奏横板动作游戏。你不需要像传统动作游戏那样一直按方向键移动,角色的前进、后退、突进、起跳、下坠和攻击位移,主要都来自你在音乐节奏上的出招。
一句话理解玩法:
> 听节奏按键,用招式带动角色移动,在过去与未来两个时间相位之间寻找敌人的弱点,打完小怪后进入 Boss 战。
---
## 1. 基本目标
试玩关卡分为两个部分:
1. 前半段清理小怪。
2. 进入右侧 Boss 区域,击败 Boss。
前场一共会出现 6 只小怪。全部击败后,通往 Boss 区域的道路打开。进入 Boss 区域后,Boss 战正式开始。
胜利条件:
- 击败 Boss。
失败条件:
- 玩家生命归零。
---
## 2. 基本操作
| 按键 | 用途 |
| --- | --- |
| A | 向左攻击,也可长按蓄力 |
| D | 向右攻击,也可长按蓄力 |
| W | 上挑,让敌人或自己进入空中连段节奏 |
| S | 防御;在空中时可下砸;也可长按蓄力 |
| Space | 特殊键,用来派生技能或释放部分蓄力招式 |
| E | 显示或隐藏招式表 |
| Esc | 暂停游戏 |
最重要的一点:
> A、D、W、S、Space 不是自由移动键。它们都是“节奏动作键”。
角色移动主要来自动作本身,比如向右攻击会带你向右推进,冲刺斩会穿过敌人,上挑和空中攻击会改变你的高度与落点。
---
## 3. 节奏判定
每次按键都会和音乐节拍比较,得到一个判定。
| 判定 | 含义 |
| --- | --- |
| Perfect | 非常准,效果最好 |
| Good | 比较准,效果稳定 |
| Bad | 勉强命中节奏,动作仍可成立,但收益下降 |
| Miss | 没有踩中节奏,动作失败 |
判定会影响这些内容:
- 你是否成功出招。
- 造成多少伤害。
- 获得多少能量。
- 使用技能时消耗多少能量。
- 是否能稳住时间相位。
大致规则:
- Perfect:标准伤害,标准回能,技能消耗更低。
- Good:伤害和回能略低。
- Bad:伤害和回能明显降低,技能消耗更高。
- Miss:不出招,连段被打断。
连续乱按不会带来优势。同一个节奏点被使用后,短时间内重复按键会被忽略;继续乱按会变成 Miss,并让你进入短暂的按键失效状态。这个设计是为了鼓励你跟着节奏打,而不是疯狂连点。
---
## 4. 招式记忆与连段
游戏会记住你最近几次有效输入,并根据输入顺序决定招式。
你可以把它理解成一小段“招式记忆”:
```text
A → 普通左攻击
A A → 第二段左攻击
A A A → 第三段左攻击
A Space → 左冲刺斩
A A Space → 左终结技
A A A Space → 左重击
```
右方向同理:
```text
D → 普通右攻击
D D → 第二段右攻击
D D D → 第三段右攻击
D Space → 右冲刺斩
D D Space → 右终结技
D D D Space → 右重击
```
当这段记忆里出现 Miss 时,连段会断开。你需要重新踩节奏开始下一轮输入。
---
## 5. 能量与技能
玩家有能量条。
能量主要通过基础攻击获得:
- 普通攻击命中节奏可以回复能量。
- Perfect 回能最多。
- Good 次之。
- Bad 回能较少。
技能通常需要消耗能量:
- 冲刺斩消耗较少,适合突进、穿越敌人和调整位置。
- 终结技消耗中等,伤害和击退更明显。
- 重击消耗较高,伤害高,并且更容易把敌人打飞或击退。
- 蓄力技能根据蓄力等级消耗不同,等级越高,威力越强,消耗也越高。
能量不足时,技能不会释放成功。
---
## 6. 常用招式
### 普通攻击
A 和 D 是最基础的攻击键。
- A:向左攻击。
- D:向右攻击。
- 连续按同方向可以打出多段攻击。
- 普通攻击会带有轻微位移,可以用来接近敌人或调整站位。
- 普通攻击是最稳定的回能方式。
### 冲刺斩
输入:
```text
A Space
D Space
```
特点:
- 向对应方向快速突进。
- 可以穿过敌人。
- 适合躲开夹击、穿到敌人背后、靠近远程敌人。
- 冲刺结束后,角色会重新恢复正常身体阻挡,不会长期站在敌人体内。
### 终结技
输入:
```text
A A Space
D D Space
```
特点:
- 比普通攻击伤害更高。
- 有更明显的击退效果。
- 适合在敌人露出破绽时使用。
### 重击
输入:
```text
A A A Space
D D D Space
```
特点:
- 消耗更高。
- 伤害高。
- 击退和打飞效果更强。
- 出手和收招更重,需要找准时机。
### 上挑
输入:
```text
W
```
特点:
- 可把战斗带入空中节奏。
- 可衔接空中攻击。
- 适合打断地面节奏,制造新的攻击角度。
### 空中攻击
在空中按:
```text
A
D
S
```
效果:
- A / D:向左或向右进行空中攻击,并带有明显横向移动。
- S:向下攻击,适合从空中压回地面。
### 防御
输入:
```text
S
```
特点:
- 地面状态下按 S 会进入防御动作。
- 正面防御可以减轻或挡住伤害。
- 背后受到攻击时,防御效果会变差。
- 防御仍然需要跟节奏配合,乱按不会稳定保命。
---
## 7. 蓄力招式
部分按键可以长按蓄力。
### A / D 蓄力:剑雨
用法:
1. 按 A 或 D 打出一次对应方向的普通攻击。
2. 继续按住不放,进入蓄力。
3. 松开按键,释放剑雨。
特点:
- A 蓄力:向左释放剑雨。
- D 蓄力:向右释放剑雨。
- 蓄力越久,等级越高。
- 等级越高,伤害越高,消耗能量越多。
- 剑雨适合攻击一片区域,尤其适合压制小怪。
注意:
- A / D 蓄力过程中按 Space,会优先变成普通连段派生,例如冲刺斩。
- 也就是说,如果你正在按住 A,途中按 Space,可能会打出 `A Space` 的冲刺斩,而不是剑雨。
### S 蓄力:斩波
用法:
1. 按住 S 进入蓄力。
2. 蓄力过程中按 Space 释放斩波。
特点:
- 斩波是远程攻击。
- 会朝角色当前面对方向发射。
- 蓄力越久,等级越高。
- 等级越高,射程、伤害和击退越强。
- 适合攻击不方便接近的敌人。
---
## 8. 过去与未来
游戏中有两个时间相位:
- 过去
- 未来
相位会影响:
- 背景与画面色调。
- 音乐层次。
- 敌人的强弱状态。
- 界面提示。
相位不会改变地形。你能站的位置、墙和地面不会因为过去或未来而变化。
---
## 9. 时间锚点
战斗中会出现特殊节奏点,叫做时间锚点。
当时间锚点出现时:
- 如果你在窗口内按出 Perfect、Good 或 Bad,当前相位会保持。
- 如果你 Miss,或者没有及时输入,相位会切换到另一边。
时间锚点不是单独的按键。你可以用当前任何合适的动作输入去稳住它。
举例:
- 时间锚点出现时,你按 D,既可能打出右攻击,也可能同时稳住相位。
- 如果这次按键是 Miss,动作失败,相位也可能切换。
---
## 10. 攻击提升与回血
成功守住时间锚点会给你奖励。
### 攻击提升
- Perfect 或 Good 守住时间锚点:获得 1 层攻击提升。
- Bad 守住时间锚点:只保持相位,不增加攻击提升。
- 攻击提升最多 7 层。
- 每层都会让你的攻击更强。
- 相位切换时,会失去 2 层。
- 受到伤害进入受击状态时,也会失去一部分层数。
### 回血
成功守住时间锚点时会回血。
- Perfect 回血最多。
- Good 次之。
- Bad 较少。
- 如果你的血量很低,回血效果会更明显。
简单理解:
> 节奏越稳,越能维持当前相位,也越能把角色养强。
---
## 11. 敌人的强弱状态
小怪会随着过去与未来改变强弱。
### 近战小怪
近战小怪在过去更强,在未来更弱。
过去时:
- 攻击更危险。
- 更容易打断你。
- 承受你的伤害更低。
未来时:
- 攻击威胁下降。
- 更容易被你击杀。
- 更适合主动输出。
### 远程小怪
远程小怪在未来更强,在过去更弱。
过去时:
- 远程压力较低。
- 更容易被处理。
未来时:
- 射击更危险。
- 更容易打断你。
- 需要优先压制或躲避。
### 推荐打法
- 过去:优先小心近战怪,可以趁机处理远程怪。
- 未来:优先小心远程怪,可以趁机处理近战怪。
- 如果场上两种怪都有,优先攻击当前较弱的那一类,能更快减少场面压力。
---
## 12. 小怪行为
### 近战小怪
近战小怪会:
- 在区域内巡逻。
- 看到你后追击。
- 靠近后按节奏出手。
- 强势状态下攻击更容易打断你。
应对方式:
- 不要一直站在它正面吃招。
- 用 A / D 普攻调整距离。
- 用冲刺斩穿过它,换到另一侧。
- 在它处于弱势相位时集中输出。
### 远程小怪
远程小怪会:
- 保持距离射击。
- 被你贴近后尝试后撤。
- 连续被打时,会短暂闪避脱离。
应对方式:
- 用冲刺斩快速接近。
- 用斩波远距离反击。
- 不要长期放任远程怪在屏幕边缘输出。
- 在它处于弱势相位时尽快解决。
---
## 13. 第一关流程
第一关小怪部分一共 3 个阶段。
### 阶段一:单只近战怪
开局你会从地图偏左位置进入,向右推进后遇到第一只近战怪。
这个阶段主要让你熟悉:
- 节奏攻击。
- 普通连段。
- 近战怪追击。
- 过去/未来对敌人强弱的影响。
### 阶段二:单只远程怪
击败第一只近战怪后,会出现一只远程怪。
这个阶段主要让你熟悉:
- 远程攻击压力。
- 接近远程怪的方法。
- 冲刺斩和斩波的使用时机。
### 阶段三:混合增援
击败远程怪后,会陆续出现 4 只增援:
- 近战怪
- 远程怪
- 近战怪
- 远程怪
它们不会一瞬间全部出现,而是分批进入战斗。
这个阶段的重点是:
- 根据当前相位选择优先目标。
- 不要被近战和远程同时压住。
- 用技能快速减少敌人数。
- 保持节奏,尽量不要让时间相位失控。
---
## 14. Boss 战
清完全部小怪后,右侧道路会打开。进入 Boss 区域后,Boss 战开始。
Boss 和小怪不同:
- Boss 不会像小怪那样有明显弱势相位。
- Boss 的攻击一般都能打断你。
- Boss 血量更高。
- 普通攻击可以造成伤害,但不容易压制 Boss。
- 消耗能量的技能更适合压制 Boss。
### 过去相位的 Boss
过去相位下,Boss 更偏向近战。
常见表现:
- 慢一些。
- 攻击更重。
- 会使用近身连击和突刺。
- 部分强力招式前会给你时间锚点压力。
应对方式:
- 不要贪刀。
- 观察它准备动作。
- 用冲刺斩调整位置。
- 用技能抓它收招后的空隙。
### 未来相位的 Boss
未来相位下,Boss 更偏向远程。
常见表现:
- 出手更频繁。
- 会使用射击和弹幕类攻击。
- 贴得太近时可能后撤拉开距离。
应对方式:
- 不要只站远处挨打。
- 找节奏接近它。
- 注意躲开弹道。
- 用斩波或冲刺斩寻找反击机会。
### Boss 战核心建议
- 普通攻击用于回能和维持节奏。
- 技能用于压制和打出爆发。
- 时间锚点尽量不要丢。
- 相位切换后,先观察 Boss 行为变化,不要立刻乱按。
---
## 15. 防御、受击和击退
### 防御
地面按 S 可以防御。
防御适合:
- 你来不及闪开时。
- Boss 或强势敌人即将出手时。
- 想稳住节奏而不是继续贪攻击时。
注意:
- 防御更适合挡正面攻击。
- 背后被打时,防御效果会变差。
- 防御不是无敌。
### 受击
受到强势攻击或 Boss 攻击时,你可能会:
- 被打断当前动作。
- 进入短暂硬直。
- 被击退。
- 掉一部分攻击提升层数。
受到弱势小怪攻击时,通常只会掉血,不一定打断你。
### 击退
很多重攻击和技能都会击退敌人。
你也可能被敌人击退。被击退后,角色会短暂滑行一段距离,这段时间要等角色稳定后再重新找节奏出手。
---
## 16. 推荐试玩思路
第一次试玩可以按这个顺序熟悉:
1. 先只用 A / D 跟节奏打普通攻击。
2. 观察 Perfect、Good、Bad、Miss 的区别。
3. 尝试 A A A 或 D D D 的三段攻击。
4. 尝试 A Space / D Space 的冲刺斩。
5. 试着用 W 把战斗带入空中。
6. 学会看过去/未来相位,优先打当前较弱的小怪。
7. 清小怪后进入 Boss 区域。
8. Boss 战中用普通攻击回能,用技能找机会压制。
---
## 17. 简单打法建议
### 面对近战怪
- 等它靠近后,用普通攻击或冲刺斩换边。
- 它强势时少贪刀。
- 它弱势时集中输出。
### 面对远程怪
- 不要让它一直在远处安全射击。
- 用冲刺斩靠近。
- 用斩波对射。
- 它未来相位更危险,优先处理。
### 面对多只怪
- 先处理当前相位下较弱的怪。
- 不要站在两只怪中间。
- 用冲刺斩穿出去,避免被夹住。
- 能量够时,用重击或蓄力技能快速减少敌人数。
### 面对 Boss
- 普通攻击主要用于回能。
- 耗能技能更适合压制 Boss。
- Boss 近战相位不要硬拼太久。
- Boss 远程相位要主动寻找靠近机会。
- 时间锚点出现时优先稳住节奏。
---
## 18. 常见问题
### 为什么按了方向键角色不走?
这是游戏设计。角色没有传统自由移动,移动来自动作。A、D、W、S、Space 都是节奏动作键。
### 为什么我按了键却没出招?
可能原因:
- 没踩中节奏,变成 Miss。
- 能量不足,技能放不出来。
- 正在上一招的收招阶段,还不能接下一招。
- 刚刚乱按触发了短暂按键失效。
### 为什么敌人有时候很硬,有时候很脆?
因为过去/未来相位会改变小怪强弱。
- 近战怪:过去强,未来弱。
- 远程怪:未来强,过去弱。
强势时敌人更难打,也更危险;弱势时更适合输出。
### 为什么相位突然切换?
你可能没有守住时间锚点。时间锚点出现后,如果没有及时输入,或者输入 Miss,就会切换过去/未来。
### 为什么我的攻击突然变强?
你可能连续守住了时间锚点,获得了攻击提升层数。层数越高,攻击越强。
### 为什么攻击提升又掉了?
相位切换会掉层,受到伤害进入受击状态也会掉层。
### 为什么冲刺斩可以穿过敌人?
这是冲刺斩的特点。它允许你穿过敌人,用来调整站位、躲避夹击或靠近远程敌人。
### 为什么普通攻击打 Boss 感觉压不住?
Boss 对普通攻击更难被压制。普通攻击适合回能和找节奏,真正压制 Boss 更依赖消耗能量的技能。
---
## 19. 一句话小抄
- 跟节奏,不要乱按。
- A / D 普攻回能。
- Space 派生技能。
- A / D 长按松开是剑雨。
- S 长按后按 Space 是斩波。
- W 进入空中节奏。
- 过去重点小心近战怪。
- 未来重点小心远程怪。
- 时间锚点尽量守住。
- Boss 用普通攻击回能,用技能压制。
+1 -1
View File
@@ -38,7 +38,7 @@
[ext_resource type="Resource" path="res://resources/effects/effect_on_landed_haste.tres" id="30_landed_haste"] [ext_resource type="Resource" path="res://resources/effects/effect_on_landed_haste.tres" id="30_landed_haste"]
[sub_resource type="RectangleShape2D" id="RectangleShape2D_player"] [sub_resource type="RectangleShape2D" id="RectangleShape2D_player"]
size = Vector2(18, 48) size = Vector2(34, 52)
[sub_resource type="RectangleShape2D" id="RectangleShape2D_hitbox"] [sub_resource type="RectangleShape2D" id="RectangleShape2D_hitbox"]
size = Vector2(48, 40) size = Vector2(48, 40)
+1 -1
View File
@@ -18,7 +18,7 @@
[ext_resource type="Script" path="res://scenes/components/frame_collision_driver.gd" id="18_frame_collision"] [ext_resource type="Script" path="res://scenes/components/frame_collision_driver.gd" id="18_frame_collision"]
[sub_resource type="RectangleShape2D" id="RectangleShape2D_body"] [sub_resource type="RectangleShape2D" id="RectangleShape2D_body"]
size = Vector2(24, 58) size = Vector2(52, 64)
[sub_resource type="RectangleShape2D" id="RectangleShape2D_hurtbox"] [sub_resource type="RectangleShape2D" id="RectangleShape2D_hurtbox"]
size = Vector2(28, 62) size = Vector2(28, 62)
+1 -1
View File
@@ -20,7 +20,7 @@
[ext_resource type="Script" path="res://scenes/components/overhead_health_bar.gd" id="18_overhead_health_bar"] [ext_resource type="Script" path="res://scenes/components/overhead_health_bar.gd" id="18_overhead_health_bar"]
[sub_resource type="RectangleShape2D" id="RectangleShape2D_body"] [sub_resource type="RectangleShape2D" id="RectangleShape2D_body"]
size = Vector2(20, 46) size = Vector2(36, 50)
[sub_resource type="RectangleShape2D" id="RectangleShape2D_hurtbox"] [sub_resource type="RectangleShape2D" id="RectangleShape2D_hurtbox"]
size = Vector2(24, 50) size = Vector2(24, 50)
+4
View File
@@ -21,6 +21,10 @@ script = ExtResource("1_ground_background")
[node name="GroundAnchor" type="Marker2D" parent="."] [node name="GroundAnchor" type="Marker2D" parent="."]
position = Vector2(2047, 560) position = Vector2(2047, 560)
[node name="SkyParallaxLayer" type="Node2D" parent="."]
position = Vector2(896.5, 0)
z_index = -11
[node name="ArtLayer" type="Node2D" parent="."] [node name="ArtLayer" type="Node2D" parent="."]
position = Vector2(896.5, 0) position = Vector2(896.5, 0)
z_index = -10 z_index = -10
+19 -17
View File
@@ -1,27 +1,26 @@
extends Node2D extends Node2D
## 2026-07-06 视差定案(策划:远景夕阳要时刻在画面内):背景是"远景天幕", ## 2026-07-07 视差修正:当前美术图里地面/步道和远景烙在同一张图上。
## 仅以 0.2 倍速随世界滚动(即 80% 跟随相机)——此前 0.92/0.8 的"近景层" ## 为了让角色脚下参照物保持 1:1 世界坐标,ArtLayer 先作为 WorldGroundLayer
## 参数方向反了,背景几乎钉在世界上,所以一直"看不出视差"。 ## 固定不随相机做视差。未来美术拆出天空/夕阳后,把远景节点放入
## 锚点 1734.5 = 让夕阳(原画 x≈760,世界 x≈1656)在出生点镜头(可见中心 ## SkyParallaxLayer,即可继续按 0.2 倍世界滚动(80% 跟随相机)。
## 1340.8)恰好居中;相机全程 [1340.8, 2753.2] 内夕阳屏幕位置从中心缓移到 ## 勿用 Parallax2D(历史上因 CanvasModulate/wipe 兼容性移除过,见 decisions.md)。
## 左 282px,始终在画面内(已验算含光晕不出屏)。两端背景覆盖也已验算:
## 极左窗 [880,1801.6]⊂[581.5,2882.5]、极右窗 [2292.4,3214]⊂[1711.5,4012.5]。
## 已知取舍:步道砖烙在原画里会明显随镜头走(角色脚下滑动感),单张烙死图
## 无解;美术把"近景步道"拆成独立 1:1 层后即可消除。勿用 Parallax2D
## (历史上因 CanvasModulate/wipe 兼容性移除过,见 decisions.md)。
const PARALLAX_SCROLL_SCALE := 0.2 const PARALLAX_SCROLL_SCALE := 0.2
const PARALLAX_ANCHOR_X := 1734.5 const PARALLAX_ANCHOR_X := 1734.5
@onready var past_background: CanvasItem = $ArtLayer/PastBackground @onready var past_background: CanvasItem = $ArtLayer/PastBackground
@onready var future_background: CanvasItem = $ArtLayer/FutureBackground @onready var future_background: CanvasItem = $ArtLayer/FutureBackground
@onready var _art_layer: Node2D = $ArtLayer @onready var _world_ground_layer: Node2D = $ArtLayer
@onready var _sky_parallax_layer: Node2D = get_node_or_null("SkyParallaxLayer") as Node2D
var _art_base_x := 0.0 var _world_ground_base_x := 0.0
var _sky_parallax_base_x := 0.0
func _ready() -> void: func _ready() -> void:
_art_base_x = _art_layer.position.x _world_ground_base_x = _world_ground_layer.position.x
if _sky_parallax_layer != null:
_sky_parallax_base_x = _sky_parallax_layer.position.x
_set_time_phase(_current_time_phase()) _set_time_phase(_current_time_phase())
var bus := _event_bus_or_null() var bus := _event_bus_or_null()
if bus != null and bus.has_signal("time_phase_changed") and not bus.is_connected("time_phase_changed", _on_time_phase_changed): if bus != null and bus.has_signal("time_phase_changed") and not bus.is_connected("time_phase_changed", _on_time_phase_changed):
@@ -29,14 +28,17 @@ func _ready() -> void:
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
_world_ground_layer.position.x = _world_ground_base_x
if _sky_parallax_layer == null:
return
var camera := get_viewport().get_camera_2d() var camera := get_viewport().get_camera_2d()
if camera == null: if camera == null:
# headless 测试/无相机场景:背景保持原位。 # headless 测试/无相机场景:远景层也保持原位。
_art_layer.position.x = _art_base_x _sky_parallax_layer.position.x = _sky_parallax_base_x
return return
# get_screen_center_position 含 limit 夹取,玩家贴墙时景不会继续滑。 # get_screen_center_position 含 limit 夹取,玩家贴墙时景不会继续滑。
var center_x := camera.get_screen_center_position().x var center_x := camera.get_screen_center_position().x
_art_layer.position.x = _art_base_x + (center_x - PARALLAX_ANCHOR_X) * (1.0 - PARALLAX_SCROLL_SCALE) _sky_parallax_layer.position.x = _sky_parallax_base_x + (center_x - PARALLAX_ANCHOR_X) * (1.0 - PARALLAX_SCROLL_SCALE)
func _on_time_phase_changed(_previous: StringName, current: StringName, _reason: StringName) -> void: func _on_time_phase_changed(_previous: StringName, current: StringName, _reason: StringName) -> void:
@@ -0,0 +1,216 @@
extends SceneTree
const PLAYER_BODY_MIN := Vector2(34, 52)
const MINION_BODY_MIN := Vector2(36, 50)
const BOSS_BODY_MIN := Vector2(52, 64)
const ENEMY_BODY_MASK := 1 << 6
var failures: Array[String] = []
func _init() -> void:
_run.call_deferred()
func _run() -> void:
root.size = Vector2i(1152, 648)
await process_frame
await _check_background_art_layer_is_world_locked()
await _check_body_shapes_match_visual_blocking_contract()
await _check_scene_body_rects_overlap_before_visible_interpenetration()
await _check_dash_through_ghost_query_uses_scene_body_shapes("Minion", "res://scenes/enemies/minion.tscn", 26.0, (PLAYER_BODY_MIN.x + MINION_BODY_MIN.x) * 0.5 + 2.0)
await _check_dash_through_ghost_query_uses_scene_body_shapes("Boss", "res://scenes/enemies/boss.tscn", 40.0, (PLAYER_BODY_MIN.x + BOSS_BODY_MIN.x) * 0.5 + 2.0)
_finish()
func _check_background_art_layer_is_world_locked() -> void:
var container := Node2D.new()
root.add_child(container)
var camera := Camera2D.new()
root.add_child(camera)
camera.global_position = Vector2(1734.5, 324.0)
camera.make_current()
var ground := (load("res://scenes/ground/ground1.tscn") as PackedScene).instantiate() as Node2D
container.add_child(ground)
await process_frame
await process_frame
var art_layer := ground.get_node("ArtLayer") as Node2D
var base_x := art_layer.position.x
camera.global_position.x += 640.0
await process_frame
await process_frame
_expect_float(art_layer.position.x, base_x, "Ground ArtLayer should stay locked to world x while camera moves")
camera.free()
container.free()
func _check_body_shapes_match_visual_blocking_contract() -> void:
var player := _instantiate_body("res://scenes/characters/player.tscn", "Player") as CharacterBody2D
var minion := _instantiate_body("res://scenes/enemies/minion.tscn", "Minion") as CharacterBody2D
var boss := _instantiate_body("res://scenes/enemies/boss.tscn", "Boss") as CharacterBody2D
_expect_vector_min(_body_shape_size(player), PLAYER_BODY_MIN, "Player body shape should cover the visual torso/feet")
_expect_vector_min(_body_shape_size(minion), MINION_BODY_MIN, "Minion body shape should cover the visual torso/feet")
_expect_vector_min(_body_shape_size(boss), BOSS_BODY_MIN, "Boss body shape should cover the visual torso/feet")
player.free()
minion.free()
boss.free()
func _check_scene_body_rects_overlap_before_visible_interpenetration() -> void:
var player := _instantiate_body("res://scenes/characters/player.tscn", "Player") as CharacterBody2D
var minion := _instantiate_body("res://scenes/enemies/minion.tscn", "Minion") as CharacterBody2D
var boss := _instantiate_body("res://scenes/enemies/boss.tscn", "Boss") as CharacterBody2D
_disable_enemy_ai(minion)
_disable_enemy_ai(boss)
player.global_position = Vector2(500.0, 300.0)
minion.global_position = Vector2(526.0, 300.0)
boss.global_position = Vector2(540.0, 300.0)
await physics_frame
_expect(_body_rects_overlap(player, minion), "Player/Minion bodies should still overlap at a 26px center gap")
_expect(_body_rects_overlap(player, boss), "Player/Boss bodies should still overlap at a 40px center gap")
minion.global_position = Vector2(500.0 + (PLAYER_BODY_MIN.x + MINION_BODY_MIN.x) * 0.5 + 2.0, 300.0)
boss.global_position = Vector2(500.0 + (PLAYER_BODY_MIN.x + BOSS_BODY_MIN.x) * 0.5 + 2.0, 300.0)
await physics_frame
_expect(not _body_rects_overlap(player, minion), "Player/Minion bodies should separate beyond the enlarged half-width sum")
_expect(not _body_rects_overlap(player, boss), "Player/Boss bodies should separate beyond the enlarged half-width sum")
player.free()
minion.free()
boss.free()
func _check_dash_through_ghost_query_uses_scene_body_shapes(label: String, scene_path: String, overlap_gap: float, clear_gap: float) -> void:
var player := _instantiate_body("res://scenes/characters/player.tscn", "Player") as CharacterBody2D
var enemy := _instantiate_body(scene_path, label) as CharacterBody2D
_disable_enemy_ai(enemy)
await process_frame
_expect(player.get_node_or_null("MotionExecutor") != null, "Player scene should include MotionExecutor for dash-through")
_expect(player.get_node_or_null("FrameCollisionDriver") != null, "Player scene should include FrameCollisionDriver")
_expect(enemy.get_node_or_null("FrameCollisionDriver") != null, "%s scene should include FrameCollisionDriver" % label)
player.global_position = Vector2(700.0, 300.0)
enemy.global_position = Vector2(700.0 + overlap_gap, 300.0)
await physics_frame
var motion := player.get_node("MotionExecutor")
motion.call("execute", _dash_through_action(), Vector2.RIGHT, 0.5, 220.0)
await physics_frame
_expect((int(player.collision_mask) & ENEMY_BODY_MASK) == 0, "dash_through should ghost enemy bodies before restore")
_expect(bool(motion.call("_overlaps_ghosted_bodies")), "dash_through ghost query should detect overlapped %s body shape" % label)
enemy.global_position = Vector2(700.0 + clear_gap, 300.0)
await physics_frame
_expect(not bool(motion.call("_overlaps_ghosted_bodies")), "dash_through ghost query should clear after %s body separation" % label)
motion.call("cancel")
_expect(not bool(motion.call("is_ghosting")), "dash_through should restore collision after %s separation" % label)
player.free()
enemy.free()
func _dash_through_action() -> Resource:
var script: Script = load("res://resources/action_data.gd")
var action: Resource = script.new()
var action_tags: Array[StringName] = [&"dash_through"]
action.set("id", &"test_scene_dash_through")
action.set("action_tags", action_tags)
action.set("move_mult_x", 1.0)
action.set("startup_beats", 0.25)
action.set("active_beats", 0.25)
action.set("recovery_beats", 0.25)
return action
func _instantiate_body(scene_path: String, label: String) -> CharacterBody2D:
var scene := load(scene_path) as PackedScene
if scene == null:
failures.append("%s scene should load from %s" % [label, scene_path])
return null
var actor := scene.instantiate() as CharacterBody2D
if actor == null:
failures.append("%s scene root should be CharacterBody2D" % label)
return null
root.add_child(actor)
return actor
func _disable_enemy_ai(actor: CharacterBody2D) -> void:
if actor == null:
return
var behavior := actor.get_node_or_null("MinionBehavior")
if behavior != null:
behavior.set("enabled", false)
var boss_behavior := actor.get_node_or_null("BossBehaviorTree")
if boss_behavior != null:
boss_behavior.set("enabled", false)
actor.set_physics_process(false)
func _body_shape_size(actor: CharacterBody2D) -> Vector2:
if actor == null:
return Vector2.ZERO
var shape_node := actor.get_node_or_null("CollisionShape2D") as CollisionShape2D
if shape_node == null or shape_node.shape == null:
failures.append("%s should have a main CollisionShape2D" % actor.name)
return Vector2.ZERO
var rect := shape_node.shape as RectangleShape2D
if rect == null:
failures.append("%s main body shape should be RectangleShape2D" % actor.name)
return Vector2.ZERO
return rect.size
func _body_rects_overlap(left_actor: CharacterBody2D, right_actor: CharacterBody2D) -> bool:
var left_rect := _body_world_rect(left_actor)
var right_rect := _body_world_rect(right_actor)
return left_rect.intersects(right_rect, true)
func _body_world_rect(actor: CharacterBody2D) -> Rect2:
if actor == null:
return Rect2()
var shape_node := actor.get_node_or_null("CollisionShape2D") as CollisionShape2D
if shape_node == null or shape_node.shape == null:
failures.append("%s should have a main CollisionShape2D" % actor.name)
return Rect2()
var rect_shape := shape_node.shape as RectangleShape2D
if rect_shape == null:
failures.append("%s main body shape should be RectangleShape2D" % actor.name)
return Rect2()
var center := shape_node.global_position
return Rect2(center - rect_shape.size * 0.5, rect_shape.size)
func _expect(condition: bool, label: String) -> void:
if not condition:
failures.append(label)
func _expect_float(actual: float, expected: float, label: String) -> void:
if absf(actual - expected) > 0.5:
failures.append("%s: expected %.2f, got %.2f" % [label, expected, actual])
func _expect_vector_min(actual: Vector2, expected_min: Vector2, label: String) -> void:
if actual.x + 0.5 < expected_min.x or actual.y + 0.5 < expected_min.y:
failures.append("%s: expected at least %s, got %s" % [label, expected_min, actual])
func _finish() -> void:
if failures.is_empty():
print("PASS parallax and body collision contract")
quit(0)
else:
for failure: String in failures:
push_error(failure)
quit(1)
@@ -0,0 +1 @@
uid://bdpqavi1hbe8t
+13 -9
View File
@@ -7,9 +7,9 @@ extends SceneTree
## 2. 趔趄门禁:击退滑行期间 Boss/小怪不得起手新动作(EnemyActionDriver ## 2. 趔趄门禁:击退滑行期间 Boss/小怪不得起手新动作(EnemyActionDriver
## 钩子),否则动作突进立刻接管 velocity 冲回玩家,把击退整个吃掉 ## 钩子),否则动作突进立刻接管 velocity 冲回玩家,把击退整个吃掉
## (真实 stage 实测净位移为负);Boss 受击链强制撤退豁免(清残速标志)。 ## (真实 stage 实测净位移为负);Boss 受击链强制撤退豁免(清残速标志)。
## 3. 关卡背景单层视差:背景水平速度为相机 0.8 倍(0.92 肉眼难辨已加强), ## 3. 关卡背景分层契约:当前整图里的地面/步道先锁在 1:1 世界层,避免脚下
## 锚点 2047,无相机保持原位;脚下地砖轻微滑动是单张烙死原画的已知 ## 地砖相对角色滑动;美术拆出的天空/夕阳放入 SkyParallaxLayer 后继续按
## 取舍,无伤视差待美术拆层 ## 0.2 倍世界滚动(相机 0.8 跟随)
## (Boss 击退 ×9 的加码钉在 test_round3_fixes。) ## (Boss 击退 ×9 的加码钉在 test_round3_fixes。)
var failures: Array[String] = [] var failures: Array[String] = []
@@ -147,24 +147,28 @@ func _check_background_parallax() -> void:
await process_frame await process_frame
await process_frame await process_frame
var art_layer := ground.get_node("ArtLayer") as Node2D var art_layer := ground.get_node("ArtLayer") as Node2D
var sky_layer := ground.get_node("SkyParallaxLayer") as Node2D
# 无相机:背景保持原位(headless/菜单兜底)。 # 无相机:世界层与可选远景层都保持原位(headless/菜单兜底)。
_expect_float(art_layer.position.x, 896.5, "无相机时背景保持基准位置") _expect_float(art_layer.position.x, 896.5, "无相机时地面世界层保持基准位置")
_expect_float(sky_layer.position.x, 896.5, "无相机时远景视差层保持基准位置")
# 有相机:偏移 = (相机中心 - 1734.5) × 0.8——"远景天幕"语义,背景 80% # 有相机:地面/步道保持 1:1 世界坐标;远景容器按
# 跟随相机,仅 0.2 倍速随世界滚动,保证远景夕阳时刻在画面内(策划定案) # (相机中心 - 1734.5) × 0.8 视差移动
var camera := Camera2D.new() var camera := Camera2D.new()
root.add_child(camera) root.add_child(camera)
camera.make_current() camera.make_current()
camera.global_position = Vector2(2547.0, 395.0) camera.global_position = Vector2(2547.0, 395.0)
await process_frame await process_frame
await process_frame await process_frame
_expect_float(art_layer.position.x, 896.5 + (2547.0 - 1734.5) * 0.8, "背景 80% 跟随相机(天幕视差") _expect_float(art_layer.position.x, 896.5, "相机右移时地面世界层不做视差")
_expect_float(sky_layer.position.x, 896.5 + (2547.0 - 1734.5) * 0.8, "相机右移时远景层 80% 跟随")
camera.global_position = Vector2(1547.0, 395.0) camera.global_position = Vector2(1547.0, 395.0)
await process_frame await process_frame
await process_frame await process_frame
_expect_float(art_layer.position.x, 896.5 + (1547.0 - 1734.5) * 0.8, "相机左移时天幕对称跟随") _expect_float(art_layer.position.x, 896.5, "相机左移时地面世界层仍不做视差")
_expect_float(sky_layer.position.x, 896.5 + (1547.0 - 1734.5) * 0.8, "相机左移时远景层对称跟随")
camera.free() camera.free()
ground.free() ground.free()