Add core game retry flow and question stats

This commit is contained in:
hz
2026-07-10 14:05:57 -05:00
parent 1822c00a3b
commit e991dccbb2
17 changed files with 1036 additions and 80 deletions
+211 -1
View File
@@ -9,10 +9,26 @@ var mode := "Tutor"
var system_name := "All Systems"
var seconds_remaining := 0.0
var block_started := false
var session_kind := "block"
var core_question_pool: Array = []
var core_answer_count := 0
var core_score := 0
var core_energy := 70
var core_target_seconds := 90.0
var core_current_target_seconds := 90.0
var core_current_had_wrong_attempt := false
var current_question_started_at := 0.0
var current_question_pause_started_at := 0.0
var core_status_message := ""
const CORE_MIN_TARGET_SECONDS := 45.0
const CORE_MAX_TARGET_SECONDS := 150.0
const CORE_RETRY_TARGET_SECONDS := 30.0
func start_block(selected_system: String, block_size: int, selected_mode: String) -> void:
system_name = selected_system
mode = selected_mode
session_kind = "block"
active_questions = QuestionBankService.build_block(selected_system, block_size)
answers.clear()
answers.resize(active_questions.size())
@@ -22,6 +38,31 @@ func start_block(selected_system: String, block_size: int, selected_mode: String
session_changed.emit()
func start_core_run() -> bool:
system_name = "All Systems"
mode = "Core"
session_kind = "core"
active_questions.clear()
answers.clear()
core_question_pool.clear()
current_index = -1
seconds_remaining = 0.0
block_started = true
core_answer_count = 0
core_score = 0
core_energy = 70
core_target_seconds = 90.0
core_current_target_seconds = core_target_seconds
core_current_had_wrong_attempt = false
core_status_message = ""
if not advance_core_question():
block_started = false
core_status_message = "All available questions cleared."
session_changed.emit()
return false
return true
func get_current_question() -> Dictionary:
if active_questions.is_empty() or current_index < 0 or current_index >= active_questions.size():
return {}
@@ -38,13 +79,57 @@ func answer_current(choice_id: String, eliminated: Array, flagged: bool) -> Dict
"selected_choice_id": choice_id,
"is_correct": choice_id == str(question.get("correct_choice_id", "")),
"eliminated_choice_ids": eliminated.duplicate(),
"flagged": flagged
"flagged": flagged,
"elapsed_seconds": get_current_elapsed_seconds(),
"target_seconds": core_target_seconds if session_kind == "core" else 0.0
}
answers[current_index] = answer
if session_kind == "core":
_apply_core_answer_rewards(answer)
session_changed.emit()
return answer
func submit_core_attempt(choice_id: String, eliminated: Array, flagged: bool) -> Dictionary:
var question := get_current_question()
if question.is_empty():
return {}
var answer := {
"question": question.duplicate(true),
"selected_choice_id": choice_id,
"is_correct": choice_id == str(question.get("correct_choice_id", "")),
"eliminated_choice_ids": eliminated.duplicate(),
"flagged": flagged,
"elapsed_seconds": get_current_elapsed_seconds(),
"target_seconds": core_current_target_seconds
}
if bool(answer.get("is_correct", false)):
var mastered := not core_current_had_wrong_attempt
answers[current_index] = answer
_apply_core_answer_rewards(answer, mastered)
session_changed.emit()
return {
"answer": answer,
"is_correct": true,
"final": true,
"mastered": mastered
}
ProfileService.record_wrong_attempt(question)
core_current_had_wrong_attempt = true
_apply_core_wrong_penalty(answer)
_start_core_retry_window()
session_changed.emit()
return {
"answer": answer,
"is_correct": false,
"final": false,
"mastered": false
}
func move_previous() -> bool:
if current_index > 0:
current_index -= 1
@@ -54,6 +139,8 @@ func move_previous() -> bool:
func move_next() -> bool:
if session_kind == "core":
return advance_core_question()
if current_index + 1 < active_questions.size():
current_index += 1
session_changed.emit()
@@ -70,10 +157,17 @@ func move_to(index: int) -> bool:
func get_progress_text() -> String:
if session_kind == "core":
return str(core_answer_count + 1)
return "%d / %d" % [min(current_index + 1, active_questions.size()), active_questions.size()]
func finish_block() -> Dictionary:
if session_kind == "core":
block_started = false
session_changed.emit()
return {}
var final_answers: Array = []
for index in range(active_questions.size()):
if answers[index] == null:
@@ -102,3 +196,119 @@ func get_answer_for_index(index: int) -> Variant:
if index < 0 or index >= answers.size():
return null
return answers[index]
func advance_core_question() -> bool:
var question := _draw_core_question()
if question.is_empty():
core_status_message = "All available questions cleared."
return false
active_questions.append(question)
answers.append(null)
current_index = active_questions.size() - 1
current_question_started_at = Time.get_ticks_msec() / 1000.0
current_question_pause_started_at = 0.0
core_current_target_seconds = core_target_seconds
core_current_had_wrong_attempt = false
session_changed.emit()
return true
func get_current_elapsed_seconds() -> float:
if current_question_started_at <= 0.0:
return 0.0
var now := current_question_pause_started_at if current_question_pause_started_at > 0.0 else Time.get_ticks_msec() / 1000.0
return max(0.0, now - current_question_started_at)
func get_core_timer_ratio() -> float:
if session_kind != "core" or core_current_target_seconds <= 0.0:
return 1.0
var ratio := 1.0 - (get_current_elapsed_seconds() / core_current_target_seconds)
return clamp(ratio, 0.0, 1.0)
func pause_current_question_timer() -> void:
if session_kind == "core" and current_question_pause_started_at <= 0.0:
current_question_pause_started_at = Time.get_ticks_msec() / 1000.0
func resume_current_question_timer() -> void:
if session_kind == "core" and current_question_pause_started_at > 0.0:
var paused_duration := Time.get_ticks_msec() / 1000.0 - current_question_pause_started_at
current_question_started_at += paused_duration
current_question_pause_started_at = 0.0
func _draw_core_question() -> Dictionary:
if core_question_pool.is_empty():
core_question_pool = _build_core_question_pool()
core_question_pool.shuffle()
if core_question_pool.is_empty():
return {}
var last_id := str(get_current_question().get("id", ""))
for index in range(core_question_pool.size()):
var candidate: Dictionary = core_question_pool[index]
if str(candidate.get("id", "")) != last_id or core_question_pool.size() == 1:
core_question_pool.remove_at(index)
return candidate.duplicate(true)
return {}
func _build_core_question_pool() -> Array:
var pool: Array = []
var profile := ProfileService.get_profile()
var correct_ids: Array = profile.get("correct_question_ids", [])
for question in QuestionBankService.get_all_questions():
var question_id := str(question.get("id", ""))
if question_id == "" or not correct_ids.has(question_id):
pool.append(question)
return pool
func _start_core_retry_window() -> void:
core_current_target_seconds = CORE_RETRY_TARGET_SECONDS
current_question_started_at = Time.get_ticks_msec() / 1000.0
current_question_pause_started_at = 0.0
func _apply_core_wrong_penalty(_answer: Dictionary) -> void:
core_score -= 25
core_energy = max(0, core_energy - 14)
func _apply_core_answer_rewards(answer: Dictionary, mastered: bool = true) -> void:
core_answer_count += 1
var elapsed := float(answer.get("elapsed_seconds", 0.0))
var target := core_current_target_seconds
if target < CORE_MIN_TARGET_SECONDS:
target = CORE_MIN_TARGET_SECONDS
var is_correct := bool(answer.get("is_correct", false))
var score_delta := 100 if is_correct else 0
if elapsed > target:
var overage_ratio := (elapsed - target) / target
if overage_ratio > 1.0:
overage_ratio = 1.0
score_delta -= int(round(40.0 * overage_ratio))
if not is_correct:
score_delta -= 25
core_score += score_delta
if is_correct:
core_energy = min(100, core_energy + 8)
else:
core_energy = max(0, core_energy - 14)
if elapsed > target:
core_energy = max(0, core_energy - 4)
if mastered and is_correct and elapsed <= target:
core_target_seconds = max(CORE_MIN_TARGET_SECONDS, core_target_seconds * 0.97)
elif not mastered or not is_correct or elapsed > target * 1.25:
core_target_seconds = min(CORE_MAX_TARGET_SECONDS, core_target_seconds * 1.05)
else:
core_target_seconds = max(CORE_MIN_TARGET_SECONDS, core_target_seconds * 0.99)
var results := ProgressionService.calculate_question_rewards(answer, elapsed, target, score_delta)
results["mastered"] = mastered
ProfileService.apply_question_results(results)
+110 -7
View File
@@ -11,8 +11,15 @@ var profile: Dictionary = {
"best_streak": 0,
"badges": [],
"completed_question_ids": [],
"correct_question_ids": [],
"question_wrong_counts": {},
"missed_question_ids": [],
"system_stats": {},
"block_history": []
"block_history": [],
"daily_date": "",
"daily_questions_answered": 0,
"daily_xp_earned": 0,
"daily_goal_questions": 10
}
func _ready() -> void:
@@ -21,6 +28,7 @@ func _ready() -> void:
func load_profile() -> void:
if not FileAccess.file_exists(SAVE_PATH):
_ensure_daily_current()
save_profile()
return
@@ -34,6 +42,8 @@ func load_profile() -> void:
for key in profile.keys():
if parsed.has(key):
profile[key] = parsed[key]
_ensure_tracking_fields()
_ensure_daily_current()
profile_changed.emit()
@@ -46,14 +56,18 @@ func save_profile() -> void:
func get_profile() -> Dictionary:
_ensure_daily_current()
return profile.duplicate(true)
func apply_block_results(results: Dictionary) -> void:
_ensure_daily_current()
profile["xp"] = int(profile["xp"]) + int(results.get("xp_earned", 0))
profile["level"] = ProgressionService.level_for_xp(int(profile["xp"]))
profile["current_streak"] = int(results.get("ending_streak", profile["current_streak"]))
profile["best_streak"] = max(int(profile["best_streak"]), int(profile["current_streak"]))
profile["daily_questions_answered"] = int(profile["daily_questions_answered"]) + int(results.get("total", 0))
profile["daily_xp_earned"] = int(profile["daily_xp_earned"]) + int(results.get("xp_earned", 0))
var unlocked_badges: Array = results.get("badges_unlocked", [])
for badge in unlocked_badges:
@@ -66,13 +80,10 @@ func apply_block_results(results: Dictionary) -> void:
var question_id := str(question.get("id", ""))
if question_id != "" and not profile["completed_question_ids"].has(question_id):
profile["completed_question_ids"].append(question_id)
_apply_question_tracking(question_id, bool(answer.get("is_correct", false)))
var system_name := str(question.get("system", "General"))
if not profile["system_stats"].has(system_name):
profile["system_stats"][system_name] = {"seen": 0, "correct": 0}
profile["system_stats"][system_name]["seen"] = int(profile["system_stats"][system_name]["seen"]) + 1
if bool(answer.get("is_correct", false)):
profile["system_stats"][system_name]["correct"] = int(profile["system_stats"][system_name]["correct"]) + 1
_apply_system_result(system_name, bool(answer.get("is_correct", false)))
var summary: Dictionary = {
"timestamp": Time.get_datetime_string_from_system(),
@@ -98,8 +109,100 @@ func reset_profile() -> void:
"best_streak": 0,
"badges": [],
"completed_question_ids": [],
"correct_question_ids": [],
"question_wrong_counts": {},
"missed_question_ids": [],
"system_stats": {},
"block_history": []
"block_history": [],
"daily_date": _today_string(),
"daily_questions_answered": 0,
"daily_xp_earned": 0,
"daily_goal_questions": 10
}
save_profile()
profile_changed.emit()
func apply_question_results(results: Dictionary) -> void:
_ensure_daily_current()
profile["xp"] = int(profile["xp"]) + int(results.get("xp_earned", 0))
profile["level"] = ProgressionService.level_for_xp(int(profile["xp"]))
profile["current_streak"] = int(results.get("ending_streak", profile["current_streak"]))
profile["best_streak"] = max(int(profile["best_streak"]), int(profile["current_streak"]))
profile["daily_questions_answered"] = int(profile["daily_questions_answered"]) + int(results.get("total", 0))
profile["daily_xp_earned"] = int(profile["daily_xp_earned"]) + int(results.get("xp_earned", 0))
var unlocked_badges: Array = results.get("badges_unlocked", [])
for badge in unlocked_badges:
if not profile["badges"].has(badge):
profile["badges"].append(badge)
var answers: Array = results.get("answers", [])
for answer in answers:
var question: Dictionary = answer.get("question", {})
var question_id := str(question.get("id", ""))
if question_id != "" and not profile["completed_question_ids"].has(question_id):
profile["completed_question_ids"].append(question_id)
_apply_question_tracking(question_id, bool(answer.get("is_correct", false)), bool(results.get("mastered", true)))
_apply_system_result(str(question.get("system", "General")), bool(answer.get("is_correct", false)))
save_profile()
profile_changed.emit()
func record_wrong_attempt(question: Dictionary) -> void:
_ensure_daily_current()
var question_id := str(question.get("id", ""))
_apply_question_tracking(question_id, false, false)
save_profile()
profile_changed.emit()
func _ensure_daily_current() -> void:
_ensure_tracking_fields()
var today := _today_string()
var changed := false
if not profile.has("daily_goal_questions"):
profile["daily_goal_questions"] = 10
changed = true
if str(profile.get("daily_date", "")) != today:
profile["daily_date"] = today
profile["daily_questions_answered"] = 0
profile["daily_xp_earned"] = 0
changed = true
if changed:
save_profile()
func _ensure_tracking_fields() -> void:
if not profile.has("correct_question_ids"):
profile["correct_question_ids"] = []
if not profile.has("question_wrong_counts"):
profile["question_wrong_counts"] = {}
if not profile.has("missed_question_ids"):
profile["missed_question_ids"] = []
func _apply_question_tracking(question_id: String, is_correct: bool, mastered: bool = true) -> void:
if question_id == "":
return
if is_correct and mastered:
if not profile["correct_question_ids"].has(question_id):
profile["correct_question_ids"].append(question_id)
elif not is_correct:
if not profile["missed_question_ids"].has(question_id):
profile["missed_question_ids"].append(question_id)
profile["question_wrong_counts"][question_id] = int(profile["question_wrong_counts"].get(question_id, 0)) + 1
func _apply_system_result(system_name: String, is_correct: bool) -> void:
if not profile["system_stats"].has(system_name):
profile["system_stats"][system_name] = {"seen": 0, "correct": 0}
profile["system_stats"][system_name]["seen"] = int(profile["system_stats"][system_name]["seen"]) + 1
if is_correct:
profile["system_stats"][system_name]["correct"] = int(profile["system_stats"][system_name]["correct"]) + 1
func _today_string() -> String:
var date := Time.get_datetime_dict_from_system()
return "%04d-%02d-%02d" % [int(date["year"]), int(date["month"]), int(date["day"])]
+33
View File
@@ -4,6 +4,7 @@ const XP_PER_LEVEL := 500
var badge_catalog := {
"First Block": "Complete your first question block.",
"First Run": "Answer your first core mode question.",
"Hot Streak": "Answer 5 questions correctly in a row.",
"Tenacious": "Complete 10 total questions.",
"Sharp Shooter": "Score at least 80% on a block.",
@@ -62,6 +63,38 @@ func calculate_block_rewards(answers: Array, mode: String, system_name: String)
}
func calculate_question_rewards(answer: Dictionary, elapsed_seconds: float, target_seconds: float, score_delta: int) -> Dictionary:
var is_correct := bool(answer.get("is_correct", false))
var profile: Dictionary = ProfileService.get_profile()
var ending_streak := int(profile.get("current_streak", 0)) + 1 if is_correct else 0
var xp := 20
if is_correct:
xp += 35
if elapsed_seconds <= target_seconds:
xp += 10
elif elapsed_seconds > target_seconds * 1.5:
xp = max(10, xp - 10)
var badges: Array[String] = []
_add_badge_if_needed(badges, profile, "First Run", true)
_add_badge_if_needed(badges, profile, "Hot Streak", ending_streak >= 5)
_add_badge_if_needed(badges, profile, "Tenacious", profile.get("completed_question_ids", []).size() + 1 >= 10)
return {
"mode": "Core",
"system": str(answer.get("question", {}).get("system", "All Systems")),
"correct": 1 if is_correct else 0,
"total": 1,
"accuracy": 1.0 if is_correct else 0.0,
"xp_earned": xp,
"score_delta": score_delta,
"ending_streak": ending_streak,
"longest_streak": ending_streak,
"badges_unlocked": badges,
"answers": [answer]
}
func _add_badge_if_needed(badges: Array[String], profile: Dictionary, badge_name: String, condition: bool) -> void:
if condition and not profile.get("badges", []).has(badge_name):
badges.append(badge_name)
+65 -3
View File
@@ -1,15 +1,20 @@
extends Node
const DEFAULT_QUESTIONS_API_URL := "https://s3q.ranaze.com/api/questions/search"
const RETRY_DELAYS := [2, 5, 10, 20, 30]
signal questions_changed
signal questions_load_failed(message: String)
var questions: Array = []
var request: HTTPRequest
var retry_timer: Timer
var questions_api_url := DEFAULT_QUESTIONS_API_URL
var is_loading := false
var last_error := ""
var load_state := "idle"
var retry_attempt := 0
var next_retry_seconds := 0
func _ready() -> void:
questions_api_url = str(ProjectSettings.get_setting("step3/questions_api_url", DEFAULT_QUESTIONS_API_URL))
@@ -17,13 +22,24 @@ func _ready() -> void:
request.request_completed.connect(_on_api_questions_loaded)
add_child(request)
retry_timer = Timer.new()
retry_timer.one_shot = true
retry_timer.timeout.connect(_on_retry_timer_timeout)
add_child(retry_timer)
func load_questions_from_api() -> void:
func load_questions_from_api(reset_retry: bool = true) -> void:
if AuthState.access_token.is_empty():
_set_load_error("Login required before loading questions.")
_set_load_error("Login required before loading questions.", false)
return
if reset_retry:
retry_attempt = 0
next_retry_seconds = 0
retry_timer.stop()
is_loading = true
load_state = "retrying" if retry_attempt > 0 else "loading"
last_error = ""
questions.clear()
questions_changed.emit()
@@ -63,6 +79,31 @@ func _on_api_questions_loaded(result: int, response_code: int, _headers: PackedS
questions = api_questions
last_error = ""
load_state = "ready"
retry_attempt = 0
next_retry_seconds = 0
retry_timer.stop()
questions_changed.emit()
func retry_now() -> void:
retry_attempt = 0
next_retry_seconds = 0
retry_timer.stop()
load_questions_from_api(false)
func clear_questions() -> void:
if request != null:
request.cancel_request()
if retry_timer != null:
retry_timer.stop()
questions.clear()
is_loading = false
last_error = ""
load_state = "idle"
retry_attempt = 0
next_retry_seconds = 0
questions_changed.emit()
@@ -101,14 +142,34 @@ func has_questions() -> bool:
return not questions.is_empty()
func _set_load_error(message: String) -> void:
func _set_load_error(message: String, schedule_retry: bool = true) -> void:
is_loading = false
last_error = message
push_warning(message)
questions_load_failed.emit(message)
if schedule_retry and not AuthState.access_token.is_empty():
_schedule_retry()
else:
load_state = "failed"
next_retry_seconds = 0
questions_changed.emit()
func _schedule_retry() -> void:
var delay_index := retry_attempt
if delay_index >= RETRY_DELAYS.size():
delay_index = RETRY_DELAYS.size() - 1
next_retry_seconds = int(RETRY_DELAYS[delay_index])
retry_attempt += 1
load_state = "retrying"
retry_timer.start(float(next_retry_seconds))
func _on_retry_timer_timeout() -> void:
next_retry_seconds = 0
load_questions_from_api(false)
func _is_valid_question(item: Variant) -> bool:
if typeof(item) != TYPE_DICTIONARY:
return false
@@ -132,6 +193,7 @@ func _normalize_api_question(item: Variant) -> Dictionary:
"correct_choice_id": str(item.get("correctChoiceId", item.get("correct_choice_id", ""))),
"explanation": str(item.get("explanation", "")),
"choice_explanations": item.get("choiceExplanations", item.get("choice_explanations", {})),
"choice_selection_percentages": item.get("choiceSelectionPercentages", item.get("choice_selection_percentages", {})),
"discipline": str(item.get("discipline", "")),
"system": str(item.get("system", "")),
"difficulty": int(item.get("difficulty", 1)),
+15 -2
View File
@@ -13,6 +13,8 @@ var start_button: Button
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
add_theme_constant_override("separation", 16)
if not QuestionBankService.questions_changed.is_connected(_refresh_setup):
QuestionBankService.questions_changed.connect(_refresh_setup)
var title := Label.new()
title.text = "Block Setup"
@@ -66,12 +68,23 @@ func _ready() -> void:
func on_show(payload: Variant = null) -> void:
selected_system = str(payload) if payload != null else "All Systems"
_refresh_setup()
func _refresh_setup() -> void:
if system_label == null:
return
system_label.text = "Zone: %s" % selected_system
var available := QuestionBankService.get_questions_for_system(selected_system).size()
size_spin.max_value = max(1, available)
size_spin.value = min(size_spin.value, available)
start_button.disabled = available == 0
status_label.text = QuestionBankService.last_error if available == 0 else ""
start_button.disabled = available == 0 or QuestionBankService.is_loading
if QuestionBankService.is_loading:
status_label.text = "Question API unavailable. Retrying in %d seconds..." % QuestionBankService.next_retry_seconds if QuestionBankService.load_state == "retrying" else "Loading question bank..."
elif available == 0:
status_label.text = QuestionBankService.last_error if QuestionBankService.last_error != "" else "No questions are available for this zone yet."
else:
status_label.text = "%d questions available." % available
func _start_block() -> void:
+155
View File
@@ -0,0 +1,155 @@
extends VBoxContainer
const Style := preload("res://scripts/ui/ui_style.gd")
var main: Control
var status_label: Label
var daily_label: Label
var xp_label: Label
var streak_label: Label
var bank_label: Label
var error_label: Label
var begin_button: Button
var retry_button: Button
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
add_theme_constant_override("separation", 18)
if not QuestionBankService.questions_changed.is_connected(_refresh):
QuestionBankService.questions_changed.connect(_refresh)
if not ProfileService.profile_changed.is_connected(_refresh):
ProfileService.profile_changed.connect(_refresh)
var title := Label.new()
title.text = "Core Mode"
Style.set_label(title, 36, Style.TEXT)
add_child(title)
var subtitle := Label.new()
subtitle.text = "Begin a continuous STEP 3 question run. Keep your energy up, build score, and stay on pace."
subtitle.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(subtitle, 17, Style.MUTED)
add_child(subtitle)
var summary := HBoxContainer.new()
summary.add_theme_constant_override("separation", 14)
add_child(summary)
daily_label = _add_metric(summary, "Today")
xp_label = _add_metric(summary, "Daily XP")
streak_label = _add_metric(summary, "Streak")
bank_label = _add_metric(summary, "Question Bank")
var action_panel := PanelContainer.new()
action_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Color("405475")))
action_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
add_child(action_panel)
var actions := VBoxContainer.new()
actions.add_theme_constant_override("separation", 12)
action_panel.add_child(actions)
status_label = Label.new()
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(status_label, 22, Style.CYAN)
actions.add_child(status_label)
error_label = Label.new()
error_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(error_label, 15, Style.RED)
actions.add_child(error_label)
var button_row := HBoxContainer.new()
button_row.add_theme_constant_override("separation", 12)
actions.add_child(button_row)
begin_button = Button.new()
begin_button.text = "Begin"
begin_button.custom_minimum_size = Vector2(260, 60)
Style.style_primary_button(begin_button)
begin_button.pressed.connect(_begin_core_mode)
button_row.add_child(begin_button)
retry_button = Button.new()
retry_button.text = "Retry Questions"
retry_button.custom_minimum_size = Vector2(170, 54)
Style.style_chip(retry_button, Style.AMBER)
retry_button.pressed.connect(QuestionBankService.retry_now)
button_row.add_child(retry_button)
var spacer := Control.new()
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(spacer)
func on_show(_payload: Variant = null) -> void:
if QuestionBankService.load_state == "idle" and AuthState.is_logged_in:
QuestionBankService.load_questions_from_api()
_refresh()
func _add_metric(parent: HBoxContainer, metric_name: String) -> Label:
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", Style.panel_box(Style.SURFACE, Color("33415c")))
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
parent.add_child(panel)
var label := Label.new()
label.text = metric_name
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(label, 18, Style.TEXT)
panel.add_child(label)
return label
func _refresh() -> void:
if daily_label == null:
return
var profile := ProfileService.get_profile()
var answered := int(profile.get("daily_questions_answered", 0))
var goal := int(profile.get("daily_goal_questions", 10))
if goal < 1:
goal = 1
var progress := int(round(float(answered) / float(goal) * 100.0))
if progress > 100:
progress = 100
daily_label.text = "Today\n%d/%d questions\n%d%% complete" % [answered, goal, progress]
xp_label.text = "Daily XP\n%d XP earned" % int(profile.get("daily_xp_earned", 0))
streak_label.text = "Streak\n%d current\n%d best" % [
int(profile.get("current_streak", 0)),
int(profile.get("best_streak", 0))
]
bank_label.text = "Question Bank\n%d available\n%s" % [
QuestionBankService.get_all_questions().size(),
QuestionBankService.load_state.capitalize()
]
var state := QuestionBankService.load_state
var has_questions := QuestionBankService.has_questions()
begin_button.disabled = not has_questions
retry_button.disabled = state == "loading"
retry_button.visible = state != "ready"
error_label.text = ""
if state == "ready":
status_label.text = BlockSessionService.core_status_message if BlockSessionService.core_status_message != "" else "Question bank ready. Begin core mode when you are ready."
elif state == "loading":
status_label.text = "Loading question bank..."
elif state == "retrying":
status_label.text = "Question API unavailable. Retrying in %d seconds." % QuestionBankService.next_retry_seconds
error_label.text = QuestionBankService.last_error
elif state == "failed":
status_label.text = "Question bank is not available."
error_label.text = QuestionBankService.last_error
else:
status_label.text = "Question bank has not loaded yet."
func _begin_core_mode() -> void:
if not QuestionBankService.has_questions():
return
if BlockSessionService.start_core_run():
main.show_screen("player")
else:
_refresh()
+1
View File
@@ -0,0 +1 @@
uid://7r2eyomsnpoe
+1 -1
View File
@@ -152,7 +152,7 @@ func _on_login_request_completed(result: int, response_code: int, _headers: Pack
)
QuestionBankService.load_questions_from_api()
password_input.text = ""
main.show_screen("menu")
main.show_screen("dashboard")
func _on_input_submitted(_new_text: String) -> void:
+93 -16
View File
@@ -6,14 +6,18 @@ var screens := {}
var header_stats: Label
var header_panel: PanelContainer
var user_label: Label
var app_menu: PanelContainer
var menu_exit_button: Button
var current_screen_name := ""
func _ready() -> void:
Style.apply_app_theme(self)
_build_ui()
show_screen("menu" if AuthState.is_logged_in else "login")
show_screen("dashboard" if AuthState.is_logged_in else "login")
func show_screen(screen_name: String, payload: Variant = null) -> void:
_close_app_menu()
for child in $Root/Screens.get_children():
child.visible = false
@@ -21,6 +25,7 @@ func show_screen(screen_name: String, payload: Variant = null) -> void:
if screen == null:
return
current_screen_name = screen_name
screen.visible = true
if header_panel != null:
header_panel.visible = screen_name != "login"
@@ -68,21 +73,6 @@ func _build_ui() -> void:
user_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
header.add_child(user_label)
var profile_button := Button.new()
profile_button.text = "Profile"
profile_button.pressed.connect(func(): show_screen("profile"))
header.add_child(profile_button)
var map_button := Button.new()
map_button.text = "Study Map"
map_button.pressed.connect(func(): show_screen("map"))
header.add_child(map_button)
var logout_button := Button.new()
logout_button.text = "Log Out"
logout_button.pressed.connect(_logout)
header.add_child(logout_button)
var screens_container := MarginContainer.new()
screens_container.name = "Screens"
screens_container.size_flags_vertical = Control.SIZE_EXPAND_FILL
@@ -104,6 +94,12 @@ func _build_ui() -> void:
screens_container.add_child(login)
screens["login"] = login
var dashboard := preload("res://scripts/ui/dashboard.gd").new()
dashboard.name = "Dashboard"
dashboard.main = self
screens_container.add_child(dashboard)
screens["dashboard"] = dashboard
var map := preload("res://scripts/ui/study_map.gd").new()
map.name = "StudyMap"
map.main = self
@@ -137,6 +133,15 @@ func _build_ui() -> void:
ProfileService.profile_changed.connect(_refresh_header_stats)
AuthState.auth_changed.connect(_refresh_header_stats)
_refresh_header_stats()
_build_app_menu()
func _input(event: InputEvent) -> void:
if not AuthState.is_logged_in or current_screen_name == "login":
return
if event.is_action_pressed("ui_cancel"):
_toggle_app_menu()
get_viewport().set_input_as_handled()
func _refresh_header_stats() -> void:
@@ -153,5 +158,77 @@ func _refresh_header_stats() -> void:
func _logout() -> void:
_close_app_menu()
if BlockSessionService.block_started:
BlockSessionService.finish_block()
QuestionBankService.clear_questions()
AuthState.clear_session()
show_screen("login")
func _build_app_menu() -> void:
app_menu = PanelContainer.new()
app_menu.visible = false
app_menu.top_level = true
app_menu.mouse_filter = Control.MOUSE_FILTER_STOP
app_menu.custom_minimum_size = Vector2(340, 230)
app_menu.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Style.CYAN))
add_child(app_menu)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 12)
app_menu.add_child(content)
var title := Label.new()
title.text = "Paused"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
Style.set_label(title, 28, Style.TEXT)
content.add_child(title)
var resume := Button.new()
resume.text = "Resume"
resume.custom_minimum_size = Vector2(0, 48)
Style.style_primary_button(resume)
resume.pressed.connect(_close_app_menu)
content.add_child(resume)
menu_exit_button = Button.new()
menu_exit_button.text = "Exit Run"
menu_exit_button.custom_minimum_size = Vector2(0, 48)
menu_exit_button.pressed.connect(_exit_run)
content.add_child(menu_exit_button)
var logout := Button.new()
logout.text = "Log Out"
logout.custom_minimum_size = Vector2(0, 48)
Style.style_chip(logout, Style.RED)
logout.pressed.connect(_logout)
content.add_child(logout)
func _toggle_app_menu() -> void:
if app_menu == null:
return
if app_menu.visible:
_close_app_menu()
else:
app_menu.visible = true
app_menu.size = app_menu.custom_minimum_size
app_menu.position = (get_viewport_rect().size - app_menu.size) * 0.5
menu_exit_button.visible = current_screen_name == "player" and BlockSessionService.block_started
if menu_exit_button.visible:
Style.style_chip(menu_exit_button, Style.AMBER)
BlockSessionService.pause_current_question_timer()
func _close_app_menu() -> void:
if app_menu != null:
app_menu.visible = false
BlockSessionService.resume_current_question_timer()
func _exit_run() -> void:
_close_app_menu()
if BlockSessionService.block_started:
BlockSessionService.finish_block()
show_screen("dashboard")
+7 -4
View File
@@ -26,8 +26,8 @@ func _ready() -> void:
var row := HBoxContainer.new()
var back := Button.new()
back.text = "Back to Menu"
back.pressed.connect(func(): main.show_screen("menu"))
back.text = "Dashboard"
back.pressed.connect(func(): main.show_screen("dashboard"))
row.add_child(back)
var reset := Button.new()
@@ -44,13 +44,16 @@ func _ready() -> void:
func on_show(_payload: Variant = null) -> void:
var profile := ProfileService.get_profile()
var xp_into := ProgressionService.xp_into_level(int(profile["xp"]))
var text := "[b]Level %d[/b]\n%d total XP (%d/%d to next level)\nCurrent streak: %d\nBest streak: %d\n\n[b]Badges[/b]\n" % [
var text := "[b]Level %d[/b]\n%d total XP (%d/%d to next level)\nCurrent streak: %d\nBest streak: %d\nToday: %d/%d questions, +%d XP\n\n[b]Badges[/b]\n" % [
profile["level"],
profile["xp"],
xp_into,
ProgressionService.XP_PER_LEVEL,
profile["current_streak"],
profile["best_streak"]
profile["best_streak"],
profile.get("daily_questions_answered", 0),
profile.get("daily_goal_questions", 10),
profile.get("daily_xp_earned", 0)
]
if profile["badges"].is_empty():
text += "No badges yet.\n"
+330 -45
View File
@@ -1,17 +1,22 @@
extends VBoxContainer
const Style := preload("res://scripts/ui/ui_style.gd")
const FEEDBACK_FADE_SECONDS := 0.45
const CORRECT_ADVANCE_DELAY_SECONDS := 0.6
var main: Control
var choice_buttons: Array[Button] = []
var eliminated: Array = []
var locked_wrong_choice_ids: Array = []
var flagged := false
var selected_choice := ""
var current_answer := {}
var question_highlights := {}
var reported_question_ids := []
var stem_plain_text := ""
var highlight_mode_enabled := false
var highlight_drag_started_in_stem := false
var timer_color_state := ""
var pending_correct_advance := false
var progress_label: Label
var timer_label: Label
@@ -19,10 +24,17 @@ var stem_label: RichTextLabel
var lead_label: Label
var explanation_label: RichTextLabel
var next_button: Button
var prev_button: Button
var flag_button: Button
var report_button: Button
var nav_palette: HBoxContainer
var question_content: VBoxContainer
var timer_bar: ProgressBar
var feedback_fog: ColorRect
var red_fog_time := 0.0
var green_fog_time := 0.0
var correct_advance_delay := 0.0
var shake_time := 0.0
var shake_origin := Vector2.ZERO
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
@@ -36,31 +48,48 @@ func _ready() -> void:
top.add_child(progress_label)
timer_label = Label.new()
Style.set_label(timer_label, 18, Style.AMBER)
timer_label.visible = false
timer_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
timer_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
top.add_child(timer_label)
flag_button = Button.new()
flag_button.text = "Flag"
flag_button.toggle_mode = true
Style.style_chip(flag_button, Style.AMBER)
flag_button.toggled.connect(func(value: bool): flagged = value)
top.add_child(flag_button)
add_child(top)
var highlight_row := HBoxContainer.new()
highlight_row.add_theme_constant_override("separation", 8)
var highlight_button := Button.new()
highlight_button.text = "Highlight Mode"
highlight_button.toggle_mode = true
highlight_button.toggled.connect(func(enabled: bool):
highlight_mode_enabled = enabled
highlight_drag_started_in_stem = false
)
Style.style_chip(highlight_button, Style.AMBER)
highlight_row.add_child(highlight_button)
var toolbar_spacer := Control.new()
toolbar_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
top.add_child(toolbar_spacer)
var toolbar := HBoxContainer.new()
toolbar.add_theme_constant_override("separation", 8)
toolbar.alignment = BoxContainer.ALIGNMENT_END
var clear_highlight_button := Button.new()
clear_highlight_button.text = "Clear Highlights"
clear_highlight_button.tooltip_text = "Clear highlights"
clear_highlight_button.icon = _make_clear_highlights_icon()
clear_highlight_button.custom_minimum_size = Vector2(44, 44)
Style.style_chip(clear_highlight_button, Style.CYAN)
clear_highlight_button.pressed.connect(_clear_current_highlights)
highlight_row.add_child(clear_highlight_button)
add_child(highlight_row)
toolbar.add_child(clear_highlight_button)
flag_button = Button.new()
flag_button.toggle_mode = true
flag_button.tooltip_text = "Flag question"
flag_button.icon = _make_flag_icon()
flag_button.custom_minimum_size = Vector2(44, 44)
Style.style_chip(flag_button, Style.AMBER)
flag_button.toggled.connect(func(value: bool):
flagged = value
_refresh_toolbar_state()
)
toolbar.add_child(flag_button)
report_button = Button.new()
report_button.tooltip_text = "Report question"
report_button.icon = _make_report_icon()
report_button.custom_minimum_size = Vector2(44, 44)
report_button.pressed.connect(_toggle_report_current_question)
toolbar.add_child(report_button)
top.add_child(toolbar)
add_child(top)
nav_palette = HBoxContainer.new()
nav_palette.add_theme_constant_override("separation", 6)
@@ -96,7 +125,7 @@ func _ready() -> void:
for index in range(5):
var button := Button.new()
button.custom_minimum_size = Vector2(0, 52)
button.custom_minimum_size = Vector2(0, 64)
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
Style.style_answer_button(button)
button.pressed.connect(func(i := index): _select_choice(i))
@@ -114,33 +143,42 @@ func _ready() -> void:
var actions := HBoxContainer.new()
actions.add_theme_constant_override("separation", 10)
prev_button = Button.new()
prev_button.text = "Previous"
prev_button.pressed.connect(func():
if BlockSessionService.move_previous():
_load_question()
)
actions.add_child(prev_button)
var eliminate := Button.new()
eliminate.text = "Eliminate Selected"
eliminate.pressed.connect(_toggle_eliminate_selected)
actions.add_child(eliminate)
next_button = Button.new()
next_button.text = "Submit"
next_button.custom_minimum_size = Vector2(140, 48)
next_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
Style.style_primary_button(next_button)
next_button.pressed.connect(_submit_or_next)
actions.add_child(next_button)
add_child(actions)
timer_bar = ProgressBar.new()
timer_bar.show_percentage = false
timer_bar.min_value = 0.0
timer_bar.max_value = 100.0
timer_bar.value = 100.0
timer_bar.custom_minimum_size = Vector2(0, 14)
timer_bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
timer_bar.add_theme_stylebox_override("background", Style.button_box(Color("232b3d"), Color("303b53")))
timer_bar.add_theme_stylebox_override("fill", Style.button_box(Style.LIME, Style.LIME))
add_child(timer_bar)
feedback_fog = ColorRect.new()
feedback_fog.color = Color(1.0, 0.0, 0.0, 0.0)
feedback_fog.mouse_filter = Control.MOUSE_FILTER_IGNORE
feedback_fog.top_level = true
feedback_fog.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(feedback_fog)
func on_show(_payload: Variant = null) -> void:
_load_question()
func _process(delta: float) -> void:
_update_answer_feedback(delta)
if visible and BlockSessionService.block_started and BlockSessionService.session_kind == "core":
_update_timer_bar()
if visible and BlockSessionService.block_started and BlockSessionService.mode == "Timed":
BlockSessionService.seconds_remaining = max(0.0, BlockSessionService.seconds_remaining - delta)
var shown_seconds := int(ceil(BlockSessionService.seconds_remaining))
@@ -151,7 +189,7 @@ func _process(delta: float) -> void:
func _input(event: InputEvent) -> void:
if not visible or not highlight_mode_enabled or stem_label == null:
if not visible or pending_correct_advance or stem_label == null:
return
var mouse_event := event as InputEventMouseButton
@@ -166,17 +204,27 @@ func _input(event: InputEvent) -> void:
func _load_question() -> void:
_reset_wrong_feedback()
eliminated.clear()
locked_wrong_choice_ids.clear()
flagged = false
selected_choice = ""
current_answer = {}
flag_button.button_pressed = false
_refresh_toolbar_state()
explanation_label.visible = false
next_button.text = "Submit"
var question := BlockSessionService.get_current_question()
progress_label.text = "Question %s" % BlockSessionService.get_progress_text()
timer_label.text = "Tutor Mode" if BlockSessionService.mode == "Tutor" else "Timed"
if question.is_empty():
main.show_screen("dashboard")
return
progress_label.visible = BlockSessionService.session_kind != "core"
progress_label.text = "" if BlockSessionService.session_kind == "core" else "Question %s" % BlockSessionService.get_progress_text()
timer_label.visible = BlockSessionService.session_kind != "core"
timer_label.text = "" if BlockSessionService.session_kind == "core" else "Tutor Mode" if BlockSessionService.mode == "Tutor" else "Timed"
timer_bar.visible = BlockSessionService.session_kind == "core"
_update_timer_bar()
stem_plain_text = str(question.get("stem", ""))
_render_stem()
lead_label.text = question.get("lead_in", "")
@@ -198,6 +246,7 @@ func _load_question() -> void:
eliminated = saved_answer.get("eliminated_choice_ids", []).duplicate()
flagged = bool(saved_answer.get("flagged", false))
flag_button.button_pressed = flagged
_refresh_toolbar_state()
if BlockSessionService.mode == "Tutor":
_show_explanation()
next_button.text = "Next"
@@ -205,15 +254,19 @@ func _load_question() -> void:
_apply_saved_choice_state()
next_button.text = "Next"
prev_button.disabled = BlockSessionService.current_index == 0
_refresh_nav_palette()
_refresh_toolbar_state()
func _select_choice(index: int) -> void:
if pending_correct_advance:
return
var question := BlockSessionService.get_current_question()
var choices: Array = question.get("choices", [])
if index >= choices.size() or (BlockSessionService.mode == "Tutor" and not current_answer.is_empty()):
return
if locked_wrong_choice_ids.has(str(choices[index].get("id", ""))):
return
selected_choice = str(choices[index].get("id", ""))
if BlockSessionService.mode == "Timed":
current_answer = {}
@@ -222,6 +275,8 @@ func _select_choice(index: int) -> void:
func _choice_gui_input(event: InputEvent, index: int) -> void:
if pending_correct_advance:
return
var mouse_event := event as InputEventMouseButton
if mouse_event == null or mouse_event.button_index != MOUSE_BUTTON_RIGHT or not mouse_event.pressed:
return
@@ -235,6 +290,8 @@ func _choice_gui_input(event: InputEvent, index: int) -> void:
var choice: Dictionary = choices[index]
var choice_id := str(choice.get("id", ""))
if locked_wrong_choice_ids.has(choice_id):
return
if eliminated.has(choice_id):
eliminated.erase(choice_id)
else:
@@ -244,7 +301,7 @@ func _choice_gui_input(event: InputEvent, index: int) -> void:
func _toggle_eliminate_selected() -> void:
if selected_choice == "" or not current_answer.is_empty():
if pending_correct_advance or selected_choice == "" or not current_answer.is_empty():
return
var question := BlockSessionService.get_current_question()
var choices: Array = question.get("choices", [])
@@ -259,9 +316,27 @@ func _toggle_eliminate_selected() -> void:
func _submit_or_next() -> void:
if pending_correct_advance:
return
if current_answer.is_empty():
if selected_choice == "":
return
if BlockSessionService.session_kind == "core":
var result := BlockSessionService.submit_core_attempt(selected_choice, eliminated, flagged)
if result.is_empty():
return
var answer: Dictionary = result.get("answer", {})
if bool(result.get("is_correct", false)):
current_answer = answer
_trigger_correct_feedback()
return
_lock_wrong_choice(selected_choice)
selected_choice = ""
current_answer = {}
next_button.text = "Submit"
_trigger_wrong_feedback()
_apply_saved_choice_state()
return
current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged)
if BlockSessionService.mode == "Tutor":
_show_explanation()
@@ -300,12 +375,106 @@ func _advance_or_finish() -> void:
func _finish() -> void:
if BlockSessionService.session_kind == "core":
BlockSessionService.finish_block()
main.show_screen("dashboard")
return
var results := BlockSessionService.finish_block()
main.show_screen("review", results)
func _exit_run() -> void:
BlockSessionService.resume_current_question_timer()
BlockSessionService.finish_block()
main.show_screen("dashboard")
func _update_timer_bar() -> void:
if timer_bar == null:
return
var ratio := BlockSessionService.get_core_timer_ratio()
timer_bar.value = ratio * 100.0
var color_state := "green"
var fill_color := Style.LIME
if ratio < 0.25:
color_state = "red"
fill_color = Style.RED
elif ratio <= 0.5:
color_state = "amber"
fill_color = Style.AMBER
if color_state != timer_color_state:
timer_color_state = color_state
timer_bar.add_theme_stylebox_override("fill", Style.button_box(fill_color, fill_color))
func _lock_wrong_choice(choice_id: String) -> void:
if choice_id == "":
return
if not locked_wrong_choice_ids.has(choice_id):
locked_wrong_choice_ids.append(choice_id)
func _trigger_wrong_feedback() -> void:
shake_time = 0.28
shake_origin = position
green_fog_time = 0.0
red_fog_time = FEEDBACK_FADE_SECONDS
if feedback_fog != null:
feedback_fog.color = Color(1.0, 0.0, 0.0, 0.26)
func _trigger_correct_feedback() -> void:
pending_correct_advance = true
correct_advance_delay = CORRECT_ADVANCE_DELAY_SECONDS
red_fog_time = 0.0
green_fog_time = FEEDBACK_FADE_SECONDS
next_button.disabled = true
for button in choice_buttons:
button.disabled = true
if feedback_fog != null:
feedback_fog.color = Color(0.25, 1.0, 0.25, 0.23)
func _update_answer_feedback(delta: float) -> void:
if shake_time > 0.0:
shake_time = max(0.0, shake_time - delta)
if shake_time > 0.0:
position = shake_origin + Vector2(randf_range(-9.0, 9.0), randf_range(-4.0, 4.0))
else:
position = shake_origin
if red_fog_time > 0.0 and feedback_fog != null:
red_fog_time = max(0.0, red_fog_time - delta)
var red_alpha := 0.26 * (red_fog_time / FEEDBACK_FADE_SECONDS)
feedback_fog.color = Color(1.0, 0.0, 0.0, red_alpha)
elif green_fog_time > 0.0 and feedback_fog != null:
green_fog_time = max(0.0, green_fog_time - delta)
var green_alpha := 0.23 * (green_fog_time / FEEDBACK_FADE_SECONDS)
feedback_fog.color = Color(0.25, 1.0, 0.25, green_alpha)
if pending_correct_advance:
correct_advance_delay = max(0.0, correct_advance_delay - delta)
if correct_advance_delay <= 0.0:
pending_correct_advance = false
next_button.disabled = false
_advance_or_finish()
func _reset_wrong_feedback() -> void:
if shake_time > 0.0:
position = shake_origin
shake_time = 0.0
red_fog_time = 0.0
green_fog_time = 0.0
correct_advance_delay = 0.0
pending_correct_advance = false
next_button.disabled = false
if feedback_fog != null:
feedback_fog.color = Color(1.0, 0.0, 0.0, 0.0)
func _commit_current_text_selection() -> void:
if not highlight_mode_enabled or stem_label == null:
if stem_label == null:
return
var raw_selected_text := stem_label.get_selected_text()
var selected_text := raw_selected_text.strip_edges()
@@ -347,6 +516,25 @@ func _clear_current_highlights() -> void:
_render_stem()
func _toggle_report_current_question() -> void:
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
if question_id == "":
return
if reported_question_ids.has(question_id):
reported_question_ids.erase(question_id)
else:
reported_question_ids.append(question_id)
_refresh_toolbar_state()
func _refresh_toolbar_state() -> void:
if flag_button != null:
Style.style_chip(flag_button, Style.AMBER if flagged else Style.CYAN)
if report_button != null:
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
Style.style_chip(report_button, Style.RED if reported_question_ids.has(question_id) else Style.CYAN)
func _render_stem() -> void:
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
var ranges: Array = question_highlights.get(question_id, []).duplicate()
@@ -393,7 +581,10 @@ func _apply_saved_choice_state() -> void:
for index in range(choices.size()):
var id := str(choices[index].get("id", ""))
_set_choice_button_text(index, choices[index])
if eliminated.has(id):
choice_buttons[index].disabled = locked_wrong_choice_ids.has(id)
if locked_wrong_choice_ids.has(id):
choice_buttons[index].modulate = Color(1.0, 0.25, 0.25)
elif eliminated.has(id):
choice_buttons[index].modulate = Color(0.45, 0.45, 0.45)
elif id == selected_choice:
choice_buttons[index].modulate = Color(0.68, 0.92, 1.0)
@@ -404,12 +595,26 @@ func _apply_saved_choice_state() -> void:
func _set_choice_button_text(index: int, choice: Dictionary) -> void:
var choice_id := str(choice.get("id", ""))
var choice_text := str(choice.get("text", ""))
if eliminated.has(choice_id):
if locked_wrong_choice_ids.has(choice_id):
var text := "%s. [wrong] %s" % [choice_id, _strike_text(choice_text)]
var percentage := _get_choice_selection_percentage(choice_id)
if percentage >= 0:
text += "\n %d%% chose this on first attempt" % percentage
choice_buttons[index].text = text
elif eliminated.has(choice_id):
choice_buttons[index].text = "%s. [eliminated] %s" % [choice_id, _strike_text(choice_text)]
else:
choice_buttons[index].text = "%s. %s" % [choice_id, choice_text]
func _get_choice_selection_percentage(choice_id: String) -> int:
var question := BlockSessionService.get_current_question()
var percentages: Dictionary = question.get("choice_selection_percentages", {})
if not percentages.has(choice_id):
return -1
return clamp(int(percentages.get(choice_id, -1)), 0, 100)
func _strike_text(value: String) -> String:
var result := ""
for character in value:
@@ -417,10 +622,90 @@ func _strike_text(value: String) -> String:
return result
func _make_flag_icon() -> Texture2D:
var image := Image.create(24, 24, false, Image.FORMAT_RGBA8)
image.fill(Color(0, 0, 0, 0))
for y in range(4, 20):
image.set_pixel(6, y, Style.TEXT)
image.set_pixel(7, y, Style.TEXT)
for x in range(8, 18):
for y in range(4, 11):
if x < 16 or y < 9:
image.set_pixel(x, y, Style.AMBER)
for y in range(11, 14):
for x in range(8, 14 - (y - 11)):
image.set_pixel(x, y, Style.AMBER)
return ImageTexture.create_from_image(image)
func _make_clear_highlights_icon() -> Texture2D:
var image := Image.create(24, 24, false, Image.FORMAT_RGBA8)
image.fill(Color(0, 0, 0, 0))
var handle_color := Style.TEXT
var bristle_color := Style.AMBER
for offset in range(3):
_draw_icon_line(image, Vector2i(6 + offset, 16), Vector2i(16 + offset, 6), handle_color)
for x in range(13, 20):
for y in range(15, 19):
if x + y < 36:
image.set_pixel(x, y, bristle_color)
for x in range(12, 20):
image.set_pixel(x, 19, bristle_color)
return ImageTexture.create_from_image(image)
func _make_report_icon() -> Texture2D:
var image := Image.create(24, 24, false, Image.FORMAT_RGBA8)
image.fill(Color(0, 0, 0, 0))
var body_color := Style.TEXT
var accent_color := Style.RED
for x in range(5, 14):
for y in range(9, 15):
image.set_pixel(x, y, body_color)
for x in range(14, 20):
image.set_pixel(x, 7, accent_color)
image.set_pixel(x, 16, accent_color)
for y in range(8, 16):
image.set_pixel(20, y, accent_color)
for y in range(10, 14):
image.set_pixel(4, y, body_color)
for y in range(15, 21):
image.set_pixel(8, y, body_color)
if y < 20:
image.set_pixel(9, y, body_color)
_draw_icon_line(image, Vector2i(2, 5), Vector2i(5, 8), accent_color)
_draw_icon_line(image, Vector2i(2, 19), Vector2i(5, 16), accent_color)
return ImageTexture.create_from_image(image)
func _draw_icon_line(image: Image, from: Vector2i, to: Vector2i, color: Color) -> void:
var dx := int(abs(to.x - from.x))
var sx := 1 if from.x < to.x else -1
var dy := -int(abs(to.y - from.y))
var sy := 1 if from.y < to.y else -1
var err: int = dx + dy
var point := from
while true:
if point.x >= 0 and point.x < image.get_width() and point.y >= 0 and point.y < image.get_height():
image.set_pixel(point.x, point.y, color)
if point == to:
break
var e2: int = 2 * err
if e2 >= dy:
err += dy
point.x += sx
if e2 <= dx:
err += dx
point.y += sy
func _refresh_nav_palette() -> void:
for child in nav_palette.get_children():
child.queue_free()
if BlockSessionService.session_kind == "core":
return
for index in range(BlockSessionService.active_questions.size()):
var button := Button.new()
button.text = str(index + 1)
+8 -1
View File
@@ -41,7 +41,7 @@ func _refresh_zones() -> void:
if QuestionBankService.is_loading:
var loading := Label.new()
loading.text = "Loading question bank..."
loading.text = "Question API unavailable. Retrying in %d seconds..." % QuestionBankService.next_retry_seconds if QuestionBankService.load_state == "retrying" else "Loading question bank..."
Style.set_label(loading, 20, Style.AMBER)
zone_list.add_child(loading)
return
@@ -52,6 +52,13 @@ func _refresh_zones() -> void:
empty.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(empty, 20, Style.RED)
zone_list.add_child(empty)
var retry := Button.new()
retry.text = "Retry Questions"
retry.custom_minimum_size = Vector2(220, 54)
Style.style_chip(retry, Style.AMBER)
retry.pressed.connect(QuestionBankService.retry_now)
zone_list.add_child(retry)
return
var systems: Array[String] = ["All Systems"]