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") const CHOICE_PRESSED := preload("res://assets/ccs_cases_2/images/doctor/dialogue-choice-pressed.png") const CHOICE_CROP := Rect2(80.0, 200.0, 1610.0, 485.0) const CHOICES_PER_PAGE := 4 const DIALOGUE_PAGE_MIN_CHARS := 32 const DIALOGUE_PAGE_MAX_CHARS := 82 const DIALOGUE_BUBBLE_MIN_HEIGHT := 110.0 const DIALOGUE_BUBBLE_MAX_HEIGHT := 240.0 const DIALOGUE_BUBBLE_VERTICAL_CHROME := 83.0 @onready var hour_hand: Node2D = $ClockMount/HourHand @onready var minute_hand: Node2D = $ClockMount/MinuteHand @onready var second_hand: Node2D = $ClockMount/SecondHand @onready var patient: TextureRect = $Patient @onready var dialogue_bubble: Control = $DialogueBubble @onready var dialogue_text: RichTextLabel = $DialogueBubble/Text @onready var dialogue_continue_hint: Label = $DialogueBubble/ContinueHint @onready var choice_area: Control = $DialogueChoiceArea @onready var choice_container: HBoxContainer = $DialogueChoiceArea/DialogueChoices @onready var choice_pager: HBoxContainer = $DialogueChoiceArea/ChoicePager @onready var previous_page_button: Button = $DialogueChoiceArea/ChoicePager/Previous @onready var page_label: Label = $DialogueChoiceArea/ChoicePager/Page @onready var next_page_button: Button = $DialogueChoiceArea/ChoicePager/Next 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" var pending_choices: Array = [] var available_choices: Array = [] var choice_page := 0 var dialogue_pages: Array[String] = [] var dialogue_page_index := 0 var dialogue_characters_per_second := 35.0 var dialogue_reveal_complete := false var dialogue_layout_generation := 0 func _ready() -> void: previous_page_button.pressed.connect(_change_choice_page.bind(-1)) next_page_button.pressed.connect(_change_choice_page.bind(1)) _update_clock() _schedule_patient_blink() hide_patient_dialogue() hide_dialogue_choices() func _process(_delta: float) -> void: pass func _update_clock() -> void: 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)) blink_tween.tween_method(_set_patient_blink, 0.0, 1.0, 0.075).set_trans( Tween.TRANS_SINE ).set_ease(Tween.EASE_IN) blink_tween.tween_interval(0.045) blink_tween.tween_method(_set_patient_blink, 1.0, 0.0, 0.10).set_trans( Tween.TRANS_SINE ).set_ease(Tween.EASE_OUT) blink_tween.tween_callback(_schedule_patient_blink) func _set_patient_blink(amount: float) -> void: patient.material.set_shader_parameter("blink_amount", amount) func show_patient_dialogue(text: String, characters_per_second: float = 35.0) -> void: if dialogue_tween and dialogue_tween.is_valid(): dialogue_tween.kill() dialogue_pages = _split_dialogue_pages(text) dialogue_page_index = 0 dialogue_characters_per_second = characters_per_second _show_dialogue_page() func _show_dialogue_page() -> void: if dialogue_tween and dialogue_tween.is_valid(): dialogue_tween.kill() if dialogue_pages.is_empty(): complete_response_transition() return dialogue_text.text = dialogue_pages[dialogue_page_index] dialogue_text.visible_ratio = 0.0 dialogue_reveal_complete = false dialogue_continue_hint.text = "Click to continue" dialogue_continue_hint.visible = true dialogue_bubble.visible = true dialogue_bubble.grab_focus() dialogue_layout_generation += 1 call_deferred("_prepare_dialogue_page", dialogue_layout_generation) func _prepare_dialogue_page(generation: int) -> void: if generation != dialogue_layout_generation or not dialogue_bubble.visible \ or dialogue_reveal_complete: return var measured_height := dialogue_text.get_content_height() + DIALOGUE_BUBBLE_VERTICAL_CHROME dialogue_bubble.size.y = clampf( measured_height, DIALOGUE_BUBBLE_MIN_HEIGHT, DIALOGUE_BUBBLE_MAX_HEIGHT) var reveal_duration := float(dialogue_text.text.length()) / maxf(dialogue_characters_per_second, 1.0) dialogue_tween = create_tween() dialogue_tween.tween_property(dialogue_text, "visible_ratio", 1.0, reveal_duration) dialogue_tween.finished.connect(_on_dialogue_reveal_finished) func hide_patient_dialogue() -> void: if dialogue_tween and dialogue_tween.is_valid(): dialogue_tween.kill() dialogue_layout_generation += 1 dialogue_bubble.visible = false dialogue_continue_hint.visible = false dialogue_pages = [] dialogue_page_index = 0 dialogue_reveal_complete = false pending_choices = [] func is_patient_dialogue_visible() -> bool: return dialogue_bubble.visible func configure_interview(node: Dictionary) -> void: if debrief_panel != null and is_instance_valid(debrief_panel): debrief_panel.queue_free() debrief_panel = null var next_choices: Array if bool(node.get("isTerminal", false)): next_choices = [{ "id": "__continue", "text": "Finish Interview" if experience_mode == "graphical_interview" else "Continue to Patient Management" }] else: next_choices = node.get("choices", []) hide_dialogue_choices() if bool(node.get("suppressPatientText", false)): hide_patient_dialogue() show_dialogue_choices(next_choices) return pending_choices = next_choices show_patient_dialogue(str(node.get("patientText", ""))) func complete_dialogue_reveal() -> void: if dialogue_reveal_complete: return if dialogue_tween and dialogue_tween.is_valid(): dialogue_tween.kill() dialogue_layout_generation += 1 dialogue_text.visible_ratio = 1.0 _on_dialogue_reveal_finished() func _on_dialogue_reveal_finished() -> void: dialogue_tween = null dialogue_reveal_complete = true dialogue_continue_hint.text = "Click to continue" dialogue_continue_hint.visible = true func advance_dialogue() -> void: if not dialogue_bubble.visible: return if not dialogue_reveal_complete: complete_dialogue_reveal() return if dialogue_page_index < dialogue_pages.size() - 1: dialogue_page_index += 1 _show_dialogue_page() return complete_response_transition() func complete_response_transition() -> void: var choices := pending_choices pending_choices = [] dialogue_bubble.visible = false dialogue_continue_hint.visible = false dialogue_pages = [] dialogue_page_index = 0 dialogue_reveal_complete = false show_dialogue_choices(choices) func _input(event: InputEvent) -> void: if not dialogue_bubble.visible: return var should_advance := false if event is InputEventMouseButton: should_advance = event.button_index == MOUSE_BUTTON_LEFT and event.pressed \ and get_global_rect().has_point(event.position) elif event is InputEventScreenTouch: should_advance = event.pressed and get_global_rect().has_point(event.position) elif event is InputEventKey: should_advance = event.pressed and not event.echo \ and event.keycode in [KEY_ENTER, KEY_KP_ENTER, KEY_SPACE] if not should_advance: return advance_dialogue() get_viewport().set_input_as_handled() func _split_dialogue_pages(text: String) -> Array[String]: var normalized := text.strip_edges() if normalized.is_empty(): return [] var pages: Array[String] = [] var current := "" for word in normalized.split(" ", false): var candidate := word if current.is_empty() else "%s %s" % [current, word] var has_natural_break := current.length() >= DIALOGUE_PAGE_MIN_CHARS \ and _ends_dialogue_phrase(word) if candidate.length() > DIALOGUE_PAGE_MAX_CHARS and not current.is_empty(): pages.append(current) current = word elif has_natural_break: pages.append(candidate) current = "" else: current = candidate if not current.is_empty(): pages.append(current) return pages func _ends_dialogue_phrase(word: String) -> bool: return word.ends_with(",") or word.ends_with(";") or word.ends_with(":") \ or word.ends_with(".") or word.ends_with("?") or word.ends_with("!") func set_choices_pending(pending: bool) -> void: for child in choice_container.get_children(): if child is BaseButton: child.disabled = pending previous_page_button.disabled = pending or choice_page <= 0 next_page_button.disabled = pending or choice_page >= _choice_page_count() - 1 func show_dialogue_choices(options: Array) -> void: hide_dialogue_choices() available_choices = options.duplicate(true) choice_page = 0 _render_choice_page() func _render_choice_page() -> void: _clear_choice_buttons() var page_count := _choice_page_count() if page_count == 0: choice_area.visible = false return choice_page = clampi(choice_page, 0, page_count - 1) var start_index := choice_page * CHOICES_PER_PAGE var end_index := mini(start_index + CHOICES_PER_PAGE, available_choices.size()) for index in range(start_index, end_index): var option: Dictionary = available_choices[index] var choice_id := str(option.get("id", "")) var option_text := str(option.get("text", "")) var button := TextureButton.new() button.set_meta("choice_id", choice_id) button.custom_minimum_size = Vector2(350.0, 136.0) button.ignore_texture_size = true button.stretch_mode = TextureButton.STRETCH_SCALE button.texture_normal = _crop_choice_texture(CHOICE_NORMAL) button.texture_hover = _crop_choice_texture(CHOICE_HOVER) button.texture_pressed = _crop_choice_texture(CHOICE_PRESSED) button.self_modulate = Color(1.0, 1.0, 1.0, 0.86) button.focus_mode = Control.FOCUS_ALL button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND button.mouse_entered.connect(_on_choice_hover.bind(button, true)) button.mouse_exited.connect(_on_choice_hover.bind(button, false)) button.button_down.connect(_on_choice_pressed_visual.bind(button, true)) button.button_up.connect(_on_choice_pressed_visual.bind(button, false)) button.pressed.connect(_on_dialogue_choice_pressed.bind(choice_id)) var label := Label.new() label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) label.offset_left = 48.0 label.offset_top = 12.0 label.offset_right = -48.0 label.offset_bottom = -12.0 label.mouse_filter = Control.MOUSE_FILTER_IGNORE label.add_theme_color_override("font_color", Color(0.055, 0.105, 0.145)) label.add_theme_font_size_override("font_size", _choice_font_size(option_text)) label.text = option_text label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART label.clip_text = true label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER button.add_child(label) choice_container.add_child(button) choice_pager.visible = page_count > 1 page_label.text = "Page %d of %d" % [choice_page + 1, page_count] previous_page_button.disabled = choice_page <= 0 next_page_button.disabled = choice_page >= page_count - 1 choice_area.visible = true choice_container.visible = true func hide_dialogue_choices() -> void: _clear_choice_buttons() available_choices = [] choice_page = 0 choice_container.visible = false choice_pager.visible = false choice_area.visible = false func _clear_choice_buttons() -> void: for child in choice_container.get_children(): choice_container.remove_child(child) child.queue_free() func _choice_page_count() -> int: return ceili(float(available_choices.size()) / float(CHOICES_PER_PAGE)) func _change_choice_page(direction: int) -> void: choice_page = clampi(choice_page + direction, 0, maxi(_choice_page_count() - 1, 0)) _render_choice_page() func _choice_font_size(text: String) -> int: if text.length() > 105: return 15 if text.length() > 75: return 16 return 17 func _crop_choice_texture(source: Texture2D) -> AtlasTexture: var cropped := AtlasTexture.new() cropped.atlas = source cropped.region = CHOICE_CROP cropped.filter_clip = true return cropped func _on_choice_hover(button: TextureButton, hovered: bool) -> void: button.self_modulate = ( Color(1.08, 1.05, 0.96, 0.94) if hovered else Color(1.0, 1.0, 1.0, 0.86) ) func _on_choice_pressed_visual(button: TextureButton, pressed: bool) -> void: button.position.y += 2.0 if pressed else -2.0 func _on_dialogue_choice_pressed(choice_id: String) -> void: if choice_id == "__continue": 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)