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>
|
||||
|
||||
<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-stack"
|
||||
data-board-deck
|
||||
:style="{ width: `${stackBox.width}px`, height: `${stackBox.height}px` }"
|
||||
>
|
||||
<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>
|
||||
<div
|
||||
class="discard-slot"
|
||||
data-discard-pile
|
||||
:class="{ large }"
|
||||
:style="{ width: `${pile.stackWidth}px`, height: `${pile.stackHeight}px` }"
|
||||
>
|
||||
|
||||
@@ -9,9 +9,12 @@ const props = defineProps<{
|
||||
topCard?: Card | null
|
||||
activeColor?: CardColor | null
|
||||
drawPenalty?: number
|
||||
canInteract?: boolean
|
||||
/** 내 턴일 때만 카드 내기 가능 */
|
||||
canPlay?: boolean
|
||||
}>()
|
||||
|
||||
const canPlay = computed(() => props.canPlay ?? false)
|
||||
|
||||
const emit = defineEmits<{
|
||||
play: [cardId: string]
|
||||
'request-color': [cardId: string]
|
||||
@@ -41,7 +44,7 @@ const cardDimensions = computed(() => ({
|
||||
const fanHeightPx = computed(() => Math.round(cardHeightPx.value * 1.15 + 48))
|
||||
|
||||
const playableIds = computed(() => {
|
||||
if (!props.topCard || !props.canInteract) return new Set<string>()
|
||||
if (!props.topCard || !canPlay.value) return new Set<string>()
|
||||
return new Set(
|
||||
getPlayableCards(
|
||||
props.cards,
|
||||
@@ -125,14 +128,20 @@ function getFanStyle(index: number, total: number) {
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent, card: Card) {
|
||||
if (!props.canInteract) return
|
||||
|
||||
if (selectedId.value !== card.id) {
|
||||
selectedId.value = card.id
|
||||
justSelected.value = true
|
||||
return
|
||||
}
|
||||
|
||||
if (!canPlay.value) {
|
||||
if (!justSelected.value) {
|
||||
selectedId.value = null
|
||||
}
|
||||
justSelected.value = false
|
||||
return
|
||||
}
|
||||
|
||||
dragCardId.value = card.id
|
||||
dragStartY.value = e.clientY
|
||||
dragStartX.value = e.clientX
|
||||
@@ -157,6 +166,12 @@ function handlePointerUp(card: Card) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!canPlay.value) {
|
||||
dragCardId.value = null
|
||||
isDragging.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (dragCardId.value !== card.id) return
|
||||
|
||||
const deltaY = dragStartY.value - dragCurrentY.value
|
||||
@@ -198,12 +213,15 @@ defineExpose({ 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 v-else-if="selectedId" class="hint muted">
|
||||
<p v-else-if="selectedId && canPlay" class="hint muted">
|
||||
낼 수 없는 카드 — 탭하여 접기
|
||||
</p>
|
||||
<p v-else-if="selectedId" class="hint muted">
|
||||
카드 상세 보기 — 탭하여 접기
|
||||
</p>
|
||||
|
||||
<div
|
||||
ref="fanRef"
|
||||
@@ -225,8 +243,7 @@ defineExpose({ clearSelection })
|
||||
:card="card"
|
||||
:active-color="activeColor"
|
||||
:selected="selectedId === card.id"
|
||||
:playable="playableIds.has(card.id)"
|
||||
:disabled="!canInteract"
|
||||
:playable="canPlay && playableIds.has(card.id)"
|
||||
:dimensions="cardDimensions"
|
||||
size="md"
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { ref } from 'vue'
|
||||
import Peer, { type DataConnection } from 'peerjs'
|
||||
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 {
|
||||
autoAssignSeats,
|
||||
loadSeats,
|
||||
turnOrderFromSeats,
|
||||
cornerForPlayer,
|
||||
MAX_PLAYERS,
|
||||
type SeatMap,
|
||||
} from '@/utils/boardLayout'
|
||||
@@ -118,13 +119,29 @@ function handleHostPlayerMessage(msg: RoomMessage, conn: PeerConnection) {
|
||||
|
||||
switch (msg.type) {
|
||||
case 'play-card': {
|
||||
const prevTop = getTopCard(gameState.value)
|
||||
const prevActiveColor = gameState.value.activeColor
|
||||
const result = playCard(gameState.value, msg.playerId, msg.cardId, msg.chosenColor)
|
||||
if (!result.ok) {
|
||||
send(conn, { type: 'play-rejected', reason: result.reason })
|
||||
return
|
||||
}
|
||||
const playedCard = result.state.discardPile[result.state.discardPile.length - 1]
|
||||
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()
|
||||
break
|
||||
}
|
||||
@@ -359,14 +376,32 @@ function drawForPlayer(playerId: string): string | null {
|
||||
if (gameState.value.phase !== 'playing') 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) {
|
||||
const result = playerDrawCards(gameState.value, playerId)
|
||||
drawCount = state.drawPenalty > 0 ? state.drawPenalty : 1
|
||||
const result = playerDrawCards(state, playerId)
|
||||
if (!result.ok) return result.reason
|
||||
gameState.value = result.state
|
||||
} 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()
|
||||
return null
|
||||
}
|
||||
|
||||
+72
-1
@@ -1,7 +1,25 @@
|
||||
import { ref, computed } from 'vue'
|
||||
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 { 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', () => {
|
||||
const roomCode = ref('')
|
||||
@@ -17,6 +35,11 @@ export const useRoomStore = defineStore('room', () => {
|
||||
const myHand = ref<Card[]>([])
|
||||
const playerHandPreviews = ref<Record<string, string | null>>({})
|
||||
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 isPlayer = computed(() => role.value === 'player')
|
||||
@@ -42,6 +65,8 @@ export const useRoomStore = defineStore('room', () => {
|
||||
myHand.value = []
|
||||
playerHandPreviews.value = {}
|
||||
statusMessage.value = ''
|
||||
playAnimation.value = null
|
||||
drawAnimation.value = null
|
||||
}
|
||||
|
||||
function setPlayerHandPreview(playerId: string, cardId: string | null) {
|
||||
@@ -61,6 +86,46 @@ export const useRoomStore = defineStore('room', () => {
|
||||
}, 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 {
|
||||
roomCode,
|
||||
role,
|
||||
@@ -75,6 +140,8 @@ export const useRoomStore = defineStore('room', () => {
|
||||
myHand,
|
||||
playerHandPreviews,
|
||||
statusMessage,
|
||||
playAnimation,
|
||||
drawAnimation,
|
||||
isBoard,
|
||||
isPlayer,
|
||||
playerCount,
|
||||
@@ -85,5 +152,9 @@ export const useRoomStore = defineStore('room', () => {
|
||||
setPlayerHandPreview,
|
||||
clearPlayerHandPreview,
|
||||
setStatus,
|
||||
triggerPlayAnimation,
|
||||
clearPlayAnimation,
|
||||
triggerDrawAnimation,
|
||||
clearDrawAnimation,
|
||||
}
|
||||
})
|
||||
@@ -21,6 +21,8 @@ import BoardSeatPicker from '@/components/BoardSeatPicker.vue'
|
||||
import BoardCornerZone from '@/components/BoardCornerZone.vue'
|
||||
import DeckView from '@/components/DeckView.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 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 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(
|
||||
() => store.canStartGame && assignedPlayerCount(seatMap.value) >= 2,
|
||||
)
|
||||
@@ -190,7 +224,7 @@ function handleContinue() {
|
||||
v-if="playerAtCorner(corner)"
|
||||
:corner="corner"
|
||||
: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"
|
||||
:preview-index="previewForCorner(corner)"
|
||||
:show-draw="isPlaying"
|
||||
@@ -199,15 +233,26 @@ function handleContinue() {
|
||||
</template>
|
||||
|
||||
<div class="center-pile">
|
||||
<DeckView large :count="deckCount" />
|
||||
<DeckView large :count="displayDeckCount" />
|
||||
<DiscardPileView
|
||||
:top-card="publicState?.topCard ?? null"
|
||||
:active-color="publicState?.activeColor"
|
||||
:top-card="discardTopCard"
|
||||
:active-color="discardActiveColor"
|
||||
large
|
||||
:class="{ 'pile-landing': !!store.playAnimation }"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<BoardPlayAnimation
|
||||
:payload="store.playAnimation"
|
||||
@complete="onPlayAnimationComplete"
|
||||
/>
|
||||
|
||||
<BoardDrawAnimation
|
||||
:payload="store.drawAnimation"
|
||||
@complete="onDrawAnimationComplete"
|
||||
/>
|
||||
|
||||
<p v-if="store.statusMessage" class="toast">{{ store.statusMessage }}</p>
|
||||
<UndoTurnButton
|
||||
v-if="store.gameStarted && (isPlaying || isRoundWon)"
|
||||
@@ -288,6 +333,16 @@ function handleContinue() {
|
||||
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 {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
|
||||
@@ -141,7 +141,7 @@ function handleColorCancel() {
|
||||
:top-card="publicState?.topCard ?? null"
|
||||
:active-color="publicState?.activeColor"
|
||||
:draw-penalty="drawPenalty"
|
||||
:can-interact="isMyTurn"
|
||||
:can-play="isMyTurn"
|
||||
@play="handlePlay"
|
||||
@request-color="handleRequestColor"
|
||||
@selection-change="handleSelectionChange"
|
||||
|
||||
Reference in new issue
Block a user