Created
April 18, 2022 21:00
-
-
Save spydon/3682f85300b2aaaf541f7f6d6670d5fe to your computer and use it in GitHub Desktop.
padracing
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 'dart:ui'; | |
import 'package:collection/collection.dart'; | |
import 'package:flame/components.dart'; | |
import 'package:flame/experimental.dart'; | |
import 'package:flame/extensions.dart'; | |
import 'package:flame/game.dart'; | |
import 'package:flame/input.dart'; | |
import 'package:flame/particles.dart'; | |
import 'package:flame_forge2d/flame_forge2d.dart' hide Particle, World; | |
import 'package:flutter/material.dart' hide Image; | |
import 'package:flutter/services.dart'; | |
class Background extends PositionComponent | |
with HasGameRef<PadRacingGame>, HasPaint { | |
Background() : super(priority: 0); | |
final Random rng = Random(1337); | |
late final Image _image; | |
@override | |
Future<void> onLoad() async { | |
final trackSize = PadRacingGame.trackSize; | |
paint..color = Colors.green; | |
final recorder = PictureRecorder(); | |
final canvas = Canvas(recorder, trackSize.toRect()); | |
final colors = [ | |
Colors.green.withAlpha(100), | |
Colors.brown.withAlpha(100), | |
Colors.lightGreen.withAlpha(100), | |
]; | |
for (var x = 0.0; x < trackSize.x; x += 0.2) { | |
for (var y = 0.0; y < trackSize.y; y += 0.2) { | |
paint | |
..color = (colors..shuffle(rng)).first | |
..darken(rng.nextDouble()); | |
canvas.drawCircle(Offset(x, y), 0.3, paint); | |
} | |
} | |
final picture = recorder.endRecording(); | |
_image = await picture.toImage(trackSize.x.toInt(), trackSize.y.toInt()); | |
} | |
final _whitePaint = Paint(); | |
@override | |
void render(Canvas canvas) { | |
canvas.drawImage(_image, Offset.zero, _whitePaint); | |
} | |
} | |
class Ball extends BodyComponent<PadRacingGame> { | |
static const radius = 80.0; | |
final Random rng = Random(); | |
late final Image _image; | |
late final Path _clipPath; | |
@override | |
Future<void> onLoad() async { | |
await super.onLoad(); | |
renderBody = false; | |
final trackSize = PadRacingGame.trackSize; | |
final recorder = PictureRecorder(); | |
final canvas = Canvas(recorder, trackSize.toRect()); | |
final colors = [ | |
Colors.lightBlue, | |
Colors.blue, | |
Colors.deepPurpleAccent, | |
]; | |
_clipPath = Path() | |
..addOval(Rect.fromCircle(center: Offset.zero, radius: radius)); | |
canvas.translate(radius, radius); | |
for (var angle = 0.0; angle < 2 * pi; angle += 0.05) { | |
canvas.rotate(0.05); | |
for (var x = radius; x > 0; x -= 0.2) { | |
paint | |
..color = (colors..shuffle(rng)).first | |
..darken(x / radius); | |
canvas.drawCircle(Offset(x, 0), 3, paint); | |
} | |
} | |
final picture = recorder.endRecording(); | |
_image = await picture.toImage((radius * 2).toInt(), (radius * 2).toInt()); | |
//gameRef.camera.followBodyComponent(this); | |
} | |
@override | |
Body createBody() { | |
paint..color = Colors.amber; | |
final startPosition = Vector2(200, 245); | |
final def = BodyDef() | |
..type = BodyType.kinematic | |
..position = startPosition; | |
final body = world.createBody(def)..angularVelocity = 1; | |
final shape = CircleShape()..radius = radius; | |
final fixtureDef = FixtureDef(shape) | |
..restitution = 0.5 | |
..friction = 0.5; | |
return body..createFixture(fixtureDef); | |
} | |
@override | |
void render(Canvas canvas) { | |
canvas.clipPath(_clipPath); | |
canvas.translate(-radius, -radius); | |
canvas.drawImage(_image, Offset.zero, paint); | |
} | |
} | |
class Car extends BodyComponent<PadRacingGame> { | |
Car({required this.playerNumber, required this.cameraComponent}) | |
: super(priority: 3); | |
static final colors = [ | |
Colors.orange, | |
Colors.lightBlue, | |
]; | |
final ValueNotifier<int> lap = ValueNotifier<int>(0); | |
late final TextComponent lapText; | |
final int playerNumber; | |
final Set<GroundSensor> passedStartControl = {}; | |
final CameraComponent cameraComponent; | |
final double _backTireMaxDriveForce = 300.0; | |
final double _frontTireMaxDriveForce = 500.0; | |
final double _backTireMaxLateralImpulse = 8.5; | |
final double _frontTireMaxLateralImpulse = 7.5; | |
late final Image _image; | |
final size = const Size(6, 10); | |
final scale = 10.0; | |
late final _renderPosition = -size.toOffset() / 2; | |
late final _scaledRect = (size * scale).toRect(); | |
late final _renderRect = _renderPosition & size; | |
final vertices = <Vector2>[ | |
Vector2(1.5, -5.0), | |
Vector2(3.0, -2.5), | |
Vector2(2.8, 0.5), | |
Vector2(1.0, 5.0), | |
Vector2(-1.0, 5.0), | |
Vector2(-2.8, 0.5), | |
Vector2(-3.0, -2.5), | |
Vector2(-1.5, -5.0), | |
]; | |
@override | |
Future<void> onLoad() async { | |
super.onLoad(); | |
lapText = TextComponent( | |
position: -cameraComponent.viewport.size / 2 + Vector2.all(20), | |
); | |
void updateLapText() { | |
lapText.text = 'Lap: ${lap.value}'; | |
} | |
lap.addListener(updateLapText); | |
updateLapText(); | |
cameraComponent.viewport.add(lapText); | |
final recorder = PictureRecorder(); | |
final canvas = Canvas(recorder, _scaledRect); | |
final path = Path(); | |
paint.color = colors[playerNumber]; | |
for (var i = 0.0; i < _scaledRect.width / 4; i++) { | |
paint.color = paint.color.darken(0.1); | |
path.reset(); | |
final offsetVertices = vertices | |
.map( | |
(v) => | |
v.toOffset() * scale - | |
Offset(i * v.x.sign, i * v.y.sign) + | |
_scaledRect.bottomRight / 2, | |
) | |
.toList(); | |
path.addPolygon(offsetVertices, true); | |
canvas.drawPath(path, paint); | |
} | |
final picture = recorder.endRecording(); | |
_image = await picture.toImage( | |
_scaledRect.width.toInt(), | |
_scaledRect.height.toInt(), | |
); | |
} | |
@override | |
Body createBody() { | |
paint..color = ColorExtension.random(); | |
final startPosition = | |
Vector2.all(20) + Vector2.all(20) * playerNumber.toDouble(); | |
final def = BodyDef() | |
..type = BodyType.dynamic | |
..position = startPosition; | |
final body = world.createBody(def) | |
..userData = this | |
..angularDamping = 3.0; | |
final shape = PolygonShape()..set(vertices); | |
final fixtureDef = FixtureDef(shape) | |
..density = 0.2 | |
..restitution = 2.0; | |
body.createFixture(fixtureDef); | |
final jointDef = RevoluteJointDef(); | |
jointDef.bodyA = body; | |
jointDef.enableLimit = true; | |
jointDef.lowerAngle = 0.0; | |
jointDef.upperAngle = 0.0; | |
jointDef.localAnchorB.setZero(); | |
final tires = List.generate(4, (i) { | |
final isFrontTire = i <= 1; | |
final isLeftTire = i.isEven; | |
return Tire( | |
gameRef.pressedKeys[playerNumber], | |
isFrontTire ? _frontTireMaxDriveForce : _backTireMaxDriveForce, | |
isFrontTire ? _frontTireMaxLateralImpulse : _backTireMaxLateralImpulse, | |
jointDef, | |
isFrontTire | |
? Vector2(isLeftTire ? -3.0 : 3.0, 3.5) | |
: Vector2(isLeftTire ? -3.0 : 3.0, -4.25), | |
isTurnableTire: isFrontTire, | |
); | |
}); | |
gameRef.cameraWorld.addAll(tires); | |
return body; | |
} | |
@override | |
void update(double dt) { | |
cameraComponent.viewfinder.position = body.position; | |
} | |
@override | |
void render(Canvas canvas) { | |
canvas.drawImageRect( | |
_image, | |
_scaledRect, | |
_renderRect, | |
paint, | |
); | |
} | |
} | |
class GroundSensor extends BodyComponent { | |
GroundSensor(this.position, this.size, this.isStart) : super(priority: 1); | |
final bool isStart; | |
final Vector2 position; | |
final Vector2 size; | |
late final Rect rect = size.toRect(); | |
@override | |
Body createBody() { | |
paint.color = | |
(isStart ? Colors.lightGreenAccent : Colors.red).withOpacity(0.5); | |
final groundBody = world.createBody( | |
BodyDef( | |
position: position, | |
userData: this, | |
), | |
); | |
final shape = PolygonShape()..setAsBoxXY(size.x / 2, size.y / 2); | |
final fixtureDef = FixtureDef(shape, isSensor: true); | |
return groundBody..createFixture(fixtureDef); | |
} | |
@override | |
void render(Canvas canvas) { | |
canvas.translate(-size.x / 2, -size.y / 2); | |
canvas.drawRect( | |
rect, | |
paint, | |
); | |
} | |
} | |
class CarContactCallback extends ContactCallback<Car, GroundSensor> { | |
@override | |
void begin(Car car, GroundSensor groundSensor, Contact contact) { | |
if (groundSensor.isStart) { | |
if (car.passedStartControl.contains(groundSensor)) { | |
// If the car has driven over one start control but then backed out | |
// again, to be able to go over the finish line. | |
car.passedStartControl.clear(); | |
print('Clearing'); | |
} | |
print('Adding'); | |
car.passedStartControl.add(groundSensor); | |
} else if (car.passedStartControl.length == 2) { | |
print('Finished'); | |
car.lap.value++; | |
car.passedStartControl.clear(); | |
} | |
} | |
} | |
void main() { | |
runApp(GameWidget(game: PadRacingGame())); | |
} | |
final List<Map<LogicalKeyboardKey, LogicalKeyboardKey>> playersKeys = [ | |
{ | |
LogicalKeyboardKey.arrowUp: LogicalKeyboardKey.arrowUp, | |
LogicalKeyboardKey.arrowDown: LogicalKeyboardKey.arrowDown, | |
LogicalKeyboardKey.arrowLeft: LogicalKeyboardKey.arrowLeft, | |
LogicalKeyboardKey.arrowRight: LogicalKeyboardKey.arrowRight, | |
}, | |
{ | |
LogicalKeyboardKey.keyW: LogicalKeyboardKey.arrowUp, | |
LogicalKeyboardKey.keyS: LogicalKeyboardKey.arrowDown, | |
LogicalKeyboardKey.keyA: LogicalKeyboardKey.arrowLeft, | |
LogicalKeyboardKey.keyD: LogicalKeyboardKey.arrowRight, | |
}, | |
]; | |
class PadRacingGame extends Forge2DGame with KeyboardEvents { | |
PadRacingGame() : super(gravity: Vector2.zero(), zoom: 1); | |
@override | |
Color backgroundColor() => Colors.grey.shade900; | |
static const numberOfPlayers = 2; | |
static Vector2 trackSize = Vector2.all(500); | |
late final World cameraWorld; | |
late final List<Map<LogicalKeyboardKey, LogicalKeyboardKey>> activeKeyMaps; | |
late final List<Set<LogicalKeyboardKey>> pressedKeys; | |
@override | |
Future<void> onLoad() async { | |
children.query(); | |
const numberOfPlayers = PadRacingGame.numberOfPlayers; | |
cameraWorld = World(); | |
await add(cameraWorld); | |
final viewportSize = Vector2(canvasSize.x / numberOfPlayers, canvasSize.y); | |
RectangleComponent viewportRimGenerator() => | |
RectangleComponent(size: viewportSize, anchor: Anchor.center) | |
..paint.color = Colors.blue | |
..paint.strokeWidth = 2.0 | |
..paint.style = PaintingStyle.stroke; | |
final cameras = List.generate( | |
numberOfPlayers, | |
(i) => CameraComponent( | |
world: cameraWorld, | |
viewport: FixedSizeViewport(viewportSize.x, viewportSize.y) | |
..position = Vector2( | |
(canvasSize.x / numberOfPlayers) * (i + 0.5), | |
canvasSize.y / 2, | |
) | |
..add(viewportRimGenerator()), | |
) | |
..viewfinder.anchor = Anchor.center | |
..viewfinder.zoom = 10, | |
); | |
await addAll(cameras); | |
cameraWorld.add(Background()); | |
cameraWorld.add(GroundSensor(Vector2(25, 50), Vector2(50, 5), true)); | |
cameraWorld.add(GroundSensor(Vector2(25, 70), Vector2(50, 5), true)); | |
cameraWorld.add(GroundSensor(Vector2(52.5, 25), Vector2(5, 50), false)); | |
cameraWorld.addAll(createWalls(trackSize)); | |
for (var i = 0; i < numberOfPlayers; i++) { | |
cameraWorld.add(Car(playerNumber: i, cameraComponent: cameras[i])); | |
} | |
cameraWorld.add(Ball()); | |
pressedKeys = List.generate(numberOfPlayers, (_) => {}); | |
activeKeyMaps = List.generate(numberOfPlayers, (i) => playersKeys[i]); | |
addContactCallback(CarContactCallback()); | |
} | |
@override | |
KeyEventResult onKeyEvent( | |
RawKeyEvent event, | |
Set<LogicalKeyboardKey> keysPressed, | |
) { | |
super.onKeyEvent(event, keysPressed); | |
if (!isLoaded) { | |
return KeyEventResult.ignored; | |
} | |
pressedKeys.forEach((e) => e.clear()); | |
keysPressed.forEach((LogicalKeyboardKey key) { | |
activeKeyMaps.forEachIndexed((i, keyMap) { | |
if (keyMap.containsKey(key)) { | |
pressedKeys[i].add(keyMap[key]!); | |
} | |
}); | |
}); | |
return KeyEventResult.handled; | |
} | |
} | |
class Tire extends BodyComponent<PadRacingGame> { | |
Tire( | |
this.pressedKeys, | |
this._maxDriveForce, | |
this._maxLateralImpulse, | |
this.jointDef, | |
this.jointAnchor, { | |
this.isTurnableTire = false, | |
}) : super(paint: Paint()..color = Colors.grey.shade700, priority: 2); | |
final size = Vector2(0.5, 1.25); | |
late final RRect _renderRect = RRect.fromLTRBR( | |
-size.x, | |
-size.y, | |
size.x, | |
size.y, | |
const Radius.circular(0.3), | |
); | |
final Set<LogicalKeyboardKey> pressedKeys; | |
final double _maxDriveForce; | |
final double _maxLateralImpulse; | |
double _currentTraction = 1.0; | |
final double _maxForwardSpeed = 250.0; | |
final double _maxBackwardSpeed = -40.0; | |
final RevoluteJointDef jointDef; | |
late final RevoluteJoint joint; | |
final Vector2 jointAnchor; | |
final bool isTurnableTire; | |
final double _lockAngle = 0.6; | |
final double _turnSpeedPerSecond = 4; | |
final random = Random(); | |
final Tween<double> noise = Tween(begin: -1, end: 1); | |
final ColorTween colorTween = ColorTween( | |
begin: Colors.brown, | |
end: Colors.black, | |
); | |
@override | |
Body createBody() { | |
final def = BodyDef()..type = BodyType.dynamic; | |
final body = world.createBody(def)..userData = this; | |
final polygonShape = PolygonShape(); | |
polygonShape.setAsBoxXY(0.5, 1.25); | |
final fixture = body.createFixtureFromShape(polygonShape, 1.0); | |
fixture.userData = this; | |
jointDef.bodyB = body; | |
jointDef.localAnchorA.setFrom(jointAnchor); | |
world.createJoint(joint = RevoluteJoint(jointDef)); | |
joint.setLimits(0, 0); | |
return body; | |
} | |
@override | |
void update(double dt) { | |
if (body.isAwake || pressedKeys.isNotEmpty) { | |
body.setAwake(true); | |
_updateTurn(dt); | |
_updateFriction(); | |
_updateDrive(); | |
if (body.linearVelocity.length2 > 100) { | |
gameRef.cameraWorld.add( | |
ParticleSystemComponent( | |
position: body.position, | |
particle: Particle.generate( | |
count: 8, | |
generator: (i) { | |
return AcceleratedParticle( | |
lifespan: 2, | |
speed: Vector2( | |
noise.transform(random.nextDouble()), | |
noise.transform(random.nextDouble()), | |
) * | |
i.toDouble(), | |
child: CircleParticle( | |
radius: 0.2, | |
paint: Paint() | |
..color = colorTween.transform(random.nextDouble())!, | |
), | |
); | |
}, | |
), | |
priority: 1, | |
), | |
); | |
} | |
} | |
} | |
@override | |
void render(Canvas canvas) { | |
canvas.drawRRect(_renderRect, paint); | |
} | |
void _updateFriction() { | |
final impulse = _lateralVelocity | |
..scale(-body.mass) | |
..clampScalar(-_maxLateralImpulse, _maxLateralImpulse) | |
..scale(_currentTraction); | |
body.applyLinearImpulse(impulse); | |
body.applyAngularImpulse( | |
0.1 * _currentTraction * body.getInertia() * -body.angularVelocity, | |
); | |
final currentForwardNormal = _forwardVelocity; | |
final currentForwardSpeed = currentForwardNormal.length; | |
currentForwardNormal.normalize(); | |
final dragForceMagnitude = -2 * currentForwardSpeed; | |
body.applyForce( | |
currentForwardNormal..scale(_currentTraction * dragForceMagnitude), | |
); | |
} | |
void _updateDrive() { | |
var desiredSpeed = 0.0; | |
if (pressedKeys.contains(LogicalKeyboardKey.arrowUp)) { | |
desiredSpeed = _maxForwardSpeed; | |
} | |
if (pressedKeys.contains(LogicalKeyboardKey.arrowDown)) { | |
desiredSpeed += _maxBackwardSpeed; | |
} | |
final currentForwardNormal = body.worldVector(Vector2(0.0, 1.0)); | |
final currentSpeed = _forwardVelocity.dot(currentForwardNormal); | |
var force = 0.0; | |
if (desiredSpeed < currentSpeed) { | |
force = -_maxDriveForce; | |
} else if (desiredSpeed > currentSpeed) { | |
force = _maxDriveForce; | |
} | |
if (force.abs() > 0) { | |
body.applyForce(currentForwardNormal..scale(_currentTraction * force)); | |
} | |
} | |
void _updateTurn(double dt) { | |
var desiredAngle = 0.0; | |
var desiredTorque = 0.0; | |
var isTurning = false; | |
if (pressedKeys.contains(LogicalKeyboardKey.arrowLeft)) { | |
desiredTorque = -15.0; | |
desiredAngle = -_lockAngle; | |
isTurning = true; | |
} | |
if (pressedKeys.contains(LogicalKeyboardKey.arrowRight)) { | |
desiredTorque += 15.0; | |
desiredAngle += _lockAngle; | |
isTurning = true; | |
} | |
if (isTurnableTire && isTurning) { | |
final turnPerTimeStep = _turnSpeedPerSecond * dt; | |
final angleNow = joint.jointAngle(); | |
final angleToTurn = (desiredAngle - angleNow) | |
.clamp(-turnPerTimeStep, turnPerTimeStep) | |
.toDouble(); | |
final angle = angleNow + angleToTurn; | |
joint.setLimits(angle, angle); | |
} else { | |
joint.setLimits(0, 0); | |
} | |
body.applyTorque(desiredTorque); | |
} | |
// Cached Vectors to reduce unnecessary object creation. | |
final Vector2 _worldLeft = Vector2(1.0, 0.0); | |
final Vector2 _worldUp = Vector2(0.0, -1.0); | |
Vector2 get _lateralVelocity { | |
final currentRightNormal = body.worldVector(_worldLeft); | |
return currentRightNormal | |
..scale(currentRightNormal.dot(body.linearVelocity)); | |
} | |
Vector2 get _forwardVelocity { | |
final currentForwardNormal = body.worldVector(_worldUp); | |
return currentForwardNormal | |
..scale(currentForwardNormal.dot(body.linearVelocity)); | |
} | |
} | |
List<Wall> createWalls(Vector2 size) { | |
final topCenter = Vector2(size.x / 2, 0); | |
final bottomCenter = Vector2(size.x / 2, size.y); | |
final leftCenter = Vector2(0, size.y / 2); | |
final rightCenter = Vector2(size.x, size.y / 2); | |
final filledSize = size.clone() + Vector2.all(5); | |
return [ | |
Wall(topCenter, Vector2(filledSize.x, 5)), | |
Wall(bottomCenter, Vector2(filledSize.y, 5)), | |
Wall(leftCenter, Vector2(5, filledSize.y)), | |
Wall(rightCenter, Vector2(5, filledSize.y)), | |
Wall(Vector2(52.5, 240), Vector2(5, 380)), | |
Wall(Vector2(200, 50), Vector2(300, 5)), | |
Wall(Vector2(72.5, 300), Vector2(5, 400)), | |
Wall(Vector2(180, 100), Vector2(220, 5)), | |
Wall(Vector2(350, 105), Vector2(5, 115)), | |
Wall(Vector2(350, 312.5), Vector2(5, 180)), | |
Wall(Vector2(310, 160), Vector2(240, 5)), | |
Wall(Vector2(210, 400), Vector2(280, 5)), | |
Wall(Vector2(430, 302.5), Vector2(5, 290)), | |
Wall(Vector2(292.5, 450), Vector2(280, 5)), | |
]; | |
} | |
class Wall extends BodyComponent<PadRacingGame> { | |
Wall(this.position, this.size) : super(priority: 3); | |
final Vector2 position; | |
final Vector2 size; | |
late final sizeRect = size.toRect(); | |
final Random rng = Random(); | |
late final Image _image; | |
@override | |
Future<void> onLoad() async { | |
await super.onLoad(); | |
paint..color = Colors.green; | |
final recorder = PictureRecorder(); | |
final canvas = Canvas(recorder, size.toRect()); | |
for (var x = 0.0; x < size.x; x += 0.2) { | |
for (var y = 0.0; y < size.y; y += 0.2) { | |
paint..color = paint.color.darken(rng.nextDouble() / 20); | |
paint..color = paint.color.brighten(rng.nextDouble() / 20); | |
canvas.drawCircle(Offset(x, y), 0.2, paint); | |
} | |
} | |
final picture = recorder.endRecording(); | |
_image = await picture.toImage(size.x.toInt(), size.y.toInt()); | |
} | |
@override | |
void render(Canvas canvas) { | |
canvas.translate(-size.x / 2, -size.y / 2); | |
canvas.drawImageRect( | |
_image, | |
sizeRect, | |
sizeRect, | |
//position.toPositionedRect(size), | |
paint, | |
); | |
} | |
@override | |
Body createBody() { | |
final def = BodyDef() | |
..type = BodyType.static | |
..position = position; | |
final body = world.createBody(def) | |
..userData = this | |
..angularDamping = 3.0; | |
final shape = PolygonShape()..setAsBoxXY(size.x / 2, size.y / 2); | |
final fixtureDef = FixtureDef(shape)..restitution = 0.5; | |
return body..createFixture(fixtureDef); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment