Files
step3/scripts/ui/question_player.gd
T

2142 lines
77 KiB
GDScript

extends VBoxContainer
const Style := preload("res://scripts/ui/ui_style.gd")
const FEEDBACK_FADE_SECONDS := 0.45
const CORRECT_ADVANCE_DELAY_SECONDS := 1.25
const TEXT_SCALE_MIN := 0.85
const TEXT_SCALE_MAX := 1.35
const TEXT_SCALE_STEP := 0.1
const REPORT_DETAILS_MIN := 10
const REPORT_DETAILS_MAX := 2000
const REPORT_CATEGORIES := [
{"id": "clarity", "label": "Clarity"},
{"id": "medical_accuracy", "label": "Medical accuracy"},
{"id": "answer_explanation", "label": "Answer / explanation"},
{"id": "formatting_media", "label": "Formatting / media"},
{"id": "technical_issue", "label": "Technical issue"},
{"id": "other", "label": "Other"}
]
var main: Control
var theme_service
var choice_buttons: Array[Button] = []
var choice_percent_labels: Array[Label] = []
var choice_top_labels: Array[Label] = []
var choice_followup_panels: Array[PanelContainer] = []
var choice_followup_contents: Array[VBoxContainer] = []
var eliminated: Array = []
var locked_wrong_choice_ids: Array = []
var followup_states := {}
var active_followup_choice_id := ""
var followup_required := false
var followup_answered := false
var flagged := false
var selected_choice := ""
var current_answer := {}
var question_highlights := {}
var reported_question_ids := []
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 theme_button: Button
var question_toolbar: HBoxContainer
var nav_palette: HBoxContainer
var question_content: VBoxContainer
var media_container: VBoxContainer
var timer_bar: ProgressBar
var feedback_fog: ColorRect
var labs_overlay: Control
var labs_panel: PanelContainer
var labs_content: VBoxContainer
var report_overlay: Control
var report_panel: PanelContainer
var report_form_content: VBoxContainer
var report_success_content: VBoxContainer
var report_category_group: ButtonGroup
var report_category_buttons: Array[CheckBox] = []
var report_details: TextEdit
var report_char_count: Label
var report_error_label: Label
var report_submit_button: Button
var report_cancel_button: Button
var report_success_label: Label
var report_is_submitting := false
var report_question_id := ""
var media_requests: Array[HTTPRequest] = []
var red_fog_time := 0.0
var green_fog_time := 0.0
var correct_advance_delay := 0.0
var shake_time := 0.0
var shake_origin := Vector2.ZERO
func _ready() -> void:
theme_service = get_node("/root/UIThemeService")
set_anchors_preset(Control.PRESET_FULL_RECT)
add_theme_constant_override("separation", 10)
if not QuestionBankService.choice_percentages_updated.is_connected(_on_choice_percentages_updated):
QuestionBankService.choice_percentages_updated.connect(_on_choice_percentages_updated)
if not QuestionBankService.question_rating_updated.is_connected(_on_question_rating_updated):
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)
if not QuestionBankService.question_report_submitted.is_connected(_on_question_report_submitted):
QuestionBankService.question_report_submitted.connect(_on_question_report_submitted)
if not QuestionBankService.question_report_failed.is_connected(_on_question_report_failed):
QuestionBankService.question_report_failed.connect(_on_question_report_failed)
var top := HBoxContainer.new()
top.add_theme_constant_override("separation", 12)
progress_label = Label.new()
_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()
_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
top.add_child(timer_label)
var toolbar_spacer := Control.new()
toolbar_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
top.add_child(toolbar_spacer)
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
theme_button = Button.new()
theme_button.custom_minimum_size = Vector2(44, 44)
theme_button.add_theme_font_size_override("font_size", 22)
UISoundService.set_button_sound(theme_button, "toggle")
theme_button.pressed.connect(theme_service.toggle_theme)
question_toolbar.add_child(theme_button)
_refresh_theme_button()
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)
UISoundService.set_button_sound(text_smaller_button, "toggle")
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)
UISoundService.set_button_sound(text_larger_button, "toggle")
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"
labs_button.icon = _make_labs_icon()
labs_button.custom_minimum_size = Vector2(44, 44)
Style.style_chip(labs_button, Style.LIME)
UISoundService.set_button_sound(labs_button, "open")
labs_button.pressed.connect(_show_labs_modal)
question_toolbar.add_child(labs_button)
var clear_highlight_button := Button.new()
clear_highlight_button.tooltip_text = "Clear highlights"
clear_highlight_button.icon = _make_clear_highlights_icon()
clear_highlight_button.custom_minimum_size = Vector2(44, 44)
Style.style_chip(clear_highlight_button, Style.CYAN)
UISoundService.set_button_sound(clear_highlight_button, "eliminate")
clear_highlight_button.pressed.connect(_clear_current_highlights)
question_toolbar.add_child(clear_highlight_button)
flag_button = Button.new()
flag_button.toggle_mode = true
flag_button.tooltip_text = "Flag question"
flag_button.icon = _make_flag_icon()
flag_button.custom_minimum_size = Vector2(44, 44)
Style.style_chip(flag_button, Style.AMBER)
UISoundService.set_button_sound(flag_button, "toggle")
flag_button.toggled.connect(func(value: bool):
flagged = value
_refresh_toolbar_state()
)
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)
Style.style_chip(report_button, Style.CYAN)
UISoundService.set_button_sound(report_button, "open")
report_button.pressed.connect(_toggle_report_current_question)
question_toolbar.add_child(report_button)
top.add_child(question_toolbar)
add_child(top)
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)
var content_margin := MarginContainer.new()
content_margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
content_margin.add_theme_constant_override("margin_right", 16)
scroll.add_child(content_margin)
question_content = VBoxContainer.new()
question_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
question_content.add_theme_constant_override("separation", 12)
content_margin.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)
_set_scaled_rich_text(stem_label, 18)
question_content.add_child(stem_label)
media_container = VBoxContainer.new()
media_container.visible = false
media_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
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
_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
_set_scaled_label(question_elo_label, 16, Style.AMBER)
question_content.add_child(question_elo_label)
for index in range(5):
var button := Button.new()
button.custom_minimum_size = Vector2(0, 64)
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
Style.style_answer_button(button)
UISoundService.set_button_sound(button, "select")
_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)
var percent_label := Label.new()
percent_label.visible = false
percent_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
percent_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
percent_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
percent_label.anchor_left = 1.0
percent_label.anchor_top = 0.0
percent_label.anchor_right = 1.0
percent_label.anchor_bottom = 0.0
percent_label.offset_left = -92
percent_label.offset_top = 8
percent_label.offset_right = -18
percent_label.offset_bottom = 40
_set_scaled_label(percent_label, 18, Style.TEXT)
button.add_child(percent_label)
choice_percent_labels.append(percent_label)
var top_label := Label.new()
top_label.visible = false
top_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
top_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
top_label.vertical_alignment = VERTICAL_ALIGNMENT_TOP
top_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
top_label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
top_label.offset_left = 14
top_label.offset_top = 10
top_label.offset_right = -96
top_label.offset_bottom = -252
_set_scaled_label(top_label, 16, Style.RED)
button.add_child(top_label)
choice_top_labels.append(top_label)
var followup_panel := PanelContainer.new()
followup_panel.visible = false
followup_panel.mouse_filter = Control.MOUSE_FILTER_STOP
followup_panel.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
followup_panel.offset_left = 16
followup_panel.offset_top = 48
followup_panel.offset_right = -16
followup_panel.offset_bottom = -16
followup_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("26151a"), Style.RED))
button.add_child(followup_panel)
choice_followup_panels.append(followup_panel)
var followup_content := VBoxContainer.new()
followup_content.add_theme_constant_override("separation", 6)
followup_panel.add_child(followup_content)
choice_followup_contents.append(followup_content)
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
_set_scaled_rich_text(explanation_label, 17)
question_content.add_child(explanation_label)
var actions := HBoxContainer.new()
actions.add_theme_constant_override("separation", 10)
skip_button = Button.new()
skip_button.text = "Skip"
skip_button.custom_minimum_size = Vector2(120, 48)
Style.style_chip(skip_button, Style.AMBER)
UISoundService.set_button_sound(skip_button, "back")
skip_button.pressed.connect(_skip_current_question)
actions.add_child(skip_button)
next_button = Button.new()
next_button.text = "Submit"
next_button.custom_minimum_size = Vector2(140, 48)
next_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
Style.style_primary_button(next_button)
next_button.pressed.connect(_submit_or_next)
actions.add_child(next_button)
add_child(actions)
timer_bar = ProgressBar.new()
timer_bar.show_percentage = false
timer_bar.min_value = 0.0
timer_bar.max_value = 100.0
timer_bar.value = 100.0
timer_bar.custom_minimum_size = Vector2(0, 14)
timer_bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
timer_bar.add_theme_stylebox_override("background", Style.button_box(Color("232b3d"), Color("303b53")))
timer_bar.add_theme_stylebox_override("fill", Style.button_box(Style.LIME, Style.LIME))
add_child(timer_bar)
feedback_fog = ColorRect.new()
feedback_fog.color = Color(1.0, 0.0, 0.0, 0.0)
feedback_fog.mouse_filter = Control.MOUSE_FILTER_IGNORE
feedback_fog.top_level = true
feedback_fog.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(feedback_fog)
_build_labs_modal()
_build_report_modal()
resized.connect(_fit_modal_panels)
_fit_modal_panels()
func _fit_modal_panels() -> void:
var available := get_viewport_rect().size - Vector2(32, 32)
if report_panel != null:
report_panel.custom_minimum_size = Vector2(
minf(700.0, maxf(320.0, available.x)),
minf(600.0, maxf(560.0, available.y)))
if labs_panel != null:
labs_panel.custom_minimum_size = Vector2(
minf(760.0, maxf(320.0, available.x)),
minf(430.0, maxf(360.0, available.y)))
func on_show(_payload: Variant = null) -> void:
_load_question()
func on_hide() -> void:
_hide_report_modal(true)
_hide_labs_modal()
func _process(delta: float) -> void:
_update_answer_feedback(delta)
if visible and BlockSessionService.block_started and BlockSessionService.session_kind == "core":
_update_timer_bar()
if visible and BlockSessionService.block_started and BlockSessionService.mode == "Timed" and (report_overlay == null or not report_overlay.visible):
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 report_overlay != null and report_overlay.visible and event.is_action_pressed("ui_cancel"):
_hide_report_modal()
get_viewport().set_input_as_handled()
return
if labs_overlay != null and labs_overlay.visible and event.is_action_pressed("ui_cancel"):
_hide_labs_modal()
get_viewport().set_input_as_handled()
return
if not visible or pending_correct_advance 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")
elif _should_clear_selection_from_click(mouse_event):
_clear_selected_choice()
func _load_question() -> void:
_hide_report_modal(true)
_reset_wrong_feedback()
_hide_labs_modal()
eliminated.clear()
locked_wrong_choice_ids.clear()
_clear_followups()
flagged = false
selected_choice = ""
current_answer = {}
flag_button.button_pressed = false
_refresh_toolbar_state()
explanation_label.visible = false
question_elo_label.visible = false
question_elo_label.text = ""
next_button.text = "Submit"
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("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()
timer_label.visible = BlockSessionService.session_kind != "core"
timer_label.text = "" if BlockSessionService.session_kind == "core" else "Tutor Mode" if BlockSessionService.mode == "Tutor" else "Timed"
timer_bar.visible = BlockSessionService.session_kind == "core"
_update_timer_bar()
stem_plain_text = str(question.get("stem", ""))
_render_stem()
_load_question_media(question)
lead_label.text = question.get("lead_in", "")
_refresh_labs_button()
presented_choices = question.get("presented_choices", question.get("choices", [])).duplicate(true)
var choices := presented_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
_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:
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
_refresh_toolbar_state()
if BlockSessionService.mode == "Tutor":
_show_explanation()
next_button.text = "Next"
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 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
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
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
if question_id == "":
return
current_answer = BlockSessionService.skip_core_question(eliminated, flagged)
if current_answer.is_empty():
return
QuestionBankService.skip_question(question_id)
selected_choice = ""
next_button.disabled = true
skip_button.disabled = true
pending_correct_advance = true
correct_advance_delay = CORRECT_ADVANCE_DELAY_SECONDS
_show_explanation()
_update_question_elo_label(true)
func _choice_gui_input(event: InputEvent, index: int) -> void:
if pending_correct_advance or followup_required:
return
var mouse_event := event as InputEventMouseButton
if mouse_event == null or mouse_event.button_index != MOUSE_BUTTON_RIGHT or not mouse_event.pressed:
return
if not current_answer.is_empty():
return
var choices := _get_presented_choices()
if index >= choices.size():
return
var choice: Dictionary = choices[index]
var choice_id := str(choice.get("id", ""))
if locked_wrong_choice_ids.has(choice_id):
return
if eliminated.has(choice_id):
eliminated.erase(choice_id)
else:
eliminated.append(choice_id)
UISoundService.play_sound("eliminate")
_set_choice_button_text(index, choice)
_apply_saved_choice_state()
func _toggle_eliminate_selected() -> void:
if pending_correct_advance or followup_required or selected_choice == "" or not current_answer.is_empty():
return
var choices := _get_presented_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)
UISoundService.play_sound("eliminate")
_set_choice_button_text(index, choices[index])
_apply_saved_choice_state()
func _submit_or_next() -> void:
if pending_correct_advance:
return
if current_answer.is_empty():
if selected_choice == "":
return
var question_id_for_attempt := str(BlockSessionService.get_current_question().get("id", ""))
if BlockSessionService.session_kind == "core":
_collapse_resolved_followups()
var attempted_choice := selected_choice
QuestionBankService.record_first_attempt(
question_id_for_attempt,
attempted_choice,
_presented_choice_ids()
)
var result := BlockSessionService.submit_core_attempt(selected_choice, eliminated, flagged)
if result.is_empty():
return
var answer: Dictionary = result.get("answer", {})
if bool(result.get("is_correct", false)):
current_answer = answer
_update_question_elo_label(true)
_trigger_correct_feedback()
return
_lock_wrong_choice(attempted_choice)
_show_wrong_answer_followup(attempted_choice)
selected_choice = ""
current_answer = {}
next_button.text = "Submit"
next_button.disabled = true
_trigger_wrong_feedback()
_apply_saved_choice_state()
return
QuestionBankService.record_first_attempt(
question_id_for_attempt,
selected_choice,
_presented_choice_ids()
)
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
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:
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
_update_question_elo_label(true)
_refresh_nav_palette()
func _advance_or_finish() -> void:
if BlockSessionService.move_next():
_load_question()
else:
_finish()
func _finish() -> void:
if BlockSessionService.session_kind == "core":
BlockSessionService.finish_block()
main.show_screen("selection", "step3")
return
var results := BlockSessionService.finish_block()
main.show_screen("review", results)
func _exit_run() -> void:
BlockSessionService.resume_current_question_timer()
BlockSessionService.finish_block()
main.show_screen("selection", "step3")
func _update_timer_bar() -> void:
if timer_bar == null:
return
var ratio := BlockSessionService.get_core_timer_ratio()
timer_bar.value = ratio * 100.0
var color_state := "green"
var fill_color := Style.LIME
if ratio < 0.25:
color_state = "red"
fill_color = Style.RED
elif ratio <= 0.5:
color_state = "amber"
fill_color = Style.AMBER
if color_state != timer_color_state:
timer_color_state = color_state
timer_bar.add_theme_stylebox_override("fill", Style.button_box(fill_color, fill_color))
func _lock_wrong_choice(choice_id: String) -> void:
if choice_id == "":
return
if not locked_wrong_choice_ids.has(choice_id):
locked_wrong_choice_ids.append(choice_id)
func _trigger_wrong_feedback() -> void:
UISoundService.play_sound("error")
shake_time = 0.28
shake_origin = position
green_fog_time = 0.0
red_fog_time = FEEDBACK_FADE_SECONDS
if feedback_fog != null:
feedback_fog.color = Color(1.0, 0.0, 0.0, _feedback_overlay_alpha(false))
func _trigger_correct_feedback() -> void:
UISoundService.play_sound("correct")
pending_correct_advance = true
correct_advance_delay = CORRECT_ADVANCE_DELAY_SECONDS
red_fog_time = 0.0
green_fog_time = FEEDBACK_FADE_SECONDS
next_button.disabled = true
if skip_button != null:
skip_button.disabled = true
for button in choice_buttons:
button.disabled = true
if feedback_fog != null:
feedback_fog.color = Color(0.25, 1.0, 0.25, _feedback_overlay_alpha(true))
func _update_answer_feedback(delta: float) -> void:
if shake_time > 0.0:
shake_time = max(0.0, shake_time - delta)
if shake_time > 0.0:
position = shake_origin + Vector2(randf_range(-9.0, 9.0), randf_range(-4.0, 4.0))
else:
position = shake_origin
if red_fog_time > 0.0 and feedback_fog != null:
red_fog_time = max(0.0, red_fog_time - delta)
var red_alpha := _feedback_overlay_alpha(false) * (red_fog_time / FEEDBACK_FADE_SECONDS)
feedback_fog.color = Color(1.0, 0.0, 0.0, red_alpha)
elif green_fog_time > 0.0 and feedback_fog != null:
green_fog_time = max(0.0, green_fog_time - delta)
var green_alpha := _feedback_overlay_alpha(true) * (green_fog_time / FEEDBACK_FADE_SECONDS)
feedback_fog.color = Color(0.25, 1.0, 0.25, green_alpha)
if pending_correct_advance:
correct_advance_delay = max(0.0, correct_advance_delay - delta)
if correct_advance_delay <= 0.0:
pending_correct_advance = false
next_button.disabled = false
if skip_button != null:
skip_button.disabled = false
_advance_or_finish()
func _feedback_overlay_alpha(is_correct: bool) -> float:
if Style.is_dark:
return 0.23 if is_correct else 0.26
return 0.07
func _reset_wrong_feedback() -> void:
if shake_time > 0.0:
position = shake_origin
shake_time = 0.0
red_fog_time = 0.0
green_fog_time = 0.0
correct_advance_delay = 0.0
pending_correct_advance = false
next_button.disabled = false
if skip_button != null:
skip_button.disabled = false
if feedback_fog != null:
feedback_fog.color = Color(1.0, 0.0, 0.0, 0.0)
func _commit_current_text_selection() -> void:
if stem_label == null:
return
var raw_selected_text := stem_label.get_selected_text()
var selected_text := raw_selected_text.strip_edges()
if selected_text == "":
return
var left_trimmed_text := raw_selected_text.strip_edges(true, false)
var leading_trim_count := raw_selected_text.length() - left_trimmed_text.length()
var selection_start := stem_label.get_selection_from() + leading_trim_count
_highlight_range(selection_start, selection_start + selected_text.length())
func _highlight_range(start: int, end: int) -> void:
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]
start = clamp(start, 0, stem_plain_text.length())
end = clamp(end, start, stem_plain_text.length())
if start == end:
return
if _subtract_highlight_range(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 _toggle_report_current_question() -> void:
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
if question_id == "":
return
_show_report_modal(question_id)
func _refresh_toolbar_state() -> void:
_refresh_theme_button()
_refresh_labs_button()
if flag_button != null:
Style.style_chip(flag_button, Style.AMBER if flagged else Style.CYAN)
if report_button != null:
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
Style.style_chip(report_button, Style.RED if reported_question_ids.has(question_id) else Style.CYAN)
report_button.tooltip_text = "Report submitted; report another issue" if reported_question_ids.has(question_id) else "Report question"
func refresh_theme_controls() -> void:
_refresh_theme_button()
if labs_button != null:
labs_button.icon = _make_labs_icon()
if flag_button != null:
flag_button.icon = _make_flag_icon()
if report_button != null:
report_button.icon = _make_report_icon()
if question_toolbar != null:
for child in question_toolbar.get_children():
if child is Button and child.tooltip_text == "Clear highlights":
child.icon = _make_clear_highlights_icon()
_refresh_toolbar_state()
_apply_saved_choice_state()
func _refresh_theme_button() -> void:
if theme_button == null:
return
theme_button.text = "☀" if theme_service.is_dark_mode() else "☾"
theme_button.tooltip_text = "Switch to light mode" if theme_service.is_dark_mode() else "Switch to dark mode"
Style.style_chip(theme_button, 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_color_override("default_color", Style.TEXT)
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
labs_button.visible = _current_question_has_labs()
func _current_question_has_labs() -> bool:
var question := BlockSessionService.get_current_question()
if question.is_empty():
return false
var labs: Array = question.get("labs", [])
return not labs.is_empty()
func _load_question_media(question: Dictionary) -> void:
_clear_question_media()
var media: Array = question.get("media", [])
if media.is_empty():
return
media_container.visible = true
var question_id := str(question.get("id", ""))
for item in media:
var media_url := _resolve_media_url(str(item))
if media_url == "":
continue
var frame := PanelContainer.new()
frame.size_flags_horizontal = Control.SIZE_EXPAND_FILL
frame.add_theme_stylebox_override("panel", Style.panel_box(Color("111a2a"), Color("344463")))
media_container.add_child(frame)
var image_box := VBoxContainer.new()
image_box.add_theme_constant_override("separation", 8)
frame.add_child(image_box)
var status := Label.new()
status.text = "Loading image..."
status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_set_scaled_label(status, 15, Style.MUTED)
image_box.add_child(status)
var texture_rect := TextureRect.new()
texture_rect.visible = false
texture_rect.expand_mode = TextureRect.EXPAND_FIT_WIDTH
texture_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
texture_rect.custom_minimum_size = Vector2(0, 260)
texture_rect.size_flags_horizontal = Control.SIZE_EXPAND_FILL
image_box.add_child(texture_rect)
var request := HTTPRequest.new()
request.request_completed.connect(
func(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray):
_on_media_loaded(result, response_code, body, question_id, texture_rect, status)
)
media_requests.append(request)
add_child(request)
var err := request.request(media_url)
if err != OK:
status.text = "Image unavailable."
func _clear_question_media() -> void:
for request in media_requests:
if is_instance_valid(request):
request.queue_free()
media_requests.clear()
if media_container == null:
return
for child in media_container.get_children():
media_container.remove_child(child)
child.queue_free()
media_container.visible = false
func _resolve_media_url(value: String) -> String:
var media_url := value.strip_edges()
if media_url == "":
return ""
if media_url.begins_with("http://") or media_url.begins_with("https://"):
return media_url
if not media_url.begins_with("/"):
media_url = "/%s" % media_url
var base_url := QuestionBankService.question_api_base_url
var scheme_end := base_url.find("://")
if scheme_end < 0:
return media_url
var path_start := base_url.find("/", scheme_end + 3)
var origin := base_url if path_start < 0 else base_url.substr(0, path_start)
return "%s%s" % [origin, media_url]
func _on_media_loaded(
result: int,
response_code: int,
body: PackedByteArray,
question_id: String,
texture_rect: TextureRect,
status: Label) -> void:
if str(BlockSessionService.get_current_question().get("id", "")) != question_id:
return
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
status.text = "Image unavailable."
return
var image := Image.new()
var err := image.load_png_from_buffer(body)
if err != OK:
err = image.load_jpg_from_buffer(body)
if err != OK:
err = image.load_webp_from_buffer(body)
if err != OK:
status.text = "Image unavailable."
return
texture_rect.texture = ImageTexture.create_from_image(image)
texture_rect.visible = true
status.visible = false
func _build_report_modal() -> void:
report_overlay = Control.new()
report_overlay.visible = false
report_overlay.top_level = true
report_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
report_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
report_overlay.gui_input.connect(_report_overlay_gui_input)
add_child(report_overlay)
var dim := ColorRect.new()
dim.color = Color(0.02, 0.03, 0.05, 0.78)
dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
report_overlay.add_child(dim)
var center := CenterContainer.new()
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
report_overlay.add_child(center)
report_panel = PanelContainer.new()
report_panel.custom_minimum_size = Vector2(700, 600)
report_panel.mouse_filter = Control.MOUSE_FILTER_STOP
report_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Style.RED))
center.add_child(report_panel)
var modal_body := VBoxContainer.new()
modal_body.add_theme_constant_override("separation", 14)
report_panel.add_child(modal_body)
var header := HBoxContainer.new()
header.add_theme_constant_override("separation", 10)
modal_body.add_child(header)
var title := Label.new()
title.text = "Report question"
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_set_scaled_label(title, 26, Style.TEXT)
header.add_child(title)
var close_button := Button.new()
close_button.text = "X"
close_button.tooltip_text = "Close report form"
close_button.custom_minimum_size = Vector2(42, 38)
Style.style_chip(close_button, Style.RED)
UISoundService.set_button_sound(close_button, "close")
close_button.pressed.connect(_hide_report_modal)
header.add_child(close_button)
report_form_content = VBoxContainer.new()
report_form_content.add_theme_constant_override("separation", 12)
modal_body.add_child(report_form_content)
var category_label := Label.new()
category_label.text = "What is the problem?"
_set_scaled_label(category_label, 18, Style.CYAN)
report_form_content.add_child(category_label)
var category_grid := GridContainer.new()
category_grid.columns = 2
category_grid.add_theme_constant_override("h_separation", 18)
category_grid.add_theme_constant_override("v_separation", 8)
report_form_content.add_child(category_grid)
report_category_group = ButtonGroup.new()
report_category_group.allow_unpress = false
for category in REPORT_CATEGORIES:
var category_button := CheckBox.new()
category_button.text = str(category["label"])
category_button.set_meta("report_category_id", str(category["id"]))
category_button.button_group = report_category_group
category_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
category_button.custom_minimum_size = Vector2(0, 38)
_set_scaled_button_font(category_button, 16)
category_button.toggled.connect(_on_report_category_toggled)
category_grid.add_child(category_button)
report_category_buttons.append(category_button)
var details_header := HBoxContainer.new()
report_form_content.add_child(details_header)
var details_label := Label.new()
details_label.text = "Details"
details_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_set_scaled_label(details_label, 18, Style.CYAN)
details_header.add_child(details_label)
report_char_count = Label.new()
report_char_count.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_set_scaled_label(report_char_count, 14, Style.MUTED)
details_header.add_child(report_char_count)
report_details = TextEdit.new()
report_details.placeholder_text = "Describe what is wrong and what should be reviewed."
report_details.custom_minimum_size = Vector2(0, 190)
report_details.size_flags_vertical = Control.SIZE_EXPAND_FILL
report_details.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
report_details.add_theme_font_size_override("font_size", 16)
report_details.add_theme_color_override("font_color", Style.TEXT)
report_details.add_theme_color_override("font_placeholder_color", Style.MUTED)
report_details.add_theme_stylebox_override("normal", Style.panel_box(Style.SURFACE_DARK, Color("3b4964")))
report_details.text_changed.connect(_on_report_details_changed)
report_form_content.add_child(report_details)
report_error_label = Label.new()
report_error_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
report_error_label.custom_minimum_size = Vector2(0, 28)
_set_scaled_label(report_error_label, 15, Style.RED)
report_form_content.add_child(report_error_label)
var actions := HBoxContainer.new()
actions.add_theme_constant_override("separation", 10)
report_form_content.add_child(actions)
report_cancel_button = Button.new()
report_cancel_button.text = "Cancel"
report_cancel_button.custom_minimum_size = Vector2(130, 46)
Style.style_chip(report_cancel_button, Style.MUTED)
UISoundService.set_button_sound(report_cancel_button, "close")
report_cancel_button.pressed.connect(_hide_report_modal)
actions.add_child(report_cancel_button)
report_submit_button = Button.new()
report_submit_button.text = "Submit report"
report_submit_button.custom_minimum_size = Vector2(170, 46)
report_submit_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
Style.style_primary_button(report_submit_button)
UISoundService.set_button_sound(report_submit_button, "confirmation")
report_submit_button.pressed.connect(_submit_question_report)
actions.add_child(report_submit_button)
report_success_content = VBoxContainer.new()
report_success_content.visible = false
report_success_content.size_flags_vertical = Control.SIZE_EXPAND_FILL
report_success_content.alignment = BoxContainer.ALIGNMENT_CENTER
report_success_content.add_theme_constant_override("separation", 18)
modal_body.add_child(report_success_content)
report_success_label = Label.new()
report_success_label.text = "Report submitted"
report_success_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
report_success_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_set_scaled_label(report_success_label, 24, Style.LIME)
report_success_content.add_child(report_success_label)
var success_close_button := Button.new()
success_close_button.text = "Close"
success_close_button.custom_minimum_size = Vector2(180, 48)
Style.style_primary_button(success_close_button)
UISoundService.set_button_sound(success_close_button, "close")
success_close_button.pressed.connect(_hide_report_modal)
report_success_content.add_child(success_close_button)
_update_report_form_state()
func _report_overlay_gui_input(event: InputEvent) -> void:
var mouse_event := event as InputEventMouseButton
if mouse_event == null or not mouse_event.pressed or mouse_event.button_index != MOUSE_BUTTON_LEFT:
return
if report_panel != null and not report_panel.get_global_rect().has_point(mouse_event.global_position):
_hide_report_modal()
get_viewport().set_input_as_handled()
func _show_report_modal(question_id: String) -> void:
if report_overlay == null or report_is_submitting:
return
_hide_labs_modal()
report_question_id = question_id
report_form_content.visible = true
report_success_content.visible = false
report_details.text = ""
report_error_label.text = ""
for category_button in report_category_buttons:
category_button.button_pressed = false
_set_report_submitting(false)
_update_report_form_state()
report_overlay.visible = true
report_overlay.move_to_front()
BlockSessionService.pause_current_question_timer()
func _hide_report_modal(force: bool = false) -> void:
if report_overlay == null or (report_is_submitting and not force):
return
if report_overlay.visible:
report_overlay.visible = false
BlockSessionService.resume_current_question_timer()
if not report_is_submitting:
report_question_id = ""
func _on_report_category_toggled(pressed: bool) -> void:
if pressed:
_update_report_form_state()
func _on_report_details_changed() -> void:
_update_report_form_state()
func _selected_report_category() -> String:
if report_category_group == null:
return ""
var selected := report_category_group.get_pressed_button()
if selected == null:
return ""
return str(selected.get_meta("report_category_id", ""))
func _update_report_form_state() -> void:
if report_details == null or report_submit_button == null:
return
var details_length := report_details.text.strip_edges().length()
report_char_count.text = "%d / %d" % [details_length, REPORT_DETAILS_MAX]
report_char_count.add_theme_color_override("font_color", Style.RED if details_length > REPORT_DETAILS_MAX else Style.MUTED)
if not report_is_submitting:
report_error_label.text = ""
report_submit_button.disabled = (
report_is_submitting
or _selected_report_category() == ""
or details_length < REPORT_DETAILS_MIN
or details_length > REPORT_DETAILS_MAX
)
func _set_report_submitting(value: bool) -> void:
report_is_submitting = value
if report_details != null:
report_details.editable = not value
for category_button in report_category_buttons:
category_button.disabled = value
if report_cancel_button != null:
report_cancel_button.disabled = value
if report_submit_button != null:
report_submit_button.text = "Submitting..." if value else "Submit report"
_update_report_form_state()
func _submit_question_report() -> void:
if report_is_submitting or report_question_id == "":
return
var category := _selected_report_category()
var details := report_details.text.strip_edges()
if category == "" or details.length() < REPORT_DETAILS_MIN or details.length() > REPORT_DETAILS_MAX:
_update_report_form_state()
return
report_error_label.text = ""
_set_report_submitting(true)
QuestionBankService.submit_question_report(report_question_id, category, details)
func _on_question_report_submitted(question_id: String, _report_id: String) -> void:
if question_id != report_question_id:
return
_set_report_submitting(false)
if not reported_question_ids.has(question_id):
reported_question_ids.append(question_id)
_refresh_toolbar_state()
report_form_content.visible = false
report_success_content.visible = true
report_success_label.text = "Report submitted\nThank you for helping improve this question."
UISoundService.play_sound("confirmation")
func _on_question_report_failed(question_id: String, message: String) -> void:
if question_id != report_question_id:
return
_set_report_submitting(false)
report_error_label.text = message
UISoundService.play_sound("error")
func _build_labs_modal() -> void:
labs_overlay = Control.new()
labs_overlay.visible = false
labs_overlay.top_level = true
labs_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
labs_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
labs_overlay.gui_input.connect(_labs_overlay_gui_input)
add_child(labs_overlay)
var dim := ColorRect.new()
dim.color = Color(0.02, 0.03, 0.05, 0.72)
dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
labs_overlay.add_child(dim)
var center := CenterContainer.new()
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
labs_overlay.add_child(center)
labs_panel = PanelContainer.new()
labs_panel.custom_minimum_size = Vector2(760, 430)
labs_panel.mouse_filter = Control.MOUSE_FILTER_STOP
labs_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Style.LIME))
center.add_child(labs_panel)
var modal_body := VBoxContainer.new()
modal_body.add_theme_constant_override("separation", 14)
labs_panel.add_child(modal_body)
var header := HBoxContainer.new()
header.add_theme_constant_override("separation", 10)
modal_body.add_child(header)
var title := Label.new()
title.text = "Lab Results"
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_set_scaled_label(title, 26, Style.TEXT)
header.add_child(title)
var close_button := Button.new()
close_button.text = "X"
close_button.tooltip_text = "Close lab results"
close_button.custom_minimum_size = Vector2(42, 38)
Style.style_chip(close_button, Style.RED)
UISoundService.set_button_sound(close_button, "close")
close_button.pressed.connect(_hide_labs_modal)
header.add_child(close_button)
var scroll := ScrollContainer.new()
scroll.custom_minimum_size = Vector2(0, 320)
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
modal_body.add_child(scroll)
labs_content = VBoxContainer.new()
labs_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
labs_content.add_theme_constant_override("separation", 8)
scroll.add_child(labs_content)
func _labs_overlay_gui_input(event: InputEvent) -> void:
var mouse_event := event as InputEventMouseButton
if mouse_event == null or not mouse_event.pressed or mouse_event.button_index != MOUSE_BUTTON_LEFT:
return
if labs_panel == null or not labs_panel.get_global_rect().has_point(mouse_event.global_position):
_hide_labs_modal()
get_viewport().set_input_as_handled()
func _show_labs_modal() -> void:
if labs_overlay == null or labs_content == null:
return
var question := BlockSessionService.get_current_question()
var labs: Array = question.get("labs", [])
if labs.is_empty():
return
_populate_labs_modal(labs)
labs_overlay.visible = true
labs_overlay.move_to_front()
func _hide_labs_modal() -> void:
if labs_overlay != null:
labs_overlay.visible = false
func _populate_labs_modal(labs: Array) -> void:
for child in labs_content.get_children():
labs_content.remove_child(child)
child.queue_free()
var grid := GridContainer.new()
grid.columns = 3
grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
grid.add_theme_constant_override("h_separation", 16)
grid.add_theme_constant_override("v_separation", 8)
labs_content.add_child(grid)
_add_lab_table_label(grid, "Test", Style.CYAN, true)
_add_lab_table_label(grid, "Value", Style.CYAN, true)
_add_lab_table_label(grid, "Reference", Style.CYAN, true)
for lab in labs:
if typeof(lab) != TYPE_DICTIONARY:
continue
var lab_dict: Dictionary = lab
_add_lab_table_label(grid, str(lab_dict.get("name", "-")), Style.TEXT)
_add_lab_table_label(grid, _format_lab_value(lab_dict), Style.TEXT)
_add_lab_table_label(grid, str(lab_dict.get("reference", "-")), Style.MUTED)
func _add_lab_table_label(parent: GridContainer, text: String, color: Color, is_header: bool = false) -> void:
var label := Label.new()
label.text = text if text.strip_edges() != "" else "-"
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_set_scaled_label(label, 16 if not is_header else 17, color)
parent.add_child(label)
func _format_lab_value(lab: Dictionary) -> String:
var value := str(lab.get("value", "")).strip_edges()
var unit := str(lab.get("unit", "")).strip_edges()
if value == "" and unit == "":
return "-"
if unit == "":
return value
if value == "":
return unit
return "%s %s" % [value, unit]
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 _subtract_highlight_range(highlights: Array, start: int, end: int) -> bool:
var changed := false
for index in range(highlights.size() - 1, -1, -1):
var highlight_range: Dictionary = highlights[index]
var highlight_start := int(highlight_range.get("start", 0))
var highlight_end := int(highlight_range.get("end", 0))
if start < highlight_end and end > highlight_start:
highlights.remove_at(index)
if highlight_start < start:
highlights.insert(index, {"start": highlight_start, "end": start})
index += 1
if end < highlight_end:
highlights.insert(index, {"start": end, "end": highlight_end})
changed = true
return changed
func _apply_saved_choice_state() -> void:
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
_set_choice_button_text(index, choices[index])
choice_buttons[index].disabled = locked_wrong_choice_ids.has(id) and not has_followup_panel
_style_choice_button_for_state(index, id)
if locked_wrong_choice_ids.has(id):
choice_buttons[index].modulate = Color(1.0, 0.25, 0.25) if Style.is_dark else Color.WHITE
elif eliminated.has(id):
choice_buttons[index].modulate = Color(0.45, 0.45, 0.45)
elif id == selected_choice:
choice_buttons[index].modulate = Color(0.68, 0.92, 1.0)
else:
choice_buttons[index].modulate = Color.WHITE
if pending_correct_advance:
_apply_correct_answer_preview()
if followup_required:
for index in range(choices.size()):
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, "")
_set_choice_top_label(index, "")
if locked_wrong_choice_ids.has(choice_id):
var percentage := _get_choice_selection_percentage(choice_id)
if percentage >= 0:
_set_choice_percent_label(index, "%d%%" % percentage)
if has_visible_followup:
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" % [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" % [display_label, choice_text]
elif eliminated.has(choice_id):
choice_buttons[index].text = "%s. [eliminated] %s" % [display_label, _strike_text(choice_text)]
else:
choice_buttons[index].text = "%s. %s" % [display_label, choice_text]
func _style_choice_button_for_state(index: int, choice_id: String) -> void:
if choice_id == selected_choice:
choice_buttons[index].add_theme_stylebox_override("normal", Style.button_box(Color("18465a"), Style.CYAN))
choice_buttons[index].add_theme_stylebox_override("hover", Style.button_box(Color("20647e"), Style.CYAN))
choice_buttons[index].add_theme_stylebox_override("pressed", Style.button_box(Color("18465a"), Style.CYAN))
else:
Style.style_answer_button(choice_buttons[index])
func _choice_row_size(_index: int, choice_id: String, has_visible_followup: bool) -> Vector2:
if not has_visible_followup and choice_id != active_followup_choice_id:
return Vector2(0, 64)
if followup_states.has(choice_id):
var state: Dictionary = followup_states[choice_id]
if bool(state.get("collapsed", false)):
return Vector2(0, 136)
return Vector2(0, 300)
func _mark_review_input_handled() -> void:
get_viewport().set_input_as_handled()
func _set_choice_percent_label(index: int, text: String) -> void:
if index < 0 or index >= choice_percent_labels.size():
return
var label := choice_percent_labels[index]
label.text = text
label.visible = text != ""
func _set_choice_top_label(index: int, text: String) -> void:
if index < 0 or index >= choice_top_labels.size():
return
var label := choice_top_labels[index]
label.text = text
label.visible = text != ""
func _get_choice_selection_percentage(choice_id: String) -> int:
var question := BlockSessionService.get_current_question()
var percentages: Dictionary = question.get("choice_selection_percentages", {})
if percentages.is_empty():
return -1
if not percentages.has(choice_id):
return 0
return clamp(int(percentages.get(choice_id, -1)), 0, 100)
func _should_show_correct_choice_percentage(choice_id: String) -> bool:
var question := BlockSessionService.get_current_question()
return choice_id == str(question.get("correct_choice_id", "")) and (pending_correct_advance or not current_answer.is_empty())
func _apply_correct_answer_preview() -> void:
var question := BlockSessionService.get_current_question()
var correct_id := str(question.get("correct_choice_id", ""))
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)
return
func _on_choice_percentages_updated(question_id: String, percentages: Dictionary) -> void:
if not visible:
return
if str(BlockSessionService.get_current_question().get("id", "")) != question_id:
return
BlockSessionService.update_question_choice_percentages(question_id, percentages)
_apply_saved_choice_state()
func _show_wrong_answer_followup(choice_id: String) -> void:
active_followup_choice_id = choice_id
followup_required = true
followup_answered = false
var index := _choice_index_for_id(choice_id)
if index < 0:
_unlock_after_followup()
return
_build_followup_panel(index, choice_id)
func _build_followup_panel(index: int, choice_id: String) -> void:
_clear_followup_content(index)
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_choices,
"correct_choice_id": str(followup.get("correct_choice_id", "")),
"teaching_point": str(followup.get("teaching_point", "")),
"selected_option_id": "",
"selected_option_text": "",
"was_correct": false,
"resolved": false,
"collapsed": false,
"has_authored_choices": bool(followup.get("has_authored_choices", false))
}
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
_set_scaled_label(prompt, 14, Style.TEXT)
content.add_child(prompt)
var choices: Array = followup_choices
if bool(followup.get("has_authored_choices", false)):
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" % [_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)
UISoundService.set_button_sound(option_button, "select")
_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)
)
content.add_child(option_button)
if not bool(followup.get("has_authored_choices", false)):
var action_row := HBoxContainer.new()
action_row.add_theme_constant_override("separation", 8)
content.add_child(action_row)
var continue_button := Button.new()
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)
panel.visible = true
func _answer_followup(option_id: String, selected_button: Button, followup: Dictionary) -> void:
if not followup_required or followup_answered:
return
var correct_id := str(followup.get("correct_choice_id", ""))
var is_correct := option_id == correct_id
_resolve_followup(followup, selected_button, true, is_correct)
func _resolve_followup(followup: Dictionary, selected_button: Button, style_selected: bool, is_correct: bool) -> void:
if not followup_required or followup_answered:
return
followup_answered = true
var resolved_choice_id := active_followup_choice_id
var selected_option_text := selected_button.text if selected_button != null else "Continued"
var index := _choice_index_for_id(active_followup_choice_id)
if index >= 0:
for child in choice_followup_contents[index].get_children():
if child is Button:
child.disabled = true
elif child is HBoxContainer:
for row_child in child.get_children():
if row_child is Button:
row_child.disabled = true
if selected_button != null and style_selected:
_style_followup_result_button(selected_button, is_correct)
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
_set_scaled_label(teaching, 14, Style.AMBER)
choice_followup_contents[index].add_child(teaching)
if followup_states.has(resolved_choice_id):
var state: Dictionary = followup_states[resolved_choice_id]
state["selected_option_id"] = str(selected_button.get_meta("option_id", "")) if selected_button != null and selected_button.has_meta("option_id") else ""
state["selected_option_text"] = selected_option_text
state["was_correct"] = is_correct
state["resolved"] = true
state["collapsed"] = false
followup_states[resolved_choice_id] = state
_unlock_after_followup()
func _style_followup_result_button(button: Button, is_correct: bool) -> void:
var fill := Color("1f7a3a") if is_correct else Color("8f1d2c")
var border := Style.LIME if is_correct else Style.RED
button.add_theme_stylebox_override("normal", Style.button_box(fill, border))
button.add_theme_stylebox_override("hover", Style.button_box(fill, border))
button.add_theme_stylebox_override("disabled", Style.button_box(fill, border))
button.add_theme_color_override("font_color", Style.TEXT)
button.add_theme_color_override("font_hover_color", Style.TEXT)
button.add_theme_color_override("font_disabled_color", Style.TEXT)
func _unlock_after_followup() -> void:
active_followup_choice_id = ""
followup_required = false
next_button.disabled = false
if skip_button != null:
skip_button.disabled = false
_apply_saved_choice_state()
func _collapse_resolved_followups() -> void:
for choice_id in followup_states.keys():
var state: Dictionary = followup_states[choice_id]
if bool(state.get("resolved", false)):
state["collapsed"] = true
followup_states[choice_id] = state
_render_followup_state(choice_id)
_apply_saved_choice_state()
func _toggle_followup_state(choice_id: String) -> void:
if not followup_states.has(choice_id):
return
var state: Dictionary = followup_states[choice_id]
state["collapsed"] = not bool(state.get("collapsed", false))
followup_states[choice_id] = state
_render_followup_state(choice_id)
_apply_saved_choice_state()
func _render_followup_state(choice_id: String) -> void:
if not followup_states.has(choice_id):
return
var index := _choice_index_for_id(choice_id)
if index < 0 or index >= choice_followup_panels.size():
return
var state: Dictionary = followup_states[choice_id]
_clear_followup_content(index)
if bool(state.get("collapsed", false)):
_build_collapsed_followup(index, state)
else:
_build_readonly_followup(index, state)
choice_followup_panels[index].visible = true
func _build_collapsed_followup(index: int, state: Dictionary) -> void:
var content := choice_followup_contents[index]
var summary := Button.new()
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)
UISoundService.set_button_sound(summary, "open")
_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)
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" % [_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)
UISoundService.set_button_sound(summary, "close")
_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)
var prompt := Label.new()
prompt.text = str(state.get("prompt", ""))
prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_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
_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
_set_scaled_label(teaching, 14, Style.AMBER)
content.add_child(teaching)
func _clear_followups() -> void:
active_followup_choice_id = ""
followup_required = false
followup_answered = false
followup_states.clear()
for index in range(choice_followup_panels.size()):
_clear_followup_content(index)
choice_followup_panels[index].visible = false
func _clear_followup_content(index: int) -> void:
if index < 0 or index >= choice_followup_contents.size():
return
for child in choice_followup_contents[index].get_children():
choice_followup_contents[index].remove_child(child)
child.queue_free()
func _get_followup_for_choice(choice_id: String) -> Dictionary:
var question := BlockSessionService.get_current_question()
var followups: Dictionary = question.get("wrong_answer_followups", {})
if followups.has(choice_id) and typeof(followups[choice_id]) == TYPE_DICTIONARY:
var authored: Dictionary = followups[choice_id]
var choices: Array = authored.get("choices", [])
return {
"prompt": str(authored.get("prompt", "What finding in the stem makes this answer less appropriate?")),
"choices": choices,
"has_authored_choices": not choices.is_empty(),
"correct_choice_id": str(authored.get("correctChoiceId", authored.get("correct_choice_id", ""))),
"teaching_point": str(authored.get("teachingPoint", authored.get("teaching_point", "Review the relevant stem finding before choosing again.")))
}
return {
"prompt": "No follow-up has been authored for this answer yet. Re-read the stem before retrying.",
"choices": [],
"has_authored_choices": false,
"correct_choice_id": "",
"teaching_point": "Review the stem for the finding that makes this answer less appropriate before trying again."
}
func _get_presented_choices() -> Array:
if not presented_choices.is_empty():
return presented_choices
return BlockSessionService.get_current_question().get("choices", [])
func _presented_choice_ids() -> Array:
var ids: Array = []
for choice in _get_presented_choices():
if typeof(choice) == TYPE_DICTIONARY:
ids.append(str(choice.get("id", "")))
return ids
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 choices := _get_presented_choices()
for index in range(choices.size()):
if str(choices[index].get("id", "")) == choice_id:
return index
return -1
func _choice_text_for_id(choice_id: String) -> String:
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", ""))
return ""
func _on_question_rating_updated(question_id: String, question_elo: int, _rating: Dictionary) -> void:
if str(BlockSessionService.get_current_question().get("id", "")) != question_id:
return
BlockSessionService.update_question_elo(question_id, question_elo)
_update_question_elo_label(not current_answer.is_empty() or pending_correct_advance)
_apply_saved_choice_state()
func _on_question_skipped(question_id: String, correct_choice_id: String, question_elo: int, _rating: Dictionary) -> void:
if str(BlockSessionService.get_current_question().get("id", "")) != question_id:
return
var question := BlockSessionService.get_current_question()
if correct_choice_id != "":
question["correct_choice_id"] = correct_choice_id
BlockSessionService.update_question_elo(question_id, question_elo)
_update_question_elo_label(true)
_apply_saved_choice_state()
func _update_question_elo_label(show_label: bool) -> void:
if question_elo_label == null:
return
if not show_label:
question_elo_label.visible = false
return
var question := BlockSessionService.get_current_question()
var question_elo := int(question.get("question_elo", 1000))
question_elo_label.text = "Question ELO %d" % question_elo
question_elo_label.visible = true
func _strike_text(value: String) -> String:
var result := ""
for character in value:
result += character + char(0x0336)
return result
func _make_labs_icon() -> Texture2D:
var image := Image.create(24, 24, false, Image.FORMAT_RGBA8)
image.fill(Color(0, 0, 0, 0))
var glass_color := Style.TEXT
var fluid_color := Style.LIME
for x in range(8, 16):
image.set_pixel(x, 4, glass_color)
for y in range(5, 10):
image.set_pixel(10, y, glass_color)
image.set_pixel(13, y, glass_color)
for y in range(10, 20):
var left := 10 - int(floor(float(y - 10) / 3.0))
var right := 13 + int(floor(float(y - 10) / 3.0))
image.set_pixel(left, y, glass_color)
image.set_pixel(right, y, glass_color)
for x in range(7, 17):
image.set_pixel(x, 20, glass_color)
for y in range(15, 20):
for x in range(8, 16):
if x >= 10 - int(floor(float(y - 10) / 3.0)) and x <= 13 + int(floor(float(y - 10) / 3.0)):
image.set_pixel(x, y, fluid_color)
image.set_pixel(7, 7, Style.CYAN)
image.set_pixel(17, 9, Style.CYAN)
image.set_pixel(18, 6, Style.CYAN)
return ImageTexture.create_from_image(image)
func _make_flag_icon() -> Texture2D:
var image := Image.create(24, 24, false, Image.FORMAT_RGBA8)
image.fill(Color(0, 0, 0, 0))
for y in range(4, 20):
image.set_pixel(6, y, Style.TEXT)
image.set_pixel(7, y, Style.TEXT)
for x in range(8, 18):
for y in range(4, 11):
if x < 16 or y < 9:
image.set_pixel(x, y, Style.AMBER)
for y in range(11, 14):
for x in range(8, 14 - (y - 11)):
image.set_pixel(x, y, Style.AMBER)
return ImageTexture.create_from_image(image)
func _make_clear_highlights_icon() -> Texture2D:
var image := Image.create(24, 24, false, Image.FORMAT_RGBA8)
image.fill(Color(0, 0, 0, 0))
var handle_color := Style.TEXT
var bristle_color := Style.AMBER
for offset in range(3):
_draw_icon_line(image, Vector2i(6 + offset, 16), Vector2i(16 + offset, 6), handle_color)
for x in range(13, 20):
for y in range(15, 19):
if x + y < 36:
image.set_pixel(x, y, bristle_color)
for x in range(12, 20):
image.set_pixel(x, 19, bristle_color)
return ImageTexture.create_from_image(image)
func _make_report_icon() -> Texture2D:
var image := Image.create(24, 24, false, Image.FORMAT_RGBA8)
image.fill(Color(0, 0, 0, 0))
var body_color := Style.TEXT
var accent_color := Style.RED
for x in range(5, 14):
for y in range(9, 15):
image.set_pixel(x, y, body_color)
for x in range(14, 20):
image.set_pixel(x, 7, accent_color)
image.set_pixel(x, 16, accent_color)
for y in range(8, 16):
image.set_pixel(20, y, accent_color)
for y in range(10, 14):
image.set_pixel(4, y, body_color)
for y in range(15, 21):
image.set_pixel(8, y, body_color)
if y < 20:
image.set_pixel(9, y, body_color)
_draw_icon_line(image, Vector2i(2, 5), Vector2i(5, 8), accent_color)
_draw_icon_line(image, Vector2i(2, 19), Vector2i(5, 16), accent_color)
return ImageTexture.create_from_image(image)
func _draw_icon_line(image: Image, from: Vector2i, to: Vector2i, color: Color) -> void:
var dx := int(abs(to.x - from.x))
var sx := 1 if from.x < to.x else -1
var dy := -int(abs(to.y - from.y))
var sy := 1 if from.y < to.y else -1
var err: int = dx + dy
var point := from
while true:
if point.x >= 0 and point.x < image.get_width() and point.y >= 0 and point.y < image.get_height():
image.set_pixel(point.x, point.y, color)
if point == to:
break
var e2: int = 2 * err
if e2 >= dy:
err += dy
point.x += sx
if e2 <= dx:
err += dx
point.y += sy
func _refresh_nav_palette() -> void:
for child in nav_palette.get_children():
child.queue_free()
if BlockSessionService.session_kind == "core":
return
for index in range(BlockSessionService.active_questions.size()):
var button := Button.new()
button.text = str(index + 1)
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)