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.
This commit is contained in:
@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### Added — Sprint 1: Project Scaffolding & Core Tech
|
||||||
|
|
||||||
- **1.1** Initialized Godot 4.x project: `project.godot` (name `chemo-sim`,
|
- **1.1** Initialized Godot 4.x project: `project.godot` (name `chemo-sim`,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
+2
-2
@@ -36,9 +36,9 @@ godot --export-release Windows "build/windows/chemo-sim.exe"
|
|||||||
godot --export-release Linux "build/linux/chemo-sim.x86_64"
|
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:
|
To trigger manually:
|
||||||
|
|
||||||
|
|||||||
+33
-1
@@ -12,10 +12,42 @@ config_version=5
|
|||||||
|
|
||||||
config/name="chemo-sim"
|
config/name="chemo-sim"
|
||||||
config/version="0.1.0"
|
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/features=PackedStringArray("4.3", "Forward Plus")
|
||||||
config/icon="res://icon.svg"
|
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]
|
[display]
|
||||||
|
|
||||||
window/size/viewport_width=1920
|
window/size/viewport_width=1920
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"]
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user