feat(runtime): add terrain-snapped reference movement
This commit is contained in:
Generated
+1
@@ -607,6 +607,7 @@ dependencies = [
|
||||
"fparkan-prototype",
|
||||
"fparkan-render",
|
||||
"fparkan-resource",
|
||||
"fparkan-terrain",
|
||||
"fparkan-vfs",
|
||||
"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-prototype = { path = "../fparkan-prototype", 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-vfs = { path = "../fparkan-vfs", 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,
|
||||
};
|
||||
use fparkan_resource::{resource_name, CachedResourceRepository, ResourceRepository};
|
||||
use fparkan_terrain::SurfaceQuery;
|
||||
use fparkan_vfs::{Vfs, VfsError};
|
||||
use fparkan_world::{
|
||||
construct_object, new as new_world, register_object, set_transform, step, InputSnapshot,
|
||||
ObjectDraft, OriginalObjectId, TransformState, World, WorldConfig, WorldSnapshot,
|
||||
construct_object, handle_by_original_id, new as new_world, register_object, set_transform,
|
||||
step, transform_state, InputSnapshot, ObjectDraft, OriginalObjectId, TransformState, World,
|
||||
WorldConfig, WorldSnapshot,
|
||||
};
|
||||
use std::num::NonZeroUsize;
|
||||
use std::sync::Arc;
|
||||
@@ -412,6 +414,8 @@ pub enum EngineError {
|
||||
},
|
||||
/// World error.
|
||||
World(fparkan_world::WorldError),
|
||||
/// Reference movement input or terrain query failed.
|
||||
Movement(String),
|
||||
/// Scheduler phase order was violated.
|
||||
SchedulerPhaseOrder {
|
||||
/// 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 {
|
||||
fn from(value: fparkan_world::WorldError) -> Self {
|
||||
Self::World(value)
|
||||
@@ -459,6 +522,7 @@ impl std::fmt::Display for EngineError {
|
||||
write!(f, "{mission}: asset preparation failed: {source}")
|
||||
}
|
||||
Self::World(source) => write!(f, "{source}"),
|
||||
Self::Movement(message) => write!(f, "reference movement: {message}"),
|
||||
Self::SchedulerPhaseOrder { previous, current } => write!(
|
||||
f,
|
||||
"scheduler phase order regressed from {previous:?} to {current:?}"
|
||||
@@ -487,6 +551,7 @@ impl std::error::Error for EngineError {
|
||||
Self::World(source) => Some(source),
|
||||
Self::AssetPreparation { source, .. } => Some(source),
|
||||
Self::MissingVfs
|
||||
| Self::Movement(_)
|
||||
| Self::PrototypeGraph { .. }
|
||||
| Self::SchedulerPhaseOrder { .. }
|
||||
| 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]
|
||||
#[ignore = "requires licensed corpus"]
|
||||
fn load_trace_records_preparation_before_registration_and_raw_transforms() {
|
||||
@@ -1313,6 +1402,69 @@ mod tests {
|
||||
.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]
|
||||
#[ignore = "requires licensed corpus"]
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// # Errors
|
||||
@@ -814,10 +828,16 @@ mod tests {
|
||||
.expect("second");
|
||||
register_object(&mut world, first).expect("register first");
|
||||
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, second), Ok(Some(1)));
|
||||
|
||||
request_delete(&mut world, first).expect("delete");
|
||||
assert_eq!(handle_by_original_id(&world, OriginalObjectId(7)), None);
|
||||
assert_eq!(
|
||||
register_object(&mut world, first),
|
||||
Err(WorldError::StaleHandle)
|
||||
|
||||
@@ -290,6 +290,30 @@ Control получает world-interface Terrain и использует пов
|
||||
последовательности, а интеграция использует одну политику `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.dll` пересобрана при неизменных размере, imports и пяти именах/ordinals
|
||||
|
||||
Reference in New Issue
Block a user