-
Paolo.Brasolin authoredPaolo.Brasolin authored
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);
}
}
}