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

import Spear from "./spear";
import Player from "./player";
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";
import HUD from "./hud";

const DEVICE_KEY = "OETZI/DEVICE_ID";

interface InputStatus {
  began_at: string;
  ended_at: string;
  typed: string;
  final: string;
  began_at_gmtm: number;
  ended_at_gmtm: number;
}

interface UIDimensions {
  kbdHeight: number;
  statsPadding: number;
  statsFontSize: string;
  statsHeight: number;
  inputPadding: number;
  inputFontSize: string;
  inputHeight: number;
  inputPosition: number;
  cluesBounds: Phaser.Geom.Rectangle;
}

export default class FightScene extends Phaser.Scene {
  foes: Array<Foe>;
  player: Player;
  cluesGroup: Phaser.Physics.Arcade.Group;
  beDevice: Types.Device;
  beGame: Types.Game;
  typewriter: Typewriter;
  score: number;
  health: number;
  hud: HUD;
  gameTime: Phaser.Time.TimerEvent;
  uiDimensions: UIDimensions;
  music!: Phaser.Sound.BaseSound;
  spawner: Phaser.Time.TimerEvent;

  constructor() {
    super("fight");
    this.foes = [];
  }

  preload() {
    this.preloadSprites();
    this.preloadSoundsEffects();
    this.preloadMusicThemes();
  }

  preloadSoundsEffects() {
    this.load.audio("sfx_lo_beep", "assets/audio/Cancel 1.wav");
    this.load.audio("sfx_md_beep", "assets/audio/Text 1.wav");
    this.load.audio("sfx_hi_beep", "assets/audio/Confirm 1.wav");
    this.load.audio("sfx_hit_critter", "assets/audio/Hit damage 1.wav");
    this.load.audio("sfx_hit_player", "assets/audio/Boss hit 1.wav");
    this.load.audio("sfx_game_over", "assets/audio/Bubble heavy 2.wav");
  }

  preloadMusicThemes() {
    this.load.audio("bkg_main_1", "assets/music/loop.wav");
    this.load.audio("bkg_main_2", "assets/music/loopTwo.wav");
    this.load.audio("bkg_main_3", "assets/music/loopThree.wav");
  }

  preloadSprites() {
    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,
    });
  }

  init() {
    this.score = 0;
    this.health = 10000;

    this.uiDimensions = this.initUiDimensions();
    this.hud = new HUD(this, {
      statsPadding: this.uiDimensions.statsPadding,
      statsFontSize: this.uiDimensions.statsFontSize,
      inputPadding: this.uiDimensions.inputPadding,
      inputFontSize: this.uiDimensions.inputFontSize,
      inputPosition: this.uiDimensions.inputPosition,
    });
    this.hud.setHealth(this.health);
    this.hud.setScore(this.score);
    this.hud.setClock(0);

    this.events.on("pause", this.onPause.bind(this));
    this.events.on("resume", this.onResume.bind(this));
  }

  onPause() {
    this.concealClues();
    this.typewriter.setActive(false);
    this.music.pause();
    this.scene.launch("pause");
  }

  onResume() {
    this.uncoverClues();
    this.typewriter.setActive(true);
    this.music.play();
    this.scene.stop("pause");
  }

  initUiDimensions(): UIDimensions {
    const ch = this.cameras.main.height;
    const cw = this.cameras.main.width;
    const vh = ch * 0.01;
    const vw = cw * 0.01;

    const kbdHeight = Math.min(40 * vh, 48 * vw); // see max-height of .hg-theme-default

    const statsPadding = Math.min(1 * vw, 10);
    const statsFontSize = "max(3vw,20px)"; // never smaller than 20px for readability
    const statsHeight = Math.max(3 * vw, 20) * 1.4 + 2 * statsPadding;

    const inputPadding = Math.min(0.5 * vw, 5);
    const inputFontSize = "min(12vw,60px)"; // always fit ~12 chars comfortably in width
    const inputHeight = Math.min(12 * vw, 60) * 1.4 + 2 * inputPadding;
    const inputPosition = (ch - kbdHeight - 0.5 * inputHeight) / ch;

    const cluesBounds = new Phaser.Geom.Rectangle(
      5,
      statsHeight,
      cw - 2 * 15,
      ch - statsHeight - kbdHeight - inputHeight,
    );

    return {
      kbdHeight,
      statsPadding,
      statsFontSize,
      statsHeight,
      inputPadding,
      inputFontSize,
      inputHeight,
      inputPosition,
      cluesBounds,
    };
  }

  musicSoftReplace(
    nextMusic: Phaser.Sound.BaseSound,
    prevMusic: Phaser.Sound.BaseSound,
  ) {
    this.music = prevMusic;
    this.music.on("complete", () => {
      this.music.stop();
      this.music.destroy();
      this.music = nextMusic;
      this.music.play();
    });
  }

  async create(data: { music: Phaser.Sound.BaseSound }) {
    this.musicSoftReplace(
      this.sound.add("bkg_main_1", { loop: true }),
      data.music,
    );

    this.bindPauseShortcut();

    this.gameTime = this.time.addEvent({
      delay: Number.MAX_SAFE_INTEGER,
      paused: true,
    });

    this.initCluesGroup();

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

    // NOTE: this helps w/ clue sprite overlap
    this.physics.world.setFPS(2 * this.game.loop.targetFps);

    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 = new Player(this);

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

    this.createAndBindTypewriter();

    await this.initBeDevice();
    await this.initBeGame();

    this.gameTime.paused = false;
    this.spawnFoes();
  }

  async initBeDevice() {
    const deviceId = sessionStorage.getItem(DEVICE_KEY);
    if (deviceId === null) {
      this.beDevice = (await backend.createDevice()).data;
    } else {
      this.beDevice = (await backend.getDevice(deviceId)).data;
    }
    sessionStorage.setItem(DEVICE_KEY, this.beDevice.id);
  }

  async initBeGame() {
    this.beGame = (
      await backend.createGame(this.beDevice.id, {
        began_at: new Date().toISOString(),
        began_at_gmtm: this.getGameTime(),
      })
    ).data;
  }

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

  createAnimation(key: string, refKey: string, from: number, to: number) {
    this.anims.create({
      key: key,
      frames: this.anims.generateFrameNumbers(refKey, {
        start: from,
        end: to,
      }),
      frameRate: 10,
      repeat: -1,
    });
  }

  updateScore(delta: number) {
    this.score += delta;
    this.hud.setScore(this.score);
    this.hud.changeFlash(this.hud.score, delta > 0 ? 0xffff00 : 0xff0000);
  }

  updateHealth(delta: number) {
    this.health += delta;
    this.health = Math.max(this.health, 0);
    this.hud.setHealth(this.health);
    this.hud.changeFlash(this.hud.health, delta > 0 ? 0x00ff00 : 0xff0000);
    this.checkAlive();
  }

  update(time: number, delta: number): void {
    // TODO: something poissonian
    // console.log(time, delta);
    this.hud.setClock(this.getGameTime());
  }

  checkAlive() {
    if (this.health > 0) return;
    this.endGame();
  }

  async endGame() {
    this.beGame = (
      await backend.updateGame(this.beGame.id, {
        ended_at: new Date().toISOString(),
        ended_at_gmtm: this.getGameTime(),
      })
    ).data;
    this.spawner.remove();
    this.foes.forEach((foe) => foe.destroy());
    this.sound.play("sfx_game_over");
    this.typewriter.setActive(false);
    this.typewriter.resetInputStatus();
    this.scene.start("game_over", { music: this.music });
  }

  initCluesGroup() {
    this.cluesGroup = this.physics.add.group({
      collideWorldBounds: true,
      customBoundsRectangle: this.uiDimensions.cluesBounds,
      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) => {
      // TODO: accept case insensitive match w/ penalty?
      const similarity = levenshtein(
        transcription,
        foe.beWord.ocr_transcript,
      ).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) {
    // NOTE: this ain't async to avoid any UX delay
    const { score, match } = this.findMatchingFoe(inputStatus.final);
    backend.createShot(this.beGame.id, {
      clue_id: match?.beClue?.id || null,
      ...inputStatus,
    });
    if (match === null) {
      // NOOP
      this.sound.play("sfx_md_beep");
      this.hud.showSubmitFeedback("white", inputStatus.final);
    } else if (score < 0.9) {
      // TODO: visual near misses based on score
      this.sound.play("sfx_lo_beep");
      this.updateScore(-1);
      match.handleFailure();
      this.hud.showSubmitFeedback("red", inputStatus.final);
      new Spear(this, this.player, undefined);
    } else {
      this.sound.play("sfx_hi_beep");
      backend.updateClue(match.beClue.id, {
        ended_at: new Date().toISOString(),
        ended_at_gmtm: this.getGameTime(),
      });
      this.updateScore(+10);
      this.popFoe(match);
      match.handleSuccess();
      this.hud.showSubmitFeedback("green", inputStatus.final);
      new Spear(this, this.player, match.critter);
      // TODO: increase score
    }
  }

  createAndBindTypewriter() {
    this.typewriter ??= new Typewriter(this.game.device.os.desktop);
    this.typewriter.setActive(true);
    this.typewriter.getGameTime = this.getGameTime.bind(this);
    this.typewriter.onSubmit = async (inputStatus) => {
      if (inputStatus.began_at === null) return;
      if (inputStatus.ended_at === null) return;
      if (inputStatus.began_at_gmtm === null) return;
      if (inputStatus.ended_at_gmtm === null) return;
      if (inputStatus.final === "") return;
      this.hud.setInput("");
      this.submitTranscription({
        began_at: inputStatus.began_at.toISOString(),
        ended_at: inputStatus.ended_at.toISOString(),
        typed: inputStatus.typed,
        final: inputStatus.final,
        began_at_gmtm: inputStatus.began_at_gmtm,
        ended_at_gmtm: inputStatus.ended_at_gmtm,
      });
    };
    this.typewriter.onChange = (inputStatus) => {
      this.sound.play("sfx_md_beep");
      this.hud.setInput(inputStatus.final);
    };
  }

  clamp(num: number, min: number, max: number) {
    return Math.min(Math.max(num, min), max);
  }

  randomExponential(rate = 1) {
    // http://en.wikipedia.org/wiki/Exponential_distribution#Generating_exponential_variates
    return -Math.log(Math.random()) / rate;
  }

  randomPareto(scale = 1, shape = 1) {
    // https://en.wikipedia.org/wiki/Pareto_distribution#Random_sample_generation
    return scale / Math.pow(Math.random(), 1 / shape);
  }

  async spawnFoes() {
    const AVG_WPM = 40; // avg is 41.4, current world record is ~212wpm
    const minDelay = 60 / (5.0 * AVG_WPM); // 0.3s -> world record!
    const maxDelay = 60 / (0.2 * AVG_WPM); // 7.5s -> utter boredom!

    const expDelay = 60 / (1.0 * AVG_WPM); // 1.5s -> average typer
    const rate = 1 / expDelay;

    const delay =
      this.clamp(this.randomExponential(rate), minDelay, maxDelay) * 1000;

    const AVG_CPM = 200; // corresponds to AVG_WPM and AVG_CPW = 5
    const minLength = 1;
    const maxLength = 9;

    const expLength = AVG_CPM / AVG_WPM; // i.e. 5 char is avg
    const scale = minLength;
    const shape = expLength / (expLength - scale);

    const length = this.clamp(
      Math.round(this.randomPareto(scale, shape)),
      minLength,
      maxLength,
    );

    // const minCount = 0; // NOTE: no minCount because the player deserves rest
    const maxCount = 3;
    // const minChars = 0; // NOTE: no minChars because the player deserves rest
    const maxChars = 30;

    const currentCount = this.foes.length;
    const currentChars = this.foes
      .map((foe) => foe.beWord.ocr_transcript.length)
      .reduce((a, b) => a + b, 0);

    // TODO: some smart length-dependent randomization
    const duration = 5;

    if (currentCount < maxCount && currentChars < maxChars) {
      // TODO: shorter should get faster
      await this.spawnFoe(length, duration);
    }

    // TODO: stuff should oscillate increasing to give illusion of waves
    // TODO: increase difficulty w/ game time

    this.spawner = this.time.delayedCall(delay, this.spawnFoes.bind(this));
  }

  async spawnFoe(length: number, timeout: number) {
    // TODO: this is a terrible pattern
    await new Foe(this, timeout).initialize(length);
  }

  concealClues() {
    this.foes.forEach((foe) => foe.clue.conceal());
  }

  uncoverClues() {
    this.foes.forEach((foe) => foe.clue.uncover());
  }

  getGameTime() {
    // NOTE: we don't need sub-ms precision.
    // NOTE: pretty please, don't access the timer directly.
    return Math.round(this.gameTime.getElapsed());
  }

  bindPauseShortcut() {
    if (this.game.device.os.desktop) {
      const escBinding = this.input.keyboard.addKey(
        Phaser.Input.Keyboard.KeyCodes.ESC,
      );
      escBinding.onDown = () => this.scene.pause();
    } else {
      const onPointerUp = (pointer: Phaser.Input.Pointer) => {
        const tapped =
          pointer.downY <
          this.cameras.main.height - this.uiDimensions.kbdHeight;
        if (tapped) this.scene.pause();
      };
      this.input.on("pointerup", onPointerUp);
    }
  }
}