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:
parent
e0c2a6970e
commit
ad3245cbb9
7 changed files with 726 additions and 245 deletions
|
@ -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>
|
||||||
|
|
||||||
|
|
177
src/routes/neko/+page.svelte
Normal file
177
src/routes/neko/+page.svelte
Normal 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
11
src/routes/neko/oneko.css
Normal 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
498
src/routes/neko/oneko.ts
Normal 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 }
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
Loading…
Add table
Add a link
Reference in a new issue