Fork the cat for relative positions and clickability

main
trivernis 5 months ago
parent 7800c08314
commit bf5b1d0b74
Signed by: Trivernis
GPG Key ID: 7E6D18B61C8D2F4B

6
package-lock.json generated

@ -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",

@ -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",

@ -21,6 +21,7 @@
height: 31px;
width: 88px;
padding: 0;
overflow: hidden;
}
}
</style>

@ -1,7 +1,8 @@
<script>
<script lang="ts">
import "$lib/vars.scss";
import Box from "../atoms/Box.svelte";
import Buttons from "../molecules/Buttons.svelte";
import Neko from "./Neko.svelte";
</script>
<div class="footer">
@ -12,7 +13,9 @@
<span><i>CC0 Public Domain, 2024</i></span>
</div>
<div class="center">
<span>Made with and powered by 🐇</span>
<span class="powered-by">
Made with and powered by <Neko />
</span>
</div>
<div class="right">
<a href="/privacy">Privacy</a>
@ -23,6 +26,14 @@
<style lang="scss">
@layer component {
#neko-anchor {
background: transparent;
border: none;
display: inline;
height: 100%;
position: relative;
}
.footer {
height: auto;
min-height: 3em;
@ -31,6 +42,10 @@
width: 100%;
padding: 1em;
.powered-by {
font-size: 24px;
}
.justified {
display: flex;
flex-direction: row;
@ -57,4 +72,9 @@
}
}
}
:global([data-neko]) {
z-index: 999 !important;
pointer-events: inherit !important;
}
</style>

@ -0,0 +1,42 @@
<script lang="ts">
import Neko from "$lib/neko";
import { onMount } from "svelte";
let nekoParentElement: HTMLDivElement | undefined;
let nekoElement: HTMLDivElement | undefined;
onMount(() => {
if (nekoElement && nekoParentElement) {
new Neko(nekoElement, nekoParentElement, {
defaultState: "sleep"
});
}
});
</script>
<div class="neko-parent" bind:this={nekoParentElement}>
<div id="neko" class="neko" bind:this={nekoElement} />
</div>
<style lang="scss">
@layer component {
.neko-parent {
display: inline;
transform: translateY(14px) translateX(24px);
height: 32px;
width: 32px;
float: right;
position: relative;
}
.neko {
height: 32px;
width: 32px;
left: 16px;
top: 16px;
position: absolute;
image-rendering: pixelated;
background-size: calc(800%) calc(400%);
z-index: 999;
}
}
</style>

@ -86,8 +86,4 @@
text-decoration: inherit;
color: inherit;
}
[data-neko] {
z-index: 999 !important;
}
}

@ -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 };
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

@ -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],
],
};

@ -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
});
});
</script>
<div class="page crt">

Loading…
Cancel
Save