pukram-server/src/vn.rs

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(())
}