🚧 Added GameState and GameControl to quizzes to allow keyboard control

This commit is contained in:
Proxyfil
2026-04-22 21:02:00 +02:00
parent 4ed0b8d626
commit 7629db7718
4 changed files with 109 additions and 45 deletions
+27 -2
View File
@@ -14,6 +14,8 @@ SPDX-License-Identifier: MPL-2.0
import Spinner from '$lib/Spinner.svelte'; import Spinner from '$lib/Spinner.svelte';
import Controls from '$lib/play/admin/controls.svelte'; import Controls from '$lib/play/admin/controls.svelte';
import Question from '$lib/play/admin/question.svelte'; import Question from '$lib/play/admin/question.svelte';
import { SocketGameControls } from '$lib/play/admin/socket_game_controls.ts';
import { GameState } from '$lib/play/admin/game_state.ts';
const { t } = getLocalization(); const { t } = getLocalization();
const default_colors = ['#D6EDC9', '#B07156', '#7F7057', '#4E6E58']; const default_colors = ['#D6EDC9', '#B07156', '#7F7057', '#4E6E58'];
@@ -33,6 +35,7 @@ SPDX-License-Identifier: MPL-2.0
final_results?: Array<null> | Array<Array<PlayerAnswer>>; final_results?: Array<null> | Array<Array<PlayerAnswer>>;
control_visible: boolean; control_visible: boolean;
player_scores: any; player_scores: any;
game_state: GameState;
} }
let { let {
@@ -41,7 +44,8 @@ SPDX-License-Identifier: MPL-2.0
bg_color, bg_color,
final_results = $bindable([null]), final_results = $bindable([null]),
control_visible, control_visible,
player_scores = $bindable() player_scores = $bindable(),
game_state = $bindable()
}: Props = $props(); }: Props = $props();
socket.on('get_question_results', () => { socket.on('get_question_results', () => {
@@ -56,11 +60,20 @@ SPDX-License-Identifier: MPL-2.0
selected_question = selected_question + 1; selected_question = selected_question + 1;
answer_count = 0; answer_count = 0;
timer(timer_res); timer(timer_res);
game_state.timer_res = '0';
game_state.question_results = null;
game_state.shown_question_now = data.question_index;
game_state.timer_res = quiz_data.questions[data.question_index].time;
game_state.selected_question = game_state.selected_question + 1;
game_state.answer_count = 0;
}); });
socket.on('solutions', (_) => { socket.on('solutions', (_) => {
timer_res = '0'; timer_res = '0';
clearInterval(timer_interval); clearInterval(timer_interval);
game_state.timer_res = '0';
}); });
socket.on('final_results', (data) => { socket.on('final_results', (data) => {
@@ -68,20 +81,28 @@ SPDX-License-Identifier: MPL-2.0
final_results_clicked = true; final_results_clicked = true;
timer_res = '0'; timer_res = '0';
final_results = data; final_results = data;
game_state.final_results = data;
game_state.timer_res = '0';
}); });
socket.on('everyone_answered', (_) => { socket.on('everyone_answered', (_) => {
timer_res = '0'; timer_res = '0';
game_state.timer_res = '0';
}); });
socket.on('question_results', (data) => { socket.on('question_results', (data) => {
console.log('question_results:', data); console.log('question_results:', data);
question_results = data; question_results = data;
timer_res = '0'; timer_res = '0';
game_state.question_results = data;
game_state.timer_res = '0';
}); });
socket.on('player_answer', (_) => { socket.on('player_answer', (_) => {
answer_count += 1; answer_count += 1;
game_state.answer_count += 1;
}); });
const timer = (time: string) => { const timer = (time: string) => {
@@ -95,8 +116,11 @@ SPDX-License-Identifier: MPL-2.0
} }
timer_res = seconds.toString(); timer_res = seconds.toString();
game_state.timer_res = seconds.toString();
}, 1000); }, 1000);
}; };
const socket_game_controls: SocketGameControls = new SocketGameControls(socket);
</script> </script>
{#if control_visible} {#if control_visible}
@@ -106,10 +130,11 @@ SPDX-License-Identifier: MPL-2.0
{quiz_data} {quiz_data}
bind:timer_res bind:timer_res
{final_results} {final_results}
{socket} {socket_game_controls}
{question_results} {question_results}
{game_token} {game_token}
{shown_question_now} {shown_question_now}
bind:game_state
/> />
{/if} {/if}
{#if timer_res !== '0' && selected_question >= 0} {#if timer_res !== '0' && selected_question >= 0}
+16 -24
View File
@@ -9,6 +9,8 @@ SPDX-License-Identifier: MPL-2.0
import type { QuizData } from '$lib/quiz_types'; import type { QuizData } from '$lib/quiz_types';
import type { Socket } from 'socket.io-client'; import type { Socket } from 'socket.io-client';
import { getLocalization } from '$lib/i18n'; import { getLocalization } from '$lib/i18n';
import { SocketGameControls } from '$lib/play/admin/socket_game_controls.ts';
import { GameState } from '$lib/play/admin/game_state.ts';
interface Props { interface Props {
bg_color: string; bg_color: string;
@@ -16,10 +18,11 @@ SPDX-License-Identifier: MPL-2.0
quiz_data: QuizData; quiz_data: QuizData;
timer_res: string; timer_res: string;
final_results: any; final_results: any;
socket: Socket; socket_game_controls: SocketGameControls;
game_token: string; game_token: string;
question_results: any; question_results: any;
shown_question_now: number; shown_question_now: number;
game_state: GameState;
} }
let { let {
@@ -28,30 +31,19 @@ SPDX-License-Identifier: MPL-2.0
quiz_data, quiz_data,
timer_res = $bindable(), timer_res = $bindable(),
final_results, final_results,
socket, socket_game_controls,
game_token, game_token,
question_results, question_results,
shown_question_now shown_question_now,
game_state = $bindable()
}: Props = $props(); }: Props = $props();
const { t } = getLocalization(); const { t } = getLocalization();
const set_question_number = (q_number: number) => {
socket.emit('set_question_number', q_number.toString());
};
const get_question_results = () => {
socket.emit('get_question_results', {
game_id: game_token,
question_number: shown_question_now
});
};
const show_solutions = () => { const show_solutions = () => {
socket.emit('show_solutions', {}); socket_game_controls.show_solutions();
timer_res = '0'; timer_res = '0';
}; game_state.timer_res = '0';
const get_final_results = () => {
socket.emit('get_final_results', {});
}; };
</script> </script>
@@ -67,7 +59,7 @@ SPDX-License-Identifier: MPL-2.0
<div class="justify-self-end ml-auto mr-0 col-start-3 col-end-3"> <div class="justify-self-end ml-auto mr-0 col-start-3 col-end-3">
{#if selected_question + 1 === quiz_data.questions.length && ((timer_res === '0' && question_results !== null) || quiz_data?.questions?.[selected_question]?.type === QuizQuestionType.SLIDE)} {#if selected_question + 1 === quiz_data.questions.length && ((timer_res === '0' && question_results !== null) || quiz_data?.questions?.[selected_question]?.type === QuizQuestionType.SLIDE)}
{#if JSON.stringify(final_results) === JSON.stringify([null])} {#if JSON.stringify(final_results) === JSON.stringify([null])}
<button onclick={get_final_results} class="admin-button" <button onclick={() => socket_game_controls.get_final_results()} class="admin-button"
>{$t('admin_page.get_final_results')} >{$t('admin_page.get_final_results')}
</button> </button>
{/if} {/if}
@@ -75,7 +67,7 @@ SPDX-License-Identifier: MPL-2.0
{#if (selected_question + 1 !== quiz_data.questions.length && question_results !== null) || selected_question === -1} {#if (selected_question + 1 !== quiz_data.questions.length && question_results !== null) || selected_question === -1}
<button <button
onclick={() => { onclick={() => {
set_question_number(selected_question + 1); socket_game_controls.set_question_number(selected_question + 1);
}} }}
class="admin-button" class="admin-button"
>{$t('admin_page.next_question', { question: selected_question + 2 })} >{$t('admin_page.next_question', { question: selected_question + 2 })}
@@ -85,7 +77,7 @@ SPDX-License-Identifier: MPL-2.0
{#if quiz_data.questions[selected_question].type === QuizQuestionType.SLIDE} {#if quiz_data.questions[selected_question].type === QuizQuestionType.SLIDE}
<button <button
onclick={() => { onclick={() => {
set_question_number(selected_question + 1); socket_game_controls.set_question_number(selected_question + 1);
}} }}
class="admin-button" class="admin-button"
>{$t('admin_page.next_question', { question: selected_question + 2 })} >{$t('admin_page.next_question', { question: selected_question + 2 })}
@@ -93,16 +85,16 @@ SPDX-License-Identifier: MPL-2.0
{:else if quiz_data.questions[selected_question]?.hide_results === true} {:else if quiz_data.questions[selected_question]?.hide_results === true}
<button <button
onclick={() => { onclick={() => {
get_question_results(); socket_game_controls.get_question_results(game_token, shown_question_now);
setTimeout(() => { setTimeout(() => {
set_question_number(selected_question + 1); socket_game_controls.set_question_number(selected_question + 1);
}, 200); }, 200);
}} }}
class="admin-button" class="admin-button"
>{$t('admin_page.next_question', { question: selected_question + 2 })} >{$t('admin_page.next_question', { question: selected_question + 2 })}
</button> </button>
{:else} {:else}
<button onclick={get_question_results} class="admin-button" <button onclick={() => socket_game_controls.get_question_results(game_token, shown_question_now)} class="admin-button"
>{$t('admin_page.show_results')} >{$t('admin_page.show_results')}
</button> </button>
{/if} {/if}
@@ -111,7 +103,7 @@ SPDX-License-Identifier: MPL-2.0
{#if quiz_data.questions[selected_question].type === QuizQuestionType.SLIDE} {#if quiz_data.questions[selected_question].type === QuizQuestionType.SLIDE}
<button <button
onclick={() => { onclick={() => {
set_question_number(selected_question + 1); socket_game_controls.set_question_number(selected_question + 1);
}} }}
class="admin-button" class="admin-button"
>{$t('admin_page.next_question', { question: selected_question + 2 })} >{$t('admin_page.next_question', { question: selected_question + 2 })}
@@ -10,15 +10,16 @@ SPDX-License-Identifier: MPL-2.0
import { getLocalization } from '$lib/i18n'; import { getLocalization } from '$lib/i18n';
import GrayButton from '$lib/components/buttons/gray.svelte'; import GrayButton from '$lib/components/buttons/gray.svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { SocketGameControls } from '$lib/play/admin/socket_game_controls.ts';
interface Props { interface Props {
game_pin: string; game_pin: string;
players: any; players: any;
socket: any; socket_game_controls: SocketGameControls;
cqc_code: string; cqc_code: string;
} }
let { game_pin, players = $bindable(), socket, cqc_code = $bindable() }: Props = $props(); let { game_pin, players = $bindable(), socket_game_controls, cqc_code = $bindable() }: Props = $props();
let fullscreen_open = $state(false); let fullscreen_open = $state(false);
const { t } = getLocalization(); const { t } = getLocalization();
@@ -27,18 +28,6 @@ SPDX-License-Identifier: MPL-2.0
if (cqc_code === 'null') { if (cqc_code === 'null') {
cqc_code = null; cqc_code = null;
} }
const kick_player = (username: string) => {
socket.emit('kick_player', { username: username });
for (let i = 0; i < players.length; i++) {
console.log(players[i].username, username);
if (players[i].username === username) {
players.splice(i, 1);
break;
}
}
players = players;
};
</script> </script>
<div class="w-full h-full"> <div class="w-full h-full">
@@ -106,7 +95,7 @@ SPDX-License-Identifier: MPL-2.0
<GrayButton <GrayButton
disabled={players.length < 1} disabled={players.length < 1}
onclick={() => { onclick={() => {
socket.emit('start_game', ''); socket_game_controls.start_game()
}} }}
>{$t('admin_page.start_game')} >{$t('admin_page.start_game')}
</GrayButton> </GrayButton>
@@ -119,7 +108,7 @@ SPDX-License-Identifier: MPL-2.0
<span <span
class="hover:line-through text-lg" class="hover:line-through text-lg"
onclick={() => { onclick={() => {
kick_player(player.username); socket_game_controls.kick_player(player.username, players);
}}>{player.username}</span }}>{player.username}</span
> >
<!-- <button>{$t('words.kick')}</button>--> <!-- <button>{$t('words.kick')}</button>-->
+61 -3
View File
@@ -18,6 +18,9 @@ SPDX-License-Identifier: MPL-2.0
import FinalResults from '$lib/play/admin/final_results.svelte'; import FinalResults from '$lib/play/admin/final_results.svelte';
import GrayButton from '$lib/components/buttons/gray.svelte'; import GrayButton from '$lib/components/buttons/gray.svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import { SocketGameControls } from '$lib/play/admin/socket_game_controls.ts';
import { GameState } from '$lib/play/admin/game_state.ts';
import { QuizQuestionType } from '$lib/quiz_types';
navbarVisible.visible = false; navbarVisible.visible = false;
@@ -143,9 +146,63 @@ SPDX-License-Identifier: MPL-2.0
let results_saved = $state(false); let results_saved = $state(false);
let show_final_results = $derived(JSON.stringify(final_results) !== JSON.stringify([null])); let show_final_results = $derived(JSON.stringify(final_results) !== JSON.stringify([null]));
const socket_game_controls: SocketGameControls = new SocketGameControls(socket);
let game_state: GameState = $state(new GameState(game_token));
// This function in called in every keyboard event in this page
const next_action = (e: KeyboardEvent) => {
if((e.key in ["Enter", " "])) return; // Don't catch events other than enter or spacebar
if(!game_started && players.length > 0) { // Game not started with conditions matched
socket_game_controls.start_game()
}
// This is a messy condition to check if we can show final results
else if(game_state.selected_question + 1 === quiz_data.questions.length && ((game_state.timer_res === '0' && game_state.question_results !== null) || quiz_data?.questions?.[game_state.selected_question]?.type === QuizQuestionType.SLIDE)){
socket_game_controls.get_final_results();
}
// Game is ongoing and the current question is eather finished or quiz just started
else if(game_state.timer_res === '0' || game_state.selected_question === -1) {
// We are eather in the beginning of the quiz or in the end of it
if((game_state.selected_question + 1 !== quiz_data.questions.length && game_state.question_results !== null) || game_state.selected_question === -1) {
socket_game_controls.set_question_number(game_state.selected_question + 1);
}
// Else we are in the middle of a question
else if(game_state.question_results === null && game_state.selected_question !== -1) {
if(quiz_data.questions[game_state.selected_question].type === QuizQuestionType.SLIDE) {
socket_game_controls.set_question_number(game_state.selected_question + 1);
}
else if(quiz_data.questions[game_state.selected_question]?.hide_results === true) {
socket_game_controls.get_question_results(game_token, game_state.shown_question_now);
setTimeout(() => {
socket_game_controls.set_question_number(game_state.selected_question + 1);
}, 200);
}
else {
socket_game_controls.get_question_results(game_token, game_state.shown_question_now);
}
}
}
// Game started, question not finished we check if the question is slide or not
else if(game_state.selected_question !== -1) {
switch(quiz_data.questions[game_state.selected_question].type) {
case QuizQuestionType.SLIDE:
socket_game_controls.set_question_number(game_state.selected_question + 1);
break;
default:
socket_game_controls.show_solutions();
game_state.timer_res = '0';
break;
}
}
};
</script> </script>
<svelte:window onbeforeunload={confirmUnload} /> <svelte:window onbeforeunload={confirmUnload} on:keydown={next_action} />
<svelte:head> <svelte:head>
<title>ClassQuiz - Host</title> <title>ClassQuiz - Host</title>
</svelte:head> </svelte:head>
@@ -207,7 +264,7 @@ SPDX-License-Identifier: MPL-2.0
<GameNotStarted <GameNotStarted
{game_pin} {game_pin}
bind:players bind:players
{socket} {socket_game_controls}
cqc_code={page.url.searchParams.get('cqc_code')} cqc_code={page.url.searchParams.get('cqc_code')}
/> />
{:else} {:else}
@@ -218,6 +275,7 @@ SPDX-License-Identifier: MPL-2.0
{bg_color} {bg_color}
bind:player_scores bind:player_scores
{control_visible} {control_visible}
bind:game_state
/> />
{/if} {/if}
</div> </div>
@@ -228,4 +286,4 @@ SPDX-License-Identifier: MPL-2.0
bind:this={dataexport_download_a} bind:this={dataexport_download_a}
download="" download=""
class="absolute -top-3/4 -left-3/4 opacity-0">Download</a class="absolute -top-3/4 -left-3/4 opacity-0">Download</a
> >