diff --git a/scenes/ccs_cases_2/doctor_office.tscn b/scenes/ccs_cases_2/doctor_office.tscn index 96c631c..7cfbdf5 100644 --- a/scenes/ccs_cases_2/doctor_office.tscn +++ b/scenes/ccs_cases_2/doctor_office.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=11 format=3] +[gd_scene load_steps=12 format=3] [ext_resource type="Texture2D" path="res://assets/ccs_cases_2/images/doctor/office-backdrop.png" id="1_backdrop"] [ext_resource type="Script" path="res://scripts/ui/ccs_cases_2/doctor_office.gd" id="2_script"] @@ -9,6 +9,7 @@ [ext_resource type="Texture2D" path="res://assets/ccs_cases_2/images/computer/computer-base.png" id="7_computer"] [ext_resource type="Texture2D" path="res://assets/icons/lucide/monitor.svg" id="8_monitor"] [ext_resource type="Texture2D" path="res://assets/icons/lucide/x.svg" id="9_close"] +[ext_resource type="Texture2D" path="res://assets/icons/lucide/heart-pulse.svg" id="10_vitals"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_patient_fit"] shader = ExtResource("5_patient_fit") @@ -272,6 +273,85 @@ texture = ExtResource("7_computer") expand_mode = 1 stretch_mode = 0 +[node name="VitalsLauncher" type="Button" parent="ComputerOverlay"] +layout_mode = 0 +offset_left = 30.0 +offset_top = 32.0 +offset_right = 150.0 +offset_bottom = 142.0 +mouse_default_cursor_shape = 2 +tooltip_text = "View nurse vital signs" +icon = ExtResource("10_vitals") +icon_max_width = 48 +expand_icon = true +text = "Vitals" +vertical_icon_alignment = 0 + +[node name="VitalsWindow" type="PanelContainer" parent="ComputerOverlay"] +visible = false +z_index = 2 +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -360.0 +offset_top = -270.0 +offset_right = 360.0 +offset_bottom = 270.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Margin" type="MarginContainer" parent="ComputerOverlay/VitalsWindow"] +layout_mode = 2 +theme_override_constants/margin_left = 32 +theme_override_constants/margin_top = 24 +theme_override_constants/margin_right = 32 +theme_override_constants/margin_bottom = 28 + +[node name="Content" type="VBoxContainer" parent="ComputerOverlay/VitalsWindow/Margin"] +layout_mode = 2 +theme_override_constants/separation = 20 + +[node name="Header" type="HBoxContainer" parent="ComputerOverlay/VitalsWindow/Margin/Content"] +layout_mode = 2 + +[node name="Title" type="Label" parent="ComputerOverlay/VitalsWindow/Margin/Content/Header"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 30 +text = "Nurse Vital Signs" +vertical_alignment = 1 + +[node name="Close" type="Button" parent="ComputerOverlay/VitalsWindow/Margin/Content/Header"] +custom_minimum_size = Vector2(48, 48) +layout_mode = 2 +mouse_default_cursor_shape = 2 +tooltip_text = "Close vital signs" +icon = ExtResource("9_close") +icon_max_width = 24 +expand_icon = true + +[node name="Divider" type="HSeparator" parent="ComputerOverlay/VitalsWindow/Margin/Content"] +layout_mode = 2 + +[node name="Rows" type="GridContainer" parent="ComputerOverlay/VitalsWindow/Margin/Content"] +layout_mode = 2 +size_flags_vertical = 3 +theme_override_constants/h_separation = 40 +theme_override_constants/v_separation = 12 +columns = 2 + +[node name="Empty" type="Label" parent="ComputerOverlay/VitalsWindow/Margin/Content"] +visible = false +layout_mode = 2 +size_flags_vertical = 3 +theme_override_font_sizes/font_size = 20 +text = "No nurse vital signs are available." +horizontal_alignment = 1 +vertical_alignment = 1 + [node name="Close" type="Button" parent="ComputerOverlay"] layout_mode = 1 anchors_preset = 1 diff --git a/scripts/ui/ccs_cases_2/doctor_office.gd b/scripts/ui/ccs_cases_2/doctor_office.gd index 6ac6de3..327f5c6 100644 --- a/scripts/ui/ccs_cases_2/doctor_office.gd +++ b/scripts/ui/ccs_cases_2/doctor_office.gd @@ -32,6 +32,11 @@ const DIALOGUE_BUBBLE_VERTICAL_CHROME := 95.0 @onready var computer_launcher: Button = $ComputerLauncher @onready var computer_overlay: Control = $ComputerOverlay @onready var computer_close_button: Button = $ComputerOverlay/Close +@onready var vitals_launcher: Button = $ComputerOverlay/VitalsLauncher +@onready var vitals_window: PanelContainer = $ComputerOverlay/VitalsWindow +@onready var vitals_close_button: Button = $ComputerOverlay/VitalsWindow/Margin/Content/Header/Close +@onready var vitals_rows: GridContainer = $ComputerOverlay/VitalsWindow/Margin/Content/Rows +@onready var vitals_empty: Label = $ComputerOverlay/VitalsWindow/Margin/Content/Empty var blink_tween: Tween var dialogue_tween: Tween @@ -55,6 +60,11 @@ func _ready() -> void: next_page_button.pressed.connect(_change_choice_page.bind(1)) computer_launcher.pressed.connect(open_computer) computer_close_button.pressed.connect(close_computer) + vitals_launcher.pressed.connect(open_vitals) + vitals_close_button.pressed.connect(close_vitals) + resized.connect(_resize_vitals_window) + _style_vitals_window() + _resize_vitals_window() _update_clock() _schedule_patient_blink() hide_patient_dialogue() @@ -77,7 +87,9 @@ func _update_clock() -> void: second_hand.rotation = TAU * second / 60.0 -func configure_presentation(presentation: Dictionary, simulated_start: String, simulated_minute: int) -> void: +func configure_presentation( + presentation: Dictionary, simulated_start: String, simulated_minute: int, + vitals: Dictionary = {}) -> 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) @@ -85,6 +97,7 @@ func configure_presentation(presentation: Dictionary, simulated_start: String, s 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() + _render_vitals(vitals) func configure_experience_mode(value: String) -> void: @@ -266,6 +279,7 @@ func open_computer() -> void: func close_computer() -> bool: if not computer_overlay.visible: return false + close_vitals() computer_overlay.visible = false computer_launcher.visible = computer_available if dialogue_tween and dialogue_tween.is_valid(): @@ -277,6 +291,126 @@ func is_computer_open() -> bool: return computer_overlay.visible +func close_transient_overlay() -> bool: + if close_vitals(): + return true + return close_computer() + + +func open_vitals() -> void: + if not computer_overlay.visible or vitals_window.visible: + return + vitals_window.visible = true + vitals_launcher.visible = false + vitals_close_button.grab_focus() + + +func close_vitals() -> bool: + if not vitals_window.visible: + return false + vitals_window.visible = false + vitals_launcher.visible = true + return true + + +func is_vitals_open() -> bool: + return vitals_window.visible + + +func _render_vitals(vitals: Dictionary) -> void: + for child in vitals_rows.get_children(): + vitals_rows.remove_child(child) + child.queue_free() + var rows := _build_vital_rows(vitals) + for row in rows: + var name_label := Label.new() + name_label.add_theme_font_size_override("font_size", 19) + name_label.add_theme_color_override("font_color", Color(0.18, 0.25, 0.31)) + name_label.text = "%s:" % str(row.get("label", "Vital sign")) + vitals_rows.add_child(name_label) + var value_label := Label.new() + value_label.add_theme_font_size_override("font_size", 19) + value_label.add_theme_color_override("font_color", Color(0.04, 0.09, 0.13)) + value_label.text = str(row.get("value", "")) + value_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + vitals_rows.add_child(value_label) + vitals_rows.visible = not rows.is_empty() + vitals_empty.visible = rows.is_empty() + + +func _build_vital_rows(vitals: Dictionary) -> Array[Dictionary]: + var rows: Array[Dictionary] = [] + var displayed := {} + _append_vital(rows, displayed, vitals, "temperature", "Temperature") + _append_vital(rows, displayed, vitals, "pulse", "Pulse") + _append_vital(rows, displayed, vitals, "respirations", "Respiratory rate") + var blood_pressure := str(vitals.get("bloodPressure", "")).strip_edges() + if not blood_pressure.is_empty(): + displayed["bloodPressure"] = true + var pattern := RegEx.create_from_string("^\\s*([^/]+)\\s*/\\s*([0-9.]+)(.*)$") + var matched := pattern.search(blood_pressure) + if matched: + var suffix := matched.get_string(3).strip_edges() + var unit_suffix := " %s" % suffix if not suffix.is_empty() else "" + rows.append({"label": "Blood pressure, systolic", "value": matched.get_string(1).strip_edges() + unit_suffix}) + rows.append({"label": "Blood pressure, diastolic", "value": matched.get_string(2).strip_edges() + unit_suffix}) + else: + rows.append({"label": "Blood pressure", "value": blood_pressure}) + _append_vital(rows, displayed, vitals, "oxygenSaturation", "Oxygen saturation") + _append_vital(rows, displayed, vitals, "height", "Height") + _append_vital(rows, displayed, vitals, "weight", "Weight") + _append_vital(rows, displayed, vitals, "bmi", "Body Mass Index") + var remaining_keys: Array = vitals.keys() + remaining_keys.sort() + for raw_key in remaining_keys: + var key := str(raw_key) + if not displayed.has(key): + _append_vital(rows, displayed, vitals, key, _friendly_vital_name(key)) + return rows + + +func _append_vital( + rows: Array[Dictionary], displayed: Dictionary, vitals: Dictionary, + key: String, label: String) -> void: + displayed[key] = true + var value := str(vitals.get(key, "")).strip_edges() + if not value.is_empty(): + rows.append({"label": label, "value": value}) + + +func _friendly_vital_name(key: String) -> String: + var result := "" + for index in key.length(): + var character := key[index] + if index > 0 and character == character.to_upper() and character != character.to_lower(): + result += " " + result += character + return result.capitalize() + + +func _style_vitals_window() -> void: + var panel_style := StyleBoxFlat.new() + panel_style.bg_color = Color(0.97, 0.98, 0.99, 0.98) + panel_style.border_color = Color(0.12, 0.24, 0.34, 0.9) + panel_style.set_border_width_all(3) + panel_style.set_corner_radius_all(14) + panel_style.shadow_color = Color(0.0, 0.0, 0.0, 0.35) + panel_style.shadow_size = 18 + vitals_window.add_theme_stylebox_override("panel", panel_style) + + +func _resize_vitals_window() -> void: + if vitals_window == null: + return + var window_size := Vector2( + minf(720.0, maxf(320.0, size.x - 48.0)), + minf(540.0, maxf(360.0, size.y - 48.0))) + vitals_window.offset_left = -window_size.x * 0.5 + vitals_window.offset_top = -window_size.y * 0.5 + vitals_window.offset_right = window_size.x * 0.5 + vitals_window.offset_bottom = window_size.y * 0.5 + + func _event_targets_computer_launcher(event: InputEvent) -> bool: if not computer_launcher.visible: return false @@ -340,14 +474,18 @@ func _render_choice_page() -> void: 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()) + var visible_choice_count := end_index - start_index 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(0.0, 136.0) - button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.custom_minimum_size = Vector2( + 0.0 if visible_choice_count == CHOICES_PER_PAGE else 350.0, 136.0) + button.size_flags_horizontal = ( + Control.SIZE_EXPAND_FILL if visible_choice_count == CHOICES_PER_PAGE + else Control.SIZE_SHRINK_CENTER) button.ignore_texture_size = true button.stretch_mode = TextureButton.STRETCH_SCALE button.texture_normal = _crop_choice_texture(CHOICE_NORMAL) diff --git a/scripts/ui/ccs_cases_2_screen.gd b/scripts/ui/ccs_cases_2_screen.gd index 2fdc5e7..0a5b495 100644 --- a/scripts/ui/ccs_cases_2_screen.gd +++ b/scripts/ui/ccs_cases_2_screen.gd @@ -71,8 +71,8 @@ func set_pause_menu_open(open: bool) -> void: func close_transient_overlay() -> bool: - if active_scene != null and active_scene.has_method("close_computer"): - return bool(active_scene.close_computer()) + if active_scene != null and active_scene.has_method("close_transient_overlay"): + return bool(active_scene.close_transient_overlay()) return false @@ -234,7 +234,8 @@ func _show_doctor_office(node: Dictionary, attempt: Dictionary = {}) -> void: active_scene.configure_presentation( attempt.get("graphicalPresentation", {}), str(attempt.get("simulatedStartUtc", "")), - int(attempt.get("simulatedMinute", 0))) + int(attempt.get("simulatedMinute", 0)), + attempt.get("vitals", {})) active_scene.configure_interview(node) active_scene.set_choices_pending(not pending_action.is_empty()) @@ -268,7 +269,8 @@ func _render_debrief(attempt: Dictionary) -> void: active_scene.configure_presentation( attempt.get("graphicalPresentation", {}), str(attempt.get("simulatedStartUtc", "")), - int(attempt.get("simulatedMinute", 0))) + int(attempt.get("simulatedMinute", 0)), + attempt.get("vitals", {})) active_scene.show_debrief(attempt.get("interviewDebrief", {}), attempt.get("score", {})) current_view = "interview_debrief" diff --git a/tests/ccs_cases_2_ui_test.gd b/tests/ccs_cases_2_ui_test.gd index 7f8026e..701de5f 100644 --- a/tests/ccs_cases_2_ui_test.gd +++ b/tests/ccs_cases_2_ui_test.gd @@ -92,9 +92,15 @@ func _run() -> void: _assert(not screen.active_scene.computer_launcher.visible, "the launcher should hide while the computer is open") _assert(screen.active_scene.computer_overlay.get_node("Screen").texture.resource_path.ends_with("computer-base.png"), "the overlay should render the supplied computer base") _assert(screen.active_scene.computer_overlay.get_node("Screen").stretch_mode == TextureRect.STRETCH_SCALE, "the computer should fill the viewport without side bars") - _assert(screen.close_transient_overlay(), "Escape routing should close the computer before the pause menu") + _assert(screen.active_scene.vitals_launcher.visible, "the computer desktop should expose a Vitals app") + screen.active_scene.vitals_launcher.pressed.emit() + _assert(screen.active_scene.is_vitals_open(), "the Vitals app should open a nurse vital-sign window") + _assert(_vital_values(screen.active_scene) == ["36.8 C (98.2 F)", "76/min", "14/min", "122 mm Hg", "76 mm Hg", "99% on room air"], "the Vitals app should render the active attempt data in clinical order") + _assert(screen.close_transient_overlay(), "the first Escape should close the Vitals app") + _assert(not screen.active_scene.is_vitals_open() and screen.active_scene.is_computer_open(), "closing Vitals should return to the computer desktop") + _assert(screen.close_transient_overlay(), "the second Escape should close the computer") _assert(not screen.active_scene.is_computer_open(), "closing the transient overlay should restore the doctor office") - _assert(not screen.close_transient_overlay(), "a second Escape should be available to open the pause menu") + _assert(not screen.close_transient_overlay(), "the third Escape should be available to open the pause menu") _assert(is_equal_approx(screen.DESIGN_SIZE.x / screen.DESIGN_SIZE.y, 16.0 / 9.0), "the graphical CCS stage should use a true 16:9 design canvas") _assert(screen.stage_root.size.is_equal_approx(screen.size), "the graphical stage should fill the available viewport") _assert(screen.stage_root.position.is_equal_approx(Vector2.ZERO), "the graphical stage should not introduce letterbox margins") @@ -173,6 +179,8 @@ func _run() -> void: screen.active_scene.advance_dialogue() _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") + var terminal_choice := screen.active_scene.choice_container.get_child(0) as Control + _assert(terminal_choice.size.x <= 360.0, "a single terminal choice should remain a compact centered card") 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") @@ -231,6 +239,10 @@ func _sample_attempt(completed: bool) -> Dictionary: "id": "attempt-1", "state": "active", "revision": 1, "simulatedStartUtc": "2026-08-05T09:00:00Z", "simulatedMinute": 0, "graphicalPresentation": {"sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male"}, + "vitals": { + "temperature": "36.8 C (98.2 F)", "bloodPressure": "122/76 mm Hg", + "pulse": "76/min", "respirations": "14/min", "oxygenSaturation": "99% on room air" + }, "openingCompleted": completed, "interview": {"currentNodeId": node.id, "currentNode": node, "visitedChoiceIds": [], "completed": completed} } @@ -288,6 +300,13 @@ func _visible_choice_ids(scene: Control) -> Array: return ids +func _vital_values(scene: Control) -> Array: + var values: Array = [] + for index in range(1, scene.vitals_rows.get_child_count(), 2): + values.append(scene.vitals_rows.get_child(index).text) + return values + + func _choice_labels_fit(scene: Control) -> bool: for button in scene.choice_container.get_children(): var label := button.get_child(0) as Label