first commit

This commit is contained in:
Sejong Yoo committed 2026-06-22 15:04:28 +09:00
commit 70cf3e9e9c
47 files changed
+8648

No files matched your search

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