first commit
This commit is contained in:
commit
8bf1d1b928
47 files changed
+8648
No files matched your search
@@ -0,0 +1,4 @@
|
||||
# Optional: custom TURN server (comma-separated URLs)
|
||||
# VITE_TURN_URLS=turn:your.server:3478,turn:your.server:3478?transport=tcp
|
||||
# VITE_TURN_USERNAME=your-username
|
||||
# VITE_TURN_CREDENTIAL=your-credential
|
||||
@@ -0,0 +1,4 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
@@ -0,0 +1,42 @@
|
||||
# tabletop-web
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
@@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_TURN_URLS?: string
|
||||
readonly VITE_TURN_USERNAME?: string
|
||||
readonly VITE_TURN_CREDENTIAL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>Tabletop - 보드게임 시뮬레이터</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3268
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "tabletop-web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build"
|
||||
},
|
||||
"dependencies": {
|
||||
"peerjs": "^1.5.5",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.38",
|
||||
"vue-router": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/node": "^24.13.2",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"npm-run-all2": "^9.0.2",
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.0.16",
|
||||
"vite-plugin-vue-devtools": "^8.1.2",
|
||||
"vue-tsc": "^3.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.12.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
+62
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
overflow: hidden;
|
||||
touch-action: manipulation;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* 입력·복사가 필요한 곳만 텍스트 선택 허용 */
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
[contenteditable='true'],
|
||||
.selectable {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
-webkit-user-drag: none;
|
||||
user-drag: none;
|
||||
}
|
||||
|
||||
button,
|
||||
a,
|
||||
[role='button'] {
|
||||
touch-action: manipulation;
|
||||
cursor: pointer;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -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
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Card, CardColor, PublicGameState, RoomRole } from '@/games/onecard/types'
|
||||
|
||||
export type RoomMessage =
|
||||
| { type: 'join'; role: RoomRole; playerId: string; name: string }
|
||||
| { type: 'joined'; playerId: string; players: { id: string; name: string }[] }
|
||||
| { type: 'players-update'; players: { id: string; name: string }[] }
|
||||
| { type: 'reject'; reason: string }
|
||||
| { type: 'player-left'; playerId: string; players: { id: string; name: string }[] }
|
||||
| { type: 'game-start'; turnOrder: string[] }
|
||||
| { type: 'hand-update'; hand: Card[] }
|
||||
| { type: 'state-update'; state: PublicGameState }
|
||||
| { type: 'play-card'; playerId: string; cardId: string; chosenColor?: CardColor }
|
||||
| { type: 'request-draw'; playerId: string }
|
||||
| { type: 'declare-onecard'; playerId: string }
|
||||
| { type: 'hand-preview'; playerId: string; cardId: string | null }
|
||||
| { type: 'play-rejected'; reason: string }
|
||||
| { type: 'ping' }
|
||||
|
||||
export interface PeerConnection {
|
||||
open: boolean
|
||||
send: (data: unknown) => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export interface ConnectedPlayer {
|
||||
id: string
|
||||
name: string
|
||||
connection: PeerConnection
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
import { ref } from 'vue'
|
||||
import Peer, { type DataConnection } from 'peerjs'
|
||||
import type { CardColor, GameState } from '@/games/onecard/types'
|
||||
import { createGame, drawCardToPlayer, playCard, playerDrawCards, declareOneCard, toPublicState, undoLastAction, continueAfterRoundWin } from '@/games/onecard/game'
|
||||
import { createPeerOptions } from '@/config/peer'
|
||||
import {
|
||||
autoAssignSeats,
|
||||
loadSeats,
|
||||
turnOrderFromSeats,
|
||||
MAX_PLAYERS,
|
||||
type SeatMap,
|
||||
} from '@/utils/boardLayout'
|
||||
import { useRoomStore } from '@/stores/roomStore'
|
||||
import type { ConnectedPlayer, PeerConnection, RoomMessage } from './messages'
|
||||
|
||||
const CONNECTION_TIMEOUT_MS = 25_000
|
||||
|
||||
const peer = ref<Peer | null>(null)
|
||||
const gameState = ref<GameState | null>(null)
|
||||
|
||||
let pendingJoinResolve: (() => void) | null = null
|
||||
let pendingJoinReject: ((err: Error) => void) | null = null
|
||||
let connectionTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearJoinPending() {
|
||||
if (connectionTimeout) {
|
||||
clearTimeout(connectionTimeout)
|
||||
connectionTimeout = null
|
||||
}
|
||||
pendingJoinResolve = null
|
||||
pendingJoinReject = null
|
||||
}
|
||||
|
||||
function send(conn: PeerConnection, msg: RoomMessage) {
|
||||
if (conn.open) {
|
||||
conn.send(msg)
|
||||
}
|
||||
}
|
||||
|
||||
function broadcast(msg: RoomMessage, excludeId?: string) {
|
||||
const store = useRoomStore()
|
||||
for (const p of store.connectedPlayers) {
|
||||
if (p.id !== excludeId) {
|
||||
send(p.connection, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncAllHands() {
|
||||
const store = useRoomStore()
|
||||
if (!gameState.value) return
|
||||
for (const p of store.connectedPlayers) {
|
||||
const hand = gameState.value.hands[p.id] ?? []
|
||||
send(p.connection, { type: 'hand-update', hand })
|
||||
}
|
||||
store.publicState = toPublicState(gameState.value)
|
||||
broadcast({ type: 'state-update', state: store.publicState })
|
||||
}
|
||||
|
||||
function resolveJoin() {
|
||||
const resolve = pendingJoinResolve
|
||||
if (!resolve) return
|
||||
clearJoinPending()
|
||||
resolve()
|
||||
}
|
||||
|
||||
function rejectJoin(message: string) {
|
||||
const reject = pendingJoinReject
|
||||
if (!reject) return
|
||||
clearJoinPending()
|
||||
reject(new Error(message))
|
||||
}
|
||||
|
||||
function handleClientMessage(msg: RoomMessage, conn: PeerConnection) {
|
||||
const store = useRoomStore()
|
||||
switch (msg.type) {
|
||||
case 'joined':
|
||||
store.players = msg.players
|
||||
store.connectedPlayers = [{
|
||||
id: 'board',
|
||||
name: '보드판',
|
||||
connection: conn,
|
||||
}]
|
||||
resolveJoin()
|
||||
break
|
||||
case 'players-update':
|
||||
store.players = msg.players
|
||||
break
|
||||
case 'reject':
|
||||
store.connectionError = msg.reason
|
||||
rejectJoin(msg.reason)
|
||||
break
|
||||
case 'player-left':
|
||||
store.players = msg.players
|
||||
break
|
||||
case 'game-start':
|
||||
store.gameStarted = true
|
||||
break
|
||||
case 'hand-update':
|
||||
store.myHand = msg.hand
|
||||
break
|
||||
case 'state-update':
|
||||
store.publicState = msg.state
|
||||
break
|
||||
case 'play-rejected':
|
||||
store.setStatus(msg.reason)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function handleHostPlayerMessage(msg: RoomMessage, conn: PeerConnection) {
|
||||
if (msg.type === 'hand-preview') {
|
||||
useRoomStore().setPlayerHandPreview(msg.playerId, msg.cardId)
|
||||
return
|
||||
}
|
||||
|
||||
if (!gameState.value) return
|
||||
|
||||
switch (msg.type) {
|
||||
case 'play-card': {
|
||||
const result = playCard(gameState.value, msg.playerId, msg.cardId, msg.chosenColor)
|
||||
if (!result.ok) {
|
||||
send(conn, { type: 'play-rejected', reason: result.reason })
|
||||
return
|
||||
}
|
||||
gameState.value = result.state
|
||||
useRoomStore().clearPlayerHandPreview(msg.playerId)
|
||||
syncAllHands()
|
||||
break
|
||||
}
|
||||
case 'request-draw': {
|
||||
const result = playerDrawCards(gameState.value, msg.playerId)
|
||||
if (!result.ok) {
|
||||
send(conn, { type: 'play-rejected', reason: result.reason })
|
||||
return
|
||||
}
|
||||
gameState.value = result.state
|
||||
syncAllHands()
|
||||
break
|
||||
}
|
||||
case 'declare-onecard': {
|
||||
gameState.value = declareOneCard(gameState.value, msg.playerId)
|
||||
const store = useRoomStore()
|
||||
store.publicState = toPublicState(gameState.value)
|
||||
broadcast({ type: 'state-update', state: store.publicState })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setupConnectionHandler(conn: DataConnection) {
|
||||
const peerConn = conn as unknown as PeerConnection
|
||||
|
||||
conn.on('open', () => {
|
||||
const store = useRoomStore()
|
||||
|
||||
conn.on('data', (data) => {
|
||||
const msg = data as RoomMessage
|
||||
if (msg.type === 'join' && msg.role === 'player') {
|
||||
const existing = store.connectedPlayers.find((cp) => cp.id === msg.playerId)
|
||||
if (existing) return
|
||||
|
||||
if (store.connectedPlayers.length >= MAX_PLAYERS) {
|
||||
send(peerConn, { type: 'reject', reason: '방이 가득 찼습니다 (최대 4명)' })
|
||||
return
|
||||
}
|
||||
|
||||
const newPlayer: ConnectedPlayer = {
|
||||
id: msg.playerId,
|
||||
name: msg.name,
|
||||
connection: peerConn,
|
||||
}
|
||||
store.connectedPlayers.push(newPlayer)
|
||||
store.players = store.connectedPlayers.map((cp) => ({
|
||||
id: cp.id,
|
||||
name: cp.name,
|
||||
}))
|
||||
|
||||
send(peerConn, {
|
||||
type: 'joined',
|
||||
playerId: msg.playerId,
|
||||
players: store.players,
|
||||
})
|
||||
broadcast(
|
||||
{ type: 'players-update', players: store.players },
|
||||
msg.playerId,
|
||||
)
|
||||
} else if (
|
||||
msg.type === 'play-card'
|
||||
|| msg.type === 'request-draw'
|
||||
|| msg.type === 'declare-onecard'
|
||||
|| msg.type === 'hand-preview'
|
||||
) {
|
||||
handleHostPlayerMessage(msg, peerConn)
|
||||
}
|
||||
})
|
||||
|
||||
conn.on('close', () => {
|
||||
const store = useRoomStore()
|
||||
const left = store.connectedPlayers.find((cp) => cp.connection === peerConn)
|
||||
if (!left) return
|
||||
store.connectedPlayers = store.connectedPlayers.filter((cp) => cp.id !== left.id)
|
||||
store.players = store.connectedPlayers.map((cp) => ({
|
||||
id: cp.id,
|
||||
name: cp.name,
|
||||
}))
|
||||
store.clearPlayerHandPreview(left.id)
|
||||
broadcast({ type: 'player-left', playerId: left.id, players: store.players })
|
||||
if (gameState.value) {
|
||||
gameState.value.phase = 'paused'
|
||||
syncAllHands()
|
||||
}
|
||||
})
|
||||
|
||||
conn.on('error', () => {
|
||||
rejectJoin('WebRTC 연결에 실패했습니다. 네트워크를 확인해주세요.')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function waitForPeerOpen(p: Peer): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (p.open) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
p.once('open', () => resolve())
|
||||
p.once('error', (err) => reject(err))
|
||||
})
|
||||
}
|
||||
|
||||
async function hostRoom(roomCode: string): Promise<void> {
|
||||
const store = useRoomStore()
|
||||
if (peer.value && !peer.value.destroyed) {
|
||||
store.isConnected = true
|
||||
return
|
||||
}
|
||||
|
||||
const p = new Peer(roomCode, createPeerOptions())
|
||||
peer.value = p
|
||||
p.on('connection', setupConnectionHandler)
|
||||
|
||||
try {
|
||||
await waitForPeerOpen(p)
|
||||
store.isConnected = true
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '방 생성에 실패했습니다.'
|
||||
if (message.includes('is taken') || message.includes('ID')) {
|
||||
store.connectionError = '방 코드가 이미 사용 중입니다. 다시 시도해주세요.'
|
||||
} else {
|
||||
store.connectionError = message
|
||||
}
|
||||
p.destroy()
|
||||
peer.value = null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function joinRoom(roomCode: string, pid: string, name: string): Promise<void> {
|
||||
const store = useRoomStore()
|
||||
if (peer.value && store.isConnected && store.players.length > 0) return
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingJoinResolve = resolve
|
||||
pendingJoinReject = reject
|
||||
|
||||
connectionTimeout = setTimeout(() => {
|
||||
store.connectionError = '연결 시간이 초과되었습니다. 보드판이 방을 열었는지 확인해주세요.'
|
||||
rejectJoin('연결 시간이 초과되었습니다.')
|
||||
}, CONNECTION_TIMEOUT_MS)
|
||||
|
||||
const p = new Peer(createPeerOptions())
|
||||
peer.value = p
|
||||
|
||||
p.on('error', (err) => {
|
||||
const msg = err.message
|
||||
if (msg.includes('Could not connect') || msg.includes('Lost connection')) {
|
||||
store.connectionError = '보드판을 찾을 수 없습니다. 방 코드를 확인해주세요.'
|
||||
} else {
|
||||
store.connectionError = msg
|
||||
}
|
||||
rejectJoin(msg)
|
||||
})
|
||||
|
||||
p.on('open', () => {
|
||||
const conn = p.connect(roomCode, { reliable: true })
|
||||
const peerConn = conn as unknown as PeerConnection
|
||||
|
||||
conn.on('open', () => {
|
||||
store.isConnected = true
|
||||
send(peerConn, { type: 'join', role: 'player', playerId: pid, name })
|
||||
})
|
||||
|
||||
conn.on('data', (data) => {
|
||||
handleClientMessage(data as RoomMessage, peerConn)
|
||||
})
|
||||
|
||||
conn.on('close', () => {
|
||||
store.isConnected = false
|
||||
store.connectionError = '보드판과의 연결이 끊어졌습니다.'
|
||||
})
|
||||
|
||||
conn.on('error', () => {
|
||||
store.connectionError = 'WebRTC 연결에 실패했습니다. 같은 Wi-Fi에 연결되어 있는지 확인해주세요.'
|
||||
rejectJoin('WebRTC ICE 연결 실패')
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function startGame(seats?: SeatMap) {
|
||||
const store = useRoomStore()
|
||||
if (!store.canStartGame) return
|
||||
beginGame(seats)
|
||||
}
|
||||
|
||||
function restartGame(seats?: SeatMap) {
|
||||
const store = useRoomStore()
|
||||
if (!store.isBoard || store.playerCount < 2) return
|
||||
beginGame(seats)
|
||||
}
|
||||
|
||||
function continueGame() {
|
||||
if (!gameState.value) return
|
||||
gameState.value = continueAfterRoundWin(gameState.value)
|
||||
syncAllHands()
|
||||
}
|
||||
|
||||
function beginGame(seats?: SeatMap) {
|
||||
const store = useRoomStore()
|
||||
|
||||
const playerIds = store.connectedPlayers.map((p) => p.id)
|
||||
const seatMap = seats ?? loadSeats() ?? autoAssignSeats(playerIds)
|
||||
const orderedIds = turnOrderFromSeats(seatMap)
|
||||
|
||||
if (orderedIds.length < 2) return
|
||||
|
||||
const playerNames: Record<string, string> = {}
|
||||
for (const p of store.connectedPlayers) {
|
||||
playerNames[p.id] = p.name
|
||||
}
|
||||
|
||||
gameState.value = createGame(orderedIds, playerNames)
|
||||
store.gameStarted = true
|
||||
store.playerHandPreviews = {}
|
||||
|
||||
broadcast({ type: 'game-start', turnOrder: gameState.value.turnOrder })
|
||||
syncAllHands()
|
||||
}
|
||||
|
||||
function drawToPlayer(targetPlayerId: string) {
|
||||
if (!gameState.value) return
|
||||
gameState.value = drawCardToPlayer(gameState.value, targetPlayerId)
|
||||
syncAllHands()
|
||||
}
|
||||
|
||||
function drawForPlayer(playerId: string): string | null {
|
||||
if (!gameState.value) return '게임이 시작되지 않았습니다.'
|
||||
if (gameState.value.phase !== 'playing') return '지금은 카드를 뽑을 수 없습니다.'
|
||||
if (!gameState.value.turnOrder.includes(playerId)) return '이미 패를 모두 낸 플레이어입니다.'
|
||||
|
||||
const currentId = gameState.value.turnOrder[gameState.value.currentTurnIndex]
|
||||
if (currentId === playerId) {
|
||||
const result = playerDrawCards(gameState.value, playerId)
|
||||
if (!result.ok) return result.reason
|
||||
gameState.value = result.state
|
||||
} else {
|
||||
gameState.value = drawCardToPlayer(gameState.value, playerId)
|
||||
}
|
||||
syncAllHands()
|
||||
return null
|
||||
}
|
||||
|
||||
function undo() {
|
||||
if (!gameState.value) return
|
||||
gameState.value = undoLastAction(gameState.value)
|
||||
syncAllHands()
|
||||
}
|
||||
|
||||
function getBoardConnection(): PeerConnection | null {
|
||||
const store = useRoomStore()
|
||||
return store.connectedPlayers[0]?.connection ?? null
|
||||
}
|
||||
|
||||
function sendToBoard(msg: RoomMessage) {
|
||||
const conn = getBoardConnection()
|
||||
if (conn) send(conn, msg)
|
||||
}
|
||||
|
||||
function sendPlayCard(cardId: string, chosenColor?: CardColor) {
|
||||
const store = useRoomStore()
|
||||
sendToBoard({
|
||||
type: 'play-card',
|
||||
playerId: store.playerId,
|
||||
cardId,
|
||||
chosenColor,
|
||||
})
|
||||
}
|
||||
|
||||
function sendRequestDraw() {
|
||||
const store = useRoomStore()
|
||||
sendToBoard({ type: 'request-draw', playerId: store.playerId })
|
||||
}
|
||||
|
||||
function sendDeclareOneCard() {
|
||||
const store = useRoomStore()
|
||||
sendToBoard({ type: 'declare-onecard', playerId: store.playerId })
|
||||
}
|
||||
|
||||
function sendHandPreview(cardId: string | null) {
|
||||
const store = useRoomStore()
|
||||
sendToBoard({ type: 'hand-preview', playerId: store.playerId, cardId })
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
const store = useRoomStore()
|
||||
clearJoinPending()
|
||||
for (const p of store.connectedPlayers) {
|
||||
p.connection.close()
|
||||
}
|
||||
peer.value?.destroy()
|
||||
peer.value = null
|
||||
gameState.value = null
|
||||
store.reset()
|
||||
}
|
||||
|
||||
export function usePeerRoom() {
|
||||
return {
|
||||
peer,
|
||||
gameState,
|
||||
hostRoom,
|
||||
joinRoom,
|
||||
startGame,
|
||||
restartGame,
|
||||
continueGame,
|
||||
drawToPlayer,
|
||||
drawForPlayer,
|
||||
undo,
|
||||
sendPlayCard,
|
||||
sendRequestDraw,
|
||||
sendDeclareOneCard,
|
||||
sendHandPreview,
|
||||
destroy,
|
||||
syncAllHands,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { PeerJSOption } from 'peerjs'
|
||||
|
||||
/**
|
||||
* PeerJS 기본 TURN(turn.peerjs.com)이 중단되어 ICE 연결이 실패합니다.
|
||||
* STUN + 공개 TURN 릴레이를 명시적으로 설정합니다.
|
||||
*
|
||||
* 자체 TURN 서버가 있으면 .env에 설정:
|
||||
* VITE_TURN_URLS=turn:your.server:3478
|
||||
* VITE_TURN_USERNAME=...
|
||||
* VITE_TURN_CREDENTIAL=...
|
||||
*/
|
||||
function buildIceServers(): RTCIceServer[] {
|
||||
const servers: RTCIceServer[] = [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
]
|
||||
|
||||
const customTurnUrls = import.meta.env.VITE_TURN_URLS
|
||||
if (customTurnUrls) {
|
||||
servers.push({
|
||||
urls: customTurnUrls.split(',').map((u: string) => u.trim()),
|
||||
username: import.meta.env.VITE_TURN_USERNAME,
|
||||
credential: import.meta.env.VITE_TURN_CREDENTIAL,
|
||||
})
|
||||
} else {
|
||||
const relay = {
|
||||
username: 'openrelayproject',
|
||||
credential: 'openrelayproject',
|
||||
}
|
||||
servers.push(
|
||||
{ urls: ['turn:openrelay.metered.ca:80', 'turn:openrelay.metered.ca:443'], ...relay },
|
||||
)
|
||||
}
|
||||
|
||||
return servers
|
||||
}
|
||||
|
||||
export function createPeerOptions(id?: string): PeerJSOption {
|
||||
return {
|
||||
...(id ? {} : {}),
|
||||
host: '0.peerjs.com',
|
||||
port: 443,
|
||||
path: '/',
|
||||
secure: true,
|
||||
debug: import.meta.env.DEV ? 1 : 0,
|
||||
config: {
|
||||
iceServers: buildIceServers(),
|
||||
iceCandidatePoolSize: 10,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Card, CardColor, CardValue } from './types'
|
||||
|
||||
export const COLOR_STYLES: Record<CardColor, { bg: string; text: string; label: string }> = {
|
||||
red: { bg: '#c0392b', text: '#fff', label: '빨강' },
|
||||
yellow: { bg: '#f1c40f', text: '#1a1a1a', label: '노랑' },
|
||||
green: { bg: '#27ae60', text: '#fff', label: '초록' },
|
||||
blue: { bg: '#2980b9', text: '#fff', label: '파랑' },
|
||||
wild: { bg: '#2c3e50', text: '#fff', label: '와일드' },
|
||||
}
|
||||
|
||||
export function getCardLabel(value: CardValue): string {
|
||||
const map: Record<string, string> = {
|
||||
skip: '⊘',
|
||||
reverse: '⇄',
|
||||
draw2: '+2',
|
||||
wild: '★',
|
||||
wild4: '+4',
|
||||
}
|
||||
return map[value] ?? value
|
||||
}
|
||||
|
||||
export function getCardDisplayColor(card: Card, activeColor: CardColor | null): CardColor {
|
||||
if (card.color !== 'wild') return card.color
|
||||
return activeColor ?? 'wild'
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Card, CardColor, CardValue } from './types'
|
||||
|
||||
const COLORS: CardColor[] = ['red', 'yellow', 'green', 'blue']
|
||||
const NUMBER_VALUES: CardValue[] = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
|
||||
const ACTION_VALUES: CardValue[] = ['skip', 'reverse', 'draw2']
|
||||
|
||||
let cardIdCounter = 0
|
||||
|
||||
function makeCard(color: CardColor, value: CardValue): Card {
|
||||
return { id: `card-${cardIdCounter++}`, color, value }
|
||||
}
|
||||
|
||||
export function createDeck(): Card[] {
|
||||
cardIdCounter = 0
|
||||
const deck: Card[] = []
|
||||
|
||||
for (const color of COLORS) {
|
||||
deck.push(makeCard(color, '0'))
|
||||
for (const value of NUMBER_VALUES.slice(1)) {
|
||||
deck.push(makeCard(color, value), makeCard(color, value))
|
||||
}
|
||||
for (const value of ACTION_VALUES) {
|
||||
deck.push(makeCard(color, value), makeCard(color, value))
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
deck.push(makeCard('wild', 'wild'), makeCard('wild', 'wild4'))
|
||||
}
|
||||
|
||||
return shuffle(deck)
|
||||
}
|
||||
|
||||
export function shuffle<T>(array: T[]): T[] {
|
||||
const result = [...array]
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
const tmp = result[i]!
|
||||
result[i] = result[j]!
|
||||
result[j] = tmp
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function drawRandom(deck: Card[]): { card: Card; deck: Card[] } | null {
|
||||
if (deck.length === 0) return null
|
||||
const index = Math.floor(Math.random() * deck.length)
|
||||
const card = deck[index]!
|
||||
const newDeck = [...deck.slice(0, index), ...deck.slice(index + 1)]
|
||||
return { card, deck: newDeck }
|
||||
}
|
||||
|
||||
export function returnToDeckRandom(deck: Card[], card: Card): Card[] {
|
||||
const index = Math.floor(Math.random() * (deck.length + 1))
|
||||
return [...deck.slice(0, index), card, ...deck.slice(index)]
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import { createDeck, drawRandom, returnToDeckRandom } from './deck'
|
||||
import { popAction, pushAction } from './history'
|
||||
import { canPlayCard, cardNeedsColorChoice } from './rules'
|
||||
import type {
|
||||
Card,
|
||||
CardColor,
|
||||
DrawCardsResult,
|
||||
GameAction,
|
||||
GameState,
|
||||
PlayCardResult,
|
||||
PlayerInfo,
|
||||
PublicGameState,
|
||||
TurnDirection,
|
||||
} from './types'
|
||||
|
||||
const INITIAL_HAND_SIZE = 7
|
||||
|
||||
function updatePlayerCounts(players: PlayerInfo[], hands: Record<string, Card[]>): PlayerInfo[] {
|
||||
return players.map((p) => ({
|
||||
...p,
|
||||
handCount: hands[p.id]?.length ?? 0,
|
||||
}))
|
||||
}
|
||||
|
||||
function drawStartCard(deck: Card[]): { card: Card; deck: Card[] } | null {
|
||||
let current = deck
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const result = drawRandom(current)
|
||||
if (!result) return null
|
||||
if (result.card.value !== 'wild4') {
|
||||
return result
|
||||
}
|
||||
current = result.deck
|
||||
}
|
||||
return drawRandom(deck)
|
||||
}
|
||||
|
||||
export function createGame(playerIds: string[], playerNames: Record<string, string>): GameState {
|
||||
let deck = createDeck()
|
||||
const hands: Record<string, Card[]> = {}
|
||||
|
||||
for (const id of playerIds) {
|
||||
const cards: Card[] = []
|
||||
for (let i = 0; i < INITIAL_HAND_SIZE; i++) {
|
||||
const result = drawRandom(deck)
|
||||
if (!result) break
|
||||
cards.push(result.card)
|
||||
deck = result.deck
|
||||
}
|
||||
hands[id] = cards
|
||||
}
|
||||
|
||||
const startResult = drawStartCard(deck)
|
||||
const topCard = startResult?.card ?? null
|
||||
if (startResult) deck = startResult.deck
|
||||
|
||||
const discardPile: Card[] = topCard ? [topCard] : []
|
||||
let activeColor: CardColor | null = topCard && topCard.color !== 'wild' ? topCard.color : null
|
||||
|
||||
const players: PlayerInfo[] = playerIds.map((id) => ({
|
||||
id,
|
||||
name: playerNames[id] ?? `플레이어 ${id.slice(0, 4)}`,
|
||||
handCount: hands[id]?.length ?? 0,
|
||||
declaredOneCard: false,
|
||||
}))
|
||||
|
||||
return {
|
||||
phase: 'playing',
|
||||
deck,
|
||||
discardPile,
|
||||
hands,
|
||||
players,
|
||||
currentTurnIndex: 0,
|
||||
turnOrder: [...playerIds],
|
||||
turnDirection: 1,
|
||||
drawPenalty: 0,
|
||||
activeColor,
|
||||
winnerId: null,
|
||||
history: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function getTopCard(state: GameState): Card | null {
|
||||
return state.discardPile[state.discardPile.length - 1] ?? null
|
||||
}
|
||||
|
||||
export function getCurrentPlayerId(state: GameState): string | null {
|
||||
return state.turnOrder[state.currentTurnIndex] ?? null
|
||||
}
|
||||
|
||||
export function drawCardToPlayer(state: GameState, playerId: string): GameState {
|
||||
const result = drawRandom(state.deck)
|
||||
if (!result) return state
|
||||
|
||||
const newHands = {
|
||||
...state.hands,
|
||||
[playerId]: [...(state.hands[playerId] ?? []), result.card],
|
||||
}
|
||||
|
||||
const action: GameAction = { type: 'draw', playerId, card: result.card }
|
||||
|
||||
return {
|
||||
...state,
|
||||
deck: result.deck,
|
||||
hands: newHands,
|
||||
players: resetDeclarations(updatePlayerCounts(state.players, newHands)),
|
||||
history: pushAction(state.history, action),
|
||||
}
|
||||
}
|
||||
|
||||
export function playerDrawCards(state: GameState, playerId: string): DrawCardsResult {
|
||||
if (state.phase !== 'playing') return { ok: false, reason: '게임이 진행 중이 아닙니다.' }
|
||||
if (!state.turnOrder.includes(playerId)) {
|
||||
return { ok: false, reason: '이미 패를 모두 낸 플레이어입니다.' }
|
||||
}
|
||||
if (getCurrentPlayerId(state) !== playerId) return { ok: false, reason: '내 턴이 아닙니다.' }
|
||||
|
||||
const count = state.drawPenalty > 0 ? state.drawPenalty : 1
|
||||
let next = state
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const result = drawRandom(next.deck)
|
||||
if (!result) return { ok: false, reason: '덱에 카드가 없습니다.' }
|
||||
const newHands = {
|
||||
...next.hands,
|
||||
[playerId]: [...(next.hands[playerId] ?? []), result.card],
|
||||
}
|
||||
next = {
|
||||
...next,
|
||||
deck: result.deck,
|
||||
hands: newHands,
|
||||
players: resetDeclarations(updatePlayerCounts(next.players, newHands)),
|
||||
history: pushAction(next.history, { type: 'draw', playerId, card: result.card }),
|
||||
}
|
||||
}
|
||||
|
||||
next = {
|
||||
...next,
|
||||
drawPenalty: 0,
|
||||
...advanceTurn(next),
|
||||
}
|
||||
|
||||
return { ok: true, state: next }
|
||||
}
|
||||
|
||||
export function playCard(
|
||||
state: GameState,
|
||||
playerId: string,
|
||||
cardId: string,
|
||||
chosenColor?: CardColor,
|
||||
): PlayCardResult {
|
||||
if (state.phase !== 'playing') return { ok: false, reason: '게임이 진행 중이 아닙니다.' }
|
||||
if (!state.turnOrder.includes(playerId)) {
|
||||
return { ok: false, reason: '이미 패를 모두 낸 플레이어입니다.' }
|
||||
}
|
||||
if (getCurrentPlayerId(state) !== playerId) return { ok: false, reason: '내 턴이 아닙니다.' }
|
||||
|
||||
const topCard = getTopCard(state)
|
||||
if (!topCard) return { ok: false, reason: '버린 더미가 없습니다.' }
|
||||
|
||||
const hand = state.hands[playerId] ?? []
|
||||
const card = hand.find((c) => c.id === cardId)
|
||||
if (!card) return { ok: false, reason: '카드를 찾을 수 없습니다.' }
|
||||
|
||||
if (!canPlayCard(card, topCard, state.activeColor, state.drawPenalty)) {
|
||||
return { ok: false, reason: '낼 수 없는 카드입니다.' }
|
||||
}
|
||||
|
||||
if (cardNeedsColorChoice(card) && !chosenColor) {
|
||||
return { ok: false, reason: '색상을 선택해주세요.' }
|
||||
}
|
||||
|
||||
const newHand = hand.filter((c) => c.id !== cardId)
|
||||
const playedCard = cardNeedsColorChoice(card) && chosenColor
|
||||
? { ...card, color: chosenColor }
|
||||
: card
|
||||
|
||||
const prevTop = topCard
|
||||
const prevActiveColor = state.activeColor
|
||||
const prevTurnIndex = state.currentTurnIndex
|
||||
const prevDirection = state.turnDirection
|
||||
const prevDrawPenalty = state.drawPenalty
|
||||
|
||||
let next: GameState = {
|
||||
...state,
|
||||
hands: { ...state.hands, [playerId]: newHand },
|
||||
discardPile: [...state.discardPile, playedCard],
|
||||
activeColor: cardNeedsColorChoice(card) ? chosenColor! : playedCard.color,
|
||||
players: updatePlayerCounts(
|
||||
state.players.map((p) =>
|
||||
p.id === playerId ? { ...p, declaredOneCard: false } : p,
|
||||
),
|
||||
{ ...state.hands, [playerId]: newHand },
|
||||
),
|
||||
history: pushAction(state.history, {
|
||||
type: 'play',
|
||||
playerId,
|
||||
card: playedCard,
|
||||
chosenColor,
|
||||
prevTop,
|
||||
prevActiveColor,
|
||||
prevTurnIndex,
|
||||
prevDirection,
|
||||
prevDrawPenalty,
|
||||
}),
|
||||
}
|
||||
|
||||
if (newHand.length === 0) {
|
||||
next = { ...next, phase: 'round_won', winnerId: playerId }
|
||||
return { ok: true, state: next }
|
||||
}
|
||||
|
||||
next = applyCardEffect(next, playedCard)
|
||||
return { ok: true, state: next }
|
||||
}
|
||||
|
||||
function applyCardEffect(state: GameState, card: Card): GameState {
|
||||
const playerCount = state.turnOrder.length
|
||||
let next = { ...state }
|
||||
|
||||
switch (card.value) {
|
||||
case 'skip':
|
||||
next = { ...next, ...advanceTurn(next) }
|
||||
next = { ...next, ...advanceTurn(next) }
|
||||
break
|
||||
case 'reverse':
|
||||
if (playerCount === 2) {
|
||||
next = { ...next, ...advanceTurn(next) }
|
||||
next = { ...next, ...advanceTurn(next) }
|
||||
} else {
|
||||
next = { ...next, turnDirection: (next.turnDirection * -1) as TurnDirection }
|
||||
next = { ...next, ...advanceTurn(next) }
|
||||
}
|
||||
break
|
||||
case 'draw2':
|
||||
next = { ...next, drawPenalty: next.drawPenalty + 2 }
|
||||
next = { ...next, ...advanceTurn(next) }
|
||||
break
|
||||
case 'wild4':
|
||||
next = { ...next, drawPenalty: next.drawPenalty + 4 }
|
||||
next = { ...next, ...advanceTurn(next) }
|
||||
break
|
||||
case 'wild':
|
||||
next = { ...next, ...advanceTurn(next) }
|
||||
break
|
||||
default:
|
||||
next = { ...next, drawPenalty: 0 }
|
||||
next = { ...next, ...advanceTurn(next) }
|
||||
break
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function advanceTurn(state: GameState): Pick<GameState, 'currentTurnIndex'> {
|
||||
const len = state.turnOrder.length
|
||||
if (len === 0) return { currentTurnIndex: 0 }
|
||||
let idx = state.currentTurnIndex + state.turnDirection
|
||||
if (idx < 0) idx = len - 1
|
||||
if (idx >= len) idx = 0
|
||||
return { currentTurnIndex: idx }
|
||||
}
|
||||
|
||||
function resetDeclarations(players: PlayerInfo[]): PlayerInfo[] {
|
||||
return players.map((p) => ({ ...p, declaredOneCard: false }))
|
||||
}
|
||||
|
||||
export function declareOneCard(state: GameState, playerId: string): GameState {
|
||||
return {
|
||||
...state,
|
||||
players: state.players.map((p) =>
|
||||
p.id === playerId ? { ...p, declaredOneCard: true } : p,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function undoLastAction(state: GameState): GameState {
|
||||
const { action, history } = popAction(state.history)
|
||||
if (!action) return state
|
||||
|
||||
if (action.type === 'draw') {
|
||||
const playerHand = state.hands[action.playerId] ?? []
|
||||
const newHand = playerHand.filter((c) => c.id !== action.card.id)
|
||||
const newHands = { ...state.hands, [action.playerId]: newHand }
|
||||
return {
|
||||
...state,
|
||||
deck: returnToDeckRandom(state.deck, action.card),
|
||||
hands: newHands,
|
||||
players: updatePlayerCounts(state.players, newHands),
|
||||
history,
|
||||
phase: 'playing',
|
||||
winnerId: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (action.type === 'play') {
|
||||
const pile = [...state.discardPile]
|
||||
pile.pop()
|
||||
const hand = [...(state.hands[action.playerId] ?? []), action.card]
|
||||
const newHands = { ...state.hands, [action.playerId]: hand }
|
||||
return {
|
||||
...state,
|
||||
discardPile: pile,
|
||||
activeColor: action.prevActiveColor,
|
||||
currentTurnIndex: action.prevTurnIndex,
|
||||
turnDirection: action.prevDirection,
|
||||
drawPenalty: action.prevDrawPenalty,
|
||||
hands: newHands,
|
||||
players: updatePlayerCounts(state.players, newHands),
|
||||
history,
|
||||
phase: 'playing',
|
||||
winnerId: null,
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
export function canUndo(state: GameState): boolean {
|
||||
return state.history.length > 0
|
||||
}
|
||||
|
||||
/** 패를 모두 낸 뒤 이어하기 — 승자는 제외하고 남은 플레이어끼리 계속 */
|
||||
export function continueAfterRoundWin(state: GameState): GameState {
|
||||
if (state.phase !== 'round_won' || !state.winnerId) return state
|
||||
|
||||
const winnerId = state.winnerId
|
||||
const remainingOrder = state.turnOrder.filter((id) => id !== winnerId)
|
||||
|
||||
if (remainingOrder.length <= 1) {
|
||||
return { ...state, phase: 'finished' }
|
||||
}
|
||||
|
||||
const { turnOrder, turnDirection } = state
|
||||
const winnerIdx = turnOrder.indexOf(winnerId)
|
||||
let idx = winnerIdx
|
||||
let nextPlayerId: string | undefined
|
||||
|
||||
for (let i = 0; i < turnOrder.length - 1; i++) {
|
||||
idx = (idx + turnDirection + turnOrder.length) % turnOrder.length
|
||||
const candidate = turnOrder[idx]
|
||||
if (candidate !== winnerId) {
|
||||
nextPlayerId = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const nextTurnIndex = nextPlayerId
|
||||
? remainingOrder.indexOf(nextPlayerId)
|
||||
: 0
|
||||
|
||||
return {
|
||||
...state,
|
||||
phase: 'playing',
|
||||
winnerId: null,
|
||||
turnOrder: remainingOrder,
|
||||
currentTurnIndex: nextTurnIndex >= 0 ? nextTurnIndex : 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function toPublicState(state: GameState): PublicGameState {
|
||||
const winner = state.winnerId
|
||||
? state.players.find((p) => p.id === state.winnerId)
|
||||
: null
|
||||
return {
|
||||
phase: state.phase,
|
||||
deckCount: state.deck.length,
|
||||
players: state.players,
|
||||
currentTurnIndex: state.currentTurnIndex,
|
||||
turnOrder: state.turnOrder,
|
||||
topCard: getTopCard(state),
|
||||
turnDirection: state.turnDirection,
|
||||
drawPenalty: state.drawPenalty,
|
||||
activeColor: state.activeColor,
|
||||
winnerId: state.winnerId,
|
||||
winnerName: winner?.name ?? null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { GameAction } from './types'
|
||||
|
||||
export function pushAction(history: GameAction[], action: GameAction): GameAction[] {
|
||||
return [...history, action]
|
||||
}
|
||||
|
||||
export function popAction(history: GameAction[]): { action: GameAction | null; history: GameAction[] } {
|
||||
if (history.length === 0) return { action: null, history }
|
||||
const action = history[history.length - 1]!
|
||||
return { action, history: history.slice(0, -1) }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Card, CardColor, CardValue } from './types'
|
||||
|
||||
export function getEffectiveColor(card: Card, activeColor: CardColor | null): CardColor {
|
||||
if (card.color !== 'wild' && card.color) return card.color
|
||||
return activeColor ?? 'wild'
|
||||
}
|
||||
|
||||
export function canPlayCard(
|
||||
card: Card,
|
||||
topCard: Card,
|
||||
activeColor: CardColor | null,
|
||||
drawPenalty: number,
|
||||
): boolean {
|
||||
if (drawPenalty > 0) {
|
||||
return card.value === 'draw2' || card.value === 'wild4'
|
||||
}
|
||||
|
||||
if (card.color === 'wild') return true
|
||||
|
||||
const topColor = getEffectiveColor(topCard, activeColor)
|
||||
if (card.color === topColor) return true
|
||||
if (card.value === topCard.value) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function getPlayableCards(
|
||||
hand: Card[],
|
||||
topCard: Card,
|
||||
activeColor: CardColor | null,
|
||||
drawPenalty: number,
|
||||
): Card[] {
|
||||
return hand.filter((c) => canPlayCard(c, topCard, activeColor, drawPenalty))
|
||||
}
|
||||
|
||||
export function isActionCard(value: CardValue): boolean {
|
||||
return value === 'skip' || value === 'reverse' || value === 'draw2' || value === 'wild' || value === 'wild4'
|
||||
}
|
||||
|
||||
export function cardNeedsColorChoice(card: Card): boolean {
|
||||
return card.color === 'wild'
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
export type CardColor = 'red' | 'yellow' | 'green' | 'blue' | 'wild'
|
||||
|
||||
export type CardValue =
|
||||
| '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
|
||||
| 'skip' | 'reverse' | 'draw2' | 'wild' | 'wild4'
|
||||
|
||||
export interface Card {
|
||||
id: string
|
||||
color: CardColor
|
||||
value: CardValue
|
||||
}
|
||||
|
||||
export interface PlayerInfo {
|
||||
id: string
|
||||
name: string
|
||||
handCount: number
|
||||
declaredOneCard?: boolean
|
||||
}
|
||||
|
||||
export type GamePhase = 'waiting' | 'playing' | 'paused' | 'round_won' | 'finished'
|
||||
|
||||
export type TurnDirection = 1 | -1
|
||||
|
||||
export interface PublicGameState {
|
||||
phase: GamePhase
|
||||
deckCount: number
|
||||
players: PlayerInfo[]
|
||||
currentTurnIndex: number
|
||||
turnOrder: string[]
|
||||
topCard: Card | null
|
||||
turnDirection: TurnDirection
|
||||
drawPenalty: number
|
||||
activeColor: CardColor | null
|
||||
winnerId: string | null
|
||||
winnerName: string | null
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
phase: GamePhase
|
||||
deck: Card[]
|
||||
discardPile: Card[]
|
||||
hands: Record<string, Card[]>
|
||||
players: PlayerInfo[]
|
||||
currentTurnIndex: number
|
||||
turnOrder: string[]
|
||||
turnDirection: TurnDirection
|
||||
drawPenalty: number
|
||||
activeColor: CardColor | null
|
||||
winnerId: string | null
|
||||
history: GameAction[]
|
||||
}
|
||||
|
||||
export type GameAction =
|
||||
| { type: 'draw'; playerId: string; card: Card }
|
||||
| {
|
||||
type: 'play'
|
||||
playerId: string
|
||||
card: Card
|
||||
chosenColor?: CardColor
|
||||
prevTop: Card
|
||||
prevActiveColor: CardColor | null
|
||||
prevTurnIndex: number
|
||||
prevDirection: TurnDirection
|
||||
prevDrawPenalty: number
|
||||
}
|
||||
|
||||
export type RoomRole = 'board' | 'player'
|
||||
|
||||
export interface SessionInfo {
|
||||
roomCode: string
|
||||
role: RoomRole
|
||||
playerId: string
|
||||
playerName?: string
|
||||
}
|
||||
|
||||
export type PlayCardResult =
|
||||
| { ok: true; state: GameState }
|
||||
| { ok: false; reason: string }
|
||||
|
||||
export type DrawCardsResult =
|
||||
| { ok: true; state: GameState }
|
||||
| { ok: false; reason: string }
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeView from '@/views/HomeView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: '/onecard/lobby',
|
||||
name: 'onecard-lobby',
|
||||
component: () => import('@/views/onecard/OneCardLobbyView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/onecard/board',
|
||||
name: 'onecard-board',
|
||||
component: () => import('@/views/onecard/OneCardBoardView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/onecard/player',
|
||||
name: 'onecard-player',
|
||||
component: () => import('@/views/onecard/OneCardPlayerView.vue'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { Card, PublicGameState, RoomRole } from '@/games/onecard/types'
|
||||
import type { ConnectedPlayer } from '@/composables/messages'
|
||||
|
||||
export const useRoomStore = defineStore('room', () => {
|
||||
const roomCode = ref('')
|
||||
const role = ref<RoomRole | null>(null)
|
||||
const playerId = ref('')
|
||||
const playerName = ref('')
|
||||
const isConnected = ref(false)
|
||||
const connectionError = ref('')
|
||||
const players = ref<{ id: string; name: string }[]>([])
|
||||
const connectedPlayers = ref<ConnectedPlayer[]>([])
|
||||
const gameStarted = ref(false)
|
||||
const publicState = ref<PublicGameState | null>(null)
|
||||
const myHand = ref<Card[]>([])
|
||||
const playerHandPreviews = ref<Record<string, string | null>>({})
|
||||
const statusMessage = ref('')
|
||||
|
||||
const isBoard = computed(() => role.value === 'board')
|
||||
const isPlayer = computed(() => role.value === 'player')
|
||||
const playerCount = computed(() => players.value.length)
|
||||
const canStartGame = computed(() => isBoard.value && playerCount.value >= 2 && !gameStarted.value)
|
||||
const totalDevices = computed(() => (isBoard.value ? 1 + playerCount.value : 0))
|
||||
const currentTurnPlayerId = computed(() => {
|
||||
if (!publicState.value) return null
|
||||
return publicState.value.turnOrder[publicState.value.currentTurnIndex] ?? null
|
||||
})
|
||||
|
||||
function reset() {
|
||||
roomCode.value = ''
|
||||
role.value = null
|
||||
playerId.value = ''
|
||||
playerName.value = ''
|
||||
isConnected.value = false
|
||||
connectionError.value = ''
|
||||
players.value = []
|
||||
connectedPlayers.value = []
|
||||
gameStarted.value = false
|
||||
publicState.value = null
|
||||
myHand.value = []
|
||||
playerHandPreviews.value = {}
|
||||
statusMessage.value = ''
|
||||
}
|
||||
|
||||
function setPlayerHandPreview(playerId: string, cardId: string | null) {
|
||||
playerHandPreviews.value = { ...playerHandPreviews.value, [playerId]: cardId }
|
||||
}
|
||||
|
||||
function clearPlayerHandPreview(playerId: string) {
|
||||
const next = { ...playerHandPreviews.value }
|
||||
delete next[playerId]
|
||||
playerHandPreviews.value = next
|
||||
}
|
||||
|
||||
function setStatus(msg: string) {
|
||||
statusMessage.value = msg
|
||||
setTimeout(() => {
|
||||
if (statusMessage.value === msg) statusMessage.value = ''
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
return {
|
||||
roomCode,
|
||||
role,
|
||||
playerId,
|
||||
playerName,
|
||||
isConnected,
|
||||
connectionError,
|
||||
players,
|
||||
connectedPlayers,
|
||||
gameStarted,
|
||||
publicState,
|
||||
myHand,
|
||||
playerHandPreviews,
|
||||
statusMessage,
|
||||
isBoard,
|
||||
isPlayer,
|
||||
playerCount,
|
||||
canStartGame,
|
||||
totalDevices,
|
||||
currentTurnPlayerId,
|
||||
reset,
|
||||
setPlayerHandPreview,
|
||||
clearPlayerHandPreview,
|
||||
setStatus,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
export const MAX_PLAYERS = 4
|
||||
|
||||
/** 시계 방향: 아래 → 왼쪽 → 위 → 오른쪽 (보드판 기준) */
|
||||
export const CORNERS_CLOCKWISE = ['bottom', 'left', 'top', 'right'] as const
|
||||
export type BoardCorner = (typeof CORNERS_CLOCKWISE)[number]
|
||||
|
||||
export type SeatMap = Record<BoardCorner, string | null>
|
||||
|
||||
export const CORNER_LABELS: Record<BoardCorner, string> = {
|
||||
bottom: '아래',
|
||||
left: '왼쪽',
|
||||
top: '위',
|
||||
right: '오른쪽',
|
||||
}
|
||||
|
||||
const SEAT_KEY = 'tabletop-board-seats'
|
||||
|
||||
export function emptySeats(): SeatMap {
|
||||
return { bottom: null, left: null, top: null, right: null }
|
||||
}
|
||||
|
||||
export function autoAssignSeats(playerIds: string[], current?: SeatMap): SeatMap {
|
||||
const seats = current ? { ...current } : emptySeats()
|
||||
|
||||
for (const corner of CORNERS_CLOCKWISE) {
|
||||
const id = seats[corner]
|
||||
if (id && !playerIds.includes(id)) {
|
||||
seats[corner] = null
|
||||
}
|
||||
}
|
||||
|
||||
const assigned = new Set(
|
||||
CORNERS_CLOCKWISE.map((c) => seats[c]).filter((id): id is string => !!id),
|
||||
)
|
||||
const unassigned = playerIds.filter((id) => !assigned.has(id))
|
||||
|
||||
for (const id of unassigned) {
|
||||
const emptyCorner = CORNERS_CLOCKWISE.find((c) => !seats[c])
|
||||
if (!emptyCorner) break
|
||||
seats[emptyCorner] = id
|
||||
}
|
||||
|
||||
return seats
|
||||
}
|
||||
|
||||
export function loadSeats(): SeatMap | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SEAT_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as SeatMap
|
||||
return { ...emptySeats(), ...parsed }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSeats(seats: SeatMap): void {
|
||||
sessionStorage.setItem(SEAT_KEY, JSON.stringify(seats))
|
||||
}
|
||||
|
||||
export function clearSeats(): void {
|
||||
sessionStorage.removeItem(SEAT_KEY)
|
||||
}
|
||||
|
||||
export function turnOrderFromSeats(seats: SeatMap): string[] {
|
||||
return CORNERS_CLOCKWISE
|
||||
.map((c) => seats[c])
|
||||
.filter((id): id is string => !!id)
|
||||
}
|
||||
|
||||
export function assignedPlayerCount(seats: SeatMap): number {
|
||||
return turnOrderFromSeats(seats).length
|
||||
}
|
||||
|
||||
export function playerAtCorner(seats: SeatMap, corner: BoardCorner): string | null {
|
||||
return seats[corner]
|
||||
}
|
||||
|
||||
export function cornerForPlayer(seats: SeatMap, playerId: string): BoardCorner | null {
|
||||
for (const corner of CORNERS_CLOCKWISE) {
|
||||
if (seats[corner] === playerId) return corner
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 플레이어를 해당 모서리로 옮김(기존 자리 비움). 대상 자리에 있던 플레이어는 미배치로 돌아감. */
|
||||
export function assignPlayerToCorner(
|
||||
seats: SeatMap,
|
||||
corner: BoardCorner,
|
||||
playerId: string | null,
|
||||
): SeatMap {
|
||||
const next = { ...seats }
|
||||
|
||||
if (playerId === null) {
|
||||
next[corner] = null
|
||||
return next
|
||||
}
|
||||
|
||||
for (const c of CORNERS_CLOCKWISE) {
|
||||
if (next[c] === playerId) next[c] = null
|
||||
}
|
||||
next[corner] = playerId
|
||||
return next
|
||||
}
|
||||
|
||||
/** 드롭: 빈 자리면 이동, occupied면 맞바꿈. 미배치에서 드롭 시 기존 자리 플레이어는 미배치. */
|
||||
export function dropPlayerOnCorner(
|
||||
seats: SeatMap,
|
||||
targetCorner: BoardCorner,
|
||||
playerId: string,
|
||||
): SeatMap {
|
||||
const next = { ...seats }
|
||||
const sourceCorner = cornerForPlayer(next, playerId)
|
||||
|
||||
if (sourceCorner === targetCorner) return next
|
||||
|
||||
const occupantId = next[targetCorner]
|
||||
|
||||
if (sourceCorner) {
|
||||
if (occupantId) {
|
||||
next[sourceCorner] = occupantId
|
||||
next[targetCorner] = playerId
|
||||
} else {
|
||||
next[sourceCorner] = null
|
||||
next[targetCorner] = playerId
|
||||
}
|
||||
} else {
|
||||
next[targetCorner] = playerId
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export function dropPlayerOnPool(seats: SeatMap, playerId: string): SeatMap {
|
||||
const sourceCorner = cornerForPlayer(seats, playerId)
|
||||
if (!sourceCorner) return seats
|
||||
return { ...seats, [sourceCorner]: null }
|
||||
}
|
||||
|
||||
export function getPlayerById(
|
||||
players: { id: string; name: string; handCount?: number }[],
|
||||
id: string | null,
|
||||
) {
|
||||
if (!id) return null
|
||||
return players.find((p) => p.id === id) ?? null
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* HTTP(LAN IP) 등 비보안 컨텍스트에서도 동작하는 클립보드 복사.
|
||||
* navigator.clipboard 실패 시 execCommand 폴백 사용.
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
// secure context가 아니면 실패 → 폴백
|
||||
}
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.top = '0'
|
||||
textarea.style.left = '0'
|
||||
textarea.style.width = '2em'
|
||||
textarea.style.height = '2em'
|
||||
textarea.style.padding = '0'
|
||||
textarea.style.border = 'none'
|
||||
textarea.style.outline = 'none'
|
||||
textarea.style.boxShadow = 'none'
|
||||
textarea.style.background = 'transparent'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
|
||||
textarea.focus()
|
||||
textarea.select()
|
||||
textarea.setSelectionRange(0, text.length)
|
||||
|
||||
let success = false
|
||||
try {
|
||||
success = document.execCommand('copy')
|
||||
} catch {
|
||||
success = false
|
||||
} finally {
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
|
||||
return success
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { SessionInfo } from '@/games/onecard/types'
|
||||
|
||||
const SESSION_KEY = 'tabletop-session'
|
||||
const PLAYER_NAME_KEY = 'tabletop-player-name'
|
||||
|
||||
export function saveSession(info: SessionInfo): void {
|
||||
sessionStorage.setItem(SESSION_KEY, JSON.stringify(info))
|
||||
}
|
||||
|
||||
export function loadSession(): SessionInfo | null {
|
||||
const raw = sessionStorage.getItem(SESSION_KEY)
|
||||
if (!raw) return null
|
||||
try {
|
||||
return JSON.parse(raw) as SessionInfo
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSession(): void {
|
||||
sessionStorage.removeItem(SESSION_KEY)
|
||||
}
|
||||
|
||||
export function loadSavedPlayerName(): string {
|
||||
try {
|
||||
return localStorage.getItem(PLAYER_NAME_KEY) ?? ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function savePlayerName(name: string): void {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
try {
|
||||
localStorage.setItem(PLAYER_NAME_KEY, trimmed)
|
||||
} catch {
|
||||
// private browsing 등
|
||||
}
|
||||
}
|
||||
|
||||
export function generateRoomCode(): string {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
let code = ''
|
||||
for (let i = 0; i < 6; i++) {
|
||||
code += chars[Math.floor(Math.random() * chars.length)]
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
export function generatePlayerId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
// HTTP(LAN IP) 등 비보안 컨텍스트에서는 randomUUID 미지원
|
||||
return `p-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
let tapLock = false
|
||||
|
||||
/** 구형 Safari(iOS) 스크롤 영역·300ms 지연 대응: click + touchend 모두 처리 */
|
||||
function enterOneCard(e?: Event) {
|
||||
e?.preventDefault()
|
||||
if (tapLock) return
|
||||
tapLock = true
|
||||
router.push('/onecard/lobby').finally(() => {
|
||||
window.setTimeout(() => { tapLock = false }, 400)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="home">
|
||||
<header class="home-header">
|
||||
<h1 class="title">Tabletop</h1>
|
||||
<p class="subtitle">보드게임 시뮬레이터</p>
|
||||
</header>
|
||||
|
||||
<section class="games">
|
||||
<button
|
||||
type="button"
|
||||
class="game-card"
|
||||
@click="enterOneCard"
|
||||
@touchend.prevent="enterOneCard"
|
||||
>
|
||||
<span class="game-icon" aria-hidden="true">🃏</span>
|
||||
<span class="game-title">원카드</span>
|
||||
<span class="game-desc">멀티 디바이스 원카드 시뮬레이터</span>
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 48px 24px 32px;
|
||||
padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.home-header {
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #a29bfe, #6c5ce7);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #888;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.games {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
/* gap 미지원 Safari 폴백 불필요 — 단일 카드 */
|
||||
|
||||
.game-card {
|
||||
display: block;
|
||||
width: 220px;
|
||||
min-height: 44px;
|
||||
padding: 32px 24px;
|
||||
border-radius: 16px;
|
||||
border: 2px solid #333;
|
||||
background: #2a2a4a;
|
||||
color: #eee;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
font: inherit;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
-webkit-tap-highlight-color: rgba(108, 92, 231, 0.25);
|
||||
touch-action: manipulation;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
transition: border-color 0.15s, background 0.15s, -webkit-transform 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.game-card:hover {
|
||||
border-color: #6c5ce7;
|
||||
}
|
||||
|
||||
.game-card:active {
|
||||
border-color: #6c5ce7;
|
||||
background: #353560;
|
||||
-webkit-transform: scale(0.98);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.game-icon {
|
||||
display: block;
|
||||
font-size: 3rem;
|
||||
margin-bottom: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.game-title {
|
||||
display: block;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.game-desc {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (orientation: landscape) and (max-height: 520px) {
|
||||
.home {
|
||||
padding: 16px 20px 20px;
|
||||
padding-bottom: calc(20px + env(safe-area-inset-bottom, 0px));
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 4px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.game-card {
|
||||
width: 220px;
|
||||
max-width: 100%;
|
||||
padding: 20px 18px;
|
||||
}
|
||||
|
||||
.game-icon {
|
||||
font-size: 2.2rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.game-title {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.game-desc {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,380 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoomStore } from '@/stores/roomStore'
|
||||
import { usePeerRoom } from '@/composables/usePeerRoom'
|
||||
import { loadSession } from '@/utils/session'
|
||||
import { canUndo } from '@/games/onecard/game'
|
||||
import {
|
||||
CORNERS_CLOCKWISE,
|
||||
autoAssignSeats,
|
||||
assignedPlayerCount,
|
||||
emptySeats,
|
||||
loadSeats,
|
||||
saveSeats,
|
||||
type BoardCorner,
|
||||
type SeatMap,
|
||||
MAX_PLAYERS,
|
||||
} from '@/utils/boardLayout'
|
||||
import RoomCodeDisplay from '@/components/RoomCodeDisplay.vue'
|
||||
import BoardSeatPicker from '@/components/BoardSeatPicker.vue'
|
||||
import BoardCornerZone from '@/components/BoardCornerZone.vue'
|
||||
import DeckView from '@/components/DeckView.vue'
|
||||
import DiscardPileView from '@/components/DiscardPileView.vue'
|
||||
import UndoTurnButton from '@/components/UndoTurnButton.vue'
|
||||
import ExitButton from '@/components/ExitButton.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useRoomStore()
|
||||
const { hostRoom, startGame, restartGame, continueGame, drawForPlayer, undo, destroy, gameState } = usePeerRoom()
|
||||
|
||||
const isReady = ref(false)
|
||||
const seatMap = ref<SeatMap>(emptySeats())
|
||||
|
||||
const publicState = computed(() => store.publicState)
|
||||
const players = computed(() => {
|
||||
if (gameState.value) return gameState.value.players
|
||||
return store.players.map((p) => ({ ...p, handCount: 0 }))
|
||||
})
|
||||
|
||||
const currentTurnId = computed(() => store.currentTurnPlayerId)
|
||||
const undoDisabled = computed(() => !gameState.value || !canUndo(gameState.value))
|
||||
const isPlaying = computed(() => publicState.value?.phase === 'playing')
|
||||
const isRoundWon = computed(() => publicState.value?.phase === 'round_won')
|
||||
const isFinished = computed(() => publicState.value?.phase === 'finished')
|
||||
const deckCount = computed(() => publicState.value?.deckCount ?? null)
|
||||
|
||||
const canStart = computed(
|
||||
() => store.canStartGame && assignedPlayerCount(seatMap.value) >= 2,
|
||||
)
|
||||
|
||||
function syncSeatsFromPlayers() {
|
||||
if (store.gameStarted) return
|
||||
const ids = store.players.map((p) => p.id)
|
||||
seatMap.value = autoAssignSeats(ids, seatMap.value)
|
||||
saveSeats(seatMap.value)
|
||||
}
|
||||
|
||||
function updateSeats(next: SeatMap) {
|
||||
seatMap.value = next
|
||||
saveSeats(next)
|
||||
}
|
||||
|
||||
function playerAtCorner(corner: BoardCorner) {
|
||||
const id = seatMap.value[corner]
|
||||
if (!id) return null
|
||||
return players.value.find((p) => p.id === id) ?? null
|
||||
}
|
||||
|
||||
function getPlayerPreviewIndex(playerId: string): number | null {
|
||||
const cardId = store.playerHandPreviews[playerId]
|
||||
if (!cardId || !gameState.value) return null
|
||||
const hand = gameState.value.hands[playerId] ?? []
|
||||
const idx = hand.findIndex((c) => c.id === cardId)
|
||||
return idx >= 0 && idx < 14 ? idx : null
|
||||
}
|
||||
|
||||
function previewForCorner(corner: BoardCorner) {
|
||||
const player = playerAtCorner(corner)
|
||||
return player ? getPlayerPreviewIndex(player.id) : null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => store.players.map((p) => p.id).join(','),
|
||||
syncSeatsFromPlayers,
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
const session = loadSession()
|
||||
if (!session || session.role !== 'board') {
|
||||
router.replace('/onecard/lobby')
|
||||
return
|
||||
}
|
||||
|
||||
store.roomCode = session.roomCode
|
||||
store.role = session.role
|
||||
store.playerId = session.playerId
|
||||
|
||||
seatMap.value = loadSeats() ?? emptySeats()
|
||||
|
||||
if (!store.isConnected) {
|
||||
try {
|
||||
await hostRoom(session.roomCode)
|
||||
} catch {
|
||||
router.replace('/onecard/lobby')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
syncSeatsFromPlayers()
|
||||
isReady.value = true
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
destroy()
|
||||
})
|
||||
|
||||
function handleStartGame() {
|
||||
saveSeats(seatMap.value)
|
||||
startGame(seatMap.value)
|
||||
}
|
||||
|
||||
function handleDraw(playerId: string) {
|
||||
if (!isPlaying.value) return
|
||||
if (!store.gameStarted) {
|
||||
store.setStatus('게임을 먼저 시작해주세요.')
|
||||
return
|
||||
}
|
||||
const err = drawForPlayer(playerId)
|
||||
if (err) store.setStatus(err)
|
||||
}
|
||||
|
||||
function handleRestart() {
|
||||
restartGame(seatMap.value)
|
||||
}
|
||||
|
||||
function handleContinue() {
|
||||
continueGame()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main v-if="isReady" class="board">
|
||||
<RoomCodeDisplay :code="store.roomCode" compact />
|
||||
|
||||
<div class="top-actions">
|
||||
<ExitButton />
|
||||
</div>
|
||||
|
||||
<section v-if="!store.gameStarted" class="pre-game">
|
||||
<p class="device-count">
|
||||
접속: {{ 1 + store.playerCount }}대 · 플레이어 {{ store.playerCount }}/{{ MAX_PLAYERS }}명
|
||||
</p>
|
||||
<BoardSeatPicker
|
||||
:seats="seatMap"
|
||||
:players="store.players"
|
||||
@update:seats="updateSeats"
|
||||
/>
|
||||
<button
|
||||
class="start-btn"
|
||||
:disabled="!canStart"
|
||||
@click="handleStartGame"
|
||||
>
|
||||
{{ canStart ? '게임 시작' : '플레이어 2명 이상 배치 필요' }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section v-else class="game-table">
|
||||
<div v-if="isRoundWon" class="winner-banner round-won">
|
||||
<p class="winner-text">🎉 {{ publicState?.winnerName }}님이 패를 모두 냈습니다!</p>
|
||||
<p class="winner-sub">이어서 진행할까요, 새 게임을 시작할까요?</p>
|
||||
<div class="winner-actions">
|
||||
<button type="button" class="continue-btn" @click="handleContinue">
|
||||
이어하기
|
||||
</button>
|
||||
<button type="button" class="restart-btn" @click="handleRestart">
|
||||
다시 하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isFinished" class="winner-banner">
|
||||
<p class="winner-text">🏆 {{ publicState?.winnerName }} 승리!</p>
|
||||
<button type="button" class="restart-btn" @click="handleRestart">
|
||||
다시 하기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-for="corner in CORNERS_CLOCKWISE" :key="corner">
|
||||
<BoardCornerZone
|
||||
v-if="playerAtCorner(corner)"
|
||||
:corner="corner"
|
||||
:player-name="playerAtCorner(corner)!.name"
|
||||
:hand-count="playerAtCorner(corner)!.handCount"
|
||||
:is-turn="currentTurnId === playerAtCorner(corner)!.id"
|
||||
:preview-index="previewForCorner(corner)"
|
||||
:show-draw="isPlaying"
|
||||
@draw="handleDraw(playerAtCorner(corner)!.id)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="center-pile">
|
||||
<DeckView large :count="deckCount" />
|
||||
<DiscardPileView
|
||||
:top-card="publicState?.topCard ?? null"
|
||||
:active-color="publicState?.activeColor"
|
||||
large
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="store.statusMessage" class="toast">{{ store.statusMessage }}</p>
|
||||
<UndoTurnButton
|
||||
v-if="store.gameStarted && (isPlaying || isRoundWon)"
|
||||
:disabled="undoDisabled"
|
||||
@undo="undo()"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.board {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: radial-gradient(ellipse at center, #1e2a4a 0%, #1a1a2e 70%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pre-game {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 56px 16px 24px;
|
||||
}
|
||||
|
||||
.device-count {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.start-btn {
|
||||
padding: 16px 32px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background: #6c5ce7;
|
||||
color: #fff;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.start-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.game-table {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
". top ."
|
||||
"left center right"
|
||||
". bottom .";
|
||||
grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr);
|
||||
grid-template-rows: auto 1fr auto;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
padding: 48px 8px 16px;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.center-pile {
|
||||
grid-area: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: clamp(24px, 6vw, 64px);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.winner-banner {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 32px;
|
||||
background: rgba(26, 26, 46, 0.92);
|
||||
border-radius: 16px;
|
||||
border: 2px solid #f1c40f;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.winner-text {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: #f1c40f;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.winner-sub {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: #aaa;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.winner-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.continue-btn {
|
||||
padding: 12px 28px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background: #27ae60;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.continue-btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.restart-btn {
|
||||
padding: 12px 28px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background: #6c5ce7;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.restart-btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 300;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #2a2a4a;
|
||||
border: 2px solid #6c5ce7;
|
||||
padding: 12px 20px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.9rem;
|
||||
z-index: 200;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,487 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoomStore } from '@/stores/roomStore'
|
||||
import { usePeerRoom } from '@/composables/usePeerRoom'
|
||||
import {
|
||||
generateRoomCode,
|
||||
generatePlayerId,
|
||||
saveSession,
|
||||
clearSession,
|
||||
loadSavedPlayerName,
|
||||
savePlayerName,
|
||||
} from '@/utils/session'
|
||||
import HomeButton from '@/components/HomeButton.vue'
|
||||
import RoomCodeDisplay from '@/components/RoomCodeDisplay.vue'
|
||||
import { MAX_PLAYERS } from '@/utils/boardLayout'
|
||||
import type { RoomRole } from '@/games/onecard/types'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useRoomStore()
|
||||
const { hostRoom, joinRoom, destroy } = usePeerRoom()
|
||||
|
||||
const mode = ref<'menu' | 'join' | 'waiting'>('menu')
|
||||
const inputCode = ref('')
|
||||
const selectedRole = ref<RoomRole>('player')
|
||||
const playerName = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
playerName.value = loadSavedPlayerName()
|
||||
})
|
||||
|
||||
watch(playerName, (name) => {
|
||||
savePlayerName(name)
|
||||
})
|
||||
const isConnecting = ref(false)
|
||||
const localRoomCode = ref('')
|
||||
|
||||
const deviceCount = computed(() => 1 + store.playerCount)
|
||||
const canStart = computed(() => store.canStartGame && deviceCount.value >= 3)
|
||||
|
||||
async function startCreateRoom() {
|
||||
const code = generateRoomCode()
|
||||
const pid = generatePlayerId()
|
||||
const role: RoomRole = 'board'
|
||||
|
||||
store.reset()
|
||||
store.roomCode = code
|
||||
store.role = role
|
||||
store.playerId = pid
|
||||
store.playerName = '보드판'
|
||||
localRoomCode.value = code
|
||||
mode.value = 'waiting'
|
||||
|
||||
saveSession({ roomCode: code, role, playerId: pid, playerName: store.playerName })
|
||||
isConnecting.value = true
|
||||
|
||||
try {
|
||||
await hostRoom(code)
|
||||
} catch {
|
||||
store.connectionError = '방 생성에 실패했습니다. 다시 시도해주세요.'
|
||||
localRoomCode.value = ''
|
||||
mode.value = 'menu'
|
||||
} finally {
|
||||
isConnecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function joinExistingRoom() {
|
||||
const code = inputCode.value.trim().toUpperCase()
|
||||
if (code.length !== 6) {
|
||||
store.connectionError = '6자리 방 코드를 입력해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
const pid = generatePlayerId()
|
||||
const role = selectedRole.value
|
||||
|
||||
if (role === 'board') {
|
||||
store.connectionError = '보드판은 방 만들기를 사용해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
store.reset()
|
||||
store.roomCode = code
|
||||
store.role = role
|
||||
store.playerId = pid
|
||||
store.playerName = playerName.value.trim() || `플레이어 ${pid.slice(0, 4)}`
|
||||
savePlayerName(store.playerName)
|
||||
|
||||
saveSession({ roomCode: code, role, playerId: pid, playerName: store.playerName })
|
||||
isConnecting.value = true
|
||||
|
||||
try {
|
||||
await joinRoom(code, pid, store.playerName)
|
||||
navigateToGame()
|
||||
} catch {
|
||||
store.connectionError = store.connectionError || '방 접속에 실패했습니다. 보드판이 방을 연 상태인지 확인해주세요.'
|
||||
} finally {
|
||||
isConnecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToGame() {
|
||||
if (store.role === 'board') {
|
||||
router.push('/onecard/board')
|
||||
} else {
|
||||
router.push('/onecard/player')
|
||||
}
|
||||
}
|
||||
|
||||
function goToBoard() {
|
||||
router.push('/onecard/board')
|
||||
}
|
||||
|
||||
function leaveSubScreen() {
|
||||
if (mode.value === 'waiting') {
|
||||
destroy()
|
||||
clearSession()
|
||||
localRoomCode.value = ''
|
||||
}
|
||||
store.connectionError = ''
|
||||
mode.value = 'menu'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="lobby">
|
||||
<div class="top-actions">
|
||||
<HomeButton v-if="mode === 'menu'" />
|
||||
</div>
|
||||
|
||||
<header class="lobby-header" :class="{ 'has-top-action': mode === 'menu' }">
|
||||
<button v-if="mode !== 'menu'" class="back" @click="leaveSubScreen">← 뒤로</button>
|
||||
<h1 class="title">원카드</h1>
|
||||
</header>
|
||||
|
||||
<div class="lobby-body">
|
||||
<!-- 메인 메뉴 -->
|
||||
<div v-if="mode === 'menu'" class="menu">
|
||||
<button class="btn primary" @click="startCreateRoom">방 만들기</button>
|
||||
<button class="btn" @click="mode = 'join'">방 참가하기</button>
|
||||
</div>
|
||||
|
||||
<!-- 방 참가 -->
|
||||
<div v-else-if="mode === 'join'" class="form">
|
||||
<label class="field">
|
||||
<span>방 코드</span>
|
||||
<input
|
||||
v-model="inputCode"
|
||||
placeholder="6자리 코드"
|
||||
maxlength="6"
|
||||
class="code-input"
|
||||
@input="inputCode = inputCode.toUpperCase()"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>닉네임</span>
|
||||
<input v-model="playerName" placeholder="플레이어" maxlength="12" />
|
||||
</label>
|
||||
|
||||
<fieldset class="role-select">
|
||||
<legend>역할 선택</legend>
|
||||
<label>
|
||||
<input v-model="selectedRole" type="radio" value="player" />
|
||||
플레이어
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<button class="btn primary" :disabled="isConnecting" @click="joinExistingRoom">
|
||||
{{ isConnecting ? '연결 중...' : '접속하기' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 대기 화면 (보드판) -->
|
||||
<div v-else-if="mode === 'waiting'" class="waiting">
|
||||
<RoomCodeDisplay :code="localRoomCode" />
|
||||
<p v-if="isConnecting" class="connecting">연결 중...</p>
|
||||
<p class="wait-info">
|
||||
접속: <strong>{{ deviceCount }}/{{ 1 + MAX_PLAYERS }}</strong>대
|
||||
· 플레이어 <strong>{{ store.playerCount }}/{{ MAX_PLAYERS }}</strong>명
|
||||
</p>
|
||||
<p class="wait-hint">플레이어 2~{{ MAX_PLAYERS }}명 접속 후 보드판에서 자리를 배치하고 게임을 시작하세요.</p>
|
||||
|
||||
<ul v-if="store.players.length > 0" class="connected-list">
|
||||
<li v-for="p in store.players" :key="p.id">{{ p.name }}</li>
|
||||
</ul>
|
||||
<p v-else class="no-players">플레이어 대기 중...</p>
|
||||
|
||||
<button
|
||||
class="btn primary"
|
||||
:disabled="!canStart"
|
||||
@click="goToBoard"
|
||||
>
|
||||
{{ canStart ? '보드판으로 이동' : `대기 중 (${deviceCount}/3)` }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="store.connectionError" class="error">{{ store.connectionError }}</p>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lobby {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lobby-header {
|
||||
flex-shrink: 0;
|
||||
padding: 24px 24px 0;
|
||||
padding-top: calc(24px + env(safe-area-inset-top, 0px));
|
||||
}
|
||||
|
||||
.lobby-header.has-top-action {
|
||||
padding-right: 96px;
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
position: fixed;
|
||||
top: calc(12px + env(safe-area-inset-top, 0px));
|
||||
right: calc(12px + env(safe-area-inset-right, 0px));
|
||||
z-index: 300;
|
||||
}
|
||||
|
||||
.lobby-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
padding: 16px 24px 32px;
|
||||
padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.back {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field span {
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.field input {
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #333;
|
||||
background: #2a2a4a;
|
||||
color: #eee;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.code-input {
|
||||
font-size: 1.4rem !important;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.role-select {
|
||||
border: 2px solid #333;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.role-select legend {
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.role-select label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 14px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid #444;
|
||||
background: #2a2a4a;
|
||||
color: #eee;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: #6c5ce7;
|
||||
border-color: #6c5ce7;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.waiting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wait-info {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.connecting {
|
||||
font-size: 0.9rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.wait-hint {
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.connected-list {
|
||||
list-style: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.connected-list li {
|
||||
padding: 10px;
|
||||
background: #2a2a4a;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.no-players {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 20px;
|
||||
color: #e74c3c;
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (orientation: landscape) and (max-height: 520px) {
|
||||
.lobby-header {
|
||||
padding: 10px 20px 0;
|
||||
padding-top: calc(10px + env(safe-area-inset-top, 0px));
|
||||
}
|
||||
|
||||
.lobby-header.has-top-action {
|
||||
padding-right: 88px;
|
||||
}
|
||||
|
||||
.lobby-body {
|
||||
padding: 8px 20px 16px;
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.back {
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.menu {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.field span {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.field input {
|
||||
padding: 8px 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.code-input {
|
||||
font-size: 1.1rem !important;
|
||||
}
|
||||
|
||||
.role-select {
|
||||
padding: 8px 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.role-select legend {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.waiting {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wait-info {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.wait-hint,
|
||||
.connecting,
|
||||
.no-players {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.connected-list li {
|
||||
padding: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.waiting :deep(.code-text) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.waiting :deep(.room-code) {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.waiting :deep(.copy-btn) {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,327 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoomStore } from '@/stores/roomStore'
|
||||
import { usePeerRoom } from '@/composables/usePeerRoom'
|
||||
import { loadSession } from '@/utils/session'
|
||||
import type { CardColor } from '@/games/onecard/types'
|
||||
import HandView from '@/components/HandView.vue'
|
||||
import WildColorPicker from '@/components/WildColorPicker.vue'
|
||||
import RoomCodeDisplay from '@/components/RoomCodeDisplay.vue'
|
||||
import ExitButton from '@/components/ExitButton.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useRoomStore()
|
||||
const { joinRoom, sendPlayCard, sendDeclareOneCard, sendHandPreview } = usePeerRoom()
|
||||
|
||||
const isReady = ref(false)
|
||||
const handRef = ref<InstanceType<typeof HandView> | null>(null)
|
||||
const pendingWildCardId = ref<string | null>(null)
|
||||
const showColorPicker = ref(false)
|
||||
|
||||
const isMyTurn = computed(() => store.currentTurnPlayerId === store.playerId)
|
||||
const publicState = computed(() => store.publicState)
|
||||
const drawPenalty = computed(() => publicState.value?.drawPenalty ?? 0)
|
||||
const isPlaying = computed(() => publicState.value?.phase === 'playing')
|
||||
const isRoundWon = computed(() => publicState.value?.phase === 'round_won')
|
||||
const isFinished = computed(() => publicState.value?.phase === 'finished')
|
||||
|
||||
const turnLabel = computed(() => {
|
||||
if (isRoundWon.value) {
|
||||
if (publicState.value?.winnerId === store.playerId) return '패 완료!'
|
||||
return `${publicState.value?.winnerName ?? '누군가'} 패 완료`
|
||||
}
|
||||
if (isFinished.value) {
|
||||
if (publicState.value?.winnerId === store.playerId) return '승리!'
|
||||
return `${publicState.value?.winnerName ?? '누군가'} 승리`
|
||||
}
|
||||
if (!store.gameStarted) return '대기 중'
|
||||
if (isMyTurn.value) return '내 턴'
|
||||
return '상대 턴'
|
||||
})
|
||||
|
||||
const isEliminated = computed(() => {
|
||||
if (!publicState.value || !store.gameStarted) return false
|
||||
return !publicState.value.turnOrder.includes(store.playerId)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const session = loadSession()
|
||||
if (!session || session.role !== 'player') {
|
||||
router.replace('/onecard/lobby')
|
||||
return
|
||||
}
|
||||
|
||||
store.roomCode = session.roomCode
|
||||
store.role = session.role
|
||||
store.playerId = session.playerId
|
||||
store.playerName = session.playerName || '플레이어'
|
||||
|
||||
if (!store.isConnected) {
|
||||
try {
|
||||
await joinRoom(session.roomCode, session.playerId, store.playerName)
|
||||
} catch {
|
||||
router.replace('/onecard/lobby')
|
||||
return
|
||||
}
|
||||
}
|
||||
isReady.value = true
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
sendHandPreview(null)
|
||||
})
|
||||
|
||||
function handleSelectionChange(cardId: string | null) {
|
||||
sendHandPreview(cardId)
|
||||
}
|
||||
|
||||
function handlePlay(cardId: string) {
|
||||
sendPlayCard(cardId)
|
||||
handRef.value?.clearSelection()
|
||||
}
|
||||
|
||||
function handleRequestColor(cardId: string) {
|
||||
pendingWildCardId.value = cardId
|
||||
showColorPicker.value = true
|
||||
}
|
||||
|
||||
function handleColorSelect(color: CardColor) {
|
||||
if (pendingWildCardId.value) {
|
||||
sendPlayCard(pendingWildCardId.value, color)
|
||||
handRef.value?.clearSelection()
|
||||
}
|
||||
pendingWildCardId.value = null
|
||||
showColorPicker.value = false
|
||||
}
|
||||
|
||||
function handleColorCancel() {
|
||||
pendingWildCardId.value = null
|
||||
showColorPicker.value = false
|
||||
handRef.value?.clearSelection()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main v-if="isReady" class="player">
|
||||
<RoomCodeDisplay :code="store.roomCode" compact />
|
||||
|
||||
<div class="top-actions">
|
||||
<ExitButton />
|
||||
</div>
|
||||
|
||||
<div class="turn-bar">
|
||||
<div class="turn-indicator" :class="{ active: isMyTurn && isPlaying }">
|
||||
{{ turnLabel }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="isRoundWon" class="round-won">
|
||||
<p v-if="publicState?.winnerId === store.playerId">
|
||||
🎉 패를 모두 냈습니다!
|
||||
</p>
|
||||
<p v-else>
|
||||
🎉 {{ publicState?.winnerName }}님이 패를 모두 냈습니다
|
||||
</p>
|
||||
<p class="round-won-hint">보드판에서 이어하기 / 다시 하기를 선택해주세요</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="store.gameStarted && isPlaying && !isEliminated" class="hand-area">
|
||||
<button
|
||||
v-if="store.myHand.length === 1"
|
||||
type="button"
|
||||
class="onecard-btn"
|
||||
@click="sendDeclareOneCard"
|
||||
>
|
||||
원카드!
|
||||
</button>
|
||||
<HandView
|
||||
ref="handRef"
|
||||
:cards="store.myHand"
|
||||
:top-card="publicState?.topCard ?? null"
|
||||
:active-color="publicState?.activeColor"
|
||||
:draw-penalty="drawPenalty"
|
||||
:can-interact="isMyTurn"
|
||||
@play="handlePlay"
|
||||
@request-color="handleRequestColor"
|
||||
@selection-change="handleSelectionChange"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section v-else-if="store.gameStarted && isPlaying && isEliminated" class="eliminated">
|
||||
<p>패를 모두 냈습니다</p>
|
||||
<p class="eliminated-hint">다른 플레이어 게임을 지켜보세요</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="!store.gameStarted" class="waiting">
|
||||
<p>게임 시작 대기 중...</p>
|
||||
</section>
|
||||
|
||||
<section v-else class="winner">
|
||||
<p>{{ publicState?.winnerId === store.playerId ? '🎉 승리!' : `🏆 ${publicState?.winnerName} 승리` }}</p>
|
||||
<p class="winner-hint">보드판에서 다시 하기를 눌러주세요</p>
|
||||
</section>
|
||||
|
||||
<p v-if="store.statusMessage" class="toast">{{ store.statusMessage }}</p>
|
||||
|
||||
<WildColorPicker
|
||||
:visible="showColorPicker"
|
||||
@select="handleColorSelect"
|
||||
@cancel="handleColorCancel"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.player {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
position: relative;
|
||||
background: #1a1a2e;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 300;
|
||||
}
|
||||
|
||||
.turn-bar {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.turn-indicator {
|
||||
padding: 5px 16px;
|
||||
border-radius: 14px;
|
||||
background: rgba(42, 42, 74, 0.9);
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.turn-indicator.active {
|
||||
background: #f1c40f;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.hand-area {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
max-height: 50vh;
|
||||
max-height: 50dvh;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.winner {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.winner p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.winner > p:first-child {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: #f1c40f;
|
||||
}
|
||||
|
||||
.winner-hint {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.round-won,
|
||||
.eliminated {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.round-won p,
|
||||
.eliminated > p:first-child {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: #f1c40f;
|
||||
}
|
||||
|
||||
.round-won-hint,
|
||||
.eliminated-hint {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.waiting {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.onecard-btn {
|
||||
position: fixed;
|
||||
bottom: calc(min(50vh, 50dvh) + 8px);
|
||||
right: 12px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 16px;
|
||||
border: none;
|
||||
background: #e74c3c;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
z-index: 20;
|
||||
animation: blink 1s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.75; }
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: calc(min(50vh, 50dvh) + min(24vh, 24dvh) + 12px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #2a2a4a;
|
||||
border: 2px solid #e74c3c;
|
||||
padding: 10px 18px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.85rem;
|
||||
z-index: 300;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
// Extra safety for array and object lookups, but may have false positives.
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
// Path mapping for cleaner imports.
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
|
||||
{
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
// Most tools use transpilation instead of Node.js's native type-stripping.
|
||||
// Bundler mode provides a smoother developer experience.
|
||||
"module": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
// Include Node.js types and avoid accidentally including other `@types/*` packages.
|
||||
"types": ["node"],
|
||||
|
||||
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
|
||||
"noEmit": true,
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user