feat: oneko

This commit is contained in:
ShittyKopper 2024-02-01 17:06:00 +03:00
parent 2811f4cca5
commit 132bf2d200
9 changed files with 256 additions and 0 deletions

View file

@ -1095,6 +1095,7 @@ accountMoved: "This user has moved to a new account:"
accountMovedShort: "This account has been migrated." accountMovedShort: "This account has been migrated."
operationForbidden: "Operation forbidden" operationForbidden: "Operation forbidden"
forceShowAds: "Always show ads" forceShowAds: "Always show ads"
oneko: "Cat friend :3"
addMemo: "Add memo" addMemo: "Add memo"
editMemo: "Edit memo" editMemo: "Edit memo"
reactionsList: "Reactions" reactionsList: "Reactions"

4
locales/index.d.ts vendored
View file

@ -4425,6 +4425,10 @@ export interface Locale extends ILocale {
* *
*/ */
"forceShowAds": string; "forceShowAds": string;
/**
* 猫友達 :3
*/
"oneko": string;
/** /**
* *
*/ */

View file

@ -1102,6 +1102,7 @@ accountMoved: "このユーザーは新しいアカウントに移行しまし
accountMovedShort: "このアカウントは移行されています" accountMovedShort: "このアカウントは移行されています"
operationForbidden: "この操作はできません" operationForbidden: "この操作はできません"
forceShowAds: "常に広告を表示する" forceShowAds: "常に広告を表示する"
oneko: "猫友達 :3"
addMemo: "メモを追加" addMemo: "メモを追加"
editMemo: "メモを編集" editMemo: "メモを編集"
reactionsList: "リアクション一覧" reactionsList: "リアクション一覧"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -0,0 +1,240 @@
<template>
<div ref="nekoEl" :class="$style.oneko" aria-hidden="true"></div>
</template>
<script lang="ts" setup>
// oneko.js: https://github.com/adryd325/oneko.js
// modified to be a vue component by ShittyKopper :3
import { shallowRef, onMounted } from 'vue';
const nekoEl = shallowRef<HTMLDivElement>();
let nekoPosX = 32;
let nekoPosY = 32;
let mousePosX = 0;
let mousePosY = 0;
let frameCount = 0;
let idleTime = 0;
let idleAnimation: string|null = null;
let idleAnimationFrame = 0;
let lastFrameTimestamp;
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 init() {
if (!nekoEl.value) return;
nekoEl.value.style.left = `${nekoPosX - 16}px`;
nekoEl.value.style.top = `${nekoPosY - 16}px`;
document.addEventListener('mousemove', (event) => {
mousePosX = event.clientX;
mousePosY = event.clientY;
});
window.requestAnimationFrame(onAnimationFrame);
}
function onAnimationFrame(timestamp) {
// Stops execution if the neko element is removed from DOM
if (!nekoEl.value?.isConnected) {
return;
}
if (!lastFrameTimestamp) {
lastFrameTimestamp = timestamp;
}
if (timestamp - lastFrameTimestamp > 100) {
lastFrameTimestamp = timestamp;
frame();
}
window.requestAnimationFrame(onAnimationFrame);
}
// eslint-disable-next-line no-shadow
function setSprite(name, frame) {
if (!nekoEl.value) return;
const sprite = spriteSets[name][frame % spriteSets[name].length];
nekoEl.value.style.backgroundPosition = `${sprite[0] * 32}px ${sprite[1] * 32}px`;
}
function resetIdleAnimation() {
idleAnimation = null;
idleAnimationFrame = 0;
}
function idle() {
idleTime += 1;
// every ~ 20 seconds
if (
idleTime > 10 &&
Math.floor(Math.random() * 200) === 0 &&
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
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() {
if (!nekoEl.value) return;
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;
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, 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.value.style.left = `${nekoPosX - 16}px`;
nekoEl.value.style.top = `${nekoPosY - 16}px`;
}
onMounted(init);
</script>
<style module>
.oneko {
width: 32px;
height: 32px;
position: fixed;
pointer-events: none;
image-rendering: pixelated;
z-index: 2147483647;
background-image: url(/client-assets/oneko.gif);
}
</style>

View file

@ -145,6 +145,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="useSystemFont">{{ i18n.ts.useSystemFont }}</MkSwitch> <MkSwitch v-model="useSystemFont">{{ i18n.ts.useSystemFont }}</MkSwitch>
<MkSwitch v-model="disableDrawer">{{ i18n.ts.disableDrawer }}</MkSwitch> <MkSwitch v-model="disableDrawer">{{ i18n.ts.disableDrawer }}</MkSwitch>
<MkSwitch v-model="forceShowAds">{{ i18n.ts.forceShowAds }}</MkSwitch> <MkSwitch v-model="forceShowAds">{{ i18n.ts.forceShowAds }}</MkSwitch>
<MkSwitch v-model="oneko">{{ i18n.ts.oneko }}</MkSwitch>
<MkSwitch v-model="enableSeasonalScreenEffect">{{ i18n.ts.seasonalScreenEffect }}</MkSwitch> <MkSwitch v-model="enableSeasonalScreenEffect">{{ i18n.ts.seasonalScreenEffect }}</MkSwitch>
</div> </div>
<div> <div>
@ -332,6 +333,7 @@ const emojiStyle = computed(defaultStore.makeGetterSetter('emojiStyle'));
const disableDrawer = computed(defaultStore.makeGetterSetter('disableDrawer')); const disableDrawer = computed(defaultStore.makeGetterSetter('disableDrawer'));
const disableShowingAnimatedImages = computed(defaultStore.makeGetterSetter('disableShowingAnimatedImages')); const disableShowingAnimatedImages = computed(defaultStore.makeGetterSetter('disableShowingAnimatedImages'));
const forceShowAds = computed(defaultStore.makeGetterSetter('forceShowAds')); const forceShowAds = computed(defaultStore.makeGetterSetter('forceShowAds'));
const oneko = computed(defaultStore.makeGetterSetter('oneko'));
const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages')); const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages'));
const highlightSensitiveMedia = computed(defaultStore.makeGetterSetter('highlightSensitiveMedia')); const highlightSensitiveMedia = computed(defaultStore.makeGetterSetter('highlightSensitiveMedia'));
const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab')); const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab'));

View file

@ -98,6 +98,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
'showClipButtonInNoteFooter', 'showClipButtonInNoteFooter',
'reactionsDisplaySize', 'reactionsDisplaySize',
'forceShowAds', 'forceShowAds',
'oneko',
'numberOfReplies', 'numberOfReplies',
'aiChanMode', 'aiChanMode',
'devMode', 'devMode',

View file

@ -407,6 +407,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device', where: 'device',
default: false, default: false,
}, },
oneko: {
where: 'device',
default: false,
},
clickToOpen: { clickToOpen: {
where: 'device', where: 'device',
default: true, default: true,

View file

@ -42,6 +42,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="dev" id="devTicker"><span>DEV BUILD</span></div> <div v-if="dev" id="devTicker"><span>DEV BUILD</span></div>
<div v-if="$i && $i.isBot" id="botWarn"><span>{{ i18n.ts.loggedInAsBot }}</span></div> <div v-if="$i && $i.isBot" id="botWarn"><span>{{ i18n.ts.loggedInAsBot }}</span></div>
<SkOneko v-if="defaultStore.state.oneko"/>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -58,6 +60,7 @@ import { useStream } from '@/stream.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { globalEvents } from '@/events.js'; import { globalEvents } from '@/events.js';
import SkOneko from '@/components/SkOneko.vue';
const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue')); const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue'));
const XUpload = defineAsyncComponent(() => import('./upload.vue')); const XUpload = defineAsyncComponent(() => import('./upload.vue'));