diff --git a/docs/new1.md b/docs/new1.md deleted file mode 100644 index 282f238..0000000 --- a/docs/new1.md +++ /dev/null @@ -1,11 +0,0 @@ -节奏输入有效范围规则 - -节奏条的中心位置为核心判定锚点。 - -在核心判定锚点左右两侧,各存在一段有效输入范围。该范围可以理解为“中心判定区”。 - -只有当普通节奏点或时间锚点进入这个中心判定区时,玩家按键才会被视为有效输入,并进入后续的节奏判定流程。 - -如果当前中心判定区内没有任何普通节奏点或时间锚点,则玩家按键视为无效输入。 - -无效输入不会触发动作,不会进入 BAD / MISS / GOOD / PERFECT 判定流程,也不会消耗节奏点。 \ No newline at end of file diff --git a/docs/new2.md b/docs/new2.md deleted file mode 100644 index cac1096..0000000 --- a/docs/new2.md +++ /dev/null @@ -1,7 +0,0 @@ - 开始界面标题固定 - * 开始界面的背景可以进行循环切换。 - * 但游戏标题不参与切换,不跟随背景变化。 - * 标题位置始终固定,避免背景切换时导致标题位置发生变化。 -3. 玩家初始生成位置 - * 游戏正式开始后,玩家角色应生成在地图最左侧。 - * 该位置作为关卡起点,玩家从左向右推进关卡。 \ No newline at end of file diff --git a/docs/new3.md b/docs/new3.md deleted file mode 100644 index 498f5f8..0000000 --- a/docs/new3.md +++ /dev/null @@ -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 -↓ -近战怪优先攻击 -↓ -强势相位下攻击可打断玩家 -↓ -命中后对玩家造成小幅击退 - -推荐击退距离: - -玩家被击退:80–120 px - -⸻ - -5. 远程怪行为逻辑 - -5.1 行为定位 - -远程怪负责远距离压制玩家。 - -它不能被玩家贴身无限连死,所以需要后撤和脱离机制。 - -⸻ - -5.2 距离规则 - -危险距离:0–220 px -理想距离:320–480 px -最大攻击距离:720 px - -⸻ - -5.3 基础行为流程 - -玩家进入攻击范围 -↓ -远程怪寻找理想距离 -↓ -保持 320–480 px 距离 -↓ -攻击预备 -↓ -发射远程攻击 -↓ -攻击冷却 -↓ -重新判断距离 - -⸻ - -5.4 玩家过近时的后撤逻辑 - -触发条件: - -玩家距离远程怪 < 220 px - -执行流程: - -停止攻击 -↓ -进入短暂脱离保护 -↓ -向远离玩家方向后撤 -↓ -恢复到 320–420 px 距离 -↓ -短暂停顿 -↓ -重新进入远程攻击循环 - -推荐数值: - -单次后撤距离:220 px -最小后撤后距离:320 px -最大后撤后距离:420 px -后撤后停顿:0.4 秒 - -⸻ - -5.5 被攻击后的脱离逻辑 - -触发条件满足任一即可: - -远程怪被玩家攻击命中 -玩家距离远程怪 < 160 px -远程怪在 1 秒内连续受击 2 次 - -执行流程: - -受击 -↓ -短暂无敌 -↓ -后撤 / 脱离 -↓ -恢复到安全距离 -↓ -取消无敌 -↓ -重新进入攻击循环 - -推荐数值: - -短暂无敌时间:0.35 秒 -后撤距离:220–260 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 近战相位 - -定位 - -主动靠近玩家 -使用高伤害近战攻击 -压迫玩家站位 - -目标距离 - -目标距离:100–160 px -攻击距离:180 px - -行为流程 - -判断玩家距离 -↓ -如果距离 > 180 px,向玩家靠近 -↓ -如果距离 ≤ 180 px,进入近战攻击预备 -↓ -攻击触发 -↓ -攻击后摇 -↓ -重新判断距离 - -伤害 - -Boss 近战伤害:25 - -打断 - -近战相位攻击可打断玩家 -玩家霸体动作除外 - -⸻ - -6.3 Boss 远程相位 - -定位 - -主动拉开距离 -使用低伤害远程攻击 -制造空间压制 - -目标距离 - -目标距离:360–520 px -最小安全距离:240 px -最大攻击距离:760 px - -行为流程 - -判断玩家距离 -↓ -如果玩家距离 < 240 px,Boss 后撤 -↓ -如果玩家距离在 360–520 px,使用远程攻击 -↓ -如果玩家距离 > 760 px,Boss 小幅靠近 -↓ -重新判断距离 - -伤害 - -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 - -执行流程: - -停止攻击 -↓ -后撤 -↓ -恢复到 360–480 px 距离 -↓ -短暂停顿 -↓ -继续远程攻击 - -推荐数值: - -Boss 后撤距离:280 px -后撤后目标距离:360–480 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. 最终规则汇总 - -近战怪 - -短距离 -高伤害 -主动靠近 -强势相位可打断玩家 -弱势相位容易被击败 -被逼墙角时不逃跑,使用攻击反压 - -远程怪 - -长距离 -低伤害 -保持 320–480 px 距离 -被近身后后撤 -被连续攻击后短暂无敌脱离 -被逼墙角后穿越玩家脱离 -脱离动作不造成伤害 - -Boss - -拥有近战 / 远程两套行为组 -随相位切换行为 -近战相位高伤害,主动压迫 -远程相位低伤害,主动拉开距离 -Boss 有韧性和阶段性霸体 -Boss 远程相位被近身时后撤 -Boss 被逼墙角时可脱离 -Boss 脱离动作不造成伤害 \ No newline at end of file diff --git a/docs/游戏说明.md b/docs/游戏说明.md new file mode 100644 index 0000000..1987124 --- /dev/null +++ b/docs/游戏说明.md @@ -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 用普通攻击回能,用技能压制。 diff --git a/scenes/characters/player.tscn b/scenes/characters/player.tscn index 4bf1d64..7ee3ec3 100644 --- a/scenes/characters/player.tscn +++ b/scenes/characters/player.tscn @@ -38,7 +38,7 @@ [ext_resource type="Resource" path="res://resources/effects/effect_on_landed_haste.tres" id="30_landed_haste"] [sub_resource type="RectangleShape2D" id="RectangleShape2D_player"] -size = Vector2(18, 48) +size = Vector2(34, 52) [sub_resource type="RectangleShape2D" id="RectangleShape2D_hitbox"] size = Vector2(48, 40) diff --git a/scenes/enemies/boss.tscn b/scenes/enemies/boss.tscn index fc576b9..2ce0467 100644 --- a/scenes/enemies/boss.tscn +++ b/scenes/enemies/boss.tscn @@ -18,7 +18,7 @@ [ext_resource type="Script" path="res://scenes/components/frame_collision_driver.gd" id="18_frame_collision"] [sub_resource type="RectangleShape2D" id="RectangleShape2D_body"] -size = Vector2(24, 58) +size = Vector2(52, 64) [sub_resource type="RectangleShape2D" id="RectangleShape2D_hurtbox"] size = Vector2(28, 62) diff --git a/scenes/enemies/minion.tscn b/scenes/enemies/minion.tscn index 0f33927..2b5607c 100644 --- a/scenes/enemies/minion.tscn +++ b/scenes/enemies/minion.tscn @@ -20,7 +20,7 @@ [ext_resource type="Script" path="res://scenes/components/overhead_health_bar.gd" id="18_overhead_health_bar"] [sub_resource type="RectangleShape2D" id="RectangleShape2D_body"] -size = Vector2(20, 46) +size = Vector2(36, 50) [sub_resource type="RectangleShape2D" id="RectangleShape2D_hurtbox"] size = Vector2(24, 50) diff --git a/scenes/ground/ground1.tscn b/scenes/ground/ground1.tscn index e4ad919..e9a750a 100644 --- a/scenes/ground/ground1.tscn +++ b/scenes/ground/ground1.tscn @@ -21,6 +21,10 @@ script = ExtResource("1_ground_background") [node name="GroundAnchor" type="Marker2D" parent="."] position = Vector2(2047, 560) +[node name="SkyParallaxLayer" type="Node2D" parent="."] +position = Vector2(896.5, 0) +z_index = -11 + [node name="ArtLayer" type="Node2D" parent="."] position = Vector2(896.5, 0) z_index = -10 @@ -56,4 +60,4 @@ position = Vector2(3213.5, 150) collision_mask = 0 [node name="CollisionShape2D" type="CollisionShape2D" parent="RightBoundaryBody"] -shape = SubResource("RectangleShape2D_right_boundary") \ No newline at end of file +shape = SubResource("RectangleShape2D_right_boundary") diff --git a/scenes/ground/ground_background.gd b/scenes/ground/ground_background.gd index f9444bd..b9ff292 100644 --- a/scenes/ground/ground_background.gd +++ b/scenes/ground/ground_background.gd @@ -1,27 +1,26 @@ extends Node2D -## 2026-07-06 视差定案(策划:远景夕阳要时刻在画面内):背景是"远景天幕", -## 仅以 0.2 倍速随世界滚动(即 80% 跟随相机)——此前 0.92/0.8 的"近景层" -## 参数方向反了,背景几乎钉在世界上,所以一直"看不出视差"。 -## 锚点 1734.5 = 让夕阳(原画 x≈760,世界 x≈1656)在出生点镜头(可见中心 -## 1340.8)恰好居中;相机全程 [1340.8, 2753.2] 内夕阳屏幕位置从中心缓移到 -## 左 282px,始终在画面内(已验算含光晕不出屏)。两端背景覆盖也已验算: -## 极左窗 [880,1801.6]⊂[581.5,2882.5]、极右窗 [2292.4,3214]⊂[1711.5,4012.5]。 -## 已知取舍:步道砖烙在原画里会明显随镜头走(角色脚下滑动感),单张烙死图 -## 无解;美术把"近景步道"拆成独立 1:1 层后即可消除。勿用 Parallax2D -## (历史上因 CanvasModulate/wipe 兼容性移除过,见 decisions.md)。 +## 2026-07-07 视差修正:当前美术图里地面/步道和远景烙在同一张图上。 +## 为了让角色脚下参照物保持 1:1 世界坐标,ArtLayer 先作为 WorldGroundLayer +## 固定不随相机做视差。未来美术拆出天空/夕阳后,把远景节点放入 +## SkyParallaxLayer,即可继续按 0.2 倍世界滚动(80% 跟随相机)。 +## 勿用 Parallax2D(历史上因 CanvasModulate/wipe 兼容性移除过,见 decisions.md)。 const PARALLAX_SCROLL_SCALE := 0.2 const PARALLAX_ANCHOR_X := 1734.5 @onready var past_background: CanvasItem = $ArtLayer/PastBackground @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: - _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()) 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): @@ -29,14 +28,17 @@ func _ready() -> 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() if camera == null: - # headless 测试/无相机场景:背景保持原位。 - _art_layer.position.x = _art_base_x + # headless 测试/无相机场景:远景层也保持原位。 + _sky_parallax_layer.position.x = _sky_parallax_base_x return - # get_screen_center_position 含 limit 夹取,玩家贴墙时背景不会继续滑。 + # get_screen_center_position 含 limit 夹取,玩家贴墙时远景不会继续滑。 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: @@ -63,4 +65,4 @@ func _current_time_phase() -> StringName: func _event_bus_or_null() -> Node: if not is_inside_tree(): return null - return get_tree().root.get_node_or_null("EventBus") \ No newline at end of file + return get_tree().root.get_node_or_null("EventBus") diff --git a/tests/test_parallax_and_body_collision_contract.gd b/tests/test_parallax_and_body_collision_contract.gd new file mode 100644 index 0000000..66924fe --- /dev/null +++ b/tests/test_parallax_and_body_collision_contract.gd @@ -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) diff --git a/tests/test_parallax_and_body_collision_contract.gd.uid b/tests/test_parallax_and_body_collision_contract.gd.uid new file mode 100644 index 0000000..0afb7fd --- /dev/null +++ b/tests/test_parallax_and_body_collision_contract.gd.uid @@ -0,0 +1 @@ +uid://bdpqavi1hbe8t diff --git a/tests/test_round4_fixes.gd b/tests/test_round4_fixes.gd index 61ff018..f1f5ed1 100644 --- a/tests/test_round4_fixes.gd +++ b/tests/test_round4_fixes.gd @@ -7,9 +7,9 @@ extends SceneTree ## 2. 趔趄门禁:击退滑行期间 Boss/小怪不得起手新动作(EnemyActionDriver ## 钩子),否则动作突进立刻接管 velocity 冲回玩家,把击退整个吃掉 ## (真实 stage 实测净位移为负);Boss 受击链强制撤退豁免(清残速标志)。 -## 3. 关卡背景单层视差:背景水平速度为相机 0.8 倍(0.92 肉眼难辨已加强), -## 锚点 2047,无相机保持原位;脚下地砖轻微滑动是单张烙死原画的已知 -## 取舍,无伤视差待美术拆层。 +## 3. 关卡背景分层契约:当前整图里的地面/步道先锁在 1:1 世界层,避免脚下 +## 地砖相对角色滑动;美术拆出的天空/夕阳放入 SkyParallaxLayer 后继续按 +## 0.2 倍世界滚动(相机 0.8 跟随)。 ## (Boss 击退 ×9 的加码钉在 test_round3_fixes。) var failures: Array[String] = [] @@ -147,24 +147,28 @@ func _check_background_parallax() -> void: await process_frame await process_frame var art_layer := ground.get_node("ArtLayer") as Node2D + var sky_layer := ground.get_node("SkyParallaxLayer") as Node2D - # 无相机:背景保持原位(headless/菜单兜底)。 - _expect_float(art_layer.position.x, 896.5, "无相机时背景保持基准位置") + # 无相机:世界层与可选远景层都保持原位(headless/菜单兜底)。 + _expect_float(art_layer.position.x, 896.5, "无相机时地面世界层保持基准位置") + _expect_float(sky_layer.position.x, 896.5, "无相机时远景视差层保持基准位置") - # 有相机:偏移 = (相机中心 - 1734.5) × 0.8——"远景天幕"语义,背景 80% - # 跟随相机,仅 0.2 倍速随世界滚动,保证远景夕阳时刻在画面内(策划定案)。 + # 有相机:地面/步道保持 1:1 世界坐标;远景容器按 + # (相机中心 - 1734.5) × 0.8 视差移动。 var camera := Camera2D.new() root.add_child(camera) camera.make_current() camera.global_position = Vector2(2547.0, 395.0) 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) 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() ground.free()