diff --git a/README.md b/README.md new file mode 100644 index 0000000..8a11b1b --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# dramma + +Donation kiosk + arcade machine for Hacker Embassy. +Accepts cash (bills + coins), processes donations, and now lets people insert coins to play retro games on RetroArch. + +--- + +## Running + +```bash +nix-shell +cargo run +``` + +The app starts fullscreen. Tap the logo **5 times** to open the diagnostics panel. + +--- + +## Configuration + +Create `.config/dramma.toml` next to the binary (or in the working directory you run from): + +```toml +token = "your-bearer-token" # For Bot donates + +# Optional overrides (these are the defaults): +home_assistant_url = "https://ha.hackem.cc/web-dramma/0?BrowserID=dramma" +cashcode_serial_port = "/dev/serial/by-id/usb-Prolific_Technology_Inc._USB-Serial_Controller_D-if00-port0" +cctalk_serial_port = "/dev/ttyUSB0" +stats_db_path = "data/Stats.db" +``` + +--- + +## ๐Ÿ•น๏ธ Setting Up Games (Arcade Mode) + +Pressing **PLAY** on the main screen takes the user to the coin-insertion screen where they can select a game and insert money: + +> **100 AMD = 5 minutes of playtime** +> **50 AMD = 2 minutes 30 seconds** + +When they hit **Launch**, dramma starts RetroArch fullscreen + kiosk and auto-closes it when the time runs out. The speaker will announce "2 minutes left" and "1 minute left". + +> ROMs are not included for obvious copyright reasons. You know where to find them. + +### Configure games in dramma.toml + +Add a `[[games]]` block for each game. `name` is what shows up in the UI, `core` is the path to the `.so`, `rom` is the path to your ROM file. + +```toml +retroarch_command = "retroarch" + +[[games]] +name = "๐Ÿงฑ Tetris" +core = "/etc/retroarch/cores/nestopia_libretro.so" +rom = "/home/dramma/roms/tetris.nes" + +[[games]] +name = "๐ŸŸก Pac-Man" +core = "/etc/retroarch/cores/fbneo_libretro.so" +rom = "/home/dramma/roms/pacman.zip" + +[[games]] +name = "๐Ÿฆ” Sonic the Hedgehog" +core = "/etc/retroarch/cores/picodrive_libretro.so" +rom = "/home/dramma/roms/sonic.md" + +[[games]] +name = "๐Ÿ”ซ DOOM" +core = "/etc/retroarch/cores/dosbox_pure_libretro.so" +rom = "/home/dramma/roms/doom.wad" + +[[games]] +name = "๐Ÿ‘Š Street Fighter II" +core = "/etc/retroarch/cores/fbneo_libretro.so" +rom = "/home/dramma/roms/sf2.zip" +``` + +If `[[games]]` is **not configured**, the UI shows a built-in placeholder list (same names, no actual cores/ROMs). RetroArch will still launch but will open its own menu โ€” not useful in production. + +### Test it manually first + +Before trusting the machine to do it, test the exact command dramma will run: + +```bash +retroarch --fullscreen --kiosk -L /path/to/core_libretro.so /path/to/rom +``` + +If the game boots correctly, the config is right. If RetroArch opens its menu instead of loading the game, the core or ROM path is wrong. + + +--- + +## Money โ†’ Time conversion + +| Inserted | Play time | +|---|---| +| 50 ึ | 2 min 30 sec | +| 100 ึ | 5 min | +| 200 ึ | 10 min | +| 500 ึ | 25 min | + +--- + +## Architecture + +``` +main.rs +โ”œโ”€โ”€ bill_acceptor โ€” CashCode bill acceptor driver (serial) +โ”œโ”€โ”€ coin_acceptor โ€” ccTalk coin acceptor driver (serial) +โ”œโ”€โ”€ donation_handler โ€” Donation flow + inactivity timeout +โ”œโ”€โ”€ game_handler โ€” Arcade mode: RetroArch lifecycle + session timer +โ”œโ”€โ”€ home_assistant_handler โ€” Chromium kiosk for HASS page +โ””โ”€โ”€ diagnostics_handler โ€” Debug log viewer + +src/ +โ”œโ”€โ”€ cashcode.rs โ€” CashCode serial protocol +โ”œโ”€โ”€ cctalk.rs โ€” ccTalk serial protocol +โ”œโ”€โ”€ config.rs โ€” dramma.toml loader +โ”œโ”€โ”€ retroarch.rs โ€” RetroArch process manager +โ”œโ”€โ”€ sound.rs โ€” Audio (yippee + time warnings) +โ””โ”€โ”€ ... + +ui/ +โ”œโ”€โ”€ pages/ +โ”‚ โ”œโ”€โ”€ main.slint โ€” Main screen (Donate / Play / HASS) +โ”‚ โ”œโ”€โ”€ insert_coins.slint โ€” Game selector + coin insertion +โ”‚ โ”œโ”€โ”€ insert_money.slint โ€” Donation coin insertion +โ”‚ โ”œโ”€โ”€ donate.slint โ€” Donation form +โ”‚ โ””โ”€โ”€ ... +โ””โ”€โ”€ assets/ + โ”œโ”€โ”€ yippee.wav + โ”œโ”€โ”€ two_minutes_left.wav + โ””โ”€โ”€ one_minute_left.wav +``` diff --git a/src/config.rs b/src/config.rs index 5e5f4e2..c2927b1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -15,25 +15,26 @@ pub enum ConfigError { ParseError(#[from] toml::de::Error), } +/// A single playable game entry, configured via `dramma.toml`. +#[derive(Debug, Clone, Deserialize)] +pub struct GameEntry { + pub name: String, + pub core: String, + pub rom: String, +} + #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct Config { pub token: Option, pub home_assistant_url: String, - /// Port for the HTTP listener that accepts `POST /close-hass` from HASS. pub hass_api_port: u16, pub cashcode_serial_port: String, - /// Serial port for the ccTalk coin acceptor. Set to `"auto"` (the - /// default) to probe all available USB serial ports at startup and on - /// every reconnect, which handles `/dev/ttyUSBx` number changes. pub cctalk_serial_port: String, - /// Override the value for specific coin positions (channels). - /// Use this when the device has misconfigured coin IDs. - /// Format in dramma.toml: - /// cctalk_coin_overrides = [[1, 50], [3, 500]] - /// means position 1 โ†’ 50 AMD, position 3 โ†’ 500 AMD. pub cctalk_coin_overrides: Vec<[i32; 2]>, pub stats_db_path: String, + pub retroarch_command: String, + pub games: Vec, } impl Default for Config { @@ -45,9 +46,11 @@ impl Default for Config { cashcode_serial_port: "/dev/serial/by-id/usb-Prolific_Technology_Inc._USB-Serial_Controller_D-if00-port0" .to_string(), - cctalk_serial_port: "auto".to_string(), + cctalk_serial_port: "/dev/ttyUSB0".to_string(), cctalk_coin_overrides: Vec::new(), stats_db_path: "data/Stats.db".to_string(), + retroarch_command: "retroarch".to_string(), + games: Vec::new(), } } } diff --git a/src/home_assistant.rs b/src/home_assistant.rs index 6d12ad7..145830f 100644 --- a/src/home_assistant.rs +++ b/src/home_assistant.rs @@ -114,8 +114,9 @@ impl Drop for ChromiumManager { /// Starts a simple HTTP listener for remote control from Home Assistant. /// When a `POST /close-hass` request is received, sends a signal through `tx`. +#[allow(dead_code)] pub fn start_close_listener(port: u16, tx: Sender<()>) { - let addr = format!("0.0.0.0:{}", port); + let addr = format!("127.0.0.1:{}", port); let listener = match TcpListener::bind(&addr) { Ok(l) => l, Err(e) => { diff --git a/src/main.rs b/src/main.rs index a730019..792e538 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ mod donation; mod error; mod funds; mod home_assistant; +mod retroarch; mod sound; use cashcode::{BillEvent, CashCode}; @@ -66,6 +67,7 @@ pub fn main() { ); donation_handler::init(&main_window, &config, cashcode_tx, cctalk_tx); home_assistant_handler::init(&main_window, &config); + game_handler::init(&main_window, &config); main_window.run().unwrap(); } @@ -1027,3 +1029,148 @@ mod home_assistant_handler { }); } } + +mod game_handler { + use super::*; + use crate::config::GameEntry; + use crate::retroarch::RetroArchManager; + use slint::{Timer, TimerMode}; + use std::sync::Arc; + + pub fn init(app: &MainWindow, config: &Config) { + // Populate game-names from config (empty list โ†’ UI uses built-in fallback) + if !config.games.is_empty() { + let names: Vec = config + .games + .iter() + .map(|g| slint::SharedString::from(g.name.clone())) + .collect(); + app.set_game_names(slint::ModelRc::new(slint::VecModel::from(names))); + } + + let retroarch = Arc::new(RetroArchManager::new(&config.retroarch_command)); + let games = config.games.clone(); + + // Active timers โ€” kept alive via Rc> + let session_timer: Rc>> = Rc::new(RefCell::new(None)); + let two_min_timer: Rc>> = Rc::new(RefCell::new(None)); + let one_min_timer: Rc>> = Rc::new(RefCell::new(None)); + let tick_timer: Rc>> = Rc::new(RefCell::new(None)); + + let weak = app.as_weak(); + + app.on_launch_game({ + let retroarch = retroarch.clone(); + let games = games.clone(); + let session_timer = session_timer.clone(); + let two_min_timer = two_min_timer.clone(); + let one_min_timer = one_min_timer.clone(); + let tick_timer = tick_timer.clone(); + let weak = weak.clone(); + + move |amount, game_name| { + // Clear any existing timers first + *session_timer.borrow_mut() = None; + *two_min_timer.borrow_mut() = None; + *one_min_timer.borrow_mut() = None; + *tick_timer.borrow_mut() = None; + + // Compute session duration: 100 AMD = 300 seconds + let total_secs = (amount as u64) * 3; + info!( + "๐ŸŽฎ Game session: {} AMD โ†’ {} sec, game: {}", + amount, total_secs, game_name + ); + + // Find the matching GameEntry (if configured), otherwise use a blank entry + // so RetroArch launches with its own saved config. + let entry: GameEntry = games + .iter() + .find(|g| g.name == game_name.as_str()) + .cloned() + .unwrap_or_else(|| GameEntry { + name: game_name.to_string(), + core: String::new(), + rom: String::new(), + }); + + if let Err(e) = retroarch.launch(&entry) { + error!("๐ŸŽฎ Failed to launch RetroArch: {}", e); + // No RetroArch โ†’ still start the UI session so the user isn't stuck + } + + // Initialise countdown display + if let Some(w) = weak.upgrade() { + w.set_game_seconds_left(total_secs as i32); + } + + // โ”€โ”€ 1-second countdown ticker โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + { + let weak_tick = weak.clone(); + let ticker = Timer::default(); + ticker.start(TimerMode::Repeated, Duration::from_secs(1), move || { + if let Some(w) = weak_tick.upgrade() { + let cur = w.get_game_seconds_left(); + if cur > 0 { + w.set_game_seconds_left(cur - 1); + } + } + }); + *tick_timer.borrow_mut() = Some(ticker); + } + + // โ”€โ”€ "2 minutes left" milestone โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if total_secs > 120 { + let fire_at = Duration::from_secs(total_secs - 120); + let t = Timer::default(); + t.start(TimerMode::SingleShot, fire_at, || { + info!("๐Ÿ”Š Announcing: 2 minutes left"); + crate::sound::play_two_minutes_left(); + }); + *two_min_timer.borrow_mut() = Some(t); + } + + // โ”€โ”€ "1 minute left" milestone โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if total_secs > 60 { + let fire_at = Duration::from_secs(total_secs - 60); + let t = Timer::default(); + t.start(TimerMode::SingleShot, fire_at, || { + info!("๐Ÿ”Š Announcing: 1 minute left"); + crate::sound::play_one_minute_left(); + }); + *one_min_timer.borrow_mut() = Some(t); + } + + // โ”€โ”€ Session-end timer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + { + let retroarch_end = retroarch.clone(); + let weak_end = weak.clone(); + let tick_end = tick_timer.clone(); + let two_end = two_min_timer.clone(); + let one_end = one_min_timer.clone(); + + let t = Timer::default(); + t.start( + TimerMode::SingleShot, + Duration::from_secs(total_secs), + move || { + info!("๐ŸŽฎ Game session expired โ€” closing RetroArch"); + retroarch_end.close(); + + // Stop all running timers + *tick_end.borrow_mut() = None; + *two_end.borrow_mut() = None; + *one_end.borrow_mut() = None; + + if let Some(w) = weak_end.upgrade() { + w.set_game_seconds_left(0); + w.invoke_game_time_expired(); + } + }, + ); + *session_timer.borrow_mut() = Some(t); + } + } + }); + } +} diff --git a/src/retroarch.rs b/src/retroarch.rs new file mode 100644 index 0000000..9854f5b --- /dev/null +++ b/src/retroarch.rs @@ -0,0 +1,90 @@ +use crate::config::GameEntry; +use log::{error, info}; +use std::process::{Child, Command}; +use std::sync::{Arc, Mutex}; + +/// Manages a RetroArch subprocess for the arcade game mode. +pub struct RetroArchManager { + process: Arc>>, + retroarch_command: String, +} + +impl RetroArchManager { + pub fn new(retroarch_command: &str) -> Self { + Self { + process: Arc::new(Mutex::new(None)), + retroarch_command: retroarch_command.to_string(), + } + } + + /// Launch RetroArch fullscreen in kiosk mode with the given game entry. + /// If `game` has empty core/rom paths, RetroArch is launched bare (uses its own last-used config). + pub fn launch(&self, game: &GameEntry) -> Result<(), String> { + let mut process_guard = self.process.lock().unwrap(); + + // Kill any already-running session + if let Some(ref mut child) = *process_guard { + info!("๐ŸŽฎ Killing existing RetroArch process before relaunch"); + let _ = child.kill(); + let _ = child.wait(); + } + + info!( + "๐ŸŽฎ Launching RetroArch: game=\"{}\" core=\"{}\" rom=\"{}\"", + game.name, game.core, game.rom + ); + + let mut cmd = Command::new(&self.retroarch_command); + cmd.arg("--fullscreen").arg("--kiosk"); + + if !game.core.is_empty() { + cmd.arg("-L").arg(&game.core); + } + if !game.rom.is_empty() { + cmd.arg(&game.rom); + } + + match cmd.spawn() { + Ok(child) => { + info!("๐ŸŽฎ RetroArch launched with PID {}", child.id()); + *process_guard = Some(child); + Ok(()) + } + Err(e) => { + let msg = format!( + "Failed to launch RetroArch (command: \"{}\"): {}", + self.retroarch_command, e + ); + error!("{}", msg); + Err(msg) + } + } + } + + /// Kill the RetroArch process if running. + pub fn close(&self) { + let mut process_guard = self.process.lock().unwrap(); + if let Some(ref mut child) = *process_guard { + info!("๐ŸŽฎ Closing RetroArch process"); + if let Err(e) = child.kill() { + error!("Failed to kill RetroArch: {}", e); + } else { + let _ = child.wait(); + info!("๐ŸŽฎ RetroArch process closed"); + } + } + *process_guard = None; + } + + /// Returns `true` if RetroArch is currently running. + #[allow(dead_code)] + pub fn is_running(&self) -> bool { + self.process.lock().unwrap().is_some() + } +} + +impl Drop for RetroArchManager { + fn drop(&mut self) { + self.close(); + } +} diff --git a/src/sound.rs b/src/sound.rs index d85b1c9..2de7a56 100644 --- a/src/sound.rs +++ b/src/sound.rs @@ -6,13 +6,21 @@ use std::thread; use rodio::{Decoder, DeviceSinkBuilder, Player}; const YIPPEE_WAV: &[u8] = include_bytes!("../ui/assets/yippee.wav"); +const TWO_MINUTES_LEFT_WAV: &[u8] = include_bytes!("../ui/assets/two_minutes_left.wav"); +const ONE_MINUTE_LEFT_WAV: &[u8] = include_bytes!("../ui/assets/one_minute_left.wav"); -static AUDIO_TX: OnceLock> = OnceLock::new(); +enum SoundEvent { + Yippee, + TwoMinutesLeft, + OneMinuteLeft, +} + +static AUDIO_TX: OnceLock> = OnceLock::new(); -/// Initializes the audio subsystem. Must be called once at startup before `play_yippee`. +/// Initializes the audio subsystem. Must be called once at startup before any `play_*` calls. pub fn init() { AUDIO_TX.get_or_init(|| { - let (tx, rx) = mpsc::sync_channel::<()>(8); + let (tx, rx) = mpsc::sync_channel::(8); thread::spawn(move || { let handle = match DeviceSinkBuilder::open_default_sink() { @@ -25,8 +33,13 @@ pub fn init() { let mixer = handle.mixer(); - while rx.recv().is_ok() { - let source = match Decoder::try_from(Cursor::new(YIPPEE_WAV)) { + while let Ok(event) = rx.recv() { + let wav_bytes: &[u8] = match event { + SoundEvent::Yippee => YIPPEE_WAV, + SoundEvent::TwoMinutesLeft => TWO_MINUTES_LEFT_WAV, + SoundEvent::OneMinuteLeft => ONE_MINUTE_LEFT_WAV, + }; + let source = match Decoder::try_from(Cursor::new(wav_bytes)) { Ok(s) => s, Err(e) => { log::error!("Failed to decode WAV: {}", e); @@ -43,15 +56,29 @@ pub fn init() { }); } -/// Plays the yippee sound. -/// Requires `init()` to have been called at startup. -pub fn play_yippee() { +fn play_sound(event: SoundEvent) { match AUDIO_TX.get() { Some(tx) => { - if let Err(e) = tx.try_send(()) { - log::warn!("Audio busy, skipping yippee: {}", e); + if let Err(e) = tx.try_send(event) { + log::warn!("Audio busy, skipping sound: {}", e); } } None => log::error!("Audio not initialized โ€” call sound::init() at startup"), } } + +/// Plays the yippee sound. +/// Requires `init()` to have been called at startup. +pub fn play_yippee() { + play_sound(SoundEvent::Yippee); +} + +/// Plays the "2 minutes left" announcement. +pub fn play_two_minutes_left() { + play_sound(SoundEvent::TwoMinutesLeft); +} + +/// Plays the "1 minute left" announcement. +pub fn play_one_minute_left() { + play_sound(SoundEvent::OneMinuteLeft); +} diff --git a/ui/assets/one_minute_left.wav b/ui/assets/one_minute_left.wav new file mode 100644 index 0000000..cc36686 Binary files /dev/null and b/ui/assets/one_minute_left.wav differ diff --git a/ui/assets/two_minutes_left.wav b/ui/assets/two_minutes_left.wav new file mode 100644 index 0000000..8680d52 Binary files /dev/null and b/ui/assets/two_minutes_left.wav differ diff --git a/ui/main_window.slint b/ui/main_window.slint index e1b52af..4539fa1 100644 --- a/ui/main_window.slint +++ b/ui/main_window.slint @@ -5,6 +5,7 @@ import { ConfettiOverlay } from "confetti.slint"; import { Main } from "pages/main.slint"; import { Donate } from "pages/donate.slint"; import { InsertMoney } from "pages/insert_money.slint"; +import { InsertCoins } from "pages/insert_coins.slint"; import { HomeAssistant } from "pages/home_assistant.slint"; import { Diagnostics, LogEntry } from "pages/diagnostics.slint"; @@ -14,6 +15,7 @@ enum Page { Main, Donate, InsertMoney, + InsertCoins, HomeAssistant, Diagnostics, Top, @@ -77,8 +79,23 @@ export component MainWindow inherits Window { root.confetti-started(); } + // game-automate mode + /// Game names fed from Rust config (empty โ†’ UI uses built-in list) + in-out property <[string]> game-names: []; + /// Countdown seconds remaining in the active game session (updated by Rust) + in-out property game-seconds-left: 0; + /// Guard: true while on the InsertCoins page + out property on-insert-coins-page: current-page == Page.InsertCoins; + + callback launch-game(int, string); // amount, game-name โ€” Rust starts RetroArch + callback game-time-expired(); // Rust calls when session timer fires + game-time-expired => { + root.current-page = Page.Main; + } + /// Called from Rust when HASS sends a POST /close-hass request. - public function close-hass-remote() { + callback close-hass-remote(); + close-hass-remote => { root.hide-home-assistant(); root.current-page = Page.Main; } @@ -98,6 +115,12 @@ export component MainWindow inherits Window { root.current-page = Page.HomeAssistant; } + play-clicked => { + root.session-amount = 0; + root.start-accepting-money(); + root.current-page = Page.InsertCoins; + } + secret-tapped => { root.current-page = Page.Diagnostics; } @@ -173,6 +196,24 @@ export component MainWindow inherits Window { root.confetti-started(); } } + if current-page == Page.InsertCoins: InsertCoins { + current-amount <=> root.session-amount; + last-added-amount <=> root.last-added-amount; + game-names: root.game-names; + + cancel-clicked => { + root.stop-accepting-money(); + root.session-amount = 0; + root.current-page = Page.Main; + } + + launch-clicked(amount, game-name) => { + root.stop-accepting-money(); + root.session-amount = 0; + root.launch-game(amount, game-name); + root.current-page = Page.Main; + } + } if current-page == Page.HomeAssistant: HomeAssistant { back-clicked => { root.hide-home-assistant(); diff --git a/ui/pages/insert_coins.slint b/ui/pages/insert_coins.slint new file mode 100644 index 0000000..98f7517 --- /dev/null +++ b/ui/pages/insert_coins.slint @@ -0,0 +1,256 @@ +import { Button, Palette } from "std-widgets.slint"; + +export component InsertCoins inherits Rectangle { + // โ”€โ”€โ”€ Inputs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + in-out property current-amount: 0; + in-out property last-added-amount: 0; + /// Game names passed in from Rust (may be empty โ†’ shows built-in list) + in property <[string]> game-names: []; + + // โ”€โ”€โ”€ Callbacks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + callback cancel-clicked(); + callback launch-clicked(int, string); // amount, game-name + + // โ”€โ”€โ”€ Internal state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + property selected-game-index: 0; + property selected-game-name: + root.game-names.length > 0 + ? root.game-names[root.selected-game-index] + : builtin-games[root.selected-game-index]; + + // Built-in fallback game list (shown when no games are configured in TOML) + property <[string]> builtin-games: [ + "Tetris", + "DOOM", + "Pac-Man", + "Street Fighter II", + "Sonic the Hedgehog", + ]; + property game-count: + root.game-names.length > 0 + ? root.game-names.length + : root.builtin-games.length; + + // Playtime computation: 100 AMD = 5 min = 300 s + property play-seconds: root.current-amount * 3; // 100 AMD โ†’ 300 s + property play-min: root.play-seconds / 60; + property play-sec: root.play-seconds - root.play-min * 60; + + // โ”€โ”€โ”€ Toast state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + property display-amount: 0; + property toast-active: false; + + Timer { interval: 16ms; running: root.last-added-amount != 0 && !root.toast-active; + triggered => { root.display-amount = root.last-added-amount; root.toast-active = true; } } + Timer { interval: 1100ms; running: root.last-added-amount != 0; + triggered => { root.last-added-amount = 0; } } + Timer { interval: 600ms; running: root.last-added-amount == 0 && root.toast-active; + triggered => { root.toast-active = false; } } + + background: Palette.background; + + // โ”€โ”€โ”€ Layout โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + HorizontalLayout { + padding: 24px; + spacing: 24px; + + // โ”€โ”€ LEFT PANEL: Game Selector โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Rectangle { + width: 420px; + border-radius: 16px; + background: Palette.color-scheme == ColorScheme.dark ? #1a1a2e : #eeeef7; + + VerticalLayout { + padding: 20px; + spacing: 12px; + + Text { + text: "๐ŸŽฎ Select Game"; + font-size: 22px; + font-weight: 700; + color: Palette.foreground; + horizontal-alignment: center; + } + + Rectangle { height: 4px; } + + // Game list + for i in root.game-count: game-row := Rectangle { + height: 72px; + border-radius: 12px; + background: root.selected-game-index == i + ? #6c5ce7 + : (Palette.color-scheme == ColorScheme.dark ? #2d2d4a : #d8d8ee); + animate background { duration: 150ms; } + + Text { + text: root.game-names.length > 0 + ? root.game-names[i] + : root.builtin-games[i]; + font-size: 20px; + font-weight: root.selected-game-index == i ? 700 : 400; + color: root.selected-game-index == i ? #ffffff : Palette.foreground; + vertical-alignment: center; + horizontal-alignment: center; + width: parent.width; + height: parent.height; + animate color { duration: 150ms; } + } + + TouchArea { + clicked => { root.selected-game-index = i; } + } + } + + Rectangle { vertical-stretch: 1; } + } + } + + // โ”€โ”€ RIGHT PANEL: Coin Insertion โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + VerticalLayout { + horizontal-stretch: 1; + alignment: start; + spacing: 20px; + + // Header row (cancel button left, title center) + HorizontalLayout { + alignment: start; + spacing: 16px; + + Button { + text: "โ† Cancel"; + width: 150px; + height: 56px; + clicked => { root.cancel-clicked(); } + } + + Text { + text: "Insert Money to Play"; + font-size: 28px; + font-weight: 700; + color: Palette.foreground; + vertical-alignment: center; + horizontal-stretch: 1; + horizontal-alignment: center; + } + } + + // Rate info + Rectangle { + height: 44px; + border-radius: 10px; + background: #6c5ce7.with-alpha(0.12); + + Text { + text: "100 ึ = 5 min ยท 50 ึ = 2 min 30 sec"; + font-size: 16px; + color: #6c5ce7; + font-weight: 600; + horizontal-alignment: center; + vertical-alignment: center; + width: parent.width; + height: parent.height; + } + } + + // Amount display with toast overlay + Rectangle { + height: 140px; + clip: false; + + amount-box := Rectangle { + width: 100%; + height: 100%; + border-radius: 16px; + background: Palette.color-scheme == ColorScheme.dark ? #2a2a2a : #f5f5f5; + border-width: 3px; + border-color: root.current-amount > 0 ? #6c5ce7 : #cccccc; + animate border-color { duration: 250ms; } + + VerticalLayout { + alignment: center; + spacing: 4px; + + Text { + text: root.current-amount + " ึ"; + font-size: 62px; + font-weight: 700; + color: root.current-amount > 0 ? #6c5ce7 : Palette.foreground; + horizontal-alignment: center; + animate color { duration: 250ms; } + } + } + } + + // Rising toast + toast-text := Text { + text: "+" + root.display-amount + " ึ"; + color: #6c5ce7; + font-size: 38px; + font-weight: 700; + horizontal-alignment: center; + width: parent.width; + y: root.toast-active ? -90px : -20px; + opacity: root.last-added-amount != 0 ? 1.0 : 0.0; + animate opacity { duration: 500ms; } + animate y { duration: 1000ms; easing: ease-in-out; } + } + } + + // Playtime label + Rectangle { + height: 64px; + border-radius: 12px; + background: root.current-amount >= 50 + ? #00b894.with-alpha(0.15) + : transparent; + animate background { duration: 300ms; } + + Text { + text: root.current-amount >= 50 + ? "โฑ Play time: " + root.play-min + " min " + (root.play-sec > 0 ? root.play-sec + " sec" : "") + : "Insert money to see your play time"; + font-size: 22px; + font-weight: 600; + color: root.current-amount >= 50 ? #00b894 : Palette.foreground; + opacity: root.current-amount >= 50 ? 1.0 : 0.5; + horizontal-alignment: center; + vertical-alignment: center; + width: parent.width; + height: parent.height; + animate color { duration: 300ms; } + animate opacity { duration: 300ms; } + } + } + + Rectangle { vertical-stretch: 1; } + + // LAUNCH button + HorizontalLayout { + alignment: center; + + launch-btn := Button { + text: root.current-amount >= 50 + ? "๐Ÿš€ Launch " + root.selected-game-name + : "๐Ÿš€ Launch (insert money first)"; + primary: true; + enabled: root.current-amount >= 50; + height: 110px; + + clicked => { + root.launch-clicked(root.current-amount, root.selected-game-name); + } + } + } + + // Disabled hint + if root.current-amount < 50: Text { + text: "Minimum 50 ึ to start playing"; + font-size: 14px; + color: Palette.foreground; + opacity: 0.5; + horizontal-alignment: center; + } + } + } +} diff --git a/ui/pages/main.slint b/ui/pages/main.slint index 796f130..36b4630 100644 --- a/ui/pages/main.slint +++ b/ui/pages/main.slint @@ -1,36 +1,142 @@ -import { LineEdit, Button } from "std-widgets.slint"; +// โ”€โ”€โ”€ Palette โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +global Theme { + out property bg-top: #0d0d1a; + out property bg-bottom: #12122b; + out property card-bg: #1c1c3a; + out property card-border: #2e2e60; + out property accent-donate: #e05a77; + out property accent-hass: #5ab8e0; + out property accent-play: #7de05a; + out property text-primary: #f0eeff; + out property text-muted: #8888bb; + out property github-accent: #c9b8ff; +} + +// โ”€โ”€โ”€ Feature card โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +component FeatureCard inherits Rectangle { + in property accent; + in property icon; + in property label; + in property description; + callback clicked(); + + property hover-factor: 0; + + width: 340px; + height: 220px; + border-radius: 18px; + background: Theme.card-bg; + border-width: 1.5px; + border-color: accent.mix(Theme.card-border, 0.35); + drop-shadow-blur: 20px; + drop-shadow-color: accent.with-alpha(0.15); + drop-shadow-offset-y: 6px; + + animate border-color { duration: 200ms; easing: ease-out; } + animate drop-shadow-color { duration: 200ms; easing: ease-out; } + + + VerticalLayout { + padding: 28px; + spacing: 10px; + alignment: start; + + // Icon + label row + HorizontalLayout { + spacing: 12px; + alignment: start; + + Text { + text: icon; + font-size: 36px; + vertical-alignment: center; + } + + Text { + text: label; + font-size: 22px; + font-weight: 700; + color: Theme.text-primary; + vertical-alignment: center; + } + } + + // Divider + Rectangle { + width: 48px; + height: 2px; + background: accent.with-alpha(0.6); + x: 0px; + } + + Text { + text: description; + font-size: 15px; + color: Theme.text-muted; + wrap: word-wrap; + } + } + + TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + pointer-event(e) => { + if e.kind == PointerEventKind.move || e.kind == PointerEventKind.up { + root.hover-factor = self.has-hover ? 1.0 : 0.0; + } + } + } +} + +// โ”€โ”€โ”€ Main page โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ export component Main inherits Rectangle { callback donate-clicked(); callback home-assistant-clicked(); + callback play-clicked(); callback secret-tapped(); - // Secret: tap the logo 5 times to open diagnostics. property tap-count: 0; - // Reset the counter 3 s after the last tap if it hasn't reached 5. tap-reset := Timer { interval: 3s; running: root.tap-count > 0 && root.tap-count < 5; triggered => { root.tap-count = 0; } } + background: Theme.bg-top; + + // Subtle radial gradient overlay in center + Rectangle { + x: 0; y: 0; + width: parent.width; + height: parent.height; + background: @linear-gradient(160deg, #1a0d33 0%, #0d0d1a 40%, #0d1a2b 100%); + } + VerticalLayout { - alignment: space-between; - padding: 16px; - spacing: 8px; + alignment: center; + spacing: 0px; + padding-top: 32px; + padding-bottom: 32px; + padding-left: 48px; + padding-right: 48px; - // logo at top center โ€” tap 5ร— to open diagnostics + // โ”€โ”€ Header row: logo + title โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ HorizontalLayout { alignment: center; + spacing: 32px; + padding-bottom: 8px; + // Logo โ€” tap 5ร— for diagnostics Rectangle { - width: 233px; - height: 250px; + width: 120px; + height: 128px; Image { - width: 233px; - height: 250px; + width: 120px; + height: 128px; source: @image-url("../assets/xkem.png"); + image-fit: contain; } TouchArea { @@ -43,36 +149,117 @@ export component Main inherits Rectangle { } } } + + // Title + tagline + VerticalLayout { + alignment: center; + spacing: 6px; + + Text { + text: "Welcome to dram'ma!"; + font-size: 46px; + font-weight: 800; + color: Theme.text-primary; + } + + Text { + text: "Your friendly Hacker Embassy station"; + font-size: 18px; + color: Theme.text-muted; + font-weight: 400; + } + } } - // spacer to push button to bottom - Rectangle { - vertical-stretch: 1; - background: transparent; + // โ”€โ”€ Subtitle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + HorizontalLayout { + alignment: center; + padding-top: 12px; + padding-bottom: 32px; + + Text { + text: "Here you can:"; + font-size: 22px; + font-weight: 600; + color: Theme.github-accent; + } } - // buttons at bottom center + // โ”€โ”€ Feature cards โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ HorizontalLayout { alignment: center; - spacing: 16px; - - Button { - text: "DONATE"; - width: 350px; - height: 120px; - - clicked => { - root.donate-clicked(); - } + spacing: 24px; + + FeatureCard { + accent: Theme.accent-donate; + icon: "๐Ÿ’ธ"; + label: "Donate"; + description: "Support Hacker Embassy directly.\nEvery coin counts!"; + clicked => { root.donate-clicked(); } + } + + FeatureCard { + accent: Theme.accent-hass; + icon: "๐Ÿ "; + label: "Control Space"; + description: "Manage lights, climate & more\nvia Home Assistant."; + clicked => { root.home-assistant-clicked(); } } - Button { - text: "HASS"; - width: 350px; - height: 120px; - - clicked => { - root.home-assistant-clicked(); + FeatureCard { + accent: Theme.accent-play; + icon: "๐ŸŽฎ"; + label: "Play Games"; + description: "Insert coins and enjoy\nretro games on the machine!"; + clicked => { root.play-clicked(); } + } + } + + // โ”€โ”€ Contribute footer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + HorizontalLayout { + alignment: center; + padding-top: 36px; + spacing: 8px; + + Text { + text: "Wanna contribute?"; + font-size: 16px; + color: Theme.text-muted; + vertical-alignment: center; + } + + Text { + text: "PR to"; + font-size: 16px; + color: Theme.text-muted; + vertical-alignment: center; + } + + HorizontalLayout { + padding-left: 10px; + padding-right: 10px; + padding-top: 4px; + padding-bottom: 4px; + height: 32px; + + Rectangle { + border-radius: 8px; + background: Theme.github-accent.with-alpha(0.12); + border-width: 1px; + border-color: Theme.github-accent.with-alpha(0.4); + + HorizontalLayout { + padding-left: 10px; + padding-right: 10px; + + Text { + text: "github.com/hackerembassy/dramma"; + font-size: 15px; + color: Theme.github-accent; + font-weight: 600; + vertical-alignment: center; + } + } } } }