diff --git a/api/QuestionsApi/Controllers/QuestionsController.cs b/api/QuestionsApi/Controllers/QuestionsController.cs index 3df47a1..97f2baf 100644 --- a/api/QuestionsApi/Controllers/QuestionsController.cs +++ b/api/QuestionsApi/Controllers/QuestionsController.cs @@ -175,7 +175,7 @@ public class QuestionsController : ControllerBase _metrics.AttemptDuplicate(); } await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt); - if (record.Inserted && !User.IsInRole("SUPER")) + if (record.Inserted && record.Attempt.IsCorrect && req.MetTimeLimit && !User.IsInRole("SUPER")) await _credits.AwardForFirstAnswerAsync(userId, record.Attempt.Id ?? ""); var ratingOwner = Guid.NewGuid().ToString("N"); diff --git a/api/QuestionsApi/Models/QuestionRequests.cs b/api/QuestionsApi/Models/QuestionRequests.cs index 7004cef..288c01e 100644 --- a/api/QuestionsApi/Models/QuestionRequests.cs +++ b/api/QuestionsApi/Models/QuestionRequests.cs @@ -49,4 +49,5 @@ public class FirstQuestionAttemptRequest public string ChoiceId { get; set; } = ""; public List PresentedChoiceIds { get; set; } = []; public int DurationSeconds { get; set; } + public bool MetTimeLimit { get; set; } = true; } diff --git a/project.godot b/project.godot index 3149262..3fad8fc 100644 --- a/project.godot +++ b/project.godot @@ -13,14 +13,13 @@ config_version=5 config/name="RanaZe: Step 3 Trainer" config/version="pre-alpha" run/main_scene="res://scenes/main.tscn" -config/features=PackedStringArray("4.6", "GL Compatibility") -config/icon="res://assets/branding/ranaze-app-icon.png" config/use_custom_user_dir=true config/custom_user_dir_name="MedMind" +config/features=PackedStringArray("4.6", "GL Compatibility") boot_splash/bg_color=Color(0.019607844, 0.02745098, 0.05882353, 1) +boot_splash/stretch_mode=0 boot_splash/image="res://assets/branding/ranaze-mark.png" -boot_splash/fullsize=false -boot_splash/use_filter=true +config/icon="res://assets/branding/ranaze-app-icon.png" [autoload] @@ -58,9 +57,9 @@ window/stretch/aspect="expand" [rendering] +rendering_device/driver.windows="d3d12" renderer/rendering_method="gl_compatibility" renderer/rendering_method.mobile="gl_compatibility" -rendering_device/driver.windows="d3d12" textures/vram_compression/import_etc2_astc=true [step3] diff --git a/scripts/autoload/credits_service.gd.uid b/scripts/autoload/credits_service.gd.uid new file mode 100644 index 0000000..c7a746f --- /dev/null +++ b/scripts/autoload/credits_service.gd.uid @@ -0,0 +1 @@ +uid://bi323du7o7dn2 diff --git a/scripts/autoload/question_bank_service.gd b/scripts/autoload/question_bank_service.gd index 705013d..ee15038 100644 --- a/scripts/autoload/question_bank_service.gd +++ b/scripts/autoload/question_bank_service.gd @@ -257,10 +257,10 @@ func clear_questions() -> void: ratings_changed.emit() -func record_first_attempt(question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0) -> void: +func record_first_attempt(question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0, met_time_limit: bool = true) -> void: if not AuthState.can_send_authenticated_request() or question_id == "" or choice_id == "": return - _enqueue_attempt("answer", question_id, choice_id, presented_choice_ids, duration_seconds) + _enqueue_attempt("answer", question_id, choice_id, presented_choice_ids, duration_seconds, met_time_limit) func skip_question(question_id: String) -> void: @@ -386,7 +386,7 @@ func _on_retry_timer_timeout() -> void: load_questions_from_api(false) -func _enqueue_attempt(kind: String, question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0) -> void: +func _enqueue_attempt(kind: String, question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0, met_time_limit: bool = true) -> void: var account := AuthState.username.strip_edges() if account == "": return @@ -400,6 +400,7 @@ func _enqueue_attempt(kind: String, question_id: String, choice_id: String, pres "choice_id": choice_id, "presented_choice_ids": presented_choice_ids.duplicate(), "duration_seconds": maxi(0, duration_seconds), + "met_time_limit": met_time_limit, "retry_count": 0 }) _save_attempt_queue() @@ -432,7 +433,8 @@ func _process_attempt_queue() -> void: "questionExternalId": question_id, "choiceId": str(queued.get("choice_id", "")), "presentedChoiceIds": queued.get("presented_choice_ids", []), - "durationSeconds": int(queued.get("duration_seconds", 0)) + "durationSeconds": int(queued.get("duration_seconds", 0)), + "metTimeLimit": bool(queued.get("met_time_limit", true)) }) if str(queued.get("kind", "answer")) == "skip": url = "%s/%s/skip" % [question_api_base_url.rstrip("/"), question_id.uri_encode()] diff --git a/scripts/ui/credits_badge.gd b/scripts/ui/credits_badge.gd index c004aad..633c3c5 100644 --- a/scripts/ui/credits_badge.gd +++ b/scripts/ui/credits_badge.gd @@ -8,6 +8,8 @@ var icon_panel: PanelContainer var icon_texture: TextureRect var value_label: Label var caption_label: Label +var displayed_value := 0 +var _initialized := false func _ready() -> void: @@ -17,6 +19,7 @@ func _ready() -> void: refresh_theme_controls() _on_credits_changed() CreditsService.credits_changed.connect(_on_credits_changed) + resized.connect(func(): pivot_offset = size * 0.5) func _build_ui() -> void: @@ -50,7 +53,7 @@ func _build_ui() -> void: value_label.add_theme_font_size_override("font_size", 15) copy.add_child(value_label) caption_label = Label.new() - caption_label.text = "RZ Points" + caption_label.text = "Rz Coins" caption_label.add_theme_font_override("font", Brand.font) caption_label.add_theme_font_size_override("font_size", 10) copy.add_child(caption_label) @@ -75,8 +78,30 @@ func refresh_theme_controls() -> void: caption_label.add_theme_color_override("font_color", Brand.muted_color(dark)) +func bump() -> void: + var tween := create_tween() + tween.tween_property(self, "scale", Vector2(1.12, 1.12), 0.12).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) + tween.tween_property(self, "scale", Vector2.ONE, 0.18).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN) + + func _on_credits_changed() -> void: - value_label.text = _format_thousands(CreditsService.credits) if CreditsService.loaded else "--" + if not CreditsService.loaded: + _initialized = false + displayed_value = 0 + value_label.text = "--" + return + if not _initialized: + _initialized = true + displayed_value = CreditsService.credits + value_label.text = _format_thousands(displayed_value) + return + var tween := create_tween() + tween.tween_method(_set_displayed_value, displayed_value, CreditsService.credits, 0.5).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) + + +func _set_displayed_value(value: int) -> void: + displayed_value = value + value_label.text = _format_thousands(value) func _format_thousands(value: int) -> String: diff --git a/scripts/ui/credits_badge.gd.uid b/scripts/ui/credits_badge.gd.uid new file mode 100644 index 0000000..384aa68 --- /dev/null +++ b/scripts/ui/credits_badge.gd.uid @@ -0,0 +1 @@ +uid://doisoxqnk1mf3 diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index 58865f8..a97c040 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -15,6 +15,7 @@ const FLAG_DARK_ICON := preload("res://assets/icons/medmind/flag-dark.svg") const FLAG_LIGHT_ICON := preload("res://assets/icons/medmind/flag-light.svg") const BADGE_CIRCLE_OUTLINE_ICON := preload("res://assets/icons/medmind/badge-circle-outline.svg") const BADGE_CIRCLE_FILLED_ICON := preload("res://assets/icons/medmind/badge-circle-filled.svg") +const COIN_ICON := preload("res://assets/icons/lucide/star.svg") const FEEDBACK_FADE_SECONDS := 0.45 const CORRECT_ADVANCE_DELAY_SECONDS := 1.25 const TEXT_SCALE_MIN := 0.85 @@ -1565,12 +1566,15 @@ func _submit_or_next() -> void: return if BlockSessionService.session_kind in ["core", "bookmark", "review"]: var attempted_choice := selected_choice + var met_time_limit := true if BlockSessionService.session_kind == "core": + met_time_limit = BlockSessionService.get_current_elapsed_seconds() <= BlockSessionService.core_current_target_seconds QuestionBankService.record_first_attempt( question_id_for_attempt, attempted_choice, _presented_choice_ids(), - int(floor(BlockSessionService.get_core_question_total_elapsed_seconds())) + int(floor(BlockSessionService.get_core_question_total_elapsed_seconds())), + met_time_limit ) var result: Dictionary if BlockSessionService.session_kind == "core": @@ -1593,6 +1597,15 @@ func _submit_or_next() -> void: _update_question_elo_label(true) _show_answer_review() _trigger_correct_feedback() + var earned_coin := ( + BlockSessionService.session_kind == "core" + and bool(result.get("mastered", false)) + and met_time_limit + ) + if earned_coin: + var correct_choice_index := _choice_index_for_id(selected_choice) + if correct_choice_index >= 0 and correct_choice_index < choice_rows.size(): + _play_coin_burst(choice_rows[correct_choice_index].get_global_rect()) return _lock_wrong_choice(attempted_choice) _show_wrong_answer_followup(attempted_choice) @@ -2185,6 +2198,39 @@ func _trigger_correct_feedback() -> void: feedback_fog.color = Color(0.25, 1.0, 0.25, _feedback_overlay_alpha(true)) +func _play_coin_burst(origin_rect: Rect2) -> void: + if credits_badge == null: + return + var origin := origin_rect.get_center() + var target := credits_badge.get_global_rect().get_center() + var coin_count := 6 + var flight_time := 0.55 + var stagger := 0.035 + for index in range(coin_count): + var coin := TextureRect.new() + coin.texture = COIN_ICON + coin.modulate = Brand.AMBER + 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 credits_badge != null: + credits_badge.bump()) + + func _update_answer_feedback(delta: float) -> void: if shake_time > 0.0: shake_time = max(0.0, shake_time - delta)