use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::time::Instant;
use crate::components::{
CueKind, FrameInput, InputKey, PlayCue, ScreenCommand, ScreenShown, Sprite, Story,
StoryCommand, StoryCompareOp, StoryGate, StoryImage, StoryOp, StoryReload, StoryScaffold,
StoryStage, TextLabel,
};
use crate::ecs::asset_id::AssetId;
use crate::ecs::{AudioClipHandle, PipelineContext, StepResult, System};
mod graph;
mod input;
mod modes;
mod overlay;
mod reload;
mod render;
mod save;
mod title;
#[cfg(test)]
mod tests;
const MENU_CENTER_X: f32 = 640.0;
const TITLE_MENU_CENTER_Y: f32 = 500.0;
const TITLE_MENU_SPACING: f32 = 62.0;
const CHOICE_BOX_TINT: [f32; 4] = [0.16, 0.20, 0.35, 0.92];
const QUICK_ACTIVE: [f32; 3] = [1.0, 0.85, 0.3];
const QUICK_IDLE: [f32; 3] = [0.75, 0.75, 0.75];
const OVERLAY_DIM_ALPHA: f32 = 0.85;
const AUTO_BASE_SECS: f32 = 0.8;
const AUTO_PER_CHAR_SECS: f32 = 0.035;
const SKIP_PAGE_SECS: f32 = 0.15;
const BACKLOG_ENTRIES: usize = 100;
const BACKLOG_LINES: usize = 20;
const SLOT_COUNT: usize = 10;
const SLOT_SCROLL_UNIT: f32 = 20.0;
struct StageIds {
screen: AssetId,
ending_screen: AssetId,
bg: Option<AssetId>,
left: Option<AssetId>,
center: Option<AssetId>,
right: Option<AssetId>,
dialog_box: Option<AssetId>,
name: Option<AssetId>,
text: Option<AssetId>,
option_boxes: Vec<AssetId>,
options: Vec<AssetId>,
start_label: Option<AssetId>,
quit_label: Option<AssetId>,
continue_label: Option<AssetId>,
title_screen_id: Option<AssetId>,
load_label: Option<AssetId>,
pause_view: Option<AssetId>,
settings_screen: Option<AssetId>,
settings_label: Option<AssetId>,
marker: Option<AssetId>,
log_label: Option<AssetId>,
auto_label: Option<AssetId>,
skip_label: Option<AssetId>,
save_label: Option<AssetId>,
overlay_dim: Option<AssetId>,
backlog_label: Option<AssetId>,
slot_title: Option<AssetId>,
slot_boxes: Vec<AssetId>,
slot_labels: Vec<AssetId>,
}
impl StageIds {
fn from_scaffold(scaffold: &StoryScaffold) -> Option<Self> {
Some(Self {
screen: scaffold.screen?,
ending_screen: scaffold.ending?,
bg: scaffold.bg,
left: scaffold.left,
center: scaffold.center,
right: scaffold.right,
dialog_box: scaffold.dialog_box,
name: scaffold.name_label,
text: scaffold.text_label,
option_boxes: scaffold.option_boxes.clone(),
options: scaffold.options.clone(),
start_label: scaffold.start_label,
quit_label: scaffold.quit_label,
continue_label: scaffold.continue_label,
title_screen_id: scaffold.title,
load_label: scaffold.load_label,
pause_view: scaffold.pause,
settings_screen: scaffold.settings,
settings_label: scaffold.settings_label,
marker: scaffold.advance_marker,
log_label: scaffold.log_label,
auto_label: scaffold.auto_label,
skip_label: scaffold.skip_label,
save_label: scaffold.save_label,
overlay_dim: scaffold.overlay_dim,
backlog_label: scaffold.backlog_label,
slot_title: scaffold.slot_title,
slot_boxes: scaffold.slot_boxes.clone(),
slot_labels: scaffold.slot_labels.clone(),
})
}
}
#[derive(serde::Serialize, serde::Deserialize)]
struct StorySave {
slug: String,
page: u32,
#[serde(default)]
vars: BTreeMap<String, i32>,
}
fn save_file(dir: &Path) -> PathBuf {
dir.join("auto")
}
fn slot_file(dir: &Path, slot: usize) -> PathBuf {
dir.join(format!("save{}", slot + 1))
}
fn read_save(path: &Path) -> Option<StorySave> {
crate::cbor_file::read(path, "StorySystem: save")
}
fn write_save(path: &Path, save: &StorySave) -> std::io::Result<()> {
crate::cbor_file::write(path, save)
}
#[derive(Default)]
struct Typewriter {
full: Vec<char>,
shown: usize,
budget: f32,
}
impl Typewriter {
fn done(&self) -> bool {
self.shown >= self.full.len()
}
fn text(&self) -> String {
self.full[..self.shown].iter().collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Overlay {
None,
Backlog,
SaveMenu,
LoadMenu,
}
pub(crate) struct StorySystem {
story: Story,
ids: Option<StageIds>,
node: usize,
page: usize,
started: bool,
in_choice: bool,
vars: HashMap<String, i32>,
save_dir: Option<PathBuf>,
menu: Vec<usize>,
typewriter: Typewriter,
last_step: Option<Instant>,
elapsed: f32,
auto: bool,
skip: bool,
hold_skip: bool,
mode_timer: f32,
overlay: Overlay,
slot_scroll: usize,
slot_scroll_accum: f32,
history: Vec<String>,
active_screen: Option<AssetId>,
settings_return: Option<AssetId>,
command_cursor: crate::ecs::EventCursor,
view_shown_cursor: crate::ecs::EventCursor,
reload_cursor: crate::ecs::EventCursor,
}
impl std::fmt::Debug for StorySystem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StorySystem")
.field("nodes", &self.story.nodes.len())
.field("node", &self.node)
.field("page", &self.page)
.field("started", &self.started)
.finish()
}
}
impl StorySystem {
pub(crate) fn new(
story: Story,
tree: Option<&concinnity_host::store::paths::StateTree>,
) -> Self {
Self {
story,
ids: None,
node: 0,
page: 0,
started: false,
in_choice: false,
vars: HashMap::new(),
save_dir: tree.map(|tree| tree.saves_dir()),
menu: Vec::new(),
typewriter: Typewriter::default(),
last_step: None,
elapsed: 0.0,
auto: false,
skip: false,
hold_skip: false,
mode_timer: 0.0,
overlay: Overlay::None,
slot_scroll: 0,
slot_scroll_accum: 0.0,
history: Vec::new(),
active_screen: None,
settings_return: None,
command_cursor: crate::ecs::EventCursor::default(),
view_shown_cursor: crate::ecs::EventCursor::default(),
reload_cursor: crate::ecs::EventCursor::default(),
}
}
}
impl System for StorySystem {
fn access(&self) -> crate::ecs::Access {
crate::ecs::Access::new()
.reads_components(crate::component_mask![crate::components::FrameInput])
.writes_components(crate::component_mask![
crate::components::TextLabel,
crate::components::Sprite,
])
.reads_resources(crate::resource_mask![
crate::components::StoryReload,
crate::components::ScreenShown,
crate::components::StoryCommand,
])
.writes_resources(crate::resource_mask![
crate::components::PlayCue,
crate::components::ScreenCommand,
])
}
fn init(&mut self, ctx: &mut PipelineContext) {
if ctx
.resource::<crate::ecs::TransientSaves>()
.is_some_and(|t| t.0)
{
self.save_dir = ctx
.resource::<concinnity_host::store::paths::StateTree>()
.map(|tree| tree.preview_saves_dir());
if let Some(dir) = &self.save_dir {
let _ = std::fs::remove_dir_all(dir);
}
}
match StageIds::from_scaffold(&self.story.scaffold) {
Some(ids) => {
self.ids = Some(ids);
}
None => {
tracing::warn!(
"StorySystem: story '{}' has no stage scaffold; story input is ignored",
self.story.title,
);
}
}
tracing::info!(
"StorySystem: '{}', {} node(s), text speed {} cps",
self.story.title,
self.story.nodes.len(),
self.story.text_speed,
);
}
fn step(&mut self, ctx: &mut PipelineContext) -> StepResult {
let now = Instant::now();
let dt = self
.last_step
.map(|t| (now - t).as_secs_f32())
.unwrap_or(0.0);
self.last_step = Some(now);
self.elapsed += dt;
let reloads: Vec<Story> = match ctx.events::<StoryReload>() {
Some(events) => events
.read(&mut self.reload_cursor)
.map(|e| e.story.clone())
.collect(),
None => Vec::new(),
};
for story in reloads {
self.reload(story, ctx);
}
let shown: Vec<AssetId> = match ctx.events::<ScreenShown>() {
Some(events) => events
.read(&mut self.view_shown_cursor)
.map(|e| e.screen)
.collect(),
None => Vec::new(),
};
for screen in shown {
self.active_screen = Some(screen);
if !self.started
&& self.overlay == Overlay::None
&& Some(screen) == self.ids.as_ref().map(|i| i.screen)
{
self.start(ctx);
}
if Some(screen) == self.ids.as_ref().and_then(|i| i.title_screen_id) {
self.started = false;
self.layout_title_menu(ctx);
}
}
let commands: Vec<StoryCommand> = match ctx.events::<StoryCommand>() {
Some(events) => events.read(&mut self.command_cursor).cloned().collect(),
None => Vec::new(),
};
let button_tick = commands.iter().any(|c| match c {
StoryCommand::ToggleAuto | StoryCommand::ToggleSkip | StoryCommand::OpenSave => {
self.page_mode()
}
StoryCommand::ToggleLog => self.page_mode() || self.overlay == Overlay::Backlog,
StoryCommand::Slot(_) => {
matches!(self.overlay, Overlay::SaveMenu | Overlay::LoadMenu)
}
_ => false,
});
let mut mode_flipped = false;
for command in commands {
let was_in_choice = self.in_choice;
match command {
StoryCommand::Start => self.start(ctx),
StoryCommand::Continue => self.continue_story(ctx),
StoryCommand::ToggleAuto => self.toggle_auto(ctx),
StoryCommand::ToggleSkip => self.toggle_skip(ctx),
StoryCommand::ToggleLog => self.toggle_log(ctx),
StoryCommand::OpenSave => self.open_save(ctx),
StoryCommand::OpenLoad => self.open_load(ctx),
StoryCommand::Slot(i) => self.pick_slot(i, ctx),
StoryCommand::TogglePause => self.toggle_pause(ctx),
StoryCommand::OpenSettings => self.open_settings(ctx),
StoryCommand::CloseSettings => self.close_settings(ctx),
StoryCommand::Advance if !mode_flipped && !button_tick => {
if self.overlay != Overlay::None {
self.close_overlay(ctx);
} else {
if self.skip {
self.skip = false;
self.render_quick_row(ctx);
}
self.advance(ctx);
}
}
StoryCommand::Choose(i) if !mode_flipped && !button_tick => self.choose(i, ctx),
StoryCommand::Advance | StoryCommand::Choose(_) => {}
}
mode_flipped |= self.in_choice != was_in_choice;
}
let frame = ctx
.query::<FrameInput>()
.last()
.cloned()
.unwrap_or_default();
self.update_hold_skip(&frame, ctx);
self.handle_slot_scroll(&frame, ctx);
self.tick_typewriter(ctx, dt);
self.tick_modes(ctx, dt);
StepResult::Continue
}
}
impl StorySystem {
pub(super) fn reveal_all(&mut self, ctx: &mut PipelineContext) {
self.typewriter.shown = self.typewriter.full.len();
let text = self.typewriter.text();
let text_id = self.ids.as_ref().expect("resolved at init").text;
set_label(ctx, text_id, |l| l.content = text);
}
}
fn set_label(ctx: &mut PipelineContext, id: Option<AssetId>, apply: impl FnOnce(&mut TextLabel)) {
crate::ecs::by_asset_id::update(ctx, id, apply);
}
fn set_sprite(ctx: &mut PipelineContext, id: Option<AssetId>, apply: impl FnOnce(&mut Sprite)) {
crate::ecs::by_asset_id::update(ctx, id, apply);
}
fn apply_stage(ctx: &mut PipelineContext, ids: &StageIds, stage: &StoryStage) {
match &stage.bg {
Some(image) => {
let texture = image.texture;
set_sprite(ctx, ids.bg, |s| {
s.texture = Some(texture);
s.tint = [1.0, 1.0, 1.0, 1.0];
});
}
None => set_sprite(ctx, ids.bg, |s| {
s.texture = None;
s.tint = [0.05, 0.06, 0.09, 1.0];
}),
}
for (slot, image) in [
(ids.left, &stage.left),
(ids.center, &stage.center),
(ids.right, &stage.right),
] {
apply_portrait(ctx, slot, image.as_ref());
}
}
fn apply_portrait(ctx: &mut PipelineContext, slot: Option<AssetId>, image: Option<&StoryImage>) {
match image {
Some(image) => {
let (texture, x, y, w, h) =
(image.texture, image.x, image.y, image.width, image.height);
set_sprite(ctx, slot, |s| {
s.visible = true;
s.tint = [1.0, 1.0, 1.0, 1.0];
s.texture = Some(texture);
s.x = x;
s.y = y;
s.width = w;
s.height = h;
});
}
None => set_sprite(ctx, slot, |s| s.tint = [1.0, 1.0, 1.0, 0.0]),
}
}
fn play(ctx: &mut PipelineContext, clip: Option<AudioClipHandle>, kind: CueKind) {
let Some(clip) = clip else { return };
ctx.events_mut::<PlayCue>().send(PlayCue {
clip,
kind,
volume: 1.0,
priority: 0,
});
}