first commit

This commit is contained in:
Sejong Yoo committed 2026-06-22 14:56:46 +09:00
commit 344a047ab9
47 files changed
+8648

No files matched your search

+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>