diff --git a/crates/skate-data/examples/inspect_skate.rs b/crates/skate-data/examples/inspect_skate.rs index 56cd0792..3d8cfa1f 100644 --- a/crates/skate-data/examples/inspect_skate.rs +++ b/crates/skate-data/examples/inspect_skate.rs @@ -1,7 +1,18 @@ fn main() { let mut failed = false; + let mut render_only = false; for path in std::env::args_os().skip(1) { - match skate_data::skate_map::SkateMap::load(std::path::Path::new(&path)) { + if path == "--render-only" { + render_only = true; + continue; + } + let result = if render_only { + std::fs::read(&path).map_err(|e| e.to_string()) + .and_then(|bytes| skate_data::skate_map::SkateMap::parse_render_only(&bytes)) + } else { + skate_data::skate_map::SkateMap::load(std::path::Path::new(&path)) + }; + match result { Ok(m) => { println!( "{} v{}: vertices={} triangles={} collision={} textures={} rails={} doors={} lights={} routes={} spawn={:?} heading={}", diff --git a/crates/skate-data/src/skate_map.rs b/crates/skate-data/src/skate_map.rs index 56ccca9b..e295dc04 100644 --- a/crates/skate-data/src/skate_map.rs +++ b/crates/skate-data/src/skate_map.rs @@ -368,6 +368,22 @@ impl SkateMap { /// Data tools can request one worker for reproducible serial comparisons. /// The decoder caps concurrency at eight and skips threading for small maps. pub fn parse_with_decode_workers(data: &[u8], workers: usize) -> Result { + Self::parse_inner(data, workers, true) + } + + /// Decode a supplemental presentation package without requiring collision. + /// Playable map loading continues to use `parse`/`load`. + pub fn parse_render_only(data: &[u8]) -> Result { + let map = Self::parse_inner(data, 4, false)?; + if !map.geometry.collision.is_empty() || !map.rails.is_empty() + || !map.doors.is_empty() || !map.lights.is_empty() || !map.routes.is_empty() + || map.extensions.iter().any(|e| e.tag != *b"WMET") + { + return Err("SKATE render-only package contains non-presentation data".into()); + } + Ok(map) + } + fn parse_inner(data: &[u8], workers: usize, require_collision: bool) -> Result { let parse_started = Instant::now(); let mut r = Reader { bytes: data, at: 0 }; let magic = r.take(8)?; @@ -671,7 +687,7 @@ impl SkateMap { }); } } - if geometry.collision.is_empty() && !extensions.iter().any(|e| e.tag == *b"RWCM" && e.schema == 1 && !e.payload.is_empty()) { + if require_collision && geometry.collision.is_empty() && !extensions.iter().any(|e| e.tag == *b"RWCM" && e.schema == 1 && !e.payload.is_empty()) { return Err("SKATE requires triangle collision or an embedded RWCM archive".into()); } if r.at != data.len() { diff --git a/crates/skate-data/tests/skate_map.rs b/crates/skate-data/tests/skate_map.rs index d48ca217..013e49b7 100644 --- a/crates/skate-data/tests/skate_map.rs +++ b/crates/skate-data/tests/skate_map.rs @@ -1,5 +1,22 @@ use skate_data::skate_map::SkateMap; +#[test] +fn presentation_loading_does_not_relax_playable_map_validation() { + let mut data = fixture(1); + let counts = 8 + 4 + 4 + "Fixture".len() + (4 + 12) * 4; + data[counts + 16..counts + 24].fill(0); // No collision or rails. + let needle: Vec = [0u32, 1, 2].into_iter().flat_map(u32::to_le_bytes).collect(); + let indices = data.windows(12).position(|w| w == needle).unwrap(); + data.truncate(indices + 12); + assert!(SkateMap::parse(&data).unwrap_err().contains("requires triangle collision")); + let render = SkateMap::parse_render_only(&data).unwrap(); + assert_eq!(render.geometry.indices, [0, 1, 2]); + assert!(render.geometry.collision.is_empty()); + data[indices..indices+4].copy_from_slice(&99u32.to_le_bytes()); + assert!(SkateMap::parse_render_only(&data).unwrap_err().contains("indices")); + assert!(SkateMap::parse_render_only(&fixture(1)).unwrap_err().contains("non-presentation")); +} + fn u(b: &mut Vec, value: u32) { b.extend(value.to_le_bytes()); } diff --git a/crates/skate-game/src/app.rs b/crates/skate-game/src/app.rs index f2a67ee8..1de89a4e 100644 --- a/crates/skate-game/src/app.rs +++ b/crates/skate-game/src/app.rs @@ -110,6 +110,7 @@ pub(crate) fn build( )); app.add_plugins((crate::session_marker::SessionMarkerPlugin, crate::customiser::CustomiserPlugin)); app.add_plugins(crate::custom_models::CustomModelsPlugin); + crate::teleport_menu::install(&mut app); app.add_plugins(crate::updater::UpdaterPlugin); app.add_plugins(crate::multiplayer::MultiplayerPlugin); app.add_plugins(crate::scoring_hud::ScoringHudPlugin); diff --git a/crates/skate-game/src/config.rs b/crates/skate-game/src/config.rs index 0c8b9de0..d19e5382 100644 --- a/crates/skate-game/src/config.rs +++ b/crates/skate-game/src/config.rs @@ -12,6 +12,7 @@ pub(crate) struct Config { pub start_paused: bool, pub multiplayer: crate::multiplayer::Options, pub map_fingerprint: u64, + pub teleport: Option, } impl Config { @@ -26,6 +27,7 @@ impl Config { start_paused: false, multiplayer: crate::multiplayer::Options::default(), map_fingerprint: 0, + teleport: None, }; let mut difficulty_override = None; let mut explicit_map = false; @@ -67,6 +69,7 @@ impl Config { Some("--test-world") => { explicit_map = true; config.map = None; config.map_path = None; } Some("--check-assets") => config.check_assets = true, Some("--start-paused") => config.start_paused = true, + Some("--teleport") => config.teleport = Some(args.next().ok_or("--teleport requires a destination ID")?.to_string_lossy().into_owned()), Some("--difficulty") => { let value = args.next().ok_or("--difficulty requires easy, normal or hardcore")?; difficulty_override = Some(crate::difficulty::Difficulty::parse(&value.to_string_lossy())?); @@ -101,6 +104,13 @@ impl Config { } config.map_fingerprint = map_fingerprint(config.map_path.as_deref())?; if config.multiplayer.direct.is_some() && config.multiplayer.session==0 {return Err("Direct multiplayer requires --net-session (a nonzero number shared by both players)".into());} + if let Some(id) = &config.teleport { + let destinations = crate::teleport_menu::load(&config.asset_root)?; + let target = destinations.iter().find(|d| &d.id == id).ok_or("Unknown teleport destination")?; + if target.matrix.is_none() || !config.map_path.as_ref().is_some_and(|p| crate::teleport_menu::same_map(p, &target.map)) { + return Err("Teleport destination is unavailable or belongs to a different map".into()); + } + } if let Some(path) = &mut config.verification_capture { if path.extension().and_then(|x| x.to_str()) != Some("png") { return Err("--verify output must be a PNG file".into()); diff --git a/crates/skate-game/src/customiser.rs b/crates/skate-game/src/customiser.rs index 76441500..e7037127 100644 --- a/crates/skate-game/src/customiser.rs +++ b/crates/skate-game/src/customiser.rs @@ -239,7 +239,7 @@ impl Plugin for CustomiserPlugin { ); } } -fn navigation(mut nav: ResMut, time: Res>, keys: Res>) { +pub(crate) fn navigation(mut nav: ResMut, time: Res>, keys: Res>) { let pad = (0..4).find_map(|i| crate::input::platform::poll(i).ok()); // Remap outside the dead zone so a resting stick cannot drift the preview. let axis = pad.as_ref().map_or(0., |p| (p.state.right[0] as f32 / 32767.).clamp(-1., 1.)); diff --git a/crates/skate-game/src/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs index b5c5eb7c..9c53d0fe 100644 --- a/crates/skate-game/src/graphics_menu.rs +++ b/crates/skate-game/src/graphics_menu.rs @@ -218,7 +218,7 @@ fn setup( BackgroundColor(Color::srgb(0.035,0.055,0.08)))).with_children(|panel| { panel.spawn((Text::new("GAME MENU"),TextFont {font_size:32.,..default()},TextColor(Color::WHITE))); panel.spawn((Text::new("GAMEPLAY & GRAPHICS"),TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.4,0.85,0.85)))); - for i in 0..14 { + for i in 0..15 { panel.spawn((Button, MenuRow(i), Node {width:percent(100),min_height:px(32),padding:UiRect::all(px(6)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(5)),..default()}, BackgroundColor(Color::srgb(0.08,0.11,0.15)))).with_children(|row| { row.spawn((MenuLabel(i),Text::new(""),TextFont {font_size:18.,..default()},TextColor(Color::WHITE))); @@ -273,13 +273,14 @@ pub(crate) fn interact( mut net: ResMut, mut typing: MessageReader, mut updater: ResMut, + mut travel: ResMut, ) { if transition.busy() { menu.open = true; time.pause(); return; } - if customiser.open || custom_models.open { return; } + if travel.open || travel.closed_this_frame || customiser.open || custom_models.open { return; } if keys.just_pressed(KeyCode::Escape) || nav.pressed & 0x10 != 0 { menu.open = !menu.open; } @@ -305,7 +306,7 @@ pub(crate) fn interact( } } if menu.open { - let rows = if menu.multiplayer { 11 } else { 14 }; + let rows = if menu.multiplayer { 11 } else { 15 }; if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 { menu.selected = (menu.selected + rows - 1) % rows; } @@ -425,6 +426,7 @@ pub(crate) fn interact( } 12 => custom_models.begin(), 13 => menu.status = updater.open(false), + 14 => travel.open = true, _ => {} } } @@ -509,6 +511,7 @@ fn labels( time: Res>, customiser: Res, custom_models: Res, + travel: Res, net: Res, window: Single<&Window, With>, mut root: Single<&mut Node, With>, @@ -516,7 +519,7 @@ fn labels( mut status: Single<&mut Text, With>, mut buttons: Query<(&MenuRow, &Interaction, &mut BackgroundColor, &mut Node), Without>, ) { - root.display = if menu.open && !customiser.open && !custom_models.open { + root.display = if menu.open && !travel.open && !customiser.open && !custom_models.open { Display::Flex } else { Display::None @@ -610,6 +613,7 @@ fn labels( 10 => "Character customiser".into(), 12 => "Custom models".into(), 13 => "Updates".into(), + 14 => "Teleport…".into(), _ => "Multiplayer".into(), } }; diff --git a/crates/skate-game/src/main.rs b/crates/skate-game/src/main.rs index 61ef3385..91a7e365 100644 --- a/crates/skate-game/src/main.rs +++ b/crates/skate-game/src/main.rs @@ -34,8 +34,11 @@ mod customiser; mod customiser_parts; mod customiser_material; mod custom_models; +mod teleport_menu; mod render_capacity; mod retail_render; +mod retail_character; +mod retail_irradiance; mod presentation; mod replay; mod world; diff --git a/crates/skate-game/src/map_render.rs b/crates/skate-game/src/map_render.rs index d1422c62..84fd2487 100644 --- a/crates/skate-game/src/map_render.rs +++ b/crates/skate-game/src/map_render.rs @@ -18,6 +18,9 @@ pub(crate) struct StagedAssets { values: Vec<(Handle, A)>, } impl StagedAssets { + pub fn iter_mut(&mut self) -> impl Iterator, &mut A)> { + self.values.iter_mut().map(|(h, a)| (h.id(), a)) + } fn new(world: &World) -> Self { Self { provider: world.resource::>().get_handle_provider(), values: Vec::new() } } @@ -92,9 +95,10 @@ impl PreparedScene { }); if let Some(map) = map { crate::skate_world::spawn(map, &mut self.commands, &mut self.meshes, - &mut self.materials, &mut self.retail, &mut self.images); + &mut self.materials, &mut self.retail, &mut self.images, &crate::retail_render::MaterialTuning::load(root)); + crate::retail_render::spawn_backdrop(&map.name, root, &mut self.commands, &mut self.meshes, &mut self.materials, &mut self.retail, &mut self.images); crate::retail_render::spawn_sky(&map.name, root, &mut self.commands, - &mut self.meshes, &mut self.images, &mut self.sky); + &mut self.meshes, &mut self.images, &mut self.sky, &mut self.retail); } else { crate::world::spawn_test_world(&mut self.commands, &mut self.meshes, &mut self.materials); } diff --git a/crates/skate-game/src/physics/skater.rs b/crates/skate-game/src/physics/skater.rs index 5073251c..0d5c6129 100644 --- a/crates/skate-game/src/physics/skater.rs +++ b/crates/skate-game/src/physics/skater.rs @@ -87,6 +87,11 @@ pub(crate) struct SkaterRuntime { } impl SkaterRuntime { + pub(crate) fn travel_to(&mut self, transform: [[f32; 4]; 4]) -> Result<(), String> { + self.player_input.request_teleport(transform)?; + self.teleport_state.request_manual(transform, true); + Ok(()) + } pub fn load( asset_root: &Path, graphs: &StockGraphs, diff --git a/crates/skate-game/src/physics/teleport_state.rs b/crates/skate-game/src/physics/teleport_state.rs index da0f2ef0..72da143f 100644 --- a/crates/skate-game/src/physics/teleport_state.rs +++ b/crates/skate-game/src/physics/teleport_state.rs @@ -32,6 +32,10 @@ impl Runtime { self.manual_on_board = Some(on_board); } pub fn take_manual_on_board(&mut self) -> Option { self.manual_on_board.take() } + pub(super) fn set_checkpoint(&mut self, checkpoint: Checkpoint) { + self.checkpoint = checkpoint; + self.pending_reply = None; + } pub fn new(checkpoint: Checkpoint) -> Self { Self { state: TeleportState::default(), diff --git a/crates/skate-game/src/retail_backdrop.rs b/crates/skate-game/src/retail_backdrop.rs new file mode 100644 index 00000000..a0dfe1d2 --- /dev/null +++ b/crates/skate-game/src/retail_backdrop.rs @@ -0,0 +1,51 @@ +//! Authored presentation supplements outside the static district geometry. +use bevy::prelude::*; +use skate_data::skate_map::SkateMap; +use std::path::Path; + +pub(crate) fn spawn_backdrop( + name: &str, + asset_root: &Path, + commands: &mut crate::map_render::SceneCommands, + meshes: &mut impl crate::map_render::AssetSink, + materials: &mut impl crate::map_render::AssetSink, + retail_materials: &mut impl crate::map_render::AssetSink, + images: &mut impl crate::map_render::AssetSink, +) { + for folder in ["native-backdrops", "native-props"] { + spawn_package(name, asset_root, folder, commands, meshes, materials, retail_materials, images); + } +} + +fn spawn_package( + name: &str, + asset_root: &Path, + folder: &str, + commands: &mut crate::map_render::SceneCommands, + meshes: &mut impl crate::map_render::AssetSink, + materials: &mut impl crate::map_render::AssetSink, + retail_materials: &mut impl crate::map_render::AssetSink, + images: &mut impl crate::map_render::AssetSink, +) { + let path = asset_root.join("private").join(folder).join(format!("{name}.skate")); + if !path.is_file() { + return; + } + let map = match std::fs::read(&path).map_err(|e| e.to_string()) + .and_then(|data| SkateMap::parse_render_only(&data)) { + Ok(map) => map, + Err(error) => { + error!("SKATE_BACKDROP: {}: {error}", path.display()); + return; + } + }; + // This package contributes presentation only; never route it into physics. + if map.name != name || !map.geometry.collision.is_empty() + || !map.lights.is_empty() || !map.doors.is_empty() || !map.rails.is_empty() + { + error!("SKATE_BACKDROP: invalid render-only package {}", path.display()); + return; + } + info!("SKATE_BACKDROP: {name} {folder} triangles={}", map.geometry.indices.len() / 3); + crate::skate_world::spawn(&map, commands, meshes, materials, retail_materials, images, &super::MaterialTuning::load(asset_root)); +} diff --git a/crates/skate-game/src/retail_character.rs b/crates/skate-game/src/retail_character.rs new file mode 100644 index 00000000..98cd43c4 --- /dev/null +++ b/crates/skate-game/src/retail_character.rs @@ -0,0 +1,339 @@ +//! Character SH/key/rim shader, independent of the world's additive lights. +use crate::retail_irradiance::Irradiance; +use bevy::{ + asset::embedded_asset, + camera::visibility::RenderLayers, + gltf::GltfMaterialName, + prelude::*, + render::render_resource::{AsBindGroup, ShaderType}, + shader::ShaderRef, +}; +use serde::Deserialize; +use std::collections::HashMap; + +pub(crate) struct CharacterLightingPlugin; +impl Plugin for CharacterLightingPlugin { + fn build(&self, app: &mut App) { + embedded_asset!(app, "retail_character.wgsl"); + embedded_asset!(app, "retail_character_depth.wgsl"); + app.add_plugins(MaterialPlugin::::default()) + .add_systems(Startup, load) + .add_systems(PreUpdate, load.after(crate::map_transition::MapTransitionSet).run_if(crate::retail_render::world_changed)) + .add_systems(Update, (bind, shadow_views, update).chain()); + } +} +#[derive(Deserialize)] +struct MaterialData { + shader: String, + params: Vec<[f32; 4]>, + specular: Option, +} +#[derive(Deserialize)] +struct LightingData { + materials: HashMap, + default_sh: [[f32; 3]; 9], +} +#[derive(Resource)] +struct Lighting { + data: LightingData, + probes: Irradiance, + light: Vec4, + display_sh: Option<[Vec4; 9]>, +} +#[derive(Clone, ShaderType)] +struct CharacterParams { + light: Vec4, + tint: Vec4, + // normal map present, dedicated specular mask, alpha cutoff, hair family + options: Vec4, + rows: [Vec4; 9], + sh: [Vec4; 9], +} +#[derive(Asset, TypePath, AsBindGroup, Clone)] +struct CharacterMaterial { + #[uniform(0)] + params: CharacterParams, + #[texture(1)] + #[sampler(2)] + diffuse: Option>, + #[texture(3)] + #[sampler(4)] + normal: Option>, + #[texture(5)] + #[sampler(6)] + mask: Option>, + alpha: AlphaMode, +} +impl Material for CharacterMaterial { + fn fragment_shader() -> ShaderRef { + bevy::asset::AssetPath::from(bevy::asset::embedded_path!("retail_character.wgsl")).with_source("embedded").into() + } + fn prepass_fragment_shader() -> ShaderRef { + bevy::asset::AssetPath::from(bevy::asset::embedded_path!("retail_character_depth.wgsl")).with_source("embedded").into() + } + fn alpha_mode(&self) -> AlphaMode { + self.alpha + } +} +#[derive(Component)] +struct ShadowSource; +fn load(mut commands: Commands, config: Res, sources: Query>, mut shadow: ResMut) { + commands.remove_resource::(); + for e in &sources { commands.entity(e).despawn(); } + *shadow = Default::default(); + let Some(map_path) = &config.map_path else { + return; + }; + let path = config.asset_root.join("private/character-lighting.json"); + let data = match std::fs::read(&path) + .map_err(|e| e.to_string()) + .and_then(|b| serde_json::from_slice::(&b).map_err(|e| e.to_string())) + { + Ok(data) => data, + Err(e) => { + warn!("SKATE_CHARACTER_LIGHTING: {e}"); + return; + } + }; + let name = map_path.file_stem().unwrap_or_default().to_string_lossy(); + let overlay = config + .asset_root + .join("private/native-lighting") + .join(format!("{name}.irradiance")); + let probe_path = if overlay.is_file() { + overlay + } else { + map_path.with_extension("irradiance") + }; + let probes = match std::fs::read(probe_path) + .map_err(|e| e.to_string()) + .and_then(|b| Irradiance::parse(&b)) + { + Ok(probes) => probes, + Err(e) => { + warn!("SKATE_CHARACTER_LIGHTING: spatial data unavailable: {e}"); + return; + } + }; + let sky_path = config + .asset_root + .join("private/native-skies") + .join(format!("{name}.json")); + let sky: serde_json::Value = match std::fs::read(sky_path) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + { + Some(sky) => sky, + None => { + warn!("SKATE_CHARACTER_LIGHTING: missing authored light direction"); + return; + } + }; + let Some(sun) = sky["environment"]["sun_direction"].as_array() else { + return; + }; + if sun.len() != 3 { + return; + } + let mut light = Vec4::new(0., 0., 0., 2.5); // Existing retail scene exposure. + for i in 0..3 { + let Some(v) = sun[i].as_f64() else { + return; + }; + light[i] = v as f32; + } + info!("SKATE_CHARACTER_LIGHTING: loaded authored {name} irradiance and character parameters"); + spawn_shadow_sources(&mut commands, light.truncate()); + commands.insert_resource(Lighting { + data, + probes, + light, + display_sh: None, + }); +} +fn spawn_shadow_sources(commands: &mut Commands, light: Vec3) { + // World + skater casters, sampled only by the character shader. + commands.spawn(( + ShadowSource, + Name::new("Character shadow visibility"), + DirectionalLight { + illuminance: 0., + shadows_enabled: true, + affects_lightmapped_mesh_diffuse: false, + ..default() + }, + Transform::default().looking_to(-light, Vec3::Y), + bevy::light::CascadeShadowConfigBuilder { + maximum_distance: 100., + first_cascade_far_bound: 10., + ..default() + } + .build(), + )); + // Only skinned player pieces inhabit layer 28. The world receiver samples + // this separate map so baked building/terrain shadows are not re-applied. + commands.spawn(( + ShadowSource, + Name::new("Player shadow onto baked world"), + DirectionalLight { + illuminance: 0., + shadows_enabled: true, + affects_lightmapped_mesh_diffuse: true, + // Receiver-only map: no self-shadow acne to hide with large bias. + shadow_depth_bias: 0.002, + shadow_normal_bias: 0.0, + ..default() + }, + RenderLayers::layer(28), + Transform::default().looking_to(-light, Vec3::Y), + bevy::light::CascadeShadowConfigBuilder { + num_cascades: 1, + maximum_distance: 24., + ..default() + } + .build(), + )); +} + +fn bind( + mut commands: Commands, + lighting: Option>, + source: Res>, + server: Res, + mut materials: ResMut>, + entities: Query<(Entity, &GltfMaterialName, &MeshMaterial3d)>, +) { + let Some(lighting) = lighting else { + return; + }; + for (entity, name, handle) in &entities { + let Some(data) = lighting.data.materials.get(&name.0) else { + continue; + }; + if data.params.len() != 9 { + continue; + } + let Some(m) = source.get(&handle.0) else { + continue; + }; + let alpha_cutoff = if let AlphaMode::Mask(cutoff) = m.alpha_mode { + cutoff + } else { + -1. + }; + let material = materials.add(CharacterMaterial { + params: CharacterParams { + light: lighting.light, + tint: Vec4::from_array(m.base_color.to_linear().to_f32_array()), + options: Vec4::new( + f32::from(m.normal_map_texture.is_some()), + f32::from(data.specular.is_some()), + alpha_cutoff, + f32::from(data.shader == "character.hair"), + ), + rows: std::array::from_fn(|i| Vec4::from_array(data.params[i])), + sh: lighting + .data + .default_sh + .map(|v| Vec3::from_array(v).extend(0.)), + }, + diffuse: m.base_color_texture.clone(), + normal: m.normal_map_texture.clone(), + mask: data.specular.as_ref().map(|path| { + server.load_with_settings( + path.clone(), + |settings: &mut bevy::image::ImageLoaderSettings| { + settings.is_srgb = false; + }, + ) + }), + alpha: m.alpha_mode, + }); + commands + .entity(entity) + .remove::>() + .insert(( + MeshMaterial3d(material), + RenderLayers::from_layers(&[0, 28]), + )); + } +} +fn shadow_views( + mut commands: Commands, + lighting: Option>, + cameras: Query<(Entity, Option<&RenderLayers>), With>, +) { + if lighting.is_none() { + return; + } + for (entity, layers) in &cameras { + let layers = layers.cloned().unwrap_or_default(); + if !layers.intersects(&RenderLayers::layer(28)) { + commands.entity(entity).insert(layers.with(28)); + } + } +} +fn update( + lighting: Option>, + root: Query<&Transform, With>, + mut materials: ResMut>, + mut shadow: ResMut, + time: Res