feat(cat): let neko wander independently
This commit is contained in:
@@ -13,6 +13,10 @@ const ALERT_TIME = 3;
|
|||||||
const IDLE_THRESHOLD = 3;
|
const IDLE_THRESHOLD = 3;
|
||||||
const IDLE_ANIMATION_CHANCE = 1 / 20;
|
const IDLE_ANIMATION_CHANCE = 1 / 20;
|
||||||
const MIN_DISTANCE = 10;
|
const MIN_DISTANCE = 10;
|
||||||
|
const ROAM_MARGIN = 24;
|
||||||
|
const MIN_ROAM_DISTANCE = 160;
|
||||||
|
const MIN_IDLE_FRAMES = 8;
|
||||||
|
const MAX_IDLE_FRAMES = 20;
|
||||||
const SPRITE_GAP = 1;
|
const SPRITE_GAP = 1;
|
||||||
const BACKGROUND_TARGET_COLOR: [number, number, number] = [0, 174, 240];
|
const BACKGROUND_TARGET_COLOR: [number, number, number] = [0, 174, 240];
|
||||||
|
|
||||||
@@ -21,8 +25,9 @@ type SpriteSet = Record<string, [number, number][]>;
|
|||||||
class Neko {
|
class Neko {
|
||||||
private posX: number;
|
private posX: number;
|
||||||
private posY: number;
|
private posY: number;
|
||||||
private mouseX: number;
|
private targetX: number;
|
||||||
private mouseY: number;
|
private targetY: number;
|
||||||
|
private idleFramesRemaining: number;
|
||||||
private frameCount: number;
|
private frameCount: number;
|
||||||
private idleTime: number;
|
private idleTime: number;
|
||||||
private idleAnimation: string | null;
|
private idleAnimation: string | null;
|
||||||
@@ -43,8 +48,9 @@ class Neko {
|
|||||||
this.nekoImageUrl = nekoImageUrl;
|
this.nekoImageUrl = nekoImageUrl;
|
||||||
this.posX = Math.max(NEKO_HALF_WIDTH, window.innerWidth - NEKO_HALF_WIDTH - margin);
|
this.posX = Math.max(NEKO_HALF_WIDTH, window.innerWidth - NEKO_HALF_WIDTH - margin);
|
||||||
this.posY = Math.max(NEKO_HALF_HEIGHT, window.innerHeight - NEKO_HALF_HEIGHT - margin);
|
this.posY = Math.max(NEKO_HALF_HEIGHT, window.innerHeight - NEKO_HALF_HEIGHT - margin);
|
||||||
this.mouseX = this.posX;
|
this.targetX = this.posX;
|
||||||
this.mouseY = this.posY;
|
this.targetY = this.posY;
|
||||||
|
this.idleFramesRemaining = this.randomIdleDuration();
|
||||||
this.frameCount = 0;
|
this.frameCount = 0;
|
||||||
this.idleTime = 0;
|
this.idleTime = 0;
|
||||||
this.idleAnimation = null;
|
this.idleAnimation = null;
|
||||||
@@ -186,18 +192,13 @@ class Neko {
|
|||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleMouseMove = (event: MouseEvent) => {
|
|
||||||
this.mouseX = event.clientX;
|
|
||||||
this.mouseY = event.clientY;
|
|
||||||
};
|
|
||||||
|
|
||||||
private handleResize = () => {
|
private handleResize = () => {
|
||||||
this.clampToViewport();
|
this.clampToViewport();
|
||||||
|
this.clampTargetToViewport();
|
||||||
this.render();
|
this.render();
|
||||||
};
|
};
|
||||||
|
|
||||||
private addEventListeners() {
|
private addEventListeners() {
|
||||||
document.addEventListener("mousemove", this.handleMouseMove);
|
|
||||||
window.addEventListener("resize", this.handleResize);
|
window.addEventListener("resize", this.handleResize);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,21 +223,34 @@ class Neko {
|
|||||||
|
|
||||||
private updateState() {
|
private updateState() {
|
||||||
this.frameCount += 1;
|
this.frameCount += 1;
|
||||||
this.followMouse();
|
|
||||||
}
|
|
||||||
|
|
||||||
private followMouse() {
|
if (this.idleFramesRemaining > 0 || this.idleAnimation !== null) {
|
||||||
const diffX = this.posX - this.mouseX;
|
|
||||||
const diffY = this.posY - this.mouseY;
|
|
||||||
const distance = Math.hypot(diffX, diffY);
|
|
||||||
|
|
||||||
if (distance < MIN_DISTANCE) {
|
|
||||||
this.idleBehavior();
|
this.idleBehavior();
|
||||||
|
|
||||||
|
if (this.idleAnimation === null) {
|
||||||
|
this.idleFramesRemaining -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.idleFramesRemaining <= 0 && this.idleAnimation === null) {
|
||||||
|
this.chooseNewTarget();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.idleTime > IDLE_THRESHOLD && this.alertTimeRemaining === 0) {
|
this.wander();
|
||||||
this.alertTimeRemaining = ALERT_TIME;
|
}
|
||||||
|
|
||||||
|
private wander() {
|
||||||
|
const diffX = this.posX - this.targetX;
|
||||||
|
const diffY = this.posY - this.targetY;
|
||||||
|
const distance = Math.hypot(diffX, diffY);
|
||||||
|
|
||||||
|
if (distance < MIN_DISTANCE) {
|
||||||
|
this.posX = this.targetX;
|
||||||
|
this.posY = this.targetY;
|
||||||
|
this.idleFramesRemaining = this.randomIdleDuration();
|
||||||
|
this.idleBehavior();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.alertTimeRemaining > 0) {
|
if (this.alertTimeRemaining > 0) {
|
||||||
@@ -263,6 +277,37 @@ class Neko {
|
|||||||
this.clampToViewport();
|
this.clampToViewport();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private chooseNewTarget() {
|
||||||
|
const minX = Math.min(NEKO_HALF_WIDTH + ROAM_MARGIN, window.innerWidth / 2);
|
||||||
|
const maxX = Math.max(minX, window.innerWidth - NEKO_HALF_WIDTH - ROAM_MARGIN);
|
||||||
|
const minY = Math.min(NEKO_HALF_HEIGHT + ROAM_MARGIN, window.innerHeight / 2);
|
||||||
|
const maxY = Math.max(minY, window.innerHeight - NEKO_HALF_HEIGHT - ROAM_MARGIN);
|
||||||
|
|
||||||
|
let nextX = this.posX;
|
||||||
|
let nextY = this.posY;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||||
|
nextX = minX + Math.random() * (maxX - minX);
|
||||||
|
nextY = minY + Math.random() * (maxY - minY);
|
||||||
|
|
||||||
|
if (Math.hypot(nextX - this.posX, nextY - this.posY) >= MIN_ROAM_DISTANCE) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.targetX = nextX;
|
||||||
|
this.targetY = nextY;
|
||||||
|
this.alertTimeRemaining = ALERT_TIME;
|
||||||
|
this.idleTime = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private randomIdleDuration() {
|
||||||
|
return (
|
||||||
|
MIN_IDLE_FRAMES +
|
||||||
|
Math.floor(Math.random() * (MAX_IDLE_FRAMES - MIN_IDLE_FRAMES + 1))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private idleBehavior() {
|
private idleBehavior() {
|
||||||
this.idleTime += 1;
|
this.idleTime += 1;
|
||||||
|
|
||||||
@@ -320,6 +365,17 @@ class Neko {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private clampTargetToViewport() {
|
||||||
|
this.targetX = Math.min(
|
||||||
|
Math.max(NEKO_HALF_WIDTH, this.targetX),
|
||||||
|
window.innerWidth - NEKO_HALF_WIDTH,
|
||||||
|
);
|
||||||
|
this.targetY = Math.min(
|
||||||
|
Math.max(NEKO_HALF_HEIGHT, this.targetY),
|
||||||
|
window.innerHeight - NEKO_HALF_HEIGHT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private render() {
|
private render() {
|
||||||
if (!this.nekoElement) return;
|
if (!this.nekoElement) return;
|
||||||
this.nekoElement.style.left = `${this.posX - NEKO_HALF_WIDTH}px`;
|
this.nekoElement.style.left = `${this.posX - NEKO_HALF_WIDTH}px`;
|
||||||
@@ -345,7 +401,6 @@ class Neko {
|
|||||||
this.animationFrameId = null;
|
this.animationFrameId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.removeEventListener("mousemove", this.handleMouseMove);
|
|
||||||
window.removeEventListener("resize", this.handleResize);
|
window.removeEventListener("resize", this.handleResize);
|
||||||
|
|
||||||
if (this.nekoElement) {
|
if (this.nekoElement) {
|
||||||
@@ -368,4 +423,3 @@ export function NekoFollower() {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user