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:
@@ -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>
|
||||
348
client/src/components/MakeOfferForm.vue
Normal file
348
client/src/components/MakeOfferForm.vue
Normal 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>
|
||||
180
client/src/components/OfferModal.vue
Normal file
180
client/src/components/OfferModal.vue
Normal 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>
|
||||
201
client/src/components/PlayerCard.vue
Normal file
201
client/src/components/PlayerCard.vue
Normal 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>
|
||||
213
client/src/components/ScrollableOffers.vue
Normal file
213
client/src/components/ScrollableOffers.vue
Normal 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>
|
||||
231
client/src/components/TradeOfferCard.vue
Normal file
231
client/src/components/TradeOfferCard.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
15
client/src/types/TokenInventory.ts
Normal file
15
client/src/types/TokenInventory.ts
Normal 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;
|
||||
}
|
||||
18
client/src/types/TradeOffer.ts
Normal file
18
client/src/types/TradeOffer.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user