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