Improve CCS dialogue layout

This commit is contained in:
hz
2026-08-06 04:41:44 -05:00
parent 4792ff2620
commit ee8e8cf787
4 changed files with 205 additions and 37 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

+55 -16
View File
@@ -105,12 +105,12 @@ antialiased = true
z_index = 30 z_index = 30
layout_mode = 1 layout_mode = 1
anchors_preset = 0 anchors_preset = 0
anchor_left = 0.51 anchor_left = 0.53
anchor_top = 0.055 anchor_top = 0.04
anchor_right = 0.51 anchor_right = 0.53
anchor_bottom = 0.055 anchor_bottom = 0.04
offset_right = 420.0 offset_right = 650.0
offset_bottom = 140.0 offset_bottom = 220.0
mouse_filter = 2 mouse_filter = 2
[node name="Background" type="TextureRect" parent="DialogueBubble"] [node name="Background" type="TextureRect" parent="DialogueBubble"]
@@ -134,7 +134,7 @@ anchor_bottom = 1.0
offset_left = 76.0 offset_left = 76.0
offset_top = 31.0 offset_top = 31.0
offset_right = -48.0 offset_right = -48.0
offset_bottom = -43.0 offset_bottom = -31.0
grow_horizontal = 2 grow_horizontal = 2
grow_vertical = 2 grow_vertical = 2
theme_override_colors/font_color = Color(0.055, 0.105, 0.145, 1) theme_override_colors/font_color = Color(0.055, 0.105, 0.145, 1)
@@ -143,17 +143,56 @@ text = "Hello, doctor."
autowrap_mode = 2 autowrap_mode = 2
vertical_alignment = 1 vertical_alignment = 1
[node name="DialogueChoices" type="GridContainer" parent="."] [node name="DialogueChoiceArea" type="Control" parent="."]
z_index = 29 z_index = 29
layout_mode = 1 layout_mode = 1
anchors_preset = 0 anchors_preset = 12
anchor_left = 0.5
anchor_top = 1.0 anchor_top = 1.0
anchor_right = 0.5 anchor_right = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
offset_left = -450.0 offset_left = 18.0
offset_top = -206.0 offset_top = -194.0
offset_right = 450.0 offset_right = -18.0
offset_bottom = -4.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
+89 -17
View File
@@ -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_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_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 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 hour_hand: Node2D = $ClockMount/HourHand
@onready var minute_hand: Node2D = $ClockMount/MinuteHand @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 patient: TextureRect = $Patient
@onready var dialogue_bubble: Control = $DialogueBubble @onready var dialogue_bubble: Control = $DialogueBubble
@onready var dialogue_text: Label = $DialogueBubble/Text @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 blink_tween: Tween
var dialogue_tween: Tween var dialogue_tween: Tween
var response_hold_tween: Tween
var clock_seconds := 9.0 * 60.0 * 60.0 var clock_seconds := 9.0 * 60.0 * 60.0
var debrief_panel: PanelContainer var debrief_panel: PanelContainer
var experience_mode := "management" var experience_mode := "management"
var pending_choices: Array = [] var pending_choices: Array = []
var available_choices: Array = []
var choice_page := 0
func _ready() -> void: 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() _update_clock()
_schedule_patient_blink() _schedule_patient_blink()
hide_patient_dialogue() 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: func show_patient_dialogue(text: String, characters_per_second: float = 35.0) -> void:
if dialogue_tween and dialogue_tween.is_valid(): if dialogue_tween and dialogue_tween.is_valid():
dialogue_tween.kill() dialogue_tween.kill()
if response_hold_tween and response_hold_tween.is_valid():
response_hold_tween.kill()
dialogue_text.text = text dialogue_text.text = text
dialogue_text.visible_ratio = 0.0 dialogue_text.visible_ratio = 0.0
dialogue_bubble.visible = true dialogue_bubble.visible = true
_resize_dialogue_bubble() call_deferred("_resize_dialogue_bubble")
var reveal_duration := float(text.length()) / maxf(characters_per_second, 1.0) var reveal_duration := float(text.length()) / maxf(characters_per_second, 1.0)
dialogue_tween = create_tween() dialogue_tween = create_tween()
dialogue_tween.tween_property(dialogue_text, "visible_ratio", 1.0, reveal_duration) 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: func hide_patient_dialogue() -> void:
if dialogue_tween and dialogue_tween.is_valid(): if dialogue_tween and dialogue_tween.is_valid():
dialogue_tween.kill() dialogue_tween.kill()
if response_hold_tween and response_hold_tween.is_valid():
response_hold_tween.kill()
dialogue_bubble.visible = false dialogue_bubble.visible = false
pending_choices = [] pending_choices = []
@@ -105,10 +121,8 @@ func is_patient_dialogue_visible() -> bool:
func _resize_dialogue_bubble() -> void: 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 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 dialogue_bubble.size.y = target_height
@@ -142,8 +156,20 @@ func complete_dialogue_reveal() -> void:
func _on_dialogue_reveal_finished() -> void: func _on_dialogue_reveal_finished() -> void:
dialogue_tween = null 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 var choices := pending_choices
pending_choices = [] pending_choices = []
dialogue_bubble.visible = false
show_dialogue_choices(choices) show_dialogue_choices(choices)
@@ -151,19 +177,33 @@ func set_choices_pending(pending: bool) -> void:
for child in choice_container.get_children(): for child in choice_container.get_children():
if child is BaseButton: if child is BaseButton:
child.disabled = pending 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: func show_dialogue_choices(options: Array) -> void:
hide_dialogue_choices() hide_dialogue_choices()
var row_count := ceili(float(options.size()) / 2.0) available_choices = options.duplicate(true)
var stack_height := row_count * 97.0 + 8.0 choice_page = 0
choice_container.offset_top = -4.0 - stack_height _render_choice_page()
for index in options.size():
var option: Dictionary = options[index]
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 choice_id := str(option.get("id", ""))
var option_text := str(option.get("text", "")) var option_text := str(option.get("text", ""))
var button := TextureButton.new() 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.ignore_texture_size = true
button.stretch_mode = TextureButton.STRETCH_SCALE button.stretch_mode = TextureButton.STRETCH_SCALE
button.texture_normal = _crop_choice_texture(CHOICE_NORMAL) button.texture_normal = _crop_choice_texture(CHOICE_NORMAL)
@@ -181,24 +221,56 @@ func show_dialogue_choices(options: Array) -> void:
var label := Label.new() var label := Label.new()
label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
label.offset_left = 48.0 label.offset_left = 48.0
label.offset_top = 14.0 label.offset_top = 12.0
label.offset_right = -48.0 label.offset_right = -48.0
label.offset_bottom = -14.0 label.offset_bottom = -12.0
label.mouse_filter = Control.MOUSE_FILTER_IGNORE label.mouse_filter = Control.MOUSE_FILTER_IGNORE
label.add_theme_color_override("font_color", Color(0.055, 0.105, 0.145)) 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.text = option_text
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.clip_text = true
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
button.add_child(label) button.add_child(label)
choice_container.add_child(button) 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: func hide_dialogue_choices() -> void:
for child in choice_container.get_children(): _clear_choice_buttons()
child.queue_free() available_choices = []
choice_page = 0
choice_container.visible = false 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: func _crop_choice_texture(source: Texture2D) -> AtlasTexture:
+61 -4
View File
@@ -74,9 +74,25 @@ func _run() -> void:
await process_frame await process_frame
_assert(screen.active_scene.is_patient_dialogue_visible(), "a selected topic should display the patient's response") _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(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() screen.active_scene.complete_dialogue_reveal()
_assert(screen.active_scene.choice_container.columns == 2, "branching interview choices should use two columns") _assert(not screen.active_scene.choice_container.visible, "the completed response should remain visible during the short reading hold")
_assert(screen.active_scene.choice_container.get_child_count() == 8, "all available branching topics should render") 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) service.attempt = _sample_attempt(true)
screen._on_attempt_changed() 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(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") _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_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(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() 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") _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: 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 = [] var choices: Array = []
for index in 8: 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 { return {
"id": "attempt-1", "state": "active", "revision": 2, "id": "attempt-1", "state": "active", "revision": 2,
"simulatedStartUtc": "2026-08-05T09:00:00Z", "simulatedMinute": 0, "simulatedStartUtc": "2026-08-05T09:00:00Z", "simulatedMinute": 0,
@@ -153,12 +181,41 @@ func _sample_branching_attempt() -> Dictionary:
"openingCompleted": false, "openingCompleted": false,
"interview": { "interview": {
"currentNodeId": "chief_concern", "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 "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: func _assert(condition: bool, message: String) -> void:
if condition: if condition:
return return