feat: Implement nausea symptom system and related features
Build / Export windows (push) Successful in 10m56s
Build / Export linux (push) Successful in 6m10s

- 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:
2026-05-30 14:14:38 +02:00
parent 2eb7f9b2cb
commit 8462a2fde7
16 changed files with 867 additions and 3 deletions
+105
View File
@@ -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)
+62
View File
@@ -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()
+77
View File
@@ -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
+52
View File
@@ -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)
+54
View File
@@ -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()
+51
View File
@@ -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)
+48
View File
@@ -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