Created
May 26, 2020 01:17
-
-
Save eseidel/260a8cb1545725ad31de7a9f4b9254d6 to your computer and use it in GitHub Desktop.
Zoolander boids
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:ui'; | |
import 'package:flutter/material.dart'; | |
import 'dart:math'; | |
const double kBoidVelocity = 5.0; | |
const double kBoidScale = 2.0; | |
const int kBoidCount = 100; | |
const double kBoidMaxAvoidSteerSpeed = .1; | |
const double kBoidMaxAlignSteerSpeed = .1; | |
// Governs how much the boids spread out at the start. | |
const double kInitialWorldSize = 1000; | |
const double kBoidSenseRadius = 200; | |
const double kBoidSenseAngle = .75 * pi; | |
const bool kEnableSeparation = false; | |
const bool kEnableAlignment = true; | |
void main() { | |
runApp(MyApp()); | |
} | |
class MyApp extends StatelessWidget { | |
@override | |
Widget build(BuildContext context) { | |
return MaterialApp( | |
title: 'Boids Demo', | |
theme: ThemeData( | |
primarySwatch: Colors.blue, | |
visualDensity: VisualDensity.adaptivePlatformDensity, | |
), | |
home: MyHomePage(), | |
); | |
} | |
} | |
class MyHomePage extends StatefulWidget { | |
MyHomePage({Key key}) : super(key: key); | |
@override | |
_MyHomePageState createState() => _MyHomePageState(); | |
} | |
class _MyHomePageState extends State<MyHomePage> | |
with SingleTickerProviderStateMixin { | |
World world; | |
void resetWorld() { | |
var random = Random(); | |
world.mobs = List.generate(kBoidCount, (int _) { | |
return Boid() | |
..velocity = kBoidVelocity | |
..position = Offset(world.lastKnownSize.width * random.nextDouble(), | |
world.lastKnownSize.height * random.nextDouble()) | |
..radians = random.nextDouble() * 2.0 * pi | |
..color = | |
Color.lerp(Colors.lightBlue, Colors.blue, random.nextDouble()); | |
}); | |
Boid focus = world.focusedMob; | |
focus.color = Colors.pink; | |
focus.showSight = true; | |
} | |
@override | |
void initState() { | |
super.initState(); | |
world = World(); | |
resetWorld(); | |
createTicker((Duration elapsed) { | |
setState(() { | |
world.tick(elapsed); | |
}); | |
}).start(); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return GestureDetector( | |
onTap: () => resetWorld(), | |
child: CustomPaint( | |
painter: WorldPainter(world), | |
child: SizedBox.expand(), | |
), | |
); | |
} | |
} | |
Offset constrainToSize(Offset offset, Size size) => | |
Offset(offset.dx % size.width, offset.dy % size.height); | |
class World { | |
List<Mob> mobs; | |
Size lastKnownSize = Size(kInitialWorldSize, kInitialWorldSize); | |
Mob get focusedMob => mobs.first; | |
void tick(Duration elapsed) { | |
// Move all mobs, if they're outside the bounds, wrap them. | |
mobs.forEach( | |
(mob) { | |
mob.tick(this); | |
if (!lastKnownSize.contains(mob.position)) | |
mob.position = constrainToSize(mob.position, lastKnownSize); | |
}, | |
); | |
// Plan the next move for all mobs, including debug details. | |
mobs.forEach((mob) { | |
mob.plan(this); | |
}); | |
if (kEnableAlignment) { | |
mobs.forEach((Mob mob) { | |
Boid boid = mob; | |
boid.showAngleVector = | |
focusedMob.inSensingArea(mob) || mob == focusedMob; | |
}); | |
} | |
// Paint comes after tick. | |
} | |
} | |
abstract class Mob { | |
Offset position = Offset.zero; | |
double radians; | |
double velocity; | |
Offset get velocityVector => Offset.fromDirection(radians, velocity); | |
bool inSensingArea(Mob mob) => false; | |
void paint(Canvas canvas, Size size); | |
void tick(World world); | |
void plan(World world); | |
} | |
class Boid extends Mob { | |
Color color; | |
Path _path = Path() | |
..moveTo(10, 0) | |
..lineTo(-5, -5) | |
..lineTo(-5, 5) | |
..close(); | |
bool showSight = false; | |
bool showAngleVector = false; | |
double nextSteeringChange = 0.0; | |
List<Offset> relativeVectorsForNearbyMobs; | |
@override | |
void tick(World world) { | |
radians += nextSteeringChange; | |
position += velocityVector; | |
} | |
double normalizeWithinPiToNegativePi(double radians) => | |
((radians + pi) % (2 * pi) - pi); | |
// TODO: This is current linear, quadratic might look better. | |
double steerIntensityForDistance(double distance) => | |
(1 - distance / kBoidSenseRadius); | |
@override | |
bool inSensingArea(Mob other) { | |
if (other == this) return false; | |
Offset offsetToOther = other.position - position; | |
if (offsetToOther.distance > kBoidSenseRadius) return false; | |
double relativeAngleToOther = | |
offsetToOther.direction - velocityVector.direction; | |
relativeAngleToOther = normalizeWithinPiToNegativePi(relativeAngleToOther); | |
// Ignore mobs outside our sense angle. | |
return (relativeAngleToOther.abs() <= kBoidSenseAngle); | |
} | |
List<Offset> collectRelativeVectorsForNearbyMobs(World world) { | |
List<Offset> nearbyVectors = <Offset>[]; | |
for (Mob other in world.mobs) { | |
if (other == this) continue; | |
Offset offsetToOther = other.position - position; | |
if (offsetToOther.distance > kBoidSenseRadius) continue; | |
double relativeAngleToOther = | |
offsetToOther.direction - velocityVector.direction; | |
relativeAngleToOther = | |
normalizeWithinPiToNegativePi(relativeAngleToOther); | |
// Ignore mobs outside our sense angle. | |
if (relativeAngleToOther.abs() > kBoidSenseAngle) continue; | |
Offset relativeVector = | |
Offset.fromDirection(relativeAngleToOther, offsetToOther.distance); | |
nearbyVectors.add(relativeVector); | |
} | |
return nearbyVectors; | |
} | |
double steerToSeparate() { | |
double totalAdjustment = 0.0; | |
// Instead of looping and summing, we could just steer away from the closest? | |
for (Offset relativeVector in relativeVectorsForNearbyMobs) { | |
// Steer away from the angle of the relative vector. | |
double angleAdjust = | |
-relativeVector.direction.sign * kBoidMaxAvoidSteerSpeed; | |
// At speed relative to how close the mob is. | |
angleAdjust *= steerIntensityForDistance(relativeVector.distance); | |
totalAdjustment += angleAdjust; | |
} | |
return totalAdjustment; | |
} | |
double steerIntensityForAngle(double angleDiff) => (angleDiff / pi); | |
double steerToAlign(World world) { | |
double averageAngle = 0.0; | |
int neighborCount = 0; | |
for (Mob mob in world.mobs) { | |
if (!inSensingArea(mob)) continue; | |
neighborCount += 1; | |
averageAngle += normalizeWithinPiToNegativePi(mob.radians); | |
} | |
if (neighborCount == 0) return 0.0; | |
averageAngle /= neighborCount; | |
double angleDiff = averageAngle - normalizeWithinPiToNegativePi(radians); | |
// If the diff is positive, steer positive. | |
// Apply a curved steering adjustment based on diff from average. | |
return angleDiff * | |
steerIntensityForAngle(angleDiff) * | |
kBoidMaxAvoidSteerSpeed; | |
} | |
@override | |
void plan(World world) { | |
nextSteeringChange = 0; | |
relativeVectorsForNearbyMobs = collectRelativeVectorsForNearbyMobs(world); | |
// Separation | |
if (kEnableSeparation) nextSteeringChange += steerToSeparate(); | |
// Alignment | |
if (kEnableAlignment) nextSteeringChange += steerToAlign(world); | |
// Cohesion | |
} | |
void paintSightArc(Canvas canvas) { | |
Paint circlePaint = Paint() | |
..color = Colors.grey.withOpacity(.1) | |
..strokeWidth = 3.0 | |
..style = PaintingStyle.fill; | |
var arcRect = Rect.fromCenter( | |
center: Offset.zero, | |
width: 2 * kBoidSenseRadius, | |
height: 2 * kBoidSenseRadius); | |
canvas.drawArc( | |
arcRect, -kBoidSenseAngle, 2 * kBoidSenseAngle, true, circlePaint); | |
} | |
void paintDistanceLines(Canvas canvas) { | |
for (Offset relativeVector in relativeVectorsForNearbyMobs) { | |
Paint obstaclePaint = Paint() | |
..color = Color.lerp(Colors.brown.withOpacity(.5), Colors.red, | |
steerIntensityForDistance(relativeVector.distance)) | |
..strokeWidth = 2.0 | |
..style = PaintingStyle.stroke; | |
canvas.drawLine(Offset.zero, relativeVector, obstaclePaint); | |
} | |
} | |
void paintAngle(Canvas canvas) { | |
Paint selfPaint = Paint() | |
..color = (showSight ? Colors.red : Colors.lightBlue) | |
..strokeWidth = 2.0 | |
..style = PaintingStyle.stroke; | |
canvas.drawLine(Offset.zero, Offset(velocity * 20, 0), selfPaint); | |
} | |
void paint(Canvas canvas, Size size) { | |
canvas.save(); | |
canvas.translate(position.dx, position.dy); | |
canvas.rotate(radians); | |
if (showSight) { | |
paintSightArc(canvas); | |
if (kEnableSeparation) paintDistanceLines(canvas); | |
} | |
if (showAngleVector) paintAngle(canvas); | |
canvas.scale(kBoidScale); | |
Paint trianglePaint = Paint() | |
..color = color | |
..strokeWidth = 3.0 | |
..style = PaintingStyle.fill; | |
canvas.drawPath(_path, trianglePaint); | |
canvas.restore(); | |
} | |
} | |
class WorldPainter extends CustomPainter { | |
final World world; | |
WorldPainter(this.world); | |
@override | |
void paint(Canvas canvas, Size size) { | |
// TODO: Remove the lastKnownSize hack. | |
world.lastKnownSize = size; | |
world.mobs.forEach((mob) { | |
mob.paint(canvas, size); | |
}); | |
} | |
@override | |
bool shouldRepaint(WorldPainter oldDelegate) => true; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment