Files
tabletop-web/src/views/onecard/OneCardPlayerView.vue
T
2026-06-22 14:56:46 +09:00

328 lines
7.6 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useRoomStore } from '@/stores/roomStore'
import { usePeerRoom } from '@/composables/usePeerRoom'
import { loadSession } from '@/utils/session'
import type { CardColor } from '@/games/onecard/types'
import HandView from '@/components/HandView.vue'
import WildColorPicker from '@/components/WildColorPicker.vue'
import RoomCodeDisplay from '@/components/RoomCodeDisplay.vue'
import ExitButton from '@/components/ExitButton.vue'
const router = useRouter()
const store = useRoomStore()
const { joinRoom, sendPlayCard, sendDeclareOneCard, sendHandPreview } = usePeerRoom()
const isReady = ref(false)
const handRef = ref<InstanceType<typeof HandView> | null>(null)
const pendingWildCardId = ref<string | null>(null)
const showColorPicker = ref(false)
const isMyTurn = computed(() => store.currentTurnPlayerId === store.playerId)
const publicState = computed(() => store.publicState)
const drawPenalty = computed(() => publicState.value?.drawPenalty ?? 0)
const isPlaying = computed(() => publicState.value?.phase === 'playing')
const isRoundWon = computed(() => publicState.value?.phase === 'round_won')
const isFinished = computed(() => publicState.value?.phase === 'finished')
const turnLabel = computed(() => {
if (isRoundWon.value) {
if (publicState.value?.winnerId === store.playerId) return '패 완료!'
return `${publicState.value?.winnerName ?? '누군가'} 패 완료`
}
if (isFinished.value) {
if (publicState.value?.winnerId === store.playerId) return '승리!'
return `${publicState.value?.winnerName ?? '누군가'} 승리`
}
if (!store.gameStarted) return '대기 중'
if (isMyTurn.value) return '내 턴'
return '상대 턴'
})
const isEliminated = computed(() => {
if (!publicState.value || !store.gameStarted) return false
return !publicState.value.turnOrder.includes(store.playerId)
})
onMounted(async () => {
const session = loadSession()
if (!session || session.role !== 'player') {
router.replace('/onecard/lobby')
return
}
store.roomCode = session.roomCode
store.role = session.role
store.playerId = session.playerId
store.playerName = session.playerName || '플레이어'
if (!store.isConnected) {
try {
await joinRoom(session.roomCode, session.playerId, store.playerName)
} catch {
router.replace('/onecard/lobby')
return
}
}
isReady.value = true
})
onUnmounted(() => {
sendHandPreview(null)
})
function handleSelectionChange(cardId: string | null) {
sendHandPreview(cardId)
}
function handlePlay(cardId: string) {
sendPlayCard(cardId)
handRef.value?.clearSelection()
}
function handleRequestColor(cardId: string) {
pendingWildCardId.value = cardId
showColorPicker.value = true
}
function handleColorSelect(color: CardColor) {
if (pendingWildCardId.value) {
sendPlayCard(pendingWildCardId.value, color)
handRef.value?.clearSelection()
}
pendingWildCardId.value = null
showColorPicker.value = false
}
function handleColorCancel() {
pendingWildCardId.value = null
showColorPicker.value = false
handRef.value?.clearSelection()
}
</script>
<template>
<main v-if="isReady" class="player">
<RoomCodeDisplay :code="store.roomCode" compact />
<div class="top-actions">
<ExitButton />
</div>
<div class="turn-bar">
<div class="turn-indicator" :class="{ active: isMyTurn && isPlaying }">
{{ turnLabel }}
</div>
</div>
<section v-if="isRoundWon" class="round-won">
<p v-if="publicState?.winnerId === store.playerId">
🎉 패를 모두 냈습니다!
</p>
<p v-else>
🎉 {{ publicState?.winnerName }}님이 패를 모두 냈습니다
</p>
<p class="round-won-hint">보드판에서 이어하기 / 다시 하기를 선택해주세요</p>
</section>
<section v-else-if="store.gameStarted && isPlaying && !isEliminated" class="hand-area">
<button
v-if="store.myHand.length === 1"
type="button"
class="onecard-btn"
@click="sendDeclareOneCard"
>
원카드!
</button>
<HandView
ref="handRef"
:cards="store.myHand"
:top-card="publicState?.topCard ?? null"
:active-color="publicState?.activeColor"
:draw-penalty="drawPenalty"
:can-interact="isMyTurn"
@play="handlePlay"
@request-color="handleRequestColor"
@selection-change="handleSelectionChange"
/>
</section>
<section v-else-if="store.gameStarted && isPlaying && isEliminated" class="eliminated">
<p>패를 모두 냈습니다</p>
<p class="eliminated-hint">다른 플레이어 게임을 지켜보세요</p>
</section>
<section v-else-if="!store.gameStarted" class="waiting">
<p>게임 시작 대기 중...</p>
</section>
<section v-else class="winner">
<p>{{ publicState?.winnerId === store.playerId ? '🎉 승리!' : `🏆 ${publicState?.winnerName} 승리` }}</p>
<p class="winner-hint">보드판에서 다시 하기를 눌러주세요</p>
</section>
<p v-if="store.statusMessage" class="toast">{{ store.statusMessage }}</p>
<WildColorPicker
:visible="showColorPicker"
@select="handleColorSelect"
@cancel="handleColorCancel"
/>
</main>
</template>
<style scoped>
.player {
width: 100vw;
height: 100vh;
height: 100dvh;
position: relative;
background: #1a1a2e;
overflow: hidden;
}
.top-actions {
position: fixed;
top: 12px;
right: 12px;
z-index: 300;
}
.turn-bar {
position: fixed;
top: 10px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
}
.turn-indicator {
padding: 5px 16px;
border-radius: 14px;
background: rgba(42, 42, 74, 0.9);
font-weight: 600;
font-size: 0.85rem;
color: #666;
}
.turn-indicator.active {
background: #f1c40f;
color: #1a1a2e;
}
.hand-area {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
max-height: 50vh;
max-height: 50dvh;
padding-bottom: env(safe-area-inset-bottom, 0);
z-index: 5;
}
.winner {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: #888;
}
.winner p {
margin: 0;
}
.winner > p:first-child {
font-size: 1.4rem;
font-weight: 700;
color: #f1c40f;
}
.winner-hint {
font-size: 0.9rem;
color: #666;
}
.round-won,
.eliminated {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px;
text-align: center;
}
.round-won p,
.eliminated > p:first-child {
margin: 0;
font-size: 1.3rem;
font-weight: 700;
color: #f1c40f;
}
.round-won-hint,
.eliminated-hint {
margin: 0;
font-size: 0.9rem;
color: #666;
font-weight: 400;
}
.waiting {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #888;
}
.onecard-btn {
position: fixed;
bottom: calc(min(50vh, 50dvh) + 8px);
right: 12px;
padding: 8px 16px;
border-radius: 16px;
border: none;
background: #e74c3c;
color: #fff;
font-weight: 700;
font-size: 0.85rem;
cursor: pointer;
z-index: 20;
animation: blink 1s ease infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.75; }
}
.toast {
position: fixed;
bottom: calc(min(50vh, 50dvh) + min(24vh, 24dvh) + 12px);
left: 50%;
transform: translateX(-50%);
background: #2a2a4a;
border: 2px solid #e74c3c;
padding: 10px 18px;
border-radius: 10px;
font-size: 0.85rem;
z-index: 300;
white-space: nowrap;
}
</style>