From ded3328d06480d2552b8fd4e1ea57e6566af78e3 Mon Sep 17 00:00:00 2001 From: juhnsa Date: Tue, 15 Jul 2025 23:26:18 +0200 Subject: [PATCH] First Iteration of Map Question Type --- django/library/views.py | 102 +++++++++----- django/play/consumers/game.py | 113 ++++++++++++++- django/static/css/t-input.css | 9 ++ django/templates/base.html | 3 + django/templates/library/detail_quiz.html | 61 ++++++-- .../library/question/question_map.html | 131 ++++++++++++++++++ django/templates/play/game/question_host.html | 96 +++++++++++-- .../play/game/question_participant.html | 109 ++++++++++++++- .../templates/play/game/score_overview.html | 97 ++++++++++++- requirements.txt | 4 +- 10 files changed, 660 insertions(+), 65 deletions(-) create mode 100644 django/templates/library/question/question_map.html diff --git a/django/library/views.py b/django/library/views.py index 5da7064..bc62d6f 100644 --- a/django/library/views.py +++ b/django/library/views.py @@ -861,12 +861,12 @@ def new_question(request): messages.error(request, 'Quiz nicht gefunden oder keine Berechtigung.') return redirect('library:overview_quiz') - if question_type not in ['multiple_choice', 'true_false','input','order','guess']: + if question_type not in ['multiple_choice', 'true_false','input','order','guess','map']: base_url = reverse('library:new_question') return redirect(f'{base_url}?type=multiple_choice&quiz_id={quiz_id}') if request.method == 'POST': - question_text = request.POST.get('question', '') + question_text = request.POST.get('question', 'Kein Fragentext angegeben') # Handle different question types if question_type == 'true_false': @@ -946,20 +946,27 @@ def new_question(request): max_value = request.POST.get('max') tolerance = request.POST.get('tolerance') json_data = { - 'type': question_type, - 'question': question_text, - 'correct_answer': correct_answer, - 'min': min_value, - 'max': max_value, - 'tolerance': tolerance, - 'options': [ - {'value': correct_answer, 'is_correct': True} - ] - } - - - + 'type': question_type, + 'question': question_text, + 'correct_answer': correct_answer, + 'min': min_value, + 'max': max_value, + 'tolerance': tolerance, + 'options': [ + {'value': correct_answer, 'is_correct': True} + ] + } + elif question_type == 'map': + target_location = request.POST.get('location') + tolerance_radius = request.POST.get('tolerance_radius') + + json_data = { + 'type': question_type, + 'question': question_text, + 'target_location': target_location, + 'tolerance_radius': tolerance_radius + } question = QivipQuestion() question.data = json.dumps(json_data) question.quiz_id = quiz @@ -1030,18 +1037,26 @@ def new_question(request): max_value = request.POST.get('max') tolerance = request.POST.get('tolerance') json_data = { - 'type': question_type, - 'question': '', - 'correct_answer': correct_answer, - 'min': min_value, - 'max': max_value, - 'tolerance': tolerance, - 'options': [ - {'value': correct_answer, 'is_correct': True} - ] - } + 'type': question_type, + 'question': '', + 'correct_answer': correct_answer, + 'min': min_value, + 'max': max_value, + 'tolerance': tolerance, + 'options': [ + {'value': correct_answer, 'is_correct': True} + ] + } standard_time_per_question = 30 + elif question_type == 'map': + question_data = { + 'type': question_type, + 'question': '', + 'target_location': "", + 'tolerance_radius': 90000 + } + standard_time_per_question = 30 template_name = f'library/question/question_{question_type}.html' context = { @@ -1083,6 +1098,13 @@ def edit_question(request, pk): {'value': 'Falsch', 'is_correct': not correct_answer} ] } + elif question_type == 'map': + question_data = { + 'type': question_type, + 'question': question_data.get('question', ''), + 'target_location': question_data.get('target_location', ''), + 'tolerance_radius': question_data.get('tolerance_radius', 90000) + } else: # For multiple choice questions question_data.setdefault('type', question_type) @@ -1200,17 +1222,27 @@ def edit_question(request, pk): max_value = request.POST.get('max') tolerance = request.POST.get('tolerance') json_data = { - 'type': question_type, - 'question': question_text, - 'correct_answer': correct_answer, - 'min': min_value, - 'max': max_value, - 'tolerance': tolerance, - 'options': [ - {'value': correct_answer, 'is_correct': True} - ] - } + 'type': question_type, + 'question': question_text, + 'correct_answer': correct_answer, + 'min': min_value, + 'max': max_value, + 'tolerance': tolerance, + 'options': [ + {'value': correct_answer, 'is_correct': True} + ] + } + elif question_type == 'map': + target_location = request.POST.get('location') + tolerance_radius = request.POST.get('tolerance_radius') + + json_data = { + 'type': question_type, + 'question': question_text, + 'target_location': target_location, + 'tolerance_radius': tolerance_radius + } question.data = json.dumps(json_data) question.time_per_question = request.POST.get('time_per_question', '30') try: diff --git a/django/play/consumers/game.py b/django/play/consumers/game.py index a227b4e..088343e 100644 --- a/django/play/consumers/game.py +++ b/django/play/consumers/game.py @@ -6,6 +6,7 @@ from django.utils import timezone from django.urls import reverse from play.models import QuizGame, QuizGameParticipant, QuizAnswer from django.conf import settings +from geopy.distance import geodesic class GameConsumer(AsyncWebsocketConsumer): @@ -31,6 +32,13 @@ class GameConsumer(AsyncWebsocketConsumer): # Check if game should be deleted await self.cleanup_game() + + @database_sync_to_async + def get_question_data(self): + quiz_game = QuizGame.objects.get(join_code=self.join_code) + current_question = quiz_game.quiz_id.questions.all()[quiz_game.current_question_index] + return json.loads(current_question.data) + async def receive(self, text_data): text_data_json = json.loads(text_data) message_type = text_data_json['type'] @@ -84,14 +92,16 @@ class GameConsumer(AsyncWebsocketConsumer): elif message_type == 'submit_answer': participant_id = text_data_json['participant_id'] - try: - answer = text_data_json['answer'] - except: - answer=-1 + answer = text_data_json['answer'] time_remaining = text_data_json.get('time_remaining', 0) - # Save answer and update participant score - await self.save_answer(participant_id, answer, time_remaining) + + question_data = await self.get_question_data() + if question_data['type'] == "map": + await self.save_answer_map(participant_id, answer, time_remaining) + else: + await self.save_answer(participant_id, answer, time_remaining) + # Notify host about the new answer await self.channel_layer.group_send( @@ -182,6 +192,97 @@ class GameConsumer(AsyncWebsocketConsumer): except QuizGame.DoesNotExist: return False + @database_sync_to_async + def save_answer_map(self, participant_id, answer_location, time_remaining): + answer_index = -1 + try: + # Get all necessary data first + participant = QuizGameParticipant.objects.get( + quiz_game__join_code=self.join_code, + participant_id=participant_id + ) + quiz_game = participant.quiz_game + current_question = quiz_game.quiz_id.questions.all()[quiz_game.current_question_index] + question_data = json.loads(current_question.data) + target_location = question_data['target_location'] + tolerance_radius = question_data['tolerance_radius'] + print(f"target_location: {target_location}") + print(f"answer_location: {answer_location}") + print(f"tolerance_radius: {tolerance_radius}") + + # Configuration - Make these easily adjustable + MAX_DISTANCE_KM = 1000 # Maximum distance for scoring (in km) + DIRECT_HIT_BONUS = 200 # Extra points for direct hit within tolerance + + # Calculate base score + distance_km = geodesic(answer_location, target_location).kilometers + tolerance_km = int(tolerance_radius) / 1000 # Convert meters to kilometers + + # Calculate base score with non-linear decrease + if distance_km <= tolerance_km: + # Direct hit - full points + bonus + base_score = 1000 + DIRECT_HIT_BONUS # 1000 + 200 bonus + is_correct = True + else: + if distance_km > MAX_DISTANCE_KM: + # Beyond max distance - 0 points + base_score = 0 + is_correct = False + else: + # Non-linear decrease using exponential falloff + # This creates a steeper drop-off at the beginning + progress = (distance_km - tolerance_km) / (MAX_DISTANCE_KM - tolerance_km) + # Using a power function to create non-linear falloff + falloff = progress ** 1.5 # 1.5 makes the falloff steeper at the beginning + base_score = 1000 * (1 - falloff) + is_correct = True + base_score = max(0, min(1000, int(base_score))) + + # Apply time penalty (reduces score based on time taken) + if is_correct and base_score > 0: + # Calculate time taken as a fraction of total time (0-1) + time_taken_sec = (int(current_question.time_per_question) * 1000 - time_remaining) / 1000 + max_time_sec = int(current_question.time_per_question) + time_fraction = min(1.0, time_taken_sec / max_time_sec) if max_time_sec > 0 else 1.0 + + # Reduce points based on time taken (up to 50% reduction for answering at the last second) + time_penalty = base_score * 0.5 * time_fraction # Up to 50% penalty + final_score = max(1, base_score - time_penalty) # At least 1 point if correct + else: + final_score = base_score # 0 points for incorrect answers + + score = int(round(final_score)) + + # Save the answer + answer_obj, created = QuizAnswer.objects.get_or_create( + participant=participant, + question_index=quiz_game.current_question_index, + defaults={ + 'answer_index': answer_index, + 'is_correct': is_correct, + 'score': score, + 'time_remaining': time_remaining + } + ) + + if not created: + answer_obj.answer_index = answer_index + answer_obj.is_correct = is_correct + answer_obj.score = score + answer_obj.time_remaining = time_remaining + answer_obj.save() + + participant.last_score = score + participant.score += score + participant.last_answer_correct = is_correct + participant.save() + + return True + + except (QuizGameParticipant.DoesNotExist, IndexError): + return False + + @database_sync_to_async def save_answer(self, participant_id, answer, time_remaining): try: diff --git a/django/static/css/t-input.css b/django/static/css/t-input.css index bc9d632..bf58810 100644 --- a/django/static/css/t-input.css +++ b/django/static/css/t-input.css @@ -124,4 +124,13 @@ div.qp-question-container { div.qp-question-container img { @apply max-w-[50vw] max-h-[40vh] mx-auto; +} + +div#mapquestion-container { + display: flex; + justify-content: center; +} + +div#map { + @apply md:w-[600px] lg:w-[900px] h-[400px] } \ No newline at end of file diff --git a/django/templates/base.html b/django/templates/base.html index b834707..5ebe66c 100644 --- a/django/templates/base.html +++ b/django/templates/base.html @@ -9,6 +9,9 @@ qivip +
diff --git a/django/templates/library/detail_quiz.html b/django/templates/library/detail_quiz.html index 111b946..a7cb110 100644 --- a/django/templates/library/detail_quiz.html +++ b/django/templates/library/detail_quiz.html @@ -98,9 +98,6 @@ Eingabe - - - Multiple Choice @@ -116,10 +113,15 @@ Reihenfolge - Schätzen + + + Karte + {% endif %}
@@ -140,8 +142,14 @@

{{ question.data.question }}

- - {% if question.data.type == 'multiple_choice' %}Multiple Choice{% elif question.data.type == 'input' %}Eingabe{% elif question.data.type == 'true_false' %}Wahr/Falsch{% elif question.data.type == 'order' %}Reihenfolge{% else %}Schätzen{% endif %} + + {% if question.data.type == 'multiple_choice' %}Multiple Choice + {% elif question.data.type == 'input' %}Eingabe + {% elif question.data.type == 'map' %}Karte + {% elif question.data.type == 'true_false' %}Wahr/Falsch + {% elif question.data.type == 'order' %}Reihenfolge + {% else %}Schätzen + {% endif %}
@@ -173,8 +181,46 @@ Bild der Frage {% endif %}
+ + {% elif question.data.type == 'map' and detail %} +
+
+ Zielort: + + {% if question.data.target_location %} + {{ question.data.target_location }} + {% else %} + Kein Zielort festgelegt + {% endif %} + +
+ {% if question.data.target_location %} + + {% endif %} + {% if question.data.tolerance_radius %} +
+ Toleranzradius: {{ question.data.tolerance_radius }} Meter +
+ {% endif %} + {% if question.image.image.url %} + Bild der Frage + {% endif %} +
{% elif question.data.type == 'order' %} -
+
    {% for option in question.data.options|dictsort:"order" %}
  • @@ -188,7 +234,6 @@ {% endif %}
- {% elif question.data.type == 'input' %}
    diff --git a/django/templates/library/question/question_map.html b/django/templates/library/question/question_map.html new file mode 100644 index 0000000..db8461d --- /dev/null +++ b/django/templates/library/question/question_map.html @@ -0,0 +1,131 @@ +{% extends 'base.html' %} + +{% block content %} + +
    +
    +
    + {% csrf_token %} +
    + + +
    + {% if image %} + Bild der Frage + + {% endif %} +
    + + +
    +
    Ziel auswählen
    +
    +
    + + +
    + +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    + +{% endblock %} \ No newline at end of file diff --git a/django/templates/play/game/question_host.html b/django/templates/play/game/question_host.html index f3a168c..9d08997 100644 --- a/django/templates/play/game/question_host.html +++ b/django/templates/play/game/question_host.html @@ -50,8 +50,7 @@
- {% if question_data.type == "multiple_choice" or question_data.type == "true_false" %} - + {% if question_data.type == "multiple_choice" or question_data.type == "true_false" %}
{% for option in question_data.options %} +
+ + + {% elif question_data.type == "map" and request.session.mode != "learn" %} +

Bitte den gesuchten Ort auf der Karte auswählen

+ {% elif question_data.type == "guess" %} {% if request.session.mode == "learn" %}
@@ -193,11 +277,7 @@ class="px-6 py-2 bg-blue-500 hover:bg-blue-600 text-white font-semibold rounded-
{% endif %} - - {% endif %} - -
{% endblock %} diff --git a/django/templates/play/game/question_participant.html b/django/templates/play/game/question_participant.html index 74c3632..9bc0c23 100644 --- a/django/templates/play/game/question_participant.html +++ b/django/templates/play/game/question_participant.html @@ -25,8 +25,7 @@
- - {% if question_data.type == "multiple_choice" or question_data.type == "true_false" %} + {% if question_data.type == "multiple_choice" or question_data.type == "true_false" %}
{% for option in question_data.options %} +
+ + {% else %} +

aaaaah gibts nicht

{% endif %} @@ -203,7 +254,22 @@ wsUtil.handleClose(e); }; - function submitAnswer(optionIndex) { + function submitAnswer(optionIndex=0, has_options=true) { + if (!has_options){ + answer = document.getElementById("singleanswer").value; + + if (answer == ""){ + alert("Bitte eine Antwort eingeben!"); + return; + } + + gameSocket.send(JSON.stringify({ + type: 'submit_answer', + participant_id: participantId, + answer:answer, + time_remaining: questionDuration + })); + } if (hasAnswered) return; let input = ""; // Declare input here @@ -249,7 +315,42 @@ } } - + function submitAnswerMap() { + answer = document.getElementById("location").value; + + if (answer == ""){ + alert("Bitte eine Antwort eingeben!"); + return; + } + + // Add grey overlay + const overlay = document.createElement('div'); + overlay.style.position = 'fixed'; + overlay.style.top = '0'; + overlay.style.left = '0'; + overlay.style.width = '100%'; + overlay.style.height = '100%'; + overlay.style.backgroundColor = 'rgba(128, 128, 128, 0.5)'; + overlay.style.zIndex = '1000'; + overlay.style.display = 'flex'; + overlay.style.justifyContent = 'center'; + overlay.style.alignItems = 'center'; + overlay.style.color = 'white'; + overlay.style.fontSize = '24px'; + overlay.style.fontWeight = 'bold'; + overlay.textContent = 'Antwort übermittelt'; + document.body.appendChild(overlay); + + const now = Date.now(); + const timeRemaining = Math.max(0, questionDuration - (now - questionStartTime)); + + gameSocket.send(JSON.stringify({ + type: 'submit_answer', + participant_id: participantId, + answer: answer, + time_remaining: timeRemaining + })); + } // Timer function updateTimer() { diff --git a/django/templates/play/game/score_overview.html b/django/templates/play/game/score_overview.html index 7230ee2..bdc76d8 100644 --- a/django/templates/play/game/score_overview.html +++ b/django/templates/play/game/score_overview.html @@ -9,9 +9,100 @@

Aktuelle Frage

{{ question_data.question }}

- {% if question_data.type != "order" and question_data.type != "guess" %} + + {% if question_data.type == "map" %} + +
+
+
+ + + + + {% elif question_data.type != "order" and question_data.type != "guess" %} +
- {% for option in question_data.options %}
@@ -25,7 +116,7 @@
{% endfor %}
- {% elif question_data.type == "guess" %} + {% elif question_data.type == "guess" %}
diff --git a/requirements.txt b/requirements.txt index 376bf71..be6814f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,6 @@ python-dotenv # Production server safety qrcode~=7.4.2 reportlab~=4.2.0 pip-licenses ~= 5.0.0 -requests \ No newline at end of file +requests +requests +geopy