use std::{ collections::HashMap, fs::File, io::{BufReader, prelude::*}, net::TcpStream, path::Path, }; use dialogi::{DialogBlock, DialogSequence, ParsingError}; use event_simulation::Simulation; use indexmap::IndexMap; use maud::{Markup, html}; use multilinear::{BorrowedMultilinearSimulation, Event}; use multilinear_parser::{MultilinearParser, NamedMultilinearInfo}; use pukram2html::convert; use vn_settings::{Change, Parameter, PlayerSettings, SettingsContext, extract_layers}; use crate::dialog::parse_map; fn render_scene(settings: &PlayerSettings, name: &str) -> Markup { html! { article .scene-section { @for object in &settings.objects.objects { @if let Some(image_set) = object.image.get(name) { @for image in &settings.images.images[image_set] { @if let Some(image_path) = image.get_ref(name) { figure .scene-image { img src=(image_path) alt=""; } } } } } } } } fn render_choice(block: &DialogBlock, index: usize, progress: &str) -> Markup { let mut content = Vec::new(); convert(std::iter::once(&block.lines[0].text), &mut content); html! { form method="POST" { input type="hidden" name="progress" value=(progress); input type="hidden" name="choice" value=(index); button type="submit" .choice-button { fieldset .choice-box { @if !block.name.is_empty() { legend .choice-name { (block.name) } } @match String::from_utf8(content) { Ok(text) => (maud::PreEscaped(text)), Err(e) => (maud::PreEscaped(format!("Error: {e}"))), } } } } } } fn render_dialog_block(block: &DialogBlock) -> Markup { if block.lines.is_empty() { return html! {}; } let mut content = Vec::new(); convert(block.lines.iter().map(|l| l.text.as_ref()), &mut content); html! { fieldset .visual-novel-box onclick="playCurrentAudio()" { @if !block.name.is_empty() { legend .character-name { (block.name) } } div .dialog-content { @match String::from_utf8(content) { Ok(text) => (maud::PreEscaped(text)), Err(e) => (maud::PreEscaped(format!("Error: {e}"))), } } } } } fn render_ending() -> Markup { html! { section .end-screen { h1 { "End of scene" } form method="POST" { button type="submit" .restart-button { "Play Again" } } } } } fn generate_html(sections: Vec, base_path: &str) -> Markup { let total_sections = sections.len(); html! { div #story-container { div .scene-viewport { @for (index, section) in sections.iter().enumerate() { section .selection-section data-section-index=(index) style=(format!("display: {};", if index == 0 { "block" } else { "none" })) { (section) } } } (navigation_controls(total_sections)) (global_styles()) (interactive_script(total_sections, base_path)) } } } fn navigation_controls(total_sections: usize) -> Markup { html! { nav .nav-controls { button .nav-button onclick="prev()" { "←" } span #section-counter { "1/" (total_sections) } button .nav-button onclick="next()" { "→" } } } } fn global_styles() -> Markup { const BASE_STYLES: &str = r" :root { --border-color: #3a3a3a; --box-shadow: 0 0.4vw 0.6vw rgba(0, 0, 0, 0.1); --character-bg: #4a6b8a; --transition-duration: 0.2s; --border-radius: 1vw; }"; const NAVIGATION: &str = r" .nav-controls { display: flex; justify-content: center; align-items: center; gap: 1rem; margin: 2rem 0; padding: 1rem 0; } .nav-button { width: 2.5rem; height: 2.5rem; border-radius: 50%; border: none; background: #f0f0f0; cursor: pointer; font-size: 1.2rem; display: flex; align-items: center; justify-content: center; transition: all var(--transition-duration) ease; margin: 0; padding: 0; } #section-counter { font-family: system-ui, sans-serif; font-size: 0.9rem; color: #666; margin: 0 1rem; padding: 0; line-height: 1; } .nav-button:hover { background: #e0e0e0; transform: scale(1.1); } .nav-button:active { transform: scale(0.95); }"; const SCENE_STYLES: &str = r" .scene-viewport { width: 75%; margin: 0 auto; position: relative; padding-top: 42.1875%; overflow: hidden; } .scene-section { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: none; aspect-ratio: 16/9; display: flex !important; align-items: center; justify-content: center; } .scene-section img { height: 100%; width: auto; object-fit: contain; position: absolute; top: 0; left: 50%; transform: translateX(-50%); }"; const TEXTBOX_STYLES: &str = r" .visual-novel-box { border: 0.2vw solid var(--border-color); border-radius: var(--border-radius); padding: 0.5vh 2vw; margin: 1vh 0; background: rgba(255, 255, 255, 0.9); box-shadow: var(--box-shadow); width: 90%; min-width: 85%; font-size: 1.6vw; position: absolute; bottom: 3vh; left: 50%; transform: translateX(-50%); } .visual-novel-box:has(.character-name) { padding-top: 1vh; } .visual-novel-box.hidden { opacity: 0; pointer-events: none; }"; const CHARACTER_STYLES: &str = r" .character-name, .choice-name { font-family: 'Georgia', serif; font-size: 1.8vw; font-weight: bold; padding: 0 1.5vw; background: var(--character-bg); color: white; border-radius: 0.5vw; margin: 0; transform: translateY(-60%); width: max-content; position: absolute; top: 0; left: 1vw; } .dialog-content { font-size: 1.6vw; line-height: 1.4; max-height: 20vh; overflow-y: auto; padding: 0.5vh 0.5vw; }"; const CHOICE_STYLES: &str = r" .choices-section { position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 3; padding: 2vw; overflow-y: auto; align-items: center; } .choice-box { width: 90%; min-width: 85%; border: 0.2vw solid var(--border-color); border-radius: var(--border-radius); margin: 1vh 0; background: rgba(255, 255, 255, 0.9); box-shadow: var(--box-shadow); transition: all var(--transition-duration) ease; position: relative; transform: none; } .choice-box:hover { transform: translateY(-0.5vh); box-shadow: 0 0.6vw 0.8vw rgba(0, 0, 0, 0.15); } .choice-box:has(.choice-name) { padding-top: 1vh; } .choice-button { width: 100%; background: none; border: none; padding: 0; font-size: 1.6vw; line-height: 1.4; cursor: pointer; color: #333; text-align: left; font-family: inherit; }"; const END_STYLES: &str = r" .end-screen { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; background: rgba(0, 0, 0, 0.9); color: white; padding: 2rem; border-radius: 1rem; max-width: 600px; } .restart-button { background: var(--character-bg); color: white; padding: 1rem 2rem; border: none; border-radius: 0.5rem; font-size: 1.2rem; cursor: pointer; transition: transform 0.2s; }"; html! { style { (maud::PreEscaped( [BASE_STYLES, NAVIGATION, SCENE_STYLES, TEXTBOX_STYLES, CHARACTER_STYLES, CHOICE_STYLES, END_STYLES] .join("") )) } } } fn interactive_script(total_sections: usize, base_path: &str) -> Markup { html! { script { (maud::PreEscaped(format!(r#" let currentScene = 0; const totalSections = {total_sections}; let textVisible = true; function toggleText(visible) {{ document.querySelectorAll('.visual-novel-box').forEach(box => {{ box.classList.toggle('hidden', !visible); }}); textVisible = visible; }} let currentAudio = null; function playCurrentAudio() {{ const section = document.querySelector( `.selection-section[data-section-index="${{currentScene}}"]` ); const audioElement = section?.querySelector('audio'); if(audioElement) {{ if(currentAudio) currentAudio.pause(); audioElement.currentTime = 0; audioElement.play(); currentAudio = audioElement; }} }} function updateSection() {{ document.querySelectorAll('.selection-section').forEach((el, index) => {{ el.style.display = index === currentScene ? 'block' : 'none'; }}); toggleText(true); document.getElementById('section-counter').textContent = `${{currentScene + 1}}/${{totalSections}}`; if (currentScene + 1 < totalSections) playCurrentAudio(); }} function prev() {{ if (currentScene > 0) currentScene--; updateSection(); }} function next() {{ if (currentScene < totalSections - 1) currentScene++; updateSection(); }} function handleKeys(e) {{ switch(e.key) {{ case 'ArrowLeft': prev(); break; case 'ArrowRight': next(); break; case 'ArrowDown': e.preventDefault(); toggleText(false); break; case 'ArrowUp': e.preventDefault(); toggleText(true); break; }} }} document.addEventListener('keydown', handleKeys); toggleText(true); document.addEventListener('DOMContentLoaded', () => {{ playCurrentAudio(); Array({total_sections}).fill().forEach((_, i) => {{ if(i > 0) {{ new Audio(`{base_path}.${{i}}.mp3`).preload = 'metadata'; }} }}); }}); "#))) } } } fn process_dialog( dialogs: &[DialogSequence], choice: usize, player_settings: &mut PlayerSettings, sections: &mut Vec, base_path: &str, ) { let mut start_index = 0; for dialog in &dialogs[0..choice] { start_index += dialog.blocks.len(); } let dialog_sequence = &dialogs[choice]; let mut states = initialize_change_states(&dialog_sequence.changes); for (i, block) in dialog_sequence.blocks.iter().enumerate() { apply_block_changes( block, &dialog_sequence.changes, &mut states, player_settings, ); sections.push(html! { (render_scene(player_settings, &block.name)) (render_dialog_block(block)) audio { source src=(format!("/{base_path}.{}.mp3", start_index + i)) type="audio/mpeg"; } }); } player_settings.reset(); } fn initialize_change_states( changes: &HashMap>, ) -> HashMap { changes.keys().map(|k| (k.clone(), 0)).collect() } fn apply_block_changes( block: &DialogBlock, changes: &HashMap>, states: &mut HashMap, settings: &mut PlayerSettings, ) { for parameter in block .lines .iter() .flat_map(|l| &l.actions) .chain(&block.final_actions) { if let Some(state) = states.get_mut(parameter) { if let Some(change) = changes[parameter].get(*state) { settings.change(change); *state += 1; } } } } fn load_multilinear(mlc_path: Option<&Path>, mld_path: &Path) -> Option { let mut parser = MultilinearParser::default(); if let Some(mlc_path) = mlc_path { if let Ok(file) = File::open(mlc_path) { for line in BufReader::new(file).lines() { let Ok(line) = line else { break; }; if let Some((channel, default_value)) = line.split_once(':') { let _ = parser.add_new_channel(channel, default_value); continue; } let line = line.trim(); if line.is_empty() { continue; } return None; } } } if let Err(e) = parser.parse(File::open(mld_path).ok()?, &[]) { eprintln!("Error parsing multilinear definition: {e}"); return None; } Some(parser.into_info()) } pub fn render_novel( mut config_map: IndexMap, Box>, pk_path: &Path, mlc_path: Option<&Path>, mld_path: &Path, base_path: &str, stream: &mut TcpStream, mut choice: usize, progress: &str, ) -> Result<(), ParsingError> { let mut settings_context = SettingsContext::new(); extract_layers(&mut settings_context.layers, &mut config_map); let mut player_settings = PlayerSettings::common(); player_settings.extract_settings(&mut settings_context, &mut config_map); let dialogs = parse_map(pk_path, &mut settings_context)?; let mut sections = Vec::new(); process_dialog( &dialogs, choice, &mut player_settings, &mut sections, base_path, ); if let Some(named_multilinear_info) = load_multilinear(mlc_path, mld_path) { let multilinear_info = &named_multilinear_info.info; let count = named_multilinear_info.channels.into_iter().count(); let mut data = vec![0; count]; for (i, num) in progress.split('+').enumerate() { if i == count { break; } if let Ok(num) = num.parse() { data[i] = num; } } let mut simulation = BorrowedMultilinearSimulation::from_data(multilinear_info, data).unwrap(); loop { simulation.try_call(Event(choice)); let progress: String = simulation .data() .iter() .map(|i| format!("{i}")) .collect::>() .join(" "); let mut choices = Vec::new(); for (i, dialog_sequence) in dialogs.iter().enumerate() { if let Some(block) = dialog_sequence.blocks.first() { if simulation.callable(Event(i)) { choices.push((i, block)) } } } if choices.len() == 1 { let next_choice = choices[0].0; if next_choice != choice { choice = next_choice; process_dialog( &dialogs, choice, &mut player_settings, &mut sections, base_path, ); continue; } } let choices_html = if choices.is_empty() { render_ending() } else { html! { div .choices-section { @for choice in choices { (render_choice(choice.1, choice.0, &progress)) } } } }; sections.push(choices_html); break; } } let html = generate_html(sections, base_path); let _ = write!(stream, "{}", html.into_string()); Ok(()) }