dartboy/lib/main.dart
2024-08-31 23:57:44 +02:00

671 lines
18 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:isolate';
import 'dart:math';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:dartboy/emulator/gameboy.dart';
import 'package:dartboy/emulator/joypad.dart';
import 'package:dartboy/emulator/rom.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/painting.dart';
import 'package:flutter/services.dart';
const _width = 160.0;
const _height = 144.0;
const _primaryColor = Color.fromARGB(255, 67, 57, 180);
const _buttonColor = Color.fromARGB(255, 140, 25, 82);
const _directionColor = Color.fromARGB(255, 22, 24, 29);
const _gbColor = Color.fromARGB(255, 204, 202, 195);
const _seColor = Color.fromARGB(255, 117, 116, 118);
const _paddingColor = Color.fromARGB(255, 99, 97, 110);
const _screenColor = Color.fromARGB(255, 98, 122, 3);
void main() {
final parentRx = ReceivePort();
Isolate.spawn(_launchGameboy, parentRx.sendPort);
ValueNotifier<ui.Image?> image = ValueNotifier(null);
ValueNotifier<int> fps = ValueNotifier(0);
SendPort? childTx;
parentRx.listen((e) {
if (e is SendPort) {
childTx = e;
}
if (e is RenderFrameEvent) {
ui.decodeImageFromPixels(e.frame, 256, 256, ui.PixelFormat.rgba8888,
(result) {
image.value = result;
});
}
if (e is FpsUpdateEvent) {
fps.value = e.fps;
}
});
runApp(MyApp(
image: image,
fps: fps,
onRomSelected: (bytes) {
childTx?.send(FileSelectedEvent(bytes));
},
onKeyPressed: (key) {
childTx?.send(KeyPressedEvent(key));
},
onKeyReleased: (key) {
childTx?.send(KeyReleasedEvent(key));
},
));
}
void _launchGameboy(SendPort parentTx) async {
final childRx = ReceivePort();
parentTx.send(childRx.sendPort);
final gb = GameBoy();
childRx.listen((message) {
if (message is FileSelectedEvent) {
final rom = Rom(message.bytes);
gb.load(rom);
gb.reset();
}
if (message is KeyPressedEvent) {
if (gb.ready) gb.press(message.key);
}
if (message is KeyReleasedEvent) {
if (gb.ready) gb.release(message.key);
}
});
var prevDateTime = DateTime.now();
var frameCount = 0;
var fpsTotal = 0.0;
var sleep = const Duration(milliseconds: 16);
while (true) {
if (gb.ready) {
for (var i = 0; i < 70224; i++) {
gb.tick();
}
final pixels = gb.render();
parentTx.send(RenderFrameEvent(pixels));
}
frameCount += 1;
await Future.delayed(sleep);
final current = DateTime.now();
final elapsed = current.difference(prevDateTime);
final fps = (1000 / elapsed.inMilliseconds).clamp(0, 80);
sleep = Duration(
milliseconds: max((sleep.inMilliseconds + (fps - 60.0)).floor(), 0),
);
fpsTotal += fps;
if (frameCount >= 60) {
parentTx.send(FpsUpdateEvent((fpsTotal / frameCount).floor()));
frameCount = 0;
fpsTotal = 0;
}
prevDateTime = current;
}
}
class FpsUpdateEvent {
FpsUpdateEvent(this.fps);
final int fps;
}
class RenderFrameEvent {
RenderFrameEvent(this.frame);
final Uint8List frame;
}
class FileSelectedEvent {
FileSelectedEvent(this.bytes);
final Uint8List bytes;
}
class KeyPressedEvent {
KeyPressedEvent(this.key);
final JoypadKey key;
}
class KeyReleasedEvent {
KeyReleasedEvent(this.key);
final JoypadKey key;
}
class MyApp extends StatelessWidget {
const MyApp({
Key? key,
required this.image,
required this.fps,
required this.onRomSelected,
required this.onKeyPressed,
required this.onKeyReleased,
}) : super(key: key);
final ValueNotifier<int> fps;
final ValueNotifier<ui.Image?> image;
final void Function(Uint8List) onRomSelected;
final void Function(JoypadKey) onKeyPressed;
final void Function(JoypadKey) onKeyReleased;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'DART BOY',
theme: ThemeData(
primaryColor: _primaryColor,
canvasColor: _gbColor,
),
home: MyHomePage(
title: 'DART BOY',
fps: fps,
image: image,
onRomSelected: onRomSelected,
onKeyPressed: onKeyPressed,
onKeyReleased: onKeyReleased,
),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({
Key? key,
required this.title,
required this.fps,
required this.image,
required this.onRomSelected,
required this.onKeyPressed,
required this.onKeyReleased,
}) : super(key: key);
final String title;
final ValueNotifier<int> fps;
final ValueNotifier<ui.Image?> image;
final void Function(Uint8List) onRomSelected;
final void Function(JoypadKey) onKeyPressed;
final void Function(JoypadKey) onKeyReleased;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
foregroundColor: _primaryColor,
backgroundColor: _gbColor,
title: Text(widget.title,
style: const TextStyle(
fontSize: 32.0,
fontWeight: FontWeight.w800,
fontStyle: FontStyle.italic,
)),
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: _Screen(
fps: widget.fps,
image: widget.image,
),
),
Expanded(
child: _Controller(
onKeyPressed: widget.onKeyPressed,
onKeyReleased: widget.onKeyReleased,
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
final result = await FilePicker.platform.pickFiles();
if (result == null) return;
var bytes = result.files.first.bytes;
if (bytes == null) {
final file = File(result.paths.first!);
bytes = await file.readAsBytes();
}
widget.onRomSelected(bytes);
},
backgroundColor: _seColor,
child: const Icon(
Icons.file_upload,
color: Colors.white,
)),
);
}
}
class _Screen extends StatefulWidget {
const _Screen({
Key? key,
required this.fps,
required this.image,
}) : super(key: key);
final ValueNotifier<int> fps;
final ValueNotifier<ui.Image?> image;
@override
State<StatefulWidget> createState() => _ScreenState();
}
class _ScreenState extends State<_Screen> {
@override
void initState() {
super.initState();
widget.image.addListener(_rebuild);
}
@override
void dispose() {
super.dispose();
widget.image.removeListener(_rebuild);
}
void _rebuild() {
setState(() {});
}
@override
Widget build(BuildContext context) {
return ColoredBox(
color: _paddingColor,
child: FittedBox(
child: Card(
elevation: 10,
clipBehavior: Clip.antiAlias,
child: CustomPaint(
painter: _ScreenPainter(
image: widget.image.value,
),
child: SizedBox(
width: _width,
height: _height,
child: Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
widget.fps.value.toString(),
style: const TextStyle(
color: _primaryColor,
fontSize: 6.0,
),
),
),
),
),
),
),
),
);
}
}
class _Controller extends StatelessWidget {
const _Controller({
Key? key,
required this.onKeyPressed,
required this.onKeyReleased,
}) : super(key: key);
final void Function(JoypadKey key) onKeyPressed;
final void Function(JoypadKey key) onKeyReleased;
static final _keyToJoypadKeyMap = {
LogicalKeyboardKey.keyZ: JoypadKey.a,
LogicalKeyboardKey.keyX: JoypadKey.b,
LogicalKeyboardKey.keyV: JoypadKey.start,
LogicalKeyboardKey.keyC: JoypadKey.select,
LogicalKeyboardKey.arrowUp: JoypadKey.up,
LogicalKeyboardKey.arrowDown: JoypadKey.down,
LogicalKeyboardKey.arrowRight: JoypadKey.right,
LogicalKeyboardKey.arrowLeft: JoypadKey.left,
};
@override
Widget build(BuildContext context) {
return KeyboardListener(
onKeyEvent: (key) {
final joypadKey = _keyToJoypadKeyMap[key.logicalKey];
if (joypadKey == null) return;
if (key is KeyDownEvent) {
onKeyPressed(joypadKey);
}
if (key is KeyUpEvent) {
onKeyReleased(joypadKey);
}
},
focusNode: FocusNode(),
autofocus: true,
child: FittedBox(
child: SizedBox(
width: 500,
height: 400,
child: ClipPath(
clipBehavior: Clip.antiAlias,
child: CustomPaint(
painter: const _ControllerPainter(),
child: Stack(
children: [
Positioned(
top: 100,
right: 40,
width: 60,
height: 40,
child: _ControllerButton(
onPressed: () => onKeyPressed(JoypadKey.a),
onReleased: () => onKeyReleased(JoypadKey.a),
color: _buttonColor,
child: const Text(
'A',
style: TextStyle(
color: Colors.white,
),
),
),
),
Positioned(
top: 100,
right: 110,
width: 60,
height: 40,
child: _ControllerButton(
onPressed: () => onKeyPressed(JoypadKey.b),
onReleased: () => onKeyReleased(JoypadKey.b),
color: _buttonColor,
child: const Text(
'B',
style: TextStyle(
color: Colors.white,
),
),
),
),
Positioned(
top: 100,
left: 30,
width: 50,
height: 50,
child: _ControllerButton(
onPressed: () => onKeyPressed(JoypadKey.left),
onReleased: () => onKeyReleased(JoypadKey.left),
color: _directionColor,
child: const Icon(
Icons.arrow_left_rounded,
color: Colors.white,
semanticLabel: 'left',
),
),
),
Positioned(
top: 100,
left: 130,
width: 50,
height: 50,
child: _ControllerButton(
onPressed: () => onKeyPressed(JoypadKey.right),
onReleased: () => onKeyReleased(JoypadKey.right),
color: _directionColor,
child: const Icon(
Icons.arrow_right_rounded,
color: Colors.white,
semanticLabel: 'right',
),
),
),
Positioned(
top: 50,
left: 80,
width: 50,
height: 50,
child: _ControllerButton(
onPressed: () => onKeyPressed(JoypadKey.up),
onReleased: () => onKeyReleased(JoypadKey.up),
color: _directionColor,
child: const Icon(
Icons.arrow_drop_up_sharp,
color: Colors.white,
semanticLabel: 'up',
),
),
),
Positioned(
top: 150,
left: 80,
width: 50,
height: 50,
child: _ControllerButton(
onPressed: () => onKeyPressed(JoypadKey.down),
onReleased: () => onKeyReleased(JoypadKey.down),
color: _directionColor,
child: const Icon(
Icons.arrow_drop_down_sharp,
color: Colors.white,
semanticLabel: 'down',
),
),
),
Positioned(
bottom: 80,
left: 0,
right: 0,
height: 40,
child: Center(
child: SizedBox(
width: 220,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Transform.rotate(
angle: -1 / 6 * pi,
child: SizedBox(
width: 100,
height: double.infinity,
child: _ControllerButton(
onPressed: () =>
onKeyPressed(JoypadKey.select),
onReleased: () =>
onKeyReleased(JoypadKey.select),
color: _seColor,
child: const Text(
'SELECT',
style: TextStyle(
color: Colors.white,
),
),
),
),
),
Transform.rotate(
angle: -1 / 6 * pi,
child: SizedBox(
width: 100,
height: double.infinity,
child: _ControllerButton(
onPressed: () =>
onKeyPressed(JoypadKey.start),
onReleased: () =>
onKeyReleased(JoypadKey.start),
color: _seColor,
child: const Text(
'START',
style: TextStyle(
color: Colors.white,
),
),
),
),
),
],
),
),
),
),
],
),
),
),
),
),
);
}
}
class _ControllerButton extends StatelessWidget {
const _ControllerButton({
Key? key,
required this.color,
required this.onPressed,
required this.onReleased,
required this.child,
}) : super(key: key);
final Color color;
final void Function() onPressed;
final void Function() onReleased;
final Widget child;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
child: Material(
color: color,
elevation: 5,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8.0)),
),
child: InkWell(
onTapDown: (_) => onPressed(),
onTap: () => onReleased(),
onTapCancel: () => onReleased(),
child: Center(child: child),
),
),
);
}
}
class _ScreenPainter extends CustomPainter {
const _ScreenPainter({
required this.image,
});
final ui.Image? image;
@override
void paint(ui.Canvas canvas, ui.Size size) {
final paint = Paint()..color = _screenColor;
canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), paint);
if (image != null) {
canvas.drawImageRect(
image!,
const Rect.fromLTWH(0, 0, _width, _height),
Rect.fromLTWH(
0,
0,
size.width,
size.height,
),
paint,
);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}
class _ControllerPainter extends CustomPainter {
const _ControllerPainter();
@override
void paint(ui.Canvas canvas, ui.Size size) {
const color = Color.fromARGB(60, 117, 113, 103);
final paint = Paint()..color = color;
const width = 16.0;
const height = 80.0;
canvas.translate(
size.width - 2 * 6 * width - 10.0,
size.height - height - 10.0,
);
canvas.rotate(-1 / 6 * pi);
for (int i = 0; i < 6; i++) {
final x = width * i * 2.0;
const y = 0.0;
canvas.drawRRect(
RRect.fromLTRBR(
x,
y,
x + width,
y + height,
const Radius.circular(4.0),
),
paint,
);
}
canvas.drawRect(
const Rect.fromLTWH(
-100,
height - 30,
2 * 6 * width + 200,
200,
),
paint,
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return false;
}
}