Merge pull request 'Main' (#16) from main into production

Reviewed-on: admin/step3#16
This commit is contained in:
hz
2026-07-30 15:04:21 -05:00
47 changed files with 2421 additions and 38 deletions
+9
View File
@@ -1,5 +1,6 @@
extends PanelContainer
signal store_requested
const Brand := preload("res://scripts/ui/medmind_style.gd")
const SHIELD_ICON := preload("res://assets/icons/lucide/shield.svg")
const STAR_ICON := preload("res://assets/icons/lucide/star.svg")
@@ -21,6 +22,9 @@ var _initialized := false
func _ready() -> void:
size_flags_vertical = Control.SIZE_SHRINK_CENTER
mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
tooltip_text = "Open Rz Coin Store"
gui_input.connect(_on_gui_input)
_build_ui()
refresh_theme_controls()
_on_credits_changed()
@@ -30,6 +34,11 @@ func _ready() -> void:
resized.connect(func(): pivot_offset = size * 0.5)
func _on_gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
store_requested.emit()
func _build_ui() -> void:
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 7)
+8
View File
@@ -207,6 +207,7 @@ func _build_ui() -> void:
header.add_child(account_chevron_button)
credits_badge = CreditsBadge.new()
credits_badge.store_requested.connect(func(): show_screen("rz_store"))
header.add_child(credits_badge)
var screens_container := MarginContainer.new()
@@ -355,6 +356,11 @@ func _build_ui() -> void:
donations.main = self
screens_container.add_child(donations)
screens["donations"] = donations
var rz_store := preload("res://scripts/ui/rz_store_screen.gd").new()
rz_store.name = "RzCoinStore"
rz_store.main = self
screens_container.add_child(rz_store)
screens["rz_store"] = rz_store
feedback_dialog = preload("res://scripts/ui/app_feedback_dialog.gd").new()
feedback_dialog.name = "AppFeedbackDialog"
@@ -433,6 +439,7 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void
container.add_child(HSeparator.new())
_add_nav_button(container, "donations", "Support RanaZe", "heart-pulse", true)
_add_nav_button(container, "performance", "Profile", "user-round", true)
_add_nav_button(container, "rz_store", "RZ Shop", "star", true)
_add_nav_button(container, "settings", "Settings", "settings", true)
_add_nav_button(container, "logout", "Log out", "log-out", true)
@@ -476,6 +483,7 @@ func _on_navigation_pressed(key: String) -> void:
"notes": show_screen("notes")
"bookmarks": show_screen("bookmarks")
"donations": show_screen("donations")
"rz_store": show_screen("rz_store")
"logout": _logout()
+2
View File
@@ -29,4 +29,6 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri
key = "bookmarks"
elif screen_name == "donations":
key = "donations"
elif screen_name == "rz_store":
key = "rz_store"
return key
+141 -6
View File
@@ -4,6 +4,7 @@ const Style := preload("res://scripts/ui/ui_style.gd")
const Brand := preload("res://scripts/ui/medmind_style.gd")
const FlashcardPrefillDialog := preload("res://scripts/ui/flashcard_prefill_dialog.gd")
const CalculatorPanel := preload("res://scripts/ui/calculator_panel.gd")
const SlotMachinePanel := preload("res://scripts/ui/rz_slot_machine_screen.gd")
const CreditsBadge := preload("res://scripts/ui/credits_badge.gd")
const CLEAR_HIGHLIGHT_DARK_ICON := preload("res://assets/icons/medmind/clear-highlight-dark.svg")
const CLEAR_HIGHLIGHT_LIGHT_ICON := preload("res://assets/icons/medmind/clear-highlight-light.svg")
@@ -60,6 +61,8 @@ var stem_plain_text := ""
var explanation_plain_text := ""
var highlight_drag_source := ""
var timer_color_state := ""
var last_timer_bar_value := -1.0
var last_timer_display_seconds := -1
var pending_correct_advance := false
var question_text_scale := 1.0
var presented_choices: Array = []
@@ -92,6 +95,7 @@ var text_smaller_button: Button
var text_larger_button: Button
var labs_button: Button
var calculator_button: Button
var slot_machine_button: Button
var exit_button: Button
var clear_highlight_button: Button
var flag_button: Button
@@ -125,6 +129,7 @@ var labs_overlay: Control
var labs_panel: PanelContainer
var labs_content: VBoxContainer
var calculator_panel
var slot_machine_panel
var report_overlay: Control
var report_panel: PanelContainer
var report_form_content: VBoxContainer
@@ -345,6 +350,18 @@ func _ready() -> void:
UISoundService.set_button_sound(calculator_button, "open")
calculator_button.pressed.connect(_toggle_calculator)
slot_machine_button = Button.new()
slot_machine_button.name = "SlotMachineButton"
slot_machine_button.tooltip_text = "Open RZ Slot Machine"
slot_machine_button.icon = preload("res://assets/icons/lucide/gem.svg")
slot_machine_button.custom_minimum_size = Vector2(44, 44)
slot_machine_button.add_theme_constant_override("icon_max_width", 22)
slot_machine_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
slot_machine_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
slot_machine_button.focus_mode = Control.FOCUS_NONE
UISoundService.set_button_sound(slot_machine_button, "open")
slot_machine_button.pressed.connect(_toggle_slot_machine)
labs_button = Button.new()
labs_button.tooltip_text = "Review lab results"
labs_button.custom_minimum_size = Vector2(44, 44)
@@ -430,6 +447,7 @@ func _ready() -> void:
question_toolbar.add_child(text_smaller_button)
question_toolbar.add_child(text_larger_button)
question_toolbar.add_child(calculator_button)
question_toolbar.add_child(slot_machine_button)
question_toolbar.add_child(labs_button)
question_toolbar.add_child(clear_highlight_button)
question_toolbar.add_child(flag_button)
@@ -723,6 +741,9 @@ func _ready() -> void:
_build_labs_modal()
calculator_panel = CalculatorPanel.new()
add_child(calculator_panel)
slot_machine_panel = SlotMachinePanel.new()
slot_machine_panel.main = main
add_child(slot_machine_panel)
_build_report_modal()
_build_media_viewer()
resized.connect(_fit_modal_panels)
@@ -808,10 +829,10 @@ func _refresh_responsive_layout() -> void:
header_toolbar_host.reparent(target_parent)
header_toolbar_host.size_flags_horizontal = Control.SIZE_EXPAND_FILL if compact else Control.SIZE_SHRINK_END
question_toolbar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
# 12 bare 44px icon buttons + the wider 92px Feedback button + the ~194px
# rank/ELO + points badge, with 8px gaps between all 14 - anything less
# 13 bare 44px icon buttons + the wider 92px Feedback button + the ~194px
# rank/ELO + points badge, with 8px gaps between all 15 - anything less
# than this wraps onto a second row.
header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 960.0
header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 1020.0
if size.x < 560.0:
nav_palette.columns = 5
else:
@@ -1067,7 +1088,7 @@ func _apply_question_theme() -> void:
var navigator_heading := navigator_popup.find_child("NavigatorHeading", true, false) as Label
if navigator_heading != null:
navigator_heading.add_theme_color_override("font_color", Brand.text_color(dark))
for control in [exit_button, text_smaller_button, text_larger_button, calculator_button, bookmark_button, notes_button, flashcard_button, app_feedback_button]:
for control in [exit_button, text_smaller_button, text_larger_button, calculator_button, slot_machine_button, bookmark_button, notes_button, flashcard_button, app_feedback_button]:
if control == null:
continue
var active: bool = control == bookmark_button and BookmarkService.is_bookmarked(str(BlockSessionService.get_current_question().get("id", "")))
@@ -1083,6 +1104,8 @@ func _apply_question_theme() -> void:
_style_theme_toggle_button(dark)
if calculator_panel != null:
calculator_panel.refresh_theme_controls()
if slot_machine_panel != null:
slot_machine_panel.refresh_theme_controls()
if credits_badge != null:
credits_badge.refresh_theme_controls()
if skip_button != null:
@@ -1133,6 +1156,8 @@ func on_hide() -> void:
if calculator_panel != null:
calculator_panel.close_panel()
calculator_panel.reset_calculation()
if slot_machine_panel != null:
slot_machine_panel.close_panel()
func _save_simulation_current_response() -> void:
@@ -1168,6 +1193,11 @@ func _on_simulation_request_failed(message: String) -> void:
if BlockSessionService.session_kind != "simulation":
return
simulation_submit_pending = false
if ExamSimulationService.last_failed_action != "submit":
# A per-question autosave failing shouldn't interrupt a timed exam with a
# blocking "Retry" - the next answer/navigation naturally retries it, and
# the final submit sends the complete, authoritative state regardless.
return
next_button.disabled = false
next_button.text = "Retry"
next_button.tooltip_text = message
@@ -1268,6 +1298,8 @@ func _load_question() -> void:
flagged = false
selected_choice = ""
current_answer = {}
last_timer_bar_value = -1.0
last_timer_display_seconds = -1
flag_button.button_pressed = false
_refresh_toolbar_state()
_clear_answer_review()
@@ -2094,7 +2126,10 @@ func _update_timer_bar() -> void:
return
var is_core_timer := BlockSessionService.session_kind == "core"
var ratio := BlockSessionService.get_session_timer_ratio()
timer_bar.value = ratio * 100.0
var next_value := ratio * 100.0
if last_timer_bar_value < 0.0 or absf(next_value - last_timer_bar_value) >= 0.1:
last_timer_bar_value = next_value
timer_bar.value = next_value
if is_core_timer and timer_label.visible:
_set_timer_display(int(floor(BlockSessionService.get_core_question_total_elapsed_seconds())))
var color_state := "blue"
@@ -2114,6 +2149,9 @@ func _set_timer_display(total_seconds: int) -> void:
if timer_label == null:
return
var safe_seconds := maxi(0, total_seconds)
if safe_seconds == last_timer_display_seconds:
return
last_timer_display_seconds = safe_seconds
timer_label.text = "%02d:%02d" % [int(floor(float(safe_seconds) / 60.0)), safe_seconds % 60]
@@ -2373,6 +2411,10 @@ func _refresh_toolbar_state() -> void:
bookmark_button.tooltip_text = ("Saved; waiting to sync" if bookmark_pending else "Remove bookmark") if bookmark_value else ("Removal waiting to sync" if bookmark_pending else "Save question")
if calculator_button != null:
calculator_button.visible = true
if slot_machine_button != null:
slot_machine_button.visible = Brand.is_uat_environment()
if credits_badge != null:
credits_badge.visible = BlockSessionService.session_kind != "simulation"
_apply_question_theme()
@@ -2381,6 +2423,11 @@ func _toggle_calculator() -> void:
calculator_panel.toggle_panel()
func _toggle_slot_machine() -> void:
if slot_machine_panel != null:
slot_machine_panel.toggle_panel()
func _toggle_bookmark_current_question() -> void:
var question := BlockSessionService.get_current_question()
if not question.is_empty():
@@ -3383,7 +3430,9 @@ func _style_choice_button_for_state(index: int, choice_id: String) -> void:
var dark: bool = true if theme_service == null else theme_service.is_dark_mode()
var state := "default"
var correct_id := str(BlockSessionService.get_current_question().get("correct_choice_id", ""))
var reviewing := pending_correct_advance or not current_answer.is_empty()
# Exam simulations don't reveal right/wrong - you move on after submitting,
# same as a real exam - so only "selected"/"eliminated" styling applies here.
var reviewing := BlockSessionService.session_kind != "simulation" and (pending_correct_advance or not current_answer.is_empty())
if reviewing and choice_id == correct_id:
state = "correct"
elif locked_wrong_choice_ids.has(choice_id) or (reviewing and choice_id != "" and choice_id != correct_id):
@@ -3505,6 +3554,7 @@ func _build_followup_panel(_index: int, choice_id: String) -> void:
"selected_option_text": "",
"was_correct": false,
"resolved": false,
"collapsed": false,
"has_authored_choices": bool(followup.get("has_authored_choices", false)),
"is_main_correct": false
}
@@ -3576,6 +3626,7 @@ func _resolve_followup(_followup: Dictionary, selected_button: Button, _style_se
state["selected_option_text"] = selected_option_text
state["was_correct"] = is_correct
state["resolved"] = true
state["collapsed"] = false
followup_states[resolved_choice_id] = state
_render_followup_state(resolved_choice_id)
@@ -3602,6 +3653,8 @@ func _render_followup_state(choice_id: String) -> void:
_style_followup_card(index, state)
if not bool(state.get("resolved", false)):
_render_active_followup(index, state)
elif bool(state.get("collapsed", false)):
_build_resolved_followup_toggle(index, state, false)
else:
_build_resolved_followup(index, state)
choice_followup_panels[index].visible = true
@@ -3610,13 +3663,95 @@ func _render_followup_state(choice_id: String) -> void:
func _build_resolved_followup(index: int, state: Dictionary) -> void:
var content := choice_followup_contents[index]
_build_resolved_followup_toggle(index, state, true)
var prompt := Label.new()
prompt.name = "FollowupPrompt"
prompt.text = str(state.get("prompt", "What finding in the stem makes this answer less appropriate?"))
prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_set_scaled_label(prompt, 14, Brand.text_color(_followup_is_dark()))
content.add_child(prompt)
var selected_id := str(state.get("selected_option_id", ""))
var correct_id := str(state.get("correct_choice_id", ""))
var options: Array = state.get("choices", [])
for option_index in range(options.size()):
var option = options[option_index]
if typeof(option) != TYPE_DICTIONARY:
continue
var option_id := str(option.get("id", ""))
var option_button := Button.new()
option_button.name = "FollowupOption_%s" % option_id
option_button.text = "%s. %s" % [_display_label_for_index(option_index), str(option.get("text", ""))]
option_button.alignment = HORIZONTAL_ALIGNMENT_LEFT
option_button.disabled = true
option_button.custom_minimum_size = Vector2(0, 38)
_set_scaled_button_font(option_button, 14)
_style_review_followup_option(
option_button,
_followup_is_dark(),
option_id,
selected_id,
correct_id,
true
)
content.add_child(option_button)
var explanation := Label.new()
explanation.name = "FollowupExplanation"
explanation.text = _strip_verdict_prefix(_choice_review_explanation(str(state.get("choice_id", "")), state))
explanation.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_set_scaled_label(explanation, 14, Brand.text_color(_followup_is_dark()))
content.add_child(explanation)
func _build_resolved_followup_toggle(index: int, state: Dictionary, expanded: bool) -> void:
var content := choice_followup_contents[index]
var dark := _followup_is_dark()
var has_authored_choices := bool(state.get("has_authored_choices", not state.get("choices", []).is_empty()))
var was_correct := bool(state.get("was_correct", false))
var accent := Brand.SUCCESS if was_correct else Color("fb5f6d") if has_authored_choices else Brand.PURPLE
var row := HBoxContainer.new()
row.name = "FollowupResult"
row.add_theme_constant_override("separation", 8)
content.add_child(row)
var result := Label.new()
result.name = "FollowupVerdict"
result.text = ("Correct" if was_correct else "Incorrect") if has_authored_choices else "Review complete"
result.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_set_scaled_label(result, 14, accent)
row.add_child(result)
var toggle := Button.new()
toggle.name = "FollowupToggle"
toggle.flat = true
toggle.text = "Minimize" if expanded else "Expand"
toggle.tooltip_text = "Minimize follow-up review" if expanded else "Expand follow-up review"
toggle.icon = preload("res://assets/icons/lucide/chevron-down.svg") if expanded else preload("res://assets/icons/lucide/chevron-right.svg")
toggle.icon_alignment = HORIZONTAL_ALIGNMENT_RIGHT
toggle.custom_minimum_size = Vector2(110, 30)
toggle.add_theme_color_override("font_color", Brand.text_color(dark))
toggle.add_theme_color_override("font_hover_color", accent)
toggle.add_theme_color_override("icon_normal_color", Brand.text_color(dark))
toggle.add_theme_color_override("icon_hover_color", accent)
_set_scaled_button_font(toggle, 13)
toggle.pressed.connect(_toggle_followup_state.bind(str(state.get("choice_id", ""))))
row.add_child(toggle)
func _toggle_followup_state(choice_id: String) -> void:
if not followup_states.has(choice_id):
return
var state: Dictionary = followup_states[choice_id]
if not bool(state.get("resolved", false)):
return
state["collapsed"] = not bool(state.get("collapsed", false))
followup_states[choice_id] = state
_render_followup_state(choice_id)
_apply_saved_choice_state()
func _add_followup_body(content: VBoxContainer, is_correct: bool) -> VBoxContainer:
var body := PanelContainer.new()
body.add_theme_stylebox_override("panel", _followup_body_box(_followup_is_dark(), is_correct))
+428
View File
@@ -0,0 +1,428 @@
extends PanelContainer
const Brand := preload("res://scripts/ui/medmind_style.gd")
const CABINET_FRAME := preload("res://assets/slot_machine/cabinet_frame.png")
const LEVER_TEXTURE := preload("res://assets/slot_machine/lever.png")
const SLOT_COIN := preload("res://assets/slot_machine/slot_coin.png")
const SLOT_GEM := preload("res://assets/slot_machine/slot_gem.png")
const SLOT_POTION := preload("res://assets/slot_machine/slot_potion.png")
const SLOT_SHIELD := preload("res://assets/slot_machine/slot_shield.png")
const SLOT_COIN_STACK := preload("res://assets/slot_machine/slot_coin_stack.png")
const SLOT_GIFT := preload("res://assets/slot_machine/slot_gift.png")
const CLOCK_ICON := preload("res://assets/icons/lucide/clock-3.svg")
const REEL_SYMBOLS := [SLOT_COIN, SLOT_GEM, SLOT_POTION, SLOT_SHIELD, SLOT_COIN_STACK, SLOT_GIFT]
# fraction-of-image rect of the cabinet's empty reel window, measured against cabinet_frame.png
const WINDOW_RECT := Rect2(0.203, 0.246, 0.591, 0.604)
const REWARD_ICONS := {
"jackpot": {"texture": SLOT_COIN, "modulate": false},
"epic": {"texture": SLOT_GEM, "modulate": false},
"great": {"texture": SLOT_SHIELD, "modulate": false},
"exam_token": {"texture": CLOCK_ICON, "modulate": true},
"qbank_reset": {"texture": SLOT_GIFT, "modulate": false},
"nice": {"texture": SLOT_COIN_STACK, "modulate": false},
"good_luck": {"texture": SLOT_COIN, "modulate": false, "tint": true},
}
const MACHINE_SIZE := 380.0
const CELL := 71.0
const REEL_SEPARATION := 4
var main: Control
var drag_active := false
var drag_offset := Vector2.ZERO
var has_been_positioned := false
var machine_root: Control
var lever_button: TextureButton
var reel_states: Array = []
var status_label: Label
var _spinning := false
func _ready() -> void:
name = "RzSlotMachinePanel"
visible = false
top_level = true
mouse_filter = Control.MOUSE_FILTER_STOP
custom_minimum_size = Vector2(440, 470)
size = custom_minimum_size
_build_ui()
if not get_viewport().size_changed.is_connected(_on_viewport_size_changed):
get_viewport().size_changed.connect(_on_viewport_size_changed)
_on_viewport_size_changed()
CreditsService.credits_changed.connect(_refresh_spin_button)
UIThemeService.theme_changed.connect(func(_dark: bool): refresh_theme_controls())
refresh_theme_controls()
_refresh_spin_button()
func open_panel() -> void:
visible = true
move_to_front()
if not has_been_positioned:
var viewport_size := get_viewport_rect().size
position = Vector2(maxf(12.0, viewport_size.x - size.x - 24.0), 72.0)
has_been_positioned = true
_clamp_to_viewport()
CreditsService.refresh()
CreditsService.fetch_spin_config()
_refresh_spin_button()
func close_panel() -> void:
visible = false
drag_active = false
func toggle_panel() -> void:
if visible:
close_panel()
else:
open_panel()
func _build_ui() -> void:
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 12)
margin.add_theme_constant_override("margin_right", 12)
margin.add_theme_constant_override("margin_top", 12)
margin.add_theme_constant_override("margin_bottom", 14)
add_child(margin)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 10)
margin.add_child(content)
_build_title_bar(content)
_build_machine(content)
status_label = Label.new()
status_label.add_theme_font_override("font", Brand.font)
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
content.add_child(status_label)
func _build_title_bar(content: VBoxContainer) -> void:
var title_bar := HBoxContainer.new()
title_bar.name = "SlotMachineTitleBar"
title_bar.mouse_filter = Control.MOUSE_FILTER_STOP
title_bar.custom_minimum_size.y = 34
title_bar.gui_input.connect(_on_title_bar_gui_input)
content.add_child(title_bar)
var spacer := Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE
title_bar.add_child(spacer)
var close := Button.new()
close.name = "SlotMachineClose"
close.text = "×"
close.tooltip_text = "Close slot machine"
close.custom_minimum_size = Vector2(36, 32)
close.focus_mode = Control.FOCUS_NONE
close.pressed.connect(close_panel)
title_bar.add_child(close)
func _build_machine(parent: Control) -> void:
var center := CenterContainer.new()
parent.add_child(center)
machine_root = Control.new()
machine_root.custom_minimum_size = Vector2(MACHINE_SIZE, MACHINE_SIZE)
center.add_child(machine_root)
var frame := TextureRect.new()
frame.texture = CABINET_FRAME
frame.set_anchors_preset(Control.PRESET_FULL_RECT)
frame.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
frame.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT
frame.mouse_filter = Control.MOUSE_FILTER_IGNORE
machine_root.add_child(frame)
var window := Control.new()
window.anchor_left = WINDOW_RECT.position.x
window.anchor_top = WINDOW_RECT.position.y
window.anchor_right = WINDOW_RECT.position.x + WINDOW_RECT.size.x
window.anchor_bottom = WINDOW_RECT.position.y + WINDOW_RECT.size.y
window.mouse_filter = Control.MOUSE_FILTER_IGNORE
machine_root.add_child(window)
var reel_center := CenterContainer.new()
reel_center.set_anchors_preset(Control.PRESET_FULL_RECT)
window.add_child(reel_center)
var reels_grid := HBoxContainer.new()
reels_grid.add_theme_constant_override("separation", REEL_SEPARATION)
reel_center.add_child(reels_grid)
reel_states.clear()
for _column_index in range(3):
var clip := Control.new()
clip.clip_contents = true
clip.custom_minimum_size = Vector2(CELL, CELL * 3)
reels_grid.add_child(clip)
var strip := VBoxContainer.new()
strip.add_theme_constant_override("separation", 0)
clip.add_child(strip)
var icons: Array = []
for _row_index in range(4):
var icon := TextureRect.new()
icon.texture = REEL_SYMBOLS[randi() % REEL_SYMBOLS.size()]
icon.custom_minimum_size = Vector2(CELL, CELL)
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
strip.add_child(icon)
icons.append(icon)
reel_states.append({
"strip": strip,
"icons": icons,
"step": CELL,
"speed": 420.0,
"spinning": false,
"stop_requested": false,
"target_texture": null,
"recycles_after_target": -1,
"done": true,
})
lever_button = TextureButton.new()
lever_button.texture_normal = LEVER_TEXTURE
lever_button.ignore_texture_size = true
lever_button.stretch_mode = TextureButton.STRETCH_KEEP_ASPECT_CENTERED
lever_button.anchor_left = 0.80
lever_button.anchor_right = 1.06
lever_button.anchor_top = 0.24
lever_button.anchor_bottom = 0.88
lever_button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
lever_button.tooltip_text = "Pull to spin"
lever_button.resized.connect(func(): lever_button.pivot_offset = Vector2(lever_button.size.x * 0.5, lever_button.size.y * 0.92))
lever_button.pressed.connect(_on_lever_pulled)
machine_root.add_child(lever_button)
func refresh_theme_controls() -> void:
var dark := UIThemeService.is_dark_mode()
add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE))
func _refresh_spin_button() -> void:
if lever_button == null:
return
lever_button.disabled = _spinning or CreditsService.credits < CreditsService.spin_cost
func _on_lever_pulled() -> void:
_play_lever_pull_animation()
_on_spin_pressed()
func _play_lever_pull_animation() -> void:
var tween := create_tween()
tween.tween_property(lever_button, "rotation_degrees", -20.0, 0.1).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
tween.tween_property(lever_button, "rotation_degrees", 0.0, 0.3).set_trans(Tween.TRANS_ELASTIC).set_ease(Tween.EASE_OUT)
func _on_spin_pressed() -> void:
if _spinning:
return
_spinning = true
_refresh_spin_button()
await _do_one_spin()
_spinning = false
_refresh_spin_button()
func _do_one_spin() -> bool:
for state in reel_states:
state["stop_requested"] = false
state["target_texture"] = null
state["recycles_after_target"] = -1
state["done"] = false
_spin_reel(state)
var result := await _await_spin_result()
if not result.get("ok", false):
for state in reel_states:
state["target_texture"] = REEL_SYMBOLS[randi() % REEL_SYMBOLS.size()]
state["stop_requested"] = true
await _await_reels_done()
status_label.text = str(result.get("message", "The spin could not be completed."))
status_label.add_theme_color_override("font_color", Brand.MAGENTA)
return false
var reward_key := str(result.get("reward_key", ""))
var reward_texture := _icon_for_reward(reward_key)
for index in range(reel_states.size()):
var state: Dictionary = reel_states[index]
get_tree().create_timer(index * 0.25).timeout.connect(func():
state["target_texture"] = reward_texture
state["stop_requested"] = true)
await _await_reels_done()
var reward_label := str(result.get("reward_label", ""))
var coins_won := int(result.get("coins_won", 0))
status_label.text = "You won: %s" % reward_label
status_label.add_theme_color_override("font_color", Brand.SUCCESS)
if coins_won > 0:
_play_coin_burst(coins_won)
elif main != null:
var credits_badge: Object = main.get("credits_badge")
if credits_badge != null:
credits_badge.bump()
return true
func _icon_for_reward(key: String) -> Texture2D:
var spec: Dictionary = REWARD_ICONS.get(key, {})
var texture: Texture2D = spec.get("texture", SLOT_COIN)
return texture if texture != null else SLOT_COIN
func _await_reels_done() -> void:
while true:
var all_done := true
for state in reel_states:
if not state.get("done", false):
all_done = false
break
if all_done:
return
await get_tree().process_frame
func _await_spin_result() -> Dictionary:
var result := {}
var on_resolved := func(reward_key: String, reward_label: String, coins_won: int):
result.merge({"ok": true, "reward_key": reward_key, "reward_label": reward_label, "coins_won": coins_won}, true)
var on_failed := func(message: String):
result.merge({"ok": false, "message": message}, true)
CreditsService.spin_resolved.connect(on_resolved, CONNECT_ONE_SHOT)
CreditsService.credits_failed.connect(on_failed, CONNECT_ONE_SHOT)
CreditsService.spin()
var deadline := Time.get_ticks_msec() + 20000
while result.is_empty() and Time.get_ticks_msec() < deadline:
await get_tree().process_frame
if result.is_empty():
result = {"ok": false, "message": "The spin timed out. Check your connection and try again."}
if CreditsService.spin_resolved.is_connected(on_resolved):
CreditsService.spin_resolved.disconnect(on_resolved)
if CreditsService.credits_failed.is_connected(on_failed):
CreditsService.credits_failed.disconnect(on_failed)
return result
func _spin_reel(state: Dictionary) -> void:
state["spinning"] = true
var strip: VBoxContainer = state["strip"]
var icons: Array = state["icons"]
var step: float = state["step"]
var speed: float = state["speed"]
while true:
var current_speed: float = speed if state["recycles_after_target"] == -1 else speed * 0.45
strip.position.y -= current_speed * get_process_delta_time()
if strip.position.y <= -step:
strip.position.y += step
var front: TextureRect = icons.pop_front()
strip.move_child(front, strip.get_child_count() - 1)
icons.append(front)
if state["stop_requested"] and state["recycles_after_target"] == -1:
front.texture = state["target_texture"]
state["recycles_after_target"] = 0
else:
front.texture = REEL_SYMBOLS[randi() % REEL_SYMBOLS.size()]
if state["recycles_after_target"] >= 0:
state["recycles_after_target"] += 1
if state["recycles_after_target"] == 2:
strip.position.y = 0.0
break
await get_tree().process_frame
state["spinning"] = false
state["done"] = true
func _play_coin_burst(coins_won: int) -> void:
if main == null:
return
var credits_badge: Control = main.get("credits_badge")
if credits_badge == null or not is_instance_valid(credits_badge):
return
var origin := machine_root.get_global_rect().get_center()
var target := credits_badge.get_global_rect().get_center()
var coin_count := clampi(3 + coins_won / 500, 4, 10)
var flight_time := 0.55
var stagger := 0.035
for index in range(coin_count):
var coin := TextureRect.new()
coin.texture = SLOT_COIN
coin.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
coin.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
coin.custom_minimum_size = Vector2(16, 16)
coin.size = Vector2(16, 16)
coin.top_level = true
coin.mouse_filter = Control.MOUSE_FILTER_IGNORE
coin.pivot_offset = Vector2(8, 8)
var jitter := Vector2(randf_range(-24.0, 24.0), randf_range(-16.0, 16.0))
coin.global_position = origin + jitter - coin.size * 0.5
add_child(coin)
var tween := create_tween()
tween.tween_interval(index * stagger)
tween.set_parallel(true)
tween.tween_property(coin, "global_position", target - coin.size * 0.5, flight_time).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN)
tween.tween_property(coin, "scale", Vector2(0.6, 0.6), flight_time).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN)
tween.set_parallel(false)
tween.tween_callback(coin.queue_free)
var settle_time := (coin_count - 1) * stagger + flight_time
get_tree().create_timer(settle_time).timeout.connect(func():
if is_instance_valid(credits_badge):
credits_badge.bump())
func _on_title_bar_gui_input(event: InputEvent) -> void:
var touch_event := event as InputEventScreenTouch
if touch_event != null:
drag_active = touch_event.pressed
if drag_active:
drag_offset = touch_event.position
get_viewport().set_input_as_handled()
return
var touch_drag := event as InputEventScreenDrag
if touch_drag != null and drag_active:
global_position += touch_drag.relative
_clamp_to_viewport()
get_viewport().set_input_as_handled()
return
var mouse_event := event as InputEventMouseButton
if mouse_event != null and mouse_event.button_index == MOUSE_BUTTON_LEFT:
drag_active = mouse_event.pressed
if drag_active:
drag_offset = mouse_event.global_position - global_position
get_viewport().set_input_as_handled()
return
var motion := event as InputEventMouseMotion
if motion != null and drag_active:
global_position = motion.global_position - drag_offset
_clamp_to_viewport()
get_viewport().set_input_as_handled()
func _on_viewport_size_changed() -> void:
_clamp_to_viewport()
func _clamp_to_viewport() -> void:
if not is_inside_tree():
return
var viewport_size := get_viewport_rect().size
position.x = clampf(position.x, 12.0, maxf(12.0, viewport_size.x - size.x - 12.0))
position.y = clampf(position.y, 12.0, maxf(12.0, viewport_size.y - size.y - 12.0))
+1
View File
@@ -0,0 +1 @@
uid://col1hauyr27rn
+549
View File
@@ -0,0 +1,549 @@
extends VBoxContainer
const Brand := preload("res://scripts/ui/medmind_style.gd")
const ICON_BY_SKU := {
"exam_token_1": "clock-3",
"qbank_reset_1": "rotate-ccw",
}
const FEATURES_BY_SKU := {
"exam_token_1": ["Full 6-block exam", "Timed & scored", "Performance analytics"],
"qbank_reset_1": ["Reset eligibility", "Keep your stats", "Smart spaced retention"],
}
const POPULAR_SKUS := ["exam_token_1"]
const TAB_DEFS := [
{"label": "Featured", "icon": "star", "active": true},
{"label": "Tokens", "icon": "clipboard-list", "active": false},
{"label": "Boosts", "icon": "activity", "active": false},
{"label": "Content", "icon": "book-open", "active": false},
{"label": "Customization", "icon": "settings", "active": false},
{"label": "Bundles", "icon": "clipboard-plus", "active": false},
]
var main: Control
var subtitle_label: Label
var summary_card: PanelContainer
var summary_icon_panel: PanelContainer
var summary_icon: TextureRect
var balance_value_label: Label
var coin_caption_label: Label
var inventory_label: Label
var tab_controls: Array = []
var section_heading: Label
var section_subtitle: Label
var products_grid: HFlowContainer
var status_label: Label
var confirm_dialog: ConfirmationDialog
var footer_card: PanelContainer
var footer_icon_panel: PanelContainer
var footer_icon: TextureRect
var footer_heading: Label
var footer_description: Label
var pending_sku := ""
func _ready() -> void:
size_flags_horizontal = Control.SIZE_EXPAND_FILL
size_flags_vertical = Control.SIZE_EXPAND_FILL
add_theme_constant_override("separation", 18)
_build_header()
_build_summary_card()
_build_tabs()
section_heading = Label.new()
section_heading.text = "Featured"
section_heading.add_theme_font_override("font", Brand.font)
section_heading.add_theme_font_size_override("font_size", 18)
add_child(section_heading)
section_subtitle = Label.new()
section_subtitle.text = "Hand-picked items to supercharge your Step 3 prep."
section_subtitle.add_theme_font_override("font", Brand.font)
section_subtitle.add_theme_font_size_override("font_size", 12)
section_subtitle.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode()))
add_child(section_subtitle)
products_grid = HFlowContainer.new()
products_grid.add_theme_constant_override("h_separation", 14)
products_grid.add_theme_constant_override("v_separation", 14)
add_child(products_grid)
status_label = Label.new()
status_label.add_theme_font_override("font", Brand.font)
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
add_child(status_label)
confirm_dialog = ConfirmationDialog.new()
confirm_dialog.confirmed.connect(_confirm_purchase)
add_child(confirm_dialog)
_build_footer()
CreditsService.credits_changed.connect(_refresh)
CreditsService.credits_failed.connect(_show_error)
CreditsService.store_operation_completed.connect(_show_message)
UIThemeService.theme_changed.connect(func(_dark: bool): _refresh_theme_controls(); _refresh())
_refresh()
func on_show(_payload: Variant = null) -> void:
CreditsService.refresh()
_refresh()
func _build_header() -> void:
var title := Label.new()
title.text = "RZ Coin Shop"
title.add_theme_font_override("font", Brand.font)
title.add_theme_font_size_override("font_size", 30)
add_child(title)
subtitle_label = Label.new()
subtitle_label.text = "Spend your RZ Coins on premium content and boosts."
subtitle_label.add_theme_font_override("font", Brand.font)
subtitle_label.add_theme_font_size_override("font_size", 14)
subtitle_label.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode()))
add_child(subtitle_label)
func _build_summary_card() -> void:
var dark := UIThemeService.is_dark_mode()
summary_card = PanelContainer.new()
summary_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.AMBER))
summary_card.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
add_child(summary_card)
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 16)
margin.add_theme_constant_override("margin_right", 16)
margin.add_theme_constant_override("margin_top", 12)
margin.add_theme_constant_override("margin_bottom", 12)
summary_card.add_child(margin)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 14)
margin.add_child(row)
summary_icon_panel = PanelContainer.new()
summary_icon_panel.custom_minimum_size = Vector2(40, 40)
summary_icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
summary_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.AMBER, 20))
summary_icon = TextureRect.new()
summary_icon.texture = load("res://assets/icons/lucide/star.svg")
summary_icon.custom_minimum_size = Vector2(22, 22)
summary_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
summary_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
summary_icon.modulate = Brand.AMBER
summary_icon_panel.add_child(summary_icon)
row.add_child(summary_icon_panel)
var copy := VBoxContainer.new()
copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER
copy.add_theme_constant_override("separation", 0)
balance_value_label = Label.new()
balance_value_label.add_theme_font_override("font", Brand.font)
balance_value_label.add_theme_font_size_override("font_size", 22)
balance_value_label.add_theme_color_override("font_color", Brand.text_color(dark))
copy.add_child(balance_value_label)
coin_caption_label = Label.new()
coin_caption_label.text = "RZ Coins"
coin_caption_label.add_theme_font_override("font", Brand.font)
coin_caption_label.add_theme_font_size_override("font_size", 12)
coin_caption_label.add_theme_color_override("font_color", Brand.muted_color(dark))
copy.add_child(coin_caption_label)
inventory_label = Label.new()
inventory_label.add_theme_font_override("font", Brand.font)
inventory_label.add_theme_font_size_override("font_size", 12)
inventory_label.add_theme_color_override("font_color", Brand.muted_color(dark))
copy.add_child(inventory_label)
row.add_child(copy)
func _build_tabs() -> void:
var dark := UIThemeService.is_dark_mode()
tab_controls.clear()
var tabs_row := HFlowContainer.new()
tabs_row.add_theme_constant_override("h_separation", 20)
tabs_row.add_theme_constant_override("v_separation", 8)
add_child(tabs_row)
for tab_def_variant in TAB_DEFS:
var tab_def: Dictionary = tab_def_variant
var tab := VBoxContainer.new()
tab.add_theme_constant_override("separation", 6)
tabs_row.add_child(tab)
var content := HBoxContainer.new()
content.add_theme_constant_override("separation", 6)
tab.add_child(content)
var icon := TextureRect.new()
icon.texture = load("res://assets/icons/lucide/%s.svg" % str(tab_def.get("icon", "star")))
icon.custom_minimum_size = Vector2(16, 16)
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
content.add_child(icon)
var label := Label.new()
label.text = str(tab_def.get("label", ""))
label.add_theme_font_override("font", Brand.font)
label.add_theme_font_size_override("font_size", 14)
content.add_child(label)
var underline := ColorRect.new()
underline.custom_minimum_size = Vector2(0, 2)
tab.add_child(underline)
var active := bool(tab_def.get("active", false))
tab_controls.append({"icon": icon, "label": label, "underline": underline, "active": active})
var divider := ColorRect.new()
divider.custom_minimum_size = Vector2(0, 1)
add_child(divider)
tab_controls.append({"divider": divider})
_apply_tab_theme(dark)
func _apply_tab_theme(dark: bool) -> void:
for entry_variant in tab_controls:
var entry: Dictionary = entry_variant
if entry.has("divider"):
(entry["divider"] as ColorRect).color = Color("2a365a") if dark else Brand.LIGHT_BORDER
continue
var active := bool(entry["active"])
var color := Brand.ROYAL_BLUE if active else Brand.muted_color(dark)
(entry["icon"] as TextureRect).modulate = color
(entry["label"] as Label).add_theme_color_override("font_color", color)
(entry["underline"] as ColorRect).color = Brand.ROYAL_BLUE if active else Color(0, 0, 0, 0)
func _build_footer() -> void:
var dark := UIThemeService.is_dark_mode()
footer_card = PanelContainer.new()
footer_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false))
add_child(footer_card)
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 16)
margin.add_theme_constant_override("margin_right", 16)
margin.add_theme_constant_override("margin_top", 12)
margin.add_theme_constant_override("margin_bottom", 12)
footer_card.add_child(margin)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 14)
margin.add_child(row)
footer_icon_panel = PanelContainer.new()
footer_icon_panel.custom_minimum_size = Vector2(36, 36)
footer_icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
footer_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.ROYAL_BLUE, 18))
footer_icon = TextureRect.new()
footer_icon.texture = load("res://assets/icons/lucide/activity.svg")
footer_icon.custom_minimum_size = Vector2(18, 18)
footer_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
footer_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
footer_icon.modulate = Brand.ROYAL_BLUE
footer_icon_panel.add_child(footer_icon)
row.add_child(footer_icon_panel)
var copy := VBoxContainer.new()
copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL
copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER
copy.add_theme_constant_override("separation", 2)
footer_heading = Label.new()
footer_heading.text = "How to earn RZ Coins"
footer_heading.add_theme_font_override("font", Brand.font)
footer_heading.add_theme_font_size_override("font_size", 15)
footer_heading.add_theme_color_override("font_color", Brand.text_color(dark))
copy.add_child(footer_heading)
footer_description = Label.new()
footer_description.text = "Answer questions correctly on the first try, within the time limit."
footer_description.add_theme_font_override("font", Brand.font)
footer_description.add_theme_font_size_override("font_size", 12)
footer_description.add_theme_color_override("font_color", Brand.muted_color(dark))
footer_description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
copy.add_child(footer_description)
row.add_child(copy)
func _refresh_theme_controls() -> void:
var dark := UIThemeService.is_dark_mode()
subtitle_label.add_theme_color_override("font_color", Brand.muted_color(dark))
summary_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.AMBER))
summary_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.AMBER, 20))
balance_value_label.add_theme_color_override("font_color", Brand.text_color(dark))
coin_caption_label.add_theme_color_override("font_color", Brand.muted_color(dark))
inventory_label.add_theme_color_override("font_color", Brand.muted_color(dark))
_apply_tab_theme(dark)
section_subtitle.add_theme_color_override("font_color", Brand.muted_color(dark))
footer_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false))
footer_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.ROYAL_BLUE, 18))
footer_heading.add_theme_color_override("font_color", Brand.text_color(dark))
footer_description.add_theme_color_override("font_color", Brand.muted_color(dark))
func _refresh() -> void:
var dark := UIThemeService.is_dark_mode()
balance_value_label.text = _format_thousands(CreditsService.credits)
inventory_label.text = "%d exam token(s) · %d QBank reset token(s)" % [
CreditsService.exam_tokens, CreditsService.qbank_reset_tokens]
for child in products_grid.get_children():
child.queue_free()
for product_variant in CreditsService.products:
if typeof(product_variant) != TYPE_DICTIONARY:
continue
var product: Dictionary = product_variant
var sku := str(product.get("sku", ""))
var price := int(product.get("coinPrice", 0))
products_grid.add_child(_build_item_card(
dark,
ICON_BY_SKU.get(sku, "star"),
Brand.ROYAL_BLUE,
str(product.get("name", "Store item")),
str(product.get("description", "")),
FEATURES_BY_SKU.get(sku, []),
POPULAR_SKUS.has(sku),
price,
"Buy",
CreditsService.credits < price,
_ask_purchase.bind(sku, str(product.get("name", "item")), price)))
if CreditsService.qbank_reset_tokens > 0:
products_grid.add_child(_build_item_card(
dark,
"rotate-ccw",
Brand.SUCCESS,
"Use QBank Reset Token",
"Question eligibility resets; ELO, history, bookmarks, notes, reviews, and earning limits are preserved.",
FEATURES_BY_SKU.get("qbank_reset_1", []),
false,
-1,
"Use token",
false,
_ask_reset))
func _build_item_card(
dark: bool,
icon_name: String,
accent: Color,
title: String,
description_text: String,
features: Array,
popular: bool,
price: int,
button_text: String,
button_disabled: bool,
on_press: Callable
) -> PanelContainer:
var card := _new_card(dark)
var content: VBoxContainer = card.get_child(0).get_child(0)
var top_row := HBoxContainer.new()
content.add_child(top_row)
top_row.add_child(_card_icon(dark, icon_name, accent))
var badge_spacer := Control.new()
badge_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
top_row.add_child(badge_spacer)
if popular:
top_row.add_child(_popular_badge())
var name_label := Label.new()
name_label.text = title
name_label.add_theme_font_override("font", Brand.font)
name_label.add_theme_font_size_override("font_size", 16)
name_label.add_theme_color_override("font_color", Brand.text_color(dark))
content.add_child(name_label)
var description := Label.new()
description.text = description_text
description.add_theme_font_override("font", Brand.font)
description.add_theme_font_size_override("font_size", 12)
description.add_theme_color_override("font_color", Brand.muted_color(dark))
description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
description.custom_minimum_size = Vector2(190, 0)
content.add_child(description)
if not features.is_empty():
var bullets := VBoxContainer.new()
bullets.add_theme_constant_override("separation", 4)
content.add_child(bullets)
for feature_text in features:
bullets.add_child(_feature_row(dark, accent, str(feature_text)))
var divider := ColorRect.new()
divider.custom_minimum_size = Vector2(0, 1)
divider.color = Color("2a365a") if dark else Brand.LIGHT_BORDER
content.add_child(divider)
var footer_row := HBoxContainer.new()
footer_row.add_theme_constant_override("separation", 8)
content.add_child(footer_row)
if price >= 0:
footer_row.add_child(_price_tag(dark, price))
var spacer := Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
footer_row.add_child(spacer)
var button := Button.new()
button.text = button_text
button.custom_minimum_size = Vector2(64, 34)
button.add_theme_font_override("font", Brand.font)
_style_buy_button(button)
button.disabled = button_disabled
button.pressed.connect(on_press)
footer_row.add_child(button)
return card
func _new_card(dark: bool) -> PanelContainer:
var card := PanelContainer.new()
card.custom_minimum_size = Vector2(230, 0)
card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE))
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 16)
margin.add_theme_constant_override("margin_right", 16)
margin.add_theme_constant_override("margin_top", 14)
margin.add_theme_constant_override("margin_bottom", 14)
card.add_child(margin)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 8)
margin.add_child(content)
return card
func _card_icon(dark: bool, icon_name: String, accent: Color) -> PanelContainer:
var icon_panel := PanelContainer.new()
icon_panel.custom_minimum_size = Vector2(48, 48)
icon_panel.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 24))
var icon := TextureRect.new()
icon.texture = load("res://assets/icons/lucide/%s.svg" % icon_name)
icon.custom_minimum_size = Vector2(24, 24)
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
icon.modulate = accent
icon_panel.add_child(icon)
return icon_panel
func _popular_badge() -> PanelContainer:
var badge := PanelContainer.new()
badge.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
var box := StyleBoxFlat.new()
box.bg_color = Brand.ROYAL_BLUE
box.set_corner_radius_all(6)
box.content_margin_left = 8
box.content_margin_right = 8
box.content_margin_top = 3
box.content_margin_bottom = 3
badge.add_theme_stylebox_override("panel", box)
var label := Label.new()
label.text = "Popular"
label.add_theme_font_override("font", Brand.font)
label.add_theme_font_size_override("font_size", 11)
label.add_theme_color_override("font_color", Brand.DARK_TEXT)
badge.add_child(label)
return badge
func _feature_row(dark: bool, accent: Color, text: String) -> HBoxContainer:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 6)
var icon := TextureRect.new()
icon.texture = load("res://assets/icons/lucide/circle-check.svg")
icon.custom_minimum_size = Vector2(14, 14)
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
icon.modulate = accent
row.add_child(icon)
var label := Label.new()
label.text = text
label.add_theme_font_override("font", Brand.font)
label.add_theme_font_size_override("font_size", 12)
label.add_theme_color_override("font_color", Brand.muted_color(dark))
row.add_child(label)
return row
func _price_tag(dark: bool, price: int) -> HBoxContainer:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 4)
var icon := TextureRect.new()
icon.texture = load("res://assets/icons/lucide/star.svg")
icon.custom_minimum_size = Vector2(14, 14)
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
icon.modulate = Brand.AMBER
row.add_child(icon)
var label := Label.new()
label.text = _format_thousands(price)
label.add_theme_font_override("font", Brand.font)
label.add_theme_font_size_override("font_size", 14)
label.add_theme_color_override("font_color", Brand.text_color(dark))
row.add_child(label)
return row
func _style_buy_button(button: Button) -> void:
var normal := StyleBoxFlat.new()
normal.bg_color = Brand.ROYAL_BLUE
normal.set_corner_radius_all(8)
var hover := StyleBoxFlat.new()
hover.bg_color = Brand.SKY_BLUE
hover.set_corner_radius_all(8)
var disabled := StyleBoxFlat.new()
disabled.bg_color = Color(Brand.ROYAL_BLUE, 0.35)
disabled.set_corner_radius_all(8)
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_stylebox_override("pressed", normal)
button.add_theme_stylebox_override("disabled", disabled)
button.add_theme_color_override("font_color", Brand.DARK_TEXT)
button.add_theme_color_override("font_hover_color", Brand.DARK_TEXT)
button.add_theme_color_override("font_disabled_color", Color(Brand.DARK_TEXT, 0.7))
func _ask_purchase(sku: String, product_name: String, price: int) -> void:
pending_sku = sku
confirm_dialog.title = "Confirm purchase"
confirm_dialog.dialog_text = "Buy %s for %d Rz Coins?" % [product_name, price]
confirm_dialog.popup_centered()
func _confirm_purchase() -> void:
if pending_sku == "__reset__":
CreditsService.reset_qbank()
elif not pending_sku.is_empty():
CreditsService.purchase(pending_sku)
pending_sku = ""
func _ask_reset() -> void:
pending_sku = "__reset__"
confirm_dialog.title = "Reset QBank eligibility"
confirm_dialog.dialog_text = "Make all QBank questions eligible again? ELO, answer history, bookmarks, notes, reviews, and the rolling coin cap will be preserved."
confirm_dialog.popup_centered()
func _show_error(message: String) -> void:
status_label.text = message
status_label.add_theme_color_override("font_color", Brand.MAGENTA)
func _show_message(message: String) -> void:
status_label.text = message
status_label.add_theme_color_override("font_color", Brand.SUCCESS)
_refresh()
func _format_thousands(value: int) -> String:
var digits := str(absi(value))
var grouped := ""
var count := 0
for index in range(digits.length() - 1, -1, -1):
grouped = digits[index] + grouped
count += 1
if count % 3 == 0 and index != 0:
grouped = "," + grouped
return ("-" if value < 0 else "") + grouped
+1
View File
@@ -0,0 +1 @@
uid://cldpnvs2k6r3t
+7
View File
@@ -137,6 +137,8 @@ func _connect_services() -> void:
ExamSimulationService.state_changed.connect(_on_simulation_loaded)
if not ExamSimulationService.request_failed.is_connected(_on_request_failed):
ExamSimulationService.request_failed.connect(_on_request_failed)
if not ExamSimulationService.exam_token_required.is_connected(_on_exam_token_required):
ExamSimulationService.exam_token_required.connect(_on_exam_token_required)
func on_show(_payload: Variant = null) -> void:
@@ -538,6 +540,11 @@ func _on_request_failed(message: String) -> void:
_render_active_section()
func _on_exam_token_required() -> void:
pending_launch = false
main.show_screen("rz_store")
func _update_responsive_layout() -> void:
var use_compact := size.x < 940
if compact_layout == use_compact: