Initial STEP 3 study game and questions API

This commit is contained in:
hz
2026-07-09 20:50:33 -05:00
commit 277076a245
51 changed files with 2671 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
extends Node
signal auth_changed
var is_logged_in := false
var username := ""
var role := ""
var access_token := ""
var refresh_token := ""
var expires_at := ""
func set_session(new_username: String, token: String, new_refresh_token: String = "", new_role: String = "", token_expiration: String = "") -> void:
is_logged_in = not token.is_empty()
username = new_username
access_token = token
refresh_token = new_refresh_token
role = new_role
expires_at = token_expiration
auth_changed.emit()
func clear_session() -> void:
is_logged_in = false
username = ""
role = ""
access_token = ""
refresh_token = ""
expires_at = ""
auth_changed.emit()
+1
View File
@@ -0,0 +1 @@
uid://cf03tijcjcp1j
+104
View File
@@ -0,0 +1,104 @@
extends Node
signal session_changed
var active_questions: Array = []
var answers: Array = []
var current_index := 0
var mode := "Tutor"
var system_name := "All Systems"
var seconds_remaining := 0.0
var block_started := false
func start_block(selected_system: String, block_size: int, selected_mode: String) -> void:
system_name = selected_system
mode = selected_mode
active_questions = QuestionBankService.build_block(selected_system, block_size)
answers.clear()
answers.resize(active_questions.size())
current_index = 0
seconds_remaining = float(active_questions.size() * 90)
block_started = true
session_changed.emit()
func get_current_question() -> Dictionary:
if active_questions.is_empty() or current_index < 0 or current_index >= active_questions.size():
return {}
return active_questions[current_index]
func answer_current(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
}
answers[current_index] = answer
session_changed.emit()
return answer
func move_previous() -> bool:
if current_index > 0:
current_index -= 1
session_changed.emit()
return true
return false
func move_next() -> bool:
if current_index + 1 < active_questions.size():
current_index += 1
session_changed.emit()
return true
return false
func move_to(index: int) -> bool:
if index >= 0 and index < active_questions.size():
current_index = index
session_changed.emit()
return true
return false
func get_progress_text() -> String:
return "%d / %d" % [min(current_index + 1, active_questions.size()), active_questions.size()]
func finish_block() -> Dictionary:
var final_answers: Array = []
for index in range(active_questions.size()):
if answers[index] == null:
final_answers.append({
"question": active_questions[index].duplicate(true),
"selected_choice_id": "",
"is_correct": false,
"eliminated_choice_ids": [],
"flagged": false
})
else:
final_answers.append(answers[index])
var results := ProgressionService.calculate_block_rewards(final_answers, mode, system_name)
ProfileService.apply_block_results(results)
block_started = false
session_changed.emit()
return results
func has_answered_current() -> bool:
return get_answer_for_index(current_index) != null
func get_answer_for_index(index: int) -> Variant:
if index < 0 or index >= answers.size():
return null
return answers[index]
@@ -0,0 +1 @@
uid://dgbbs67a8wdl4
+105
View File
@@ -0,0 +1,105 @@
extends Node
signal profile_changed
const SAVE_PATH := "user://profile.json"
var profile: Dictionary = {
"xp": 0,
"level": 1,
"current_streak": 0,
"best_streak": 0,
"badges": [],
"completed_question_ids": [],
"system_stats": {},
"block_history": []
}
func _ready() -> void:
load_profile()
func load_profile() -> void:
if not FileAccess.file_exists(SAVE_PATH):
save_profile()
return
var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
if file == null:
push_error("Unable to open profile save.")
return
var parsed = JSON.parse_string(file.get_as_text())
if typeof(parsed) == TYPE_DICTIONARY:
for key in profile.keys():
if parsed.has(key):
profile[key] = parsed[key]
profile_changed.emit()
func save_profile() -> void:
var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if file == null:
push_error("Unable to write profile save.")
return
file.store_string(JSON.stringify(profile, "\t"))
func get_profile() -> Dictionary:
return profile.duplicate(true)
func apply_block_results(results: Dictionary) -> void:
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"]))
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)
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
var summary: Dictionary = {
"timestamp": Time.get_datetime_string_from_system(),
"mode": results.get("mode", "Tutor"),
"system": results.get("system", "All Systems"),
"correct": results.get("correct", 0),
"total": results.get("total", 0),
"xp_earned": results.get("xp_earned", 0)
}
profile["block_history"].push_front(summary)
if profile["block_history"].size() > 12:
profile["block_history"].resize(12)
save_profile()
profile_changed.emit()
func reset_profile() -> void:
profile = {
"xp": 0,
"level": 1,
"current_streak": 0,
"best_streak": 0,
"badges": [],
"completed_question_ids": [],
"system_stats": {},
"block_history": []
}
save_profile()
profile_changed.emit()
+1
View File
@@ -0,0 +1 @@
uid://epx6yqvy5nf6
+67
View File
@@ -0,0 +1,67 @@
extends Node
const XP_PER_LEVEL := 500
var badge_catalog := {
"First Block": "Complete your first question block.",
"Hot Streak": "Answer 5 questions correctly in a row.",
"Tenacious": "Complete 10 total questions.",
"Sharp Shooter": "Score at least 80% on a block.",
"Clock Ready": "Complete a timed block."
}
func level_for_xp(xp: int) -> int:
return int(floor(float(xp) / float(XP_PER_LEVEL))) + 1
func xp_into_level(xp: int) -> int:
return xp % XP_PER_LEVEL
func calculate_block_rewards(answers: Array, mode: String, system_name: String) -> Dictionary:
var correct := 0
var longest_streak := 0
var running_streak := int(ProfileService.get_profile().get("current_streak", 0))
var ending_streak := running_streak
for answer in answers:
if bool(answer.get("is_correct", false)):
correct += 1
ending_streak += 1
longest_streak = max(longest_streak, ending_streak)
else:
ending_streak = 0
var total: int = max(answers.size(), 1)
var accuracy := float(correct) / float(total)
var xp := answers.size() * 25 + correct * 35 + longest_streak * 10
if accuracy >= 0.8:
xp += 100
if mode == "Timed":
xp += 50
var profile: Dictionary = ProfileService.get_profile()
var badges: Array[String] = []
_add_badge_if_needed(badges, profile, "First Block", true)
_add_badge_if_needed(badges, profile, "Hot Streak", longest_streak >= 5)
_add_badge_if_needed(badges, profile, "Tenacious", profile.get("completed_question_ids", []).size() + answers.size() >= 10)
_add_badge_if_needed(badges, profile, "Sharp Shooter", accuracy >= 0.8)
_add_badge_if_needed(badges, profile, "Clock Ready", mode == "Timed")
return {
"mode": mode,
"system": system_name,
"correct": correct,
"total": answers.size(),
"accuracy": accuracy,
"xp_earned": xp,
"ending_streak": ending_streak,
"longest_streak": longest_streak,
"badges_unlocked": badges,
"answers": answers
}
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)
@@ -0,0 +1 @@
uid://d2w2nbhsf6uv6
+142
View File
@@ -0,0 +1,142 @@
extends Node
const QUESTION_FILE := "res://data/questions/sample_questions.json"
const DEFAULT_QUESTIONS_API_URL := "http://localhost:5008/api/questions/search"
signal questions_changed
var questions: Array = []
var request: HTTPRequest
var questions_api_url := DEFAULT_QUESTIONS_API_URL
func _ready() -> void:
questions_api_url = str(ProjectSettings.get_setting("step3/questions_api_url", DEFAULT_QUESTIONS_API_URL))
request = HTTPRequest.new()
request.request_completed.connect(_on_api_questions_loaded)
add_child(request)
load_questions()
func load_questions() -> void:
questions.clear()
var file := FileAccess.open(QUESTION_FILE, FileAccess.READ)
if file == null:
push_error("Unable to open question file: %s" % QUESTION_FILE)
return
var parsed = JSON.parse_string(file.get_as_text())
if typeof(parsed) != TYPE_ARRAY:
push_error("Question file must contain an array.")
return
for item in parsed:
if _is_valid_question(item):
questions.append(item)
else:
push_warning("Skipping invalid question: %s" % str(item.get("id", "unknown")))
questions_changed.emit()
func load_questions_from_api() -> void:
if AuthState.access_token.is_empty():
return
var headers := PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer %s" % AuthState.access_token
])
var payload := {
"limit": 80
}
var err := request.request(questions_api_url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload))
if err != OK:
push_warning("Failed to request questions API. Using local question bank.")
func _on_api_questions_loaded(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
push_warning("Questions API request failed. Using local question bank.")
return
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if typeof(parsed) != TYPE_ARRAY:
push_warning("Questions API response was not an array. Using local question bank.")
return
var api_questions: Array = []
for item in parsed:
var normalized := _normalize_api_question(item)
if _is_valid_question(normalized):
api_questions.append(normalized)
if api_questions.is_empty():
push_warning("Questions API returned no usable questions. Using local question bank.")
return
questions = api_questions
questions_changed.emit()
func get_all_questions() -> Array:
return questions.duplicate(true)
func get_systems() -> Array[String]:
var system_names: Array[String] = []
for question in questions:
var system_name := str(question.get("system", "General"))
if not system_names.has(system_name):
system_names.append(system_name)
system_names.sort()
return system_names
func get_questions_for_system(system_name: String) -> Array:
if system_name == "All Systems":
return get_all_questions()
var filtered: Array = []
for question in questions:
if str(question.get("system", "")) == system_name:
filtered.append(question.duplicate(true))
return filtered
func build_block(system_name: String, block_size: int) -> Array:
var pool := get_questions_for_system(system_name)
pool.shuffle()
return pool.slice(0, min(block_size, pool.size()))
func _is_valid_question(item: Variant) -> bool:
if typeof(item) != TYPE_DICTIONARY:
return false
var required := ["id", "stem", "lead_in", "choices", "correct_choice_id", "explanation", "system", "discipline"]
for key in required:
if not item.has(key):
return false
return typeof(item["choices"]) == TYPE_ARRAY and item["choices"].size() >= 2
func _normalize_api_question(item: Variant) -> Dictionary:
if typeof(item) != TYPE_DICTIONARY:
return {}
var normalized := {
"id": str(item.get("externalId", item.get("id", ""))),
"api_id": str(item.get("id", "")),
"stem": str(item.get("stem", "")),
"lead_in": str(item.get("leadIn", item.get("lead_in", ""))),
"choices": item.get("choices", []),
"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", {})),
"discipline": str(item.get("discipline", "")),
"system": str(item.get("system", "")),
"difficulty": int(item.get("difficulty", 1)),
"labs": item.get("labs", []),
"media": item.get("media", []),
"tags": item.get("tags", [])
}
return normalized
@@ -0,0 +1 @@
uid://bb16xajj1m36q
+70
View File
@@ -0,0 +1,70 @@
extends VBoxContainer
const Style := preload("res://scripts/ui/ui_style.gd")
var main: Control
var selected_system := "All Systems"
var system_label: Label
var size_spin: SpinBox
var mode_option: OptionButton
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
add_theme_constant_override("separation", 16)
var title := Label.new()
title.text = "Block Setup"
Style.set_label(title, 34, Style.TEXT)
add_child(title)
system_label = Label.new()
Style.set_label(system_label, 22, Style.CYAN)
add_child(system_label)
var size_row := HBoxContainer.new()
size_row.add_child(Label.new())
size_row.get_child(0).text = "Question count"
size_spin = SpinBox.new()
size_spin.min_value = 1
size_spin.max_value = 40
size_spin.value = 6
size_spin.custom_minimum_size = Vector2(110, 44)
size_row.add_child(size_spin)
add_child(size_row)
var mode_row := HBoxContainer.new()
var mode_label := Label.new()
mode_label.text = "Mode"
mode_row.add_child(mode_label)
mode_option = OptionButton.new()
mode_option.add_item("Tutor")
mode_option.add_item("Timed")
mode_option.custom_minimum_size = Vector2(180, 44)
mode_row.add_child(mode_option)
add_child(mode_row)
var note := Label.new()
note.text = "Tutor mode shows explanations after each answer. Timed mode scores the block at the end."
note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(note, 16, Style.MUTED)
add_child(note)
var start := Button.new()
start.text = "Launch Block"
start.custom_minimum_size = Vector2(220, 54)
Style.style_primary_button(start)
start.pressed.connect(_start_block)
add_child(start)
func on_show(payload: Variant = null) -> void:
selected_system = str(payload) if payload != null else "All Systems"
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)
func _start_block() -> void:
BlockSessionService.start_block(selected_system, int(size_spin.value), mode_option.get_item_text(mode_option.selected))
main.show_screen("player")
+1
View File
@@ -0,0 +1 @@
uid://ckykfr70d812h
+163
View File
@@ -0,0 +1,163 @@
extends VBoxContainer
const Style := preload("res://scripts/ui/ui_style.gd")
const AUTH_LOGIN_URL := "https://pauth.ranaze.com/api/Auth/login"
var main: Control
var username_input: LineEdit
var password_input: LineEdit
var login_button: Button
var error_label: Label
var login_request: HTTPRequest
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
add_theme_constant_override("separation", 18)
var spacer_top := Control.new()
spacer_top.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(spacer_top)
var center := CenterContainer.new()
center.size_flags_horizontal = Control.SIZE_EXPAND_FILL
add_child(center)
var panel := PanelContainer.new()
panel.custom_minimum_size = Vector2(520, 430)
panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Color("2bdbff")))
center.add_child(panel)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 14)
panel.add_child(content)
var title := Label.new()
title.text = "STEP 3 Arcade"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
Style.set_label(title, 36, Style.TEXT)
content.add_child(title)
var subtitle := Label.new()
subtitle.text = "Sign in with your Promiscuity account to start studying."
subtitle.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
subtitle.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(subtitle, 16, Style.MUTED)
content.add_child(subtitle)
var username_label := Label.new()
username_label.text = "Username"
Style.set_label(username_label, 15, Style.CYAN)
content.add_child(username_label)
username_input = LineEdit.new()
username_input.placeholder_text = "enter username"
username_input.custom_minimum_size = Vector2(0, 44)
username_input.text_submitted.connect(_on_input_submitted)
content.add_child(username_input)
var password_label := Label.new()
password_label.text = "Password"
Style.set_label(password_label, 15, Style.CYAN)
content.add_child(password_label)
password_input = LineEdit.new()
password_input.placeholder_text = "enter password"
password_input.secret = true
password_input.custom_minimum_size = Vector2(0, 44)
password_input.text_submitted.connect(_on_input_submitted)
content.add_child(password_input)
login_button = Button.new()
login_button.text = "Log In"
login_button.custom_minimum_size = Vector2(0, 50)
Style.style_primary_button(login_button)
login_button.pressed.connect(_on_login_pressed)
content.add_child(login_button)
error_label = Label.new()
error_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
error_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(error_label, 15, Style.RED)
content.add_child(error_label)
var spacer_bottom := Control.new()
spacer_bottom.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(spacer_bottom)
login_request = HTTPRequest.new()
login_request.name = "LoginRequest"
login_request.request_completed.connect(_on_login_request_completed)
add_child(login_request)
func on_show(_payload: Variant = null) -> void:
error_label.text = ""
login_button.disabled = false
login_button.text = "Log In"
username_input.grab_focus()
func _on_login_pressed() -> void:
var username := username_input.text.strip_edges()
var password := password_input.text
if username.is_empty() or password.is_empty():
_show_error("Username and password required.")
return
login_button.disabled = true
login_button.text = "Signing in..."
error_label.text = ""
var payload := {
"username": username,
"password": password,
}
var headers := PackedStringArray(["Content-Type: application/json"])
var err := login_request.request(AUTH_LOGIN_URL, headers, HTTPClient.METHOD_POST, JSON.stringify(payload))
if err != OK:
login_button.disabled = false
login_button.text = "Log In"
_show_error("Failed to send login request.")
func _on_login_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
login_button.disabled = false
login_button.text = "Log In"
if result != HTTPRequest.RESULT_SUCCESS:
_show_error("Network error. Please try again.")
return
if response_code < 200 or response_code >= 300:
_show_error("Login failed. Check your credentials.")
return
var response: Variant = JSON.parse_string(body.get_string_from_utf8())
if typeof(response) != TYPE_DICTIONARY:
_show_error("Login response was not valid JSON.")
return
var token := String(response.get("accessToken", ""))
var username := String(response.get("username", username_input.text.strip_edges()))
if token.is_empty():
_show_error("Login response did not include an access token.")
return
AuthState.set_session(
username,
token,
String(response.get("refreshToken", "")),
String(response.get("role", "")),
String(response.get("exp", ""))
)
QuestionBankService.load_questions_from_api()
password_input.text = ""
main.show_screen("menu")
func _on_input_submitted(_new_text: String) -> void:
_on_login_pressed()
func _show_error(message: String) -> void:
error_label.text = message
+1
View File
@@ -0,0 +1 @@
uid://cfw3y7g7kn4xj
+157
View File
@@ -0,0 +1,157 @@
extends Control
const Style := preload("res://scripts/ui/ui_style.gd")
var screens := {}
var header_stats: Label
var header_panel: PanelContainer
var user_label: Label
func _ready() -> void:
Style.apply_app_theme(self)
_build_ui()
show_screen("menu" if AuthState.is_logged_in else "login")
func show_screen(screen_name: String, payload: Variant = null) -> void:
for child in $Root/Screens.get_children():
child.visible = false
var screen = screens.get(screen_name)
if screen == null:
return
screen.visible = true
if header_panel != null:
header_panel.visible = screen_name != "login"
if screen.has_method("on_show"):
screen.on_show(payload)
func _build_ui() -> void:
var background := ColorRect.new()
background.color = Style.BACKGROUND
background.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(background)
var root := VBoxContainer.new()
root.name = "Root"
root.set_anchors_preset(Control.PRESET_FULL_RECT)
root.add_theme_constant_override("separation", 0)
add_child(root)
header_panel = PanelContainer.new()
header_panel.name = "Header"
header_panel.add_theme_stylebox_override("panel", Style.panel_box(Style.SURFACE_DARK, Color("263752")))
root.add_child(header_panel)
var header := HBoxContainer.new()
header.custom_minimum_size = Vector2(0, 78)
header.add_theme_constant_override("separation", 14)
header_panel.add_child(header)
var title := Label.new()
title.text = "STEP 3 Arcade"
Style.set_label(title, 31, Style.TEXT)
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header.add_child(title)
header_stats = Label.new()
header_stats.name = "HeaderStats"
Style.set_label(header_stats, 15, Style.MUTED)
header_stats.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header_stats.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
header.add_child(header_stats)
user_label = Label.new()
Style.set_label(user_label, 15, Style.CYAN)
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
screens_container.add_theme_constant_override("margin_left", 24)
screens_container.add_theme_constant_override("margin_right", 24)
screens_container.add_theme_constant_override("margin_top", 18)
screens_container.add_theme_constant_override("margin_bottom", 22)
root.add_child(screens_container)
var menu := preload("res://scripts/ui/main_menu.gd").new()
menu.name = "MainMenu"
menu.main = self
screens_container.add_child(menu)
screens["menu"] = menu
var login := preload("res://scripts/ui/login_screen.gd").new()
login.name = "LoginScreen"
login.main = self
screens_container.add_child(login)
screens["login"] = login
var map := preload("res://scripts/ui/study_map.gd").new()
map.name = "StudyMap"
map.main = self
screens_container.add_child(map)
screens["map"] = map
var setup := preload("res://scripts/ui/block_setup.gd").new()
setup.name = "BlockSetup"
setup.main = self
screens_container.add_child(setup)
screens["setup"] = setup
var player := preload("res://scripts/ui/question_player.gd").new()
player.name = "QuestionPlayer"
player.main = self
screens_container.add_child(player)
screens["player"] = player
var review := preload("res://scripts/ui/review_results.gd").new()
review.name = "ReviewResults"
review.main = self
screens_container.add_child(review)
screens["review"] = review
var profile := preload("res://scripts/ui/progress_profile.gd").new()
profile.name = "ProgressProfile"
profile.main = self
screens_container.add_child(profile)
screens["profile"] = profile
ProfileService.profile_changed.connect(_refresh_header_stats)
AuthState.auth_changed.connect(_refresh_header_stats)
_refresh_header_stats()
func _refresh_header_stats() -> void:
if header_stats == null:
return
var profile := ProfileService.get_profile()
header_stats.text = "LV %d | %d XP | Streak %d" % [
profile.get("level", 1),
profile.get("xp", 0),
profile.get("current_streak", 0)
]
if user_label != null:
user_label.text = AuthState.username if AuthState.is_logged_in else ""
func _logout() -> void:
AuthState.clear_session()
show_screen("login")
+1
View File
@@ -0,0 +1 @@
uid://ctijx6n0pe0x
+80
View File
@@ -0,0 +1,80 @@
extends VBoxContainer
const Style := preload("res://scripts/ui/ui_style.gd")
var main: Control
var stats_label: Label
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
add_theme_constant_override("separation", 18)
var headline := Label.new()
headline.text = "Answer board-style questions. Earn XP. Unlock the map."
headline.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(headline, 38, Style.TEXT)
add_child(headline)
var subhead := Label.new()
subhead.text = "A desktop study game for STEP 3-style single-best-answer blocks with tutor and timed modes."
subhead.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(subhead, 18, Style.MUTED)
add_child(subhead)
var hero := PanelContainer.new()
hero.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Color("2bdbff")))
hero.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(hero)
var hero_content := VBoxContainer.new()
hero_content.add_theme_constant_override("separation", 16)
hero.add_child(hero_content)
var loop := Label.new()
loop.text = "XP Rush | Streak Heat | System Zones | Timed Blocks"
Style.set_label(loop, 24, Style.CYAN)
hero_content.add_child(loop)
var details := Label.new()
details.text = "Pick a clinical zone, clear a block, review every miss, and turn weak systems into unlock progress."
details.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(details, 18, Style.TEXT)
hero_content.add_child(details)
var button_row := HBoxContainer.new()
button_row.add_theme_constant_override("separation", 12)
hero_content.add_child(button_row)
var start := Button.new()
start.text = "Start Block"
start.custom_minimum_size = Vector2(180, 52)
Style.style_primary_button(start)
start.pressed.connect(func(): main.show_screen("setup", "All Systems"))
button_row.add_child(start)
var map := Button.new()
map.text = "Open Study Map"
map.custom_minimum_size = Vector2(180, 52)
map.pressed.connect(func(): main.show_screen("map"))
button_row.add_child(map)
var profile := Button.new()
profile.text = "View Profile"
profile.custom_minimum_size = Vector2(180, 52)
profile.pressed.connect(func(): main.show_screen("profile"))
button_row.add_child(profile)
stats_label = Label.new()
stats_label.name = "Stats"
Style.set_label(stats_label, 20, Style.LIME)
hero_content.add_child(stats_label)
func on_show(_payload: Variant = null) -> void:
var profile := ProfileService.get_profile()
stats_label.text = "Level %d | %d XP | Current streak: %d | Badges: %d" % [
profile["level"],
profile["xp"],
profile["current_streak"],
profile["badges"].size()
]
+1
View File
@@ -0,0 +1 @@
uid://bqy7hcss1sd2p
+81
View File
@@ -0,0 +1,81 @@
extends VBoxContainer
const Style := preload("res://scripts/ui/ui_style.gd")
var main: Control
var profile_text: RichTextLabel
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
add_theme_constant_override("separation", 14)
var title := Label.new()
title.text = "Progress Profile"
Style.set_label(title, 34, Style.CYAN)
add_child(title)
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Color("405475")))
panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(panel)
profile_text = RichTextLabel.new()
profile_text.bbcode_enabled = true
profile_text.size_flags_vertical = Control.SIZE_EXPAND_FILL
panel.add_child(profile_text)
var row := HBoxContainer.new()
var back := Button.new()
back.text = "Back to Menu"
back.pressed.connect(func(): main.show_screen("menu"))
row.add_child(back)
var reset := Button.new()
reset.text = "Reset Progress"
Style.style_chip(reset, Style.RED)
reset.pressed.connect(func():
ProfileService.reset_profile()
on_show()
)
row.add_child(reset)
add_child(row)
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" % [
profile["level"],
profile["xp"],
xp_into,
ProgressionService.XP_PER_LEVEL,
profile["current_streak"],
profile["best_streak"]
]
if profile["badges"].is_empty():
text += "No badges yet.\n"
else:
for badge in profile["badges"]:
text += "- %s\n" % badge
text += "\n[b]System Accuracy[/b]\n"
if profile["system_stats"].is_empty():
text += "No completed questions yet.\n"
else:
for system_name in profile["system_stats"].keys():
var stats: Dictionary = profile["system_stats"][system_name]
var seen := int(stats.get("seen", 0))
var correct := int(stats.get("correct", 0))
var accuracy := int(round(float(correct) / float(max(seen, 1)) * 100.0))
text += "- %s: %d/%d (%d%%)\n" % [system_name, correct, seen, accuracy]
text += "\n[b]Recent Blocks[/b]\n"
for block in profile["block_history"]:
text += "- %s: %s %d/%d, +%d XP\n" % [
block.get("timestamp", ""),
block.get("system", ""),
block.get("correct", 0),
block.get("total", 0),
block.get("xp_earned", 0)
]
profile_text.text = text
+1
View File
@@ -0,0 +1 @@
uid://dwhuknyk0bh7l
+453
View File
@@ -0,0 +1,453 @@
extends VBoxContainer
const Style := preload("res://scripts/ui/ui_style.gd")
var main: Control
var choice_buttons: Array[Button] = []
var eliminated: Array = []
var flagged := false
var selected_choice := ""
var current_answer := {}
var question_highlights := {}
var stem_plain_text := ""
var highlight_mode_enabled := false
var highlight_drag_started_in_stem := false
var progress_label: Label
var timer_label: Label
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 nav_palette: HBoxContainer
var question_content: VBoxContainer
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
add_theme_constant_override("separation", 10)
var top := HBoxContainer.new()
top.add_theme_constant_override("separation", 12)
progress_label = Label.new()
Style.set_label(progress_label, 18, Style.CYAN)
progress_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
top.add_child(progress_label)
timer_label = Label.new()
Style.set_label(timer_label, 18, Style.AMBER)
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 clear_highlight_button := Button.new()
clear_highlight_button.text = "Clear Highlights"
clear_highlight_button.pressed.connect(_clear_current_highlights)
highlight_row.add_child(clear_highlight_button)
add_child(highlight_row)
nav_palette = HBoxContainer.new()
nav_palette.add_theme_constant_override("separation", 6)
add_child(nav_palette)
var question_panel := PanelContainer.new()
question_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("182235"), Color("344463")))
question_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(question_panel)
var scroll := ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
question_panel.add_child(scroll)
question_content = VBoxContainer.new()
question_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
question_content.add_theme_constant_override("separation", 12)
scroll.add_child(question_content)
stem_label = RichTextLabel.new()
stem_label.fit_content = true
stem_label.bbcode_enabled = true
stem_label.selection_enabled = true
stem_label.scroll_active = false
stem_label.custom_minimum_size = Vector2(0, 96)
question_content.add_child(stem_label)
lead_label = Label.new()
lead_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(lead_label, 21, Style.TEXT)
question_content.add_child(lead_label)
for index in range(5):
var button := Button.new()
button.custom_minimum_size = Vector2(0, 52)
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
Style.style_answer_button(button)
button.pressed.connect(func(i := index): _select_choice(i))
button.gui_input.connect(func(event: InputEvent, i := index): _choice_gui_input(event, i))
choice_buttons.append(button)
question_content.add_child(button)
explanation_label = RichTextLabel.new()
explanation_label.bbcode_enabled = true
explanation_label.fit_content = true
explanation_label.scroll_active = false
explanation_label.custom_minimum_size = Vector2(0, 150)
explanation_label.visible = false
question_content.add_child(explanation_label)
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)
Style.style_primary_button(next_button)
next_button.pressed.connect(_submit_or_next)
actions.add_child(next_button)
add_child(actions)
func on_show(_payload: Variant = null) -> void:
_load_question()
func _process(delta: float) -> void:
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))
var minutes := int(floor(float(shown_seconds) / 60.0))
timer_label.text = "Timed: %02d:%02d" % [minutes, shown_seconds % 60]
if BlockSessionService.seconds_remaining <= 0:
_finish()
func _input(event: InputEvent) -> void:
if not visible or not highlight_mode_enabled or stem_label == null:
return
var mouse_event := event as InputEventMouseButton
if mouse_event == null or mouse_event.button_index != MOUSE_BUTTON_LEFT:
return
if mouse_event.pressed:
highlight_drag_started_in_stem = stem_label.get_global_rect().has_point(mouse_event.global_position)
elif highlight_drag_started_in_stem:
highlight_drag_started_in_stem = false
call_deferred("_commit_current_text_selection")
func _load_question() -> void:
eliminated.clear()
flagged = false
selected_choice = ""
current_answer = {}
flag_button.button_pressed = false
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"
stem_plain_text = str(question.get("stem", ""))
_render_stem()
lead_label.text = question.get("lead_in", "")
var choices: Array = question.get("choices", [])
for index in range(choice_buttons.size()):
var button := choice_buttons[index]
button.visible = index < choices.size()
button.disabled = false
if index < choices.size():
var choice: Dictionary = choices[index]
_set_choice_button_text(index, choice)
button.modulate = Color.WHITE
var saved_answer = BlockSessionService.get_answer_for_index(BlockSessionService.current_index)
if saved_answer != null:
current_answer = saved_answer
selected_choice = str(saved_answer.get("selected_choice_id", ""))
eliminated = saved_answer.get("eliminated_choice_ids", []).duplicate()
flagged = bool(saved_answer.get("flagged", false))
flag_button.button_pressed = flagged
if BlockSessionService.mode == "Tutor":
_show_explanation()
next_button.text = "Next"
else:
_apply_saved_choice_state()
next_button.text = "Next"
prev_button.disabled = BlockSessionService.current_index == 0
_refresh_nav_palette()
func _select_choice(index: int) -> void:
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
selected_choice = str(choices[index].get("id", ""))
if BlockSessionService.mode == "Timed":
current_answer = {}
next_button.text = "Submit"
_apply_saved_choice_state()
func _choice_gui_input(event: InputEvent, index: int) -> void:
var mouse_event := event as InputEventMouseButton
if mouse_event == null or mouse_event.button_index != MOUSE_BUTTON_RIGHT or not mouse_event.pressed:
return
if not current_answer.is_empty():
return
var question := BlockSessionService.get_current_question()
var choices: Array = question.get("choices", [])
if index >= choices.size():
return
var choice: Dictionary = choices[index]
var choice_id := str(choice.get("id", ""))
if eliminated.has(choice_id):
eliminated.erase(choice_id)
else:
eliminated.append(choice_id)
_set_choice_button_text(index, choice)
_apply_saved_choice_state()
func _toggle_eliminate_selected() -> void:
if selected_choice == "" or not current_answer.is_empty():
return
var question := BlockSessionService.get_current_question()
var choices: Array = question.get("choices", [])
for index in range(choices.size()):
if str(choices[index].get("id", "")) == selected_choice:
if eliminated.has(selected_choice):
eliminated.erase(selected_choice)
else:
eliminated.append(selected_choice)
_set_choice_button_text(index, choices[index])
_apply_saved_choice_state()
func _submit_or_next() -> void:
if current_answer.is_empty():
if selected_choice == "":
return
current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged)
if BlockSessionService.mode == "Tutor":
_show_explanation()
next_button.text = "Next"
else:
next_button.text = "Next"
_refresh_nav_palette()
else:
_advance_or_finish()
func _show_explanation() -> void:
var question := BlockSessionService.get_current_question()
var correct_id := str(question.get("correct_choice_id", ""))
for button in choice_buttons:
button.disabled = true
for index in range(question.get("choices", []).size()):
var choice: Dictionary = question["choices"][index]
var id := str(choice.get("id", ""))
if id == correct_id:
choice_buttons[index].modulate = Color(0.54, 1.0, 0.58)
elif id == selected_choice:
choice_buttons[index].modulate = Color(1.0, 0.48, 0.48)
var result_text := "Correct" if current_answer.get("is_correct", false) else "Incorrect"
explanation_label.text = "[b]%s[/b]\n%s" % [result_text, question.get("explanation", "")]
explanation_label.visible = true
_refresh_nav_palette()
func _advance_or_finish() -> void:
if BlockSessionService.move_next():
_load_question()
else:
_finish()
func _finish() -> void:
var results := BlockSessionService.finish_block()
main.show_screen("review", results)
func _commit_current_text_selection() -> void:
if not highlight_mode_enabled or stem_label == null:
return
var selected_text := stem_label.get_selected_text().strip_edges()
if selected_text == "":
return
_highlight_text(selected_text)
func _highlight_text(selected_text: String) -> void:
if selected_text == "":
return
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
if not question_highlights.has(question_id):
question_highlights[question_id] = []
var highlights: Array = question_highlights[question_id]
var start := _find_available_highlight_start(selected_text, highlights)
if start == -1:
start = stem_plain_text.find(selected_text)
if start == -1:
return
var end := start + selected_text.length()
if _remove_overlapping_highlights(highlights, start, end):
_render_stem()
if stem_label.has_method("deselect"):
stem_label.deselect()
return
highlights.append({"start": start, "end": end})
_render_stem()
if stem_label.has_method("deselect"):
stem_label.deselect()
func _clear_current_highlights() -> void:
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
question_highlights[question_id] = []
_render_stem()
func _render_stem() -> void:
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
var ranges: Array = question_highlights.get(question_id, []).duplicate()
ranges.sort_custom(func(a: Dictionary, b: Dictionary): return int(a["start"]) < int(b["start"]))
var rendered := ""
var cursor := 0
for highlight_range in ranges:
var start: int = clamp(int(highlight_range.get("start", 0)), 0, stem_plain_text.length())
var end: int = clamp(int(highlight_range.get("end", 0)), start, stem_plain_text.length())
if start < cursor:
continue
rendered += _escape_bbcode(stem_plain_text.substr(cursor, start - cursor))
rendered += "[bgcolor=yellow][color=black]%s[/color][/bgcolor]" % _escape_bbcode(stem_plain_text.substr(start, end - start))
cursor = end
rendered += _escape_bbcode(stem_plain_text.substr(cursor))
stem_label.text = rendered
func _escape_bbcode(value: String) -> String:
return value.replace("[", "[lb]").replace("]", "[rb]")
func _find_available_highlight_start(selected_text: String, highlights: Array) -> int:
var search_from := 0
while search_from < stem_plain_text.length():
var start := stem_plain_text.find(selected_text, search_from)
if start == -1:
return -1
var end := start + selected_text.length()
var overlaps := false
for highlight_range in highlights:
if start < int(highlight_range.get("end", 0)) and end > int(highlight_range.get("start", 0)):
overlaps = true
break
if not overlaps:
return start
search_from = start + 1
return -1
func _remove_overlapping_highlights(highlights: Array, start: int, end: int) -> bool:
var removed := false
for index in range(highlights.size() - 1, -1, -1):
var highlight_range: Dictionary = highlights[index]
if start < int(highlight_range.get("end", 0)) and end > int(highlight_range.get("start", 0)):
highlights.remove_at(index)
removed = true
return removed
func _apply_saved_choice_state() -> void:
var question := BlockSessionService.get_current_question()
var choices: Array = question.get("choices", [])
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].modulate = Color(0.45, 0.45, 0.45)
elif id == selected_choice:
choice_buttons[index].modulate = Color(0.68, 0.92, 1.0)
else:
choice_buttons[index].modulate = Color.WHITE
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):
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 _strike_text(value: String) -> String:
var result := ""
for character in value:
result += character + char(0x0336)
return result
func _refresh_nav_palette() -> void:
for child in nav_palette.get_children():
child.queue_free()
for index in range(BlockSessionService.active_questions.size()):
var button := Button.new()
button.text = str(index + 1)
button.custom_minimum_size = Vector2(38, 34)
var answer = BlockSessionService.get_answer_for_index(index)
if index == BlockSessionService.current_index:
Style.style_chip(button, Style.CYAN)
elif answer != null and bool(answer.get("flagged", false)):
Style.style_chip(button, Style.AMBER)
elif answer != null:
Style.style_chip(button, Style.LIME)
else:
Style.style_chip(button, Color("596982"))
button.pressed.connect(func(target_index: int = index):
if BlockSessionService.mode == "Timed":
BlockSessionService.move_to(target_index)
_load_question()
)
nav_palette.add_child(button)
+1
View File
@@ -0,0 +1 @@
uid://cfonfdpxr62qf
+77
View File
@@ -0,0 +1,77 @@
extends VBoxContainer
const Style := preload("res://scripts/ui/ui_style.gd")
var main: Control
var summary_label: Label
var answer_list: VBoxContainer
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
add_theme_constant_override("separation", 14)
var title := Label.new()
title.text = "Block Complete"
Style.set_label(title, 34, Style.LIME)
add_child(title)
summary_label = Label.new()
summary_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(summary_label, 20, Style.TEXT)
add_child(summary_label)
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Color("405475")))
panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(panel)
var scroll := ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
answer_list = VBoxContainer.new()
answer_list.add_theme_constant_override("separation", 10)
scroll.add_child(answer_list)
panel.add_child(scroll)
var row := HBoxContainer.new()
var map := Button.new()
map.text = "Back to Map"
map.pressed.connect(func(): main.show_screen("map"))
row.add_child(map)
var again := Button.new()
again.text = "New Block"
Style.style_primary_button(again)
again.pressed.connect(func(): main.show_screen("setup", "All Systems"))
row.add_child(again)
add_child(row)
func on_show(payload: Variant = null) -> void:
var results: Dictionary = payload if typeof(payload) == TYPE_DICTIONARY else {}
var accuracy := int(round(float(results.get("accuracy", 0.0)) * 100.0))
summary_label.text = "%d/%d correct (%d%%) | +%d XP | Streak: %d | New badges: %s" % [
results.get("correct", 0),
results.get("total", 0),
accuracy,
results.get("xp_earned", 0),
results.get("ending_streak", 0),
", ".join(results.get("badges_unlocked", [])) if results.get("badges_unlocked", []).size() > 0 else "None"
]
for child in answer_list.get_children():
child.queue_free()
for answer in results.get("answers", []):
var question: Dictionary = answer.get("question", {})
var item := RichTextLabel.new()
item.bbcode_enabled = true
item.fit_content = true
item.custom_minimum_size = Vector2(0, 90)
var icon := "[color=#a6ff5f]CLEAR[/color]" if answer.get("is_correct", false) else "[color=#ff5f6d]MISS[/color]"
item.text = "[b]%s[/b] %s\n[color=#aeb8ca]Selected: %s | Correct: %s[/color]\n%s" % [
icon,
question.get("lead_in", ""),
answer.get("selected_choice_id", ""),
question.get("correct_choice_id", ""),
question.get("explanation", "")
]
answer_list.add_child(item)
+1
View File
@@ -0,0 +1 @@
uid://dtouhe6v0wu34
+54
View File
@@ -0,0 +1,54 @@
extends VBoxContainer
const Style := preload("res://scripts/ui/ui_style.gd")
var main: Control
var zone_list: GridContainer
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
add_theme_constant_override("separation", 14)
var title := Label.new()
title.text = "Study Map"
Style.set_label(title, 34, Style.TEXT)
add_child(title)
var help := Label.new()
help.text = "Choose a system zone. Strong scores unlock momentum badges and level progress."
help.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(help, 16, Style.MUTED)
add_child(help)
zone_list = GridContainer.new()
zone_list.columns = 3
zone_list.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(zone_list)
func on_show(_payload: Variant = null) -> void:
for child in zone_list.get_children():
child.queue_free()
var systems: Array[String] = ["All Systems"]
systems.append_array(QuestionBankService.get_systems())
var stats: Dictionary = ProfileService.get_profile().get("system_stats", {})
for system_name in systems:
var button := Button.new()
button.custom_minimum_size = Vector2(260, 110)
button.text = _zone_text(system_name, stats)
Style.style_chip(button, Style.CYAN if system_name == "All Systems" else Style.MAGENTA)
button.pressed.connect(func(zone_name: String = system_name): main.show_screen("setup", zone_name))
zone_list.add_child(button)
func _zone_text(system_name: String, stats: Dictionary) -> String:
if system_name == "All Systems" or not stats.has(system_name):
return "%s\nStart block" % system_name
var seen := int(stats[system_name].get("seen", 0))
var correct := int(stats[system_name].get("correct", 0))
var accuracy := 0
if seen > 0:
accuracy = int(round(float(correct) / float(seen) * 100.0))
return "%s\n%d seen | %d%%" % [system_name, seen, accuracy]
+1
View File
@@ -0,0 +1 @@
uid://b15rio8jane4y
+84
View File
@@ -0,0 +1,84 @@
extends RefCounted
const BACKGROUND := Color("121827")
const SURFACE := Color("1d2638")
const SURFACE_DARK := Color("151c2b")
const SURFACE_LIGHT := Color("27334a")
const TEXT := Color("f6f8ff")
const MUTED := Color("aeb8ca")
const CYAN := Color("4de1ff")
const LIME := Color("a6ff5f")
const MAGENTA := Color("ff4fd8")
const AMBER := Color("ffc857")
const RED := Color("ff5f6d")
static func apply_app_theme(root: Control) -> void:
var theme := Theme.new()
theme.set_color("font_color", "Label", TEXT)
theme.set_color("font_color", "Button", TEXT)
theme.set_color("font_color", "RichTextLabel", TEXT)
theme.set_font_size("font_size", "Label", 17)
theme.set_font_size("font_size", "Button", 16)
theme.set_stylebox("normal", "Button", button_box(SURFACE_LIGHT, Color("3b4964")))
theme.set_stylebox("hover", "Button", button_box(Color("33425d"), CYAN))
theme.set_stylebox("pressed", "Button", button_box(Color("21344a"), LIME))
theme.set_stylebox("disabled", "Button", button_box(Color("202637"), Color("2c3445")))
theme.set_stylebox("focus", "Button", StyleBoxEmpty.new())
root.theme = theme
static func panel_box(color: Color = SURFACE, border: Color = Color("33415c")) -> StyleBoxFlat:
var box := StyleBoxFlat.new()
box.bg_color = color
box.border_color = border
box.set_border_width_all(1)
box.corner_radius_top_left = 8
box.corner_radius_top_right = 8
box.corner_radius_bottom_left = 8
box.corner_radius_bottom_right = 8
box.content_margin_left = 18
box.content_margin_right = 18
box.content_margin_top = 16
box.content_margin_bottom = 16
return box
static func button_box(color: Color, border: Color) -> StyleBoxFlat:
var box := StyleBoxFlat.new()
box.bg_color = color
box.border_color = border
box.set_border_width_all(1)
box.corner_radius_top_left = 6
box.corner_radius_top_right = 6
box.corner_radius_bottom_left = 6
box.corner_radius_bottom_right = 6
box.content_margin_left = 14
box.content_margin_right = 14
box.content_margin_top = 10
box.content_margin_bottom = 10
return box
static func set_label(label: Label, font_size: int, color: Color = TEXT) -> void:
label.add_theme_font_size_override("font_size", font_size)
label.add_theme_color_override("font_color", color)
static func style_primary_button(button: Button) -> void:
button.add_theme_stylebox_override("normal", button_box(Color("18465a"), CYAN))
button.add_theme_stylebox_override("hover", button_box(Color("20647e"), Color("8df1ff")))
button.add_theme_stylebox_override("pressed", button_box(Color("153b4d"), LIME))
button.add_theme_color_override("font_color", TEXT)
static func style_answer_button(button: Button) -> void:
button.add_theme_stylebox_override("normal", button_box(SURFACE_DARK, Color("303c55")))
button.add_theme_stylebox_override("hover", button_box(Color("26354f"), CYAN))
button.add_theme_stylebox_override("pressed", button_box(Color("20384d"), CYAN))
button.add_theme_color_override("font_color", TEXT)
static func style_chip(button: Button, color: Color) -> void:
button.add_theme_stylebox_override("normal", button_box(Color(color, 0.22), color))
button.add_theme_stylebox_override("hover", button_box(Color(color, 0.34), color))
button.add_theme_color_override("font_color", TEXT)
+1
View File
@@ -0,0 +1 @@
uid://c0t63ls30huo1