diff options
| -rw-r--r-- | apps/fparkan-game/src/main.rs | 68 | ||||
| -rw-r--r-- | crates/fparkan-terrain/src/lib.rs | 16 | ||||
| -rw-r--r-- | docs/appendices/knowledge-boundaries.md | 4 | ||||
| -rw-r--r-- | docs/reference/render-frame.md | 15 |
4 files changed, 99 insertions, 4 deletions
diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 633b6fe..02d9a1b 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -332,6 +332,11 @@ impl FreeFlightCamera { if !self.ground_clearance.is_finite() || self.ground_clearance < 0.0 { return Err("free-flight ground clearance must be finite and nonnegative".to_string()); } + if let Some([min, max]) = terrain.surface_bounds() { + for axis in 0..2 { + self.position[axis] = self.position[axis].clamp(min[axis], max[axis]); + } + } let Some(ground_height) = terrain .height_at([self.position[0], self.position[1]]) .map_err(|error| format!("free-flight terrain height query: {error}"))? @@ -9021,15 +9026,27 @@ mod tests { assert_eq!(camera.position, [0.75, 0.25, 36.5]); let mut outside_camera = FreeFlightCamera { - position: [2.0, 2.0, -17.0], + position: [2.0, -2.0, -17.0], ground_clearance: 2.0, ..camera }; - let unchanged = outside_camera.position; outside_camera .keep_above_terrain(&terrain) - .expect("missing terrain sample leaves the camera alone"); - assert_eq!(outside_camera.position, unchanged); + .expect("clamp to the map before sampling the floor"); + assert_eq!(outside_camera.position, [1.0, 0.0, 36.0]); + + outside_camera.position = [-2.0, 2.0, 100.0]; + outside_camera + .keep_above_terrain(&terrain) + .expect("clamp the other map edges without limiting altitude"); + assert_eq!(outside_camera.position, [0.0, 1.0, 100.0]); + + outside_camera.move_speed = 40.0; + outside_camera.advance(&HashSet::from([KeyCode::KeyW, KeyCode::ShiftLeft]), 0.1); + outside_camera + .keep_above_terrain(&terrain) + .expect("accelerated movement still stops at the map edge"); + assert_eq!(outside_camera.position, [1.0, 1.0, 100.0]); let mut unsupported_camera = FreeFlightCamera { position: [0.25, 0.25, 35.5], @@ -9043,6 +9060,49 @@ mod tests { } #[test] + #[ignore = "requires the original GOG installation"] + fn free_flight_camera_stops_at_original_map_edges_under_accelerated_movement() { + let root = std::env::var_os("FPARKAN_CORPUS_PART1_ROOT") + .expect("set FPARKAN_CORPUS_PART1_ROOT to the original GOG installation"); + let mut engine = + create(EngineServices::new(Arc::new(DirectoryVfs::new(root)))).expect("engine"); + load_mission_static_preview( + &mut engine, + MissionRequest { + key: "MISSIONS/Autodemo.00/data.tma".to_string(), + mode: MissionMode::SingleMission, + }, + ) + .expect("original mission"); + let terrain = loaded_terrain(&engine).expect("original terrain"); + let [min, max] = terrain.surface_bounds().expect("original map bounds"); + let keys = HashSet::from([KeyCode::KeyW, KeyCode::ShiftLeft]); + for (axis, yaw, edge) in [ + (0, 0.0, max[0]), + (0, std::f32::consts::PI, min[0]), + (1, std::f32::consts::FRAC_PI_2, max[1]), + (1, -std::f32::consts::FRAC_PI_2, min[1]), + ] { + let mut camera = FreeFlightCamera::from_bounds(min, max).expect("camera"); + camera.position = [ + (min[0] + max[0]) * 0.5, + (min[1] + max[1]) * 0.5, + max[2] + 10.0, + ]; + camera.yaw = yaw; + camera.pitch = 0.0; + for _ in 0..512 { + camera.advance(&keys, 0.1); + camera.keep_above_terrain(terrain).expect("map constraint"); + for axis in 0..2 { + assert!((min[axis]..=max[axis]).contains(&camera.position[axis])); + } + } + assert_eq!(camera.position[axis], edge); + } + } + + #[test] fn world_sort_key_uses_the_first_indexed_vertex() { let mesh = VulkanStaticMesh::smoke_triangle(); let distance = first_indexed_vertex_distance(&mesh, 0, [0.0, -0.55, -3.0]) diff --git a/crates/fparkan-terrain/src/lib.rs b/crates/fparkan-terrain/src/lib.rs index 54946e7..5e17a23 100644 --- a/crates/fparkan-terrain/src/lib.rs +++ b/crates/fparkan-terrain/src/lib.rs @@ -409,6 +409,13 @@ impl TerrainWorld { self.surfaces.len() } + /// Bounds of the indexed surface geometry, excluding unused source vertices. + #[must_use] + pub fn surface_bounds(&self) -> Option<[[f32; 3]; 2]> { + let root = self.surface_bvh.nodes.get(self.surface_bvh.root?)?; + Some([root.min, root.max]) + } + /// Returns the validated source mesh retained for renderer integration. /// /// Runtime collision queries use their own compact triangle representation; @@ -1215,6 +1222,15 @@ mod tests { } #[test] + fn surface_bounds_cover_query_geometry_and_ignore_unused_positions() { + let mut mesh = synthetic_land_mesh(); + mesh.positions.push([1000.0, -1000.0, 1000.0]); + let world = TerrainWorld::from_land_msh(&mesh).expect("world"); + assert_eq!(world.surface_bounds(), Some([[0.0; 3], [1.0, 1.0, 2.0]])); + assert_eq!(TerrainWorld::default().surface_bounds(), None); + } + + #[test] fn synthetic_surface_height_and_raycast_work() { let world = TerrainWorld::from_land_msh(&synthetic_land_mesh()).expect("world"); diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index c58462b..a31b561 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -10,6 +10,10 @@ authoring API без динамического подтверждения. Не доказаны символами точные имена renderer vtable slots, полный набор CShade state transitions и окончательный порядок части transparent/FX/shadow subpasses. +Свободная камера превью удерживается в XY-границах поверхностей карты и над +рельефом. Она пока не проверяет стены и проёмы строений; эти ограничения не +подтверждают поведение стратегической камеры или камеры робота оригинала. + Закрывающий эксперимент: запустить оригинал в совместимой Windows/DirectX среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state log на минимальных сценах с одним типом материала. diff --git a/docs/reference/render-frame.md b/docs/reference/render-frame.md index 3085731..8cba630 100644 --- a/docs/reference/render-frame.md +++ b/docs/reference/render-frame.md @@ -181,6 +181,21 @@ fn vertical_fov(self, aspect: f32) -> f32 { (`forward · direction <= 1e-4`). Лучи в передней полусфере могут проецироваться за край viewport; владелец спрайта отдельно решает, нужно ли его рисовать. +После движения свободная камера ограничивает X и Y прямоугольником +поверхностей карты, затем поднимается над найденным рельефом на заданный +зазор. Границы берутся из корня пространственного индекса `TerrainWorld`, +поэтому неиспользуемые вершины файла их не расширяют и повторного обхода +геометрии в каждом кадре нет: + +```rust +position[axis] = position[axis].clamp(min[axis], max[axis]); // axis = 0, 1 +position[2] = position[2].max(ground_height + ground_clearance); +``` + +Высота полёта сверху не ограничена. Это ограничение диагностической камеры +превью; столкновения со стенами зданий и точные правила игровых камер оригинала +остаются отдельными задачами. + ## Ограничения точного совпадения `VulkanStaticCamera::from_legacy_d3d7` выполняет восстановление вида и проекции |
