From 8462a2fde71289f2132a160de54d3aebdd51dc44 Mon Sep 17 00:00:00 2001 From: zwitschi Date: Sat, 30 May 2026 14:14:38 +0200 Subject: [PATCH] feat: Implement nausea symptom system and related features - Added SymptomManager autoload for managing symptom intensities. - Introduced nausea tilt effect for camera roll based on nausea intensity. - Created color desaturation shader and overlay for visual feedback. - Developed TreatmentManager to handle nausea ramping during IV treatment. - Added hospital room scene with first-person controller and interactions. - Implemented sit-down interaction for the treatment chair. - Created IV insertion sequence with animations and completion signal. - Added player controller with movement and interaction capabilities. - Integrated nausea effects into the player experience with smooth transitions. --- CHANGELOG.md | 40 +++++ assets/shaders/desaturation.gdshader | 17 ++ docs/BUILD_GUIDE.md | 4 +- project.godot | 34 +++- scenes/hospital/chair_interaction.tscn | 33 ++++ scenes/hospital/hospital_room.tscn | 200 ++++++++++++++++++++++ scenes/hospital/iv_insertion.tscn | 46 +++++ scenes/hospital/player.tscn | 25 +++ scenes/symptoms/nausea_overlay.tscn | 22 +++ scripts/character/chair_interaction.gd | 105 ++++++++++++ scripts/character/iv_insertion.gd | 62 +++++++ scripts/character/player_controller.gd | 77 +++++++++ scripts/game_systems/symptom_manager.gd | 52 ++++++ scripts/game_systems/treatment_manager.gd | 54 ++++++ scripts/symptoms/nausea_desaturation.gd | 51 ++++++ scripts/symptoms/nausea_tilt.gd | 48 ++++++ 16 files changed, 867 insertions(+), 3 deletions(-) create mode 100644 assets/shaders/desaturation.gdshader create mode 100644 scenes/hospital/chair_interaction.tscn create mode 100644 scenes/hospital/hospital_room.tscn create mode 100644 scenes/hospital/iv_insertion.tscn create mode 100644 scenes/hospital/player.tscn create mode 100644 scenes/symptoms/nausea_overlay.tscn create mode 100644 scripts/character/chair_interaction.gd create mode 100644 scripts/character/iv_insertion.gd create mode 100644 scripts/character/player_controller.gd create mode 100644 scripts/game_systems/symptom_manager.gd create mode 100644 scripts/game_systems/treatment_manager.gd create mode 100644 scripts/symptoms/nausea_desaturation.gd create mode 100644 scripts/symptoms/nausea_tilt.gd diff --git a/CHANGELOG.md b/CHANGELOG.md index 82a3595..498a021 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added — Sprint 3: Nausea Symptom System + +- **3.1** Symptom manager autoload (`scripts/game_systems/symptom_manager.gd`): + generic per-symptom intensities clamped to [0, 100], `trigger_symptom` / + `add_intensity` / `get_intensity` / `reset_all`, and + `nausea_intensity_changed` + generic `symptom_intensity_changed` signals. + Registered as the `SymptomManager` autoload. +- **3.2** Screen tilt effect (`scripts/symptoms/nausea_tilt.gd`): camera Z-axis + roll oscillating at ~0.3 Hz, amplitude scaled by nausea up to ~5°, smoothly + following intensity. Attached to the player `Camera3D`. +- **3.3** Color desaturation shader (`assets/shaders/desaturation.gdshader` + + `scripts/symptoms/nausea_desaturation.gd` + `scenes/symptoms/nausea_overlay.tscn`): + full-screen ColorRect that blends the screen toward grayscale, reaching full + desaturation at nausea ≥ 80, fading smoothly with intensity. +- **3.4** Treatment manager (`scripts/game_systems/treatment_manager.gd`): on IV + `iv_completed`, ramps nausea from its current value to ~40 over 30 seconds, + then holds. SymptomManager propagates changes to the tilt and desaturation + effects. Wired `IVInsertion.iv_completed → TreatmentManager.start_ramp`. + +### Added — Sprint 2: Environment & Character + +- **2.1** Hospital room scene (`scenes/hospital/hospital_room.tscn`): floor, + ceiling, four walls (with collision), placeholder furniture (bed, chair, + IV stand, bedside table), fluorescent lighting (directional + omni), and a + start camera/player facing the chair. Set as `run/main_scene`. +- **2.2** First-person controller (`scripts/character/player_controller.gd` + + `scenes/hospital/player.tscn`): `CharacterBody3D` with capsule collision, + child `Camera3D`, WASD movement, captured mouse-look (pitch clamped), + gravity, and an interaction `RayCast3D`. Added `move_*` / `interact` input + actions to `project.godot`. +- **2.3** Sit-down interaction (`scripts/character/chair_interaction.gd` + + `scenes/hospital/chair_interaction.tscn`): Area3D trigger with "Press E to + sit/stand" prompt, smooth tween to a seat marker, movement lock while + seated, and `player_seated` / `player_stood` signals. +- **2.4** IV insertion sequence (`scripts/character/iv_insertion.gd` + + `scenes/hospital/iv_insertion.tscn`): IV arm placeholder with a tweened + needle-approach + tape-press animation, emitting `iv_completed`. Wired + `ChairInteraction.player_seated → IVInsertion.begin_sequence` in the room + scene. + ### Added — Sprint 1: Project Scaffolding & Core Tech - **1.1** Initialized Godot 4.x project: `project.godot` (name `chemo-sim`, diff --git a/assets/shaders/desaturation.gdshader b/assets/shaders/desaturation.gdshader new file mode 100644 index 0000000..0805111 --- /dev/null +++ b/assets/shaders/desaturation.gdshader @@ -0,0 +1,17 @@ +shader_type canvas_item; +// Full-screen nausea desaturation. +// Samples the screen, converts to luminance, and blends back toward grayscale +// based on the `desaturation` uniform (0 = full color, 1 = full grayscale). + +uniform sampler2D screen_tex : hint_screen_texture, filter_linear_mipmap; +uniform float desaturation : hint_range(0.0, 1.0) = 0.0; + +// Rec. 709 luma weights. +const vec3 LUMA = vec3(0.2126, 0.7152, 0.0722); + +void fragment() { + vec3 col = texture(screen_tex, SCREEN_UV).rgb; + float gray = dot(col, LUMA); + vec3 result = mix(col, vec3(gray), clamp(desaturation, 0.0, 1.0)); + COLOR = vec4(result, 1.0); +} diff --git a/docs/BUILD_GUIDE.md b/docs/BUILD_GUIDE.md index aaa44b1..c8fa024 100644 --- a/docs/BUILD_GUIDE.md +++ b/docs/BUILD_GUIDE.md @@ -36,9 +36,9 @@ godot --export-release Windows "build/windows/chemo-sim.exe" godot --export-release Linux "build/linux/chemo-sim.x86_64" ``` -## CI/CD (GitHub Actions) +## CI/CD (Gitea Actions) -Builds run automatically on push to `main`. See `.github/workflows/build.yml`. +Builds run automatically on push to `main`. See `.gitea/workflows/build.yml`. To trigger manually: diff --git a/project.godot b/project.godot index a0cb3de..d932ba9 100644 --- a/project.godot +++ b/project.godot @@ -12,10 +12,42 @@ config_version=5 config/name="chemo-sim" config/version="0.1.0" -run/main_scene="" +run/main_scene="res://scenes/hospital/hospital_room.tscn" config/features=PackedStringArray("4.3", "Forward Plus") config/icon="res://icon.svg" +[autoload] + +SymptomManager="*res://scripts/game_systems/symptom_manager.gd" + +[input] + +move_forward={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +move_back={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +move_left={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +move_right={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +interact={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} + [display] window/size/viewport_width=1920 diff --git a/scenes/hospital/chair_interaction.tscn b/scenes/hospital/chair_interaction.tscn new file mode 100644 index 0000000..2d312fc --- /dev/null +++ b/scenes/hospital/chair_interaction.tscn @@ -0,0 +1,33 @@ +[gd_scene load_steps=3 format=3 uid="uid://dchair1nt0001a"] + +[ext_resource type="Script" path="res://scripts/character/chair_interaction.gd" id="1_chair"] + +[sub_resource type="BoxShape3D" id="Shape_Trigger"] +size = Vector3(1.6, 2.0, 1.6) + +[node name="ChairInteraction" type="Area3D" node_paths=PackedStringArray("seat_transform_path")] +script = ExtResource("1_chair") +seat_transform_path = NodePath("SeatMarker") + +[node name="TriggerShape" type="CollisionShape3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) +shape = SubResource("Shape_Trigger") + +[node name="SeatMarker" type="Marker3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0.35) + +[node name="PromptLayer" type="CanvasLayer" parent="."] + +[node name="PromptLabel" type="Label" parent="PromptLayer"] +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -100.0 +offset_top = 60.0 +offset_right = 100.0 +offset_bottom = 90.0 +horizontal_alignment = 1 +vertical_alignment = 1 +text = "Press E to sit" diff --git a/scenes/hospital/hospital_room.tscn b/scenes/hospital/hospital_room.tscn new file mode 100644 index 0000000..fe8848f --- /dev/null +++ b/scenes/hospital/hospital_room.tscn @@ -0,0 +1,200 @@ +[gd_scene load_steps=20 format=3 uid="uid://b1hosp1room01a"] + +[ext_resource type="PackedScene" path="res://scenes/hospital/player.tscn" id="1_player"] +[ext_resource type="PackedScene" path="res://scenes/hospital/chair_interaction.tscn" id="2_chair"] +[ext_resource type="PackedScene" path="res://scenes/hospital/iv_insertion.tscn" id="3_iv"] +[ext_resource type="PackedScene" path="res://scenes/symptoms/nausea_overlay.tscn" id="4_overlay"] +[ext_resource type="Script" path="res://scripts/game_systems/treatment_manager.gd" id="5_treatment"] + +; --- Materials --- +[sub_resource type="StandardMaterial3D" id="Mat_Floor"] +albedo_color = Color(0.72, 0.74, 0.76, 1) +roughness = 0.6 + +[sub_resource type="StandardMaterial3D" id="Mat_Wall"] +albedo_color = Color(0.86, 0.88, 0.85, 1) +roughness = 0.85 + +[sub_resource type="StandardMaterial3D" id="Mat_Ceiling"] +albedo_color = Color(0.93, 0.94, 0.94, 1) +roughness = 0.9 + +[sub_resource type="StandardMaterial3D" id="Mat_Bed"] +albedo_color = Color(0.3, 0.45, 0.55, 1) +roughness = 0.7 + +[sub_resource type="StandardMaterial3D" id="Mat_Chair"] +albedo_color = Color(0.2, 0.5, 0.4, 1) +roughness = 0.7 + +[sub_resource type="StandardMaterial3D" id="Mat_Metal"] +albedo_color = Color(0.8, 0.82, 0.85, 1) +metallic = 0.7 +roughness = 0.3 + +[sub_resource type="StandardMaterial3D" id="Mat_Wood"] +albedo_color = Color(0.55, 0.42, 0.3, 1) +roughness = 0.6 + +; --- Meshes --- +[sub_resource type="PlaneMesh" id="Mesh_Floor"] +size = Vector2(6, 8) + +[sub_resource type="BoxMesh" id="Mesh_BedFrame"] +size = Vector3(1, 0.5, 2) + +[sub_resource type="BoxMesh" id="Mesh_BedMattress"] +size = Vector3(0.95, 0.2, 1.95) + +[sub_resource type="BoxMesh" id="Mesh_ChairSeat"] +size = Vector3(0.7, 0.15, 0.7) + +[sub_resource type="BoxMesh" id="Mesh_ChairBack"] +size = Vector3(0.7, 0.8, 0.15) + +[sub_resource type="CylinderMesh" id="Mesh_IVPole"] +top_radius = 0.02 +bottom_radius = 0.02 +height = 1.8 + +[sub_resource type="BoxMesh" id="Mesh_Table"] +size = Vector3(0.5, 0.7, 0.5) + +[sub_resource type="BoxMesh" id="Mesh_Wall"] +size = Vector3(0.1, 3, 8) + +; --- Collision shapes --- +[sub_resource type="WorldBoundaryShape3D" id="Shape_Floor"] + +[sub_resource type="BoxShape3D" id="Shape_Wall"] +size = Vector3(0.1, 3, 8) + +[node name="HospitalRoom" type="Node3D"] + +; ---------------- Structure ---------------- +[node name="Floor" type="StaticBody3D" parent="."] + +[node name="FloorMesh" type="MeshInstance3D" parent="Floor"] +mesh = SubResource("Mesh_Floor") +surface_material_override/0 = SubResource("Mat_Floor") + +[node name="FloorCollision" type="CollisionShape3D" parent="Floor"] +shape = SubResource("Shape_Floor") + +[node name="Ceiling" type="MeshInstance3D" parent="."] +transform = Transform3D(1, 0, 0, 0, -1, 0, 0, 0, 1, 0, 3, 0) +mesh = SubResource("Mesh_Floor") +surface_material_override/0 = SubResource("Mat_Ceiling") + +[node name="WallNorth" type="StaticBody3D" parent="."] +transform = Transform3D(0, 0, 1, 0, 1, 0, -1, 0, 0, 0, 1.5, -4) + +[node name="WallNorthMesh" type="MeshInstance3D" parent="WallNorth"] +mesh = SubResource("Mesh_Wall") +surface_material_override/0 = SubResource("Mat_Wall") + +[node name="WallNorthCollision" type="CollisionShape3D" parent="WallNorth"] +shape = SubResource("Shape_Wall") + +[node name="WallSouth" type="StaticBody3D" parent="."] +transform = Transform3D(0, 0, 1, 0, 1, 0, -1, 0, 0, 0, 1.5, 4) + +[node name="WallSouthMesh" type="MeshInstance3D" parent="WallSouth"] +mesh = SubResource("Mesh_Wall") +surface_material_override/0 = SubResource("Mat_Wall") + +[node name="WallSouthCollision" type="CollisionShape3D" parent="WallSouth"] +shape = SubResource("Shape_Wall") + +[node name="WallEast" type="StaticBody3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 3, 1.5, 0) + +[node name="WallEastMesh" type="MeshInstance3D" parent="WallEast"] +mesh = SubResource("Mesh_Wall") +surface_material_override/0 = SubResource("Mat_Wall") + +[node name="WallEastCollision" type="CollisionShape3D" parent="WallEast"] +shape = SubResource("Shape_Wall") + +[node name="WallWest" type="StaticBody3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -3, 1.5, 0) + +[node name="WallWestMesh" type="MeshInstance3D" parent="WallWest"] +mesh = SubResource("Mesh_Wall") +surface_material_override/0 = SubResource("Mat_Wall") + +[node name="WallWestCollision" type="CollisionShape3D" parent="WallWest"] +shape = SubResource("Shape_Wall") + +; ---------------- Furniture ---------------- +[node name="Bed" type="Node3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.8, 0, -2) + +[node name="BedFrame" type="MeshInstance3D" parent="Bed"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.25, 0) +mesh = SubResource("Mesh_BedFrame") +surface_material_override/0 = SubResource("Mat_Metal") + +[node name="BedMattress" type="MeshInstance3D" parent="Bed"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.6, 0) +mesh = SubResource("Mesh_BedMattress") +surface_material_override/0 = SubResource("Mat_Bed") + +[node name="Chair" type="Node3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.2, 0, 1.5) + +[node name="ChairSeat" type="MeshInstance3D" parent="Chair"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0) +mesh = SubResource("Mesh_ChairSeat") +surface_material_override/0 = SubResource("Mat_Chair") + +[node name="ChairBack" type="MeshInstance3D" parent="Chair"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.95, -0.27) +mesh = SubResource("Mesh_ChairBack") +surface_material_override/0 = SubResource("Mat_Chair") + +[node name="ChairInteraction" parent="." instance=ExtResource("2_chair")] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.2, 0, 1.5) + +[node name="IVStand" type="MeshInstance3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2, 0.9, 1.5) +mesh = SubResource("Mesh_IVPole") +surface_material_override/0 = SubResource("Mat_Metal") + +[node name="BedsideTable" type="MeshInstance3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2.5, 0.35, 1.5) +mesh = SubResource("Mesh_Table") +surface_material_override/0 = SubResource("Mat_Wood") + +; ---------------- Lighting ---------------- +[node name="OverheadLight" type="DirectionalLight3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 0.5, 0.866, 0, -0.866, 0.5, 0, 2.9, 0) +light_energy = 0.9 +shadow_enabled = true + +[node name="CeilingLamp" type="OmniLight3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.7, 0) +light_energy = 1.5 +omni_range = 8.0 + +[node name="WorldEnvironment" type="WorldEnvironment" parent="."] + +; ---------------- Player ---------------- +; Spawn near south wall (z=3), rotated ~ -45deg to face the chair (chair at x=1.2, z=1.5). +[node name="Player" parent="." instance=ExtResource("1_player")] +transform = Transform3D(0.7071, 0, 0.7071, 0, 1, 0, -0.7071, 0, 0.7071, -1.5, 0, 3) + +; ---------------- IV Insertion ---------------- +; Positioned roughly in front of and below the seated player's view. +[node name="IVInsertion" parent="." instance=ExtResource("3_iv")] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.0, 1.1, 1.4) + +; ---------------- Symptom Overlay ---------------- +[node name="NauseaOverlay" parent="." instance=ExtResource("4_overlay")] + +; ---------------- Treatment Manager ---------------- +[node name="TreatmentManager" type="Node" parent="."] +script = ExtResource("5_treatment") + +[connection signal="player_seated" from="ChairInteraction" to="IVInsertion" method="begin_sequence"] +[connection signal="iv_completed" from="IVInsertion" to="TreatmentManager" method="start_ramp"] diff --git a/scenes/hospital/iv_insertion.tscn b/scenes/hospital/iv_insertion.tscn new file mode 100644 index 0000000..ff05ed6 --- /dev/null +++ b/scenes/hospital/iv_insertion.tscn @@ -0,0 +1,46 @@ +[gd_scene load_steps=8 format=3 uid="uid://div1nsert0001a"] + +[ext_resource type="Script" path="res://scripts/character/iv_insertion.gd" id="1_iv"] + +[sub_resource type="StandardMaterial3D" id="Mat_Skin"] +albedo_color = Color(0.82, 0.64, 0.55, 1) +roughness = 0.7 + +[sub_resource type="StandardMaterial3D" id="Mat_Needle"] +albedo_color = Color(0.85, 0.86, 0.9, 1) +metallic = 0.8 +roughness = 0.2 + +[sub_resource type="StandardMaterial3D" id="Mat_Tape"] +albedo_color = Color(0.95, 0.93, 0.85, 1) +roughness = 0.9 + +[sub_resource type="BoxMesh" id="Mesh_Forearm"] +size = Vector3(0.4, 0.08, 0.1) + +[sub_resource type="CylinderMesh" id="Mesh_Needle"] +top_radius = 0.004 +bottom_radius = 0.004 +height = 0.12 + +[sub_resource type="BoxMesh" id="Mesh_Tape"] +size = Vector3(0.08, 0.005, 0.06) + +[node name="IVInsertion" type="Node3D" node_paths=PackedStringArray()] +script = ExtResource("1_iv") + +[node name="Arm" type="Node3D" parent="."] + +[node name="Forearm" type="MeshInstance3D" parent="Arm"] +mesh = SubResource("Mesh_Forearm") +surface_material_override/0 = SubResource("Mat_Skin") + +[node name="Needle" type="MeshInstance3D" parent="Arm"] +transform = Transform3D(1, 0, 0, 0, 0, -1, 0, 1, 0, 0.05, 0.12, 0) +mesh = SubResource("Mesh_Needle") +surface_material_override/0 = SubResource("Mat_Needle") + +[node name="Tape" type="MeshInstance3D" parent="Arm"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.05, 0.045, 0) +mesh = SubResource("Mesh_Tape") +surface_material_override/0 = SubResource("Mat_Tape") diff --git a/scenes/hospital/player.tscn b/scenes/hospital/player.tscn new file mode 100644 index 0000000..64b2ef1 --- /dev/null +++ b/scenes/hospital/player.tscn @@ -0,0 +1,25 @@ +[gd_scene load_steps=4 format=3 uid="uid://c0play3r0001a"] + +[ext_resource type="Script" path="res://scripts/character/player_controller.gd" id="1_player"] +[ext_resource type="Script" path="res://scripts/symptoms/nausea_tilt.gd" id="2_tilt"] + +[sub_resource type="CapsuleShape3D" id="Shape_Body"] +radius = 0.3 +height = 1.8 + +[node name="Player" type="CharacterBody3D"] +script = ExtResource("1_player") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.9, 0) +shape = SubResource("Shape_Body") + +[node name="Camera3D" type="Camera3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.6, 0) +current = true +script = ExtResource("2_tilt") + +[node name="InteractionRay" type="RayCast3D" parent="Camera3D"] +target_position = Vector3(0, 0, -2.5) +collide_with_areas = true +collide_with_bodies = true diff --git a/scenes/symptoms/nausea_overlay.tscn b/scenes/symptoms/nausea_overlay.tscn new file mode 100644 index 0000000..f83ba49 --- /dev/null +++ b/scenes/symptoms/nausea_overlay.tscn @@ -0,0 +1,22 @@ +[gd_scene load_steps=4 format=3 uid="uid://cnaus3aov0001a"] + +[ext_resource type="Script" path="res://scripts/symptoms/nausea_desaturation.gd" id="1_desat"] +[ext_resource type="Shader" path="res://assets/shaders/desaturation.gdshader" id="2_shader"] + +[sub_resource type="ShaderMaterial" id="Mat_Desat"] +shader = ExtResource("2_shader") +shader_parameter/desaturation = 0.0 + +[node name="NauseaOverlay" type="CanvasLayer"] +layer = 2 + +[node name="DesaturationRect" type="ColorRect" parent="."] +material = SubResource("Mat_Desat") +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +color = Color(1, 1, 1, 1) +script = ExtResource("1_desat") diff --git a/scripts/character/chair_interaction.gd b/scripts/character/chair_interaction.gd new file mode 100644 index 0000000..294ea9c --- /dev/null +++ b/scripts/character/chair_interaction.gd @@ -0,0 +1,105 @@ +class_name ChairInteraction +extends Area3D +## Sit-down interaction for the treatment chair. +## Shows a prompt when the player is in range, sits them on `interact`, +## smoothly lerps the camera to a seated pose, locks movement, and emits +## `player_seated`. Standing again unlocks movement and emits `player_stood`. + +signal player_seated +signal player_stood + +@export var seat_transform_path: NodePath +@export var lerp_duration: float = 0.6 + +@onready var prompt_label: Label = $PromptLayer/PromptLabel +@onready var seat_marker: Node3D = get_node_or_null(seat_transform_path) + +var _player: PlayerController = null +var _player_in_range: bool = false +var _is_seated: bool = false +var _stand_transform: Transform3D +var _tween: Tween = null + + +func _ready() -> void: + if prompt_label: + prompt_label.visible = false + body_entered.connect(_on_body_entered) + body_exited.connect(_on_body_exited) + + +func _on_body_entered(body: Node3D) -> void: + if body is PlayerController: + _player = body + _player_in_range = true + _update_prompt() + + +func _on_body_exited(body: Node3D) -> void: + if body == _player and not _is_seated: + _player_in_range = false + _player = null + _update_prompt() + + +func _unhandled_input(event: InputEvent) -> void: + if not event.is_action_pressed("interact"): + return + if _is_seated: + _stand_up() + elif _player_in_range and _player != null: + _sit_down() + + +func _update_prompt() -> void: + if not prompt_label: + return + if _is_seated: + prompt_label.text = "Press E to stand" + prompt_label.visible = true + elif _player_in_range: + prompt_label.text = "Press E to sit" + prompt_label.visible = true + else: + prompt_label.visible = false + + +func _sit_down() -> void: + if seat_marker == null: + push_warning("ChairInteraction: no seat marker assigned; cannot sit.") + return + _is_seated = true + _stand_transform = _player.global_transform + _player.set_movement_locked(true) + _update_prompt() + _lerp_player_to(seat_marker.global_transform, func() -> void: + player_seated.emit() + ) + + +func _stand_up() -> void: + _is_seated = false + _update_prompt() + _lerp_player_to(_stand_transform, func() -> void: + if _player: + _player.set_movement_locked(false) + player_stood.emit() + # Refresh range state after standing. + _player_in_range = overlaps_body(_player) if _player else false + _update_prompt() + ) + + +func _lerp_player_to(target: Transform3D, on_done: Callable) -> void: + if _tween and _tween.is_running(): + _tween.kill() + _tween = create_tween() + _tween.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) + _tween.tween_method(_apply_player_transform.bind(_player.global_transform, target), + 0.0, 1.0, lerp_duration) + _tween.tween_callback(on_done) + + +func _apply_player_transform(t: float, from_xform: Transform3D, to_xform: Transform3D) -> void: + if _player: + _player.global_transform = from_xform.interpolate_with(to_xform, t) diff --git a/scripts/character/iv_insertion.gd b/scripts/character/iv_insertion.gd new file mode 100644 index 0000000..da8573d --- /dev/null +++ b/scripts/character/iv_insertion.gd @@ -0,0 +1,62 @@ +class_name IVInsertion +extends Node3D +## IV insertion sequence. Presents an IV arm in front of the seated player, +## plays a short needle-approach + tape-press animation, then emits +## `iv_completed` which starts the treatment timer. + +signal iv_completed + +@export var auto_hide_on_complete: bool = true +@export var needle_approach_time: float = 1.2 +@export var tape_press_time: float = 0.6 +@export var hold_time: float = 0.5 + +@onready var needle: Node3D = $Arm/Needle +@onready var tape: Node3D = $Arm/Tape + +var _needle_start: Vector3 +var _needle_end: Vector3 +var _tape_hidden_scale: Vector3 = Vector3(1, 0.01, 1) +var _tape_shown_scale: Vector3 = Vector3.ONE +var _running: bool = false + + +func _ready() -> void: + visible = false + if needle: + _needle_start = needle.position + # Needle ends pressed into the arm (move toward the arm along +Z/-Y a touch). + _needle_end = needle.position + Vector3(0, -0.04, -0.08) + if tape: + tape.scale = _tape_hidden_scale + + +## Begin the IV insertion sequence. Safe to wire directly to `player_seated`. +func begin_sequence() -> void: + if _running: + return + _running = true + visible = true + if needle: + needle.position = _needle_start + if tape: + tape.scale = _tape_hidden_scale + + var tween := create_tween() + tween.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) + # Needle approaches and presses in. + if needle: + tween.tween_property(needle, "position", _needle_end, needle_approach_time) + # Tape presses on. + if tape: + tween.tween_property(tape, "scale", _tape_shown_scale, tape_press_time) + # Brief hold, then finish. + tween.tween_interval(hold_time) + tween.tween_callback(_on_sequence_finished) + + +func _on_sequence_finished() -> void: + _running = false + if auto_hide_on_complete: + visible = false + iv_completed.emit() diff --git a/scripts/character/player_controller.gd b/scripts/character/player_controller.gd new file mode 100644 index 0000000..fe92908 --- /dev/null +++ b/scripts/character/player_controller.gd @@ -0,0 +1,77 @@ +class_name PlayerController +extends CharacterBody3D +## First-person player controller. +## WASD movement, mouse-look, gravity, and an interaction raycast. + +@export var walk_speed: float = 4.0 +@export var mouse_sensitivity: float = 0.0025 +@export var pitch_limit_deg: float = 89.0 + +## When true, all movement and look input is ignored (e.g. while seated). +var movement_locked: bool = false + +@onready var camera: Camera3D = $Camera3D +@onready var interaction_ray: RayCast3D = $Camera3D/InteractionRay + +var _gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity", 9.8) + + +func _ready() -> void: + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + + +func _unhandled_input(event: InputEvent) -> void: + if movement_locked: + return + + # Mouse look. + if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: + rotate_y(-event.relative.x * mouse_sensitivity) + camera.rotate_x(-event.relative.y * mouse_sensitivity) + var limit := deg_to_rad(pitch_limit_deg) + camera.rotation.x = clampf(camera.rotation.x, -limit, limit) + + # Release / recapture mouse with Escape (handy for alt-tab / debugging). + if event.is_action_pressed("ui_cancel"): + if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + else: + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + + +func _physics_process(delta: float) -> void: + # Gravity. + if not is_on_floor(): + velocity.y -= _gravity * delta + + if movement_locked: + velocity.x = 0.0 + velocity.z = 0.0 + move_and_slide() + return + + # Horizontal movement from input. + var input_dir := Input.get_vector( + "move_left", "move_right", "move_forward", "move_back" + ) + var direction := (transform.basis * Vector3(input_dir.x, 0.0, input_dir.y)).normalized() + if direction != Vector3.ZERO: + velocity.x = direction.x * walk_speed + velocity.z = direction.z * walk_speed + else: + velocity.x = move_toward(velocity.x, 0.0, walk_speed) + velocity.z = move_toward(velocity.z, 0.0, walk_speed) + + move_and_slide() + + +## Returns the object currently under the interaction ray, or null. +func get_interaction_target() -> Object: + if interaction_ray and interaction_ray.is_colliding(): + return interaction_ray.get_collider() + return null + + +## Lock or unlock player movement/look (used by sit-down and minigames). +func set_movement_locked(locked: bool) -> void: + movement_locked = locked diff --git a/scripts/game_systems/symptom_manager.gd b/scripts/game_systems/symptom_manager.gd new file mode 100644 index 0000000..8030fde --- /dev/null +++ b/scripts/game_systems/symptom_manager.gd @@ -0,0 +1,52 @@ +extends Node +## Autoload singleton managing symptom states. +## +## Intensities are floats in the range [0, 100]. Nausea is the MVP symptom but +## the API is generic so future symptoms (fatigue, taste distortion, etc.) can +## reuse it. Register this script as an autoload named `SymptomManager`. + +## Emitted whenever any symptom intensity changes. +## `symptom_id` is the changed symptom, `value` its new clamped intensity. +signal symptom_intensity_changed(symptom_id: StringName, value: float) + +## Convenience signal specific to nausea (MVP visual effects subscribe to this). +signal nausea_intensity_changed(value: float) + +const MIN_INTENSITY: float = 0.0 +const MAX_INTENSITY: float = 100.0 + +## Well-known symptom ids. +const NAUSEA: StringName = &"nausea" + +var _intensities: Dictionary = {} + + +## Set a symptom's intensity to an absolute value (clamped to [0, 100]). +func trigger_symptom(symptom_id: StringName, intensity: float) -> void: + _set_intensity(symptom_id, intensity) + + +## Add (or subtract, with a negative delta) to a symptom's current intensity. +func add_intensity(symptom_id: StringName, delta: float) -> void: + _set_intensity(symptom_id, get_intensity(symptom_id) + delta) + + +## Return a symptom's current intensity, or 0.0 if never set. +func get_intensity(symptom_id: StringName) -> float: + return _intensities.get(symptom_id, 0.0) + + +## Reset all symptoms to 0 (e.g. between treatments / new game). +func reset_all() -> void: + for symptom_id in _intensities.keys(): + _set_intensity(symptom_id, 0.0) + + +func _set_intensity(symptom_id: StringName, value: float) -> void: + var clamped := clampf(value, MIN_INTENSITY, MAX_INTENSITY) + if is_equal_approx(_intensities.get(symptom_id, 0.0), clamped): + return + _intensities[symptom_id] = clamped + symptom_intensity_changed.emit(symptom_id, clamped) + if symptom_id == NAUSEA: + nausea_intensity_changed.emit(clamped) diff --git a/scripts/game_systems/treatment_manager.gd b/scripts/game_systems/treatment_manager.gd new file mode 100644 index 0000000..2675010 --- /dev/null +++ b/scripts/game_systems/treatment_manager.gd @@ -0,0 +1,54 @@ +class_name TreatmentManager +extends Node +## Drives nausea during an active treatment. +## +## When `start_ramp()` is called (wired to IV `iv_completed`), nausea ramps from +## its current value up to `ramp_target` over `ramp_duration` seconds, then holds. +## The SymptomManager autoload propagates changes to the tilt + desaturation +## effects, so this node only needs to push intensity values. + +signal treatment_started +signal ramp_complete + +## Target nausea intensity at the end of the initial ramp. +@export var ramp_target: float = 40.0 +## How long (seconds) the initial ramp takes. +@export var ramp_duration: float = 30.0 + +var _ramping: bool = false +var _elapsed: float = 0.0 +var _start_value: float = 0.0 +var _sm: Node = null + + +func _ready() -> void: + if is_inside_tree(): + _sm = get_tree().root.get_node_or_null("SymptomManager") + if _sm == null: + _sm = SymptomManager + set_process(false) + + +## Begin the nausea ramp. Safe to wire directly to IVInsertion.iv_completed. +func start_ramp() -> void: + if _sm == null: + push_warning("TreatmentManager: SymptomManager not available.") + return + _start_value = _sm.get_intensity(_sm.NAUSEA) + _elapsed = 0.0 + _ramping = true + set_process(true) + treatment_started.emit() + + +func _process(delta: float) -> void: + if not _ramping: + return + _elapsed += delta + var t := clampf(_elapsed / ramp_duration, 0.0, 1.0) + var value := lerpf(_start_value, ramp_target, t) + _sm.trigger_symptom(_sm.NAUSEA, value) + if t >= 1.0: + _ramping = false + set_process(false) + ramp_complete.emit() diff --git a/scripts/symptoms/nausea_desaturation.gd b/scripts/symptoms/nausea_desaturation.gd new file mode 100644 index 0000000..5281b6e --- /dev/null +++ b/scripts/symptoms/nausea_desaturation.gd @@ -0,0 +1,51 @@ +extends ColorRect +## Drives the desaturation shader's `desaturation` uniform from nausea intensity. +## +## Nausea ramps desaturation from 0 (no effect) up to full grayscale at +## `max_at_intensity`. The uniform is smoothly faded toward its target. +## Attach to a full-screen ColorRect that uses `desaturation.gdshader`. + +## Nausea intensity at (and above) which the screen is fully desaturated. +@export var max_at_intensity: float = 80.0 +## How fast the shader uniform follows its target, per second. +@export var fade_speed: float = 2.0 + +var _target: float = 0.0 +var _current: float = 0.0 + + +func _ready() -> void: + # Cover the whole viewport and never block input. + mouse_filter = Control.MOUSE_FILTER_IGNORE + set_anchors_preset(Control.PRESET_FULL_RECT) + + var sm: Node = null + if is_inside_tree(): + sm = get_tree().root.get_node_or_null("SymptomManager") + if sm == null: + sm = SymptomManager + if sm: + sm.nausea_intensity_changed.connect(_on_nausea_changed) + _set_target_from_intensity(sm.get_intensity(sm.NAUSEA)) + _current = _target + _apply() + + +func _on_nausea_changed(value: float) -> void: + _set_target_from_intensity(value) + + +func _set_target_from_intensity(intensity: float) -> void: + _target = clampf(intensity / max_at_intensity, 0.0, 1.0) + + +func _process(delta: float) -> void: + if is_equal_approx(_current, _target): + return + _current = move_toward(_current, _target, fade_speed * delta) + _apply() + + +func _apply() -> void: + if material is ShaderMaterial: + (material as ShaderMaterial).set_shader_parameter("desaturation", _current) diff --git a/scripts/symptoms/nausea_tilt.gd b/scripts/symptoms/nausea_tilt.gd new file mode 100644 index 0000000..e5700f3 --- /dev/null +++ b/scripts/symptoms/nausea_tilt.gd @@ -0,0 +1,48 @@ +extends Camera3D +## Nausea screen-tilt effect. +## +## Rolls the camera slightly on its local Z axis, oscillating over time, with +## amplitude proportional to nausea intensity. Attach to the player `Camera3D`. +## The first-person controller only writes camera rotation.x (pitch), so writing +## rotation.z here does not conflict. + +## Maximum tilt in degrees at full nausea (100). +@export var max_tilt_deg: float = 5.0 +## Oscillation frequency in Hz. +@export var frequency_hz: float = 0.3 +## How fast the effective intensity follows the target, in intensity units/sec. +## At 200, a full 0->100 change resolves in ~0.5s. +@export var intensity_follow_speed: float = 200.0 + +var _target_intensity: float = 0.0 +var _current_intensity: float = 0.0 +var _time: float = 0.0 + + +func _ready() -> void: + # Resolve the SymptomManager autoload via the scene tree root when possible, + # falling back to the global autoload identifier. + var sm: Node = null + if is_inside_tree(): + sm = get_tree().root.get_node_or_null("SymptomManager") + if sm == null: + sm = SymptomManager + if sm: + sm.nausea_intensity_changed.connect(_on_nausea_changed) + _target_intensity = sm.get_intensity(sm.NAUSEA) + _current_intensity = _target_intensity + + +func _on_nausea_changed(value: float) -> void: + _target_intensity = value + + +func _process(delta: float) -> void: + _time += delta + _current_intensity = move_toward( + _current_intensity, _target_intensity, intensity_follow_speed * delta + ) + + var amplitude := deg_to_rad(max_tilt_deg) * (_current_intensity / 100.0) + var roll := sin(_time * TAU * frequency_hz) * amplitude + rotation.z = roll