1use crate::project_vfs::ProjectVfs;
25use khora_sdk::prelude::ecs::*;
26use khora_sdk::prelude::math::{LinearRgba, Vec3};
27use khora_sdk::{GameWorld, SceneFile, SerializationGoal, SerializationService};
28use std::path::Path;
29
30pub const DEFAULT_SCENE_REL: &str = "scenes/default.kscene";
32
33pub fn snapshot_scene(world: &GameWorld) -> Vec<u8> {
50 let svc = SerializationService::new();
51 match svc.save_world(world.inner_world(), SerializationGoal::EditorInterchange) {
52 Ok(scene) => scene.to_bytes(),
53 Err(e) => {
54 log::error!("Play-mode snapshot failed: {:?}", e);
55 Vec::new()
56 }
57 }
58}
59
60pub fn restore_scene(world: &mut GameWorld, snapshot: &[u8]) {
66 if snapshot.is_empty() {
67 return;
68 }
69 let scene = match SceneFile::from_bytes(snapshot) {
70 Ok(f) => f,
71 Err(e) => {
72 log::error!("Play-mode restore: invalid snapshot: {:?}", e);
73 return;
74 }
75 };
76
77 let all: Vec<_> = world.iter_entities().collect();
78 for e in all {
79 world.despawn(e);
80 }
81
82 let svc = SerializationService::new();
83 if let Err(e) = svc.load_world(&scene, world.inner_world_mut()) {
84 log::error!("Play-mode restore failed: {:?}", e);
85 }
86}
87
88#[allow(dead_code)]
101pub fn save_scene_in_project(pvfs: &mut ProjectVfs, world: &GameWorld, rel_path: &Path) -> bool {
102 save_scene_in_project_with_goal(pvfs, world, rel_path, SerializationGoal::EditorInterchange)
103}
104
105pub fn save_scene_in_project_with_goal(
107 pvfs: &mut ProjectVfs,
108 world: &GameWorld,
109 rel_path: &Path,
110 goal: SerializationGoal,
111) -> bool {
112 let agent = SerializationService::new();
113 let scene_file = match agent.save_world(world.inner_world(), goal) {
114 Ok(f) => f,
115 Err(e) => {
116 log::error!("Failed to serialize scene: {:?}", e);
117 return false;
118 }
119 };
120 let bytes = scene_file.to_bytes();
121 if let Err(e) = pvfs.write_asset(rel_path, &bytes) {
122 log::error!("Failed to write scene to {:?}: {:#}", rel_path, e);
123 return false;
124 }
125 if let Err(e) = pvfs.rebuild_index() {
126 log::warn!("Scene saved but index rebuild failed: {:#}", e);
127 }
128 log::info!(
129 "Scene saved to '{}' ({} bytes, goal={:?}) via ProjectVfs",
130 rel_path.display(),
131 bytes.len(),
132 goal,
133 );
134 true
135}
136
137pub fn load_scene_in_project(
141 pvfs: &mut ProjectVfs,
142 world: &mut GameWorld,
143 rel_path_fwd_slash: &str,
144) -> bool {
145 let uuid = pvfs.resolve_uuid(rel_path_fwd_slash);
147
148 let bytes = match pvfs.asset_service.load_raw(&uuid) {
150 Ok(b) => b,
151 Err(_) => {
152 if let Err(e) = pvfs.rebuild_index() {
153 log::error!(
154 "Failed to rebuild index while resolving '{}': {:#}",
155 rel_path_fwd_slash,
156 e
157 );
158 return false;
159 }
160 match pvfs.asset_service.load_raw(&uuid) {
161 Ok(b) => b,
162 Err(e) => {
163 log::error!(
164 "Failed to load scene '{}' from ProjectVfs: {:#}",
165 rel_path_fwd_slash,
166 e
167 );
168 return false;
169 }
170 }
171 }
172 };
173
174 let scene_file = match SceneFile::from_bytes(&bytes) {
175 Ok(f) => f,
176 Err(e) => {
177 log::error!("Invalid scene file '{}': {:?}", rel_path_fwd_slash, e);
178 return false;
179 }
180 };
181
182 let all_entities: Vec<_> = world.iter_entities().collect();
184 for entity in all_entities {
185 world.despawn(entity);
186 }
187
188 let agent = SerializationService::new();
189 match agent.load_world(&scene_file, world.inner_world_mut()) {
190 Ok(()) => {
191 log::info!(
192 "Scene loaded from '{}' ({} bytes) via ProjectVfs",
193 rel_path_fwd_slash,
194 bytes.len()
195 );
196 true
197 }
198 Err(e) => {
199 log::error!(
200 "Failed to deserialize scene '{}': {:?}",
201 rel_path_fwd_slash,
202 e
203 );
204 false
205 }
206 }
207}
208
209pub fn auto_load_or_create_default_scene(pvfs: &mut ProjectVfs, world: &mut GameWorld) {
212 let rel = DEFAULT_SCENE_REL;
213 let abs = pvfs.assets_root.join(Path::new(rel));
214 if abs.exists() {
215 load_scene_in_project(pvfs, world, rel);
216 } else {
217 create_default_scene_in_project(pvfs, world, rel);
218 }
219}
220
221fn create_default_scene_in_project(pvfs: &mut ProjectVfs, world: &mut GameWorld, rel_path: &str) {
224 world.spawn((
225 Transform {
226 translation: Vec3::new(0.0, 5.0, 10.0),
227 ..Default::default()
228 },
229 GlobalTransform::identity(),
230 Camera::default(),
231 Name("Main Camera".to_string()),
232 ));
233
234 world.spawn((
235 Transform {
236 translation: Vec3::new(0.0, 10.0, 0.0),
237 ..Default::default()
238 },
239 GlobalTransform::identity(),
240 Light::new(LightType::Directional(DirectionalLight {
241 direction: Vec3::new(-0.4, -0.8, -0.45),
242 color: LinearRgba::WHITE,
243 intensity: 1.0,
244 ..Default::default()
245 })),
246 Name("Directional Light".to_string()),
247 ));
248
249 if !save_scene_in_project(pvfs, world, Path::new(rel_path)) {
250 log::error!("Failed to seed default scene at '{}'", rel_path);
251 }
252}
253
254#[allow(dead_code)]
264pub fn save_scene_to_path(world: &GameWorld, path: &str) -> bool {
265 save_scene_to_path_with_goal(world, path, SerializationGoal::EditorInterchange)
266}
267
268pub fn save_scene_to_path_with_goal(
270 world: &GameWorld,
271 path: &str,
272 goal: SerializationGoal,
273) -> bool {
274 let agent = SerializationService::new();
275 match agent.save_world(world.inner_world(), goal) {
276 Ok(scene_file) => {
277 let bytes = scene_file.to_bytes();
278 match std::fs::write(path, &bytes) {
279 Ok(()) => {
280 log::warn!(
281 "Scene saved to '{}' ({} bytes, goal={:?}) — outside project, not VFS-managed.",
282 path,
283 bytes.len(),
284 goal,
285 );
286 true
287 }
288 Err(e) => {
289 log::error!("Failed to write scene file '{}': {}", path, e);
290 false
291 }
292 }
293 }
294 Err(e) => {
295 log::error!("Failed to serialize scene: {:?}", e);
296 false
297 }
298 }
299}
300
301pub fn load_scene_from_path(world: &mut GameWorld, path: &str) -> bool {
304 let bytes = match std::fs::read(path) {
305 Ok(bytes) => bytes,
306 Err(e) => {
307 log::error!("Failed to read scene file '{}': {}", path, e);
308 return false;
309 }
310 };
311
312 let scene_file = match SceneFile::from_bytes(&bytes) {
313 Ok(file) => file,
314 Err(e) => {
315 log::error!("Invalid scene file '{}': {:?}", path, e);
316 return false;
317 }
318 };
319
320 let all_entities: Vec<_> = world.iter_entities().collect();
321 for entity in all_entities {
322 world.despawn(entity);
323 }
324
325 let agent = SerializationService::new();
326 match agent.load_world(&scene_file, world.inner_world_mut()) {
327 Ok(()) => {
328 log::warn!(
329 "Scene loaded from '{}' ({} bytes) — outside project, not VFS-managed.",
330 path,
331 bytes.len()
332 );
333 true
334 }
335 Err(e) => {
336 log::error!("Failed to deserialize scene '{}': {:?}", path, e);
337 false
338 }
339 }
340}
341
342pub fn rel_inside_project(abs_path: &Path, assets_root: &Path) -> Option<String> {
350 let rel = abs_path.strip_prefix(assets_root).ok()?;
351 Some(
352 rel.components()
353 .map(|c| c.as_os_str().to_string_lossy().into_owned())
354 .collect::<Vec<_>>()
355 .join("/"),
356 )
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 #[test]
368 fn snapshot_restore_preserves_name() {
369 let mut world = GameWorld::new();
370 world.spawn((
371 Transform {
372 translation: Vec3::new(1.0, 2.0, 3.0),
373 ..Default::default()
374 },
375 GlobalTransform::identity(),
376 Name::new("TestCube"),
377 ));
378
379 let snap = snapshot_scene(&world);
380 assert!(!snap.is_empty(), "snapshot should not be empty");
381
382 let all: Vec<_> = world.iter_entities().collect();
384 for e in all {
385 world.despawn(e);
386 }
387 assert_eq!(world.iter_entities().count(), 0);
388
389 restore_scene(&mut world, &snap);
390
391 let names: Vec<String> = world
392 .iter_entities()
393 .filter_map(|e| {
394 world
395 .get_component::<Name>(e)
396 .map(|n| n.as_str().to_owned())
397 })
398 .collect();
399 assert!(
400 names.contains(&"TestCube".to_string()),
401 "expected 'TestCube' Name to survive restore, got {:?}",
402 names
403 );
404 }
405
406 #[test]
411 fn snapshot_restore_preserves_multiple_components() {
412 let mut world = GameWorld::new();
413
414 let cube = world.spawn((
415 Transform::from_translation(Vec3::new(4.0, 5.0, 6.0)),
416 GlobalTransform::identity(),
417 Name::new("Cube"),
418 ));
419 let _light = world.spawn((
420 Transform::from_translation(Vec3::new(0.0, 10.0, 0.0)),
421 GlobalTransform::identity(),
422 Name::new("Sun"),
423 Light::directional(),
424 ));
425 let _camera = world.spawn((
426 Transform::from_translation(Vec3::new(0.0, 2.0, 9.0)),
427 GlobalTransform::identity(),
428 Name::new("Cam"),
429 Camera::new_perspective(std::f32::consts::FRAC_PI_4, 1.5, 0.1, 800.0),
430 ));
431 let _ = cube;
432
433 let before = world.iter_entities().count();
434 assert_eq!(before, 3);
435
436 let snap = snapshot_scene(&world);
437 assert!(!snap.is_empty());
438
439 let all: Vec<_> = world.iter_entities().collect();
441 for e in all {
442 world.despawn(e);
443 }
444 restore_scene(&mut world, &snap);
445
446 assert_eq!(
447 world.iter_entities().count(),
448 before,
449 "entity count must survive the round-trip"
450 );
451
452 let restored: Vec<_> = world.iter_entities().collect();
454 let cube_back = restored
455 .iter()
456 .find(|&&e| {
457 world
458 .get_component::<Name>(e)
459 .is_some_and(|n| n.as_str() == "Cube")
460 })
461 .copied()
462 .expect("the 'Cube' entity must survive restore");
463 let t = world
464 .get_component::<Transform>(cube_back)
465 .expect("restored cube keeps its Transform");
466 assert_eq!(t.translation, Vec3::new(4.0, 5.0, 6.0));
467
468 let has_light = restored
470 .iter()
471 .any(|&e| world.get_component::<Light>(e).is_some());
472 let has_camera = restored
473 .iter()
474 .any(|&e| world.get_component::<Camera>(e).is_some());
475 assert!(has_light, "a Light must survive the round-trip");
476 assert!(has_camera, "a Camera must survive the round-trip");
477 }
478
479 #[test]
485 fn play_stop_restores_state_after_multi_domain_despawn() {
486 let mut world = GameWorld::new();
487
488 let a = world.spawn((
491 Transform::from_translation(Vec3::new(1.0, 0.0, 0.0)),
492 GlobalTransform::identity(),
493 Name::new("MeshA"),
494 MeshRef::procedural(ProceduralMeshKind::Cube, [1.0, 0.0, 0.0, 0.0]),
495 ));
496 let _b = world.spawn((
497 Transform::from_translation(Vec3::new(2.0, 0.0, 0.0)),
498 GlobalTransform::identity(),
499 Name::new("MeshB"),
500 MeshRef::procedural(ProceduralMeshKind::Sphere, [0.5, 16.0, 16.0, 0.0]),
501 ));
502 let _light = world.spawn((
503 Transform::identity(),
504 GlobalTransform::identity(),
505 Name::new("Light"),
506 Light::point(),
507 ));
508 let _ = a;
509
510 let before = world.iter_entities().count();
511 assert_eq!(before, 3);
512
513 let snap = snapshot_scene(&world);
515 assert!(!snap.is_empty());
516
517 let all: Vec<_> = world.iter_entities().collect();
519 for e in all {
520 world.despawn(e);
521 }
522 world.spawn((Transform::identity(), GlobalTransform::identity()));
523 assert_ne!(world.iter_entities().count(), before);
524
525 restore_scene(&mut world, &snap);
527
528 assert_eq!(
529 world.iter_entities().count(),
530 before,
531 "stop must restore the exact pre-play entity count"
532 );
533
534 let restored: Vec<_> = world.iter_entities().collect();
536 let mesh_a = restored
537 .iter()
538 .find(|&&e| {
539 world
540 .get_component::<Name>(e)
541 .is_some_and(|n| n.as_str() == "MeshA")
542 })
543 .copied()
544 .expect("'MeshA' must survive restore");
545 let t = world
546 .get_component::<Transform>(mesh_a)
547 .expect("restored MeshA keeps its Spatial Transform");
548 assert_eq!(t.translation, Vec3::new(1.0, 0.0, 0.0));
549 assert!(
550 world.get_component::<MeshRef>(mesh_a).is_some(),
551 "restored MeshA keeps its Render-domain MeshRef"
552 );
553 }
554}