✨ Implemented ClassQuizController-logic
This commit is contained in:
@@ -32,6 +32,7 @@ from classquiz.routers import (
|
||||
avatar,
|
||||
results,
|
||||
admin,
|
||||
box_controller,
|
||||
)
|
||||
from classquiz.socket_server import sio
|
||||
from classquiz.helpers import meilisearch_init, telemetry_ping, bg_tasks
|
||||
@@ -85,6 +86,9 @@ async def auth_middleware_wrapper(request: Request, call_next):
|
||||
return await rememberme_middleware(request, call_next)
|
||||
|
||||
|
||||
app.include_router(
|
||||
box_controller.router, tags=["boxcontroller"], prefix="/api/v1/box-controller", include_in_schema=True
|
||||
)
|
||||
app.include_router(results.router, tags=["results"], prefix="/api/v1/results", include_in_schema=True)
|
||||
app.include_router(remote.router, tags=["remote"], prefix="/api/v1/remote", include_in_schema=True)
|
||||
app.include_router(login.router, tags=["auth"], prefix="/api/v1/login", include_in_schema=True)
|
||||
|
||||
@@ -241,11 +241,12 @@ class PlayGame(BaseModel):
|
||||
background_color: str | None
|
||||
background_image: str | None
|
||||
custom_field: str | None
|
||||
question_show: bool = False
|
||||
|
||||
|
||||
class GamePlayer(BaseModel):
|
||||
username: str
|
||||
sid: str
|
||||
sid: str | None
|
||||
|
||||
|
||||
class GameAnswer2(BaseModel):
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
from fastapi import APIRouter
|
||||
from classquiz.routers.box_controller import web, embedded
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
router.include_router(web.router, prefix="/web")
|
||||
router.include_router(embedded.router, prefix="/embedded")
|
||||
@@ -0,0 +1,153 @@
|
||||
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
import enum
|
||||
import os
|
||||
import typing
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, status
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from classquiz.config import redis
|
||||
from classquiz.db.models import PlayGame, QuizQuestionType, AnswerData, GamePlayer
|
||||
from classquiz.socket_server import sio, calculate_score, set_answer
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class JoinGameInput(BaseModel):
|
||||
code: str
|
||||
name: str
|
||||
|
||||
|
||||
class JoinGameResponse(BaseModel):
|
||||
id: str
|
||||
|
||||
|
||||
@router.post("/join")
|
||||
async def join_game(data: JoinGameInput):
|
||||
game_pin = await redis.get(f"game:cqc:code:{data.code}")
|
||||
if game_pin is None:
|
||||
raise HTTPException(status_code=404, detail="Game not found")
|
||||
game = await redis.get(f"game:{game_pin}")
|
||||
game = PlayGame.parse_raw(game)
|
||||
# Check if game is already running
|
||||
if game.started:
|
||||
raise HTTPException(status_code=400, detail="Game started already")
|
||||
# check if username already exists
|
||||
if await redis.get(f"game_session:{game_pin}:players:{data.name}") is not None:
|
||||
raise HTTPException(status_code=409, detail="Username already exists")
|
||||
|
||||
player_id = os.urandom(5).hex()
|
||||
await redis.set(f"game:cqc:player:{player_id}", data.name)
|
||||
return JoinGameResponse(id=f"{player_id}:{game_pin}")
|
||||
|
||||
|
||||
class SubmitAnswerInput(BaseModel):
|
||||
answer: int
|
||||
|
||||
|
||||
async def submit_answer_fn(data_answer: int, game_pin: str, player_id: str, now: datetime):
|
||||
redis_res_game = await redis.get(f"game:{game_pin}")
|
||||
username = await redis.get(f"game:cqc:player:{player_id}")
|
||||
if redis_res_game is None or username is None:
|
||||
raise HTTPException(status_code=404, detail="id not existent")
|
||||
game = PlayGame.parse_raw(redis_res_game)
|
||||
if not game.question_show:
|
||||
return
|
||||
question = game.questions[game.current_question]
|
||||
question_time = datetime.fromisoformat(await redis.get(f"game:{game_pin}:current_time"))
|
||||
try:
|
||||
selected_answer = question.answers[data_answer].answer
|
||||
except KeyError:
|
||||
return
|
||||
answer_right = False
|
||||
if question.type == QuizQuestionType.ABCD:
|
||||
for answer in question.answers:
|
||||
if answer.answer == selected_answer and answer.right:
|
||||
answer_right = True
|
||||
break
|
||||
elif question.type == QuizQuestionType.VOTING:
|
||||
answer_right = False
|
||||
else:
|
||||
return
|
||||
diff = (question_time - now).total_seconds() * 1000
|
||||
score = 0
|
||||
if answer_right:
|
||||
score = calculate_score(abs(diff), int(float(question.time)))
|
||||
await redis.hincrby(f"game_session:{game_pin}:player_scores", username, score)
|
||||
answer_data = AnswerData(
|
||||
username=username,
|
||||
answer=selected_answer,
|
||||
right=answer_right,
|
||||
time_taken=abs(diff),
|
||||
score=score,
|
||||
)
|
||||
answers = await redis.get(f"game_session:{game_pin}:{game.current_question}")
|
||||
answers = await set_answer(answers, game_pin=game_pin, data=answer_data, q_index=game.current_question)
|
||||
player_count = await redis.scard(f"game_session:{game_pin}:players")
|
||||
print(player_count, answers)
|
||||
if answers is not None and len(answers.__root__) == player_count:
|
||||
await sio.emit("everyone_answered", {})
|
||||
|
||||
|
||||
class WebSocketTypes(enum.Enum):
|
||||
ButtonPress = "bp"
|
||||
Error = "e"
|
||||
|
||||
|
||||
class WebSocketRequest(BaseModel):
|
||||
type: WebSocketTypes
|
||||
data: typing.Any
|
||||
|
||||
|
||||
wss_clients = {}
|
||||
|
||||
button_to_index_map = {"b": 0, "g": 1, "y": 2, "r": 3}
|
||||
|
||||
|
||||
@router.websocket("/socket/{id}")
|
||||
async def websocket_endpoint(ws: WebSocket, game_id: str, id: str):
|
||||
try:
|
||||
if id in wss_clients.keys():
|
||||
await ws.close(code=status.WS_1001_GOING_AWAY)
|
||||
print("Client {} already exists.".format(id))
|
||||
return
|
||||
|
||||
await ws.accept()
|
||||
wss_clients[id] = ws
|
||||
|
||||
player_id, game_pin = game_id.split(":")
|
||||
if player_id is None or game_pin is None:
|
||||
await ws.send_text(WebSocketRequest(type=WebSocketTypes.Error, data="BadId").json())
|
||||
await ws.close(code=status.WS_1003_UNSUPPORTED_DATA)
|
||||
username = await redis.get(f"game:cqc:player:{player_id}")
|
||||
await sio.emit(
|
||||
"player_joined",
|
||||
{"username": username, "sid": None},
|
||||
room=f"admin:{game_pin}",
|
||||
)
|
||||
await redis.sadd(f"game_session:{game_pin}:players", GamePlayer(username=username, sid=None).json())
|
||||
|
||||
while True:
|
||||
raw_data = await ws.receive_text()
|
||||
try:
|
||||
data = WebSocketRequest.parse_raw(raw_data)
|
||||
except ValidationError:
|
||||
await ws.send_text(WebSocketRequest(type=WebSocketTypes.Error, data="ValidationError").json())
|
||||
continue
|
||||
|
||||
if data.type == WebSocketTypes.ButtonPress:
|
||||
now = datetime.now()
|
||||
try:
|
||||
answer_index = button_to_index_map[data.data.lower()]
|
||||
except (KeyError, AttributeError):
|
||||
await ws.send_text(WebSocketRequest(type=WebSocketTypes.Error, data="InvalidButton").json())
|
||||
continue
|
||||
|
||||
await submit_answer_fn(answer_index, game_pin, player_id, now)
|
||||
print("Data from client {}: {}".format(id, data))
|
||||
|
||||
except WebSocketDisconnect as ex:
|
||||
print("Client {} is disconnected: {}".format(id, ex))
|
||||
wss_clients.pop(id, None)
|
||||
@@ -0,0 +1,25 @@
|
||||
## Basic Request
|
||||
|
||||
Every request is a json-object: `{"type": "SOME_TYPE", "data": "ANY_DATA"}`
|
||||
|
||||
### Types
|
||||
|
||||
#### e (Error)
|
||||
|
||||
- [ ] Client
|
||||
- [x] Server
|
||||
|
||||
Pretty self-explanatory.
|
||||
|
||||
Value is a CamelCase errorcode.
|
||||
Codes:
|
||||
|
||||
- `ValidationError`
|
||||
- `BadId`
|
||||
|
||||
#### bp (ButtonPress)
|
||||
|
||||
- [x] Client
|
||||
- [ ] Server
|
||||
|
||||
Sends a button-press to the server, where `data` is either `b`, `g`, `y` or `r`. Capital letters indicate a long-press.
|
||||
@@ -0,0 +1,52 @@
|
||||
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
import random
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from classquiz.auth import get_current_user
|
||||
from classquiz.db.models import User, PlayGame
|
||||
from classquiz.config import redis
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def generate_code() -> str:
|
||||
specified_length = 6
|
||||
buttons = [
|
||||
"B",
|
||||
"b",
|
||||
"G",
|
||||
"g",
|
||||
"Y",
|
||||
"y",
|
||||
"R",
|
||||
"r",
|
||||
] # Capital stands for long press, lowercase letter for short press
|
||||
resulting_code = ""
|
||||
for i in range(specified_length):
|
||||
resulting_code += random.choice(buttons)
|
||||
return resulting_code
|
||||
|
||||
|
||||
class ActivateCqbForCurrentQuizInput(BaseModel):
|
||||
game_pin: int
|
||||
|
||||
|
||||
class ActivateCqbForCurrentQuizResponse(BaseModel):
|
||||
code: str
|
||||
|
||||
|
||||
@router.post("/activate-for-quiz", response_model=ActivateCqbForCurrentQuizResponse)
|
||||
async def activate_cqc_for_current_quiz(data: ActivateCqbForCurrentQuizInput, user: User = Depends(get_current_user)):
|
||||
redis_res = await redis.get(f"game:{data.game_pin}")
|
||||
if redis_res is None:
|
||||
raise HTTPException(status_code=404, detail="Game not found")
|
||||
game_data = PlayGame.parse_raw(redis_res)
|
||||
if game_data.user_id != user.id:
|
||||
raise HTTPException(status_code=401, detail="The quiz wasn't started by you")
|
||||
code = generate_code()
|
||||
await redis.set(f"game:cqc:code:{code}", game_data.game_pin, ex=3600)
|
||||
return ActivateCqbForCurrentQuizResponse(code=code)
|
||||
@@ -3,6 +3,7 @@
|
||||
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
@@ -98,12 +99,31 @@ async def get_public_quiz(quiz_id: str):
|
||||
return PublicQuizResponse(**quiz.dict())
|
||||
|
||||
|
||||
def generate_code() -> str:
|
||||
specified_length = 6
|
||||
buttons = [
|
||||
"B",
|
||||
"b",
|
||||
"G",
|
||||
"g",
|
||||
"Y",
|
||||
"y",
|
||||
"R",
|
||||
"r",
|
||||
] # Capital stands for long press, lowercase letter for short press
|
||||
resulting_code = ""
|
||||
for i in range(specified_length):
|
||||
resulting_code += random.choice(buttons)
|
||||
return resulting_code
|
||||
|
||||
|
||||
@router.post("/start/{quiz_id}")
|
||||
async def start_quiz(
|
||||
quiz_id: str,
|
||||
game_mode: str,
|
||||
captcha_enabled: bool = True,
|
||||
custom_field: str | None = None,
|
||||
cqcs_enabled: bool = False,
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
try:
|
||||
@@ -138,14 +158,19 @@ async def start_quiz(
|
||||
custom_field=custom_field,
|
||||
background_image=quiz.background_image,
|
||||
)
|
||||
code = None
|
||||
if cqcs_enabled:
|
||||
code = generate_code()
|
||||
await redis.set(f"game:cqc:code:{code}", game_pin, ex=3600)
|
||||
await redis.set(f"game:{str(game.game_pin)}", game.json(), ex=18000)
|
||||
await redis.set(f"game_pin:{user.id}:{quiz_id}", game_pin, ex=18000)
|
||||
|
||||
await redis.set(
|
||||
f"game_in_lobby:{user.id.hex}",
|
||||
GameInLobby(game_id=game.game_id, game_pin=str(game_pin), quiz_title=quiz.title).json(),
|
||||
ex=900,
|
||||
)
|
||||
return {**quiz.dict(exclude={"id"}), **game.dict(exclude={"questions"})}
|
||||
return {**quiz.dict(exclude={"id"}), **game.dict(exclude={"questions"}), "cqc_code": code}
|
||||
|
||||
|
||||
class CheckIfCaptchaEnabledResponse(BaseModel):
|
||||
|
||||
@@ -51,6 +51,26 @@ async def generate_final_results(game_data: PlayGame, game_pin: str) -> dict:
|
||||
return results
|
||||
|
||||
|
||||
def calculate_score(z: float, t: int) -> int:
|
||||
t = t * 1000
|
||||
res = (t - z) / t
|
||||
return int(res * 1000)
|
||||
|
||||
|
||||
async def set_answer(answers, game_pin: str, q_index: int, data: AnswerData) -> AnswerDataList:
|
||||
if answers is None:
|
||||
answers = AnswerDataList(__root__=[data])
|
||||
else:
|
||||
answers = AnswerDataList.parse_raw(answers)
|
||||
answers.__root__.append(data)
|
||||
await redis.set(
|
||||
f"game_session:{game_pin}:{q_index}",
|
||||
answers.json(),
|
||||
ex=7200,
|
||||
)
|
||||
return answers
|
||||
|
||||
|
||||
class _JoinGameData(BaseModel):
|
||||
username: str
|
||||
game_pin: str
|
||||
@@ -205,6 +225,9 @@ async def get_question_results(sid: str, data: dict):
|
||||
session = await sio.get_session(sid)
|
||||
if session["admin"]:
|
||||
redis_res = await redis.get(f"game_session:{session['game_pin']}:{data['question_number']}")
|
||||
game_data = PlayGame.parse_raw(await redis.get(f"game:{session['game_pin']}"))
|
||||
game_data.question_show = False
|
||||
await redis.set(f"game:{session['game_pin']}", game_data.json())
|
||||
game_pin = session["game_pin"]
|
||||
await sio.emit("question_results", redis_res, room=game_pin)
|
||||
|
||||
@@ -244,6 +267,7 @@ async def set_question_number(sid, data: str):
|
||||
game_pin = session["game_pin"]
|
||||
game_data = PlayGame.parse_raw(await redis.get(f"game:{session['game_pin']}"))
|
||||
game_data.current_question = int(float(data))
|
||||
game_data.question_show = True
|
||||
await redis.set(f"game:{session['game_pin']}", game_data.json(), ex=7200)
|
||||
await redis.set(f"game:{session['game_pin']}:current_time", datetime.now().isoformat(), ex=7200)
|
||||
temp_return = game_data.dict(include={"questions"})["questions"][int(float(data))]
|
||||
@@ -323,7 +347,6 @@ async def submit_answer(sid: str, data: dict):
|
||||
data.answer = ", ".join(answer_order)
|
||||
if correct_answers == data.dict()["complex_answer"]:
|
||||
answer_right = True
|
||||
# TODO Set data.answer to a real value for export
|
||||
|
||||
elif game_data.questions[int(float(data.question_index))].type == QuizQuestionType.TEXT:
|
||||
answer_right = False
|
||||
@@ -344,10 +367,6 @@ async def submit_answer(sid: str, data: dict):
|
||||
diff = (time_q_started - now).total_seconds() * 1000 # - timedelta(milliseconds=latency)
|
||||
|
||||
# print(abs(diff) - latency, latency, abs(diff))
|
||||
def calculate_score(z: float, t: int) -> int:
|
||||
t = t * 1000
|
||||
res = (t - z) / t
|
||||
return int(res * 1000)
|
||||
|
||||
score = 0
|
||||
if answer_right:
|
||||
@@ -355,39 +374,16 @@ async def submit_answer(sid: str, data: dict):
|
||||
abs(diff) - latency, int(float(game_data.questions[int(float(data.question_index))].time))
|
||||
)
|
||||
await redis.hincrby(f"game_session:{session['game_pin']}:player_scores", session["username"], score)
|
||||
if answers is None:
|
||||
await redis.set(
|
||||
f"game_session:{session['game_pin']}:{data.question_index}",
|
||||
AnswerDataList(
|
||||
__root__=[
|
||||
AnswerData(
|
||||
username=session["username"],
|
||||
answer=data.answer,
|
||||
right=answer_right,
|
||||
time_taken=abs(diff) - latency,
|
||||
score=score,
|
||||
)
|
||||
]
|
||||
).json(),
|
||||
ex=7200,
|
||||
)
|
||||
else:
|
||||
answers = AnswerDataList.parse_raw(answers)
|
||||
answers.__root__.append(
|
||||
AnswerData(
|
||||
username=session["username"],
|
||||
answer=data.answer,
|
||||
right=answer_right,
|
||||
time_taken=abs(diff) - latency,
|
||||
score=score,
|
||||
)
|
||||
)
|
||||
await redis.set(
|
||||
f"game_session:{session['game_pin']}:{data.question_index}",
|
||||
answers.json(),
|
||||
ex=7200,
|
||||
)
|
||||
answers = AnswerDataList.parse_raw(await redis.get(f"game_session:{session['game_pin']}:{data.question_index}"))
|
||||
answer_data = AnswerData(
|
||||
username=session["username"],
|
||||
answer=data.answer,
|
||||
right=answer_right,
|
||||
time_taken=abs(diff) - latency,
|
||||
score=score,
|
||||
)
|
||||
answers = await set_answer(
|
||||
answers, game_pin=session["game_pin"], data=answer_data, q_index=int(float(data.question_index))
|
||||
)
|
||||
player_count = await redis.scard(f"game_session:{session['game_pin']}:players")
|
||||
if len(answers.__root__) == player_count:
|
||||
# await sio.emit(
|
||||
@@ -395,6 +391,9 @@ async def submit_answer(sid: str, data: dict):
|
||||
# await redis.get(f"game_session:{session['game_pin']}:{data.question_index}"),
|
||||
# room=session["game_pin"],
|
||||
# )
|
||||
game_data = PlayGame.parse_raw(await redis.get(f"game:{session['game_pin']}"))
|
||||
game_data.question_show = False
|
||||
await redis.set(f"game:{session['game_pin']}", game_data.json())
|
||||
await sio.emit("everyone_answered", {})
|
||||
|
||||
|
||||
|
||||
@@ -10,12 +10,21 @@
|
||||
import { fade } from 'svelte/transition';
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { createTippy } from 'svelte-tippy';
|
||||
|
||||
export let quiz_id;
|
||||
let captcha_selected = false;
|
||||
let selected_game_mode = 'kahoot';
|
||||
let loading = false;
|
||||
let custom_field = '';
|
||||
let cqcs_enabled = false;
|
||||
|
||||
const tippy = createTippy({
|
||||
arrow: true,
|
||||
animation: 'perspective-subtle',
|
||||
placement: 'top-start',
|
||||
allowHTML: true
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const ls_data = localStorage.getItem('custom_field');
|
||||
@@ -26,16 +35,17 @@
|
||||
let res;
|
||||
loading = true;
|
||||
localStorage.setItem('custom_field', custom_field);
|
||||
const cqcs_enabled_parsed = cqcs_enabled ? 'True' : 'False';
|
||||
if (captcha_enabled && captcha_selected) {
|
||||
res = await fetch(
|
||||
`/api/v1/quiz/start/${id}?captcha_enabled=True&game_mode=${selected_game_mode}&custom_field=${custom_field}`,
|
||||
`/api/v1/quiz/start/${id}?captcha_enabled=True&game_mode=${selected_game_mode}&custom_field=${custom_field}&cqcs_enabled=${cqcs_enabled_parsed}`,
|
||||
{
|
||||
method: 'POST'
|
||||
}
|
||||
);
|
||||
} else {
|
||||
res = await fetch(
|
||||
`/api/v1/quiz/start/${id}?captcha_enabled=False&game_mode=${selected_game_mode}&custom_field=${custom_field}`,
|
||||
`/api/v1/quiz/start/${id}?captcha_enabled=False&game_mode=${selected_game_mode}&custom_field=${custom_field}&cqcs_enabled=${cqcs_enabled_parsed}`,
|
||||
{
|
||||
method: 'POST'
|
||||
}
|
||||
@@ -54,7 +64,9 @@
|
||||
const data = await res.json();
|
||||
// eslint-disable-next-line no-undef
|
||||
plausible('Started Game', { props: { quiz_id: id, game_id: data.game_id } });
|
||||
window.location.assign(`/admin?token=${data.game_id}&pin=${data.game_pin}&connect=1`);
|
||||
window.location.assign(
|
||||
`/admin?token=${data.game_id}&pin=${data.game_pin}&connect=1&cqc_code=${data.cqc_code}`
|
||||
);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -89,7 +101,7 @@
|
||||
</label>
|
||||
</div>
|
||||
{#if captcha_selected}
|
||||
<div class="flex justify-center mt-2" transition:fade>
|
||||
<div class="flex justify-center mt-2" in:fade>
|
||||
<p class="w-1/3">
|
||||
If enabled, Google's ReCaptcha will load in the browser of players. Only enable
|
||||
if you really need it, since you need the consent of <b>EVERY</b> player to load
|
||||
@@ -128,7 +140,7 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center items-center">
|
||||
<div class="flex justify-center items-center my-auto">
|
||||
<label class="mr-4">Custom Field</label>
|
||||
<input
|
||||
bind:value={custom_field}
|
||||
@@ -136,6 +148,36 @@
|
||||
placeholder="Phone Number or Email"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-center w-full my-auto">
|
||||
<label
|
||||
for="cqc-toggle"
|
||||
class="inline-flex relative items-center cursor-pointer"
|
||||
class:pointer-events-none={!captcha_enabled}
|
||||
class:opacity-50={!captcha_enabled}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={cqcs_enabled}
|
||||
id="cqc-toggle"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<span
|
||||
class="w-14 h-7 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-[4px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-6 after:w-6 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"
|
||||
/>
|
||||
<span class="ml-3 text-sm font-medium text-gray-900"
|
||||
><a
|
||||
href="/controller"
|
||||
target="_blank"
|
||||
use:tippy={{
|
||||
content:
|
||||
'ClassQuizControllers are small physical devices to play ClassQuiz. Click to learn more.'
|
||||
}}
|
||||
class="decoration-dashed underline cursor-help">ClassQuizControllers</a
|
||||
>
|
||||
are {cqcs_enabled ? 'enabled' : 'disabled'}</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="mt-auto mx-auto bg-green-500 p-4 rounded-lg shadow-lg hover:bg-green-400 transition-all marck-script text-2xl"
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<!--
|
||||
- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import AudioPlayer from '$lib/play/audio_player.svelte';
|
||||
import { getLocalization } from '$lib/i18n';
|
||||
|
||||
export let game_pin: string;
|
||||
export let players;
|
||||
export let socket;
|
||||
|
||||
export let cqc_code: string;
|
||||
const { t } = getLocalization();
|
||||
let play_music = false;
|
||||
|
||||
if (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;
|
||||
};
|
||||
const color_map = {
|
||||
r: 'red',
|
||||
g: 'green',
|
||||
y: 'yellow',
|
||||
b: 'blue'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="w-full h-full">
|
||||
<AudioPlayer bind:play={play_music} />
|
||||
<div class="grid grid-cols-3 mt-12">
|
||||
<div class="flex justify-center">
|
||||
<p class="m-auto text-2xl">
|
||||
Join at <b>{window.location.host}/play</b> and enter <b>{game_pin}</b>.
|
||||
</p>
|
||||
</div>
|
||||
<img
|
||||
alt="QR code to join the game"
|
||||
src="/api/v1/utils/qr/{game_pin}"
|
||||
class="block mx-auto w-1/2 dark:bg-white"
|
||||
/>
|
||||
{#if cqc_code}
|
||||
<div class="m-auto">
|
||||
<div class="flex-col flex justify-center">
|
||||
<p class="mx-auto">Join by entering the following code</p>
|
||||
<div class="flex flex-row gap-2 mx-auto">
|
||||
{#each cqc_code as c}
|
||||
<div class="flex flex-col">
|
||||
<p class="text-center">{c}</p>
|
||||
<span
|
||||
style="background-color: {color_map[
|
||||
c.toLowerCase()
|
||||
]}; width: 2rem; height: {c.toLowerCase() == c ? '2' : '4'}rem"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-3xl text-center ">{$t('words.pin')}: {game_pin}</p>
|
||||
<div class="flex justify-center w-full mt-4">
|
||||
<ul class="list-disc pl-8">
|
||||
{#if players.length > 0}
|
||||
{#each players as player}
|
||||
<li>
|
||||
<span
|
||||
class="hover:line-through"
|
||||
on:click={() => {
|
||||
kick_player(player.username);
|
||||
}}>{player.username}</span
|
||||
>
|
||||
<!-- <button>{$t('words.kick')}</button>-->
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
{#if players.length > 0}
|
||||
<div class="flex justify-center w-full mt-4">
|
||||
<button
|
||||
class="ml-4 admin-button"
|
||||
id="startGame"
|
||||
on:click={() => {
|
||||
socket.emit('start_game', '');
|
||||
}}
|
||||
>{$t('admin_page.start_game')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -11,11 +11,12 @@
|
||||
import { navbarVisible } from '$lib/stores';
|
||||
import type { PlayerAnswer, Player } from '$lib/admin.ts';
|
||||
import SomeAdminScreen from '$lib/admin.svelte';
|
||||
import AudioPlayer from '$lib/play/audio_player.svelte';
|
||||
import GameNotStarted from '$lib/play/admin/game_not_started.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
import FinalResults from '$lib/play/admin/final_results.svelte';
|
||||
import GrayButton from '$lib/components/buttons/gray.svelte';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
navbarVisible.set(false);
|
||||
|
||||
@@ -41,7 +42,6 @@
|
||||
let success = false;
|
||||
let dataexport_download_a;
|
||||
let warnToLeave = true;
|
||||
let play_music = false;
|
||||
|
||||
const connect = async () => {
|
||||
socket.emit('register_as_admin', {
|
||||
@@ -127,17 +127,6 @@
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
let bg_color;
|
||||
let bg_image;
|
||||
let results_saved = false;
|
||||
@@ -201,46 +190,12 @@
|
||||
<p class="text-red-700">{errorMessage}</p>
|
||||
{/if}
|
||||
{:else if !game_started}
|
||||
<div class="w-full h-full">
|
||||
<AudioPlayer bind:play={play_music} />
|
||||
<div>
|
||||
<img
|
||||
alt="QR code to join the game"
|
||||
src="/api/v1/utils/qr/{quiz_data.game_pin}"
|
||||
class="block mx-auto w-1/6 mt-12 dark:bg-white"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-3xl text-center ">{$t('words.pin')}: {quiz_data.game_pin}</p>
|
||||
<div class="flex justify-center w-full mt-4">
|
||||
<ul class="list-disc pl-8">
|
||||
{#if players.length > 0}
|
||||
{#each players as player}
|
||||
<li>
|
||||
<span
|
||||
class="hover:line-through"
|
||||
on:click={() => {
|
||||
kick_player(player.username);
|
||||
}}>{player.username}</span
|
||||
>
|
||||
<!-- <button>{$t('words.kick')}</button>-->
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
{#if players.length > 0}
|
||||
<div class="flex justify-center w-full mt-4">
|
||||
<button
|
||||
class="ml-4 admin-button"
|
||||
id="startGame"
|
||||
on:click={() => {
|
||||
socket.emit('start_game', '');
|
||||
}}
|
||||
>{$t('admin_page.start_game')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<GameNotStarted
|
||||
{game_pin}
|
||||
bind:players
|
||||
{socket}
|
||||
cqc_code={$page.url.searchParams.get('cqc_code')}
|
||||
/>
|
||||
{:else}
|
||||
<SomeAdminScreen
|
||||
bind:final_results
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<!--
|
||||
- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div>
|
||||
<h1 class="text-center text-8xl marck-script mt-12">ClassQuizController</h1>
|
||||
<div>
|
||||
<p class="text-center">Play a quiz with a physical controller, not on a touchscreen!</p>
|
||||
</div>
|
||||
<p class="text-center text-4xl m-12">More infos will follow soon</p>
|
||||
</div>
|
||||
@@ -122,3 +122,9 @@ Holds the custom-field data, but is only set if the custom-field is enabled.
|
||||
|
||||
|
||||
data: `{PLAYER_NAME} = {CUSTOM_FIELD_VALUE}`
|
||||
|
||||
## game:cqb:code:{cqc_code} [string]
|
||||
|
||||
{cqc_code} is the code used to join with a **C**lass**Q**uiz**C**ontroller
|
||||
|
||||
Only holds the game-pin
|
||||
|
||||
Reference in New Issue
Block a user