pukram-server/src/vn.rs
2025-04-27 18:46:11 +02:00

607 lines
18 KiB
Rust

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<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 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<Markup>, 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<Change, Parameter>],
choice: usize,
player_settings: &mut PlayerSettings,
sections: &mut Vec<Markup>,
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<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(mlc_path: Option<&Path>, mld_path: &Path) -> Option<NamedMultilinearInfo> {
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<str>, Box<str>>,
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::<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((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(())
}