From a93e06f60cf9aba703c39b192ea0e40c05b923a4 Mon Sep 17 00:00:00 2001 From: CancerAi Date: Sun, 25 Oct 2026 16:03:05 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E5=90=8E=E7=9A=84=E5=9F=BA=E7=A1=80UI=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .workbuddy/memory/2026-10-24.md | 10 + .workbuddy/memory/2026-10-25.md | 86 ++ .workbuddy/memory/MEMORY.md | 6 +- .../src/main/res/values-night-v28/styles.xml | 15 + .../app/src/main/res/values-night/styles.xml | 3 + .../app/src/main/res/values-v28/styles.xml | 16 + .../app/src/main/res/values/styles.xml | 3 + app/ios/Runner/Info.plist | 9 +- app/lib/main.dart | 17 +- app/lib/src/app_root.dart | 255 +++-- app/lib/src/game/level.dart | 10 +- app/lib/src/game/world.dart | 107 +- app/lib/src/ui/editor_panel.dart | 931 ++++++++++++------ app/lib/src/ui/game_canvas.dart | 85 +- app/lib/src/ui/screens.dart | 919 ++++++++--------- app/lib/src/ui/touch_controls.dart | 94 +- app/lib/src/ui/touch_editor.dart | 299 ++++++ app/lib/src/ui/touch_layout.dart | 63 +- app/test/title_render_test.dart | 448 +++++++-- tmp/css/style.css | 324 ------ tmp/dump_levels.mjs | 25 - tmp/index.html | 207 ---- tmp/js/audio.js | 71 -- tmp/js/boss.js | 325 ------ tmp/js/camera.js | 90 -- tmp/js/editor.js | 555 ----------- tmp/js/input.js | 92 -- tmp/js/level.js | 712 -------------- tmp/js/levels/boss.js | 63 -- tmp/js/levels/endurance.js | 131 --- tmp/js/levels/index.js | 26 - tmp/js/levels/jump.js | 519 ---------- tmp/js/levels/kit.js | 72 -- tmp/js/levels/needle.js | 79 -- tmp/js/levels/troll.js | 91 -- tmp/js/levels/tutorial.js | 47 - tmp/js/main.js | 352 ------- tmp/js/particles.js | 44 - tmp/js/player.js | 396 -------- tmp/js/save.js | 59 -- tmp/js/settings.js | 335 ------- tmp/js/sprites.js | 364 ------- tmp/js/touch.js | 98 -- tmp/js/ui.js | 174 ---- tmp/js/util.js | 45 - tmp/levels_dump.json | 1 - tmp/mapview.html | 108 -- tmp/package.json | 1 - tmp/spike-preview.html | 37 - tmp/tools/bot.mjs | 40 - tmp/tools/check.mjs | 55 -- tmp/tools/death-test.mjs | 272 ----- tmp/tools/dj-test.mjs | 95 -- tmp/tools/dom-smoke.mjs | 94 -- tmp/tools/editor-pick-test.mjs | 136 --- tmp/tools/editor-wysiwyg-test.mjs | 97 -- tmp/tools/ice-test.mjs | 103 -- tmp/tools/jumpdist.mjs | 54 - tmp/tools/key-test.mjs | 120 --- tmp/tools/lib-reach.mjs | 169 ---- tmp/tools/map-test.mjs | 71 -- tmp/tools/reach-diag.mjs | 31 - tmp/tools/save-diag.mjs | 31 - tmp/tools/save-test.mjs | 59 -- tmp/tools/settings-test.mjs | 223 ----- tmp/tools/smoke.mjs | 105 -- tmp/tools/solve.mjs | 37 - tmp/tools/spike-test.mjs | 86 -- 68 files changed, 2293 insertions(+), 8404 deletions(-) create mode 100644 .workbuddy/memory/2026-10-25.md create mode 100644 app/android/app/src/main/res/values-night-v28/styles.xml create mode 100644 app/android/app/src/main/res/values-v28/styles.xml create mode 100644 app/lib/src/ui/touch_editor.dart delete mode 100644 tmp/css/style.css delete mode 100644 tmp/dump_levels.mjs delete mode 100644 tmp/index.html delete mode 100644 tmp/js/audio.js delete mode 100644 tmp/js/boss.js delete mode 100644 tmp/js/camera.js delete mode 100644 tmp/js/editor.js delete mode 100644 tmp/js/input.js delete mode 100644 tmp/js/level.js delete mode 100644 tmp/js/levels/boss.js delete mode 100644 tmp/js/levels/endurance.js delete mode 100644 tmp/js/levels/index.js delete mode 100644 tmp/js/levels/jump.js delete mode 100644 tmp/js/levels/kit.js delete mode 100644 tmp/js/levels/needle.js delete mode 100644 tmp/js/levels/troll.js delete mode 100644 tmp/js/levels/tutorial.js delete mode 100644 tmp/js/main.js delete mode 100644 tmp/js/particles.js delete mode 100644 tmp/js/player.js delete mode 100644 tmp/js/save.js delete mode 100644 tmp/js/settings.js delete mode 100644 tmp/js/sprites.js delete mode 100644 tmp/js/touch.js delete mode 100644 tmp/js/ui.js delete mode 100644 tmp/js/util.js delete mode 100644 tmp/levels_dump.json delete mode 100644 tmp/mapview.html delete mode 100644 tmp/package.json delete mode 100644 tmp/spike-preview.html delete mode 100644 tmp/tools/bot.mjs delete mode 100644 tmp/tools/check.mjs delete mode 100644 tmp/tools/death-test.mjs delete mode 100644 tmp/tools/dj-test.mjs delete mode 100644 tmp/tools/dom-smoke.mjs delete mode 100644 tmp/tools/editor-pick-test.mjs delete mode 100644 tmp/tools/editor-wysiwyg-test.mjs delete mode 100644 tmp/tools/ice-test.mjs delete mode 100644 tmp/tools/jumpdist.mjs delete mode 100644 tmp/tools/key-test.mjs delete mode 100644 tmp/tools/lib-reach.mjs delete mode 100644 tmp/tools/map-test.mjs delete mode 100644 tmp/tools/reach-diag.mjs delete mode 100644 tmp/tools/save-diag.mjs delete mode 100644 tmp/tools/save-test.mjs delete mode 100644 tmp/tools/settings-test.mjs delete mode 100644 tmp/tools/smoke.mjs delete mode 100644 tmp/tools/solve.mjs delete mode 100644 tmp/tools/spike-test.mjs diff --git a/.workbuddy/memory/2026-10-24.md b/.workbuddy/memory/2026-10-24.md index a5a176f..d29efd4 100644 --- a/.workbuddy/memory/2026-10-24.md +++ b/.workbuddy/memory/2026-10-24.md @@ -178,3 +178,13 @@ - **`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 一致。 - **用户侧交付**:本沙箱无法跑原生构建,需用户在本地 `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 通过**。 diff --git a/.workbuddy/memory/2026-10-25.md b/.workbuddy/memory/2026-10-25.md new file mode 100644 index 0000000..3771871 --- /dev/null +++ b/.workbuddy/memory/2026-10-25.md @@ -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()` 在 `doors` 为 null 时会因 `.firstWhere` 调在 null 上抛 `NoSuchMethodError`,改为 `?? const []`,消除同类崩溃。 +- **回归测试**:新增 `含 warp 但无匹配门的关卡可正常构建(doorId 类型回归)`——构造含 `W` 且 `doors:[]` 的关卡,`Level(data)` 不再抛 TypeError,且 warp 实体的 `doorId` 为 int(回退序号)。 +- 验证:`flutter test` 22/22、`flutter analyze` 0 error。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index f9936bf..3e27c4f 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -62,11 +62,15 @@ ## Flutter 移植版(`app/`,脱胎于本 web 源码) 目标是 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 缩放。 - **输入**:Flutter 按键经 `LogicalKeyboardKey.debugName` 去空格 → 喂给 `Input.onKey()`,得到与 web `KeyboardEvent.code` 完全相同的字符串(`ShiftLeft/KeyZ/KeyR/Backspace/Escape`)。 - **`Positioned` 必须是 `Stack` 的直接子级**(血泪):把它写在 `ValueListenableBuilder.builder` 的返回值里会丢弃定位数据 → 子树横向塌缩为 0 宽 → 表现为"黑屏"。一律写成 `Positioned(child: ValueListenableBuilder(...))`。 - **凡是"某状态决定某子树显隐",该状态必须可监听**:`World.state` 已改由 `ValueNotifier 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 的插件噪声降级为日志。 - **测试**:widget 测试永远覆盖不到 `main()` 的 bootstrap;断言要用 `tester.getSize()` 查**可见尺寸**(`find` 不查可见性)。游戏有常驻 Ticker → **禁用 `pumpAndSettle`**。原生构建需真实机器;本沙箱 Flutter 写不了自己的 `/opt/flutter/bin/cache` 缓存。 - 手感回归请跑 `flutter test`(含 `test/gameplay_test.dart` 的跳初速/重力/松手-guard/死亡重生断言)。 diff --git a/app/android/app/src/main/res/values-night-v28/styles.xml b/app/android/app/src/main/res/values-night-v28/styles.xml new file mode 100644 index 0000000..fdb5699 --- /dev/null +++ b/app/android/app/src/main/res/values-night-v28/styles.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/app/android/app/src/main/res/values-night/styles.xml b/app/android/app/src/main/res/values-night/styles.xml index 06952be..a42f205 100644 --- a/app/android/app/src/main/res/values-night/styles.xml +++ b/app/android/app/src/main/res/values-night/styles.xml @@ -5,6 +5,8 @@ @drawable/launch_background + + true diff --git a/app/android/app/src/main/res/values-v28/styles.xml b/app/android/app/src/main/res/values-v28/styles.xml new file mode 100644 index 0000000..1ff579b --- /dev/null +++ b/app/android/app/src/main/res/values-v28/styles.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/app/android/app/src/main/res/values/styles.xml b/app/android/app/src/main/res/values/styles.xml index cb1ef88..ffea0df 100644 --- a/app/android/app/src/main/res/values/styles.xml +++ b/app/android/app/src/main/res/values/styles.xml @@ -5,6 +5,8 @@ @drawable/launch_background + + true diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist index 0334c69..5c844d8 100644 --- a/app/ios/Runner/Info.plist +++ b/app/ios/Runner/Info.plist @@ -55,16 +55,19 @@ Main UISupportedInterfaceOrientations - UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UISupportedInterfaceOrientations~ipad - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + UIStatusBarHidden + diff --git a/app/lib/main.dart b/app/lib/main.dart index 89df29c..c2feb87 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -8,7 +8,7 @@ import 'package:flutter/foundation.dart' show FlutterErrorDetails, defaultTargetPlatform, TargetPlatform; import 'package:flutter/material.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/app_root.dart' show AppRoot; @@ -36,12 +36,17 @@ Future main() async { ); } -/// 移动端专属的显示适配:锁定横屏。 +/// 移动端专属的显示适配:横屏锁定 + 沉浸式全屏。 /// /// 画面是 800×608 的横向虚拟舞台,横屏下信箱缩放才能拿到可用的可视比例; /// 竖屏时上下留白巨大、画面极小,虚拟手柄也会挤成一团。桌面端保持原样不干预。 /// (安卓另有 AndroidManifest 的 `screenOrientation="sensorLandscape"`, /// 保证冷启动第一帧就是横屏,避免先竖后横的闪烁。) +/// +/// 沉浸式:用 `SystemUiMode.immersiveSticky` 隐藏状态栏与导航栏,让游戏 +/// 铺满整个屏幕,与常规手机游戏一致。相比普通 `immersive`, +/// `immersiveSticky` 在用户从屏幕边缘滑出系统栏后数秒会**自动重新隐藏**, +/// 不会弹出"退出沉浸模式"的提示条,体验更顺。 Future _configureMobile() async { final t = defaultTargetPlatform; if (t != TargetPlatform.android && t != TargetPlatform.iOS) return; @@ -50,8 +55,12 @@ Future _configureMobile() async { DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight, ]); + // 沉浸式全屏:隐藏状态栏与导航栏,游戏铺满整个屏幕。 + await SystemChrome.setEnabledSystemUIMode( + SystemUiMode.immersiveSticky, + ); } catch (e) { - // 某些设备/系统策略不允许锁定方向,属可容忍情况,不影响游戏运行。 - AppError.warn('锁定横屏失败(保持系统默认方向)', '$e'); + // 某些设备/系统策略不允许锁定方向或沉浸,属可容忍情况,不影响游戏运行。 + AppError.warn('移动端显示适配失败(横屏/沉浸)', '$e'); } } diff --git a/app/lib/src/app_root.dart b/app/lib/src/app_root.dart index 8673c0f..d1a3f3b 100644 --- a/app/lib/src/app_root.dart +++ b/app/lib/src/app_root.dart @@ -6,7 +6,7 @@ import 'package:flutter/foundation.dart' show debugPrint, defaultTargetPlatform, TargetPlatform; import 'package:flutter/scheduler.dart' show Ticker; 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/save.dart'; 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/overlays.dart' show HudOverlay; 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/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 { const AppRoot({super.key}); @@ -27,7 +34,7 @@ class AppRoot extends StatefulWidget { } class _AppRootState extends State - with SingleTickerProviderStateMixin + with SingleTickerProviderStateMixin, WidgetsBindingObserver implements GameHost { late World world; late EditorController editor; @@ -58,6 +65,8 @@ class _AppRootState extends State @override void initState() { super.initState(); + // 监听生命周期:切后台再回来时系统栏会重新出现,需重新进入沉浸模式。 + WidgetsBinding.instance.addObserver(this); _useTouch = defaultTargetPlatform == TargetPlatform.android; // 看门狗:初始化迟迟不完成时报出可诊断信息,避免永远停在加载态。 _watchdog = Timer(const Duration(seconds: 12), () { @@ -132,12 +141,25 @@ class _AppRootState extends State @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _watchdog?.cancel(); RawKeyboard.instance.removeListener(_rawKey); _ticker.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 ---------------- @override void onShowScreen(String name) { @@ -187,105 +209,166 @@ class _AppRootState extends State builder: (_, String? err, __) { // 致命错误优先展示详情 —— release 包里原本只会留下一片黑屏。 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(); }, ); } + /// 系统返回手势 / 返回键 = 游戏内的「返回上一页」,而不是退出应用: + /// - 游戏中 → 暂停菜单;暂停中 → 继续游戏 + /// - 结算界面 → 关卡选择;编辑器 → 标题 + /// - 关卡选择 / 设置 → 标题 + /// - 标题页不响应(已是「第一页」,忽略返回,防止误退游戏) + 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 + 触屏手柄)。 Widget _buildGame() { if (!_loggedBuild) { _loggedBuild = true; debugPrint( - '[boot] buildGame: state=${world.state} screen=${screen.value}'); + '[boot] buildGame: state=${world.state} screen=${screen.value}', + ); } return MaterialApp( - home: Scaffold( - backgroundColor: const Color(0xff000000), - body: Stack( - children: [ - Positioned.fill( - child: GameCanvas(world: world, repaint: frame), - ), - // 注意:Positioned 必须是 Stack 的直接子级。此前把 Positioned 放在 - // ValueListenableBuilder 的返回值里(隔着一层),定位数据被丢弃, - // 导致整棵标题树横向塌缩为 0 宽 —— 界面上只剩近黑的画布,即"黑屏"。 - Positioned.fill( - child: ValueListenableBuilder( - valueListenable: screen, - builder: (_, String s, __) { - if (s == 'scr-title') { - return TitleScreen( - world: world, - onSettings: () => screen.value = 'scr-settings', - onEditor: () => world.toEditor(), - ); - } - if (s == 'scr-levels') { - return LevelSelectScreen( - world: world, - reload: levelsReload, - onChanged: () => levelsReload.value++, - ); - } - if (s == 'scr-settings') { - return SettingsScreen( - world: world, - isTouch: _useTouch, - onBack: () => world.toTitle(), - ); - } - if (s == 'scr-editor') { - return EditorPanel( - controller: editor, - onBack: () => world.toTitle(), - ); - } - return const SizedBox(); - }, - ), - ), - // 暂停 / 结算(半透明,覆盖在画布之上) - Positioned.fill( - child: ValueListenableBuilder( - valueListenable: screen, - builder: (_, String s, __) { - if (s == 'scr-pause') { - return PauseScreen(world: world, quitLabel: pauseQuit.value); - } - if (s == 'scr-result') { - return ResultScreen(world: world, result: result.value); - } - return const SizedBox(); - }, - ), - ), - // HUD:显隐必须随 world.state 变化而重建。 - // 早先把它写成 build() 里读 world.state 的 if 分支 —— 但 startLevel() - // 只改 World 字段、不会触发 AppRoot 重建,于是进关卡后 HUD 永远不显示。 - // 这里改为监听 stateN。(Positioned 仍保持 Stack 直接子级,监听在内。) - Positioned.fill( - child: ValueListenableBuilder( - valueListenable: world.stateN, - builder: (_, String s, __) => - (s == 'playing' || s == 'paused') - ? HudOverlay(hud: hud, boss: boss, toast: toast) - : const SizedBox(), - ), - ), - if (_useTouch) + debugShowCheckedModeBanner: false, + home: PopScope( + // 拦截系统返回:交给 _handleSystemBack 映射为游戏内导航。 + canPop: false, + onPopInvokedWithResult: (bool didPop, Object? result) { + if (!didPop) _handleSystemBack(); + }, + child: Scaffold( + backgroundColor: const Color(0xff000000), + // 游戏类 App 不让软键盘压缩界面(否则编辑器/设置里一调起键盘, + // 可用高度骤减,固定头部立刻 BOTTOM OVERFLOWED)。 + // 所有输入框都在屏幕上半区,键盘不会遮挡它们。 + resizeToAvoidBottomInset: false, + body: Stack( + children: [ Positioned.fill( - child: TouchControls( - world: world, - visible: touchVisible, - hasGun: touchGun, + child: GameCanvas(world: world, repaint: frame), + ), + // 注意:Positioned 必须是 Stack 的直接子级。此前把 Positioned 放在 + // ValueListenableBuilder 的返回值里(隔着一层),定位数据被丢弃, + // 导致整棵标题树横向塌缩为 0 宽 —— 界面上只剩近黑的画布,即"黑屏"。 + Positioned.fill( + child: ValueListenableBuilder( + valueListenable: screen, + builder: (_, String s, __) { + if (s == 'scr-title') { + return TitleScreen( + world: world, + onSettings: () => screen.value = 'scr-settings', + onEditor: () => world.toEditor(), + ); + } + if (s == 'scr-levels') { + return LevelSelectScreen( + world: world, + reload: levelsReload, + onChanged: () => levelsReload.value++, + ); + } + if (s == 'scr-settings') { + return SettingsScreen( + world: world, + isTouch: _useTouch, + onBack: () => world.toTitle(), + onTouchEditor: () => screen.value = 'scr-touchedit', + ); + } + // 自定义按键:独立全屏页,透出下面的实时游戏画布, + // 编辑体验与正常玩游戏一致。 + if (s == 'scr-touchedit') { + return TouchLayoutEditor( + onDone: () => screen.value = 'scr-settings', + ); + } + if (s == 'scr-editor') { + return EditorPanel( + controller: editor, + onBack: () => world.toTitle(), + ); + } + return const SizedBox(); + }, ), ), - ], + // 暂停 / 结算(半透明,覆盖在画布之上) + Positioned.fill( + child: ValueListenableBuilder( + valueListenable: screen, + builder: (_, String s, __) { + if (s == 'scr-pause') { + return PauseScreen( + world: world, + quitLabel: pauseQuit.value, + ); + } + if (s == 'scr-result') { + return ResultScreen(world: world, result: result.value); + } + return const SizedBox(); + }, + ), + ), + // HUD:显隐必须随 world.state 变化而重建。 + // 早先把它写成 build() 里读 world.state 的 if 分支 —— 但 startLevel() + // 只改 World 字段、不会触发 AppRoot 重建,于是进关卡后 HUD 永远不显示。 + // 这里改为监听 stateN。(Positioned 仍保持 Stack 直接子级,监听在内。) + Positioned.fill( + child: ValueListenableBuilder( + valueListenable: world.stateN, + builder: (_, String s, __) => + (s == 'playing' || s == 'paused') + ? HudOverlay(hud: hud, boss: boss, toast: toast) + : const SizedBox(), + ), + ), + if (_useTouch) + Positioned.fill( + child: TouchControls( + world: world, + visible: touchVisible, + hasGun: touchGun, + ), + ), + ], + ), ), ), ); diff --git a/app/lib/src/game/level.dart b/app/lib/src/game/level.dart index 305d277..ae33af6 100644 --- a/app/lib/src/game/level.dart +++ b/app/lib/src/game/level.dart @@ -59,7 +59,12 @@ class Entity implements EntityView, CamZoneLike { double speed = 2.4; int timer = 0; 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; dynamic to; int cool = 0; @@ -252,7 +257,8 @@ class Level implements LevelLike { } if (type == 'warp') { final cfg = (idx < warpSrc.length ? warpSrc[idx] : {}) as Map? ?? {}; - final door = (data['doors'] as List?)?.cast() + final doors = (data['doors'] as List?)?.cast() ?? const []; + final door = doors .firstWhere((d) => d['tx'] == tx && d['ty'] == ty, orElse: () => {}); e.doorId = (door != null && door.isNotEmpty) ? door['id'] : idx; e.pair = cfg['pair'] ?? (idx % 2 == 0 ? idx + 1 : idx - 1); diff --git a/app/lib/src/game/world.dart b/app/lib/src/game/world.dart index 9f5c13f..6531672 100644 --- a/app/lib/src/game/world.dart +++ b/app/lib/src/game/world.dart @@ -1,10 +1,18 @@ // 游戏循环 / 状态机(对应 web 版 main.js)。 // 用固定 60Hz 时间步长驱动已移植的纯 Canvas 游戏逻辑,保证手感 1:1。 -// 渲染在 800×608 虚拟分辨率下完成,由 GameCanvas 做信箱(letterbox)缩放。 -import 'dart:ui' show Canvas, Color, Paint, Path, Rect, Offset, TextAlign, TextDirection; +// 渲染在虚拟视口下完成(≥800×608、随屏幕比例加宽),由 GameCanvas 铺满全屏。 +import 'dart:ui' + show Canvas, Color, Paint, Path, Rect, Offset, TextAlign, TextDirection; import 'dart:math' show sin; 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/save.dart'; import 'package:iwbtc/src/core/util.dart'; @@ -32,8 +40,10 @@ abstract class GameHost { void onBossBar(dynamic boss); void onToast(String text); void onResult(Map r); + /// 暂停菜单「退出」按钮文案随来源变化。 void onPauseMenu(String label); + /// 进入编辑器界面。 void editorShow(); void touchShow(); @@ -75,10 +85,29 @@ class World implements LevelTestRunner { double _acc = 0; String _lastToast = ''; + /// 当前虚拟视口(GameCanvas 每帧 paint 前同步)。 + /// 保证 ≥ 800×608;屏幕更宽/更高时另一维加宽,画面铺满全屏无黑边。 + double viewW = W; + double viewH = H; + 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 faceData(Map map, Map f, int i) { + Map faceData( + Map map, + Map f, + int i, + ) { final grid = (f['grid'] as List?)?.cast() ?? []; return { 'id': (map['id'] ?? 'map').toString() + '-' + i.toString(), @@ -115,7 +144,7 @@ class World implements LevelTestRunner { level = map![face]; player = Player(level!); - cam = Camera(W, H); + cam = Camera(viewW, viewH); cam!.reset(level!, player!); player!.onDeath = () { @@ -194,8 +223,14 @@ class World implements LevelTestRunner { } cam!.reset(level!, player!); Sfx.instance.warp(); - level!.particles.spawn(player!.cx, player!.cy, 12, - colors: const ['#a86bff', '#fff'], speed: 2.5, life: 24); + level!.particles.spawn( + player!.cx, + player!.cy, + 12, + colors: const ['#a86bff', '#fff'], + speed: 2.5, + life: 24, + ); } void doRespawnFace() { @@ -229,7 +264,12 @@ class World implements LevelTestRunner { void shoot() { 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(); } @@ -334,7 +374,8 @@ class World implements LevelTestRunner { if (++hudTick % 5 == 0) { final L = level!; host.onHud({ - 'name': (data!['name'] ?? 'STAGE') + + 'name': + (data!['name'] ?? 'STAGE') + (map!.length > 1 ? ' · 面${face + 1}/${map!.length}' : ''), 'deaths': deaths, 'ms': ms, @@ -345,7 +386,8 @@ class World implements LevelTestRunner { 'totalSaves': L.totalSaves, }); 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); _pushToast(L.toast ?? ''); } @@ -371,8 +413,11 @@ class World implements LevelTestRunner { } else if (state == 'paused' && Input.hit('pause')) { resume(); } + // 每个固定步长都刷新输入边沿(prev=now)。否则某一帧跑 0 次更新、 + // 下一帧跑 2 次时,同一次按键会被当两帧的 hit —— 表现为误触二段跳; + // 合帧亦不应丢失按键:now 在两次更新间保持为 true,下一子步才清 prev。 + Input.endFrame(); } - Input.endFrame(); // toast 推送必须在 Ticker 回调阶段(transientCallbacks)完成, // 不能放进 render() —— paint 阶段改被 ValueListenableBuilder 监听的 // notifier 会触发 "Build scheduled during frame"(晚一帧且刷警告)。 @@ -380,14 +425,16 @@ class World implements LevelTestRunner { if (state != 'playing') time++; } - // ---------------- 渲染(800×608 虚拟空间) ---------------- + // ---------------- 渲染(虚拟视口空间,≥800×608 随屏幕比例加宽) ---------------- void render(Canvas c) { final t = time; + final vw = viewW; + final vh = viewH; if (state == 'playing' || state == 'paused' || state == 'result') { final L = level!; final P = player!; 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); c.save(); C.apply(c); @@ -404,7 +451,9 @@ class World implements LevelTestRunner { text: TextSpan( text: useTouch ? '按右下角重生键' : '按 R 重生', 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, fontWeight: FontWeight.bold, fontFamily: 'monospace', @@ -414,7 +463,7 @@ class World implements LevelTestRunner { textDirection: TextDirection.ltr, ); 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( text: TextSpan( text: 'Backspace 整关重来', @@ -428,7 +477,10 @@ class World implements LevelTestRunner { textDirection: TextDirection.ltr, ); 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 { drawTitleBg(c); @@ -436,16 +488,18 @@ class World implements LevelTestRunner { } void drawTitleBg(Canvas c) { + final vw = viewW; + final vh = viewH; final g = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [hex('#181828'), hex('#08080f')], - ).createShader(Rect.fromLTWH(0, 0, W, H)); - c.drawRect(Rect.fromLTWH(0, 0, W, H), Paint()..shader = g); + ).createShader(Rect.fromLTWH(0, 0, vw, vh)); + c.drawRect(Rect.fromLTWH(0, 0, vw, vh), Paint()..shader = g); for (int i = 0; i < 26; i++) { - final double x = ((i * 137 + time * 0.15) % (W + 60)) - 30; - final double y = 120 + ((i * 71) % (H - 160)).toDouble(); + final double x = ((i * 137 + time * 0.15) % (vw + 60)) - 30; + final double y = 120 + ((i * 71) % (vh - 160)).toDouble(); final col = i % 3 == 0 ? hex('#3a3a4c') : hex('#2a2a38'); final p = Path() ..moveTo(x, y + 18) @@ -455,13 +509,16 @@ class World implements LevelTestRunner { 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'); - for (double x = 0; x < W; x += 22) { + for (double x = 0; x < vw; x += 22) { final p = Path() - ..moveTo(x, H - 4) - ..lineTo(x + 11, H - 26) - ..lineTo(x + 22, H - 4) + ..moveTo(x, vh - 4) + ..lineTo(x + 11, vh - 26) + ..lineTo(x + 22, vh - 4) ..close(); c.drawPath(p, spikePaint); } diff --git a/app/lib/src/ui/editor_panel.dart b/app/lib/src/ui/editor_panel.dart index 71683fe..ec31ed8 100644 --- a/app/lib/src/ui/editor_panel.dart +++ b/app/lib/src/ui/editor_panel.dart @@ -35,34 +35,139 @@ const List> PALETTE = >[ ['.', '橡皮', '#20202c'], ]; -const Map>> SCHEMA = >>{ - '^': >[ - {'type': 'select', 'key': 'dir', 'label': '方向', 'def': 'up', 'opts': >[['up', '上'], ['down', '下'], ['left', '左'], ['right', '右']]} - ], - 'W': >[], - '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}, - ], -}; +const Map>> SCHEMA = + >>{ + '^': >[ + { + 'type': 'select', + 'key': 'dir', + 'label': '方向', + 'def': 'up', + 'opts': >[ + ['up', '上'], + ['down', '下'], + ['left', '左'], + ['right', '右'], + ], + }, + ], + 'W': >[], + '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, + }, + ], + }; -const Map SPIKE_CHAR = {'up': '^', 'down': 'v', 'left': '<', 'right': '>'}; -const Map SPIKE_DIR = {'^': 'up', 'v': 'down', '<': 'left', '>': 'right'}; +const Map SPIKE_CHAR = { + 'up': '^', + 'down': 'v', + 'left': '<', + 'right': '>', +}; +const Map SPIKE_DIR = { + '^': 'up', + 'v': 'down', + '<': 'left', + '>': 'right', +}; class EditFace { String name; @@ -72,14 +177,15 @@ class EditFace { List> grid; List?>> props; List> doors; - EditFace( - {required this.name, - required this.w, - required this.h, - required this.seq, - required this.grid, - required this.props, - required this.doors}); + EditFace({ + required this.name, + required this.w, + required this.h, + required this.seq, + required this.grid, + required this.props, + required this.doors, + }); } class _Ent implements EntityView { @@ -91,8 +197,15 @@ class _Ent implements EntityView { bool falling; @override String dir; - _Ent({required this.x, required this.y, required this.w, required this.h, - this.active = false, this.falling = false, this.dir = 'left'}); + _Ent({ + required this.x, + required this.y, + required this.w, + required this.h, + this.active = false, + this.falling = false, + this.dir = 'left', + }); } class _KidView implements PlayerView { @@ -102,8 +215,16 @@ class _KidView implements PlayerView { int facing; @override bool grounded, hasGun, dead; - _KidView({required this.x, required this.y, required this.w, required this.h, - this.facing = 1, this.grounded = true, this.hasGun = false, this.dead = false}); + _KidView({ + required this.x, + required this.y, + required this.w, + required this.h, + this.facing = 1, + this.grounded = true, + this.hasGun = false, + this.dead = false, + }); @override double get cx => x + w / 2; @override @@ -141,7 +262,7 @@ class EditorController { grid: blank(w, h), props: propsBlank(w, h), doors: >[], - ) + ), ]; face = 0; curId = null; @@ -189,15 +310,17 @@ class EditorController { void addFace() { final f = cur(); - faces.add(EditFace( - name: '面${faces.length + 1}', - w: f.w, - h: f.h, - seq: 0, - grid: blank(f.w, f.h), - props: propsBlank(f.w, f.h), - doors: >[], - )); + faces.add( + EditFace( + name: '面${faces.length + 1}', + w: f.w, + h: f.h, + seq: 0, + grid: blank(f.w, f.h), + props: propsBlank(f.w, f.h), + doors: >[], + ), + ); face = faces.length - 1; selTx = selTy = null; onChange?.call(); @@ -205,15 +328,23 @@ class EditorController { void copyFace() { final f = cur(); - faces.add(EditFace( - name: f.name + ' 副本', - w: f.w, - h: f.h, - seq: f.seq, - grid: f.grid.map((r) => List.from(r)).toList(), - props: f.props.map((r) => r.map((c) => c == null ? null : Map.from(c)).toList()).toList(), - doors: f.doors.map((d) => Map.from(d)).toList(), - )); + faces.add( + EditFace( + name: f.name + ' 副本', + w: f.w, + h: f.h, + seq: f.seq, + grid: f.grid.map((r) => List.from(r)).toList(), + props: f.props + .map( + (r) => r + .map((c) => c == null ? null : Map.from(c)) + .toList(), + ) + .toList(), + doors: f.doors.map((d) => Map.from(d)).toList(), + ), + ); face = faces.length - 1; selTx = selTy = null; onChange?.call(); @@ -241,7 +372,12 @@ class EditorController { if (prev == 'W' && ch != 'W') removeDoor(f, tx, ty); if (ch == 'W' && prev != 'W') { f.seq++; - f.doors.add({'id': f.seq, 'tx': tx, 'ty': ty, 'to': null}); + f.doors.add({ + 'id': f.seq, + 'tx': tx, + 'ty': ty, + 'to': null, + }); } if (prev != ch) f.props[ty][tx] = null; f.grid[ty][tx] = ch; @@ -265,8 +401,10 @@ class EditorController { } void setPropFaceDoor(EditFace f, int tx, int ty, int face, int door) { - final d = f.doors.firstWhere((o) => o['tx'] == tx && o['ty'] == ty, - orElse: () => {}); + final d = f.doors.firstWhere( + (o) => o['tx'] == tx && o['ty'] == ty, + orElse: () => {}, + ); if (d.isEmpty) return; d['to'] = door >= 0 ? {'face': face, 'door': door} : null; onChange?.call(); @@ -275,13 +413,17 @@ class EditorController { Map buildData() { final facesData = faces.map((f) { final grid = f.grid.map((r) { - return r.map((ch) { - if (ch == '^') { - final dir = (f.props[f.grid.indexOf(r)]?[r.indexOf(ch)] ?? {})['dir'] ?? 'up'; - return SPIKE_CHAR[dir] ?? '^'; - } - return ch; - }).join(''); + return r + .map((ch) { + if (ch == '^') { + final dir = + (f.props[f.grid.indexOf(r)]?[r.indexOf(ch)] ?? {})['dir'] ?? + 'up'; + return SPIKE_CHAR[dir] ?? '^'; + } + return ch; + }) + .join(''); }).toList(); final messages = []; final turrets = >[]; @@ -291,7 +433,8 @@ class EditorController { for (int x = 0; x < f.w; x++) { final ch = f.grid[y][x]; final p = f.props[y][x]; - if (ch == 'T' && p != null && (p['msg'] ?? '').isNotEmpty) messages.add(p['msg']); + if (ch == 'T' && p != null && (p['msg'] ?? '').isNotEmpty) + messages.add(p['msg']); if (ch == 'O' && p != null) { turrets.add({ 'dir': p['dir'] ?? 'left', @@ -323,12 +466,14 @@ class EditorController { 'height': f.h, 'grid': grid, 'doors': f.doors - .map((d) => { - 'id': d['id'], - 'tx': d['tx'], - 'ty': d['ty'], - 'to': d['to'], - }) + .map( + (d) => { + 'id': d['id'], + 'tx': d['tx'], + 'ty': d['ty'], + 'to': d['to'], + }, + ) .toList(), 'messages': messages, 'turrets': turrets, @@ -396,8 +541,9 @@ class EditorController { void pickImport(String id) { final l = Save.customs().cast>().firstWhere( - (c) => c['id'] == id, - orElse: () => {}); + (c) => c['id'] == id, + orElse: () => {}, + ); if (l.isEmpty) return; curId = id; hydrate(l); @@ -408,15 +554,20 @@ class EditorController { final facesRaw = (o['faces'] as List?) ?? []; faces = facesRaw.map((fr) { final f = fr as Map; - final w = (f['width'] as int?) ?? ((f['grid'] as List).first as String).length; + final w = + (f['width'] as int?) ?? ((f['grid'] as List).first as String).length; final h = (f['height'] as int?) ?? (f['grid'] as List).length; - final grid = (f['grid'] as List).map((r) => (r as String).split('').toList()).toList(); + final grid = (f['grid'] as List) + .map((r) => (r as String).split('').toList()) + .toList(); final props = propsBlank(w, h); final face = EditFace( name: f['name'] ?? '面', w: w, h: h, - seq: (f['doors'] as List? ?? []).map((d) => (d as Map)['id'] as int).fold(0, (m, e) => m > e ? m : e), + seq: (f['doors'] as List? ?? []) + .map((d) => (d as Map)['id'] as int) + .fold(0, (m, e) => m > e ? m : e), grid: grid, props: props, doors: (f['doors'] as List? ?? []) @@ -456,14 +607,21 @@ List> blank(int w, int h) => List.generate(h, (_) => List.filled(w, '.')); List?>> propsBlank(int w, int h) => List.generate(h, (_) => List?>.filled(w, null)); -int clampInt(int v, int a, int b) => v < a ? a : v > b ? b : v; +int clampInt(int v, int a, int b) => v < a + ? a + : v > b + ? b + : v; // ---------------- 面板 ---------------- class EditorPanel extends StatefulWidget { final EditorController controller; final VoidCallback onBack; - const EditorPanel( - {super.key, required this.controller, required this.onBack}); + const EditorPanel({ + super.key, + required this.controller, + required this.onBack, + }); @override State createState() => _EditorPanelState(); @@ -475,13 +633,31 @@ class _EditorPanelState extends State { final TextEditingController _hCtl = TextEditingController(); bool _importOpen = false; + // 注册到共享 EditorController 的刷新回调;dispose 时必须清掉,否则离开本页后 + // controller 仍持有旧 State 的闭包,再次进入触发 onChange?.call() 会对已销毁 + // 的 State 调 setState(setState() called after dispose)。 + late final VoidCallback _onChange = () { + if (mounted) setState(() {}); + }; + @override void initState() { super.initState(); - widget.controller.onChange = () => setState(() {}); + widget.controller.onChange = _onChange; _syncFields(); } + @override + void dispose() { + if (widget.controller.onChange == _onChange) { + widget.controller.onChange = null; + } + _nameCtl.dispose(); + _wCtl.dispose(); + _hCtl.dispose(); + super.dispose(); + } + void _syncFields() { if (widget.controller.active) { _nameCtl.text = widget.controller.cur().name; @@ -511,78 +687,100 @@ class _EditorPanelState extends State { return _MenuBg( child: Column( children: [ - Row(children: [ - const Text('自制关卡', - style: TextStyle(color: Color(0xffffffff), fontSize: 22)), - const Spacer(), - _SmallButton('返回', onPressed: widget.onBack), - ]), + Row( + children: [ + const Text( + '自制关卡', + style: TextStyle(color: Color(0xffffffff), fontSize: 22), + ), + const Spacer(), + _SmallButton('返回', onPressed: widget.onBack), + ], + ), const SizedBox(height: 8), - Row(children: [ - SizedBox( - width: 120, - child: TextField( - controller: _nameCtl, - onChanged: (v) => f.name = v, - style: const TextStyle(color: Color(0xffffffff)), - decoration: const InputDecoration( - isCollapsed: true, - contentPadding: EdgeInsets.all(6), - border: OutlineInputBorder(), - hintText: '地图名', - hintStyle: TextStyle(color: Color(0xff55556a)), + // 工具行:窄屏(横屏手机逻辑宽度有限)时自动换行,杜绝 Row 横向溢出。 + Wrap( + spacing: 8, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + SizedBox( + width: 120, + child: TextField( + controller: _nameCtl, + onChanged: (v) => f.name = v, + style: const TextStyle(color: Color(0xffffffff)), + decoration: const InputDecoration( + isCollapsed: true, + contentPadding: EdgeInsets.all(6), + border: OutlineInputBorder(), + hintText: '地图名', + hintStyle: TextStyle(color: Color(0xff55556a)), + ), ), ), - ), - const SizedBox(width: 8), - const Text('宽', style: TextStyle(color: Color(0xffc8c8d8))), - SizedBox( - width: 48, - child: TextField( - controller: _wCtl, - keyboardType: TextInputType.number, - style: const TextStyle(color: Color(0xffffffff)), - decoration: const InputDecoration( - isCollapsed: true, - contentPadding: EdgeInsets.all(6), - border: OutlineInputBorder(), + const SizedBox(width: 8), + const Text('宽', style: TextStyle(color: Color(0xffc8c8d8))), + SizedBox( + width: 48, + child: TextField( + controller: _wCtl, + keyboardType: TextInputType.number, + style: const TextStyle(color: Color(0xffffffff)), + decoration: const InputDecoration( + isCollapsed: true, + contentPadding: EdgeInsets.all(6), + border: OutlineInputBorder(), + ), ), ), - ), - const SizedBox(width: 8), - const Text('高', style: TextStyle(color: Color(0xffc8c8d8))), - SizedBox( - width: 48, - child: TextField( - controller: _hCtl, - keyboardType: TextInputType.number, - style: const TextStyle(color: Color(0xffffffff)), - decoration: const InputDecoration( - isCollapsed: true, - contentPadding: EdgeInsets.all(6), - border: OutlineInputBorder(), + const SizedBox(width: 8), + const Text('高', style: TextStyle(color: Color(0xffc8c8d8))), + SizedBox( + width: 48, + child: TextField( + controller: _hCtl, + keyboardType: TextInputType.number, + style: const TextStyle(color: Color(0xffffffff)), + decoration: const InputDecoration( + isCollapsed: true, + contentPadding: EdgeInsets.all(6), + border: OutlineInputBorder(), + ), ), ), - ), - _SmallButton('调整', onPressed: () { - c.resize(int.tryParse(_wCtl.text) ?? 25, - int.tryParse(_hCtl.text) ?? 19); - }), - _SmallButton('铺底', onPressed: c.fillBottom), - _SmallButton('清空', onPressed: c.clearFace), - _SmallButton('导入', onPressed: () => setState(() => _importOpen = true)), - _SmallButton('试玩', onPressed: c.test), - _SmallButton('保存', on: true, onPressed: c.save), - Text(c.curId == null ? '→ 新地图' : '→ 已保存', - style: const TextStyle(color: Color(0xff8a8aa8), fontSize: 12)), - ]), + _SmallButton( + '调整', + onPressed: () { + c.resize( + int.tryParse(_wCtl.text) ?? 25, + int.tryParse(_hCtl.text) ?? 19, + ); + }, + ), + _SmallButton('铺底', onPressed: c.fillBottom), + _SmallButton('清空', onPressed: c.clearFace), + _SmallButton( + '导入', + onPressed: () => setState(() => _importOpen = true), + ), + _SmallButton('试玩', onPressed: c.test), + _SmallButton('保存', on: true, onPressed: c.save), + Text( + c.curId == null ? '→ 新地图' : '→ 已保存', + style: const TextStyle(color: Color(0xff8a8aa8), fontSize: 12), + ), + ], + ), if (c.lastError != null) Container( margin: const EdgeInsets.only(top: 6), padding: const EdgeInsets.all(6), color: const Color(0x33ff5c7a), - child: Text(c.lastError!, - style: const TextStyle(color: Color(0xffffffff))), + child: Text( + c.lastError!, + style: const TextStyle(color: Color(0xffffffff)), + ), ), const SizedBox(height: 8), Expanded( @@ -620,9 +818,13 @@ class _EditorPanelState extends State { borderRadius: BorderRadius.circular(4), ), child: Center( - child: Text(p[1], - style: const TextStyle( - color: Color(0xffffffff), fontSize: 10)), + child: Text( + p[1], + style: const TextStyle( + color: Color(0xffffffff), + fontSize: 10, + ), + ), ), ), ); @@ -651,8 +853,10 @@ class _EditorPanelState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('属性(选中方块)', - style: TextStyle(color: Color(0xffc8c8d8))), + const Text( + '属性(选中方块)', + style: TextStyle(color: Color(0xffc8c8d8)), + ), const SizedBox(height: 6), Expanded( child: SingleChildScrollView( @@ -660,27 +864,38 @@ class _EditorPanelState extends State { ), ), const SizedBox(height: 8), - const Text('面(多面地图)', - style: TextStyle(color: Color(0xffc8c8d8))), - Row(children: [ - _SmallButton('+面', onPressed: c.addFace), - _SmallButton('复制', onPressed: c.copyFace), - _SmallButton('删除', onPressed: c.delFace), - ]), - ...c.faces.asMap().entries.map((e) => GestureDetector( - onTap: () => setState(() => c.face = e.key), - child: Container( - margin: const EdgeInsets.only(top: 2), - padding: const EdgeInsets.all(4), - color: e.key == c.face - ? const Color(0xff2a2a3a) - : const Color(0xff0a0a14), - child: Text( + const Text( + '面(多面地图)', + style: TextStyle(color: Color(0xffc8c8d8)), + ), + Wrap( + spacing: 4, + runSpacing: 4, + children: [ + _SmallButton('+面', onPressed: c.addFace), + _SmallButton('复制', onPressed: c.copyFace), + _SmallButton('删除', onPressed: c.delFace), + ], + ), + ...c.faces.asMap().entries.map( + (e) => GestureDetector( + onTap: () => setState(() => c.face = e.key), + child: Container( + margin: const EdgeInsets.only(top: 2), + padding: const EdgeInsets.all(4), + color: e.key == c.face + ? const Color(0xff2a2a3a) + : const Color(0xff0a0a14), + child: Text( '${e.value.name} ${e.value.w}×${e.value.h} · ${e.value.doors.length}门', style: const TextStyle( - color: Color(0xffc8c8d8), fontSize: 11)), + color: Color(0xffc8c8d8), + fontSize: 11, + ), + ), + ), ), - )), + ), ], ), ), @@ -714,10 +929,7 @@ class _EditorCanvas extends StatelessWidget { onSecondaryTapDown: (d) => _handle(d.localPosition, true), child: CustomPaint( painter: _EditorPainter(controller), - size: Size( - controller.cur().w * TILE, - controller.cur().h * TILE, - ), + size: Size(controller.cur().w * TILE, controller.cur().h * TILE), ), ); } @@ -739,8 +951,9 @@ class _EditorPainter extends CustomPainter { void paint(Canvas canvas, Size size) { final f = c.cur(); canvas.drawRect( - Rect.fromLTWH(0, 0, size.width, size.height), - Paint()..color = const Color(0xff101018)); + Rect.fromLTWH(0, 0, size.width, size.height), + Paint()..color = const Color(0xff101018), + ); for (int y = 0; y < f.h; y++) { for (int x = 0; x < f.w; x++) { final ch = f.grid[y][x]; @@ -751,16 +964,22 @@ class _EditorPainter extends CustomPainter { } final gridPaint = Paint()..color = const Color(0x10ffffff); canvas.drawRect( - Rect.fromLTWH(px + 0.5, py + 0.5, TILE - 1, TILE - 1), gridPaint); + Rect.fromLTWH(px + 0.5, py + 0.5, TILE - 1, TILE - 1), + gridPaint, + ); if (ch == 'W') { final d = f.doors.firstWhere( - (dd) => dd['tx'] == x && dd['ty'] == y, - orElse: () => {}); + (dd) => dd['tx'] == x && dd['ty'] == y, + orElse: () => {}, + ); final tp = TextPainter( text: TextSpan( text: 'W${d['id'] ?? ''}', style: const TextStyle( - color: Color(0xffffffff), fontSize: 9, fontFamily: 'monospace'), + color: Color(0xffffffff), + fontSize: 9, + fontFamily: 'monospace', + ), ), textDirection: TextDirection.ltr, ); @@ -771,11 +990,17 @@ class _EditorPainter extends CustomPainter { } if (c.selTx != null && c.selTy != null) { canvas.drawRect( - Rect.fromLTWH(c.selTx! * TILE + 1, c.selTy! * TILE + 1, TILE - 2, TILE - 2), - Paint() - ..color = const Color(0xffffd23a) - ..style = PaintingStyle.stroke - ..strokeWidth = 2); + Rect.fromLTWH( + c.selTx! * TILE + 1, + c.selTy! * TILE + 1, + TILE - 2, + TILE - 2, + ), + Paint() + ..color = const Color(0xffffd23a) + ..style = PaintingStyle.stroke + ..strokeWidth = 2, + ); } } @@ -783,8 +1008,14 @@ class _EditorPainter extends CustomPainter { bool shouldRepaint(_EditorPainter old) => true; } -void drawTileReal(Canvas c, String ch, double px, double py, - Map? p, EditFace f) { +void drawTileReal( + Canvas c, + String ch, + double px, + double py, + Map? p, + EditFace f, +) { p ??= {}; switch (ch) { case '#': @@ -812,8 +1043,12 @@ void drawTileReal(Canvas c, String ch, double px, double py, S.drawVanish(c, px, py, 0, 0); break; case '^': - final dir = [0, 1, 2, 3]['up,down,left,right'.split(',') - .indexOf((p['dir'] ?? 'up').toString())]; + final dir = [ + 0, + 1, + 2, + 3, + ]['up,down,left,right'.split(',').indexOf((p['dir'] ?? 'up').toString())]; S.drawSpike(c, px, py, dir < 0 ? 0 : dir, 0); break; case '*': @@ -844,21 +1079,21 @@ void drawTileReal(Canvas c, String ch, double px, double py, S.drawSign(c, _Ent(x: px, y: py, w: TILE, h: TILE), 0); break; case 'O': - S.drawTurret(c, _Ent(x: px, y: py, w: TILE, h: TILE, dir: p['dir'] ?? 'left'), 0); + S.drawTurret( + c, + _Ent(x: px, y: py, w: TILE, h: TILE, dir: p['dir'] ?? 'left'), + 0, + ); break; case 'C': S.drawCamera(c, _Ent(x: px, y: py, w: TILE, h: TILE), 0); break; case 'S': S.drawKid( - c, - _KidView( - x: px + (TILE - 11) / 2, - y: py + (TILE - 21), - w: 11, - h: 21, - ), - 0); + c, + _KidView(x: px + (TILE - 11) / 2, y: py + (TILE - 21), w: 11, h: 21), + 0, + ); break; default: drawMarker(c, ch, px, py); @@ -866,21 +1101,34 @@ void drawTileReal(Canvas c, String ch, double px, double py, } void drawMarker(Canvas c, String ch, double px, double py) { - final item = PALETTE.firstWhere((p) => p[0] == ch, orElse: () => ['', '', '#555']); - final col = item.length > 2 ? Color(int.parse(item[2].substring(1), radix: 16) | 0xff000000) : const Color(0xff555555); - c.drawRect(Rect.fromLTWH(px + 1, py + 1, TILE - 2, TILE - 2), - Paint()..color = col.withValues(alpha: 0.85 * 255)); + final item = PALETTE.firstWhere( + (p) => p[0] == ch, + orElse: () => ['', '', '#555'], + ); + final col = item.length > 2 + ? Color(int.parse(item[2].substring(1), radix: 16) | 0xff000000) + : const Color(0xff555555); + c.drawRect( + Rect.fromLTWH(px + 1, py + 1, TILE - 2, TILE - 2), + Paint()..color = col.withValues(alpha: 0.85 * 255), + ); final tp = TextPainter( text: TextSpan( text: ch, style: const TextStyle( - color: Color(0xffffffff), fontSize: 14, fontFamily: 'monospace'), + color: Color(0xffffffff), + fontSize: 14, + fontFamily: 'monospace', + ), ), textAlign: TextAlign.center, textDirection: TextDirection.ltr, ); tp.layout(); - tp.paint(c, Offset(px + TILE / 2 - tp.width / 2, py + TILE / 2 - tp.height / 2)); + tp.paint( + c, + Offset(px + TILE / 2 - tp.width / 2, py + TILE / 2 - tp.height / 2), + ); } class _PropsPanel extends StatelessWidget { @@ -892,15 +1140,20 @@ class _PropsPanel extends StatelessWidget { final c = controller; final f = c.cur(); if (c.selTx == null || c.selTy == null) { - return const Text('点击画布上的方块,查看并设置它的属性', - style: TextStyle(color: Color(0xff666680), fontSize: 12)); + return const Text( + '点击画布上的方块,查看并设置它的属性', + style: TextStyle(color: Color(0xff666680), fontSize: 12), + ); } final tx = c.selTx!, ty = c.selTy!; if (tx < 0 || ty < 0 || tx >= f.w || ty >= f.h) { return const Text('(越界)', style: TextStyle(color: Color(0xff666680))); } final ch = f.grid[ty][tx]; - final label = PALETTE.firstWhere((p) => p[0] == ch, orElse: () => ['?', ch])[1]; + final label = PALETTE.firstWhere( + (p) => p[0] == ch, + orElse: () => ['?', ch], + )[1]; final fields = SCHEMA[ch]; if (fields == null || fields.isEmpty) { if (ch == 'W') { @@ -909,18 +1162,21 @@ class _PropsPanel extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('「$ch」 $label', - style: const TextStyle(color: Color(0xffffffff))), - const Text('该方块没有可设置的属性', - style: TextStyle(color: Color(0xff666680), fontSize: 12)), + Text( + '「$ch」 $label', + style: const TextStyle(color: Color(0xffffffff)), + ), + const Text( + '该方块没有可设置的属性', + style: TextStyle(color: Color(0xff666680), fontSize: 12), + ), ], ); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('「$ch」 $label', - style: const TextStyle(color: Color(0xffffffff))), + Text('「$ch」 $label', style: const TextStyle(color: Color(0xffffffff))), const SizedBox(height: 6), if (ch == 'W') _portalRow(c, f, tx, ty), for (final fd in fields) _propField(c, f, tx, ty, fd), @@ -930,44 +1186,67 @@ class _PropsPanel extends StatelessWidget { Widget _portalRow(EditorController c, EditFace f, int tx, int ty) { final door = f.doors.firstWhere( - (d) => d['tx'] == tx && d['ty'] == ty, - orElse: () => {}); + (d) => d['tx'] == tx && d['ty'] == ty, + orElse: () => {}, + ); if (door.isEmpty) { - return const Text('(此格未登记为门,请重新放置 W)', - style: TextStyle(color: Color(0xff666680), fontSize: 12)); + return const Text( + '(此格未登记为门,请重新放置 W)', + style: TextStyle(color: Color(0xff666680), fontSize: 12), + ); } final cur = door['to'] as Map?; final tf = cur != null ? cur['face'] as int : c.face; final td = cur != null ? cur['door'] as int : -1; - return Row(children: [ - const Text('跳转到', style: TextStyle(color: Color(0xffc8c8d8))), - DropdownButton( - value: tf, - items: c.faces - .asMap() - .entries - .map((e) => DropdownMenuItem( - value: e.key, child: Text('面${e.key + 1}${e.value.name != '面${e.key + 1}' ? '·' + e.value.name : ''}'))) - .toList(), - onChanged: (v) => c.setPropFaceDoor(f, tx, ty, v!, td), - ), - DropdownButton( - value: td < 0 ? -1 : td, - items: [ - const DropdownMenuItem(value: -1, child: Text('(无)')), - ...c.faces[tf].doors - .where((od) => !(od['id'] == door['id'] && tf == c.face)) - .map((od) => DropdownMenuItem( - value: od['id'] as int, child: Text('门${od['id']}'))) + return Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + const Text('跳转到', style: TextStyle(color: Color(0xffc8c8d8))), + DropdownButton( + value: tf, + items: c.faces + .asMap() + .entries + .map( + (e) => DropdownMenuItem( + value: e.key, + child: Text( + '面${e.key + 1}${e.value.name != '面${e.key + 1}' ? '·' + e.value.name : ''}', + ), + ), + ) .toList(), - ], - onChanged: (v) => c.setPropFaceDoor(f, tx, ty, tf, v!), - ), - ]); + onChanged: (v) => c.setPropFaceDoor(f, tx, ty, v!, td), + ), + DropdownButton( + value: td < 0 ? -1 : td, + items: [ + const DropdownMenuItem(value: -1, child: Text('(无)')), + ...c.faces[tf].doors + .where((od) => !(od['id'] == door['id'] && tf == c.face)) + .map( + (od) => DropdownMenuItem( + value: od['id'] as int, + child: Text('门${od['id']}'), + ), + ) + .toList(), + ], + onChanged: (v) => c.setPropFaceDoor(f, tx, ty, tf, v!), + ), + ], + ); } - Widget _propField(EditorController c, EditFace f, int tx, int ty, - Map fd) { + Widget _propField( + EditorController c, + EditFace f, + int tx, + int ty, + Map fd, + ) { final type = fd['type']; final key = fd['key']; final cur = c.propAt(tx, ty) ?? {}; @@ -997,51 +1276,64 @@ class _PropsPanel extends StatelessWidget { final val = cur[key] ?? fd['def'] ?? ''; return Padding( padding: const EdgeInsets.only(bottom: 6), - child: Row(children: [ - Text('${fd['label']}', - style: const TextStyle(color: Color(0xffc8c8d8))), - const SizedBox(width: 8), - SizedBox( - width: 70, - child: TextField( - controller: TextEditingController(text: val.toString()), - keyboardType: const TextInputType.numberWithOptions(decimal: true), - onChanged: (v) { - final step = fd['step']; - final num = step != null - ? (double.tryParse(v) ?? 0) - : (int.tryParse(v) ?? 0); - c.setProp(tx, ty, key, num); - }, - style: const TextStyle(color: Color(0xffffffff)), - decoration: const InputDecoration( - isCollapsed: true, - contentPadding: EdgeInsets.all(6), - border: OutlineInputBorder(), + child: Row( + children: [ + Text( + '${fd['label']}', + style: const TextStyle(color: Color(0xffc8c8d8)), + ), + const SizedBox(width: 8), + SizedBox( + width: 70, + child: TextField( + controller: TextEditingController(text: val.toString()), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + onChanged: (v) { + final step = fd['step']; + final num = step != null + ? (double.tryParse(v) ?? 0) + : (int.tryParse(v) ?? 0); + c.setProp(tx, ty, key, num); + }, + style: const TextStyle(color: Color(0xffffffff)), + decoration: const InputDecoration( + isCollapsed: true, + contentPadding: EdgeInsets.all(6), + border: OutlineInputBorder(), + ), ), ), - ), - ]), + ], + ), ); } if (type == 'select') { final val = cur[key] ?? fd['def']; return Padding( padding: const EdgeInsets.only(bottom: 6), - child: Row(children: [ - Text('${fd['label']}', - style: const TextStyle(color: Color(0xffc8c8d8))), - const SizedBox(width: 8), - DropdownButton( - value: val as String, - items: (fd['opts'] as List) - .map((o) => DropdownMenuItem( - value: (o as List)[0] as String, - child: Text((o as List)[1] as String))) - .toList(), - onChanged: (v) => c.setProp(tx, ty, key, v), - ), - ]), + child: Row( + children: [ + Text( + '${fd['label']}', + style: const TextStyle(color: Color(0xffc8c8d8)), + ), + const SizedBox(width: 8), + DropdownButton( + value: val as String, + items: (fd['opts'] as List) + .map( + (o) => DropdownMenuItem( + value: (o as List)[0] as String, + child: Text((o as List)[1] as String), + ), + ) + .toList(), + onChanged: (v) => c.setProp(tx, ty, key, v), + ), + ], + ), ); } return const SizedBox(); @@ -1069,34 +1361,61 @@ class _ImportModal extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Text('导入地图', - style: TextStyle(color: Color(0xffffffff), fontSize: 18)), + const Text( + '导入地图', + style: TextStyle(color: Color(0xffffffff), fontSize: 18), + ), const SizedBox(height: 8), - const Text('从以前保存过的地图里选一个载入编辑器', - style: TextStyle(color: Color(0xff666680), fontSize: 12)), + const Text( + '从以前保存过的地图里选一个载入编辑器', + style: TextStyle(color: Color(0xff666680), fontSize: 12), + ), const SizedBox(height: 8), - if (customs.isEmpty) - const Text('还没有保存过的地图,先「保存」一个吧。', - style: TextStyle(color: Color(0xff666680))) - else - ...customs.map((l) => GestureDetector( - onTap: () => onPick(l['id'] as String), - child: Container( - margin: const EdgeInsets.only(bottom: 4), - padding: const EdgeInsets.all(8), - color: const Color(0xff0a0a14), - child: Row(children: [ - Text(l['name'] ?? 'MY MAP', - style: const TextStyle(color: Color(0xffffffff))), - const Spacer(), - Text( - (l['faces'] as List? ?? []).length > 0 - ? '${(l['faces'] as List).length} 面' - : '单关', - style: const TextStyle(color: Color(0xff8a8aa8))), - ]), + // 保存的地图多时内容会超出屏高(横屏手机更甚),列表区可滚动 + Flexible( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (customs.isEmpty) + const Text( + '还没有保存过的地图,先「保存」一个吧。', + style: TextStyle(color: Color(0xff666680)), + ) + else + ...customs.map( + (l) => GestureDetector( + onTap: () => onPick(l['id'] as String), + child: Container( + margin: const EdgeInsets.only(bottom: 4), + padding: const EdgeInsets.all(8), + color: const Color(0xff0a0a14), + child: Row( + children: [ + Text( + l['name'] ?? 'MY MAP', + style: const TextStyle( + color: Color(0xffffffff), + ), + ), + const Spacer(), + Text( + (l['faces'] as List? ?? []).length > 0 + ? '${(l['faces'] as List).length} 面' + : '单关', + style: const TextStyle( + color: Color(0xff8a8aa8), + ), + ), + ], + ), + ), + ), + ), + ], ), - )), + ), + ), const SizedBox(height: 8), _SmallButton('取消', onPressed: onClose), ], @@ -1131,20 +1450,24 @@ class _SmallButton extends StatelessWidget { final String text; final VoidCallback onPressed; final bool on; - const _SmallButton(this.text, - {required this.onPressed, this.on = false}); + const _SmallButton(this.text, {required this.onPressed, this.on = false}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(left: 4), child: ElevatedButton( onPressed: onPressed, + // 去掉 ElevatedButton 默认的 64px 最小宽度和 48px 点击目标外边距: + // 否则「+面/复制/删除」三个按钮塞进 200px 宽的属性栏必然横向溢出。 style: ElevatedButton.styleFrom( backgroundColor: on ? const Color(0xff5a3a7a) : const Color(0xff1a1a26), foregroundColor: const Color(0xffffffff), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, ), child: Text(text, style: const TextStyle(fontSize: 12)), ), diff --git a/app/lib/src/ui/game_canvas.dart b/app/lib/src/ui/game_canvas.dart index ee062bf..3c4ac22 100644 --- a/app/lib/src/ui/game_canvas.dart +++ b/app/lib/src/ui/game_canvas.dart @@ -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:math' show min; import 'package:flutter/widgets.dart'; import 'package:flutter/foundation.dart' show debugPrint; import 'package:iwbtc/src/core/app_error.dart' show AppError; import 'package:iwbtc/src/game/world.dart' show World, W, H; -/// 计算 800×608 虚拟画面在给定屏幕尺寸下的信箱矩形(居中、等比缩放)。 -/// -/// 画布和虚拟手柄都用它:横屏手机宽高比远大于画面比例,两侧会出现黑边, -/// 手柄按钮必须锚定在**画面区域**内,否则会漂到黑边上(手指按到的位置和 -/// 屏幕上看到的游戏内容对不上)。尺寸非法(尚未布局完成)时返回 Rect.zero。 -Rect letterboxRect(Size size) { - if (!size.width.isFinite || !size.height.isFinite) return Rect.zero; - if (size.width <= 0 || size.height <= 0) return Rect.zero; - final s = min(size.width / W, size.height / H); - if (!s.isFinite || s <= 0) return Rect.zero; - final double w = W * s; - final double h = H * s; - return Rect.fromLTWH( - (size.width - w) / 2, (size.height - h) / 2, w, h); +/// 屏幕对应的虚拟视口:缩放系数 + 视口尺寸(虚拟像素)。 +class StageMetrics { + final double scale; + final double vw; + final double vh; + const StageMetrics(this.scale, this.vw, this.vh); + + bool get isValid => + scale > 0 && + scale.isFinite && + vw.isFinite && + vh.isFinite && + vw >= W - 0.5 && + 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 { @@ -32,9 +52,7 @@ class GameCanvas extends StatelessWidget { // 用 SizedBox.expand 明确占满父级,避免 CustomPaint 使用 Size.infinite // 时在部分平台上拿到零尺寸/无穷尺寸,算出 scale = 0 而整屏消失(表现为黑屏)。 return SizedBox.expand( - child: CustomPaint( - painter: _GamePainter(world, repaint), - ), + child: CustomPaint(painter: _GamePainter(world, repaint)), ); } } @@ -48,21 +66,21 @@ class _GamePainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { - // 信箱外框(黑色) - canvas.drawRect( - Rect.fromLTWH(0, 0, size.width, size.height), - Paint()..color = const Color(0xff000000)); + // 首帧可能尚未完成布局,尺寸为 0 时不要绘制内容(stageMetrics 无效)。 + final m = stageMetrics(size); + if (!m.isValid) return; - // 首帧可能尚未完成布局,尺寸为 0 时不要绘制内容(letterboxRect 返回 zero)。 - final rect = letterboxRect(size); - if (rect.isEmpty) return; + // 同步虚拟视口(只改纯数值字段,不触发任何通知 —— paint 阶段禁止 notifier)。 + world.setViewport(m.vw, m.vh); canvas.save(); - canvas.translate(rect.left, rect.top); - canvas.scale(rect.width / W, rect.width / W); - canvas.clipRect(Rect.fromLTWH(0, 0, W, H)); + canvas.scale(m.scale); + canvas.clipRect(Rect.fromLTWH(0, 0, m.vw, m.vh)); + // 兜底底色:即使 world.render 抛异常也不会露出透明/白屏。 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), + ); // 渲染异常原本会被静默吞掉,画面变成纯黑。 // 偶发的一帧异常不该让整局报废 —— 只记录日志;但若连续多帧都失败 // (比如某张地图/某种渲染路径真有问题),才升级成占屏错误以便真机诊断。 @@ -84,9 +102,10 @@ class _GamePainter extends CustomPainter { if (!_loggedFirstPaint) { _loggedFirstPaint = true; debugPrint( - '[boot] canvas rendered: screen=${size.width}x${size.height} ' - 'stage=${rect.width}x${rect.height} at (${rect.left},${rect.top}) ' - 'state=${world.state}'); + '[boot] canvas rendered: screen=${size.width}x${size.height} ' + 'viewport=${m.vw.toStringAsFixed(0)}x${m.vh.toStringAsFixed(0)} ' + 'state=${world.state}', + ); } } diff --git a/app/lib/src/ui/screens.dart b/app/lib/src/ui/screens.dart index 5a562d4..e93eccf 100644 --- a/app/lib/src/ui/screens.dart +++ b/app/lib/src/ui/screens.dart @@ -3,8 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/services.dart' show KeyDownEvent; import 'package:iwbtc/src/core/save.dart'; -import 'package:iwbtc/src/game/levels.dart' - show LEVELS, TYPE_LABEL, TYPE_CLASS; +import 'package:iwbtc/src/game/levels.dart' show LEVELS; import 'package:iwbtc/src/ui/touch_layout.dart' show metaSettings, KEY_ACTIONS, keyLabel, defaultBindings; import 'package:iwbtc/src/core/input.dart' show Input; @@ -19,9 +18,6 @@ const List> TABS = >[ ['custom', '自制'], ]; -String typeLabel(String t) => TYPE_LABEL[t] ?? '自制'; -String typeClass(String t) => TYPE_CLASS[t] ?? 't-custom'; - String stars(int d) { d = d.clamp(0, 5); return '★' * d + '☆' * (5 - d); @@ -32,64 +28,78 @@ class TitleScreen extends StatelessWidget { final World world; final VoidCallback onSettings; final VoidCallback onEditor; - const TitleScreen( - {super.key, - required this.world, - required this.onSettings, - required this.onEditor}); + const TitleScreen({ + super.key, + required this.world, + required this.onSettings, + required this.onEditor, + }); @override Widget build(BuildContext context) { final user = metaSettings()['username'] as String? ?? ''; return _MenuBg( - // 横屏手机逻辑高度只有 ~360px,标题内容更高 —— 不够高时允许滚动, - // 够高时仍居中(ConstrainedBox minHeight 保底),杜绝 BOTTOM OVERFLOWED。 - child: _FitScroll( + // 横屏手机逻辑高度只有 ~360px,标题内容放不下时**整体等比缩小**居中显示 + //(FittedBox.scaleDown:放得下就按原尺寸,放不下才缩),既不滚动也不溢出。 + child: FittedBox( + fit: BoxFit.scaleDown, child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('FAN-MADE TRIBUTE PROJECT', + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'FAN-MADE TRIBUTE PROJECT', style: TextStyle( - color: Color(0xff8a8aa8), - fontSize: 12, - letterSpacing: 2)), - const SizedBox(height: 8), - RichText( - text: const TextSpan( - children: [ - TextSpan( + color: Color(0xff8a8aa8), + fontSize: 12, + letterSpacing: 2, + ), + ), + const SizedBox(height: 8), + RichText( + text: const TextSpan( + children: [ + TextSpan( text: 'I WANNA BE\nTHE ', style: TextStyle( - color: Color(0xffffffff), - fontSize: 44, - fontWeight: FontWeight.bold, - height: 1.05)), - TextSpan( + color: Color(0xffffffff), + fontSize: 44, + fontWeight: FontWeight.bold, + height: 1.05, + ), + ), + TextSpan( text: 'CREATOR', style: TextStyle( - color: Color(0xffff5c7a), - fontSize: 44, - fontWeight: FontWeight.bold, - height: 1.05)), - ], + color: Color(0xffff5c7a), + fontSize: 44, + fontWeight: FontWeight.bold, + height: 1.05, + ), + ), + ], + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - const Text('跳刺 · 耐久 · BOSS · 坑向 — 23 个内置关卡', - style: TextStyle(color: Color(0xffc8c8d8), fontSize: 14)), - const SizedBox(height: 28), - _MenuButton('关卡选择', onPressed: () => world.toLevels()), - _MenuButton('自制关卡', onPressed: onEditor), - _MenuButton('设置', onPressed: onSettings), - const SizedBox(height: 18), - Text('玩家:${user.isEmpty ? '无名氏' : user}', - style: const TextStyle(color: Color(0xff8a8aa8), fontSize: 13)), - const SizedBox(height: 18), - const Text( + const SizedBox(height: 8), + const Text( + '跳刺 · 耐久 · BOSS · 坑向 — 23 个内置关卡', + style: TextStyle(color: Color(0xffc8c8d8), fontSize: 14), + ), + const SizedBox(height: 28), + _MenuButton('关卡选择', onPressed: () => world.toLevels()), + _MenuButton('自制关卡', onPressed: onEditor), + _MenuButton('设置', onPressed: onSettings), + const SizedBox(height: 18), + Text( + '玩家:${user.isEmpty ? '无名氏' : user}', + style: const TextStyle(color: Color(0xff8a8aa8), fontSize: 13), + ), + const SizedBox(height: 18), + const Text( '非商业同人作品 · 致敬 I Wanna Be The Guy / I Wanna Be The Creator', - style: TextStyle(color: Color(0xff55556a), fontSize: 11)), - ], + style: TextStyle(color: Color(0xff55556a), fontSize: 11), + ), + ], ), ), ); @@ -101,11 +111,12 @@ class LevelSelectScreen extends StatelessWidget { final World world; final ValueListenable reload; final VoidCallback onChanged; - const LevelSelectScreen( - {super.key, - required this.world, - required this.reload, - required this.onChanged}); + const LevelSelectScreen({ + super.key, + required this.world, + required this.reload, + required this.onChanged, + }); void _play(Map lv) { world.fromEditor = false; @@ -128,8 +139,11 @@ class _LevelBody extends StatefulWidget { final World world; final void Function(Map) onPlay; final VoidCallback onChanged; - const _LevelBody( - {required this.world, required this.onPlay, required this.onChanged}); + const _LevelBody({ + required this.world, + required this.onPlay, + required this.onChanged, + }); @override State<_LevelBody> createState() => _LevelBodyState(); @@ -156,12 +170,17 @@ class _LevelBodyState extends State<_LevelBody> { children: [ Row( children: [ - const Text('关卡选择', - style: TextStyle(color: Color(0xffffffff), fontSize: 22)), + const Text( + '关卡选择', + style: TextStyle(color: Color(0xffffffff), fontSize: 22), + ), const Spacer(), if (_filter == 'custom') - _SmallButton(_manage ? '完成' : '管理地图', on: _manage, - onPressed: () => setState(() => _manage = !_manage)), + _SmallButton( + _manage ? '完成' : '管理地图', + on: _manage, + onPressed: () => setState(() => _manage = !_manage), + ), _SmallButton('返回', onPressed: () => widget.world.toTitle()), ], ), @@ -171,11 +190,14 @@ class _LevelBodyState extends State<_LevelBody> { final on = _filter == t[0]; return Padding( padding: const EdgeInsets.only(right: 6), - child: _Tab(t[1], on, - onPressed: () => setState(() { - _filter = t[0]; - _manage = false; - })), + child: _Tab( + t[1], + on, + onPressed: () => setState(() { + _filter = t[0]; + _manage = false; + }), + ), ); }).toList(), ), @@ -183,28 +205,31 @@ class _LevelBodyState extends State<_LevelBody> { Expanded( child: list.isEmpty ? const Center( - child: Text('这个分类下还没有关卡。去「自制关卡」造一个吧。', - style: TextStyle(color: Color(0xff666680)))) - : GridView.count( + child: Text( + '这个分类下还没有关卡。去「自制关卡」造一个吧。', + style: TextStyle(color: Color(0xff666680)), + ), + ) + : GridView.count( crossAxisCount: 5, childAspectRatio: 0.8, mainAxisSpacing: 10, crossAxisSpacing: 10, - children: list.map((l) => _LevelCard( - lv: l, - manage: _manage, - onPlay: () => widget.onPlay(l), - onDelete: () { - Save.removeCustom(l['id'] as String); - widget.onChanged(); - }, - )).toList(), + children: list + .map( + (l) => _LevelCard( + lv: l, + manage: _manage, + onPlay: () => widget.onPlay(l), + onDelete: () { + Save.removeCustom(l['id'] as String); + widget.onChanged(); + }, + ), + ) + .toList(), ), ), - Wrap( - spacing: 6, - children: TABS.map((t) => _Tag(typeLabel(t[0]), typeClass(t[0]))).toList(), - ), ], ); } @@ -215,11 +240,12 @@ class _LevelCard extends StatelessWidget { final bool manage; final VoidCallback onPlay; final VoidCallback onDelete; - const _LevelCard( - {required this.lv, - required this.manage, - required this.onPlay, - required this.onDelete}); + const _LevelCard({ + required this.lv, + required this.manage, + required this.onPlay, + required this.onDelete, + }); @override Widget build(BuildContext context) { @@ -236,31 +262,42 @@ class _LevelCard extends StatelessWidget { color: const Color(0xff15151f), borderRadius: BorderRadius.circular(8), border: Border.all( - color: rec != null - ? const Color(0xffb06bff) - : const Color(0xff2a2a3a)), + color: rec != null + ? const Color(0xffb06bff) + : const Color(0xff2a2a3a), + ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - builtin ? (lv['index'] + 1).toString().padLeft(2, '0') : 'U${lv['no'] ?? ''}', - style: const TextStyle(color: Color(0xff8a8aa8), fontSize: 12), + builtin + ? (lv['index'] + 1).toString().padLeft(2, '0') + : 'U${lv['no'] ?? ''}', + style: const TextStyle( + color: Color(0xff8a8aa8), + fontSize: 12, + ), ), const SizedBox(height: 4), Expanded( - child: Text(lv['name'] ?? 'STAGE', - style: const TextStyle( - color: Color(0xffffffff), fontSize: 14)), + child: Text( + lv['name'] ?? 'STAGE', + style: const TextStyle( + color: Color(0xffffffff), + fontSize: 14, + ), + ), ), Row( children: [ - _Tag(typeLabel(lv['type'] ?? 'custom'), - typeClass(lv['type'] ?? 'custom')), - const SizedBox(width: 4), - Text(stars(diff), - style: const TextStyle( - color: Color(0xffffc63a), fontSize: 11)), + Text( + stars(diff), + style: const TextStyle( + color: Color(0xffffc63a), + fontSize: 11, + ), + ), ], ), const SizedBox(height: 4), @@ -269,15 +306,10 @@ class _LevelCard extends StatelessWidget { ? '✔ 死亡 ${rec['deaths']} · ${(rec['time'] as int?) != null ? _fmt(rec['time'] as int) : ''}' : '未通关', style: const TextStyle( - color: Color(0xff8a8aa8), fontSize: 11), - ), - if (rec != null) - const Positioned( - top: 6, - right: 6, - child: Text('✔', - style: TextStyle(color: Color(0xffb06bff))), + color: Color(0xff8a8aa8), + fontSize: 11, ), + ), ], ), ), @@ -296,11 +328,25 @@ class _LevelCard extends StatelessWidget { shape: BoxShape.circle, ), child: const Center( - child: Text('✕', - style: TextStyle(color: Color(0xffffffff), fontSize: 12))), + child: Text( + '✕', + style: TextStyle(color: Color(0xffffffff), fontSize: 12), + ), + ), ), ), ), + // 已通关角标:必须是外层 Stack 的直接子级(Positioned 不能嵌在 + // Column 里,否则报 Incorrect use of ParentDataWidget)。 + if (rec != null) + const Positioned( + top: 6, + right: 6, + child: Text( + '✔', + style: TextStyle(color: Color(0xffb06bff)), + ), + ), ], ); } @@ -325,15 +371,17 @@ class PauseScreen extends StatelessWidget { title: '暂停', children: [ _MenuButton('继续', onPressed: () => world.resume()), - _MenuButton('从头再来(清空存档点)', - onPressed: () => world.restart()), - _MenuButton(quitLabel, onPressed: () { - if (world.fromEditor) { - world.toEditor(); - } else { - world.toLevels(); - } - }), + _MenuButton('从头再来(清空存档点)', onPressed: () => world.restart()), + _MenuButton( + quitLabel, + onPressed: () { + if (world.fromEditor) { + world.toEditor(); + } else { + world.toLevels(); + } + }, + ), ], ); } @@ -343,8 +391,7 @@ class PauseScreen extends StatelessWidget { class ResultScreen extends StatelessWidget { final World world; final Map result; - const ResultScreen( - {super.key, required this.world, required this.result}); + const ResultScreen({super.key, required this.world, required this.result}); @override Widget build(BuildContext context) { @@ -355,21 +402,26 @@ class ResultScreen extends StatelessWidget { _ResultLine('关卡', result['name'] ?? '—'), _ResultLine('用时', _fmt(result['ms'] ?? 0)), _ResultLine('死亡', (result['deaths'] ?? 0).toString()), - _ResultLine('苹果', - (result['totalApples'] ?? 0) > 0 - ? '${result['apples']}/${result['totalApples']}' - : '—'), + _ResultLine( + '苹果', + (result['totalApples'] ?? 0) > 0 + ? '${result['apples']}/${result['totalApples']}' + : '—', + ), if (result['better'] == true) const Padding( - padding: EdgeInsets.only(top: 6), - child: Text('★ 新纪录!', - style: TextStyle(color: Color(0xffffc63a), fontSize: 16)), + padding: EdgeInsets.only(top: 4), + child: Text( + '★ 新纪录!', + style: TextStyle(color: Color(0xffffc63a), fontSize: 15), + ), ), - const SizedBox(height: 12), - if (hasNext) - _MenuButton('下一关', onPressed: () => world.nextLevel()), - _MenuButton('再挑战', onPressed: () => world.startLevel( - world.data!, world.idx)), + const SizedBox(height: 8), + if (hasNext) _MenuButton('下一关', onPressed: () => world.nextLevel()), + _MenuButton( + '再挑战', + onPressed: () => world.startLevel(world.data!, world.idx), + ), _MenuButton('关卡选择', onPressed: () => world.toLevels()), ], ); @@ -380,12 +432,15 @@ class ResultScreen extends StatelessWidget { class SettingsScreen extends StatefulWidget { final World world; final VoidCallback onBack; + final VoidCallback onTouchEditor; // 进入独立全屏的「自定义按键」页 final bool isTouch; - const SettingsScreen( - {super.key, - required this.world, - required this.onBack, - required this.isTouch}); + const SettingsScreen({ + super.key, + required this.world, + required this.onBack, + required this.onTouchEditor, + required this.isTouch, + }); @override State createState() => _SettingsScreenState(); @@ -399,7 +454,6 @@ class _SettingsScreenState extends State { final TextEditingController _nameCtl = TextEditingController(); final FocusNode _listenNode = FocusNode(); - final Map _touch = {}; // 手柄布局工作副本 @override void initState() { @@ -409,27 +463,16 @@ class _SettingsScreenState extends State { 'username': s['username'] ?? '', 'keys': _cloneKeys(s['keys'] as Map), }; - // 手柄布局工作副本(cx/cy/size 三个数值) - final t = s['touch'] as Map; - t.forEach((k, v) => _touch[k.toString()] = _cloneTouch(v as Map)); _nameCtl.text = _work['username'] as String; } /// 键位表克隆:action -> List。 Map _cloneKeys(Map m) => { - for (final e in m.entries) - e.key.toString(): - List.from((e.value as List).map((x) => x.toString())), - }; - - /// 手柄布局克隆:button -> {cx, cy, size}(数值)。 - /// 注意这里**不能**复用 _cloneKeys —— 两种结构的值类型不同(List vs Map), - /// 早期混用导致点「设置」直接抛 CastError,设置界面根本打不开。 - Map _cloneTouch(Map m) => { - 'cx': double.tryParse(m['cx'].toString()) ?? 50, - 'cy': double.tryParse(m['cy'].toString()) ?? 50, - 'size': double.tryParse(m['size'].toString()) ?? 50, - }; + for (final e in m.entries) + e.key.toString(): List.from( + (e.value as List).map((x) => x.toString()), + ), + }; String? _conflictInfo(String code, String act, int idx) { for (final a in KEY_ACTIONS) { @@ -454,15 +497,16 @@ class _SettingsScreenState extends State { } final clash = _conflictInfo(code, _listeningKey!, _listeningIdx); if (clash != null) { - setState(() => _conflict = { - 'code': code, - 'label': clash, - 'act': _listeningKey!, - }); + setState( + () => _conflict = { + 'code': code, + 'label': clash, + 'act': _listeningKey!, + }, + ); return KeyEventResult.ignored; } - final arr = - (_work['keys'][_listeningKey!] as List?) ?? []; + final arr = (_work['keys'][_listeningKey!] as List?) ?? []; if (_listeningIdx >= arr.length) { arr.add(code); } else { @@ -478,10 +522,13 @@ class _SettingsScreenState extends State { void _confirm() { final s = metaSettings(); - _work['username'] = _nameCtl.text.trim().substring(0, _nameCtl.text.trim().length.clamp(0, 16)); + _work['username'] = _nameCtl.text.trim().substring( + 0, + _nameCtl.text.trim().length.clamp(0, 16), + ); s['username'] = _work['username']; s['keys'] = _work['keys']; - s['touch'] = _touch; + // touch 布局已改由独立的「自定义按键」页直接写存档,这里不再代管。 Save.flush(); Input.applyBindings(_work['keys'] as Map>); widget.onBack(); @@ -498,52 +545,69 @@ class _SettingsScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - const Text('设置', - style: TextStyle(color: Color(0xffffffff), fontSize: 22)), - const Spacer(), - _SmallButton('返回', onPressed: widget.onBack), - ]), - const SizedBox(height: 14), - Row(children: [ - const Text('用户名', style: TextStyle(color: Color(0xffc8c8d8))), - const SizedBox(width: 10), - SizedBox( - width: 180, - child: TextField( - controller: _nameCtl, - maxLength: 16, - style: const TextStyle(color: Color(0xffffffff)), - decoration: const InputDecoration( - hintText: '无名氏', - hintStyle: TextStyle(color: Color(0xff55556a)), - isCollapsed: true, - contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8), - border: OutlineInputBorder(), + Row( + children: [ + const Text( + '设置', + style: TextStyle(color: Color(0xffffffff), fontSize: 22), ), - ), + const Spacer(), + _SmallButton('返回', onPressed: widget.onBack), + ], ), - const SizedBox(width: 10), - const Text('显示在标题界面', - style: TextStyle(color: Color(0xff55556a), fontSize: 12)), - ]), - const SizedBox(height: 16), - if (!widget.isTouch) ...[ - const Text('键盘按键', - style: TextStyle(color: Color(0xffc8c8d8))), - const SizedBox(height: 4), - const Text('点击按键后按下新键即可重绑;同一动作可绑定多个键,点 × 删除', - style: TextStyle(color: Color(0xff55556a), fontSize: 12)), - const SizedBox(height: 8), - if (_conflict != null) - Container( - padding: const EdgeInsets.all(6), - color: const Color(0x33ff5c7a), - child: Text( - '「${keyLabel(_conflict!['code']!)}」已被【${_conflict!['label']}】占用,请换一个键(Esc 取消)', - style: const TextStyle(color: Color(0xffffffff), fontSize: 12)), + const SizedBox(height: 14), + Row( + children: [ + const Text('用户名', style: TextStyle(color: Color(0xffc8c8d8))), + const SizedBox(width: 10), + SizedBox( + width: 180, + child: TextField( + controller: _nameCtl, + maxLength: 16, + style: const TextStyle(color: Color(0xffffffff)), + decoration: const InputDecoration( + hintText: '无名氏', + hintStyle: TextStyle(color: Color(0xff55556a)), + isCollapsed: true, + contentPadding: EdgeInsets.symmetric( + vertical: 6, + horizontal: 8, + ), + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 10), + const Text( + '显示在标题界面', + style: TextStyle(color: Color(0xff55556a), fontSize: 12), + ), + ], + ), + const SizedBox(height: 16), + if (!widget.isTouch) ...[ + const Text('键盘按键', style: TextStyle(color: Color(0xffc8c8d8))), + const SizedBox(height: 4), + const Text( + '点击按键后按下新键即可重绑;同一动作可绑定多个键,点 × 删除', + style: TextStyle(color: Color(0xff55556a), fontSize: 12), ), - ...KEY_ACTIONS.map((a) => _KeyRow( + const SizedBox(height: 8), + if (_conflict != null) + Container( + padding: const EdgeInsets.all(6), + color: const Color(0x33ff5c7a), + child: Text( + '「${keyLabel(_conflict!['code']!)}」已被【${_conflict!['label']}】占用,请换一个键(Esc 取消)', + style: const TextStyle( + color: Color(0xffffffff), + fontSize: 12, + ), + ), + ), + ...KEY_ACTIONS.map( + (a) => _KeyRow( act: a[0], label: a[1], keys: (_work['keys'][a[0]] as List?) ?? [], @@ -561,33 +625,50 @@ class _SettingsScreenState extends State { _listeningIdx = 1 << 30; _conflict = null; }), - )), - ] else ...[ - const Text('手机操作按钮', - style: TextStyle(color: Color(0xffc8c8d8))), - const SizedBox(height: 4), - const Text('拖动按钮调整位置,拖动右下角圆点调整大小(仅移动端生效)', - style: TextStyle(color: Color(0xff55556a), fontSize: 12)), - const SizedBox(height: 8), - _TouchPreview(touch: _touch), + ), + ), + ] else ...[ + const Text( + '手机操作按钮', + style: TextStyle(color: Color(0xffc8c8d8)), + ), + const SizedBox(height: 4), + const Text( + '进入全屏编辑页,像玩游戏时一样拖动按钮调整位置与大小', + style: TextStyle(color: Color(0xff55556a), fontSize: 12), + ), + const SizedBox(height: 10), + ElevatedButton( + onPressed: widget.onTouchEditor, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xff2a2a3a), + foregroundColor: const Color(0xffffffff), + padding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 10, + ), + ), + child: const Text('自定义按键', style: TextStyle(fontSize: 15)), + ), + ], + // 纵向 Spacer 在滚动容器(无界高度)里会抛异常,换成固定间距。 + const SizedBox(height: 16), + Row( + children: [ + _SmallButton( + '恢复默认', + onPressed: () { + setState(() { + _work['keys'] = defaultBindings(); + _nameCtl.text = ''; + }); + }, + ), + const Spacer(), + _SmallButton('确定', on: true, onPressed: _confirm), + ], + ), ], - // 纵向 Spacer 在滚动容器(无界高度)里会抛异常,换成固定间距。 - const SizedBox(height: 16), - Row(children: [ - _SmallButton('恢复默认', onPressed: () { - setState(() { - _work['keys'] = defaultBindings(); - final s = metaSettings(); - _touch.clear(); - (s['touch'] as Map).forEach( - (k, v) => _touch[k.toString()] = _cloneTouch(v as Map)); - _nameCtl.text = ''; - }); - }), - const Spacer(), - _SmallButton('确定', on: true, onPressed: _confirm), - ]), - ], ), ), ), @@ -603,214 +684,95 @@ class _KeyRow extends StatelessWidget { final void Function(int) onChip; final void Function(int) onRemove; final VoidCallback onAdd; - const _KeyRow( - {required this.act, - required this.label, - required this.keys, - required this.listening, - required this.onChip, - required this.onRemove, - required this.onAdd}); + const _KeyRow({ + required this.act, + required this.label, + required this.keys, + required this.listening, + required this.onChip, + required this.onRemove, + required this.onAdd, + }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 3), - child: Row(children: [ - SizedBox( + child: Row( + children: [ + SizedBox( width: 110, - child: Text(label, - style: const TextStyle(color: Color(0xffc8c8d8)))), - Wrap( - spacing: 6, - children: [ - for (int i = 0; i < keys.length; i++) - GestureDetector( - onTap: () => onChip(i), - child: Container( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: listening && i == 0 - ? const Color(0xff5a2030) - : const Color(0xff2a2a3a), - borderRadius: BorderRadius.circular(4), - ), - child: Row(children: [ - Text(keyLabel(keys[i] as String), - style: const TextStyle(color: Color(0xffffffff))), - const SizedBox(width: 4), - GestureDetector( - onTap: () => onRemove(i), - child: const Text('×', - style: TextStyle(color: Color(0xffff5c7a))), - ), - ]), - ), - ), - GestureDetector( - onTap: onAdd, - child: Container( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: const Color(0xff1a1a26), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: const Color(0xff3a3a4a)), - ), - child: const Text('+ 添加', - style: TextStyle(color: Color(0xff8a8aa8), fontSize: 11)), - ), + child: Text( + label, + style: const TextStyle(color: Color(0xffc8c8d8)), ), - if (listening) - const Text('按下一个键…(Esc 取消)', - style: TextStyle(color: Color(0xffffe9a8), fontSize: 11)), - ], - ), - ]), - ); - } -} - -class _TouchPreview extends StatelessWidget { - final Map touch; - const _TouchPreview({required this.touch}); - - @override - Widget build(BuildContext context) { - return Container( - height: 280, - decoration: BoxDecoration( - color: const Color(0xff0a0a14), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xff2a2a3a)), - ), - child: LayoutBuilder( - builder: (BuildContext _, BoxConstraints cons) { - // 预览区按**真实画面比例 800×608** 居中显示。 - // 移动端虚拟手柄锚定的是画面区域(letterboxRect)而非整屏, - // 比例一致才能做到"拖到哪、实际就在哪"。 - final double aspect = W / H; - double w = cons.maxWidth - 16; - double h = w / aspect; - final double maxH = cons.maxHeight - 34; // 顶部提示文字占位 - if (h > maxH) { - h = maxH; - w = h * aspect; - } - return Column( + ), + Wrap( + spacing: 6, children: [ - const Padding( - padding: EdgeInsets.symmetric(vertical: 6), - child: Center( - child: Text('拖动按钮移动位置(预览比例与实际画面一致)', - style: TextStyle(color: Color(0xff55556a), fontSize: 11)), - ), - ), - SizedBox( - width: w, - height: h, - child: Stack( - children: [ - Container( - decoration: BoxDecoration( - color: const Color(0x22141826), - border: Border.all(color: const Color(0xff2a2a44)), - ), + for (int i = 0; i < keys.length; i++) + GestureDetector( + onTap: () => onChip(i), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, ), - for (final entry in touch.entries) - _DragBtn(entry.key, entry.value as Map, - w, h), - ], + decoration: BoxDecoration( + color: listening && i == 0 + ? const Color(0xff5a2030) + : const Color(0xff2a2a3a), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + children: [ + Text( + keyLabel(keys[i] as String), + style: const TextStyle(color: Color(0xffffffff)), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: () => onRemove(i), + child: const Text( + '×', + style: TextStyle(color: Color(0xffff5c7a)), + ), + ), + ], + ), + ), + ), + GestureDetector( + onTap: onAdd, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: const Color(0xff1a1a26), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: const Color(0xff3a3a4a)), + ), + child: const Text( + '+ 添加', + style: TextStyle(color: Color(0xff8a8aa8), fontSize: 11), + ), ), ), + if (listening) + const Text( + '按下一个键…(Esc 取消)', + style: TextStyle(color: Color(0xffffe9a8), fontSize: 11), + ), ], - ); - }, + ), + ], ), ); } } -class _DragBtn extends StatefulWidget { - final String key2; - final Map p; - final double boxW; - final double boxH; - const _DragBtn(this.key2, this.p, this.boxW, this.boxH); - - @override - State<_DragBtn> createState() => _DragBtnState(); -} - -class _DragBtnState extends State<_DragBtn> { - @override - Widget build(BuildContext context) { - final p = widget.p; - final cx = (p['cx'] as num).toDouble(); - final cy = (p['cy'] as num).toDouble(); - final size = (p['size'] as num).toDouble(); - // 与真机手柄同一套换算:真实按钮(屏幕像素)= size × (画面宽/800), - // 预览只是把画面缩小了 boxW/800 倍,因此这里用同一个系数,所见即所得。 - final double k = widget.boxW / 800; - final double sz = size * k; - return Positioned( - left: cx / 100 * widget.boxW - sz / 2, - top: cy / 100 * widget.boxH - sz / 2, - width: sz, - height: sz, - child: GestureDetector( - onPanUpdate: (d) { - // delta 换算成预览区的百分比(拖到哪里,画面上的相对位置就到哪里) - final dx = d.delta.dx / widget.boxW * 100; - final dy = d.delta.dy / widget.boxH * 100; - setState(() { - p['cx'] = (cx + dx).clamp(3.0, 97.0); - p['cy'] = (cy + dy).clamp(3.0, 97.0); - }); - }, - child: Container( - decoration: BoxDecoration( - color: const Color(0x553a6ea5), - shape: widget.key2 == 'jump' - ? BoxShape.circle - : BoxShape.rectangle, - borderRadius: widget.key2 == 'jump' - ? null - : BorderRadius.circular(10), - border: Border.all(color: const Color(0xff7ae0ff)), - ), - child: Center( - child: Text(_btnLabel(widget.key2), - style: const TextStyle(color: Color(0xffffffff), fontSize: 14)), - ), - ), - ), - ); - } -} - -String _btnLabel(String k) { - switch (k) { - case 'left': - return '◀'; - case 'right': - return '▶'; - case 'jump': - return '跳'; - case 'shoot': - return '🔫'; - case 'down': - return '▼'; - case 'pause': - return '❚❚'; - case 'suicide': - return '↺'; - default: - return k; - } -} - // ---------------- 通用小部件 ---------------- /// 菜单内容自适应包装:空间够时按原布局(子级自己决定居中/顶对齐), /// 空间不够(横屏手机逻辑高度 ~360px)时允许整体滚动,杜绝 BOTTOM OVERFLOWED。 @@ -823,12 +785,13 @@ class _FitScroll extends StatelessWidget { return LayoutBuilder( builder: (BuildContext context, BoxConstraints c) => SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: c.maxHeight.isFinite ? c.maxHeight : 0), - child: child, - ), - ), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: c.maxHeight.isFinite ? c.maxHeight : 0, + ), + child: child, + ), + ), ); } } @@ -860,7 +823,7 @@ class _MenuButton extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 5), + padding: const EdgeInsets.symmetric(vertical: 3), child: SizedBox( width: 240, child: ElevatedButton( @@ -868,9 +831,9 @@ class _MenuButton extends StatelessWidget { style: ElevatedButton.styleFrom( backgroundColor: const Color(0xff2a2a3a), foregroundColor: const Color(0xffffffff), - padding: const EdgeInsets.symmetric(vertical: 12), + padding: const EdgeInsets.symmetric(vertical: 8), ), - child: Text(text, style: const TextStyle(fontSize: 16)), + child: Text(text, style: const TextStyle(fontSize: 14)), ), ), ); @@ -887,10 +850,12 @@ class _SmallButton extends StatelessWidget { return ElevatedButton( onPressed: onPressed, style: ElevatedButton.styleFrom( - backgroundColor: - on ? const Color(0xff5a3a7a) : const Color(0xff1a1a26), + backgroundColor: on ? const Color(0xff5a3a7a) : const Color(0xff1a1a26), foregroundColor: const Color(0xffffffff), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, ), child: Text(text, style: const TextStyle(fontSize: 13)), ); @@ -907,8 +872,7 @@ class _Tab extends StatelessWidget { return ElevatedButton( onPressed: onPressed, style: ElevatedButton.styleFrom( - backgroundColor: - on ? const Color(0xffb06bff) : const Color(0xff1a1a26), + backgroundColor: on ? const Color(0xffb06bff) : const Color(0xff1a1a26), foregroundColor: const Color(0xffffffff), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), ), @@ -917,41 +881,6 @@ class _Tab extends StatelessWidget { } } -class _Tag extends StatelessWidget { - final String text; - final String cls; - const _Tag(this.text, this.cls); - @override - Widget build(BuildContext context) { - final color = _tagColor(cls); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.2 * 255), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: color), - ), - child: - Text(text, style: TextStyle(color: color, fontSize: 11)), - ); - } -} - -Color _tagColor(String cls) { - switch (cls) { - case 't-jump': - return const Color(0xff3ddc84); - case 't-endur': - return const Color(0xff4f9e46); - case 't-boss': - return const Color(0xffff5c7a); - case 't-troll': - return const Color(0xffc9a86b); - default: - return const Color(0xff7ae0ff); - } -} - class _Modal extends StatelessWidget { final String title; final List children; @@ -962,8 +891,10 @@ class _Modal extends StatelessWidget { color: const Color(0x99000000), child: Center( child: Container( - width: 320, - padding: const EdgeInsets.all(20), + // 固定紧凑卡片:高度只包住内容(mainAxisSize.min + Center), + // 不会撑满全屏;内容也已压到能在 ~360 逻辑高的横屏手机里完整放下。 + width: 300, + padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: const Color(0xff15151f), borderRadius: BorderRadius.circular(12), @@ -972,12 +903,15 @@ class _Modal extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text(title, - style: const TextStyle( - color: Color(0xffffffff), - fontSize: 26, - fontWeight: FontWeight.bold)), - const SizedBox(height: 16), + Text( + title, + style: const TextStyle( + color: Color(0xffffffff), + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), ...children, ], ), @@ -994,16 +928,19 @@ class _ResultLine extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 3), - child: Row(children: [ - SizedBox( - width: 60, - child: Text(k, - style: const TextStyle(color: Color(0xff8a8aa8)))), - Text(v, - style: const TextStyle( - color: Color(0xffffffff), fontSize: 15)), - ]), + padding: const EdgeInsets.symmetric(vertical: 1), + child: Row( + children: [ + SizedBox( + width: 56, + child: Text(k, style: const TextStyle(color: Color(0xff8a8aa8))), + ), + Text( + v, + style: const TextStyle(color: Color(0xffffffff), fontSize: 13), + ), + ], + ), ); } } diff --git a/app/lib/src/ui/touch_controls.dart b/app/lib/src/ui/touch_controls.dart index 12684b9..107b4eb 100644 --- a/app/lib/src/ui/touch_controls.dart +++ b/app/lib/src/ui/touch_controls.dart @@ -2,20 +2,19 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; import 'package:iwbtc/src/core/input.dart' show Input; -import 'package:iwbtc/src/ui/touch_layout.dart' - show BUTTONS, metaSettings; +import 'package:iwbtc/src/ui/touch_layout.dart' show BUTTONS, metaSettings; import 'package:iwbtc/src/game/world.dart' show World, W; -import 'package:iwbtc/src/ui/game_canvas.dart' show letterboxRect; class TouchControls extends StatelessWidget { final World world; final ValueNotifier visible; final ValueNotifier hasGun; - const TouchControls( - {super.key, - required this.world, - required this.visible, - required this.hasGun}); + const TouchControls({ + super.key, + required this.world, + required this.visible, + required this.hasGun, + }); @override Widget build(BuildContext context) { @@ -24,11 +23,10 @@ class TouchControls extends StatelessWidget { valueListenable: visible, builder: (_, bool v, __) { if (!v) return const SizedBox(); - // 按钮锚定在「信箱缩放后的画面矩形」内,而不是整块屏幕: - // 横屏时两侧是黑边,若按全屏百分比定位,左/右/跳跃键会飘到黑边上。 + // 画面已铺满全屏(无信箱黑边),按钮直接按整屏百分比定位。 return LayoutBuilder( builder: (_, BoxConstraints cons) { - final area = letterboxRect(Size(cons.maxWidth, cons.maxHeight)); + final area = Rect.fromLTWH(0, 0, cons.maxWidth, cons.maxHeight); return Stack( children: [ for (final b in BUTTONS) @@ -68,17 +66,18 @@ class _TouchButton extends StatefulWidget { /// 空 Rect 表示尚未完成布局,此时不渲染。 final Rect area; - const _TouchButton( - {required this.world, - required this.keyName, - required this.label, - this.label2, - required this.shape, - required this.cx, - required this.cy, - required this.size, - required this.hasGun, - required this.area}); + const _TouchButton({ + required this.world, + required this.keyName, + required this.label, + this.label2, + required this.shape, + required this.cx, + required this.cy, + required this.size, + required this.hasGun, + required this.area, + }); @override State<_TouchButton> createState() => _TouchButtonState(); @@ -114,8 +113,14 @@ class _TouchButtonState extends State<_TouchButton> { } void _release() { - if (!_pressed) return; - _pressed = false; + // 注意:即便 _pressed 因事件丢失而处于异常状态,也要强制复位虚拟键, + // 否则 touch['jump'] 会卡在 true(漏接松手事件时极易发生), + // 之后每次按下都因 setTouch 的「值未变则直接 return」短路而触发不了 + // 新的边沿 —— 表现就是「跳跃键经常失灵、时好时坏」。 + if (_pressed) { + _pressed = false; + setState(() {}); + } _shootTimer?.cancel(); _shootTimer = null; final a = widget.keyName; @@ -124,7 +129,6 @@ class _TouchButtonState extends State<_TouchButton> { } else { Input.setTouch(a, false); } - setState(() {}); } @override @@ -143,10 +147,15 @@ class _TouchButtonState extends State<_TouchButton> { top: top, width: sz, height: sz, - child: Listener( - onPointerDown: (_) => _press(), - onPointerUp: (_) => _release(), - onPointerCancel: (_) => _release(), + // 用 GestureDetector(Pan 识别器会「认领」指针)替代 Listener: + // Listener 不捕获指针,手指在按钮上滑动后于按钮外松手时,松手事件 + // 落到别的控件上 → 本按钮的虚拟键永久卡在按下态。Pan 会持续把 + // 抬起/取消事件回传给本按钮,松手必定复位。 + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: (_) => _press(), + onPanEnd: (_) => _release(), + onPanCancel: () => _release(), child: ValueListenableBuilder( valueListenable: widget.hasGun, builder: (_, __, ___) { @@ -160,23 +169,28 @@ class _TouchButtonState extends State<_TouchButton> { : const Color(0x444a5a7a), shape: isJump ? BoxShape.circle : BoxShape.rectangle, borderRadius: isJump ? null : BorderRadius.circular(sz * 0.3), - border: Border.all( - color: const Color(0xff7ae0ff), width: 2), + border: Border.all(color: const Color(0xff7ae0ff), width: 2), ), child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text(widget.label, - style: TextStyle( - color: const Color(0xffffffff), - fontSize: sz * 0.32, - fontWeight: FontWeight.bold)), + Text( + widget.label, + style: TextStyle( + color: const Color(0xffffffff), + fontSize: sz * 0.32, + fontWeight: FontWeight.bold, + ), + ), if (widget.label2 != null) - Text(widget.label2!, - style: TextStyle( - color: const Color(0xffcfe8ff), - fontSize: sz * 0.16)), + Text( + widget.label2!, + style: TextStyle( + color: const Color(0xffcfe8ff), + fontSize: sz * 0.16, + ), + ), ], ), ), diff --git a/app/lib/src/ui/touch_editor.dart b/app/lib/src/ui/touch_editor.dart new file mode 100644 index 0000000..eb3ea11 --- /dev/null +++ b/app/lib/src/ui/touch_editor.dart @@ -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 touchLayoutOf(String key) { + final t = metaSettings()['touch'] as Map; + final m = (t[key] as Map?) ?? const {}; + return { + '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 p) { + (metaSettings()['touch'] as Map)[key] = { + '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 createState() => _TouchLayoutEditorState(); +} + +class _TouchLayoutEditorState extends State { + @override + Widget build(BuildContext context) { + // 背景保持透明:透出下面的实时游戏画布(标题星空+刺地), + // 编辑体验与正常游戏完全一致(全屏沉浸)。 + return Stack( + children: [ + // 按钮编辑层(铺满全屏,与真实手柄同一坐标系) + 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: [ + for (final b in BUTTONS) ...[ + _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: [ + ElevatedButton( + onPressed: () { + final t = metaSettings()['touch'] as Map; + t.clear(); + for (final b in BUTTONS) { + t[b.key] = { + '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: [ + 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), + ), + ), + ), + ); + } +} diff --git a/app/lib/src/ui/touch_layout.dart b/app/lib/src/ui/touch_layout.dart index 1229f7f..24addef 100644 --- a/app/lib/src/ui/touch_layout.dart +++ b/app/lib/src/ui/touch_layout.dart @@ -10,18 +10,29 @@ class TouchBtn { final double cx; final double cy; final double size; - const TouchBtn(this.key, this.label, this.shape, this.cx, this.cy, this.size, - [this.label2]); + const TouchBtn( + this.key, + this.label, + this.shape, + this.cx, + this.cy, + this.size, [ + this.label2, + ]); } +// 默认布局仿照主流 IWBT 手游截图:左下 ◀▶ 并排、右侧大「跳」、 +// 左下角🔫射击 / 右下角▼下蹲、右中上 ↺ 自杀、右上角 ❚❚ 暂停。 const List BUTTONS = [ - TouchBtn('left', '◀', 'round', 12, 80, 62), - TouchBtn('right', '▶', 'round', 24, 80, 62), - TouchBtn('jump', '跳', 'jump', 88, 84, 94), - TouchBtn('shoot', '🔫', 'item', 79, 72, 54, '射击'), - TouchBtn('down', '▼', 'item', 90, 72, 54, '下蹲'), - TouchBtn('pause', '❚❚', 'sm', 81, 58, 42), - TouchBtn('suicide', '↺', 'sm', 90, 58, 42), + TouchBtn('left', '◀', 'round', 12.5, 81, 62), + TouchBtn('right', '▶', 'round', 22, 81, 62), + TouchBtn('shoot', '🔫', 'item', 78.5, 90, 56, '射击'), + TouchBtn('down', '▼', 'item', 94.5, 89, 56, '下蹲'), + TouchBtn('pause', '❚❚', 'sm', 96.5, 8, 40), + TouchBtn('suicide', '↺', 'sm', 91, 56, 52), + // 跳跃键放最后(绘制在最上层):即便与其它键像素重叠,重叠区也优先归跳跃键, + // 避免按下跳跃时落到射击/下蹲键上导致「跳跃键失灵」。 + TouchBtn('jump', '跳', 'jump', 85, 73, 78), ]; const List> KEY_ACTIONS = >[ @@ -38,32 +49,28 @@ const List> KEY_ACTIONS = >[ Map defaultTouch() { final m = {}; for (final b in BUTTONS) { - m[b.key] = { - 'cx': b.cx, - 'cy': b.cy, - 'size': b.size, - }; + m[b.key] = {'cx': b.cx, 'cy': b.cy, 'size': b.size}; } return m; } Map defaultSettings() => { - 'username': '', - 'touch': defaultTouch(), - 'keys': defaultBindings(), - }; + 'username': '', + 'touch': defaultTouch(), + 'keys': defaultBindings(), +}; Map> defaultBindings() => >{ - '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'], - 'restart': ['Backspace'], - 'pause': ['Escape', 'KeyP'], - }; + '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'], + 'restart': ['Backspace'], + 'pause': ['Escape', 'KeyP'], +}; /// 读取/补全存档里的设置对象(保证 touch / keys 字段齐全)。 Map metaSettings() { diff --git a/app/test/title_render_test.dart b/app/test/title_render_test.dart index 556be3b..d71686c 100644 --- a/app/test/title_render_test.dart +++ b/app/test/title_render_test.dart @@ -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/save.dart' show Save; import 'package:iwbtc/src/game/levels.dart' show loadLevels, LEVELS; -import 'package:iwbtc/src/game/world.dart' - show GameHost, World, W, H; -import 'package:iwbtc/src/ui/game_canvas.dart' - show GameCanvas, letterboxRect; +import 'package:iwbtc/src/game/level.dart' show Level; +import 'package:iwbtc/src/game/world.dart' show GameHost, World, W, H; +import 'package:iwbtc/src/ui/game_canvas.dart' show GameCanvas, stageMetrics; 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/core/input.dart' show Input; /// 按典型**横屏手机**分辨率设定虚拟窗口(安卓端已锁定横屏)。 const Size kLandscapePhone = Size(2340, 1080); @@ -62,15 +65,19 @@ void main() { final btn = find.text('关卡选择'); final btnSize = tester.getSize(btn); final btnPos = tester.getTopLeft(btn); - expect(btnSize.width, greaterThan(0), - reason: '按钮宽度塌缩为 0,标题树布局被破坏(Positioned 必须是 Stack 直接子级)'); + expect( + btnSize.width, + greaterThan(0), + reason: '按钮宽度塌缩为 0,标题树布局被破坏(Positioned 必须是 Stack 直接子级)', + ); expect(btnSize.height, greaterThan(0)); expect(btnPos.dx, greaterThanOrEqualTo(0), reason: '按钮被定位到屏外'); expect(btnPos.dy, greaterThanOrEqualTo(0)); }); - testWidgets('横屏小逻辑高度下标题不溢出(真机 BOTTOM OVERFLOWED 回归)', - (WidgetTester tester) async { + testWidgets('横屏小逻辑高度下标题不溢出(真机 BOTTOM OVERFLOWED 回归)', ( + WidgetTester tester, + ) async { // 真机横屏 1080 物理高 ÷ DPR ≈ 360~400 **逻辑**像素;此前测试用的 // 2340×1080 是逻辑尺寸、远高于真机,所以测不出溢出。 await tester.binding.setSurfaceSize(const Size(800, 360)); @@ -91,13 +98,15 @@ void main() { expect(find.text('关卡选择'), findsOneWidget); expect(tester.getSize(find.text('关卡选择')).height, greaterThan(0)); - // 一屏放不下的底部文案必须滚动可达 + // 整体等比缩小方案(FittedBox.scaleDown):不滚动、不溢出, + // 底部版权行在一屏内直接可见(只是整体缩小了)。 final footer = find.textContaining('非商业同人作品'); - await tester.scrollUntilVisible(footer, 120); - expect(footer, findsOneWidget); + expect(footer, findsOneWidget, reason: '标题内容应等比缩小后完整可见,而不是被裁掉'); + final btn = tester.getSize(find.text('关卡选择')); + expect(btn.height, greaterThan(0)); }); - testWidgets('游戏画布拿到真实尺寸并按信箱缩放', (WidgetTester tester) async { + testWidgets('游戏画布铺满全屏(无信箱黑边)且虚拟视口完整包含 800×608', (WidgetTester tester) async { await _boot(tester); final canvas = find.byType(GameCanvas); @@ -107,18 +116,14 @@ void main() { expect(size.width, greaterThan(0), reason: '画布宽度为 0,界面不会显示任何内容'); expect(size.height, greaterThan(0)); expect(size.width, kLandscapePhone.width); - // 800×608 的画面按信箱缩放居中:横屏下高度占满、左右留黑边 - final double s = - size.width / W < size.height / H ? size.width / W : size.height / H; - final stage = letterboxRect(size); - expect(s, greaterThan(0)); - expect(stage.width, closeTo(800 * s, 0.001)); - expect(stage.height, closeTo(608 * s, 0.001)); - expect(stage.left, closeTo((size.width - 800 * s) / 2, 0.001)); - expect(stage.width, lessThan(size.width), - reason: '横屏宽高比大于画面比例,应当左右留黑边'); - expect(stage.height, closeTo(size.height, 0.001), - reason: '横屏下画面高度应被占满'); + + // 横屏比 800×608 宽 → 高度定标 608、视口横向加宽,铺满全屏无黑边 + final m = stageMetrics(size); + expect(m.isValid, isTrue); + expect(m.scale, closeTo(size.height / H, 0.001)); + expect(m.vh, closeTo(H, 0.001)); + expect(m.vw, closeTo(size.width / m.scale, 0.001)); + expect(m.vw, greaterThanOrEqualTo(W), reason: '视口必须完整包含原 800 宽的画面(不裁游戏内容)'); }); testWidgets('HUD / Toast 有可见尺寸(不得横向塌缩)', (WidgetTester tester) async { @@ -142,23 +147,30 @@ void main() { toast.dispose(); }); - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: Stack( - children: [ - Positioned.fill(child: HudOverlay(hud: hud, boss: boss, toast: toast)), - ], + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Stack( + children: [ + Positioned.fill( + child: HudOverlay(hud: hud, boss: boss, toast: toast), + ), + ], + ), ), ), - )); + ); await tester.pump(); // 关卡名必须被撑开到有宽度(Positioned 嵌套错误时会塌缩为 0) final name = find.text('测试关卡'); expect(name, findsOneWidget); final Size size = tester.getSize(name); - expect(size.width, greaterThan(0), - reason: 'HUD 宽度为 0:Positioned 未作为 Stack 直接子级,布局被丢弃'); + expect( + size.width, + greaterThan(0), + reason: 'HUD 宽度为 0:Positioned 未作为 Stack 直接子级,布局被丢弃', + ); expect(size.height, greaterThan(0)); // Toast 同样必须可见 @@ -171,8 +183,7 @@ void main() { expect(find.text('存档 0/4'), findsOneWidget); }); - testWidgets('从标题点进关卡能真正开始游戏(端到端)', - (WidgetTester tester) async { + testWidgets('从标题点进关卡能真正开始游戏(端到端)', (WidgetTester tester) async { await _boot(tester); // 进入关卡选择(注意:游戏 Ticker 常驻,不能用 pumpAndSettle) @@ -195,8 +206,7 @@ void main() { expect(hudName, findsWidgets); }); - testWidgets('横屏下虚拟手柄按钮落在画面区域内(不飘到黑边上)', - (WidgetTester tester) async { + testWidgets('横屏下虚拟手柄按钮锚定全屏且完整在屏内', (WidgetTester tester) async { await tester.binding.setSurfaceSize(kLandscapePhone); addTearDown(() => tester.binding.setSurfaceSize(null)); SharedPreferences.setMockInitialValues({}); @@ -215,45 +225,176 @@ void main() { // 需要一个 World 才有回调目标(构造不触发任何加载)。 final world = World(_Host()); - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: Stack( - children: [ - Positioned.fill( - child: TouchControls( - world: world, visible: visible, hasGun: hasGun), - ), - ], + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Stack( + children: [ + Positioned.fill( + child: TouchControls( + world: world, + visible: visible, + hasGun: hasGun, + ), + ), + ], + ), ), ), - )); + ); await tester.pump(); final Size screen = tester.getSize(find.byType(TouchControls)); - final stage = letterboxRect(screen); - expect(stage.left, greaterThan(0), reason: '横屏下应存在左右黑边'); + // 画面铺满全屏后,按钮直接按整屏百分比锚定(不再有黑边偏移) - // 「◀」左移键必须落在画面矩形内,而不是黑边上 + // 「◀」左移键必须完整在屏内 final leftBtn = find.text('◀'); expect(leftBtn, findsOneWidget); final Size btnSize = tester.getSize(leftBtn); expect(btnSize.width, greaterThan(0)); final Offset pos = tester.getTopLeft(leftBtn); - expect(pos.dx, greaterThanOrEqualTo(stage.left), - reason: '左移键飘到了左侧黑边上(应锚定在画面区域内)'); - expect(pos.dx + btnSize.width, lessThanOrEqualTo(stage.right)); + expect(pos.dx, greaterThanOrEqualTo(0), reason: '左移键被定位到屏外'); + expect(pos.dx + btnSize.width, lessThanOrEqualTo(screen.width)); // 「跳」键同理,且必须完整在屏内 final jumpBtn = find.text('跳'); expect(jumpBtn, findsOneWidget); final Offset jp = tester.getTopLeft(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(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({}); + await tester.runAsync(() async { + await Save.load(); + await loadLevels(); + }); + + final visible = ValueNotifier(true); + final hasGun = ValueNotifier(false); + addTearDown(() { + visible.dispose(); + hasGun.dispose(); + Input.clear(); + }); + + final world = World(_Host()); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Stack( + children: [ + 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({}); + 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 tester.tap(find.text('设置')); @@ -262,12 +403,120 @@ void main() { expect(AppError.current.value, isNull, reason: '设置界面渲染时抛出了异常'); expect(find.byType(SettingsScreen), findsOneWidget); - // 手柄预览区(无线端:安卓默认 isTouch=true,键位区按设计不显示) 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); + + // 进入自定义按键页:应全屏显示,所有虚拟键可编辑 + 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 ['◀', '▶', '跳', '🔫', '▼', '❚❚', '↺']) { + 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({}); + await tester.runAsync(() async { + await Save.load(); + await loadLevels(); + }); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResultScreen( + world: World(_Host()), + result: { + '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({}); + 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(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 { @@ -279,15 +528,18 @@ void main() { await loadLevels(); }); - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: SettingsScreen( - world: World(_Host()), - isTouch: false, - onBack: () {}, + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SettingsScreen( + world: World(_Host()), + isTouch: false, + onBack: () {}, + onTouchEditor: () {}, + ), ), ), - )); + ); await tester.pump(); expect(AppError.current.value, isNull, reason: '设置界面构建时抛出了异常'); @@ -302,6 +554,70 @@ void main() { 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 = { + 'id': 'test-warp', + 'name': 'warp 关卡', + 'type': 'custom', + 'width': 12, + 'grid': [ + ' ', + ' W ', + ' ', + ], + 'doors': [], + }; + 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(), reason: '无门时 doorId 应回退为 int 序号'); + }); + testWidgets('内置关卡清单能从 assets 正确加载', (WidgetTester tester) async { await tester.runAsync(() async { await Save.load(); diff --git a/tmp/css/style.css b/tmp/css/style.css deleted file mode 100644 index 20581bf..0000000 --- a/tmp/css/style.css +++ /dev/null @@ -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 触屏布局) ---------- */ -/* 桌面端兜底:只要 没有 .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)} diff --git a/tmp/dump_levels.mjs b/tmp/dump_levels.mjs deleted file mode 100644 index 9757c5f..0000000 --- a/tmp/dump_levels.mjs +++ /dev/null @@ -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 : '?')); -} diff --git a/tmp/index.html b/tmp/index.html deleted file mode 100644 index 9cc4a1d..0000000 --- a/tmp/index.html +++ /dev/null @@ -1,207 +0,0 @@ - - - - - -I WANNA BE THE CREATOR — Fan Project - - - -
-
- - - - - - -
-
-
FAN-MADE TRIBUTE PROJECT
-

I WANNA BE
THE CREATOR

-
跳刺 · 耐久 · BOSS · 坑向 — 23 个内置关卡
-
- -
-
非商业同人作品 · 致敬 I Wanna Be The Guy / I Wanna Be The Creator · 素材与代码原创
-
- - - - - - - - - - - - - - - - - - -
-
- - - diff --git a/tmp/js/audio.js b/tmp/js/audio.js deleted file mode 100644 index 834ab6c..0000000 --- a/tmp/js/audio.js +++ /dev/null @@ -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 }); }, -}; diff --git a/tmp/js/boss.js b/tmp/js/boss.js deleted file mode 100644 index 7af4010..0000000 --- a/tmp/js/boss.js +++ /dev/null @@ -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 }; diff --git a/tmp/js/camera.js b/tmp/js/camera.js deleted file mode 100644 index 013407f..0000000 --- a/tmp/js/camera.js +++ /dev/null @@ -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); - } -} diff --git a/tmp/js/editor.js b/tmp/js/editor.js deleted file mode 100644 index fd2044b..0000000 --- a/tmp/js/editor.js +++ /dev/null @@ -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 = `${f.w}×${f.h} · ${f.doors.length}门`; - 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 = '
点击画布上的方块,查看并设置它的属性
'; return; } - const { tx, ty } = this.selTile; - if (tx < 0 || ty < 0 || tx >= f.w || ty >= f.h) { box.innerHTML = '
(越界)
'; return; } - const ch = f.grid[ty][tx]; - const label = charLabel(ch); - const fields = SCHEMA[ch]; - if (!fields) { - box.innerHTML = `
「${ch}」 ${label}
该方块没有可设置的属性
`; - return; - } - box.innerHTML = `
「${ch}」 ${label}
`; - 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 = '跳转到下一关(此格未登记为门,请重新放置 W)'; 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) => ``).join(''); - const tface = this.faces[tf]; - const doorOpts = (tface.doors.length - ? tface.doors.filter(od => !(od.id === door.id && tf === this.face)).map(od => ``).join('') - : '') || ''; - row.innerHTML = `跳转到下一关
`; - 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 = `${fd.label}`; - 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 = `${fd.label}`; - 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]) => ``).join(''); - row.innerHTML = `${fd.label}`; - 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 = '
还没有保存过的地图,先「保存」一个吧
'; - } 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 = `${escapeHtml(l.name || 'MY MAP')}${fn ? fn + ' 面' : '单关'}`; - 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); -} diff --git a/tmp/js/input.js b/tmp/js/input.js deleted file mode 100644 index 29c2e8a..0000000 --- a/tmp/js/input.js +++ /dev/null @@ -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); diff --git a/tmp/js/level.js b/tmp/js/level.js deleted file mode 100644 index c7e0af2..0000000 --- a/tmp/js/level.js +++ /dev/null @@ -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 }; diff --git a/tmp/js/levels/boss.js b/tmp/js/levels/boss.js deleted file mode 100644 index a826793..0000000 --- a/tmp/js/levels/boss.js +++ /dev/null @@ -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 - ]), - }, -]; diff --git a/tmp/js/levels/endurance.js b/tmp/js/levels/endurance.js deleted file mode 100644 index 8245bc5..0000000 --- a/tmp/js/levels/endurance.js +++ /dev/null @@ -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 }, - ], - }, -]; diff --git a/tmp/js/levels/index.js b/tmp/js/levels/index.js deleted file mode 100644 index a17367e..0000000 --- a/tmp/js/levels/index.js +++ /dev/null @@ -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); -} diff --git a/tmp/js/levels/jump.js b/tmp/js/levels/jump.js deleted file mode 100644 index e8c2361..0000000 --- a/tmp/js/levels/jump.js +++ /dev/null @@ -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; diff --git a/tmp/js/levels/kit.js b/tmp/js/levels/kit.js deleted file mode 100644 index 3e169a9..0000000 --- a/tmp/js/levels/kit.js +++ /dev/null @@ -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; -} diff --git a/tmp/js/levels/needle.js b/tmp/js/levels/needle.js deleted file mode 100644 index b184dff..0000000 --- a/tmp/js/levels/needle.js +++ /dev/null @@ -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 }; diff --git a/tmp/js/levels/troll.js b/tmp/js/levels/troll.js deleted file mode 100644 index 60e426e..0000000 --- a/tmp/js/levels/troll.js +++ /dev/null @@ -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 }] }, - ], - }, -]; diff --git a/tmp/js/levels/tutorial.js b/tmp/js/levels/tutorial.js deleted file mode 100644 index 7e233b6..0000000 --- a/tmp/js/levels/tutorial.js +++ /dev/null @@ -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), - ]), - }, -]; diff --git a/tmp/js/main.js b/tmp/js/main.js deleted file mode 100644 index ec30d7d..0000000 --- a/tmp/js/main.js +++ /dev/null @@ -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)/ 粗指针(触屏笔或手指) -// 只有命中才给 加 .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 }; diff --git a/tmp/js/particles.js b/tmp/js/particles.js deleted file mode 100644 index add8210..0000000 --- a/tmp/js/particles.js +++ /dev/null @@ -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; - } -} diff --git a/tmp/js/player.js b/tmp/js/player.js deleted file mode 100644 index a64112b..0000000 --- a/tmp/js/player.js +++ /dev/null @@ -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); - } -} diff --git a/tmp/js/save.js b/tmp/js/save.js deleted file mode 100644 index 0150383..0000000 --- a/tmp/js/save.js +++ /dev/null @@ -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(); }, -}; diff --git a/tmp/js/settings.js b/tmp/js/settings.js deleted file mode 100644 index 1406b4b..0000000 --- a/tmp/js/settings.js +++ /dev/null @@ -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 = `${b.label}` + (b.label2 ? `${b.label2}` : ''); - 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); -} diff --git a/tmp/js/sprites.js b/tmp/js/sprites.js deleted file mode 100644 index 68096b9..0000000 --- a/tmp/js/sprites.js +++ /dev/null @@ -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 }; diff --git a/tmp/js/touch.js b/tmp/js/touch.js deleted file mode 100644 index 6b73dc5..0000000 --- a/tmp/js/touch.js +++ /dev/null @@ -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); - }, -}; diff --git a/tmp/js/ui.js b/tmp/js/ui.js deleted file mode 100644 index 632d1a0..0000000 --- a/tmp/js/ui.js +++ /dev/null @@ -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 = '
这个分类下还没有关卡。去「自制关卡」造一个吧。
'; - 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 = ` -
${l.builtin === false ? 'U' + (l.no || '') : String(l.index + 1).padStart(2, '0')}
-
${escapeHtml(l.name || 'STAGE')}
-
- ${TYPE_LABEL[l.type] || '自制'} - ${stars} -
-
${rec ? `✔ 死亡 ${rec.deaths} · ${fmtTime(rec.time)}` : '未通关'}
- ${rec ? '
✔
' : ''} - `; - // 管理态下自制卡不再可进入,只保留删除按钮,避免误点开始 - 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); -} diff --git a/tmp/js/util.js b/tmp/js/util.js deleted file mode 100644 index 5bbedb7..0000000 --- a/tmp/js/util.js +++ /dev/null @@ -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; -} diff --git a/tmp/levels_dump.json b/tmp/levels_dump.json deleted file mode 100644 index 88c864f..0000000 --- a/tmp/levels_dump.json +++ /dev/null @@ -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^.^^.^..>^....#...................>...#","#.........vvvvvvvvvvvvvvvvv.vvvvvvvvvvvvvvvv..............................#..###...................>...#","#.........................................................................#....<...................>..<#","#...........................................#############..........#......#....#...................>..<#","#..............v.....v........v...v...v......................#.....#.......##..#...................>..<#","#.........................................................#..#..#..#..#........#......................<#","#..S....G.^^^..^..^..^..^.^.G.^...^...^..^^..G.>.<..>^<...#..#..#..#..#.G......#....................X..#","#########################################################..............#################################","#........................................................^^^^^^^^^^^^^^................................#","#......................................................................................................#","########################################################################################################"],"index":2,"builtin":true},{"id":"j2","name":"VERTICAL HELL","cn":"垂直地狱","type":"jump","diff":3,"hint":"52 格高塔:四段之字竖井,每层刺毯+上下夹缝,垂刺压顶、掉落刺追身。","width":68,"grid":["####################################################################","#..................................................................#","#..................................................................#","#..................................................................#","#..................................................................#","#..................................................................#","#..................................................................#","#..................................................................#","#..................................................................#","#..................................................................#","#.................D...............D.....................D..........#","#..........vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv......#","#..................................................................#","#.......................................v..........................#","#..................................................................#","#..........^.^.^^.^^.^^..>.G..^^.^^.^^..^..^.^^.^^.G.<.^.^^.^^X....#","##..##############################################################.#","##.......#.........................................................#","#######..#....D.........D.........D.........D......................#","##......^#vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv............#","##..######.........................................................#","##^...................v.................v..........................#","#######............................................................#","##.........^^.^^.^^G..^...^^.^^.^^.^^...^...^^.G^.^^.^.............#","###############################################################...##","#........................................................#........##","#........................................................#...#######","#..........vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv..#^.......##","#........................................................######...##","#...............................v.................v..............^##","#............................................................#######","#..........^.^.^^.^G.>.<.^.^^...^...^^.^.>.<.^^G..^...^^..........##","##..################################################################","##.......#.........................................................#","#######..#.........................................................#","##......^#vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv............#","##..######.........................................................#","##^.........v.............v...............v........................#","#######............................................................#","##..........^..^.^^G^^.^..^..^^.^^.^^.^^..^..^.G^.^^.^.............#","###############################################################...##","#........................................................#........##","#........................................................#...#######","#....vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv..#^.......##","#........................................................######...##","#...............v.............v...............v..................^##","#............................................................#######","#..S.^.^.^^G^^..^..^.^^.^^.^..^..^^.^^.G^.^^..^..^.^^.^...........##","####################################################################","#..................................................................#","#..................................................................#","####################################################################"],"index":3,"builtin":true},{"id":"j3","name":"TIGHTROPE","cn":"钢丝","type":"jump","diff":3,"hint":"一格宽的柱子立在刺海上,高矮间距都不同,垂刺压着头顶。","width":132,"grid":["####################################################################################################################################","#..................................................................................................................................#","#..................................................................................................................................#","#..................................................................................................................................#","#..................................................................................................................................#","#..................................................................................................................................#","#..................................................................................................................................#","#..................................................................................................................................#","#..................................................................................................................................#","#..................................................................................................................................#","#...............................vvvvvvvvvvvvvvvvvvvvvvvvv...vvvvvvvvvvvvvvvvvvvvvvvvvvvvv..........................................#","#.....vvvvvvvvvvvvvvvvvvvvvvv...............................................................vvvvvvvvvvvvvvvvvvvvvvvvvvvvv..........#","#..................................................................................................................................#","#..................................................................................................................................#","#..................................................................................................................................#","#......................G..........................#...............................#................................................#","#......................#..............#...........#.......G.......#...............#.......G.......#...............#.......G........#","#.......#......#.......#......#.......#.......#...#.......#.......#.......#.......#.......#.......#.......#.......#.......#........#","#.S.#...#......#...#...#......#...#...#.......#...#...#...#.......#...#...#.......#...#...#.......#...#...#.......#...#...#.....X..#","#####^^^#^^^#^^#^^^#^^^#^^^#^^#^^^#^^^#^^^#^^^#^^^#^^^#^^^#^^^#^^^#^^^#^^^#^^^#^^^#^^^#^^^#^^^#^^^#^^^#^^^#^^^#^^^#^^^#^^^#^^^####.#","####################################################################################################################################","#..................................................................................................................................#","#..................................................................................................................................#","####################################################################################################################################"],"index":4,"builtin":true},{"id":"j4","name":"ICE NEEDLES","cn":"冰刺","type":"jump","diff":4,"hint":"冰面刹不住车:滑坑、冰上钻石、冰柱钢丝,最后还有一段冰顶矮洞。","width":132,"grid":["####################################################################################################################################","#..................................................................................................................................#","#..................................................................................................................................#","#..................................................................................................................................#","#..................................................................................................................................#","#..................................................................................................................................#","#..................................................................................................................................#","#..................................................................................................................................#","#..................................................................................................................................#","#..................................................................................................................................#","#..................................................................................................................................#","#..................................................................................................................................#","#......................................................vvvvvvvvvvvvvvvvvvvvvvv.....................................................#","#..................................................................................................................................#","#..................................................................................................................................#","#..................................................................................................................................#","#..................................................................................................................................#","#.....vvvvvvvvvvvvvvvvvvv......vvvvvvvvvvvvvvvvvvvvvv...............%.................................vvvvvvvvvvvvvvvvvvvvvvv......#","#...........................................................%.......%.......%......................................................#","#...............................................v.......%...%...G...%...%...%...%%%%%%%%%%%%%%%%%%%%%.....v....v....v....v.........#","#.....................................##................%...%...%...%...%...%......................................................#","#..S........G............................G......^.....^^%^^^%^^^%^^^%^^^%^^^%^^..G..^^^.^^^.^^^.^..G..^G..^....^....^....^..^.G..X.#","#%%%%%%...%%%...%%%...%%%...%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%#","#......^^^...^^^...^^^...^^^.......................................................................................................#","#..................................................................................................................................#","####################################################################################################################################"],"index":5,"builtin":true},{"id":"j5","name":"THE GAUNTLET","cn":"关口","type":"jump","diff":4,"hint":"移动平台跨刺海,炮台点名,矮洞与钻石阵穿插。","width":122,"grid":["##########################################################################################################################","#........................................................................................................................#","#........................................................................................................................#","#........................................................................................................................#","#........................................................................................................................#","#........................................................................................................................#","#........................................................................................................................#","#........................................................................................................................#","#........................................................................................................................#","#........................................................................................................................#","#........................................................................................................................#","#........................................................................................................................#","#........................................................................................................................#","#........................................................................................................................#","#........................................................................................................................#","#..........................................................................vvvvvvvvvvvvvvvvvvvvvv........................#","#........................................................................................................................#","#........................................................................................................................#","#....vvvvvvvvvvvvvvvvv.........................................................O...........O......vvvvvvvvvvvvvvvvvvvv...#","#.......................................................O................................................................#","#..................................................######################.........##.....................................#","#........v.....v.....v.......................................................................##......v....v....v....v....#","#...........................................................................##..........##...............................#","#..S.^^G.^..^..^..^..^............................G.G^^.>^<.^..>.<...>.<.^^^^^^^^^^^^^^^^^^^^^^G^.G..^....^....^....^..GX#","#######################...........................########################################################################","#......................^^^^^^^^^^^^^^^^^^^^^^^^^^^.......................................................................#","#........................................................................................................................#","##########################################################################################################################"],"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,"offset":24},{"dir":"left","period":66,"speed":2.7,"offset":40}],"index":6,"builtin":true},{"id":"j6","name":"NEEDLE CATHEDRAL","cn":"针刺教堂","type":"jump","diff":5,"hint":"三层殿堂:底层向右,竖井爬升,中层折返向左,再爬升,顶层冲刺。","width":132,"grid":["####################################################################################################################################","#..................................................................................................................................#","#........vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv........#","#..................................................................................................................................#","#..................................................................................................................................#","#.......................v...................................v...................................v..................................#","#..................................................................................................................................#","#...####.....^^G^^.^^...^...^^.^^..>.<..^^.^^.^^.^^G^^.^^...^...^^.^^.^^.^^.^^.^^..>.<.G^^.^^...^...^^.^^.^^.^^.^^.^^.^^.^X........#","#.......######################################################################################################################.....#","#.......#..........................................................................................................................#","#.>#....#vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv............#","#.......<..........................................................................................................................#","#.......#..........................................................................................................................#","#...##........................v.........................v.........................v.........................v......................#","#..................................................................................................................................#","#........^^^.^^.^##^^G^^.^^...^...^^.^^.^.>^..^^.^^.^^..^..^.^^G^^.^^##^.^^.^^.^..^..^^.^^.^^.>.<.^.^^.^^...^...^G.^^.^^...........#","###########################################################################################################################......#.#","#.........................................................................................................................#......#.#","#.........................................................................................................................#...##.#.#","#.........................................................................................................................#......#.#","#.........................................................................................................................#......<.#","#.........................................................................................................................#.##...#.#","#.........................................................................................................................#......#.#","#.........................................................................................................................>......#.#","#....vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv.#...##.#.#","#.........................................................................................................................#......#.#","#.........................................................................................................................#......<.#","#.................v...........................v...........................v...........................v.....................##...#.#","#................................................................................................................................#.#","#..S.^^^.^^G^^.^..^..^^.^^.^^.^^##^.^^.^^.^^..^..^.^^.^G.^^.##.^^.^^.^^...^...^^.^^.^^.^##^^.^^G^^.^..^..^^.^^.^##^^.^^.^........#.#","####################################################################################################################################","#..................................................................................................................................#","#..................................................................................................................................#","#..................................................................................................................................#","#..................................................................................................................................#","####################################################################################################################################"],"index":7,"builtin":true},{"id":"j7","name":"SKY SPIKES","cn":"天降刺","type":"jump","diff":4,"hint":"之字爬塔,每层头顶悬着掉落刺。走近就掉。别停。","width":72,"grid":["########################################################################","#......................................................................#","#......................................................................#","#......................................................................#","#......................................................................#","#......................................................................#","#............................................D......D......D...........#","#......................................................................#","#......................................................................#","#.........................................vvvvvvvvvvvvvvvvvvvvv........#","#......................................................................#","#...................................................v..................#","#............D......D......D...........................................#","#.........................................^G^^.^^...^...^^.^X.^........#","#.......................................#########################......#","#.........vvvvvvvvvvvvvvvvvvvvvvvvv....................................#","#.......................................##.............................#","#.....................v................................................#","#.....................................##...D......D......D.............#","#.........^G^^.^^.^^..^..^.^^.^^.^^....................................#","#.......#############################..................................#","#.......................................vvvvvvvvvvvvvvvvvvvvvvv........#","#.................................##...................................#","#..................................................v...................#","#..........D......D......D..........##.................................#","#.......................................^G^^.^^.^..^..^^.^^.^^.........#","#.....................................###########################......#","#.......vvvvvvvvvvvvvvvvvvvvvvvvv......................................#","#.....................................##...............................#","#...................v..................................................#","#...................................##.D......D......D.................#","#.......^G^^.^^.^^..^..^.^^.^^.^^......................................#","#.....#############################....................................#","#...................................vvvvvvvvvvvvvvvvvvvvvvvvv..........#","#.............................##.......................................#","#...............................................v......................#","#........D......D......D........##.....................................#","#...................................^G^^.^^.^^..^..^.^^.^^.^^..........#","#.................................#############################........#","#.....vvvvvvvvvvvvvvvvvvvvvvv..........................................#","#.................................##...................................#","#................v.....................................................#","#...............................##.....................................#","#.....^G^^.^^.^..^..^^.^^.^^...........................................#","#...###########################........................................#","#......................................................................#","#...............................##.....................................#","#..S...................................................................#","########################################################################","#......................................................................#","#......................................................................#","########################################################################"],"index":8,"builtin":true},{"id":"j8","name":"TRIPLE THREAT","cn":"三重威胁","type":"jump","diff":4,"hint":"冰面滑行、塌陷浮桥、单向台攀爬三连考,中间不许喘气。","width":130,"grid":["##################################################################################################################################","#................................................................................................................................#","#................................................................................................................................#","#................................................................................................................................#","#................................................................................................................................#","#................................................................................................................................#","#................................................................................................................................#","#................................................................................................................................#","#................................................................................................................................#","#................................................................................................................................#","#................................................................................................................................#","#................................................................................................................................#","#................................................................................................................................#","#.......................................vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv.............v..........................................#","#................................................................................................................................#","#............................................................................................v...................................#","#......................................................................................---.......................................#","#............................................................................................^........vvvvvvvvvvvvvvvvvvvvvvvv...#","#.............................................^...^G..^...^...^...^...^............--........--..................................#","#..........v.....v......v.....v.......VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV..............................v....v....v....v........#","#.............................................................................--.................--..............................#","#..S.......G.....^......^.....^.G....................................................................GG..^....^....^....^...^^X..#","#%%%%%%...%%%...%%%...%%%%....%%%%%%%#.....................................#........................##############################","#......^^^...^^^...^^^....^^^^........^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.^^^^^^^^^^^^^^^^^^^^^^^^.............................#","#................................................................................................................................#","##################################################################################################################################"],"index":9,"builtin":true},{"id":"j9","name":"CHERRY BLOSSOM","cn":"樱花","type":"jump","diff":5,"hint":"双层结构收 3 个苹果:钻石缝中央一个,矮洞里一个,中层对刺门后一个。","width":128,"grid":["################################################################################################################################","#..............................................................................................................................#","#..............................................................................................................................#","#..............................................................................................................................#","#..............................................................................................................................#","#..............................................................................................................................#","#..............................................................................................................................#","#..............................................................................................................................#","#........vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv..........#","#..............................................................................................................................#","#..............................................................................................................................#","#...................v...............................v...............................v..........................................#","#...........................................F..................................................................................#","#........^X^.^^.^^..^..^.^^.^^.^^..>.<..^^.^^.^^.^..^..^^.^G.^^.^^.>.<.^.^^.^^.^^...^...^^.^^.^^.^.>^<.^^G^^.^^.^^.^^..........#","#.######################################################################################################################......##","#......................................................................................................................#......##","#......................................................................................................................#....####","#......................................................................................................................#......##","#......................................................................................................................#......<#","#......................................................................................................................#.##...##","#......................................................................................................................#......##","#......................................................................................................................>......##","#....vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv#v...####","#......................................................................................................................#......##","#...........................................................################...........................................#......<#","#.............v.......................v...............................................v.......................v..........##...##","#.............................................................F...F...........................................................##","#..S.^^^.^^G..^...^^.^^.^^##^.^^.^^...^...^^.^^.^^##^.^G.^^.^^..^..^..^..^#.^.^^.^^...^...^^.^^G^^##^.^^.^^...^...^^..........##","################################################################################################################################","#..............................................................................................................................#","#..............................................................................................................................#","#..............................................................................................................................#","#..............................................................................................................................#","################################################################################################################################"],"index":10,"builtin":true},{"id":"j10","name":"FINAL NEEDLE","cn":"毕业针刺","type":"jump","diff":5,"hint":"四层大考场:刺毯长廊 → 冰面折返 → 矮洞 → 高空钢丝。全程无尿点。","width":150,"grid":["######################################################################################################################################################","#....................................................................................................................................................#","#....................................................................................................................................................#","#....................................................................................................................................................#","#....................................................................................................................................................#","#.....................#.......#.................#...G..#..#.................#.......#.................#....G..#.................#....................#","#........X....#.......#....#..#.........#.......#...#..#..#........#........#...#...#.......#.........#....#..#.........#.......#...#................#","#.......####^^#^^^#^^^#^^^^#^^#^^^^^#^^^#^^^^#^^#^^^#^^#^^#^^^#^^^^#^^#^^^^^#^^^#^^^#^^^^#^^#^^^^^#^^^#^^^^#^^#^^^#^^^^^#^^#^^^^#^^^#.....#####......#","#.......##################################################################################################################################.........#.#","#..........................................................................................................................................#.......#.#","#..........................................................................................................................................>....##.#.#","#..........................................................................................................................................#.......#.#","#.........################################################################################################################################.#.......<.#","#............................................................................................................................................##....#.#","#..................................................................................................................................................#.#","#.........^^^.^^.^^G^^.^^.^^.^^.^^.^^.^^.^^.^^.^^.^^.^^.^^.^^.^^.^^G^^.>^<.^.^^.^^.^^.^^.^^.^^.^^.^^.^^.^^.^^.^^.^^G^^.^^.^^.^^.^^.^^.^^.^.........#.#","#.......##########################################################################################################################################...#","#....##.#............................................................................................................................................#","#.......<............................................................................................................................................#","#...##..#............................................................................................................................................#","#.>.....#............................................................................................................................................#","#.......<............................................................................................................................................#","#.##....#..vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv............#","#.......#............................................................................................................................................#","#.......#............................................................................................................................................#","#...##..........................v...........................v...........................v...........................v................................#","#...............................................................................................................................................##...#","#..........^^^.^^.^^.^^G^^.^^...^...^^.^^.^^.^##^^.^^.^^.^..^..^^.^^.G^.^^##^.^^.^^.^^..^..^.^^.^^.^^.##.^^.^^.^^...^...^G.^^.^^.^^.^^.^.............#","#.%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%.......#.#","#..........................................................................................................................................#.##....#.#","#..........................................................................................................................................#.......#.#","#..........................................................................................................................................>.......#.#","#....vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv#....##.#.#","#..........................................................................................................................................#.......#.#","#..........................................................................................................................................#.......<.#","#...................v...........................v...........................v...........................v.......................v............##....#.#","#..................................................................................................................................................#.#","#..S.^^^.^^G^^.^^...^...^^.^^.^^.^##^^.^^.^^.^..^..^^.^^.^^G^^##^.^^.^^.^^..^..^.^^.^^.^^.##.^^.^^.^^...^..G^^.^^.^^.^##^^.^^...^...^^.^^.^........#.#","######################################################################################################################################################","#....................................................................................................................................................#","#....................................................................................................................................................#","#....................................................................................................................................................#","#....................................................................................................................................................#","######################################################################################################################################################"],"index":11,"builtin":true},{"id":"e1","name":"LONG ROAD","type":"endur","diff":3,"bg":"endur","width":100,"hint":"100 格长路,8 个刺坑,只有 5 个存档点。别急。","messages":["才刚刚开始"],"grid":["####################################################################################################","#..................................................................................................#","#..................................................................................................#","#..................................................................................................#","#..................................................................................................#","#..................................................................................................#","#..................................................................................................#","#..................................................................................................#","#..................................................................................................#","#..................................................................................................#","#..................................................................................................#","#..................................................................................................#","#..............v.v............v.v......................v.v.................v.v.....................#","#..........................................F..............................................F........#","#........................................######.........................................######.....#","#..................................................................................................#","#..................................................................................................#","#.S.............G.........^^..G.........^^..........G..........^^...........G.^^..................X#","##########^^^#######^^^^###########^^^######^^^^^#########^^^########^^^^##########^^^######^^^^^###"],"index":12,"builtin":true},{"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,"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":["########################################################################################","#......................................................................................#","#......................................................................................#","#......................................................................................#","#......................................................................................#","#......................................................................................#","#......................................................................................#","#......................................................................................#","#......................................................................................#","#......................................................................................#","#......................................................................................#","#..................................................................................X...#","#.................................................................................######","#......................................................................................#","#.............................................................................#####....#","#......................................................................................#","#..................................................................................G...#","#.................................................................................######","#......................................................................................#","#.............................................................................#####....#","#......................................................................................#","#.S.O.....G.........O.........G.....O.............G.O...............O.G.............O..#","#.#####...#####...#####...#####...#####...#####...#####...#####...#####...#####...######","#^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^#","########################################################################################"],"index":13,"builtin":true},{"id":"e3","name":"THE PIT","type":"endur","diff":4,"bg":"endur","width":64,"hint":"先从左侧竖井下到底,游过水下刺池,再从右边一路爬回顶端。","messages":["下面有东西在等你"],"grid":["################################################################","#..............................................................#","#..............................................................#","#........................................................X.....#","#..S....................................................########","#.#######......................................................#","#..................................................G...........#","#................................................#######.......#","#..........#######.............................................#","#..........................................................G...#","#...G...................................................########","#.#######......................................................#","#..................................................G...........#","#................................................#######.......#","#..........#######.............................................#","#..............................................................#","#...G...................................................########","#.#######......................................................#","#..............................................................#","#................................................#######.......#","#..........#######.............................................#","#..............................................................#","#............G..........................................########","#..........#######.............................................#","#..................................................G...........#","#.................~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~#######.......#","#.................~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~..............#","#.................~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~..............#","#^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^#","################################################################"],"index":14,"builtin":true},{"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":["########################################################################################################################","#......................................................................................................................#","#......................................................................................................................#","#......................................................................................................................#","#......................................................................................................................#","#......................................................................................................................#","#......................................................................................................................#","#......................................................................................................................#","#......................................................................................................................#","#......................................................................................................................#","#......................................................................................................................#","#......................................................................................................................#","#......................................................................................................................#","#......................................................................................................................#","#......................................................................................................................#","#......................................................................................................................#","#......................................................................................................................#","#...................v.v.............v.v.............v.v...............v.v...............v.v.............v.v............#","#......................................................................................................................#","#......................................................................................................................#","#......................................................................................................................#","#......................................................................................................................#","#.S..............^^.O...G...........O...G...........O.^^..........G...O.^^..............G...........O...G^^.........X..#","#%%%%%%%%%%^^^^############^^^^^%%%%%%%%%%%%^^^^##########^^^^^###############^^^^%%%%%%%%%%^^^^^############^^^^#######","########################################################################################################################"],"movers":[{"x":57,"y":22,"w":3,"h":1,"path":[[57,22],[63,22]],"speed":1.3}],"index":15,"builtin":true},{"id":"b1","name":"THE GUY","type":"boss","diff":3,"bg":"boss","width":48,"hint":"X 键射击。他放扇形弹+砸地冲击波,站在平台边缘躲开再输出。","startGun":true,"bosses":[{"kind":"guy","hp":72}],"grid":["################################################","#..............................................#","#..............................................#","#..............................................#","#..............................................#","#..............................................#","#..............................................#","#..............................................#","#.....######........................######.....#","#..............................................#","#..............................................#","#..............................................#","#...............######........######...........#","#..............................................#","#..............................................#","#..............................................#","#.....######........................######.....#","#..............................................#","#..............................................#","#..............................................#","#.S.....................B......................#","################################################"],"index":16,"builtin":true},{"id":"b2","name":"MECHA KID","type":"boss","diff":4,"bg":"boss","width":48,"hint":"冲刺时贴地必死。跳上高台躲,等他停下再打。","startGun":true,"bosses":[{"kind":"mecha","hp":96}],"grid":["################################################","#..............................................#","#..............................................#","#..............................................#","#..............................................#","#............######..........######............#","#..............................................#","#..............................................#","#..............................................#","#...######............................######...#","#..............................................#","#..............................................#","#..............................................#","#...............######........######...........#","#..............................................#","#..............................................#","#..............................................#","#...######............................######...#","#..............................................#","#..............................................#","#..............................................#","#.S.....................B......................#","################################################"],"index":17,"builtin":true},{"id":"b3","name":"THE DEVIL","type":"boss","diff":5,"bg":"boss","width":48,"hint":"三阶段:追踪弹 → 激光横扫 → 环形弹幕。地上还有刺。活下来。","startGun":true,"bosses":[{"kind":"devil","hp":128}],"grid":["################################################","#..............................................#","#..............................................#","#..............................................#","#.....######........................######.....#","#..............................................#","#..............................................#","#..............................................#","#...............######........######...........#","#..............................................#","#..............................................#","#..............................................#","#...######............................######...#","#..............................................#","#..............................................#","#..............................................#","#...............######........######...........#","#..............................................#","#..............................................#","#..............................................#","#.S.........^^..........B.........^^...........#","################################################"],"index":18,"builtin":true},{"id":"r1","name":"TRUST NO ONE","type":"troll","diff":3,"bg":"troll","width":56,"hint":"路牌在骗你。那两颗\"补给\"也不怀好意。地板会塌。","messages":["← 地板很结实,放心走 →","这颗苹果看起来很好吃"],"grid":["########################################################","#......................................................#","#......................................................#","#......................................................#","#......................................................#","#......................................................#","#......................................................#","#......................................................#","#......................................................#","#......................................................#","#......................................................#","#......................................................#","#......................................................#","#......................................................#","#...................D.................D................#","#......................................................#","#......................................................#","#.S..T...*............G.............*...........T...X..#","###########VVVVVVVV########^^^^^#########VVVVVV#########"],"index":19,"builtin":true},{"id":"r2","name":"FALSE GOAL","type":"troll","diff":4,"bg":"troll","width":56,"hint":"地上那扇门是假的——走过去就死。真正的终点在右上角。","messages":["终点就在前面!→","真的在上面。"],"grid":["########################################################","#......................................................#","#......................................................#","#......................................................#","#......................................................#","#......................................................#","#......................................................#","#.................................................X....#","#...............................................#####..#","#......................................................#","#...........................................#####......#","#......................................................#","#.....................................#####............#","#......................................................#","#...........................................#####......#","#......................................................#","#.....................................#####............#","#.S.....T......G........^^....X.....G..................#","########################################################"],"events":[{"x":28,"y":14,"w":6,"h":5,"once":false,"actions":[{"type":"kill"}]}],"index":20,"builtin":true},{"id":"r3","name":"BLIND","type":"troll","diff":4,"bg":"troll","width":56,"hint":"摄像头接管了视野:你只能看见身边一小块地方。天上下刺也看不见。","messages":["摄像头已启动 ★"],"grid":["########################################################","#......................................................#","#......................................................#","#......................................................#","#......................................................#","#......................................................#","#......................................................#","#......................................................#","#......................................................#","#......................................................#","#......................................................#","#......................................................#","#......................................................#","#......................................................#","#.................D...........D.............D..........#","#......................................................#","#......................................................#","#.S...T........G................G............X.........#","#########^^^##########^^^^############^^^#########^^^^##"],"camzones":[{"x":1,"y":14,"w":54,"h":5,"mode":"zoom","zoom":2.6}],"index":21,"builtin":true},{"id":"r4","name":"TROLL PARADISE","type":"troll","diff":5,"bg":"troll","width":64,"hint":"漩涡送你回起点,\"补给\"会杀你,地板会塌,天上还会下刺雨。","messages":["这是最后的考验(笑)","前面的门是假的"],"grid":["################################################################","#..............................................................#","#..............................................................#","#..............................................................#","#..............................................................#","#..............................................................#","#..............................................................#","#..............................................................#","#..............................................................#","#..............................................................#","#..............................................................#","#..............................................................#","#..............................................................#","#..............................................................#","#...........D............D............D................D.......#","#..............................................................#","#..............................................................#","#.S..T.*...........G..........*.............W..T..........X....#","#########VVVVVV########^^^^########VVVVVV########^^^############"],"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}]}],"index":22,"builtin":true}],"typeLabel":{"jump":"跳刺","endur":"耐久","boss":"BOSS","troll":"坑向","custom":"自制"},"typeClass":{"jump":"t-jump","endur":"t-endur","boss":"t-boss","troll":"t-troll","custom":"t-custom"}} \ No newline at end of file diff --git a/tmp/mapview.html b/tmp/mapview.html deleted file mode 100644 index f007df5..0000000 --- a/tmp/mapview.html +++ /dev/null @@ -1,108 +0,0 @@ - - - - -关卡地图预览 · IWBT C 同人 - - - -
-

关卡地图预览

- - -
-
-
- 实心块 - 冰面 - 单向台 - 水 - 四向刺 - 即死块 - 出生点 - 存档点 - 终点 / 苹果 - 手枪·跳台·炮台·BOSS·掉落刺 -
- - - - diff --git a/tmp/package.json b/tmp/package.json deleted file mode 100644 index 8898b26..0000000 --- a/tmp/package.json +++ /dev/null @@ -1 +0,0 @@ -{ "type": "module", "private": true } diff --git a/tmp/spike-preview.html b/tmp/spike-preview.html deleted file mode 100644 index c1dd1ce..0000000 --- a/tmp/spike-preview.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - -刺外观预览(对齐参考图) - - - -

单三角形刺 · 冰蓝白渐变(游戏真实代码渲染)

- -

上排:地面刺(朝上) · 中间:天花板刺(朝下) · 左右:贴墙刺(朝左/朝右)

- - - diff --git a/tmp/tools/bot.mjs b/tmp/tools/bot.mjs deleted file mode 100644 index 4324089..0000000 --- a/tmp/tools/bot.mjs +++ /dev/null @@ -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})`); diff --git a/tmp/tools/check.mjs b/tmp/tools/check.mjs deleted file mode 100644 index ec0ab53..0000000 --- a/tmp/tools/check.mjs +++ /dev/null @@ -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); diff --git a/tmp/tools/death-test.mjs b/tmp/tools/death-test.mjs deleted file mode 100644 index 2422754..0000000 --- a/tmp/tools/death-test.mjs +++ /dev/null @@ -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); diff --git a/tmp/tools/dj-test.mjs b/tmp/tools/dj-test.mjs deleted file mode 100644 index 4167112..0000000 --- a/tmp/tools/dj-test.mjs +++ /dev/null @@ -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 格'); diff --git a/tmp/tools/dom-smoke.mjs b/tmp/tools/dom-smoke.mjs deleted file mode 100644 index d5f3d4a..0000000 --- a/tmp/tools/dom-smoke.mjs +++ /dev/null @@ -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); diff --git a/tmp/tools/editor-pick-test.mjs b/tmp/tools/editor-pick-test.mjs deleted file mode 100644 index cc03b0c..0000000 --- a/tmp/tools/editor-pick-test.mjs +++ /dev/null @@ -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); diff --git a/tmp/tools/editor-wysiwyg-test.mjs b/tmp/tools/editor-wysiwyg-test.mjs deleted file mode 100644 index b51c2ca..0000000 --- a/tmp/tools/editor-wysiwyg-test.mjs +++ /dev/null @@ -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); diff --git a/tmp/tools/ice-test.mjs b/tmp/tools/ice-test.mjs deleted file mode 100644 index 998e9c5..0000000 --- a/tmp/tools/ice-test.mjs +++ /dev/null @@ -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); diff --git a/tmp/tools/jumpdist.mjs b/tmp/tools/jumpdist.mjs deleted file mode 100644 index 5e798b2..0000000 --- a/tmp/tools/jumpdist.mjs +++ /dev/null @@ -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 + ' 帧)'}`); -} diff --git a/tmp/tools/key-test.mjs b/tmp/tools/key-test.mjs deleted file mode 100644 index 3d56270..0000000 --- a/tmp/tools/key-test.mjs +++ /dev/null @@ -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); diff --git a/tmp/tools/lib-reach.mjs b/tmp/tools/lib-reach.mjs deleted file mode 100644 index 4ed20df..0000000 --- a/tmp/tools/lib-reach.mjs +++ /dev/null @@ -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; -} diff --git a/tmp/tools/map-test.mjs b/tmp/tools/map-test.mjs deleted file mode 100644 index ed58089..0000000 --- a/tmp/tools/map-test.mjs +++ /dev/null @@ -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); diff --git a/tmp/tools/reach-diag.mjs b/tmp/tools/reach-diag.mjs deleted file mode 100644 index 4fa2003..0000000 --- a/tmp/tools/reach-diag.mjs +++ /dev/null @@ -1,31 +0,0 @@ -// 诊断:打印可达站立点范围(按行),找出断连位置 -// 用法: node reach-diag.mjs -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}`); diff --git a/tmp/tools/save-diag.mjs b/tmp/tools/save-diag.mjs deleted file mode 100644 index 2ce1f06..0000000 --- a/tmp/tools/save-diag.mjs +++ /dev/null @@ -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(' | ')}`); -} diff --git a/tmp/tools/save-test.mjs b/tmp/tools/save-test.mjs deleted file mode 100644 index 08b71d6..0000000 --- a/tmp/tools/save-test.mjs +++ /dev/null @@ -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); diff --git a/tmp/tools/settings-test.mjs b/tmp/tools/settings-test.mjs deleted file mode 100644 index 66f9886..0000000 --- a/tmp/tools/settings-test.mjs +++ /dev/null @@ -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); diff --git a/tmp/tools/smoke.mjs b/tmp/tools/smoke.mjs deleted file mode 100644 index 39aecd9..0000000 --- a/tmp/tools/smoke.mjs +++ /dev/null @@ -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); diff --git a/tmp/tools/solve.mjs b/tmp/tools/solve.mjs deleted file mode 100644 index 4a23f21..0000000 --- a/tmp/tools/solve.mjs +++ /dev/null @@ -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); diff --git a/tmp/tools/spike-test.mjs b/tmp/tools/spike-test.mjs deleted file mode 100644 index d60d3ef..0000000 --- a/tmp/tools/spike-test.mjs +++ /dev/null @@ -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);