feat(runtime): add terrain-snapped reference movement
This commit is contained in:
Generated
+1
@@ -607,6 +607,7 @@ dependencies = [
|
|||||||
"fparkan-prototype",
|
"fparkan-prototype",
|
||||||
"fparkan-render",
|
"fparkan-render",
|
||||||
"fparkan-resource",
|
"fparkan-resource",
|
||||||
|
"fparkan-terrain",
|
||||||
"fparkan-vfs",
|
"fparkan-vfs",
|
||||||
"fparkan-world",
|
"fparkan-world",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
|
|||||||
fparkan-platform = { path = "../fparkan-platform", version = "0.1.0" }
|
fparkan-platform = { path = "../fparkan-platform", version = "0.1.0" }
|
||||||
fparkan-prototype = { path = "../fparkan-prototype", version = "0.1.0" }
|
fparkan-prototype = { path = "../fparkan-prototype", version = "0.1.0" }
|
||||||
fparkan-render = { path = "../fparkan-render", version = "0.1.0" }
|
fparkan-render = { path = "../fparkan-render", version = "0.1.0" }
|
||||||
|
fparkan-terrain = { path = "../fparkan-terrain", version = "0.1.0" }
|
||||||
fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
|
fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
|
||||||
fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
|
fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
|
||||||
fparkan-world = { path = "../fparkan-world", version = "0.1.0" }
|
fparkan-world = { path = "../fparkan-world", version = "0.1.0" }
|
||||||
|
|||||||
@@ -33,10 +33,12 @@ use fparkan_prototype::{
|
|||||||
build_prototype_graph_report, PrototypeGraph, PrototypeGraphFailure, PrototypeGraphReport,
|
build_prototype_graph_report, PrototypeGraph, PrototypeGraphFailure, PrototypeGraphReport,
|
||||||
};
|
};
|
||||||
use fparkan_resource::{resource_name, CachedResourceRepository, ResourceRepository};
|
use fparkan_resource::{resource_name, CachedResourceRepository, ResourceRepository};
|
||||||
|
use fparkan_terrain::SurfaceQuery;
|
||||||
use fparkan_vfs::{Vfs, VfsError};
|
use fparkan_vfs::{Vfs, VfsError};
|
||||||
use fparkan_world::{
|
use fparkan_world::{
|
||||||
construct_object, new as new_world, register_object, set_transform, step, InputSnapshot,
|
construct_object, handle_by_original_id, new as new_world, register_object, set_transform,
|
||||||
ObjectDraft, OriginalObjectId, TransformState, World, WorldConfig, WorldSnapshot,
|
step, transform_state, InputSnapshot, ObjectDraft, OriginalObjectId, TransformState, World,
|
||||||
|
WorldConfig, WorldSnapshot,
|
||||||
};
|
};
|
||||||
use std::num::NonZeroUsize;
|
use std::num::NonZeroUsize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -412,6 +414,8 @@ pub enum EngineError {
|
|||||||
},
|
},
|
||||||
/// World error.
|
/// World error.
|
||||||
World(fparkan_world::WorldError),
|
World(fparkan_world::WorldError),
|
||||||
|
/// Reference movement input or terrain query failed.
|
||||||
|
Movement(String),
|
||||||
/// Scheduler phase order was violated.
|
/// Scheduler phase order was violated.
|
||||||
SchedulerPhaseOrder {
|
SchedulerPhaseOrder {
|
||||||
/// Previous phase.
|
/// Previous phase.
|
||||||
@@ -430,6 +434,65 @@ pub enum EngineError {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Advances one object toward an explicit XY target and snaps it to terrain.
|
||||||
|
///
|
||||||
|
/// This is a deterministic reference controller, not recovered original AI.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error when the input is invalid, the mission/object is absent,
|
||||||
|
/// the source transform is non-finite, or the requested point has no terrain
|
||||||
|
/// surface.
|
||||||
|
pub fn advance_reference_movement(
|
||||||
|
engine: &mut Engine,
|
||||||
|
original_id: OriginalObjectId,
|
||||||
|
target_xy: [f32; 2],
|
||||||
|
max_step: f32,
|
||||||
|
) -> Result<bool, EngineError> {
|
||||||
|
if !target_xy
|
||||||
|
.iter()
|
||||||
|
.chain(std::iter::once(&max_step))
|
||||||
|
.all(|v| v.is_finite())
|
||||||
|
|| max_step <= 0.0
|
||||||
|
{
|
||||||
|
return Err(EngineError::Movement(
|
||||||
|
"target and max_step must be finite; max_step must be positive".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let terrain = engine
|
||||||
|
.loaded
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| EngineError::Movement("mission terrain is unavailable".to_string()))?
|
||||||
|
.terrain
|
||||||
|
.clone();
|
||||||
|
let handle = handle_by_original_id(&engine.world, original_id).ok_or_else(|| {
|
||||||
|
EngineError::Movement(format!("original object {} is unavailable", original_id.0))
|
||||||
|
})?;
|
||||||
|
let mut transform = transform_state(&engine.world, handle)?;
|
||||||
|
let position = transform.position.map(f32::from_bits);
|
||||||
|
if !position.iter().all(|v| v.is_finite()) {
|
||||||
|
return Err(EngineError::Movement(
|
||||||
|
"object transform is non-finite".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let dx = target_xy[0] - position[0];
|
||||||
|
let dy = target_xy[1] - position[1];
|
||||||
|
let distance = dx.hypot(dy);
|
||||||
|
let reached = distance <= max_step;
|
||||||
|
let ratio = if reached { 1.0 } else { max_step / distance };
|
||||||
|
let x = position[0] + dx * ratio;
|
||||||
|
let y = position[1] + dy * ratio;
|
||||||
|
let z = terrain
|
||||||
|
.height_at([x, y])
|
||||||
|
.map_err(|err| EngineError::Movement(err.to_string()))?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
EngineError::Movement("movement target lies outside terrain surface".to_string())
|
||||||
|
})?;
|
||||||
|
transform.position = [x.to_bits(), y.to_bits(), z.to_bits()];
|
||||||
|
set_transform(&mut engine.world, handle, transform)?;
|
||||||
|
Ok(reached)
|
||||||
|
}
|
||||||
|
|
||||||
impl From<fparkan_world::WorldError> for EngineError {
|
impl From<fparkan_world::WorldError> for EngineError {
|
||||||
fn from(value: fparkan_world::WorldError) -> Self {
|
fn from(value: fparkan_world::WorldError) -> Self {
|
||||||
Self::World(value)
|
Self::World(value)
|
||||||
@@ -459,6 +522,7 @@ impl std::fmt::Display for EngineError {
|
|||||||
write!(f, "{mission}: asset preparation failed: {source}")
|
write!(f, "{mission}: asset preparation failed: {source}")
|
||||||
}
|
}
|
||||||
Self::World(source) => write!(f, "{source}"),
|
Self::World(source) => write!(f, "{source}"),
|
||||||
|
Self::Movement(message) => write!(f, "reference movement: {message}"),
|
||||||
Self::SchedulerPhaseOrder { previous, current } => write!(
|
Self::SchedulerPhaseOrder { previous, current } => write!(
|
||||||
f,
|
f,
|
||||||
"scheduler phase order regressed from {previous:?} to {current:?}"
|
"scheduler phase order regressed from {previous:?} to {current:?}"
|
||||||
@@ -487,6 +551,7 @@ impl std::error::Error for EngineError {
|
|||||||
Self::World(source) => Some(source),
|
Self::World(source) => Some(source),
|
||||||
Self::AssetPreparation { source, .. } => Some(source),
|
Self::AssetPreparation { source, .. } => Some(source),
|
||||||
Self::MissingVfs
|
Self::MissingVfs
|
||||||
|
| Self::Movement(_)
|
||||||
| Self::PrototypeGraph { .. }
|
| Self::PrototypeGraph { .. }
|
||||||
| Self::SchedulerPhaseOrder { .. }
|
| Self::SchedulerPhaseOrder { .. }
|
||||||
| Self::RegistrationTeardown { .. } => None,
|
| Self::RegistrationTeardown { .. } => None,
|
||||||
@@ -1270,6 +1335,30 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reference_movement_rejects_non_finite_or_non_positive_input() {
|
||||||
|
let mut engine = create(
|
||||||
|
EngineConfig {
|
||||||
|
mode: EngineMode::Headless,
|
||||||
|
},
|
||||||
|
EngineServices::default(),
|
||||||
|
)
|
||||||
|
.expect("engine");
|
||||||
|
|
||||||
|
let err =
|
||||||
|
advance_reference_movement(&mut engine, OriginalObjectId(0), [f32::NAN, 0.0], 1.0)
|
||||||
|
.expect_err("non-finite target must fail");
|
||||||
|
assert_eq!(
|
||||||
|
err.to_string(),
|
||||||
|
"reference movement: target and max_step must be finite; max_step must be positive"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
advance_reference_movement(&mut engine, OriginalObjectId(0), [0.0, 0.0], 0.0),
|
||||||
|
Err(EngineError::Movement(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore = "requires licensed corpus"]
|
#[ignore = "requires licensed corpus"]
|
||||||
fn load_trace_records_preparation_before_registration_and_raw_transforms() {
|
fn load_trace_records_preparation_before_registration_and_raw_transforms() {
|
||||||
@@ -1313,6 +1402,69 @@ mod tests {
|
|||||||
.all(|component| component.is_finite())));
|
.all(|component| component.is_finite())));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
|
fn reference_movement_snaps_a_live_mission_object_to_terrain() {
|
||||||
|
let root = licensed_root("IS");
|
||||||
|
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
|
||||||
|
let mut engine = create(
|
||||||
|
EngineConfig {
|
||||||
|
mode: EngineMode::Headless,
|
||||||
|
},
|
||||||
|
EngineServices::new(vfs),
|
||||||
|
)
|
||||||
|
.expect("engine");
|
||||||
|
load_mission(
|
||||||
|
&mut engine,
|
||||||
|
MissionRequest {
|
||||||
|
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("load mission");
|
||||||
|
|
||||||
|
let (original_id, start_xy, target_xy, expected_xy, expected_height) = {
|
||||||
|
let loaded = engine.loaded.as_ref().expect("loaded mission");
|
||||||
|
loaded
|
||||||
|
.object_drafts
|
||||||
|
.iter()
|
||||||
|
.find_map(|draft| {
|
||||||
|
let original_id = draft.original_id?;
|
||||||
|
let start_xy = [draft.position[0], draft.position[1]];
|
||||||
|
[[1.0, 0.0], [-1.0, 0.0], [0.0, 1.0], [0.0, -1.0]]
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|offset| {
|
||||||
|
let target_xy = [start_xy[0] + offset[0], start_xy[1] + offset[1]];
|
||||||
|
let expected_xy =
|
||||||
|
[start_xy[0] + offset[0] * 0.5, start_xy[1] + offset[1] * 0.5];
|
||||||
|
let height = loaded.terrain.height_at(expected_xy).ok()??;
|
||||||
|
loaded
|
||||||
|
.terrain
|
||||||
|
.height_at(target_xy)
|
||||||
|
.ok()??
|
||||||
|
.is_finite()
|
||||||
|
.then_some((original_id, start_xy, target_xy, expected_xy, height))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.expect("mission object with a movable terrain-neighbour target")
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!advance_reference_movement(&mut engine, original_id, target_xy, 0.5)
|
||||||
|
.expect("bounded reference movement")
|
||||||
|
);
|
||||||
|
let handle = handle_by_original_id(&engine.world, original_id).expect("world object");
|
||||||
|
let transform = transform_state(&engine.world, handle).expect("world transform");
|
||||||
|
assert_eq!(
|
||||||
|
transform.position,
|
||||||
|
[
|
||||||
|
expected_xy[0].to_bits(),
|
||||||
|
expected_xy[1].to_bits(),
|
||||||
|
expected_height.to_bits()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_ne!(start_xy, expected_xy);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore = "requires licensed corpus"]
|
#[ignore = "requires licensed corpus"]
|
||||||
fn missing_map_and_missing_reachable_resource_fail_before_registration() {
|
fn missing_map_and_missing_reachable_resource_fail_before_registration() {
|
||||||
|
|||||||
@@ -376,6 +376,20 @@ pub fn transform_state(world: &World, handle: ObjectHandle) -> Result<TransformS
|
|||||||
Ok(checked_slot(world, handle)?.transform)
|
Ok(checked_slot(world, handle)?.transform)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the live registered handle carrying an original mission id.
|
||||||
|
#[must_use]
|
||||||
|
pub fn handle_by_original_id(world: &World, original_id: OriginalObjectId) -> Option<ObjectHandle> {
|
||||||
|
world.slots.iter().enumerate().find_map(|(index, slot)| {
|
||||||
|
let index = u32::try_from(index).ok()?;
|
||||||
|
(slot.live && slot.registered && slot.original_id == Some(original_id)).then_some(
|
||||||
|
ObjectHandle {
|
||||||
|
generation: slot.generation,
|
||||||
|
slot: index,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Requests deletion.
|
/// Requests deletion.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@@ -814,10 +828,16 @@ mod tests {
|
|||||||
.expect("second");
|
.expect("second");
|
||||||
register_object(&mut world, first).expect("register first");
|
register_object(&mut world, first).expect("register first");
|
||||||
register_object(&mut world, second).expect("register second");
|
register_object(&mut world, second).expect("register second");
|
||||||
|
assert_eq!(
|
||||||
|
handle_by_original_id(&world, OriginalObjectId(7)),
|
||||||
|
Some(first)
|
||||||
|
);
|
||||||
|
assert_eq!(handle_by_original_id(&world, OriginalObjectId(9)), None);
|
||||||
assert_eq!(registration_sequence(&world, first), Ok(Some(0)));
|
assert_eq!(registration_sequence(&world, first), Ok(Some(0)));
|
||||||
assert_eq!(registration_sequence(&world, second), Ok(Some(1)));
|
assert_eq!(registration_sequence(&world, second), Ok(Some(1)));
|
||||||
|
|
||||||
request_delete(&mut world, first).expect("delete");
|
request_delete(&mut world, first).expect("delete");
|
||||||
|
assert_eq!(handle_by_original_id(&world, OriginalObjectId(7)), None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
register_object(&mut world, first),
|
register_object(&mut world, first),
|
||||||
Err(WorldError::StaleHandle)
|
Err(WorldError::StaleHandle)
|
||||||
|
|||||||
@@ -290,6 +290,30 @@ Control получает world-interface Terrain и использует пов
|
|||||||
последовательности, а интеграция использует одну политику `dt` и округления.
|
последовательности, а интеграция использует одну политику `dt` и округления.
|
||||||
Иначе одинаковая миссия постепенно расходится даже без сети.
|
Иначе одинаковая миссия постепенно расходится даже без сети.
|
||||||
|
|
||||||
|
#### Reference controller в текущем runtime
|
||||||
|
|
||||||
|
`fparkan-runtime::advance_reference_movement` — намеренно маленький
|
||||||
|
детерминированный мост между сохранённым mission transform и `TerrainWorld`.
|
||||||
|
Он получает `OriginalObjectId`, явную XY-цель и положительный максимум шага,
|
||||||
|
находит только live/registered object по исходному ID, двигает XY не более чем
|
||||||
|
на этот шаг и записывает высоту из `TerrainWorld::height_at`. Orientation и
|
||||||
|
scale остаются исходными IEEE-754 words; функция не изменяет clock, очередь
|
||||||
|
World3D или animation state. Возвращаемое значение означает достижение именно
|
||||||
|
заданной XY-цели.
|
||||||
|
|
||||||
|
Это **не** восстановленный Control, Behavior или navigation controller:
|
||||||
|
функция не строит маршрут, не использует `dt`, скорость, terrain normal,
|
||||||
|
коллизии, acceleration и оригинальные AI decisions. Она существует как
|
||||||
|
проверяемый reference path, который фиксирует границу будущих controller-ов и
|
||||||
|
не позволяет renderer-у или gameplay обходить terrain query. Non-finite input,
|
||||||
|
неположительный шаг, отсутствующая миссия/объект и XY вне поверхности дают
|
||||||
|
явную ошибку без частичного изменения transform.
|
||||||
|
|
||||||
|
Licensed test на GOG `Autodemo.00` запускает этот путь для live mission object,
|
||||||
|
находит существующую поверхность и проверяет точные XY/Z words после snap. Это
|
||||||
|
доказывает связывание current runtime data, но не доказывает семантику
|
||||||
|
оригинального движения.
|
||||||
|
|
||||||
### Различия Control в Части 2
|
### Различия Control в Части 2
|
||||||
|
|
||||||
`Control.dll` пересобрана при неизменных размере, imports и пяти именах/ordinals
|
`Control.dll` пересобрана при неизменных размере, imports и пяти именах/ordinals
|
||||||
|
|||||||
Reference in New Issue
Block a user