first commit

This commit is contained in:
ysj7705 committed 2026-06-22 15:17:05 +09:00
commit 8bf1d1b928
47 files changed
+8648

No files matched your search

+312
View File
@@ -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>
+360
View File
@@ -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>
+291
View File
@@ -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>
+160
View File
@@ -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>
+88
View File
@@ -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>
+39
View File
@@ -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>
+307
View File
@@ -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>
+39
View File
@@ -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>
+240
View File
@@ -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>
+79
View File
@@ -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>
+63
View File
@@ -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>
+135
View File
@@ -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>
+47
View File
@@ -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>
+100
View File
@@ -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>
+7
View File
@@ -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