Compare commits
1 Commits
a40fad7194
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 04969a45ea |
282
CHANGELOG.md
Normal file
282
CHANGELOG.md
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Tutte le modifiche significative a questo progetto sono documentate in questo file.
|
||||||
|
|
||||||
|
Il formato si basa su [Keep a Changelog](https://keepachangelog.com/it/1.0.0/),
|
||||||
|
e questo progetto aderisce al [Versionamento Semantico](https://semver.org/lang/it/).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-02-10
|
||||||
|
|
||||||
|
Rilascio iniziale di **Segnapunti Anto**, un'applicazione web Progressive Web App (PWA) professionale per il tracciamento in tempo reale dei punteggi durante partite di pallavolo.
|
||||||
|
|
||||||
|
### Funzionalità Principali
|
||||||
|
|
||||||
|
#### Gestione Partite e Punteggi
|
||||||
|
- **Tracciamento punti in tempo reale** per entrambe le squadre (casa e ospite)
|
||||||
|
- **Conteggio automatico dei set** con supporto per modalità al meglio di 3 o al meglio di 5
|
||||||
|
- **Logica regolamentare completa**:
|
||||||
|
- Set regolari: primo a 25 punti con almeno 2 di vantaggio
|
||||||
|
- Set decisivo (tie-break): primo a 15 punti con almeno 2 di vantaggio
|
||||||
|
- Blocco automatico assegnazione punti al raggiungimento della vittoria
|
||||||
|
- **Indicatore visivo del servizio** per identificare quale squadra è al servizio
|
||||||
|
- **Cronologia punti** con striscia visiva per seguire l'andamento della partita
|
||||||
|
- Possibilità di **incrementare e decrementare punti** e **set**
|
||||||
|
- **Annullamento punti** con ripristino automatico del servizio precedente
|
||||||
|
|
||||||
|
#### Formazioni e Rotazioni
|
||||||
|
- **Visualizzazione interattiva** della formazione in campo con 6 giocatori per squadra
|
||||||
|
- **Rotazione automatica regolamentare** quando la squadra conquista il servizio (cambio palla)
|
||||||
|
- **Configurazione manuale dei numeri di maglia** per ogni giocatore
|
||||||
|
- **Sistema di cambi giocatori**:
|
||||||
|
- Dialog dedicato per effettuare i cambi
|
||||||
|
- Supporto per cambi singoli o multipli
|
||||||
|
- Tabella IN/OUT con validazione degli input
|
||||||
|
- Verifica che i numeri di maglia siano numerici
|
||||||
|
- Scorciatoie da tastiera dedicate per squadra casa e ospite
|
||||||
|
- **Limitazione cambio palla manuale** solo a inizio set (0-0) per prevenire errori nella rotazione
|
||||||
|
|
||||||
|
#### Interfaccia Utente e Personalizzazione
|
||||||
|
- **Interfaccia fullscreen touch-friendly** ottimizzata per tablet e smartphone
|
||||||
|
- **Layout responsive** con media queries per schermi piccoli (<768px)
|
||||||
|
- **Modalità di visualizzazione**:
|
||||||
|
- Punteggio semplice
|
||||||
|
- Formazioni complete con posizioni giocatori
|
||||||
|
- Toggle tra le due modalità con scorciatoia da tastiera
|
||||||
|
- **Personalizzazione squadre**:
|
||||||
|
- Configurazione dinamica dei nomi squadre
|
||||||
|
- Inversione layout orizzontale (scambio posizione casa/ospite)
|
||||||
|
- Configurazione numeri di maglia giocatori
|
||||||
|
- **Controlli nascondibili**: possibilità di nascondere/mostrare barra pulsanti e cronologia
|
||||||
|
- **Stabilizzazione UI**: dimensioni fisse per riquadri punteggio per evitare spostamenti durante gli aggiornamenti
|
||||||
|
|
||||||
|
#### Controlli e Accessibilità
|
||||||
|
- **Controlli da tastiera completi** con scorciatoie dedicate:
|
||||||
|
- **Squadra Casa**: `Ctrl + ↑/↓` (punti), `Ctrl + →` (set), `Ctrl + C` (cambi)
|
||||||
|
- **Squadra Ospite**: `Shift + ↑/↓` (punti), `Shift + →` (set), `Shift + C` (cambi)
|
||||||
|
- **Comandi globali**:
|
||||||
|
- `Ctrl + ←` (cambio palla, solo a 0-0)
|
||||||
|
- `Ctrl + M` (configurazione)
|
||||||
|
- `Ctrl + B` (toggle barra pulsanti)
|
||||||
|
- `Ctrl + F` (fullscreen)
|
||||||
|
- `Ctrl + S` (annuncio vocale)
|
||||||
|
- `Ctrl + Z` (switch visualizzazione)
|
||||||
|
- **Sintesi vocale** per annunci punteggio in italiano usando Web Speech API
|
||||||
|
- **Sistema di alert professionale** usando Wave UI per notifiche e conferme
|
||||||
|
|
||||||
|
#### Progressive Web App (PWA)
|
||||||
|
- **Installabile** come app nativa su smartphone, tablet e desktop
|
||||||
|
- **Funzionamento offline completo** grazie ai Service Worker
|
||||||
|
- **Auto-update automatico** per ricevere nuovi aggiornamenti senza reinstallazione
|
||||||
|
- **Display fullscreen** per massimizzare lo spazio visivo
|
||||||
|
- **Orientamento landscape ottimizzato** per utilizzo su tablet
|
||||||
|
- **Orientamento sensor landscape** con supporto rotazione 180°
|
||||||
|
- **Icone PWA personalizzate** (192x192 e 512x512)
|
||||||
|
- **Prevenzione scroll indesiderato** su mobile (overscroll-behavior)
|
||||||
|
- **Supporto 100dvh** e position:fixed per layout mobile stabile
|
||||||
|
- **Blocco scroll** per evitare ricariche accidentali con swipe-down
|
||||||
|
|
||||||
|
#### Build e Deployment
|
||||||
|
- **Build APK Android** tramite Capacitor per distribuzione nativa
|
||||||
|
- **Setup automatico icone Android** con script dedicato per multi-densità (ldpi, mdpi, hdpi, xhdpi, xxhdpi, xxxhdpi)
|
||||||
|
- **Configurazione Capacitor** ottimizzata per landscape senza splash screen
|
||||||
|
- **Output unificato** in `/dist/android` per build Android
|
||||||
|
- **Base path configurabile** (`/segnap`) per deployment su sottocartelle
|
||||||
|
|
||||||
|
### Tecnologie e Dipendenze
|
||||||
|
|
||||||
|
#### Stack Tecnologico
|
||||||
|
- **Vue 3.4.38** - Framework JavaScript reattivo
|
||||||
|
- **Vite 5.4.10** - Build tool e dev server veloce
|
||||||
|
- **Wave UI 3.17.0** - Libreria UI components per alert e dialogs
|
||||||
|
- **NoSleep.js 0.12.0** - Prevenzione standby durante le partite
|
||||||
|
- **vite-plugin-pwa 0.20.5** - Plugin per generazione PWA
|
||||||
|
- **Capacitor 6.2.0** - Framework per build app native Android/iOS
|
||||||
|
|
||||||
|
#### Ambiente di Sviluppo
|
||||||
|
- **Node.js 20.x LTS** (consigliato v20.2.0)
|
||||||
|
- **npm 9.0.0+** per gestione dipendenze
|
||||||
|
- **NVM support** per gestione versioni Node.js
|
||||||
|
- **Hot Module Replacement (HMR)** in modalità sviluppo
|
||||||
|
- **Source maps** per debugging
|
||||||
|
- **Vue DevTools** supportato
|
||||||
|
|
||||||
|
#### Browser Supportati
|
||||||
|
- **Chrome/Chromium 90+** - Supporto completo (consigliato)
|
||||||
|
- **Firefox 88+** - Supporto completo
|
||||||
|
|
||||||
|
### Build e Comandi
|
||||||
|
|
||||||
|
#### Comandi Disponibili
|
||||||
|
- `npm run dev` - Server di sviluppo con hot-reload su http://localhost:5173
|
||||||
|
- `npm run build` - Build di produzione ottimizzata in `/dist`
|
||||||
|
- `npm run preview` - Anteprima locale della build di produzione
|
||||||
|
|
||||||
|
#### Output Build
|
||||||
|
- File statici ottimizzati e minificati
|
||||||
|
- Service Worker generato automaticamente
|
||||||
|
- PWA manifest configurato
|
||||||
|
- Assets con hash per cache busting
|
||||||
|
- Permessi automatici per cartella dist in ambiente Docker
|
||||||
|
|
||||||
|
### Documentazione
|
||||||
|
- **README.md completo** con:
|
||||||
|
- Panoramica funzionalità
|
||||||
|
- Requisiti di sistema dettagliati
|
||||||
|
- Istruzioni di installazione e setup con NVM
|
||||||
|
- Guida ai comandi per sviluppo e build
|
||||||
|
- Tabella completa shortcuts tastiera
|
||||||
|
- Configurazione PWA documentata
|
||||||
|
- Spiegazione logica regolamentare pallavolo
|
||||||
|
- Browser testati e supportati
|
||||||
|
- **Encoding corretto** per caratteri accentati italiani
|
||||||
|
|
||||||
|
### Miglioramenti Architetturali
|
||||||
|
- **Separazione componenti**: HomePage estratta in componente dedicato
|
||||||
|
- **Rimozione codice legacy**: eliminati file non utilizzati (HelloWorld.vue)
|
||||||
|
- **Refactoring CSS**: file style.css organizzato e ottimizzato
|
||||||
|
- **Gestione servizio migliorata**: variabile booleana `servHome` per gestione cambio servizio
|
||||||
|
- **Validazione input**: controlli su numeri di maglia e cambi giocatori
|
||||||
|
- **Gestione errori**: prevenzione incrementi a set concluso senza notifiche spam
|
||||||
|
|
||||||
|
### Note di Sviluppo
|
||||||
|
- **Orientamento sensor landscape**: permette rotazione 180° del dispositivo
|
||||||
|
- **Fix tentati audio mobile**: implementati ma richiedono ancora debug (WIP)
|
||||||
|
- Aggiunto supporto @capacitor-community/text-to-speech per audio nativo
|
||||||
|
- Implementato fallback Web API su desktop, plugin nativo su mobile
|
||||||
|
- Correzione lingua da 'it_IT' a 'it-IT'
|
||||||
|
- **Java 17 → 21**: aggiornamento per compatibilità plugin TTS
|
||||||
|
- **ImageMagick in Dockerfile**: per generazione automatica icone Android
|
||||||
|
|
||||||
|
### Configurazione
|
||||||
|
- **Base path**: `/segnap` (configurabile in vite.config.js)
|
||||||
|
- **Tema PWA**: background `#eee`, theme color `#ffffff`
|
||||||
|
- **Display**: fullscreen landscape
|
||||||
|
- **Manifest name**: app_segnap
|
||||||
|
- **Short name**: segnap
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architettura e Funzionamento Interno
|
||||||
|
|
||||||
|
### Come Funziona l'Applicazione Web
|
||||||
|
|
||||||
|
L'applicazione è una **Single Page Application (SPA)** completamente **client-side**:
|
||||||
|
|
||||||
|
- **Server**: Serve solo file statici (nessun backend, nessun database)
|
||||||
|
- **Esecuzione**: Tutto gira nel browser dell'utente
|
||||||
|
- **Stato**: Memorizzato solo nella RAM del browser (si perde al refresh)
|
||||||
|
- **Offline**: Funziona completamente offline dopo la prima visita (Service Worker)
|
||||||
|
|
||||||
|
#### Build e Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Vite compila il codice Vue/JavaScript
|
||||||
|
2. Ottimizza e minifica JavaScript e CSS
|
||||||
|
3. Genera Service Worker e manifest PWA
|
||||||
|
4. Output in `/dist` con file statici pronti
|
||||||
|
|
||||||
|
**Deploy**: Copia `/dist` su web server statico (nginx, Apache, Vercel, Netlify). Non serve Node.js, PHP o database sul server.
|
||||||
|
|
||||||
|
#### Funzionamento Runtime
|
||||||
|
|
||||||
|
**Prima visita:**
|
||||||
|
1. Browser scarica index.html dal server
|
||||||
|
2. Carica bundle JavaScript e CSS
|
||||||
|
3. Vue.js inizializza l'applicazione
|
||||||
|
4. Service Worker cachea tutti gli asset
|
||||||
|
5. App pronta (nessuna ulteriore chiamata al server)
|
||||||
|
|
||||||
|
**Visite successive:**
|
||||||
|
1. Service Worker serve i file dalla cache locale
|
||||||
|
2. App si avvia istantaneamente anche senza internet
|
||||||
|
3. In background verifica se esistono aggiornamenti
|
||||||
|
4. Aggiornamenti applicati al prossimo refresh
|
||||||
|
|
||||||
|
#### Gestione Stato
|
||||||
|
|
||||||
|
Tutto lo stato della partita vive nella memoria del browser:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
sp: {
|
||||||
|
punt: { home: 0, guest: 0 },
|
||||||
|
set: { home: 0, guest: 0 },
|
||||||
|
servHome: true,
|
||||||
|
form: { home: ["1","2","3","4","5","6"], guest: ["1","2","3","4","5","6"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Limitazioni:**
|
||||||
|
- Refresh della pagina azzera tutto lo stato
|
||||||
|
- Nessuna sincronizzazione tra dispositivi
|
||||||
|
- Nessuno storico partite
|
||||||
|
|
||||||
|
**Vantaggi:**
|
||||||
|
- Privacy totale (nessun dato esce dal dispositivo)
|
||||||
|
- Velocità massima (nessuna latenza di rete)
|
||||||
|
- Funzionamento offline completo
|
||||||
|
|
||||||
|
**Possibili evoluzioni:**
|
||||||
|
- Persistenza con localStorage
|
||||||
|
- Backend con database per multi-dispositivo
|
||||||
|
- WebSocket per sincronizzazione real-time
|
||||||
|
|
||||||
|
### Note Tecniche
|
||||||
|
|
||||||
|
**Problemi noti:**
|
||||||
|
- Audio mobile: sintesi vocale non funziona su alcuni dispositivi Android
|
||||||
|
- Persistenza: lo stato si perde al refresh della pagina
|
||||||
|
- Codice legacy: presenza di file HomePage duplicati
|
||||||
|
|
||||||
|
**Architettura futura:**
|
||||||
|
- Stato centralizzato con Pinia/Vuex
|
||||||
|
- Architettura client-server con WebSocket per display remoto
|
||||||
|
- Persistenza con localStorage o database
|
||||||
|
- Testing con Vitest e Cypress
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Informazioni sul Progetto
|
||||||
|
|
||||||
|
**Nome progetto**: Segnapunti Anto
|
||||||
|
**Descrizione**: Applicazione web PWA per tracciare i punteggi di partite di pallavolo in tempo reale
|
||||||
|
**Sviluppato per**: Team Antoniana
|
||||||
|
**Licenza**: Privata
|
||||||
|
**Repository**: https://github.com/[username]/segnapunti
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Come Leggere Questo Changelog
|
||||||
|
|
||||||
|
- **Funzionalità Principali**: nuove caratteristiche aggiunte all'applicazione
|
||||||
|
- **Tecnologie e Dipendenze**: stack tecnologico e versioni utilizzate
|
||||||
|
- **Build e Comandi**: istruzioni per compilare ed eseguire il progetto
|
||||||
|
- **Documentazione**: miglioramenti alla documentazione utente e sviluppatore
|
||||||
|
- **Miglioramenti Architetturali**: refactoring e ottimizzazioni del codice
|
||||||
|
- **Note di Sviluppo**: work in progress e problemi noti
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Supporto e Contributi
|
||||||
|
|
||||||
|
Per segnalare bug o richiedere funzionalità, aprire una issue nel repository del progetto.
|
||||||
|
|
||||||
|
Per contribuire al progetto:
|
||||||
|
1. Fork del repository
|
||||||
|
2. Creare un branch per la feature (`git checkout -b feature/nome-feature`)
|
||||||
|
3. Commit delle modifiche (`git commit -m 'Aggiunge nome-feature'`)
|
||||||
|
4. Push al branch (`git push origin feature/nome-feature`)
|
||||||
|
5. Aprire una Pull Request
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Data primo commit**: 2024
|
||||||
|
**Data rilascio 1.0.0**: 2026-02-10
|
||||||
@@ -252,4 +252,4 @@ Visualizzazione a 6 posizioni standard:
|
|||||||
└─────┴─────┴─────┘
|
└─────┴─────┴─────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
La rotazione avviene in senso orario: 1→2→3→4→5→6→1
|
La rotazione avviene in senso orario: 1→6→5→4→3→2→1
|
||||||
|
|||||||
1017
package-lock.json
generated
1017
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,17 +6,12 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview"
|
||||||
"start": "node server.js",
|
|
||||||
"serve": "vite build && node server.js"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^5.2.1",
|
|
||||||
"nosleep.js": "^0.12.0",
|
"nosleep.js": "^0.12.0",
|
||||||
"vue": "^3.2.47",
|
"vue": "^3.2.47",
|
||||||
"vue-router": "^4.6.4",
|
"wave-ui": "^3.3.0"
|
||||||
"wave-ui": "^3.3.0",
|
|
||||||
"ws": "^8.19.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^4.1.0",
|
"@vitejs/plugin-vue": "^4.1.0",
|
||||||
|
|||||||
235
server.js
235
server.js
@@ -1,235 +0,0 @@
|
|||||||
import { createServer } from 'http'
|
|
||||||
import express from 'express'
|
|
||||||
import { WebSocketServer } from 'ws'
|
|
||||||
import { fileURLToPath } from 'url'
|
|
||||||
import { dirname, join } from 'path'
|
|
||||||
|
|
||||||
// Import shared game logic
|
|
||||||
// We need to read it as a dynamic import since it uses ES modules
|
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
|
||||||
const __dirname = dirname(__filename)
|
|
||||||
|
|
||||||
// Inline the game state logic for the server (avoid complex ESM import from src/)
|
|
||||||
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: [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyAction(state, action) {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// ——— Server Setup ———
|
|
||||||
|
|
||||||
const app = express()
|
|
||||||
const PORT = process.env.PORT || 3000
|
|
||||||
|
|
||||||
// Serve the Vite build output
|
|
||||||
app.use(express.static(join(__dirname, 'dist')))
|
|
||||||
|
|
||||||
// SPA fallback: serve index.html for all non-file routes
|
|
||||||
app.get('/{*splat}', (req, res) => {
|
|
||||||
res.sendFile(join(__dirname, 'dist', 'index.html'))
|
|
||||||
})
|
|
||||||
|
|
||||||
const server = createServer(app)
|
|
||||||
|
|
||||||
// WebSocket server
|
|
||||||
const wss = new WebSocketServer({ server })
|
|
||||||
|
|
||||||
// Global game state
|
|
||||||
let gameState = createInitialState()
|
|
||||||
|
|
||||||
// Track client roles
|
|
||||||
const clients = new Map() // ws -> { role: 'display' | 'controller' }
|
|
||||||
|
|
||||||
wss.on('connection', (ws) => {
|
|
||||||
console.log('New WebSocket connection')
|
|
||||||
|
|
||||||
ws.on('message', (data) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(data.toString())
|
|
||||||
|
|
||||||
if (msg.type === 'register') {
|
|
||||||
clients.set(ws, { role: msg.role || 'display' })
|
|
||||||
console.log(`Client registered as: ${msg.role || 'display'}`)
|
|
||||||
// Send current state immediately
|
|
||||||
ws.send(JSON.stringify({ type: 'state', state: gameState }))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.type === 'action') {
|
|
||||||
// Only controllers can send actions
|
|
||||||
const client = clients.get(ws)
|
|
||||||
if (!client || client.role !== 'controller') {
|
|
||||||
console.log('Action rejected: not a controller')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply the action to game state
|
|
||||||
gameState = applyAction(gameState, msg.action)
|
|
||||||
|
|
||||||
// Broadcast new state to ALL connected clients
|
|
||||||
const stateMsg = JSON.stringify({ type: 'state', state: gameState })
|
|
||||||
wss.clients.forEach((c) => {
|
|
||||||
if (c.readyState === 1) { // WebSocket.OPEN
|
|
||||||
c.send(stateMsg)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error processing message:', err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
ws.on('close', () => {
|
|
||||||
clients.delete(ws)
|
|
||||||
console.log('Client disconnected')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
server.listen(PORT, '0.0.0.0', () => {
|
|
||||||
console.log(`\n🏐 Segnapunti Server running on:`)
|
|
||||||
console.log(` Display: http://localhost:${PORT}/`)
|
|
||||||
console.log(` Controller: http://localhost:${PORT}/controller`)
|
|
||||||
console.log(`\n Per accedere da altri dispositivi sulla rete locale,`)
|
|
||||||
console.log(` usa l'IP di questo computer, es: http://192.168.1.x:${PORT}/controller\n`)
|
|
||||||
})
|
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
<script setup>
|
||||||
|
import HomePage from './components/HomePage/index.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<router-view />
|
<HomePage />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,704 +0,0 @@
|
|||||||
<template>
|
|
||||||
<section class="controller-page">
|
|
||||||
<!-- Connection status bar -->
|
|
||||||
<div class="conn-bar" :class="{ connected: wsConnected }">
|
|
||||||
<span class="dot"></span>
|
|
||||||
{{ wsConnected ? 'Connesso' : 'Connessione...' }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Score preview -->
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Undo row -->
|
|
||||||
<div class="undo-row">
|
|
||||||
<button class="btn btn-undo" @click="sendAction({ type: 'decPunt' })">
|
|
||||||
↩ ANNULLA PUNTO
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Set buttons -->
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Controls -->
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Reset confirmation -->
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Config dialog -->
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Cambi team selection -->
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Cambi dialog -->
|
|
||||||
<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,
|
|
||||||
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()
|
|
||||||
},
|
|
||||||
beforeUnmount() {
|
|
||||||
if (this.ws) this.ws.close()
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
connectWebSocket() {
|
|
||||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
||||||
const wsUrl = `${protocol}//${location.host}`
|
|
||||||
this.ws = new WebSocket(wsUrl)
|
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
|
||||||
this.wsConnected = true
|
|
||||||
this.ws.send(JSON.stringify({ type: 'register', role: 'controller' }))
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(event.data)
|
|
||||||
if (msg.type === 'state') {
|
|
||||||
this.state = msg.state
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('WS parse error:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ws.onclose = () => {
|
|
||||||
this.wsConnected = false
|
|
||||||
setTimeout(() => this.connectWebSocket(), 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ws.onerror = () => { this.wsConnected = false }
|
|
||||||
},
|
|
||||||
|
|
||||||
sendAction(action) {
|
|
||||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
||||||
this.ws.send(JSON.stringify({ type: 'action', action }))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Connection bar */
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Score preview */
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Undo row */
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Set buttons */
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Controls grid */
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Overlays & Dialogs */
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form groups */
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form grid */
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mode buttons */
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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>
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
<template>
|
|
||||||
<section class="display-page">
|
|
||||||
<div class="campo">
|
|
||||||
<span v-if="state.order">
|
|
||||||
<!-- 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>
|
|
||||||
<!-- 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>
|
|
||||||
|
|
||||||
<!-- Connection status indicator -->
|
|
||||||
<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,
|
|
||||||
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()
|
|
||||||
// Fullscreen on mobile
|
|
||||||
if (this.isMobile()) {
|
|
||||||
try { document.documentElement.requestFullscreen() } catch (e) {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
beforeUnmount() {
|
|
||||||
if (this.ws) {
|
|
||||||
this.ws.close()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
isMobile() {
|
|
||||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
|
|
||||||
},
|
|
||||||
connectWebSocket() {
|
|
||||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
||||||
const wsUrl = `${protocol}//${location.host}`
|
|
||||||
this.ws = new WebSocket(wsUrl)
|
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
|
||||||
this.wsConnected = true
|
|
||||||
// Register as display
|
|
||||||
this.ws.send(JSON.stringify({ type: 'register', role: 'display' }))
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(event.data)
|
|
||||||
if (msg.type === 'state') {
|
|
||||||
this.state = msg.state
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error parsing WS message:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ws.onclose = () => {
|
|
||||||
this.wsConnected = false
|
|
||||||
// Auto-reconnect after 2 seconds
|
|
||||||
setTimeout(() => this.connectWebSocket(), 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ws.onerror = () => {
|
|
||||||
this.wsConnected = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</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>
|
|
||||||
204
src/gameState.js
204
src/gameState.js
@@ -1,204 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared game logic for segnapunti.
|
|
||||||
* Used by both the WebSocket server and the client-side for local preview.
|
|
||||||
*/
|
|
||||||
|
|
||||||
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) {
|
|
||||||
// Deep-clone to avoid mutation issues (server-side)
|
|
||||||
// Returns new state
|
|
||||||
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,21 +1,10 @@
|
|||||||
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')
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { VitePWA } from 'vite-plugin-pwa'
|
|||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: '/',
|
base: process.env.NODE_ENV === 'production' ? '/segnap' : '/',
|
||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
|
|||||||
Reference in New Issue
Block a user