Add CCS computer vitals app
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user