pukram-server/src/vn.rs
2025-04-20 23:17:17 +02:00

490 lines
16 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 {
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<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" {
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))),
}
}
}
}
}