Log - Patrycioss/HellInSpace GitHub Wiki
- Spend 15-min to half an hour actually deciding what I want to make.
- First things first: I have no prior experience with game development in flutter. I've only made an app in flutter so I will need a little bit of time adapting to the way it works. I'll probably just set at least 2 hours for just learning what's going on in the Flame engine and setting up the project.
- Develop the actual game
- Confirm styling of code at the end: 15-30 min
I'm going to make a simple bullet hell. I inspected the flame website a little bit so I should be ready to start. I started by installing Android Studio as I don't do this kind of development that often I figured it would be nice to use it for this project. I installed the Flutter plugin and saw I needed and sdk so I installed it from the website. I then created a project and tried to install the Flame package. However I got an error about that my Dart sdk was the wrong version and a notification that I could install a newer version of Flutter. After installing this newer version I tried installing the package again and it worked 🥳.
The project started me with a default main.dart file with some standard code that worked in the browser. I however wanted to use the base example from Flame as a starting point. I copied one of the example main files into my main file which gave me some spinning rectangles.
I started making a "Player" class to see if I could draw my own thing on the screen and move it. For now I'll stick to the same "RectangleComponent" as they use. I noticed there's a "KeyboardHandler" that I can mixin with my player. I add my player to the world and it doesn't work 😔. After a bit of reading I see that my game needs to mixin "HasKeyboardHandlerComponents". I however have a world but not a custom game. Adding the game and removing the world since I don't know it's purpose at the moment, my main file looks like this now:
void main() {
runApp(
GameWidget(
game: MyGame(),
),
);
}
class MyGame extends FlameGame with HasKeyboardHandlerComponents{
@override
Future<void> onLoad() async {
add(Player(Vector2(10,10)));
}
}
class Player extends RectangleComponent with KeyboardHandler {
Player(Vector2 position)
: super(
position: position,
size: Vector2.all(128),
anchor: Anchor.center,
);
@override
bool onKeyEvent(KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
Vector2 direction = Vector2.zero();
if (keysPressed.contains(LogicalKeyboardKey.keyW)) {
direction.y -= 1;
}
if (keysPressed.contains(LogicalKeyboardKey.keyS)){
direction.y += 1;
}
if (keysPressed.contains(LogicalKeyboardKey.keyA)){
direction.x -= 1;
}
if (keysPressed.contains(LogicalKeyboardKey.keyD)){
direction.x += 1;
}
position += direction * 10;
return super.onKeyEvent(event, keysPressed);
}
}
This "keyPressed.contains" thing seems kind of annoying, from the event I can sadly only pull one key at a time however so it seems like this is the only option for now.
I also notice some delay with moving when I press the keys. From what I can read online this has to do with a built-in delay in Javascript. My solution around it for now is storing the inputs that I want to read in a map with a bool value set to false. In the "onKeyEvent" I then loop through the entries and set them according to what is given in "keysPressed".
final Map<LogicalKeyboardKey, bool> pressedKeys = {
LogicalKeyboardKey.keyW: false,
LogicalKeyboardKey.keyA: false,
LogicalKeyboardKey.keyS: false,
LogicalKeyboardKey.keyD: false,
};
@override
KeyEventResult onKeyEvent(
KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
for (LogicalKeyboardKey key in pressedKeys.keys){
pressedKeys[key] = keysPressed.contains(key);
}
return super.onKeyEvent(event, keysPressed);
}
In "update" I then check the value of them in the map, I had some problems with this because the value from the dictionary could be null and Dart forces me to check that. According to the official documentation it's fine to use the '!' in this case as I know for certain the key has to be there.
@override void update(double dt) {
Vector2 direction = Vector2.zero();
if (pressedKeys[LogicalKeyboardKey.keyW]!) {
direction.y -= 1;
}
if (pressedKeys[LogicalKeyboardKey.keyS]!) {
direction.y += 1;
}
if (pressedKeys[LogicalKeyboardKey.keyA]!){
direction.x -= 1;
}
if (pressedKeys[LogicalKeyboardKey.keyD]!){
direction.x += 1;
}
player.translate(direction * 1000 * dt);
super.update(dt);
}
I think it would be cooler if I could have like a little spaceship that you can fly around with steering and such. For this however I'll have to edit some things. Since the assessment mentioned Forge2D I looked into it and it seems to just be Box2D for Flame. I would like to use an actual physics body for my spaceship so that seems like a nice idea since I have a little bit of experience with using Box2D in a 2D game engine that I made in C++.
Reading the Forge2D documentation it seems like I'll have to turn my Game into a "Forge2DGame".
At this point however I had the realization that I hadn't created a git repository yet 🤦♂️.
class Player extends BodyComponent with KeyboardHandler {
final Map<LogicalKeyboardKey, bool> _pressedKeys = {
LogicalKeyboardKey.keyW: false,
LogicalKeyboardKey.keyA: false,
LogicalKeyboardKey.keyS: false,
LogicalKeyboardKey.keyD: false,
};
final BodyType _bodyType = BodyType.dynamic;
final Vector2 _position;
final double _radius = 10;
final double _impulseForce = 10000;
Player(this._position);
@override
Body createBody() {
final shape = CircleShape();
shape.radius = _radius;
final fixtureDef = FixtureDef(
shape,
friction: 1.0,
);
final bodyDef = BodyDef(
position: _position,
type: _bodyType,
linearDamping: 0.6,
);
final massData = MassData();
massData.mass = 10;
return world.createBody(bodyDef)
..createFixture(fixtureDef)
..setMassData(massData);
}
@override
bool onKeyEvent(KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
for (LogicalKeyboardKey key in _pressedKeys.keys) {
_pressedKeys[key] = keysPressed.contains(key);
}
return super.onKeyEvent(event, keysPressed);
}
@override
void update(double dt) {
Vector2 direction = Vector2.zero();
if (_pressedKeys[LogicalKeyboardKey.keyW]!) {
direction.y -= 1;
}
if (_pressedKeys[LogicalKeyboardKey.keyS]!) {
direction.y += 1;
}
if (_pressedKeys[LogicalKeyboardKey.keyA]!) {
direction.x -= 1;
}
if (_pressedKeys[LogicalKeyboardKey.keyD]!) {
direction.x += 1;
}
body.applyLinearImpulse(direction * _impulseForce * dt);
super.update(dt);
}
}
With some slight changes this is now my new player class which has nice floaty movement like what you'd expect from your little spaceship in space. The next thing I think I want to add is a sprite for the spaceship because that is generally nicer to look at.
After looking up some examples online and reading into how to use the "TexturePacker" I got a super simple (and not that pretty) spaceship sprite in the game. I got the original asset fromKenney's Simple Space Pack and edited it in Krita to have green and blue colours to better indicate that it's the player.
Here I load in the sprite atlas and get the player sprite and pass it to the player:
final atlas = await atlasFromAssets('HellInSpaceTextures.atlas');
final playerSprite = atlas.findSpriteByName('player');
assert(playerSprite != null);
player = Player(Vector2(50, 50), playerSprite);
I then added some simple code to turn the spaceship with the linear velocity of the body:
body.applyLinearImpulse(direction * _impulseForce * dt);
double targetAngle = atan2(body.linearVelocity.y, body.linearVelocity.x) +
(0.5 * pi);
body.setTransform(body.position, targetAngle);
body.angularVelocity = 0;
From some of the example projects I saw that they show the sprite with a different custom "SpriteComponent" that they then add as a child to their object. This is thus also what I did for my player.
class _PlayerSpriteComponent extends SpriteComponent {
_PlayerSpriteComponent(sprite) : super(anchor: Anchor.center, sprite: sprite);
}
Player(this._position, sprite)
: super(renderBody: false, children: [
_PlayerSpriteComponent(sprite),
]);
In this case I would imagine I could also construct a normal SpriteComponent in place of my own "_PlayerSpriteComponent" but I imagine this makes it easier to customize later on. I later decided to make my own animation for the player with thrusters. I don't really have pixel art experience so it was mostly just for fun and it is animated right now but the animation is not that noticeable 😅.
I then started to work on enemies for the player to dodge. I'm not sure if I actually want them to be enemies but I want them to be physics objects that at least move around the screen that the player has to dodge. I created an enemy class very similar to the player one and was happy to see that the player and the enemy easily collided. I then wanted to see if I could print something when this collision happened and looked into "ContactCallbacks" for that. I couldn't get this to work at first but the documentation told me I needed to add the instance of the class of the body to the body's userdata:
final bodyDef = BodyDef(
position: _position,
type: _bodyType,
linearDamping: 0.6,
userData: this,
);
Before using this ContactCallback I want something to display that the player takes damage from the enemy. Thus a health bar! !Pasted image 20241123005723.png I add "HeartVisuals" based on the maximum health that is given to the player. I then set the visuals to one of three versions of the heart sprite=
for (int i = 0; i < (_maxHealth / 2).ceil(); i++) {
_hearts.add(_HeartVisual(Vector2(gapBetweenHearts * i, 0), heartScale));
add(_hearts.last);
}
_updateHealthVisuals();
void setHealth(int health) {
print("Setting health from: $currentHealth to $health");
currentHealth = health;
_updateHealthVisuals();
}
void _updateHealthVisuals() {
int healthPool = currentHealth;
for (int i = 0; i < _hearts.length; i++) {
healthPool -= 2;
if (healthPool < 0) {
if (healthPool < -1) {
_hearts[i].sprite = sprites[0];
} else {
_hearts[i].sprite = sprites[1];
}
} else {
_hearts[i].sprite = sprites[2];
}
}
}
With a "onHealthChangeCallback" the player notifies the health bar of any changes to the health stored in the player.
int _health = 0;
set health(int value) {
if (value <= 0) {
_health = 0;
_onHealthChangeCallback(_health);
_onDeath(); // TODO
} else {
_health = value > _maxHealth ? _maxHealth : value;
_onHealthChangeCallback(_health);
}
}
int get health => _health;
After reading the requirements I read that I needed to use BLOC for game state. I then implemented BLOC for use with my player. It's a bit much to put here code-wise but my health bar now implements a "FlameBlocListenable" so I can do this in the health bar:
@override
bool listenWhen(PlayerState previousState, PlayerState newState) {
return previousState != newState;
}
@override
void onNewState(PlayerState state) {
updateHealthBar(state.health);
}
I have to admit that I forgot to write a bit in here so I will skip over some things. I did turn the code that I made for steering the spaceship into a mixin.
mixin Steering on BodyComponent {
Vector2 getSteeringDirection({
upPressed = false,
downPressed = false,
leftPressed = false,
rightPressed = false,
}) {
Vector2 direction = Vector2.zero();
if (upPressed) {
direction.y -= 1;
}
if (downPressed) {
direction.y += 1;
}
if (leftPressed) {
direction.x -= 1;
}
if (rightPressed) {
direction.x += 1;
}
return direction;
}
void handleRotation(Body body) {
double targetAngle =
atan2(body.linearVelocity.y, body.linearVelocity.x) + (0.5 * pi);
body.setTransform(body.position, targetAngle);
body.angularVelocity = 0;
}
}
Figuring that I wanted to make the enemies work to add challenge I decided to make an "EnemySpawner". I made the spawner so it spawns the enemy at a random position on the screen but not too close to the player. I did have to make an extension for calculating the distance between two vectors as Flame doesn't seem to have it which surprised me. It does have MathExtensions but there's only a DistanceTo implementation that doesn't take the square root. To my knowledge using sqrt doesn't have a significant performance impact anymore on most modern CPUs as it should just be a single CPU instruction.
void spawnEnemy() {
Vector2 position;
do {
position = Vector2(_random.nextDouble() * gameRef.canvasSize.x,
_random.nextDouble() * gameRef.canvasSize.y);
} while (position.distanceTo(hellInSpaceGame.player.position) <
GameSettings.minimumSpawnDistance);
Enemy enemy = Enemy(position);
add(enemy);
dev.log(
"Spawned Enemy of type: ${enemy.runtimeType.toString()} at: ${position}");
}
extension Math on Vector2 {
double distanceTo(Vector2 other) {
double v1 = x - other.x;
double v2 = y - other.y;
return sqrt((v1 * v1) + (v2 * v2));
}
}
After this I implemented EnemySettings and EnemyMoveBehaviours that I add to the enemies in the spawner to make different enemies. The enemies also have a timer that deletes them after the timer ends. The duration of the timer can be configured in the enemy settings.
abstract class EnemyMoveBehaviour extends Component {
void handleMovement(
double deltaTime, Body body, Player player, EnemySettings enemySettings);
}
class EnemySettings {
final String name;
final Duration lifeTime;
final double moveSpeed;
final bool breaksOnAnyCollision;
final int strength;
final double width;
final double height;
const EnemySettings(
this.name, {
Duration? lifeTime,
double? moveSpeed,
bool? breaksOnAnyCollision,
int? strength,
double? width,
double? height,
}) : lifeTime = lifeTime ?? const Duration(seconds: 10),
moveSpeed = moveSpeed ?? 700,
breaksOnAnyCollision = breaksOnAnyCollision ?? true,
strength = strength ?? 1,
width = width ?? 10,
height = height ?? 10;
}
These settings and behaviours can be added in the "GameSettings" file.
I then made my own "Component Pool" which can keep track of Components to reuse and added a "Resettable" class that the components then can implement to reset.
abstract class Resettable {
void reset();
}
class ComponentPool<T extends Component> {
final List<T> _componentsReady = [];
final T Function() _createComponentCallback;
int _maxComponentCount = 200;
ComponentPool(this._createComponentCallback);
T getComponent() {
if (_componentsReady.isEmpty) {
return _createComponentCallback();
} else {
return _componentsReady.removeLast();
}
}
int get componentsAvailable => _componentsReady.length;
void setMaxComponentCount(int count) {
_maxComponentCount = count;
}
void returnComponent(T component) {
(component as Resettable).reset();
if (_componentsReady.length < _maxComponentCount) {
_componentsReady.add(component);
}
}
}
I now use this for all the Enemy spawning in the Enemy Spawner.
After this I implemented a simple way to reset the game by forcing the GameWidget to be rebuilt. I provide the Game with a callback to call when it wants to reset itself. The callback just increments a number in the state of my stateful "GamePage" widget.
I then found a nasty bug where the enemies didn't work when they were reused from the pool. This turned out to be the case because the physic bodies remove themselves from the world, now I reconstruct the bodies in the "onMount" function.
I then implemented some wave spawning in the spawner so I don't have to manually spawn everything. I also added a decorator for the player so when the player is hit and has invincibility the player turns red.
After this I implemented a screen shake effect that lasts as long as the player is invulnerable.
The mixin "HasGameRef" only works for classes that extend from "FlameGame<World>" which was a problem because my game extends from "Forge2DGame<Forge2DWorld>". To circumvent this issue I used to make an extra "HellInSpaceGame" variable that I would initialize by casting the "gameRef" to my game. I finally decided to make my own mixin to solve this issue which reuses the techniques from the original mixin but fixed so it works with my class:
mixin HasHellInSpaceGameRef on Component {
HellInSpaceGame? _game;
HellInSpaceGame get game => _game ??= _findGame();
set hellInSpaceGame(HellInSpaceGame? value) => _game = value;
HellInSpaceGame get gameRef => game;
late final HellInSpaceGame hellInSpaceGameRef;
@override
Future<void> onLoad() async {
await super.onLoad();
hellInSpaceGameRef = game;
}
HellInSpaceGame _findGame() {
FlameGame<World>? game = super.findGame();
assert(
game != null,
"Could not find Game instance: the component is "
"detached from the component tree");
return game as HellInSpaceGame;
}
}
I also made a special component that listens for whether the player has died. When the player dies the game is ended.
I decided to also make a special "InputManager" component that handles all the input across the game. I can also add actions with keys into the input manager.
I then implemented two different end screens based on whether you won or not. They have a button that gets bigger when you hover over it. I've done this through effects.
I noticed one of the requirements was to use a State pattern so I decided to turn the code that makes my Player invulnerable into a separate state.
abstract class _PlayerState {
bool get canBeHit;
void start(Player player);
void update(Player player, double dt);
void stop(Player player);
}
class _RegularPlayerState extends _PlayerState {
@override
bool get canBeHit => true;
@override
void start(Player player) {}
@override
void stop(Player player) {}
@override
void update(Player player, double dt) {}
}
class _InvulnerablePlayerState extends _PlayerState {
final void Function() _onEndCallback;
_InvulnerablePlayerState(this._onEndCallback);
@override
bool get canBeHit => false;
@override
void start(Player player) {
async.Timer(GameSettings.invincibilityDuration, _onEndCallback);
player._spriteComponent.decorator
.addLast(PaintDecorator.tint(const Color.fromARGB(180, 255, 1, 1)));
}
@override
void update(Player player, double dt) {}
@override
void stop(Player player) {
player._spriteComponent.decorator.removeLast();
}
}
if (_activeState.canBeHit) {
// Taking damage
_setState(
_InvulnerablePlayerState(() => _setState(_RegularPlayerState())));
// Playing effects: screenshake, audio, particle
}
With the implementation of the audio I had some issues, especially with the background music not playing because of chrome. So I made a small wrapper around flames functionality to be able to retry playing music and catching any errors that are thrown.
static Future<void> _tryPlayMusic(String file,
{bool hasToStart = false, double volume = 1.0}) async {
try {
await FlameAudio.bgm.play(file, volume: volume);
} catch (e) {
if (hasToStart) {
Timer(_checkInterval, () {
_tryPlayMusic(file, hasToStart: hasToStart, volume: volume);
});
}
}
}
static Future<void> _tryPlaySound(String file,
{bool hasToStart = false, double volume = 1.0}) async {
try {
await FlameAudio.play(file, volume: volume);
} catch (e) {
if (hasToStart) {
Timer(_checkInterval, () {
_tryPlaySound(file, hasToStart: hasToStart, volume: volume);
});
}
}
}
Then the task came of getting it working on GitHub pages. I wanted to use workflows and luckily I still had some things lying around from another project so I could use that as reference even though that was in C++ with Emscripten.
After some tinkering and a lot of failed runs this is what I ended up with:
name: Deploy to GitHub Pages
on:
workflow_dispatch:
push:
branches:
- master
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
build-and-deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Pages
uses: actions/configure-pages@v5
- uses: subosito/flutter-action@v1
with:
channel: stable
flutter-version: 3.24.5
- name: Set Flutter Beta Channel
run: flutter channel beta
- name: Upgrade Flutter
run: flutter upgrade
- name: Enable Flutter Web
run: flutter config --enable-web
- name: Download Dependencies
run: flutter pub get
- name: Add Web App?
run: flutter create .
- name: Build
run: flutter build web --release --base-href="/HellInSpace/"
- name: Upload Artifact
uses: actions/upload-pages-artifact@v3
with:
path: 'build/web'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
There is probably a way to make this better, include some caching for example to make it faster. But I don't care about that right now.