Improve CCS dialogue layout
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
@@ -105,12 +105,12 @@ antialiased = true
|
||||
z_index = 30
|
||||
layout_mode = 1
|
||||
anchors_preset = 0
|
||||
anchor_left = 0.51
|
||||
anchor_top = 0.055
|
||||
anchor_right = 0.51
|
||||
anchor_bottom = 0.055
|
||||
offset_right = 420.0
|
||||
offset_bottom = 140.0
|
||||
anchor_left = 0.53
|
||||
anchor_top = 0.04
|
||||
anchor_right = 0.53
|
||||
anchor_bottom = 0.04
|
||||
offset_right = 650.0
|
||||
offset_bottom = 220.0
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="Background" type="TextureRect" parent="DialogueBubble"]
|
||||
@@ -134,7 +134,7 @@ anchor_bottom = 1.0
|
||||
offset_left = 76.0
|
||||
offset_top = 31.0
|
||||
offset_right = -48.0
|
||||
offset_bottom = -43.0
|
||||
offset_bottom = -31.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_colors/font_color = Color(0.055, 0.105, 0.145, 1)
|
||||
@@ -143,17 +143,56 @@ text = "Hello, doctor."
|
||||
autowrap_mode = 2
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="DialogueChoices" type="GridContainer" parent="."]
|
||||
[node name="DialogueChoiceArea" type="Control" parent="."]
|
||||
z_index = 29
|
||||
layout_mode = 1
|
||||
anchors_preset = 0
|
||||
anchor_left = 0.5
|
||||
anchors_preset = 12
|
||||
anchor_top = 1.0
|
||||
anchor_right = 0.5
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -450.0
|
||||
offset_top = -206.0
|
||||
offset_right = 450.0
|
||||
offset_left = 18.0
|
||||
offset_top = -194.0
|
||||
offset_right = -18.0
|
||||
offset_bottom = -4.0
|
||||
theme_override_constants/separation = -8
|
||||
columns = 2
|
||||
|
||||
[node name="ChoicePager" type="HBoxContainer" parent="DialogueChoiceArea"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 10
|
||||
anchor_right = 1.0
|
||||
offset_bottom = 42.0
|
||||
grow_horizontal = 2
|
||||
theme_override_constants/separation = 12
|
||||
alignment = 1
|
||||
|
||||
[node name="Previous" type="Button" parent="DialogueChoiceArea/ChoicePager"]
|
||||
custom_minimum_size = Vector2(108, 38)
|
||||
layout_mode = 2
|
||||
text = "Previous"
|
||||
|
||||
[node name="Page" type="Label" parent="DialogueChoiceArea/ChoicePager"]
|
||||
custom_minimum_size = Vector2(100, 38)
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.94, 0.95, 0.91, 1)
|
||||
theme_override_colors/font_shadow_color = Color(0.02, 0.035, 0.05, 0.9)
|
||||
theme_override_constants/shadow_offset_x = 1
|
||||
theme_override_constants/shadow_offset_y = 2
|
||||
theme_override_font_sizes/font_size = 17
|
||||
text = "Page 1 of 2"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="Next" type="Button" parent="DialogueChoiceArea/ChoicePager"]
|
||||
custom_minimum_size = Vector2(108, 38)
|
||||
layout_mode = 2
|
||||
text = "Next"
|
||||
|
||||
[node name="DialogueChoices" type="HBoxContainer" parent="DialogueChoiceArea"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_top = 46.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/separation = 10
|
||||
alignment = 1
|
||||
|
||||
@@ -9,6 +9,8 @@ const CHOICE_NORMAL := preload("res://assets/ccs_cases_2/images/doctor/dialogue-
|
||||
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 RESPONSE_HOLD_SECONDS := 1.25
|
||||
|
||||
@onready var hour_hand: Node2D = $ClockMount/HourHand
|
||||
@onready var minute_hand: Node2D = $ClockMount/MinuteHand
|
||||
@@ -16,17 +18,27 @@ 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: GridContainer = $DialogueChoices
|
||||
@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 response_hold_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
|
||||
|
||||
|
||||
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()
|
||||
@@ -83,10 +95,12 @@ func _set_patient_blink(amount: float) -> void:
|
||||
func show_patient_dialogue(text: String, characters_per_second: float = 35.0) -> void:
|
||||
if dialogue_tween and dialogue_tween.is_valid():
|
||||
dialogue_tween.kill()
|
||||
if response_hold_tween and response_hold_tween.is_valid():
|
||||
response_hold_tween.kill()
|
||||
dialogue_text.text = text
|
||||
dialogue_text.visible_ratio = 0.0
|
||||
dialogue_bubble.visible = true
|
||||
_resize_dialogue_bubble()
|
||||
call_deferred("_resize_dialogue_bubble")
|
||||
var reveal_duration := float(text.length()) / maxf(characters_per_second, 1.0)
|
||||
dialogue_tween = create_tween()
|
||||
dialogue_tween.tween_property(dialogue_text, "visible_ratio", 1.0, reveal_duration)
|
||||
@@ -96,6 +110,8 @@ func show_patient_dialogue(text: String, characters_per_second: float = 35.0) ->
|
||||
func hide_patient_dialogue() -> void:
|
||||
if dialogue_tween and dialogue_tween.is_valid():
|
||||
dialogue_tween.kill()
|
||||
if response_hold_tween and response_hold_tween.is_valid():
|
||||
response_hold_tween.kill()
|
||||
dialogue_bubble.visible = false
|
||||
pending_choices = []
|
||||
|
||||
@@ -105,10 +121,8 @@ func is_patient_dialogue_visible() -> bool:
|
||||
|
||||
|
||||
func _resize_dialogue_bubble() -> void:
|
||||
# Label wrapping is calculated from the fixed text width immediately after
|
||||
# assignment, so explicit newlines and automatic wraps both affect height.
|
||||
var line_count := maxi(dialogue_text.get_line_count(), 1)
|
||||
var target_height := clampf(110.0 + line_count * 30.0, 140.0, 320.0)
|
||||
var target_height := clampf(82.0 + line_count * 29.0, 150.0, 310.0)
|
||||
dialogue_bubble.size.y = target_height
|
||||
|
||||
|
||||
@@ -142,8 +156,20 @@ func complete_dialogue_reveal() -> void:
|
||||
|
||||
func _on_dialogue_reveal_finished() -> void:
|
||||
dialogue_tween = null
|
||||
if response_hold_tween and response_hold_tween.is_valid():
|
||||
response_hold_tween.kill()
|
||||
response_hold_tween = create_tween()
|
||||
response_hold_tween.tween_interval(RESPONSE_HOLD_SECONDS)
|
||||
response_hold_tween.tween_callback(complete_response_transition)
|
||||
|
||||
|
||||
func complete_response_transition() -> void:
|
||||
if response_hold_tween and response_hold_tween.is_valid():
|
||||
response_hold_tween.kill()
|
||||
response_hold_tween = null
|
||||
var choices := pending_choices
|
||||
pending_choices = []
|
||||
dialogue_bubble.visible = false
|
||||
show_dialogue_choices(choices)
|
||||
|
||||
|
||||
@@ -151,19 +177,33 @@ 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()
|
||||
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]
|
||||
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.custom_minimum_size = Vector2(440.0, 105.0)
|
||||
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)
|
||||
@@ -181,24 +221,56 @@ func show_dialogue_choices(options: Array) -> void:
|
||||
var label := Label.new()
|
||||
label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
label.offset_left = 48.0
|
||||
label.offset_top = 14.0
|
||||
label.offset_top = 12.0
|
||||
label.offset_right = -48.0
|
||||
label.offset_bottom = -14.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", 19)
|
||||
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_container.visible = not options.is_empty()
|
||||
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:
|
||||
for child in choice_container.get_children():
|
||||
child.queue_free()
|
||||
_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:
|
||||
|
||||
@@ -74,9 +74,25 @@ func _run() -> void:
|
||||
await process_frame
|
||||
_assert(screen.active_scene.is_patient_dialogue_visible(), "a selected topic should display the patient's response")
|
||||
_assert(not screen.active_scene.choice_container.visible, "the next choices should wait until the patient finishes speaking")
|
||||
_assert(_label_content_fits(screen.active_scene.dialogue_text), "the complete patient response should fit inside its speech bubble")
|
||||
screen.active_scene.complete_dialogue_reveal()
|
||||
_assert(screen.active_scene.choice_container.columns == 2, "branching interview choices should use two columns")
|
||||
_assert(screen.active_scene.choice_container.get_child_count() == 8, "all available branching topics should render")
|
||||
_assert(not screen.active_scene.choice_container.visible, "the completed response should remain visible during the short reading hold")
|
||||
await create_timer(screen.active_scene.RESPONSE_HOLD_SECONDS + 0.1).timeout
|
||||
await process_frame
|
||||
_assert(not screen.active_scene.is_patient_dialogue_visible(), "questions should replace the patient response after the reading hold")
|
||||
_assert(screen.active_scene.choice_container.get_child_count() == 4, "the first question page should contain four choices")
|
||||
_assert(screen.active_scene.choice_pager.visible, "eight choices should display paging controls")
|
||||
_assert(screen.active_scene.choice_page == 0, "new question sets should begin on the first page")
|
||||
_assert(_visible_choice_ids(screen.active_scene) == ["topic_0", "topic_1", "topic_2", "topic_3"], "the first page should preserve choice order and IDs")
|
||||
_assert(_choice_labels_fit(screen.active_scene), "first-page question text should fit within every button")
|
||||
screen.active_scene._change_choice_page(1)
|
||||
await process_frame
|
||||
_assert(screen.active_scene.choice_page == 1, "Next should advance to the second question page")
|
||||
_assert(screen.active_scene.choice_container.get_child_count() == 4, "the second question page should contain the remaining choices")
|
||||
_assert(_visible_choice_ids(screen.active_scene) == ["topic_4", "topic_5", "topic_6", "topic_7"], "the second page should preserve choice order and IDs")
|
||||
_assert(_choice_labels_fit(screen.active_scene), "second-page question text should fit within every button")
|
||||
screen.active_scene._change_choice_page(-1)
|
||||
_assert(screen.active_scene.choice_page == 0, "Previous should return to the first question page")
|
||||
|
||||
service.attempt = _sample_attempt(true)
|
||||
screen._on_attempt_changed()
|
||||
@@ -84,7 +100,9 @@ func _run() -> void:
|
||||
_assert(screen.active_scene.dialogue_text.text == "Okay, doctor.", "attempt updates should advance the rendered patient response")
|
||||
_assert(not screen.active_scene.choice_container.visible, "the terminal action should wait for the closing response")
|
||||
screen.active_scene.complete_dialogue_reveal()
|
||||
screen.active_scene.complete_response_transition()
|
||||
_assert(screen.active_scene.choice_container.get_child_count() == 1, "terminal dialogue should expose the management handoff")
|
||||
_assert(not screen.active_scene.choice_pager.visible, "a single terminal action should not show paging controls")
|
||||
screen.active_scene.interview_continue_requested.emit()
|
||||
_assert(host.shown_screen == "ccs_player", "terminal dialogue should hand off the same attempt to the CCS player")
|
||||
|
||||
@@ -143,9 +161,19 @@ func _sample_graphical_debrief() -> Dictionary:
|
||||
|
||||
|
||||
func _sample_branching_attempt() -> Dictionary:
|
||||
var option_texts := [
|
||||
"Tell me more about the dizziness. Does the room spin, and have you fainted?",
|
||||
"How has the fatigue affected you, and how have you been sleeping and eating?",
|
||||
"You mentioned your stomach. Please describe the discomfort and when it occurs.",
|
||||
"Have you had black stools, blood in vomit or stool, trouble swallowing, persistent vomiting, weight loss, or severe sudden pain?",
|
||||
"What medicines do you take, including aspirin, ibuprofen, naproxen, or other over-the-counter products?",
|
||||
"Do you have prior stomach problems, other medical conditions, surgeries, or medication allergies?",
|
||||
"Do you smoke, drink alcohol, or use recreational drugs?",
|
||||
"Any fever, shortness of breath, headaches, bowel changes, or urinary symptoms?"
|
||||
]
|
||||
var choices: Array = []
|
||||
for index in 8:
|
||||
choices.append({"id": "topic_%d" % index, "text": "Ask about history topic %d" % (index + 1), "nextNodeId": "topic_%d" % index})
|
||||
choices.append({"id": "topic_%d" % index, "text": option_texts[index], "nextNodeId": "topic_%d" % index})
|
||||
return {
|
||||
"id": "attempt-1", "state": "active", "revision": 2,
|
||||
"simulatedStartUtc": "2026-08-05T09:00:00Z", "simulatedMinute": 0,
|
||||
@@ -153,12 +181,41 @@ func _sample_branching_attempt() -> Dictionary:
|
||||
"openingCompleted": false,
|
||||
"interview": {
|
||||
"currentNodeId": "chief_concern",
|
||||
"currentNode": {"id": "chief_concern", "patientText": "I feel tired and lightheaded.", "isTerminal": false, "choices": choices},
|
||||
"currentNode": {
|
||||
"id": "chief_concern",
|
||||
"patientText": "For the past few weeks, I have felt worn out, and every so often I get lightheaded—especially when I stand up quickly.",
|
||||
"isTerminal": false,
|
||||
"choices": choices
|
||||
},
|
||||
"visitedChoiceIds": ["chief_concern"], "completed": false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func _visible_choice_ids(scene: Control) -> Array:
|
||||
var ids: Array = []
|
||||
for child in scene.choice_container.get_children():
|
||||
ids.append(str(child.get_meta("choice_id", "")))
|
||||
return ids
|
||||
|
||||
|
||||
func _choice_labels_fit(scene: Control) -> bool:
|
||||
for button in scene.choice_container.get_children():
|
||||
var label := button.get_child(0) as Label
|
||||
if label == null or not _label_content_fits(label):
|
||||
return false
|
||||
if not scene.choice_area.get_global_rect().encloses(button.get_global_rect()):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _label_content_fits(label: Label) -> bool:
|
||||
var font := label.get_theme_font("font")
|
||||
var font_size := label.get_theme_font_size("font_size")
|
||||
var content_height := float(label.get_line_count()) * font.get_height(font_size)
|
||||
return content_height <= label.size.y + 0.5
|
||||
|
||||
|
||||
func _assert(condition: bool, message: String) -> void:
|
||||
if condition:
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user