fix: 修复迁移后的基础UI显示问题

This commit is contained in:
cancerai committed 2026-10-25 16:03:05 +08:00
1 parent 18017f819a
commit a93e06f60c
68 files changed
+1865 -7976

No files matched your search

+10
View File
@@ -178,3 +178,13 @@
- **`withOpacity` 弃用清理**:5 处换成 `Color.withValues(alpha: o*255)`(本 SDK Dart 3.12 的 `withValues` 只有 `alpha` 参数、无 `opacity`;`opacity` 写法会编译失败,已实测回退)。`RawKeyboard` 等其余弃用属输入层 API、仍可用、保持不动(重写风险大收益小)。 - **`withOpacity` 弃用清理**:5 处换成 `Color.withValues(alpha: o*255)`(本 SDK Dart 3.12 的 `withValues` 只有 `alpha` 参数、无 `opacity`;`opacity` 写法会编译失败,已实测回退)。`RawKeyboard` 等其余弃用属输入层 API、仍可用、保持不动(重写风险大收益小)。
- **最终确认**:`flutter analyze` **0 error**(约 201 条 info 级 lint,主要是 web 常量大写命名 style,不影响构建);`flutter test` **13/13 通过**(gameplay 物理保真 4 项 + title 渲染/横屏/手柄 8 项 + AppRoot smoke 1 项)。物理保真:JUMP 6.6/GRAV 0.25、可变跳高、死亡不自动重生按 R 回存档点均与 web 一致。 - **最终确认**:`flutter analyze` **0 error**(约 201 条 info 级 lint,主要是 web 常量大写命名 style,不影响构建);`flutter test` **13/13 通过**(gameplay 物理保真 4 项 + title 渲染/横屏/手柄 8 项 + AppRoot smoke 1 项)。物理保真:JUMP 6.6/GRAV 0.25、可变跳高、死亡不自动重生按 R 回存档点均与 web 一致。
- **用户侧交付**:本沙箱无法跑原生构建,需用户在本地 `app/` 执行 `flutter pub get` → `flutter build apk`(安卓)/ `flutter build macos` / `flutter build linux` / `flutter build windows`;iOS 触屏检测仍可拓宽。 - **用户侧交付**:本沙箱无法跑原生构建,需用户在本地 `app/` 执行 `flutter pub get` → `flutter build apk`(安卓)/ `flutter build macos` / `flutter build linux` / `flutter build windows`;iOS 触屏检测仍可拓宽。
### Flutter 真机首跑反馈修复:标题溢出 + Build scheduled during frame(同日晚)
- 用户真机(安卓横屏,锁屏生效——截图相机孔在左侧短边)反馈:① 标题菜单 `BOTTOM OVERFLOWED BY 125 PIXELS`;② 控制台刷 `Build scheduled during frame`。
- **② 根因**:`world.render()`(paint 阶段)里调 `_pushToast(L.toast)` → `toast.value` 变化,而 `toast` 被 HudOverlay 的 ValueListenableBuilder 监听 → paint 阶段 markNeedsBuild。修复:把 toast 推送从 `render()` 移到 `tick()`(Ticker 回调 = transientCallbacks 阶段,合法),并顺带从 update() 的 hudTick%5 块改为每帧推送(更贴 web 逐帧行为)。**通则:paint/build 阶段绝不能写被 ValueListenableBuilder 监听的 notifier;一切 host 回调只允许在 tick() 里发。**
- **① 根因**:横屏真机逻辑高度只有 ~360-400px(1080 物理 ÷ DPR),标题 Column 内容 ~455px → 底部溢出 125px。之前测试没抓到是因为 `setSurfaceSize(2340×1080)` 设的是**逻辑**像素,比真机大得多。修复:
- `screens.dart` 新增 `_FitScroll`(LayoutBuilder + SingleChildScrollView + ConstrainedBox(minHeight) + 保底高度):空间够时按原布局、不够时整体可滚动。应用于 TitleScreen(Column 已 center)。
- SettingsScreen 同样包 `_FitScroll`,纵向 `Spacer()` 换 `SizedBox(height:16)`(Spacer 在无界高度滚动容器里会抛异常;底部 Row 里的横向 Spacer 保留)。
- LevelSelectScreen 不动(含 Expanded 的列表布局,ListView 自滚)。
- **通则:`_FitScroll` 内部禁止 Spacer/Expanded/Flexible。**
- 回归:`title_render_test.dart` 新增「横屏小逻辑高度(800×360)下标题不溢出」用例(pump 即可,overflow 异常会直接判失败;另 scrollUntilVisible 验证底部版权行滚动可达)。当前 `flutter analyze` 0 error、`flutter test` **14/14 通过**。
+86
View File
@@ -0,0 +1,86 @@
# 2026-10-25
## 移动端沉浸式全屏(用户诉求:手机端整个游戏应沉浸式全屏显示)
之前只锁了横屏,但系统状态栏/导航栏仍可见,不是真正的沉浸。
改动文件:
- `app/lib/main.dart`:`_configureMobile()` 加 `SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky)`(隐藏状态栏+导航栏;滑出后自动收回,最适合游戏)。
- `app/android/app/src/main/res/values/styles.xml` 与 `values-night/styles.xml`:LaunchTheme/NormalTheme 加 `android:windowFullscreen=true`(冷启动即隐藏系统 UI,避免首帧闪现)。
- 新建 `values-v28/styles.xml` 与 `values-night-v28/styles.xml`:两主题再加 `android:windowLayoutInDisplayCutoutMode=shortEdges`,让画面延伸到刘海/挖孔区真正铺满(该属性仅 API28+,必须放 v28 限定目录否则低版本 aapt 编译报找不到资源)。
- `app/ios/Runner/Info.plist`:加 `UIViewControllerBasedStatusBarAppearance=false` + `UIStatusBarHidden=true`(launch 阶段即隐藏状态栏);并把 `UISupportedInterfaceOrientations` 与 `~ipad` 收紧为只横屏(与安卓一致)。
- `app/lib/src/app_root.dart`:`_AppRootState` 加 `WidgetsBindingObserver`,`didChangeAppLifecycleState` 在 `resumed` 时重设 `immersiveSticky`(切后台再回来不会丢沉浸,常规游戏标准做法)。
验证:`flutter analyze` 0 error;`flutter test` 14/14 通过。
说明:信箱(letterbox)黑边保留——游戏按比例完整显示在画面矩形内、外围黑边,这正是常规横版游戏的表现;沉浸仅指隐藏系统 UI、游戏铺满屏幕。iOS 底部 Home 指示条无法用公开 API 完全隐藏,属平台限制。沙箱仍无法跑原生 `flutter build`,需在真机 `flutter pub get && flutter run` 验证。
## 全屏铺满(去信箱黑边)+ 系统返回 = 游戏内上一页(用户二次诉求)
用户不接受信箱黑边("游戏内部显示也要全屏"),且系统返回手势不应退出游戏。
改动:
- `app/lib/src/ui/game_canvas.dart`:`letterboxRect` 改为 `stageMetrics(Size)` —— 视口保证 ≥800×608 完整可见,另一维随屏幕比例加宽铺满全屏(横屏手机:高度定标 608、vw=屏宽/scale≈1318;比游戏窄的窗口则宽度定标 800)。Painter 每帧 paint 前 `world.setViewport(vw,vh)`(只改纯数值,paint 阶段禁止 notifier)。
- `app/lib/src/game/world.dart`:新增 `viewW/viewH` 字段 + `setViewport()`;`startLevel` 用 `Camera(viewW,viewH)`;render/drawTitleBg 全部改用 viewW/viewH(含死亡提示居中、标题星空/刺地带)。摄像机自带关卡边界钳制,关卡比视口窄时自动居中 —— 横向看得多是"PC 游戏支持任意宽高比"的同款做法,不裁内容、不变形。
- `app/lib/src/ui/touch_controls.dart`:按钮定位从 letterboxRect 改为整屏 Rect(画面已铺满,无黑边可飘)。
- `app/lib/src/app_root.dart`:`MaterialApp.home` 包 `PopScope(canPop:false, onPopInvokedWithResult)` → `_handleSystemBack()`:playing→pause;paused→resume;result→toLevels;editor→toTitle;scr-levels/scr-settings→toTitle;标题页忽略(防误退)。
- 测试:`title_render_test.dart` 重写画布断言(铺满全屏、视口≥800×608)、手柄断言改整屏锚定;新增 2 个返回导航用例(关卡选择→标题、游戏中→暂停→继续,用 `tester.binding.handlePopRoute()` 模拟)。
验证:analyze 0 error;`flutter test` 16/16 通过。
## 修复「跳跃键经常失灵」(用户反馈)
根因有两处叠加:
1. **虚拟键卡住常按(主因)**:`touch_controls.dart` 的按钮用 `Listener`(不捕获指针)。手指在键上滑动后于键外松手时,松手事件落到别的控件 → `touch['jump']` 永久卡在 `true`;之后每次按下都因 `Input.setTouch` 的「值未变则 return」短路,触发不了新边沿 → `hit('jump')` 恒 false,跳不起来、时好时坏。`_TouchButton._release` 还有 `if(!_pressed) return` 进一步放任卡键。
- 修:`GestureDetector(behavior: HitTestBehavior.opaque, onPanDown/onPanEnd/onPanCancel)` 替代 `Listener`(Pan 识别器会认领指针,键外抬起/取消也回传本按钮);`_release` 改为**无论如何都强制 `setTouch(a,false)` 复位**(去掉 `_pressed` 早退)。
2. **按钮重叠误触**:旧默认布局大号跳跃键(94px)与射击/下蹲键在纵向像素重叠,重叠区按下会落到上层(射击)键。`BUTTONS` 重排让 `jump` 居末(绘制最上层,重叠区优先归跳跃键),并微调坐标消除重叠。
3. **正确性问题(顺带)**:`world.tick` 的 `Input.endFrame()` 原本整个 tick 调一次;合帧时某帧跑 0 次更新、下一帧跑 2 次,会让一次按键被当两帧 hit → 误触二段跳。改为**每个固定步长子步都调一次 `endFrame()`**(now 在两子步间保持,下一子步才清 prev,不丢按键)。
新增回归测试:模拟「跳跃键上按下→滑到屏幕外松手」,断言 `Input.held('jump')` 最终为 false(验证不再卡键)。
验证:analyze 0 error;`flutter test` 17/17 通过。注:用户设备已保存旧触屏布局(首跑写入),默认布局改动只影响新装/「恢复默认」;卡键修复与布局无关,对其现设备直接生效。
## 首页去滚动(改等比缩小)+ 编辑器溢出修复 + 关卡选择去色块(用户反馈)
1. **首页滚动被用户否决**:`screens.dart` TitleScreen 的 `_FitScroll` 改为 `FittedBox(fit: BoxFit.scaleDown)` + `Column(mainAxisSize: min)` —— 放得下按原尺寸、放不下整体等比缩小居中,既不滚动也不溢出。设置屏保留 `_FitScroll`(内容 ~600px 缩到 310px 会小到不可读,滚动是合理方案)。
2. **编辑器 RIGHT OVERFLOWED BY 48 PIXELS**:根因是 `ElevatedButton` 默认 `minimumSize(64,36)` + `MaterialTapTargetSize.padded`,属性栏 200px 宽塞不下三个 `_SmallButton`。修复:两个文件的 `_SmallButton` 加 `minimumSize: Size.zero` + `tapTargetSize: shrinkWrap` + `visualDensity: compact`;顶部工具行 Row→`Wrap(spacing:8, runSpacing:6)`(窄屏自动换行);面操作行 Row→Wrap;`_portalRow` Row→Wrap(双下拉可能超宽)。
3. **关卡选择去掉蓝绿黄色块**:删掉底部 `_Tag` 图例 Wrap 和卡片内的类型 `_Tag`(用户:没什么用;分类已有 Tab 承担);`_Tag`/`_tagColor`/`typeLabel`/`typeClass` 一并删除,import 清理为仅 LEVELS。卡片保留编号/名称/难度星星/通关状态。
4. 测试:小高度标题用例从 `scrollUntilVisible` 改为断言 footer 一屏可见;新增「编辑器 800×360 不溢出」回归(EditorController.open(25,19) 真渲染)。`flutter test` 18/18、analyze 0 error。
## 「自定义按键」独立全屏页(用户需求)
- **新文件 `lib/src/ui/touch_editor.dart`**:`TouchLayoutEditor` 全屏编辑页,作为 AppRoot 的浮层 `scr-touchedit`(透出下方实时游戏画布=标题星空动画,与玩游戏同等沉浸)。按钮用与 `_TouchButton` 完全一致的渲染与 `k=(w/W).clamp(0.6,2.6)` 缩放(所见即所得);点选后出现右下角黄点手柄拖动改大小(30~180);拖动/缩放**即改即存**(writeTouchLayout→metaSettings+Save.flush),返回无需确认;顶部「恢复默认/返回」,底部提示。
- **入口**:设置页触摸区去掉内嵌 `_TouchPreview`(连同 `_DragBtn`/`_btnLabel`/`_cloneTouch`/`_touch` 工作副本一并删除,touch 布局不再由设置页代管,_confirm 不再写 s['touch']),改为「自定义按键」按钮 → `screen.value='scr-touchedit'`;系统返回手势映射为回设置页。
- **默认布局仿用户截图**(touch_layout.dart BUTTONS):◀(12.5,81,62) ▶(22,81,62) 🔫(78.5,90,56) ▼(94.5,89,56) ❚❚(96.5,8,40) ↺(91,56,52) 跳(85,73,78)。注意:老设备存档里已有旧布局,默认值只影响新装/恢复默认。
- **血泪**:macOS 沙箱里 `sed -i ''` 静默成功但参数被吞 → python 二次删除同一区间,误删 screens.dart 的 `_FitScroll/_MenuBg/_MenuButton/_SmallButton/_Tab/_Modal`;已从本会话早前读过的完整内容精确重建(_SmallButton 带紧凑样式)。教训:批量删行一律用 python/专用工具,别混用 sed -i。
- **测试坑**:widget 测试里 `startGesture+单次 moveBy(60)` 不触发 pan(单大步位移不产出 update),改用 `tester.dragFrom` 或小步 move+pump 才有效。新测试:设置→自定义按键→全屏 7 键齐全→拖动跳跃键位置变化→返回设置。
- 验证:analyze 0 error;`flutter test` 18/18。
## 结算界面 BOTTOM OVERFLOWED BY 43px + 编辑器调键盘溢出 5.8px(用户反馈)
- **结算 `_Modal`**(暂停/结算共用):外层 Column(min) 里内容区包 `Flexible(SingleChildScrollView)`(min Column + Flexible 的标准做法:内容少时按原尺寸居中、超出时内部滚动)。横屏 ~360 逻辑高下「标题+4 行数据+新纪录+3 按钮」约 403px 必然超。
- **编辑器调键盘溢出**:`app_root` Scaffold 加 `resizeToAvoidBottomInset: false` —— 游戏类 App 不让软键盘压缩界面(一压缩固定头部就溢出);所有输入框(地图名/宽/高/用户名)都在上半区,键盘不遮挡。
- 顺带:编辑器 `_ImportModal` 地图列表同样包 Flexible+滚动(地图多时也会溢出)。
- 回归测试:800×360 下直泵 `ResultScreen`(better=true + hasNext=true 最长内容),断言无异常、三按钮可见。`flutter test` 19/19、analyze 0 error。
## 结算弹窗改回「紧凑固定、不可滚动、不撑满」(用户推翻上一轮方案)
用户明确反对上一轮的可滚动方案:「地图通关不能设计成可滚动的,而且高度不要撑满,太丑了」。
- **根因**:`Flexible(SingleChildScrollView)` 在 `Center` 下会把内容区撑到可用全高 → 弹窗高度=全屏高("撑满"),用户觉得丑。
- **新方案**:删掉 `Flexible`/`SingleChildScrollView`,`_Modal` 改 `Column(mainAxisSize.min)` + `Center`,高度只包住内容、不撑满。同时整体压紧凑以在 ~360 逻辑高横屏内不溢出:
- `lib/src/ui/screens.dart`:`_Modal` width 320→300、padding 20→14、标题 26→20、标题后间距 16→10;`_ResultLine` 行距 3→1、字号 15→14;`_MenuButton` 外距 5→3、内 padding 12→8、字号 16→14;`ResultScreen` 按钮前间距 12→8、新纪录上行距 6→4/字号16→15。
- `lib/src/app_root.dart`:三处 `MaterialApp` 全部加 `debugShowCheckedModeBanner:false` —— 去掉用户所说的「app右上角的bug标」(即 Flutter 调试模式默认右上角 DEBUG 角标)。
- **验证**:800×360 直泵 `ResultScreen`(better+hasNext 最长内容)不再 OVERFLOW;`flutter test` 19/19、`flutter analyze` 0 error。
- 教训:弹窗类 UI 在横屏小高度下,宁可用「紧凑固定尺寸」而非「可滚动」,用户对滚动版接受度低(觉得丑/撑满)。
## 关卡选择界面滑动报 Incorrect use of ParentDataWidget(修复)
- **根因**:`_LevelCard` 的「已通关 ✔ 角标」原来是 `Positioned` 直接放在**内层 `Column`** 里。但 `Positioned` 必须是 `Stack` 的直接子级(它外层的 Stack 隔着 `GestureDetector→Container→Column`),一旦某张已通关卡片被构建/滚入视图就抛 `Incorrect use of ParentDataWidget`。GridView 懒构建 → 表现为「滑动到某张卡片时才报错」。
- **修复**:把那枚 `Positioned` 角标从内层 Column 删掉,改为**外层 Stack 的直接子级**(与删除按钮的 `Positioned` 并列),只对 `rec != null` 显示。
- **回归测试**:新增 `关卡选择:已通关卡片的 ✔ 角标不报 ParentDataWidget 错误`——`Save.clear` 标记首关已通关后直泵 `LevelSelectScreen`,断言无异常且 `✔` 正常渲染。
- 验证:`flutter test` 20/20、`flutter analyze` 0 error。
## 离开编辑器后再进入报 setState() called after dispose()(修复)
- **根因**:`_EditorPanelState.initState` 把 `setState` 闭包直接挂到**共享** `EditorController.onChange`(闭包捕获当前 State);但 State 没有 `dispose()` 清掉它。`world.toEditor()` 在 `host.onShowScreen` 设屏之后**同步**调 `host.editorShow()` → `editor.showOrOpen()` → `onChange?.call()`(见 editor_panel.dart `showOrOpen`/`open` 等)。离开编辑器时旧 State 已 dispose、`onChange` 仍指向它,再次进入的那次同步 `onChange?.call()` 就对死 State 调 `setState` → 报错。
- **修复**(`lib/src/ui/editor_panel.dart`):① 把闭包存为 `_onChange` 字段并加 `if (mounted)` 守卫;② 新增 `dispose()`——若 `onChange == _onChange` 则置 `null`,并 `dispose()` 三个 `TextEditingController`(此前一直没 dispose,轻微泄漏);③ `initState` 改为赋值 `_onChange`。
- **回归测试**:新增 `进出编辑器多次不报 setState-after-dispose`——`_boot` 后进编辑器→点「返回」离开→再进,断言 `AppError.current` 为 null 且 `EditorPanel` 正常重建。
- 验证:`flutter test` 21/21、`flutter analyze` 0 error。
## 点开第二个地图报 type 'int' is not a subtype of type 'String?'(修复)
- **崩溃栈**:`Level.reset (level.dart:257) ← new Level ← World.startLevel ← LevelSelectScreen._play`。报错发生在构造含 `warp` 实体、但 `data.doors` 里找不到对应门的关卡时。
- **根因**:web 原版 `tmp/js/level.js:133` 是 `e.doorId = door ? door.id : e.idx;`——JS 弱类型下 `doorId` 既可是字符串也可是数字(int),`===` 比较同名类型即匹配。Flutter 移植把 `Entity.doorId` 声明成 `String?`(level.dart:62),无匹配门时兜底 `idx`(int)赋给 `String?` 即抛 TypeError。内置 23 关无 `doors/warps` 故不触发;自制关卡含 warp 且无匹配门即崩("先玩一个再开另一个"只是第二个关卡恰好有该内容)。
- **修复**(`lib/src/game/level.dart`):
1. `doorId` 字段由 `String?` 改为 `Object?`(容纳 String 或 int,与 web 一致);`world.dart` 里两处 `o.doorId == to['door']` 比较对 String/String、int/int 走值相等,语义不变。
2. 顺手加固同行的 null 兜底:`(data['doors'] as List?)?.cast<Map>()` 在 `doors` 为 null 时会因 `.firstWhere` 调在 null 上抛 `NoSuchMethodError`,改为 `?? const <Map>[]`,消除同类崩溃。
- **回归测试**:新增 `含 warp 但无匹配门的关卡可正常构建(doorId 类型回归)`——构造含 `W` 且 `doors:[]` 的关卡,`Level(data)` 不再抛 TypeError,且 warp 实体的 `doorId` 为 int(回退序号)。
- 验证:`flutter test` 22/22、`flutter analyze` 0 error。
+5 -1
View File
@@ -62,11 +62,15 @@
## Flutter 移植版(`app/`,脱胎于本 web 源码) ## Flutter 移植版(`app/`,脱胎于本 web 源码)
目标是 android+macos+linux+windows 四平台 100% 复刻 UI/功能/手感。核心约定: 目标是 android+macos+linux+windows 四平台 100% 复刻 UI/功能/手感。核心约定:
- **渲染**:自定义 `Ticker` + `CustomPaint`,信箱缩放 800×608 → 适配屏幕;`GameCanvas` 用 `SizedBox.expand`(不要用 `Size.infinite`,零尺寸会让 scale=0 整屏消失)。 - **渲染**:自定义 `Ticker` + `CustomPaint`;**全屏铺满视口**(用户明确不要信箱黑边):`stageMetrics(Size)` 保证 800×608 完整可见、另一维随屏幕比例加宽(横屏手机=高度定标 608、vw≈1318),paint 前 `world.setViewport(vw,vh)` 同步给 Camera;render/drawTitleBg 一律用 `viewW/viewH`,别写回常量 W/H。`GameCanvas` 用 `SizedBox.expand`(不要用 `Size.infinite`,零尺寸会让 scale=0 整屏消失)。
- **系统返回手势 = 游戏内上一页**(用户明确不要退出 App):`PopScope(canPop:false)` → `_handleSystemBack()`(playing→暂停、paused→继续、result→关卡选择、settings/levels/editor→标题、标题页忽略)。
- **循环**:`World.tick(dt)` 固定 60Hz 累加器(`STEP=1000/60`),`Player.update(1)` 定步长 —— 手感 1:1 的关键,别改成 dt 缩放。 - **循环**:`World.tick(dt)` 固定 60Hz 累加器(`STEP=1000/60`),`Player.update(1)` 定步长 —— 手感 1:1 的关键,别改成 dt 缩放。
- **输入**:Flutter 按键经 `LogicalKeyboardKey.debugName` 去空格 → 喂给 `Input.onKey()`,得到与 web `KeyboardEvent.code` 完全相同的字符串(`ShiftLeft/KeyZ/KeyR/Backspace/Escape`)。 - **输入**:Flutter 按键经 `LogicalKeyboardKey.debugName` 去空格 → 喂给 `Input.onKey()`,得到与 web `KeyboardEvent.code` 完全相同的字符串(`ShiftLeft/KeyZ/KeyR/Backspace/Escape`)。
- **`Positioned` 必须是 `Stack` 的直接子级**(血泪):把它写在 `ValueListenableBuilder.builder` 的返回值里会丢弃定位数据 → 子树横向塌缩为 0 宽 → 表现为"黑屏"。一律写成 `Positioned(child: ValueListenableBuilder(...))`。 - **`Positioned` 必须是 `Stack` 的直接子级**(血泪):把它写在 `ValueListenableBuilder.builder` 的返回值里会丢弃定位数据 → 子树横向塌缩为 0 宽 → 表现为"黑屏"。一律写成 `Positioned(child: ValueListenableBuilder(...))`。
- **凡是"某状态决定某子树显隐",该状态必须可监听**:`World.state` 已改由 `ValueNotifier<String> stateN` 承载(曾在 `build()` 里读普通字段导致 HUD 永不出现)。 - **凡是"某状态决定某子树显隐",该状态必须可监听**:`World.state` 已改由 `ValueNotifier<String> stateN` 承载(曾在 `build()` 里读普通字段导致 HUD 永不出现)。
- **paint/build 阶段绝不能写被监听的 notifier**(血泪):曾在 `world.render()`(paint 阶段)里推 toast → 控制台刷 "Build scheduled during frame"。通则:一切 host 回调(onToast/onHud/onShowScreen…)只允许在 `World.tick()`(Ticker 回调 = transientCallbacks 阶段)里发,render() 只画不通知。
- **菜单屏用 `_FitScroll` 防小逻辑高度溢出**(screens.dart):横屏真机逻辑高度仅 ~360-400px,静态 Column 必溢出;`_FitScroll` 内部**禁止 Spacer/Expanded/Flexible**(无界高度会抛异常),列表屏用 ListView 自滚。测试注意:`setSurfaceSize` 设的是**逻辑**像素,模拟真机要设 ~800×360 而不是物理分辨率。
- **横屏锁定**:AndroidManifest `screenOrientation="sensorLandscape"`(冷启动首帧即横屏)+ main.dart 对 android/iOS `SystemChrome.setPreferredOrientations`;manifest 改动必须重装 App 才生效(hot reload 无效)。
- **音频不能拖垮游戏**:`Sfx` 有 `broken` 永久静音开关;`AppError.reportZone` 会把 audioplayers 的插件噪声降级为日志。 - **音频不能拖垮游戏**:`Sfx` 有 `broken` 永久静音开关;`AppError.reportZone` 会把 audioplayers 的插件噪声降级为日志。
- **测试**:widget 测试永远覆盖不到 `main()` 的 bootstrap;断言要用 `tester.getSize()` 查**可见尺寸**(`find` 不查可见性)。游戏有常驻 Ticker → **禁用 `pumpAndSettle`**。原生构建需真实机器;本沙箱 Flutter 写不了自己的 `/opt/flutter/bin/cache` 缓存。 - **测试**:widget 测试永远覆盖不到 `main()` 的 bootstrap;断言要用 `tester.getSize()` 查**可见尺寸**(`find` 不查可见性)。游戏有常驻 Ticker → **禁用 `pumpAndSettle`**。原生构建需真实机器;本沙箱 Flutter 写不了自己的 `/opt/flutter/bin/cache` 缓存。
- 手感回归请跑 `flutter test`(含 `test/gameplay_test.dart` 的跳初速/重力/松手-guard/死亡重生断言)。 - 手感回归请跑 `flutter test`(含 `test/gameplay_test.dart` 的跳初速/重力/松手-guard/死亡重生断言)。
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- API 28+ + 深色模式:让游戏画面延伸到刘海/挖孔区(shortEdges),
真正铺满屏幕。沿用深色父主题。 -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowFullscreen">true</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
<item name="android:windowFullscreen">true</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
</resources>
@@ -5,6 +5,8 @@
<!-- Show a splash screen on the activity. Automatically removed when <!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame --> the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item> <item name="android:windowBackground">@drawable/launch_background</item>
<!-- 沉浸式全屏:冷启动阶段即隐藏状态栏/导航栏,避免首帧闪现系统 UI -->
<item name="android:windowFullscreen">true</item>
</style> </style>
<!-- Theme applied to the Android Window as soon as the process has started. <!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your This theme determines the color of the Android Window while your
@@ -14,5 +16,6 @@
This Theme is only used starting with V2 of Flutter's Android embedding. --> This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar"> <style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item> <item name="android:windowBackground">?android:colorBackground</item>
<item name="android:windowFullscreen">true</item>
</style> </style>
</resources> </resources>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- API 28+:让游戏画面延伸到刘海/挖孔区(shortEdges),真正铺满屏幕。
该属性在 API<28 不存在,必须放在 values-v28 限定目录,否则低版本
aapt 编译会报找不到资源。day 模式沿用浅色父主题。 -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowFullscreen">true</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
<item name="android:windowFullscreen">true</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
</resources>
@@ -5,6 +5,8 @@
<!-- Show a splash screen on the activity. Automatically removed when <!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame --> the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item> <item name="android:windowBackground">@drawable/launch_background</item>
<!-- 沉浸式全屏:冷启动阶段即隐藏状态栏/导航栏,避免首帧闪现系统 UI -->
<item name="android:windowFullscreen">true</item>
</style> </style>
<!-- Theme applied to the Android Window as soon as the process has started. <!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your This theme determines the color of the Android Window while your
@@ -14,5 +16,6 @@
This Theme is only used starting with V2 of Flutter's Android embedding. --> This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar"> <style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item> <item name="android:windowBackground">?android:colorBackground</item>
<item name="android:windowFullscreen">true</item>
</style> </style>
</resources> </resources>
+6 -3
View File
@@ -55,16 +55,19 @@
<string>Main</string> <string>Main</string>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UISupportedInterfaceOrientations~ipad</key> <key>UISupportedInterfaceOrientations~ipad</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<!-- 沉浸式全屏:由 plist 全局控制状态栏(而非各 ViewController),
启动即隐藏状态栏,与安卓沉浸模式一致。 -->
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>UIStatusBarHidden</key>
<true/>
</dict> </dict>
</plist> </plist>
+13 -4
View File
@@ -8,7 +8,7 @@ import 'package:flutter/foundation.dart'
show FlutterErrorDetails, defaultTargetPlatform, TargetPlatform; show FlutterErrorDetails, defaultTargetPlatform, TargetPlatform;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart' import 'package:flutter/services.dart'
show DeviceOrientation, SystemChrome; show DeviceOrientation, SystemChrome, SystemUiMode;
import 'package:iwbtc/src/core/app_error.dart'; import 'package:iwbtc/src/core/app_error.dart';
import 'package:iwbtc/src/app_root.dart' show AppRoot; import 'package:iwbtc/src/app_root.dart' show AppRoot;
@@ -36,12 +36,17 @@ Future<void> main() async {
); );
} }
/// 移动端专属的显示适配:锁定横屏。 /// 移动端专属的显示适配:横屏锁定 + 沉浸式全屏。
/// ///
/// 画面是 800×608 的横向虚拟舞台,横屏下信箱缩放才能拿到可用的可视比例; /// 画面是 800×608 的横向虚拟舞台,横屏下信箱缩放才能拿到可用的可视比例;
/// 竖屏时上下留白巨大、画面极小,虚拟手柄也会挤成一团。桌面端保持原样不干预。 /// 竖屏时上下留白巨大、画面极小,虚拟手柄也会挤成一团。桌面端保持原样不干预。
/// (安卓另有 AndroidManifest 的 `screenOrientation="sensorLandscape"`, /// (安卓另有 AndroidManifest 的 `screenOrientation="sensorLandscape"`,
/// 保证冷启动第一帧就是横屏,避免先竖后横的闪烁。) /// 保证冷启动第一帧就是横屏,避免先竖后横的闪烁。)
///
/// 沉浸式:用 `SystemUiMode.immersiveSticky` 隐藏状态栏与导航栏,让游戏
/// 铺满整个屏幕,与常规手机游戏一致。相比普通 `immersive`,
/// `immersiveSticky` 在用户从屏幕边缘滑出系统栏后数秒会**自动重新隐藏**,
/// 不会弹出"退出沉浸模式"的提示条,体验更顺。
Future<void> _configureMobile() async { Future<void> _configureMobile() async {
final t = defaultTargetPlatform; final t = defaultTargetPlatform;
if (t != TargetPlatform.android && t != TargetPlatform.iOS) return; if (t != TargetPlatform.android && t != TargetPlatform.iOS) return;
@@ -50,8 +55,12 @@ Future<void> _configureMobile() async {
DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight, DeviceOrientation.landscapeRight,
]); ]);
// 沉浸式全屏:隐藏状态栏与导航栏,游戏铺满整个屏幕。
await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.immersiveSticky,
);
} catch (e) { } catch (e) {
// 某些设备/系统策略不允许锁定方向,属可容忍情况,不影响游戏运行。 // 某些设备/系统策略不允许锁定方向或沉浸,属可容忍情况,不影响游戏运行。
AppError.warn('锁定横屏失败(保持系统默认方向)', '$e'); AppError.warn('移动端显示适配失败(横屏/沉浸)', '$e');
} }
} }
+92 -9
View File
@@ -6,7 +6,7 @@ import 'package:flutter/foundation.dart'
show debugPrint, defaultTargetPlatform, TargetPlatform; show debugPrint, defaultTargetPlatform, TargetPlatform;
import 'package:flutter/scheduler.dart' show Ticker; import 'package:flutter/scheduler.dart' show Ticker;
import 'package:flutter/services.dart' import 'package:flutter/services.dart'
show RawKeyboard, RawKeyEvent, RawKeyDownEvent; show RawKeyboard, RawKeyEvent, RawKeyDownEvent, SystemChrome, SystemUiMode;
import 'package:iwbtc/src/core/app_error.dart' show AppError; import 'package:iwbtc/src/core/app_error.dart' show AppError;
import 'package:iwbtc/src/core/save.dart'; import 'package:iwbtc/src/core/save.dart';
import 'package:iwbtc/src/core/input.dart' show Input; import 'package:iwbtc/src/core/input.dart' show Input;
@@ -16,9 +16,16 @@ import 'package:iwbtc/src/audio/sfx.dart' show Sfx;
import 'package:iwbtc/src/ui/game_canvas.dart' show GameCanvas; import 'package:iwbtc/src/ui/game_canvas.dart' show GameCanvas;
import 'package:iwbtc/src/ui/overlays.dart' show HudOverlay; import 'package:iwbtc/src/ui/overlays.dart' show HudOverlay;
import 'package:iwbtc/src/ui/screens.dart' import 'package:iwbtc/src/ui/screens.dart'
show TitleScreen, LevelSelectScreen, PauseScreen, ResultScreen, SettingsScreen; show
TitleScreen,
LevelSelectScreen,
PauseScreen,
ResultScreen,
SettingsScreen;
import 'package:iwbtc/src/ui/touch_controls.dart' show TouchControls; import 'package:iwbtc/src/ui/touch_controls.dart' show TouchControls;
import 'package:iwbtc/src/ui/editor_panel.dart' show EditorPanel, EditorController; import 'package:iwbtc/src/ui/touch_editor.dart' show TouchLayoutEditor;
import 'package:iwbtc/src/ui/editor_panel.dart'
show EditorPanel, EditorController;
class AppRoot extends StatefulWidget { class AppRoot extends StatefulWidget {
const AppRoot({super.key}); const AppRoot({super.key});
@@ -27,7 +34,7 @@ class AppRoot extends StatefulWidget {
} }
class _AppRootState extends State<AppRoot> class _AppRootState extends State<AppRoot>
with SingleTickerProviderStateMixin with SingleTickerProviderStateMixin, WidgetsBindingObserver
implements GameHost { implements GameHost {
late World world; late World world;
late EditorController editor; late EditorController editor;
@@ -58,6 +65,8 @@ class _AppRootState extends State<AppRoot>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// 监听生命周期:切后台再回来时系统栏会重新出现,需重新进入沉浸模式。
WidgetsBinding.instance.addObserver(this);
_useTouch = defaultTargetPlatform == TargetPlatform.android; _useTouch = defaultTargetPlatform == TargetPlatform.android;
// 看门狗:初始化迟迟不完成时报出可诊断信息,避免永远停在加载态。 // 看门狗:初始化迟迟不完成时报出可诊断信息,避免永远停在加载态。
_watchdog = Timer(const Duration(seconds: 12), () { _watchdog = Timer(const Duration(seconds: 12), () {
@@ -132,12 +141,25 @@ class _AppRootState extends State<AppRoot>
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this);
_watchdog?.cancel(); _watchdog?.cancel();
RawKeyboard.instance.removeListener(_rawKey); RawKeyboard.instance.removeListener(_rawKey);
_ticker.dispose(); _ticker.dispose();
super.dispose(); super.dispose();
} }
/// 切回前台时重新进入沉浸式全屏:系统 UI(状态栏/导航栏)在
/// 用户从屏幕边缘滑出或切后台后会重新显示,常规游戏都会在此处复位。
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
final t = defaultTargetPlatform;
if (t == TargetPlatform.android || t == TargetPlatform.iOS) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
}
}
}
// ---------------- GameHost ---------------- // ---------------- GameHost ----------------
@override @override
void onShowScreen(String name) { void onShowScreen(String name) {
@@ -187,25 +209,74 @@ class _AppRootState extends State<AppRoot>
builder: (_, String? err, __) { builder: (_, String? err, __) {
// 致命错误优先展示详情 —— release 包里原本只会留下一片黑屏。 // 致命错误优先展示详情 —— release 包里原本只会留下一片黑屏。
if (err != null) { if (err != null) {
return MaterialApp(home: Scaffold(body: AppError.buildScreen(err))); return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(body: AppError.buildScreen(err)),
);
} }
// 尚未就绪:显示可见的加载界面(不再是纯黑背景)。 // 尚未就绪:显示可见的加载界面(不再是纯黑背景)。
if (!_ready || !_worldOk) return const MaterialApp(home: _BootScreen()); if (!_ready || !_worldOk) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: _BootScreen(),
);
}
return _buildGame(); return _buildGame();
}, },
); );
} }
/// 系统返回手势 / 返回键 = 游戏内的「返回上一页」,而不是退出应用:
/// - 游戏中 → 暂停菜单;暂停中 → 继续游戏
/// - 结算界面 → 关卡选择;编辑器 → 标题
/// - 关卡选择 / 设置 → 标题
/// - 标题页不响应(已是「第一页」,忽略返回,防止误退游戏)
void _handleSystemBack() {
switch (world.state) {
case 'playing':
world.pause();
return;
case 'paused':
world.resume();
return;
case 'result':
world.toLevels();
return;
case 'editor':
world.toTitle();
return;
}
final s = screen.value;
if (s == 'scr-touchedit') {
// 自定义按键页:系统返回 = 返回设置(布局已即改即存,无需额外处理)
screen.value = 'scr-settings';
return;
}
if (s == 'scr-levels' || s == 'scr-settings') world.toTitle();
}
/// 真正的游戏界面栈(画布 + 各屏幕 + HUD + 触屏手柄)。 /// 真正的游戏界面栈(画布 + 各屏幕 + HUD + 触屏手柄)。
Widget _buildGame() { Widget _buildGame() {
if (!_loggedBuild) { if (!_loggedBuild) {
_loggedBuild = true; _loggedBuild = true;
debugPrint( debugPrint(
'[boot] buildGame: state=${world.state} screen=${screen.value}'); '[boot] buildGame: state=${world.state} screen=${screen.value}',
);
} }
return MaterialApp( return MaterialApp(
home: Scaffold( debugShowCheckedModeBanner: false,
home: PopScope(
// 拦截系统返回:交给 _handleSystemBack 映射为游戏内导航。
canPop: false,
onPopInvokedWithResult: (bool didPop, Object? result) {
if (!didPop) _handleSystemBack();
},
child: Scaffold(
backgroundColor: const Color(0xff000000), backgroundColor: const Color(0xff000000),
// 游戏类 App 不让软键盘压缩界面(否则编辑器/设置里一调起键盘,
// 可用高度骤减,固定头部立刻 BOTTOM OVERFLOWED)。
// 所有输入框都在屏幕上半区,键盘不会遮挡它们。
resizeToAvoidBottomInset: false,
body: Stack( body: Stack(
children: <Widget>[ children: <Widget>[
Positioned.fill( Positioned.fill(
@@ -237,6 +308,14 @@ class _AppRootState extends State<AppRoot>
world: world, world: world,
isTouch: _useTouch, isTouch: _useTouch,
onBack: () => world.toTitle(), onBack: () => world.toTitle(),
onTouchEditor: () => screen.value = 'scr-touchedit',
);
}
// 自定义按键:独立全屏页,透出下面的实时游戏画布,
// 编辑体验与正常玩游戏一致。
if (s == 'scr-touchedit') {
return TouchLayoutEditor(
onDone: () => screen.value = 'scr-settings',
); );
} }
if (s == 'scr-editor') { if (s == 'scr-editor') {
@@ -255,7 +334,10 @@ class _AppRootState extends State<AppRoot>
valueListenable: screen, valueListenable: screen,
builder: (_, String s, __) { builder: (_, String s, __) {
if (s == 'scr-pause') { if (s == 'scr-pause') {
return PauseScreen(world: world, quitLabel: pauseQuit.value); return PauseScreen(
world: world,
quitLabel: pauseQuit.value,
);
} }
if (s == 'scr-result') { if (s == 'scr-result') {
return ResultScreen(world: world, result: result.value); return ResultScreen(world: world, result: result.value);
@@ -288,6 +370,7 @@ class _AppRootState extends State<AppRoot>
], ],
), ),
), ),
),
); );
} }
} }
+8 -2
View File
@@ -59,7 +59,12 @@ class Entity implements EntityView, CamZoneLike {
double speed = 2.4; double speed = 2.4;
int timer = 0; int timer = 0;
String kind = 'ball'; String kind = 'ball';
String? doorId; // 与 web 原版一致:doorId 既可来自 data.doors[].id(常为 String),
// 也可在无匹配门时回退到实体序号 idx(int)。JS 弱类型下二者都能用 === 比较;
// 移植到 Dart 后若声明为 String?,回退分支把 int 赋给 String? 会抛
// "type 'int' is not a subtype of type 'String?'"。故用 Object? 容纳两种类型,
// == 比较(world.dart)对 String/String、int/int 走值相等,语义与 web 一致。
Object? doorId;
int pair = 0; int pair = 0;
dynamic to; dynamic to;
int cool = 0; int cool = 0;
@@ -252,7 +257,8 @@ class Level implements LevelLike {
} }
if (type == 'warp') { if (type == 'warp') {
final cfg = (idx < warpSrc.length ? warpSrc[idx] : {}) as Map? ?? {}; final cfg = (idx < warpSrc.length ? warpSrc[idx] : {}) as Map? ?? {};
final door = (data['doors'] as List?)?.cast<Map>() final doors = (data['doors'] as List?)?.cast<Map>() ?? const <Map>[];
final door = doors
.firstWhere((d) => d['tx'] == tx && d['ty'] == ty, orElse: () => <String, dynamic>{}); .firstWhere((d) => d['tx'] == tx && d['ty'] == ty, orElse: () => <String, dynamic>{});
e.doorId = (door != null && door.isNotEmpty) ? door['id'] : idx; e.doorId = (door != null && door.isNotEmpty) ? door['id'] : idx;
e.pair = cfg['pair'] ?? (idx % 2 == 0 ? idx + 1 : idx - 1); e.pair = cfg['pair'] ?? (idx % 2 == 0 ? idx + 1 : idx - 1);
+82 -25
View File
@@ -1,10 +1,18 @@
// 游戏循环 / 状态机(对应 web 版 main.js)。 // 游戏循环 / 状态机(对应 web 版 main.js)。
// 用固定 60Hz 时间步长驱动已移植的纯 Canvas 游戏逻辑,保证手感 1:1。 // 用固定 60Hz 时间步长驱动已移植的纯 Canvas 游戏逻辑,保证手感 1:1。
// 渲染在 800×608 虚拟分辨率下完成,由 GameCanvas 做信箱(letterbox)缩放。 // 渲染在虚拟视口下完成(≥800×608、随屏幕比例加宽),由 GameCanvas 铺满全屏。
import 'dart:ui' show Canvas, Color, Paint, Path, Rect, Offset, TextAlign, TextDirection; import 'dart:ui'
show Canvas, Color, Paint, Path, Rect, Offset, TextAlign, TextDirection;
import 'dart:math' show sin; import 'dart:math' show sin;
import 'package:flutter/widgets.dart' import 'package:flutter/widgets.dart'
show TextPainter, TextSpan, TextStyle, LinearGradient, Alignment, FontWeight, ValueNotifier; show
TextPainter,
TextSpan,
TextStyle,
LinearGradient,
Alignment,
FontWeight,
ValueNotifier;
import 'package:iwbtc/src/core/input.dart'; import 'package:iwbtc/src/core/input.dart';
import 'package:iwbtc/src/core/save.dart'; import 'package:iwbtc/src/core/save.dart';
import 'package:iwbtc/src/core/util.dart'; import 'package:iwbtc/src/core/util.dart';
@@ -32,8 +40,10 @@ abstract class GameHost {
void onBossBar(dynamic boss); void onBossBar(dynamic boss);
void onToast(String text); void onToast(String text);
void onResult(Map<String, dynamic> r); void onResult(Map<String, dynamic> r);
/// 暂停菜单「退出」按钮文案随来源变化。 /// 暂停菜单「退出」按钮文案随来源变化。
void onPauseMenu(String label); void onPauseMenu(String label);
/// 进入编辑器界面。 /// 进入编辑器界面。
void editorShow(); void editorShow();
void touchShow(); void touchShow();
@@ -75,10 +85,29 @@ class World implements LevelTestRunner {
double _acc = 0; double _acc = 0;
String _lastToast = ''; String _lastToast = '';
/// 当前虚拟视口(GameCanvas 每帧 paint 前同步)。
/// 保证 ≥ 800×608;屏幕更宽/更高时另一维加宽,画面铺满全屏无黑边。
double viewW = W;
double viewH = H;
World(this.host); World(this.host);
/// 由 GameCanvas 在 paint 前调用:视口随屏幕比例变化。
/// 只改纯数值字段,不触发任何通知(paint 阶段禁止 notifier)。
void setViewport(double vw, double vh) {
if (vw == viewW && vh == viewH) return;
viewW = vw;
viewH = vh;
cam?.w = vw;
cam?.h = vh;
}
// ---------------- 关卡流程 ---------------- // ---------------- 关卡流程 ----------------
Map<String, dynamic> faceData(Map<String, dynamic> map, Map<String, dynamic> f, int i) { Map<String, dynamic> faceData(
Map<String, dynamic> map,
Map<String, dynamic> f,
int i,
) {
final grid = (f['grid'] as List?)?.cast<String>() ?? <String>[]; final grid = (f['grid'] as List?)?.cast<String>() ?? <String>[];
return <String, dynamic>{ return <String, dynamic>{
'id': (map['id'] ?? 'map').toString() + '-' + i.toString(), 'id': (map['id'] ?? 'map').toString() + '-' + i.toString(),
@@ -115,7 +144,7 @@ class World implements LevelTestRunner {
level = map![face]; level = map![face];
player = Player(level!); player = Player(level!);
cam = Camera(W, H); cam = Camera(viewW, viewH);
cam!.reset(level!, player!); cam!.reset(level!, player!);
player!.onDeath = () { player!.onDeath = () {
@@ -194,8 +223,14 @@ class World implements LevelTestRunner {
} }
cam!.reset(level!, player!); cam!.reset(level!, player!);
Sfx.instance.warp(); Sfx.instance.warp();
level!.particles.spawn(player!.cx, player!.cy, 12, level!.particles.spawn(
colors: const ['#a86bff', '#fff'], speed: 2.5, life: 24); player!.cx,
player!.cy,
12,
colors: const ['#a86bff', '#fff'],
speed: 2.5,
life: 24,
);
} }
void doRespawnFace() { void doRespawnFace() {
@@ -229,7 +264,12 @@ class World implements LevelTestRunner {
void shoot() { void shoot() {
final P = player; final P = player;
if (state != 'playing' || P == null || P.dead || !P.hasGun || P.shootCool != 0) return; if (state != 'playing' ||
P == null ||
P.dead ||
!P.hasGun ||
P.shootCool != 0)
return;
P.shoot(); P.shoot();
} }
@@ -334,7 +374,8 @@ class World implements LevelTestRunner {
if (++hudTick % 5 == 0) { if (++hudTick % 5 == 0) {
final L = level!; final L = level!;
host.onHud(<String, dynamic>{ host.onHud(<String, dynamic>{
'name': (data!['name'] ?? 'STAGE') + 'name':
(data!['name'] ?? 'STAGE') +
(map!.length > 1 ? ' · 面${face + 1}/${map!.length}' : ''), (map!.length > 1 ? ' · 面${face + 1}/${map!.length}' : ''),
'deaths': deaths, 'deaths': deaths,
'ms': ms, 'ms': ms,
@@ -345,7 +386,8 @@ class World implements LevelTestRunner {
'totalSaves': L.totalSaves, 'totalSaves': L.totalSaves,
}); });
host.touchSetGun(P.hasGun); host.touchSetGun(P.hasGun);
final boss = L.bosses.where((b) => !b.dead).firstOrNull ?? L.bosses.firstOrNull; final boss =
L.bosses.where((b) => !b.dead).firstOrNull ?? L.bosses.firstOrNull;
host.onBossBar(L.bosses.isNotEmpty ? boss : null); host.onBossBar(L.bosses.isNotEmpty ? boss : null);
_pushToast(L.toast ?? ''); _pushToast(L.toast ?? '');
} }
@@ -371,8 +413,11 @@ class World implements LevelTestRunner {
} else if (state == 'paused' && Input.hit('pause')) { } else if (state == 'paused' && Input.hit('pause')) {
resume(); resume();
} }
} // 每个固定步长都刷新输入边沿(prev=now)。否则某一帧跑 0 次更新、
// 下一帧跑 2 次时,同一次按键会被当两帧的 hit —— 表现为误触二段跳;
// 合帧亦不应丢失按键:now 在两次更新间保持为 true,下一子步才清 prev。
Input.endFrame(); Input.endFrame();
}
// toast 推送必须在 Ticker 回调阶段(transientCallbacks)完成, // toast 推送必须在 Ticker 回调阶段(transientCallbacks)完成,
// 不能放进 render() —— paint 阶段改被 ValueListenableBuilder 监听的 // 不能放进 render() —— paint 阶段改被 ValueListenableBuilder 监听的
// notifier 会触发 "Build scheduled during frame"(晚一帧且刷警告)。 // notifier 会触发 "Build scheduled during frame"(晚一帧且刷警告)。
@@ -380,14 +425,16 @@ class World implements LevelTestRunner {
if (state != 'playing') time++; if (state != 'playing') time++;
} }
// ---------------- 渲染(800×608 虚拟空间) ---------------- // ---------------- 渲染(虚拟视口空间,≥800×608 随屏幕比例加宽) ----------------
void render(Canvas c) { void render(Canvas c) {
final t = time; final t = time;
final vw = viewW;
final vh = viewH;
if (state == 'playing' || state == 'paused' || state == 'result') { if (state == 'playing' || state == 'paused' || state == 'result') {
final L = level!; final L = level!;
final P = player!; final P = player!;
final C = cam!; final C = cam!;
c.drawRect(Rect.fromLTWH(0, 0, W, H), Paint()..color = hex('#0a0a14')); c.drawRect(Rect.fromLTWH(0, 0, vw, vh), Paint()..color = hex('#0a0a14'));
L.drawBg(c, C, t); L.drawBg(c, C, t);
c.save(); c.save();
C.apply(c); C.apply(c);
@@ -404,7 +451,9 @@ class World implements LevelTestRunner {
text: TextSpan( text: TextSpan(
text: useTouch ? '按右下角重生键' : '按 R 重生', text: useTouch ? '按右下角重生键' : '按 R 重生',
style: TextStyle( style: TextStyle(
color: const Color(0xffff8fa3).withValues(alpha: (blink ? 1.0 : 0.3) * 255), color: const Color(
0xffff8fa3,
).withValues(alpha: (blink ? 1.0 : 0.3) * 255),
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontFamily: 'monospace', fontFamily: 'monospace',
@@ -414,7 +463,7 @@ class World implements LevelTestRunner {
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
); );
tp.layout(); tp.layout();
tp.paint(c, Offset(W / 2 - tp.width / 2, H / 2 - 36 - tp.height / 2)); tp.paint(c, Offset(vw / 2 - tp.width / 2, vh / 2 - 36 - tp.height / 2));
final sub = TextPainter( final sub = TextPainter(
text: TextSpan( text: TextSpan(
text: 'Backspace 整关重来', text: 'Backspace 整关重来',
@@ -428,7 +477,10 @@ class World implements LevelTestRunner {
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
); );
sub.layout(); sub.layout();
sub.paint(c, Offset(W / 2 - sub.width / 2, H / 2 - 12 - sub.height / 2)); sub.paint(
c,
Offset(vw / 2 - sub.width / 2, vh / 2 - 12 - sub.height / 2),
);
} }
} else { } else {
drawTitleBg(c); drawTitleBg(c);
@@ -436,16 +488,18 @@ class World implements LevelTestRunner {
} }
void drawTitleBg(Canvas c) { void drawTitleBg(Canvas c) {
final vw = viewW;
final vh = viewH;
final g = LinearGradient( final g = LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: <Color>[hex('#181828'), hex('#08080f')], colors: <Color>[hex('#181828'), hex('#08080f')],
).createShader(Rect.fromLTWH(0, 0, W, H)); ).createShader(Rect.fromLTWH(0, 0, vw, vh));
c.drawRect(Rect.fromLTWH(0, 0, W, H), Paint()..shader = g); c.drawRect(Rect.fromLTWH(0, 0, vw, vh), Paint()..shader = g);
for (int i = 0; i < 26; i++) { for (int i = 0; i < 26; i++) {
final double x = ((i * 137 + time * 0.15) % (W + 60)) - 30; final double x = ((i * 137 + time * 0.15) % (vw + 60)) - 30;
final double y = 120 + ((i * 71) % (H - 160)).toDouble(); final double y = 120 + ((i * 71) % (vh - 160)).toDouble();
final col = i % 3 == 0 ? hex('#3a3a4c') : hex('#2a2a38'); final col = i % 3 == 0 ? hex('#3a3a4c') : hex('#2a2a38');
final p = Path() final p = Path()
..moveTo(x, y + 18) ..moveTo(x, y + 18)
@@ -455,13 +509,16 @@ class World implements LevelTestRunner {
c.drawPath(p, Paint()..color = col.withValues(alpha: 0.5 * 255)); c.drawPath(p, Paint()..color = col.withValues(alpha: 0.5 * 255));
} }
c.drawRect(Rect.fromLTWH(0, H - 30, W, 30), Paint()..color = hex('#1a1a26')); c.drawRect(
Rect.fromLTWH(0, vh - 30, vw, 30),
Paint()..color = hex('#1a1a26'),
);
final spikePaint = Paint()..color = hex('#c8c8d8'); final spikePaint = Paint()..color = hex('#c8c8d8');
for (double x = 0; x < W; x += 22) { for (double x = 0; x < vw; x += 22) {
final p = Path() final p = Path()
..moveTo(x, H - 4) ..moveTo(x, vh - 4)
..lineTo(x + 11, H - 26) ..lineTo(x + 11, vh - 26)
..lineTo(x + 22, H - 4) ..lineTo(x + 22, vh - 4)
..close(); ..close();
c.drawPath(p, spikePaint); c.drawPath(p, spikePaint);
} }
File diff suppressed because it is too large. Load diff
+51 -32
View File
@@ -1,25 +1,45 @@
// 游戏画布:把 World 在 800×608 虚拟分辨率下的渲染做信箱(letterbox)缩放。 // 游戏画布:把 World 的虚拟视口渲染铺满整个屏幕(全屏、无信箱黑边)。
// 虚拟视口保证 800×608 始终完整可见(不裁游戏内容),另一维随屏幕比例
// 自动加宽:例如 2340×1080 的横屏手机 → 视口约 1318×608(横向看得更多),
// 与 PC 游戏支持任意宽高比的做法一致。
import 'dart:ui' show Canvas, Rect, Paint, Color; import 'dart:ui' show Canvas, Rect, Paint, Color;
import 'dart:math' show min;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter/foundation.dart' show debugPrint; import 'package:flutter/foundation.dart' show debugPrint;
import 'package:iwbtc/src/core/app_error.dart' show AppError; import 'package:iwbtc/src/core/app_error.dart' show AppError;
import 'package:iwbtc/src/game/world.dart' show World, W, H; import 'package:iwbtc/src/game/world.dart' show World, W, H;
/// 计算 800×608 虚拟画面在给定屏幕尺寸下的信箱矩形(居中、等比缩放)。 /// 屏幕对应的虚拟视口:缩放系数 + 视口尺寸(虚拟像素)。
/// class StageMetrics {
/// 画布和虚拟手柄都用它:横屏手机宽高比远大于画面比例,两侧会出现黑边, final double scale;
/// 手柄按钮必须锚定在**画面区域**内,否则会漂到黑边上(手指按到的位置和 final double vw;
/// 屏幕上看到的游戏内容对不上)。尺寸非法(尚未布局完成)时返回 Rect.zero。 final double vh;
Rect letterboxRect(Size size) { const StageMetrics(this.scale, this.vw, this.vh);
if (!size.width.isFinite || !size.height.isFinite) return Rect.zero;
if (size.width <= 0 || size.height <= 0) return Rect.zero; bool get isValid =>
final s = min(size.width / W, size.height / H); scale > 0 &&
if (!s.isFinite || s <= 0) return Rect.zero; scale.isFinite &&
final double w = W * s; vw.isFinite &&
final double h = H * s; vh.isFinite &&
return Rect.fromLTWH( vw >= W - 0.5 &&
(size.width - w) / 2, (size.height - h) / 2, w, h); vh >= H - 0.5;
}
/// 计算铺满全屏的虚拟视口(无黑边),且保证 800×608 完整可见:
/// - 屏幕比游戏「更宽」(横屏手机几乎总是如此)→ 高度定标 608,
/// 视口横向加宽到 vw = 屏宽 / scale,游戏内容横向看得更多;
/// - 屏幕比游戏「更窄」→ 宽度定标 800,视口纵向加高到 vh = 屏高 / scale。
/// 摄像机随后按视口尺寸取景(Camera 自带对关卡边界的钳制)。
StageMetrics stageMetrics(Size size) {
if (!size.width.isFinite || !size.height.isFinite) {
return const StageMetrics(0, 0, 0);
}
if (size.width <= 0 || size.height <= 0) return const StageMetrics(0, 0, 0);
if (size.width / size.height >= W / H) {
final s = size.height / H;
return StageMetrics(s, size.width / s, H);
}
final s = size.width / W;
return StageMetrics(s, W, size.height / s);
} }
class GameCanvas extends StatelessWidget { class GameCanvas extends StatelessWidget {
@@ -32,9 +52,7 @@ class GameCanvas extends StatelessWidget {
// 用 SizedBox.expand 明确占满父级,避免 CustomPaint 使用 Size.infinite // 用 SizedBox.expand 明确占满父级,避免 CustomPaint 使用 Size.infinite
// 时在部分平台上拿到零尺寸/无穷尺寸,算出 scale = 0 而整屏消失(表现为黑屏)。 // 时在部分平台上拿到零尺寸/无穷尺寸,算出 scale = 0 而整屏消失(表现为黑屏)。
return SizedBox.expand( return SizedBox.expand(
child: CustomPaint( child: CustomPaint(painter: _GamePainter(world, repaint)),
painter: _GamePainter(world, repaint),
),
); );
} }
} }
@@ -48,21 +66,21 @@ class _GamePainter extends CustomPainter {
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
// 信箱外框(黑色) // 首帧可能尚未完成布局,尺寸为 0 时不要绘制内容(stageMetrics 无效)。
canvas.drawRect( final m = stageMetrics(size);
Rect.fromLTWH(0, 0, size.width, size.height), if (!m.isValid) return;
Paint()..color = const Color(0xff000000));
// 首帧可能尚未完成布局,尺寸为 0 时不要绘制内容(letterboxRect 返回 zero)。 // 同步虚拟视口(只改纯数值字段,不触发任何通知 —— paint 阶段禁止 notifier)。
final rect = letterboxRect(size); world.setViewport(m.vw, m.vh);
if (rect.isEmpty) return;
canvas.save(); canvas.save();
canvas.translate(rect.left, rect.top); canvas.scale(m.scale);
canvas.scale(rect.width / W, rect.width / W); canvas.clipRect(Rect.fromLTWH(0, 0, m.vw, m.vh));
canvas.clipRect(Rect.fromLTWH(0, 0, W, H)); // 兜底底色:即使 world.render 抛异常也不会露出透明/白屏。
canvas.drawRect( canvas.drawRect(
Rect.fromLTWH(0, 0, W, H), Paint()..color = const Color(0xff0a0a14)); Rect.fromLTWH(0, 0, m.vw, m.vh),
Paint()..color = const Color(0xff0a0a14),
);
// 渲染异常原本会被静默吞掉,画面变成纯黑。 // 渲染异常原本会被静默吞掉,画面变成纯黑。
// 偶发的一帧异常不该让整局报废 —— 只记录日志;但若连续多帧都失败 // 偶发的一帧异常不该让整局报废 —— 只记录日志;但若连续多帧都失败
// (比如某张地图/某种渲染路径真有问题),才升级成占屏错误以便真机诊断。 // (比如某张地图/某种渲染路径真有问题),才升级成占屏错误以便真机诊断。
@@ -85,8 +103,9 @@ class _GamePainter extends CustomPainter {
_loggedFirstPaint = true; _loggedFirstPaint = true;
debugPrint( debugPrint(
'[boot] canvas rendered: screen=${size.width}x${size.height} ' '[boot] canvas rendered: screen=${size.width}x${size.height} '
'stage=${rect.width}x${rect.height} at (${rect.left},${rect.top}) ' 'viewport=${m.vw.toStringAsFixed(0)}x${m.vh.toStringAsFixed(0)} '
'state=${world.state}'); 'state=${world.state}',
);
} }
} }
File diff suppressed because it is too large. Load diff
+38 -24
View File
@@ -2,20 +2,19 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:iwbtc/src/core/input.dart' show Input; import 'package:iwbtc/src/core/input.dart' show Input;
import 'package:iwbtc/src/ui/touch_layout.dart' import 'package:iwbtc/src/ui/touch_layout.dart' show BUTTONS, metaSettings;
show BUTTONS, metaSettings;
import 'package:iwbtc/src/game/world.dart' show World, W; import 'package:iwbtc/src/game/world.dart' show World, W;
import 'package:iwbtc/src/ui/game_canvas.dart' show letterboxRect;
class TouchControls extends StatelessWidget { class TouchControls extends StatelessWidget {
final World world; final World world;
final ValueNotifier<bool> visible; final ValueNotifier<bool> visible;
final ValueNotifier<bool> hasGun; final ValueNotifier<bool> hasGun;
const TouchControls( const TouchControls({
{super.key, super.key,
required this.world, required this.world,
required this.visible, required this.visible,
required this.hasGun}); required this.hasGun,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -24,11 +23,10 @@ class TouchControls extends StatelessWidget {
valueListenable: visible, valueListenable: visible,
builder: (_, bool v, __) { builder: (_, bool v, __) {
if (!v) return const SizedBox(); if (!v) return const SizedBox();
// 按钮锚定在「信箱缩放后的画面矩形」内,而不是整块屏幕: // 画面已铺满全屏(无信箱黑边),按钮直接按整屏百分比定位。
// 横屏时两侧是黑边,若按全屏百分比定位,左/右/跳跃键会飘到黑边上。
return LayoutBuilder( return LayoutBuilder(
builder: (_, BoxConstraints cons) { builder: (_, BoxConstraints cons) {
final area = letterboxRect(Size(cons.maxWidth, cons.maxHeight)); final area = Rect.fromLTWH(0, 0, cons.maxWidth, cons.maxHeight);
return Stack( return Stack(
children: <Widget>[ children: <Widget>[
for (final b in BUTTONS) for (final b in BUTTONS)
@@ -68,8 +66,8 @@ class _TouchButton extends StatefulWidget {
/// 空 Rect 表示尚未完成布局,此时不渲染。 /// 空 Rect 表示尚未完成布局,此时不渲染。
final Rect area; final Rect area;
const _TouchButton( const _TouchButton({
{required this.world, required this.world,
required this.keyName, required this.keyName,
required this.label, required this.label,
this.label2, this.label2,
@@ -78,7 +76,8 @@ class _TouchButton extends StatefulWidget {
required this.cy, required this.cy,
required this.size, required this.size,
required this.hasGun, required this.hasGun,
required this.area}); required this.area,
});
@override @override
State<_TouchButton> createState() => _TouchButtonState(); State<_TouchButton> createState() => _TouchButtonState();
@@ -114,8 +113,14 @@ class _TouchButtonState extends State<_TouchButton> {
} }
void _release() { void _release() {
if (!_pressed) return; // 注意:即便 _pressed 因事件丢失而处于异常状态,也要强制复位虚拟键,
// 否则 touch['jump'] 会卡在 true(漏接松手事件时极易发生),
// 之后每次按下都因 setTouch 的「值未变则直接 return」短路而触发不了
// 新的边沿 —— 表现就是「跳跃键经常失灵、时好时坏」。
if (_pressed) {
_pressed = false; _pressed = false;
setState(() {});
}
_shootTimer?.cancel(); _shootTimer?.cancel();
_shootTimer = null; _shootTimer = null;
final a = widget.keyName; final a = widget.keyName;
@@ -124,7 +129,6 @@ class _TouchButtonState extends State<_TouchButton> {
} else { } else {
Input.setTouch(a, false); Input.setTouch(a, false);
} }
setState(() {});
} }
@override @override
@@ -143,10 +147,15 @@ class _TouchButtonState extends State<_TouchButton> {
top: top, top: top,
width: sz, width: sz,
height: sz, height: sz,
child: Listener( // 用 GestureDetector(Pan 识别器会「认领」指针)替代 Listener:
onPointerDown: (_) => _press(), // Listener 不捕获指针,手指在按钮上滑动后于按钮外松手时,松手事件
onPointerUp: (_) => _release(), // 落到别的控件上 → 本按钮的虚拟键永久卡在按下态。Pan 会持续把
onPointerCancel: (_) => _release(), // 抬起/取消事件回传给本按钮,松手必定复位。
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanDown: (_) => _press(),
onPanEnd: (_) => _release(),
onPanCancel: () => _release(),
child: ValueListenableBuilder<bool>( child: ValueListenableBuilder<bool>(
valueListenable: widget.hasGun, valueListenable: widget.hasGun,
builder: (_, __, ___) { builder: (_, __, ___) {
@@ -160,23 +169,28 @@ class _TouchButtonState extends State<_TouchButton> {
: const Color(0x444a5a7a), : const Color(0x444a5a7a),
shape: isJump ? BoxShape.circle : BoxShape.rectangle, shape: isJump ? BoxShape.circle : BoxShape.rectangle,
borderRadius: isJump ? null : BorderRadius.circular(sz * 0.3), borderRadius: isJump ? null : BorderRadius.circular(sz * 0.3),
border: Border.all( border: Border.all(color: const Color(0xff7ae0ff), width: 2),
color: const Color(0xff7ae0ff), width: 2),
), ),
child: Center( child: Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
Text(widget.label, Text(
widget.label,
style: TextStyle( style: TextStyle(
color: const Color(0xffffffff), color: const Color(0xffffffff),
fontSize: sz * 0.32, fontSize: sz * 0.32,
fontWeight: FontWeight.bold)), fontWeight: FontWeight.bold,
),
),
if (widget.label2 != null) if (widget.label2 != null)
Text(widget.label2!, Text(
widget.label2!,
style: TextStyle( style: TextStyle(
color: const Color(0xffcfe8ff), color: const Color(0xffcfe8ff),
fontSize: sz * 0.16)), fontSize: sz * 0.16,
),
),
], ],
), ),
), ),
+299
View File
@@ -0,0 +1,299 @@
// 触屏手柄布局编辑器:独立全屏页,从「设置 → 自定义按键」进入。
// 背景保持透明,透出下面的实时游戏画布(标题星空 + 刺地带动画),
// 编辑体验与正常玩游戏时完全一致(同样的全屏沉浸、同样的按钮渲染)。
//
// 布局即改即存(每次拖动/缩放都写回存档),返回时无需再点「确定」。
// 位置/大小与真实手柄同一套换算(cx/cy 百分比 + size×k),所见即所得。
import 'package:flutter/material.dart';
import 'package:iwbtc/src/core/save.dart' show Save;
import 'package:iwbtc/src/game/world.dart' show W;
import 'package:iwbtc/src/ui/touch_layout.dart'
show BUTTONS, TouchBtn, metaSettings;
/// 读取某按键当前布局(cx/cy/size)。
Map<String, double> touchLayoutOf(String key) {
final t = metaSettings()['touch'] as Map;
final m = (t[key] as Map?) ?? const <String, dynamic>{};
return <String, double>{
'cx': double.tryParse(m['cx'].toString()) ?? 50,
'cy': double.tryParse(m['cy'].toString()) ?? 50,
'size': double.tryParse(m['size'].toString()) ?? 60,
};
}
void writeTouchLayout(String key, Map<String, double> p) {
(metaSettings()['touch'] as Map)[key] = <String, dynamic>{
'cx': p['cx'],
'cy': p['cy'],
'size': p['size'],
};
// 即改即存:布局写回存档,返回/切后台都不会丢。
Save.flush();
}
class TouchLayoutEditor extends StatefulWidget {
final VoidCallback onDone; // 仅负责返回设置页(布局已即改即存)
const TouchLayoutEditor({super.key, required this.onDone});
@override
State<TouchLayoutEditor> createState() => _TouchLayoutEditorState();
}
class _TouchLayoutEditorState extends State<TouchLayoutEditor> {
@override
Widget build(BuildContext context) {
// 背景保持透明:透出下面的实时游戏画布(标题星空+刺地),
// 编辑体验与正常游戏完全一致(全屏沉浸)。
return Stack(
children: <Widget>[
// 按钮编辑层(铺满全屏,与真实手柄同一坐标系)
Positioned.fill(
child: LayoutBuilder(
builder: (BuildContext _, BoxConstraints cons) {
final double w = cons.maxWidth;
final double h = cons.maxHeight;
// 与 _TouchButton 完全一致的缩放系数,保证编辑所见 = 游戏所得
final double k = (w / W).clamp(0.6, 2.6);
return Stack(
children: <Widget>[
for (final b in BUTTONS) ...<Widget>[
_EditBtn(
b: b,
k: k,
screenW: w,
screenH: h,
onChanged: () => setState(() {}),
),
_ResizeHandle(
b: b,
k: k,
screenW: w,
screenH: h,
onChanged: () => setState(() {}),
),
],
],
);
},
),
),
// 顶部操作行:恢复默认 / 返回
Positioned(
top: 10,
right: 12,
child: Row(
children: <Widget>[
ElevatedButton(
onPressed: () {
final t = metaSettings()['touch'] as Map;
t.clear();
for (final b in BUTTONS) {
t[b.key] = <String, dynamic>{
'cx': b.cx,
'cy': b.cy,
'size': b.size,
};
}
Save.flush();
setState(() {});
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0x991a1a26),
foregroundColor: const Color(0xffffffff),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
child: const Text('恢复默认', style: TextStyle(fontSize: 13)),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: widget.onDone,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0x995a3a7a),
foregroundColor: const Color(0xffffffff),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 6,
),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
child: const Text('返回', style: TextStyle(fontSize: 13)),
),
],
),
),
// 底部提示
const Positioned(
left: 0,
right: 0,
bottom: 8,
child: Center(
child: Text(
'点选按钮后可拖动位置 · 拖右下角黄点调整大小(改动即时保存)',
style: TextStyle(color: Color(0x88ffffff), fontSize: 12),
),
),
),
],
);
}
}
class _EditBtn extends StatefulWidget {
final TouchBtn b;
final double k; // 真实手柄同款缩放系数
final double screenW;
final double screenH;
final VoidCallback onChanged;
const _EditBtn({
required this.b,
required this.k,
required this.screenW,
required this.screenH,
required this.onChanged,
});
@override
State<_EditBtn> createState() => _EditBtnState();
}
class _EditBtnState extends State<_EditBtn> {
bool _selected = false;
@override
Widget build(BuildContext context) {
final p = touchLayoutOf(widget.b.key);
final double sz = p['size']! * widget.k;
final double left = p['cx']! / 100 * widget.screenW - sz / 2;
final double top = p['cy']! / 100 * widget.screenH - sz / 2;
final isJump = widget.b.shape == 'jump';
return Positioned(
left: left,
top: top,
width: sz,
height: sz,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _selected = !_selected),
onPanStart: (_) => setState(() => _selected = true),
onPanUpdate: (d) {
final np = touchLayoutOf(widget.b.key);
np['cx'] = (np['cx']! + d.delta.dx / widget.screenW * 100).clamp(
3.0,
97.0,
);
np['cy'] = (np['cy']! + d.delta.dy / widget.screenH * 100).clamp(
4.0,
96.0,
);
writeTouchLayout(widget.b.key, np);
widget.onChanged();
},
child: Container(
decoration: BoxDecoration(
color: _selected
? const Color(0x88ffffff)
: const Color(0x444a5a7a),
shape: isJump ? BoxShape.circle : BoxShape.rectangle,
borderRadius: isJump ? null : BorderRadius.circular(sz * 0.3),
border: Border.all(
color: _selected
? const Color(0xffffd23a)
: const Color(0xff7ae0ff),
width: 2,
),
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
widget.b.label,
style: TextStyle(
color: const Color(0xffffffff),
fontSize: sz * 0.3,
fontWeight: FontWeight.bold,
),
),
if (widget.b.label2 != null)
Text(
widget.b.label2!,
style: TextStyle(
color: const Color(0xffcfe8ff),
fontSize: sz * 0.15,
),
),
],
),
),
),
),
);
}
}
/// 缩放手柄:选中按钮后显示在其右下角的小黄点,拖动改变按钮大小。
/// 单独一层 Positioned(在按钮之后 = 绘制在上层),不吃按钮本体的手势。
class _ResizeHandle extends StatefulWidget {
final TouchBtn b;
final double k;
final double screenW;
final double screenH;
final VoidCallback onChanged;
const _ResizeHandle({
required this.b,
required this.k,
required this.screenW,
required this.screenH,
required this.onChanged,
});
@override
State<_ResizeHandle> createState() => _ResizeHandleState();
}
class _ResizeHandleState extends State<_ResizeHandle> {
bool _active = false;
@override
Widget build(BuildContext context) {
final p = touchLayoutOf(widget.b.key);
final double sz = p['size']! * widget.k;
final double left = p['cx']! / 100 * widget.screenW + sz / 2 - 14;
final double top = p['cy']! / 100 * widget.screenH + sz / 2 - 14;
return Positioned(
left: left,
top: top,
width: 26,
height: 26,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: (_) => setState(() => _active = true),
onPanEnd: (_) => setState(() => _active = false),
onPanCancel: () => setState(() => _active = false),
onPanUpdate: (d) {
final np = touchLayoutOf(widget.b.key);
// 向右/向下拖 = 变大,换算回虚拟 size 单位
final double dSize = (d.delta.dx + d.delta.dy) / widget.k;
np['size'] = (np['size']! + dSize).clamp(30.0, 180.0);
writeTouchLayout(widget.b.key, np);
widget.onChanged();
},
child: Container(
decoration: BoxDecoration(
color: _active ? const Color(0xffffd23a) : const Color(0xccffd23a),
shape: BoxShape.circle,
border: Border.all(color: const Color(0xff2a2a3a), width: 1.5),
),
child: const Center(
child: Icon(Icons.open_in_full, size: 14, color: Colors.black87),
),
),
),
);
}
}
+21 -14
View File
@@ -10,18 +10,29 @@ class TouchBtn {
final double cx; final double cx;
final double cy; final double cy;
final double size; final double size;
const TouchBtn(this.key, this.label, this.shape, this.cx, this.cy, this.size, const TouchBtn(
[this.label2]); this.key,
this.label,
this.shape,
this.cx,
this.cy,
this.size, [
this.label2,
]);
} }
// 默认布局仿照主流 IWBT 手游截图:左下 ◀▶ 并排、右侧大「跳」、
// 左下角🔫射击 / 右下角▼下蹲、右中上 ↺ 自杀、右上角 ❚❚ 暂停。
const List<TouchBtn> BUTTONS = <TouchBtn>[ const List<TouchBtn> BUTTONS = <TouchBtn>[
TouchBtn('left', '◀', 'round', 12, 80, 62), TouchBtn('left', '◀', 'round', 12.5, 81, 62),
TouchBtn('right', '▶', 'round', 24, 80, 62), TouchBtn('right', '▶', 'round', 22, 81, 62),
TouchBtn('jump', '跳', 'jump', 88, 84, 94), TouchBtn('shoot', '🔫', 'item', 78.5, 90, 56, '射击'),
TouchBtn('shoot', '🔫', 'item', 79, 72, 54, '射击'), TouchBtn('down', '▼', 'item', 94.5, 89, 56, '下蹲'),
TouchBtn('down', '▼', 'item', 90, 72, 54, '下蹲'), TouchBtn('pause', '❚❚', 'sm', 96.5, 8, 40),
TouchBtn('pause', '❚❚', 'sm', 81, 58, 42), TouchBtn('suicide', '↺', 'sm', 91, 56, 52),
TouchBtn('suicide', '↺', 'sm', 90, 58, 42), // 跳跃键放最后(绘制在最上层):即便与其它键像素重叠,重叠区也优先归跳跃键,
// 避免按下跳跃时落到射击/下蹲键上导致「跳跃键失灵」。
TouchBtn('jump', '跳', 'jump', 85, 73, 78),
]; ];
const List<List<String>> KEY_ACTIONS = <List<String>>[ const List<List<String>> KEY_ACTIONS = <List<String>>[
@@ -38,11 +49,7 @@ const List<List<String>> KEY_ACTIONS = <List<String>>[
Map<String, dynamic> defaultTouch() { Map<String, dynamic> defaultTouch() {
final m = <String, dynamic>{}; final m = <String, dynamic>{};
for (final b in BUTTONS) { for (final b in BUTTONS) {
m[b.key] = <String, dynamic>{ m[b.key] = <String, dynamic>{'cx': b.cx, 'cy': b.cy, 'size': b.size};
'cx': b.cx,
'cy': b.cy,
'size': b.size,
};
} }
return m; return m;
} }
+366 -50
View File
@@ -12,13 +12,16 @@ import 'package:iwbtc/src/app_root.dart' show AppRoot;
import 'package:iwbtc/src/core/app_error.dart' show AppError; import 'package:iwbtc/src/core/app_error.dart' show AppError;
import 'package:iwbtc/src/core/save.dart' show Save; import 'package:iwbtc/src/core/save.dart' show Save;
import 'package:iwbtc/src/game/levels.dart' show loadLevels, LEVELS; import 'package:iwbtc/src/game/levels.dart' show loadLevels, LEVELS;
import 'package:iwbtc/src/game/world.dart' import 'package:iwbtc/src/game/level.dart' show Level;
show GameHost, World, W, H; import 'package:iwbtc/src/game/world.dart' show GameHost, World, W, H;
import 'package:iwbtc/src/ui/game_canvas.dart' import 'package:iwbtc/src/ui/game_canvas.dart' show GameCanvas, stageMetrics;
show GameCanvas, letterboxRect;
import 'package:iwbtc/src/ui/overlays.dart' show HudOverlay; import 'package:iwbtc/src/ui/overlays.dart' show HudOverlay;
import 'package:iwbtc/src/ui/screens.dart' show SettingsScreen; import 'package:iwbtc/src/ui/screens.dart'
show SettingsScreen, ResultScreen, LevelSelectScreen;
import 'package:iwbtc/src/ui/editor_panel.dart'
show EditorPanel, EditorController;
import 'package:iwbtc/src/ui/touch_controls.dart' show TouchControls; import 'package:iwbtc/src/ui/touch_controls.dart' show TouchControls;
import 'package:iwbtc/src/core/input.dart' show Input;
/// 按典型**横屏手机**分辨率设定虚拟窗口(安卓端已锁定横屏)。 /// 按典型**横屏手机**分辨率设定虚拟窗口(安卓端已锁定横屏)。
const Size kLandscapePhone = Size(2340, 1080); const Size kLandscapePhone = Size(2340, 1080);
@@ -62,15 +65,19 @@ void main() {
final btn = find.text('关卡选择'); final btn = find.text('关卡选择');
final btnSize = tester.getSize(btn); final btnSize = tester.getSize(btn);
final btnPos = tester.getTopLeft(btn); final btnPos = tester.getTopLeft(btn);
expect(btnSize.width, greaterThan(0), expect(
reason: '按钮宽度塌缩为 0,标题树布局被破坏(Positioned 必须是 Stack 直接子级)'); btnSize.width,
greaterThan(0),
reason: '按钮宽度塌缩为 0,标题树布局被破坏(Positioned 必须是 Stack 直接子级)',
);
expect(btnSize.height, greaterThan(0)); expect(btnSize.height, greaterThan(0));
expect(btnPos.dx, greaterThanOrEqualTo(0), reason: '按钮被定位到屏外'); expect(btnPos.dx, greaterThanOrEqualTo(0), reason: '按钮被定位到屏外');
expect(btnPos.dy, greaterThanOrEqualTo(0)); expect(btnPos.dy, greaterThanOrEqualTo(0));
}); });
testWidgets('横屏小逻辑高度下标题不溢出(真机 BOTTOM OVERFLOWED 回归)', testWidgets('横屏小逻辑高度下标题不溢出(真机 BOTTOM OVERFLOWED 回归)', (
(WidgetTester tester) async { WidgetTester tester,
) async {
// 真机横屏 1080 物理高 ÷ DPR ≈ 360~400 **逻辑**像素;此前测试用的 // 真机横屏 1080 物理高 ÷ DPR ≈ 360~400 **逻辑**像素;此前测试用的
// 2340×1080 是逻辑尺寸、远高于真机,所以测不出溢出。 // 2340×1080 是逻辑尺寸、远高于真机,所以测不出溢出。
await tester.binding.setSurfaceSize(const Size(800, 360)); await tester.binding.setSurfaceSize(const Size(800, 360));
@@ -91,13 +98,15 @@ void main() {
expect(find.text('关卡选择'), findsOneWidget); expect(find.text('关卡选择'), findsOneWidget);
expect(tester.getSize(find.text('关卡选择')).height, greaterThan(0)); expect(tester.getSize(find.text('关卡选择')).height, greaterThan(0));
// 一屏放不下的底部文案必须滚动可达 // 整体等比缩小方案(FittedBox.scaleDown):不滚动、不溢出,
// 底部版权行在一屏内直接可见(只是整体缩小了)。
final footer = find.textContaining('非商业同人作品'); final footer = find.textContaining('非商业同人作品');
await tester.scrollUntilVisible(footer, 120); expect(footer, findsOneWidget, reason: '标题内容应等比缩小后完整可见,而不是被裁掉');
expect(footer, findsOneWidget); final btn = tester.getSize(find.text('关卡选择'));
expect(btn.height, greaterThan(0));
}); });
testWidgets('游戏画布拿到真实尺寸并按信箱缩放', (WidgetTester tester) async { testWidgets('游戏画布铺满全屏(无信箱黑边)且虚拟视口完整包含 800×608', (WidgetTester tester) async {
await _boot(tester); await _boot(tester);
final canvas = find.byType(GameCanvas); final canvas = find.byType(GameCanvas);
@@ -107,18 +116,14 @@ void main() {
expect(size.width, greaterThan(0), reason: '画布宽度为 0,界面不会显示任何内容'); expect(size.width, greaterThan(0), reason: '画布宽度为 0,界面不会显示任何内容');
expect(size.height, greaterThan(0)); expect(size.height, greaterThan(0));
expect(size.width, kLandscapePhone.width); expect(size.width, kLandscapePhone.width);
// 800×608 的画面按信箱缩放居中:横屏下高度占满、左右留黑边
final double s = // 横屏比 800×608 宽 → 高度定标 608、视口横向加宽,铺满全屏无黑边
size.width / W < size.height / H ? size.width / W : size.height / H; final m = stageMetrics(size);
final stage = letterboxRect(size); expect(m.isValid, isTrue);
expect(s, greaterThan(0)); expect(m.scale, closeTo(size.height / H, 0.001));
expect(stage.width, closeTo(800 * s, 0.001)); expect(m.vh, closeTo(H, 0.001));
expect(stage.height, closeTo(608 * s, 0.001)); expect(m.vw, closeTo(size.width / m.scale, 0.001));
expect(stage.left, closeTo((size.width - 800 * s) / 2, 0.001)); expect(m.vw, greaterThanOrEqualTo(W), reason: '视口必须完整包含原 800 宽的画面(不裁游戏内容)');
expect(stage.width, lessThan(size.width),
reason: '横屏宽高比大于画面比例,应当左右留黑边');
expect(stage.height, closeTo(size.height, 0.001),
reason: '横屏下画面高度应被占满');
}); });
testWidgets('HUD / Toast 有可见尺寸(不得横向塌缩)', (WidgetTester tester) async { testWidgets('HUD / Toast 有可见尺寸(不得横向塌缩)', (WidgetTester tester) async {
@@ -142,23 +147,30 @@ void main() {
toast.dispose(); toast.dispose();
}); });
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(
MaterialApp(
home: Scaffold( home: Scaffold(
body: Stack( body: Stack(
children: <Widget>[ children: <Widget>[
Positioned.fill(child: HudOverlay(hud: hud, boss: boss, toast: toast)), Positioned.fill(
child: HudOverlay(hud: hud, boss: boss, toast: toast),
),
], ],
), ),
), ),
)); ),
);
await tester.pump(); await tester.pump();
// 关卡名必须被撑开到有宽度(Positioned 嵌套错误时会塌缩为 0) // 关卡名必须被撑开到有宽度(Positioned 嵌套错误时会塌缩为 0)
final name = find.text('测试关卡'); final name = find.text('测试关卡');
expect(name, findsOneWidget); expect(name, findsOneWidget);
final Size size = tester.getSize(name); final Size size = tester.getSize(name);
expect(size.width, greaterThan(0), expect(
reason: 'HUD 宽度为 0:Positioned 未作为 Stack 直接子级,布局被丢弃'); size.width,
greaterThan(0),
reason: 'HUD 宽度为 0:Positioned 未作为 Stack 直接子级,布局被丢弃',
);
expect(size.height, greaterThan(0)); expect(size.height, greaterThan(0));
// Toast 同样必须可见 // Toast 同样必须可见
@@ -171,8 +183,7 @@ void main() {
expect(find.text('存档 0/4'), findsOneWidget); expect(find.text('存档 0/4'), findsOneWidget);
}); });
testWidgets('从标题点进关卡能真正开始游戏(端到端)', testWidgets('从标题点进关卡能真正开始游戏(端到端)', (WidgetTester tester) async {
(WidgetTester tester) async {
await _boot(tester); await _boot(tester);
// 进入关卡选择(注意:游戏 Ticker 常驻,不能用 pumpAndSettle) // 进入关卡选择(注意:游戏 Ticker 常驻,不能用 pumpAndSettle)
@@ -195,8 +206,7 @@ void main() {
expect(hudName, findsWidgets); expect(hudName, findsWidgets);
}); });
testWidgets('横屏下虚拟手柄按钮落在画面区域内(不飘到黑边上)', testWidgets('横屏下虚拟手柄按钮锚定全屏且完整在屏内', (WidgetTester tester) async {
(WidgetTester tester) async {
await tester.binding.setSurfaceSize(kLandscapePhone); await tester.binding.setSurfaceSize(kLandscapePhone);
addTearDown(() => tester.binding.setSurfaceSize(null)); addTearDown(() => tester.binding.setSurfaceSize(null));
SharedPreferences.setMockInitialValues(<String, Object>{}); SharedPreferences.setMockInitialValues(<String, Object>{});
@@ -215,45 +225,176 @@ void main() {
// 需要一个 World 才有回调目标(构造不触发任何加载)。 // 需要一个 World 才有回调目标(构造不触发任何加载)。
final world = World(_Host()); final world = World(_Host());
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(
MaterialApp(
home: Scaffold( home: Scaffold(
body: Stack( body: Stack(
children: <Widget>[ children: <Widget>[
Positioned.fill( Positioned.fill(
child: TouchControls( child: TouchControls(
world: world, visible: visible, hasGun: hasGun), world: world,
visible: visible,
hasGun: hasGun,
),
), ),
], ],
), ),
), ),
)); ),
);
await tester.pump(); await tester.pump();
final Size screen = tester.getSize(find.byType(TouchControls)); final Size screen = tester.getSize(find.byType(TouchControls));
final stage = letterboxRect(screen); // 画面铺满全屏后,按钮直接按整屏百分比锚定(不再有黑边偏移)
expect(stage.left, greaterThan(0), reason: '横屏下应存在左右黑边');
// 「◀」左移键必须落在画面矩形内,而不是黑边上 // 「◀」左移键必须完整在屏内
final leftBtn = find.text('◀'); final leftBtn = find.text('◀');
expect(leftBtn, findsOneWidget); expect(leftBtn, findsOneWidget);
final Size btnSize = tester.getSize(leftBtn); final Size btnSize = tester.getSize(leftBtn);
expect(btnSize.width, greaterThan(0)); expect(btnSize.width, greaterThan(0));
final Offset pos = tester.getTopLeft(leftBtn); final Offset pos = tester.getTopLeft(leftBtn);
expect(pos.dx, greaterThanOrEqualTo(stage.left), expect(pos.dx, greaterThanOrEqualTo(0), reason: '左移键被定位到屏外');
reason: '左移键飘到了左侧黑边上(应锚定在画面区域内)'); expect(pos.dx + btnSize.width, lessThanOrEqualTo(screen.width));
expect(pos.dx + btnSize.width, lessThanOrEqualTo(stage.right));
// 「跳」键同理,且必须完整在屏内 // 「跳」键同理,且必须完整在屏内
final jumpBtn = find.text('跳'); final jumpBtn = find.text('跳');
expect(jumpBtn, findsOneWidget); expect(jumpBtn, findsOneWidget);
final Offset jp = tester.getTopLeft(jumpBtn); final Offset jp = tester.getTopLeft(jumpBtn);
final Size js = tester.getSize(jumpBtn); final Size js = tester.getSize(jumpBtn);
expect(jp.dx, greaterThanOrEqualTo(stage.left)); expect(jp.dx, greaterThanOrEqualTo(0));
expect(jp.dx + js.width, lessThanOrEqualTo(screen.width)); expect(jp.dx + js.width, lessThanOrEqualTo(screen.width));
expect(js.width, greaterThan(0)); expect(js.width, greaterThan(0));
}); });
testWidgets('设置界面能渲染,手柄预览可见', (WidgetTester tester) async { testWidgets('虚拟跳跃键:在按键外松手也能复位(不卡住常按)', (WidgetTester tester) async {
// 回归:旧实现用 Listener(不捕获指针),手指在键上滑动后于键外松手时,
// 松手事件落到别的控件上 → touch['jump'] 永久卡在 true,之后每次按下都
// 因 setTouch 短路而不触发新边沿 → 跳跃键经常失灵。改用 GestureDetector(Pan)
// 后,抬起/取消事件必定回传本按钮,松手即复位。
await tester.binding.setSurfaceSize(kLandscapePhone);
addTearDown(() => tester.binding.setSurfaceSize(null));
SharedPreferences.setMockInitialValues(<String, Object>{});
await tester.runAsync(() async {
await Save.load();
await loadLevels();
});
final visible = ValueNotifier<bool>(true);
final hasGun = ValueNotifier<bool>(false);
addTearDown(() {
visible.dispose();
hasGun.dispose();
Input.clear();
});
final world = World(_Host());
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Stack(
children: <Widget>[
Positioned.fill(
child: TouchControls(
world: world,
visible: visible,
hasGun: hasGun,
),
),
],
),
),
),
);
await tester.pump();
final center = tester.getCenter(find.text('跳'));
final g = await tester.startGesture(center);
await tester.pump();
expect(Input.held('jump'), isTrue, reason: '按下后跳跃键应为按下态');
// 手指滑到屏幕左上角再松手(旧实现会丢失松手事件 → 卡住常按)
await g.moveTo(const Offset(5, 5));
await g.up();
await tester.pump();
expect(Input.held('jump'), isFalse, reason: '在键外松手后跳跃键必须复位,否则会卡住常按导致后续失灵');
});
testWidgets('系统返回手势:关卡选择 → 返回标题(不退出应用)', (WidgetTester tester) async {
await _boot(tester);
// 进入关卡选择
await tester.tap(find.text('关卡选择'));
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(find.text('返回'), findsWidgets);
// 模拟系统返回手势/返回键
await tester.binding.handlePopRoute();
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
// 回到标题(标题菜单重新出现),应用未退出
expect(find.text('自制关卡'), findsOneWidget);
expect(find.textContaining('23 个内置关卡'), findsOneWidget);
});
testWidgets('系统返回手势:游戏中 → 暂停菜单;再按 → 继续游戏', (WidgetTester tester) async {
await _boot(tester);
await tester.tap(find.text('关卡选择'));
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
final card = find.text(LEVELS.first['name'] as String);
await tester.tap(card.first);
await tester.pump(const Duration(milliseconds: 200));
await tester.pump();
// 游戏中按返回 → 应弹出暂停菜单,而不是退出应用
await tester.binding.handlePopRoute();
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(find.text('继续'), findsOneWidget, reason: '返回应打开暂停菜单');
// 暂停中再按返回 → 继续游戏(暂停菜单消失)
await tester.binding.handlePopRoute();
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(find.text('继续'), findsNothing, reason: '返回应恢复游戏');
});
testWidgets('自制关卡编辑器在横屏小屏下不溢出(RIGHT OVERFLOWED 回归)', (
WidgetTester tester,
) async {
// 回归:属性栏 200px 宽里塞三个默认 64px 最小宽的按钮必然横向溢出;
// 顶部工具行在窄屏也可能溢出。修复后按钮紧凑化 + 工具行可换行。
await tester.binding.setSurfaceSize(const Size(800, 360));
addTearDown(() => tester.binding.setSurfaceSize(null));
SharedPreferences.setMockInitialValues(<String, Object>{});
await tester.runAsync(() async {
await Save.load();
await loadLevels();
});
final c = EditorController();
c.open(25, 19);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: EditorPanel(controller: c, onBack: () {}),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(AppError.current.value, isNull, reason: '编辑器渲染时抛出了溢出/布局异常');
expect(find.text('删除'), findsOneWidget);
expect(find.text('+面'), findsOneWidget);
expect(find.text('保存'), findsOneWidget);
});
testWidgets('设置界面渲染,并能进入独立全屏的自定义按键页', (WidgetTester tester) async {
await _boot(tester); await _boot(tester);
await tester.tap(find.text('设置')); await tester.tap(find.text('设置'));
@@ -262,12 +403,120 @@ void main() {
expect(AppError.current.value, isNull, reason: '设置界面渲染时抛出了异常'); expect(AppError.current.value, isNull, reason: '设置界面渲染时抛出了异常');
expect(find.byType(SettingsScreen), findsOneWidget); expect(find.byType(SettingsScreen), findsOneWidget);
// 手柄预览区(无线端:安卓默认 isTouch=true,键位区按设计不显示)
expect(find.text('手机操作按钮'), findsOneWidget); expect(find.text('手机操作按钮'), findsOneWidget);
final Size btn = tester.getSize(find.text('跳').first); // 触屏端入口:进入独立全屏编辑页
expect(btn.width, greaterThan(0), reason: '手柄预览按钮塌缩为 0 宽'); expect(find.text('自定义按键'), findsOneWidget);
expect(find.text('恢复默认'), findsOneWidget); expect(find.text('恢复默认'), findsOneWidget);
expect(find.text('确定'), findsOneWidget); expect(find.text('确定'), findsOneWidget);
// 进入自定义按键页:应全屏显示,所有虚拟键可编辑
await tester.tap(find.text('自定义按键'));
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(AppError.current.value, isNull, reason: '自定义按键页渲染时抛出了异常');
expect(find.text('返回'), findsWidgets); // 编辑页返回按钮
expect(find.text('恢复默认'), findsOneWidget);
// 所有默认虚拟键都在编辑页里
for (final label in <String>['◀', '▶', '跳', '🔫', '▼', '❚❚', '↺']) {
expect(find.text(label), findsOneWidget, reason: '缺少 $label 按钮');
}
// 跳跃键必须有可见尺寸(布局编辑器按钮不能塌缩)
expect(tester.getSize(find.text('跳')).width, greaterThan(0));
// 拖动跳跃键 → 布局即时保存;返回设置页
final before = tester.getTopLeft(find.text('跳'));
await tester.dragFrom(
tester.getCenter(find.text('跳')),
const Offset(60, -40),
);
await tester.pump();
final after = tester.getTopLeft(find.text('跳'));
expect(after.dx, greaterThan(before.dx), reason: '拖动后按钮应移动');
await tester.tap(find.text('返回').last);
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(find.text('自定义按键'), findsOneWidget, reason: '应回到设置页');
});
testWidgets('结算界面在横屏小高度下不溢出(BOTTOM OVERFLOWED 回归)', (
WidgetTester tester,
) async {
// 回归:_Modal 固定内容(标题+4 行数据+新纪录+3 按钮)在 ~360 逻辑高的
// 横屏手机上曾超出屏高。修复后改为紧凑固定卡片(不滚动、不撑满全屏),
// 内容压到能在 360 逻辑高内完整放下。
await tester.binding.setSurfaceSize(const Size(800, 360));
addTearDown(() => tester.binding.setSurfaceSize(null));
SharedPreferences.setMockInitialValues(<String, Object>{});
await tester.runAsync(() async {
await Save.load();
await loadLevels();
});
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ResultScreen(
world: World(_Host()),
result: <String, dynamic>{
'name': 'DOUBLE JUMP',
'ms': 13490,
'deaths': 0,
'apples': 0,
'totalApples': 0,
'better': true,
'hasNext': true,
},
),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(AppError.current.value, isNull, reason: '结算界面渲染时抛出了溢出/布局异常');
expect(find.text('CLEAR!'), findsOneWidget);
// 三个按钮都必须构建且可见(在滚动区内)
expect(find.text('下一关'), findsOneWidget);
expect(find.text('再挑战'), findsOneWidget);
expect(tester.getSize(find.text('下一关')).height, greaterThan(0));
});
testWidgets('关卡选择:已通关卡片的 ✔ 角标不报 ParentDataWidget 错误', (
WidgetTester tester,
) async {
// 回归:_LevelCard 曾把「已通关 ✔ 角标」写成内层 Column 的直接子级
// Positioned(Positioned 必须是 Stack 直接子级)→ 一旦该卡片被构建就抛
// Incorrect use of ParentDataWidget。先标记一关为已通关,使其渲染角标。
await tester.binding.setSurfaceSize(const Size(800, 360));
addTearDown(() => tester.binding.setSurfaceSize(null));
SharedPreferences.setMockInitialValues(<String, Object>{});
await tester.runAsync(() async {
await Save.load();
await loadLevels();
});
final id = LEVELS.first['id'] as String? ?? '';
Save.clear(id, 3, 12345, 0);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: LevelSelectScreen(
world: World(_Host()),
reload: ValueNotifier<int>(0),
onChanged: () {},
),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(AppError.current.value, isNull,
reason: '已通关卡片渲染时抛出了 ParentDataWidget 异常');
// 角标应作为外层 Stack 的直接子级正常渲染(不报错)。
expect(find.text('✔'), findsWidgets, reason: '已通关角标未正常渲染');
}); });
testWidgets('桌面(非触屏)设置界面显示键位重绑', (WidgetTester tester) async { testWidgets('桌面(非触屏)设置界面显示键位重绑', (WidgetTester tester) async {
@@ -279,15 +528,18 @@ void main() {
await loadLevels(); await loadLevels();
}); });
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(
MaterialApp(
home: Scaffold( home: Scaffold(
body: SettingsScreen( body: SettingsScreen(
world: World(_Host()), world: World(_Host()),
isTouch: false, isTouch: false,
onBack: () {}, onBack: () {},
onTouchEditor: () {},
), ),
), ),
)); ),
);
await tester.pump(); await tester.pump();
expect(AppError.current.value, isNull, reason: '设置界面构建时抛出了异常'); expect(AppError.current.value, isNull, reason: '设置界面构建时抛出了异常');
@@ -302,6 +554,70 @@ void main() {
expect(find.text('Shift'), findsWidgets); expect(find.text('Shift'), findsWidgets);
}); });
testWidgets('进出编辑器多次不报 setState-after-dispose', (
WidgetTester tester,
) async {
// 回归:EditorPanel 在 initState 把 setState 闭包挂到共享的
// EditorController.onChange,离开时若不清掉,再次进入触发
// controller.showOrOpen() → onChange?.call() 会对已 dispose 的 State
// 调 setState(setState() called after dispose)。
await _boot(tester);
// 进编辑器
await tester.tap(find.text('自制关卡'));
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(find.byType(EditorPanel), findsOneWidget, reason: '应进入编辑器');
expect(AppError.current.value, isNull, reason: '首次进入不应抛异常');
// 返回标题(EditorPanel 被 dispose)
await tester.tap(find.text('返回').first);
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(find.byType(EditorPanel), findsNothing, reason: '应离开编辑器');
// 再次进入:此处同步触发 controller.showOrOpen() → onChange?.call()
await tester.tap(find.text('自制关卡'));
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(AppError.current.value, isNull,
reason: '再次进入编辑器时不应抛 setState-after-dispose');
expect(find.byType(EditorPanel), findsOneWidget);
});
testWidgets('含 warp 但无匹配门的关卡可正常构建(doorId 类型回归)', (
WidgetTester tester,
) async {
// 回归:Level.reset 给 warp 实体的 doorId 赋值时,若 (tx,ty) 在 data.doors
// 里找不到对应门,会回退到实体序号 idx(int)。原字段声明为 String?,把
// int 赋给 String? 即抛 "type 'int' is not a subtype of type 'String?'",
// 在关卡选择里点开这类自制关卡便崩溃(崩溃栈:Level.reset → new Level →
// World.startLevel → LevelSelectScreen._play)。修复后 doorId 为 Object?。
final data = <String, dynamic>{
'id': 'test-warp',
'name': 'warp 关卡',
'type': 'custom',
'width': 12,
'grid': <String>[
' ',
' W ',
' ',
],
'doors': <dynamic>[],
};
late Level lv;
// 此前此处直接抛 TypeError,导致第二次点开地图即崩溃
expect(() {
lv = Level(data);
}, returnsNormally, reason: '含 warp 但无门的关卡不应抛类型错误');
final warp = lv.entities.firstWhere(
(e) => e.type == 'warp',
orElse: () => throw StateError('未生成 warp 实体'),
);
// 无匹配门时 doorId 回退为序号(int),与 web 原版弱类型语义一致
expect(warp.doorId, isA<int>(), reason: '无门时 doorId 应回退为 int 序号');
});
testWidgets('内置关卡清单能从 assets 正确加载', (WidgetTester tester) async { testWidgets('内置关卡清单能从 assets 正确加载', (WidgetTester tester) async {
await tester.runAsync(() async { await tester.runAsync(() async {
await Save.load(); await Save.load();
-324
View File
@@ -1,324 +0,0 @@
:root{
--bg:#0a0a10; --bg2:#13131c; --line:#2c2c3c;
--fg:#e8e8f0; --dim:#9a9ab0;
--red:#e03050; --red2:#ff5c7a;
--gold:#ffc63a; --green:#3ddc84; --blue:#4aa8ff; --purple:#a86bff;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overscroll-behavior:none}
body{
background:
radial-gradient(circle at 50% 20%, #1a1a28 0%, #08080d 70%);
color:var(--fg);
font-family:"Courier New",Consolas,Monaco,monospace;
display:flex;align-items:center;justify-content:center;
overflow:hidden;
}
#app{width:100%;height:100%;display:flex;align-items:center;justify-content:center;padding:8px}
#stage{
position:relative;
width:min(100%, calc((100vh - 16px) * 800 / 608));
aspect-ratio:800/608;
background:#000;
border:3px solid var(--line);
box-shadow:0 0 0 3px #000, 0 12px 60px rgba(0,0,0,.8);
overflow:hidden;
}
canvas#game{
width:100%;height:100%;display:block;
image-rendering:pixelated;image-rendering:crisp-edges;
background:#0d0d16;
}
/* ---------- HUD ---------- */
#hud{position:absolute;inset:0;pointer-events:none;font-size:clamp(9px,1.35vh,14px)}
.hud-row{position:absolute;top:8px;left:8px;right:8px;display:flex;gap:6px;flex-wrap:wrap}
.hud-box{
background:rgba(0,0,0,.55);border:1px solid rgba(255,255,255,.18);
padding:3px 8px;letter-spacing:.5px;color:#fff;text-shadow:0 1px 0 #000;
}
.hud-box#hud-save.on{color:#d9b3ff;border-color:rgba(176,107,255,.7);box-shadow:0 0 8px rgba(176,107,255,.35)}
#boss-bar{position:absolute;top:38px;left:50%;transform:translateX(-50%);width:60%;min-width:220px}
.boss-name{text-align:center;font-size:clamp(10px,1.6vh,15px);letter-spacing:3px;color:var(--red2);text-shadow:0 2px 0 #000;margin-bottom:3px}
.boss-track{height:12px;background:#22222e;border:2px solid #000;box-shadow:0 0 0 1px #555}
.boss-fill{height:100%;width:100%;background:linear-gradient(#ff6b8a,#c01a3a);transition:width .12s linear}
#toast{
position:absolute;left:50%;top:34%;transform:translateX(-50%);
background:rgba(0,0,0,.72);border:1px solid #fff3;padding:8px 16px;
font-size:clamp(11px,2vh,18px);color:#fff;opacity:0;transition:opacity .2s;max-width:80%;text-align:center;line-height:1.5;
}
#toast.show{opacity:1}
/* ---------- Screens ---------- */
.screen{
position:absolute;inset:0;display:flex;flex-direction:column;
align-items:center;justify-content:center;gap:2vh;padding:3vh 3vw;
background:linear-gradient(#0a0a12ee,#0a0a12f6);
backdrop-filter:blur(2px);
font-size:clamp(10px,1.6vh,15px);
}
.screen.hidden{display:none}
#hud.hidden,#boss-bar.hidden{display:none}
.hidden{display:none !important} /* 通用:任何元素加 hidden 类即隐藏(如自制页「管理地图」按钮) */
.title-wrap{text-align:center;margin-bottom:1vh}
.title-sub{color:var(--dim);letter-spacing:6px;font-size:.75em}
.title-main{
font-size:clamp(22px,5.6vh,54px);line-height:1.05;letter-spacing:2px;
text-shadow:3px 3px 0 #000, 0 0 24px rgba(224,48,80,.35);
}
.title-main .red{color:var(--red);text-shadow:3px 3px 0 #000,0 0 20px rgba(224,48,80,.6)}
.title-tag{margin-top:1vh;color:var(--gold);letter-spacing:2px;font-size:.9em}
.menu{display:flex;flex-direction:column;gap:8px;width:min(340px,80%)}
.btn{
background:var(--bg2);color:var(--fg);border:2px solid var(--line);
padding:9px 14px;font-family:inherit;font-size:1em;letter-spacing:2px;
cursor:pointer;transition:.12s;text-align:center;
}
.btn:hover{background:var(--red);border-color:var(--red2);color:#fff;transform:translateY(-1px)}
.btn:active{transform:translateY(1px)}
.btn.big{font-size:1.25em;padding:12px;background:var(--red);border-color:var(--red2)}
.btn.big:hover{background:#ff4466}
.btn.small{font-size:.8em;padding:6px;color:var(--dim)}
.btn.tiny{font-size:.8em;padding:5px 10px}
.btn.go{background:var(--green);border-color:#7dffb0;color:#04180d}
.foot{position:absolute;bottom:10px;color:#5a5a70;font-size:.72em;text-align:center;width:100%;padding:0 12px}
/* ---------- Panels ---------- */
.panel{
background:var(--bg2);border:2px solid var(--line);
width:min(760px,96%);max-height:94%;display:flex;flex-direction:column;
padding:14px;gap:10px;overflow:hidden;
}
.panel.narrow{width:min(400px,92%)}
.panel-head{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);padding-bottom:6px}
.head-btns{display:flex;gap:6px;align-items:center}
.head-btns .btn.on{background:var(--red);border-color:var(--red2);color:#fff}
.panel h2{font-size:1.3em;letter-spacing:3px}
.panel h3{font-size:1em;letter-spacing:2px;color:var(--gold);margin-bottom:5px}
.tabs{display:flex;gap:6px;flex-wrap:wrap}
.tab{
background:#1b1b26;border:1px solid var(--line);color:var(--dim);
padding:4px 12px;cursor:pointer;font-size:.9em;letter-spacing:1px;font-family:inherit;
}
.tab.on{background:var(--red);border-color:var(--red2);color:#fff}
.level-grid{
display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));
gap:8px;overflow-y:auto;padding:4px 2px;flex:1;align-content:start;
}
.lv-card{
background:#1a1a25;border:1px solid var(--line);padding:8px;cursor:pointer;
transition:.12s;position:relative;text-align:left;
}
.lv-card:hover{border-color:var(--red2);background:#24243a;transform:translateY(-2px)}
.lv-card .idx{color:#55556a;font-size:.75em}
.lv-card .nm{font-size:1.05em;margin:2px 0 5px;letter-spacing:1px}
.lv-card .meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;font-size:.78em;color:var(--dim)}
.lv-card .st{margin-top:5px;font-size:.78em;color:var(--dim)}
.lv-card.cleared{border-color:#2f6b45}
.lv-card .chk{position:absolute;right:7px;top:6px;color:var(--green)}
/* 自制页管理态:删除按钮 */
.lv-card.manage{cursor:default;border-color:var(--red2)}
.lv-card.manage:hover{transform:none;border-color:var(--red);background:#241a22}
.lv-del{
position:absolute;right:6px;top:6px;width:22px;height:22px;padding:0;
background:#3a1420;border:1px solid var(--red2);color:var(--red);
font-family:inherit;font-size:.8em;line-height:1;cursor:pointer;border-radius:2px;
}
.lv-del:hover{background:var(--red);color:#fff;border-color:var(--red)}
.tag{padding:1px 6px;border:1px solid;font-size:.85em}
.t-jump{color:#ff8a5c;border-color:#ff8a5c}
.t-endur{color:#4ad0ff;border-color:#4ad0ff}
.t-boss{color:#ff5c7a;border-color:#ff5c7a}
.t-troll{color:#c08aff;border-color:#c08aff}
.t-custom{color:#ffc63a;border-color:#ffc63a}
.legend{display:flex;gap:8px;flex-wrap:wrap;border-top:1px solid var(--line);padding-top:7px;color:var(--dim);font-size:.85em;align-items:center}
.clear-title{text-align:center;font-size:2.4em;color:var(--gold);letter-spacing:6px;text-shadow:3px 3px 0 #000}
.result-body{display:flex;flex-direction:column;gap:5px}
.rline{display:flex;justify-content:space-between;border-bottom:1px dotted var(--line);padding:4px 2px}
.rline span{color:var(--dim)}
.rbest{color:var(--green);font-size:.85em;text-align:center;margin-top:4px}
/* ---------- Editor ---------- */
.editor-wrap{display:flex;gap:10px;flex:1;overflow:hidden}
.palette{width:112px;display:flex;flex-direction:column;gap:3px;overflow-y:auto;border-right:1px solid var(--line);padding-right:6px}
.pal{
display:flex;align-items:center;gap:5px;background:#1b1b26;border:1px solid var(--line);
padding:2px 5px;cursor:pointer;font-size:.75em;color:var(--dim);
}
.pal:hover{border-color:#888}
.pal.on{border-color:var(--gold);color:#fff;background:#2b2b3c}
.pal-cv{width:26px;height:26px;flex:none;border:1px solid #000;background:#101018;image-rendering:pixelated}
.pal .ch{font-weight:bold;color:var(--fg);width:12px;text-align:center}
.editor-main{flex:1;display:flex;flex-direction:column;gap:6px;min-width:0;overflow:auto}
.editor-bar{display:flex;gap:5px;align-items:center;flex-wrap:wrap}
.editor-bar input,.editor-bar select{
background:#111119;border:1px solid var(--line);color:var(--fg);
font-family:inherit;font-size:.85em;padding:4px 6px;
}
#ed-name{width:110px}#ed-w{width:52px}#ed-h{width:52px}
.ed-wh{display:inline-flex;align-items:center;gap:3px;font-size:.78em;color:var(--dim)}
.ed-wh input{width:48px}
canvas#ed-canvas{
width:800px;height:auto;background:#0d0d16;border:1px solid var(--line);
image-rendering:pixelated;cursor:crosshair;display:block;
}
.editor-tip{color:var(--dim);font-size:.78em}
.editor-side{width:170px;display:flex;flex-direction:column;gap:5px;border-left:1px solid var(--line);padding-left:8px;overflow-y:auto}
#my-levels{display:flex;flex-direction:column;gap:4px}
.my-lv{background:#1b1b26;border:1px solid var(--line);padding:4px 6px;font-size:.78em;cursor:pointer;display:flex;justify-content:space-between;gap:4px}
.my-lv:hover{border-color:var(--gold)}
.my-lv.on{border-color:var(--gold);background:#2b2b3c}
.my-lv .x{color:var(--red)}
.face-btns{display:flex;gap:4px;flex-wrap:wrap}
#ed-faces{display:flex;flex-direction:column;gap:4px;max-height:160px;overflow-y:auto}
.face-row{background:#1b1b26;border:1px solid var(--line);padding:3px 5px;font-size:.78em;cursor:pointer;display:flex;flex-direction:column;gap:2px}
.face-row:hover{border-color:var(--gold)}
.face-row.on{border-color:var(--gold);background:#2b2b3c}
.face-row .face-name{background:#111119;border:1px solid var(--line);color:var(--fg);font-family:inherit;font-size:1em;padding:2px 4px;width:100%}
.face-row .face-wh{color:var(--dim);font-size:.9em}
/* 属性面板(选中方块) */
#ed-props{display:flex;flex-direction:column;gap:5px;max-height:36vh;overflow-y:auto;border:1px solid var(--line);background:#111119;padding:6px}
.prop-empty{color:var(--dim);font-size:.76em;line-height:1.5}
.prop-head{color:var(--gold);font-weight:bold;font-size:.92em;letter-spacing:1px;margin-bottom:2px}
.prop-row{display:flex;flex-direction:column;gap:3px;background:#1b1b26;border:1px solid var(--line);padding:4px 5px;font-size:.78em}
.prop-lab{color:var(--fg)}
.prop-note{color:var(--dim);font-size:.92em}
.prop-ctl{display:flex;gap:4px}
.prop-in{background:#111119;border:1px solid var(--line);color:var(--fg);font-family:inherit;font-size:1em;padding:3px 4px;width:100%}
.prop-ctl .prop-in{width:auto;flex:1}
/* 「保存」去向提示 */
.save-state{color:var(--dim);font-size:.78em;white-space:nowrap;align-self:center}
.save-state.on{color:var(--gold)}
/* 导入地图弹窗 */
.modal{position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;background:rgba(4,4,10,.72);padding:4vh 4vw}
.modal.hidden{display:none}
.modal-panel{background:var(--bg2);border:2px solid var(--line);width:min(380px,94%);max-height:86vh;display:flex;flex-direction:column;gap:8px;padding:14px}
.modal-panel h3{font-size:1.05em;letter-spacing:2px;color:var(--gold)}
.modal-tip{color:var(--dim);font-size:.78em;line-height:1.5}
#ed-import-list{display:flex;flex-direction:column;gap:4px;overflow-y:auto}
.imp-row{background:#1b1b26;border:1px solid var(--line);padding:6px 8px;font-size:.84em;cursor:pointer;display:flex;justify-content:space-between;align-items:center;gap:6px}
.imp-row:hover{border-color:var(--gold)}
.imp-row.on{border-color:var(--gold);background:#2b2b3c}
.imp-nm{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.imp-meta{color:var(--dim);font-size:.9em;white-space:nowrap}
.imp-empty{color:var(--dim);font-size:.8em;padding:10px 2px}
.modal-btns{display:flex;justify-content:flex-end;gap:6px}
/* ---------- 设置界面 ---------- */
.title-user{color:var(--dim);font-size:.85em;letter-spacing:2px;margin-top:1vh}
.set-body{display:flex;flex-direction:column;gap:14px;overflow-y:auto;padding:2px}
.set-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.set-row.col{flex-direction:column;align-items:stretch;gap:6px;justify-content:flex-start}
.set-lab{color:var(--gold);letter-spacing:1px;font-size:.95em;min-width:64px}
.set-in{background:#111119;border:1px solid var(--line);color:var(--fg);font-family:inherit;font-size:1em;padding:6px 8px;min-width:200px}
.set-in:focus{outline:none;border-color:var(--gold)}
.set-hint{color:var(--dim);font-size:.78em}
.set-btns{margin-top:2px}
/* 键盘按键映射面板(电脑端) */
.set-keys{display:flex;flex-direction:column;gap:8px}
.krow{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.kact{color:var(--fg);min-width:104px;font-size:.92em}
.kkeys{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.kchip{
position:relative;background:#1a1a26;border:1px solid var(--line);color:var(--fg);
font-family:inherit;font-size:.88em;padding:5px 22px 5px 10px;border-radius:7px;cursor:pointer;
transition:border-color .1s,background .1s;
}
.kchip:hover{border-color:var(--gold)}
.kchip.listen{border-color:var(--gold);background:rgba(255,198,58,.16);box-shadow:0 0 0 2px rgba(255,198,58,.25)}
.kchip .krm{
position:absolute;top:-7px;right:-7px;width:16px;height:16px;border-radius:50%;
background:#c01a3a;color:#fff;font-size:.62em;line-height:16px;text-align:center;
border:1px solid #000;cursor:pointer;
}
.kadd{
background:transparent;border:1px dashed var(--line);color:var(--dim);
font-family:inherit;font-size:.82em;padding:5px 9px;border-radius:7px;cursor:pointer;
}
.kadd:hover{color:var(--gold);border-color:var(--gold)}
.klisten{color:var(--gold);font-size:.8em;animation:blink 1s infinite}
.klisten.bad{color:#ff6b6b;animation:none;font-weight:bold}
.kwarb{
width:100%;box-sizing:border-box;margin:0 0 10px;padding:8px 12px;border-radius:8px;
background:rgba(192,26,58,.16);border:1px solid #c01a3a;color:#ff8a8a;font-size:.86em;
}
@keyframes blink{50%{opacity:.4}}
/* 手机手柄预览区(与舞台同比例),按钮绝对定位可拖拽 */
.touch-preview{
position:relative;width:100%;aspect-ratio:800/608;
background:#0d0d16;border:1px solid var(--line);overflow:hidden;max-height:46vh;
}
.tp-tip{
position:absolute;top:6px;left:50%;transform:translateX(-50%);
color:var(--dim);font-size:.72em;background:rgba(0,0,0,.45);
padding:2px 8px;border-radius:6px;pointer-events:none;z-index:3;white-space:nowrap;
}
::-webkit-scrollbar{width:8px;height:8px}
::-webkit-scrollbar-track{background:#111}
::-webkit-scrollbar-thumb{background:#333;}
@media (max-width:720px){
.editor-wrap{flex-direction:column;overflow-y:auto}
.palette{width:100%;flex-direction:row;flex-wrap:wrap;border-right:none;border-bottom:1px solid var(--line);padding-bottom:6px}
.editor-side{width:100%;border-left:none;border-top:1px solid var(--line);padding-top:8px}
}
/* ---------- 移动端虚拟手柄(参考 IWBT Creator 触屏布局) ---------- */
/* 桌面端兜底:只要 <html> 没有 .touch 类(即未检测到移动端/触屏),无论如何都不显示按钮 */
html:not(.touch) #touch{display:none !important}
#touch{
position:absolute;inset:0;z-index:25;
display:flex;justify-content:space-between;align-items:flex-end;
padding:12px 12px max(14px, env(safe-area-inset-bottom));
pointer-events:none; /* 仅按钮可点,中间区域穿透到画布 */
touch-action:none;
}
#touch.hidden{display:none}
.tc-left,.tc-right{display:flex;gap:14px;align-items:flex-end;pointer-events:none}
.tc-right{flex-direction:column;align-items:flex-end}
.tc-top{display:flex;gap:10px;margin-bottom:10px}
.tc-mid{display:flex;gap:12px;align-items:flex-end;margin-bottom:10px}
.tc-btn{
pointer-events:auto;-webkit-user-select:none;user-select:none;touch-action:none;
position:absolute;transform:translate(-50%,-50%);
background:rgba(18,18,28,.52);border:2px solid rgba(255,255,255,.28);
color:#fff;font-family:inherit;display:flex;align-items:center;justify-content:center;
flex-direction:column;line-height:1;backdrop-filter:blur(2px);
box-shadow:0 2px 8px rgba(0,0,0,.35);
transition:transform .05s, background .1s, filter .1s;
}
.tc-btn span{font-size:1.5em}
.tc-btn .lbl{font-size:.6em;letter-spacing:1px;opacity:.85;margin-top:2px}
.tc-btn.on,.tc-btn:active{transform:translate(-50%,-50%) scale(.93);background:rgba(224,48,80,.6);border-color:#ff5c7a}
/* 设置界面预览里的缩放手柄(真实手柄不挂此元素) */
.tc-rsz{
position:absolute;right:-7px;bottom:-7px;width:16px;height:16px;border-radius:50%;
background:var(--gold);border:2px solid #000;cursor:nwse-resize;opacity:.92;touch-action:none;
box-shadow:0 1px 4px rgba(0,0,0,.5);
}
.tc-move{width:62px;height:62px;border-radius:14px}
.tc-item{width:54px;height:54px;border-radius:50%}
.tc-sm{width:42px;height:42px;border-radius:11px;font-size:1em}
.tc-jump{
width:94px;height:94px;border-radius:50%;margin-top:4px;
background:radial-gradient(circle at 35% 30%, #ff7088, #c01a3a);
border:3px solid #ffd0da;box-shadow:0 6px 18px rgba(192,26,58,.5);
font-size:1.9em;font-weight:bold;color:#fff;
}
.tc-jump.on,.tc-jump:active{transform:translate(-50%,-50%) scale(.92);filter:brightness(1.12)}
/* 未持枪时射击键置灰 */
#tc-gun.off{opacity:.4;filter:grayscale(1);background:rgba(18,18,28,.4)}
-25
View File
@@ -1,25 +0,0 @@
// One-off: export all built-in levels to JSON for the Flutter port.
import { LEVELS, TYPE_LABEL, TYPE_CLASS } from './js/levels/index.js';
import { writeFileSync } from 'fs';
// Sanity: ensure serializable and report shapes.
function deepCheck(v, path) {
if (v === null || v === undefined) return;
if (typeof v === 'function') throw new Error('function at ' + path);
if (typeof v === 'object') {
if (Array.isArray(v)) { v.forEach((e, i) => deepCheck(e, path + '[' + i + ']')); }
else { for (const k of Object.keys(v)) deepCheck(v[k], path + '.' + k); }
}
}
for (const l of LEVELS) deepCheck(l, l.id || '?');
const out = {
levels: LEVELS,
typeLabel: TYPE_LABEL,
typeClass: TYPE_CLASS,
};
writeFileSync('levels_dump.json', JSON.stringify(out));
console.log('OK levels=', LEVELS.length, 'bytes=', JSON.stringify(out).length);
for (const l of LEVELS) {
console.log(' -', l.id, l.name, 'type=' + l.type, 'w=' + l.width, 'rows=' + (l.grid ? l.grid.length : '?'));
}
-207
View File
@@ -1,207 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>I WANNA BE THE CREATOR — Fan Project</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div id="app">
<div id="stage">
<canvas id="game" width="800" height="608"></canvas>
<!-- ===== HUD ===== -->
<div id="hud" class="hidden">
<div class="hud-row">
<div class="hud-box" id="hud-level">—</div>
<div class="hud-box" id="hud-deaths">DEATH 0</div>
<div class="hud-box" id="hud-time">0:00.00</div>
<div class="hud-box" id="hud-apples" style="display:none">🍎 0/0</div>
<div class="hud-box" id="hud-save" style="display:none">SAVE 0/0</div>
</div>
<div id="boss-bar" class="hidden">
<div class="boss-name" id="boss-name">BOSS</div>
<div class="boss-track"><div class="boss-fill" id="boss-fill"></div></div>
</div>
<div id="toast"></div>
</div>
<!-- ===== MAIN MENU ===== -->
<div class="screen" id="scr-title">
<div class="title-wrap">
<div class="title-sub">FAN-MADE TRIBUTE PROJECT</div>
<h1 class="title-main">I WANNA BE<br>THE <span class="red">CREATOR</span></h1>
<div class="title-tag">跳刺 · 耐久 · BOSS · 坑向 — 23 个内置关卡</div>
</div>
<div class="menu">
<button class="btn big" data-act="levels">关卡选择</button>
<button class="btn" data-act="editor">自制关卡</button>
<button class="btn" data-act="settings">设置</button>
</div>
<div id="title-user" class="title-user"></div>
<div class="foot">非商业同人作品 · 致敬 I Wanna Be The Guy / I Wanna Be The Creator · 素材与代码原创</div>
</div>
<!-- ===== LEVEL SELECT ===== -->
<div class="screen hidden" id="scr-levels">
<div class="panel">
<div class="panel-head">
<h2>关卡选择</h2>
<div class="head-btns">
<button class="btn tiny hidden" id="manage-btn" data-act="manage">管理地图</button>
<button class="btn tiny" data-act="back">返回</button>
</div>
</div>
<div class="tabs" id="level-tabs"></div>
<div class="level-grid" id="level-grid"></div>
<div class="legend">
<span class="tag t-jump">跳刺</span>
<span class="tag t-endur">耐久</span>
<span class="tag t-boss">BOSS</span>
<span class="tag t-troll">坑向</span>
<span class="tag t-custom">自制</span>
</div>
</div>
</div>
<!-- ===== PAUSE ===== -->
<div class="screen hidden" id="scr-pause">
<div class="panel narrow">
<div class="panel-head"><h2>暂停</h2></div>
<div class="menu">
<button class="btn" data-act="resume">继续</button>
<button class="btn" data-act="restart">从头再来(清空存档点)</button>
<button class="btn" data-act="quit">返回关卡选择</button>
</div>
</div>
</div>
<!-- ===== RESULT ===== -->
<div class="screen hidden" id="scr-result">
<div class="panel narrow">
<h2 class="clear-title">CLEAR!</h2>
<div class="result-body">
<div class="rline"><span>关卡</span><b id="r-name">—</b></div>
<div class="rline"><span>用时</span><b id="r-time">—</b></div>
<div class="rline"><span>死亡</span><b id="r-death">—</b></div>
<div class="rline"><span>苹果</span><b id="r-apple">—</b></div>
<div class="rbest" id="r-best"></div>
</div>
<div class="menu">
<button class="btn" data-act="next">下一关</button>
<button class="btn" data-act="retry">再挑战</button>
<button class="btn" data-act="quit">关卡选择</button>
</div>
</div>
</div>
<!-- ===== SETTINGS ===== -->
<div class="screen hidden" id="scr-settings">
<div class="panel">
<div class="panel-head">
<h2>设置</h2>
<button class="btn tiny" data-act="back">返回</button>
</div>
<div class="set-body">
<div class="set-row">
<label class="set-lab" for="set-username">用户名</label>
<input id="set-username" class="set-in" maxlength="16" placeholder="无名氏">
<span class="set-hint">显示在标题界面</span>
</div>
<div class="set-row col" id="set-keys-row">
<label class="set-lab">键盘按键</label>
<span class="set-hint">点击按键后按下新键即可重绑;同一动作可绑定多个键,点 × 删除</span>
<div id="set-keys" class="set-keys"></div>
</div>
<div class="set-row col" id="set-touch-row">
<label class="set-lab">手机操作按钮</label>
<span class="set-hint">拖动按钮调整位置,拖动右下角圆点调整大小(仅移动端生效)</span>
<div id="touch-preview" class="touch-preview">
<div class="tp-tip">拖动按钮移动位置 · 拖动右下角圆点缩放大小</div>
</div>
</div>
</div>
<div class="modal-btns set-btns">
<button class="btn tiny" data-act="set-reset">恢复默认</button>
<button class="btn tiny go" data-act="set-ok">确定</button>
</div>
</div>
</div>
<!-- ===== EDITOR ===== -->
<div class="screen hidden" id="scr-editor">
<div class="panel">
<div class="panel-head">
<h2>自制关卡</h2>
<button class="btn tiny" data-act="back">返回</button>
</div>
<div class="editor-wrap">
<div class="palette" id="palette"></div>
<div class="editor-main">
<div class="editor-bar">
<input id="ed-name" placeholder="地图名" value="MY MAP">
<label class="ed-wh">宽<input id="ed-w" type="number" value="25" min="12" max="160"></label>
<label class="ed-wh">高<input id="ed-h" type="number" value="19" min="8" max="80"></label>
<button class="btn tiny" data-act="ed-resize">调整</button>
<button class="btn tiny" data-act="ed-fill">铺底</button>
<button class="btn tiny" data-act="ed-clear">清空</button>
<button class="btn tiny" data-act="ed-import">导入地图</button>
<button class="btn tiny" data-act="ed-test">试玩</button>
<button class="btn tiny go" data-act="ed-save">保存</button>
<span id="ed-savestate" class="save-state">→ 新地图</span>
</div>
<canvas id="ed-canvas" width="800" height="608"></canvas>
<div class="editor-tip">左键放置 / 右键擦除,滚轮水平平移。<b>不会覆盖已有方块</b>——放在空格才生效,要换掉先擦除。选「⌖ 查看属性」再点方块可只查看不修改。<b>刺</b>只有一个,放好后在右侧「属性」面板调<b>方向(上/下/左/右)</b>;传送门 <b>W</b>、提示牌 <b>T</b>、炮台 <b>O</b>、摄像头 <b>C</b>、BOSS <b>B</b> 的属性也都在这里设置。每面至少 1 个 <b>S</b>(出生点) 与 1 个 <b>X</b>(终点)。</div>
</div>
<div class="editor-side">
<h3>属性(选中方块)</h3>
<div id="ed-props"><div class="prop-empty">点击画布上的方块,查看并设置它的属性</div></div>
<h3>面(多面地图)</h3>
<div class="face-btns">
<button class="btn tiny" data-act="ed-addface">+ 新增面</button>
<button class="btn tiny" data-act="ed-copyface">复制面</button>
<button class="btn tiny" data-act="ed-delface">删除面</button>
</div>
<div id="ed-faces"></div>
</div>
</div>
<!-- 导入地图选择 -->
<div id="ed-importbox" class="modal hidden">
<div class="modal-panel">
<h3>导入地图</h3>
<p class="modal-tip">从以前保存过的地图里选一个载入编辑器(会替换当前编辑内容)</p>
<div id="ed-import-list"></div>
<div class="modal-btns">
<button class="btn tiny" data-act="ed-import-cancel">取消</button>
</div>
</div>
</div>
</div>
</div>
<!-- ===== TOUCH CONTROLS(移动端虚拟手柄,参考 IWBT Creator 触屏布局) ===== -->
<div id="touch" class="hidden">
<!-- 左侧:左右移动 -->
<div class="tc-left">
<button class="tc-btn tc-move" data-touch="left" aria-label="左移"><span>◀</span></button>
<button class="tc-btn tc-move" data-touch="right" aria-label="右移"><span>▶</span></button>
</div>
<!-- 右侧:跳跃 + 各种道具 -->
<div class="tc-right">
<div class="tc-top">
<button class="tc-btn tc-sm" data-touch="pause" aria-label="暂停">❚❚</button>
<button class="tc-btn tc-sm" data-touch="suicide" aria-label="重生">↺</button>
</div>
<div class="tc-mid">
<button class="tc-btn tc-item" id="tc-gun" data-touch="shoot" aria-label="射击"><span>🔫</span><span class="lbl">射击</span></button>
<button class="tc-btn tc-item" data-touch="down" aria-label="下蹲/下穿"><span>▼</span><span class="lbl">下蹲</span></button>
</div>
<button class="tc-btn tc-jump" data-touch="jump" aria-label="跳跃"><span>跳</span></button>
</div>
</div>
</div>
</div>
<script type="module" src="js/main.js"></script>
</body>
</html>
-71
View File
@@ -1,71 +0,0 @@
// 纯 WebAudio 合成音效,无外部资源
let ctx = null, master = null;
export const Audio2 = { enabled: true, volume: 0.5 };
function ac() {
if (!ctx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
ctx = new AC();
master = ctx.createGain();
master.gain.value = Audio2.volume;
master.connect(ctx.destination);
}
if (ctx.state === 'suspended') ctx.resume();
return ctx;
}
export function unlockAudio() { ac(); }
function tone({ f = 440, f2 = null, d = 0.1, type = 'square', vol = 0.25, delay = 0, decay = 0 }) {
if (!Audio2.enabled) return;
const c = ac(); if (!c) return;
const t0 = c.currentTime + delay;
const o = c.createOscillator();
const g = c.createGain();
o.type = type;
o.frequency.setValueAtTime(f, t0);
if (f2 !== null) o.frequency.exponentialRampToValueAtTime(Math.max(20, f2), t0 + d);
g.gain.setValueAtTime(0.0001, t0);
g.gain.linearRampToValueAtTime(vol, t0 + 0.008);
const end = t0 + d + decay;
g.gain.exponentialRampToValueAtTime(0.0001, end);
o.connect(g); g.connect(master);
o.start(t0); o.stop(end + 0.02);
}
function noise({ d = 0.2, vol = 0.25, delay = 0, hp = 400 }) {
if (!Audio2.enabled) return;
const c = ac(); if (!c) return;
const t0 = c.currentTime + delay;
const len = Math.floor(c.sampleRate * d);
const buf = c.createBuffer(1, len, c.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < len; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / len);
const src = c.createBufferSource(); src.buffer = buf;
const f = c.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = hp;
const g = c.createGain(); g.gain.value = vol;
src.connect(f); f.connect(g); g.connect(master);
src.start(t0);
}
export const sfx = {
jump() { tone({ f: 380, f2: 700, d: 0.09, type: 'square', vol: 0.16 }); },
djump() { tone({ f: 520, f2: 900, d: 0.09, type: 'square', vol: 0.14 }); },
shoot() { tone({ f: 900, f2: 260, d: 0.07, type: 'square', vol: 0.14 }); noise({ d: 0.05, vol: 0.06, hp: 2000 }); },
death() { tone({ f: 620, f2: 60, d: 0.55, type: 'sawtooth', vol: 0.26 });
noise({ d: 0.35, vol: 0.16, hp: 300, delay: 0.02 }); },
save() { tone({ f: 700, d: 0.08, type: 'triangle', vol: 0.2 });
tone({ f: 1050, d: 0.13, type: 'triangle', vol: 0.18, delay: 0.07 }); },
goal() { [523, 659, 784, 1047].forEach((f, i) =>
tone({ f, d: 0.16, type: 'triangle', vol: 0.2, delay: i * 0.1 })); },
warp() { tone({ f: 300, f2: 1400, d: 0.2, type: 'sine', vol: 0.18 }); },
hit() { tone({ f: 200, f2: 90, d: 0.08, type: 'square', vol: 0.16 }); },
bossHit() { tone({ f: 260, f2: 140, d: 0.1, type: 'sawtooth', vol: 0.14 }); },
bossDie() { [400, 330, 260, 180, 110].forEach((f, i) =>
tone({ f, d: 0.22, type: 'sawtooth', vol: 0.22, delay: i * 0.11 }));
noise({ d: 0.8, vol: 0.2, hp: 150, delay: 0.1 }); },
menu() { tone({ f: 620, d: 0.05, type: 'square', vol: 0.12 }); },
vanish() { tone({ f: 700, f2: 300, d: 0.12, type: 'sine', vol: 0.1 }); },
troll() { tone({ f: 180, f2: 900, d: 0.3, type: 'sine', vol: 0.18 }); },
splash() { noise({ d: 0.18, vol: 0.12, hp: 800 }); },
};
-325
View File
@@ -1,325 +0,0 @@
// BOSS 战系统:多阶段弹幕 / 冲撞 / 刺雨
import { clamp, sgn, rand, randInt, overlap } from './util.js';
import { sfx } from './audio.js';
import { TILE } from './util.js';
const DEFS = {
guy: { hp: 42, w: 64, h: 74, name: 'THE GUY' },
mecha: { hp: 62, w: 78, h: 62, name: 'MECHA KID' },
devil: { hp: 84, w: 74, h: 90, name: 'THE DEVIL' },
};
export class Boss {
constructor(level, e) {
const d = DEFS[e.kind] || DEFS.guy;
this.level = level;
this.kind = e.kind;
this.name = d.name;
this.x = e.x; this.y = e.y;
this.w = d.w; this.h = d.h;
this.maxHp = e.hp || d.hp;
this.hp = this.maxHp;
this.flash = 0;
this.dead = false;
this.deathT = 0;
this.bodyKill = true;
this.t = 0;
this.phase = 1;
this.vx = 0; this.vy = 0;
this.state = 'idle';
this.stateT = 0;
// BOSS 贴地活动:保证玩家在地面平射就能命中
this.bounds = {
x0: 40, x1: level.pxW - 40 - this.w,
y0: 40, y1: level.pxH - 32 - this.h, // 底部贴地
};
this.homeY = clamp(e.y, this.bounds.y0, this.bounds.y1);
this.y = this.homeY;
this.homeX = e.x;
this.wing = 0;
}
get rect() { return { x: this.x, y: this.y, w: this.w, h: this.h }; }
shoot(level, x, y, vx, vy, col = '#ff5c7a', big = false) {
level.ebullets.push({ x: x - 4, y: y - 4, w: big ? 12 : 8, h: big ? 12 : 8, vx, vy, dead: false, col, big, life: 500 });
}
hit(dmg = 1) {
if (this.dead || this.flash > 6) return;
this.hp -= dmg;
this.flash = 10;
sfx.bossHit();
this.level.particles.spawn(this.x + this.w / 2, this.y + this.h / 2, 6,
{ colors: ['#fff', '#ffc63a'], speed: 2.6, life: 18 });
const r = this.hp / this.maxHp;
this.phase = r > 0.6 ? 1 : r > 0.3 ? 2 : 3;
if (this.hp <= 0) this.die();
}
die() {
this.dead = true; this.hp = 0; this.bodyKill = false;
sfx.bossDie();
this.level.shake = 16;
this.level.particles.burst(this.x + this.w / 2, this.y + this.h / 2,
{ n: 40, colors: ['#ff5c7a', '#ffc63a', '#fff', '#e03050'], speed: 5, life: 60, size: 4 });
}
update(dt, player) {
if (this.dead) {
this.deathT++;
if (this.deathT % 8 === 0 && this.deathT < 90) {
this.level.particles.burst(this.x + rand(0, this.w), this.y + rand(0, this.h),
{ n: 6, colors: ['#ffc63a', '#ff5c7a', '#fff'], speed: 3, life: 30, size: 3 });
sfx.hit();
}
if (this.deathT === 90) { if (this.onDefeat) this.onDefeat(); }
return;
}
this.t++;
this.stateT++;
if (this.flash > 0) this.flash--;
// 子类型行为
if (this.kind === 'guy') this.updateGuy(player);
else if (this.kind === 'mecha') this.updateMecha(player);
else this.updateDevil(player);
this.x += this.vx; this.y += this.vy;
this.x = clamp(this.x, this.bounds.x0, Math.max(this.bounds.x0, this.bounds.x1));
this.y = clamp(this.y, this.bounds.y0, Math.max(this.bounds.y0, this.bounds.y1));
// 被子弹击中
for (const b of this.level.bullets) {
if (b.dead) continue;
if (overlap(b, this.rect)) {
b.dead = true;
this.hit(1);
}
}
}
// ---------- THE GUY:飘移 + 扇形弹 + 砸地 ----------
updateGuy(player) {
const L = this.level;
if (this.phase === 1) {
this.vx = Math.sin(this.t / 60) * 1.5;
this.y = this.homeY + Math.sin(this.t / 45) * 8;
if (this.t % 96 === 0) {
const cx = this.x + this.w / 2, cy = this.y + this.h / 2;
for (let i = -1; i <= 1; i++) {
const a = Math.atan2(player.cy - cy, player.cx - cx) + i * 0.28;
this.shoot(L, cx, cy, Math.cos(a) * 2.8, Math.sin(a) * 2.8);
}
sfx.shoot();
}
} else {
// 追踪 + 砸地
const dir = sgn(player.cx - (this.x + this.w / 2));
if (this.state === 'idle') {
this.vx = dir * 1.9;
this.y = this.homeY + Math.sin(this.t / 30) * 6;
if (this.stateT > 70) { this.state = 'slam'; this.stateT = 0; this.vy = 0; }
} else if (this.state === 'slam') {
this.vx *= 0.9;
this.vy = Math.min(this.vy + 0.7, 11);
if (this.y + this.h >= this.bounds.y1 + this.h * 0.2 || this.y >= this.homeY + 150) {
// 落地冲击:左右各飞出 3 发
this.state = 'idle'; this.stateT = 0; this.vy = -3;
L.shake = 10; sfx.hit();
for (let i = 0; i < 4; i++) {
this.shoot(L, this.x + this.w / 2, this.y + this.h - 6, -2.6 - i * 0.4, -1.2 - i * 0.15, '#ffc63a');
this.shoot(L, this.x + this.w / 2, this.y + this.h - 6, 2.6 + i * 0.4, -1.2 - i * 0.15, '#ffc63a');
}
}
}
}
}
// ---------- MECHA KID:冲撞 + 刺雨 ----------
updateMecha(player) {
const L = this.level;
if (this.state === 'idle') {
this.vx *= 0.92;
this.y = this.homeY + Math.sin(this.t / 40) * 5;
if (this.stateT > 100 - this.phase * 18) {
this.state = 'dash'; this.stateT = 0;
this.vx = sgn(player.cx - (this.x + this.w / 2)) * (5 + this.phase * 0.8);
sfx.shoot();
}
if (this.t % 150 === 0) {
// 刺雨
for (let i = 0; i < 4 + this.phase; i++) {
const tx = randInt(1, L.w - 2);
L.entities.push({
type: 'dropspike', x: tx * TILE, y: -TILE, w: TILE, h: TILE,
falling: true, vy: 0, homeY: -TILE, t: 0, temp: true,
});
}
L.showToast('刺雨来了!', 80);
sfx.troll();
}
} else if (this.state === 'dash') {
this.vy = 0;
if (this.x <= this.bounds.x0 + 1 || this.x >= this.bounds.x1 - 1 || this.stateT > 90) {
this.state = 'idle'; this.stateT = 0; this.vx = 0;
L.shake = 6;
L.particles.spawn(this.x + this.w / 2, this.y + this.h / 2, 8,
{ colors: ['#9ad8ff', '#fff'], speed: 3, life: 20 });
}
}
}
// ---------- THE DEVIL:追踪弹 / 激光 / 环形弹幕 ----------
updateDevil(player) {
const L = this.level;
this.wing += 0.16;
const cx = this.x + this.w / 2, cy = this.y + this.h / 2;
const p2 = this.phase;
// 移动
if (p2 === 1) { this.vx = Math.sin(this.t / 70) * 1.6; this.y = this.homeY + Math.sin(this.t / 50) * 8; }
else if (p2 === 2) { this.vx = Math.sin(this.t / 40) * 2.6; this.y = this.homeY + Math.sin(this.t / 28) * 11; }
else { this.vx = Math.sin(this.t / 25) * 3.4; this.y = this.homeY + Math.sin(this.t / 20) * 14; }
if (p2 === 1) {
if (this.t % 60 === 0) {
const a = Math.atan2(player.cy - cy, player.cx - cx);
for (let i = -1; i <= 1; i++)
this.shoot(L, cx, cy, Math.cos(a + i * 0.2) * 3, Math.sin(a + i * 0.2) * 3, '#ff9a3a');
sfx.shoot();
}
} else if (p2 === 2) {
if (this.t % 110 === 0) { this.state = 'laser'; this.stateT = 0; }
if (this.state === 'laser' && this.stateT < 40 && this.stateT % 5 === 0) {
// 横向激光弹幕(从上往下扫)
const yy = 40 + (this.stateT / 40) * (L.pxH - 90);
this.shoot(L, 8, yy, 6.5, 0, '#ff4060', true);
this.shoot(L, L.pxW - 12, L.pxH - yy, -6.5, 0, '#ff4060', true);
sfx.shoot();
}
if (this.stateT > 44) this.state = 'idle';
} else {
if (this.t % 34 === 0) {
const n = 10;
const off = this.t / 200;
for (let i = 0; i < n; i++) {
const a = off + (i / n) * Math.PI * 2;
this.shoot(L, cx, cy, Math.cos(a) * 2.6, Math.sin(a) * 2.6, '#ff5c7a');
}
sfx.shoot();
}
if (this.t % 170 === 0) {
const a = Math.atan2(player.cy - cy, player.cx - cx);
this.shoot(L, cx, cy, Math.cos(a) * 4.4, Math.sin(a) * 4.4, '#ffc63a', true);
}
}
}
// ---------------- 绘制 ----------------
draw(ctx, t) {
if (this.dead && this.deathT > 96) return;
const x = this.x, y = this.y, w = this.w, h = this.h;
const fl = this.flash > 0;
ctx.save();
if (this.dead) ctx.globalAlpha = Math.max(0, 1 - this.deathT / 96);
const body = fl ? '#fff' : null;
if (this.kind === 'guy') this.drawGuy(ctx, x, y, w, h, body, t);
else if (this.kind === 'mecha') this.drawMecha(ctx, x, y, w, h, body, t);
else this.drawDevil(ctx, x, y, w, h, body, t);
ctx.restore();
}
drawGuy(ctx, x, y, w, h, fl, t) {
const c = fl || '#2a1020', c2 = fl || '#5a1a34';
ctx.fillStyle = c;
ctx.beginPath(); ctx.ellipse(x + w / 2, y + h * 0.62, w * 0.42, h * 0.34, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = c2;
ctx.beginPath(); ctx.ellipse(x + w / 2, y + h * 0.3, w * 0.36, h * 0.3, 0, 0, Math.PI * 2); ctx.fill();
// 角
ctx.fillStyle = fl || '#c8305a';
ctx.beginPath();
ctx.moveTo(x + w * 0.2, y + h * 0.16); ctx.lineTo(x + w * 0.05, y - h * 0.12); ctx.lineTo(x + w * 0.34, y + h * 0.06);
ctx.closePath(); ctx.fill();
ctx.beginPath();
ctx.moveTo(x + w * 0.8, y + h * 0.16); ctx.lineTo(x + w * 0.95, y - h * 0.12); ctx.lineTo(x + w * 0.66, y + h * 0.06);
ctx.closePath(); ctx.fill();
// 眼
ctx.fillStyle = fl ? '#000' : '#ffe14a';
ctx.fillRect(x + w * 0.3, y + h * 0.28, w * 0.14, h * 0.1);
ctx.fillRect(x + w * 0.56, y + h * 0.28, w * 0.14, h * 0.1);
// 嘴
ctx.fillStyle = fl ? '#000' : '#801025';
ctx.fillRect(x + w * 0.34, y + h * 0.46, w * 0.32, h * 0.06);
if (this.phase >= 2) {
ctx.strokeStyle = fl ? '#000' : '#ff4060'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(x + w / 2, y + h / 2, w * 0.62 + Math.sin(t / 90) * 3, 0, Math.PI * 2); ctx.stroke();
}
}
drawMecha(ctx, x, y, w, h, fl, t) {
ctx.fillStyle = fl || '#3a4258';
ctx.fillRect(x + 4, y + 6, w - 8, h - 10);
ctx.fillStyle = fl || '#68728e';
ctx.fillRect(x + 8, y + 10, w - 16, h - 20);
// 眼
const blink = (Math.floor(t / 200) % 5) === 0;
ctx.fillStyle = fl ? '#000' : (this.state === 'dash' ? '#ff4060' : '#7ae0ff');
ctx.fillRect(x + w * 0.22, y + h * 0.34, w * 0.18, h * 0.14);
ctx.fillRect(x + w * 0.6, y + h * 0.34, w * 0.18, h * 0.14);
// 炮口
ctx.fillStyle = fl || '#2a2f40';
ctx.fillRect(x + w * 0.42, y + h - 8, w * 0.16, 10);
// 天线
ctx.fillStyle = fl || '#8a94ad';
ctx.fillRect(x + w / 2 - 1, y - 6, 2, 8);
ctx.fillStyle = (Math.floor(t / 120) % 2) ? '#ff4060' : '#601020';
ctx.beginPath(); ctx.arc(x + w / 2, y - 8, 3, 0, Math.PI * 2); ctx.fill();
// 推进器
if (this.state === 'dash') {
ctx.fillStyle = '#ffb040';
ctx.fillRect(x - 6, y + h * 0.4, 8, h * 0.2);
ctx.fillRect(x + w - 2, y + h * 0.4, 8, h * 0.2);
}
}
drawDevil(ctx, x, y, w, h, fl, t) {
// 翅膀
const fl2 = Math.sin(this.wing) * 8;
ctx.fillStyle = fl || '#4a0f22';
ctx.beginPath();
ctx.moveTo(x + 6, y + h * 0.4);
ctx.quadraticCurveTo(x - 26, y + h * 0.1 + fl2, x - 4, y + h * 0.72 + fl2);
ctx.closePath(); ctx.fill();
ctx.beginPath();
ctx.moveTo(x + w - 6, y + h * 0.4);
ctx.quadraticCurveTo(x + w + 26, y + h * 0.1 + fl2, x + w + 4, y + h * 0.72 + fl2);
ctx.closePath(); ctx.fill();
// 身体
ctx.fillStyle = fl || '#8e1030';
ctx.beginPath(); ctx.ellipse(x + w / 2, y + h * 0.6, w * 0.4, h * 0.33, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = fl || '#c01a3a';
ctx.beginPath(); ctx.ellipse(x + w / 2, y + h * 0.32, w * 0.34, h * 0.29, 0, 0, Math.PI * 2); ctx.fill();
// 角
ctx.fillStyle = fl || '#ffc63a';
ctx.beginPath();
ctx.moveTo(x + w * 0.18, y + h * 0.14); ctx.lineTo(x - w * 0.06, y - h * 0.16); ctx.lineTo(x + w * 0.32, y + h * 0.04);
ctx.closePath(); ctx.fill();
ctx.beginPath();
ctx.moveTo(x + w * 0.82, y + h * 0.14); ctx.lineTo(x + w * 1.06, y - h * 0.16); ctx.lineTo(x + w * 0.68, y + h * 0.04);
ctx.closePath(); ctx.fill();
// 眼(阶段变色)
const ec = this.phase === 3 ? '#7ae0ff' : this.phase === 2 ? '#ffb040' : '#ffe14a';
ctx.fillStyle = fl ? '#000' : ec;
ctx.fillRect(x + w * 0.26, y + h * 0.26, w * 0.16, h * 0.12);
ctx.fillRect(x + w * 0.58, y + h * 0.26, w * 0.16, h * 0.12);
// 牙
ctx.fillStyle = fl ? '#000' : '#fff';
for (let i = 0; i < 4; i++) ctx.fillRect(x + w * 0.32 + i * w * 0.1, y + h * 0.46, 4, 6);
}
}
export function bossName(kind) { return (DEFS[kind] || DEFS.guy).name; }
export { DEFS as BOSS_DEFS };
-90
View File
@@ -1,90 +0,0 @@
// 摄像机:跟随 / 锁定 / 缩放(坑向关的"摄像头"道具靠它搞事)
import { clamp, lerp } from './util.js';
export class Camera {
constructor(w = 800, h = 608) {
this.w = w; this.h = h;
this.x = 0; this.y = 0;
this.zoom = 1;
this.tz = 1;
this.mode = 'follow';
this.lockX = 0; this.lockY = 0;
this.shakeX = 0; this.shakeY = 0;
this.snapped = false;
}
reset(level, player) {
this.zoom = 1; this.tz = 1; this.mode = 'follow';
this.snapTo(player, level);
}
viewW() { return this.w / this.zoom; }
viewH() { return this.h / this.zoom; }
snapTo(player, level) {
this.x = clamp(player.cx - this.viewW() / 2, 0, Math.max(0, level.pxW - this.viewW()));
this.y = clamp(player.cy - this.viewH() / 2, 0, Math.max(0, level.pxH - this.viewH()));
this.mode = 'follow';
this.zoom = 1; this.tz = 1;
}
update(level, player, dt) {
const zone = level.camZone;
let tx, ty, tz = 1, speed = 0.16;
if (zone) {
if (zone.mode === 'lock') {
this.mode = 'lock';
tx = zone.lx !== null ? zone.lx : zone.x;
ty = zone.ly !== null ? zone.ly : zone.y;
tz = zone.zoom || 1;
speed = 0.05; // 缓慢移动,制造迷失感
} else if (zone.mode === 'zoom') {
this.mode = 'zoom';
tz = zone.zoom || 1;
tx = player.cx - this.w / tz / 2;
ty = player.cy - this.h / tz / 2;
speed = 0.14;
} else if (zone.mode === 'off') {
this.mode = 'off';
tx = this.x; ty = this.y; tz = zone.zoom || this.zoom; speed = 0;
}
} else {
this.mode = 'follow';
tx = player.cx - this.w / this.zoom / 2;
ty = player.cy - this.h / this.zoom / 2;
tz = 1;
speed = 0.16;
}
this.tz = lerp(this.tz, tz, 0.06);
if (Math.abs(this.tz - this.zoom) > 0.002) this.zoom = this.tz;
if (speed > 0) {
this.x = lerp(this.x, tx, speed);
this.y = lerp(this.y, ty, speed);
}
this.clampToLevel(level);
// 抖动
const sh = level.shake || 0;
if (sh > 0.2) {
this.shakeX = (Math.random() - 0.5) * sh;
this.shakeY = (Math.random() - 0.5) * sh;
} else { this.shakeX = 0; this.shakeY = 0; }
}
clampToLevel(level) {
const vw = this.viewW(), vh = this.viewH();
if (level.pxW <= vw) { this.x = (level.pxW - vw) / 2; }
else this.x = clamp(this.x, 0, level.pxW - vw);
if (level.pxH <= vh) { this.y = (level.pxH - vh) / 2; }
else this.y = clamp(this.y, 0, level.pxH - vh);
}
apply(ctx) {
ctx.translate(this.shakeX, this.shakeY);
if (this.zoom !== 1) ctx.scale(this.zoom, this.zoom);
ctx.translate(-this.x, -this.y);
}
}
-555
View File
@@ -1,555 +0,0 @@
// 自制关卡编辑器(多面地图 + 可变宽高 + 属性面板)
import { TILE } from './util.js';
import { Save } from './save.js';
import { makeCustomLevel } from './levels/index.js';
import * as S from './sprites.js';
const $ = s => document.querySelector(s);
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
export const PALETTE = [
['#', '方块', '#6b6b7e'], ['@', '草地', '#4f9e46'], ['=', '砖块', '#a8543c'], ['M', '金属', '#5a6478'],
['%', '冰面', '#8fd8f0'], ['~', '水', '#2a6fd8'], ['-', '单向台', '#9aa6bd'], ['V', '消失块', '#7a7a8c'],
['^', '刺', '#c8c8d8'],
['*', '美味水果', '#e03050'], ['S', '出生点', '#3ddc84'], ['G', '存档点', '#b06bff'], ['X', '终点', '#ffc63a'],
['P', '手枪', '#b8b8c8'], ['F', '苹果', '#ff8095'], ['J', '跳刷新', '#3ddc84'], ['W', '传送门', '#a86bff'],
['T', '提示牌', '#c9a86b'], ['O', '炮台', '#6a6a80'], ['D', '掉落刺', '#8a5a6a'], ['C', '摄像头', '#7ae0ff'],
['B', 'BOSS', '#ff5c7a'], ['!', '触发器', '#ff9a3a'], ['.', '橡皮', '#20202c'],
];
// 每个方块类型可编辑的属性(属性面板按此渲染)
// type: portal(传送门目标) | text | num | select
const SCHEMA = {
'^': [{ type: 'select', key: 'dir', label: '方向', def: 'up', opts: [['up', '上'], ['down', '下'], ['left', '左'], ['right', '右']] }],
W: [{ type: 'portal' }],
T: [{ type: 'text', key: 'msg', label: '提示文字', placeholder: '这块牌子显示的话' }],
O: [
{ type: 'select', key: 'dir', label: '朝向', opts: [['left', '左'], ['right', '右'], ['up', '上'], ['down', '下']] },
{ type: 'num', key: 'period', label: '发射间隔(帧)', min: 10, max: 600, def: 90 },
{ type: 'num', key: 'speed', label: '子弹速度', min: 0.5, max: 10, step: 0.1, def: 2.4 },
{ type: 'num', key: 'offset', label: '初始延迟(帧)', min: 0, max: 600, def: 0 },
],
C: [
{ type: 'num', key: 'w', label: '区域宽(格)', min: 1, max: 40, def: 3 },
{ type: 'num', key: 'h', label: '区域高(格)', min: 1, max: 40, def: 3 },
{ type: 'select', key: 'mode', label: '镜头模式', opts: [['lock', '锁定'], ['zoom', '缩放'], ['follow-off', '跟随关闭']] },
{ type: 'num', key: 'zoom', label: '缩放倍数', min: 0.3, max: 3, step: 0.1, def: 1 },
],
B: [
{ type: 'select', key: 'kind', label: 'BOSS 类型', opts: [['guy', '小兵'], ['mecha', '机甲']] },
{ type: 'num', key: 'hp', label: '血量', min: 1, max: 999, def: 40 },
],
};
function charLabel(ch) { const it = PALETTE.find(p => p[0] === ch); return it ? it[1] : ch; }
// 把某个方块按「游戏内真实外观」画到 ctx 的 (px,py) 处 —— 编辑器所见即所得
function drawTileReal(ctx, ch, px, py, props, t) {
const P = props || {};
switch (ch) {
case '#': S.drawBlock(ctx, px, py, 0); break; // 方块
case '@': S.drawBlock(ctx, px, py, 1); break; // 草地
case '=': S.drawBlock(ctx, px, py, 2); break; // 砖块
case 'M': S.drawBlock(ctx, px, py, 3); break; // 金属
case '%': S.drawIce(ctx, px, py); break; // 冰面
case '~': S.drawWater(ctx, px, py, t); break; // 水
case '-': S.drawOneWay(ctx, px, py); break; // 单向台
case 'V': S.drawVanish(ctx, px, py, 0, t); break; // 消失块
case '^': { // 刺:真实三角形,按道具方向
const di = { up: 0, down: 1, left: 2, right: 3 }[P.dir || 'up'] || 0;
S.drawSpike(ctx, px, py, di, t);
break;
}
case '*': S.drawKiller(ctx, px, py, t); break; // 美味水果
case 'D': S.drawDropSpike(ctx, { x: px, y: py, falling: false }, t); break; // 掉落刺
case 'X': S.drawGoal(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 终点
case 'G': S.drawSave(ctx, { x: px, y: py, w: TILE, h: TILE, active: false }, t); break; // 存档点
case 'P': S.drawGun(ctx, { x: px, y: py }, t); break; // 手枪
case 'F': S.drawApple(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 苹果
case 'J': S.drawJumpRef(ctx, px, py, t); break; // 跳刷新
case 'W': S.drawWarp(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 传送门
case 'T': S.drawSign(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 提示牌
case 'O': S.drawTurret(ctx, { x: px, y: py, dir: P.dir || 'left' }, t); break; // 炮台
case 'C': S.drawCamera(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 摄像头
case 'S': S.drawKid(ctx, { // 出生点:画一个小人
x: px + (TILE - 11) / 2, y: py + (TILE - 21), w: 11, h: 21,
facing: 1, grounded: true, hasGun: false, dead: false,
}, t); break;
default: drawMarker(ctx, px, py, ch); break; // BOSS / 触发器等无独立美术:色块+字母兜底
}
}
// 没有独立 sprite 的方块(BOSS / 触发器等):保留「色块 + 字母」保证可见
function drawMarker(ctx, px, py, ch) {
const item = PALETTE.find(p => p[0] === ch);
const col = item ? item[2] : '#555';
ctx.fillStyle = col; ctx.globalAlpha = 0.85;
ctx.fillRect(px + 1, py + 1, TILE - 2, TILE - 2);
ctx.globalAlpha = 1;
ctx.fillStyle = '#fff';
ctx.font = 'bold 14px monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(ch, px + TILE / 2, py + TILE / 2);
}
// 编辑器中刺统一用 '^',方向存在 props.dir;导出时翻译成引擎的四个字符
const SPIKE_CHAR = { up: '^', down: 'v', left: '<', right: '>' };
const SPIKE_DIR = { '^': 'up', 'v': 'down', '<': 'left', '>': 'right' };
export const Editor = {
api: {},
map: null, w: 25, sel: '#',
scroll: 0, drag: 0, cur: null,
canvas: null, ctx: null,
// 多面地图状态
faces: null, face: 0,
// 当前选中的方块坐标(属性面板依据它渲染)
selTile: null,
init(api) {
this.api = api;
this.canvas = $('#ed-canvas');
this.ctx = this.canvas.getContext('2d');
this.buildPalette();
this.bind();
$('#ed-w').addEventListener('change', () => this.resize(+$('#ed-w').value, +$('#ed-h').value));
$('#ed-h').addEventListener('change', () => this.resize(+$('#ed-w').value, +$('#ed-h').value));
},
buildPalette() {
const p = $('#palette');
p.innerHTML = '';
PALETTE.forEach(([ch, label]) => {
const d = document.createElement('div');
d.className = 'pal' + (ch === this.sel ? ' on' : '');
// 用真实绘制函数渲染缩略图,所见即所得
const cv = document.createElement('canvas');
cv.className = 'pal-cv'; cv.width = TILE; cv.height = TILE;
drawTileReal(cv.getContext('2d'), ch, 0, 0, null, 0);
d.appendChild(cv);
const sp = document.createElement('span');
sp.textContent = label;
d.appendChild(sp);
d.onclick = () => {
this.sel = ch;
Array.from(p.children).forEach(c => c.classList.remove('on'));
d.classList.add('on');
};
p.appendChild(d);
});
},
bind() {
const c = this.canvas;
const pos = e => {
const r = c.getBoundingClientRect();
const sx = (e.clientX - r.left) * (c.width / r.width);
const sy = (e.clientY - r.top) * (c.height / r.height);
return { tx: Math.floor((sx + this.scroll) / TILE), ty: Math.floor(sy / TILE) };
};
const act = (e, erase) => {
const f = this.curFace();
const { tx, ty } = pos(e);
if (tx < 0 || ty < 0 || tx >= f.w || ty >= f.h) return;
// 无论手上拿的是什么方块,点击格子都选中它,属性面板立即显示该方块的属性
this.selTile = { tx, ty };
const prev = f.grid[ty][tx];
const ch = erase ? '.' : (this.sel === '.' ? '.' : this.sel);
// 放置不覆盖:放实方块时若该格已有方块则跳过,仅选中查看属性
if (!erase && ch !== '.' && prev !== '.') { this.renderProps(); return; }
// 门的增删:放上传送门自动登记一扇门;擦掉或覆盖传送门则移除对应门
if (prev === 'W' && ch !== 'W') this.removeDoor(f, tx, ty);
if (ch === 'W' && prev !== 'W') { f.seq = (f.seq || 0) + 1; f.doors.push({ id: f.seq, tx, ty, to: null }); }
// 改变方块种类时清空该格属性(属性按类型存储)
if (prev !== ch) f.props[ty][tx] = null;
f.grid[ty][tx] = ch;
this.draw();
this.renderProps();
};
c.addEventListener('mousedown', e => {
e.preventDefault();
this.drag = e.button === 2 ? 2 : 1;
act(e, e.button === 2);
});
c.addEventListener('mousemove', e => { if (this.drag) act(e, this.drag === 2); });
addEventListener('mouseup', () => { this.drag = 0; });
c.addEventListener('contextmenu', e => e.preventDefault());
c.addEventListener('wheel', e => {
e.preventDefault();
this.scroll = Math.max(0, Math.min((this.curFace().w - 25) * TILE, this.scroll + Math.sign(e.deltaY) * TILE * 2));
this.draw();
}, { passive: false });
const on = (actName, fn) => { const el = document.querySelector(`[data-act="${actName}"]`); if (el) el.onclick = fn; };
on('ed-resize', () => this.resize(+$('#ed-w').value, +$('#ed-h').value));
on('ed-fill', () => { const f = this.curFace(); for (let x = 0; x < f.w; x++) f.grid[f.h - 1][x] = '#'; this.draw(); });
on('ed-clear', () => { if (typeof confirm === 'function' && !confirm('清空当前面?')) return; const f = this.curFace(); f.grid = blank(f.w, f.h); f.doors = []; f.props = propsBlank(f.w, f.h); this.selTile = null; this.draw(); this.renderProps(); });
on('ed-test', () => this.test());
on('ed-save', () => this.save());
on('ed-import', () => this.openImport());
on('ed-import-cancel', () => this.closeImport());
on('ed-addface', () => this.addFace());
on('ed-copyface', () => this.copyFace());
on('ed-delface', () => this.delFace());
},
// ---------------- 多面地图状态 ----------------
curFace() { return this.faces[this.face]; },
open() {
// 从菜单进入编辑器:始终是新地图(宽高沿用输入框里的值)
const w = +$('#ed-w').value || 25, h = +$('#ed-h').value || 19;
this.faces = [{ name: '面1', w, h, seq: 0, grid: blank(w, h), doors: [], props: propsBlank(w, h) }];
this.face = 0;
this.cur = null; // 未关联任何已存地图 → 首次保存为新建
this.show();
},
/** 仅重绘当前状态(不从零重置):用于试玩后「返回编辑器」等场景,保留正在编辑的地图 */
show() {
if (!this.faces) { this.open(); return; }
this.selTile = null;
$('#ed-w').value = this.curFace().w;
$('#ed-h').value = this.curFace().h;
this.draw();
this.renderFaces();
this.renderProps();
this.updateSaveState();
},
/** 「保存」按钮旁的提示:会更新哪张图,还是另存为新图 */
updateSaveState() {
const el = $('#ed-savestate');
if (!el) return;
const l = this.cur ? Save.customs().find(o => o.id === this.cur) : null;
el.textContent = l ? '→ 更新「' + (l.name || 'MY MAP') + '」' : '→ 另存为新图';
el.classList.toggle('on', !!l);
},
resize(w, h) {
w = clamp(w || 25, 12, 160);
h = clamp(h || 19, 8, 80);
const f = this.curFace();
const og = f.grid, op = f.props;
f.grid = blank(w, h);
f.props = propsBlank(w, h);
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
if (og[y] && og[y][x] !== undefined) f.grid[y][x] = og[y][x];
if (op[y] && op[y][x] !== undefined) f.props[y][x] = op[y][x];
}
f.doors = f.doors.filter(d => d.tx < w && d.ty < h);
f.w = w; f.h = h;
$('#ed-w').value = w; $('#ed-h').value = h;
this.canvas.height = h * TILE;
this.selTile = null;
this.draw();
this.renderProps();
},
addFace() {
const w = this.curFace().w, h = this.curFace().h;
this.faces.push({ name: '面' + (this.faces.length + 1), w, h, seq: 0, grid: blank(w, h), doors: [], props: propsBlank(w, h) });
this.face = this.faces.length - 1;
this.afterFaceSwitch();
},
copyFace() {
const f = this.curFace();
const copy = {
name: f.name + ' 副本', w: f.w, h: f.h, seq: f.seq,
grid: f.grid.map(r => r.slice()),
doors: f.doors.map(d => ({ ...d })),
props: f.props.map(r => r.map(c => (c ? { ...c } : null))),
};
this.faces.push(copy);
this.face = this.faces.length - 1;
this.afterFaceSwitch();
},
delFace() {
if (this.faces.length <= 1) { alert('至少保留一个面'); return; }
if (typeof confirm === 'function' && !confirm('删除「' + this.curFace().name + '」?该面的门与连接都会消失')) return;
this.faces.splice(this.face, 1);
this.face = Math.max(0, this.face - 1);
this.afterFaceSwitch();
},
afterFaceSwitch() {
$('#ed-w').value = this.curFace().w;
$('#ed-h').value = this.curFace().h;
this.scroll = 0;
this.selTile = null;
this.draw();
this.renderFaces();
this.renderProps();
},
removeDoor(f, tx, ty) {
const i = f.doors.findIndex(d => d.tx === tx && d.ty === ty);
if (i >= 0) f.doors.splice(i, 1);
},
// ---------------- 绘制 ----------------
draw() {
const ctx = this.ctx, c = this.canvas;
const f = this.curFace();
c.width = 800; c.height = f.h * TILE;
ctx.fillStyle = '#101018'; ctx.fillRect(0, 0, c.width, c.height);
ctx.save();
ctx.translate(-this.scroll, 0);
const t = 0; // 编辑器为静态预览,不跑动画
for (let y = 0; y < f.h; y++) {
for (let x = 0; x < f.w; x++) {
const ch = f.grid[y][x];
const px = x * TILE, py = y * TILE;
// 真实外观绘制(刺→三角形,方块→纹理,实体→对应精灵)
if (ch !== '.') drawTileReal(ctx, ch, px, py, f.props[y][x] || null, t);
// 淡网格线(编辑对位用)
ctx.strokeStyle = 'rgba(255,255,255,.06)';
ctx.lineWidth = 1;
ctx.strokeRect(px + .5, py + .5, TILE - 1, TILE - 1);
// 传送门:叠加门编号与连接状态(编辑器元数据,游戏内无此显示)
if (ch === 'W') {
const d = f.doors.find(o => o.tx === x && o.ty === y);
ctx.fillStyle = (d && d.to) ? '#bfffd0' : '#ffe6a0';
ctx.font = 'bold 9px monospace';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText('W' + (d ? d.id : ''), px + 2, py + 2);
}
}
}
// 选中方块高亮
if (this.selTile && this.selTile.tx >= 0 && this.selTile.ty >= 0 && this.selTile.tx < f.w && this.selTile.ty < f.h) {
const sx = this.selTile.tx * TILE, sy = this.selTile.ty * TILE;
ctx.strokeStyle = '#ffd23a'; ctx.lineWidth = 2;
ctx.strokeRect(sx + 1, sy + 1, TILE - 2, TILE - 2);
}
ctx.restore();
},
// ---------------- 面列表面板 ----------------
renderFaces() {
const box = $('#ed-faces');
if (!box) return;
box.innerHTML = '';
this.faces.forEach((f, i) => {
const d = document.createElement('div');
d.className = 'face-row' + (i === this.face ? ' on' : '');
d.innerHTML = `<input class="face-name" value="${escapeHtml(f.name)}" title="面名称"><span class="face-wh">${f.w}×${f.h} · ${f.doors.length}门</span>`;
d.querySelector('.face-name').addEventListener('change', e => { f.name = e.target.value; });
d.querySelector('.face-name').addEventListener('click', e => e.stopPropagation());
d.onclick = () => { this.face = i; this.afterFaceSwitch(); };
box.appendChild(d);
});
},
// ---------------- 属性面板(选中方块) ----------------
renderProps() {
const box = $('#ed-props');
if (!box) return;
const f = this.curFace();
if (!this.selTile) { box.innerHTML = '<div class="prop-empty">点击画布上的方块,查看并设置它的属性</div>'; return; }
const { tx, ty } = this.selTile;
if (tx < 0 || ty < 0 || tx >= f.w || ty >= f.h) { box.innerHTML = '<div class="prop-empty">(越界)</div>'; return; }
const ch = f.grid[ty][tx];
const label = charLabel(ch);
const fields = SCHEMA[ch];
if (!fields) {
box.innerHTML = `<div class="prop-head">「${ch}」 ${label}</div><div class="prop-empty">该方块没有可设置的属性</div>`;
return;
}
box.innerHTML = `<div class="prop-head">「${ch}」 ${label}</div>`;
for (const fd of fields) {
const row = document.createElement('div');
row.className = 'prop-row';
if (fd.type === 'portal') {
const door = f.doors.find(d => d.tx === tx && d.ty === ty);
if (!door) { row.innerHTML = '<span class="prop-lab">跳转到下一关</span><span class="prop-note">(此格未登记为门,请重新放置 W)</span>'; box.appendChild(row); continue; }
const cur = door.to || null;
const tf = cur ? cur.face : this.face;
const td = cur ? cur.door : -1;
const faceOpts = this.faces.map((gf, gi) => `<option value="${gi}" ${gi === tf ? 'selected' : ''}>面${gi + 1}${gf.name !== ('面' + (gi + 1)) ? '·' + escapeHtml(gf.name) : ''}</option>`).join('');
const tface = this.faces[tf];
const doorOpts = (tface.doors.length
? tface.doors.filter(od => !(od.id === door.id && tf === this.face)).map(od => `<option value="${od.id}" ${td === od.id ? 'selected' : ''}>门${od.id}</option>`).join('')
: '') || '<option value="-1">(该面暂无其他门)</option>';
row.innerHTML = `<span class="prop-lab">跳转到下一关</span><div class="prop-ctl"><select class="p-face">${faceOpts}</select><select class="p-door">${doorOpts}</select></div>`;
const fsel = row.querySelector('.p-face');
const dsel = row.querySelector('.p-door');
const apply = () => {
const ntf = +fsel.value, ntd = +dsel.value;
door.to = (ntd >= 0) ? { face: ntf, door: ntd } : null;
this.draw();
this.renderProps();
};
fsel.onchange = apply;
dsel.onchange = apply;
} else if (fd.type === 'text') {
const val = (f.props[ty][tx] || {})[fd.key] || '';
row.innerHTML = `<span class="prop-lab">${fd.label}</span><input class="prop-in" type="text" placeholder="${fd.placeholder || ''}" value="${escapeHtml(val)}">`;
const inp = row.querySelector('.prop-in');
inp.oninput = () => { ensureProp(f, tx, ty)[fd.key] = inp.value; };
} else if (fd.type === 'num') {
const raw = (f.props[ty][tx] || {})[fd.key];
const val = raw !== undefined && raw !== null && raw !== '' ? raw : (fd.def !== undefined ? fd.def : '');
row.innerHTML = `<span class="prop-lab">${fd.label}</span><input class="prop-in" type="number" min="${fd.min !== undefined ? fd.min : ''}" max="${fd.max !== undefined ? fd.max : ''}" step="${fd.step !== undefined ? fd.step : 1}" value="${val}">`;
const inp = row.querySelector('.prop-in');
inp.oninput = () => {
const v = inp.value === '' ? (fd.def !== undefined ? fd.def : null) : (fd.step && String(fd.step).includes('.') ? +inp.value : +inp.value);
ensureProp(f, tx, ty)[fd.key] = v;
};
} else if (fd.type === 'select') {
const cur = (f.props[ty][tx] || {})[fd.key];
const val = cur !== undefined && cur !== '' ? cur : (fd.def !== undefined ? fd.def : '');
const opts = fd.opts.map(([v, t]) => `<option value="${v}" ${val === v ? 'selected' : ''}>${t}</option>`).join('');
row.innerHTML = `<span class="prop-lab">${fd.label}</span><select class="prop-in">${opts}</select>`;
const sel = row.querySelector('.prop-in');
sel.onchange = () => { ensureProp(f, tx, ty)[fd.key] = sel.value; };
}
box.appendChild(row);
}
},
// ---------------- 数据 ----------------
buildData() {
const faces = this.faces.map(f => {
// 编辑器刺统一为 '^'+props.dir,导出时按方向翻译成引擎字符 ^ v < >
const grid = f.grid.map((r, y) => r.map((ch, x) => {
if (ch === '^') return SPIKE_CHAR[((f.props[y][x] || {}).dir) || 'up'];
return ch;
}).join(''));
// 按扫描顺序收集各类型属性,整型数组(引擎按扫描下标读取)
const messages = [], turrets = [], camzones = [], bosses = [];
for (let y = 0; y < f.h; y++) for (let x = 0; x < f.w; x++) {
const ch = f.grid[y][x];
const p = f.props[y][x];
if (ch === 'T' && p && p.msg !== undefined && p.msg !== '') messages.push(p.msg);
if (ch === 'O' && p) turrets.push({ dir: p.dir || 'left', period: +(p.period || 90), speed: +(p.speed || 2.4), offset: +(p.offset || 0), kind: p.kind || 'ball' });
if (ch === 'C' && p) camzones.push({ w: +(p.w || 3), h: +(p.h || 3), mode: p.mode || 'lock', zoom: +(p.zoom || 1) });
if (ch === 'B' && p) bosses.push({ kind: p.kind || 'guy', hp: +(p.hp || 40) });
}
return {
name: f.name,
width: f.w,
height: f.h,
grid,
doors: f.doors.map(d => ({ id: d.id, tx: d.tx, ty: d.ty, to: d.to || null })),
messages, turrets, camzones, bosses,
};
});
return {
name: ($('#ed-name').value || 'MY MAP').toUpperCase(),
type: 'custom',
startFace: 0,
faces,
};
},
validate() {
for (let i = 0; i < this.faces.length; i++) {
const g = this.faces[i].grid.map(r => r.join(''));
const s = g.join('').split('S').length - 1;
const x = g.join('').split('X').length - 1;
if (s !== 1) return `面${i + 1}(${this.faces[i].name})必须且只能有 1 个 S,当前 ${s} 个`;
if (x < 1) return `面${i + 1}(${this.faces[i].name})至少要 1 个 X(终点)`;
}
// 门不能指向自己所在格子
for (let i = 0; i < this.faces.length; i++) {
for (const d of this.faces[i].doors) {
if (d.to && d.to.face === i && d.to.door === d.id) return `面${i + 1} 的门${d.id} 不能指向自己`;
}
}
return null;
},
test() {
const err = this.validate();
if (err) { alert(err); return; }
this.api.startLevel(makeCustomLevel(this.buildData()));
},
save() {
const err = this.validate();
if (err) { alert(err); return; }
let lv = makeCustomLevel(this.buildData());
if (this.cur) lv.id = this.cur; // 保存当前地图:沿用已有 id,就地更新
Save.updateCustom(lv);
this.cur = lv.id;
this.updateSaveState();
this.api.refresh?.();
},
// ---------------- 导入地图(从已保存的地图里选) ----------------
openImport() {
const box = $('#ed-importbox');
if (!box) return;
const list = $('#ed-import-list');
const customs = Save.customs();
list.innerHTML = '';
if (!customs.length) {
list.innerHTML = '<div class="imp-empty">还没有保存过的地图,先「保存」一个吧</div>';
} else {
customs.forEach(l => {
const fn = (l.faces || []).length;
const d = document.createElement('div');
d.className = 'imp-row' + (l.id === this.cur ? ' on' : '');
d.innerHTML = `<span class="imp-nm">${escapeHtml(l.name || 'MY MAP')}</span><span class="imp-meta">${fn ? fn + ' 面' : '单关'}</span>`;
d.onclick = () => this.pickImport(l.id);
list.appendChild(d);
});
}
box.classList.remove('hidden');
},
closeImport() { $('#ed-importbox')?.classList.add('hidden'); },
pickImport(id) {
const l = Save.customs().find(o => o.id === id);
if (!l) return;
this.cur = l.id;
this.hydrate(l);
$('#ed-name').value = l.name || '';
this.closeImport();
this.afterFaceSwitch();
this.updateSaveState();
},
// 从存档/数据对象恢复多面 + 每格属性(按扫描顺序回填 messages/turrets/camzones/bosses)
hydrate(o) {
this.faces = o.faces.map(f => {
const w = f.width || (f.grid[0] ? f.grid[0].length : 25);
const h = f.height || f.grid.length;
const grid = f.grid.map(r => r.split(''));
const props = propsBlank(w, h);
const face = {
name: f.name || '面',
w, h,
seq: (f.doors || []).reduce((m, d) => Math.max(m, d.id), 0),
grid,
doors: (f.doors || []).map(d => ({ id: d.id, tx: d.tx, ty: d.ty, to: d.to || null })),
props,
};
let mi = 0, ti = 0, ci = 0, bi = 0;
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
const ch = grid[y][x];
if (ch === 'T') { const m = (f.messages || [])[mi++]; if (m !== undefined) props[y][x] = { msg: m }; }
else if (ch === 'O') { const t = (f.turrets || [])[ti++]; if (t) props[y][x] = { ...t }; }
else if (ch === 'C') { const c = (f.camzones || [])[ci++]; if (c) props[y][x] = { ...c }; }
else if (ch === 'B') { const b = (f.bosses || [])[bi++]; if (b) props[y][x] = { ...b }; }
// 引擎的四个刺字符 ^ v < > 统一还原为编辑器 '^' + props.dir
else if (SPIKE_DIR[ch] !== undefined) { grid[y][x] = '^'; props[y][x] = { dir: SPIKE_DIR[ch] }; }
}
return face;
});
this.face = 0;
},
};
function blank(w, h) {
return Array.from({ length: h }, () => Array.from({ length: w }, () => '.'));
}
function propsBlank(w, h) {
return Array.from({ length: h }, () => Array.from({ length: w }, () => null));
}
function ensureProp(f, tx, ty) {
if (!f.props[ty][tx]) f.props[ty][tx] = {};
return f.props[ty][tx];
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
-92
View File
@@ -1,92 +0,0 @@
// 输入:IWBT 经典键位(Shift 跳 / Z 跳 / X 射击 / R 重来)
export const BIND = {
left: ['ArrowLeft', 'KeyA'],
right: ['ArrowRight', 'KeyD'],
up: ['ArrowUp', 'KeyW'],
down: ['ArrowDown', 'KeyS'],
jump: ['ShiftLeft', 'ShiftRight', 'Space', 'KeyZ', 'KeyN'],
shoot: ['KeyX', 'KeyJ', 'KeyC', 'KeyM'],
suicide: ['KeyR'], // IWBT 标准:自杀并回到最近存档点
restart: ['Backspace'], // 整关从头再来(放弃所有存档点)
pause: ['Escape', 'KeyP'],
menu: ['KeyQ'],
};
// 出厂键位快照(供设置里的「恢复默认」使用)
const DEFAULT_BIND = JSON.parse(JSON.stringify(BIND));
const down = new Set(); // 当前按住的原始 code
const now = {}; // action 是否按下
const prev = {}; // 上一帧
const touch = {}; // action -> bool(来自触屏虚拟按键)
const blocked = new Set(['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Tab']);
export const Input = {
enabled: true,
/** 是否按下 */
is(action) { return !!now[action]; },
/** 本帧刚按下 */
hit(action) { return !!now[action] && !prev[action]; },
/** 本帧刚松开 */
released(action) { return !now[action] && !!prev[action]; },
raw(code) { return down.has(code); },
anyHit() { for (const k in now) if (now[k] && !prev[k]) return true; return false; },
clear() {
down.clear();
for (const k in now) now[k] = false;
for (const k in prev) prev[k] = false;
},
/** 每逻辑帧末尾调用 */
endFrame() {
for (const k in now) prev[k] = now[k];
},
};
function refresh() {
for (const act in BIND) {
let v = false;
for (const c of BIND[act]) if (down.has(c)) { v = true; break; }
if (touch[act]) v = true; // 触屏虚拟按键
now[act] = Input.enabled ? v : false;
}
}
// ---------------- 触屏虚拟按键接口 ----------------
export function setTouch(action, on) {
if (!(action in touch)) touch[action] = false;
if (touch[action] === on) return;
touch[action] = on;
refresh();
}
export function clearTouch() {
let any = false;
for (const k in touch) if (touch[k]) { touch[k] = false; any = true; }
if (any) refresh();
}
addEventListener('keydown', e => {
if (blocked.has(e.code)) e.preventDefault();
if (e.repeat) return;
down.add(e.code);
refresh();
});
addEventListener('keyup', e => {
down.delete(e.code);
refresh();
});
addEventListener('blur', () => { down.clear(); refresh(); });
// ---------------- 键位自定义(设置界面调用) ----------------
/** 返回一份出厂键位的深拷贝 */
export function defaultBindings() { return JSON.parse(JSON.stringify(DEFAULT_BIND)); }
/** 用一份 {action: [code,...]} 映射覆盖当前生效键位(设置确定后调用) */
export function applyBindings(map) {
if (!map) return;
for (const act in map) if (act in BIND) BIND[act] = map[act].slice();
}
// 初始化
for (const act in BIND) { now[act] = false; prev[act] = false; }
export const ACTIONS = Object.keys(BIND);
-712
View File
@@ -1,712 +0,0 @@
// 关卡解析与运行时:ASCII 网格 -> tiles + 实体 + 事件
import { TILE, clamp, overlap, hashNoise } from './util.js';
import * as S from './sprites.js';
import { sfx } from './audio.js';
import { Particles } from './particles.js';
import { Boss } from './boss.js';
export const T = {
EMPTY: 0, BLOCK: 1, GRASS: 2, BRICK: 3, METAL: 4,
ICE: 5, WATER: 6, ONEWAY: 7, KILLER: 8, VANISH: 9,
SPIKE_U: 10, SPIKE_D: 11, SPIKE_L: 12, SPIKE_R: 13,
};
const SOLID = { 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 9: 1 };
const DEADLY = { 8: 1, 10: 1, 11: 1, 12: 1, 13: 1 };
/** ASCII 字符 -> tile */
export const CHAR2TILE = {
'#': T.BLOCK, '@': T.GRASS, '=': T.BRICK, 'M': T.METAL,
'%': T.ICE, '~': T.WATER, '-': T.ONEWAY, '*': T.KILLER, 'V': T.VANISH,
'^': T.SPIKE_U, 'v': T.SPIKE_D, '<': T.SPIKE_L, '>': T.SPIKE_R,
};
/** ASCII 字符 -> 实体类型 */
const CHAR2ENT = {
S: 'spawn', G: 'save', X: 'goal', P: 'gun', F: 'apple', J: 'jumpref',
W: 'warp', T: 'sign', O: 'turret', B: 'boss', D: 'dropspike',
C: 'camzone', '!': 'trigger',
};
const ESIZE = {
spawn: [16, 26], save: [20, 22], goal: [30, 42], gun: [28, 24], apple: [18, 20],
jumpref: [26, 26], warp: [28, 28], sign: [30, 26], turret: [32, 32],
boss: [64, 64], dropspike: [32, 32], camzone: [96, 96], trigger: [32, 32],
};
export class Level {
constructor(data) {
this.data = data;
this.name = data.name || 'STAGE';
this.type = data.type || 'jump';
this.parse(data);
this.particles = new Particles();
this.reset(true);
}
// ---------------- 解析 ----------------
parse(data) {
const rows = data.grid || [];
this.h = rows.length || 19;
this.w = data.width || Math.max(...rows.map(r => r.length), 25);
this.pxW = this.w * TILE;
this.pxH = this.h * TILE;
this.tiles = [];
const srcEntities = [];
const order = { warp: 0, boss: 0, sign: 0, turret: 0, camzone: 0, trigger: 0 };
for (let y = 0; y < this.h; y++) {
const line = rows[y] || '';
const row = [];
for (let x = 0; x < this.w; x++) {
const ch = line[x] || ' ';
if (CHAR2TILE[ch] !== undefined) {
row.push(CHAR2TILE[ch]);
} else if (CHAR2ENT[ch] !== undefined) {
const type = CHAR2ENT[ch];
const [ew, eh] = ESIZE[type];
const idx = order[type] !== undefined ? order[type]++ : 0;
srcEntities.push({ type, idx, tx: x, ty: y, ch });
row.push(T.EMPTY);
} else row.push(T.EMPTY);
}
this.tiles.push(row);
}
// 构建实体
this.srcEntities = srcEntities;
this.moversSrc = data.movers || [];
this.eventSrc = data.events || [];
this.msgs = data.messages || [];
this.bossSrc = data.bosses || [];
this.warpSrc = data.warps || [];
this.turretSrc = data.turrets || [];
this.camSrc = data.camzones || [];
this.spawnExtra = data.spawn || null;
this.hint = data.hint || '';
this.bgTheme = data.bg || (this.type === 'boss' ? 'boss' : this.type === 'troll' ? 'troll' : this.type === 'endur' ? 'endur' : 'jump');
}
/** 完整重置(死亡 / 重开) */
reset(full = false) {
// tiles 状态(vanish 等)
if (full) { this.deathMarks = []; this.deathFX = []; } // 血迹/死亡特效只在整关重开时清空,复活后保留
this.vanish = new Map();
this.bullets = [];
this.ebullets = [];
this.entities = [];
this.movers = [];
this.bosses = [];
this.events = [];
this.time = 0;
this.toast = null;
this.toastT = 0;
this.particles?.clear();
this.shake = 0;
this.beat = false;
// 实体实例化
let saveNo = 0;
for (const s of this.srcEntities) {
const [ew, eh] = ESIZE[s.type];
const e = {
type: s.type, idx: s.idx,
tx: s.tx, ty: s.ty,
x: s.tx * TILE + (TILE - ew) / 2,
y: s.ty * TILE + (TILE - eh),
w: ew, h: eh,
active: false, dead: false, t: 0,
};
if (s.type === 'goal') e.y = s.ty * TILE + TILE - eh;
if (s.type === 'save') { e.fake = false; e.no = ++saveNo; }
if (s.type === 'sign') e.text = this.msgs[e.idx] || '...';
if (s.type === 'turret') {
const cfg = this.turretSrc[e.idx] || {};
e.dir = cfg.dir || 'left';
e.period = cfg.period || 90;
e.speed = cfg.speed || 2.4;
e.timer = cfg.offset || 0;
e.kind = cfg.kind || 'ball';
}
if (s.type === 'warp') {
const cfg = this.warpSrc[e.idx] || {};
// 自制多面地图:门的目标直接挂在 door.to 上
// door.to 可以是跨面 {face, door}(自制门),或原有 'spawn' / {x,y}(内置同关传送)
const door = (this.data.doors || []).find(d => d.tx === s.tx && d.ty === s.ty);
e.doorId = door ? door.id : e.idx;
e.pair = cfg.pair !== undefined ? cfg.pair : (e.idx % 2 === 0 ? e.idx + 1 : e.idx - 1);
e.to = (door && door.to) ? door.to : (cfg.to || null);
e.cool = 0;
e.hidden = !!cfg.hidden;
}
if (s.type === 'camzone') {
const cfg = this.camSrc[e.idx] || {};
e.w = (cfg.w || 3) * TILE; e.h = (cfg.h || 3) * TILE;
e.x = s.tx * TILE; e.y = s.ty * TILE;
e.mode = cfg.mode || 'lock'; // lock | zoom | follow-off
e.zoom = cfg.zoom || 1;
e.lx = cfg.lx !== undefined ? cfg.lx * TILE : null;
e.ly = cfg.ly !== undefined ? cfg.ly * TILE : null;
}
if (s.type === 'dropspike') {
e.falling = false; e.vy = 0; e.homeY = e.y;
e.triggerX = (this.srcEntities.length ? 0 : 0);
}
if (s.type === 'boss') {
const cfg = this.bossSrc[e.idx] || { kind: 'guy' };
e.kind = cfg.kind || 'guy';
e.hp = cfg.hp || 40;
e.w = cfg.w || 64; e.h = cfg.h || 64;
e.x = s.tx * TILE + TILE / 2 - e.w / 2;
e.y = s.ty * TILE + TILE - e.h;
}
this.entities.push(e);
}
// 移动平台
for (const m of this.moversSrc) {
const path = (m.path || []).map(p => ({ x: p[0] * TILE, y: p[1] * TILE }));
const a = path[0] || { x: m.x * TILE, y: m.y * TILE };
this.movers.push({
x: a.x, y: a.y,
w: (m.w || 3) * TILE, h: (m.h || 1) * TILE,
path, i: 0, dir: 1, speed: m.speed || 1.2, t: 0,
mode: m.mode || 'loop', // loop | pingpong
dx: 0, dy: 0,
ax: a.x, ay: a.y, aw: (m.w || 3) * TILE, ah: (m.h || 1) * TILE,
bx: (path[1] || a).x, by: (path[1] || a).y,
});
}
// 事件(坑向触发器)
for (const ev of this.eventSrc) {
this.events.push({
x: ev.x * TILE, y: ev.y * TILE, w: (ev.w || 1) * TILE, h: (ev.h || 1) * TILE,
once: ev.once !== false, fired: false,
actions: ev.actions || [], hidden: ev.hidden !== false,
});
}
// 补齐未在 ASCII 中放置的摄像头区域(camzones 数组独立定义)
const camPlaced = this.srcEntities.filter(s => s.type === 'camzone').length;
for (let i = camPlaced; i < this.camSrc.length; i++) {
const cfg = this.camSrc[i];
this.entities.push({
type: 'camzone', idx: i, tx: cfg.x, ty: cfg.y,
x: cfg.x * TILE, y: cfg.y * TILE,
w: (cfg.w || 3) * TILE, h: (cfg.h || 3) * TILE,
mode: cfg.mode || 'lock', zoom: cfg.zoom || 1,
lx: cfg.lx !== undefined ? cfg.lx * TILE : null,
ly: cfg.ly !== undefined ? cfg.ly * TILE : null,
active: false, dead: false, t: 0,
});
}
// BOSS
for (const e of this.entities) {
if (e.type !== 'boss') continue;
this.bosses.push(new Boss(this, e));
}
// 出生点
const sp = this.entities.find(e => e.type === 'spawn');
this.spawn = sp
? { x: sp.x + sp.w / 2 - 5.5, y: sp.y + sp.h - 21 }
: (this.spawnExtra ? { x: this.spawnExtra[0] * TILE, y: this.spawnExtra[1] * TILE } : { x: 40, y: 40 });
// 总苹果数 / 存档点数
this.totalApples = this.entities.filter(e => e.type === 'apple').length;
this.totalSaves = saveNo;
}
/** 死亡后的软重置:保留存档点与已拾取道具,其余复位。
* 粒子与震屏是"演出层":不清场——无论主动按 R 还是被动死亡后秒按 R,
* 死亡特效都留在死亡位置自然播完(粒子 34 帧 / 冲击环 14 帧 / 震屏衰减),
* 重生只重置关卡逻辑状态,不掐断演出。整关重开(reset(true))才会清空 */
softReset() {
this.vanish.clear();
if (this.vanishT) this.vanishT.clear();
this.bullets.length = 0;
this.ebullets.length = 0;
for (const m of this.movers) {
const a = m.path[0];
if (a) { m.x = a.x; m.y = a.y; m.i = 0; }
m.dx = m.dy = 0;
const nx = m.path[1] || a;
m.ax = m.x; m.ay = m.y; m.bx = nx?.x ?? m.x; m.by = nx?.y ?? m.y;
}
for (const e of this.entities) {
if (e.type === 'dropspike') {
if (e.temp) { e.dead = true; continue; }
e.y = e.homeY; e.falling = false; e.vy = 0; e.landed = false; e.landT = 0;
}
if (e.type === 'warp') e.cool = 0;
}
this.entities = this.entities.filter(e => !e.dead);
for (const ev of this.events) ev.fired = false;
for (const b of this.bosses) {
b.hp = b.maxHp; b.dead = false; b.deathT = 0; b.bodyKill = true;
b.t = 0; b.state = 'idle'; b.stateT = 0; b.phase = 1;
b.x = b.homeX; b.y = b.homeY; b.vx = 0; b.vy = 0;
}
}
// 死亡标记:在死亡地点留下一个常驻「红叉」(IWBTG 风格死亡指示)。
// 只保留上一次死亡的红叉:新增前先清空,多次死亡不会累积。
addDeathMark(x, y) {
this.deathMarks.length = 0;
this.deathMarks.push({ x, y });
}
// 死亡特效(扩散冲击环):记录到关卡层而非玩家——主动重生时玩家当帧就复活了,
// 环要留在死亡位置播完。t 由 update 推进,14 帧扩散淡出后移除
addDeathFX(x, y) {
this.deathFX.push({ x, y, t: 0 });
}
drawDeathRing(ctx, fx) {
const t = fx.t;
if (t > 14) return;
const r = 4 + t * 1.8, a = Math.max(0, 1 - t / 14);
ctx.save();
ctx.globalAlpha = a * 0.85;
ctx.strokeStyle = '#ff8fa3';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(fx.x, fx.y, r, 0, Math.PI * 2);
ctx.stroke();
ctx.globalAlpha = a * 0.3;
ctx.fillStyle = '#ff5c7a';
ctx.beginPath();
ctx.arc(fx.x, fx.y, r * 0.55, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
drawDeathMark(ctx, dm) {
const s = 4; // 红叉半边长(< kid 的 11×21,整体约 8px)
ctx.lineCap = 'round';
// 深色描边,保证在任意背景下都清晰
ctx.lineWidth = 3;
ctx.strokeStyle = 'rgba(0,0,0,0.55)';
ctx.beginPath();
ctx.moveTo(dm.x - s, dm.y - s); ctx.lineTo(dm.x + s, dm.y + s);
ctx.moveTo(dm.x + s, dm.y - s); ctx.lineTo(dm.x - s, dm.y + s);
ctx.stroke();
// 红色主体
ctx.lineWidth = 2;
ctx.strokeStyle = '#ff2535';
ctx.beginPath();
ctx.moveTo(dm.x - s, dm.y - s); ctx.lineTo(dm.x + s, dm.y + s);
ctx.moveTo(dm.x + s, dm.y - s); ctx.lineTo(dm.x - s, dm.y + s);
ctx.stroke();
}
// ---------------- 查询 ----------------
tileAt(tx, ty) {
if (tx < 0 || ty < 0 || tx >= this.w || ty >= this.h) return ty < 0 ? T.EMPTY : T.BLOCK;
return this.tiles[ty][tx];
}
pxTile(px, py) { return this.tileAt(Math.floor(px / TILE), Math.floor(py / TILE)); }
isSolidTile(t, tx, ty) {
if (!SOLID[t]) return false;
if (t === T.VANISH) {
const st = this.vanishState(tx, ty);
return st !== 2;
}
return true;
}
vanishState(tx, ty) { return this.vanish.get(ty * 1000 + tx) ?? 0; }
setVanish(tx, ty, st) { this.vanish.set(ty * 1000 + tx, st); }
/** 矩形是否与实心地形相交(可指定忽略单向平台的下落判定) */
solidRect(x, y, w, h, opts = {}) {
const x0 = Math.floor(x / TILE), x1 = Math.floor((x + w - 0.001) / TILE);
const y0 = Math.floor(y / TILE), y1 = Math.floor((y + h - 0.001) / TILE);
for (let ty = y0; ty <= y1; ty++) {
for (let tx = x0; tx <= x1; tx++) {
const t = this.tileAt(tx, ty);
if (t === T.ONEWAY) {
if (!opts.dropThrough && opts.prevBottom !== undefined && opts.prevBottom <= ty * TILE + 6) {
// 从上方落下:只有当前底部穿过平台面时才碰撞
if (y + h <= ty * TILE + 8) continue;
if (opts.prevBottom > ty * TILE + 2) continue;
return true;
}
continue;
}
if (this.isSolidTile(t, tx, ty)) return true;
}
}
// 移动平台
for (const m of this.movers) {
if (m.gone) continue;
if (x < m.x + m.w && x + w > m.x && y < m.y + m.h && y + h > m.y) {
if (opts.moverExclude === m) continue;
return true;
}
}
return false;
}
/** 致死判定(刺 / 美味水果 / 敌人子弹) */
deadlyRect(x, y, w, h) {
const x0 = Math.floor(x / TILE), x1 = Math.floor((x + w - 0.001) / TILE);
const y0 = Math.floor(y / TILE), y1 = Math.floor((y + h - 0.001) / TILE);
for (let ty = y0; ty <= y1; ty++) {
for (let tx = x0; tx <= x1; tx++) {
const t = this.tileAt(tx, ty);
if (!DEADLY[t]) continue;
const tr = spikeTri(t, tx * TILE, ty * TILE);
if (triRectOverlap(tr.ax, tr.ay, tr.bx, tr.by, tr.cx, tr.cy, x, y, w, h)) return true;
}
}
for (const e of this.entities) {
if (e.type === 'dropspike' && e.falling) {
const tr = spikeTri(T.SPIKE_L, e.x, e.y); // 与 drawDropSpike 的 dir=2(朝左)一致
if (triRectOverlap(tr.ax, tr.ay, tr.bx, tr.by, tr.cx, tr.cy, x, y, w, h)) return true;
}
}
for (const b of this.ebullets) {
if (b.dead) continue;
const r = { x: b.x, y: b.y, w: b.w, h: b.h };
if (overlap({ x, y, w, h }, r)) return true;
}
for (const bs of this.bosses) {
if (bs.dead || !bs.bodyKill) continue;
if (overlap({ x, y, w, h }, bs)) return true;
}
return false;
}
waterRect(x, y, w, h) {
const x0 = Math.floor(x / TILE), x1 = Math.floor((x + w - 0.001) / TILE);
const y0 = Math.floor(y / TILE), y1 = Math.floor((y + h - 0.001) / TILE);
for (let ty = y0; ty <= y1; ty++)
for (let tx = x0; tx <= x1; tx++)
if (this.tileAt(tx, ty) === T.WATER) return true;
return false;
}
iceAtBottom(x, y, w, h) {
const tx0 = Math.floor(x / TILE), tx1 = Math.floor((x + w - 1) / TILE);
const ty = Math.floor((y + h + 1) / TILE);
for (let tx = tx0; tx <= tx1; tx++) if (this.tileAt(tx, ty) === T.ICE) return true;
return false;
}
// ---------------- 更新 ----------------
update(dt, player, cam) {
this.time += dt;
const t = this.time;
// 死亡特效(冲击环)推进:14 帧后移除;粒子由 particles.update 自理
for (let i = this.deathFX.length - 1; i >= 0; i--) {
if (++this.deathFX[i].t > 14) this.deathFX.splice(i, 1);
}
// 移动平台
for (const m of this.movers) {
if (!m.path.length) continue;
const tgt = m.path[(m.i + 1) % m.path.length];
const ox = m.x, oy = m.y;
const dx = tgt.x - m.x, dy = tgt.y - m.y;
const dist = Math.hypot(dx, dy);
if (dist <= m.speed) {
m.x = tgt.x; m.y = tgt.y;
m.i = (m.i + 1) % m.path.length;
} else {
m.x += dx / dist * m.speed;
m.y += dy / dist * m.speed;
}
m.dx = m.x - ox; m.dy = m.y - oy;
const next = m.path[(m.i + 1) % m.path.length];
m.ax = m.x; m.ay = m.y; m.bx = next.x; m.by = next.y;
}
// 消失方块计时
for (const [key, st] of this.vanish) {
if (st === 0) continue;
const tt = (this.vanishT?.get(key) || 0) + 1;
this.vanishT = this.vanishT || new Map();
this.vanishT.set(key, tt);
if (st === 1 && tt > 55) { this.vanish.set(key, 2); this.vanishT.set(key, 0); }
else if (st === 2 && tt > 110) { this.vanish.set(key, 0); this.vanishT.set(key, 0); }
}
// 掉落刺
for (const e of this.entities) {
if (e.type !== 'dropspike') continue;
if (!e.falling) {
const px = player.x + player.w / 2;
if (Math.abs(px - (e.x + e.w / 2)) < 26 && Math.abs(player.y - e.y) < 260 && player.y > e.y) {
e.falling = true; e.vy = 0;
sfx.vanish();
}
} else if (e.landed) {
// 落地后插在地上,80 帧后碎掉(否则会永久堵死落脚点)
e.landT = (e.landT || 0) + 1;
if (e.landT > 80) { e.dead = true; e.falling = false; }
} else {
e.vy = Math.min(e.vy + 0.5, 11);
e.y += e.vy;
const ty = Math.floor((e.y + e.h) / TILE);
const tx = Math.floor((e.x + e.w / 2) / TILE);
if (this.isSolidTile(this.tileAt(tx, ty), tx, ty)) {
e.y = ty * TILE - e.h; e.vy = 0; e.landed = true; e.landT = 0;
this.shake = Math.max(this.shake, 5);
}
if (e.y > this.pxH + 100) e.dead = true;
}
}
// 炮台
for (const e of this.entities) {
if (e.type !== 'turret') continue;
e.t++;
if (e.t % (e.period || 90) === 0) {
const cx = e.x + e.w / 2, cy = e.y + e.h / 2;
let vx = 0, vy = 0;
if (e.dir === 'left') vx = -e.speed;
else if (e.dir === 'right') vx = e.speed;
else if (e.dir === 'up') vy = -e.speed;
else vy = e.speed;
if (e.kind === 'aim' && player.alive) {
const dx = (player.x + player.w / 2) - cx, dy = (player.y + player.h / 2) - cy;
const d = Math.hypot(dx, dy) || 1;
vx = dx / d * e.speed; vy = dy / d * e.speed;
}
this.ebullets.push({ x: cx - 4, y: cy - 4, w: 8, h: 8, vx, vy, dead: false, col: '#ff5c7a', life: 400 });
sfx.shoot();
}
}
// 敌人子弹
for (const b of this.ebullets) {
b.x += b.vx; b.y += b.vy; b.life--;
if (b.life <= 0) b.dead = true;
const tx = Math.floor((b.x + b.w / 2) / TILE), ty = Math.floor((b.y + b.h / 2) / TILE);
if (this.isSolidTile(this.tileAt(tx, ty), tx, ty)) {
b.dead = true;
this.particles.spawn(b.x + 4, b.y + 4, 4, { colors: ['#ff5c7a', '#fff'], speed: 2, life: 14 });
}
}
this.ebullets = this.ebullets.filter(b => !b.dead);
// 玩家子弹
for (const b of this.bullets) {
b.x += b.vx; b.y += b.vy; b.life--;
if (b.life <= 0) b.dead = true;
const tx = Math.floor((b.x + b.w / 2) / TILE), ty = Math.floor((b.y + b.h / 2) / TILE);
const tt = this.tileAt(tx, ty);
if (tt === T.BLOCK || tt === T.GRASS || tt === T.BRICK || tt === T.METAL) {
b.dead = true;
this.particles.spawn(b.x + 3, b.y + 3, 5, { colors: ['#fff6a0', '#ffb040'], speed: 2.4, life: 16 });
}
}
this.bullets = this.bullets.filter(b => !b.dead);
// 事件触发
for (const ev of this.events) {
if (ev.fired && ev.once) continue;
if (overlap(player, ev)) this.fireEvent(ev, player);
}
// 摄像机区域
this.camZone = null;
for (const e of this.entities) {
if (e.type !== 'camzone') continue;
if (overlap({ x: player.x + player.w / 2 - 1, y: player.y + player.h / 2 - 1, w: 2, h: 2 }, e)) {
this.camZone = e; break;
}
}
if (this.shake > 0) this.shake *= 0.86;
if (this.toastT > 0 && --this.toastT === 0) this.toast = null;
this.particles.update();
// 死亡红叉为常驻静态标记,无需逐帧演化,复活后仍保留
}
fireEvent(ev, player) {
ev.fired = true;
for (const a of ev.actions) {
if (a.type === 'msg') { this.showToast(a.text, a.dur || 150); }
else if (a.type === 'spikes') {
// 生成一排下落刺
for (let i = 0; i < (a.count || 5); i++) {
const x = (a.x + i * (a.gap || 1)) * TILE;
this.entities.push({
type: 'dropspike', x, y: (a.y || 0) * TILE, w: TILE, h: TILE,
falling: true, vy: 0, homeY: (a.y || 0) * TILE, t: 0, temp: true,
});
}
sfx.troll();
} else if (a.type === 'vanish') {
for (let i = 0; i < (a.count || 3); i++) {
const tx = (a.x + i), ty = a.y;
if (this.tileAt(tx, ty) === T.EMPTY) continue;
this.setVanish(tx, ty, 1);
this.vanishT = this.vanishT || new Map();
this.vanishT.set(ty * 1000 + tx, 0);
}
sfx.vanish();
} else if (a.type === 'warp') {
player.x = a.x * TILE; player.y = a.y * TILE;
player.vx = 0; player.vy = 0;
sfx.warp();
} else if (a.type === 'kill') { player.kill(); }
else if (a.type === 'shake') { this.shake = a.v || 10; }
}
}
showToast(text, dur = 150) { this.toast = text; this.toastT = dur; }
// ---------------- 绘制 ----------------
drawBg(ctx, cam, t) {
const g = ctx.createLinearGradient(0, 0, 0, cam.h);
const th = {
jump: ['#1a1830', '#0a0a14'],
endur: ['#101f24', '#080c10'],
boss: ['#2a1018', '#0d0810'],
troll: ['#221432', '#0b0812'],
custom: ['#141428', '#08080f'],
}[this.bgTheme] || ['#1a1830', '#0a0a14'];
g.addColorStop(0, th[0]); g.addColorStop(1, th[1]);
ctx.fillStyle = g;
ctx.fillRect(0, 0, cam.w, cam.h);
// 远景装饰(视差)
const px = -cam.x * 0.25, py = -cam.y * 0.15;
ctx.globalAlpha = 0.16;
ctx.fillStyle = '#ffffff';
for (let i = 0; i < 60; i++) {
const nx = hashNoise(i, 7) * 2000, ny = hashNoise(i, 13) * 900;
const sx = ((nx + px) % (cam.w + 200)) - 100;
const sy = ((ny + py) % (cam.h + 120)) - 60;
ctx.fillRect(sx, sy, 2, 2);
}
ctx.globalAlpha = 1;
// 网格
ctx.strokeStyle = 'rgba(255,255,255,.035)';
ctx.lineWidth = 1;
ctx.beginPath();
const off = TILE * 2;
const sx0 = -((cam.x % off));
const sy0 = -((cam.y % off));
for (let x = sx0; x < cam.w; x += off) { ctx.moveTo(x + .5, 0); ctx.lineTo(x + .5, cam.h); }
for (let y = sy0; y < cam.h; y += off) { ctx.moveTo(0, y + .5); ctx.lineTo(cam.w, y + .5); }
ctx.stroke();
}
draw(ctx, cam, t) {
const vx0 = Math.max(0, Math.floor(cam.x / TILE) - 1);
const vx1 = Math.min(this.w - 1, Math.ceil((cam.x + cam.w / (cam.zoom || 1)) / TILE) + 1);
const vy0 = Math.max(0, Math.floor(cam.y / TILE) - 1);
const vy1 = Math.min(this.h - 1, Math.ceil((cam.y + cam.h / (cam.zoom || 1)) / TILE) + 1);
ctx.save();
// 摄像机变换由外部处理
for (let ty = vy0; ty <= vy1; ty++) {
for (let tx = vx0; tx <= vx1; tx++) {
const v = this.tiles[ty][tx];
if (v === T.EMPTY) continue;
const x = tx * TILE, y = ty * TILE;
switch (v) {
case T.BLOCK: S.drawBlock(ctx, x, y, 0); break;
case T.GRASS: S.drawBlock(ctx, x, y, 1); break;
case T.BRICK: S.drawBlock(ctx, x, y, 2); break;
case T.METAL: S.drawBlock(ctx, x, y, 3); break;
case T.ICE: S.drawIce(ctx, x, y); break;
case T.WATER: S.drawWater(ctx, x, y, t); break;
case T.ONEWAY: S.drawOneWay(ctx, x, y); break;
case T.KILLER: S.drawKiller(ctx, x, y, t); break;
case T.VANISH: S.drawVanish(ctx, x, y, this.vanishState(tx, ty), t); break;
case T.SPIKE_U: S.drawSpike(ctx, x, y, 0, t); break;
case T.SPIKE_D: S.drawSpike(ctx, x, y, 1, t); break;
case T.SPIKE_L: S.drawSpike(ctx, x, y, 2, t); break;
case T.SPIKE_R: S.drawSpike(ctx, x, y, 3, t); break;
}
}
}
// 移动平台
for (const m of this.movers) S.drawMovingPlatform(ctx, m);
// 实体
for (const e of this.entities) {
if (e.dead) continue;
switch (e.type) {
case 'save': S.drawSave(ctx, e, t); break;
case 'goal': S.drawGoal(ctx, e, t); break;
case 'gun': if (!e.taken) S.drawGun(ctx, e, t); break;
case 'apple': if (!e.taken) S.drawApple(ctx, e, t); break;
case 'jumpref': S.drawJumpRef(ctx, e, t); break;
case 'warp': if (!e.hidden) S.drawWarp(ctx, e, t); break;
case 'sign': S.drawSign(ctx, e, t); break;
case 'turret': S.drawTurret(ctx, e, t); break;
case 'camzone': S.drawCamera(ctx, e, t); break;
case 'dropspike': S.drawDropSpike(ctx, e, t); break;
}
}
// 子弹
for (const b of this.bullets) S.drawBullet(ctx, b);
for (const b of this.ebullets) S.drawEBullet(ctx, b, t);
// 死亡血迹(IWBTG 风格,常驻于死亡地点)
for (const dm of this.deathMarks) this.drawDeathMark(ctx, dm);
// 死亡特效(扩散冲击环,14 帧淡出)
for (const fx of this.deathFX) this.drawDeathRing(ctx, fx);
this.particles.draw(ctx);
ctx.restore();
}
/** 屏幕空间 UI:提示牌文字 / toast */
drawOverlay(ctx, cam, t) {
for (const e of this.entities) {
if (e.type !== 'sign') continue;
const sx = (e.x + e.w / 2 - cam.x) * (cam.zoom || 1);
const sy = (e.y - 6 - cam.y) * (cam.zoom || 1);
if (sx < -80 || sx > cam.w + 80) continue;
ctx.font = 'bold 11px "Courier New",monospace';
ctx.textAlign = 'center';
const txt = e.text;
const wd = ctx.measureText(txt).width;
ctx.fillStyle = 'rgba(0,0,0,.72)';
ctx.fillRect(sx - wd / 2 - 5, sy - 13, wd + 10, 16);
ctx.fillStyle = '#ffe9a8';
ctx.fillText(txt, sx, sy);
}
}
}
// 单三角形刺的顶点(与 sprites.drawSpike 完全一致)。
// 尖端指向刺的方向,底边贴在相反一侧(地面 / 天花板 / 墙面)。
function spikeTri(t, x, y) {
const s = TILE;
if (t === T.SPIKE_U) return { ax: x + s / 2, ay: y, bx: x, by: y + s, cx: x + s, cy: y + s };
if (t === T.SPIKE_D) return { ax: x + s / 2, ay: y + s, bx: x, by: y, cx: x + s, cy: y };
if (t === T.SPIKE_L) return { ax: x, ay: y + s / 2, bx: x + s, by: y, cx: x + s, cy: y + s };
if (t === T.SPIKE_R) return { ax: x + s, ay: y + s / 2, bx: x, by: y, cx: x, cy: y + s };
return { ax: x, ay: y, bx: x + s, by: y, cx: x, cy: y + s }; // 兜底(不应到达)
}
// 三角形与轴对齐矩形的相交判定(分离轴定理,凸多边形充要条件)。
function triRectOverlap(ax, ay, bx, by, cx, cy, rx, ry, rw, rh) {
const rMinX = rx, rMaxX = rx + rw, rMinY = ry, rMaxY = ry + rh;
const tMinX = Math.min(ax, bx, cx), tMaxX = Math.max(ax, bx, cx);
const tMinY = Math.min(ay, by, cy), tMaxY = Math.max(ay, by, cy);
if (tMaxX < rMinX || tMinX > rMaxX || tMaxY < rMinY || tMinY > rMaxY) return false; // AABB 快速排除
const edges = [[bx - ax, by - ay], [cx - bx, cy - by], [ax - cx, ay - cy]];
for (const [ex, ey] of edges) {
const nx = -ey, ny = ex; // 边法线
const t0 = nx * ax + ny * ay, t1 = nx * bx + ny * by, t2 = nx * cx + ny * cy;
const tMin = Math.min(t0, t1, t2), tMax = Math.max(t0, t1, t2);
const p0 = nx * rMinX + ny * rMinY, p1 = nx * rMaxX + ny * rMinY,
p2 = nx * rMinX + ny * rMaxY, p3 = nx * rMaxX + ny * rMaxY;
const rMin = Math.min(p0, p1, p2, p3), rMax = Math.max(p0, p1, p2, p3);
if (tMax < rMin || rMax < tMin) return false; // 找到分离轴
}
return true;
}
export { spikeTri, triRectOverlap };
-63
View File
@@ -1,63 +0,0 @@
// BOSS 战关卡(击败 BOSS 即通关)
import { G, rep, E, plat, ent } from './kit.js';
export default [
{
id: 'b1', name: 'THE GUY', type: 'boss', diff: 3, bg: 'boss', width: 48,
hint: 'X 键射击。他放扇形弹+砸地冲击波,站在平台边缘躲开再输出。',
startGun: true,
bosses: [{ kind: 'guy', hp: 72 }],
grid: G(48, [
rep('#', 48), // 0
E(48), E(48), E(48), E(48), E(48), E(48), E(48),// 1-7
'#' + rep('.', 5) + '######' + rep('.', 24) + '######', // 8
E(48), E(48), E(48), // 9-11
'#' + rep('.', 15) + '######' + rep('.', 8) + '######', // 12
E(48), E(48), E(48), // 13-15
'#' + rep('.', 5) + '######' + rep('.', 24) + '######', // 16
E(48), E(48), E(48), // 17-19
ent(48, [2, 'S'], [24, 'B']), // 20
rep('#', 48), // 21
]),
},
{
id: 'b2', name: 'MECHA KID', type: 'boss', diff: 4, bg: 'boss', width: 48,
hint: '冲刺时贴地必死。跳上高台躲,等他停下再打。',
startGun: true,
bosses: [{ kind: 'mecha', hp: 96 }],
grid: G(48, [
rep('#', 48), // 0
E(48), E(48), E(48), E(48), // 1-4
'#' + rep('.', 12) + '######' + rep('.', 10) + '######', // 5
E(48), E(48), E(48), // 6-8
'#' + rep('.', 3) + '######' + rep('.', 28) + '######', // 9
E(48), E(48), E(48), // 10-12
'#' + rep('.', 15) + '######' + rep('.', 8) + '######', // 13
E(48), E(48), E(48), // 14-16
'#' + rep('.', 3) + '######' + rep('.', 28) + '######', // 17
E(48), E(48), E(48), // 18-19
ent(48, [2, 'S'], [24, 'B']), // 20
rep('#', 48), // 21
]),
},
{
id: 'b3', name: 'THE DEVIL', type: 'boss', diff: 5, bg: 'boss', width: 48,
hint: '三阶段:追踪弹 → 激光横扫 → 环形弹幕。地上还有刺。活下来。',
startGun: true,
bosses: [{ kind: 'devil', hp: 128 }],
grid: G(48, [
rep('#', 48), // 0
E(48), E(48), E(48), // 1-3
'#' + rep('.', 5) + '######' + rep('.', 24) + '######', // 4
E(48), E(48), E(48), // 5-7
'#' + rep('.', 15) + '######' + rep('.', 8) + '######', // 8
E(48), E(48), E(48), // 9-11
'#' + rep('.', 3) + '######' + rep('.', 28) + '######', // 12
E(48), E(48), E(48), // 13-15
'#' + rep('.', 15) + '######' + rep('.', 8) + '######', // 16
E(48), E(48), E(48), // 17-19
ent(48, [2, 'S'], [24, 'B'], [12, '^'], [13, '^'], [34, '^'], [35, '^']), // 20
rep('#', 48), // 21
]),
},
];
-131
View File
@@ -1,131 +0,0 @@
// 耐久(Endurance)关卡:长距离连续考验,存档点稀疏
import { G, rep, E, plat, ent, flr } from './kit.js';
export default [
// ---------------- 1 ----------------
{
id: 'e1', name: 'LONG ROAD', type: 'endur', diff: 3, bg: 'endur', width: 100,
hint: '100 格长路,8 个刺坑,只有 5 个存档点。别急。',
messages: ['才刚刚开始'],
grid: G(100, [
rep('#', 100), // 0
E(100), E(100), E(100), E(100), E(100), // 1-5
E(100), E(100), E(100), E(100), E(100), // 6-10
E(100), // 11
ent(100, [15, 'v'], [17, 'v'], [30, 'v'], [32, 'v'], [55, 'v'], [57, 'v'], [75, 'v'], [77, 'v']), // 12
ent(100, [43, 'F'], [90, 'F']), // 13
'#' + rep('.', 40) + '######' + rep('.', 41) + '######', // 14
E(100), E(100), // 15-16
ent(100, [2, 'S'], [16, 'G'], [30, 'G'], [52, 'G'], [76, 'G'], [98, 'X'], // 17
[26, '^'], [27, '^'], [40, '^'], [41, '^'], [63, '^'], [64, '^'], [78, '^'], [79, '^']),
flr(['#', 9], ['^', 3], ['#', 7], ['^', 4], ['#', 11], ['^', 3], ['#', 6], ['^', 5],
['#', 9], ['^', 3], ['#', 8], ['^', 4], ['#', 10], ['^', 3], ['#', 6], ['^', 5], ['#', 3]), // 18
]),
},
// ---------------- 2 ----------------
{
id: 'e2', name: 'TURRET HELL', type: 'endur', diff: 4, bg: 'endur', width: 88,
hint: '炮台沿路扫射。踩空就是刺海,别站在开阔地。',
turrets: [
{ dir: 'right', period: 78, speed: 2.8, offset: 0 },
{ dir: 'left', period: 92, speed: 2.6, offset: 25 },
{ dir: 'right', period: 70, speed: 3.0, offset: 10 },
{ dir: 'left', period: 86, speed: 2.7, offset: 40 },
{ dir: 'right', period: 74, speed: 2.9, offset: 20 },
{ dir: 'aim', period: 110, speed: 2.4, offset: 55 },
],
grid: G(88, [
rep('#', 88), // 0
E(88), E(88), E(88), E(88), E(88), E(88), // 1-6
E(88), E(88), E(88), // 7-9
E(88), // 10
ent(88, [83, 'X']), // 11
plat(88, 82, 5), // 12
E(88), // 13
plat(88, 78, 5), // 14
E(88), // 15
ent(88, [83, 'G']), // 16
plat(88, 82, 5), // 17
E(88), // 18
plat(88, 78, 5), // 19
E(88), // 20
ent(88, [2, 'S'], [4, 'O'], [10, 'G'], [20, 'O'], [30, 'G'], [36, 'O'],
[50, 'G'], [52, 'O'], [68, 'O'], [70, 'G'], [84, 'O']), // 21
'#' + '.' + rep('#####...', 10) + '#####', // 22
'#' + rep('^', 87), // 23
rep('#', 88), // 24
]),
},
// ---------------- 3 ----------------
{
id: 'e3', name: 'THE PIT', type: 'endur', diff: 4, bg: 'endur', width: 64,
hint: '先从左侧竖井下到底,游过水下刺池,再从右边一路爬回顶端。',
messages: ['下面有东西在等你'],
grid: G(64, [
rep('#', 64), // 0
E(64), E(64), // 1-2
ent(64, [57, 'X']), // 3
'#' + '..' + 'S' + rep('.', 52) + rep('#', 7), // 4 出生 + 右·顶台
'#.' + rep('#', 7), // 5 左
'#' + rep('.', 50) + 'G', // 6
'#' + rep('.', 48) + rep('#', 7), // 7 右
'#' + rep('.', 10) + rep('#', 7), // 8 左
'#' + rep('.', 58) + 'G', // 9
'#' + '...' + 'G' + rep('.', 51) + rep('#', 7), // 10 右
'#.' + rep('#', 7), // 11 左
'#' + rep('.', 50) + 'G', // 12
'#' + rep('.', 48) + rep('#', 7), // 13 右
'#' + rep('.', 10) + rep('#', 7), // 14 左
E(64), // 15
'#' + '...' + 'G' + rep('.', 51) + rep('#', 7), // 16 右
'#.' + rep('#', 7), // 17 左
E(64), // 18
'#' + rep('.', 48) + rep('#', 7), // 19 右
'#' + rep('.', 10) + rep('#', 7), // 20 左
E(64), // 21
'#' + rep('.', 12) + 'G' + rep('.', 42) + rep('#', 7), // 22 右
'#' + rep('.', 10) + rep('#', 7), // 23 左·井底台
'#' + rep('.', 50) + 'G', // 24 出水台
'#' + rep('.', 17) + rep('~', 31) + rep('#', 7) + rep('.', 8), // 25
'#' + rep('.', 17) + rep('~', 31) + rep('.', 14), // 26
'#' + rep('.', 17) + rep('~', 31) + rep('.', 14), // 27
'#' + rep('^', 63), // 28
rep('#', 64), // 29
]),
},
// ---------------- 4 ----------------
{
id: 'e4', name: 'MARATHON', type: 'endur', diff: 5, bg: 'endur', width: 120,
hint: '120 格:冰面、炮台、刺坑、移动平台全都有。5 个存档,自求多福。',
turrets: [
{ dir: 'right', period: 76, speed: 2.9, offset: 0 },
{ dir: 'left', period: 88, speed: 2.7, offset: 30 },
{ dir: 'right', period: 68, speed: 3.1, offset: 15 },
{ dir: 'aim', period: 120, speed: 2.3, offset: 50 },
{ dir: 'left', period: 80, speed: 2.8, offset: 25 },
],
grid: G(120, [
rep('#', 120), // 0
E(120), E(120), E(120), E(120), E(120), // 1-5
E(120), E(120), E(120), E(120), E(120), // 6-10
E(120), E(120), E(120), E(120), E(120), // 11-15
E(120), // 16
ent(120, [20, 'v'], [22, 'v'], [36, 'v'], [38, 'v'], [52, 'v'], [54, 'v'],
[70, 'v'], [72, 'v'], [88, 'v'], [90, 'v'], [104, 'v'], [106, 'v']), // 17
E(120), E(120), E(120), E(120), // 18-21
ent(120, [2, 'S'], [20, 'O'], [24, 'G'], [36, 'O'], [40, 'G'], [52, 'O'],
[66, 'G'], [70, 'O'], [88, 'G'], [100, 'O'], [104, 'G'], [116, 'X'],
[17, '^'], [18, '^'], [54, '^'], [55, '^'], [72, '^'], [73, '^'], [105, '^'], [106, '^']), // 22
flr(['%', 10], ['^', 4], ['#', 12], ['^', 5], ['%', 12], ['^', 4],
['#', 10], ['^', 5], ['#', 15], ['^', 4], ['%', 10], ['^', 5],
['#', 12], ['^', 4], ['#', 7]), // 23
rep('#', 120), // 24
]),
movers: [
{ x: 57, y: 22, w: 3, h: 1, path: [[57, 22], [63, 22]], speed: 1.3 },
],
},
];
-26
View File
@@ -1,26 +0,0 @@
import tutorial from './tutorial.js';
import jump from './jump.js';
import endurance from './endurance.js';
import boss from './boss.js';
import troll from './troll.js';
const raw = [...tutorial, ...jump, ...endurance, ...boss, ...troll];
raw.forEach((l, i) => { l.index = i; l.builtin = true; });
export const LEVELS = raw;
export const TYPE_LABEL = {
jump: '跳刺', endur: '耐久', boss: 'BOSS', troll: '坑向', custom: '自制',
};
export const TYPE_CLASS = {
jump: 't-jump', endur: 't-endur', boss: 't-boss', troll: 't-troll', custom: 't-custom',
};
let uid = 0;
export function makeCustomLevel(data) {
return { ...data, id: 'u' + (Date.now() % 100000) + (uid++), type: 'custom', builtin: false, index: -1 };
}
export function findLevel(id) {
return LEVELS.find(l => l.id === id);
}
-519
View File
@@ -1,519 +0,0 @@
// 跳刺(Needle)10 关 —— IWBT 经典密集针刺风格
// 设计语法(已经 tools/solve.mjs 验证):
// 刺毯缺口每 3 格一个(跳 2 连刺 = 64px 单跳节奏);钻石中心 = 地板上方 2~3 格;
// 垂刺离最低落脚点 5 格(单跳安全、二段跳必死);2 格高矮洞可用撞头跳;
// 竖井台阶每级升 2~3 格横移 3 格内;钢丝柱间距 ≤4(单跳)/ ≤6(二段)。
import { Grid } from './kit.js';
import { vgap, hgap, diamond, spikeRun, block, corner, ceil, wall, well } from './needle.js';
// 刺毯:从 x0 起 n 格留落脚缺口。
// 缺口间距统一 3 格(跳 2 连刺 = 64px)。
// 2026-10-23:JUMP 7.5 + GRAV_FALL 0.55 下单跳水平实测 118.8px(tools/jumpdist.mjs),
// 但落地前一帧竖直速度已达 8.9px/帧,3 连刺(96px)的起拍窗被压到 0~1 帧(不可玩);
// 2 连刺留 55px 射程余量,落地窗恢复充裕。井口/左端右行出口处的 3 连刺须显式拆断(见各关 gaps 注释)
const carpet = (m, x0, y, n, gaps = []) => {
const g = [];
let i = 3;
while (i < n) { g.push(i); i += 3; }
return spikeRun(m, x0, y, n, '^', g.concat(gaps));
};
export const JUMP_LEVELS = [
// ============================================================ j1 刺巷
{
id: 'j1', name: 'SPIKE ALLEY', cn: '刺巷', type: 'jump', diff: 2,
hint: '针刺入门:刺毯单跳节奏、钻石缝、矮洞撞头跳、竖井、对刺门。',
width: 104,
grid: (() => {
const W = 104, H = 26, R = 22;
const m = new Grid(W, H);
m.border('#');
m.rect(1, R, W - 2, 1, '#'); // 主地板
// --- x1-9 起步 ---
m.set(3, R - 1, 'S');
m.set(8, R - 1, 'G');
// --- x10-26 刺毯(单跳节奏,垂刺压顶禁二段,两道上下夹缝)---
carpet(m, 10, R - 1, 17);
ceil(m, 10, 26, R - 6);
vgap(m, 15, R - 2); vgap(m, 21, R - 2);
// --- x28-43 钻石门三连(中心 R-2,单跳穿缝)---
carpet(m, 28, R - 1, 16, [0, 4, 8, 12, 15]); // 加密缺口便于站位
ceil(m, 28, 43, R - 6);
vgap(m, 30, R - 2); vgap(m, 34, R - 2); vgap(m, 38, R - 2);
m.set(28, R - 1, 'G');
// --- x44-56 矮洞(顶 row 18,3 格高,撞头跳 + 对刺门)---
m.rect(44, R - 4, 13, 1, '#');
carpet(m, 45, R - 1, 11);
hgap(m, 48, R - 1); hgap(m, 53, R - 1);
m.set(45, R - 1, 'G');
// --- x57-71 刺坑上的钢丝柱 ---
for (let x = 57; x <= 70; x++) { m.set(x, R, '.'); m.set(x, R + 1, '^'); }
const wires = [[58, 20], [61, 19], [64, 20], [67, 18], [70, 20]];
for (const [x, top] of wires) m.vline(x, top, R - 1, '#');
m.set(72, R - 1, 'G');
// --- x74-79 竖井上上层(井底留 3 格入口)---
m.vline(74, R - 10, R - 4, '#');
m.vline(79, R - 12, R - 1, '#');
block(m, 75, R - 3, 2); // 19
block(m, 77, R - 6, 2); // 16
block(m, 75, R - 9, 2); // 13
wall(m, 79, R - 5, R - 5, '<'); wall(m, 74, R - 7, R - 7, '>');
// --- 上层回廊(地板 row 12)x80-97 ---
m.rect(80, R - 10, 18, 1, '#');
carpet(m, 81, R - 11, 15, [0, 8]); // 8=x89:给对刺门留左侧起拍位(新物理下跳台落点偏右,须能走进 x90)
ceil(m, 81, 95, R - 16); // row 6 垂刺
hgap(m, 92, R - 11); // 对刺门
m.set(82, R - 11, 'G'); m.set(94, R - 11, 'G');
// --- x98-102 下落井 + 终点 ---
wall(m, 99, R - 7, R - 3, '>');
wall(m, 102, R - 5, R - 2, '<');
m.set(100, R - 1, 'X');
return m.out();
})(),
},
// ============================================================ j2 垂直地狱
{
id: 'j2', name: 'VERTICAL HELL', cn: '垂直地狱', type: 'jump', diff: 3,
hint: '52 格高塔:四段之字竖井,每层刺毯+上下夹缝,垂刺压顶、掉落刺追身。',
width: 68,
grid: (() => {
const W = 68, H = 52;
const m = new Grid(W, H);
m.border('#');
// ---- L0 地板 row 48(向右:刺毯 + 三道上下夹缝)----
m.rect(1, 48, 66, 1, '#');
m.set(3, 47, 'S');
carpet(m, 5, 47, 50, [1, 18]); // 1=x6 出生旁缺口;18=x23 放宽落点窗(3 连刺落点贴右腰会被 6px 状态桶卡死)
ceil(m, 5, 54, 43);
vgap(m, 16, 46); vgap(m, 30, 46); vgap(m, 46, 46);
m.set(11, 47, 'G'); m.set(39, 47, 'G');
// ---- 右井 x58..65:L0 → L1(井底 3 格敞口,之字每级升 2 格)----
well(m, 58, 65, 40, 48, 'l');
m.set(65, 45, '^'); m.set(58, 43, '^'); // 踏石外沿刺,逼迫精准落点
// ---- L1 地板 row 40(向左;垂刺避开左井 x2..9)----
m.rect(2, 40, 56, 1, '#');
carpet(m, 10, 39, 44);
ceil(m, 10, 54, 35);
vgap(m, 42, 38); vgap(m, 26, 38); vgap(m, 12, 38);
m.set(47, 39, 'G'); m.set(19, 39, 'G');
// ---- 左井 x2..8:L1 → L2 ----
well(m, 2, 8, 32, 40, 'r');
m.set(2, 37, '^'); m.set(8, 35, '^');
// ---- L2 地板 row 32(向右,井底延伸到右井下方)----
m.rect(9, 32, 57, 1, '#');
carpet(m, 11, 31, 46, [1, 11, 31]); // 1=x12 拆井口 3 连刺;11=x22/31=x42 是 hgap 夹缝通道列(必须为空,否则整门变 3 连刺、4 格跳超射程)
ceil(m, 11, 54, 27);
hgap(m, 22, 31); hgap(m, 42, 31);
vgap(m, 32, 30); vgap(m, 50, 30);
m.set(19, 31, 'G'); m.set(47, 31, 'G');
// ---- 右井 x58..65:L2 → L3 ----
well(m, 58, 65, 24, 32, 'l');
m.set(65, 29, '^'); m.set(58, 27, '^');
// ---- L3 地板 row 24(向左,掉落刺;垂刺避开左井 x2..9)----
m.rect(2, 24, 56, 1, '#');
carpet(m, 10, 23, 44, [0]); // 0=x10:拆掉左端 3 连刺(左行跳落点差 0.8px 蹭 x10 左坡;L1 同位置能过是因 vgap(12) 清空了 x10-14,L3 没有)
ceil(m, 10, 54, 19);
[14, 24, 34, 44].forEach(x => m.set(x, 18, 'D'));
vgap(m, 40, 22); vgap(m, 22, 22);
m.set(47, 23, 'G'); m.set(19, 23, 'G');
// ---- 左井 x2..8:L3 → L4 ----
well(m, 2, 8, 16, 24, 'r');
m.set(2, 21, '^'); m.set(8, 19, '^');
// ---- L4 地板 row 16(向右到终点)----
m.rect(9, 16, 57, 1, '#');
carpet(m, 11, 15, 52, [1, 41]); // 1=x12 拆井口 3 连刺;41=x52 是 hgap 夹缝通道列(必须为空)
ceil(m, 11, 60, 11);
hgap(m, 26, 15); vgap(m, 40, 14); hgap(m, 52, 15);
[18, 34, 56].forEach(x => m.set(x, 10, 'D'));
m.set(27, 15, 'G'); m.set(51, 15, 'G');
m.set(62, 15, 'X');
return m.out();
})(),
},
// ============================================================ j3 钢丝
{
id: 'j3', name: 'TIGHTROPE', cn: '钢丝', type: 'jump', diff: 3,
hint: '一格宽的柱子立在刺海上,高矮间距都不同,垂刺压着头顶。',
width: 132,
grid: (() => {
const W = 132, H = 24, R = 20;
const m = new Grid(W, H);
m.border('#');
m.rect(1, R, W - 2, 1, '#'); // 基座
spikeRun(m, 4, R - 1, W - 6, '^'); // 刺海
// 钢丝柱(顶可站 1 格),高矮错落、间距 3~5
const cols = [
[4, 18], [8, 17], [12, 19], [15, 17], [19, 18], [23, 16], [27, 19],
[30, 17], [34, 18], [38, 16], [42, 19], [46, 17], [50, 15], [54, 18],
[58, 17], [62, 19], [66, 16], [70, 18], [74, 17], [78, 19], [82, 15],
[86, 18], [90, 17], [94, 19], [98, 16], [102, 18], [106, 17], [110, 19],
[114, 16], [118, 18], [122, 17],
];
for (const [x, top] of cols) m.vline(x, top, R - 1, '#');
// 起点/终点平台
m.rect(1, R - 1, 3, 1, '#'); m.set(2, R - 2, 'S');
m.rect(126, R - 1, 4, 1, '#'); m.set(128, R - 2, 'X');
// 垂刺段(离最低柱顶 5 格:单跳安全,二段跳死)
ceil(m, 6, 28, 11); // 段内最低顶 16
ceil(m, 32, 56, 10); // 最低顶 15
ceil(m, 60, 88, 10);
ceil(m, 92, 120, 11);
// 存档(柱顶)
m.set(23, 15, 'G'); m.set(58, 16, 'G'); m.set(90, 16, 'G'); m.set(122, 16, 'G');
return m.out();
})(),
},
// ============================================================ j4 冰刺
{
id: 'j4', name: 'ICE NEEDLES', cn: '冰刺', type: 'jump', diff: 4,
hint: '冰面刹不住车:滑坑、冰上钻石、冰柱钢丝,最后还有一段冰顶矮洞。',
width: 132,
grid: (() => {
const W = 132, H = 26, R = 22;
const m = new Grid(W, H);
m.border('#');
m.rect(1, R, W - 2, 1, '%'); // 冰地板
m.set(3, R - 1, 'S');
// --- x6-30 冰面刺坑(3 格宽:新物理单跳 108.8px > 96px;旧 4~5 格坑跳不过)---
for (const [a, b] of [[7, 9], [13, 15], [19, 21], [25, 27]])
for (let x = a; x <= b; x++) { m.set(x, R, '.'); m.set(x, R + 1, '^'); }
ceil(m, 6, 24, R - 5); // 压顶垂刺:冰面上禁二段跳
m.set(12, R - 1, 'G');
// --- x32-52 冰台阶 + 角刺 + 钻石 ---
block(m, 33, R - 2, 2, '%');
block(m, 38, R - 2, 2);
block(m, 43, R - 4, 2, '%');
ceil(m, 31, 52, R - 5);
vgap(m, 48, R - 2);
m.set(41, R - 1, 'G');
// --- x54-78 冰柱钢丝 ---
spikeRun(m, 54, R - 1, 25, '^');
const ice = [[56, 19], [60, 18], [64, 20], [68, 17], [72, 19], [76, 18]];
for (const [x, top] of ice) m.vline(x, top, R - 1, '%');
ceil(m, 55, 77, 12); // 最低柱顶 17 → 12
m.set(64, 19, 'G');
// --- x80-100 冰顶矮洞(2 格高,撞头跳越刺)---
m.rect(80, R - 3, 21, 1, '%'); // 冰顶 row 19
spikeRun(m, 83, R - 1, 15, '^', [0, 4, 8, 12, 14]);
m.set(81, R - 1, 'G'); m.set(99, R - 1, 'G');
// --- x102-124 冰上钻石阵 ---
carpet(m, 102, R - 1, 23, '%'.length ? [] : []); // 冰面刺毯
ceil(m, 102, 124, R - 5);
vgap(m, 106, R - 2); vgap(m, 111, R - 2);
vgap(m, 116, R - 2); vgap(m, 121, R - 2);
m.set(103, R - 1, 'G');
m.set(126, R - 1, 'G');
m.set(129, R - 1, 'X');
return m.out();
})(),
},
// ============================================================ j5 关口
{
id: 'j5', name: 'THE GAUNTLET', cn: '关口', type: 'jump', diff: 4,
hint: '移动平台跨刺海,炮台点名,矮洞与钻石阵穿插。',
width: 122,
grid: (() => {
const W = 122, H = 28, R = 24;
const m = new Grid(W, H);
m.border('#');
m.rect(1, R, W - 2, 1, '#');
m.set(3, R - 1, 'S');
// --- x5-22 热身刺毯(三道上下夹缝)---
carpet(m, 5, R - 1, 17);
ceil(m, 5, 21, R - 6);
vgap(m, 9, R - 2); vgap(m, 15, R - 2); vgap(m, 21, R - 2);
m.set(7, R - 1, 'G');
// --- x23-49 移动平台跨海 ---
for (let x = 23; x <= 49; x++) { m.set(x, R, '.'); m.set(x, R + 1, '^'); }
m.set(50, R - 1, 'G');
// --- x51-72 矮洞 + 炮台 ---
m.rect(51, R - 4, 22, 1, '#'); // 顶 row 20
carpet(m, 52, R - 1, 20, [0]);
hgap(m, 57, R - 1); hgap(m, 64, R - 1); hgap(m, 70, R - 1);
m.set(56, R - 5, 'O');
m.set(52, R - 1, 'G');
// --- x73-97 浮块跨刺海 + 交叉火力 ---
spikeRun(m, 73, R - 1, 24, '^');
block(m, 76, R - 2, 2); block(m, 82, R - 4, 2); block(m, 88, R - 2, 2); block(m, 93, R - 3, 2);
ceil(m, 75, 96, R - 9); // 浮块区压顶:高块上二段跳必死
m.set(79, R - 6, 'O'); m.set(91, R - 6, 'O');
m.set(95, R - 1, 'G');
// --- x98-118 钻石收尾 ---
carpet(m, 98, R - 1, 20, [0, 1, 19]);
ceil(m, 98, 117, R - 6);
vgap(m, 101, R - 2); vgap(m, 106, R - 2); vgap(m, 111, R - 2); vgap(m, 116, R - 2);
m.set(98, R - 1, 'G');
m.set(119, R - 1, 'G');
m.set(120, R - 1, 'X');
return m.out();
})(),
movers: [
{ x: 25, y: 22, w: 2, h: 1, path: [[25, 22], [35, 22]], speed: 1.6 },
{ x: 39, y: 20, w: 2, h: 1, path: [[39, 20], [47, 20]], speed: 1.9 },
],
turrets: [
{ dir: 'left', period: 70, speed: 2.8, offset: 0 },
{ dir: 'left', period: 62, speed: 3.0, offset: 24 },
{ dir: 'left', period: 66, speed: 2.7, offset: 40 },
],
},
// ============================================================ j6 针刺教堂
{
id: 'j6', name: 'NEEDLE CATHEDRAL', cn: '针刺教堂', type: 'jump', diff: 5,
hint: '三层殿堂:底层向右,竖井爬升,中层折返向左,再爬升,顶层冲刺。',
width: 132,
grid: (() => {
const W = 132, H = 36;
const m = new Grid(W, H);
m.border('#');
// ============ L0 底层(地板 row 30)============
m.rect(1, 30, W - 2, 1, '#');
m.set(3, 29, 'S');
carpet(m, 5, 29, 116);
ceil(m, 5, 120, 24);
vgap(m, 18, 28); vgap(m, 46, 28); vgap(m, 74, 28); vgap(m, 102, 28);
block(m, 32, 29, 2); block(m, 60, 29, 2);
block(m, 88, 29, 2); block(m, 112, 29, 2);
m.set(11, 29, 'G'); m.set(55, 29, 'G'); m.set(95, 29, 'G');
// ============ 右井 x122-129 → L1(左墙底部留 3 格入口)============
m.vline(122, 17, 26, '#'); m.vline(129, 16, 29, '#');
block(m, 124, 27, 2); block(m, 126, 24, 2); block(m, 124, 21, 2); block(m, 126, 18, 2);
wall(m, 129, 26, 26, '<'); wall(m, 122, 23, 23, '>'); wall(m, 129, 20, 20, '<');
// ============ L1 中层(地板 row 16,向左折返,延伸到两侧井底)============
m.rect(1, 16, 122, 1, '#');
carpet(m, 9, 15, 112, [86]); // 86=x95:补 hgap(95) 通道列(否则门芯留一根 '^',GRAV_FALL 0.40 下回跳轨迹下降慢正好嵌进去)
ceil(m, 9, 118, 10);
vgap(m, 108, 14); vgap(m, 82, 14); vgap(m, 56, 14); vgap(m, 30, 14);
hgap(m, 95, 15); hgap(m, 43, 15);
m.set(44, 15, '.'); // 拆掉对刺门左刺'<':GRAV 0.33 下从 x45 起跳第 3 帧必被刺尖别住(起拍窗窄到 2px),清掉后留 ^+> 双刺门,整格起拍都能过
block(m, 69, 15, 2); block(m, 17, 15, 2);
m.set(113, 15, 'G'); m.set(63, 15, 'G'); m.set(21, 15, 'G');
// ============ 左井 x2-7 → L2(右墙底部留 3 格入口,桥接块接 L2 地板边)============
m.vline(8, 9, 12, '#');
block(m, 4, 13, 2); block(m, 2, 10, 2); block(m, 4, 7, 2);
block(m, 6, 7, 2); // 桥
wall(m, 8, 11, 11, '<'); wall(m, 2, 10, 10, '>');
// ============ L2 顶层(地板 row 8,向右到终点)============
m.rect(8, 8, 118, 1, '#');
carpet(m, 9, 7, 114);
m.set(8, 7, '.'); m.set(9, 7, '.'); m.set(10, 7, '.'); m.set(11, 7, '.'); // 井口净空
ceil(m, 9, 122, 2);
vgap(m, 24, 6); vgap(m, 60, 6); vgap(m, 96, 6);
hgap(m, 36, 7); hgap(m, 84, 7);
m.set(15, 7, 'G'); m.set(51, 7, 'G'); m.set(87, 7, 'G');
m.set(122, 7, 'X');
return m.out();
})(),
},
// ============================================================ j7 天降刺
{
id: 'j7', name: 'SKY SPIKES', cn: '天降刺', type: 'jump', diff: 4,
hint: '之字爬塔,每层头顶悬着掉落刺。走近就掉。别停。',
width: 72,
grid: (() => {
const W = 72, H = 52;
const m = new Grid(W, H);
m.border('#');
m.rect(1, 48, W - 2, 1, '#');
m.set(3, 47, 'S');
// 6 层之字平台:每层刺毯 + 钻石 + 掉落刺
const tiers = [
{ y: 44, x0: 4, x1: 30 }, { y: 38, x0: 34, x1: 62 },
{ y: 32, x0: 6, x1: 34 }, { y: 26, x0: 38, x1: 64 },
{ y: 20, x0: 8, x1: 36 }, { y: 14, x0: 40, x1: 64 },
];
for (const t of tiers) {
m.rect(t.x0, t.y, t.x1 - t.x0 + 1, 1, '#');
// 3/4 格交替刺毯;两端各留 2 格净空作为上一层跃入 / 下一层起跳的落脚点
const n = t.x1 - t.x0 + 1;
carpet(m, t.x0, t.y - 1, n, [0, 1, n - 2, n - 1]);
for (let x = t.x0 + 5; x <= t.x1 - 3; x += 7) m.set(x, t.y - 8, 'D');
ceil(m, t.x0 + 2, t.x1 - 2, t.y - 5); // 压顶垂刺:层内二段跳必死
vgap(m, Math.floor((t.x0 + t.x1) / 2), t.y - 2);
}
// 层间连接块(升 2 格,落点对准缺口)
block(m, 32, 46, 2);
block(m, 32, 42, 2); block(m, 34, 40, 2);
block(m, 32, 36, 2); block(m, 30, 34, 2);
block(m, 36, 30, 2); block(m, 38, 28, 2);
block(m, 36, 24, 2); block(m, 34, 22, 2);
block(m, 38, 18, 2); block(m, 40, 16, 2);
// 存档(各层缺口上)
m.set(7, 43, 'G'); m.set(37, 37, 'G'); m.set(9, 31, 'G');
m.set(41, 25, 'G'); m.set(11, 19, 'G'); m.set(43, 13, 'G');
m.set(60, 13, 'X');
return m.out();
})(),
},
// ============================================================ j8 三重威胁
{
id: 'j8', name: 'TRIPLE THREAT', cn: '三重威胁', type: 'jump', diff: 4,
hint: '冰面滑行、塌陷浮桥、单向台攀爬三连考,中间不许喘气。',
width: 130,
grid: (() => {
const W = 130, H = 26, R = 22;
const m = new Grid(W, H);
m.border('#');
m.rect(1, R, W - 2, 1, '#');
m.set(3, R - 1, 'S');
// --- x1-34 冰面段 ---
m.rect(1, R, 36, 1, '%');
for (const [a, b] of [[7, 9], [13, 15], [19, 21], [26, 29]])
for (let x = a; x <= b; x++) { m.set(x, R, '.'); m.set(x, R + 1, '^'); }
vgap(m, 11, R - 2); vgap(m, 17, R - 2); vgap(m, 24, R - 2); vgap(m, 30, R - 2);
m.set(11, R - 1, 'G'); m.set(32, R - 1, 'G');
// --- x38-74 塌陷浮桥(桥下刺海)---
for (let x = 38; x <= 74; x++) { m.set(x, R, '.'); m.set(x, R + 1, '^'); }
for (let x = 38; x <= 74; x++) m.set(x, R - 3, 'V');
m.set(46, R - 4, '^'); m.set(50, R - 4, '^'); m.set(54, R - 4, '^');
m.set(58, R - 4, '^'); m.set(62, R - 4, '^'); m.set(66, R - 4, '^'); m.set(70, R - 4, '^');
ceil(m, 40, 72, R - 9); // 垂刺限高
m.set(51, R - 4, 'G');
// --- x76-100 单向台攀爬 ---
for (let x = 76; x <= 99; x++) { m.set(x, R, '.'); m.set(x, R + 1, '^'); }
m.set(78, R - 2, '-'); m.set(79, R - 2, '-');
m.set(83, R - 4, '-'); m.set(84, R - 4, '-');
m.set(87, R - 6, '-'); m.set(88, R - 6, '-'); m.set(89, R - 6, '-');
m.set(93, R - 4, '-'); m.set(94, R - 4, '-');
m.set(97, R - 2, '-'); m.set(98, R - 2, '-');
// x86 只留压顶刺:JUMP 8.0 下 apex 盒底 479 与 '^' 顶点 480 只差 1px,
// 上升航线有 4 帧顶点落在盒内必死(8.5 时 apex 466 可从容擦过),故去掉地刺
m.set(86, R - 9, 'v');
vgap(m, 93, R - 6);
m.set(101, R - 1, 'G');
// --- x102-126 钻石收尾 ---
carpet(m, 102, R - 1, 24, [0, 1, 20, 21]);
ceil(m, 102, 125, R - 5);
vgap(m, 105, R - 2); vgap(m, 110, R - 2);
vgap(m, 115, R - 2); vgap(m, 120, R - 2);
m.set(102, R - 1, 'G');
m.set(126, R - 1, 'X');
return m.out();
})(),
},
// ============================================================ j9 樱花
{
id: 'j9', name: 'CHERRY BLOSSOM', cn: '樱花', type: 'jump', diff: 5,
hint: '双层结构收 3 个苹果:钻石缝中央一个,矮洞里一个,中层对刺门后一个。',
width: 128,
grid: (() => {
const W = 128, H = 34;
const m = new Grid(W, H);
m.border('#');
// ============ L0 下层(地板 row 28)============
m.rect(1, 28, W - 2, 1, '#');
m.set(3, 27, 'S');
carpet(m, 5, 27, 116, [54]); // 54=x59:拆开矮洞入口的四格跳(洞顶 row24 会挡跳的横向漂移)
ceil(m, 5, 120, 22);
vgap(m, 14, 26); vgap(m, 38, 26);
vgap(m, 86, 26); vgap(m, 110, 26);
block(m, 26, 27, 2); block(m, 50, 27, 2);
block(m, 74, 27, 2); block(m, 98, 27, 2);
m.set(11, 27, 'G'); m.set(55, 27, 'G'); m.set(95, 27, 'G');
// 井口前净空(否则刺毯把竖井入口封死)
m.set(117, 27, '.'); m.set(118, 27, '.'); m.set(119, 27, '.'); m.set(120, 27, '.');
// 苹果 1:钻石缝中央
m.set(62, 26, 'F');
// ============ L0 中段矮洞(顶 row 24,3 格高;撞头跳射程≈3 格,洞内缺口加密到 3)============
m.rect(60, 24, 16, 1, '#');
[63, 66, 69, 72, 75].forEach(x => m.set(x, 27, '.'));
// 苹果 2:矮洞里(跳跃途中拾取)
m.set(66, 26, 'F');
// ============ 右井 x120-126 → L1(左墙底部留 3 格入口)============
m.vline(119, 15, 24, '#'); m.vline(126, 14, 27, '#');
block(m, 121, 25, 2); block(m, 124, 22, 2); block(m, 121, 19, 2); block(m, 124, 16, 2);
wall(m, 126, 24, 24, '<'); wall(m, 119, 21, 21, '>'); wall(m, 126, 18, 18, '<');
// ============ L1 上层(地板 row 14,向左折返,延伸到井底)============
m.rect(2, 14, 118, 1, '#');
carpet(m, 9, 13, 108, [27, 59]); // 27=x36 / 59=x68:补对刺门通道列(否则整门是一面 3 连刺,往回跳必被 < 刺别住)
ceil(m, 9, 116, 8);
hgap(m, 100, 13); hgap(m, 68, 13); hgap(m, 36, 13);
vgap(m, 84, 12); vgap(m, 52, 12); vgap(m, 20, 12);
m.set(105, 13, 'G'); m.set(59, 13, 'G');
// 苹果 3:对刺门后
m.set(44, 12, 'F');
// ============ 终点(上层左端)============
m.set(10, 13, 'X');
return m.out();
})(),
},
// ============================================================ j10 毕业
{
id: 'j10', name: 'FINAL NEEDLE', cn: '毕业针刺', type: 'jump', diff: 5,
hint: '四层大考场:刺毯长廊 → 冰面折返 → 矮洞 → 高空钢丝。全程无尿点。',
width: 150,
grid: (() => {
const W = 150, H = 44;
const m = new Grid(W, H);
m.border('#');
// ============ L0 刺毯长廊(地板 row 38,向右)============
m.rect(1, 38, W - 2, 1, '#');
m.set(3, 37, 'S');
carpet(m, 5, 37, 134);
ceil(m, 5, 138, 32);
vgap(m, 20, 36); vgap(m, 48, 36); vgap(m, 76, 36);
vgap(m, 104, 36); vgap(m, 128, 36);
block(m, 34, 37, 2); block(m, 62, 37, 2);
block(m, 90, 37, 2); block(m, 118, 37, 2);
m.set(11, 37, 'G'); m.set(59, 37, 'G'); m.set(107, 37, 'G');
// ============ 右井 A x140-147 → L1(左墙底部留 3 格入口)============
m.vline(139, 29, 34, '#'); m.vline(147, 28, 37, '#');
block(m, 141, 35, 2); block(m, 144, 32, 2); block(m, 141, 29, 2); block(m, 144, 26, 2);
wall(m, 147, 34, 34, '<'); wall(m, 139, 31, 31, '>');
// ============ L1 冰面层(地板 row 28,向左,延伸到井底)============
m.rect(2, 28, 138, 1, '%');
carpet(m, 11, 27, 126);
m.set(136, 27, '.'); m.set(137, 27, '.'); m.set(138, 27, '.'); // 井 A 出口落点净空
ceil(m, 11, 136, 22);
vgap(m, 116, 26); vgap(m, 88, 26); vgap(m, 60, 26); vgap(m, 32, 26);
block(m, 102, 27, 2); block(m, 74, 27, 2); block(m, 46, 27, 2);
m.set(121, 27, 'G'); m.set(69, 27, 'G'); m.set(23, 27, 'G');
// ============ 左井 B x2-8 → L2(右墙底部留 3 格入口)============
m.vline(8, 17, 24, '#');
block(m, 4, 25, 2); block(m, 2, 22, 2); block(m, 4, 19, 2); block(m, 5, 17, 2);
wall(m, 8, 21, 21, '<'); wall(m, 2, 20, 20, '>'); wall(m, 8, 18, 18, '<');
// ============ L2 矮洞层(地板 row 16,向右,延伸到井 C 底)============
m.rect(8, 16, 138, 1, '#');
m.rect(10, 12, 128, 1, '#'); // 矮洞顶(3 格高洞)
carpet(m, 10, 15, 128);
m.set(8, 15, '.'); m.set(9, 15, '.'); // 井 B 出口净空
hgap(m, 72, 15);
m.set(19, 15, 'G'); m.set(67, 15, 'G'); m.set(115, 15, 'G');
// ============ 右井 C x140-147 → L3(左墙底部留 3 格入口,桥接块接 L3)============
m.vline(139, 9, 12, '#'); m.vline(147, 8, 15, '#');
block(m, 141, 13, 2); block(m, 144, 10, 2); block(m, 141, 7, 2);
block(m, 137, 7, 4); // 桥:向左落到 L3 基座
wall(m, 147, 12, 12, '<'); wall(m, 139, 10, 10, '>');
// ============ L3 高空钢丝(基座 row 8 + 刺毯 row 7,开放高空,向左到终点)============
m.rect(8, 8, 130, 1, '#');
spikeRun(m, 9, 7, 128, '^'); // 刺毯基底
for (let x = 133; x <= 137; x++) m.set(x, 7, '.'); // 井口落脚区
// 钢丝柱:间距 3~6 不规则,柱顶 row 5~7 错落
const wires = [];
const steps = [4, 5, 3, 6, 4, 3, 5, 4, 6, 3, 5, 4, 4, 6, 3, 5, 4, 6, 4, 3, 5, 4, 6, 3, 5, 4, 4, 3];
let wx = 132;
wires.push(wx);
for (const s of steps) { wx -= s; if (wx < 14) break; wires.push(wx); }
wires.forEach((x, i) => m.vline(x, [6, 5, 7, 6, 7, 5][i % 6], 7, '#'));
m.vline(55, 5, 7, '#'); // 过渡柱:x58→x52 原为 6 格跳,HSPD 2.6 下"晚二段"落点整体左移 8px 掉过柱顶摔死;x55 拆成 3+3 格两跳(dj=12 轨迹实测两跳都能精确接住)
// 存档放在两根钢丝顶上
const g1 = wires[6], g2 = wires[Math.floor(wires.length / 2) + 4];
const topOf = i => [6, 5, 7, 6, 7, 5][i % 6];
m.set(g1, topOf(6) - 1, 'G'); m.set(g2, topOf(wires.indexOf(g2)) - 1, 'G');
m.rect(8, 7, 4, 1, '#'); // 终点台
m.set(9, 6, 'X');
return m.out();
})(),
},
];
export default JUMP_LEVELS;
-72
View File
@@ -1,72 +0,0 @@
// 关卡地图工具:让手工 ASCII 不必逐格数点
// G(w, rows) —— 每行不足宽度自动补 '.',最后一列强制为 '#'(外墙)
export function G(w, rows) {
return rows.map(r => {
const s = r.length >= w ? r.slice(0, w) : r + '.'.repeat(w - r.length);
return s.slice(0, w - 1) + '#';
});
}
// 重复字符:rep('%', 20)
export const rep = (ch, n) => ch.repeat(Math.max(0, n | 0));
// 空格行
export const E = w => '#' + rep('.', w - 2);
// 实心跳台:plat(w, 起列, 长度) => 一行带 '#####' 的字符串
export function plat(w, x0, len, ch = '#') {
if (x0 < 1) x0 = 1;
return '#' + rep('.', x0 - 1) + rep(ch, len);
}
// 实体行:ent(w, [列, 字符], ...) => 一行只在指定列放实体字符
export function ent(w, ...items) {
const a = new Array(w).fill('.');
a[0] = '#';
for (const [x, ch] of items) if (x > 0 && x < w - 1) a[x] = ch;
return a.join('');
}
// 地板行:从 col1 开始按 [字符, 长度] 铺满,最后一列由 G 补 '#'
export function flr(...parts) {
let s = '#';
for (const [ch, n] of parts) s += rep(ch, n);
return s;
}
// ---------------- 2D 网格构建器 ----------------
// 大地图用拼字符串极易出错(宽度对不齐 / 数错列),改用网格对象:
// const m = new Map(120, 30); m.rect(x,y,w,h,'#'); ... m.out()
export class Grid {
constructor(w, h, fill = '.') {
this.w = w; this.h = h;
this.g = Array.from({ length: h }, () => new Array(w).fill(fill));
}
set(x, y, ch) {
if (x >= 0 && x < this.w && y >= 0 && y < this.h) this.g[y][x] = ch;
return this;
}
rect(x, y, w, h, ch) {
for (let iy = y; iy < y + h; iy++)
for (let ix = x; ix < x + w; ix++) this.set(ix, iy, ch);
return this;
}
hline(y, x0, x1, ch) { for (let x = x0; x <= x1; x++) this.set(x, y, ch); return this; }
vline(x, y0, y1, ch) { for (let y = y0; y <= y1; y++) this.set(x, y, ch); return this; }
border(ch = '#') {
this.hline(0, 0, this.w - 1, ch);
this.hline(this.h - 1, 0, this.w - 1, ch);
this.vline(0, 0, this.h - 1, ch);
this.vline(this.w - 1, 0, this.h - 1, ch);
return this;
}
out() { return this.g.map(r => r.join('')); }
}
/** 落脚台序列:chain(起始列, 起始宽, [间隙...], 台宽) => [{x,w}, ...] */
export function chain(x0, w0, gaps, padW = 2) {
const out = [{ x: x0, w: w0 }];
let x = x0 + w0;
for (const g of gaps) { x += g; out.push({ x, w: padW }); x += padW; }
return out;
}
-79
View File
@@ -1,79 +0,0 @@
// 经典针刺阵型库(IWBT 风格):所有函数基于 Grid,坐标单位 = 格
import { Grid } from './kit.js';
// 上下对刺夹缝(横向穿越 1 格高的缝):缝在行 y。
// 自动清理两侧地毯刺作起跳/落点(否则有效跳距翻倍,无解)。
export function vgap(m, x, y) {
m.set(x, y - 1, 'v'); m.set(x, y + 1, '^');
m.set(x - 1, y + 1, '.'); m.set(x + 1, y + 1, '.');
m.set(x - 2, y + 1, '.'); m.set(x + 2, y + 1, '.');
}
// 左右对刺夹缝(竖向穿越 1 格宽的缝):缝在列 x,自动清理两侧落点。
export function hgap(m, x, y) {
m.set(x - 1, y, '>'); m.set(x + 1, y, '<');
m.set(x - 2, y, '.'); m.set(x + 2, y, '.');
}
// 钻石阵:中心空格 + 四向刺(经典 diamond jump)
// 注意:矩形判定盒下,完整钻石的横向穿越窗口只有几像素——
// 作为"跳穿门"请用 vgap/hgap;diamond 只用作翻越/绕行的障碍或装饰。
export function diamond(m, x, y) { vgap(m, x, y); hgap(m, x, y); }
// 一排刺,gaps 为留空偏移(相对 x0)
export function spikeRun(m, x0, y, n, ch = '^', gaps = []) {
for (let i = 0; i < n; i++) if (!gaps.includes(i)) m.set(x0 + i, y, ch);
}
// 悬浮块,顶面铺刺(不可站)/ 或纯块
export function block(m, x, y, w = 1, h = 1, ch = '#') { m.rect(x, y, w, h, ch); }
// 悬浮块 + 顶刺
export function spikedBlock(m, x, y, w = 1) { m.rect(x, y, w, 1, '#'); spikeRun(m, x, y - 1, w, '^'); }
// 天花板垂刺
export function ceil(m, x0, x1, y) { m.hline(y, x0, x1, 'v'); }
// 墙刺竖段
export function wall(m, x, y0, y1, ch) { m.vline(x, y0, y1, ch); }
// 角刺块:w×h 实心块,某个外角带 L 形双刺(经典 corner)
// dir: 'tl' 左上角 / 'tr' 右上 / 'bl' 左下 / 'br' 右下
export function corner(m, x, y, w, h, dir) {
m.rect(x, y, w, h, '#');
if (dir === 'tl') { m.set(x, y - 1, '^'); m.set(x - 1, y, '>'); }
if (dir === 'tr') { m.set(x + w - 1, y - 1, '^'); m.set(x + w, y, '<'); }
if (dir === 'bl') { m.set(x, y + h, 'v'); m.set(x - 1, y + h - 1, '>'); }
if (dir === 'br') { m.set(x + w - 1, y + h, 'v'); m.set(x + w, y + h - 1, '<'); }
}
// 之字踏石竖井(可解性已用 tools/solve.mjs 验证)
// xL..xR 为井内列;topRow = 上层地板行,botRow = 下层地板行(差须为偶数且 ≥4)
// floorSide: 'l' 上层地板在井的左侧(井在右侧)/ 'r' 反之
// 规则:
// 1) 踏石每级升 2 格(单跳 90px > 64px),左右交替,宽度 w 使两级有 2 格重叠
// —— overlap 让"上升后横移"只需 1 格左右,避免横向距离超过滞空距离。
// 2) 靠地板一侧的井壁从 topRow+1 起到 botRow-4,底部留 3 格入口。
// 3) 顶台位于 topRow 且与上层地板紧邻(同高相接),不存在"洞口被顶台封死"。
export function well(m, xL, xR, topRow, botRow, floorSide = 'l', opts = {}) {
const w = opts.w || 5;
let i = 0;
for (let r = topRow; r <= botRow - 2; r += 2, i++) {
const onLeft = (i % 2 === 0) === (floorSide === 'l');
m.rect(onLeft ? xL : xR - w + 1, r, w, 1, '#');
}
const floorX = floorSide === 'l' ? xL - 1 : xR + 1;
const backX = floorSide === 'l' ? xR + 1 : xL - 1;
m.vline(backX, topRow, botRow, '#');
m.vline(floorX, topRow + 1, botRow - 4, '#');
return m;
}
// 竖井:在 (x0..x1) 之间开竖直通道,交替放小平台,两侧可布墙刺
// plats: [{y, side:'l'|'r', w}],spikeEvery: 每隔几行在对面墙上放刺
export function shaft(m, x0, x1, yTop, yBot, plats, opts = {}) {
for (const p of plats) {
const px = p.side === 'l' ? x0 : x1 - p.w + 1;
m.rect(px, p.y, p.w, 1, '#');
if (p.spike) spikeRun(m, px, p.y - 1, p.w, '^', p.spikeGaps || []);
}
if (opts.wallSpikes) {
const { side = 'r', every = 3, from = yTop + 1, to = yBot - 1 } = opts.wallSpikes;
for (let y = from; y <= to; y += every) {
if (plats.some(p => Math.abs(p.y - y) <= 1)) continue;
m.set(side === 'r' ? x1 : x0, y, side === 'r' ? '<' : '>');
}
}
}
export { Grid };
-91
View File
@@ -1,91 +0,0 @@
// 坑向(Troll / Trap)关卡:背叛玩家的一切直觉
import { G, rep, E, plat, ent, flr } from './kit.js';
export default [
// ---------------- 1 ----------------
{
id: 'r1', name: 'TRUST NO ONE', type: 'troll', diff: 3, bg: 'troll', width: 56,
hint: '路牌在骗你。那两颗"补给"也不怀好意。地板会塌。',
messages: ['← 地板很结实,放心走 →', '这颗苹果看起来很好吃'],
grid: G(56, [
rep('#', 56), // 0
E(56), E(56), E(56), E(56), E(56), E(56), // 1-6
E(56), E(56), E(56), E(56), E(56), E(56), // 7-12
E(56), // 13
ent(56, [20, 'D'], [38, 'D']), // 14
E(56), // 15
E(56), // 16
ent(56, [2, 'S'], [5, 'T'], [9, '*'], [22, 'G'], [36, '*'], [48, 'T'], [52, 'X']), // 17
flr(['#', 10], ['V', 8], ['#', 8], ['^', 5], ['#', 9], ['V', 6], ['#', 9]), // 18
]),
},
// ---------------- 2 ----------------
{
id: 'r2', name: 'FALSE GOAL', type: 'troll', diff: 4, bg: 'troll', width: 56,
hint: '地上那扇门是假的——走过去就死。真正的终点在右上角。',
messages: ['终点就在前面!→', '真的在上面。'],
grid: G(56, [
rep('#', 56), // 0
E(56), E(56), E(56), E(56), E(56), E(56), // 1-6
ent(56, [50, 'X']), // 7
plat(56, 48, 5), // 8
E(56), // 9
plat(56, 44, 5), // 10
E(56), // 11
plat(56, 38, 5), // 12
E(56), // 13
plat(56, 44, 5), // 14
E(56), // 15
plat(56, 38, 5), // 16
ent(56, [2, 'S'], [8, 'T'], [15, 'G'], [24, '^'], [25, '^'],
[30, 'X'], [36, 'G']), // 17
rep('#', 56), // 18
]),
events: [
{ x: 28, y: 14, w: 6, h: 5, once: false, actions: [{ type: 'kill' }] },
],
},
// ---------------- 3 ----------------
{
id: 'r3', name: 'BLIND', type: 'troll', diff: 4, bg: 'troll', width: 56,
hint: '摄像头接管了视野:你只能看见身边一小块地方。天上下刺也看不见。',
messages: ['摄像头已启动 ★'],
grid: G(56, [
rep('#', 56), // 0
E(56), E(56), E(56), E(56), E(56), E(56), // 1-6
E(56), E(56), E(56), E(56), E(56), E(56), // 7-12
E(56), // 13
ent(56, [18, 'D'], [30, 'D'], [44, 'D']), // 14
E(56), // 15
E(56), // 16
ent(56, [2, 'S'], [6, 'T'], [15, 'G'], [32, 'G'], [45, 'X']), // 17
flr(['#', 8], ['^', 3], ['#', 10], ['^', 4], ['#', 12], ['^', 3], ['#', 9], ['^', 4], ['#', 2]), // 18
]),
camzones: [{ x: 1, y: 14, w: 54, h: 5, mode: 'zoom', zoom: 2.6 }],
},
// ---------------- 4 ----------------
{
id: 'r4', name: 'TROLL PARADISE', type: 'troll', diff: 5, bg: 'troll', width: 64,
hint: '漩涡送你回起点,"补给"会杀你,地板会塌,天上还会下刺雨。',
messages: ['这是最后的考验(笑)', '前面的门是假的'],
grid: G(64, [
rep('#', 64), // 0
E(64), E(64), E(64), E(64), E(64), E(64), // 1-6
E(64), E(64), E(64), E(64), E(64), E(64), // 7-12
E(64), // 13
ent(64, [12, 'D'], [25, 'D'], [38, 'D'], [55, 'D']), // 14
E(64), // 15
E(64), // 16
ent(64, [2, 'S'], [5, 'T'], [7, '*'], [19, 'G'], [30, '*'],
[44, 'W'], [47, 'T'], [58, 'X']), // 17
flr(['#', 8], ['V', 6], ['#', 8], ['^', 4], ['#', 8], ['V', 6], ['#', 8], ['^', 3], ['#', 12]), // 18
]),
warps: [{ to: 'spawn' }],
events: [
{ x: 28, y: 14, w: 5, h: 5, once: false, actions: [{ type: 'spikes', x: 26, y: 0, count: 8, gap: 2 }] },
],
},
];
-47
View File
@@ -1,47 +0,0 @@
// 教学关(短,用来熟悉手感)
import { G, rep, E, plat, ent } from './kit.js';
export default [
{
id: 't1', name: 'FIRST STEPS', type: 'jump', diff: 1, bg: 'jump', width: 36,
hint: '← → 移动,Shift / Z / 空格跳跃。刺碰到就死,R 键重来。',
messages: ['← → 移动 Shift 跳跃 R 重来'],
grid: G(36, [
rep('#', 36),
E(36), E(36), E(36), E(36), E(36), E(36),
ent(36, [30, 'X']),
plat(36, 29, 5),
E(36),
plat(36, 26, 5),
E(36),
plat(36, 22, 5),
E(36),
plat(36, 18, 5),
E(36),
plat(36, 13, 5),
ent(36, [2, 'S'], [4, 'T'], [7, '^'], [8, '^'], [9, '^'], [22, 'G']),
rep('#', 36),
]),
},
{
id: 't2', name: 'DOUBLE JUMP', type: 'jump', diff: 1, bg: 'custom', width: 40,
hint: '空中再按一次跳跃=二段跳。5 格宽的刺坑只能靠二段跳过去。',
messages: ['空中再按一次 Shift = 二段跳'],
grid: G(40, [
rep('#', 40),
E(40), E(40), E(40), E(40), E(40), E(40), E(40),
ent(40, [19, 'X']),
plat(40, 18, 4),
E(40),
plat(40, 26, 4),
E(40),
plat(40, 18, 4),
E(40),
plat(40, 26, 4),
E(40),
plat(40, 18, 4),
ent(40, [2, 'S'], [4, 'T'], [11, '^'], [12, '^'], [13, '^'], [14, '^'], [15, '^'], [19, 'G']),
rep('#', 40),
]),
},
];
-352
View File
@@ -1,352 +0,0 @@
// 入口:游戏循环 / 状态机
import { LEVELS, findLevel } from './levels/index.js';
import { Level } from './level.js';
import { Player } from './player.js';
import { Camera } from './camera.js';
import { Input } from './input.js';
import { UI } from './ui.js';
import { Save } from './save.js';
import { Touch } from './touch.js';
import { sfx, unlockAudio, Audio2 } from './audio.js';
import { Editor } from './editor.js';
import { Settings } from './settings.js';
import { clamp } from './util.js';
const W = 800, H = 608;
const STEP = 1000 / 60;
// 移动端检测:触屏设备自动启用虚拟手柄;?touch=1 强制开启,?touch=0 强制关闭(桌面调试用)
// 判定条件:① 是移动端(存在 ontouchstart)/ 支持触屏(maxTouchPoints>0)/ 粗指针(触屏笔或手指)
// 只有命中才给 <html> 加 .touch 类;CSS 里 html:not(.touch) #touch 会强制隐藏,桌面端永远不显示按钮
const _qp = new URLSearchParams(location.search);
const _force = _qp.get('touch');
const _isTouch = ('ontouchstart' in window) || navigator.maxTouchPoints > 0 || matchMedia('(pointer: coarse)').matches;
const useTouch = _force === '1' || (_force !== '0' && _isTouch);
if (useTouch) document.documentElement.classList.add('touch');
function showTouch() { if (useTouch) Touch.show(); }
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d', { alpha: false });
ctx.imageSmoothingEnabled = false;
const G = {
state: 'menu', // menu | playing | paused | result | editor
level: null, player: null, cam: null,
data: null, idx: -1,
map: null, face: 0, // 多面地图:map=各面 Level 数组,face=当前面下标
fromEditor: false, // 是否从编辑器「试玩」进入(决定暂停菜单末项去向)
deaths: 0, ms: 0, hudTick: 0,
time: 0,
lastLevelId: null,
};
// ---------------- 关卡流程 ----------------
// 把一张「面」的数据包装成 Level 可用的 data(门目标来自面自身的 doors)
function faceData(map, face, i) {
return {
id: (map.id || 'map') + '-' + i,
name: face.name || ('面' + (i + 1)),
type: 'custom',
width: face.width || (face.grid[0] ? face.grid[0].length : 25),
grid: face.grid,
doors: face.doors || [],
bg: 'custom', hint: '', messages: [],
};
}
function startLevel(data, idx = -1) {
unlockAudio();
G.data = data; G.idx = idx; G.lastLevelId = data.id;
// 自制多面地图:data.faces 存在则构建每面一个 Level;否则按传统单关处理
if (data.faces && data.faces.length) {
G.map = data.faces.map((f, i) => { const L = new Level(faceData(data, f, i)); L.faceIndex = i; return L; });
G.face = Math.max(0, Math.min(data.startFace || 0, G.map.length - 1));
} else {
G.map = [new Level(data)]; G.face = 0;
}
G.level = G.map[G.face];
G.player = new Player(G.level);
G.cam = new Camera(W, H);
G.cam.reset(G.level, G.player);
G.player.onDeath = () => { G.deaths++; };
G.player.onClear = () => finish();
G.player.onPortal = (e) => doPortal(e);
G.player.onRespawnFace = () => doRespawnFace();
G.level.bosses.forEach(b => { b.onDefeat = () => finish(); });
G.deaths = 0; G.ms = 0; G.hudTick = 0;
G.state = 'playing';
UI.showGame();
showTouch();
UI.toast('');
if (data.hint) UI.toast(data.hint);
}
// 把玩家放到某面某门上(不切面的同面传送也用)
function placeOnDoor(dest) {
const P = G.player;
P.x = dest.x + dest.w / 2 - P.w / 2;
P.y = dest.y + dest.h - P.h;
P.vx = 0; P.vy = 0; P.jumps = 0;
}
// 踩到门:e.to = {face, door}
function doPortal(e) {
const to = e.to;
const tp = Math.max(0, Math.min(G.map.length - 1, to.face | 0));
if (tp === G.face) {
const dest = G.map[tp].entities.find(o => o.type === 'warp' && o.doorId === to.door);
if (dest) { placeOnDoor(dest); dest.cool = 40; }
sfx.warp();
} else {
switchFace(tp, to.door);
}
}
// 切换到第 f 面,并落到该面的目标门上
function switchFace(f, doorId) {
G.face = f;
G.level = G.map[f];
G.level.reset(true); // 进入该面时重置其运行时
G.player.level = G.level;
const dest = G.level.entities.find(o => o.type === 'warp' && o.doorId === doorId);
if (dest) { placeOnDoor(dest); dest.cool = 40; }
else { const sp = G.level.spawn; G.player.x = sp.x; G.player.y = sp.y; G.player.vx = 0; G.player.vy = 0; }
G.cam.reset(G.level, G.player);
sfx.warp();
G.level.particles.spawn(G.player.cx, G.player.cy, 12, { colors: ['#a86bff', '#fff'], speed: 2.5, life: 24 });
}
// 复活回到存档点所在面(跨面)
function doRespawnFace() {
const sp = G.player.savePoint;
if (sp && typeof sp.face === 'number' && sp.face !== G.face) {
G.face = sp.face;
G.level = G.map[sp.face];
G.level.reset(true);
G.player.level = G.level;
G.cam.reset(G.level, G.player);
}
}
/** 整关从头再来:保留死亡数,丢弃所有存档点进度 */
function restart() {
if (!G.data) return;
const d = G.deaths;
startLevel(G.data, G.idx);
G.deaths = d; // 死亡数不重置
}
/** R 键 / 触屏重生键:直接重生回最近的存档点——死亡中按 = 立即复活;活着按 = 原地播死亡动画后当帧重生 */
function suicide() {
if (!G.player || G.state !== 'playing') return;
if (G.player.dead) G.player.respawn();
// 活着按:原地播死亡动画(粒子+轻震+冲击环),但不留红叉、不计死亡数,当帧就回存档点——
// 不等待,特效由 Level 层留在死亡位置自然播完(重生不清粒子/震屏)
else { G.player.deathBurst(); G.player.respawn(); }
}
/** 触屏射击:仅在持枪、未死亡且游戏进行中时发射(受玩家自身冷却限制) */
function shoot() {
const P = G.player;
if (G.state !== 'playing' || !P || !P.alive || !P.hasGun || P.shootCool !== 0) return;
P.shoot();
}
function finish() {
if (G.state !== 'playing') return;
G.state = 'result';
Touch.hide();
sfx.goal();
const apples = G.player.apples || 0;
const total = G.level.totalApples || 0;
const better = Save.clear(G.data.id, G.deaths, G.ms, apples);
const hasNext = G.idx >= 0 && G.idx + 1 < LEVELS.length;
UI.result({
name: G.data.name, ms: G.ms, deaths: G.deaths,
apples, totalApples: total, better, hasNext,
});
}
function nextLevel() {
if (G.idx >= 0 && G.idx + 1 < LEVELS.length) startLevel(LEVELS[G.idx + 1], G.idx + 1);
else toLevels();
}
function toLevels() { G.state = 'menu'; UI.show('scr-levels'); Touch.hide(); }
function toTitle() { G.state = 'menu'; UI.show('scr-title'); Touch.hide(); }
function toEditor() {
G.state = 'editor';
UI.show('scr-editor');
Touch.hide();
// 试玩后返回:保留正在编辑的地图;从未进过编辑器时才新建
if (Editor.faces) Editor.show(); else Editor.open();
}
function pause() {
if (G.state !== 'playing') return;
G.state = 'paused';
// 暂停菜单末项:从编辑器试玩进来 → 「返回编辑器」;否则 → 「返回关卡选择」
const q = document.querySelector('#scr-pause [data-act="quit"]');
if (q) q.textContent = G.fromEditor ? '返回编辑器' : '返回关卡选择';
UI.show('scr-pause');
Touch.hide();
}
function resume() { if (G.state === 'paused') { G.state = 'playing'; UI.showGame(); showTouch(); } }
// ---------------- 更新 ----------------
function update() {
const P = G.player;
// R = 重生回存档点;Backspace = 整关重开
if (Input.hit('suicide')) { suicide(); return; }
if (Input.hit('restart')) { restart(); return; }
if (Input.hit('pause')) { pause(); return; }
const wasDead = P.dead;
G.level.update(1, P, G.cam);
for (const b of G.level.bosses) b.update(1, P);
P.update(1);
// 刚从死亡中复活:若存档点在别的面,先切回该面,再镜头瞬移
if (P.justRespawned) {
if (P.savePoint && typeof P.savePoint.face === 'number' && P.savePoint.face !== G.face) doRespawnFace();
G.cam.snapTo(P, G.level);
P.justRespawned = false;
}
G.cam.update(G.level, P, 1);
G.ms += STEP;
G.time++;
if (++G.hudTick % 5 === 0) {
const L = G.level;
UI.hud({
name: G.data.name + (G.map.length > 1 ? ` · 面${G.face + 1}/${G.map.length}` : ''),
deaths: G.deaths, ms: G.ms,
apples: P.apples, totalApples: L.totalApples, hasGun: P.hasGun,
save: P.saveIdx || 0, totalSaves: L.totalSaves || 0,
});
Touch.setGun(P.hasGun);
const boss = L.bosses.find(b => !b.dead) || L.bosses[0];
UI.bossBar(L.bosses.length ? boss : null);
}
}
// ---------------- 渲染 ----------------
function render() {
const t = G.time;
if (G.state === 'playing' || G.state === 'paused' || G.state === 'result') {
const L = G.level, P = G.player, C = G.cam;
ctx.fillStyle = '#0a0a14';
ctx.fillRect(0, 0, W, H);
L.drawBg(ctx, C, t);
ctx.save();
C.apply(ctx);
L.draw(ctx, C, t);
for (const b of L.bosses) b.draw(ctx, t);
P.draw(ctx);
ctx.restore();
L.drawOverlay(ctx, C, t);
UI.toast(L.toast || '');
// 死亡中:提示手动重生(呼吸闪烁,不抢死亡动画镜头)
if (G.state === 'playing' && P.dead) {
const blink = Math.sin(t / 12) > -0.3;
ctx.save();
ctx.textAlign = 'center';
ctx.globalAlpha = blink ? 1 : 0.3;
ctx.fillStyle = '#ff8fa3';
ctx.font = 'bold 20px monospace';
ctx.fillText(useTouch ? '按右下角重生键' : '按 R 重生', W / 2, H / 2 - 36);
ctx.globalAlpha = 0.7;
ctx.fillStyle = '#a8a8c0';
ctx.font = '12px monospace';
ctx.fillText('Backspace 整关重来', W / 2, H / 2 - 12);
ctx.restore();
}
} else {
drawTitleBg(t);
}
}
function drawTitleBg(t) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, '#181828'); g.addColorStop(1, '#08080f');
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
// 漂浮的刺
ctx.globalAlpha = 0.5;
for (let i = 0; i < 26; i++) {
const x = ((i * 137 + t * 0.15) % (W + 60)) - 30;
const y = 120 + ((i * 71) % (H - 160));
ctx.fillStyle = i % 3 === 0 ? '#3a3a4c' : '#2a2a38';
ctx.beginPath();
ctx.moveTo(x, y + 18); ctx.lineTo(x + 11, y); ctx.lineTo(x + 22, y + 18);
ctx.closePath(); ctx.fill();
}
ctx.globalAlpha = 1;
// 底部刺海
ctx.fillStyle = '#1a1a26';
ctx.fillRect(0, H - 30, W, 30);
ctx.fillStyle = '#c8c8d8';
for (let x = 0; x < W; x += 22) {
ctx.beginPath();
ctx.moveTo(x, H - 4); ctx.lineTo(x + 11, H - 26); ctx.lineTo(x + 22, H - 4);
ctx.closePath(); ctx.fill();
}
}
// ---------------- 主循环 ----------------
let last = performance.now(), acc = 0;
function loop(now) {
requestAnimationFrame(loop);
let dt = now - last; last = now;
if (dt > 200) dt = 200;
acc += dt;
let guard = 0;
while (acc >= STEP && guard++ < 6) {
acc -= STEP;
if (G.state === 'playing') update();
else if (G.state === 'paused' && Input.hit('pause')) resume();
Input.endFrame();
}
if (G.state !== 'playing') G.time++;
render();
}
// ---------------- 启动 ----------------
let settingsFrom = 'scr-title'; // 进入设置前的界面,确定后返回
Save.load();
Audio2.enabled = Save.data.audio !== false;
UI.init({
play: (lv) => { G.fromEditor = false; startLevel(lv, lv.builtin === false ? -1 : (lv.index ?? -1)); },
levels: toLevels,
manage: () => UI.setManage(!UI.manage),
editor: toEditor,
settings: () => { settingsFrom = UI.cur; Settings.enter(); UI.show('scr-settings'); },
'set-ok': () => { Settings.confirm(); UI.show(settingsFrom); },
'set-reset': () => Settings.reset(),
back: toTitle,
resume,
restart: () => { restart(); },
quit: () => (G.fromEditor ? toEditor() : toLevels()),
retry: () => startLevel(G.data, G.idx),
next: nextLevel,
wipe: () => {
if (confirm('确定清除所有通关记录与自制关卡?')) { Save.wipe(); UI.renderLevels(); }
},
});
Editor.init({
startLevel: (data) => { G.fromEditor = true; startLevel(data, -1); },
refresh: () => UI.renderLevels(),
});
// 首次交互解锁音频
const unlock = () => { unlockAudio(); removeEventListener('pointerdown', unlock); removeEventListener('keydown', unlock); };
addEventListener('pointerdown', unlock);
addEventListener('keydown', unlock);
// 移动端虚拟手柄
Touch.init({ pause, suicide, shoot });
Settings.init(useTouch);
UI.show('scr-title');
requestAnimationFrame(loop);
// 调试钩子
window.__IW = { G, LEVELS, Save, startLevel, pause, resume, restart, suicide };
-44
View File
@@ -1,44 +0,0 @@
// 粒子系统
import { rand, randInt } from './util.js';
export class Particles {
constructor() { this.list = []; }
clear() { this.list.length = 0; }
spawn(x, y, n, opt = {}) {
const {
colors = ['#fff', '#ff5c7a', '#ffc63a'],
speed = 3, life = 40, size = 3, grav = 0.12, spread = Math.PI * 2, dir = 0,
} = opt;
for (let i = 0; i < n; i++) {
const a = dir + (Math.random() - 0.5) * spread;
const s = rand(speed * 0.35, speed);
this.list.push({
x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s,
life: life * rand(0.6, 1.25), max: life,
c: colors[randInt(0, colors.length - 1)],
s: size * rand(0.6, 1.3), g: grav,
});
}
}
burst(x, y, opt = {}) { this.spawn(x, y, opt.n || 18, opt); }
update() {
for (let i = this.list.length - 1; i >= 0; i--) {
const p = this.list[i];
p.x += p.vx; p.y += p.vy; p.vy += p.g;
p.vx *= 0.985;
if (--p.life <= 0) this.list.splice(i, 1);
}
}
draw(ctx) {
for (const p of this.list) {
ctx.globalAlpha = Math.min(1, p.life / (p.max * 0.5));
ctx.fillStyle = p.c;
ctx.fillRect(p.x | 0, p.y | 0, p.s | 0, p.s | 0);
}
ctx.globalAlpha = 1;
}
}
-396
View File
@@ -1,396 +0,0 @@
// 玩家:还原 IWBT 的硬核手感 —— 松手即停、无加速、可变跳跃高度、二段跳
import { TILE, clamp, sgn, overlap } from './util.js';
import { Input } from './input.js';
import { sfx } from './audio.js';
import * as S from './sprites.js';
import { T } from './level.js';
export const PHY = {
GRAV: 0.25, // 上升段重力:小 = 跳跃飘
GRAV_FALL: 0.32, // 下落段重力:小 = 下坠轻、飘落感(0.35→0.32,用户反馈"下落有点沉")
GRAV_DJ: 0.35, // 二段跳上升段专属重力:比 GRAV 稍重——初速 6.7 与一段 6.6 一致,配 0.35 才能又冲(不起悬停)又只有 4.5 格总高
MAXFALL: 9,
JUMP: 6.6,
DJUMP: 6.7, // 二段跳冲量:起跳初速与一段 JUMP 6.6 几乎一致(起跳"嘭"感同款);顶点接二段总高 = 4.51 格
DJ_INERTIA: 0, // 二段跳时下落速度对冲量的抵消比例:0=任何时机接二段都满力起跳(初速恒 -DJUMP,同 IWBT 原版),
// 1=全额抵消(旧 bug:顶点晚几帧接二段直接归零)。0.35/0.5 都被用户反馈"好沉+短"——起跳初速被下落速度
// 吃掉才是"沉"的根源(初速从 6.7 掉到 3~4,起跳那一下没劲),与上升重力无关。
HSPD: 2.6,
WATER_GRAV: 0.16,
WATER_FALL: 2.6,
WATER_RISE: 0.42,
ICE_ACC: 0.18,
ICE_FRIC: 0.07,
// 滑出冰块后的惯性:空中几乎不损失,地面缓慢收住
SLIDE_FRIC: 0.12, // 非冰地面上收住惯性的摩擦
SLIDE_AIR: 0.02, // 空中惯性衰减(≈0,滑出冰块边缘能一路飞出去)
SLIDE_EASE: 0.14, // 超速滑行时按住同向键,收敛回正常走速的速度
SLIDE_BRAKE: 0.45, // 滑行中按反向键的刹车
SLIDE_MIN: 0.35, // 冰面上攒到多大速度才算进入滑行状态
SLIDE_STOP: 0.05, // 低于此速度惯性结束
ICE_TOP: 1.6, // 冰面最高速度 = HSPD × ICE_TOP(调大=冰上能滑得更快,留更猛的惯性)
};
export class Player {
constructor(level) {
this.level = level;
// IWBT 官方判定盒:11×21 像素(比精灵视觉略小,支持像素级擦弹/贴刺)
this.w = 11; this.h = 21;
this.reset(true);
}
reset(full) {
const sp = full ? this.level.spawn : (this.savePoint || this.level.spawn);
this.x = sp.x; this.y = sp.y;
this.vx = 0; this.vy = 0;
this.jumps = 0;
this.djUp = false;
this.grounded = false;
this.facing = 1;
this.alive = true;
this.dead = false;
this.deadT = 0;
this.deathX = 0; this.deathY = 0; // 死亡动画圆心(掉坑死亡时 clamp 到关卡内)
this.inWater = false;
this.animT = 0;
this.shootCool = 0;
this.mover = null;
this.sliding = false; // 冰面滑行惯性状态:离开冰块后仍保留
this.justRespawned = false; // 本帧刚复活(供镜头瞬移)
if (full) {
this.hasGun = !!this.level.data.startGun;
this.apples = 0;
this.savePoint = null;
this.saveIdx = 0;
}
}
get cx() { return this.x + this.w / 2; }
get cy() { return this.y + this.h / 2; }
// 纯死亡演出(不进死亡状态):粒子爆开 + 屏幕轻震 + 死亡点扩散冲击环(Level 层,见 addDeathFX)。
// 主动按重生键时直接调它再当帧 respawn——特效留在死亡位置自然播完。
// 圆心 clamp 到关卡内:掉坑死亡时玩家已在屏幕外,保证红叉/粒子/圆环可见
deathBurst() {
const L = this.level;
const dx = clamp(this.cx, 10, Math.max(10, L.pxW - 10));
const dy = clamp(this.cy, 10, Math.max(10, L.pxH - 10));
sfx.death();
L.particles.spawn(dx, dy, 16, {
colors: ['#ff5c7a', '#ff8095', '#fff', '#ffc63a'], speed: 3.4, life: 34, size: 3, grav: 0.16,
});
L.shake = Math.max(L.shake || 0, 6);
L.addDeathFX(dx, dy);
return { dx, dy };
}
// 死亡:定格在原地,等玩家手动按重生键(R / 触屏重生键)。
// 视觉:① 死亡地点留下常驻红叉(IWBTG 血迹,复活后仍在;opts.mark=false 时不留)
// ② deathBurst() 的"小"死亡动画(粒子+轻震+冲击环)
// opts:
// mark=false → 不留红叉
// count=false → 不触发 onDeath 计数(不计入死亡数)
kill(opts = {}) {
if (this.dead || !this.alive) return;
this.dead = true; this.alive = false; this.deadT = 0;
this.vx = 0; this.vy = 0; this.sliding = false;
const { dx, dy } = this.deathBurst();
this.deathX = dx; this.deathY = dy;
if (opts.mark !== false) this.level.addDeathMark(dx, dy);
if (opts.count !== false && this.onDeath) this.onDeath();
}
respawn() {
this.dead = false; this.alive = true;
const L = this.level;
const sp = this.savePoint || L.spawn;
this.x = sp.x; this.y = sp.y;
this.vx = 0; this.vy = 0; this.jumps = 0;
this.djUp = false;
this.shootCool = 0; this.sliding = false;
this.deadT = 0;
// 安全兜底:万一重生点被地形/消失块堵住,向上顶出,别一复活就卡死
let guard = 0;
while (L.solidRect(this.x, this.y, this.w, this.h) && guard++ < 48) this.y -= 2;
L.softReset(); // 不清粒子/震屏:死亡特效留在死亡位置自然播完
this.justRespawned = true; // 供 main.js 把镜头直接吸附到重生点
}
// ---------------- 每帧更新 ----------------
update(dt) {
const L = this.level;
this.animT++;
if (this.shootCool > 0) this.shootCool--;
// 死亡:定格在原地等玩家手动重生(R 键 / 触屏重生键),
// 世界继续运转(敌人/子弹/粒子照走),但玩家不响应任何输入
if (this.dead) { this.deadT++; return; }
const inWater = L.waterRect(this.x + 2, this.y + 4, this.w - 4, this.h - 4);
if (inWater && !this.inWater) sfx.splash();
this.inWater = inWater;
// ---- 水平输入(IWBT:普通地面瞬时、无惯性;冰面打滑并保留惯性)----
const moveL = Input.is('left'), moveR = Input.is('right');
const onIce = this.grounded && L.iceAtBottom(this.x, this.y, this.w, this.h);
let target = 0;
if (moveL && !moveR) target = -PHY.HSPD;
else if (moveR && !moveL) target = PHY.HSPD;
const ICE_MAX = PHY.HSPD * PHY.ICE_TOP;
const decel = f => {
if (Math.abs(this.vx) <= f) this.vx = 0;
else this.vx -= sgn(this.vx) * f;
};
if (onIce) {
// 冰面:加速/减速都很慢,速度上限也更高
if (target !== 0) this.vx += sgn(target) * PHY.ICE_ACC;
else decel(PHY.ICE_FRIC);
this.vx = clamp(this.vx, -ICE_MAX, ICE_MAX);
// 攒出一定速度即进入「滑行」状态——这是惯性得以保留的关键
if (Math.abs(this.vx) > PHY.SLIDE_MIN) this.sliding = true;
} else if (this.sliding && Math.abs(this.vx) > PHY.SLIDE_STOP) {
// 滑出冰块后:惯性依然保留 —— 空中几乎不衰减,地面才慢慢收住
if (target !== 0) {
const dir = sgn(this.vx);
if (dir === 0 || sgn(target) === dir) {
if (Math.abs(this.vx) >= PHY.HSPD) {
// 还在超速滑行:同向按键只让它缓慢收敛回正常走速,不再继续加速
this.vx -= dir * PHY.SLIDE_EASE;
if (Math.abs(this.vx) < PHY.HSPD) { this.vx = target; this.sliding = false; }
} else {
this.vx = target; // 已低于走速:交还玩家控制
this.sliding = false;
}
} else {
this.vx -= dir * PHY.SLIDE_BRAKE; // 反向按键:刹车,刹住后立刻反向
if (sgn(this.vx) === sgn(target)) { this.vx = target; this.sliding = false; }
}
this.vx = clamp(this.vx, -ICE_MAX, ICE_MAX);
} else {
decel(this.grounded ? PHY.SLIDE_FRIC : PHY.SLIDE_AIR);
if (this.vx === 0) this.sliding = false;
}
if (this.vx !== 0 && Math.abs(this.vx) < PHY.SLIDE_STOP) { this.vx = 0; this.sliding = false; }
} else {
// 常规地面/空中(非滑行):IWBT 标准手感,松手即停、无惯性
this.vx = target;
this.sliding = false;
}
if (target !== 0) this.facing = sgn(target);
// ---- 跳跃 ----
if (inWater) {
if (Input.is('jump')) this.vy -= PHY.WATER_RISE;
if (Input.is('down')) this.vy += 0.25;
this.vy = clamp(this.vy, -3.2, PHY.WATER_FALL);
this.jumps = 0; this.djUp = false;
} else {
if (Input.hit('jump')) {
if (this.grounded || this.jumps === 0) {
this.vy = -PHY.JUMP; this.jumps = 1; this.grounded = false;
// 不再做起跳拉伸:空中保持身体比例,避免跳跃时视觉上下晃动
sfx.jump();
L.particles.spawn(this.cx, this.y + this.h, 5,
{ colors: ['#fff', '#cfcfe0'], speed: 1.6, life: 16, size: 2, dir: -Math.PI / 2, spread: 2 });
} else if (this.jumps === 1) {
// 二段跳冲量叠减当前速度:上升中按 = 原版语义(直接获得 -DJUMP);
// 下落中按 = 下落惯性只抵消一部分冲量(DJUMP 前乘 DJ_INERTIA)——
// 全额抵消会让"满力一段跳到顶点、晚几帧接二段"的二段塌到接近 0(非常短),
// 只抵消一半则顶点附近接二段保持满力,摔得快时才明显变弱
this.vy = Math.max(this.vy, 0) * PHY.DJ_INERTIA - PHY.DJUMP; this.jumps = 2;
this.djUp = this.vy < 0; // 二段上升段启用专属轻重力(与一段同款飘)
sfx.djump();
L.particles.spawn(this.cx, this.cy, 10,
{ colors: ['#fff', '#9ad8ff'], speed: 2.2, life: 20, size: 2, spread: Math.PI * 2 });
}
}
// 可变跳跃高度
if (!Input.is('jump') && this.vy < -3) this.vy = -3;
// 上升轻、下落重:分手感。二段上升段用更轻的 GRAV_DJ(初速小,同款飘)
this.vy = Math.min(this.vy + (this.vy < 0 ? (this.djUp ? PHY.GRAV_DJ : PHY.GRAV) : PHY.GRAV_FALL), PHY.MAXFALL);
if (this.djUp && this.vy >= 0) this.djUp = false; // 二段到顶,交回普通重力
}
// ---- 站在移动平台上则跟随 ----
if (this.mover && !this.mover.gone) {
this.x += this.mover.dx;
this.y += this.mover.dy;
}
// ---- X 轴移动 ----
const prevBottom = this.y + this.h;
this.mover = null;
let steps = Math.abs(this.vx);
const dx = sgn(this.vx);
while (steps > 0) {
const m = Math.min(1, steps);
if (!L.solidRect(this.x + dx * m, this.y, this.w, this.h)) { this.x += dx * m; steps -= m; }
else break;
}
if (L.solidRect(this.x + dx, this.y, this.w, this.h) && Math.abs(this.vx) > 0) this.vx = 0;
// ---- Y 轴移动 ----
const dropThrough = Input.is('down');
steps = Math.abs(this.vy);
const dy = sgn(this.vy);
let hitGround = false;
while (steps > 0) {
const m = Math.min(1, steps);
const ny = this.y + dy * m;
const solidHere = L.solidRect(this.x, ny, this.w, this.h, {
prevBottom, dropThrough,
});
if (!solidHere) { this.y = ny; steps -= m; }
else {
if (dy > 0) hitGround = true;
this.vy = 0;
break;
}
}
// 检查是否踩到移动平台
for (const m of L.movers) {
if (m.gone) continue;
if (this.x + this.w > m.x && this.x < m.x + m.w) {
if (this.y + this.h >= m.y - 2 && this.y + this.h <= m.y + m.h) {
this.mover = m; hitGround = true;
}
}
}
const wasAir = !this.grounded;
this.grounded = hitGround || (this.vy === 0 && L.solidRect(this.x, this.y + 1, this.w, this.h));
if (this.grounded) {
this.jumps = 0; this.djUp = false;
}
// ---- 滑行反馈:超速滑行(冰上,或刚滑出冰块仍带惯性)时脚下溅起冰屑 ----
if (this.sliding && this.grounded && Math.abs(this.vx) > PHY.HSPD && this.animT % 5 === 0) {
L.particles.spawn(this.cx - sgn(this.vx) * 5, this.y + this.h - 2, 2, {
colors: ['#dff4ff', '#8fd8f0', '#fff'], speed: 1.1, life: 16, size: 2,
dir: -Math.PI / 2, spread: 1.7, grav: 0.1,
});
}
// ---- 边界 ----
this.x = clamp(this.x, 0, L.pxW - this.w);
if (this.y > L.pxH + 200) { this.kill(); return; }
if (this.y < -300) { this.y = -300; this.vy = 0; }
// ---- 致死:同帧立刻复活,画面上不留任何死亡状态 ----
if (L.deadlyRect(this.x, this.y, this.w, this.h)) { this.kill(); return; }
// ---- 射击 ----
if (this.hasGun && Input.hit('shoot') && this.shootCool === 0 && L.bullets.length < 5) {
this.shoot();
}
// ---- 实体交互 ----
this.touchEntities();
// ---- 消失方块踩踏 ----
if (this.grounded) {
const ty = Math.floor((this.y + this.h + 2) / TILE);
for (let tx = Math.floor(this.x / TILE); tx <= Math.floor((this.x + this.w - 1) / TILE); tx++) {
if (L.tiles[ty] && L.tiles[ty][tx] === T.VANISH && L.vanishState(tx, ty) === 0) {
L.setVanish(tx, ty, 1);
L.vanishT = L.vanishT || new Map();
L.vanishT.set(ty * 1000 + tx, 0);
}
}
}
}
shoot() {
const L = this.level;
this.shootCool = 9;
L.bullets.push({
x: this.facing > 0 ? this.x + this.w - 2 : this.x - 6,
y: this.y + 8, w: 10, h: 5,
vx: 9 * this.facing, vy: 0, life: 130, dead: false,
});
sfx.shoot();
L.particles.spawn(this.facing > 0 ? this.x + this.w : this.x, this.y + 10, 3,
{ colors: ['#fff6a0'], speed: 1.5, life: 10, size: 2 });
}
touchEntities() {
const L = this.level;
for (const e of L.entities) {
if (e.dead) continue;
// 存档点判定放宽 6px:跑得太快或从旁边擦过去也能吃到
const hit = e.type === 'save'
? overlap({ x: this.x - 6, y: this.y - 6, w: this.w + 12, h: this.h + 12 }, e)
: overlap(this, e);
if (!hit) continue;
switch (e.type) {
case 'save':
if (!e.active) {
e.active = true;
this.savePoint = { x: e.x + e.w / 2 - this.w / 2, y: e.y + e.h - this.h, face: (this.level.faceIndex ?? 0) };
this.saveIdx = e.no || 1;
sfx.save();
L.particles.spawn(e.x + e.w / 2, e.y + e.h / 2, 18,
{ colors: ['#b06bff', '#fff', '#e5d0ff'], speed: 2.6, life: 30 });
// 假存档:立刻复活回上一个存档点,不播死亡动画
if (e.fake) { L.showToast('假的。', 90); this.kill(); return; }
}
break;
case 'goal':
if (!this.clearing) { this.clearing = true; if (this.onClear) this.onClear(); }
break;
case 'gun':
if (!e.taken) {
e.taken = true; this.hasGun = true;
L.particles.spawn(e.x + e.w / 2, e.y + e.h / 2, 12, { colors: ['#dfe0f0', '#fff'], speed: 2.2, life: 24 });
L.showToast('获得手枪!按 X 射击', 120);
sfx.save();
}
break;
case 'apple':
if (!e.taken) { e.taken = true; this.apples++; sfx.save(); }
break;
case 'jumpref':
if (this.animT % 4 === 0) this.jumps = 0;
break;
case 'warp': {
if (e.cool > 0) break;
e.cool = 40;
const to = e.to;
// 自制多面地图的跨面传送门:to = {face, door} —— 交给 main 切换面
if (to && typeof to === 'object' && !('x' in to) && !('y' in to) && typeof to.face === 'number') {
if (this.onPortal) this.onPortal(e);
break;
}
// 原有逻辑(同关内传送 / 回出生点 / 绝对坐标 / pair 配对)
const tgt = to === 'spawn' ? L.spawn
: to && ('x' in to) ? { x: to.x * TILE, y: to.y * TILE }
: (() => {
const p = L.entities.find(o => o.type === 'warp' && o.idx === e.pair);
return p ? { x: p.x + p.w / 2 - this.w / 2, y: p.y + p.h - this.h } : null;
})();
if (tgt) {
this.x = tgt.x; this.y = tgt.y;
this.vx = 0; this.vy = 0;
const partner = L.entities.find(o => o.type === 'warp' && o.idx === e.pair);
if (partner) partner.cool = 40;
sfx.warp();
L.particles.spawn(this.cx, this.cy, 12, { colors: ['#a86bff', '#fff'], speed: 2.5, life: 24 });
}
break;
}
case 'sign':
if (this.animT % 30 === 0) L.showToast(e.text, 90);
break;
}
}
}
draw(ctx) {
// 死亡中:kid 已爆开,不画本体(粒子/冲击环由 Level 层渲染,重生后仍能播完)
if (this.dead) return;
S.drawKid(ctx, this, this.animT);
}
}
-59
View File
@@ -1,59 +0,0 @@
// 本地存档:通关记录、最佳成绩、自制关卡
const KEY = 'iwbtc_fan_v1';
const blank = () => ({ cleared: {}, customs: [], audio: true });
export const Save = {
data: blank(),
load() {
try {
const s = localStorage.getItem(KEY);
if (s) this.data = Object.assign(blank(), JSON.parse(s));
} catch (e) { this.data = blank(); }
return this.data;
},
flush() {
try { localStorage.setItem(KEY, JSON.stringify(this.data)); } catch (e) { }
},
get(id) { return this.data.cleared[id] || null; },
isCleared(id) { return !!this.data.cleared[id]; },
/** 记录通关,返回是否刷新纪录 */
clear(id, deaths, ms, apples) {
const old = this.data.cleared[id];
const rec = { deaths, time: ms, apples: apples || 0, at: Date.now() };
let better = false;
if (!old) { this.data.cleared[id] = rec; better = true; }
else {
const nd = Math.min(old.deaths, deaths);
const nt = Math.min(old.time, ms);
better = deaths < old.deaths || ms < old.time;
this.data.cleared[id] = { ...old, deaths: nd, time: nt, apples: Math.max(old.apples || 0, apples || 0) };
}
this.flush();
return better;
},
wipe() { this.data = blank(); this.flush(); },
customs() { return this.data.customs || []; },
addCustom(lv) {
this.data.customs = this.data.customs || [];
this.data.customs.push(lv);
this.flush();
return lv;
},
/** 更新已保存的自制关卡(按 id 就地替换,不新增);id 不存在则追加 */
updateCustom(lv) {
this.data.customs = this.data.customs || [];
const i = this.data.customs.findIndex(l => l.id === lv.id);
if (i >= 0) this.data.customs[i] = lv;
else this.data.customs.push(lv);
this.flush();
return lv;
},
removeCustom(id) {
this.data.customs = (this.data.customs || []).filter(l => l.id !== id);
this.flush();
},
setAudio(on) { this.data.audio = on; this.flush(); },
};
-335
View File
@@ -1,335 +0,0 @@
// 设置:用户名 + 设备相关自定义
// · PC(电脑端)→ 键盘按键映射
// · 移动端 → 手机虚拟手柄按钮的位置/大小(拖动调整)
import { Save } from './save.js';
import { applyBindings, defaultBindings, BIND } from './input.js';
// 每个可自定义的手柄按钮:key 对应 touch.js 里的 data-touch,
// def 为默认布局(cx/cy 为相对舞台中心的百分比,size 为按钮边长 px)
const BUTTONS = [
{ key: 'left', label: '◀', shape: 'round', def: { cx: 12, cy: 80, size: 62 } },
{ key: 'right', label: '▶', shape: 'round', def: { cx: 24, cy: 80, size: 62 } },
{ key: 'jump', label: '跳', shape: 'jump', def: { cx: 88, cy: 84, size: 94 } },
{ key: 'shoot', label: '🔫', shape: 'item', label2: '射击', def: { cx: 79, cy: 72, size: 54 } },
{ key: 'down', label: '▼', shape: 'item', label2: '下蹲', def: { cx: 90, cy: 72, size: 54 } },
{ key: 'pause', label: '❚❚', shape: 'sm', def: { cx: 81, cy: 58, size: 42 } },
{ key: 'suicide', label: '↺', shape: 'sm', def: { cx: 90, cy: 58, size: 42 } },
];
// 设置界面里可重绑的键盘动作(标签 + action)
const KEY_ACTIONS = [
['left', '左移'], ['right', '右移'],
['up', '上(上浮)'],
['jump', '跳跃'], ['shoot', '射击'],
['suicide', '自杀 / 重生'], ['restart', '整关重来'],
['pause', '暂停'],
];
const clone = (o) => JSON.parse(JSON.stringify(o));
const DEFAULT_TOUCH = {};
BUTTONS.forEach(b => { DEFAULT_TOUCH[b.key] = clone(b.def); });
const makeDefaults = () => ({ username: '', touch: clone(DEFAULT_TOUCH), keys: defaultBindings() });
// 工作副本(未点「确定」前不落盘,点「返回」即丢弃)
let work = makeDefaults();
let wrap = null;
let pcMode = false; // true=电脑端(键盘映射),false=移动端(手柄按钮)
let listening = null; // 重绑监听:{act, idx}
let conflict = null; // 冲突警告:{code, label, act}
const els = {};
let keysBox = null; // 键盘映射容器
/** 读取/补全存档里的设置对象(保证 touch / keys 字段齐全) */
function meta() {
if (!Save.data.settings) Save.data.settings = {};
const s = Save.data.settings;
if (typeof s.username !== 'string') s.username = '';
if (!s.touch) s.touch = clone(DEFAULT_TOUCH);
if (!s.keys) s.keys = defaultBindings();
return s;
}
/** 把某个按钮按 p={cx,cy,size} 绝对定位(百分比定位 + 像素边长) */
function place(el, p) {
el.style.left = p.cx + '%';
el.style.top = p.cy + '%';
el.style.width = p.size + 'px';
el.style.height = p.size + 'px';
}
/** 把当前存档布局应用到真正的 #touch 手柄按钮(游戏内生效) */
function apply() {
const t = meta().touch;
for (const b of BUTTONS) {
const el = document.querySelector(`#touch [data-touch="${b.key}"]`);
if (!el) continue;
const p = t[b.key];
if (!p) continue;
el.style.left = p.cx + '%';
el.style.top = p.cy + '%';
el.style.width = p.size + 'px';
el.style.height = p.size + 'px';
}
}
/** 标题界面显示当前用户名 */
function showUsername() {
const el = document.getElementById('title-user');
if (el) el.textContent = '玩家:' + (meta().username || '无名氏');
}
/** 把键盘 code 转成可读标签 */
function keyLabel(code) {
const M = {
Space: '空格', ShiftLeft: 'Shift', ShiftRight: 'Shift', ControlLeft: 'Ctrl', ControlRight: 'Ctrl',
AltLeft: 'Alt', AltRight: 'Alt', Tab: 'Tab', Backspace: '⌫', Escape: 'Esc', Enter: 'Enter',
ArrowLeft: '←', ArrowRight: '→', ArrowUp: '↑', ArrowDown: '↓',
MetaLeft: 'Win', MetaRight: 'Win', ContextMenu: '菜单',
};
if (M[code]) return M[code];
if (code.startsWith('Key')) return code.slice(3);
if (code.startsWith('Digit')) return code.slice(5);
if (code.startsWith('Numpad')) return '小键' + code.slice(6);
if (/^F\d{1,2}$/.test(code)) return code;
return code;
}
/** 构建设置界面里的键盘映射行(点击按键后按新键重绑) */
function renderKeys() {
if (!keysBox) return;
keysBox.innerHTML = '';
// 冲突警告横幅(始终置于面板顶部,醒目)
if (conflict) {
const w = document.createElement('div');
w.className = 'kwarb';
w.textContent = `「${keyLabel(conflict.code)}」已被【${conflict.label}】占用,请换一个键(Esc 取消)`;
keysBox.appendChild(w);
}
for (const [act, label] of KEY_ACTIONS) {
const row = document.createElement('div');
row.className = 'krow';
const nm = document.createElement('span');
nm.className = 'kact'; nm.textContent = label;
const keys = document.createElement('div');
keys.className = 'kkeys';
(work.keys[act] || []).forEach((code, idx) => {
const chip = document.createElement('button');
chip.type = 'button';
const isListen = listening && listening.act === act && listening.idx === idx;
chip.className = 'kchip' + (isListen ? ' listen' : '');
chip.dataset.kact = act; chip.dataset.kidx = idx;
chip.textContent = keyLabel(code);
const rm = document.createElement('b');
rm.className = 'krm'; rm.dataset.krm = act; rm.dataset.kidx = idx; rm.textContent = '×';
chip.appendChild(rm);
keys.appendChild(chip);
});
const add = document.createElement('button');
add.type = 'button';
add.className = 'kadd'; add.dataset.kadd = act; add.textContent = '+ 添加';
keys.appendChild(add);
if (listening && listening.act === act) {
const tip = document.createElement('span');
const isClash = conflict && conflict.act === act;
tip.className = 'klisten' + (isClash ? ' bad' : '');
tip.textContent = isClash ? '该键冲突,请换一个键(Esc 取消)' : '按下一个键…(Esc 取消)';
keys.appendChild(tip);
}
row.appendChild(nm); row.appendChild(keys);
keysBox.appendChild(row);
}
}
/** 检测 code 是否已绑定到其它动作(返回冲突动作的标签,否则 null) */
function conflictInfo(code, act, idx) {
for (const [a, label] of KEY_ACTIONS) {
const arr = work.keys[a] || [];
for (let i = 0; i < arr.length; i++) {
if (arr[i] === code && !(a === act && i === idx)) return label;
}
}
// 不可重绑的系统动作也算冲突(如「菜单」用的 KeyQ)
if (BIND.menu && BIND.menu.includes(code)) return '菜单';
return null;
}
/** 重绑监听:捕获下一次按键并写入 work.keys(检测到冲突则拒绝并提示) */
function onCaptureKey(e) {
if (!listening) return;
e.preventDefault();
if (e.code === 'Escape') { listening = null; conflict = null; renderKeys(); return; }
const { act, idx } = listening;
const clash = conflictInfo(e.code, act, idx);
if (clash) { // 该键已被其它动作占用 → 拒绝并提示
conflict = { code: e.code, label: clash, act };
renderKeys();
return; // 停留在监听态,等待换键或 Esc
}
if (idx >= (work.keys[act] || []).length) (work.keys[act] = work.keys[act] || []).push(e.code);
else work.keys[act][idx] = e.code;
conflict = null;
listening = null;
renderKeys();
}
/** 给键盘映射容器绑定点击委托(重绑 / 添加 / 删除) */
let keysWired = false;
function wireKeys() {
if (!keysBox || keysWired) return;
keysWired = true;
keysBox.addEventListener('click', (e) => {
const rm = e.target.closest('.krm');
if (rm) {
const a = rm.dataset.krm, i = +rm.dataset.kidx;
work.keys[a].splice(i, 1);
if (listening && listening.act === a) listening = null;
renderKeys();
return;
}
const chip = e.target.closest('.kchip');
if (chip) { listening = { act: chip.dataset.kact, idx: +chip.dataset.kidx }; renderKeys(); return; }
const add = e.target.closest('.kadd');
if (add) { listening = { act: add.dataset.kadd, idx: 1e9 }; renderKeys(); return; }
});
}
export const Settings = {
/** isTouch:是否移动端(由 main.js 依据 ?touch 覆盖与设备检测传入) */
init(isTouch) {
pcMode = !isTouch;
wrap = document.getElementById('touch-preview');
keysBox = document.getElementById('set-keys');
work = clone(meta());
this.build();
renderKeys();
wireKeys();
showUsername();
apply();
applyBindings(meta().keys);
document.addEventListener('keydown', onCaptureKey);
},
/** 构建设置界面里的可拖拽手柄预览(克隆 #touch 的按钮形状) */
build() {
if (!wrap) return;
wrap.querySelectorAll('.tc-btn').forEach(n => n.remove());
for (const b of BUTTONS) {
const el = document.createElement('div');
el.className = 'tc-btn tc-' + b.shape;
el.innerHTML = `<span>${b.label}</span>` + (b.label2 ? `<span class="lbl">${b.label2}</span>` : '');
const h = document.createElement('div');
h.className = 'tc-rsz';
el.appendChild(h);
wrap.appendChild(el);
els[b.key] = el;
place(el, work.touch[b.key]);
wire(el, h, b.key);
}
},
/** 打开设置界面:从存档同步工作副本与输入框,并按设备切换面板 */
enter() {
const s = meta();
work = { username: s.username || '', touch: clone(s.touch), keys: clone(s.keys) };
const inp = document.getElementById('set-username');
if (inp) inp.value = work.username;
listening = null;
conflict = null;
this.build();
renderKeys();
// 按设备显示对应面板:PC→键盘映射;移动端→手柄按钮
const keysRow = document.getElementById('set-keys-row');
const touchRow = document.getElementById('set-touch-row');
if (keysRow) keysRow.classList.toggle('hidden', !pcMode);
if (touchRow) touchRow.classList.toggle('hidden', pcMode);
},
/** 确定:保存用户名 / 手柄布局 / 键位到存档,并应用,然后由调用方返回上一页 */
confirm() {
const s = meta();
const inp = document.getElementById('set-username');
work.username = (inp ? inp.value : '').trim().slice(0, 16);
s.username = work.username;
s.touch = clone(work.touch);
s.keys = clone(work.keys);
Save.flush();
showUsername();
apply();
applyBindings(s.keys);
},
/** 恢复默认:用户名/手柄布局/键位全部回到出厂值(仅影响工作副本,需点确定才落盘) */
reset() {
work = makeDefaults();
listening = null;
conflict = null;
const inp = document.getElementById('set-username');
if (inp) inp.value = '';
this.build();
renderKeys();
},
/** 供调试 / 测试使用 */
_state() { return work; },
_els() { return els; },
_pcMode() { return pcMode; },
_conflict() { return conflict; },
_listening() { return listening; },
};
/** 给单个预览按钮绑定「拖动移动」与「右下角缩放」 */
function wire(el, handle, key) {
let mode = null; // 'move' | 'resize'
let rect = null; // 预览区矩形(按下时记录)
let lastX = 0, lastY = 0; // 上一帧指针(相对位移用)
let startSize = 0, startDist = 1, cx0 = 0, cy0 = 0; // 缩放用:按下时按钮中心与指针距离
// 按下:只记录基准,不重算绝对坐标 —— 避免任何「按下即跳角」
const begin = (e, m) => {
e.preventDefault();
try { (m === 'resize' ? handle : el).setPointerCapture(e.pointerId); } catch (_) { }
mode = m;
rect = wrap.getBoundingClientRect();
const W = rect.width || 1, H = rect.height || 1;
const p = work.touch[key];
lastX = e.clientX; lastY = e.clientY;
if (m === 'resize') {
cx0 = rect.left + p.cx / 100 * W;
cy0 = rect.top + p.cy / 100 * H;
startSize = p.size;
startDist = Math.max(1, Math.hypot(e.clientX - cx0, e.clientY - cy0));
}
};
// 移动:用「相对上一帧的位移」累加,第一帧必然停在正确位置
const drag = (e) => {
if (!mode || !rect) return;
const p = work.touch[key];
if (mode === 'move') {
const W = rect.width || 1, H = rect.height || 1;
const dx = (e.clientX - lastX) / W * 100; // 像素位移 → 百分比
const dy = (e.clientY - lastY) / H * 100;
lastX = e.clientX; lastY = e.clientY;
p.cx = Math.max(3, Math.min(97, p.cx + dx));
p.cy = Math.max(3, Math.min(97, p.cy + dy));
} else {
const d = Math.max(1, Math.hypot(e.clientX - cx0, e.clientY - cy0));
p.size = Math.max(24, Math.min(160, startSize * (d / startDist)));
}
place(el, p);
};
const end = (e) => {
if (mode) { try { (mode === 'resize' ? handle : el).releasePointerCapture(e.pointerId); } catch (_) { } }
mode = null;
};
el.addEventListener('pointerdown', (e) => { if (e.target === handle) return; begin(e, 'move'); });
handle.addEventListener('pointerdown', (e) => { e.stopPropagation(); begin(e, 'resize'); });
el.addEventListener('pointermove', drag);
handle.addEventListener('pointermove', drag);
el.addEventListener('pointerup', end);
handle.addEventListener('pointerup', end);
el.addEventListener('pointercancel', end);
handle.addEventListener('pointercancel', end);
}
-364
View File
@@ -1,364 +0,0 @@
// 程序化像素绘制(无外部图片资源)
import { TILE, shade, hashNoise } from './util.js';
const C = {
stone: '#6b6b7e', stoneDark: '#3a3a48', stoneLite: '#8f8fa6',
grass: '#4f9e46', grassDark: '#2c5c2b',
brick: '#a8543c', brickDark: '#632a1d',
metal: '#5a6478', metalDark: '#333a4a',
ice: '#8fd8f0', iceDark: '#4a90b8',
water: '#2a6fd8',
spike: '#cfdde9', spikeDark: '#a8bccf',
kid: '#d94a55', kidDark: '#8e2530', skin: '#f5cfa8', hair: '#5a3a22', pant: '#2c3a7a',
save: '#b06bff', goal: '#ffc63a', gun: '#b8b8c8', apple: '#e03050',
};
/** 归一化的像素块绘制助手 */
function px(ctx, x, y, w, h, col) { ctx.fillStyle = col; ctx.fillRect(x | 0, y | 0, w | 0, h | 0); }
// ---------------- 地形 ----------------
export function drawBlock(ctx, x, y, variant = 0, lit = false) {
const s = TILE;
let base = C.stone, dark = C.stoneDark, lite = C.stoneLite;
if (variant === 1) { base = C.grass; dark = C.grassDark; lite = '#7fd06a'; }
if (variant === 2) { base = C.brick; dark = C.brickDark; lite = '#d1785c'; }
if (variant === 3) { base = C.metal; dark = C.metalDark; lite = '#8a94ad'; }
px(ctx, x, y, s, s, dark);
px(ctx, x + 1, y + 1, s - 2, s - 2, base);
// 纹理
if (variant === 2) { // 砖
px(ctx, x + 1, y + 8, s - 2, 1, dark);
px(ctx, x + 1, y + 17, s - 2, 1, dark);
px(ctx, x + 10, y + 1, 1, 7, dark);
px(ctx, x + 20, y + 9, 1, 8, dark);
px(ctx, x + 6, y + 18, 1, 7, dark);
} else if (variant === 1) { // 草
px(ctx, x + 1, y + 1, s - 2, 5, lite);
for (let i = 0; i < 4; i++) px(ctx, x + 3 + i * 7, y + 2, 1, 4, dark);
} else { // 石
const n = hashNoise(x, y);
px(ctx, x + 2, y + 2, 6, 2, lite);
if (n > 0.5) px(ctx, x + 18, y + 12, 8, 2, dark);
if (n < 0.35) px(ctx, x + 6, y + 22, 7, 2, dark);
}
// 高光/描边
px(ctx, x, y, s, 1, lite);
px(ctx, x, y, 1, s, lite);
px(ctx, x, y + s - 1, s, 1, dark);
px(ctx, x + s - 1, y, 1, s, dark);
if (lit) { ctx.fillStyle = 'rgba(255,255,255,.09)'; ctx.fillRect(x, y, s, s); }
}
export function drawIce(ctx, x, y) {
const s = TILE;
px(ctx, x, y, s, s, C.iceDark);
px(ctx, x + 1, y + 1, s - 2, s - 2, C.ice);
px(ctx, x + 2, y + 3, 10, 3, '#e6fbff');
px(ctx, x + 18, y + 16, 8, 2, 'rgba(255,255,255,.7)');
px(ctx, x, y, s, 1, '#e6fbff');
px(ctx, x, y + s - 1, s, 1, C.iceDark);
}
export function drawWater(ctx, x, y, t) {
const s = TILE;
px(ctx, x, y, s, s, 'rgba(40,110,215,.55)');
ctx.fillStyle = 'rgba(120,200,255,.35)';
const off = Math.sin(t / 260 + x * 0.09) * 3;
ctx.fillRect(x, y + 4 + off, s, 3);
ctx.fillRect(x, y + 16 - off, s, 2);
}
export function drawOneWay(ctx, x, y) {
const s = TILE;
px(ctx, x, y, s, 6, C.metalDark);
px(ctx, x, y, s, 3, '#9aa6bd');
for (let i = 0; i < 8; i++) px(ctx, x + i * 4, y + 6, 2, 3, C.metalDark);
}
// 单个三角形刺:尖端指向 dir,底边贴在相反一侧(与 level.js 的 spikeTri 顶点完全一致)。
// 外观对齐经典 I wanna 素材:冰蓝白渐变的干净大三角,占满整格。
export function drawSpike(ctx, x, y, dir, t) {
const s = TILE;
let ax, ay, bx, by, cx, cy;
if (dir === 0) { // 朝上(地面上)
ax = x + s / 2; ay = y; bx = x; by = y + s; cx = x + s; cy = y + s;
} else if (dir === 1) { // 朝下(天花板)
ax = x + s / 2; ay = y + s; bx = x; by = y; cx = x + s; cy = y;
} else if (dir === 2) { // 朝左(贴右墙)
ax = x; ay = y + s / 2; bx = x + s; by = y; cx = x + s; cy = y + s;
} else { // 朝右(贴左墙)
ax = x + s; ay = y + s / 2; bx = x; by = y; cx = x; cy = y + s;
}
// 冰蓝白渐变:尖端最亮,往底边过渡到淡蓝
let g;
if (dir === 0) { g = ctx.createLinearGradient(x, y, x, y + s); }
else if (dir === 1) { g = ctx.createLinearGradient(x, y + s, x, y); }
else if (dir === 2) { g = ctx.createLinearGradient(x, y, x + s, y); }
else { g = ctx.createLinearGradient(x + s, y, x, y); }
g.addColorStop(0, '#f4f9fd');
g.addColorStop(0.55, '#cfdde9');
g.addColorStop(1, '#a8bccf');
ctx.beginPath();
ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.lineTo(cx, cy); ctx.closePath();
ctx.fillStyle = g; ctx.fill();
// 淡蓝描边(参考图的柔和轮廓)
ctx.strokeStyle = 'rgba(150,175,200,.9)'; ctx.lineWidth = 1; ctx.stroke();
}
/** 全致死方块(美味水果外观) */
export function drawKiller(ctx, x, y, t) {
const s = TILE;
const bob = Math.sin(t / 300 + x) * 1.5;
ctx.save();
ctx.translate(x + s / 2, y + s / 2 + bob);
// 苹果
ctx.fillStyle = C.apple;
ctx.beginPath(); ctx.arc(0, 2, 11, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#ff7a90';
ctx.beginPath(); ctx.arc(-4, -2, 4, 0, Math.PI * 2); ctx.fill();
px(ctx, -1, -13, 2, 6, '#6b4423');
ctx.fillStyle = '#3ddc84';
ctx.beginPath(); ctx.ellipse(6, -11, 5, 2.6, -0.5, 0, Math.PI * 2); ctx.fill();
// 恶作剧的眼睛
px(ctx, -5, 0, 2, 2, '#000'); px(ctx, 3, 0, 2, 2, '#000');
ctx.restore();
}
export function drawVanish(ctx, x, y, state, t) {
const s = TILE;
const alpha = state === 0 ? 1 : state === 1 ? (Math.sin(t / 60) > 0 ? 0.55 : 0.9) : 0.14;
ctx.globalAlpha = alpha;
drawBlock(ctx, x, y, 3);
ctx.globalAlpha = 1;
if (state !== 2) {
ctx.strokeStyle = 'rgba(255,200,60,.55)'; ctx.lineWidth = 1;
ctx.strokeRect(x + 2.5, y + 2.5, s - 5, s - 5);
}
}
export function drawJumpRef(ctx, x, y, t) {
const s = TILE;
const b = Math.sin(t / 200 + x) * 2;
ctx.fillStyle = '#3ddc84';
ctx.beginPath();
ctx.moveTo(x + s / 2, y + 8 + b); ctx.lineTo(x + s - 6, y + s - 10 + b); ctx.lineTo(x + 6, y + s - 10 + b);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = '#0d5c30'; ctx.lineWidth = 1; ctx.stroke();
}
// ---------------- 玩家 ----------------
export function drawKid(ctx, p, t) {
const x = Math.round(p.x), y = Math.round(p.y);
const w = p.w, h = p.h;
const air = !p.grounded;
// 不做任何 squash/stretch 形变:起跳与落地都保持原始体型,
// 避免身体忽高忽低被感知为"上下晃动"(2026-10-23 用户两次反馈后彻底移除)
ctx.save();
ctx.translate(x, y);
const face = p.facing; // 1 右 -1 左
// 身体(红衣)
px(ctx, 2, 7, w - 4, 7, C.kid);
px(ctx, 2, 7, w - 4, 1, shade(C.kid, 40));
// 手臂
if (air) { px(ctx, face > 0 ? w - 3 : 0, 5, 3, 5, C.skin); }
else { px(ctx, face > 0 ? w - 3 : 0, 9, 3, 5, C.skin); }
// 腿
if (air) { px(ctx, 3, 14, 4, 4, C.pant); px(ctx, w - 7, 14, 4, 3, C.pant); }
else { px(ctx, 2, 14, 4, 4, C.pant); px(ctx, w - 6, 14, 4, 4, C.pant); }
// 头
px(ctx, 1, 0, w - 2, 8, C.skin);
px(ctx, 1, 0, w - 2, 3, C.hair); // 头发
px(ctx, face > 0 ? w - 5 : 3, 1, 3, 2, C.hair);
// 眼睛
px(ctx, face > 0 ? w - 6 : 3, 4, 2, 2, '#101018');
px(ctx, face > 0 ? w - 3 : 0, 4, 1, 2, '#101018');
ctx.restore();
// 持枪
if (p.hasGun && !p.dead) {
const gx = face > 0 ? x + w - 1 : x - 7;
const gy = y + 9;
px(ctx, gx, gy, 8, 3, C.gun);
px(ctx, face > 0 ? gx + 6 : gx, gy + 1, 3, 2, '#7a7a8c');
px(ctx, gx + 1, gy + 3, 3, 3, '#6a6a7c');
}
}
// ---------------- 实体 ----------------
export function drawSave(ctx, e, t) {
const cx = e.x + e.w / 2, cy = e.y + e.h / 2;
const on = e.active;
const pulse = 1 + Math.sin(t / 180) * 0.09;
if (on) {
ctx.globalAlpha = 0.28;
ctx.fillStyle = C.save;
ctx.beginPath(); ctx.arc(cx, cy, 15 * pulse, 0, Math.PI * 2); ctx.fill();
ctx.globalAlpha = 1;
} else {
// 未激活:画一圈呼吸光环,别让玩家看不见它
const r = 13 + Math.sin(t / 26) * 2.2;
ctx.globalAlpha = 0.30 + Math.sin(t / 26) * 0.12;
ctx.strokeStyle = '#8a6ab8';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
ctx.globalAlpha = 1;
}
const col = on ? C.save : '#8f74b5';
ctx.fillStyle = col;
ctx.beginPath(); ctx.arc(cx - 3, cy - 1, 4.2, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(cx + 4, cy + 1, 4.2, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(cx, cy + 5, 4.2, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(cx + 1, cy - 5, 4.2, 0, Math.PI * 2); ctx.fill();
if (on) { ctx.fillStyle = '#e5d0ff'; ctx.beginPath(); ctx.arc(cx - 1, cy - 2, 1.6, 0, Math.PI * 2); ctx.fill(); }
// 梗
px(ctx, cx + 1, cy - 9, 2, 4, '#4a8a3a');
}
export function drawGoal(ctx, e, t) {
const x = e.x, y = e.y, w = e.w, h = e.h;
// 门
px(ctx, x + 3, y + 2, w - 6, h - 2, '#2a1e3a');
px(ctx, x + 5, y + 4, w - 10, h - 6, '#140d20');
// 光柱
const g = ctx.createLinearGradient(x, y + h, x, y);
g.addColorStop(0, 'rgba(255,200,60,.55)');
g.addColorStop(1, 'rgba(255,200,60,0)');
ctx.fillStyle = g;
ctx.fillRect(x + 5, y + 4, w - 10, h - 6);
// 闪烁粒子
for (let i = 0; i < 3; i++) {
const p = ((t / 700) + i / 3) % 1;
px(ctx, x + 7 + ((i * 9) % (w - 12)), y + h - 4 - p * (h - 8), 2, 2, C.goal);
}
// 门框
px(ctx, x + 3, y + 2, w - 6, 2, C.goal);
px(ctx, x + 3, y + 2, 2, h - 2, '#c99a1e');
px(ctx, x + w - 5, y + 2, 2, h - 2, '#c99a1e');
}
export function drawGun(ctx, e, t) {
const x = e.x, y = e.y + Math.sin(t / 250) * 2;
px(ctx, x + 2, y + 6, 20, 7, '#4a4a5c'); // 枪身
px(ctx, x + 2, y + 6, 20, 2, '#8a8aa0');
px(ctx, x + 18, y + 8, 9, 3, C.gun); // 枪管
px(ctx, x + 6, y + 13, 5, 8, '#3a3a4c'); // 握把
px(ctx, x + 8, y + 19, 3, 3, '#2a2a3a');
px(ctx, x + 24, y + 7, 2, 5, '#d0d0e0');
}
export function drawApple(ctx, e, t) {
const cx = e.x + e.w / 2, cy = e.y + e.h / 2 + Math.sin(t / 320 + e.x) * 1.5;
ctx.fillStyle = C.apple;
ctx.beginPath(); ctx.arc(cx, cy, 8, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#ff8095';
ctx.beginPath(); ctx.arc(cx - 3, cy - 3, 3, 0, Math.PI * 2); ctx.fill();
px(ctx, cx - 1, cy - 11, 2, 4, '#6b4423');
ctx.fillStyle = '#3ddc84';
ctx.beginPath(); ctx.ellipse(cx + 4, cy - 9, 4, 2, -0.5, 0, Math.PI * 2); ctx.fill();
}
export function drawWarp(ctx, e, t) {
const cx = e.x + e.w / 2, cy = e.y + e.h / 2;
for (let i = 0; i < 3; i++) {
const r = 14 - i * 4;
const a = t / (300 + i * 120);
ctx.strokeStyle = `rgba(${170 - i * 30},${90 + i * 40},255,${0.85 - i * 0.2})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx, cy, r, a, a + Math.PI * 1.4);
ctx.stroke();
}
ctx.fillStyle = 'rgba(120,60,200,.35)';
ctx.beginPath(); ctx.arc(cx, cy, 5, 0, Math.PI * 2); ctx.fill();
}
/** 摄像头对象(镜头控制区标记) */
export function drawCamera(ctx, e, t) {
const x = e.x, y = e.y, w = e.w, h = e.h;
ctx.strokeStyle = `rgba(80,220,255,${0.35 + Math.sin(t / 300) * 0.15})`;
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.strokeRect(x + 1, y + 1, w - 2, h - 2);
ctx.setLineDash([]);
// 镜头图标(放中央上方)
const ix = x + w / 2 - 9, iy = y + 6;
px(ctx, ix, iy + 3, 18, 11, '#1e1e2a');
px(ctx, ix + 2, iy + 5, 14, 7, '#3a3a4c');
ctx.fillStyle = '#7ae0ff';
ctx.beginPath(); ctx.arc(ix + 9, iy + 8.5, 3.5, 0, Math.PI * 2); ctx.fill();
px(ctx, ix + 12, iy, 5, 3, '#1e1e2a');
px(ctx, ix + 13, iy - 1, 3, 2, '#ff4060');
}
export function drawSign(ctx, e, t) {
const x = e.x, y = e.y, w = e.w, h = e.h;
px(ctx, x + w / 2 - 1, y + h - 8, 3, 8, '#6b4423');
px(ctx, x, y + 2, w, h - 10, '#c9a86b');
px(ctx, x + 2, y + 4, w - 4, h - 14, '#e8d5a8');
px(ctx, x + 4, y + 8, w - 8, 2, '#8a7550');
px(ctx, x + 4, y + 13, w - 12, 2, '#8a7550');
}
export function drawTurret(ctx, e, t) {
const x = e.x, y = e.y, s = TILE;
px(ctx, x + 2, y + 6, s - 4, s - 6, '#4a4a5e');
px(ctx, x + 4, y + 8, s - 8, s - 12, '#6a6a80');
const dirx = e.dir === 'left' ? -1 : e.dir === 'right' ? 1 : 0;
const diry = e.dir === 'up' ? -1 : e.dir === 'down' ? 1 : 0;
const cx = x + s / 2, cy = y + s / 2;
px(ctx, cx + dirx * 8 - 3, cy + diry * 8 - 3, 7, 7, '#2a2a38');
const blink = (Math.floor(t / 300) % 3) === 0;
px(ctx, cx + dirx * 12 - 2, cy + diry * 12 - 2, 4, 4, blink ? '#ff4060' : '#8a2030');
}
export function drawMovingPlatform(ctx, e) {
const x = e.x, y = e.y, w = e.w, h = e.h;
px(ctx, x, y, w, h, '#3a3a4c');
px(ctx, x + 1, y + 1, w - 2, h - 2, '#6a6a84');
px(ctx, x + 1, y + 1, w - 2, 2, '#9a9ab4');
for (let i = 0; i < w; i += 8) px(ctx, x + i + 2, y + h - 4, 4, 2, '#3a3a4c');
// 轨道虚线
ctx.strokeStyle = 'rgba(255,255,255,.10)';
ctx.setLineDash([3, 5]);
ctx.beginPath();
ctx.moveTo(e.ax + e.aw / 2, e.ay + e.ah / 2);
ctx.lineTo(e.bx + e.aw / 2, e.by + e.ah / 2);
ctx.stroke();
ctx.setLineDash([]);
}
/** 掉落刺(坑向:玩家靠近触发) */
export function drawDropSpike(ctx, e, t) {
const s = TILE;
if (!e.falling) {
ctx.globalAlpha = 0.85;
px(ctx, e.x + 4, e.y + 4, s - 8, s - 8, '#4a4a5c');
px(ctx, e.x + 6, e.y + 6, s - 12, s - 12, '#6a6a80');
ctx.globalAlpha = 1;
} else {
drawSpike(ctx, e.x, e.y, 2, t);
}
}
export function drawBullet(ctx, b) {
px(ctx, b.x - 1, b.y - 1, b.w + 2, b.h + 2, 'rgba(255,240,120,.35)');
px(ctx, b.x, b.y, b.w, b.h, '#fff6a0');
px(ctx, b.x, b.y + b.h - 2, b.w, 2, '#ffb040');
}
export function drawEBullet(ctx, b, t) {
const cx = b.x + b.w / 2, cy = b.y + b.h / 2;
ctx.fillStyle = b.col || '#ff5c7a';
ctx.beginPath(); ctx.arc(cx, cy, b.w / 2 + 1, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(cx - 1.5, cy - 1.5, b.w / 4, 0, Math.PI * 2); ctx.fill();
if (b.big) {
ctx.strokeStyle = 'rgba(255,255,255,.4)';
ctx.beginPath(); ctx.arc(cx, cy, b.w / 2 + 4 + Math.sin(t / 90) * 2, 0, Math.PI * 2); ctx.stroke();
}
}
export { C, px };
-98
View File
@@ -1,98 +0,0 @@
// 移动端虚拟手柄:左手方向、右手跳跃 + 道具(参考 IWBT Creator 的触屏布局)
// 左下方:← → 移动;右下方:大跳键 + 射击/下蹲 道具键 + 暂停/重生
import { setTouch, clearTouch } from './input.js';
import { Settings } from './settings.js';
// 持续按住型动作(通过 action 状态机驱动)
const HELD = ['left', 'right', 'jump', 'down'];
const api = {
pause() {},
suicide() {},
shoot() {},
};
let visible = false;
let root = null;
function q(sel) { return document.querySelector(sel); }
function bind(btn) {
const action = btn.dataset.touch;
if (!action) return;
let timer = null;
const press = (e) => {
e.preventDefault();
if (btn._pid != null) return; // 已在按住(多指防重入)
btn._pid = e.pointerId;
try { btn.setPointerCapture(e.pointerId); } catch (_) {}
btn.classList.add('on');
if (action === 'shoot') {
// 长按连发(受玩家冷却与是否持枪限制)
api.shoot();
timer = setInterval(() => api.shoot(), 140);
} else if (HELD.includes(action)) {
setTouch(action, true);
} else if (action === 'pause') {
api.pause();
} else if (action === 'suicide') {
api.suicide();
}
};
const release = (e) => {
if (btn._pid != null && e.pointerId !== btn._pid) return;
btn._pid = null;
try { btn.releasePointerCapture(e.pointerId); } catch (_) {}
btn.classList.remove('on');
if (action === 'shoot') {
if (timer) { clearInterval(timer); timer = null; }
} else if (HELD.includes(action)) {
setTouch(action, false);
}
};
btn.addEventListener('pointerdown', press);
btn.addEventListener('pointerup', release);
btn.addEventListener('pointercancel', release);
btn.addEventListener('lostpointercapture', release);
btn.addEventListener('contextmenu', (e) => e.preventDefault());
}
export const Touch = {
init(handlers) {
Object.assign(api, handlers || {});
root = q('#touch');
root.querySelectorAll('[data-touch]').forEach(bind);
// 游戏进行中禁止页面滚动/回弹
const stage = q('#stage');
if (stage) {
stage.addEventListener('touchmove', (e) => { if (visible) e.preventDefault(); }, { passive: false });
stage.addEventListener('contextmenu', (e) => { if (visible) e.preventDefault(); });
}
},
show() {
if (!root) root = q('#touch');
if (!root) return;
visible = true;
Settings.apply(); // 应用用户自定义的手柄布局
root.classList.remove('hidden');
},
hide() {
if (!root) root = q('#touch');
if (!root) return;
visible = false;
root.classList.add('hidden');
clearTouch(); // 防止按键卡住
},
/** 反映是否持有手枪:无枪时射击键置灰 */
setGun(has) {
const g = q('#tc-gun');
if (g) g.classList.toggle('off', !has);
},
};
-174
View File
@@ -1,174 +0,0 @@
// DOM 界面:菜单 / 关卡选择 / HUD / 结算
import { LEVELS, TYPE_LABEL, TYPE_CLASS } from './levels/index.js';
import { Save } from './save.js';
import { fmtTime } from './util.js';
const $ = s => document.querySelector(s);
const $$ = s => Array.from(document.querySelectorAll(s));
const SCREENS = ['scr-title', 'scr-levels', 'scr-settings', 'scr-pause', 'scr-result', 'scr-editor'];
const TABS = [
['all', '全部'], ['jump', '跳刺'], ['endur', '耐久'], ['boss', 'BOSS'], ['troll', '坑向'], ['custom', '自制'],
];
export const UI = {
H: {}, // handlers
filter: 'all',
manage: false, // 自制页「管理」态:开启后每张自制地图显示删除按钮
cur: 'scr-title',
toastTimer: 0,
init(handlers) {
this.H = handlers;
// 按钮
$$('[data-act]').forEach(btn => {
btn.addEventListener('click', () => {
const a = btn.dataset.act;
if (this.H[a]) this.H[a]();
});
});
// 分类 tabs
const tabs = $('#level-tabs');
tabs.innerHTML = '';
TABS.forEach(([k, label]) => {
const b = document.createElement('button');
b.className = 'tab' + (k === 'all' ? ' on' : '');
b.textContent = label;
b.onclick = () => {
this.filter = k;
this.setManage(false); // 切换分类时退出管理模式
$$('#level-tabs .tab').forEach(t => t.classList.remove('on'));
b.classList.add('on');
this.renderLevels();
};
tabs.appendChild(b);
});
},
show(name) {
SCREENS.forEach(s => $('#' + s).classList.toggle('hidden', s !== name));
this.cur = name;
$('#hud').classList.toggle('hidden', name !== null && name !== '');
if (name === 'scr-levels') { this.setManage(false); this.renderLevels(); }
},
showGame() {
SCREENS.forEach(s => $('#' + s).classList.add('hidden'));
this.cur = '';
$('#hud').classList.remove('hidden');
},
renderLevels() {
const grid = $('#level-grid');
grid.innerHTML = '';
const customs = Save.customs().map((l, i) => ({ ...l, index: 100 + i, no: i + 1 }));
const all = [...LEVELS, ...customs];
const list = all.filter(l => this.filter === 'all' ? true : l.type === this.filter);
// 「管理地图」按钮只在自制分类下显示
this.updateManageBtn();
if (!list.length) {
grid.innerHTML = '<div style="color:#666;padding:20px">这个分类下还没有关卡。去「自制关卡」造一个吧。</div>';
return;
}
list.forEach(l => {
const rec = Save.get(l.id);
const card = document.createElement('div');
card.className = 'lv-card' + (rec ? ' cleared' : '') + (this.manage && l.builtin === false ? ' manage' : '');
const stars = '★'.repeat(l.diff || 1) + '☆'.repeat(Math.max(0, 5 - (l.diff || 1)));
card.innerHTML = `
<div class="idx">${l.builtin === false ? 'U' + (l.no || '') : String(l.index + 1).padStart(2, '0')}</div>
<div class="nm">${escapeHtml(l.name || 'STAGE')}</div>
<div class="meta">
<span class="tag ${TYPE_CLASS[l.type] || 't-custom'}">${TYPE_LABEL[l.type] || '自制'}</span>
<span style="color:#ffc63a">${stars}</span>
</div>
<div class="st">${rec ? `✔ 死亡 ${rec.deaths} · ${fmtTime(rec.time)}` : '未通关'}</div>
${rec ? '<div class="chk">✔</div>' : ''}
`;
// 管理态下自制卡不再可进入,只保留删除按钮,避免误点开始
if (!(this.manage && l.builtin === false)) card.onclick = () => this.H.play(l);
// 管理态:自制地图显示删除按钮
if (l.builtin === false && this.manage) {
const del = document.createElement('button');
del.className = 'lv-del';
del.textContent = '✕';
del.title = '删除这张地图';
del.onclick = (e) => { e.stopPropagation(); this.delCustom(l); };
card.appendChild(del);
}
grid.appendChild(card);
});
},
/** 刷新「管理地图」按钮:仅在自制分类可见;管理态文案变「完成」并高亮 */
updateManageBtn() {
const mb = $('#manage-btn');
if (!mb) return;
mb.classList.toggle('hidden', this.filter !== 'custom');
mb.textContent = this.manage ? '完成' : '管理地图';
mb.classList.toggle('on', this.manage);
},
/** 切换自制页的管理态(显示/隐藏删除按钮) */
setManage(on) {
// 仅自制分类可进入管理态;非自制分类的开启请求按退出处理(按钮不可见,须自动复位)
if (on && this.filter !== 'custom') on = false;
this.manage = !!on;
this.updateManageBtn();
this.renderLevels();
},
/** 删除一张自制地图(从关卡选择界面管理) */
delCustom(l) {
if (typeof confirm === 'function' && !confirm('删除地图「' + (l.name || 'MY MAP') + '」?此操作不可恢复')) return;
Save.removeCustom(l.id);
this.renderLevels();
},
hud({ name, deaths, ms, apples, totalApples, hasGun, save, totalSaves }) {
$('#hud-level').textContent = name || '';
$('#hud-deaths').textContent = 'DEATH ' + deaths;
$('#hud-time').textContent = fmtTime(ms);
const ap = $('#hud-apples');
if (totalApples > 0) { ap.style.display = ''; ap.textContent = `苹果 ${apples}/${totalApples}`; }
else ap.style.display = 'none';
const sv = $('#hud-save');
if (totalSaves > 0) {
sv.style.display = '';
sv.textContent = save > 0 ? `存档 ${save}/${totalSaves}` : `存档 0/${totalSaves}`;
sv.classList.toggle('on', save > 0);
} else sv.style.display = 'none';
},
bossBar(boss) {
const bar = $('#boss-bar');
if (!boss) { bar.classList.add('hidden'); return; }
bar.classList.remove('hidden');
$('#boss-name').textContent = boss.name;
$('#boss-fill').style.width = Math.max(0, (boss.hp / boss.maxHp) * 100) + '%';
},
toast(text) {
const el = $('#toast');
if (!text) { el.classList.remove('show'); return; }
if (el.textContent !== text) el.textContent = text;
el.classList.add('show');
},
result({ name, ms, deaths, apples, totalApples, better, hasNext }) {
$('#r-name').textContent = name;
$('#r-time').textContent = fmtTime(ms);
$('#r-death').textContent = deaths;
$('#r-apple').textContent = totalApples ? `${apples}/${totalApples}` : '—';
$('#r-best').textContent = better ? '★ 新纪录!' : '';
$('#scr-result').querySelector('[data-act="next"]').style.display = hasNext ? '' : 'none';
this.show('scr-result');
},
};
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
-45
View File
@@ -1,45 +0,0 @@
// 通用工具
export const TILE = 32;
export const ROOM_W = 800;
export const ROOM_H = 608;
export const GRID_W = ROOM_W / TILE; // 25
export const GRID_H = ROOM_H / TILE; // 19
export const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
export const lerp = (a, b, t) => a + (b - a) * t;
export const sgn = v => (v < 0 ? -1 : v > 0 ? 1 : 0);
export const rand = (a, b) => a + Math.random() * (b - a);
export const randInt = (a, b) => Math.floor(a + Math.random() * (b - a + 1));
export const pick = arr => arr[Math.floor(Math.random() * arr.length)];
export function overlap(a, b) {
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
}
export function pointIn(px, py, r) {
return px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h;
}
/** 浅色/深色化 hex 颜色,n>0 变亮 n<0 变暗 */
export function shade(hex, n) {
const h = hex.replace('#', '');
const v = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16);
let r = (v >> 16) & 255, g = (v >> 8) & 255, b = v & 255;
r = clamp(r + n, 0, 255); g = clamp(g + n, 0, 255); b = clamp(b + n, 0, 255);
return `rgb(${r},${g},${b})`;
}
/** 时间毫秒 -> m:ss.cc */
export function fmtTime(ms) {
const t = Math.max(0, ms);
const m = Math.floor(t / 60000);
const s = Math.floor((t % 60000) / 1000);
const c = Math.floor((t % 1000) / 10);
return `${m}:${String(s).padStart(2, '0')}.${String(c).padStart(2, '0')}`;
}
/** 平滑的确定性伪随机(用于装饰,不影响帧同步) */
export function hashNoise(x, y) {
let h = x * 374761393 + y * 668265263;
h = (h ^ (h >> 13)) * 1274126177;
return ((h ^ (h >> 16)) >>> 0) / 4294967295;
}
-1
View File
@@ -1 +0,0 @@
{"levels":[{"id":"t1","name":"FIRST STEPS","type":"jump","diff":1,"bg":"jump","width":36,"hint":"← → 移动,Shift / Z / 空格跳跃。刺碰到就死,R 键重来。","messages":["← → 移动 Shift 跳跃 R 重来"],"grid":["####################################","#..................................#","#..................................#","#..................................#","#..................................#","#..................................#","#..................................#","#.............................X....#","#............................#####.#","#..................................#","#.........................#####....#","#..................................#","#.....................#####........#","#..................................#","#.................#####............#","#..................................#","#............#####.................#","#.S.T..^^^............G............#","####################################"],"index":0,"builtin":true},{"id":"t2","name":"DOUBLE JUMP","type":"jump","diff":1,"bg":"custom","width":40,"hint":"空中再按一次跳跃=二段跳。5 格宽的刺坑只能靠二段跳过去。","messages":["空中再按一次 Shift = 二段跳"],"grid":["########################################","#......................................#","#......................................#","#......................................#","#......................................#","#......................................#","#......................................#","#......................................#","#..................X...................#","#.................####.................#","#......................................#","#.........................####.........#","#......................................#","#.................####.................#","#......................................#","#.........................####.........#","#......................................#","#.................####.................#","#.S.T......^^^^^...G...................#","########################################"],"index":1,"builtin":true},{"id":"j1","name":"SPIKE ALLEY","cn":"刺巷","type":"jump","diff":2,"hint":"针刺入门:刺毯单跳节奏、钻石缝、矮洞撞头跳、竖井、对刺门。","width":104,"grid":["########################################################################################################","#......................................................................................................#","#......................................................................................................#","#......................................................................................................#","#......................................................................................................#","#......................................................................................................#","#................................................................................vvvvvvvvvvvvvvv.......#","#......................................................................................................#","#......................................................................................................#","#......................................................................................................#","#..............................................................................#.......................#","#..............................................................................#..G^.^^.^..>^<G^.......#","#.........................................................................#....###################.....#","#.........................................................................###..#.......................#","#.........................................................................#....#.......................#","#.........................................................................>....#...................>...#","#.........vvvvvvvvvvvvvvvvv.vvvvvvvvvvvvvvvv..............................#..###...................>...#","#.........................................................................#....<...................>..<#","#...........................................#############..........#......#....#...................>..<#","#..............v.....v........v...v...v......................#.....#.......##..#...................>..<#","#.........................................................#..#..#..#..#........#......................<#","#..S....G.^^^..^..^..^..^.^.G.^...^...^..^^..G.>.<..>^<...#..#..#..#..#.G......#....................X..#","#########################################################..............#################################","#........................................................^^^^^^^^^^^^^^................................#","#......................................................................................................#","########Line truncated
-108
View File
@@ -1,108 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>关卡地图预览 · IWBT C 同人</title>
<style>
:root { --bg:#0e1018; --panel:#171b26; --line:#2a3040; --fg:#dfe3ee; --dim:#8b93a7; }
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.6 ui-sans-serif,system-ui,"PingFang SC","Microsoft YaHei",sans-serif; }
header { padding:14px 18px; border-bottom:1px solid var(--line); display:flex; gap:14px; align-items:center; flex-wrap:wrap; }
h1 { font-size:15px; font-weight:500; margin:0 14px 0 0; }
select, button { background:var(--panel); color:var(--fg); border:1px solid var(--line); border-radius:6px; padding:5px 10px; font:inherit; }
.stat { color:var(--dim); font-size:13px; }
.stat b { color:var(--fg); font-weight:500; }
main { padding:14px 18px 30px; overflow:auto; }
canvas { display:block; background:var(--panel); border:1px solid var(--line); border-radius:6px; image-rendering:pixelated; }
.legend { margin-top:12px; display:flex; gap:18px; flex-wrap:wrap; color:var(--dim); font-size:13px; }
.legend i { display:inline-block; width:12px; height:12px; border-radius:2px; vertical-align:-2px; margin-right:5px; }
</style>
</head>
<body>
<header>
<h1>关卡地图预览</h1>
<select id="sel"></select>
<span class="stat" id="stat"></span>
</header>
<main><canvas id="cv"></canvas></main>
<div class="legend">
<span><i style="background:#3a4056"></i>实心块</span>
<span><i style="background:#5b7fa6"></i>冰面</span>
<span><i style="background:#6b5b8a"></i>单向台</span>
<span><i style="background:#2d5a7a"></i>水</span>
<span><i style="background:#e04b6a"></i>四向刺</span>
<span><i style="background:#8b3ad0"></i>即死块</span>
<span><i style="background:#3ad07a"></i>出生点</span>
<span><i style="background:#4bc8ff"></i>存档点</span>
<span><i style="background:#ffd24b"></i>终点 / 苹果</span>
<span><i style="background:#c48bff"></i>手枪·跳台·炮台·BOSS·掉落刺</span>
</div>
<script type="module">
import { LEVELS } from './js/levels/index.js';
const COLORS = {
'#': '#3a4056', '@': '#3a4056', '=': '#3a4056', 'M': '#4a4f63',
'%': '#5b7fa6', '-': '#6b5b8a', '~': '#2d5a7a', '*': '#8b3ad0', 'V': '#4a4f63',
'^': '#e04b6a', 'v': '#e04b6a', '<': '#e04b6a', '>': '#e04b6a',
S: '#3ad07a', G: '#4bc8ff', X: '#ffd24b', F: '#ffd24b',
P: '#c48bff', J: '#c48bff', O: '#c48bff', B: '#c48bff', D: '#c48bff',
W: '#c48bff', T: '#8b93a7', C: '#8b93a7', '!': '#8b93a7',
};
const sel = document.getElementById('sel');
const stat = document.getElementById('stat');
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
LEVELS.forEach((l, i) => {
const o = document.createElement('option');
o.value = i;
o.textContent = `${l.id} ${l.name} ${l.cn || ''}`;
sel.appendChild(o);
});
function draw(d) {
const rows = d.grid, h = rows.length, w = d.width;
const s = Math.max(4, Math.min(16, Math.floor(Math.min(1500 / w, 820 / h))));
cv.width = w * s; cv.height = h * s;
cv.style.width = cv.width + 'px';
cv.style.height = cv.height + 'px';
ctx.clearRect(0, 0, cv.width, cv.height);
let spike = 0;
for (let y = 0; y < h; y++) {
const line = rows[y] || '';
for (let x = 0; x < w; x++) {
const ch = line[x] || '.';
if (ch === '.') continue;
if ('^v<>'.includes(ch)) spike++;
const c = COLORS[ch];
if (!c) continue;
ctx.fillStyle = c;
if ('^v<>'.includes(ch)) { // 刺画成三角形,方向和判定一致
ctx.beginPath();
const px = x * s, py = y * s;
if (ch === '^') { ctx.moveTo(px + s / 2, py + 2); ctx.lineTo(px + s - 2, py + s - 1); ctx.lineTo(px + 2, py + s - 1); }
if (ch === 'v') { ctx.moveTo(px + 2, py + 1); ctx.lineTo(px + s - 2, py + 1); ctx.lineTo(px + s / 2, py + s - 2); }
if (ch === '<') { ctx.moveTo(px + 1, py + s / 2); ctx.lineTo(px + s - 1, py + 2); ctx.lineTo(px + s - 1, py + s - 2); }
if (ch === '>') { ctx.moveTo(px + 1, py + 2); ctx.lineTo(px + s - 1, py + 1); ctx.lineTo(px + 1, py + s - 2); }
ctx.closePath(); ctx.fill();
} else if ('SGXFPJODBW'.includes(ch)) {
ctx.beginPath();
ctx.arc(x * s + s / 2, y * s + s / 2, s * 0.3, 0, 7);
ctx.fill();
} else {
ctx.fillRect(x * s, y * s, s, s);
}
}
}
const total = w * h;
const saves = rows.join('').split('').filter(c => c === 'G').length;
stat.innerHTML = `<b>${d.name}</b> ${d.cn || ''} · ${w}×${h} 格 · 类型 ${d.type} · 难度 ${d.diff} · 刺 ${spike} 格(${(spike / total * 100).toFixed(1)}%)· 存档点 ${saves}`;
}
sel.addEventListener('change', () => draw(LEVELS[+sel.value]));
draw(LEVELS[0]);
</script>
</body>
</html>
-1
View File
@@ -1 +0,0 @@
{ "type": "module", "private": true }
-37
View File
@@ -1,37 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>刺外观预览(对齐参考图)</title>
<style>
body { background:#20242c; color:#dde; font-family:monospace; text-align:center; padding:16px; }
canvas { image-rendering:pixelated; border:1px solid #445; max-width:100%; }
p { font-size:13px; color:#99a; }
</style>
</head>
<body>
<h3>单三角形刺 · 冰蓝白渐变(游戏真实代码渲染)</h3>
<canvas id="cv" width="768" height="384"></canvas>
<p>上排:地面刺(朝上) · 中间:天花板刺(朝下) · 左右:贴墙刺(朝左/朝右)</p>
<script type="module">
import { drawSpike, drawBlock } from './js/sprites.js';
const TILE = 32;
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
// 地面与天花板(参照截图的格子地面)
for (let x = 0; x < 24; x++) {
drawBlock(ctx, x * TILE, 8 * TILE, 0); // 下半地面
drawBlock(ctx, x * TILE, 0, 0); // 顶部天花板
}
// 地面刺一排(朝上)
for (let x = 3; x <= 9; x++) drawSpike(ctx, x * TILE, 7 * TILE, 0, 0);
// 天花板刺(朝下)
for (let x = 13; x <= 17; x++) drawSpike(ctx, x * TILE, TILE, 1, 0);
// 贴墙刺:左墙(朝右)+ 右墙(朝左)
drawSpike(ctx, 0, 5 * TILE, 3, 0);
drawSpike(ctx, 23 * TILE, 5 * TILE, 2, 0);
// 中间孤刺
drawSpike(ctx, 11 * TILE, 7 * TILE, 0, 0);
</script>
</body>
</html>
-40
View File
@@ -1,40 +0,0 @@
// 机器人试玩:真实物理下向右推进,看存档点是否会被自然触发
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
globalThis.addEventListener = addL;
globalThis.window = { addEventListener: addL };
const key = (t, c) => (handlers[t] || []).forEach(fn => fn({ code: c, preventDefault() { }, repeat: false }));
const down = c => key('keydown', c);
const up = c => key('keyup', c);
const { LEVELS } = await import('../js/levels/index.js');
const { Level } = await import('../js/level.js');
const { Player } = await import('../js/player.js');
const { Input } = await import('../js/input.js');
const id = process.argv[2] || 'j1';
const d = LEVELS.find(l => l.id === id);
const L = new Level(d);
const P = new Player(L);
let deaths = 0;
P.onDeath = () => deaths++;
down('ArrowRight');
let jumpPhase = 0;
for (let i = 0; i < 3000; i++) {
// 简易 AI:遇到前方障碍/刺就跳,滞空一会儿再补二段跳
const ahead = L.solidRect(P.x + 3, P.y, P.w, P.h) || L.deadlyRect(P.x + 20, P.y + P.h - 6, 12, 8);
const gap = !L.solidRect(P.x + 6, P.y + P.h + 2, P.w, 4);
if (P.grounded && (ahead || gap)) { down('ShiftLeft'); jumpPhase = i; }
if (!P.grounded && i - jumpPhase === 12) { up('ShiftLeft'); down('ShiftLeft'); }
if (i - jumpPhase === 30) up('ShiftLeft');
L.update(1, P, null);
for (const b of L.bosses) b.update(1, P);
P.update(1);
Input.endFrame();
if (P.savePoint) break;
}
const saves = L.entities.filter(e => e.type === 'save');
console.log(`${id}: 存档点 ${saves.filter(s => s.active).length}/${saves.length} 被激活, x=${P.x.toFixed(0)}, 死亡=${deaths}`);
saves.forEach(s => console.log(` (${s.tx},${s.ty}) active=${s.active}`));
console.log(` savePoint=${P.savePoint ? `(${P.savePoint.x.toFixed(0)},${P.savePoint.y.toFixed(0)})` : 'null'} spawn=(${L.spawn.x},${L.spawn.y})`);
-55
View File
@@ -1,55 +0,0 @@
// 关卡数据格式校验
import tutorial from '../js/levels/tutorial.js';
import jump from '../js/levels/jump.js';
import endurance from '../js/levels/endurance.js';
import boss from '../js/levels/boss.js';
import troll from '../js/levels/troll.js';
const KNOWN = new Set([...'#@=M%~-*V^v<>SGFXPJWTOCBD! .']);
const ALL = [...tutorial, ...jump, ...endurance, ...boss, ...troll];
let bad = 0;
console.log('=== 关卡格式 ===');
for (const l of ALL) {
const w = l.width;
const h = l.grid.length;
const tag = `${l.id} ${l.name}`;
if (h < 19) { console.log(`! ${tag}: 高度 ${h} 行(建议 >=19,画布高 19 格)`); bad++; }
if (h > 60) { console.log(`! ${tag}: 高度 ${h} 行过大`); bad++; }
l.grid.forEach((row, y) => {
if (row.length !== w) { console.log(`! ${tag} row${y}: len=${row.length} 应为 ${w}`); bad++; }
for (const ch of row) if (!KNOWN.has(ch)) { console.log(`! ${tag} row${y}: 未知字符 '${ch}'`); bad++; }
});
const all = l.grid.join('');
const cnt = c => all.split(c).length - 1;
const s = cnt('S'), x = cnt('X'), b = cnt('B');
if (s !== 1) { console.log(`! ${tag}: S=${s}(必须且只能有一个出生点)`); bad++; }
if (l.type === 'boss') { if (b < 1) { console.log(`! ${tag}: BOSS关缺 B`); bad++; } }
else if (x < 1) { console.log(`! ${tag}: X=${x}(缺终点)`); bad++; }
if (l.turrets) {
const o = cnt('O');
if (o !== l.turrets.length) { console.log(`! ${tag}: O=${o} 但 turrets=${l.turrets.length}`); bad++; }
}
if (l.camzones) {
const c = cnt('C');
if (c > l.camzones.length) { console.log(`! ${tag}: C=${c} camzones=${l.camzones.length}`); bad++; }
}
if (l.warps) {
const ww = cnt('W');
if (ww !== l.warps.length) { console.log(`! ${tag}: W=${ww} 但 warps=${l.warps.length}`); bad++; }
}
if (l.bosses) {
if (b !== l.bosses.length) { console.log(`! ${tag}: B=${b} 但 bosses=${l.bosses.length}`); bad++; }
}
if (l.messages) {
const t = cnt('T');
if (t > l.messages.length) { console.log(`! ${tag}: T=${t} 但 messages=${l.messages.length}`); bad++; }
}
const g = cnt('G'), f = cnt('F');
console.log(` ${tag.padEnd(24)} ${String(w).padStart(3)}x${String(h).padStart(2)} S=${s} X=${x} G=${g} F=${f} B=${b} ${l.type}`);
}
console.log(`\n共 ${ALL.length} 关`);
console.log(bad ? `❌ 发现 ${bad} 个问题` : '✅ 全部关卡格式 OK');
process.exit(bad ? 1 : 0);
-272
View File
@@ -1,272 +0,0 @@
// 回归测试:死亡流程(2026-10-24 新语义,推翻旧的"即时复活、无动画"需求)
// 新需求(用户原话):
// ① "当角色死亡后不要立马重生,需要用户手动按重生键重生"
// ② "死亡时除了原先的红叉,还要附加一个小的死亡动画"
// 本测试覆盖:死亡定格等待手动重生 / 死亡动画要素(粒子+震动+冲击环圆心)/
// 手动 respawn 回存档点 / 连续死亡 / 红叉常驻与去重 / 掉坑死亡位置 clamp
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
globalThis.addEventListener = addL;
globalThis.window = { addEventListener: addL };
const key = (type, code) => (handlers[type] || []).forEach(fn => fn({ code, preventDefault() { }, repeat: false }));
const down = c => key('keydown', c);
const up = c => key('keyup', c);
const { Level } = await import('../js/level.js');
const { Player } = await import('../js/player.js');
const { Input } = await import('../js/input.js');
const W = 20, H = 8, TILE = 32;
// row1 有存档点 'G'(col1);row2 放两枚上刺(col5/col10);row3 实心地板
const grid = [
'#'.repeat(W),
'#' + 'G' + '.'.repeat(W - 3) + '#',
'#' + '.'.repeat(4) + '^' + '.'.repeat(4) + '^' + '.'.repeat(9) + '#',
'#'.repeat(W),
'#'.repeat(W),
'#'.repeat(W),
'#'.repeat(W),
'#'.repeat(W),
];
let fail = 0;
const err = m => { console.log(' ✗ ' + m); fail++; };
const ok = m => console.log(' ✓ ' + m);
const step = (L, P, n) => { for (let i = 0; i < n; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); } };
function fresh() {
const L = new Level({ id: 'dt', name: 'DT', type: 'jump', width: W, grid });
const P = new Player(L);
let deaths = 0;
P.onDeath = () => deaths++;
step(L, P, 12);
return { L, P, deaths: () => deaths };
}
// 1) 踩刺:当帧进入死亡状态并定格(不自动复活),动画要素齐全
{
const { L, P, deaths } = fresh();
const sx = L.spawn.x;
P.x = 5 * TILE; P.y = 2 * TILE + 11; // 直接压在上刺上
L.update(1, P, null); P.update(1); Input.endFrame();
if (P.alive || !P.dead) err('踩刺后没有进入死亡定格状态');
else ok('踩刺后当帧死亡并定格(等待手动重生)');
if (deaths() !== 1) err(`死亡计数应为 1,实际 ${deaths()}`);
else ok('死亡计数 = 1(正常累加)');
if (P.vx !== 0 || P.vy !== 0) err('死亡时速度未清零');
if (!(L.particles.list.length > 0)) err('死亡没有触发粒子爆发(小死亡动画缺失)');
else ok(`死亡动画:粒子爆发 ${L.particles.list.length} 个`);
if (!(L.shake > 0)) err('死亡没有触发屏幕轻震');
else ok(`死亡动画:屏幕轻震 shake=${L.shake.toFixed(1)}`);
if (Math.abs(P.deathX - (5 * TILE + 5.5)) > 12 || Math.abs(P.deathY - (2 * TILE + 21.5)) > 12)
err(`死亡动画圆心偏离死亡点 (${P.deathX.toFixed(0)},${P.deathY.toFixed(0)})`);
else ok('死亡动画圆心 = 死亡点');
if (!L.deathMarks.length) err('死亡未在死亡地点留下 IWBT 血迹效果');
else {
const dm = L.deathMarks[0];
const ex = 5 * TILE + 5.5, ey = 2 * TILE + 21.5;
if (Math.abs(dm.x - ex) > 30 || Math.abs(dm.y - ey) > 30) err(`血迹偏离死亡点 (${dm.x.toFixed(0)},${dm.y.toFixed(0)})`);
else ok('在死亡地点留下红色死亡叉(X)');
}
if (P.x !== 5 * TILE) err('死亡后位置被改动(应定格在原处)');
else ok('死亡后定格在死亡位置');
}
// 2) 死亡后 60 帧:仍然 dead、不自动重生、输入完全无效
{
const { L, P, deaths } = fresh();
P.x = 5 * TILE; P.y = 2 * TILE + 11;
L.update(1, P, null); P.update(1); Input.endFrame();
const x0 = P.x;
down('ArrowRight'); down('ShiftLeft');
step(L, P, 60);
up('ArrowRight'); up('ShiftLeft');
if (P.alive) err('死亡 60 帧后自动复活了(违反"手动重生"需求)');
else ok('死亡后 60 帧仍定格等待手动重生');
if (deaths() !== 1) err(`死亡计数被重复触发(${deaths()})`);
if (P.deadT < 60) err(`deadT 未随死亡时间递增(${P.deadT})`);
else ok(`deadT 随时间递增(${P.deadT})`);
if (P.x !== x0) err('死亡后按方向键仍能移动');
else ok('死亡中输入无效(方向/跳跃均不响应)');
}
// 3) 手动 respawn:回到最近存档点、justRespawned 置位、立即可操作
{
const { L, P } = fresh();
// 先碰存档点(col1)
const s = L.entities.find(e => e.type === 'save');
P.x = s.x + s.w / 2 - P.w / 2; P.y = s.y + s.h - P.h;
step(L, P, 3);
const cp = P.savePoint;
if (!cp) { err('存档点未激活(前置失败)'); }
else {
P.x = 5 * TILE; P.y = 2 * TILE + 11;
L.update(1, P, null); P.update(1); Input.endFrame();
if (!P.dead) err('踩刺未死');
P.respawn(); // ← 模拟玩家按 R 手动重生
if (!P.alive || P.dead) err('手动 respawn 后未复活');
else ok('手动 respawn 成功复活');
if (Math.abs(P.x - cp.x) > 1) err(`复活位置不对:x=${P.x.toFixed(1)} 期望 ${cp.x.toFixed(1)}`);
else ok('复活位置 = 最近存档点');
if (P.justRespawned !== true) err('未标记 justRespawned,镜头会慢慢飘回去');
else ok('justRespawned 已置位(镜头瞬移)');
const x0 = P.x;
down('ArrowRight');
step(L, P, 6);
up('ArrowRight');
if (P.x <= x0) err('重生后当帧无法操作');
else ok(`重生后立即可操作(6 帧走了 ${(P.x - x0).toFixed(1)}px)`);
}
}
// 4) 连续死亡 5 次:每次都须手动重生才能继续,计数准确
{
const { L, P, deaths } = fresh();
for (let k = 0; k < 5; k++) {
P.x = 5 * TILE; P.y = 2 * TILE + 11;
L.update(1, P, null); P.update(1); Input.endFrame();
if (!P.dead) { err(`第 ${k + 1} 次踩刺未死`); break; }
step(L, P, 10); // 死亡中跑 10 帧:不许自动复活
if (P.alive) { err(`第 ${k + 1} 次死亡后自动复活`); break; }
P.respawn(); // 手动重生后才有下一次
}
if (deaths() !== 5) err(`连续 5 次死亡计数为 ${deaths()}`);
else ok('连续 5 次死亡均等待手动重生,计数 = 5');
}
// 5) 红叉常驻保留;整关重开清空
{
const { L, P } = fresh();
P.x = 5 * TILE; P.y = 2 * TILE + 11;
L.update(1, P, null); P.update(1); Input.endFrame();
P.respawn();
step(L, P, 30);
if (!L.deathMarks.length) err('重生后红叉被清掉了(应常驻保留)');
else ok(`死亡红叉常驻保留(${L.deathMarks.length} 处,重生 30 帧后仍在)`);
L.reset(true);
if (L.deathMarks.length) err(`整关重开后血迹应清空,实际 ${L.deathMarks.length}`);
else ok('整关重开(reset(true))后血迹清空');
}
// 6) 多次死亡:只显示最后一次死亡的红叉(不累积)
{
const { L, P } = fresh();
P.x = 5 * TILE; P.y = 2 * TILE + 11;
L.update(1, P, null); P.update(1); Input.endFrame();
P.respawn();
P.x = 10 * TILE; P.y = 2 * TILE + 11;
L.update(1, P, null); P.update(1); Input.endFrame();
if (L.deathMarks.length !== 1) err(`多次死亡应只显示最后一次血迹,实际 ${L.deathMarks.length} 处`);
else {
const dm = L.deathMarks[0];
if (Math.abs(dm.x - (10 * TILE + 5.5)) > 30) err(`应只保留最后一次(col10)血迹,实际在 x=${dm.x.toFixed(0)}`);
else ok('多次死亡只保留上一次死亡地点的血迹');
}
}
// 7) 掉坑死亡:动画圆心/红叉 clamp 到关卡内(玩家已在屏幕外也要可见)
{
const { L, P } = fresh();
P.y = L.pxH + 300; // 深坑底(屏幕外)
L.update(1, P, null); P.update(1); Input.endFrame();
if (!P.dead) err('掉坑未触发死亡');
else ok('掉坑触发死亡并定格');
if (P.deathY > L.pxH - 10) err(`掉坑死亡的动画圆心未 clamp 到关卡内(deathY=${P.deathY.toFixed(0)} > ${L.pxH - 10})`);
else ok(`掉坑死亡圆心 clamp 到关卡底缘(deathY=${P.deathY.toFixed(0)})`);
if (P.deathX < 10 || P.deathX > L.pxW - 10) err('动画圆心 X 未 clamp 到关卡内');
if (!L.deathMarks.length) err('掉坑死亡未留红叉');
else if (L.deathMarks[0].y > L.pxH) err('掉坑死亡的红叉画到了关卡外');
else ok('掉坑死亡的红叉在关卡内可见');
}
// 8) 死亡动画渲染冒烟:冲击环(Level 层)+ 粒子逐帧 draw 不抛错(canvas 用代理桩)
{
const { L } = fresh();
const ctx2d = new Proxy({ canvas: { width: 800, height: 608 } },
{ get(t, k) { return k in t ? t[k] : (() => { }); }, set(t, k, v) { t[k] = v; return true; } });
try {
for (let i = 0; i < 20; i++) {
L.drawDeathRing(ctx2d, { x: 100, y: 100, t: i });
L.particles.spawn(100, 100, 4, { life: 20 });
L.particles.draw(ctx2d);
}
ok('死亡动画(冲击环+粒子)20 帧渲染无异常');
} catch (e) { err(`死亡动画渲染抛错:${e.message}`); }
}
// 9) 主动按重生键(main.js suicide):当帧重生 + 演出留在死亡位置播完(不清粒子/震屏)
{
const { L, P, deaths } = fresh();
const s = L.entities.find(e => e.type === 'save');
P.x = s.x + s.w / 2 - P.w / 2; P.y = s.y + s.h - P.h;
step(L, P, 3);
const cp = P.savePoint;
if (!cp) { err('存档点未激活(前置失败)'); }
else {
P.x = 12 * TILE; P.y = 2 * TILE - P.h; // 走到右侧空地(col5/col10 有刺,避开)
step(L, P, 5);
const marks0 = L.deathMarks.length;
const burstX = P.cx, burstY = P.cy;
P.deathBurst(); // ← 模拟活着按 R 的演出部分(不进死亡状态)
if (P.dead || !P.alive) err('死亡演出把玩家带进了死亡状态(应纯演出)');
else ok('死亡演出为纯演出(不进死亡状态,可当帧重生)');
if (deaths() !== 0) err(`主动重生键不应计死亡,实际 ${deaths()}`);
else ok('主动重生键不计死亡数');
if (L.deathMarks.length !== marks0) err('主动重生键不应留红叉');
else ok('主动重生键不留红叉');
if (!(L.particles.list.length > 0) || !(L.shake > 0)) err('主动重生键的死亡动画缺失(粒子/震屏)');
else ok('主动重生键播死亡动画(粒子+轻震+冲击环)');
if (!L.deathFX.length) err('冲击环未记录到关卡层');
else if (Math.abs(L.deathFX[0].x - burstX) > 12) err('冲击环圆心偏离死亡点');
else ok('冲击环记录在死亡位置(Level 层)');
// 当帧重生:不等动画
P.respawn();
if (!P.alive || P.dead) err('未当帧重生');
else ok('当帧重生(不等待动画)');
if (Math.abs(P.x - cp.x) > 1) err(`重生位置不对:x=${P.x.toFixed(1)} 期望 ${cp.x.toFixed(1)}`);
else ok('重生回最近存档点');
if (P.justRespawned !== true) err('未标记 justRespawned,镜头不会瞬移');
// 动画在死亡位置继续播完:粒子未被清、冲击环在推进
if (!(L.particles.list.length > 0)) err('重生把演出粒子清掉了(应留在死亡位置播完)');
else ok('重生不清场:粒子留在死亡位置继续播');
step(L, P, 5);
if (!L.deathFX.length) err('冲击环被提前清掉了');
step(L, P, 12); // 累计 17 帧 > 14
if (L.deathFX.length) err('冲击环 14 帧后未移除');
else ok('冲击环 14 帧扩散淡出后自动移除');
step(L, P, 25); // 粒子 life 34,累计 42 帧应自然播完
if (L.particles.list.length) err(`粒子未自然播完(残余 ${L.particles.list.length} 个)`);
else ok('粒子自然播完(34 帧寿命)');
if (!(L.shake < 0.6)) err(`震屏未自然衰减(${L.shake.toFixed(2)})`);
else ok('震屏自然衰减归零');
}
}
// 10) 真死亡(被动)后玩家很快按 R:特效不被重生清场,留在死亡位置自然播完
{
const { L, P, deaths } = fresh();
P.x = 5 * TILE; P.y = 2 * TILE + 11;
L.update(1, P, null); P.update(1); Input.endFrame(); // 踩刺真死
if (!P.dead) err('踩刺未死(前置失败)');
else {
step(L, P, 3); // 玩家只等了 3 帧就按 R
if (deaths() !== 1) err(`死亡计数应为 1,实际 ${deaths()}`);
P.respawn();
if (!P.alive || P.dead) err('快速重生失败');
else ok('真死亡后 3 帧即按 R:成功重生');
if (!(L.particles.list.length > 0)) err('快速重生把死亡粒子清掉了(应留在死亡位置播完)');
else ok('快速重生不清场:粒子留在死亡位置继续播');
if (!L.deathFX.length) err('快速重生把冲击环清掉了');
else ok('快速重生不清场:冲击环继续扩散');
if (!(L.shake > 0)) err('快速重生把震屏清掉了');
else ok('快速重生不清场:震屏继续衰减');
step(L, P, 50); // 粒子 34 / 环 14 / 震屏衰减,全部应自然结束
if (L.particles.list.length) err(`粒子未自然播完(残余 ${L.particles.list.length})`);
else if (L.deathFX.length) err('冲击环未自然播完');
else if (L.shake > 0.01) err(`震屏未自然衰减(${L.shake.toFixed(3)})`);
else ok('特效全部自然播完(粒子 34 帧 / 冲击环 14 帧 / 震屏衰减)');
}
}
console.log(fail ? `\n❌ ${fail} 项失败` : '\n✅ 死亡流程全部通过:定格等待手动重生 + 小红死亡动画 + 主动重生键当帧重生');
process.exit(fail ? 1 : 0);
-95
View File
@@ -1,95 +0,0 @@
// 二段跳时机回归(tools/dj-test.mjs)
// 历史 bug 链:
// ① 全额抵消(旧 `vy = max(vy,0) - DJUMP`):满力一段到顶点后晚几帧接二段,二段塌到接近 0(用户报"二段非常短")
// ② 部分抵消(DJ_INERTIA 0.5→0.35):仍有"好沉+高度短"——起跳初速被下落速度从 6.7 吃到 3~4,
// 起跳那一下没劲才是"沉"的根源,与上升重力无关
// ③ 现行(DJ_INERTIA = 0,同 IWBT 原版):任何时机接二段都满力起跳,初速恒 -DJUMP
// 本测试用真实引擎测量:不同时机接二段时,二段自身的提升高度(djRise)。
// 通过标准:
// · 任意时机接二段都 ≈ 满力(≈60px ≈ 1.9 格)——INERTIA=0 语义,曲线应基本持平
// · 满力一段 ≈ 84px(≈2.6 格);顶点接二段总高 ≈ 4.5 格
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
globalThis.addEventListener = addL;
globalThis.window = { addEventListener: addL };
const key = (t, c) => (handlers[t] || []).forEach(fn => fn({ code: c, preventDefault() { }, repeat: false }));
const { Level } = await import('../js/level.js');
const { Player } = await import('../js/player.js');
const { Input } = await import('../js/input.js');
// 与 jumpdist.mjs 同一块平地:row17 是实心平台
const flat = {
id: 'test', name: 'T', type: 'jump', width: 44,
grid: (() => {
const rows = [];
rows.push('#'.repeat(44));
for (let i = 0; i < 16; i++) rows.push('#' + '.'.repeat(42) + '#');
rows.push('#' + '#'.repeat(42) + '#');
rows.push('#'.repeat(44));
return rows;
})(),
};
// 满力一段跳(起跳后一直按住);第 djFrame 帧松键、djFrame+1 帧接二段(之后保持按住)
// 返回:firstRise 一段顶点提升 / djRise 二段自身提升(接二段位置→二段后最高点)/ total 二段顶点离平台高度
function measure(djFrame) {
const L = new Level(flat);
const P = new Player(L);
P.x = 8 * 32; P.y = 17 * 32 - P.h; // 站在 row17 实心平台上
P.vx = 0; P.vy = 0; P.grounded = true; P.jumps = 0;
const y0 = P.y;
key('keydown', 'ShiftLeft'); // 起跳并按住 = 满力一段
let apex1 = y0, apex2 = y0, djDone = false, vyAtDj = null, yAtDj = y0;
for (let i = 0; i < 240; i++) {
const f = i + 1;
if (f === djFrame) key('keyup', 'ShiftLeft');
if (f === djFrame + 1) { key('keydown', 'ShiftLeft'); vyAtDj = P.vy; yAtDj = P.y; }
L.update(1, P, null);
P.update(1);
Input.endFrame();
if (djFrame && f === djFrame + 1 && P.jumps === 2) djDone = true;
if (djDone) apex2 = Math.min(apex2, P.y);
else apex1 = Math.min(apex1, P.y);
if (i > 2 && P.grounded) break;
if (P.dead) break;
}
key('keyup', 'ShiftLeft');
Input.endFrame();
return { firstRise: y0 - apex1, djRise: djDone ? yAtDj - apex2 : 0, total: y0 - apex2, vyAtDj, djDone };
}
// 满力一段顶点≈第 27 帧;落地≈第 50 帧。取 27~46 帧覆盖"顶点 → 落地前"整个二段窗口
const FRAMES = [27, 30, 34, 38, 42, 46];
const T = 32;
let fail = 0;
const rows = [];
console.log('满力一段跳后,不同时机接二段:二段自身提升高度(真实引擎实测)');
console.log(' 时机 接二段时vy 一段顶点 二段自身提升 二段顶点离平台');
for (const f of FRAMES) {
const r = measure(f);
rows.push({ f, ...r });
const dj = r.djDone
? `${r.djRise.toFixed(1).padStart(6)}px = ${(r.djRise / T).toFixed(2)} 格`
: ' —— 已落地,二段窗口已过 ——';
console.log(` 第${String(f).padStart(2)}帧 ${(r.vyAtDj ?? 0).toFixed(2).padStart(5)} ${r.firstRise.toFixed(1).padStart(6)}px ${dj} ${(r.total / T).toFixed(2)} 格`);
}
const at = f => rows.find(r => r.f === f);
const need = (cond, msg) => { if (!cond) { fail++; console.log(' ❌ ' + msg); } };
for (const r of rows) need(r.djDone, `第 ${r.f} 帧应还在二段窗口内(未落地)`);
const apex = at(27);
need(apex.firstRise > 79 && apex.firstRise < 90, `满力一段高度应 ≈84px(2.6格),实测 ${apex.firstRise.toFixed(1)}`);
need(apex.djRise > 52 && apex.djRise < 68, `顶点接二段自身提升应 ≈59px(1.85格),实测 ${apex.djRise.toFixed(1)}`);
need(apex.total > 138 && apex.total < 150, `顶点接二段总高应 ≈4.5格,实测 ${(apex.total / T).toFixed(2)}`);
// INERTIA=0:任何时机接二段都满力起跳,曲线应基本持平(与顶点差异 ≤3px)
for (const r of rows) {
if (!r.f) continue;
need(r.djRise >= 55, `第 ${r.f} 帧接二段自身提升应 ≈满力(≥55px),实测 ${r.djRise.toFixed(1)}`);
need(Math.abs(r.djRise - apex.djRise) <= 3, `第 ${r.f} 帧与顶点接二段差异应 ≤3px(INERTIA=0 曲线持平),实测差 ${Math.abs(r.djRise - apex.djRise).toFixed(1)}`);
}
need(at(46).djRise >= 55, `即使摔得快二段也不应衰减(INERTIA=0),实测 ${at(46).djRise.toFixed(1)}`);
if (fail) { console.log(`\n❌ dj-test 失败 ${fail} 项`); process.exit(1); }
console.log('\n✅ dj-test 通过:任意时机接二段均满力起跳(INERTIA=0),顶点总高 ≈4.5 格');
-94
View File
@@ -1,94 +0,0 @@
// DOM 环境冒烟:mock 浏览器 API,跑通入口 main.js 的初始化 + 若干帧
const noop = () => { };
const els = new Map();
function mkEl(id = '') {
const el = {
id, tagName: 'DIV', value: '', textContent: '', innerHTML: '',
style: {}, dataset: {}, children: [], width: 800, height: 608,
classList: { add: noop, remove: noop, toggle: noop, contains: () => false },
addEventListener: noop, removeEventListener: noop, appendChild: noop,
querySelector: () => mkEl(), querySelectorAll: () => [],
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 608 }),
getContext: () => ctx2d,
focus: noop, click: noop,
};
return el;
}
const ctx2d = new Proxy({
canvas: { width: 800, height: 608 },
measureText: () => ({ width: 12 }),
createLinearGradient: () => ({ addColorStop: noop }),
createRadialGradient: () => ({ addColorStop: noop }),
getImageData: () => ({ data: [] }),
}, {
get(t, k) { return k in t ? t[k] : noop; },
set(t, k, v) { t[k] = v; return true; },
});
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
globalThis.location = { search: '', href: 'http://localhost/', pathname: '/', reload: noop };
Object.defineProperty(globalThis, 'navigator', { value: { maxTouchPoints: 0, userAgent: 'node' }, configurable: true });
globalThis.matchMedia = q => ({ matches: false, media: q, addEventListener: noop, removeEventListener: noop });
globalThis.window = {
addEventListener: addL, removeEventListener: noop,
AudioContext: null, webkitAudioContext: null,
innerWidth: 1280, innerHeight: 800,
location: globalThis.location, navigator: globalThis.navigator,
matchMedia: globalThis.matchMedia,
};
globalThis.addEventListener = addL;
globalThis.removeEventListener = noop;
globalThis.confirm = () => true;
globalThis.alert = noop;
globalThis.localStorage = {
_d: {},
getItem(k) { return this._d[k] ?? null; },
setItem(k, v) { this._d[k] = String(v); },
removeItem(k) { delete this._d[k]; },
};
globalThis.document = {
getElementById(id) { if (!els.has(id)) els.set(id, mkEl(id)); return els.get(id); },
querySelector(s) { return mkEl(s); },
querySelectorAll() { return []; },
createElement(t) { const e = mkEl(); e.tagName = t.toUpperCase(); return e; },
addEventListener: addL,
body: mkEl('body'),
};
let frames = 0;
globalThis.requestAnimationFrame = (fn) => {
if (frames++ > 12) return 0;
setTimeout(() => fn(performance.now() + frames * 16.7), 0);
return frames;
};
try {
await import('../js/main.js');
console.log('✅ main.js 初始化成功');
} catch (e) {
console.log('❌ main.js 初始化失败: ' + e.message);
console.log(e.stack.split('\n').slice(0, 5).join('\n'));
process.exit(1);
}
// 等几帧
await new Promise(r => setTimeout(r, 200));
const { LEVELS } = await import('../js/levels/index.js');
const api = globalThis.window.__IW;
console.log('✅ 已跑 ' + frames + ' 帧,状态=' + (api ? api.G.state : '?'));
if (api) {
// 逐个关卡启动一次,确认不炸
let bad = 0;
for (const lv of LEVELS) {
try {
api.startLevel(lv, lv.index ?? -1);
for (let i = 0; i < 90; i++) { /* 由 rAF 驱动,这里只验证构造 */ }
if (!api.G.level || !api.G.player) throw new Error('未创建关卡');
} catch (e) { console.log(' ✗ ' + lv.id + ': ' + e.message); bad++; }
}
console.log(bad ? `❌ ${bad} 个关卡启动失败` : `✅ 全部 ${LEVELS.length} 关均可启动`);
}
process.exit(0);
-136
View File
@@ -1,136 +0,0 @@
// 编辑器交互回归:调色板无「查看属性」工具;无论手上拿什么方块(橡皮除外),
// 点击画布上任意方块都选中该格并在属性面板显示其属性(不覆盖已有方块)。
const noop = () => { };
let fail = 0;
const ok = (c, m) => { console.log((c ? ' ✔ ' : ' ✗ ') + m); if (!c) fail++; };
// ---------- 最小假 DOM(addEventListener 收句柄,children 真实入列)----------
const els = new Map();
function mkEl(id = '') {
const e = {
id, tagName: 'DIV', value: '', _html: '', textContent: '',
style: {}, dataset: {}, children: [],
width: 800, height: 608,
classList: {
_s: new Set(),
add(...c) { c.forEach(x => this._s.add(x)); },
remove(...c) { c.forEach(x => this._s.delete(x)); },
toggle(c, on) { on ? this._s.add(c) : this._s.delete(c); },
contains(c) { return this._s.has(c); },
},
_h: {},
addEventListener(t, fn) { (this._h[t] = this._h[t] || []).push(fn); },
removeEventListener: noop,
appendChild(c) { this.children.push(c); return c; },
querySelector: () => mkEl(), querySelectorAll: () => [],
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 608 }),
getContext: () => ctx2d, focus: noop, click: noop,
preventDefault: noop,
};
Object.defineProperty(e, 'innerHTML', {
get() { return this._html; },
set(v) { this._html = String(v); this.children = []; },
});
return e;
}
const ctx2d = new Proxy({
canvas: { width: 800, height: 608 },
measureText: () => ({ width: 12 }),
createLinearGradient: () => ({ addColorStop: noop }),
createRadialGradient: () => ({ addColorStop: noop }),
getImageData: () => ({ data: [] }),
}, {
get(t, k) { return k in t ? t[k] : noop; },
set(t, k, v) { t[k] = v; return true; },
});
const getEl = s => { if (!els.has(s)) els.set(s, mkEl(s)); return els.get(s); };
globalThis.location = { search: '', href: 'http://localhost/', pathname: '/', reload: noop };
Object.defineProperty(globalThis, 'navigator', { value: { maxTouchPoints: 0, userAgent: 'node' }, configurable: true });
globalThis.matchMedia = q => ({ matches: false, media: q, addEventListener: noop, removeEventListener: noop });
globalThis.window = {
addEventListener: noop, removeEventListener: noop, AudioContext: null, webkitAudioContext: null,
innerWidth: 1280, innerHeight: 800, location: globalThis.location, navigator: globalThis.navigator, matchMedia: globalThis.matchMedia,
};
globalThis.addEventListener = noop;
globalThis.removeEventListener = noop;
globalThis.confirm = () => true;
globalThis.alert = noop;
globalThis.localStorage = { _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; } };
globalThis.document = {
getElementById: getEl, querySelector: getEl, querySelectorAll: () => [],
createElement(t) { const e = mkEl(); e.tagName = t.toUpperCase(); return e; },
addEventListener: noop, body: mkEl('body'),
};
const { Editor, PALETTE } = await import('../js/editor.js');
// 画布坐标 → 格子:pos() 用 clientX/Y(scroll=0,缩放 1:1),格心 (tx*32+16, ty*32+16)
const ev = (tx, ty, button = 0) => ({ clientX: tx * 32 + 16, clientY: ty * 32 + 16, button, preventDefault: noop });
const clickTile = (tx, ty, button = 0) => {
for (const fn of getEl('#ed-canvas')._h.mousedown) fn(ev(tx, ty, button));
};
// renderProps 先写面板 innerHTML(标题),再把每个属性字段 appendChild 成子行
const propsHtml = () => {
const box = getEl('#ed-props');
return box.innerHTML + box.children.map(c => c.innerHTML || '').join('');
};
console.log('=== 调色板不再有「查看属性」工具 ===');
ok(!PALETTE.some(p => p[0] === '?'), "PALETTE 中已无 '?'(查看属性)");
ok(!PALETTE.some(p => p[1] === '查看属性'), 'PALETTE 中已无「查看属性」条目');
Editor.init({ startLevel: noop });
const palLabels = getEl('#palette').children.map(c => c.textContent || '');
ok(palLabels.length === PALETTE.length, `调色板渲染 ${PALETTE.length} 项(无工具项),实际 ${palLabels.length}`);
ok(!palLabels.some(t => t.includes('查看属性')), '调色板 UI 无「查看属性」按钮');
console.log('=== 打开地图,构造一块带属性的摄像头 C ===');
Editor.open();
const f = Editor.curFace();
f.grid[5][7] = 'C'; // 摄像头:SCHEMA 里有 4 个可编辑属性
f.props[5][7] = { w: 4, h: 4, mode: 'zoom', zoom: 1.5 };
console.log('=== 手上拿刺(^),点击已有摄像头 → 只看属性,不覆盖 ===');
Editor.sel = '^';
clickTile(7, 5);
ok(f.grid[5][7] === 'C', '格子内容未被覆盖(仍是 C)');
ok(Editor.selTile && Editor.selTile.tx === 7 && Editor.selTile.ty === 5, 'selTile 指向被点方块 (7,5)');
const html1 = propsHtml();
ok(html1.includes('区域宽') && html1.includes('镜头模式'), '属性面板显示该方块的属性(区域宽/镜头模式)');
console.log('=== 手上拿方块(#),点击同一格 → 同样只查看,不覆盖 ===');
Editor.sel = '#';
clickTile(7, 5);
ok(f.grid[5][7] === 'C', '拿 # 点击也不覆盖已有方块');
ok(propsHtml().includes('区域宽'), '属性面板仍显示 C 的属性');
console.log('=== 手上拿炮台(O),点击空格子 → 放置并显示新方块属性 ===');
Editor.sel = 'O';
clickTile(3, 9);
ok(f.grid[9][3] === 'O', '空格子正常放置 O');
ok(propsHtml().includes('发射间隔'), '放置后属性面板显示 O 的属性');
console.log('=== 提示牌 T 的文本属性可随点击查看 ===');
f.grid[9][4] = 'T'; f.props[9][4] = { msg: '小心刺' };
Editor.sel = '%'; // 拿着冰面点提示牌
clickTile(4, 9);
ok(f.grid[9][4] === 'T' && propsHtml().includes('提示文字'), '非相关工具点击 T 也能查看文字属性');
console.log('=== 橡皮擦例外:选中橡皮点击 → 擦除 ===');
Editor.sel = '.';
clickTile(4, 9);
ok(f.grid[9][4] === '.', '橡皮擦点击后 T 被擦除');
Editor.sel = '^'; // 恢复非橡皮工具
clickTile(7, 5); // 顺手确认 C 还在
console.log('=== 右键擦除与工具无关 ===');
Editor.sel = '#';
clickTile(3, 9, 2); // button=2 右键
ok(f.grid[9][3] === '.', '右键擦除不受手上工具影响');
console.log('=== 属性面板空态提示仍存在 ===');
Editor.show(); // 重置 selTile
ok(propsHtml().includes('点击画布上的方块'), '未选中时显示引导文案');
console.log(fail === 0 ? '\n✅ 编辑器点击查看属性 全部断言通过' : `\n❌ 失败 ${fail} 项`);
process.exit(fail === 0 ? 0 : 1);
-97
View File
@@ -1,97 +0,0 @@
// 编辑器所见即所得测试:刺以三角形绘制 + 每种方块都能正常渲染
const noop = () => { };
const els = new Map();
// 记录关键绘制调用的计数器(证明刺用三角形图元、方块用真实纹理)
const C = { lineTo: 0, fillRect: 0, beginPath: 0, moveTo: 0, arc: 0 };
const ctx2d = new Proxy({
canvas: { width: 800, height: 608 },
measureText: () => ({ width: 12 }),
createLinearGradient: () => ({ addColorStop: noop }),
createRadialGradient: () => ({ addColorStop: noop }),
getImageData: () => ({ data: [] }),
}, {
get(t, k) { return k in t ? t[k] : noop; },
set(t, k, v) { t[k] = v; return true; },
});
// 装上计数器
ctx2d.lineTo = () => C.lineTo++;
ctx2d.fillRect = () => C.fillRect++;
ctx2d.beginPath = () => C.beginPath++;
ctx2d.moveTo = () => C.moveTo++;
ctx2d.arc = () => C.arc++;
function mkEl(id = '') {
return {
id, tagName: 'DIV', value: '', textContent: '', innerHTML: '',
style: {}, dataset: {}, children: [], width: 800, height: 608,
classList: { add: noop, remove: noop, toggle: noop, contains: () => false },
addEventListener: noop, removeEventListener: noop, appendChild: noop,
querySelector: () => mkEl(), querySelectorAll: () => [],
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 608 }),
getContext: () => ctx2d, focus: noop, click: noop,
};
}
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
globalThis.location = { search: '', href: 'http://localhost/', pathname: '/', reload: noop };
Object.defineProperty(globalThis, 'navigator', { value: { maxTouchPoints: 0, userAgent: 'node' }, configurable: true });
globalThis.matchMedia = q => ({ matches: false, media: q, addEventListener: noop, removeEventListener: noop });
globalThis.window = {
addEventListener: addL, removeEventListener: noop, AudioContext: null, webkitAudioContext: null,
innerWidth: 1280, innerHeight: 800, location: globalThis.location, navigator: globalThis.navigator, matchMedia: globalThis.matchMedia,
};
globalThis.addEventListener = addL;
globalThis.removeEventListener = noop;
globalThis.confirm = () => true;
globalThis.alert = noop;
globalThis.localStorage = { _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; } };
globalThis.document = {
getElementById(id) { if (!els.has(id)) els.set(id, mkEl(id)); return els.get(id); },
querySelector(s) { return mkEl(s); },
querySelectorAll() { return []; },
createElement(t) { const e = mkEl(); e.tagName = t.toUpperCase(); return e; },
addEventListener: addL, body: mkEl('body'),
};
let fail = 0;
const ok = (c, m) => { console.log((c ? ' ✔ ' : ' ✗ ') + m); if (!c) fail++; };
const resetC = () => { for (const k in C) C[k] = 0; };
const { Editor } = await import('../js/editor.js');
const { PALETTE } = await import('../js/editor.js');
console.log('=== 编辑器初始化(会经 buildPalette 渲染全部调色板方块)===');
try {
Editor.init({ startLevel: noop });
ok(true, 'Editor.init 成功(所有调色板方块缩略图渲染无异常)');
} catch (e) { ok(false, 'Editor.init 失败: ' + e.message); console.log(e.stack.split('\n').slice(0, 4).join('\n')); }
console.log('=== 打开空白地图并绘制 ===');
Editor.open();
const f = Editor.curFace();
ok(f && f.w > 0 && f.h > 0, 'open 生成可编辑面(' + f.w + '×' + f.h + ')');
console.log('=== 刺在编辑器里以三角形绘制 ===');
f.grid[1][1] = '^';
resetC();
Editor.draw();
ok(C.lineTo > 0 && C.beginPath > 0, '刺:用三角形图元绘制(lineTo=' + C.lineTo + ', beginPath=' + C.beginPath + ')');
console.log('=== 方块在编辑器里以真实纹理绘制 ===');
f.grid[1][1] = '#';
resetC();
Editor.draw();
ok(C.fillRect > 0, '方块:实心纹理绘制(fillRect=' + C.fillRect + ')');
console.log('=== 每种方块类型在编辑器画布都能正常绘制不抛异常 ===');
let bad = 0;
for (const [ch] of PALETTE) {
if (ch === '.') continue;
try { f.grid[2][2] = ch; Editor.draw(); }
catch (e) { console.log(' ✗ ' + ch + ': ' + e.message); bad++; }
}
ok(bad === 0, '全部 ' + PALETTE.filter(p => p[0] !== '.').length + ' 种方块绘制无异常');
console.log(fail === 0 ? '\n✅ 编辑器所见即所得 全部断言通过' : `\n❌ 失败 ${fail} 项`);
process.exit(fail === 0 ? 0 : 1);
-103
View File
@@ -1,103 +0,0 @@
// 冰面惯性回归测试:滑出冰块后必须保留惯性(空中几乎不衰减,地面缓慢收住)
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
globalThis.addEventListener = addL;
globalThis.window = { addEventListener: addL };
const key = (type, code) => (handlers[type] || []).forEach(fn => fn({ code, preventDefault() { }, repeat: false }));
const down = c => key('keydown', c);
const up = c => key('keyup', c);
const { Level } = await import('../js/level.js');
const { Player, PHY } = await import('../js/player.js');
const { Input } = await import('../js/input.js');
const W = 44;
// 地板 row 11:冰面 col1..6,之后 tail(普通地面或空)
function grid(tail) {
const g = [];
g.push('#'.repeat(W));
for (let y = 1; y <= 10; y++) {
g.push((y === 10 ? '#S' + '.'.repeat(W - 3) : '#' + '.'.repeat(W - 2)) + '#');
}
g.push('#' + '%'.repeat(6) + tail + '#');
g.push('#'.repeat(W));
g.push('#'.repeat(W));
return g;
}
let fail = 0;
const err = m => { console.log(' ✗ ' + m); fail++; };
function run(tail, label, releaseAt = 1e9, jumpAt = 1e9) {
const L = new Level({ id: 'ice', name: 'ICE', type: 'jump', width: W, grid: grid(tail) });
const P = new Player(L);
for (let i = 0; i < 10; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
down('ArrowRight');
const log = [];
for (let i = 0; i < 90; i++) {
if (i === releaseAt) up('ArrowRight');
if (i === jumpAt) down('ShiftLeft');
if (i === jumpAt + 1) up('ShiftLeft');
L.update(1, P, null); P.update(1); Input.endFrame();
log.push({
f: i, x: +P.x.toFixed(1), vx: +P.vx.toFixed(2), y: +P.y.toFixed(1),
g: P.grounded, sl: P.sliding,
ice: P.grounded && L.iceAtBottom(P.x, P.y, P.w, P.h),
});
}
up('ArrowRight');
console.log(`\n=== ${label} ===`);
console.log('帧 x vx 地 滑 冰');
let crossAt = -1;
for (const r of log) {
const cross = !r.ice && log.find(o => o.f === r.f - 1)?.ice;
if (cross && crossAt < 0) { crossAt = r.f; console.log('--- 离开冰面 ---'); }
if (r.f % 3 === 0 || cross) console.log(`${String(r.f).padStart(3)} ${String(r.x).padStart(6)} ${String(r.vx).padStart(6)} ${r.g ? 'Y' : '.'} ${r.sl ? 'Y' : '.'} ${r.ice ? 'Y' : '.'}`);
}
const off = log.filter(r => r.f >= crossAt && crossAt >= 0);
const dist = off.length ? Math.max(...off.map(r => r.x)) - off[crossAt === 0 ? 0 : 0].x : 0;
console.log(`离冰时 vx=${off[0]?.vx ?? 0},离冰后位移=${dist.toFixed(1)}px`);
return { log, crossAt, off };
}
// A:一直按住右键冲出冰面 —— 惯性要把 4.32 平滑收敛到正常走速,不能瞬间刹停也不能一直超速
{
const { off } = run('='.repeat(34), 'A 冰面 -> 普通地面(一直按住右键)');
const v0 = off[0].vx, v10 = off[10]?.vx ?? 0, v30 = off[30]?.vx ?? 0;
if (v0 < PHY.HSPD) err(`A 离冰瞬间就丢了速度 vx=${v0}`);
if (v10 < 2) err(`A 离冰 10 帧后速度掉太快 vx=${v10}(应仍在滑行)`);
if (Math.abs(v30) > PHY.HSPD + 0.01) err(`A 离冰 30 帧后仍超速 vx=${v30}(应收敛回 ${PHY.HSPD})`);
}
// B:松手靠惯性滑出到普通地面 —— 地面摩擦收住,但不能一帧刹死
{
const { off } = run('='.repeat(34), 'B 冰面 -> 普通地面(第30帧松开方向键)', 30);
const v0 = off[0].vx, v5 = off[5]?.vx ?? 0;
if (v5 <= 0) err(`B 离冰 5 帧内就刹死了(v0=${v0} v5=${v5})`);
if (v5 > v0) err(`B 松手后速度反而上升`);
}
// C:滑出冰块边缘掉下去 —— 空中必须几乎不衰减,能一路飞出去
{
const { off } = run('.'.repeat(34), 'C 冰面 -> 悬空(第30帧松开后滑出边缘)', 30);
const air = off.filter(r => !r.g);
if (air.length < 5) { err('C 没掉下去(场景构造失败)'); }
else {
const k = Math.min(10, air.length - 1);
const v0 = air[0].vx, vk = air[k].vx;
if (vk < v0 - 0.5) err(`C 空中惯性衰减过快:${v0} -> ${vk}(${k} 帧)`);
else console.log(` ✓ 空中 ${k} 帧后仍保留 vx=${vk}(起 ${v0})`);
}
}
// D:冰面上滑行中起跳 —— 空中保持滑行速度
{
const { off } = run('.'.repeat(34), 'D 冰面滑行中起跳(第30帧松开 + 第34帧跳)', 30, 34);
const v0 = off[0]?.vx ?? 0, v12 = off[12]?.vx ?? 0;
if (v0 < 2) err(`D 起跳时速度太小 vx=${v0}`);
if (v12 < v0 - 0.6) err(`D 跳跃后空中丢失惯性:${v0} -> ${v12}`);
else console.log(` ✓ 起跳后 12 帧仍保留 vx=${v12}(起 ${v0})`);
}
console.log(fail ? `\n❌ ${fail} 项失败` : '\n✅ 冰面惯性全部通过');
process.exit(fail ? 1 : 0);
-54
View File
@@ -1,54 +0,0 @@
// 用真实引擎(Player/Level)测量跳跃水平距离,核对求解器的物理近似是否可信
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
globalThis.addEventListener = addL;
globalThis.window = { addEventListener: addL };
const key = (t, c) => (handlers[t] || []).forEach(fn => fn({ code: c, preventDefault() { }, repeat: false }));
const { Level } = await import('../js/level.js');
const { Player } = await import('../js/player.js');
const { Input } = await import('../js/input.js');
// 一块 40 格长的平地,右端断开
const flat = {
id: 'test', name: 'T', type: 'jump', width: 44,
grid: (() => {
const rows = [];
rows.push('#'.repeat(44));
for (let i = 0; i < 16; i++) rows.push('#' + '.'.repeat(42) + '#');
rows.push('#' + '#'.repeat(42) + '#'); // 整条实心平台
rows.push('#'.repeat(44));
return rows;
})(),
};
function measure(djFrame) {
const L = new Level(flat);
const P = new Player(L);
P.x = 8 * 32; P.y = 16 * 32 - P.h; // 站在 row17 平台上(平台行 17)
P.vx = 0; P.vy = 0; P.grounded = true; P.jumps = 0;
const x0 = P.x;
key('keydown', 'ArrowRight');
key('keydown', 'ShiftLeft');
let f = 0, landed = -1, released = false;
for (let i = 0; i < 200; i++) {
f++;
// 二段跳需要先松开再按下(跨帧),否则不算一次新的按下
if (djFrame && f === djFrame) { key('keyup', 'ShiftLeft'); released = true; }
if (released && f === djFrame + 1) { key('keydown', 'ShiftLeft'); released = false; }
L.update(1, P, null);
P.update(1);
Input.endFrame();
if (i > 2 && P.grounded) { landed = P.x; break; }
if (P.dead) break;
}
key('keyup', 'ArrowRight'); key('keyup', 'ShiftLeft');
Input.endFrame();
return { dist: landed >= 0 ? landed - x0 : null, f };
}
console.log('真实引擎跳跃水平距离(起跳点 x 到落点 x,单位 px / 格)');
for (const dj of [0, 6, 12, 18, 24, 30]) {
const r = measure(dj);
console.log(` 二段跳时机=${String(dj).padStart(2)} 帧 -> ${r.dist === null ? '未落地' : r.dist.toFixed(1) + 'px = ' + (r.dist / 32).toFixed(2) + ' 格 (' + r.f + ' 帧)'}`);
}
-120
View File
@@ -1,120 +0,0 @@
// 回归测试:R 键必须"重生回存档点",不能把整关重开(否则存档点白碰)
const noop = () => { };
const els = new Map();
function mkEl(id = '') {
return {
id, tagName: 'DIV', value: '', textContent: '', innerHTML: '',
style: {}, dataset: {}, children: [], width: 800, height: 608,
classList: { add: noop, remove: noop, toggle: noop, contains: () => false },
addEventListener: noop, removeEventListener: noop, appendChild: noop,
querySelector: () => mkEl(), querySelectorAll: () => [],
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 608 }),
getContext: () => ctx2d, focus: noop, click: noop,
};
}
const ctx2d = new Proxy({
canvas: { width: 800, height: 608 },
measureText: () => ({ width: 12 }),
createLinearGradient: () => ({ addColorStop: noop }),
createRadialGradient: () => ({ addColorStop: noop }),
getImageData: () => ({ data: [] }),
}, { get(t, k) { return k in t ? t[k] : noop; }, set(t, k, v) { t[k] = v; return true; } });
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
const key = (t, c) => (handlers[t] || []).forEach(fn => fn({ code: c, preventDefault() { }, repeat: false }));
let clock = 1000;
globalThis.performance = { now: () => clock };
let rafCb = null;
globalThis.requestAnimationFrame = fn => { rafCb = fn; return 1; };
globalThis.location = { search: '', href: 'http://localhost/', pathname: '/', reload: noop };
Object.defineProperty(globalThis, 'navigator', { value: { maxTouchPoints: 0, userAgent: 'node' }, configurable: true });
globalThis.matchMedia = q => ({ matches: false, media: q, addEventListener: noop, removeEventListener: noop });
globalThis.window = {
addEventListener: addL, removeEventListener: noop,
AudioContext: null, webkitAudioContext: null, innerWidth: 1280, innerHeight: 800,
location: globalThis.location, navigator: globalThis.navigator,
matchMedia: globalThis.matchMedia,
};
globalThis.addEventListener = addL;
globalThis.removeEventListener = noop;
globalThis.confirm = () => true;
globalThis.alert = noop;
globalThis.localStorage = {
_d: {}, getItem(k) { return this._d[k] ?? null; },
setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; },
};
globalThis.document = {
getElementById(id) { if (!els.has(id)) els.set(id, mkEl(id)); return els.get(id); },
querySelector: s => mkEl(s), querySelectorAll: () => [],
createElement: t => mkEl(), addEventListener: addL, body: mkEl('body'),
};
await import('../js/main.js');
const { LEVELS } = await import('../js/levels/index.js');
const api = globalThis.window.__IW;
let fail = 0;
const err = m => { console.log(' ✗ ' + m); fail++; };
const step = n => { for (let i = 0; i < n; i++) { clock += 17; const f = rafCb; rafCb = null; if (f) f(clock); } };
const d = LEVELS.find(l => l.id === 'j1');
api.startLevel(d, 0);
step(10);
const L0 = api.G.level, P = api.G.player;
// 手动吃一个存档点(模拟玩家走到那里)
const s = L0.entities.filter(e => e.type === 'save')[1];
P.x = s.x + s.w / 2 - P.w / 2; P.y = s.y + s.h - P.h;
step(3);
const cp = P.savePoint;
console.log(` 存档点激活=${!!cp} 位置=(${cp?.x.toFixed(0)},${cp?.y.toFixed(0)}) 关卡出生点x=${L0.spawn.x.toFixed(0)}`);
if (!cp) err('存档点未激活');
// ---- 按 R(活着)= 原地播死亡动画后当帧重生回存档点(不计死亡、不留红叉、不等动画)----
const deaths0 = api.G.deaths;
const marks0 = L0.deathMarks.length;
P.x = L0.spawn.x; P.y = L0.spawn.y; // 先站到出生点,验证能回到存档点而非原地不动
step(5);
const beforeX = api.G.player.x;
key('keydown', 'KeyR');
step(2);
key('keyup', 'KeyR');
console.log(` 活着按 R 后:玩家x=${beforeX.toFixed(0)}→${api.G.player.x.toFixed(0)} alive=${api.G.player.alive} dead=${api.G.player.dead} 死亡数=${deaths0}→${api.G.deaths} 红叉数=${marks0}→${L0.deathMarks.length} 粒子=${L0.particles.list.length} 冲击环=${L0.deathFX.length}`);
if (api.G.player.dead || !api.G.player.alive) err('活着按 R 没有当帧重生');
if (api.G.deaths !== deaths0) err('主动重生被计入了死亡数(主动重生不是死亡)');
if (L0.deathMarks.length !== marks0) err('主动重生留下了红叉(不应留血迹)');
if (Math.abs(api.G.player.x - cp.x) > 4) err(`按 R 没有回到存档点(x=${api.G.player.x.toFixed(0)} 期望 ${cp.x.toFixed(0)})`);
if (!(L0.particles.list.length > 0)) err('死亡演出粒子缺失(应留在死亡位置播完)');
if (!L0.deathFX.length) err('死亡演出冲击环缺失(应留在死亡位置播完)');
// ---- 死亡中按 R = 手动重生回存档点 ----
api.G.player.kill();
step(3);
if (!api.G.player.dead) err('kill 后未进入死亡定格状态');
key('keydown', 'KeyR');
step(2);
key('keyup', 'KeyR');
step(10);
const survivedLevel = api.G.level === L0;
console.log(` 死亡中按 R 重生后:关卡对象未被重建=${survivedLevel} 玩家x=${api.G.player.x.toFixed(0)} alive=${api.G.player.alive} 残余粒子=${L0.particles.list.length}`);
if (!survivedLevel) err('R 键把整关重开了,存档进度丢失');
if (api.G.player.dead || !api.G.player.alive) err('死亡中按 R 未手动重生');
if (Math.abs(api.G.player.x - cp.x) > 4) err(`重生后没有回到存档点(x=${api.G.player.x.toFixed(0)} 期望 ${cp.x.toFixed(0)})`);
if (Math.abs(api.G.player.x - L0.spawn.x) < 4) err('重生后被送回了关卡起点');
if (!(L0.particles.list.length > 0)) err('死亡中按 R 重生把特效清掉了(应留在死亡位置播完)');
// ---- 按 Backspace = 整关重来 ----
key('keydown', 'Backspace');
step(2);
key('keyup', 'Backspace');
step(10);
console.log(` 按 Backspace 后:关卡对象被重建=${api.G.level !== L0} 玩家x=${api.G.player.x.toFixed(0)}`);
if (api.G.level === L0) err('Backspace 没有整关重开');
if (Math.abs(api.G.player.x - api.G.level.spawn.x) > 4) err('Backspace 后玩家不在出生点');
console.log(fail ? `\n❌ ${fail} 项失败` : '\n✅ 按键语义正确:R=回存档点,Backspace=整关重来');
process.exit(fail ? 1 : 0);
-169
View File
@@ -1,169 +0,0 @@
// 共享可达性 BFS 核心:solve.mjs 与 reach-diag.mjs 共用
// 用真实物理常量模拟走/落/跳(含二段跳时机、空中转向、松键短跳)
export const TILE = 32, PW = 11, PH = 21;
export const GRAV = 0.25, GRAV_FALL = 0.32, GRAV_DJ = 0.35, MAXFALL = 9, JUMP = 6.6, DJUMP = 6.7, HSPD = 2.6, DJ_INERTIA = 0;
export function setupGlobals() {
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
globalThis.addEventListener = addL;
globalThis.window = { addEventListener: addL };
return handlers;
}
export function makeSim(L) {
// 水:近似为可站立
for (let y = 0; y < L.h; y++)
for (let x = 0; x < L.w; x++)
if (L.tiles[y][x] === 6) L.tiles[y][x] = 1;
// 移动平台:整条路径当桥(乐观近似)
const br = [];
for (const m of L.movers) {
const xs = m.path.map(p => p.x).concat([m.x]);
const ys = m.path.map(p => p.y).concat([m.y]);
const x0 = Math.min(...xs), x1 = Math.max(...xs) + m.w;
const y0 = Math.min(...ys), y1 = Math.max(...ys) + m.h;
br.push({ x: x0, y: y0, w: x1 - x0, h: y1 - y0 });
}
function solidAt(x, y, w, h, down, prevBottom) {
const x0 = Math.floor(x / TILE), x1 = Math.floor((x + w - 0.001) / TILE);
const y0 = Math.floor(y / TILE), y1 = Math.floor((y + h - 0.001) / TILE);
for (let ty = y0; ty <= y1; ty++) for (let tx = x0; tx <= x1; tx++) {
const t = L.tileAt(tx, ty);
if (t === 7) { if (down && prevBottom !== undefined && prevBottom <= ty * TILE + 6) return true; continue; }
if (L.isSolidTile(t, tx, ty)) return true;
}
for (const b of br) if (x < b.x + b.w && x + w > b.x && y < b.y + b.h && y + h > b.y) return true;
return false;
}
function canStand(fx, fy) {
if (fx < 0 || fx + PW > L.pxW) return false;
if (solidAt(fx, fy - PH, PW, PH, false)) return false;
if (!solidAt(fx, fy - PH + 1, PW, PH, true, fy - 1)) return false;
if (L.deadlyRect(fx, fy - PH, PW, PH)) return false;
return true;
}
const overlap = (a, b) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
function jump(fx, fy, vx0, djump, flip, rel, targets) {
let x = fx, y = fy - PH, vy = -JUMP, vx = vx0, t = 0, used = false, djUp = false;
while (t++ < 240) {
// 与 player.js 一致:上升中按二段 = 直接 -DJUMP;下落中按只抵消 DJ_INERTIA 比例的下落速度
if (djump && !used && t === djump) { vy = Math.max(vy, 0) * DJ_INERTIA - DJUMP; used = true; djUp = vy < 0; }
if (flip && t === flip) vx = -vx;
if (rel && t === rel && vy < -3) vy = -3;
vy = Math.min(vy + (vy < 0 ? (djUp ? GRAV_DJ : GRAV) : GRAV_FALL), MAXFALL);
if (djUp && vy >= 0) djUp = false;
// 真实行为(player.js):本帧被墙挡住只是没推动,按住方向键下一帧 vx 仍为 ±3。
// 绝不能把 vx 永久清零,否则"贴着墙上升、越过台面边缘后再横移"这条经典路线会丢失。
if (vx !== 0) {
const nx = x + vx;
if (!solidAt(nx, y, PW, PH, false)) x = nx;
}
const prevBottom = y + PH;
let rem = Math.abs(vy), dy = Math.sign(vy), landed = false;
while (rem > 0.0001) {
const m = Math.min(1, rem), ny = y + dy * m;
if (!solidAt(x, ny, PW, PH, dy > 0, prevBottom)) { y = ny; rem -= m; }
else { if (dy > 0) landed = true; vy = 0; break; }
}
if (L.deadlyRect(x, y, PW, PH)) return null;
if (targets) {
const box = { x, y, w: PW, h: PH };
for (const tg of targets) if (!tg.hit && overlap(box, tg.rect)) tg.hit = true;
}
if (y > L.pxH + 120 || y < -240) return null;
if (landed) return { fx: x, fy: y + PH };
}
return null;
}
function fall(fx, fy, dir, targets) {
let x = fx + dir * 3, y = fy - PH, vy = 0, t = 0;
if (solidAt(x, y, PW, PH, false)) return null;
while (t++ < 400) {
vy = Math.min(vy + (vy < 0 ? GRAV : GRAV_FALL), MAXFALL);
const prevBottom = y + PH;
let rem = Math.abs(vy), landed = false;
while (rem > 0.0001) {
const m = Math.min(1, rem), ny = y + m;
if (!solidAt(x, ny, PW, PH, true, prevBottom)) { y = ny; rem -= m; }
else { landed = true; break; }
}
if (L.deadlyRect(x, y, PW, PH)) return null;
if (targets) {
const box = { x, y, w: PW, h: PH };
for (const tg of targets) if (!tg.hit && overlap(box, tg.rect)) tg.hit = true;
}
if (y > L.pxH + 120) return null;
if (landed) return { fx: x, fy: y + PH };
}
return null;
}
return { solidAt, canStand, jump, fall, overlap };
}
/** BFS 可达性:targets=[{rect,type,...}],onState 回调每个新状态 */
export const DEF_DJ = [0, 6, 12, 18, 24, 30];
// 2026-10-23:加 rel=5/6。vgap 夹缝(缝高 32px、盒高 21px)要求短跳 apex 精确落在 32~43px,
// 且上升早期要躲开地刺左坡:rel=4 apex 35.7 但 t=7 蹭坡 2.6px,rel=6 apex 44.7 但 t=11 擦垂刺尖 0.6px,
// 只有 rel=5(apex 38.9)同时满足(j2 x16 夹缝实证);j7 顶层 x52 夹缝则要 rel=6
export const DEF_REL = [0, 4, 5, 6, 8, 14];
export function reach(L, { DJSET = DEF_DJ, RELSET = DEF_REL, targets = [], onState = null, maxStates = 40000 } = {}) {
const sim = makeSim(L);
let sx = L.spawn.x, sy = L.spawn.y + PH, start = null;
for (let k = 0; k < 400; k++) {
if (sim.canStand(sx, sy + k)) { start = { fx: sx, fy: sy + k }; break; }
if (sy + k > L.pxH) break;
}
if (!start) return { err: '出生点下方找不到落脚点', states: 0, targets };
// 3px 状态桶:6px 桶会把"贴右腰 1~3px"的起拍/落点窗量化吞掉(j2 连续三段因此误判不可达)
// fy 规范化:jump/fall 返回的落点常比地面高零点几 px(如 1535.5),与精确落点 1536 同桶——
// 先占桶者赢,导致走链被错位几 px 的跳链顶掉,芝麻距离的跳跃窗口翻转(加 rel=6 后 j2 状态数反而暴跌的根因)。
// 真实游戏里重力下一帧即把玩家按到底(地面顶 = 32 的倍数),故按 canStand 门控 snapping
const key = s => `${Math.round(s.fx / 3)},${Math.round(s.fy / 3)}`;
const canon = (fx, fy) => {
const r = Math.round(fy / TILE) * TILE;
return (r !== fy && sim.canStand(fx, r)) ? r : fy;
};
start.fy = canon(start.fx, start.fy);
const seen = new Set([key(start)]);
const q = [start];
let n = 0;
while (q.length && n < maxStates) {
const s = q.shift(); n++;
const box = { x: s.fx, y: s.fy - PH, w: PW, h: PH };
for (const tg of targets) if (!tg.hit && sim.overlap(box, tg.rect)) tg.hit = true;
const push = r => {
if (!r || !sim.canStand(r.fx, r.fy)) return;
r.fy = canon(r.fx, r.fy);
const k = key(r);
if (seen.has(k)) return;
seen.add(k); q.push(r);
if (onState) onState(r);
};
for (const d of [-HSPD, HSPD]) push(sim.canStand(s.fx + d, s.fy) ? { fx: s.fx + d, fy: s.fy } : null);
for (const d of [-1, 1]) push(sim.fall(s.fx, s.fy, d, targets));
for (const vx of [-HSPD, 0, HSPD]) for (const dj of DJSET) for (const rel of RELSET) {
if (dj && rel) continue;
push(sim.jump(s.fx, s.fy, vx, dj, 0, rel, targets));
if (vx !== 0) push(sim.jump(s.fx, s.fy, vx, dj, 22, rel, targets));
}
}
return { states: seen.size, targets };
}
export function makeTargets(L) {
const targets = [];
for (const e of L.entities) {
if (e.type === 'goal' || e.type === 'save' || e.type === 'apple')
targets.push({ type: e.type, idx: e.idx, no: e.no, rect: { x: e.x, y: e.y, w: e.w, h: e.h }, hit: false });
}
return targets;
}
-71
View File
@@ -1,71 +0,0 @@
// 回归测试:多面地图的跨面传送门 + 存档点带面 + 内置关卡兼容
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
globalThis.addEventListener = addL;
globalThis.window = { addEventListener: addL };
const key = (type, code) => (handlers[type] || []).forEach(fn => fn({ code, preventDefault() { }, repeat: false }));
const { Level } = await import('../js/level.js');
const { Player } = await import('../js/player.js');
const { Input } = await import('../js/input.js');
const W = 20, TILE = 32;
let fail = 0;
const err = m => { console.log(' ✗ ' + m); fail++; };
const ok = m => console.log(' ✓ ' + m);
// 两面临门地图:A 面门 col5 指向 B 面门;B 面门 col10 指回 A 面门
const gridA = ['#'.repeat(W), '#' + 'S' + '.'.repeat(W - 3) + '#', '#' + '.'.repeat(4) + 'W' + '.'.repeat(W - 6) + '#', '#'.repeat(W), '#X' + '.'.repeat(W - 3) + '#', '#'.repeat(W), '#'.repeat(W), '#'.repeat(W), '#'.repeat(W), '#'.repeat(W)];
const gridB = ['#'.repeat(W), '#' + 'S' + '.'.repeat(W - 3) + '#', '#' + '.'.repeat(9) + 'W' + '.'.repeat(W - 11) + '#', '#'.repeat(W), '#X' + '.'.repeat(W - 3) + '#', '#'.repeat(W), '#'.repeat(W), '#'.repeat(W), '#'.repeat(W), '#'.repeat(W)];
const doorA = { id: 1, tx: 5, ty: 2, to: { face: 1, door: 1 } };
const doorB = { id: 1, tx: 10, ty: 2, to: { face: 0, door: 1 } };
const L0 = new Level({ name: 'A', type: 'custom', width: W, grid: gridA, doors: [doorA] }); L0.faceIndex = 0;
const L1 = new Level({ name: 'B', type: 'custom', width: W, grid: gridB, doors: [doorB] }); L1.faceIndex = 1;
// 1) 门目标正确挂接
{
const w0 = L0.entities.find(e => e.type === 'warp');
const w1 = L1.entities.find(e => e.type === 'warp');
if (!w0 || !w1) err('门实体未生成');
else {
if (w0.to && w0.to.face === 1 && w0.to.door === 1 && w0.doorId === 1) ok('A 面门挂上跨面目标 {face:1,door:1}');
else err('A 面门目标错误: ' + JSON.stringify(w0.to));
if (w1.to && w1.to.face === 0 && w1.to.door === 1 && w1.doorId === 1) ok('B 面门挂上跨面目标 {face:0,door:1}');
else err('B 面门目标错误: ' + JSON.stringify(w1.to));
}
}
// 2) 踩门触发 onPortal 钩子(main 据此切面)
{
const P = new Player(L0);
let captured = null; P.onPortal = (e) => { captured = e; };
const w0 = L0.entities.find(e => e.type === 'warp');
P.x = w0.x; P.y = w0.y; P.vx = 0; P.vy = 0;
L0.update(1, P, null); P.update(1); Input.endFrame();
if (captured && captured.to && captured.to.face === 1) ok('踩到门触发 onPortal,目标面=1');
else err('踩门未触发跨面 onPortal');
}
// 3) 存档点带 faceIndex
{
const gridC = ['#'.repeat(W), '#' + 'S' + '.'.repeat(W - 3) + '#', '#' + 'G' + '.'.repeat(W - 3) + '#', '#X' + '.'.repeat(W - 3) + '#'];
const LC = new Level({ name: 'C', type: 'custom', width: W, grid: gridC, doors: [] }); LC.faceIndex = 2;
const P = new Player(LC);
const g = LC.entities.find(e => e.type === 'save');
P.x = g.x; P.y = g.y;
LC.update(1, P, null); P.update(1); Input.endFrame();
if (P.savePoint && P.savePoint.face === 2) ok('存档点记录 face=2');
else err('存档点未带 face,得到 ' + (P.savePoint && P.savePoint.face));
}
// 4) 兼容:无 doors 的内置风格关卡,门仍走原有 pair 逻辑(to 为 null)
{
const gridD = ['#'.repeat(W), '#' + 'S' + '.'.repeat(W - 3) + '#', '#' + '.'.repeat(4) + 'W' + '.'.repeat(W - 6) + '#', '#'.repeat(W), '#' + '.'.repeat(4) + 'W' + '.'.repeat(W - 6) + '#', '#X' + '.'.repeat(W - 3) + '#'];
const LD = new Level({ name: 'D', type: 'jump', width: W, grid: gridD });
const ws = LD.entities.filter(e => e.type === 'warp');
if (ws.length === 2 && ws[0].to === null && ws[1].to === null) ok('无 doors 时门 to=null(走原有 pair 逻辑,不退化)');
else err('无 doors 时门目标异常: ' + JSON.stringify(ws.map(w => w.to)));
}
console.log(fail ? `\n❌ ${fail} 项失败` : '\n✅ 多面地图/传送门 全部通过');
process.exit(fail ? 1 : 0);
-31
View File
@@ -1,31 +0,0 @@
// 诊断:打印可达站立点范围(按行),找出断连位置
// 用法: node reach-diag.mjs <levelId>
import { setupGlobals, reach, TILE } from './lib-reach.mjs';
setupGlobals();
const { LEVELS } = await import('../js/levels/index.js');
const { Level } = await import('../js/level.js');
const id = process.argv[2] || 'j6';
const d = LEVELS.find(l => l.id === id);
const L = new Level(d);
const byRow = new Map();
const DJSET = (process.env.DJ || '0,6,12,18,24,30').split(',').map(Number);
const RELSET = (process.env.REL || '0,4,5,6,8,14').split(',').map(Number);
const r = reach(L, {
DJSET, RELSET,
onState: s => {
const row = Math.round(s.fy / TILE);
const cur = byRow.get(row) || { max: -1, min: 1e9 };
byRow.set(row, { max: Math.max(cur.max, s.fx), min: Math.min(cur.min, s.fx) });
},
});
console.log(`${id} ${d.name} ${L.w}x${L.h} 状态=${r.states}${r.err ? ' ERR:' + r.err : ''}`);
[...byRow.keys()].sort((a, b) => b - a).forEach(row => {
const v = byRow.get(row);
console.log(` row ${String(row).padStart(2)} (y=${row * TILE}): x ${(v.min / TILE).toFixed(1)} .. ${(v.max / TILE).toFixed(1)} 格`);
});
for (const e of L.entities) if (e.type === 'goal' || e.type === 'save')
console.log(` ${e.type}#${e.no ?? ''} at col ${e.tx}, row ${e.ty}`);
-31
View File
@@ -1,31 +0,0 @@
// 诊断:每个存档点的放置环境(脚下是什么 / 重生点是否致命 / 是否在路径上)
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
globalThis.addEventListener = addL;
globalThis.window = { addEventListener: addL };
const { LEVELS } = await import('../js/levels/index.js');
const { Level } = await import('../js/level.js');
const { Player } = await import('../js/player.js');
const { Input } = await import('../js/input.js');
const { T } = await import('../js/level.js');
const NAME = { 0: '空', 1: '块', 2: '草', 3: '砖', 4: '金属', 5: '冰', 6: '水', 7: '单向', 8: '即死', 9: '消失', 10: '上刺', 11: '下刺', 12: '左刺', 13: '右刺' };
for (const d of LEVELS) {
const L = new Level(d);
const P = new Player(L);
const saves = L.entities.filter(e => e.type === 'save');
if (!saves.length) { console.log(`${d.id.padEnd(3)} ${d.name.padEnd(16)} ⚠ 无存档点`); continue; }
const lines = [];
for (const s of saves) {
const below = L.tileAt(s.tx, s.ty + 1);
const above = L.tileAt(s.tx, s.ty - 1);
// 重生点
const rx = s.x + s.w / 2 - P.w / 2, ry = s.y + s.h - P.h;
const deadly = L.deadlyRect(rx, ry, P.w, P.h);
const solid = L.solidRect(rx, ry, P.w, P.h);
lines.push(`(${s.tx},${s.ty}) 脚=${NAME[below] || below} 头=${NAME[above] || above}${deadly ? ' ❌重生即死' : ''}${solid ? ' ❌卡墙里' : ''}`);
}
console.log(`${d.id.padEnd(3)} ${d.name.padEnd(16)} ${saves.length} 个: ${lines.join(' | ')}`);
}
-59
View File
@@ -1,59 +0,0 @@
// 存档点专项测试:触碰 -> 激活 -> 死亡 -> 回到存档点
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
globalThis.addEventListener = addL;
globalThis.window = { addEventListener: addL };
const key = (type, code) => (handlers[type] || []).forEach(fn => fn({ code, preventDefault() { }, repeat: false }));
const down = c => key('keydown', c);
const up = c => key('keyup', c);
const { LEVELS } = await import('../js/levels/index.js');
const { Level } = await import('../js/level.js');
const { Player } = await import('../js/player.js');
const { Input } = await import('../js/input.js');
let fail = 0;
const err = m => { console.log(' ✗ ' + m); fail++; };
for (const d of LEVELS) {
const L = new Level(d);
const P = new Player(L);
const saves = L.entities.filter(e => e.type === 'save');
if (!saves.length) { console.log(` ${d.id} 无存档点`); continue; }
// 依次把玩家瞬移到每个存档点,检查是否激活
for (const s of saves) {
P.x = s.x + s.w / 2 - P.w / 2;
P.y = s.y + s.h - P.h;
P.vx = 0; P.vy = 0; P.dead = false; P.alive = true;
L.update(1, P, null);
P.update(1);
Input.endFrame();
if (!s.active) err(`${d.id} 存档点(${s.tx},${s.ty}) 触碰后未激活`);
if (!P.savePoint) err(`${d.id} 存档点(${s.tx},${s.ty}) 未写入 player.savePoint`);
}
// 死亡后应回到最后一个存档点
const last = saves[saves.length - 1];
const want = { x: last.x + last.w / 2 - P.w / 2, y: last.y + last.h - P.h };
const spawn = L.spawn;
P.kill(); // 死亡后不再自动重生(新语义:定格等待手动重生)
P.respawn(); // 模拟玩家按 R 手动重生,应回到最后一个存档点
for (let i = 0; i < 45; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
const dx = Math.abs(P.x - want.x), dy = Math.abs(P.y - want.y);
const backToStart = Math.abs(P.x - spawn.x) < 3 && Math.abs(P.y - spawn.y) < 3;
const ok = dx < 2 && dy < 2;
console.log(` ${d.id.padEnd(3)} 存档=${saves.length} 重生=(${P.x.toFixed(0)},${P.y.toFixed(0)}) 期望=(${want.x.toFixed(0)},${want.y.toFixed(0)}) ${ok ? 'OK' : (backToStart ? '❌回到了起点' : '❌位置不符')}`);
if (!ok) err(`${d.id} 死亡后未回到存档点`);
// 重生后是否立刻死亡
const alive0 = P.alive;
for (let i = 0; i < 20; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
if (alive0 && !P.alive) err(`${d.id} 在存档点重生后立刻死亡`);
// 存档点状态是否在软重置后保留
if (!last.active) err(`${d.id} 软重置后存档点激活状态丢失`);
}
console.log(fail ? `\n❌ ${fail} 项失败` : '\n✅ 存档点全部正常');
process.exit(fail ? 1 : 0);
-223
View File
@@ -1,223 +0,0 @@
// 设置模块单元测试:默认布局 / 应用定位 / 进入 / 确定保存 / 恢复默认
const noop = () => { };
class El {
constructor(tag = 'DIV') {
this.tagName = tag.toUpperCase();
this.value = '';
this.textContent = '';
this._html = '';
this._classes = new Set();
this._style = {};
this.dataset = {};
this.children = [];
this._ev = {};
this.parent = null;
const self = this;
this.classList = {
add: c => self._classes.add(c),
remove: c => self._classes.delete(c),
contains: c => self._classes.has(c),
toggle: (c, f) => { const on = f === undefined ? !self._classes.has(c) : f; on ? self._classes.add(c) : self._classes.delete(c); return on; },
};
}
get style() { return this._style; }
set className(v) { this._classes = new Set(String(v).split(/\s+/).filter(Boolean)); }
get className() { return [...this._classes].join(' '); }
set innerHTML(v) { this._html = v; if (v === '') this.children = []; }
get innerHTML() { return this._html; }
appendChild(c) { c.parent = this; this.children.push(c); return c; }
remove() { if (this.parent) { const i = this.parent.children.indexOf(this); if (i >= 0) this.parent.children.splice(i, 1); } }
querySelector() { return new El(); }
querySelectorAll(sel) { return sel === '.tc-btn' ? this.children.filter(c => c._classes.has('tc-btn')) : []; }
closest(sel) { const cls = sel.replace(/^\./, ''); let n = this; while (n) { if (n._classes && n._classes.has(cls)) return n; n = n.parent; } return null; }
addEventListener(type, fn) { (this._ev[type] ||= []).push(fn); }
removeEventListener() { }
dispatch(type, ev) { (this._ev[type] || []).forEach(fn => fn(ev)); }
setPointerCapture() { }
releasePointerCapture() { }
getBoundingClientRect() { return { left: 0, top: 0, width: 800, height: 608 }; }
}
const byId = new Map();
const touchMap = new Map();
const docListeners = {};
globalThis.localStorage = {
_d: {},
getItem(k) { return k in this._d ? this._d[k] : null; },
setItem(k, v) { this._d[k] = String(v); },
removeItem(k) { delete this._d[k]; },
};
globalThis.document = {
getElementById(id) { if (!byId.has(id)) byId.set(id, new El()); return byId.get(id); },
createElement(t) { return new El(t); },
querySelector(sel) {
const m = sel.match(/data-touch="([^"]+)"/);
if (m) { const k = m[1]; if (!touchMap.has(k)) touchMap.set(k, new El()); return touchMap.get(k); }
return new El();
},
querySelectorAll() { return []; },
addEventListener(type, fn) { (docListeners[type] ||= []).push(fn); },
removeEventListener() { },
dispatch(type, ev) { (docListeners[type] || []).forEach(fn => fn(ev)); },
};
globalThis.window = { addEventListener: noop, removeEventListener: noop };
globalThis.addEventListener = (t, fn) => { (docListeners[t] ||= []).push(fn); };
globalThis.removeEventListener = noop;
globalThis.confirm = () => true;
let fail = 0;
const ok = (c, m) => { console.log((c ? ' ✔ ' : ' ✗ ') + m); if (!c) fail++; };
const { Save } = await import('../js/save.js');
const { Settings } = await import('../js/settings.js');
const { applyBindings, defaultBindings } = await import('../js/input.js');
console.log('=== input 键位 API ===');
ok(defaultBindings().left.includes('ArrowLeft'), 'defaultBindings 含出厂左移键');
applyBindings({ left: ['KeyA'], jump: ['Space'] });
ok(true, 'applyBindings 调用不报错');
console.log('=== 初始化 / 默认布局(PC 模式)===');
Settings.init(false);
const s0 = Save.data.settings;
ok(!!s0 && !!s0.touch, '存档含 settings.touch');
const keys = ['left', 'right', 'jump', 'shoot', 'down', 'pause', 'suicide'];
ok(keys.every(k => s0.touch[k]), '7 个手柄键都有默认布局');
ok(s0.touch.left.cx === 12 && s0.touch.jump.size === 94, '默认坐标/尺寸正确');
ok(byId.get('title-user').textContent === '玩家:无名氏', '标题默认显示「无名氏」');
console.log('=== apply 把布局写到真实手柄 ===');
const L = touchMap.get('left'), J = touchMap.get('jump');
ok(L._style.left === '12%' && L._style.top === '80%' && L._style.width === '62px', 'left 按钮定位/尺寸已应用');
ok(J._style.width === '94px' && J._style.left === '88%', 'jump 按钮定位/尺寸已应用');
console.log('=== 进入 / 确定 保存用户名与布局 ===');
Settings.enter();
byId.get('set-username').value = '阿测玩家';
Settings.confirm();
ok(Save.data.settings.username === '阿测玩家', '用户名已保存到存档');
ok(byId.get('title-user').textContent === '玩家:阿测玩家', '标题用户名已更新');
ok(localStorage.getItem('iwbtc_fan_v1').includes('阿测玩家'), '已写入 localStorage');
console.log('=== 恢复默认 ===');
byId.get('set-username').value = '随便';
Settings.reset();
ok(Settings._state().username === '', '恢复默认后用户名为空');
ok(Settings._state().touch.jump.size === 94 && Settings._state().touch.left.cx === 12, '恢复默认后布局回到出厂值');
// 恢复默认后必须再点确定才落盘
ok(Save.data.settings.username === '阿测玩家', '恢复默认未自动落盘(需点确定)');
Settings.confirm();
ok(Save.data.settings.username === '', '点确定后空用户名落盘');
console.log('=== 布局越长越界会被限制(place 约束在交互层,这里校验默认在合法区间)===');
ok(keys.every(k => { const p = Save.data.settings.touch[k]; return p.cx >= 4 && p.cx <= 96 && p.size >= 24 && p.size <= 160; }), '默认布局均在合法范围');
console.log('=== UI SCREENS 注册(防止设置屏永远不显示)===');
const uiSrc = (await import('node:fs')).readFileSync(new URL('../js/ui.js', import.meta.url), 'utf8');
ok(/SCREENS\s*=\s*\[[^\]]*'scr-settings'/.test(uiSrc), 'scr-settings 已加入 SCREENS 列表');
ok(/data-act="settings"/.test((await import('node:fs')).readFileSync(new URL('../index.html', import.meta.url), 'utf8')), '标题菜单有「设置」入口');
console.log('=== 模拟拖动 / 缩放(防「按下即跳角」)===');
Settings.enter(); // 重新同步工作副本
const ev = (t, x, y) => ({ target: t, clientX: x, clientY: y, pointerId: 1, preventDefault: noop, stopPropagation: noop });
const left = Settings._els().left;
const handle = left.children.find(c => c._classes.has('tc-rsz')) || left.children[0];
const wt = Settings._state().touch;
const cx0 = wt.left.cx, cy0 = wt.left.cy;
// 在按钮中心 (96,486) 按下,向右拖 40px(预览宽 800 → +5%)
left.dispatch('pointerdown', ev(left, 96, 486));
left.dispatch('pointermove', ev(left, 136, 486));
ok(Math.abs(wt.left.cx - (cx0 + 5)) < 0.01 && wt.left.cy === cy0, '拖动:cx 按位移增量变化,无跳角');
// 再拖回原处
left.dispatch('pointermove', ev(left, 96, 486));
ok(Math.abs(wt.left.cx - cx0) < 0.01, '拖动:逆向位移可精确归位');
left.dispatch('pointerup', ev(left, 96, 486));
// 缩放手柄:中心 (96,486),起点距中心 ~56,拉远到 ~113 → 尺寸翻倍
const size0 = wt.left.size;
handle.dispatch('pointerdown', ev(handle, 136, 526));
handle.dispatch('pointermove', ev(handle, 176, 566));
ok(wt.left.size > size0 && wt.left.size <= 160, `缩放:尺寸随距离放大(${size0}→${wt.left.size.toFixed(0)})`);
handle.dispatch('pointerup', ev(handle, 176, 566));
ok(wt.left.cx === cx0 && wt.left.cy === cy0, '缩放:不改变按钮位置');
console.log('=== 设备模式:PC 显示键盘映射,移动端显示手柄 ===');
Settings.init(true); // isTouch=true → 移动端
Settings.enter();
ok(byId.get('set-keys-row')._classes.has('hidden'), '移动端:键盘映射面板隐藏');
ok(!byId.get('set-touch-row')._classes.has('hidden'), '移动端:手柄面板显示');
Settings.init(false); // PC
Settings.enter();
ok(!byId.get('set-keys-row')._classes.has('hidden'), 'PC:键盘映射面板显示');
ok(byId.get('set-touch-row')._classes.has('hidden'), 'PC:手柄面板隐藏');
console.log('=== 键盘重绑:点击按键 → 按新键 ===');
Settings.enter();
const keysBox = byId.get('set-keys');
const firstRow = keysBox.children[0]; // 左移行
const kkeys = firstRow.children[1]; // kkeys 容器
const chip = kkeys.children[0]; // 第一个按键 chip(left[0])
ok(chip.dataset.kact === 'left' && String(chip.dataset.kidx) === '0', '首行首键为 left[0]');
keysBox.dispatch('click', { target: chip }); // 进入重绑监听
document.dispatch('keydown', { code: 'KeyB', preventDefault: noop }); // KeyB 未被任何动作占用
ok(Settings._state().keys.left[0] === 'KeyB', '重绑:left[0] 变为空闲键 KeyB');
// 添加第二个键
const before = Settings._state().keys.left.length;
const addBtn = kkeys.children[kkeys.children.length - 1]; // +添加
keysBox.dispatch('click', { target: addBtn });
document.dispatch('keydown', { code: 'KeyF', preventDefault: noop }); // KeyF 空闲
ok(Settings._state().keys.left.length === before + 1 && Settings._state().keys.left.includes('KeyF'), '添加:left 多绑定了一个键 KeyF');
// 删除第一个键(即刚重绑的 KeyB)
const chip1 = kkeys.children[0];
const rm = chip1.children.find(c => c._classes.has('krm'));
keysBox.dispatch('click', { target: rm });
ok(!Settings._state().keys.left.includes('KeyB') && Settings._state().keys.left.includes('KeyF'), '删除:left[0] 被移除(KeyB 消失,KeyF 仍在)');
// 确定后落盘
Settings.confirm();
ok(Save.data.settings.keys.left.includes('KeyF'), '确定:键位变更已保存到存档');
console.log('=== 键盘冲突检测 ===');
Settings.reset(); // 出厂:left=[ArrowLeft,KeyA]
Settings.enter();
const kb = byId.get('set-keys');
const jumpRow = kb.children[3]; // 跳跃行(KEY_ACTIONS 第 4 项,down 已移除)
const jumpKeys = jumpRow.children[1];
const jumpChip = jumpKeys.children[0];
ok(jumpChip.dataset.kact === 'jump', '找到跳跃行的按键');
kb.dispatch('click', { target: jumpChip }); // 监听跳跃键
document.dispatch('keydown', { code: 'KeyA', preventDefault: noop }); // KeyA 已被左移占用
const st2 = Settings._state();
ok(!st2.keys.jump.includes('KeyA'), '冲突:jump 不会绑定到被【左移】占用的 KeyA');
ok(Settings._conflict() && Settings._conflict().act === 'jump' && Settings._conflict().label === '左移', '冲突:记录到被【左移】占用');
ok(kb.children[0] && kb.children[0]._classes.has('kwarb'), '冲突:面板顶部显示警告横幅');
document.dispatch('keydown', { code: 'KeyK', preventDefault: noop }); // 换空闲键
ok(st2.keys.jump[0] === 'KeyK', '冲突解除:改用空闲键 KeyK 成功');
ok(Settings._conflict() === null, '冲突:解除后警告清空');
// 同一动作内重复键也算冲突(jump 已含 KeyK,再绑到第二个槽应被拒)
kb.dispatch('click', { target: jumpKeys.children[jumpKeys.children.length - 1] }); // +添加
document.dispatch('keydown', { code: 'KeyK', preventDefault: noop });
ok(st2.keys.jump.filter(c => c === 'KeyK').length === 1, '冲突:同一动作内重复键不重复写入');
// Esc 取消监听
kb.dispatch('click', { target: jumpChip });
document.dispatch('keydown', { code: 'Escape', preventDefault: noop });
ok(Settings._listening() === null && Settings._conflict() === null, 'Esc:退出监听且无残留警告');
// 系统动作(菜单 KeyQ)也算冲突
kb.dispatch('click', { target: jumpChip });
document.dispatch('keydown', { code: 'KeyQ', preventDefault: noop });
ok(Settings._conflict() && Settings._conflict().label === '菜单', '冲突:KeyQ 已被系统「菜单」占用');
console.log('=== 恢复默认:键位回到出厂 ===');
Settings.reset();
ok(Settings._state().keys.left.includes('ArrowLeft'), '恢复默认:left 含出厂 ArrowLeft');
ok(Settings._state().keys.jump.includes('ShiftLeft'), '恢复默认:jump 含出厂 ShiftLeft');
console.log('=== 回归:main.js 中「确定」应返回上一页 ===');
const mainSrc = (await import('node:fs')).readFileSync(new URL('../js/main.js', import.meta.url), 'utf8');
ok(/settings:\s*\(\)\s*=>\s*\{\s*settingsFrom\s*=\s*UI\.cur/.test(mainSrc), '进入设置前记录来源界面');
ok(/'set-ok':\s*\(\)\s*=>\s*\{\s*Settings\.confirm\(\);\s*UI\.show\(settingsFrom\)/.test(mainSrc), '确定后 UI.show(settingsFrom) 返回上一页');
ok(/Settings\.init\(useTouch\)/.test(mainSrc), 'Settings.init 传入设备检测结果');
console.log(fail === 0 ? '\n✅ settings 全部断言通过' : `\n❌ 失败 ${fail} 项`);
process.exit(fail === 0 ? 0 : 1);
-105
View File
@@ -1,105 +0,0 @@
// 无头冒烟测试:构造全部关卡 + 模拟输入跑物理
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
globalThis.addEventListener = addL;
globalThis.window = { addEventListener: addL };
const key = (type, code) => (handlers[type] || []).forEach(fn => fn({ code, preventDefault() { }, repeat: false }));
const down = c => key('keydown', c);
const up = c => key('keyup', c);
const { LEVELS } = await import('../js/levels/index.js');
const { Level } = await import('../js/level.js');
const { Player } = await import('./player.js'.replace('./player.js', '../js/player.js'));
const { Input } = await import('../js/input.js');
let fail = 0;
const err = m => { console.log(' ✗ ' + m); fail++; };
console.log('=== 关卡构造 ===');
for (const d of LEVELS) {
try {
const L = new Level(d);
const P = new Player(L);
// 静止 60 帧
for (let i = 0; i < 60; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
const grounded = P.grounded, alive = P.alive;
console.log(` ${d.id.padEnd(3)} ${String(d.name).padEnd(16)} 实体=${String(L.entities.length).padStart(3)} boss=${L.bosses.length} 平台=${L.movers.length} 站住=${grounded ? 'Y' : 'N'} 存活=${alive ? 'Y' : 'N'}`);
if (!grounded && d.type !== 'boss') err(`${d.id} 出生点悬空(60帧后未着地)`);
if (!alive) err(`${d.id} 出生即死`);
// 模拟:向右跑 + 反复跳跃 400 帧
down('ArrowRight');
for (let i = 0; i < 400; i++) {
if (i % 40 === 0) down('ShiftLeft');
if (i % 40 === 8) up('ShiftLeft');
if (i % 40 === 12) { down('ShiftLeft'); }
if (i % 40 === 16) up('ShiftLeft');
L.update(1, P, null);
for (const b of L.bosses) b.update(1, P);
P.update(1);
Input.endFrame();
}
up('ArrowRight'); up('ShiftLeft');
const moved = P.x;
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) err(`${d.id} 坐标 NaN`);
if (Math.abs(P.vx) > 20 || Math.abs(P.vy) > 40) err(`${d.id} 速度异常 vx=${P.vx} vy=${P.vy}`);
console.log(` → 400帧后 x=${moved.toFixed(0)} y=${P.y.toFixed(0)} 死亡=${G_DEATHS(P)}`);
} catch (e) {
err(`${d.id} 运行异常: ${e.message}\n${e.stack?.split('\n')[1] || ''}`);
}
}
function G_DEATHS() { return ''; }
console.log('\n=== 死亡与手动重生 ===');
{
const L = new Level(LEVELS.find(l => l.id === 'j1'));
const P = new Player(L);
let deaths = 0;
P.onDeath = () => deaths++;
for (let i = 0; i < 30; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
P.kill();
// 新语义(2026-10-24):死亡后不自动重生,定格等待玩家手动按重生键
for (let i = 0; i < 80; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
if (P.alive) err('死亡后未等待手动重生(自动复活了)');
else console.log(` 死亡计数=${deaths} 80帧后仍定格等待手动重生 ✓(deadT=${P.deadT})`);
P.respawn();
for (let i = 0; i < 10; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
if (!P.alive) err('手动重生失败');
else console.log(` 手动重生后 alive ✓ 位置=(${P.x.toFixed(0)},${P.y.toFixed(0)})`);
if (deaths !== 1) err('死亡回调未触发');
}
console.log('\n=== 射击与 BOSS ===');
{
const L = new Level(LEVELS.find(l => l.id === 'b1'));
const P = new Player(L);
const boss = L.bosses[0];
const hp0 = boss.hp;
P.hasGun = true;
for (let i = 0; i < 600; i++) {
if (i % 12 === 0) P.shootCool = 0, P.shoot();
L.update(1, P, null);
for (const b of L.bosses) b.update(1, P);
P.update(1);
Input.endFrame();
if (boss.hp <= 0) break;
}
console.log(` BOSS hp ${hp0} → ${boss.hp} 弹幕数=${L.ebullets.length} 阶段=${boss.phase}`);
if (boss.hp >= hp0) err('子弹没有对 BOSS 造成伤害');
}
console.log('\n=== 坑向事件 ===');
{
const L = new Level(LEVELS.find(l => l.id === 'r4'));
const P = new Player(L);
let died = 0; P.onDeath = () => died++;
// 走到传送门 (r4: col 44, row 17)
P.x = 44 * 32; P.y = 17 * 32;
for (let i = 0; i < 4; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
console.log(` 传送门:玩家被送到 x=${P.x.toFixed(0)} (起点 x=${L.spawn.x})`);
if (Math.abs(P.x - L.spawn.x) > 4) err('传送门未送回起点');
}
console.log(fail ? `\n❌ ${fail} 项失败` : '\n✅ 全部通过');
process.exit(fail ? 1 : 0);
-37
View File
@@ -1,37 +0,0 @@
// 可达性求解器:验证每关终点 / 存档点 / 苹果能否到达
// DJ=0,12,24 收紧二段跳时机;REL=0 禁用短跳 —— 检验是否只有"某一帧"能过
import { setupGlobals, reach, makeTargets } from './lib-reach.mjs';
setupGlobals();
const { LEVELS } = await import('../js/levels/index.js');
const DJSET = (process.env.DJ || '0,6,12,18,24,30').split(',').map(Number);
const RELSET = (process.env.REL || '0,4,5,6,8,14').split(',').map(Number);
let fail = 0;
console.log(`=== 可达性求解 (DJ=${DJSET} REL=${RELSET}) ===`);
for (const d of LEVELS) {
if (d.type === 'boss') { console.log(` ${d.id.padEnd(3)} ${d.name.padEnd(17)} (BOSS 关跳过)`); continue; }
let r;
try {
const { Level } = await import('../js/level.js');
const L = new Level(d);
const targets = makeTargets(L);
r = reach(L, { DJSET, RELSET, targets });
r.targets = targets;
} catch (e) { console.log(` ✗ ${d.id} 求解异常: ${e.message}`); fail++; continue; }
if (r.err) { console.log(` ✗ ${d.id.padEnd(3)} ${d.name.padEnd(17)} ${r.err}`); fail++; continue; }
const goal = r.targets.find(t => t.type === 'goal');
const saves = r.targets.filter(t => t.type === 'save');
const apples = r.targets.filter(t => t.type === 'apple');
const okSave = saves.filter(s => s.hit).length;
const okApple = apples.filter(a => a.hit).length;
const badSave = saves.filter(s => !s.hit).map(s => `#${s.no ?? s.idx}`);
const flag = goal && goal.hit ? ' ' : '✗';
if (!goal || !goal.hit) fail++;
if (badSave.length) fail++;
console.log(` ${flag} ${d.id.padEnd(3)} ${d.name.padEnd(17)} 终点=${goal ? (goal.hit ? 'OK' : '不可达') : '无'} 存档=${okSave}/${saves.length}${badSave.length ? ' 缺:' + badSave.join(',') : ''}${apples.length ? ` 苹果=${okApple}/${apples.length}` : ''} (状态${r.states})`);
}
console.log(fail ? `\n❌ ${fail} 项不可达` : '\n✅ 所有关卡终点与存档点均可达');
process.exit(fail ? 1 : 0);
-86
View File
@@ -1,86 +0,0 @@
// 单三角形刺:视觉与碰撞形状完全对应的单元测试
// 验证 spikeTri 顶点、triRectOverlap 的正确性,以及 deadlyRect 对刺的判定。
const noop = () => { };
const ctx2d = new Proxy({
canvas: { width: 800, height: 608 },
measureText: () => ({ width: 12 }),
createLinearGradient: () => ({ addColorStop: noop }),
createRadialGradient: () => ({ addColorStop: noop }),
getImageData: () => ({ data: [] }),
}, { get(t, k) { return k in t ? t[k] : noop; }, set(t, k, v) { t[k] = v; return true; } });
globalThis.location = { search: '', href: 'http://localhost/', pathname: '/', reload: noop };
Object.defineProperty(globalThis, 'navigator', { value: { maxTouchPoints: 0, userAgent: 'node' }, configurable: true });
globalThis.matchMedia = q => ({ matches: false, media: q, addEventListener: noop, removeEventListener: noop });
globalThis.window = { addEventListener: noop, removeEventListener: noop, AudioContext: null, webkitAudioContext: null, innerWidth: 1280, innerHeight: 800, location: globalThis.location, navigator: globalThis.navigator, matchMedia: globalThis.matchMedia };
globalThis.document = { getElementById: () => ({ getContext: () => ctx2d, style: {}, classList: { add: noop, remove: noop, toggle: noop }, addEventListener: noop, appendChild: noop, querySelector: () => ({ getContext: () => ctx2d }), querySelectorAll: () => [] }), querySelector: () => ({ getContext: () => ctx2d }), querySelectorAll: () => [], createElement: () => ({ getContext: () => ctx2d, style: {}, classList: { add: noop, remove: noop } }), addEventListener: noop, body: {} };
globalThis.localStorage = { _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; } };
globalThis.confirm = () => true;
globalThis.alert = noop;
const { Level, T, spikeTri, triRectOverlap } = await import('../js/level.js');
const { TILE } = await import('../js/util.js');
let fail = 0;
const ok = (c, m) => { console.log((c ? ' ✔ ' : ' ✗ ') + m); if (!c) fail++; };
const near = (a, b, e = 1e-6) => Math.abs(a - b) < e;
console.log('=== spikeTri 顶点(与 drawSpike 一致)===');
const s = TILE;
{
const tr = spikeTri(T.SPIKE_U, 0, 0);
ok(near(tr.ax, s / 2) && near(tr.ay, 0) && near(tr.bx, 0) && near(tr.by, s) && near(tr.cx, s) && near(tr.cy, s), 'SPIKE_U:尖在顶中,底在底部');
const tr2 = spikeTri(T.SPIKE_D, 0, 0);
ok(near(tr2.ax, s / 2) && near(tr2.ay, s) && near(tr2.bx, 0) && near(tr2.by, 0), 'SPIKE_D:尖在底中,底在顶部');
const tr3 = spikeTri(T.SPIKE_L, 0, 0);
ok(near(tr3.ax, 0) && near(tr3.ay, s / 2) && near(tr3.bx, s) && near(tr3.by, 0) && near(tr3.cx, s) && near(tr3.cy, s), 'SPIKE_L:尖在左中,底在右侧');
const tr4 = spikeTri(T.SPIKE_R, 0, 0);
ok(near(tr4.ax, s) && near(tr4.ay, s / 2) && near(tr4.bx, 0) && near(tr4.by, 0), 'SPIKE_R:尖在右中,底在左侧');
}
console.log('=== triRectOverlap 基本几何 ===');
{
// 上刺三角形:A(16,0) B(0,32) C(32,32)(TILE=32)
const A = spikeTri(T.SPIKE_U, 0, 0);
// 完全在三角形内部的矩形
ok(triRectOverlap(A.ax, A.ay, A.bx, A.by, A.cx, A.cy, 14, 20, 4, 4), '三角形内部矩形 → 相交');
// 在三角形“上方窄尖之外”的矩形(顶部左侧,AABB 内但三角外)
ok(!triRectOverlap(A.ax, A.ay, A.bx, A.by, A.cx, A.cy, 1, 1, 4, 4), '尖顶外侧矩形(AABB 内、三角外)→ 不相交');
// 完全在三角形之外(远离)
ok(!triRectOverlap(A.ax, A.ay, A.bx, A.by, A.cx, A.cy, 100, 100, 4, 4), '远处矩形 → 不相交');
// AABB 都不重叠
ok(!triRectOverlap(A.ax, A.ay, A.bx, A.by, A.cx, A.cy, 50, 0, 4, 4), 'AABB 不重叠 → 不相交');
// 底边左角(上刺底部很宽)——应当相交
ok(triRectOverlap(A.ax, A.ay, A.bx, A.by, A.cx, A.cy, 1, 28, 4, 4), '上刺底左角矩形 → 相交(形状精确:底宽尖窄)');
}
console.log('=== deadlyRect 集成(上刺占一格)===');
{
const data = { name: 't', width: 3, grid: ['###', '#^#', '###'] };
const L = new Level(data);
ok(L.tiles[1][1] === T.SPIKE_U, '网格解析:中心格子为 SPIKE_U');
const cx = 1 * s, cy = 1 * s; // 刺格像素 [32,32]-[64,64]
// 顶部左侧(尖窄处,三角外)→ 不致死
ok(!L.deadlyRect(cx + 1, cy + 1, 4, 4), '上刺尖顶外侧的碰撞盒 → 不致死');
// 底部中央(基底宽处)→ 致死
ok(L.deadlyRect(cx + 13, cy + 26, 8, 4), '上刺底部中央的碰撞盒 → 致死');
// 底左角 → 致死
ok(L.deadlyRect(cx + 1, cy + 26, 6, 4), '上刺底左角的碰撞盒 → 致死(形状精确)');
// 相邻空格(格外)→ 不致死
ok(!L.deadlyRect(0, 0, 11, 21), '完全在空地上的碰撞盒 → 不致死');
}
console.log('=== 形状精确性对比:旧矩形近似会把底左角判为安全,现在判为致死 ===');
{
// 旧 spikeBox 对上刺 x+6..x+10, y+8.. :底左角 (cx+1,cy+26) 落在 x+6 之外,旧逻辑安全;
// 新三角在底部很宽,应致死。下面断言新逻辑与“旧近似矩形”在该点结论相反(证明形状更精确)。
const data = { name: 't', width: 3, grid: ['###', '#^#', '###'] };
const L = new Level(data);
const cx = s, cy = s;
const oldSafe = (cx + 1 < cx + 6); // 旧矩形左界 x+6,该点在其左 → 旧逻辑“安全”
const newDeadly = L.deadlyRect(cx + 1, cy + 26, 3, 3);
ok(oldSafe && newDeadly, '底左角:旧矩形近似判安全,新三角判致死(符合“底宽尖窄”的真实形状)');
}
console.log(fail === 0 ? '\n✅ 单三角形刺(视觉=碰撞)全部断言通过' : `\n❌ 失败 ${fail} 项`);
process.exit(fail === 0 ? 0 : 1);