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:
@@ -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
|
||||
Reference in New Issue
Block a user