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 0000000..a009c2c Binary files /dev/null and b/src/lib/neko/neko.gif differ 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 - }); - });