Files
snatchgame/client/src/views/DemoGame.vue
2025-08-16 18:02:16 -06:00

498 lines
19 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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