mirror of
https://github.com/kokonect-link/cherrypick
synced 2025-01-23 02:04:33 +09:00
feat(reversi): ゲーム中にリアクションを打てるように
This commit is contained in:
parent
8aea3603a6
commit
8608801fee
@ -191,6 +191,10 @@ export interface ReversiGameEventTypes {
|
||||
canceled: {
|
||||
userId: MiUser['id'];
|
||||
};
|
||||
reacted: {
|
||||
userId: MiUser['id'];
|
||||
reaction: string;
|
||||
};
|
||||
}
|
||||
//#endregion
|
||||
|
||||
|
@ -64,7 +64,7 @@ type DecodedReaction = {
|
||||
host?: string | null;
|
||||
};
|
||||
|
||||
const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/;
|
||||
export const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/;
|
||||
const decodeCustomEmojiRegexp = /^:([\w+-]+)(?:@([\w.-]+))?:$/;
|
||||
|
||||
@Injectable()
|
||||
|
@ -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 {
|
||||
}
|
||||
|
@ -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
|
||||
|
77
packages/frontend/src/components/MkBalloon.vue
Normal file
77
packages/frontend/src/components/MkBalloon.vue
Normal 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>
|
@ -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>
|
||||
|
88
packages/frontend/src/pages/reversi/game.emoji-balloon.vue
Normal file
88
packages/frontend/src/pages/reversi/game.emoji-balloon.vue
Normal 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>
|
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user