498 lines
19 KiB
Vue
498 lines
19 KiB
Vue
<template>
|
||
<div class="game">
|
||
<!-- End of game modal overlay -->
|
||
<div v-if="endModal.visible" class="end-modal-overlay" @click.self="dismissEndModal">
|
||
<div class="end-modal">
|
||
<button class="close-btn" @click="dismissEndModal" aria-label="Cerrar">×</button>
|
||
<div class="title">🏁 Juego finalizado</div>
|
||
<div class="scores">
|
||
<div v-for="s in finalScores" :key="s.sessionId" class="score-row">
|
||
<span class="name">{{ s.name }}</span>
|
||
<span class="tokens">🦃 {{ s.pavo }} · 🌽 {{ s.elote }}</span>
|
||
<span class="points">Puntos: {{ s.points }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button @click="changeToPreviousVariant" class="btn btn-prev-variant">
|
||
⏪ {{ getPreviousVariant() }}
|
||
</button>
|
||
<button @click="restartCurrentVariant" class="btn btn-restart-variant">
|
||
🔄 {{ currentVariant }}
|
||
</button>
|
||
<button @click="changeToNextVariant" class="btn btn-next-variant">
|
||
{{ getNextVariant() }} ⏩
|
||
</button>
|
||
</div>
|
||
<div class="hint">Se cerrará en {{ remainingSeconds }}s</div>
|
||
</div>
|
||
</div>
|
||
<div class="game-container">
|
||
<div class="game-header">
|
||
<h1>🧪 Demo Room</h1>
|
||
<div class="meta">
|
||
<div>Room: <code>{{ roomId }}</code></div>
|
||
<div>Round: {{ currentRound }}/3</div>
|
||
<div>Status: <span class="badge">{{ gameStatus }}</span></div>
|
||
</div>
|
||
<div class="variant-selector">
|
||
<button v-for="g in variants" :key="g" @click="setVariant(g)" :class="['btn', 'btn-variant', { active: currentVariant === g }]">
|
||
{{ g }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="players-section">
|
||
<PlayerStats v-for="p in players" :key="p.sessionId" :player="p" :highlight="p.sessionId === sessionId" />
|
||
</div>
|
||
|
||
<div v-if="gameStatus === 'waiting'" class="waiting-area">
|
||
<div class="waiting-message">
|
||
<div class="spinner"></div>
|
||
<h2>Waiting for opponent...</h2>
|
||
<p>Players in room: {{ players.length }}/2</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else class="gameplay">
|
||
<component :is="currentComponent"
|
||
:state="roundState"
|
||
:my-role="myRole"
|
||
:players="players"
|
||
@p2Force="onP2Force"
|
||
@p1Action="onP1Action"
|
||
@p2Action="onP2Action"
|
||
@report="onReport"
|
||
@assignShame="onAssignShame"
|
||
@proposeOffer="onProposeOffer"
|
||
/>
|
||
|
||
<div class="outcome" v-if="outcomeP1 || outcomeP2">
|
||
<div class="outcome-box">
|
||
<div>Outcome P1: <strong>{{ outcomeP1 }}</strong></div>
|
||
<div>Outcome P2: <strong>{{ outcomeP2 }}</strong></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<ChatWidget />
|
||
|
||
<div class="game-footer">
|
||
<button @click="leaveGame" class="btn btn-leave">Leave Game</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Pause overlay to block all interactions -->
|
||
<div v-if="gameStatus === 'paused'" class="pause-overlay">
|
||
<div class="pause-box">
|
||
<div class="icon">⏸️</div>
|
||
<div class="title">Juego en pausa</div>
|
||
<div class="hint">Esperando a que ambos jugadores estén conectados…</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted, computed } from 'vue';
|
||
import { useRouter, useRoute } from 'vue-router';
|
||
import { colyseusService } from '../services/colyseus';
|
||
import { getStateCallbacks } from 'colyseus.js';
|
||
|
||
import G1 from './games/G1.vue';
|
||
import G2 from './games/G2.vue';
|
||
import G3 from './games/G3.vue';
|
||
import G4 from './games/G4.vue';
|
||
import G5 from './games/G5.vue';
|
||
import PlayerStats from './games/PlayerStats.vue';
|
||
import ChatWidget from './games/ChatWidget.vue';
|
||
|
||
const router = useRouter();
|
||
const route = useRoute();
|
||
const routeUuid = computed(() => (route.params as any)?.uuid as string || '');
|
||
|
||
const players = ref<any[]>([]);
|
||
const gameStatus = ref('waiting');
|
||
const roomId = ref('');
|
||
const currentVariant = ref<'G1'|'G2'|'G3'|'G4'|'G5'>('G1');
|
||
const currentRound = ref(1);
|
||
const p1Action = ref('');
|
||
const p2Action = ref('');
|
||
const forcedByP2 = ref(false);
|
||
const reported = ref(false);
|
||
const shameAssigned = ref(false);
|
||
const outcomeP1 = ref(0);
|
||
const outcomeP2 = ref(0);
|
||
|
||
const variants = ['G1','G2','G3','G4','G5'];
|
||
|
||
// End-of-game modal state and helpers
|
||
const endModal = ref<{ visible: boolean }>({ visible: false });
|
||
const remainingSeconds = ref(20);
|
||
let endTimerTimeout: any = null;
|
||
let endTimerInterval: any = null;
|
||
|
||
function showEndModal() {
|
||
// Prevent multiple timers
|
||
if (endModal.value.visible) return;
|
||
endModal.value.visible = true;
|
||
remainingSeconds.value = 20;
|
||
if (endTimerInterval) clearInterval(endTimerInterval);
|
||
if (endTimerTimeout) clearTimeout(endTimerTimeout);
|
||
endTimerInterval = setInterval(() => {
|
||
remainingSeconds.value = Math.max(0, remainingSeconds.value - 1);
|
||
}, 1000);
|
||
endTimerTimeout = setTimeout(() => {
|
||
dismissEndModal();
|
||
}, 20000);
|
||
}
|
||
|
||
function dismissEndModal() {
|
||
endModal.value.visible = false;
|
||
if (endTimerInterval) { clearInterval(endTimerInterval); endTimerInterval = null; }
|
||
if (endTimerTimeout) { clearTimeout(endTimerTimeout); endTimerTimeout = null; }
|
||
}
|
||
|
||
// Function to get next variant in sequence
|
||
function getNextVariant(): string {
|
||
const currentIndex = variants.indexOf(currentVariant.value);
|
||
const nextIndex = (currentIndex + 1) % variants.length;
|
||
return variants[nextIndex];
|
||
}
|
||
|
||
// Function to get previous variant in sequence
|
||
function getPreviousVariant(): string {
|
||
const currentIndex = variants.indexOf(currentVariant.value);
|
||
const previousIndex = (currentIndex - 1 + variants.length) % variants.length;
|
||
return variants[previousIndex];
|
||
}
|
||
|
||
// Function to change to next variant and dismiss modal
|
||
function changeToNextVariant() {
|
||
const nextVariant = getNextVariant();
|
||
setVariant(nextVariant);
|
||
dismissEndModal();
|
||
}
|
||
|
||
// Function to change to previous variant and dismiss modal
|
||
function changeToPreviousVariant() {
|
||
const previousVariant = getPreviousVariant();
|
||
setVariant(previousVariant);
|
||
dismissEndModal();
|
||
}
|
||
|
||
// Function to restart the same variant and dismiss modal
|
||
function restartCurrentVariant() {
|
||
setVariant(currentVariant.value);
|
||
dismissEndModal();
|
||
}
|
||
|
||
const finalScores = computed(() => {
|
||
return players.value.map(p => {
|
||
const points = (p.role === 'P2')
|
||
? (p.eloteTokens || 0) * 1 + (p.pavoTokens || 0) * 2
|
||
: (p.pavoTokens || 0) * 1 + (p.eloteTokens || 0) * 2;
|
||
return {
|
||
sessionId: p.sessionId,
|
||
name: p.name,
|
||
pavo: p.pavoTokens || 0,
|
||
elote: p.eloteTokens || 0,
|
||
points
|
||
};
|
||
}).sort((a, b) => b.points - a.points);
|
||
});
|
||
|
||
// Round transition banner state and helper
|
||
const roundBanner = ref<{ visible: boolean; text: string; kind: 'start'|'end' }>({ visible: false, text: '', kind: 'start' });
|
||
let roundBannerTimeout: any = null;
|
||
|
||
function showRoundBanner(text: string, kind: 'start'|'end', ms = 1400) {
|
||
if (roundBannerTimeout) { clearTimeout(roundBannerTimeout); roundBannerTimeout = null; }
|
||
roundBanner.value = { visible: true, text, kind };
|
||
roundBannerTimeout = setTimeout(() => { roundBanner.value.visible = false; }, ms);
|
||
}
|
||
|
||
const sessionId = computed(() => colyseusService.sessionId.value);
|
||
const myRole = computed(() => {
|
||
const me = players.value.find(p => p.sessionId === sessionId.value);
|
||
return me?.role || '';
|
||
});
|
||
|
||
const roundState = computed(() => ({
|
||
currentVariant: currentVariant.value,
|
||
currentRound: currentRound.value,
|
||
p1Action: p1Action.value,
|
||
p2Action: p2Action.value,
|
||
forcedByP2: forcedByP2.value,
|
||
reported: reported.value,
|
||
shameAssigned: shameAssigned.value,
|
||
offer: {
|
||
offerPavo: roomOffer('offerPavo'),
|
||
offerElote: roomOffer('offerElote'),
|
||
requestPavo: roomOffer('requestPavo'),
|
||
requestElote: roomOffer('requestElote'),
|
||
active: roomOffer('offerActive')
|
||
}
|
||
}));
|
||
|
||
const componentMap: Record<string, any> = { G1, G2, G3, G4, G5 };
|
||
const currentComponent = computed(() => componentMap[currentVariant.value]);
|
||
|
||
onMounted(() => {
|
||
let room = colyseusService.gameRoom.value;
|
||
if (!room) {
|
||
router.push(`/${routeUuid.value}`);
|
||
return;
|
||
}
|
||
|
||
setupRoom(room);
|
||
|
||
function setupRoom(room: any) {
|
||
const $ = getStateCallbacks(room);
|
||
|
||
room.onStateChange.once((state: any) => {
|
||
gameStatus.value = state.gameStatus || 'waiting';
|
||
});
|
||
|
||
$(room.state).listen("gameStatus", (value: string) => {
|
||
gameStatus.value = value;
|
||
if ((value || '').toLowerCase() === 'finished') {
|
||
showEndModal();
|
||
}
|
||
});
|
||
$(room.state).listen("roomId", (value: string) => { roomId.value = value; });
|
||
$(room.state).listen("currentVariant", (value: string) => { currentVariant.value = value as any; });
|
||
$(room.state).listen("currentRound", (value: number) => { currentRound.value = value; });
|
||
$(room.state).listen("p1Action", (value: string) => { p1Action.value = value; });
|
||
$(room.state).listen("p2Action", (value: string) => { p2Action.value = value; });
|
||
$(room.state).listen("forcedByP2", (value: boolean) => { forcedByP2.value = value; });
|
||
$(room.state).listen("reported", (value: boolean) => { reported.value = value; });
|
||
$(room.state).listen("shameAssigned", (value: boolean) => { shameAssigned.value = value; });
|
||
// Offer fields
|
||
$(room.state).listen("offerPavo", () => forceUpdate());
|
||
$(room.state).listen("offerElote", () => forceUpdate());
|
||
$(room.state).listen("requestPavo", () => forceUpdate());
|
||
$(room.state).listen("requestElote", () => forceUpdate());
|
||
$(room.state).listen("offerActive", () => forceUpdate());
|
||
|
||
$(room.state).players.onAdd((player: any, key: string) => {
|
||
const idx = players.value.findIndex(p => p.sessionId === key);
|
||
if (idx === -1) {
|
||
players.value.push({
|
||
sessionId: key,
|
||
name: player.name,
|
||
role: player.role,
|
||
pavoTokens: player.pavoTokens,
|
||
eloteTokens: player.eloteTokens,
|
||
shameTokens: player.shameTokens,
|
||
color: player.color,
|
||
});
|
||
}
|
||
$(player).listen("role", (v: string) => { const p = players.value.find(x => x.sessionId === key); if (p) p.role = v; });
|
||
$(player).listen("pavoTokens", (v: number) => { const p = players.value.find(x => x.sessionId === key); if (p) p.pavoTokens = v; });
|
||
$(player).listen("eloteTokens", (v: number) => { const p = players.value.find(x => x.sessionId === key); if (p) p.eloteTokens = v; });
|
||
$(player).listen("shameTokens", (v: number) => { const p = players.value.find(x => x.sessionId === key); if (p) p.shameTokens = v; });
|
||
$(player).listen("color", (v: string) => { const p = players.value.find(x => x.sessionId === key); if (p) p.color = v; });
|
||
});
|
||
$(room.state).players.onRemove((_: any, key: string) => {
|
||
const i = players.value.findIndex(p => p.sessionId === key);
|
||
if (i !== -1) players.value.splice(i, 1);
|
||
});
|
||
|
||
room.onMessage("playerInfo", (info: any) => {
|
||
colyseusService.sessionId.value = info.sessionId;
|
||
colyseusService.playerName.value = info.name;
|
||
});
|
||
|
||
room.onMessage("gameEnd", () => { showEndModal(); });
|
||
|
||
// Register additional message handlers to avoid warnings
|
||
room.onMessage("gamePaused", () => {
|
||
// Game paused, could update UI state if needed
|
||
});
|
||
|
||
room.onMessage("gameRestart", () => {
|
||
// Game restarted, could update UI state if needed
|
||
});
|
||
|
||
room.onMessage("variantChanged", (data: { variant: string }) => {
|
||
currentVariant.value = data.variant as any;
|
||
// Close end modal if it's open when variant changes
|
||
if (endModal.value.visible) {
|
||
dismissEndModal();
|
||
}
|
||
});
|
||
|
||
// No round transition banners
|
||
|
||
// Handle room closure/disconnection
|
||
room.onLeave((code: number) => {
|
||
console.log('[DemoGame] Room disconnected with code:', code);
|
||
|
||
// Handle shuffle disconnection specially
|
||
if (code === 1002) {
|
||
console.log('[DemoGame] Disconnected for player shuffle - will redirect to lobby');
|
||
try {
|
||
if (typeof window !== 'undefined') {
|
||
window.localStorage.removeItem('snatch.game.roomId');
|
||
window.localStorage.removeItem('snatch.game.sessionId');
|
||
}
|
||
} catch {}
|
||
|
||
// Redirect to lobby and let it handle the shuffle redirect
|
||
router.push(`/${routeUuid.value}`);
|
||
return;
|
||
}
|
||
|
||
// Normal disconnection handling
|
||
// Always clean up local storage when room closes
|
||
// no-op for local storage cleanup
|
||
|
||
// If not on lobby page, redirect there
|
||
if (router.currentRoute.value.path !== `/${routeUuid.value}`) {
|
||
console.log('[DemoGame] Room closed, redirecting to lobby');
|
||
router.push(`/${routeUuid.value}`);
|
||
}
|
||
});
|
||
|
||
room.onError((code: number, message: any) => {
|
||
console.error('[DemoGame] Room error:', code, message);
|
||
// On error, redirect to lobby
|
||
router.push(`/${routeUuid.value}`);
|
||
});
|
||
}
|
||
});
|
||
|
||
function roomOffer<K extends string>(key: K): any {
|
||
const room = colyseusService.gameRoom.value as any;
|
||
return room?.state?.[key as any];
|
||
}
|
||
|
||
const refreshTick = ref(0);
|
||
function forceUpdate() { refreshTick.value++; }
|
||
|
||
function setVariant(g: string) { colyseusService.setVariant(g); }
|
||
function onP2Force(force: boolean) { colyseusService.p2Force(force); }
|
||
function onP1Action(action: 'no_offer') { colyseusService.noOffer(); }
|
||
function onProposeOffer(payload: { offerPavo:number; offerElote:number; requestPavo:number; requestElote:number; }) { colyseusService.proposeOffer(payload.offerPavo, payload.offerElote, payload.requestPavo, payload.requestElote); }
|
||
function onP2Action(action: 'accept'|'reject'|'snatch') { colyseusService.p2Action(action); }
|
||
function onReport(val: boolean) { colyseusService.report(val); }
|
||
function onAssignShame(val: boolean) { colyseusService.assignShame(val); }
|
||
|
||
async function leaveGame() {
|
||
// Ask for confirmation before closing the room for both players
|
||
if (!confirm('¿Cerrar la sala para ambos jugadores? Esto terminará el juego inmediatamente.')) {
|
||
return;
|
||
}
|
||
|
||
console.log('[DemoGame] User closing room for both players');
|
||
|
||
try {
|
||
// Close the room for both players using the admin API
|
||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3000/api'}/rooms/${roomId.value}/close`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' }
|
||
});
|
||
|
||
if (!response.ok) {
|
||
console.error('Failed to close room via API');
|
||
// Fallback to normal leave if API fails
|
||
colyseusService.leaveGame();
|
||
} else {
|
||
console.log(`Room ${roomId.value} closed successfully for both players`);
|
||
// Just leave locally, the server will handle disconnecting both players
|
||
if (colyseusService.gameRoom.value) {
|
||
colyseusService.gameRoom.value.leave();
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Error closing room:', error);
|
||
// Fallback to normal leave if error occurs
|
||
colyseusService.leaveGame();
|
||
}
|
||
|
||
// Navigate back to lobby
|
||
router.push(`/${routeUuid.value}`);
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.game { min-height: 100vh; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display:flex; align-items:center; justify-content:center; padding:20px; }
|
||
.end-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.55); display:flex; align-items:center; justify-content:center; z-index: 1200; }
|
||
.end-modal { position: relative; background: white; color:#111; border-radius: 16px; padding: 24px 24px 18px; width: min(520px, 92vw); box-shadow: 0 30px 80px rgba(0,0,0,0.5); border:1px solid #e5e7eb; }
|
||
.end-modal .close-btn { position:absolute; top:8px; right:8px; width:32px; height:32px; border-radius: 8px; border:1px solid #e5e7eb; background:#f8fafc; color:#111; font-weight:800; cursor:pointer; }
|
||
.end-modal .close-btn:hover { background:#eef2ff; }
|
||
.end-modal .title { font-size: 20px; font-weight: 900; margin-bottom: 8px; }
|
||
.end-modal .scores { display:flex; flex-direction:column; gap:8px; margin: 8px 0 12px; }
|
||
.end-modal .score-row { display:flex; align-items:center; justify-content:space-between; gap:8px; background:#f8fafc; border:1px solid #e5e7eb; border-radius: 10px; padding:8px 10px; }
|
||
.end-modal .score-row .name { font-weight:800; color:#1f2937; }
|
||
.end-modal .score-row .tokens { font-weight:700; color:#374151; }
|
||
.end-modal .score-row .points { font-weight:900; color:#111827; }
|
||
.end-modal .modal-actions {
|
||
margin: 16px 0;
|
||
display: flex;
|
||
gap: 12px;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
.end-modal .btn-next-variant, .end-modal .btn-prev-variant, .end-modal .btn-restart-variant {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
border: none;
|
||
padding: 12px 16px;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||
min-width: 85px;
|
||
}
|
||
.end-modal .btn-prev-variant {
|
||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||
box-shadow: 0 4px 12px rgba(240, 147, 251, 0.3);
|
||
}
|
||
.end-modal .btn-restart-variant {
|
||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||
box-shadow: 0 4px 12px rgba(79, 172, 254, 0.3);
|
||
}
|
||
.end-modal .btn-next-variant:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.4);
|
||
}
|
||
.end-modal .btn-prev-variant:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 6px 16px rgba(240, 147, 251, 0.4);
|
||
}
|
||
.end-modal .btn-restart-variant:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 6px 16px rgba(79, 172, 254, 0.4);
|
||
}
|
||
.end-modal .hint { font-size: 12px; color:#6b7280; text-align:right; }
|
||
.game-container { background: white; border-radius: 20px; padding: 24px; max-width: 1000px; width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }
|
||
.game-header { display:flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
|
||
.game-header h1 { margin: 0; font-size: 20px; }
|
||
.meta { display:flex; gap: 16px; font-size: 14px; }
|
||
.badge { background:#e3f2fd; color:#2196f3; padding: 2px 8px; border-radius: 12px; font-size: 12px; }
|
||
.variant-selector { display:flex; gap: 8px; flex-wrap: wrap; }
|
||
.btn { padding: 8px 12px; border-radius: 8px; border: none; cursor: pointer; }
|
||
.btn-variant { background: #f2f2f2; }
|
||
.btn-variant.active { background: #667eea; color: white; }
|
||
.btn-next { background:#2196f3; color:white; margin-top: 12px; }
|
||
.btn-leave { background:#f44336; color:white; }
|
||
.players-section { display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; margin: 12px 0; }
|
||
.player-card { padding: 12px; background:#f8f9fa; border-radius: 10px; }
|
||
.player-role { color:#666; margin-top: 4px; }
|
||
.player-tokens { display:flex; gap: 12px; margin-top: 8px; }
|
||
.waiting-area { text-align:center; padding: 24px 0; }
|
||
.spinner { width:40px; height:40px; border: 4px solid #eee; border-top:4px solid #667eea; border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 8px; }
|
||
@keyframes spin { 0%{transform:rotate(0)} 100%{transform:rotate(360deg)} }
|
||
.outcome-box { display:flex; gap: 24px; background:#f5f5f5; padding: 12px; border-radius: 8px; }
|
||
|
||
/* Full-screen overlay while paused */
|
||
.pause-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.55); display:flex; align-items:center; justify-content:center; z-index: 1000; }
|
||
.pause-box { background: white; color:#333; border-radius: 16px; padding: 24px 32px; text-align: center; box-shadow: 0 20px 60px rgba(0,0,0,0.4); }
|
||
.pause-box .icon { font-size: 48px; margin-bottom: 8px; }
|
||
.pause-box .title { font-weight: 800; font-size: 20px; }
|
||
.pause-box .hint { margin-top: 6px; color:#666; font-size: 14px; }
|
||
</style>
|