diff --git a/frontend/src/HomePage/CreateRoomModal.tsx b/frontend/src/HomePage/CreateRoomModal.tsx index e39fa85..3760445 100644 --- a/frontend/src/HomePage/CreateRoomModal.tsx +++ b/frontend/src/HomePage/CreateRoomModal.tsx @@ -105,7 +105,7 @@ const CreateRoomModal = ({ isOpen, onClose, onCreate }: CreateRoomModalProps) => onChange={(e) => setGameName(e.target.value as GameName)} > - {/* 향후 다른 게임 추가 가능 */} + diff --git a/frontend/src/RoomDetailPage.tsx b/frontend/src/RoomDetailPage.tsx index 8b9ab3e..715a9fc 100644 --- a/frontend/src/RoomDetailPage.tsx +++ b/frontend/src/RoomDetailPage.tsx @@ -5,13 +5,23 @@ import { useAuth } from '@/useAuth.tsx'; import { useAxios } from "@/useAxios.tsx"; import { useRoom } from "@/useRoom.tsx"; import { useStomp } from "@/useStomp.tsx" -import { GameRule } from "@/games/types.ts"; +import { GameMessage, GameName, GameRule } from "@/games/types.ts"; import TichuPage from "@/games/tichu/TichuPage.tsx"; import { Team } from "@/games/tichu/domain/Team.ts"; import { TichuRule, TichuWinningScore } from "@/games/tichu/domain/TichuRule.ts"; -import { TichuMessage, TichuMessageType } from "@/games/tichu/dtos/TichuMessage.ts"; +import HanabiPage from "@/games/hanabi/HanabiPage.tsx"; +import { HanabiRule, RainbowMode } from "@/games/hanabi/domain/HanabiRule.ts"; import styles from './RoomDetailPage.module.css'; +const gameSlugOf = (gameName: GameName) => { + switch (gameName) { + case GameName.TICHU: + return 'tichu'; + case GameName.HANABI: + return 'hanabi'; + } +}; + const RoomDetailPage = () => { const { roomId } = useParams(); const navigate = useNavigate(); @@ -105,28 +115,33 @@ const RoomDetailPage = () => { }; useEffect(() => { - if (!user || !room) return; + if (!user || !room) { + return; + } + + const gameSlug = gameSlugOf(room.gameName); - const handleTichuMessage = (message: TichuMessage) => { - if (message.type === TichuMessageType.START) { + const handleGameMessage = (message: GameMessage) => { + if (message.type === 'START') { setRoom(prev => ({ ...prev!, hasGameStarted: true, })); - } else if (message.type === TichuMessageType.SET_RULE) { + } else if (message.type === 'SET_RULE') { setRoom(prev => ({ ...prev!, - gameRule: message.data as TichuRule + gameRule: message.data as GameRule, })); } }; + const handleError = (error: Error) => { alert(`Error: ${error.message || 'Unknown error'}`); }; stomp.subscribe(`/topic/rooms/${roomId}/members`, handleMemberChange); stomp.subscribe(`/topic/rooms/${roomId}/chat`, handleReceiveChatMessage); - stomp.subscribe(`/user/${user.id}/queue/game/tichu`, handleTichuMessage); + stomp.subscribe(`/user/${user.id}/queue/game/${gameSlug}`, handleGameMessage); stomp.subscribe(`/user/${user.id}/queue/errors`, handleError); api.get('/auth/issue/web-socket-token') @@ -136,14 +151,17 @@ const RoomDetailPage = () => { return () => { stomp.unsubscribe(`/topic/rooms/${roomId}/members`, handleMemberChange); stomp.unsubscribe(`/topic/rooms/${roomId}/chat`, handleReceiveChatMessage); - stomp.unsubscribe(`/user/${user.id}/queue/game/tichu`, handleTichuMessage); + stomp.unsubscribe(`/user/${user.id}/queue/game/${gameSlug}`, handleGameMessage); stomp.unsubscribe(`/user/${user.id}/queue/errors`, handleError); stomp.disconnect(); }; }, [roomId, room == null, user, handleMemberChange, handleReceiveChatMessage]); const startGame = () => { - stomp.publish(`/app/rooms/${roomId}/game/tichu/start`, {}); + if (!room) { + return; + } + stomp.publish(`/app/rooms/${roomId}/game/${gameSlugOf(room.gameName)}/start`, {}); } const toggleReady = () => { @@ -152,7 +170,10 @@ const RoomDetailPage = () => { }; const setRule = (newRule: GameRule) => { - stomp.publish(`/app/rooms/${roomId}/game/tichu/set-rule`, newRule); + if (!room) { + return; + } + stomp.publish(`/app/rooms/${roomId}/game/${gameSlugOf(room.gameName)}/set-rule`, newRule); }; const changeWinningScore = (score: TichuWinningScore) => { @@ -174,22 +195,28 @@ const RoomDetailPage = () => { setRule(newRule); }; + const changeHanabiRule = (patch: Partial) => { + setRule({ ...(room!.gameRule as HanabiRule), ...patch }); + }; + if (loading || room === null) { return
Loading...
; } if (room.hasGameStarted) { - return { - setRoom(prev => ({ - ...prev!, - hasGameStarted: false, - })) - }} - /> + const onGameEnd = () => { + setRoom(prev => ({ + ...prev!, + hasGameStarted: false, + })); + }; + + switch (room.gameName) { + case GameName.TICHU: + return ; + case GameName.HANABI: + return ; + } } const currentMember = room.members.find(m => m.id === user?.id); @@ -214,6 +241,9 @@ const RoomDetailPage = () => { } } + const isHanabi = room.gameName === GameName.HANABI; + const hanabiRule = room.gameRule as HanabiRule; + return (
@@ -230,7 +260,7 @@ const RoomDetailPage = () => {
-

참가자 ({room.members?.length || 0} / 4)

+

참가자 ({room.members?.length || 0} / {room.gameRule.maxPlayers})

    {room.members?.map((member) => (
  • @@ -244,46 +274,92 @@ const RoomDetailPage = () => {

    게임 설정

    -
    -
    - 승리 점수 -
    - {[TichuWinningScore.ZERO, TichuWinningScore.TWO_HUNDRED, TichuWinningScore.FIVE_HUNDRED, TichuWinningScore.ONE_THOUSAND].map((score) => ( - - ))} + ) : ( +
    +
    + 승리 점수 +
    + {[TichuWinningScore.ZERO, TichuWinningScore.TWO_HUNDRED, TichuWinningScore.FIVE_HUNDRED, TichuWinningScore.ONE_THOUSAND].map((score) => ( + + ))} +
    +
    +
    + 팀 선택 +
    + {room.members.map((member) => { + const assignedTeam = (room.gameRule as TichuRule).teamAssignment[member.id] ?? Team.NONE; + return ( +
    + {member.name} +
    + {[Team.RED, Team.NONE, Team.BLUE].map((team) => ( + + ))} +
    -
    - ); - })} + ); + })} +
    -
    + )}
    diff --git a/frontend/src/games/hanabi/HanabiPage.module.css b/frontend/src/games/hanabi/HanabiPage.module.css new file mode 100644 index 0000000..dd95933 --- /dev/null +++ b/frontend/src/games/hanabi/HanabiPage.module.css @@ -0,0 +1,568 @@ +.container { + position: relative; + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 16px; + padding: 16px; + width: 100%; + max-width: var(--content-max-width); +} + +.main { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: 16px; +} + +.loading { + padding: 40px; + text-align: center; + color: #666; +} + +/* Status bar */ +.statusBar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + padding: 10px 14px; + background: #1f2933; + color: #fff; + border-radius: 10px; +} + +.token { + font-weight: 600; + font-size: 0.95rem; +} + +.lastRound { + background: #b91c1c; + color: #fff; + padding: 2px 8px; + border-radius: 6px; + font-size: 0.85rem; +} + +.turnInfo { + margin-left: auto; + font-weight: 700; +} + +/* Firework stacks */ +.fireworks { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: center; +} + +.stack { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 4.5rem; + height: 6rem; + border-radius: 0.5rem; + border: 2px solid rgba(0, 0, 0, 0.25); + position: relative; +} + +.stackLabel { + font-size: 0.7rem; + opacity: 0.85; +} + +.stackValue { + font-size: 2rem; + font-weight: 800; +} + +.complete { + box-shadow: 0 0 0 3px gold; +} + +/* Players */ +.players { + display: flex; + flex-wrap: wrap; + gap: 14px; +} + +.playerArea { + flex: 1 1 240px; + border: 1px solid #d0d5dd; + border-radius: 12px; + padding: 10px; + background: #fff; +} + +.activePlayer { + border-color: #2563eb; + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.25); +} + +.playerHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.playerName { + font-weight: 700; +} + +.hintTargetButton { + font-size: 0.8rem; + padding: 4px 8px; + border-radius: 6px; + border: 1px solid #2563eb; + background: #eff6ff; + cursor: pointer; +} + +.hand { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.myArea { + border: 2px solid #111827; + border-radius: 12px; + padding: 10px; + background: #fff; +} + +/* Cards */ + +/* A card plus the actions that belong to it. The actions live here rather than inside .card so + the card box stays the same size whether or not it is your turn. */ +.cardSlot { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; + width: 56px; +} + +.card { + position: relative; + width: 56px; + min-height: 78px; + border-radius: 8px; + border: 2px solid rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 4px; + cursor: default; +} + +.cardValue { + font-size: 1.6rem; + font-weight: 800; +} + +.cardBack { + background: repeating-linear-gradient(45deg, #475569, #475569 6px, #334155 6px, #334155 12px); + color: #fff; +} + +.cardClue { + font-size: 0.7rem; + text-align: center; + font-weight: 700; +} + +.clueBadge { + font-size: 0.6rem; + background: rgba(0, 0, 0, 0.55); + color: #fff; + border-radius: 4px; + padding: 1px 4px; +} + +/* Holds the badge's space (and the flex gap above it) so the card value sits in the same + place whether or not the card has a clue. */ +.badgePlaceholder { + visibility: hidden; +} + +/* Hover panel: what the card's owner can still deduce. Anchored above the card and lifted out of + the hand row, so it never reflows the cards around it. */ +.cardCandidates { + /* Hidden by visibility rather than display so it still has a box to measure: the clamp below + reads its rect while it is hidden, and a display:none element measures as all zeros. */ + visibility: hidden; + opacity: 0; + pointer-events: none; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + margin-bottom: 6px; + z-index: 10; + width: max-content; + max-width: 170px; + padding: 6px 8px; + border-radius: 8px; + background: #1f2933; + color: #fff; + text-align: left; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); +} + +.card:hover .cardCandidates { + visibility: visible; + opacity: 1; + pointer-events: auto; +} + +/* Flipped under the card when there is no room above it. */ +.cardCandidates.below { + bottom: auto; + top: 100%; + margin-bottom: 0; + margin-top: 6px; +} + +.candidateHeading { + font-size: 0.6rem; + font-weight: 700; + opacity: 0.7; + margin-bottom: 3px; +} + +.candidateRow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 3px; + margin-top: 3px; +} + +.candidateLabel { + font-size: 0.6rem; + opacity: 0.7; +} + +.candidateChip { + font-size: 0.6rem; + font-weight: 700; + border-radius: 3px; + padding: 0 3px; + border: 1px solid rgba(255, 255, 255, 0.25); +} + +.cardActions { + display: flex; + flex-direction: column; + gap: 3px; +} + +.cardActions button { + font-size: 0.7rem; + padding: 2px; + cursor: pointer; + border-radius: 4px; + border: none; + background: #e5e7eb; +} + +.cardActions button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Hint picker */ +.hintPicker { + margin-top: 8px; + padding-top: 8px; + border-top: 1px dashed #d0d5dd; +} + +.hintRow { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 6px; +} + +.hintChip { + padding: 4px 10px; + border-radius: 6px; + border: 1px solid rgba(0, 0, 0, 0.2); + cursor: pointer; + font-weight: 700; +} + +.hintChip.selected { + outline: 2px solid #2563eb; +} + +/* Bonus */ +.bonusBanner { + border: 2px solid #d97706; + background: #fffbeb; + border-radius: 12px; + padding: 12px; +} + +.bonusBody { + margin-top: 8px; +} + +/* Onlookers' copy: present, but visibly not something they can act on. */ +.bonusWaiting { + font-size: 0.9rem; + color: #92400e; +} + +/* Discard */ +.discardSection { + border: 1px solid #d0d5dd; + border-radius: 12px; + padding: 10px; + background: #fff; +} + +.discardPile { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 6px; +} + +.miniCard { + width: 28px; + height: 38px; + border-radius: 5px; + border: 1px solid rgba(0, 0, 0, 0.25); + display: flex; + align-items: center; + justify-content: center; + font-weight: 800; + font-size: 0.9rem; + cursor: default; +} + +/* End overlay */ +.endOverlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.endModal { + background: #fff; + border-radius: 16px; + padding: 32px; + text-align: center; +} + +.finalScore { + font-size: 1.3rem; + font-weight: 800; + margin: 12px 0; +} + +.leaveButton { + padding: 8px 20px; + border-radius: 8px; + border: none; + background: #2563eb; + color: #fff; + cursor: pointer; + font-weight: 700; +} + +/* Chat — a sidebar column of .container, sized by itself rather than by a margin on .main. */ +.chatSection { + flex: 0 0 300px; + align-self: flex-start; + position: sticky; + top: 16px; + display: flex; + flex-direction: column; + max-height: 50vh; + border: 1px solid #d0d5dd; + border-radius: 12px; + overflow: hidden; + background: #fff; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); +} + +/* The toggle only exists in the narrow floating layout. */ +.chatToggle { + display: none; +} + +.chatMessages { + flex: 1; + min-height: 120px; + overflow-y: auto; + padding: 8px; +} + +.chatMessage { + font-size: 0.9rem; + margin-bottom: 4px; +} + +.chatInputArea { + display: flex; + border-top: 1px solid #d0d5dd; +} + +.chatInputArea input { + flex: 1; + border: none; + outline: none; + font-size: 0.9rem; +} + +.chatInputArea input:hover { + outline: none; +} + +.chatInputArea button { + border: none; + background: #2563eb; + color: #fff; + cursor: pointer; + font-size: 0.9rem; +} + +/* Hover. + The global `button:hover` (index.css) is more specific than a bare class, so a hovered button + here would drop its own color for the global grey. Each rule below restates its background at + class+pseudo specificity to win, and uses brightness for the actual hover feedback. */ +.hintChip:hover, +button.miniCard:hover { + background: var(--hanabi-bg, #f8f8f8); + filter: brightness(0.92); +} + +.hintTargetButton:hover { + background: #eff6ff; + filter: brightness(0.95); +} + +.cardActions button:hover:not(:disabled) { + background: #e5e7eb; + filter: brightness(0.92); +} + +.chatToggle:hover, +.leaveButton:hover, +.chatInputArea button:hover { + background: #2563eb; + filter: brightness(0.92); +} + +/* On narrow screens there is no room for a column: the game takes the full width and chat + becomes a floating panel opened from a button, overlapping nothing until asked for. */ +@media (max-width: 768px) { + .container { + flex-direction: column; + } + + .main { + width: 100%; + } + + .chatToggle { + display: block; + position: fixed; + right: 16px; + bottom: 16px; + z-index: 60; + padding: 10px 16px; + border: none; + border-radius: 999px; + background: #2563eb; + color: #fff; + font-weight: 700; + cursor: pointer; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25); + } + + .chatSection { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: min(90vw, 360px); + max-height: 70vh; + z-index: 59; + flex: none; + } + + .chatSection:not(.open) { + display: none; + } + + .chatMessages { + flex: 1; + min-height: 0; + } +} + +/* Color themes. + Each theme publishes its background as --hanabi-bg so the hover rules above can restate it + without repeating the value. */ +.color_WHITE { + --hanabi-bg: #f8fafc; + background: var(--hanabi-bg); + color: #1f2933; +} + +.color_RED { + --hanabi-bg: #ef4444; + background: var(--hanabi-bg); + color: #fff; +} + +.color_BLUE { + --hanabi-bg: #3b82f6; + background: var(--hanabi-bg); + color: #fff; +} + +.color_YELLOW { + --hanabi-bg: #f5c518; + background: var(--hanabi-bg); + color: #1f2933; +} + +.color_GREEN { + --hanabi-bg: #22c55e; + background: var(--hanabi-bg); + color: #fff; +} + +.color_RAINBOW { + --hanabi-bg: linear-gradient(135deg, #ef4444, #f5c518, #22c55e, #3b82f6, #a855f7); + background: var(--hanabi-bg); + background-origin: border-box; + color: #fff; +} + +.color_BLACK { + --hanabi-bg: #111827; + background: var(--hanabi-bg); + color: #fff; +} diff --git a/frontend/src/games/hanabi/HanabiPage.tsx b/frontend/src/games/hanabi/HanabiPage.tsx new file mode 100644 index 0000000..fc9136e --- /dev/null +++ b/frontend/src/games/hanabi/HanabiPage.tsx @@ -0,0 +1,598 @@ +import { RefObject, useCallback, useEffect, useRef, useState } from 'react'; +import { useAuth } from "@/useAuth.tsx"; +import { useStomp } from "@/useStomp.tsx"; +import { ChatMessage } from "@/types.ts"; +import { + BonusEffect, + CardViewDto, + ClueType, + FireworkDto, + HanabiCardDto, + HanabiColor, + HanabiDto, + PlayerDto, +} from "@/games/hanabi/dtos/HanabiDto.ts"; +import { HanabiMessage, HanabiMessageType } from "@/games/hanabi/dtos/HanabiMessage.ts"; +import { applyDelta } from "@/games/hanabi/domain/applyDelta.ts"; +import { RainbowMode } from "@/games/hanabi/domain/HanabiRule.ts"; +import styles from './HanabiPage.module.css'; + +const COLOR_LABEL: Record = { + [HanabiColor.WHITE]: '하양', + [HanabiColor.RED]: '빨강', + [HanabiColor.BLUE]: '파랑', + [HanabiColor.YELLOW]: '노랑', + [HanabiColor.GREEN]: '초록', + [HanabiColor.RAINBOW]: '무지개', + [HanabiColor.BLACK]: '검정', +}; + +const BONUS_LABEL: Record = { + [BonusEffect.GAIN_CLUE]: '힌트 토큰 획득', + [BonusEffect.REPAIR_AND_CLUE]: '실수 복구 + 힌트 토큰', + [BonusEffect.FREE_COLOR_HINT]: '무료 색깔 힌트', + [BonusEffect.FREE_VALUE_HINT]: '무료 숫자 힌트', + [BonusEffect.RECOVER_TO_DECK]: '버린 카드를 덱으로', + [BonusEffect.RECOVER_TO_PLAY]: '버린 카드를 즉시 플레이', +}; + +const colorClass = (color: HanabiColor | null) => (color === null ? '' : styles[`color_${color}`] ?? ''); + +const COLOR_ORDER = Object.values(HanabiColor); +// A discard is always a real card, but the DTO allows a masked one; sort those to the end. +const colorRank = (color: HanabiColor | null) => (color === null ? COLOR_ORDER.length : COLOR_ORDER.indexOf(color)); + +// Mirrors HanabiColor.isDescending(): the black stack builds 5..1, so its discards read that way too. +const isDescendingColor = (color: HanabiColor | null) => color === HanabiColor.BLACK; + +const VALUES = [1, 2, 3, 4, 5]; + +// Mirrors HeldCard.matches on the backend: black is never touched by a color clue, and rainbow is +// touched by every color clue in wildcard mode but only by a rainbow clue otherwise. +const colorMatchesClue = (color: HanabiColor, clueColor: HanabiColor, rainbowMode: RainbowMode) => { + if (color === HanabiColor.BLACK) { + return false; + } + if (color === HanabiColor.RAINBOW) { + return rainbowMode === RainbowMode.WILDCARD || clueColor === HanabiColor.RAINBOW; + } + return color === clueColor; +}; + +// A color is still possible when it would have produced exactly the clues this card received: +// touched by every positive clue, untouched by every negative one. Negative clues carry real +// information, which is the whole point of showing this. +const possibleColors = (view: CardViewDto, activeColors: HanabiColor[], rainbowMode: RainbowMode) => + activeColors.filter(color => + view.positiveColors.every(clue => colorMatchesClue(color, clue, rainbowMode)) + && view.negativeColors.every(clue => !colorMatchesClue(color, clue, rainbowMode))); + +const possibleValues = (view: CardViewDto) => + VALUES.filter(value => + view.positiveValues.every(clue => clue === value) && !view.negativeValues.includes(value)); + +const compareDiscards = (a: HanabiCardDto, b: HanabiCardDto) => + colorRank(a.color) - colorRank(b.color) + || (isDescendingColor(a.color) + ? (b.value ?? 0) - (a.value ?? 0) + : (a.value ?? 0) - (b.value ?? 0)); + +const HanabiPage = ({ roomId, stomp, chatMessages, onGameEnd }: { + roomId: string, + stomp: useStomp, + chatMessages: ChatMessage[], + onGameEnd: Function, +}) => { + const { user } = useAuth(); + if (user === null) { + return null; + } + + const [dto, setDto] = useState(null); + const [hintTargetId, setHintTargetId] = useState(null); + const [chatInput, setChatInput] = useState(''); + // Only consulted by the narrow floating layout; the wide sidebar is always shown. + const [chatOpen, setChatOpen] = useState(false); + + const handleMessageRef = useRef<(message: HanabiMessage) => void>(() => {}); + handleMessageRef.current = (message: HanabiMessage) => { + if (message.type === HanabiMessageType.STATE || message.type === HanabiMessageType.END) { + // Full-state snapshots (initial load, game start, game end) replace local state outright. + setDto(message.data as HanabiDto); + } else { + // Per-action deltas are folded onto the last known state. + setDto(prev => (prev === null ? prev : applyDelta(prev, message))); + } + }; + + useEffect(() => { + const callback = (message: HanabiMessage) => handleMessageRef.current(message); + const destination = `/user/${user.id}/queue/game/hanabi`; + stomp.subscribe(destination, callback); + stomp.publish(`/app/rooms/${roomId}/game/hanabi/get`, {}); + return () => stomp.unsubscribe(destination, callback); + }, [roomId, user.id]); + + const giveHint = useCallback((targetId: number, clueType: ClueType, color: HanabiColor | null, value: number | null) => { + stomp.publish(`/app/rooms/${roomId}/game/hanabi/hint`, { targetId, clueType, color, value }); + }, [roomId]); + + const playCard = useCallback((index: number) => { + stomp.publish(`/app/rooms/${roomId}/game/hanabi/play`, { index }); + }, [roomId]); + + const discardCard = useCallback((index: number) => { + stomp.publish(`/app/rooms/${roomId}/game/hanabi/discard`, { index }); + }, [roomId]); + + const resolveBonus = useCallback((payload: object) => { + stomp.publish(`/app/rooms/${roomId}/game/hanabi/resolve-bonus`, payload); + }, [roomId]); + + const sendChatMessage = () => { + if (chatInput.trim() === '') { + return; + } + stomp.publish(`/app/rooms/${roomId}/chat`, { message: chatInput }); + setChatInput(''); + }; + + if (dto === null) { + return
    게임을 불러오는 중...
    ; + } + + // The backend sends currentTurnIndex and per-firework counts; the current player and score are derived. + const currentPlayerId = dto.players[dto.currentTurnIndex]?.playerId; + const score = dto.fireworks.reduce((sum, fw) => sum + fw.playCount, 0); + + const myTurn = currentPlayerId === user.id && dto.pendingBonus === null && !dto.ended; + const canHint = myTurn && dto.clueTokens > 0; + const canDiscard = myTurn && dto.clueTokens < dto.maxClueTokens; + const bonusIsMine = dto.pendingBonus !== null && dto.pendingBonus.playerId === user.id; + + const me = dto.players.find(p => p.playerId === user.id); + // Others are shown in turn order starting from the player after me, so the row reads in the + // order they will act. A spectator (not seated) sees the raw seat order. + const myIndex = dto.players.findIndex(p => p.playerId === user.id); + const others = myIndex < 0 + ? dto.players + : Array.from({ length: dto.players.length - 1 }, (_, i) => dto.players[(myIndex + 1 + i) % dto.players.length]); + const playerNameById = (id: number) => dto.players.find(p => p.playerId === id)?.name ?? '?'; + // Display copy only. BonusBanner keeps the raw pile, since a recovery is resolved server-side + // by the card's position in it. + const sortedDiscardPile = [...dto.discardPile].sort(compareDiscards); + // Every color in play, in stack order. A card can only be one of these. + const activeColors = dto.fireworks.map(fw => fw.color); + // Every active color is hintable except black (colorless). Rainbow can be named only in the + // sixth-color modes; in wildcard mode it counts as all colors and is never named directly. + const hintableColors = activeColors + .filter(color => color !== HanabiColor.BLACK) + .filter(color => color !== HanabiColor.RAINBOW || dto.rule.rainbowMode !== RainbowMode.WILDCARD); + + return ( +
    +
    +
    + 🔵 힌트 {dto.clueTokens}/{dto.maxClueTokens} + 🧨 실수 {dto.maxFuseTokens - dto.fuseTokens}/{dto.maxFuseTokens} + 🎴 덱 {dto.deckSize} + ⭐ 점수 {score} + {dto.finalTurnsRemaining !== null && !dto.ended && ( + 마지막 라운드 (남은 턴 {dto.finalTurnsRemaining}) + )} + + {dto.ended ? '게임 종료' : `${playerNameById(currentPlayerId)} 의 차례`} + +
    + +
    + {dto.fireworks.map((fw: FireworkDto) => ( + + ))} +
    + +
    + {others.map((player: PlayerDto) => ( +
    +
    + {player.name} + {canHint && ( + + )} +
    +
    + {player.cardViews.map((card, index) => ( + + ))} +
    + {canHint && hintTargetId === player.playerId && ( + { + giveHint(player.playerId, type, color, value); + setHintTargetId(null); + }} /> + )} +
    + ))} +
    + +
    +
    + 내 손패 {currentPlayerId === user.id && !dto.ended ? '(내 차례)' : ''} +
    +
    + {me?.cardViews.map((card, index) => ( + playCard(index)} + onDiscard={() => discardCard(index)} + /> + ))} +
    +
    + + {dto.pendingBonus !== null && ( + + )} + +
    + 버린 카드 ({dto.discardPile.length}) +
    + {sortedDiscardPile.map((card: HanabiCardDto, index) => ( +
    {card.value}
    + ))} +
    +
    + + {dto.ended && ( +
    +
    +

    {dto.exploded ? '💥 폭죽이 터졌습니다!' : '🎆 게임 종료'}

    +

    최종 점수: {score}

    + +
    +
    + )} +
    + + + +
    +
    + {chatMessages.map((msg, index) => ( +
    + {playerNameById(msg.userId)}: {msg.message} +
    + ))} +
    +
    + setChatInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && sendChatMessage()} + /> + +
    +
    +
    + ); +}; + +const Stack = ({ firework }: { firework: FireworkDto }) => ( +
    + {COLOR_LABEL[firework.color]} + {firework.topValue === 0 ? '-' : firework.topValue} +
    +); + +// The badge names a color only when the deduction leaves exactly one candidate, and marks it +// uncertain otherwise. In wildcard mode a lone 파랑 clue also touches 무지개, so it reads "파랑?"; +// a second color clue rules 파랑 out and leaves 무지개 alone, which then reads plainly. +const clueColorText = (card: CardViewDto, activeColors: HanabiColor[], rainbowMode: RainbowMode) => { + const candidates = possibleColors(card, activeColors, rainbowMode); + const [confirmed] = candidates; + if (confirmed !== undefined && candidates.length === 1) { + return COLOR_LABEL[confirmed]; + } + // Still ambiguous: name the clue the owner was actually given rather than listing candidates, + // which the hover panel already does with more room. + const [firstClue] = card.positiveColors; + if (firstClue === undefined) { + return ''; + } + return `${COLOR_LABEL[firstClue]}?`; +}; + +const clueText = (card: CardViewDto, activeColors: HanabiColor[], rainbowMode: RainbowMode) => { + const parts: string[] = []; + const color = clueColorText(card, activeColors, rainbowMode); + if (color !== '') { + parts.push(color); + } + if (card.positiveValues.length > 0) { + parts.push(card.positiveValues.join('/')); + } + return parts.join(' '); +}; + +const CANDIDATE_MARGIN = 8; + +/** + * Keeps the hover panel inside the window. CSS cannot know where the card sits, so the panel is + * measured on hover and nudged sideways / flipped under the card as needed. + */ +const useCandidateClamp = () => { + const ref = useRef(null); + const [shift, setShift] = useState(0); + const [below, setBelow] = useState(false); + + const clampIntoView = useCallback(() => { + const panel = ref.current; + if (panel === null) { + return; + } + const rect = panel.getBoundingClientRect(); + // clientWidth, not window.innerWidth: innerWidth counts the scrollbar as usable space, which + // leaves the panel's right edge parked underneath it. + const viewportWidth = document.documentElement.clientWidth; + + // The rect already includes the shift in effect, so corrections accumulate onto it. + if (rect.left < CANDIDATE_MARGIN) { + setShift(current => current + CANDIDATE_MARGIN - rect.left); + } else if (rect.right > viewportWidth - CANDIDATE_MARGIN) { + setShift(current => current + viewportWidth - CANDIDATE_MARGIN - rect.right); + } + + // Decided from the card's position and the panel's height, never from the panel's current + // placement — otherwise flipping would change the input and the two states would oscillate. + const card = panel.parentElement; + if (card !== null) { + setBelow(card.getBoundingClientRect().top - rect.height - CANDIDATE_MARGIN < CANDIDATE_MARGIN); + } + }, []); + + return { ref, shift, below, clampIntoView }; +}; + +/** Everything the card's owner can still deduce about it, revealed on hover. */ +const CardCandidates = ({ ref, shift, below, heading, card, activeColors, rainbowMode }: { + ref: RefObject, + shift: number, + below: boolean, + heading: string, + card: CardViewDto, + activeColors: HanabiColor[], + rainbowMode: RainbowMode, +}) => ( +
    +
    {heading}
    +
    + 가능한 색깔 + {possibleColors(card, activeColors, rainbowMode).map(color => ( + {COLOR_LABEL[color]} + ))} +
    +
    + 가능한 숫자 + {possibleValues(card).map(value => ( + {value} + ))} +
    +
    +); + +const OpponentCard = ({ card, activeColors, rainbowMode }: { + card: CardViewDto, + activeColors: HanabiColor[], + rainbowMode: RainbowMode, +}) => { + // The badge is always rendered, only hidden when there is no clue, so gaining a clue does not + // re-center the value and shift it upward. + const clue = clueText(card, activeColors, rainbowMode); + const { ref, shift, below, clampIntoView } = useCandidateClamp(); + return ( +
    + {card.card?.value ?? '?'} + + {clue || '\u00A0'} + + +
    + ); +}; + +const MyCard = ({ card, canAct, canDiscard, activeColors, rainbowMode, onPlay, onDiscard }: { + card: CardViewDto, + canAct: boolean, + canDiscard: boolean, + activeColors: HanabiColor[], + rainbowMode: RainbowMode, + onPlay: () => void, + onDiscard: () => void, +}) => { + const { ref, shift, below, clampIntoView } = useCandidateClamp(); + // The actions sit in the slot beneath the card rather than inside it, so the card keeps the same + // box on every turn instead of growing when it becomes actionable. The candidates panel stays + // inside the card: it is positioned against it, and the clamp measures from it. + return ( +
    +
    + {clueText(card, activeColors, rainbowMode) || '???'} + +
    + {canAct && ( +
    + + +
    + )} +
    + ); +}; + +const HintPicker = ({ colors, only, onHint }: { + colors: HanabiColor[], + only?: ClueType, + onHint: (type: ClueType, color: HanabiColor | null, value: number | null) => void, +}) => ( +
    + {only !== ClueType.VALUE && ( +
    + {colors.map(color => ( + + ))} +
    + )} + {only !== ClueType.COLOR && ( +
    + {[1, 2, 3, 4, 5].map(value => ( + + ))} +
    + )} +
    +); + +/** + * The pending bonus, shown to everyone: its owner gets the controls to resolve it, while the rest of + * the table sees only what was drawn and whose turn it is to act. Note this covers just the four + * effects that pend — GAIN_CLUE and REPAIR_AND_CLUE apply the moment they are drawn and never reach + * this state. + */ +const BonusBanner = ({ effect, isMine, ownerName, others, discardPile, colors, onResolve }: { + effect: BonusEffect, + isMine: boolean, + ownerName: string, + others: PlayerDto[], + discardPile: HanabiCardDto[], + colors: HanabiColor[], + onResolve: (payload: object) => void, +}) => { + const [targetId, setTargetId] = useState(null); + const isHint = effect === BonusEffect.FREE_COLOR_HINT || effect === BonusEffect.FREE_VALUE_HINT; + const isRecover = effect === BonusEffect.RECOVER_TO_DECK || effect === BonusEffect.RECOVER_TO_PLAY; + + return ( +
    + {isMine ? '보너스' : `${ownerName} 의 보너스`}: {BONUS_LABEL[effect]} + {!isMine && ( +
    + {ownerName} 이(가) 해결하기를 기다리는 중... +
    + )} + {isMine && isHint && ( +
    +
    + {others.map(p => ( + + ))} +
    + {targetId !== null && ( + onResolve({ + targetId, + clueType: type, + color, + value, + })} + /> + )} +
    + )} + {isMine && isRecover && ( +
    + 되살릴 버린 카드를 선택하세요 +
    + {discardPile.map((card, index) => ( + + ))} +
    +
    + )} +
    + ); +}; + +export default HanabiPage; diff --git a/frontend/src/games/hanabi/domain/HanabiRule.ts b/frontend/src/games/hanabi/domain/HanabiRule.ts new file mode 100644 index 0000000..d039012 --- /dev/null +++ b/frontend/src/games/hanabi/domain/HanabiRule.ts @@ -0,0 +1,14 @@ +import { GameRule } from "@/games/types.ts"; + +export enum RainbowMode { + WILDCARD = 'WILDCARD', + SIXTH_LONG = 'SIXTH_LONG', + SIXTH_SHORT = 'SIXTH_SHORT', +} + +export interface HanabiRule extends GameRule { + isRainbowEnabled: boolean; + rainbowMode: RainbowMode; + isBlackEnabled: boolean; + isBonusEnabled: boolean; +} diff --git a/frontend/src/games/hanabi/domain/applyDelta.ts b/frontend/src/games/hanabi/domain/applyDelta.ts new file mode 100644 index 0000000..284c5b9 --- /dev/null +++ b/frontend/src/games/hanabi/domain/applyDelta.ts @@ -0,0 +1,273 @@ +import { BonusEffect, CardViewDto, ClueType, FireworkDto, HanabiCardDto, HanabiColor, HanabiDto, PlayerDto } from "@/games/hanabi/dtos/HanabiDto.ts"; +import { + DiscardInfo, + FreeHintInfo, + HanabiMessage, + HanabiMessageType, + HintInfo, + PlayInfo, + RecoverToDeckInfo, + RecoverToPlayInfo, +} from "@/games/hanabi/dtos/HanabiMessage.ts"; + +const COMPLETE_COUNT = 5; + +/** + * Applies a per-action delta message to the current game state and returns the next state. + * + * The backend broadcasts only what an action changed (see the `*Info` payloads); the resulting scalar + * counters — clue/fuse tokens, deck size, current turn, final-turn countdown, pending bonus — are not + * transmitted. This reducer reconstructs them by mirroring the server's engine (`Hanabi.java`) exactly, + * so that folding the deltas over the last full `STATE` snapshot reproduces the authoritative state. + * + * `STATE`/`END` carry a full snapshot and are handled by the caller; any non-delta message is returned + * unchanged. + */ +export const applyDelta = (dto: HanabiDto, message: HanabiMessage): HanabiDto => { + const next = structuredClone(dto); + switch (message.type) { + case HanabiMessageType.HINT: { + const info = message.data as HintInfo; + next.clueTokens -= 1; + applyClueToHand(next, info.targetId, info.clueType, info.color, info.value, info.matchedIndices); + advanceTurn(next); + break; + } + case HanabiMessageType.DISCARD: { + const info = message.data as DiscardInfo; + removeHandCard(next, info.playerId, info.index); + next.discardPile.push(info.discardedCard); + if (next.clueTokens < next.maxClueTokens) { + next.clueTokens += 1; + } + drawTo(next, info.playerId, info.drawnCard); + advanceTurn(next); + break; + } + case HanabiMessageType.PLAY: { + const info = message.data as PlayInfo; + removeHandCard(next, info.playerId, info.index); + if (info.success) { + playOntoFirework(next, info.playedCard); + if (info.fireworkCompleted) { + applyFireworkCompleteBonus(next, info.playerId, info.bonusEffect); + } + } else { + next.discardPile.push(info.playedCard); + next.fuseTokens -= 1; + } + drawTo(next, info.playerId, info.drawnCard); + if (next.fuseTokens <= 0) { + next.exploded = true; + next.ended = true; + } else { + concludeTurn(next); + } + break; + } + case HanabiMessageType.RESOLVE_FREE_HINT: { + const info = message.data as FreeHintInfo; + applyClueToHand(next, info.targetId, info.clueType, info.color, info.value, info.matchedIndices); + next.pendingBonus = null; + drawTo(next, info.fromId, info.drawnCard); + advanceTurn(next); + break; + } + case HanabiMessageType.RESOLVE_RECOVER_TO_DECK: { + const info = message.data as RecoverToDeckInfo; + removeFromDiscard(next, info.recoveredCard); + next.deckSize += 1; + next.pendingBonus = null; + drawTo(next, info.playerId, info.drawnCard); + advanceTurn(next); + break; + } + case HanabiMessageType.RESOLVE_RECOVER_TO_PLAY: { + const info = message.data as RecoverToPlayInfo; + next.discardPile.splice(info.index, 1); + playOntoFirework(next, info.playedCard); + next.pendingBonus = null; + if (info.fireworkCompleted) { + applyFireworkCompleteBonus(next, info.playerId, info.bonusEffect); + } + drawTo(next, info.playerId, info.drawnCard); + concludeTurn(next); + break; + } + default: + // STATE / END / START / SET_RULE are not deltas. + return dto; + } + return next; +}; + +// ----- Turn flow ----- + +const advanceTurn = (dto: HanabiDto): void => { + if (dto.ended) { + return; + } + if (dto.finalTurnsRemaining !== null) { + dto.finalTurnsRemaining -= 1; + if (dto.finalTurnsRemaining <= 0) { + dto.ended = true; + return; + } + } else if (dto.deckSize === 0) { + dto.finalTurnsRemaining = dto.players.length; + } + dto.currentTurnIndex = (dto.currentTurnIndex + 1) % dto.players.length; +}; + +const concludeTurn = (dto: HanabiDto): void => { + if (dto.fireworks.every(fw => fw.isComplete)) { + dto.ended = true; + return; + } + if (dto.pendingBonus !== null) { + return; + } + advanceTurn(dto); +}; + +// ----- Fireworks & bonuses ----- + +const nextValue = (fw: FireworkDto): number => { + if (fw.isComplete) { + return -1; + } + return fw.descending ? COMPLETE_COUNT - fw.playCount : fw.playCount + 1; +}; + +const canPlay = (fw: FireworkDto, card: HanabiCardDto): boolean => + card.color === fw.color && card.value === nextValue(fw); + +const playOntoFirework = (dto: HanabiDto, card: HanabiCardDto): void => { + const fw = dto.fireworks.find(f => f.color === card.color); + if (fw === undefined) { + return; + } + fw.playCount += 1; + fw.isComplete = fw.playCount === COMPLETE_COUNT; + fw.topValue = fw.descending ? COMPLETE_COUNT + 1 - fw.playCount : fw.playCount; +}; + +const gainClue = (dto: HanabiDto): void => { + if (dto.clueTokens < dto.maxClueTokens) { + dto.clueTokens += 1; + } +}; + +const noDiscardIsPlayable = (dto: HanabiDto): boolean => + !dto.discardPile.some(card => { + const fw = dto.fireworks.find(f => f.color === card.color); + return fw !== undefined && canPlay(fw, card); + }); + +/** Mirrors `Hanabi.onFireworkComplete` + `applyBonus`: apply immediate effects, or pend one for the player. */ +const applyFireworkCompleteBonus = (dto: HanabiDto, playerId: number, effect: BonusEffect | null): void => { + if (effect === null) { + gainClue(dto); + return; + } + switch (effect) { + case BonusEffect.GAIN_CLUE: + gainClue(dto); + break; + case BonusEffect.REPAIR_AND_CLUE: + if (dto.fuseTokens < dto.maxFuseTokens) { + dto.fuseTokens += 1; + } + gainClue(dto); + break; + case BonusEffect.FREE_COLOR_HINT: + case BonusEffect.FREE_VALUE_HINT: + dto.pendingBonus = { effect, playerId }; + break; + case BonusEffect.RECOVER_TO_DECK: + if (dto.discardPile.length === 0) { + break; + } + if (dto.deckSize === 0 && noDiscardIsPlayable(dto)) { + break; + } + dto.pendingBonus = { effect, playerId }; + break; + case BonusEffect.RECOVER_TO_PLAY: + if (dto.discardPile.length === 0 || noDiscardIsPlayable(dto)) { + break; + } + dto.pendingBonus = { effect, playerId }; + break; + } +}; + +// ----- Hands & cards ----- + +const findPlayer = (dto: HanabiDto, playerId: number): PlayerDto | undefined => + dto.players.find(p => p.playerId === playerId); + +const removeHandCard = (dto: HanabiDto, playerId: number, index: number): void => { + findPlayer(dto, playerId)?.cardViews.splice(index, 1); +}; + +/** A present `drawnCard` (even a masked `{null,null}`) means a card was drawn onto the player's hand. */ +const drawTo = (dto: HanabiDto, playerId: number, drawnCard: HanabiCardDto | null): void => { + if (drawnCard === null) { + return; + } + const player = findPlayer(dto, playerId); + if (player === undefined) { + return; + } + player.cardViews.push({ + card: drawnCard, + positiveColors: [], + negativeColors: [], + positiveValues: [], + negativeValues: [], + }); + dto.deckSize -= 1; +}; + +const removeFromDiscard = (dto: HanabiDto, card: HanabiCardDto): void => { + const index = dto.discardPile.findIndex(c => c.color === card.color && c.value === card.value); + if (index >= 0) { + dto.discardPile.splice(index, 1); + } +}; + +const pushUnique = (list: T[], item: T): void => { + if (!list.includes(item)) { + list.push(item); + } +}; + +/** Records the clue knowledge on the target's cards, using the server-computed matched indices. */ +const applyClueToHand = ( + dto: HanabiDto, + targetId: number, + clueType: ClueType, + color: HanabiColor | null, + value: number | null, + matchedIndices: number[], +): void => { + const target = findPlayer(dto, targetId); + if (target === undefined) { + return; + } + const matched = new Set(matchedIndices); + target.cardViews.forEach((cv: CardViewDto, i: number) => { + if (clueType === ClueType.COLOR) { + if (color === null) { + return; + } + pushUnique(matched.has(i) ? cv.positiveColors : cv.negativeColors, color); + } else { + if (value === null) { + return; + } + pushUnique(matched.has(i) ? cv.positiveValues : cv.negativeValues, value); + } + }); +}; diff --git a/frontend/src/games/hanabi/dtos/HanabiDto.ts b/frontend/src/games/hanabi/dtos/HanabiDto.ts new file mode 100644 index 0000000..8f94500 --- /dev/null +++ b/frontend/src/games/hanabi/dtos/HanabiDto.ts @@ -0,0 +1,77 @@ +import { HanabiRule } from "@/games/hanabi/domain/HanabiRule.ts"; + +export enum HanabiColor { + WHITE = 'WHITE', + RED = 'RED', + BLUE = 'BLUE', + YELLOW = 'YELLOW', + GREEN = 'GREEN', + RAINBOW = 'RAINBOW', + BLACK = 'BLACK', +} + +export enum ClueType { + COLOR = 'COLOR', + VALUE = 'VALUE', +} + +export enum BonusEffect { + GAIN_CLUE = 'GAIN_CLUE', + REPAIR_AND_CLUE = 'REPAIR_AND_CLUE', + FREE_COLOR_HINT = 'FREE_COLOR_HINT', + FREE_VALUE_HINT = 'FREE_VALUE_HINT', + RECOVER_TO_DECK = 'RECOVER_TO_DECK', + RECOVER_TO_PLAY = 'RECOVER_TO_PLAY', +} + +// A card is masked (color/value null) when the viewer is not allowed to see it — their own hand +// cards and their own freshly drawn card. Real cards (opponents' hands, discards) are fully populated. +export interface HanabiCardDto { + color: HanabiColor | null; + value: number | null; +} + +export interface CardViewDto { + card: HanabiCardDto | null; + positiveColors: HanabiColor[]; + negativeColors: HanabiColor[]; + positiveValues: number[]; + negativeValues: number[]; +} + +export interface PlayerDto { + playerId: number; + name: string; + cardViews: CardViewDto[]; +} + +export interface FireworkDto { + color: HanabiColor; + playCount: number; + isComplete: boolean; + topValue: number; + descending: boolean; +} + +export interface PendingBonusDto { + effect: BonusEffect; + playerId: number; +} + +export interface HanabiDto { + rule: HanabiRule; + myId: number; + players: PlayerDto[]; + deckSize: number; + fireworks: FireworkDto[]; + discardPile: HanabiCardDto[]; + clueTokens: number; + maxClueTokens: number; + fuseTokens: number; + maxFuseTokens: number; + currentTurnIndex: number; + finalTurnsRemaining: number | null; + exploded: boolean; + ended: boolean; + pendingBonus: PendingBonusDto | null; +} diff --git a/frontend/src/games/hanabi/dtos/HanabiMessage.ts b/frontend/src/games/hanabi/dtos/HanabiMessage.ts new file mode 100644 index 0000000..d7307fc --- /dev/null +++ b/frontend/src/games/hanabi/dtos/HanabiMessage.ts @@ -0,0 +1,75 @@ +import { BonusEffect, ClueType, HanabiCardDto, HanabiColor } from "@/games/hanabi/dtos/HanabiDto.ts"; + +export enum HanabiMessageType { + SET_RULE = 'SET_RULE', + START = 'START', + STATE = 'STATE', + HINT = 'HINT', + DISCARD = 'DISCARD', + PLAY = 'PLAY', + RESOLVE_FREE_HINT = 'RESOLVE_FREE_HINT', + RESOLVE_RECOVER_TO_DECK = 'RESOLVE_RECOVER_TO_DECK', + RESOLVE_RECOVER_TO_PLAY = 'RESOLVE_RECOVER_TO_PLAY', + END = 'END', +} + +export interface HanabiMessage { + type: HanabiMessageType; + data: unknown; +} + +// ----- Per-action delta payloads (carried by the messages above) ----- +// These mirror the backend `*Info` records. They describe only what the action changed; the resulting +// scalar counters (tokens, deck size, turn) are derived client-side by `applyDelta`. A `drawnCard` that +// is present but masked (`{color: null, value: null}`) still means a card was drawn. + +export interface HintInfo { + fromId: number; + targetId: number; + clueType: ClueType; + color: HanabiColor | null; + value: number | null; + matchedIndices: number[]; +} + +export interface DiscardInfo { + playerId: number; + index: number; + discardedCard: HanabiCardDto; + drawnCard: HanabiCardDto | null; +} + +export interface PlayInfo { + playerId: number; + index: number; + playedCard: HanabiCardDto; + success: boolean; + fireworkCompleted: boolean; + bonusEffect: BonusEffect | null; + drawnCard: HanabiCardDto | null; +} + +export interface FreeHintInfo { + fromId: number; + targetId: number; + clueType: ClueType; + color: HanabiColor | null; + value: number | null; + matchedIndices: number[]; + drawnCard: HanabiCardDto | null; +} + +export interface RecoverToDeckInfo { + playerId: number; + recoveredCard: HanabiCardDto; + drawnCard: HanabiCardDto | null; +} + +export interface RecoverToPlayInfo { + playerId: number; + index: number; + playedCard: HanabiCardDto; + fireworkCompleted: boolean; + bonusEffect: BonusEffect | null; + drawnCard: HanabiCardDto | null; +} diff --git a/frontend/src/games/types.ts b/frontend/src/games/types.ts index 2f4a509..381e353 100644 --- a/frontend/src/games/types.ts +++ b/frontend/src/games/types.ts @@ -6,4 +6,10 @@ export interface GameRule { export enum GameName { TICHU = 'TICHU', + HANABI = 'HANABI', +} + +export interface GameMessage { + type: string; + data: unknown; }