475 lines
14 KiB
Rust
475 lines
14 KiB
Rust
use std::{collections::HashMap, fs::File, io::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::{NamedMultilinearInfo, parse_multilinear};
|
|
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<Parameter>, 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<Parameter>) -> 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 {
|
|
@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 generate_html(sections: Vec<Markup>) -> 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))
|
|
}
|
|
}
|
|
}
|
|
|
|
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::after {
|
|
content: '';
|
|
position: absolute;
|
|
bottom: 10px;
|
|
right: 10px;
|
|
width: 20px;
|
|
height: 20px;
|
|
opacity: 0.5;
|
|
}
|
|
.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;
|
|
}";
|
|
|
|
html! {
|
|
style {
|
|
(maud::PreEscaped(
|
|
[BASE_STYLES, NAVIGATION, SCENE_STYLES, TEXTBOX_STYLES, CHARACTER_STYLES, CHOICE_STYLES]
|
|
.join("")
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn interactive_script(total_sections: usize) -> 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;
|
|
}}
|
|
|
|
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}}`;
|
|
}}
|
|
|
|
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);
|
|
")))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn process_dialog(
|
|
dialog_sequence: &DialogSequence<Change, Parameter>,
|
|
player_settings: &mut PlayerSettings,
|
|
) -> Vec<Markup> {
|
|
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,
|
|
);
|
|
|
|
sections.push(html! {
|
|
(render_scene(player_settings, &block.name))
|
|
(render_dialog_block(block))
|
|
});
|
|
}
|
|
|
|
player_settings.reset();
|
|
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: &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 load_multilinear(mld_path: &Path) -> Option<NamedMultilinearInfo> {
|
|
parse_multilinear(File::open(mld_path).ok()?).ok()
|
|
}
|
|
|
|
pub fn render_novel(
|
|
mut config_map: IndexMap<Box<str>, Box<str>>,
|
|
pk_path: &Path,
|
|
mld_path: &Path,
|
|
stream: &mut TcpStream,
|
|
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 = process_dialog(&dialogs[choice], &mut player_settings);
|
|
|
|
if let Some(named_multilinear_info) = load_multilinear(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();
|
|
simulation.try_call(Event(choice));
|
|
let progress: String = simulation
|
|
.data()
|
|
.iter()
|
|
.map(|i| format!("{i}"))
|
|
.collect::<Vec<_>>()
|
|
.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(render_choice(block, i, &progress))
|
|
}
|
|
}
|
|
}
|
|
|
|
if !choices.is_empty() {
|
|
let choices_html = html! {
|
|
div .choices-section {
|
|
@for choice in choices {
|
|
(choice)
|
|
}
|
|
}
|
|
};
|
|
sections.push(choices_html);
|
|
}
|
|
}
|
|
|
|
let html = generate_html(sections);
|
|
let _ = write!(stream, "{}", html.into_string());
|
|
|
|
Ok(())
|
|
}
|