init
This commit is contained in:
161
rust/src/core/game_state.rs
Normal file
161
rust/src/core/game_state.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
use godot::prelude::*;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use crate::data::hazard_def::HazardType;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum GameEvent {
|
||||
HazardSpawned,
|
||||
HazardResolved,
|
||||
TraumaApplied(f32),
|
||||
LiftCompleted(bool),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct InnerState {
|
||||
pub current_focus: f32, // 0.0 to 1.0
|
||||
pub lift_effort: f32, // 0.0 to 1.0
|
||||
pub is_lifting: bool,
|
||||
pub active_hazards: Vec<HazardType>,
|
||||
pub lift_progress: f32, // 0.0 to 1.0
|
||||
pub visual_height: f32,
|
||||
pub is_lift_complete: bool,
|
||||
pub is_lift_failed: bool,
|
||||
pub camera_trauma: f32,
|
||||
pub event_queue: Vec<GameEvent>,
|
||||
}
|
||||
|
||||
#[derive(GodotClass)]
|
||||
#[class(base=Resource)]
|
||||
pub struct GameState {
|
||||
pub inner: RwLock<InnerState>,
|
||||
|
||||
base: Base<Resource>,
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl IResource for GameState {
|
||||
fn init(base: Base<Resource>) -> Self {
|
||||
Self {
|
||||
inner: RwLock::new(InnerState::default()),
|
||||
base,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl GameState {
|
||||
// --- WRITERS (Updated to push events) ---
|
||||
|
||||
#[func]
|
||||
pub fn apply_effort(&self, delta: f32) {
|
||||
let mut guard = self.inner.write().unwrap();
|
||||
guard.is_lifting = true;
|
||||
guard.lift_effort += delta;
|
||||
}
|
||||
|
||||
#[func]
|
||||
pub fn release_focus(&self) {
|
||||
let mut guard = self.inner.write().unwrap();
|
||||
guard.is_lifting = false;
|
||||
}
|
||||
|
||||
#[func]
|
||||
pub fn set_focus_intensity(&self, intensity: f32) {
|
||||
let mut guard = self.inner.write().unwrap();
|
||||
guard.current_focus = intensity;
|
||||
}
|
||||
|
||||
#[func]
|
||||
pub fn set_lift_state(&self, progress: f32, visual_height: f32) {
|
||||
let mut guard = self.inner.write().unwrap();
|
||||
guard.lift_progress = progress;
|
||||
guard.visual_height = visual_height;
|
||||
}
|
||||
|
||||
#[func]
|
||||
pub fn set_complete(&self, complete: bool) {
|
||||
let mut guard = self.inner.write().unwrap();
|
||||
if complete {
|
||||
guard.is_lift_complete = true;
|
||||
} else {
|
||||
guard.is_lift_failed = true;
|
||||
}
|
||||
|
||||
guard.event_queue.push(GameEvent::LiftCompleted(complete));
|
||||
}
|
||||
|
||||
#[func]
|
||||
pub fn add_trauma(&self, amount: f32) {
|
||||
let mut guard = self.inner.write().unwrap();
|
||||
guard.camera_trauma = (guard.camera_trauma + amount).clamp(0.0, 1.0);
|
||||
guard.event_queue.push(GameEvent::TraumaApplied(amount));
|
||||
}
|
||||
|
||||
#[func]
|
||||
pub fn add_active_hazard(&self, type_: HazardType) {
|
||||
let mut guard = self.inner.write().unwrap();
|
||||
guard.active_hazards.push(type_);
|
||||
guard.event_queue.push(GameEvent::HazardSpawned);
|
||||
}
|
||||
|
||||
#[func]
|
||||
pub fn remove_active_hazard(&self, type_: HazardType) {
|
||||
let mut guard = self.inner.write().unwrap();
|
||||
if let Some(index) = guard.active_hazards.iter().position(|t| *t == type_) {
|
||||
guard.active_hazards.remove(index);
|
||||
guard.event_queue.push(GameEvent::HazardResolved);
|
||||
}
|
||||
}
|
||||
|
||||
// --- READERS ---
|
||||
|
||||
#[func]
|
||||
pub fn consume_effort(&self) -> f32 {
|
||||
let mut guard = self.inner.write().unwrap();
|
||||
let effort = guard.lift_effort;
|
||||
guard.lift_effort = 0.0;
|
||||
effort
|
||||
}
|
||||
|
||||
#[func]
|
||||
pub fn get_active_hazard_count(&self) -> i32 {
|
||||
self.inner.read().unwrap().active_hazards.len() as i32
|
||||
}
|
||||
|
||||
#[func]
|
||||
pub fn get_focus(&self) -> f32 {
|
||||
self.inner.read().unwrap().current_focus
|
||||
}
|
||||
|
||||
#[func]
|
||||
pub fn consume_trauma(&self, decay: f32) -> f32 {
|
||||
let mut guard = self.inner.write().unwrap();
|
||||
let t = guard.camera_trauma;
|
||||
guard.camera_trauma = (t - decay).max(0.0);
|
||||
t
|
||||
}
|
||||
|
||||
pub fn pop_events(&self) -> Vec<GameEvent> {
|
||||
let mut guard = self.inner.write().unwrap();
|
||||
let events = guard.event_queue.clone();
|
||||
guard.event_queue.clear();
|
||||
events
|
||||
}
|
||||
|
||||
pub fn get_lift_progress(&self) -> f32 {
|
||||
self.inner.read().unwrap().lift_progress
|
||||
}
|
||||
|
||||
pub fn get_is_lifting(&self) -> bool {
|
||||
self.inner.read().unwrap().is_lifting
|
||||
}
|
||||
|
||||
pub fn get_is_lift_complete(&self) -> bool {
|
||||
self.inner.read().unwrap().is_lift_complete
|
||||
}
|
||||
|
||||
pub fn get_is_lift_failed(&self) -> bool {
|
||||
self.inner.read().unwrap().is_lift_failed
|
||||
}
|
||||
}
|
||||
1
rust/src/core/mod.rs
Normal file
1
rust/src/core/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod game_state;
|
||||
37
rust/src/data/day_config.rs
Normal file
37
rust/src/data/day_config.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use godot::prelude::*;
|
||||
|
||||
use crate::data::hazard_def::HazardDef;
|
||||
|
||||
#[derive(GodotClass)]
|
||||
#[class(base=Resource)]
|
||||
pub struct DayConfig {
|
||||
#[export]
|
||||
pub day_number: i32,
|
||||
#[export]
|
||||
pub day_title: GString,
|
||||
#[export]
|
||||
pub target_weight: f32,
|
||||
#[export]
|
||||
pub gravity: f32,
|
||||
#[export]
|
||||
pub mini_game_scene: Option<Gd<PackedScene>>,
|
||||
#[export]
|
||||
pub available_hazards: Array<Gd<HazardDef>>,
|
||||
|
||||
base: Base<Resource>,
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl IResource for DayConfig {
|
||||
fn init(base: Base<Resource>) -> Self {
|
||||
Self {
|
||||
day_number: 0,
|
||||
day_title: "Day 1".into(),
|
||||
target_weight: 100.0,
|
||||
gravity: 2.0,
|
||||
mini_game_scene: None,
|
||||
available_hazards: Array::new(),
|
||||
base,
|
||||
}
|
||||
}
|
||||
}
|
||||
55
rust/src/data/hazard_def.rs
Normal file
55
rust/src/data/hazard_def.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use godot::{classes::SpriteFrames, prelude::*};
|
||||
|
||||
#[derive(GodotConvert, Export, Var, Default, Clone, PartialEq, Eq, Debug)]
|
||||
#[godot(via=GString)]
|
||||
pub enum HazardType {
|
||||
#[default]
|
||||
None,
|
||||
GymBro,
|
||||
LoosePlate,
|
||||
PhoneDistraction,
|
||||
Manager,
|
||||
}
|
||||
|
||||
#[derive(GodotClass)]
|
||||
#[class(base=Resource)]
|
||||
pub struct HazardDef {
|
||||
#[export]
|
||||
pub hazard_type: HazardType,
|
||||
#[export]
|
||||
pub display_name: GString,
|
||||
#[export]
|
||||
pub animations: Option<Gd<SpriteFrames>>,
|
||||
#[export]
|
||||
pub idle_anim_name: GString,
|
||||
#[export]
|
||||
pub walk_anim_name: GString,
|
||||
#[export]
|
||||
pub time_to_fail: f32,
|
||||
#[export]
|
||||
pub spawn_weight: f32,
|
||||
#[export]
|
||||
pub min_focus_to_spawn: f32,
|
||||
#[export]
|
||||
pub clicks_to_resolve: i32,
|
||||
|
||||
base: Base<Resource>,
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl IResource for HazardDef {
|
||||
fn init(base: Base<Resource>) -> Self {
|
||||
Self {
|
||||
hazard_type: HazardType::None,
|
||||
display_name: "Unnamed Hazard".into(),
|
||||
animations: None,
|
||||
idle_anim_name: "idle".into(),
|
||||
walk_anim_name: "walk".into(),
|
||||
time_to_fail: 5.0,
|
||||
spawn_weight: 1.0,
|
||||
min_focus_to_spawn: 0.0,
|
||||
clicks_to_resolve: 1,
|
||||
base,
|
||||
}
|
||||
}
|
||||
}
|
||||
4
rust/src/data/mod.rs
Normal file
4
rust/src/data/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod day_config;
|
||||
pub mod hazard_def;
|
||||
pub mod sound_bank;
|
||||
pub mod tunnel_config;
|
||||
48
rust/src/data/sound_bank.rs
Normal file
48
rust/src/data/sound_bank.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use godot::{classes::AudioStream, prelude::*};
|
||||
|
||||
#[derive(GodotClass)]
|
||||
#[class(base=Resource)]
|
||||
pub struct SoundBank {
|
||||
#[export]
|
||||
pub strain_loop: Option<Gd<AudioStream>>,
|
||||
#[export]
|
||||
pub effort_exhale: Option<Gd<AudioStream>>,
|
||||
#[export]
|
||||
pub win_stinger: Option<Gd<AudioStream>>,
|
||||
#[export]
|
||||
pub fail_stinger: Option<Gd<AudioStream>>,
|
||||
#[export]
|
||||
pub heartbeat_loop: Option<Gd<AudioStream>>,
|
||||
#[export]
|
||||
pub hazard_spawn: Option<Gd<AudioStream>>,
|
||||
#[export]
|
||||
pub hazard_resolve: Option<Gd<AudioStream>>,
|
||||
#[export]
|
||||
pub camera_trauma: Option<Gd<AudioStream>>,
|
||||
|
||||
#[export]
|
||||
pub menu_music: Option<Gd<AudioStream>>,
|
||||
#[export]
|
||||
pub game_music: Option<Gd<AudioStream>>,
|
||||
|
||||
base: Base<Resource>,
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl IResource for SoundBank {
|
||||
fn init(base: Base<Resource>) -> Self {
|
||||
Self {
|
||||
strain_loop: None,
|
||||
effort_exhale: None,
|
||||
win_stinger: None,
|
||||
fail_stinger: None,
|
||||
heartbeat_loop: None,
|
||||
hazard_spawn: None,
|
||||
hazard_resolve: None,
|
||||
camera_trauma: None,
|
||||
menu_music: None,
|
||||
game_music: None,
|
||||
base,
|
||||
}
|
||||
}
|
||||
}
|
||||
29
rust/src/data/tunnel_config.rs
Normal file
29
rust/src/data/tunnel_config.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use godot::{classes::Curve, prelude::*};
|
||||
|
||||
#[derive(GodotClass)]
|
||||
#[class(base=Resource)]
|
||||
pub struct TunnelConfig {
|
||||
#[export]
|
||||
pub vision_narrow_rate: f32,
|
||||
#[export]
|
||||
pub vision_recover_rate: f32,
|
||||
#[export]
|
||||
pub max_tunnel_intensity: f32,
|
||||
#[export]
|
||||
pub vision_curve: Option<Gd<Curve>>,
|
||||
|
||||
base: Base<Resource>,
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl IResource for TunnelConfig {
|
||||
fn init(base: Base<Resource>) -> Self {
|
||||
Self {
|
||||
vision_narrow_rate: 0.5,
|
||||
vision_recover_rate: 2.0,
|
||||
max_tunnel_intensity: 0.95,
|
||||
vision_curve: None,
|
||||
base,
|
||||
}
|
||||
}
|
||||
}
|
||||
12
rust/src/lib.rs
Normal file
12
rust/src/lib.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use godot::prelude::*;
|
||||
|
||||
mod core;
|
||||
mod data;
|
||||
mod systems;
|
||||
mod ui;
|
||||
mod visuals;
|
||||
|
||||
struct MaxEffort;
|
||||
|
||||
#[gdextension]
|
||||
unsafe impl ExtensionLibrary for MaxEffort {}
|
||||
70
rust/src/systems/bench_press_system.rs
Normal file
70
rust/src/systems/bench_press_system.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use crate::core::game_state::GameState;
|
||||
use godot::prelude::*;
|
||||
|
||||
#[derive(GodotClass)]
|
||||
#[class(base=Node)]
|
||||
pub struct BenchPressSystem {
|
||||
#[export]
|
||||
power_per_click: f32,
|
||||
#[export]
|
||||
gravity: f32,
|
||||
#[export]
|
||||
target_value: f32,
|
||||
|
||||
#[export]
|
||||
game_state: Option<Gd<GameState>>,
|
||||
|
||||
current_progress: f32,
|
||||
is_lift_complete: bool,
|
||||
|
||||
base: Base<Node>,
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl INode for BenchPressSystem {
|
||||
fn init(base: Base<Node>) -> Self {
|
||||
Self {
|
||||
power_per_click: 5.0,
|
||||
gravity: 2.0,
|
||||
target_value: 100.0,
|
||||
game_state: None,
|
||||
current_progress: 0.0,
|
||||
is_lift_complete: false,
|
||||
base,
|
||||
}
|
||||
}
|
||||
|
||||
fn process(&mut self, delta: f64) {
|
||||
if self.is_lift_complete {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(state) = &self.game_state else {
|
||||
return;
|
||||
};
|
||||
let dt = delta as f32;
|
||||
|
||||
let state_bind = state.bind();
|
||||
let effort = state_bind.consume_effort();
|
||||
let hazard_count = state_bind.get_active_hazard_count();
|
||||
drop(state_bind);
|
||||
|
||||
if self.current_progress > 0.0 {
|
||||
self.current_progress -= self.gravity * dt;
|
||||
self.current_progress = self.current_progress.max(0.0);
|
||||
}
|
||||
|
||||
if hazard_count == 0 {
|
||||
self.current_progress += self.power_per_click * effort;
|
||||
}
|
||||
|
||||
if self.current_progress >= self.target_value {
|
||||
self.is_lift_complete = true;
|
||||
state.bind().set_complete(true);
|
||||
}
|
||||
|
||||
let ratio = self.current_progress / self.target_value;
|
||||
|
||||
state.bind().set_lift_state(ratio, ratio);
|
||||
}
|
||||
}
|
||||
86
rust/src/systems/camera_shake_system.rs
Normal file
86
rust/src/systems/camera_shake_system.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use crate::core::game_state::GameState;
|
||||
use godot::{
|
||||
classes::{Camera2D, RandomNumberGenerator},
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
#[derive(GodotClass)]
|
||||
#[class(base=Node)]
|
||||
pub struct CameraShakeSystem {
|
||||
#[export]
|
||||
camera: Option<Gd<Camera2D>>,
|
||||
|
||||
#[export]
|
||||
game_state: Option<Gd<GameState>>,
|
||||
|
||||
#[export]
|
||||
decay_rate: f32,
|
||||
#[export]
|
||||
max_offset: f32,
|
||||
#[export]
|
||||
max_roll: f32,
|
||||
#[export]
|
||||
min_focus_for_shake: f32,
|
||||
|
||||
trauma: f32,
|
||||
rng: Gd<RandomNumberGenerator>,
|
||||
|
||||
base: Base<Node>,
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl INode for CameraShakeSystem {
|
||||
fn init(base: Base<Node>) -> Self {
|
||||
Self {
|
||||
camera: None,
|
||||
game_state: None,
|
||||
decay_rate: 0.8,
|
||||
max_offset: 20.0,
|
||||
max_roll: 0.1,
|
||||
trauma: 0.0,
|
||||
min_focus_for_shake: 0.1,
|
||||
rng: RandomNumberGenerator::new_gd(),
|
||||
base,
|
||||
}
|
||||
}
|
||||
|
||||
fn process(&mut self, delta: f64) {
|
||||
let Some(camera) = &mut self.camera else {
|
||||
godot_error!("CameraShakeSystem: No camera assigned");
|
||||
return;
|
||||
};
|
||||
let Some(state) = &self.game_state else {
|
||||
godot_error!("CameraShakeSystem: No game state assigned");
|
||||
return;
|
||||
};
|
||||
|
||||
let dt = delta as f32;
|
||||
|
||||
let focus = state.bind().get_focus();
|
||||
|
||||
if focus > self.min_focus_for_shake {
|
||||
state.bind().add_trauma(1.5 * dt);
|
||||
}
|
||||
|
||||
let new_trauma = state.bind().consume_trauma(self.decay_rate * dt);
|
||||
self.trauma = new_trauma;
|
||||
|
||||
if self.trauma > 0.0 {
|
||||
let shake = self.trauma * self.trauma;
|
||||
|
||||
let offset_x = self.max_offset * shake * self.rng.randf_range(-1.0, 1.0);
|
||||
let offset_y = self.max_offset * shake * self.rng.randf_range(-1.0, 1.0);
|
||||
let rotation = self.max_roll * shake * self.rng.randf_range(-1.0, 1.0);
|
||||
|
||||
camera.set_offset(Vector2::new(offset_x, offset_y));
|
||||
camera.set_rotation(rotation);
|
||||
} else {
|
||||
if camera.get_offset() != Vector2::ZERO {
|
||||
let curr_off = camera.get_offset();
|
||||
let curr_rot = camera.get_rotation();
|
||||
camera.set_offset(curr_off.lerp(Vector2::ZERO, dt * 5.0));
|
||||
camera.set_rotation(curr_rot.lerp(0.0, dt * 5.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
105
rust/src/systems/deadlift_system.rs
Normal file
105
rust/src/systems/deadlift_system.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use crate::core::game_state::GameState;
|
||||
use godot::prelude::*;
|
||||
|
||||
#[derive(GodotClass)]
|
||||
#[class(base=Node)]
|
||||
pub struct DeadliftSystem {
|
||||
#[export]
|
||||
power_per_click: f32,
|
||||
#[export]
|
||||
gravity: f32,
|
||||
#[export]
|
||||
target_value: f32,
|
||||
|
||||
#[export]
|
||||
bar_height: f32,
|
||||
#[export]
|
||||
hold_zone_threshold: f32,
|
||||
#[export]
|
||||
bar_visual: Option<Gd<Node2D>>,
|
||||
#[export]
|
||||
start_pos: Vector2,
|
||||
#[export]
|
||||
end_pos: Vector2,
|
||||
|
||||
#[export]
|
||||
game_state: Option<Gd<GameState>>,
|
||||
|
||||
current_bar_height: f32,
|
||||
hold_timer: f32,
|
||||
is_lift_complete: bool,
|
||||
|
||||
base: Base<Node>,
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl INode for DeadliftSystem {
|
||||
fn init(base: Base<Node>) -> Self {
|
||||
Self {
|
||||
power_per_click: 5.0,
|
||||
gravity: 2.0,
|
||||
target_value: 5.0,
|
||||
|
||||
bar_height: 100.0,
|
||||
hold_zone_threshold: 0.8,
|
||||
bar_visual: None,
|
||||
start_pos: Vector2::ZERO,
|
||||
end_pos: Vector2::ZERO,
|
||||
|
||||
game_state: None,
|
||||
|
||||
current_bar_height: 0.0,
|
||||
hold_timer: 0.0,
|
||||
is_lift_complete: false,
|
||||
base,
|
||||
}
|
||||
}
|
||||
|
||||
fn process(&mut self, delta: f64) {
|
||||
if self.is_lift_complete {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(state) = &self.game_state else {
|
||||
return;
|
||||
};
|
||||
let dt = delta as f32;
|
||||
|
||||
let state_bind = state.bind();
|
||||
let effort = state_bind.consume_effort();
|
||||
let hazard_count = state_bind.get_active_hazard_count();
|
||||
drop(state_bind);
|
||||
|
||||
if self.current_bar_height > 0.0 {
|
||||
self.current_bar_height -= self.gravity * dt;
|
||||
self.current_bar_height = self.current_bar_height.max(0.0);
|
||||
}
|
||||
|
||||
if hazard_count == 0 {
|
||||
self.current_bar_height += self.power_per_click * effort;
|
||||
self.current_bar_height = self.current_bar_height.min(self.bar_height);
|
||||
}
|
||||
|
||||
let visual_ratio = self.current_bar_height / self.bar_height;
|
||||
|
||||
if visual_ratio >= self.hold_zone_threshold && hazard_count == 0 {
|
||||
self.hold_timer += dt;
|
||||
|
||||
state.bind().add_trauma(0.15 * dt);
|
||||
}
|
||||
|
||||
if let Some(visual) = &mut self.bar_visual {
|
||||
let new_pos = self.start_pos.lerp(self.end_pos, visual_ratio);
|
||||
visual.set_position(new_pos);
|
||||
}
|
||||
|
||||
let progress = self.hold_timer / self.target_value;
|
||||
state.bind().set_lift_state(progress, visual_ratio);
|
||||
|
||||
if self.hold_timer >= self.target_value {
|
||||
self.is_lift_complete = true;
|
||||
state.bind().add_trauma(1.0);
|
||||
state.bind().set_complete(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
207
rust/src/systems/game_manager.rs
Normal file
207
rust/src/systems/game_manager.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
use crate::{
|
||||
core::game_state::GameState, data::day_config::DayConfig, systems::hazard_system::HazardSystem,
|
||||
};
|
||||
use godot::{
|
||||
classes::{Control, Label},
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
#[derive(GodotClass)]
|
||||
#[class(base=Node)]
|
||||
pub struct GameManager {
|
||||
#[export]
|
||||
days: Array<Gd<DayConfig>>,
|
||||
|
||||
#[export]
|
||||
game_state: Option<Gd<GameState>>,
|
||||
|
||||
#[export]
|
||||
hazard_system: Option<Gd<HazardSystem>>,
|
||||
#[export]
|
||||
minigame_container: Option<Gd<Node>>,
|
||||
|
||||
#[export]
|
||||
win_screen: Option<Gd<Control>>,
|
||||
#[export]
|
||||
lose_screen: Option<Gd<Control>>,
|
||||
#[export]
|
||||
game_complete_screen: Option<Gd<Control>>,
|
||||
|
||||
#[export]
|
||||
day_label: Option<Gd<Label>>,
|
||||
#[export]
|
||||
main_menu_scene: Option<Gd<PackedScene>>,
|
||||
|
||||
current_day_index: i32,
|
||||
current_mini_game: Option<Gd<Node>>,
|
||||
|
||||
level_ended: bool,
|
||||
|
||||
base: Base<Node>,
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl INode for GameManager {
|
||||
fn init(base: Base<Node>) -> Self {
|
||||
Self {
|
||||
days: Array::new(),
|
||||
game_state: None,
|
||||
hazard_system: None,
|
||||
minigame_container: None,
|
||||
win_screen: None,
|
||||
lose_screen: None,
|
||||
game_complete_screen: None,
|
||||
day_label: None,
|
||||
main_menu_scene: None,
|
||||
current_day_index: 0,
|
||||
current_mini_game: None,
|
||||
level_ended: false,
|
||||
base,
|
||||
}
|
||||
}
|
||||
|
||||
fn ready(&mut self) {
|
||||
let idx = self.current_day_index;
|
||||
self.base_mut()
|
||||
.call_deferred("start_day", &[idx.to_variant()]);
|
||||
}
|
||||
|
||||
fn process(&mut self, _delta: f64) {
|
||||
if self.level_ended {
|
||||
return;
|
||||
}
|
||||
|
||||
let (won, lost) = if let Some(state) = &self.game_state {
|
||||
let bind = state.bind();
|
||||
(bind.get_is_lift_complete(), bind.get_is_lift_failed())
|
||||
} else {
|
||||
(false, false)
|
||||
};
|
||||
|
||||
if won {
|
||||
self.handle_win();
|
||||
self.level_ended = true;
|
||||
} else if lost {
|
||||
self.handle_loss();
|
||||
self.level_ended = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl GameManager {
|
||||
#[func]
|
||||
fn start_day(&mut self, index: i32) {
|
||||
self.level_ended = false;
|
||||
|
||||
if let Some(state) = &self.game_state {
|
||||
let bind = state.bind();
|
||||
|
||||
if let Ok(mut inner) = bind.inner.write() {
|
||||
inner.is_lift_complete = false;
|
||||
inner.is_lift_failed = false;
|
||||
|
||||
inner.lift_progress = 0.0;
|
||||
inner.visual_height = 0.0;
|
||||
inner.lift_effort = 0.0;
|
||||
inner.active_hazards.clear();
|
||||
}
|
||||
}
|
||||
|
||||
if index >= self.days.len() as i32 {
|
||||
self.cleanup_level();
|
||||
if let Some(s) = &mut self.game_complete_screen {
|
||||
s.set_visible(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
self.cleanup_level();
|
||||
|
||||
let config = self.days.get(index as usize).expect("Invalid Day Index");
|
||||
let config_bind = config.bind();
|
||||
|
||||
if let Some(prefab) = &config_bind.mini_game_scene {
|
||||
if let Some(instance) = prefab.instantiate() {
|
||||
if let Some(container) = &mut self.minigame_container {
|
||||
container.add_child(&instance.clone());
|
||||
}
|
||||
|
||||
if let Some(mut system) = instance.get_node_or_null("System") {
|
||||
system.set("target_value", &config_bind.target_weight.to_variant());
|
||||
system.set("gravity", &config_bind.gravity.to_variant());
|
||||
|
||||
if let Some(state) = &self.game_state {
|
||||
system.set("game_state", &state.to_variant());
|
||||
}
|
||||
}
|
||||
self.current_mini_game = Some(instance);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(hs) = &mut self.hazard_system {
|
||||
hs.bind_mut()
|
||||
.set_available_hazards(config_bind.available_hazards.clone());
|
||||
}
|
||||
|
||||
if let Some(l) = &mut self.day_label {
|
||||
l.set_text(&config_bind.day_title);
|
||||
}
|
||||
if let Some(s) = &mut self.win_screen {
|
||||
s.set_visible(false);
|
||||
}
|
||||
if let Some(s) = &mut self.lose_screen {
|
||||
s.set_visible(false);
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup_level(&mut self) {
|
||||
if let Some(game) = &mut self.current_mini_game {
|
||||
game.queue_free();
|
||||
self.current_mini_game = None;
|
||||
}
|
||||
if let Some(hs) = &mut self.hazard_system {
|
||||
hs.bind_mut().clear_hazards();
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_win(&mut self) {
|
||||
if let Some(s) = &mut self.win_screen {
|
||||
s.set_visible(true);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_loss(&mut self) {
|
||||
if let Some(s) = &mut self.lose_screen {
|
||||
s.set_visible(true);
|
||||
}
|
||||
}
|
||||
|
||||
#[func]
|
||||
pub fn on_next_day_pressed(&mut self) {
|
||||
self.current_day_index += 1;
|
||||
let idx = self.current_day_index;
|
||||
self.base_mut()
|
||||
.call_deferred("start_day", &[idx.to_variant()]);
|
||||
}
|
||||
|
||||
#[func]
|
||||
pub fn on_retry_pressed(&mut self) {
|
||||
let idx = self.current_day_index;
|
||||
self.base_mut()
|
||||
.call_deferred("start_day", &[idx.to_variant()]);
|
||||
}
|
||||
|
||||
#[func]
|
||||
pub fn on_menu_pressed(&mut self) {
|
||||
if let Some(menu) = &self.main_menu_scene {
|
||||
let menu_ref = menu.clone();
|
||||
self.base()
|
||||
.get_tree()
|
||||
.unwrap()
|
||||
.change_scene_to_packed(&menu_ref);
|
||||
} else {
|
||||
self.base().get_tree().unwrap().quit();
|
||||
}
|
||||
}
|
||||
}
|
||||
209
rust/src/systems/hazard_controller.rs
Normal file
209
rust/src/systems/hazard_controller.rs
Normal file
@@ -0,0 +1,209 @@
|
||||
use godot::{
|
||||
classes::{
|
||||
AnimatedSprite2D, Area2D, CollisionShape2D, InputEvent, InputEventMouseButton, Label,
|
||||
RectangleShape2D,
|
||||
},
|
||||
global::MouseButton,
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
use crate::{core::game_state::GameState, data::hazard_def::HazardDef};
|
||||
|
||||
#[derive(GodotClass)]
|
||||
#[class(base=Node2D)]
|
||||
pub struct HazardController {
|
||||
#[export]
|
||||
anim_sprite: Option<Gd<AnimatedSprite2D>>,
|
||||
#[export]
|
||||
click_area: Option<Gd<Area2D>>,
|
||||
#[export]
|
||||
click_shape: Option<Gd<CollisionShape2D>>,
|
||||
#[export]
|
||||
name_label: Option<Gd<Label>>,
|
||||
|
||||
#[export]
|
||||
game_state: Option<Gd<GameState>>,
|
||||
|
||||
data: Option<Gd<HazardDef>>,
|
||||
time_active: f32,
|
||||
is_resolved: bool,
|
||||
current_health: i32,
|
||||
|
||||
base: Base<Node2D>,
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl INode2D for HazardController {
|
||||
fn init(base: Base<Node2D>) -> Self {
|
||||
Self {
|
||||
anim_sprite: None,
|
||||
click_area: None,
|
||||
click_shape: None,
|
||||
name_label: None,
|
||||
game_state: None,
|
||||
data: None,
|
||||
time_active: 0.0,
|
||||
is_resolved: false,
|
||||
current_health: 1,
|
||||
base,
|
||||
}
|
||||
}
|
||||
|
||||
fn ready(&mut self) {
|
||||
if let Some(mut area) = self.click_area.clone() {
|
||||
let callable = self.base_mut().callable("on_input_event");
|
||||
area.connect("input_event", &callable);
|
||||
}
|
||||
}
|
||||
|
||||
fn process(&mut self, delta: f64) {
|
||||
if self.is_resolved {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(data) = &self.data.clone() else {
|
||||
return;
|
||||
};
|
||||
let data_bind = data.bind();
|
||||
|
||||
self.time_active += delta as f32;
|
||||
|
||||
if self.time_active >= data_bind.time_to_fail {
|
||||
if let Some(state) = &self.game_state {
|
||||
state.bind().set_complete(false);
|
||||
}
|
||||
self.base_mut().queue_free();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl HazardController {
|
||||
#[func]
|
||||
pub fn initialize(&mut self, data: Gd<HazardDef>) {
|
||||
let data_bind = data.bind();
|
||||
self.current_health = data_bind.clicks_to_resolve;
|
||||
|
||||
if let Some(sprite) = &mut self.anim_sprite {
|
||||
if let Some(frames) = &data_bind.animations {
|
||||
sprite.set_sprite_frames(frames);
|
||||
|
||||
let anim_name = StringName::from(&data_bind.idle_anim_name);
|
||||
|
||||
if let Some(texture) = frames.get_frame_texture(&anim_name, 0) {
|
||||
if let Some(shape) = &mut self.click_shape {
|
||||
let mut rect = RectangleShape2D::new_gd();
|
||||
rect.set_size(texture.get_size());
|
||||
shape.set_shape(&rect.upcast::<RectangleShape2D>());
|
||||
}
|
||||
}
|
||||
|
||||
sprite.play_ex().name(&anim_name).done();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(label) = &mut self.name_label {
|
||||
label.set_text(&data_bind.display_name);
|
||||
}
|
||||
|
||||
self.base_mut().set_scale(Vector2::ZERO);
|
||||
|
||||
let node = self.base().clone();
|
||||
|
||||
if let Some(mut tween) = self.base_mut().create_tween() {
|
||||
tween
|
||||
.set_trans(godot::classes::tween::TransitionType::BACK)
|
||||
.unwrap()
|
||||
.set_ease(godot::classes::tween::EaseType::OUT);
|
||||
|
||||
tween.tween_property(
|
||||
&node.upcast::<Object>(),
|
||||
"scale",
|
||||
&Vector2::ONE.to_variant(),
|
||||
0.4,
|
||||
);
|
||||
}
|
||||
|
||||
drop(data_bind);
|
||||
self.data = Some(data);
|
||||
}
|
||||
|
||||
#[func]
|
||||
fn on_input_event(&mut self, _viewport: Gd<Node>, event: Gd<InputEvent>, _shape_idx: i64) {
|
||||
if self.is_resolved {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(mouse_event) = event.try_cast::<InputEventMouseButton>() {
|
||||
if mouse_event.is_pressed() && mouse_event.get_button_index() == MouseButton::LEFT {
|
||||
self.take_damage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn take_damage(&mut self) {
|
||||
self.current_health -= 1;
|
||||
|
||||
if let Some(sprite) = &mut self.anim_sprite {
|
||||
sprite.set_modulate(Color::RED);
|
||||
}
|
||||
|
||||
let node = self.base().clone();
|
||||
let sprite_opt = self.anim_sprite.clone();
|
||||
|
||||
if let Some(mut tween) = self.base_mut().create_tween() {
|
||||
tween.tween_property(
|
||||
&node.clone().upcast::<Object>(),
|
||||
"scale",
|
||||
&Vector2::new(1.2, 0.8).to_variant(),
|
||||
0.05,
|
||||
);
|
||||
tween.tween_property(
|
||||
&node.clone().upcast::<Object>(),
|
||||
"scale",
|
||||
&Vector2::ONE.to_variant(),
|
||||
0.05,
|
||||
);
|
||||
|
||||
// Flash Red
|
||||
if let Some(sprite) = sprite_opt {
|
||||
if let Some(mut parallel) = tween.parallel() {
|
||||
parallel.tween_property(
|
||||
&sprite.upcast::<Object>(),
|
||||
"modulate",
|
||||
&Color::WHITE.to_variant(),
|
||||
0.1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.current_health <= 0 {
|
||||
self.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve(&mut self) {
|
||||
self.is_resolved = true;
|
||||
|
||||
if let Some(data) = &self.data {
|
||||
let hazard_type = data.bind().hazard_type.clone();
|
||||
|
||||
if let Some(state) = &self.game_state {
|
||||
state.bind().remove_active_hazard(hazard_type);
|
||||
}
|
||||
}
|
||||
|
||||
let node = self.base().clone();
|
||||
|
||||
if let Some(mut tween) = self.base_mut().create_tween() {
|
||||
tween.tween_property(
|
||||
&node.clone().upcast::<Object>(),
|
||||
"scale",
|
||||
&Vector2::ZERO.to_variant(),
|
||||
0.2,
|
||||
);
|
||||
tween.tween_callback(&node.callable("queue_free"));
|
||||
}
|
||||
}
|
||||
}
|
||||
144
rust/src/systems/hazard_system.rs
Normal file
144
rust/src/systems/hazard_system.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
use godot::{classes::RandomNumberGenerator, prelude::*};
|
||||
|
||||
use crate::{
|
||||
core::game_state::GameState, data::hazard_def::HazardDef,
|
||||
systems::hazard_controller::HazardController,
|
||||
};
|
||||
|
||||
#[derive(GodotClass)]
|
||||
#[class(base=Node)]
|
||||
pub struct HazardSystem {
|
||||
#[export]
|
||||
possible_hazards: Array<Gd<HazardDef>>,
|
||||
#[export]
|
||||
spawn_locations: Array<Gd<Node2D>>,
|
||||
#[export]
|
||||
hazard_prefab: Option<Gd<PackedScene>>,
|
||||
#[export]
|
||||
check_interval: f32,
|
||||
|
||||
#[export]
|
||||
game_state: Option<Gd<GameState>>,
|
||||
|
||||
timer: f32,
|
||||
rng: Gd<RandomNumberGenerator>,
|
||||
|
||||
base: Base<Node>,
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl INode for HazardSystem {
|
||||
fn init(base: Base<Node>) -> Self {
|
||||
Self {
|
||||
possible_hazards: Array::new(),
|
||||
spawn_locations: Array::new(),
|
||||
hazard_prefab: None,
|
||||
check_interval: 1.0,
|
||||
game_state: None,
|
||||
timer: 0.0,
|
||||
rng: RandomNumberGenerator::new_gd(),
|
||||
base,
|
||||
}
|
||||
}
|
||||
|
||||
fn process(&mut self, delta: f64) {
|
||||
self.timer += delta as f32;
|
||||
if self.timer >= self.check_interval {
|
||||
self.timer = 0.0;
|
||||
self.try_spawn_hazard();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl HazardSystem {
|
||||
#[func]
|
||||
pub fn clear_hazards(&mut self) {
|
||||
for location in self.spawn_locations.iter_shared() {
|
||||
for mut child in location.get_children().iter_shared() {
|
||||
child.queue_free();
|
||||
}
|
||||
}
|
||||
self.timer = 0.0;
|
||||
}
|
||||
|
||||
#[func]
|
||||
pub fn set_available_hazards(&mut self, hazards: Array<Gd<HazardDef>>) {
|
||||
self.possible_hazards = hazards;
|
||||
}
|
||||
|
||||
fn try_spawn_hazard(&mut self) {
|
||||
let Some(state) = &self.game_state else {
|
||||
return;
|
||||
};
|
||||
|
||||
let current_focus = state.bind().get_focus();
|
||||
|
||||
if current_focus < 0.2 {
|
||||
return;
|
||||
}
|
||||
|
||||
let spawn_chance = current_focus * 0.5;
|
||||
if self.rng.randf() < spawn_chance {
|
||||
self.spawn_random_hazard(current_focus);
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_random_hazard(&mut self, current_focus: f32) {
|
||||
if self.possible_hazards.is_empty()
|
||||
|| self.spawn_locations.is_empty()
|
||||
|| self.hazard_prefab.is_none()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let empty_locations: Vec<Gd<Node2D>> = self
|
||||
.spawn_locations
|
||||
.iter_shared()
|
||||
.filter(|loc| loc.get_child_count() == 0)
|
||||
.collect();
|
||||
|
||||
if empty_locations.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let loc_idx = self.rng.randi() as usize % empty_locations.len();
|
||||
let mut target_loc = empty_locations[loc_idx].clone();
|
||||
|
||||
let valid_hazards: Vec<Gd<HazardDef>> = self
|
||||
.possible_hazards
|
||||
.iter_shared()
|
||||
.filter(|h| h.bind().min_focus_to_spawn <= current_focus)
|
||||
.collect();
|
||||
|
||||
if valid_hazards.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let haz_idx = self.rng.randi() as usize % valid_hazards.len();
|
||||
let selected_hazard = valid_hazards[haz_idx].clone();
|
||||
let hazard_type = selected_hazard.bind().hazard_type.clone();
|
||||
|
||||
if let Some(prefab) = &self.hazard_prefab {
|
||||
if let Some(instance) = prefab.instantiate() {
|
||||
if let Ok(mut hazard_node) = instance.try_cast::<HazardController>() {
|
||||
hazard_node
|
||||
.clone()
|
||||
.upcast::<Node2D>()
|
||||
.set_position(Vector2::ZERO);
|
||||
|
||||
target_loc.add_child(&hazard_node.clone().upcast::<Node>());
|
||||
|
||||
hazard_node.bind_mut().initialize(selected_hazard);
|
||||
|
||||
if let Some(state) = &self.game_state {
|
||||
hazard_node.bind_mut().set_game_state(Some(state.clone()));
|
||||
state.bind().add_active_hazard(hazard_type);
|
||||
}
|
||||
} else {
|
||||
godot_print!("Error: Hazard Prefab root is not a HazardController!");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
rust/src/systems/mod.rs
Normal file
9
rust/src/systems/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod bench_press_system;
|
||||
pub mod camera_shake_system;
|
||||
pub mod deadlift_system;
|
||||
pub mod game_manager;
|
||||
pub mod hazard_controller;
|
||||
pub mod hazard_system;
|
||||
pub mod player_input_system;
|
||||
pub mod sound_manager;
|
||||
pub mod tunnel_system;
|
||||
40
rust/src/systems/player_input_system.rs
Normal file
40
rust/src/systems/player_input_system.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use godot::{classes::Input, prelude::*};
|
||||
|
||||
use crate::core::game_state::GameState;
|
||||
|
||||
const LIFT_ACTION: &str = "lift_action";
|
||||
|
||||
#[derive(GodotClass)]
|
||||
#[class(base=Node)]
|
||||
pub struct PlayerInputSystem {
|
||||
#[export]
|
||||
game_state: Option<Gd<GameState>>,
|
||||
base: Base<Node>,
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl INode for PlayerInputSystem {
|
||||
fn init(base: Base<Node>) -> Self {
|
||||
Self {
|
||||
game_state: None,
|
||||
base,
|
||||
}
|
||||
}
|
||||
|
||||
fn process(&mut self, delta: f64) {
|
||||
let Some(state) = &self.game_state else {
|
||||
return;
|
||||
};
|
||||
let state_bind = state.bind();
|
||||
|
||||
let input = Input::singleton();
|
||||
|
||||
if input.is_action_pressed(LIFT_ACTION) {
|
||||
state_bind.apply_effort(delta as f32);
|
||||
}
|
||||
|
||||
if input.is_action_just_released(LIFT_ACTION) {
|
||||
state_bind.release_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
213
rust/src/systems/sound_manager.rs
Normal file
213
rust/src/systems/sound_manager.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
use crate::{
|
||||
core::game_state::{GameEvent, GameState},
|
||||
data::sound_bank::SoundBank,
|
||||
};
|
||||
use godot::{
|
||||
classes::{AudioStream, AudioStreamPlayer},
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
#[derive(GodotClass)]
|
||||
#[class(base=Node)]
|
||||
pub struct SoundManager {
|
||||
#[export]
|
||||
bank: Option<Gd<SoundBank>>,
|
||||
#[export]
|
||||
game_state: Option<Gd<GameState>>,
|
||||
|
||||
#[export]
|
||||
pool_size: i32,
|
||||
|
||||
sfx_pool: Vec<Gd<AudioStreamPlayer>>,
|
||||
strain_player: Option<Gd<AudioStreamPlayer>>,
|
||||
heartbeat_player: Option<Gd<AudioStreamPlayer>>,
|
||||
music_player: Option<Gd<AudioStreamPlayer>>,
|
||||
|
||||
was_lifting: bool,
|
||||
|
||||
base: Base<Node>,
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl INode for SoundManager {
|
||||
fn init(base: Base<Node>) -> Self {
|
||||
Self {
|
||||
bank: None,
|
||||
game_state: None,
|
||||
pool_size: 8,
|
||||
sfx_pool: Vec::new(),
|
||||
strain_player: None,
|
||||
heartbeat_player: None,
|
||||
music_player: None,
|
||||
was_lifting: false,
|
||||
base,
|
||||
}
|
||||
}
|
||||
|
||||
fn ready(&mut self) {
|
||||
self.initialize_pool();
|
||||
|
||||
// FIX: Clone bank to release borrow on self
|
||||
if let Some(bank) = self.bank.clone() {
|
||||
if let Some(music) = &bank.bind().game_music {
|
||||
self.play_music(music.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn process(&mut self, _delta: f64) {
|
||||
let Some(state) = self.game_state.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let events = state.bind().pop_events();
|
||||
|
||||
for event in events {
|
||||
match event {
|
||||
GameEvent::HazardSpawned => self.play_bank_sfx(|b| b.hazard_spawn.clone()),
|
||||
GameEvent::HazardResolved => self.play_bank_sfx(|b| b.hazard_resolve.clone()),
|
||||
GameEvent::LiftCompleted(success) => {
|
||||
self.stop_strain();
|
||||
if success {
|
||||
self.play_bank_sfx(|b| b.win_stinger.clone());
|
||||
} else {
|
||||
self.play_bank_sfx(|b| b.fail_stinger.clone());
|
||||
}
|
||||
}
|
||||
GameEvent::TraumaApplied(amount) => {
|
||||
if amount > 0.5 {
|
||||
self.play_bank_sfx(|b| b.camera_trauma.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let is_lifting = state.bind().get_is_lifting();
|
||||
let progress = state.bind().get_lift_progress();
|
||||
let focus = state.bind().get_focus();
|
||||
|
||||
if is_lifting != self.was_lifting {
|
||||
if is_lifting {
|
||||
self.play_strain_loop();
|
||||
} else {
|
||||
self.stop_strain();
|
||||
self.play_bank_sfx(|b| b.effort_exhale.clone());
|
||||
}
|
||||
self.was_lifting = is_lifting;
|
||||
}
|
||||
|
||||
if is_lifting {
|
||||
self.update_strain_pitch(progress);
|
||||
}
|
||||
|
||||
self.update_heartbeat(focus);
|
||||
}
|
||||
}
|
||||
|
||||
impl SoundManager {
|
||||
fn initialize_pool(&mut self) {
|
||||
for _ in 0..self.pool_size {
|
||||
let mut p = AudioStreamPlayer::new_alloc();
|
||||
p.set_bus("Sfx");
|
||||
self.base_mut().add_child(&p.clone().upcast::<Node>());
|
||||
self.sfx_pool.push(p);
|
||||
}
|
||||
|
||||
let mut strain = AudioStreamPlayer::new_alloc();
|
||||
strain.set_bus("Sfx");
|
||||
self.base_mut().add_child(&strain.clone().upcast::<Node>());
|
||||
self.strain_player = Some(strain);
|
||||
|
||||
let mut hb = AudioStreamPlayer::new_alloc();
|
||||
hb.set_bus("Sfx");
|
||||
self.base_mut().add_child(&hb.clone().upcast::<Node>());
|
||||
self.heartbeat_player = Some(hb);
|
||||
|
||||
let mut mus = AudioStreamPlayer::new_alloc();
|
||||
mus.set_bus("Music");
|
||||
self.base_mut().add_child(&mus.clone().upcast::<Node>());
|
||||
self.music_player = Some(mus);
|
||||
|
||||
if let Some(bank) = self.bank.clone() {
|
||||
let bank_bind = bank.bind();
|
||||
if let Some(clip) = &bank_bind.heartbeat_loop {
|
||||
if let Some(hb_player) = &mut self.heartbeat_player {
|
||||
hb_player.set_stream(&clip.clone());
|
||||
hb_player.set_volume_db(-80.0);
|
||||
hb_player.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn play_music(&mut self, clip: Gd<AudioStream>) {
|
||||
if let Some(p) = &mut self.music_player {
|
||||
p.set_stream(&clip);
|
||||
p.play();
|
||||
}
|
||||
}
|
||||
|
||||
fn play_bank_sfx<F>(&mut self, selector: F)
|
||||
where
|
||||
F: FnOnce(&SoundBank) -> Option<Gd<AudioStream>>,
|
||||
{
|
||||
let clip = self.bank.as_ref().and_then(|b| selector(&b.bind()));
|
||||
|
||||
if let Some(c) = clip {
|
||||
self.play_sfx(c);
|
||||
}
|
||||
}
|
||||
|
||||
fn play_sfx(&mut self, clip: Gd<AudioStream>) {
|
||||
for p in &mut self.sfx_pool {
|
||||
if !p.is_playing() {
|
||||
p.set_stream(&clip);
|
||||
p.set_pitch_scale(1.0);
|
||||
p.play();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(p) = self.sfx_pool.first_mut() {
|
||||
p.set_stream(&clip);
|
||||
p.play();
|
||||
}
|
||||
}
|
||||
|
||||
fn play_strain_loop(&mut self) {
|
||||
if let Some(bank) = self.bank.clone() {
|
||||
if let Some(clip) = &bank.bind().strain_loop {
|
||||
if let Some(p) = &mut self.strain_player {
|
||||
p.set_stream(&clip.clone());
|
||||
p.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_strain(&mut self) {
|
||||
if let Some(p) = &mut self.strain_player {
|
||||
p.stop();
|
||||
}
|
||||
}
|
||||
|
||||
fn update_strain_pitch(&mut self, progress: f32) {
|
||||
if let Some(p) = &mut self.strain_player {
|
||||
let pitch = 1.0 + (progress * 0.3);
|
||||
p.set_pitch_scale(pitch);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_heartbeat(&mut self, focus: f32) {
|
||||
if let Some(p) = &mut self.heartbeat_player {
|
||||
if focus < 0.1 {
|
||||
p.set_volume_db(-80.0);
|
||||
} else {
|
||||
let t = (focus - 0.1) / 0.9;
|
||||
let vol = -30.0 + (t * (5.0 - (-30.0)));
|
||||
let pitch = 1.0 + (t * 0.4);
|
||||
p.set_volume_db(vol);
|
||||
p.set_pitch_scale(pitch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
82
rust/src/systems/tunnel_system.rs
Normal file
82
rust/src/systems/tunnel_system.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use godot::{
|
||||
classes::{ColorRect, ShaderMaterial},
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
use crate::{core::game_state::GameState, data::tunnel_config::TunnelConfig};
|
||||
|
||||
#[derive(GodotClass)]
|
||||
#[class(base=Node)]
|
||||
pub struct TunnelSystem {
|
||||
#[export]
|
||||
game_state: Option<Gd<GameState>>,
|
||||
#[export]
|
||||
config: Option<Gd<TunnelConfig>>,
|
||||
#[export]
|
||||
vignette_overlay: Option<Gd<ColorRect>>,
|
||||
|
||||
base: Base<Node>,
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl INode for TunnelSystem {
|
||||
fn init(base: Base<Node>) -> Self {
|
||||
Self {
|
||||
game_state: None,
|
||||
config: None,
|
||||
vignette_overlay: None,
|
||||
base,
|
||||
}
|
||||
}
|
||||
|
||||
fn process(&mut self, delta: f64) {
|
||||
let Some(state) = &self.game_state else {
|
||||
return;
|
||||
};
|
||||
let Some(config) = &self.config else {
|
||||
return;
|
||||
};
|
||||
|
||||
let state_bind = state.bind();
|
||||
let is_lifting = state_bind.get_is_lifting();
|
||||
let current_focus = state_bind.get_focus();
|
||||
|
||||
drop(state_bind);
|
||||
|
||||
let config_bind = config.bind();
|
||||
let dt = delta as f32;
|
||||
|
||||
let mut new_focus = current_focus;
|
||||
|
||||
if is_lifting {
|
||||
new_focus += config_bind.vision_narrow_rate * dt;
|
||||
} else {
|
||||
new_focus -= config_bind.vision_recover_rate * dt;
|
||||
}
|
||||
|
||||
new_focus = new_focus.clamp(0.0, 1.0);
|
||||
|
||||
let max_intensity = config_bind.max_tunnel_intensity;
|
||||
|
||||
let visual_raw = if let Some(curve) = &config_bind.vision_curve {
|
||||
curve.sample(new_focus)
|
||||
} else {
|
||||
new_focus
|
||||
};
|
||||
|
||||
let visual_value = visual_raw * max_intensity;
|
||||
|
||||
if let Some(overlay) = &mut self.vignette_overlay {
|
||||
if let Some(material) = overlay
|
||||
.get_material()
|
||||
.and_then(|m| Some(m.try_cast::<ShaderMaterial>()))
|
||||
{
|
||||
if let Ok(mut mat) = material {
|
||||
mat.set_shader_parameter("vignette_intensity", &visual_value.to_variant());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.bind().set_focus_intensity(new_focus);
|
||||
}
|
||||
}
|
||||
37
rust/src/ui/lift_progress_bar.rs
Normal file
37
rust/src/ui/lift_progress_bar.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use crate::core::game_state::GameState;
|
||||
use godot::{
|
||||
classes::{IProgressBar, ProgressBar},
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
#[derive(GodotClass)]
|
||||
#[class(base=ProgressBar)]
|
||||
pub struct LiftProgressBar {
|
||||
#[export]
|
||||
game_state: Option<Gd<GameState>>,
|
||||
|
||||
base: Base<ProgressBar>,
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl IProgressBar for LiftProgressBar {
|
||||
fn init(base: Base<ProgressBar>) -> Self {
|
||||
Self {
|
||||
game_state: None,
|
||||
base,
|
||||
}
|
||||
}
|
||||
|
||||
fn ready(&mut self) {
|
||||
self.base_mut().set_min(0.0);
|
||||
self.base_mut().set_max(1.0);
|
||||
self.base_mut().set_value(0.0);
|
||||
}
|
||||
|
||||
fn process(&mut self, _delta: f64) {
|
||||
if let Some(state) = &self.game_state {
|
||||
let progress = state.bind().get_lift_progress();
|
||||
self.base_mut().set_value(progress as f64);
|
||||
}
|
||||
}
|
||||
}
|
||||
59
rust/src/ui/main_menu.rs
Normal file
59
rust/src/ui/main_menu.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use godot::{
|
||||
classes::{AudioServer, CheckButton, Control, IControl},
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
#[derive(GodotClass)]
|
||||
#[class(base=Control)]
|
||||
pub struct MainMenu {
|
||||
#[export]
|
||||
game_scene: Option<Gd<PackedScene>>,
|
||||
|
||||
#[export]
|
||||
mute_button: Option<Gd<CheckButton>>,
|
||||
|
||||
base: Base<Control>,
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl IControl for MainMenu {
|
||||
fn init(base: Base<Control>) -> Self {
|
||||
Self {
|
||||
game_scene: None,
|
||||
mute_button: None,
|
||||
base,
|
||||
}
|
||||
}
|
||||
|
||||
fn ready(&mut self) {
|
||||
let mut audio_server = AudioServer::singleton();
|
||||
let master_bus = audio_server.get_bus_index("Master");
|
||||
audio_server.set_bus_mute(master_bus, false);
|
||||
}
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl MainMenu {
|
||||
#[func]
|
||||
fn on_play_pressed(&mut self) {
|
||||
if let Some(scene) = &self.game_scene {
|
||||
if let Some(mut tree) = self.base().get_tree() {
|
||||
tree.change_scene_to_packed(&scene.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[func]
|
||||
fn on_quit_pressed(&mut self) {
|
||||
if let Some(mut tree) = self.base().get_tree() {
|
||||
tree.quit();
|
||||
}
|
||||
}
|
||||
|
||||
#[func]
|
||||
fn on_mute_toggled(&mut self, toggled_on: bool) {
|
||||
let mut audio_server = AudioServer::singleton();
|
||||
let master_bus = audio_server.get_bus_index("Master");
|
||||
audio_server.set_bus_mute(master_bus, toggled_on);
|
||||
}
|
||||
}
|
||||
2
rust/src/ui/mod.rs
Normal file
2
rust/src/ui/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod lift_progress_bar;
|
||||
pub mod main_menu;
|
||||
107
rust/src/visuals/lift_sync_controller.rs
Normal file
107
rust/src/visuals/lift_sync_controller.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use crate::core::game_state::GameState;
|
||||
use godot::{
|
||||
classes::{AnimatedSprite2D, Sprite2D},
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
#[derive(GodotClass)]
|
||||
#[class(base=Node)]
|
||||
pub struct LiftSyncController {
|
||||
#[export]
|
||||
player_anim: Option<Gd<AnimatedSprite2D>>,
|
||||
#[export]
|
||||
barbell: Option<Gd<Sprite2D>>,
|
||||
#[export]
|
||||
game_state: Option<Gd<GameState>>,
|
||||
|
||||
#[export]
|
||||
animation_name: GString,
|
||||
#[export]
|
||||
bar_positions: Array<Vector2>,
|
||||
#[export]
|
||||
simulate_reps: bool,
|
||||
#[export]
|
||||
rep_speed: f32,
|
||||
|
||||
current_rep_progress: f32,
|
||||
|
||||
base: Base<Node>,
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl INode for LiftSyncController {
|
||||
fn init(base: Base<Node>) -> Self {
|
||||
Self {
|
||||
player_anim: None,
|
||||
barbell: None,
|
||||
game_state: None,
|
||||
animation_name: "lift".into(),
|
||||
bar_positions: Array::new(),
|
||||
simulate_reps: true,
|
||||
rep_speed: 2.0,
|
||||
current_rep_progress: 0.0,
|
||||
base,
|
||||
}
|
||||
}
|
||||
|
||||
fn process(&mut self, delta: f64) {
|
||||
let Some(state) = &self.game_state else {
|
||||
godot_error!("LiftSyncController: No game state assigned");
|
||||
return;
|
||||
};
|
||||
|
||||
let state_bind = state.bind();
|
||||
let is_lifting = state_bind.get_is_lifting();
|
||||
|
||||
let visual_height = state_bind.inner.read().unwrap().visual_height;
|
||||
drop(state_bind);
|
||||
|
||||
let dt = delta as f32;
|
||||
|
||||
if self.simulate_reps {
|
||||
if is_lifting {
|
||||
self.current_rep_progress += self.rep_speed * dt;
|
||||
} else {
|
||||
if self.current_rep_progress > 0.0 {
|
||||
self.current_rep_progress -= self.rep_speed * dt;
|
||||
self.current_rep_progress = self.current_rep_progress.max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
let ping_pong_val =
|
||||
godot::global::pingpong(self.current_rep_progress as f64, 1.0) as f32;
|
||||
self.update_visuals(ping_pong_val);
|
||||
} else {
|
||||
self.update_visuals(visual_height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LiftSyncController {
|
||||
fn update_visuals(&mut self, normalized_height: f32) {
|
||||
if self.player_anim.is_none() || self.barbell.is_none() || self.bar_positions.is_empty() {
|
||||
godot_error!("LiftSyncController: Missing visuals or bar positions");
|
||||
return;
|
||||
}
|
||||
|
||||
let total_frames = self.bar_positions.len() as i32;
|
||||
let frame_index_f = normalized_height * (total_frames as f32 - 0.01);
|
||||
let frame_index = (frame_index_f.floor() as i32).clamp(0, total_frames - 1);
|
||||
|
||||
if let Some(anim) = &mut self.player_anim {
|
||||
anim.set_frame(frame_index);
|
||||
|
||||
let current_anim = anim.get_animation();
|
||||
if current_anim != StringName::from(&self.animation_name) {
|
||||
let anim_name_sn = StringName::from(&self.animation_name);
|
||||
anim.play_ex().name(&anim_name_sn).done();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(bar) = &mut self.barbell {
|
||||
if let Some(pos) = self.bar_positions.get(frame_index as usize) {
|
||||
bar.set_position(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
rust/src/visuals/mod.rs
Normal file
1
rust/src/visuals/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod lift_sync_controller;
|
||||
Reference in New Issue
Block a user