modificada la logica de juego e interfaz para acomodarse a su objetivo real. llevado a un punto de al menos 3 jugadores simultaneos

This commit is contained in:
2025-07-03 18:08:29 -06:00
parent f739c6b3c7
commit 656cf7988e
19 changed files with 7190 additions and 231 deletions

View File

@@ -11,37 +11,63 @@
</div>
</div>
<!-- Playing Phase -->
<div v-else-if="gamePhase === 'playing'" class="game-screen">
<!-- Scoreboard -->
<div class="scoreboard">
<div
v-for="player in players"
:key="player.id"
class="player-score"
:class="{ 'current-player': player.id === currentPlayerId }"
>
<span class="player-name">{{ player.name }}</span>
<span class="score">{{ player.score }}</span>
<!-- Trading Phase -->
<div v-else-if="gamePhase === 'trading'" class="game-screen">
<!-- Game Header -->
<div class="game-header">
<div class="round-info">
<h2>Ronda {{ round }}</h2>
<span class="phase">Fase de Intercambio</span>
</div>
</div>
<!-- Click Button -->
<div class="click-area">
<button
@click="handleClick"
class="click-button"
:class="{ 'clicked': isClicked }"
>
<span class="click-text">¡CLICK!</span>
<div class="click-effect" v-if="showEffect"></div>
</button>
<!-- Main Game Layout -->
<div class="game-layout">
<!-- Left side: Players -->
<div class="players-section">
<!-- Other Players (compact) -->
<div class="other-players">
<PlayerCard
v-for="player in otherPlayers"
:key="player.id"
:player="player"
:is-current-player="false"
:compact="true"
@click="openOfferModal"
/>
</div>
<!-- Current Player (large) -->
<div class="current-player-section">
<PlayerCard
v-if="currentPlayer"
:player="currentPlayer"
:is-current-player="true"
:compact="false"
/>
</div>
</div>
<!-- Right side: Offers (Desktop) / Bottom: Offers (Mobile) -->
<div class="offers-section">
<ScrollableOffers
:offers="activeOffers"
:current-player-id="currentPlayerId"
:get-player-name="getPlayerName"
@cancel="cancelOffer"
@respond="respondToOffer"
/>
</div>
</div>
<!-- Current Player Info -->
<div class="player-info">
<p>Tu puntaje: <strong>{{ currentPlayerScore }}</strong></p>
</div>
<!-- Offer Modal -->
<OfferModal
:is-open="showOfferModal"
:target-player-id="selectedTargetId"
:all-players="players"
@close="closeOfferModal"
@make-offer="makeOffer"
/>
</div>
</div>
</template>
@@ -49,17 +75,20 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, triggerRef } from 'vue'
import { GameClient } from '@/services/gameClient'
import { GameState, Player } from '@/types'
import { GameState, Player, TradeOffer } from '@/types'
import type { Room } from 'colyseus.js'
import { logger } from '@/services/logger'
import PlayerCard from './PlayerCard.vue'
import ScrollableOffers from './ScrollableOffers.vue'
import OfferModal from './OfferModal.vue'
const props = defineProps<{
gameClient: any
}>()
const gameState = ref<GameState | null>(null)
const isClicked = ref(false)
const showEffect = ref(false)
const showOfferModal = ref(false)
const selectedTargetId = ref('')
// Computed properties
const gamePhase = computed(() => {
@@ -67,7 +96,8 @@ const gamePhase = computed(() => {
logger.computedProperty('gamePhase', phase)
return phase
})
const minPlayers = computed(() => gameState.value?.minPlayers || 2)
const round = computed(() => gameState.value?.round || 1)
const minPlayers = computed(() => gameState.value?.minPlayers || 3)
const playerCount = computed(() => {
const count = gameState.value?.players.size || 0
logger.computedProperty('playerCount', count)
@@ -80,29 +110,63 @@ const players = computed(() => {
return playerList
})
const currentPlayerId = computed(() => props.gameClient?.currentPlayerId || '')
const currentPlayerScore = computed(() => {
if (!gameState.value || !currentPlayerId.value) return 0
const player = gameState.value.players.get(currentPlayerId.value)
return player?.score || 0
const currentPlayer = computed(() => {
return players.value.find(p => p.id === currentPlayerId.value) || null
})
const otherPlayers = computed(() => {
return players.value.filter(p => p.id !== currentPlayerId.value)
})
const activeOffers = computed(() => {
if (!gameState.value) return []
return Array.from(gameState.value.activeTradeOffers.values()).reverse()
})
const handleClick = () => {
if (!props.gameClient || gamePhase.value !== 'playing') return
// Helper functions
const getPlayerName = (playerId: string): string => {
if (!gameState.value) return 'Desconocido'
const player = gameState.value.players.get(playerId)
return player?.name || 'Desconocido'
}
// Modal actions
const openOfferModal = (targetPlayerId: string) => {
selectedTargetId.value = targetPlayerId
showOfferModal.value = true
}
const closeOfferModal = () => {
showOfferModal.value = false
selectedTargetId.value = ''
}
// Game actions
const makeOffer = (offerData: {
targetId: string,
offering: { turkey: number, coffee: number, corn: number },
requesting: { turkey: number, coffee: number, corn: number }
}) => {
if (!props.gameClient) return
// Send click through gameClient
props.gameClient.sendClick()
props.gameClient.makeOffer(offerData)
}
const respondToOffer = (offerId: string, response: string) => {
if (!props.gameClient) return
// Visual feedback
isClicked.value = true
showEffect.value = true
props.gameClient.respondToOffer({
offerId,
response
})
}
const cancelOffer = (offerId: string) => {
if (!props.gameClient) return
setTimeout(() => {
isClicked.value = false
}, 150)
setTimeout(() => {
showEffect.value = false
}, 400)
props.gameClient.cancelOffer({
offerId
})
}
onMounted(() => {
@@ -115,7 +179,8 @@ onMounted(() => {
logger.gameComponentUpdate({
gamePhase: state.gamePhase,
playerCount: state.players.size,
gameStarted: state.gameStarted
gameStarted: state.gameStarted,
round: state.round
})
// Force Vue reactivity by assigning new reference and triggering update
@@ -145,16 +210,19 @@ onMounted(() => {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
justify-content: flex-start;
height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem;
padding: 1rem;
box-sizing: border-box;
overflow: hidden;
}
/* Waiting Screen */
.waiting-screen {
text-align: center;
margin-top: 10vh;
}
.waiting-content h2 {
@@ -187,114 +255,99 @@ onMounted(() => {
/* Game Screen */
.game-screen {
width: 100%;
max-width: 800px;
}
.scoreboard {
max-width: 1400px;
height: 100%;
display: flex;
justify-content: center;
gap: 2rem;
margin-bottom: 3rem;
flex-wrap: wrap;
flex-direction: column;
box-sizing: border-box;
}
.player-score {
background: rgba(255, 255, 255, 0.1);
padding: 1rem 1.5rem;
border-radius: 12px;
.game-header {
text-align: center;
backdrop-filter: blur(10px);
border: 2px solid transparent;
transition: all 0.3s ease;
margin-bottom: 1rem;
flex-shrink: 0;
}
.player-score.current-player {
border-color: #ffd700;
background: rgba(255, 215, 0, 0.2);
}
.player-name {
display: block;
font-size: 1rem;
.round-info h2 {
font-size: 2rem;
margin-bottom: 0.5rem;
opacity: 0.9;
}
.score {
display: block;
font-size: 2rem;
font-weight: 700;
}
/* Click Button */
.click-area {
display: flex;
justify-content: center;
margin-bottom: 2rem;
}
.click-button {
position: relative;
width: 250px;
height: 250px;
border-radius: 50%;
border: none;
background: linear-gradient(45deg, #ff6b6b, #ff8e53);
color: white;
font-size: 2rem;
font-weight: 700;
cursor: pointer;
transition: all 0.1s ease;
box-shadow: 0 8px 25px rgba(255, 107, 107, 0.4);
overflow: hidden;
}
.click-button:hover {
transform: scale(1.05);
box-shadow: 0 12px 35px rgba(255, 107, 107, 0.6);
}
.click-button.clicked {
transform: scale(0.95);
background: linear-gradient(45deg, #ff8e53, #ff6b6b);
}
.click-text {
position: relative;
z-index: 2;
}
.click-effect {
position: absolute;
top: 50%;
left: 50%;
width: 30px;
height: 30px;
background: rgba(255, 255, 255, 0.8);
border-radius: 50%;
transform: translate(-50%, -50%);
animation: clickRipple 0.4s ease-out;
}
@keyframes clickRipple {
0% {
width: 30px;
height: 30px;
opacity: 0.8;
}
100% {
width: 300px;
height: 300px;
opacity: 0;
}
}
.player-info {
text-align: center;
.phase {
font-size: 1.2rem;
opacity: 0.8;
}
.player-info strong {
color: #ffd700;
/* Main Game Layout */
.game-layout {
display: grid;
grid-template-columns: 1fr 350px;
gap: 2rem;
flex: 1;
min-height: 0;
}
.players-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.other-players {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.current-player-section {
margin-top: auto;
}
.offers-section {
min-height: 0;
}
/* Mobile Layout */
@media (max-width: 768px) {
.game-container {
height: auto;
min-height: 100vh;
overflow: auto;
}
.game-screen {
height: auto;
min-height: calc(100vh - 2rem);
}
.game-layout {
grid-template-columns: 1fr;
grid-template-rows: auto 400px;
}
.players-section {
order: 1;
}
.offers-section {
order: 2;
min-height: 400px;
}
.other-players {
grid-template-columns: 1fr 1fr;
margin-bottom: 1rem;
}
.current-player-section {
margin-top: 0;
}
}
/* Tablet adjustments */
@media (max-width: 1024px) and (min-width: 769px) {
.game-layout {
grid-template-columns: 1fr 300px;
}
}
</style>

View File

@@ -0,0 +1,348 @@
<template>
<div class="make-offer-form">
<h4>Hacer Oferta</h4>
<div class="offer-form">
<div v-if="!preSelectedTarget" class="target-selection">
<label>Ofrecer a:</label>
<select v-model="form.targetId">
<option value="">Seleccionar jugador</option>
<option
v-for="player in otherPlayers"
:key="player.id"
:value="player.id"
>
{{ player.name }}
</option>
</select>
</div>
<div class="tokens-form">
<div class="form-section">
<label>Ofrecer:</label>
<div class="token-inputs">
<div class="token-input-compact">
<span class="token-emoji">🦃</span>
<button @click="adjustToken('offering', 'turkey', -1)" class="quantity-btn minus-btn">-</button>
<input
type="number"
v-model.number="form.offering.turkey"
min="0"
max="999"
class="quantity-input"
@input="validateInput('offering', 'turkey', $event)"
>
<button @click="adjustToken('offering', 'turkey', 1)" class="quantity-btn plus-btn">+</button>
</div>
<div class="token-input-compact">
<span class="token-emoji"></span>
<button @click="adjustToken('offering', 'coffee', -1)" class="quantity-btn minus-btn">-</button>
<input
type="number"
v-model.number="form.offering.coffee"
min="0"
max="999"
class="quantity-input"
@input="validateInput('offering', 'coffee', $event)"
>
<button @click="adjustToken('offering', 'coffee', 1)" class="quantity-btn plus-btn">+</button>
</div>
<div class="token-input-compact">
<span class="token-emoji">🌽</span>
<button @click="adjustToken('offering', 'corn', -1)" class="quantity-btn minus-btn">-</button>
<input
type="number"
v-model.number="form.offering.corn"
min="0"
max="999"
class="quantity-input"
@input="validateInput('offering', 'corn', $event)"
>
<button @click="adjustToken('offering', 'corn', 1)" class="quantity-btn plus-btn">+</button>
</div>
</div>
</div>
<div class="form-section">
<label>Por:</label>
<div class="token-inputs">
<div class="token-input-compact">
<span class="token-emoji">🦃</span>
<button @click="adjustToken('requesting', 'turkey', -1)" class="quantity-btn minus-btn">-</button>
<input
type="number"
v-model.number="form.requesting.turkey"
min="0"
max="999"
class="quantity-input"
@input="validateInput('requesting', 'turkey', $event)"
>
<button @click="adjustToken('requesting', 'turkey', 1)" class="quantity-btn plus-btn">+</button>
</div>
<div class="token-input-compact">
<span class="token-emoji"></span>
<button @click="adjustToken('requesting', 'coffee', -1)" class="quantity-btn minus-btn">-</button>
<input
type="number"
v-model.number="form.requesting.coffee"
min="0"
max="999"
class="quantity-input"
@input="validateInput('requesting', 'coffee', $event)"
>
<button @click="adjustToken('requesting', 'coffee', 1)" class="quantity-btn plus-btn">+</button>
</div>
<div class="token-input-compact">
<span class="token-emoji">🌽</span>
<button @click="adjustToken('requesting', 'corn', -1)" class="quantity-btn minus-btn">-</button>
<input
type="number"
v-model.number="form.requesting.corn"
min="0"
max="999"
class="quantity-input"
@input="validateInput('requesting', 'corn', $event)"
>
<button @click="adjustToken('requesting', 'corn', 1)" class="quantity-btn plus-btn">+</button>
</div>
</div>
</div>
</div>
<button
@click="submitOffer"
class="make-offer-btn"
:disabled="!canMakeOffer"
>
Hacer Oferta
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Player } from '@/types'
const props = defineProps<{
otherPlayers: Player[]
preSelectedTarget?: string
}>()
const emit = defineEmits<{
makeOffer: [offerData: {
targetId: string,
offering: { turkey: number, coffee: number, corn: number },
requesting: { turkey: number, coffee: number, corn: number }
}]
}>()
const form = ref({
targetId: props.preSelectedTarget || '',
offering: { turkey: 0, coffee: 0, corn: 0 },
requesting: { turkey: 0, coffee: 0, corn: 0 }
})
const canMakeOffer = computed(() => {
if (!form.value.targetId) return false
const hasOffering = form.value.offering.turkey > 0 || form.value.offering.coffee > 0 || form.value.offering.corn > 0
const hasRequesting = form.value.requesting.turkey > 0 || form.value.requesting.coffee > 0 || form.value.requesting.corn > 0
return hasOffering && hasRequesting
})
const adjustToken = (section: 'offering' | 'requesting', token: 'turkey' | 'coffee' | 'corn', delta: number) => {
const currentValue = form.value[section][token]
const newValue = Math.max(0, Math.min(999, currentValue + delta))
form.value[section][token] = newValue
}
const validateInput = (section: 'offering' | 'requesting', token: 'turkey' | 'coffee' | 'corn', event: Event) => {
const target = event.target as HTMLInputElement
let value = parseInt(target.value) || 0
value = Math.max(0, Math.min(999, value))
form.value[section][token] = value
}
const submitOffer = () => {
if (!canMakeOffer.value) return
emit('makeOffer', {
targetId: form.value.targetId,
offering: { ...form.value.offering },
requesting: { ...form.value.requesting }
})
// Reset form
form.value.offering = { turkey: 0, coffee: 0, corn: 0 }
form.value.requesting = { turkey: 0, coffee: 0, corn: 0 }
}
</script>
<style scoped>
.make-offer-form {
background: rgba(255, 255, 255, 0.1);
padding: 1rem;
border-radius: 12px;
backdrop-filter: blur(10px);
}
.make-offer-form h4 {
margin-bottom: 0.75rem;
text-align: center;
font-size: 1.1rem;
}
.target-selection {
margin-bottom: 1.5rem;
}
.target-selection label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
}
.target-selection select {
width: 100%;
padding: 0.75rem;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.9);
font-size: 1rem;
color: #333;
}
.tokens-form {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.form-section label {
display: block;
margin-bottom: 0.75rem;
font-weight: 600;
font-size: 0.9rem;
}
.token-inputs {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.token-input-compact {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.1);
padding: 0.5rem;
border-radius: 8px;
gap: 0.75rem;
}
.token-emoji {
font-size: 1.3rem;
min-width: 32px;
text-align: center;
}
.quantity-btn {
width: 36px;
height: 36px;
border: none;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 1.4rem;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.minus-btn {
background: linear-gradient(135deg, #ff6b6b, #ff5252);
color: white;
}
.minus-btn:hover {
background: linear-gradient(135deg, #ff5252, #ff1744);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(255, 107, 107, 0.4);
}
.plus-btn {
background: linear-gradient(135deg, #4CAF50, #43A047);
color: white;
}
.plus-btn:hover {
background: linear-gradient(135deg, #43A047, #388E3C);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(76, 175, 80, 0.4);
}
.quantity-btn:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.quantity-input {
width: 60px;
padding: 0.5rem;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.9);
color: #333;
text-align: center;
font-weight: 600;
font-size: 1rem;
}
.quantity-input:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.5);
}
/* Hide spinner arrows */
.quantity-input::-webkit-outer-spin-button,
.quantity-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.quantity-input[type=number] {
-moz-appearance: textfield;
}
.make-offer-btn {
width: 100%;
padding: 1rem;
border: none;
border-radius: 8px;
background: linear-gradient(45deg, #4CAF50, #45a049);
color: white;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.make-offer-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.4);
}
.make-offer-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
background: #666;
}
@media (max-width: 768px) {
.tokens-form {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,180 @@
<template>
<Teleport to="body">
<div
v-if="isOpen"
class="modal-overlay"
@click="closeModal"
>
<div
class="modal-content"
@click.stop
>
<div class="modal-header">
<h3>Hacer Oferta a {{ targetPlayerName }}</h3>
<button class="close-btn" @click="closeModal">×</button>
</div>
<div class="modal-body">
<MakeOfferForm
:other-players="[targetPlayer].filter(Boolean)"
:pre-selected-target="targetPlayerId"
@make-offer="handleMakeOffer"
/>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { Player } from '@/types'
import MakeOfferForm from './MakeOfferForm.vue'
const props = defineProps<{
isOpen: boolean
targetPlayerId: string
allPlayers: Player[]
}>()
const emit = defineEmits<{
close: []
makeOffer: [offerData: {
targetId: string,
offering: { turkey: number, coffee: number, corn: number },
requesting: { turkey: number, coffee: number, corn: number }
}]
}>()
const targetPlayer = computed(() =>
props.allPlayers.find(p => p.id === props.targetPlayerId)
)
const targetPlayerName = computed(() =>
targetPlayer.value?.name || 'Jugador'
)
const closeModal = () => {
emit('close')
}
const handleMakeOffer = (offerData: {
targetId: string,
offering: { turkey: number, coffee: number, corn: number },
requesting: { turkey: number, coffee: number, corn: number }
}) => {
emit('makeOffer', offerData)
closeModal()
}
// Close modal on escape key
watch(() => props.isOpen, (isOpen) => {
if (isOpen) {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
closeModal()
}
}
document.addEventListener('keydown', handleEscape)
// Cleanup
return () => {
document.removeEventListener('keydown', handleEscape)
}
}
})
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.modal-content {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
max-width: 600px;
width: 90vw;
max-height: 80vh;
overflow: hidden;
animation: modalSlideIn 0.3s ease-out;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: scale(0.9) translateY(-20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
.modal-header h3 {
margin: 0;
color: white;
font-size: 1.3rem;
}
.close-btn {
background: none;
border: none;
color: white;
font-size: 2rem;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s ease;
}
.close-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.modal-body {
padding: 0;
max-height: calc(80vh - 80px);
overflow-y: auto;
}
/* Mobile adjustments */
@media (max-width: 768px) {
.modal-content {
width: 95vw;
max-height: 90vh;
}
.modal-header {
padding: 1rem;
}
.modal-header h3 {
font-size: 1.1rem;
}
}
</style>

View File

@@ -0,0 +1,201 @@
<template>
<div
class="player-card"
:class="{
'current-player': isCurrentPlayer,
'compact': compact,
'clickable': !isCurrentPlayer
}"
@click="handleClick"
>
<div class="player-header">
<span class="player-name">{{ player.name }}</span>
<span class="producer-role" v-if="!compact">{{ getProducerRoleText(player.producerRole) }}</span>
</div>
<div class="tokens-display" :class="{ 'compact': compact }">
<div class="token-item">
<span class="token-icon">🦃</span>
<span class="token-count">{{ player.tokens.turkey }}</span>
</div>
<div class="token-item">
<span class="token-icon"></span>
<span class="token-count">{{ player.tokens.coffee }}</span>
</div>
<div class="token-item">
<span class="token-icon">🌽</span>
<span class="token-count">{{ player.tokens.corn }}</span>
</div>
</div>
<div class="player-points">
<span class="points-label" v-if="!compact">Puntos:</span>
<span class="points-value">{{ player.points }}</span>
</div>
<div v-if="!isCurrentPlayer && compact" class="click-indicator">
Click para ofertar
</div>
</div>
</template>
<script setup lang="ts">
import { Player } from '@/types'
const props = defineProps<{
player: Player
isCurrentPlayer: boolean
compact?: boolean
}>()
const emit = defineEmits<{
click: [playerId: string]
}>()
const getProducerRoleText = (role: string): string => {
switch (role) {
case 'turkey': return 'Productor de Pavos'
case 'coffee': return 'Productor de Café'
case 'corn': return 'Productor de Maíz'
default: return role
}
}
const handleClick = () => {
if (!props.isCurrentPlayer) {
emit('click', props.player.id)
}
}
</script>
<style scoped>
.player-card {
background: rgba(255, 255, 255, 0.1);
padding: 1rem;
border-radius: 12px;
backdrop-filter: blur(10px);
border: 2px solid transparent;
transition: all 0.3s ease;
position: relative;
}
.player-card.current-player {
border-color: #ffd700;
background: rgba(255, 215, 0, 0.2);
}
.player-card.compact {
padding: 0.75rem;
min-height: 120px;
}
.player-card.clickable {
cursor: pointer;
}
.player-card.clickable:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.3);
}
.player-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.player-card.compact .player-header {
margin-bottom: 0.5rem;
}
.player-name {
font-size: 1.1rem;
font-weight: 600;
}
.player-card.compact .player-name {
font-size: 1rem;
}
.producer-role {
font-size: 0.9rem;
opacity: 0.7;
}
.tokens-display {
display: flex;
justify-content: space-around;
margin-bottom: 1rem;
}
.tokens-display.compact {
margin-bottom: 0.5rem;
}
.token-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.player-card.compact .token-item {
gap: 0.15rem;
}
.token-icon {
font-size: 1.5rem;
}
.player-card.compact .token-icon {
font-size: 1.2rem;
}
.token-count {
font-size: 1.2rem;
font-weight: 600;
}
.player-card.compact .token-count {
font-size: 1rem;
}
.player-points {
text-align: center;
padding-top: 0.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.2);
}
.player-card.compact .player-points {
padding-top: 0.25rem;
}
.points-label {
opacity: 0.8;
margin-right: 0.5rem;
}
.points-value {
font-size: 1.3rem;
font-weight: 700;
color: #ffd700;
}
.player-card.compact .points-value {
font-size: 1.1rem;
}
.click-indicator {
position: absolute;
bottom: 0.5rem;
left: 50%;
transform: translateX(-50%);
font-size: 0.75rem;
opacity: 0.6;
text-align: center;
transition: opacity 0.3s ease;
}
.player-card.clickable:hover .click-indicator {
opacity: 1;
}
</style>

View File

@@ -0,0 +1,213 @@
<template>
<div class="offers-container">
<h3>Ofertas Comerciales</h3>
<div class="offers-scroll-area" ref="scrollArea">
<div class="offers-content">
<TradeOfferCard
v-for="offer in offers"
:key="offer.id"
:offer="offer"
:current-player-id="currentPlayerId"
:get-player-name="getPlayerName"
@cancel="$emit('cancel', $event)"
@respond="$emit('respond', $event, arguments[1])"
/>
<div v-if="offers.length === 0" class="no-offers">
No hay ofertas activas
</div>
</div>
<div class="custom-scrollbar" v-show="showScrollbar">
<div
class="scrollbar-thumb"
:style="{
height: thumbHeight + '%',
top: thumbPosition + '%'
}"
@mousedown="startDrag"
></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { TradeOffer } from '@/types'
import TradeOfferCard from './TradeOfferCard.vue'
defineProps<{
offers: TradeOffer[]
currentPlayerId: string
getPlayerName: (playerId: string) => string
}>()
defineEmits<{
cancel: [offerId: string]
respond: [offerId: string, response: string]
}>()
const scrollArea = ref<HTMLElement>()
const showScrollbar = ref(false)
const thumbHeight = ref(100)
const thumbPosition = ref(0)
const isDragging = ref(false)
const dragStartY = ref(0)
const dragStartScrollTop = ref(0)
const updateScrollbar = () => {
if (!scrollArea.value) return
const { scrollTop, scrollHeight, clientHeight } = scrollArea.value
const isScrollable = scrollHeight > clientHeight
showScrollbar.value = isScrollable
if (isScrollable) {
thumbHeight.value = (clientHeight / scrollHeight) * 100
thumbPosition.value = (scrollTop / (scrollHeight - clientHeight)) * (100 - thumbHeight.value)
}
}
const startDrag = (e: MouseEvent) => {
isDragging.value = true
dragStartY.value = e.clientY
dragStartScrollTop.value = scrollArea.value?.scrollTop || 0
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
e.preventDefault()
}
const onDrag = (e: MouseEvent) => {
if (!isDragging.value || !scrollArea.value) return
const deltaY = e.clientY - dragStartY.value
const scrollAreaHeight = scrollArea.value.clientHeight
const scrollableHeight = scrollArea.value.scrollHeight - scrollAreaHeight
const scrollRatio = deltaY / scrollAreaHeight
scrollArea.value.scrollTop = dragStartScrollTop.value + (scrollRatio * scrollableHeight)
}
const stopDrag = () => {
isDragging.value = false
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
}
const onScroll = () => {
updateScrollbar()
}
onMounted(() => {
if (scrollArea.value) {
scrollArea.value.addEventListener('scroll', onScroll)
updateScrollbar()
// Watch for content changes
const observer = new ResizeObserver(() => {
nextTick(() => updateScrollbar())
})
observer.observe(scrollArea.value)
onUnmounted(() => {
observer.disconnect()
})
}
})
onUnmounted(() => {
if (scrollArea.value) {
scrollArea.value.removeEventListener('scroll', onScroll)
}
stopDrag()
})
</script>
<style scoped>
.offers-container {
background: rgba(255, 255, 255, 0.05);
border-radius: 16px;
backdrop-filter: blur(10px);
padding: 1.5rem;
height: 100%;
display: flex;
flex-direction: column;
}
.offers-container h3 {
margin-bottom: 1rem;
font-size: 1.2rem;
text-align: center;
}
.offers-scroll-area {
position: relative;
flex: 1;
overflow: hidden;
padding-right: 8px;
}
.offers-content {
height: 100%;
overflow-y: auto;
padding-right: 8px;
margin-right: -8px;
}
/* Hide default scrollbar */
.offers-content::-webkit-scrollbar {
display: none;
}
.offers-content {
-ms-overflow-style: none;
scrollbar-width: none;
}
.custom-scrollbar {
position: absolute;
right: 0;
top: 0;
width: 6px;
height: 100%;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
.scrollbar-thumb {
position: absolute;
width: 100%;
background: rgba(255, 255, 255, 0.5);
border-radius: 3px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.7);
}
.no-offers {
display: flex;
align-items: center;
justify-content: center;
height: 100px;
color: rgba(255, 255, 255, 0.6);
font-style: italic;
}
/* Mobile optimizations */
@media (max-width: 768px) {
.offers-container {
max-height: 300px;
}
}
/* Desktop optimizations */
@media (min-width: 769px) {
.offers-container {
min-height: 400px;
}
}
</style>

View File

@@ -0,0 +1,231 @@
<template>
<div
class="trade-offer"
:class="{
'my-offer': offer.offererId === currentPlayerId,
'target-offer': offer.targetId === currentPlayerId
}"
>
<div class="offer-header">
<span class="offerer">{{ getPlayerName(offer.offererId) }}</span>
<span class="arrow"></span>
<span class="target">{{ getPlayerName(offer.targetId) }}</span>
</div>
<div class="offer-details">
<div class="offering">
<span class="label">Ofrece:</span>
<div class="tokens">
<span v-if="offer.offering.turkey > 0">🦃 {{ offer.offering.turkey }}</span>
<span v-if="offer.offering.coffee > 0"> {{ offer.offering.coffee }}</span>
<span v-if="offer.offering.corn > 0">🌽 {{ offer.offering.corn }}</span>
</div>
</div>
<div class="requesting">
<span class="label">Por:</span>
<div class="tokens">
<span v-if="offer.requesting.turkey > 0">🦃 {{ offer.requesting.turkey }}</span>
<span v-if="offer.requesting.coffee > 0"> {{ offer.requesting.coffee }}</span>
<span v-if="offer.requesting.corn > 0">🌽 {{ offer.requesting.corn }}</span>
</div>
</div>
</div>
<div class="offer-actions">
<button
v-if="offer.offererId === currentPlayerId && offer.status === 'pending'"
@click="$emit('cancel', offer.id)"
class="cancel-btn"
>
Cancelar
</button>
<div v-else-if="offer.targetId === currentPlayerId && offer.status === 'pending'" class="response-actions">
<button @click="$emit('respond', offer.id, 'accept')" class="accept-btn">Aceptar</button>
<button @click="$emit('respond', offer.id, 'reject')" class="reject-btn">Rechazar</button>
<button @click="$emit('respond', offer.id, 'snatch')" class="snatch-btn">Snatch</button>
</div>
<span v-else class="offer-status">{{ getOfferStatusText(offer.status) }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { TradeOffer } from '@/types'
defineProps<{
offer: TradeOffer
currentPlayerId: string
getPlayerName: (playerId: string) => string
}>()
defineEmits<{
cancel: [offerId: string]
respond: [offerId: string, response: string]
}>()
const getOfferStatusText = (status: string): string => {
switch (status) {
case 'pending': return 'Pendiente'
case 'accepted': return 'Aceptada'
case 'rejected': return 'Rechazada'
case 'snatched': return 'Snatched'
case 'cancelled': return 'Cancelada'
default: return status
}
}
</script>
<style scoped>
.trade-offer {
background: rgba(255, 255, 255, 0.1);
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
border-left: 4px solid #ccc;
}
.trade-offer.my-offer {
border-left-color: #4CAF50;
}
.trade-offer.target-offer {
border-left-color: #FF9800;
}
.offer-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
font-weight: 600;
}
.arrow {
font-size: 1.2rem;
opacity: 0.7;
}
.offer-details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.label {
font-size: 0.9rem;
opacity: 0.8;
margin-bottom: 0.5rem;
display: block;
}
.tokens {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.tokens span {
background: rgba(255, 255, 255, 0.2);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.9rem;
}
.offer-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.response-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.offer-actions button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s ease;
}
.accept-btn {
background: #4CAF50;
color: white;
}
.reject-btn {
background: #f44336;
color: white;
}
.snatch-btn {
background: #FF9800;
color: white;
}
.cancel-btn {
background: #757575;
color: white;
}
.offer-actions button:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.offer-status {
padding: 0.5rem;
font-size: 0.9rem;
opacity: 0.7;
}
@media (max-width: 768px) {
.trade-offer {
padding: 0.75rem;
}
.offer-header {
margin-bottom: 0.75rem;
font-size: 0.9rem;
}
.offer-details {
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.label {
font-size: 0.8rem;
margin-bottom: 0.25rem;
}
.tokens span {
font-size: 0.8rem;
padding: 0.2rem 0.4rem;
}
.offer-actions {
gap: 0.25rem;
}
.offer-actions button {
padding: 0.4rem 0.75rem;
font-size: 0.8rem;
}
.response-actions {
gap: 0.25rem;
flex-wrap: nowrap;
}
.response-actions button {
flex: 1;
min-width: 0;
white-space: nowrap;
}
}
</style>

View File

@@ -132,6 +132,37 @@ export class GameClient {
}
}
makeOffer(offerData: {
targetId: string,
offering: { turkey: number, coffee: number, corn: number },
requesting: { turkey: number, coffee: number, corn: number }
}): void {
if (this.room && this.gameState?.gamePhase === 'trading') {
this.room.send('makeOffer', offerData)
logger.info('Trade offer sent:', offerData)
} else {
logger.info('Trade offer ignored - not in trading phase')
}
}
respondToOffer(responseData: { offerId: string, response: string }): void {
if (this.room && this.gameState?.gamePhase === 'trading') {
this.room.send('respondToOffer', responseData)
logger.info('Trade response sent:', responseData)
} else {
logger.info('Trade response ignored - not in trading phase')
}
}
cancelOffer(cancelData: { offerId: string }): void {
if (this.room && this.gameState?.gamePhase === 'trading') {
this.room.send('cancelOffer', cancelData)
logger.info('Trade cancellation sent:', cancelData)
} else {
logger.info('Trade cancellation ignored - not in trading phase')
}
}
// Getters
getCurrentPlayer(): Player | null {
if (!this.gameState || !this.currentPlayerId) return null

View File

@@ -0,0 +1,15 @@
//
// THIS FILE HAS BEEN GENERATED AUTOMATICALLY
// DO NOT CHANGE IT MANUALLY UNLESS YOU KNOW WHAT YOU'RE DOING
//
// GENERATED USING @colyseus/schema 3.0.42
//
import { Schema, type, ArraySchema, MapSchema, SetSchema, DataChange } from '@colyseus/schema';
export class TokenInventory extends Schema {
@type("number") public turkey!: number;
@type("number") public coffee!: number;
@type("number") public corn!: number;
}

View File

@@ -0,0 +1,18 @@
//
// THIS FILE HAS BEEN GENERATED AUTOMATICALLY
// DO NOT CHANGE IT MANUALLY UNLESS YOU KNOW WHAT YOU'RE DOING
//
// GENERATED USING @colyseus/schema 3.0.42
//
import { Schema, type, ArraySchema, MapSchema, SetSchema, DataChange } from '@colyseus/schema';
import { TokenInventory } from './TokenInventory'
export class TradeOffer extends Schema {
@type("string") public id!: string;
@type("string") public offererId!: string;
@type("string") public targetId!: string;
@type(TokenInventory) public offering: TokenInventory = new TokenInventory();
@type(TokenInventory) public requesting: TokenInventory = new TokenInventory();
@type("string") public status!: string;
}