fix(game): prevent green-felt freeze — loading spinner + retry resync
Three changes: 1. GameTable shows a spinner instead of an empty table when mode=online and seatPlayers is empty (waiting for first state broadcast). 2. enterServerMatch schedules a 3s interval that calls service.resync() until seatPlayers is populated, guaranteeing the state always lands. 3. resync() added to OnlineService interface + both implementations so the game store can call it without casting. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -55,7 +55,26 @@ export function GameTable({
|
|||||||
const game = useGameStore((s) => s.game);
|
const game = useGameStore((s) => s.game);
|
||||||
const reset = useGameStore((s) => s.reset);
|
const reset = useGameStore((s) => s.reset);
|
||||||
const mode = useGameStore((s) => s.mode);
|
const mode = useGameStore((s) => s.mode);
|
||||||
|
const seatPlayers = useGameStore((s) => s.seatPlayers);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
// While waiting for the first server state broadcast, show a spinner instead
|
||||||
|
// of an empty green felt — prevents the "3 colored dots / stuck table" bug.
|
||||||
|
const connecting = mode === "online" && seatPlayers.length === 0;
|
||||||
|
if (connecting) {
|
||||||
|
return (
|
||||||
|
<main className="persian-pattern relative h-dvh w-full flex items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ repeat: Infinity, duration: 1.5, ease: "linear" }}
|
||||||
|
className="size-14 border-4 border-gold-400/30 border-t-gold-400 rounded-full"
|
||||||
|
/>
|
||||||
|
<p className="gold-text font-bold text-lg">{t("mm.searching")}</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
const [askFf, setAskFf] = useState(false);
|
const [askFf, setAskFf] = useState(false);
|
||||||
|
|
||||||
const sfx = useSoundStore((s) => s.sfx);
|
const sfx = useSoundStore((s) => s.sfx);
|
||||||
|
|||||||
@@ -448,6 +448,16 @@ export const useGameStore = create<GameStore>((set, get) => {
|
|||||||
reconnectDeadline: null,
|
reconnectDeadline: null,
|
||||||
seatPlayers: [],
|
seatPlayers: [],
|
||||||
});
|
});
|
||||||
|
// Green-felt guard: if the initial state hasn't arrived after 3 s, ask the
|
||||||
|
// server to resend it. Repeat every 3 s until it lands (stops automatically
|
||||||
|
// once seatPlayers is populated, meaning applyServerState ran).
|
||||||
|
const resyncInterval = setInterval(() => {
|
||||||
|
if (get().seatPlayers.length > 0 || !get().live) {
|
||||||
|
clearInterval(resyncInterval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
service.resync();
|
||||||
|
}, 3000);
|
||||||
},
|
},
|
||||||
|
|
||||||
applyServerState: (s) => {
|
applyServerState: (s) => {
|
||||||
|
|||||||
@@ -984,6 +984,8 @@ export class MockOnlineService implements OnlineService {
|
|||||||
this.matchmaking.phase = "idle";
|
this.matchmaking.phase = "idle";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resync() { /* mock: game runs client-side, no server resync needed */ }
|
||||||
|
|
||||||
async playNow() {
|
async playNow() {
|
||||||
if (this.matchmaking.phase !== "searching" && this.matchmaking.phase !== "queued") return;
|
if (this.matchmaking.phase !== "searching" && this.matchmaking.phase !== "queued") return;
|
||||||
const me = this.profile!;
|
const me = this.profile!;
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ export interface OnlineService {
|
|||||||
cancelMatchmaking(): Promise<void>;
|
cancelMatchmaking(): Promise<void>;
|
||||||
/** Stop waiting for online players — start now, filling empty seats with bots. */
|
/** Stop waiting for online players — start now, filling empty seats with bots. */
|
||||||
playNow(): Promise<void>;
|
playNow(): Promise<void>;
|
||||||
|
/** Ask the server to re-broadcast the current game state (green-felt guard). */
|
||||||
|
resync(): void;
|
||||||
onMatchmaking(cb: (state: MatchmakingState) => void): Unsubscribe;
|
onMatchmaking(cb: (state: MatchmakingState) => void): Unsubscribe;
|
||||||
|
|
||||||
/* ----- match players (for the online game driver) ----- */
|
/* ----- match players (for the online game driver) ----- */
|
||||||
|
|||||||
@@ -338,6 +338,10 @@ export class SignalrService implements OnlineService {
|
|||||||
await this.conn?.invoke("PlayNow");
|
await this.conn?.invoke("PlayNow");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resync(): void {
|
||||||
|
void this.conn?.invoke("Resync").catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
onMatchmaking(cb: (s: MatchmakingState) => void): Unsubscribe {
|
onMatchmaking(cb: (s: MatchmakingState) => void): Unsubscribe {
|
||||||
this.mmCbs.add(cb);
|
this.mmCbs.add(cb);
|
||||||
return () => this.mmCbs.delete(cb);
|
return () => this.mmCbs.delete(cb);
|
||||||
|
|||||||
Reference in New Issue
Block a user