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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-03 13:33:22 +01:00
1 parent 8853d84564
commit c9848c8948
15 files changed
+1232 -83

No files matched your search

+162 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+23
View File
@@ -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()));
}