527 lines
18 KiB
GDScript
527 lines
18 KiB
GDScript
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 := 130.0
|
|
const DIALOGUE_BUBBLE_MAX_HEIGHT := 240.0
|
|
const DIALOGUE_BUBBLE_VERTICAL_CHROME := 95.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
|
|
@onready var computer_launcher: Button = $ComputerLauncher
|
|
@onready var computer_overlay: Control = $ComputerOverlay
|
|
@onready var computer_close_button: Button = $ComputerOverlay/Close
|
|
|
|
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
|
|
var dialogue_input_enabled := true
|
|
var computer_available := true
|
|
|
|
|
|
func _ready() -> void:
|
|
previous_page_button.pressed.connect(_change_choice_page.bind(-1))
|
|
next_page_button.pressed.connect(_change_choice_page.bind(1))
|
|
computer_launcher.pressed.connect(open_computer)
|
|
computer_close_button.pressed.connect(close_computer)
|
|
_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:
|
|
computer_available = true
|
|
computer_launcher.visible = not computer_overlay.visible
|
|
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 computer_overlay.visible:
|
|
return
|
|
if _event_targets_computer_launcher(event):
|
|
return
|
|
if not dialogue_input_enabled or 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 set_dialogue_input_enabled(enabled: bool) -> void:
|
|
dialogue_input_enabled = enabled
|
|
|
|
|
|
func open_computer() -> void:
|
|
if not computer_available or computer_overlay.visible:
|
|
return
|
|
computer_overlay.visible = true
|
|
computer_launcher.visible = false
|
|
if dialogue_tween and dialogue_tween.is_valid():
|
|
dialogue_tween.pause()
|
|
computer_close_button.grab_focus()
|
|
|
|
|
|
func close_computer() -> bool:
|
|
if not computer_overlay.visible:
|
|
return false
|
|
computer_overlay.visible = false
|
|
computer_launcher.visible = computer_available
|
|
if dialogue_tween and dialogue_tween.is_valid():
|
|
dialogue_tween.play()
|
|
return true
|
|
|
|
|
|
func is_computer_open() -> bool:
|
|
return computer_overlay.visible
|
|
|
|
|
|
func _event_targets_computer_launcher(event: InputEvent) -> bool:
|
|
if not computer_launcher.visible:
|
|
return false
|
|
if event is InputEventMouseButton:
|
|
return event.button_index == MOUSE_BUTTON_LEFT \
|
|
and computer_launcher.get_global_rect().has_point(event.position)
|
|
if event is InputEventScreenTouch:
|
|
return computer_launcher.get_global_rect().has_point(event.position)
|
|
return false
|
|
|
|
|
|
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:
|
|
computer_available = false
|
|
close_computer()
|
|
computer_launcher.visible = false
|
|
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.size = Vector2(870.0, 600.0)
|
|
debrief_panel.position = (size - debrief_panel.size) * 0.5
|
|
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)
|