/**
* This module provides {@link Neko} class which encapsulates neko logic
*
* Most users would want to use {@link defaultNeko}
* @packageDocumentation
*/
/**
* Represents the interface for configs
* Defines the behaviour of neko
*
* @export
* @interface NekoConfig
*/
export interface NekoConfig {
/** Used to update neko's position */
speed: number;
/**
* Range where neko would not react to cursor
* i.e. when the cursor in the circle with the given radius
*/
radius: number;
/**
* Returns the number of ticks before Itching
* Used after itching
*/
ticksBeforeItch: () => number;
/**
* Returns the number of ticks before Scratching
* Used after scratching
*/
ticksBeforeScratch: () => number;
/**
* Returns the number of ticks before Yawning
* Used after yawning
*/
ticksBeforeYawn: () => number;
/**
* Returns the direction for the current scratch
*/
scratchDirection: () => 's' | 'w' | 'e' | 'n';
}
/**
* @internal
*/
const randInt = (min: number, max: number) =>
Math.floor(Math.random() * (max - min + 1)) + min;
/**
* The default config for {@link Neko}
*
* Ticks and scratch direction are randomly generated
*/
export const defaultConfig: NekoConfig = {
/** The speed param */
speed: 10,
radius: 10,
ticksBeforeItch: () => {
let min = 7;
let max = 14;
return randInt(min, max);
},
ticksBeforeScratch: () => {
let min = 7;
let max = 14;
return randInt(min, max);
},
ticksBeforeYawn: () => {
let min = 20;
let max = 40;
return randInt(min, max);
},
scratchDirection: () => {
const directions = {
1: 's',
2: 'w',
3: 'e',
4: 'n',
};
const rnd = Math.floor(Math.random() * (5 - 1)) + 1;
// @ts-ignore
return directions[rnd];
},
};
/**
* Represents what Neko should implement so that it can be drawn on the screen
*
* Used in helper functions
* See {@link "web"} module
*
* @export
* @interface NekoInterface
*/
export interface NekoInterface {
state: {
x: number;
y: number;
};
/** Updates the neko's state */
update: (cursorX: number, cursorY: number) => void;
img: string;
}
/**
* Creates a {@link Neko} with {@link defaultConfig}
*/
export function defaultNeko(): NekoInterface {
return new Neko(defaultConfig);
}
/**
* An implementation of neko
*
* @export
* @class Neko
* @implements {@link NekoInterface}
*/
export class Neko implements NekoInterface {
state: {
name: 'still' | 'itch' | 'alert' | 'run' | 'scratch' | 'yawn' | 'sleep';
x: number;
y: number;
tick?: number;
direction?: string;
ticksBeforeItch: number;
framesItch?: number;
ticksBeforeScratch: number;
framesScratch?: number;
ticksBeforeYawn: number;
framesYawn?: number;
} = {
name: 'still',
x: 0,
y: 0,
ticksBeforeItch: 5,
ticksBeforeScratch: 5,
ticksBeforeYawn: 10,
};
get img(): string {
return `${this.state.direction ? this.state.direction : ''}${
this.state.name
}${this.state.tick ? this.state.tick : ''}`;
}
config: NekoConfig;
constructor(config: NekoConfig) {
this.config = { ...config };
this.state.ticksBeforeItch = config.ticksBeforeItch();
this.state.ticksBeforeScratch = config.ticksBeforeScratch();
this.state.ticksBeforeYawn = config.ticksBeforeYawn();
}
// updates the state
update = (x: number, y: number) => {
if (this.state.name == 'still') {
this.updateStill(x, y);
} else if (this.state.name == 'itch') {
this.updateItch(x, y);
} else if (this.state.name == 'alert') {
this.updateAlert(x, y);
} else if (this.state.name == 'run') {
this.updateRun(x, y);
} else if (this.state.name == 'scratch') {
this.updateScratch(x, y);
} else if (this.state.name == 'yawn') {
this.updateYawn(x, y);
} else if (this.state.name == 'sleep') {
this.updateSleep(x, y);
}
};
updateSleep(x: number, y: number) {
this.checkState('sleep');
// reset ticks
if (!this.cursorClose(x, y)) {
this.state.name = 'alert';
this.state.tick = null;
this.state.ticksBeforeItch = this.config.ticksBeforeItch();
this.state.ticksBeforeScratch = this.config.ticksBeforeScratch();
return;
}
this.state.tick = this.state.tick === 1 ? 2 : 1;
}
updateYawn(x: number, y: number) {
this.checkState('yawn');
// reset ticks
if (!this.cursorClose(x, y)) {
this.state.name = 'alert';
this.state.framesYawn = null;
return;
}
if (this.state.framesYawn - 1 == 0) {
this.state.name = 'sleep';
this.state.tick = 1;
this.state.framesYawn = null;
this.state.ticksBeforeYawn = this.config.ticksBeforeYawn();
}
this.state.framesYawn -= 1;
}
updateScratch(x: number, y: number) {
this.checkState('scratch');
if (!this.cursorClose(x, y)) {
this.state.name = 'alert';
this.state.tick = null;
this.state.framesScratch = null;
this.state.ticksBeforeScratch = this.config.ticksBeforeScratch();
this.state.direction = null;
return;
}
// done scratching
if (this.state.framesScratch - 1 == 0) {
this.state.name = 'still';
this.state.tick = null;
this.state.framesScratch = null;
this.state.ticksBeforeScratch = this.config.ticksBeforeScratch();
this.state.direction = null;
return;
}
this.state.framesScratch -= 1;
this.state.tick = this.state.tick === 1 ? 2 : 1;
}
updateStill(x: number, y: number) {
this.checkState('still');
if (!this.cursorClose(x, y)) {
this.state.name = 'alert';
this.state.tick = null;
}
if (this.state.ticksBeforeYawn === 0) {
this.state.name = 'yawn';
this.state.framesYawn = 2;
return;
}
if (this.state.ticksBeforeItch === 0) {
this.state.name = 'itch';
this.state.framesItch = 4;
this.state.tick = 1;
return;
}
if (this.state.ticksBeforeScratch === 0) {
this.state.name = 'scratch';
this.state.framesScratch = 4;
this.state.tick = 1;
this.state.direction = this.config.scratchDirection();
return;
}
this.state.ticksBeforeItch -= 1;
this.state.ticksBeforeScratch -= 1;
}
updateItch(x: number, y: number) {
this.checkState('itch');
if (!this.cursorClose(x, y)) {
this.state.name = 'alert';
this.state.tick = null;
this.state.framesItch = null;
this.state.ticksBeforeItch = this.config.ticksBeforeItch();
return;
}
// done itching
if (this.state.framesItch - 1 == 0) {
this.state.name = 'still';
this.state.tick = null;
this.state.framesItch = null;
this.state.ticksBeforeItch = this.config.ticksBeforeItch();
return;
}
this.state.framesItch -= 1;
this.state.tick = this.state.tick === 1 ? 2 : 1;
}
updateAlert(x: number, y: number) {
this.checkState('alert');
if (this.cursorClose(x, y)) {
this.state.name = 'still';
this.state.tick = null;
this.state.direction = null;
return;
}
this.state.tick = 1;
this.state.name = 'run';
this.state.direction = this.chooseRunDirection(x, y);
this.makeStep(x, y);
}
updateRun(x: number, y: number) {
this.checkState('run');
if (this.cursorClose(x, y)) {
this.state.name = 'still';
this.state.tick = null;
this.state.direction = null;
return;
}
this.state.ticksBeforeYawn = Math.max(this.state.ticksBeforeYawn - 1, 0);
this.state.direction = this.chooseRunDirection(x, y);
this.state.tick = this.state.tick === 1 ? 2 : 1;
this.makeStep(x, y);
}
makeStep(x: number, y: number) {
// x=0 y=-10
const dx = x - this.state.x;
const dy = y - this.state.y;
let phi = Math.atan2(dy, dx);
this.state.x += this.config.speed * Math.cos(phi);
this.state.y += this.config.speed * Math.sin(phi);
}
cursorClose(x: number, y: number) {
return Math.hypot(this.state.x - x, this.state.y - y) < this.config.radius;
}
chooseRunDirection(x: number, y: number): string {
const dx = x - this.state.x;
const dy = y - this.state.y;
const diag = Math.hypot(dx, dy);
let phi = calcAngleDegrees(dx, dy);
// todo use math.pi
switch (true) {
case -22.5 < phi && phi <= 22.5:
return 'e';
case -67.5 < phi && phi <= -22.5:
return 'ne';
case -112.5 < phi && phi <= -67.5:
return 'n';
case -157.5 < phi && phi <= -112.5:
return 'nw';
case (-180 <= phi && phi <= -157.5) || (157.5 < phi && phi <= 180):
return 'w';
case 112.5 < phi && phi <= 157.5:
return 'sw';
case 67.5 < phi && phi <= 112.5:
return 's';
case 22.5 < phi && phi <= 67.5:
return 'se';
default:
throw Error(`error in finding path direction ${diag} ${phi}`);
}
}
private checkState(name: string) {
if (name != this.state.name) {
throw Error(`expected state: ${name}, got: ${this.state.name}`);
}
}
}
/**
* @internal
*/
function calcAngleDegrees(x: any, y: any): number {
return (Math.atan2(y, x) * 180) / Math.PI;
}