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
+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()) {
|
||||
|
||||
Reference in new issue
Block a user