게임기능추가

-자본금,닉네임,사용자별베팅,대소홀짝
This commit is contained in:
pd0a6847 2025-11-17 13:55:52 +09:00
parent 7509f3d23c
commit 14a3743616
2 changed files with 664 additions and 232 deletions

View File

@ -7,40 +7,59 @@ export interface Env {
interface Session {
id: string;
webSocket: WebSocket;
quit?: boolean;
nickname?: string;
capital?: number;
// 배팅 정보
oddBet: number;
evenBet: number;
bigBet: number;
smallBet: number;
// 결과
oddResult?: 'win' | 'lose' | null;
evenResult?: 'win' | 'lose' | null;
bigResult?: 'win' | 'lose' | null;
smallResult?: 'win' | 'lose' | null;
lastWinAmount?: number;
}
export class CounterDurableObject {
private ctx: DurableObjectState;
private env: Env;
private sessions: Map<WebSocket, Session>;
private count: number;
private lastUpdate: number;
private diceNumber: number;
private isPlaying: boolean;
private diceInterval: ReturnType<typeof setInterval> | null;
// 주사위 게임 상태
private noMoreBet: boolean;
private dice1: number | null;
private dice2: number | null;
private dice3: number | null;
private noMoreBetStartTime: number | null;
private noMoreBetEndTime: number | null;
private gameTimer: ReturnType<typeof setTimeout> | null;
constructor(ctx: DurableObjectState, env: Env) {
this.ctx = ctx;
this.env = env;
this.sessions = new Map();
this.count = 0;
this.lastUpdate = Date.now();
this.diceNumber = 1;
this.isPlaying = false;
this.diceInterval = null;
// Durable Objects에서 영구 저장소로부터 카운트를 복원
// 주사위 게임 초기화
this.noMoreBet = false;
this.dice1 = null;
this.dice2 = null;
this.dice3 = null;
this.noMoreBetStartTime = null;
this.noMoreBetEndTime = null;
this.gameTimer = null;
// Durable Objects에서 영구 저장소로부터 상태 복원
this.ctx.blockConcurrencyWhile(async () => {
const stored = await this.ctx.storage.get<number>('count');
if (stored !== undefined) {
this.count = stored;
}
const storedDice = await this.ctx.storage.get<number>('diceNumber');
if (storedDice !== undefined) {
this.diceNumber = storedDice;
const storedNoMoreBet = await this.ctx.storage.get<boolean>('noMoreBet');
if (storedNoMoreBet !== undefined) {
this.noMoreBet = storedNoMoreBet;
}
});
// 게임 루프 시작
this.startGameLoop();
}
async fetch(request: Request): Promise<Response> {
@ -57,7 +76,18 @@ export class CounterDurableObject {
// 세션 생성
const session: Session = {
id: crypto.randomUUID(),
webSocket: server
webSocket: server,
nickname: undefined,
capital: undefined,
oddBet: 0,
evenBet: 0,
bigBet: 0,
smallBet: 0,
oddResult: null,
evenResult: null,
bigResult: null,
smallResult: null,
lastWinAmount: 0
};
// WebSocket Hibernation API 사용
@ -78,66 +108,170 @@ export class CounterDurableObject {
async webSocketMessage(ws: WebSocket, message: ArrayBuffer | string) {
try {
const data = typeof message === 'string' ? JSON.parse(message) : null;
const session = this.sessions.get(ws);
if (data && data.type === 'increment') {
// 카운트 증가
this.count++;
this.lastUpdate = Date.now();
if (!session) return;
// 영구 저장소에 저장
await this.ctx.storage.put('count', this.count);
// 모든 클라이언트에 브로드캐스트
if (data && data.type === 'setUser') {
// 사용자 정보 설정
session.nickname = data.nickname;
session.capital = data.capital;
this.broadcast();
} else if (data && data.type === 'reset') {
// 카운트 리셋
this.count = 0;
this.lastUpdate = Date.now();
await this.ctx.storage.put('count', this.count);
} else if (data && data.type === 'bet' && !this.noMoreBet) {
// 배팅 처리
const amount = data.amount || 1000;
if (session.capital && session.capital >= amount) {
switch (data.betType) {
case 'odd':
session.oddBet += amount;
break;
case 'even':
session.evenBet += amount;
break;
case 'big':
session.bigBet += amount;
break;
case 'small':
session.smallBet += amount;
break;
}
// 클라이언트 측에서 자본금 차감은 이미 되어있으므로 서버에서도 동기화
session.capital -= amount;
this.broadcast();
} else if (data && data.type === 'play') {
// 주사위 플레이 시작
this.startDiceRolling();
} else if (data && data.type === 'stop') {
// 주사위 플레이 정지
this.stopDiceRolling();
}
}
} catch (error) {
console.error('Error handling message:', error);
}
}
private startDiceRolling() {
if (this.isPlaying) return; // 이미 실행 중이면 무시
// 게임 루프 시작
private startGameLoop() {
// 처음 시작 시 베팅 기간으로 시작 (45초)
this.startBettingPeriod();
}
this.isPlaying = true;
this.broadcast(); // 플레이 상태 전송
// 1번 로직: noMoreBet = true, 15초간 유지
private startNoMoreBetPeriod() {
this.noMoreBet = true;
this.noMoreBetStartTime = Date.now();
this.noMoreBetEndTime = this.noMoreBetStartTime + 15000; // 15초
// 1초마다 주사위 굴리기
this.diceInterval = setInterval(async () => {
// 1-6 사이 랜덤 숫자
this.diceNumber = Math.floor(Math.random() * 6) + 1;
this.lastUpdate = Date.now();
// 3개의 주사위 랜덤 생성 (1-6)
this.dice1 = Math.floor(Math.random() * 6) + 1;
this.dice2 = Math.floor(Math.random() * 6) + 1;
this.dice3 = Math.floor(Math.random() * 6) + 1;
// Durable Object Storage에 저장
await this.ctx.storage.put('diceNumber', this.diceNumber);
// 주사위 합계
const sum = this.dice1 + this.dice2 + this.dice3;
const isOdd = sum % 2 === 1;
const isBig = sum >= 10;
// 모든 클라이언트에 브로드캐스트
// 각 세션별로 배팅 결과 계산
this.sessions.forEach((session) => {
if (!session.capital) return;
let winAmount = 0;
// 홀수 배팅 결과
if (session.oddBet > 0) {
if (isOdd) {
session.oddResult = 'win';
winAmount += session.oddBet * 2;
} else {
session.oddResult = 'lose';
}
}
// 짝수 배팅 결과
if (session.evenBet > 0) {
if (!isOdd) {
session.evenResult = 'win';
winAmount += session.evenBet * 2;
} else {
session.evenResult = 'lose';
}
}
// 대 배팅 결과
if (session.bigBet > 0) {
if (isBig) {
session.bigResult = 'win';
winAmount += session.bigBet * 2;
} else {
session.bigResult = 'lose';
}
}
// 소 배팅 결과
if (session.smallBet > 0) {
if (!isBig) {
session.smallResult = 'win';
winAmount += session.smallBet * 2;
} else {
session.smallResult = 'lose';
}
}
// 자본금 업데이트
const totalBet = session.oddBet + session.evenBet + session.bigBet + session.smallBet;
session.lastWinAmount = winAmount - totalBet;
session.capital += winAmount;
});
// 상태 저장
this.ctx.storage.put('noMoreBet', this.noMoreBet);
// 브로드캐스트 (15초간 한 번만)
this.broadcast();
}, 1000);
// 15초 후 베팅 기간으로 전환
if (this.gameTimer) {
clearTimeout(this.gameTimer);
}
this.gameTimer = setTimeout(() => {
this.startBettingPeriod();
}, 15000);
}
private stopDiceRolling() {
if (!this.isPlaying) return;
// 2번 로직: noMoreBet = false, 45초간 유지
private startBettingPeriod() {
this.noMoreBet = false;
this.noMoreBetStartTime = Date.now();
this.noMoreBetEndTime = this.noMoreBetStartTime + 45000; // 45초
this.isPlaying = false;
// 주사위 값 클리어
this.dice1 = null;
this.dice2 = null;
this.dice3 = null;
if (this.diceInterval) {
clearInterval(this.diceInterval);
this.diceInterval = null;
// 모든 세션의 배팅 및 결과 클리어
this.sessions.forEach((session) => {
session.oddBet = 0;
session.evenBet = 0;
session.bigBet = 0;
session.smallBet = 0;
session.oddResult = null;
session.evenResult = null;
session.bigResult = null;
session.smallResult = null;
session.lastWinAmount = 0;
});
// 상태 저장
this.ctx.storage.put('noMoreBet', this.noMoreBet);
// 브로드캐스트
this.broadcast();
// 45초 후 noMoreBet 기간으로 전환
if (this.gameTimer) {
clearTimeout(this.gameTimer);
}
this.broadcast(); // 정지 상태 전송
this.gameTimer = setTimeout(() => {
this.startNoMoreBetPeriod();
}, 45000);
}
async webSocketClose(ws: WebSocket, code: number, _reason: string, _wasClean: boolean) {
@ -145,11 +279,6 @@ export class CounterDurableObject {
this.sessions.delete(ws);
ws.close(code, 'Durable Object is closing WebSocket');
// 남은 클라이언트가 없으면 주사위 정지
const connectedWebSockets = this.ctx.getWebSockets();
if (connectedWebSockets.length === 0) {
this.stopDiceRolling();
}
// 남은 클라이언트들에게 업데이트 전송
this.broadcast();
@ -159,18 +288,52 @@ export class CounterDurableObject {
// WebSocket Hibernation API를 사용할 때는 getWebSockets()로 실제 연결 수를 확인
const connectedWebSockets = this.ctx.getWebSockets();
const message = JSON.stringify({
count: this.count,
online: connectedWebSockets.length,
lastUpdate: this.lastUpdate,
diceNumber: this.diceNumber,
isPlaying: this.isPlaying
// 전체 사용자 배팅 내역 수집
const allBettings: Array<{
nickname: string;
oddBet: number;
evenBet: number;
bigBet: number;
smallBet: number;
}> = [];
this.sessions.forEach((session) => {
if (session.nickname && (session.oddBet > 0 || session.evenBet > 0 || session.bigBet > 0 || session.smallBet > 0)) {
allBettings.push({
nickname: session.nickname,
oddBet: session.oddBet,
evenBet: session.evenBet,
bigBet: session.bigBet,
smallBet: session.smallBet
});
}
});
// 모든 연결된 WebSocket에 메시지 전송
// 각 세션별로 메시지 전송
// @ts-ignore - Cloudflare Workers types 불일치
connectedWebSockets.forEach((ws: WebSocket) => {
try {
const session = this.sessions.get(ws);
const message = JSON.stringify({
noMoreBet: this.noMoreBet,
dice1: this.dice1,
dice2: this.dice2,
dice3: this.dice3,
noMoreBetStartTime: this.noMoreBetStartTime,
noMoreBetEndTime: this.noMoreBetEndTime,
online: connectedWebSockets.length,
// 세션별 배팅 정보
capital: session?.capital,
oddResult: session?.oddResult,
evenResult: session?.evenResult,
bigResult: session?.bigResult,
smallResult: session?.smallResult,
lastWinAmount: session?.lastWinAmount,
// 전체 사용자 배팅 내역
allBettings: allBettings
});
ws.send(message);
} catch (error) {
console.error('Error broadcasting to client:', error);

View File

@ -1,15 +1,52 @@
<script lang="ts">
let count = $state(0);
let online = $state(0);
let lastUpdate = $state<Date | null>(null);
let isConnected = $state(false);
let isConnecting = $state(false);
let ws = $state<WebSocket | null>(null);
let diceNumber = $state(1);
let isPlaying = $state(false);
// 사용자 정보
let nickname = $state('');
let capital = $state(10000); // 초기 자본금
let inputNickname = $state('');
let inputCapital = $state('10000');
// 주사위 게임 상태
let noMoreBet = $state(false);
let dice1 = $state<number | null>(null);
let dice2 = $state<number | null>(null);
let dice3 = $state<number | null>(null);
let noMoreBetStartTime = $state<number | null>(null);
let noMoreBetEndTime = $state<number | null>(null);
// 배팅 관련
let oddBet = $state(0);
let evenBet = $state(0);
let bigBet = $state(0);
let smallBet = $state(0);
let oddResult = $state<'win' | 'lose' | null>(null);
let evenResult = $state<'win' | 'lose' | null>(null);
let bigResult = $state<'win' | 'lose' | null>(null);
let smallResult = $state<'win' | 'lose' | null>(null);
let lastWinAmount = $state(0);
// 모든 사용자의 배팅 내역
type BettingInfo = {
nickname: string;
oddBet: number;
evenBet: number;
bigBet: number;
smallBet: number;
};
let allBettings = $state<BettingInfo[]>([]);
// Progress bar 관련
let progressPercent = $state(0);
let remainingTime = $state(0);
function connectWebSocket() {
isConnecting = true;
nickname = inputNickname;
capital = parseInt(inputCapital);
// WebSocket 프로토콜 결정 (https -> wss, http -> ws)
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
@ -21,16 +58,54 @@
isConnected = true;
isConnecting = false;
console.log('WebSocket connected');
// 서버에 사용자 정보 전송
if (ws) {
ws.send(JSON.stringify({
type: 'setUser',
nickname: nickname,
capital: capital
}));
}
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
count = data.count;
noMoreBet = data.noMoreBet;
dice1 = data.dice1;
dice2 = data.dice2;
dice3 = data.dice3;
noMoreBetStartTime = data.noMoreBetStartTime;
noMoreBetEndTime = data.noMoreBetEndTime;
online = data.online;
lastUpdate = new Date(data.lastUpdate);
diceNumber = data.diceNumber || 1;
isPlaying = data.isPlaying || false;
// 배팅 결과
if (data.capital !== undefined) {
capital = data.capital;
}
if (data.oddResult !== undefined) oddResult = data.oddResult;
if (data.evenResult !== undefined) evenResult = data.evenResult;
if (data.bigResult !== undefined) bigResult = data.bigResult;
if (data.smallResult !== undefined) smallResult = data.smallResult;
if (data.lastWinAmount !== undefined) lastWinAmount = data.lastWinAmount;
// 모든 사용자의 배팅 내역
if (data.allBettings) {
allBettings = data.allBettings;
}
// 새로운 라운드가 시작되면 배팅 초기화
if (!noMoreBet && data.dice1 === null) {
oddBet = 0;
evenBet = 0;
bigBet = 0;
smallBet = 0;
oddResult = null;
evenResult = null;
bigResult = null;
smallResult = null;
}
} catch (error) {
console.error('Error parsing message:', error);
}
@ -55,27 +130,35 @@
}
}
function incrementCount() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'increment' }));
function betOdd() {
if (ws && ws.readyState === WebSocket.OPEN && !noMoreBet && capital >= 1000) {
oddBet += 1000;
capital -= 1000;
ws.send(JSON.stringify({ type: 'bet', betType: 'odd', amount: 1000 }));
}
}
function resetCount() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'reset' }));
function betEven() {
if (ws && ws.readyState === WebSocket.OPEN && !noMoreBet && capital >= 1000) {
evenBet += 1000;
capital -= 1000;
ws.send(JSON.stringify({ type: 'bet', betType: 'even', amount: 1000 }));
}
}
function playDice() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'play' }));
function betBig() {
if (ws && ws.readyState === WebSocket.OPEN && !noMoreBet && capital >= 1000) {
bigBet += 1000;
capital -= 1000;
ws.send(JSON.stringify({ type: 'bet', betType: 'big', amount: 1000 }));
}
}
function stopDice() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'stop' }));
function betSmall() {
if (ws && ws.readyState === WebSocket.OPEN && !noMoreBet && capital >= 1000) {
smallBet += 1000;
capital -= 1000;
ws.send(JSON.stringify({ type: 'bet', betType: 'small', amount: 1000 }));
}
}
@ -116,6 +199,29 @@
return patterns[number] || patterns[1];
}
// Progress bar 업데이트
$effect(() => {
let interval: ReturnType<typeof setInterval> | null = null;
if (noMoreBetStartTime !== null && noMoreBetEndTime !== null) {
interval = setInterval(() => {
if (noMoreBetStartTime === null || noMoreBetEndTime === null) return;
const now = Date.now();
const total = noMoreBetEndTime - noMoreBetStartTime;
const elapsed = now - noMoreBetStartTime;
const remaining = Math.max(0, noMoreBetEndTime - now);
progressPercent = Math.min(100, (elapsed / total) * 100);
remainingTime = Math.ceil(remaining / 1000);
}, 100);
}
return () => {
if (interval) clearInterval(interval);
};
});
// 컴포넌트 언마운트 시 WebSocket 정리
$effect(() => {
return () => {
@ -124,15 +230,18 @@
});
</script>
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div class="max-w-2xl w-full bg-white rounded-2xl shadow-xl p-8">
<div class="min-h-screen bg-gradient-to-br from-purple-50 to-pink-100 flex items-center justify-center p-4">
<div class="max-w-3xl w-full bg-white rounded-2xl shadow-xl p-8">
<h1 class="text-4xl font-bold text-center mb-8 text-gray-800">
Cloudflare Durable Objects + WebSocket
Durable Objects TEST
</h1>
<!-- 연결 상태, 접속자 수, 자본금 (3 columns) -->
<div class="grid grid-cols-3 gap-4 mb-8">
<!-- 연결 상태 -->
<div class="mb-8 p-4 rounded-lg {isConnected ? 'bg-green-50 border-2 border-green-200' : 'bg-gray-50 border-2 border-gray-200'}">
<div class="flex items-center justify-between">
<div
class="p-4 rounded-lg {isConnected ? 'bg-green-50 border-2 border-green-200' : 'bg-gray-50 border-2 border-gray-200'}">
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full {isConnected ? 'bg-green-500 animate-pulse' : 'bg-gray-400'}"></div>
<span class="font-medium text-gray-700">
@ -140,17 +249,29 @@
</span>
</div>
{#if !isConnected}
<input
type="text"
bind:value={inputNickname}
placeholder="닉네임"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<input
type="number"
bind:value={inputCapital}
placeholder="초기 자본금"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onclick={connectWebSocket}
disabled={isConnecting}
class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
disabled={isConnecting || !inputNickname || !inputCapital}
class="w-full px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isConnecting ? '연결 중...' : '연결하기'}
</button>
{:else}
<button
onclick={disconnectWebSocket}
class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
class="w-full px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
>
연결 끊기
</button>
@ -158,135 +279,283 @@
</div>
</div>
<!-- 카운터 디스플레이 -->
<div class="mb-8">
<div class="text-center mb-6">
<div class="text-6xl font-bold text-indigo-600 mb-2">
{count}
</div>
<div class="text-gray-600">전체 카운트</div>
</div>
<!-- 버튼들 -->
<div class="flex gap-4 justify-center mb-6">
<button
onclick={incrementCount}
disabled={!isConnected}
class="px-8 py-3 bg-indigo-500 text-white rounded-lg font-semibold hover:bg-indigo-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all transform hover:scale-105"
>
카운트 증가 +1
</button>
<button
onclick={resetCount}
disabled={!isConnected}
class="px-8 py-3 bg-orange-500 text-white rounded-lg font-semibold hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all transform hover:scale-105"
>
리셋
</button>
</div>
</div>
<!-- 통계 정보 -->
<div class="grid grid-cols-2 gap-4 mb-8">
<div class="bg-blue-50 p-4 rounded-lg border-2 border-blue-100">
<div class="text-3xl font-bold text-blue-600 mb-1">
<!-- 접속자 수 -->
<div
class="bg-gradient-to-br from-green-50 to-emerald-50 p-4 rounded-lg border-2 border-green-200 flex flex-col items-center justify-center">
<div class="text-4xl font-bold text-green-600 mb-1">
{online}
</div>
<div class="text-sm text-gray-600">실시간 접속자</div>
</div>
<div class="bg-purple-50 p-4 rounded-lg border-2 border-purple-100">
<div class="text-lg font-semibold text-purple-600 mb-1">
{lastUpdate ? lastUpdate.toLocaleTimeString('ko-KR') : '-'}
<!-- 현재 자본금 -->
<div
class="bg-gradient-to-br from-amber-50 to-yellow-50 p-4 rounded-lg border-2 border-amber-200 flex flex-col items-center justify-center">
{#if isConnected && nickname}
<div class="text-xs text-gray-600 mb-1">{nickname}</div>
<div class="text-3xl font-bold text-amber-700 mb-1">{capital.toLocaleString()}</div>
<div class="text-xs text-gray-600">현재 자본금</div>
{#if lastWinAmount !== 0}
<div class="text-sm font-semibold mt-1 {lastWinAmount > 0 ? 'text-green-600' : 'text-red-600'}">
{lastWinAmount > 0 ? '+' : ''}{lastWinAmount.toLocaleString()}
</div>
<div class="text-sm text-gray-600">마지막 업데이트</div>
{/if}
{:else}
<div class="text-2xl font-bold text-gray-400">-</div>
<div class="text-xs text-gray-600">자본금</div>
{/if}
</div>
</div>
<!-- Progress Bar -->
<div class="mb-8 p-6 bg-gradient-to-br from-indigo-50 to-purple-50 rounded-xl border-2 border-indigo-200">
<div class="mb-4">
<div class="flex justify-between items-center mb-2">
<span class="font-semibold text-gray-700">
{noMoreBet ? '🚫 베팅 마감' : '✅ 베팅 가능'}
</span>
<span class="text-lg font-bold text-indigo-600">
{remainingTime}
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-4 overflow-hidden">
<div
class="h-full transition-all duration-100 {noMoreBet ? 'bg-red-500' : 'bg-green-500'}"
style="width: {progressPercent}%"
></div>
</div>
</div>
<div class="text-sm text-gray-600 text-center">
{noMoreBet ? '주사위를 굴리는 중... 다음 라운드를 기다려주세요' : '홀/짝, 대/소를 선택하세요!'}
</div>
</div>
<!-- 주사위 성능 테스트 -->
<div class="bg-gradient-to-br from-yellow-50 to-orange-50 p-6 rounded-xl border-2 border-yellow-200 mb-8">
<h2 class="text-2xl font-bold text-center mb-4 text-gray-800">
🎲 Durable Object 성능 테스트
</h2>
<!-- 주사위 디스플레이 -->
<div class="flex justify-center mb-6">
{#if dice1 !== null && dice2 !== null && dice3 !== null}
<div class="mb-8 p-6 bg-gradient-to-br from-yellow-50 to-orange-50 rounded-xl border-2 border-yellow-200">
<h2 class="text-2xl font-bold text-center mb-4 text-gray-800">
🎲 결과
</h2>
<!-- 3개의 주사위 -->
<div class="flex justify-center gap-6 mb-6">
{#each [dice1, dice2, dice3] as dice}
<div class="relative">
<div class="w-32 h-32 bg-white rounded-2xl shadow-2xl border-4 border-gray-800 p-4
transform transition-all duration-300 {isPlaying ? 'animate-bounce' : ''}">
<div class="grid grid-cols-3 gap-2 h-full">
{#each getDiceDots(diceNumber) as row}
<div class="w-24 h-24 bg-white rounded-2xl shadow-2xl border-4 border-gray-800 p-3">
<div class="grid grid-cols-3 gap-1 h-full">
{#each getDiceDots(dice) as row}
{#each row as dot}
<div class="flex items-center justify-center">
{#if dot}
<div class="w-4 h-4 bg-gray-800 rounded-full"></div>
<div class="w-3 h-3 bg-gray-800 rounded-full"></div>
{/if}
</div>
{/each}
{/each}
</div>
</div>
<!-- 주사위 번호 표시 -->
<div class="absolute -top-3 -right-3 w-10 h-10 bg-red-500 text-white
rounded-full flex items-center justify-center text-xl font-bold shadow-lg">
{diceNumber}
<div class="absolute -top-2 -right-2 w-8 h-8 bg-red-500 text-white
rounded-full flex items-center justify-center text-sm font-bold shadow-lg">
{dice}
</div>
</div>
{/each}
</div>
<!-- 합계 -->
<div class="text-center mb-4">
<div class="text-4xl font-bold text-indigo-600 mb-2">
합계: {dice1 + dice2 + dice3}
</div>
<div class="flex justify-center gap-4 text-2xl font-semibold">
<span class="{(dice1 + dice2 + dice3) % 2 === 1 ? 'text-blue-600' : 'text-gray-400'}">
{(dice1 + dice2 + dice3) % 2 === 1 ? '✓ 홀수' : '홀수'}
</span>
<span class="{(dice1 + dice2 + dice3) % 2 === 0 ? 'text-pink-600' : 'text-gray-400'}">
{(dice1 + dice2 + dice3) % 2 === 0 ? '✓ 짝수' : '짝수'}
</span>
</div>
<div class="flex justify-center gap-4 text-2xl font-semibold mt-2">
<span class="{(dice1 + dice2 + dice3) >= 10 ? 'text-orange-600' : 'text-gray-400'}">
{(dice1 + dice2 + dice3) >= 10 ? '✓ 대' : '대'}
</span>
<span class="{(dice1 + dice2 + dice3) <= 9 ? 'text-purple-600' : 'text-gray-400'}">
{(dice1 + dice2 + dice3) <= 9 ? '✓ 소' : '소'}
</span>
</div>
</div>
<!-- 플레이 컨트롤 -->
<div class="flex gap-4 justify-center mb-4">
{#if !isPlaying}
<!-- 배팅 결과 표시 -->
{#if oddBet > 0 || evenBet > 0 || bigBet > 0 || smallBet > 0}
<div class="grid grid-cols-2 gap-3 mb-4">
{#if oddBet > 0}
<div class="p-3 rounded-lg {oddResult === 'win' ? 'bg-green-100 border-2 border-green-300' : oddResult === 'lose' ? 'bg-red-100 border-2 border-red-300' : 'bg-gray-100 border-2 border-gray-300'}">
<div class="text-sm text-gray-600">홀수 배팅</div>
<div class="text-lg font-bold">{oddBet.toLocaleString()}</div>
{#if oddResult === 'win'}
<div class="text-green-600 font-semibold">✓ 승리 +{oddBet.toLocaleString()}</div>
{:else if oddResult === 'lose'}
<div class="text-red-600 font-semibold">✗ 패배</div>
{/if}
</div>
{/if}
{#if evenBet > 0}
<div class="p-3 rounded-lg {evenResult === 'win' ? 'bg-green-100 border-2 border-green-300' : evenResult === 'lose' ? 'bg-red-100 border-2 border-red-300' : 'bg-gray-100 border-2 border-gray-300'}">
<div class="text-sm text-gray-600">짝수 배팅</div>
<div class="text-lg font-bold">{evenBet.toLocaleString()}</div>
{#if evenResult === 'win'}
<div class="text-green-600 font-semibold">✓ 승리 +{evenBet.toLocaleString()}</div>
{:else if evenResult === 'lose'}
<div class="text-red-600 font-semibold">✗ 패배</div>
{/if}
</div>
{/if}
{#if bigBet > 0}
<div class="p-3 rounded-lg {bigResult === 'win' ? 'bg-green-100 border-2 border-green-300' : bigResult === 'lose' ? 'bg-red-100 border-2 border-red-300' : 'bg-gray-100 border-2 border-gray-300'}">
<div class="text-sm text-gray-600">대 배팅</div>
<div class="text-lg font-bold">{bigBet.toLocaleString()}</div>
{#if bigResult === 'win'}
<div class="text-green-600 font-semibold">✓ 승리 +{bigBet.toLocaleString()}</div>
{:else if bigResult === 'lose'}
<div class="text-red-600 font-semibold">✗ 패배</div>
{/if}
</div>
{/if}
{#if smallBet > 0}
<div class="p-3 rounded-lg {smallResult === 'win' ? 'bg-green-100 border-2 border-green-300' : smallResult === 'lose' ? 'bg-red-100 border-2 border-red-300' : 'bg-gray-100 border-2 border-gray-300'}">
<div class="text-sm text-gray-600">소 배팅</div>
<div class="text-lg font-bold">{smallBet.toLocaleString()}</div>
{#if smallResult === 'win'}
<div class="text-green-600 font-semibold">✓ 승리 +{smallBet.toLocaleString()}</div>
{:else if smallResult === 'lose'}
<div class="text-red-600 font-semibold">✗ 패배</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
{/if}
<!-- 베팅 버튼 -->
<div class="mb-8 p-6 bg-gradient-to-br from-blue-50 to-cyan-50 rounded-xl border-2 border-blue-200">
<h2 class="text-xl font-bold text-center mb-4 text-gray-800">
배팅 선택 (1000원씩 배팅)
</h2>
<!-- 홀/짝/대/소 버튼 (4개 컬럼) -->
<div class="grid grid-cols-4 gap-4">
<button
onclick={playDice}
disabled={!isConnected}
class="px-8 py-3 bg-green-500 text-white rounded-lg font-semibold
hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed
transition-all transform hover:scale-105 flex items-center gap-2"
onclick={betOdd}
disabled={!isConnected || noMoreBet || capital < 1000}
class="px-4 py-4 bg-blue-500 text-white rounded-xl font-bold text-xl
hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed
transition-all transform hover:scale-105"
>
<span class="text-2xl">▶️</span>
<span>플레이</span>
홀수
{#if oddBet > 0}
<div class="text-sm mt-1">{oddBet.toLocaleString()}</div>
{/if}
</button>
{:else}
<button
onclick={stopDice}
disabled={!isConnected}
class="px-8 py-3 bg-red-500 text-white rounded-lg font-semibold
hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed
transition-all transform hover:scale-105 flex items-center gap-2"
onclick={betEven}
disabled={!isConnected || noMoreBet || capital < 1000}
class="px-4 py-4 bg-pink-500 text-white rounded-xl font-bold text-xl
hover:bg-pink-600 disabled:opacity-50 disabled:cursor-not-allowed
transition-all transform hover:scale-105"
>
<span class="text-2xl">⏸️</span>
<span>정지</span>
짝수
{#if evenBet > 0}
<div class="text-sm mt-1">{evenBet.toLocaleString()}</div>
{/if}
</button>
<button
onclick={betBig}
disabled={!isConnected || noMoreBet || capital < 1000}
class="px-4 py-4 bg-orange-500 text-white rounded-xl font-bold text-xl
hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed
transition-all transform hover:scale-105"
>
대 (大)
{#if bigBet > 0}
<div class="text-sm mt-1">{bigBet.toLocaleString()}</div>
{/if}
</button>
<button
onclick={betSmall}
disabled={!isConnected || noMoreBet || capital < 1000}
class="px-4 py-4 bg-purple-500 text-white rounded-xl font-bold text-xl
hover:bg-purple-600 disabled:opacity-50 disabled:cursor-not-allowed
transition-all transform hover:scale-105"
>
소 (小)
{#if smallBet > 0}
<div class="text-sm mt-1">{smallBet.toLocaleString()}</div>
{/if}
</button>
</div>
{#if capital < 1000 && isConnected}
<div class="mt-4 text-center text-sm text-red-600 font-semibold">
자본금이 부족합니다!
</div>
{/if}
</div>
<!-- 상태 표시 -->
<div class="text-center">
<div class="inline-block px-4 py-2 rounded-full {isPlaying ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700'}">
{isPlaying ? '🔄 주사위가 1초마다 자동으로 굴러가는 중...' : '⏸️ 정지됨'}
<!-- 전체 사용자 배팅 내역 -->
{#if isConnected && allBettings.length > 0}
<div class="mb-8 p-6 bg-gradient-to-br from-slate-50 to-gray-50 rounded-xl border-2 border-slate-200">
<h2 class="text-xl font-bold text-center mb-4 text-gray-800">
📊 전체 배팅 현황
</h2>
<div class="space-y-3 max-h-60 overflow-y-auto">
{#each allBettings as betting}
<div class="p-3 bg-white rounded-lg border border-gray-200">
<div class="font-semibold text-gray-800 mb-2">{betting.nickname}</div>
<div class="grid grid-cols-4 gap-2 text-sm">
{#if betting.oddBet > 0}
<div class="bg-blue-50 p-2 rounded text-center">
<div class="text-xs text-gray-600"></div>
<div class="font-semibold text-blue-600">{betting.oddBet.toLocaleString()}</div>
</div>
{/if}
{#if betting.evenBet > 0}
<div class="bg-pink-50 p-2 rounded text-center">
<div class="text-xs text-gray-600"></div>
<div class="font-semibold text-pink-600">{betting.evenBet.toLocaleString()}</div>
</div>
{/if}
{#if betting.bigBet > 0}
<div class="bg-orange-50 p-2 rounded text-center">
<div class="text-xs text-gray-600"></div>
<div class="font-semibold text-orange-600">{betting.bigBet.toLocaleString()}</div>
</div>
{/if}
{#if betting.smallBet > 0}
<div class="bg-purple-50 p-2 rounded text-center">
<div class="text-xs text-gray-600"></div>
<div class="font-semibold text-purple-600">{betting.smallBet.toLocaleString()}</div>
</div>
{/if}
</div>
</div>
<!-- 설명 -->
<div class="mt-4 p-3 bg-white/50 rounded-lg text-sm text-gray-600">
<p class="font-semibold mb-1">💡 성능 테스트:</p>
<p>• 플레이 버튼 클릭 시 1초마다 주사위(1-6)가 자동으로 변경됩니다</p>
<p>• 모든 값은 Durable Object에 실시간 저장됩니다</p>
<p>• 모든 연결된 클라이언트에 즉시 동기화됩니다</p>
{/each}
</div>
</div>
{/if}
<!-- 설명 -->
<div class="mt-8 p-4 bg-gray-50 rounded-lg border border-gray-200">
<h2 class="font-semibold text-gray-800 mb-2">기능 설명</h2>
<h2 class="font-semibold text-gray-800 mb-2">게임 규칙</h2>
<ul class="text-sm text-gray-600 space-y-1">
<li>✅ Cloudflare Durable Objects로 상태 관리</li>
<li>✅ WebSocket으로 실시간 양방향 통신</li>
<li>✅ 카운트 버튼 클릭으로 증가</li>
<li>✅ 실시간 접속자 수 표시</li>
<li>✅ 모든 클라이언트에 실시간 동기화</li>
<li>✅ Durable Objects 영구 저장소에 상태 저장</li>
<li>🎲 초단위 주사위 자동 롤링으로 성능 테스트</li>
<li>🎲 <strong>베팅 기간 (45초)</strong>: 홀수 또는 짝수 선택 가능</li>
<li>🚫 <strong>베팅 마감 (15초)</strong>: 주사위 결과 확인 및 대기</li>
<li>📊 Progress bar로 남은 시간 시각</li>
<li>🔄 게임은 자동으로 반복됩니다</li>
<li>👥 모든 접속자에게 실시간 동기화</li>
</ul>
</div>
</div>