애니메이션 수정
CI / Type-check & Build (push) Successful in 35s

This commit is contained in:
ysj7705 committed 2026-06-23 19:29:41 +09:00
1 parent 15e8869acd
commit 3d23c1347f
10 files changed
+469 -20

No files matched your search

+1 -1
View File
@@ -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">
<!--
배치 기준: 각 모서리에 앉아 테이블 중앙을 바라볼 때
- 패 좌측 = 이름
+143
View File
@@ -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>
+126
View File
@@ -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>
+1
View File
@@ -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">
+1
View File
@@ -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` }"
>
+25 -8
View File
@@ -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"
/>
+40 -5
View File
@@ -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
View File
@@ -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,
}
})
+59 -4
View File
@@ -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%;
+1 -1
View File
@@ -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"