use std::{
io::Write as _,
thread,
time::{Duration, Instant},
};
use anyhow::Result;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent};
use serde::{Deserialize, Serialize};
use tixel::HalfCellCanvas;
use crate::{
game::{Game, Wallet},
tui::Terminal,
};
mod game;
mod menu;
mod render;
mod store;
use game::{Game as CoreGame, Input, Loadout};
use menu::Menu;
use render::Renderer;
use store::Store;
const GAME_DIMS: (usize, usize) = (60, 20);
const TARGET_FRAME_TIME: Duration = Duration::from_millis(20);
const TICK_TIME: Duration = Duration::from_millis(50);
const FADE_DUR: Duration = Duration::from_millis(500);
const GAME_OVER_DELAY: Duration = Duration::from_millis(750);
const CLEAR_COLOR: (u8, u8, u8) = (10, 10, 10);
const WELCOME_TITLE: &str = "TREASURE DEPTHS";
const WELCOME_BODY: &[&str] = &[
"Dig down, find treasure, and climb back",
"to the surface before fuel or time runs out.",
"",
" Arrows move / dig",
" Space pause ",
"",
"- Digging burns more fuel than moving. ",
"- Return to the surface to bank loot & refuel.",
"- You only keep the money you bank. ",
];
const WELCOME_FOOTER: &str = "[Enter] continue [Q] back to Party";
pub struct TreasureDepths;
enum Scene {
FadeIn { since: Instant },
Welcome,
Store,
Playing { last_tick: Instant, running: bool },
GameOver { since: Instant },
FadeOut { since: Instant },
Done,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct State {
best_haul: u64,
}
impl Game for TreasureDepths {
type State = State;
fn id(&self) -> &'static str {
"treasure_depths"
}
fn name(&self) -> &'static str {
"Treasure Depths"
}
fn description(&self) -> &'static str {
"Dig deep for treasure and climb back to the surface with your haul before your fuel or time runs out."
}
fn cost(&self) -> u64 {
150
}
fn clear_color(&self) -> (u8, u8, u8) {
CLEAR_COLOR
}
fn run(
&self,
terminal: &mut Terminal,
wallet: &mut dyn Wallet,
state: &mut State,
) -> Result<()> {
let mut stdout = std::io::stdout();
let size = terminal.size()?;
let rows = size.height as usize;
let cols = size.width as usize;
let offset_x = cols.saturating_sub(GAME_DIMS.0) / 2;
let offset_y = (rows.saturating_sub(GAME_DIMS.1) / 2).max(1);
let offset = (offset_x, offset_y);
let mut canvas = HalfCellCanvas::new(GAME_DIMS, (offset_x, offset_y));
let mut renderer = Renderer::new();
let menu = Menu::new((cols, rows));
let mut store = Store::new(wallet.balance()?);
let mut game = CoreGame::new(canvas.width(), Loadout::base());
let mut scene = Scene::FadeIn {
since: Instant::now(),
};
let mut prev_disc = std::mem::discriminant(&scene);
loop {
let frame_start = Instant::now();
let mut quit = false;
while let Some(code) = read_key()? {
if matches!(code, KeyCode::Char('q' | 'Q')) {
quit = true;
break;
}
scene = match scene {
Scene::Welcome => {
if code == KeyCode::Enter {
Scene::Store
} else {
Scene::Welcome
}
}
Scene::Store => match code {
KeyCode::Up => {
store.select_prev();
Scene::Store
}
KeyCode::Down => {
store.select_next();
Scene::Store
}
KeyCode::Left => {
store.tier_down();
Scene::Store
}
KeyCode::Right => {
store.tier_up();
Scene::Store
}
KeyCode::Enter => {
wallet.spend(store.spent())?;
game = CoreGame::new(canvas.width(), store.loadout());
Scene::Playing {
last_tick: Instant::now(),
running: true,
}
}
_ => Scene::Store,
},
Scene::Playing {
mut last_tick,
mut running,
} => {
match code {
KeyCode::Char(' ') => {
running = !running;
if running {
last_tick = Instant::now();
}
}
KeyCode::Up => game.handle(Input::Up),
KeyCode::Down => game.handle(Input::Down),
KeyCode::Left => game.handle(Input::Left),
KeyCode::Right => game.handle(Input::Right),
_ => {}
}
Scene::Playing { last_tick, running }
}
Scene::GameOver { since } => {
if since.elapsed() > GAME_OVER_DELAY {
Scene::FadeOut {
since: Instant::now(),
}
} else {
Scene::GameOver { since }
}
}
other => other,
};
}
if quit {
break;
}
scene = match scene {
Scene::FadeIn { since } => {
if since.elapsed() > FADE_DUR {
Scene::Welcome
} else {
Scene::FadeIn { since }
}
}
Scene::Playing {
mut last_tick,
running,
} => {
if running && last_tick.elapsed() > TICK_TIME {
game.tick(last_tick.elapsed());
last_tick = Instant::now();
}
if game.is_over() {
Scene::GameOver {
since: Instant::now(),
}
} else {
Scene::Playing { last_tick, running }
}
}
Scene::FadeOut { since } => {
if since.elapsed() > FADE_DUR {
Scene::Done
} else {
Scene::FadeOut { since }
}
}
other => other,
};
let disc = std::mem::discriminant(&scene);
if disc != prev_disc {
canvas.reset();
}
prev_disc = disc;
if matches!(scene, Scene::Done) {
break;
}
let mut output = String::new();
render_scene(
&scene,
&mut renderer,
&mut canvas,
&mut game,
&store,
&menu,
&mut output,
offset,
(cols, rows),
state,
);
let elapsed = frame_start.elapsed();
if elapsed < TARGET_FRAME_TIME {
thread::sleep(TARGET_FRAME_TIME - elapsed);
}
let _ = stdout.write_all(output.as_bytes());
let _ = stdout.flush();
}
let haul = game.bank_value();
if haul > 0 {
wallet.earn(haul)?;
}
state.best_haul = state.best_haul.max(haul);
Ok(())
}
}
#[expect(clippy::too_many_arguments)]
fn render_scene(
scene: &Scene,
renderer: &mut Renderer,
canvas: &mut HalfCellCanvas,
game: &mut CoreGame,
store: &Store,
menu: &Menu,
output: &mut String,
offset: (usize, usize),
term: (usize, usize),
state: &State,
) {
match scene {
Scene::FadeIn { since } => {
let opacity = (since.elapsed().as_secs_f64() / FADE_DUR.as_secs_f64()).min(1.);
renderer.render(canvas, game, output, offset, false, opacity, CLEAR_COLOR);
}
Scene::Welcome => {
renderer.render(canvas, game, output, offset, false, 1., CLEAR_COLOR);
menu.render(output, WELCOME_TITLE, WELCOME_BODY, WELCOME_FOOTER);
}
Scene::Store => {
renderer.render(canvas, game, output, offset, false, 1., CLEAR_COLOR);
store.render(output, term);
}
Scene::Playing { .. } => {
renderer.render(canvas, game, output, offset, true, 1., CLEAR_COLOR);
}
Scene::GameOver { .. } => {
renderer.render(canvas, game, output, offset, true, 1., CLEAR_COLOR);
let haul = game.bank_value();
let new_best = haul > state.best_haul;
let depth = format!("Max depth \u{2193}{}", game.max_depth());
let banked = format!("Gained {haul} P");
let best = if new_best {
"\u{2605} New best haul! \u{2605}".to_string()
} else {
format!("Best haul {} P", state.best_haul)
};
menu.render(
output,
"RUN OVER",
&[&depth, &banked, &best],
"[any key] return to Party",
);
}
Scene::FadeOut { since } => {
let opacity = (1. - since.elapsed().as_secs_f64() / FADE_DUR.as_secs_f64()).max(0.);
renderer.render(canvas, game, output, offset, false, opacity, CLEAR_COLOR);
}
Scene::Done => {}
}
}
fn read_key() -> Result<Option<KeyCode>> {
Ok(
if event::poll(Duration::ZERO)?
&& let Event::Key(KeyEvent { code, .. }) = event::read()?
{
Some(code)
} else {
None
},
)
}