52
loading...
This website collects cookies to deliver better user experience
You can find the code for the end result of Part 1 here
gh repo create flappigotchi --template="https://github.com/aavegotchi/aavegotchi-minigame-template.git"
cd flappigotchi
git pull origin main
If you are authenticated, you will be presented with various options for setting up your repo. Just press Enter to go through each step. You will now have your own flappigotchi repo setup on Github.
Flappigotchi
directory where you should see the following folder structure.cd flappigotchi/app
npm install
npm run start
or npm run start:offchain
depending if you want to connect to web3 and use your personal Aavegotchi's or not.app/package.json
with the following:"scripts": {
...
- "start:offchain": "REACT_APP_OFFCHAIN=true react-scripts start",
+ "start:offchain": "set REACT_APP_OFFCHAIN=true && react-scripts start",
...
},
npm install
to install the dependencies:cd flappigotchi/server
npm install
If this is your first time using Typescript in nodejs, then you will need to run npm i -g ts-node
to install ts-node
on your machine.
npm run start
to run the server on port 443.server/package.json
to the following:"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
- "start:prod": "NODE_ENV=production nodemon server.ts",
+ "start:prod": "set NODE_ENV=production && nodemon server.ts",
- "start": "NODE_ENV=development nodemon server.ts"
+ "start": "set NODE_ENV=development && nodemon server.ts"
},
Aavegotchi
and the Pipes
. Due to the magic of blockchain and more importantly Aavegotchi, the Aavegotchi svg comes straight from the Aavegotchi Smart Contract (Unless of-course you are using the default Aavegotchi, they just exist on your computer unfortunately).app/public/assets/sprites
directory so that the Phaser scenes have access to it.src/game/assets/index.ts
and assign a unique key that will reference the pipe spritesheet. This is used within the Phaser scenes to fetch the correct assets.SPRITESHEET
also require a frameWidth
and frameHeight
value. This is the size of the frame of each sprite. So seeing as our spritesheet is 80x217, but it is 1 tile across and 3 tiles down, we can set the frame data to frameWidth: 80 /1
and frameHeight: 217 / 3
.// src/game/assets/index.tsx
export interface Asset {
key: string;
src: string;
type: 'IMAGE' | 'SVG' | 'SPRITESHEET' | 'AUDIO';
data?: {
frameWidth?: number;
frameHeight?: number;
};
}
export interface SpritesheetAsset extends Asset {
type: 'SPRITESHEET';
data: {
frameWidth: number;
frameHeight: number;
};
}
export const BG = 'bg';
export const FULLSCREEN = 'fullscreen';
export const LEFT_CHEVRON = 'left_chevron';
export const CLICK = 'click';
export const PIPES = 'pipes';
// Save all in game assets in the public folder
export const assets: Array<Asset | SpritesheetAsset> = [
{
key: BG,
src: 'assets/images/bg.png',
type: 'IMAGE',
},
{
key: LEFT_CHEVRON,
src: 'assets/icons/chevron_left.svg',
type: 'SVG',
},
{
key: CLICK,
src: 'assets/sounds/click.mp3',
type: 'AUDIO',
},
{
key: PIPES,
src: 'assets/sprites/spritesheet.png',
type: 'SPRITESHEET',
data: {
frameWidth: 80 / 1,
frameHeight: 217 / 3,
}
}
];
assets
array to ensure each asset is loaded in before starting the game.game/objects
create a new typescript file called pipe.ts
and inside it put:// src/game/objects/pipe.ts
import { getGameHeight } from '../helpers';
import { PIPES } from 'game/assets';
export class Pipe extends Phaser.GameObjects.Image {
constructor(scene: Phaser.Scene) {
super(scene, -100, -100, PIPES, 0);
this.setOrigin(0, 0);
this.displayHeight = getGameHeight(scene) / 7;
this.displayWidth = getGameHeight(scene) / 7;
}
}
Phaser.GameObjects.Image
class. When this object is constructed the x and y value of its placement is both -100. This means that the pipe will be rendered off the screen. This is useful for object pooling as it means we can load in all the pipes we need at the start of the game and not have it interfere with the gameplay.game/objects/index.ts
so that we can import all our objects from the same file:// src/game/object/index.ts
export * from './pipe';
export * from './player';
group
within the create()
method:// src/game/scenes/game-scene.ts
...
import { Player, Pipe } from 'game/objects';
...
export class GameScene extends Phaser.Scene {
private player?: Player;
private selectedGotchi?: AavegotchiGameObject;
private pipes?: Phaser.GameObjects.Group;
...
public create(): void {
...
// Add pipes
this.pipes = this.add.group({
maxSize: 25,
classType: Pipe,
});
...
}
...
GameScene
called addPipeRow()
:// src/game/scenes/game-scene.ts
...
export class GameScene extends Phaser.Scene {
...
private addPipeRow = () => {
const size = getGameHeight(this) / 7;
const x = getGameWidth(this);
const velocityX = -getGameWidth(this) / 5;
const gap = Math.floor(Math.random() * 4) + 1;
for (let i = 0; i < 7; i++) {
if (i !== gap && i !== gap + 1) {
const frame = i === gap - 1 ? 2 : i === gap + 2 ? 0 : 1;
this.addPipe(x, size * i, frame, velocityX);
}
}
};
...
addPipe()
function:// src/game/scenes/game-scene.ts
...
export class GameScene extends Phaser.Scene {
...
private addPipe = (x: number, y: number, frame: number, velocityX: number): void => {
const pipe: Pipe = this.pipes?.get();
pipe.activate(x, y, frame, velocityX);
};
...
get()
method to pull an active pipe object from our object pool. We then need to activate it by setting its position
, frame
and velocity
.// src/game/objects/pipe.ts
...
export class Pipe extends Phaser.GameObjects.Image {
...
public activate = (x: number, y: number, frame: number, velocityX: number) => {
// Physics
this.scene.physics.world.enable(this);
(this.body as Phaser.Physics.Arcade.Body).setVelocityX(velocityX);
this.setPosition(x, y);
this.setFrame(frame);
}
}
addPipeRow()
method upon the games start as well as in intervals of 2 seconds:// src/game/scenes/game-scene.ts
...
export class GameScene extends Phaser.Scene {
...
public create(): void {
...
this.addPipeRow();
this.time.addEvent({
delay: 2000,
callback: this.addPipeRow,
callbackScope: this,
loop: true,
});
}
...
}
maxSize
of the pipe pool to 25. Therefore when our game tries activating pipe number 26, it cannot, as it does not exist. Therefore we need a way to deactivate our pipes and add them back into the pool.Pipe
class we need to add a method that allows the Pipe
to destroy itself upon going off the screen:// src/game/objects/pipe.ts
...
export class Pipe extends Phaser.GameObjects.Image {
...
public update = () => {
if (this.x < -2 * this.displayWidth) {
this.destroy()
}
}
}
GameScene
updates. To do this, inside the object where we constructed out group add runChildUpdate: true
:// src/game/scenes/game-scene.ts
// Add pipes
this.pipes = this.add.group({
maxSize: 25,
classType: Pipe,
runChildUpdate: true,
});
Player
to move up. However we only want this to occur on the initial press. So we also need an isFlapping
property that toggles on press and release.player.ts
with the following:// src/game/objects/player.ts
import { getGameHeight } from 'game/helpers';
interface Props {
scene: Phaser.Scene;
x: number;
y: number;
key: string;
frame?: number;
}
export class Player extends Phaser.GameObjects.Sprite {
private jumpKey: Phaser.Input.Keyboard.Key;
private pointer: Phaser.Input.Pointer;
private isFlapping = false;
constructor({ scene, x, y, key }: Props) {
super(scene, x, y, key);
// sprite
this.setOrigin(0, 0);
// physics
this.scene.physics.world.enable(this);
// input
this.jumpKey = this.scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
this.pointer = this.scene.input.activePointer;
this.scene.add.existing(this);
}
update(): void {
// handle input
if ((this.jumpKey.isDown || this.pointer.isDown) && !this.isFlapping) {
// flap
this.isFlapping = true;
(this.body as Phaser.Physics.Arcade.Body).setVelocityY(-getGameHeight(this.scene) * 0.6);
} else if (this.jumpKey.isUp && !this.pointer.isDown && this.isFlapping) {
this.isFlapping = false;
}
}
}
Player
will continue to fly up. This is because we have nothing to counteract the upwards velocity. For that we need to add gravity to our Player
. Luckily, Phaser handles the physics for us, we just need to apply it to our Player
like so:// src/game/objects/player.ts
...
export class Player extends Phaser.GameObjects.Sprite {
...
constructor({ scene, x, y, key }: Props) {
...
// physics
this.scene.physics.world.enable(this);
(this.body as Phaser.Physics.Arcade.Body).setGravityY(getGameHeight(this.scene) * 1.5);
...
}
...
}
constructSpritesheet()
function that takes a spriteMatrix
as an argument. We can add different poses to the spriteMatrix
using the customiseSvg()
function.boot-scene.ts
we are going to construct two animations for our Aavegotchi, one for flapping and one for death. (Don't worry, the Aavegotchi is a ghost so can't actually die).// src/game/scenes/boot-scene.ts
...
export class BootScene extends Phaser.Scene {
...
/**
* Constructs and loads in the Aavegotchi spritesheet, you can use customiseSvg() to create custom poses and animations
*/
private loadInGotchiSpritesheet = async (
gotchiObject: AavegotchiGameObject
) => {
const svg = gotchiObject.svg;
const spriteMatrix = [
// Flapping animation
[
customiseSvg(svg, {
armsUp: true,
removeBg: true,
removeShadow: true
}),
customiseSvg(svg, { removeBg: true, removeShadow: true }),
],
// Dead frame
[
customiseSvg(svg, { removeBg: true, removeShadow: true, eyes: 'sleeping', mouth: 'neutral' }),
]
];
const { src, dimensions } = await constructSpritesheet(spriteMatrix);
this.load.spritesheet(gotchiObject.spritesheetKey, src, {
frameWidth: dimensions.width / dimensions.x,
frameHeight: dimensions.height / dimensions.y,
});
this.load.start();
};
}
As you develop your game, you may want to add more frames into your Aavegotchi animation repertoire. To do that you can just add extra options into customiseSvg()
with your own custom SVG manipulations.
Player
object, let's add in our two new animations:// src/game/objects/player.ts
...
export class Player extends Phaser.GameObjects.Sprite {
...
constructor({ scene, x, y, key }: Props) {
super(scene, x, y, key);
// Animations
this.anims.create({
key: 'flap',
frames: this.anims.generateFrameNumbers(key || '', { frames: [ 1, 0 ]}),
frameRate: 2,
});
this.anims.create({
key: 'dead',
frames: this.anims.generateFrameNumbers(key || '', { frames: [ 2 ]}),
});
...
}
...
}
this.anims.play('ANI_KEY')
. We want to call the 'flap'
animation when the Player
flaps, and we want to call the 'dead'
animation upon death. For that we want to add in setDead()
method and call this method upon the Player
flying too high or too low.Player
class will look like this:// src/game/objects/player.ts
import { getGameHeight } from 'game/helpers';
interface Props {
scene: Phaser.Scene;
x: number;
y: number;
key: string;
frame?: number;
}
export class Player extends Phaser.GameObjects.Sprite {
private jumpKey: Phaser.Input.Keyboard.Key;
private pointer: Phaser.Input.Pointer;
private isFlapping = false;
private isDead = false;
constructor({ scene, x, y, key }: Props) {
super(scene, x, y, key);
// Animations
this.anims.create({
key: 'flap',
frames: this.anims.generateFrameNumbers(key || '', { frames: [ 1, 0 ]}),
frameRate: 2,
});
this.anims.create({
key: 'dead',
frames: this.anims.generateFrameNumbers(key || '', { frames: [ 2 ]}),
});
// physics
this.scene.physics.world.enable(this);
(this.body as Phaser.Physics.Arcade.Body).setGravityY(getGameHeight(this.scene) * 1.5);
(this.body as Phaser.Physics.Arcade.Body).setSize(90, 120);
// sprite
this.setOrigin(0, 0);
this.setDisplaySize(this.displayHeight * getGameHeight(scene) / 1200, this.displayHeight * getGameHeight(scene) / 1200);
// input
this.jumpKey = this.scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
this.pointer = this.scene.input.activePointer;
this.scene.add.existing(this);
}
public getDead(): boolean {
return this.isDead;
}
public setDead(dead: boolean): void {
this.isDead = dead;
this.anims.play('dead');
}
update(): void {
// handle input
if ((this.jumpKey.isDown || this.pointer.isDown) && !this.isFlapping) {
// flap
this.isFlapping = true;
this.anims.play('flap');
(this.body as Phaser.Physics.Arcade.Body).setVelocityY(-getGameHeight(this.scene) * 0.6);
} else if (this.jumpKey.isUp && !this.pointer.isDown && this.isFlapping) {
this.isFlapping = false;
}
// check if off the screen
if (this.y > getGameHeight(this.scene) || this.y < 0) {
this.setDead(true);
}
}
}
I also added a change to the body
size, to reduce the hitbox and make the game more fair. You will be able to see this by playing the game with debug mode on (this is on by default whilst running the app in development mode).
Player
and the Pipes
object:// src/game/scenes/game-scene.ts
...
export class GameScene extends Phaser.Scene {
...
public update(): void {
if (this.player && !this.player?.getDead()) {
this.player.update();
this.physics.overlap(
this.player,
this.pipes,
() => {
this.player?.setDead(true);
},
undefined,
this,
);
} else {
Phaser.Actions.Call(
(this.pipes as Phaser.GameObjects.Group).getChildren(),
(pipe) => {
(pipe.body as Phaser.Physics.Arcade.Body).setVelocityX(0);
},
this,
);
}
if (this.player && this.player.y > this.sys.canvas.height) {
window.history.back();
}
}
}
dead
using setDead(true)
. If the player is dead
, then we prevent the player from updating and set the pipes velocity to 0.GameOver
method, however for the scope of this tutorial we are using native Javascript to go back to the home screen.src/assets/sounds
we already have a bunch of mp3 files. So what we will do is copy boop.mp3
from src/assets/sounds
and paste it into the public/assets/sounds
directory. Like we did before with the pipes spritesheet, we can import our sound into the game by adding it to the assets
array in src/game/assets/index.tsx
:// src/game/assets/index.tsx
...
export const BOOP = 'boop';
export const assets: Array<Asset | SpritesheetAsset> = [
...
{
key: BOOP,
src: 'assets/sounds/boop.mp3',
type: 'AUDIO',
},
];
// src/game/scenes/game-scene.ts
import { LEFT_CHEVRON, BG, CLICK, BOOP } from 'game/assets';
...
export class GameScene extends Phaser.Scene {
...
// Sounds
private back?: Phaser.Sound.BaseSound;
private boop?: Phaser.Sound.BaseSound;
...
public create(): void {
...
this.back = this.sound.add(CLICK, { loop: false });
this.boop = this.sound.add(BOOP, { loop: false });
...
}
...
public update(): void {
if (this.player && !this.player?.getDead()) {
this.player.update();
this.physics.overlap(
this.player,
this.pipes,
() => {
this.player?.setDead(true);
this.boop?.play();
},
undefined,
this,
);
}
...
GameScene
. Then we can add a function to add to the score and edit the text like so:// src/game/scenes/game-scene.ts
...
export class GameScene extends Phaser.Scene {
...
// Score
private score = 0;
private scoreText?: Phaser.GameObjects.Text;
...
public create(): void {
...
this.scoreText = this.add
.text(getGameWidth(this) / 2, getGameHeight(this) / 2 - getRelative(190, this), this.score.toString(), {
color: '#FFFFFF',
})
.setFontSize(getRelative(94, this))
.setOrigin(0.5)
.setDepth(1);
...
}
...
private addScore = () => {
if (this.scoreText) {
this.score += 1;
this.scoreText.setText(this.score.toString());
}
};
...
To make sure we can see the score in front of the Pipes, we set the depth to 1.
addScore()
every time the Player
passes through the gaps in the pipes. Therefore we need to add an invisible game object called a Zone
:// src/game/objects/scoreZone.ts
interface Props {
scene: Phaser.Scene;
x: number;
y: number;
width: number;
height: number;
velocityX: number;
}
export class ScoreZone extends Phaser.GameObjects.Zone {
constructor({ scene, x, y, width, height, velocityX }: Props) {
super(scene, x, y);
this.setOrigin(0, 0);
this.displayHeight = height;
this.displayWidth = width;
// Physics
this.scene.physics.world.enable(this);
(this.body as Phaser.Physics.Arcade.Body).setVelocityX(velocityX);
this.scene.add.existing(this);
}
public handleOverlap = () => {
this.destroy();
}
}
GameScene
for the ScoreZone
. We then construct a ScoreZone
and add to this group for every gap in addPipeRow()
:/// src/game/objects/index.ts
export * from './player';
export * from './pipes';
export * from './scoreZone';
// src/game/scenes/game-scene.ts
...
import { Player, Pipe, ScoreZone } from 'game/objects';
...
export class GameScene extends Phaser.Scene {
...
private scoreZone?: Phaser.GameObjects.Group;
public create(): void {
...
this.scoreZone = this.add.group({ classType: ScoreZone });
this.addPipeRow();
...
}
private addPipeRow = () => {
const size = getGameHeight(this) / 7;
const x = getGameWidth(this);
const velocityX = -getGameWidth(this) / 5;
const gap = Math.floor(Math.random() * 4) + 1;
for (let i = 0; i < 7; i++) {
if (i !== gap && i !== gap + 1) {
const frame = i === gap - 1 ? 2 : i === gap + 2 ? 0 : 1;
this.addPipe(x, size * i, frame, velocityX);
} else if (i === gap) {
this.addScoreZone(x, size * i, velocityX);
}
}
};
private addScoreZone = (x: number, y: number, velocityX: number): void => {
const height = 2 * getGameHeight(this) / 7;
const width = getGameHeight(this) / 7;
this.scoreZone?.add(
new ScoreZone({
scene: this,
x,
y,
width,
height,
velocityX
})
)
}
...
}
The ScoreZone
is 2 times the height of the pipe to fill the space.
ScoreZone
being invisible, you will still be able to see the hit-box in debug mode.update()
method between the Player
and the ScoreZone
to add to score:// src/game/scenes/game-scene.ts
public update(): void {
if (this.player && !this.player?.getDead()) {
this.player.update();
this.physics.overlap(
this.player,
this.pipes,
() => {
this.player?.setDead(true);
this.boop?.play();
},
undefined,
this,
);
this.physics.overlap(
this.player,
this.scoreZone,
(_, zone) => {
(zone as ScoreZone).handleOverlap();
this.addScore();
}
)
}
...
}
ScoreZone
from the group upon coming into contact with it to avoid duplicate points.You can find the code for the end result of Part 1 here
Make sure to follow me @ccoyotedev or @gotchidevs on Twitter for updates on future tutorials.
If you have any questions about Aavegotchi or want to work with others to build Aavegotchi minigames, then join the Aavegotchi discord community where you can chat and collaborate with other Aavegotchi Aarchitects!
52