Files
kidgo/app/test/title_render_test.dart
T

654 lines
25 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 真机冒烟测试:在虚拟设备尺寸下真实渲染界面。
// 目的:验证 AppRoot 完成初始化后能正常出画面,而不是停在加载态/错误界面(黑屏)。
// 任何 build / layout / paint 阶段的异常都会被测试捕获成失败。
//
// 关键回归点:Positioned 必须是 Stack 的**直接子级**。若把它写在 ValueListenableBuilder
// 的返回值里(隔着一层),定位数据被丢弃、子树横向塌缩为 0 宽 —— 这正是真机黑屏的根因。
// 因此这里断言的是「可见尺寸」,而不是「widget 存在」(find 不检查可见性,测不出塌缩)。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:iwbtc/src/app_root.dart' show AppRoot;
import 'package:iwbtc/src/core/app_error.dart' show AppError;
import 'package:iwbtc/src/core/save.dart' show Save;
import 'package:iwbtc/src/game/levels.dart' show loadLevels, LEVELS;
import 'package:iwbtc/src/game/level.dart' show Level;
import 'package:iwbtc/src/game/world.dart' show GameHost, World, W, H;
import 'package:iwbtc/src/ui/game_canvas.dart' show GameCanvas, stageMetrics;
import 'package:iwbtc/src/ui/overlays.dart' show HudOverlay;
import 'package:iwbtc/src/ui/screens.dart'
show SettingsScreen, ResultScreen, LevelSelectScreen;
import 'package:iwbtc/src/ui/editor_panel.dart'
show EditorPanel, EditorController;
import 'package:iwbtc/src/ui/touch_controls.dart' show TouchControls;
import 'package:iwbtc/src/core/input.dart' show Input;
/// 按典型**横屏手机**分辨率设定虚拟窗口(安卓端已锁定横屏)。
const Size kLandscapePhone = Size(2340, 1080);
/// 完成 AppRoot 初始化并出到稳定帧。
Future<void> _boot(WidgetTester tester) async {
await tester.binding.setSurfaceSize(kLandscapePhone);
await tester.runAsync(() async {
await Save.load();
await loadLevels();
});
await tester.pumpWidget(const AppRoot());
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
await tester.pump();
expect(AppError.current.value, isNull, reason: '初始化/渲染过程中抛出了异常');
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
AppError.current.value = null;
SharedPreferences.setMockInitialValues(<String, Object>{});
});
testWidgets('标题界面能真实渲染出来(非黑屏)', (WidgetTester tester) async {
await _boot(tester);
expect(find.textContaining('启动失败'), findsNothing);
// 不得仍停留在加载态
expect(find.text('正在加载关卡…'), findsNothing, reason: '一直停在加载态,等同于黑屏');
// 标题菜单必须存在
expect(find.text('关卡选择'), findsOneWidget);
expect(find.text('自制关卡'), findsOneWidget);
expect(find.text('设置'), findsOneWidget);
expect(find.textContaining('23 个内置关卡'), findsOneWidget);
// 关键回归:按钮必须有可见尺寸且在屏内
final btn = find.text('关卡选择');
final btnSize = tester.getSize(btn);
final btnPos = tester.getTopLeft(btn);
expect(
btnSize.width,
greaterThan(0),
reason: '按钮宽度塌缩为 0,标题树布局被破坏(Positioned 必须是 Stack 直接子级)',
);
expect(btnSize.height, greaterThan(0));
expect(btnPos.dx, greaterThanOrEqualTo(0), reason: '按钮被定位到屏外');
expect(btnPos.dy, greaterThanOrEqualTo(0));
});
testWidgets('横屏小逻辑高度下标题不溢出(真机 BOTTOM OVERFLOWED 回归)', (
WidgetTester tester,
) async {
// 真机横屏 1080 物理高 ÷ DPR ≈ 360~400 **逻辑**像素;此前测试用的
// 2340×1080 是逻辑尺寸、远高于真机,所以测不出溢出。
await tester.binding.setSurfaceSize(const Size(800, 360));
addTearDown(() => tester.binding.setSurfaceSize(null));
SharedPreferences.setMockInitialValues(<String, Object>{});
await tester.runAsync(() async {
await Save.load();
await loadLevels();
});
// 布局溢出(RenderFlex overflow)在测试里会作为异常抛出并使测试失败。
await tester.pumpWidget(const AppRoot());
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
await tester.pump();
expect(AppError.current.value, isNull, reason: '小高度下渲染抛出了异常');
expect(find.text('关卡选择'), findsOneWidget);
expect(tester.getSize(find.text('关卡选择')).height, greaterThan(0));
// 整体等比缩小方案(FittedBox.scaleDown):不滚动、不溢出,
// 底部版权行在一屏内直接可见(只是整体缩小了)。
final footer = find.textContaining('非商业同人作品');
expect(footer, findsOneWidget, reason: '标题内容应等比缩小后完整可见,而不是被裁掉');
final btn = tester.getSize(find.text('关卡选择'));
expect(btn.height, greaterThan(0));
});
testWidgets('游戏画布铺满全屏(无信箱黑边)且虚拟视口完整包含 800×608', (WidgetTester tester) async {
await _boot(tester);
final canvas = find.byType(GameCanvas);
expect(canvas, findsOneWidget);
final Size size = tester.getSize(canvas);
// 必须是全屏尺寸,而不是 0(之前整棵 Stack 布局塌缩时画布也是 0×0)
expect(size.width, greaterThan(0), reason: '画布宽度为 0,界面不会显示任何内容');
expect(size.height, greaterThan(0));
expect(size.width, kLandscapePhone.width);
// 横屏比 800×608 宽 → 高度定标 608、视口横向加宽,铺满全屏无黑边
final m = stageMetrics(size);
expect(m.isValid, isTrue);
expect(m.scale, closeTo(size.height / H, 0.001));
expect(m.vh, closeTo(H, 0.001));
expect(m.vw, closeTo(size.width / m.scale, 0.001));
expect(m.vw, greaterThanOrEqualTo(W), reason: '视口必须完整包含原 800 宽的画面(不裁游戏内容)');
});
testWidgets('HUD / Toast 有可见尺寸(不得横向塌缩)', (WidgetTester tester) async {
await tester.binding.setSurfaceSize(const Size(1080, 2340));
addTearDown(() => tester.binding.setSurfaceSize(null));
final hud = ValueNotifier<Map<String, dynamic>>(<String, dynamic>{
'name': '测试关卡',
'deaths': 3,
'ms': 12345,
'apples': 1,
'totalApples': 3,
'save': 0,
'totalSaves': 4,
});
final boss = ValueNotifier<dynamic>(null);
final toast = ValueNotifier<String>('测试提示');
addTearDown(() {
hud.dispose();
boss.dispose();
toast.dispose();
});
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Stack(
children: <Widget>[
Positioned.fill(
child: HudOverlay(hud: hud, boss: boss, toast: toast),
),
],
),
),
),
);
await tester.pump();
// 关卡名必须被撑开到有宽度(Positioned 嵌套错误时会塌缩为 0)
final name = find.text('测试关卡');
expect(name, findsOneWidget);
final Size size = tester.getSize(name);
expect(
size.width,
greaterThan(0),
reason: 'HUD 宽度为 0:Positioned 未作为 Stack 直接子级,布局被丢弃',
);
expect(size.height, greaterThan(0));
// Toast 同样必须可见
final tip = find.text('测试提示');
expect(tip, findsOneWidget);
expect(tester.getSize(tip).width, greaterThan(0));
// 苹果 / 存档 文案与 web 版保持一致
expect(find.text('苹果 1/3'), findsOneWidget);
expect(find.text('存档 0/4'), findsOneWidget);
});
testWidgets('从标题点进关卡能真正开始游戏(端到端)', (WidgetTester tester) async {
await _boot(tester);
// 进入关卡选择(注意:游戏 Ticker 常驻,不能用 pumpAndSettle)
await tester.tap(find.text('关卡选择'));
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
// 点第一张关卡卡片 → 应进入 playing 态并显示 HUD
final card = find.text(LEVELS.first['name'] as String);
expect(card, findsWidgets);
await tester.tap(card.first);
await tester.pump(const Duration(milliseconds: 200));
await tester.pump();
expect(AppError.current.value, isNull, reason: '进入关卡的过程中抛出了异常');
// 画布仍在正常出帧
expect(tester.getSize(find.byType(GameCanvas)).width, greaterThan(0));
// HUD 的关卡名已显示且有可见宽度
final hudName = find.text(LEVELS.first['name'] as String);
expect(hudName, findsWidgets);
});
testWidgets('横屏下虚拟手柄按钮锚定全屏且完整在屏内', (WidgetTester tester) async {
await tester.binding.setSurfaceSize(kLandscapePhone);
addTearDown(() => tester.binding.setSurfaceSize(null));
SharedPreferences.setMockInitialValues(<String, Object>{});
await tester.runAsync(() async {
await Save.load();
await loadLevels();
});
final visible = ValueNotifier<bool>(true);
final hasGun = ValueNotifier<bool>(false);
addTearDown(() {
visible.dispose();
hasGun.dispose();
});
// 需要一个 World 才有回调目标(构造不触发任何加载)。
final world = World(_Host());
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Stack(
children: <Widget>[
Positioned.fill(
child: TouchControls(
world: world,
visible: visible,
hasGun: hasGun,
),
),
],
),
),
),
);
await tester.pump();
final Size screen = tester.getSize(find.byType(TouchControls));
// 画面铺满全屏后,按钮直接按整屏百分比锚定(不再有黑边偏移)
// 「◀」左移键必须完整在屏内
final leftBtn = find.text('◀');
expect(leftBtn, findsOneWidget);
final Size btnSize = tester.getSize(leftBtn);
expect(btnSize.width, greaterThan(0));
final Offset pos = tester.getTopLeft(leftBtn);
expect(pos.dx, greaterThanOrEqualTo(0), reason: '左移键被定位到屏外');
expect(pos.dx + btnSize.width, lessThanOrEqualTo(screen.width));
// 「跳」键同理,且必须完整在屏内
final jumpBtn = find.text('跳');
expect(jumpBtn, findsOneWidget);
final Offset jp = tester.getTopLeft(jumpBtn);
final Size js = tester.getSize(jumpBtn);
expect(jp.dx, greaterThanOrEqualTo(0));
expect(jp.dx + js.width, lessThanOrEqualTo(screen.width));
expect(js.width, greaterThan(0));
});
testWidgets('虚拟跳跃键:在按键外松手也能复位(不卡住常按)', (WidgetTester tester) async {
// 回归:旧实现用 Listener(不捕获指针),手指在键上滑动后于键外松手时,
// 松手事件落到别的控件上 → touch['jump'] 永久卡在 true,之后每次按下都
// 因 setTouch 短路而不触发新边沿 → 跳跃键经常失灵。改用 GestureDetector(Pan)
// 后,抬起/取消事件必定回传本按钮,松手即复位。
await tester.binding.setSurfaceSize(kLandscapePhone);
addTearDown(() => tester.binding.setSurfaceSize(null));
SharedPreferences.setMockInitialValues(<String, Object>{});
await tester.runAsync(() async {
await Save.load();
await loadLevels();
});
final visible = ValueNotifier<bool>(true);
final hasGun = ValueNotifier<bool>(false);
addTearDown(() {
visible.dispose();
hasGun.dispose();
Input.clear();
});
final world = World(_Host());
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Stack(
children: <Widget>[
Positioned.fill(
child: TouchControls(
world: world,
visible: visible,
hasGun: hasGun,
),
),
],
),
),
),
);
await tester.pump();
final center = tester.getCenter(find.text('跳'));
final g = await tester.startGesture(center);
await tester.pump();
expect(Input.held('jump'), isTrue, reason: '按下后跳跃键应为按下态');
// 手指滑到屏幕左上角再松手(旧实现会丢失松手事件 → 卡住常按)
await g.moveTo(const Offset(5, 5));
await g.up();
await tester.pump();
expect(Input.held('jump'), isFalse, reason: '在键外松手后跳跃键必须复位,否则会卡住常按导致后续失灵');
});
testWidgets('系统返回手势:关卡选择 → 返回标题(不退出应用)', (WidgetTester tester) async {
await _boot(tester);
// 进入关卡选择
await tester.tap(find.text('关卡选择'));
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(find.text('返回'), findsWidgets);
// 模拟系统返回手势/返回键
await tester.binding.handlePopRoute();
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
// 回到标题(标题菜单重新出现),应用未退出
expect(find.text('自制关卡'), findsOneWidget);
expect(find.textContaining('23 个内置关卡'), findsOneWidget);
});
testWidgets('系统返回手势:游戏中 → 暂停菜单;再按 → 继续游戏', (WidgetTester tester) async {
await _boot(tester);
await tester.tap(find.text('关卡选择'));
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
final card = find.text(LEVELS.first['name'] as String);
await tester.tap(card.first);
await tester.pump(const Duration(milliseconds: 200));
await tester.pump();
// 游戏中按返回 → 应弹出暂停菜单,而不是退出应用
await tester.binding.handlePopRoute();
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(find.text('继续'), findsOneWidget, reason: '返回应打开暂停菜单');
// 暂停中再按返回 → 继续游戏(暂停菜单消失)
await tester.binding.handlePopRoute();
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(find.text('继续'), findsNothing, reason: '返回应恢复游戏');
});
testWidgets('自制关卡编辑器在横屏小屏下不溢出(RIGHT OVERFLOWED 回归)', (
WidgetTester tester,
) async {
// 回归:属性栏 200px 宽里塞三个默认 64px 最小宽的按钮必然横向溢出;
// 顶部工具行在窄屏也可能溢出。修复后按钮紧凑化 + 工具行可换行。
await tester.binding.setSurfaceSize(const Size(800, 360));
addTearDown(() => tester.binding.setSurfaceSize(null));
SharedPreferences.setMockInitialValues(<String, Object>{});
await tester.runAsync(() async {
await Save.load();
await loadLevels();
});
final c = EditorController();
c.open(25, 19);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: EditorPanel(controller: c, onBack: () {}),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(AppError.current.value, isNull, reason: '编辑器渲染时抛出了溢出/布局异常');
expect(find.text('删除'), findsOneWidget);
expect(find.text('+面'), findsOneWidget);
expect(find.text('保存'), findsOneWidget);
});
testWidgets('设置界面渲染,并能进入独立全屏的自定义按键页', (WidgetTester tester) async {
await _boot(tester);
await tester.tap(find.text('设置'));
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(AppError.current.value, isNull, reason: '设置界面渲染时抛出了异常');
expect(find.byType(SettingsScreen), findsOneWidget);
expect(find.text('手机操作按钮'), findsOneWidget);
// 触屏端入口:进入独立全屏编辑页
expect(find.text('自定义按键'), findsOneWidget);
expect(find.text('恢复默认'), findsOneWidget);
expect(find.text('确定'), findsOneWidget);
// 进入自定义按键页:应全屏显示,所有虚拟键可编辑
await tester.tap(find.text('自定义按键'));
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(AppError.current.value, isNull, reason: '自定义按键页渲染时抛出了异常');
expect(find.text('返回'), findsWidgets); // 编辑页返回按钮
expect(find.text('恢复默认'), findsOneWidget);
// 所有默认虚拟键都在编辑页里
for (final label in <String>['◀', '▶', '跳', '🔫', '▼', '❚❚', '↺']) {
expect(find.text(label), findsOneWidget, reason: '缺少 $label 按钮');
}
// 跳跃键必须有可见尺寸(布局编辑器按钮不能塌缩)
expect(tester.getSize(find.text('跳')).width, greaterThan(0));
// 拖动跳跃键 → 布局即时保存;返回设置页
final before = tester.getTopLeft(find.text('跳'));
await tester.dragFrom(
tester.getCenter(find.text('跳')),
const Offset(60, -40),
);
await tester.pump();
final after = tester.getTopLeft(find.text('跳'));
expect(after.dx, greaterThan(before.dx), reason: '拖动后按钮应移动');
await tester.tap(find.text('返回').last);
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(find.text('自定义按键'), findsOneWidget, reason: '应回到设置页');
});
testWidgets('结算界面在横屏小高度下不溢出(BOTTOM OVERFLOWED 回归)', (
WidgetTester tester,
) async {
// 回归:_Modal 固定内容(标题+4 行数据+新纪录+3 按钮)在 ~360 逻辑高的
// 横屏手机上曾超出屏高。修复后改为紧凑固定卡片(不滚动、不撑满全屏),
// 内容压到能在 360 逻辑高内完整放下。
await tester.binding.setSurfaceSize(const Size(800, 360));
addTearDown(() => tester.binding.setSurfaceSize(null));
SharedPreferences.setMockInitialValues(<String, Object>{});
await tester.runAsync(() async {
await Save.load();
await loadLevels();
});
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ResultScreen(
world: World(_Host()),
result: <String, dynamic>{
'name': 'DOUBLE JUMP',
'ms': 13490,
'deaths': 0,
'apples': 0,
'totalApples': 0,
'better': true,
'hasNext': true,
},
),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(AppError.current.value, isNull, reason: '结算界面渲染时抛出了溢出/布局异常');
expect(find.text('CLEAR!'), findsOneWidget);
// 三个按钮都必须构建且可见(在滚动区内)
expect(find.text('下一关'), findsOneWidget);
expect(find.text('再挑战'), findsOneWidget);
expect(tester.getSize(find.text('下一关')).height, greaterThan(0));
});
testWidgets('关卡选择:已通关卡片的 ✔ 角标不报 ParentDataWidget 错误', (
WidgetTester tester,
) async {
// 回归:_LevelCard 曾把「已通关 ✔ 角标」写成内层 Column 的直接子级
// Positioned(Positioned 必须是 Stack 直接子级)→ 一旦该卡片被构建就抛
// Incorrect use of ParentDataWidget。先标记一关为已通关,使其渲染角标。
await tester.binding.setSurfaceSize(const Size(800, 360));
addTearDown(() => tester.binding.setSurfaceSize(null));
SharedPreferences.setMockInitialValues(<String, Object>{});
await tester.runAsync(() async {
await Save.load();
await loadLevels();
});
final id = LEVELS.first['id'] as String? ?? '';
Save.clear(id, 3, 12345, 0);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: LevelSelectScreen(
world: World(_Host()),
reload: ValueNotifier<int>(0),
onChanged: () {},
),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(AppError.current.value, isNull,
reason: '已通关卡片渲染时抛出了 ParentDataWidget 异常');
// 角标应作为外层 Stack 的直接子级正常渲染(不报错)。
expect(find.text('✔'), findsWidgets, reason: '已通关角标未正常渲染');
});
testWidgets('桌面(非触屏)设置界面显示键位重绑', (WidgetTester tester) async {
await tester.binding.setSurfaceSize(const Size(1280, 720));
addTearDown(() => tester.binding.setSurfaceSize(null));
SharedPreferences.setMockInitialValues(<String, Object>{});
await tester.runAsync(() async {
await Save.load();
await loadLevels();
});
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SettingsScreen(
world: World(_Host()),
isTouch: false,
onBack: () {},
onTouchEditor: () {},
),
),
),
);
await tester.pump();
expect(AppError.current.value, isNull, reason: '设置界面构建时抛出了异常');
// 键位行(web 版 settings.js 的 KEY_ACTIONS)
expect(find.text('键盘按键'), findsOneWidget);
expect(find.text('左移'), findsOneWidget);
expect(find.text('跳跃'), findsOneWidget);
expect(find.text('自杀 / 重生'), findsOneWidget);
expect(find.text('整关重来'), findsOneWidget);
// 已绑定键位会以标签形式出现
expect(find.text('←'), findsWidgets);
expect(find.text('Shift'), findsWidgets);
});
testWidgets('进出编辑器多次不报 setState-after-dispose', (
WidgetTester tester,
) async {
// 回归:EditorPanel 在 initState 把 setState 闭包挂到共享的
// EditorController.onChange,离开时若不清掉,再次进入触发
// controller.showOrOpen() → onChange?.call() 会对已 dispose 的 State
// 调 setState(setState() called after dispose)。
await _boot(tester);
// 进编辑器
await tester.tap(find.text('自制关卡'));
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(find.byType(EditorPanel), findsOneWidget, reason: '应进入编辑器');
expect(AppError.current.value, isNull, reason: '首次进入不应抛异常');
// 返回标题(EditorPanel 被 dispose)
await tester.tap(find.text('返回').first);
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(find.byType(EditorPanel), findsNothing, reason: '应离开编辑器');
// 再次进入:此处同步触发 controller.showOrOpen() → onChange?.call()
await tester.tap(find.text('自制关卡'));
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(AppError.current.value, isNull,
reason: '再次进入编辑器时不应抛 setState-after-dispose');
expect(find.byType(EditorPanel), findsOneWidget);
});
testWidgets('含 warp 但无匹配门的关卡可正常构建(doorId 类型回归)', (
WidgetTester tester,
) async {
// 回归:Level.reset 给 warp 实体的 doorId 赋值时,若 (tx,ty) 在 data.doors
// 里找不到对应门,会回退到实体序号 idx(int)。原字段声明为 String?,把
// int 赋给 String? 即抛 "type 'int' is not a subtype of type 'String?'",
// 在关卡选择里点开这类自制关卡便崩溃(崩溃栈:Level.reset → new Level →
// World.startLevel → LevelSelectScreen._play)。修复后 doorId 为 Object?。
final data = <String, dynamic>{
'id': 'test-warp',
'name': 'warp 关卡',
'type': 'custom',
'width': 12,
'grid': <String>[
' ',
' W ',
' ',
],
'doors': <dynamic>[],
};
late Level lv;
// 此前此处直接抛 TypeError,导致第二次点开地图即崩溃
expect(() {
lv = Level(data);
}, returnsNormally, reason: '含 warp 但无门的关卡不应抛类型错误');
final warp = lv.entities.firstWhere(
(e) => e.type == 'warp',
orElse: () => throw StateError('未生成 warp 实体'),
);
// 无匹配门时 doorId 回退为序号(int),与 web 原版弱类型语义一致
expect(warp.doorId, isA<int>(), reason: '无门时 doorId 应回退为 int 序号');
});
testWidgets('内置关卡清单能从 assets 正确加载', (WidgetTester tester) async {
await tester.runAsync(() async {
await Save.load();
await loadLevels();
});
expect(LEVELS.isNotEmpty, isTrue, reason: 'levels.json 未加载到任何关卡');
expect(LEVELS.length, 23);
});
}
/// 仅供测试用的空 GameHost 实现。
class _Host implements GameHost {
@override
void onBossBar(dynamic boss) {}
@override
void onHud(Map<String, dynamic> hud) {}
@override
void onPauseMenu(String label) {}
@override
void onResult(Map<String, dynamic> result) {}
@override
void onShowScreen(String screen) {}
@override
void onToast(String text) {}
@override
void editorShow() {}
@override
void touchHide() {}
@override
void touchSetGun(bool has) {}
@override
void touchShow() {}
}