Add core game retry flow and question stats

This commit is contained in:
hz
2026-07-10 14:05:57 -05:00
parent 1822c00a3b
commit e991dccbb2
17 changed files with 1036 additions and 80 deletions
+330 -45
View File
@@ -1,17 +1,22 @@
extends VBoxContainer
const Style := preload("res://scripts/ui/ui_style.gd")
const FEEDBACK_FADE_SECONDS := 0.45
const CORRECT_ADVANCE_DELAY_SECONDS := 0.6
var main: Control
var choice_buttons: Array[Button] = []
var eliminated: Array = []
var locked_wrong_choice_ids: Array = []
var flagged := false
var selected_choice := ""
var current_answer := {}
var question_highlights := {}
var reported_question_ids := []
var stem_plain_text := ""
var highlight_mode_enabled := false
var highlight_drag_started_in_stem := false
var timer_color_state := ""
var pending_correct_advance := false
var progress_label: Label
var timer_label: Label
@@ -19,10 +24,17 @@ var stem_label: RichTextLabel
var lead_label: Label
var explanation_label: RichTextLabel
var next_button: Button
var prev_button: Button
var flag_button: Button
var report_button: Button
var nav_palette: HBoxContainer
var question_content: VBoxContainer
var timer_bar: ProgressBar
var feedback_fog: ColorRect
var red_fog_time := 0.0
var green_fog_time := 0.0
var correct_advance_delay := 0.0
var shake_time := 0.0
var shake_origin := Vector2.ZERO
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
@@ -36,31 +48,48 @@ func _ready() -> void:
top.add_child(progress_label)
timer_label = Label.new()
Style.set_label(timer_label, 18, Style.AMBER)
timer_label.visible = false
timer_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
timer_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
top.add_child(timer_label)
flag_button = Button.new()
flag_button.text = "Flag"
flag_button.toggle_mode = true
Style.style_chip(flag_button, Style.AMBER)
flag_button.toggled.connect(func(value: bool): flagged = value)
top.add_child(flag_button)
add_child(top)
var highlight_row := HBoxContainer.new()
highlight_row.add_theme_constant_override("separation", 8)
var highlight_button := Button.new()
highlight_button.text = "Highlight Mode"
highlight_button.toggle_mode = true
highlight_button.toggled.connect(func(enabled: bool):
highlight_mode_enabled = enabled
highlight_drag_started_in_stem = false
)
Style.style_chip(highlight_button, Style.AMBER)
highlight_row.add_child(highlight_button)
var toolbar_spacer := Control.new()
toolbar_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
top.add_child(toolbar_spacer)
var toolbar := HBoxContainer.new()
toolbar.add_theme_constant_override("separation", 8)
toolbar.alignment = BoxContainer.ALIGNMENT_END
var clear_highlight_button := Button.new()
clear_highlight_button.text = "Clear Highlights"
clear_highlight_button.tooltip_text = "Clear highlights"
clear_highlight_button.icon = _make_clear_highlights_icon()
clear_highlight_button.custom_minimum_size = Vector2(44, 44)
Style.style_chip(clear_highlight_button, Style.CYAN)
clear_highlight_button.pressed.connect(_clear_current_highlights)
highlight_row.add_child(clear_highlight_button)
add_child(highlight_row)
toolbar.add_child(clear_highlight_button)
flag_button = Button.new()
flag_button.toggle_mode = true
flag_button.tooltip_text = "Flag question"
flag_button.icon = _make_flag_icon()
flag_button.custom_minimum_size = Vector2(44, 44)
Style.style_chip(flag_button, Style.AMBER)
flag_button.toggled.connect(func(value: bool):
flagged = value
_refresh_toolbar_state()
)
toolbar.add_child(flag_button)
report_button = Button.new()
report_button.tooltip_text = "Report question"
report_button.icon = _make_report_icon()
report_button.custom_minimum_size = Vector2(44, 44)
report_button.pressed.connect(_toggle_report_current_question)
toolbar.add_child(report_button)
top.add_child(toolbar)
add_child(top)
nav_palette = HBoxContainer.new()
nav_palette.add_theme_constant_override("separation", 6)
@@ -96,7 +125,7 @@ func _ready() -> void:
for index in range(5):
var button := Button.new()
button.custom_minimum_size = Vector2(0, 52)
button.custom_minimum_size = Vector2(0, 64)
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
Style.style_answer_button(button)
button.pressed.connect(func(i := index): _select_choice(i))
@@ -114,33 +143,42 @@ func _ready() -> void:
var actions := HBoxContainer.new()
actions.add_theme_constant_override("separation", 10)
prev_button = Button.new()
prev_button.text = "Previous"
prev_button.pressed.connect(func():
if BlockSessionService.move_previous():
_load_question()
)
actions.add_child(prev_button)
var eliminate := Button.new()
eliminate.text = "Eliminate Selected"
eliminate.pressed.connect(_toggle_eliminate_selected)
actions.add_child(eliminate)
next_button = Button.new()
next_button.text = "Submit"
next_button.custom_minimum_size = Vector2(140, 48)
next_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
Style.style_primary_button(next_button)
next_button.pressed.connect(_submit_or_next)
actions.add_child(next_button)
add_child(actions)
timer_bar = ProgressBar.new()
timer_bar.show_percentage = false
timer_bar.min_value = 0.0
timer_bar.max_value = 100.0
timer_bar.value = 100.0
timer_bar.custom_minimum_size = Vector2(0, 14)
timer_bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
timer_bar.add_theme_stylebox_override("background", Style.button_box(Color("232b3d"), Color("303b53")))
timer_bar.add_theme_stylebox_override("fill", Style.button_box(Style.LIME, Style.LIME))
add_child(timer_bar)
feedback_fog = ColorRect.new()
feedback_fog.color = Color(1.0, 0.0, 0.0, 0.0)
feedback_fog.mouse_filter = Control.MOUSE_FILTER_IGNORE
feedback_fog.top_level = true
feedback_fog.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(feedback_fog)
func on_show(_payload: Variant = null) -> void:
_load_question()
func _process(delta: float) -> void:
_update_answer_feedback(delta)
if visible and BlockSessionService.block_started and BlockSessionService.session_kind == "core":
_update_timer_bar()
if visible and BlockSessionService.block_started and BlockSessionService.mode == "Timed":
BlockSessionService.seconds_remaining = max(0.0, BlockSessionService.seconds_remaining - delta)
var shown_seconds := int(ceil(BlockSessionService.seconds_remaining))
@@ -151,7 +189,7 @@ func _process(delta: float) -> void:
func _input(event: InputEvent) -> void:
if not visible or not highlight_mode_enabled or stem_label == null:
if not visible or pending_correct_advance or stem_label == null:
return
var mouse_event := event as InputEventMouseButton
@@ -166,17 +204,27 @@ func _input(event: InputEvent) -> void:
func _load_question() -> void:
_reset_wrong_feedback()
eliminated.clear()
locked_wrong_choice_ids.clear()
flagged = false
selected_choice = ""
current_answer = {}
flag_button.button_pressed = false
_refresh_toolbar_state()
explanation_label.visible = false
next_button.text = "Submit"
var question := BlockSessionService.get_current_question()
progress_label.text = "Question %s" % BlockSessionService.get_progress_text()
timer_label.text = "Tutor Mode" if BlockSessionService.mode == "Tutor" else "Timed"
if question.is_empty():
main.show_screen("dashboard")
return
progress_label.visible = BlockSessionService.session_kind != "core"
progress_label.text = "" if BlockSessionService.session_kind == "core" else "Question %s" % BlockSessionService.get_progress_text()
timer_label.visible = BlockSessionService.session_kind != "core"
timer_label.text = "" if BlockSessionService.session_kind == "core" else "Tutor Mode" if BlockSessionService.mode == "Tutor" else "Timed"
timer_bar.visible = BlockSessionService.session_kind == "core"
_update_timer_bar()
stem_plain_text = str(question.get("stem", ""))
_render_stem()
lead_label.text = question.get("lead_in", "")
@@ -198,6 +246,7 @@ func _load_question() -> void:
eliminated = saved_answer.get("eliminated_choice_ids", []).duplicate()
flagged = bool(saved_answer.get("flagged", false))
flag_button.button_pressed = flagged
_refresh_toolbar_state()
if BlockSessionService.mode == "Tutor":
_show_explanation()
next_button.text = "Next"
@@ -205,15 +254,19 @@ func _load_question() -> void:
_apply_saved_choice_state()
next_button.text = "Next"
prev_button.disabled = BlockSessionService.current_index == 0
_refresh_nav_palette()
_refresh_toolbar_state()
func _select_choice(index: int) -> void:
if pending_correct_advance:
return
var question := BlockSessionService.get_current_question()
var choices: Array = question.get("choices", [])
if index >= choices.size() or (BlockSessionService.mode == "Tutor" and not current_answer.is_empty()):
return
if locked_wrong_choice_ids.has(str(choices[index].get("id", ""))):
return
selected_choice = str(choices[index].get("id", ""))
if BlockSessionService.mode == "Timed":
current_answer = {}
@@ -222,6 +275,8 @@ func _select_choice(index: int) -> void:
func _choice_gui_input(event: InputEvent, index: int) -> void:
if pending_correct_advance:
return
var mouse_event := event as InputEventMouseButton
if mouse_event == null or mouse_event.button_index != MOUSE_BUTTON_RIGHT or not mouse_event.pressed:
return
@@ -235,6 +290,8 @@ func _choice_gui_input(event: InputEvent, index: int) -> void:
var choice: Dictionary = choices[index]
var choice_id := str(choice.get("id", ""))
if locked_wrong_choice_ids.has(choice_id):
return
if eliminated.has(choice_id):
eliminated.erase(choice_id)
else:
@@ -244,7 +301,7 @@ func _choice_gui_input(event: InputEvent, index: int) -> void:
func _toggle_eliminate_selected() -> void:
if selected_choice == "" or not current_answer.is_empty():
if pending_correct_advance or selected_choice == "" or not current_answer.is_empty():
return
var question := BlockSessionService.get_current_question()
var choices: Array = question.get("choices", [])
@@ -259,9 +316,27 @@ func _toggle_eliminate_selected() -> void:
func _submit_or_next() -> void:
if pending_correct_advance:
return
if current_answer.is_empty():
if selected_choice == "":
return
if BlockSessionService.session_kind == "core":
var result := BlockSessionService.submit_core_attempt(selected_choice, eliminated, flagged)
if result.is_empty():
return
var answer: Dictionary = result.get("answer", {})
if bool(result.get("is_correct", false)):
current_answer = answer
_trigger_correct_feedback()
return
_lock_wrong_choice(selected_choice)
selected_choice = ""
current_answer = {}
next_button.text = "Submit"
_trigger_wrong_feedback()
_apply_saved_choice_state()
return
current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged)
if BlockSessionService.mode == "Tutor":
_show_explanation()
@@ -300,12 +375,106 @@ func _advance_or_finish() -> void:
func _finish() -> void:
if BlockSessionService.session_kind == "core":
BlockSessionService.finish_block()
main.show_screen("dashboard")
return
var results := BlockSessionService.finish_block()
main.show_screen("review", results)
func _exit_run() -> void:
BlockSessionService.resume_current_question_timer()
BlockSessionService.finish_block()
main.show_screen("dashboard")
func _update_timer_bar() -> void:
if timer_bar == null:
return
var ratio := BlockSessionService.get_core_timer_ratio()
timer_bar.value = ratio * 100.0
var color_state := "green"
var fill_color := Style.LIME
if ratio < 0.25:
color_state = "red"
fill_color = Style.RED
elif ratio <= 0.5:
color_state = "amber"
fill_color = Style.AMBER
if color_state != timer_color_state:
timer_color_state = color_state
timer_bar.add_theme_stylebox_override("fill", Style.button_box(fill_color, fill_color))
func _lock_wrong_choice(choice_id: String) -> void:
if choice_id == "":
return
if not locked_wrong_choice_ids.has(choice_id):
locked_wrong_choice_ids.append(choice_id)
func _trigger_wrong_feedback() -> void:
shake_time = 0.28
shake_origin = position
green_fog_time = 0.0
red_fog_time = FEEDBACK_FADE_SECONDS
if feedback_fog != null:
feedback_fog.color = Color(1.0, 0.0, 0.0, 0.26)
func _trigger_correct_feedback() -> void:
pending_correct_advance = true
correct_advance_delay = CORRECT_ADVANCE_DELAY_SECONDS
red_fog_time = 0.0
green_fog_time = FEEDBACK_FADE_SECONDS
next_button.disabled = true
for button in choice_buttons:
button.disabled = true
if feedback_fog != null:
feedback_fog.color = Color(0.25, 1.0, 0.25, 0.23)
func _update_answer_feedback(delta: float) -> void:
if shake_time > 0.0:
shake_time = max(0.0, shake_time - delta)
if shake_time > 0.0:
position = shake_origin + Vector2(randf_range(-9.0, 9.0), randf_range(-4.0, 4.0))
else:
position = shake_origin
if red_fog_time > 0.0 and feedback_fog != null:
red_fog_time = max(0.0, red_fog_time - delta)
var red_alpha := 0.26 * (red_fog_time / FEEDBACK_FADE_SECONDS)
feedback_fog.color = Color(1.0, 0.0, 0.0, red_alpha)
elif green_fog_time > 0.0 and feedback_fog != null:
green_fog_time = max(0.0, green_fog_time - delta)
var green_alpha := 0.23 * (green_fog_time / FEEDBACK_FADE_SECONDS)
feedback_fog.color = Color(0.25, 1.0, 0.25, green_alpha)
if pending_correct_advance:
correct_advance_delay = max(0.0, correct_advance_delay - delta)
if correct_advance_delay <= 0.0:
pending_correct_advance = false
next_button.disabled = false
_advance_or_finish()
func _reset_wrong_feedback() -> void:
if shake_time > 0.0:
position = shake_origin
shake_time = 0.0
red_fog_time = 0.0
green_fog_time = 0.0
correct_advance_delay = 0.0
pending_correct_advance = false
next_button.disabled = false
if feedback_fog != null:
feedback_fog.color = Color(1.0, 0.0, 0.0, 0.0)
func _commit_current_text_selection() -> void:
if not highlight_mode_enabled or stem_label == null:
if stem_label == null:
return
var raw_selected_text := stem_label.get_selected_text()
var selected_text := raw_selected_text.strip_edges()
@@ -347,6 +516,25 @@ func _clear_current_highlights() -> void:
_render_stem()
func _toggle_report_current_question() -> void:
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
if question_id == "":
return
if reported_question_ids.has(question_id):
reported_question_ids.erase(question_id)
else:
reported_question_ids.append(question_id)
_refresh_toolbar_state()
func _refresh_toolbar_state() -> void:
if flag_button != null:
Style.style_chip(flag_button, Style.AMBER if flagged else Style.CYAN)
if report_button != null:
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
Style.style_chip(report_button, Style.RED if reported_question_ids.has(question_id) else Style.CYAN)
func _render_stem() -> void:
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
var ranges: Array = question_highlights.get(question_id, []).duplicate()
@@ -393,7 +581,10 @@ func _apply_saved_choice_state() -> void:
for index in range(choices.size()):
var id := str(choices[index].get("id", ""))
_set_choice_button_text(index, choices[index])
if eliminated.has(id):
choice_buttons[index].disabled = locked_wrong_choice_ids.has(id)
if locked_wrong_choice_ids.has(id):
choice_buttons[index].modulate = Color(1.0, 0.25, 0.25)
elif eliminated.has(id):
choice_buttons[index].modulate = Color(0.45, 0.45, 0.45)
elif id == selected_choice:
choice_buttons[index].modulate = Color(0.68, 0.92, 1.0)
@@ -404,12 +595,26 @@ func _apply_saved_choice_state() -> void:
func _set_choice_button_text(index: int, choice: Dictionary) -> void:
var choice_id := str(choice.get("id", ""))
var choice_text := str(choice.get("text", ""))
if eliminated.has(choice_id):
if locked_wrong_choice_ids.has(choice_id):
var text := "%s. [wrong] %s" % [choice_id, _strike_text(choice_text)]
var percentage := _get_choice_selection_percentage(choice_id)
if percentage >= 0:
text += "\n %d%% chose this on first attempt" % percentage
choice_buttons[index].text = text
elif eliminated.has(choice_id):
choice_buttons[index].text = "%s. [eliminated] %s" % [choice_id, _strike_text(choice_text)]
else:
choice_buttons[index].text = "%s. %s" % [choice_id, choice_text]
func _get_choice_selection_percentage(choice_id: String) -> int:
var question := BlockSessionService.get_current_question()
var percentages: Dictionary = question.get("choice_selection_percentages", {})
if not percentages.has(choice_id):
return -1
return clamp(int(percentages.get(choice_id, -1)), 0, 100)
func _strike_text(value: String) -> String:
var result := ""
for character in value:
@@ -417,10 +622,90 @@ func _strike_text(value: String) -> String:
return result
func _make_flag_icon() -> Texture2D:
var image := Image.create(24, 24, false, Image.FORMAT_RGBA8)
image.fill(Color(0, 0, 0, 0))
for y in range(4, 20):
image.set_pixel(6, y, Style.TEXT)
image.set_pixel(7, y, Style.TEXT)
for x in range(8, 18):
for y in range(4, 11):
if x < 16 or y < 9:
image.set_pixel(x, y, Style.AMBER)
for y in range(11, 14):
for x in range(8, 14 - (y - 11)):
image.set_pixel(x, y, Style.AMBER)
return ImageTexture.create_from_image(image)
func _make_clear_highlights_icon() -> Texture2D:
var image := Image.create(24, 24, false, Image.FORMAT_RGBA8)
image.fill(Color(0, 0, 0, 0))
var handle_color := Style.TEXT
var bristle_color := Style.AMBER
for offset in range(3):
_draw_icon_line(image, Vector2i(6 + offset, 16), Vector2i(16 + offset, 6), handle_color)
for x in range(13, 20):
for y in range(15, 19):
if x + y < 36:
image.set_pixel(x, y, bristle_color)
for x in range(12, 20):
image.set_pixel(x, 19, bristle_color)
return ImageTexture.create_from_image(image)
func _make_report_icon() -> Texture2D:
var image := Image.create(24, 24, false, Image.FORMAT_RGBA8)
image.fill(Color(0, 0, 0, 0))
var body_color := Style.TEXT
var accent_color := Style.RED
for x in range(5, 14):
for y in range(9, 15):
image.set_pixel(x, y, body_color)
for x in range(14, 20):
image.set_pixel(x, 7, accent_color)
image.set_pixel(x, 16, accent_color)
for y in range(8, 16):
image.set_pixel(20, y, accent_color)
for y in range(10, 14):
image.set_pixel(4, y, body_color)
for y in range(15, 21):
image.set_pixel(8, y, body_color)
if y < 20:
image.set_pixel(9, y, body_color)
_draw_icon_line(image, Vector2i(2, 5), Vector2i(5, 8), accent_color)
_draw_icon_line(image, Vector2i(2, 19), Vector2i(5, 16), accent_color)
return ImageTexture.create_from_image(image)
func _draw_icon_line(image: Image, from: Vector2i, to: Vector2i, color: Color) -> void:
var dx := int(abs(to.x - from.x))
var sx := 1 if from.x < to.x else -1
var dy := -int(abs(to.y - from.y))
var sy := 1 if from.y < to.y else -1
var err: int = dx + dy
var point := from
while true:
if point.x >= 0 and point.x < image.get_width() and point.y >= 0 and point.y < image.get_height():
image.set_pixel(point.x, point.y, color)
if point == to:
break
var e2: int = 2 * err
if e2 >= dy:
err += dy
point.x += sx
if e2 <= dx:
err += dx
point.y += sy
func _refresh_nav_palette() -> void:
for child in nav_palette.get_children():
child.queue_free()
if BlockSessionService.session_kind == "core":
return
for index in range(BlockSessionService.active_questions.size()):
var button := Button.new()
button.text = str(index + 1)