refactor: 使用flutter对原先的web项目重构,正在进行中

This commit is contained in:
cancerai committed 2026-10-25 00:06:51 +08:00
1 parent 273224f5cf
commit 18017f819a
205 files changed
+13563

No files matched your search

File diff suppressed because it is too large. Load diff
+95
View File
@@ -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;
}
+147
View File
@@ -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
+190
View File
@@ -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)),
],
),
),
),
);
},
),
),
);
}
}
+104
View File
@@ -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;
}