mejoras de UI, mensajes del sistema y hints
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
<template>
|
||||
<div class="chat-widget">
|
||||
<TransitionGroup name="ephem" tag="div" class="ephemeral-stack" v-if="ephemeralBubbles.length">
|
||||
<div class="bubble" :class="{ fading: e.fading }" v-for="e in ephemeralBubbles" :key="e.id">{{ e.text }}</div>
|
||||
<!-- System bubbles: top-left, newest on top -->
|
||||
<TransitionGroup name="ephem" tag="div" class="ephemeral-stack-sys" v-if="ephemeralBubblesSys.length">
|
||||
<div class="bubble" :class="[ e.kind ? `k-${e.kind}` : null, { fading: e.fading } ]" v-for="e in ephemeralBubblesSys" :key="e.id">{{ e.text }}</div>
|
||||
</TransitionGroup>
|
||||
|
||||
<!-- Chat bubbles: bottom-right, newest at bottom -->
|
||||
<TransitionGroup name="ephem" tag="div" class="ephemeral-stack-chat" v-if="ephemeralBubblesChat.length">
|
||||
<div class="bubble chat" :class="{ fading: e.fading }" v-for="e in ephemeralBubblesChat" :key="e.id" :style="{ background: e.bg, color: e.fg }">{{ e.text }}</div>
|
||||
</TransitionGroup>
|
||||
|
||||
<div class="composer" :class="{ 'is-focused': isComposerFocused }">
|
||||
@@ -27,7 +33,7 @@
|
||||
<div
|
||||
v-for="m in messages"
|
||||
:key="m.id"
|
||||
:class="['msg', m.mine ? 'mine' : 'theirs']"
|
||||
:class="['msg', m.system ? 'system' : (m.mine ? 'mine' : 'theirs'), m.kind ? `k-${m.kind}` : null]"
|
||||
>
|
||||
<div class="meta">
|
||||
<span class="author">{{ m.mine ? 'Tú' : m.from }}</span>
|
||||
@@ -61,6 +67,9 @@ interface ChatMsg {
|
||||
text: string;
|
||||
ts: number;
|
||||
mine: boolean;
|
||||
kind?: string;
|
||||
system?: boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const text = ref("");
|
||||
@@ -68,9 +77,11 @@ const isComposerFocused = ref(false);
|
||||
const messages = ref<ChatMsg[]>([]);
|
||||
const showModal = ref(false);
|
||||
const messagesEl = ref<HTMLDivElement | null>(null);
|
||||
const ephemeralBubbles = ref<{ id: string; text: string; expiresAt: number; fading?: boolean }[]>([]);
|
||||
const ephemeralBubblesSys = ref<{ id: string; text: string; expiresAt: number; fading?: boolean; kind?: string }[]>([]);
|
||||
const ephemeralBubblesChat = ref<{ id: string; text: string; expiresAt: number; fading?: boolean; bg?: string; fg?: string }[]>([]);
|
||||
|
||||
const ephemeralTimers = new Map<string, { fade?: any; remove?: any }>();
|
||||
const ephemeralTimersSys = new Map<string, { fade?: any; remove?: any }>();
|
||||
const ephemeralTimersChat = new Map<string, { fade?: any; remove?: any }>();
|
||||
let removeHandler: (() => void) | null = null;
|
||||
const FADE_MS = 500; // start leave transition this long before removal
|
||||
|
||||
@@ -114,50 +125,93 @@ function handleIncoming(payload: any) {
|
||||
text: payload.text || "",
|
||||
ts: payload.ts || Date.now(),
|
||||
mine,
|
||||
kind: payload.kind,
|
||||
system: payload.fromId === 'system',
|
||||
color: payload.color,
|
||||
};
|
||||
messages.value.push(msg);
|
||||
scrollToBottomSoon();
|
||||
|
||||
if (!mine) {
|
||||
queueEphemeral(msg.id, msg.text);
|
||||
if (!mine && msg.system) {
|
||||
queueEphemeralSystem(msg.id, msg.text, msg.kind);
|
||||
} else if (!mine && !msg.system) {
|
||||
queueEphemeralChat(msg.id, msg.text, msg.color);
|
||||
}
|
||||
}
|
||||
|
||||
function openModal() { showModal.value = true; }
|
||||
function closeModal() { showModal.value = false; }
|
||||
|
||||
function queueEphemeral(id: string, t: string) {
|
||||
// Compute duration based on words: min 3s, +0.5s per word up to 15s
|
||||
function queueEphemeralSystem(id: string, t: string, kind?: string) {
|
||||
// System: top-left, longer duration (min 4s, 0.7s/word, max 20s)
|
||||
const words = (t || '').trim().split(/\s+/).filter(Boolean).length;
|
||||
const computed = Math.min(15, Math.max(3, words * 0.5));
|
||||
const computed = Math.min(20, Math.max(4, words * 0.7));
|
||||
const now = Date.now();
|
||||
const upperMax = ephemeralBubbles.value.length
|
||||
? Math.max(...ephemeralBubbles.value.map(e => e.expiresAt))
|
||||
: 0;
|
||||
const expiresAt = Math.max(now + computed * 1000, upperMax || 0);
|
||||
const expiresAt = now + computed * 1000;
|
||||
|
||||
const bubble = { id, text: t, expiresAt };
|
||||
ephemeralBubbles.value.push(bubble);
|
||||
const bubble = { id, text: t, expiresAt, kind };
|
||||
ephemeralBubblesSys.value.unshift(bubble); // newest on top
|
||||
|
||||
// Schedule removal when its time comes
|
||||
// Clear old timers if any
|
||||
if (ephemeralTimers.has(id)) {
|
||||
const t = ephemeralTimers.get(id)!;
|
||||
if (ephemeralTimersSys.has(id)) {
|
||||
const t = ephemeralTimersSys.get(id)!;
|
||||
if (t.fade) clearTimeout(t.fade);
|
||||
if (t.remove) clearTimeout(t.remove);
|
||||
}
|
||||
const delay = Math.max(0, expiresAt - now);
|
||||
const startDelay = Math.max(0, delay - FADE_MS);
|
||||
const fadeTimer = setTimeout(() => {
|
||||
const b = ephemeralBubbles.value.find(e => e.id === id);
|
||||
const b = ephemeralBubblesSys.value.find(e => e.id === id);
|
||||
if (b) b.fading = true;
|
||||
}, startDelay);
|
||||
const removeTimer = setTimeout(() => {
|
||||
const idx = ephemeralBubbles.value.findIndex(e => e.id === id);
|
||||
if (idx !== -1) ephemeralBubbles.value.splice(idx, 1);
|
||||
ephemeralTimers.delete(id);
|
||||
const idx = ephemeralBubblesSys.value.findIndex(e => e.id === id);
|
||||
if (idx !== -1) ephemeralBubblesSys.value.splice(idx, 1);
|
||||
ephemeralTimersSys.delete(id);
|
||||
}, delay);
|
||||
ephemeralTimers.set(id, { fade: fadeTimer, remove: removeTimer });
|
||||
ephemeralTimersSys.set(id, { fade: fadeTimer, remove: removeTimer });
|
||||
}
|
||||
|
||||
function queueEphemeralChat(id: string, t: string, bg?: string) {
|
||||
// Chat: bottom-right, slightly shorter (min 3s, 0.5s/word, max 15s)
|
||||
const words = (t || '').trim().split(/\s+/).filter(Boolean).length;
|
||||
const computed = Math.min(15, Math.max(3, words * 0.5));
|
||||
const now = Date.now();
|
||||
const expiresAt = now + computed * 1000;
|
||||
|
||||
const fg = getReadableTextColor(bg || '#667eea');
|
||||
const bubble = { id, text: t, expiresAt, bg: bg || '#667eea', fg };
|
||||
ephemeralBubblesChat.value.push(bubble); // newest at bottom
|
||||
|
||||
if (ephemeralTimersChat.has(id)) {
|
||||
const t = ephemeralTimersChat.get(id)!;
|
||||
if (t.fade) clearTimeout(t.fade);
|
||||
if (t.remove) clearTimeout(t.remove);
|
||||
}
|
||||
const delay = Math.max(0, expiresAt - now);
|
||||
const startDelay = Math.max(0, delay - FADE_MS);
|
||||
const fadeTimer = setTimeout(() => {
|
||||
const b = ephemeralBubblesChat.value.find(e => e.id === id);
|
||||
if (b) b.fading = true;
|
||||
}, startDelay);
|
||||
const removeTimer = setTimeout(() => {
|
||||
const idx = ephemeralBubblesChat.value.findIndex(e => e.id === id);
|
||||
if (idx !== -1) ephemeralBubblesChat.value.splice(idx, 1);
|
||||
ephemeralTimersChat.delete(id);
|
||||
}, delay);
|
||||
ephemeralTimersChat.set(id, { fade: fadeTimer, remove: removeTimer });
|
||||
}
|
||||
|
||||
function getReadableTextColor(hex?: string): string {
|
||||
const c = (hex || '').trim();
|
||||
const m = c.match(/^#?([a-fA-F0-9]{6})$/);
|
||||
let r=102, g=126, b=234; // fallback to #667eea
|
||||
if (m) {
|
||||
const int = parseInt(m[1], 16);
|
||||
r = (int >> 16) & 255; g = (int >> 8) & 255; b = int & 255;
|
||||
}
|
||||
// Perceived brightness formula
|
||||
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
|
||||
return yiq >= 140 ? '#111111' : '#ffffff';
|
||||
}
|
||||
|
||||
function formatTime(ts: number) {
|
||||
@@ -187,11 +241,16 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
if (removeHandler) removeHandler();
|
||||
for (const [, obj] of ephemeralTimers) {
|
||||
for (const [, obj] of ephemeralTimersSys) {
|
||||
if (obj.fade) clearTimeout(obj.fade);
|
||||
if (obj.remove) clearTimeout(obj.remove);
|
||||
}
|
||||
ephemeralTimers.clear();
|
||||
for (const [, obj] of ephemeralTimersChat) {
|
||||
if (obj.fade) clearTimeout(obj.fade);
|
||||
if (obj.remove) clearTimeout(obj.remove);
|
||||
}
|
||||
ephemeralTimersSys.clear();
|
||||
ephemeralTimersChat.clear();
|
||||
});
|
||||
|
||||
// Ensure modal autoscrolls when opened
|
||||
@@ -213,11 +272,23 @@ watch(showModal, (v) => { if (v) scrollToBottomSoon(); });
|
||||
.btn.secondary { background:#f5f5f5; color:#333; }
|
||||
.btn.close { background:#f5f5f5; color:#333; }
|
||||
|
||||
.ephemeral-stack { position:absolute; right: 0; bottom: 56px; display:flex; flex-direction: column; align-items:flex-end; gap:8px; pointer-events: none; width: clamp(220px, 50vw, 380px); }
|
||||
.ephemeral-stack-sys { position:fixed; left: 16px; top: 16px; display:flex; flex-direction: column; align-items:flex-start; gap:8px; pointer-events: none; width: clamp(220px, 50vw, 420px); z-index: 60; }
|
||||
.ephemeral-stack-chat { position:fixed; right: 16px; bottom: 70px; display:flex; flex-direction: column; align-items:flex-end; gap:8px; pointer-events: none; width: clamp(220px, 50vw, 420px); z-index: 60; }
|
||||
.bubble { max-width: 100%; background:#333; color:#fff; padding:8px 10px; border-radius:12px; box-shadow:0 8px 16px rgba(0,0,0,0.25); transition: opacity 0.5s ease; white-space: normal; overflow-wrap: anywhere; word-break: break-word; }
|
||||
.bubble.k-p2_accept { background: linear-gradient(135deg, #10b981 0%, #059669 100%); }
|
||||
.bubble.k-p2_reject { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); }
|
||||
.bubble.k-p2_snatch { background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); }
|
||||
.bubble.k-p1_shame { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); }
|
||||
.bubble.k-p1_no_shame { background: #6b7280; }
|
||||
.bubble.k-p1_report { background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); }
|
||||
.bubble.k-p1_no_report { background: #6b7280; }
|
||||
.bubble.k-p1_no_offer { background: #6b7280; }
|
||||
.bubble.k-p1_propose { background: linear-gradient(135deg, #667eea 0%, #5a67d8 100%); }
|
||||
.bubble.k-variant_change { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
||||
.bubble.k-round_advance { background: linear-gradient(135deg, #2196f3 0%, #1e88e5 100%); }
|
||||
.bubble.fading { opacity: 0; }
|
||||
/* TransitionGroup animations for stacking upward smoothly and fading */
|
||||
.ephem-enter-from { opacity: 0; transform: translateY(10px); }
|
||||
.ephem-enter-from { opacity: 0; transform: translateY(-8px); }
|
||||
.ephem-enter-to { opacity: 1; transform: translateY(0); }
|
||||
.ephem-enter-active { transition: all 0.25s ease; }
|
||||
.ephem-leave-from { opacity: 1; transform: translateY(0); }
|
||||
@@ -239,4 +310,16 @@ watch(showModal, (v) => { if (v) scrollToBottomSoon(); });
|
||||
.msg .body { white-space: pre-wrap; }
|
||||
.msg.mine { align-self: flex-end; background:#e3f2fd; }
|
||||
.msg.theirs { align-self: flex-start; background:#f5f5f5; }
|
||||
.msg.system { align-self: center; background:#fafafa; border:1px solid #eee; color:#333; }
|
||||
.msg.system.k-p2_accept { border-left: 4px solid #10b981; }
|
||||
.msg.system.k-p2_reject { border-left: 4px solid #f59e0b; }
|
||||
.msg.system.k-p2_snatch { border-left: 4px solid #ef4444; }
|
||||
.msg.system.k-p1_shame { border-left: 4px solid #f59e0b; }
|
||||
.msg.system.k-p1_no_shame { border-left: 4px solid #6b7280; }
|
||||
.msg.system.k-p1_report { border-left: 4px solid #7c3aed; }
|
||||
.msg.system.k-p1_no_report { border-left: 4px solid #6b7280; }
|
||||
.msg.system.k-p1_no_offer { border-left: 4px solid #6b7280; }
|
||||
.msg.system.k-p1_propose { border-left: 4px solid #667eea; }
|
||||
.msg.system.k-variant_change { border-left: 4px solid #764ba2; }
|
||||
.msg.system.k-round_advance { border-left: 4px solid #2196f3; }
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<div v-if="shouldShowComponent" class="offer-actions-card">
|
||||
<div
|
||||
v-if="shouldShowComponent"
|
||||
class="offer-actions-card"
|
||||
:style="({ '--p1': p1Color, '--p2': p2Color } as any)"
|
||||
>
|
||||
<!-- G2: Force option for P2 -->
|
||||
<div v-if="myRole === 'P2' && currentVariant === 'G2' && !state.offer?.active" class="force-section">
|
||||
<label class="force-checkbox">
|
||||
@@ -15,26 +19,36 @@
|
||||
<!-- Offer display when active -->
|
||||
<div v-if="state.offer?.active" class="offer-display">
|
||||
<div class="offer-summary">
|
||||
<div class="offer-part giving" :style="{ '--p1-color': p1Color }">
|
||||
<div class="offer-part giving">
|
||||
<span class="offer-label">P1 Ofrece</span>
|
||||
<div class="tokens-display">
|
||||
<span v-if="state.offer.offerPavo > 0" class="token-item">🦃 {{ state.offer.offerPavo }}</span>
|
||||
<span v-if="state.offer.offerPavo > 0 && state.offer.offerElote > 0" class="separator">/</span>
|
||||
<span v-if="state.offer.offerElote > 0" class="token-item">🌽 {{ state.offer.offerElote }}</span>
|
||||
<span v-if="state.offer.offerPavo === 0 && state.offer.offerElote === 0" class="no-tokens">Nada</span>
|
||||
<span v-if="state.offer.offerPavo > 0" class="token-item pill">
|
||||
<span class="icon">🦃</span>
|
||||
<span class="val">{{ state.offer.offerPavo }}</span>
|
||||
</span>
|
||||
<span v-if="state.offer.offerElote > 0" class="token-item pill">
|
||||
<span class="icon">🌽</span>
|
||||
<span class="val">{{ state.offer.offerElote }}</span>
|
||||
</span>
|
||||
<span v-if="state.offer.offerPavo === 0 && state.offer.offerElote === 0" class="no-tokens pill subtle">Nada</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="exchange-arrows">
|
||||
<span class="arrow-p1" :style="{ color: p1Color }">→</span>
|
||||
<span class="arrow-p2" :style="{ color: p2Color }">←</span>
|
||||
</div>
|
||||
<div class="offer-part requesting" :style="{ '--p2-color': p2Color }">
|
||||
<div class="offer-part requesting">
|
||||
<span class="offer-label">P2 Entrega</span>
|
||||
<div class="tokens-display">
|
||||
<span v-if="state.offer.requestPavo > 0" class="token-item">🦃 {{ state.offer.requestPavo }}</span>
|
||||
<span v-if="state.offer.requestPavo > 0 && state.offer.requestElote > 0" class="separator">/</span>
|
||||
<span v-if="state.offer.requestElote > 0" class="token-item">🌽 {{ state.offer.requestElote }}</span>
|
||||
<span v-if="state.offer.requestPavo === 0 && state.offer.requestElote === 0" class="no-tokens">Nada</span>
|
||||
<span v-if="state.offer.requestPavo > 0" class="token-item pill">
|
||||
<span class="icon">🦃</span>
|
||||
<span class="val">{{ state.offer.requestPavo }}</span>
|
||||
</span>
|
||||
<span v-if="state.offer.requestElote > 0" class="token-item pill">
|
||||
<span class="icon">🌽</span>
|
||||
<span class="val">{{ state.offer.requestElote }}</span>
|
||||
</span>
|
||||
<span v-if="state.offer.requestPavo === 0 && state.offer.requestElote === 0" class="no-tokens pill subtle">Nada</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,10 +71,19 @@
|
||||
|
||||
<!-- P1 waiting for P2 decision -->
|
||||
<div v-else-if="myRole === 'P1' && !state.p2Action" class="waiting-state">
|
||||
<div class="spinner"></div>
|
||||
<div class="spinner spinner--p2"></div>
|
||||
<span class="waiting-text">Esperando decisión de P2...</span>
|
||||
</div>
|
||||
|
||||
<!-- P2 waiting for P1 punishment decision after snatch (G3/G4) -->
|
||||
<div
|
||||
v-else-if="myRole === 'P2' && state.p2Action === 'snatch' && (currentVariant === 'G3' || currentVariant === 'G4') && ((currentVariant === 'G3' && !state.shameAssigned) || (currentVariant === 'G4' && state.reported === false))"
|
||||
class="waiting-state"
|
||||
>
|
||||
<div class="spinner spinner--p1"></div>
|
||||
<span class="waiting-text">Esperando castigo de P1...</span>
|
||||
</div>
|
||||
|
||||
<!-- Show P2's decision -->
|
||||
<div v-else-if="state.p2Action && !showP1PostActions" class="decision-display">
|
||||
<div class="decision-badge" :class="state.p2Action">
|
||||
@@ -102,7 +125,7 @@
|
||||
|
||||
<!-- Waiting for offer from P1 (P2 perspective) -->
|
||||
<div v-else-if="myRole === 'P2' && !state.offer?.active && currentVariant !== 'G2'" class="waiting-state">
|
||||
<div class="spinner"></div>
|
||||
<div class="spinner spinner--p1"></div>
|
||||
<span class="waiting-text">Esperando oferta de P1...</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,8 +143,8 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const p1Color = props.p1Color || '#667eea';
|
||||
const p2Color = props.p2Color || '#764ba2';
|
||||
const p1Color = props.p1Color || "#667eea";
|
||||
const p2Color = props.p2Color || "#764ba2";
|
||||
|
||||
const showP1PostActions = computed(() => {
|
||||
if (props.myRole !== 'P1' || props.state.p2Action !== 'snatch') return false;
|
||||
@@ -161,10 +184,11 @@ defineEmits(['p2Action', 'p2Force', 'assignShame', 'report']);
|
||||
<style scoped>
|
||||
.offer-actions-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e9f0;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #eeeeee;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
margin-top: 10px;
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.force-section {
|
||||
@@ -213,18 +237,18 @@ defineEmits(['p2Action', 'p2Force', 'assignShame', 'report']);
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.offer-part.giving {
|
||||
background: color-mix(in srgb, var(--p1-color) 8%, white);
|
||||
border-color: color-mix(in srgb, var(--p1-color) 30%, white);
|
||||
background: color-mix(in srgb, var(--p1) 10%, white);
|
||||
border-color: color-mix(in srgb, var(--p1) 30%, #e6e9ff);
|
||||
}
|
||||
|
||||
.offer-part.requesting {
|
||||
background: color-mix(in srgb, var(--p2-color) 8%, white);
|
||||
border-color: color-mix(in srgb, var(--p2-color) 30%, white);
|
||||
background: color-mix(in srgb, var(--p2) 10%, white);
|
||||
border-color: color-mix(in srgb, var(--p2) 30%, #e6e9ff);
|
||||
}
|
||||
|
||||
.offer-label {
|
||||
@@ -244,15 +268,13 @@ defineEmits(['p2Action', 'p2Force', 'assignShame', 'report']);
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.token-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.pill { display:inline-flex; align-items:center; gap:6px; padding:6px 10px; border-radius:999px; background:#f7f7f7; border:1px solid #eee; }
|
||||
.pill.subtle { background:#fafafa; color:#666; }
|
||||
.token-item { display:flex; align-items:center; gap:4px; }
|
||||
.icon { font-size: 16px; }
|
||||
.val { font-weight: 700; color:#333; }
|
||||
|
||||
.separator {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
/* separator removed in favor of pill grouping */
|
||||
|
||||
.exchange-arrows {
|
||||
display: flex;
|
||||
@@ -277,79 +299,59 @@ defineEmits(['p2Action', 'p2Force', 'assignShame', 'report']);
|
||||
|
||||
.action-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(85px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 14px;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn.accept {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
.btn.accept { background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: #fff; box-shadow: 0 10px 20px rgba(16,185,129,0.25); }
|
||||
|
||||
.btn.accept:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(16, 185, 129, 0.35);
|
||||
}
|
||||
|
||||
.btn.reject {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.25);
|
||||
}
|
||||
.btn.reject { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: #fff; box-shadow: 0 10px 20px rgba(245,158,11,0.25); }
|
||||
|
||||
.btn.reject:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(245, 158, 11, 0.35);
|
||||
}
|
||||
|
||||
.btn.snatch {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
.btn.snatch { background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); color: #fff; box-shadow: 0 10px 20px rgba(239,68,68,0.25); }
|
||||
|
||||
.btn.snatch:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(239, 68, 68, 0.35);
|
||||
}
|
||||
|
||||
.btn.shame {
|
||||
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(251, 191, 36, 0.25);
|
||||
}
|
||||
.btn.shame { background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%); color: #fff; box-shadow: 0 10px 20px rgba(251,191,36,0.25); }
|
||||
|
||||
.btn.shame:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(251, 191, 36, 0.35);
|
||||
}
|
||||
|
||||
.btn.report {
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.25);
|
||||
}
|
||||
.btn.report { background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); color: #fff; box-shadow: 0 10px 20px rgba(139,92,246,0.25); }
|
||||
|
||||
.btn.report:hover {
|
||||
transform: translateY(-2px);
|
||||
@@ -381,14 +383,9 @@ defineEmits(['p2Action', 'p2Force', 'assignShame', 'report']);
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid #e2e8f0;
|
||||
border-top: 3px solid #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
.spinner { width: 24px; height: 24px; border: 3px solid #e2e8f0; border-radius: 50%; animation: spin 1s linear infinite; }
|
||||
.spinner--p1 { border-top: 3px solid var(--p1); }
|
||||
.spinner--p2 { border-top: 3px solid var(--p2); }
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
@@ -406,31 +403,13 @@ defineEmits(['p2Action', 'p2Force', 'assignShame', 'report']);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.decision-badge {
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
.decision-badge { padding: 10px 20px; border-radius: 10px; font-weight: 700; font-size: 14px; text-align: center; }
|
||||
|
||||
.decision-badge.accept {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
border: 1px solid #6ee7b7;
|
||||
}
|
||||
.decision-badge.accept { background: #d1fae5; color: #065f46; border: 1px solid #6ee7b7; box-shadow: 0 6px 14px rgba(16,185,129,0.15); }
|
||||
|
||||
.decision-badge.reject {
|
||||
background: #fed7aa;
|
||||
color: #92400e;
|
||||
border: 1px solid #fb923c;
|
||||
}
|
||||
.decision-badge.reject { background: #fed7aa; color: #92400e; border: 1px solid #fb923c; box-shadow: 0 6px 14px rgba(245,158,11,0.15); }
|
||||
|
||||
.decision-badge.snatch {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fca5a5;
|
||||
}
|
||||
.decision-badge.snatch { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; box-shadow: 0 6px 14px rgba(239,68,68,0.15); }
|
||||
|
||||
.p1-response {
|
||||
margin-top: 16px;
|
||||
@@ -450,4 +429,4 @@ defineEmits(['p2Action', 'p2Force', 'assignShame', 'report']);
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div class="offer-card">
|
||||
<div class="offer-card" :class="{ disabled: isFinished }" :aria-disabled="isFinished ? 'true' : 'false'">
|
||||
<div v-if="isFinished" class="banner finished">🏁 Juego finalizado</div>
|
||||
<div class="offer-grid">
|
||||
<div class="group">
|
||||
<div class="group-title">Ofrezco</div>
|
||||
@@ -7,17 +8,17 @@
|
||||
<div class="token-ctrl">
|
||||
<span class="icon">🦃</span>
|
||||
<div class="ctrl">
|
||||
<button class="step" @click="dec('offerPavo')" aria-label="-1 pavo" tabindex="-1">−</button>
|
||||
<input type="number" min="0" :max="maxOfferPavo" v-model.number="offerPavo" />
|
||||
<button class="step" @click="inc('offerPavo')" aria-label="+1 pavo" tabindex="-1">+</button>
|
||||
<button class="step" @click="dec('offerPavo')" :disabled="isFinished" aria-label="-1 pavo" tabindex="-1">−</button>
|
||||
<input type="number" min="0" :max="maxOfferPavo" v-model.number="offerPavo" :disabled="isFinished" />
|
||||
<button class="step" @click="inc('offerPavo')" :disabled="isFinished" aria-label="+1 pavo" tabindex="-1">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="token-ctrl">
|
||||
<span class="icon">🌽</span>
|
||||
<div class="ctrl">
|
||||
<button class="step" @click="dec('offerElote')" aria-label="-1 elote" tabindex="-1">−</button>
|
||||
<input type="number" min="0" :max="maxOfferElote" v-model.number="offerElote" />
|
||||
<button class="step" @click="inc('offerElote')" aria-label="+1 elote" tabindex="-1">+</button>
|
||||
<button class="step" @click="dec('offerElote')" :disabled="isFinished" aria-label="-1 elote" tabindex="-1">−</button>
|
||||
<input type="number" min="0" :max="maxOfferElote" v-model.number="offerElote" :disabled="isFinished" />
|
||||
<button class="step" @click="inc('offerElote')" :disabled="isFinished" aria-label="+1 elote" tabindex="-1">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -29,17 +30,17 @@
|
||||
<div class="token-ctrl">
|
||||
<span class="icon">🦃</span>
|
||||
<div class="ctrl">
|
||||
<button class="step" @click="dec('requestPavo')" aria-label="-1 pavo" tabindex="-1">−</button>
|
||||
<input type="number" min="0" :max="maxRequestPavo" v-model.number="requestPavo" />
|
||||
<button class="step" @click="inc('requestPavo')" aria-label="+1 pavo" tabindex="-1">+</button>
|
||||
<button class="step" @click="dec('requestPavo')" :disabled="isFinished" aria-label="-1 pavo" tabindex="-1">−</button>
|
||||
<input type="number" min="0" :max="maxRequestPavo" v-model.number="requestPavo" :disabled="isFinished" />
|
||||
<button class="step" @click="inc('requestPavo')" :disabled="isFinished" aria-label="+1 pavo" tabindex="-1">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="token-ctrl">
|
||||
<span class="icon">🌽</span>
|
||||
<div class="ctrl">
|
||||
<button class="step" @click="dec('requestElote')" aria-label="-1 elote" tabindex="-1">−</button>
|
||||
<input type="number" min="0" :max="maxRequestElote" v-model.number="requestElote" />
|
||||
<button class="step" @click="inc('requestElote')" aria-label="+1 elote" tabindex="-1">+</button>
|
||||
<button class="step" @click="dec('requestElote')" :disabled="isFinished" aria-label="-1 elote" tabindex="-1">−</button>
|
||||
<input type="number" min="0" :max="maxRequestElote" v-model.number="requestElote" :disabled="isFinished" />
|
||||
<button class="step" @click="inc('requestElote')" :disabled="isFinished" aria-label="+1 elote" tabindex="-1">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -47,15 +48,18 @@
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn primary" @click="propose">Enviar oferta</button>
|
||||
<button class="btn ghost" @click="noOffer" :disabled="disableNoOffer">No ofrecer</button>
|
||||
<button class="btn primary" @click="propose" :disabled="isFinished || isNonsense">Enviar oferta</button>
|
||||
<button class="btn ghost" @click="noOffer" :disabled="disableNoOffer || isFinished">No ofrecer</button>
|
||||
</div>
|
||||
<div v-if="isNonsense && !isFinished" class="hint invalid">⚠️ La oferta no tiene sentido.</div>
|
||||
<div v-if="!isFinished && disableNoOffer" class="hint blocked">🚫 "No ofrecer" no está disponible porque P2 te obliga a proponer.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { colyseusService } from '../../services/colyseus';
|
||||
import { getStateCallbacks } from 'colyseus.js';
|
||||
const props = defineProps<{ disableNoOffer?: boolean; myRole?: string }>();
|
||||
const emit = defineEmits(['propose','no-offer']);
|
||||
const offerPavo = ref(0);
|
||||
@@ -66,10 +70,12 @@ const requestElote = ref(0);
|
||||
const room = computed(() => colyseusService.gameRoom.value as any);
|
||||
const p1 = computed(() => room.value?.state ? room.value.state.players.get(room.value.state.p1Id) : null);
|
||||
const p2 = computed(() => room.value?.state ? room.value.state.players.get(room.value.state.p2Id) : null);
|
||||
const isFinished = ref(false);
|
||||
const maxOfferPavo = computed(() => (p1.value?.pavoTokens ?? Infinity));
|
||||
const maxOfferElote = computed(() => (p1.value?.eloteTokens ?? Infinity));
|
||||
const maxRequestPavo = computed(() => (p2.value?.pavoTokens ?? Infinity));
|
||||
const maxRequestElote = computed(() => (p2.value?.eloteTokens ?? Infinity));
|
||||
const isNonsense = computed(() => (offerPavo.value|0) === (requestPavo.value|0) && (offerElote.value|0) === (requestElote.value|0));
|
||||
|
||||
function clampAll() {
|
||||
offerPavo.value = Math.max(0, Math.min(offerPavo.value | 0, maxOfferPavo.value));
|
||||
@@ -79,6 +85,17 @@ function clampAll() {
|
||||
}
|
||||
watch([offerPavo, offerElote, requestPavo, requestElote, maxOfferPavo, maxOfferElote, maxRequestPavo, maxRequestElote], clampAll);
|
||||
|
||||
onMounted(() => {
|
||||
const r = room.value;
|
||||
if (r?.state) {
|
||||
isFinished.value = ((r.state.gameStatus || '').toLowerCase() === 'finished');
|
||||
const $ = getStateCallbacks(r);
|
||||
$(r.state).listen('gameStatus', (v: string) => {
|
||||
isFinished.value = (v || '').toLowerCase() === 'finished';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function inc(key: 'offerPavo'|'offerElote'|'requestPavo'|'requestElote') {
|
||||
if (key === 'offerPavo') offerPavo.value = Math.min((offerPavo.value|0)+1, maxOfferPavo.value);
|
||||
else if (key === 'offerElote') offerElote.value = Math.min((offerElote.value|0)+1, maxOfferElote.value);
|
||||
@@ -93,6 +110,7 @@ function dec(key: 'offerPavo'|'offerElote'|'requestPavo'|'requestElote') {
|
||||
}
|
||||
|
||||
function propose() {
|
||||
if (isFinished.value || isNonsense.value) return;
|
||||
// Always emit the proposal with current values
|
||||
const payload = {
|
||||
offerPavo: Math.max(0, Math.min(offerPavo.value|0, maxOfferPavo.value)),
|
||||
@@ -110,6 +128,7 @@ function propose() {
|
||||
}
|
||||
|
||||
function noOffer() {
|
||||
if (isFinished.value) return;
|
||||
// Clear inputs
|
||||
offerPavo.value = 0;
|
||||
offerElote.value = 0;
|
||||
@@ -121,6 +140,9 @@ function noOffer() {
|
||||
|
||||
<style scoped>
|
||||
.offer-card { margin-top:10px; }
|
||||
.offer-card.disabled { opacity: 0.6; filter: grayscale(0.15); pointer-events: none; }
|
||||
.banner { margin-bottom:8px; padding:8px 10px; border-radius:10px; font-weight:700; font-size:13px; display:flex; align-items:center; gap:8px; }
|
||||
.banner.finished { background:#f8fafc; border:1px solid #e5e9f0; color:#334155; }
|
||||
.offer-grid { display:grid; grid-template-columns: 1fr; gap:12px; }
|
||||
@media (min-width: 500px) { .offer-grid { grid-template-columns: 1fr 1fr; } }
|
||||
|
||||
@@ -135,11 +157,16 @@ function noOffer() {
|
||||
.ctrl input[type=number] { -moz-appearance: textfield; }
|
||||
.step { width:28px; height:28px; border-radius:8px; border:1px solid #cbd5e1; background:#f1f5f9; cursor:pointer; line-height:1; display:flex; align-items:center; justify-content:center; }
|
||||
.step:hover { background:#e2e8f0; }
|
||||
.step:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
.actions { display:flex; gap:10px; justify-content:flex-end; margin-top:12px; flex-wrap: wrap; }
|
||||
.hint.invalid { margin-top:8px; font-size:13px; font-weight:600; background:#fff7ed; color:#92400e; border:1px solid #fed7aa; padding:6px 10px; border-radius:8px; display:inline-flex; align-items:center; gap:6px; }
|
||||
.hint.blocked { margin-top:6px; font-size:13px; font-weight:600; background:#eef2ff; color:#3949ab; border:1px solid #c7d2fe; padding:6px 10px; border-radius:8px; display:inline-flex; align-items:center; gap:6px; }
|
||||
.btn { padding:10px 14px; border:none; border-radius:10px; cursor:pointer; font-weight:700; }
|
||||
.btn.primary { background:#667eea; color:#fff; box-shadow: 0 10px 20px rgba(102,126,234,0.35); }
|
||||
.btn.primary:hover { filter: brightness(1.05); }
|
||||
.btn.ghost { background:#eef2ff; color:#3949ab; border:1px solid #c7d2fe; }
|
||||
.btn.ghost:disabled { opacity:0.5; cursor:not-allowed; }
|
||||
.btn.primary:disabled { opacity: 0.5; cursor: not-allowed; box-shadow: none; }
|
||||
.ctrl input:disabled { background: #f8fafc; color:#94a3b8; }
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,17 @@ export class GameRoom extends Room<GameState> {
|
||||
maxClients = 2;
|
||||
private gameInterval?: NodeJS.Timeout;
|
||||
|
||||
private sysChat(text: string, kind: string) {
|
||||
this.broadcast("chat", {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
text,
|
||||
from: "Sistema",
|
||||
fromId: "system",
|
||||
ts: Date.now(),
|
||||
kind,
|
||||
} as any);
|
||||
}
|
||||
|
||||
onCreate(options: any) {
|
||||
this.setState(new GameState());
|
||||
this.state.roomId = this.roomId;
|
||||
@@ -26,6 +37,7 @@ export class GameRoom extends Room<GameState> {
|
||||
this.state.forcedByP2 = true;
|
||||
}
|
||||
this.broadcast("variantChanged", { variant });
|
||||
this.sysChat(`🔄 Variante cambiada a ${variant}`, 'variant_change');
|
||||
});
|
||||
|
||||
// P1 proposes a variable offer (offer -> P2, request <- from P2)
|
||||
@@ -56,6 +68,8 @@ export class GameRoom extends Room<GameState> {
|
||||
this.state.requestElote = rElote;
|
||||
this.state.offerActive = true; // Always set active when an offer is proposed
|
||||
this.state.p1Action = "offer";
|
||||
// System chat with proposal summary
|
||||
this.sysChat(`📨 P1 ofrece`, 'p1_propose');
|
||||
});
|
||||
|
||||
// P1 decides to not offer
|
||||
@@ -67,6 +81,8 @@ export class GameRoom extends Room<GameState> {
|
||||
|
||||
this.state.resetRound();
|
||||
this.state.p1Action = "no_offer";
|
||||
// System chat for no-offer
|
||||
this.sysChat('⛔ P1 no ofrece', 'p1_no_offer');
|
||||
// Auto-advance to next round when P1 doesn't offer
|
||||
this.advanceRound();
|
||||
});
|
||||
@@ -80,6 +96,8 @@ export class GameRoom extends Room<GameState> {
|
||||
// When forced, P1 must propose an offer; nothing automatic here.
|
||||
});
|
||||
|
||||
// System chat helper moved to class method this.sysChat
|
||||
|
||||
// P2 action
|
||||
this.onMessage("p2Action", (client, action: string) => {
|
||||
const player = this.state.players.get(client.sessionId);
|
||||
@@ -91,6 +109,11 @@ export class GameRoom extends Room<GameState> {
|
||||
|
||||
this.state.p2Action = action; // accept | reject | snatch
|
||||
this.resolveP2Action();
|
||||
|
||||
// System chat feedback for both players
|
||||
if (action === 'accept') this.sysChat('P2 aceptó', 'p2_accept');
|
||||
else if (action === 'reject') this.sysChat('P2 rechazó la oferta', 'p2_reject');
|
||||
else if (action === 'snatch') this.sysChat('👹 P2 robó', 'p2_snatch');
|
||||
|
||||
// Auto-advance unless it's a snatch in G3 or G4 (need shame/report)
|
||||
if (action !== 'snatch' || (this.state.currentVariant !== 'G3' && this.state.currentVariant !== 'G4')) {
|
||||
@@ -124,6 +147,9 @@ export class GameRoom extends Room<GameState> {
|
||||
// Clear offer now
|
||||
this.clearOffer();
|
||||
}
|
||||
// System chat feedback
|
||||
if (report) this.sysChat('⚖️ P1 denunció al juez y se confiscaron tokens', 'p1_report');
|
||||
else this.sysChat('🤝 P1 decidió no denunciar al juez', 'p1_no_report');
|
||||
// Auto-advance after report decision
|
||||
this.advanceRound();
|
||||
});
|
||||
@@ -135,10 +161,11 @@ export class GameRoom extends Room<GameState> {
|
||||
if (!text.trim()) return;
|
||||
const player = this.state.players.get(client.sessionId);
|
||||
const from = player?.name || "player";
|
||||
const color = (player as any)?.color || "#667eea";
|
||||
const ts = Date.now();
|
||||
const id = (payload as any)?.id || `${ts}-${client.sessionId}`;
|
||||
// Broadcast to all (including sender) so both UIs render the same
|
||||
this.broadcast("chat", { id, text, from, fromId: client.sessionId, ts });
|
||||
this.broadcast("chat", { id, text, from, fromId: client.sessionId, ts, color });
|
||||
});
|
||||
|
||||
// G3 shame token after snatch
|
||||
@@ -152,6 +179,9 @@ export class GameRoom extends Room<GameState> {
|
||||
const p2 = this.state.p2Id ? this.state.players.get(this.state.p2Id) : undefined;
|
||||
if (p2) p2.shameTokens += 1;
|
||||
}
|
||||
// System chat feedback
|
||||
if (assign) this.sysChat('😶 P1 asignó un token de vergüenza a P2', 'p1_shame');
|
||||
else this.sysChat('😌 P1 decidió no asignar vergüenza', 'p1_no_shame');
|
||||
// Auto-advance after shame decision
|
||||
this.advanceRound();
|
||||
});
|
||||
@@ -249,6 +279,8 @@ export class GameRoom extends Room<GameState> {
|
||||
this.state.forcedByP2 = true;
|
||||
}
|
||||
this.broadcast("gameStart");
|
||||
// System chat: start at round 1
|
||||
this.sysChat(`▶️ Ronda ${this.state.currentRound}/3`, 'round_advance');
|
||||
}
|
||||
|
||||
private pauseGame() {
|
||||
@@ -380,6 +412,7 @@ export class GameRoom extends Room<GameState> {
|
||||
this.state.currentRound += 1;
|
||||
this.state.resetRound();
|
||||
this.broadcast("roundStarted", { round: this.state.currentRound });
|
||||
this.sysChat(`▶️ Ronda ${this.state.currentRound}/3`, 'round_advance');
|
||||
} else {
|
||||
this.state.finishGame();
|
||||
this.endGame();
|
||||
|
||||
Reference in New Issue
Block a user