Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions korangar-networking/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,12 @@ pub enum NetworkEvent {
choices: Vec<String>,
npc_id: EntityId,
},
OpenNumberInput {
npc_id: EntityId,
},
OpenTextInput {
npc_id: EntityId,
},
AddQuestEffect {
quest_effect: QuestEffectPacket,
},
Expand Down
78 changes: 77 additions & 1 deletion korangar-networking/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,18 @@ where
}
}

pub fn submit_dialog_number_input(&mut self, npc_id: EntityId, value: i32) -> Result<(), NotConnectedError> {
match self.map_server_packet_version()? {
SupportedPacketVersion::_20220406 => self.send_map_server_packet(NumberInputPacket::new(npc_id, value)),
}
}

pub fn submit_dialog_text_input(&mut self, npc_id: EntityId, text: String) -> Result<(), NotConnectedError> {
match self.map_server_packet_version()? {
SupportedPacketVersion::_20220406 => self.send_map_server_packet(TextInputPacket::new(npc_id, text)),
}
}

pub fn request_item_equip(&mut self, item_index: InventoryIndex, equip_position: EquipPosition) -> Result<(), NotConnectedError> {
match self.map_server_packet_version()? {
SupportedPacketVersion::_20220406 => self.send_map_server_packet(RequestEquipItemPacket::new(item_index, equip_position)),
Expand Down Expand Up @@ -910,7 +922,10 @@ where
mod packet_handlers {
use ragnarok_bytes::{ByteReader, ByteWriter};
use ragnarok_packets::handler::{HandlerResult, NoPacketCallback};
use ragnarok_packets::{DisplayEmotionPacket, EntityId, PacketExt, RequestEmotionPacket};
use ragnarok_packets::{
DisplayEmotionPacket, EntityId, NumberInputPacket, OpenNumberInputPacket, OpenTextInputPacket, PacketExt, RequestEmotionPacket,
TextInputPacket,
};

use crate::{NetworkEvent, NetworkingSystem, SupportedPacketVersion};

Expand Down Expand Up @@ -960,4 +975,65 @@ mod packet_handlers {
emotion: 3,
}]));
}

#[test]
fn dialog_input_requests_map_to_network_events() {
let mut handler = NetworkingSystem::create_map_server_packet_handler(NoPacketCallback, SupportedPacketVersion::_20220406).unwrap();
let mut writer = ByteWriter::new();
OpenNumberInputPacket {
npc_id: EntityId(0x1234_5678),
}
.packet_to_bytes(&mut writer)
.unwrap();
OpenTextInputPacket {
npc_id: EntityId(0x8765_4321),
}
.packet_to_bytes(&mut writer)
.unwrap();
let mut reader = ByteReader::without_metadata(writer.as_slice());

assert!(matches!(
handler.process_one(&mut reader),
HandlerResult::Ok(events)
if matches!(
events.0.as_slice(),
[NetworkEvent::OpenNumberInput {
npc_id: EntityId(0x1234_5678),
}]
)
));
assert!(matches!(
handler.process_one(&mut reader),
HandlerResult::Ok(events)
if matches!(
events.0.as_slice(),
[NetworkEvent::OpenTextInput {
npc_id: EntityId(0x8765_4321),
}]
)
));
}

#[test]
fn dialog_input_responses_use_expected_wire_layouts() {
let mut writer = ByteWriter::new();
NumberInputPacket {
npc_id: EntityId(0x1234_5678),
value: -42,
}
.packet_to_bytes(&mut writer)
.unwrap();
assert_eq!(writer.as_slice(), &[0x43, 0x01, 0x78, 0x56, 0x34, 0x12, 0xD6, 0xFF, 0xFF, 0xFF]);

writer.clear();
TextInputPacket {
npc_id: EntityId(0x1234_5678),
text: "abc".to_string(),
}
.packet_to_bytes(&mut writer)
.unwrap();
assert_eq!(writer.as_slice(), &[
0xD5, 0x01, 0x0C, 0x00, 0x78, 0x56, 0x34, 0x12, b'a', b'b', b'c', 0x00
]);
}
}
2 changes: 2 additions & 0 deletions korangar-networking/src/packet_versions/version_20220406.rs
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,8 @@ where

NetworkEvent::OpenDialog { text, npc_id }
})?;
packet_handler.register(|packet: OpenNumberInputPacket| NetworkEvent::OpenNumberInput { npc_id: packet.npc_id })?;
packet_handler.register(|packet: OpenTextInputPacket| NetworkEvent::OpenTextInput { npc_id: packet.npc_id })?;
packet_handler.register(|packet: RequestEquipItemStatusPacket| match packet.result {
RequestEquipItemStatus::Success => Some(NetworkEvent::UpdateEquippedPosition {
index: packet.inventory_index,
Expand Down
2 changes: 2 additions & 0 deletions korangar/archive/data/languages/de-DE.ron
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
dialog_window_title: "Dialog",
next_button_text: "Weiter",
close_button_text: "Schließen",
okay_button_text: "OK",
dialog_text_box_message: "Wert eingeben",
error_window_title: "Fehler",
friend_list_window_title: "Freundesliste",
friend_list_text_box_message: "Freund durch Name hinzufügen",
Expand Down
2 changes: 2 additions & 0 deletions korangar/archive/data/languages/en-US.ron
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
dialog_window_title: "Dialog",
next_button_text: "Next",
close_button_text: "Close",
okay_button_text: "OK",
dialog_text_box_message: "Enter a value",
error_window_title: "Error",
friend_list_window_title: "Friend List",
friend_list_text_box_message: "Add friend by name",
Expand Down
14 changes: 14 additions & 0 deletions korangar/src/input/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,20 @@ pub enum InputEvent {
/// Id of the option.
option: i8,
},
/// Submit the number typed into a dialog input.
SubmitDialogNumberInput {
/// Id of the NPC the player is in a dialog with.
npc_id: EntityId,
/// The number the player entered.
value: i32,
},
/// Submit the text typed into a dialog input.
SubmitDialogTextInput {
/// Id of the NPC the player is in a dialog with.
npc_id: EntityId,
/// The text the player entered.
text: String,
},
/// Move an item in the user interface.
MoveItem {
/// Source of the move.
Expand Down
124 changes: 122 additions & 2 deletions korangar/src/interface/windows/dialog.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::cell::UnsafeCell;

use korangar_interface::components::text_box::DefaultHandler;
use korangar_interface::element::store::ElementStoreMut;
use korangar_interface::element::{Element, ElementBox, ErasedElement, StateElement};
use korangar_interface::layout::{Resolvers, with_single_resolver};
Expand All @@ -13,6 +14,14 @@ use crate::state::localization::LocalizationPathExt;
use crate::state::theme::InterfaceThemeType;
use crate::state::{ClientState, ClientStatePathExt, client_state};

/// Maximum number of characters for a dialog input. The server caps text
/// inputs at rAthena's `CHATBOX_SIZE` (70).
const MAXIMUM_INPUT_LENGTH: usize = 70;

fn parse_dialog_number(text: &str) -> Option<i32> {
text.trim().parse().ok()
}

/// A small wrapper struct that serves two purposes:
/// - Making the elements nicer to construct by putting the [`UnsafeCell::new`]
/// and [`Box::new`] behind a function call.
Expand All @@ -26,6 +35,9 @@ pub struct DialogElement {
#[hidden_element]
element: UnsafeCell<ElementBox<ClientState>>,
is_next_button: bool,
/// Marks the elements of an input row so they can be removed once the
/// input is submitted.
is_input: bool,
}

impl DialogElement {
Expand All @@ -38,6 +50,20 @@ impl DialogElement {
Self {
element: UnsafeCell::new(ErasedElement::new(element)),
is_next_button,
is_input: false,
}
}

/// Creates a new dialog element that is part of an input row.
#[inline(always)]
fn new_input<E>(element: E) -> Self
where
E: Element<ClientState> + 'static,
{
Self {
element: UnsafeCell::new(ErasedElement::new(element)),
is_next_button: false,
is_input: true,
}
}
}
Expand All @@ -52,6 +78,8 @@ pub struct DialogWindowState {
/// Whether or not the elements should be cleared the next time
/// [`start`](Self::start) is called.
clear_next: bool,
/// Backing store for the text box of an input row.
input_buffer: String,
}

impl DialogWindowState {
Expand Down Expand Up @@ -109,7 +137,7 @@ impl DialogWindowState {
pub fn add_close_button(&mut self) {
use korangar_interface::prelude::*;

self.elements.retain(|element| !element.is_next_button);
self.elements.retain(|element| !element.is_next_button && !element.is_input);

let npc_id = self.npc_id;

Expand All @@ -132,7 +160,7 @@ impl DialogWindowState {
pub fn add_choice_buttons(&mut self, choices: Vec<String>) {
use korangar_interface::prelude::*;

self.elements.retain(|element| !element.is_next_button);
self.elements.retain(|element| !element.is_next_button && !element.is_input);

let npc_id = self.npc_id;

Expand All @@ -149,6 +177,70 @@ impl DialogWindowState {
});
}

/// Add a number input row to the dialog.
pub fn add_number_input(&mut self) {
self.add_input(true);
}

/// Add a text input row to the dialog.
pub fn add_text_input(&mut self) {
self.add_input(false);
}

/// Add an input row (text box and an "OK"-button) to the dialog.
fn add_input(&mut self, numbers_only: bool) {
use korangar_interface::prelude::*;

if self.clear_next {
// An input request may be the first packet after advancing a dialog page.
// In that case, clear the previous page just like add_text() does.
self.elements.clear();
self.clear_next = false;
} else {
// The server should only ever request one input at a time. Also remove
// any stale Next button: the server is now waiting for an input packet.
self.elements.retain(|element| !element.is_input && !element.is_next_button);
}
self.input_buffer.clear();

let npc_id = self.npc_id;
let input_path = client_state().dialog_window().input_buffer();

struct DialogInputTextBox;

let submit_action = move |state: &State<ClientState>, queue: &mut EventQueue<ClientState>| {
let text = state.get(&input_path).clone();

match numbers_only {
true => {
let Some(value) = parse_dialog_number(&text) else {
return;
};
queue.queue(InputEvent::SubmitDialogNumberInput { npc_id, value });
}
false => queue.queue(InputEvent::SubmitDialogTextInput { npc_id, text }),
}
};

self.elements.push(DialogElement::new_input(text_box! {
ghost_text: client_state().localization().dialog_text_box_message(),
state: input_path,
input_handler: DefaultHandler::<_, _, MAXIMUM_INPUT_LENGTH>::new(input_path, submit_action),
focus_id: DialogInputTextBox,
}));

self.elements.push(DialogElement::new_input(button! {
text: client_state().localization().okay_button_text(),
event: submit_action,
}));
}

/// Remove the input row after the input has been submitted.
pub fn input_submitted(&mut self) {
self.elements.retain(|element| !element.is_input);
self.input_buffer.clear();
}

/// End the dialog.
///
/// This has no side effects.
Expand All @@ -165,6 +257,7 @@ impl Default for DialogWindowState {
// Arguably not very clean but avoids using an Option.
npc_id: EntityId(0),
clear_next: false,
input_buffer: Default::default(),
}
}
}
Expand Down Expand Up @@ -256,3 +349,30 @@ where
}
}
}

#[cfg(test)]
mod tests {
use super::{DialogWindowState, parse_dialog_number};

#[test]
fn dialog_numbers_require_valid_i32_input() {
assert_eq!(parse_dialog_number(" -42 "), Some(-42));
assert_eq!(parse_dialog_number(""), None);
assert_eq!(parse_dialog_number("-"), None);
assert_eq!(parse_dialog_number("12x"), None);
assert_eq!(parse_dialog_number("2147483648"), None);
}

#[test]
fn input_after_next_starts_a_new_dialog_page() {
let mut state = DialogWindowState::default();
state.add_next_button();
assert!(state.elements.iter().any(|element| element.is_next_button));

state.add_number_input();

assert!(!state.clear_next);
assert!(!state.elements.iter().any(|element| element.is_next_button));
assert!(state.elements.iter().all(|element| element.is_input));
}
}
26 changes: 26 additions & 0 deletions korangar/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1595,6 +1595,24 @@ impl Client {

self.interface.open_window(DialogWindow::new(client_state().dialog_window()));
}
NetworkEvent::OpenNumberInput { npc_id } => {
self.client_state
.follow_mut(client_state().dialog_window())
// Some NPCs start the dialog with this packet so we need to make sure it's initialized.
.initialize(npc_id)
.add_number_input();

self.interface.open_window(DialogWindow::new(client_state().dialog_window()));
}
NetworkEvent::OpenTextInput { npc_id } => {
self.client_state
.follow_mut(client_state().dialog_window())
// Some NPCs start the dialog with this packet so we need to make sure it's initialized.
.initialize(npc_id)
.add_text_input();

self.interface.open_window(DialogWindow::new(client_state().dialog_window()));
}
NetworkEvent::AddQuestEffect { quest_effect } => {
if let Some(map) = &self.map {
self.particle_holder.add_quest_icon(&self.texture_loader, map, quest_effect)
Expand Down Expand Up @@ -2284,6 +2302,14 @@ impl Client {
self.interface.close_window_with_class(WindowClass::Dialog);
}
}
InputEvent::SubmitDialogNumberInput { npc_id, value } => {
let _ = self.networking_system.submit_dialog_number_input(npc_id, value);
self.client_state.follow_mut(client_state().dialog_window()).input_submitted();
}
InputEvent::SubmitDialogTextInput { npc_id, text } => {
let _ = self.networking_system.submit_dialog_text_input(npc_id, text);
self.client_state.follow_mut(client_state().dialog_window()).input_submitted();
}
InputEvent::MoveItem { source, destination, item } => match (source, destination) {
(ItemSource::Inventory, ItemSource::Equipment { position }) => {
let _ = self.networking_system.request_item_equip(item.index, position);
Expand Down
2 changes: 2 additions & 0 deletions korangar/src/state/localization/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ pub struct Localization {
dialog_window_title: String,
next_button_text: String,
close_button_text: String,
okay_button_text: String,
dialog_text_box_message: String,
error_window_title: String,
friend_list_window_title: String,
friend_list_text_box_message: String,
Expand Down
Loading
Loading