diff --git a/locales/en-US.yml b/locales/en-US.yml index 6c1cfc9940..913525d7ba 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1095,6 +1095,7 @@ accountMoved: "This user has moved to a new account:" accountMovedShort: "This account has been migrated." operationForbidden: "Operation forbidden" forceShowAds: "Always show ads" +oneko: "Cat friend :3" addMemo: "Add memo" editMemo: "Edit memo" reactionsList: "Reactions" diff --git a/locales/index.d.ts b/locales/index.d.ts index 138c87b765..b9935ae6c9 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -4425,6 +4425,10 @@ export interface Locale extends ILocale { * 常に広告を表示する */ "forceShowAds": string; + /** + * 猫友達 :3 + */ + "oneko": string; /** * メモを追加 */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index a154acca68..b63624f674 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1102,6 +1102,7 @@ accountMoved: "このユーザーは新しいアカウントに移行しまし accountMovedShort: "このアカウントは移行されています" operationForbidden: "この操作はできません" forceShowAds: "常に広告を表示する" +oneko: "猫友達 :3" addMemo: "メモを追加" editMemo: "メモを編集" reactionsList: "リアクション一覧" diff --git a/packages/frontend/assets/oneko.gif b/packages/frontend/assets/oneko.gif new file mode 100644 index 0000000000..a009c2cc19 Binary files /dev/null and b/packages/frontend/assets/oneko.gif differ diff --git a/packages/frontend/src/components/SkOneko.vue b/packages/frontend/src/components/SkOneko.vue new file mode 100644 index 0000000000..fbf50067a9 --- /dev/null +++ b/packages/frontend/src/components/SkOneko.vue @@ -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> diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue index 2c9c1697e3..3b2946e2b7 100644 --- a/packages/frontend/src/pages/settings/general.vue +++ b/packages/frontend/src/pages/settings/general.vue @@ -145,6 +145,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="useSystemFont">{{ i18n.ts.useSystemFont }}</MkSwitch> <MkSwitch v-model="disableDrawer">{{ i18n.ts.disableDrawer }}</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> </div> <div> @@ -332,6 +333,7 @@ const emojiStyle = computed(defaultStore.makeGetterSetter('emojiStyle')); const disableDrawer = computed(defaultStore.makeGetterSetter('disableDrawer')); const disableShowingAnimatedImages = computed(defaultStore.makeGetterSetter('disableShowingAnimatedImages')); const forceShowAds = computed(defaultStore.makeGetterSetter('forceShowAds')); +const oneko = computed(defaultStore.makeGetterSetter('oneko')); const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages')); const highlightSensitiveMedia = computed(defaultStore.makeGetterSetter('highlightSensitiveMedia')); const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab')); diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue index 5fccf15df6..ad0903caee 100644 --- a/packages/frontend/src/pages/settings/preferences-backups.vue +++ b/packages/frontend/src/pages/settings/preferences-backups.vue @@ -98,6 +98,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [ 'showClipButtonInNoteFooter', 'reactionsDisplaySize', 'forceShowAds', + 'oneko', 'numberOfReplies', 'aiChanMode', 'devMode', diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 3c39288d5b..4dad6ce406 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -407,6 +407,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: false, }, + oneko: { + where: 'device', + default: false, + }, clickToOpen: { where: 'device', default: true, diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue index 0ec036c5cb..476394ee21 100644 --- a/packages/frontend/src/ui/_common_/common.vue +++ b/packages/frontend/src/ui/_common_/common.vue @@ -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="$i && $i.isBot" id="botWarn"><span>{{ i18n.ts.loggedInAsBot }}</span></div> + +<SkOneko v-if="defaultStore.state.oneko"/> </template> <script lang="ts" setup> @@ -59,6 +61,8 @@ import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; import { globalEvents } from '@/events.js'; +const SkOneko = defineAsyncComponent(() => import('@/components/SkOneko.vue')); + const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue')); const XUpload = defineAsyncComponent(() => import('./upload.vue'));