use flatland_cli::characters::{self, CharacterSummary};
use flatland_cli::config::{SavedLogin, SessionConfig};
use flatland_cli::session_auth;
use flatland_gfx_engine::GfxTheme;
use macroquad::prelude::*;
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Phase {
Auth,
CreateCharacter,
SelectCharacter,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AuthMode {
Login,
Register,
}
pub struct OnboardingOpts<'a> {
pub preferred_name: Option<&'a str>,
pub force_character_select: bool,
pub status_hint: Option<String>,
}
struct OnboardingState {
phase: Phase,
auth_mode: AuthMode,
email: String,
password: String,
display_name: String,
character_name: String,
characters: Vec<CharacterSummary>,
status: String,
busy: bool,
egui_configured: bool,
preferred_name: Option<String>,
force_character_select: bool,
highlight_id: Option<Uuid>,
result: Option<CharacterSummary>,
quit: bool,
}
impl OnboardingState {
fn new(opts: &OnboardingOpts<'_>) -> Self {
let saved = SavedLogin::load();
Self {
phase: Phase::Auth,
auth_mode: AuthMode::Login,
email: saved
.as_ref()
.map(|s| s.email.clone())
.unwrap_or_default(),
password: saved
.as_ref()
.map(|s| s.password.clone())
.unwrap_or_default(),
display_name: String::new(),
character_name: opts.preferred_name.unwrap_or("").to_string(),
characters: Vec::new(),
status: if let Some(hint) = opts.status_hint.clone() {
hint
} else if saved.is_some() {
"Sign in to play (saved login loaded).".into()
} else {
"Sign in to play.".into()
},
busy: false,
egui_configured: false,
preferred_name: opts.preferred_name.map(str::to_string),
force_character_select: opts.force_character_select,
highlight_id: None,
result: None,
quit: false,
}
}
}
pub async fn run(opts: OnboardingOpts<'_>) -> anyhow::Result<Option<CharacterSummary>> {
if session_auth::has_api_token() {
return Ok(None);
}
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let mut state = OnboardingState::new(&opts);
if opts.force_character_select {
let session_ok = rt.block_on(session_auth::session_is_valid())
|| rt.block_on(session_auth::try_restore_expired_session());
if session_ok {
match rt.block_on(enter_character_phase(&mut state)) {
Ok(Some(chosen)) => return Ok(Some(chosen)),
Ok(None) => {}
Err(err) => {
state.status = err.to_string();
state.phase = Phase::Auth;
}
}
} else {
state.status = "Session expired — sign in again.".into();
state.phase = Phase::Auth;
}
} else if !rt.block_on(session_auth::needs_interactive_login()) {
match rt.block_on(enter_character_phase(&mut state)) {
Ok(Some(chosen)) => return Ok(Some(chosen)),
Ok(None) => {}
Err(err) => {
state.status = err.to_string();
state.phase = Phase::Auth;
}
}
}
while state.result.is_none() && !state.quit {
clear_background(Color::from_rgba(8, 9, 12, 255));
let mut submit = false;
let mut switch_login = false;
let mut switch_register = false;
let mut selected: Option<usize> = None;
egui_macroquad::ui(|ctx| {
if !state.egui_configured {
flatland_gfx_engine::configure_egui(ctx);
state.egui_configured = true;
}
draw_panel(
ctx,
&mut state,
&mut submit,
&mut switch_login,
&mut switch_register,
&mut selected,
);
});
egui_macroquad::draw();
flatland_gfx_engine::drain_pending_input();
if is_key_pressed(KeyCode::Escape) {
anyhow::bail!("onboarding cancelled");
}
if switch_login {
state.auth_mode = AuthMode::Login;
state.status = "Sign in with email and password.".into();
}
if switch_register {
state.auth_mode = AuthMode::Register;
state.status = "Create an account, then make a character.".into();
}
if let Some(idx) = selected {
if let Some(c) = state.characters.get(idx).cloned() {
let _ = characters::remember_character(c.id);
state.result = Some(c);
}
}
if submit && !state.busy {
state.busy = true;
state.status = "Working…".into();
match state.phase {
Phase::Auth => {
let auth_result = match state.auth_mode {
AuthMode::Login => {
let email = state.email.clone();
let password = state.password.clone();
rt.block_on(session_auth::login_default(&email, &password))
}
AuthMode::Register => {
let email = state.email.clone();
let password = state.password.clone();
let display_name = state.display_name.clone();
rt.block_on(session_auth::register_default(
&email,
&password,
&display_name,
))
}
};
match auth_result {
Ok(()) => match rt.block_on(enter_character_phase(&mut state)) {
Ok(Some(chosen)) => state.result = Some(chosen),
Ok(None) => {}
Err(err) => state.status = err.to_string(),
},
Err(err) => state.status = err.to_string(),
}
}
Phase::CreateCharacter => {
let name = state.character_name.clone();
match rt.block_on(characters::create_character(&name)) {
Ok(created) => {
state.status = format!("Created {}.", created.name);
state.result = Some(created);
}
Err(err) => state.status = err.to_string(),
}
}
Phase::SelectCharacter => {}
}
state.busy = false;
}
next_frame().await;
}
if state.quit {
anyhow::bail!("onboarding cancelled");
}
for _ in 0..2 {
flatland_gfx_engine::drain_pending_input();
next_frame().await;
}
flatland_gfx_engine::drain_pending_input();
Ok(state.result)
}
async fn enter_character_phase(
state: &mut OnboardingState,
) -> anyhow::Result<Option<CharacterSummary>> {
let list = characters::list_characters().await?;
state.characters = list.clone();
state.highlight_id = resolve_highlight(&list, state.preferred_name.as_deref());
if list.is_empty() {
if let Some(preferred) = state
.preferred_name
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
state.character_name = preferred.to_string();
state.status = format!("Create character \"{preferred}\" to continue.");
} else {
state.status = "Create your first character.".into();
}
state.phase = Phase::CreateCharacter;
return Ok(None);
}
if list.len() == 1 && !state.force_character_select {
let only = list[0].clone();
let _ = characters::remember_character(only.id);
return Ok(Some(only));
}
state.phase = Phase::SelectCharacter;
state.status = match state.highlight_id.and_then(|id| list.iter().find(|c| c.id == id)) {
Some(c) => format!("Select a character (last / suggested: {}).", c.name),
None => "Select a character.".into(),
};
Ok(None)
}
fn resolve_highlight(list: &[CharacterSummary], preferred_name: Option<&str>) -> Option<Uuid> {
if let Some(raw) = preferred_name.map(str::trim).filter(|s| !s.is_empty()) {
if let Some(found) = list.iter().find(|c| c.name == raw) {
return Some(found.id);
}
}
if let Ok(session) = SessionConfig::load() {
if let Some(id) = session.last_character_id {
if list.iter().any(|c| c.id == id) {
return Some(id);
}
}
}
None
}
fn draw_panel(
ctx: &egui::Context,
state: &mut OnboardingState,
submit: &mut bool,
switch_login: &mut bool,
switch_register: &mut bool,
selected: &mut Option<usize>,
) {
let theme = GfxTheme::default();
let screen = ctx.screen_rect();
let width = (screen.width() * 0.42).clamp(360.0, 520.0);
let height = match state.phase {
Phase::Auth if state.auth_mode == AuthMode::Register => 420.0,
Phase::Auth => 360.0,
Phase::CreateCharacter => 300.0,
Phase::SelectCharacter => (280.0 + state.characters.len() as f32 * 36.0).min(520.0),
};
egui::Window::new(match state.phase {
Phase::Auth => match state.auth_mode {
AuthMode::Login => "Flatland3 — Sign in",
AuthMode::Register => "Flatland3 — Create account",
},
Phase::CreateCharacter => "Flatland3 — Create character",
Phase::SelectCharacter => "Flatland3 — Select character",
})
.title_bar(false)
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
.default_size(egui::vec2(width, height))
.frame(flatland_gfx_engine::ui::card_frame(&theme))
.show(ctx, |ui| {
ui.set_min_size(egui::vec2(width - 24.0, height - 48.0));
let title = match state.phase {
Phase::Auth => match state.auth_mode {
AuthMode::Login => "Sign in",
AuthMode::Register => "Create account",
},
Phase::CreateCharacter => "Create character",
Phase::SelectCharacter => "Select character",
};
flatland_gfx_engine::ui::draw_card_header(ui, &theme, title, Some("Flatland3"));
ui.add_space(6.0);
ui.add_enabled_ui(!state.busy, |ui| {
match state.phase {
Phase::Auth => draw_auth(ui, state, submit, switch_login, switch_register),
Phase::CreateCharacter => draw_create_character(ui, state, submit),
Phase::SelectCharacter => draw_select_character(ui, state, selected),
}
});
ui.add_space(8.0);
if !state.status.is_empty() {
let color = if state.busy {
theme.accent
} else {
theme.selected
};
ui.colored_label(color, &state.status);
}
flatland_gfx_engine::ui::draw_keybind_footer(
ui,
&theme,
&[
flatland_gfx_engine::ui::Keybind::new("Enter", "continue"),
flatland_gfx_engine::ui::Keybind::new("Esc", "quit"),
],
);
});
}
fn draw_auth(
ui: &mut egui::Ui,
state: &mut OnboardingState,
submit: &mut bool,
switch_login: &mut bool,
switch_register: &mut bool,
) {
ui.horizontal(|ui| {
if ui
.selectable_label(state.auth_mode == AuthMode::Login, "Login")
.clicked()
{
*switch_login = true;
}
if ui
.selectable_label(state.auth_mode == AuthMode::Register, "Create account")
.clicked()
{
*switch_register = true;
}
});
ui.add_space(8.0);
ui.label("Email");
ui.add(
egui::TextEdit::singleline(&mut state.email)
.desired_width(f32::INFINITY)
.hint_text("you@example.com"),
);
ui.label("Password");
ui.add(
egui::TextEdit::singleline(&mut state.password)
.password(true)
.desired_width(f32::INFINITY),
);
if state.auth_mode == AuthMode::Register {
ui.label("Display name");
ui.add(
egui::TextEdit::singleline(&mut state.display_name)
.desired_width(f32::INFINITY)
.hint_text("Shown on your account"),
);
}
ui.add_space(12.0);
let label = match state.auth_mode {
AuthMode::Login => "Sign in",
AuthMode::Register => "Create account",
};
if ui
.add_sized(
egui::vec2(ui.available_width(), 32.0),
egui::Button::new(label),
)
.clicked()
|| ui.input(|i| i.key_pressed(egui::Key::Enter))
{
*submit = true;
}
}
fn draw_create_character(ui: &mut egui::Ui, state: &mut OnboardingState, submit: &mut bool) {
ui.label("Choose a character name (letters, digits, _ and -).");
ui.add_space(8.0);
ui.label("Name");
ui.add(
egui::TextEdit::singleline(&mut state.character_name)
.desired_width(f32::INFINITY)
.hint_text("Traveler"),
);
ui.add_space(12.0);
if ui
.add_sized(
egui::vec2(ui.available_width(), 32.0),
egui::Button::new("Create character"),
)
.clicked()
|| ui.input(|i| i.key_pressed(egui::Key::Enter))
{
*submit = true;
}
}
fn draw_select_character(
ui: &mut egui::Ui,
state: &mut OnboardingState,
selected: &mut Option<usize>,
) {
ui.label("Choose which character to play. Your choice is remembered next time.");
ui.add_space(8.0);
egui::ScrollArea::vertical().show(ui, |ui| {
for (idx, c) in state.characters.iter().enumerate() {
let suggested = state.highlight_id == Some(c.id);
let label = if suggested {
format!("{} ★", c.name)
} else {
c.name.clone()
};
let button = if suggested {
egui::Button::new(egui::RichText::new(label).strong())
} else {
egui::Button::new(label)
};
if ui
.add_sized(egui::vec2(ui.available_width(), 28.0), button)
.clicked()
{
*selected = Some(idx);
}
}
});
}