1 parent
15e8869acd
commit
3d23c1347f
10 files changed
+469
-20
No files matched your search
@@ -30,7 +30,7 @@ const large = computed(() => props.corner === 'top' || props.corner === 'bottom'
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="corner-zone" :class="[`corner-${corner}`, { 'is-turn': isTurn }]">
|
<div class="corner-zone" :class="[`corner-${corner}`, { 'is-turn': isTurn }]" :data-board-corner="corner">
|
||||||
<!--
|
<!--
|
||||||
배치 기준: 각 모서리에 앉아 테이블 중앙을 바라볼 때
|
배치 기준: 각 모서리에 앉아 테이블 중앙을 바라볼 때
|
||||||
- 패 좌측 = 이름
|
- 패 좌측 = 이름
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, nextTick } from 'vue'
|
||||||
|
import type { DrawAnimationPayload } from '@/stores/roomStore'
|
||||||
|
import { PILE_CARD } from '@/components/cardTypes'
|
||||||
|
import CardView from './CardView.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
payload: DrawAnimationPayload | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
complete: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const active = ref<DrawAnimationPayload | null>(null)
|
||||||
|
const flightEls = ref<(HTMLElement | null)[]>([])
|
||||||
|
|
||||||
|
const pile = PILE_CARD.lg
|
||||||
|
const deckBack = { id: 'deck-fly', color: 'wild' as const, value: '0' as const }
|
||||||
|
|
||||||
|
function setFlightRef(i: number, el: unknown) {
|
||||||
|
flightEls.value[i] = el as HTMLElement | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function centerOf(el: Element) {
|
||||||
|
const r = el.getBoundingClientRect()
|
||||||
|
return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function animateCard(
|
||||||
|
el: HTMLElement,
|
||||||
|
from: { x: number; y: number },
|
||||||
|
to: { x: number; y: number },
|
||||||
|
corner: DrawAnimationPayload['corner'],
|
||||||
|
index: number,
|
||||||
|
) {
|
||||||
|
const dx = to.x - from.x
|
||||||
|
const dy = to.y - from.y
|
||||||
|
const arcLift = Math.min(64, Math.max(28, Math.hypot(dx, dy) * 0.12))
|
||||||
|
const startRotate = corner === 'top' ? 6 : corner === 'bottom' ? -4 : 0
|
||||||
|
const spread = (index - (active.value!.count - 1) / 2) * 8
|
||||||
|
|
||||||
|
el.style.left = `${from.x}px`
|
||||||
|
el.style.top = `${from.y}px`
|
||||||
|
|
||||||
|
const anim = el.animate([
|
||||||
|
{
|
||||||
|
transform: `translate(-50%, -50%) translate(${spread}px, 0) scale(0.45) rotate(${startRotate}deg)`,
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
transform: `translate(-50%, -50%) translate(${dx * 0.5 + spread}px, ${dy * 0.5 - arcLift}px) scale(0.75) rotate(${startRotate * 0.2}deg)`,
|
||||||
|
opacity: 1,
|
||||||
|
offset: 0.45,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
transform: `translate(-50%, -50%) translate(${dx + spread}px, ${dy}px) scale(0.92) rotate(0deg)`,
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
], {
|
||||||
|
duration: 460,
|
||||||
|
easing: 'cubic-bezier(0.22, 0.85, 0.32, 1)',
|
||||||
|
fill: 'forwards',
|
||||||
|
delay: index * 110,
|
||||||
|
})
|
||||||
|
|
||||||
|
await anim.finished.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAnimation(payload: DrawAnimationPayload) {
|
||||||
|
active.value = payload
|
||||||
|
flightEls.value = []
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
await nextTick()
|
||||||
|
await new Promise<void>((r) => {
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => r()))
|
||||||
|
})
|
||||||
|
|
||||||
|
const fromEl = document.querySelector('[data-board-deck]')
|
||||||
|
const toEl = document.querySelector(`[data-board-corner="${payload.corner}"] .corner-inner`)
|
||||||
|
?? document.querySelector(`[data-board-corner="${payload.corner}"]`)
|
||||||
|
|
||||||
|
if (!fromEl || !toEl) {
|
||||||
|
active.value = null
|
||||||
|
emit('complete')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = centerOf(fromEl)
|
||||||
|
const to = centerOf(toEl)
|
||||||
|
|
||||||
|
const tasks: Promise<void>[] = []
|
||||||
|
for (let i = 0; i < payload.count; i++) {
|
||||||
|
const el = flightEls.value[i]
|
||||||
|
if (el) tasks.push(animateCard(el, from, to, payload.corner, i))
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(tasks)
|
||||||
|
|
||||||
|
active.value = null
|
||||||
|
emit('complete')
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.payload?.id,
|
||||||
|
(id) => {
|
||||||
|
if (id != null && props.payload) {
|
||||||
|
runAnimation(props.payload)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-for="i in active?.count ?? 0"
|
||||||
|
:key="`${active?.id}-${i}`"
|
||||||
|
:ref="(el) => setFlightRef(i - 1, el)"
|
||||||
|
class="draw-flight"
|
||||||
|
>
|
||||||
|
<CardView
|
||||||
|
:card="deckBack"
|
||||||
|
face-down
|
||||||
|
:dimensions="{ width: pile.width, height: pile.height }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.draw-flight {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 500;
|
||||||
|
pointer-events: none;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-flight :deep(.card) {
|
||||||
|
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, nextTick } from 'vue'
|
||||||
|
import type { PlayAnimationPayload } from '@/stores/roomStore'
|
||||||
|
import { PILE_CARD } from '@/components/cardTypes'
|
||||||
|
import CardView from './CardView.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
payload: PlayAnimationPayload | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
complete: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const flightRef = ref<HTMLElement | null>(null)
|
||||||
|
const activePayload = ref<PlayAnimationPayload | null>(null)
|
||||||
|
|
||||||
|
const pile = PILE_CARD.lg
|
||||||
|
|
||||||
|
function centerOf(el: Element) {
|
||||||
|
const r = el.getBoundingClientRect()
|
||||||
|
return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAnimation(payload: PlayAnimationPayload) {
|
||||||
|
activePayload.value = payload
|
||||||
|
visible.value = true
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
await new Promise<void>((r) => {
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => r()))
|
||||||
|
})
|
||||||
|
|
||||||
|
const fromEl = document.querySelector(`[data-board-corner="${payload.corner}"] .corner-inner`)
|
||||||
|
?? document.querySelector(`[data-board-corner="${payload.corner}"]`)
|
||||||
|
const toEl = document.querySelector('[data-discard-pile]')
|
||||||
|
const el = flightRef.value
|
||||||
|
|
||||||
|
if (!fromEl || !toEl || !el) {
|
||||||
|
visible.value = false
|
||||||
|
activePayload.value = null
|
||||||
|
emit('complete')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = centerOf(fromEl)
|
||||||
|
const to = centerOf(toEl)
|
||||||
|
const dx = to.x - from.x
|
||||||
|
const dy = to.y - from.y
|
||||||
|
const arcLift = Math.min(72, Math.max(32, Math.hypot(dx, dy) * 0.15))
|
||||||
|
|
||||||
|
el.style.left = `${from.x}px`
|
||||||
|
el.style.top = `${from.y}px`
|
||||||
|
|
||||||
|
const startRotate = payload.corner === 'top' ? 8 : payload.corner === 'bottom' ? -6 : 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
const anim = el.animate([
|
||||||
|
{
|
||||||
|
transform: `translate(-50%, -50%) translate(0px, 0px) scale(0.5) rotate(${startRotate}deg)`,
|
||||||
|
opacity: 0.85,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
transform: `translate(-50%, -50%) translate(${dx * 0.5}px, ${dy * 0.5 - arcLift}px) scale(0.82) rotate(${startRotate * 0.3}deg)`,
|
||||||
|
opacity: 1,
|
||||||
|
offset: 0.42,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
transform: `translate(-50%, -50%) translate(${dx}px, ${dy}px) scale(1) rotate(0deg)`,
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
], {
|
||||||
|
duration: 520,
|
||||||
|
easing: 'cubic-bezier(0.22, 0.85, 0.32, 1)',
|
||||||
|
fill: 'forwards',
|
||||||
|
})
|
||||||
|
|
||||||
|
await anim.finished
|
||||||
|
} catch {
|
||||||
|
// interrupted
|
||||||
|
}
|
||||||
|
|
||||||
|
visible.value = false
|
||||||
|
activePayload.value = null
|
||||||
|
emit('complete')
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.payload?.id,
|
||||||
|
(id) => {
|
||||||
|
if (id != null && props.payload) {
|
||||||
|
runAnimation(props.payload)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="visible && activePayload"
|
||||||
|
ref="flightRef"
|
||||||
|
class="play-flight"
|
||||||
|
>
|
||||||
|
<CardView
|
||||||
|
:card="activePayload.card"
|
||||||
|
:active-color="activePayload.activeColor"
|
||||||
|
:dimensions="{ width: pile.width, height: pile.height }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.play-flight {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 500;
|
||||||
|
pointer-events: none;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-flight :deep(.card) {
|
||||||
|
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -72,6 +72,7 @@ function layerStyle(layerIndex: number) {
|
|||||||
<div class="deck" :class="{ large, empty: visualStack.layers === 0 }">
|
<div class="deck" :class="{ large, empty: visualStack.layers === 0 }">
|
||||||
<div
|
<div
|
||||||
class="deck-stack"
|
class="deck-stack"
|
||||||
|
data-board-deck
|
||||||
:style="{ width: `${stackBox.width}px`, height: `${stackBox.height}px` }"
|
:style="{ width: `${stackBox.width}px`, height: `${stackBox.height}px` }"
|
||||||
>
|
>
|
||||||
<template v-if="visualStack.layers > 0">
|
<template v-if="visualStack.layers > 0">
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const pile = computed(() => (props.large ? PILE_CARD.lg : PILE_CARD.md))
|
|||||||
<span v-if="showLabel" class="label">버린 더미</span>
|
<span v-if="showLabel" class="label">버린 더미</span>
|
||||||
<div
|
<div
|
||||||
class="discard-slot"
|
class="discard-slot"
|
||||||
|
data-discard-pile
|
||||||
:class="{ large }"
|
:class="{ large }"
|
||||||
:style="{ width: `${pile.stackWidth}px`, height: `${pile.stackHeight}px` }"
|
:style="{ width: `${pile.stackWidth}px`, height: `${pile.stackHeight}px` }"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ const props = defineProps<{
|
|||||||
topCard?: Card | null
|
topCard?: Card | null
|
||||||
activeColor?: CardColor | null
|
activeColor?: CardColor | null
|
||||||
drawPenalty?: number
|
drawPenalty?: number
|
||||||
canInteract?: boolean
|
/** 내 턴일 때만 카드 내기 가능 */
|
||||||
|
canPlay?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const canPlay = computed(() => props.canPlay ?? false)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
play: [cardId: string]
|
play: [cardId: string]
|
||||||
'request-color': [cardId: string]
|
'request-color': [cardId: string]
|
||||||
@@ -41,7 +44,7 @@ const cardDimensions = computed(() => ({
|
|||||||
const fanHeightPx = computed(() => Math.round(cardHeightPx.value * 1.15 + 48))
|
const fanHeightPx = computed(() => Math.round(cardHeightPx.value * 1.15 + 48))
|
||||||
|
|
||||||
const playableIds = computed(() => {
|
const playableIds = computed(() => {
|
||||||
if (!props.topCard || !props.canInteract) return new Set<string>()
|
if (!props.topCard || !canPlay.value) return new Set<string>()
|
||||||
return new Set(
|
return new Set(
|
||||||
getPlayableCards(
|
getPlayableCards(
|
||||||
props.cards,
|
props.cards,
|
||||||
@@ -125,14 +128,20 @@ function getFanStyle(index: number, total: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handlePointerDown(e: PointerEvent, card: Card) {
|
function handlePointerDown(e: PointerEvent, card: Card) {
|
||||||
if (!props.canInteract) return
|
|
||||||
|
|
||||||
if (selectedId.value !== card.id) {
|
if (selectedId.value !== card.id) {
|
||||||
selectedId.value = card.id
|
selectedId.value = card.id
|
||||||
justSelected.value = true
|
justSelected.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!canPlay.value) {
|
||||||
|
if (!justSelected.value) {
|
||||||
|
selectedId.value = null
|
||||||
|
}
|
||||||
|
justSelected.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
dragCardId.value = card.id
|
dragCardId.value = card.id
|
||||||
dragStartY.value = e.clientY
|
dragStartY.value = e.clientY
|
||||||
dragStartX.value = e.clientX
|
dragStartX.value = e.clientX
|
||||||
@@ -157,6 +166,12 @@ function handlePointerUp(card: Card) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!canPlay.value) {
|
||||||
|
dragCardId.value = null
|
||||||
|
isDragging.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (dragCardId.value !== card.id) return
|
if (dragCardId.value !== card.id) return
|
||||||
|
|
||||||
const deltaY = dragStartY.value - dragCurrentY.value
|
const deltaY = dragStartY.value - dragCurrentY.value
|
||||||
@@ -198,12 +213,15 @@ defineExpose({ clearSelection })
|
|||||||
@pointerdown.prevent="clearSelection"
|
@pointerdown.prevent="clearSelection"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p v-if="selectedId && playableIds.has(selectedId)" class="hint">
|
<p v-if="selectedId && canPlay && playableIds.has(selectedId)" class="hint">
|
||||||
↑ 위로 드래그하여 내기
|
↑ 위로 드래그하여 내기
|
||||||
</p>
|
</p>
|
||||||
<p v-else-if="selectedId" class="hint muted">
|
<p v-else-if="selectedId && canPlay" class="hint muted">
|
||||||
낼 수 없는 카드 — 탭하여 접기
|
낼 수 없는 카드 — 탭하여 접기
|
||||||
</p>
|
</p>
|
||||||
|
<p v-else-if="selectedId" class="hint muted">
|
||||||
|
카드 상세 보기 — 탭하여 접기
|
||||||
|
</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref="fanRef"
|
ref="fanRef"
|
||||||
@@ -225,8 +243,7 @@ defineExpose({ clearSelection })
|
|||||||
:card="card"
|
:card="card"
|
||||||
:active-color="activeColor"
|
:active-color="activeColor"
|
||||||
:selected="selectedId === card.id"
|
:selected="selectedId === card.id"
|
||||||
:playable="playableIds.has(card.id)"
|
:playable="canPlay && playableIds.has(card.id)"
|
||||||
:disabled="!canInteract"
|
|
||||||
:dimensions="cardDimensions"
|
:dimensions="cardDimensions"
|
||||||
size="md"
|
size="md"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import Peer, { type DataConnection } from 'peerjs'
|
import Peer, { type DataConnection } from 'peerjs'
|
||||||
import type { CardColor, GameState } from '@/games/onecard/types'
|
import type { CardColor, GameState } from '@/games/onecard/types'
|
||||||
import { createGame, drawCardToPlayer, playCard, playerDrawCards, declareOneCard, toPublicState, undoLastAction, continueAfterRoundWin } from '@/games/onecard/game'
|
import { createGame, drawCardToPlayer, playCard, playerDrawCards, declareOneCard, toPublicState, undoLastAction, continueAfterRoundWin, getTopCard } from '@/games/onecard/game'
|
||||||
import { createPeerOptions } from '@/config/peer'
|
import { createPeerOptions } from '@/config/peer'
|
||||||
import {
|
import {
|
||||||
autoAssignSeats,
|
autoAssignSeats,
|
||||||
loadSeats,
|
loadSeats,
|
||||||
turnOrderFromSeats,
|
turnOrderFromSeats,
|
||||||
|
cornerForPlayer,
|
||||||
MAX_PLAYERS,
|
MAX_PLAYERS,
|
||||||
type SeatMap,
|
type SeatMap,
|
||||||
} from '@/utils/boardLayout'
|
} from '@/utils/boardLayout'
|
||||||
@@ -118,13 +119,29 @@ function handleHostPlayerMessage(msg: RoomMessage, conn: PeerConnection) {
|
|||||||
|
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'play-card': {
|
case 'play-card': {
|
||||||
|
const prevTop = getTopCard(gameState.value)
|
||||||
|
const prevActiveColor = gameState.value.activeColor
|
||||||
const result = playCard(gameState.value, msg.playerId, msg.cardId, msg.chosenColor)
|
const result = playCard(gameState.value, msg.playerId, msg.cardId, msg.chosenColor)
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
send(conn, { type: 'play-rejected', reason: result.reason })
|
send(conn, { type: 'play-rejected', reason: result.reason })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const playedCard = result.state.discardPile[result.state.discardPile.length - 1]
|
||||||
gameState.value = result.state
|
gameState.value = result.state
|
||||||
useRoomStore().clearPlayerHandPreview(msg.playerId)
|
const store = useRoomStore()
|
||||||
|
store.clearPlayerHandPreview(msg.playerId)
|
||||||
|
const seats = loadSeats()
|
||||||
|
const corner = seats ? cornerForPlayer(seats, msg.playerId) : null
|
||||||
|
if (playedCard && corner) {
|
||||||
|
store.triggerPlayAnimation(
|
||||||
|
msg.playerId,
|
||||||
|
corner,
|
||||||
|
playedCard,
|
||||||
|
result.state.activeColor,
|
||||||
|
prevTop,
|
||||||
|
prevActiveColor,
|
||||||
|
)
|
||||||
|
}
|
||||||
syncAllHands()
|
syncAllHands()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -359,14 +376,32 @@ function drawForPlayer(playerId: string): string | null {
|
|||||||
if (gameState.value.phase !== 'playing') return '지금은 카드를 뽑을 수 없습니다.'
|
if (gameState.value.phase !== 'playing') return '지금은 카드를 뽑을 수 없습니다.'
|
||||||
if (!gameState.value.turnOrder.includes(playerId)) return '이미 패를 모두 낸 플레이어입니다.'
|
if (!gameState.value.turnOrder.includes(playerId)) return '이미 패를 모두 낸 플레이어입니다.'
|
||||||
|
|
||||||
const currentId = gameState.value.turnOrder[gameState.value.currentTurnIndex]
|
const state = gameState.value
|
||||||
|
const currentId = state.turnOrder[state.currentTurnIndex]
|
||||||
|
const store = useRoomStore()
|
||||||
|
|
||||||
|
if (state.drawPenalty > 0 && currentId !== playerId) {
|
||||||
|
return '공격 카드를 받을 차례인 플레이어부터 뽑아주세요.'
|
||||||
|
}
|
||||||
|
|
||||||
|
let drawCount = 1
|
||||||
|
|
||||||
if (currentId === playerId) {
|
if (currentId === playerId) {
|
||||||
const result = playerDrawCards(gameState.value, playerId)
|
drawCount = state.drawPenalty > 0 ? state.drawPenalty : 1
|
||||||
|
const result = playerDrawCards(state, playerId)
|
||||||
if (!result.ok) return result.reason
|
if (!result.ok) return result.reason
|
||||||
gameState.value = result.state
|
gameState.value = result.state
|
||||||
} else {
|
} else {
|
||||||
gameState.value = drawCardToPlayer(gameState.value, playerId)
|
drawCount = 1
|
||||||
|
gameState.value = drawCardToPlayer(state, playerId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const seats = loadSeats()
|
||||||
|
const corner = seats ? cornerForPlayer(seats, playerId) : null
|
||||||
|
if (corner) {
|
||||||
|
store.triggerDrawAnimation(playerId, corner, drawCount)
|
||||||
|
}
|
||||||
|
|
||||||
syncAllHands()
|
syncAllHands()
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
+72
-1
@@ -1,7 +1,25 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import type { Card, PublicGameState, RoomRole } from '@/games/onecard/types'
|
import type { Card, CardColor, PublicGameState, RoomRole } from '@/games/onecard/types'
|
||||||
import type { ConnectedPlayer } from '@/composables/messages'
|
import type { ConnectedPlayer } from '@/composables/messages'
|
||||||
|
import type { BoardCorner } from '@/utils/boardLayout'
|
||||||
|
|
||||||
|
export interface DrawAnimationPayload {
|
||||||
|
id: number
|
||||||
|
playerId: string
|
||||||
|
corner: BoardCorner
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayAnimationPayload {
|
||||||
|
id: number
|
||||||
|
playerId: string
|
||||||
|
corner: BoardCorner
|
||||||
|
card: Card
|
||||||
|
activeColor: CardColor | null
|
||||||
|
previousTopCard: Card | null
|
||||||
|
previousActiveColor: CardColor | null
|
||||||
|
}
|
||||||
|
|
||||||
export const useRoomStore = defineStore('room', () => {
|
export const useRoomStore = defineStore('room', () => {
|
||||||
const roomCode = ref('')
|
const roomCode = ref('')
|
||||||
@@ -17,6 +35,11 @@ export const useRoomStore = defineStore('room', () => {
|
|||||||
const myHand = ref<Card[]>([])
|
const myHand = ref<Card[]>([])
|
||||||
const playerHandPreviews = ref<Record<string, string | null>>({})
|
const playerHandPreviews = ref<Record<string, string | null>>({})
|
||||||
const statusMessage = ref('')
|
const statusMessage = ref('')
|
||||||
|
const playAnimation = ref<PlayAnimationPayload | null>(null)
|
||||||
|
const drawAnimation = ref<DrawAnimationPayload | null>(null)
|
||||||
|
|
||||||
|
let playAnimationSeq = 0
|
||||||
|
let drawAnimationSeq = 0
|
||||||
|
|
||||||
const isBoard = computed(() => role.value === 'board')
|
const isBoard = computed(() => role.value === 'board')
|
||||||
const isPlayer = computed(() => role.value === 'player')
|
const isPlayer = computed(() => role.value === 'player')
|
||||||
@@ -42,6 +65,8 @@ export const useRoomStore = defineStore('room', () => {
|
|||||||
myHand.value = []
|
myHand.value = []
|
||||||
playerHandPreviews.value = {}
|
playerHandPreviews.value = {}
|
||||||
statusMessage.value = ''
|
statusMessage.value = ''
|
||||||
|
playAnimation.value = null
|
||||||
|
drawAnimation.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPlayerHandPreview(playerId: string, cardId: string | null) {
|
function setPlayerHandPreview(playerId: string, cardId: string | null) {
|
||||||
@@ -61,6 +86,46 @@ export const useRoomStore = defineStore('room', () => {
|
|||||||
}, 3000)
|
}, 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function triggerPlayAnimation(
|
||||||
|
playerId: string,
|
||||||
|
corner: BoardCorner,
|
||||||
|
card: Card,
|
||||||
|
activeColor: CardColor | null,
|
||||||
|
previousTopCard: Card | null,
|
||||||
|
previousActiveColor: CardColor | null,
|
||||||
|
) {
|
||||||
|
playAnimation.value = {
|
||||||
|
id: ++playAnimationSeq,
|
||||||
|
playerId,
|
||||||
|
corner,
|
||||||
|
card,
|
||||||
|
activeColor,
|
||||||
|
previousTopCard,
|
||||||
|
previousActiveColor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPlayAnimation() {
|
||||||
|
playAnimation.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerDrawAnimation(
|
||||||
|
playerId: string,
|
||||||
|
corner: BoardCorner,
|
||||||
|
count: number,
|
||||||
|
) {
|
||||||
|
drawAnimation.value = {
|
||||||
|
id: ++drawAnimationSeq,
|
||||||
|
playerId,
|
||||||
|
corner,
|
||||||
|
count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDrawAnimation() {
|
||||||
|
drawAnimation.value = null
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
roomCode,
|
roomCode,
|
||||||
role,
|
role,
|
||||||
@@ -75,6 +140,8 @@ export const useRoomStore = defineStore('room', () => {
|
|||||||
myHand,
|
myHand,
|
||||||
playerHandPreviews,
|
playerHandPreviews,
|
||||||
statusMessage,
|
statusMessage,
|
||||||
|
playAnimation,
|
||||||
|
drawAnimation,
|
||||||
isBoard,
|
isBoard,
|
||||||
isPlayer,
|
isPlayer,
|
||||||
playerCount,
|
playerCount,
|
||||||
@@ -85,5 +152,9 @@ export const useRoomStore = defineStore('room', () => {
|
|||||||
setPlayerHandPreview,
|
setPlayerHandPreview,
|
||||||
clearPlayerHandPreview,
|
clearPlayerHandPreview,
|
||||||
setStatus,
|
setStatus,
|
||||||
|
triggerPlayAnimation,
|
||||||
|
clearPlayAnimation,
|
||||||
|
triggerDrawAnimation,
|
||||||
|
clearDrawAnimation,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -21,6 +21,8 @@ import BoardSeatPicker from '@/components/BoardSeatPicker.vue'
|
|||||||
import BoardCornerZone from '@/components/BoardCornerZone.vue'
|
import BoardCornerZone from '@/components/BoardCornerZone.vue'
|
||||||
import DeckView from '@/components/DeckView.vue'
|
import DeckView from '@/components/DeckView.vue'
|
||||||
import DiscardPileView from '@/components/DiscardPileView.vue'
|
import DiscardPileView from '@/components/DiscardPileView.vue'
|
||||||
|
import BoardPlayAnimation from '@/components/BoardPlayAnimation.vue'
|
||||||
|
import BoardDrawAnimation from '@/components/BoardDrawAnimation.vue'
|
||||||
import UndoTurnButton from '@/components/UndoTurnButton.vue'
|
import UndoTurnButton from '@/components/UndoTurnButton.vue'
|
||||||
import ExitButton from '@/components/ExitButton.vue'
|
import ExitButton from '@/components/ExitButton.vue'
|
||||||
|
|
||||||
@@ -44,6 +46,38 @@ const isRoundWon = computed(() => publicState.value?.phase === 'round_won')
|
|||||||
const isFinished = computed(() => publicState.value?.phase === 'finished')
|
const isFinished = computed(() => publicState.value?.phase === 'finished')
|
||||||
const deckCount = computed(() => publicState.value?.deckCount ?? null)
|
const deckCount = computed(() => publicState.value?.deckCount ?? null)
|
||||||
|
|
||||||
|
const displayDeckCount = computed(() => {
|
||||||
|
const c = deckCount.value
|
||||||
|
if (c == null || !store.drawAnimation) return c
|
||||||
|
return c + store.drawAnimation.count
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 애니메이션 중 버린 더미는 이전 카드 유지 → 날아오는 카드와 겹침 방지 */
|
||||||
|
const discardTopCard = computed(() => {
|
||||||
|
if (store.playAnimation) return store.playAnimation.previousTopCard
|
||||||
|
return publicState.value?.topCard ?? null
|
||||||
|
})
|
||||||
|
const discardActiveColor = computed(() => {
|
||||||
|
if (store.playAnimation) return store.playAnimation.previousActiveColor
|
||||||
|
return publicState.value?.activeColor
|
||||||
|
})
|
||||||
|
|
||||||
|
function onPlayAnimationComplete() {
|
||||||
|
store.clearPlayAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrawAnimationComplete() {
|
||||||
|
store.clearDrawAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handCountForPlayer(playerId: string, actual: number) {
|
||||||
|
const anim = store.drawAnimation
|
||||||
|
if (anim?.playerId === playerId) {
|
||||||
|
return Math.max(0, actual - anim.count)
|
||||||
|
}
|
||||||
|
return actual
|
||||||
|
}
|
||||||
|
|
||||||
const canStart = computed(
|
const canStart = computed(
|
||||||
() => store.canStartGame && assignedPlayerCount(seatMap.value) >= 2,
|
() => store.canStartGame && assignedPlayerCount(seatMap.value) >= 2,
|
||||||
)
|
)
|
||||||
@@ -190,7 +224,7 @@ function handleContinue() {
|
|||||||
v-if="playerAtCorner(corner)"
|
v-if="playerAtCorner(corner)"
|
||||||
:corner="corner"
|
:corner="corner"
|
||||||
:player-name="playerAtCorner(corner)!.name"
|
:player-name="playerAtCorner(corner)!.name"
|
||||||
:hand-count="playerAtCorner(corner)!.handCount"
|
:hand-count="handCountForPlayer(playerAtCorner(corner)!.id, playerAtCorner(corner)!.handCount)"
|
||||||
:is-turn="currentTurnId === playerAtCorner(corner)!.id"
|
:is-turn="currentTurnId === playerAtCorner(corner)!.id"
|
||||||
:preview-index="previewForCorner(corner)"
|
:preview-index="previewForCorner(corner)"
|
||||||
:show-draw="isPlaying"
|
:show-draw="isPlaying"
|
||||||
@@ -199,15 +233,26 @@ function handleContinue() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="center-pile">
|
<div class="center-pile">
|
||||||
<DeckView large :count="deckCount" />
|
<DeckView large :count="displayDeckCount" />
|
||||||
<DiscardPileView
|
<DiscardPileView
|
||||||
:top-card="publicState?.topCard ?? null"
|
:top-card="discardTopCard"
|
||||||
:active-color="publicState?.activeColor"
|
:active-color="discardActiveColor"
|
||||||
large
|
large
|
||||||
|
:class="{ 'pile-landing': !!store.playAnimation }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<BoardPlayAnimation
|
||||||
|
:payload="store.playAnimation"
|
||||||
|
@complete="onPlayAnimationComplete"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BoardDrawAnimation
|
||||||
|
:payload="store.drawAnimation"
|
||||||
|
@complete="onDrawAnimationComplete"
|
||||||
|
/>
|
||||||
|
|
||||||
<p v-if="store.statusMessage" class="toast">{{ store.statusMessage }}</p>
|
<p v-if="store.statusMessage" class="toast">{{ store.statusMessage }}</p>
|
||||||
<UndoTurnButton
|
<UndoTurnButton
|
||||||
v-if="store.gameStarted && (isPlaying || isRoundWon)"
|
v-if="store.gameStarted && (isPlaying || isRoundWon)"
|
||||||
@@ -288,6 +333,16 @@ function handleContinue() {
|
|||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.center-pile :deep(.pile-landing .discard-slot) {
|
||||||
|
animation: pileLand 0.35s ease 0.42s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pileLand {
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
40% { transform: scale(1.06); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
.winner-banner {
|
.winner-banner {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ function handleColorCancel() {
|
|||||||
:top-card="publicState?.topCard ?? null"
|
:top-card="publicState?.topCard ?? null"
|
||||||
:active-color="publicState?.activeColor"
|
:active-color="publicState?.activeColor"
|
||||||
:draw-penalty="drawPenalty"
|
:draw-penalty="drawPenalty"
|
||||||
:can-interact="isMyTurn"
|
:can-play="isMyTurn"
|
||||||
@play="handlePlay"
|
@play="handlePlay"
|
||||||
@request-color="handleRequestColor"
|
@request-color="handleRequestColor"
|
||||||
@selection-change="handleSelectionChange"
|
@selection-change="handleSelectionChange"
|
||||||
|
|||||||
Reference in new issue
Block a user