nightshade-editor 0.30.0

Interactive map editor for the Nightshade game engine
use crate::ecs::EditorWorld;
use crate::systems::shell::{CutsceneCommand, EditorShellContext};
use nightshade::ecs::camera::components::Projection;
use nightshade::ecs::cutscene::Cutscene;
#[cfg(not(target_arch = "wasm32"))]
use nightshade::ecs::cutscene::CutsceneAction;
use nightshade::ecs::cutscene::components::{
    CutsceneShot, camera_look_rotation, sample_camera_path,
};
use nightshade::ecs::lines::components::{Line, Lines};
use nightshade::ecs::primitives::EasingFunction;
use nightshade::ecs::world::{GLOBAL_TRANSFORM, LINES, VISIBILITY};
use nightshade::prelude::*;

const MARKER_SIZE: f32 = 0.5;
const MARKER_MATERIAL: &str = "editor_cutscene_waypoint";
const CURVE_COLOR: Vec4 = Vec4::new(1.0, 0.55, 0.15, 1.0);
const SEGMENTS_PER_SPAN: usize = 16;

/// Drains the cutscene authoring commands queued by the shell.
pub fn apply_commands(
    editor_world: &mut EditorWorld,
    world: &mut World,
    context: &mut EditorShellContext,
) {
    let commands = std::mem::take(&mut context.cutscene_commands);
    for command in commands {
        match command {
            CutsceneCommand::AddPoint => add_point(editor_world, world),
            CutsceneCommand::Clear => clear(editor_world, world),
            CutsceneCommand::Save(name) => save(editor_world, &name),
            CutsceneCommand::Load(name) => load(editor_world, world, &name),
            CutsceneCommand::Preview => preview(editor_world, world),
            CutsceneCommand::Stop => stop_preview(editor_world, world),
        }
    }
}

/// Reads the draggable waypoint markers back into the path shots, redraws the
/// catmull-rom curve, and advances an active preview.
pub fn sync(editor_world: &mut EditorWorld, world: &mut World) {
    let waypoints = editor_world.resources.cutscene_edit.waypoints.clone();
    for (index, marker) in waypoints.iter().enumerate() {
        if let Some(transform) = world.core.get_local_transform(*marker) {
            let eye = transform.translation;
            let forward =
                nalgebra_glm::quat_rotate_vec3(&transform.rotation, &Vec3::new(0.0, 0.0, -1.0));
            if let Some(shot) = editor_world.resources.cutscene_edit.shots.get_mut(index) {
                shot.eye = eye;
                shot.target = eye + forward;
            }
        }
    }

    let lines_entity = ensure_curve_lines(editor_world, world);
    let lines = build_curve_lines(&editor_world.resources.cutscene_edit.shots);
    world.core.set_lines(lines_entity, Lines::new(lines));

    if editor_world.resources.cutscene_edit.previewing {
        advance_cutscene_system(world);
        if cutscene_finished(world) {
            stop_preview(editor_world, world);
        }
    }
}

fn add_point(editor_world: &mut EditorWorld, world: &mut World) {
    let Some(camera) = editor_world.resources.camera.camera_entity else {
        return;
    };
    let Some(transform) = world.core.get_global_transform(camera) else {
        return;
    };
    let eye = transform.translation();
    let forward = nalgebra_glm::normalize(&transform.forward_vector());
    let field_of_view_degrees = world
        .core
        .get_camera(camera)
        .and_then(|component| match &component.projection {
            Projection::Perspective(perspective) => Some(perspective.y_fov_rad.to_degrees()),
            _ => None,
        })
        .unwrap_or(45.0);
    let shot = CutsceneShot {
        eye,
        target: eye + forward,
        field_of_view_degrees,
        roll_degrees: 0.0,
    };
    let rotation = camera_look_rotation(shot.eye, shot.target, 0.0);
    let marker = spawn_marker(editor_world, world, eye, rotation);
    editor_world.resources.cutscene_edit.shots.push(shot);
    editor_world.resources.cutscene_edit.waypoints.push(marker);
}

fn clear(editor_world: &mut EditorWorld, world: &mut World) {
    let markers = std::mem::take(&mut editor_world.resources.cutscene_edit.waypoints);
    for marker in markers {
        editor_world
            .resources
            .editor_scene
            .unregister_scaffolding(marker);
        despawn_recursive_immediate(world, marker);
    }
    editor_world.resources.cutscene_edit.shots.clear();
}

fn preview(editor_world: &mut EditorWorld, world: &mut World) {
    let shots = editor_world.resources.cutscene_edit.shots.clone();
    if shots.len() < 2 {
        return;
    }
    let camera = ensure_preview_camera(editor_world, world);
    set_cutscene_camera(world, camera);
    editor_world.resources.cutscene_edit.restore_camera = world.resources.active_camera;
    world.resources.active_camera = Some(camera);
    play_cutscene(world, build_cutscene(&shots));
    editor_world.resources.cutscene_edit.previewing = true;
}

fn stop_preview(editor_world: &mut EditorWorld, world: &mut World) {
    if !editor_world.resources.cutscene_edit.previewing {
        return;
    }
    stop_cutscene(world);
    advance_cutscene_system(world);
    let restore = editor_world
        .resources
        .cutscene_edit
        .restore_camera
        .take()
        .or(editor_world.resources.camera.camera_entity);
    if let Some(camera) = restore {
        world.resources.active_camera = Some(camera);
    }
    editor_world.resources.cutscene_edit.previewing = false;
}

fn build_cutscene(shots: &[CutsceneShot]) -> Cutscene {
    let duration = (shots.len() as f32 * 1.5).max(2.0);
    Cutscene::new("Editor Path").camera_path(
        0.0,
        duration,
        EasingFunction::SineInOut,
        shots.to_vec(),
    )
}

#[cfg(not(target_arch = "wasm32"))]
fn extract_path(cutscene: &Cutscene) -> Vec<CutsceneShot> {
    cutscene
        .events
        .iter()
        .find_map(|event| match &event.action {
            CutsceneAction::CameraPath { waypoints } => Some(waypoints.clone()),
            _ => None,
        })
        .unwrap_or_default()
}

#[cfg(not(target_arch = "wasm32"))]
fn save(editor_world: &EditorWorld, name: &str) {
    let cutscene = build_cutscene(&editor_world.resources.cutscene_edit.shots);
    let path = format!("{name}.cutscene.json");
    match serde_json::to_string_pretty(&cutscene) {
        Ok(text) => match std::fs::write(&path, text) {
            Ok(()) => tracing::info!("Saved cutscene to {path}"),
            Err(error) => tracing::error!("Failed to write {path}: {error}"),
        },
        Err(error) => tracing::error!("Failed to serialize cutscene: {error}"),
    }
}

#[cfg(target_arch = "wasm32")]
fn save(_editor_world: &EditorWorld, _name: &str) {
    tracing::warn!("Saving cutscenes is not supported on this target");
}

#[cfg(not(target_arch = "wasm32"))]
fn load(editor_world: &mut EditorWorld, world: &mut World, name: &str) {
    let path = format!("{name}.cutscene.json");
    let text = match std::fs::read_to_string(&path) {
        Ok(text) => text,
        Err(error) => {
            tracing::error!("Failed to read {path}: {error}");
            return;
        }
    };
    let cutscene = match serde_json::from_str::<Cutscene>(&text) {
        Ok(cutscene) => cutscene,
        Err(error) => {
            tracing::error!("Failed to parse {path}: {error}");
            return;
        }
    };
    clear(editor_world, world);
    for shot in extract_path(&cutscene) {
        let rotation = camera_look_rotation(shot.eye, shot.target, shot.roll_degrees);
        let marker = spawn_marker(editor_world, world, shot.eye, rotation);
        editor_world.resources.cutscene_edit.shots.push(shot);
        editor_world.resources.cutscene_edit.waypoints.push(marker);
    }
}

#[cfg(target_arch = "wasm32")]
fn load(_editor_world: &mut EditorWorld, _world: &mut World, _name: &str) {
    tracing::warn!("Loading cutscenes is not supported on this target");
}

fn spawn_marker(
    editor_world: &mut EditorWorld,
    world: &mut World,
    position: Vec3,
    rotation: Quat,
) -> Entity {
    ensure_marker_material(world);
    let marker = spawn_mesh(
        world,
        "Cube",
        position,
        Vec3::new(MARKER_SIZE, MARKER_SIZE, MARKER_SIZE),
    );
    world
        .core
        .set_material_ref(marker, MaterialRef::new(MARKER_MATERIAL));
    if let Some(transform) = world.core.get_local_transform_mut(marker) {
        transform.translation = position;
        transform.rotation = rotation;
    }
    mark_local_transform_dirty(world, marker);
    editor_world
        .resources
        .editor_scene
        .register_scaffolding(marker);
    marker
}

fn ensure_preview_camera(editor_world: &mut EditorWorld, world: &mut World) -> Entity {
    if let Some(camera) = editor_world.resources.cutscene_edit.preview_camera
        && world
            .core
            .entity_has_components(camera, nightshade::ecs::world::CAMERA)
    {
        return camera;
    }
    let camera = spawn_camera(world, Vec3::zeros(), "Cutscene Preview".to_string());
    if let Some(component) = world.core.get_camera_mut(camera) {
        component.smoothing = None;
    }
    editor_world
        .resources
        .editor_scene
        .register_scaffolding(camera);
    editor_world.resources.cutscene_edit.preview_camera = Some(camera);
    camera
}

fn ensure_curve_lines(editor_world: &mut EditorWorld, world: &mut World) -> Entity {
    if let Some(entity) = editor_world.resources.cutscene_edit.curve_lines
        && world.core.entity_has_components(entity, LINES)
    {
        return entity;
    }
    let entity = spawn_entities(world, LINES | VISIBILITY | GLOBAL_TRANSFORM, 1)[0];
    world.core.set_lines(entity, Lines::default());
    world
        .core
        .set_visibility(entity, Visibility { visible: true });
    world
        .core
        .set_global_transform(entity, GlobalTransform::default());
    editor_world
        .resources
        .editor_scene
        .register_scaffolding(entity);
    editor_world.resources.cutscene_edit.curve_lines = Some(entity);
    entity
}

fn build_curve_lines(shots: &[CutsceneShot]) -> Vec<Line> {
    if shots.len() < 2 {
        return Vec::new();
    }
    let total = SEGMENTS_PER_SPAN * (shots.len() - 1);
    let mut lines = Vec::with_capacity(total);
    let mut previous = sample_camera_path(shots, 0.0).eye;
    for index in 1..=total {
        let progress = index as f32 / total as f32;
        let point = sample_camera_path(shots, progress).eye;
        lines.push(Line {
            start: previous,
            end: point,
            color: CURVE_COLOR,
        });
        previous = point;
    }
    lines
}

fn ensure_marker_material(world: &mut World) {
    if world
        .resources
        .assets
        .material_registry
        .registry
        .name_to_index
        .contains_key(MARKER_MATERIAL)
    {
        return;
    }
    material_registry_insert(
        &mut world.resources.assets.material_registry,
        MARKER_MATERIAL.to_string(),
        Material {
            base_color: [1.0, 0.55, 0.15, 1.0],
            emissive_factor: [0.5, 0.25, 0.05],
            emissive_strength: 2.0,
            unlit: true,
            ..Default::default()
        },
    );
    if let Some(&index) = world
        .resources
        .assets
        .material_registry
        .registry
        .name_to_index
        .get(MARKER_MATERIAL)
    {
        registry_add_reference(
            &mut world.resources.assets.material_registry.registry,
            index,
        );
    }
}