diff --git a/src/vn.rs b/src/vn.rs index 634cf38..297e16d 100644 --- a/src/vn.rs +++ b/src/vn.rs @@ -2,40 +2,284 @@ use std::{collections::HashMap, io::prelude::*, net::TcpStream, path::Path}; use dialogi::DialogParameter; use indexmap::IndexMap; -use maud::html; +use maud::{Markup, html}; use pukram2html::convert_subheader; use vn_settings::{ - ColorSettings, ImageSettings, LayoutSettings, Names, ObjectSettings, Parameter, PlayerSettings, - SettingsContext, TimingSettings, + Change, ColorSettings, ImageSettings, LayoutSettings, Names, ObjectSettings, Parameter, + PlayerSettings, SettingsContext, TimingSettings, }; use crate::dialog::parse_map; -fn render_scene(settings: &PlayerSettings, name: &str, output: &mut Vec) { - for object in &settings.objects.objects { - if let Some(image_set) = object.image.get(name) { - for image in &settings.images.images[image_set] { - let Some(image) = image.get_ref(name) else { - continue; - }; - - let _ = write!( - output, - r#"
- -
"#, - ); +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_sections: usize) -> Markup { + html! { + div class="nav-controls" { + button class="nav-button" onclick="prev()" { "←" } + span id="section-counter" { "1/" (total_sections) } + button class="nav-button" onclick="next()" { "→" } + } + } +} + +fn generate_html(scenes: Vec, sections: Vec) -> Markup { + let total_sections = sections.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" { + (maud::PreEscaped(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_sections)) + (global_styles()) + (interactive_script(total_sections)) + } + } +} + +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; + } + ")) + } + } +} + +fn interactive_script(total_sections: usize) -> Markup { + html! { + script { + (maud::PreEscaped(format!(r" + let currentSection = 0; + const totalSections = {total_sections}; + + function updateSection() {{ + document.querySelectorAll('.story-section').forEach((el, index) => {{ + el.style.display = index === currentSection ? 'block' : 'none'; + }}); + document.querySelectorAll('.scene-section').forEach((el, index) => {{ + el.style.display = index === currentSection ? 'block' : 'none'; + }}); + document.getElementById('section-counter').textContent = + `${{currentSection + 1}}/${{totalSections}}`; + }} + + function prev() {{ + if (currentSection > 0) currentSection--; + updateSection(); + }} + + function next() {{ + if (currentSection < totalSections - 1) currentSection++; + updateSection(); + }} + + document.addEventListener('keydown', (e) => {{ + if(e.key === 'ArrowLeft') prev(); + if(e.key === 'ArrowRight') next(); + }}); + "))) + } + } +} + pub fn render_novel( mut config_map: IndexMap, Box>, pk_path: &Path, stream: &mut TcpStream, start_level: usize, -) -> std::result::Result<(), dialogi::ParsingError> { +) -> Result<(), dialogi::ParsingError> { let mut player_settings = PlayerSettings { colors: ColorSettings::common(), timing: TimingSettings::common(), @@ -45,6 +289,24 @@ pub fn render_novel( names: Names::new(), }; + let layers = process_layers(&mut config_map); + let mut settings_context = SettingsContext { + object_cache: HashMap::new(), + layers, + }; + + apply_config_settings(&mut config_map, &mut settings_context, &mut player_settings); + + let dialogs = parse_map(pk_path, &mut settings_context)?; + let (scenes, sections) = process_dialogs(dialogs, &mut player_settings, start_level); + + let html = generate_html(scenes, sections); + let _ = write!(stream, "{}", html.into_string()); + + Ok(()) +} + +fn process_layers(config_map: &mut IndexMap, Box>) -> HashMap, usize> { let mut layers = HashMap::new(); let keys: Vec<_> = config_map.keys().cloned().collect(); @@ -65,313 +327,101 @@ pub fn render_novel( layers.insert("Character".into(), 1); } - let mut settings_context = SettingsContext { - object_cache: HashMap::new(), - layers, - }; + layers +} - for (key, value) in config_map { - if let Some(parameter) = Parameter::create(&key, &mut settings_context) { - let setter = parameter.value_setter(&value, &settings_context); - player_settings.set_character_default(setter); +fn apply_config_settings( + config_map: &mut IndexMap, Box>, + context: &mut SettingsContext, + settings: &mut PlayerSettings, +) { + for (key, value) in config_map.drain(..) { + if let Some(parameter) = Parameter::create(&key, context) { + let setter = parameter.value_setter(&value, context); + settings.set_character_default(setter); } } +} - let dialogs = parse_map(pk_path, &mut settings_context)?; - +fn process_dialogs( + dialogs: Vec>, + player_settings: &mut PlayerSettings, + start_level: usize, +) -> (Vec, Vec) { let mut scenes = Vec::new(); let mut sections = Vec::new(); for dialog_sequence in dialogs { - let changes = &dialog_sequence.changes; - let mut states = HashMap::new(); - for parameter in changes.keys() { - states.insert(parameter.clone(), 0); - } + let mut states = initialize_change_states(&dialog_sequence.changes); - for block in &dialog_sequence.blocks { - let mut block_content = Vec::new(); + for block in dialog_sequence.blocks { + apply_block_changes( + &block, + &dialog_sequence.changes, + &mut states, + player_settings, + ); - for parameter in block - .lines - .iter() - .flat_map(|l| &l.actions) - .chain(&block.final_actions) - { - if let Some(state) = states.get_mut(parameter) { - let change = &dialog_sequence.changes[parameter][*state]; - player_settings.change(change); - *state += 1; - } - } - - let mut scene_data = Vec::new(); - render_scene(&player_settings, &block.name, &mut scene_data); - scenes.push(unsafe { String::from_utf8_unchecked(scene_data) }); - - let section_html = if block.lines.is_empty() { - html! {} - } else { - convert_subheader( - block.lines.iter().map(|l| l.text.as_ref()), - &mut block_content, - start_level, - ); - - html! { - fieldset class="visual-novel-box" { - @if !block.name.is_empty() { - legend class="character-name" { (block.name) } - } - div class="dialog-content" { - @if let Ok(content) = String::from_utf8(block_content) { - (maud::PreEscaped(content)) - } - } - } - } - }; - - sections.push(section_html); + scenes.push(render_scene(player_settings, &block.name).into_string()); + sections.push(render_dialog_block(&block, start_level)); } player_settings.reset(); } - let html = 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" { - (maud::PreEscaped(scene)) - } - } - } - } + (scenes, sections) +} - div class="textbox-content" { - @for (index, section) in sections.iter().enumerate() { - div class="story-section" data-section-index=(index) - style=(format!("display: {};", if index == 0 { "block" } else { "none" })) { - (section) - } - } - } - } +fn initialize_change_states( + changes: &HashMap>, +) -> HashMap { + changes.keys().map(|k| (k.clone(), 0)).collect() +} - div class="nav-controls" { - button class="nav-button" onclick="prev()" { "←" } - span id="section-counter" { "1/" (sections.len()) } - button class="nav-button" onclick="next()" { "→" } - } - - 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; - } - - .dialogue-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; - } - ")) - } - - script { - (maud::PreEscaped(r" - let currentSection = 0; - const totalSections = ".to_owned() + §ions.len().to_string() + r"; - - function updateSection() { - document.querySelectorAll('.story-section').forEach((el, index) => { - el.style.display = index === currentSection ? 'block' : 'none'; - }); - document.querySelectorAll('.scene-section').forEach((el, index) => { - el.style.display = index === currentSection ? 'block' : 'none'; - }); - document.getElementById('section-counter').textContent = - `${currentSection + 1}/${totalSections}`; - } - - function prev() { - if (currentSection > 0) { - --currentSection; - updateSection(); - } - } - - function next() { - if (currentSection < totalSections - 1) { - ++currentSection; - updateSection(); - } - } - - document.addEventListener('keydown', (e) => { - switch(e.keyCode) { - case 37: - prev(); - break; - case 39: - next(); - break; - } - }); - ")) +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; } } - }; - - let _ = write!(stream, "{}", html.into_string()); - - Ok(()) + } +} + +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))), + } + } + } + } }