use std::net::SocketAddr;
use std::time::Instant;
use clap::Parser;
use flatland_client_lib::{
apply_host_override, default_game_server_addr, host_override_active, load_client_settings,
resolve_game_host_addr, ClientConfig,
};
use flatland_client_ui::{
ActiveOverlay, InputAction, UiKeyCode, UiKeyEvent, UiKeyEventKind, UiKeyModifiers,
UiMouseButton, UiPointerEvent,
};
use flatland_gfx_engine::{
blocks_map_click, blocks_map_zoom, drain_pending_input, is_map_zoom_key, letter_key_held,
menu_blocking, route_editor_active, screen_to_world, zoom_from_input, ClientPerf,
ClientPerfConfig, DisconnectGate, FrameSample, GfxInputResult, GfxRenderer,
};
use flatland_play_loop::{
build_play_hud, movement_interval_elapsed, should_dispatch_to_net, PlayHudInput, PlayLocalState,
};
use flatland_protocol::ChatChannel;
use macroquad::prelude::*;
use crate::debug_log::session_log;
use crate::network::{ConnectionUi, GfxNet, NetCmd};
use crate::onboarding;
use crate::update::{self, SharedUpdater};
use crate::window_prefs::{apply_saved_position, capture_window_geometry, save_window_geometry};
#[derive(Debug, Clone, Parser)]
pub struct GfxPlayArgs {
#[arg(short, long)]
pub name: Option<String>,
#[arg(long)]
pub host: Option<String>,
#[arg(long)]
pub server: Option<SocketAddr>,
#[arg(long)]
pub no_reconnect: bool,
#[arg(long)]
pub skip_asset_sync: bool,
#[arg(long)]
pub perf: bool,
#[arg(long)]
pub perf_hud: bool,
#[arg(long)]
pub perf_hitch_ms: Option<f32>,
#[arg(long)]
pub check_update: bool,
}
impl GfxPlayArgs {
pub fn prepare(&self) -> anyhow::Result<SocketAddr> {
if let Some(host) = self.host.as_deref() {
let (addr, _) = apply_host_override(host)?;
return Ok(addr);
}
if let Some(addr) = self.server {
return Ok(addr);
}
Ok(default_game_server_addr())
}
pub fn resolve_server(&self) -> anyhow::Result<SocketAddr> {
if host_override_active() {
return Ok(default_game_server_addr());
}
if let Some(host) = self.host.as_deref() {
return resolve_game_host_addr(host);
}
if let Some(addr) = self.server {
return Ok(addr);
}
ClientConfig::load()
.resolve_game_server_addr()
.or_else(|_| Ok(default_game_server_addr()))
}
}
enum SessionEnd {
Quit,
SwitchCharacter,
Logout,
Disconnected,
}
pub async fn run(args: GfxPlayArgs) -> anyhow::Result<()> {
if args.check_update {
return update::run_check_update_cli().await;
}
let _ = args.prepare()?;
let keys = load_client_settings().keys;
let updater = update::new_shared();
update::spawn_check(updater.clone());
let mut renderer = GfxRenderer::init().await;
let mut client_perf = ClientPerf::new(
ClientPerfConfig::from_env().with_cli(args.perf, args.perf_hud, args.perf_hitch_ms),
);
client_perf.set_log_sink(Box::new(|line| session_log(line)));
if client_perf.enabled() {
session_log(&format!(
"client perf enabled hitch≥{:.0}ms hud={} (F8 toggles HUD)",
client_perf.config().hitch_ms,
client_perf.hud_visible()
));
}
let mut window_position_applied = false;
let mut last_window_save = Instant::now();
let mut last_saved_geometry = capture_window_geometry();
let mut force_character_select = false;
let mut force_auth = false;
let mut status_hint: Option<String> = None;
loop {
let chosen = onboarding::run(onboarding::OnboardingOpts {
preferred_name: args.name.as_deref(),
force_character_select,
force_auth,
status_hint: status_hint.take(),
host_override: host_override_active(),
})
.await?;
force_character_select = false;
force_auth = false;
if let Some(ref c) = chosen {
session_log(&format!(
"character selected: {} ({})",
c.name, c.id
));
} else {
session_log("character resolved via API token / name");
}
let end = run_play_session(
args.clone(),
chosen,
&keys,
&mut renderer,
&mut client_perf,
&mut window_position_applied,
&mut last_window_save,
&mut last_saved_geometry,
&updater,
)
.await?;
match end {
SessionEnd::Quit => {
session_log("session end: quit");
break;
}
SessionEnd::SwitchCharacter => {
session_log("session end: switch character");
force_character_select = true;
}
SessionEnd::Logout => {
session_log("session end: logout");
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
if let Err(err) = rt.block_on(flatland_cli::session_auth::logout()) {
eprintln!("flatland3-gfx: logout: {err:#}");
}
force_auth = true;
status_hint = Some("Logged out — sign in again.".into());
}
SessionEnd::Disconnected => {
session_log("session end: disconnected → login");
force_auth = true;
status_hint = Some(
"Disconnected from the game server — sign in or pick a character to retry."
.into(),
);
}
}
}
save_window_geometry(capture_window_geometry());
renderer.flush_ui_prefs();
Ok(())
}
async fn run_play_session(
args: GfxPlayArgs,
chosen: Option<flatland_cli::characters::CharacterSummary>,
keys: &flatland_client_lib::ClientKeyBindings,
renderer: &mut GfxRenderer,
client_perf: &mut ClientPerf,
window_position_applied: &mut bool,
last_window_save: &mut Instant,
last_saved_geometry: &mut flatland_client_lib::GfxWindowPrefs,
updater: &SharedUpdater,
) -> anyhow::Result<SessionEnd> {
let (net, ready_rx) = GfxNet::spawn_connecting(args, chosen)?;
loop {
match ready_rx.try_recv() {
Ok(Ok(())) => break,
Ok(Err(err)) => {
let msg = err.to_string();
loop {
let gate = DisconnectGate {
reconnecting: false,
attempt: 0,
next_secs: 0.0,
reason: Some("failed to connect"),
last_error: Some(msg.as_str()),
};
renderer.draw_offline_gate(gate);
let menu_actions = renderer.take_menu_actions();
if menu_actions.return_to_login
|| menu_actions.logout
|| is_key_pressed(KeyCode::Escape)
{
drop(net);
return Ok(SessionEnd::Logout);
}
if menu_actions.retry_connect || is_key_pressed(KeyCode::R) {
drop(net);
return Ok(SessionEnd::Disconnected);
}
if is_key_pressed(KeyCode::Q) {
drop(net);
return Ok(SessionEnd::Quit);
}
next_frame().await;
}
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
session_log("network thread exited before ready (channel closed)");
loop {
let gate = DisconnectGate {
reconnecting: false,
attempt: 0,
next_secs: 0.0,
reason: Some("network thread crashed"),
last_error: Some(
"Gfx net thread stopped before connect finished. \
See ~/.config/flatland/gfx-session.log",
),
};
renderer.draw_offline_gate(gate);
if is_key_pressed(KeyCode::Escape) {
return Ok(SessionEnd::Logout);
}
if is_key_pressed(KeyCode::Q) {
return Ok(SessionEnd::Quit);
}
next_frame().await;
}
}
}
clear_background(Color::from_rgba(8, 9, 12, 255));
egui_macroquad::ui(|ctx| {
flatland_gfx_engine::configure_egui(ctx);
let theme = flatland_gfx_engine::GfxTheme::default();
egui::Area::new(egui::Id::new("gfx_connecting"))
.anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
.show(ctx, |ui| {
ui.vertical_centered(|ui| {
ui.add(egui::Spinner::new().size(28.0));
ui.add_space(10.0);
ui.colored_label(theme.accent, "Connecting to game…");
ui.label(
egui::RichText::new("Syncing sprites / opening session")
.color(theme.text_muted)
.italics(),
);
});
});
});
egui_macroquad::draw();
next_frame().await;
}
if let Some(local) = flatland_client_lib::read_local_state() {
renderer
.maybe_reload_sprites_for_publish_rev(0, local.publish_rev)
.await;
} else if let Some(rev) = flatland_client_lib::read_repo_publish_rev() {
renderer.maybe_reload_sprites_for_publish_rev(0, rev).await;
}
session_log("entering play loop");
let mut local = PlayLocalState::new(keys.clone());
renderer.hud_view = local.hud_view;
let mut movement_sent_at = Instant::now();
let mut suppress_login_keys = true;
drain_pending_input();
let mut play_frames: u64 = 0;
'play: loop {
let frame_t0 = Instant::now();
let Some(snapshot) = net.snapshot() else {
let gate = DisconnectGate {
reconnecting: false,
attempt: 0,
next_secs: 0.0,
reason: Some("network thread stopped"),
last_error: Some("No live session — return to login or restart."),
};
renderer.draw_offline_gate(gate);
let menu_actions = renderer.take_menu_actions();
if menu_actions.return_to_login || menu_actions.logout || is_key_pressed(KeyCode::Escape)
{
net.send(NetCmd::Shutdown);
drop(net);
return Ok(SessionEnd::Disconnected);
}
if menu_actions.retry_connect || is_key_pressed(KeyCode::R) {
net.send(NetCmd::Shutdown);
drop(net);
return Ok(SessionEnd::Disconnected);
}
next_frame().await;
continue;
};
let state = &snapshot.game;
let disconnect_gate = match &snapshot.presentation.connection {
ConnectionUi::Connected if state.connected => None,
ConnectionUi::Connected => Some(DisconnectGate {
reconnecting: true,
attempt: 0,
next_secs: 1.0,
reason: state.disconnect_reason.as_deref(),
last_error: None,
}),
ConnectionUi::Reconnecting {
attempt,
reason,
next_secs,
last_error,
} => Some(DisconnectGate {
reconnecting: true,
attempt: *attempt,
next_secs: *next_secs,
reason: reason.as_deref().or(state.disconnect_reason.as_deref()),
last_error: last_error.as_deref(),
}),
ConnectionUi::Lost {
reason,
last_error,
} => Some(DisconnectGate {
reconnecting: false,
attempt: 0,
next_secs: 0.0,
reason: reason.as_deref().or(state.disconnect_reason.as_deref()),
last_error: last_error.as_deref(),
}),
};
let disconnected = disconnect_gate.is_some() || !state.connected;
if suppress_login_keys {
drain_pending_input();
if !letter_key_held() {
suppress_login_keys = false;
}
}
let input_t0 = Instant::now();
let chat_mode = local.chat.active || state.social_chat.composer_open();
let input = if disconnected {
flatland_gfx_engine::GfxInputFrame::default()
} else {
renderer.poll_input(suppress_login_keys)
};
let block_zoom = blocks_map_zoom(state, chat_mode, &local.map_target);
let zoom_result = if block_zoom {
GfxInputResult::default()
} else {
zoom_from_input(&input, renderer.map_zoom)
};
renderer.apply_input_result(zoom_result);
if is_key_pressed(KeyCode::F8) {
client_perf.toggle_hud();
}
let input_ms = input_t0.elapsed().as_secs_f32() * 1000.0;
if !disconnected
&& flatland_gfx_engine::menu_list_wheel_active(state, chat_mode)
&& renderer.egui_wants_pointer()
{
let (_, wheel_y) = macroquad::input::mouse_wheel();
if wheel_y.abs() > f32::EPSILON {
use flatland_client_ui::{UiKeyCode, UiKeyEvent, UiKeyEventKind, UiKeyModifiers};
let code = if wheel_y > 0.0 {
UiKeyCode::Down
} else {
UiKeyCode::Up
};
net.send(NetCmd::MenuKey(UiKeyEvent {
kind: UiKeyEventKind::Press,
code,
modifiers: UiKeyModifiers::default(),
}));
}
}
let route_editing = route_editor_active(state);
if !disconnected && is_key_pressed(KeyCode::Escape) {
if renderer.show_connection_settings {
renderer.show_connection_settings = false;
} else if state.claim_mode.is_some() {
net.send(NetCmd::Esc);
} else if state.relocate_mode.is_some() {
net.send(NetCmd::Esc);
} else if route_editing {
net.send(NetCmd::Esc);
} else if state.show_npc_chat
|| state.show_npc_verb_menu
|| state.player_verbs.open
|| state.social_chat.composer_open()
|| state.trade_ui.panel.is_some()
|| state.show_shop_menu
|| state.bank_panel.is_some()
|| state.storage_panel.is_some()
|| state.market_panel.is_some()
|| (state.show_quest_offer
&& (state.show_npc_chat || state.npc_verb_target.is_some()))
{
net.send(NetCmd::Esc);
} else if state.show_plant_menu || state.show_farm_access {
net.send(NetCmd::Esc);
} else if local.map_target.active {
local.clear_nav();
net.send(NetCmd::CancelAutoNav);
} else if local.chat.active {
local.chat.close();
} else if renderer.show_help {
renderer.show_help = false;
} else if state.show_loadout_menu
|| state.show_rotation_editor
|| state.show_stats
{
let overlay = if state.show_loadout_menu {
ActiveOverlay::Loadout
} else if state.show_rotation_editor {
ActiveOverlay::RotationEditor(state.rotation_editor.mode)
} else {
ActiveOverlay::Stats
};
let esc = UiKeyEvent {
kind: UiKeyEventKind::Press,
code: UiKeyCode::Esc,
modifiers: UiKeyModifiers::default(),
};
match local.movement.apply_ui_key(esc, overlay, false) {
InputAction::Quit => {}
action => net.send(NetCmd::Action(action)),
}
local.movement.reset();
} else {
net.send(NetCmd::Esc);
local.clear_nav();
}
}
if !disconnected && state.claim_mode.is_some() {
let shift = is_key_down(KeyCode::LeftShift) || is_key_down(KeyCode::RightShift);
let ctrl = is_key_down(KeyCode::LeftControl) || is_key_down(KeyCode::RightControl);
if is_key_pressed(KeyCode::Enter) {
net.send(NetCmd::ClaimConfirm);
}
if is_key_pressed(KeyCode::LeftBracket) {
let (dw, dh) = if shift {
(-1, 0)
} else if ctrl {
(0, -1)
} else {
(-1, -1)
};
net.send(NetCmd::ClaimNudge { dw, dh });
}
if is_key_pressed(KeyCode::RightBracket) {
let (dw, dh) = if shift {
(1, 0)
} else if ctrl {
(0, 1)
} else {
(1, 1)
};
net.send(NetCmd::ClaimNudge { dw, dh });
}
if is_key_pressed(KeyCode::Key2) {
net.send(NetCmd::ClaimPreset { w: 2, h: 2 });
}
if is_key_pressed(KeyCode::Key4) {
net.send(NetCmd::ClaimPreset { w: 4, h: 4 });
}
if is_key_pressed(KeyCode::Key8) {
net.send(NetCmd::ClaimPreset { w: 8, h: 8 });
}
if is_key_pressed(KeyCode::A) && shift {
net.send(NetCmd::ClaimBuyAllFree);
}
if is_key_pressed(KeyCode::W) || is_key_pressed(KeyCode::Up) {
net.send(NetCmd::ClaimMoveNudge { dx: 0, dy: 1 });
}
if is_key_pressed(KeyCode::S) || is_key_pressed(KeyCode::Down) {
net.send(NetCmd::ClaimMoveNudge { dx: 0, dy: -1 });
}
if (is_key_pressed(KeyCode::A) || is_key_pressed(KeyCode::Left)) && !shift {
net.send(NetCmd::ClaimMoveNudge { dx: -1, dy: 0 });
}
if is_key_pressed(KeyCode::D) || is_key_pressed(KeyCode::Right) {
net.send(NetCmd::ClaimMoveNudge { dx: 1, dy: 0 });
}
}
if !disconnected && is_key_pressed(KeyCode::F2) {
renderer.show_connection_settings = !renderer.show_connection_settings;
}
for key in input.keys {
if key.kind == UiKeyEventKind::Press && matches!(key.code, UiKeyCode::Esc) {
continue;
}
if local.chat.active {
if let Some(text) = local.chat.handle_key(key) {
net.send(NetCmd::Say(ChatChannel::Nearby, text, None));
}
continue;
}
if state.show_stats && renderer.try_character_sheet_scroll_key(&key) {
continue;
}
if state.social_chat.composer_open() {
net.send(NetCmd::MenuKey(key));
continue;
}
if state.social_chat.pending_trade.is_some()
&& matches!(key.code, UiKeyCode::Char('y' | 'Y' | 'n' | 'N'))
{
net.send(NetCmd::MenuKey(key));
continue;
}
if state.player_verbs.open || state.trade_ui.panel.is_some() {
net.send(NetCmd::MenuKey(key));
continue;
}
if route_editing {
if key.kind == UiKeyEventKind::Press {
if local.map_target.active {
match key.code {
UiKeyCode::Enter => {
let (_, _, pz) = state.player_position_with_z();
net.send(NetCmd::RouteEditorWaypoint {
x: local.map_target.cursor_x,
y: local.map_target.cursor_y,
z: pz,
});
local.map_target.deactivate();
}
UiKeyCode::Char('m') | UiKeyCode::Esc => {
local.map_target.deactivate();
}
UiKeyCode::Up | UiKeyCode::Char('k') | UiKeyCode::Char('w') => {
local.map_target.nudge(
0,
1,
state.world_width_m,
state.world_height_m,
);
}
UiKeyCode::Down | UiKeyCode::Char('j') => {
local.map_target.nudge(
0,
-1,
state.world_width_m,
state.world_height_m,
);
}
UiKeyCode::Left | UiKeyCode::Char('a') => {
local.map_target.nudge(
-1,
0,
state.world_width_m,
state.world_height_m,
);
}
UiKeyCode::Right | UiKeyCode::Char('d') => {
local.map_target.nudge(
1,
0,
state.world_width_m,
state.world_height_m,
);
}
_ => {}
}
} else {
match key.code {
UiKeyCode::Char('m') => {
let (px, py, _) = state.player_position_with_z();
local.map_target.activate_at(px, py);
}
_ => {
net.send(NetCmd::MenuKey(key));
}
}
}
}
continue;
}
if menu_blocking(state) {
net.send(NetCmd::MenuKey(key));
continue;
}
if key.kind == UiKeyEventKind::Press
&& state.claim_mode.is_none()
&& state.farmable_plot_under_player().is_some()
{
match key.code {
UiKeyCode::Char('c') | UiKeyCode::Char('C') => {
net.send(NetCmd::Action(InputAction::FarmCultivate));
continue;
}
UiKeyCode::Char('p') | UiKeyCode::Char('P') if !key.modifiers.control => {
net.send(NetCmd::Action(InputAction::FarmPlant));
continue;
}
UiKeyCode::Char('o') | UiKeyCode::Char('O')
if state.my_plot_under_player().is_some() =>
{
net.send(NetCmd::OpenFarmAccess);
continue;
}
_ => {}
}
}
if state.claim_mode.is_some()
&& key.kind == UiKeyEventKind::Press
&& matches!(
key.code,
UiKeyCode::Enter
| UiKeyCode::Up
| UiKeyCode::Down
| UiKeyCode::Left
| UiKeyCode::Right
| UiKeyCode::Char(
'['
| ']'
| '2'
| '4'
| '8'
| 'a'
| 'A'
| 'w'
| 'W'
| 's'
| 'S'
| 'd'
| 'D'
)
)
{
continue;
}
if key.kind == UiKeyEventKind::Press && matches!(key.code, UiKeyCode::Char(';')) {
renderer.show_use_radius = !renderer.show_use_radius;
continue;
}
if is_map_zoom_key(&key) && !state.show_rotation_editor {
continue;
}
let overlay = if state.show_loadout_menu {
ActiveOverlay::Loadout
} else if state.show_rotation_editor {
ActiveOverlay::RotationEditor(state.rotation_editor.mode)
} else if state.show_stats {
ActiveOverlay::Stats
} else {
ActiveOverlay::None
};
match local.movement.apply_ui_key_ex(
key,
overlay,
local.map_target.active,
state.relocate_mode.is_some(),
state.claim_mode.is_some(),
) {
InputAction::Quit => {
session_log("user quit (Ctrl+Q/C)");
break 'play;
}
InputAction::ToggleHelp => renderer.show_help = !renderer.show_help,
InputAction::ToggleHudLog => {
net.send(NetCmd::Action(InputAction::ToggleHudLog));
}
InputAction::CycleHudView => {
local.cycle_hud_view();
renderer.hud_view = local.hud_view;
}
InputAction::ToggleMapTarget => {
if local.map_target.active {
local.map_target.deactivate();
} else {
let (px, py, _) = state.player_position_with_z();
local.map_target.activate_at(px, py);
}
}
InputAction::ConfirmMapTarget => {
if local.map_target.active {
local.auto_nav_goal =
Some((local.map_target.cursor_x, local.map_target.cursor_y));
net.send(NetCmd::AutoNavTo {
x: local.map_target.cursor_x,
y: local.map_target.cursor_y,
});
local.map_target.deactivate();
}
}
InputAction::CancelMapTarget | InputAction::CancelAutoNav => {
local.clear_nav();
net.send(NetCmd::CancelAutoNav);
}
InputAction::StopMovement => {
local.clear_nav();
net.send(NetCmd::StopMovement);
}
InputAction::StartChat { whisper } => {
net.send(NetCmd::Action(InputAction::StartChat { whisper }));
}
InputAction::ToggleSprintMode => local.movement.toggle_sprint_mode(),
InputAction::MapTargetNudge { dx, dy } => {
local
.map_target
.nudge(dx, dy, state.world_width_m, state.world_height_m);
}
InputAction::RelocateBeginNearest => {
local.map_target.deactivate();
net.send(NetCmd::Action(InputAction::RelocateBeginNearest));
}
other => {
if should_dispatch_to_net(other) {
net.send(NetCmd::Action(other));
}
}
}
}
for ptr in input.pointers {
match ptr {
UiPointerEvent::Move { x, y } => {
local.mouse_hover = screen_to_world(
&renderer.map_grid,
x,
y,
&state,
&renderer.world_rect,
&renderer.camera,
);
}
UiPointerEvent::Click {
x,
y,
button,
shift,
} => {
if let Some((wx, wy)) = screen_to_world(
&renderer.map_grid,
x,
y,
&state,
&renderer.world_rect,
&renderer.camera,
) {
if route_editing && button == UiMouseButton::Left {
net.send(NetCmd::RouteEditorMapClick { x: wx, y: wy });
continue;
}
if state.relocate_mode.is_some() && button == UiMouseButton::Left {
net.send(NetCmd::RelocateSetCursor { x: wx, y: wy });
continue;
}
if blocks_map_click(state, local.chat.active, false) {
continue;
}
match button {
UiMouseButton::Left => {
let slot = if shift { 2 } else { 1 };
if let Some((target_id, label)) =
state.pick_combat_target_at(wx, wy, slot, 1.75)
{
net.send(NetCmd::SetCombatTargetSlot {
slot_index: slot,
target_id,
label,
});
local.map_target.deactivate();
} else if !shift {
local.auto_nav_goal = Some((wx, wy));
net.send(NetCmd::AutoNavTo { x: wx, y: wy });
local.map_target.deactivate();
}
}
UiMouseButton::Right => {
local.clear_nav();
net.send(NetCmd::CancelAutoNav);
}
UiMouseButton::Middle => {}
}
}
}
UiPointerEvent::Scroll { .. } => {}
}
}
let movement_blocked = disconnected
|| menu_blocking(state)
|| state.blocking_active
|| route_editing
|| state.show_loadout_menu
|| state.show_rotation_editor
|| state.show_stats
|| local.chat.active
|| state.social_chat.composer_open()
|| state.player_verbs.open
|| state.trade_ui.panel.is_some()
|| local.map_target.active
|| state.claim_mode.is_some()
|| state.relocate_mode.is_some();
let now = Instant::now();
if !movement_blocked {
use macroquad::input::KeyCode::{
A, D, Down, J, Left, LeftShift, Right, RightShift, S, U, Up, W,
};
local.movement.sync_physical_holds(
is_key_down(W) || is_key_down(Up),
is_key_down(S) || is_key_down(Down),
is_key_down(A) || is_key_down(Left),
is_key_down(D) || is_key_down(Right),
is_key_down(U),
is_key_down(J),
is_key_down(LeftShift) || is_key_down(RightShift),
);
if movement_interval_elapsed(movement_sent_at, now) {
movement_sent_at = now;
let (f, s) = local.movement.current().components();
net.send(NetCmd::Movement {
forward: f,
strafe: s,
vertical: local.movement.vertical_axis(),
sprint: local.movement.sprinting(),
});
}
} else {
local.movement.expire_idle(local.movement.idle_timeout());
}
let (px, py) = state.player_position();
let (facing_f, facing_s) = if let Some((f, s)) = snapshot.presentation.auto_nav_facing_axes
{
(f, s)
} else {
let (lf, ls) = local.movement.last_move_axes();
let (cf, cs) = local.movement.current().components();
if cf.abs() > f32::EPSILON || cs.abs() > f32::EPSILON {
(cf, cs)
} else {
(lf, ls)
}
};
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
renderer.update_camera(
px,
py,
facing_f,
facing_s,
now_ms,
state.effective_inside_building().as_deref(),
);
let movement_input = local.movement.current();
let hud = build_play_hud(PlayHudInput {
movement_label: movement_input.label(),
sprint_mode: local.movement.sprint_mode(),
map_target: &local.map_target,
auto_nav_goal: local.auto_nav_goal,
mouse_hover: local.mouse_hover,
view_mode: local.hud_view,
});
let chat_line = if state.social_chat.composer_open() {
format!(
"{}> {}",
state.social_chat.prompt_prefix(),
state.social_chat.buffer
)
} else {
local.chat.prompt_line()
};
if local.was_harvesting && !state.harvest_in_progress {
renderer.trigger_harvest_flash();
}
local.was_harvesting = state.harvest_in_progress;
let logic_t0 = Instant::now();
renderer
.maybe_reload_sprites_for_publish_rev(state.content_rev, state.publish_rev)
.await;
renderer.tick_fx(get_frame_time());
for cue in net.drain_audio_cues() {
if let Some(bank) = renderer.tones_ref() {
bank.play(cue);
}
}
let logic_ms = logic_t0.elapsed().as_secs_f32() * 1000.0;
let mut restart_for_update = false;
let updater_ref = updater;
let draw = renderer.draw_frame(
state,
&hud,
&chat_line,
disconnect_gate,
Some(&*client_perf),
Some(&mut |ctx| {
if update::draw_banner(ctx, updater_ref) {
restart_for_update = true;
}
}),
);
if restart_for_update {
session_log("client update applied — exiting for restart");
net.send(NetCmd::Shutdown);
drop(net);
std::process::exit(0);
}
let cpu_ms = frame_t0.elapsed().as_secs_f32() * 1000.0;
play_frames = play_frames.saturating_add(1);
if play_frames == 1 {
session_log(&format!(
"first play frame tick={} entities={} connected={}",
state.tick,
state.entities.len(),
state.connected
));
}
let menu_actions = renderer.take_menu_actions();
if let Some(idx) = menu_actions.keychain_select {
net.send(NetCmd::KeychainSelect(idx));
}
if let Some(idx) = menu_actions.inventory_select {
net.send(NetCmd::InventorySelect(idx));
}
for click in menu_actions.route_editor {
net.send(NetCmd::RouteEditorClick(click));
}
for action in menu_actions.farm_access {
net.send(NetCmd::FarmAccess(action));
}
if menu_actions.retry_connect || (disconnected && is_key_pressed(KeyCode::R)) {
net.send(NetCmd::RetryConnect);
}
if menu_actions.return_to_login || (disconnected && is_key_pressed(KeyCode::Escape)) {
renderer.show_connection_settings = false;
net.send(NetCmd::Shutdown);
drop(net);
return Ok(SessionEnd::Logout);
}
if menu_actions.logout {
renderer.show_connection_settings = false;
net.send(NetCmd::Shutdown);
drop(net);
return Ok(SessionEnd::Logout);
}
if menu_actions.switch_character {
renderer.show_connection_settings = false;
net.send(NetCmd::Shutdown);
drop(net);
return Ok(SessionEnd::SwitchCharacter);
}
if !*window_position_applied {
apply_saved_position();
*window_position_applied = true;
}
let geometry = capture_window_geometry();
if geometry.width != last_saved_geometry.width
|| geometry.height != last_saved_geometry.height
|| geometry.x != last_saved_geometry.x
|| geometry.y != last_saved_geometry.y
{
*last_saved_geometry = geometry;
if last_window_save.elapsed() >= std::time::Duration::from_millis(500) {
save_window_geometry(last_saved_geometry.clone());
*last_window_save = Instant::now();
}
}
let present_t0 = Instant::now();
next_frame().await;
let present_ms = present_t0.elapsed().as_secs_f32() * 1000.0;
let wall_ms = frame_t0.elapsed().as_secs_f32() * 1000.0;
let (cam_x, cam_y) = renderer.camera.floor_cell();
client_perf.record_frame(FrameSample {
wall_ms,
cpu_ms,
input_ms,
logic_ms,
draw,
present_ms,
entities: state.entities.len() as u32,
zones: state.terrain_zones.len() as u32,
camera_x: cam_x,
camera_y: cam_y,
});
}
save_window_geometry(capture_window_geometry());
renderer.flush_ui_prefs();
Ok(SessionEnd::Quit)
}