first commit
This commit is contained in:
commit
8bf1d1b928
47 files changed
+8648
No files matched your search
@@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { BoardCorner } from '@/utils/boardLayout'
|
||||
import OpponentHandFan from './OpponentHandFan.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
corner: BoardCorner
|
||||
playerName: string
|
||||
handCount: number
|
||||
isTurn?: boolean
|
||||
previewIndex?: number | null
|
||||
showDraw?: boolean
|
||||
drawDisabled?: boolean
|
||||
}>(), {
|
||||
isTurn: false,
|
||||
previewIndex: null,
|
||||
showDraw: false,
|
||||
drawDisabled: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
draw: []
|
||||
}>()
|
||||
|
||||
const fanPosition = computed((): 'top' | 'bottom' => (
|
||||
props.corner === 'top' ? 'top' : 'bottom'
|
||||
))
|
||||
|
||||
const large = computed(() => props.corner === 'top' || props.corner === 'bottom')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="corner-zone" :class="[`corner-${corner}`, { 'is-turn': isTurn }]">
|
||||
<!--
|
||||
배치 기준: 각 모서리에 앉아 테이블 중앙을 바라볼 때
|
||||
- 패 좌측 = 이름
|
||||
- 패 우측 = 카드뽑기
|
||||
- 패 상단 = 테이블 쪽 가장자리
|
||||
-->
|
||||
<div class="corner-content">
|
||||
<!-- 아래: [뽑기][이름][패] -->
|
||||
<template v-if="corner === 'bottom'">
|
||||
<button
|
||||
v-if="showDraw"
|
||||
type="button"
|
||||
class="draw-btn"
|
||||
:class="{ 'is-turn': isTurn }"
|
||||
:disabled="drawDisabled"
|
||||
@click="emit('draw')"
|
||||
>
|
||||
{{ playerName }} 카드뽑기
|
||||
</button>
|
||||
<div class="fan-row">
|
||||
<span class="player-name" :class="{ 'is-turn': isTurn }">{{ playerName }}</span>
|
||||
<div class="corner-inner">
|
||||
<OpponentHandFan
|
||||
:count="handCount"
|
||||
:position="fanPosition"
|
||||
:is-turn="isTurn"
|
||||
:preview-index="previewIndex"
|
||||
:large="large"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 위: [패][이름][뽑기] -->
|
||||
<template v-else-if="corner === 'top'">
|
||||
<div class="fan-row">
|
||||
<div class="corner-inner">
|
||||
<OpponentHandFan
|
||||
:count="handCount"
|
||||
:position="fanPosition"
|
||||
:is-turn="isTurn"
|
||||
:preview-index="previewIndex"
|
||||
:large="large"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<span class="player-name" :class="{ 'is-turn': isTurn }">{{ playerName }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="showDraw"
|
||||
type="button"
|
||||
class="draw-btn"
|
||||
:class="{ 'is-turn': isTurn }"
|
||||
:disabled="drawDisabled"
|
||||
@click="emit('draw')"
|
||||
>
|
||||
{{ playerName }} 카드뽑기
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- 왼쪽: [뽑기][패][이름] — 좌측=아래, 우측=위 -->
|
||||
<template v-else-if="corner === 'left'">
|
||||
<div class="sideways-stack">
|
||||
<button
|
||||
v-if="showDraw"
|
||||
type="button"
|
||||
class="draw-btn"
|
||||
:class="{ 'is-turn': isTurn }"
|
||||
:disabled="drawDisabled"
|
||||
@click="emit('draw')"
|
||||
>
|
||||
{{ playerName }} 카드뽑기
|
||||
</button>
|
||||
<div class="sideways-fan-slot">
|
||||
<div class="corner-inner sideways side-left">
|
||||
<OpponentHandFan
|
||||
:count="handCount"
|
||||
:position="fanPosition"
|
||||
:is-turn="isTurn"
|
||||
:preview-index="previewIndex"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span class="player-name" :class="{ 'is-turn': isTurn }">{{ playerName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 오른쪽: [이름][패][뽑기] — 좌측=위, 우측=아래 -->
|
||||
<template v-else>
|
||||
<div class="sideways-stack">
|
||||
<span class="player-name" :class="{ 'is-turn': isTurn }">{{ playerName }}</span>
|
||||
<div class="sideways-fan-slot">
|
||||
<div class="corner-inner sideways side-right">
|
||||
<OpponentHandFan
|
||||
:count="handCount"
|
||||
:position="fanPosition"
|
||||
:is-turn="isTurn"
|
||||
:preview-index="previewIndex"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="showDraw"
|
||||
type="button"
|
||||
class="draw-btn"
|
||||
:class="{ 'is-turn': isTurn }"
|
||||
:disabled="drawDisabled"
|
||||
@click="emit('draw')"
|
||||
>
|
||||
{{ playerName }} 카드뽑기
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.corner-zone {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.corner-top { grid-area: top; justify-self: center; }
|
||||
.corner-bottom { grid-area: bottom; justify-self: center; }
|
||||
.corner-left { grid-area: left; align-self: center; justify-self: start; }
|
||||
.corner-right { grid-area: right; align-self: center; justify-self: end; }
|
||||
|
||||
.corner-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fan-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.player-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #aaa;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.corner-top .player-name,
|
||||
.corner-bottom .player-name {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.player-name.is-turn {
|
||||
color: #f1c40f;
|
||||
}
|
||||
|
||||
/* 모서리에서 중앙을 바라볼 때 정방향 */
|
||||
.corner-top .player-name {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.corner-left .player-name {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.corner-right .player-name {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.sideways-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sideways-fan-slot {
|
||||
/* 회전 후 패가 차지하는 영역(280×120)을 분리해 이름·버튼과 겹치지 않게 */
|
||||
width: 140px;
|
||||
height: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.corner-left .draw-btn,
|
||||
.corner-left .player-name,
|
||||
.corner-right .draw-btn,
|
||||
.corner-right .player-name {
|
||||
position: relative;
|
||||
z-index: 350;
|
||||
}
|
||||
|
||||
.corner-inner.side-left.sideways {
|
||||
transform: rotate(90deg);
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.corner-inner.side-right.sideways {
|
||||
transform: rotate(-90deg);
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.corner-inner.sideways :deep(.fan) {
|
||||
width: min(100%, 280px);
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.draw-btn {
|
||||
padding: 8px 14px;
|
||||
border-radius: 18px;
|
||||
border: 2px solid #444;
|
||||
background: rgba(42, 42, 74, 0.9);
|
||||
color: #ccc;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.draw-btn.is-turn {
|
||||
border-color: #f1c40f;
|
||||
background: rgba(241, 196, 15, 0.15);
|
||||
color: #f1c40f;
|
||||
}
|
||||
|
||||
.draw-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.corner-top .draw-btn {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.corner-top .draw-btn:active:not(:disabled) {
|
||||
transform: rotate(180deg) scale(0.97);
|
||||
}
|
||||
|
||||
.corner-bottom .draw-btn:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.corner-left .draw-btn {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.corner-left .draw-btn:active:not(:disabled) {
|
||||
transform: rotate(90deg) scale(0.97);
|
||||
}
|
||||
|
||||
.corner-right .draw-btn {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.corner-right .draw-btn:active:not(:disabled) {
|
||||
transform: rotate(-90deg) scale(0.97);
|
||||
}
|
||||
|
||||
.corner-left .draw-btn,
|
||||
.corner-right .draw-btn {
|
||||
font-size: 0.75rem;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,360 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import {
|
||||
CORNERS_CLOCKWISE,
|
||||
CORNER_LABELS,
|
||||
dropPlayerOnCorner,
|
||||
dropPlayerOnPool,
|
||||
type BoardCorner,
|
||||
type SeatMap,
|
||||
} from '@/utils/boardLayout'
|
||||
|
||||
const props = defineProps<{
|
||||
seats: SeatMap
|
||||
players: { id: string; name: string }[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:seats': [seats: SeatMap]
|
||||
}>()
|
||||
|
||||
type DropZone = BoardCorner | 'pool'
|
||||
type DragSource = DropZone
|
||||
|
||||
interface DragState {
|
||||
playerId: string
|
||||
from: DragSource
|
||||
}
|
||||
|
||||
const dragging = ref<DragState | null>(null)
|
||||
const hoverZone = ref<DropZone | null>(null)
|
||||
const ghostPos = ref({ x: 0, y: 0 })
|
||||
|
||||
const assignedIds = computed(() => new Set(
|
||||
CORNERS_CLOCKWISE.map((c) => props.seats[c]).filter(Boolean) as string[],
|
||||
))
|
||||
|
||||
const unassignedPlayers = computed(() =>
|
||||
props.players.filter((p) => !assignedIds.value.has(p.id)),
|
||||
)
|
||||
|
||||
function playerAt(corner: BoardCorner) {
|
||||
const id = props.seats[corner]
|
||||
return props.players.find((p) => p.id === id) ?? null
|
||||
}
|
||||
|
||||
function playerName(playerId: string) {
|
||||
return props.players.find((p) => p.id === playerId)?.name ?? ''
|
||||
}
|
||||
|
||||
function zoneFromElement(el: Element | null): DropZone | null {
|
||||
const zone = el?.closest('[data-seat-drop]') as HTMLElement | null
|
||||
if (!zone) return null
|
||||
const value = zone.dataset.seatDrop
|
||||
if (value === 'pool') return 'pool'
|
||||
if (CORNERS_CLOCKWISE.includes(value as BoardCorner)) return value as BoardCorner
|
||||
return null
|
||||
}
|
||||
|
||||
function updateHover(clientX: number, clientY: number) {
|
||||
hoverZone.value = zoneFromElement(document.elementFromPoint(clientX, clientY))
|
||||
}
|
||||
|
||||
function finishDrag(clientX: number, clientY: number) {
|
||||
if (!dragging.value) return
|
||||
|
||||
const zone = zoneFromElement(document.elementFromPoint(clientX, clientY))
|
||||
const { playerId, from } = dragging.value
|
||||
|
||||
if (zone && zone !== from) {
|
||||
if (zone === 'pool') {
|
||||
emit('update:seats', dropPlayerOnPool(props.seats, playerId))
|
||||
} else {
|
||||
emit('update:seats', dropPlayerOnCorner(props.seats, zone, playerId))
|
||||
}
|
||||
}
|
||||
|
||||
dragging.value = null
|
||||
hoverZone.value = null
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!dragging.value) return
|
||||
ghostPos.value = { x: e.clientX, y: e.clientY }
|
||||
updateHover(e.clientX, e.clientY)
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
finishDrag(e.clientX, e.clientY)
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('pointercancel', onPointerUp)
|
||||
}
|
||||
|
||||
function startDrag(e: PointerEvent, playerId: string, from: DragSource) {
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
|
||||
dragging.value = { playerId, from }
|
||||
ghostPos.value = { x: e.clientX, y: e.clientY }
|
||||
hoverZone.value = from
|
||||
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
window.addEventListener('pointercancel', onPointerUp)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('pointercancel', onPointerUp)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="seat-picker" :class="{ 'is-dragging': dragging }">
|
||||
<p class="seat-hint">
|
||||
가운데 플레이어를 드래그해 자리에 놓으세요. 이미 있는 자리면 서로 바뀝니다.
|
||||
</p>
|
||||
|
||||
<div class="seat-table">
|
||||
<div
|
||||
v-for="corner in CORNERS_CLOCKWISE"
|
||||
:key="corner"
|
||||
class="seat-cell"
|
||||
:class="[
|
||||
`cell-${corner}`,
|
||||
{ 'drop-hover': hoverZone === corner && dragging?.from !== corner },
|
||||
]"
|
||||
:data-seat-drop="corner"
|
||||
>
|
||||
<span class="corner-label">{{ CORNER_LABELS[corner] }}</span>
|
||||
<div class="corner-slot">
|
||||
<div
|
||||
v-if="playerAt(corner)"
|
||||
class="player-chip seated-chip"
|
||||
:class="{ dragging: dragging?.playerId === playerAt(corner)!.id }"
|
||||
@pointerdown="startDrag($event, playerAt(corner)!.id, corner)"
|
||||
>
|
||||
{{ playerAt(corner)!.name }}
|
||||
</div>
|
||||
<span v-else class="corner-empty">놓기</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="seat-center"
|
||||
:class="{ 'drop-hover': hoverZone === 'pool' && dragging?.from !== 'pool' }"
|
||||
data-seat-drop="pool"
|
||||
>
|
||||
<span class="table-label">미배치 {{ unassignedPlayers.length }}명</span>
|
||||
<TransitionGroup name="pool-chip" tag="div" class="pool-slot">
|
||||
<div
|
||||
v-for="p in unassignedPlayers"
|
||||
:key="p.id"
|
||||
class="player-chip pool-chip"
|
||||
:class="{ dragging: dragging?.playerId === p.id }"
|
||||
@pointerdown="startDrag($event, p.id, 'pool')"
|
||||
>
|
||||
{{ p.name }}
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
<p v-if="unassignedPlayers.length === 0" class="pool-note">모두 자리에 배치됨</p>
|
||||
<p v-else class="pool-note">여기로 끌어오면 자리에서 빠짐</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="dragging"
|
||||
class="drag-ghost"
|
||||
:style="{ left: `${ghostPos.x}px`, top: `${ghostPos.y}px` }"
|
||||
>
|
||||
{{ playerName(dragging.playerId) }}
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.seat-picker {
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.seat-picker.is-dragging {
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.seat-hint {
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.seat-table {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
". top ."
|
||||
"left center right"
|
||||
". bottom .";
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: rgba(42, 42, 74, 0.5);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.seat-cell,
|
||||
.seat-center {
|
||||
border: 2px dashed #444;
|
||||
border-radius: 10px;
|
||||
background: rgba(26, 26, 46, 0.6);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.seat-cell.drop-hover,
|
||||
.seat-center.drop-hover {
|
||||
border-color: #6c5ce7;
|
||||
background: rgba(108, 92, 231, 0.12);
|
||||
}
|
||||
|
||||
.seat-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
min-width: 96px;
|
||||
}
|
||||
|
||||
.cell-top { grid-area: top; justify-self: center; }
|
||||
.cell-left { grid-area: left; align-self: center; }
|
||||
.cell-right { grid-area: right; align-self: center; }
|
||||
.cell-bottom { grid-area: bottom; justify-self: center; }
|
||||
|
||||
.seat-center {
|
||||
grid-area: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
min-width: 148px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.corner-label,
|
||||
.table-label {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-label {
|
||||
font-weight: 600;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.corner-slot,
|
||||
.pool-slot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pool-slot {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.player-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid #444;
|
||||
background: #2a2a4a;
|
||||
color: #eee;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: opacity 0.15s, transform 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.player-chip:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.player-chip.dragging {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.seated-chip {
|
||||
padding: 6px 10px;
|
||||
font-size: 0.8rem;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pool-chip {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.corner-empty {
|
||||
color: #555;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.pool-note {
|
||||
margin: 0;
|
||||
font-size: 0.68rem;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.drag-ghost {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 8px 14px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid #6c5ce7;
|
||||
background: rgba(42, 42, 74, 0.95);
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.pool-chip-enter-active,
|
||||
.pool-chip-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.pool-chip-enter-from,
|
||||
.pool-chip-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.pool-chip-move {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,291 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { Card, CardColor } from "@/games/onecard/types";
|
||||
import type { CardViewSize } from "@/components/cardTypes";
|
||||
import {
|
||||
COLOR_STYLES,
|
||||
getCardDisplayColor,
|
||||
getCardLabel,
|
||||
} from "@/games/onecard/cardDisplay";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
card: Card;
|
||||
activeColor?: CardColor | null;
|
||||
size?: CardViewSize;
|
||||
faceDown?: boolean;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
playable?: boolean;
|
||||
dimensions?: { width: number; height: number };
|
||||
}>(),
|
||||
{
|
||||
activeColor: null,
|
||||
size: "md",
|
||||
faceDown: false,
|
||||
selected: false,
|
||||
disabled: false,
|
||||
playable: true,
|
||||
},
|
||||
);
|
||||
|
||||
const displayColor = computed(() =>
|
||||
getCardDisplayColor(props.card, props.activeColor),
|
||||
);
|
||||
const style = computed(() => COLOR_STYLES[displayColor.value]);
|
||||
const label = computed(() => getCardLabel(props.card.value));
|
||||
|
||||
const dimensionStyle = computed(() => {
|
||||
if (!props.dimensions) return undefined;
|
||||
return {
|
||||
"--card-w": `${props.dimensions.width}px`,
|
||||
"--card-h": `${props.dimensions.height}px`,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="card"
|
||||
:class="[
|
||||
size,
|
||||
{
|
||||
selected,
|
||||
disabled,
|
||||
unplayable: !playable && !faceDown,
|
||||
'face-down': faceDown,
|
||||
fluid: !!dimensions,
|
||||
},
|
||||
]"
|
||||
:style="[
|
||||
faceDown
|
||||
? undefined
|
||||
: { '--card-color': style.bg, '--card-text': style.text },
|
||||
dimensionStyle,
|
||||
]"
|
||||
>
|
||||
<template v-if="faceDown">
|
||||
<div class="card-back">
|
||||
<div class="back-pattern" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="card-inner">
|
||||
<div class="corner top-left">
|
||||
<span class="corner-value">{{ label }}</span>
|
||||
</div>
|
||||
<div class="center">
|
||||
<div class="center-oval" />
|
||||
<span class="center-value">{{ label }}</span>
|
||||
</div>
|
||||
<div class="corner bottom-right">
|
||||
<span class="corner-value">{{ label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
--card-w: 72px;
|
||||
--card-h: 108px;
|
||||
width: var(--card-w);
|
||||
height: var(--card-h);
|
||||
border-radius: 10px;
|
||||
background: #faf8f5;
|
||||
border: 2px solid #d4cfc7;
|
||||
box-shadow:
|
||||
0 1px 0 #fff inset,
|
||||
0 4px 12px rgba(0, 0, 0, 0.35),
|
||||
0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card.sm {
|
||||
--card-w: 52px;
|
||||
--card-h: 78px;
|
||||
}
|
||||
|
||||
.card.lg {
|
||||
--card-w: 100px;
|
||||
--card-h: 150px;
|
||||
}
|
||||
|
||||
.card.xl {
|
||||
--card-w: 120px;
|
||||
--card-h: 176px;
|
||||
}
|
||||
|
||||
.card.2xl {
|
||||
--card-w: 140px;
|
||||
--card-h: 210px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.card.fluid {
|
||||
width: var(--card-w);
|
||||
height: var(--card-h);
|
||||
}
|
||||
|
||||
.card.fluid .center-value {
|
||||
font-size: calc(var(--card-h) * 0.15);
|
||||
}
|
||||
|
||||
.card.fluid .top-left,
|
||||
.card.fluid .bottom-right {
|
||||
font-size: calc(var(--card-h) * 0.08);
|
||||
}
|
||||
|
||||
.card.selected {
|
||||
box-shadow:
|
||||
0 0 0 3px #f1c40f,
|
||||
0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.card.disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.card.unplayable:not(.disabled) {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.card.face-down {
|
||||
cursor: default;
|
||||
border-color: #1a3a5c;
|
||||
background: #1a3a5c;
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.corner {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: var(--card-text);
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.top-left {
|
||||
top: 6px;
|
||||
left: 7px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.bottom-right {
|
||||
bottom: 6px;
|
||||
right: 7px;
|
||||
transform: rotate(180deg);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.card.sm .top-left,
|
||||
.card.sm .bottom-right {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.card.lg .top-left,
|
||||
.card.lg .bottom-right {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.center {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.center-oval {
|
||||
width: 55%;
|
||||
height: 42%;
|
||||
border-radius: 50%;
|
||||
background: var(--card-color);
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.center-value {
|
||||
position: absolute;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 900;
|
||||
color: var(--card-text);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.card.sm .center-value {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.card.lg .center-value {
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
|
||||
.card.xl .top-left,
|
||||
.card.xl .bottom-right {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.card.xl .center-value {
|
||||
font-size: 2.6rem;
|
||||
}
|
||||
|
||||
.card.2xl .top-left,
|
||||
.card.2xl .bottom-right {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.card.2xl .center-value {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.corner-value {
|
||||
background: var(--card-color);
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
min-width: 1.2em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card-back {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #1a3a5c 0%, #0d2137 50%, #1a3a5c 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.back-pattern {
|
||||
width: 80%;
|
||||
height: 85%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 4px,
|
||||
rgba(255, 255, 255, 0.04) 4px,
|
||||
rgba(255, 255, 255, 0.04) 8px
|
||||
);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Card } from '@/games/onecard/types'
|
||||
import { PILE_CARD } from '@/components/cardTypes'
|
||||
import CardView from './CardView.vue'
|
||||
|
||||
/** 원카드 덱 초기 장수 (108장) */
|
||||
const FULL_DECK = 108
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
large?: boolean
|
||||
count?: number | null
|
||||
}>(), {
|
||||
large: false,
|
||||
count: null,
|
||||
})
|
||||
|
||||
const pile = computed(() => (props.large ? PILE_CARD.lg : PILE_CARD.md))
|
||||
const stepPx = computed(() => (props.large ? 4 : 3))
|
||||
|
||||
const visualStack = computed(() => {
|
||||
const c = props.count
|
||||
if (c == null) {
|
||||
return { layers: 3, depth: 1 }
|
||||
}
|
||||
if (c <= 0) {
|
||||
return { layers: 0, depth: 0 }
|
||||
}
|
||||
|
||||
const depth = Math.max(0.12, Math.min(1, c / (FULL_DECK * 0.65)))
|
||||
|
||||
let layers = 1
|
||||
if (c > 12) layers = 2
|
||||
if (c > 40) layers = 3
|
||||
|
||||
return { layers, depth }
|
||||
})
|
||||
|
||||
const cardDimensions = computed(() => {
|
||||
const squash = 1 - (1 - visualStack.value.depth) * 0.06
|
||||
return {
|
||||
width: pile.value.width,
|
||||
height: Math.round(pile.value.height * squash),
|
||||
}
|
||||
})
|
||||
|
||||
const stackBox = computed(() => {
|
||||
const { depth, layers } = visualStack.value
|
||||
const maxOffset = Math.max(0, layers - 1) * stepPx.value * depth
|
||||
const heightTrim = (1 - depth) * (props.large ? 18 : 12)
|
||||
return {
|
||||
width: pile.value.stackWidth,
|
||||
height: Math.round(pile.value.stackHeight - heightTrim + maxOffset),
|
||||
}
|
||||
})
|
||||
|
||||
const deckBackCard: Card = { id: 'deck-back', color: 'wild', value: '0' }
|
||||
|
||||
function layerStyle(layerIndex: number) {
|
||||
const { layers, depth } = visualStack.value
|
||||
const backIndex = layers - 1 - layerIndex
|
||||
const offset = backIndex * stepPx.value * depth
|
||||
return {
|
||||
top: `${offset}px`,
|
||||
left: `${offset}px`,
|
||||
zIndex: layerIndex,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="deck" :class="{ large, empty: visualStack.layers === 0 }">
|
||||
<div
|
||||
class="deck-stack"
|
||||
:style="{ width: `${stackBox.width}px`, height: `${stackBox.height}px` }"
|
||||
>
|
||||
<template v-if="visualStack.layers > 0">
|
||||
<div
|
||||
v-for="i in visualStack.layers"
|
||||
:key="i"
|
||||
class="deck-card"
|
||||
:style="layerStyle(i - 1)"
|
||||
>
|
||||
<CardView
|
||||
:card="deckBackCard"
|
||||
face-down
|
||||
:size="large ? '2xl' : 'md'"
|
||||
:dimensions="cardDimensions"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="deck-empty" :style="cardDimensions" />
|
||||
<span v-if="count != null" class="deck-count">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.deck {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.deck-stack {
|
||||
position: relative;
|
||||
transition: width 0.35s ease, height 0.35s ease;
|
||||
}
|
||||
|
||||
.deck-card {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transition: top 0.35s ease, left 0.35s ease;
|
||||
}
|
||||
|
||||
.deck-empty {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
border: 2px dashed #444;
|
||||
border-radius: 10px;
|
||||
background: rgba(26, 26, 46, 0.5);
|
||||
}
|
||||
|
||||
.deck.large .deck-empty {
|
||||
border-width: 3px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.deck-count {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 10;
|
||||
min-width: 2em;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
line-height: 1.2;
|
||||
transition: transform 0.35s ease;
|
||||
}
|
||||
|
||||
.deck.large .deck-count {
|
||||
font-size: 1.5rem;
|
||||
padding: 6px 14px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.deck.empty .deck-count {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Card, CardColor } from '@/games/onecard/types'
|
||||
import { PILE_CARD } from '@/components/cardTypes'
|
||||
import CardView from './CardView.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
topCard: Card | null
|
||||
activeColor?: CardColor | null
|
||||
large?: boolean
|
||||
showLabel?: boolean
|
||||
}>(), {
|
||||
large: false,
|
||||
showLabel: false,
|
||||
})
|
||||
|
||||
const pile = computed(() => (props.large ? PILE_CARD.lg : PILE_CARD.md))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="discard">
|
||||
<span v-if="showLabel" class="label">버린 더미</span>
|
||||
<div
|
||||
class="discard-slot"
|
||||
:class="{ large }"
|
||||
:style="{ width: `${pile.stackWidth}px`, height: `${pile.stackHeight}px` }"
|
||||
>
|
||||
<CardView
|
||||
v-if="topCard"
|
||||
:card="topCard"
|
||||
:active-color="activeColor"
|
||||
:dimensions="{ width: pile.width, height: pile.height }"
|
||||
/>
|
||||
<div v-else class="empty-pile" :class="{ large }">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.discard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.discard-slot {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.discard-slot :deep(.card) {
|
||||
border-width: 2px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.discard-slot.large :deep(.card) {
|
||||
border-width: 3px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.empty-pile {
|
||||
width: 72px;
|
||||
height: 104px;
|
||||
border: 2px dashed #444;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.empty-pile.large {
|
||||
width: 140px;
|
||||
height: 210px;
|
||||
border-radius: 12px;
|
||||
border-width: 3px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { usePeerRoom } from '@/composables/usePeerRoom'
|
||||
import { clearSession } from '@/utils/session'
|
||||
|
||||
const router = useRouter()
|
||||
const { destroy } = usePeerRoom()
|
||||
|
||||
function leave() {
|
||||
destroy()
|
||||
clearSession()
|
||||
router.push('/onecard/lobby')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button type="button" class="exit-btn" @click="leave">
|
||||
나가기
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.exit-btn {
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #555;
|
||||
background: rgba(42, 42, 74, 0.9);
|
||||
color: #ccc;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.exit-btn:active {
|
||||
background: #3d3560;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,307 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import type { Card, CardColor } from '@/games/onecard/types'
|
||||
import { getPlayableCards } from '@/games/onecard/rules'
|
||||
import CardView from './CardView.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
cards: Card[]
|
||||
topCard?: Card | null
|
||||
activeColor?: CardColor | null
|
||||
drawPenalty?: number
|
||||
canInteract?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
play: [cardId: string]
|
||||
'request-color': [cardId: string]
|
||||
'selection-change': [cardId: string | null]
|
||||
}>()
|
||||
|
||||
const selectedId = ref<string | null>(null)
|
||||
const fanRef = ref<HTMLElement | null>(null)
|
||||
const fanWidthPx = ref(360)
|
||||
const cardHeightPx = ref(120)
|
||||
const cardWidthPx = ref(80)
|
||||
const playDragThreshold = ref(72)
|
||||
|
||||
const dragCardId = ref<string | null>(null)
|
||||
const dragStartY = ref(0)
|
||||
const dragStartX = ref(0)
|
||||
const dragCurrentY = ref(0)
|
||||
const dragCurrentX = ref(0)
|
||||
const isDragging = ref(false)
|
||||
const justSelected = ref(false)
|
||||
|
||||
const cardDimensions = computed(() => ({
|
||||
width: cardWidthPx.value,
|
||||
height: cardHeightPx.value,
|
||||
}))
|
||||
|
||||
const fanHeightPx = computed(() => Math.round(cardHeightPx.value * 1.15 + 48))
|
||||
|
||||
const playableIds = computed(() => {
|
||||
if (!props.topCard || !props.canInteract) return new Set<string>()
|
||||
return new Set(
|
||||
getPlayableCards(
|
||||
props.cards,
|
||||
props.topCard,
|
||||
props.activeColor ?? null,
|
||||
props.drawPenalty ?? 0,
|
||||
).map((c) => c.id),
|
||||
)
|
||||
})
|
||||
|
||||
const cardSpacing = computed(() => {
|
||||
const n = props.cards.length
|
||||
if (n <= 1) return 0
|
||||
const cardW = cardWidthPx.value
|
||||
return Math.min(cardW * 0.55, Math.max(24, (fanWidthPx.value - cardW) / (n - 1)))
|
||||
})
|
||||
|
||||
function updateLayout() {
|
||||
const vh = window.innerHeight
|
||||
const vw = window.innerWidth
|
||||
|
||||
const maxCardH = vh * 0.5
|
||||
const minCardH = vh < 400 ? 96 : 108
|
||||
const targetH = Math.max(minCardH, Math.min(maxCardH, vh * 0.48))
|
||||
cardHeightPx.value = Math.round(targetH)
|
||||
cardWidthPx.value = Math.round(targetH * (2 / 3))
|
||||
playDragThreshold.value = Math.round(targetH * 0.34)
|
||||
|
||||
if (fanRef.value) {
|
||||
fanWidthPx.value = fanRef.value.clientWidth
|
||||
} else {
|
||||
fanWidthPx.value = Math.min(vw * 0.98, Math.max(360, props.cards.length * cardWidthPx.value * 0.45 + 120))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateLayout()
|
||||
window.addEventListener('resize', updateLayout)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updateLayout)
|
||||
})
|
||||
|
||||
watch(selectedId, (cardId) => {
|
||||
emit('selection-change', cardId)
|
||||
})
|
||||
|
||||
function getFanStyle(index: number, total: number) {
|
||||
const card = props.cards[index]
|
||||
if (!card) return {}
|
||||
|
||||
const maxSpread = Math.min(16, 3 + total * 0.5)
|
||||
const step = total > 1 ? maxSpread / (total - 1) : 0
|
||||
const angle = total > 1 ? -maxSpread / 2 + step * index : 0
|
||||
const offset = total > 1 ? (index - (total - 1) / 2) * cardSpacing.value : 0
|
||||
|
||||
const isSelected = selectedId.value === card.id
|
||||
const isActiveDrag = dragCardId.value === card.id
|
||||
|
||||
if (isSelected) {
|
||||
const dragDeltaY = isActiveDrag ? dragStartY.value - dragCurrentY.value : 0
|
||||
const dragDeltaX = isActiveDrag ? dragCurrentX.value - dragStartX.value : 0
|
||||
const lift = -cardHeightPx.value * 0.42 - Math.max(0, dragDeltaY)
|
||||
const scale = isDragging.value ? 1.12 : 1.08
|
||||
|
||||
return {
|
||||
transform: `translateX(${offset + dragDeltaX}px) translateY(${lift}px) rotate(0deg) scale(${scale})`,
|
||||
zIndex: 200,
|
||||
transition: isDragging.value ? 'none' : 'transform 0.25s ease',
|
||||
marginLeft: `-${cardWidthPx.value / 2}px`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
transform: `translateX(${offset}px) translateY(0) rotate(${angle}deg) scale(1)`,
|
||||
zIndex: index,
|
||||
transition: 'transform 0.25s ease',
|
||||
marginLeft: `-${cardWidthPx.value / 2}px`,
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent, card: Card) {
|
||||
if (!props.canInteract) return
|
||||
|
||||
if (selectedId.value !== card.id) {
|
||||
selectedId.value = card.id
|
||||
justSelected.value = true
|
||||
return
|
||||
}
|
||||
|
||||
dragCardId.value = card.id
|
||||
dragStartY.value = e.clientY
|
||||
dragStartX.value = e.clientX
|
||||
dragCurrentY.value = e.clientY
|
||||
dragCurrentX.value = e.clientX
|
||||
isDragging.value = false
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (!dragCardId.value) return
|
||||
dragCurrentY.value = e.clientY
|
||||
dragCurrentX.value = e.clientX
|
||||
if (Math.abs(dragStartY.value - e.clientY) > 6) {
|
||||
isDragging.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerUp(card: Card) {
|
||||
if (justSelected.value && selectedId.value === card.id && !dragCardId.value) {
|
||||
justSelected.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (dragCardId.value !== card.id) return
|
||||
|
||||
const deltaY = dragStartY.value - dragCurrentY.value
|
||||
|
||||
if (isDragging.value && deltaY >= playDragThreshold.value) {
|
||||
if (playableIds.value.has(card.id)) {
|
||||
if (card.color === 'wild' || card.value === 'wild' || card.value === 'wild4') {
|
||||
emit('request-color', card.id)
|
||||
} else {
|
||||
emit('play', card.id)
|
||||
}
|
||||
selectedId.value = null
|
||||
}
|
||||
} else if (!isDragging.value) {
|
||||
selectedId.value = null
|
||||
}
|
||||
|
||||
justSelected.value = false
|
||||
dragCardId.value = null
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
if (isDragging.value) return
|
||||
selectedId.value = null
|
||||
dragCardId.value = null
|
||||
justSelected.value = false
|
||||
}
|
||||
|
||||
defineExpose({ clearSelection })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="hand-container">
|
||||
<div
|
||||
v-if="selectedId"
|
||||
class="backdrop"
|
||||
@click="clearSelection"
|
||||
@pointerdown.prevent="clearSelection"
|
||||
/>
|
||||
|
||||
<p v-if="selectedId && playableIds.has(selectedId)" class="hint">
|
||||
↑ 위로 드래그하여 내기
|
||||
</p>
|
||||
<p v-else-if="selectedId" class="hint muted">
|
||||
낼 수 없는 카드 — 탭하여 접기
|
||||
</p>
|
||||
|
||||
<div
|
||||
ref="fanRef"
|
||||
class="hand-fan"
|
||||
:style="{ width: 'min(98vw, 960px)', height: `${fanHeightPx}px` }"
|
||||
>
|
||||
<div
|
||||
v-for="(card, index) in cards"
|
||||
:key="card.id"
|
||||
class="card-slot"
|
||||
:class="{ selected: selectedId === card.id, dragging: dragCardId === card.id && isDragging }"
|
||||
:style="getFanStyle(index, cards.length)"
|
||||
@pointerdown="handlePointerDown($event, card)"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp(card)"
|
||||
@pointercancel="handlePointerUp(card)"
|
||||
>
|
||||
<CardView
|
||||
:card="card"
|
||||
:active-color="activeColor"
|
||||
:selected="selectedId === card.id"
|
||||
:playable="playableIds.has(card.id)"
|
||||
:disabled="!canInteract"
|
||||
:dimensions="cardDimensions"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="cards.length === 0" class="empty">패가 비어 있습니다</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hand-container {
|
||||
width: 100%;
|
||||
max-height: 50vh;
|
||||
max-height: 50dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 1vw;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.hint {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(min(50vh, 50dvh) + min(24vh, 24dvh) + 12px);
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.85rem;
|
||||
color: #f1c40f;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
background: rgba(26, 26, 46, 0.9);
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hint.muted {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.hand-fan {
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
z-index: 60;
|
||||
}
|
||||
|
||||
.card-slot {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
transform-origin: bottom center;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.card-slot.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.card-slot:not(.selected) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { usePeerRoom } from '@/composables/usePeerRoom'
|
||||
import { clearSession } from '@/utils/session'
|
||||
|
||||
const router = useRouter()
|
||||
const { destroy } = usePeerRoom()
|
||||
|
||||
function goHome() {
|
||||
destroy()
|
||||
clearSession()
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button type="button" class="home-btn" @click="goHome">
|
||||
홈으로
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-btn {
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #555;
|
||||
background: rgba(42, 42, 74, 0.9);
|
||||
color: #ccc;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-btn:active {
|
||||
background: #3d3560;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,240 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { CardViewSize } from '@/components/cardTypes'
|
||||
import CardView from './CardView.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
count: number
|
||||
position: 'top' | 'bottom'
|
||||
isTurn?: boolean
|
||||
large?: boolean
|
||||
compact?: boolean
|
||||
previewIndex?: number | null
|
||||
}>(), {
|
||||
isTurn: false,
|
||||
large: false,
|
||||
compact: false,
|
||||
previewIndex: null,
|
||||
})
|
||||
|
||||
const isHovered = ref(false)
|
||||
|
||||
const visibleCount = computed(() => Math.min(Math.max(props.count, 0), 14))
|
||||
|
||||
const cardSize = computed((): CardViewSize => (props.large ? '2xl' : 'md'))
|
||||
const cardHalf = computed(() => (props.large ? 70 : 36))
|
||||
const cardHeight = computed(() => (props.large ? 210 : 120))
|
||||
const cardSpacing = computed(() => (props.large ? 28 : 18))
|
||||
const previewLift = computed(() => Math.round(cardHeight.value * 0.42))
|
||||
const maxSpread = computed(() => {
|
||||
const total = visibleCount.value
|
||||
if (props.large) return Math.min(42, 8 + total * 2.2)
|
||||
return Math.min(48, 10 + total * 3)
|
||||
})
|
||||
|
||||
/** 상단 플레이어 fan은 좌우 반전 배치 → 강조는 패 인덱스 그대로 매칭 */
|
||||
function isPreviewSlot(slotIndex: number) {
|
||||
if (props.previewIndex == null) return false
|
||||
return slotIndex === props.previewIndex
|
||||
}
|
||||
|
||||
function slotOffset(slotIndex: number, total: number) {
|
||||
const raw = (slotIndex - (total - 1) / 2) * cardSpacing.value
|
||||
return props.position === 'top' ? -raw : raw
|
||||
}
|
||||
|
||||
function cardStyle(index: number) {
|
||||
const total = visibleCount.value
|
||||
if (total <= 0) return { display: 'none' }
|
||||
|
||||
const spread = maxSpread.value
|
||||
const step = total > 1 ? spread / (total - 1) : 0
|
||||
const angle = total > 1
|
||||
? (props.position === 'top'
|
||||
? spread / 2 - step * index
|
||||
: -spread / 2 + step * index)
|
||||
: 0
|
||||
const offset = slotOffset(index, total)
|
||||
|
||||
const isPreview = isPreviewSlot(index)
|
||||
|
||||
if (isPreview) {
|
||||
const lift = props.position === 'top' ? previewLift.value : -previewLift.value
|
||||
const previewRotate = props.position === 'top' ? 180 : 0
|
||||
|
||||
return {
|
||||
transform: `translateX(${offset}px) translateY(${lift}px) rotate(${previewRotate}deg) scale(1.08)`,
|
||||
zIndex: 300,
|
||||
'--delay': `${index * 0.05}s`,
|
||||
}
|
||||
}
|
||||
|
||||
const hoverSpread = isHovered.value ? 1.35 : 1
|
||||
const adjustedAngle = angle * hoverSpread
|
||||
const adjustedOffset = slotOffset(index, total) * hoverSpread
|
||||
const hoverLift = isHovered.value ? (props.large ? 20 : 14) : 0
|
||||
|
||||
if (props.position === 'top') {
|
||||
return {
|
||||
transform: `translateX(${adjustedOffset}px) translateY(${hoverLift}px) rotate(${180 - adjustedAngle}deg)`,
|
||||
zIndex: index,
|
||||
'--delay': `${index * 0.05}s`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
transform: `translateX(${adjustedOffset}px) translateY(${-hoverLift}px) rotate(${adjustedAngle}deg)`,
|
||||
zIndex: index,
|
||||
'--delay': `${index * 0.05}s`,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="opponent-zone"
|
||||
:class="[position, { 'is-turn': isTurn, hovered: isHovered, large, compact }]"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
@touchstart.passive="isHovered = true"
|
||||
@touchend="isHovered = false"
|
||||
>
|
||||
<div class="fan" :class="{ empty: count === 0 }">
|
||||
<div
|
||||
v-for="i in visibleCount"
|
||||
:key="i"
|
||||
class="card-slot"
|
||||
:class="{ 'is-preview': isPreviewSlot(i - 1) }"
|
||||
:style="[cardStyle(i - 1), { marginLeft: `-${cardHalf}px` }]"
|
||||
>
|
||||
<div class="card-float">
|
||||
<CardView
|
||||
:card="{ id: `back-${i}`, color: 'wild', value: '0' }"
|
||||
face-down
|
||||
:selected="isPreviewSlot(i - 1)"
|
||||
:size="cardSize"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="count === 0" class="empty-hand">패 없음</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.opponent-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
transition: filter 0.2s;
|
||||
}
|
||||
|
||||
.opponent-zone.large {
|
||||
padding: 12px 24px;
|
||||
}
|
||||
|
||||
.opponent-zone.compact {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.opponent-zone.compact.large {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.opponent-zone.top {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.opponent-zone.bottom {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.opponent-zone.is-turn {
|
||||
filter: drop-shadow(0 0 16px rgba(241, 196, 15, 0.5));
|
||||
}
|
||||
|
||||
.fan {
|
||||
position: relative;
|
||||
width: min(100%, 420px);
|
||||
height: 120px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.opponent-zone.large .fan {
|
||||
width: min(92vw, 760px);
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.opponent-zone.top .fan {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.opponent-zone.bottom .fan {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.opponent-zone.top .card-slot {
|
||||
top: 0;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.opponent-zone.bottom .card-slot {
|
||||
bottom: 0;
|
||||
transform-origin: bottom center;
|
||||
}
|
||||
|
||||
.card-slot {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.card-slot.is-preview {
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
.card-slot .card-float {
|
||||
animation: cardBob 3s ease-in-out infinite;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
.opponent-zone.top .card-slot .card-float {
|
||||
animation-name: cardBobTop;
|
||||
}
|
||||
|
||||
.opponent-zone.bottom .card-slot .card-float {
|
||||
animation-name: cardBobBottom;
|
||||
}
|
||||
|
||||
.opponent-zone.hovered .card-slot:not(.is-preview) .card-float {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.card-slot.is-preview .card-float {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes cardBobTop {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(5px); }
|
||||
}
|
||||
|
||||
@keyframes cardBobBottom {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(5px); }
|
||||
}
|
||||
|
||||
.empty-hand {
|
||||
color: #555;
|
||||
font-size: 0.8rem;
|
||||
align-self: center;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.opponent-zone.large .empty-hand {
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
players: { id: string; name: string; handCount?: number }[]
|
||||
selectedId: string | null
|
||||
currentTurnId?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [playerId: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="draw-buttons">
|
||||
<button
|
||||
v-for="player in players"
|
||||
:key="player.id"
|
||||
class="player-btn"
|
||||
:class="{
|
||||
selected: selectedId === player.id,
|
||||
'is-turn': currentTurnId === player.id,
|
||||
}"
|
||||
@click="emit('select', player.id)"
|
||||
>
|
||||
<span class="player-name">{{ player.name }}</span>
|
||||
<span v-if="player.handCount !== undefined" class="hand-count">
|
||||
{{ player.handCount }}장
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.draw-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.player-btn {
|
||||
padding: 12px 20px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid #444;
|
||||
background: #2a2a4a;
|
||||
color: #eee;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 160px;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.player-btn.selected {
|
||||
border-color: #6c5ce7;
|
||||
background: #3d3560;
|
||||
}
|
||||
|
||||
.player-btn.is-turn {
|
||||
border-color: #f1c40f;
|
||||
}
|
||||
|
||||
.player-btn.selected.is-turn {
|
||||
border-color: #a29bfe;
|
||||
box-shadow: 0 0 0 2px #f1c40f;
|
||||
}
|
||||
|
||||
.player-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hand-count {
|
||||
font-size: 0.85rem;
|
||||
color: #aaa;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
players: { id: string; name: string; handCount?: number }[]
|
||||
currentTurnId?: string | null
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="player-list">
|
||||
<li
|
||||
v-for="player in players"
|
||||
:key="player.id"
|
||||
class="player-item"
|
||||
:class="{ active: currentTurnId === player.id }"
|
||||
>
|
||||
<span class="name">{{ player.name }}</span>
|
||||
<span v-if="player.handCount !== undefined" class="count">{{ player.handCount }}장</span>
|
||||
<span v-if="currentTurnId === player.id" class="turn-badge">턴</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.player-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.player-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: #2a2a4a;
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.player-item.active {
|
||||
border-color: #f1c40f;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: #aaa;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.turn-badge {
|
||||
background: #f1c40f;
|
||||
color: #1a1a2e;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
code: string
|
||||
compact?: boolean
|
||||
}>(), {
|
||||
compact: false,
|
||||
})
|
||||
|
||||
const status = ref<'idle' | 'copied' | 'failed'>('idle')
|
||||
|
||||
async function handleCopy() {
|
||||
if (!props.code) return
|
||||
const ok = await copyToClipboard(props.code)
|
||||
status.value = ok ? 'copied' : 'failed'
|
||||
setTimeout(() => { status.value = 'idle' }, 2000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="room-code" :class="{ compact }">
|
||||
<div class="code-display">
|
||||
<span v-if="!compact" class="label">방 코드</span>
|
||||
<span class="code-text">{{ code }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="copy-btn"
|
||||
:class="{ copied: status === 'copied', failed: status === 'failed' }"
|
||||
:disabled="!code"
|
||||
@click="handleCopy"
|
||||
>
|
||||
{{ status === 'copied' ? '복사됨!' : status === 'failed' ? '실패' : '복사' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.room-code {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.room-code.compact {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 300;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px 6px 10px;
|
||||
background: rgba(42, 42, 74, 0.85);
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.code-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.compact .code-display {
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.code-text {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.2em;
|
||||
color: #a29bfe;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.compact .code-text {
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #555;
|
||||
background: rgba(42, 42, 74, 0.9);
|
||||
color: #ccc;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.compact .copy-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.copy-btn:active:not(:disabled) {
|
||||
background: #3d3560;
|
||||
}
|
||||
|
||||
.copy-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.copy-btn.copied {
|
||||
border-color: #27ae60;
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.copy-btn.failed {
|
||||
border-color: #e74c3c;
|
||||
color: #e74c3c;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
undo: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="undo-btn"
|
||||
:disabled="disabled"
|
||||
@click="emit('undo')"
|
||||
>
|
||||
↩ 되돌리기
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.undo-btn {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
padding: 12px 18px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid #555;
|
||||
background: #2a2a4a;
|
||||
color: #eee;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
z-index: 100;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.undo-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.undo-btn:not(:disabled):active {
|
||||
background: #3d3560;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import type { CardColor } from '@/games/onecard/types'
|
||||
import { COLOR_STYLES } from '@/games/onecard/cardDisplay'
|
||||
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [color: CardColor]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const colors: CardColor[] = ['red', 'yellow', 'green', 'blue']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="overlay" @click.self="emit('cancel')">
|
||||
<div class="picker">
|
||||
<h3>색상 선택</h3>
|
||||
<p class="desc">와일드 카드 색상을 고르세요</p>
|
||||
<div class="colors">
|
||||
<button
|
||||
v-for="color in colors"
|
||||
:key="color"
|
||||
type="button"
|
||||
class="color-btn"
|
||||
:style="{ background: COLOR_STYLES[color].bg, color: COLOR_STYLES[color].text }"
|
||||
@click="emit('select', color)"
|
||||
>
|
||||
{{ COLOR_STYLES[color].label }}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="cancel-btn" @click="emit('cancel')">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 500;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.picker {
|
||||
background: #2a2a4a;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.picker h3 {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.colors {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.color-btn {
|
||||
padding: 16px;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.color-btn:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
export type CardViewSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||
|
||||
/** 보드판 중앙 덱·버린 더미 공통 카드 크기 */
|
||||
export const PILE_CARD = {
|
||||
md: { width: 72, height: 104, stackWidth: 80, stackHeight: 110 },
|
||||
lg: { width: 140, height: 210, stackWidth: 152, stackHeight: 224 },
|
||||
} as const
|
||||
Reference in new issue
Block a user