refactor: 使用flutter对原先的web项目重构,正在进行中
No files matched your search
@@ -104,3 +104,77 @@
|
|||||||
- 测试:death-test 场景 8/9 重写(当帧重生、纯演出不进死亡态、粒子/环/震屏全程自然播完断言),key-test 活着按 R 段重写;全套回归过。
|
- 测试: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 必须原样带回签名与后续语句。
|
- 补充(用户六次反馈):「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 段加"残余粒子"断言;全套回归过。
|
- 测试: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<int>` 避免越权调 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` 的 `<application>` 下加 `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<String> stateN` 的 getter/setter(`menu|playing|paused|result|editor`),HUD 用 `ValueListenableBuilder<String>(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` 的 `<activity>` 加 `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 触屏检测仍可拓宽。
|
||||||
@@ -60,3 +60,14 @@
|
|||||||
- 仅在 `playing` 态显示(`startLevel`/`resume` 显示,菜单/暂停/结算隐藏);HUD 每帧 `Touch.setGun(P.hasGun)` 控制射击键置灰。
|
- 仅在 `playing` 态显示(`startLevel`/`resume` 显示,菜单/暂停/结算隐藏);HUD 每帧 `Touch.setGun(P.hasGun)` 控制射击键置灰。
|
||||||
- 若改键位含义,记得同步 `js/input.js` 的 `BIND` 与 `touch.js` 的 `HELD`/`api` 映射。
|
- 若改键位含义,记得同步 `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<String> 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/死亡重生断言)。
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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'
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 = "../.."
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application
|
||||||
|
android:label="iwbtc"
|
||||||
|
android:name="${applicationName}"
|
||||||
|
android:icon="@mipmap/ic_launcher">
|
||||||
|
<!-- 屏幕方向:游戏画面是 800×608 的横向舞台,竖屏下信箱缩放后画面极小、
|
||||||
|
虚拟手柄也会挤在一起,故安卓端锁定横屏。
|
||||||
|
sensorLandscape:左横/右横都允许,跟随重力(只保留一个方向就写 landscape)。
|
||||||
|
Dart 侧 SystemChrome 也会再锁一次,两者共同保证冷启动首帧即为横屏。 -->
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:screenOrientation="sensorLandscape"
|
||||||
|
android:resizeableActivity="false"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
|
the Android process has started. This theme is visible to the user
|
||||||
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
|
to determine the Window background behind the Flutter UI. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
|
android:resource="@style/NormalTheme"
|
||||||
|
/>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<!-- Don't delete the meta-data below.
|
||||||
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
</application>
|
||||||
|
<!-- Required to query activities that can process text, see:
|
||||||
|
https://developer.android.com/training/package-visibility and
|
||||||
|
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||||
|
|
||||||
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
|
<data android:mimeType="text/plain"/>
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.iwbtc.iwbtc
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="?android:colorBackground" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@android:color/white" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
@@ -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<Delete>("clean") {
|
||||||
|
delete(rootProject.layout.buildDirectory)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>App</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>io.flutter.flutter.app</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>App</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>FMWK</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleSignature</key>
|
||||||
|
<string>????</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
@@ -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 = "<group>"; };
|
||||||
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||||
|
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
||||||
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||||
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
|
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
||||||
|
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
|
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
/* 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 = "<group>";
|
||||||
|
};
|
||||||
|
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||||
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||||
|
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||||
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||||
|
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
||||||
|
);
|
||||||
|
name = Flutter;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
97C146E51CF9000F007C117D = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
9740EEB11CF90186004384FC /* Flutter */,
|
||||||
|
97C146F01CF9000F007C117D /* Runner */,
|
||||||
|
97C146EF1CF9000F007C117D /* Products */,
|
||||||
|
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
97C146EF1CF9000F007C117D /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||||
|
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
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 = "<group>";
|
||||||
|
};
|
||||||
|
/* 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 = "<group>";
|
||||||
|
};
|
||||||
|
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
|
||||||
|
isa = PBXVariantGroup;
|
||||||
|
children = (
|
||||||
|
97C147001CF9000F007C117D /* Base */,
|
||||||
|
);
|
||||||
|
name = LaunchScreen.storyboard;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* 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 */;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>IDEDidComputeMac32BitWarning</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>PreviewsEnabled</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1510"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<PreActions>
|
||||||
|
<ExecutionAction
|
||||||
|
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||||
|
<ActionContent
|
||||||
|
title = "Run Prepare Flutter Framework Script"
|
||||||
|
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||||
|
<EnvironmentBuildable>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</EnvironmentBuildable>
|
||||||
|
</ActionContent>
|
||||||
|
</ExecutionAction>
|
||||||
|
</PreActions>
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "331C8080294A63A400263BE5"
|
||||||
|
BuildableName = "RunnerTests.xctest"
|
||||||
|
BlueprintName = "RunnerTests"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
enableGPUValidationMode = "1"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Profile"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "group:Runner.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>IDEDidComputeMac32BitWarning</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>PreviewsEnabled</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
@@ -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.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||||
|
<dependencies>
|
||||||
|
<deployment identifier="iOS"/>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
|
||||||
|
</dependencies>
|
||||||
|
<scenes>
|
||||||
|
<!--View Controller-->
|
||||||
|
<scene sceneID="EHf-IW-A2E">
|
||||||
|
<objects>
|
||||||
|
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||||
|
<layoutGuides>
|
||||||
|
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
|
||||||
|
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
|
||||||
|
</layoutGuides>
|
||||||
|
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<subviews>
|
||||||
|
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||||
|
</imageView>
|
||||||
|
</subviews>
|
||||||
|
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||||
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||||
|
</constraints>
|
||||||
|
</view>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
<point key="canvasLocation" x="53" y="375"/>
|
||||||
|
</scene>
|
||||||
|
</scenes>
|
||||||
|
<resources>
|
||||||
|
<image name="LaunchImage" width="168" height="185"/>
|
||||||
|
</resources>
|
||||||
|
</document>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
||||||
|
<dependencies>
|
||||||
|
<deployment identifier="iOS"/>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
||||||
|
</dependencies>
|
||||||
|
<scenes>
|
||||||
|
<!--Flutter View Controller-->
|
||||||
|
<scene sceneID="tne-QT-ifu">
|
||||||
|
<objects>
|
||||||
|
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
|
||||||
|
<layoutGuides>
|
||||||
|
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
|
||||||
|
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||||
|
</layoutGuides>
|
||||||
|
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
||||||
|
</view>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
</scene>
|
||||||
|
</scenes>
|
||||||
|
</document>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
|
<true/>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Iwbtc</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>iwbtc</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||||
|
<key>CFBundleSignature</key>
|
||||||
|
<string>????</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<false/>
|
||||||
|
<key>UISceneConfigurations</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIWindowSceneSessionRoleApplication</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UISceneClassName</key>
|
||||||
|
<string>UIWindowScene</string>
|
||||||
|
<key>UISceneConfigurationName</key>
|
||||||
|
<string>flutter</string>
|
||||||
|
<key>UISceneDelegateClassName</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||||
|
<key>UISceneStoryboardFile</key>
|
||||||
|
<string>Main</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
|
<true/>
|
||||||
|
<key>UILaunchStoryboardName</key>
|
||||||
|
<string>LaunchScreen</string>
|
||||||
|
<key>UIMainStoryboardFile</key>
|
||||||
|
<string>Main</string>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
#import "GeneratedPluginRegistrant.h"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import Flutter
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class SceneDelegate: FlutterSceneDelegate {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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<void> 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<void> _configureMobile() async {
|
||||||
|
final t = defaultTargetPlatform;
|
||||||
|
if (t != TargetPlatform.android && t != TargetPlatform.iOS) return;
|
||||||
|
try {
|
||||||
|
await SystemChrome.setPreferredOrientations(<DeviceOrientation>[
|
||||||
|
DeviceOrientation.landscapeLeft,
|
||||||
|
DeviceOrientation.landscapeRight,
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
// 某些设备/系统策略不允许锁定方向,属可容忍情况,不影响游戏运行。
|
||||||
|
AppError.warn('锁定横屏失败(保持系统默认方向)', '$e');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AppRoot> createState() => _AppRootState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AppRootState extends State<AppRoot>
|
||||||
|
with SingleTickerProviderStateMixin
|
||||||
|
implements GameHost {
|
||||||
|
late World world;
|
||||||
|
late EditorController editor;
|
||||||
|
bool _ready = false;
|
||||||
|
bool _worldOk = false;
|
||||||
|
bool _useTouch = false;
|
||||||
|
bool _audioUnlocked = false;
|
||||||
|
|
||||||
|
final ValueNotifier<String> screen = ValueNotifier<String>('scr-title');
|
||||||
|
final ValueNotifier<Map<String, dynamic>> hud =
|
||||||
|
ValueNotifier<Map<String, dynamic>>(<String, dynamic>{});
|
||||||
|
final ValueNotifier<dynamic> boss = ValueNotifier<dynamic>(null);
|
||||||
|
final ValueNotifier<String> toast = ValueNotifier<String>('');
|
||||||
|
final ValueNotifier<Map<String, dynamic>> result =
|
||||||
|
ValueNotifier<Map<String, dynamic>>(<String, dynamic>{});
|
||||||
|
final ValueNotifier<bool> touchVisible = ValueNotifier<bool>(false);
|
||||||
|
final ValueNotifier<bool> touchGun = ValueNotifier<bool>(false);
|
||||||
|
final ValueNotifier<String> pauseQuit = ValueNotifier<String>('返回关卡选择');
|
||||||
|
final ValueNotifier<int> levelsReload = ValueNotifier<int>(0);
|
||||||
|
final ValueNotifier<int> frame = ValueNotifier<int>(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<void> _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<String, dynamic> h) => hud.value = h;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onBossBar(dynamic b) => boss.value = b;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onToast(String t) => toast.value = t;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onResult(Map<String, dynamic> 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<String?>(
|
||||||
|
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: <Widget>[
|
||||||
|
Positioned.fill(
|
||||||
|
child: GameCanvas(world: world, repaint: frame),
|
||||||
|
),
|
||||||
|
// 注意:Positioned 必须是 Stack 的直接子级。此前把 Positioned 放在
|
||||||
|
// ValueListenableBuilder 的返回值里(隔着一层),定位数据被丢弃,
|
||||||
|
// 导致整棵标题树横向塌缩为 0 宽 —— 界面上只剩近黑的画布,即"黑屏"。
|
||||||
|
Positioned.fill(
|
||||||
|
child: ValueListenableBuilder<String>(
|
||||||
|
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<String>(
|
||||||
|
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<String>(
|
||||||
|
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 <Widget>[
|
||||||
|
CircularProgressIndicator(color: Color(0xffff5c7a)),
|
||||||
|
SizedBox(height: 18),
|
||||||
|
Text(
|
||||||
|
'正在加载关卡…',
|
||||||
|
style: TextStyle(color: Color(0xffc8c8d8), fontSize: 14),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AudioPlayer> _pool = [];
|
||||||
|
final Map<String, Uint8List> _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');
|
||||||
|
}
|
||||||
@@ -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<String?> current = ValueNotifier<String?>(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: <Widget>[
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, List<String>> 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<String> actions =
|
||||||
|
bind.keys.toList(); // ['left','right',...]
|
||||||
|
|
||||||
|
static final Map<String, List<String>> _defaultBind = {
|
||||||
|
for (final e in bind.entries) e.key: List<String>.from(e.value),
|
||||||
|
};
|
||||||
|
|
||||||
|
static final Set<String> down = {}; // 当前按住的原始 code
|
||||||
|
static final Map<String, bool> now = {for (final a in bind.keys) a: false};
|
||||||
|
static final Map<String, bool> prev = {for (final a in bind.keys) a: false};
|
||||||
|
static final Map<String, bool> 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<String, List<String>> defaultBindings() =>
|
||||||
|
{for (final e in bind.entries) e.key: List<String>.from(e.value)};
|
||||||
|
|
||||||
|
static Map<String, List<String>> snapshotBindings() =>
|
||||||
|
{for (final e in bind.entries) e.key: List<String>.from(e.value)};
|
||||||
|
|
||||||
|
static void applyBindings(Map<String, List<String>>? map) {
|
||||||
|
if (map == null) return;
|
||||||
|
for (final act in map.keys) {
|
||||||
|
if (bind.containsKey(act)) bind[act] = List<String>.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<String, dynamic>();
|
||||||
|
final decoded = m.map((k, v) => MapEntry(k, List<String>.from(v as List)));
|
||||||
|
applyBindings(decoded);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> data = _blank();
|
||||||
|
static late SharedPreferences _sp;
|
||||||
|
static bool _ready = false;
|
||||||
|
|
||||||
|
static Map<String, dynamic> _blank() => {
|
||||||
|
'cleared': <String, dynamic>{},
|
||||||
|
'customs': <dynamic>[],
|
||||||
|
'audio': true,
|
||||||
|
'settings': <String, dynamic>{},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 设置对象(用户名 / 触屏布局 / 键位)。不存在时自动创建。
|
||||||
|
static Map<String, dynamic> settings() {
|
||||||
|
if (data['settings'] is! Map) data['settings'] = <String, dynamic>{};
|
||||||
|
return data['settings'] as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> 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<String, dynamic>();
|
||||||
|
data = {..._blank(), ...m};
|
||||||
|
data['cleared'] = (data['cleared'] as Map?)?.cast<String, dynamic>() ??
|
||||||
|
<String, dynamic>{};
|
||||||
|
data['customs'] = (data['customs'] as List?)?.cast<dynamic>() ??
|
||||||
|
<dynamic>[];
|
||||||
|
} catch (_) {
|
||||||
|
data = _blank();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _flush() {
|
||||||
|
if (!_ready) return;
|
||||||
|
// 异步写盘,不阻塞游戏循环。
|
||||||
|
_sp.setString('iwbtc_fan_v1', jsonEncode(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 公开写盘入口(设置界面「确定」时调用)。
|
||||||
|
static void flush() => _flush();
|
||||||
|
|
||||||
|
static Map<String, dynamic>? get(String id) => data['cleared'][id] as Map<String, dynamic>?;
|
||||||
|
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<String, dynamic>?;
|
||||||
|
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<dynamic> customs() => (data['customs'] as List?)?.cast<dynamic>() ?? <dynamic>[];
|
||||||
|
static void addCustom(Map<String, dynamic> lv) {
|
||||||
|
(data['customs'] as List).add(lv);
|
||||||
|
_flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新已保存的自制关卡(按 id 就地替换,不新增);id 不存在则追加。
|
||||||
|
static void updateCustom(Map<String, dynamic> lv) {
|
||||||
|
final list = (data['customs'] as List).cast<dynamic>();
|
||||||
|
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;
|
||||||
@@ -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<T>(List<T> 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;
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> 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'];
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<String, Tile> 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<String, String> 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<String, List<int>> 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<Tile> solidTiles = {
|
||||||
|
Tile.block, Tile.grass, Tile.brick, Tile.metal, Tile.ice, Tile.vanish,
|
||||||
|
};
|
||||||
|
const Set<Tile> 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<Cell> 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<String, dynamic> data;
|
||||||
|
String name;
|
||||||
|
String type;
|
||||||
|
late int w, h;
|
||||||
|
late double pxW, pxH;
|
||||||
|
late List<List<Tile>> tiles;
|
||||||
|
late List<Entity> entities;
|
||||||
|
late List<Mover> movers;
|
||||||
|
late List<Boss> bosses;
|
||||||
|
late List<dynamic> events;
|
||||||
|
late Particles particles;
|
||||||
|
late List<DeathMark> deathMarks;
|
||||||
|
late List<DeathFX> deathFX;
|
||||||
|
late Map<int, int> vanish;
|
||||||
|
Map<int, int>? vanishT;
|
||||||
|
late List<Bullet> bullets;
|
||||||
|
late List<EBullet> ebullets;
|
||||||
|
late List<dynamic> srcEntities;
|
||||||
|
late List<dynamic> moversSrc;
|
||||||
|
late List<dynamic> eventSrc;
|
||||||
|
late List<dynamic> msgs;
|
||||||
|
late List<dynamic> bossSrc;
|
||||||
|
late List<dynamic> warpSrc;
|
||||||
|
late List<dynamic> turretSrc;
|
||||||
|
late List<dynamic> 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<String, dynamic> 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 = <Map<String, dynamic>>[];
|
||||||
|
final order = <String, int>{};
|
||||||
|
|
||||||
|
for (int y = 0; y < h; y++) {
|
||||||
|
final line = rows[y];
|
||||||
|
final row = <Tile>[];
|
||||||
|
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<Map>()
|
||||||
|
.firstWhere((d) => d['tx'] == tx && d['ty'] == ty, orElse: () => <String, dynamic>{});
|
||||||
|
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<int>.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;
|
||||||
|
}
|
||||||
@@ -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<String, String> TYPE_LABEL = {
|
||||||
|
'jump': '跳刺',
|
||||||
|
'endur': '耐久',
|
||||||
|
'boss': 'BOSS',
|
||||||
|
'troll': '坑向',
|
||||||
|
'custom': '自制',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Map<String, String> TYPE_CLASS = {
|
||||||
|
'jump': 't-jump',
|
||||||
|
'endur': 't-endur',
|
||||||
|
'boss': 't-boss',
|
||||||
|
'troll': 't-troll',
|
||||||
|
'custom': 't-custom',
|
||||||
|
};
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> _builtin = <Map<String, dynamic>>[];
|
||||||
|
bool _loaded = false;
|
||||||
|
|
||||||
|
/// 加载内置关卡(assets/levels.json)。重复调用安全。
|
||||||
|
Future<void> loadLevels() async {
|
||||||
|
if (_loaded) return;
|
||||||
|
final s = await rootBundle.loadString('assets/levels.json');
|
||||||
|
final m = (jsonDecode(s) as Map).cast<String, dynamic>();
|
||||||
|
final list = ((m['levels'] as List?) ?? <dynamic>[])
|
||||||
|
.cast<Map<String, dynamic>>()
|
||||||
|
.toList();
|
||||||
|
for (int i = 0; i < list.length; i++) {
|
||||||
|
list[i] = <String, dynamic>{...list[i], 'index': i, 'builtin': true};
|
||||||
|
}
|
||||||
|
_builtin = list;
|
||||||
|
_loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 内置关卡清单(已标注 index / builtin)。
|
||||||
|
List<Map<String, dynamic>> get LEVELS => _builtin;
|
||||||
|
|
||||||
|
/// 合成一张自制关卡数据对象(与 web makeCustomLevel 一致)。
|
||||||
|
Map<String, dynamic> makeCustomLevel(Map<String, dynamic> data) {
|
||||||
|
return <String, dynamic>{
|
||||||
|
...data,
|
||||||
|
'id': 'u' + (DateTime.now().millisecondsSinceEpoch % 100000).toString() + (_uid++).toString(),
|
||||||
|
'type': 'custom',
|
||||||
|
'builtin': false,
|
||||||
|
'index': -1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
int _uid = 0;
|
||||||
|
|
||||||
|
/// 按 id 查找关卡(内置优先,其次自制)。
|
||||||
|
Map<String, dynamic>? 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<String, dynamic>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -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<Particle> list = [];
|
||||||
|
|
||||||
|
void clear() => list.clear();
|
||||||
|
|
||||||
|
void spawn(double x, double y, int n,
|
||||||
|
{List<String> 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<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> 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<String, dynamic> hud);
|
||||||
|
void onBossBar(dynamic boss);
|
||||||
|
void onToast(String text);
|
||||||
|
void onResult(Map<String, dynamic> 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<String> stateN = ValueNotifier<String>('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<String, dynamic>? data;
|
||||||
|
int idx = -1;
|
||||||
|
List<Level>? 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<String, dynamic> faceData(Map<String, dynamic> map, Map<String, dynamic> f, int i) {
|
||||||
|
final grid = (f['grid'] as List?)?.cast<String>() ?? <String>[];
|
||||||
|
return <String, dynamic>{
|
||||||
|
'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'] ?? <dynamic>[],
|
||||||
|
'bg': 'custom',
|
||||||
|
'hint': '',
|
||||||
|
'messages': <dynamic>[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void startLevel(Map<String, dynamic> 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 = <Level>[];
|
||||||
|
for (int i = 0; i < faces.length; i++) {
|
||||||
|
final L = Level(faceData(data, faces[i] as Map<String, dynamic>, i));
|
||||||
|
L.faceIndex = i;
|
||||||
|
map!.add(L);
|
||||||
|
}
|
||||||
|
face = ((data['startFace'] as int?) ?? 0).clamp(0, map!.length - 1);
|
||||||
|
} else {
|
||||||
|
map = <Level>[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(<String, dynamic>{
|
||||||
|
'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(<String, dynamic>{
|
||||||
|
'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: <Color>[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<Map<String, dynamic>> hud;
|
||||||
|
final ValueNotifier<dynamic> boss;
|
||||||
|
final ValueNotifier<String> 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: <Widget>[
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
child: ValueListenableBuilder<Map<String, dynamic>>(
|
||||||
|
valueListenable: hud,
|
||||||
|
builder: (_, Map<String, dynamic> h, __) {
|
||||||
|
final totalApples = h['totalApples'] ?? 0;
|
||||||
|
final totalSaves = h['totalSaves'] ?? 0;
|
||||||
|
final save = h['save'] ?? 0;
|
||||||
|
return Row(
|
||||||
|
children: <Widget>[
|
||||||
|
_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<dynamic>(
|
||||||
|
valueListenable: boss,
|
||||||
|
builder: (_, dynamic b, __) =>
|
||||||
|
b == null ? const SizedBox() : _BossBar(boss: b as Boss),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 66,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: ValueListenableBuilder<String>(
|
||||||
|
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: <Widget>[
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<bool> visible;
|
||||||
|
final ValueNotifier<bool> 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<bool>(
|
||||||
|
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: <Widget>[
|
||||||
|
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<bool> 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<bool>(
|
||||||
|
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: <Widget>[
|
||||||
|
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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TouchBtn> BUTTONS = <TouchBtn>[
|
||||||
|
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<List<String>> KEY_ACTIONS = <List<String>>[
|
||||||
|
<String>['left', '左移'],
|
||||||
|
<String>['right', '右移'],
|
||||||
|
<String>['up', '上(上浮)'],
|
||||||
|
<String>['jump', '跳跃'],
|
||||||
|
<String>['shoot', '射击'],
|
||||||
|
<String>['suicide', '自杀 / 重生'],
|
||||||
|
<String>['restart', '整关重来'],
|
||||||
|
<String>['pause', '暂停'],
|
||||||
|
];
|
||||||
|
|
||||||
|
Map<String, dynamic> defaultTouch() {
|
||||||
|
final m = <String, dynamic>{};
|
||||||
|
for (final b in BUTTONS) {
|
||||||
|
m[b.key] = <String, dynamic>{
|
||||||
|
'cx': b.cx,
|
||||||
|
'cy': b.cy,
|
||||||
|
'size': b.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> defaultSettings() => <String, dynamic>{
|
||||||
|
'username': '',
|
||||||
|
'touch': defaultTouch(),
|
||||||
|
'keys': defaultBindings(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Map<String, List<String>> defaultBindings() => <String, List<String>>{
|
||||||
|
'left': <String>['ArrowLeft', 'KeyA'],
|
||||||
|
'right': <String>['ArrowRight', 'KeyD'],
|
||||||
|
'up': <String>['ArrowUp', 'KeyW'],
|
||||||
|
'down': <String>['ArrowDown', 'KeyS'],
|
||||||
|
'jump': <String>['ShiftLeft', 'ShiftRight', 'Space', 'KeyZ', 'KeyN'],
|
||||||
|
'shoot': <String>['KeyX', 'KeyJ', 'KeyC', 'KeyM'],
|
||||||
|
'suicide': <String>['KeyR'],
|
||||||
|
'restart': <String>['Backspace'],
|
||||||
|
'pause': <String>['Escape', 'KeyP'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 读取/补全存档里的设置对象(保证 touch / keys 字段齐全)。
|
||||||
|
Map<String, dynamic> 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<String, String> M = <String, String>{
|
||||||
|
'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;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
flutter/ephemeral
|
||||||
@@ -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 "$<$<NOT:$<CONFIG:Debug>>:-O3>")
|
||||||
|
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>: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()
|
||||||
@@ -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}
|
||||||
|
)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//
|
||||||
|
// Generated file. Do not edit.
|
||||||
|
//
|
||||||
|
|
||||||
|
// clang-format off
|
||||||
|
|
||||||
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <audioplayers_linux/audioplayers_linux_plugin.h>
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//
|
||||||
|
// Generated file. Do not edit.
|
||||||
|
//
|
||||||
|
|
||||||
|
// clang-format off
|
||||||
|
|
||||||
|
#ifndef GENERATED_PLUGIN_REGISTRANT_
|
||||||
|
#define GENERATED_PLUGIN_REGISTRANT_
|
||||||
|
|
||||||
|
#include <flutter_linux/flutter_linux.h>
|
||||||
|
|
||||||
|
// Registers Flutter plugins.
|
||||||
|
void fl_register_plugins(FlPluginRegistry* registry);
|
||||||
|
|
||||||
|
#endif // GENERATED_PLUGIN_REGISTRANT_
|
||||||
@@ -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 $<TARGET_FILE:${plugin}_plugin>)
|
||||||
|
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)
|
||||||
@@ -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}")
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
#include "my_application.h"
|
||||||
|
|
||||||
|
#include <flutter_linux/flutter_linux.h>
|
||||||
|
#ifdef GDK_WINDOWING_X11
|
||||||
|
#include <gdk/gdkx.h>
|
||||||
|
#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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#ifndef FLUTTER_MY_APPLICATION_H_
|
||||||
|
#define FLUTTER_MY_APPLICATION_H_
|
||||||
|
|
||||||
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
|
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_
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Flutter-related
|
||||||
|
**/Flutter/ephemeral/
|
||||||
|
**/Pods/
|
||||||
|
|
||||||
|
# Xcode-related
|
||||||
|
**/dgph
|
||||||
|
**/xcuserdata/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
#include "ephemeral/Flutter-Generated.xcconfig"
|
||||||