diff --git a/assets/ccs_cases_2/images/computer/computer-base.png b/assets/ccs_cases_2/images/computer/computer-base.png new file mode 100644 index 0000000..4596f72 Binary files /dev/null and b/assets/ccs_cases_2/images/computer/computer-base.png differ diff --git a/assets/ccs_cases_2/images/computer/computer-base.png.import b/assets/ccs_cases_2/images/computer/computer-base.png.import new file mode 100644 index 0000000..7450813 --- /dev/null +++ b/assets/ccs_cases_2/images/computer/computer-base.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://crss7xdb4asxw" +path="res://.godot/imported/computer-base.png-c22fe210cc788b2a30551b9078564447.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ccs_cases_2/images/computer/computer-base.png" +dest_files=["res://.godot/imported/computer-base.png-c22fe210cc788b2a30551b9078564447.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/icons/lucide/monitor.svg b/assets/icons/lucide/monitor.svg new file mode 100644 index 0000000..99b4e32 --- /dev/null +++ b/assets/icons/lucide/monitor.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/lucide/monitor.svg.import b/assets/icons/lucide/monitor.svg.import new file mode 100644 index 0000000..1381188 --- /dev/null +++ b/assets/icons/lucide/monitor.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://diqn3ntrortw" +path="res://.godot/imported/monitor.svg-1101ac90716471f71a4ffd9814ccca4c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/lucide/monitor.svg" +dest_files=["res://.godot/imported/monitor.svg-1101ac90716471f71a4ffd9814ccca4c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/scenes/ccs_cases_2/doctor_office.tscn b/scenes/ccs_cases_2/doctor_office.tscn index a6988d1..fd57627 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=8 format=3] +[gd_scene load_steps=11 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"] @@ -6,6 +6,9 @@ [ext_resource type="Texture2D" path="res://assets/ccs_cases_2/images/doctor/patient-44-white-male.png" id="4_patient"] [ext_resource type="Shader" path="res://shaders/ccs_cases_2/patient_idle.gdshader" id="5_patient_fit"] [ext_resource type="Texture2D" path="res://assets/ccs_cases_2/images/doctor/chat-bubble.png" id="6_chat_bubble"] +[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"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_patient_fit"] shader = ExtResource("5_patient_fit") @@ -221,3 +224,62 @@ grow_horizontal = 2 grow_vertical = 2 theme_override_constants/separation = 10 alignment = 1 + +[node name="ComputerLauncher" type="Button" parent="."] +z_index = 40 +layout_mode = 0 +offset_left = 24.0 +offset_top = 24.0 +offset_right = 188.0 +offset_bottom = 78.0 +mouse_default_cursor_shape = 2 +tooltip_text = "Open simulated computer" +icon = ExtResource("8_monitor") +icon_max_width = 25 +expand_icon = true +text = "Computer" + +[node name="ComputerOverlay" type="Control" parent="."] +visible = false +z_index = 100 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 0 + +[node name="Dimmer" type="ColorRect" parent="ComputerOverlay"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 0 +color = Color(0.015, 0.025, 0.04, 0.86) + +[node name="Screen" type="TextureRect" parent="ComputerOverlay"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = ExtResource("7_computer") +expand_mode = 1 +stretch_mode = 5 + +[node name="Close" type="Button" parent="ComputerOverlay"] +layout_mode = 0 +offset_left = 1208.0 +offset_top = 20.0 +offset_right = 1272.0 +offset_bottom = 84.0 +mouse_default_cursor_shape = 2 +tooltip_text = "Close computer" +icon = ExtResource("9_close") +icon_max_width = 30 +expand_icon = true diff --git a/scripts/ui/ccs_cases_2/doctor_office.gd b/scripts/ui/ccs_cases_2/doctor_office.gd index d00e6cb..9c14cf1 100644 --- a/scripts/ui/ccs_cases_2/doctor_office.gd +++ b/scripts/ui/ccs_cases_2/doctor_office.gd @@ -29,6 +29,9 @@ const DIALOGUE_BUBBLE_VERTICAL_CHROME := 95.0 @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 +@onready var computer_launcher: Button = $ComputerLauncher +@onready var computer_overlay: Control = $ComputerOverlay +@onready var computer_close_button: Button = $ComputerOverlay/Close var blink_tween: Tween var dialogue_tween: Tween @@ -44,11 +47,14 @@ var dialogue_characters_per_second := 35.0 var dialogue_reveal_complete := false var dialogue_layout_generation := 0 var dialogue_input_enabled := true +var computer_available := true func _ready() -> void: previous_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_close_button.pressed.connect(close_computer) _update_clock() _schedule_patient_blink() hide_patient_dialogue() @@ -158,6 +164,8 @@ func is_patient_dialogue_visible() -> bool: func configure_interview(node: Dictionary) -> void: + computer_available = true + computer_launcher.visible = not computer_overlay.visible if debrief_panel != null and is_instance_valid(debrief_panel): debrief_panel.queue_free() debrief_panel = null @@ -220,6 +228,10 @@ func complete_response_transition() -> void: func _input(event: InputEvent) -> void: + if computer_overlay.visible: + return + if _event_targets_computer_launcher(event): + return if not dialogue_input_enabled or not dialogue_bubble.visible: return var should_advance := false @@ -241,6 +253,41 @@ func set_dialogue_input_enabled(enabled: bool) -> void: dialogue_input_enabled = enabled +func open_computer() -> void: + if not computer_available or computer_overlay.visible: + return + computer_overlay.visible = true + computer_launcher.visible = false + if dialogue_tween and dialogue_tween.is_valid(): + dialogue_tween.pause() + computer_close_button.grab_focus() + + +func close_computer() -> bool: + if not computer_overlay.visible: + return false + computer_overlay.visible = false + computer_launcher.visible = computer_available + if dialogue_tween and dialogue_tween.is_valid(): + dialogue_tween.play() + return true + + +func is_computer_open() -> bool: + return computer_overlay.visible + + +func _event_targets_computer_launcher(event: InputEvent) -> bool: + if not computer_launcher.visible: + return false + if event is InputEventMouseButton: + return event.button_index == MOUSE_BUTTON_LEFT \ + and computer_launcher.get_global_rect().has_point(event.position) + if event is InputEventScreenTouch: + return computer_launcher.get_global_rect().has_point(event.position) + return false + + func _split_dialogue_pages(text: String) -> Array[String]: var normalized := text.strip_edges() if normalized.is_empty(): @@ -397,6 +444,9 @@ func _on_dialogue_choice_pressed(choice_id: String) -> void: func show_debrief(debrief: Dictionary, score: Dictionary) -> void: + computer_available = false + close_computer() + computer_launcher.visible = false hide_patient_dialogue() hide_dialogue_choices() if debrief_panel != null and is_instance_valid(debrief_panel): diff --git a/scripts/ui/ccs_cases_2_screen.gd b/scripts/ui/ccs_cases_2_screen.gd index a68d1e0..29f5ae7 100644 --- a/scripts/ui/ccs_cases_2_screen.gd +++ b/scripts/ui/ccs_cases_2_screen.gd @@ -70,6 +70,12 @@ func set_pause_menu_open(open: bool) -> void: active_scene.set_dialogue_input_enabled(not open) +func close_transient_overlay() -> bool: + if active_scene != null and active_scene.has_method("close_computer"): + return bool(active_scene.close_computer()) + return false + + func compatible_cases(cases: Array) -> Array: return cases.filter(func(item: Variant) -> bool: return typeof(item) == TYPE_DICTIONARY \ diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index f647697..239020e 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -699,6 +699,9 @@ func _input(event: InputEvent) -> void: if not AuthState.is_logged_in or current_screen_name == "login": return if event.is_action_pressed("ui_cancel"): + if _close_current_transient_overlay(): + get_viewport().set_input_as_handled() + return if drawer_open: _close_drawer() get_viewport().set_input_as_handled() @@ -708,6 +711,13 @@ func _input(event: InputEvent) -> void: get_viewport().set_input_as_handled() +func _close_current_transient_overlay() -> bool: + var screen: Control = screens.get(current_screen_name) + if screen != null and screen.has_method("close_transient_overlay"): + return bool(screen.close_transient_overlay()) + return false + + func _screen_uses_pause_menu(screen_name: String) -> bool: return screen_name in ["player", "ccs_player", "ccs_cases_2", "flashcards_study"] diff --git a/tests/ccs_cases_2_ui_test.gd b/tests/ccs_cases_2_ui_test.gd index 6ab74b9..3621eeb 100644 --- a/tests/ccs_cases_2_ui_test.gd +++ b/tests/ccs_cases_2_ui_test.gd @@ -85,6 +85,16 @@ func _run() -> void: _assert(screen.current_view == "doctor_office", "an active interactive attempt should open the doctor office") _assert(not screen.active_scene.is_patient_dialogue_visible(), "the opening should wait for a clinician dialogue selection") _assert(screen.active_scene.choice_container.get_child_count() == 1, "the scene should render server-provided dialogue choices") + _assert(screen.active_scene.computer_launcher.visible, "the doctor office should expose the simulated computer launcher") + _assert(screen.active_scene.computer_launcher.icon.resource_path.ends_with("monitor.svg"), "the computer launcher should use the monitor icon") + screen.active_scene.computer_launcher.pressed.emit() + _assert(screen.active_scene.is_computer_open(), "the computer launcher should open the simulated desktop") + _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_KEEP_ASPECT_CENTERED, "the computer should be fully contained without cropping") + _assert(screen.close_transient_overlay(), "Escape routing should close the computer before the pause menu") + _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(is_equal_approx(screen.stage_root.scale.x, screen.stage_root.scale.y), "the graphical stage should preserve its aspect ratio") var stale_graphical_attempt := _sample_attempt(false) stale_graphical_attempt.experienceMode = "graphical_interview" @@ -106,6 +116,13 @@ func _run() -> void: _assert(screen.active_scene.dialogue_continue_hint.text == "Click to continue", "every dialogue page should use supported continuation wording") _assert(screen.active_scene.dialogue_text.visible_characters_behavior == TextServer.VC_CHARS_AFTER_SHAPING, "typewriter text should wrap before glyphs are revealed") var wrapped_line_count: int = screen.active_scene.dialogue_text.get_line_count() + screen.active_scene.open_computer() + var computer_blocked_page: int = screen.active_scene.dialogue_page_index + var computer_click := _left_click(screen.active_scene.get_global_rect().get_center()) + screen.active_scene._input(computer_click) + _assert(screen.active_scene.dialogue_page_index == computer_blocked_page and not screen.active_scene.dialogue_reveal_complete, "the open computer should block scene-wide dialogue clicks") + screen.active_scene.computer_close_button.pressed.emit() + _assert(not screen.active_scene.is_computer_open(), "the computer close button should return to the interview") var outside_click := _left_click(screen.active_scene.get_global_rect().end + Vector2(50.0, 50.0)) screen.active_scene._input(outside_click) _assert(not screen.active_scene.dialogue_reveal_complete, "clicks outside the doctor scene should not affect dialogue") @@ -169,6 +186,7 @@ func _run() -> void: await process_frame _assert(screen.current_view == "interview_debrief", "a completed graphical interview should remain in CCS Cases 2") _assert(screen.active_scene.find_child("InterviewDebrief", true, false) != null, "the graphical case should render its debrief") + _assert(not screen.active_scene.computer_launcher.visible, "the computer launcher should stay hidden during the interview debrief") var sizing_scene = load("res://scenes/ccs_cases_2/doctor_office.tscn").instantiate() host.add_child(sizing_scene)