25 Commits

Author SHA1 Message Date
9598d587c6 chore(dev): aggiorna workflow locale e configurazione Vite
Introduce script di sviluppo concorrenti (frontend + server) con concurrently.

Aggiorna dipendenze lockfile e rimuove dipendenze non più necessarie.

Aggiunge configurazione server/proxy Vite e include plugin WebSocket dedicato.
2026-02-10 09:54:38 +01:00
f44138efd3 chore(cleanup): rimuove l'implementazione HomePage legacy non più utilizzata
Elimina componenti, template e stili della vecchia HomePage.

Riduce codice morto e semplifica la manutenzione del progetto.
2026-02-10 09:54:31 +01:00
082a52dc3e feat(client): migliora robustezza connessioni WebSocket su display e controller
Aggiunge gestione riconnessione con backoff esponenziale e protezione da reconnect multipli.

Migliora cleanup su unmount/HMR per evitare listener e timeout pendenti.

Uniforma gestione errori e stato connessione lato client.

Semplifica etichette pulsanti controller rimuovendo emoji e aggiorna commenti.
2026-02-10 09:54:10 +01:00
f7c4fdc2ef refactor(server): separa la logica WebSocket e centralizza le utility di avvio
Estrae la gestione dei messaggi WebSocket in un modulo dedicato.

Rende server.js più snello e focalizzato su bootstrap HTTP/WS.

Introduce utility per stampa URL di accesso e discovery IP di rete.

Mantiene la logica di stato partita condivisa in gameState.js.
2026-02-10 09:53:46 +01:00
a40fad7194 Separa app in client-server con WebSocket
- Aggiunto server Express + WebSocket (server.js)
- Creata pagina Display (solo visualizzazione punteggio)
- Creata pagina Controller (pannello comandi da mobile)
- Aggiunto Vue Router con rotte / e /controller
- Estratta logica di gioco condivisa in gameState.js
2026-02-10 00:42:48 +01:00
3789f25d0d Feat(config): navigazione tastiera per dialog configurazione
Implementa ordine tabindex personalizzato per inserimento formazioni:
- Focus automatico su primo campo all'apertura (Ctrl+M)
- Sequenza Tab: nomi squadre → zone 1-6 home → zone 1-6 guest
- Navigazione ciclica con Tab/Shift+Tab all'interno del dialog
- Bottoni esclusi dalla navigazione (tabindex="-1")
- Handler dedicato per gestione focus e prevenzione navigazione esterna
2026-02-09 23:49:52 +01:00
d3698a506d Merge pull request 'wip-cambi' (#5) from wip-cambi into master
Reviewed-on: #5
2026-01-29 15:08:10 +01:00
1972fd37a4 Fix(cambi): valida input numerici per i cambi
- Blocca conferma se IN/OUT contengono caratteri non numerici
- Mostra un warning con messaggio dedicato
2026-01-29 13:42:10 +01:00
ea4d8ec523 Aggiorna README.md 2026-01-29 13:26:19 +01:00
f190db2161 Feat(cambi): supporto cambi multipli con UI migliorata
Consente di effettuare fino a 2 cambi simultanei per squadra con una nuova
interfaccia utente più compatta e visuale. Gli input IN sono colorati di
verde, gli OUT di rosso, e una freccia indica la direzione del cambio.

La validazione permette cambi parziali (campi vuoti) ma richiede che ogni
cambio inserito sia completo (sia IN che OUT) e che almeno un cambio sia
presente per confermare
2026-01-29 11:08:25 +01:00
9df74a760f feat(cambi): dialog squadra, scorciatoie dedicate e aggiornamenti README
Dettagli:
- richiesta squadra prima del cambio e tabella a riga singola
- scorciatoie Ctrl+C (home) e Shift+C (guest)
- conferma cambio con validazioni e sostituzione in formazione
- README aggiornato con nuovi shortcut e funzione cambi
2026-01-28 18:27:50 +01:00
44617f2f86 Aggiunge shortcut per cambi e aggiorna README.md 2026-01-28 18:14:51 +01:00
33a1534319 feat(cambi): dialog cambi con tabella IN/OUT, validazioni e aggiornamento formazione
- dialog “CAMBI” con tabella 2x2 e intestazioni IN/OUT
- etichette riga con nomi squadre
- conferma solo con righe complete (almeno un cambio)
- sostituzione OUT→IN in formazione con controlli errori
2026-01-28 18:08:18 +01:00
2e66a6cf2a style(ui): simmetria header in modalità Formazione
- Allinea l’ordine degli elementi a destra (punteggio, servizio, nome)
- Mantiene coerenza visiva tra lato sinistro e destro
2026-01-28 16:15:00 +01:00
c923bdbf64 fix(ui): stabilizza header in formazione e cambio servizio
- Riserva spazio fisso per l’icona del servizio per evitare scatti
- Stabilizza la larghezza del punteggio inline in modalità Formazione
- Migliora la coerenza visiva nelle testate home/guest
2026-01-28 16:09:36 +01:00
139dcc9c5b Fix: blocca incrementi a set concluso senza notifiche
- Rimuove il messaggio "set terminato" mantenendo il blocco incrementi
- Semplifica il controllo: stop su checkVittoria() senza flag persistente
- Evita ripetizioni dovute a shortcut (Ctrl/Shift + ↑)
2026-01-28 16:05:11 +01:00
24dda41b0d Aggiunge selezione modalità partita (2/3 o 3/5)
- UI per scegliere la modalità partita nella Home
- Logica set decisivo adattata al best-of selezionato
- README aggiornato con nuove regole e descrizione feature
2026-01-28 14:57:14 +01:00
4cbb5fb48d Corregge ripristino servizio quando si annulla un punto
Risolve il bug dove l'indicatore del servizio (palla) non veniva
ripristinato correttamente quando si tornava indietro nel punteggio.

Implementa uno storico completo che salva lo stato del servizio prima
di ogni punto, permettendo di ripristinare esattamente la situazione
precedente quando si annulla un punto (incluso servizio e rotazioni)
2026-01-28 14:35:40 +01:00
eae5cbf964 Fissa dimensioni riquadri punteggio per evitare spostamenti 2026-01-28 14:30:17 +01:00
2c6416bfe0 Aggiorna README.md 2026-01-28 13:31:42 +01:00
9a808e566d Merge pull request 'wip-formazione' (#3) from wip-formazione into master
Reviewed-on: #3
2026-01-28 12:00:45 +01:00
6c6ac7fc29 Limita il cambio palla solo a inizio set (0-0)
- Aggiunge computed property isPunteggioZeroZero per verificare lo stato del punteggio
- Crea metodo cambiaPalla() con validazione che blocca il cambio se il punteggio non è 0-0
- Disabilita il pulsante cambio palla quando il punteggio non è 0-0
- Mostra notifica di avviso se si tenta il cambio palla durante il set
- Aggiorna scorciatoia tastiera Ctrl+ArrowLeft per usare la stessa validazione
2026-01-26 13:45:22 +01:00
bbe0862241 Implementa rotazione regolamentare con cambio palla
- La formazione ruota solo quando si conquista il servizio (cambio palla)
- Aggiunge array servizioPrecedente per tracciare i cambi palla
- Fix decPunt per annullare correttamente la rotazione
- Fix resetta per pulire lo stack dei servizi precedenti
2026-01-25 17:57:22 +01:00
26d647dce7 Aggiunge configurazione manuale numeri di maglia
- Aggiunge campi input nel dialog configurazione per modificare manualmente i numeri di maglia dei giocatori
- Disegna campi da pallavolo stilizzati (220x220px) con linea dei 3 metri posizionata a 1/3 dall'alto
- Layout corrisponde alla visualizzazione sul campo (ordine rotazione [3,2,1,4,5,0])
- Proporzioni realistiche: zona anteriore 33%, zona posteriore 67%
- Sfondo marrone chiaro e bordi grigi per migliore leggibilità
2026-01-25 17:46:31 +01:00
a72bc1844e Blocca assegnazione punti al raggiungimento della vittoria
Aggiunge controllo che impedisce di assegnare ulteriori punti quando
viene raggiunta la condizione di vittoria (25 punti con 2 di vantaggio
nei set 1-4, 15 punti con 2 di vantaggio nel set decisivo)
2026-01-24 19:00:07 +01:00
19 changed files with 3317 additions and 841 deletions

257
README.md
View File

@@ -1,8 +1,255 @@
# Vue 3 + Vite # Segnapunti Anto
# nvm use v20.2.0
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more. Applicazione web **Progressive Web App (PWA)** per tracciare i punteggi di partite di pallavolo in tempo reale.
## Recommended IDE Setup ---
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin). ## Panoramica
**Segnapunti Anto** è un'applicazione digitale per il tracciamento dei punteggi durante partite di pallavolo, ottimizzata per l'uso su tablet e smartphone. Sviluppata per il team Antoniana, l'app fornisce un'interfaccia fullscreen touch-friendly con supporto offline e controlli da tastiera.
### Funzionalità Principali
- **Gestione Completa Partite**
- Tracciamento punti in tempo reale per entrambe le squadre
- Conteggio automatico dei set (modalità 2/3 o 3/5)
- Indicatore visivo del servizio
- Blocco incremento punti a set concluso
- Cronologia punti con striscia visiva
- **Formazioni Squadra**
- Visualizzazione interattiva dei 6 giocatori in campo
- Rotazione automatica regolamentare al cambio palla
- Configurazione manuale dei numeri di maglia
- Dialog cambi con uno o due cambi (IN → OUT) e validazioni
- Supporto logica pallavolo ufficiale (25 punti + 2 di vantaggio, tie-break a 15 nel set decisivo)
- **Controlli Multimodali**
- Scorciatoie da tastiera complete (vedi sezione [Shortcuts](#shortcuts))
- Sintesi vocale per annunci punteggio in italiano (Web Speech API)
- **Personalizzazione**
- Configurazione dinamica nomi squadre
- Selettore modalità partita: al meglio di 3 o al meglio di 5
- Toggle layout orizzontale (inverti home/guest)
- Modalità visualizzazione: punteggio semplice o formazioni complete
- Nascondi/mostra controlli e cronologia
---
## Requisiti
### Requisiti di Sistema
#### Per Sviluppo
- **Sistema Operativo**: Linux, macOS, Windows (WSL2 consigliato)
- **Node.js**: v20.2.0 o superiore (LTS consigliato)
- **npm**: v9.0.0 o superiore (incluso con Node.js)
- **RAM**: Minimo 2GB, consigliato 4GB
- **Spazio Disco**: ~500MB per dipendenze e build
#### Per Deployment
- **Server Web**: Qualsiasi server statico (nginx, Apache, Vercel, Netlify)
- **HTTPS**: Obbligatorio per Service Worker e PWA (eccetto localhost)
- **Connessione Internet**: Solo per primo caricamento (poi funziona offline)
### Requisiti Browser (Utente Finale)
| Requisito | Dettaglio | Necessità |
|-----------|-----------|-----------|
| **JavaScript ES6+** | Supporto moduli, arrow functions, async/await | Obbligatorio |
| **Service Worker API** | Per funzionalità offline PWA | Obbligatorio |
| **Fullscreen API** | Per modalità schermo intero | Consigliato |
| **Web Speech API** | Per sintesi vocale punteggi | Opzionale |
| **Local Storage** | Per persistenza configurazioni | Consigliato |
### Browser Testati e Supportati
| Browser | Versione Minima | Supporto | Note |
|---------|-----------------|----------|------|
| Chrome/Chromium | 90+ | ✅ Completo | Consigliato per tutte le features |
| Firefox | 88+ | ✅ Completo | Supporto completo PWA e Speech API |
---
## Installazione e Setup
### Prerequisiti
- **Node.js** v20.2.0 (consigliato)
- **npm** o **yarn**
### Installazione con NVM (consigliato)
```bash
# Installa la versione corretta di Node.js
nvm install v20.2.0
nvm use v20.2.0
# Clona il repository
git clone <repository-url>
cd segnapunti
# Installa le dipendenze
npm install
```
---
## Comandi per Sviluppo
### Dev Server
Avvia il server di sviluppo con hot-reload:
```bash
npm run dev
```
L'applicazione sarà disponibile su [http://localhost:5173](http://localhost:5173)
### Modalità Sviluppo
- Hot Module Replacement (HMR) attivo
- Source maps per debugging
- Vue DevTools supportato
- Errori e warnings in console
---
## Comandi per Build
### Build Produzione
Genera i file ottimizzati per il deployment:
```bash
npm run build
```
**Output:**
- Cartella `/dist` con file statici ottimizzati
- Service Worker generato automaticamente
- PWA manifest configurato
- Assets minificati e con hash per cache busting
- Base path: `/segnap` (modificabile in `vite.config.js`)
### Preview Build
Anteprima locale della build di produzione:
```bash
npm run preview
```
Serve i file dalla cartella `/dist` per testare la build prima del deploy.
---
## Shortcuts
### Controlli Tastiera Squadra Home
| Scorciatoia | Azione |
|-------------|--------|
| `Ctrl + ↑` | Incrementa punti |
| `Ctrl + ↓` | Decrementa punti |
| `Ctrl + →` | Incrementa set |
| `Ctrl + C` | Apri dialog cambi |
### Controlli Tastiera Squadra Guest
| Scorciatoia | Azione |
|-------------|--------|
| `Shift + ↑` | Incrementa punti |
| `Shift + ↓` | Decrementa punti |
| `Shift + →` | Incrementa set |
| `Shift + C` | Apri dialog cambi |
### Comandi Globali
| Scorciatoia | Azione |
|-------------|--------|
| `Ctrl + ←` | Cambio palla (servizio) - **solo a 0-0** |
| `Ctrl + M` | Apri configurazione nomi squadre e formazioni |
| `Ctrl + B` | Toggle visibilità barra pulsanti |
| `Ctrl + F` | Attiva/disattiva fullscreen |
| `Ctrl + S` | Annuncio vocale punteggio corrente |
| `Ctrl + Z` | Switch tra visualizzazione formazioni e punteggio |
---
## Configurazione PWA
L'applicazione è configurata come **Progressive Web App** nel file [vite.config.js](vite.config.js):
```javascript
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: "app_segnap",
short_name: "segnap",
description: "Segnapunti standalone.",
background_color: "#eee",
theme_color: '#ffffff',
display: "fullscreen",
orientation: "landscape",
icons: [
{ src: 'segnap-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: 'segnap-512x512.png', sizes: '512x512', type: 'image/png' }
]
}
})
```
### Caratteristiche PWA
- **Display**: Fullscreen per massimizzare lo spazio visivo
- **Orientamento**: Landscape (orizzontale) ottimizzato per tablet
- **Auto-update**: Service Worker con aggiornamento automatico
- **Offline**: Funzionamento completo senza connessione internet
- **Installabile**: Aggiungibile alla home screen come app nativa
### Installazione PWA
**Android/Desktop (Chrome):**
- Menu → "Installa app" o icona (⊕) nella barra degli indirizzi
**iOS (Safari):**
- Share (□↑) → "Aggiungi a Home"
---
## Logica Regolamentare Pallavolo
### Vittoria Set
- **Set regolari (1-4)**: Primo a 25 punti con almeno 2 di vantaggio
- **Set decisivo**:
- Modalità 2/3: 3° set a 15 punti con almeno 2 di vantaggio
- Modalità 3/5: 5° set a 15 punti con almeno 2 di vantaggio
- **Blocco automatico**: Non consente assegnare punti oltre la vittoria
### Rotazione Formazione
La rotazione avviene **automaticamente** quando:
1. La squadra **conquista il servizio** (cambio palla)
2. Il punteggio è diverso da 0-0
**Limitazione cambio palla manuale:**
- Il cambio manuale del servizio (`Ctrl + ←`) è consentito **solo a 0-0**
- Questa limitazione previene errori nella rotazione delle formazioni
### Formazione in Campo
Visualizzazione a 6 posizioni standard:
```
Rete
┌─────┬─────┬─────┐
│ 4 │ 3 │ 2 │ ← Fila anteriore
├─────┼─────┼─────┤
│ 5 │ 6 │ 1 │ ← Fila posteriore
└─────┴─────┴─────┘
```
La rotazione avviene in senso orario: 1→2→3→4→5→6→1

1340
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,17 +4,24 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "concurrently -n vite,server -c cyan,yellow \"npm run dev:vite\" \"npm run dev:server\"",
"dev:vite": "vite --clearScreen false",
"dev:server": "PORT=5173 node server.js",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"start": "node server.js",
"serve": "vite build && node server.js"
}, },
"dependencies": { "dependencies": {
"nosleep.js": "^0.12.0", "express": "^5.2.1",
"vue": "^3.2.47", "vue": "^3.2.47",
"wave-ui": "^3.3.0" "vue-router": "^4.6.4",
"wave-ui": "^3.3.0",
"ws": "^8.19.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^4.1.0", "@vitejs/plugin-vue": "^4.1.0",
"concurrently": "^9.2.1",
"vite": "^4.3.9", "vite": "^4.3.9",
"vite-plugin-pwa": "^0.16.0" "vite-plugin-pwa": "^0.16.0"
} }

34
server.js Normal file
View File

@@ -0,0 +1,34 @@
import { createServer } from 'http'
import express from 'express'
import { WebSocketServer } from 'ws'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import { setupWebSocketHandler } from './src/websocket-handler.js'
import { printServerInfo } from './src/server-utils.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
// --- Configurazione del server ---
const app = express()
const PORT = process.env.PORT || 3000
// Espone i file generati dalla build di Vite.
app.use(express.static(join(__dirname, 'dist')))
// Fallback per SPA: restituisce `index.html` per tutte le route che non puntano a file statici.
app.get('/{*splat}', (_req, res) => {
res.sendFile(join(__dirname, 'dist', 'index.html'))
})
const server = createServer(app)
// Inizializza il server WebSocket con la logica di gioco.
const wss = new WebSocketServer({ server })
setupWebSocketHandler(wss)
// Avvia il server HTTP.
server.listen(PORT, '0.0.0.0', () => {
printServerInfo(PORT)
})

View File

@@ -1,7 +1,3 @@
<script setup>
import HomePage from './components/HomePage/index.vue'
</script>
<template> <template>
<HomePage /> <router-view />
</template> </template>

View File

@@ -0,0 +1,849 @@
<template>
<section class="controller-page">
<!-- Barra di stato connessione -->
<div class="conn-bar" :class="{ connected: wsConnected }">
<span class="dot"></span>
{{ wsConnected ? 'Connesso' : 'Connessione...' }}
</div>
<!-- Anteprima punteggio -->
<div class="score-preview">
<div class="team-score home-bg" @click="sendAction({ type: 'incPunt', team: 'home' })">
<div class="team-name">{{ state.sp.nomi.home }}</div>
<div class="team-pts">{{ state.sp.punt.home }}</div>
<div class="team-set">SET {{ state.sp.set.home }}</div>
<img v-show="state.sp.servHome" src="/serv.png" class="serv-icon" />
</div>
<div class="team-score guest-bg" @click="sendAction({ type: 'incPunt', team: 'guest' })">
<div class="team-name">{{ state.sp.nomi.guest }}</div>
<div class="team-pts">{{ state.sp.punt.guest }}</div>
<div class="team-set">SET {{ state.sp.set.guest }}</div>
<img v-show="!state.sp.servHome" src="/serv.png" class="serv-icon" />
</div>
</div>
<!-- Riga annulla punto -->
<div class="undo-row">
<button class="btn btn-undo" @click="sendAction({ type: 'decPunt' })">
ANNULLA PUNTO
</button>
</div>
<!-- Pulsanti set -->
<div class="action-row">
<button class="btn btn-set home-bg" @click="sendAction({ type: 'incSet', team: 'home' })">
SET {{ state.sp.nomi.home }}
</button>
<button class="btn btn-set guest-bg" @click="sendAction({ type: 'incSet', team: 'guest' })">
SET {{ state.sp.nomi.guest }}
</button>
</div>
<!-- Controlli principali -->
<div class="controls">
<button class="btn btn-ctrl" @click="sendAction({ type: 'cambiaPalla' })" :disabled="!isPunteggioZeroZero">
Cambio Palla
</button>
<button class="btn btn-ctrl" @click="sendAction({ type: 'toggleFormazione' })">
{{ state.visuForm ? 'Punteggio' : 'Formazioni' }}
</button>
<button class="btn btn-ctrl" @click="sendAction({ type: 'toggleStriscia' })">
Striscia
</button>
<button class="btn btn-ctrl" @click="sendAction({ type: 'toggleOrder' })">
Inverti
</button>
<button class="btn btn-ctrl" @click="speak()">
Voce
</button>
<button class="btn btn-ctrl" @click="openConfig()">
Config
</button>
<button class="btn btn-ctrl" @click="openCambiTeam()">
Cambi
</button>
<button class="btn btn-danger" @click="confirmReset = true">
Reset
</button>
</div>
<!-- Finestra conferma reset -->
<div class="overlay" v-if="confirmReset" @click.self="confirmReset = false">
<div class="dialog">
<div class="dialog-title">Azzero punteggio?</div>
<div class="dialog-buttons">
<button class="btn btn-cancel" @click="confirmReset = false">NO</button>
<button class="btn btn-confirm" @click="doReset()">SI</button>
</div>
</div>
</div>
<!-- Finestra configurazione -->
<div class="overlay" v-if="showConfig" @click.self="showConfig = false">
<div class="dialog dialog-config">
<div class="dialog-title">Configurazione</div>
<div class="form-group">
<label>Nome Home</label>
<input type="text" v-model="configData.nomeHome" class="input-field" />
</div>
<div class="form-group">
<label>Nome Guest</label>
<input type="text" v-model="configData.nomeGuest" class="input-field" />
</div>
<div class="form-group">
<label>Modalità partita</label>
<div class="mode-buttons">
<button :class="['btn', 'btn-mode', configData.modalita === '2/3' ? 'active' : '']"
@click="configData.modalita = '2/3'">2/3</button>
<button :class="['btn', 'btn-mode', configData.modalita === '3/5' ? 'active' : '']"
@click="configData.modalita = '3/5'">3/5</button>
</div>
</div>
<div class="form-group">
<label>Formazione Home</label>
<div class="form-grid">
<div class="form-row">
<input type="text" v-model="configData.formHome[3]" class="input-num" />
<input type="text" v-model="configData.formHome[2]" class="input-num" />
<input type="text" v-model="configData.formHome[1]" class="input-num" />
</div>
<div class="form-line"></div>
<div class="form-row">
<input type="text" v-model="configData.formHome[4]" class="input-num" />
<input type="text" v-model="configData.formHome[5]" class="input-num" />
<input type="text" v-model="configData.formHome[0]" class="input-num" />
</div>
</div>
</div>
<div class="form-group">
<label>Formazione Guest</label>
<div class="form-grid">
<div class="form-row">
<input type="text" v-model="configData.formGuest[3]" class="input-num" />
<input type="text" v-model="configData.formGuest[2]" class="input-num" />
<input type="text" v-model="configData.formGuest[1]" class="input-num" />
</div>
<div class="form-line"></div>
<div class="form-row">
<input type="text" v-model="configData.formGuest[4]" class="input-num" />
<input type="text" v-model="configData.formGuest[5]" class="input-num" />
<input type="text" v-model="configData.formGuest[0]" class="input-num" />
</div>
</div>
</div>
<div class="dialog-buttons">
<button class="btn btn-cancel" @click="showConfig = false">Annulla</button>
<button class="btn btn-confirm" @click="saveConfig()">OK</button>
</div>
</div>
</div>
<!-- Selezione squadra per cambi -->
<div class="overlay" v-if="showCambiTeam" @click.self="showCambiTeam = false">
<div class="dialog">
<div class="dialog-title">Scegli squadra</div>
<div class="dialog-buttons">
<button class="btn btn-set home-bg" @click="openCambi('home')">{{ state.sp.nomi.home }}</button>
<button class="btn btn-set guest-bg" @click="openCambi('guest')">{{ state.sp.nomi.guest }}</button>
</div>
</div>
</div>
<!-- Finestra gestione cambi -->
<div class="overlay" v-if="showCambi" @click.self="closeCambi()">
<div class="dialog">
<div class="dialog-title">{{ state.sp.nomi[cambiTeam] }}: CAMBIO</div>
<div class="cambi-container">
<div class="cambio-row" v-for="(c, i) in cambiData" :key="i">
<input type="text" v-model="c.in" placeholder="IN" class="input-num cambi-in-field" />
<span class="cambio-arrow"></span>
<input type="text" v-model="c.out" placeholder="OUT" class="input-num cambi-out-field" />
</div>
</div>
<div class="dialog-buttons">
<button class="btn btn-cancel" @click="closeCambi()">Annulla</button>
<button class="btn btn-confirm" :disabled="!cambiValid" @click="confermaCambi()">CONFERMA</button>
</div>
</div>
</div>
</section>
</template>
<script>
export default {
name: "ControllerPage",
data() {
return {
ws: null,
wsConnected: false,
isConnecting: false,
reconnectTimeout: null,
reconnectAttempts: 0,
maxReconnectDelay: 30000,
confirmReset: false,
showConfig: false,
showCambiTeam: false,
showCambi: false,
cambiTeam: "home",
cambiData: [
{ in: "", out: "" },
{ in: "", out: "" },
],
configData: {
nomeHome: "",
nomeGuest: "",
modalita: "3/5",
formHome: ["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: { home: [0], guest: [0] },
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"],
},
storicoServizio: [],
},
},
}
},
computed: {
isPunteggioZeroZero() {
return this.state.sp.punt.home === 0 && this.state.sp.punt.guest === 0
},
cambiValid() {
let hasComplete = false
let allValid = true
this.cambiData.forEach((c) => {
const cin = (c.in || "").trim()
const cout = (c.out || "").trim()
if (!cin && !cout) return
if (!cin || !cout) { allValid = false; return }
hasComplete = true
})
return allValid && hasComplete
}
},
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: {
connectWebSocket() {
// Evita connessioni simultanee multiple.
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:'
const wsUrl = `${protocol}//${location.host}`
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) {
if (!this.wsConnected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
console.warn('[Controller] Cannot send action: not connected')
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) {
// Feedback visivo degli errori: attualmente solo log su console.
// In futuro puo essere esteso con notifiche a comparsa (toast).
console.error('[Controller] Error:', message)
},
doReset() {
this.sendAction({ type: 'resetta' })
this.confirmReset = false
},
openConfig() {
this.configData.nomeHome = this.state.sp.nomi.home
this.configData.nomeGuest = this.state.sp.nomi.guest
this.configData.modalita = this.state.modalitaPartita
this.configData.formHome = [...this.state.sp.form.home]
this.configData.formGuest = [...this.state.sp.form.guest]
this.showConfig = true
},
saveConfig() {
this.sendAction({ type: 'setNomi', home: this.configData.nomeHome, guest: this.configData.nomeGuest })
this.sendAction({ type: 'setModalita', modalita: this.configData.modalita })
this.sendAction({ type: 'setFormazione', team: 'home', form: this.configData.formHome })
this.sendAction({ type: 'setFormazione', team: 'guest', form: this.configData.formGuest })
this.showConfig = false
},
openCambiTeam() {
this.showCambiTeam = true
},
openCambi(team) {
this.showCambiTeam = false
this.cambiTeam = team
this.cambiData = [{ in: "", out: "" }, { in: "", out: "" }]
this.showCambi = true
},
closeCambi() {
this.showCambi = false
this.cambiData = [{ in: "", out: "" }, { in: "", out: "" }]
},
confermaCambi() {
if (!this.cambiValid) return
const cambi = this.cambiData
.filter(c => (c.in || "").trim() && (c.out || "").trim())
.map(c => ({ in: c.in.trim(), out: c.out.trim() }))
this.sendAction({ type: 'confermaCambi', team: this.cambiTeam, cambi })
this.closeCambi()
},
speak() {
const msg = new SpeechSynthesisUtterance()
if (this.state.sp.punt.home + this.state.sp.punt.guest === 0) {
msg.text = "zero a zero"
} else if (this.state.sp.punt.home === this.state.sp.punt.guest) {
msg.text = this.state.sp.punt.home + " pari"
} else {
if (this.state.sp.servHome) {
msg.text = this.state.sp.punt.home + " a " + this.state.sp.punt.guest
} else {
msg.text = this.state.sp.punt.guest + " a " + this.state.sp.punt.home
}
}
const voices = window.speechSynthesis.getVoices()
msg.voice = voices.find(v => v.name === 'Google italiano')
window.speechSynthesis.speak(msg)
}
}
}
</script>
<style scoped>
.controller-page {
min-height: 100vh;
background: #111;
color: #fff;
padding: 8px;
padding-top: 36px;
font-family: 'Inter', system-ui, sans-serif;
-webkit-user-select: none;
user-select: none;
}
/* Barra stato connessione */
.conn-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
background: #c62828;
color: white;
z-index: 200;
transition: background 0.3s;
}
.conn-bar.connected {
background: #2e7d32;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: white;
}
/* Anteprima punteggio */
.score-preview {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.team-score {
flex: 1;
border-radius: 16px;
padding: 16px 12px;
text-align: center;
cursor: pointer;
position: relative;
transition: transform 0.1s;
min-height: 120px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.team-score:active {
transform: scale(0.97);
}
.home-bg {
background: linear-gradient(145deg, #1a1a1a, #333);
border: 2px solid #fdd835;
color: #fdd835;
}
.guest-bg {
background: linear-gradient(145deg, #0d47a1, #1565c0);
border: 2px solid #64b5f6;
color: #fff;
}
.team-name {
font-size: 14px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 4px;
}
.team-pts {
font-size: 56px;
font-weight: 900;
line-height: 1;
}
.team-set {
font-size: 13px;
font-weight: 600;
opacity: 0.75;
margin-top: 4px;
}
.serv-icon {
position: absolute;
top: 8px;
right: 8px;
width: 20px;
height: 20px;
}
/* Riga annulla punto */
.undo-row {
margin-bottom: 8px;
}
.btn-undo {
width: 100%;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.2);
color: #ffab91;
padding: 10px;
font-size: 14px;
font-weight: 700;
border-radius: 12px;
cursor: pointer;
}
.btn-undo:active {
background: rgba(255,100,50,0.2);
}
/* Pulsanti set */
.action-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.btn-set {
flex: 1;
padding: 12px;
font-size: 13px;
font-weight: 700;
border-radius: 12px;
cursor: pointer;
border: none;
text-transform: uppercase;
}
.btn-set:active {
transform: scale(0.97);
}
/* Griglia controlli */
.controls {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.btn {
border: none;
font-family: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.btn-ctrl {
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
color: #e0e0e0;
padding: 14px 8px;
font-size: 14px;
font-weight: 600;
border-radius: 12px;
transition: background 0.15s;
}
.btn-ctrl:active {
background: rgba(255,255,255,0.18);
}
.btn-ctrl:disabled {
opacity: 0.35;
}
.btn-danger {
background: rgba(198, 40, 40, 0.25);
border: 1px solid rgba(239, 83, 80, 0.4);
color: #ef5350;
padding: 14px 8px;
font-size: 14px;
font-weight: 600;
border-radius: 12px;
}
.btn-danger:active {
background: rgba(198, 40, 40, 0.45);
}
/* Overlay e finestre modali */
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 300;
padding: 16px;
}
.dialog {
background: #1e1e1e;
border-radius: 20px;
padding: 24px;
width: 100%;
max-width: 400px;
border: 1px solid rgba(255,255,255,0.12);
}
.dialog-config {
max-height: 85vh;
overflow-y: auto;
}
.dialog-title {
font-size: 18px;
font-weight: 800;
text-align: center;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255,255,255,0.12);
}
.dialog-buttons {
display: flex;
gap: 12px;
margin-top: 20px;
}
.btn-cancel {
flex: 1;
background: rgba(255,255,255,0.08);
color: #aaa;
padding: 12px;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
}
.btn-confirm {
flex: 1;
background: #2e7d32;
color: white;
padding: 12px;
border-radius: 12px;
font-size: 15px;
font-weight: 700;
}
.btn-confirm:disabled {
opacity: 0.4;
}
/* Gruppi form */
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 600;
color: #aaa;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.input-field {
width: 100%;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.2);
color: white;
padding: 10px 14px;
border-radius: 10px;
font-size: 16px;
box-sizing: border-box;
}
.input-field:focus {
outline: none;
border-color: #64b5f6;
}
.input-num {
width: 52px;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.2);
color: white;
padding: 8px;
border-radius: 8px;
font-size: 18px;
text-align: center;
box-sizing: border-box;
}
.input-num:focus {
outline: none;
border-color: #64b5f6;
}
/* Griglia formazione */
.form-grid {
background: rgba(205, 133, 63, 0.15);
border: 2px solid rgba(255,255,255,0.15);
border-radius: 12px;
padding: 12px;
}
.form-row {
display: flex;
justify-content: center;
gap: 10px;
padding: 8px 0;
}
.form-line {
border-top: 1px dashed rgba(255,255,255,0.3);
margin: 4px 0;
}
/* Pulsanti modalita */
.mode-buttons {
display: flex;
gap: 8px;
}
.btn-mode {
flex: 1;
padding: 10px;
background: rgba(255,255,255,0.08);
color: #aaa;
border-radius: 10px;
font-size: 16px;
font-weight: 700;
transition: all 0.15s;
}
.btn-mode.active {
background: #2e7d32;
color: white;
}
/* Sezione cambi */
.cambi-container {
display: flex;
flex-direction: column;
gap: 14px;
padding: 8px 0;
}
.cambio-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.cambio-arrow {
font-size: 20px;
font-weight: 700;
color: #aaa;
}
.cambi-in-field {
background: rgba(120, 200, 120, 0.2) !important;
border-color: rgba(120, 200, 120, 0.4) !important;
}
.cambi-out-field {
background: rgba(200, 120, 120, 0.2) !important;
border-color: rgba(200, 120, 120, 0.4) !important;
}
</style>

View File

@@ -0,0 +1,384 @@
<template>
<section class="display-page">
<div class="campo">
<span v-if="state.order">
<!-- Ordine visualizzazione: home / guest -->
<div class="hea home">
<span :style="{ 'float': 'left' }">
{{ state.sp.nomi.home }}
<span class="serv-slot">
<img v-show="state.sp.servHome" src="/serv.png" width="25" />
</span>
<span v-if="state.visuForm" class="score-inline">{{ state.sp.punt.home }}</span>
</span>
<span class="mr3" :style="{ 'float': 'right' }">set {{ state.sp.set.home }}</span>
</div>
<div class="hea guest">
<span :style="{ 'float': 'right' }">
<span v-if="state.visuForm" class="score-inline">{{ state.sp.punt.guest }}</span>
<span class="serv-slot">
<img v-show="!state.sp.servHome" src="/serv.png" width="25" />
</span>
{{ state.sp.nomi.guest }}
</span>
<span class="ml3" :style="{ 'float': 'left' }">set {{ state.sp.set.guest }}</span>
</div>
<span v-if="state.visuForm">
<div class="col form home">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]" :key="'hf'+x">
{{ state.sp.form.home[x] }}
</div>
</div>
<div class="col form guest">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]" :key="'gf'+x">
{{ state.sp.form.guest[x] }}
</div>
</div>
</span>
<span v-else>
<div class="punteggio-container">
<div class="col punt home">{{ state.sp.punt.home }}</div>
<div class="col punt guest">{{ state.sp.punt.guest }}</div>
</div>
</span>
</span>
<span v-else>
<!-- Ordine visualizzazione: guest / home -->
<div class="hea guest">
<span :style="{ 'float': 'left' }">
{{ state.sp.nomi.guest }}
<span class="serv-slot">
<img v-show="!state.sp.servHome" src="/serv.png" width="25" />
</span>
<span v-if="state.visuForm" class="score-inline">{{ state.sp.punt.guest }}</span>
</span>
<span class="mr3" :style="{ 'float': 'right' }">set {{ state.sp.set.guest }}</span>
</div>
<div class="hea home">
<span :style="{ 'float': 'right' }">
<span v-if="state.visuForm" class="score-inline">{{ state.sp.punt.home }}</span>
<span class="serv-slot">
<img v-show="state.sp.servHome" src="/serv.png" width="25" />
</span>
{{ state.sp.nomi.home }}
</span>
<span class="ml3" :style="{ 'float': 'left' }">set {{ state.sp.set.home }}</span>
</div>
<span v-if="state.visuForm">
<div class="col form guest">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]" :key="'gf2'+x">
{{ state.sp.form.guest[x] }}
</div>
</div>
<div class="col form home">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]" :key="'hf2'+x">
{{ state.sp.form.home[x] }}
</div>
</div>
</span>
<span v-else>
<div class="punteggio-container">
<div class="col punt guest">{{ state.sp.punt.guest }}</div>
<div class="col punt home">{{ state.sp.punt.home }}</div>
</div>
</span>
</span>
<div class="striscia" v-if="state.visuStriscia">
<div>
<span class="text-bold mr1">{{ state.sp.nomi.home }}</span>
<div v-for="(h, i) in state.sp.striscia.home" :key="'sh'+i" class="item">
{{ String(h) }}
</div>
</div>
<div class="guest-striscia">
<span class="text-bold mr1">{{ state.sp.nomi.guest }}</span>
<div v-for="(h, i) in state.sp.striscia.guest" :key="'sg'+i" class="item">
{{ String(h) }}
</div>
</div>
</div>
<!-- Indicatore stato connessione -->
<div class="connection-status" :class="{ connected: wsConnected, disconnected: !wsConnected }">
<span class="dot"></span>
{{ wsConnected ? '' : 'Disconnesso' }}
</div>
</div>
</section>
</template>
<script>
export default {
name: "DisplayPage",
data() {
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: { home: [0], guest: [0] },
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"],
},
storicoServizio: [],
},
},
}
},
mounted() {
this.connectWebSocket()
// Attiva la modalita fullscreen su dispositivi mobili.
if (this.isMobile()) {
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
}
},
methods: {
isMobile() {
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:'
const wsUrl = `${protocol}//${location.host}`
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 === '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)
}
}
}
</script>
<style scoped>
.display-page {
width: 100%;
height: 100vh;
background: #000;
overflow: hidden;
}
.connection-status {
position: fixed;
top: 8px;
right: 8px;
font-size: 12px;
padding: 4px 10px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 5px;
z-index: 100;
transition: opacity 0.5s;
}
.connection-status.connected {
opacity: 0;
}
.connection-status.disconnected {
background: rgba(255, 50, 50, 0.8);
color: white;
opacity: 1;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.connected .dot {
background: #4caf50;
}
.disconnected .dot {
background: #f44336;
animation: blink 1s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.guest-striscia {
color: white;
}
.punteggio-container {
width: 100%;
display: flex;
}
.punt {
font-size: 60vh;
flex: 1;
display: flex;
justify-content: center;
align-items: center;
min-height: 50vh;
min-width: 50vw;
max-width: 50vw;
overflow: hidden;
box-sizing: border-box;
}
</style>

View File

@@ -1,220 +0,0 @@
<script>
import NoSleep from "nosleep.js";
export default {
name: "HomePage",
components: {},
data() {
return {
voices: null,
diaNomi: {
show: false,
home: "",
guest: "",
},
visuForm: false,
visuButt: true,
sp: {
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() {
this.voices = window.speechSynthesis.getVoices();
if (this.isMobile()) {
this.speak();
var noSleep = new NoSleep();
noSleep.enable();
document.documentElement.requestFullscreen();
}
this.abilitaTastiSpeciali();
},
methods: {
closeApp() {
var win = window.open("", "_self");
win.close();
},
fullScreen() {
document.documentElement.requestFullscreen();
},
isMobile() {
if (
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
)
) {
return true;
} else {
return false;
}
},
resetta() {
this.$waveui.notify("Punteggio<br />RESETTATO", "success");
this.visuForm = false;
this.sp.punt.home = 0;
this.sp.punt.guest = 0;
this.sp.form = {
home: ["1", "2", "3", "4", "5", "6"],
guest: ["1", "2", "3", "4", "5", "6"],
}
},
incSet(team) {
if (this.sp.set[team] == 2) {
this.sp.set[team] = 0;
} else {
this.sp.set[team]++;
}
},
incPunt(team) {
this.sp.punt[team]++;
this.sp.servHome = (team == "home");
this.sp.form[team].push(this.sp.form[team].shift());
},
decPunt(team) {
// decrementa il punteggio se è > 0.
if (this.sp.punt[team] > 0) {
this.sp.punt[team]--;
this.sp.form[team].unshift(this.sp.form[team].pop());
}
},
speak() {
const msg = new SpeechSynthesisUtterance();
if (this.sp.punt.home + this.sp.punt.guest == 0) {
msg.text = "zero a zero";
} else if (this.sp.punt.home == this.sp.punt.guest) {
msg.text = this.sp.punt.home + " pari";
} else {
if (this.sp.servHome) {
msg.text = this.sp.punt.home + " a " + this.sp.punt.guest;
} else {
msg.text = this.sp.punt.guest + " a " + this.sp.punt.home;
}
}
// msg.volume = 1.0; // speech volume (default: 1.0)
// msg.pitch = 1.0; // speech pitch (default: 1.0)
// msg.rate = 1.0; // speech rate (default: 1.0)
msg.lang = 'it_IT'; // speech language (default: 'en-US')
const voices = window.speechSynthesis.getVoices();
msg.voice = voices.find(voice => voice.name === 'Google italiano'); // voice URI (default: platform-dependent)
// msg.onboundary = function (event) {
// console.log('Speech reached a boundary:', event.name);
// };
// msg.onpause = function (event) {
// console.log('Speech paused:', event.utterance.text.substring(event.charIndex));
// };
window.speechSynthesis.speak(msg);
},
apriDialogConfig() {
this.disabilitaTastiSpeciali();
this.diaNomi.show = true;
},
disabilitaTastiSpeciali() {
window.removeEventListener("keydown", this.funzioneTastiSpeciali);
},
abilitaTastiSpeciali() {
window.addEventListener("keydown", this.funzioneTastiSpeciali);
},
funzioneTastiSpeciali(e) {
e.preventDefault();
if (e.ctrlKey && e.key == "m") {
this.diaNomi.show = true
} else if (e.ctrlKey && e.key == "b") {
this.visuButt = !this.visuButt
} else if (e.ctrlKey && e.key == "f") {
document.documentElement.requestFullscreen();
} else if (e.ctrlKey && e.key == "s") {
this.speak();
} else if (e.ctrlKey && e.key == "z") {
this.visuForm = !this.visuForm
} else if (e.ctrlKey && e.key == "ArrowUp") {
this.incPunt("home")
} else if (e.ctrlKey && e.key == "ArrowDown") {
this.decPunt("home")
} else if (e.ctrlKey && e.key == "ArrowRight") {
this.incSet("home")
} else if (e.shiftKey && e.key == "ArrowUp") {
this.incPunt("guest")
} else if (e.shiftKey && e.key == "ArrowDown") {
this.decPunt("guest")
} else if (e.shiftKey && e.key == "ArrowRight") {
this.incSet("guest")
} else if (e.ctrlKey && e.key == "ArrowLeft") {
this.sp.servHome = !this.sp.servHome
} else { return false }
}
}
};
</script>
<template>
<w-dialog v-model="diaNomi.show" :width="500" @close="abilitaTastiSpeciali()">
<w-input v-model="sp.nomi.home" type="text" class="pa3">Home</w-input>
<w-input v-model="sp.nomi.guest" type="text" class="pa3">Guest</w-input>
<w-button bg-color="success" @click="diaNomi.show = false">
Ok
</w-button>
</w-dialog>
<div class="campo">
<div class="hea home">
<span @click="decPunt('home')" :style="{ 'float': 'left' }">
{{ sp.nomi.home }} <img v-if="sp.servHome" src="/serv.png" width="25" />
<span v-if="visuForm">{{ sp.punt.home }}</span>
</span>
<span @click="incSet('home')" class="mr3" :style="{ 'float': 'right' }">set {{ sp.set.home }}</span>
</div>
<div class="hea guest">
<span @click="decPunt('guest')" :style="{ 'float': 'right' }">
<img v-if="!sp.servHome" src="/serv.png" width="25" /> {{ sp.nomi.guest }}
<span v-if="visuForm">{{ sp.punt.guest }}</span>
</span>
<span @click="incSet('guest')" class="ml3" :style="{ 'float': 'left' }">set {{ sp.set.guest }}</span>
</div>
<span v-if="visuForm">
<div class="col form home" @click="incPunt('home')">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]">
{{ sp.form.home[x] }}
</div>
</div>
<div class="col form guest" @click="incPunt('guest')">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]">
{{ sp.form.guest[x] }}
</div>
</div>
</span>
<span v-else>
<div class="col punt home" @click="incPunt('home')">{{ sp.punt.home }}</div>
<div class="col punt guest" @click="incPunt('guest')">{{ sp.punt.guest }}</div>
</span>
<div class="bot" v-if="visuButt">
<w-flex justify-space-between class="pa2">
<w-confirm right align-bottom v-if="isMobile()" question="CHIUDO ?" cancel="NO" confirm="SI" @confirm="closeApp">
<img src="/exit.png" width="25" />
</w-confirm>
<w-button @click="apriDialogConfig()">
<img src="/gear.png" width="25" />
</w-button>
<w-button @click="sp.servHome = !sp.servHome">
<img src="/serv.png" width="25" />
</w-button>
<w-confirm top left question="Azzero punteggio ?" cancel="NO" confirm="SI" @confirm="resetta">
RESET
</w-confirm>
<w-button @click="visuForm = !visuForm">
<span v-if="visuForm">PUNTEGGIO</span>
<span v-if="!visuForm">FORMAZIONI</span>
</w-button>
<w-button @click="speak">
<img src="/speaker.png" width="25" />
</w-button>
</w-flex>
</div>
</div>
</template>

View File

@@ -1,129 +0,0 @@
<section class="homepage">
<w-dialog v-model="diaNomi.show" :width="500" @close="abilitaTastiSpeciali()">
<w-input v-model="sp.nomi.home" type="text" class="pa3">Home</w-input>
<w-input v-model="sp.nomi.guest" type="text" class="pa3">Guest</w-input>
<w-button @click="order = !order">Inverti ordine</w-button>
<w-button bg-color="success" @click="diaNomi.show = false">
Ok
</w-button>
</w-dialog>
<div class="campo">
<span v-if="order">
<!-- home guest -->
<div class="hea home">
<span @click="decPunt('home')" :style="{ 'float': 'left' }">
{{ sp.nomi.home }} <img v-if="sp.servHome" src="/serv.png" width="25" />
<span v-if="visuForm">{{ sp.punt.home }}</span>
</span>
<span @click="incSet('home')" class="mr3" :style="{ 'float': 'right' }">set {{ sp.set.home }}</span>
</div>
<div class="hea guest">
<span @click="decPunt('guest')" :style="{ 'float': 'right' }">
<img v-if="!sp.servHome" src="/serv.png" width="25" /> {{ sp.nomi.guest }}
<span v-if="visuForm">{{ sp.punt.guest }}</span>
</span>
<span @click="incSet('guest')" class="ml3" :style="{ 'float': 'left' }">set {{ sp.set.guest }}</span>
</div>
<span v-if="visuForm">
<div class="col form home" @click="incPunt('home')">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]">
{{ sp.form.home[x] }}
</div>
</div>
<div class="col form guest" @click="incPunt('guest')">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]">
{{ sp.form.guest[x] }}
</div>
</div>
</span>
<span v-else>
<div class="col punt home" @click="incPunt('home')">{{ sp.punt.home }}</div>
<div class="col punt guest" @click="incPunt('guest')">{{ sp.punt.guest }}</div>
</span>
</span>
<span v-else>
<!-- guest home -->
<div class="hea guest">
<span @click="decPunt('guest')" :style="{ 'float': 'left' }">
{{ sp.nomi.guest }} <img v-if="!sp.servHome" src="/serv.png" width="25" />
<span v-if="visuForm">{{ sp.punt.guest }}</span>
</span>
<span @click="incSet('guest')" class="mr3" :style="{ 'float': 'right' }">set {{ sp.set.guest }}</span>
</div>
<div class="hea home">
<span @click="decPunt('home')" :style="{ 'float': 'right' }">
<img v-if="sp.servHome" src="/serv.png" width="25" /> {{ sp.nomi.home }}
<span v-if="visuForm">{{ sp.punt.home }}</span>
</span>
<span @click="incSet('home')" class="ml3" :style="{ 'float': 'left' }">set {{ sp.set.home }}</span>
</div>
<span v-if="visuForm">
<div class="col form guest" @click="incPunt('guest')">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]">
{{ sp.form.guest[x] }}
</div>
</div>
<div class="col form home" @click="incPunt('home')">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]">
{{ sp.form.home[x] }}
</div>
</div>
</span>
<span v-else>
<div class="col punt guest" @click="incPunt('guest')">{{ sp.punt.guest }}</div>
<div class="col punt home" @click="incPunt('home')">{{ sp.punt.home }}</div>
</span>
</span>
<div class="striscia" v-if="visuStriscia">
<div>
<span class="text-bold mr1">{{ sp.nomi.home }}</span>
<div v-for="h in sp.striscia.home" class="item">
{{String(h)}}
</div>
</div>
<div class="guest">
<span class="text-bold mr1">{{ sp.nomi.guest }}</span>
<div v-for="h in sp.striscia.guest" class="item">
{{String(h)}}
</div>
</div>
</div>
<div class="bot" v-if="visuButt">
<w-flex justify-space-between class="pa2">
<w-confirm right align-bottom v-if="isMobile()" question="CHIUDO ?" cancel="NO" confirm="SI" @confirm="closeApp">
<img src="/exit.png" width="25" />
</w-confirm>
<w-button @click="apriDialogConfig()">
<img src="/gear.png" width="25" />
</w-button>
<w-button @click="sp.servHome = !sp.servHome">
<img src="/serv.png" width="25" />
</w-button>
<w-confirm top left question="Azzero punteggio ?" cancel="NO" confirm="SI" @confirm="resetta">
RESET
</w-confirm>
<w-button @click="visuForm = !visuForm">
<span v-if="visuForm">PUNTEGGIO</span>
<span v-if="!visuForm">FORMAZIONI</span>
</w-button>
<w-button @click="visuStriscia = !visuStriscia">
STRISCIA
</w-button>
<w-button @click="speak">
<img src="/speaker.png" width="25" />
</w-button>
</w-flex>
</div>
</div>
</section>

View File

@@ -1,178 +0,0 @@
import NoSleep from "nosleep.js";
export default {
name: "HomePage",
components: {},
data() {
return {
order: true,
voices: null,
diaNomi: {
show: false,
home: "",
guest: "",
},
visuForm: false,
visuButt: true,
visuStriscia: true,
sp: {
striscia: { home: [0], guest: [0] },
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() {
this.voices = window.speechSynthesis.getVoices();
if (this.isMobile()) {
this.speak();
var noSleep = new NoSleep();
noSleep.enable();
document.documentElement.requestFullscreen();
}
this.abilitaTastiSpeciali();
},
methods: {
closeApp() {
var win = window.open("", "_self");
win.close();
},
fullScreen() {
document.documentElement.requestFullscreen();
},
isMobile() {
if (
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
)
) {
return true;
} else {
return false;
}
},
resetta() {
this.$waveui.notify("Punteggio<br />RESETTATO", "success");
this.visuForm = false;
this.sp.punt.home = 0;
this.sp.punt.guest = 0;
this.sp.form = {
home: ["1", "2", "3", "4", "5", "6"],
guest: ["1", "2", "3", "4", "5", "6"],
}
this.sp.striscia = { home: [0], guest: [0] }
},
incSet(team) {
if (this.sp.set[team] == 2) {
this.sp.set[team] = 0;
} else {
this.sp.set[team]++;
}
},
incPunt(team) {
this.sp.punt[team]++;
if (team == 'home') {
this.sp.striscia.home.push(this.sp.punt.home)
this.sp.striscia.guest.push(' ')
} else {
this.sp.striscia.guest.push(this.sp.punt.guest)
this.sp.striscia.home.push(' ')
}
this.sp.servHome = (team == "home");
this.sp.form[team].push(this.sp.form[team].shift());
},
decPunt() {
if (this.sp.striscia.home.length > 1) {
var tmpHome = this.sp.striscia.home.pop()
var tmpGuest = this.sp.striscia.guest.pop()
if (tmpHome == ' ') {
this.sp.punt.guest--
this.sp.form.guest.unshift(this.sp.form.guest.pop());
} else {
this.sp.punt.home--
this.sp.form.home.unshift(this.sp.form.home.pop());
}
}
},
// decPunt(team) {
// // decrementa il punteggio se è > 0.
// if (this.sp.punt[team] > 0) {
// this.sp.punt[team]--;
// this.sp.striscia.home.pop()
// this.sp.striscia.guest.pop()
// this.sp.form[team].unshift(this.sp.form[team].pop());
// }
// },
speak() {
const msg = new SpeechSynthesisUtterance();
if (this.sp.punt.home + this.sp.punt.guest == 0) {
msg.text = "zero a zero";
} else if (this.sp.punt.home == this.sp.punt.guest) {
msg.text = this.sp.punt.home + " pari";
} else {
if (this.sp.servHome) {
msg.text = this.sp.punt.home + " a " + this.sp.punt.guest;
} else {
msg.text = this.sp.punt.guest + " a " + this.sp.punt.home;
}
}
// msg.volume = 1.0; // speech volume (default: 1.0)
// msg.pitch = 1.0; // speech pitch (default: 1.0)
// msg.rate = 1.0; // speech rate (default: 1.0)
// msg.lang = 'it_IT'; // speech language (default: 'en-US')
const voices = window.speechSynthesis.getVoices();
msg.voice = voices.find(voice => voice.name === 'Google italiano');
// voice URI (default: platform-dependent)
// msg.onboundary = function (event) {
// console.log('Speech reached a boundary:', event.name);
// };
// msg.onpause = function (event) {
// console.log('Speech paused:', event.utterance.text.substring(event.charIndex));
// };
window.speechSynthesis.speak(msg);
},
apriDialogConfig() {
this.disabilitaTastiSpeciali();
this.diaNomi.show = true;
},
disabilitaTastiSpeciali() {
window.removeEventListener("keydown", this.funzioneTastiSpeciali);
},
abilitaTastiSpeciali() {
window.addEventListener("keydown", this.funzioneTastiSpeciali);
},
funzioneTastiSpeciali(e) {
e.preventDefault();
if (e.ctrlKey && e.key == "m") {
this.diaNomi.show = true
} else if (e.ctrlKey && e.key == "b") {
this.visuButt = !this.visuButt
} else if (e.ctrlKey && e.key == "f") {
document.documentElement.requestFullscreen();
} else if (e.ctrlKey && e.key == "s") {
this.speak();
} else if (e.ctrlKey && e.key == "z") {
this.visuForm = !this.visuForm
} else if (e.ctrlKey && e.key == "ArrowUp") {
this.incPunt("home")
} else if (e.ctrlKey && e.key == "ArrowDown") {
this.decPunt("home")
} else if (e.ctrlKey && e.key == "ArrowRight") {
this.incSet("home")
} else if (e.shiftKey && e.key == "ArrowUp") {
this.incPunt("guest")
} else if (e.shiftKey && e.key == "ArrowDown") {
this.decPunt("guest")
} else if (e.shiftKey && e.key == "ArrowRight") {
this.incSet("guest")
} else if (e.ctrlKey && e.key == "ArrowLeft") {
this.sp.servHome = !this.sp.servHome
} else { return false }
}
}
}

View File

@@ -1,112 +0,0 @@
.homepage {
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
touch-action: pan-x pan-y;
height: 100%
}
body {
overscroll-behavior-y: contain;
margin: 0;
place-items: center;
min-width: 320px;
width: 100%;
min-height: 100vh;
background-color: #000;
}
button {
margin-left: 10px;
margin-right: 10px;
border-radius: 8px;
border: 1px solid #fff;
padding: 0.6em 1.2em;
font-size: 0.8em;
font-weight: 500;
font-family: inherit;
color: #fff;
background-color: #000;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
background-color: #333;
}
button:focus, button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
#app {
margin: 0 auto;
text-align: center;
}
.campo {
user-select: none;
width: 100%;
display: table;
color: #fff;
}
.hea {
float: left;
width: 50%;
font-size: xx-large;
}
.hea span {
/* border: 1px solid #f3fb00; */
padding-left: 10px;
padding-right: 10px;
border-radius: 5px;
}
.tal {
text-align: left;
}
.tar {
text-align: right;
}
.bot {
position: fixed;
left: 0;
bottom: 0;
width: 100%;
margin-top: 10px;
margin-bottom: 1px;
background-color: #111;
}
.col {
margin-left: auto;
margin-right: auto;
text-align: center;
float: left;
width: 50%;
}
.punt {
font-size: 60vh;
}
.form {
font-size: 5vh;
border-top: #fff dashed 25px;
padding-top: 50px;
}
.formtit {
font-size: 5vh;
margin-top: 30px;
margin-bottom: 20px;
}
.formdiv {
font-size: 20vh;
float: left;
width: 32%;
}
.home {
background-color: black;
color: yellow;
}
.guest {
background-color: blue;
color: white
}
.item-stri {
background-color: #fff;
}
}

View File

@@ -1,3 +0,0 @@
<template src="./HomePage.html"></template>
<script src="./HomePage.js"></script>

204
src/gameState.js Normal file
View File

@@ -0,0 +1,204 @@
/**
* Logica di gioco condivisa per il segnapunti.
* Utilizzata sia dal server WebSocket sia dal client per l'anteprima locale.
*/
export function createInitialState() {
return {
order: true,
visuForm: false,
visuStriscia: true,
modalitaPartita: "3/5",
sp: {
striscia: { home: [0], guest: [0] },
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"],
},
storicoServizio: [],
},
}
}
export function checkVittoria(state) {
const puntHome = state.sp.punt.home
const puntGuest = state.sp.punt.guest
const setHome = state.sp.set.home
const setGuest = state.sp.set.guest
const totSet = setHome + setGuest
let isSetDecisivo = false
if (state.modalitaPartita === "2/3") {
isSetDecisivo = totSet >= 2
} else {
isSetDecisivo = totSet >= 4
}
const punteggioVittoria = isSetDecisivo ? 15 : 25
if (puntHome >= punteggioVittoria && puntHome - puntGuest >= 2) {
return true
}
if (puntGuest >= punteggioVittoria && puntGuest - puntHome >= 2) {
return true
}
return false
}
export function applyAction(state, action) {
// Esegue un deep clone per evitare mutazioni indesiderate dello stato lato server.
// Restituisce sempre un nuovo oggetto di stato.
const s = JSON.parse(JSON.stringify(state))
switch (action.type) {
case "incPunt": {
const team = action.team
if (checkVittoria(s)) break
s.sp.storicoServizio.push({
servHome: s.sp.servHome,
cambioPalla: (team === "home" && !s.sp.servHome) || (team === "guest" && s.sp.servHome),
})
s.sp.punt[team]++
if (team === "home") {
s.sp.striscia.home.push(s.sp.punt.home)
s.sp.striscia.guest.push(" ")
} else {
s.sp.striscia.guest.push(s.sp.punt.guest)
s.sp.striscia.home.push(" ")
}
const cambioPalla = (team === "home" && !s.sp.servHome) || (team === "guest" && s.sp.servHome)
if (cambioPalla) {
s.sp.form[team].push(s.sp.form[team].shift())
}
s.sp.servHome = team === "home"
break
}
case "decPunt": {
if (s.sp.striscia.home.length > 1 && s.sp.storicoServizio.length > 0) {
const tmpHome = s.sp.striscia.home.pop()
s.sp.striscia.guest.pop()
const statoServizio = s.sp.storicoServizio.pop()
if (tmpHome === " ") {
s.sp.punt.guest--
if (statoServizio.cambioPalla) {
s.sp.form.guest.unshift(s.sp.form.guest.pop())
}
} else {
s.sp.punt.home--
if (statoServizio.cambioPalla) {
s.sp.form.home.unshift(s.sp.form.home.pop())
}
}
s.sp.servHome = statoServizio.servHome
}
break
}
case "incSet": {
const team = action.team
if (s.sp.set[team] === 2) {
s.sp.set[team] = 0
} else {
s.sp.set[team]++
}
break
}
case "cambiaPalla": {
if (s.sp.punt.home === 0 && s.sp.punt.guest === 0) {
s.sp.servHome = !s.sp.servHome
}
break
}
case "resetta": {
s.visuForm = false
s.sp.punt.home = 0
s.sp.punt.guest = 0
s.sp.form = {
home: ["1", "2", "3", "4", "5", "6"],
guest: ["1", "2", "3", "4", "5", "6"],
}
s.sp.striscia = { home: [0], guest: [0] }
s.sp.storicoServizio = []
break
}
case "toggleFormazione": {
s.visuForm = !s.visuForm
break
}
case "toggleStriscia": {
s.visuStriscia = !s.visuStriscia
break
}
case "toggleOrder": {
s.order = !s.order
break
}
case "setNomi": {
if (action.home !== undefined) s.sp.nomi.home = action.home
if (action.guest !== undefined) s.sp.nomi.guest = action.guest
break
}
case "setModalita": {
s.modalitaPartita = action.modalita
break
}
case "setFormazione": {
if (action.team && action.form) {
s.sp.form[action.team] = [...action.form]
}
break
}
case "confermaCambi": {
const team = action.team
const cambi = action.cambi || []
const form = s.sp.form[team].map((val) => String(val).trim())
const formAggiornata = [...form]
let valid = true
for (const cambio of cambi) {
const cin = (cambio.in || "").trim()
const cout = (cambio.out || "").trim()
if (!cin || !cout) continue
if (!/^\d+$/.test(cin) || !/^\d+$/.test(cout)) { valid = false; break }
if (cin === cout) { valid = false; break }
if (formAggiornata.includes(cin)) { valid = false; break }
if (!formAggiornata.includes(cout)) { valid = false; break }
const idx = formAggiornata.findIndex((val) => String(val).trim() === cout)
if (idx !== -1) {
formAggiornata.splice(idx, 1, cin)
}
}
if (valid) {
s.sp.form[team] = formAggiornata
}
break
}
default:
break
}
return s
}

View File

@@ -1,10 +1,21 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import './style.css' import './style.css'
import App from './App.vue' import App from './App.vue'
import WaveUI from 'wave-ui' import WaveUI from 'wave-ui'
import 'wave-ui/dist/wave-ui.css' import 'wave-ui/dist/wave-ui.css'
import DisplayPage from './components/DisplayPage.vue'
import ControllerPage from './components/ControllerPage.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: DisplayPage },
{ path: '/controller', component: ControllerPage },
],
})
const app = createApp(App) const app = createApp(App)
app.use(router)
app.use(WaveUI) app.use(WaveUI)
app.mount('#app') app.mount('#app')

45
src/server-utils.js Normal file
View File

@@ -0,0 +1,45 @@
import { networkInterfaces } from 'os'
/**
* Restituisce gli indirizzi IP di rete del sistema, escludendo loopback e bridge Docker.
* @returns {string[]} Elenco degli indirizzi IP disponibili.
*/
export function getNetworkIPs() {
const nets = networkInterfaces()
const networkIPs = []
for (const name of Object.keys(nets)) {
for (const net of nets[name]) {
// Esclude loopback (127.0.0.1), indirizzi non IPv4 e bridge Docker (172.17.x.x, 172.18.x.x).
if (net.family === 'IPv4' &&
!net.internal &&
!net.address.startsWith('172.17.') &&
!net.address.startsWith('172.18.')) {
networkIPs.push(net.address)
}
}
}
return networkIPs
}
/**
* Stampa il riepilogo di avvio del server con gli URL di accesso.
* @param {number} port - Porta sulla quale il server e in ascolto.
*/
export function printServerInfo(port = 5173) {
const networkIPs = getNetworkIPs()
console.log(`\nSegnapunti Server`)
console.log(` Display: http://localhost:${port}/`)
console.log(` Controller: http://localhost:${port}/controller`)
if (networkIPs.length > 0) {
console.log(`\n Da dispositivi remoti:`)
networkIPs.forEach(ip => {
console.log(` http://${ip}:${port}/controller`)
})
}
console.log()
}

View File

@@ -53,11 +53,24 @@ button:focus-visible {
font-size: xx-large; font-size: xx-large;
} }
.hea span { .hea span {
/* border: 1px solid #f3fb00; */ /* Bordo di debug: border: 1px solid #f3fb00; */
padding-left: 10px; padding-left: 10px;
padding-right: 10px; padding-right: 10px;
border-radius: 5px; border-radius: 5px;
} }
.score-inline {
display: inline-block;
min-width: 3ch;
text-align: center;
}
.serv-slot {
display: inline-flex;
width: 25px;
height: 25px;
align-items: center;
justify-content: center;
vertical-align: middle;
}
.tal { .tal {
text-align: left; text-align: left;
} }
@@ -80,8 +93,23 @@ button:focus-visible {
float: left; float: left;
width: 50%; width: 50%;
} }
.punteggio-container {
width: 100%;
height: 100%;
display: flex;
}
.punt { .punt {
font-size: 60vh; font-size: 60vh;
flex: 1;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
min-height: 50vh;
min-width: 50vw;
max-width: 50vw;
overflow: hidden;
box-sizing: border-box;
} }
.form { .form {
font-size: 5vh; font-size: 5vh;
@@ -122,3 +150,115 @@ button:focus-visible {
color: blue; color: blue;
border-radius: 5px; border-radius: 5px;
} }
.campo-config {
display: flex;
flex-direction: column;
align-items: center;
}
.campo-pallavolo {
border: 3px solid #999;
background-color: rgba(205, 133, 63, 0.25);
position: relative;
width: 220px;
height: 220px;
display: flex;
flex-direction: column;
padding: 0;
}
.fila-anteriore {
height: 33.33%;
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
}
.fila-posteriore {
height: 66.67%;
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
}
.linea-tre-metri {
border-top: 2px solid #666;
width: 100%;
height: 0;
margin: 0;
}
.cambi-rows {
display: flex;
flex-direction: column;
gap: 12px;
padding: 8px 0;
}
.cambi-dialog {
padding: 8px 6px 2px;
}
.cambi-title {
text-align: center;
font-weight: 800;
letter-spacing: 0.5px;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
}
.cambi-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.cambi-arrow {
font-weight: 700;
font-size: 18px;
line-height: 1;
padding: 6px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
white-space: nowrap;
}
.cambi-input {
min-width: 48px;
max-width: 64px;
}
.cambi-input input,
.cambi-input .w-input__input,
.cambi-input .w-input__field {
border: 2px solid rgba(255, 255, 255, 0.35);
border-radius: 8px;
padding: 6px 10px;
color: #000;
text-align: center;
box-sizing: border-box;
}
.cambi-in input,
.cambi-in .w-input__input,
.cambi-in .w-input__field {
background: rgba(120, 200, 120, 0.4);
}
.cambi-out input,
.cambi-out .w-input__input,
.cambi-out .w-input__field {
background: rgba(200, 120, 120, 0.4);
}
.cambi-input input:focus,
.cambi-input .w-input__input:focus,
.cambi-input .w-input__field:focus {
border-color: rgba(255, 255, 255, 0.7);
outline: none;
}

171
src/websocket-handler.js Normal file
View File

@@ -0,0 +1,171 @@
import { createInitialState, applyAction } from './gameState.js'
/**
* Crea e configura il server WebSocket per la gestione dello stato di gioco.
* @param {WebSocketServer} wss - Istanza del server WebSocket.
* @returns {Object} Oggetto con metodi di gestione dello stato.
*/
export function setupWebSocketHandler(wss) {
// Stato globale della partita.
let gameState = createInitialState()
// Mappa dei ruoli associati ai client connessi.
const clients = new Map() // ws -> { role: 'display' | 'controller' }
/**
* Gestisce i messaggi in arrivo dal client
*/
function handleMessage(ws, data) {
try {
// Converte il payload in stringa in modo sicuro, anche se arriva come Buffer.
const dataStr = typeof data === 'string' ? data : data.toString('utf8')
const msg = JSON.parse(dataStr)
if (msg.type === 'register') {
return handleRegister(ws, msg)
}
if (msg.type === 'action') {
return handleAction(ws, msg)
}
} catch (err) {
console.error('Error processing message:', err, 'data:', data)
// Invia l'errore solo se la connessione e ancora aperta.
if (ws.readyState === 1) { // Stato WebSocket.OPEN
try {
sendError(ws, 'Invalid message format')
} catch (sendErr) {
console.error('Error sending error message:', sendErr)
}
}
}
}
/**
* Gestisce la registrazione di un client (display o controller)
*/
function handleRegister(ws, msg) {
const role = msg.role || 'display'
// Valida il ruolo dichiarato dal client.
if (!['display', 'controller'].includes(role)) {
sendError(ws, 'Invalid role')
return
}
clients.set(ws, { role })
console.log(`[WebSocket] Client registered as: ${role} (total clients: ${clients.size})`)
// Invia subito lo stato corrente, se la connessione e aperta.
if (ws.readyState === 1) { // Stato WebSocket.OPEN
try {
ws.send(JSON.stringify({ type: 'state', state: gameState }))
} catch (err) {
console.error('Error sending initial state:', err)
}
}
}
/**
* Gestisce un'azione di gioco dal controller
*/
function handleAction(ws, msg) {
// Solo i client controller possono inviare azioni.
const client = clients.get(ws)
if (!client || client.role !== 'controller') {
sendError(ws, 'Only controllers can send actions')
return
}
// Verifica il formato dell'azione ricevuta.
if (!msg.action || !msg.action.type) {
sendError(ws, 'Invalid action format')
return
}
// Applica l'azione allo stato della partita.
const previousState = gameState
try {
gameState = applyAction(gameState, msg.action)
} catch (err) {
console.error('Error applying action:', err)
sendError(ws, 'Failed to apply action')
gameState = previousState
return
}
// Propaga il nuovo stato a tutti i client connessi.
broadcastState()
}
/**
* Invia un messaggio di errore al client
*/
function sendError(ws, message) {
if (ws.readyState === 1) { // Stato WebSocket.OPEN
try {
ws.send(JSON.stringify({ type: 'error', message }))
} catch (err) {
console.error('Failed to send error message:', err)
}
}
}
/**
* Invia lo stato corrente a tutti i client connessi.
*/
function broadcastState() {
const stateMsg = JSON.stringify({ type: 'state', state: gameState })
wss.clients.forEach((client) => {
if (client.readyState === 1) { // Stato WebSocket.OPEN
client.send(stateMsg)
}
})
}
/**
* Gestisce la chiusura della connessione
*/
function handleClose(ws) {
const client = clients.get(ws)
const role = client?.role || 'unknown'
console.log(`[WebSocket] Client disconnected (role: ${role})`)
clients.delete(ws)
}
/**
* Gestisce gli errori WebSocket
*/
function handleError(err, ws) {
console.error('WebSocket error:', err)
// In caso di frame non validi, chiude forzatamente la connessione.
if (err.code === 'WS_ERR_INVALID_CLOSE_CODE' || err.code === 'WS_ERR_INVALID_UTF8') {
try {
if (ws && ws.readyState === 1) { // Stato WebSocket.OPEN
ws.terminate() // Chiusura forzata senza handshake di chiusura.
}
} catch (closeErr) {
console.error('Error closing connection:', closeErr)
}
}
}
// Registra gli handler per ogni nuova connessione.
wss.on('connection', (ws) => {
// Imposta il tipo binario per ridurre i problemi di codifica.
ws.binaryType = 'arraybuffer'
ws.on('message', (data) => handleMessage(ws, data))
ws.on('close', () => handleClose(ws))
ws.on('error', (err) => handleError(err, ws))
})
// Espone un'API pubblica per controllo esterno, se necessario.
return {
getState: () => gameState,
setState: (newState) => { gameState = newState },
broadcastState,
getClients: () => clients,
}
}

39
vite-plugin-websocket.js Normal file
View File

@@ -0,0 +1,39 @@
import { WebSocketServer } from 'ws'
import { setupWebSocketHandler } from './src/websocket-handler.js'
import { printServerInfo } from './src/server-utils.js'
/**
* Plugin Vite che integra un server WebSocket per la gestione dello stato di gioco.
* @returns {import('vite').Plugin}
*/
export default function websocketPlugin() {
return {
name: 'vite-plugin-websocket',
configureServer(server) {
// Inizializza un server WebSocket collegato al server HTTP di Vite.
// Importante: usa `noServer: true` per evitare conflitti con l'HMR di Vite.
const wss = new WebSocketServer({ noServer: true })
// Registra i gestori WebSocket con la logica di gioco.
setupWebSocketHandler(wss)
// Intercetta le richieste di upgrade WebSocket.
server.httpServer.on('upgrade', (request, socket, head) => {
// Se la richiesta non riguarda l'HMR di Vite (es. /@vite/client),
// la gestisce il server WebSocket dell'applicazione.
const pathname = new URL(request.url, `http://${request.headers.host}`).pathname
if (!pathname.startsWith('/@vite') && !pathname.startsWith('/@fs')) {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request)
})
}
})
// Stampa le informazioni di accesso dopo l'avvio del server Vite.
server.httpServer.once('listening', () => {
setTimeout(() => printServerInfo(5173), 100)
})
}
}
}

View File

@@ -2,9 +2,9 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
// https://vitejs.dev/config/ // Configurazione principale di Vite
export default defineConfig({ export default defineConfig({
base: process.env.NODE_ENV === 'production' ? '/segnap' : '/', base: '/',
plugins: [ plugins: [
vue(), vue(),
VitePWA({ VitePWA({
@@ -32,4 +32,15 @@ export default defineConfig({
} }
}) })
], ],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/': {
target: 'http://127.0.0.1:5174',
ws: true,
changeOrigin: true,
},
},
},
}) })