use std::{collections::HashMap, fs::File, io::prelude::*, net::TcpStream, path::Path}; use indexmap::IndexMap; use maud::{Markup, html}; use multilinear_parser::{NamedMultilinearInfo, parse_multilinear}; use pukram2html::convert_subheader; use vn_settings::{Change, Parameter, PlayerSettings, SettingsContext, extract_layers}; use crate::dialog::parse_map; fn render_scene(settings: &PlayerSettings, name: &str) -> Markup { html! { @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) { div style="position:relative; height:100%; width:100%; overflow:hidden" { img src=(image_path) style="height:100%; width:auto; object-fit:contain; position:absolute; left:50%; transform:translateX(-50%)"; } } } } } } } fn navigation_controls(total_scenes: usize) -> Markup { html! { div class="nav-controls" { button class="nav-button" onclick="prev()" { "←" } span id="section-counter" { "1/" (total_scenes) } button class="nav-button" onclick="next()" { "→" } } } } fn generate_html(mut scenes: Vec, sections: Vec, choices: Markup) -> Markup { scenes.push(html! { div class="choices-section" { (choices) } }); let total_scenes = scenes.len(); html! { div id="story-container" { div class="textbox-container" { div class="scene-container" { @for (index, scene) in scenes.iter().enumerate() { div class="scene-section" data-section-index=(index) style=(format!("display: {};", if index == 0 { "block" } else { "none" })) { div class="scene-content" { (scene) } } } } div class="textbox-content" { @for (index, section) in sections.into_iter().enumerate() { div class="story-section" data-section-index=(index) style=(format!("display: {};", if index == 0 { "block" } else { "none" })) { (section) } } } } (navigation_controls(total_scenes)) (global_styles()) (interactive_script(total_scenes)) } } } fn global_styles() -> Markup { html! { style { (maud::PreEscaped(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 0.2s 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); } .textbox-container { width: 75%; margin: 0 auto; position: relative; padding-top: 42.1875%; /* 16:9 Aspect Ratio */ background: #f8f8f8; border-radius: 8px; overflow: hidden; } .scene-container { position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 1; } .scene-content { width: 100%; height: 100%; position: relative; overflow: hidden; } .scene-section { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: none; aspect-ratio: 16/9; } .scene-section[style*='block'] { 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%); } .textbox-content { position: absolute; bottom: 0; left: 0; right: 0; z-index: 2; background: linear-gradient(to top, rgba(0,0,0,0.5) 0%, transparent 30%); padding: 2rem; } .visual-novel-box { border: 0.2vw solid #3a3a3a; border-radius: 1vw; padding: 2vw; margin: 1vh 0; background: rgba(255, 255, 255, 0.9); box-shadow: 0 0.4vw 0.6vw rgba(0, 0, 0, 0.1); width: 90%; min-width: 85%; font-size: 1.6vw; position: absolute; bottom: 3vh; left: 50%; transform: translateX(-50%); } .character-name { font-family: 'Georgia', serif; font-size: 1.8vw; font-weight: bold; padding: 0 1.5vw; background: #4a6b8a; color: white; border-radius: 0.5vw; margin: 0; transform: translateY(-60%); position: absolute; top: 0; left: 1vw; } .dialog-content { font-size: 1.6vw; line-height: 1.4; max-height: 20vh; overflow-y: auto; padding: 1vh 0.5vw; } .visual-novel-box::after { content: ''; position: absolute; bottom: 10px; right: 10px; width: 20px; height: 20px; opacity: 0.5; } .choices-section { position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 3; background: rgba(0, 0, 0, 0.85); padding: 2rem; overflow-y: auto; display: flex; flex-direction: column; align-items: center; } .choice-box:hover { transform: translateX(-50%) translateY(-0.5vh); box-shadow: 0 0.6vw 0.8vw rgba(0, 0, 0, 0.15); } .choices-section input[type='submit'] { width: 100%; padding: 1rem; border: none; background: none; cursor: pointer; font-size: 1.1rem; text-align: left; } .choice-box { width: 90%; min-width: 85%; border: 0.2vw solid #3a3a3a; border-radius: 1vw; padding: 2vw; margin: 1vh auto; background: rgba(255, 255, 255, 0.9); box-shadow: 0 0.4vw 0.6vw rgba(0, 0, 0, 0.1); position: relative; transform: translateX(-50%); left: 50%; } .choice-button { width: 100%; background: none; border: none; padding: 1.5vh 2vw; font-size: 1.6vw; line-height: 1.4; cursor: pointer; color: #333; text-align: left; font-family: inherit; } @media (max-width: 768px) { .choices-section { padding: 1rem; } .choice-box { width: 95%; padding: 3vw; } .choice-button { font-size: 1.2rem; padding: 1rem; } } ")) } } } fn interactive_script(total_scenes: usize) -> Markup { html! { script { (maud::PreEscaped(format!(r" let currentScene = 0; const totalScenes = {total_scenes}; function updateSection() {{ document.querySelectorAll('.story-section').forEach((el, index) => {{ el.style.display = index === currentScene ? 'block' : 'none'; }}); document.querySelectorAll('.scene-section').forEach((el, index) => {{ el.style.display = index === currentScene ? 'block' : 'none'; }}); document.getElementById('section-counter').textContent = `${{currentScene + 1}}/${{totalScenes}}`; }} function prev() {{ if (currentScene > 0) currentScene--; updateSection(); }} function next() {{ if (currentScene < totalScenes - 1) currentScene++; updateSection(); }} document.addEventListener('keydown', (e) => {{ if(e.key === 'ArrowLeft') prev(); if(e.key === 'ArrowRight') next(); }}); "))) } } } fn load_multilinear(mld_path: &Path) -> Option { let Ok(file) = File::open(mld_path) else { return None; }; parse_multilinear(file).ok() } pub fn render_novel( mut config_map: IndexMap, Box>, pk_path: &Path, mld_path: &Path, stream: &mut TcpStream, start_level: usize, choice: usize, progress: &str, ) -> Result<(), dialogi::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 named_multilinear_info = load_multilinear(mld_path); let named_multilinear_info = named_multilinear_info.as_ref(); let dialogs = parse_map(pk_path, &mut settings_context)?; let (scenes, sections) = process_dialog(&dialogs[choice], &mut player_settings, start_level); let mut choices_html = html! {}; if let Some(_named_multilinear_info) = named_multilinear_info { choices_html = html! { div class="choices-section" { @for (i, dialog_sequence) in dialogs.iter().enumerate() { @if let Some(block) = dialog_sequence.blocks.first() { div class="choice-box" { form method="POST" { input type="hidden" name="progress" value=(progress); input type="hidden" name="choice" value=(i); button type="submit" class="choice-button" { (block.lines[0].text) } } } } } } }; } let html = generate_html(scenes, sections, choices_html); let _ = write!(stream, "{}", html.into_string()); Ok(()) } fn process_dialog( dialog_sequence: &dialogi::DialogSequence, player_settings: &mut PlayerSettings, start_level: usize, ) -> (Vec, Vec) { let mut scenes = Vec::new(); let mut sections = Vec::new(); let mut states = initialize_change_states(&dialog_sequence.changes); for block in &dialog_sequence.blocks { apply_block_changes( block, &dialog_sequence.changes, &mut states, player_settings, ); scenes.push(render_scene(player_settings, &block.name)); sections.push(render_dialog_block(block, start_level)); } player_settings.reset(); (scenes, sections) } fn initialize_change_states( changes: &HashMap>, ) -> HashMap { changes.keys().map(|k| (k.clone(), 0)).collect() } fn apply_block_changes( block: &dialogi::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 render_dialog_block(block: &dialogi::DialogBlock, start_level: usize) -> Markup { if block.lines.is_empty() { return html! {}; } let mut content = Vec::new(); convert_subheader( block.lines.iter().map(|l| l.text.as_ref()), &mut content, start_level, ); html! { fieldset class="visual-novel-box" { @if !block.name.is_empty() { legend class="character-name" { (block.name) } } div class="dialog-content" { @match String::from_utf8(content) { Ok(text) => (maud::PreEscaped(text)), Err(e) => (maud::PreEscaped(format!("Error: {}", e))), } } } } }