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; ended_at: string; 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; typewriter: Typewriter; score: number; health: number; hud: HUD; constructor() { super("fight"); this.foes = []; this.hud = {}; } preload() { this.preloadSprites(); } 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 = 100; this.events.on("pause", this.concealClues.bind(this)); this.events.on("resume", this.uncoverClues.bind(this)); } async create() { this.initCluesGroup(); this.createAnimations(); 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.createPlayer(); // this.scale.displaySize.setAspectRatio( // this.cameras.main.width / this.cameras.main.height, // ); // this.scale.refresh(); this.createHUD(); this.createAndBindTypewriter(); this.beGame = (await backend.createGame()).data; this.beGame = ( await backend.updateGame(this.beGame.id, { began_at: new Date().toISOString(), }) ).data; this.spawnFoes(); } 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, }); } createPlayer() { 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: () => { this.player.play({ key: "player_run", repeat: -1 }); }, }); } createHUD() { 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.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: number) { this.score += delta; this.hud.score.text = "✪ " + this.score.toString(); } updateHealth(delta: number) { 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; this.endGame(); } async endGame() { this.beGame = ( await backend.updateGame(this.beGame.id, { ended_at: new Date().toISOString(), }) ).data; this.foes.forEach((foe) => foe.destroy()); this.scene.start("game_over"); } showSubmitFeedback(color: string, input: string) { const text = this.add.text( this.cameras.main.width / 2, this.cameras.main.height / 2, input, { 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) => { // 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.showSubmitFeedback("#FFFFFF", inputStatus.final); } else if (score < 0.9) { // TODO: visual near misses based on score this.updateScore(-1); match.handleFailure(); this.showSubmitFeedback("#FF0000", inputStatus.final); new Spear(this, this.player, undefined); } else { backend.updateClue(match.beClue.id, { ended_at: new Date().toISOString(), }); this.updateScore(+10); this.popFoe(match); match.handleSuccess(); this.showSubmitFeedback("#00FF00", inputStatus.final); new Spear(this, this.player, match.critter); // TODO: increase score } } createAndBindTypewriter() { this.typewriter ??= new Typewriter(); if (this.game.device.os.desktop) { this.typewriter.setHidden(true); this.typewriter.setShiftModeHoldable(); } else { this.typewriter.setHidden(false); this.typewriter.setShiftModeOneShot(); } this.typewriter.onSubmit = async (inputStatus) => { if (inputStatus.began_at === null) return; if (inputStatus.ended_at === null) return; if (inputStatus.final === "") return; this.hud.input.text = ""; this.submitTranscription({ began_at: inputStatus.began_at.toISOString(), ended_at: inputStatus.ended_at.toISOString(), typed: inputStatus.typed, final: inputStatus.final, }); }; this.typewriter.onChange = (inputStatus) => { this.hud.input.text = inputStatus.final; }; } async spawnFoes() { await this.spawnFoe(); // TODO: think of a progression which makes sense const delay = Math.max( 2000, (8 * 1000 * (60 * 1000 - this.time.now)) / 60 / 1000 + 2 * 1000, ); this.time.delayedCall(delay, this.spawnFoes.bind(this)); } async spawnFoe() { await new Foe(this).initialize(); } concealClues() { this.foes.forEach((foe) => foe.clue.conceal()); } uncoverClues() { this.foes.forEach((foe) => foe.clue.uncover()); } }