1
1
mirror of https://github.com/kokonect-link/cherrypick synced 2025-01-23 02:04:33 +09:00

feat(reversi): ゲーム中にリアクションを打てるように

This commit is contained in:
kakkokari-gtyih 2024-01-31 17:55:18 +09:00
parent 8aea3603a6
commit 8608801fee
8 changed files with 408 additions and 3 deletions

View File

@ -191,6 +191,10 @@ export interface ReversiGameEventTypes {
canceled: {
userId: MiUser['id'];
};
reacted: {
userId: MiUser['id'];
reaction: string;
};
}
//#endregion

View File

@ -64,7 +64,7 @@ type DecodedReaction = {
host?: string | null;
};
const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/;
export const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/;
const decodeCustomEmojiRegexp = /^:([\w+-]+)(?:@([\w.-]+))?:$/;
@Injectable()

View File

@ -20,6 +20,8 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { IdService } from '@/core/IdService.js';
import { NotificationService } from '@/core/NotificationService.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { isCustomEmojiRegexp, ReactionService } from '@/core/ReactionService.js';
import { Serialized } from '@/types.js';
import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js';
import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
@ -44,6 +46,8 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
private globalEventService: GlobalEventService,
private reversiGameEntityService: ReversiGameEntityService,
private idService: IdService,
private customEmojiService: CustomEmojiService,
private reactionService: ReactionService,
) {
}
@ -602,6 +606,43 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
}
}
@bindThis
public async sendReaction(gameId: MiReversiGame['id'], user: MiUser, reaction: string) {
const game = await this.get(gameId);
if (game == null) throw new Error('game not found');
if (!game.isStarted || game.isEnded) return;
if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) return;
const lastReactedAt = await this.redisClient.get(`reversi:game:lastReactedAt:${game.id}:${user.id}`);
if (lastReactedAt && (Date.now() - parseInt(lastReactedAt, 10) < 3000)) {
// レートリミット3秒
return;
}
let _reaction = '❤️';
const custom = reaction.match(isCustomEmojiRegexp);
if (custom) {
const name = custom[1];
const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name);
if (emoji && !emoji.isSensitive) {
_reaction = `:${name}:`;
}
} else {
_reaction = this.reactionService.normalize(reaction);
}
this.globalEventService.publishReversiGameStream(game.id, 'reacted', {
userId: user.id,
reaction: _reaction,
});
this.redisClient.setex(`reversi:game:lastReactedAt:${game.id}:${user.id}`, 60 * 60, Date.now().toString());
}
@bindThis
public dispose(): void {
}

View File

@ -42,6 +42,7 @@ class ReversiGameChannel extends Channel {
case 'cancel': this.cancelGame(); break;
case 'putStone': this.putStone(body.pos, body.id); break;
case 'claimTimeIsUp': this.claimTimeIsUp(); break;
case 'reaction': this.sendReaction(body); break;
}
}
@ -80,6 +81,13 @@ class ReversiGameChannel extends Channel {
this.reversiService.checkTimeout(this.gameId!);
}
@bindThis
private async sendReaction(reaction: string) {
if (this.user == null) return;
this.reversiService.sendReaction(this.gameId!, this.user, reaction);
}
@bindThis
public dispose() {
// Unsubscribe events

View File

@ -0,0 +1,77 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="[$style.root, tail === 'left' ? $style.left : $style.right]">
<div :class="$style.bg">
<svg :class="$style.tail" version="1.1" viewBox="0 0 14.597 14.58" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-173.71 -87.184)">
<path d="m188.19 87.657c-1.469 2.3218-3.9315 3.8312-6.667 4.0865-2.2309-1.7379-4.9781-2.6816-7.8061-2.6815h-5.1e-4v12.702h12.702v-5.1e-4c2e-5 -1.9998-0.47213-3.9713-1.378-5.754 2.0709-1.6834 3.2732-4.2102 3.273-6.8791-6e-5 -0.49375-0.0413-0.98662-0.1235-1.4735z" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round" stroke-width=".33225" style="paint-order:stroke fill markers"/>
</g>
</svg>
<div :class="$style.content">
<slot></slot>
</div>
</div>
</div>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
tail?: 'left' | 'right';
}>(), {
tail: 'right',
});
</script>
<style module lang="scss">
.root {
--balloon-radius: 16px;
--balloon-bg: var(--panel);
position: relative;
display: inline-block;
min-height: calc(var(--balloon-radius) * 2);
padding-top: calc(var(--balloon-radius) * .13);
&.left {
padding-left: calc(var(--balloon-radius) * .13);
}
&.right {
padding-right: calc(var(--balloon-radius) * .13);
}
}
.bg {
width: 100%;
height: 100%;
background: var(--balloon-bg);
border-radius: var(--balloon-radius);
}
.content {
position: relative;
padding: 8px 12px;
}
.tail {
position: absolute;
top: 0;
display: block;
width: calc(var(--balloon-radius) * 1.13);
height: auto;
fill: var(--balloon-bg);
}
.left .tail {
left: 0;
transform: rotateY(180deg);
}
.right .tail {
right: 0;
}
</style>

View File

@ -8,9 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.root" class="_gaps">
<div style="display: flex; align-items: center; justify-content: center; gap: 10px;">
<span>({{ i18n.ts._reversi.black }})</span>
<MkAvatar style="width: 32px; height: 32px;" :user="blackUser" :showIndicator="true"/>
<div ref="blackUserEl">
<MkAvatar style="width: 32px; height: 32px;" :user="blackUser" :showIndicator="true"/>
</div>
<span> vs </span>
<MkAvatar style="width: 32px; height: 32px;" :user="whiteUser" :showIndicator="true"/>
<div ref="whiteUserEl">
<MkAvatar style="width: 32px; height: 32px;" :user="whiteUser" :showIndicator="true"/>
</div>
<span>({{ i18n.ts._reversi.white }})</span>
</div>
@ -121,6 +125,19 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<div v-if="iAmPlayer && !game.isEnded" class="_panel" style="text-align: start; padding: 16px;">
<div :class="$style.reactionLabel">{{ i18n.ts.reaction }}</div>
<div :class="$style.reactionPickerBar">
<button v-for="element in reactionEmojis" :key="element" class="_button" :class="$style.emojisItem" :disabled="!canReact" @click="onReactionEmojiClick(element, $event)">
<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true"/>
<MkEmoji v-else :emoji="element" :normal="true"/>
</button>
<button ref="reactButton" class="_button" :class="[$style.emojisItem, $style.plus]" :disabled="!canReact" @click="onReactionPickerClick">
<i class="ti ti-plus"></i>
</button>
</div>
</div>
<MkFolder>
<template #label>{{ i18n.ts.options }}</template>
<div class="_gaps_s" style="text-align: left;">
@ -145,9 +162,12 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref, shallowRef, triggerRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
import * as Reversi from 'misskey-reversi';
import type { UnicodeEmojiDef } from '@/scripts/emojilist.js';
import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import XEmojiBalloon from './game.emoji-balloon.vue';
import { deepClone } from '@/scripts/clone.js';
import { useInterval } from '@/scripts/use-interval.js';
import { signinRequired } from '@/account.js';
@ -156,6 +176,8 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
import { userPage } from '@/filters/user.js';
import * as sound from '@/scripts/sound.js';
import * as os from '@/os.js';
import { defaultStore } from '@/store.js';
import { reactionPicker } from '@/scripts/reaction-picker.js';
import { confetti } from '@/scripts/confetti.js';
const $i = signinRequired();
@ -183,6 +205,7 @@ const iAmPlayer = computed(() => {
return game.value.user1Id === $i.id || game.value.user2Id === $i.id;
});
// true: , false:
const myColor = computed(() => {
if (!iAmPlayer.value) return null;
if (game.value.user1Id === $i.id && game.value.black === 1) return true;
@ -447,9 +470,109 @@ function share() {
});
}
const _reactionEmojis = ref(defaultStore.reactiveState.reactions.value);
const reactionEmojis = computed(() => _reactionEmojis.value.slice(0, 10));
const blackUserEl = ref<HTMLElement | null>(null);
const whiteUserEl = ref<HTMLElement | null>(null);
const canReact = ref(true);
let canReactFallbackTimer: number | null = null;
function getKey(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef): string {
return typeof emoji === 'string' ? emoji : 'char' in emoji ? emoji.char : `:${emoji.name}:`;
}
//
function onReactionEmojiClick(emoji: string, ev?: MouseEvent) {
const el = ev && (ev.currentTarget ?? ev.target) as HTMLElement | null | undefined;
if (el) {
const rect = el.getBoundingClientRect();
const x = rect.left + (el.offsetWidth / 2);
const y = rect.top + (el.offsetHeight / 2);
os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
const key = getKey(emoji);
sendReaction(key);
if (!_reactionEmojis.value.includes(key)) {
_reactionEmojis.value.unshift(key);
}
}
// #region
const reactButton = ref<HTMLElement | null>(null);
function onReactionPickerClick() {
reactionPicker.show(reactButton.value ?? null, reaction => {
const key = getKey(reaction);
sendReaction(key);
if (!_reactionEmojis.value.includes(key)) {
_reactionEmojis.value.unshift(key);
}
});
}
// #endregion
function sendReaction(emojiKey: string) {
if (!canReact.value) return;
canReact.value = false;
//
props.connection!.send('reaction', emojiKey);
// 3 or
if (canReactFallbackTimer != null) {
window.clearTimeout(canReactFallbackTimer);
}
canReactFallbackTimer = window.setTimeout(() => {
if (!canReact.value) {
canReact.value = true;
}
}, 10000);
}
function onReacted(payload: Parameters<Misskey.Channels['reversiGame']['events']['reacted']>['0']) {
console.log('onReacted', payload);
const { userId, reaction } = payload;
sound.playMisskeySfx('reaction');
const el = (userId === blackUser.value.id) ? blackUserEl.value : whiteUserEl.value;
if (el) {
const rect = el.getBoundingClientRect();
const x = rect.right;
const y = rect.bottom;
os.popup(XEmojiBalloon, {
reaction,
tail: 'left',
x,
y,
}, {}, 'end');
}
if (userId === $i.id) {
//
window.setTimeout(() => {
if (canReactFallbackTimer != null) {
window.clearTimeout(canReactFallbackTimer);
}
if (!canReact.value) {
canReact.value = true;
}
}, 3000);
}
}
onMounted(() => {
if (props.connection != null) {
props.connection.on('log', onStreamLog);
props.connection.on('reacted', onReacted);
props.connection.on('ended', onStreamEnded);
}
});
@ -457,6 +580,7 @@ onMounted(() => {
onActivated(() => {
if (props.connection != null) {
props.connection.on('log', onStreamLog);
props.connection.on('reacted', onReacted);
props.connection.on('ended', onStreamEnded);
}
});
@ -464,6 +588,7 @@ onActivated(() => {
onDeactivated(() => {
if (props.connection != null) {
props.connection.off('log', onStreamLog);
props.connection.off('reacted', onReacted);
props.connection.off('ended', onStreamEnded);
}
});
@ -471,6 +596,7 @@ onDeactivated(() => {
onUnmounted(() => {
if (props.connection != null) {
props.connection.off('log', onStreamLog);
props.connection.off('reacted', onReacted);
props.connection.off('ended', onStreamEnded);
}
});
@ -630,4 +756,63 @@ $gap: 4px;
height: 100%;
border-radius: 100%;
}
.reactionLabel {
font-size: 0.85em;
padding: 0 0 8px 0;
user-select: none;
&:empty {
display: none;
}
}
.reactionPickerBar {
display: flex;
flex-wrap: wrap;
padding: 12px;
background: var(--bg);
border-radius: calc(var(--radius) / 2);
}
.emojisItem {
font-size: 24px;
border-radius: 4px;
width: 40px;
height: 40px;
&.plus {
font-size: 16px;
&:active {
background: var(--accentedBg);
}
}
&:focus-visible {
outline: solid 2px var(--focus);
z-index: 1;
}
&:hover {
background: rgba(0, 0, 0, 0.05);
}
&:not(:disabled):active {
background: var(--accent);
box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15);
}
&:disabled {
opacity: 0.7;
}
> .emoji {
height: 1.25em;
vertical-align: -.25em;
pointer-events: none;
width: 100%;
object-fit: contain;
}
}
</style>

View File

@ -0,0 +1,88 @@
<template>
<Transition
:enterActiveClass="$style.transition_balloon_enterActive"
:leaveActiveClass="$style.transition_balloon_leaveActive"
:enterFromClass="$style.transition_balloon_enterFrom"
:leaveToClass="$style.transition_balloon_leaveTo"
mode="default"
>
<MkBalloon
v-if="active"
:style="{
zIndex,
top: `${y}px`,
left: `${x}px`,
transformOrigin: (tail === 'left' ? 'top left' : 'top right'),
}"
:class="$style.balloonRoot"
:tail="tail"
>
<MkReactionIcon :reaction="reaction" :class="$style.emoji"/>
</MkBalloon>
</Transition>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import * as os from '@/os.js';
import MkBalloon from '@/components/MkBalloon.vue';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
defineProps<{
reaction: string;
tail: 'left' | 'right';
x: number;
y: number;
}>();
const emit = defineEmits<{
(ev: 'end'): void;
}>();
const zIndex = os.claimZIndex('high');
const active = ref(false);
onMounted(() => {
active.value = true;
setTimeout(() => {
active.value = false;
setTimeout(() => {
emit('end');
}, 1000);
}, 3000);
});
</script>
<style module>
.transition_balloon_enterActive {
transition: all .15s cubic-bezier(0.65, 0.05, 0.36, 1);
}
.transition_balloon_leaveActive {
transition: all 1s ease;
}
.transition_balloon_enterFrom {
transform: translateY(-100%) scale(0);
opacity: 0;
}
.transition_balloon_leaveTo {
opacity: 0;
}
.balloonRoot {
position: absolute;
filter: drop-shadow(0 2px 8px var(--shadow));
--balloon-radius: 24px;
user-select: none;
pointer-events: none;
}
.emoji {
height: 32px;
width: auto;
max-width: 200px;
}
</style>

View File

@ -208,6 +208,7 @@ export type Channels = {
canceled: (payload: { userId: User['id']; }) => void;
changeReadyStates: (payload: { user1: boolean; user2: boolean; }) => void;
updateSettings: (payload: { userId: User['id']; key: string; value: any; }) => void;
reacted: (payload: { userId: User['id']; reaction: string; }) => void;
log: (payload: Record<string, any>) => void;
};
receives: {
@ -222,6 +223,7 @@ export type Channels = {
value: any;
};
claimTimeIsUp: null | Record<string, never>;
reaction: string;
}
}
};