Add possibility to guess as a player
All checks were successful
Deploy Feature / deploy-feature (push) Successful in 25s

This commit is contained in:
Johnny322
2026-03-01 22:19:34 +01:00
parent 993b1a2c13
commit f440118fef
3 changed files with 106 additions and 77 deletions

View File

@@ -162,13 +162,19 @@ server.on('upgrade', (req, socket, head) => {
) )
ws.on('message', (buffer) => { ws.on('message', (buffer) => {
let parsed: { type?: string; state?: SessionState } | null = null let parsed: { type?: string; state?: SessionState; teamId?: string } | null = null
try { try {
parsed = JSON.parse(buffer.toString()) as { type?: string; state?: SessionState } parsed = JSON.parse(buffer.toString()) as {
type?: string
state?: SessionState
teamId?: string
}
} catch { } catch {
return return
} }
if (!parsed || parsed.type !== 'state:update' || !parsed.state) return if (!parsed) return
if (parsed.type === 'state:update' && parsed.state) {
const targetSession = ensureSession(gameId) const targetSession = ensureSession(gameId)
targetSession.state = parsed.state targetSession.state = parsed.state
targetSession.updatedAt = Date.now() targetSession.updatedAt = Date.now()
@@ -182,6 +188,20 @@ server.on('upgrade', (req, socket, head) => {
}, },
ws ws
) )
return
}
if (parsed.type === 'guess:request' && parsed.teamId) {
broadcast(
gameId,
{
type: 'guess:request',
gameId,
teamId: parsed.teamId
},
ws
)
}
}) })
ws.on('close', () => { ws.on('close', () => {

View File

@@ -266,6 +266,9 @@
<button v-if="showEnableAudio" class="primary enable-audio" @click="enableViewerAudio"> <button v-if="showEnableAudio" class="primary enable-audio" @click="enableViewerAudio">
Tap To Enable Audio Tap To Enable Audio
</button> </button>
<button v-if="canViewerGuess" class="primary viewer-guess" @click="requestGuessStop">
Guess Now
</button>
<div v-if="!currentClipUrl" class="player-empty"></div> <div v-if="!currentClipUrl" class="player-empty"></div>
</div> </div>
</div> </div>
@@ -331,13 +334,12 @@ type RealtimeState = {
currentSelectorId: string | null currentSelectorId: string | null
lastAwardedTeamId: string | null lastAwardedTeamId: string | null
isAnswerClip: boolean isAnswerClip: boolean
playbackPosition: number
playbackCapturedAt: number
} }
type RealtimeMessage = { type RealtimeMessage = {
type: 'session:init' | 'state:update' type: 'session:init' | 'state:update' | 'guess:request'
state: RealtimeState | null state?: RealtimeState | null
teamId?: string
} }
const REALTIME_BASE = '/realtime' const REALTIME_BASE = '/realtime'
@@ -395,7 +397,6 @@ export default {
isApplyingRemote: false, isApplyingRemote: false,
queuedRemoteState: null as RealtimeState | null, queuedRemoteState: null as RealtimeState | null,
syncTimer: 0, syncTimer: 0,
playbackSyncInterval: 0,
audioUnlocked: true, audioUnlocked: true,
latestRemoteState: null as RealtimeState | null, latestRemoteState: null as RealtimeState | null,
viewerTeamId: '' viewerTeamId: ''
@@ -433,6 +434,14 @@ export default {
showEnableAudio() { showEnableAudio() {
return !this.canControlGame && !!this.currentClipUrl && !this.audioUnlocked return !this.canControlGame && !!this.currentClipUrl && !this.audioUnlocked
}, },
canViewerGuess() {
return (
!this.canControlGame &&
this.audioUnlocked &&
!!this.viewerTeamId &&
this.getCurrentTileStatus() === 'playing'
)
},
canControlGame() { canControlGame() {
return !this.gameId || this.isHost return !this.gameId || this.isHost
}, },
@@ -562,7 +571,6 @@ export default {
this.audioUnlocked = true this.audioUnlocked = true
this.latestRemoteState = null this.latestRemoteState = null
this.viewerTeamId = '' this.viewerTeamId = ''
this.stopHostPlaybackSync()
this.setGameInUrl('') this.setGameInUrl('')
}, },
async connectSession() { async connectSession() {
@@ -585,6 +593,10 @@ export default {
if (!message) return if (!message) return
if (message.type === 'session:init' || message.type === 'state:update') { if (message.type === 'session:init' || message.type === 'state:update') {
if (message.state) await this.applyRemoteState(message.state) if (message.state) await this.applyRemoteState(message.state)
return
}
if (message.type === 'guess:request') {
await this.handleRemoteGuessRequest(message.teamId || '')
} }
} }
@@ -611,7 +623,6 @@ export default {
}).catch(() => {}) }).catch(() => {})
}, },
buildRealtimeState(): RealtimeState { buildRealtimeState(): RealtimeState {
const player = this.getPlayer()
return { return {
step: this.step, step: this.step,
teams: this.teams.map((team) => ({ ...team })), teams: this.teams.map((team) => ({ ...team })),
@@ -621,9 +632,7 @@ export default {
currentClipUrl: this.currentClipUrl, currentClipUrl: this.currentClipUrl,
currentSelectorId: this.currentSelectorId, currentSelectorId: this.currentSelectorId,
lastAwardedTeamId: this.lastAwardedTeamId, lastAwardedTeamId: this.lastAwardedTeamId,
isAnswerClip: this.isAnswerClip, isAnswerClip: this.isAnswerClip
playbackPosition: player?.currentTime || 0,
playbackCapturedAt: Date.now()
} }
}, },
publishState() { publishState() {
@@ -673,36 +682,62 @@ export default {
this.isApplyingRemote = false this.isApplyingRemote = false
} }
await nextTick() await nextTick()
this.syncRemotePlayback(previousClipUrl !== this.currentClipUrl, state) this.syncRemotePlayback(previousClipUrl !== this.currentClipUrl)
}, },
async enableViewerAudio() { async enableViewerAudio() {
if (this.canControlGame) return if (this.canControlGame) return
this.audioUnlocked = true this.audioUnlocked = true
this.ensureAudioContext() this.ensureAudioContext()
await nextTick() await nextTick()
if (this.latestRemoteState) { this.syncRemotePlayback(false)
this.syncRemotePlayback(false, this.latestRemoteState) },
requestGuessStop() {
if (this.canControlGame) return
if (!this.socketConnected || !this.socket) return
if (!this.viewerTeamId) return
if (this.getCurrentTileStatus() !== 'playing') return
this.socket.send(
JSON.stringify({
type: 'guess:request',
teamId: this.viewerTeamId
})
)
},
async handleRemoteGuessRequest(teamId: string) {
if (!this.canControlGame || !this.isHost) return
if (!teamId) return
if (!this.currentTileKey || !this.selectedGame) return
const status = this.getCurrentTileStatus()
if (status !== 'playing') return
const key = this.currentTileKey
const [cIndex, qIndex] = key.split('-').map(Number)
const clue = this.selectedGame.categories[cIndex].clues[qIndex]
this.tiles[key].status = 'guessed'
this.lastAwardedTeamId = null
if (clue.answer) {
this.currentClipUrl = encodeURI(clue.answer)
this.isAnswerClip = true
await nextTick()
const player = this.getPlayer()
if (player) {
this.ensureAudioContext()
player.currentTime = 0
player.load()
player.play().catch(() => {})
} }
} else {
const player = this.getPlayer()
player?.pause()
this.currentClipUrl = ''
this.isAnswerClip = false
}
this.queueStateSync()
}, },
getCurrentTileStatus() { getCurrentTileStatus() {
if (!this.currentTileKey) return 'available' if (!this.currentTileKey) return 'available'
return this.tiles[this.currentTileKey]?.status || 'available' return this.tiles[this.currentTileKey]?.status || 'available'
}, },
setPlayerCurrentTime(player: HTMLAudioElement, targetTime: number) { syncRemotePlayback(forceReload: boolean) {
const seek = () => {
try {
player.currentTime = targetTime
} catch {
// ignore seek failures on unsupported ranges
}
}
if (player.readyState > 0) {
seek()
return
}
player.addEventListener('loadedmetadata', seek, { once: true })
},
syncRemotePlayback(forceReload: boolean, state: RealtimeState) {
if (this.canControlGame) return if (this.canControlGame) return
const player = this.getPlayer() const player = this.getPlayer()
if (!player) return if (!player) return
@@ -713,23 +748,11 @@ export default {
return return
} }
const elapsedSeconds = Math.max(0, (Date.now() - (state.playbackCapturedAt || Date.now())) / 1000)
let targetTime = state.playbackPosition || 0
if (tileStatus === 'playing' || tileStatus === 'guessed') {
targetTime += elapsedSeconds
}
if (Number.isFinite(player.duration) && player.duration > 0) {
targetTime = Math.min(targetTime, Math.max(0, player.duration - 0.05))
}
if (forceReload) { if (forceReload) {
player.currentTime = 0
player.load() player.load()
} }
if (Math.abs((player.currentTime || 0) - targetTime) > 0.2) {
this.setPlayerCurrentTime(player, targetTime)
}
if (tileStatus === 'paused') { if (tileStatus === 'paused') {
player.pause() player.pause()
return return
@@ -794,7 +817,6 @@ export default {
this.tentacleLevels = this.tentacleLevels.map(() => 0) this.tentacleLevels = this.tentacleLevels.map(() => 0)
}, },
teardownAudio() { teardownAudio() {
this.stopHostPlaybackSync()
if (this.rafId) { if (this.rafId) {
cancelAnimationFrame(this.rafId) cancelAnimationFrame(this.rafId)
this.rafId = 0 this.rafId = 0
@@ -873,25 +895,10 @@ export default {
if (this.rafId) cancelAnimationFrame(this.rafId) if (this.rafId) cancelAnimationFrame(this.rafId)
this.rafId = requestAnimationFrame(tick) this.rafId = requestAnimationFrame(tick)
}, },
startHostPlaybackSync() {
if (!this.isHost || !this.socketConnected || !this.socket) return
this.stopHostPlaybackSync()
this.playbackSyncInterval = window.setInterval(() => {
this.queueStateSync()
}, 400)
},
stopHostPlaybackSync() {
if (!this.playbackSyncInterval) return
window.clearInterval(this.playbackSyncInterval)
this.playbackSyncInterval = 0
},
handlePlayerPlay() { handlePlayerPlay() {
this.isPlaying = true this.isPlaying = true
this.startPulse() this.startPulse()
if (this.isHost) { if (this.isHost) this.queueStateSync()
this.startHostPlaybackSync()
this.queueStateSync()
}
}, },
handlePlayerPause() { handlePlayerPause() {
this.isPlaying = false this.isPlaying = false
@@ -900,10 +907,7 @@ export default {
this.rafId = 0 this.rafId = 0
} }
this.pulseLevel = 0 this.pulseLevel = 0
if (this.isHost) { if (this.isHost) this.queueStateSync()
this.stopHostPlaybackSync()
this.queueStateSync()
}
}, },
togglePlayback() { togglePlayback() {
const player = this.getPlayer() const player = this.getPlayer()
@@ -1125,7 +1129,6 @@ export default {
this.teardownAudio() this.teardownAudio()
this.closeSocket() this.closeSocket()
if (this.syncTimer) window.clearTimeout(this.syncTimer) if (this.syncTimer) window.clearTimeout(this.syncTimer)
if (this.playbackSyncInterval) window.clearInterval(this.playbackSyncInterval)
} }
} }
</script> </script>

View File

@@ -560,6 +560,12 @@ audio.hidden-audio {
z-index: 3; z-index: 3;
} }
.viewer-guess {
position: absolute;
bottom: 20px;
z-index: 3;
}
.end-panel { .end-panel {
text-align: center; text-align: center;
} }