Add dedicated skinned rendering, pose restoration, shared Material and Material Instance slots, registry-driven components, Surface/Solari integration, transactional schema upgrades, navigation authoring, documentation, and evaluation evidence.
1747 lines
57 KiB
Rust
1747 lines
57 KiB
Rust
use avian3d::prelude::ColliderConstructor;
|
||
use bevy::prelude::*;
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
/// Stable persisted identity for an actor. Bevy [`Entity`] IDs are runtime-only.
|
||
#[derive(
|
||
Component, Reflect, Default, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize,
|
||
)]
|
||
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct ActorId(pub String);
|
||
|
||
impl ActorId {
|
||
pub fn new(id: impl Into<String>) -> Self {
|
||
Self(id.into())
|
||
}
|
||
}
|
||
|
||
/// Stable scene-document identity and its authored subscene references.
|
||
///
|
||
/// This is persisted as a reflected scene resource. Cross-scene ownership never
|
||
/// depends on runtime [`Entity`] identifiers.
|
||
#[derive(Resource, Reflect, Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[reflect(Resource, Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct SceneComposition {
|
||
pub scene_id: String,
|
||
#[serde(default)]
|
||
pub subscenes: Vec<SubsceneReference>,
|
||
}
|
||
|
||
/// One stable, project-relative reference in a [`SceneComposition`].
|
||
#[derive(Reflect, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct SubsceneReference {
|
||
pub id: String,
|
||
pub path: String,
|
||
#[serde(default = "default_true")]
|
||
pub visible: bool,
|
||
#[serde(default = "default_true")]
|
||
pub locked: bool,
|
||
}
|
||
|
||
impl Default for SubsceneReference {
|
||
fn default() -> Self {
|
||
Self {
|
||
id: String::new(),
|
||
path: String::new(),
|
||
visible: true,
|
||
locked: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// User-facing actor display name, separate from Bevy's runtime [`Name`].
|
||
#[derive(Component, Reflect, Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct ActorName(pub String);
|
||
|
||
impl ActorName {
|
||
pub fn new(name: impl Into<String>) -> Self {
|
||
Self(name.into())
|
||
}
|
||
}
|
||
|
||
/// Stable identity for repeatable authoring items such as renderer slots.
|
||
#[derive(Reflect, Default, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct ComponentInstanceId(pub String);
|
||
|
||
impl ComponentInstanceId {
|
||
pub fn new(id: impl Into<String>) -> Self {
|
||
Self(id.into())
|
||
}
|
||
|
||
pub fn is_empty(&self) -> bool {
|
||
self.0.trim().is_empty()
|
||
}
|
||
}
|
||
|
||
/// Editor-only component ordering metadata. Runtime systems must not depend on it.
|
||
#[derive(Component, Reflect, Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct InspectorOrder {
|
||
/// Stable component IDs used by current scenes.
|
||
#[serde(default)]
|
||
pub component_ids: Vec<String>,
|
||
/// Legacy reflected type paths used by schema-v3 scenes.
|
||
#[serde(default)]
|
||
pub component_type_names: Vec<String>,
|
||
/// Legacy schema-v3 storage. New authoring writes active state to
|
||
/// [`AuthoringComponentStates`]; this field remains readable so old scenes can
|
||
/// be upgraded without losing disabled components.
|
||
#[serde(default)]
|
||
pub component_states: Vec<InspectorComponentState>,
|
||
}
|
||
|
||
/// Persisted active state for authoring components.
|
||
///
|
||
/// This is intentionally separate from [`InspectorOrder`]: hydration and runtime
|
||
/// behavior must not depend on how the editor happens to arrange component cards.
|
||
#[derive(Component, Reflect, Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct AuthoringComponentStates {
|
||
#[serde(default)]
|
||
pub states: Vec<InspectorComponentState>,
|
||
}
|
||
|
||
/// Per-component inspector metadata that must survive scene save/load.
|
||
#[derive(Reflect, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct InspectorComponentState {
|
||
/// Stable authoring component identity used by current scenes.
|
||
#[serde(default)]
|
||
pub component_id: String,
|
||
/// Legacy schema-v3 reflected type path.
|
||
#[serde(default)]
|
||
pub type_name: String,
|
||
#[serde(default = "default_true")]
|
||
pub active: bool,
|
||
}
|
||
|
||
impl Default for InspectorComponentState {
|
||
fn default() -> Self {
|
||
Self {
|
||
component_id: String::new(),
|
||
type_name: String::new(),
|
||
active: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl InspectorOrder {
|
||
pub fn is_component_active(&self, type_name: &str) -> bool {
|
||
let component_id = authoring_component_key(type_name);
|
||
self.component_states
|
||
.iter()
|
||
.find(|state| {
|
||
state.component_id == component_id
|
||
|| state.type_name == type_name
|
||
|| state.type_name == component_id
|
||
})
|
||
.map(|state| state.active)
|
||
.unwrap_or(true)
|
||
}
|
||
|
||
pub fn set_component_active(&mut self, type_name: impl Into<String>, active: bool) {
|
||
let type_name = type_name.into();
|
||
let component_id = authoring_component_key(&type_name).to_string();
|
||
if let Some(state) = self.component_states.iter_mut().find(|state| {
|
||
state.component_id == component_id
|
||
|| state.type_name == type_name
|
||
|| state.type_name == component_id
|
||
}) {
|
||
state.component_id = component_id;
|
||
state.type_name.clear();
|
||
state.active = active;
|
||
} else {
|
||
self.component_states.push(InspectorComponentState {
|
||
component_id,
|
||
type_name: String::new(),
|
||
active,
|
||
});
|
||
}
|
||
}
|
||
|
||
pub fn ensure_component_order(&mut self, present: &[&str]) {
|
||
for type_name in present {
|
||
let component_id = authoring_component_key(type_name);
|
||
if !self
|
||
.component_ids
|
||
.iter()
|
||
.any(|existing| existing == component_id)
|
||
{
|
||
self.component_ids.push(component_id.to_string());
|
||
}
|
||
}
|
||
// Once the editor touches order, the stable-ID representation is
|
||
// authoritative and legacy paths no longer need to be re-saved.
|
||
self.component_type_names.clear();
|
||
}
|
||
|
||
pub fn ordered_components<'a>(&self, present: &[&'a str]) -> Vec<&'a str> {
|
||
let mut ordered = Vec::new();
|
||
for component_id in &self.component_ids {
|
||
if let Some(present_type) = present
|
||
.iter()
|
||
.find(|present| authoring_component_key(present) == component_id)
|
||
{
|
||
ordered.push(*present_type);
|
||
}
|
||
}
|
||
for type_name in &self.component_type_names {
|
||
if let Some(present_type) = present.iter().find(|present| **present == type_name) {
|
||
ordered.push(*present_type);
|
||
}
|
||
}
|
||
for type_name in present {
|
||
if !ordered.iter().any(|ordered_type| ordered_type == type_name) {
|
||
ordered.push(*type_name);
|
||
}
|
||
}
|
||
ordered
|
||
}
|
||
|
||
pub fn move_component(&mut self, type_name: &str, offset: isize, present: &[&str]) -> bool {
|
||
self.ensure_component_order(present);
|
||
let Some(index) = self
|
||
.component_ids
|
||
.iter()
|
||
.position(|existing| existing == authoring_component_key(type_name))
|
||
else {
|
||
return false;
|
||
};
|
||
let target =
|
||
(index as isize + offset).clamp(0, self.component_ids.len() as isize - 1) as usize;
|
||
if target == index {
|
||
return false;
|
||
}
|
||
let item = self.component_ids.remove(index);
|
||
self.component_ids.insert(target, item);
|
||
true
|
||
}
|
||
}
|
||
|
||
impl AuthoringComponentStates {
|
||
pub fn is_component_active(&self, type_name: &str) -> bool {
|
||
let component_id = authoring_component_key(type_name);
|
||
self.states
|
||
.iter()
|
||
.find(|state| {
|
||
state.component_id == component_id
|
||
|| state.type_name == type_name
|
||
|| state.type_name == component_id
|
||
})
|
||
.map(|state| state.active)
|
||
.unwrap_or(true)
|
||
}
|
||
|
||
pub fn set_component_active(&mut self, type_name: impl Into<String>, active: bool) {
|
||
let type_name = type_name.into();
|
||
let component_id = authoring_component_key(&type_name).to_string();
|
||
if let Some(state) = self.states.iter_mut().find(|state| {
|
||
state.component_id == component_id
|
||
|| state.type_name == type_name
|
||
|| state.type_name == component_id
|
||
}) {
|
||
state.component_id = component_id;
|
||
state.type_name.clear();
|
||
state.active = active;
|
||
} else {
|
||
self.states.push(InspectorComponentState {
|
||
component_id,
|
||
type_name: String::new(),
|
||
active,
|
||
});
|
||
}
|
||
}
|
||
|
||
pub fn remove_component(&mut self, type_name: &str) {
|
||
let component_id = authoring_component_key(type_name);
|
||
self.states.retain(|state| {
|
||
state.component_id != component_id
|
||
&& state.type_name != type_name
|
||
&& state.type_name != component_id
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Returns whether a persisted authoring component is enabled.
|
||
///
|
||
/// `legacy_order` is consulted only when an older scene has not yet been migrated
|
||
/// to [`AuthoringComponentStates`].
|
||
pub fn authoring_component_active(
|
||
states: Option<&AuthoringComponentStates>,
|
||
legacy_order: Option<&InspectorOrder>,
|
||
type_name: &str,
|
||
) -> bool {
|
||
states
|
||
.map(|states| states.is_component_active(type_name))
|
||
.or_else(|| legacy_order.map(|order| order.is_component_active(type_name)))
|
||
.unwrap_or(true)
|
||
}
|
||
|
||
pub fn inspector_component_active(order: Option<&InspectorOrder>, type_name: &str) -> bool {
|
||
order
|
||
.map(|order| order.is_component_active(type_name))
|
||
.unwrap_or(true)
|
||
}
|
||
|
||
// Stable authoring component identities. These are editor/protocol keys; reflected
|
||
// Rust type paths remain serialization details and may change during refactors.
|
||
pub const AUTHORING_COMPONENT_PRIMITIVE: &str = "render.primitive";
|
||
pub const AUTHORING_COMPONENT_BRUSH: &str = "render.brush";
|
||
pub const AUTHORING_COMPONENT_STATIC_MESH_RENDERER: &str = "render.static_mesh_renderer";
|
||
pub const AUTHORING_COMPONENT_SKINNED_MESH_RENDERER: &str = "render.skinned_mesh_renderer";
|
||
pub const AUTHORING_COMPONENT_MATERIAL: &str = "render.material";
|
||
pub const AUTHORING_COMPONENT_LIGHT: &str = "render.light";
|
||
pub const AUTHORING_COMPONENT_ANIMATION_CONTROLLER: &str = "animation.controller";
|
||
pub const AUTHORING_COMPONENT_AUDIO_SOURCE: &str = "audio.source";
|
||
pub const AUTHORING_COMPONENT_AUDIO_LISTENER: &str = "audio.listener";
|
||
pub const AUTHORING_COMPONENT_RIGID_BODY: &str = "physics.rigid_body";
|
||
pub const AUTHORING_COMPONENT_COLLIDER: &str = "physics.collider";
|
||
pub const AUTHORING_COMPONENT_LEGACY_PHYSICS_BODY: &str = "physics.legacy_body";
|
||
pub const AUTHORING_COMPONENT_PLAYER_SPAWN: &str = "gameplay.player_spawn";
|
||
pub const AUTHORING_COMPONENT_WEAPON_SPAWN: &str = "gameplay.weapon_spawn";
|
||
pub const AUTHORING_COMPONENT_TRIGGER_VOLUME: &str = "gameplay.trigger_volume";
|
||
pub const AUTHORING_COMPONENT_TEAM_SPAWN: &str = "gameplay.team_spawn";
|
||
pub const AUTHORING_COMPONENT_OBJECTIVE: &str = "gameplay.objective";
|
||
pub const AUTHORING_COMPONENT_POST_PROCESS_VOLUME: &str = "render.post_process_volume";
|
||
pub const AUTHORING_COMPONENT_NAVIGATION_BOUNDS: &str = "navigation.bounds";
|
||
pub const AUTHORING_COMPONENT_NAVIGATION_OBSTACLE: &str = "navigation.obstacle";
|
||
pub const AUTHORING_COMPONENT_NAVIGATION_AREA: &str = "navigation.area";
|
||
pub const AUTHORING_COMPONENT_NAVIGATION_LINK: &str = "navigation.link";
|
||
|
||
/// Resolves either a stable authoring ID or a reflected Rust type path to its
|
||
/// stable editor/protocol identity. Unknown extension keys are returned as
|
||
/// `None` and remain usable as-is by generic state/order storage.
|
||
pub fn authoring_component_id(key: &str) -> Option<&'static str> {
|
||
match key {
|
||
AUTHORING_COMPONENT_PRIMITIVE | COMPONENT_PRIMITIVE => Some(AUTHORING_COMPONENT_PRIMITIVE),
|
||
AUTHORING_COMPONENT_BRUSH | COMPONENT_BRUSH_DESC => Some(AUTHORING_COMPONENT_BRUSH),
|
||
AUTHORING_COMPONENT_STATIC_MESH_RENDERER | COMPONENT_STATIC_MESH_RENDERER => {
|
||
Some(AUTHORING_COMPONENT_STATIC_MESH_RENDERER)
|
||
}
|
||
AUTHORING_COMPONENT_SKINNED_MESH_RENDERER | "shared::animation::SkinnedMeshRenderer" => {
|
||
Some(AUTHORING_COMPONENT_SKINNED_MESH_RENDERER)
|
||
}
|
||
AUTHORING_COMPONENT_MATERIAL | COMPONENT_MATERIAL_DESC => {
|
||
Some(AUTHORING_COMPONENT_MATERIAL)
|
||
}
|
||
AUTHORING_COMPONENT_LIGHT | COMPONENT_LIGHT_DESC => Some(AUTHORING_COMPONENT_LIGHT),
|
||
AUTHORING_COMPONENT_ANIMATION_CONTROLLER | "shared::animation::AnimationControllerDesc" => {
|
||
Some(AUTHORING_COMPONENT_ANIMATION_CONTROLLER)
|
||
}
|
||
AUTHORING_COMPONENT_AUDIO_SOURCE | COMPONENT_AUDIO_SOURCE_DESC => {
|
||
Some(AUTHORING_COMPONENT_AUDIO_SOURCE)
|
||
}
|
||
AUTHORING_COMPONENT_AUDIO_LISTENER | COMPONENT_AUDIO_LISTENER_DESC => {
|
||
Some(AUTHORING_COMPONENT_AUDIO_LISTENER)
|
||
}
|
||
AUTHORING_COMPONENT_RIGID_BODY | COMPONENT_RIGID_BODY_DESC => {
|
||
Some(AUTHORING_COMPONENT_RIGID_BODY)
|
||
}
|
||
AUTHORING_COMPONENT_COLLIDER | COMPONENT_COLLIDER_DESC => {
|
||
Some(AUTHORING_COMPONENT_COLLIDER)
|
||
}
|
||
AUTHORING_COMPONENT_LEGACY_PHYSICS_BODY | COMPONENT_PHYSICS_BODY => {
|
||
Some(AUTHORING_COMPONENT_LEGACY_PHYSICS_BODY)
|
||
}
|
||
AUTHORING_COMPONENT_PLAYER_SPAWN | COMPONENT_PLAYER_SPAWN => {
|
||
Some(AUTHORING_COMPONENT_PLAYER_SPAWN)
|
||
}
|
||
AUTHORING_COMPONENT_WEAPON_SPAWN | COMPONENT_WEAPON_SPAWN => {
|
||
Some(AUTHORING_COMPONENT_WEAPON_SPAWN)
|
||
}
|
||
AUTHORING_COMPONENT_TRIGGER_VOLUME | COMPONENT_TRIGGER_VOLUME => {
|
||
Some(AUTHORING_COMPONENT_TRIGGER_VOLUME)
|
||
}
|
||
AUTHORING_COMPONENT_TEAM_SPAWN | COMPONENT_TEAM_SPAWN => {
|
||
Some(AUTHORING_COMPONENT_TEAM_SPAWN)
|
||
}
|
||
AUTHORING_COMPONENT_OBJECTIVE | COMPONENT_OBJECTIVE_MARKER => {
|
||
Some(AUTHORING_COMPONENT_OBJECTIVE)
|
||
}
|
||
AUTHORING_COMPONENT_POST_PROCESS_VOLUME | COMPONENT_POST_PROCESS_VOLUME => {
|
||
Some(AUTHORING_COMPONENT_POST_PROCESS_VOLUME)
|
||
}
|
||
AUTHORING_COMPONENT_NAVIGATION_BOUNDS | "shared::navigation::NavigationBounds" => {
|
||
Some(AUTHORING_COMPONENT_NAVIGATION_BOUNDS)
|
||
}
|
||
AUTHORING_COMPONENT_NAVIGATION_OBSTACLE | "shared::navigation::NavigationObstacle" => {
|
||
Some(AUTHORING_COMPONENT_NAVIGATION_OBSTACLE)
|
||
}
|
||
AUTHORING_COMPONENT_NAVIGATION_AREA | "shared::navigation::NavigationArea" => {
|
||
Some(AUTHORING_COMPONENT_NAVIGATION_AREA)
|
||
}
|
||
AUTHORING_COMPONENT_NAVIGATION_LINK | "shared::navigation::NavigationLink" => {
|
||
Some(AUTHORING_COMPONENT_NAVIGATION_LINK)
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
pub fn authoring_component_key(key: &str) -> &str {
|
||
authoring_component_id(key).unwrap_or(key)
|
||
}
|
||
|
||
pub const COMPONENT_PRIMITIVE: &str = "shared::components::Primitive";
|
||
pub const COMPONENT_BRUSH_DESC: &str = "shared::components::BrushDesc";
|
||
pub const COMPONENT_STATIC_MESH_RENDERER: &str = "shared::components::StaticMeshRenderer";
|
||
pub const COMPONENT_MATERIAL_DESC: &str = "shared::components::MaterialDesc";
|
||
pub const COMPONENT_MATERIAL_OVERRIDE: &str = "shared::components::MaterialOverride";
|
||
pub const COMPONENT_LIGHT_DESC: &str = "shared::components::LightDesc";
|
||
pub const COMPONENT_RIGID_BODY_DESC: &str = "shared::components::RigidBodyDesc";
|
||
pub const COMPONENT_COLLIDER_DESC: &str = "shared::components::ColliderDesc";
|
||
pub const COMPONENT_PHYSICS_BODY: &str = "shared::components::PhysicsBody";
|
||
pub const COMPONENT_PLAYER_SPAWN: &str = "shared::components::PlayerSpawn";
|
||
pub const COMPONENT_WEAPON_SPAWN: &str = "shared::components::WeaponSpawn";
|
||
pub const COMPONENT_TRIGGER_VOLUME: &str = "shared::components::TriggerVolume";
|
||
pub const COMPONENT_TEAM_SPAWN: &str = "shared::components::TeamSpawn";
|
||
pub const COMPONENT_OBJECTIVE_MARKER: &str = "shared::components::ObjectiveMarker";
|
||
pub const COMPONENT_PREFAB_INSTANCE: &str = "shared::components::PrefabInstance";
|
||
pub const COMPONENT_POST_PROCESS_VOLUME: &str = "shared::components::PostProcessVolumeDesc";
|
||
pub const COMPONENT_PROJECT_SUN: &str = "shared::components::ProjectSun";
|
||
pub const COMPONENT_AUDIO_SOURCE_DESC: &str = "shared::components::AudioSourceDesc";
|
||
pub const COMPONENT_AUDIO_LISTENER_DESC: &str = "shared::components::AudioListenerDesc";
|
||
|
||
/// Stable imported sub-asset identifier used by authored audio clip references.
|
||
pub const AUDIO_CLIP_SUB_ASSET_ID: &str = "audio:source";
|
||
|
||
/// Reference to an imported content-browser asset or sub-asset.
|
||
#[derive(Reflect, Default, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct EditorAssetRef {
|
||
/// Stable project asset registry UUID string.
|
||
pub asset_id: String,
|
||
/// Stable imported sub-asset ID inside the generated artifact.
|
||
#[serde(default, alias = "mesh_label")]
|
||
pub sub_asset_id: String,
|
||
/// UI label cached for inspectors. The asset registry/artifact remains authoritative.
|
||
#[serde(default)]
|
||
pub label: String,
|
||
/// Optional loadable project path cached for runtime hydration when the
|
||
/// referenced asset is not available through an imported artifact lookup.
|
||
#[serde(default)]
|
||
pub source_path: Option<String>,
|
||
}
|
||
|
||
impl EditorAssetRef {
|
||
pub fn new(
|
||
asset_id: impl Into<String>,
|
||
sub_asset_id: impl Into<String>,
|
||
label: impl Into<String>,
|
||
) -> Self {
|
||
Self {
|
||
asset_id: asset_id.into(),
|
||
sub_asset_id: sub_asset_id.into(),
|
||
label: label.into(),
|
||
source_path: None,
|
||
}
|
||
}
|
||
|
||
pub fn with_source_path(mut self, source_path: impl Into<String>) -> Self {
|
||
self.source_path = Some(source_path.into());
|
||
self
|
||
}
|
||
|
||
pub fn is_resolved(&self) -> bool {
|
||
!self.asset_id.trim().is_empty() && !self.sub_asset_id.trim().is_empty()
|
||
}
|
||
}
|
||
|
||
/// Distance curve used by an authored spatial audio source.
|
||
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub enum AudioRolloff {
|
||
Linear,
|
||
#[default]
|
||
Inverse,
|
||
Exponential,
|
||
}
|
||
|
||
/// Serializable distance attenuation parameters for spatial audio playback.
|
||
#[derive(Reflect, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct AudioAttenuationDesc {
|
||
/// Radius in meters inside which the source plays at full gain.
|
||
#[serde(default = "default_audio_min_distance")]
|
||
pub min_distance: f32,
|
||
/// Radius in meters at which distance attenuation reaches silence.
|
||
#[serde(default = "default_audio_max_distance")]
|
||
pub max_distance: f32,
|
||
#[serde(default)]
|
||
pub rolloff: AudioRolloff,
|
||
/// Curve strength. `1.0` is the neutral authored value.
|
||
#[serde(default = "default_audio_rolloff_factor")]
|
||
pub rolloff_factor: f32,
|
||
}
|
||
|
||
impl Default for AudioAttenuationDesc {
|
||
fn default() -> Self {
|
||
Self {
|
||
min_distance: default_audio_min_distance(),
|
||
max_distance: default_audio_max_distance(),
|
||
rolloff: AudioRolloff::default(),
|
||
rolloff_factor: default_audio_rolloff_factor(),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn default_audio_min_distance() -> f32 {
|
||
1.0
|
||
}
|
||
|
||
fn default_audio_max_distance() -> f32 {
|
||
25.0
|
||
}
|
||
|
||
fn default_audio_rolloff_factor() -> f32 {
|
||
1.0
|
||
}
|
||
|
||
fn default_audio_pitch() -> f32 {
|
||
1.0
|
||
}
|
||
|
||
fn default_audio_spatial_blend() -> f32 {
|
||
1.0
|
||
}
|
||
|
||
fn default_audio_bus() -> String {
|
||
settings::AUDIO_BUS_SFX_ID.to_string()
|
||
}
|
||
|
||
fn default_audio_ear_gap() -> f32 {
|
||
0.2
|
||
}
|
||
|
||
/// Scene-authored audio source. Runtime playback components are derived from this descriptor.
|
||
#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct AudioSourceDesc {
|
||
#[serde(default)]
|
||
pub clip: Option<EditorAssetRef>,
|
||
/// Source gain in decibels before bus gain is applied.
|
||
#[serde(default)]
|
||
pub gain_db: f32,
|
||
#[serde(default = "default_audio_pitch")]
|
||
pub pitch: f32,
|
||
#[serde(default)]
|
||
pub looping: bool,
|
||
#[serde(default = "default_true")]
|
||
pub autoplay: bool,
|
||
/// Continuous authored blend where `0.0` is 2D and `1.0` is fully spatial.
|
||
#[serde(default = "default_audio_spatial_blend")]
|
||
pub spatial_blend: f32,
|
||
#[serde(default)]
|
||
pub attenuation: AudioAttenuationDesc,
|
||
/// Stable project audio bus ID.
|
||
#[serde(default = "default_audio_bus")]
|
||
pub bus: String,
|
||
}
|
||
|
||
impl Default for AudioSourceDesc {
|
||
fn default() -> Self {
|
||
Self {
|
||
clip: None,
|
||
gain_db: 0.0,
|
||
pitch: default_audio_pitch(),
|
||
looping: false,
|
||
autoplay: true,
|
||
spatial_blend: default_audio_spatial_blend(),
|
||
attenuation: AudioAttenuationDesc::default(),
|
||
bus: default_audio_bus(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Scene-authored spatial listener. Runtime selection resolves enabled listeners by priority.
|
||
#[derive(Component, Reflect, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct AudioListenerDesc {
|
||
#[serde(default = "default_true")]
|
||
pub enabled: bool,
|
||
#[serde(default)]
|
||
pub priority: i32,
|
||
/// Distance in meters between the listener's virtual ears.
|
||
#[serde(default = "default_audio_ear_gap")]
|
||
pub ear_gap: f32,
|
||
}
|
||
|
||
impl Default for AudioListenerDesc {
|
||
fn default() -> Self {
|
||
Self {
|
||
enabled: true,
|
||
priority: 0,
|
||
ear_gap: default_audio_ear_gap(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Marker for entities that belong to editable/savable level data.
|
||
#[derive(Component, Reflect, Default, Debug, Clone, Copy, Serialize, Deserialize)]
|
||
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
|
||
pub struct LevelObject;
|
||
|
||
/// Runtime marker for helper meshes that must not enter Solari raytracing.
|
||
#[derive(Component, Reflect, Default, Debug, Clone, Copy)]
|
||
#[reflect(Component, Default, Debug)]
|
||
pub struct RaytracingExcluded;
|
||
|
||
/// Marks the player's spawn location for editor Play mode.
|
||
///
|
||
/// Place on a level object and set its `Transform` to choose where Play starts.
|
||
/// If none exists, the game default spawn is used.
|
||
#[derive(Component, Reflect, Default, Debug, Clone, Copy, Serialize, Deserialize)]
|
||
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
|
||
pub struct PlayerSpawn;
|
||
|
||
/// Marks the runtime/project default directional sun.
|
||
///
|
||
/// Scene-authored directional [`LightDesc`] entities can override this default
|
||
/// so levels can own their lighting without duplicating sun contribution.
|
||
#[derive(Component, Reflect, Default, Debug, Clone, Copy)]
|
||
#[reflect(Component, Default, Debug)]
|
||
pub struct ProjectSun;
|
||
|
||
/// Simple primitive meshes that can be authored without external assets.
|
||
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub enum PrimitiveShape {
|
||
#[default]
|
||
Box,
|
||
Sphere,
|
||
Ramp,
|
||
}
|
||
|
||
/// Reflectable authoring primitive. Hydration turns this into `Mesh3d`.
|
||
#[derive(Component, Reflect, Debug, Clone, Serialize, Deserialize)]
|
||
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
|
||
pub struct Primitive {
|
||
pub shape: PrimitiveShape,
|
||
pub size: Vec3,
|
||
}
|
||
|
||
impl Primitive {
|
||
pub fn cuboid(size: Vec3) -> Self {
|
||
Self {
|
||
shape: PrimitiveShape::Box,
|
||
size,
|
||
}
|
||
}
|
||
|
||
pub fn sphere(radius: f32) -> Self {
|
||
Self {
|
||
shape: PrimitiveShape::Sphere,
|
||
size: Vec3::splat(radius * 2.0),
|
||
}
|
||
}
|
||
|
||
pub fn ramp(size: Vec3) -> Self {
|
||
Self {
|
||
shape: PrimitiveShape::Ramp,
|
||
size,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Default for Primitive {
|
||
fn default() -> Self {
|
||
Self::cuboid(Vec3::ONE)
|
||
}
|
||
}
|
||
|
||
/// Brush boolean role. Subtractive brushes are authored now but CSG is applied
|
||
/// by later tooling.
|
||
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub enum BrushKind {
|
||
#[default]
|
||
Additive,
|
||
SubtractiveMarker,
|
||
}
|
||
|
||
/// Serializable convex brush plane. Plane equation is `normal.dot(point) = distance`.
|
||
#[derive(Reflect, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct BrushPlaneDesc {
|
||
pub normal: Vec3,
|
||
pub distance: f32,
|
||
}
|
||
|
||
impl Default for BrushPlaneDesc {
|
||
fn default() -> Self {
|
||
Self {
|
||
normal: Vec3::Y,
|
||
distance: 0.0,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One polygon face on a convex authoring brush.
|
||
#[derive(Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct BrushFaceDesc {
|
||
#[serde(default)]
|
||
pub id: ComponentInstanceId,
|
||
pub plane: BrushPlaneDesc,
|
||
#[serde(default)]
|
||
pub vertices: Vec<Vec3>,
|
||
#[serde(default)]
|
||
pub material: Option<EditorAssetRef>,
|
||
#[serde(default)]
|
||
pub texture: Option<EditorAssetRef>,
|
||
#[serde(default)]
|
||
pub uv_offset: Vec2,
|
||
#[serde(default = "default_uv_scale")]
|
||
pub uv_scale: Vec2,
|
||
#[serde(default)]
|
||
pub uv_rotation: f32,
|
||
#[serde(default)]
|
||
pub smoothing_group: u32,
|
||
}
|
||
|
||
impl Default for BrushFaceDesc {
|
||
fn default() -> Self {
|
||
Self {
|
||
id: ComponentInstanceId::default(),
|
||
plane: BrushPlaneDesc::default(),
|
||
vertices: Vec::new(),
|
||
material: None,
|
||
texture: None,
|
||
uv_offset: Vec2::ZERO,
|
||
uv_scale: default_uv_scale(),
|
||
uv_rotation: 0.0,
|
||
smoothing_group: 0,
|
||
}
|
||
}
|
||
}
|
||
|
||
fn default_uv_scale() -> Vec2 {
|
||
Vec2::ONE
|
||
}
|
||
|
||
/// Convex brush authoring data. Hydration turns this into generated mesh
|
||
/// children while the scene stores only this component.
|
||
#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct BrushDesc {
|
||
pub kind: BrushKind,
|
||
#[serde(default)]
|
||
pub faces: Vec<BrushFaceDesc>,
|
||
#[serde(default = "default_true")]
|
||
pub cast_shadows: bool,
|
||
#[serde(default = "default_true")]
|
||
pub receive_shadows: bool,
|
||
}
|
||
|
||
impl BrushDesc {
|
||
pub fn cuboid(size: Vec3) -> Self {
|
||
let hx = size.x.max(0.001) * 0.5;
|
||
let hy = size.y.max(0.001) * 0.5;
|
||
let hz = size.z.max(0.001) * 0.5;
|
||
let faces = vec![
|
||
brush_face(
|
||
"face:+x",
|
||
Vec3::X,
|
||
vec![
|
||
Vec3::new(hx, -hy, -hz),
|
||
Vec3::new(hx, hy, -hz),
|
||
Vec3::new(hx, hy, hz),
|
||
Vec3::new(hx, -hy, hz),
|
||
],
|
||
),
|
||
brush_face(
|
||
"face:-x",
|
||
Vec3::NEG_X,
|
||
vec![
|
||
Vec3::new(-hx, -hy, hz),
|
||
Vec3::new(-hx, hy, hz),
|
||
Vec3::new(-hx, hy, -hz),
|
||
Vec3::new(-hx, -hy, -hz),
|
||
],
|
||
),
|
||
brush_face(
|
||
"face:+y",
|
||
Vec3::Y,
|
||
vec![
|
||
Vec3::new(-hx, hy, -hz),
|
||
Vec3::new(-hx, hy, hz),
|
||
Vec3::new(hx, hy, hz),
|
||
Vec3::new(hx, hy, -hz),
|
||
],
|
||
),
|
||
brush_face(
|
||
"face:-y",
|
||
Vec3::NEG_Y,
|
||
vec![
|
||
Vec3::new(-hx, -hy, hz),
|
||
Vec3::new(-hx, -hy, -hz),
|
||
Vec3::new(hx, -hy, -hz),
|
||
Vec3::new(hx, -hy, hz),
|
||
],
|
||
),
|
||
brush_face(
|
||
"face:+z",
|
||
Vec3::Z,
|
||
vec![
|
||
Vec3::new(hx, -hy, hz),
|
||
Vec3::new(hx, hy, hz),
|
||
Vec3::new(-hx, hy, hz),
|
||
Vec3::new(-hx, -hy, hz),
|
||
],
|
||
),
|
||
brush_face(
|
||
"face:-z",
|
||
Vec3::NEG_Z,
|
||
vec![
|
||
Vec3::new(-hx, -hy, -hz),
|
||
Vec3::new(-hx, hy, -hz),
|
||
Vec3::new(hx, hy, -hz),
|
||
Vec3::new(hx, -hy, -hz),
|
||
],
|
||
),
|
||
];
|
||
Self {
|
||
kind: BrushKind::Additive,
|
||
faces,
|
||
cast_shadows: true,
|
||
receive_shadows: true,
|
||
}
|
||
}
|
||
|
||
pub fn extruded_prism(base_vertices: &[Vec3], height: f32) -> Option<Self> {
|
||
let mut base = crate::brush_math::normalize_floor_polygon(base_vertices).ok()?;
|
||
crate::brush_math::validate_floor_polygon(&base).ok()?;
|
||
let height = height.max(0.001);
|
||
if crate::brush_math::signed_area_xz(&base) < 0.0 {
|
||
base.reverse();
|
||
}
|
||
let top: Vec<Vec3> = base
|
||
.iter()
|
||
.map(|vertex| *vertex + Vec3::Y * height)
|
||
.collect();
|
||
let mut top_face = top;
|
||
top_face.reverse();
|
||
|
||
let mut faces = Vec::with_capacity(base.len() + 2);
|
||
faces.push(brush_face("face:+y", Vec3::Y, top_face));
|
||
faces.push(brush_face("face:-y", Vec3::NEG_Y, base.clone()));
|
||
for index in 0..base.len() {
|
||
let next = (index + 1) % base.len();
|
||
let a = base[index];
|
||
let b = base[next];
|
||
let edge = b - a;
|
||
let normal = Vec3::new(edge.z, 0.0, -edge.x).normalize_or_zero();
|
||
if normal.length_squared() <= f32::EPSILON {
|
||
return None;
|
||
}
|
||
faces.push(brush_face(
|
||
&format!("face:side:{index}"),
|
||
normal,
|
||
vec![a, a + Vec3::Y * height, b + Vec3::Y * height, b],
|
||
));
|
||
}
|
||
|
||
Some(Self {
|
||
kind: BrushKind::Additive,
|
||
faces,
|
||
cast_shadows: true,
|
||
receive_shadows: true,
|
||
})
|
||
}
|
||
}
|
||
|
||
impl Default for BrushDesc {
|
||
fn default() -> Self {
|
||
Self::cuboid(Vec3::ONE)
|
||
}
|
||
}
|
||
|
||
fn brush_face(id: &str, normal: Vec3, vertices: Vec<Vec3>) -> BrushFaceDesc {
|
||
let distance = vertices
|
||
.first()
|
||
.map(|vertex| normal.dot(*vertex))
|
||
.unwrap_or(0.0);
|
||
BrushFaceDesc {
|
||
id: ComponentInstanceId::new(id),
|
||
plane: BrushPlaneDesc { normal, distance },
|
||
vertices,
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// Legacy static mesh collision setting kept only for loading older scenes.
|
||
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub enum StaticMeshColliderMode {
|
||
#[default]
|
||
None,
|
||
/// Generate an Avian triangle mesh collider from the loaded render mesh.
|
||
TrimeshFromMesh,
|
||
}
|
||
|
||
/// Backward-compatible alias for the previous static mesh reference name.
|
||
pub type StaticMeshAssetRef = EditorAssetRef;
|
||
|
||
/// One material slot on a static mesh renderer entry.
|
||
#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct StaticMeshMaterialSlot {
|
||
pub slot_index: usize,
|
||
pub name: String,
|
||
/// Bevy sub-asset label for the source material, if the importer provided one.
|
||
pub source_material_label: Option<String>,
|
||
/// Optional per-instance material override.
|
||
pub override_material: Option<MaterialDesc>,
|
||
}
|
||
|
||
/// One mesh draw slot owned by a [`StaticMeshRenderer`].
|
||
#[derive(Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct MeshRenderSlot {
|
||
#[serde(default)]
|
||
pub id: ComponentInstanceId,
|
||
pub name: String,
|
||
pub mesh: EditorAssetRef,
|
||
/// Stable renderer material slot used by this draw part.
|
||
#[serde(default)]
|
||
pub material_slot_id: ComponentInstanceId,
|
||
/// Legacy inline imported-material reference. Schema-v4 migration moves this into the
|
||
/// renderer's shared `RendererMaterialSet`.
|
||
#[serde(default)]
|
||
pub material: Option<EditorAssetRef>,
|
||
#[serde(default)]
|
||
pub local_transform: Transform,
|
||
#[serde(default = "default_true")]
|
||
pub visible: bool,
|
||
#[serde(default = "default_true")]
|
||
pub cast_shadows: bool,
|
||
#[serde(default = "default_true")]
|
||
pub receive_shadows: bool,
|
||
}
|
||
|
||
impl Default for MeshRenderSlot {
|
||
fn default() -> Self {
|
||
Self {
|
||
id: ComponentInstanceId::default(),
|
||
name: "Mesh".into(),
|
||
mesh: EditorAssetRef::default(),
|
||
material_slot_id: ComponentInstanceId::default(),
|
||
material: None,
|
||
local_transform: Transform::default(),
|
||
visible: true,
|
||
cast_shadows: true,
|
||
receive_shadows: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Backward-compatible alias for old code paths while scenes migrate to slots.
|
||
pub type StaticMeshRendererEntry = MeshRenderSlot;
|
||
|
||
/// Public renderer-foundation name for one static draw part.
|
||
pub type StaticMeshPart = MeshRenderSlot;
|
||
|
||
/// Reflectable static mesh renderer. Hydration turns entries into child `Mesh3d`
|
||
/// entities and source/default materials.
|
||
#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct StaticMeshRenderer {
|
||
#[serde(default, alias = "entries")]
|
||
pub slots: Vec<MeshRenderSlot>,
|
||
#[serde(default)]
|
||
pub materials: crate::RendererMaterialSet,
|
||
}
|
||
|
||
impl StaticMeshRenderer {
|
||
pub fn empty() -> Self {
|
||
Self {
|
||
slots: Vec::new(),
|
||
materials: crate::RendererMaterialSet::default(),
|
||
}
|
||
}
|
||
|
||
pub fn single(mut slot: MeshRenderSlot) -> Self {
|
||
if slot.material_slot_id.0.trim().is_empty() {
|
||
slot.material_slot_id = ComponentInstanceId::new(format!("slot:{}", slot.id.0));
|
||
}
|
||
let source_material = slot.material.clone().map(crate::MaterialRef::new);
|
||
let materials = crate::RendererMaterialSet {
|
||
slots: vec![crate::RendererMaterialSlot {
|
||
id: slot.material_slot_id.clone(),
|
||
name: slot.name.clone(),
|
||
source_material,
|
||
material: None,
|
||
}],
|
||
orphaned_assignments: Vec::new(),
|
||
};
|
||
Self {
|
||
slots: vec![slot],
|
||
materials,
|
||
}
|
||
}
|
||
|
||
pub fn material_slot_for_part(
|
||
&self,
|
||
part: &MeshRenderSlot,
|
||
) -> Option<&crate::RendererMaterialSlot> {
|
||
let slot_id = if part.material_slot_id.0.trim().is_empty() {
|
||
&part.id
|
||
} else {
|
||
&part.material_slot_id
|
||
};
|
||
self.materials.slot(slot_id)
|
||
}
|
||
}
|
||
|
||
impl Default for StaticMeshRenderer {
|
||
fn default() -> Self {
|
||
Self::empty()
|
||
}
|
||
}
|
||
|
||
fn default_true() -> bool {
|
||
true
|
||
}
|
||
|
||
/// How a material should be resolved at runtime.
|
||
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub enum MaterialShaderKind {
|
||
#[default]
|
||
StandardLit,
|
||
Unlit,
|
||
Custom,
|
||
}
|
||
|
||
/// Reference to a built-in or custom shader schema.
|
||
#[derive(Reflect, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct ShaderRefDesc {
|
||
pub kind: MaterialShaderKind,
|
||
#[serde(default)]
|
||
pub schema_path: Option<String>,
|
||
#[serde(default)]
|
||
pub shader_path: Option<String>,
|
||
}
|
||
|
||
impl Default for ShaderRefDesc {
|
||
fn default() -> Self {
|
||
Self {
|
||
kind: MaterialShaderKind::StandardLit,
|
||
schema_path: None,
|
||
shader_path: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Typed material parameter value exposed by a shader schema.
|
||
#[derive(Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub enum MaterialParameterValue {
|
||
Bool(bool),
|
||
Float(f32),
|
||
Vec2(Vec2),
|
||
Vec3(Vec3),
|
||
Color(ColorDesc),
|
||
Enum(String),
|
||
}
|
||
|
||
impl Default for MaterialParameterValue {
|
||
fn default() -> Self {
|
||
Self::Float(0.0)
|
||
}
|
||
}
|
||
|
||
#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct MaterialParameter {
|
||
pub name: String,
|
||
pub value: MaterialParameterValue,
|
||
}
|
||
|
||
#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct MaterialTextureBinding {
|
||
pub name: String,
|
||
pub texture: Option<EditorAssetRef>,
|
||
}
|
||
|
||
/// Per-render-slot material override stored on scene actors.
|
||
#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct MaterialSlotOverride {
|
||
pub slot_id: ComponentInstanceId,
|
||
#[serde(default)]
|
||
pub base_material: Option<EditorAssetRef>,
|
||
pub material: MaterialDesc,
|
||
}
|
||
|
||
/// Actor-level material overrides. Shared material assets are edited by asset
|
||
/// inspectors, while actor inspectors write only to this component.
|
||
#[derive(Component, Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct MaterialOverride {
|
||
#[serde(default)]
|
||
pub slots: Vec<MaterialSlotOverride>,
|
||
}
|
||
|
||
/// Serializable color description. This avoids coupling saved scenes to any
|
||
/// internal color representation details.
|
||
#[derive(Reflect, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||
#[reflect(Default, Debug, Serialize, Deserialize)]
|
||
pub struct ColorDesc {
|
||
pub r: f32,
|
||
pub g: f32,
|
||
pub b: f32,
|
||
pub a: f32,
|
||
}
|
||
|
||
impl ColorDesc {
|
||
pub const fn srgb(r: f32, g: f32, b: f32) -> Self {
|
||
Self { r, g, b, a: 1.0 }
|
||
}
|
||
|
||
pub fn to_color(self) -> Color {
|
||
Color::srgba(self.r, self.g, self.b, self.a)
|
||
}
|
||
}
|
||
|
||
impl Default for ColorDesc {
|
||
fn default() -> Self {
|
||
Self::srgb(0.8, 0.8, 0.8)
|
||
}
|
||
}
|
||
|
||
/// Reflectable authoring material. Hydration turns this into `StandardMaterial`.
|
||
#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
|
||
pub struct MaterialDesc {
|
||
#[serde(default)]
|
||
pub shader: ShaderRefDesc,
|
||
pub base_color: ColorDesc,
|
||
pub metallic: f32,
|
||
pub roughness: f32,
|
||
/// Emissive radiance tint. Values are multiplied by `emissive_intensity`.
|
||
#[serde(default = "default_emissive_color")]
|
||
pub emissive_color: ColorDesc,
|
||
/// Emissive luminance multiplier in nits. `0.0` means non-emissive.
|
||
#[serde(default)]
|
||
pub emissive_intensity: f32,
|
||
/// Optional base color (albedo) texture, path relative to `assets/`.
|
||
pub base_color_texture: Option<String>,
|
||
/// Optional emissive texture, path relative to `assets/`.
|
||
#[serde(default)]
|
||
pub emissive_texture: Option<String>,
|
||
/// Optional normal map texture, path relative to `assets/`.
|
||
pub normal_map_texture: Option<String>,
|
||
/// Optional metallic/roughness texture, path relative to `assets/`.
|
||
pub metallic_roughness_texture: Option<String>,
|
||
/// When set, references a [`crate::MaterialAsset`] RON under `assets/materials/`.
|
||
#[serde(default)]
|
||
pub material_asset_path: Option<String>,
|
||
/// Shader-schema-driven scalar/vector/color values.
|
||
#[serde(default)]
|
||
pub parameters: Vec<MaterialParameter>,
|
||
/// Shader-schema-driven texture references.
|
||
#[serde(default)]
|
||
pub textures: Vec<MaterialTextureBinding>,
|
||
}
|
||
|
||
impl MaterialDesc {
|
||
pub fn new(base_color: ColorDesc, metallic: f32, roughness: f32) -> Self {
|
||
Self {
|
||
shader: ShaderRefDesc::default(),
|
||
base_color,
|
||
metallic,
|
||
roughness,
|
||
emissive_color: default_emissive_color(),
|
||
emissive_intensity: 0.0,
|
||
base_color_texture: None,
|
||
emissive_texture: None,
|
||
normal_map_texture: None,
|
||
metallic_roughness_texture: None,
|
||
material_asset_path: None,
|
||
parameters: Vec::new(),
|
||
textures: Vec::new(),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn default_emissive_color() -> ColorDesc {
|
||
ColorDesc::srgb(1.0, 1.0, 1.0)
|
||
}
|
||
|
||
impl Default for MaterialDesc {
|
||
fn default() -> Self {
|
||
Self::new(ColorDesc::default(), 0.0, 0.65)
|
||
}
|
||
}
|
||
|
||
/// Reflectable reference to an imported 3D model scene (glTF/GLB or FBX).
|
||
/// Hydration turns this into a `WorldAssetRoot` on the entity.
|
||
#[derive(Component, Reflect, Default, Debug, Clone, Serialize, Deserialize)]
|
||
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
|
||
pub struct ModelRef {
|
||
/// Stable asset-registry UUID. Empty only for legacy or unresolved references.
|
||
#[serde(default)]
|
||
pub asset_id: String,
|
||
/// Asset path relative to `assets/`, e.g. `models/foo.glb` or `models/foo.fbx`.
|
||
pub path: String,
|
||
/// Scene index within the model file (`#SceneN` for FBX; glTF scene index otherwise).
|
||
pub scene_index: usize,
|
||
}
|
||
|
||
impl ModelRef {
|
||
pub fn new(path: impl Into<String>) -> Self {
|
||
Self {
|
||
asset_id: String::new(),
|
||
path: path.into(),
|
||
scene_index: 0,
|
||
}
|
||
}
|
||
|
||
pub fn with_asset_id(mut self, asset_id: impl Into<String>) -> Self {
|
||
self.asset_id = asset_id.into();
|
||
self
|
||
}
|
||
|
||
/// Asset-server path for FBX `#SceneN` labels (see `bevy_ufbx`).
|
||
pub fn fbx_scene_asset_path(path: &str, scene_index: usize) -> String {
|
||
format!("{path}#Scene{scene_index}")
|
||
}
|
||
}
|
||
|
||
/// Reflectable reference to a saved `.scn.ron` dynamic scene under `assets/`.
|
||
/// Hydration turns this into a `DynamicWorldRoot`.
|
||
#[derive(Component, Reflect, Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
|
||
pub struct PrefabRef {
|
||
/// Asset path relative to `assets/`, e.g. "prefabs/foo.scn.ron".
|
||
pub path: String,
|
||
}
|
||
|
||
impl PrefabRef {
|
||
pub fn new(path: impl Into<String>) -> Self {
|
||
Self { path: path.into() }
|
||
}
|
||
}
|
||
|
||
/// Marks a placed prefab instance with stable registry identity.
|
||
#[derive(Component, Reflect, Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
|
||
pub struct PrefabInstance {
|
||
/// Stable asset registry UUID string.
|
||
pub asset_id: String,
|
||
/// Source prefab path relative to `assets/`.
|
||
pub source_path: String,
|
||
/// Optional per-instance transform override serialized as RON (v1: unused).
|
||
#[serde(default)]
|
||
pub overrides_ron: Option<String>,
|
||
}
|
||
|
||
impl PrefabInstance {
|
||
pub fn new(asset_id: impl Into<String>, source_path: impl Into<String>) -> Self {
|
||
Self {
|
||
asset_id: asset_id.into(),
|
||
source_path: source_path.into(),
|
||
overrides_ron: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Weapon pickup / spawn pad for FPS gameplay authoring.
|
||
#[derive(Component, Reflect, Default, Debug, Clone, Serialize, Deserialize)]
|
||
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
|
||
pub struct WeaponSpawn {
|
||
pub weapon_id: String,
|
||
}
|
||
|
||
/// Per-field post-process overrides (`None` = inherit project settings).
|
||
#[derive(Reflect, Debug, Clone, Default, Serialize, Deserialize)]
|
||
#[reflect(Default, Debug, Serialize, Deserialize)]
|
||
pub struct PostProcessVolumeOverrides {
|
||
pub exposure_ev100: Option<f32>,
|
||
pub fog_color: Option<ColorDesc>,
|
||
pub fog_density: Option<f32>,
|
||
pub bloom: Option<bool>,
|
||
pub taa: Option<bool>,
|
||
pub ssao: Option<bool>,
|
||
pub tonemapping_aces: Option<bool>,
|
||
pub atmosphere: Option<bool>,
|
||
pub hdr: Option<bool>,
|
||
}
|
||
|
||
impl PostProcessVolumeOverrides {
|
||
pub fn override_count(&self) -> u32 {
|
||
let mut count = 0u32;
|
||
if self.exposure_ev100.is_some() {
|
||
count += 1;
|
||
}
|
||
if self.fog_color.is_some() {
|
||
count += 1;
|
||
}
|
||
if self.fog_density.is_some() {
|
||
count += 1;
|
||
}
|
||
if self.bloom.is_some() {
|
||
count += 1;
|
||
}
|
||
if self.taa.is_some() {
|
||
count += 1;
|
||
}
|
||
if self.ssao.is_some() {
|
||
count += 1;
|
||
}
|
||
if self.tonemapping_aces.is_some() {
|
||
count += 1;
|
||
}
|
||
if self.atmosphere.is_some() {
|
||
count += 1;
|
||
}
|
||
if self.hdr.is_some() {
|
||
count += 1;
|
||
}
|
||
count
|
||
}
|
||
}
|
||
|
||
/// Scene-authored post-process volume (axis-aligned box in local space).
|
||
#[derive(Component, Reflect, Debug, Clone, Serialize, Deserialize)]
|
||
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
|
||
pub struct PostProcessVolumeDesc {
|
||
pub half_extents: Vec3,
|
||
pub priority: i32,
|
||
pub blend_distance: f32,
|
||
pub overrides: PostProcessVolumeOverrides,
|
||
/// Path to `assets/post_fx/*.ron`, if any.
|
||
pub fullscreen_effect: Option<String>,
|
||
/// Optional `assets/rendering_profiles/*.ron` preset seed.
|
||
pub profile: Option<String>,
|
||
/// Editor-friendly label for HUD / Rendering panel.
|
||
pub label: Option<String>,
|
||
}
|
||
|
||
impl Default for PostProcessVolumeDesc {
|
||
fn default() -> Self {
|
||
Self {
|
||
half_extents: Vec3::new(4.0, 2.0, 4.0),
|
||
priority: 0,
|
||
blend_distance: 2.0,
|
||
overrides: PostProcessVolumeOverrides::default(),
|
||
fullscreen_effect: None,
|
||
profile: None,
|
||
label: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Axis-aligned trigger volume for gameplay events.
|
||
#[derive(Component, Reflect, Debug, Clone, Serialize, Deserialize)]
|
||
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
|
||
pub struct TriggerVolume {
|
||
pub half_extents: Vec3,
|
||
pub event_name: String,
|
||
}
|
||
|
||
impl Default for TriggerVolume {
|
||
fn default() -> Self {
|
||
Self {
|
||
half_extents: Vec3::new(2.0, 2.0, 2.0),
|
||
event_name: "trigger".into(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Team spawn point marker.
|
||
#[derive(Component, Reflect, Default, Debug, Clone, Serialize, Deserialize)]
|
||
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
|
||
pub struct TeamSpawn {
|
||
pub team_id: u8,
|
||
}
|
||
|
||
/// Objective / game mode marker.
|
||
#[derive(Component, Reflect, Default, Debug, Clone, Serialize, Deserialize)]
|
||
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
|
||
pub struct ObjectiveMarker {
|
||
pub objective_id: String,
|
||
}
|
||
|
||
/// Stable sibling order among entities sharing the same parent in the hierarchy outliner.
|
||
#[derive(Component, Reflect, Default, Debug, Clone, Copy, Serialize, Deserialize)]
|
||
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
|
||
pub struct HierarchySiblingIndex(pub i32);
|
||
|
||
/// Authoring visibility saved with the scene; hydration maps this to ECS [`Visibility`].
|
||
#[derive(Component, Reflect, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
|
||
pub struct EditorVisibility {
|
||
pub visible: bool,
|
||
}
|
||
|
||
impl Default for EditorVisibility {
|
||
fn default() -> Self {
|
||
Self { visible: true }
|
||
}
|
||
}
|
||
|
||
/// Declared role of a level object for inspector routing and save validation.
|
||
#[derive(Component, Reflect, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[reflect(Component, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub enum ActorKind {
|
||
Empty,
|
||
Brush,
|
||
StaticMesh,
|
||
SkinnedMesh,
|
||
ImportedModel,
|
||
Light,
|
||
PrefabAnchor,
|
||
PlayerSpawn,
|
||
WeaponSpawn,
|
||
TriggerVolume,
|
||
PostProcessVolume,
|
||
TeamSpawn,
|
||
Objective,
|
||
AudioSource,
|
||
AudioListener,
|
||
Navigation,
|
||
}
|
||
|
||
/// Inspector upper bound for point/spot [`LightDesc::intensity`] (lumens).
|
||
///
|
||
/// Bevy's `PointLight` / `SpotLight` default is `light_consts::lumens::VERY_LARGE_CINEMA_LIGHT`
|
||
/// (1M lumens) for outdoor exposure (~12–15 EV100). This cap is 2× that reference so authored
|
||
/// lights can exceed the old 200k ceiling without unbounded sliders.
|
||
pub const AUTHORING_POINT_SPOT_LUMENS_MAX: f32 = 2_000_000.0;
|
||
|
||
/// Inspector upper bound for directional [`LightDesc::intensity`] (lux).
|
||
pub const AUTHORING_DIRECTIONAL_LUX_MAX: f32 = 150_000.0;
|
||
|
||
/// Serializable light kinds for editor-authored lighting.
|
||
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub enum AuthoringLightKind {
|
||
#[default]
|
||
Point,
|
||
Spot,
|
||
Directional,
|
||
}
|
||
|
||
/// Reflectable authoring light. Hydration turns this into Bevy light components.
|
||
#[derive(Component, Reflect, Debug, Clone, Serialize, Deserialize)]
|
||
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
|
||
pub struct LightDesc {
|
||
pub kind: AuthoringLightKind,
|
||
pub color: ColorDesc,
|
||
/// Lumens (point/spot) or lux (directional).
|
||
pub intensity: f32,
|
||
/// Attenuation range in meters (point/spot).
|
||
pub range: f32,
|
||
pub shadows: bool,
|
||
/// Spot inner cone half-angle in degrees.
|
||
#[serde(default = "default_spot_inner_angle_deg")]
|
||
pub inner_angle_deg: f32,
|
||
/// Spot outer cone half-angle in degrees.
|
||
#[serde(default = "default_spot_outer_angle_deg")]
|
||
pub outer_angle_deg: f32,
|
||
}
|
||
|
||
fn default_spot_inner_angle_deg() -> f32 {
|
||
25.0
|
||
}
|
||
|
||
fn default_spot_outer_angle_deg() -> f32 {
|
||
35.0
|
||
}
|
||
|
||
impl Default for LightDesc {
|
||
fn default() -> Self {
|
||
Self::for_kind(AuthoringLightKind::Point)
|
||
}
|
||
}
|
||
|
||
impl LightDesc {
|
||
/// Sensible defaults when spawning a builtin light from the asset browser.
|
||
pub fn for_kind(kind: AuthoringLightKind) -> Self {
|
||
match kind {
|
||
AuthoringLightKind::Point => Self {
|
||
kind,
|
||
color: ColorDesc::srgb(1.0, 0.95, 0.85),
|
||
intensity: 25_000.0,
|
||
range: 25.0,
|
||
shadows: true,
|
||
inner_angle_deg: default_spot_inner_angle_deg(),
|
||
outer_angle_deg: default_spot_outer_angle_deg(),
|
||
},
|
||
AuthoringLightKind::Spot => Self {
|
||
kind,
|
||
color: ColorDesc::srgb(1.0, 0.95, 0.85),
|
||
intensity: 30_000.0,
|
||
range: 35.0,
|
||
shadows: true,
|
||
inner_angle_deg: default_spot_inner_angle_deg(),
|
||
outer_angle_deg: default_spot_outer_angle_deg(),
|
||
},
|
||
AuthoringLightKind::Directional => Self {
|
||
kind,
|
||
color: ColorDesc::srgb(1.0, 0.95, 0.85),
|
||
// Matches `ProjectSettings::rendering.sun_illuminance` default; spawn code
|
||
// refreshes this from live project settings when placing in the scene.
|
||
intensity: 100_000.0,
|
||
range: 0.0,
|
||
shadows: true,
|
||
inner_angle_deg: default_spot_inner_angle_deg(),
|
||
outer_angle_deg: default_spot_outer_angle_deg(),
|
||
},
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Runtime body type in a serialized-friendly form.
|
||
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub enum AuthoringRigidBody {
|
||
#[default]
|
||
Static,
|
||
Kinematic,
|
||
Dynamic,
|
||
}
|
||
|
||
/// Reflectable authoring rigid body separated from collider geometry.
|
||
#[derive(Component, Reflect, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct RigidBodyDesc {
|
||
pub body: AuthoringRigidBody,
|
||
}
|
||
|
||
impl Default for RigidBodyDesc {
|
||
fn default() -> Self {
|
||
Self {
|
||
body: AuthoringRigidBody::Static,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Mesh collider cooking options for imported static mesh assets.
|
||
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub enum MeshColliderCooking {
|
||
#[default]
|
||
Default,
|
||
}
|
||
|
||
/// Serializable collider shape independent of rigid body settings.
|
||
#[derive(Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub enum ColliderShapeDesc {
|
||
Cuboid {
|
||
x_length: f32,
|
||
y_length: f32,
|
||
z_length: f32,
|
||
},
|
||
Sphere {
|
||
radius: f32,
|
||
},
|
||
Capsule {
|
||
radius: f32,
|
||
height: f32,
|
||
},
|
||
StaticMesh {
|
||
meshes: Vec<EditorAssetRef>,
|
||
cooking: MeshColliderCooking,
|
||
},
|
||
}
|
||
|
||
impl Default for ColliderShapeDesc {
|
||
fn default() -> Self {
|
||
Self::Cuboid {
|
||
x_length: 1.0,
|
||
y_length: 1.0,
|
||
z_length: 1.0,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl ColliderShapeDesc {
|
||
pub fn static_mesh(meshes: Vec<EditorAssetRef>) -> Self {
|
||
Self::StaticMesh {
|
||
meshes,
|
||
cooking: MeshColliderCooking::Default,
|
||
}
|
||
}
|
||
|
||
pub fn to_constructor(&self) -> Option<ColliderConstructor> {
|
||
match self {
|
||
ColliderShapeDesc::Cuboid {
|
||
x_length,
|
||
y_length,
|
||
z_length,
|
||
} => Some(ColliderConstructor::Cuboid {
|
||
x_length: *x_length,
|
||
y_length: *y_length,
|
||
z_length: *z_length,
|
||
}),
|
||
ColliderShapeDesc::Sphere { radius } => {
|
||
Some(ColliderConstructor::Sphere { radius: *radius })
|
||
}
|
||
ColliderShapeDesc::Capsule { radius, height } => Some(ColliderConstructor::Capsule {
|
||
radius: *radius,
|
||
height: *height,
|
||
}),
|
||
ColliderShapeDesc::StaticMesh { .. } => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Reflectable authoring collider. Static mesh colliders are hydrated on
|
||
/// generated mesh children so they can reuse Bevy's mesh-to-collider path.
|
||
#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct ColliderDesc {
|
||
#[serde(default = "default_true")]
|
||
pub enabled: bool,
|
||
#[serde(default)]
|
||
pub is_trigger: bool,
|
||
pub shape: ColliderShapeDesc,
|
||
}
|
||
|
||
impl ColliderDesc {
|
||
pub fn static_cuboid(size: Vec3) -> Self {
|
||
Self {
|
||
enabled: true,
|
||
is_trigger: false,
|
||
shape: ColliderShapeDesc::Cuboid {
|
||
x_length: size.x,
|
||
y_length: size.y,
|
||
z_length: size.z,
|
||
},
|
||
}
|
||
}
|
||
|
||
pub fn static_sphere(radius: f32) -> Self {
|
||
Self {
|
||
enabled: true,
|
||
is_trigger: false,
|
||
shape: ColliderShapeDesc::Sphere { radius },
|
||
}
|
||
}
|
||
|
||
pub fn static_mesh(meshes: Vec<EditorAssetRef>) -> Self {
|
||
Self {
|
||
enabled: true,
|
||
is_trigger: false,
|
||
shape: ColliderShapeDesc::static_mesh(meshes),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Default for ColliderDesc {
|
||
fn default() -> Self {
|
||
Self::static_cuboid(Vec3::ONE)
|
||
}
|
||
}
|
||
|
||
/// Reflectable authoring physics. Hydration turns this into `RigidBody` and
|
||
/// Avian's reflectable `ColliderConstructor` component.
|
||
#[derive(Component, Reflect, Debug, Clone, Serialize, Deserialize)]
|
||
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
|
||
pub struct PhysicsBody {
|
||
pub body: AuthoringRigidBody,
|
||
pub collider: ColliderConstructor,
|
||
}
|
||
|
||
impl PhysicsBody {
|
||
pub fn static_cuboid(size: Vec3) -> Self {
|
||
Self {
|
||
body: AuthoringRigidBody::Static,
|
||
collider: ColliderConstructor::Cuboid {
|
||
x_length: size.x,
|
||
y_length: size.y,
|
||
z_length: size.z,
|
||
},
|
||
}
|
||
}
|
||
|
||
pub fn static_sphere(radius: f32) -> Self {
|
||
Self {
|
||
body: AuthoringRigidBody::Static,
|
||
collider: ColliderConstructor::Sphere { radius },
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Default for PhysicsBody {
|
||
fn default() -> Self {
|
||
Self::static_cuboid(Vec3::ONE)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{
|
||
AudioListenerDesc, AudioRolloff, AudioSourceDesc, EditorAssetRef, ModelRef,
|
||
AUDIO_CLIP_SUB_ASSET_ID,
|
||
};
|
||
use bevy::light::light_consts;
|
||
|
||
#[test]
|
||
fn point_spot_lumen_max_covers_bevy_cinema_reference() {
|
||
const {
|
||
assert!(
|
||
super::AUTHORING_POINT_SPOT_LUMENS_MAX
|
||
>= light_consts::lumens::VERY_LARGE_CINEMA_LIGHT
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn fbx_scene_asset_path_format() {
|
||
assert_eq!(
|
||
ModelRef::fbx_scene_asset_path("models/foo.fbx", 0),
|
||
"models/foo.fbx#Scene0"
|
||
);
|
||
assert_eq!(
|
||
ModelRef::fbx_scene_asset_path("models/foo.fbx", 2),
|
||
"models/foo.fbx#Scene2"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn legacy_model_ref_without_asset_id_remains_readable() {
|
||
let model: ModelRef = ron::from_str(
|
||
r#"(
|
||
path: "assets/models/legacy.glb",
|
||
scene_index: 2,
|
||
)"#,
|
||
)
|
||
.expect("legacy ModelRef should deserialize");
|
||
|
||
assert!(model.asset_id.is_empty());
|
||
assert_eq!(model.path, "assets/models/legacy.glb");
|
||
assert_eq!(model.scene_index, 2);
|
||
}
|
||
|
||
#[test]
|
||
fn audio_authoring_descriptors_round_trip() {
|
||
let source = AudioSourceDesc {
|
||
clip: Some(
|
||
EditorAssetRef::new("clip-id", AUDIO_CLIP_SUB_ASSET_ID, "Alarm")
|
||
.with_source_path("assets/audio/alarm.ogg"),
|
||
),
|
||
gain_db: -3.0,
|
||
pitch: 0.9,
|
||
looping: true,
|
||
..Default::default()
|
||
};
|
||
let serialized = ron::to_string(&(source.clone(), AudioListenerDesc::default())).unwrap();
|
||
let (parsed_source, parsed_listener): (AudioSourceDesc, AudioListenerDesc) =
|
||
ron::from_str(&serialized).unwrap();
|
||
|
||
assert_eq!(parsed_source, source);
|
||
assert_eq!(parsed_listener, AudioListenerDesc::default());
|
||
assert_eq!(parsed_source.attenuation.rolloff, AudioRolloff::Inverse);
|
||
assert_eq!(parsed_source.bus, settings::AUDIO_BUS_SFX_ID);
|
||
}
|
||
}
|