Compare commits
25 Commits
apk
...
9598d587c6
| Author | SHA1 | Date | |
|---|---|---|---|
| 9598d587c6 | |||
| f44138efd3 | |||
| 082a52dc3e | |||
| f7c4fdc2ef | |||
| a40fad7194 | |||
| 3789f25d0d | |||
| d3698a506d | |||
| 1972fd37a4 | |||
| ea4d8ec523 | |||
| f190db2161 | |||
| 9df74a760f | |||
| 44617f2f86 | |||
| 33a1534319 | |||
| 2e66a6cf2a | |||
| c923bdbf64 | |||
| 139dcc9c5b | |||
| 24dda41b0d | |||
| 4cbb5fb48d | |||
| eae5cbf964 | |||
| 2c6416bfe0 | |||
| 9a808e566d | |||
| 6c6ac7fc29 | |||
| bbe0862241 | |||
| 26d647dce7 | |||
| a72bc1844e |
14
.gitignore
vendored
14
.gitignore
vendored
@@ -11,8 +11,7 @@ lerna-debug.log*
|
|||||||
currentCommit.txt
|
currentCommit.txt
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist/*
|
dist
|
||||||
!dist/android/
|
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
@@ -26,14 +25,3 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
# Capacitor
|
|
||||||
android/
|
|
||||||
ios/
|
|
||||||
.capacitor/
|
|
||||||
|
|
||||||
# Build output
|
|
||||||
output/
|
|
||||||
*.apk
|
|
||||||
!dist/android/*.apk
|
|
||||||
*.aab
|
|
||||||
83
Dockerfile
83
Dockerfile
@@ -1,83 +0,0 @@
|
|||||||
# Dockerfile per build APK Android con Capacitor
|
|
||||||
FROM ubuntu:22.04
|
|
||||||
|
|
||||||
# Evita prompt interattivi
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
|
||||||
|
|
||||||
# Installa dipendenze base
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
curl \
|
|
||||||
git \
|
|
||||||
unzip \
|
|
||||||
wget \
|
|
||||||
openjdk-21-jdk \
|
|
||||||
build-essential \
|
|
||||||
imagemagick \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Installa Node.js 20.x (LTS)
|
|
||||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
|
||||||
&& apt-get install -y nodejs
|
|
||||||
|
|
||||||
# Installa Gradle 8.5
|
|
||||||
ENV GRADLE_VERSION=8.5
|
|
||||||
RUN wget https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip -P /tmp \
|
|
||||||
&& unzip -d /opt/gradle /tmp/gradle-${GRADLE_VERSION}-bin.zip \
|
|
||||||
&& ln -s /opt/gradle/gradle-${GRADLE_VERSION}/bin/gradle /usr/bin/gradle \
|
|
||||||
&& rm /tmp/gradle-${GRADLE_VERSION}-bin.zip
|
|
||||||
|
|
||||||
# Installa Android SDK
|
|
||||||
ENV ANDROID_SDK_ROOT=/opt/android-sdk
|
|
||||||
ENV ANDROID_HOME=${ANDROID_SDK_ROOT}
|
|
||||||
ENV PATH=${PATH}:${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${ANDROID_SDK_ROOT}/platform-tools
|
|
||||||
|
|
||||||
RUN mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools \
|
|
||||||
&& wget https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip -P /tmp \
|
|
||||||
&& unzip -d ${ANDROID_SDK_ROOT}/cmdline-tools /tmp/commandlinetools-linux-9477386_latest.zip \
|
|
||||||
&& mv ${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools ${ANDROID_SDK_ROOT}/cmdline-tools/latest \
|
|
||||||
&& rm /tmp/commandlinetools-linux-9477386_latest.zip
|
|
||||||
|
|
||||||
# Accetta licenze Android SDK
|
|
||||||
RUN yes | sdkmanager --licenses || true
|
|
||||||
|
|
||||||
# Installa componenti Android necessari
|
|
||||||
RUN sdkmanager "platform-tools" \
|
|
||||||
"platforms;android-34" \
|
|
||||||
"build-tools;34.0.0" \
|
|
||||||
"extras;google;google_play_services" \
|
|
||||||
"extras;android;m2repository" \
|
|
||||||
"extras;google;m2repository"
|
|
||||||
|
|
||||||
# Imposta JAVA_HOME in base all'architettura
|
|
||||||
RUN ARCH=$(dpkg --print-architecture) && \
|
|
||||||
echo "export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-${ARCH}" >> /etc/profile && \
|
|
||||||
ln -sf /usr/lib/jvm/java-21-openjdk-${ARCH} /usr/lib/jvm/default-java
|
|
||||||
|
|
||||||
ENV JAVA_HOME=/usr/lib/jvm/default-java
|
|
||||||
|
|
||||||
# Directory di lavoro
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copia package files per cache layer
|
|
||||||
COPY package*.json ./
|
|
||||||
|
|
||||||
# Installa dipendenze npm
|
|
||||||
RUN npm ci --legacy-peer-deps || npm install --legacy-peer-deps
|
|
||||||
|
|
||||||
# Copia il resto del progetto
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build script
|
|
||||||
CMD ["bash", "-c", "\
|
|
||||||
npm install --legacy-peer-deps && \
|
|
||||||
npm run build && \
|
|
||||||
npx cap add android && \
|
|
||||||
npx cap sync && \
|
|
||||||
sed -i 's/android:configChanges=\"\\([^\"]*\\)\"/android:configChanges=\"\\1\" android:screenOrientation=\"sensorLandscape\"/g' android/app/src/main/AndroidManifest.xml && \
|
|
||||||
bash setup-android-icons.sh && \
|
|
||||||
cd android && ./gradlew assembleDebug --no-daemon && \
|
|
||||||
mkdir -p /app/dist/android && \
|
|
||||||
cp app/build/outputs/apk/debug/app-debug.apk /app/dist/android/segnapunti-debug.apk && \
|
|
||||||
echo '' && \
|
|
||||||
echo 'APK generato: /app/dist/android/segnapunti-debug.apk'\
|
|
||||||
"]
|
|
||||||
566
README.md
566
README.md
@@ -1,437 +1,255 @@
|
|||||||
# Segnapunti - Anto
|
# Segnapunti Anto
|
||||||
|
|
||||||
Un'applicazione web segnapunti per pallavolo con funzionalità di sintesi vocale, modalità formazione e supporto PWA (Progressive Web App).
|
Applicazione web **Progressive Web App (PWA)** per tracciare i punteggi di partite di pallavolo in tempo reale.
|
||||||
|
|
||||||
## Cos'è
|
---
|
||||||
|
|
||||||
Segnapunti è un'applicazione standalone per tenere il punteggio durante le partite di pallavolo. L'applicazione è ottimizzata per dispositivi mobili in modalità landscape (orizzontale) e può essere installata come app nativa grazie al supporto PWA.
|
## Panoramica
|
||||||
|
|
||||||
### Caratteristiche principali
|
**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.
|
||||||
|
|
||||||
- **Segnapunti digitale**: Visualizzazione chiara del punteggio per le due squadre (Home e Guest)
|
### Funzionalità Principali
|
||||||
- **Contatore set**: Traccia i set vinti da ciascuna squadra (fino a 3 set)
|
|
||||||
- **Indicatore battuta**: Mostra quale squadra ha il servizio
|
|
||||||
- **Modalità formazione**: Visualizza la rotazione dei giocatori in campo (posizioni 1-6)
|
|
||||||
- **Sintesi vocale**: Annuncia il punteggio in italiano
|
|
||||||
- **PWA**: Installabile come app standalone con funzionamento offline
|
|
||||||
- **Controllo da tastiera**: Scorciatoie per gestire rapidamente il punteggio
|
|
||||||
- **No Sleep**: Impedisce allo schermo di spegnersi durante l'uso
|
|
||||||
- **Fullscreen automatico**: Su dispositivi mobili si avvia a schermo intero
|
|
||||||
|
|
||||||
## Come funziona
|
- **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
|
||||||
|
|
||||||
### Interfaccia
|
- **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)
|
||||||
|
|
||||||
L'applicazione divide lo schermo in due metà:
|
- **Controlli Multimodali**
|
||||||
|
- Scorciatoie da tastiera complete (vedi sezione [Shortcuts](#shortcuts))
|
||||||
|
- Sintesi vocale per annunci punteggio in italiano (Web Speech API)
|
||||||
|
|
||||||
- **Metà sinistra (gialla su sfondo nero)**: Squadra Home - Antoniana
|
- **Personalizzazione**
|
||||||
- **Metà destra (bianca su sfondo blu)**: Squadra Guest
|
- 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
|
||||||
|
|
||||||
#### Visualizzazioni
|
---
|
||||||
|
|
||||||
1. **Modalità Punteggio** (default):
|
## Requisiti
|
||||||
- Mostra il punteggio corrente in caratteri molto grandi
|
|
||||||
- Cliccando sul punteggio si incrementa di 1
|
|
||||||
- Cliccando sul nome della squadra si decrementa di 1
|
|
||||||
|
|
||||||
2. **Modalità Formazione**:
|
### Requisiti di Sistema
|
||||||
- Mostra le posizioni dei giocatori in campo (1-6)
|
|
||||||
- La formazione ruota automaticamente quando si incrementa il punteggio
|
|
||||||
- L'ordine visualizzato è: [3, 2, 1, 4, 5, 0] (ordine inverso rispetto alla rotazione standard)
|
|
||||||
|
|
||||||
#### Funzionalità
|
#### 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
|
||||||
|
|
||||||
- **Reset**: Azzera punteggio e formazioni, mantiene i nomi delle squadre
|
#### Per Deployment
|
||||||
- **Cambio servizio**: Toggle manuale del servizio tra le squadre
|
- **Server Web**: Qualsiasi server statico (nginx, Apache, Vercel, Netlify)
|
||||||
- **Configurazione nomi**: Dialog per modificare i nomi delle squadre
|
- **HTTPS**: Obbligatorio per Service Worker e PWA (eccetto localhost)
|
||||||
- **Sintesi vocale**:
|
- **Connessione Internet**: Solo per primo caricamento (poi funziona offline)
|
||||||
- Pronuncia il punteggio in italiano
|
|
||||||
- Se 0-0 dice "zero a zero"
|
|
||||||
- Se pari dice "[punteggio] pari"
|
|
||||||
- Altrimenti annuncia prima il punteggio della squadra al servizio
|
|
||||||
- **Incremento set**: Click sul contatore set per aumentarlo (si azzera dopo 2)
|
|
||||||
|
|
||||||
### Controlli da tastiera
|
### Requisiti Browser (Utente Finale)
|
||||||
|
|
||||||
- **Ctrl + M**: Apri dialog configurazione nomi
|
| Requisito | Dettaglio | Necessità |
|
||||||
- **Ctrl + B**: Mostra/nascondi barra pulsanti in basso
|
|-----------|-----------|-----------|
|
||||||
- **Ctrl + F**: Attiva modalità fullscreen
|
| **JavaScript ES6+** | Supporto moduli, arrow functions, async/await | Obbligatorio |
|
||||||
- **Ctrl + S**: Pronuncia il punteggio
|
| **Service Worker API** | Per funzionalità offline PWA | Obbligatorio |
|
||||||
- **Ctrl + Z**: Alterna tra modalità punteggio e formazione
|
| **Fullscreen API** | Per modalità schermo intero | Consigliato |
|
||||||
- **Ctrl + ↑**: Incrementa punteggio Home
|
| **Web Speech API** | Per sintesi vocale punteggi | Opzionale |
|
||||||
- **Ctrl + ↓**: Decrementa punteggio Home
|
| **Local Storage** | Per persistenza configurazioni | Consigliato |
|
||||||
- **Ctrl + →**: Incrementa set Home
|
|
||||||
- **Shift + ↑**: Incrementa punteggio Guest
|
|
||||||
- **Shift + ↓**: Decrementa punteggio Guest
|
|
||||||
- **Shift + →**: Incrementa set Guest
|
|
||||||
- **Ctrl + ←**: Cambia servizio
|
|
||||||
|
|
||||||
### Comportamento su mobile
|
### Browser Testati e Supportati
|
||||||
|
|
||||||
Su dispositivi mobili l'applicazione:
|
| Browser | Versione Minima | Supporto | Note |
|
||||||
1. Attiva automaticamente NoSleep per impedire lo spegnimento dello schermo
|
|---------|-----------------|----------|------|
|
||||||
2. Si avvia in modalità fullscreen
|
| Chrome/Chromium | 90+ | ✅ Completo | Consigliato per tutte le features |
|
||||||
3. Esegue un test di sintesi vocale al caricamento
|
| Firefox | 88+ | ✅ Completo | Supporto completo PWA e Speech API |
|
||||||
4. Mostra il pulsante "Esci" per chiudere l'app
|
|
||||||
|
|
||||||
## Stack tecnologico
|
---
|
||||||
|
|
||||||
### Framework e librerie
|
## Installazione e Setup
|
||||||
|
|
||||||
- **Vue 3** (^3.2.47): Framework JavaScript progressivo per l'interfaccia utente
|
|
||||||
- **Vite** (^4.3.9): Build tool moderno e veloce
|
|
||||||
- **Wave UI** (^3.3.0): Libreria di componenti UI per Vue
|
|
||||||
- **NoSleep.js** (^0.12.0): Previene lo spegnimento automatico dello schermo
|
|
||||||
- **vite-plugin-pwa** (^0.16.0): Plugin per generare la PWA
|
|
||||||
|
|
||||||
### API Web utilizzate
|
|
||||||
|
|
||||||
- **Web Speech API**: Per la sintesi vocale (Text-to-Speech)
|
|
||||||
- **Fullscreen API**: Per la modalità schermo intero
|
|
||||||
- **Service Worker**: Per il funzionamento offline (PWA)
|
|
||||||
|
|
||||||
## Come svilupparlo
|
|
||||||
|
|
||||||
### Prerequisiti
|
### Prerequisiti
|
||||||
|
|
||||||
- **Node.js** (versione 14 o superiore, raccomandata v20.2.0)
|
- **Node.js** v20.2.0 (consigliato)
|
||||||
- **npm** o **yarn**
|
- **npm** o **yarn**
|
||||||
|
|
||||||
### Installazione
|
### Installazione con NVM (consigliato)
|
||||||
|
|
||||||
1. Clona il repository:
|
|
||||||
|
|
||||||
2. Installa le dipendenze:
|
|
||||||
```bash
|
```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
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### Comandi di sviluppo
|
---
|
||||||
|
|
||||||
#### Avviare il server di sviluppo
|
## Comandi per Sviluppo
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
Questo comando avvia Vite in modalità development. L'applicazione sarà disponibile su `http://localhost:5173` (o altra porta se già occupata).
|
|
||||||
|
|
||||||
#### Build per produzione
|
### Dev Server
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
Genera i file ottimizzati per la produzione nella cartella `dist/`. Include:
|
|
||||||
- Minificazione del codice
|
|
||||||
- Tree-shaking per rimuovere codice non utilizzato
|
|
||||||
- Generazione del Service Worker per la PWA
|
|
||||||
- Generazione del manifest per l'installazione
|
|
||||||
|
|
||||||
#### Anteprima build di produzione
|
Avvia il server di sviluppo con hot-reload:
|
||||||
```bash
|
|
||||||
npm run preview
|
|
||||||
```
|
|
||||||
Avvia un server locale per testare la build di produzione.
|
|
||||||
|
|
||||||
La configurazione PWA si trova in [vite.config.js](vite.config.js:10-32):
|
|
||||||
|
|
||||||
- **Display**: fullscreen
|
|
||||||
- **Orientamento**: landscape (orizzontale)
|
|
||||||
- **Colore tema**: bianco
|
|
||||||
- **Base path**: `/segnap` in produzione, `/` in sviluppo
|
|
||||||
|
|
||||||
### Modifiche comuni
|
|
||||||
|
|
||||||
#### Cambiare i colori delle squadre
|
|
||||||
|
|
||||||
Modifica il file [src/style.css](src/style.css:101-108):
|
|
||||||
```css
|
|
||||||
.home {
|
|
||||||
background-color: black;
|
|
||||||
color: yellow;
|
|
||||||
}
|
|
||||||
.guest {
|
|
||||||
background-color: blue;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Modificare i nomi predefiniti
|
|
||||||
|
|
||||||
Nel file [src/components/HomePage.vue](src/components/HomePage.vue:20), modifica:
|
|
||||||
```javascript
|
|
||||||
nomi: { home: "Antoniana", guest: "Guest" }
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Cambiare la voce della sintesi vocale
|
|
||||||
|
|
||||||
Nel file [src/components/HomePage.vue](src/components/HomePage.vue:104), modifica:
|
|
||||||
```javascript
|
|
||||||
msg.voice = voices.find(voice => voice.name === 'Google italiano');
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Modificare l'ordine di visualizzazione delle formazioni
|
|
||||||
|
|
||||||
Nel template del componente [src/components/HomePage.vue](src/components/HomePage.vue:181), l'ordine di visualizzazione delle posizioni è controllato dall'array nell'attributo `v-for`. La sequenza `[3, 2, 1, 4, 5, 0]` rappresenta l'ordine in cui vengono mostrate le posizioni dei giocatori sulla griglia 2x3 del campo.
|
|
||||||
|
|
||||||
Per modificare l'ordine di visualizzazione, modifica questo array:
|
|
||||||
```vue
|
|
||||||
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]">
|
|
||||||
{{ sp.form.home[x] }}
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
L'ordine attuale mostra le posizioni in questo schema:
|
|
||||||
```
|
|
||||||
3 2 1
|
|
||||||
4 5 0
|
|
||||||
```
|
|
||||||
|
|
||||||
Dove 0 corrisponde alla posizione 1 nel pallavolo, 1 alla posizione 2, e così via.
|
|
||||||
|
|
||||||
## Testing e Deploy
|
|
||||||
|
|
||||||
### Testing su dispositivi mobili
|
|
||||||
|
|
||||||
Per testare l'applicazione su dispositivi mobili nella stessa rete locale:
|
|
||||||
|
|
||||||
1. Avvia il server di sviluppo:
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Vite mostrerà l'indirizzo locale e di rete. Se non viene mostrato, trova l'indirizzo IP del tuo computer:
|
L'applicazione sarà disponibile su [http://localhost:5173](http://localhost:5173)
|
||||||
```bash
|
|
||||||
# Su Linux/Mac
|
|
||||||
hostname -I
|
|
||||||
# oppure
|
|
||||||
ifconfig | grep "inet "
|
|
||||||
|
|
||||||
# Su Windows
|
### Modalità Sviluppo
|
||||||
ipconfig
|
- Hot Module Replacement (HMR) attivo
|
||||||
```
|
- Source maps per debugging
|
||||||
|
- Vue DevTools supportato
|
||||||
|
- Errori e warnings in console
|
||||||
|
|
||||||
3. Dal dispositivo mobile, connesso alla stessa rete Wi-Fi, naviga su:
|
---
|
||||||
```
|
|
||||||
http://[tuo-ip]:5173
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Accetta eventuali permessi richiesti dal browser per:
|
## Comandi per Build
|
||||||
- Modalità fullscreen
|
|
||||||
- Sintesi vocale
|
|
||||||
- Wake Lock (prevenzione spegnimento schermo)
|
|
||||||
|
|
||||||
### Build e Deploy
|
### Build Produzione
|
||||||
|
|
||||||
#### Build per produzione
|
Genera i file ottimizzati per il deployment:
|
||||||
|
|
||||||
Genera i file ottimizzati per la produzione:
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
Questo comando crea una cartella `dist/` contenente:
|
**Output:**
|
||||||
- HTML, CSS e JavaScript minificati
|
- Cartella `/dist` con file statici ottimizzati
|
||||||
- Service Worker per il funzionamento offline
|
- Service Worker generato automaticamente
|
||||||
- Manifest PWA per l'installazione come app
|
- PWA manifest configurato
|
||||||
- Asset ottimizzati
|
- Assets minificati e con hash per cache busting
|
||||||
|
- Base path: `/segnap` (modificabile in `vite.config.js`)
|
||||||
|
|
||||||
#### Anteprima della build
|
### Preview Build
|
||||||
|
|
||||||
|
Anteprima locale della build di produzione:
|
||||||
|
|
||||||
Prima del deploy, puoi testare la build in locale:
|
|
||||||
```bash
|
```bash
|
||||||
npm run preview
|
npm run preview
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Deploy su GitHub Pages
|
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):
|
||||||
|
|
||||||
1. Nel file [vite.config.js](vite.config.js:7), verifica che il `base` path corrisponda al nome del repository:
|
|
||||||
```javascript
|
```javascript
|
||||||
base: process.env.NODE_ENV === 'production' ? '/nome-repository' : '/',
|
VitePWA({
|
||||||
```
|
registerType: 'autoUpdate',
|
||||||
|
manifest: {
|
||||||
2. Esegui la build:
|
name: "app_segnap",
|
||||||
```bash
|
short_name: "segnap",
|
||||||
npm run build
|
description: "Segnapunti standalone.",
|
||||||
```
|
background_color: "#eee",
|
||||||
|
theme_color: '#ffffff',
|
||||||
3. Publica il contenuto della cartella `dist/` su GitHub Pages usando uno di questi metodi:
|
display: "fullscreen",
|
||||||
- Manualmente tramite l'interfaccia GitHub
|
orientation: "landscape",
|
||||||
- Usando `gh-pages`:
|
icons: [
|
||||||
```bash
|
{ src: 'segnap-192x192.png', sizes: '192x192', type: 'image/png' },
|
||||||
npm install -D gh-pages
|
{ src: 'segnap-512x512.png', sizes: '512x512', type: 'image/png' }
|
||||||
npx gh-pages -d dist
|
]
|
||||||
```
|
|
||||||
- Tramite GitHub Actions (configurando un workflow)
|
|
||||||
|
|
||||||
#### Deploy su server web
|
|
||||||
|
|
||||||
Il contenuto della cartella `dist/` può essere servito da qualsiasi server web statico:
|
|
||||||
|
|
||||||
**Nginx:**
|
|
||||||
```nginx
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name tuo-dominio.com;
|
|
||||||
root /percorso/a/dist;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Apache (.htaccess):**
|
|
||||||
```apache
|
|
||||||
<IfModule mod_rewrite.c>
|
|
||||||
RewriteEngine On
|
|
||||||
RewriteBase /
|
|
||||||
RewriteRule ^index\.html$ - [L]
|
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
|
||||||
RewriteRule . /index.html [L]
|
|
||||||
</IfModule>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Installazione come PWA
|
|
||||||
|
|
||||||
Dopo il deploy, gli utenti possono installare l'app:
|
|
||||||
|
|
||||||
**Su Android/Chrome:**
|
|
||||||
1. Visita il sito
|
|
||||||
2. Tocca il menu (⋮) > "Aggiungi a schermata Home"
|
|
||||||
3. L'app si avvierà in modalità fullscreen standalone
|
|
||||||
|
|
||||||
**Su iOS/Safari:**
|
|
||||||
1. Visita il sito
|
|
||||||
2. Tocca il pulsante Condividi
|
|
||||||
3. Seleziona "Aggiungi a Home"
|
|
||||||
|
|
||||||
## Dettagli tecnici
|
|
||||||
|
|
||||||
### Architettura del componente
|
|
||||||
|
|
||||||
L'applicazione è strutturata come Single Page Application (SPA) con un unico componente principale `HomePage`. Questo design semplice è ideale per un'app focalizzata su un'unica funzionalità.
|
|
||||||
|
|
||||||
### Gestione dello stato
|
|
||||||
|
|
||||||
Lo stato dell'applicazione è gestito localmente nel componente tramite `data()`:
|
|
||||||
- `punt`: punteggio corrente delle due squadre
|
|
||||||
- `set`: numero di set vinti
|
|
||||||
- `servHome`: boolean che indica quale squadra ha il servizio
|
|
||||||
- `form`: array con le posizioni dei giocatori (1-6)
|
|
||||||
- `nomi`: nomi personalizzabili delle squadre
|
|
||||||
- `visuForm`: toggle tra visualizzazione punteggio/formazione
|
|
||||||
- `visuButt`: visibilità della barra dei pulsanti
|
|
||||||
|
|
||||||
### Gestione della rotazione
|
|
||||||
|
|
||||||
La rotazione dei giocatori segue le regole del pallavolo:
|
|
||||||
|
|
||||||
**Incremento punteggio** ([HomePage.vue:74-78](src/components/HomePage.vue:74-78)):
|
|
||||||
```javascript
|
|
||||||
incPunt(team) {
|
|
||||||
this.sp.punt[team]++;
|
|
||||||
this.sp.servHome = (team == "home");
|
|
||||||
this.sp.form[team].push(this.sp.form[team].shift());
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- Il servizio passa alla squadra che ha segnato
|
|
||||||
- La rotazione avviene con `push(shift())`: il primo elemento va in fondo
|
|
||||||
- Simula la rotazione oraria dei giocatori
|
|
||||||
|
|
||||||
**Decremento punteggio** ([HomePage.vue:79-85](src/components/HomePage.vue:79-85)):
|
|
||||||
```javascript
|
|
||||||
decPunt(team) {
|
|
||||||
if (this.sp.punt[team] > 0) {
|
|
||||||
this.sp.punt[team]--;
|
|
||||||
this.sp.form[team].unshift(this.sp.form[team].pop());
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
```
|
|
||||||
- La rotazione inversa avviene con `unshift(pop())`: l'ultimo elemento va all'inizio
|
|
||||||
- Permette di annullare errori di punteggio
|
|
||||||
|
|
||||||
### Sintesi vocale
|
|
||||||
|
|
||||||
La funzione `speak()` ([HomePage.vue:86-112](src/components/HomePage.vue:86-112)) utilizza la Web Speech API:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const msg = new SpeechSynthesisUtterance();
|
|
||||||
msg.lang = 'it_IT';
|
|
||||||
msg.voice = voices.find(voice => voice.name === 'Google italiano');
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Logica di annuncio:
|
### Caratteristiche PWA
|
||||||
- "zero a zero" se il punteggio è 0-0
|
|
||||||
- "[numero] pari" se il punteggio è uguale
|
|
||||||
- "[servizio] a [ricezione]" annunciando prima chi ha il servizio
|
|
||||||
|
|
||||||
### Prevenzione spegnimento schermo
|
- **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
|
||||||
|
|
||||||
NoSleep.js ([HomePage.vue:32-33](src/components/HomePage.vue:32-33)) viene attivato al mount su dispositivi mobili:
|
### Installazione PWA
|
||||||
```javascript
|
|
||||||
var noSleep = new NoSleep();
|
**Android/Desktop (Chrome):**
|
||||||
noSleep.enable();
|
- 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
|
||||||
|
└─────┴─────┴─────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
Questo impedisce allo schermo di spegnersi durante le partite, funzionalità essenziale per un segnapunti.
|
La rotazione avviene in senso orario: 1→2→3→4→5→6→1
|
||||||
|
|
||||||
### Controlli da tastiera
|
|
||||||
|
|
||||||
Gli eventi tastiera sono gestiti tramite listener globale ([HomePage.vue:121-150](src/components/HomePage.vue:121-150)):
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
window.addEventListener("keydown", this.funzioneTastiSpeciali);
|
|
||||||
```
|
|
||||||
|
|
||||||
I listener vengono temporaneamente disabilitati quando si aprono dialog per permettere l'inserimento di testo.
|
|
||||||
|
|
||||||
### Progressive Web App
|
|
||||||
|
|
||||||
La configurazione PWA in [vite.config.js](vite.config.js:10-33) definisce:
|
|
||||||
|
|
||||||
- **Manifest**: metadati dell'app (nome, icone, orientamento)
|
|
||||||
- **Service Worker**: strategia di cache per funzionamento offline
|
|
||||||
- **Display mode**: fullscreen per esperienza app-like
|
|
||||||
- **Orientamento forzato**: landscape per massimizzare spazio visibile
|
|
||||||
|
|
||||||
## Setup IDE raccomandato
|
|
||||||
|
|
||||||
- [VS Code](https://code.visualstudio.com/) - Editor consigliato
|
|
||||||
- [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) - Supporto Vue 3 (disabilita Vetur se installato)
|
|
||||||
- [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) - IntelliSense migliorato
|
|
||||||
|
|
||||||
### Estensioni VS Code utili
|
|
||||||
|
|
||||||
File [.vscode/extensions.json](.vscode/extensions.json) suggerisce:
|
|
||||||
- Vue Language Features (Volar)
|
|
||||||
- TypeScript Vue Plugin
|
|
||||||
|
|
||||||
## Risoluzione problemi comuni
|
|
||||||
|
|
||||||
### La sintesi vocale non funziona
|
|
||||||
|
|
||||||
- Verifica che il browser supporti la Web Speech API (Chrome/Edge consigliati)
|
|
||||||
- Su alcuni browser è necessaria l'interazione utente prima di utilizzare la sintesi vocale
|
|
||||||
- Controlla che la voce "Google italiano" sia disponibile nel sistema
|
|
||||||
|
|
||||||
### L'app non si installa come PWA
|
|
||||||
|
|
||||||
- Verifica che il sito sia servito tramite HTTPS (richiesto per Service Workers)
|
|
||||||
- Controlla che le icone in `public/` siano presenti e accessibili
|
|
||||||
- Ispeziona la console del browser per errori del Service Worker
|
|
||||||
|
|
||||||
### Lo schermo si spegne comunque
|
|
||||||
|
|
||||||
- NoSleep.js richiede un'interazione utente per attivarsi
|
|
||||||
- Su iOS, la prevenzione dello spegnimento ha limitazioni del sistema operativo
|
|
||||||
- Verifica che non ci siano impostazioni di risparmio energetico aggressive
|
|
||||||
|
|
||||||
### La rotazione non funziona correttamente
|
|
||||||
|
|
||||||
- Le formazioni si azzerano con il RESET
|
|
||||||
- Verifica che gli array `form` abbiano sempre 6 elementi
|
|
||||||
- La visualizzazione può essere invertita ma la logica di rotazione rimane corretta
|
|
||||||
|
|
||||||
## Licenza
|
|
||||||
|
|
||||||
Progetto privato. Tutti i diritti riservati.
|
|
||||||
|
|||||||
41
build-apk.sh
41
build-apk.sh
@@ -1,41 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "========================================="
|
|
||||||
echo " Building APK for Segnapunti Android "
|
|
||||||
echo "========================================="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Rileva architettura
|
|
||||||
ARCH=$(uname -m)
|
|
||||||
echo "Architettura rilevata: $ARCH"
|
|
||||||
|
|
||||||
# Crea directory dist/android se non esiste
|
|
||||||
mkdir -p dist/android
|
|
||||||
|
|
||||||
echo "Building Docker image..."
|
|
||||||
docker build --platform linux/$(uname -m) -t segnapunti-android-builder .
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Building APK inside Docker container..."
|
|
||||||
docker run --rm \
|
|
||||||
-v "$(pwd)/dist:/app/dist" \
|
|
||||||
segnapunti-android-builder
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Fixing permissions on dist folder..."
|
|
||||||
sudo chown -R $USER:$USER dist
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "========================================="
|
|
||||||
echo "Build completed successfully!"
|
|
||||||
echo "========================================="
|
|
||||||
echo ""
|
|
||||||
echo "APK location: $(pwd)/dist/android/segnapunti-debug.apk"
|
|
||||||
echo ""
|
|
||||||
echo "Per installare su dispositivo Android:"
|
|
||||||
echo " adb install dist/android/segnapunti-debug.apk"
|
|
||||||
echo ""
|
|
||||||
echo "Oppure trasferisci il file sul dispositivo"
|
|
||||||
echo "e installalo manualmente."
|
|
||||||
echo ""
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"appId": "com.antoniana.segnapunti",
|
|
||||||
"appName": "Segnapunti",
|
|
||||||
"webDir": "dist",
|
|
||||||
"bundledWebRuntime": false,
|
|
||||||
"server": {
|
|
||||||
"androidScheme": "https"
|
|
||||||
},
|
|
||||||
"android": {
|
|
||||||
"allowMixedContent": true,
|
|
||||||
"webContentsDebuggingEnabled": true
|
|
||||||
},
|
|
||||||
"plugins": {
|
|
||||||
"SplashScreen": {
|
|
||||||
"launchShowDuration": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"preferences": {
|
|
||||||
"orientation": "landscape"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Script per generare automaticamente le icone Android da segnap-512x512.png
|
|
||||||
|
|
||||||
SOURCE="./public/segnap-512x512.png"
|
|
||||||
BASE_DIR="./android/app/src/main/res"
|
|
||||||
|
|
||||||
# Verifica che ImageMagick sia installato
|
|
||||||
if ! command -v convert &> /dev/null; then
|
|
||||||
echo "Errore: ImageMagick non è installato."
|
|
||||||
echo "Installa con: sudo apt-get install imagemagick"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Verifica che il file sorgente esista
|
|
||||||
if [ ! -f "$SOURCE" ]; then
|
|
||||||
echo "Errore: File sorgente $SOURCE non trovato"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Verifica che la directory android esista
|
|
||||||
if [ ! -d "./android" ]; then
|
|
||||||
echo "Errore: Directory android/ non trovata."
|
|
||||||
echo "Esegui prima: npx cap add android"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Generazione icone Android da $SOURCE..."
|
|
||||||
|
|
||||||
# Crea le directory se non esistono e genera le icone
|
|
||||||
for size in "mdpi:48" "hdpi:72" "xhdpi:96" "xxhdpi:144" "xxxhdpi:192"; do
|
|
||||||
density="${size%:*}"
|
|
||||||
pixels="${size#*:}"
|
|
||||||
dir="$BASE_DIR/mipmap-$density"
|
|
||||||
|
|
||||||
mkdir -p "$dir"
|
|
||||||
convert "$SOURCE" -resize ${pixels}x${pixels} "$dir/ic_launcher.png"
|
|
||||||
echo " ✓ Creata icona $density (${pixels}x${pixels})"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Icone generate con successo!"
|
|
||||||
echo ""
|
|
||||||
echo "Prossimo passo: npx cap sync"
|
|
||||||
@@ -3,10 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
||||||
<title>Segnapunti - Anto</title>
|
<title>Segnapunti - Anto</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
6273
package-lock.json
generated
6273
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@@ -4,27 +4,25 @@
|
|||||||
"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",
|
||||||
"cap:sync": "npm run build && cap sync",
|
"start": "node server.js",
|
||||||
"cap:open": "cap open android",
|
"serve": "vite build && node server.js"
|
||||||
"android:build:debug": "cd android && ./gradlew assembleDebug",
|
|
||||||
"docker:build": "./build-apk.sh"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor-community/text-to-speech": "^6.0.0",
|
"express": "^5.2.1",
|
||||||
"@capacitor/android": "^6.2.0",
|
"vue": "^3.2.47",
|
||||||
"@capacitor/app": "^6.0.1",
|
"vue-router": "^4.6.4",
|
||||||
"@capacitor/core": "^6.2.0",
|
"wave-ui": "^3.3.0",
|
||||||
"nosleep.js": "^0.12.0",
|
"ws": "^8.19.0"
|
||||||
"vue": "^3.4.38",
|
|
||||||
"wave-ui": "^3.17.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/cli": "^6.2.0",
|
"@vitejs/plugin-vue": "^4.1.0",
|
||||||
"@vitejs/plugin-vue": "^5.1.4",
|
"concurrently": "^9.2.1",
|
||||||
"vite": "^5.4.10",
|
"vite": "^4.3.9",
|
||||||
"vite-plugin-pwa": "^0.20.5"
|
"vite-plugin-pwa": "^0.16.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
server.js
Normal file
34
server.js
Normal 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)
|
||||||
|
})
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "Configurazione icone Android..."
|
|
||||||
|
|
||||||
# Rimuove icone default di Capacitor
|
|
||||||
rm -rf android/app/src/main/res/mipmap-*
|
|
||||||
|
|
||||||
# Crea directory per tutte le densità
|
|
||||||
for density in mdpi hdpi xhdpi xxhdpi xxxhdpi; do
|
|
||||||
mkdir -p android/app/src/main/res/mipmap-$density
|
|
||||||
done
|
|
||||||
|
|
||||||
# Mappa densità -> dimensioni
|
|
||||||
declare -A sizes=(
|
|
||||||
["mdpi"]="48"
|
|
||||||
["hdpi"]="72"
|
|
||||||
["xhdpi"]="96"
|
|
||||||
["xxhdpi"]="144"
|
|
||||||
["xxxhdpi"]="192"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Genera icone per ogni densità
|
|
||||||
for density in "${!sizes[@]}"; do
|
|
||||||
size="${sizes[$density]}"
|
|
||||||
echo " Generando icone ${size}x${size} per $density..."
|
|
||||||
|
|
||||||
convert public/segnap-512x512.png -resize ${size}x${size} \
|
|
||||||
android/app/src/main/res/mipmap-$density/ic_launcher.png
|
|
||||||
|
|
||||||
convert public/segnap-512x512.png -resize ${size}x${size} \
|
|
||||||
android/app/src/main/res/mipmap-$density/ic_launcher_round.png
|
|
||||||
done
|
|
||||||
|
|
||||||
# Icona per il file APK (visibile da PC)
|
|
||||||
mkdir -p android/app/src/main/res/drawable
|
|
||||||
convert public/segnap-512x512.png -resize 512x512 \
|
|
||||||
android/app/src/main/res/drawable/icon.png
|
|
||||||
|
|
||||||
echo "Icone configurate con successo!"
|
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
<script setup>
|
|
||||||
import HomePage from './components/HomePage/index.vue'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<HomePage />
|
<router-view />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
849
src/components/ControllerPage.vue
Normal file
849
src/components/ControllerPage.vue
Normal 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>
|
||||||
384
src/components/DisplayPage.vue
Normal file
384
src/components/DisplayPage.vue
Normal 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>
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
<script>
|
|
||||||
import NoSleep from "nosleep.js";
|
|
||||||
import { Capacitor } from '@capacitor/core';
|
|
||||||
import { App } from '@capacitor/app';
|
|
||||||
import { TextToSpeech } from '@capacitor-community/text-to-speech';
|
|
||||||
|
|
||||||
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() {
|
|
||||||
// Carica le voci (necessario su alcuni browser)
|
|
||||||
this.voices = window.speechSynthesis.getVoices();
|
|
||||||
|
|
||||||
// Ascolta l'evento voiceschanged per Android/Chrome
|
|
||||||
if (speechSynthesis.onvoiceschanged !== undefined) {
|
|
||||||
speechSynthesis.onvoiceschanged = () => {
|
|
||||||
this.voices = window.speechSynthesis.getVoices();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isMobile()) {
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async speak() {
|
|
||||||
// Prepara il testo da pronunciare
|
|
||||||
let text = "";
|
|
||||||
if (this.sp.punt.home + this.sp.punt.guest == 0) {
|
|
||||||
text = "zero a zero";
|
|
||||||
} else if (this.sp.punt.home == this.sp.punt.guest) {
|
|
||||||
text = this.sp.punt.home + " pari";
|
|
||||||
} else {
|
|
||||||
if (this.sp.servHome) {
|
|
||||||
text = this.sp.punt.home + " a " + this.sp.punt.guest;
|
|
||||||
} else {
|
|
||||||
text = this.sp.punt.guest + " a " + this.sp.punt.home;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Speak called, text:', text);
|
|
||||||
console.log('Is native platform:', Capacitor.isNativePlatform());
|
|
||||||
|
|
||||||
// Usa plugin nativo su mobile, Web API su desktop
|
|
||||||
if (Capacitor.isNativePlatform()) {
|
|
||||||
try {
|
|
||||||
console.log('Using native TTS');
|
|
||||||
await TextToSpeech.speak({
|
|
||||||
text: text,
|
|
||||||
lang: 'it-IT',
|
|
||||||
rate: 0.9,
|
|
||||||
pitch: 1.0,
|
|
||||||
volume: 1.0,
|
|
||||||
category: 'ambient'
|
|
||||||
});
|
|
||||||
console.log('TTS completed successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('TTS error:', error);
|
|
||||||
this.$waveui.notify('Errore TTS: ' + error.message, 'error', 3000);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback Web API per desktop/browser
|
|
||||||
console.log('Using web API speechSynthesis');
|
|
||||||
window.speechSynthesis.cancel();
|
|
||||||
|
|
||||||
const msg = new SpeechSynthesisUtterance();
|
|
||||||
msg.text = text;
|
|
||||||
msg.volume = 1.0;
|
|
||||||
msg.rate = 0.9;
|
|
||||||
msg.pitch = 1.0;
|
|
||||||
msg.lang = 'it-IT';
|
|
||||||
|
|
||||||
const voices = window.speechSynthesis.getVoices();
|
|
||||||
const italianVoice = voices.find(voice =>
|
|
||||||
voice.lang.startsWith('it') || voice.name.includes('Italian') || voice.name.includes('italiano')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (italianVoice) {
|
|
||||||
msg.voice = italianVoice;
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
@@ -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>
|
|
||||||
|
|
||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<template src="./HomePage.html"></template>
|
|
||||||
<script src="./HomePage.js"></script>
|
|
||||||
|
|
||||||
204
src/gameState.js
Normal file
204
src/gameState.js
Normal 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
|
||||||
|
}
|
||||||
13
src/main.js
13
src/main.js
@@ -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
45
src/server-utils.js
Normal 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()
|
||||||
|
}
|
||||||
212
src/style.css
212
src/style.css
@@ -1,33 +1,17 @@
|
|||||||
:root {
|
:root {
|
||||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
touch-action: none;
|
touch-action: pan-x pan-y;
|
||||||
height: 100%;
|
height: 100%
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
position: fixed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
overscroll-behavior: none;
|
overscroll-behavior-y: contain;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
min-width: 320px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100vh;
|
min-height: 100vh;
|
||||||
height: 100dvh; /* Dynamic viewport height per mobile */
|
|
||||||
background-color: #000;
|
background-color: #000;
|
||||||
overflow: hidden;
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@@ -54,13 +38,8 @@ button:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
margin: 0;
|
margin: 0 auto;
|
||||||
padding: 0;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
.campo {
|
.campo {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@@ -74,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;
|
||||||
}
|
}
|
||||||
@@ -101,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;
|
||||||
@@ -144,43 +151,114 @@ button:focus-visible {
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive mobile */
|
.campo-config {
|
||||||
@media (max-width: 768px) {
|
display: flex;
|
||||||
.hea {
|
flex-direction: column;
|
||||||
font-size: 4vw;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hea span {
|
.campo-pallavolo {
|
||||||
padding-left: 5px;
|
border: 3px solid #999;
|
||||||
padding-right: 5px;
|
background-color: rgba(205, 133, 63, 0.25);
|
||||||
}
|
position: relative;
|
||||||
|
width: 220px;
|
||||||
.hea img {
|
height: 220px;
|
||||||
width: 18px !important;
|
display: flex;
|
||||||
}
|
flex-direction: column;
|
||||||
|
padding: 0;
|
||||||
.punt {
|
}
|
||||||
font-size: 45vh;
|
|
||||||
}
|
.fila-anteriore {
|
||||||
|
height: 33.33%;
|
||||||
.form {
|
display: flex;
|
||||||
font-size: 4vh;
|
align-items: center;
|
||||||
border-top: #fff dashed 15px;
|
justify-content: center;
|
||||||
padding-top: 30px;
|
padding: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.formdiv {
|
.fila-posteriore {
|
||||||
font-size: 15vh;
|
height: 66.67%;
|
||||||
}
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
.bot button {
|
justify-content: center;
|
||||||
font-size: 0.7em;
|
padding: 5px;
|
||||||
padding: 0.4em 0.8em;
|
}
|
||||||
margin-left: 3px;
|
|
||||||
margin-right: 3px;
|
.linea-tre-metri {
|
||||||
}
|
border-top: 2px solid #666;
|
||||||
|
width: 100%;
|
||||||
.bot img {
|
height: 0;
|
||||||
width: 20px !important;
|
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
171
src/websocket-handler.js
Normal 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
39
vite-plugin-websocket.js
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ 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: '/',
|
base: '/',
|
||||||
plugins: [
|
plugins: [
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user