diff --git a/.workbuddy/memory/2026-10-24.md b/.workbuddy/memory/2026-10-24.md index cb9aff4..a5a176f 100644 --- a/.workbuddy/memory/2026-10-24.md +++ b/.workbuddy/memory/2026-10-24.md @@ -104,3 +104,77 @@ - 测试:death-test 场景 8/9 重写(当帧重生、纯演出不进死亡态、粒子/环/震屏全程自然播完断言),key-test 活着按 R 段重写;全套回归过。 - 补充(用户六次反馈):「kid 被动死亡的,死亡动画也应该放完,尽管玩家很快按了重生键」。根因:`softReset()` 无脑清 particles+shake——重生即清场,特效被腰斩。修复:softReset 永久不清粒子/震屏("演出层"语义:重生只重置关卡逻辑状态,不掐断演出;仅 reset(true) 整关重开才清空)。keepFX 参数随之删除,respawn() 恢复无参。踩坑:python 替换时把 `softReset(keepFX=false){` 函数头和 vanish/bullets 清逻辑一起吃进 old 串,文件语法损坏——已修复(补回函数头+4 行清逻辑,node import 验证)。教训:rep() 的 old 串跨函数签名行时,new 必须原样带回签名与后续语句。 - 测试:death-test 新增场景 10(真死亡 3 帧秒按 R:粒子/环/震屏全程保留并自然播完,7 断言);key-test 死亡中按 R 段加"残余粒子"断言;全套回归过。 + +## Flutter 全平台复刻(app/ 目录)——集成层收口,`flutter analyze` 0 error +- 目标:把 `tmp/` 下 web 源码 100% 复刻成 Flutter(android+macos+linux+windows+ios),手感/UI/功能全保留。 +- 集成层全部落地并通过静态分析: + - `world.dart` = main.js 的状态机+固定 60Hz 时间步长循环(STEP=1000/60,guard<6,P.update(1) 定步长保手感);GameHost 回调接口 + LevelTestRunner。 + - `app_root.dart`:`SingleTickerProviderStateMixin`+`Ticker` 驱动 `world.tick`;`RawKeyboard` 监听 → `Input.onKey(logicalKey.debugName 去空格, isDown)`(debugName 与 web `KeyboardEvent.code` 字符串一致:ArrowLeft/KeyA/ShiftLeft/KeyR/Backspace/Escape…);9 个 GameHost 方法接 UI。 + - `game_canvas.dart` 信箱缩放(800×608 虚拟→适配保留比例);`overlays.dart` HUD/BOSS 条/吐司;`screens.dart` 标题/选关/暂停/结算/设置(含键位重绑 Focus.onKeyEvent 捕获 + 用户名);`touch_layout.dart`+`touch_controls.dart` 虚拟手柄(android 默认开);`editor_panel.dart` 关卡编辑器(多面地图+属性面板,1:1 editor.js)。 + - `save.dart` 加 settings 槽 + flush;`levels.dart` 从 `assets/levels.json`(23 关,已注册 pubspec)加载;`main.dart` = `runApp(AppRoot)`。 +- 修复的编译错误:缺 `material.dart`/`scheduler.dart`/`foundation.dart` 导入(Ticker/MaterialApp/ValueListenable/defaultTargetPlatform/KeyDownEvent);`_onKeyCapture` 改 `(FocusNode, KeyEvent)→KeyEventResult`;`debugName` 可空用 `?.replaceAll() ?? ''`;`levelsReload` 改 `ValueNotifier` 避免越权调 notifyListeners;清掉未用导入/变量。 +- **环境限制**:本沙箱拦截 Flutter 写 `/opt/flutter/bin/cache/engine.stamp.tmp`,故 `flutter build`(任何原生平台)无法在此跑通——仅能做静态 `flutter analyze`(已 0 error)。原生构建需用户在自有机器上跑(SDK 可写)。 +- 待办(用户机器):`flutter pub get` → `flutter build <平台>`;若需 iOS 触屏检测可拓宽 `_useTouch`(现仅 android);`withOpacity` 弃用警告可后续换 `withValues`。 + +## Flutter 安卓「打开后黑屏」排障与加固(同日) +- 报告:安卓打开后是黑屏。排查路径(静态读代码 + 实证 widget 测试): + - **已排除**:`levels.json` 顶层是合法 `{"levels":[...]}`(非数组,decode 不抛);`EditorController()` 是空构造;`Save.load()`/`loadLevels()`/`Input.endFrame()`/`metaSettings()['touch'] as Map` 均安全;`MainActivity` 是标准 `FlutterActivity`、manifest/styles 无改动无资源引用;`_MenuBg` 只是渐变 Container,`TouchControls` 在 `visible=false` 时不渲染。 + - **实证**:新增 `test/title_render_test.dart`,在 1080×2340 虚拟窗口真实 pump `AppRoot`,断言标题三按钮与"23 个内置关卡"文案可见、不卡加载态、无错误界面 → **全部通过**。证明启动逻辑与 build 本身没问题(`flutter test` 全 3 项绿)。 +- 结论:黑屏属**设备级渲染问题**,最可能是 Android 默认 Impeller(Vulkan)后端处理「每帧 scale+clipRect+渐变 shader 的 CustomPaint」时整帧丢失,会连普通 widget 一起黑掉。 +- 修复/加固: + 1. **`AndroidManifest.xml` 的 `` 下加 `EnableImpeller=false`**(Flutter 官方关闭方式,置「逐帧重绘自定义 Canvas」避免 OEM ROM/Impeller 黑屏;确认正常后可改回 true)。 + 2. **`game_canvas.dart`**:去掉 `isComplex:true`(我们每帧重绘,缓存有害)——最可能致设备 blank 的元凶;去掉 `CustomPaint(size: Size.infinite)` 改 `SizedBox.expand`(零/无穷尺寸会算出 scale=0 让整屏消失);paint 里加 `size/scale` 有限性与正数校验。 + 3. **新增 `core/app_error.dart`**:全局错误收口。`main.dart` 装 `FlutterError.onError` + `ErrorWidget.builder` + `runZonedGuarded`,任何异常都切到可读错误详情界面(release 包原本静默吞异常只剩黑屏)。区分 `report`(致命,占屏)与 `warn`(非致命,仅 logcat)。 + 4. **`app_root.dart`**:`_init()` 改分阶段 try/catch——存档/关卡失败用 `warn` 降级继续(不再导致 `_ready` 永假、永久停黑屏);世界装配失败才 `report`;加 12s 看门狗超时报错;加载态换成带 CircularProgressIndicator 的 `_BootScreen`(原为近黑空 Scaffold);build 三级优先级:错误详情 → 加载界面 → `_buildGame()`。 + 5. `game_canvas` 的 `world.render` 包 try/catch 上报渲染异常。 +- 验证:`flutter analyze` **0 error / 0 warning**(余 206 条为 info 级 lint);`flutter test` 3/3 通过;manifest XML 通过 python ElementTree 校验。 + +### 安卓黑屏 → 真凶定位(第二轮,同日) +- 用户二次反馈:**改后仍打不开,启动循环报 `View.of() was called with a context that does not contain a View widget.`** +- **根因确认(我上一轮自己引入的回归)**:为套 `runZonedGuarded` 我把 `main.dart` 的 `runApp(AppRoot())` 换成了底层 `binding.scheduleAttachRootWidget(const AppRoot())`,**漏掉了 `binding.wrapWithDefaultView(...)`**。 + - SDK 源码(`packages/flutter/lib/src/widgets/binding.dart`)为证: + `void runApp(Widget app) { final b = WidgetsFlutterBinding.ensureInitialized(); _runWidget(b.wrapWithDefaultView(app), b, 'runApp'); }` + `void _runWidget(app, b, entry) { assert(b.debugCheckZone(entry)); b..scheduleAttachRootWidget(app)..scheduleWarmUpFrame(); }` + - `wrapWithDefaultView` 负责注入 `View` 祖先 + 提供隐式 `FlutterView`;缺了它,`MaterialApp` 内部一调 `View.of(context)` 就抛上述错误。官方 `runWidget` 的文档也明写"缺 View 祖先会出错"。 +- **不要改回 `runApp` 塞进 `runZonedGuarded`**:`debugCheckZone` 会比较 `Zone.current` 与 binding 初始化时的 `_debugBindingZone`,不一致会抛 **"Zone mismatch"**(`foundation/binding.dart` 有实现)。故保留"复刻 runApp 但不带 assert"的写法最稳。 +- 修复后 `main.dart`: + ```dart + final binding = WidgetsFlutterBinding.ensureInitialized(); + FlutterError.onError = ...; ErrorWidget.builder = ...; + runZonedGuarded(() { + binding + ..scheduleAttachRootWidget(binding.wrapWithDefaultView(const AppRoot())) + ..scheduleWarmUpFrame(); + }, (e, st) => AppError.reportZone(e, st)); + ``` +- 加固:`AppError.buildScreen` 外层包 `Directionality`。**ErrorWidget 在很早期替换出错 widget 时树里可能还没有 Directionality**,缺了会引发二次报错盖住真正原因(血泪点)。 +- **方法论教训**:widget 测试(`tester.pumpWidget`)由测试框架自己注入 View,**永远无法覆盖 `main()` 里的 bootstrap 问题**。改动启动流程必须真机构建验证,不能只看 `flutter analyze` + widget 测试全绿。 + +### 安卓黑屏 → 真凶终于抓到(第三轮,同日,已收敛) +- **真凶:`Positioned` 被 `ValueListenableBuilder` 包裹**,不是 Stack 的直接子级 → 定位数据被丢弃 → 整棵 Stack 布局横向塌缩成 0 宽。 + - 实证(探针测试量的真实尺寸,不是靠猜):修复前 `button size=Size(0.0, 92.0)`、`title text found: 0`、画布 `0.0×0.0`;修复后 `canvas rendered: size=1080.0x2340.0 scale=1.35`、按钮 `64.4×23` 居中 (507.8, 1151.5)。 + - 界面上只剩画布里那条近黑渐变 `#181828→#08080f`,看着就是"黑屏"。这也解释了为什么 widget 测试能 `find` 到按钮却依然黑:`find` **不检查可见性**。 + - 涉及位置:`app_root._buildGame` 的四个屏幕分支(已改为 Positioned 在外、监听在内);**`overlays.dart` HudOverlay 的 HUD 行 / BOSS 血条 / Toast 三处同样踩坑**(HUD 塌缩),`_BossBar` 改为只返回内容(`Column`),定位交给外层 `Positioned`。 +- 回退前两轮的推测性改动(已证明无关,留着只会有副作用):`MainActivity` 恢复默认 `FlutterActivity`(SurfaceView,不要强制 Texture);**删除 manifest 里的 `EnableImpeller=false`**(官方已弃用,且本作与此无关)。 +- **顺带挖出的真功能 bug:进关卡后 HUD 永远不显示**。原因:`showHud` 在 `_buildGame()` 里读 `world.state`,而 `startLevel()` 只改 World 字段、不触发 AppRoot 重建。 + - 修法:`World.state` 改为 `ValueNotifier stateN` 的 getter/setter(`menu|playing|paused|result|editor`),HUD 用 `ValueListenableBuilder(valueListenable: world.stateN, ...)` 驱动。 + - 教训:web 版主循环每帧直接改 DOM 所以没这个问题;**Flutter 侧凡是"某状态决定某子树显隐",该状态就必须是可监听的**。 +- **音频异常会整死 App**:audioplayers 的 `MissingPluginException` 会在别的 Zone 上下文冒出来,`Sfx._play` 内的 try/catch 抓不到,冒泡到全局钩子后整个界面被切成错误详情。 + - 加固:`Sfx.broken` 一旦出错永久静音;`init()`/`unlock()` 加 try/catch 且 `unlock()` 在 `!enabled` 时直接 return;`AppError.reportZone` 对 `MissingPluginException + audioplayers/xyz.luan` 降级为 warn。**原则:音频坏掉 ≠ 游戏坏掉。** +- 渲染容错策略调整:`world.render` 单帧异常只 warn(连续 60 帧才升级占屏),避免偶发一帧就把整局判死。 +- **手感 1:1 已用测试锁死**(`test/gameplay_test.dart`,无头、固定步长,直接调 `world.tick`): + - web 版顺序是「本帧 `vy = -JUMP` 后**立刻**加一次重力」,所以第一帧读到 `-6.35` 而非 `-6.6`;Dart 侧完全一致(别再写错断言)。 + - 松键可变跳跃:`!is('jump') && vy < -3` 收紧到 -3,再加一帧重力 → `-2.75`。 + - 死亡后不自动重生、按 R 才回存档点(`deaths` +1)。 +- **把"布局塌缩"固化成回归测试**(`test/title_render_test.dart`,5 个用例):断言 `tester.getSize()` 的**可见尺寸**(Width > 0)而不是"widget 存在";另加端到端「标题 → 关卡选择 → 点卡进关 → HUD 显示关卡名」。注意游戏有常驻 Ticker,**测试里不能用 `pumpAndSettle`**(会超时)。 +- 当前状态:`flutter analyze` **0 error / 0 warning**;`flutter test` **10/10 通过**。原生构建仍被沙箱拦(同前:Flutter 无法写 `/opt/flutter/bin/cache/engine.stamp.tmp`),需用户在自己机器上 `flutter build apk`。 + +### Flutter 收尾:安卓横屏锁定 + 弃用清理(同日,最终态) +- **安卓仅横屏(用户:「安卓平台不能竖屏,只能横屏玩,你重新适应一下」)**: + - `android/app/src/main/AndroidManifest.xml` 的 `` 加 `android:screenOrientation="sensorLandscape"` + `android:resizeableActivity="false"`(冷启动首帧即横屏,避免先竖后横闪烁)。 + - `lib/main.dart` 在 `runZonedGuarded` 内对 android/iOS 调 `SystemChrome.setPreferredOrientations([landscapeLeft, landscapeRight])`(桌面端不干预)。 + - `game_canvas.dart` 提取 `static Rect letterboxRect(Size)` 供画布与触屏共用;`touch_controls.dart` 用 `letterboxRect(MediaQuery.size)` 把虚拟手柄按钮锚定在画面区内(不再漂到黑边)。 + - 新增 `test/title_render_test.dart` 横屏用例(2340×1080):画布非 0 宽、手柄按钮落在 `stage` 内。 +- **`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 触屏检测仍可拓宽。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index 0fa1b4c..f9936bf 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -60,3 +60,14 @@ - 仅在 `playing` 态显示(`startLevel`/`resume` 显示,菜单/暂停/结算隐藏);HUD 每帧 `Touch.setGun(P.hasGun)` 控制射击键置灰。 - 若改键位含义,记得同步 `js/input.js` 的 `BIND` 与 `touch.js` 的 `HELD`/`api` 映射。 +## Flutter 移植版(`app/`,脱胎于本 web 源码) +目标是 android+macos+linux+windows 四平台 100% 复刻 UI/功能/手感。核心约定: +- **渲染**:自定义 `Ticker` + `CustomPaint`,信箱缩放 800×608 → 适配屏幕;`GameCanvas` 用 `SizedBox.expand`(不要用 `Size.infinite`,零尺寸会让 scale=0 整屏消失)。 +- **循环**:`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 永不出现)。 +- **音频不能拖垮游戏**:`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/.gitignore b/app/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/app/.metadata b/app/.metadata new file mode 100644 index 0000000..cf4b47a --- /dev/null +++ b/app/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "c9a6c484230f8b5e408ec57be1ef71dee1e77020" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 + base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 + - platform: android + create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 + base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 + - platform: ios + create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 + base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 + - platform: linux + create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 + base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 + - platform: macos + create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 + base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 + - platform: web + create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 + base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 + - platform: windows + create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 + base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..d4ce680 --- /dev/null +++ b/app/README.md @@ -0,0 +1,17 @@ +# iwbtc + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/app/analysis_options.yaml b/app/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/app/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/app/android/.gitignore b/app/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/app/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts new file mode 100644 index 0000000..ac99811 --- /dev/null +++ b/app/android/app/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.iwbtc.iwbtc" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.iwbtc.iwbtc" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/app/android/app/src/debug/AndroidManifest.xml b/app/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/app/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9bc0e8f --- /dev/null +++ b/app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/android/app/src/main/kotlin/com/iwbtc/iwbtc/MainActivity.kt b/app/android/app/src/main/kotlin/com/iwbtc/iwbtc/MainActivity.kt new file mode 100644 index 0000000..009fc6d --- /dev/null +++ b/app/android/app/src/main/kotlin/com/iwbtc/iwbtc/MainActivity.kt @@ -0,0 +1,5 @@ +package com.iwbtc.iwbtc + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/app/android/app/src/main/res/drawable-v21/launch_background.xml b/app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/android/app/src/main/res/drawable/launch_background.xml b/app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/values-night/styles.xml b/app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/android/app/src/main/res/values/styles.xml b/app/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/android/app/src/profile/AndroidManifest.xml b/app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/android/build.gradle.kts b/app/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/app/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/app/android/gradle.properties b/app/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/app/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/app/android/gradle/wrapper/gradle-wrapper.properties b/app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/app/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/app/android/settings.gradle.kts b/app/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/app/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/app/assets/levels.json b/app/assets/levels.json new file mode 100644 index 0000000..b3942be --- /dev/null +++ b/app/assets/levels.json @@ -0,0 +1,1186 @@ +{ + "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/app/ios/.gitignore b/app/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/app/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/app/ios/Flutter/AppFrameworkInfo.plist b/app/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/app/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/app/ios/Flutter/Debug.xcconfig b/app/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/app/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/app/ios/Flutter/Release.xcconfig b/app/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/app/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/app/ios/Runner.xcodeproj/project.pbxproj b/app/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..f6f919e --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,644 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.iwbtc.iwbtc; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.iwbtc.iwbtc.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.iwbtc.iwbtc.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.iwbtc.iwbtc.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.iwbtc.iwbtc; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.iwbtc.iwbtc; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/ios/Runner.xcworkspace/contents.xcworkspacedata b/app/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/app/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/app/ios/Runner/AppDelegate.swift b/app/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/app/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/app/ios/Runner/Base.lproj/LaunchScreen.storyboard b/app/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/app/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/ios/Runner/Base.lproj/Main.storyboard b/app/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/app/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist new file mode 100644 index 0000000..0334c69 --- /dev/null +++ b/app/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Iwbtc + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + iwbtc + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/app/ios/Runner/Runner-Bridging-Header.h b/app/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/app/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/app/ios/Runner/SceneDelegate.swift b/app/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/app/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/app/ios/RunnerTests/RunnerTests.swift b/app/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/app/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/app/lib/main.dart b/app/lib/main.dart new file mode 100644 index 0000000..89df29c --- /dev/null +++ b/app/lib/main.dart @@ -0,0 +1,57 @@ +// 入口:安装全局错误钩子后用标准 runApp 启动。 +// 关键点:release 包里框架/build 抛出的异常不会被自动展示,只会留黑屏; +// 这里统一接管 FlutterError.onError、Zone 未捕获异常与 ErrorWidget.builder。 +// ensureInitialized 与 runApp 都放进同一个 zone,避免触发 debugCheckZone 的 +// "Zone mismatch"。runApp 内部会做 wrapWithDefaultView,注入 View 祖先。 +import 'dart:async' show runZonedGuarded; +import 'package:flutter/foundation.dart' + show FlutterErrorDetails, defaultTargetPlatform, TargetPlatform; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' + show DeviceOrientation, SystemChrome; +import 'package:iwbtc/src/core/app_error.dart'; +import 'package:iwbtc/src/app_root.dart' show AppRoot; + +Future main() async { + runZonedGuarded( + () async { + WidgetsFlutterBinding.ensureInitialized(); + + // 框架级错误(build / layout / paint / 手势回调等)。 + FlutterError.onError = (FlutterErrorDetails d) { + FlutterError.presentError(d); // 保持控制台/日志里的完整输出 + AppError.reportFlutter(d); + }; + + // Widget 抛错时的占位界面 —— 换成可读文案,而不是默认的红屏/黑屏。 + ErrorWidget.builder = (FlutterErrorDetails d) => + AppError.buildScreen(d.exceptionAsString()); + + await _configureMobile(); + runApp(const AppRoot()); + }, + (Object e, StackTrace st) { + AppError.reportZone(e, st); + }, + ); +} + +/// 移动端专属的显示适配:锁定横屏。 +/// +/// 画面是 800×608 的横向虚拟舞台,横屏下信箱缩放才能拿到可用的可视比例; +/// 竖屏时上下留白巨大、画面极小,虚拟手柄也会挤成一团。桌面端保持原样不干预。 +/// (安卓另有 AndroidManifest 的 `screenOrientation="sensorLandscape"`, +/// 保证冷启动第一帧就是横屏,避免先竖后横的闪烁。) +Future _configureMobile() async { + final t = defaultTargetPlatform; + if (t != TargetPlatform.android && t != TargetPlatform.iOS) return; + try { + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + } catch (e) { + // 某些设备/系统策略不允许锁定方向,属可容忍情况,不影响游戏运行。 + AppError.warn('锁定横屏失败(保持系统默认方向)', '$e'); + } +} diff --git a/app/lib/src/app_root.dart b/app/lib/src/app_root.dart new file mode 100644 index 0000000..8673c0f --- /dev/null +++ b/app/lib/src/app_root.dart @@ -0,0 +1,319 @@ +// 应用根:实现 GameHost,拥有 World,驱动 Ticker 循环,处理键盘,组装界面栈。 +import 'dart:ui' show Color; +import 'dart:async' show Timer; +import 'package:flutter/material.dart'; +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; +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; +import 'package:iwbtc/src/game/levels.dart' show LEVELS, loadLevels; +import 'package:iwbtc/src/game/world.dart' show World, GameHost; +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; +import 'package:iwbtc/src/ui/touch_controls.dart' show TouchControls; +import 'package:iwbtc/src/ui/editor_panel.dart' show EditorPanel, EditorController; + +class AppRoot extends StatefulWidget { + const AppRoot({super.key}); + @override + State createState() => _AppRootState(); +} + +class _AppRootState extends State + with SingleTickerProviderStateMixin + implements GameHost { + late World world; + late EditorController editor; + bool _ready = false; + bool _worldOk = false; + bool _useTouch = false; + bool _audioUnlocked = false; + + final ValueNotifier screen = ValueNotifier('scr-title'); + final ValueNotifier> hud = + ValueNotifier>({}); + final ValueNotifier boss = ValueNotifier(null); + final ValueNotifier toast = ValueNotifier(''); + final ValueNotifier> result = + ValueNotifier>({}); + final ValueNotifier touchVisible = ValueNotifier(false); + final ValueNotifier touchGun = ValueNotifier(false); + final ValueNotifier pauseQuit = ValueNotifier('返回关卡选择'); + final ValueNotifier levelsReload = ValueNotifier(0); + final ValueNotifier frame = ValueNotifier(0); + + late Ticker _ticker; + int _lastMs = 0; + Timer? _watchdog; + bool _loggedTick = false; + bool _loggedBuild = false; + + @override + void initState() { + super.initState(); + _useTouch = defaultTargetPlatform == TargetPlatform.android; + // 看门狗:初始化迟迟不完成时报出可诊断信息,避免永远停在加载态。 + _watchdog = Timer(const Duration(seconds: 12), () { + if (mounted && !_ready) { + AppError.report( + '初始化超时(12 秒未完成)', + 'ready=$_ready worldOk=$_worldOk\n' + '请用 adb logcat 查看 DEBUG 输出中的 [AppError] / [AppWarn] 行。', + ); + } + }); + _init(); + } + + /// 分阶段初始化:任何一环失败都记录错误并尽力继续, + /// 避免一处异常导致 _ready 永远为 false —— 那正是"永久黑屏"的成因。 + Future _init() async { + debugPrint('[boot] init start'); + // 1) 存档读取失败:退化为内存存档,游戏仍可玩(仅记日志)。 + try { + await Save.load(); + debugPrint('[boot] save loaded ok'); + } catch (e, s) { + AppError.warn('存档读取失败(已退化为内存存档)', '$e\n$s'); + } + // 2) 内置关卡加载失败:退化为空清单,不影响标题与其他界面(仅记日志)。 + try { + await loadLevels(); + debugPrint('[boot] levels loaded: ${LEVELS.length}'); + } catch (e, s) { + AppError.warn('内置关卡加载失败(levels.json 未能读取)', '$e\n$s'); + } + // 3) 世界与输入装配:失败则无法继续,交给错误界面展示。 + try { + Sfx.enabled = Save.data['audio'] != false; + world = World(this); + world.useTouch = _useTouch; + editor = EditorController(); + editor.runner = world; + RawKeyboard.instance.addListener(_rawKey); + _ticker = createTicker(_onTick); + _ticker.start(); + _worldOk = true; + debugPrint('[boot] world assembled'); + } catch (e, s) { + AppError.report('游戏初始化失败', '$e\n$s'); + } + if (mounted) setState(() => _ready = true); + debugPrint('[boot] init done: ready=$_ready worldOk=$_worldOk'); + } + + void _onTick(Duration elapsed) { + if (!_loggedTick) { + _loggedTick = true; + debugPrint('[boot] first tick fired (game loop running)'); + } + final dt = elapsed.inMilliseconds - _lastMs; + _lastMs = elapsed.inMilliseconds; + world.tick(dt); + frame.value++; + } + + void _rawKey(RawKeyEvent e) { + if (world.state != 'playing' && world.state != 'paused') return; + if (!_audioUnlocked) { + _audioUnlocked = true; + Sfx.instance.unlock(); + } + final code = e.logicalKey.debugName?.replaceAll(' ', '') ?? ''; + Input.onKey(code, e is RawKeyDownEvent); + } + + @override + void dispose() { + _watchdog?.cancel(); + RawKeyboard.instance.removeListener(_rawKey); + _ticker.dispose(); + super.dispose(); + } + + // ---------------- GameHost ---------------- + @override + void onShowScreen(String name) { + screen.value = name; + if (name == 'scr-editor' || + name == 'scr-title' || + name == 'scr-levels' || + name == 'scr-settings') { + touchVisible.value = false; + } + } + + @override + void onHud(Map h) => hud.value = h; + + @override + void onBossBar(dynamic b) => boss.value = b; + + @override + void onToast(String t) => toast.value = t; + + @override + void onResult(Map r) => result.value = r; + + @override + void onPauseMenu(String label) => pauseQuit.value = label; + + @override + void editorShow() => editor.showOrOpen(); + + @override + void touchShow() => touchVisible.value = true; + + @override + void touchHide() { + touchVisible.value = false; + Input.clearTouch(); + } + + @override + void touchSetGun(bool has) => touchGun.value = has; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: AppError.current, + builder: (_, String? err, __) { + // 致命错误优先展示详情 —— release 包里原本只会留下一片黑屏。 + if (err != null) { + return MaterialApp(home: Scaffold(body: AppError.buildScreen(err))); + } + // 尚未就绪:显示可见的加载界面(不再是纯黑背景)。 + if (!_ready || !_worldOk) return const MaterialApp(home: _BootScreen()); + return _buildGame(); + }, + ); + } + + /// 真正的游戏界面栈(画布 + 各屏幕 + HUD + 触屏手柄)。 + Widget _buildGame() { + if (!_loggedBuild) { + _loggedBuild = true; + debugPrint( + '[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) + Positioned.fill( + child: TouchControls( + world: world, + visible: touchVisible, + hasGun: touchGun, + ), + ), + ], + ), + ), + ); + } +} + +/// 启动加载界面。取代原先近黑的空 Scaffold, +/// 这样即使初始化较慢或出错,用户也能看到明确反馈而不是"黑屏"。 +class _BootScreen extends StatelessWidget { + const _BootScreen(); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xff08080f), + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: const [ + CircularProgressIndicator(color: Color(0xffff5c7a)), + SizedBox(height: 18), + Text( + '正在加载关卡…', + style: TextStyle(color: Color(0xffc8c8d8), fontSize: 14), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/src/audio/sfx.dart b/app/lib/src/audio/sfx.dart new file mode 100644 index 0000000..1b2926a --- /dev/null +++ b/app/lib/src/audio/sfx.dart @@ -0,0 +1,278 @@ +// 纯合成音效(对应 web 版 audio.js 的 WebAudio 合成),无外部资源。 +// 在 Dart 侧生成 16-bit PCM WAV 字节,通过 audioplayers 播放(自带播放池以支持重叠)。 +import 'dart:math'; +import 'dart:typed_data'; +import 'package:audioplayers/audioplayers.dart'; + +class _Voice { + final Float32List samples; // 已含包络、相对自身 0 起点 + final int delaySamples; + _Voice(this.samples, this.delaySamples); +} + +class Sfx { + static final Sfx instance = Sfx._(); + factory Sfx() => instance; + Sfx._(); + + static bool enabled = true; + static double volume = 0.5; + + /// 音频不可用时永久静默。 + /// + /// 音效纯属装饰,但 audioplayers 的异常会在**别的 Zone 上下文**里冒出来, + /// 现有的 try/catch 抓不到;一旦冒泡到全局错误钩子,整个界面会被切换成错误详情。 + /// 因此只要音频子系统出过一次错,就彻底关掉,保证「音频坏掉 ≠ 游戏坏掉」。 + static bool broken = false; + + final int rate = 44100; + final List _pool = []; + final Map _cache = {}; + bool _init = false; + + void init() { + if (_init || broken) return; + _init = true; + try { + for (int i = 0; i < 12; i++) { + final p = AudioPlayer(); + p.setVolume(volume); + _pool.add(p); + } + } catch (_) { + broken = true; + } + } + + void setVolume(double v) { + volume = v; + for (final p in _pool) p.setVolume(v); + } + + /// 首次用户手势时解锁音频(移动端要求用户手势后才能播放)。 + /// 音效被关闭时不创建播放器,省资源也避免插件噪声。 + void unlock() { + if (!enabled || broken) return; + init(); + } + + Uint8List _render(List<_Voice> voices) { + int maxLen = 0; + for (final v in voices) { + maxLen = max(maxLen, v.delaySamples + v.samples.length); + } + final mix = Float32List(maxLen); + for (final v in voices) { + for (int i = 0; i < v.samples.length; i++) { + mix[v.delaySamples + i] += v.samples[i]; + } + } + // 限幅 + for (int i = 0; i < mix.length; i++) { + if (mix[i] > 1) mix[i] = 1; + if (mix[i] < -1) mix[i] = -1; + } + return _encodeWav(mix); + } + + Float32List _genTone({required double f, double f2 = 0, required double d, String type = 'square', double vol = 0.25, double delay = 0, double decay = 0}) { + final total = d + decay + 0.02; + final n = (total * rate).ceil(); + final out = Float32List(n); + final t0 = (delay * rate).round(); + final len = (d * rate).round(); + final end = ((d + decay) * rate).round(); + final k = decay > 0 ? -log(0.0001) / max(1e-4, (d + decay - 0.008)) : 0.0; + double phase = 0; + for (int i = 0; i < n; i++) { + final t = i / rate; + final fr = f2 > 0 ? f * pow(f2 / f, t / max(1e-6, d)) : f; + phase += 2 * pi * fr / rate; + if (phase > 1e9) phase = phase % (2 * pi); + double e; + if (t < 0.008) { + e = t / 0.008; + } else { + e = exp(-k * (t - 0.008)); + } + final w = _wave(type, phase); + out[i] = w * vol * e; + } + return out; + } + + Float32List _genNoise({required double d, double vol = 0.25, double delay = 0, double hp = 400}) { + final total = d + 0.02; + final n = (total * rate).ceil(); + final out = Float32List(n); + final t0 = (delay * rate).round(); + final end = (d * rate).round(); + final a = hp / (hp + rate); + double hpState = 0, prevX = 0, prevOut = 0; + for (int i = 0; i < n; i++) { + final t = i / rate; + final x = (Random().nextDouble() * 2 - 1) * (1 - i / max(1, n)); + final e = (i < end) ? 1.0 : 0.0; + hpState = 0.99 * hpState + x - prevX; + prevX = x; + prevOut = a * (prevOut + hpState); + out[i] = prevOut * vol * e; + } + return out; + } + + static double _wave(String type, double phase) { + switch (type) { + case 'sine': + return sin(phase); + case 'triangle': + final f = (phase / (2 * pi)) % 1; + return 4 * (f - 0.5).abs() - 1; + case 'sawtooth': + final f = (phase / (2 * pi)) % 1; + return 2 * f - 1; + case 'square': + default: + return sin(phase) >= 0 ? 1 : -1; + } + } + + Uint8List _encodeWav(Float32List samples) { + final n = samples.length; + final bytes = ByteData(44 + n * 2); + final bc = bytes.buffer.asUint8List(); + // RIFF + bc.setRange(0, 4, [0x52, 0x49, 0x46, 0x46]); // RIFF + bytes.setUint32(4, 36 + n * 2, Endian.little); + bc.setRange(8, 12, [0x57, 0x41, 0x56, 0x45]); // WAVE + bc.setRange(12, 16, [0x66, 0x6d, 0x74, 0x20]); // fmt + bytes.setUint32(16, 16, Endian.little); + bytes.setUint16(20, 1, Endian.little); // PCM + bytes.setUint16(22, 1, Endian.little); // mono + bytes.setUint32(24, rate, Endian.little); + bytes.setUint32(28, rate * 2, Endian.little); + bytes.setUint16(32, 2, Endian.little); + bytes.setUint16(34, 16, Endian.little); + bc.setRange(36, 40, [0x64, 0x61, 0x74, 0x61]); // data + bytes.setUint32(40, n * 2, Endian.little); + for (int i = 0; i < n; i++) { + int s = (samples[i] * 32767).round().clamp(-32768, 32767); + bytes.setInt16(44 + i * 2, s, Endian.little); + } + return bc; + } + + Uint8List _build(String name) { + late List<_Voice> voices; + switch (name) { + case 'jump': + voices = [_v(_genTone(f: 380, f2: 700, d: 0.09, type: 'square', vol: 0.16))]; + break; + case 'djump': + voices = [_v(_genTone(f: 520, f2: 900, d: 0.09, type: 'square', vol: 0.14))]; + break; + case 'shoot': + voices = [ + _v(_genTone(f: 900, f2: 260, d: 0.07, type: 'square', vol: 0.14)), + _v(_genNoise(d: 0.05, vol: 0.06, hp: 2000), 0), + ]; + break; + case 'death': + voices = [ + _v(_genTone(f: 620, f2: 60, d: 0.55, type: 'sawtooth', vol: 0.26)), + _v(_genNoise(d: 0.35, vol: 0.16, hp: 300), (0.02 * rate).round()), + ]; + break; + case 'save': + voices = [ + _v(_genTone(f: 700, d: 0.08, type: 'triangle', vol: 0.2)), + _v(_genTone(f: 1050, d: 0.13, type: 'triangle', vol: 0.18), (0.07 * rate).round()), + ]; + break; + case 'goal': + voices = [ + for (int i = 0; i < 4; i++) + _v(_genTone(f: [523.0, 659.0, 784.0, 1047.0][i], d: 0.16, type: 'triangle', vol: 0.2), (i * 0.1 * rate).round()), + ]; + break; + case 'warp': + voices = [_v(_genTone(f: 300, f2: 1400, d: 0.2, type: 'sine', vol: 0.18))]; + break; + case 'hit': + voices = [_v(_genTone(f: 200, f2: 90, d: 0.08, type: 'square', vol: 0.16))]; + break; + case 'bossHit': + voices = [_v(_genTone(f: 260, f2: 140, d: 0.1, type: 'sawtooth', vol: 0.14))]; + break; + case 'bossDie': + voices = [ + for (int i = 0; i < 5; i++) + _v(_genTone(f: [400.0, 330.0, 260.0, 180.0, 110.0][i], d: 0.22, type: 'sawtooth', vol: 0.22), (i * 0.11 * rate).round()), + _v(_genNoise(d: 0.8, vol: 0.2, hp: 150), (0.1 * rate).round()), + ]; + break; + case 'menu': + voices = [_v(_genTone(f: 620, d: 0.05, type: 'square', vol: 0.12))]; + break; + case 'vanish': + voices = [_v(_genTone(f: 700, f2: 300, d: 0.12, type: 'sine', vol: 0.1))]; + break; + case 'troll': + voices = [_v(_genTone(f: 180, f2: 900, d: 0.3, type: 'sine', vol: 0.18))]; + break; + case 'splash': + voices = [_v(_genNoise(d: 0.18, vol: 0.12, hp: 800))]; + break; + default: + voices = [_v(_genTone(f: 440, d: 0.05, type: 'square', vol: 0.1))]; + } + return _render(voices); + } + + _Voice _v(Float32List s, [int delaySamples = 0]) => _Voice(s, delaySamples); + + void _play(String name) { + if (!enabled || broken) return; + try { + init(); + final bytes = _cache.putIfAbsent(name, () => _build(name)); + // 找一个空闲播放器 + AudioPlayer? free; + for (final p in _pool) { + if (p.state == PlayerState.stopped || p.state == PlayerState.completed) { + free = p; + break; + } + } + free ??= _pool[_lastIdx = (_lastIdx + 1) % _pool.length]; + free.stop().catchError((Object _) { + broken = true; + }); + free + .play(BytesSource(bytes, mimeType: 'audio/wav')) + .catchError((Object _) { + broken = true; + }); + } catch (_) { + // 任何失败都只静音,不影响玩法。 + broken = true; + } + } + + int _lastIdx = 0; + + void jump() => _play('jump'); + void djump() => _play('djump'); + void shoot() => _play('shoot'); + void death() => _play('death'); + void save() => _play('save'); + void goal() => _play('goal'); + void warp() => _play('warp'); + void hit() => _play('hit'); + void bossHit() => _play('bossHit'); + void bossDie() => _play('bossDie'); + void menu() => _play('menu'); + void vanish() => _play('vanish'); + void troll() => _play('troll'); + void splash() => _play('splash'); +} diff --git a/app/lib/src/core/app_error.dart b/app/lib/src/core/app_error.dart new file mode 100644 index 0000000..0d0b101 --- /dev/null +++ b/app/lib/src/core/app_error.dart @@ -0,0 +1,110 @@ +// 全局错误捕获与展示。 +// 目的:release 包里任何未捕获异常都会被静默吞掉,只留下一片黑屏。 +// 这里把所有异常统一收拢到一个 ValueNotifier,并由 AppRoot 优先渲染成 +// 可读的错误详情界面,方便在真机上直接看到堆栈(配合 adb logcat 更好)。 +import 'package:flutter/foundation.dart' + show debugPrint, FlutterErrorDetails, ValueNotifier; +import 'package:flutter/widgets.dart'; + +class AppError { + AppError._(); + + /// 首个被捕获的错误详情;非空时整个应用改为显示错误界面。 + static final ValueNotifier current = ValueNotifier(null); + + static bool get hasError => current.value != null; + + static void _log(String tag, String head, String body) { + final trimmed = body.trim(); + final text = trimmed.isEmpty ? head : '$head\n\n$trimmed'; + debugPrint('[$tag] $text'); + } + + /// 记录致命错误(占屏展示;只保留第一个,避免刷屏把界面刷乱)。 + static void report(String head, [String body = '']) { + _log('AppError', head, body); + if (current.value != null) return; + final trimmed = body.trim(); + current.value = trimmed.isEmpty ? head : '$head\n\n$trimmed'; + } + + /// 记录 Flutter 框架级错误(build / layout / paint 抛出的)。 + static void reportFlutter(FlutterErrorDetails d) { + report(d.exceptionAsString(), d.stack?.toString() ?? ''); + } + + /// 记录 Zone 内未捕获的异步错误。 + static void reportZone(Object e, StackTrace st) { + // 音效插件的缺失/未初始化会以异步错误的形式冒出来,届时已错过 Sfx 内部的 + // try/catch。音频只是装饰性能力,不能因为设备不支持就把整局游戏判死刑, + // 因此这类错误降级为日志。其余错误照常占屏,保证真机上仍看得见堆栈。 + if (isAudioNoise(e)) { + warn('音频子系统不可用(已静音,游戏继续)', '$e'); + return; + } + report(e.toString(), st.toString()); + } + + /// 是否为已知的音频插件噪声错误。 + static bool isAudioNoise(Object e) { + final s = e.toString().toLowerCase(); + return s.contains('missingpluginexception') && + (s.contains('audioplayers') || s.contains('xyz.luan')); + } + + /// 非致命问题:仅写日志(adb logcat 可查),不占用界面,游戏继续运行。 + static void warn(String head, [String body = '']) { + _log('AppWarn', head, body); + } + + /// 错误详情界面(替代黑屏)。 + static Widget buildScreen(String text) { + // 显式提供 Directionality:ErrorWidget 可能在很早期替换出错 widget, + // 那时树里未必有 Directionality,缺失会引发二次报错盖住真正原因。 + return Directionality( + textDirection: TextDirection.ltr, + child: Container( + color: const Color(0xff14060a), + padding: const EdgeInsets.all(24), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 40), + const Text( + '启动失败 / 运行错误', + style: TextStyle( + color: Color(0xffff8fa3), + fontSize: 20, + fontWeight: FontWeight.bold, + decoration: TextDecoration.none, + ), + ), + const SizedBox(height: 6), + const Text( + '请把下面内容截图发给开发者,或用 adb logcat 抓取完整堆栈。', + style: TextStyle( + color: Color(0xff9a9ab0), + fontSize: 12, + decoration: TextDecoration.none, + ), + ), + const SizedBox(height: 16), + Text( + text, + style: const TextStyle( + color: Color(0xffe8e8f0), + fontSize: 11, + height: 1.4, + fontFamily: 'monospace', + decoration: TextDecoration.none, + ), + ), + const SizedBox(height: 40), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/src/core/input.dart b/app/lib/src/core/input.dart new file mode 100644 index 0000000..c5fd55b --- /dev/null +++ b/app/lib/src/core/input.dart @@ -0,0 +1,124 @@ +// 输入:IWBT 经典键位(Shift 跳 / Z 跳 / X 射击 / R 重来)。 +// 与 web 版 input.js 1:1 对应。键码沿用 Web 的 KeyboardEvent.code 字符串, +// 由 UI 层的键盘监听器把 Flutter 按键翻译成 code 后喂给 onKey()。 +import 'dart:convert'; + +class Input { + /// 动作 -> 默认绑定的按键 code 列表。 + static final Map> 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'], + }; + + static final List actions = + bind.keys.toList(); // ['left','right',...] + + static final Map> _defaultBind = { + for (final e in bind.entries) e.key: List.from(e.value), + }; + + static final Set down = {}; // 当前按住的原始 code + static final Map now = {for (final a in bind.keys) a: false}; + static final Map prev = {for (final a in bind.keys) a: false}; + static final Map touch = {}; // 触屏虚拟按键 action -> bool + + static bool enabled = true; + + static bool held(String a) => now[a] ?? false; + static bool hit(String a) => (now[a] ?? false) && !(prev[a] ?? false); + static bool released(String a) => !(now[a] ?? false) && (prev[a] ?? false); + static bool raw(String c) => down.contains(c); + static bool anyHit() { + for (final k in now.keys) { + if (now[k]! && !(prev[k] ?? false)) return true; + } + return false; + } + + static void clear() { + down.clear(); + for (final k in now.keys) now[k] = false; + for (final k in prev.keys) prev[k] = false; + } + + /// 每逻辑帧末尾调用。 + static void endFrame() { + for (final k in now.keys) prev[k] = now[k]!; + } + + static void _refresh() { + for (final act in bind.keys) { + bool v = false; + for (final c in bind[act]!) { + if (down.contains(c)) { + v = true; + break; + } + } + if (touch[act] ?? false) v = true; + now[act] = enabled ? v : false; + } + } + + /// 原始键盘事件(Web code 字符串)。 + static void onKey(String code, bool isDown) { + if (isDown) { + down.add(code); + } else { + down.remove(code); + } + _refresh(); + } + + // ---------------- 触屏虚拟按键 ---------------- + static void setTouch(String action, bool on) { + touch.putIfAbsent(action, () => false); + if (touch[action] == on) return; + touch[action] = on; + _refresh(); + } + + static void clearTouch() { + bool any = false; + for (final k in touch.keys) { + if (touch[k]!) { + touch[k] = false; + any = true; + } + } + if (any) _refresh(); + } + + // ---------------- 键位自定义(设置界面调用)---------------- + static Map> defaultBindings() => + {for (final e in bind.entries) e.key: List.from(e.value)}; + + static Map> snapshotBindings() => + {for (final e in bind.entries) e.key: List.from(e.value)}; + + static void applyBindings(Map>? map) { + if (map == null) return; + for (final act in map.keys) { + if (bind.containsKey(act)) bind[act] = List.from(map[act]!); + } + } + + /// 持久化用:转 JSON 字符串。 + static String toJson() => jsonEncode(bind); + static void fromJson(String? s) { + if (s == null) return; + try { + final m = (jsonDecode(s) as Map).cast(); + final decoded = m.map((k, v) => MapEntry(k, List.from(v as List))); + applyBindings(decoded); + } catch (_) {} + } +} diff --git a/app/lib/src/core/save.dart b/app/lib/src/core/save.dart new file mode 100644 index 0000000..4cd9871 --- /dev/null +++ b/app/lib/src/core/save.dart @@ -0,0 +1,112 @@ +// 本地存档:通关记录、最佳成绩、自制关卡。对应 web 版 save.js(localStorage)。 +// 跨平台使用 shared_preferences;内存中保留 data 镜像,写盘异步进行。 +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; + +class Save { + static Map data = _blank(); + static late SharedPreferences _sp; + static bool _ready = false; + + static Map _blank() => { + 'cleared': {}, + 'customs': [], + 'audio': true, + 'settings': {}, + }; + + /// 设置对象(用户名 / 触屏布局 / 键位)。不存在时自动创建。 + static Map settings() { + if (data['settings'] is! Map) data['settings'] = {}; + return data['settings'] as Map; + } + + static Future load() async { + _sp = await SharedPreferences.getInstance(); + _ready = true; + final s = _sp.getString('iwbtc_fan_v1'); + if (s != null) { + try { + final m = (jsonDecode(s) as Map).cast(); + data = {..._blank(), ...m}; + data['cleared'] = (data['cleared'] as Map?)?.cast() ?? + {}; + data['customs'] = (data['customs'] as List?)?.cast() ?? + []; + } catch (_) { + data = _blank(); + } + } + } + + static void _flush() { + if (!_ready) return; + // 异步写盘,不阻塞游戏循环。 + _sp.setString('iwbtc_fan_v1', jsonEncode(data)); + } + + /// 公开写盘入口(设置界面「确定」时调用)。 + static void flush() => _flush(); + + static Map? get(String id) => data['cleared'][id] as Map?; + static bool isCleared(String id) => data['cleared'][id] != null; + + /// 记录通关,返回是否刷新纪录。 + static bool clear(String id, int deaths, int ms, int apples) { + final old = data['cleared'][id] as Map?; + final rec = {'deaths': deaths, 'time': ms, 'apples': apples, 'at': DateTime.now().millisecondsSinceEpoch}; + bool better = false; + if (old == null) { + data['cleared'][id] = rec; + better = true; + } else { + final nd = min(old['deaths'] as int, deaths); + final nt = min(old['time'] as int, ms); + better = deaths < (old['deaths'] as int) || ms < (old['time'] as int); + data['cleared'][id] = { + ...old, + 'deaths': nd, + 'time': nt, + 'apples': max((old['apples'] as int? ?? 0), apples), + }; + } + _flush(); + return better; + } + + static void wipe() { + data = _blank(); + _flush(); + } + + static List customs() => (data['customs'] as List?)?.cast() ?? []; + static void addCustom(Map lv) { + (data['customs'] as List).add(lv); + _flush(); + } + + /// 更新已保存的自制关卡(按 id 就地替换,不新增);id 不存在则追加。 + static void updateCustom(Map lv) { + final list = (data['customs'] as List).cast(); + final i = list.indexWhere((l) => l['id'] == lv['id']); + if (i >= 0) { + list[i] = lv; + } else { + list.add(lv); + } + _flush(); + } + + static void removeCustom(String id) { + (data['customs'] as List).removeWhere((l) => l['id'] == id); + _flush(); + } + + static void setAudio(bool on) { + data['audio'] = on; + _flush(); + } +} + +int min(int a, int b) => a < b ? a : b; +int max(int a, int b) => a > b ? a : b; diff --git a/app/lib/src/core/util.dart b/app/lib/src/core/util.dart new file mode 100644 index 0000000..2af1c47 --- /dev/null +++ b/app/lib/src/core/util.dart @@ -0,0 +1,69 @@ +// 通用工具:常量、数学、AABB、颜色、时间格式、确定性噪声。 +// 与 web 版 util.js 1:1 对应,保证逻辑一致。 +import 'dart:math'; +import 'dart:ui' show Color; + +const double TILE = 32.0; +const double ROOM_W = 800.0; +const double ROOM_H = 608.0; +const int GRID_W = 25; +const int GRID_H = 19; + +final Random _rng = Random(); + +double clamp(double v, double a, double b) => v < a ? a : v > b ? b : v; +double lerp(double a, double b, double t) => a + (b - a) * t; +int sgn(double v) => v < 0 ? -1 : v > 0 ? 1 : 0; +double rand(double a, double b) => a + _rng.nextDouble() * (b - a); +int randInt(int a, int b) => (a + _rng.nextDouble() * (b - a + 1)).floor(); +T pick(List arr) => arr[_rng.nextInt(arr.length)]; + +/// 轴对齐矩形(顶-左 x,y,w,h)。 +class Box { + final double x, y, w, h; + const Box(this.x, this.y, this.w, this.h); + double get right => x + w; + double get bottom => y + h; +} + +bool overlap(Box a, Box 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; + +bool pointIn(double px, double py, Box r) => + px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h; + +// ---------------- 颜色 ---------------- +Color hex(String s) { + var h = s.replaceFirst('#', ''); + if (h.length == 3) { + h = h.split('').map((c) => c + c).join(''); + } + final v = int.parse(h, radix: 16); + return Color.fromARGB(255, (v >> 16) & 255, (v >> 8) & 255, v & 255); +} + +Color shade(Color c, int n) { + final r = (c.red + n).clamp(0, 255).toInt(); + final g = (c.green + n).clamp(0, 255).toInt(); + final b = (c.blue + n).clamp(0, 255).toInt(); + return Color.fromARGB(255, r, g, b); +} + +// ---------------- 时间 ---------------- +String fmtTime(int ms) { + final t = max(0, ms); + final m = (t / 60000).floor(); + final s = ((t % 60000) / 1000).floor(); + final c = ((t % 1000) / 10).floor(); + final ss = s.toString().padLeft(2, '0'); + final cc = c.toString().padLeft(2, '0'); + return '$m:$ss.$cc'; +} + +// ---------------- 平滑确定性伪随机(仅装饰,不影响帧同步)---------------- +double hashNoise(int x, int y) { + int h = (x * 374761393 + y * 668265263).toUnsigned(32); + h = ((h ^ (h ~/ 8192)) * 1274126177).toUnsigned(32); + h = (h ^ (h ~/ 65536)).toUnsigned(32); + return h / 4294967295.0; +} diff --git a/app/lib/src/game/boss.dart b/app/lib/src/game/boss.dart new file mode 100644 index 0000000..125a902 --- /dev/null +++ b/app/lib/src/game/boss.dart @@ -0,0 +1,287 @@ +// BOSS 战系统:多阶段弹幕 / 冲撞 / 刺雨。对应 web 版 boss.js。 +import 'dart:ui' show Canvas, Paint, Path, Color, Rect, Offset, PaintingStyle; +import 'dart:math' show sin, cos, atan2, pi, min, max; +import 'package:iwbtc/src/core/util.dart'; +import 'package:iwbtc/src/audio/sfx.dart'; +import 'package:iwbtc/src/game/level.dart' show Level, Entity, EBullet; +import 'package:iwbtc/src/game/views.dart' show PlayerView; + +const Map 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'}, +}; + +class Boss { + final Level level; + final String kind; + final String name; + double x, y, w, h; + double maxHp; + double hp; + int flash = 0; + bool dead = false; + int deathT = 0; + bool bodyKill = true; + int t = 0; + int phase = 1; + double vx = 0, vy = 0; + String state = 'idle'; + int stateT = 0; + late double homeX, homeY; + double wing = 0; + late Map bounds; + void Function()? onDefeat; + + Boss(this.level, Entity e) + : kind = e.kind, + name = (defs[e.kind] ?? defs['guy'])['name'], + x = e.x, y = e.y, + w = (defs[e.kind] ?? defs['guy'])['w'].toDouble(), + h = (defs[e.kind] ?? defs['guy'])['h'].toDouble(), + maxHp = (e.hp ?? (defs[e.kind] ?? defs['guy'])['hp']).toDouble(), + hp = (e.hp ?? (defs[e.kind] ?? defs['guy'])['hp']).toDouble() { + bounds = { + 'x0': 40.0, 'x1': level.pxW - 40 - w, + 'y0': 40.0, 'y1': level.pxH - 32 - h, + }; + homeY = clamp(e.y, bounds['y0'], bounds['y1']); + y = homeY; + homeX = e.x; + } + + Box get rect => Box(x, y, w, h); + + void shoot(double x2, double y2, double vx, double vy, [String col = '#ff5c7a', bool big = false]) { + level.ebullets.add(EBullet(x: x2 - 4, y: y2 - 4, w: big ? 12 : 8, h: big ? 12 : 8, vx: vx, vy: vy, col: col, big: big)); + } + + void hit([int dmg = 1]) { + if (dead || flash > 6) return; + hp -= dmg; + flash = 10; + Sfx.instance.bossHit(); + level.particles.spawn(x + w / 2, y + h / 2, 6, colors: const ['#fff', '#ffc63a'], speed: 2.6, life: 18); + final r = hp / maxHp; + phase = r > 0.6 ? 1 : r > 0.3 ? 2 : 3; + if (hp <= 0) die(); + } + + void die() { + dead = true; hp = 0; bodyKill = false; + Sfx.instance.bossDie(); + level.shake = 16; + level.particles.burst(x + w / 2, y + h / 2, + n: 40, colors: const ['#ff5c7a', '#ffc63a', '#fff', '#e03050'], speed: 5, life: 60, size: 4); + } + + void update(int dt, PlayerView player) { + if (dead) { + deathT++; + if (deathT % 8 == 0 && deathT < 90) { + level.particles.burst(x + rand(0, w), y + rand(0, h), + n: 6, colors: const ['#ffc63a', '#ff5c7a', '#fff'], speed: 3, life: 30, size: 3); + Sfx.instance.hit(); + } + if (deathT == 90 && onDefeat != null) onDefeat!(); + return; + } + t++; + stateT++; + if (flash > 0) flash--; + + if (kind == 'guy') updateGuy(player); + else if (kind == 'mecha') updateMecha(player); + else updateDevil(player); + + x += vx; y += vy; + x = clamp(x, bounds['x0'], max(bounds['x0'], bounds['x1'])); + y = clamp(y, bounds['y0'], max(bounds['y0'], bounds['y1'])); + + for (final b in level.bullets) { + if (b.dead) continue; + if (overlap(Box(b.x, b.y, b.w, b.h), rect)) { + b.dead = true; + hit(1); + } + } + } + + void updateGuy(PlayerView player) { + final L = level; + if (phase == 1) { + vx = sin(t / 60) * 1.5; + y = homeY + sin(t / 45) * 8; + if (t % 96 == 0) { + final cx = x + w / 2, cy = y + h / 2; + for (int i = -1; i <= 1; i++) { + final a = atan2(player.cy - cy, player.cx - cx) + i * 0.28; + shoot(cx, cy, cos(a) * 2.8, sin(a) * 2.8); + } + Sfx.instance.shoot(); + } + } else { + final dir = sgn(player.cx - (x + w / 2)); + if (state == 'idle') { + vx = dir * 1.9; + y = homeY + sin(t / 30) * 6; + if (stateT > 70) { state = 'slam'; stateT = 0; vy = 0; } + } else if (state == 'slam') { + vx *= 0.9; + vy = min(vy + 0.7, 11); + if (y + h >= bounds['y1'] + h * 0.2 || y >= homeY + 150) { + state = 'idle'; stateT = 0; vy = -3; + L.shake = 10; Sfx.instance.hit(); + for (int i = 0; i < 4; i++) { + shoot(x + w / 2, y + h - 6, -2.6 - i * 0.4, -1.2 - i * 0.15, '#ffc63a'); + shoot(x + w / 2, y + h - 6, 2.6 + i * 0.4, -1.2 - i * 0.15, '#ffc63a'); + } + } + } + } + } + + void updateMecha(PlayerView player) { + final L = level; + if (state == 'idle') { + vx *= 0.92; + y = homeY + sin(t / 40) * 5; + if (stateT > 100 - phase * 18) { + state = 'dash'; stateT = 0; + vx = sgn(player.cx - (x + w / 2)) * (5 + phase * 0.8); + Sfx.instance.shoot(); + } + if (t % 150 == 0) { + for (int i = 0; i < 4 + phase; i++) { + final tx = randInt(1, L.w - 2); + L.entities.add(Entity('dropspike', 0, 0, 0, tx * TILE, -TILE, TILE, TILE) + ..falling = true + ..vy = 0 + ..homeY = -TILE + ..temp = true); + } + L.showToast('刺雨来了!', 80); + Sfx.instance.troll(); + } + } else if (state == 'dash') { + vy = 0; + if (x <= bounds['x0'] + 1 || x >= bounds['x1'] - 1 || stateT > 90) { + state = 'idle'; stateT = 0; vx = 0; + L.shake = 6; + L.particles.spawn(x + w / 2, y + h / 2, 8, colors: const ['#9ad8ff', '#fff'], speed: 3, life: 20); + } + } + } + + void updateDevil(PlayerView player) { + final L = level; + wing += 0.16; + final cx = x + w / 2, cy = y + h / 2; + final p2 = phase; + if (p2 == 1) { vx = sin(t / 70) * 1.6; y = homeY + sin(t / 50) * 8; } + else if (p2 == 2) { vx = sin(t / 40) * 2.6; y = homeY + sin(t / 28) * 11; } + else { vx = sin(t / 25) * 3.4; y = homeY + sin(t / 20) * 14; } + + if (p2 == 1) { + if (t % 60 == 0) { + final a = atan2(player.cy - cy, player.cx - cx); + for (int i = -1; i <= 1; i++) shoot(cx, cy, cos(a + i * 0.2) * 3, sin(a + i * 0.2) * 3, '#ff9a3a'); + Sfx.instance.shoot(); + } + } else if (p2 == 2) { + if (t % 110 == 0) { state = 'laser'; stateT = 0; } + if (state == 'laser' && stateT < 40 && stateT % 5 == 0) { + final yy = 40 + (stateT / 40) * (L.pxH - 90); + shoot(8, yy, 6.5, 0, '#ff4060', true); + shoot(L.pxW - 12, L.pxH - yy, -6.5, 0, '#ff4060', true); + Sfx.instance.shoot(); + } + if (stateT > 44) state = 'idle'; + } else { + if (t % 34 == 0) { + const n = 10; + final off = t / 200; + for (int i = 0; i < n; i++) { + final a = off + (i / n) * 2 * pi; + shoot(cx, cy, cos(a) * 2.6, sin(a) * 2.6, '#ff5c7a'); + } + Sfx.instance.shoot(); + } + if (t % 170 == 0) { + final a = atan2(player.cy - cy, player.cx - cx); + shoot(cx, cy, cos(a) * 4.4, sin(a) * 4.4, '#ffc63a', true); + } + } + } + + // ---------------- 绘制 ---------------- + void draw(Canvas c, int t) { + if (dead && deathT > 96) return; + final x = this.x, y = this.y, w = this.w, h = this.h; + final fl = flash > 0; + c.save(); + if (kind == 'guy') drawGuy(c, x, y, w, h, fl, t); + else if (kind == 'mecha') drawMecha(c, x, y, w, h, fl, t); + else drawDevil(c, x, y, w, h, fl, t); + c.restore(); + } + + void drawGuy(Canvas c, double x, double y, double w, double h, bool fl, int t) { + final c1 = fl ? const Color(0xffffffff) : const Color(0xff2a1020); + final c2 = fl ? const Color(0xffffffff) : const Color(0xff5a1a34); + c.drawCircle(Offset(x + w / 2, y + h * 0.62), w * 0.42, Paint()..color = c1); + c.drawCircle(Offset(x + w / 2, y + h * 0.3), w * 0.36, Paint()..color = c2); + final horn = fl ? const Color(0xffffffff) : const Color(0xffc8305a); + _tri(c, x + w * 0.2, y + h * 0.16, x + w * 0.05, y - h * 0.12, x + w * 0.34, y + h * 0.06, horn); + _tri(c, x + w * 0.8, y + h * 0.16, x + w * 0.95, y - h * 0.12, x + w * 0.66, y + h * 0.06, horn); + c.drawRect(Rect.fromLTWH(x + w * 0.3, y + h * 0.28, w * 0.14, h * 0.1), Paint()..color = fl ? const Color(0xff000000) : const Color(0xffffe14a)); + c.drawRect(Rect.fromLTWH(x + w * 0.56, y + h * 0.28, w * 0.14, h * 0.1), Paint()..color = fl ? const Color(0xff000000) : const Color(0xffffe14a)); + c.drawRect(Rect.fromLTWH(x + w * 0.34, y + h * 0.46, w * 0.32, h * 0.06), Paint()..color = fl ? const Color(0xff000000) : const Color(0xff801025)); + if (phase >= 2) { + final sp = Paint() + ..color = fl ? const Color(0xff000000) : const Color(0xffff4060) + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + c.drawCircle(Offset(x + w / 2, y + h / 2), w * 0.62 + sin(t / 90) * 3, sp); + } + } + + void drawMecha(Canvas c, double x, double y, double w, double h, bool fl, int t) { + c.drawRect(Rect.fromLTWH(x + 4, y + 6, w - 8, h - 10), Paint()..color = fl ? const Color(0xffffffff) : const Color(0xff3a4258)); + c.drawRect(Rect.fromLTWH(x + 8, y + 10, w - 16, h - 20), Paint()..color = fl ? const Color(0xffffffff) : const Color(0xff68728e)); + final blink = (t ~/ 200) % 5 == 0; + c.drawRect(Rect.fromLTWH(x + w * 0.22, y + h * 0.34, w * 0.18, h * 0.14), Paint()..color = fl ? const Color(0xff000000) : (state == 'dash' ? const Color(0xffff4060) : const Color(0xff7ae0ff))); + c.drawRect(Rect.fromLTWH(x + w * 0.6, y + h * 0.34, w * 0.18, h * 0.14), Paint()..color = fl ? const Color(0xff000000) : (state == 'dash' ? const Color(0xffff4060) : const Color(0xff7ae0ff))); + c.drawRect(Rect.fromLTWH(x + w * 0.42, y + h - 8, w * 0.16, 10), Paint()..color = fl ? const Color(0xffffffff) : const Color(0xff2a2f40)); + c.drawRect(Rect.fromLTWH(x + w / 2 - 1, y - 6, 2, 8), Paint()..color = fl ? const Color(0xffffffff) : const Color(0xff8a94ad)); + c.drawCircle(Offset(x + w / 2, y - 8), 3, Paint()..color = (t ~/ 120) % 2 == 0 ? const Color(0xffff4060) : const Color(0xff601020)); + if (state == 'dash') { + c.drawRect(Rect.fromLTWH(x - 6, y + h * 0.4, 8, h * 0.2), Paint()..color = const Color(0xffffb040)); + c.drawRect(Rect.fromLTWH(x + w - 2, y + h * 0.4, 8, h * 0.2), Paint()..color = const Color(0xffffb040)); + } + } + + void drawDevil(Canvas c, double x, double y, double w, double h, bool fl, int t) { + final fl2 = sin(this.wing) * 8; + final wingCol = fl ? const Color(0xffffffff) : const Color(0xff4a0f22); + _tri(c, x + 6, y + h * 0.4, x - 26, y + h * 0.1 + fl2, x - 4, y + h * 0.72 + fl2, wingCol); + _tri(c, x + w - 6, y + h * 0.4, x + w + 26, y + h * 0.1 + fl2, x + w + 4, y + h * 0.72 + fl2, wingCol); + c.drawCircle(Offset(x + w / 2, y + h * 0.6), w * 0.4, Paint()..color = fl ? const Color(0xffffffff) : const Color(0xff8e1030)); + c.drawCircle(Offset(x + w / 2, y + h * 0.32), w * 0.34, Paint()..color = fl ? const Color(0xffffffff) : const Color(0xffc01a3a)); + final horn = fl ? const Color(0xffffffff) : const Color(0xffffc63a); + _tri(c, x + w * 0.18, y + h * 0.14, x - w * 0.06, y - h * 0.16, x + w * 0.32, y + h * 0.04, horn); + _tri(c, x + w * 0.82, y + h * 0.14, x + w * 1.06, y - h * 0.16, x + w * 0.68, y + h * 0.04, horn); + final ec = phase == 3 ? const Color(0xff7ae0ff) : phase == 2 ? const Color(0xffffb040) : const Color(0xffffe14a); + c.drawRect(Rect.fromLTWH(x + w * 0.26, y + h * 0.26, w * 0.16, h * 0.12), Paint()..color = fl ? const Color(0xff000000) : ec); + c.drawRect(Rect.fromLTWH(x + w * 0.58, y + h * 0.26, w * 0.16, h * 0.12), Paint()..color = fl ? const Color(0xff000000) : ec); + for (int i = 0; i < 4; i++) c.drawRect(Rect.fromLTWH(x + w * 0.32 + i * w * 0.1, y + h * 0.46, 4, 6), Paint()..color = fl ? const Color(0xff000000) : const Color(0xffffffff)); + } + + void _tri(Canvas c, double ax, double ay, double bx, double by, double cx, double cy, Color col) { + final p = Path(); + p.moveTo(ax, ay); p.lineTo(bx, by); p.lineTo(cx, cy); p.close(); + c.drawPath(p, Paint()..color = col); + } +} + +String bossName(String kind) => (defs[kind] ?? defs['guy'])['name']; diff --git a/app/lib/src/game/camera.dart b/app/lib/src/game/camera.dart new file mode 100644 index 0000000..b09718e --- /dev/null +++ b/app/lib/src/game/camera.dart @@ -0,0 +1,124 @@ +// 摄像机:跟随 / 锁定 / 缩放(坑向关的"摄像头"道具靠它搞事)。 +// 与 web 版 camera.js 1:1 对应。渲染变换通过 apply()。 +import 'dart:ui' show Canvas; +import 'dart:math' show min, max; +import 'package:iwbtc/src/core/util.dart'; +import 'package:iwbtc/src/game/views.dart' show PlayerView; + +class Camera { + double w, h; + double x = 0, y = 0; + double zoom = 1; + double tz = 1; + String mode = 'follow'; + double lockX = 0, lockY = 0; + double shakeX = 0, shakeY = 0; + + Camera([this.w = 800, this.h = 608]); + + void reset(LevelLike level, PlayerView player) { + zoom = 1; + tz = 1; + mode = 'follow'; + snapTo(player, level); + } + + double viewW() => w / zoom; + double viewH() => h / zoom; + + void snapTo(PlayerView player, LevelLike level) { + x = clamp(player.cx - viewW() / 2, 0, max(0, level.pxW - viewW())); + y = clamp(player.cy - viewH() / 2, 0, max(0, level.pxH - viewH())); + mode = 'follow'; + zoom = 1; + tz = 1; + } + + void update(LevelLike level, PlayerView player, int dt) { + final zone = level.camZone; + double? tx, ty, tz = 1; + double speed = 0.16; + + if (zone != null) { + if (zone.mode == 'lock') { + 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') { + mode = 'zoom'; + tz = zone.zoom ?? 1; + tx = player.cx - w / tz / 2; + ty = player.cy - h / tz / 2; + speed = 0.14; + } else if (zone.mode == 'off') { + mode = 'off'; + tx = x; + ty = y; + tz = zone.zoom ?? zoom; + speed = 0; + } + } else { + mode = 'follow'; + tx = player.cx - w / zoom / 2; + ty = player.cy - h / zoom / 2; + tz = 1; + speed = 0.16; + } + + this.tz = lerp(this.tz, tz, 0.06); + if ((this.tz - zoom).abs() > 0.002) zoom = this.tz; + + if (speed > 0) { + x = lerp(x, tx!, speed); + y = lerp(y, ty!, speed); + } + _clampToLevel(level); + + final sh = level.shake; + if (sh > 0.2) { + shakeX = (rand(-0.5, 0.5)) * sh; + shakeY = (rand(-0.5, 0.5)) * sh; + } else { + shakeX = 0; + shakeY = 0; + } + } + + void _clampToLevel(LevelLike level) { + final vw = viewW(), vh = viewH(); + if (level.pxW <= vw) { + x = (level.pxW - vw) / 2; + } else { + x = clamp(x, 0, level.pxW - vw); + } + if (level.pxH <= vh) { + y = (level.pxH - vh) / 2; + } else { + y = clamp(y, 0, level.pxH - vh); + } + } + + void apply(Canvas canvas) { + canvas.translate(shakeX, shakeY); + if (zoom != 1) canvas.scale(zoom, zoom); + canvas.translate(-x, -y); + } +} + +/// 摄像机只关心关卡/玩家的少量字段,用抽象接口解耦避免循环依赖。 +abstract class LevelLike { + double get pxW; + double get pxH; + double get shake; + CamZoneLike? get camZone; +} +abstract class CamZoneLike { + String get mode; + double? get zoom; + double get x; + double get y; + double? get lx; + double? get ly; +} diff --git a/app/lib/src/game/level.dart b/app/lib/src/game/level.dart new file mode 100644 index 0000000..305d277 --- /dev/null +++ b/app/lib/src/game/level.dart @@ -0,0 +1,785 @@ +// 关卡解析与运行时:ASCII 网格 -> tiles + 实体 + 事件。 +// 与 web 版 level.js 1:1 对应(含三角刺的 SAT 判定)。 +import 'dart:ui' show Canvas, Paint, Color, Rect, Offset; +import 'dart:math' show min, max, sqrt; +import 'package:flutter/widgets.dart'; +import 'package:iwbtc/src/core/util.dart'; +import 'package:iwbtc/src/game/sprites.dart'; +import 'package:iwbtc/src/game/particles.dart'; +import 'package:iwbtc/src/game/boss.dart'; +import 'package:iwbtc/src/game/camera.dart' show CamZoneLike, LevelLike, Camera; +import 'package:iwbtc/src/game/views.dart'; +import 'package:iwbtc/src/audio/sfx.dart'; +import 'package:iwbtc/src/game/player.dart' show Player; + +enum Tile { + empty, block, grass, brick, metal, ice, water, oneway, killer, vanish, + spikeU, spikeD, spikeL, spikeR, +} + +const Map char2tile = { + '#': Tile.block, '@': Tile.grass, '=': Tile.brick, 'M': Tile.metal, + '%': Tile.ice, '~': Tile.water, '-': Tile.oneway, '*': Tile.killer, 'V': Tile.vanish, + '^': Tile.spikeU, 'v': Tile.spikeD, '<': Tile.spikeL, '>': Tile.spikeR, +}; + +const Map 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 Map> 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], +}; + +const Set solidTiles = { + Tile.block, Tile.grass, Tile.brick, Tile.metal, Tile.ice, Tile.vanish, +}; +const Set deadlyTiles = { + Tile.killer, Tile.spikeU, Tile.spikeD, Tile.spikeL, Tile.spikeR, +}; + +class Entity implements EntityView, CamZoneLike { + final String type; + int idx; + int tx, ty; + double x, y, w, h; + bool active = false; + bool dead = false; + bool taken = false; + int t = 0; + int? no; + bool fake = false; + String? text; + String dir = 'left'; + int period = 90; + double speed = 2.4; + int timer = 0; + String kind = 'ball'; + String? doorId; + int pair = 0; + dynamic to; + int cool = 0; + bool hidden = false; + String mode = 'lock'; + double? zoom; + double? lx; + double? ly; + bool falling = false; + double vy = 0; + double homeY = 0; + bool landed = false; + int landT = 0; + bool temp = false; + double? hp; + + Entity(this.type, this.idx, this.tx, this.ty, this.x, this.y, this.w, this.h); +} + +class Mover implements MoverView { + double x, y, w, h; + List path; + int i = 0; + int dir = 1; + double speed; + int t = 0; + String mode; + double dx = 0, dy = 0; + double ax, ay, aw, ah; + double bx, by; + bool gone = false; + + Mover(this.x, this.y, this.w, this.h, this.path, this.speed, this.mode, this.ax, this.ay, this.aw, this.ah, this.bx, this.by); +} + +class Cell { + final double x, y; + Cell(this.x, this.y); +} + +class Bullet implements BulletView { + double x, y, w, h, vx, vy; + int life; + bool dead = false; + String col = '#fff6a0'; + bool big = false; + Bullet({required this.x, required this.y, required this.w, required this.h, required this.vx, required this.vy, required this.life}); +} + +class EBullet implements BulletView { + double x, y, w, h, vx, vy; + int life; + bool dead = false; + String col; + bool big; + EBullet({required this.x, required this.y, required this.w, required this.h, required this.vx, required this.vy, this.life = 500, this.col = '#ff5c7a', this.big = false}); +} + +class Level implements LevelLike { + Map data; + String name; + String type; + late int w, h; + late double pxW, pxH; + late List> tiles; + late List entities; + late List movers; + late List bosses; + late List events; + late Particles particles; + late List deathMarks; + late List deathFX; + late Map vanish; + Map? vanishT; + late List bullets; + late List ebullets; + late List srcEntities; + late List moversSrc; + late List eventSrc; + late List msgs; + late List bossSrc; + late List warpSrc; + late List turretSrc; + late List camSrc; + dynamic spawnExtra; + String hint = ''; + String bgTheme = 'jump'; + int time = 0; + String? toast; + int toastT = 0; + double shake = 0; + bool beat = false; + CamZoneLike? camZone; + late Cell spawn; + int totalApples = 0; + int totalSaves = 0; + int? faceIndex; + + Level(this.data) : name = data['name'] ?? 'STAGE', type = data['type'] ?? 'jump' { + parse(data); + particles = Particles(); + reset(true); + } + + // ---------------- 解析 ---------------- + void parse(Map data) { + final rows = (data['grid'] as List?)?.map((e) => e as String).toList() ?? []; + h = rows.length; + w = (data['width'] as int?) ?? rows.map((r) => r.length).fold(0, max); + pxW = w * TILE; + pxH = h * TILE; + tiles = []; + final srcEnts = >[]; + final order = {}; + + for (int y = 0; y < h; y++) { + final line = rows[y]; + final row = []; + for (int x = 0; x < w; x++) { + final ch = x < line.length ? line[x] : ' '; + if (char2tile.containsKey(ch)) { + row.add(char2tile[ch]!); + } else if (char2ent.containsKey(ch)) { + final type = char2ent[ch]!; + final ew = esize[type]![0], eh = esize[type]![1]; + final idx = (order[type] = (order[type] ?? 0) + 1) - 1; + srcEnts.add({'type': type, 'idx': idx, 'tx': x, 'ty': y, 'ch': ch}); + row.add(Tile.empty); + } else { + row.add(Tile.empty); + } + } + tiles.add(row); + } + + srcEntities = srcEnts; + moversSrc = (data['movers'] as List?) ?? []; + eventSrc = (data['events'] as List?) ?? []; + msgs = (data['messages'] as List?) ?? []; + bossSrc = (data['bosses'] as List?) ?? []; + warpSrc = (data['warps'] as List?) ?? []; + turretSrc = (data['turrets'] as List?) ?? []; + camSrc = (data['camzones'] as List?) ?? []; + spawnExtra = data['spawn']; + hint = data['hint'] ?? ''; + bgTheme = data['bg'] ?? + (type == 'boss' ? 'boss' : type == 'troll' ? 'troll' : type == 'endur' ? 'endur' : 'jump'); + } + + void reset([bool full = false]) { + if (full) { + deathMarks = []; + deathFX = []; + } + vanish = {}; + bullets = []; + ebullets = []; + entities = []; + movers = []; + bosses = []; + events = []; + time = 0; + toast = null; + toastT = 0; + particles.clear(); + shake = 0; + beat = false; + + int saveNo = 0; + for (final s in srcEntities) { + final type = s['type'] as String; + final ew = esize[type]![0], eh = esize[type]![1]; + final idx = s['idx'] as int; + final tx = s['tx'] as int, ty = s['ty'] as int; + final e = Entity(type, idx, tx, ty, + tx * TILE + (TILE - ew) / 2, + ty * TILE + (TILE - eh), + ew.toDouble(), eh.toDouble()); + e.t = 0; + if (type == 'goal') e.y = ty * TILE + TILE - eh; + if (type == 'save') { e.fake = false; e.no = ++saveNo; } + if (type == 'sign') e.text = (msgs.length > idx ? msgs[idx] : '...') as String?; + if (type == 'turret') { + final cfg = (idx < turretSrc.length ? turretSrc[idx] : {}) as Map? ?? {}; + e.dir = cfg['dir'] ?? 'left'; + e.period = cfg['period'] ?? 90; + e.speed = (cfg['speed'] ?? 2.4).toDouble(); + e.timer = cfg['offset'] ?? 0; + e.kind = cfg['kind'] ?? 'ball'; + } + if (type == 'warp') { + final cfg = (idx < warpSrc.length ? warpSrc[idx] : {}) as Map? ?? {}; + final door = (data['doors'] as List?)?.cast() + .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); + e.to = (door != null && door.isNotEmpty && door['to'] != null) ? door['to'] : (cfg['to']); + e.cool = 0; + e.hidden = cfg['hidden'] ?? false; + } + if (type == 'camzone') { + final cfg = (idx < camSrc.length ? camSrc[idx] : {}) as Map? ?? {}; + e.w = ((cfg['w'] ?? 3) * TILE).toDouble(); + e.h = ((cfg['h'] ?? 3) * TILE).toDouble(); + e.x = tx * TILE; + e.y = ty * TILE; + e.mode = cfg['mode'] ?? 'lock'; + e.zoom = cfg['zoom']?.toDouble(); + e.lx = cfg['lx']?.toDouble(); + e.ly = cfg['ly']?.toDouble(); + } + if (type == 'dropspike') { + e.falling = false; e.vy = 0; e.homeY = e.y; + } + if (type == 'boss') { + final cfg = (idx < bossSrc.length ? bossSrc[idx] : {}) as Map? ?? {'kind': 'guy'}; + e.kind = cfg['kind'] ?? 'guy'; + e.hp = (cfg['hp'] ?? 40).toDouble(); + e.w = ((cfg['w'] ?? 64)).toDouble(); + e.h = ((cfg['h'] ?? 64)).toDouble(); + e.x = tx * TILE + TILE / 2 - e.w / 2; + e.y = ty * TILE + TILE - e.h; + } + entities.add(e); + } + + for (final m in moversSrc) { + final pathRaw = (m['path'] as List?)?.map((p) => Cell((p[0] as num) * TILE, (p[1] as num) * TILE)).toList() ?? []; + final a = pathRaw.isNotEmpty ? pathRaw[0] : Cell((m['x'] as num) * TILE, (m['y'] as num) * TILE); + movers.add(Mover( + a.x, a.y, + ((m['w'] ?? 3) as num) * TILE, ((m['h'] ?? 1) as num) * TILE, + pathRaw, + (m['speed'] ?? 1.2).toDouble(), + m['mode'] ?? 'loop', + a.x, a.y, ((m['w'] ?? 3) as num) * TILE, ((m['h'] ?? 1) as num) * TILE, + (pathRaw.length > 1 ? pathRaw[1] : a).x, (pathRaw.length > 1 ? pathRaw[1] : a).y, + )); + } + + for (final ev in eventSrc) { + events.add({ + 'x': (ev['x'] as num) * TILE, 'y': (ev['y'] as num) * TILE, + 'w': ((ev['w'] ?? 1) as num) * TILE, 'h': ((ev['h'] ?? 1) as num) * TILE, + 'once': ev['once'] ?? true, 'fired': false, + 'actions': ev['actions'] ?? [], 'hidden': ev['hidden'] ?? true, + }); + } + + final camPlaced = srcEntities.where((s) => s['type'] == 'camzone').length; + for (int i = camPlaced; i < camSrc.length; i++) { + final cfg = camSrc[i] as Map; + entities.add(Entity('camzone', i, cfg['x'] as int, cfg['y'] as int, + (cfg['x'] as num) * TILE, (cfg['y'] as num) * TILE, + ((cfg['w'] ?? 3) as num) * TILE, ((cfg['h'] ?? 3) as num) * TILE) + ..mode = cfg['mode'] ?? 'lock' + ..zoom = (cfg['zoom'] as num?)?.toDouble() + ..lx = (cfg['lx'] as num?)?.toDouble() + ..ly = (cfg['ly'] as num?)?.toDouble()); + } + + for (final e in entities) { + if (e.type == 'boss') bosses.add(Boss(this, e)); + } + + final sp = entities.firstWhere((e) => e.type == 'spawn', orElse: () => Entity('spawn', 0, 0, 0, 0, 0, 0, 0)); + if (sp.type == 'spawn') { + spawn = Cell(sp.x + sp.w / 2 - 5.5, sp.y + sp.h - 21); + } else if (spawnExtra != null) { + spawn = Cell((spawnExtra[0] as num) * TILE, (spawnExtra[1] as num) * TILE); + } else { + spawn = Cell(40, 40); + } + totalApples = entities.where((e) => e.type == 'apple').length; + totalSaves = saveNo; + } + + /// 死亡后的软重置:保留存档点与已拾取道具,其余复位(不清粒子/震屏)。 + void softReset() { + vanish.clear(); + vanishT?.clear(); + bullets.clear(); + ebullets.clear(); + for (final m in movers) { + final a = m.path.isNotEmpty ? m.path[0] : Cell(m.x, m.y); + m.x = a.x; m.y = a.y; m.i = 0; + m.dx = 0; m.dy = 0; + final nx = m.path.length > 1 ? m.path[1] : a; + m.ax = m.x; m.ay = m.y; m.bx = nx.x; m.by = nx.y; + } + for (final e in 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; + } + entities = entities.where((e) => !e.dead).toList(); + for (final ev in events) ev['fired'] = false; + for (final b in 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; + } + } + + void addDeathMark(double x, double y) { + deathMarks.clear(); + deathMarks.add(DeathMark(x, y)); + } + + void addDeathFX(double x, double y) { + deathFX.add(DeathFX(x, y, 0)); + } + + // ---------------- 查询 ---------------- + Tile tileAt(int tx, int ty) { + if (tx < 0 || ty < 0 || tx >= w || ty >= h) return ty < 0 ? Tile.empty : Tile.block; + return tiles[ty][tx]; + } + + bool isSolidTile(Tile t, int tx, int ty) { + if (!solidTiles.contains(t)) return false; + if (t == Tile.vanish) { + final st = vanishState(tx, ty); + return st != 2; + } + return true; + } + + int vanishState(int tx, int ty) => vanish[ty * 1000 + tx] ?? 0; + void setVanish(int tx, int ty, int st) => vanish[ty * 1000 + tx] = st; + + bool solidRect(double x, double y, double ww, double hh, {bool dropThrough = false, double? prevBottom, Mover? moverExclude}) { + final x0 = (x / TILE).floor(); + final x1 = ((x + ww - 0.001) / TILE).floor(); + final y0 = (y / TILE).floor(); + final y1 = ((y + hh - 0.001) / TILE).floor(); + for (int ty = y0; ty <= y1; ty++) { + for (int tx = x0; tx <= x1; tx++) { + final t = tileAt(tx, ty); + if (t == Tile.oneway) { + if (!dropThrough && prevBottom != null && prevBottom <= ty * TILE + 6) { + if (y + hh <= ty * TILE + 8) continue; + if (prevBottom > ty * TILE + 2) continue; + return true; + } + continue; + } + if (isSolidTile(t, tx, ty)) return true; + } + } + for (final m in movers) { + if (m.gone) continue; + if (x < m.x + m.w && x + ww > m.x && y < m.y + m.h && y + hh > m.y) { + if (moverExclude == m) continue; + return true; + } + } + return false; + } + + bool deadlyRect(double x, double y, double ww, double hh) { + final x0 = (x / TILE).floor(); + final x1 = ((x + ww - 0.001) / TILE).floor(); + final y0 = (y / TILE).floor(); + final y1 = ((y + hh - 0.001) / TILE).floor(); + for (int ty = y0; ty <= y1; ty++) { + for (int tx = x0; tx <= x1; tx++) { + final t = tileAt(tx, ty); + if (!deadlyTiles.contains(t)) continue; + final tr = spikeTri(t, tx * TILE, ty * TILE); + if (triRectOverlap(tr.ax, tr.ay, tr.bx, tr.by, tr.cx, tr.cy, x, y, ww, hh)) return true; + } + } + for (final e in entities) { + if (e.type == 'dropspike' && e.falling) { + final tr = spikeTri(Tile.spikeL, e.x, e.y); + if (triRectOverlap(tr.ax, tr.ay, tr.bx, tr.by, tr.cx, tr.cy, x, y, ww, hh)) return true; + } + } + for (final b in ebullets) { + if (b.dead) continue; + if (overlap(Box(x, y, ww, hh), Box(b.x, b.y, b.w, b.h))) return true; + } + for (final bs in bosses) { + if (bs.dead || !bs.bodyKill) continue; + if (overlap(Box(x, y, ww, hh), Box(bs.x, bs.y, bs.w, bs.h))) return true; + } + return false; + } + + bool waterRect(double x, double y, double ww, double hh) { + final x0 = (x / TILE).floor(); + final x1 = ((x + ww - 0.001) / TILE).floor(); + final y0 = (y / TILE).floor(); + final y1 = ((y + hh - 0.001) / TILE).floor(); + for (int ty = y0; ty <= y1; ty++) + for (int tx = x0; tx <= x1; tx++) + if (tileAt(tx, ty) == Tile.water) return true; + return false; + } + + bool iceAtBottom(double x, double y, double ww, double hh) { + final tx0 = (x / TILE).floor(); + final tx1 = ((x + ww - 1) / TILE).floor(); + final ty = ((y + hh + 1) / TILE).floor(); + for (int tx = tx0; tx <= tx1; tx++) if (tileAt(tx, ty) == Tile.ice) return true; + return false; + } + + // ---------------- 更新 ---------------- + void update(int dt, Player player, Camera cam) { + time += dt; + final t = time; + + for (int i = deathFX.length - 1; i >= 0; i--) { + if (++deathFX[i].t > 14) deathFX.removeAt(i); + } + + for (final m in movers) { + if (m.path.isEmpty) continue; + final tgt = m.path[(m.i + 1) % m.path.length]; + final ox = m.x, oy = m.y; + final dx = tgt.x - m.x, dy = tgt.y - m.y; + final dist = sqrt(dx * dx + dy * 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; + final 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 (final key in List.from(vanish.keys)) { + final st = vanish[key]!; + if (st == 0) continue; + final tt = (vanishT?[key] ?? 0) + 1; + vanishT ??= {}; + vanishT![key] = tt; + if (st == 1 && tt > 55) { vanish[key] = 2; vanishT![key] = 0; } + else if (st == 2 && tt > 110) { vanish[key] = 0; vanishT![key] = 0; } + } + + for (final e in entities) { + if (e.type != 'dropspike') continue; + if (!e.falling) { + final px = player.x + player.w / 2; + if ((px - (e.x + e.w / 2)).abs() < 26 && (player.y - e.y).abs() < 260 && player.y > e.y) { + e.falling = true; e.vy = 0; + Sfx.instance.vanish(); + } + } else if (e.landed) { + e.landT++; + if (e.landT > 80) { e.dead = true; e.falling = false; } + } else { + e.vy = min(e.vy + 0.5, 11); + e.y += e.vy; + final ty = ((e.y + e.h) / TILE).floor(); + final tx = ((e.x + e.w / 2) / TILE).floor(); + if (isSolidTile(tileAt(tx, ty), tx, ty)) { + e.y = ty * TILE - e.h; e.vy = 0; e.landed = true; e.landT = 0; + shake = max(shake, 5); + } + if (e.y > pxH + 100) e.dead = true; + } + } + + for (final e in entities) { + if (e.type != 'turret') continue; + e.t++; + if (e.t % (e.period) == 0) { + final cx = e.x + e.w / 2, cy = e.y + e.h / 2; + double 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) { + final dx = (player.x + player.w / 2) - cx, dy = (player.y + player.h / 2) - cy; + final d = max(sqrt(dx * dx + dy * dy), 1e-6); + vx = dx / d * e.speed; vy = dy / d * e.speed; + } + ebullets.add(EBullet(x: cx - 4, y: cy - 4, w: 8, h: 8, vx: vx, vy: vy, col: '#ff5c7a')); + Sfx.instance.shoot(); + } + } + + for (final b in ebullets) { + b.x += b.vx; b.y += b.vy; b.life--; + if (b.life <= 0) b.dead = true; + final tx = ((b.x + b.w / 2) / TILE).floor(), ty = ((b.y + b.h / 2) / TILE).floor(); + if (isSolidTile(tileAt(tx, ty), tx, ty)) { + b.dead = true; + particles.spawn(b.x + 4, b.y + 4, 4, colors: const ['#ff5c7a', '#fff'], speed: 2, life: 14); + } + } + ebullets.removeWhere((b) => b.dead); + + for (final b in bullets) { + b.x += b.vx; b.y += b.vy; b.life--; + if (b.life <= 0) b.dead = true; + final tx = ((b.x + b.w / 2) / TILE).floor(), ty = ((b.y + b.h / 2) / TILE).floor(); + final tt = tileAt(tx, ty); + if (tt == Tile.block || tt == Tile.grass || tt == Tile.brick || tt == Tile.metal) { + b.dead = true; + particles.spawn(b.x + 3, b.y + 3, 5, colors: const ['#fff6a0', '#ffb040'], speed: 2.4, life: 16); + } + } + bullets.removeWhere((b) => b.dead); + + for (final ev in events) { + if (ev['fired'] && ev['once']) continue; + if (overlap(Box(player.x, player.y, player.w, player.h), Box(ev['x'], ev['y'], ev['w'], ev['h']))) { + fireEvent(ev, player); + } + } + + camZone = null; + for (final e in entities) { + if (e.type != 'camzone') continue; + if (overlap(Box(player.x + player.w / 2 - 1, player.y + player.h / 2 - 1, 2, 2), Box(e.x, e.y, e.w, e.h))) { + camZone = e; + break; + } + } + + if (shake > 0) shake *= 0.86; + if (toastT > 0 && --toastT == 0) toast = null; + particles.update(); + } + + void fireEvent(Map ev, Player player) { + ev['fired'] = true; + for (final a in (ev['actions'] as List)) { + final type = a['type']; + if (type == 'msg') { + showToast(a['text'] ?? '', a['dur'] ?? 150); + } else if (type == 'spikes') { + for (int i = 0; i < (a['count'] ?? 5); i++) { + final x = ((a['x'] + i * (a['gap'] ?? 1)) as num) * TILE; + entities.add(Entity('dropspike', 0, 0, 0, x, ((a['y'] ?? 0) as num) * TILE, TILE, TILE) + ..falling = true + ..vy = 0 + ..homeY = ((a['y'] ?? 0) as num) * TILE + ..temp = true); + } + Sfx.instance.troll(); + } else if (type == 'vanish') { + for (int i = 0; i < (a['count'] ?? 3); i++) { + final tx = (a['x'] + i) as int, ty = a['y'] as int; + if (tileAt(tx, ty) == Tile.empty) continue; + setVanish(tx, ty, 1); + vanishT ??= {}; + vanishT![ty * 1000 + tx] = 0; + } + Sfx.instance.vanish(); + } else if (type == 'warp') { + player.x = (a['x'] as num) * TILE; + player.y = (a['y'] as num) * TILE; + player.vx = 0; player.vy = 0; + Sfx.instance.warp(); + } else if (type == 'kill') { + player.kill(); + } else if (type == 'shake') { + shake = (a['v'] ?? 10).toDouble(); + } + } + } + + void showToast(String text, [int dur = 150]) { + toast = text; + toastT = dur; + } + + // ---------------- 绘制 ---------------- + void drawBg(Canvas c, Camera cam, int t) { + final th = { + 'jump': ['#1a1830', '#0a0a14'], + 'endur': ['#101f24', '#080c10'], + 'boss': ['#2a1018', '#0d0810'], + 'troll': ['#221432', '#0b0812'], + 'custom': ['#141428', '#08080f'], + }[bgTheme] ?? ['#1a1830', '#0a0a14']; + // 主题双色近似(顶/底) + c.drawRect(Rect.fromLTWH(0, 0, cam.w, cam.h), Paint()..color = hex(th[1])); + c.drawRect(Rect.fromLTWH(0, 0, cam.w, cam.h * 0.5), Paint()..color = hex(th[0])); + + final px = -cam.x * 0.25, py = -cam.y * 0.15; + final sp = Paint()..color = const Color(0x29ffffff); + for (int i = 0; i < 60; i++) { + final nx = hashNoise(i, 7) * 2000, ny = hashNoise(i, 13) * 900; + final sx = ((nx + px) % (cam.w + 200)) - 100; + final sy = ((ny + py) % (cam.h + 120)) - 60; + c.drawRect(Rect.fromLTWH(sx, sy, 2, 2), sp); + } + + final gp = Paint()..color = const Color(0x0affffff); + final off = TILE * 2; + final sx0 = -((cam.x % off)); + final sy0 = -((cam.y % off)); + for (double x = sx0; x < cam.w; x += off) { + c.drawLine(Offset(x + 0.5, 0), Offset(x + 0.5, cam.h), gp); + } + for (double y = sy0; y < cam.h; y += off) { + c.drawLine(Offset(0, y + 0.5), Offset(cam.w, y + 0.5), gp); + } + } + + void draw(Canvas c, Camera cam, int t) { + final vx0 = max(0, (cam.x / TILE).floor() - 1); + final vx1 = min(w - 1, (((cam.x + cam.w / (cam.zoom)) / TILE).ceil() + 1)); + final vy0 = max(0, (cam.y / TILE).floor() - 1); + final vy1 = min(h - 1, (((cam.y + cam.h / (cam.zoom)) / TILE).ceil() + 1)); + + for (int ty = vy0; ty <= vy1; ty++) { + for (int tx = vx0; tx <= vx1; tx++) { + final v = tiles[ty][tx]; + if (v == Tile.empty) continue; + final x = tx * TILE, y = ty * TILE; + switch (v) { + case Tile.block: drawBlock(c, x, y, 0); break; + case Tile.grass: drawBlock(c, x, y, 1); break; + case Tile.brick: drawBlock(c, x, y, 2); break; + case Tile.metal: drawBlock(c, x, y, 3); break; + case Tile.ice: drawIce(c, x, y); break; + case Tile.water: drawWater(c, x, y, t); break; + case Tile.oneway: drawOneWay(c, x, y); break; + case Tile.killer: drawKiller(c, x, y, t); break; + case Tile.vanish: drawVanish(c, x, y, vanishState(tx, ty), t); break; + case Tile.spikeU: drawSpike(c, x, y, 0, t); break; + case Tile.spikeD: drawSpike(c, x, y, 1, t); break; + case Tile.spikeL: drawSpike(c, x, y, 2, t); break; + case Tile.spikeR: drawSpike(c, x, y, 3, t); break; + default: break; + } + } + } + + for (final m in movers) drawMovingPlatform(c, m); + + for (final e in entities) { + if (e.dead) continue; + switch (e.type) { + case 'save': drawSave(c, e, t); break; + case 'goal': drawGoal(c, e, t); break; + case 'gun': if (!e.taken) drawGun(c, e, t); break; + case 'apple': if (!e.taken) drawApple(c, e, t); break; + case 'jumpref': drawJumpRef(c, e.x, e.y, t); break; + case 'warp': if (!e.hidden) drawWarp(c, e, t); break; + case 'sign': drawSign(c, e, t); break; + case 'turret': drawTurret(c, e, t); break; + case 'camzone': drawCamera(c, e, t); break; + case 'dropspike': drawDropSpike(c, e, t); break; + default: break; + } + } + + for (final b in bullets) drawBullet(c, b); + for (final b in ebullets) drawEBullet(c, b, t); + + for (final dm in deathMarks) drawDeathMark(c, dm); + for (final fx in deathFX) drawDeathRing(c, fx); + + particles.draw(c); + } + + void drawOverlay(Canvas c, Camera cam, int t) { + for (final e in entities) { + if (e.type != 'sign') continue; + final sx = (e.x + e.w / 2 - cam.x) * (cam.zoom); + final sy = (e.y - 6 - cam.y) * (cam.zoom); + if (sx < -80 || sx > cam.w + 80) continue; + final txt = e.text ?? ''; + final tp = TextPainter( + text: TextSpan(text: txt, style: TextStyle(color: const Color(0xffffe9a8), fontSize: 11, fontFamily: 'Courier')), + textDirection: TextDirection.ltr, + ); + tp.layout(); + c.drawRect(Rect.fromLTWH(sx - tp.width / 2 - 5, sy - 13, tp.width + 10, 16), Paint()..color = rgba(0, 0, 0, 0.72)); + tp.paint(c, Offset(sx - tp.width / 2, sy)); + } + } +} + +// ---------------- 三角刺顶点与 SAT 判定 ---------------- +class Tri { + final double ax, ay, bx, by, cx, cy; + Tri(this.ax, this.ay, this.bx, this.by, this.cx, this.cy); +} + +Tri spikeTri(Tile t, double x, double y) { + const s = TILE; + if (t == Tile.spikeU) return Tri(x + s / 2, y, x, y + s, x + s, y + s); + if (t == Tile.spikeD) return Tri(x + s / 2, y + s, x, y, x + s, y); + if (t == Tile.spikeL) return Tri(x, y + s / 2, x + s, y, x + s, y + s); + if (t == Tile.spikeR) return Tri(x + s, y + s / 2, x, y, x, y + s); + return Tri(x, y, x + s, y, x, y + s); +} + +bool triRectOverlap(double ax, double ay, double bx, double by, double cx, double cy, + double rx, double ry, double rw, double rh) { + final rMinX = rx, rMaxX = rx + rw, rMinY = ry, rMaxY = ry + rh; + final tMinX = min(ax, min(bx, cx)), tMaxX = max(ax, max(bx, cx)); + final tMinY = min(ay, min(by, cy)), tMaxY = max(ay, max(by, cy)); + if (tMaxX < rMinX || tMinX > rMaxX || tMaxY < rMinY || tMinY > rMaxY) return false; + final edges = [[bx - ax, by - ay], [cx - bx, cy - by], [ax - cx, ay - cy]]; + for (final e in edges) { + final ex = e[0] as double, ey = e[1] as double; + final nx = -ey, ny = ex; + final t0 = nx * ax + ny * ay, t1 = nx * bx + ny * by, t2 = nx * cx + ny * cy; + final tMin = min(t0, min(t1, t2)), tMax = max(t0, max(t1, t2)); + final p0 = nx * rMinX + ny * rMinY, p1 = nx * rMaxX + ny * rMinY; + final p2 = nx * rMinX + ny * rMaxY, p3 = nx * rMaxX + ny * rMaxY; + final rMin = min(p0, min(p1, min(p2, p3))), rMax = max(p0, max(p1, max(p2, p3))); + if (tMax < rMin || rMax < tMin) return false; + } + return true; +} diff --git a/app/lib/src/game/levels.dart b/app/lib/src/game/levels.dart new file mode 100644 index 0000000..e2c4738 --- /dev/null +++ b/app/lib/src/game/levels.dart @@ -0,0 +1,66 @@ +// 关卡清单:从 assets/levels.json 加载内置关卡,并提供与 web 版 +// levels/index.js 一致的 TYPE_LABEL / TYPE_CLASS / makeCustomLevel / findLevel。 +import 'dart:convert'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:iwbtc/src/core/save.dart'; + +const Map TYPE_LABEL = { + 'jump': '跳刺', + 'endur': '耐久', + 'boss': 'BOSS', + 'troll': '坑向', + 'custom': '自制', +}; + +const Map TYPE_CLASS = { + 'jump': 't-jump', + 'endur': 't-endur', + 'boss': 't-boss', + 'troll': 't-troll', + 'custom': 't-custom', +}; + +List> _builtin = >[]; +bool _loaded = false; + +/// 加载内置关卡(assets/levels.json)。重复调用安全。 +Future loadLevels() async { + if (_loaded) return; + final s = await rootBundle.loadString('assets/levels.json'); + final m = (jsonDecode(s) as Map).cast(); + final list = ((m['levels'] as List?) ?? []) + .cast>() + .toList(); + for (int i = 0; i < list.length; i++) { + list[i] = {...list[i], 'index': i, 'builtin': true}; + } + _builtin = list; + _loaded = true; +} + +/// 内置关卡清单(已标注 index / builtin)。 +List> get LEVELS => _builtin; + +/// 合成一张自制关卡数据对象(与 web makeCustomLevel 一致)。 +Map makeCustomLevel(Map data) { + return { + ...data, + 'id': 'u' + (DateTime.now().millisecondsSinceEpoch % 100000).toString() + (_uid++).toString(), + 'type': 'custom', + 'builtin': false, + 'index': -1, + }; +} + +int _uid = 0; + +/// 按 id 查找关卡(内置优先,其次自制)。 +Map? findLevel(String id) { + for (final l in _builtin) { + if (l['id'] == id) return l; + } + for (final l in Save.customs()) { + if (l['id'] == id) return l as Map; + } + return null; +} diff --git a/app/lib/src/game/particles.dart b/app/lib/src/game/particles.dart new file mode 100644 index 0000000..bbb5ecc --- /dev/null +++ b/app/lib/src/game/particles.dart @@ -0,0 +1,66 @@ +// 粒子系统。与 web 版 particles.js 1:1 对应。 +import 'dart:ui' show Canvas, Paint, Color, Rect; +import 'dart:math' show cos, sin, min, max, sqrt, atan2, pi; +import 'package:iwbtc/src/core/util.dart'; + +class Particle { + double x, y, vx, vy; + int life, max; + Color c; + double s, g; + Particle(this.x, this.y, this.vx, this.vy, this.life, this.max, this.c, this.s, this.g); +} + +class Particles { + final List list = []; + + void clear() => list.clear(); + + void spawn(double x, double y, int n, + {List colors = const ['#fff', '#ff5c7a', '#ffc63a'], + double speed = 3, + int life = 40, + double size = 3, + double grav = 0.12, + double spread = 6.283185307179586, + double dir = 0}) { + for (int i = 0; i < n; i++) { + final a = dir + (rand(-0.5, 0.5)) * spread; + final s = rand(speed * 0.35, speed); + list.add(Particle( + x, + y, + cos(a) * s, + sin(a) * s, + (life * rand(0.6, 1.25)).round(), + life, + hex(colors[randInt(0, colors.length - 1)]), + size * rand(0.6, 1.3), + grav, + )); + } + } + + void burst(double x, double y, {int n = 18, double speed = 3, int life = 40, double size = 3, double grav = 0.12, List colors = const ['#fff', '#ff5c7a', '#ffc63a']}) { + spawn(x, y, n, colors: colors, speed: speed, life: life, size: size, grav: grav); + } + + void update() { + for (int i = list.length - 1; i >= 0; i--) { + final p = list[i]; + p.x += p.vx; + p.y += p.vy; + p.vy += p.g; + p.vx *= 0.985; + if (--p.life <= 0) list.removeAt(i); + } + } + + void draw(Canvas canvas) { + final paint = Paint(); + for (final p in list) { + paint.color = p.c.withAlpha((min(1.0, p.life / (p.max * 0.5)) * 255).round().clamp(0, 255)); + canvas.drawRect(Rect.fromLTWH(p.x.floorToDouble(), p.y.floorToDouble(), p.s.floorToDouble(), p.s.floorToDouble()), paint); + } + } +} diff --git a/app/lib/src/game/player.dart b/app/lib/src/game/player.dart new file mode 100644 index 0000000..334d51c --- /dev/null +++ b/app/lib/src/game/player.dart @@ -0,0 +1,383 @@ +// 玩家:还原 IWBT 的硬核手感 —— 松手即停、无加速、可变跳跃高度、二段跳。 +// 与 web 版 player.js 1:1 对应(逐帧积分完全一致,保证手感)。 +import 'dart:ui' show Canvas; +import 'dart:math' show pi, max, min; +import 'package:iwbtc/src/core/util.dart'; +import 'package:iwbtc/src/core/input.dart'; +import 'package:iwbtc/src/audio/sfx.dart'; +import 'package:iwbtc/src/game/sprites.dart'; +import 'package:iwbtc/src/game/views.dart'; +import 'package:iwbtc/src/game/level.dart' show Level, Tile, Mover, Cell, Bullet; + +class SavePoint { + double x, y; + int face; + SavePoint(this.x, this.y, this.face); +} + +class PHY { + static const double GRAV = 0.25; + static const double GRAV_FALL = 0.32; + static const double GRAV_DJ = 0.35; + static const double MAXFALL = 9; + static const double JUMP = 6.6; + static const double DJUMP = 6.7; + static const double DJ_INERTIA = 0; + static const double HSPD = 2.6; + static const double WATER_GRAV = 0.16; + static const double WATER_FALL = 2.6; + static const double WATER_RISE = 0.42; + static const double ICE_ACC = 0.18; + static const double ICE_FRIC = 0.07; + static const double SLIDE_FRIC = 0.12; + static const double SLIDE_AIR = 0.02; + static const double SLIDE_EASE = 0.14; + static const double SLIDE_BRAKE = 0.45; + static const double SLIDE_MIN = 0.35; + static const double SLIDE_STOP = 0.05; + static const double ICE_TOP = 1.6; +} + +class Player implements PlayerView { + Level level; + double w = 11, h = 21; + double x = 0, y = 0; + double vx = 0, vy = 0; + int jumps = 0; + bool djUp = false; + bool grounded = false; + int facing = 1; + bool alive = true; + bool dead = false; + int deadT = 0; + double deathX = 0, deathY = 0; + bool inWater = false; + int animT = 0; + int shootCool = 0; + Mover? mover; + bool sliding = false; + bool justRespawned = false; + bool hasGun = false; + int apples = 0; + SavePoint? savePoint; + int saveIdx = 0; + bool clearing = false; + + // 回调(由 World 设置) + void Function()? onDeath; + void Function()? onClear; + void Function(dynamic e)? onPortal; + void Function()? onRespawnFace; + + Player(this.level) { + reset(true); + } + + double get cx => x + w / 2; + double get cy => y + h / 2; + + void reset(bool full) { + final sp = (full || savePoint == null) + ? SavePoint(level.spawn.x, level.spawn.y, level.faceIndex ?? 0) + : savePoint!; + x = sp.x; y = sp.y; + vx = 0; vy = 0; + jumps = 0; + djUp = false; + grounded = false; + facing = 1; + alive = true; + dead = false; + deadT = 0; + deathX = 0; deathY = 0; + inWater = false; + animT = 0; + shootCool = 0; + mover = null; + sliding = false; + justRespawned = false; + if (full) { + hasGun = level.data['startGun'] == true; + apples = 0; + savePoint = null; + saveIdx = 0; + } + } + + /// 纯死亡演出(不进死亡状态):粒子爆开 + 屏幕轻震 + 死亡点扩散冲击环。 + void deathBurst() { + final L = level; + final dx = clamp(cx, 10, max(10, L.pxW - 10)); + final dy = clamp(cy, 10, max(10, L.pxH - 10)); + Sfx.instance.death(); + L.particles.spawn(dx, dy, 16, colors: const ['#ff5c7a', '#ff8095', '#fff', '#ffc63a'], speed: 3.4, life: 34, size: 3, grav: 0.16); + L.shake = max(L.shake, 6); + L.addDeathFX(dx, dy); + deathX = dx; deathY = dy; + } + + void kill([Map opts = const {}]) { + if (dead || !alive) return; + dead = true; alive = false; deadT = 0; + vx = 0; vy = 0; sliding = false; + deathBurst(); + if (opts['mark'] != false) level.addDeathMark(deathX, deathY); + if (opts['count'] != false && onDeath != null) onDeath!(); + } + + void respawn() { + dead = false; alive = true; + final L = level; + final sp = savePoint ?? SavePoint(L.spawn.x, L.spawn.y, 0); + x = sp.x; y = sp.y; + vx = 0; vy = 0; jumps = 0; + djUp = false; + shootCool = 0; sliding = false; + deadT = 0; + int guard = 0; + while (L.solidRect(x, y, w, h) && guard++ < 48) y -= 2; + L.softReset(); + justRespawned = true; + } + + void update(int dt) { + final L = level; + animT++; + if (shootCool > 0) shootCool--; + + if (dead) { deadT++; return; } + + final inWater = L.waterRect(x + 2, y + 4, w - 4, h - 4); + if (inWater && !this.inWater) Sfx.instance.splash(); + this.inWater = inWater; + + final moveL = Input.held('left'), moveR = Input.held('right'); + final onIce = grounded && L.iceAtBottom(x, y, w, h); + double target = 0; + if (moveL && !moveR) target = -PHY.HSPD; + else if (moveR && !moveL) target = PHY.HSPD; + + final ICE_MAX = PHY.HSPD * PHY.ICE_TOP; + final decel = (double f) { + if (vx.abs() <= f) vx = 0; + else vx -= sgn(vx) * f; + }; + + if (onIce) { + if (target != 0) vx += sgn(target) * PHY.ICE_ACC; + else decel(PHY.ICE_FRIC); + vx = clamp(vx, -ICE_MAX, ICE_MAX); + if (vx.abs() > PHY.SLIDE_MIN) sliding = true; + } else if (sliding && vx.abs() > PHY.SLIDE_STOP) { + if (target != 0) { + final dir = sgn(vx); + if (dir == 0 || sgn(target) == dir) { + if (vx.abs() >= PHY.HSPD) { + vx -= dir * PHY.SLIDE_EASE; + if (vx.abs() < PHY.HSPD) { vx = target; sliding = false; } + } else { + vx = target; sliding = false; + } + } else { + vx -= dir * PHY.SLIDE_BRAKE; + if (sgn(vx) == sgn(target)) { vx = target; sliding = false; } + } + vx = clamp(vx, -ICE_MAX, ICE_MAX); + } else { + decel(grounded ? PHY.SLIDE_FRIC : PHY.SLIDE_AIR); + if (vx == 0) sliding = false; + } + if (vx != 0 && vx.abs() < PHY.SLIDE_STOP) { vx = 0; sliding = false; } + } else { + vx = target; + sliding = false; + } + if (target != 0) facing = sgn(target).toInt(); + + if (inWater) { + if (Input.held('jump')) vy -= PHY.WATER_RISE; + if (Input.held('down')) vy += 0.25; + vy = clamp(vy, -3.2, PHY.WATER_FALL); + jumps = 0; djUp = false; + } else { + if (Input.hit('jump')) { + if (grounded || jumps == 0) { + vy = -PHY.JUMP; jumps = 1; grounded = false; + Sfx.instance.jump(); + L.particles.spawn(cx, y + h, 5, colors: const ['#fff', '#cfcfe0'], speed: 1.6, life: 16, size: 2, dir: -pi / 2, spread: 2); + } else if (jumps == 1) { + vy = max(vy, 0) * PHY.DJ_INERTIA - PHY.DJUMP; jumps = 2; + djUp = vy < 0; + Sfx.instance.djump(); + L.particles.spawn(cx, cy, 10, colors: const ['#fff', '#9ad8ff'], speed: 2.2, life: 20, size: 2, spread: pi * 2); + } + } + if (!Input.held('jump') && vy < -3) vy = -3; + vy = min(vy + (vy < 0 ? (djUp ? PHY.GRAV_DJ : PHY.GRAV) : PHY.GRAV_FALL), PHY.MAXFALL); + if (djUp && vy >= 0) djUp = false; + } + + if (mover != null && !(mover!.gone)) { + x += mover!.dx; + y += mover!.dy; + } + + // X 轴移动 + final prevBottom = y + h; + mover = null; + double steps = vx.abs(); + final dx = sgn(vx).toDouble(); + while (steps > 0) { + final m = min(1, steps); + if (!L.solidRect(x + dx * m, y, w, h)) { x += dx * m; steps -= m; } + else break; + } + if (L.solidRect(x + dx, y, w, h) && vx.abs() > 0) vx = 0; + + // Y 轴移动 + final dropThrough = Input.held('down'); + steps = vy.abs(); + final dy = sgn(vy).toDouble(); + bool hitGround = false; + while (steps > 0) { + final m = min(1, steps); + final ny = y + dy * m; + final solidHere = L.solidRect(x, ny, w, h, prevBottom: prevBottom, dropThrough: dropThrough); + if (!solidHere) { y = ny; steps -= m; } + else { + if (dy > 0) hitGround = true; + vy = 0; + break; + } + } + for (final m in L.movers) { + if (m.gone) continue; + if (x + w > m.x && x < m.x + m.w) { + if (y + h >= m.y - 2 && y + h <= m.y + m.h) { + mover = m; hitGround = true; + } + } + } + + final wasAir = !grounded; + grounded = hitGround || (vy == 0 && L.solidRect(x, y + 1, w, h)); + if (grounded) { + jumps = 0; djUp = false; + } + + if (sliding && grounded && vx.abs() > PHY.HSPD && animT % 5 == 0) { + L.particles.spawn(cx - sgn(vx) * 5, y + h - 2, 2, colors: const ['#dff4ff', '#8fd8f0', '#fff'], speed: 1.1, life: 16, size: 2, dir: -pi / 2, spread: 1.7, grav: 0.1); + } + + x = clamp(x, 0, L.pxW - w); + if (y > L.pxH + 200) { kill(); return; } + if (y < -300) { y = -300; vy = 0; } + + if (L.deadlyRect(x, y, w, h)) { kill(); return; } + + if (hasGun && Input.hit('shoot') && shootCool == 0 && L.bullets.length < 5) { + shoot(); + } + + touchEntities(); + + if (grounded) { + final ty = ((y + h + 2) / TILE).floor(); + for (int tx = (x / TILE).floor(); tx <= ((x + w - 1) / TILE).floor(); tx++) { + if (ty < L.tiles.length && tx >= 0 && tx < L.tiles[0].length && L.tiles[ty][tx] == Tile.vanish && L.vanishState(tx, ty) == 0) { + L.setVanish(tx, ty, 1); + L.vanishT ??= {}; + L.vanishT![ty * 1000 + tx] = 0; + } + } + } + } + + void shoot() { + final L = level; + shootCool = 9; + L.bullets.add(Bullet( + x: facing > 0 ? x + w - 2 : x - 6, + y: y + 8, w: 10.0, h: 5.0, + vx: 9.0 * facing, vy: 0.0, life: 130, + )); + Sfx.instance.shoot(); + L.particles.spawn(facing > 0 ? x + w : x, y + 10, 3, colors: const ['#fff6a0'], speed: 1.5, life: 10, size: 2); + } + + void touchEntities() { + final L = level; + for (final e in L.entities) { + if (e.dead) continue; + final hit = e.type == 'save' + ? overlap(Box(x - 6, y - 6, w + 12, h + 12), Box(e.x, e.y, e.w, e.h)) + : overlap(Box(x, y, w, h), Box(e.x, e.y, e.w, e.h)); + if (!hit) continue; + switch (e.type) { + case 'save': + if (!e.active) { + e.active = true; + savePoint = SavePoint(e.x + e.w / 2 - w / 2, e.y + e.h - h, level.faceIndex ?? 0); + saveIdx = e.no ?? 1; + Sfx.instance.save(); + L.particles.spawn(e.x + e.w / 2, e.y + e.h / 2, 18, colors: const ['#b06bff', '#fff', '#e5d0ff'], speed: 2.6, life: 30); + if (e.fake) { L.showToast('假的。', 90); kill(); return; } + } + break; + case 'goal': + if (!clearing) { clearing = true; if (onClear != null) onClear!(); } + break; + case 'gun': + if (!e.taken) { + e.taken = true; hasGun = true; + L.particles.spawn(e.x + e.w / 2, e.y + e.h / 2, 12, colors: const ['#dfe0f0', '#fff'], speed: 2.2, life: 24); + L.showToast('获得手枪!按 X 射击', 120); + Sfx.instance.save(); + } + break; + case 'apple': + if (!e.taken) { e.taken = true; apples++; Sfx.instance.save(); } + break; + case 'jumpref': + if (animT % 4 == 0) jumps = 0; + break; + case 'warp': + if (e.cool > 0) break; + e.cool = 40; + final to = e.to; + if (to != null && to is Map && !to.containsKey('x') && !to.containsKey('y') && to['face'] is int) { + if (onPortal != null) onPortal!(e); + break; + } + double? nx, ny; + if (to == 'spawn') { + nx = L.spawn.x; ny = L.spawn.y; + } else if (to != null && to is Map && to.containsKey('x')) { + nx = (to['x'] as num) * TILE; ny = (to['y'] as num) * TILE; + } else { + final p = L.entities.firstWhere((o) => o.type == 'warp' && o.idx == e.pair, orElse: () => e); + if (p != e) { + nx = p.x + p.w / 2 - w / 2; ny = p.y + p.h - h; + } + } + if (nx != null && ny != null) { + x = nx; y = ny; + vx = 0; vy = 0; + final partner = L.entities.firstWhere((o) => o.type == 'warp' && o.idx == e.pair, orElse: () => e); + if (partner != e) partner.cool = 40; + Sfx.instance.warp(); + L.particles.spawn(cx, cy, 12, colors: const ['#a86bff', '#fff'], speed: 2.5, life: 24); + } + break; + case 'sign': + if (animT % 30 == 0) L.showToast(e.text ?? '', 90); + break; + } + } + } + + void draw(Canvas c) { + if (dead) return; + drawKid(c, this, animT); + } +} diff --git a/app/lib/src/game/sprites.dart b/app/lib/src/game/sprites.dart new file mode 100644 index 0000000..b468258 --- /dev/null +++ b/app/lib/src/game/sprites.dart @@ -0,0 +1,448 @@ +// 程序化像素绘制(无外部图片资源)。对应 web 版 sprites.js。 +// 全部使用 Flutter dart:ui 的 Canvas / Paint / Path,逐函数镜像。 +import 'dart:ui' show Canvas, Paint, Path, Rect, Color, Offset, PaintingStyle, StrokeCap, TextAlign; +import 'dart:math' show sin, cos, pi, max, min, sqrt, atan2; +import 'package:flutter/widgets.dart'; +import 'package:iwbtc/src/core/util.dart'; +import 'package:iwbtc/src/game/views.dart'; + +Color rgba(int r, int g, int b, double a) => + Color.fromARGB((a * 255).round().clamp(0, 255), r, g, b); + +class C { + static const Color stone = Color(0xff6b6b7e); + static const Color stoneDark = Color(0xff3a3a48); + static const Color stoneLite = Color(0xff8f8fa6); + static const Color grass = Color(0xff4f9e46); + static const Color grassDark = Color(0xff2c5c2b); + static const Color brick = Color(0xffa8543c); + static const Color brickDark = Color(0xff632a1d); + static const Color metal = Color(0xff5a6478); + static const Color metalDark = Color(0xff333a4a); + static const Color ice = Color(0xff8fd8f0); + static const Color iceDark = Color(0xff4a90b8); + static const Color water = Color(0xff2a6fd8); + static const Color spike = Color(0xffcfdde9); + static const Color spikeDark = Color(0xffa8bccf); + static const Color kid = Color(0xffd94a55); + static const Color kidDark = Color(0xff8e2530); + static const Color skin = Color(0xfff5cfa8); + static const Color hair = Color(0xff5a3a22); + static const Color pant = Color(0xff2c3a7a); + static const Color save = Color(0xffb06bff); + static const Color goal = Color(0xffffc63a); + static const Color gun = Color(0xffb8b8c8); + static const Color apple = Color(0xffe03050); +} + +void px(Canvas c, double x, double y, double w, double h, Color col) { + final p = Paint()..color = col; + c.drawRect(Rect.fromLTWH(x, y, w, h), p); +} + +// ---------------- 地形 ---------------- +void drawBlock(Canvas c, double x, double y, int variant, [bool lit = false]) { + const s = TILE; + Color base = C.stone, dark = C.stoneDark, lite = C.stoneLite; + if (variant == 1) { + base = C.grass; dark = C.grassDark; lite = const Color(0xff7fd06a); + } else if (variant == 2) { + base = C.brick; dark = C.brickDark; lite = const Color(0xffd1785c); + } else if (variant == 3) { + base = C.metal; dark = C.metalDark; lite = const Color(0xff8a94ad); + } + px(c, x, y, s, s, dark); + px(c, x + 1, y + 1, s - 2, s - 2, base); + if (variant == 2) { + px(c, x + 1, y + 8, s - 2, 1, dark); + px(c, x + 1, y + 17, s - 2, 1, dark); + px(c, x + 10, y + 1, 1, 7, dark); + px(c, x + 20, y + 9, 1, 8, dark); + px(c, x + 6, y + 18, 1, 7, dark); + } else if (variant == 1) { + px(c, x + 1, y + 1, s - 2, 5, lite); + for (int i = 0; i < 4; i++) px(c, x + 3 + i * 7, y + 2, 1, 4, dark); + } else { + final n = hashNoise(x.round(), y.round()); + px(c, x + 2, y + 2, 6, 2, lite); + if (n > 0.5) px(c, x + 18, y + 12, 8, 2, dark); + if (n < 0.35) px(c, x + 6, y + 22, 7, 2, dark); + } + px(c, x, y, s, 1, lite); + px(c, x, y, 1, s, lite); + px(c, x, y + s - 1, s, 1, dark); + px(c, x + s - 1, y, 1, s, dark); + if (lit) px(c, x, y, s, s, rgba(255, 255, 255, 0.09)); +} + +void drawIce(Canvas c, double x, double y) { + const s = TILE; + px(c, x, y, s, s, C.iceDark); + px(c, x + 1, y + 1, s - 2, s - 2, C.ice); + px(c, x + 2, y + 3, 10, 3, const Color(0xffe6fbff)); + px(c, x + 18, y + 16, 8, 2, rgba(255, 255, 255, 0.7)); + px(c, x, y, s, 1, const Color(0xffe6fbff)); + px(c, x, y + s - 1, s, 1, C.iceDark); +} + +void drawWater(Canvas c, double x, double y, int t) { + const s = TILE; + px(c, x, y, s, s, rgba(40, 110, 215, 0.55)); + final off = sin(t / 260 + x * 0.09) * 3; + px(c, x, y + 4 + off, s, 3, rgba(120, 200, 255, 0.35)); + px(c, x, y + 16 - off, s, 2, rgba(120, 200, 255, 0.35)); +} + +void drawOneWay(Canvas c, double x, double y) { + const s = TILE; + px(c, x, y, s, 6, C.metalDark); + px(c, x, y, s, 3, const Color(0xff9aa6bd)); + for (int i = 0; i < 8; i++) px(c, x + i * 4, y + 6, 2, 3, C.metalDark); +} + +void drawSpike(Canvas c, double x, double y, int dir, int t) { + const s = TILE; + late double ax, ay, bx, by, cx2, cy2; + if (dir == 0) { + ax = x + s / 2; ay = y; bx = x; by = y + s; cx2 = x + s; cy2 = y + s; + } else if (dir == 1) { + ax = x + s / 2; ay = y + s; bx = x; by = y; cx2 = x + s; cy2 = y; + } else if (dir == 2) { + ax = x; ay = y + s / 2; bx = x + s; by = y; cx2 = x + s; cy2 = y + s; + } else { + ax = x + s; ay = y + s / 2; bx = x; by = y; cx2 = x; cy2 = y + s; + } + late Offset from, to; + if (dir == 0) { from = Offset(x, y); to = Offset(x, y + s); } + else if (dir == 1) { from = Offset(x, y + s); to = Offset(x, y); } + else if (dir == 2) { from = Offset(x, y); to = Offset(x + s, y); } + else { from = Offset(x + s, y); to = Offset(x, y); } + final rect = Rect.fromLTWH(x, y, s, s); + final g = LinearGradient( + begin: _gradAlign(from, rect), end: _gradAlign(to, rect), + colors: const [Color(0xfff4f9fd), Color(0xffcfdde9), Color(0xffa8bccf)], + ); + final p = Path(); + p.moveTo(ax, ay); p.lineTo(bx, by); p.lineTo(cx2, cy2); p.close(); + final paint = Paint()..shader = g.createShader(rect); + c.drawPath(p, paint); + final stroke = Paint() + ..color = rgba(150, 175, 200, 0.9) + ..style = PaintingStyle.stroke + ..strokeWidth = 1; + c.drawPath(p, stroke); +} + +void drawKiller(Canvas c, double x, double y, int t) { + const s = TILE; + final bob = sin(t / 300 + x) * 1.5; + c.save(); + c.translate(x + s / 2, y + s / 2 + bob); + final p = Paint()..color = C.apple; + c.drawCircle(Offset(0, 2), 11, p); + final p2 = Paint()..color = const Color(0xffff7a90); + c.drawCircle(Offset(-4, -2), 4, p2); + px(c, -1, -13, 2, 6, const Color(0xff6b4423)); + final leaf = Paint()..color = const Color(0xff3ddc84); + c.drawOval(Rect.fromLTWH(1, -13, 10, 5.2), leaf); + px(c, -5, 0, 2, 2, const Color(0xff000000)); + px(c, 3, 0, 2, 2, const Color(0xff000000)); + c.restore(); +} + +void drawVanish(Canvas c, double x, double y, int state, int t) { + const s = TILE; + final alpha = state == 0 ? 1.0 : state == 1 ? (sin(t / 60) > 0 ? 0.55 : 0.9) : 0.14; + drawBlock(c, x, y, 3); + if (alpha < 1) { + // 模拟 globalAlpha:用半透明黑覆盖来压暗 + px(c, x, y, s, s, Color.fromARGB(((1 - alpha) * 255).round().clamp(0, 255), 0, 0, 0)); + } + if (state != 2) { + final sp = Paint() + ..color = rgba(255, 200, 60, 0.55) + ..style = PaintingStyle.stroke + ..strokeWidth = 1; + c.drawRect(Rect.fromLTWH(x + 2.5, y + 2.5, s - 5, s - 5), sp); + } +} + +void drawJumpRef(Canvas c, double x, double y, int t) { + const s = TILE; + final b = sin(t / 200 + x) * 2; + final p = Path(); + p.moveTo(x + s / 2, y + 8 + b); + p.lineTo(x + s - 6, y + s - 10 + b); + p.lineTo(x + 6, y + s - 10 + b); + p.close(); + c.drawPath(p, Paint()..color = const Color(0xff3ddc84)); + final sp = Paint() + ..color = const Color(0xff0d5c30) + ..style = PaintingStyle.stroke + ..strokeWidth = 1; + c.drawPath(p, sp); +} + +// ---------------- 玩家 ---------------- +void drawKid(Canvas c, PlayerView p, int t) { + final x = p.x.roundToDouble(), y = p.y.roundToDouble(); + final w = p.w, h = p.h; + final air = !p.grounded; + c.save(); + c.translate(x, y); + // 身体(红衣) + px(c, 2, 7, w - 4, 7, C.kid); + px(c, 2, 7, w - 4, 1, shade(C.kid, 40)); + if (air) { + px(c, p.facing > 0 ? w - 3 : 0, 5, 3, 5, C.skin); + } else { + px(c, p.facing > 0 ? w - 3 : 0, 9, 3, 5, C.skin); + } + if (air) { + px(c, 3, 14, 4, 4, C.pant); + px(c, w - 7, 14, 4, 3, C.pant); + } else { + px(c, 2, 14, 4, 4, C.pant); + px(c, w - 6, 14, 4, 4, C.pant); + } + px(c, 1, 0, w - 2, 8, C.skin); + px(c, 1, 0, w - 2, 3, C.hair); + px(c, p.facing > 0 ? w - 5 : 3, 1, 3, 2, C.hair); + px(c, p.facing > 0 ? w - 6 : 3, 4, 2, 2, const Color(0xff101018)); + px(c, p.facing > 0 ? w - 3 : 0, 4, 1, 2, const Color(0xff101018)); + c.restore(); + + if (p.hasGun && !p.dead) { + final gx = p.facing > 0 ? x + w - 1 : x - 7; + final gy = y + 9; + px(c, gx, gy, 8, 3, C.gun); + px(c, p.facing > 0 ? gx + 6 : gx, gy + 1, 3, 2, const Color(0xff7a7a8c)); + px(c, gx + 1, gy + 3, 3, 3, const Color(0xff6a6a7c)); + } +} + +// ---------------- 实体 ---------------- +void drawSave(Canvas c, EntityView e, int t) { + final cx = e.x + e.w / 2, cy = e.y + e.h / 2; + final on = e.active; + final pulse = 1 + sin(t / 180) * 0.09; + if (on) { + final p = Paint()..color = rgba(176, 107, 255, 0.28); + c.drawCircle(Offset(cx, cy), 15 * pulse, p); + } else { + final r = 13 + sin(t / 26) * 2.2; + final sp = Paint() + ..color = rgba(138, 106, 184, 0.30 + sin(t / 26) * 0.12) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + c.drawCircle(Offset(cx, cy), r, sp); + } + final col = on ? C.save : const Color(0xff8f74b5); + final p = Paint()..color = col; + c.drawCircle(Offset(cx - 3, cy - 1), 4.2, p); + c.drawCircle(Offset(cx + 4, cy + 1), 4.2, p); + c.drawCircle(Offset(cx, cy + 5), 4.2, p); + c.drawCircle(Offset(cx + 1, cy - 5), 4.2, p); + if (on) { + c.drawCircle(Offset(cx - 1, cy - 2), 1.6, Paint()..color = const Color(0xffe5d0ff)); + } + px(c, cx + 1, cy - 9, 2, 4, const Color(0xff4a8a3a)); +} + +void drawGoal(Canvas c, EntityView e, int t) { + final x = e.x, y = e.y, w = e.w, h = e.h; + px(c, x + 3, y + 2, w - 6, h - 2, const Color(0xff2a1e3a)); + px(c, x + 5, y + 4, w - 10, h - 6, const Color(0xff140d20)); + final gr = Rect.fromLTWH(x + 5, y + 4, w - 10, h - 6); + final g = LinearGradient( + begin: _gradAlign(Offset(x, y + h), gr), end: _gradAlign(Offset(x, y), gr), + colors: [rgba(255, 200, 60, 0.55), rgba(255, 200, 60, 0)], + ); + c.drawRect(gr, Paint()..shader = g.createShader(gr)); + for (int i = 0; i < 3; i++) { + final pp = (((t / 700) + i / 3) % 1); + px(c, x + 7 + ((i * 9) % (w - 12)), y + h - 4 - pp * (h - 8), 2, 2, C.goal); + } + px(c, x + 3, y + 2, w - 6, 2, C.goal); + px(c, x + 3, y + 2, 2, h - 2, const Color(0xffc99a1e)); + px(c, x + w - 5, y + 2, 2, h - 2, const Color(0xffc99a1e)); +} + +void drawGun(Canvas c, EntityView e, int t) { + final x = e.x, y = e.y + sin(t / 250) * 2; + px(c, x + 2, y + 6, 20, 7, const Color(0xff4a4a5c)); + px(c, x + 2, y + 6, 20, 2, const Color(0xff8a8aa0)); + px(c, x + 18, y + 8, 9, 3, C.gun); + px(c, x + 6, y + 13, 5, 8, const Color(0xff3a3a4c)); + px(c, x + 8, y + 19, 3, 3, const Color(0xff2a2a3a)); + px(c, x + 24, y + 7, 2, 5, const Color(0xffd0d0e0)); +} + +void drawApple(Canvas c, EntityView e, int t) { + final cx = e.x + e.w / 2, cy = e.y + e.h / 2 + sin(t / 320 + e.x) * 1.5; + c.drawCircle(Offset(cx, cy), 8, Paint()..color = C.apple); + c.drawCircle(Offset(cx - 3, cy - 3), 3, Paint()..color = const Color(0xffff8095)); + px(c, cx - 1, cy - 11, 2, 4, const Color(0xff6b4423)); + c.drawOval(Rect.fromLTWH(cx + 4 - 4, cy - 9 - 2, 8, 4), Paint()..color = const Color(0xff3ddc84)); +} + +void drawWarp(Canvas c, EntityView e, int t) { + final cx = e.x + e.w / 2, cy = e.y + e.h / 2; + for (int i = 0; i < 3; i++) { + final r = 14 - i * 4; + final a = t / (300 + i * 120); + final sp = Paint() + ..color = Color.fromARGB(((0.85 - i * 0.2) * 255).round().clamp(0, 255), (170 - i * 30).clamp(0, 255), (90 + i * 40).clamp(0, 255), 255) + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + c.drawArc(Rect.fromLTWH(cx - r, cy - r, r * 2, r * 2), a, pi * 1.4, false, sp); + } + c.drawCircle(Offset(cx, cy), 5, Paint()..color = rgba(120, 60, 200, 0.35)); +} + +void drawCamera(Canvas c, EntityView e, int t) { + final x = e.x, y = e.y, w = e.w, h = e.h; + _dashedRect(c, Rect.fromLTWH(x + 1, y + 1, w - 2, h - 2), + rgba(80, 220, 255, 0.35 + sin(t / 300) * 0.15), 1, 4, 4); + final ix = x + w / 2 - 9, iy = y + 6; + px(c, ix, iy + 3, 18, 11, const Color(0xff1e1e2a)); + px(c, ix + 2, iy + 5, 14, 7, const Color(0xff3a3a4c)); + c.drawCircle(Offset(ix + 9, iy + 8.5), 3.5, Paint()..color = const Color(0xff7ae0ff)); + px(c, ix + 12, iy, 5, 3, const Color(0xff1e1e2a)); + px(c, ix + 13, iy - 1, 3, 2, const Color(0xffff4060)); +} + +void drawSign(Canvas c, EntityView e, int t) { + final x = e.x, y = e.y, w = e.w, h = e.h; + px(c, x + w / 2 - 1, y + h - 8, 3, 8, const Color(0xff6b4423)); + px(c, x, y + 2, w, h - 10, const Color(0xffc9a86b)); + px(c, x + 2, y + 4, w - 4, h - 14, const Color(0xffe8d5a8)); + px(c, x + 4, y + 8, w - 8, 2, const Color(0xff8a7550)); + px(c, x + 4, y + 13, w - 12, 2, const Color(0xff8a7550)); +} + +void drawTurret(Canvas c, EntityView e, int t) { + final x = e.x, y = e.y; + const s = TILE; + px(c, x + 2, y + 6, s - 4, s - 6, const Color(0xff4a4a5e)); + px(c, x + 4, y + 8, s - 8, s - 12, const Color(0xff6a6a80)); + final dirx = e.dir == 'left' ? -1 : e.dir == 'right' ? 1 : 0; + final diry = e.dir == 'up' ? -1 : e.dir == 'down' ? 1 : 0; + final cx = x + s / 2, cy = y + s / 2; + px(c, cx + dirx * 8 - 3, cy + diry * 8 - 3, 7, 7, const Color(0xff2a2a38)); + final blink = (t ~/ 300) % 3 == 0; + px(c, cx + dirx * 12 - 2, cy + diry * 12 - 2, 4, 4, blink ? const Color(0xffff4060) : const Color(0xff8a2030)); +} + +void drawMovingPlatform(Canvas c, MoverView m) { + final x = m.x, y = m.y, w = m.w, h = m.h; + px(c, x, y, w, h, const Color(0xff3a3a4c)); + px(c, x + 1, y + 1, w - 2, h - 2, const Color(0xff6a6a84)); + px(c, x + 1, y + 1, w - 2, 2, const Color(0xff9a9ab4)); + for (double i = 0; i < w; i += 8) px(c, x + i + 2, y + h - 4, 4, 2, const Color(0xff3a3a4c)); + _dashedLine(c, Offset(m.ax + m.aw / 2, m.ay + m.ah / 2), Offset(m.bx + m.aw / 2, m.by + m.ah / 2), + rgba(255, 255, 255, 0.10), 1, 3, 5); +} + +void drawDropSpike(Canvas c, EntityView e, int t) { + const s = TILE; + if (!e.falling) { + px(c, e.x + 4, e.y + 4, s - 8, s - 8, rgba(74, 74, 92, 0.85)); + px(c, e.x + 6, e.y + 6, s - 12, s - 12, rgba(106, 106, 128, 0.85)); + } else { + drawSpike(c, e.x, e.y, 2, t); + } +} + +void drawBullet(Canvas c, BulletView b) { + px(c, b.x - 1, b.y - 1, b.w + 2, b.h + 2, rgba(255, 240, 120, 0.35)); + px(c, b.x, b.y, b.w, b.h, const Color(0xfffff6a0)); + px(c, b.x, b.y + b.h - 2, b.w, 2, const Color(0xffffb040)); +} + +void drawEBullet(Canvas c, BulletView b, int t) { + final cx = b.x + b.w / 2, cy = b.y + b.h / 2; + c.drawCircle(Offset(cx, cy), b.w / 2 + 1, Paint()..color = hex(b.col)); + c.drawCircle(Offset(cx - 1.5, cy - 1.5), b.w / 4, Paint()..color = const Color(0xffffffff)); + if (b.big) { + final sp = Paint() + ..color = rgba(255, 255, 255, 0.4) + ..style = PaintingStyle.stroke; + c.drawCircle(Offset(cx, cy), b.w / 2 + 4 + sin(t / 90) * 2, sp); + } +} + +// ---------------- 死亡特效 ---------------- +void drawDeathRing(Canvas c, DeathFX fx) { + final t = fx.t; + if (t > 14) return; + final r = 4 + t * 1.8; + final a = max(0.0, 1 - t / 14); + final sp = Paint() + ..color = rgba(255, 143, 163, a * 0.85) + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + c.drawCircle(Offset(fx.x, fx.y), r, sp); + c.drawCircle(Offset(fx.x, fx.y), r * 0.55, Paint()..color = rgba(255, 92, 122, a * 0.3)); +} + +void drawDeathMark(Canvas c, DeathMark dm) { + const s = 4.0; + final sp = Paint() + ..color = rgba(0, 0, 0, 0.55) + ..style = PaintingStyle.stroke + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round; + _line(c, sp, dm.x - s, dm.y - s, dm.x + s, dm.y + s); + _line(c, sp, dm.x + s, dm.y - s, dm.x - s, dm.y + s); + final sp2 = Paint() + ..color = const Color(0xffff2535) + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round; + _line(c, sp2, dm.x - s, dm.y - s, dm.x + s, dm.y + s); + _line(c, sp2, dm.x + s, dm.y - s, dm.x - s, dm.y + s); +} + +// ---------------- 文本 ---------------- +void drawText(Canvas c, String text, double x, double y, + {Color color = const Color(0xffffffff), double size = 11, TextAlign align = TextAlign.center}) { + final tp = TextPainter( + text: TextSpan(text: text, style: TextStyle(color: color, fontSize: size, fontFamily: 'Courier')), + textAlign: align, + textDirection: TextDirection.ltr, + ); + tp.layout(); + tp.paint(c, Offset(x - (align == TextAlign.center ? tp.width / 2 : 0), y)); +} + +// ---------------- 虚线辅助 ---------------- +void _dashedLine(Canvas c, Offset a, Offset b, Color col, double width, double dash, double gap) { + final p = Paint()..color = col..strokeWidth = width; + final dx = b.dx - a.dx, dy = b.dy - a.dy; + final dist = sqrt(dx * dx + dy * dy); + if (dist == 0) return; + final ux = dx / dist, uy = dy / dist; + double d = 0; + while (d < dist) { + final d2 = min(d + dash, dist); + c.drawLine(Offset(a.dx + ux * d, a.dy + uy * d), Offset(a.dx + ux * d2, a.dy + uy * d2), p); + d += dash + gap; + } +} + +void _dashedRect(Canvas c, Rect r, Color col, double width, double dash, double gap) { + _dashedLine(c, r.topLeft, r.topRight, col, width, dash, gap); + _dashedLine(c, r.topRight, r.bottomRight, col, width, dash, gap); + _dashedLine(c, r.bottomRight, r.bottomLeft, col, width, dash, gap); + _dashedLine(c, r.bottomLeft, r.topLeft, col, width, dash, gap); +} + +void _line(Canvas c, Paint p, double x1, double y1, double x2, double y2) { + c.drawLine(Offset(x1, y1), Offset(x2, y2), p); +} + +// 把像素空间的绝对 Offset 换算成 Flutter LinearGradient 所需的 Alignment。 +Alignment _gradAlign(Offset abs, Rect r) => + Alignment(((abs.dx - r.left) - r.width / 2) / (r.width / 2), + ((abs.dy - r.top) - r.height / 2) / (r.height / 2)); diff --git a/app/lib/src/game/views.dart b/app/lib/src/game/views.dart new file mode 100644 index 0000000..55a75bd --- /dev/null +++ b/app/lib/src/game/views.dart @@ -0,0 +1,58 @@ +// 渲染层只读的"视图"接口:让 sprites.dart 不依赖完整游戏类,避免循环依赖。 +import 'dart:ui' show Color; + +abstract class PlayerView { + double get x; + double get y; + double get w; + double get h; + double get cx; + double get cy; + bool get grounded; + int get facing; + bool get hasGun; + bool get dead; +} + +abstract class EntityView { + double get x; + double get y; + double get w; + double get h; + bool get active; + bool get falling; + String get dir; +} + +abstract class MoverView { + double get x; + double get y; + double get w; + double get h; + double get ax; + double get ay; + double get aw; + double get ah; + double get bx; + double get by; +} + +abstract class BulletView { + double get x; + double get y; + double get w; + double get h; + String get col; + bool get big; +} + +class DeathFX { + double x, y; + int t; + DeathFX(this.x, this.y, this.t); +} + +class DeathMark { + double x, y; + DeathMark(this.x, this.y); +} diff --git a/app/lib/src/game/world.dart b/app/lib/src/game/world.dart new file mode 100644 index 0000000..9f5c13f --- /dev/null +++ b/app/lib/src/game/world.dart @@ -0,0 +1,469 @@ +// 游戏循环 / 状态机(对应 web 版 main.js)。 +// 用固定 60Hz 时间步长驱动已移植的纯 Canvas 游戏逻辑,保证手感 1:1。 +// 渲染在 800×608 虚拟分辨率下完成,由 GameCanvas 做信箱(letterbox)缩放。 +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; +import 'package:iwbtc/src/core/input.dart'; +import 'package:iwbtc/src/core/save.dart'; +import 'package:iwbtc/src/core/util.dart'; +import 'package:iwbtc/src/audio/sfx.dart'; +import 'package:iwbtc/src/game/level.dart'; +import 'package:iwbtc/src/game/player.dart'; +import 'package:iwbtc/src/game/camera.dart'; +import 'package:iwbtc/src/game/levels.dart' show LEVELS; + +const double W = 800.0; +const double H = 608.0; +const double STEP = 1000 / 60; + +/// 编辑器试玩回调(避免 World <-> EditorController 循环依赖)。 +abstract class LevelTestRunner { + void startLevel(Map data, int idx); +} + +/// 渲染宿主回调:World 通过它把界面状态推给 Flutter 层。 +abstract class GameHost { + /// 切换可见界面:''=游戏中,'scr-title'|'scr-levels'|'scr-settings'| + /// 'scr-pause'|'scr-result'|'scr-editor'。 + void onShowScreen(String name); + void onHud(Map hud); + void onBossBar(dynamic boss); + void onToast(String text); + void onResult(Map r); + /// 暂停菜单「退出」按钮文案随来源变化。 + void onPauseMenu(String label); + /// 进入编辑器界面。 + void editorShow(); + void touchShow(); + void touchHide(); + void touchSetGun(bool has); +} + +class World implements LevelTestRunner { + final GameHost host; + + /// 游戏状态:menu | playing | paused | result | editor + /// + /// 用 ValueNotifier 承载,是因为 Flutter 侧界面(HUD 显隐)必须能监听它重建: + /// 若只存普通字段,`startLevel()` 改完状态不会触发 AppRoot 重建,HUD 永远不出现。 + /// (web 版由主循环每帧直接改 DOM,不存在这个问题。) + final ValueNotifier stateN = ValueNotifier('menu'); + + String get state => stateN.value; + set state(String v) { + if (stateN.value != v) stateN.value = v; + } + + String screen = 'scr-title'; + Level? level; + Player? player; + Camera? cam; + Map? data; + int idx = -1; + List? map; + int face = 0; + bool fromEditor = false; + int deaths = 0; + int ms = 0; + int hudTick = 0; + int time = 0; + String? lastLevelId; + bool useTouch = false; + + double _acc = 0; + String _lastToast = ''; + + World(this.host); + + // ---------------- 关卡流程 ---------------- + Map faceData(Map map, Map f, int i) { + final grid = (f['grid'] as List?)?.cast() ?? []; + return { + 'id': (map['id'] ?? 'map').toString() + '-' + i.toString(), + 'name': f['name'] ?? ('面' + (i + 1).toString()), + 'type': 'custom', + 'width': f['width'] ?? (grid.isNotEmpty ? grid[0].length : 25), + 'grid': f['grid'], + 'doors': f['doors'] ?? [], + 'bg': 'custom', + 'hint': '', + 'messages': [], + }; + } + + void startLevel(Map data, int idx) { + Sfx.instance.unlock(); + this.data = data; + this.idx = idx; + lastLevelId = data['id']?.toString(); + + final faces = data['faces']; + if (faces is List && faces.isNotEmpty) { + map = []; + for (int i = 0; i < faces.length; i++) { + final L = Level(faceData(data, faces[i] as Map, i)); + L.faceIndex = i; + map!.add(L); + } + face = ((data['startFace'] as int?) ?? 0).clamp(0, map!.length - 1); + } else { + map = [Level(data)]; + face = 0; + } + + level = map![face]; + player = Player(level!); + cam = Camera(W, H); + cam!.reset(level!, player!); + + player!.onDeath = () { + deaths++; + }; + player!.onClear = () { + finish(); + }; + player!.onPortal = (e) { + doPortal(e as Entity); + }; + player!.onRespawnFace = () { + doRespawnFace(); + }; + for (final b in level!.bosses) { + b.onDefeat = () { + finish(); + }; + } + + deaths = 0; + ms = 0; + hudTick = 0; + state = 'playing'; + host.onShowScreen(''); + if (useTouch) host.touchShow(); + host.onToast(''); + final hint = data['hint']; + if (hint is String && hint.isNotEmpty) host.onToast(hint); + } + + void placeOnDoor(Entity dest) { + final P = 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; + } + + void doPortal(Entity e) { + final to = e.to; + if (to is! Map) return; + final tp = (to['face'] as int? ?? face).clamp(0, map!.length - 1); + if (tp == face) { + final dest = level!.entities + .where((o) => o.type == 'warp' && o.doorId == to['door']) + .firstOrNull; + if (dest != null) { + placeOnDoor(dest); + dest.cool = 40; + } + Sfx.instance.warp(); + } else { + switchFace(tp, to['door']); + } + } + + void switchFace(int f, dynamic doorId) { + face = f; + level = map![f]; + level!.reset(true); + player!.level = level!; + final dest = level!.entities + .where((o) => o.type == 'warp' && o.doorId == doorId) + .firstOrNull; + if (dest != null) { + placeOnDoor(dest); + dest.cool = 40; + } else { + final sp = level!.spawn; + player!.x = sp.x; + player!.y = sp.y; + player!.vx = 0; + player!.vy = 0; + } + cam!.reset(level!, player!); + Sfx.instance.warp(); + level!.particles.spawn(player!.cx, player!.cy, 12, + colors: const ['#a86bff', '#fff'], speed: 2.5, life: 24); + } + + void doRespawnFace() { + final sp = player!.savePoint; + if (sp != null && sp.face != face) { + face = sp.face; + level = map![face]; + level!.reset(true); + player!.level = level!; + cam!.reset(level!, player!); + } + } + + void restart() { + if (data == null) return; + final d = deaths; + startLevel(data!, idx); + deaths = d; + } + + void suicide() { + final P = player; + if (P == null || state != 'playing') return; + if (P.dead) { + P.respawn(); + } else { + P.deathBurst(); + P.respawn(); + } + } + + void shoot() { + final P = player; + if (state != 'playing' || P == null || P.dead || !P.hasGun || P.shootCool != 0) return; + P.shoot(); + } + + void finish() { + if (state != 'playing') return; + state = 'result'; + host.touchHide(); + Sfx.instance.goal(); + final apples = player!.apples; + final total = level!.totalApples; + final better = Save.clear(data!['id'].toString(), deaths, ms, apples); + final hasNext = idx >= 0 && idx + 1 < LEVELS.length; + host.onResult({ + 'name': data!['name'], + 'ms': ms, + 'deaths': deaths, + 'apples': apples, + 'totalApples': total, + 'better': better, + 'hasNext': hasNext, + }); + host.onShowScreen('scr-result'); + } + + void nextLevel() { + if (idx >= 0 && idx + 1 < LEVELS.length) { + startLevel(LEVELS[idx + 1], idx + 1); + } else { + toLevels(); + } + } + + void toLevels() { + state = 'menu'; + screen = 'scr-levels'; + host.onShowScreen('scr-levels'); + host.touchHide(); + } + + void toTitle() { + state = 'menu'; + screen = 'scr-title'; + host.onShowScreen('scr-title'); + host.touchHide(); + } + + void toEditor() { + state = 'editor'; + screen = 'scr-editor'; + host.onShowScreen('scr-editor'); + host.touchHide(); + host.editorShow(); + } + + void pause() { + if (state != 'playing') return; + state = 'paused'; + // 暂停菜单末项:从编辑器试玩进来 → 「返回编辑器」;否则 → 「返回关卡选择」 + host.onPauseMenu(fromEditor ? '返回编辑器' : '返回关卡选择'); + host.onShowScreen('scr-pause'); + host.touchHide(); + } + + void resume() { + if (state == 'paused') { + state = 'playing'; + host.onShowScreen(''); + if (useTouch) host.touchShow(); + } + } + + // ---------------- 更新 ---------------- + void update() { + final P = player!; + if (Input.hit('suicide')) { + suicide(); + return; + } + if (Input.hit('restart')) { + restart(); + return; + } + if (Input.hit('pause')) { + pause(); + return; + } + + level!.update(1, P, cam!); + for (final b in level!.bosses) b.update(1, P); + P.update(1); + + if (P.justRespawned) { + if (P.savePoint != null && P.savePoint!.face != face) doRespawnFace(); + cam!.snapTo(P, level!); + P.justRespawned = false; + } + + cam!.update(level!, P, 1); + ms += STEP.round(); + time++; + + if (++hudTick % 5 == 0) { + final L = level!; + host.onHud({ + 'name': (data!['name'] ?? 'STAGE') + + (map!.length > 1 ? ' · 面${face + 1}/${map!.length}' : ''), + 'deaths': deaths, + 'ms': ms, + 'apples': P.apples, + 'totalApples': L.totalApples, + 'hasGun': P.hasGun, + 'save': P.saveIdx, + 'totalSaves': L.totalSaves, + }); + host.touchSetGun(P.hasGun); + final boss = L.bosses.where((b) => !b.dead).firstOrNull ?? L.bosses.firstOrNull; + host.onBossBar(L.bosses.isNotEmpty ? boss : null); + _pushToast(L.toast ?? ''); + } + } + + void _pushToast(String t) { + if (t != _lastToast) { + _lastToast = t; + host.onToast(t); + } + } + + // ---------------- 循环驱动(由 Ticker 调用) ---------------- + void tick(int dtMs) { + int dt = dtMs; + if (dt > 200) dt = 200; + _acc += dt; + int guard = 0; + while (_acc >= STEP && guard++ < 6) { + _acc -= STEP; + if (state == 'playing') { + update(); + } else if (state == 'paused' && Input.hit('pause')) { + resume(); + } + } + Input.endFrame(); + // toast 推送必须在 Ticker 回调阶段(transientCallbacks)完成, + // 不能放进 render() —— paint 阶段改被 ValueListenableBuilder 监听的 + // notifier 会触发 "Build scheduled during frame"(晚一帧且刷警告)。 + _pushToast(level?.toast ?? ''); + if (state != 'playing') time++; + } + + // ---------------- 渲染(800×608 虚拟空间) ---------------- + void render(Canvas c) { + final t = time; + 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')); + L.drawBg(c, C, t); + c.save(); + C.apply(c); + L.draw(c, C, t); + for (final b in L.bosses) b.draw(c, t); + P.draw(c); + c.restore(); + L.drawOverlay(c, C, t); + + // 死亡中:提示手动重生 + if (state == 'playing' && P.dead) { + final blink = sin(t / 12) > -0.3; + final tp = TextPainter( + text: TextSpan( + text: useTouch ? '按右下角重生键' : '按 R 重生', + style: TextStyle( + color: const Color(0xffff8fa3).withValues(alpha: (blink ? 1.0 : 0.3) * 255), + fontSize: 20, + fontWeight: FontWeight.bold, + fontFamily: 'monospace', + ), + ), + textAlign: TextAlign.center, + textDirection: TextDirection.ltr, + ); + tp.layout(); + tp.paint(c, Offset(W / 2 - tp.width / 2, H / 2 - 36 - tp.height / 2)); + final sub = TextPainter( + text: TextSpan( + text: 'Backspace 整关重来', + style: TextStyle( + color: const Color(0xffa8a8c0).withValues(alpha: 0.7 * 255), + fontSize: 12, + fontFamily: 'monospace', + ), + ), + textAlign: TextAlign.center, + textDirection: TextDirection.ltr, + ); + sub.layout(); + sub.paint(c, Offset(W / 2 - sub.width / 2, H / 2 - 12 - sub.height / 2)); + } + } else { + drawTitleBg(c); + } + } + + void drawTitleBg(Canvas c) { + 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); + + 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 col = i % 3 == 0 ? hex('#3a3a4c') : hex('#2a2a38'); + final p = Path() + ..moveTo(x, y + 18) + ..lineTo(x + 11, y) + ..lineTo(x + 22, y + 18) + ..close(); + c.drawPath(p, Paint()..color = col.withValues(alpha: 0.5 * 255)); + } + + c.drawRect(Rect.fromLTWH(0, H - 30, W, 30), Paint()..color = hex('#1a1a26')); + final spikePaint = Paint()..color = hex('#c8c8d8'); + for (double x = 0; x < W; x += 22) { + final p = Path() + ..moveTo(x, H - 4) + ..lineTo(x + 11, H - 26) + ..lineTo(x + 22, H - 4) + ..close(); + c.drawPath(p, spikePaint); + } + } +} diff --git a/app/lib/src/ui/editor_panel.dart b/app/lib/src/ui/editor_panel.dart new file mode 100644 index 0000000..71683fe --- /dev/null +++ b/app/lib/src/ui/editor_panel.dart @@ -0,0 +1,1153 @@ +// 自制关卡编辑器(对应 web 版 editor.js):多面地图 + 可变宽高 + 属性面板。 +import 'dart:ui' show Canvas; +import 'package:flutter/material.dart'; +import 'package:iwbtc/src/core/util.dart' show TILE; +import 'package:iwbtc/src/core/save.dart' show Save; +import 'package:iwbtc/src/game/levels.dart' show makeCustomLevel, LEVELS; +import 'package:iwbtc/src/game/sprites.dart' as S; +import 'package:iwbtc/src/game/views.dart' show PlayerView, EntityView; +import 'package:iwbtc/src/game/world.dart' show LevelTestRunner; + +const List> 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'], +]; + +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'}; + +class EditFace { + String name; + int w; + int h; + int seq; + 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}); +} + +class _Ent implements EntityView { + @override + double x, y, w, h; + @override + bool active; + @override + 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'}); +} + +class _KidView implements PlayerView { + @override + double x, y, w, h; + @override + 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}); + @override + double get cx => x + w / 2; + @override + double get cy => y + h / 2; +} + +class EditorController { + List faces = []; + int face = 0; + String sel = '#'; + int? selTx, selTy; + String? curId; + VoidCallback? onChange; + LevelTestRunner? runner; + + bool get active => faces.isNotEmpty; + + void showOrOpen() { + if (faces.isEmpty) { + open(25, 19); + } else { + onChange?.call(); + } + } + + void open(int w, int h) { + w = clampInt(w, 12, 160); + h = clampInt(h, 8, 80); + faces = [ + EditFace( + name: '面1', + w: w, + h: h, + seq: 0, + grid: blank(w, h), + props: propsBlank(w, h), + doors: >[], + ) + ]; + face = 0; + curId = null; + selTx = selTy = null; + onChange?.call(); + } + + EditFace cur() => faces[face]; + + void resize(int w, int h) { + w = clampInt(w, 12, 160); + h = clampInt(h, 8, 80); + final f = cur(); + final og = f.grid; + final op = f.props; + f.grid = blank(w, h); + f.props = propsBlank(w, h); + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + if (y < og.length && x < og[y].length) f.grid[y][x] = og[y][x]; + if (y < op.length && x < op[y].length) f.props[y][x] = op[y][x]; + } + } + f.doors = f.doors.where((d) => d['tx'] < w && d['ty'] < h).toList(); + f.w = w; + f.h = h; + selTx = selTy = null; + onChange?.call(); + } + + void fillBottom() { + final f = cur(); + for (int x = 0; x < f.w; x++) f.grid[f.h - 1][x] = '#'; + onChange?.call(); + } + + void clearFace() { + final f = cur(); + f.grid = blank(f.w, f.h); + f.props = propsBlank(f.w, f.h); + f.doors = >[]; + selTx = selTy = null; + onChange?.call(); + } + + 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: >[], + )); + face = faces.length - 1; + selTx = selTy = null; + onChange?.call(); + } + + 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(), + )); + face = faces.length - 1; + selTx = selTy = null; + onChange?.call(); + } + + void delFace() { + if (faces.length <= 1) return; + faces.removeAt(face); + face = (face - 1).clamp(0, faces.length - 1); + selTx = selTy = null; + onChange?.call(); + } + + void paintTile(int tx, int ty, bool erase) { + final f = cur(); + if (tx < 0 || ty < 0 || tx >= f.w || ty >= f.h) return; + selTx = tx; + selTy = ty; + final prev = f.grid[ty][tx]; + final ch = erase ? '.' : (sel == '.' ? '.' : sel); + if (!erase && ch != '.' && prev != '.') { + onChange?.call(); + return; + } + 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}); + } + if (prev != ch) f.props[ty][tx] = null; + f.grid[ty][tx] = ch; + onChange?.call(); + } + + void removeDoor(EditFace f, int tx, int ty) { + f.doors.removeWhere((d) => d['tx'] == tx && d['ty'] == ty); + } + + Map? propAt(int tx, int ty) { + if (tx < 0 || ty < 0 || tx >= cur().w || ty >= cur().h) return null; + return cur().props[ty][tx]; + } + + void setProp(int tx, int ty, String key, dynamic value) { + final f = cur(); + f.props[ty][tx] ??= {}; + f.props[ty][tx]![key] = value; + onChange?.call(); + } + + 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: () => {}); + if (d.isEmpty) return; + d['to'] = door >= 0 ? {'face': face, 'door': door} : null; + onChange?.call(); + } + + 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(''); + }).toList(); + final messages = []; + final turrets = >[]; + final camzones = >[]; + final bosses = >[]; + for (int y = 0; y < f.h; y++) { + 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 == 'O' && p != null) { + turrets.add({ + 'dir': p['dir'] ?? 'left', + 'period': (p['period'] ?? 90).toInt(), + 'speed': (p['speed'] ?? 2.4).toDouble(), + 'offset': (p['offset'] ?? 0).toInt(), + 'kind': p['kind'] ?? 'ball', + }); + } + if (ch == 'C' && p != null) { + camzones.add({ + 'w': (p['w'] ?? 3).toInt(), + 'h': (p['h'] ?? 3).toInt(), + 'mode': p['mode'] ?? 'lock', + 'zoom': (p['zoom'] ?? 1).toDouble(), + }); + } + if (ch == 'B' && p != null) { + bosses.add({ + 'kind': p['kind'] ?? 'guy', + 'hp': (p['hp'] ?? 40).toInt(), + }); + } + } + } + return { + 'name': f.name, + 'width': f.w, + 'height': f.h, + 'grid': grid, + 'doors': f.doors + .map((d) => { + 'id': d['id'], + 'tx': d['tx'], + 'ty': d['ty'], + 'to': d['to'], + }) + .toList(), + 'messages': messages, + 'turrets': turrets, + 'camzones': camzones, + 'bosses': bosses, + }; + }).toList(); + return { + 'name': 'MY MAP', + 'type': 'custom', + 'startFace': 0, + 'faces': facesData, + }; + } + + String? validate() { + for (int i = 0; i < faces.length; i++) { + final g = faces[i].grid; + int s = 0, x = 0; + for (final row in g) { + s += row.where((c) => c == 'S').length; + x += row.where((c) => c == 'X').length; + } + if (s != 1) return '面${i + 1}(${faces[i].name})必须且只能有 1 个 S,当前 $s 个'; + if (x < 1) return '面${i + 1}(${faces[i].name})至少要 1 个 X(终点)'; + } + for (int i = 0; i < faces.length; i++) { + for (final d in faces[i].doors) { + final to = d['to']; + if (to is Map && to['face'] == i && to['door'] == d['id']) { + return '面${i + 1} 的门${d['id']} 不能指向自己'; + } + } + } + return null; + } + + void test() { + final err = validate(); + if (err != null) { + lastError = err; + onChange?.call(); + return; + } + lastError = null; + runner?.startLevel(makeCustomLevel(buildData()), -1); + } + + String? lastError; + + void save() { + final err = validate(); + if (err != null) { + lastError = err; + onChange?.call(); + return; + } + lastError = null; + Map lv = makeCustomLevel(buildData()); + if (curId != null) lv['id'] = curId; + Save.updateCustom(lv); + curId = lv['id'] as String; + onChange?.call(); + } + + void pickImport(String id) { + final l = Save.customs().cast>().firstWhere( + (c) => c['id'] == id, + orElse: () => {}); + if (l.isEmpty) return; + curId = id; + hydrate(l); + onChange?.call(); + } + + void hydrate(Map o) { + 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 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 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), + grid: grid, + props: props, + doors: (f['doors'] as List? ?? []) + .map((d) => Map.from(d as Map)) + .toList(), + ); + int mi = 0, ti = 0, ci = 0, bi = 0; + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + final ch = grid[y][x]; + if (ch == 'T') { + final m = (f['messages'] as List? ?? [])[mi++]; + if (m != null) props[y][x] = {'msg': m}; + } else if (ch == 'O') { + final t = (f['turrets'] as List? ?? [])[ti++]; + if (t != null) props[y][x] = Map.from(t as Map); + } else if (ch == 'C') { + final c = (f['camzones'] as List? ?? [])[ci++]; + if (c != null) props[y][x] = Map.from(c as Map); + } else if (ch == 'B') { + final b = (f['bosses'] as List? ?? [])[bi++]; + if (b != null) props[y][x] = Map.from(b as Map); + } else if (SPIKE_DIR.containsKey(ch)) { + grid[y][x] = '^'; + props[y][x] = {'dir': SPIKE_DIR[ch]}; + } + } + } + return face; + }).toList(); + face = 0; + selTx = selTy = null; + } +} + +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; + +// ---------------- 面板 ---------------- +class EditorPanel extends StatefulWidget { + final EditorController controller; + final VoidCallback onBack; + const EditorPanel( + {super.key, required this.controller, required this.onBack}); + + @override + State createState() => _EditorPanelState(); +} + +class _EditorPanelState extends State { + final TextEditingController _nameCtl = TextEditingController(); + final TextEditingController _wCtl = TextEditingController(); + final TextEditingController _hCtl = TextEditingController(); + bool _importOpen = false; + + @override + void initState() { + super.initState(); + widget.controller.onChange = () => setState(() {}); + _syncFields(); + } + + void _syncFields() { + if (widget.controller.active) { + _nameCtl.text = widget.controller.cur().name; + _wCtl.text = widget.controller.cur().w.toString(); + _hCtl.text = widget.controller.cur().h.toString(); + } + } + + @override + Widget build(BuildContext context) { + final c = widget.controller; + if (!c.active) { + return _MenuBg( + child: Center( + child: ElevatedButton( + onPressed: () { + c.open(25, 19); + _syncFields(); + }, + child: const Text('新建地图'), + ), + ), + ); + } + _syncFields(); + final f = c.cur(); + return _MenuBg( + child: Column( + children: [ + 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)), + ), + ), + ), + 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(), + ), + ), + ), + _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))), + ), + const SizedBox(height: 8), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 调色板 + Container( + width: 150, + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: const Color(0xff0a0a14), + borderRadius: BorderRadius.circular(8), + ), + child: SingleChildScrollView( + child: Wrap( + spacing: 4, + runSpacing: 4, + children: PALETTE.map((p) { + final on = c.sel == p[0]; + return GestureDetector( + onTap: () => setState(() => c.sel = p[0]), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: on + ? const Color(0xff5a3a7a) + : const Color(0xff15151f), + border: Border.all( + color: on + ? const Color(0xffb06bff) + : const Color(0xff2a2a3a), + ), + borderRadius: BorderRadius.circular(4), + ), + child: Center( + child: Text(p[1], + style: const TextStyle( + color: Color(0xffffffff), fontSize: 10)), + ), + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(width: 8), + // 画布 + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SingleChildScrollView( + child: SizedBox( + width: f.w * TILE, + height: f.h * TILE, + child: _EditorCanvas(controller: c), + ), + ), + ), + ), + const SizedBox(width: 8), + // 属性 + 面 + Container( + width: 200, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('属性(选中方块)', + style: TextStyle(color: Color(0xffc8c8d8))), + const SizedBox(height: 6), + Expanded( + child: SingleChildScrollView( + child: _PropsPanel(controller: c), + ), + ), + 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( + '${e.value.name} ${e.value.w}×${e.value.h} · ${e.value.doors.length}门', + style: const TextStyle( + color: Color(0xffc8c8d8), fontSize: 11)), + ), + )), + ], + ), + ), + ], + ), + ), + if (_importOpen) + _ImportModal( + onClose: () => setState(() => _importOpen = false), + onPick: (id) { + c.pickImport(id); + setState(() => _importOpen = false); + _syncFields(); + }, + ), + ], + ), + ); + } +} + +class _EditorCanvas extends StatelessWidget { + final EditorController controller; + const _EditorCanvas({required this.controller}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onPanDown: (d) => _handle(d.localPosition, false), + onPanUpdate: (d) => _handle(d.localPosition, false), + onSecondaryTapDown: (d) => _handle(d.localPosition, true), + child: CustomPaint( + painter: _EditorPainter(controller), + size: Size( + controller.cur().w * TILE, + controller.cur().h * TILE, + ), + ), + ); + } + + void _handle(Offset local, bool right) { + final f = controller.cur(); + final tx = (local.dx / TILE).floor(); + final ty = (local.dy / TILE).floor(); + if (tx < 0 || ty < 0 || tx >= f.w || ty >= f.h) return; + controller.paintTile(tx, ty, right); + } +} + +class _EditorPainter extends CustomPainter { + final EditorController c; + _EditorPainter(this.c); + + @override + 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)); + for (int y = 0; y < f.h; y++) { + for (int x = 0; x < f.w; x++) { + final ch = f.grid[y][x]; + final px = x * TILE; + final py = y * TILE; + if (ch != '.') { + drawTileReal(canvas, ch, px, py, f.props[y][x], f); + } + final gridPaint = Paint()..color = const Color(0x10ffffff); + canvas.drawRect( + 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: () => {}); + final tp = TextPainter( + text: TextSpan( + text: 'W${d['id'] ?? ''}', + style: const TextStyle( + color: Color(0xffffffff), fontSize: 9, fontFamily: 'monospace'), + ), + textDirection: TextDirection.ltr, + ); + tp.layout(); + tp.paint(canvas, Offset(px + 2, py + 2)); + } + } + } + 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); + } + } + + @override + bool shouldRepaint(_EditorPainter old) => true; +} + +void drawTileReal(Canvas c, String ch, double px, double py, + Map? p, EditFace f) { + p ??= {}; + switch (ch) { + case '#': + S.drawBlock(c, px, py, 0); + break; + case '@': + S.drawBlock(c, px, py, 1); + break; + case '=': + S.drawBlock(c, px, py, 2); + break; + case 'M': + S.drawBlock(c, px, py, 3); + break; + case '%': + S.drawIce(c, px, py); + break; + case '~': + S.drawWater(c, px, py, 0); + break; + case '-': + S.drawOneWay(c, px, py); + break; + case 'V': + 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())]; + S.drawSpike(c, px, py, dir < 0 ? 0 : dir, 0); + break; + case '*': + S.drawKiller(c, px, py, 0); + break; + case 'D': + S.drawDropSpike(c, _Ent(x: px, y: py, w: TILE, h: TILE), 0); + break; + case 'X': + S.drawGoal(c, _Ent(x: px, y: py, w: TILE, h: TILE), 0); + break; + case 'G': + S.drawSave(c, _Ent(x: px, y: py, w: TILE, h: TILE), 0); + break; + case 'P': + S.drawGun(c, _Ent(x: px, y: py, w: TILE, h: TILE), 0); + break; + case 'F': + S.drawApple(c, _Ent(x: px, y: py, w: TILE, h: TILE), 0); + break; + case 'J': + S.drawJumpRef(c, px, py, 0); + break; + case 'W': + S.drawWarp(c, _Ent(x: px, y: py, w: TILE, h: TILE), 0); + break; + case 'T': + 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); + 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); + break; + default: + drawMarker(c, ch, px, 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 tp = TextPainter( + text: TextSpan( + text: ch, + style: const TextStyle( + 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)); +} + +class _PropsPanel extends StatelessWidget { + final EditorController controller; + const _PropsPanel({required this.controller}); + + @override + Widget build(BuildContext context) { + final c = controller; + final f = c.cur(); + if (c.selTx == null || c.selTy == null) { + 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 fields = SCHEMA[ch]; + if (fields == null || fields.isEmpty) { + if (ch == 'W') { + return _portalRow(c, f, tx, ty); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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))), + const SizedBox(height: 6), + if (ch == 'W') _portalRow(c, f, tx, ty), + for (final fd in fields) _propField(c, f, tx, ty, fd), + ], + ); + } + + Widget _portalRow(EditorController c, EditFace f, int tx, int ty) { + final door = f.doors.firstWhere( + (d) => d['tx'] == tx && d['ty'] == ty, + orElse: () => {}); + if (door.isEmpty) { + 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']}'))) + .toList(), + ], + onChanged: (v) => c.setPropFaceDoor(f, tx, ty, tf, v!), + ), + ]); + } + + 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) ?? {}; + if (type == 'text') { + final ctl = TextEditingController(text: cur[key] ?? ''); + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(fd['label'], style: const TextStyle(color: Color(0xffc8c8d8))), + TextField( + controller: ctl, + onChanged: (v) => c.setProp(tx, ty, key, v), + style: const TextStyle(color: Color(0xffffffff)), + decoration: const InputDecoration( + isCollapsed: true, + contentPadding: EdgeInsets.all(6), + border: OutlineInputBorder(), + ), + ), + ], + ), + ); + } + if (type == 'num') { + 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(), + ), + ), + ), + ]), + ); + } + 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), + ), + ]), + ); + } + return const SizedBox(); + } +} + +class _ImportModal extends StatelessWidget { + final VoidCallback onClose; + final void Function(String) onPick; + const _ImportModal({required this.onClose, required this.onPick}); + + @override + Widget build(BuildContext context) { + final customs = Save.customs().cast>(); + return Container( + color: const Color(0x99000000), + child: Center( + child: Container( + width: 320, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xff15151f), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('导入地图', + style: TextStyle(color: Color(0xffffffff), fontSize: 18)), + const SizedBox(height: 8), + 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))), + ]), + ), + )), + const SizedBox(height: 8), + _SmallButton('取消', onPressed: onClose), + ], + ), + ), + ), + ); + } +} + +class _MenuBg extends StatelessWidget { + final Widget child; + const _MenuBg({required this.child}); + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xff181828), Color(0xff08080f)], + ), + ), + child: SafeArea( + child: Padding(padding: const EdgeInsets.all(16), child: child), + ), + ); + } +} + +class _SmallButton extends StatelessWidget { + final String text; + final VoidCallback onPressed; + final bool on; + 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, + style: ElevatedButton.styleFrom( + backgroundColor: on + ? const Color(0xff5a3a7a) + : const Color(0xff1a1a26), + foregroundColor: const Color(0xffffffff), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + ), + 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 new file mode 100644 index 0000000..ee062bf --- /dev/null +++ b/app/lib/src/ui/game_canvas.dart @@ -0,0 +1,95 @@ +// 游戏画布:把 World 在 800×608 虚拟分辨率下的渲染做信箱(letterbox)缩放。 +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 GameCanvas extends StatelessWidget { + final World world; + final Listenable repaint; + const GameCanvas({super.key, required this.world, required this.repaint}); + + @override + Widget build(BuildContext context) { + // 用 SizedBox.expand 明确占满父级,避免 CustomPaint 使用 Size.infinite + // 时在部分平台上拿到零尺寸/无穷尺寸,算出 scale = 0 而整屏消失(表现为黑屏)。 + return SizedBox.expand( + child: CustomPaint( + painter: _GamePainter(world, repaint), + ), + ); + } +} + +class _GamePainter extends CustomPainter { + final World world; + _GamePainter(this.world, Listenable repaint) : super(repaint: repaint); + + static bool _loggedFirstPaint = false; + static int _renderErrors = 0; + + @override + void paint(Canvas canvas, Size size) { + // 信箱外框(黑色) + canvas.drawRect( + Rect.fromLTWH(0, 0, size.width, size.height), + Paint()..color = const Color(0xff000000)); + + // 首帧可能尚未完成布局,尺寸为 0 时不要绘制内容(letterboxRect 返回 zero)。 + final rect = letterboxRect(size); + if (rect.isEmpty) return; + + 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.drawRect( + Rect.fromLTWH(0, 0, W, H), Paint()..color = const Color(0xff0a0a14)); + // 渲染异常原本会被静默吞掉,画面变成纯黑。 + // 偶发的一帧异常不该让整局报废 —— 只记录日志;但若连续多帧都失败 + // (比如某张地图/某种渲染路径真有问题),才升级成占屏错误以便真机诊断。 + try { + world.render(canvas); + } catch (e, s) { + _renderErrors++; + if (_renderErrors <= 3 || _renderErrors == 60) { + AppError.warn('游戏渲染异常(第 $_renderErrors 次,已跳过该帧)', '$e\n$s'); + } + if (_renderErrors == 60) { + AppError.report('游戏渲染持续失败(连续 60 帧)', '$e\n$s'); + } + } finally { + canvas.restore(); + } + + // 以有效尺寸完成一次真实渲染后才记录,便于真机确认渲染管线在跑。 + 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}'); + } + } + + @override + bool shouldRepaint(_GamePainter old) => true; +} diff --git a/app/lib/src/ui/overlays.dart b/app/lib/src/ui/overlays.dart new file mode 100644 index 0000000..e2e57f3 --- /dev/null +++ b/app/lib/src/ui/overlays.dart @@ -0,0 +1,147 @@ +// HUD / BOSS 血条 / Toast 覆盖层(对应 web 版 ui.js 的 hud/bossBar/toast)。 +import 'package:flutter/widgets.dart'; +import 'package:iwbtc/src/core/util.dart' show fmtTime; +import 'package:iwbtc/src/game/boss.dart' show Boss; + +/// 顶部 HUD + BOSS 血条 + Toast。 +class HudOverlay extends StatelessWidget { + final ValueNotifier> hud; + final ValueNotifier boss; + final ValueNotifier toast; + const HudOverlay( + {super.key, required this.hud, required this.boss, required this.toast}); + + @override + Widget build(BuildContext context) { + // 注意:Positioned 必须是 Stack 的**直接子级**。 + // 此前把它写在 ValueListenableBuilder 的返回值里(隔着一层),定位数据被丢弃, + // 覆盖层会像真机黑屏那次一样横向塌缩。故此处一律 Positioned 在外、监听在内。 + return Stack( + children: [ + Positioned( + top: 8, + left: 8, + right: 8, + child: ValueListenableBuilder>( + valueListenable: hud, + builder: (_, Map h, __) { + final totalApples = h['totalApples'] ?? 0; + final totalSaves = h['totalSaves'] ?? 0; + final save = h['save'] ?? 0; + return Row( + children: [ + _box(h['name'] ?? '', flex: 1), + _box('DEATH ' + (h['deaths'] ?? 0).toString()), + _box(fmtTime(h['ms'] ?? 0)), + // 文案与 web 版 ui.js 保持一致(中文)。 + if (totalApples > 0) _box('苹果 ${h['apples'] ?? 0}/$totalApples'), + if (totalSaves > 0) _box('存档 $save/$totalSaves', on: save > 0), + ], + ); + }, + ), + ), + Positioned( + top: 40, + left: 24, + right: 24, + child: ValueListenableBuilder( + valueListenable: boss, + builder: (_, dynamic b, __) => + b == null ? const SizedBox() : _BossBar(boss: b as Boss), + ), + ), + Positioned( + top: 66, + left: 0, + right: 0, + child: ValueListenableBuilder( + valueListenable: toast, + builder: (_, String t, __) => t.isEmpty + ? const SizedBox() + : Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xb0000000), + borderRadius: BorderRadius.circular(6), + ), + child: Text(t, + style: const TextStyle( + color: Color(0xffffe9a8), + fontSize: 14, + fontFamily: 'monospace')), + ), + ), + ), + ), + ], + ); + } + + static Widget _box(String text, {double flex = 0, bool on = false}) { + final w = Expanded( + flex: flex > 0 ? 1 : 0, + child: Container( + margin: const EdgeInsets.only(right: 6), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0x99000000), + borderRadius: BorderRadius.circular(4), + border: on + ? Border.all(color: const Color(0xffb06bff)) + : null, + ), + child: Text(text, + style: const TextStyle( + color: Color(0xffd8d8e8), + fontSize: 12, + fontFamily: 'monospace')), + ), + ); + return w; + } +} + +class _BossBar extends StatelessWidget { + final Boss boss; + const _BossBar({required this.boss}); + + @override + Widget build(BuildContext context) { + final ratio = (boss.hp / boss.maxHp).clamp(0.0, 1.0); + // 只负责内容,定位由外层 Stack 的 Positioned 提供。 + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(boss.name, + style: const TextStyle( + color: Color(0xffffffff), + fontSize: 13, + fontFamily: 'monospace', + fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + Container( + height: 10, + decoration: BoxDecoration( + color: const Color(0x66000000), + borderRadius: BorderRadius.circular(5), + border: Border.all(color: const Color(0xffffffff)), + ), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: ratio, + child: Container( + decoration: BoxDecoration( + color: const Color(0xffff5c7a), + borderRadius: BorderRadius.circular(5), + ), + ), + ), + ), + ], + ); + } +} diff --git a/app/lib/src/ui/screens.dart b/app/lib/src/ui/screens.dart new file mode 100644 index 0000000..5a562d4 --- /dev/null +++ b/app/lib/src/ui/screens.dart @@ -0,0 +1,1009 @@ +// 菜单界面:标题 / 关卡选择 / 暂停 / 结算 / 设置(对应 web 版 ui.js + index.html 各 screen)。 +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/ui/touch_layout.dart' + show metaSettings, KEY_ACTIONS, keyLabel, defaultBindings; +import 'package:iwbtc/src/core/input.dart' show Input; +import 'package:iwbtc/src/game/world.dart' show World, W, H; + +const List> TABS = >[ + ['all', '全部'], + ['jump', '跳刺'], + ['endur', '耐久'], + ['boss', 'BOSS'], + ['troll', '坑向'], + ['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); +} + +// ---------------- 标题 ---------------- +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}); + + @override + Widget build(BuildContext context) { + final user = metaSettings()['username'] as String? ?? ''; + return _MenuBg( + // 横屏手机逻辑高度只有 ~360px,标题内容更高 —— 不够高时允许滚动, + // 够高时仍居中(ConstrainedBox minHeight 保底),杜绝 BOTTOM OVERFLOWED。 + child: _FitScroll( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + 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( + text: 'I WANNA BE\nTHE ', + style: TextStyle( + 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)), + ], + ), + 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( + '非商业同人作品 · 致敬 I Wanna Be The Guy / I Wanna Be The Creator', + style: TextStyle(color: Color(0xff55556a), fontSize: 11)), + ], + ), + ), + ); + } +} + +// ---------------- 关卡选择 ---------------- +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}); + + void _play(Map lv) { + world.fromEditor = false; + world.startLevel(lv, lv['builtin'] == false ? -1 : (lv['index'] ?? -1)); + } + + @override + Widget build(BuildContext context) { + return _MenuBg( + child: ValueListenableBuilder( + valueListenable: reload, + builder: (_, __, ___) => + _LevelBody(world: world, onPlay: _play, onChanged: onChanged), + ), + ); + } +} + +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}); + + @override + State<_LevelBody> createState() => _LevelBodyState(); +} + +class _LevelBodyState extends State<_LevelBody> { + String _filter = 'all'; + bool _manage = false; + + @override + Widget build(BuildContext context) { + final customs = Save.customs().cast>(); + final all = >[ + ...LEVELS, + for (int i = 0; i < customs.length; i++) + {...customs[i], 'index': 100 + i, 'no': i + 1}, + ]; + final list = all.where((l) { + final type = l['type'] as String? ?? 'custom'; + return _filter == 'all' ? true : type == _filter; + }).toList(); + + return Column( + children: [ + Row( + children: [ + const Text('关卡选择', + style: TextStyle(color: Color(0xffffffff), fontSize: 22)), + const Spacer(), + if (_filter == 'custom') + _SmallButton(_manage ? '完成' : '管理地图', on: _manage, + onPressed: () => setState(() => _manage = !_manage)), + _SmallButton('返回', onPressed: () => widget.world.toTitle()), + ], + ), + const SizedBox(height: 10), + Row( + children: TABS.map((t) { + 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; + })), + ); + }).toList(), + ), + const SizedBox(height: 10), + Expanded( + child: list.isEmpty + ? const Center( + 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(), + ), + ), + Wrap( + spacing: 6, + children: TABS.map((t) => _Tag(typeLabel(t[0]), typeClass(t[0]))).toList(), + ), + ], + ); + } +} + +class _LevelCard extends StatelessWidget { + final Map lv; + final bool manage; + final VoidCallback onPlay; + final VoidCallback onDelete; + const _LevelCard( + {required this.lv, + required this.manage, + required this.onPlay, + required this.onDelete}); + + @override + Widget build(BuildContext context) { + final rec = Save.get(lv['id'] as String? ?? ''); + final builtin = lv['builtin'] == true; + final diff = (lv['diff'] as int?) ?? 1; + return Stack( + children: [ + GestureDetector( + onTap: manage && !builtin ? null : onPlay, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xff15151f), + borderRadius: BorderRadius.circular(8), + border: Border.all( + 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), + ), + const SizedBox(height: 4), + Expanded( + 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)), + ], + ), + const SizedBox(height: 4), + Text( + rec != null + ? '✔ 死亡 ${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))), + ), + ], + ), + ), + ), + if (manage && !builtin) + Positioned( + top: 4, + right: 4, + child: GestureDetector( + onTap: onDelete, + child: Container( + width: 22, + height: 22, + decoration: const BoxDecoration( + color: Color(0xff5a2030), + shape: BoxShape.circle, + ), + child: const Center( + child: Text('✕', + style: TextStyle(color: Color(0xffffffff), fontSize: 12))), + ), + ), + ), + ], + ); + } +} + +String _fmt(int ms) { + final m = (ms / 60000).floor(); + final s = ((ms % 60000) / 1000).floor(); + final c = ((ms % 1000) / 10).floor(); + return '$m:${s.toString().padLeft(2, '0')}.${c.toString().padLeft(2, '0')}'; +} + +// ---------------- 暂停 ---------------- +class PauseScreen extends StatelessWidget { + final World world; + final String quitLabel; + const PauseScreen({super.key, required this.world, required this.quitLabel}); + + @override + Widget build(BuildContext context) { + return _Modal( + title: '暂停', + children: [ + _MenuButton('继续', onPressed: () => world.resume()), + _MenuButton('从头再来(清空存档点)', + onPressed: () => world.restart()), + _MenuButton(quitLabel, onPressed: () { + if (world.fromEditor) { + world.toEditor(); + } else { + world.toLevels(); + } + }), + ], + ); + } +} + +// ---------------- 结算 ---------------- +class ResultScreen extends StatelessWidget { + final World world; + final Map result; + const ResultScreen( + {super.key, required this.world, required this.result}); + + @override + Widget build(BuildContext context) { + final hasNext = result['hasNext'] == true; + return _Modal( + title: 'CLEAR!', + children: [ + _ResultLine('关卡', result['name'] ?? '—'), + _ResultLine('用时', _fmt(result['ms'] ?? 0)), + _ResultLine('死亡', (result['deaths'] ?? 0).toString()), + _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)), + ), + const SizedBox(height: 12), + if (hasNext) + _MenuButton('下一关', onPressed: () => world.nextLevel()), + _MenuButton('再挑战', onPressed: () => world.startLevel( + world.data!, world.idx)), + _MenuButton('关卡选择', onPressed: () => world.toLevels()), + ], + ); + } +} + +// ---------------- 设置 ---------------- +class SettingsScreen extends StatefulWidget { + final World world; + final VoidCallback onBack; + final bool isTouch; + const SettingsScreen( + {super.key, + required this.world, + required this.onBack, + required this.isTouch}); + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + late Map _work; + String? _listeningKey; // 监听中的动作 + int _listeningIdx = -1; + Map? _conflict; // {code, label, act} + + final TextEditingController _nameCtl = TextEditingController(); + final FocusNode _listenNode = FocusNode(); + final Map _touch = {}; // 手柄布局工作副本 + + @override + void initState() { + super.initState(); + final s = metaSettings(); + _work = { + '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, + }; + + String? _conflictInfo(String code, String act, int idx) { + for (final a in KEY_ACTIONS) { + final arr = (_work['keys'][a[0]] as List?) ?? []; + for (int i = 0; i < arr.length; i++) { + if (arr[i] == code && !(a[0] == act && i == idx)) return a[1]; + } + } + return null; + } + + KeyEventResult _onKeyCapture(FocusNode node, KeyEvent e) { + if (_listeningKey == null) return KeyEventResult.ignored; + if (e is KeyDownEvent) { + final code = e.logicalKey.debugName?.replaceAll(' ', '') ?? ''; + if (code == 'Escape') { + setState(() { + _listeningKey = null; + _conflict = null; + }); + return KeyEventResult.ignored; + } + final clash = _conflictInfo(code, _listeningKey!, _listeningIdx); + if (clash != null) { + setState(() => _conflict = { + 'code': code, + 'label': clash, + 'act': _listeningKey!, + }); + return KeyEventResult.ignored; + } + final arr = + (_work['keys'][_listeningKey!] as List?) ?? []; + if (_listeningIdx >= arr.length) { + arr.add(code); + } else { + arr[_listeningIdx] = code; + } + setState(() { + _listeningKey = null; + _conflict = null; + }); + } + return KeyEventResult.ignored; + } + + void _confirm() { + final s = metaSettings(); + _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; + Save.flush(); + Input.applyBindings(_work['keys'] as Map>); + widget.onBack(); + } + + @override + Widget build(BuildContext context) { + return _MenuBg( + child: Focus( + autofocus: true, + focusNode: _listenNode, + onKeyEvent: _onKeyCapture, + child: _FitScroll( + 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(), + ), + ), + ), + 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)), + ), + ...KEY_ACTIONS.map((a) => _KeyRow( + act: a[0], + label: a[1], + keys: (_work['keys'][a[0]] as List?) ?? [], + listening: _listeningKey == a[0], + onChip: (idx) => setState(() { + _listeningKey = a[0]; + _listeningIdx = idx; + _conflict = null; + }), + onRemove: (idx) => setState(() { + (_work['keys'][a[0]] as List).removeAt(idx); + }), + onAdd: () => setState(() { + _listeningKey = a[0]; + _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), + ], + // 纵向 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), + ]), + ], + ), + ), + ), + ); + } +} + +class _KeyRow extends StatelessWidget { + final String act; + final String label; + final List keys; + final bool listening; + 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}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + 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)), + ), + ), + 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( + 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 (final entry in touch.entries) + _DragBtn(entry.key, entry.value as Map, + w, h), + ], + ), + ), + ], + ); + }, + ), + ); + } +} + +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。 +/// 注意:内部不能放 Spacer/Expanded(需要有限高度),列表类屏幕请用 ListView 自滚。 +class _FitScroll extends StatelessWidget { + final Widget child; + const _FitScroll({required this.child}); + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints c) => + SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: c.maxHeight.isFinite ? c.maxHeight : 0), + child: child, + ), + ), + ); + } +} + +class _MenuBg extends StatelessWidget { + final Widget child; + const _MenuBg({required this.child}); + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xff181828), Color(0xff08080f)], + ), + ), + child: SafeArea( + child: Padding(padding: const EdgeInsets.all(20), child: child), + ), + ); + } +} + +class _MenuButton extends StatelessWidget { + final String text; + final VoidCallback onPressed; + const _MenuButton(this.text, {required this.onPressed}); + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: SizedBox( + width: 240, + child: ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xff2a2a3a), + foregroundColor: const Color(0xffffffff), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: Text(text, style: const TextStyle(fontSize: 16)), + ), + ), + ); + } +} + +class _SmallButton extends StatelessWidget { + final String text; + final VoidCallback onPressed; + final bool on; + const _SmallButton(this.text, {required this.onPressed, this.on = false}); + @override + Widget build(BuildContext context) { + return ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: + on ? const Color(0xff5a3a7a) : const Color(0xff1a1a26), + foregroundColor: const Color(0xffffffff), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + ), + child: Text(text, style: const TextStyle(fontSize: 13)), + ); + } +} + +class _Tab extends StatelessWidget { + final String text; + final bool on; + final VoidCallback onPressed; + const _Tab(this.text, this.on, {required this.onPressed}); + @override + Widget build(BuildContext context) { + return ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: + on ? const Color(0xffb06bff) : const Color(0xff1a1a26), + foregroundColor: const Color(0xffffffff), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + ), + child: Text(text, style: const TextStyle(fontSize: 12)), + ); + } +} + +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; + const _Modal({required this.title, required this.children}); + @override + Widget build(BuildContext context) { + return Container( + color: const Color(0x99000000), + child: Center( + child: Container( + width: 320, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: const Color(0xff15151f), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xff2a2a3a)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(title, + style: const TextStyle( + color: Color(0xffffffff), + fontSize: 26, + fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + ...children, + ], + ), + ), + ), + ); + } +} + +class _ResultLine extends StatelessWidget { + final String k; + final String v; + const _ResultLine(this.k, this.v); + @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)), + ]), + ); + } +} diff --git a/app/lib/src/ui/touch_controls.dart b/app/lib/src/ui/touch_controls.dart new file mode 100644 index 0000000..12684b9 --- /dev/null +++ b/app/lib/src/ui/touch_controls.dart @@ -0,0 +1,190 @@ +// 移动端虚拟手柄(对应 web 版 touch.js):方向 + 跳跃 + 射击/下蹲 + 暂停/重生。 +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/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}); + + @override + Widget build(BuildContext context) { + final touch = metaSettings()['touch'] as Map; + return ValueListenableBuilder( + valueListenable: visible, + builder: (_, bool v, __) { + if (!v) return const SizedBox(); + // 按钮锚定在「信箱缩放后的画面矩形」内,而不是整块屏幕: + // 横屏时两侧是黑边,若按全屏百分比定位,左/右/跳跃键会飘到黑边上。 + return LayoutBuilder( + builder: (_, BoxConstraints cons) { + final area = letterboxRect(Size(cons.maxWidth, cons.maxHeight)); + return Stack( + children: [ + for (final b in BUTTONS) + _TouchButton( + world: world, + keyName: b.key, + label: b.label, + label2: b.label2, + shape: b.shape, + cx: (touch[b.key]?['cx'] ?? 50).toDouble(), + cy: (touch[b.key]?['cy'] ?? 50).toDouble(), + size: (touch[b.key]?['size'] ?? 50).toDouble(), + hasGun: hasGun, + area: area, + ), + ], + ); + }, + ); + }, + ); + } +} + +class _TouchButton extends StatefulWidget { + final World world; + final String keyName; + final String label; + final String? label2; + final String shape; + final double cx; + final double cy; + final double size; + final ValueNotifier hasGun; + + /// 画面区域(信箱缩放后的矩形);按钮在其中按百分比定位。 + /// 空 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}); + + @override + State<_TouchButton> createState() => _TouchButtonState(); +} + +class _TouchButtonState extends State<_TouchButton> { + Timer? _shootTimer; + bool _pressed = false; + + @override + void dispose() { + _shootTimer?.cancel(); + super.dispose(); + } + + void _press() { + if (_pressed) return; + _pressed = true; + final a = widget.keyName; + if (a == 'shoot') { + widget.world.shoot(); + _shootTimer = Timer.periodic(const Duration(milliseconds: 140), (_) { + widget.world.shoot(); + }); + } else if (a == 'pause') { + widget.world.pause(); + } else if (a == 'suicide') { + widget.world.suicide(); + } else { + Input.setTouch(a, true); + } + setState(() {}); + } + + void _release() { + if (!_pressed) return; + _pressed = false; + _shootTimer?.cancel(); + _shootTimer = null; + final a = widget.keyName; + if (a == 'shoot' || a == 'pause' || a == 'suicide') { + // 瞬时动作,无需松开处理 + } else { + Input.setTouch(a, false); + } + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final Rect area = widget.area; + if (area.isEmpty) return const SizedBox.shrink(); + // 按钮尺寸随画面缩放等比放大:默认数值是按 800 宽的画面设计的, + // 大屏/高 DPI 下直接套用会显得过小(反之亦然);限制在合理区间避免失控。 + final double k = (area.width / W).clamp(0.6, 2.6); + final double sz = widget.size * k; + final left = area.left + widget.cx / 100 * area.width - sz / 2; + final top = area.top + widget.cy / 100 * area.height - sz / 2; + final isJump = widget.shape == 'jump'; + return Positioned( + left: left, + top: top, + width: sz, + height: sz, + child: Listener( + onPointerDown: (_) => _press(), + onPointerUp: (_) => _release(), + onPointerCancel: (_) => _release(), + child: ValueListenableBuilder( + valueListenable: widget.hasGun, + builder: (_, __, ___) { + final off = widget.keyName == 'shoot' && !widget.hasGun.value; + return Opacity( + opacity: off ? 0.3 : 1, + child: Container( + decoration: BoxDecoration( + color: _pressed + ? const Color(0x88ffffff) + : 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), + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + 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)), + ], + ), + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/app/lib/src/ui/touch_layout.dart b/app/lib/src/ui/touch_layout.dart new file mode 100644 index 0000000..1229f7f --- /dev/null +++ b/app/lib/src/ui/touch_layout.dart @@ -0,0 +1,104 @@ +// 触屏手柄按钮定义与默认布局(对应 web 版 settings.js 的 BUTTONS / DEFAULT_TOUCH)。 +// 坐标 cx/cy 为相对舞台中心的百分比,size 为按钮边长(px)。 +import 'package:iwbtc/src/core/save.dart'; + +class TouchBtn { + final String key; + final String label; + final String shape; + final String? label2; + 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 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), +]; + +const List> KEY_ACTIONS = >[ + ['left', '左移'], + ['right', '右移'], + ['up', '上(上浮)'], + ['jump', '跳跃'], + ['shoot', '射击'], + ['suicide', '自杀 / 重生'], + ['restart', '整关重来'], + ['pause', '暂停'], +]; + +Map defaultTouch() { + final m = {}; + for (final b in BUTTONS) { + m[b.key] = { + 'cx': b.cx, + 'cy': b.cy, + 'size': b.size, + }; + } + return m; +} + +Map defaultSettings() => { + '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'], + }; + +/// 读取/补全存档里的设置对象(保证 touch / keys 字段齐全)。 +Map metaSettings() { + final s = Save.settings(); + if (s['username'] is! String) s['username'] = ''; + if (s['touch'] is! Map) s['touch'] = defaultTouch(); + if (s['keys'] is! Map) s['keys'] = defaultBindings(); + return s; +} + +String keyLabel(String code) { + const Map 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.containsKey(code)) return M[code]!; + if (code.startsWith('Key')) return code.substring(3); + if (code.startsWith('Digit')) return code.substring(5); + if (code.startsWith('Numpad')) return '小键' + code.substring(6); + if (RegExp(r'^F\d{1,2}$').hasMatch(code)) return code; + return code; +} diff --git a/app/linux/.gitignore b/app/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/app/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/app/linux/CMakeLists.txt b/app/linux/CMakeLists.txt new file mode 100644 index 0000000..ccd29c8 --- /dev/null +++ b/app/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "iwbtc") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.iwbtc.iwbtc") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/app/linux/flutter/CMakeLists.txt b/app/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/app/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/app/linux/flutter/generated_plugin_registrant.cc b/app/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..1830e5c --- /dev/null +++ b/app/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); + audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); +} diff --git a/app/linux/flutter/generated_plugin_registrant.h b/app/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/app/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/app/linux/flutter/generated_plugins.cmake b/app/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..f4bf1dc --- /dev/null +++ b/app/linux/flutter/generated_plugins.cmake @@ -0,0 +1,25 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/app/linux/runner/CMakeLists.txt b/app/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/app/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/app/linux/runner/main.cc b/app/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/app/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/app/linux/runner/my_application.cc b/app/linux/runner/my_application.cc new file mode 100644 index 0000000..cb0b6ab --- /dev/null +++ b/app/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "iwbtc"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "iwbtc"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/app/linux/runner/my_application.h b/app/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/app/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/app/macos/.gitignore b/app/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/app/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/app/macos/Flutter/Flutter-Debug.xcconfig b/app/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/app/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/app/macos/Flutter/Flutter-Release.xcconfig b/app/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/app/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/app/macos/Flutter/GeneratedPluginRegistrant.swift b/app/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..5d79b0b --- /dev/null +++ b/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import audioplayers_darwin +import shared_preferences_foundation + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) +} diff --git a/app/macos/Runner.xcodeproj/project.pbxproj b/app/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..d19c8bf --- /dev/null +++ b/app/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,729 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* iwbtc.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "iwbtc.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* iwbtc.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* iwbtc.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.iwbtc.iwbtc.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/iwbtc.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/iwbtc"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.iwbtc.iwbtc.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/iwbtc.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/iwbtc"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.iwbtc.iwbtc.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/iwbtc.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/iwbtc"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..1a26f1c --- /dev/null +++ b/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/macos/Runner.xcworkspace/contents.xcworkspacedata b/app/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/app/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/app/macos/Runner/AppDelegate.swift b/app/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/app/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/app/macos/Runner/Base.lproj/MainMenu.xib b/app/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/app/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/macos/Runner/Configs/AppInfo.xcconfig b/app/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..b78c294 --- /dev/null +++ b/app/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = iwbtc + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.iwbtc.iwbtc + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.iwbtc. All rights reserved. diff --git a/app/macos/Runner/Configs/Debug.xcconfig b/app/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/app/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/app/macos/Runner/Configs/Release.xcconfig b/app/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/app/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/app/macos/Runner/Configs/Warnings.xcconfig b/app/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/app/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/app/macos/Runner/DebugProfile.entitlements b/app/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/app/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/app/macos/Runner/Info.plist b/app/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/app/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/app/macos/Runner/MainFlutterWindow.swift b/app/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/app/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/app/macos/Runner/Release.entitlements b/app/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/app/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/app/macos/RunnerTests/RunnerTests.swift b/app/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/app/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/app/pubspec.lock b/app/pubspec.lock new file mode 100644 index 0000000..0395d16 --- /dev/null +++ b/app/pubspec.lock @@ -0,0 +1,594 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.13.1" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: "2ba4bb2944baacbdd5372ff8254a8e7feb8c10d7739545e392f5605a8f618745" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.8.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: f5ff5b15620fbab8cb0849e9636c48e2b96c3f0f71723bbbe2ad3c761b205f05 + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.3.0" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "1ca553add991384ecf421b9569da850f3ab2472ffb83f6970b0416365abc51be" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.5.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: "15178b726b7cdee5364d0463c8d445630c4e0fb7d26612b73c767e7d25de9417" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.3.0" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "765f6f0e6dca55cb471c9483fc77700564b3484d19198aca4ebb5147c6c85acb" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.2.0" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: ae1e0103c865a03e273f6d13d97b93f5595eac09915729cd5e37ef96e2857319 + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.3.0" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: a70ae82bba2dfcb6eb03dd4815d737a2d46d33ea5a96a03f535cfcaac490e413 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.4.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.9" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + flame: + dependency: "direct main" + description: + name: flame + sha256: b4d7e59b81f35a426f8b39dcec10175180b9c2984b2b2e619021660d8951307c + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.38.2" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.2" + jni: + dependency: transitive + description: + name: jni + sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "0dd33d67797560f66e961a6053882d4df54bd345bf063b8fe7ea133f7ad8b191" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.4+1" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.flutter-io.cn" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.18.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.5.0" + ordered_set: + dependency: transitive + description: + name: ordered_set + sha256: ca415a0e28b8e8f5e284af4b7d1b189d2d09af6d2082532ddaec54473f67d226 + url: "https://pub.flutter-io.cn" + source: hosted + version: "8.0.1" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.1" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.6.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.6" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "593034a16e82953ca31145db6c85174286262715f9c5e6a9d8c65ffd9fcdce09" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.3" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: b3d516dbd3757ff2a458735e4f6cbac9ff261f576f6e87bcb17feabb39e657c5 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.5.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78" + url: "https://pub.flutter-io.cn" + source: hosted + version: "15.4.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.12.2 <4.0.0" + flutter: ">=3.44.0" diff --git a/app/pubspec.yaml b/app/pubspec.yaml new file mode 100644 index 0000000..60d17de --- /dev/null +++ b/app/pubspec.yaml @@ -0,0 +1,98 @@ +name: iwbtc +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.12.2 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + + # 2D game engine: game loop, game widget, input, scaling. + flame: ^1.24.0 + # Synthesized sound effects (we generate WAV buffers, no asset files). + audioplayers: ^6.4.0 + # Local persistence (replaces web localStorage): cleared records + custom levels. + shared_preferences: ^2.5.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + assets: + - assets/levels.json + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/app/test/gameplay_test.dart b/app/test/gameplay_test.dart new file mode 100644 index 0000000..0c78816 --- /dev/null +++ b/app/test/gameplay_test.dart @@ -0,0 +1,115 @@ +// 无头玩法测试:验证「输入 → World → 物理」链路真的在动(手感保真的核心)。 +// 不依赖 widget 树,直接用固定步长推进,与游戏内 world.tick 完全一致。 +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:iwbtc/src/audio/sfx.dart' show Sfx; +import 'package:iwbtc/src/core/input.dart' show Input; +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; + +class _Host implements GameHost { + String screen = ''; + String toast = ''; + bool touchShown = false; + @override + void onBossBar(dynamic boss) {} + @override + void onHud(Map hud) {} + @override + void onPauseMenu(String label) {} + @override + void onResult(Map result) {} + @override + void onShowScreen(String s) => screen = s; + @override + void onToast(String text) => toast = text; + @override + void editorShow() {} + @override + void touchHide() => touchShown = false; + @override + void touchSetGun(bool has) {} + @override + void touchShow() => touchShown = true; +} + +Future _start() async { + SharedPreferences.setMockInitialValues({}); + await Save.load(); + await loadLevels(); + Input.clear(); + Sfx.enabled = false; // 测试环境没有音频插件,保持静音(Sfx 也不会因此报错) + final world = World(_Host()); + world.startLevel(LEVELS.first, 0); + return world; +} + +/// 推进 n 个固定 60Hz 逻辑帧。 +void _step(World w, int n) { + for (int i = 0; i < n; i++) { + w.tick(17); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test('进入关卡后按方向键玩家会移动', () async { + final world = await _start(); + expect(world.state, 'playing'); + expect(world.player, isNotNull); + + final double x0 = world.player!.x; + Input.onKey('ArrowRight', true); + _step(world, 15); + Input.onKey('ArrowRight', false); + + expect(world.player!.x, greaterThan(x0), reason: '按住右键后玩家没有向右移动'); + // 水平速度与 web 版一致(HSPD = 2.6) + expect(world.player!.vx, closeTo(2.6, 0.001)); + }); + + test('跳跃初速与重力与 web 版一致(JUMP 6.6 / GRAV 0.25)', () async { + final world = await _start(); + // 先落地,避免出生即空中导致二段跳语义不同 + _step(world, 30); + final P = world.player!; + Input.onKey('ShiftLeft', true); + world.tick(17); + world.tick(17); + // 按住不放时持续受 GRAV = 0.25:-6.6 + 0.25 × 2 + expect(P.vy, closeTo(-6.6 + 0.5, 0.001), reason: '上升重力不符'); + }); + + test('松键即停:可变跳跃高度(与 web 版一致)', () async { + final world = await _start(); + _step(world, 30); + final P = world.player!; + Input.onKey('ShiftLeft', true); + world.tick(17); + // 中途松开:web 版 player.js 会把 vy 收紧到 -3,再叠加一帧重力 → -2.75 + Input.onKey('ShiftLeft', false); + world.tick(17); + expect(P.vy, closeTo(-3 + 0.25, 0.001), reason: '松键后的上升速度未按 -3 收紧'); + }); + + test('死亡后不自动重生,按 R 才回存档点', () async { + final world = await _start(); + final P = world.player!; + P.kill(); + expect(P.dead, isTrue); + final double deadX = P.x; + + Input.onKey('KeyR', false); + _step(world, 30); + expect(P.dead, isTrue, reason: '死亡后不应自动重生'); + expect(P.x, deadX, reason: '死亡后玩家应定格在死亡点'); + + Input.onKey('KeyR', true); + world.tick(17); + Input.onKey('KeyR', false); + world.tick(17); + expect(P.dead, isFalse, reason: '按 R 后应重回存档点'); + expect(world.deaths, 1); + }); +} diff --git a/app/test/title_render_test.dart b/app/test/title_render_test.dart new file mode 100644 index 0000000..556be3b --- /dev/null +++ b/app/test/title_render_test.dart @@ -0,0 +1,337 @@ +// 真机冒烟测试:在虚拟设备尺寸下真实渲染界面。 +// 目的:验证 AppRoot 完成初始化后能正常出画面,而不是停在加载态/错误界面(黑屏)。 +// 任何 build / layout / paint 阶段的异常都会被测试捕获成失败。 +// +// 关键回归点:Positioned 必须是 Stack 的**直接子级**。若把它写在 ValueListenableBuilder +// 的返回值里(隔着一层),定位数据被丢弃、子树横向塌缩为 0 宽 —— 这正是真机黑屏的根因。 +// 因此这里断言的是「可见尺寸」,而不是「widget 存在」(find 不检查可见性,测不出塌缩)。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +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/ui/overlays.dart' show HudOverlay; +import 'package:iwbtc/src/ui/screens.dart' show SettingsScreen; +import 'package:iwbtc/src/ui/touch_controls.dart' show TouchControls; + +/// 按典型**横屏手机**分辨率设定虚拟窗口(安卓端已锁定横屏)。 +const Size kLandscapePhone = Size(2340, 1080); + +/// 完成 AppRoot 初始化并出到稳定帧。 +Future _boot(WidgetTester tester) async { + await tester.binding.setSurfaceSize(kLandscapePhone); + await tester.runAsync(() async { + await Save.load(); + await loadLevels(); + }); + await tester.pumpWidget(const AppRoot()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(); + expect(AppError.current.value, isNull, reason: '初始化/渲染过程中抛出了异常'); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + AppError.current.value = null; + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets('标题界面能真实渲染出来(非黑屏)', (WidgetTester tester) async { + await _boot(tester); + + expect(find.textContaining('启动失败'), findsNothing); + // 不得仍停留在加载态 + expect(find.text('正在加载关卡…'), findsNothing, reason: '一直停在加载态,等同于黑屏'); + + // 标题菜单必须存在 + expect(find.text('关卡选择'), findsOneWidget); + expect(find.text('自制关卡'), findsOneWidget); + expect(find.text('设置'), findsOneWidget); + expect(find.textContaining('23 个内置关卡'), findsOneWidget); + + // 关键回归:按钮必须有可见尺寸且在屏内 + 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.height, greaterThan(0)); + expect(btnPos.dx, greaterThanOrEqualTo(0), reason: '按钮被定位到屏外'); + expect(btnPos.dy, greaterThanOrEqualTo(0)); + }); + + testWidgets('横屏小逻辑高度下标题不溢出(真机 BOTTOM OVERFLOWED 回归)', + (WidgetTester tester) async { + // 真机横屏 1080 物理高 ÷ DPR ≈ 360~400 **逻辑**像素;此前测试用的 + // 2340×1080 是逻辑尺寸、远高于真机,所以测不出溢出。 + await tester.binding.setSurfaceSize(const Size(800, 360)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + SharedPreferences.setMockInitialValues({}); + await tester.runAsync(() async { + await Save.load(); + await loadLevels(); + }); + + // 布局溢出(RenderFlex overflow)在测试里会作为异常抛出并使测试失败。 + await tester.pumpWidget(const AppRoot()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(); + + expect(AppError.current.value, isNull, reason: '小高度下渲染抛出了异常'); + expect(find.text('关卡选择'), findsOneWidget); + expect(tester.getSize(find.text('关卡选择')).height, greaterThan(0)); + + // 一屏放不下的底部文案必须滚动可达 + final footer = find.textContaining('非商业同人作品'); + await tester.scrollUntilVisible(footer, 120); + expect(footer, findsOneWidget); + }); + + testWidgets('游戏画布拿到真实尺寸并按信箱缩放', (WidgetTester tester) async { + await _boot(tester); + + final canvas = find.byType(GameCanvas); + expect(canvas, findsOneWidget); + final Size size = tester.getSize(canvas); + // 必须是全屏尺寸,而不是 0(之前整棵 Stack 布局塌缩时画布也是 0×0) + 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: '横屏下画面高度应被占满'); + }); + + testWidgets('HUD / Toast 有可见尺寸(不得横向塌缩)', (WidgetTester tester) async { + await tester.binding.setSurfaceSize(const Size(1080, 2340)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final hud = ValueNotifier>({ + 'name': '测试关卡', + 'deaths': 3, + 'ms': 12345, + 'apples': 1, + 'totalApples': 3, + 'save': 0, + 'totalSaves': 4, + }); + final boss = ValueNotifier(null); + final toast = ValueNotifier('测试提示'); + addTearDown(() { + hud.dispose(); + boss.dispose(); + toast.dispose(); + }); + + 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.height, greaterThan(0)); + + // Toast 同样必须可见 + final tip = find.text('测试提示'); + expect(tip, findsOneWidget); + expect(tester.getSize(tip).width, greaterThan(0)); + + // 苹果 / 存档 文案与 web 版保持一致 + expect(find.text('苹果 1/3'), findsOneWidget); + expect(find.text('存档 0/4'), findsOneWidget); + }); + + testWidgets('从标题点进关卡能真正开始游戏(端到端)', + (WidgetTester tester) async { + await _boot(tester); + + // 进入关卡选择(注意:游戏 Ticker 常驻,不能用 pumpAndSettle) + await tester.tap(find.text('关卡选择')); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + // 点第一张关卡卡片 → 应进入 playing 态并显示 HUD + final card = find.text(LEVELS.first['name'] as String); + expect(card, findsWidgets); + await tester.tap(card.first); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(); + + expect(AppError.current.value, isNull, reason: '进入关卡的过程中抛出了异常'); + // 画布仍在正常出帧 + expect(tester.getSize(find.byType(GameCanvas)).width, greaterThan(0)); + // HUD 的关卡名已显示且有可见宽度 + final hudName = find.text(LEVELS.first['name'] as String); + expect(hudName, findsWidgets); + }); + + testWidgets('横屏下虚拟手柄按钮落在画面区域内(不飘到黑边上)', + (WidgetTester tester) async { + 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(); + }); + + // 需要一个 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.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)); + + // 「跳」键同理,且必须完整在屏内 + 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 + js.width, lessThanOrEqualTo(screen.width)); + expect(js.width, greaterThan(0)); + }); + + testWidgets('设置界面能渲染,手柄预览可见', (WidgetTester tester) async { + await _boot(tester); + + await tester.tap(find.text('设置')); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + 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); + }); + + testWidgets('桌面(非触屏)设置界面显示键位重绑', (WidgetTester tester) async { + await tester.binding.setSurfaceSize(const Size(1280, 720)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + SharedPreferences.setMockInitialValues({}); + await tester.runAsync(() async { + await Save.load(); + await loadLevels(); + }); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SettingsScreen( + world: World(_Host()), + isTouch: false, + onBack: () {}, + ), + ), + )); + await tester.pump(); + + expect(AppError.current.value, isNull, reason: '设置界面构建时抛出了异常'); + // 键位行(web 版 settings.js 的 KEY_ACTIONS) + expect(find.text('键盘按键'), findsOneWidget); + expect(find.text('左移'), findsOneWidget); + expect(find.text('跳跃'), findsOneWidget); + expect(find.text('自杀 / 重生'), findsOneWidget); + expect(find.text('整关重来'), findsOneWidget); + // 已绑定键位会以标签形式出现 + expect(find.text('←'), findsWidgets); + expect(find.text('Shift'), findsWidgets); + }); + + testWidgets('内置关卡清单能从 assets 正确加载', (WidgetTester tester) async { + await tester.runAsync(() async { + await Save.load(); + await loadLevels(); + }); + expect(LEVELS.isNotEmpty, isTrue, reason: 'levels.json 未加载到任何关卡'); + expect(LEVELS.length, 23); + }); +} + +/// 仅供测试用的空 GameHost 实现。 +class _Host implements GameHost { + @override + void onBossBar(dynamic boss) {} + @override + void onHud(Map hud) {} + @override + void onPauseMenu(String label) {} + @override + void onResult(Map result) {} + @override + void onShowScreen(String screen) {} + @override + void onToast(String text) {} + @override + void editorShow() {} + @override + void touchHide() {} + @override + void touchSetGun(bool has) {} + @override + void touchShow() {} +} diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart new file mode 100644 index 0000000..1090cff --- /dev/null +++ b/app/test/widget_test.dart @@ -0,0 +1,12 @@ +// Smoke test for the IWBT-C Flutter port. The default counter-app test was +// removed because the app root is `AppRoot` (see lib/src/app_root.dart), not the +// boilerplate `MyApp`. +import 'package:flutter_test/flutter_test.dart'; +import 'package:iwbtc/src/app_root.dart' show AppRoot; + +void main() { + testWidgets('AppRoot instantiates', (WidgetTester tester) async { + // Smoke check: the game root widget constructs without throwing. + expect(const AppRoot(), isA()); + }); +} diff --git a/app/web/favicon.png b/app/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/app/web/favicon.png differ diff --git a/app/web/icons/Icon-192.png b/app/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/app/web/icons/Icon-192.png differ diff --git a/app/web/icons/Icon-512.png b/app/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/app/web/icons/Icon-512.png differ diff --git a/app/web/icons/Icon-maskable-192.png b/app/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/app/web/icons/Icon-maskable-192.png differ diff --git a/app/web/icons/Icon-maskable-512.png b/app/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/app/web/icons/Icon-maskable-512.png differ diff --git a/app/web/index.html b/app/web/index.html new file mode 100644 index 0000000..2fe1ca6 --- /dev/null +++ b/app/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + iwbtc + + + + + + + diff --git a/app/web/manifest.json b/app/web/manifest.json new file mode 100644 index 0000000..7096c79 --- /dev/null +++ b/app/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "iwbtc", + "short_name": "iwbtc", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/app/windows/.gitignore b/app/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/app/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/app/windows/CMakeLists.txt b/app/windows/CMakeLists.txt new file mode 100644 index 0000000..ea798bc --- /dev/null +++ b/app/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(iwbtc LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "iwbtc") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/app/windows/flutter/CMakeLists.txt b/app/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/app/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/app/windows/flutter/generated_plugin_registrant.cc b/app/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..09e8e2c --- /dev/null +++ b/app/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + AudioplayersWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); +} diff --git a/app/windows/flutter/generated_plugin_registrant.h b/app/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/app/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/app/windows/flutter/generated_plugins.cmake b/app/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..53f4ef8 --- /dev/null +++ b/app/windows/flutter/generated_plugins.cmake @@ -0,0 +1,25 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/app/windows/runner/CMakeLists.txt b/app/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/app/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/app/windows/runner/Runner.rc b/app/windows/runner/Runner.rc new file mode 100644 index 0000000..1784128 --- /dev/null +++ b/app/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.iwbtc" "\0" + VALUE "FileDescription", "iwbtc" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "iwbtc" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.iwbtc. All rights reserved." "\0" + VALUE "OriginalFilename", "iwbtc.exe" "\0" + VALUE "ProductName", "iwbtc" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/app/windows/runner/flutter_window.cpp b/app/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/app/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/app/windows/runner/flutter_window.h b/app/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/app/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/app/windows/runner/main.cpp b/app/windows/runner/main.cpp new file mode 100644 index 0000000..d3485ac --- /dev/null +++ b/app/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"iwbtc", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/app/windows/runner/resource.h b/app/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/app/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/app/windows/runner/resources/app_icon.ico b/app/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/app/windows/runner/resources/app_icon.ico differ diff --git a/app/windows/runner/runner.exe.manifest b/app/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/app/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/app/windows/runner/utils.cpp b/app/windows/runner/utils.cpp new file mode 100644 index 0000000..3cb7146 --- /dev/null +++ b/app/windows/runner/utils.cpp @@ -0,0 +1,69 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + // First, find the length of the string with a safe upper bound (CWE-126). + // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. + int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); + // Now use that bounded length to determine the required buffer size. + // When an explicit length is passed, WideCharToMultiByte does not include + // the null terminator in its returned size. + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/app/windows/runner/utils.h b/app/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/app/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/app/windows/runner/win32_window.cpp b/app/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/app/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/app/windows/runner/win32_window.h b/app/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/app/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/css/style.css b/tmp/css/style.css similarity index 100% rename from css/style.css rename to tmp/css/style.css diff --git a/tmp/dump_levels.mjs b/tmp/dump_levels.mjs new file mode 100644 index 0000000..9757c5f --- /dev/null +++ b/tmp/dump_levels.mjs @@ -0,0 +1,25 @@ +// 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/index.html b/tmp/index.html similarity index 100% rename from index.html rename to tmp/index.html diff --git a/js/audio.js b/tmp/js/audio.js similarity index 100% rename from js/audio.js rename to tmp/js/audio.js diff --git a/js/boss.js b/tmp/js/boss.js similarity index 100% rename from js/boss.js rename to tmp/js/boss.js diff --git a/js/camera.js b/tmp/js/camera.js similarity index 100% rename from js/camera.js rename to tmp/js/camera.js diff --git a/js/editor.js b/tmp/js/editor.js similarity index 100% rename from js/editor.js rename to tmp/js/editor.js diff --git a/js/input.js b/tmp/js/input.js similarity index 100% rename from js/input.js rename to tmp/js/input.js diff --git a/js/level.js b/tmp/js/level.js similarity index 100% rename from js/level.js rename to tmp/js/level.js diff --git a/js/levels/boss.js b/tmp/js/levels/boss.js similarity index 100% rename from js/levels/boss.js rename to tmp/js/levels/boss.js diff --git a/js/levels/endurance.js b/tmp/js/levels/endurance.js similarity index 100% rename from js/levels/endurance.js rename to tmp/js/levels/endurance.js diff --git a/js/levels/index.js b/tmp/js/levels/index.js similarity index 100% rename from js/levels/index.js rename to tmp/js/levels/index.js diff --git a/js/levels/jump.js b/tmp/js/levels/jump.js similarity index 100% rename from js/levels/jump.js rename to tmp/js/levels/jump.js diff --git a/js/levels/kit.js b/tmp/js/levels/kit.js similarity index 100% rename from js/levels/kit.js rename to tmp/js/levels/kit.js diff --git a/js/levels/needle.js b/tmp/js/levels/needle.js similarity index 100% rename from js/levels/needle.js rename to tmp/js/levels/needle.js diff --git a/js/levels/troll.js b/tmp/js/levels/troll.js similarity index 100% rename from js/levels/troll.js rename to tmp/js/levels/troll.js diff --git a/js/levels/tutorial.js b/tmp/js/levels/tutorial.js similarity index 100% rename from js/levels/tutorial.js rename to tmp/js/levels/tutorial.js diff --git a/js/main.js b/tmp/js/main.js similarity index 100% rename from js/main.js rename to tmp/js/main.js diff --git a/js/particles.js b/tmp/js/particles.js similarity index 100% rename from js/particles.js rename to tmp/js/particles.js diff --git a/js/player.js b/tmp/js/player.js similarity index 100% rename from js/player.js rename to tmp/js/player.js diff --git a/js/save.js b/tmp/js/save.js similarity index 100% rename from js/save.js rename to tmp/js/save.js diff --git a/js/settings.js b/tmp/js/settings.js similarity index 100% rename from js/settings.js rename to tmp/js/settings.js diff --git a/js/sprites.js b/tmp/js/sprites.js similarity index 100% rename from js/sprites.js rename to tmp/js/sprites.js diff --git a/js/touch.js b/tmp/js/touch.js similarity index 100% rename from js/touch.js rename to tmp/js/touch.js diff --git a/js/ui.js b/tmp/js/ui.js similarity index 100% rename from js/ui.js rename to tmp/js/ui.js diff --git a/js/util.js b/tmp/js/util.js similarity index 100% rename from js/util.js rename to tmp/js/util.js diff --git a/tmp/levels_dump.json b/tmp/levels_dump.json new file mode 100644 index 0000000..88c864f --- /dev/null +++ b/tmp/levels_dump.json @@ -0,0 +1 @@ +{"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/mapview.html b/tmp/mapview.html similarity index 100% rename from mapview.html rename to tmp/mapview.html diff --git a/tmp/package.json b/tmp/package.json new file mode 100644 index 0000000..8898b26 --- /dev/null +++ b/tmp/package.json @@ -0,0 +1 @@ +{ "type": "module", "private": true } diff --git a/spike-preview.html b/tmp/spike-preview.html similarity index 100% rename from spike-preview.html rename to tmp/spike-preview.html diff --git a/tools/bot.mjs b/tmp/tools/bot.mjs similarity index 100% rename from tools/bot.mjs rename to tmp/tools/bot.mjs diff --git a/tools/check.mjs b/tmp/tools/check.mjs similarity index 100% rename from tools/check.mjs rename to tmp/tools/check.mjs diff --git a/tools/death-test.mjs b/tmp/tools/death-test.mjs similarity index 100% rename from tools/death-test.mjs rename to tmp/tools/death-test.mjs diff --git a/tools/dj-test.mjs b/tmp/tools/dj-test.mjs similarity index 100% rename from tools/dj-test.mjs rename to tmp/tools/dj-test.mjs diff --git a/tools/dom-smoke.mjs b/tmp/tools/dom-smoke.mjs similarity index 100% rename from tools/dom-smoke.mjs rename to tmp/tools/dom-smoke.mjs diff --git a/tools/editor-pick-test.mjs b/tmp/tools/editor-pick-test.mjs similarity index 100% rename from tools/editor-pick-test.mjs rename to tmp/tools/editor-pick-test.mjs diff --git a/tools/editor-wysiwyg-test.mjs b/tmp/tools/editor-wysiwyg-test.mjs similarity index 100% rename from tools/editor-wysiwyg-test.mjs rename to tmp/tools/editor-wysiwyg-test.mjs diff --git a/tools/ice-test.mjs b/tmp/tools/ice-test.mjs similarity index 100% rename from tools/ice-test.mjs rename to tmp/tools/ice-test.mjs diff --git a/tools/jumpdist.mjs b/tmp/tools/jumpdist.mjs similarity index 100% rename from tools/jumpdist.mjs rename to tmp/tools/jumpdist.mjs diff --git a/tools/key-test.mjs b/tmp/tools/key-test.mjs similarity index 100% rename from tools/key-test.mjs rename to tmp/tools/key-test.mjs diff --git a/tools/lib-reach.mjs b/tmp/tools/lib-reach.mjs similarity index 100% rename from tools/lib-reach.mjs rename to tmp/tools/lib-reach.mjs diff --git a/tools/map-test.mjs b/tmp/tools/map-test.mjs similarity index 100% rename from tools/map-test.mjs rename to tmp/tools/map-test.mjs diff --git a/tools/reach-diag.mjs b/tmp/tools/reach-diag.mjs similarity index 100% rename from tools/reach-diag.mjs rename to tmp/tools/reach-diag.mjs diff --git a/tools/save-diag.mjs b/tmp/tools/save-diag.mjs similarity index 100% rename from tools/save-diag.mjs rename to tmp/tools/save-diag.mjs diff --git a/tools/save-test.mjs b/tmp/tools/save-test.mjs similarity index 100% rename from tools/save-test.mjs rename to tmp/tools/save-test.mjs diff --git a/tools/settings-test.mjs b/tmp/tools/settings-test.mjs similarity index 100% rename from tools/settings-test.mjs rename to tmp/tools/settings-test.mjs diff --git a/tools/smoke.mjs b/tmp/tools/smoke.mjs similarity index 100% rename from tools/smoke.mjs rename to tmp/tools/smoke.mjs diff --git a/tools/solve.mjs b/tmp/tools/solve.mjs similarity index 100% rename from tools/solve.mjs rename to tmp/tools/solve.mjs diff --git a/tools/spike-test.mjs b/tmp/tools/spike-test.mjs similarity index 100% rename from tools/spike-test.mjs rename to tmp/tools/spike-test.mjs