diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 0fd9bf6bb0..a93dd5e274 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -215,11 +215,13 @@ export interface MahjongRoomEventTypes { dahai: { house: Mahjong.Common.House; tile: Mahjong.Common.Tile; + riichi: boolean; }; dahaiAndTsumo: { dahaiHouse: Mahjong.Common.House; dahaiTile: Mahjong.Common.Tile; tsumoTile: Mahjong.Common.Tile; + riichi: boolean; }; ponned: { caller: Mahjong.Common.House; diff --git a/packages/backend/src/core/MahjongService.ts b/packages/backend/src/core/MahjongService.ts index 0120b36e8a..5903284b6e 100644 --- a/packages/backend/src/core/MahjongService.ts +++ b/packages/backend/src/core/MahjongService.ts @@ -26,7 +26,7 @@ import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; const INVITATION_TIMEOUT_MS = 1000 * 20; // 20sec -const CALL_AND_RON_ASKING_TIMEOUT_MS = 1000 * 5; // 5sec +const CALL_AND_RON_ASKING_TIMEOUT_MS = 1000 * 7; // 7sec const TURN_TIMEOUT_MS = 1000 * 30; // 30sec const NEXT_KYOKU_CONFIRMATION_TIMEOUT_MS = 1000 * 15; // 15sec @@ -318,7 +318,7 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { switch (res.type) { case 'tsumo': this.globalEventService.publishMahjongRoomStream(room.id, 'tsumo', { house: res.house, tile: res.tile }); - this.next(room, engine); + this.waitForTurn(room, res.turn, engine); break; case 'ponned': this.globalEventService.publishMahjongRoomStream(room.id, 'ponned', { caller: res.caller, callee: res.callee, tile: res.tile }); @@ -349,23 +349,6 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { } } - @bindThis - private async next(room: Room, engine: Mahjong.MasterGameEngine) { - const turn = engine.state.turn; - if (turn == null) throw new Error('turn is null'); - - const aiHouses = [[1, room.user1Ai], [2, room.user2Ai], [3, room.user3Ai], [4, room.user4Ai]].filter(([id, ai]) => ai).map(([id, ai]) => engine.getHouse(id)); - - if (aiHouses.includes(turn)) { - // TODO: ちゃんと思考するようにする - setTimeout(() => { - this.dahai(room, engine, turn, engine.state.handTiles[turn].at(-1)); - }, 500); - } else { - this.waitForTurn(room, turn, engine); - } - } - @bindThis private async endKyoku(room: Room, engine: Mahjong.MasterGameEngine) { const confirmation: NextKyokuConfirmation = { @@ -469,11 +452,11 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { } }, 1000); - this.globalEventService.publishMahjongRoomStream(room.id, 'dahai', { house: house, tile }); + this.globalEventService.publishMahjongRoomStream(room.id, 'dahai', { house: house, tile, riichi }); } else { - this.globalEventService.publishMahjongRoomStream(room.id, 'dahaiAndTsumo', { dahaiHouse: house, dahaiTile: tile, tsumoTile: res.tsumoTile }); + this.globalEventService.publishMahjongRoomStream(room.id, 'dahaiAndTsumo', { dahaiHouse: house, dahaiTile: tile, tsumoTile: res.tsumoTile, riichi }); - this.next(room, engine); + this.waitForTurn(room, res.next, engine); } } @@ -556,7 +539,7 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { const res = engine.commit_tsumoHora(myHouse); - this.globalEventService.publishMahjongRoomStream(room.id, 'tsumoHora', { }); + this.globalEventService.publishMahjongRoomStream(room.id, 'tsumoHora', { house: myHouse, handTiles: res.handTiles, tsumoTile: res.tsumoTile }); } @bindThis @@ -624,6 +607,8 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { */ @bindThis private async waitForTurn(room: Room, house: Mahjong.Common.House, engine: Mahjong.MasterGameEngine) { + const aiHouses = [[1, room.user1Ai], [2, room.user2Ai], [3, room.user3Ai], [4, room.user4Ai]].filter(([id, ai]) => ai).map(([id, ai]) => engine.getHouse(id)); + if (engine.state.riichis[house]) { // リーチ時はアガリ牌でない限りツモ切り const handTiles = engine.state.handTiles[house]; @@ -636,6 +621,13 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { } } + if (aiHouses.includes(house)) { + setTimeout(() => { + this.dahai(room, engine, house, engine.state.handTiles[house].at(-1)); + }, 500); + return; + } + const id = Math.random().toString(36).slice(2); console.log('waitForTurn', house, id); this.redisClient.sadd(`mahjong:gameTurnWaiting:${room.id}`, id); diff --git a/packages/frontend/assets/mahjong/riichi.png b/packages/frontend/assets/mahjong/riichi.png new file mode 100644 index 0000000000..2860f5dc65 Binary files /dev/null and b/packages/frontend/assets/mahjong/riichi.png differ diff --git a/packages/frontend/assets/mahjong/ron.png b/packages/frontend/assets/mahjong/ron.png index 6e1f889865..c508f81adb 100644 Binary files a/packages/frontend/assets/mahjong/ron.png and b/packages/frontend/assets/mahjong/ron.png differ diff --git a/packages/frontend/assets/mahjong/tsumo.png b/packages/frontend/assets/mahjong/tsumo.png index 14577818ab..547d01cbb7 100644 Binary files a/packages/frontend/assets/mahjong/tsumo.png and b/packages/frontend/assets/mahjong/tsumo.png differ diff --git a/packages/frontend/src/pages/mahjong/huro.vue b/packages/frontend/src/pages/mahjong/huro.vue new file mode 100644 index 0000000000..1eaa5971f1 --- /dev/null +++ b/packages/frontend/src/pages/mahjong/huro.vue @@ -0,0 +1,46 @@ + + + + + + + diff --git a/packages/frontend/src/pages/mahjong/room.game.vue b/packages/frontend/src/pages/mahjong/room.game.vue index ec9bc2a298..c44b708685 100644 --- a/packages/frontend/src/pages/mahjong/room.game.vue +++ b/packages/frontend/src/pages/mahjong/room.game.vue @@ -56,6 +56,22 @@ SPDX-License-Identifier: AGPL-3.0-only +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
@@ -107,44 +123,66 @@ SPDX-License-Identifier: AGPL-3.0-only
-
-
-
- - - -
-
-
-
- - - - - + + + + + + + +
- - - - - + + + + + + + +
- - - - - + + + + + + + +
- - - - - + + + + + + + +
@@ -156,6 +194,18 @@ SPDX-License-Identifier: AGPL-3.0-only Riichi
+
+
+
+
{{ house === 'e' ? i18n.ts._mahjong.east : house === 's' ? i18n.ts._mahjong.south : house === 'w' ? i18n.ts._mahjong.west : i18n.ts._mahjong.north }}
+
+
{{ yaku.name }} ({{ yaku.fan }})
+
+
{{ res.doraCount }} dora
+
{{ res.pointDeltas.e }} / {{ res.pointDeltas.s }} / {{ res.pointDeltas.w }} / {{ res.pointDeltas.n }}
+
+
+
@@ -164,6 +214,7 @@ import { computed, onActivated, onDeactivated, onMounted, onUnmounted, reactive, import * as Misskey from 'misskey-js'; import * as Mahjong from 'misskey-mahjong'; import XTile from './tile.vue'; +import XHuro from './huro.vue'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; @@ -202,6 +253,7 @@ const ciiSerifHouses = reactive>({ e: fals const ponSerifHouses = reactive>({ e: false, s: false, w: false, n: false }); const kanSerifHouses = reactive>({ e: false, s: false, w: false, n: false }); const tsumoSerifHouses = reactive>({ e: false, s: false, w: false, n: false }); +const riichiSerifHouses = reactive>({ e: false, s: false, w: false, n: false }); /* console.log(Mahjong.Common.getTilesForRiichi([ @@ -338,6 +390,19 @@ function skip() { const iTsumoed = ref(false); const kyokuEnded = ref(false); +const kyokuResults = ref; +} | null>>({ + e: null, + s: null, + w: null, + n: null, +}); function kyokuEnd() { kyokuEnded.value = true; @@ -359,9 +424,14 @@ function onStreamDahai(log) { // return; //} - engine.value.commit_dahai(log.house, log.tile); + engine.value.commit_dahai(log.house, log.tile, log.riichi); triggerRef(engine); + riichiSerifHouses[log.house] = log.riichi; + window.setTimeout(() => { + riichiSerifHouses[log.house] = false; + }, 2000); + myTurnTimerRmain.value = room.value.timeLimitForEachTurn; } @@ -402,9 +472,14 @@ function onStreamDahaiAndTsumo(log) { playbackRate: 1, }); - engine.value.commit_dahai(log.dahaiHouse, log.dahaiTile); + engine.value.commit_dahai(log.dahaiHouse, log.dahaiTile, log.riichi); triggerRef(engine); + riichiSerifHouses[log.dahaiHouse] = log.riichi; + window.setTimeout(() => { + riichiSerifHouses[log.dahaiHouse] = false; + }, 2000); + window.setTimeout(() => { engine.value.commit_tsumo(Mahjong.Common.nextHouse(log.dahaiHouse), log.tsumoTile); triggerRef(engine); @@ -431,9 +506,9 @@ function onStreamPonned(log) { engine.value.commit_pon(log.caller, log.callee); triggerRef(engine); - ponSerifHouses[log.house] = true; + ponSerifHouses[log.caller] = true; window.setTimeout(() => { - ponSerifHouses[log.house] = false; + ponSerifHouses[log.caller] = false; }, 2000); myTurnTimerRmain.value = room.value.timeLimitForEachTurn; @@ -442,21 +517,31 @@ function onStreamPonned(log) { function onStreamRonned(log) { console.log('onStreamRonned', log); - engine.value.commit_ronHora(log.callers, log.callee, log.handTiles); + const res = engine.value.commit_ronHora(log.callers, log.callee, log.handTiles); triggerRef(engine); + kyokuResults.value = res; + kyokuEnded.value = true; + for (const caller of log.callers) { ronSerifHouses[caller] = true; } + + console.log('ronned', res); } function onStreamTsumoHora(log) { console.log('onStreamTsumoHora', log); + const res = engine.value.commit_tsumoHora(log.house, log.handTiles, log.tsumoTile); + triggerRef(engine); + + kyokuResults.value[log.house] = res; + kyokuEnded.value = true; + tsumoSerifHouses[log.house] = true; - engine.value.commit_tsumoHora(); - triggerRef(engine); + console.log('tsumohora', res); } function restoreRoom(_room) { @@ -516,6 +601,18 @@ onUnmounted(() => { 100% { translate: -130px; } } +.transition_serif_enterActive, +.transition_serif_leaveActive { + transition: opacity 0.1s linear, scale 0.1s linear; +} +.transition_serif_enterFrom { + scale: 1.25; + opacity: 0; +} +.transition_serif_leaveTo { + opacity: 0; +} + .root { background: #3C7A43; background-image: url('/client-assets/mahjong/bg.jpg'); @@ -535,6 +632,22 @@ onUnmounted(() => { box-sizing: border-box; } +.kyokuResult { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + width: 100%; + height: 100%; + max-width: 800px; + margin: auto; + box-sizing: border-box; + background: #0009; + color: #fff; +} + .centerPanel { position: absolute; top: 0; @@ -634,6 +747,24 @@ onUnmounted(() => { right: 80px; } +.huroTilesOfToimen { + position: absolute; + top: 0; + left: 80px; +} + +.huroTilesOfKamitya { + position: absolute; + top: 80px; + left: 0; +} + +.huroTilesOfSimotya { + position: absolute; + top: 80px; + right: 0; +} + .hoTilesContainer { position: absolute; top: 0; @@ -766,6 +897,7 @@ onUnmounted(() => { aspect-ratio: 0.7; transition: translate 0.1s ease; cursor: pointer; + overflow: clip; } .myTile:hover { translate: 0 -10px; diff --git a/packages/misskey-mahjong/src/common.ts b/packages/misskey-mahjong/src/common.ts index 27c2276ad4..d696dea0fb 100644 --- a/packages/misskey-mahjong/src/common.ts +++ b/packages/misskey-mahjong/src/common.ts @@ -158,7 +158,7 @@ type EnvForCalcYaku = { house: House; /** - * 和了る人の手牌(副露した牌は含まない) + * 和了る人の手牌(副露牌および和了る際のツモ牌・ロン牌は含まない) */ handTiles: Tile[]; @@ -214,6 +214,12 @@ export const YAKU_DEFINITIONS = [{ calc: (state: EnvForCalcYaku) => { return state.riichi; }, +}, { + name: 'tsumo', + fan: 1, + calc: (state: EnvForCalcYaku) => { + return state.tsumoTile != null; + }, }, { name: 'red', fan: 1, @@ -272,7 +278,7 @@ export const YAKU_DEFINITIONS = [{ calc: (state: EnvForCalcYaku) => { const yaochuTiles: Tile[] = ['m1', 'm9', 'p1', 'p9', 's1', 's9', 'e', 's', 'w', 'n', 'haku', 'hatsu', 'chun']; return ( - (state.handTiles.filter(t => yaochuTiles.includes(t)).length === 0) && + (!state.handTiles.some(t => yaochuTiles.includes(t))) && (state.huros.filter(huro => huro.type === 'pon' ? yaochuTiles.includes(huro.tile) : huro.type === 'ankan' ? yaochuTiles.includes(huro.tile) : @@ -281,6 +287,27 @@ export const YAKU_DEFINITIONS = [{ false).length === 0) ); }, +}, { + name: 'pinfu', + fan: 1, + calc: (state: EnvForCalcYaku) => { + // 面前じゃないとダメ + if (state.huros.length !== 0) return false; + // 三元牌はダメ + if (state.handTiles.some(t => ['haku', 'hatsu', 'chun'].includes(t))) return false; + + // TODO: 両面待ちかどうか + + const horaSets = getHoraSets(state.handTiles.concat(state.tsumoTile ?? state.ronTile)); + return horaSets.some(horaSet => { + // 風牌判定(役牌でなければOK) + if (horaSet.head === state.seatWind) return false; + if (horaSet.head === state.fieldWind) return false; + + // 全て順子か? + if (horaSet.mentsus.some((mentsu) => mentsu[0] === mentsu[1])) return false; + }); + }, }]; export function fanToPoint(fan: number, isParent: boolean): number { @@ -423,29 +450,44 @@ export const SHUNTU_PATTERNS: [Tile, Tile, Tile][] = [ ['s7', 's8', 's9'], ]; -const SHUNTU_PATTERN_IDS = [ - 'm123', - 'm234', - 'm345', - 'm456', - 'm567', - 'm678', - 'm789', - 'p123', - 'p234', - 'p345', - 'p456', - 'p567', - 'p678', - 'p789', - 's123', - 's234', - 's345', - 's456', - 's567', - 's678', - 's789', -] as const; +export type KyokuResult = { + yakus: { name: string; fan: number; }[]; + doraCount: number; + pointDeltas: { e: number; s: number; w: number; n: number; }; +}; + +function extractShuntsus(tiles: Tile[]): [Tile, Tile, Tile][] { + const tempTiles = [...tiles]; + + tempTiles.sort((a, b) => { + const aIndex = TILE_TYPES.indexOf(a); + const bIndex = TILE_TYPES.indexOf(b); + return aIndex - bIndex; + }); + + const shuntsus: [Tile, Tile, Tile][] = []; + while (tempTiles.length > 0) { + let isShuntu = false; + for (const shuntuPattern of SHUNTU_PATTERNS) { + if ( + tempTiles[0] === shuntuPattern[0] && + tempTiles.includes(shuntuPattern[1]) && + tempTiles.includes(shuntuPattern[2]) + ) { + shuntsus.push(shuntuPattern); + tempTiles.splice(0, 1); + tempTiles.splice(tempTiles.indexOf(shuntuPattern[1]), 1); + tempTiles.splice(tempTiles.indexOf(shuntuPattern[2]), 1); + isShuntu = true; + break; + } + } + + if (!isShuntu) tempTiles.splice(0, 1); + } + + return shuntsus; +} /** * アガリ形パターン一覧を取得 @@ -537,34 +579,7 @@ export function getHoraSets(handTiles: Tile[]): HoraSet[] { tempHandTilesWithoutKotsu.splice(tempHandTilesWithoutKotsu.indexOf(kotsuTile), 1); } - tempHandTilesWithoutKotsu.sort((a, b) => { - const aIndex = TILE_TYPES.indexOf(a); - const bIndex = TILE_TYPES.indexOf(b); - return aIndex - bIndex; - }); - - const tempHandTilesWithoutKotsuAndShuntsu: (Tile | null)[] = [...tempHandTilesWithoutKotsu]; - - const shuntsus: [Tile, Tile, Tile][] = []; - while (tempHandTilesWithoutKotsuAndShuntsu.length > 0) { - let isShuntu = false; - for (const shuntuPattern of SHUNTU_PATTERNS) { - if ( - tempHandTilesWithoutKotsuAndShuntsu[0] === shuntuPattern[0] && - tempHandTilesWithoutKotsuAndShuntsu.includes(shuntuPattern[1]) && - tempHandTilesWithoutKotsuAndShuntsu.includes(shuntuPattern[2]) - ) { - shuntsus.push(shuntuPattern); - tempHandTilesWithoutKotsuAndShuntsu.splice(0, 1); - tempHandTilesWithoutKotsuAndShuntsu.splice(tempHandTilesWithoutKotsuAndShuntsu.indexOf(shuntuPattern[1]), 1); - tempHandTilesWithoutKotsuAndShuntsu.splice(tempHandTilesWithoutKotsuAndShuntsu.indexOf(shuntuPattern[2]), 1); - isShuntu = true; - break; - } - } - - if (!isShuntu) tempHandTilesWithoutKotsuAndShuntsu.splice(0, 1); - } + const shuntsus = extractShuntsus(tempHandTilesWithoutKotsu); if (shuntsus.length * 3 === tempHandTilesWithoutKotsu.length) { // アガリ形 horaSets.push({ diff --git a/packages/misskey-mahjong/src/engine.master.ts b/packages/misskey-mahjong/src/engine.master.ts index 26707bfb65..452918bb08 100644 --- a/packages/misskey-mahjong/src/engine.master.ts +++ b/packages/misskey-mahjong/src/engine.master.ts @@ -380,6 +380,7 @@ export class MasterGameEngine { return { asking: false as const, tsumoTile: tsumoTile, + next: this.state.turn, }; } @@ -439,6 +440,11 @@ export class MasterGameEngine { console.log('yakus', house, yakus); this.endKyoku(); + + return { + handTiles: this.state.handTiles[house], + tsumoTile: this.state.handTiles[house].at(-1)!, + }; } public commit_resolveCallAndRonInterruption(answers: { diff --git a/packages/misskey-mahjong/src/engine.player.ts b/packages/misskey-mahjong/src/engine.player.ts index 0f6d86b874..578555ffd4 100644 --- a/packages/misskey-mahjong/src/engine.player.ts +++ b/packages/misskey-mahjong/src/engine.player.ts @@ -64,6 +64,12 @@ export type PlayerState = { canRonTo: House | null; }; +export type KyokuResult = { + yakus: { name: string; fan: number; }[]; + doraCount: number; + pointDeltas: { e: number; s: number; w: number; n: number; }; +}; + export class PlayerGameEngine { /** * このエラーが発生したときはdesyncが疑われる @@ -145,10 +151,33 @@ export class PlayerGameEngine { if (this.state.turn !== house) throw new PlayerGameEngine.InvalidOperationError(); } - public commit_tsumoHora(house: House) { + public commit_tsumoHora(house: House, handTiles: Tile[], tsumoTile: Tile): KyokuResult { console.log('commit_tsumoHora', this.state.turn, house); - // TODO: ツモした人の手牌情報を貰う必要がある + const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc({ + house: house, + handTiles: handTiles, + huros: this.state.huros[house], + tsumoTile: tsumoTile, + ronTile: null, + riichi: this.state.riichis[house], + })); + const doraCount = Common.calcOwnedDoraCount(handTiles, this.state.huros[house], this.doras); + const fans = yakus.map(yaku => yaku.fan).reduce((a, b) => a + b, 0) + doraCount; + const pointDeltas = Common.calcTsumoHoraPointDeltas(house, fans); + this.state.points.e += pointDeltas.e; + this.state.points.s += pointDeltas.s; + this.state.points.w += pointDeltas.w; + this.state.points.n += pointDeltas.n; + + return { + yakus: yakus.map(yaku => ({ + name: yaku.name, + fan: yaku.fan, + })), + doraCount, + pointDeltas, + }; } /** @@ -161,23 +190,16 @@ export class PlayerGameEngine { s: Tile[]; w: Tile[]; n: Tile[]; - }) { + }): Record { console.log('commit_ronHora', this.state.turn, callers, callee); this.state.canRonSource = null; - const yakusMap: Record = { - e: [] as { name: string; fan: number; }[], - s: [] as { name: string; fan: number; }[], - w: [] as { name: string; fan: number; }[], - n: [] as { name: string; fan: number; }[], - }; - - const doraCountsMap: Record = { - e: 0, - s: 0, - w: 0, - n: 0, + const resultMap: Record = { + e: { yakus: [], doraCount: 0, pointDeltas: { e: 0, s: 0, w: 0, n: 0 } }, + s: { yakus: [], doraCount: 0, pointDeltas: { e: 0, s: 0, w: 0, n: 0 } }, + w: { yakus: [], doraCount: 0, pointDeltas: { e: 0, s: 0, w: 0, n: 0 } }, + n: { yakus: [], doraCount: 0, pointDeltas: { e: 0, s: 0, w: 0, n: 0 } }, }; for (const house of callers) { @@ -194,14 +216,17 @@ export class PlayerGameEngine { const point = Common.fanToPoint(fans, house === 'e'); this.state.points[callee] -= point; this.state.points[house] += point; - yakusMap[house] = yakus.map(yaku => ({ name: yaku.name, fan: yaku.fan })); - doraCountsMap[house] = doraCount; - console.log('yakus', house, yakus); + resultMap[house].yakus = yakus.map(yaku => ({ name: yaku.name, fan: yaku.fan })); + resultMap[house].doraCount = doraCount; + resultMap[house].pointDeltas[callee] = -point; + resultMap[house].pointDeltas[house] = point; } return { - yakusMap, - doraCountsMap, + e: callers.includes('e') ? resultMap.e : null, + s: callers.includes('s') ? resultMap.s : null, + w: callers.includes('w') ? resultMap.w : null, + n: callers.includes('n') ? resultMap.n : null, }; }