nightshade-editor 0.28.0

Interactive map editor for the Nightshade game engine
//! First-person play-test mode: drop a player into the level at a placed
//! `PLAYER SPAWN` marker and run around it. The character controller and
//! physics run in the engine frame schedule; this module only spawns/despawns
//! the player, drives mouse look, and locks the cursor.

use crate::ecs::{EditorWorld, PlacementShape};
use nightshade::ecs::camera::systems::first_person_camera_look_system;
use nightshade::ecs::physics::commands::spawn_first_person_player;
use nightshade::ecs::physics::components::{ColliderComponent, RigidBodyComponent};
use nightshade::prelude::*;

const PLAY_GROUND_HALF_EXTENT: f32 = 200.0;
const PLAY_GROUND_THICKNESS: f32 = 1.0;

pub const PLAYER_SPAWN_TAG: &str = "player_spawn";

/// Spawn an obvious, selectable PLAYER SPAWN marker at the view focus. It is a
/// real scene entity (tagged and saved) so the drop point is authored from the
/// editor.
pub fn add_player_spawn(editor_world: &mut EditorWorld, world: &mut World) {
    let focus = crate::systems::camera::focus_point(editor_world, world);
    let position = Vec3::new(focus.x, 0.9, focus.z);
    let entity = super::retained_ui::build::spawn_shape(world, PlacementShape::Cube, position);
    if let Some(local) = world.core.get_local_transform_mut(entity) {
        local.translation = position;
        local.scale = Vec3::new(0.7, 1.8, 0.7);
    }
    mark_local_transform_dirty(world, entity);
    super::retained_ui::build::apply_to_entity(
        world,
        11 % super::retained_ui::build::palette_count(),
        entity,
    );
    if let Some(name) = world.core.get_name_mut(entity) {
        name.0 = "Player Spawn".to_string();
    }
    world
        .resources
        .entities
        .tags
        .entry(entity)
        .or_default()
        .push(PLAYER_SPAWN_TAG.to_string());

    crate::scene_writeback::register_subtree(
        &mut editor_world.resources.project,
        &mut editor_world.resources.editor_scene,
        world,
        entity,
    );
    let captured =
        crate::undo::capture_subtree(world, &mut editor_world.resources.editor_scene, entity);
    editor_world.resources.undo.push(
        crate::undo::UndoableOperation::EntityCreated {
            captured: Box::new(captured),
        },
        "Add player spawn",
    );
    crate::systems::selection::set_primary(editor_world, Some(entity));
}

pub fn toggle(editor_world: &mut EditorWorld, world: &mut World) {
    if editor_world.resources.play.active {
        exit(editor_world, world);
    } else {
        enter(editor_world, world);
    }
}

/// Toggle play mode and keep the greybox panel's Play button label in sync.
pub fn toggle_and_sync_button(editor_world: &mut EditorWorld, world: &mut World) {
    toggle(editor_world, world);
    let label = if editor_world.resources.play.active {
        "Stop (Esc)"
    } else {
        "Play (first person)"
    };
    let button = editor_world.resources.ui_handles.create_shape.play_button;
    ui_button_set_text(world, button, label);
}

/// Toggle play mode from anywhere with F5, so walking mode does not depend on
/// finding the greybox panel button. Ignore the key while a text field is
/// focused so typing in a panel cannot drop the user into walk mode, while
/// still allowing F5 to leave play mode.
pub fn poll_hotkey(editor_world: &mut EditorWorld, world: &mut World) {
    if !world.resources.input.keyboard.just_pressed(KeyCode::F5) {
        return;
    }
    if !editor_world.resources.play.active && text_input_focused(world) {
        return;
    }
    toggle_and_sync_button(editor_world, world);
}

fn text_input_focused(world: &World) -> bool {
    world
        .resources
        .retained_ui
        .interaction_for_active()
        .focused_entity
        .is_some_and(|entity| {
            world.ui.get_ui_text_input(entity).is_some()
                || world.ui.get_ui_text_area(entity).is_some()
                || world.ui.get_ui_drag_value(entity).is_some()
        })
}

fn spawn_point(editor_world: &EditorWorld, world: &World) -> Vec3 {
    let tagged = world
        .resources
        .entities
        .tags
        .iter()
        .find(|(_, tags)| tags.iter().any(|tag| tag == PLAYER_SPAWN_TAG))
        .map(|(entity, _)| *entity)
        .and_then(|entity| world.core.get_global_transform(entity))
        .map(|transform| transform.translation());
    match tagged {
        Some(position) => Vec3::new(position.x, position.y + 0.6, position.z),
        None => {
            let focus = crate::systems::camera::focus_point(editor_world, world);
            Vec3::new(focus.x, 2.0, focus.z)
        }
    }
}

fn enter(editor_world: &mut EditorWorld, world: &mut World) {
    let spawn = spawn_point(editor_world, world);
    let (player, camera) = spawn_first_person_player(world, spawn);
    if let Some(controller) = world.core.get_character_controller_mut(player) {
        controller.engine_input_enabled = true;
        controller.config.max_slope_climb_angle = 55_f32.to_radians();
        controller.config.min_slope_slide_angle = 50_f32.to_radians();
    }
    editor_world
        .resources
        .editor_scene
        .register_scaffolding(player);
    editor_world
        .resources
        .editor_scene
        .register_scaffolding(camera);
    editor_world.resources.play.restore_camera = world.resources.active_camera;
    editor_world.resources.play.player = Some(player);
    editor_world.resources.play.camera = Some(camera);
    editor_world.resources.play.active = true;
    editor_world.resources.play.escape_was_pressed = true;
    editor_world.resources.play.ground = Some(spawn_play_ground(world));
    world.resources.active_camera = Some(camera);
    world.resources.physics.enabled = true;
    world.resources.retained_ui.enabled = false;
    world.resources.render_settings.render_world_to_swapchain = true;
    clear_editor_viewport(world);
    set_spawn_markers_visible(world, false);
    set_cursor_locked(world, true);
    set_cursor_visible(world, false);
}

/// Force the renderer onto the direct full-window path the game uses: no
/// editor viewport tile, no camera-tile render-to-texture, no UI viewport rect.
/// Re-asserted every play frame so a stray UI update cannot pull us back into
/// the tiled, letterboxed compose path.
fn clear_editor_viewport(world: &mut World) {
    world.resources.window.active_viewport_rect = None;
    world.resources.window.camera_tile_rects.clear();
    world.resources.user_interface.required_cameras.clear();
}

fn exit(editor_world: &mut EditorWorld, world: &mut World) {
    if let Some(player) = editor_world.resources.play.player.take() {
        despawn_recursive_immediate(world, player);
    }
    if let Some(ground) = editor_world.resources.play.ground.take() {
        despawn_recursive_immediate(world, ground);
    }
    editor_world.resources.play.camera = None;
    editor_world.resources.play.active = false;
    world.resources.active_camera = editor_world.resources.play.restore_camera.take();
    world.resources.physics.enabled = false;
    world.resources.retained_ui.enabled = true;
    world.resources.render_settings.render_world_to_swapchain = false;
    if let Some(camera) = world.resources.active_camera {
        world.resources.window.force_render_cameras.insert(camera);
    }
    set_spawn_markers_visible(world, true);
    set_cursor_locked(world, false);
    set_cursor_visible(world, true);
}

/// An invisible, collision-only floor at y=0 so the player has something to
/// walk on during a play test even where the level has no geometry. It is
/// scaffolding that lives only for the duration of play mode.
fn spawn_play_ground(world: &mut World) -> Entity {
    use nightshade::ecs::world::{
        COLLIDER, GLOBAL_TRANSFORM, LOCAL_TRANSFORM, LOCAL_TRANSFORM_DIRTY, RIGID_BODY,
    };
    let entity = spawn_entities(
        world,
        RIGID_BODY | COLLIDER | LOCAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY | GLOBAL_TRANSFORM,
        1,
    )[0];
    if let Some(local) = world.core.get_local_transform_mut(entity) {
        local.translation = Vec3::new(0.0, -PLAY_GROUND_THICKNESS, 0.0);
    }
    mark_local_transform_dirty(world, entity);
    if let Some(rigid_body) = world.core.get_rigid_body_mut(entity) {
        *rigid_body =
            RigidBodyComponent::new_static().with_translation(0.0, -PLAY_GROUND_THICKNESS, 0.0);
    }
    if let Some(collider) = world.core.get_collider_mut(entity) {
        *collider = ColliderComponent::new_cuboid(
            PLAY_GROUND_HALF_EXTENT,
            PLAY_GROUND_THICKNESS,
            PLAY_GROUND_HALF_EXTENT,
        )
        .with_friction(0.9);
    }
    entity
}

fn set_spawn_markers_visible(world: &mut World, visible: bool) {
    let markers: Vec<Entity> = world
        .resources
        .entities
        .tags
        .iter()
        .filter(|(_, tags)| tags.iter().any(|tag| tag == PLAYER_SPAWN_TAG))
        .map(|(entity, _)| *entity)
        .collect();
    for marker in markers {
        world.core.add_components(marker, VISIBILITY);
        world.core.set_visibility(marker, Visibility { visible });
    }
}

/// Each play frame: hold Escape to leave, lock the cursor and drive mouse look
/// unless the console is open.
pub fn tick(editor_world: &mut EditorWorld, world: &mut World, shell_capturing: bool) {
    if !editor_world.resources.play.active {
        return;
    }
    let escape = world
        .resources
        .input
        .keyboard
        .is_key_pressed(KeyCode::Escape);
    let escape_edge = escape && !editor_world.resources.play.escape_was_pressed && !shell_capturing;
    editor_world.resources.play.escape_was_pressed = escape;
    if escape_edge {
        exit(editor_world, world);
        return;
    }
    clear_editor_viewport(world);
    if shell_capturing {
        set_cursor_locked(world, false);
        set_cursor_visible(world, true);
    } else {
        set_cursor_locked(world, true);
        set_cursor_visible(world, false);
        first_person_camera_look_system(world);
    }
}