User accounts, login before the title screen, own savegames per user, standalone download
- Service: accounts with Argon2 password hashes and session tokens; the first login creates the admin (shared savegames of before move to the admin); the admin creates and deletes users; everybody can change the password; savegames are stored and listed per user - Game: login page before the title (only when the service is reachable), with licence and repository link; account page (password, logout; users for the admin); text fields with Tab/Enter, masked passwords, browser dialog on touch screens; long status messages wrap - Download Standalone: DungeonCraft.html with the game embedded (built in the Docker image, served to logged-in users via nginx auth_request); works from file:// without server and opens downloaded savegames - Without the service (standalone, local files) there is no login, savegame files as before Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8853d84564
commit
c9848c8948
15 files changed
+1232
-83
No files matched your search
+162
-4
@@ -4,7 +4,7 @@
|
||||
// `online` becomes false and the game falls back to savegame files and the browser autosave.
|
||||
|
||||
use crate::platform;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const API: &str = "api"; // relative, so the game also works below a sub path
|
||||
|
||||
@@ -37,22 +37,54 @@ enum Req {
|
||||
Save,
|
||||
Load,
|
||||
Delete,
|
||||
Login,
|
||||
Me,
|
||||
Logout,
|
||||
Password,
|
||||
Users,
|
||||
CreateUser,
|
||||
DeleteUser,
|
||||
}
|
||||
|
||||
// the logged-in account
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub token: String,
|
||||
pub name: String,
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct UserInfo {
|
||||
pub name: String,
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
const SESSION_KEY: &str = "dc.session";
|
||||
|
||||
pub enum CloudEvent {
|
||||
None,
|
||||
Saved(String), // name of the stored savegame
|
||||
Loaded(Vec<u8>), // savegame data
|
||||
Deleted,
|
||||
Error(&'static str), // language key
|
||||
LoggedIn, // login accepted (or the stored session is still valid)
|
||||
LoginNeeded, // the service is there but nobody is logged in
|
||||
LoginFailed(&'static str),
|
||||
PasswordChanged,
|
||||
UsersChanged,
|
||||
}
|
||||
|
||||
pub struct Cloud {
|
||||
pub online: Online,
|
||||
pub setup: bool, // no account yet: the first login creates the admin
|
||||
pub session: Option<Session>,
|
||||
pub list: Vec<SaveInfo>,
|
||||
pub listed: bool, // the list has been fetched at least once
|
||||
pub users: Vec<UserInfo>,
|
||||
pending: Option<(u32, Req)>,
|
||||
save_name: String,
|
||||
login_name: String,
|
||||
}
|
||||
|
||||
// percent-encoding for a query parameter
|
||||
@@ -71,6 +103,7 @@ fn encode(s: &str) -> String {
|
||||
fn error_key(status: i32) -> &'static str {
|
||||
match status {
|
||||
0 => "cloud.err_net",
|
||||
401 => "login.err_session",
|
||||
413 => "cloud.err_large",
|
||||
429 => "cloud.err_rate",
|
||||
507 => "cloud.err_full",
|
||||
@@ -83,11 +116,57 @@ impl Cloud {
|
||||
// starts the availability check
|
||||
pub fn new() -> Cloud {
|
||||
let id = platform::http("GET", &format!("{}/health", API), &[]);
|
||||
Cloud { online: Online::Unknown, list: Vec::new(), listed: false, pending: Some((id, Req::Health)), save_name: String::new() }
|
||||
let session: Option<Session> = platform::storage_get(SESSION_KEY).and_then(|s| serde_json::from_str(&s).ok());
|
||||
if let Some(s) = &session {
|
||||
platform::set_token(&s.token);
|
||||
}
|
||||
Cloud {
|
||||
online: Online::Unknown,
|
||||
setup: false,
|
||||
session,
|
||||
list: Vec::new(),
|
||||
listed: false,
|
||||
users: Vec::new(),
|
||||
pending: Some((id, Req::Health)),
|
||||
save_name: String::new(),
|
||||
login_name: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// savegames on the server: the service is there and somebody is logged in
|
||||
pub fn available(&self) -> bool {
|
||||
self.online == Online::Yes
|
||||
self.online == Online::Yes && self.session.is_some()
|
||||
}
|
||||
|
||||
pub fn login(&mut self, name: &str, password: &str) -> bool {
|
||||
self.login_name = name.to_string();
|
||||
let body = serde_json::json!({ "name": name, "password": password }).to_string();
|
||||
self.start("POST", "login", body.as_bytes(), Req::Login)
|
||||
}
|
||||
|
||||
pub fn logout(&mut self) {
|
||||
self.start("POST", "logout", &[], Req::Logout);
|
||||
self.session = None;
|
||||
platform::set_token("");
|
||||
platform::storage_set(SESSION_KEY, "");
|
||||
}
|
||||
|
||||
pub fn change_password(&mut self, old: &str, new: &str) -> bool {
|
||||
let body = serde_json::json!({ "old": old, "new": new }).to_string();
|
||||
self.start("POST", "password", body.as_bytes(), Req::Password)
|
||||
}
|
||||
|
||||
pub fn load_users(&mut self) -> bool {
|
||||
self.start("GET", "users", &[], Req::Users)
|
||||
}
|
||||
|
||||
pub fn create_user(&mut self, name: &str, password: &str) -> bool {
|
||||
let body = serde_json::json!({ "name": name, "password": password }).to_string();
|
||||
self.start("POST", "users", body.as_bytes(), Req::CreateUser)
|
||||
}
|
||||
|
||||
pub fn delete_user(&mut self, name: &str) -> bool {
|
||||
self.start("DELETE", &format!("users/{}", encode(name)), &[], Req::DeleteUser)
|
||||
}
|
||||
|
||||
pub fn busy(&self) -> bool {
|
||||
@@ -132,7 +211,86 @@ impl Cloud {
|
||||
match req {
|
||||
Req::Health => {
|
||||
self.online = if ok && res.body.starts_with(b"ok") { Online::Yes } else { Online::No };
|
||||
CloudEvent::None
|
||||
self.setup = res.body.ends_with(b"setup");
|
||||
if self.online != Online::Yes {
|
||||
return CloudEvent::None;
|
||||
}
|
||||
// a stored session: still valid?
|
||||
if self.session.is_some() && !self.setup {
|
||||
self.start("GET", "me", &[], Req::Me);
|
||||
return CloudEvent::None;
|
||||
}
|
||||
self.session = None;
|
||||
CloudEvent::LoginNeeded
|
||||
}
|
||||
Req::Me => {
|
||||
if ok {
|
||||
CloudEvent::LoggedIn
|
||||
} else if res.status == 0 {
|
||||
self.online = Online::No;
|
||||
CloudEvent::None
|
||||
} else {
|
||||
self.session = None;
|
||||
platform::set_token("");
|
||||
platform::storage_set(SESSION_KEY, "");
|
||||
CloudEvent::LoginNeeded
|
||||
}
|
||||
}
|
||||
Req::Login => {
|
||||
#[derive(Deserialize)]
|
||||
struct Answer {
|
||||
token: String,
|
||||
name: String,
|
||||
admin: bool,
|
||||
}
|
||||
match (ok, serde_json::from_slice::<Answer>(&res.body)) {
|
||||
(true, Ok(a)) => {
|
||||
let s = Session { token: a.token, name: a.name, admin: a.admin };
|
||||
platform::set_token(&s.token);
|
||||
platform::storage_set(SESSION_KEY, &serde_json::to_string(&s).unwrap_or_default());
|
||||
self.session = Some(s);
|
||||
self.setup = false;
|
||||
CloudEvent::LoggedIn
|
||||
}
|
||||
_ => CloudEvent::LoginFailed(match res.status {
|
||||
0 => "cloud.err_net",
|
||||
400 => "login.err_invalid",
|
||||
429 => "cloud.err_rate",
|
||||
_ => "login.err_wrong",
|
||||
}),
|
||||
}
|
||||
}
|
||||
Req::Logout => CloudEvent::None,
|
||||
Req::Password => {
|
||||
if ok {
|
||||
CloudEvent::PasswordChanged
|
||||
} else {
|
||||
CloudEvent::Error(match res.status {
|
||||
403 => "account.err_old",
|
||||
400 => "login.err_invalid",
|
||||
_ => error_key(res.status),
|
||||
})
|
||||
}
|
||||
}
|
||||
Req::Users => {
|
||||
if ok {
|
||||
self.users = serde_json::from_slice(&res.body).unwrap_or_default();
|
||||
CloudEvent::None
|
||||
} else {
|
||||
CloudEvent::Error(error_key(res.status))
|
||||
}
|
||||
}
|
||||
Req::CreateUser | Req::DeleteUser => {
|
||||
if ok {
|
||||
self.load_users();
|
||||
CloudEvent::UsersChanged
|
||||
} else {
|
||||
CloudEvent::Error(match res.status {
|
||||
409 => "account.err_exists",
|
||||
400 => "login.err_invalid",
|
||||
_ => error_key(res.status),
|
||||
})
|
||||
}
|
||||
}
|
||||
Req::List => {
|
||||
if !ok {
|
||||
|
||||
+140
-9
@@ -153,6 +153,61 @@ fn store_progress(p: &campaign::Progress) {
|
||||
platform::storage_set(PROGRESS_KEY, &serde_json::to_string(p).unwrap_or_default());
|
||||
}
|
||||
|
||||
// login, account and standalone actions of the menu
|
||||
fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut App, now: f64) {
|
||||
match a {
|
||||
MenuAction::Login(name, pw) => {
|
||||
if name.is_empty() || pw.is_empty() {
|
||||
menu.error = Some(i18n::tr("login.err_invalid"));
|
||||
} else if cloud.login(&name, &pw) {
|
||||
menu.error = Some(i18n::tr("saves.wait"));
|
||||
}
|
||||
}
|
||||
MenuAction::Logout => {
|
||||
cloud.logout();
|
||||
menu.open_login();
|
||||
menu.error = None;
|
||||
}
|
||||
MenuAction::OpenAccount => {
|
||||
if cloud.session.as_ref().map_or(false, |s| s.admin) {
|
||||
cloud.load_users();
|
||||
}
|
||||
}
|
||||
MenuAction::ChangePassword(old, new) => {
|
||||
if new.chars().count() < 4 {
|
||||
menu.error = Some(i18n::tr("login.err_invalid"));
|
||||
} else {
|
||||
cloud.change_password(&old, &new);
|
||||
}
|
||||
}
|
||||
MenuAction::CreateUser(name, pw) => {
|
||||
if name.is_empty() || pw.chars().count() < 4 {
|
||||
menu.error = Some(i18n::tr("login.err_invalid"));
|
||||
} else {
|
||||
cloud.create_user(&name, &pw);
|
||||
}
|
||||
}
|
||||
MenuAction::DeleteUser(name) => {
|
||||
cloud.delete_user(&name);
|
||||
}
|
||||
MenuAction::DownloadStandalone => {
|
||||
platform::fetch_download("standalone/DungeonCraft.html", "DungeonCraft.html");
|
||||
app.set_status(i18n::tr("standalone.running"), now);
|
||||
}
|
||||
MenuAction::EditField(i) => {
|
||||
// touch screens have no keyboard in the canvas: the browser asks
|
||||
if ui::touch_ui() {
|
||||
let secret = matches!((menu.page, i), (Some(Page::Login), 1) | (Some(Page::Account), 0 | 1 | 3));
|
||||
let title = i18n::tr(if secret { "login.password" } else { "login.name" });
|
||||
if let Some(t) = platform::prompt(&title, if secret { "" } else { &menu.fields[i] }) {
|
||||
menu.fields[i] = t.chars().filter(|c| !c.is_control()).take(40).collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn next_seed(seed: u32) -> u32 {
|
||||
seed.wrapping_mul(1103515245).wrapping_add(12345) | 1
|
||||
}
|
||||
@@ -378,21 +433,77 @@ async fn main() {
|
||||
CloudEvent::Saved(name) => app.set_status(i18n::trf("save.saved", &[name]), now),
|
||||
CloudEvent::Loaded(bytes) => app.load_bytes(&bytes, &mut ui, &mut menu, &mut r3d, now),
|
||||
CloudEvent::Deleted => app.set_status(i18n::tr("cloud.deleted"), now),
|
||||
CloudEvent::Error(key) => app.set_status(i18n::tr(key), now),
|
||||
CloudEvent::Error(key) => {
|
||||
if matches!(menu.page, Some(Page::Account)) {
|
||||
menu.error = Some(i18n::tr(key));
|
||||
} else {
|
||||
app.set_status(i18n::tr(key), now);
|
||||
}
|
||||
}
|
||||
// accounts: the login page comes before the title screen
|
||||
CloudEvent::LoginNeeded => {
|
||||
if matches!(menu.page, Some(Page::Title) | None) {
|
||||
menu.open_login();
|
||||
menu.error = None;
|
||||
}
|
||||
}
|
||||
CloudEvent::LoggedIn => {
|
||||
if menu.page == Some(Page::Login) {
|
||||
menu.page = Some(Page::Title);
|
||||
}
|
||||
menu.error = None;
|
||||
if let Some(s) = &cloud.session {
|
||||
app.set_status(i18n::trf("login.welcome", &[s.name.clone()]), now);
|
||||
}
|
||||
}
|
||||
CloudEvent::LoginFailed(key) => menu.error = Some(i18n::tr(key)),
|
||||
CloudEvent::PasswordChanged => {
|
||||
menu.fields[0].clear();
|
||||
menu.fields[1].clear();
|
||||
menu.error = None;
|
||||
app.set_status(i18n::tr("account.pw_changed"), now);
|
||||
}
|
||||
CloudEvent::UsersChanged => {
|
||||
menu.fields[2].clear();
|
||||
menu.fields[3].clear();
|
||||
menu.error = None;
|
||||
app.set_status(i18n::tr("account.users_changed"), now);
|
||||
}
|
||||
}
|
||||
// download of the standalone file
|
||||
match platform::fetch_download_state() {
|
||||
2 => app.set_status(i18n::tr("standalone.done"), now),
|
||||
3 => app.set_status(i18n::tr("standalone.failed"), now),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// ---------------- name field of the savegame page
|
||||
// ---------------- text fields (savegame name, login, account)
|
||||
if menu.typing() {
|
||||
// Tab switches the field right away (the following characters go to the next one)
|
||||
let mut tab_char = false;
|
||||
while let Some(c) = get_char_pressed() {
|
||||
menu.type_char(c);
|
||||
if c == '\t' {
|
||||
tab_char = true;
|
||||
menu.next_field();
|
||||
} else {
|
||||
menu.type_char(c);
|
||||
}
|
||||
}
|
||||
if is_key_pressed(KeyCode::Tab) && !tab_char {
|
||||
menu.next_field();
|
||||
}
|
||||
if is_key_pressed(KeyCode::Backspace) {
|
||||
menu.save_name.pop();
|
||||
menu.backspace();
|
||||
}
|
||||
if is_key_pressed(KeyCode::Enter) || is_key_pressed(KeyCode::KpEnter) {
|
||||
let name = menu.save_name.trim().to_string();
|
||||
if cloud.save(None, &name, &app.autosave_json()) {
|
||||
app.set_status(i18n::tr("saves.wait"), now);
|
||||
if matches!(menu.page, Some(Page::Saves { .. })) {
|
||||
let name = menu.save_name.trim().to_string();
|
||||
if cloud.save(None, &name, &app.autosave_json()) {
|
||||
app.set_status(i18n::tr("saves.wait"), now);
|
||||
}
|
||||
} else {
|
||||
let a = menu.submit();
|
||||
account_action(a, &mut cloud, &mut menu, &mut app, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -680,6 +791,14 @@ async fn main() {
|
||||
autosave_t = 0.0;
|
||||
}
|
||||
MenuAction::OpenRepo => platform::open_url(menu::REPO_URL),
|
||||
a @ (MenuAction::Login(..)
|
||||
| MenuAction::Logout
|
||||
| MenuAction::OpenAccount
|
||||
| MenuAction::ChangePassword(..)
|
||||
| MenuAction::CreateUser(..)
|
||||
| MenuAction::DeleteUser(..)
|
||||
| MenuAction::DownloadStandalone
|
||||
| MenuAction::EditField(..)) => account_action(a, &mut cloud, &mut menu, &mut app, now),
|
||||
MenuAction::EditName => {
|
||||
if let Some(n) = platform::prompt(&i18n::tr("saves.name"), &menu.save_name) {
|
||||
menu.save_name = n.chars().filter(|c| !c.is_control()).take(40).collect();
|
||||
@@ -865,7 +984,13 @@ async fn main() {
|
||||
let on_title = menu.page == Some(Page::Title)
|
||||
|| matches!(
|
||||
menu.page,
|
||||
Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. }) | Some(Page::NewGame) | Some(Page::Campaign) | Some(Page::Briefing { ingame: false, .. })
|
||||
Some(Page::Settings { from_title: true, .. })
|
||||
| Some(Page::KeyCapture { from_title: true, .. })
|
||||
| Some(Page::NewGame)
|
||||
| Some(Page::Campaign)
|
||||
| Some(Page::Briefing { ingame: false, .. })
|
||||
| Some(Page::Login)
|
||||
| Some(Page::Account)
|
||||
);
|
||||
music.update(&app.game, on_title, (app.cam.tx, app.cam.tz));
|
||||
for m in app.game.msgs.drain(..) {
|
||||
@@ -914,7 +1039,13 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
|
||||
r3d.draw(&app.game, gfx, &app.cam, now, &ov);
|
||||
let title_screen = matches!(
|
||||
menu.page,
|
||||
Some(Page::Title) | Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. }) | Some(Page::Campaign) | Some(Page::Briefing { ingame: false, .. })
|
||||
Some(Page::Title)
|
||||
| Some(Page::Settings { from_title: true, .. })
|
||||
| Some(Page::KeyCapture { from_title: true, .. })
|
||||
| Some(Page::Campaign)
|
||||
| Some(Page::Briefing { ingame: false, .. })
|
||||
| Some(Page::Login)
|
||||
| Some(Page::Account)
|
||||
);
|
||||
if !title_screen {
|
||||
let info = if over_ui || (touch && inp.inspect.is_none()) {
|
||||
|
||||
+237
-11
@@ -22,6 +22,8 @@ pub enum Page {
|
||||
Saves { save: bool, from_title: bool }, // savegame list on the server (save or load mode)
|
||||
Campaign, // list of the campaign missions
|
||||
Briefing { mission: usize, ingame: bool }, // story and goals of a mission
|
||||
Login, // login to the savegame service (before the title)
|
||||
Account, // password, logout, users (admin)
|
||||
}
|
||||
|
||||
pub const TABS: [&str; 5] = ["set.tab.sound", "set.tab.camera", "set.tab.graphics", "set.tab.game", "set.tab.keys"];
|
||||
@@ -202,6 +204,14 @@ enum Btn {
|
||||
StartMission,
|
||||
Goals,
|
||||
Repo,
|
||||
Field(usize),
|
||||
DoLogin,
|
||||
Account,
|
||||
Standalone,
|
||||
ChangePassword,
|
||||
Logout,
|
||||
CreateUser,
|
||||
DeleteUser(usize),
|
||||
}
|
||||
|
||||
pub const REPO_URL: &str = "https://git.familie-zink.org/zink/dungeoncraft";
|
||||
@@ -225,6 +235,14 @@ pub enum MenuAction {
|
||||
EditName, // the name field was tapped (touch: ask with a browser dialog)
|
||||
StartMission(usize),
|
||||
OpenRepo,
|
||||
Login(String, String),
|
||||
Logout,
|
||||
OpenAccount,
|
||||
ChangePassword(String, String),
|
||||
CreateUser(String, String),
|
||||
DeleteUser(String),
|
||||
DownloadStandalone,
|
||||
EditField(usize), // touch: ask for the text with the browser dialog
|
||||
}
|
||||
|
||||
pub struct Menu {
|
||||
@@ -233,6 +251,9 @@ pub struct Menu {
|
||||
pub save_name: String, // name for a new savegame (typed on the savegame page)
|
||||
pub progress: Progress, // campaign progress
|
||||
pub mission: Option<usize>, // mission being played (pause menu: goals)
|
||||
pub fields: [String; 4], // text fields of the login and account pages
|
||||
pub focus: usize, // field that gets the typed text
|
||||
pub error: Option<String>, // message on the login and account pages
|
||||
confirm: Option<Btn>, // overwrite / delete waiting for a second click
|
||||
scroll: usize,
|
||||
rows_visible: usize,
|
||||
@@ -254,6 +275,9 @@ impl Menu {
|
||||
save_name: String::new(),
|
||||
progress: Progress::default(),
|
||||
mission: None,
|
||||
fields: Default::default(),
|
||||
focus: 0,
|
||||
error: None,
|
||||
confirm: None,
|
||||
scroll: 0,
|
||||
rows_visible: 1,
|
||||
@@ -275,6 +299,8 @@ impl Menu {
|
||||
Some(Page::Saves { from_title: true, .. }) => Some(Page::Title),
|
||||
Some(Page::Saves { from_title: false, .. }) => Some(Page::Pause),
|
||||
Some(Page::Campaign) => Some(Page::Title),
|
||||
Some(Page::Login) => Some(Page::Login),
|
||||
Some(Page::Account) => Some(Page::Title),
|
||||
Some(Page::Briefing { ingame: true, .. }) => Some(Page::Pause),
|
||||
Some(Page::Briefing { ingame: false, .. }) => Some(Page::Campaign),
|
||||
Some(Page::Pause) => None,
|
||||
@@ -291,17 +317,56 @@ impl Menu {
|
||||
self.scroll = 0;
|
||||
}
|
||||
|
||||
// the name field takes keyboard input
|
||||
// pages with text fields take keyboard input
|
||||
pub fn typing(&self) -> bool {
|
||||
matches!(self.page, Some(Page::Saves { save: true, .. }))
|
||||
matches!(self.page, Some(Page::Saves { save: true, .. }) | Some(Page::Login) | Some(Page::Account))
|
||||
}
|
||||
|
||||
fn input(&mut self) -> &mut String {
|
||||
if matches!(self.page, Some(Page::Saves { .. })) {
|
||||
&mut self.save_name
|
||||
} else {
|
||||
&mut self.fields[self.focus.min(3)]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn type_char(&mut self, c: char) {
|
||||
if !c.is_control() && self.save_name.chars().count() < 40 {
|
||||
self.save_name.push(c);
|
||||
let s = self.input();
|
||||
if !c.is_control() && s.chars().count() < 40 {
|
||||
s.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn backspace(&mut self) {
|
||||
self.input().pop();
|
||||
}
|
||||
|
||||
// Tab: next text field
|
||||
pub fn next_field(&mut self) {
|
||||
let n = match self.page {
|
||||
Some(Page::Login) => 2,
|
||||
Some(Page::Account) => 4,
|
||||
_ => 1,
|
||||
};
|
||||
self.focus = (self.focus + 1) % n;
|
||||
}
|
||||
|
||||
// Enter on the login page logs in, on the account page it uses the focused group
|
||||
pub fn submit(&mut self) -> MenuAction {
|
||||
match self.page {
|
||||
Some(Page::Login) => MenuAction::Login(self.fields[0].trim().to_string(), self.fields[1].clone()),
|
||||
Some(Page::Account) if self.focus < 2 => MenuAction::ChangePassword(self.fields[0].clone(), self.fields[1].clone()),
|
||||
Some(Page::Account) => MenuAction::CreateUser(self.fields[2].trim().to_string(), self.fields[3].clone()),
|
||||
_ => MenuAction::None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_login(&mut self) {
|
||||
self.page = Some(Page::Login);
|
||||
self.fields = Default::default();
|
||||
self.focus = 0;
|
||||
}
|
||||
|
||||
pub fn capturing(&self) -> Option<Action> {
|
||||
match self.page {
|
||||
Some(Page::KeyCapture { action, .. }) => all_actions().get(action).copied(),
|
||||
@@ -408,6 +473,30 @@ impl Menu {
|
||||
}
|
||||
Some(Btn::NameField) => MenuAction::EditName,
|
||||
Some(Btn::Repo) => MenuAction::OpenRepo,
|
||||
Some(Btn::Field(i)) => {
|
||||
self.focus = i;
|
||||
MenuAction::EditField(i)
|
||||
}
|
||||
Some(Btn::DoLogin) => MenuAction::Login(self.fields[0].trim().to_string(), self.fields[1].clone()),
|
||||
Some(Btn::Account) => {
|
||||
self.page = Some(Page::Account);
|
||||
self.fields = Default::default();
|
||||
self.focus = 0;
|
||||
self.error = None;
|
||||
MenuAction::OpenAccount
|
||||
}
|
||||
Some(Btn::Standalone) => MenuAction::DownloadStandalone,
|
||||
Some(Btn::ChangePassword) => MenuAction::ChangePassword(self.fields[0].clone(), self.fields[1].clone()),
|
||||
Some(Btn::Logout) => MenuAction::Logout,
|
||||
Some(Btn::CreateUser) => MenuAction::CreateUser(self.fields[2].trim().to_string(), self.fields[3].clone()),
|
||||
Some(b @ Btn::DeleteUser(i)) => match cloud.users.get(i) {
|
||||
Some(u) if confirm == Some(b) => MenuAction::DeleteUser(u.name.clone()),
|
||||
Some(_) => {
|
||||
self.confirm = Some(b);
|
||||
MenuAction::Consumed
|
||||
}
|
||||
None => MenuAction::Consumed,
|
||||
},
|
||||
Some(Btn::Campaign) => {
|
||||
self.page = Some(Page::Campaign);
|
||||
self.scroll = 0;
|
||||
@@ -441,12 +530,14 @@ impl Menu {
|
||||
let sh = sh();
|
||||
draw_rectangle(0.0, 0.0, sw, sh, Color::new(0.0, 0.0, 0.0, if page == Page::Title { 0.55 } else { 0.45 }));
|
||||
match page {
|
||||
Page::Title => self.draw_title(gfx),
|
||||
Page::Title => self.draw_title(gfx, cloud),
|
||||
Page::Pause => self.draw_pause(gfx),
|
||||
Page::NewGame => self.draw_new_game(gfx, settings),
|
||||
Page::Settings { tab, .. } => self.draw_settings(gfx, settings, tab),
|
||||
Page::Saves { save, .. } => self.draw_saves(gfx, cloud, save),
|
||||
Page::Campaign => self.draw_campaign(gfx),
|
||||
Page::Login => self.draw_login(gfx, cloud),
|
||||
Page::Account => self.draw_account(gfx, cloud),
|
||||
Page::Briefing { mission, ingame } => self.draw_briefing(gfx, mission, ingame),
|
||||
Page::KeyCapture { action, .. } => {
|
||||
self.draw_settings(gfx, settings, 4);
|
||||
@@ -463,12 +554,18 @@ impl Menu {
|
||||
}
|
||||
}
|
||||
if let Some(st) = status {
|
||||
let w = gfx.measure(st, S()) + CW() * 4.0;
|
||||
// long messages wrap onto several lines
|
||||
let maxc = ((sw / CW()) as usize).saturating_sub(8).clamp(10, 70);
|
||||
let lines = wrap(st, maxc);
|
||||
let w = lines.iter().map(|l| gfx.measure(l, S())).fold(0.0, f32::max) + CW() * 4.0;
|
||||
let h = CH() * (lines.len() as f32 + 1.0);
|
||||
let x = grid_center(sw, w);
|
||||
let y = sh - CH() * 9.0;
|
||||
draw_rectangle(x, y, w, CH() * 2.0, Color::new(0.05, 0.03, 0.04, 0.95));
|
||||
draw_rectangle_lines(x, y, w, CH() * 2.0, 2.0, GOLD);
|
||||
gfx.text(st, x + CW() * 2.0, y + CH() * 0.5, S(), GOLD);
|
||||
let y = sh - CH() * 8.0 - h;
|
||||
draw_rectangle(x, y, w, h, Color::new(0.05, 0.03, 0.04, 0.95));
|
||||
draw_rectangle_lines(x, y, w, h, 2.0, GOLD);
|
||||
for (k, l) in lines.iter().enumerate() {
|
||||
gfx.text(l, x + CW() * 2.0, y + CH() * (0.5 + k as f32), S(), GOLD);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,7 +578,7 @@ impl Menu {
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_title(&mut self, gfx: &Gfx) {
|
||||
fn draw_title(&mut self, gfx: &Gfx, cloud: &Cloud) {
|
||||
let sw = sw();
|
||||
let sh = sh();
|
||||
let big = TITLE();
|
||||
@@ -499,6 +596,10 @@ impl Menu {
|
||||
items.push((Btn::New, tr("menu.new")));
|
||||
items.push((Btn::Load, tr("menu.load")));
|
||||
items.push((Btn::Settings, tr("menu.settings")));
|
||||
if let Some(s) = cloud.session.as_ref().filter(|_| cloud.available()) {
|
||||
items.push((Btn::Account, trf("menu.account", &[s.name.clone()])));
|
||||
items.push((Btn::Standalone, tr("menu.standalone")));
|
||||
}
|
||||
items.push((Btn::Help, tr("menu.help")));
|
||||
let w = 26.0 * CW();
|
||||
let y = ty + GH * big + CH() * 4.0;
|
||||
@@ -921,6 +1022,131 @@ impl Menu {
|
||||
pub fn scroll_lines(&mut self, d: i32) {
|
||||
self.scroll = (self.scroll as i32 + d).max(0) as usize;
|
||||
}
|
||||
|
||||
// a text field; passwords are shown as stars
|
||||
fn field(&mut self, gfx: &Gfx, x: f32, y: f32, w: f32, label: &str, i: usize, secret: bool) {
|
||||
gfx.text(label, x, y + CH() * 0.5, S(), TXT);
|
||||
let lw = 16.0 * CW();
|
||||
let r = Rect::new(x + lw, y, w - lw, CH() * 2.0);
|
||||
let focused = self.focus == i;
|
||||
draw_rectangle(r.x, r.y, r.w, r.h, Color::new(0.02, 0.01, 0.02, 1.0));
|
||||
draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if focused { FRAME_HI } else { FRAME });
|
||||
let shown: String = if secret { "*".repeat(self.fields[i].chars().count()) } else { self.fields[i].clone() };
|
||||
let maxc = ((r.w - CW() * 2.0) / CW()).max(1.0) as usize;
|
||||
let all: Vec<char> = shown.chars().collect();
|
||||
let mut text: String = all[all.len().saturating_sub(maxc - 1)..].iter().collect();
|
||||
if focused && (get_time() * 2.0) as i64 % 2 == 0 {
|
||||
text.push('_');
|
||||
}
|
||||
gfx.text(&text, r.x + CW(), r.y + CH() * 0.5, S(), GOLD);
|
||||
self.buttons.push((r, Btn::Field(i)));
|
||||
}
|
||||
|
||||
// Login to the server (the first login creates the admin account)
|
||||
fn draw_login(&mut self, gfx: &Gfx, cloud: &Cloud) {
|
||||
let sw = sw();
|
||||
let sh = sh();
|
||||
let (mx, my) = mouse();
|
||||
let big = TITLE();
|
||||
let title = "DungeonCraft";
|
||||
let ty = (sh * 0.12 / CH()).floor() * CH();
|
||||
gfx.text(title, grid_center(sw, gfx.measure(title, big)) + big, ty + big, big, Color::new(0.0, 0.0, 0.0, 0.8));
|
||||
gfx.text(title, grid_center(sw, gfx.measure(title, big)), ty, big, FRAME_HI);
|
||||
let cols = ((sw / CW()) as i32 - 2).min(50);
|
||||
let rows = 13;
|
||||
let x = grid_center(sw, cols as f32 * CW()).max(0.0);
|
||||
let y = ty + GH * big + CH() * 3.0;
|
||||
let head = tr(if cloud.setup { "login.setup" } else { "login.title" });
|
||||
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &head, FRAME_HI);
|
||||
let w = (cols - 6) as f32 * CW();
|
||||
if cloud.setup {
|
||||
gfx.text(&tr("login.setup_hint"), x + CW() * 3.0, y + CH() * 1.5, SMALL(), DIM);
|
||||
}
|
||||
self.field(gfx, x + CW() * 3.0, y + CH() * 3.0, w, &tr("login.name"), 0, false);
|
||||
self.field(gfx, x + CW() * 3.0, y + CH() * 5.5, w, &tr("login.password"), 1, true);
|
||||
if let Some(e) = &self.error {
|
||||
let maxc = ((cols - 6) as f32 * CW() / (GW * SMALL())) as usize;
|
||||
for (k, l) in wrap(e, maxc).iter().take(2).enumerate() {
|
||||
gfx.text(l, x + CW() * 3.0, y + CH() * 7.9 + k as f32 * GH * SMALL() * 1.2, SMALL(), Color::new(0.95, 0.4, 0.35, 1.0));
|
||||
}
|
||||
}
|
||||
let label = tr(if cloud.setup { "login.create_admin" } else { "login.button" });
|
||||
let bw = gfx.measure(&label, S()) + CW() * 6.0;
|
||||
let br = Rect::new(x + cols as f32 * CW() - bw - CW() * 3.0, y + CH() * 9.5, bw, CH() * 2.0);
|
||||
button(gfx, br, &label, br.contains(vec2(mx, my)) && !cloud.busy(), FRAME_HI);
|
||||
self.buttons.push((br, Btn::DoLogin));
|
||||
self.draw_license(gfx);
|
||||
}
|
||||
|
||||
// Account: change the password, log out; the admin manages the users
|
||||
fn draw_account(&mut self, gfx: &Gfx, cloud: &Cloud) {
|
||||
let sw = sw();
|
||||
let sh = sh();
|
||||
let (mx, my) = mouse();
|
||||
let admin = cloud.session.as_ref().map_or(false, |s| s.admin);
|
||||
let cols = ((sw / CW()) as i32 - 2).min(60);
|
||||
let rows = ((sh / CH()) as i32 - 2).min(if admin { 38 } else { 18 });
|
||||
let x = grid_center(sw, cols as f32 * CW()).max(0.0);
|
||||
let y = grid_center_y(sh, rows as f32 * CH()).max(0.0);
|
||||
let who = cloud.session.as_ref().map(|s| s.name.clone()).unwrap_or_default();
|
||||
let head = trf(if admin { "account.title_admin" } else { "account.title" }, &[who]);
|
||||
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &head, FRAME_HI);
|
||||
let ix = x + CW() * 3.0;
|
||||
let w = (cols - 6) as f32 * CW();
|
||||
gfx.text(&tr("account.change_pw"), ix, y + CH() * 2.0, S(), GOLD);
|
||||
self.field(gfx, ix, y + CH() * 3.5, w, &tr("account.old_pw"), 0, true);
|
||||
self.field(gfx, ix, y + CH() * 6.0, w, &tr("account.new_pw"), 1, true);
|
||||
let cl = tr("account.save_pw");
|
||||
let cw2 = gfx.measure(&cl, S()) + CW() * 4.0;
|
||||
let cr = Rect::new(ix + w - cw2, y + CH() * 8.5, cw2, CH() * 2.0);
|
||||
button(gfx, cr, &cl, cr.contains(vec2(mx, my)), FRAME);
|
||||
self.buttons.push((cr, Btn::ChangePassword));
|
||||
let mut yy = y + CH() * 11.5;
|
||||
if admin {
|
||||
gfx.text(&tr("account.users"), ix, yy, S(), GOLD);
|
||||
yy += CH() * 1.5;
|
||||
self.field(gfx, ix, yy, w, &tr("account.user_name"), 2, false);
|
||||
self.field(gfx, ix, yy + CH() * 2.5, w, &tr("account.user_pw"), 3, true);
|
||||
let nl = tr("account.create");
|
||||
let nw = gfx.measure(&nl, S()) + CW() * 4.0;
|
||||
let nr = Rect::new(ix + w - nw, yy + CH() * 5.0, nw, CH() * 2.0);
|
||||
button(gfx, nr, &nl, nr.contains(vec2(mx, my)), FRAME);
|
||||
self.buttons.push((nr, Btn::CreateUser));
|
||||
yy += CH() * 8.0;
|
||||
let del = tr("saves.delete");
|
||||
let sure = tr("saves.sure");
|
||||
let dw = gfx.measure(&del, S()).max(gfx.measure(&sure, S())) + CW() * 3.0;
|
||||
let bottom = y + (rows - 5) as f32 * CH();
|
||||
for (k, u) in cloud.users.iter().enumerate() {
|
||||
let ry = yy + k as f32 * CH() * 2.5;
|
||||
if ry + CH() * 2.0 > bottom {
|
||||
break;
|
||||
}
|
||||
let label = if u.admin { format!("{} (admin)", u.name) } else { u.name.clone() };
|
||||
gfx.text(&label, ix, ry + CH() * 0.5, S(), TXT);
|
||||
if !u.admin {
|
||||
let r = Rect::new(ix + w - dw, ry, dw, CH() * 2.0);
|
||||
let c = self.confirm == Some(Btn::DeleteUser(k));
|
||||
button(gfx, r, if c { &sure } else { &del }, r.contains(vec2(mx, my)) || c, Color::new(0.6, 0.2, 0.15, 1.0));
|
||||
self.buttons.push((r, Btn::DeleteUser(k)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = yy;
|
||||
if let Some(e) = &self.error {
|
||||
gfx.text(e, ix, y + (rows - 5) as f32 * CH() + CH() * 0.5, S(), Color::new(0.95, 0.4, 0.35, 1.0));
|
||||
}
|
||||
let by = y + (rows - 4) as f32 * CH();
|
||||
let back = tr("menu.back");
|
||||
let br = Rect::new(ix - CW(), by, gfx.measure(&back, S()) + CW() * 6.0, CH() * 2.0);
|
||||
button(gfx, br, &back, br.contains(vec2(mx, my)), FRAME);
|
||||
self.buttons.push((br, Btn::Back));
|
||||
let lo = tr("account.logout");
|
||||
let lw = gfx.measure(&lo, S()) + CW() * 4.0;
|
||||
let lr = Rect::new(ix + w - lw, by, lw, CH() * 2.0);
|
||||
button(gfx, lr, &lo, lr.contains(vec2(mx, my)), FRAME);
|
||||
self.buttons.push((lr, Btn::Logout));
|
||||
}
|
||||
}
|
||||
|
||||
// description of a mission goal (without progress)
|
||||
|
||||
@@ -16,6 +16,9 @@ mod imp {
|
||||
fn dc_storage_take(dst: *mut u8);
|
||||
fn dc_coarse_pointer() -> i32;
|
||||
fn dc_open_url(u: *const u8, ul: u32);
|
||||
fn dc_set_token(t: *const u8, tl: u32);
|
||||
fn dc_fetch_download(u: *const u8, ul: u32, n: *const u8, nl: u32);
|
||||
fn dc_fetch_download_state() -> i32;
|
||||
fn dc_prompt(t: *const u8, tl: u32, d: *const u8, dl: u32) -> i32;
|
||||
fn dc_storage_set(key: *const u8, key_len: u32, val: *const u8, val_len: u32);
|
||||
fn dc_cache_set(key: *const u8, key_len: u32, val: *const u8, val_len: u32);
|
||||
@@ -67,6 +70,21 @@ mod imp {
|
||||
unsafe { dc_open_url(url.as_ptr(), url.len() as u32) }
|
||||
}
|
||||
|
||||
// login session sent with every request to the service
|
||||
pub fn set_token(token: &str) {
|
||||
unsafe { dc_set_token(token.as_ptr(), token.len() as u32) }
|
||||
}
|
||||
|
||||
// downloads a file of the server (with the login) straight to the user's disk
|
||||
pub fn fetch_download(url: &str, name: &str) {
|
||||
unsafe { dc_fetch_download(url.as_ptr(), url.len() as u32, name.as_ptr(), name.len() as u32) }
|
||||
}
|
||||
|
||||
// 0 idle, 1 running, 2 done, 3 failed (2 and 3 are reported once)
|
||||
pub fn fetch_download_state() -> i32 {
|
||||
unsafe { dc_fetch_download_state() }
|
||||
}
|
||||
|
||||
// asks for a text with the browser's dialog; None when cancelled
|
||||
pub fn prompt(title: &str, default: &str) -> Option<String> {
|
||||
unsafe {
|
||||
@@ -128,6 +146,11 @@ mod imp {
|
||||
false
|
||||
}
|
||||
pub fn open_url(_url: &str) {}
|
||||
pub fn set_token(_token: &str) {}
|
||||
pub fn fetch_download(_url: &str, _name: &str) {}
|
||||
pub fn fetch_download_state() -> i32 {
|
||||
0
|
||||
}
|
||||
pub fn storage_set(key: &str, val: &str) {
|
||||
STORE.with(|s| s.borrow_mut().insert(key.to_string(), val.to_string()));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user