Add CCS computer vitals app

This commit is contained in:
hz
2026-08-07 22:44:30 -05:00
parent 2b25e25424
commit 065db96211
4 changed files with 249 additions and 10 deletions
+81 -1
View File
@@ -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="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"] [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/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/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/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"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_patient_fit"]
shader = ExtResource("5_patient_fit") shader = ExtResource("5_patient_fit")
@@ -272,6 +273,85 @@ texture = ExtResource("7_computer")
expand_mode = 1 expand_mode = 1
stretch_mode = 0 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"] [node name="Close" type="Button" parent="ComputerOverlay"]
layout_mode = 1 layout_mode = 1
anchors_preset = 1 anchors_preset = 1
+141 -3
View File
@@ -32,6 +32,11 @@ const DIALOGUE_BUBBLE_VERTICAL_CHROME := 95.0
@onready var computer_launcher: Button = $ComputerLauncher @onready var computer_launcher: Button = $ComputerLauncher
@onready var computer_overlay: Control = $ComputerOverlay @onready var computer_overlay: Control = $ComputerOverlay
@onready var computer_close_button: Button = $ComputerOverlay/Close @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 blink_tween: Tween
var dialogue_tween: Tween var dialogue_tween: Tween
@@ -55,6 +60,11 @@ func _ready() -> void:
next_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_launcher.pressed.connect(open_computer)
computer_close_button.pressed.connect(close_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() _update_clock()
_schedule_patient_blink() _schedule_patient_blink()
hide_patient_dialogue() hide_patient_dialogue()
@@ -77,7 +87,9 @@ func _update_clock() -> void:
second_hand.rotation = TAU * second / 60.0 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": if str(presentation.get("patientAssetKey", "")) != "patient_44_white_male":
push_warning("Unsupported CCS patient asset key; using the office default.") push_warning("Unsupported CCS patient asset key; using the office default.")
var date_time := Time.get_datetime_dict_from_datetime_string(simulated_start, false) 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(int(date_time.get("hour", 9)) * 3600 + int(date_time.get("minute", 0)) * 60)
clock_seconds += float(simulated_minute * 60) clock_seconds += float(simulated_minute * 60)
_update_clock() _update_clock()
_render_vitals(vitals)
func configure_experience_mode(value: String) -> void: func configure_experience_mode(value: String) -> void:
@@ -266,6 +279,7 @@ func open_computer() -> void:
func close_computer() -> bool: func close_computer() -> bool:
if not computer_overlay.visible: if not computer_overlay.visible:
return false return false
close_vitals()
computer_overlay.visible = false computer_overlay.visible = false
computer_launcher.visible = computer_available computer_launcher.visible = computer_available
if dialogue_tween and dialogue_tween.is_valid(): if dialogue_tween and dialogue_tween.is_valid():
@@ -277,6 +291,126 @@ func is_computer_open() -> bool:
return computer_overlay.visible 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: func _event_targets_computer_launcher(event: InputEvent) -> bool:
if not computer_launcher.visible: if not computer_launcher.visible:
return false return false
@@ -340,14 +474,18 @@ func _render_choice_page() -> void:
choice_page = clampi(choice_page, 0, page_count - 1) choice_page = clampi(choice_page, 0, page_count - 1)
var start_index := choice_page * CHOICES_PER_PAGE var start_index := choice_page * CHOICES_PER_PAGE
var end_index := mini(start_index + CHOICES_PER_PAGE, available_choices.size()) 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): for index in range(start_index, end_index):
var option: Dictionary = available_choices[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.set_meta("choice_id", choice_id) button.set_meta("choice_id", choice_id)
button.custom_minimum_size = Vector2(0.0, 136.0) button.custom_minimum_size = Vector2(
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL 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.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)
+6 -4
View File
@@ -71,8 +71,8 @@ func set_pause_menu_open(open: bool) -> void:
func close_transient_overlay() -> bool: func close_transient_overlay() -> bool:
if active_scene != null and active_scene.has_method("close_computer"): if active_scene != null and active_scene.has_method("close_transient_overlay"):
return bool(active_scene.close_computer()) return bool(active_scene.close_transient_overlay())
return false return false
@@ -234,7 +234,8 @@ func _show_doctor_office(node: Dictionary, attempt: Dictionary = {}) -> void:
active_scene.configure_presentation( active_scene.configure_presentation(
attempt.get("graphicalPresentation", {}), attempt.get("graphicalPresentation", {}),
str(attempt.get("simulatedStartUtc", "")), str(attempt.get("simulatedStartUtc", "")),
int(attempt.get("simulatedMinute", 0))) int(attempt.get("simulatedMinute", 0)),
attempt.get("vitals", {}))
active_scene.configure_interview(node) active_scene.configure_interview(node)
active_scene.set_choices_pending(not pending_action.is_empty()) active_scene.set_choices_pending(not pending_action.is_empty())
@@ -268,7 +269,8 @@ func _render_debrief(attempt: Dictionary) -> void:
active_scene.configure_presentation( active_scene.configure_presentation(
attempt.get("graphicalPresentation", {}), attempt.get("graphicalPresentation", {}),
str(attempt.get("simulatedStartUtc", "")), 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", {})) active_scene.show_debrief(attempt.get("interviewDebrief", {}), attempt.get("score", {}))
current_view = "interview_debrief" current_view = "interview_debrief"
+21 -2
View File
@@ -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(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").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.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.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(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.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") _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() screen.active_scene.advance_dialogue()
_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") _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() 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")
@@ -231,6 +239,10 @@ func _sample_attempt(completed: bool) -> Dictionary:
"id": "attempt-1", "state": "active", "revision": 1, "id": "attempt-1", "state": "active", "revision": 1,
"simulatedStartUtc": "2026-08-05T09:00:00Z", "simulatedMinute": 0, "simulatedStartUtc": "2026-08-05T09:00:00Z", "simulatedMinute": 0,
"graphicalPresentation": {"sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male"}, "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, "openingCompleted": completed,
"interview": {"currentNodeId": node.id, "currentNode": node, "visitedChoiceIds": [], "completed": completed} "interview": {"currentNodeId": node.id, "currentNode": node, "visitedChoiceIds": [], "completed": completed}
} }
@@ -288,6 +300,13 @@ func _visible_choice_ids(scene: Control) -> Array:
return ids 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: func _choice_labels_fit(scene: Control) -> bool:
for button in scene.choice_container.get_children(): for button in scene.choice_container.get_children():
var label := button.get_child(0) as Label var label := button.get_child(0) as Label