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) => {
|
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
|
||||||
const targetSession = ensureSession(gameId)
|
|
||||||
targetSession.state = parsed.state
|
|
||||||
targetSession.updatedAt = Date.now()
|
|
||||||
|
|
||||||
broadcast(
|
if (parsed.type === 'state:update' && parsed.state) {
|
||||||
gameId,
|
const targetSession = ensureSession(gameId)
|
||||||
{
|
targetSession.state = parsed.state
|
||||||
type: 'state:update',
|
targetSession.updatedAt = Date.now()
|
||||||
|
|
||||||
|
broadcast(
|
||||||
gameId,
|
gameId,
|
||||||
state: targetSession.state
|
{
|
||||||
},
|
type: 'state:update',
|
||||||
ws
|
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', () => {
|
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">
|
<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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user