Last active
February 17, 2020 02:36
-
-
Save filiph/9359148ad9868540b7e3b686a3a232cd to your computer and use it in GitHub Desktop.
An experiment of spacial navigation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:math'; | |
import 'package:flutter/material.dart'; | |
import 'package:flutter/services.dart'; | |
void main() => runApp(MyApp()); | |
class MapPainter extends CustomPainter { | |
final Set<_Vector2> _points; | |
final _Vector2 _selectedPoint; | |
MapPainter(this._points, this._selectedPoint); | |
@override | |
void paint(Canvas canvas, Size size) { | |
Offset toOffset(_Vector2 point) => | |
Offset(point.x * size.width, point.y * size.height); | |
final paint = Paint(); | |
for (final point in _points) { | |
assert(point.x <= 1); | |
assert(point.y <= 1); | |
canvas.drawCircle(toOffset(point), 2, paint); | |
} | |
final redPaint = Paint()..color = Colors.red; | |
if (_selectedPoint != null) { | |
canvas.drawCircle(toOffset(_selectedPoint), 5, redPaint); | |
} | |
} | |
@override | |
bool shouldRepaint(CustomPainter oldDelegate) { | |
return true; | |
} | |
} | |
class MyApp extends StatelessWidget { | |
@override | |
Widget build(BuildContext context) { | |
return MaterialApp( | |
title: 'Flutter Demo', | |
theme: ThemeData( | |
primarySwatch: Colors.blue, | |
), | |
home: MyHomePage(title: 'Spacial Navigation Experiment'), | |
); | |
} | |
} | |
class MyHomePage extends StatefulWidget { | |
final String title; | |
MyHomePage({Key key, this.title}) : super(key: key); | |
@override | |
_MyHomePageState createState() => _MyHomePageState(); | |
} | |
class _MyHomePageState extends State<MyHomePage> { | |
static final _left = _Vector2(-1, 0); | |
static final _right = _Vector2(1, 0); | |
static final _up = _Vector2(0, -1); | |
static final _down = _Vector2(0, 1); | |
FocusNode _focusNode; | |
Set<_Vector2> _points = _generateVectors().toSet(); | |
_Vector2 _selectedPoint; | |
String _displayArrow = ''; | |
@override | |
Widget build(BuildContext context) { | |
return RawKeyboardListener( | |
autofocus: true, | |
focusNode: _focusNode, | |
child: Scaffold( | |
appBar: AppBar( | |
title: Text(widget.title), | |
), | |
body: Align( | |
child: Column( | |
mainAxisAlignment: MainAxisAlignment.center, | |
crossAxisAlignment: CrossAxisAlignment.center, | |
children: [ | |
Text('Tap here, then use hardware arrows.'), | |
SizedBox(height: 50), | |
CustomPaint( | |
size: Size(200, 200), | |
painter: MapPainter(_points, _selectedPoint), | |
), | |
SizedBox(height: 50), | |
SizedBox( | |
height: 50, | |
child: Text(_displayArrow, style: TextStyle(fontSize: 40)), | |
), | |
], | |
), | |
), | |
), | |
); | |
} | |
@override | |
void initState() { | |
super.initState(); | |
_focusNode = FocusNode(onKey: _onKey); | |
_selectedPoint = _points.first; | |
} | |
void _moveInDirection(_Vector2 direction) { | |
final sorted = _listByClosest(_selectedPoint, direction, _points); | |
final nextSelected = sorted.firstWhere((v) { | |
// Only take points that are in the general direction. | |
return (v - _selectedPoint).angleTo(direction) < pi / 2; | |
}, orElse: () => _selectedPoint); | |
setState(() { | |
_selectedPoint = nextSelected; | |
}); | |
} | |
bool _onKey(FocusNode node, RawKeyEvent event) { | |
if (event.isKeyPressed(LogicalKeyboardKey.arrowLeft)) { | |
_moveInDirection(_left); | |
_displayArrow = '⇦'; | |
return true; | |
} | |
if (event.isKeyPressed(LogicalKeyboardKey.arrowRight)) { | |
_moveInDirection(_right); | |
_displayArrow = '⇨'; | |
return true; | |
} | |
if (event.isKeyPressed(LogicalKeyboardKey.arrowUp)) { | |
_moveInDirection(_up); | |
_displayArrow = '⇧'; | |
return true; | |
} | |
if (event.isKeyPressed(LogicalKeyboardKey.arrowDown)) { | |
_moveInDirection(_down); | |
_displayArrow = '⇩'; | |
return true; | |
} | |
return false; | |
} | |
static Iterable<_Vector2> _generateVectors() sync* { | |
var random = Random(); | |
for (int i = 0; i < 20; i++) { | |
yield _Vector2(random.nextDouble(), random.nextDouble()); | |
} | |
} | |
/// Returns [points] sorted by closeness "score". Points that are close | |
/// and in the [direction] from [origin] will be ranked higher. | |
/// | |
/// This list will still have points that might not be a good fit. | |
/// Those can be filtered out by the caller of the method. | |
static List<_Vector2> _listByClosest( | |
_Vector2 origin, _Vector2 direction, Set<_Vector2> points) { | |
final result = List<_Vector2>.from(points); | |
result.remove(origin); | |
result.sort((_Vector2 a, _Vector2 b) { | |
final aDistance = (a.x - origin.x).abs() + (a.y - origin.y).abs(); | |
if (aDistance == 0) return 1; | |
final bDistance = (b.x - origin.x).abs() + (b.y - origin.y).abs(); | |
if (bDistance == 0) return -1; | |
final aDistNormalized = aDistance / max(aDistance, bDistance); | |
final bDistNormalized = bDistance / max(aDistance, bDistance); | |
final aAngle = (a - origin).angleTo(direction); | |
final bAngle = (b - origin).angleTo(direction); | |
final aAngleNormalized = min(aAngle, pi / 3) / (pi / 3); | |
final bAngleNormalized = min(bAngle, pi / 3) / (pi / 3); | |
final aScore = aDistNormalized + aAngleNormalized; | |
final bScore = bDistNormalized + bAngleNormalized; | |
return aScore.compareTo(bScore); | |
}); | |
return result; | |
} | |
} | |
/// A simple immutable 2D vector class. | |
class _Vector2 { | |
final double x, y; | |
const _Vector2(this.x, this.y); | |
/// TO DO: better hash code | |
@override | |
int get hashCode => x.hashCode ^ (y.hashCode + 42); | |
/// Length. | |
double get length => sqrt(length2); | |
/// Length squared. | |
double get length2 { | |
double sum; | |
sum = (x * x); | |
sum += (y * y); | |
return sum; | |
} | |
/// Add two vectors. | |
_Vector2 operator +(_Vector2 other) => _Vector2(x + other.x, y + other.y); | |
/// Negate. | |
_Vector2 operator -() => _Vector2(-x, -y); | |
/// Subtract two vectors. | |
_Vector2 operator -(_Vector2 other) => _Vector2(x - other.x, y - other.y); | |
/// Check if two vectors are the same. | |
@override | |
bool operator ==(Object other) => | |
(other is _Vector2) && (x == other.x) && (y == other.y); | |
/// Returns the angle between [this] vector and [other] in radians. | |
double angleTo(_Vector2 other) { | |
if (x == other.x && y == other.y) { | |
return 0.0; | |
} | |
final double d = dot(other) / (length * other.length); | |
return acos(d.clamp(-1.0, 1.0)); | |
} | |
/// Returns the signed angle between [this] and [other] in radians. | |
double angleToSigned(_Vector2 other) { | |
if (x == other.x && y == other.y) { | |
return 0.0; | |
} | |
final double s = cross(other); | |
final double c = dot(other); | |
return atan2(s, c); | |
} | |
/// Cross product. | |
double cross(_Vector2 other) { | |
return x * other.y - y * other.x; | |
} | |
/// Inner product. | |
double dot(_Vector2 other) { | |
return x * other.x + y * other.y; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment