Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c52f1761e | |||
| 805021dcf5 | |||
| 1fdf059261 | |||
| 58554c3c61 | |||
| c4599a2722 | |||
| e179295af3 | |||
| d11b47c809 | |||
| 0441b0c7f5 |
@@ -91,3 +91,4 @@ Il volume `./.segnapunti` persiste lo stato tra i riavvii del container.
|
||||
- Qualsiasi modifica al protocollo WebSocket va fatta in `src/websocket-handler.js`.
|
||||
- Aggiungere test unit in `tests/unit/gameState.test.js` per ogni nuovo action type o regola di gioco.
|
||||
- Il codice deve essere scritto in italiano per commenti e nomi di variabili di dominio (es. `servHome`, `striscia`, `nomi`), ma in inglese per nomi tecnici standard (`state`, `action`, `handler`).
|
||||
- Non inserire emoji in codice, UI (label, testi dei bottoni, banner) o commenti, a meno che l'utente non le richieda esplicitamente.
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Banner modalità time out attiva -->
|
||||
<div class="timeout-banner" v-if="state.timeout">⏱ MODALITÀ TIME OUT ATTIVA</div>
|
||||
<div class="timeout-banner" v-if="state.timeout">
|
||||
TIME OUT {{ state.sp.nomi[state.timeoutTeam] }} — {{ timeoutCountdown }}
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════ MODALITÀ MOBILE ══════════════════ -->
|
||||
<template v-if="!isEstesa">
|
||||
@@ -67,8 +69,9 @@
|
||||
<button class="btn btn-ctrl" @click="openCambiTeam()">
|
||||
Cambi
|
||||
</button>
|
||||
<button class="btn btn-ctrl btn-timeout" :class="{ active: state.timeout }" @click="sendAction({ type: 'toggleTimeout' })">
|
||||
⏱ Time Out
|
||||
<button class="btn btn-ctrl btn-timeout" :class="{ active: state.timeout }"
|
||||
@click="state.timeout ? terminaTimeout() : openTimeout()">
|
||||
{{ state.timeout ? timeoutCountdown : 'Time Out' }}
|
||||
</button>
|
||||
<button class="btn btn-danger" @click="confirmReset = true">
|
||||
Reset
|
||||
@@ -141,14 +144,15 @@
|
||||
|
||||
<!-- Barra azioni secondarie -->
|
||||
<div class="e-actions">
|
||||
<button class="btn e-act e-act--undo" @click="sendAction({ type: 'decPunt' })">↩ Annulla</button>
|
||||
<button class="btn e-act e-act--undo" @click="sendAction({ type: 'decPunt' })">Annulla</button>
|
||||
<button class="btn e-act" :disabled="!isPunteggioZeroZero" @click="sendAction({ type: 'cambiaPalla' })">Cambio Palla</button>
|
||||
<button class="btn e-act" @click="openCambiTeam()">Cambi</button>
|
||||
<button class="btn e-act" @click="sendAction({ type: 'toggleOrder' })">Inverti</button>
|
||||
<button class="btn e-act" @click="sendAction({ type: 'toggleStriscia' })">Striscia {{ state.visuStriscia ? 'ON' : 'OFF' }}</button>
|
||||
<button class="btn e-act" @click="speak()">Voce</button>
|
||||
<button class="btn e-act" @click="openConfig()">Config</button>
|
||||
<button class="btn e-act btn-timeout" :class="{ active: state.timeout }" @click="sendAction({ type: 'toggleTimeout' })">⏱ Time Out</button>
|
||||
<button class="btn e-act btn-timeout" :class="{ active: state.timeout }"
|
||||
@click="state.timeout ? terminaTimeout() : openTimeout()">{{ state.timeout ? timeoutCountdown : 'Time Out' }}</button>
|
||||
<button class="btn e-act e-act--danger" @click="confirmReset = true">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -277,6 +281,43 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Finestra avvio time out: squadra + scelta video -->
|
||||
<div class="overlay" v-if="showTimeoutModal" @click.self="showTimeoutModal = false">
|
||||
<div class="dialog dialog-timeout">
|
||||
<div class="dialog-title">Time Out</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Squadra che lo ha richiesto</label>
|
||||
<div class="dialog-buttons">
|
||||
<button class="btn btn-set home-bg" :class="{ 'team-sel-active': timeoutTeamSel === 'home' }"
|
||||
@click="timeoutTeamSel = 'home'">{{ state.sp.nomi.home }}</button>
|
||||
<button class="btn btn-set guest-bg" :class="{ 'team-sel-active': timeoutTeamSel === 'guest' }"
|
||||
@click="timeoutTeamSel = 'guest'">{{ state.sp.nomi.guest }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Video (30s nominali)</label>
|
||||
<div class="timeout-video-list">
|
||||
<label class="timeout-video-option" v-for="file in spotList" :key="file">
|
||||
<input type="radio" name="timeoutVideo" :value="file" v-model="timeoutVideoSel" />
|
||||
{{ file }}
|
||||
<span class="video-duration">{{ formatDurata(spotDurations[file]) }}</span>
|
||||
</label>
|
||||
<label class="timeout-video-option">
|
||||
<input type="radio" name="timeoutVideo" :value="null" v-model="timeoutVideoSel" />
|
||||
Nessun video (overlay "TIME OUT")
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dialog-buttons">
|
||||
<button class="btn btn-cancel" @click="showTimeoutModal = false">Annulla</button>
|
||||
<button class="btn btn-confirm" :disabled="!timeoutTeamSel" @click="avviaTimeout()">Avvia</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -304,6 +345,13 @@ export default {
|
||||
formGuest: ["1", "2", "3", "4", "5", "6"],
|
||||
},
|
||||
isLandscape: window.innerWidth > window.innerHeight,
|
||||
showTimeoutModal: false,
|
||||
timeoutTeamSel: null,
|
||||
timeoutVideoSel: null,
|
||||
spotList: [],
|
||||
spotDurations: {},
|
||||
nowTick: Date.now(),
|
||||
timeoutTicker: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -338,6 +386,17 @@ export default {
|
||||
}
|
||||
return hasComplete
|
||||
},
|
||||
timeoutRemaining() {
|
||||
if (!this.state.timeout || !this.state.timeoutStartedAt) return 30
|
||||
const trascorsi = (this.nowTick - this.state.timeoutStartedAt) / 1000
|
||||
return Math.max(0, Math.ceil(30 - trascorsi))
|
||||
},
|
||||
timeoutCountdown() {
|
||||
const r = this.timeoutRemaining
|
||||
const m = String(Math.floor(r / 60)).padStart(2, '0')
|
||||
const s = String(r % 60).padStart(2, '0')
|
||||
return `${m}:${s}`
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
squadraVincente(val) {
|
||||
@@ -346,6 +405,12 @@ export default {
|
||||
this.showSetVinto = true
|
||||
}
|
||||
},
|
||||
'state.timeout': {
|
||||
immediate: true,
|
||||
handler(attivo) {
|
||||
attivo ? this.avviaTimeoutTicker() : this.fermaTimeoutTicker()
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this._resizeHandler = () => { this.isLandscape = window.innerWidth > window.innerHeight }
|
||||
@@ -355,6 +420,7 @@ export default {
|
||||
beforeUnmount() {
|
||||
window.removeEventListener('resize', this._resizeHandler)
|
||||
window.removeEventListener('orientationchange', this._resizeHandler)
|
||||
this.fermaTimeoutTicker()
|
||||
},
|
||||
methods: {
|
||||
generaReferto,
|
||||
@@ -458,6 +524,47 @@ export default {
|
||||
: this.servHome ? `${home} a ${guest}` : `${guest} a ${home}`
|
||||
this.sendWs({ type: 'speak', text })
|
||||
},
|
||||
avviaTimeoutTicker() {
|
||||
this.fermaTimeoutTicker()
|
||||
this.nowTick = Date.now()
|
||||
this.timeoutTicker = setInterval(() => { this.nowTick = Date.now() }, 250)
|
||||
},
|
||||
fermaTimeoutTicker() {
|
||||
if (this.timeoutTicker) { clearInterval(this.timeoutTicker); this.timeoutTicker = null }
|
||||
},
|
||||
async openTimeout() {
|
||||
this.timeoutTeamSel = null
|
||||
this.timeoutVideoSel = null
|
||||
this.spotList = []
|
||||
this.spotDurations = {}
|
||||
this.showTimeoutModal = true
|
||||
try {
|
||||
const res = await fetch('/spot/list')
|
||||
this.spotList = await res.json()
|
||||
} catch {
|
||||
this.spotList = []
|
||||
}
|
||||
this.spotList.forEach(file => this.probeDurata(file))
|
||||
},
|
||||
probeDurata(file) {
|
||||
const video = document.createElement('video')
|
||||
video.preload = 'metadata'
|
||||
video.onloadedmetadata = () => {
|
||||
this.spotDurations = { ...this.spotDurations, [file]: video.duration }
|
||||
}
|
||||
video.src = '/spot/' + encodeURIComponent(file)
|
||||
},
|
||||
formatDurata(sec) {
|
||||
return typeof sec === 'number' && isFinite(sec) ? `${Math.round(sec)}s` : '…'
|
||||
},
|
||||
avviaTimeout() {
|
||||
if (!this.timeoutTeamSel) return
|
||||
this.sendAction({ type: 'startTimeout', team: this.timeoutTeamSel, video: this.timeoutVideoSel })
|
||||
this.showTimeoutModal = false
|
||||
},
|
||||
terminaTimeout() {
|
||||
this.sendAction({ type: 'stopTimeout' })
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -1070,4 +1177,37 @@ export default {
|
||||
padding: 8px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Modal avvio time out */
|
||||
.dialog-timeout {
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.btn-set.team-sel-active {
|
||||
box-shadow: 0 0 0 3px rgba(255,255,255,0.6) inset;
|
||||
}
|
||||
.timeout-video-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.timeout-video-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.video-duration {
|
||||
margin-left: auto;
|
||||
color: #aaa;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -113,10 +113,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overlay time out: proietta i video spot a tutto schermo -->
|
||||
<!-- Overlay time out: proietta il video spot a tutto schermo -->
|
||||
<div v-if="state.timeout" class="timeout-overlay">
|
||||
<video v-if="spotList.length" ref="spotVideo" class="timeout-video" playsinline></video>
|
||||
<div v-else class="timeout-placeholder">TIME OUT</div>
|
||||
<div class="timeout-countdown">{{ timeoutCountdown }}</div>
|
||||
<video v-if="state.timeoutVideo && !mostraPlaceholder" ref="spotVideo" class="timeout-video"
|
||||
:class="{ 'timeout-video--fading': timeoutFading }" playsinline></video>
|
||||
<div v-else class="timeout-placeholder">
|
||||
TIME OUT<br />{{ state.sp.nomi[state.timeoutTeam] }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -129,16 +133,38 @@ export default {
|
||||
mixins: [createWsMixin('display')],
|
||||
data() {
|
||||
return {
|
||||
spotList: [], // filename dei video spot da riprodurre
|
||||
spotIndex: 0, // video corrente nella sequenza
|
||||
mostraPlaceholder: false, // video terminato prima dei 30s: mostra l'immagine "TIME OUT"
|
||||
spotVideoDuration: null, // durata reale del video in riproduzione, nota dopo il loadedmetadata
|
||||
nowTick: Date.now(),
|
||||
timeoutTicker: null,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.isMobile()) {
|
||||
try { document.documentElement.requestFullscreen() } catch (e) {}
|
||||
}
|
||||
if (this.state.timeout) this.avviaTimeout()
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.fermaTimeoutTicker()
|
||||
},
|
||||
computed: {
|
||||
timeoutRemaining() {
|
||||
if (!this.state.timeout || !this.state.timeoutStartedAt) return 30
|
||||
const trascorsi = (this.nowTick - this.state.timeoutStartedAt) / 1000
|
||||
return Math.max(0, Math.ceil(30 - trascorsi))
|
||||
},
|
||||
timeoutCountdown() {
|
||||
const r = this.timeoutRemaining
|
||||
const m = String(Math.floor(r / 60)).padStart(2, '0')
|
||||
const s = String(r % 60).padStart(2, '0')
|
||||
return `${m}:${s}`
|
||||
},
|
||||
timeoutFading() {
|
||||
// Video più lungo di 30s: dissolvenza negli ultimi istanti prima dello stop del server.
|
||||
if (!this.spotVideoDuration || this.spotVideoDuration <= 30) return false
|
||||
return this.timeoutRemaining <= 1
|
||||
},
|
||||
stricciaStrip() {
|
||||
const currentSet = this.state.sp.striscia.at(-1)
|
||||
if (!currentSet) return { home: [], guest: [] }
|
||||
@@ -168,32 +194,41 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
avviaTimeoutTicker() {
|
||||
this.fermaTimeoutTicker()
|
||||
this.nowTick = Date.now()
|
||||
this.timeoutTicker = setInterval(() => { this.nowTick = Date.now() }, 200)
|
||||
},
|
||||
fermaTimeoutTicker() {
|
||||
if (this.timeoutTicker) { clearInterval(this.timeoutTicker); this.timeoutTicker = null }
|
||||
},
|
||||
async avviaTimeout() {
|
||||
this.spotIndex = 0
|
||||
try {
|
||||
const res = await fetch('/spot/list')
|
||||
this.spotList = await res.json()
|
||||
} catch {
|
||||
this.spotList = []
|
||||
}
|
||||
if (!this.spotList.length) return
|
||||
this.mostraPlaceholder = !this.state.timeoutVideo
|
||||
this.spotVideoDuration = null
|
||||
this.avviaTimeoutTicker()
|
||||
if (!this.state.timeoutVideo) return
|
||||
await this.$nextTick()
|
||||
this.caricaSpotCorrente()
|
||||
},
|
||||
fermaTimeout() {
|
||||
this.fermaTimeoutTicker()
|
||||
const video = this.$refs.spotVideo
|
||||
if (video) { video.pause(); video.removeAttribute('src'); video.load() }
|
||||
this.spotList = []
|
||||
this.mostraPlaceholder = false
|
||||
this.spotVideoDuration = null
|
||||
},
|
||||
caricaSpotCorrente() {
|
||||
const video = this.$refs.spotVideo
|
||||
if (!video || !this.spotList.length) return
|
||||
video.src = '/spot/' + encodeURIComponent(this.spotList[this.spotIndex])
|
||||
// Avanza al prossimo video alla fine (loop: con un solo video torna su sé stesso)
|
||||
video.onended = () => {
|
||||
this.spotIndex = (this.spotIndex + 1) % this.spotList.length
|
||||
this.caricaSpotCorrente()
|
||||
if (!video || !this.state.timeoutVideo) return
|
||||
video.src = '/spot/' + encodeURIComponent(this.state.timeoutVideo)
|
||||
video.onloadedmetadata = () => {
|
||||
this.spotVideoDuration = video.duration
|
||||
// Riconnessione a metà time out: riallinea il video al punto corretto.
|
||||
const trascorsi = this.state.timeoutStartedAt ? (Date.now() - this.state.timeoutStartedAt) / 1000 : 0
|
||||
if (trascorsi > 0.5 && trascorsi < video.duration) video.currentTime = trascorsi
|
||||
}
|
||||
// Video più corto dei 30s: a fine riproduzione mostra l'immagine "TIME OUT" fino allo stop del server.
|
||||
video.onended = () => { this.mostraPlaceholder = true }
|
||||
this.playSpot()
|
||||
},
|
||||
playSpot() {
|
||||
@@ -232,7 +267,8 @@ export default {
|
||||
.display-page {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: #000;
|
||||
background: #111;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -255,7 +291,7 @@ export default {
|
||||
}
|
||||
|
||||
.connection-status.disconnected {
|
||||
background: rgba(255, 50, 50, 0.8);
|
||||
background: #c62828;
|
||||
color: white;
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -268,11 +304,11 @@ export default {
|
||||
}
|
||||
|
||||
.connected .dot {
|
||||
background: #4caf50;
|
||||
background: #2e7d32;
|
||||
}
|
||||
|
||||
.disconnected .dot {
|
||||
background: #f44336;
|
||||
background: #ff6b6b;
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
@@ -292,6 +328,8 @@ export default {
|
||||
|
||||
.punt {
|
||||
font-size: 60vh;
|
||||
font-weight: 900;
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -317,13 +355,42 @@ export default {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
transition: opacity 0.8s ease-out;
|
||||
}
|
||||
|
||||
.timeout-video--fading {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.timeout-countdown {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
font-size: 4vh;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 6px 18px;
|
||||
border-radius: 12px;
|
||||
z-index: 501;
|
||||
}
|
||||
|
||||
.timeout-placeholder {
|
||||
color: #fff;
|
||||
font-size: 20vh;
|
||||
color: #ff9800;
|
||||
font-size: 14vh;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
animation: timeout-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes timeout-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.55; }
|
||||
}
|
||||
</style>
|
||||
|
||||
+18
-2
@@ -22,6 +22,9 @@ export function createInitialState() {
|
||||
visuForm: false,
|
||||
visuStriscia: true,
|
||||
timeout: false,
|
||||
timeoutTeam: null,
|
||||
timeoutVideo: null,
|
||||
timeoutStartedAt: null,
|
||||
modalitaPartita: "3/5",
|
||||
sp: {
|
||||
striscia: [{ serv: 'h', ris: '', vinc: null }],
|
||||
@@ -165,8 +168,21 @@ export function applyAction(state, action) {
|
||||
break
|
||||
}
|
||||
|
||||
case "toggleTimeout": {
|
||||
s.timeout = !s.timeout
|
||||
case "startTimeout": {
|
||||
const team = action.team
|
||||
if (team !== 'home' && team !== 'guest') break
|
||||
s.timeout = true
|
||||
s.timeoutTeam = team
|
||||
s.timeoutVideo = action.video ?? null
|
||||
s.timeoutStartedAt = action.startedAt ?? null
|
||||
break
|
||||
}
|
||||
|
||||
case "stopTimeout": {
|
||||
s.timeout = false
|
||||
s.timeoutTeam = null
|
||||
s.timeoutVideo = null
|
||||
s.timeoutStartedAt = null
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
+78
-10
@@ -46,11 +46,31 @@ button:focus-visible {
|
||||
width: 100%;
|
||||
display: table;
|
||||
color: #fff;
|
||||
position: relative;
|
||||
}
|
||||
/* Linea centrale che separa i due campi, come un tabellone reale */
|
||||
.campo::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 2px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.hea {
|
||||
float: left;
|
||||
width: 50%;
|
||||
font-size: xx-large;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 18px 4px 12px;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 3px solid currentColor;
|
||||
opacity: 0.96;
|
||||
}
|
||||
.hea span {
|
||||
/* Bordo di debug: border: 1px solid #f3fb00; */
|
||||
@@ -70,6 +90,27 @@ button:focus-visible {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
vertical-align: middle;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
.mr3, .ml3 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 3vh;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 4px 16px;
|
||||
margin-top: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.mr3 {
|
||||
margin-right: 14px;
|
||||
}
|
||||
.ml3 {
|
||||
margin-left: 14px;
|
||||
}
|
||||
.tal {
|
||||
text-align: left;
|
||||
@@ -110,10 +151,11 @@ button:focus-visible {
|
||||
max-width: 50vw;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
text-shadow: 0 0 40px currentColor;
|
||||
}
|
||||
.form {
|
||||
font-size: 5vh;
|
||||
border-top: #fff dashed 25px;
|
||||
border-top: 2px solid rgba(255, 255, 255, 0.18);
|
||||
padding-top: 50px;
|
||||
}
|
||||
.formtit {
|
||||
@@ -125,14 +167,23 @@ button:focus-visible {
|
||||
font-size: 20vh;
|
||||
float: left;
|
||||
width: 32%;
|
||||
box-sizing: border-box;
|
||||
margin: 3px 0;
|
||||
color: #ddd;
|
||||
font-weight: 700;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.text-bold {
|
||||
font-weight: 800;
|
||||
}
|
||||
.home {
|
||||
background-color: black;
|
||||
color: yellow;
|
||||
background-color: transparent;
|
||||
color: #f5c518;
|
||||
}
|
||||
.guest {
|
||||
background-color: blue;
|
||||
color: white
|
||||
background-color: transparent;
|
||||
color: #2196f3;
|
||||
}
|
||||
.striscia {
|
||||
position: fixed;
|
||||
@@ -141,17 +192,28 @@ button:focus-visible {
|
||||
right: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
row-gap: 2px;
|
||||
row-gap: 6px;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(6px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
z-index: 2;
|
||||
}
|
||||
.striscia-nome {
|
||||
white-space: nowrap;
|
||||
padding-right: 6px;
|
||||
padding-right: 10px;
|
||||
font-size: 2.2vh;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.striscia-items {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
gap: 2px;
|
||||
}
|
||||
.striscia .item {
|
||||
width: 25px;
|
||||
@@ -159,11 +221,17 @@ button:focus-visible {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
border-radius: 5px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.striscia .item:not(.item-vuoto) {
|
||||
background-color: rgb(206, 247, 3);
|
||||
color: blue;
|
||||
background-color: #f5c518;
|
||||
color: #111;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.striscia-items.guest-striscia .item:not(.item-vuoto) {
|
||||
background-color: #2196f3;
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.campo-config {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { createInitialState, applyAction } from './gameState.js'
|
||||
* @param {WebSocketServer} wss - Istanza del server WebSocket.
|
||||
* @returns {Object} Oggetto con metodi di gestione dello stato.
|
||||
*/
|
||||
const DURATA_TIMEOUT_MS = 30000
|
||||
|
||||
export function setupWebSocketHandler(wss, options = {}) {
|
||||
// Stato globale della partita.
|
||||
let gameState = options.initialState ?? createInitialState()
|
||||
@@ -12,6 +14,17 @@ export function setupWebSocketHandler(wss, options = {}) {
|
||||
// Mappa dei ruoli associati ai client connessi.
|
||||
const clients = new Map() // ws -> { role: 'display' | 'controller' }
|
||||
|
||||
// Timer che chiude automaticamente il time out dopo 30s, indipendentemente
|
||||
// dal controller (sopravvive a refresh/disconnessione/standby del tablet).
|
||||
let timeoutTimer = null
|
||||
|
||||
function annullaTimerTimeout() {
|
||||
if (timeoutTimer) {
|
||||
clearTimeout(timeoutTimer)
|
||||
timeoutTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gestisce i messaggi in arrivo dal client
|
||||
*/
|
||||
@@ -87,10 +100,16 @@ export function setupWebSocketHandler(wss, options = {}) {
|
||||
return
|
||||
}
|
||||
|
||||
// Il momento di inizio del time out lo decide il server (non il client),
|
||||
// cosi' gameState.js resta puro/deterministico e testabile.
|
||||
const action = msg.action.type === 'startTimeout'
|
||||
? { ...msg.action, startedAt: Date.now() }
|
||||
: msg.action
|
||||
|
||||
// Applica l'azione allo stato della partita.
|
||||
const previousState = gameState
|
||||
try {
|
||||
gameState = applyAction(gameState, msg.action)
|
||||
gameState = applyAction(gameState, action)
|
||||
} catch (err) {
|
||||
console.error('Error applying action:', err)
|
||||
sendError(ws, 'Failed to apply action')
|
||||
@@ -98,6 +117,20 @@ export function setupWebSocketHandler(wss, options = {}) {
|
||||
return
|
||||
}
|
||||
|
||||
// Qualsiasi azione che tocca il time out invalida il timer di auto-chiusura
|
||||
// pendente; se il time out e' (ri)partito, ne arma uno nuovo.
|
||||
if (action.type === 'startTimeout' || action.type === 'stopTimeout') {
|
||||
annullaTimerTimeout()
|
||||
}
|
||||
if (action.type === 'startTimeout' && gameState.timeout) {
|
||||
timeoutTimer = setTimeout(() => {
|
||||
timeoutTimer = null
|
||||
gameState = applyAction(gameState, { type: 'stopTimeout' })
|
||||
broadcastState()
|
||||
options.onStateChange?.(gameState)
|
||||
}, DURATA_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
// Propaga il nuovo stato a tutti i client connessi.
|
||||
broadcastState()
|
||||
options.onStateChange?.(gameState)
|
||||
|
||||
@@ -164,6 +164,117 @@ describe('WebSocket Integration (websocket-handler.js)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// TIME OUT (timer server-side)
|
||||
// =============================================
|
||||
describe('Time out', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function inviaStartTimeout(controller, overrides = {}) {
|
||||
controller.emit('message', JSON.stringify({
|
||||
type: 'action',
|
||||
action: { type: 'startTimeout', team: 'home', video: 'spot.mp4', ...overrides }
|
||||
}))
|
||||
}
|
||||
|
||||
it('dovrebbe iniettare startedAt lato server (ignorando quello del client)', () => {
|
||||
vi.setSystemTime(50000)
|
||||
const controller = connectAndRegister(wss, 'controller')
|
||||
|
||||
inviaStartTimeout(controller, { startedAt: 12345 })
|
||||
|
||||
const state = handler.getState()
|
||||
expect(state.timeout).toBe(true)
|
||||
expect(state.timeoutStartedAt).toBe(50000)
|
||||
})
|
||||
|
||||
it('dovrebbe chiudere automaticamente il time out dopo 30 secondi', () => {
|
||||
const controller = connectAndRegister(wss, 'controller')
|
||||
const display = connectAndRegister(wss, 'display')
|
||||
|
||||
inviaStartTimeout(controller)
|
||||
expect(handler.getState().timeout).toBe(true)
|
||||
|
||||
vi.advanceTimersByTime(29999)
|
||||
expect(handler.getState().timeout).toBe(true)
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
const state = handler.getState()
|
||||
expect(state.timeout).toBe(false)
|
||||
expect(state.timeoutTeam).toBe(null)
|
||||
expect(state.timeoutVideo).toBe(null)
|
||||
expect(state.timeoutStartedAt).toBe(null)
|
||||
|
||||
// La chiusura automatica viene broadcastata ai client
|
||||
const msg = lastSent(display)
|
||||
expect(msg.type).toBe('state')
|
||||
expect(msg.state.timeout).toBe(false)
|
||||
})
|
||||
|
||||
it('la chiusura automatica dovrebbe persistere lo stato (onStateChange)', () => {
|
||||
const onStateChange = vi.fn()
|
||||
wss = new MockWebSocketServer()
|
||||
handler = setupWebSocketHandler(wss, { onStateChange })
|
||||
const controller = connectAndRegister(wss, 'controller')
|
||||
|
||||
inviaStartTimeout(controller)
|
||||
onStateChange.mockClear()
|
||||
|
||||
vi.advanceTimersByTime(30000)
|
||||
expect(onStateChange).toHaveBeenCalledTimes(1)
|
||||
expect(onStateChange.mock.calls[0][0].timeout).toBe(false)
|
||||
})
|
||||
|
||||
it('stopTimeout manuale dovrebbe annullare il timer pendente', () => {
|
||||
const controller = connectAndRegister(wss, 'controller')
|
||||
|
||||
inviaStartTimeout(controller)
|
||||
controller.emit('message', JSON.stringify({
|
||||
type: 'action',
|
||||
action: { type: 'stopTimeout' }
|
||||
}))
|
||||
expect(handler.getState().timeout).toBe(false)
|
||||
controller.send.mockClear()
|
||||
|
||||
// Allo scadere dei 30s non deve partire nessun broadcast fantasma
|
||||
vi.advanceTimersByTime(30000)
|
||||
expect(controller.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('un nuovo startTimeout dovrebbe riarmare il timer da capo', () => {
|
||||
const controller = connectAndRegister(wss, 'controller')
|
||||
|
||||
inviaStartTimeout(controller)
|
||||
vi.advanceTimersByTime(20000)
|
||||
|
||||
// Riavvio: il timer riparte da zero
|
||||
inviaStartTimeout(controller, { team: 'guest' })
|
||||
vi.advanceTimersByTime(20000)
|
||||
expect(handler.getState().timeout).toBe(true)
|
||||
expect(handler.getState().timeoutTeam).toBe('guest')
|
||||
|
||||
vi.advanceTimersByTime(10000)
|
||||
expect(handler.getState().timeout).toBe(false)
|
||||
})
|
||||
|
||||
it('startTimeout con squadra non valida non dovrebbe armare il timer', () => {
|
||||
const controller = connectAndRegister(wss, 'controller')
|
||||
|
||||
inviaStartTimeout(controller, { team: 'arbitro' })
|
||||
expect(handler.getState().timeout).toBe(false)
|
||||
controller.send.mockClear()
|
||||
|
||||
vi.advanceTimersByTime(30000)
|
||||
expect(controller.send).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// BROADCAST MULTI-CLIENT
|
||||
// =============================================
|
||||
|
||||
@@ -80,8 +80,11 @@ describe('Game Logic (gameState.js)', () => {
|
||||
expect(state.visuStriscia).toBe(true)
|
||||
})
|
||||
|
||||
it('dovrebbe avere timeout false', () => {
|
||||
it('dovrebbe avere timeout false e campi timeout nulli', () => {
|
||||
expect(state.timeout).toBe(false)
|
||||
expect(state.timeoutTeam).toBe(null)
|
||||
expect(state.timeoutVideo).toBe(null)
|
||||
expect(state.timeoutStartedAt).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -484,12 +487,50 @@ describe('Game Logic (gameState.js)', () => {
|
||||
expect(s.order).toBe(false)
|
||||
})
|
||||
|
||||
it('toggleTimeout: false → true → false', () => {
|
||||
expect(state.timeout).toBe(false)
|
||||
const s1 = applyAction(state, { type: 'toggleTimeout' })
|
||||
expect(s1.timeout).toBe(true)
|
||||
const s2 = applyAction(s1, { type: 'toggleTimeout' })
|
||||
expect(s2.timeout).toBe(false)
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// TIME OUT (startTimeout, stopTimeout)
|
||||
// =============================================
|
||||
describe('Time out', () => {
|
||||
it('startTimeout dovrebbe attivare il time out con squadra, video e inizio', () => {
|
||||
const s = applyAction(state, { type: 'startTimeout', team: 'home', video: 'spot.mp4', startedAt: 1000 })
|
||||
expect(s.timeout).toBe(true)
|
||||
expect(s.timeoutTeam).toBe('home')
|
||||
expect(s.timeoutVideo).toBe('spot.mp4')
|
||||
expect(s.timeoutStartedAt).toBe(1000)
|
||||
})
|
||||
|
||||
it('startTimeout senza video dovrebbe lasciare timeoutVideo null', () => {
|
||||
const s = applyAction(state, { type: 'startTimeout', team: 'guest', startedAt: 1000 })
|
||||
expect(s.timeout).toBe(true)
|
||||
expect(s.timeoutTeam).toBe('guest')
|
||||
expect(s.timeoutVideo).toBe(null)
|
||||
})
|
||||
|
||||
it('startTimeout senza squadra valida dovrebbe essere ignorato', () => {
|
||||
for (const team of [undefined, null, 'arbitro', '']) {
|
||||
const s = applyAction(state, { type: 'startTimeout', team, video: 'spot.mp4', startedAt: 1000 })
|
||||
expect(s.timeout).toBe(false)
|
||||
expect(s.timeoutTeam).toBe(null)
|
||||
expect(s.timeoutVideo).toBe(null)
|
||||
expect(s.timeoutStartedAt).toBe(null)
|
||||
}
|
||||
})
|
||||
|
||||
it('stopTimeout dovrebbe azzerare tutti i campi del time out', () => {
|
||||
const attivo = applyAction(state, { type: 'startTimeout', team: 'home', video: 'spot.mp4', startedAt: 1000 })
|
||||
const s = applyAction(attivo, { type: 'stopTimeout' })
|
||||
expect(s.timeout).toBe(false)
|
||||
expect(s.timeoutTeam).toBe(null)
|
||||
expect(s.timeoutVideo).toBe(null)
|
||||
expect(s.timeoutStartedAt).toBe(null)
|
||||
})
|
||||
|
||||
it('stopTimeout su time out già inattivo non dovrebbe avere effetti', () => {
|
||||
const s = applyAction(state, { type: 'stopTimeout' })
|
||||
expect(s.timeout).toBe(false)
|
||||
expect(s.timeoutTeam).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user