use std::collections::{BTreeMap, BTreeSet};
use std::f32::consts::{PI, TAU};
use std::time::Duration;
use bevy_ecs::change_detection::DetectChangesMut;
use bevy_ecs::entity::Entity;
use bevy_ecs::prelude::{
Commands, Component, Or, Query, Res, ResMut, Resource, With, World,
};
use serde::{Deserialize, Serialize};
use super::sim_math;
use super::{
EventQueue, FrameTime, GlobalTransform, MeshRenderer, PhysicsWorld,
RandomSeed, SceneId,
};
use crate::Transform;
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
)]
pub enum Easing {
#[default]
Linear,
QuadIn,
QuadOut,
QuadInOut,
CubicOut,
SineInOut,
BackOut,
BounceOut,
}
impl Easing {
#[must_use]
pub fn apply(self, t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
match self {
Self::Linear => t,
Self::QuadIn => t * t,
Self::QuadOut => 1.0 - (1.0 - t) * (1.0 - t),
Self::QuadInOut => {
if t < 0.5 {
2.0 * t * t
} else {
let u = -2.0 * t + 2.0;
1.0 - u * u / 2.0
}
}
Self::CubicOut => 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t),
Self::SineInOut => -(sim_math::sin_cos(PI * t).1 - 1.0) / 2.0,
Self::BackOut => {
let c1 = 1.70158;
let u = t - 1.0;
1.0 + (c1 + 1.0) * u * u * u + c1 * u * u
}
Self::BounceOut => {
let (n, d) = (7.5625, 2.75);
if t < 1.0 / d {
n * t * t
} else if t < 2.0 / d {
let t = t - 1.5 / d;
n * t * t + 0.75
} else if t < 2.5 / d {
let t = t - 2.25 / d;
n * t * t + 0.9375
} else {
let t = t - 2.625 / d;
n * t * t + 0.984_375
}
}
}
}
}
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
)]
pub enum TweenProperty {
#[default]
Position,
Rotation,
Scale,
}
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
)]
pub enum TweenRepeat {
#[default]
Once,
Loop,
PingPong,
}
#[derive(Component, Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Tween {
pub property: TweenProperty,
pub from: [f32; 3],
pub to: [f32; 3],
pub duration: f32,
pub delay: f32,
pub easing: Easing,
pub repeat: TweenRepeat,
#[serde(skip)]
pub elapsed: f32,
}
impl Default for Tween {
fn default() -> Self {
Self {
property: TweenProperty::Position,
from: [0.0; 3],
to: [0.0, 1.0, 0.0],
duration: 1.0,
delay: 0.0,
easing: Easing::QuadInOut,
repeat: TweenRepeat::PingPong,
elapsed: 0.0,
}
}
}
impl Tween {
#[must_use]
pub fn progress(&self) -> f32 {
if self.duration <= 0.0 {
return 1.0;
}
let played = (self.elapsed - self.delay).max(0.0) / self.duration;
match self.repeat {
TweenRepeat::Once => played.min(1.0),
TweenRepeat::Loop => played.fract(),
TweenRepeat::PingPong => {
let phase = played % 2.0;
if phase > 1.0 {
2.0 - phase
} else {
phase
}
}
}
}
#[must_use]
pub fn finished(&self) -> bool {
self.repeat == TweenRepeat::Once
&& self.elapsed >= self.delay + self.duration
}
#[must_use]
pub fn sample(&self) -> [f32; 3] {
let eased = self.easing.apply(self.progress());
std::array::from_fn(|axis| {
self.from[axis] + (self.to[axis] - self.from[axis]) * eased
})
}
pub fn restart(&mut self) {
self.elapsed = 0.0;
}
}
pub const CUE_CAPTION_SECONDS: f32 = 2.0;
#[derive(Clone, Debug, PartialEq)]
pub struct SoundEvent {
pub entity: Entity,
pub clip: String,
pub volume: f32,
pub caption: String,
}
#[derive(Component, Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CameraShake {
pub trauma: f32,
pub decay: f32,
pub max_offset: [f32; 3],
pub max_roll: f32,
pub frequency: f32,
}
impl Default for CameraShake {
fn default() -> Self {
Self {
trauma: 0.0,
decay: 1.5,
max_offset: [0.3, 0.3, 0.0],
max_roll: 0.05,
frequency: 12.0,
}
}
}
impl CameraShake {
pub fn add_trauma(&mut self, amount: f32) {
self.trauma = (self.trauma + amount).clamp(0.0, 1.0);
}
#[must_use]
pub fn apply(&self, matrix: [[f32; 4]; 4], seconds: f32) -> [[f32; 4]; 4] {
let strength = self.trauma * self.trauma;
if strength <= 0.0 {
return matrix;
}
let phase = TAU * self.frequency * seconds;
let wave = |axis: f32| {
strength
* (0.6 * (phase + axis * 1.7).sin()
+ 0.4 * (2.3 * phase + axis * 4.1).sin())
};
let mut out = matrix;
for (axis, offset) in self.max_offset.into_iter().enumerate() {
let amount = offset * wave(axis as f32);
for row in 0..3 {
out[3][row] += matrix[axis][row] * amount;
}
}
let (sin, cos) = (self.max_roll * wave(3.0)).sin_cos();
for row in 0..3 {
out[0][row] = matrix[0][row] * cos + matrix[1][row] * sin;
out[1][row] = matrix[1][row] * cos - matrix[0][row] * sin;
}
out
}
}
#[derive(Component, Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct SpawnGrid {
pub count: [u32; 3],
pub spacing: [f32; 3],
}
impl Default for SpawnGrid {
fn default() -> Self {
Self {
count: [1; 3],
spacing: [1.0; 3],
}
}
}
#[derive(Component, Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Squash {
pub stiffness: f32,
pub damping: f32,
#[serde(skip)]
pub amount: f32,
#[serde(skip)]
pub velocity: f32,
#[serde(skip)]
pub rest: Option<[f32; 3]>,
}
impl Default for Squash {
fn default() -> Self {
Self {
stiffness: 400.0,
damping: 14.0,
amount: 0.0,
velocity: 0.0,
rest: None,
}
}
}
impl Squash {
pub fn squash(&mut self, amount: f32) {
self.amount = (self.amount + amount).clamp(-0.8, 0.8);
}
}
#[derive(Component, Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Flash {
pub color: [f32; 3],
pub duration: f32,
#[serde(skip)]
pub remaining: f32,
}
impl Default for Flash {
fn default() -> Self {
Self {
color: [1.0; 3],
duration: 0.12,
remaining: 0.0,
}
}
}
impl Flash {
pub fn flash(&mut self) {
self.remaining = self.duration;
}
#[must_use]
pub fn strength(&self) -> f32 {
if self.duration > 0.0 {
(self.remaining / self.duration).clamp(0.0, 1.0)
} else {
0.0
}
}
}
#[derive(Resource, Clone, Copy, Debug, Default, PartialEq)]
pub struct HitStop {
pub remaining: Duration,
}
impl HitStop {
pub fn stop(&mut self, seconds: f32) {
let seconds = Duration::from_micros(
(seconds.clamp(0.0, 1.0) * 1e6).round() as u64,
);
self.remaining = self.remaining.max(seconds);
}
}
pub fn after_hit_stop(world: &mut World, delta: Duration) -> Duration {
let Some(mut stop) = world.get_resource_mut::<HitStop>() else {
return delta;
};
if stop.remaining.is_zero() {
return delta;
}
let eaten = stop.remaining.min(delta);
stop.remaining -= eaten;
delta - eaten
}
pub(super) fn fade_flashes(
time: Res<FrameTime>,
mut flashes: Query<&mut Flash>,
) {
let dt = time.fixed_delta.as_secs_f32();
for mut flash in &mut flashes {
if flash.remaining > 0.0 {
flash.remaining = (flash.remaining - dt).max(0.0);
}
}
}
pub(super) fn spring_squash(
time: Res<FrameTime>,
mut squashes: Query<(&mut Squash, &mut Transform)>,
) {
let dt = time.fixed_delta.as_secs_f32();
for (mut squash, mut transform) in &mut squashes {
if squash.amount == 0.0 && squash.velocity == 0.0 {
continue;
}
let rest = *squash.rest.get_or_insert(transform.scale);
let force = -squash.stiffness * squash.amount
- squash.damping * squash.velocity;
squash.velocity += force * dt;
squash.amount = (squash.amount + squash.velocity * dt).clamp(-0.8, 0.8);
if squash.amount.abs() < 1e-4 && squash.velocity.abs() < 1e-3 {
squash.amount = 0.0;
squash.velocity = 0.0;
squash.rest = None;
transform.scale = rest;
continue;
}
let tall = 1.0 - squash.amount;
let wide = 1.0 / tall.sqrt();
transform.scale = [rest[0] * wide, rest[1] * tall, rest[2] * wide];
}
}
pub(super) fn decay_camera_shake(
time: Res<FrameTime>,
mut shakes: Query<&mut CameraShake>,
) {
let dt = time.fixed_delta.as_secs_f32();
for mut shake in &mut shakes {
if shake.trauma > 0.0 {
shake.trauma = (shake.trauma - shake.decay * dt).max(0.0);
}
}
}
#[derive(Component, Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct SoundCue {
pub clip: String,
pub volume: f32,
pub on_collision: bool,
#[serde(skip_serializing_if = "String::is_empty")]
pub caption: String,
pub full_volume_speed: f32,
#[serde(skip_serializing_if = "String::is_empty")]
pub on_gpu_event: String,
#[serde(skip)]
pub triggered: bool,
#[serde(skip)]
pub touching: bool,
#[serde(skip)]
pub hit_speed: Option<f32>,
}
impl Default for SoundCue {
fn default() -> Self {
Self {
clip: String::new(),
volume: 1.0,
on_collision: true,
caption: String::new(),
full_volume_speed: 0.0,
on_gpu_event: String::new(),
triggered: false,
touching: false,
hit_speed: None,
}
}
}
impl SoundCue {
pub fn trigger(&mut self) {
self.triggered = true;
self.hit_speed = None;
}
}
#[derive(Component, Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct BurstEmitter {
pub count: u32,
pub speed: f32,
pub lifetime: f32,
pub particle_scale: f32,
pub gravity: f32,
pub on_collision: bool,
pub rate: f32,
pub area: [f32; 3],
pub stretch: f32,
#[serde(skip)]
pub pending: f32,
#[serde(skip)]
pub triggered: bool,
#[serde(skip)]
pub touching: bool,
}
impl Default for BurstEmitter {
fn default() -> Self {
Self {
count: 12,
speed: 3.0,
lifetime: 0.6,
particle_scale: 0.15,
gravity: 9.81,
on_collision: true,
rate: 0.0,
area: [0.0; 3],
stretch: 1.0,
pending: 0.0,
triggered: false,
touching: false,
}
}
}
impl BurstEmitter {
pub fn trigger(&mut self) {
self.triggered = true;
}
}
#[derive(Component, Clone, Copy, Debug, PartialEq)]
pub struct BurstParticle {
pub velocity: [f32; 3],
pub gravity: f32,
pub remaining: f32,
pub lifetime: f32,
pub scale: [f32; 3],
}
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
)]
pub enum HudAnchor {
#[default]
TopLeft,
Top,
TopRight,
Center,
BottomLeft,
Bottom,
BottomRight,
}
#[derive(Component, Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct HudElement {
pub text: String,
pub anchor: HudAnchor,
pub offset: [f32; 2],
pub font_size: f32,
pub color: [f32; 4],
pub button: bool,
pub requires: Option<String>,
pub camera: Option<String>,
pub follow: Option<String>,
pub follow_offset: [f32; 3],
}
impl Default for HudElement {
fn default() -> Self {
Self {
text: "Text".into(),
anchor: HudAnchor::TopLeft,
offset: [16.0, 16.0],
font_size: 18.0,
color: [1.0; 4],
button: false,
requires: None,
camera: None,
follow: None,
follow_offset: [0.0; 3],
}
}
}
#[derive(
Component, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
)]
#[serde(default)]
pub struct LootTable {
pub entries: Vec<LootEntry>,
}
#[derive(
Component, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
)]
#[serde(default)]
pub struct Dialogue {
pub lines: Vec<DialogueLine>,
pub current: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct DialogueLine {
pub id: String,
pub speaker: String,
pub text: String,
pub next: String,
pub choices: Vec<DialogueChoice>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct DialogueChoice {
pub text: String,
pub next: String,
pub counter: String,
pub add: i32,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct LootEntry {
pub item: String,
pub weight: u32,
}
#[derive(Component, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Health {
pub value: i32,
pub max: i32,
pub team: String,
}
#[derive(bevy_ecs::event::EntityEvent, Clone, Debug, PartialEq, Eq)]
pub struct Damaged {
pub entity: Entity,
pub amount: i32,
pub health: i32,
}
impl Default for Health {
fn default() -> Self {
Self {
value: 3,
max: 3,
team: String::new(),
}
}
}
#[derive(
Component, Clone, Debug, Default, PartialEq, Serialize, Deserialize,
)]
#[serde(default)]
pub struct ObjectState {
pub state: String,
pub since_tick: u64,
pub transitions: Vec<StateTransition>,
pub actions: Vec<StateAction>,
}
impl ObjectState {
pub fn changes(&self, from: &str, to: &str) -> Vec<(String, i32)> {
let exits = self.actions.iter().filter(|a| a.exit && a.state == from);
let enters = self.actions.iter().filter(|a| !a.exit && a.state == to);
exits
.chain(enters)
.filter(|action| !action.counter.is_empty())
.map(|action| (action.counter.clone(), action.add))
.collect()
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct StateAction {
pub state: String,
pub exit: bool,
pub counter: String,
pub add: i32,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct StateTransition {
pub from: String,
pub to: String,
pub after_seconds: f32,
pub counter: String,
pub at_least: i32,
pub held: String,
pub touching: String,
pub then_counter: String,
pub then_add: i32,
}
pub fn run_state_machines(
mut commands: Commands,
time: Res<FrameTime>,
mut machines: Query<(Entity, &mut ObjectState)>,
mut counters: Query<(&mut Counter, Option<&SceneId>)>,
(actions, input): (
Option<Res<super::ActionMap>>,
Option<Res<super::RuntimeInput>>,
),
physics: Option<Res<PhysicsWorld>>,
names: Query<&super::Name>,
) {
let touching = |entity: Entity, name: &str| {
name.is_empty()
|| physics.as_ref().is_some_and(|physics| {
physics.contacts().iter().any(|contact| {
let other = if contact.a == entity {
contact.b
} else if contact.b == entity {
contact.a
} else {
return false;
};
names.get(other).is_ok_and(|other| other.0 == name)
})
})
};
let held = |action: &str| {
action.is_empty()
|| actions
.as_ref()
.zip(input.as_ref())
.is_some_and(|(actions, input)| actions.held(input, action))
};
let mut adds = std::collections::BTreeMap::<String, i32>::new();
let delta = time.fixed_delta.as_secs_f64();
for (entity, mut machine) in &mut machines {
let elapsed =
time.fixed_tick.saturating_sub(machine.since_tick) as f64 * delta;
let next = machine.transitions.iter().find(|edge| {
(edge.from.is_empty() || edge.from == machine.state)
&& edge.to != machine.state
&& elapsed + 1e-9 >= f64::from(edge.after_seconds)
&& held(&edge.held)
&& touching(entity, &edge.touching)
&& (edge.counter.is_empty()
|| find_counter(counters.iter(), &edge.counter)
.is_some_and(|counter| counter.value >= edge.at_least))
});
if let Some(edge) = next {
let mut changes = vec![(edge.then_counter.clone(), edge.then_add)];
changes.extend(machine.changes(&machine.state, &edge.to));
for (counter, value) in changes {
if !counter.is_empty() {
let add = adds.entry(counter).or_default();
*add = add.saturating_add(value);
}
}
let to = edge.to.clone();
machine.state = to;
machine.since_tick = time.fixed_tick;
}
}
for (name, add) in adds {
match find_counter(counters.iter_mut(), &name) {
Some(mut counter) => {
counter.value = counter.value.saturating_add(add);
}
None => commands.queue(move |world: &mut World| {
let order = super::next_spawn_order(world);
world.spawn((
super::Name(name.clone()),
SceneId::new(),
order,
Counter {
name,
value: add,
target: None,
},
));
}),
}
}
}
#[derive(Component, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Counter {
pub name: String,
pub value: i32,
pub target: Option<i32>,
}
impl Default for Counter {
fn default() -> Self {
Self {
name: "score".into(),
value: 0,
target: None,
}
}
}
impl Counter {
#[must_use]
pub fn complete(&self) -> bool {
self.target.is_some_and(|target| self.value >= target)
}
}
#[derive(Component, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Pickup {
pub counter: String,
pub value: i32,
pub requires: Option<String>,
pub collected: bool,
}
impl Default for Pickup {
fn default() -> Self {
Self {
counter: "score".into(),
value: 1,
requires: None,
collected: false,
}
}
}
pub(crate) fn find_counter<'a, C: std::ops::Deref<Target = Counter>>(
counters: impl Iterator<Item = (C, Option<&'a SceneId>)>,
name: &str,
) -> Option<C> {
counters
.filter(|(counter, _)| counter.name == name)
.min_by_key(|(_, id)| id.map(|id| id.0))
.map(|(counter, _)| counter)
}
#[derive(Resource, Clone, Copy, Debug, PartialEq)]
pub struct TextScale(pub f32);
impl Default for TextScale {
fn default() -> Self {
Self(1.0)
}
}
#[derive(Resource, Clone, Debug, Default, PartialEq, Eq)]
pub struct RebindWait(pub Option<String>);
#[derive(Resource, Clone, Debug, Default, PartialEq, Eq)]
pub struct Translations {
pub locale: String,
pub strings: std::collections::BTreeMap<String, String>,
}
impl Translations {
#[must_use]
pub fn get<'a>(&'a self, key: &'a str) -> &'a str {
self.strings.get(key).map_or(key, String::as_str)
}
#[must_use]
pub fn plural(&self, key: &str, count: i64) -> String {
let form = plural_form(&self.locale, count);
self.strings
.get(&format!("{key}.{form}"))
.or_else(|| self.strings.get(&format!("{key}.other")))
.map_or(key, String::as_str)
.replace("{count}", &count.to_string())
}
}
fn plural_form(locale: &str, count: i64) -> &'static str {
let language = locale.split(['-', '_']).next().unwrap_or_default();
let n = count.unsigned_abs();
let (ten, hundred) = (n % 10, n % 100);
let few = (2..=4).contains(&ten) && !(12..=14).contains(&hundred);
match language {
"ja" | "zh" | "ko" | "th" | "vi" | "id" | "ms" | "tr" => "other",
"fr" | "pt" if n <= 1 => "one",
"ru" | "uk" | "be" | "sr" | "hr" | "bs" => match () {
() if ten == 1 && hundred != 11 => "one",
() if few => "few",
() => "many",
},
"pl" => match () {
() if n == 1 => "one",
() if few => "few",
() => "many",
},
"cs" | "sk" => match n {
1 => "one",
2..=4 => "few",
_ => "other",
},
_ if n == 1 => "one",
_ => "other",
}
}
#[must_use]
pub fn hud_text<'a>(
text: &str,
counters: impl Iterator<Item = (&'a Counter, Option<&'a SceneId>)> + Clone,
translations: Option<&Translations>,
) -> String {
let translated = fill_placeholders(text, |name| {
let key = name.strip_prefix("tr:")?;
Some(translations.map_or(key, |t| t.get(key)).to_owned())
});
fill_placeholders(&translated, |name| {
find_counter(counters.clone(), name)
.map(|counter| counter.value.to_string())
})
}
#[must_use]
pub fn dialogue_placeholder<'a>(
placeholder: &str,
mut dialogues: impl Iterator<Item = (&'a super::Name, &'a Dialogue)>,
translations: Option<&Translations>,
) -> Option<String> {
let key = placeholder.strip_prefix("dialogue:")?;
let (object, choice) = match key.rsplit_once('/') {
Some((object, n)) => (object, Some(n.parse::<usize>().ok()?)),
None => (key, None),
};
let (_, dialogue) = dialogues.find(|(name, _)| name.0 == object)?;
let tr = |key: &str| translations.map_or(key, |t| t.get(key)).to_owned();
let Some(line) = dialogue
.lines
.iter()
.find(|line| line.id == dialogue.current && !line.id.is_empty())
else {
return Some(String::new());
};
Some(match choice {
None if line.speaker.is_empty() => tr(&line.text),
None => format!("{}: {}", tr(&line.speaker), tr(&line.text)),
Some(1) if line.choices.is_empty() => translations
.and_then(|t| t.strings.get("dialogue.continue").cloned())
.unwrap_or_else(|| "Continue".to_owned()),
Some(n) => n
.checked_sub(1)
.and_then(|i| line.choices.get(i))
.map(|choice| tr(&choice.text))
.unwrap_or_default(),
})
}
#[must_use]
pub fn state_placeholder<'a>(
placeholder: &str,
mut states: impl Iterator<Item = (&'a super::Name, &'a ObjectState)>,
translations: Option<&Translations>,
) -> Option<String> {
let object = placeholder.strip_prefix("state:")?;
let (_, state) = states.find(|(name, _)| name.0 == object)?;
Some(
translations
.map_or(&*state.state, |t| t.get(&state.state))
.to_owned(),
)
}
#[must_use]
pub fn binding_placeholder<'a>(
placeholder: &str,
mut actions: impl Iterator<Item = &'a super::InputAction>,
waiting: Option<&str>,
translations: Option<&Translations>,
) -> Option<String> {
let action = placeholder.strip_prefix("binding:")?;
if waiting == Some(action) {
return Some(
translations
.and_then(|t| t.strings.get("binding.waiting").cloned())
.unwrap_or_else(|| "Press a key".to_owned()),
);
}
Some(
actions
.find(|found| found.action == action)
.map(|found| found.inputs.join(" / "))
.unwrap_or_default(),
)
}
fn fill_placeholders(
text: &str,
value: impl Fn(&str) -> Option<String>,
) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let after = &rest[open + 1..];
let filled = after
.find('}')
.and_then(|close| value(&after[..close]).map(|text| (text, close)));
match filled {
Some((text, close)) => {
out.push_str(&text);
rest = &after[close + 1..];
}
None => {
out.push('{');
rest = after;
}
}
}
out.push_str(rest);
out
}
#[derive(Component, Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct SceneBackground {
pub color: [f32; 4],
}
impl Default for SceneBackground {
fn default() -> Self {
Self {
color: super::RenderSettings::default().background_color,
}
}
}
pub(super) fn apply_scene_background(
settings: Option<ResMut<super::RenderSettings>>,
backgrounds: Query<(&SceneBackground, Option<&SceneId>)>,
) {
let first = backgrounds
.iter()
.min_by_key(|(_, id)| id.map(|id| id.0))
.map(|(background, _)| background.color);
if let (Some(mut settings), Some(color)) = (settings, first) {
if settings.background_color != color {
settings.background_color = color;
}
}
}
#[derive(Component, Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct EnvironmentMap {
pub texture: std::path::PathBuf,
pub intensity: f32,
#[serde(skip)]
pub handle: Option<crate::assets::Handle<crate::assets::TextureAsset>>,
}
impl Default for EnvironmentMap {
fn default() -> Self {
Self {
texture: std::path::PathBuf::new(),
intensity: 1.0,
handle: None,
}
}
}
#[derive(Component, Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ReflectionProbe {
pub extents: [f32; 3],
pub intensity: f32,
}
impl Default for ReflectionProbe {
fn default() -> Self {
Self {
extents: [5.0; 3],
intensity: 1.0,
}
}
}
#[derive(Component, Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Fog {
pub color: [f32; 3],
pub density: f32,
pub height: f32,
pub height_falloff: f32,
pub sun_scatter: f32,
pub sky_affect: f32,
}
impl Default for Fog {
fn default() -> Self {
Self {
color: [0.5, 0.6, 0.7],
density: 0.01,
height: 0.0,
height_falloff: 0.1,
sun_scatter: 0.3,
sky_affect: 1.0,
}
}
}
impl Fog {
#[must_use]
pub fn lerp(self, other: Self, t: f32) -> Self {
let mix = |a: f32, b: f32| a + (b - a) * t;
Self {
color: std::array::from_fn(|i| mix(self.color[i], other.color[i])),
density: mix(self.density, other.density),
height: mix(self.height, other.height),
height_falloff: mix(self.height_falloff, other.height_falloff),
sun_scatter: mix(self.sun_scatter, other.sun_scatter),
sky_affect: mix(self.sky_affect, other.sky_affect),
}
}
}
#[derive(Component, Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct PostVolume {
pub extents: [f32; 3],
pub blend: f32,
pub priority: i32,
}
impl Default for PostVolume {
fn default() -> Self {
Self {
extents: [5.0, 3.0, 5.0],
blend: 1.0,
priority: 0,
}
}
}
impl PostVolume {
#[must_use]
pub fn weight(&self, center: [f32; 3], eye: [f32; 3]) -> f32 {
let outside = (0..3)
.map(|i| {
((eye[i] - center[i]).abs() - self.extents[i])
.max(0.0)
.powi(2)
})
.sum::<f32>()
.sqrt();
if outside <= 0.0 {
1.0
} else if self.blend > 0.0 {
(1.0 - outside / self.blend).max(0.0)
} else {
0.0
}
}
}
#[derive(Component, Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Bloom {
pub intensity: f32,
pub threshold: f32,
pub spread: f32,
}
impl Default for Bloom {
fn default() -> Self {
Self {
intensity: 0.5,
threshold: 1.0,
spread: 0.7,
}
}
}
#[derive(Component, Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CameraScreen {
pub camera: String,
pub size: [u32; 2],
pub update_every: u32,
pub enabled: bool,
pub grading: Option<ColorGrading>,
pub exposure: f32,
}
impl Default for CameraScreen {
fn default() -> Self {
Self {
camera: String::new(),
size: [640, 360],
update_every: 1,
enabled: true,
grading: None,
exposure: 1.0,
}
}
}
#[derive(Component, Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ColorGrading {
pub contrast: f32,
pub saturation: f32,
pub shadows: [f32; 3],
pub highlights: [f32; 3],
pub vignette: f32,
pub grain: f32,
pub chromatic_aberration: f32,
pub scanlines: f32,
pub color_bleed: f32,
pub noise_band: f32,
pub distortion: f32,
}
impl ColorGrading {
pub const DEFAULT: Self = Self {
contrast: 1.0,
saturation: 1.0,
shadows: [1.0; 3],
highlights: [1.0; 3],
vignette: 0.0,
grain: 0.0,
chromatic_aberration: 0.0,
scanlines: 0.0,
color_bleed: 0.0,
noise_band: 0.0,
distortion: 0.0,
};
#[must_use]
pub fn lerp(self, other: Self, t: f32) -> Self {
let mix = |a: f32, b: f32| a + (b - a) * t;
let mix3 =
|a: [f32; 3], b: [f32; 3]| std::array::from_fn(|i| mix(a[i], b[i]));
Self {
contrast: mix(self.contrast, other.contrast),
saturation: mix(self.saturation, other.saturation),
shadows: mix3(self.shadows, other.shadows),
highlights: mix3(self.highlights, other.highlights),
vignette: mix(self.vignette, other.vignette),
grain: mix(self.grain, other.grain),
chromatic_aberration: mix(
self.chromatic_aberration,
other.chromatic_aberration,
),
scanlines: mix(self.scanlines, other.scanlines),
color_bleed: mix(self.color_bleed, other.color_bleed),
noise_band: mix(self.noise_band, other.noise_band),
distortion: mix(self.distortion, other.distortion),
}
}
}
impl Default for ColorGrading {
fn default() -> Self {
Self::DEFAULT
}
}
#[derive(Component, Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct AmbientOcclusion {
pub radius: f32,
pub intensity: f32,
}
impl Default for AmbientOcclusion {
fn default() -> Self {
Self {
radius: 1.0,
intensity: 1.0,
}
}
}
pub(super) fn load_environment_maps(
assets: Option<ResMut<crate::assets::AssetServer>>,
mut maps: Query<&mut EnvironmentMap>,
mut frame: bevy_ecs::prelude::Local<u32>,
) {
let Some(mut assets) = assets else {
return;
};
*frame = frame.wrapping_add(1);
for mut map in &mut maps {
let retry = map.handle.is_none()
&& !map.texture.as_os_str().is_empty()
&& (*frame).is_multiple_of(120);
if !bevy_ecs::change_detection::DetectChanges::is_changed(&map)
&& !retry
{
continue;
}
let map = map.bypass_change_detection();
map.handle = (!map.texture.as_os_str().is_empty())
.then(|| {
assets
.textures
.handle_for_path(&map.texture)
.map(Ok)
.unwrap_or_else(|| assets.load_texture(&map.texture))
.map_err(|error| eprintln!("environment map: {error}"))
.ok()
})
.flatten();
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HudButtonPressed {
pub entity: Entity,
}
pub(super) fn advance_tweens(
time: Res<FrameTime>,
mut tweens: Query<(&mut Tween, &mut Transform)>,
) {
let dt = time.fixed_delta.as_secs_f32();
for (mut tween, mut transform) in &mut tweens {
if tween.finished() {
continue;
}
tween.elapsed += dt;
let value = tween.sample();
match tween.property {
TweenProperty::Position => transform.position = value,
TweenProperty::Rotation => transform.rotation = value,
TweenProperty::Scale => transform.scale = value,
}
}
}
pub(super) fn trigger_on_contact(
physics: Res<PhysicsWorld>,
mut cues: Query<(Entity, &mut SoundCue)>,
mut emitters: Query<(Entity, &mut BurstEmitter)>,
) {
if cues.is_empty() && emitters.is_empty() {
return;
}
let mut touching = BTreeMap::<Entity, f32>::new();
for contact in physics.contacts() {
for body in [contact.a, contact.b] {
let speed = touching.entry(body).or_default();
*speed = speed.max(contact.speed);
}
}
for (entity, mut cue) in &mut cues {
let hit = touching.get(&entity).filter(|_| cue.on_collision);
if hit.is_some() && !cue.touching {
cue.triggered = true;
cue.hit_speed = hit.copied();
}
cue.touching = hit.is_some();
}
for (entity, mut emitter) in &mut emitters {
let now = emitter.on_collision && touching.contains_key(&entity);
emitter.triggered |= now && !emitter.touching;
emitter.touching = now;
}
}
pub(super) fn trigger_on_gpu_events(
events: Option<Res<EventQueue<super::GpuPhysicsEvent>>>,
registry: Option<Res<super::GpuEventRegistry>>,
mut cues: Query<&mut SoundCue>,
) {
let (Some(events), Some(registry)) = (events, registry) else {
return;
};
for event in events.iter() {
let Ok(mut cue) = cues.get_mut(event.entity) else {
continue;
};
if !cue.on_gpu_event.is_empty()
&& registry.id(&cue.on_gpu_event) == Some(event.event_id)
{
cue.trigger();
}
}
}
type IsPlayer = Or<(
With<super::PlatformerController>,
With<super::PlayerController>,
)>;
type PickupParts = (
Entity,
&'static mut Pickup,
Option<&'static SceneId>,
Option<&'static mut SoundCue>,
Option<&'static mut BurstEmitter>,
);
pub(super) fn collect_pickups(
mut commands: Commands,
physics: Res<PhysicsWorld>,
players: Query<(), IsPlayer>,
mut pickups: Query<PickupParts>,
mut counters: Query<(&mut Counter, Option<&SceneId>)>,
) {
let touched: BTreeSet<Entity> = physics
.contacts()
.iter()
.flat_map(|contact| [(contact.a, contact.b), (contact.b, contact.a)])
.filter(|(_, other)| players.contains(*other))
.map(|(entity, _)| entity)
.collect();
if touched.is_empty() {
return;
}
let mut ready: Vec<_> = pickups
.iter_mut()
.filter(|(entity, pickup, ..)| {
!pickup.collected && touched.contains(entity)
})
.collect();
ready.sort_by_key(|(entity, _, id, ..)| (id.map(|id| id.0), *entity));
for (entity, mut pickup, _, cue, emitter) in ready {
if let Some(required) = &pickup.requires {
let done = find_counter(counters.iter(), required)
.is_some_and(|counter| counter.complete());
if !done {
continue;
}
}
pickup.collected = true;
if let Some(mut counter) =
find_counter(counters.iter_mut(), &pickup.counter)
{
counter.value += pickup.value;
}
commands
.entity(entity)
.insert(super::Visibility { visible: false });
if let Some(mut cue) = cue {
cue.trigger();
}
if let Some(mut emitter) = emitter {
emitter.trigger();
}
}
}
pub(super) fn fire_sound_cues(
mut events: ResMut<EventQueue<SoundEvent>>,
mut cues: Query<(Entity, &mut SoundCue)>,
) {
for (entity, mut cue) in &mut cues {
if std::mem::take(&mut cue.triggered) {
let mut volume = cue.volume;
match cue.hit_speed.take() {
Some(speed) if cue.full_volume_speed > 0.0 => {
volume *= (speed / cue.full_volume_speed).min(1.0);
}
_ => {}
}
events.send(SoundEvent {
entity,
clip: cue.clip.clone(),
volume,
caption: cue.caption.clone(),
});
}
}
}
type EmitterParts = (
&'static mut BurstEmitter,
&'static Transform,
Option<&'static GlobalTransform>,
Option<&'static SceneId>,
Option<&'static MeshRenderer>,
Entity,
);
pub(super) fn fire_bursts(
mut commands: Commands,
time: Res<FrameTime>,
seed: Res<RandomSeed>,
mut emitters: Query<EmitterParts>,
) {
let dt = time.fixed_delta.as_secs_f32();
for (mut emitter, transform, global, id, mesh, entity) in &mut emitters {
let mut count = 0;
if std::mem::take(&mut emitter.triggered) {
count += u64::from(emitter.count);
}
if emitter.rate > 0.0 {
emitter.pending += emitter.rate * dt;
let whole = emitter.pending.floor();
emitter.pending -= whole;
count += whole as u64;
}
if count == 0 {
continue;
}
let origin = global.map_or(transform.position, |global| {
let column = global.matrix[3];
[column[0], column[1], column[2]]
});
let key = id.map_or(entity.to_bits(), |id| {
let bits = id.0.as_u128();
(bits as u64) ^ ((bits >> 64) as u64)
});
let stream = RandomSeed::stream("bursts", key);
let area = RandomSeed::stream("burst_area", key);
let mut scale =
transform.scale.map(|axis| axis * emitter.particle_scale);
scale[1] *= emitter.stretch;
for index in 0..count {
let up = seed.unit(time.fixed_tick, stream ^ (index << 1));
let turn = seed.unit(time.fixed_tick, stream ^ (index << 1 | 1));
let side = (1.0 - up * up).sqrt();
let (sin, cos) = (turn * TAU).sin_cos();
let velocity =
[side * cos, up, side * sin].map(|axis| axis * emitter.speed);
let particle = BurstParticle {
velocity,
gravity: emitter.gravity,
remaining: emitter.lifetime,
lifetime: emitter.lifetime,
scale,
};
let position = std::array::from_fn(|axis| {
let unit = seed
.unit(time.fixed_tick, area ^ (index * 3 + axis as u64));
origin[axis] + (unit * 2.0 - 1.0) * emitter.area[axis]
});
let transform = Transform {
position,
scale,
..Transform::default()
};
let mut spawned = commands.spawn((transform, particle));
if let Some(mesh) = mesh {
spawned.insert(*mesh);
}
}
}
}
pub(super) fn update_burst_particles(
mut commands: Commands,
time: Res<FrameTime>,
mut particles: Query<(Entity, &mut BurstParticle, &mut Transform)>,
) {
let dt = time.fixed_delta.as_secs_f32();
for (entity, mut particle, mut transform) in &mut particles {
particle.remaining -= dt;
if particle.remaining <= 0.0 {
commands.entity(entity).despawn();
continue;
}
particle.velocity[1] -= particle.gravity * dt;
for axis in 0..3 {
transform.position[axis] += particle.velocity[axis] * dt;
}
let left = particle.remaining / particle.lifetime;
transform.scale = particle.scale.map(|axis| axis * left);
}
}
#[derive(Resource, Clone, Debug, Default, PartialEq)]
pub struct PerfOverlay(pub String);
#[cfg(feature = "ui")]
fn draw_perf_overlay(context: &egui::Context, overlay: Option<&PerfOverlay>) {
let Some(PerfOverlay(text)) = overlay else {
return;
};
egui::Area::new(egui::Id::new("rusting.perf_overlay"))
.sense(egui::Sense::hover())
.anchor(egui::Align2::RIGHT_TOP, egui::vec2(-8.0, 8.0))
.fade_in(false)
.order(egui::Order::Foreground)
.show(context, |ui| {
egui::Frame::new()
.fill(egui::Color32::from_black_alpha(170))
.inner_margin(egui::Margin::symmetric(8, 4))
.corner_radius(4.0)
.show(ui, |ui| {
ui.add(
egui::Label::new(
egui::RichText::new(text)
.monospace()
.color(egui::Color32::WHITE),
)
.selectable(false),
);
});
});
}
#[cfg(feature = "ui")]
fn draw_captions(
context: &egui::Context,
audio: Option<&super::AudioQueue>,
settings: &super::CaptionSettings,
) {
let lines = audio.map(super::AudioQueue::captions).unwrap_or_default();
if !settings.enabled || lines.is_empty() {
return;
}
let screen = context.screen_rect();
egui::Area::new(egui::Id::new("rusting.captions"))
.sense(egui::Sense::hover())
.anchor(egui::Align2::CENTER_BOTTOM, egui::vec2(0.0, -48.0))
.fade_in(false)
.order(egui::Order::Foreground)
.show(context, |ui| {
ui.set_max_width(screen.width() * 0.8);
egui::Frame::new()
.fill(egui::Color32::from_black_alpha(170))
.inner_margin(egui::Margin::symmetric(12, 6))
.corner_radius(4.0)
.show(ui, |ui| {
for line in lines {
ui.add(
egui::Label::new(
egui::RichText::new(line)
.size(settings.size)
.color(egui::Color32::WHITE),
)
.selectable(false),
);
}
});
});
}
#[cfg(feature = "ui")]
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
pub(super) fn draw_hud(
ui: Res<super::RuntimeUi>,
mut pressed: ResMut<EventQueue<HudButtonPressed>>,
elements: Query<(Entity, &HudElement, Option<&SceneId>)>,
counters: Query<(&Counter, Option<&SceneId>)>,
visibility: Query<&super::Visibility>,
parents: Query<&super::Parent>,
cameras: Query<(
Entity,
&super::Name,
&super::Camera,
Option<&GlobalTransform>,
)>,
audio: Option<Res<super::AudioQueue>>,
caption_settings: Option<Res<super::CaptionSettings>>,
perf: Option<Res<PerfOverlay>>,
(translations, dialogues, states, text_scale, actions, rebind, named): (
Option<Res<Translations>>,
Query<(&super::Name, &Dialogue)>,
Query<(&super::Name, &ObjectState)>,
Option<Res<TextScale>>,
Query<&super::InputAction>,
Option<Res<RebindWait>>,
Query<(&super::Name, &GlobalTransform)>,
),
) {
let scale = text_scale.map_or(1.0, |scale| scale.0);
let captions = caption_settings.as_deref().cloned().unwrap_or_default();
draw_perf_overlay(ui.context(), perf.as_deref());
draw_captions(
ui.context(),
audio.as_deref(),
&super::CaptionSettings {
size: captions.size * scale,
..captions
},
);
let visible = |entity: Entity| {
std::iter::successors(Some(entity), |&entity| {
parents.get(entity).ok().map(|parent| parent.0)
})
.take(1024)
.all(|entity| visibility.get(entity).map_or(true, |v| v.visible))
};
let mut elements: Vec<_> = elements.iter().collect();
elements.sort_by_key(|(entity, _, id)| (id.map(|id| id.0), *entity));
let mut placed = Vec::new();
for (entity, element, _) in elements {
if !visible(entity) {
continue;
}
if let Some(required) = &element.requires {
if !find_counter(counters.iter(), required)
.is_some_and(|counter| counter.complete())
{
continue;
}
}
let screen = ui.context().screen_rect();
let camera = match &element.camera {
None => cameras
.iter()
.filter(|(.., camera, _)| camera.active)
.max_by_key(|(entity, _, camera, _)| {
(
camera.viewport.is_none(),
camera.priority,
std::cmp::Reverse(entity.to_bits()),
)
}),
Some(wanted) => {
let found = cameras.iter().find(|(_, name, camera, _)| {
name.0 == *wanted && camera.active
});
if found.is_none() {
continue;
}
found
}
};
let area = match camera.and_then(|(.., camera, _)| camera.viewport) {
Some([x, y, w, h]) if element.camera.is_some() => {
egui::Rect::from_min_size(
screen.min + egui::vec2(x, y) * screen.size(),
egui::vec2(w, h) * screen.size(),
)
}
_ => screen,
};
let (align, inward) = match element.anchor {
HudAnchor::TopLeft => (egui::Align2::LEFT_TOP, [1.0, 1.0]),
HudAnchor::Top => (egui::Align2::CENTER_TOP, [1.0, 1.0]),
HudAnchor::TopRight => (egui::Align2::RIGHT_TOP, [-1.0, 1.0]),
HudAnchor::Center => (egui::Align2::CENTER_CENTER, [1.0, 1.0]),
HudAnchor::BottomLeft => (egui::Align2::LEFT_BOTTOM, [1.0, -1.0]),
HudAnchor::Bottom => (egui::Align2::CENTER_BOTTOM, [1.0, -1.0]),
HudAnchor::BottomRight => {
(egui::Align2::RIGHT_BOTTOM, [-1.0, -1.0])
}
};
let offset = egui::vec2(
element.offset[0] * inward[0],
element.offset[1] * inward[1],
);
let point = match &element.follow {
None => align.pos_in_rect(&area) + offset,
Some(target) => {
let Some(on_screen) = camera.and_then(|(.., camera, view)| {
let (_, at) =
named.iter().find(|(name, _)| name.0 == *target)?;
let at = at.matrix[3];
let point = [0, 1, 2]
.map(|axis| at[axis] + element.follow_offset[axis]);
super::picking::project_point(
point.into(),
*camera,
*view?,
area.min.into(),
area.size().into(),
)
}) else {
continue;
};
egui::pos2(on_screen[0], on_screen[1])
+ egui::vec2(element.offset[0], element.offset[1])
}
};
placed.push((entity, element, align, point));
}
placed.sort_by(|a, b| {
(a.3.y.total_cmp(&b.3.y)).then(a.3.x.total_cmp(&b.3.x))
});
for (entity, element, align, point) in placed {
let [r, g, b, a] =
element.color.map(|c| (c.clamp(0.0, 1.0) * 255.0) as u8);
let filled = fill_placeholders(&element.text, |placeholder| {
dialogue_placeholder(
placeholder,
dialogues.iter(),
translations.as_deref(),
)
.or_else(|| {
state_placeholder(
placeholder,
states.iter(),
translations.as_deref(),
)
})
.or_else(|| {
binding_placeholder(
placeholder,
actions.iter(),
rebind.as_ref().and_then(|wait| wait.0.as_deref()),
translations.as_deref(),
)
})
});
let filled =
hud_text(&filled, counters.iter(), translations.as_deref());
if filled.trim().is_empty() {
continue;
}
let text = egui::RichText::new(filled)
.size(element.font_size * scale)
.color(egui::Color32::from_rgba_unmultiplied(r, g, b, a));
let context = ui.context();
let galley = egui::WidgetText::from(text.clone()).into_galley_impl(
context,
&context.style(),
egui::text::TextWrapping::no_max_width(),
egui::FontSelection::Default,
egui::Align::LEFT,
);
let padding = if element.button {
2.0 * context.style().spacing.button_padding
} else {
egui::Vec2::ZERO
};
let position = align.anchor_size(point, galley.size() + padding).min;
let id = egui::Id::new(("rusting.hud", entity));
if !element.button {
context
.layer_painter(egui::LayerId::new(egui::Order::Middle, id))
.galley(position, galley, egui::Color32::PLACEHOLDER);
continue;
}
egui::Area::new(id)
.sense(egui::Sense::hover())
.fixed_pos(position)
.fade_in(false)
.show(context, |ui| {
if ui.button(text).clicked() {
pressed.send(HudButtonPressed { entity });
}
});
}
}