hiraku-engine 0.1.0

Hiraku visual-novel engine
use std::collections::BTreeMap;

use bevy::prelude::*;
use hiraku_video::{VideoAsset, VideoEvent, VideoPlaybackId, VideoPlayer};

use crate::script::{
    ScriptRequestId, ScriptResponse, ScriptResponseMessage, ScriptRuntimeState, VideoCommand,
};

#[derive(Resource, Default)]
pub struct PendingMovieWaits(BTreeMap<VideoPlaybackId, ScriptRequestId>);

pub(super) fn dispatch_video_command(
    command: VideoCommand,
    asset_server: &AssetServer,
    player: &mut VideoPlayer,
    waits: &mut PendingMovieWaits,
) {
    match command {
        VideoCommand::Play { path, done } => {
            let asset: Handle<VideoAsset> = asset_server.load(path);
            let playback = player.play(asset);
            waits.0.insert(playback, done);
        }
    }
}

pub fn complete_movie_waits(
    mut events: MessageReader<VideoEvent>,
    mut waits: ResMut<PendingMovieWaits>,
    runtime: Res<ScriptRuntimeState>,
    mut responses: MessageWriter<ScriptResponseMessage>,
) {
    for event in events.read() {
        let playback = match event {
            VideoEvent::Started { .. } => continue,
            VideoEvent::Finished { id } | VideoEvent::Skipped { id } => *id,
            VideoEvent::Failed { id, error } => {
                warn!("movie playback failed: {error}");
                *id
            }
        };
        if let Some(request) = waits.0.remove(&playback)
            && runtime.wait_request == Some(request)
        {
            responses.write(ScriptResponseMessage {
                request,
                response: ScriptResponse::Continue,
            });
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn terminal_video_events_resume_only_the_matching_story_wait() {
        let mut app = App::new();
        app.init_resource::<PendingMovieWaits>()
            .init_resource::<ScriptRuntimeState>()
            .add_message::<VideoEvent>()
            .add_message::<ScriptResponseMessage>()
            .add_systems(Update, complete_movie_waits);
        let playback = VideoPlaybackId(7);
        app.world_mut()
            .resource_mut::<PendingMovieWaits>()
            .0
            .insert(playback, ScriptRequestId(11));
        app.world_mut()
            .resource_mut::<ScriptRuntimeState>()
            .wait_request = Some(ScriptRequestId(11));
        app.world_mut()
            .write_message(VideoEvent::Finished { id: playback });
        app.update();
        let responses = app.world().resource::<Messages<ScriptResponseMessage>>();
        let mut cursor = responses.get_cursor();
        let response = cursor
            .read(responses)
            .next()
            .expect("completion must resume the matching story request");
        assert_eq!(response.request, ScriptRequestId(11));
    }

    #[test]
    fn terminal_video_events_discard_cancelled_story_waits() {
        let mut app = App::new();
        app.init_resource::<PendingMovieWaits>()
            .init_resource::<ScriptRuntimeState>()
            .add_message::<VideoEvent>()
            .add_message::<ScriptResponseMessage>()
            .add_systems(Update, complete_movie_waits);
        let playback = VideoPlaybackId(9);
        app.world_mut()
            .resource_mut::<PendingMovieWaits>()
            .0
            .insert(playback, ScriptRequestId(12));
        app.world_mut()
            .write_message(VideoEvent::Finished { id: playback });
        app.update();

        let responses = app.world().resource::<Messages<ScriptResponseMessage>>();
        assert_eq!(responses.len(), 0);
        assert!(app.world().resource::<PendingMovieWaits>().0.is_empty());
    }
}