Add possibility to guess as a player
All checks were successful
Deploy Feature / deploy-feature (push) Successful in 25s
All checks were successful
Deploy Feature / deploy-feature (push) Successful in 25s
This commit is contained in:
@@ -162,26 +162,46 @@ server.on('upgrade', (req, socket, head) => {
|
||||
)
|
||||
|
||||
ws.on('message', (buffer) => {
|
||||
let parsed: { type?: string; state?: SessionState } | null = null
|
||||
let parsed: { type?: string; state?: SessionState; teamId?: string } | null = null
|
||||
try {
|
||||
parsed = JSON.parse(buffer.toString()) as { type?: string; state?: SessionState }
|
||||
parsed = JSON.parse(buffer.toString()) as {
|
||||
type?: string
|
||||
state?: SessionState
|
||||
teamId?: string
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!parsed || parsed.type !== 'state:update' || !parsed.state) return
|
||||
const targetSession = ensureSession(gameId)
|
||||
targetSession.state = parsed.state
|
||||
targetSession.updatedAt = Date.now()
|
||||
if (!parsed) return
|
||||
|
||||
broadcast(
|
||||
gameId,
|
||||
{
|
||||
type: 'state:update',
|
||||
if (parsed.type === 'state:update' && parsed.state) {
|
||||
const targetSession = ensureSession(gameId)
|
||||
targetSession.state = parsed.state
|
||||
targetSession.updatedAt = Date.now()
|
||||
|
||||
broadcast(
|
||||
gameId,
|
||||
state: targetSession.state
|
||||
},
|
||||
ws
|
||||
)
|
||||
{
|
||||
type: 'state:update',
|
||||
gameId,
|
||||
state: targetSession.state
|
||||
},
|
||||
ws
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (parsed.type === 'guess:request' && parsed.teamId) {
|
||||
broadcast(
|
||||
gameId,
|
||||
{
|
||||
type: 'guess:request',
|
||||
gameId,
|
||||
teamId: parsed.teamId
|
||||
},
|
||||
ws
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
ws.on('close', () => {
|
||||
|
||||
129
src/App.vue
129
src/App.vue
@@ -266,6 +266,9 @@
|
||||
<button v-if="showEnableAudio" class="primary enable-audio" @click="enableViewerAudio">
|
||||
Tap To Enable Audio
|
||||
</button>
|
||||
<button v-if="canViewerGuess" class="primary viewer-guess" @click="requestGuessStop">
|
||||
Guess Now
|
||||
</button>
|
||||
<div v-if="!currentClipUrl" class="player-empty"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -331,13 +334,12 @@ type RealtimeState = {
|
||||
currentSelectorId: string | null
|
||||
lastAwardedTeamId: string | null
|
||||
isAnswerClip: boolean
|
||||
playbackPosition: number
|
||||
playbackCapturedAt: number
|
||||
}
|
||||
|
||||
type RealtimeMessage = {
|
||||
type: 'session:init' | 'state:update'
|
||||
state: RealtimeState | null
|
||||
type: 'session:init' | 'state:update' | 'guess:request'
|
||||
state?: RealtimeState | null
|
||||
teamId?: string
|
||||
}
|
||||
|
||||
const REALTIME_BASE = '/realtime'
|
||||
@@ -395,7 +397,6 @@ export default {
|
||||
isApplyingRemote: false,
|
||||
queuedRemoteState: null as RealtimeState | null,
|
||||
syncTimer: 0,
|
||||
playbackSyncInterval: 0,
|
||||
audioUnlocked: true,
|
||||
latestRemoteState: null as RealtimeState | null,
|
||||
viewerTeamId: ''
|
||||
@@ -433,6 +434,14 @@ export default {
|
||||
showEnableAudio() {
|
||||
return !this.canControlGame && !!this.currentClipUrl && !this.audioUnlocked
|
||||
},
|
||||
canViewerGuess() {
|
||||
return (
|
||||
!this.canControlGame &&
|
||||
this.audioUnlocked &&
|
||||
!!this.viewerTeamId &&
|
||||
this.getCurrentTileStatus() === 'playing'
|
||||
)
|
||||
},
|
||||
canControlGame() {
|
||||
return !this.gameId || this.isHost
|
||||
},
|
||||
@@ -562,7 +571,6 @@ export default {
|
||||
this.audioUnlocked = true
|
||||
this.latestRemoteState = null
|
||||
this.viewerTeamId = ''
|
||||
this.stopHostPlaybackSync()
|
||||
this.setGameInUrl('')
|
||||
},
|
||||
async connectSession() {
|
||||
@@ -585,6 +593,10 @@ export default {
|
||||
if (!message) return
|
||||
if (message.type === 'session:init' || message.type === 'state:update') {
|
||||
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(() => {})
|
||||
},
|
||||
buildRealtimeState(): RealtimeState {
|
||||
const player = this.getPlayer()
|
||||
return {
|
||||
step: this.step,
|
||||
teams: this.teams.map((team) => ({ ...team })),
|
||||
@@ -621,9 +632,7 @@ export default {
|
||||
currentClipUrl: this.currentClipUrl,
|
||||
currentSelectorId: this.currentSelectorId,
|
||||
lastAwardedTeamId: this.lastAwardedTeamId,
|
||||
isAnswerClip: this.isAnswerClip,
|
||||
playbackPosition: player?.currentTime || 0,
|
||||
playbackCapturedAt: Date.now()
|
||||
isAnswerClip: this.isAnswerClip
|
||||
}
|
||||
},
|
||||
publishState() {
|
||||
@@ -673,36 +682,62 @@ export default {
|
||||
this.isApplyingRemote = false
|
||||
}
|
||||
await nextTick()
|
||||
this.syncRemotePlayback(previousClipUrl !== this.currentClipUrl, state)
|
||||
this.syncRemotePlayback(previousClipUrl !== this.currentClipUrl)
|
||||
},
|
||||
async enableViewerAudio() {
|
||||
if (this.canControlGame) return
|
||||
this.audioUnlocked = true
|
||||
this.ensureAudioContext()
|
||||
await nextTick()
|
||||
if (this.latestRemoteState) {
|
||||
this.syncRemotePlayback(false, this.latestRemoteState)
|
||||
this.syncRemotePlayback(false)
|
||||
},
|
||||
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() {
|
||||
if (!this.currentTileKey) return 'available'
|
||||
return this.tiles[this.currentTileKey]?.status || 'available'
|
||||
},
|
||||
setPlayerCurrentTime(player: HTMLAudioElement, targetTime: number) {
|
||||
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) {
|
||||
syncRemotePlayback(forceReload: boolean) {
|
||||
if (this.canControlGame) return
|
||||
const player = this.getPlayer()
|
||||
if (!player) return
|
||||
@@ -713,23 +748,11 @@ export default {
|
||||
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) {
|
||||
player.currentTime = 0
|
||||
player.load()
|
||||
}
|
||||
|
||||
if (Math.abs((player.currentTime || 0) - targetTime) > 0.2) {
|
||||
this.setPlayerCurrentTime(player, targetTime)
|
||||
}
|
||||
|
||||
if (tileStatus === 'paused') {
|
||||
player.pause()
|
||||
return
|
||||
@@ -794,7 +817,6 @@ export default {
|
||||
this.tentacleLevels = this.tentacleLevels.map(() => 0)
|
||||
},
|
||||
teardownAudio() {
|
||||
this.stopHostPlaybackSync()
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId)
|
||||
this.rafId = 0
|
||||
@@ -873,25 +895,10 @@ export default {
|
||||
if (this.rafId) cancelAnimationFrame(this.rafId)
|
||||
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() {
|
||||
this.isPlaying = true
|
||||
this.startPulse()
|
||||
if (this.isHost) {
|
||||
this.startHostPlaybackSync()
|
||||
this.queueStateSync()
|
||||
}
|
||||
if (this.isHost) this.queueStateSync()
|
||||
},
|
||||
handlePlayerPause() {
|
||||
this.isPlaying = false
|
||||
@@ -900,10 +907,7 @@ export default {
|
||||
this.rafId = 0
|
||||
}
|
||||
this.pulseLevel = 0
|
||||
if (this.isHost) {
|
||||
this.stopHostPlaybackSync()
|
||||
this.queueStateSync()
|
||||
}
|
||||
if (this.isHost) this.queueStateSync()
|
||||
},
|
||||
togglePlayback() {
|
||||
const player = this.getPlayer()
|
||||
@@ -1125,7 +1129,6 @@ export default {
|
||||
this.teardownAudio()
|
||||
this.closeSocket()
|
||||
if (this.syncTimer) window.clearTimeout(this.syncTimer)
|
||||
if (this.playbackSyncInterval) window.clearInterval(this.playbackSyncInterval)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -560,6 +560,12 @@ audio.hidden-audio {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.viewer-guess {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.end-panel {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user