summaryrefslogtreecommitdiff
path: root/src/ts/oneko.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/ts/oneko.ts')
-rw-r--r--src/ts/oneko.ts493
1 files changed, 307 insertions, 186 deletions
diff --git a/src/ts/oneko.ts b/src/ts/oneko.ts
index 236c7cb..2cf3a9b 100644
--- a/src/ts/oneko.ts
+++ b/src/ts/oneko.ts
@@ -1,182 +1,179 @@
// oneko.js: https://github.com/adryd325/oneko.js
-export function initOneko(): void {
- const isReducedMotion =
- window.matchMedia('(prefers-reduced-motion: reduce)') === true ||
- window.matchMedia('(prefers-reduced-motion: reduce)').matches === true;
+type SpriteOffset = readonly [number, number];
- if (isReducedMotion) return;
+type Direction = 'N' | 'NE' | 'E' | 'SE' | 'S' | 'SW' | 'W' | 'NW';
- const nekoEl = document.createElement('div');
- let persistPosition = true;
+type IdleAnimation =
+ | 'sleeping'
+ | 'scratchSelf'
+ | 'scratchWallN'
+ | 'scratchWallS'
+ | 'scratchWallE'
+ | 'scratchWallW';
- let nekoPosX = 32;
- let nekoPosY = 32;
+type SavedState = {
+ nekoPosX: number;
+ nekoPosY: number;
+ mousePosX: number;
+ mousePosY: number;
+ frameCount: number;
+ idleTime: number;
+ idleAnimation: IdleAnimation | null;
+ idleAnimationFrame: number;
+ bgPos: string;
+};
- let mousePosX = 0;
- let mousePosY = 0;
+const NEKO_STORAGE_KEY = 'oneko';
+const FRAME_INTERVAL_MS = 100;
- let frameCount = 0;
- let idleTime = 0;
- let idleAnimation: string | null = null;
- let idleAnimationFrame = 0;
+const NEKO_SIZE_PX = 32;
+const NEKO_HALF_SIZE_PX = NEKO_SIZE_PX / 2;
+const NEKO_SPEED_PX = 10;
- const nekoSpeed = 10;
- const spriteSets: Record<string, [number, number][]> = {
- idle: [[-3, -3]],
- alert: [[-7, -3]],
- scratchSelf: [
- [-5, 0],
- [-6, 0],
- [-7, 0],
- ],
- scratchWallN: [
- [0, 0],
- [0, -1],
- ],
- scratchWallS: [
- [-7, -1],
- [-6, -2],
- ],
- scratchWallE: [
- [-2, -2],
- [-2, -3],
- ],
- scratchWallW: [
- [-4, 0],
- [-4, -1],
- ],
- tired: [[-3, -2]],
- sleeping: [
- [-2, 0],
- [-2, -1],
- ],
- N: [
- [-1, -2],
- [-1, -3],
- ],
- NE: [
- [0, -2],
- [0, -3],
- ],
- E: [
- [-3, 0],
- [-3, -1],
- ],
- SE: [
- [-5, -1],
- [-5, -2],
- ],
- S: [
- [-6, -3],
- [-7, -2],
- ],
- SW: [
- [-5, -3],
- [-6, -1],
- ],
- W: [
- [-4, -2],
- [-4, -3],
- ],
- NW: [
- [-1, 0],
- [-1, -1],
- ],
- };
+const IDLE_DISTANCE_THRESHOLD_PX = 48;
- function init(): void {
- const assetBase = window.ASSET_BASE || '';
- let nekoFile = `${assetBase}/oneko/oneko.gif`;
- const curScript = document.currentScript as HTMLScriptElement;
- if (curScript?.dataset.cat) {
- nekoFile = curScript.dataset.cat;
- }
- if (curScript?.dataset.persistPosition) {
- if (curScript.dataset.persistPosition === '') {
- persistPosition = true;
- } else {
- persistPosition = JSON.parse(curScript.dataset.persistPosition.toLowerCase());
- }
- }
+const spriteSets = {
+ idle: [[-3, -3]],
+ alert: [[-7, -3]],
+ scratchSelf: [
+ [-5, 0],
+ [-6, 0],
+ [-7, 0],
+ ],
+ scratchWallN: [
+ [0, 0],
+ [0, -1],
+ ],
+ scratchWallS: [
+ [-7, -1],
+ [-6, -2],
+ ],
+ scratchWallE: [
+ [-2, -2],
+ [-2, -3],
+ ],
+ scratchWallW: [
+ [-4, 0],
+ [-4, -1],
+ ],
+ tired: [[-3, -2]],
+ sleeping: [
+ [-2, 0],
+ [-2, -1],
+ ],
+ N: [
+ [-1, -2],
+ [-1, -3],
+ ],
+ NE: [
+ [0, -2],
+ [0, -3],
+ ],
+ E: [
+ [-3, 0],
+ [-3, -1],
+ ],
+ SE: [
+ [-5, -1],
+ [-5, -2],
+ ],
+ S: [
+ [-6, -3],
+ [-7, -2],
+ ],
+ SW: [
+ [-5, -3],
+ [-6, -1],
+ ],
+ W: [
+ [-4, -2],
+ [-4, -3],
+ ],
+ NW: [
+ [-1, 0],
+ [-1, -1],
+ ],
+} as const satisfies Record<string, readonly SpriteOffset[]>;
- if (persistPosition) {
- const storedNekoStr = window.localStorage.getItem('oneko');
- const storedNeko = storedNekoStr ? JSON.parse(storedNekoStr) : null;
- if (storedNeko !== null) {
- nekoPosX = storedNeko.nekoPosX;
- nekoPosY = storedNeko.nekoPosY;
- mousePosX = storedNeko.mousePosX;
- mousePosY = storedNeko.mousePosY;
- frameCount = storedNeko.frameCount;
- idleTime = storedNeko.idleTime;
- idleAnimation = storedNeko.idleAnimation;
- idleAnimationFrame = storedNeko.idleAnimationFrame;
- nekoEl.style.backgroundPosition = storedNeko.bgPos;
- }
- }
+type SpriteName = keyof typeof spriteSets;
- nekoEl.id = 'oneko';
- nekoEl.ariaHidden = 'true';
- nekoEl.style.width = '32px';
- nekoEl.style.height = '32px';
- nekoEl.style.position = 'fixed';
- nekoEl.style.pointerEvents = 'none';
- nekoEl.style.imageRendering = 'pixelated';
- nekoEl.style.left = `${nekoPosX - 16}px`;
- nekoEl.style.top = `${nekoPosY - 16}px`;
- nekoEl.style.zIndex = '2147483647';
+function prefersReducedMotion(): boolean {
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+}
- nekoEl.style.backgroundImage = `url(${nekoFile})`;
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(Math.max(min, value), max);
+}
- document.body.appendChild(nekoEl);
+function safeJsonParse<T>(value: string | null): T | null {
+ if (!value) return null;
+ try {
+ return JSON.parse(value) as T;
+ } catch {
+ return null;
+ }
+}
- document.addEventListener('mousemove', (event: MouseEvent) => {
- mousePosX = event.clientX;
- mousePosY = event.clientY;
- });
+function parseBooleanAttribute(value: string): boolean | null {
+ const normalized = value.trim().toLowerCase();
+ if (normalized === 'true') return true;
+ if (normalized === 'false') return false;
+ return null;
+}
- if (persistPosition) {
- window.addEventListener('beforeunload', () => {
- window.localStorage.setItem(
- 'oneko',
- JSON.stringify({
- nekoPosX: nekoPosX,
- nekoPosY: nekoPosY,
- mousePosX: mousePosX,
- mousePosY: mousePosY,
- frameCount: frameCount,
- idleTime: idleTime,
- idleAnimation: idleAnimation,
- idleAnimationFrame: idleAnimationFrame,
- bgPos: nekoEl.style.backgroundPosition,
- })
- );
- });
- }
+function sample<T>(items: readonly T[]): T {
+ return items[Math.floor(Math.random() * items.length)];
+}
- window.requestAnimationFrame(onAnimationFrame);
+function directionFromVector(dx: number, dy: number): Direction {
+ // Screen coordinates: +x is right, +y is down.
+ // atan2 gives: 0 = E, π/2 = S, π = W, -π/2 = N.
+ const angle = Math.atan2(dy, dx);
+ const octant = (Math.round((8 * angle) / (2 * Math.PI)) + 8) % 8;
+
+ switch (octant) {
+ case 0:
+ return 'E';
+ case 1:
+ return 'SE';
+ case 2:
+ return 'S';
+ case 3:
+ return 'SW';
+ case 4:
+ return 'W';
+ case 5:
+ return 'NW';
+ case 6:
+ return 'N';
+ case 7:
+ return 'NE';
+ default:
+ return 'E';
}
+}
- let lastFrameTimestamp: number | undefined;
+export function initOneko(): void {
+ if (prefersReducedMotion()) return;
- function onAnimationFrame(timestamp: number): void {
- if (!nekoEl.isConnected) {
- return;
- }
- if (!lastFrameTimestamp) {
- lastFrameTimestamp = timestamp;
- }
- if (lastFrameTimestamp && timestamp - lastFrameTimestamp > 100) {
- lastFrameTimestamp = timestamp;
- frame();
- }
- window.requestAnimationFrame(onAnimationFrame);
- }
+ const nekoEl = document.createElement('div');
+ let persistPosition = true;
+
+ let nekoPosX = NEKO_SIZE_PX;
+ let nekoPosY = NEKO_SIZE_PX;
- function setSprite(name: string, frame: number): void {
+ let mousePosX = 0;
+ let mousePosY = 0;
+
+ let frameCount = 0;
+ let idleTime = 0;
+ let idleAnimation: IdleAnimation | null = null;
+ let idleAnimationFrame = 0;
+
+ function setSprite(name: SpriteName, frame: number): void {
const sprite = spriteSets[name][frame % spriteSets[name].length];
- nekoEl.style.backgroundPosition = `${sprite[0] * 32}px ${sprite[1] * 32}px`;
+ nekoEl.style.backgroundPosition = `${sprite[0] * NEKO_SIZE_PX}px ${sprite[1] * NEKO_SIZE_PX}px`;
}
function resetIdleAnimation(): void {
@@ -184,26 +181,83 @@ export function initOneko(): void {
idleAnimationFrame = 0;
}
+ function loadPersistedState(): void {
+ if (!persistPosition) return;
+
+ const stored = safeJsonParse<Partial<SavedState>>(
+ window.localStorage.getItem(NEKO_STORAGE_KEY)
+ );
+ if (!stored) return;
+
+ if (typeof stored.nekoPosX === 'number') nekoPosX = stored.nekoPosX;
+ if (typeof stored.nekoPosY === 'number') nekoPosY = stored.nekoPosY;
+ if (typeof stored.mousePosX === 'number') mousePosX = stored.mousePosX;
+ if (typeof stored.mousePosY === 'number') mousePosY = stored.mousePosY;
+ if (typeof stored.frameCount === 'number') frameCount = stored.frameCount;
+ if (typeof stored.idleTime === 'number') idleTime = stored.idleTime;
+ if (typeof stored.idleAnimationFrame === 'number')
+ idleAnimationFrame = stored.idleAnimationFrame;
+
+ if (stored.idleAnimation === null) {
+ idleAnimation = null;
+ } else if (typeof stored.idleAnimation === 'string') {
+ const allowed: IdleAnimation[] = [
+ 'sleeping',
+ 'scratchSelf',
+ 'scratchWallN',
+ 'scratchWallS',
+ 'scratchWallE',
+ 'scratchWallW',
+ ];
+ if (allowed.includes(stored.idleAnimation as IdleAnimation)) {
+ idleAnimation = stored.idleAnimation as IdleAnimation;
+ }
+ }
+
+ if (typeof stored.bgPos === 'string') {
+ nekoEl.style.backgroundPosition = stored.bgPos;
+ }
+ }
+
+ function saveState(): void {
+ if (!persistPosition) return;
+
+ const state: SavedState = {
+ nekoPosX,
+ nekoPosY,
+ mousePosX,
+ mousePosY,
+ frameCount,
+ idleTime,
+ idleAnimation,
+ idleAnimationFrame,
+ bgPos: nekoEl.style.backgroundPosition,
+ };
+
+ window.localStorage.setItem(NEKO_STORAGE_KEY, JSON.stringify(state));
+ }
+
function idle(): void {
idleTime += 1;
- // every ~ 20 seconds
- if (idleTime > 10 && Math.floor(Math.random() * 200) === 0 && idleAnimation == null) {
- const avalibleIdleAnimations: string[] = ['sleeping', 'scratchSelf'];
- if (nekoPosX < 32) {
- avalibleIdleAnimations.push('scratchWallW');
+ // Every ~20 seconds, pick an idle animation.
+ if (idleTime > 10 && Math.floor(Math.random() * 200) === 0 && idleAnimation === null) {
+ const availableIdleAnimations: IdleAnimation[] = ['sleeping', 'scratchSelf'];
+
+ if (nekoPosX < NEKO_SIZE_PX) {
+ availableIdleAnimations.push('scratchWallW');
}
- if (nekoPosY < 32) {
- avalibleIdleAnimations.push('scratchWallN');
+ if (nekoPosY < NEKO_SIZE_PX) {
+ availableIdleAnimations.push('scratchWallN');
}
- if (nekoPosX > window.innerWidth - 32) {
- avalibleIdleAnimations.push('scratchWallE');
+ if (nekoPosX > window.innerWidth - NEKO_SIZE_PX) {
+ availableIdleAnimations.push('scratchWallE');
}
- if (nekoPosY > window.innerHeight - 32) {
- avalibleIdleAnimations.push('scratchWallS');
+ if (nekoPosY > window.innerHeight - NEKO_SIZE_PX) {
+ availableIdleAnimations.push('scratchWallS');
}
- idleAnimation =
- avalibleIdleAnimations[Math.floor(Math.random() * avalibleIdleAnimations.length)];
+
+ idleAnimation = sample(availableIdleAnimations);
}
switch (idleAnimation) {
@@ -212,6 +266,7 @@ export function initOneko(): void {
setSprite('tired', 0);
break;
}
+
setSprite('sleeping', Math.floor(idleAnimationFrame / 4));
if (idleAnimationFrame > 192) {
resetIdleAnimation();
@@ -231,46 +286,112 @@ export function initOneko(): void {
setSprite('idle', 0);
return;
}
+
idleAnimationFrame += 1;
}
function frame(): void {
frameCount += 1;
+
const diffX = nekoPosX - mousePosX;
const diffY = nekoPosY - mousePosY;
const distance = Math.sqrt(diffX ** 2 + diffY ** 2);
- if (distance < nekoSpeed || distance < 48) {
+ if (distance < IDLE_DISTANCE_THRESHOLD_PX || distance < NEKO_SPEED_PX) {
idle();
return;
}
- idleAnimation = null;
- idleAnimationFrame = 0;
+ resetIdleAnimation();
if (idleTime > 1) {
setSprite('alert', 0);
- // count down after being alerted before moving
+ // Count down after being alerted before moving.
idleTime = Math.min(idleTime, 7);
idleTime -= 1;
return;
}
- let direction = '';
- direction += diffY / distance > 0.5 ? 'N' : '';
- direction += diffY / distance < -0.5 ? 'S' : '';
- direction += diffX / distance > 0.5 ? 'W' : '';
- direction += diffX / distance < -0.5 ? 'E' : '';
+ const targetDx = mousePosX - nekoPosX;
+ const targetDy = mousePosY - nekoPosY;
+ const direction = directionFromVector(targetDx, targetDy);
setSprite(direction, frameCount);
- nekoPosX -= (diffX / distance) * nekoSpeed;
- nekoPosY -= (diffY / distance) * nekoSpeed;
+ nekoPosX -= (diffX / distance) * NEKO_SPEED_PX;
+ nekoPosY -= (diffY / distance) * NEKO_SPEED_PX;
- nekoPosX = Math.min(Math.max(16, nekoPosX), window.innerWidth - 16);
- nekoPosY = Math.min(Math.max(16, nekoPosY), window.innerHeight - 16);
+ nekoPosX = clamp(nekoPosX, NEKO_HALF_SIZE_PX, window.innerWidth - NEKO_HALF_SIZE_PX);
+ nekoPosY = clamp(nekoPosY, NEKO_HALF_SIZE_PX, window.innerHeight - NEKO_HALF_SIZE_PX);
- nekoEl.style.left = `${nekoPosX - 16}px`;
- nekoEl.style.top = `${nekoPosY - 16}px`;
+ nekoEl.style.left = `${nekoPosX - NEKO_HALF_SIZE_PX}px`;
+ nekoEl.style.top = `${nekoPosY - NEKO_HALF_SIZE_PX}px`;
+ }
+
+ function init(): void {
+ const assetBase = window.ASSET_BASE ?? '';
+ let nekoFile = `${assetBase}/oneko/oneko.gif`;
+
+ const curScript = document.currentScript as HTMLScriptElement | null;
+ if (curScript?.dataset.cat) {
+ nekoFile = curScript.dataset.cat;
+ }
+
+ const persistPositionAttr = curScript?.dataset.persistPosition;
+ if (persistPositionAttr !== undefined) {
+ if (persistPositionAttr === '') {
+ persistPosition = true;
+ } else {
+ const parsed = parseBooleanAttribute(persistPositionAttr);
+ if (parsed !== null) {
+ persistPosition = parsed;
+ }
+ }
+ }
+
+ nekoEl.id = 'oneko';
+ nekoEl.ariaHidden = 'true';
+ nekoEl.style.width = `${NEKO_SIZE_PX}px`;
+ nekoEl.style.height = `${NEKO_SIZE_PX}px`;
+ nekoEl.style.position = 'fixed';
+ nekoEl.style.pointerEvents = 'none';
+ nekoEl.style.imageRendering = 'pixelated';
+ nekoEl.style.zIndex = '2147483647';
+ nekoEl.style.backgroundImage = `url(${nekoFile})`;
+
+ loadPersistedState();
+
+ nekoEl.style.left = `${nekoPosX - NEKO_HALF_SIZE_PX}px`;
+ nekoEl.style.top = `${nekoPosY - NEKO_HALF_SIZE_PX}px`;
+
+ document.body.appendChild(nekoEl);
+
+ document.addEventListener('mousemove', (event: MouseEvent) => {
+ mousePosX = event.clientX;
+ mousePosY = event.clientY;
+ });
+
+ if (persistPosition) {
+ window.addEventListener('beforeunload', saveState);
+ }
+
+ window.requestAnimationFrame(onAnimationFrame);
+ }
+
+ let lastFrameTimestamp: number | undefined;
+
+ function onAnimationFrame(timestamp: number): void {
+ if (!nekoEl.isConnected) return;
+
+ if (lastFrameTimestamp === undefined) {
+ lastFrameTimestamp = timestamp;
+ }
+
+ if (timestamp - lastFrameTimestamp >= FRAME_INTERVAL_MS) {
+ lastFrameTimestamp = timestamp;
+ frame();
+ }
+
+ window.requestAnimationFrame(onAnimationFrame);
}
init();