From bf5b1d0b743e82a6833c636a87e2b8b1b5e8b2e2 Mon Sep 17 00:00:00 2001 From: trivernis Date: Sun, 7 Jul 2024 22:57:35 +0200 Subject: [PATCH] Fork the cat for relative positions and clickability --- package-lock.json | 6 - package.json | 1 - src/components/atoms/Button8831.svelte | 1 + src/components/organisms/Footer.svelte | 26 +- src/components/organisms/Neko.svelte | 42 +++ src/global.scss | 4 - src/lib/neko/index.ts | 357 +++++++++++++++++++++++++ src/lib/neko/neko.gif | Bin 0 -> 3316 bytes src/lib/neko/spritesets.ts | 62 +++++ src/routes/+layout.svelte | 9 - 10 files changed, 485 insertions(+), 23 deletions(-) create mode 100644 src/components/organisms/Neko.svelte create mode 100644 src/lib/neko/index.ts create mode 100644 src/lib/neko/neko.gif create mode 100644 src/lib/neko/spritesets.ts diff --git a/package-lock.json b/package-lock.json index 44c2f1f..140c1a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "0.0.1", "dependencies": { "moment": "^2.30.1", - "neko-ts": "^0.0.6", "qs": "^6.12.2", "sanitize.css": "^13.0.0", "svelte-markdown": "^0.4.1", @@ -1885,11 +1884,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/neko-ts": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/neko-ts/-/neko-ts-0.0.6.tgz", - "integrity": "sha512-PqKIgmqD1LwRM7fb1gqC3ITbojxB47hZmm6eUYjLUAM5w1+KnQ9TZxMDMJHl7pK0XE37ekMPSD7Vzfn0Pa6wow==" - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/package.json b/package.json index cefa8a6..845c849 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ "type": "module", "dependencies": { "moment": "^2.30.1", - "neko-ts": "^0.0.6", "qs": "^6.12.2", "sanitize.css": "^13.0.0", "svelte-markdown": "^0.4.1", diff --git a/src/components/atoms/Button8831.svelte b/src/components/atoms/Button8831.svelte index 3454a54..76c5990 100644 --- a/src/components/atoms/Button8831.svelte +++ b/src/components/atoms/Button8831.svelte @@ -21,6 +21,7 @@ height: 31px; width: 88px; padding: 0; + overflow: hidden; } } diff --git a/src/components/organisms/Footer.svelte b/src/components/organisms/Footer.svelte index 23160c2..1f73338 100644 --- a/src/components/organisms/Footer.svelte +++ b/src/components/organisms/Footer.svelte @@ -1,7 +1,8 @@ -
- Made with and powered by 🐇 + + Made with and powered by +
Privacy @@ -23,6 +26,14 @@ diff --git a/src/components/organisms/Neko.svelte b/src/components/organisms/Neko.svelte new file mode 100644 index 0000000..52d5187 --- /dev/null +++ b/src/components/organisms/Neko.svelte @@ -0,0 +1,42 @@ + + +
+
+
+ + diff --git a/src/global.scss b/src/global.scss index 11d6ade..fb59771 100644 --- a/src/global.scss +++ b/src/global.scss @@ -86,8 +86,4 @@ text-decoration: inherit; color: inherit; } - - [data-neko] { - z-index: 999 !important; - } } \ No newline at end of file diff --git a/src/lib/neko/index.ts b/src/lib/neko/index.ts new file mode 100644 index 0000000..b80def7 --- /dev/null +++ b/src/lib/neko/index.ts @@ -0,0 +1,357 @@ +import NekoGif from "./neko.gif"; +import spritesets from "./spritesets"; + +type Position = { + x: number; + y: number; +}; + +export default class NekoController { + /** + * Status of the neko. If it is awake or not. + * + * @default true + * @readonly + * + * can be changed with wake() and sleep() methods. + */ + public isAwake = true; + + private nekoEl: HTMLDivElement; + private nekoPosX = 16; + private nekoPosY = 16; + private mousePosX = 16; + private mousePosY = 16; + private mousePosAbs: Position = { x: 0, y: 0 }; + + private isReduced: boolean = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ).matches; + + private mouseMoveController = new AbortController(); + private touchController = new AbortController(); + + private frameCount = 0; + private idleTime = 0; + private idleAnimation: string | null = null; + private idleAnimationFrame = 0; + private nekoSpeed = 10; + + private distanceFromMouse = 25; + + private spriteSets: { + [key: string]: number[][]; + } = spritesets; + + private parent: HTMLElement = document.body; + + constructor( + element: HTMLDivElement, + parent: HTMLElement, + options?: { + speed?: number | null; + defaultState?: "awake" | "sleep"; + }, + ) { + this.nekoEl = element; + this.parent = parent; + // get element with attribute data-neko + const isNekoAlive = document.querySelector("[data-neko]") as HTMLDivElement; + if (this.isReduced || isNekoAlive) { + return; + } + + if (options?.speed) { + this.nekoSpeed = Math.max(Math.min(options.speed, 60), 1); + } + + this.nekoPosX = 0; + this.nekoPosY = 0; + + this.mousePosX = 0; + this.mousePosY = 0; + + if (options?.defaultState === "sleep") { + this.isAwake = false; + } + + this.create(); + } + + private getOffset() { + return 3; + } + + private create() { + this.nekoEl.id = "neko"; + this.nekoEl.style.left = `${this.nekoPosX - 16}px`; + this.nekoEl.style.top = `${this.nekoPosY - 16}px`; + + this.nekoEl.style.backgroundImage = `url(${NekoGif})`; + + (window as any).nekoInterval = setInterval(this.frame.bind(this), 60); + + this.nekoEl.addEventListener("click", () => { + console.log("neko click"); + if (this.isAwake) { + this.sleep(); + } else { + this.wake(); + } + }); + + if (!this.isAwake) { + this.idle(); + return; + } + + this.attachListeners(); + } + + private setSprite(name: string, frame: number) { + if (!this.nekoEl || !this.spriteSets || !this.spriteSets[name]) { + return; + } + const sprite = this.spriteSets[name][frame % this.spriteSets[name].length]; + + this.nekoEl.style.backgroundPosition = `${sprite[0] * 32}px ${ + sprite[1] * 32 + }px`; + } + + private resetIdleAnimation() { + this.idleAnimation = null; + this.idleAnimationFrame = 0; + } + + private idle() { + this.idleTime += 1; + + // every ~20 seconds + if ( + this.idleTime > 5 && + Math.floor(Math.random() * 100) === 0 && + this.idleAnimation == null + ) { + const availableIdleAnimations = ["sleeping", "scratchSelf"]; + + if (this.nekoPosX < 32) { + availableIdleAnimations.push("scratchWallW"); + } + if (this.nekoPosY < 32) { + availableIdleAnimations.push("scratchWallN"); + } + if (this.nekoPosX > window.innerWidth - 32) { + availableIdleAnimations.push("scratchWallE"); + } + if (this.nekoPosY > window.innerHeight - 32) { + availableIdleAnimations.push("scratchWallS"); + } + this.idleAnimation = + availableIdleAnimations[ + Math.floor(Math.random() * availableIdleAnimations.length) + ]; + } + + switch (this.idleAnimation) { + case "sleeping": + if (this.idleAnimationFrame < 8) { + this.setSprite("tired", 0); + break; + } + this.setSprite("sleeping", Math.floor(this.idleAnimationFrame / 4)); + if (this.idleAnimationFrame > 192) { + this.resetIdleAnimation(); + } + break; + case "scratchWallN": + case "scratchWallS": + case "scratchWallE": + case "scratchWallW": + case "scratchSelf": + this.setSprite(this.idleAnimation, this.idleAnimationFrame); + if (this.idleAnimationFrame > 9) { + this.resetIdleAnimation(); + } + break; + default: + this.setSprite("idle", 0); + return; + } + this.idleAnimationFrame += 1; + } + + private frame() { + this.frameCount += 1; + const diffX = this.nekoPosX - this.mousePosX; + const diffY = this.nekoPosY - this.mousePosY; + const distance = Math.sqrt(diffX ** 2 + diffY ** 2); + + if ( + this.isAwake && + (distance < this.nekoSpeed || distance < this.distanceFromMouse) + ) { + this.idle(); + return; + } + if (!this.isAwake && distance < this.nekoSpeed) { + this.idle(); + this.nekoEl.style.left = "-16px"; + this.nekoEl.style.top = "-16px"; + return; + } + + this.idleAnimation = null; + this.idleAnimationFrame = 0; + + if (this.idleTime > 1) { + this.setSprite("alert", 0); + // count down after being alerted before moving + this.idleTime = Math.min(this.idleTime, 7); + this.idleTime -= 1; + return; + } + + let direction: string; + + direction = diffY / distance > 0.5 ? "N" : ""; + direction += diffY / distance < -0.5 ? "S" : ""; + direction += diffX / distance > 0.5 ? "W" : ""; + direction += diffX / distance < -0.5 ? "E" : ""; + + this.setSprite(direction, this.frameCount); + + this.nekoPosX -= (diffX / distance) * this.nekoSpeed; + this.nekoPosY -= (diffY / distance) * this.nekoSpeed; + + this.nekoEl.style.left = `${this.nekoPosX - 16}px`; + this.nekoEl.style.top = `${this.nekoPosY - 16}px`; + } + + /** + * If id is not provided, it will try to destroy the neko associated with this instance. + * @param {number} id + * @returns {void} + * @example + * const neko = new Neko({ + * nekoId: 1, + * }); + * + * neko.destroy(); + * + */ + public destroy() { + const neko = document.querySelector(`[data-neko="neko"]`); + + if (neko) { + neko.remove(); + clearInterval((window as any).nekoInterval); + + if (this.nekoEl) { + this.nekoEl.remove(); + } + } + } + + /** + * Put the neko to sleep. It will stop listening to mousemove and touchmove events and neko will return to its origin(+/- some random pixels). + * + * @returns {void} + * @example + * const neko = new Neko(); + * + * neko.sleep(); + */ + public sleep() { + if (!this.isAwake) return; + + this.mouseMoveController.abort(); + this.touchController.abort(); + + this.mousePosX = 0; + this.mousePosY = 0; + + this.isAwake = false; + } + + /** + * Wake up the neko. It will start listening to mousemove and touchmove events. + * @returns {void} + * @example + * const neko = new Neko(); + * neko.wake(); + */ + public wake() { + if (this.isAwake) return; + + this.isAwake = true; + + this.attachListeners(); + } + + private attachListeners() { + this.mouseMoveController = new AbortController(); + this.touchController = new AbortController(); + + document.addEventListener( + "scroll", + () => { + this.updateMousePosition(); + }, + { signal: this.mouseMoveController.signal }, + ); + + document.addEventListener( + "mouseout", + (event: MouseEvent) => { + this.mousePosX = 0; + this.mousePosY = 0; + }, + { signal: this.mouseMoveController.signal }, + ); + + document.addEventListener( + "mousemove", + (event: MouseEvent) => { + this.mousePosAbs = { + x: event.clientX, + y: event.clientY, + }; + this.updateMousePosition(); + }, + { signal: this.mouseMoveController.signal }, + ); + + document.addEventListener( + "touchmove", + (event: TouchEvent) => { + this.mousePosAbs = { + x: event.touches[0].clientX, + y: event.touches[0].clientY, + }; + this.updateMousePosition(); + }, + { signal: this.touchController.signal }, + ); + } + + private updateMousePosition() { + const relativePosition = this.translateRelativePosition(this.mousePosAbs); + this.mousePosX = relativePosition.x; + this.mousePosY = relativePosition.y; + } + + private translateRelativePosition({ x, y }: Position): Position { + const parentPosition = this.getParentPosition(); + + return { + x: x - parentPosition.x, + y: y - parentPosition.y, + }; + } + + private getParentPosition(): { x: number; y: number } { + const parentPosition = this.parent.getBoundingClientRect(); + return { x: parentPosition.x, y: parentPosition.y }; + } +} diff --git a/src/lib/neko/neko.gif b/src/lib/neko/neko.gif new file mode 100644 index 0000000000000000000000000000000000000000..a009c2cc19c96b001ac76e96f27e5fa1a9e56577 GIT binary patch literal 3316 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 yo73M10028!@k;#w literal 0 HcmV?d00001 diff --git a/src/lib/neko/spritesets.ts b/src/lib/neko/spritesets.ts new file mode 100644 index 0000000..04778a0 --- /dev/null +++ b/src/lib/neko/spritesets.ts @@ -0,0 +1,62 @@ +export default { + 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], + ], +}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index e3faa99..2843ce7 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,15 +2,6 @@ import "../global.scss"; import Header from "../components/organisms/Header.svelte"; import Footer from "../components/organisms/Footer.svelte"; - - import { Neko } from "neko-ts"; - import { onMount } from "svelte"; - - onMount(() => { - new Neko({ - speed: 10 - }); - });