511 lines
15 KiB
Rust
511 lines
15 KiB
Rust
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<Markup>, sections: Vec<Markup>, 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 {
|
|
let base_styles = 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;
|
|
}";
|
|
|
|
let navigation = 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;
|
|
}
|
|
.nav-button:hover {
|
|
background: #e0e0e0;
|
|
transform: scale(1.1);
|
|
}
|
|
.nav-button:active {
|
|
transform: scale(0.95);
|
|
}
|
|
#section-counter {
|
|
font-family: system-ui, sans-serif;
|
|
font-size: 0.9rem;
|
|
color: #666;
|
|
margin: 0 1rem;
|
|
padding: 0;
|
|
line-height: 1;
|
|
}";
|
|
|
|
let container_styles = r"
|
|
.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;
|
|
}";
|
|
|
|
let scene_styles = r"
|
|
.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-content {
|
|
width: 100%;
|
|
height: 100%;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
.scene-section img {
|
|
height: 100%;
|
|
width: auto;
|
|
object-fit: contain;
|
|
position: absolute;
|
|
top: 0;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
}";
|
|
|
|
let textbox_styles = r"
|
|
.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 var(--border-color);
|
|
border-radius: var(--border-radius);
|
|
padding: 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::after {
|
|
content: '';
|
|
position: absolute;
|
|
bottom: 10px;
|
|
right: 10px;
|
|
width: 20px;
|
|
height: 20px;
|
|
opacity: 0.5;
|
|
}";
|
|
|
|
let character_styles = 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;
|
|
}
|
|
.character-name {
|
|
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;
|
|
}";
|
|
|
|
let choice_styles = r"
|
|
.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 {
|
|
width: 90%;
|
|
min-width: 85%;
|
|
border: 0.2vw solid var(--border-color);
|
|
border-radius: var(--border-radius);
|
|
padding: 2vw;
|
|
margin: 1vh auto;
|
|
background: rgba(255, 255, 255, 0.9);
|
|
box-shadow: var(--box-shadow);
|
|
position: relative;
|
|
transform: translateX(-50%);
|
|
left: 50%;
|
|
transition: all var(--transition-duration) ease;
|
|
}
|
|
.choice-box:hover {
|
|
transform: translateX(-50%) translateY(-0.5vh);
|
|
box-shadow: 0 0.6vw 0.8vw rgba(0, 0, 0, 0.15);
|
|
}
|
|
.choice-box:has(.choice-name) {
|
|
padding-top: 2.5vw;
|
|
}
|
|
.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;
|
|
}
|
|
.choice-name {
|
|
margin: -1.2vw 0 1vw 0;
|
|
position: relative;
|
|
z-index: 1;
|
|
}";
|
|
|
|
let responsive = r"
|
|
@media (max-width: 768px) {
|
|
.choice-name {
|
|
font-size: 1.2rem;
|
|
margin: -1rem 0 0.8rem 0;
|
|
transform: translateY(-40%);
|
|
}
|
|
.choices-section {
|
|
padding: 1rem;
|
|
}
|
|
.choice-box {
|
|
width: 95%;
|
|
padding: 3vw;
|
|
}
|
|
.choice-button {
|
|
font-size: 1.2rem;
|
|
padding: 1rem;
|
|
}
|
|
}";
|
|
|
|
html! {
|
|
style {
|
|
(maud::PreEscaped([
|
|
base_styles,
|
|
navigation,
|
|
container_styles,
|
|
scene_styles,
|
|
textbox_styles,
|
|
character_styles,
|
|
choice_styles,
|
|
responsive
|
|
].join("")))
|
|
}
|
|
}
|
|
}
|
|
|
|
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<NamedMultilinearInfo> {
|
|
let Ok(file) = File::open(mld_path) else {
|
|
return None;
|
|
};
|
|
|
|
parse_multilinear(file).ok()
|
|
}
|
|
|
|
pub fn render_novel(
|
|
mut config_map: IndexMap<Box<str>, Box<str>>,
|
|
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" {
|
|
@if !block.name.is_empty() {
|
|
div class="choice-name" {
|
|
(block.name)
|
|
}
|
|
}
|
|
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<Change, Parameter>,
|
|
player_settings: &mut PlayerSettings,
|
|
start_level: usize,
|
|
) -> (Vec<Markup>, Vec<Markup>) {
|
|
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<Parameter, Vec<Change>>,
|
|
) -> HashMap<Parameter, usize> {
|
|
changes.keys().map(|k| (k.clone(), 0)).collect()
|
|
}
|
|
|
|
fn apply_block_changes(
|
|
block: &dialogi::DialogBlock<Parameter>,
|
|
changes: &HashMap<Parameter, Vec<Change>>,
|
|
states: &mut HashMap<Parameter, usize>,
|
|
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<Parameter>, 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))),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|