564 lines
20 KiB
Rust
564 lines
20 KiB
Rust
use std::{
|
|
sync::{Arc, mpsc},
|
|
time::Duration,
|
|
};
|
|
|
|
use crate::{
|
|
app::UserEvent,
|
|
config::{COLOR_PRESETS, SimConfig},
|
|
emulator::{EmulatorClient, EmulatorCommand, EmulatorState, SimId, SimState},
|
|
input::{Command, ShortcutProvider},
|
|
persistence::Persistence,
|
|
window::InitArgs,
|
|
};
|
|
use anyhow::Context as _;
|
|
use egui::{
|
|
Align2, Button, CentralPanel, Color32, Context, Frame, MenuBar, Panel, Ui, Vec2,
|
|
ViewportBuilder, ViewportCommand, ViewportId, Window,
|
|
};
|
|
use egui_notify::{Anchor, Toast, Toasts};
|
|
use winit::event_loop::EventLoopProxy;
|
|
|
|
use super::{
|
|
AppWindow,
|
|
game_screen::{DisplayMode, GameScreen},
|
|
utils::UiExt as _,
|
|
};
|
|
|
|
pub struct GameWindow {
|
|
client: EmulatorClient,
|
|
proxy: EventLoopProxy<UserEvent>,
|
|
persistence: Persistence,
|
|
shortcuts: ShortcutProvider,
|
|
sim_id: SimId,
|
|
config: SimConfig,
|
|
toasts: Toasts,
|
|
screen: Option<GameScreen>,
|
|
messages: Option<mpsc::Receiver<Toast>>,
|
|
color_picker: Option<ColorPickerState>,
|
|
window: Option<Arc<winit::window::Window>>,
|
|
}
|
|
|
|
impl GameWindow {
|
|
pub fn new(
|
|
client: EmulatorClient,
|
|
proxy: EventLoopProxy<UserEvent>,
|
|
persistence: Persistence,
|
|
shortcuts: ShortcutProvider,
|
|
sim_id: SimId,
|
|
) -> Self {
|
|
let config = SimConfig::load(&persistence, sim_id);
|
|
let toasts = Toasts::new()
|
|
.with_anchor(Anchor::BottomLeft)
|
|
.with_margin((10.0, 10.0).into())
|
|
.reverse(true);
|
|
Self {
|
|
client,
|
|
proxy,
|
|
persistence,
|
|
shortcuts,
|
|
sim_id,
|
|
config,
|
|
toasts,
|
|
screen: None,
|
|
messages: None,
|
|
color_picker: None,
|
|
window: None,
|
|
}
|
|
}
|
|
|
|
fn show_menu(&mut self, ui: &mut Ui) {
|
|
let state = self.client.emulator_state();
|
|
let is_ready = self.client.sim_state(self.sim_id) == SimState::Ready;
|
|
let can_pause = is_ready && state == EmulatorState::Running;
|
|
let can_resume = is_ready && state == EmulatorState::Paused;
|
|
let can_frame_advance = is_ready && state != EmulatorState::Debugging;
|
|
|
|
for command in ui.input_mut(|input| self.shortcuts.consume_all(input)) {
|
|
match command {
|
|
Command::OpenRom => {
|
|
let rom = rfd::FileDialog::new()
|
|
.add_filter("Virtual Boy ROMs", &["vb", "vbrom", "elf", "isx"])
|
|
.pick_file();
|
|
if let Some(path) = rom {
|
|
self.client
|
|
.send_command(EmulatorCommand::LoadGame(self.sim_id, path));
|
|
}
|
|
}
|
|
Command::ReloadRom => {
|
|
self.client
|
|
.send_command(EmulatorCommand::ReloadRom(self.sim_id));
|
|
}
|
|
Command::Quit => {
|
|
let _ = self.proxy.send_event(UserEvent::Quit(self.sim_id));
|
|
}
|
|
Command::PauseResume => {
|
|
if state == EmulatorState::Paused && can_resume {
|
|
self.client.send_command(EmulatorCommand::Resume);
|
|
}
|
|
if state == EmulatorState::Running && can_pause {
|
|
self.client.send_command(EmulatorCommand::Pause);
|
|
}
|
|
}
|
|
Command::Reset => {
|
|
if is_ready {
|
|
self.client
|
|
.send_command(EmulatorCommand::Reset(self.sim_id));
|
|
}
|
|
}
|
|
Command::FrameAdvance => {
|
|
if can_frame_advance {
|
|
self.client.send_command(EmulatorCommand::FrameAdvance);
|
|
}
|
|
}
|
|
Command::FastForward(speed) => {
|
|
self.client
|
|
.send_command(EmulatorCommand::SetSpeed(speed as f64));
|
|
}
|
|
Command::Screenshot => {
|
|
let autopause = state == EmulatorState::Running && can_pause;
|
|
if autopause {
|
|
self.client.send_command(EmulatorCommand::Pause);
|
|
}
|
|
pollster::block_on(self.take_screenshot());
|
|
if autopause {
|
|
self.client.send_command(EmulatorCommand::Resume);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
ui.menu_button("ROM", |ui| {
|
|
if ui
|
|
.add(self.button_for(ui.ctx(), "Open ROM", Command::OpenRom))
|
|
.clicked()
|
|
{
|
|
let rom = rfd::FileDialog::new()
|
|
.add_filter("Virtual Boy ROMs", &["vb", "vbrom", "elf", "isx"])
|
|
.pick_file();
|
|
if let Some(path) = rom {
|
|
self.client
|
|
.send_command(EmulatorCommand::LoadGame(self.sim_id, path));
|
|
}
|
|
}
|
|
if ui
|
|
.add(self.button_for(ui.ctx(), "Reload ROM", Command::ReloadRom))
|
|
.clicked()
|
|
{
|
|
self.client
|
|
.send_command(EmulatorCommand::ReloadRom(self.sim_id));
|
|
}
|
|
let watch_rom = self.client.is_rom_watched(self.sim_id);
|
|
if ui.selectable_button(watch_rom, "Watch ROM").clicked() {
|
|
self.client
|
|
.send_command(EmulatorCommand::WatchRom(self.sim_id, !watch_rom));
|
|
}
|
|
if ui
|
|
.add(self.button_for(ui.ctx(), "Quit", Command::Quit))
|
|
.clicked()
|
|
{
|
|
let _ = self.proxy.send_event(UserEvent::Quit(self.sim_id));
|
|
}
|
|
});
|
|
ui.menu_button("Emulation", |ui| {
|
|
if state == EmulatorState::Running {
|
|
if ui
|
|
.add_enabled(
|
|
can_pause,
|
|
self.button_for(ui.ctx(), "Pause", Command::PauseResume),
|
|
)
|
|
.clicked()
|
|
{
|
|
self.client.send_command(EmulatorCommand::Pause);
|
|
}
|
|
} else if ui
|
|
.add_enabled(
|
|
can_resume,
|
|
self.button_for(ui.ctx(), "Resume", Command::PauseResume),
|
|
)
|
|
.clicked()
|
|
{
|
|
self.client.send_command(EmulatorCommand::Resume);
|
|
}
|
|
if ui
|
|
.add_enabled(is_ready, self.button_for(ui.ctx(), "Reset", Command::Reset))
|
|
.clicked()
|
|
{
|
|
self.client
|
|
.send_command(EmulatorCommand::Reset(self.sim_id));
|
|
}
|
|
ui.separator();
|
|
if ui
|
|
.add_enabled(
|
|
can_frame_advance,
|
|
self.button_for(ui.ctx(), "Frame Advance", Command::FrameAdvance),
|
|
)
|
|
.clicked()
|
|
{
|
|
self.client.send_command(EmulatorCommand::FrameAdvance);
|
|
}
|
|
ui.separator();
|
|
if ui
|
|
.add_enabled(
|
|
is_ready,
|
|
self.button_for(ui.ctx(), "Screenshot", Command::Screenshot),
|
|
)
|
|
.clicked()
|
|
{
|
|
pollster::block_on(self.take_screenshot());
|
|
}
|
|
});
|
|
ui.menu_button("Options", |ui| self.show_options_menu(ui));
|
|
ui.menu_button("Multiplayer", |ui| {
|
|
let has_player_2 = self.client.sim_state(SimId::Player2) != SimState::Uninitialized;
|
|
if self.sim_id == SimId::Player1
|
|
&& !has_player_2
|
|
&& ui.button("Open Player 2").clicked()
|
|
{
|
|
self.client
|
|
.send_command(EmulatorCommand::StartSecondSim(None));
|
|
self.proxy.send_event(UserEvent::OpenPlayer2).unwrap();
|
|
}
|
|
if has_player_2 {
|
|
let linked = self.client.are_sims_linked();
|
|
if linked && ui.button("Unlink").clicked() {
|
|
self.client.send_command(EmulatorCommand::Unlink);
|
|
}
|
|
if !linked && ui.button("Link").clicked() {
|
|
self.client.send_command(EmulatorCommand::Link);
|
|
}
|
|
}
|
|
});
|
|
ui.menu_button("Tools", |ui| {
|
|
if ui.button("Terminal").clicked() {
|
|
self.proxy
|
|
.send_event(UserEvent::OpenTerminal(self.sim_id))
|
|
.unwrap();
|
|
}
|
|
if ui.button("Profiler").clicked() {
|
|
self.proxy
|
|
.send_event(UserEvent::OpenProfiler(self.sim_id))
|
|
.unwrap();
|
|
}
|
|
if ui.button("GDB Server").clicked() {
|
|
self.proxy
|
|
.send_event(UserEvent::OpenDebugger(self.sim_id))
|
|
.unwrap();
|
|
}
|
|
ui.separator();
|
|
if ui.button("Character Data").clicked() {
|
|
self.proxy
|
|
.send_event(UserEvent::OpenCharacterData(self.sim_id))
|
|
.unwrap();
|
|
}
|
|
if ui.button("Background Maps").clicked() {
|
|
self.proxy
|
|
.send_event(UserEvent::OpenBgMap(self.sim_id))
|
|
.unwrap();
|
|
}
|
|
if ui.button("Objects").clicked() {
|
|
self.proxy
|
|
.send_event(UserEvent::OpenObjects(self.sim_id))
|
|
.unwrap();
|
|
}
|
|
if ui.button("Worlds").clicked() {
|
|
self.proxy
|
|
.send_event(UserEvent::OpenWorlds(self.sim_id))
|
|
.unwrap();
|
|
}
|
|
if ui.button("Frame Buffers").clicked() {
|
|
self.proxy
|
|
.send_event(UserEvent::OpenFrameBuffers(self.sim_id))
|
|
.unwrap();
|
|
}
|
|
if ui.button("Registers").clicked() {
|
|
self.proxy
|
|
.send_event(UserEvent::OpenRegisters(self.sim_id))
|
|
.unwrap();
|
|
}
|
|
});
|
|
ui.menu_button("Help", |ui| {
|
|
if ui.button("About").clicked() {
|
|
self.proxy.send_event(UserEvent::OpenAbout).unwrap();
|
|
}
|
|
});
|
|
}
|
|
|
|
async fn take_screenshot(&mut self) {
|
|
match self.try_take_screenshot().await {
|
|
Ok(Some(path)) => {
|
|
let mut toast = Toast::info(format!("Saved to {path}"));
|
|
toast.duration(Some(Duration::from_secs(5)));
|
|
self.toasts.add(toast);
|
|
}
|
|
Ok(None) => {}
|
|
Err(error) => {
|
|
let mut toast = Toast::error(format!("{error:#}"));
|
|
toast.duration(Some(Duration::from_secs(5)));
|
|
self.toasts.add(toast);
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn try_take_screenshot(&self) -> anyhow::Result<Option<String>> {
|
|
let (tx, rx) = oneshot::channel();
|
|
self.client
|
|
.send_command(EmulatorCommand::Screenshot(self.sim_id, tx));
|
|
let bytes = rx.await.context("Could not take screenshot")?;
|
|
let mut file_dialog = rfd::FileDialog::new()
|
|
.add_filter("PNG images", &["png"])
|
|
.set_file_name("screenshot.png");
|
|
if let Some(window) = self.window.as_ref() {
|
|
file_dialog = file_dialog.set_parent(window);
|
|
}
|
|
let file = file_dialog.save_file();
|
|
let Some(path) = file else {
|
|
return Ok(None);
|
|
};
|
|
if bytes.len() != 384 * 224 * 2 {
|
|
anyhow::bail!("Unexpected screenshot size");
|
|
}
|
|
let mut screencap = image::GrayImage::new(384 * 2, 224);
|
|
for (index, pixel) in bytes.into_iter().enumerate() {
|
|
let x = (index / 2) % 384 + ((index % 2) * 384);
|
|
let y = (index / 2) / 384;
|
|
screencap.put_pixel(x as u32, y as u32, image::Luma([pixel]));
|
|
}
|
|
screencap.save(&path).context("Could not save screenshot")?;
|
|
Ok(Some(path.display().to_string()))
|
|
}
|
|
|
|
fn show_options_menu(&mut self, ui: &mut Ui) {
|
|
ui.menu_button("Video", |ui| {
|
|
ui.menu_button("Screen Size", |ui| {
|
|
let current_dims = self.config.dimensions;
|
|
|
|
for scale in 1..=4 {
|
|
let label = format!("x{scale}");
|
|
let scale = scale as f32;
|
|
let dims = {
|
|
let Vec2 { x, y } = self.config.display_mode.proportions();
|
|
Vec2::new(x * scale, y * scale + 22.0)
|
|
};
|
|
if ui
|
|
.selectable_button((current_dims - dims).length() < 1.0, label)
|
|
.clicked()
|
|
{
|
|
ui.send_viewport_cmd(ViewportCommand::InnerSize(dims));
|
|
}
|
|
}
|
|
});
|
|
|
|
ui.menu_button("Display Mode", |ui| {
|
|
let old_proportions = self.config.display_mode.proportions();
|
|
let mut changed = false;
|
|
let mut display_mode = self.config.display_mode;
|
|
changed |= ui
|
|
.selectable_option(&mut display_mode, DisplayMode::Anaglyph, "Anaglyph")
|
|
.clicked();
|
|
changed |= ui
|
|
.selectable_option(&mut display_mode, DisplayMode::LeftEye, "Left Eye")
|
|
.clicked();
|
|
changed |= ui
|
|
.selectable_option(&mut display_mode, DisplayMode::RightEye, "Right Eye")
|
|
.clicked();
|
|
changed |= ui
|
|
.selectable_option(&mut display_mode, DisplayMode::SideBySide, "Side by Side")
|
|
.clicked();
|
|
|
|
if !changed {
|
|
return;
|
|
}
|
|
|
|
let current_dims = self.config.dimensions;
|
|
let new_proportions = display_mode.proportions();
|
|
let scale = new_proportions / old_proportions;
|
|
if scale != Vec2::new(1.0, 1.0) {
|
|
ui.send_viewport_cmd(ViewportCommand::InnerSize(current_dims * scale));
|
|
}
|
|
|
|
self.update_config(|c| {
|
|
c.display_mode = display_mode;
|
|
c.dimensions = current_dims * scale;
|
|
});
|
|
});
|
|
ui.menu_button("Colors", |ui| {
|
|
for preset in COLOR_PRESETS {
|
|
if ui.color_pair_button(preset[0], preset[1]).clicked() {
|
|
self.update_config(|c| c.colors = preset);
|
|
}
|
|
}
|
|
ui.with_layout(ui.layout().with_cross_align(egui::Align::Center), |ui| {
|
|
if ui.button("Custom").clicked() {
|
|
let color_str = |color: Color32| {
|
|
format!("{:02x}{:02x}{:02x}", color.r(), color.g(), color.b())
|
|
};
|
|
let is_running = self.client.emulator_state() == EmulatorState::Running;
|
|
if is_running {
|
|
self.client.send_command(EmulatorCommand::Pause);
|
|
}
|
|
let color_codes = [
|
|
color_str(self.config.colors[0]),
|
|
color_str(self.config.colors[1]),
|
|
];
|
|
self.color_picker = Some(ColorPickerState {
|
|
color_codes,
|
|
just_opened: true,
|
|
unpause_on_close: is_running,
|
|
});
|
|
}
|
|
});
|
|
});
|
|
});
|
|
ui.menu_button("Audio", |ui| {
|
|
if ui
|
|
.selectable_button(self.config.audio_enabled, "Enabled")
|
|
.clicked()
|
|
{
|
|
self.update_config(|c| c.audio_enabled = !c.audio_enabled);
|
|
self.client.send_command(EmulatorCommand::SetAudioEnabled(
|
|
self.sim_id,
|
|
self.config.audio_enabled,
|
|
));
|
|
}
|
|
});
|
|
ui.menu_button("Input", |ui| {
|
|
if ui.button("Bind Inputs").clicked() {
|
|
self.proxy.send_event(UserEvent::OpenInput).unwrap();
|
|
}
|
|
});
|
|
if ui.button("Hotkeys").clicked() {
|
|
self.proxy.send_event(UserEvent::OpenHotkeys).unwrap();
|
|
}
|
|
}
|
|
|
|
fn show_color_picker(&mut self, ui: &mut Ui) {
|
|
let mut colors = self.config.colors;
|
|
let Some(state) = self.color_picker.as_mut() else {
|
|
return;
|
|
};
|
|
let (open, updated) = ui
|
|
.horizontal(|ui| {
|
|
let left_color = ui.color_picker(&mut colors[0], &mut state.color_codes[0]);
|
|
if state.just_opened {
|
|
left_color.request_focus();
|
|
state.just_opened = false;
|
|
}
|
|
let right_color = ui.color_picker(&mut colors[1], &mut state.color_codes[1]);
|
|
let open = left_color.has_focus() || right_color.has_focus();
|
|
let updated = left_color.changed() || right_color.changed();
|
|
(open, updated)
|
|
})
|
|
.inner;
|
|
if !open {
|
|
if state.unpause_on_close {
|
|
self.client.send_command(EmulatorCommand::Resume);
|
|
}
|
|
self.color_picker = None;
|
|
}
|
|
if updated {
|
|
self.update_config(|c| c.colors = colors);
|
|
}
|
|
}
|
|
|
|
fn update_config(&mut self, update: impl FnOnce(&mut SimConfig)) {
|
|
let mut new_config = self.config.clone();
|
|
update(&mut new_config);
|
|
if self.config != new_config {
|
|
let _ = new_config.save(&self.persistence, self.sim_id);
|
|
}
|
|
self.config = new_config;
|
|
}
|
|
|
|
fn button_for(&self, ctx: &Context, text: &str, command: Command) -> Button<'_> {
|
|
let button = Button::new(text);
|
|
match self.shortcuts.shortcut_for(command) {
|
|
Some(shortcut) => button.shortcut_text(ctx.format_shortcut(&shortcut)),
|
|
None => button,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AppWindow for GameWindow {
|
|
fn viewport_id(&self) -> ViewportId {
|
|
match self.sim_id {
|
|
SimId::Player1 => ViewportId::ROOT,
|
|
SimId::Player2 => ViewportId::from_hash_of("Player2"),
|
|
}
|
|
}
|
|
|
|
fn sim_id(&self) -> SimId {
|
|
self.sim_id
|
|
}
|
|
|
|
fn initial_viewport(&self) -> ViewportBuilder {
|
|
ViewportBuilder::default()
|
|
.with_title("Lemur")
|
|
.with_inner_size(self.config.dimensions)
|
|
}
|
|
|
|
fn show(&mut self, ui: &mut Ui) {
|
|
let dimensions = {
|
|
let bounds = ui.content_rect();
|
|
bounds.max - bounds.min
|
|
};
|
|
self.update_config(|c| c.dimensions = dimensions);
|
|
|
|
if let Some(messages) = self.messages.as_mut() {
|
|
while let Ok(toast) = messages.try_recv() {
|
|
self.toasts.add(toast);
|
|
}
|
|
}
|
|
Panel::top("menubar")
|
|
.exact_size(22.0)
|
|
.show_inside(ui, |ui| {
|
|
MenuBar::new().ui(ui, |ui| {
|
|
self.show_menu(ui);
|
|
});
|
|
});
|
|
if self.color_picker.is_some() {
|
|
Window::new("Color Picker")
|
|
.title_bar(false)
|
|
.resizable(false)
|
|
.anchor(Align2::CENTER_CENTER, Vec2::ZERO)
|
|
.show(ui, |ui| {
|
|
self.show_color_picker(ui);
|
|
});
|
|
}
|
|
let frame = Frame::central_panel(ui.style()).fill(Color32::BLACK);
|
|
CentralPanel::default().frame(frame).show_inside(ui, |ui| {
|
|
if let Some(screen) = self.screen.as_mut() {
|
|
screen.update(self.config.display_mode, self.config.colors);
|
|
ui.add(screen);
|
|
}
|
|
});
|
|
self.toasts.show(ui);
|
|
}
|
|
|
|
fn on_init(&mut self, args: InitArgs) {
|
|
let (screen, sink) = GameScreen::init(args.render_state);
|
|
let (message_sink, message_source) = mpsc::channel();
|
|
self.client.send_command(EmulatorCommand::ConnectToSim(
|
|
self.sim_id,
|
|
sink,
|
|
message_sink,
|
|
));
|
|
self.screen = Some(screen);
|
|
self.messages = Some(message_source);
|
|
self.window = Some(args.window.clone());
|
|
}
|
|
|
|
fn on_destroy(&mut self) {
|
|
if self.sim_id == SimId::Player2 {
|
|
self.client.send_command(EmulatorCommand::StopSecondSim);
|
|
}
|
|
let _ = self.proxy.send_event(UserEvent::Quit(self.sim_id));
|
|
}
|
|
}
|
|
|
|
struct ColorPickerState {
|
|
color_codes: [String; 2],
|
|
just_opened: bool,
|
|
unpause_on_close: bool,
|
|
}
|