+
+
+
+
\ No newline at end of file
diff --git a/apps/games/bit-jumper/project.json b/apps/games/bit-jumper/project.json
new file mode 100644
index 00000000..840f4434
--- /dev/null
+++ b/apps/games/bit-jumper/project.json
@@ -0,0 +1,9 @@
+{
+ "name": "games-bit-jumper",
+ "$schema": "../../../node_modules/nx/schemas/project-schema.json",
+ "projectType": "application",
+ "sourceRoot": "apps/games/bit-jumper/src",
+ "tags": [],
+ "// targets": "to see all targets run: nx show project games-bit-jumper --web",
+ "targets": {}
+}
diff --git a/apps/games/bit-jumper/public/assets/achievements/1000.png b/apps/games/bit-jumper/public/assets/achievements/1000.png
new file mode 100644
index 00000000..90fcf64d
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/achievements/1000.png differ
diff --git a/apps/games/bit-jumper/public/assets/achievements/10000.png b/apps/games/bit-jumper/public/assets/achievements/10000.png
new file mode 100644
index 00000000..e867cb43
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/achievements/10000.png differ
diff --git a/apps/games/bit-jumper/public/assets/achievements/15000.png b/apps/games/bit-jumper/public/assets/achievements/15000.png
new file mode 100644
index 00000000..4e6aaced
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/achievements/15000.png differ
diff --git a/apps/games/bit-jumper/public/assets/achievements/20000.png b/apps/games/bit-jumper/public/assets/achievements/20000.png
new file mode 100644
index 00000000..d74ccdca
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/achievements/20000.png differ
diff --git a/apps/games/bit-jumper/public/assets/achievements/2500.png b/apps/games/bit-jumper/public/assets/achievements/2500.png
new file mode 100644
index 00000000..f8a96f2f
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/achievements/2500.png differ
diff --git a/apps/games/bit-jumper/public/assets/achievements/5000.png b/apps/games/bit-jumper/public/assets/achievements/5000.png
new file mode 100644
index 00000000..17dfa1e6
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/achievements/5000.png differ
diff --git a/apps/games/bit-jumper/public/assets/achievements/7500.png b/apps/games/bit-jumper/public/assets/achievements/7500.png
new file mode 100644
index 00000000..3afa0f48
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/achievements/7500.png differ
diff --git a/apps/games/bit-jumper/public/assets/achievements/balloon.png b/apps/games/bit-jumper/public/assets/achievements/balloon.png
new file mode 100644
index 00000000..3a3da9c6
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/achievements/balloon.png differ
diff --git a/apps/games/bit-jumper/public/assets/achievements/chomper.png b/apps/games/bit-jumper/public/assets/achievements/chomper.png
new file mode 100644
index 00000000..4e3221c3
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/achievements/chomper.png differ
diff --git a/apps/games/bit-jumper/public/assets/achievements/floater.png b/apps/games/bit-jumper/public/assets/achievements/floater.png
new file mode 100644
index 00000000..a588c407
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/achievements/floater.png differ
diff --git a/apps/games/bit-jumper/public/assets/achievements/helicopter.png b/apps/games/bit-jumper/public/assets/achievements/helicopter.png
new file mode 100644
index 00000000..7af11b01
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/achievements/helicopter.png differ
diff --git a/apps/games/bit-jumper/public/assets/achievements/rocket.png b/apps/games/bit-jumper/public/assets/achievements/rocket.png
new file mode 100644
index 00000000..4f638607
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/achievements/rocket.png differ
diff --git a/apps/games/bit-jumper/public/assets/achievements/smasher.png b/apps/games/bit-jumper/public/assets/achievements/smasher.png
new file mode 100644
index 00000000..ab103edb
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/achievements/smasher.png differ
diff --git a/apps/games/bit-jumper/public/assets/achievements/spiker.png b/apps/games/bit-jumper/public/assets/achievements/spiker.png
new file mode 100644
index 00000000..45d8716b
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/achievements/spiker.png differ
diff --git a/apps/games/bit-jumper/public/assets/audio/balloon_end.mp3 b/apps/games/bit-jumper/public/assets/audio/balloon_end.mp3
new file mode 100644
index 00000000..bd2bbd04
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/audio/balloon_end.mp3 differ
diff --git a/apps/games/bit-jumper/public/assets/audio/balloon_start.mp3 b/apps/games/bit-jumper/public/assets/audio/balloon_start.mp3
new file mode 100644
index 00000000..23315e03
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/audio/balloon_start.mp3 differ
diff --git a/apps/games/bit-jumper/public/assets/audio/break.mp3 b/apps/games/bit-jumper/public/assets/audio/break.mp3
new file mode 100644
index 00000000..ac260313
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/audio/break.mp3 differ
diff --git a/apps/games/bit-jumper/public/assets/audio/death.mp3 b/apps/games/bit-jumper/public/assets/audio/death.mp3
new file mode 100644
index 00000000..e5c95f4f
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/audio/death.mp3 differ
diff --git a/apps/games/bit-jumper/public/assets/audio/helicopter_end.mp3 b/apps/games/bit-jumper/public/assets/audio/helicopter_end.mp3
new file mode 100644
index 00000000..1320b715
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/audio/helicopter_end.mp3 differ
diff --git a/apps/games/bit-jumper/public/assets/audio/helicopter_start.mp3 b/apps/games/bit-jumper/public/assets/audio/helicopter_start.mp3
new file mode 100644
index 00000000..90aefc8f
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/audio/helicopter_start.mp3 differ
diff --git a/apps/games/bit-jumper/public/assets/audio/jump.mp3 b/apps/games/bit-jumper/public/assets/audio/jump.mp3
new file mode 100644
index 00000000..544e60b5
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/audio/jump.mp3 differ
diff --git a/apps/games/bit-jumper/public/assets/audio/menu.mp3 b/apps/games/bit-jumper/public/assets/audio/menu.mp3
new file mode 100644
index 00000000..31a613ef
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/audio/menu.mp3 differ
diff --git a/apps/games/bit-jumper/public/assets/audio/rocket_end.mp3 b/apps/games/bit-jumper/public/assets/audio/rocket_end.mp3
new file mode 100644
index 00000000..24411cbd
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/audio/rocket_end.mp3 differ
diff --git a/apps/games/bit-jumper/public/assets/audio/rocket_start.mp3 b/apps/games/bit-jumper/public/assets/audio/rocket_start.mp3
new file mode 100644
index 00000000..0126a67c
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/audio/rocket_start.mp3 differ
diff --git a/apps/games/bit-jumper/public/assets/audio/smash.mp3 b/apps/games/bit-jumper/public/assets/audio/smash.mp3
new file mode 100644
index 00000000..7602ab7f
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/audio/smash.mp3 differ
diff --git a/apps/games/bit-jumper/public/assets/audio/spring.mp3 b/apps/games/bit-jumper/public/assets/audio/spring.mp3
new file mode 100644
index 00000000..f20719d4
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/audio/spring.mp3 differ
diff --git a/apps/games/bit-jumper/public/assets/fonts/frog-block.ttf b/apps/games/bit-jumper/public/assets/fonts/frog-block.ttf
new file mode 100644
index 00000000..0e487a24
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/fonts/frog-block.ttf differ
diff --git a/apps/games/bit-jumper/public/assets/fonts/license.txt b/apps/games/bit-jumper/public/assets/fonts/license.txt
new file mode 100644
index 00000000..354f1e04
--- /dev/null
+++ b/apps/games/bit-jumper/public/assets/fonts/license.txt
@@ -0,0 +1,121 @@
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+ CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+ LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+ ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+ INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+ REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+ PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+ THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+ HEREUNDER.
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator
+and subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for
+the purpose of contributing to a commons of creative, cultural and
+scientific works ("Commons") that the public can reliably and without fear
+of later claims of infringement build upon, modify, incorporate in other
+works, reuse and redistribute as freely as possible in any form whatsoever
+and for any purposes, including without limitation commercial purposes.
+These owners may contribute to the Commons to promote the ideal of a free
+culture and the further production of creative, cultural and scientific
+works, or to gain reputation or greater distribution for their Work in
+part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any
+expectation of additional consideration or compensation, the person
+associating CC0 with a Work (the "Affirmer"), to the extent that he or she
+is an owner of Copyright and Related Rights in the Work, voluntarily
+elects to apply CC0 to the Work and publicly distribute the Work under its
+terms, with knowledge of his or her Copyright and Related Rights in the
+Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not
+limited to, the following:
+
+ i. the right to reproduce, adapt, distribute, perform, display,
+ communicate, and translate a Work;
+ ii. moral rights retained by the original author(s) and/or performer(s);
+iii. publicity and privacy rights pertaining to a person's image or
+ likeness depicted in a Work;
+ iv. rights protecting against unfair competition in regards to a Work,
+ subject to the limitations in paragraph 4(a), below;
+ v. rights protecting the extraction, dissemination, use and reuse of data
+ in a Work;
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+ European Parliament and of the Council of 11 March 1996 on the legal
+ protection of databases, and under any national implementation
+ thereof, including any amended or successor version of such
+ directive); and
+vii. other similar, equivalent or corresponding rights throughout the
+ world based on applicable law or treaty, and any national
+ implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention
+of, applicable law, Affirmer hereby overtly, fully, permanently,
+irrevocably and unconditionally waives, abandons, and surrenders all of
+Affirmer's Copyright and Related Rights and associated claims and causes
+of action, whether now known or unknown (including existing as well as
+future claims and causes of action), in the Work (i) in all territories
+worldwide, (ii) for the maximum duration provided by applicable law or
+treaty (including future time extensions), (iii) in any current or future
+medium and for any number of copies, and (iv) for any purpose whatsoever,
+including without limitation commercial, advertising or promotional
+purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
+member of the public at large and to the detriment of Affirmer's heirs and
+successors, fully intending that such Waiver shall not be subject to
+revocation, rescission, cancellation, termination, or any other legal or
+equitable action to disrupt the quiet enjoyment of the Work by the public
+as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason
+be judged legally invalid or ineffective under applicable law, then the
+Waiver shall be preserved to the maximum extent permitted taking into
+account Affirmer's express Statement of Purpose. In addition, to the
+extent the Waiver is so judged Affirmer hereby grants to each affected
+person a royalty-free, non transferable, non sublicensable, non exclusive,
+irrevocable and unconditional license to exercise Affirmer's Copyright and
+Related Rights in the Work (i) in all territories worldwide, (ii) for the
+maximum duration provided by applicable law or treaty (including future
+time extensions), (iii) in any current or future medium and for any number
+of copies, and (iv) for any purpose whatsoever, including without
+limitation commercial, advertising or promotional purposes (the
+"License"). The License shall be deemed effective as of the date CC0 was
+applied by Affirmer to the Work. Should any part of the License for any
+reason be judged legally invalid or ineffective under applicable law, such
+partial invalidity or ineffectiveness shall not invalidate the remainder
+of the License, and in such case Affirmer hereby affirms that he or she
+will not (i) exercise any of his or her remaining Copyright and Related
+Rights in the Work or (ii) assert any associated claims and causes of
+action with respect to the Work, in either case contrary to Affirmer's
+express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+ surrendered, licensed or otherwise affected by this document.
+ b. Affirmer offers the Work as-is and makes no representations or
+ warranties of any kind concerning the Work, express, implied,
+ statutory or otherwise, including without limitation warranties of
+ title, merchantability, fitness for a particular purpose, non
+ infringement, or the absence of latent or other defects, accuracy, or
+ the present or absence of errors, whether or not discoverable, all to
+ the greatest extent permissible under applicable law.
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+ that may apply to the Work or any use thereof, including without
+ limitation any person's Copyright and Related Rights in the Work.
+ Further, Affirmer disclaims responsibility for obtaining any necessary
+ consents, permissions or other rights required for any use of the
+ Work.
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+ party to this document and has no duty or obligation with respect to
+ this CC0 or use of the Work.
diff --git a/apps/games/bit-jumper/public/assets/icons/back.png b/apps/games/bit-jumper/public/assets/icons/back.png
new file mode 100644
index 00000000..2d1f0458
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/icons/back.png differ
diff --git a/apps/games/bit-jumper/public/assets/icons/heart.png b/apps/games/bit-jumper/public/assets/icons/heart.png
new file mode 100644
index 00000000..c56a338d
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/icons/heart.png differ
diff --git a/apps/games/bit-jumper/public/assets/icons/sound_off.png b/apps/games/bit-jumper/public/assets/icons/sound_off.png
new file mode 100644
index 00000000..ed55f95a
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/icons/sound_off.png differ
diff --git a/apps/games/bit-jumper/public/assets/icons/sound_on.png b/apps/games/bit-jumper/public/assets/icons/sound_on.png
new file mode 100644
index 00000000..886cab7e
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/icons/sound_on.png differ
diff --git a/apps/games/bit-jumper/public/assets/media/banner.png b/apps/games/bit-jumper/public/assets/media/banner.png
new file mode 100644
index 00000000..c8e3009d
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/media/banner.png differ
diff --git a/apps/games/bit-jumper/public/assets/media/thumbnail.png b/apps/games/bit-jumper/public/assets/media/thumbnail.png
new file mode 100644
index 00000000..ee04edf4
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/media/thumbnail.png differ
diff --git a/apps/games/bit-jumper/public/assets/particles/dot.png b/apps/games/bit-jumper/public/assets/particles/dot.png
new file mode 100644
index 00000000..668fe4fc
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/particles/dot.png differ
diff --git a/apps/games/bit-jumper/public/assets/particles/ring.png b/apps/games/bit-jumper/public/assets/particles/ring.png
new file mode 100644
index 00000000..ed4590ab
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/particles/ring.png differ
diff --git a/apps/games/bit-jumper/public/assets/sprites/infinite_jumper.png b/apps/games/bit-jumper/public/assets/sprites/infinite_jumper.png
new file mode 100644
index 00000000..191f5247
Binary files /dev/null and b/apps/games/bit-jumper/public/assets/sprites/infinite_jumper.png differ
diff --git a/apps/games/bit-jumper/public/favicon.ico b/apps/games/bit-jumper/public/favicon.ico
new file mode 100644
index 00000000..317ebcb2
Binary files /dev/null and b/apps/games/bit-jumper/public/favicon.ico differ
diff --git a/apps/games/bit-jumper/public/sources.txt b/apps/games/bit-jumper/public/sources.txt
new file mode 100644
index 00000000..3faf2c37
--- /dev/null
+++ b/apps/games/bit-jumper/public/sources.txt
@@ -0,0 +1,3 @@
+https://i-am-44.itch.io/free-1-bit-infinite-jumper-assets
+https://polyducks.itch.io/frogblock
+https://coloralpha.itch.io/50-menu-interface-sfx
\ No newline at end of file
diff --git a/apps/games/bit-jumper/src/lib/data.ts b/apps/games/bit-jumper/src/lib/data.ts
new file mode 100644
index 00000000..63ac1b4f
--- /dev/null
+++ b/apps/games/bit-jumper/src/lib/data.ts
@@ -0,0 +1,15 @@
+export const DEPTHS = {
+ UI: 100,
+ PLAYER: 10,
+ ENEMY: 7,
+ PLATFORM: 5,
+ POWER_UP: 3,
+};
+export const OUT_OF_BOUNDS = { x: -100, y: -100 };
+
+export enum StorageKey {
+ HIGH_SCORE = 'high_score',
+ MUTE = 'mute-sound',
+}
+
+export const VERSION = 'v1.0.2';
diff --git a/apps/games/bit-jumper/src/lib/objects/collisions.ts b/apps/games/bit-jumper/src/lib/objects/collisions.ts
new file mode 100644
index 00000000..b4b285ca
--- /dev/null
+++ b/apps/games/bit-jumper/src/lib/objects/collisions.ts
@@ -0,0 +1,123 @@
+import { Enemies, Enemy } from './enemies';
+import { Particles } from './particles';
+import { BreakingPlatform, Platform, Platforms, Spring } from './platforms';
+import { Player } from './player';
+import { Balloon, Helicopter, PowerUp, PowerUps, Rocket } from './power-ups';
+
+export class Collisions {
+ scene: Phaser.Scene;
+ powerUps: PowerUps;
+ constructor(
+ scene: Phaser.Scene,
+ player: Player,
+ platforms: Platforms,
+ powerUps: PowerUps,
+ enemies: Enemies
+ ) {
+ this.scene = scene;
+ scene.physics.add.collider(
+ player,
+ platforms,
+ this.onPlatformsCollide,
+ undefined,
+ this
+ );
+
+ scene.physics.add.overlap(
+ player,
+ powerUps,
+ this.onPowerUpsOverlap,
+ undefined,
+ this
+ );
+
+ scene.physics.add.overlap(
+ player,
+ enemies,
+ this.onEnemiesOverlap,
+ undefined,
+ this
+ );
+ }
+
+ onPlatformsCollide(
+ a: Phaser.Types.Physics.Arcade.GameObjectWithBody | Phaser.Tilemaps.Tile,
+ b: Phaser.Types.Physics.Arcade.GameObjectWithBody | Phaser.Tilemaps.Tile
+ ) {
+ if (a instanceof Player) {
+ if (!a.jumping) {
+ if (b instanceof Platform) {
+ if (a.body.touching.down) {
+ if (b instanceof BreakingPlatform) {
+ const { x, y } = b.center();
+ this.scene.sound.play('break');
+ this.scene.events.emit(
+ Particles.DOTS,
+ BreakingPlatform.PARTICLE_QUANTITY,
+ x,
+ y
+ );
+ b.terminate();
+ } else if (b instanceof Spring) {
+ this.scene.sound.play('spring');
+ b.launch(a);
+ } else {
+ this.scene.sound.play('jump');
+ a.jump();
+ }
+ }
+ }
+ }
+ }
+ }
+
+ onPowerUpsOverlap(
+ a: Phaser.Types.Physics.Arcade.GameObjectWithBody | Phaser.Tilemaps.Tile,
+ b: Phaser.Types.Physics.Arcade.GameObjectWithBody | Phaser.Tilemaps.Tile
+ ) {
+ if (a instanceof Player) {
+ if (b instanceof PowerUp) {
+ if (a.powering) return;
+
+ if (b instanceof Helicopter) {
+ a.helicopter();
+ } else if (b instanceof Balloon) {
+ a.balloon();
+ } else if (b instanceof Rocket) {
+ a.rocket();
+ }
+
+ b.terminate();
+ }
+ }
+ }
+
+ onEnemiesOverlap(
+ a: Phaser.Types.Physics.Arcade.GameObjectWithBody | Phaser.Tilemaps.Tile,
+ b: Phaser.Types.Physics.Arcade.GameObjectWithBody | Phaser.Tilemaps.Tile
+ ) {
+ if (a instanceof Player) {
+ if (b instanceof Enemy) {
+ if (a.powering) {
+ this.scene.events.emit(
+ Particles.IMPACT_DEATH,
+ Enemy.PARTICLE_QUANTITY,
+ b.x,
+ b.y
+ );
+ this.scene.sound.play('smash');
+ a.emit(Player.EVENT_SMASH_ENEMY, b.type);
+ b.terminate();
+ } else {
+ this.scene.events.emit(
+ Particles.IMPACT_DEATH,
+ Player.IMPACT_DEATH_PARTICLE_QUANTITY,
+ a.x,
+ a.y
+ );
+ a.explode(b.type);
+ }
+ }
+ }
+ }
+}
diff --git a/apps/games/bit-jumper/src/lib/objects/controller.ts b/apps/games/bit-jumper/src/lib/objects/controller.ts
new file mode 100644
index 00000000..d06772ff
--- /dev/null
+++ b/apps/games/bit-jumper/src/lib/objects/controller.ts
@@ -0,0 +1,75 @@
+import { Particles } from './particles';
+import { Player } from './player';
+
+export class Controller {
+ static DEATH_OFFSET = 5;
+ static TOUCH_OFFSET = 8;
+ scene: Phaser.Scene;
+ player: Player;
+ enabled: boolean;
+
+ constructor(scene: Phaser.Scene, player: Player) {
+ this.scene = scene;
+ this.player = player;
+ this.enabled = false;
+ }
+
+ enable() {
+ this.enabled = true;
+ }
+
+ update() {
+ if (!this.enabled) return;
+
+ const width = this.scene.sys.game.canvas.width;
+ if (this.scene.input.keyboard?.addKey('LEFT').isDown) {
+ this.player.moveLeft();
+ } else if (this.scene.input.keyboard?.addKey('RIGHT').isDown) {
+ this.player.moveRight();
+ } else {
+ this.player.body.setVelocityX(0);
+ }
+
+ // detect if user is pressing the left side of the screen
+ if (
+ this.scene.input.pointer1.isDown &&
+ this.scene.input.pointer1.x < width / 2 - Controller.TOUCH_OFFSET
+ ) {
+ this.player.moveLeft();
+ } else if (
+ this.scene.input.pointer1.isDown &&
+ this.scene.input.pointer1.x > width / 2 + Controller.TOUCH_OFFSET
+ ) {
+ this.player.moveRight();
+ }
+
+ if (this.player.body.velocity.y > 0) {
+ this.player.fall();
+ }
+
+ // if the character goes to far to the left, reset it to the right
+ if (this.player.x < 0 - this.player.width) {
+ this.player.x = this.scene.sys.game.canvas.width;
+ } else if (this.player.x > this.scene.sys.game.canvas.width) {
+ this.player.x = 0 - this.player.width;
+ }
+
+ // if the player goes too low, explode them.
+ const camera = this.scene.cameras.main;
+ if (
+ this.player.y >
+ this.scene.sys.game.canvas.height +
+ camera.scrollY +
+ Controller.DEATH_OFFSET
+ ) {
+ if (this.player.explode('fall')) {
+ this.scene.events.emit(
+ Particles.FLOOR_DEATH,
+ Player.FLOOR_DEATH_PARTICLE_QUANTITY,
+ this.player.x,
+ this.player.y
+ );
+ }
+ }
+ }
+}
diff --git a/apps/games/bit-jumper/src/lib/objects/enemies.ts b/apps/games/bit-jumper/src/lib/objects/enemies.ts
new file mode 100644
index 00000000..003e2ce5
--- /dev/null
+++ b/apps/games/bit-jumper/src/lib/objects/enemies.ts
@@ -0,0 +1,348 @@
+import { assertNever } from '@worksheets/util/errors';
+import { once } from '@worksheets/util/functions';
+
+import { DEPTHS, OUT_OF_BOUNDS } from '../data';
+import { EnemyType } from '../types';
+import { Observer } from './observer';
+
+export class Enemies extends Phaser.GameObjects.Group {
+ static SPAWN_CHANCE = 7;
+ static RECLAIM_OFFSET = 100;
+ static PREFILL_QUANTITY = 20;
+ observer: Observer;
+ recycled: Record;
+ constructor(scene: Phaser.Scene) {
+ super(scene);
+ this.recycled = {
+ spiker: [],
+ chomper: [],
+ floater: [],
+ };
+ this.createAnimations();
+ this.prefill();
+ }
+
+ prefill() {
+ // create 10 of each enemy type
+ for (let i = 0; i < Enemies.PREFILL_QUANTITY; i++) {
+ this.recycled.spiker.push(new Spiker(this));
+ this.recycled.chomper.push(new Chomper(this));
+ this.recycled.floater.push(new Floater(this));
+ }
+ }
+
+ spawn(x: number, y: number) {
+ this.spawnType(x, y, this.randomKey());
+ }
+
+ spawnType(x: number, y: number, type: EnemyType) {
+ const enemy = this.getOrCreate(type);
+ this.add(enemy.place(x, y));
+ }
+
+ createAnimations() {
+ if (!this.scene.anims.get('spike')) {
+ this.scene.anims.create({
+ key: 'spike',
+ frames: this.scene.anims.generateFrameNumbers('jumper_spritesheet', {
+ start: 60,
+ end: 62,
+ }),
+ frameRate: 4,
+ });
+ }
+ }
+
+ update() {
+ this.children.iterate((enemy: Phaser.GameObjects.GameObject) => {
+ if (enemy instanceof Enemy) {
+ const camera = this.scene.cameras.main;
+ if (enemy.y > camera.scrollY + camera.height + Enemies.RECLAIM_OFFSET) {
+ enemy.terminate();
+ }
+ }
+ return true;
+ });
+ }
+
+ getOrCreate(key: EnemyType) {
+ const category = this.recycled[key];
+ if (category.length) {
+ return category.pop() as Enemy;
+ } else {
+ const EnemyConstructor = this.pickConstructor(key);
+ return new EnemyConstructor(this);
+ }
+ }
+
+ randomKey() {
+ const keys = Object.keys(this.recycled) as EnemyType[];
+ return Phaser.Math.RND.pick(keys);
+ }
+
+ pickConstructor(key: EnemyType) {
+ switch (key) {
+ case 'spiker':
+ return Spiker;
+ case 'chomper':
+ return Chomper;
+ case 'floater':
+ return Floater;
+ default:
+ throw assertNever(key);
+ }
+ }
+}
+
+export abstract class Enemy extends Phaser.Physics.Arcade.Sprite {
+ static PARTICLE_QUANTITY = 25;
+ declare type: EnemyType;
+ enemies: Enemies;
+ body: Phaser.Physics.Arcade.Body;
+ constructor(enemies: Enemies) {
+ super(
+ enemies.scene,
+ OUT_OF_BOUNDS.x,
+ OUT_OF_BOUNDS.y,
+ 'jumper_spritesheet',
+ 0
+ );
+ this.enemies = enemies;
+ enemies.scene.add.existing(this);
+ enemies.scene.physics.add.existing(this);
+ this.setBodySize(12, 12);
+ this.setDepth(DEPTHS.ENEMY);
+ if (!(this.body instanceof Phaser.Physics.Arcade.Body)) {
+ throw new Error(
+ 'Expected body to be an instance of Phaser.Physics.Arcade.Body'
+ );
+ }
+ this.body.setAllowGravity(false);
+ this.body.setImmovable(true);
+ }
+
+ place(x: number, y: number) {
+ this.body.checkCollision.none = false;
+ this.setVisible(true);
+ this.x = x;
+ this.y = y;
+ this.animate();
+ return this;
+ }
+
+ terminate() {
+ this.body.checkCollision.none = true;
+ this.setVisible(false);
+ this.enemies.remove(this);
+ this.enemies.recycled[this.type].push(this);
+ }
+
+ abstract animate(): void;
+}
+
+export class Spiker extends Enemy {
+ type: EnemyType = 'spiker';
+ bouncing?: Phaser.Tweens.Tween;
+ blinking?: Phaser.Time.TimerEvent;
+ wandering?: Phaser.Tweens.Tween;
+
+ constructor(enemies: Enemies) {
+ super(enemies);
+ this.setTexture('jumper_spritesheet', 60);
+ }
+
+ animate() {
+ this.blink();
+ this.wander();
+ this.bounce();
+ }
+
+ terminate() {
+ super.terminate();
+
+ this.wandering?.stop();
+ this.wandering = undefined;
+ this.bouncing?.stop();
+ this.bouncing = undefined;
+ this.blinking?.remove();
+ this.blinking = undefined;
+ }
+
+ private bounce() {
+ this.bouncing = this.scene.tweens.add({
+ targets: this,
+ y: this.y + Phaser.Math.RND.pick([-10, -5, 5, 10]),
+ duration: 1000,
+ yoyo: true,
+ repeat: -1,
+ });
+ }
+
+ private wander() {
+ let lastX = 1;
+ this.wandering = this.scene.tweens.add({
+ targets: this,
+ x: this.x + 50,
+ duration: 3000,
+ yoyo: true,
+ repeat: -1,
+ onUpdate: () => {
+ if (lastX !== this.x) {
+ this.flipX = this.x < lastX;
+ lastX = this.x;
+ }
+ },
+ });
+ }
+
+ private blink(waitFor = 2000) {
+ this.blinking = this.scene.time.delayedCall(
+ waitFor,
+ () => {
+ this.anims.play({
+ key: 'spike',
+ repeat: 1,
+ });
+
+ this.blink(Phaser.Math.Between(1000, 5000));
+ },
+ [],
+ this
+ );
+ }
+}
+
+export class Chomper extends Enemy {
+ type: EnemyType = 'chomper';
+ direction: boolean;
+ tween?: Phaser.Tweens.Tween;
+ constructor(enemies: Enemies) {
+ super(enemies);
+ this.direction = false;
+ this.pickSprite();
+ }
+
+ pickSprite() {
+ this.setTexture('jumper_spritesheet', this.direction ? 63 : 64);
+ }
+
+ animate() {
+ this.wander();
+ }
+
+ terminate() {
+ super.terminate();
+
+ this.tween?.stop();
+ this.tween = undefined;
+ }
+
+ wander() {
+ this.pickSprite();
+ this.tween = this.scene.tweens.add({
+ targets: this,
+ x: this.x + (this.direction ? -25 : +25),
+ duration: 3000,
+ onComplete: () => {
+ this.direction = !this.direction;
+ this.chomp();
+ },
+ });
+ }
+
+ chomp() {
+ this.setTexture('jumper_spritesheet', 65);
+
+ const openMouth = once(() => this.setTexture('jumper_spritesheet', 66));
+
+ const originalX = this.x;
+ this.tween = this.scene.tweens.add({
+ targets: this,
+ x: { from: this.x - 1, to: this.x + 1 },
+ duration: 50,
+ yoyo: true,
+ repeat: 14,
+ delay: 1000,
+ onUpdate: (t) => {
+ const v = t.progress;
+ if (v > 0.1) {
+ openMouth();
+ }
+ },
+ onComplete: () => {
+ this.x = originalX;
+ this.wander();
+ },
+ });
+ }
+}
+
+export class Floater extends Enemy {
+ type: EnemyType = 'floater';
+ tween?: Phaser.Tweens.Tween;
+
+ constructor(enemies: Enemies) {
+ super(enemies);
+ this.setTexture('jumper_spritesheet', 67);
+ }
+
+ animate() {
+ this.moveRandomly();
+ }
+
+ terminate() {
+ super.terminate();
+
+ this.tween?.stop();
+ this.tween = undefined;
+ }
+
+ moveRandomly() {
+ // pick a random point to float to.
+ const offset = 20;
+ const x = Phaser.Math.Between(
+ offset,
+ this.scene.sys.game.canvas.width - offset
+ );
+ const y = Phaser.Math.Between(this.y - 50, this.y + 50);
+ this.tween = this.scene.tweens.add({
+ targets: this,
+ x,
+ y,
+ duration: 3000,
+ onComplete: () => {
+ this.laugh();
+ },
+ });
+ }
+
+ laugh() {
+ const openMouth = once(() => {
+ this.setTexture('jumper_spritesheet', 69);
+ });
+ const closeMouth = once(() => {
+ this.setTexture('jumper_spritesheet', 68);
+ });
+
+ this.setTexture('jumper_spritesheet', 68);
+ // bounce up and down
+ this.tween = this.scene.tweens.add({
+ targets: this,
+ y: this.y + 3,
+ duration: 150,
+ yoyo: true,
+ repeat: 6,
+ delay: 50,
+ onUpdate: (t) => {
+ if (t.progress > 0.9) {
+ closeMouth();
+ } else if (t.progress > 0.1) {
+ openMouth();
+ }
+ },
+ onComplete: () => {
+ this.moveRandomly();
+ },
+ });
+ }
+}
diff --git a/apps/games/bit-jumper/src/lib/objects/loading-bar.ts b/apps/games/bit-jumper/src/lib/objects/loading-bar.ts
new file mode 100644
index 00000000..da9b91a1
--- /dev/null
+++ b/apps/games/bit-jumper/src/lib/objects/loading-bar.ts
@@ -0,0 +1,52 @@
+export type ProgressBarOptions = {
+ width: number;
+ height: number;
+ background?: number;
+ progress?: number;
+};
+
+export class ProgressBar extends Phaser.GameObjects.Container {
+ background: Phaser.GameObjects.Graphics;
+ progress: Phaser.GameObjects.Graphics;
+ options: ProgressBarOptions;
+
+ constructor(
+ scene: Phaser.Scene,
+ x: number,
+ y: number,
+ options: ProgressBarOptions
+ ) {
+ super(scene, x, y);
+ this.options = options;
+
+ this.createBars();
+ this.scene.add.existing(this);
+ }
+
+ createBars() {
+ this.background = new Phaser.GameObjects.Graphics(this.scene);
+ this.background.fillStyle(this.options.background ?? 0xffffff, 1);
+ this.background.fillRect(
+ -this.options.width / 2 - 1,
+ -this.options.height / 2 - 1,
+ this.options.width + 2,
+ this.options.height + 2
+ );
+
+ this.add(this.background);
+
+ this.progress = new Phaser.GameObjects.Graphics(this.scene);
+ this.add(this.progress);
+ }
+
+ updateProgress(value: number) {
+ this.progress.clear();
+ this.progress.fillStyle(this.options.background ?? 0x000000, 1);
+ this.progress.fillRect(
+ -this.options.width / 2,
+ -this.options.height / 2,
+ this.options.width * value,
+ this.options.height
+ );
+ }
+}
diff --git a/apps/games/bit-jumper/src/lib/objects/observer.ts b/apps/games/bit-jumper/src/lib/objects/observer.ts
new file mode 100644
index 00000000..d1935c75
--- /dev/null
+++ b/apps/games/bit-jumper/src/lib/objects/observer.ts
@@ -0,0 +1,53 @@
+import { Player } from './player';
+
+export class Observer {
+ static POWER_UP_SCREEN_OFFSET = 130;
+ static DEFAULT_SCREEN_OFFSET = 90;
+ static DEFAULT_STARTING_APEX = 60;
+ static DEFAULT_SCREEN_INTERPOLATION = 0.05;
+ static EVENT_APEX_CHANGE = 'apex-change';
+ apex: number;
+ scene: Phaser.Scene;
+ main: Phaser.Cameras.Scene2D.Camera;
+ player: Player;
+ screenOffset: number;
+ interpolation: number;
+ events: Phaser.Events.EventEmitter;
+
+ constructor(scene: Phaser.Scene, player: Player) {
+ this.events = new Phaser.Events.EventEmitter();
+ this.scene = scene;
+ this.main = scene.cameras.main;
+ this.player = player;
+ this.apex = Observer.DEFAULT_STARTING_APEX;
+ this.screenOffset = Observer.DEFAULT_SCREEN_OFFSET;
+ this.interpolation = Observer.DEFAULT_SCREEN_INTERPOLATION;
+
+ this.player.on(Player.EVENT_POWER_UP_START, () => {
+ this.screenOffset = Observer.POWER_UP_SCREEN_OFFSET;
+ });
+
+ this.player.on(Player.EVENT_POWER_UP_OVER, () => {
+ this.screenOffset = Observer.DEFAULT_SCREEN_OFFSET;
+ });
+ }
+
+ cleanup() {
+ this.player.off(Player.EVENT_POWER_UP_START);
+ this.player.off(Player.EVENT_POWER_UP_OVER);
+ }
+
+ update() {
+ if (this.player.y < this.apex) {
+ this.apex = this.player.y;
+
+ this.main.scrollY = Phaser.Math.Linear(
+ this.main.scrollY,
+ this.apex - this.screenOffset,
+ this.interpolation
+ );
+
+ this.events.emit(Observer.EVENT_APEX_CHANGE, this.apex);
+ }
+ }
+}
diff --git a/apps/games/bit-jumper/src/lib/objects/particles.ts b/apps/games/bit-jumper/src/lib/objects/particles.ts
new file mode 100644
index 00000000..80193833
--- /dev/null
+++ b/apps/games/bit-jumper/src/lib/objects/particles.ts
@@ -0,0 +1,129 @@
+export class Particles {
+ static FLOOR_DEATH = 'floor_death';
+ static IMPACT_DEATH = 'impact_death';
+ static DOTS = 'explode_dots';
+ static RINGS = 'explode_rings';
+ static EXHAUST = 'rocket_exhaust';
+ constructor(scene: Phaser.Scene, x: number, y: number) {
+ const exhaust = new Phaser.GameObjects.Particles.ParticleEmitter(
+ scene,
+ x,
+ y,
+ 'dot',
+ {
+ lifespan: 1000,
+ speedX: { min: -30, max: 30 },
+ scale: { min: 0.1, max: 2.5 },
+ alpha: 1,
+ emitting: false,
+ }
+ );
+
+ const dots = new Phaser.GameObjects.Particles.ParticleEmitter(
+ scene,
+ x,
+ y,
+ 'dot',
+ {
+ lifespan: 1000,
+ speedX: { min: -50, max: 50 },
+ speedY: { min: -10, max: -40 },
+ scale: { min: 0.5, max: 2 },
+ alpha: { start: 1, end: 0 },
+ gravityY: 200,
+ emitting: false,
+ }
+ );
+
+ const rings = new Phaser.GameObjects.Particles.ParticleEmitter(
+ scene,
+ x,
+ y,
+ 'ring',
+ {
+ lifespan: 150,
+ scale: { start: 0, end: 1.25 },
+ alpha: { start: 1, end: 0 },
+ emitting: false,
+ }
+ );
+
+ const impact = new Phaser.GameObjects.Particles.ParticleEmitter(
+ scene,
+ x,
+ y,
+ 'dot',
+ {
+ lifespan: 3000,
+ speedX: { min: -100, max: 100 },
+ speedY: { min: -150, max: 50 },
+ scale: { min: 0.5, max: 2 },
+ alpha: { start: 1, end: 0 },
+ gravityY: 100,
+ emitting: false,
+ }
+ );
+ const floor = new Phaser.GameObjects.Particles.ParticleEmitter(
+ scene,
+ x,
+ y,
+ 'dot',
+ {
+ lifespan: 3000,
+ speedX: { min: -100, max: 100 },
+ speedY: { min: -300, max: 0 },
+ scale: { min: 0.5, max: 2 },
+ alpha: { start: 1, end: 0 },
+ gravityY: 200,
+ emitting: false,
+ }
+ );
+
+ scene.add.existing(dots);
+ scene.add.existing(rings);
+ scene.add.existing(exhaust);
+ scene.add.existing(impact);
+ scene.add.existing(floor);
+
+ scene.events.on(
+ Particles.DOTS,
+ (quantity: number, x: number, y: number) => {
+ dots.explode(quantity, x, y);
+ }
+ );
+
+ scene.events.on(Particles.RINGS, (x: number, y: number) => {
+ rings.emitParticleAt(x, y);
+ });
+
+ scene.events.on(
+ Particles.EXHAUST,
+ (quantity: number, x: number, y: number) => {
+ exhaust.explode(quantity, x, y);
+ }
+ );
+
+ scene.events.on(
+ Particles.IMPACT_DEATH,
+ (quantity: number, x: number, y: number) => {
+ impact.explode(quantity, x, y);
+ }
+ );
+
+ scene.events.on(
+ Particles.FLOOR_DEATH,
+ (quantity: number, x: number, y: number) => {
+ floor.explode(quantity, x, y);
+ }
+ );
+
+ scene.events.on('shutdown', () => {
+ scene.events.off(Particles.DOTS);
+ scene.events.off(Particles.RINGS);
+ scene.events.off(Particles.EXHAUST);
+ scene.events.off(Particles.IMPACT_DEATH);
+ scene.events.off(Particles.FLOOR_DEATH);
+ scene.events.off('shutdown');
+ });
+ }
+}
diff --git a/apps/games/bit-jumper/src/lib/objects/platforms.ts b/apps/games/bit-jumper/src/lib/objects/platforms.ts
new file mode 100644
index 00000000..b34e412d
--- /dev/null
+++ b/apps/games/bit-jumper/src/lib/objects/platforms.ts
@@ -0,0 +1,347 @@
+import { assertNever } from '@worksheets/util/errors';
+
+import { DEPTHS, OUT_OF_BOUNDS } from '../data';
+import { PlatformType } from '../types';
+import { Observer } from './observer';
+import { Player } from './player';
+
+export class PlatformsPool {
+ static PREFILL_QUANTITY = {
+ basic: 100,
+ spring: 20,
+ sliding: 20,
+ breaking: 20,
+ floor: 1,
+ };
+ scene: Phaser.Scene;
+ recycled: Record;
+ constructor(scene: Phaser.Scene) {
+ this.scene = scene;
+ this.recycled = {
+ basic: [],
+ spring: [],
+ sliding: [],
+ breaking: [],
+ floor: [],
+ };
+ }
+
+ terminate(platform: Platform) {
+ const type = platform.type as PlatformType;
+ const exists = this.recycled[type].some((p) => p.id === platform.id);
+ if (exists) return;
+
+ this.recycled[type].push(platform);
+ }
+
+ get(type: PlatformType) {
+ const category = this.recycled[type];
+ if (category) {
+ const platform = category.pop();
+ if (platform) {
+ return platform;
+ }
+ }
+
+ return this.create(type);
+ }
+
+ prefill() {
+ for (const key in this.recycled) {
+ const k = key as PlatformType;
+ for (let i = 0; i < PlatformsPool.PREFILL_QUANTITY[k]; i++) {
+ this.create(k).terminate();
+ }
+ }
+ }
+
+ private create(type: PlatformType) {
+ const PlatformConstructor = this.pickConstructor(type);
+ return new PlatformConstructor(
+ this.scene,
+ this,
+ OUT_OF_BOUNDS.x,
+ OUT_OF_BOUNDS.y
+ );
+ }
+
+ private pickConstructor(type: PlatformType) {
+ switch (type) {
+ case 'basic':
+ return Platform;
+ case 'spring':
+ return Spring;
+ case 'sliding':
+ return SlidingPlatform;
+ case 'breaking':
+ return BreakingPlatform;
+ case 'floor':
+ return Floor;
+ default:
+ throw assertNever(type);
+ }
+ }
+
+ group() {
+ // return the count of each type of platform
+ return Object.keys(this.recycled).reduce(
+ (acc, type) => ({
+ ...acc,
+ [type]: this.recycled[type as PlatformType].length,
+ }),
+ { basic: 0, spring: 0, sliding: 0, breaking: 0, floor: 0 }
+ );
+ }
+
+ count() {
+ return Object.keys(this.recycled).reduce(
+ (acc, type) => acc + this.recycled[type as PlatformType].length,
+ 0
+ );
+ }
+}
+
+export class Platforms extends Phaser.GameObjects.Group {
+ static SPAWN_EVENT = 'spawn';
+ static SPAWN_RATE = 2;
+ static GAP = 12;
+ static SCREEN_OFFSET = 200;
+
+ observer: Observer;
+ scene: Phaser.Scene;
+ height: number;
+ width: number;
+ pool: PlatformsPool;
+ constructor(scene: Phaser.Scene, observer: Observer) {
+ super(scene);
+ this.scene = scene;
+ this.observer = observer;
+ this.height = 0;
+ this.pool = new PlatformsPool(scene);
+
+ const { width, height } = scene.sys.game.canvas;
+ this.width = width;
+ this.height = height;
+
+ this.initialize();
+
+ this.spawnPlatforms(height - 100);
+ }
+
+ private initialize() {
+ this.pool.prefill();
+ const { width, height } = this;
+ this.add(this.pool.get('floor').place(width / 2, height));
+ this.add(this.pool.get('basic').place(width / 2, height / 1.25));
+ this.add(this.pool.get('spring').place(width / 2, height / 1.25));
+ }
+
+ private spawnPlatforms(height: number) {
+ for (let i = 0; i < Platforms.SPAWN_RATE; i++) {
+ const y =
+ height -
+ Phaser.Math.Between(Platforms.GAP * 1, Platforms.GAP * 1.5) * i;
+ const x = Phaser.Math.Between(0, this.width);
+ this.spawnPlatform(x, y);
+ this.height = y;
+ }
+ }
+
+ private spawnPlatform(x: number, y: number) {
+ const type = Phaser.Math.Between(0, 100);
+ const generator = (type: PlatformType) =>
+ this.add(this.pool.get(type).place(x, y));
+ if (type < 15) {
+ generator('sliding');
+ } else if (type < 25) {
+ generator('breaking');
+ } else if (type < 30) {
+ generator('basic');
+ generator('spring');
+ } else {
+ generator('basic');
+ this.emit(Platforms.SPAWN_EVENT, x, y);
+ }
+ }
+
+ update() {
+ if (this.observer.apex < this.height + Platforms.SCREEN_OFFSET) {
+ this.spawnPlatforms(this.height - Platforms.GAP);
+ }
+ // iterate over all children and check if they are out of bounds
+ const lowerBound =
+ this.observer.main.scrollY + this.scene.sys.game.canvas.height;
+ this.children.iterate((platform) => {
+ if (platform instanceof Platform) {
+ if (platform.y - Platforms.SCREEN_OFFSET > lowerBound) {
+ this.remove(platform);
+ platform.terminate();
+ }
+ }
+ return true;
+ });
+ }
+}
+
+export class Platform extends Phaser.Physics.Arcade.Sprite {
+ type: PlatformType = 'basic';
+ pool: PlatformsPool;
+ body: Phaser.Physics.Arcade.Body;
+ id: string;
+ constructor(scene: Phaser.Scene, pool: PlatformsPool, x: number, y: number) {
+ super(scene, x, y, 'jumper_spritesheet', 27);
+ this.pool = pool;
+ this.id = Phaser.Math.RND.uuid();
+ scene.add.existing(this);
+ scene.physics.add.existing(this);
+ this.setBodySize(8, 2);
+ this.setDepth(DEPTHS.PLATFORM);
+ if (!(this.body instanceof Phaser.Physics.Arcade.Body)) {
+ throw new Error(
+ 'Expected body to be an instance of Phaser.Physics.Arcade.Body'
+ );
+ }
+ this.body.setImmovable(true);
+ this.body.setAllowGravity(false);
+
+ this.body.checkCollision.down = false;
+ this.body.checkCollision.left = false;
+ this.body.checkCollision.right = false;
+ }
+
+ place(x: number, y: number) {
+ this.x = x;
+ this.y = y;
+ return this;
+ }
+
+ terminate() {
+ this.pool.terminate(this);
+ }
+}
+
+export class Floor extends Platform {
+ type: PlatformType = 'floor';
+ constructor(scene: Phaser.Scene, pool: PlatformsPool, x: number, y: number) {
+ super(scene, pool, x, y);
+ this.scaleX = 20;
+ }
+}
+
+export class SlidingPlatform extends Platform {
+ type: PlatformType = 'sliding';
+ tween?: Phaser.Tweens.Tween;
+ constructor(scene: Phaser.Scene, pool: PlatformsPool, x: number, y: number) {
+ super(scene, pool, x, y);
+ this.setTexture('jumper_spritesheet', 29);
+ }
+ place(x: number, y: number) {
+ super.place(x, y);
+
+ this.tween = this.scene.tweens.add({
+ targets: this,
+ x: x + Phaser.Math.RND.pick([-30, 30]),
+ ease: 'Linear',
+ duration: 1500,
+ yoyo: true,
+ repeat: -1,
+ });
+
+ return this;
+ }
+
+ terminate() {
+ super.terminate();
+ if (this.tween) {
+ this.tween.stop();
+ this.tween = undefined;
+ }
+ }
+}
+
+export class BreakingPlatform extends Platform {
+ static PARTICLE_QUANTITY = 10;
+ type: PlatformType = 'breaking';
+ breaking = false;
+ constructor(scene: Phaser.Scene, pool: PlatformsPool, x: number, y: number) {
+ super(scene, pool, x, y);
+ this.setTexture('jumper_spritesheet', 28);
+ }
+
+ place(x: number, y: number) {
+ super.place(x, y);
+ this.breaking = false;
+ this.body.checkCollision.none = false;
+ this.setVisible(true);
+ this.setActive(true);
+ return this;
+ }
+
+ terminate() {
+ super.terminate();
+ if (!this.breaking) {
+ this.breaking = true;
+ this.setVisible(false);
+ this.setActive(false);
+ this.body.checkCollision.none = true;
+ }
+ }
+
+ /** The middle of the square for particle emission */
+ center() {
+ return { x: this.x, y: this.y };
+ }
+}
+
+export class Spring extends Platform {
+ type: PlatformType = 'spring';
+ constructor(scene: Phaser.Scene, pool: PlatformsPool, x: number, y: number) {
+ super(scene, pool, x, y);
+ this.setBodySize(6, 2);
+ this.setOffset(5, 3);
+ this.setTexture('jumper_spritesheet', 19);
+ this.setDepth(DEPTHS.PLATFORM + 1);
+
+ if (scene.anims.get('spring') === undefined) {
+ scene.anims.create({
+ key: 'spring',
+ frames: scene.anims.generateFrameNumbers('jumper_spritesheet', {
+ start: 17,
+ end: 19,
+ }),
+ frameRate: 24,
+ });
+ }
+ }
+
+ launch(player: Player) {
+ this.playReverse({
+ key: 'spring',
+ yoyo: true,
+ });
+
+ player.jump(1.5);
+ player.on('jump', () => {
+ this.body.checkCollision.none = true;
+ this.body.setAllowGravity(true);
+ this.setVelocityY(-50);
+
+ this.scene.time.delayedCall(1000, () => {
+ this.terminate();
+ });
+ player.off('jump');
+ });
+ }
+
+ terminate() {
+ super.terminate();
+ this.body.setAllowGravity(false);
+ this.setVelocity(0);
+ }
+
+ place(x: number, y: number) {
+ super.place(x, y);
+ this.body.checkCollision.none = false;
+ return this;
+ }
+}
diff --git a/apps/games/bit-jumper/src/lib/objects/player.ts b/apps/games/bit-jumper/src/lib/objects/player.ts
new file mode 100644
index 00000000..eb7cb74a
--- /dev/null
+++ b/apps/games/bit-jumper/src/lib/objects/player.ts
@@ -0,0 +1,223 @@
+import { DEPTHS } from '../data';
+import { DeathReason, PowerUpType } from '../types';
+import { Particles } from './particles';
+import { Balloon, Helicopter, Rocket } from './power-ups';
+
+export class Player extends Phaser.Physics.Arcade.Sprite {
+ static EVENT_POWER_UP_START = 'power-up-start';
+ static EVENT_POWER_UP_OVER = 'power-up-over';
+ static EVENT_DEATH = 'death';
+ static EVENT_SMASH_ENEMY = 'smash-enemy';
+ static IMPACT_DEATH_PARTICLE_QUANTITY = 50;
+ static FLOOR_DEATH_PARTICLE_QUANTITY = 150;
+ static FLASH_DURATION = 1000;
+ static FLASH_REPETITIONS = 5;
+ jumpLag = 50;
+ speed = 75;
+ power = 175;
+ balloonSpeed = 100;
+ balloonDuration = 3000;
+ helicopterSpeed = 125;
+ helicopterDuration = 3500;
+ rocketDuration = 4000;
+ rocketSpeed = 250;
+ body: Phaser.Physics.Arcade.Body;
+ jumping = false;
+ powering?: PowerUpType = undefined;
+ dead = false;
+
+ constructor(scene: Phaser.Scene, x: number, y: number) {
+ super(scene, x, y, 'jumper_spritesheet', 0);
+ scene.add.existing(this);
+ scene.physics.add.existing(this);
+ this.setBodySize(8, 8);
+ this.setDepth(DEPTHS.PLAYER);
+ if (!(this.body instanceof Phaser.Physics.Arcade.Body)) {
+ throw new Error(
+ 'Expected body to be an instance of Phaser.Physics.Arcade.Body'
+ );
+ }
+
+ this.createAnimations();
+ }
+
+ createAnimations() {
+ if (!this.anims.get('balloon')) {
+ this.anims.create({
+ key: 'balloon',
+ frames: this.anims.generateFrameNumbers('jumper_spritesheet', {
+ start: 10,
+ end: 13,
+ }),
+ frameRate: 12,
+ });
+ }
+
+ if (!this.anims.get('helicopter')) {
+ this.anims.create({
+ key: 'helicopter',
+ frames: this.anims.generateFrameNumbers('jumper_spritesheet', {
+ start: 20,
+ end: 24,
+ }),
+ frameRate: 36,
+ });
+ }
+
+ if (!this.anims.get('rocket')) {
+ this.anims.create({
+ key: 'rocket',
+ frames: this.anims.generateFrameNumbers('jumper_spritesheet', {
+ start: 30,
+ end: 34,
+ }),
+ frameRate: 24,
+ });
+ }
+ }
+
+ fall() {
+ if (this.jumping) return;
+ this.setTexture('jumper_spritesheet', 1);
+ }
+
+ jump(multiplier = 1) {
+ if (this.body.blocked.down) {
+ this.jumping = true;
+ this.setTexture('jumper_spritesheet', 2);
+ setTimeout(() => {
+ this.body.setVelocityY(-this.power * multiplier);
+ this.rotation = 0;
+ this.setTexture('jumper_spritesheet', 0);
+ this.jumping = false;
+ this.emit('jump');
+ }, this.jumpLag);
+ }
+ }
+
+ moveLeft() {
+ if (this.jumping) this.body.setVelocityX(0);
+ else this.body.setVelocityX(-this.speed);
+ }
+
+ moveRight() {
+ if (this.jumping) this.body.setVelocityX(0);
+ else this.body.setVelocityX(this.speed);
+ }
+
+ balloon() {
+ this.powerUp({
+ key: 'balloon',
+ duration: this.balloonDuration,
+ speed: this.balloonSpeed,
+ onComplete: () => {
+ const balloon = new Balloon(this.scene, this.x, this.y).release(
+ this.balloonSpeed * 1.15,
+ this.body.velocity.x
+ );
+ this.scene.time.delayedCall(5000, () => {
+ balloon.terminate();
+ });
+ },
+ });
+ }
+
+ helicopter() {
+ this.powerUp({
+ key: 'helicopter',
+ duration: this.helicopterDuration,
+ speed: this.helicopterSpeed,
+ onComplete: () => {
+ const helicopter = new Helicopter(this.scene, this.x, this.y).release(
+ this.helicopterSpeed * 1.25
+ );
+ this.scene.time.delayedCall(5000, () => {
+ helicopter.terminate();
+ });
+ },
+ });
+ }
+
+ rocket() {
+ this.scene.cameras.main.flash(1000, 255, 255, 255);
+
+ const event = this.scene.time.addEvent({
+ delay: 50,
+ callback: () => {
+ this.scene.events.emit(Particles.EXHAUST, 3, this.x, this.y);
+ },
+ repeat: -1,
+ });
+
+ this.powerUp({
+ key: 'rocket',
+ duration: this.rocketDuration,
+ speed: this.rocketSpeed,
+ onComplete: () => {
+ event.remove();
+ const rocket = new Rocket(this.scene, this.x, this.y).release(200);
+ this.scene.time.delayedCall(5000, () => {
+ rocket.terminate();
+ });
+ },
+ });
+ }
+
+ explode(reason: DeathReason) {
+ if (this.dead) return false;
+ this.body.setVelocity(0);
+ this.body.setAllowGravity(false);
+ this.body.checkCollision.none = true;
+ this.setVisible(false);
+ this.dead = true;
+ this.emit(Player.EVENT_DEATH, reason);
+ this.scene.sound.play('death');
+ return true;
+ }
+
+ private powerUp({
+ key,
+ duration,
+ speed,
+ onComplete,
+ }: {
+ key: PowerUpType;
+ duration: number;
+ speed: number;
+ onComplete: () => void;
+ }) {
+ if (this.powering || this.dead) return;
+
+ this.powering = key;
+ this.emit(Player.EVENT_POWER_UP_START, key);
+ this.scene.sound.play(`${key}_start`, {
+ loop: true,
+ });
+ this.anims.play({
+ key,
+ repeat: -1,
+ });
+ this.body.setVelocityY(-Math.abs(speed));
+ this.body.setAllowGravity(false);
+ this.scene.time.delayedCall(duration, () => {
+ this.scene.tweens.add({
+ targets: this,
+ alpha: 0,
+ duration: Player.FLASH_DURATION / Player.FLASH_REPETITIONS / 2,
+ yoyo: true,
+ repeat: Player.FLASH_REPETITIONS,
+ onComplete: () => {
+ this.scene.sound.stopByKey(`${key}_start`);
+ this.scene.sound.play(`${key}_end`);
+ this.alpha = 1;
+ this.powering = undefined;
+ this.anims.stop();
+ this.body.setAllowGravity(true);
+ this.setTexture('jumper_spritesheet', 1);
+ this.emit(Player.EVENT_POWER_UP_OVER);
+ onComplete();
+ },
+ });
+ });
+ }
+}
diff --git a/apps/games/bit-jumper/src/lib/objects/power-ups.ts b/apps/games/bit-jumper/src/lib/objects/power-ups.ts
new file mode 100644
index 00000000..45fc0f49
--- /dev/null
+++ b/apps/games/bit-jumper/src/lib/objects/power-ups.ts
@@ -0,0 +1,93 @@
+import { DEPTHS } from '../data';
+import { Particles } from './particles';
+
+export class PowerUps extends Phaser.GameObjects.Group {
+ static SPAWN_CHANCE = 1;
+ constructor(scene: Phaser.Scene) {
+ super(scene);
+ }
+
+ spawn(x: number, y: number) {
+ const type = Phaser.Math.Between(0, 100);
+
+ if (type < 50) {
+ this.add(new Balloon(this.scene, x, y - Balloon.HEIGHT_OFFSET));
+ } else if (type < 90) {
+ this.add(new Helicopter(this.scene, x, y - Helicopter.HEIGHT_OFFSET));
+ } else {
+ this.add(new Rocket(this.scene, x, y - Rocket.HEIGHT_OFFSET));
+ }
+ }
+}
+
+export class PowerUp extends Phaser.Physics.Arcade.Sprite {
+ body: Phaser.Physics.Arcade.Body;
+ constructor(scene: Phaser.Scene, x: number, y: number, frame: number) {
+ super(scene, x, y, 'jumper_spritesheet', frame);
+ scene.add.existing(this);
+ scene.physics.add.existing(this);
+ this.setDepth(DEPTHS.POWER_UP);
+ if (!(this.body instanceof Phaser.Physics.Arcade.Body)) {
+ throw new Error(
+ 'Expected body to be an instance of Phaser.Physics.Arcade.Body'
+ );
+ }
+
+ this.body.setAllowGravity(false);
+ }
+
+ release(speed = 10, variance = 10) {
+ this.setVelocityY(-Math.abs(speed));
+ this.setVelocityX(Phaser.Math.Between(-variance, variance));
+ return this;
+ }
+
+ terminate() {
+ this.destroy();
+ }
+}
+
+export class Balloon extends PowerUp {
+ static HEIGHT_OFFSET = 6;
+ event: Phaser.Time.TimerEvent;
+ constructor(scene: Phaser.Scene, x: number, y: number) {
+ super(scene, x, y, 6);
+
+ this.setBodySize(5, 8);
+ }
+
+ terminate() {
+ this.scene.events.emit(Particles.RINGS, this.x, this.y);
+ super.terminate();
+ }
+}
+
+export class Helicopter extends PowerUp {
+ static HEIGHT_OFFSET = 5;
+ constructor(scene: Phaser.Scene, x: number, y: number) {
+ super(scene, x, y, 7);
+ this.body.setSize(8, 8);
+ }
+
+ release(speed: number) {
+ super.release(speed);
+ this.body.setAllowGravity(true);
+ this.body.setAngularVelocity(Phaser.Math.Between(speed, speed * 3));
+ return this;
+ }
+}
+
+export class Rocket extends PowerUp {
+ static HEIGHT_OFFSET = 5;
+ constructor(scene: Phaser.Scene, x: number, y: number) {
+ super(scene, x, y, 8);
+ this.setBodySize(8, 8);
+ }
+
+ release(speed: number) {
+ super.release(speed);
+ this.setTexture('jumper_spritesheet', 35);
+ this.setVelocityY(-Math.abs(speed));
+ return this;
+ }
+}
diff --git a/apps/games/bit-jumper/src/lib/objects/ui/button.ts b/apps/games/bit-jumper/src/lib/objects/ui/button.ts
new file mode 100644
index 00000000..d26b7ed1
--- /dev/null
+++ b/apps/games/bit-jumper/src/lib/objects/ui/button.ts
@@ -0,0 +1,26 @@
+import { DEPTHS } from '../../data';
+
+export class Button extends Phaser.GameObjects.Rectangle {
+ constructor(scene: Phaser.Scene, x: number, y: number, text: string) {
+ super(scene, x, y, text.length * 8 + 4, 11, 0xffffff);
+ this.setInteractive({ useHandCursor: true });
+ this.on('pointerdown', () => {
+ this.emit('click');
+ this.scene.sound.play('menu');
+ }).setDepth(DEPTHS.UI);
+
+ scene.add.existing(this);
+
+ scene.add
+ .text(x, y, text, {
+ fontSize: '8px',
+ align: 'center',
+ color: '#fff',
+ fontFamily: 'frog-block',
+ })
+ .setStroke('#000', 4)
+ .setOrigin(0.5, 0.5)
+ .setResolution(10)
+ .setDepth(DEPTHS.UI);
+ }
+}
diff --git a/apps/games/bit-jumper/src/lib/objects/ui/icons.ts b/apps/games/bit-jumper/src/lib/objects/ui/icons.ts
new file mode 100644
index 00000000..b2569c86
--- /dev/null
+++ b/apps/games/bit-jumper/src/lib/objects/ui/icons.ts
@@ -0,0 +1,61 @@
+import { DEPTHS } from '../../data';
+
+export class Icon extends Phaser.GameObjects.Sprite {
+ constructor(scene: Phaser.Scene, x: number, y: number, texture: string) {
+ super(scene, x, y, texture);
+ this.setDepth(DEPTHS.UI);
+ this.setInteractive({ useHandCursor: true });
+ this.setOrigin(0, 0);
+
+ this.on(
+ 'pointerdown',
+ () => {
+ this.emit('click');
+ this.scene.sound.play('menu');
+ },
+ this
+ );
+
+ this.scene.add.existing(this);
+ }
+}
+
+export class BackIcon extends Icon {
+ constructor(scene: Phaser.Scene, x: number, y: number) {
+ super(scene, x, y, 'back');
+ }
+}
+
+export class HeartIcon extends Icon {
+ constructor(scene: Phaser.Scene, x: number, y: number) {
+ super(scene, x, y, 'heart');
+ }
+}
+
+export class SoundIcon extends Icon {
+ constructor(scene: Phaser.Scene, x: number, y: number) {
+ super(scene, x, y, 'sound_on');
+
+ this.on(
+ 'pointerdown',
+ () => {
+ this.toggle();
+ },
+ this
+ );
+
+ this.scene.add.existing(this);
+ }
+
+ mute(mute: boolean) {
+ this.scene.sound.mute = mute;
+ this.setTexture(mute ? 'sound_off' : 'sound_on');
+ return this;
+ }
+
+ toggle() {
+ const newState = !this.scene.sound.mute;
+ this.mute(newState);
+ this.emit('toggle', newState);
+ }
+}
diff --git a/apps/games/bit-jumper/src/lib/objects/ui/link.ts b/apps/games/bit-jumper/src/lib/objects/ui/link.ts
new file mode 100644
index 00000000..e297e3da
--- /dev/null
+++ b/apps/games/bit-jumper/src/lib/objects/ui/link.ts
@@ -0,0 +1,16 @@
+import { Typography } from './typography';
+
+export class Link extends Typography {
+ constructor(
+ scene: Phaser.Scene,
+ x: number,
+ y: number,
+ text: string,
+ href: string
+ ) {
+ super(scene, x, y, text);
+ this.setInteractive({ useHandCursor: true }).on('pointerdown', () => {
+ window.open(href, '_blank');
+ });
+ }
+}
diff --git a/apps/games/bit-jumper/src/lib/objects/ui/typography.ts b/apps/games/bit-jumper/src/lib/objects/ui/typography.ts
new file mode 100644
index 00000000..6f5343a2
--- /dev/null
+++ b/apps/games/bit-jumper/src/lib/objects/ui/typography.ts
@@ -0,0 +1,17 @@
+import { DEPTHS } from '../../data';
+
+export class Typography extends Phaser.GameObjects.Text {
+ constructor(scene: Phaser.Scene, x: number, y: number, text: string) {
+ super(scene, x, y, text, {
+ fontSize: '8px',
+ align: 'center',
+ color: '#fff',
+ fontFamily: 'frog-block',
+ });
+ this.setStroke('#000', 4);
+ this.setOrigin(0.5, 0.5);
+ this.setResolution(10);
+ this.setDepth(DEPTHS.UI);
+ scene.add.existing(this);
+ }
+}
diff --git a/apps/games/bit-jumper/src/lib/scenes/boot.ts b/apps/games/bit-jumper/src/lib/scenes/boot.ts
new file mode 100644
index 00000000..bf5ac231
--- /dev/null
+++ b/apps/games/bit-jumper/src/lib/scenes/boot.ts
@@ -0,0 +1,97 @@
+import { CharityGamesPlugin } from '@worksheets/phaser/plugins';
+import Phaser from 'phaser';
+
+import { ProgressBar } from '../objects/loading-bar';
+import { Typography } from '../objects/ui/typography';
+import { Menu } from './menu';
+
+export class Boot extends Phaser.Scene {
+ static readonly KEY = 'boot';
+ server: CharityGamesPlugin;
+ serverProgress: ProgressBar;
+ assetProgress: ProgressBar;
+ constructor() {
+ super(Boot.KEY);
+ }
+
+ preload() {
+ this.server = CharityGamesPlugin.find(this);
+ this.server.initialize();
+
+ this.createBars();
+ this.setLoadEvents();
+
+ const { width, height } = this.cameras.main;
+
+ new Typography(this, width / 2, height * 0.65, 'LOADING');
+
+ this.load.image('dot', './assets/particles/dot.png');
+ this.load.image('ring', './assets/particles/ring.png');
+ this.load.image('sound_on', './assets/icons/sound_on.png');
+ this.load.image('sound_off', './assets/icons/sound_off.png');
+ this.load.image('back', './assets/icons/back.png');
+ this.load.image('heart', './assets/icons/heart.png');
+
+ this.load.audio('balloon_end', './assets/audio/balloon_end.mp3');
+ this.load.audio('balloon_start', './assets/audio/balloon_start.mp3');
+ this.load.audio('break', './assets/audio/break.mp3');
+ this.load.audio('death', './assets/audio/death.mp3');
+ this.load.audio('helicopter_end', './assets/audio/helicopter_end.mp3');
+ this.load.audio('helicopter_start', './assets/audio/helicopter_start.mp3');
+ this.load.audio('jump', './assets/audio/jump.mp3');
+ this.load.audio('menu', './assets/audio/menu.mp3');
+ this.load.audio('rocket_end', './assets/audio/rocket_end.mp3');
+ this.load.audio('rocket_start', './assets/audio/rocket_start.mp3');
+ this.load.audio('smash', './assets/audio/smash.mp3');
+ this.load.audio('spring', './assets/audio/spring.mp3');
+
+ this.load.spritesheet(
+ 'jumper_spritesheet',
+ './assets/sprites/infinite_jumper.png',
+ {
+ frameHeight: 16,
+ frameWidth: 16,
+ }
+ );
+ }
+
+ createBars() {
+ const { width, height } = this.cameras.main;
+ this.assetProgress = new ProgressBar(this, width / 2, height / 2 - 6, {
+ height: 8,
+ width: width * 0.5,
+ });
+ this.serverProgress = new ProgressBar(this, width / 2, height / 2 + 6, {
+ height: 8,
+ width: width * 0.5,
+ });
+ }
+
+ setLoadEvents() {
+ this.load.on(
+ 'progress',
+ (value: number) => {
+ this.assetProgress.updateProgress(value);
+ },
+ this
+ );
+
+ this.load.on('complete', () => {
+ this.startGame();
+ });
+
+ this.server.on('initializing', (value: number) => {
+ this.serverProgress.updateProgress(value);
+ });
+
+ this.server.on('initialized', () => {
+ this.startGame();
+ });
+ }
+
+ startGame() {
+ if (this.server.isInitialized && this.load.isReady()) {
+ this.scene.start(Menu.KEY);
+ }
+ }
+}
diff --git a/apps/games/bit-jumper/src/lib/scenes/credits.ts b/apps/games/bit-jumper/src/lib/scenes/credits.ts
new file mode 100644
index 00000000..c749f6ea
--- /dev/null
+++ b/apps/games/bit-jumper/src/lib/scenes/credits.ts
@@ -0,0 +1,33 @@
+import { VERSION } from '../data';
+import { BackIcon } from '../objects/ui/icons';
+import { Link } from '../objects/ui/link';
+import { Typography } from '../objects/ui/typography';
+import { Menu } from './menu';
+
+export class Credits extends Phaser.Scene {
+ static readonly KEY = 'credits';
+ constructor() {
+ super(Credits.KEY);
+ }
+
+ create() {
+ new BackIcon(this, 2, 2).on('click', () => {
+ this.scene.start(Menu.KEY);
+ });
+ new Typography(this, 45, 20, 'CREDITS');
+ // draw a line under the title
+ this.add.rectangle(45, 25, 60, 1, 0xffffff);
+
+ new Typography(this, 45, 40, 'CODE:');
+ new Link(this, 45, 50, 'ModestDuck', 'https://modestduck.itch.io');
+
+ new Typography(this, 45, 70, 'ART:');
+ new Link(this, 45, 80, 'i-am-44', 'https://i-am-44.itch.io/');
+ new Link(this, 45, 90, 'Polyducks', 'https://polyducks.itch.io/');
+
+ new Typography(this, 45, 110, 'MUSIC:').setFontSize(8);
+ new Link(this, 45, 120, 'ColorAlpha', 'https://coloralpha.itch.io/');
+
+ new Typography(this, 45, 150, VERSION);
+ }
+}
diff --git a/apps/games/bit-jumper/src/lib/scenes/end.ts b/apps/games/bit-jumper/src/lib/scenes/end.ts
new file mode 100644
index 00000000..19751c1a
--- /dev/null
+++ b/apps/games/bit-jumper/src/lib/scenes/end.ts
@@ -0,0 +1,81 @@
+import { CharityGamesPlugin } from '@worksheets/phaser/plugins';
+
+import { StorageKey } from '../data';
+import { Enemies } from '../objects/enemies';
+import { Button } from '../objects/ui/button';
+import { Typography } from '../objects/ui/typography';
+import { GameOverPayload } from '../types';
+import { Menu } from './menu';
+import { Play } from './play';
+
+export class End extends Phaser.Scene {
+ static readonly KEY = 'end';
+ payload: GameOverPayload;
+ server: CharityGamesPlugin;
+
+ constructor() {
+ super(End.KEY);
+ }
+
+ init(payload: GameOverPayload) {
+ this.payload = payload;
+ this.server = CharityGamesPlugin.find(this);
+ }
+
+ create() {
+ const { width, height } = this.cameras.main;
+ const highScore = Math.max(
+ this.payload.score,
+ this.server.storage.get(StorageKey.HIGH_SCORE, 0)
+ );
+
+ this.server.storage.set(StorageKey.HIGH_SCORE, highScore);
+
+ new Typography(this, width * 0.5, 25, 'GAME\nOVER').setFontSize(16);
+
+ new Typography(this, width * 0.5, 65, `SCR: ${this.payload.score}`);
+
+ new Typography(this, width * 0.5, 75, `HI: ${highScore}`);
+
+ new Button(this, width * 0.5, height - 60, 'MENU').on('click', () => {
+ this.scene.start(Menu.KEY);
+ });
+ new Button(this, width * 0.5, height - 40, 'RESTART').on('click', () => {
+ this.scene.start(Play.KEY);
+ });
+
+ const enemies = new Enemies(this);
+ enemies.spawn(width * Phaser.Math.FloatBetween(0.2, 0.8), height * 0.9);
+
+ this.server.storage.save();
+ this.submitScore(highScore);
+ this.submitAchievements(highScore);
+ }
+
+ submitScore(score: number) {
+ this.server.leaderboard.submit(score);
+ }
+
+ submitAchievements(score: number) {
+ const achievements: string[] = [];
+ const { death, powerUps, smashed } = this.payload;
+ death === 'spiker' && achievements.push(achievement('spiker'));
+ death === 'chomper' && achievements.push(achievement('chomper'));
+ death === 'floater' && achievements.push(achievement('floater'));
+ smashed >= 5 && achievements.push(achievement('smasher'));
+ powerUps['balloon'] >= 2 && achievements.push(achievement('balloon'));
+ powerUps['helicopter'] >= 2 && achievements.push(achievement('helicopter'));
+ powerUps['rocket'] >= 2 && achievements.push(achievement('rocket'));
+ score >= 1000 && achievements.push(achievement('1000'));
+ score >= 2500 && achievements.push(achievement('2500'));
+ score >= 5000 && achievements.push(achievement('5000'));
+ score >= 7500 && achievements.push(achievement('7500'));
+ score >= 10000 && achievements.push(achievement('10000'));
+ score >= 15000 && achievements.push(achievement('15000'));
+ score >= 20000 && achievements.push(achievement('20000'));
+
+ this.server.achievements.unlock(achievements);
+ }
+}
+
+const achievement = (name: string) => `bit-jumper:${name}`;
diff --git a/apps/games/bit-jumper/src/lib/scenes/menu.ts b/apps/games/bit-jumper/src/lib/scenes/menu.ts
new file mode 100644
index 00000000..cfce4142
--- /dev/null
+++ b/apps/games/bit-jumper/src/lib/scenes/menu.ts
@@ -0,0 +1,86 @@
+import { CharityGamesPlugin } from '@worksheets/phaser/plugins';
+
+import { StorageKey } from '../data';
+import { Enemies } from '../objects/enemies';
+import { PlatformsPool } from '../objects/platforms';
+import { Button } from '../objects/ui/button';
+import { HeartIcon, SoundIcon } from '../objects/ui/icons';
+import { Typography } from '../objects/ui/typography';
+import { EnemyType, PlatformType } from '../types';
+import { Credits } from './credits';
+
+export class Menu extends Phaser.Scene {
+ static readonly KEY = 'menu';
+ server: CharityGamesPlugin;
+
+ constructor() {
+ super(Menu.KEY);
+ }
+
+ preload() {
+ this.server = CharityGamesPlugin.find(this);
+ }
+
+ create() {
+ const { width, height } = this.cameras.main;
+ const highScore = this.server.storage.get(StorageKey.HIGH_SCORE, 0);
+ const muted = this.server.storage.get(StorageKey.MUTE, false);
+
+ new HeartIcon(this, 2, 2).on('click', () => {
+ this.scene.start(Credits.KEY);
+ });
+ new SoundIcon(this, width - 2, 2)
+ .setOrigin(1, 0)
+ .mute(muted)
+ .on('toggle', (update: boolean) => {
+ this.server.storage.set(StorageKey.MUTE, update);
+ this.server.storage.save();
+ });
+
+ new Typography(this, width / 2, 40, 'BIT\nJUMPER')
+ .setFontSize(16)
+ .setScale(0.9);
+
+ new Button(this, width / 2, height - 60, 'PLAY').on('click', () => {
+ this.scene.start('play');
+ });
+
+ new Button(this, width / 2, height - 40, `HI: ${highScore}`);
+
+ this.spawnPlatforms();
+ this.spawnEnemies();
+ }
+
+ spawnPlatforms() {
+ const { width, height } = this.cameras.main;
+
+ const pool = new PlatformsPool(this);
+ const offset = 5;
+ const options: PlatformType[] = ['basic', 'basic', 'breaking', 'sliding'];
+ const randomWithinScreen = () =>
+ Phaser.Math.Between(offset, width - offset);
+ for (let i = 1; i < 8; i++) {
+ const key = Phaser.Math.RND.pick(options);
+ this.add.existing(
+ pool.get(key).place(randomWithinScreen(), height - 21 * i)
+ );
+ }
+ }
+
+ spawnEnemies() {
+ const { width, height } = this.cameras.main;
+
+ const pool = new Enemies(this);
+ const offset = 15;
+ const options: EnemyType[] = ['spiker', 'chomper', 'floater'];
+
+ const randomWithinScreen = () =>
+ Phaser.Math.Between(width / 2 - offset, width / 2 + offset);
+
+ const key = Phaser.Math.RND.pick(options);
+
+ this.add.existing(
+ pool.getOrCreate(key).place(randomWithinScreen(), height / 2)
+ );
+ }
+}
diff --git a/apps/games/bit-jumper/src/lib/scenes/play.ts b/apps/games/bit-jumper/src/lib/scenes/play.ts
new file mode 100644
index 00000000..4e408c25
--- /dev/null
+++ b/apps/games/bit-jumper/src/lib/scenes/play.ts
@@ -0,0 +1,117 @@
+import Phaser from 'phaser';
+
+import { Collisions } from '../objects/collisions';
+import { Controller } from '../objects/controller';
+import { Enemies } from '../objects/enemies';
+import { Observer } from '../objects/observer';
+import { Particles } from '../objects/particles';
+import { Platforms } from '../objects/platforms';
+import { Player } from '../objects/player';
+import { PowerUps } from '../objects/power-ups';
+import { DeathReason, GameOverPayload, PowerUpType } from '../types';
+import { End } from './end';
+import { ScorePlane } from './score-plane';
+
+export class Play extends Phaser.Scene {
+ static readonly KEY = 'play';
+ static readonly GAME_OVER_DELAY = 3000;
+ particles: Particles;
+ player: Player;
+ controller: Controller;
+ observer: Observer;
+ collisions: Collisions;
+ platforms: Platforms;
+ powerUps: PowerUps;
+ enemies: Enemies;
+
+ constructor() {
+ super(Play.KEY);
+ }
+
+ create() {
+ const { width, height } = this.sys.game.canvas;
+ this.particles = new Particles(this, 0, 0);
+ this.player = new Player(this, width / 1.25, height / 1.5);
+ this.observer = new Observer(this, this.player);
+ this.platforms = new Platforms(this, this.observer);
+ this.controller = new Controller(this, this.player);
+ this.powerUps = new PowerUps(this);
+ this.enemies = new Enemies(this);
+ this.collisions = new Collisions(
+ this,
+ this.player,
+ this.platforms,
+ this.powerUps,
+ this.enemies
+ );
+
+ this.scene.launch(ScorePlane.KEY);
+ this.connect();
+ this.cleanup();
+ }
+
+ connect() {
+ const scorePlane = this.scene.get(ScorePlane.KEY) as ScorePlane;
+ this.time.delayedCall(250, () => {
+ this.controller.enable();
+ });
+
+ this.platforms.on(Platforms.SPAWN_EVENT, (x: number, y: number) => {
+ const luck = Phaser.Math.Between(0, 100);
+ if (luck > PowerUps.SPAWN_CHANCE) return;
+
+ this.powerUps.spawn(x, y);
+ });
+
+ this.platforms.on(Platforms.SPAWN_EVENT, (x: number, y: number) => {
+ const luck = Phaser.Math.Between(0, 100);
+ if (luck > Enemies.SPAWN_CHANCE) return;
+
+ this.enemies.spawn(x, y);
+ });
+
+ this.observer.events.on(Observer.EVENT_APEX_CHANGE, (apex: number) => {
+ if (apex < 0) {
+ const score = Math.floor(Math.abs(apex));
+ this.scene
+ .get(ScorePlane.KEY)
+ .events.emit(ScorePlane.EVENT_UPDATE_SCORE, score);
+ }
+ });
+
+ this.player.on(Player.EVENT_DEATH, (reason: DeathReason) => {
+ this.time.delayedCall(Play.GAME_OVER_DELAY, () => {
+ const payload: GameOverPayload = {
+ death: reason,
+ score: scorePlane.score,
+ powerUps: scorePlane.powerUps,
+ smashed: scorePlane.smashed,
+ };
+ this.scene.stop(ScorePlane.KEY);
+ this.scene.start(End.KEY, payload);
+ });
+ });
+
+ this.player.on(Player.EVENT_POWER_UP_START, (key: PowerUpType) => {
+ scorePlane.events.emit(ScorePlane.EVENT_POWER_UP, key);
+ });
+
+ this.player.on(Player.EVENT_SMASH_ENEMY, () => {
+ scorePlane.events.emit(ScorePlane.EVENT_SMASH_ENEMY);
+ });
+ }
+
+ update() {
+ this.controller.update();
+ this.observer.update();
+ this.platforms.update();
+ this.enemies.update();
+ }
+
+ cleanup() {
+ this.events.on('shutdown', () => {
+ this.observer.cleanup();
+ this.platforms.off(Platforms.SPAWN_EVENT);
+ });
+ }
+}
diff --git a/apps/games/bit-jumper/src/lib/scenes/score-plane.ts b/apps/games/bit-jumper/src/lib/scenes/score-plane.ts
new file mode 100644
index 00000000..7a7ca2a5
--- /dev/null
+++ b/apps/games/bit-jumper/src/lib/scenes/score-plane.ts
@@ -0,0 +1,60 @@
+import { Typography } from '../objects/ui/typography';
+import { PowerUpType } from '../types';
+
+export class ScorePlane extends Phaser.Scene {
+ static readonly KEY = 'score-plane';
+ static readonly EVENT_UPDATE_SCORE = 'update-score';
+ static readonly EVENT_POWER_UP = 'power-up';
+ static readonly EVENT_SMASH_ENEMY = 'smash-enemy';
+ static readonly SMASH_ENEMY_POINTS = 100;
+ typography: Typography;
+ score: number;
+ smashed: number;
+ powerUps: Record;
+ constructor() {
+ super(ScorePlane.KEY);
+ this.score = 0;
+ this.smashed = 0;
+ this.powerUps = {
+ balloon: 0,
+ helicopter: 0,
+ rocket: 0,
+ };
+ }
+
+ updateScore(score: number) {
+ this.score = score;
+ this.typography.setText(`${score}`);
+ }
+
+ create() {
+ this.typography = new Typography(this, 4, 4, '0')
+ .setAlign('left')
+ .setOrigin(0);
+
+ this.events.on(
+ ScorePlane.EVENT_UPDATE_SCORE,
+ (score: number) => {
+ this.updateScore(score);
+ },
+ this
+ );
+
+ this.events.on(
+ ScorePlane.EVENT_POWER_UP,
+ (key: PowerUpType) => {
+ this.powerUps[key]++;
+ },
+ this
+ );
+
+ this.events.on(
+ ScorePlane.EVENT_SMASH_ENEMY,
+ () => {
+ this.updateScore(this.score + ScorePlane.SMASH_ENEMY_POINTS);
+ this.smashed++;
+ },
+ this
+ );
+ }
+}
diff --git a/apps/games/bit-jumper/src/lib/types.ts b/apps/games/bit-jumper/src/lib/types.ts
new file mode 100644
index 00000000..91049ae0
--- /dev/null
+++ b/apps/games/bit-jumper/src/lib/types.ts
@@ -0,0 +1,19 @@
+export type PlatformType =
+ | 'basic'
+ | 'spring'
+ | 'sliding'
+ | 'breaking'
+ | 'floor';
+
+export type EnemyType = 'spiker' | 'chomper' | 'floater';
+
+export type PowerUpType = 'balloon' | 'helicopter' | 'rocket';
+
+export type GameOverPayload = {
+ score: number;
+ death: DeathReason;
+ powerUps: Record;
+ smashed: number;
+};
+
+export type DeathReason = EnemyType | 'fall';
diff --git a/apps/games/bit-jumper/src/main.ts b/apps/games/bit-jumper/src/main.ts
new file mode 100644
index 00000000..2fb2edaa
--- /dev/null
+++ b/apps/games/bit-jumper/src/main.ts
@@ -0,0 +1,50 @@
+import 'phaser';
+
+import { CharityGamesPlugin } from '@worksheets/phaser/plugins';
+
+import { Boot } from './lib/scenes/boot';
+import { Credits } from './lib/scenes/credits';
+import { End } from './lib/scenes/end';
+import { Menu } from './lib/scenes/menu';
+import { Play } from './lib/scenes/play';
+import { ScorePlane } from './lib/scenes/score-plane';
+
+const config: Phaser.Types.Core.GameConfig = {
+ type: Phaser.AUTO,
+ backgroundColor: '#000000',
+ roundPixels: true,
+ render: {
+ pixelArt: true,
+ },
+ scale: {
+ parent: 'game',
+ mode: Phaser.Scale.FIT,
+ autoCenter: Phaser.Scale.CENTER_BOTH,
+ width: 90,
+ height: 160,
+ },
+ scene: [Boot, Menu, Play, End, ScorePlane, Credits],
+ plugins: {
+ global: [
+ {
+ key: CharityGamesPlugin.KEY,
+ plugin: CharityGamesPlugin,
+ start: true,
+ },
+ ],
+ },
+ physics: {
+ default: 'arcade',
+ arcade: {
+ gravity: { y: 250, x: 0 },
+ debug: false,
+ },
+ },
+ dom: {
+ createContainer: true,
+ },
+};
+
+window.addEventListener('load', () => {
+ new Phaser.Game(config);
+});
diff --git a/apps/games/bit-jumper/src/styles.css b/apps/games/bit-jumper/src/styles.css
new file mode 100644
index 00000000..169a5b28
--- /dev/null
+++ b/apps/games/bit-jumper/src/styles.css
@@ -0,0 +1,25 @@
+body,
+html {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+ margin: 0px;
+ position: relative;
+ touch-action: none;
+ /* background: linear-gradient(180deg, #fff -10%, #d0eeff 110%); */
+ background-color: #000;
+}
+canvas {
+ border: 4px solid #fff;
+ box-sizing: border-box;
+ margin: auto;
+}
+
+@font-face {
+ font-family: 'frog-block';
+ src: url('/assets/fonts/frog-block.ttf');
+ font-weight: 400;
+ font-weight: normal;
+}
diff --git a/apps/games/bit-jumper/tsconfig.app.json b/apps/games/bit-jumper/tsconfig.app.json
new file mode 100644
index 00000000..357ad000
--- /dev/null
+++ b/apps/games/bit-jumper/tsconfig.app.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../../dist/out-tsc",
+ "types": ["node"]
+ },
+ "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"],
+ "include": ["src/**/*.ts"]
+}
diff --git a/apps/games/bit-jumper/tsconfig.json b/apps/games/bit-jumper/tsconfig.json
new file mode 100644
index 00000000..149b69e8
--- /dev/null
+++ b/apps/games/bit-jumper/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "files": [],
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ESNext", "DOM"],
+ "moduleResolution": "Node",
+ "strict": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "noEmit": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noImplicitReturns": true,
+ "skipLibCheck": true,
+ "strictPropertyInitialization": false,
+ "types": ["vite/client"]
+ },
+ "include": ["src"],
+ "references": [
+ {
+ "path": "./tsconfig.app.json"
+ }
+ ]
+}
diff --git a/apps/games/bit-jumper/vite.config.ts b/apps/games/bit-jumper/vite.config.ts
new file mode 100644
index 00000000..72f042a4
--- /dev/null
+++ b/apps/games/bit-jumper/vite.config.ts
@@ -0,0 +1,31 @@
+///
+import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
+import dotenv from 'dotenv';
+import { defineConfig } from 'vite';
+
+dotenv.config({
+ path: '.env',
+});
+export default defineConfig({
+ root: __dirname,
+ cacheDir: '../../../node_modules/.vite/apps/games/bit-jumper',
+ base: './', // Use relative paths for all assets
+ define: {
+ 'process.env': process.env,
+ },
+ server: {
+ port: 7013,
+ host: 'localhost',
+ },
+
+ plugins: [nxViteTsPaths()],
+
+ build: {
+ outDir: '../../../dist/apps/games/bit-jumper',
+ emptyOutDir: true,
+ reportCompressedSize: true,
+ commonjsOptions: {
+ transformMixedEsModules: true,
+ },
+ },
+});
diff --git a/apps/prisma/seed/src/insert-achievements.ts b/apps/prisma/seed/src/insert-achievements.ts
index bb5a8df7..8e49527c 100644
--- a/apps/prisma/seed/src/insert-achievements.ts
+++ b/apps/prisma/seed/src/insert-achievements.ts
@@ -3,12 +3,15 @@ import { prisma } from '@worksheets/prisma';
import { getSeedingChanges, seedingProperties } from '@worksheets/util/seeding';
import { SeedableGameAchievementSchema } from '@worksheets/util/types';
+type GameAchievementSchema = SeedableGameAchievementSchema & { gameId: string };
export const insertAchievements = async () => {
const storedAchievements = await prisma.gameAchievement.findMany({
select: seedingProperties,
});
- const achievements = games.flatMap((g) => g.achievements);
+ const achievements: GameAchievementSchema[] = games.flatMap((g) =>
+ g.achievements.map((a) => ({ ...a, gameId: g.id }))
+ );
const { creating, updating } = getSeedingChanges(
achievements,
@@ -32,9 +35,7 @@ export const insertAchievements = async () => {
}
};
-const insertAchievement = async (
- achievement: SeedableGameAchievementSchema
-) => {
+const insertAchievement = async (achievement: GameAchievementSchema) => {
await prisma.$transaction(async (tx) => {
await tx.gameAchievement.create({
data: {
@@ -63,9 +64,7 @@ const insertAchievement = async (
});
};
-const updateAchievement = async (
- achievement: SeedableGameAchievementSchema
-) => {
+const updateAchievement = async (achievement: GameAchievementSchema) => {
await prisma.$transaction(async (tx) => {
await tx.gameAchievement.update({
where: {
diff --git a/libs/data/games/src/index.ts b/libs/data/games/src/index.ts
index db6594b0..388d023d 100644
--- a/libs/data/games/src/index.ts
+++ b/libs/data/games/src/index.ts
@@ -11,6 +11,266 @@ import {
import { SeedableGameSchema } from '@worksheets/util/types';
const integratedGames: SeedableGameSchema[] = [
+ {
+ version: 2,
+ id: 'bit-jumper',
+ name: 'Bit Jumper',
+ developerId: 'charity-games',
+ iconUrl:
+ 'https://cdn.charity.games/_games/bit-jumper/assets/media/thumbnail.png',
+ bannerUrl:
+ 'https://cdn.charity.games/_games/bit-jumper/assets/media/banner.png',
+ viewport: viewports['PORTRAIT-ONLY'],
+ categories: ['1p', 'arcade', 'endless', 'mobile', 'desktop'],
+ file: {
+ type: 'HTML',
+ url: 'https://cdn.charity.games/_games/bit-jumper/index.html',
+ },
+ markets: {},
+ createdAt: new Date('2024-08-24T00:00:00.000Z'),
+ updatedAt: new Date('2024-08-24T00:00:00.000Z'),
+ description:
+ '
Bit Jumper is a fast-paced endless runner where you must jump from platform to platform to reach the highest score possible. How high can you jump?Move with left/right arrow keys or touching the left/right sides of the screen.