diff --git a/scripts/ui/dashboard.gd b/scripts/ui/dashboard.gd deleted file mode 100644 index d996cd9..0000000 --- a/scripts/ui/dashboard.gd +++ /dev/null @@ -1,160 +0,0 @@ -extends VBoxContainer - -const Style := preload("res://scripts/ui/ui_style.gd") - -var main: Control -var status_label: Label -var daily_label: Label -var elo_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 QuestionBankService.ratings_changed.is_connected(_refresh): - QuestionBankService.ratings_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") - elo_label = _add_metric(summary, "ELO") - 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] - elo_label.text = "ELO\n%d overall\n%d rated" % [ - QuestionBankService.overall_elo, - QuestionBankService.rated_question_count - ] - 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() diff --git a/scripts/ui/dashboard.gd.uid b/scripts/ui/dashboard.gd.uid deleted file mode 100644 index 6e7a523..0000000 --- a/scripts/ui/dashboard.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://7r2eyomsnpoe diff --git a/scripts/ui/login_screen.gd b/scripts/ui/login_screen.gd index a75061b..9807eb8 100644 --- a/scripts/ui/login_screen.gd +++ b/scripts/ui/login_screen.gd @@ -2,13 +2,30 @@ extends VBoxContainer const Style := preload("res://scripts/ui/ui_style.gd") const AUTH_LOGIN_URL := "https://pauth.ranaze.com/api/Auth/login" +const AUTH_REGISTER_URL := "https://pauth.ranaze.com/api/Auth/register" var main: Control +var auth_panel: PanelContainer +var login_tab: Button +var register_tab: Button +var login_fields: VBoxContainer +var register_fields: VBoxContainer var username_input: LineEdit var password_input: LineEdit +var registration_username_input: LineEdit +var registration_email_input: LineEdit +var registration_password_input: LineEdit +var registration_confirm_input: LineEdit var login_button: Button +var register_button: Button var error_label: Label +var success_label: Label var login_request: HTTPRequest +var register_request: HTTPRequest +var auth_mode := "login" +var automatic_login_pending := false +var login_attempt_username := "" + func _ready() -> void: set_anchors_preset(Control.PRESET_FULL_RECT) @@ -22,57 +39,82 @@ func _ready() -> void: 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) + auth_panel = PanelContainer.new() + auth_panel.custom_minimum_size = Vector2(540, 470) + auth_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Style.CYAN)) + center.add_child(auth_panel) var content := VBoxContainer.new() content.add_theme_constant_override("separation", 14) - panel.add_child(content) + auth_panel.add_child(content) var title := Label.new() - title.text = "STEP 3 Arcade" + title.text = "RanaZe's Doctor Maker" title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - Style.set_label(title, 36, Style.TEXT) + Style.set_label(title, 34, Style.TEXT) content.add_child(title) var subtitle := Label.new() - subtitle.text = "Sign in with your Promiscuity account to start studying." + subtitle.text = "Sign in or create an 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) + var tabs := HBoxContainer.new() + tabs.add_theme_constant_override("separation", 8) + content.add_child(tabs) - 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) + login_tab = Button.new() + login_tab.text = "Log In" + login_tab.custom_minimum_size = Vector2(0, 44) + login_tab.size_flags_horizontal = Control.SIZE_EXPAND_FILL + login_tab.pressed.connect(func(): _set_auth_mode("login")) + tabs.add_child(login_tab) - var password_label := Label.new() - password_label.text = "Password" - Style.set_label(password_label, 15, Style.CYAN) - content.add_child(password_label) + register_tab = Button.new() + register_tab.text = "Create Account" + register_tab.custom_minimum_size = Vector2(0, 44) + register_tab.size_flags_horizontal = Control.SIZE_EXPAND_FILL + register_tab.pressed.connect(func(): _set_auth_mode("register")) + tabs.add_child(register_tab) - 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_fields = VBoxContainer.new() + login_fields.add_theme_constant_override("separation", 10) + content.add_child(login_fields) + username_input = _add_text_field(login_fields, "Username", "enter username") + username_input.text_submitted.connect(_on_login_input_submitted) + password_input = _add_text_field(login_fields, "Password", "enter password", true) + password_input.text_submitted.connect(_on_login_input_submitted) 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) + login_fields.add_child(login_button) + + register_fields = VBoxContainer.new() + register_fields.add_theme_constant_override("separation", 10) + content.add_child(register_fields) + registration_username_input = _add_text_field(register_fields, "Username", "3-24 letters, numbers, or underscores") + registration_email_input = _add_text_field(register_fields, "Email", "name@example.com") + registration_password_input = _add_text_field(register_fields, "Password", "at least 8 characters", true) + registration_confirm_input = _add_text_field(register_fields, "Confirm password", "re-enter password", true) + registration_confirm_input.text_submitted.connect(_on_register_input_submitted) + + register_button = Button.new() + register_button.text = "Create Account" + register_button.custom_minimum_size = Vector2(0, 50) + Style.style_primary_button(register_button) + register_button.pressed.connect(_on_register_pressed) + register_fields.add_child(register_button) + + success_label = Label.new() + success_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + success_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(success_label, 15, Style.LIME) + content.add_child(success_label) error_label = Label.new() error_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER @@ -89,47 +131,90 @@ func _ready() -> void: login_request.request_completed.connect(_on_login_request_completed) add_child(login_request) + register_request = HTTPRequest.new() + register_request.name = "RegisterRequest" + register_request.request_completed.connect(_on_register_request_completed) + add_child(register_request) + _set_auth_mode("login") + func on_show(_payload: Variant = null) -> void: - error_label.text = "" - login_button.disabled = false - login_button.text = "Log In" + _set_busy(false) + _set_auth_mode("login") + _clear_messages() username_input.grab_focus() +func _add_text_field(parent: VBoxContainer, field_name: String, placeholder: String, secret: bool = false) -> LineEdit: + var label := Label.new() + label.text = field_name + Style.set_label(label, 15, Style.CYAN) + parent.add_child(label) + + var input := LineEdit.new() + input.placeholder_text = placeholder + input.secret = secret + input.custom_minimum_size = Vector2(0, 42) + parent.add_child(input) + return input + + +func _set_auth_mode(next_mode: String) -> void: + auth_mode = next_mode + if login_fields == null: + return + login_fields.visible = auth_mode == "login" + register_fields.visible = auth_mode == "register" + auth_panel.custom_minimum_size.y = 470 if auth_mode == "login" else 650 + if auth_mode == "login": + Style.style_primary_button(login_tab) + Style.style_chip(register_tab, Style.CYAN) + username_input.grab_focus() + else: + Style.style_chip(login_tab, Style.CYAN) + Style.style_primary_button(register_tab) + registration_username_input.grab_focus() + _clear_messages() + + func _on_login_pressed() -> void: - var username := username_input.text.strip_edges() - var password := password_input.text + _send_login(username_input.text.strip_edges(), password_input.text, false) + + +func _send_login(username: String, password: String, is_automatic: bool) -> void: if username.is_empty() or password.is_empty(): _show_error("Username and password required.") return - login_button.disabled = true + automatic_login_pending = is_automatic + login_attempt_username = username + _set_busy(true) login_button.text = "Signing in..." - error_label.text = "" - - var payload := { - "username": username, - "password": password, - } + _clear_messages() + if is_automatic: + success_label.text = "Account created. Signing you in..." + 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 + var request_error := login_request.request(AUTH_LOGIN_URL, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)) + if request_error != OK: + _set_busy(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 + _set_busy(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.") + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + if automatic_login_pending: + _set_auth_mode("login") + username_input.text = login_attempt_username + password_input.text = "" + _show_error("Account created, but automatic login failed. Please log in with your new account.") + else: + _show_error("Login failed. Check your credentials and try again.") + automatic_login_pending = false return var response: Variant = JSON.parse_string(body.get_string_from_utf8()) @@ -138,13 +223,13 @@ func _on_login_request_completed(result: int, response_code: int, _headers: Pack return var token := String(response.get("accessToken", "")) - var username := String(response.get("username", username_input.text.strip_edges())) + var response_username := String(response.get("username", login_attempt_username)) if token.is_empty(): _show_error("Login response did not include an access token.") return AuthState.set_session( - username, + response_username, token, String(response.get("refreshToken", "")), String(response.get("role", "")), @@ -152,12 +237,112 @@ func _on_login_request_completed(result: int, response_code: int, _headers: Pack ) QuestionBankService.load_questions_from_api() password_input.text = "" - main.show_screen("dashboard") + registration_password_input.text = "" + registration_confirm_input.text = "" + automatic_login_pending = false + main.show_screen("selection", "exam") -func _on_input_submitted(_new_text: String) -> void: +func _on_register_pressed() -> void: + var username := registration_username_input.text.strip_edges() + var email := registration_email_input.text.strip_edges() + var password := registration_password_input.text + var confirmation := registration_confirm_input.text + var validation_error := _validate_registration(username, email, password, confirmation) + if validation_error != "": + _show_error(validation_error) + return + + _set_busy(true) + register_button.text = "Creating account..." + _clear_messages() + var payload := {"username": username, "email": email, "password": password} + var headers := PackedStringArray(["Content-Type: application/json"]) + var request_error := register_request.request(AUTH_REGISTER_URL, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)) + if request_error != OK: + _set_busy(false) + register_button.text = "Create Account" + _show_error("Failed to send account creation request.") + + +func _on_register_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + _set_busy(false) + register_button.text = "Create Account" + if result != HTTPRequest.RESULT_SUCCESS: + _clear_registration_passwords() + _show_error("Network error. Please try again.") + return + if response_code < 200 or response_code >= 300: + _clear_registration_passwords() + var server_message := _response_message(body) + _show_error(server_message if server_message != "" else "Account creation failed.") + return + + var username := registration_username_input.text.strip_edges() + var password := registration_password_input.text + username_input.text = username + _send_login(username, password, true) + + +func _validate_registration(username: String, email: String, password: String, confirmation: String) -> String: + var username_pattern := RegEx.new() + username_pattern.compile("^[A-Za-z0-9_]{3,24}$") + if username_pattern.search(username) == null: + registration_username_input.grab_focus() + return "Username must be 3-24 characters using only letters, numbers, or underscores." + var email_pattern := RegEx.new() + email_pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$") + if email_pattern.search(email) == null: + registration_email_input.grab_focus() + return "Enter a valid email address." + if password.length() < 8: + registration_password_input.grab_focus() + return "Password must be at least 8 characters." + if password != confirmation: + registration_confirm_input.grab_focus() + return "Passwords do not match." + return "" + + +func _set_busy(is_busy: bool) -> void: + if login_button == null: + return + login_button.disabled = is_busy + register_button.disabled = is_busy + login_tab.disabled = is_busy + register_tab.disabled = is_busy + username_input.editable = not is_busy + password_input.editable = not is_busy + registration_username_input.editable = not is_busy + registration_email_input.editable = not is_busy + registration_password_input.editable = not is_busy + registration_confirm_input.editable = not is_busy + + +func _clear_registration_passwords() -> void: + registration_password_input.text = "" + registration_confirm_input.text = "" + + +func _response_message(body: PackedByteArray) -> String: + var raw := body.get_string_from_utf8().strip_edges() + var parsed: Variant = JSON.parse_string(raw) + return String(parsed) if typeof(parsed) == TYPE_STRING else raw + + +func _on_login_input_submitted(_new_text: String) -> void: _on_login_pressed() +func _on_register_input_submitted(_new_text: String) -> void: + _on_register_pressed() + + +func _clear_messages() -> void: + error_label.text = "" + success_label.text = "" + + func _show_error(message: String) -> void: + success_label.text = "" error_label.text = message diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 55c85ab..7aa7a49 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -13,12 +13,14 @@ var current_screen_name := "" func _ready() -> void: Style.apply_app_theme(self) _build_ui() - show_screen("dashboard" if AuthState.is_logged_in else "login") + show_screen("selection" if AuthState.is_logged_in else "login", "exam") func show_screen(screen_name: String, payload: Variant = null) -> void: _close_app_menu() for child in $Root/Screens.get_children(): + if child.visible and child.has_method("on_hide"): + child.on_hide() child.visible = false var screen = screens.get(screen_name) @@ -28,7 +30,7 @@ func show_screen(screen_name: String, payload: Variant = null) -> void: current_screen_name = screen_name screen.visible = true if header_panel != null: - header_panel.visible = screen_name != "login" + header_panel.visible = screen_name != "login" and screen_name != "player" if screen.has_method("on_show"): screen.on_show(payload) @@ -56,7 +58,7 @@ func _build_ui() -> void: header_panel.add_child(header) var title := Label.new() - title.text = "STEP 3 Arcade" + title.text = "RanaZe's Doctor Maker" Style.set_label(title, 31, Style.TEXT) title.size_flags_horizontal = Control.SIZE_EXPAND_FILL header.add_child(title) @@ -98,11 +100,11 @@ 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 selector := preload("res://scripts/ui/study_selector.gd").new() + selector.name = "StudySelector" + selector.main = self + screens_container.add_child(selector) + screens["selection"] = selector var map := preload("res://scripts/ui/study_map.gd").new() map.name = "StudyMap" @@ -153,7 +155,7 @@ func _refresh_header_stats() -> void: if header_stats == null: return var profile := ProfileService.get_profile() - header_stats.text = "ELO %d | Streak %d" % [ + header_stats.text = "Rating: %d | Streak %d" % [ QuestionBankService.overall_elo, profile.get("current_streak", 0) ] @@ -242,7 +244,7 @@ func _exit_run() -> void: _close_app_menu() if BlockSessionService.block_started: BlockSessionService.finish_block() - show_screen("dashboard") + show_screen("selection", "step3") func _open_profile_from_menu() -> void: diff --git a/scripts/ui/progress_profile.gd b/scripts/ui/progress_profile.gd index 3b73a4f..3010e16 100644 --- a/scripts/ui/progress_profile.gd +++ b/scripts/ui/progress_profile.gd @@ -28,8 +28,8 @@ func _ready() -> void: var row := HBoxContainer.new() var back := Button.new() - back.text = "Dashboard" - back.pressed.connect(func(): main.show_screen("dashboard")) + back.text = "Back" + back.pressed.connect(func(): main.show_screen("selection", "step3")) row.add_child(back) var reset := Button.new() diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index 9d6bae7..65f8488 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -3,6 +3,9 @@ extends VBoxContainer const Style := preload("res://scripts/ui/ui_style.gd") const FEEDBACK_FADE_SECONDS := 0.45 const CORRECT_ADVANCE_DELAY_SECONDS := 0.6 +const TEXT_SCALE_MIN := 0.85 +const TEXT_SCALE_MAX := 1.35 +const TEXT_SCALE_STEP := 0.1 var main: Control var choice_buttons: Array[Button] = [] @@ -25,18 +28,24 @@ var stem_plain_text := "" var highlight_drag_started_in_stem := false var timer_color_state := "" var pending_correct_advance := false +var question_text_scale := 1.0 +var presented_choices: Array = [] var progress_label: Label var timer_label: Label +var player_stats_label: Label var stem_label: RichTextLabel var lead_label: Label var explanation_label: RichTextLabel var question_elo_label: Label var next_button: Button var skip_button: Button +var text_smaller_button: Button +var text_larger_button: Button var labs_button: Button var flag_button: Button var report_button: Button +var question_toolbar: HBoxContainer var nav_palette: HBoxContainer var question_content: VBoxContainer var media_container: VBoxContainer @@ -61,15 +70,21 @@ func _ready() -> void: QuestionBankService.question_rating_updated.connect(_on_question_rating_updated) if not QuestionBankService.question_skipped.is_connected(_on_question_skipped): QuestionBankService.question_skipped.connect(_on_question_skipped) + if not ProfileService.profile_changed.is_connected(_refresh_player_stats): + ProfileService.profile_changed.connect(_refresh_player_stats) + if not QuestionBankService.ratings_changed.is_connected(_refresh_player_stats): + QuestionBankService.ratings_changed.connect(_refresh_player_stats) + if not AuthState.auth_changed.is_connected(_refresh_player_stats): + AuthState.auth_changed.connect(_refresh_player_stats) var top := HBoxContainer.new() top.add_theme_constant_override("separation", 12) progress_label = Label.new() - Style.set_label(progress_label, 18, Style.CYAN) + _set_scaled_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) + _set_scaled_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 @@ -79,9 +94,30 @@ func _ready() -> void: 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 + player_stats_label = Label.new() + player_stats_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + _set_scaled_label(player_stats_label, 15, Style.MUTED) + top.add_child(player_stats_label) + + question_toolbar = HBoxContainer.new() + question_toolbar.add_theme_constant_override("separation", 8) + question_toolbar.alignment = BoxContainer.ALIGNMENT_END + + text_smaller_button = Button.new() + text_smaller_button.text = "A-" + text_smaller_button.tooltip_text = "Decrease text size" + text_smaller_button.custom_minimum_size = Vector2(44, 44) + Style.style_chip(text_smaller_button, Style.CYAN) + text_smaller_button.pressed.connect(func(): _adjust_question_text_scale(-TEXT_SCALE_STEP)) + question_toolbar.add_child(text_smaller_button) + + text_larger_button = Button.new() + text_larger_button.text = "A+" + text_larger_button.tooltip_text = "Increase text size" + text_larger_button.custom_minimum_size = Vector2(44, 44) + Style.style_chip(text_larger_button, Style.CYAN) + text_larger_button.pressed.connect(func(): _adjust_question_text_scale(TEXT_SCALE_STEP)) + question_toolbar.add_child(text_larger_button) labs_button = Button.new() labs_button.tooltip_text = "Review lab results" @@ -89,7 +125,7 @@ func _ready() -> void: labs_button.custom_minimum_size = Vector2(44, 44) Style.style_chip(labs_button, Style.LIME) labs_button.pressed.connect(_show_labs_modal) - toolbar.add_child(labs_button) + question_toolbar.add_child(labs_button) var clear_highlight_button := Button.new() clear_highlight_button.tooltip_text = "Clear highlights" @@ -97,7 +133,7 @@ func _ready() -> void: 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) - toolbar.add_child(clear_highlight_button) + question_toolbar.add_child(clear_highlight_button) flag_button = Button.new() flag_button.toggle_mode = true @@ -109,16 +145,16 @@ func _ready() -> void: flagged = value _refresh_toolbar_state() ) - toolbar.add_child(flag_button) + question_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) + question_toolbar.add_child(report_button) - top.add_child(toolbar) + top.add_child(question_toolbar) add_child(top) nav_palette = HBoxContainer.new() @@ -151,6 +187,7 @@ func _ready() -> void: stem_label.selection_enabled = true stem_label.scroll_active = false stem_label.custom_minimum_size = Vector2(0, 96) + _set_scaled_rich_text(stem_label, 18) question_content.add_child(stem_label) media_container = VBoxContainer.new() @@ -159,15 +196,20 @@ func _ready() -> void: media_container.add_theme_constant_override("separation", 10) question_content.add_child(media_container) + var lead_margin := MarginContainer.new() + lead_margin.add_theme_constant_override("margin_top", 12) + lead_margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL + question_content.add_child(lead_margin) + 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) + _set_scaled_label(lead_label, 21, Style.TEXT) + lead_margin.add_child(lead_label) question_elo_label = Label.new() question_elo_label.visible = false question_elo_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT - Style.set_label(question_elo_label, 16, Style.AMBER) + _set_scaled_label(question_elo_label, 16, Style.AMBER) question_content.add_child(question_elo_label) for index in range(5): @@ -175,6 +217,7 @@ func _ready() -> void: button.custom_minimum_size = Vector2(0, 64) button.alignment = HORIZONTAL_ALIGNMENT_LEFT Style.style_answer_button(button) + _set_scaled_button_font(button, 17) 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) @@ -192,7 +235,7 @@ func _ready() -> void: percent_label.offset_top = 8 percent_label.offset_right = -18 percent_label.offset_bottom = 40 - Style.set_label(percent_label, 18, Style.TEXT) + _set_scaled_label(percent_label, 18, Style.TEXT) button.add_child(percent_label) choice_percent_labels.append(percent_label) @@ -207,7 +250,7 @@ func _ready() -> void: top_label.offset_top = 10 top_label.offset_right = -96 top_label.offset_bottom = -252 - Style.set_label(top_label, 16, Style.RED) + _set_scaled_label(top_label, 16, Style.RED) button.add_child(top_label) choice_top_labels.append(top_label) @@ -236,6 +279,7 @@ func _ready() -> void: explanation_label.scroll_active = false explanation_label.custom_minimum_size = Vector2(0, 150) explanation_label.visible = false + _set_scaled_rich_text(explanation_label, 17) question_content.add_child(explanation_label) var actions := HBoxContainer.new() @@ -311,6 +355,8 @@ func _input(event: InputEvent) -> void: elif highlight_drag_started_in_stem: highlight_drag_started_in_stem = false call_deferred("_commit_current_text_selection") + elif _should_clear_selection_from_click(mouse_event): + _clear_selected_choice() func _load_question() -> void: @@ -328,13 +374,13 @@ func _load_question() -> void: question_elo_label.visible = false question_elo_label.text = "" next_button.text = "Submit" - next_button.disabled = false + next_button.disabled = true skip_button.disabled = false skip_button.visible = BlockSessionService.session_kind == "core" var question := BlockSessionService.get_current_question() if question.is_empty(): - main.show_screen("dashboard") + main.show_screen("selection", "step3") return progress_label.visible = BlockSessionService.session_kind != "core" progress_label.text = "" if BlockSessionService.session_kind == "core" else "Question %s" % BlockSessionService.get_progress_text() @@ -348,7 +394,9 @@ func _load_question() -> void: lead_label.text = question.get("lead_in", "") _refresh_labs_button() - var choices: Array = question.get("choices", []) + presented_choices = question.get("choices", []).duplicate(true) + presented_choices.shuffle() + var choices := presented_choices for index in range(choice_buttons.size()): var button := choice_buttons[index] button.visible = index < choices.size() @@ -357,6 +405,9 @@ func _load_question() -> void: var choice: Dictionary = choices[index] _set_choice_button_text(index, choice) button.modulate = Color.WHITE + _style_choice_button_for_state(index, str(choice.get("id", ""))) + + _refresh_submit_state() var saved_answer = BlockSessionService.get_answer_for_index(BlockSessionService.current_index) if saved_answer != null: @@ -372,28 +423,78 @@ func _load_question() -> void: else: _apply_saved_choice_state() next_button.text = "Next" + next_button.disabled = false _update_question_elo_label(true) _refresh_nav_palette() _refresh_toolbar_state() + _refresh_player_stats() func _select_choice(index: int) -> void: if pending_correct_advance or followup_required: return - var question := BlockSessionService.get_current_question() - var choices: Array = question.get("choices", []) + var choices := _get_presented_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", "")) + var choice_id := str(choices[index].get("id", "")) + selected_choice = "" if selected_choice == choice_id else choice_id if BlockSessionService.mode == "Timed": current_answer = {} next_button.text = "Submit" _apply_saved_choice_state() +func _clear_selected_choice() -> void: + if selected_choice == "": + return + selected_choice = "" + if BlockSessionService.mode == "Timed": + current_answer = {} + next_button.text = "Submit" + _apply_saved_choice_state() + + +func _should_clear_selection_from_click(mouse_event: InputEventMouseButton) -> bool: + if selected_choice == "" or followup_required or not current_answer.is_empty(): + return false + if mouse_event.pressed or mouse_event.button_index != MOUSE_BUTTON_LEFT: + return false + var pointer_position := mouse_event.global_position + if stem_label.get_global_rect().has_point(pointer_position): + return false + if lead_label.get_global_rect().has_point(pointer_position): + return false + if explanation_label.visible and explanation_label.get_global_rect().has_point(pointer_position): + return false + if labs_overlay != null and labs_overlay.visible: + return false + if skip_button != null and skip_button.get_global_rect().has_point(pointer_position): + return false + if next_button != null and next_button.get_global_rect().has_point(pointer_position): + return false + if question_toolbar != null and question_toolbar.get_global_rect().has_point(pointer_position): + return false + if labs_button != null and labs_button.visible and labs_button.get_global_rect().has_point(pointer_position): + return false + if flag_button != null and flag_button.get_global_rect().has_point(pointer_position): + return false + if report_button != null and report_button.get_global_rect().has_point(pointer_position): + return false + for button in choice_buttons: + if button.visible and button.get_global_rect().has_point(pointer_position): + return false + for panel in choice_followup_panels: + if panel.visible and panel.get_global_rect().has_point(pointer_position): + return false + for child in media_container.get_children(): + if child is Control and child.get_global_rect().has_point(pointer_position): + return false + return true + + func _skip_current_question() -> void: if pending_correct_advance or not current_answer.is_empty() or BlockSessionService.session_kind != "core": return @@ -422,8 +523,7 @@ func _choice_gui_input(event: InputEvent, index: int) -> void: if not current_answer.is_empty(): return - var question := BlockSessionService.get_current_question() - var choices: Array = question.get("choices", []) + var choices := _get_presented_choices() if index >= choices.size(): return @@ -442,8 +542,7 @@ func _choice_gui_input(event: InputEvent, index: int) -> void: func _toggle_eliminate_selected() -> void: if pending_correct_advance or followup_required or selected_choice == "" or not current_answer.is_empty(): return - var question := BlockSessionService.get_current_question() - var choices: Array = question.get("choices", []) + var choices := _get_presented_choices() for index in range(choices.size()): if str(choices[index].get("id", "")) == selected_choice: if eliminated.has(selected_choice): @@ -499,8 +598,9 @@ func _show_explanation() -> void: 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 choices := _get_presented_choices() + for index in range(choices.size()): + var choice: Dictionary = choices[index] var id := str(choice.get("id", "")) _set_choice_button_text(index, choice) if id == correct_id: @@ -525,7 +625,7 @@ func _advance_or_finish() -> void: func _finish() -> void: if BlockSessionService.session_kind == "core": BlockSessionService.finish_block() - main.show_screen("dashboard") + main.show_screen("selection", "step3") return var results := BlockSessionService.finish_block() main.show_screen("review", results) @@ -534,7 +634,7 @@ func _finish() -> void: func _exit_run() -> void: BlockSessionService.resume_current_question_timer() BlockSessionService.finish_block() - main.show_screen("dashboard") + main.show_screen("selection", "step3") func _update_timer_bar() -> void: @@ -690,6 +790,61 @@ func _refresh_toolbar_state() -> void: Style.style_chip(report_button, Style.RED if reported_question_ids.has(question_id) else Style.CYAN) +func _refresh_player_stats() -> void: + if player_stats_label == null: + return + var display_name := AuthState.username if AuthState.username != "" else AuthState.role + player_stats_label.text = "Rating: %d | %s" % [ + QuestionBankService.overall_elo, + display_name + ] + + +func _adjust_question_text_scale(delta: float) -> void: + var next_scale: float = clamp(question_text_scale + delta, TEXT_SCALE_MIN, TEXT_SCALE_MAX) + if is_equal_approx(next_scale, question_text_scale): + return + question_text_scale = next_scale + _refresh_scaled_fonts(self) + _render_stem() + _apply_saved_choice_state() + + +func _set_scaled_label(label: Label, base_size: int, color: Color = Style.TEXT) -> void: + label.set_meta("question_base_font_size", base_size) + label.add_theme_color_override("font_color", color) + label.add_theme_font_size_override("font_size", _scaled_font_size(base_size)) + + +func _set_scaled_button_font(button: Button, base_size: int) -> void: + button.set_meta("question_base_font_size", base_size) + button.add_theme_font_size_override("font_size", _scaled_font_size(base_size)) + + +func _set_scaled_rich_text(label: RichTextLabel, base_size: int) -> void: + label.set_meta("question_base_font_size", base_size) + label.add_theme_font_size_override("normal_font_size", _scaled_font_size(base_size)) + label.add_theme_font_size_override("bold_font_size", _scaled_font_size(base_size)) + + +func _refresh_scaled_fonts(node: Node) -> void: + if node.has_meta("question_base_font_size"): + var base_size := int(node.get_meta("question_base_font_size")) + if node is Label: + node.add_theme_font_size_override("font_size", _scaled_font_size(base_size)) + elif node is Button: + node.add_theme_font_size_override("font_size", _scaled_font_size(base_size)) + elif node is RichTextLabel: + node.add_theme_font_size_override("normal_font_size", _scaled_font_size(base_size)) + node.add_theme_font_size_override("bold_font_size", _scaled_font_size(base_size)) + for child in node.get_children(): + _refresh_scaled_fonts(child) + + +func _scaled_font_size(base_size: int) -> int: + return int(round(float(base_size) * question_text_scale)) + + func _refresh_labs_button() -> void: if labs_button == null: return @@ -728,7 +883,7 @@ func _load_question_media(question: Dictionary) -> void: var status := Label.new() status.text = "Loading image..." status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - Style.set_label(status, 15, Style.MUTED) + _set_scaled_label(status, 15, Style.MUTED) image_box.add_child(status) var texture_rect := TextureRect.new() @@ -847,7 +1002,7 @@ func _build_labs_modal() -> void: var title := Label.new() title.text = "Lab Results" title.size_flags_horizontal = Control.SIZE_EXPAND_FILL - Style.set_label(title, 26, Style.TEXT) + _set_scaled_label(title, 26, Style.TEXT) header.add_child(title) var close_button := Button.new() @@ -926,7 +1081,7 @@ func _add_lab_table_label(parent: GridContainer, text: String, color: Color, is_ label.text = text if text.strip_edges() != "" else "-" label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - Style.set_label(label, 16 if not is_header else 17, color) + _set_scaled_label(label, 16 if not is_header else 17, color) parent.add_child(label) @@ -983,8 +1138,7 @@ func _subtract_highlight_range(highlights: Array, start: int, end: int) -> bool: func _apply_saved_choice_state() -> void: - var question := BlockSessionService.get_current_question() - var choices: Array = question.get("choices", []) + var choices := _get_presented_choices() for index in range(choices.size()): var id := str(choices[index].get("id", "")) var has_followup_panel := index < choice_followup_panels.size() and choice_followup_panels[index].visible @@ -1008,10 +1162,25 @@ func _apply_saved_choice_state() -> void: var id := str(choices[index].get("id", "")) choice_buttons[index].disabled = id != active_followup_choice_id + _refresh_submit_state() + + +func _refresh_submit_state() -> void: + if next_button == null: + return + if pending_correct_advance or followup_required: + next_button.disabled = true + return + if not current_answer.is_empty(): + next_button.disabled = false + return + next_button.disabled = selected_choice == "" + func _set_choice_button_text(index: int, choice: Dictionary) -> void: var choice_id := str(choice.get("id", "")) var choice_text := str(choice.get("text", "")) + var display_label := _display_label_for_index(index) var has_visible_followup := index < choice_followup_panels.size() and choice_followup_panels[index].visible choice_buttons[index].custom_minimum_size = _choice_row_size(index, choice_id, has_visible_followup) _set_choice_percent_label(index, "") @@ -1021,21 +1190,21 @@ func _set_choice_button_text(index: int, choice: Dictionary) -> void: if percentage >= 0: _set_choice_percent_label(index, "%d%%" % percentage) if has_visible_followup: - var wrong_text := "%s. %s" % [choice_id, choice_text] + var wrong_text := "%s. %s" % [display_label, choice_text] choice_buttons[index].text = "" _set_choice_top_label(index, wrong_text) else: - var wrong_text := "%s. %s" % [choice_id, _strike_text(choice_text)] + var wrong_text := "%s. %s" % [display_label, _strike_text(choice_text)] choice_buttons[index].text = wrong_text elif _should_show_correct_choice_percentage(choice_id): var correct_percentage := _get_choice_selection_percentage(choice_id) if correct_percentage >= 0: _set_choice_percent_label(index, "%d%%" % correct_percentage) - choice_buttons[index].text = "%s. %s" % [choice_id, choice_text] + choice_buttons[index].text = "%s. %s" % [display_label, choice_text] elif eliminated.has(choice_id): - choice_buttons[index].text = "%s. [eliminated] %s" % [choice_id, _strike_text(choice_text)] + choice_buttons[index].text = "%s. [eliminated] %s" % [display_label, _strike_text(choice_text)] else: - choice_buttons[index].text = "%s. %s" % [choice_id, choice_text] + choice_buttons[index].text = "%s. %s" % [display_label, choice_text] func _style_choice_button_for_state(index: int, choice_id: String) -> void: @@ -1095,7 +1264,7 @@ func _should_show_correct_choice_percentage(choice_id: String) -> bool: func _apply_correct_answer_preview() -> void: var question := BlockSessionService.get_current_question() var correct_id := str(question.get("correct_choice_id", "")) - var choices: Array = question.get("choices", []) + var choices := _get_presented_choices() for index in range(choices.size()): if str(choices[index].get("id", "")) == correct_id: choice_buttons[index].modulate = Color(0.54, 1.0, 0.58) @@ -1127,11 +1296,14 @@ func _build_followup_panel(index: int, choice_id: String) -> void: var panel := choice_followup_panels[index] var content := choice_followup_contents[index] var followup := _get_followup_for_choice(choice_id) + var followup_choices: Array = followup.get("choices", []).duplicate(true) + followup_choices.shuffle() + followup["choices"] = followup_choices followup_states[choice_id] = { "choice_id": choice_id, "choice_text": _choice_text_for_id(choice_id), "prompt": str(followup.get("prompt", "")), - "choices": followup.get("choices", []), + "choices": followup_choices, "correct_choice_id": str(followup.get("correct_choice_id", "")), "teaching_point": str(followup.get("teaching_point", "")), "selected_option_id": "", @@ -1145,19 +1317,21 @@ func _build_followup_panel(index: int, choice_id: String) -> void: var prompt := Label.new() prompt.text = str(followup.get("prompt", "What finding in the stem makes this answer less appropriate?")) prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - Style.set_label(prompt, 14, Style.TEXT) + _set_scaled_label(prompt, 14, Style.TEXT) content.add_child(prompt) - var choices: Array = followup.get("choices", []) + var choices: Array = followup_choices if bool(followup.get("has_authored_choices", false)): - for option in choices: + for option_index in range(choices.size()): + var option = choices[option_index] if typeof(option) != TYPE_DICTIONARY: continue var option_button := Button.new() - option_button.text = "%s. %s" % [str(option.get("id", "")), str(option.get("text", ""))] + option_button.text = "%s. %s" % [_display_label_for_index(option_index), str(option.get("text", ""))] option_button.alignment = HORIZONTAL_ALIGNMENT_LEFT option_button.custom_minimum_size = Vector2(0, 32) Style.style_chip(option_button, Style.CYAN) + _set_scaled_button_font(option_button, 15) option_button.set_meta("option_id", str(option.get("id", ""))) option_button.pressed.connect(func(option_id := str(option.get("id", "")), selected_button := option_button, followup_data := followup): _answer_followup(option_id, selected_button, followup_data) @@ -1173,6 +1347,7 @@ func _build_followup_panel(index: int, choice_id: String) -> void: continue_button.text = "Continue" continue_button.custom_minimum_size = Vector2(130, 32) Style.style_chip(continue_button, Style.CYAN) + _set_scaled_button_font(continue_button, 15) continue_button.pressed.connect(func(followup_data := followup): _resolve_followup(followup_data, null, false, false)) action_row.add_child(continue_button) @@ -1209,7 +1384,7 @@ func _resolve_followup(followup: Dictionary, selected_button: Button, style_sele var teaching := Label.new() teaching.text = str(followup.get("teaching_point", "Review the relevant stem finding before choosing again.")) teaching.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - Style.set_label(teaching, 14, Style.AMBER) + _set_scaled_label(teaching, 14, Style.AMBER) choice_followup_contents[index].add_child(teaching) if followup_states.has(resolved_choice_id): @@ -1280,10 +1455,11 @@ func _render_followup_state(choice_id: String) -> void: func _build_collapsed_followup(index: int, state: Dictionary) -> void: var content := choice_followup_contents[index] var summary := Button.new() - summary.text = "Review: %s. %s" % [str(state.get("choice_id", "")), str(state.get("choice_text", ""))] + summary.text = "Review: %s. %s" % [_display_label_for_choice_id(str(state.get("choice_id", ""))), str(state.get("choice_text", ""))] summary.alignment = HORIZONTAL_ALIGNMENT_LEFT summary.custom_minimum_size = Vector2(0, 40) Style.style_chip(summary, Style.AMBER) + _set_scaled_button_font(summary, 15) summary.gui_input.connect(func(_event: InputEvent): _mark_review_input_handled()) summary.pressed.connect(func(choice_id := str(state.get("choice_id", ""))): _toggle_followup_state(choice_id)) content.add_child(summary) @@ -1292,10 +1468,11 @@ func _build_collapsed_followup(index: int, state: Dictionary) -> void: func _build_readonly_followup(index: int, state: Dictionary) -> void: var content := choice_followup_contents[index] var summary := Button.new() - summary.text = "Hide review: %s. %s" % [str(state.get("choice_id", "")), str(state.get("choice_text", ""))] + summary.text = "Hide review: %s. %s" % [_display_label_for_choice_id(str(state.get("choice_id", ""))), str(state.get("choice_text", ""))] summary.alignment = HORIZONTAL_ALIGNMENT_LEFT summary.custom_minimum_size = Vector2(0, 36) Style.style_chip(summary, Style.AMBER) + _set_scaled_button_font(summary, 15) summary.gui_input.connect(func(_event: InputEvent): _mark_review_input_handled()) summary.pressed.connect(func(choice_id := str(state.get("choice_id", ""))): _toggle_followup_state(choice_id)) content.add_child(summary) @@ -1303,20 +1480,20 @@ func _build_readonly_followup(index: int, state: Dictionary) -> void: var prompt := Label.new() prompt.text = str(state.get("prompt", "")) prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - Style.set_label(prompt, 14, Style.TEXT) + _set_scaled_label(prompt, 14, Style.TEXT) content.add_child(prompt) var selected := Label.new() var selected_text := str(state.get("selected_option_text", "")) selected.text = "Your follow-up answer: %s" % selected_text if selected_text != "" else "Follow-up reviewed." selected.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - Style.set_label(selected, 14, Style.LIME if bool(state.get("was_correct", false)) else Style.RED) + _set_scaled_label(selected, 14, Style.LIME if bool(state.get("was_correct", false)) else Style.RED) content.add_child(selected) var teaching := Label.new() teaching.text = str(state.get("teaching_point", "")) teaching.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - Style.set_label(teaching, 14, Style.AMBER) + _set_scaled_label(teaching, 14, Style.AMBER) content.add_child(teaching) @@ -1361,9 +1538,23 @@ func _get_followup_for_choice(choice_id: String) -> Dictionary: } +func _get_presented_choices() -> Array: + if not presented_choices.is_empty(): + return presented_choices + return BlockSessionService.get_current_question().get("choices", []) + + +func _display_label_for_index(index: int) -> String: + return String.chr(65 + index) + + +func _display_label_for_choice_id(choice_id: String) -> String: + var index := _choice_index_for_id(choice_id) + return _display_label_for_index(index) if index >= 0 else choice_id + + func _choice_index_for_id(choice_id: String) -> int: - var question := BlockSessionService.get_current_question() - var choices: Array = question.get("choices", []) + var choices := _get_presented_choices() for index in range(choices.size()): if str(choices[index].get("id", "")) == choice_id: return index @@ -1371,8 +1562,7 @@ func _choice_index_for_id(choice_id: String) -> int: func _choice_text_for_id(choice_id: String) -> String: - var question := BlockSessionService.get_current_question() - var choices: Array = question.get("choices", []) + var choices := _get_presented_choices() for choice in choices: if typeof(choice) == TYPE_DICTIONARY and str(choice.get("id", "")) == choice_id: return str(choice.get("text", "")) diff --git a/scripts/ui/study_selector.gd b/scripts/ui/study_selector.gd new file mode 100644 index 0000000..435bd7e --- /dev/null +++ b/scripts/ui/study_selector.gd @@ -0,0 +1,250 @@ +extends VBoxContainer + +const Style := preload("res://scripts/ui/ui_style.gd") + +var main: Control +var exam_view: VBoxContainer +var mode_view: VBoxContainer +var exam_grid: GridContainer +var mode_grid: GridContainer +var question_bank_button: Button +var bank_status_label: Label +var bank_error_label: Label +var retry_button: Button +var current_stage := "exam" +var pending_question_bank_launch := false + + +func _ready() -> void: + set_anchors_preset(Control.PRESET_FULL_RECT) + add_theme_constant_override("separation", 18) + resized.connect(_refresh_grid_columns) + if not QuestionBankService.questions_changed.is_connected(_on_questions_changed): + QuestionBankService.questions_changed.connect(_on_questions_changed) + if not QuestionBankService.questions_load_failed.is_connected(_on_questions_load_failed): + QuestionBankService.questions_load_failed.connect(_on_questions_load_failed) + + _build_exam_view() + _build_mode_view() + _show_stage("exam") + + +func on_show(payload: Variant = null) -> void: + if str(payload) == "step3": + _show_stage("step3") + elif str(payload) == "exam": + _show_stage("exam") + _refresh_bank_state() + + +func on_hide() -> void: + pending_question_bank_launch = false + + +func _build_exam_view() -> void: + exam_view = VBoxContainer.new() + exam_view.add_theme_constant_override("separation", 18) + exam_view.size_flags_vertical = Control.SIZE_EXPAND_FILL + add_child(exam_view) + + var eyebrow := Label.new() + eyebrow.text = "CHOOSE YOUR EXAM" + Style.set_label(eyebrow, 14, Style.CYAN) + exam_view.add_child(eyebrow) + + var title := Label.new() + title.text = "What are you preparing for?" + Style.set_label(title, 34, Style.TEXT) + exam_view.add_child(title) + + var subtitle := Label.new() + subtitle.text = "Select a path to enter its study modes. More exams will be added as they are ready." + subtitle.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(subtitle, 17, Style.MUTED) + exam_view.add_child(subtitle) + + exam_grid = GridContainer.new() + exam_grid.columns = 2 + exam_grid.add_theme_constant_override("h_separation", 16) + exam_grid.add_theme_constant_override("v_separation", 16) + exam_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL + exam_grid.size_flags_vertical = Control.SIZE_EXPAND_FILL + exam_view.add_child(exam_grid) + + _add_exam_option("STEP 1", "Foundational sciences", false) + _add_exam_option("STEP 2", "Clinical knowledge", false) + _add_exam_option("STEP 3", "Advanced clinical management", true) + _add_exam_option("CCS Cases", "Interactive case simulations", false) + + +func _build_mode_view() -> void: + mode_view = VBoxContainer.new() + mode_view.add_theme_constant_override("separation", 18) + mode_view.size_flags_vertical = Control.SIZE_EXPAND_FILL + add_child(mode_view) + + var back_button := Button.new() + back_button.text = "< All exams" + back_button.alignment = HORIZONTAL_ALIGNMENT_LEFT + back_button.custom_minimum_size = Vector2(150, 42) + Style.style_chip(back_button, Style.CYAN) + back_button.pressed.connect(func(): _show_stage("exam")) + mode_view.add_child(back_button) + + var eyebrow := Label.new() + eyebrow.text = "STEP 3" + Style.set_label(eyebrow, 14, Style.LIME) + mode_view.add_child(eyebrow) + + var title := Label.new() + title.text = "Choose a study mode" + Style.set_label(title, 34, Style.TEXT) + mode_view.add_child(title) + + var subtitle := Label.new() + subtitle.text = "Build clinical judgment through focused questions. Additional modes are in development." + subtitle.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(subtitle, 17, Style.MUTED) + mode_view.add_child(subtitle) + + mode_grid = GridContainer.new() + mode_grid.columns = 2 + mode_grid.add_theme_constant_override("h_separation", 16) + mode_grid.add_theme_constant_override("v_separation", 16) + mode_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL + mode_view.add_child(mode_grid) + + question_bank_button = Button.new() + question_bank_button.text = "Question Bank\nAdaptive core run | Available" + question_bank_button.alignment = HORIZONTAL_ALIGNMENT_LEFT + question_bank_button.custom_minimum_size = Vector2(360, 150) + question_bank_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + question_bank_button.add_theme_font_size_override("font_size", 20) + Style.style_primary_button(question_bank_button) + question_bank_button.pressed.connect(_select_question_bank) + mode_grid.add_child(question_bank_button) + + var dynamic_button := Button.new() + dynamic_button.text = "Dynamic Questions\nAI-generated practice | Coming Soon" + dynamic_button.alignment = HORIZONTAL_ALIGNMENT_LEFT + dynamic_button.custom_minimum_size = Vector2(360, 150) + dynamic_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + dynamic_button.add_theme_font_size_override("font_size", 20) + dynamic_button.disabled = true + mode_grid.add_child(dynamic_button) + + bank_status_label = Label.new() + bank_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(bank_status_label, 16, Style.CYAN) + mode_view.add_child(bank_status_label) + + bank_error_label = Label.new() + bank_error_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(bank_error_label, 15, Style.RED) + mode_view.add_child(bank_error_label) + + retry_button = Button.new() + retry_button.text = "Retry Question Bank" + retry_button.custom_minimum_size = Vector2(220, 46) + retry_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + Style.style_chip(retry_button, Style.AMBER) + retry_button.pressed.connect(_retry_question_bank) + mode_view.add_child(retry_button) + + var spacer := Control.new() + spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL + mode_view.add_child(spacer) + + +func _add_exam_option(option_name: String, description: String, available: bool) -> void: + var button := Button.new() + button.text = "%s\n%s | %s" % [option_name, description, "Available" if available else "Coming Soon"] + button.alignment = HORIZONTAL_ALIGNMENT_LEFT + button.custom_minimum_size = Vector2(360, 150) + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.size_flags_vertical = Control.SIZE_EXPAND_FILL + button.add_theme_font_size_override("font_size", 20) + button.disabled = not available + if available: + Style.style_primary_button(button) + button.pressed.connect(func(): _show_stage("step3")) + exam_grid.add_child(button) + + +func _show_stage(stage_name: String) -> void: + current_stage = stage_name + pending_question_bank_launch = false + if exam_view != null: + exam_view.visible = stage_name == "exam" + if mode_view != null: + mode_view.visible = stage_name == "step3" + _refresh_grid_columns() + _refresh_bank_state() + + +func _select_question_bank() -> void: + if QuestionBankService.has_questions() and QuestionBankService.load_state == "ready": + _start_question_bank() + return + + pending_question_bank_launch = true + if QuestionBankService.load_state == "idle": + QuestionBankService.load_questions_from_api() + elif QuestionBankService.load_state == "failed": + QuestionBankService.retry_now() + _refresh_bank_state() + + +func _retry_question_bank() -> void: + pending_question_bank_launch = true + QuestionBankService.retry_now() + _refresh_bank_state() + + +func _on_questions_changed() -> void: + _refresh_bank_state() + if pending_question_bank_launch and visible and current_stage == "step3" and QuestionBankService.load_state == "ready": + pending_question_bank_launch = false + call_deferred("_start_question_bank") + + +func _on_questions_load_failed(_message: String) -> void: + pending_question_bank_launch = false + _refresh_bank_state() + + +func _refresh_bank_state() -> void: + if question_bank_button == null: + return + var state := QuestionBankService.load_state + question_bank_button.disabled = pending_question_bank_launch + retry_button.visible = state == "failed" or state == "retrying" + retry_button.disabled = state == "retrying" + bank_error_label.text = QuestionBankService.last_error if state == "failed" or state == "retrying" else "" + if pending_question_bank_launch or state == "loading": + bank_status_label.text = "Preparing your question bank..." + elif state == "retrying": + bank_status_label.text = "Question bank unavailable. Retrying in %d seconds." % QuestionBankService.next_retry_seconds + elif state == "ready": + bank_status_label.text = "%d questions ready" % QuestionBankService.get_all_questions().size() + elif state == "failed": + bank_status_label.text = "The question bank could not be loaded." + else: + bank_status_label.text = "Question bank will load when selected." + + +func _start_question_bank() -> void: + if not visible or current_stage != "step3" or not QuestionBankService.has_questions(): + return + if BlockSessionService.start_core_run(): + main.show_screen("player") + else: + bank_error_label.text = BlockSessionService.core_status_message + + +func _refresh_grid_columns() -> void: + var column_count := 1 if size.x < 820.0 else 2 + if exam_grid != null: + exam_grid.columns = column_count + if mode_grid != null: + mode_grid.columns = column_count diff --git a/scripts/ui/study_selector.gd.uid b/scripts/ui/study_selector.gd.uid new file mode 100644 index 0000000..f251864 --- /dev/null +++ b/scripts/ui/study_selector.gd.uid @@ -0,0 +1 @@ +uid://db6gcbemiotwl