Skip to content
Snippets Groups Projects
fight_scene.ts 8.74 KiB
import "phaser";

import Spear from "./spear";
import backend from "./backend";

// TODO: write interfaces
import levenshtein from "damerau-levenshtein";

import * as Types from "../../../backend/src/types";
import Foe from "./foe";
import Typewriter from "./typewriter";

interface InputStatus {
  began_at: string | null;
  ended_at: string | null;
  typed: string;
  final: string;
}

interface HUD {
  score: Phaser.GameObjects.Text;
  input: Phaser.GameObjects.Text;
  health: Phaser.GameObjects.Text;
}

export default class FightScene extends Phaser.Scene {
  foes: Array<Foe>;
  player: Phaser.Types.Physics.Arcade.SpriteWithDynamicBody;
  cluesGroup: Phaser.Physics.Arcade.Group;
  beGame: Types.Game;
  inputStatus: InputStatus;
  typewriter: Typewriter;
  score: number;
  health: number;
  hud: HUD;

  constructor() {
    super("fight");
    this.foes = [];
    this.score = 0;
    this.health = 100;
    this.hud = {};
  }

  preload() {
    this.load.spritesheet("oezi", "assets/sprites/player/oezi.png", {
      frameWidth: 27,
      frameHeight: 35,
    });
    this.load.spritesheet("deer", "assets/sprites/player/deer.png", {
      frameWidth: 72,
      frameHeight: 52,
    });
    this.load.spritesheet("boar", "assets/sprites/player/boar.png", {
      frameWidth: 52,
      frameHeight: 28,
    });
    this.load.spritesheet("wolf", "assets/sprites/player/wolf.png", {
      frameWidth: 54,
      frameHeight: 35,
    });
    this.load.spritesheet("bear", "assets/sprites/player/bear.png", {
      frameWidth: 60,
      frameHeight: 31,
    });
    this.load.spritesheet("spear", "assets/sprites/player/spear.png", {
      frameWidth: 31,
      frameHeight: 7,
    });
    this.load.spritesheet("spearhit", "assets/sprites/player/spearhit.png", {
      frameWidth: 14,
      frameHeight: 33,
    });
  }

  async create() {
    this.inputStatus = {
      typed: "",
      final: "",
    };
    this.initCluesGroup();

    createAnim(this, "player_idle", "oezi", 1, 5);
    createAnim(this, "player_run", "oezi", 6, 13);
    createAnim(this, "deer_run", "deer", 0, 5);
    createAnim(this, "deer_idle", "deer", 6, 15);
    createAnim(this, "deer_walk", "deer", 16, 23);
    createAnim(this, "boar_run", "boar", 0, 5);
    createAnim(this, "boar_idle", "boar", 6, 13);
    createAnim(this, "boar_walk", "boar", 14, 22);
    createAnim(this, "wolf_run", "wolf", 0, 5);
    createAnim(this, "wolf_idle", "wolf", 6, 15);
    createAnim(this, "wolf_walk", "wolf", 16, 23);
    createAnim(this, "bear_run", "bear", 12, 16);
    createAnim(this, "bear_idle", "bear", 0, 11);
    createAnim(this, "bear_walk", "bear", 17, 24);
    createAnim(this, "spearAni", "spear", 0, 3);
    createAnim(this, "spearHitAni", "spearhit", 0, 8);

    this.physics.world.setBounds(
      0,
      0,
      this.cameras.main.width,
      this.cameras.main.height - 30,
      false,
      false,
      false,
      true,
    );

    this.physics.world.on(
      "worldbounds",
      function (
        body: Phaser.Physics.Arcade.Body,
        up: boolean,
        down: boolean,
        left: boolean,
        right: boolean,
      ) {
        body.gameObject.emit("hitWorldBounds", { up, down, left, right });
      },
    );

    this.player = this.physics.add
      .sprite(
        this.cameras.main.width + 300,
        this.cameras.main.height - 100,
        "oezi",
      )
      .setScale(3)
      .setInteractive();

    this.player.flipX = true;

    this.player.play({ key: "player_run" });

    this.player.setCollideWorldBounds(true);

    this.tweens.add({
      targets: this.player,
      x: this.cameras.main.width - 80,
      ease: "Power2",
      duration: 2000,
      onComplete: () => {
        setAnimation(this.player, "player_run");
      },
    });

    // this.scale.displaySize.setAspectRatio(
    //   this.cameras.main.width / this.cameras.main.height,
    // );
    // this.scale.refresh();
    this.initAndBindGuessPreview();

    this.initHUD();

    this.beGame = (await backend.createGame()).data;
    this.beGame = (
      await backend.updateGame(this.beGame.id, {
        began_at: new Date().toISOString(),
        ended_at: null,
      })
    ).data;

    gameStart(this);
  }

  initHUD() {
    this.hud.score = this.add.text(10, 10, "", {
      font: "bold 48px Courier",
      color: "lightgreen",
    });
    this.hud.score.setOrigin(0, 0);
    this.updateScore(0);

    this.hud.health = this.add.text(this.cameras.main.width - 10, 10, "", {
      font: "bold 48px Courier",
      color: "orange",
    });
    this.hud.health.setOrigin(1, 0);
    this.updateHealth(0);
  }

  updateScore(delta) {
    this.score += delta;
    this.hud.score.text = "✪ " + this.score.toString();
  }

  updateHealth(delta) {
    this.health += delta;
    this.health = Math.max(this.health, 0);
    this.hud.health.text = this.health.toString() + " ❤";
    this.checkAlive();
  }

  checkAlive() {
    if (this.health > 0) return;
    // TODO: destroy scene more gracefully, as some stuff breaks
    this.scene.start("game_over");
  }

  showSubmitFeedback(color: string) {
    const text = this.add.text(
      this.cameras.main.width / 2,
      this.cameras.main.height / 2,
      this.hud.input.text,
      {
        font: "bold 64px Courier",
        color: color,
      },
    );
    text.setOrigin(0.5, 0.5);
    this.tweens.add({
      targets: text,
      scaleX: 5,
      scaleY: 5,
      alpha: 0,
      ease: "Power2",
      duration: 500,
      onComplete: (_tween, [target]) => target.destroy(),
    });
  }

  initCluesGroup() {
    const bounds = new Phaser.Geom.Rectangle(
      0,
      0,
      this.cameras.main.width,
      this.cameras.main.height / 2,
    );
    this.cluesGroup = this.physics.add.group({
      collideWorldBounds: true,
      customBoundsRectangle: bounds,
      bounceY: 0.2,
      dragY: 180,
    });
    this.physics.add.collider(this.cluesGroup, this.cluesGroup);
  }

  findMatchingFoe(transcription: string) {
    let result: { score: number; match: Foe | null } = {
      score: -1,
      match: null,
    };
    if (this.foes.length < 1) return result;
    this.foes.forEach((foe) => {
      const similarity = levenshtein(
        transcription.toLowerCase(),
        foe.beWord.ocr_transcript.toLowerCase(),
      ).similarity;
      if (similarity < result.score) return;
      result = { score: similarity, match: foe };
    });
    // match ??= scene.foes[0]; // TODO: remove this
    // console.log(similarity, match.beWord.ocr_transcript);
    return result;
  }

  popFoe(foe) {
    this.foes.splice(this.foes.indexOf(foe), 1);
  }

  submitTranscription(inputStatus: InputStatus) {
    const { score, match } = this.findMatchingFoe(inputStatus.final);
    // TODO: visual near misses based on score
    if (match === null) {
      // NOOP
      this.showSubmitFeedback("#FFFFFF");
    } else if (score < 0.9) {
      this.updateScore(-1);
      match.handleFailure();
      this.showSubmitFeedback("#FF0000");
      new Spear(this, this.player, undefined);
    } else {
      this.updateScore(+10);
      this.popFoe(match);
      match.handleSuccess();
      this.showSubmitFeedback("#00FF00");
      new Spear(this, this.player, match.critter);
      // TODO: increase score
    }
  }

  initAndBindGuessPreview() {
    this.hud.input = this.add.text(
      this.cameras.main.width / 2,
      this.cameras.main.height / 2,
      "",
      {
        font: "bold 64px Courier",
        color: "#ffffff",
      },
    );
    this.hud.input.setOrigin(0.5, 0.5);
    this.typewriter = new Typewriter();
    this.typewriter.setHidden(this.game.device.os.desktop);
    this.typewriter.onSubmit = (inputStatus) => {
      if (inputStatus.began_at === null) return;
      if (inputStatus.ended_at === null) return;
      if (inputStatus.final === "") return;
      this.submitTranscription({
        began_at: inputStatus.began_at.toISOString(),
        ended_at: inputStatus.ended_at.toISOString(),
        typed: inputStatus.typed,
        final: inputStatus.final,
      });
      this.hud.input.text = "";
    };
    this.typewriter.onChange = (inputStatus) => {
      this.hud.input.text = inputStatus.final;
    };
  }
}

// TODO: remove any
function setAnimation(obj: any, idleKey: any) {
  obj.play({ key: idleKey, repeat: -1 });
}

function createAnim(scene: any, key: any, refKey: any, from: any, to: any) {
  scene.anims.create({
    key: key,
    frames: scene.anims.generateFrameNumbers(refKey, {
      start: from,
      end: to,
    }),
    frameRate: 10,
    repeat: -1,
  });
}

function gameStart(scene: any) {
  spawn(scene);
}

async function spawn(scene: any) {
  await spawnFoe(scene);
  scene.time.now;
  // const delay = 2000;
  const delay =
    (8 * 1000 * (60 * 1000 - scene.time.now)) / 60 / 1000 + 2 * 1000;
  setTimeout(() => spawn(scene), Math.max(delay, 2000));
}

async function spawnFoe(scene: any) {
  await new Foe(scene).initialize();
}