enhance(reversi): tweak reversi

This commit is contained in:
syuilo 2024-01-21 12:05:51 +09:00
parent 4de77784c9
commit 6039f27bd5
7 changed files with 149 additions and 78 deletions

4
locales/index.d.ts vendored
View file

@ -9553,6 +9553,10 @@ export interface Locale extends ILocale {
* *
*/ */
"lookingForPlayer": string; "lookingForPlayer": string;
/**
*
*/
"gameCanceled": string;
}; };
"_offlineScreen": { "_offlineScreen": {
/** /**

View file

@ -2544,6 +2544,7 @@ _reversi:
timeLimitForEachTurn: "1ターンの時間制限" timeLimitForEachTurn: "1ターンの時間制限"
freeMatch: "フリーマッチ" freeMatch: "フリーマッチ"
lookingForPlayer: "対戦相手を探しています" lookingForPlayer: "対戦相手を探しています"
gameCanceled: "対局がキャンセルされました"
_offlineScreen: _offlineScreen:
title: "オフライン - サーバーに接続できません" title: "オフライン - サーバーに接続できません"

View file

@ -188,6 +188,9 @@ export interface ReversiGameEventTypes {
winnerId: MiUser['id'] | null; winnerId: MiUser['id'] | null;
game: Packed<'ReversiGameDetailed'>; game: Packed<'ReversiGameDetailed'>;
}; };
canceled: {
userId: MiUser['id'];
};
} }
//#endregion //#endregion

View file

@ -61,6 +61,11 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
await this.redisClient.setex(`reversi:game:cache:${game.id}`, 60 * 3, JSON.stringify(game)); await this.redisClient.setex(`reversi:game:cache:${game.id}`, 60 * 3, JSON.stringify(game));
} }
@bindThis
private async deleteGameCache(gameId: MiReversiGame['id']) {
await this.redisClient.del(`reversi:game:cache:${gameId}`);
}
@bindThis @bindThis
public async matchSpecificUser(me: MiUser, targetUser: MiUser): Promise<MiReversiGame | null> { public async matchSpecificUser(me: MiUser, targetUser: MiUser): Promise<MiReversiGame | null> {
if (targetUser.id === me.id) { if (targetUser.id === me.id) {
@ -239,88 +244,93 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
if (isBothReady) { if (isBothReady) {
// 3秒後、両者readyならゲーム開始 // 3秒後、両者readyならゲーム開始
setTimeout(async () => { setTimeout(async () => {
const freshGame = await this.reversiGamesRepository.findOneBy({ id: game.id }); const freshGame = await this.get(game.id);
if (freshGame == null || freshGame.isStarted || freshGame.isEnded) return; if (freshGame == null || freshGame.isStarted || freshGame.isEnded) return;
if (!freshGame.user1Ready || !freshGame.user2Ready) return; if (!freshGame.user1Ready || !freshGame.user2Ready) return;
let bw: number; this.startGame(freshGame);
if (freshGame.bw === 'random') {
bw = Math.random() > 0.5 ? 1 : 2;
} else {
bw = parseInt(freshGame.bw, 10);
}
function getRandomMap() {
const mapCount = Object.entries(Reversi.maps).length;
const rnd = Math.floor(Math.random() * mapCount);
return Object.values(Reversi.maps)[rnd].data;
}
const map = freshGame.map != null ? freshGame.map : getRandomMap();
const crc32 = CRC32.str(JSON.stringify(freshGame.logs)).toString();
const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update()
.set({
startedAt: new Date(),
isStarted: true,
black: bw,
map: map,
crc32,
})
.where('id = :id', { id: game.id })
.returning('*')
.execute()
.then((response) => response.raw[0]);
this.cacheGame(updatedGame);
//#region 盤面に最初から石がないなどして始まった瞬間に勝敗が決定する場合があるのでその処理
const engine = new Reversi.Game(map, {
isLlotheo: freshGame.isLlotheo,
canPutEverywhere: freshGame.canPutEverywhere,
loopedBoard: freshGame.loopedBoard,
});
if (engine.isEnded) {
let winner;
if (engine.winner === true) {
winner = bw === 1 ? freshGame.user1Id : freshGame.user2Id;
} else if (engine.winner === false) {
winner = bw === 1 ? freshGame.user2Id : freshGame.user1Id;
} else {
winner = null;
}
const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update()
.set({
isEnded: true,
endedAt: new Date(),
winnerId: winner,
})
.where('id = :id', { id: game.id })
.returning('*')
.execute()
.then((response) => response.raw[0]);
this.cacheGame(updatedGame);
this.globalEventService.publishReversiGameStream(game.id, 'ended', {
winnerId: winner,
game: await this.reversiGameEntityService.packDetail(game.id),
});
return;
}
//#endregion
this.redisClient.setex(`reversi:game:turnTimer:${game.id}:1`, updatedGame.timeLimitForEachTurn, '');
this.globalEventService.publishReversiGameStream(game.id, 'started', {
game: await this.reversiGameEntityService.packDetail(game.id),
});
}, 3000); }, 3000);
} }
} }
@bindThis
private async startGame(game: MiReversiGame) {
let bw: number;
if (game.bw === 'random') {
bw = Math.random() > 0.5 ? 1 : 2;
} else {
bw = parseInt(game.bw, 10);
}
function getRandomMap() {
const mapCount = Object.entries(Reversi.maps).length;
const rnd = Math.floor(Math.random() * mapCount);
return Object.values(Reversi.maps)[rnd].data;
}
const map = game.map != null ? game.map : getRandomMap();
const crc32 = CRC32.str(JSON.stringify(game.logs)).toString();
const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update()
.set({
startedAt: new Date(),
isStarted: true,
black: bw,
map: map,
crc32,
})
.where('id = :id', { id: game.id })
.returning('*')
.execute()
.then((response) => response.raw[0]);
this.cacheGame(updatedGame);
//#region 盤面に最初から石がないなどして始まった瞬間に勝敗が決定する場合があるのでその処理
const engine = new Reversi.Game(map, {
isLlotheo: game.isLlotheo,
canPutEverywhere: game.canPutEverywhere,
loopedBoard: game.loopedBoard,
});
if (engine.isEnded) {
let winner;
if (engine.winner === true) {
winner = bw === 1 ? game.user1Id : game.user2Id;
} else if (engine.winner === false) {
winner = bw === 1 ? game.user2Id : game.user1Id;
} else {
winner = null;
}
const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update()
.set({
isEnded: true,
endedAt: new Date(),
winnerId: winner,
})
.where('id = :id', { id: game.id })
.returning('*')
.execute()
.then((response) => response.raw[0]);
this.cacheGame(updatedGame);
this.globalEventService.publishReversiGameStream(game.id, 'ended', {
winnerId: winner,
game: await this.reversiGameEntityService.packDetail(game.id),
});
return;
}
//#endregion
this.redisClient.setex(`reversi:game:turnTimer:${game.id}:1`, updatedGame.timeLimitForEachTurn, '');
this.globalEventService.publishReversiGameStream(game.id, 'started', {
game: await this.reversiGameEntityService.packDetail(game.id),
});
}
@bindThis @bindThis
public async getInvitations(user: MiUser): Promise<MiUser['id'][]> { public async getInvitations(user: MiUser): Promise<MiUser['id'][]> {
const invitations = await this.redisClient.zrange( const invitations = await this.redisClient.zrange(
@ -510,6 +520,21 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
} }
} }
@bindThis
public async cancelGame(gameId: MiReversiGame['id'], user: MiUser) {
const game = await this.get(gameId);
if (game == null) throw new Error('game not found');
if (game.isStarted) return;
if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) return;
await this.reversiGamesRepository.delete(game.id);
this.deleteGameCache(game.id);
this.globalEventService.publishReversiGameStream(game.id, 'canceled', {
userId: user.id,
});
}
@bindThis @bindThis
public async get(id: MiReversiGame['id']): Promise<MiReversiGame | null> { public async get(id: MiReversiGame['id']): Promise<MiReversiGame | null> {
const cached = await this.redisClient.get(`reversi:game:cache:${id}`); const cached = await this.redisClient.get(`reversi:game:cache:${id}`);

View file

@ -40,6 +40,7 @@ class ReversiGameChannel extends Channel {
switch (type) { switch (type) {
case 'ready': this.ready(body); break; case 'ready': this.ready(body); break;
case 'updateSettings': this.updateSettings(body.key, body.value); break; case 'updateSettings': this.updateSettings(body.key, body.value); break;
case 'cancel': this.cancelGame(); break;
case 'putStone': this.putStone(body.pos, body.id); break; case 'putStone': this.putStone(body.pos, body.id); break;
case 'checkState': this.checkState(body.crc32); break; case 'checkState': this.checkState(body.crc32); break;
case 'claimTimeIsUp': this.claimTimeIsUp(); break; case 'claimTimeIsUp': this.claimTimeIsUp(); break;
@ -60,6 +61,13 @@ class ReversiGameChannel extends Channel {
this.reversiService.gameReady(this.gameId!, this.user, ready); this.reversiService.gameReady(this.gameId!, this.user, ready);
} }
@bindThis
private async cancelGame() {
if (this.user == null) return;
this.reversiService.cancelGame(this.gameId!, this.user);
}
@bindThis @bindThis
private async putStone(pos: number, id: string) { private async putStone(pos: number, id: string) {
if (this.user == null) return; if (this.user == null) return;

View file

@ -86,7 +86,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-if="!isReady && !isOpReady">{{ i18n.ts._reversi.waitingBoth }}<MkEllipsis/></template> <template v-if="!isReady && !isOpReady">{{ i18n.ts._reversi.waitingBoth }}<MkEllipsis/></template>
</div> </div>
<div class="_buttonsCenter"> <div class="_buttonsCenter">
<MkButton rounded danger @click="exit">{{ i18n.ts.cancel }}</MkButton> <MkButton rounded danger @click="cancel">{{ i18n.ts.cancel }}</MkButton>
<MkButton v-if="!isReady" rounded primary @click="ready">{{ i18n.ts._reversi.ready }}</MkButton> <MkButton v-if="!isReady" rounded primary @click="ready">{{ i18n.ts._reversi.ready }}</MkButton>
<MkButton v-if="isReady" rounded @click="unready">{{ i18n.ts._reversi.cancelReady }}</MkButton> <MkButton v-if="isReady" rounded @click="unready">{{ i18n.ts._reversi.cancelReady }}</MkButton>
</div> </div>
@ -109,9 +109,12 @@ import MkSwitch from '@/components/MkSwitch.vue';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { MenuItem } from '@/types/menu.js'; import { MenuItem } from '@/types/menu.js';
import { useRouter } from '@/global/router/supplier.js';
const $i = signinRequired(); const $i = signinRequired();
const router = useRouter();
const mapCategories = Array.from(new Set(Object.values(Reversi.maps).map(x => x.category))); const mapCategories = Array.from(new Set(Object.values(Reversi.maps).map(x => x.category)));
const props = defineProps<{ const props = defineProps<{
@ -171,8 +174,16 @@ function chooseMap(ev: MouseEvent) {
os.popupMenu(menu, ev.currentTarget ?? ev.target); os.popupMenu(menu, ev.currentTarget ?? ev.target);
} }
function exit() { async function cancel() {
props.connection.send('exit', {}); const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.areYouSure,
});
if (canceled) return;
props.connection.send('cancel', {});
router.push('/reversi');
} }
function ready() { function ready() {

View file

@ -17,6 +17,14 @@ import GameBoard from './game.board.vue';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
import { useStream } from '@/stream.js'; import { useStream } from '@/stream.js';
import { signinRequired } from '@/account.js';
import { useRouter } from '@/global/router/supplier.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
const $i = signinRequired();
const router = useRouter();
const props = defineProps<{ const props = defineProps<{
gameId: string; gameId: string;
@ -45,6 +53,17 @@ async function fetchGame() {
connection.value.on('started', x => { connection.value.on('started', x => {
game.value = x.game; game.value = x.game;
}); });
connection.value.on('canceled', x => {
connection.value?.dispose();
if (x.userId !== $i.id) {
os.alert({
type: 'warning',
text: i18n.ts._reversi.gameCanceled,
});
router.push('/reversi');
}
});
} }
onMounted(() => { onMounted(() => {