characters invisible on human vs bot fight entrance fix

This commit is contained in:
Dorian
2026-03-11 09:17:20 +00:00
parent bcbcd17fce
commit 974566778e
3 changed files with 39 additions and 7 deletions
+26 -6
View File
@@ -1,5 +1,9 @@
import { ref, type Ref } from 'vue' import { ref, type Ref } from 'vue'
// Buffer to ensure client timer finishes BEFORE server timer
// (accounts for SSE delivery delay + network latency)
const CLIENT_TIMER_BUFFER_MS = 1500
export function useHumanChallenge( export function useHumanChallenge(
fightId: Ref<string>, fightId: Ref<string>,
myBotId: Ref<string | null>, myBotId: Ref<string | null>,
@@ -18,6 +22,7 @@ export function useHumanChallenge(
const humanChoices = ref<string[]>([]) const humanChoices = ref<string[]>([])
const roundCooldown = ref(0) const roundCooldown = ref(0)
const pendingChallengeData = ref<{ data: any; receivedAt: number } | null>(null) const pendingChallengeData = ref<{ data: any; receivedAt: number } | null>(null)
const entrancePlaying = ref(false)
let humanPollHandle: ReturnType<typeof setInterval> | null = null let humanPollHandle: ReturnType<typeof setInterval> | null = null
let timerHandle: ReturnType<typeof setInterval> | null = null let timerHandle: ReturnType<typeof setInterval> | null = null
@@ -25,10 +30,11 @@ export function useHumanChallenge(
function applyChallenge(data: any, receivedAt?: number) { function applyChallenge(data: any, receivedAt?: number) {
const elapsed = receivedAt ? Date.now() - receivedAt : 0 const elapsed = receivedAt ? Date.now() - receivedAt : 0
// Cap timeout for multiple choice — you're just tapping a button, not typing // Use server-computed remainingMs when available (from polling), otherwise derive from timeoutMs
const hasChoices = data.choices && data.choices.length > 0 const hasChoices = data.choices && data.choices.length > 0
const baseTimeout = hasChoices ? Math.min(data.timeoutMs || 8000, 10000) : (data.timeoutMs || 8000) const rawTimeout = data.remainingMs || (hasChoices ? Math.min(data.timeoutMs || 8000, 10000) : (data.timeoutMs || 8000))
const remaining = Math.max(1000, baseTimeout - elapsed) // Subtract buffer so client always submits before server times out
const remaining = Math.max(1000, rawTimeout - elapsed - CLIENT_TIMER_BUFFER_MS)
humanChallenge.value = { humanChallenge.value = {
type: data.type, type: data.type,
label: data.label, label: data.label,
@@ -51,7 +57,6 @@ export function useHumanChallenge(
if (humanTimer.value <= 0) { if (humanTimer.value <= 0) {
if (timerHandle) clearInterval(timerHandle) if (timerHandle) clearInterval(timerHandle)
if (!humanSubmitted.value) { if (!humanSubmitted.value) {
// BUG-9: Submit empty timeout instead of random choice
humanSubmitted.value = true humanSubmitted.value = true
void submitTimeout() void submitTimeout()
} }
@@ -113,7 +118,8 @@ export function useHumanChallenge(
const data = await res.json() const data = await res.json()
if (data.pending) { if (data.pending) {
if (roundCooldown.value > 0) { // Queue if entrance is playing or cooldown active
if (entrancePlaying.value || roundCooldown.value > 0) {
if (!pendingChallengeData.value || (data.roundNumber && data.roundNumber > (pendingChallengeData.value.data.roundNumber || 0))) { if (!pendingChallengeData.value || (data.roundNumber && data.roundNumber > (pendingChallengeData.value.data.roundNumber || 0))) {
pendingChallengeData.value = { data, receivedAt: Date.now() } pendingChallengeData.value = { data, receivedAt: Date.now() }
} }
@@ -179,10 +185,22 @@ export function useHumanChallenge(
humanChoices.value = [] humanChoices.value = []
roundCooldown.value = 0 roundCooldown.value = 0
pendingChallengeData.value = null pendingChallengeData.value = null
entrancePlaying.value = false
}
/** Mark entrance as playing — queues any challenges that arrive during animation */
function setEntrancePlaying(playing: boolean) {
entrancePlaying.value = playing
// When entrance finishes, flush any queued challenge
if (!playing && pendingChallengeData.value) {
applyChallenge(pendingChallengeData.value.data, pendingChallengeData.value.receivedAt)
pendingChallengeData.value = null
}
} }
function handleSSEChallenge(sseData: any) { function handleSSEChallenge(sseData: any) {
if (roundCooldown.value > 0) { // Queue challenges during entrance animation or round cooldown
if (entrancePlaying.value || roundCooldown.value > 0) {
pendingChallengeData.value = { data: sseData, receivedAt: Date.now() } pendingChallengeData.value = { data: sseData, receivedAt: Date.now() }
} else { } else {
applyChallenge(sseData) applyChallenge(sseData)
@@ -197,6 +215,7 @@ export function useHumanChallenge(
humanChoices, humanChoices,
roundCooldown, roundCooldown,
pendingChallengeData, pendingChallengeData,
entrancePlaying,
applyChallenge, applyChallenge,
submitHumanAnswer, submitHumanAnswer,
submitChoice, submitChoice,
@@ -206,6 +225,7 @@ export function useHumanChallenge(
startCooldown, startCooldown,
clearChallenge, clearChallenge,
resetState, resetState,
setEntrancePlaying,
handleSSEChallenge, handleSSEChallenge,
} }
} }
+6
View File
@@ -49,6 +49,7 @@ const {
roundCooldown, pendingChallengeData, roundCooldown, pendingChallengeData,
applyChallenge, submitChoice, startHumanPolling, stopHumanPolling, applyChallenge, submitChoice, startHumanPolling, stopHumanPolling,
startCooldown, clearChallenge, resetState: resetChallengeState, handleSSEChallenge, startCooldown, clearChallenge, resetState: resetChallengeState, handleSSEChallenge,
setEntrancePlaying,
} = challenge } = challenge
// Live scene state // Live scene state
@@ -155,6 +156,10 @@ async function initLiveScene() {
await ensureAudioContext() await ensureAudioContext()
liveScene.startMusic() liveScene.startMusic()
} }
// Let Kaplay register game objects before entrance (prevents invisible fighters)
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))
// Block challenges during entrance so the animation plays fully
setEntrancePlaying(true)
announceDeepIntro() announceDeepIntro()
try { try {
await liveScene.playEntrance() await liveScene.playEntrance()
@@ -163,6 +168,7 @@ async function initLiveScene() {
} }
// Safety: ensure fighters are visible after entrance (prevents invisible characters on mobile) // Safety: ensure fighters are visible after entrance (prevents invisible characters on mobile)
liveScene._resetPositions() liveScene._resetPositions()
setEntrancePlaying(false)
} }
liveLogItems.value.push( liveLogItems.value.push(
+7 -1
View File
@@ -30,6 +30,10 @@ const trashTalk = ref('')
const remainingSeconds = ref(45) const remainingSeconds = ref(45)
const showTrashTalk = ref(false) const showTrashTalk = ref(false)
// Buffer to ensure client timer finishes BEFORE server timer
// (accounts for polling delivery delay + network latency)
const CLIENT_TIMER_BUFFER_MS = 1500
// Feedback state // Feedback state
const feedback = ref<'correct' | 'wrong' | null>(null) const feedback = ref<'correct' | 'wrong' | null>(null)
const feedbackTimer = ref<ReturnType<typeof setTimeout> | null>(null) const feedbackTimer = ref<ReturnType<typeof setTimeout> | null>(null)
@@ -197,7 +201,9 @@ async function pollForChallenge() {
feedback.value = null feedback.value = null
phase.value = 'challenge' phase.value = 'challenge'
// Cap timer for multiple choice — just tapping a button, not typing // Cap timer for multiple choice — just tapping a button, not typing
const timerMs = (data.choices?.length > 0) ? Math.min(data.remainingMs, 10000) : data.remainingMs // Subtract buffer so client always submits before server times out
const rawMs = (data.choices?.length > 0) ? Math.min(data.remainingMs, 10000) : data.remainingMs
const timerMs = Math.max(1000, rawMs - CLIENT_TIMER_BUFFER_MS)
startTimer(timerMs) startTimer(timerMs)
await nextTick() await nextTick()
if (!hasChoices.value) answerInput.value?.focus() if (!hasChoices.value) answerInput.value?.focus()