refactor: 使用flutter对原先的web项目重构,正在进行中
This commit is contained in:
1 parent
273224f5cf
commit
18017f819a
205 files changed
+13563
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user