Add graphical duodenal ulcer interview

This commit is contained in:
hz
2026-08-05 23:14:51 -05:00
parent 7709d16018
commit 58dd370149
14 changed files with 768 additions and 29 deletions
+117 -10
View File
@@ -2,6 +2,8 @@ extends Control
signal choice_selected(choice_id: String)
signal interview_continue_requested
signal debrief_return_requested
signal debrief_repeat_requested
const CHOICE_NORMAL := preload("res://assets/ccs_cases_2/images/doctor/dialogue-choice-normal.png")
const CHOICE_HOVER := preload("res://assets/ccs_cases_2/images/doctor/dialogue-choice-hover.png")
@@ -14,10 +16,13 @@ const CHOICE_CROP := Rect2(80.0, 200.0, 1610.0, 485.0)
@onready var patient: TextureRect = $Patient
@onready var dialogue_bubble: Control = $DialogueBubble
@onready var dialogue_text: Label = $DialogueBubble/Text
@onready var choice_container: VBoxContainer = $DialogueChoices
@onready var choice_container: GridContainer = $DialogueChoices
var blink_tween: Tween
var dialogue_tween: Tween
var clock_seconds := 9.0 * 60.0 * 60.0
var debrief_panel: PanelContainer
var experience_mode := "management"
func _ready() -> void:
@@ -28,20 +33,35 @@ func _ready() -> void:
func _process(_delta: float) -> void:
_update_clock()
pass
func _update_clock() -> void:
var time := Time.get_time_dict_from_system()
var unix_time := Time.get_unix_time_from_system()
var second := float(time.second) + fmod(unix_time, 1.0)
var minute := float(time.minute) + second / 60.0
var hour := float(time.hour % 12) + minute / 60.0
var hour_value := floori(clock_seconds / 3600.0)
var minute_value := floori(fmod(clock_seconds, 3600.0) / 60.0)
var second_value := fmod(clock_seconds, 60.0)
var second := float(second_value)
var minute := float(minute_value) + second / 60.0
var hour := float(hour_value % 12) + minute / 60.0
hour_hand.rotation = TAU * hour / 12.0
minute_hand.rotation = TAU * minute / 60.0
second_hand.rotation = TAU * second / 60.0
func configure_presentation(presentation: Dictionary, simulated_start: String, simulated_minute: int) -> void:
if str(presentation.get("patientAssetKey", "")) != "patient_44_white_male":
push_warning("Unsupported CCS patient asset key; using the office default.")
var date_time := Time.get_datetime_dict_from_datetime_string(simulated_start, false)
if not date_time.is_empty():
clock_seconds = float(int(date_time.get("hour", 9)) * 3600 + int(date_time.get("minute", 0)) * 60)
clock_seconds += float(simulated_minute * 60)
_update_clock()
func configure_experience_mode(value: String) -> void:
experience_mode = value
func _schedule_patient_blink() -> void:
blink_tween = create_tween()
blink_tween.tween_interval(randf_range(2.8, 5.2))
@@ -90,9 +110,15 @@ func _resize_dialogue_bubble() -> void:
func configure_interview(node: Dictionary) -> void:
if debrief_panel != null and is_instance_valid(debrief_panel):
debrief_panel.queue_free()
debrief_panel = null
show_patient_dialogue(str(node.get("patientText", "")))
if bool(node.get("isTerminal", false)):
show_dialogue_choices([{"id": "__continue", "text": "Continue to Patient Management"}])
show_dialogue_choices([{
"id": "__continue",
"text": "Finish Interview" if experience_mode == "graphical_interview" else "Continue to Patient Management"
}])
else:
show_dialogue_choices(node.get("choices", []))
@@ -105,14 +131,15 @@ func set_choices_pending(pending: bool) -> void:
func show_dialogue_choices(options: Array) -> void:
hide_dialogue_choices()
var stack_height := options.size() * 97.0 + 8.0
var row_count := ceili(float(options.size()) / 2.0)
var stack_height := row_count * 97.0 + 8.0
choice_container.offset_top = -4.0 - stack_height
for index in options.size():
var option: Dictionary = options[index]
var choice_id := str(option.get("id", ""))
var option_text := str(option.get("text", ""))
var button := TextureButton.new()
button.custom_minimum_size = Vector2(420.0, 105.0)
button.custom_minimum_size = Vector2(440.0, 105.0)
button.ignore_texture_size = true
button.stretch_mode = TextureButton.STRETCH_SCALE
button.texture_normal = _crop_choice_texture(CHOICE_NORMAL)
@@ -175,3 +202,83 @@ func _on_dialogue_choice_pressed(choice_id: String) -> void:
interview_continue_requested.emit()
else:
choice_selected.emit(choice_id)
func show_debrief(debrief: Dictionary, score: Dictionary) -> void:
hide_patient_dialogue()
hide_dialogue_choices()
if debrief_panel != null and is_instance_valid(debrief_panel):
debrief_panel.queue_free()
debrief_panel = PanelContainer.new()
debrief_panel.name = "InterviewDebrief"
debrief_panel.z_index = 50
debrief_panel.position = Vector2(315.0, 100.0)
debrief_panel.size = Vector2(870.0, 600.0)
var panel_style := StyleBoxFlat.new()
panel_style.bg_color = Color(0.96, 0.95, 0.91, 0.97)
panel_style.border_color = Color(0.12, 0.22, 0.28, 0.8)
panel_style.set_border_width_all(2)
panel_style.set_corner_radius_all(12)
panel_style.set_content_margin_all(28.0)
debrief_panel.add_theme_stylebox_override("panel", panel_style)
add_child(debrief_panel)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 16)
debrief_panel.add_child(content)
var heading := Label.new()
heading.text = "Interview Debrief"
heading.add_theme_font_size_override("font_size", 30)
heading.add_theme_color_override("font_color", Color(0.055, 0.105, 0.145))
content.add_child(heading)
var diagnosis := Label.new()
diagnosis.text = str(debrief.get("likelyDiagnosis", "Interview complete"))
diagnosis.add_theme_font_size_override("font_size", 23)
diagnosis.add_theme_color_override("font_color", Color(0.12, 0.36, 0.27))
diagnosis.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
content.add_child(diagnosis)
var summary := Label.new()
summary.text = str(debrief.get("summary", ""))
summary.add_theme_font_size_override("font_size", 18)
summary.add_theme_color_override("font_color", Color(0.08, 0.12, 0.15))
summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
content.add_child(summary)
var score_label := Label.new()
score_label.text = "History coverage: %d%%" % int(round(float(score.get("percent", 0.0))))
score_label.add_theme_font_size_override("font_size", 20)
content.add_child(score_label)
var covered: Array[String] = []
var missed: Array[String] = []
for raw_criterion in score.get("criteria", []):
var criterion: Dictionary = raw_criterion
var title := str(criterion.get("title", "History topic"))
if str(criterion.get("status", "")) == "credited":
covered.append(title)
elif str(criterion.get("status", "")) == "missed":
missed.append(title)
var coverage := Label.new()
coverage.text = "Covered: %s\nMissed optional topics: %s" % [
", ".join(covered), "None" if missed.is_empty() else ", ".join(missed)]
coverage.add_theme_font_size_override("font_size", 16)
coverage.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
content.add_child(coverage)
var points := Label.new()
var teaching_points: Array = debrief.get("teachingPoints", [])
points.text = "Key points\n- " + "\n- ".join(teaching_points) if not teaching_points.is_empty() else ""
points.add_theme_font_size_override("font_size", 17)
points.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
points.size_flags_vertical = Control.SIZE_EXPAND_FILL
content.add_child(points)
var actions := HBoxContainer.new()
actions.alignment = BoxContainer.ALIGNMENT_END
actions.add_theme_constant_override("separation", 12)
content.add_child(actions)
var return_button := Button.new()
return_button.text = "Return to Cases"
return_button.custom_minimum_size = Vector2(190.0, 48.0)
return_button.pressed.connect(func(): debrief_return_requested.emit())
actions.add_child(return_button)
var repeat_button := Button.new()
repeat_button.text = "Repeat Interview"
repeat_button.custom_minimum_size = Vector2(190.0, 48.0)
repeat_button.pressed.connect(func(): debrief_repeat_requested.emit())
actions.add_child(repeat_button)
+52 -4
View File
@@ -57,7 +57,10 @@ func on_hide() -> void:
func compatible_cases(cases: Array) -> Array:
return cases.filter(func(item: Variant) -> bool:
return typeof(item) == TYPE_DICTIONARY and bool(item.get("interactiveInterviewAvailable", false)))
return typeof(item) == TYPE_DICTIONARY \
and bool(item.get("interactiveInterviewAvailable", false)) \
and bool(item.get("graphicalPresentationAvailable", false)) \
and str(item.get("graphicalPresentation", {}).get("sceneKey", "")) == "doctor_office")
func _on_cases_changed() -> void:
@@ -77,6 +80,10 @@ func _render_state() -> void:
if not active_loaded:
return
var attempt: Dictionary = CCSService.attempt
if not attempt.is_empty() and str(attempt.get("state", "")) == "completed" \
and str(attempt.get("experienceMode", "")) == "graphical_interview":
_render_debrief(attempt)
return
if not attempt.is_empty() and str(attempt.get("state", "")) in ["active", "final_orders"]:
_render_attempt(attempt)
elif cases_loaded and active_loaded:
@@ -98,7 +105,7 @@ func _render_attempt(attempt: Dictionary) -> void:
if bool(attempt.get("openingCompleted", false)) and not bool(node.get("isTerminal", false)):
_handoff_to_classic_player()
return
_show_doctor_office(node)
_show_doctor_office(node, attempt)
func _build_selection_view() -> void:
@@ -156,7 +163,7 @@ func _render_case_selection() -> void:
_clear_children(case_list)
var available := compatible_cases(CCSService.cases)
if available.is_empty():
status_label.text = "No published CCS cases currently include an interactive interview."
status_label.text = "No published CCS cases currently have a supported graphical interview."
return
status_label.text = "%d interactive %s available." % [available.size(), "case is" if available.size() == 1 else "cases are"]
retry_button.visible = false
@@ -183,13 +190,20 @@ func _start_case(case_external_id: String) -> void:
CCSService.create_attempt(case_external_id, "timed")
func _show_doctor_office(node: Dictionary) -> void:
func _show_doctor_office(node: Dictionary, attempt: Dictionary = {}) -> void:
if active_scene == null:
_set_active_scene(DOCTOR_SCENE.instantiate(), "doctor_office")
active_scene.choice_selected.connect(_submit_dialogue_choice)
active_scene.interview_continue_requested.connect(_handoff_to_classic_player)
active_scene.debrief_return_requested.connect(_return_to_cases)
active_scene.debrief_repeat_requested.connect(_repeat_interview)
selection_view.visible = false
stage_root.visible = true
active_scene.configure_experience_mode(str(attempt.get("experienceMode", "management")))
active_scene.configure_presentation(
attempt.get("graphicalPresentation", {}),
str(attempt.get("simulatedStartUtc", "")),
int(attempt.get("simulatedMinute", 0)))
active_scene.configure_interview(node)
active_scene.set_choices_pending(not pending_action.is_empty())
@@ -203,11 +217,45 @@ func _submit_dialogue_choice(choice_id: String) -> void:
func _handoff_to_classic_player() -> void:
if str(CCSService.attempt.get("experienceMode", "")) == "graphical_interview":
pending_action = "finishing_interview"
active_scene.set_choices_pending(true)
CCSService.send_command("exit_case")
return
current_view = "handoff"
if main != null:
main.show_screen("ccs_player")
func _render_debrief(attempt: Dictionary) -> void:
if active_scene == null:
_set_active_scene(DOCTOR_SCENE.instantiate(), "doctor_office")
active_scene.debrief_return_requested.connect(_return_to_cases)
active_scene.debrief_repeat_requested.connect(_repeat_interview)
selection_view.visible = false
stage_root.visible = true
active_scene.configure_presentation(
attempt.get("graphicalPresentation", {}),
str(attempt.get("simulatedStartUtc", "")),
int(attempt.get("simulatedMinute", 0)))
active_scene.show_debrief(attempt.get("interviewDebrief", {}), attempt.get("score", {}))
current_view = "interview_debrief"
func _return_to_cases() -> void:
CCSService.attempt = {}
pending_action = ""
_render_case_selection()
func _repeat_interview() -> void:
var case_external_id := str(CCSService.attempt.get("caseExternalId", ""))
if case_external_id.is_empty() or not pending_action.is_empty():
return
pending_action = "creating"
CCSService.create_attempt(case_external_id, "timed")
func _on_request_failed(message: String) -> void:
if not visible:
return