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
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
+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_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:
+61 -4
View File
@@ -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