refactor: deriva punt/set/servHome dalla striscia, estrai mixin WebSocket

La striscia è ora l'unica source of truth: punt, set vinti e servizio
vengono calcolati via helper puri (punteggio/servizio/setVinti) invece
di essere mantenuti come campi ridondanti nello stato.

Il boilerplate WebSocket (~150 righe identiche in entrambi i componenti)
è estratto in src/wsMixin.js con createWsMixin(role); i componenti
diventano solo logica specifica del loro ruolo.
This commit is contained in:
2026-06-20 15:23:53 +02:00
parent 8e2f3d759d
commit 38703116ff
4 changed files with 274 additions and 561 deletions
+55 -301
View File
@@ -10,15 +10,15 @@
<div class="score-preview"> <div class="score-preview">
<div class="team-score home-bg" @click="sendAction({ type: 'incPunt', team: 'home' })"> <div class="team-score home-bg" @click="sendAction({ type: 'incPunt', team: 'home' })">
<div class="team-name">{{ state.sp.nomi.home }}</div> <div class="team-name">{{ state.sp.nomi.home }}</div>
<div class="team-pts">{{ state.sp.punt.home }}</div> <div class="team-pts">{{ punt.home }}</div>
<div class="team-set">SET {{ state.sp.set.home }}</div> <div class="team-set">SET {{ set.home }}</div>
<img v-show="state.sp.servHome" src="/serv.png" class="serv-icon" alt="Servizio" /> <img v-show="servHome" src="/serv.png" class="serv-icon" alt="Servizio" />
</div> </div>
<div class="team-score guest-bg" @click="sendAction({ type: 'incPunt', team: 'guest' })"> <div class="team-score guest-bg" @click="sendAction({ type: 'incPunt', team: 'guest' })">
<div class="team-name">{{ state.sp.nomi.guest }}</div> <div class="team-name">{{ state.sp.nomi.guest }}</div>
<div class="team-pts">{{ state.sp.punt.guest }}</div> <div class="team-pts">{{ punt.guest }}</div>
<div class="team-set">SET {{ state.sp.set.guest }}</div> <div class="team-set">SET {{ set.guest }}</div>
<img v-show="!state.sp.servHome" src="/serv.png" class="serv-icon" alt="Servizio" /> <img v-show="!servHome" src="/serv.png" class="serv-icon" alt="Servizio" />
</div> </div>
</div> </div>
@@ -190,16 +190,13 @@
</template> </template>
<script> <script>
import { createWsMixin } from '../wsMixin.js'
export default { export default {
name: "ControllerPage", name: "ControllerPage",
mixins: [createWsMixin('controller')],
data() { data() {
return { return {
ws: null,
wsConnected: false,
isConnecting: false,
reconnectTimeout: null,
reconnectAttempts: 0,
maxReconnectDelay: 30000,
confirmReset: false, confirmReset: false,
showSetVinto: false, showSetVinto: false,
setVintoTeam: null, setVintoTeam: null,
@@ -207,44 +204,23 @@ export default {
showCambiTeam: false, showCambiTeam: false,
showCambi: false, showCambi: false,
cambiTeam: "home", cambiTeam: "home",
cambiData: [ cambiData: [{ in: "", out: "" }, { in: "", out: "" }],
{ in: "", out: "" },
{ in: "", out: "" },
],
cambiError: "", cambiError: "",
configData: { configData: {
nomeHome: "", nomeHome: "", nomeGuest: "", modalita: "3/5",
nomeGuest: "",
modalita: "3/5",
formHome: ["1", "2", "3", "4", "5", "6"], formHome: ["1", "2", "3", "4", "5", "6"],
formGuest: ["1", "2", "3", "4", "5", "6"], formGuest: ["1", "2", "3", "4", "5", "6"],
}, },
state: {
order: true,
visuForm: false,
visuStriscia: true,
modalitaPartita: "3/5",
sp: {
striscia: [{ serv: 'h', ris: '' }],
servHome: true,
punt: { home: 0, guest: 0 },
set: { home: 0, guest: 0 },
nomi: { home: "Antoniana", guest: "Guest" },
form: {
home: ["1", "2", "3", "4", "5", "6"],
guest: ["1", "2", "3", "4", "5", "6"],
},
},
},
} }
}, },
computed: { computed: {
isPunteggioZeroZero() { isPunteggioZeroZero() {
return this.state.sp.punt.home === 0 && this.state.sp.punt.guest === 0 return this.state.sp.striscia.at(-1).ris === ''
}, },
squadraVincente() { squadraVincente() {
const { home, guest } = this.state.sp.punt const { home, guest } = this.punt
const totSet = this.state.sp.set.home + this.state.sp.set.guest const sv = this.set
const totSet = sv.home + sv.guest
const isSetDecisivo = this.state.modalitaPartita === '2/3' ? totSet >= 2 : totSet >= 4 const isSetDecisivo = this.state.modalitaPartita === '2/3' ? totSet >= 2 : totSet >= 4
const soglia = isSetDecisivo ? 15 : 25 const soglia = isSetDecisivo ? 15 : 25
if (home >= soglia && home - guest >= 2) return 'home' if (home >= soglia && home - guest >= 2) return 'home'
@@ -254,20 +230,18 @@ export default {
isPartitaFinita() { isPartitaFinita() {
if (!this.setVintoTeam) return false if (!this.setVintoTeam) return false
const setsToWin = this.state.modalitaPartita === '2/3' ? 2 : 3 const setsToWin = this.state.modalitaPartita === '2/3' ? 2 : 3
return this.state.sp.set[this.setVintoTeam] + 1 >= setsToWin return this.set[this.setVintoTeam] + 1 >= setsToWin
}, },
cambiValid() { cambiValid() {
let hasComplete = false let hasComplete = false
let allValid = true for (const c of this.cambiData) {
this.cambiData.forEach((c) => { const cin = (c.in || "").trim(), cout = (c.out || "").trim()
const cin = (c.in || "").trim() if (!cin && !cout) continue
const cout = (c.out || "").trim() if (!cin || !cout) return false
if (!cin && !cout) return
if (!cin || !cout) { allValid = false; return }
hasComplete = true hasComplete = true
}) }
return allValid && hasComplete return hasComplete
} },
}, },
watch: { watch: {
squadraVincente(val) { squadraVincente(val) {
@@ -277,231 +251,47 @@ export default {
} }
}, },
}, },
mounted() {
this.connectWebSocket()
// Gestisce l'HMR di Vite evitando riconnessioni durante la ricarica a caldo.
if (import.meta.hot) {
import.meta.hot.on('vite:beforeUpdate', () => {
// Annulla eventuali tentativi di riconnessione pianificati.
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
})
}
},
beforeUnmount() {
// Pulisce il timeout di riconnessione.
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
// Chiude il WebSocket con il codice di chiusura appropriato.
if (this.ws) {
this.ws.onclose = null // Rimuove il listener per evitare nuove riconnessioni pianificate.
this.ws.onerror = null
this.ws.onmessage = null
this.ws.onopen = null
// Usa il codice 1000 (chiusura normale) se la connessione e aperta.
try {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.close(1000, 'Component unmounting')
} else if (this.ws.readyState === WebSocket.CONNECTING) {
// Se la connessione e ancora in fase di apertura, chiude direttamente.
this.ws.close()
}
} catch (err) {
console.error('[Controller] Error closing WebSocket:', err)
}
this.ws = null
}
},
methods: { methods: {
connectWebSocket() { onWsMessage(msg) {
// Evita connessioni simultanee multiple. if (msg.type === 'error') this.showErrorFeedback(msg.message)
if (this.isConnecting) {
console.log('[Controller] Already connecting, skipping...')
return
}
// Chiude la connessione precedente, se presente.
if (this.ws) {
this.ws.onclose = null
this.ws.onerror = null
this.ws.onmessage = null
this.ws.onopen = null
try {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.close(1000, 'Reconnecting')
} else if (this.ws.readyState === WebSocket.CONNECTING) {
this.ws.close()
}
} catch (err) {
console.error('[Controller] Error closing previous WebSocket:', err)
}
this.ws = null
}
this.isConnecting = true
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
// Permette di specificare un host WebSocket alternativo via query parameter
// Utile per scenari WSL2 o development remoto: ?wsHost=192.168.1.100:3001
const params = new URLSearchParams(location.search)
const defaultHost = (
location.hostname === 'localhost' || location.hostname === '::1'
)
? `127.0.0.1${location.port ? `:${location.port}` : ''}`
: location.host
const wsHost = params.get('wsHost') || defaultHost
const wsUrl = `${protocol}//${wsHost}/ws`
console.log('[Controller] Connecting to WebSocket:', wsUrl)
try {
this.ws = new WebSocket(wsUrl)
} catch (err) {
console.error('[Controller] Failed to create WebSocket:', err)
this.isConnecting = false
this.scheduleReconnect()
return
}
this.ws.onopen = () => {
this.isConnecting = false
this.wsConnected = true
this.reconnectAttempts = 0
console.log('[Controller] Connected to server')
// Invia la registrazione solo se la connessione e realmente aperta.
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try {
this.ws.send(JSON.stringify({ type: 'register', role: 'controller' }))
} catch (err) {
console.error('[Controller] Failed to register:', err)
}
}
}
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'state') {
this.state = msg.state
} else if (msg.type === 'error') {
console.error('[Controller] Server error:', msg.message)
// Fornisce feedback di errore all'utente.
this.showErrorFeedback(msg.message)
}
} catch (e) {
console.error('[Controller] Parse error:', e)
}
}
this.ws.onclose = (event) => {
this.isConnecting = false
this.wsConnected = false
console.log('[Controller] Disconnected from server', event.code, event.reason)
// Non riconnette durante HMR (codice 1001, "going away")
// ne in caso di chiusura pulita (codice 1000).
if (event.code === 1000 || event.code === 1001) {
console.log('[Controller] Clean close, not reconnecting')
return
}
this.scheduleReconnect()
}
this.ws.onerror = (err) => {
console.error('[Controller] WebSocket error:', err)
this.isConnecting = false
this.wsConnected = false
}
}, },
scheduleReconnect() {
// Evita pianificazioni multiple della riconnessione.
if (this.reconnectTimeout) {
return
}
// Applica backoff esponenziale: 1s, 2s, 4s, 8s, 16s, fino a 30s.
const delay = Math.min(
1000 * Math.pow(2, this.reconnectAttempts),
this.maxReconnectDelay
)
this.reconnectAttempts++
console.log(`[Controller] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`)
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null
this.connectWebSocket()
}, delay)
},
sendAction(action) { sendAction(action) {
if (!this.wsConnected || !this.ws || this.ws.readyState !== WebSocket.OPEN) { if (!action?.type) return
console.warn('[Controller] Cannot send action: not connected') if (!this.sendWs({ type: 'action', action })) this.showErrorFeedback('Non connesso al server')
this.showErrorFeedback('Non connesso al server')
return
}
// Valida l'azione prima dell'invio.
if (!action || !action.type) {
console.error('[Controller] Invalid action format:', action)
return
}
try {
this.ws.send(JSON.stringify({ type: 'action', action }))
} catch (err) {
console.error('[Controller] Failed to send action:', err)
this.showErrorFeedback('Errore invio comando')
}
}, },
showErrorFeedback(message) { showErrorFeedback(message) {
// Feedback visivo degli errori: attualmente solo log su console.
// In futuro puo essere esteso con notifiche a comparsa (toast).
console.error('[Controller] Error:', message) console.error('[Controller] Error:', message)
}, },
doReset() { doReset() {
this.sendAction({ type: 'resetta' }) this.sendAction({ type: 'resetta' })
this.confirmReset = false this.confirmReset = false
}, },
undoUltimoPoint() { undoUltimoPoint() {
this.sendAction({ type: 'decPunt' }) this.sendAction({ type: 'decPunt' })
this.showSetVinto = false this.showSetVinto = false
}, },
doNuovoSet() { doNuovoSet() {
this.sendAction({ type: 'nuovoSet', team: this.setVintoTeam }) this.sendAction({ type: 'nuovoSet', team: this.setVintoTeam })
this.showSetVinto = false this.showSetVinto = false
this.configData.nomeHome = this.state.sp.nomi.home this.configData = {
this.configData.nomeGuest = this.state.sp.nomi.guest nomeHome: this.state.sp.nomi.home,
this.configData.modalita = this.state.modalitaPartita nomeGuest: this.state.sp.nomi.guest,
this.configData.formHome = ["1", "2", "3", "4", "5", "6"] modalita: this.state.modalitaPartita,
this.configData.formGuest = ["1", "2", "3", "4", "5", "6"] formHome: ["1", "2", "3", "4", "5", "6"],
formGuest: ["1", "2", "3", "4", "5", "6"],
}
this.showConfig = true this.showConfig = true
}, },
openConfig() { openConfig() {
this.configData.nomeHome = this.state.sp.nomi.home this.configData = {
this.configData.nomeGuest = this.state.sp.nomi.guest nomeHome: this.state.sp.nomi.home,
this.configData.modalita = this.state.modalitaPartita nomeGuest: this.state.sp.nomi.guest,
this.configData.formHome = [...this.state.sp.form.home] modalita: this.state.modalitaPartita,
this.configData.formGuest = [...this.state.sp.form.guest] formHome: [...this.state.sp.form.home],
formGuest: [...this.state.sp.form.guest],
}
this.showConfig = true this.showConfig = true
}, },
saveConfig() { saveConfig() {
this.sendAction({ type: 'setNomi', home: this.configData.nomeHome, guest: this.configData.nomeGuest }) this.sendAction({ type: 'setNomi', home: this.configData.nomeHome, guest: this.configData.nomeGuest })
this.sendAction({ type: 'setModalita', modalita: this.configData.modalita }) this.sendAction({ type: 'setModalita', modalita: this.configData.modalita })
@@ -509,11 +299,7 @@ export default {
this.sendAction({ type: 'setFormazione', team: 'guest', form: this.configData.formGuest }) this.sendAction({ type: 'setFormazione', team: 'guest', form: this.configData.formGuest })
this.showConfig = false this.showConfig = false
}, },
openCambiTeam() { this.showCambiTeam = true },
openCambiTeam() {
this.showCambiTeam = true
},
openCambi(team) { openCambi(team) {
this.showCambiTeam = false this.showCambiTeam = false
this.cambiTeam = team this.cambiTeam = team
@@ -521,76 +307,44 @@ export default {
this.cambiError = "" this.cambiError = ""
this.showCambi = true this.showCambi = true
}, },
closeCambi() { closeCambi() {
this.showCambi = false this.showCambi = false
this.cambiData = [{ in: "", out: "" }, { in: "", out: "" }] this.cambiData = [{ in: "", out: "" }, { in: "", out: "" }]
this.cambiError = "" this.cambiError = ""
}, },
confermaCambi() { confermaCambi() {
if (!this.cambiValid) return if (!this.cambiValid) return
this.cambiError = "" this.cambiError = ""
const cambi = this.cambiData const cambi = this.cambiData
.filter(c => (c.in || "").trim() && (c.out || "").trim()) .filter(c => (c.in || "").trim() && (c.out || "").trim())
.map(c => ({ in: c.in.trim(), out: c.out.trim() })) .map(c => ({ in: c.in.trim(), out: c.out.trim() }))
const formSimulata = this.state.sp.form[this.cambiTeam].map(v => String(v).trim())
// Simula i cambi in sequenza per validare
const formCorrente = this.state.sp.form[this.cambiTeam].map(v => String(v).trim())
const formSimulata = [...formCorrente]
for (const cambio of cambi) { for (const cambio of cambi) {
if (!/^\d+$/.test(cambio.in) || !/^\d+$/.test(cambio.out)) { if (!/^\d+$/.test(cambio.in) || !/^\d+$/.test(cambio.out)) {
this.cambiError = "I numeri dei giocatori devono essere cifre" this.cambiError = "I numeri dei giocatori devono essere cifre"; return
return
} }
if (cambio.in === cambio.out) { if (cambio.in === cambio.out) {
this.cambiError = `Il giocatore ${cambio.in} non può sostituire sé stesso` this.cambiError = `Il giocatore ${cambio.in} non può sostituire sé stesso`; return
return
} }
if (formSimulata.includes(cambio.in)) { if (formSimulata.includes(cambio.in)) {
this.cambiError = `Il giocatore ${cambio.in} è già in formazione` this.cambiError = `Il giocatore ${cambio.in} è già in formazione`; return
return
} }
if (!formSimulata.includes(cambio.out)) { if (!formSimulata.includes(cambio.out)) {
this.cambiError = `Il giocatore ${cambio.out} non è in formazione` this.cambiError = `Il giocatore ${cambio.out} non è in formazione`; return
return
} }
const idx = formSimulata.indexOf(cambio.out) formSimulata.splice(formSimulata.indexOf(cambio.out), 1, cambio.in)
formSimulata.splice(idx, 1, cambio.in)
} }
this.sendAction({ type: 'confermaCambi', team: this.cambiTeam, cambi }) this.sendAction({ type: 'confermaCambi', team: this.cambiTeam, cambi })
this.closeCambi() this.closeCambi()
}, },
speak() { speak() {
let text = '' const { home, guest } = this.punt
if (this.state.sp.punt.home + this.state.sp.punt.guest === 0) { const text = home + guest === 0 ? "zero a zero"
text = "zero a zero" : home === guest ? `${home} pari`
} else if (this.state.sp.punt.home === this.state.sp.punt.guest) { : this.servHome ? `${home} a ${guest}` : `${guest} a ${home}`
text = this.state.sp.punt.home + " pari" this.sendWs({ type: 'speak', text })
} else { },
if (this.state.sp.servHome) { },
text = this.state.sp.punt.home + " a " + this.state.sp.punt.guest
} else {
text = this.state.sp.punt.guest + " a " + this.state.sp.punt.home
}
}
if (!this.wsConnected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
this.showErrorFeedback('Non connesso al server')
return
}
try {
this.ws.send(JSON.stringify({ type: 'speak', text }))
} catch (err) {
console.error('[Controller] Failed to send speak command:', err)
this.showErrorFeedback('Errore invio comando voce')
}
}
}
} }
</script> </script>
+29 -223
View File
@@ -7,22 +7,22 @@
<span :style="{ 'float': 'left' }"> <span :style="{ 'float': 'left' }">
{{ state.sp.nomi.home }} {{ state.sp.nomi.home }}
<span class="serv-slot"> <span class="serv-slot">
<img v-show="state.sp.servHome" src="/serv.png" width="25" alt="Servizio" /> <img v-show="servHome" src="/serv.png" width="25" alt="Servizio" />
</span> </span>
<span v-if="state.visuForm" class="score-inline">{{ state.sp.punt.home }}</span> <span v-if="state.visuForm" class="score-inline">{{ punt.home }}</span>
</span> </span>
<span class="mr3" :style="{ 'float': 'right' }">set {{ state.sp.set.home }}</span> <span class="mr3" :style="{ 'float': 'right' }">set {{ set.home }}</span>
</div> </div>
<div class="hea guest"> <div class="hea guest">
<span :style="{ 'float': 'right' }"> <span :style="{ 'float': 'right' }">
<span v-if="state.visuForm" class="score-inline">{{ state.sp.punt.guest }}</span> <span v-if="state.visuForm" class="score-inline">{{ punt.guest }}</span>
<span class="serv-slot"> <span class="serv-slot">
<img v-show="!state.sp.servHome" src="/serv.png" width="25" alt="Servizio" /> <img v-show="!servHome" src="/serv.png" width="25" alt="Servizio" />
</span> </span>
{{ state.sp.nomi.guest }} {{ state.sp.nomi.guest }}
</span> </span>
<span class="ml3" :style="{ 'float': 'left' }">set {{ state.sp.set.guest }}</span> <span class="ml3" :style="{ 'float': 'left' }">set {{ set.guest }}</span>
</div> </div>
<span v-if="state.visuForm"> <span v-if="state.visuForm">
@@ -39,8 +39,8 @@
</span> </span>
<span v-else> <span v-else>
<div class="punteggio-container"> <div class="punteggio-container">
<div class="col punt home">{{ state.sp.punt.home }}</div> <div class="col punt home">{{ punt.home }}</div>
<div class="col punt guest">{{ state.sp.punt.guest }}</div> <div class="col punt guest">{{ punt.guest }}</div>
</div> </div>
</span> </span>
</span> </span>
@@ -51,22 +51,22 @@
<span :style="{ 'float': 'left' }"> <span :style="{ 'float': 'left' }">
{{ state.sp.nomi.guest }} {{ state.sp.nomi.guest }}
<span class="serv-slot"> <span class="serv-slot">
<img v-show="!state.sp.servHome" src="/serv.png" width="25" alt="Servizio" /> <img v-show="!servHome" src="/serv.png" width="25" alt="Servizio" />
</span> </span>
<span v-if="state.visuForm" class="score-inline">{{ state.sp.punt.guest }}</span> <span v-if="state.visuForm" class="score-inline">{{ punt.guest }}</span>
</span> </span>
<span class="mr3" :style="{ 'float': 'right' }">set {{ state.sp.set.guest }}</span> <span class="mr3" :style="{ 'float': 'right' }">set {{ set.guest }}</span>
</div> </div>
<div class="hea home"> <div class="hea home">
<span :style="{ 'float': 'right' }"> <span :style="{ 'float': 'right' }">
<span v-if="state.visuForm" class="score-inline">{{ state.sp.punt.home }}</span> <span v-if="state.visuForm" class="score-inline">{{ punt.home }}</span>
<span class="serv-slot"> <span class="serv-slot">
<img v-show="state.sp.servHome" src="/serv.png" width="25" alt="Servizio" /> <img v-show="servHome" src="/serv.png" width="25" alt="Servizio" />
</span> </span>
{{ state.sp.nomi.home }} {{ state.sp.nomi.home }}
</span> </span>
<span class="ml3" :style="{ 'float': 'left' }">set {{ state.sp.set.home }}</span> <span class="ml3" :style="{ 'float': 'left' }">set {{ set.home }}</span>
</div> </div>
<span v-if="state.visuForm"> <span v-if="state.visuForm">
@@ -83,8 +83,8 @@
</span> </span>
<span v-else> <span v-else>
<div class="punteggio-container"> <div class="punteggio-container">
<div class="col punt guest">{{ state.sp.punt.guest }}</div> <div class="col punt guest">{{ punt.guest }}</div>
<div class="col punt home">{{ state.sp.punt.home }}</div> <div class="col punt home">{{ punt.home }}</div>
</div> </div>
</span> </span>
</span> </span>
@@ -116,80 +116,15 @@
</template> </template>
<script> <script>
import { createWsMixin } from '../wsMixin.js'
export default { export default {
name: "DisplayPage", name: "DisplayPage",
data() { mixins: [createWsMixin('display')],
return {
ws: null,
wsConnected: false,
isConnecting: false,
reconnectTimeout: null,
reconnectAttempts: 0,
maxReconnectDelay: 30000, // Ritardo massimo di riconnessione: 30 secondi
state: {
order: true,
visuForm: false,
visuStriscia: true,
modalitaPartita: "3/5",
sp: {
striscia: [{ serv: 'h', ris: '' }],
servHome: true,
punt: { home: 0, guest: 0 },
set: { home: 0, guest: 0 },
nomi: { home: "Antoniana", guest: "Guest" },
form: {
home: ["1", "2", "3", "4", "5", "6"],
guest: ["1", "2", "3", "4", "5", "6"],
},
},
},
}
},
mounted() { mounted() {
this.connectWebSocket()
// Attiva la modalita fullscreen su dispositivi mobili.
if (this.isMobile()) { if (this.isMobile()) {
try { document.documentElement.requestFullscreen() } catch (e) {} try { document.documentElement.requestFullscreen() } catch (e) {}
} }
// Gestisce l'HMR di Vite evitando riconnessioni durante la ricarica a caldo.
if (import.meta.hot) {
import.meta.hot.on('vite:beforeUpdate', () => {
// Annulla eventuali tentativi di riconnessione pianificati.
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
})
}
},
beforeUnmount() {
// Pulisce il timeout di riconnessione.
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
// Chiude il WebSocket con il codice di chiusura appropriato.
if (this.ws) {
this.ws.onclose = null // Rimuove il listener per evitare nuove riconnessioni pianificate.
this.ws.onerror = null
this.ws.onmessage = null
this.ws.onopen = null
// Usa il codice 1000 (chiusura normale) se la connessione e aperta.
try {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.close(1000, 'Component unmounting')
} else if (this.ws.readyState === WebSocket.CONNECTING) {
// Se la connessione e ancora in fase di apertura, chiude direttamente.
this.ws.close()
}
} catch (err) {
console.error('[Display] Error closing WebSocket:', err)
}
this.ws = null
}
}, },
computed: { computed: {
stricciaStrip() { stricciaStrip() {
@@ -216,154 +151,25 @@ export default {
}, },
}, },
methods: { methods: {
onWsMessage(msg) {
if (msg.type === 'speak') this.speakOnDisplay(msg.text)
else if (msg.type === 'error') console.error('[Display] Server error:', msg.message)
},
isMobile() { isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
}, },
connectWebSocket() {
// Evita connessioni simultanee multiple.
if (this.isConnecting) {
console.log('[Display] Already connecting, skipping...')
return
}
// Chiude la connessione precedente, se presente.
if (this.ws) {
this.ws.onclose = null
this.ws.onerror = null
this.ws.onmessage = null
this.ws.onopen = null
try {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.close(1000, 'Reconnecting')
} else if (this.ws.readyState === WebSocket.CONNECTING) {
this.ws.close()
}
} catch (err) {
console.error('[Display] Error closing previous WebSocket:', err)
}
this.ws = null
}
this.isConnecting = true
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
// Permette di specificare un host WebSocket alternativo via query parameter
// Utile per scenari WSL2 o development remoto: ?wsHost=192.168.1.100:5173
const params = new URLSearchParams(location.search)
const defaultHost = (
location.hostname === 'localhost' || location.hostname === '::1'
)
? `127.0.0.1${location.port ? `:${location.port}` : ''}`
: location.host
const wsHost = params.get('wsHost') || defaultHost
const wsUrl = `${protocol}//${wsHost}/ws`
console.log('[Display] Connecting to WebSocket:', wsUrl)
try {
this.ws = new WebSocket(wsUrl)
} catch (err) {
console.error('[Display] Failed to create WebSocket:', err)
this.isConnecting = false
this.scheduleReconnect()
return
}
this.ws.onopen = () => {
this.isConnecting = false
this.wsConnected = true
this.reconnectAttempts = 0
console.log('[Display] Connected to server')
// Registra il client come display solo con connessione effettivamente aperta.
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try {
this.ws.send(JSON.stringify({ type: 'register', role: 'display' }))
} catch (err) {
console.error('[Display] Failed to register:', err)
}
}
}
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'state') {
this.state = msg.state
} else if (msg.type === 'speak') {
this.speakOnDisplay(msg.text)
} else if (msg.type === 'error') {
console.error('[Display] Server error:', msg.message)
}
} catch (e) {
console.error('[Display] Error parsing message:', e)
}
}
this.ws.onclose = (event) => {
this.isConnecting = false
this.wsConnected = false
console.log('[Display] Disconnected from server', event.code, event.reason)
// Non riconnette durante HMR (codice 1001, "going away")
// ne in caso di chiusura pulita (codice 1000).
if (event.code === 1000 || event.code === 1001) {
console.log('[Display] Clean close, not reconnecting')
return
}
this.scheduleReconnect()
}
this.ws.onerror = (err) => {
console.error('[Display] WebSocket error:', err)
this.isConnecting = false
this.wsConnected = false
}
},
scheduleReconnect() {
// Evita pianificazioni multiple della riconnessione.
if (this.reconnectTimeout) {
return
}
// Applica backoff esponenziale: 1s, 2s, 4s, 8s, 16s, fino a 30s.
const delay = Math.min(
1000 * Math.pow(2, this.reconnectAttempts),
this.maxReconnectDelay
)
this.reconnectAttempts++
console.log(`[Display] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`)
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null
this.connectWebSocket()
}, delay)
},
speakOnDisplay(text) { speakOnDisplay(text) {
if (typeof text !== 'string' || !text.trim()) { if (typeof text !== 'string' || !text.trim() || !('speechSynthesis' in window)) return
return
}
if (!('speechSynthesis' in window)) {
console.warn('[Display] speechSynthesis not supported')
return
}
const utterance = new SpeechSynthesisUtterance(text.trim()) const utterance = new SpeechSynthesisUtterance(text.trim())
const voices = window.speechSynthesis.getVoices() const voices = window.speechSynthesis.getVoices()
const preferredVoice = voices.find((v) => v.name === 'Google italiano') utterance.voice = voices.find(v => v.name === 'Google italiano')
|| voices.find((v) => v.lang && v.lang.toLowerCase().startsWith('it')) || voices.find(v => v.lang?.toLowerCase().startsWith('it'))
if (preferredVoice) { || null
utterance.voice = preferredVoice
}
utterance.lang = 'it-IT' utterance.lang = 'it-IT'
window.speechSynthesis.cancel() window.speechSynthesis.cancel()
window.speechSynthesis.speak(utterance) window.speechSynthesis.speak(utterance)
} },
} },
} }
</script> </script>
+44 -37
View File
@@ -1,3 +1,21 @@
export function punteggio(striscia) {
let home = 0, guest = 0
for (const c of striscia.at(-1).ris) c === 'h' ? home++ : guest++
return { home, guest }
}
export function servizio(striscia) {
const set = striscia.at(-1)
return set.ris.length === 0 ? set.serv === 'h' : set.ris.at(-1) === 'h'
}
export function setVinti(striscia) {
return {
home: striscia.filter(s => s.vinc === 'h').length,
guest: striscia.filter(s => s.vinc === 'g').length,
}
}
export function createInitialState() { export function createInitialState() {
return { return {
order: true, order: true,
@@ -5,10 +23,7 @@ export function createInitialState() {
visuStriscia: true, visuStriscia: true,
modalitaPartita: "3/5", modalitaPartita: "3/5",
sp: { sp: {
striscia: [{ serv: 'h', ris: '' }], striscia: [{ serv: 'h', ris: '', vinc: null }],
servHome: true,
punt: { home: 0, guest: 0 },
set: { home: 0, guest: 0 },
nomi: { home: "Antoniana", guest: "Guest" }, nomi: { home: "Antoniana", guest: "Guest" },
form: { form: {
home: ["1", "2", "3", "4", "5", "6"], home: ["1", "2", "3", "4", "5", "6"],
@@ -19,12 +34,9 @@ export function createInitialState() {
} }
export function checkVittoria(state) { export function checkVittoria(state) {
const puntHome = state.sp.punt.home const { home: puntHome, guest: puntGuest } = punteggio(state.sp.striscia)
const puntGuest = state.sp.punt.guest const sv = setVinti(state.sp.striscia)
const setHome = state.sp.set.home const totSet = sv.home + sv.guest
const setGuest = state.sp.set.guest
const totSet = setHome + setGuest
const isSetDecisivo = state.modalitaPartita === "2/3" ? totSet >= 2 : totSet >= 4 const isSetDecisivo = state.modalitaPartita === "2/3" ? totSet >= 2 : totSet >= 4
const punteggioVittoria = isSetDecisivo ? 15 : 25 const punteggioVittoria = isSetDecisivo ? 15 : 25
@@ -35,7 +47,8 @@ export function checkVittoria(state) {
export function checkVittoriaPartita(state) { export function checkVittoriaPartita(state) {
const setsToWin = state.modalitaPartita === "2/3" ? 2 : 3 const setsToWin = state.modalitaPartita === "2/3" ? 2 : 3
return state.sp.set.home >= setsToWin || state.sp.set.guest >= setsToWin const sv = setVinti(state.sp.striscia)
return sv.home >= setsToWin || sv.guest >= setsToWin
} }
export function applyAction(state, action) { export function applyAction(state, action) {
@@ -46,15 +59,13 @@ export function applyAction(state, action) {
const team = action.team const team = action.team
if (checkVittoria(s)) break if (checkVittoria(s)) break
const cambioPalla = (team === "home") !== s.sp.servHome const servHome = servizio(s.sp.striscia)
s.sp.punt[team]++ const cambioPalla = (team === "home") !== servHome
s.sp.striscia.at(-1).ris += team === 'home' ? 'h' : 'g' s.sp.striscia.at(-1).ris += team === 'home' ? 'h' : 'g'
if (cambioPalla) { if (cambioPalla) {
s.sp.form[team].push(s.sp.form[team].shift()) s.sp.form[team].push(s.sp.form[team].shift())
} }
s.sp.servHome = team === "home"
break break
} }
@@ -72,10 +83,6 @@ export function applyAction(state, action) {
currentSet.ris = currentSet.ris.slice(0, -1) currentSet.ris = currentSet.ris.slice(0, -1)
const lastScorer = lastScorerShort === 'h' ? 'home' : 'guest' const lastScorer = lastScorerShort === 'h' ? 'home' : 'guest'
const prevServer = prevServerShort === 'h' ? 'home' : 'guest'
s.sp.punt[lastScorer]--
s.sp.servHome = prevServerShort === 'h'
if (wasCambioPalla) { if (wasCambioPalla) {
s.sp.form[lastScorer].unshift(s.sp.form[lastScorer].pop()) s.sp.form[lastScorer].unshift(s.sp.form[lastScorer].pop())
@@ -85,10 +92,17 @@ export function applyAction(state, action) {
case "incSet": { case "incSet": {
const team = action.team const team = action.team
if (s.sp.set[team] === 2) { const sv = setVinti(s.sp.striscia)
s.sp.set[team] = 0 const count = sv[team]
const teamShort = team === 'home' ? 'h' : 'g'
if (count >= 2) {
// cicla a 0: rimuove le voci fantasma per questo team
s.sp.striscia = s.sp.striscia.filter(
entry => !(entry._phantom && entry.vinc === teamShort)
)
} else { } else {
s.sp.set[team]++ // inserisce una voce fantasma prima dell'ultimo set (quello in corso)
s.sp.striscia.splice(-1, 0, { serv: teamShort, ris: '', vinc: teamShort, _phantom: true })
} }
break break
} }
@@ -97,13 +111,9 @@ export function applyAction(state, action) {
const team = action.team const team = action.team
if (team !== 'home' && team !== 'guest') break if (team !== 'home' && team !== 'guest') break
if (checkVittoriaPartita(s)) break if (checkVittoriaPartita(s)) break
const setsToWin = s.modalitaPartita === "2/3" ? 2 : 3 s.sp.striscia.at(-1).vinc = team === 'home' ? 'h' : 'g'
s.sp.set[team]++ if (checkVittoriaPartita(s)) break
if (s.sp.set[team] >= setsToWin) break s.sp.striscia.push({ serv: team === 'home' ? 'h' : 'g', ris: '', vinc: null })
s.sp.punt.home = 0
s.sp.punt.guest = 0
s.sp.servHome = team === 'home'
s.sp.striscia.push({ serv: team === 'home' ? 'h' : 'g', ris: '' })
s.sp.form = { s.sp.form = {
home: ["1", "2", "3", "4", "5", "6"], home: ["1", "2", "3", "4", "5", "6"],
guest: ["1", "2", "3", "4", "5", "6"], guest: ["1", "2", "3", "4", "5", "6"],
@@ -112,24 +122,21 @@ export function applyAction(state, action) {
} }
case "cambiaPalla": { case "cambiaPalla": {
if (s.sp.punt.home === 0 && s.sp.punt.guest === 0) { const currentSet = s.sp.striscia.at(-1)
s.sp.servHome = !s.sp.servHome if (currentSet.ris.length === 0) {
s.sp.striscia.at(-1).serv = s.sp.servHome ? 'h' : 'g' currentSet.serv = currentSet.serv === 'h' ? 'g' : 'h'
} }
break break
} }
case "resetta": { case "resetta": {
s.visuForm = false s.visuForm = false
s.sp.punt.home = 0 const servIniziale = s.sp.striscia[0]?.serv ?? 'h'
s.sp.punt.guest = 0 s.sp.striscia = [{ serv: servIniziale, ris: '', vinc: null }]
s.sp.set.home = 0
s.sp.set.guest = 0
s.sp.form = { s.sp.form = {
home: ["1", "2", "3", "4", "5", "6"], home: ["1", "2", "3", "4", "5", "6"],
guest: ["1", "2", "3", "4", "5", "6"], guest: ["1", "2", "3", "4", "5", "6"],
} }
s.sp.striscia = [{ serv: s.sp.servHome ? 'h' : 'g', ris: '' }]
break break
} }
+146
View File
@@ -0,0 +1,146 @@
import { createInitialState, punteggio, servizio, setVinti } from './gameState.js'
export function createWsMixin(role) {
return {
data() {
return {
ws: null,
wsConnected: false,
isConnecting: false,
reconnectTimeout: null,
reconnectAttempts: 0,
maxReconnectDelay: 30000,
state: createInitialState(),
}
},
computed: {
punt() { return punteggio(this.state.sp.striscia) },
servHome() { return servizio(this.state.sp.striscia) },
set() { return setVinti(this.state.sp.striscia) },
},
mounted() {
this.connectWebSocket()
if (import.meta.hot) {
import.meta.hot.on('vite:beforeUpdate', () => {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
})
}
},
beforeUnmount() {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
if (this.ws) {
this.ws.onclose = null
this.ws.onerror = null
this.ws.onmessage = null
this.ws.onopen = null
try {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.close(1000, 'Component unmounting')
} else if (this.ws.readyState === WebSocket.CONNECTING) {
this.ws.close()
}
} catch (err) {
console.error(`[${role}] Error closing WebSocket:`, err)
}
this.ws = null
}
},
methods: {
connectWebSocket() {
if (this.isConnecting) return
if (this.ws) {
this.ws.onclose = null
this.ws.onerror = null
this.ws.onmessage = null
this.ws.onopen = null
try {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.close(1000, 'Reconnecting')
} else if (this.ws.readyState === WebSocket.CONNECTING) {
this.ws.close()
}
} catch (err) {
console.error(`[${role}] Error closing previous WebSocket:`, err)
}
this.ws = null
}
this.isConnecting = true
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
const params = new URLSearchParams(location.search)
const defaultHost = (location.hostname === 'localhost' || location.hostname === '::1')
? `127.0.0.1${location.port ? `:${location.port}` : ''}`
: location.host
const wsUrl = `${protocol}//${params.get('wsHost') || defaultHost}/ws`
try {
this.ws = new WebSocket(wsUrl)
} catch (err) {
console.error(`[${role}] Failed to create WebSocket:`, err)
this.isConnecting = false
this.scheduleReconnect()
return
}
this.ws.onopen = () => {
this.isConnecting = false
this.wsConnected = true
this.reconnectAttempts = 0
try {
this.ws?.send(JSON.stringify({ type: 'register', role }))
} catch (err) {
console.error(`[${role}] Failed to register:`, err)
}
}
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'state') this.state = msg.state
else this.onWsMessage?.(msg)
} catch (e) {
console.error(`[${role}] Error parsing message:`, e)
}
}
this.ws.onclose = (event) => {
this.isConnecting = false
this.wsConnected = false
if (event.code !== 1000 && event.code !== 1001) this.scheduleReconnect()
}
this.ws.onerror = () => {
this.isConnecting = false
this.wsConnected = false
}
},
scheduleReconnect() {
if (this.reconnectTimeout) return
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay)
this.reconnectAttempts++
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null
this.connectWebSocket()
}, delay)
},
sendWs(msg) {
if (!this.wsConnected || this.ws?.readyState !== WebSocket.OPEN) return false
try {
this.ws.send(JSON.stringify(msg))
return true
} catch (err) {
console.error(`[${role}] Failed to send:`, err)
return false
}
},
},
}
}