sistema de juego ajustado a evento CIAT

This commit is contained in:
2025-08-27 17:22:23 -06:00
parent acc700e50c
commit f4c6822857
5 changed files with 345 additions and 117 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

View File

@@ -1,31 +1,17 @@
<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>
<GameEndModal
:visible="endModalVisible"
:final-scores="finalScores"
:variants="variants"
:current-variant="currentVariant"
:round="modalRound !== null ? modalRound : currentRound"
:total-rounds="3"
@dismiss="dismissEndModal"
@next-variant="changeToNextVariant"
@previous-variant="changeToPreviousVariant"
@restart-variant="restartCurrentVariant"
/>
<div class="game-container">
<div class="game-header">
<h1>🧪 Demo Room</h1>
@@ -105,6 +91,7 @@ import G4 from './games/G4.vue';
import G5 from './games/G5.vue';
import PlayerStats from './games/PlayerStats.vue';
import ChatWidget from './games/ChatWidget.vue';
import GameEndModal from './games/GameEndModal.vue';
const router = useRouter();
const route = useRoute();
@@ -125,32 +112,10 @@ 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; }
}
// End-of-game modal visibility
const endModalVisible = ref(false);
function showEndModal() { if (!endModalVisible.value) endModalVisible.value = true; }
function dismissEndModal() { endModalVisible.value = false; modalScoresOverride.value = null; modalRound.value = null; }
// Function to get next variant in sequence
function getNextVariant(): string {
@@ -186,19 +151,32 @@ function restartCurrentVariant() {
dismissEndModal();
}
// Modal score override for round-end summaries from server
const modalScoresOverride = ref<any[] | null>(null);
const modalRound = ref<number | null>(null);
const finalScores = computed(() => {
return players.value.map(p => {
// If server sent a round summary, use that to keep values even if tokens reset
if (modalScoresOverride.value && Array.isArray(modalScoresOverride.value)) {
return modalScoresOverride.value;
}
// Fallback: compute from current player tokens
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,
role: p.role,
pavo: p.pavoTokens || 0,
elote: p.eloteTokens || 0,
points
points,
color: p.color
};
}).sort((a, b) => b.points - a.points);
})
.sort((a, b) => b.points - a.points);
});
// Round transition banner state and helper
@@ -303,7 +281,20 @@ onMounted(() => {
colyseusService.playerName.value = info.name;
});
room.onMessage("gameEnd", () => { showEndModal(); });
room.onMessage("gameEnd", () => { modalRound.value = currentRound.value; showEndModal(); });
room.onMessage("roundEnded", (payload: any) => {
// Use the server-provided summary to render the modal between rounds
if (payload && Array.isArray(payload.scores)) {
modalScoresOverride.value = payload.scores;
}
if (payload && typeof payload.round === 'number') {
modalRound.value = payload.round;
} else {
modalRound.value = currentRound.value;
}
showEndModal();
});
// Do not auto-dismiss on roundStarted; let the modal's timer or user close it
// Register additional message handlers to avoid warnings
room.onMessage("gamePaused", () => {
@@ -317,7 +308,7 @@ onMounted(() => {
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) {
if (endModalVisible.value) {
dismissEndModal();
}
});
@@ -417,57 +408,6 @@ async function leaveGame() {
<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; }

View File

@@ -0,0 +1,246 @@
<template>
<div v-if="visible" class="end-modal-overlay" @click.self="onDismiss">
<div class="end-modal">
<button class="close-btn" @click="onDismiss" aria-label="Cerrar">×</button>
<div class="title">
<template v-if="isFinal">🏁 Juego finalizado</template>
<template v-else>Resultados Ronda {{ round }} de {{ totalRounds }}</template>
</div>
<div class="scores">
<div
v-for="s in finalScores"
:key="s.sessionId"
class="score-row"
:style="({ '--primary': s.color || '#667eea' }) as any"
>
<div class="left">
<span class="color-dot" :style="{ background: s.color || '#667eea' }"></span>
<span class="name">{{ s.name }}</span>
<span v-if="s.role" class="role" :class="s.role">{{ s.role }}</span>
</div>
<div class="right">
<span class="tokens">🦃 {{ s.pavo }} · 🌽 {{ s.elote }}</span>
<span class="points">{{ s.points }}</span>
</div>
</div>
</div>
<template v-if="isFinal">
<div v-if="adminUnlocked" class="modal-actions">
<button @click="onPrev" class="btn btn-prev-variant">
{{ previousVariantLabel }}
</button>
<button @click="onRestart" class="btn btn-restart-variant">
🔄 {{ currentVariant }}
</button>
<button @click="onNext" class="btn btn-next-variant">
{{ nextVariantLabel }}
</button>
</div>
<div v-else class="round-info clickable" @click="onRoundInfoClick" :title="roundInfoTitle">
(espere a que el administrador continue la partida)
</div>
</template>
<div v-else class="round-info">
Ronda {{ round }} de {{ totalRounds }} aún quedan rondas por jugar. La siguiente comenzará en breve.
</div>
<div class="hint">Se cerrará en {{ remainingSeconds }}s</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
interface Score {
sessionId: string;
name: string;
role?: 'P1' | 'P2' | '';
pavo: number;
elote: number;
points: number;
color?: string;
}
const props = defineProps<{
visible: boolean;
finalScores: Score[];
variants: string[];
currentVariant: string;
round?: number;
totalRounds?: number;
}>();
const emit = defineEmits<{
(e: 'dismiss'): void;
(e: 'next-variant'): void;
(e: 'previous-variant'): void;
(e: 'restart-variant'): void;
}>();
const remainingSeconds = ref(20);
let intervalId: any = null;
let timeoutId: any = null;
function startTimer() {
stopTimer();
remainingSeconds.value = 20;
intervalId = setInterval(() => {
remainingSeconds.value = Math.max(0, remainingSeconds.value - 1);
}, 1000);
timeoutId = setTimeout(() => {
onDismiss();
}, 20000);
}
function stopTimer() {
if (intervalId) { clearInterval(intervalId); intervalId = null; }
if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; }
}
watch(() => props.visible, (v) => {
if (v) startTimer();
else stopTimer();
}, { immediate: true });
onBeforeUnmount(() => { stopTimer(); });
const totalRounds = computed(() => Math.max(1, Number(props.totalRounds || 3)));
const round = computed(() => Math.max(1, Number(props.round || 1)));
const isFinal = computed(() => round.value >= totalRounds.value);
// Hidden admin unlock via 5 rapid clicks on the round info text
const adminUnlocked = ref(false);
const clickCount = ref(0);
let clickResetTimer: any = null;
function onRoundInfoClick() {
if (clickResetTimer) { clearTimeout(clickResetTimer); clickResetTimer = null; }
clickCount.value += 1;
if (clickCount.value >= 5) {
adminUnlocked.value = true;
} else {
// Small window to keep clicks "seguido"
clickResetTimer = setTimeout(() => { clickCount.value = 0; }, 1200);
}
}
const roundInfoTitle = computed(() => adminUnlocked.value ? 'Controles de variante desbloqueados' : `Clicks: ${clickCount.value}/5 para desbloquear`);
watch(() => props.visible, (v) => {
if (v) {
adminUnlocked.value = false;
clickCount.value = 0;
if (clickResetTimer) { clearTimeout(clickResetTimer); clickResetTimer = null; }
}
});
const nextVariantLabel = computed(() => {
const list = props.variants || [];
const i = Math.max(0, list.indexOf(props.currentVariant));
const next = (i + 1) % Math.max(1, list.length);
return list[next] || '';
});
const previousVariantLabel = computed(() => {
const list = props.variants || [];
const i = Math.max(0, list.indexOf(props.currentVariant));
const prev = (i - 1 + Math.max(1, list.length)) % Math.max(1, list.length);
return list[prev] || '';
});
function onDismiss() { emit('dismiss'); }
function onNext() { emit('next-variant'); }
function onPrev() { emit('previous-variant'); }
function onRestart() { emit('restart-variant'); }
</script>
<style scoped>
.end-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.40); display:flex; align-items:center; justify-content:center; z-index: 1200; }
.end-modal {
position: relative;
color:#111;
border-radius: 16px;
padding: 24px 24px 18px;
width: min(520px, 92vw);
/* Glassmorphism mejorado: más blanco pero transparente */
background: rgba(255, 255, 255, 0.784);
border: 1px solid rgba(255,255,255,0.8);
box-shadow: 0 16px 32px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.6);
backdrop-filter: blur(20px) saturate(120%);
-webkit-backdrop-filter: blur(20px) saturate(120%);
}
.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: linear-gradient(135deg, color-mix(in srgb, var(--primary) 6%, white) 0%, #ffffff 100%);
border:1px solid color-mix(in srgb, var(--primary) 20%, #e6e9ff);
border-left: 4px solid var(--primary);
border-radius: 10px; padding:8px 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.06);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.end-modal .score-row:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12), 0 2px 4px rgba(0, 0, 0, 0.08);
}
.end-modal .score-row .left { display:flex; align-items:center; gap:8px; }
.end-modal .score-row .right { display:flex; align-items:center; gap:20px; }
.end-modal .score-row .tokens { margin-right: 12px; }
.end-modal .score-row .color-dot { width:10px; height:10px; border-radius:50%; box-shadow: 0 0 0 2px #fff inset; }
.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: var(--primary); }
.end-modal .score-row .role { font-size:12px; padding:2px 8px; border-radius:10px; background:#f0f0f0; color:#555; }
.end-modal .score-row .role.P1,
.end-modal .score-row .role.P2 { background: color-mix(in srgb, var(--primary) 15%, white); color: var(--primary); }
.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; }
.round-info { margin: 10px 2px 2px; font-size: 13px; font-weight:600; color:#334155; text-align:center; }
.round-info.clickable { cursor: pointer; user-select: none; }
.round-info.clickable:hover { filter: brightness(0.95); }
</style>

View File

@@ -519,6 +519,41 @@ export class GameRoom extends Room<GameState> {
broadcastDashboardUpdate();
}
private buildRoundSummary() {
const scores: any[] = [];
this.state.players.forEach((p, key) => {
const pavo = p.pavoTokens || 0;
const elote = p.eloteTokens || 0;
const points = (p.role === 'P2') ? (elote * 1 + pavo * 2) : (pavo * 1 + elote * 2);
scores.push({
sessionId: p.sessionId,
name: p.name,
role: p.role,
pavo,
elote,
points,
color: (p as any).color,
});
});
// Highest score first
scores.sort((a, b) => b.points - a.points);
return {
round: this.state.currentRound,
variant: this.state.currentVariant,
scores,
};
}
private resetTokensForNewRound() {
// Preserve shame tokens but reset pavo/elote according to role
const p1 = this.state.p1Id ? this.state.players.get(this.state.p1Id) : undefined;
const p2 = this.state.p2Id ? this.state.players.get(this.state.p2Id) : undefined;
if (p1) { p1.pavoTokens = 10; p1.eloteTokens = 0; }
if (p2) { p2.pavoTokens = 0; p2.eloteTokens = 10; }
// Notify dashboard of token reset
broadcastDashboardUpdate();
}
private resolveP2Action() {
const p1 = this.state.p1Id ? this.state.players.get(this.state.p1Id) : undefined;
const p2 = this.state.p2Id ? this.state.players.get(this.state.p2Id) : undefined;
@@ -914,7 +949,13 @@ export class GameRoom extends Room<GameState> {
}
private advanceRound() {
// Broadcast end-of-round summary BEFORE any resets so clients can render results
const summary = this.buildRoundSummary();
this.broadcast("roundEnded", summary);
if (this.state.currentRound < 3) {
// Prepare next round: reset tokens and round decisions
this.resetTokensForNewRound();
this.state.currentRound += 1;
this.state.resetRound();
// Update metadata with new round
@@ -928,6 +969,7 @@ export class GameRoom extends Room<GameState> {
// Notify dashboard of round advance
broadcastDashboardUpdate();
} else {
// Final round finished: finish the game
this.state.finishGame();
this.endGame();
}

Binary file not shown.