1
0
Fork 0
mirror of https://github.com/mat-1/matdoesdev.git synced 2025-08-02 23:44:39 +00:00

cat config page

This commit is contained in:
mat 2024-09-28 02:02:30 +00:00
commit ad3245cbb9
7 changed files with 726 additions and 245 deletions

View file

@ -4,6 +4,7 @@
import type { LayoutData } from '../$types' import type { LayoutData } from '../$types'
import { browser } from '$app/environment' import { browser } from '$app/environment'
import { writable } from 'svelte/store' import { writable } from 'svelte/store'
import { onMount } from 'svelte'
export let data: LayoutData export let data: LayoutData
@ -56,6 +57,7 @@
} }
} }
const pageRendered = writable(false)
if (browser) { if (browser) {
const initialTheme = localStorage.getItem('theme') ?? 'dark' const initialTheme = localStorage.getItem('theme') ?? 'dark'
let globalTheme = writable(initialTheme) let globalTheme = writable(initialTheme)
@ -76,6 +78,24 @@
// update copyright year from local storage // update copyright year from local storage
const storedCopyrightYear = localStorage.getItem('copyrightYear') const storedCopyrightYear = localStorage.getItem('copyrightYear')
if (storedCopyrightYear) copyrightYear = Number(storedCopyrightYear) if (storedCopyrightYear) copyrightYear = Number(storedCopyrightYear)
// neko persistence
const persistNeko = localStorage.getItem('neko-persist')
if (persistNeko === 'true') {
;(async () => {
const { pageRendered: nekoPageRendered } = await import('../neko/oneko')
import('../neko/oneko.css')
// our neko script needs to know when the page is rendered, which may or may not have already happened
nekoPageRendered.set($pageRendered)
pageRendered.subscribe((v) => {
nekoPageRendered.set(v)
})
})()
}
onMount(() => {
$pageRendered = true
})
} }
</script> </script>

View file

@ -0,0 +1,177 @@
<script lang="ts">
import './oneko.css'
import {
BASE_SPRITESHEET_URL,
initNeko,
LOCALSTORAGE_NAMES,
nekoConfig,
pageRendered,
} from './oneko'
import { browser } from '$app/environment'
import { onMount } from 'svelte'
const spritesheetUrls = nekoConfig.spritesheetUrls
let accel = nekoConfig.accelMultiplier
let slipperiness = nekoConfig.slipperiness * 100
let persistOnReload = nekoConfig.persistOnReload
$: accelStr = accel.toString()
$: slipperinessStr = slipperiness.toString()
function updateFromAccelStr() {
accel = parseInt(accelStr)
}
function updateFromSlipperinessStr() {
slipperiness = parseInt(slipperinessStr)
}
$: {
nekoConfig.accelMultiplier = accel
if (browser) localStorage.setItem(LOCALSTORAGE_NAMES.accelMultiplier, JSON.stringify(accel))
}
$: {
nekoConfig.slipperiness = slipperiness * 0.01
if (browser)
localStorage.setItem(LOCALSTORAGE_NAMES.slipperiness, JSON.stringify(nekoConfig.slipperiness))
}
$: {
nekoConfig.persistOnReload = persistOnReload
if (browser)
localStorage.setItem(LOCALSTORAGE_NAMES.persistOnReload, JSON.stringify(persistOnReload))
}
function addSpritesheet() {
$spritesheetUrls = [...$spritesheetUrls, BASE_SPRITESHEET_URL]
}
function removeSpritesheet(i: number) {
$spritesheetUrls = $spritesheetUrls.filter((_, j) => j !== i)
}
onMount(() => {
$pageRendered = true
})
</script>
<svelte:head>
<title>cat config page</title>
<meta
name="description"
content="meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow :3"
/>
</svelte:head>
<h1>cat config page</h1>
<noscript>
this page depends on js, sorry :(
<style>
main {
display: none;
}
</style>
</noscript>
<main>
<section>
<button on:click={() => initNeko()()}>summon neko</button>
<!-- you intentionally cannot despawn nekos without disabling persistence and reloading -->
</section>
<section>
<label>
<div>
acceleration:
<input
class="neko-config-text-input"
type="text"
bind:value={accel}
on:input={updateFromAccelStr}
/>
</div>
<input type="range" min="0" max="25" bind:value={accel} />
</label>
</section>
<section>
<label>
<!-- idea for slipperiness yoinked from goldenstack -->
<!-- https://github.com/GoldenStack/icey-oneko -->
<div>
slipperiness:
<input
class="neko-config-text-input"
type="text"
bind:value={slipperiness}
on:input={updateFromSlipperinessStr}
/>%
</div>
<input type="range" min="0" max="95" bind:value={slipperiness} />
</label>
</section>
<section>
<label>
<div>
persist nekos on reload:
<input type="checkbox" bind:checked={persistOnReload} />
</div>
</label>
</section>
<!--
unfinished for now because a lot of the existing spritesheets change the
sprite coordinates and animations as well so that'd also need to be
configurable and i haven't decided how to make that work
-->
<!-- <section>
<div>spritesheets:</div>
<div class="neko-spritesheets">
{#each $spritesheetUrls as spritesheetUrl, i}
<div class="neko-spritesheet-container">
<div class="neko-spritesheet-btns">
<button class="neko-spritesheet-remove-btn" on:click={() => removeSpritesheet(i)}
>Remove</button
>
{#if i === $spritesheetUrls.length - 1}
<button class="neko-spritesheet-add-btn" on:click={addSpritesheet}>Add</button>
{/if}
</div>
<label>
<img src={spritesheetUrl} alt="oneko spritesheet" class="neko-spritesheet" />
<input type="file" accept="image/*" />
</label>
</div>
{/each}
</div>
</section> -->
</main>
<style>
section {
margin-bottom: 1em;
display: block;
max-width: fit-content;
}
.neko-config-text-input {
width: 2em;
}
/* .neko-spritesheets {
display: flex;
flex-wrap: wrap;
gap: 1em;
}
.neko-spritesheet {
image-rendering: pixelated;
display: block;
}
.neko-spritesheet-btns {
display: block;
}
.neko-spritesheet-add-btn {
float: right;
} */
</style>

11
src/routes/neko/oneko.css Normal file
View file

@ -0,0 +1,11 @@
.oneko {
width: 32px;
height: 32px;
image-rendering: pixelated;
z-index: 2147483647;
position: absolute;
cursor: pointer;
/* default idle sprite */
background-position: -96px -96px;
}

498
src/routes/neko/oneko.ts Normal file
View file

@ -0,0 +1,498 @@
// based on code written by adryd, ty <3
// https://github.com/adryd325/oneko.js/blob/main/oneko.js
import { browser } from '$app/environment'
import { writable } from 'svelte/store'
let followerNekoCount = 0
export let loadedNekoCount = writable(0)
export const BASE_SPRITESHEET_URL = '/retro/oneko.gif'
// this gets updated later (from localStorage)
let nekoConfig = {
accelMultiplier: 10,
slipperiness: 0,
persistOnReload: false,
// we only store initialized nekos here
// (so we don't store the idle one on /retro unless it's clicked)
nekoStates: [] as NekoState[],
// this is a writable since we need to be able to listen to changes without polling
spritesheetUrls: writable([BASE_SPRITESHEET_URL]),
}
export interface NekoState {
index: number
/**
* An index into spritesheetSources. Defaults to 0 if out of bounds.
*/
spritesheetIndex: number
x: number
y: number
velX: number
velY: number
mouseX: number
mouseY: number
frameCount: number
idleTime: number
idleAnimation: string | null
idleAnimationFrame: number
// nekos have their speed slightly randomized so it looks better when
// there's many of them
speedMultiplier: number
}
// this gets updated later
let exactMousePosX: number | undefined = undefined
let exactMousePosY: number | undefined = undefined
const FRAMES_PER_SECOND = 10
const SPRITE_SETS = {
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],
],
}
export function initNeko(
givenNekoEl: HTMLDivElement | undefined = undefined,
updateSpriteCallback: ((name: string) => void) | undefined = undefined,
state: NekoState | undefined = undefined
) {
loadedNekoCount.update((value) => value + 1)
let nekoEl: HTMLDivElement
if (givenNekoEl) {
nekoEl = givenNekoEl
} else {
nekoEl = document.createElement('div')
// background-image: url(/retro/oneko.gif)
nekoEl.classList.add('oneko')
// set our position randomly to an edge on the page
let edge = Math.floor(Math.random() * 4)
let nekoX = 0
let nekoY = 0
if (state) {
nekoX = state.x - 16
nekoY = state.y - 16
} else {
switch (edge) {
case 0:
nekoX = 0
nekoY = Math.random() * window.innerHeight
break
case 1:
nekoX = Math.random() * window.innerWidth
nekoY = 0
break
case 2:
nekoX = window.innerWidth
nekoY = Math.random() * window.innerHeight
break
case 3:
nekoX = Math.random() * window.innerWidth
nekoY = window.innerHeight
break
}
}
document.body.appendChild(nekoEl)
nekoEl.style.left = `${window.scrollX + nekoX}px`
nekoEl.style.top = `${window.scrollY + nekoY}px`
}
nekoEl.style.backgroundImage = 'url("/retro/oneko.gif")'
// by default, don't move until the mouse is moved
const xFromElement = nekoEl.offsetLeft + 16
const yFromElement = nekoEl.offsetTop + 16
const nekoState: NekoState = {
index: nekoConfig.nekoStates.length,
spritesheetIndex: 0,
// set our pos based on where the element is on the page
x: state?.x ?? xFromElement,
y: state?.y ?? yFromElement,
velX: state?.velX ?? 0,
velY: state?.velY ?? 0,
mouseX: state?.mouseX ?? xFromElement,
mouseY: state?.mouseY ?? yFromElement,
frameCount: state?.frameCount ?? 0,
idleTime: state?.idleTime ?? 0,
idleAnimation: state?.idleAnimation ?? null,
idleAnimationFrame: state?.idleAnimationFrame ?? 0,
speedMultiplier: state?.speedMultiplier ?? 1,
}
const startedAt = Date.now()
let followingMouse = false
async function startFollowingMouse() {
// make sure the function doesn't get run multiple times
if (followingMouse) return
followingMouse = true
// make sure we've waited at least 100ms since the neko was created.
// this is partially to fix a bug where the position is detected as 0,0
await new Promise((resolve) => setTimeout(resolve, startedAt + 100 - Date.now()))
followerNekoCount += 1
nekoState.index = nekoConfig.nekoStates.length
// set the neko's speed, if necessary
if (nekoState.index > 0 && nekoState.speedMultiplier === 1) {
// random between 0.75 and 1.25
nekoState.speedMultiplier = Math.random() * 0.5 + 0.75
}
nekoConfig.nekoStates.push(nekoState)
let randomMouseOffsetDirection = Math.random() * Math.PI * 2
// arbitrary, felt like a good enough value
let randomMouseOffsetDistance = Math.log(followerNekoCount) * 10
let randomMouseOffsetX = Math.cos(randomMouseOffsetDirection) * randomMouseOffsetDistance
let randomMouseOffsetY = Math.sin(randomMouseOffsetDirection) * randomMouseOffsetDistance
nekoState.x = nekoEl.offsetLeft - window.scrollX + 16
nekoState.y = nekoEl.offsetTop - window.scrollY + 16
function clampMousePos() {
// fix the position and velocity in case it hits a wall
if (nekoState.mouseX < 0) {
nekoState.mouseX = 0
nekoState.velX = 0
}
if (nekoState.mouseX > window.innerWidth) {
nekoState.mouseX = window.innerWidth
nekoState.velX = 0
}
if (nekoState.mouseY < 0) {
nekoState.mouseY = 0
nekoState.velY = 0
}
if (nekoState.mouseY > window.innerHeight) {
nekoState.mouseY = window.innerHeight
nekoState.velY = 0
}
}
if (exactMousePosX !== undefined) nekoState.mouseX = exactMousePosX + randomMouseOffsetX
if (exactMousePosY !== undefined) nekoState.mouseY = exactMousePosY + randomMouseOffsetY
clampMousePos()
nekoEl.style.position = 'fixed'
nekoEl.style.pointerEvents = 'none'
nekoEl.style.left = `${nekoState.x - 16}px`
nekoEl.style.top = `${nekoState.y - 16}px`
nekoEl.style.zIndex = Number.MAX_VALUE.toString()
document.addEventListener('mousemove', function (event) {
nekoState.mouseX = event.clientX + randomMouseOffsetX
nekoState.mouseY = event.clientY + randomMouseOffsetY
clampMousePos()
})
// move to body so it persists on page changes
document.body.appendChild(nekoEl)
}
function init() {
requestAnimationFrame(animationFrameLoop)
}
let lastFrameTimestamp: undefined | number
function animationFrameLoop(timestamp: number) {
// Stops execution if the neko element is removed from DOM
if (!nekoEl.isConnected) {
return
}
if (!lastFrameTimestamp) {
lastFrameTimestamp = timestamp
}
const msPerFrame = 1000 / FRAMES_PER_SECOND
if (timestamp - lastFrameTimestamp > msPerFrame) {
lastFrameTimestamp = timestamp
frame()
}
requestAnimationFrame(animationFrameLoop)
}
function setSprite(name: keyof typeof SPRITE_SETS, frame: number) {
const sprite = SPRITE_SETS[name][frame % SPRITE_SETS[name].length]
nekoEl.style.backgroundPosition = `${sprite[0] * 32}px ${sprite[1] * 32}px`
updateSpriteCallback?.(name)
}
function resetIdleAnimation() {
nekoState.idleAnimation = null
nekoState.idleAnimationFrame = 0
}
function idle() {
nekoState.idleTime += 1
// every ~20 seconds
if (
nekoState.idleTime > 10 &&
Math.floor(Math.random() * 200) == 0 &&
nekoState.idleAnimation == null
) {
let avalibleIdleAnimations = ['sleeping', 'scratchSelf']
if (nekoState.x < 32) {
avalibleIdleAnimations.push('scratchWallW')
}
if (nekoState.y < 32) {
avalibleIdleAnimations.push('scratchWallN')
}
if (nekoState.x > window.innerWidth - 32) {
avalibleIdleAnimations.push('scratchWallE')
}
if (nekoState.y > window.innerHeight - 32) {
avalibleIdleAnimations.push('scratchWallS')
}
nekoState.idleAnimation =
avalibleIdleAnimations[Math.floor(Math.random() * avalibleIdleAnimations.length)]
}
switch (nekoState.idleAnimation) {
case 'sleeping':
if (nekoState.idleAnimationFrame < 8) {
setSprite('tired', 0)
break
}
setSprite('sleeping', Math.floor(nekoState.idleAnimationFrame / 4))
if (nekoState.idleAnimationFrame > 192) {
resetIdleAnimation()
}
break
case 'scratchWallN':
case 'scratchWallS':
case 'scratchWallE':
case 'scratchWallW':
case 'scratchSelf':
setSprite(nekoState.idleAnimation, nekoState.idleAnimationFrame)
if (nekoState.idleAnimationFrame > 9) {
resetIdleAnimation()
}
break
default:
setSprite('idle', 0)
return
}
nekoState.idleAnimationFrame += 1
}
// between 32 and 64
// helps when there's a lot of nekos
let nekoFollowDistance = followerNekoCount === 0 ? 48 : Math.random() * 32 + 32
function frame() {
nekoState.frameCount += 1
const diffX = nekoState.x - nekoState.mouseX
const diffY = nekoState.y - nekoState.mouseY
const distance = Math.sqrt(diffX ** 2 + diffY ** 2)
const speed = Math.sqrt(nekoState.velX ** 2 + nekoState.velY ** 2)
if (
distance <
Math.max(nekoState.speedMultiplier * nekoConfig.accelMultiplier, nekoFollowDistance) &&
speed <= nekoState.speedMultiplier * nekoConfig.accelMultiplier * 2
) {
nekoState.velX = 0
nekoState.velY = 0
idle()
return
}
nekoState.idleAnimation = null
nekoState.idleAnimationFrame = 0
if (nekoState.idleTime > 1) {
setSprite('alert', 0)
// count down after being alerted before moving
nekoState.idleTime = Math.min(nekoState.idleTime, 7)
nekoState.idleTime -= 1
return
}
// idea for slipperiness yoinked from goldenstack
// https://github.com/GoldenStack/icey-oneko
// meow :3
let accelX = diffX / distance
let accelY = diffY / distance
nekoState.velX *= nekoConfig.slipperiness
nekoState.velY *= nekoConfig.slipperiness
nekoState.velX += accelX * nekoConfig.accelMultiplier
nekoState.velY += accelY * nekoConfig.accelMultiplier
let direction: string
direction = accelY > 0.5 ? 'N' : ''
direction += accelY < -0.5 ? 'S' : ''
direction += accelX > 0.5 ? 'W' : ''
direction += accelX < -0.5 ? 'E' : ''
if (direction !== '') setSprite(direction as any, nekoState.frameCount)
nekoState.x -= nekoState.velX * nekoState.speedMultiplier
nekoState.y -= nekoState.velY * nekoState.speedMultiplier
nekoState.x = Math.min(Math.max(16, nekoState.x), window.innerWidth - 16)
nekoState.y = Math.min(Math.max(16, nekoState.y), window.innerHeight - 16)
nekoEl.style.left = `${nekoState.x - 16}px`
nekoEl.style.top = `${nekoState.y - 16}px`
}
init()
if (state) startFollowingMouse()
return startFollowingMouse
}
export const LOCALSTORAGE_NAMES = {
// this one is also hardcoded in other places since it's used to detect
// whether the script should run
persistOnReload: 'neko-persist',
nekoStates: 'neko-states',
accelMultiplier: 'neko-accel',
slipperiness: 'neko-slipperiness',
}
declare global {
interface Window {
nekosLoaded: boolean | undefined
}
}
export let pageRendered = writable(false)
if (browser) {
document.addEventListener('mousemove', function (event) {
exactMousePosX = event.clientX
exactMousePosY = event.clientY
})
// persist on reload
const nekoPersist = localStorage.getItem(LOCALSTORAGE_NAMES.persistOnReload) === 'true'
if (nekoPersist) nekoConfig.persistOnReload = nekoPersist
// create any nekos if necessary
const nekoStates = nekoPersist ? localStorage.getItem(LOCALSTORAGE_NAMES.nekoStates) : '[]'
if (nekoStates) {
const nekoStatesJson = JSON.parse(nekoStates)
async function spawnNekos() {
for (const state of nekoStatesJson) {
console.log('creating neko', state)
initNeko(undefined, undefined, state)
}
setInterval(() => {
// update localstorage
localStorage.setItem(LOCALSTORAGE_NAMES.nekoStates, JSON.stringify(nekoConfig.nekoStates))
}, 100)
}
// this is just to make sure we don't spawn the nekos multiple times if
// the script gets reloaded
if (window.nekosLoaded === undefined) {
window.nekosLoaded = true
// we can't spawn the nekos immediately since it could happen before
// svelte hydrates the page (and then our nekos get deleted)
const pageRenderedUnsubscribe = pageRendered.subscribe((value) => {
if (value) {
// also now wait for the page to be loaded if it's not already
spawnNekos()
pageRenderedUnsubscribe()
}
})
}
}
// accel multiplier
const accelMultiplier = localStorage.getItem(LOCALSTORAGE_NAMES.accelMultiplier)
if (accelMultiplier) nekoConfig.accelMultiplier = JSON.parse(accelMultiplier)
// slipperiness
const slipperiness = localStorage.getItem(LOCALSTORAGE_NAMES.slipperiness)
if (slipperiness) nekoConfig.slipperiness = JSON.parse(slipperiness)
}
export { nekoConfig }

View file

@ -4,9 +4,10 @@
import links from './links.gif' import links from './links.gif'
import projects from '../_projects.json' import projects from '../_projects.json'
import { initNeko } from './oneko' import { initNeko, pageRendered, loadedNekoCount } from '../neko/oneko'
import '../neko/oneko.css'
import type { BlogPostPreview } from '../blog.json/+server.js' import type { BlogPostPreview } from '../blog.json/preview'
import Button from './Button.svelte' import Button from './Button.svelte'
import { browser } from '$app/environment' import { browser } from '$app/environment'
import { onMount } from 'svelte' import { onMount } from 'svelte'
@ -101,6 +102,12 @@
W: 'up', W: 'up',
NW: 'up', NW: 'up',
} }
onMount(() => {
$pageRendered = true
})
$: nekoStatusClickable = $loadedNekoCount >= 2
</script> </script>
<table id="main-table"> <table id="main-table">
@ -163,16 +170,22 @@
<div class="neko-status-title-container"> <div class="neko-status-title-container">
<h3>Neko status</h3> <h3>Neko status</h3>
<div <div
id="oneko" class="oneko"
aria-hidden="true" aria-hidden="true"
style="background-image: url(/retro/oneko.gif)" style="background-image: url(/retro/oneko.gif)"
bind:this={nekoEl} bind:this={nekoEl}
></div> ></div>
</div> </div>
<div class="neko-status-value"> <div class="neko-status-value">
<span class="status-{nekoSpriteIdsToStatuses[nekoSpriteName]}" {#if nekoStatusClickable}
>{nekoSpriteIdsToNames[nekoSpriteName]}</span <a class="status-{nekoSpriteIdsToStatuses[nekoSpriteName]}" href="/neko">
> {nekoSpriteIdsToNames[nekoSpriteName]}
</a>
{:else}
<span class="status-{nekoSpriteIdsToStatuses[nekoSpriteName]}">
{nekoSpriteIdsToNames[nekoSpriteName]}
</span>
{/if}
</div> </div>
</td> </td>
</tr> </tr>
@ -537,7 +550,7 @@
.neko-status-title-container h3 { .neko-status-title-container h3 {
display: inline-block; display: inline-block;
} }
#oneko { .oneko {
display: inline-block; display: inline-block;
} }

View file

@ -51,18 +51,6 @@ h2 {
box-sizing: border-box; box-sizing: border-box;
} }
#oneko {
width: 32px;
height: 32px;
image-rendering: pixelated;
z-index: 2147483647;
position: absolute;
cursor: pointer;
/* default idle sprite */
background-position: -96px -96px;
}
#main-title:after { #main-title:after {
content: 'silly edition'; content: 'silly edition';
display: block; display: block;

View file

@ -1,226 +0,0 @@
// based on code written by adryd, ty <3
// https://github.com/adryd325/oneko.js/blob/main/oneko.js
export function initNeko(nekoEl: HTMLDivElement, updateSpriteCallback: (name: string) => void) {
// set our pos based on where the element is on the page
let nekoPosX = nekoEl.offsetLeft + 16
let nekoPosY = nekoEl.offsetTop + 16
let mousePosX = nekoEl.offsetLeft
let mousePosY = nekoEl.offsetTop
let frameCount = 0
let idleTime = 0
let idleAnimation: string | null = null
let idleAnimationFrame = 0
const nekoSpeed = 10
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],
],
}
function startFollowingMouse() {
nekoPosX = nekoEl.offsetLeft - window.scrollX + 16
nekoPosY = nekoEl.offsetTop - window.scrollY + 16
mousePosX = nekoPosX
mousePosY = nekoPosY
nekoEl.style.position = 'fixed'
nekoEl.style.pointerEvents = 'none'
nekoEl.style.left = `${nekoPosX - 16}px`
nekoEl.style.top = `${nekoPosY - 16}px`
nekoEl.style.zIndex = Number.MAX_VALUE.toString()
document.addEventListener('mousemove', function (event) {
mousePosX = event.clientX
mousePosY = event.clientY
})
// move to body so it persists on page changes
document.body.appendChild(nekoEl)
}
function init() {
requestAnimationFrame(onAnimationFrame)
}
let lastFrameTimestamp: undefined | number
function onAnimationFrame(timestamp: number) {
// Stops execution if the neko element is removed from DOM
if (!nekoEl.isConnected) {
return
}
if (!lastFrameTimestamp) {
lastFrameTimestamp = timestamp
}
if (timestamp - lastFrameTimestamp > 100) {
lastFrameTimestamp = timestamp
frame()
}
requestAnimationFrame(onAnimationFrame)
}
function setSprite(name: keyof typeof spriteSets, frame: number) {
const sprite = spriteSets[name][frame % spriteSets[name].length]
nekoEl.style.backgroundPosition = `${sprite[0] * 32}px ${sprite[1] * 32}px`
updateSpriteCallback(name)
}
function resetIdleAnimation() {
idleAnimation = null
idleAnimationFrame = 0
}
function idle() {
idleTime += 1
// every ~ 20 seconds
if (idleTime > 10 && Math.floor(Math.random() * 200) == 0 && idleAnimation == null) {
let avalibleIdleAnimations = ['sleeping', 'scratchSelf']
if (nekoPosX < 32) {
avalibleIdleAnimations.push('scratchWallW')
}
if (nekoPosY < 32) {
avalibleIdleAnimations.push('scratchWallN')
}
if (nekoPosX > window.innerWidth - 32) {
avalibleIdleAnimations.push('scratchWallE')
}
if (nekoPosY > window.innerHeight - 32) {
avalibleIdleAnimations.push('scratchWallS')
}
idleAnimation =
avalibleIdleAnimations[Math.floor(Math.random() * avalibleIdleAnimations.length)]
}
switch (idleAnimation) {
case 'sleeping':
if (idleAnimationFrame < 8) {
setSprite('tired', 0)
break
}
setSprite('sleeping', Math.floor(idleAnimationFrame / 4))
if (idleAnimationFrame > 192) {
resetIdleAnimation()
}
break
case 'scratchWallN':
case 'scratchWallS':
case 'scratchWallE':
case 'scratchWallW':
case 'scratchSelf':
setSprite(idleAnimation, idleAnimationFrame)
if (idleAnimationFrame > 9) {
resetIdleAnimation()
}
break
default:
setSprite('idle', 0)
return
}
idleAnimationFrame += 1
}
function frame() {
frameCount += 1
const diffX = nekoPosX - mousePosX
const diffY = nekoPosY - mousePosY
const distance = Math.sqrt(diffX ** 2 + diffY ** 2)
if (distance < nekoSpeed || distance < 48) {
idle()
return
}
idleAnimation = null
idleAnimationFrame = 0
if (idleTime > 1) {
setSprite('alert', 0)
// count down after being alerted before moving
idleTime = Math.min(idleTime, 7)
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' : ''
setSprite(direction as any, frameCount)
nekoPosX -= (diffX / distance) * nekoSpeed
nekoPosY -= (diffY / distance) * nekoSpeed
nekoPosX = Math.min(Math.max(16, nekoPosX), window.innerWidth - 16)
nekoPosY = Math.min(Math.max(16, nekoPosY), window.innerHeight - 16)
nekoEl.style.left = `${nekoPosX - 16}px`
nekoEl.style.top = `${nekoPosY - 16}px`
}
init()
return startFollowingMouse
}