Gate Rz Coins on correct-first-try-within-time-limit; match coin-flight animation to it
Credits were previously awarded on any first attempt regardless of correctness or timing. The award now requires the first-ever attempt to be correct (record.Attempt.IsCorrect) and submitted before the adaptive per-question timer expired (new MetTimeLimit request field, computed client-side from the same elapsed/target comparison that already drives the visible countdown bar). The coin-flight animation in question_player.gd now only plays when both conditions are met (BlockSessionService.submit_core_attempt's existing "mastered" flag plus met_time_limit), so the effect never promises a coin that isn't actually earned. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -175,7 +175,7 @@ public class QuestionsController : ControllerBase
|
|||||||
_metrics.AttemptDuplicate();
|
_metrics.AttemptDuplicate();
|
||||||
}
|
}
|
||||||
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
|
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 ?? "");
|
await _credits.AwardForFirstAnswerAsync(userId, record.Attempt.Id ?? "");
|
||||||
|
|
||||||
var ratingOwner = Guid.NewGuid().ToString("N");
|
var ratingOwner = Guid.NewGuid().ToString("N");
|
||||||
|
|||||||
@@ -49,4 +49,5 @@ public class FirstQuestionAttemptRequest
|
|||||||
public string ChoiceId { get; set; } = "";
|
public string ChoiceId { get; set; } = "";
|
||||||
public List<string> PresentedChoiceIds { get; set; } = [];
|
public List<string> PresentedChoiceIds { get; set; } = [];
|
||||||
public int DurationSeconds { get; set; }
|
public int DurationSeconds { get; set; }
|
||||||
|
public bool MetTimeLimit { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-5
@@ -13,14 +13,13 @@ config_version=5
|
|||||||
config/name="RanaZe: Step 3 Trainer"
|
config/name="RanaZe: Step 3 Trainer"
|
||||||
config/version="pre-alpha"
|
config/version="pre-alpha"
|
||||||
run/main_scene="res://scenes/main.tscn"
|
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/use_custom_user_dir=true
|
||||||
config/custom_user_dir_name="MedMind"
|
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/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/image="res://assets/branding/ranaze-mark.png"
|
||||||
boot_splash/fullsize=false
|
config/icon="res://assets/branding/ranaze-app-icon.png"
|
||||||
boot_splash/use_filter=true
|
|
||||||
|
|
||||||
[autoload]
|
[autoload]
|
||||||
|
|
||||||
@@ -58,9 +57,9 @@ window/stretch/aspect="expand"
|
|||||||
|
|
||||||
[rendering]
|
[rendering]
|
||||||
|
|
||||||
|
rendering_device/driver.windows="d3d12"
|
||||||
renderer/rendering_method="gl_compatibility"
|
renderer/rendering_method="gl_compatibility"
|
||||||
renderer/rendering_method.mobile="gl_compatibility"
|
renderer/rendering_method.mobile="gl_compatibility"
|
||||||
rendering_device/driver.windows="d3d12"
|
|
||||||
textures/vram_compression/import_etc2_astc=true
|
textures/vram_compression/import_etc2_astc=true
|
||||||
|
|
||||||
[step3]
|
[step3]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bi323du7o7dn2
|
||||||
@@ -257,10 +257,10 @@ func clear_questions() -> void:
|
|||||||
ratings_changed.emit()
|
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 == "":
|
if not AuthState.can_send_authenticated_request() or question_id == "" or choice_id == "":
|
||||||
return
|
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:
|
func skip_question(question_id: String) -> void:
|
||||||
@@ -386,7 +386,7 @@ func _on_retry_timer_timeout() -> void:
|
|||||||
load_questions_from_api(false)
|
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()
|
var account := AuthState.username.strip_edges()
|
||||||
if account == "":
|
if account == "":
|
||||||
return
|
return
|
||||||
@@ -400,6 +400,7 @@ func _enqueue_attempt(kind: String, question_id: String, choice_id: String, pres
|
|||||||
"choice_id": choice_id,
|
"choice_id": choice_id,
|
||||||
"presented_choice_ids": presented_choice_ids.duplicate(),
|
"presented_choice_ids": presented_choice_ids.duplicate(),
|
||||||
"duration_seconds": maxi(0, duration_seconds),
|
"duration_seconds": maxi(0, duration_seconds),
|
||||||
|
"met_time_limit": met_time_limit,
|
||||||
"retry_count": 0
|
"retry_count": 0
|
||||||
})
|
})
|
||||||
_save_attempt_queue()
|
_save_attempt_queue()
|
||||||
@@ -432,7 +433,8 @@ func _process_attempt_queue() -> void:
|
|||||||
"questionExternalId": question_id,
|
"questionExternalId": question_id,
|
||||||
"choiceId": str(queued.get("choice_id", "")),
|
"choiceId": str(queued.get("choice_id", "")),
|
||||||
"presentedChoiceIds": queued.get("presented_choice_ids", []),
|
"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":
|
if str(queued.get("kind", "answer")) == "skip":
|
||||||
url = "%s/%s/skip" % [question_api_base_url.rstrip("/"), question_id.uri_encode()]
|
url = "%s/%s/skip" % [question_api_base_url.rstrip("/"), question_id.uri_encode()]
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ var icon_panel: PanelContainer
|
|||||||
var icon_texture: TextureRect
|
var icon_texture: TextureRect
|
||||||
var value_label: Label
|
var value_label: Label
|
||||||
var caption_label: Label
|
var caption_label: Label
|
||||||
|
var displayed_value := 0
|
||||||
|
var _initialized := false
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -17,6 +19,7 @@ func _ready() -> void:
|
|||||||
refresh_theme_controls()
|
refresh_theme_controls()
|
||||||
_on_credits_changed()
|
_on_credits_changed()
|
||||||
CreditsService.credits_changed.connect(_on_credits_changed)
|
CreditsService.credits_changed.connect(_on_credits_changed)
|
||||||
|
resized.connect(func(): pivot_offset = size * 0.5)
|
||||||
|
|
||||||
|
|
||||||
func _build_ui() -> void:
|
func _build_ui() -> void:
|
||||||
@@ -50,7 +53,7 @@ func _build_ui() -> void:
|
|||||||
value_label.add_theme_font_size_override("font_size", 15)
|
value_label.add_theme_font_size_override("font_size", 15)
|
||||||
copy.add_child(value_label)
|
copy.add_child(value_label)
|
||||||
caption_label = Label.new()
|
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_override("font", Brand.font)
|
||||||
caption_label.add_theme_font_size_override("font_size", 10)
|
caption_label.add_theme_font_size_override("font_size", 10)
|
||||||
copy.add_child(caption_label)
|
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))
|
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:
|
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:
|
func _format_thousands(value: int) -> String:
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://doisoxqnk1mf3
|
||||||
@@ -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 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_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 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 FEEDBACK_FADE_SECONDS := 0.45
|
||||||
const CORRECT_ADVANCE_DELAY_SECONDS := 1.25
|
const CORRECT_ADVANCE_DELAY_SECONDS := 1.25
|
||||||
const TEXT_SCALE_MIN := 0.85
|
const TEXT_SCALE_MIN := 0.85
|
||||||
@@ -1565,12 +1566,15 @@ func _submit_or_next() -> void:
|
|||||||
return
|
return
|
||||||
if BlockSessionService.session_kind in ["core", "bookmark", "review"]:
|
if BlockSessionService.session_kind in ["core", "bookmark", "review"]:
|
||||||
var attempted_choice := selected_choice
|
var attempted_choice := selected_choice
|
||||||
|
var met_time_limit := true
|
||||||
if BlockSessionService.session_kind == "core":
|
if BlockSessionService.session_kind == "core":
|
||||||
|
met_time_limit = BlockSessionService.get_current_elapsed_seconds() <= BlockSessionService.core_current_target_seconds
|
||||||
QuestionBankService.record_first_attempt(
|
QuestionBankService.record_first_attempt(
|
||||||
question_id_for_attempt,
|
question_id_for_attempt,
|
||||||
attempted_choice,
|
attempted_choice,
|
||||||
_presented_choice_ids(),
|
_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
|
var result: Dictionary
|
||||||
if BlockSessionService.session_kind == "core":
|
if BlockSessionService.session_kind == "core":
|
||||||
@@ -1593,6 +1597,15 @@ func _submit_or_next() -> void:
|
|||||||
_update_question_elo_label(true)
|
_update_question_elo_label(true)
|
||||||
_show_answer_review()
|
_show_answer_review()
|
||||||
_trigger_correct_feedback()
|
_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
|
return
|
||||||
_lock_wrong_choice(attempted_choice)
|
_lock_wrong_choice(attempted_choice)
|
||||||
_show_wrong_answer_followup(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))
|
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:
|
func _update_answer_feedback(delta: float) -> void:
|
||||||
if shake_time > 0.0:
|
if shake_time > 0.0:
|
||||||
shake_time = max(0.0, shake_time - delta)
|
shake_time = max(0.0, shake_time - delta)
|
||||||
|
|||||||
Reference in New Issue
Block a user