├── .gitignore ├── assets ├── cat.ico ├── meru.ico └── fonts │ └── x12y16pxMaruMonica.ttf ├── web ├── favicon.ico └── index.html ├── .cargo └── config.toml ├── meru-interface ├── README.md ├── Cargo.toml └── src │ ├── config.rs │ ├── lib.rs │ └── key_assign.rs ├── src ├── main.rs ├── lib.rs ├── utils.rs ├── archive.rs ├── config.rs ├── hotkey.rs ├── rewinding.rs ├── file.rs ├── input.rs ├── app.rs ├── core.rs └── menu.rs ├── .gitmodules ├── LICENSE ├── README.md ├── .github └── workflows │ ├── pages.yml │ ├── release.yaml │ └── ci.yaml └── Cargo.toml /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | web/*.d.ts 3 | web/*.js 4 | web/*.wasm 5 | -------------------------------------------------------------------------------- /assets/cat.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tanakh/meru/HEAD/assets/cat.ico -------------------------------------------------------------------------------- /assets/meru.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tanakh/meru/HEAD/assets/meru.ico -------------------------------------------------------------------------------- /web/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tanakh/meru/HEAD/web/favicon.ico -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | 2 | [target.wasm32-unknown-unknown] 3 | runner = "wasm-server-runner" 4 | -------------------------------------------------------------------------------- /meru-interface/README.md: -------------------------------------------------------------------------------- 1 | # meru-interface 2 | 3 | Core interface for MERU multi emulator. 4 | 5 | -------------------------------------------------------------------------------- /assets/fonts/x12y16pxMaruMonica.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tanakh/meru/HEAD/assets/fonts/x12y16pxMaruMonica.ttf -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | // #![windows_subsystem = "windows"] 2 | 3 | #[async_std::main] 4 | async fn main() { 5 | meru::app::main().await; 6 | } 7 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | pub mod archive; 3 | pub mod config; 4 | pub mod core; 5 | pub mod file; 6 | pub mod hotkey; 7 | pub mod input; 8 | pub mod menu; 9 | pub mod rewinding; 10 | pub mod utils; 11 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "tgba"] 2 | path = tgba 3 | url = https://github.com/tanakh/tgba 4 | [submodule "tgbr"] 5 | path = tgbr 6 | url = https://github.com/tanakh/tgbr 7 | [submodule "super-sabicom"] 8 | path = super-sabicom 9 | url = https://github.com/tanakh/super-sabicom 10 | [submodule "sabicom"] 11 | path = sabicom 12 | url = https://github.com/tanakh/sabicom 13 | -------------------------------------------------------------------------------- /meru-interface/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "meru-interface" 3 | version = "0.3.0" 4 | edition = "2021" 5 | authors = ["Hideyuki Tanaka "] 6 | license = "MIT" 7 | 8 | description = "Core interface for MERU multi emulator" 9 | repository = "https://github.com/tanakh/meru" 10 | readme = "README.md" 11 | categories = ["emulators"] 12 | keywords = ["emulators"] 13 | 14 | [dependencies] 15 | schemars = "0.8.10" 16 | serde = { version = "1.0.144", features = ["derive"] } 17 | thiserror = "1.0.32" 18 | 19 | [target.'cfg(target_arch = "wasm32")'.dependencies] 20 | base64 = "0.13.0" 21 | base64-serde = "0.6.1" 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Hideyuki Tanaka 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::future::Future; 2 | use std::ops::Deref; 3 | 4 | pub fn unbounded_channel() -> (Sender, Receiver) { 5 | let (s, r) = async_channel::unbounded(); 6 | (Sender::new(s), Receiver::new(r)) 7 | } 8 | 9 | pub struct Sender(async_channel::Sender); 10 | 11 | impl Clone for Sender { 12 | fn clone(&self) -> Self { 13 | Self(self.0.clone()) 14 | } 15 | } 16 | 17 | impl Deref for Sender { 18 | type Target = async_channel::Sender; 19 | 20 | fn deref(&self) -> &Self::Target { 21 | &self.0 22 | } 23 | } 24 | 25 | impl Sender { 26 | pub fn new(sender: async_channel::Sender) -> Self { 27 | Self(sender) 28 | } 29 | } 30 | 31 | pub struct Receiver(async_channel::Receiver); 32 | 33 | impl Deref for Receiver { 34 | type Target = async_channel::Receiver; 35 | 36 | fn deref(&self) -> &Self::Target { 37 | &self.0 38 | } 39 | } 40 | 41 | impl Receiver { 42 | pub fn new(receiver: async_channel::Receiver) -> Self { 43 | Self(receiver) 44 | } 45 | } 46 | 47 | #[cfg(target_arch = "wasm32")] 48 | pub fn spawn_local(f: impl Future + 'static) { 49 | // On wasm, astnc_std::task::block_on does not block. 50 | async_std::task::block_on(f); 51 | } 52 | 53 | #[cfg(not(target_arch = "wasm32"))] 54 | pub fn spawn_local(f: impl Future + Send + 'static) { 55 | async_std::task::spawn(f); 56 | } 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MERU 2 | 3 | Meru is a multiple game consoles emulator written in Rust. 4 | 5 | Current supported cores: 6 | 7 | * [Sabicom](https://github.com/tanakh/sabicom) (NES / Famicom) 8 | * [Super Sabicom](https://github.com/tanakh/sabicom) (SNES / Super Famicom) 9 | * [TGBR](https://github.com/tanakh/tgbr) (Game Boy) 10 | * [TGBA](https://github.com/tanakh/tgba) (Game Boy Advance) 11 | 12 | ## Install 13 | 14 | ### Pre-build binary 15 | 16 | Download pre-build binary archive from [Releases Page](https://github.com/tanakh/meru/releases) and extract it to an appropriate directory. 17 | 18 | ### Build from source 19 | 20 | First, [install the Rust toolchain](https://www.rust-lang.org/tools/install) so that you can use the `cargo` commands. 21 | 22 | You can use the `cargo` command to build and install it from the source code. 23 | 24 | ```sh 25 | $ cargo install meru 26 | ``` 27 | 28 | To use the development version, please clone this repository. 29 | 30 | ```sh 31 | $ git clone https://github.com/tanakh/meru 32 | $ cd meru 33 | $ cargo run --release 34 | ``` 35 | 36 | On Windows, you need to install dependencies by `cargo-vcpkg`: 37 | 38 | ```sh 39 | $ git clone https://github.com/tanakh/meru 40 | $ cd meru 41 | $ cargo install cargo-vcpkg # if you are not installed cargo-vcpkg yet 42 | $ cargo vcpkg build 43 | $ cargo build --release 44 | ``` 45 | 46 | ## Usage 47 | 48 | Execute `meru.exe` or `meru` and load ROM from GUI. 49 | 50 | By default, the Esc key returns to the menu. The hotkeys can be changed from the hotkey settings in the menu. 51 | 52 | ## License 53 | 54 | [MIT](LICENSE) 55 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy web app to Pages 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | workflow_dispatch: 9 | 10 | permissions: 11 | contents: read 12 | pages: write 13 | id-token: write 14 | 15 | concurrency: 16 | group: "pages" 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | deploy: 21 | environment: 22 | name: github-pages 23 | url: ${{ steps.deployment.outputs.page_url }} 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | with: 29 | submodules: recursive 30 | 31 | - name: Build | Install toolchain 32 | uses: actions-rs/toolchain@v1 33 | with: 34 | toolchain: stable 35 | profile: minimal 36 | target: wasm32-unknown-unknown 37 | override: true 38 | 39 | - name: Build | Install wasm-bindgen-cli 40 | uses: actions-rs/cargo@v1 41 | with: 42 | command: install 43 | args: wasm-bindgen-cli 44 | 45 | - name: Build | Build 46 | uses: actions-rs/cargo@v1 47 | with: 48 | command: build 49 | args: --release --target=wasm32-unknown-unknown 50 | 51 | - name: Build | Generate page 52 | run: wasm-bindgen --out-dir ./web/ --target web ./target/wasm32-unknown-unknown/release/meru.wasm 53 | 54 | - name: Setup Pages 55 | uses: actions/configure-pages@v2 56 | 57 | - name: Upload artifact 58 | uses: actions/upload-pages-artifact@v1 59 | with: 60 | path: 'web' 61 | 62 | - name: Deploy to GitHub Pages 63 | id: deployment 64 | uses: actions/deploy-pages@v1 65 | -------------------------------------------------------------------------------- /src/archive.rs: -------------------------------------------------------------------------------- 1 | #[cfg(not(target_arch = "wasm32"))] 2 | mod inner { 3 | use anyhow::Result; 4 | use std::io::{Read, Seek, SeekFrom}; 5 | 6 | pub trait ReadSeek: Read + Seek + Send + 'static {} 7 | impl ReadSeek for T {} 8 | 9 | pub struct Archive { 10 | source: Box, 11 | } 12 | 13 | impl Archive { 14 | pub fn new(source: impl ReadSeek) -> Result { 15 | Ok(Self { 16 | source: Box::new(source), 17 | }) 18 | } 19 | 20 | pub fn file_names(&mut self) -> Result> { 21 | let ret = compress_tools::list_archive_files(&mut self.source)?; 22 | Ok(ret) 23 | } 24 | 25 | pub fn uncompress_file(&mut self, path: &str) -> Result> { 26 | let mut data = vec![]; 27 | self.source.seek(SeekFrom::Start(0))?; 28 | compress_tools::uncompress_archive_file(&mut self.source, &mut data, path)?; 29 | Ok(data) 30 | } 31 | } 32 | } 33 | 34 | #[cfg(target_arch = "wasm32")] 35 | mod inner { 36 | use anyhow::Result; 37 | use std::io::{Read, Seek}; 38 | use zip::ZipArchive; 39 | 40 | pub trait ReadSeek: Read + Seek + Send + 'static {} 41 | impl ReadSeek for T {} 42 | 43 | pub struct Archive { 44 | zip: ZipArchive>, 45 | } 46 | 47 | impl Archive { 48 | pub fn new(reader: impl ReadSeek) -> Result { 49 | let reader = Box::new(reader) as Box; 50 | let zip = ZipArchive::new(reader)?; 51 | Ok(Self { zip }) 52 | } 53 | 54 | pub fn file_names(&mut self) -> Result> { 55 | let ret = self 56 | .zip 57 | .file_names() 58 | .into_iter() 59 | .map(|s| s.to_string()) 60 | .collect(); 61 | Ok(ret) 62 | } 63 | 64 | pub fn uncompress_file(&mut self, path: &str) -> Result> { 65 | let mut file = self.zip.by_name(path)?; 66 | let mut data = vec![]; 67 | file.read_to_end(&mut data)?; 68 | Ok(data) 69 | } 70 | } 71 | } 72 | 73 | pub use inner::*; 74 | -------------------------------------------------------------------------------- /meru-interface/src/config.rs: -------------------------------------------------------------------------------- 1 | use schemars::{ 2 | gen::SchemaGenerator, 3 | schema::{Schema, SchemaObject}, 4 | JsonSchema, 5 | }; 6 | use std::path::{Path, PathBuf}; 7 | 8 | #[cfg(not(target_arch = "wasm32"))] 9 | mod imp { 10 | use serde::{Deserialize, Serialize}; 11 | use std::path::PathBuf; 12 | 13 | #[derive(Clone, Serialize, Deserialize)] 14 | #[serde(from = "String", into = "String")] 15 | pub struct File { 16 | pub(crate) path: PathBuf, 17 | } 18 | 19 | impl From for File { 20 | fn from(s: String) -> Self { 21 | File { 22 | path: PathBuf::from(s), 23 | } 24 | } 25 | } 26 | 27 | impl From for String { 28 | fn from(f: File) -> Self { 29 | f.path.to_string_lossy().to_string() 30 | } 31 | } 32 | } 33 | 34 | #[cfg(target_arch = "wasm32")] 35 | mod imp { 36 | use base64::STANDARD; 37 | use serde::{Deserialize, Serialize}; 38 | use std::path::PathBuf; 39 | 40 | #[derive(Clone, Serialize, Deserialize)] 41 | pub struct File { 42 | pub(crate) path: PathBuf, 43 | #[serde(with = "Base64Standard")] 44 | pub(crate) data: Vec, 45 | } 46 | 47 | base64_serde_type!(Base64Standard, STANDARD); 48 | } 49 | 50 | pub use imp::File; 51 | 52 | impl JsonSchema for File { 53 | fn schema_name() -> String { 54 | "File".to_string() 55 | } 56 | 57 | fn json_schema(gen: &mut SchemaGenerator) -> Schema { 58 | let mut schema: SchemaObject = ::json_schema(gen).into(); 59 | schema.format = Some("file".to_owned()); 60 | schema.into() 61 | } 62 | } 63 | 64 | impl File { 65 | #[allow(unused_variables)] 66 | pub fn new(path: PathBuf, data: Vec) -> Self { 67 | File { 68 | path, 69 | #[cfg(target_arch = "wasm32")] 70 | data, 71 | } 72 | } 73 | 74 | pub fn path(&self) -> &Path { 75 | &self.path 76 | } 77 | 78 | #[cfg(not(target_arch = "wasm32"))] 79 | pub fn data(&self) -> Result, std::io::Error> { 80 | std::fs::read(&self.path) 81 | } 82 | 83 | #[cfg(target_arch = "wasm32")] 84 | pub fn data(&self) -> Result, std::io::Error> { 85 | Ok(self.data.clone()) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["meru-interface"] 3 | exclude = ["super-sabicom"] 4 | 5 | [package] 6 | name = "meru" 7 | version = "0.3.0" 8 | edition = "2021" 9 | authors = ["Hideyuki Tanaka "] 10 | license = "MIT" 11 | 12 | description = "Multi game console Emulator written in Rust" 13 | repository = "https://github.com/tanakh/meru" 14 | readme = "README.md" 15 | categories = ["emulators"] 16 | keywords = ["emulators"] 17 | 18 | [dependencies] 19 | meru-interface = { path = "meru-interface", version = "0.3.0" } 20 | sabicom = { path = "sabicom", version = "0.2.0" } 21 | super-sabicom = { path = "super-sabicom", version = "0.2.0" } 22 | tgbr = { path = "tgbr", version = "0.4.0" } 23 | tgba = { path = "tgba", version = "0.3.0" } 24 | 25 | anyhow = "1.0.63" 26 | async-channel = "1.7.1" 27 | async-std = { version = "1.12.0", features = ["attributes"] } 28 | bincode = "1.3.3" 29 | bevy = { version = "0.8.1", default-features = false, features = [ 30 | "bevy_audio", 31 | "bevy_gilrs", 32 | "bevy_winit", 33 | "render", 34 | ] } 35 | bevy_easings = "0.8.1" 36 | bevy_egui = "0.16.0" 37 | bevy_tiled_camera = "0.4.1" 38 | cfg-if = "1.0.0" 39 | chrono = "0.4.22" 40 | directories = "4.0.1" 41 | either = "1.8.0" 42 | enum-iterator = "1.2.0" 43 | image = { version = "0.24.3", default-features = false, features = ["ico"] } 44 | log = "0.4.17" 45 | rfd = "0.10.0" 46 | rodio = { version = "0.15.0", default-features = false } 47 | schemars = "0.8.10" 48 | serde = { version = "1.0.144", features = ["derive"] } 49 | serde_json = "1.0.85" 50 | thiserror = "1.0.33" 51 | tempfile = "3.3.0" 52 | winit = "0.26" # bevy_winit-0.8.1 depends on 0.25.x 53 | 54 | [target.'cfg(not(target_arch = "wasm32"))'.dependencies] 55 | compress-tools = "0.13.0" 56 | futures = { version = "0.3.24" } 57 | 58 | [target.'cfg(target_arch = "wasm32")'.dependencies] 59 | indexed_db_futures = "0.2.3" 60 | js-sys = "0.3.59" 61 | url = "2.2.2" 62 | wasm-bindgen = { version = "0.2.82", features = ["serde-serialize"] } 63 | web-sys = "0.3.59" 64 | zip = { version = "0.6.2", default-features = false, features = ["deflate"] } 65 | 66 | [build-dependencies] 67 | winres = "0.1" 68 | 69 | [profile.release] 70 | lto = true 71 | 72 | [package.metadata.vcpkg] 73 | git = "https://github.com/microsoft/vcpkg" 74 | branch = "master" 75 | dependencies = ["libarchive"] 76 | 77 | [package.metadata.vcpkg.target] 78 | x86_64-pc-windows-msvc = { triplet = "x64-windows-static-md" } 79 | 80 | [patch.crates-io] 81 | meru-interface = { path = "meru-interface" } 82 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | workflow_dispatch: 9 | 10 | jobs: 11 | github_build: 12 | name: Build release binaries 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | include: 17 | - target: x86_64-unknown-linux-gnu 18 | os: ubuntu-latest 19 | binary: meru 20 | extension: tar.xz 21 | - target: x86_64-pc-windows-msvc 22 | os: windows-latest 23 | binary: meru.exe 24 | extension: zip 25 | 26 | runs-on: ${{ matrix.os }} 27 | steps: 28 | - name: Build | Checkout 29 | uses: actions/checkout@v2 30 | with: 31 | submodules: recursive 32 | 33 | - name: Build | Install dependencies (Ubuntu) 34 | if: matrix.os == 'ubuntu-latest' 35 | run: sudo apt-get update && sudo apt install -y libarchive-dev libasound2-dev libudev-dev libgtk-3-dev 36 | 37 | - name: Setup | Install dependencies (Windows) 38 | if: matrix.os == 'windows-latest' 39 | run: | 40 | cargo install cargo-vcpkg 41 | cargo vcpkg build 42 | 43 | - name: Build | Install toolchain 44 | uses: actions-rs/toolchain@v1 45 | with: 46 | toolchain: stable 47 | profile: minimal 48 | target: ${{ matrix.target }} 49 | override: true 50 | 51 | - name: Build | rust-cache 52 | uses: Swatinem/rust-cache@v1 53 | 54 | - name: Build | Build 55 | uses: actions-rs/cargo@v1 56 | with: 57 | command: build 58 | args: --release --target=${{ matrix.target }} 59 | 60 | - name: Post Build | Prepare artifacts [Windows] 61 | if: matrix.os == 'windows-latest' 62 | run: | 63 | cd target/${{ matrix.target }}/release 64 | strip ${{ matrix.binary }} 65 | cd - 66 | cp target/${{ matrix.target }}/release/${{ matrix.binary }} . 67 | 7z a meru-${{ github.ref_name }}-${{ matrix.target }}.${{ matrix.extension }} ${{ matrix.binary }} README.md LICENSE 68 | 69 | - name: Post Build | Prepare artifacts [-nix] 70 | if: matrix.os != 'windows-latest' 71 | run: | 72 | strip target/${{ matrix.target }}/release/${{ matrix.binary }} 73 | mkdir meru-${{ github.ref_name }} 74 | cp target/${{ matrix.target }}/release/${{ matrix.binary }} README.md LICENSE meru-${{ github.ref_name }} 75 | tar -cJvf meru-${{ github.ref_name }}-${{ matrix.target }}.${{ matrix.extension }} meru-${{ github.ref_name }} 76 | 77 | - name: Upload artifact 78 | uses: actions/upload-artifact@v2 79 | with: 80 | name: meru-${{ github.ref_name }}-${{ matrix.target }}.${{ matrix.extension }} 81 | path: meru-${{ github.ref_name }}-${{ matrix.target }}.${{ matrix.extension }} 82 | 83 | github_release: 84 | name: Create GitHub Release 85 | needs: github_build 86 | runs-on: ubuntu-latest 87 | steps: 88 | - name: Setup | Checkout 89 | uses: actions/checkout@v2.4.0 90 | with: 91 | submodules: recursive 92 | fetch-depth: 0 93 | 94 | - name: Setup | Artifacts 95 | uses: actions/download-artifact@v2 96 | 97 | - name: Build | Publish 98 | uses: softprops/action-gh-release@v1 99 | with: 100 | files: meru-*/meru-* 101 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | rustfmt: 7 | name: Rustfmt [Formatter] 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Setup | Checkout 11 | uses: actions/checkout@v2 12 | with: 13 | submodules: recursive 14 | 15 | - name: Setup | Rust 16 | uses: actions-rs/toolchain@v1 17 | with: 18 | toolchain: stable 19 | override: true 20 | profile: minimal 21 | components: rustfmt 22 | 23 | - name: Build | Format 24 | run: cargo fmt --all -- --check 25 | 26 | clippy: 27 | name: Clippy [Linter] 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Setup | Checkout 31 | uses: actions/checkout@v2 32 | with: 33 | submodules: recursive 34 | 35 | - name: Setup | Install dependencies 36 | run: sudo apt-get update && sudo apt install -y libarchive-dev libasound2-dev libudev-dev libgtk-3-dev 37 | 38 | - name: Setup | Cache 39 | uses: Swatinem/rust-cache@v1 40 | 41 | - name: Setup | Rust 42 | uses: actions-rs/toolchain@v1 43 | with: 44 | toolchain: stable 45 | profile: minimal 46 | override: true 47 | components: clippy 48 | 49 | - name: Build | Lint 50 | uses: actions-rs/cargo@v1 51 | with: 52 | command: clippy 53 | args: --workspace --locked --all-targets --all-features -- -D clippy::all 54 | 55 | cargo_check: 56 | name: Compile 57 | strategy: 58 | matrix: 59 | include: 60 | - target: x86_64-unknown-linux-gnu 61 | os: ubuntu-latest 62 | - target: x86_64-pc-windows-msvc 63 | os: windows-latest 64 | - target: wasm32-unknown-unknown 65 | os: ubuntu-latest 66 | runs-on: ${{ matrix.os }} 67 | steps: 68 | - name: Setup | Checkout 69 | uses: actions/checkout@v2 70 | with: 71 | submodules: recursive 72 | 73 | - name: Setup | Install dependencies (Ubuntu) 74 | if: matrix.os == 'ubuntu-latest' 75 | run: sudo apt-get update && sudo apt install -y libarchive-dev libasound2-dev libudev-dev libgtk-3-dev 76 | 77 | - name: Setup | Install dependencies (Windows) 78 | if: matrix.os == 'windows-latest' 79 | run: | 80 | cargo install cargo-vcpkg 81 | cargo vcpkg build 82 | 83 | - name: Setup | Cache 84 | uses: Swatinem/rust-cache@v1 85 | 86 | - name: Setup | Rust 87 | uses: actions-rs/toolchain@v1 88 | with: 89 | toolchain: stable 90 | profile: minimal 91 | target: ${{ matrix.target }} 92 | override: true 93 | 94 | - name: Build | Check 95 | run: cargo check --workspace --locked --target=${{ matrix.target }} 96 | 97 | test: 98 | name: Test Suite 99 | runs-on: ubuntu-latest 100 | needs: cargo_check 101 | steps: 102 | - name: Setup | Checkout 103 | uses: actions/checkout@v2 104 | with: 105 | submodules: recursive 106 | 107 | - name: Setup | Install dependencies 108 | run: sudo apt-get update && sudo apt install -y libarchive-dev libasound2-dev libudev-dev libgtk-3-dev 109 | 110 | - name: Setup | Cache 111 | uses: Swatinem/rust-cache@v1 112 | 113 | - name: Setup | Rust 114 | uses: actions-rs/toolchain@v1 115 | with: 116 | toolchain: stable 117 | profile: minimal 118 | override: true 119 | 120 | - name: Build | Test 121 | # FIXME: fix tests 122 | run: cargo test --workspace --locked --release || true 123 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 20 | 21 | 29 | 30 | MERU 31 | 32 | 33 | 34 | 35 | 39 | 40 | 80 | 81 |
82 |
83 | 84 | 85 |
86 |
87 | 88 |
89 |
90 | 91 |

92 | MERU: Multi-console Emulator written in Rust 93 |   94 | GitHub Repository 95 |
96 | © 2022 tanakh 97 |

98 |
99 |
100 |
101 | 102 | 103 | -------------------------------------------------------------------------------- /meru-interface/src/lib.rs: -------------------------------------------------------------------------------- 1 | #[cfg(target_arch = "wasm32")] 2 | #[macro_use] 3 | extern crate base64_serde; 4 | 5 | pub mod config; 6 | pub mod key_assign; 7 | 8 | pub use config::File; 9 | 10 | use schemars::{ 11 | gen::SchemaGenerator, 12 | schema::{Schema, SchemaObject}, 13 | JsonSchema, 14 | }; 15 | use serde::{de::DeserializeOwned, Deserialize, Serialize}; 16 | 17 | pub use crate::key_assign::{ 18 | Gamepad, GamepadAxis, GamepadAxisType, GamepadButton, GamepadButtonType, InputState, KeyAssign, 19 | KeyCode, MultiKey, SingleKey, 20 | }; 21 | 22 | pub struct CoreInfo { 23 | pub system_name: &'static str, 24 | pub abbrev: &'static str, 25 | pub file_extensions: &'static [&'static str], 26 | } 27 | 28 | #[derive(Default)] 29 | pub struct FrameBuffer { 30 | pub width: usize, 31 | pub height: usize, 32 | pub buffer: Vec, 33 | } 34 | 35 | impl FrameBuffer { 36 | pub fn new(width: usize, height: usize) -> Self { 37 | let mut ret = Self::default(); 38 | ret.resize(width, height); 39 | ret 40 | } 41 | 42 | pub fn resize(&mut self, width: usize, height: usize) { 43 | if (width, height) == (self.width, self.height) { 44 | return; 45 | } 46 | self.width = width; 47 | self.height = height; 48 | self.buffer.resize(width * height, Color::default()); 49 | } 50 | 51 | pub fn pixel(&self, x: usize, y: usize) -> &Color { 52 | &self.buffer[y * self.width + x] 53 | } 54 | 55 | pub fn pixel_mut(&mut self, x: usize, y: usize) -> &mut Color { 56 | &mut self.buffer[y * self.width + x] 57 | } 58 | } 59 | 60 | #[derive(Default, Clone, PartialEq, Eq, Serialize, Deserialize)] 61 | #[serde(try_from = "String", into = "String")] 62 | pub struct Color { 63 | pub r: u8, 64 | pub g: u8, 65 | pub b: u8, 66 | } 67 | 68 | impl JsonSchema for Color { 69 | fn schema_name() -> String { 70 | "Color".to_string() 71 | } 72 | 73 | fn json_schema(gen: &mut SchemaGenerator) -> Schema { 74 | let mut schema: SchemaObject = ::json_schema(gen).into(); 75 | schema.format = Some("color".to_owned()); 76 | schema.into() 77 | } 78 | } 79 | 80 | #[derive(thiserror::Error, Debug)] 81 | pub enum ParseColorError { 82 | #[error("Color string must be hex color code: `#RRGGBB`")] 83 | InvalidFormat, 84 | } 85 | 86 | impl TryFrom for Color { 87 | type Error = ParseColorError; 88 | 89 | fn try_from(s: String) -> Result { 90 | if s.len() != 7 || &s[0..1] != "#" || !s[1..].chars().all(|c| c.is_ascii_hexdigit()) { 91 | Err(ParseColorError::InvalidFormat)?; 92 | } 93 | 94 | Ok(Color { 95 | r: u8::from_str_radix(&s[1..3], 16).unwrap(), 96 | g: u8::from_str_radix(&s[3..5], 16).unwrap(), 97 | b: u8::from_str_radix(&s[5..7], 16).unwrap(), 98 | }) 99 | } 100 | } 101 | 102 | impl From for String { 103 | fn from(c: Color) -> Self { 104 | format!("#{:02X}{:02X}{:02X}", c.r, c.g, c.b) 105 | } 106 | } 107 | 108 | impl Color { 109 | pub const fn new(r: u8, g: u8, b: u8) -> Self { 110 | Self { r, g, b } 111 | } 112 | } 113 | 114 | pub struct AudioBuffer { 115 | pub sample_rate: u32, 116 | pub channels: u16, 117 | pub samples: Vec, 118 | } 119 | 120 | impl Default for AudioBuffer { 121 | fn default() -> Self { 122 | Self { 123 | sample_rate: 48000, 124 | channels: 2, 125 | samples: vec![], 126 | } 127 | } 128 | } 129 | 130 | impl AudioBuffer { 131 | pub fn new(sample_rate: u32, channels: u16) -> Self { 132 | Self { 133 | sample_rate, 134 | channels, 135 | samples: vec![], 136 | } 137 | } 138 | } 139 | 140 | #[derive(Default, Clone, PartialEq, Eq, Serialize, Deserialize)] 141 | pub struct AudioSample { 142 | pub left: i16, 143 | pub right: i16, 144 | } 145 | 146 | impl AudioSample { 147 | pub fn new(left: i16, right: i16) -> Self { 148 | Self { left, right } 149 | } 150 | } 151 | 152 | #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] 153 | pub struct KeyConfig { 154 | pub controllers: Vec>, 155 | } 156 | 157 | impl KeyConfig { 158 | pub fn input(&self, input_state: &impl InputState) -> InputData { 159 | let controllers = self 160 | .controllers 161 | .iter() 162 | .map(|keys| { 163 | keys.iter() 164 | .map(|(key, assign)| (key.clone(), assign.pressed(input_state))) 165 | .collect() 166 | }) 167 | .collect(); 168 | 169 | InputData { controllers } 170 | } 171 | } 172 | 173 | #[derive(Default)] 174 | pub struct InputData { 175 | pub controllers: Vec>, 176 | } 177 | 178 | pub trait EmulatorCore { 179 | type Error: std::error::Error + Send + Sync + 'static; 180 | type Config: JsonSchema + Serialize + DeserializeOwned + Default; 181 | 182 | fn core_info() -> &'static CoreInfo; 183 | 184 | fn try_from_file( 185 | data: &[u8], 186 | backup: Option<&[u8]>, 187 | config: &Self::Config, 188 | ) -> Result 189 | where 190 | Self: Sized; 191 | fn game_info(&self) -> Vec<(String, String)>; 192 | 193 | fn set_config(&mut self, config: &Self::Config); 194 | 195 | fn exec_frame(&mut self, render_graphics: bool); 196 | fn reset(&mut self); 197 | 198 | fn frame_buffer(&self) -> &FrameBuffer; 199 | fn audio_buffer(&self) -> &AudioBuffer; 200 | 201 | fn default_key_config() -> KeyConfig; 202 | fn set_input(&mut self, input: &InputData); 203 | 204 | fn backup(&self) -> Option>; 205 | 206 | fn save_state(&self) -> Vec; 207 | fn load_state(&mut self, data: &[u8]) -> Result<(), Self::Error>; 208 | } 209 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use enum_iterator::Sequence; 3 | use log::{info, warn}; 4 | use serde::{Deserialize, Serialize}; 5 | use serde_json::Value; 6 | use std::{ 7 | collections::{BTreeMap, VecDeque}, 8 | fmt::Display, 9 | future::Future, 10 | path::{Path, PathBuf}, 11 | }; 12 | 13 | use crate::{ 14 | core::{Emulator, EmulatorCores, EMULATOR_CORES}, 15 | file::{create_dir_all, read, read_to_string, write}, 16 | hotkey::HotKeys, 17 | input::KeyConfig, 18 | }; 19 | 20 | #[derive(PartialEq, Eq, Clone, Copy, Debug, Serialize, Deserialize, Sequence)] 21 | pub enum SystemKey { 22 | Up, 23 | Down, 24 | Left, 25 | Right, 26 | Ok, 27 | Cancel, 28 | } 29 | 30 | impl Display for SystemKey { 31 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 32 | let s = match self { 33 | SystemKey::Up => "Up", 34 | SystemKey::Down => "Down", 35 | SystemKey::Left => "Left", 36 | SystemKey::Right => "Right", 37 | SystemKey::Ok => "Ok", 38 | SystemKey::Cancel => "Cancel", 39 | }; 40 | write!(f, "{s}") 41 | } 42 | } 43 | 44 | pub type SystemKeys = KeyConfig; 45 | 46 | impl Default for SystemKeys { 47 | fn default() -> Self { 48 | use meru_interface::key_assign::*; 49 | use SystemKey::*; 50 | Self(vec![ 51 | (Up, any!(keycode!(Up), pad_button!(0, DPadUp))), 52 | (Down, any!(keycode!(Down), pad_button!(0, DPadDown))), 53 | (Left, any!(keycode!(Left), pad_button!(0, DPadLeft))), 54 | (Right, any!(keycode!(Right), pad_button!(0, DPadRight))), 55 | (Ok, any!(keycode!(Return), pad_button!(0, East))), 56 | (Cancel, any!(keycode!(Back), pad_button!(0, South))), 57 | ]) 58 | } 59 | } 60 | 61 | #[derive(PartialEq, Eq, Clone, Serialize, Deserialize)] 62 | pub struct Config { 63 | pub save_dir: PathBuf, 64 | pub show_fps: bool, 65 | pub frame_skip_on_turbo: usize, 66 | pub scaling: usize, 67 | pub auto_state_save_rate: usize, // byte/s 68 | pub auto_state_save_limit: usize, // byte 69 | pub minimum_auto_save_span: usize, // frames 70 | pub hotkeys: HotKeys, 71 | pub system_keys: SystemKeys, 72 | 73 | #[serde(default)] 74 | core_configs: BTreeMap, 75 | #[serde(default)] 76 | key_configs: BTreeMap, 77 | } 78 | 79 | #[cfg(not(target_arch = "wasm32"))] 80 | mod dirs { 81 | use anyhow::{anyhow, Result}; 82 | use directories::ProjectDirs; 83 | 84 | pub fn project_dirs() -> Result { 85 | let ret = ProjectDirs::from("", "", "meru") 86 | .ok_or_else(|| anyhow!("Cannot find project directory"))?; 87 | Ok(ret) 88 | } 89 | } 90 | 91 | #[cfg(target_arch = "wasm32")] 92 | mod dirs { 93 | use anyhow::{bail, Result}; 94 | use directories::ProjectDirs; 95 | 96 | pub fn project_dirs() -> Result { 97 | bail!("wasm does not support project directories") 98 | } 99 | } 100 | 101 | use dirs::project_dirs; 102 | 103 | impl Default for Config { 104 | fn default() -> Self { 105 | let (save_dir, state_dir) = if let Ok(project_dirs) = project_dirs() { 106 | ( 107 | project_dirs.data_dir().to_owned(), 108 | project_dirs 109 | .state_dir() 110 | .unwrap_or_else(|| project_dirs.data_dir()) 111 | .to_owned(), 112 | ) 113 | } else { 114 | warn!("Cannot get project directory. Defaults to `save` and `state`"); 115 | (PathBuf::from("save"), PathBuf::from("state")) 116 | }; 117 | 118 | create_dir_all(&save_dir).unwrap(); 119 | create_dir_all(&state_dir).unwrap(); 120 | 121 | Self { 122 | save_dir, 123 | show_fps: false, 124 | frame_skip_on_turbo: 4, 125 | scaling: 2, 126 | auto_state_save_rate: 128 * 1024, // 128KB/s 127 | auto_state_save_limit: 1024 * 1024 * 1024, // 1GB 128 | minimum_auto_save_span: 60, 129 | system_keys: SystemKeys::default(), 130 | hotkeys: HotKeys::default(), 131 | core_configs: BTreeMap::new(), 132 | key_configs: BTreeMap::new(), 133 | } 134 | } 135 | } 136 | 137 | fn config_dir() -> Result { 138 | let config_dir = if let Ok(project_dirs) = project_dirs() { 139 | project_dirs.config_dir().to_owned() 140 | } else { 141 | warn!("Cannot find project directory. Defaults to `config`"); 142 | Path::new("config").to_owned() 143 | }; 144 | create_dir_all(&config_dir)?; 145 | Ok(config_dir) 146 | } 147 | 148 | fn config_path() -> Result { 149 | Ok(config_dir()?.join("config.json")) 150 | } 151 | 152 | impl Config { 153 | pub async fn save(&self) -> Result<()> { 154 | let s = serde_json::to_string_pretty(self)?; 155 | let path = config_path()?; 156 | write(&path, s).await?; 157 | info!("Saved config file: {:?}", path.display()); 158 | Ok(()) 159 | } 160 | 161 | pub fn core_config(&self, abbrev: &str) -> Value { 162 | if let Some(config) = self.core_configs.get(abbrev) { 163 | config.clone() 164 | } else { 165 | EmulatorCores::from_abbrev(abbrev).unwrap().default_config() 166 | } 167 | } 168 | 169 | pub fn set_core_config(&mut self, abbrev: &str, value: Value) { 170 | self.core_configs.insert(abbrev.to_owned(), value); 171 | } 172 | 173 | pub fn key_config(&mut self, abbrev: &str) -> &meru_interface::KeyConfig { 174 | self.key_configs 175 | .entry(abbrev.to_string()) 176 | .or_insert_with(|| Emulator::default_key_config(abbrev)) 177 | } 178 | 179 | pub fn set_key_config(&mut self, abbrev: &str, key_config: meru_interface::KeyConfig) { 180 | self.key_configs.insert(abbrev.to_string(), key_config); 181 | } 182 | } 183 | 184 | pub async fn load_config() -> Result { 185 | let ret = if let Ok(s) = read_to_string(config_path()?).await { 186 | let mut config: Config = serde_json::from_str(&s)?; 187 | 188 | for core in EMULATOR_CORES { 189 | let core_config = config.core_config(core.core_info().abbrev); 190 | if !core.check_config(core_config) { 191 | warn!( 192 | "Config for {} is invalid. Initialize to default", 193 | core.core_info().abbrev 194 | ); 195 | config.set_core_config(core.core_info().abbrev, core.default_config()); 196 | } 197 | } 198 | config 199 | } else { 200 | Config::default() 201 | }; 202 | Ok(ret) 203 | } 204 | 205 | #[derive(Default, Serialize, Deserialize)] 206 | pub struct PersistentState { 207 | pub recent: VecDeque, 208 | } 209 | 210 | #[derive(Serialize, Deserialize)] 211 | pub struct RecentFile { 212 | pub path: PathBuf, 213 | #[cfg(target_arch = "wasm32")] 214 | pub data: Vec, 215 | } 216 | 217 | impl PersistentState { 218 | pub fn add_recent(&mut self, recent: RecentFile) { 219 | self.recent.retain(|r| r.path != recent.path); 220 | self.recent.push_front(recent); 221 | while self.recent.len() > 20 { 222 | self.recent.pop_back(); 223 | } 224 | } 225 | 226 | pub fn save(&self) -> impl Future> { 227 | let s = bincode::serialize(self).unwrap(); 228 | async move { 229 | write(persistent_state_path().unwrap(), s).await?; 230 | Ok::<(), anyhow::Error>(()) 231 | } 232 | } 233 | } 234 | 235 | fn persistent_state_path() -> Result { 236 | let config_dir = config_dir()?; 237 | create_dir_all(&config_dir)?; 238 | Ok(config_dir.join("state.json")) 239 | } 240 | 241 | pub async fn load_persistent_state() -> Result { 242 | let ret = if let Ok(s) = read(persistent_state_path()?).await { 243 | if let Ok(ret) = bincode::deserialize(&s) { 244 | ret 245 | } else { 246 | Default::default() 247 | } 248 | } else { 249 | Default::default() 250 | }; 251 | Ok(ret) 252 | } 253 | -------------------------------------------------------------------------------- /src/hotkey.rs: -------------------------------------------------------------------------------- 1 | use bevy::prelude::*; 2 | use either::Either; 3 | use enum_iterator::{all, Sequence}; 4 | use serde::{Deserialize, Serialize}; 5 | use std::fmt::Display; 6 | use Either::{Left, Right}; 7 | 8 | use crate::{ 9 | app::{AppState, ShowMessage, UiState, WindowControlEvent}, 10 | config::Config, 11 | core::Emulator, 12 | input::{InputState, KeyConfig}, 13 | utils::{spawn_local, unbounded_channel, Receiver, Sender}, 14 | }; 15 | 16 | pub struct HotKeyPlugin; 17 | 18 | impl Plugin for HotKeyPlugin { 19 | fn build(&self, app: &mut App) { 20 | let (s, r) = unbounded_channel::>(); 21 | app.add_system(check_hotkey) 22 | .add_system(process_hotkey) 23 | .insert_resource(IsTurbo(false)) 24 | .insert_resource(s) 25 | .insert_resource(r); 26 | } 27 | } 28 | 29 | #[derive(PartialEq, Eq, Clone, Copy, Debug, Serialize, Deserialize, Sequence)] 30 | pub enum HotKey { 31 | Reset, 32 | Turbo, 33 | StateSave, 34 | StateLoad, 35 | NextSlot, 36 | PrevSlot, 37 | Rewind, 38 | Menu, 39 | FullScreen, 40 | ScaleUp, 41 | ScaleDown, 42 | } 43 | 44 | enum HotKeyCont { 45 | StateLoadDone(anyhow::Result>), 46 | } 47 | 48 | impl Display for HotKey { 49 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 50 | let s = match self { 51 | HotKey::Reset => "Reset", 52 | HotKey::Turbo => "Turbo", 53 | HotKey::StateSave => "State Save", 54 | HotKey::StateLoad => "State Load", 55 | HotKey::NextSlot => "State Slot Next", 56 | HotKey::PrevSlot => "State Slot Prev", 57 | HotKey::Rewind => "Start Rewindng", 58 | HotKey::Menu => "Enter/Leave Menu", 59 | HotKey::FullScreen => "Fullsceen", 60 | HotKey::ScaleUp => "Window Scale +", 61 | HotKey::ScaleDown => "Window Scale -", 62 | }; 63 | write!(f, "{s}") 64 | } 65 | } 66 | 67 | pub type HotKeys = KeyConfig; 68 | 69 | impl Default for HotKeys { 70 | fn default() -> Self { 71 | use meru_interface::key_assign::*; 72 | use HotKey::*; 73 | Self(vec![ 74 | (Reset, all![keycode!(LControl), keycode!(R)]), 75 | (Turbo, any![keycode!(Tab), pad_button!(0, LeftTrigger2)]), 76 | (StateSave, all![keycode!(LControl), keycode!(S)]), 77 | (StateLoad, all![keycode!(LControl), keycode!(L)]), 78 | (NextSlot, all![keycode!(LControl), keycode!(N)]), 79 | (PrevSlot, all![keycode!(LControl), keycode!(P)]), 80 | ( 81 | Rewind, 82 | any![ 83 | keycode!(Back), 84 | all![pad_button!(0, LeftTrigger2), pad_button!(0, RightTrigger2)] 85 | ], 86 | ), 87 | (Menu, keycode!(Escape)), 88 | (FullScreen, all![keycode!(RAlt), keycode!(Return)]), 89 | ( 90 | ScaleUp, 91 | all![keycode!(LControl), any![keycode!(Plus), keycode!(Equals)]], 92 | ), 93 | (ScaleDown, all![keycode!(LControl), keycode!(Minus)]), 94 | ]) 95 | } 96 | } 97 | 98 | pub struct IsTurbo(pub bool); 99 | 100 | fn check_hotkey( 101 | config: Res, 102 | input_keycode: Res>, 103 | input_gamepad_button: Res>, 104 | input_gamepad_axis: Res>, 105 | writer: Res>>, 106 | mut is_turbo: ResMut, 107 | ) { 108 | let input_state = InputState::new(&input_keycode, &input_gamepad_button, &input_gamepad_axis); 109 | 110 | for hotkey in all::() { 111 | if config.hotkeys.just_pressed(&hotkey, &input_state) { 112 | writer.try_send(Left(hotkey)).unwrap(); 113 | } 114 | } 115 | 116 | is_turbo.0 = config.hotkeys.pressed( 117 | &HotKey::Turbo, 118 | &InputState::new(&input_keycode, &input_gamepad_button, &input_gamepad_axis), 119 | ); 120 | } 121 | 122 | #[allow(clippy::too_many_arguments)] 123 | fn process_hotkey( 124 | mut config: ResMut, 125 | recv: Res>>, 126 | send: Res>>, 127 | mut app_state: ResMut>, 128 | mut emulator: Option>, 129 | mut ui_state: ResMut, 130 | mut window_control_event: EventWriter, 131 | mut message_event: EventWriter, 132 | ) { 133 | while let Ok(hotkey) = recv.try_recv() { 134 | match hotkey { 135 | Left(HotKey::Reset) => { 136 | if let Some(emulator) = &mut emulator { 137 | emulator.reset(); 138 | message_event.send(ShowMessage("Reset machine".to_string())); 139 | } 140 | } 141 | Left(HotKey::StateSave) => { 142 | if let Some(emulator) = &emulator { 143 | let fut = emulator.save_state_slot(ui_state.state_save_slot, config.as_ref()); 144 | 145 | spawn_local(async move { fut.await.unwrap() }); 146 | 147 | message_event.send(ShowMessage(format!( 148 | "State saved: #{}", 149 | ui_state.state_save_slot 150 | ))); 151 | } 152 | } 153 | Left(HotKey::StateLoad) => { 154 | if let Some(emulator) = &emulator { 155 | let send = send.clone(); 156 | 157 | let fut = emulator.load_state_slot(ui_state.state_save_slot, config.as_ref()); 158 | 159 | spawn_local(async move { 160 | let result = fut.await; 161 | send.send(Right(HotKeyCont::StateLoadDone(result))) 162 | .await 163 | .unwrap(); 164 | }); 165 | } 166 | } 167 | Right(HotKeyCont::StateLoadDone(data)) => { 168 | if let Some(emulator) = &mut emulator { 169 | match data { 170 | Ok(data) => { 171 | if let Err(err) = emulator.load_state_data(&data) { 172 | message_event 173 | .send(ShowMessage(format!("Failed to load state: {err:?}"))); 174 | } else { 175 | message_event.send(ShowMessage(format!( 176 | "State loaded: #{}", 177 | ui_state.state_save_slot 178 | ))); 179 | } 180 | } 181 | Err(err) => { 182 | message_event 183 | .send(ShowMessage(format!("Failed to load state: {err:?}"))); 184 | } 185 | } 186 | } 187 | } 188 | Left(HotKey::NextSlot) => { 189 | ui_state.state_save_slot += 1; 190 | message_event.send(ShowMessage(format!( 191 | "State slot changed: #{}", 192 | ui_state.state_save_slot 193 | ))); 194 | } 195 | Left(HotKey::PrevSlot) => { 196 | ui_state.state_save_slot = ui_state.state_save_slot.saturating_sub(1); 197 | message_event.send(ShowMessage(format!( 198 | "State slot changed: #{}", 199 | ui_state.state_save_slot 200 | ))); 201 | } 202 | Left(HotKey::Rewind) => { 203 | if app_state.current() == &AppState::Running { 204 | let emulator = emulator.as_mut().unwrap(); 205 | emulator.push_auto_save(); 206 | app_state.push(AppState::Rewinding).unwrap(); 207 | } 208 | } 209 | Left(HotKey::Menu) => { 210 | if app_state.current() == &AppState::Running { 211 | app_state.set(AppState::Menu).unwrap(); 212 | } else if app_state.current() == &AppState::Menu && emulator.is_some() { 213 | app_state.set(AppState::Running).unwrap(); 214 | } 215 | } 216 | Left(HotKey::FullScreen) => { 217 | window_control_event.send(WindowControlEvent::ToggleFullscreen); 218 | } 219 | Left(HotKey::ScaleUp) => { 220 | config.scaling += 1; 221 | window_control_event.send(WindowControlEvent::Restore); 222 | } 223 | Left(HotKey::ScaleDown) => { 224 | config.scaling = (config.scaling - 1).max(1); 225 | window_control_event.send(WindowControlEvent::Restore); 226 | } 227 | 228 | Left(HotKey::Turbo) => {} 229 | } 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/rewinding.rs: -------------------------------------------------------------------------------- 1 | use bevy::prelude::*; 2 | use bevy_easings::*; 3 | use std::time::Duration; 4 | 5 | use crate::{ 6 | app::{AppState, ScreenSprite}, 7 | config::{self, SystemKey}, 8 | core::Emulator, 9 | hotkey::HotKey, 10 | input::InputState, 11 | }; 12 | 13 | #[derive(Clone)] 14 | pub struct AutoSavedState { 15 | pub thumbnail: Image, 16 | pub data: Vec, 17 | } 18 | 19 | impl AutoSavedState { 20 | pub fn size(&self) -> usize { 21 | self.data.len() + self.thumbnail.data.len() 22 | } 23 | } 24 | 25 | pub struct RewindingState { 26 | pos: usize, 27 | load_pos: Option, 28 | exit: bool, 29 | } 30 | 31 | pub struct RewindingPlugin; 32 | 33 | impl Plugin for RewindingPlugin { 34 | fn build(&self, app: &mut App) { 35 | app.add_system_set( 36 | SystemSet::on_enter(AppState::Rewinding).with_system(enter_rewinding_system), 37 | ) 38 | .add_system_set(SystemSet::on_update(AppState::Rewinding).with_system(rewinding_system)) 39 | .add_system_set(SystemSet::on_exit(AppState::Rewinding).with_system(exit_rewinding_system)); 40 | } 41 | } 42 | 43 | #[derive(Component)] 44 | struct BgColor; 45 | 46 | #[derive(Component)] 47 | struct Preview; 48 | 49 | #[derive(Component)] 50 | struct Thumbnail(usize); 51 | 52 | fn enter_rewinding_system( 53 | mut commands: Commands, 54 | emulator: ResMut, 55 | mut images: ResMut>, 56 | mut screen_visibility: Query<&mut Visibility, With>, 57 | ) { 58 | let screen_width = emulator.core.frame_buffer().width as f32; 59 | let screen_height = emulator.core.frame_buffer().height as f32; 60 | 61 | for mut visibility in screen_visibility.iter_mut() { 62 | visibility.is_visible = false; 63 | } 64 | 65 | let state_num = emulator.auto_saved_states.len(); 66 | assert!(state_num > 0); 67 | 68 | let preview_image = images.add(emulator.auto_saved_states[state_num - 1].thumbnail.clone()); 69 | 70 | commands 71 | .spawn_bundle(SpriteBundle { 72 | sprite: Sprite { 73 | color: Color::GRAY, 74 | custom_size: Some(Vec2::new(screen_width, screen_height)), 75 | ..Default::default() 76 | }, 77 | transform: Transform::from_xyz(0.0, 0.0, -0.01), 78 | ..Default::default() 79 | }) 80 | .insert(BgColor); 81 | 82 | commands 83 | .spawn_bundle(SpriteBundle { 84 | texture: preview_image, 85 | transform: Transform::from_xyz(0.0, 0.0, 1.0), 86 | ..Default::default() 87 | }) 88 | .insert( 89 | Transform { 90 | ..Default::default() 91 | } 92 | .ease_to( 93 | Transform::from_xyz(0.0, screen_height / 6.0, 1.0) 94 | .with_scale(Vec3::splat(2.0 / 3.0)), 95 | EaseFunction::CubicInOut, 96 | EasingType::Once { 97 | duration: Duration::from_millis(200), 98 | }, 99 | ), 100 | ) 101 | .insert(Preview); 102 | 103 | for i in 0..4 { 104 | if state_num > i { 105 | let thumbnail = images.add( 106 | emulator.auto_saved_states[state_num - 1 - i] 107 | .thumbnail 108 | .clone(), 109 | ); 110 | commands 111 | .spawn_bundle(SpriteBundle { 112 | texture: thumbnail, 113 | transform: Transform::from_xyz( 114 | -(i as f32) * screen_width / 4.0, 115 | -screen_height / 2.0 + screen_height / 6.0, 116 | 0.0, 117 | ) 118 | .with_scale(Vec3::splat(1.0 / 4.5)), 119 | ..Default::default() 120 | }) 121 | .insert(Thumbnail(i)); 122 | } 123 | } 124 | 125 | commands.insert_resource(RewindingState { 126 | pos: state_num - 1, 127 | load_pos: None, 128 | exit: false, 129 | }); 130 | } 131 | 132 | #[allow(clippy::too_many_arguments)] 133 | fn rewinding_system( 134 | mut commands: Commands, 135 | mut emulator: ResMut, 136 | mut app_state: ResMut>, 137 | mut rewinding_state: ResMut, 138 | mut preview: Query<(&mut Handle, &Transform, Entity), With>, 139 | thumbnails: Query<(Entity, &Transform), With>, 140 | config: Res, 141 | input_keycode: Res>, 142 | mut images: ResMut>, 143 | input_gamepad_button: Res>, 144 | input_gamepad_axis: Res>, 145 | easing: Query<&EasingComponent>, 146 | ) { 147 | let screen_width = emulator.core.frame_buffer().width as f32; 148 | let screen_height = emulator.core.frame_buffer().height as f32; 149 | 150 | let input_state = InputState::new(&input_keycode, &input_gamepad_button, &input_gamepad_axis); 151 | 152 | // wait for animation 153 | if easing.iter().next().is_some() { 154 | // remove invisible thumbnails 155 | for (entity, transform) in thumbnails.iter() { 156 | if transform.translation.x.abs() > screen_width { 157 | commands.entity(entity).despawn(); 158 | // TODO: remove image from assets 159 | } 160 | } 161 | return; 162 | } 163 | 164 | if rewinding_state.exit { 165 | app_state.pop().unwrap(); 166 | return; 167 | } 168 | 169 | if let Some(load_pos) = &rewinding_state.load_pos { 170 | while emulator.auto_saved_states.len() > *load_pos + 1 { 171 | emulator.auto_saved_states.pop_back(); 172 | } 173 | let state = emulator.auto_saved_states.back().unwrap().clone(); 174 | 175 | let mut preview = preview.single_mut(); 176 | *preview.0 = images.add(state.thumbnail); 177 | commands.entity(preview.2).insert(preview.1.ease_to( 178 | Transform::from_xyz(0.0, 0.0, 1.0), 179 | EaseFunction::CubicInOut, 180 | EasingType::Once { 181 | duration: Duration::from_millis(200), 182 | }, 183 | )); 184 | emulator.core.load_state(&state.data).unwrap(); 185 | rewinding_state.exit = true; 186 | return; 187 | } 188 | 189 | let left = config.system_keys.pressed(&SystemKey::Left, &input_state); 190 | let right = config.system_keys.pressed(&SystemKey::Right, &input_state); 191 | 192 | if left || right { 193 | let mut do_move = false; 194 | if left && rewinding_state.pos > 0 { 195 | if rewinding_state.pos >= 4 { 196 | let ix = rewinding_state.pos - 4; 197 | let thumbnail = images.add(emulator.auto_saved_states[ix].thumbnail.clone()); 198 | 199 | commands 200 | .spawn_bundle(SpriteBundle { 201 | texture: thumbnail, 202 | transform: Transform::from_xyz( 203 | -3.0 * screen_width / 4.0, 204 | -screen_height / 2.0 + screen_height / 6.0, 205 | 0.0, 206 | ) 207 | .with_scale(Vec3::splat(1.0 / 4.5)), 208 | ..Default::default() 209 | }) 210 | .insert(Thumbnail(ix)); 211 | } 212 | 213 | rewinding_state.pos -= 1; 214 | do_move = true; 215 | } 216 | if right && rewinding_state.pos < emulator.auto_saved_states.len() - 1 { 217 | if rewinding_state.pos + 4 < emulator.auto_saved_states.len() { 218 | let ix = rewinding_state.pos + 4; 219 | let thumbnail = images.add(emulator.auto_saved_states[ix].thumbnail.clone()); 220 | 221 | commands 222 | .spawn_bundle(SpriteBundle { 223 | texture: thumbnail, 224 | transform: Transform::from_xyz( 225 | 3.0 * screen_width / 4.0, 226 | -screen_height / 2.0 + screen_height / 6.0, 227 | 0.0, 228 | ) 229 | .with_scale(Vec3::splat(1.0 / 4.5)), 230 | ..Default::default() 231 | }) 232 | .insert(Thumbnail(ix)); 233 | } 234 | 235 | rewinding_state.pos += 1; 236 | do_move = true; 237 | } 238 | 239 | if do_move { 240 | let dx = if left { 1.0 } else { -1.0 } * screen_width / 4.0; 241 | for (entity, trans) in thumbnails.iter() { 242 | commands.entity(entity).insert(trans.ease_to( 243 | Transform::from_xyz(dx, 0.0, 0.0) * *trans, 244 | EaseFunction::CubicInOut, 245 | EasingType::Once { 246 | duration: Duration::from_millis(100), 247 | }, 248 | )); 249 | } 250 | 251 | *preview.single_mut().0 = images.add( 252 | emulator.auto_saved_states[rewinding_state.pos] 253 | .thumbnail 254 | .clone(), 255 | ); 256 | } 257 | } 258 | 259 | if config 260 | .system_keys 261 | .just_pressed(&SystemKey::Ok, &input_state) 262 | { 263 | rewinding_state.load_pos = Some(rewinding_state.pos); 264 | } else if config 265 | .system_keys 266 | .just_pressed(&SystemKey::Cancel, &input_state) 267 | || config.hotkeys.just_pressed(&HotKey::Menu, &input_state) 268 | { 269 | rewinding_state.load_pos = Some(emulator.auto_saved_states.len() - 1); 270 | } 271 | } 272 | 273 | fn exit_rewinding_system( 274 | mut commands: Commands, 275 | bg_color: Query>, 276 | preview: Query>, 277 | thumbnails: Query>, 278 | mut screen_visibility: Query<&mut Visibility, With>, 279 | ) { 280 | for mut visibility in screen_visibility.iter_mut() { 281 | visibility.is_visible = true; 282 | } 283 | 284 | for entity in bg_color 285 | .iter() 286 | .chain(preview.iter()) 287 | .chain(thumbnails.iter()) 288 | { 289 | commands.entity(entity).despawn(); 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /src/file.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Result}; 2 | use chrono::prelude::*; 3 | use log::info; 4 | use std::path::{Path, PathBuf}; 5 | 6 | #[derive(thiserror::Error, Debug)] 7 | pub enum FileSystemError { 8 | #[error("{0}")] 9 | IoError(#[from] std::io::Error), 10 | #[error("{0}")] 11 | PersistError(#[from] tempfile::PersistError), 12 | #[error("File not found")] 13 | FileNotFound, 14 | 15 | #[error("{0}")] 16 | SerdeError(#[from] serde_json::Error), 17 | 18 | #[cfg(target_arch = "wasm32")] 19 | #[error("DOM exception")] 20 | DomException, 21 | } 22 | 23 | #[cfg(not(target_arch = "wasm32"))] 24 | mod filesystem { 25 | use super::FileSystemError; 26 | use chrono::prelude::*; 27 | use std::fs; 28 | use std::path::Path; 29 | 30 | pub fn create_dir_all(dir: impl AsRef) -> Result<(), FileSystemError> { 31 | fs::create_dir_all(dir)?; 32 | Ok(()) 33 | } 34 | 35 | pub async fn exists(path: &Path) -> Result { 36 | Ok(path.is_file()) 37 | } 38 | 39 | pub async fn write( 40 | path: impl AsRef, 41 | data: impl AsRef<[u8]>, 42 | ) -> Result<(), FileSystemError> { 43 | use std::io::Write; 44 | let mut f = tempfile::NamedTempFile::new()?; 45 | f.write_all(data.as_ref())?; 46 | f.persist(path)?; 47 | Ok(()) 48 | } 49 | 50 | pub async fn read(path: impl AsRef) -> Result, FileSystemError> { 51 | let ret = fs::read(path)?; 52 | Ok(ret) 53 | } 54 | 55 | pub async fn modified(path: impl AsRef) -> Result, FileSystemError> { 56 | Ok(fs::metadata(path)?.modified()?.into()) 57 | } 58 | } 59 | 60 | #[cfg(target_arch = "wasm32")] 61 | mod filesystem { 62 | use super::FileSystemError; 63 | use chrono::prelude::*; 64 | use indexed_db_futures::prelude::*; 65 | use js_sys::Uint8Array; 66 | use log::info; 67 | use serde::{Deserialize, Serialize}; 68 | use std::path::Path; 69 | use std::time::SystemTime; 70 | use wasm_bindgen::{prelude::*, JsCast}; 71 | use web_sys::DomException; 72 | 73 | async fn open_db() -> Result { 74 | let mut db_req: OpenDbRequest = IdbDatabase::open_u32("meru", 1)?; 75 | db_req.set_on_upgrade_needed(Some(|evt: &IdbVersionChangeEvent| -> Result<(), JsValue> { 76 | const STORES: &[&str] = &["save", "config", "data"]; 77 | for store in STORES { 78 | if let None = evt.db().object_store_names().find(|n| n == store) { 79 | evt.db().create_object_store(store)?; 80 | } 81 | } 82 | Ok(()) 83 | })); 84 | 85 | let db = db_req.into_future().await?; 86 | Ok(db) 87 | } 88 | 89 | // parse path to (store name, file_name) 90 | fn parse_path(path: &Path) -> (String, String) { 91 | let mut it = path.iter(); 92 | ( 93 | it.next().unwrap().to_str().unwrap().to_string(), 94 | it.as_path().to_str().unwrap().to_string(), 95 | ) 96 | } 97 | 98 | pub fn create_dir_all(_dir: impl AsRef) -> Result<(), FileSystemError> { 99 | Ok(()) 100 | } 101 | 102 | pub async fn exists(path: &Path) -> Result { 103 | let (store_name, file_name) = parse_path(path.as_ref()); 104 | 105 | let db = open_db().await.map_err(|_| FileSystemError::DomException)?; 106 | 107 | let tx: IdbTransaction = db 108 | .transaction_on_one_with_mode(&store_name, IdbTransactionMode::Readonly) 109 | .map_err(|_| FileSystemError::DomException)?; 110 | 111 | let store: IdbObjectStore = tx 112 | .object_store(&store_name) 113 | .map_err(|_| FileSystemError::DomException)?; 114 | 115 | Ok(store 116 | .count_with_key_owned(&file_name) 117 | .map_err(|_| FileSystemError::DomException)? 118 | .await 119 | .map_err(|_| FileSystemError::DomException)? 120 | > 0) 121 | } 122 | 123 | #[derive(Serialize, Deserialize)] 124 | struct Metadata { 125 | modified: SystemTime, 126 | } 127 | 128 | pub async fn write( 129 | path: impl AsRef, 130 | data: impl AsRef<[u8]>, 131 | ) -> Result<(), FileSystemError> { 132 | info!("fs: write: {}", path.as_ref().display()); 133 | 134 | let (store_name, file_name) = parse_path(path.as_ref()); 135 | 136 | let db = open_db().await.map_err(|_| FileSystemError::DomException)?; 137 | 138 | let tx: IdbTransaction = db 139 | .transaction_on_one_with_mode(&store_name, IdbTransactionMode::Readwrite) 140 | .map_err(|_| FileSystemError::DomException)?; 141 | let store: IdbObjectStore = tx 142 | .object_store(&store_name) 143 | .map_err(|_| FileSystemError::DomException)?; 144 | 145 | store 146 | .put_key_val_owned(&file_name, &Uint8Array::from(data.as_ref())) 147 | .map_err(|_| FileSystemError::DomException)?; 148 | 149 | store 150 | .put_key_val_owned( 151 | &format!("{file_name}.metadata"), 152 | &JsValue::from_serde(&Metadata { 153 | modified: Utc::now().into(), 154 | })?, 155 | ) 156 | .map_err(|_| FileSystemError::DomException)?; 157 | 158 | tx.await 159 | .into_result() 160 | .map_err(|_| FileSystemError::DomException)?; 161 | 162 | Ok(()) 163 | } 164 | 165 | pub async fn read(path: impl AsRef) -> Result, FileSystemError> { 166 | info!("fs: read: {}", path.as_ref().display()); 167 | 168 | let (store_name, file_name) = parse_path(path.as_ref()); 169 | 170 | let db = open_db().await.map_err(|_| FileSystemError::DomException)?; 171 | 172 | let tx: IdbTransaction = db 173 | .transaction_on_one_with_mode(&store_name, IdbTransactionMode::Readonly) 174 | .map_err(|_| FileSystemError::DomException)?; 175 | 176 | let store: IdbObjectStore = tx 177 | .object_store(&store_name) 178 | .map_err(|_| FileSystemError::DomException)?; 179 | 180 | let jsvalue = if let Some(jsvalue) = store 181 | .get_owned(file_name.as_str()) 182 | .map_err(|_| FileSystemError::DomException)? 183 | .await 184 | .map_err(|_| FileSystemError::DomException)? 185 | { 186 | jsvalue 187 | } else { 188 | Err(FileSystemError::FileNotFound)? 189 | }; 190 | 191 | let array = jsvalue 192 | .dyn_into::() 193 | .map_err(|_| FileSystemError::DomException)?; 194 | 195 | tx.await 196 | .into_result() 197 | .map_err(|_| FileSystemError::DomException)?; 198 | 199 | Ok(array.to_vec()) 200 | } 201 | 202 | pub async fn modified( 203 | path: impl AsRef, 204 | ) -> anyhow::Result, FileSystemError> { 205 | info!("fs: modified: {}", path.as_ref().display()); 206 | 207 | let (store_name, file_name) = parse_path(path.as_ref()); 208 | 209 | let db = open_db().await.map_err(|_| FileSystemError::DomException)?; 210 | 211 | let tx: IdbTransaction = db 212 | .transaction_on_one_with_mode(&store_name, IdbTransactionMode::Readwrite) 213 | .map_err(|_| FileSystemError::DomException)?; 214 | 215 | let store: IdbObjectStore = tx 216 | .object_store(&store_name) 217 | .map_err(|_| FileSystemError::DomException)?; 218 | 219 | let jsvalue = if let Some(jsvalue) = store 220 | .get_owned(&format!("{file_name}.metadata")) 221 | .map_err(|_| FileSystemError::DomException)? 222 | .await 223 | .map_err(|_| FileSystemError::DomException)? 224 | { 225 | jsvalue 226 | } else { 227 | Err(FileSystemError::FileNotFound)? 228 | }; 229 | 230 | let metadata = jsvalue.into_serde::()?; 231 | 232 | tx.await 233 | .into_result() 234 | .map_err(|_| FileSystemError::DomException)?; 235 | 236 | Ok(metadata.modified.into()) 237 | } 238 | } 239 | 240 | pub use filesystem::*; 241 | 242 | pub async fn read_to_string(path: impl AsRef) -> Result { 243 | info!("fs: read_to_string: {}", path.as_ref().display()); 244 | 245 | let bin = read(path).await?; 246 | let ret = String::from_utf8(bin)?; 247 | Ok(ret) 248 | } 249 | 250 | pub fn get_save_dir(core_abbrev: &str, save_dir: &Path) -> Result { 251 | let dir = save_dir.join(core_abbrev); 252 | 253 | if !dir.exists() { 254 | create_dir_all(&dir)?; 255 | } else if !dir.is_dir() { 256 | bail!("`{}` is not a directory", dir.display()); 257 | } 258 | Ok(dir) 259 | } 260 | 261 | fn get_backup_file_path(core_abbrev: &str, name: &str, save_dir: &Path) -> Result { 262 | Ok(get_save_dir(core_abbrev, save_dir)?.join(format!("{name}.sav"))) 263 | } 264 | 265 | pub fn get_state_file_path( 266 | core_abbrev: &str, 267 | name: &str, 268 | slot: usize, 269 | save_dir: &Path, 270 | ) -> Result { 271 | Ok(get_save_dir(core_abbrev, save_dir)?.join(format!("{name}-{slot}.state"))) 272 | } 273 | 274 | pub async fn load_backup( 275 | core_abbrev: &str, 276 | name: &str, 277 | save_dir: &Path, 278 | ) -> Result>> { 279 | let path = get_backup_file_path(core_abbrev, name, save_dir)?; 280 | 281 | Ok(if exists(&path).await? { 282 | info!("Loading backup RAM: `{}`", path.display()); 283 | Some(read(path).await?) 284 | } else { 285 | info!("Backup RAM not found: `{}`", path.display()); 286 | None 287 | }) 288 | } 289 | 290 | pub async fn save_backup(core_abbrev: &str, name: &str, ram: &[u8], save_dir: &Path) -> Result<()> { 291 | let path = get_backup_file_path(core_abbrev, name, save_dir)?; 292 | 293 | if !exists(&path).await? { 294 | info!("Creating backup RAM file: `{}`", path.display()); 295 | } else { 296 | info!("Overwriting backup RAM file: `{}`", path.display()); 297 | } 298 | write(&path, ram).await?; 299 | Ok(()) 300 | } 301 | 302 | pub async fn save_state( 303 | core_abbrev: &str, 304 | name: &str, 305 | slot: usize, 306 | data: &[u8], 307 | save_dir: &Path, 308 | ) -> Result<()> { 309 | write( 310 | &get_state_file_path(core_abbrev, name, slot, save_dir)?, 311 | data, 312 | ) 313 | .await?; 314 | Ok(()) 315 | } 316 | 317 | pub async fn load_state( 318 | core_abbrev: &str, 319 | name: &str, 320 | slot: usize, 321 | save_dir: &Path, 322 | ) -> Result> { 323 | let ret = read(get_state_file_path(core_abbrev, name, slot, save_dir)?).await?; 324 | Ok(ret) 325 | } 326 | 327 | pub async fn state_date( 328 | core_abbrev: &str, 329 | name: &str, 330 | slot: usize, 331 | save_dir: &Path, 332 | ) -> Result>> { 333 | let path = get_state_file_path(core_abbrev, name, slot, save_dir)?; 334 | if let Ok(date) = modified(&path).await { 335 | Ok(Some(date)) 336 | } else { 337 | Ok(None) 338 | } 339 | } 340 | -------------------------------------------------------------------------------- /src/input.rs: -------------------------------------------------------------------------------- 1 | use bevy::prelude::*; 2 | use meru_interface::KeyAssign; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | pub struct InputState<'a> { 6 | keycode: &'a Input, 7 | gamepad_button: &'a Input, 8 | gamepad_axis: &'a Axis, 9 | } 10 | 11 | impl<'a> InputState<'a> { 12 | pub fn new( 13 | input_keycode: &'a Input, 14 | input_gamepad_button: &'a Input, 15 | input_gamepad_axis: &'a Axis, 16 | ) -> Self { 17 | Self { 18 | keycode: input_keycode, 19 | gamepad_button: input_gamepad_button, 20 | gamepad_axis: input_gamepad_axis, 21 | } 22 | } 23 | } 24 | 25 | impl<'a> meru_interface::InputState for InputState<'a> { 26 | fn pressed(&self, key: &meru_interface::SingleKey) -> bool { 27 | use meru_interface::SingleKey; 28 | match key { 29 | SingleKey::KeyCode(key_code) => self.keycode.pressed(ConvertInput(*key_code).into()), 30 | SingleKey::GamepadButton(button) => { 31 | self.gamepad_button.pressed(ConvertInput(*button).into()) 32 | } 33 | SingleKey::GamepadAxis(axis, dir) => { 34 | let value = self 35 | .gamepad_axis 36 | .get(ConvertInput(*axis).into()) 37 | .unwrap_or(0.0); 38 | match dir { 39 | meru_interface::key_assign::GamepadAxisDir::Pos => { 40 | value > bevy::input::Axis::::MAX / 2.0 41 | } 42 | meru_interface::key_assign::GamepadAxisDir::Neg => { 43 | value < bevy::input::Axis::::MIN / 2.0 44 | } 45 | } 46 | } 47 | } 48 | } 49 | 50 | fn just_pressed(&self, key: &meru_interface::key_assign::SingleKey) -> bool { 51 | use meru_interface::SingleKey; 52 | match key { 53 | SingleKey::KeyCode(key_code) => { 54 | self.keycode.just_pressed(ConvertInput(*key_code).into()) 55 | } 56 | SingleKey::GamepadButton(button) => self 57 | .gamepad_button 58 | .just_pressed(ConvertInput(*button).into()), 59 | SingleKey::GamepadAxis(_, _) => todo!(), 60 | } 61 | } 62 | } 63 | 64 | macro_rules! map_macro { 65 | ($macro_name:ident, $($key:ident),* $(,)?) => { 66 | macro_rules! $macro_name { 67 | ($key_code:expr, $from:ty, $to:ty) => { 68 | match $key_code { 69 | $( 70 | <$from>::$key => <$to>::$key, 71 | )* 72 | } 73 | } 74 | } 75 | } 76 | } 77 | 78 | map_macro! { 79 | map_code, 80 | Key1, 81 | Key2, 82 | Key3, 83 | Key4, 84 | Key5, 85 | Key6, 86 | Key7, 87 | Key8, 88 | Key9, 89 | Key0, 90 | A, 91 | B, 92 | C, 93 | D, 94 | E, 95 | F, 96 | G, 97 | H, 98 | I, 99 | J, 100 | K, 101 | L, 102 | M, 103 | N, 104 | O, 105 | P, 106 | Q, 107 | R, 108 | S, 109 | T, 110 | U, 111 | V, 112 | W, 113 | X, 114 | Y, 115 | Z, 116 | Escape, 117 | F1, 118 | F2, 119 | F3, 120 | F4, 121 | F5, 122 | F6, 123 | F7, 124 | F8, 125 | F9, 126 | F10, 127 | F11, 128 | F12, 129 | F13, 130 | F14, 131 | F15, 132 | F16, 133 | F17, 134 | F18, 135 | F19, 136 | F20, 137 | F21, 138 | F22, 139 | F23, 140 | F24, 141 | Snapshot, 142 | Scroll, 143 | Pause, 144 | Insert, 145 | Home, 146 | Delete, 147 | End, 148 | PageDown, 149 | PageUp, 150 | Left, 151 | Up, 152 | Right, 153 | Down, 154 | Back, 155 | Return, 156 | Space, 157 | Compose, 158 | Caret, 159 | Numlock, 160 | Numpad0, 161 | Numpad1, 162 | Numpad2, 163 | Numpad3, 164 | Numpad4, 165 | Numpad5, 166 | Numpad6, 167 | Numpad7, 168 | Numpad8, 169 | Numpad9, 170 | AbntC1, 171 | AbntC2, 172 | NumpadAdd, 173 | Apostrophe, 174 | Apps, 175 | Asterisk, 176 | Plus, 177 | At, 178 | Ax, 179 | Backslash, 180 | Calculator, 181 | Capital, 182 | Colon, 183 | Comma, 184 | Convert, 185 | NumpadDecimal, 186 | NumpadDivide, 187 | Equals, 188 | Grave, 189 | Kana, 190 | Kanji, 191 | LAlt, 192 | LBracket, 193 | LControl, 194 | LShift, 195 | LWin, 196 | Mail, 197 | MediaSelect, 198 | MediaStop, 199 | Minus, 200 | NumpadMultiply, 201 | Mute, 202 | MyComputer, 203 | NavigateForward, 204 | NavigateBackward, 205 | NextTrack, 206 | NoConvert, 207 | NumpadComma, 208 | NumpadEnter, 209 | NumpadEquals, 210 | Oem102, 211 | Period, 212 | PlayPause, 213 | Power, 214 | PrevTrack, 215 | RAlt, 216 | RBracket, 217 | RControl, 218 | RShift, 219 | RWin, 220 | Semicolon, 221 | Slash, 222 | Sleep, 223 | Stop, 224 | NumpadSubtract, 225 | Sysrq, 226 | Tab, 227 | Underline, 228 | Unlabeled, 229 | VolumeDown, 230 | VolumeUp, 231 | Wake, 232 | WebBack, 233 | WebFavorites, 234 | WebForward, 235 | WebHome, 236 | WebRefresh, 237 | WebSearch, 238 | WebStop, 239 | Yen, 240 | Copy, 241 | Paste, 242 | Cut, 243 | } 244 | 245 | map_macro! { 246 | map_gamepad_button_type, 247 | South, 248 | East, 249 | North, 250 | West, 251 | C, 252 | Z, 253 | LeftTrigger, 254 | LeftTrigger2, 255 | RightTrigger, 256 | RightTrigger2, 257 | Select, 258 | Start, 259 | Mode, 260 | LeftThumb, 261 | RightThumb, 262 | DPadUp, 263 | DPadDown, 264 | DPadLeft, 265 | DPadRight, 266 | } 267 | 268 | map_macro! { 269 | map_gamepad_axis_type, 270 | LeftStickX, 271 | LeftStickY, 272 | LeftZ, 273 | RightStickX, 274 | RightStickY, 275 | RightZ, 276 | } 277 | 278 | pub struct ConvertInput(pub T); 279 | 280 | impl From> for bevy::prelude::KeyCode { 281 | fn from(key_code: ConvertInput) -> Self { 282 | map_code!(key_code.0, meru_interface::KeyCode, bevy::prelude::KeyCode) 283 | } 284 | } 285 | 286 | impl From> for meru_interface::KeyCode { 287 | fn from(key_code: ConvertInput) -> Self { 288 | map_code!(key_code.0, bevy::prelude::KeyCode, meru_interface::KeyCode) 289 | } 290 | } 291 | 292 | impl From> for bevy::prelude::GamepadButton { 293 | fn from(button: ConvertInput) -> Self { 294 | bevy::prelude::GamepadButton::new( 295 | bevy::prelude::Gamepad::new(button.0.gamepad.id), 296 | ConvertInput(button.0.button_type).into(), 297 | ) 298 | } 299 | } 300 | 301 | impl From> for meru_interface::GamepadButton { 302 | fn from(button: ConvertInput) -> Self { 303 | meru_interface::GamepadButton::new( 304 | meru_interface::Gamepad::new(button.0.gamepad.id), 305 | ConvertInput(button.0.button_type).into(), 306 | ) 307 | } 308 | } 309 | 310 | impl From> for bevy::prelude::GamepadButtonType { 311 | fn from(button_type: ConvertInput) -> Self { 312 | map_gamepad_button_type!( 313 | button_type.0, 314 | meru_interface::GamepadButtonType, 315 | bevy::prelude::GamepadButtonType 316 | ) 317 | } 318 | } 319 | 320 | impl From> for meru_interface::GamepadButtonType { 321 | fn from(button_type: ConvertInput) -> Self { 322 | map_gamepad_button_type!( 323 | button_type.0, 324 | bevy::prelude::GamepadButtonType, 325 | meru_interface::GamepadButtonType 326 | ) 327 | } 328 | } 329 | 330 | impl From> for bevy::prelude::GamepadAxis { 331 | fn from(axis: ConvertInput) -> Self { 332 | bevy::prelude::GamepadAxis::new( 333 | bevy::prelude::Gamepad::new(axis.0.gamepad.id), 334 | ConvertInput(axis.0.axis_type).into(), 335 | ) 336 | } 337 | } 338 | 339 | impl From> for meru_interface::GamepadAxis { 340 | fn from(axis: ConvertInput) -> Self { 341 | meru_interface::GamepadAxis::new( 342 | meru_interface::Gamepad::new(axis.0.gamepad.id), 343 | ConvertInput(axis.0.axis_type).into(), 344 | ) 345 | } 346 | } 347 | 348 | impl From> for bevy::prelude::GamepadAxisType { 349 | fn from(axis_type: ConvertInput) -> Self { 350 | map_gamepad_axis_type!( 351 | axis_type.0, 352 | meru_interface::GamepadAxisType, 353 | bevy::prelude::GamepadAxisType 354 | ) 355 | } 356 | } 357 | 358 | impl From> for meru_interface::GamepadAxisType { 359 | fn from(axis_type: ConvertInput) -> Self { 360 | map_gamepad_axis_type!( 361 | axis_type.0, 362 | bevy::prelude::GamepadAxisType, 363 | meru_interface::GamepadAxisType 364 | ) 365 | } 366 | } 367 | 368 | #[derive(PartialEq, Eq, Clone, Serialize, Deserialize)] 369 | pub struct KeyConfig(pub Vec<(Key, KeyAssign)>); 370 | 371 | impl KeyConfig { 372 | pub fn key_assign(&self, key: &Key) -> Option<&KeyAssign> { 373 | self.0.iter().find(|(h, _)| h == key).map(|(_, k)| k) 374 | } 375 | 376 | pub fn key_assign_mut(&mut self, key: &Key) -> Option<&mut KeyAssign> { 377 | self.0.iter_mut().find(|(h, _)| h == key).map(|(_, k)| k) 378 | } 379 | 380 | pub fn insert_keycode(&mut self, key: &Key, key_code: meru_interface::KeyCode) { 381 | if let Some(key_assign) = self.key_assign_mut(key) { 382 | key_assign.insert_keycode(key_code); 383 | } else { 384 | use meru_interface::key_assign::*; 385 | self.0 386 | .push((key.clone(), SingleKey::KeyCode(key_code).into())); 387 | } 388 | } 389 | 390 | pub fn insert_gamepad(&mut self, key: &Key, button: meru_interface::GamepadButton) { 391 | if let Some(key_assign) = self.key_assign_mut(key) { 392 | key_assign.insert_gamepad(button); 393 | } else { 394 | use meru_interface::key_assign::*; 395 | self.0 396 | .push((key.clone(), SingleKey::GamepadButton(button).into())); 397 | } 398 | } 399 | 400 | pub fn just_pressed(&self, key: &Key, input_state: &InputState<'_>) -> bool { 401 | self.0 402 | .iter() 403 | .find(|r| &r.0 == key) 404 | .map_or(false, |r| r.1.just_pressed(input_state)) 405 | } 406 | 407 | pub fn pressed(&self, key: &Key, input_state: &InputState<'_>) -> bool { 408 | self.0 409 | .iter() 410 | .find(|r| &r.0 == key) 411 | .map_or(false, |r| r.1.pressed(input_state)) 412 | } 413 | } 414 | -------------------------------------------------------------------------------- /meru-interface/src/key_assign.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::fmt::Display; 3 | 4 | #[derive(PartialEq, Eq, Copy, Clone, Debug, Serialize, Deserialize)] 5 | pub enum KeyCode { 6 | Key1, 7 | Key2, 8 | Key3, 9 | Key4, 10 | Key5, 11 | Key6, 12 | Key7, 13 | Key8, 14 | Key9, 15 | Key0, 16 | A, 17 | B, 18 | C, 19 | D, 20 | E, 21 | F, 22 | G, 23 | H, 24 | I, 25 | J, 26 | K, 27 | L, 28 | M, 29 | N, 30 | O, 31 | P, 32 | Q, 33 | R, 34 | S, 35 | T, 36 | U, 37 | V, 38 | W, 39 | X, 40 | Y, 41 | Z, 42 | Escape, 43 | F1, 44 | F2, 45 | F3, 46 | F4, 47 | F5, 48 | F6, 49 | F7, 50 | F8, 51 | F9, 52 | F10, 53 | F11, 54 | F12, 55 | F13, 56 | F14, 57 | F15, 58 | F16, 59 | F17, 60 | F18, 61 | F19, 62 | F20, 63 | F21, 64 | F22, 65 | F23, 66 | F24, 67 | Snapshot, 68 | Scroll, 69 | Pause, 70 | Insert, 71 | Home, 72 | Delete, 73 | End, 74 | PageDown, 75 | PageUp, 76 | Left, 77 | Up, 78 | Right, 79 | Down, 80 | Back, 81 | Return, 82 | Space, 83 | Compose, 84 | Caret, 85 | Numlock, 86 | Numpad0, 87 | Numpad1, 88 | Numpad2, 89 | Numpad3, 90 | Numpad4, 91 | Numpad5, 92 | Numpad6, 93 | Numpad7, 94 | Numpad8, 95 | Numpad9, 96 | AbntC1, 97 | AbntC2, 98 | NumpadAdd, 99 | Apostrophe, 100 | Apps, 101 | Asterisk, 102 | Plus, 103 | At, 104 | Ax, 105 | Backslash, 106 | Calculator, 107 | Capital, 108 | Colon, 109 | Comma, 110 | Convert, 111 | NumpadDecimal, 112 | NumpadDivide, 113 | Equals, 114 | Grave, 115 | Kana, 116 | Kanji, 117 | LAlt, 118 | LBracket, 119 | LControl, 120 | LShift, 121 | LWin, 122 | Mail, 123 | MediaSelect, 124 | MediaStop, 125 | Minus, 126 | NumpadMultiply, 127 | Mute, 128 | MyComputer, 129 | NavigateForward, 130 | NavigateBackward, 131 | NextTrack, 132 | NoConvert, 133 | NumpadComma, 134 | NumpadEnter, 135 | NumpadEquals, 136 | Oem102, 137 | Period, 138 | PlayPause, 139 | Power, 140 | PrevTrack, 141 | RAlt, 142 | RBracket, 143 | RControl, 144 | RShift, 145 | RWin, 146 | Semicolon, 147 | Slash, 148 | Sleep, 149 | Stop, 150 | NumpadSubtract, 151 | Sysrq, 152 | Tab, 153 | Underline, 154 | Unlabeled, 155 | VolumeDown, 156 | VolumeUp, 157 | Wake, 158 | WebBack, 159 | WebFavorites, 160 | WebForward, 161 | WebHome, 162 | WebRefresh, 163 | WebSearch, 164 | WebStop, 165 | Yen, 166 | Copy, 167 | Paste, 168 | Cut, 169 | } 170 | 171 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] 172 | pub struct GamepadButton { 173 | pub gamepad: Gamepad, 174 | pub button_type: GamepadButtonType, 175 | } 176 | 177 | impl GamepadButton { 178 | pub fn new(gamepad: Gamepad, button_type: GamepadButtonType) -> Self { 179 | Self { 180 | gamepad, 181 | button_type, 182 | } 183 | } 184 | } 185 | 186 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] 187 | pub struct Gamepad { 188 | pub id: usize, 189 | } 190 | 191 | impl Gamepad { 192 | pub fn new(id: usize) -> Self { 193 | Self { id } 194 | } 195 | } 196 | 197 | #[derive(PartialEq, Eq, Copy, Clone, Debug, Serialize, Deserialize)] 198 | pub enum GamepadButtonType { 199 | South, 200 | East, 201 | North, 202 | West, 203 | C, 204 | Z, 205 | LeftTrigger, 206 | LeftTrigger2, 207 | RightTrigger, 208 | RightTrigger2, 209 | Select, 210 | Start, 211 | Mode, 212 | LeftThumb, 213 | RightThumb, 214 | DPadUp, 215 | DPadDown, 216 | DPadLeft, 217 | DPadRight, 218 | } 219 | 220 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] 221 | pub struct GamepadAxis { 222 | pub gamepad: Gamepad, 223 | pub axis_type: GamepadAxisType, 224 | } 225 | 226 | impl GamepadAxis { 227 | pub fn new(gamepad: Gamepad, axis_type: GamepadAxisType) -> Self { 228 | Self { gamepad, axis_type } 229 | } 230 | } 231 | 232 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] 233 | pub enum GamepadAxisType { 234 | LeftStickX, 235 | LeftStickY, 236 | LeftZ, 237 | RightStickX, 238 | RightStickY, 239 | RightZ, 240 | } 241 | 242 | #[derive(PartialEq, Eq, Default, Clone, Debug, Serialize, Deserialize)] 243 | pub struct KeyAssign(pub Vec); 244 | 245 | impl From for KeyAssign { 246 | fn from(key: SingleKey) -> Self { 247 | KeyAssign(vec![MultiKey(vec![key])]) 248 | } 249 | } 250 | 251 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 252 | pub struct MultiKey(pub Vec); 253 | 254 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 255 | pub enum SingleKey { 256 | KeyCode(KeyCode), 257 | GamepadButton(GamepadButton), 258 | GamepadAxis(GamepadAxis, GamepadAxisDir), 259 | } 260 | 261 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] 262 | pub enum GamepadAxisDir { 263 | Pos, 264 | Neg, 265 | } 266 | 267 | impl Display for KeyCode { 268 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 269 | write!(f, "{:?}", self) 270 | } 271 | } 272 | 273 | impl Display for GamepadButton { 274 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 275 | write!(f, "Pad{}.{}", self.gamepad.id, self.button_type) 276 | } 277 | } 278 | 279 | impl Display for GamepadButtonType { 280 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 281 | use GamepadButtonType::*; 282 | let s = match self { 283 | South => "S", 284 | East => "E", 285 | North => "N", 286 | West => "W", 287 | C => "C", 288 | Z => "Z", 289 | LeftTrigger => "LB", 290 | LeftTrigger2 => "LT", 291 | RightTrigger => "RB", 292 | RightTrigger2 => "RT", 293 | Select => "Select", 294 | Start => "Start", 295 | Mode => "Mode", 296 | LeftThumb => "LS", 297 | RightThumb => "RS", 298 | DPadUp => "DPadUp", 299 | DPadDown => "DPadDown", 300 | DPadLeft => "DPadLeft", 301 | DPadRight => "DPadRight", 302 | }; 303 | write!(f, "{s}") 304 | } 305 | } 306 | 307 | impl Display for GamepadAxis { 308 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 309 | write!(f, "Pad{}.{}", self.gamepad.id, self.axis_type) 310 | } 311 | } 312 | 313 | impl Display for GamepadAxisDir { 314 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 315 | let s = match self { 316 | GamepadAxisDir::Pos => "+", 317 | GamepadAxisDir::Neg => "-", 318 | }; 319 | write!(f, "{s}") 320 | } 321 | } 322 | 323 | impl Display for GamepadAxisType { 324 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 325 | use GamepadAxisType::*; 326 | let s = match self { 327 | LeftStickX => "LX", 328 | LeftStickY => "LY", 329 | LeftZ => "LZ", 330 | RightStickX => "RX", 331 | RightStickY => "RY", 332 | RightZ => "RZ", 333 | }; 334 | write!(f, "{s}") 335 | } 336 | } 337 | 338 | impl Display for MultiKey { 339 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 340 | let mut first = true; 341 | for single_key in &self.0 { 342 | if !first { 343 | write!(f, "+")?; 344 | } 345 | write!(f, "{}", single_key)?; 346 | first = false; 347 | } 348 | Ok(()) 349 | } 350 | } 351 | 352 | impl Display for SingleKey { 353 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 354 | match self { 355 | SingleKey::KeyCode(kc) => write!(f, "{kc}"), 356 | SingleKey::GamepadButton(button) => write!(f, "{button}"), 357 | SingleKey::GamepadAxis(axis, dir) => write!(f, "{axis}{dir}"), 358 | } 359 | } 360 | } 361 | 362 | pub trait InputState { 363 | fn pressed(&self, key: &SingleKey) -> bool; 364 | fn just_pressed(&self, key: &SingleKey) -> bool; 365 | } 366 | 367 | impl KeyAssign { 368 | pub fn and(self, rhs: Self) -> Self { 369 | let mut ret = vec![]; 370 | for l in self.0.into_iter() { 371 | for r in rhs.0.iter() { 372 | let mut t = l.0.clone(); 373 | t.append(&mut r.0.clone()); 374 | ret.push(MultiKey(t)); 375 | } 376 | } 377 | Self(ret) 378 | } 379 | 380 | pub fn or(mut self, mut rhs: Self) -> Self { 381 | self.0.append(&mut rhs.0); 382 | self 383 | } 384 | pub fn pressed(&self, input_state: &impl InputState) -> bool { 385 | self.0 386 | .iter() 387 | .any(|multi_key| multi_key.pressed(input_state)) 388 | } 389 | 390 | pub fn just_pressed(&self, input_state: &impl InputState) -> bool { 391 | self.0 392 | .iter() 393 | .any(|multi_key| multi_key.just_pressed(input_state)) 394 | } 395 | 396 | pub fn extract_keycode(&self) -> Option { 397 | for MultiKey(mk) in &self.0 { 398 | if let [SingleKey::KeyCode(r)] = &mk[..] { 399 | return Some(*r); 400 | } 401 | } 402 | None 403 | } 404 | 405 | pub fn insert_keycode(&mut self, kc: KeyCode) { 406 | for MultiKey(mk) in self.0.iter_mut() { 407 | if let [SingleKey::KeyCode(r)] = &mut mk[..] { 408 | *r = kc; 409 | return; 410 | } 411 | } 412 | self.0.push(MultiKey(vec![SingleKey::KeyCode(kc)])); 413 | } 414 | 415 | pub fn extract_gamepad(&self) -> Option { 416 | for MultiKey(mk) in &self.0 { 417 | if let [SingleKey::GamepadButton(r)] = &mk[..] { 418 | return Some(*r); 419 | } 420 | } 421 | None 422 | } 423 | 424 | pub fn insert_gamepad(&mut self, button: GamepadButton) { 425 | for MultiKey(mk) in self.0.iter_mut() { 426 | if let [SingleKey::GamepadButton(r)] = &mut mk[..] { 427 | *r = button; 428 | return; 429 | } 430 | } 431 | self.0 432 | .push(MultiKey(vec![SingleKey::GamepadButton(button)])); 433 | } 434 | } 435 | 436 | impl MultiKey { 437 | fn pressed(&self, input_state: &impl InputState) -> bool { 438 | self.0 439 | .iter() 440 | .all(|single_key| input_state.pressed(single_key)) 441 | } 442 | 443 | fn just_pressed(&self, input_state: &impl InputState) -> bool { 444 | // all key are pressed and some key is just pressed 445 | self.pressed(input_state) 446 | && self 447 | .0 448 | .iter() 449 | .any(|single_key| input_state.just_pressed(single_key)) 450 | } 451 | } 452 | 453 | #[macro_export] 454 | macro_rules! any { 455 | ($x:expr, $($xs:expr),* $(,)?) => { 456 | [$($xs),*].into_iter().fold($x, |a, b| a.or(b)) 457 | }; 458 | } 459 | pub use any; 460 | 461 | #[macro_export] 462 | macro_rules! all { 463 | ($x:expr, $($xs:expr),* $(,)?) => {{ 464 | [$($xs),*].into_iter().fold($x, |a, b| a.and(b)) 465 | }}; 466 | } 467 | pub use all; 468 | 469 | #[macro_export] 470 | macro_rules! keycode { 471 | ($code:ident) => { 472 | KeyAssign(vec![MultiKey(vec![ 473 | $crate::key_assign::SingleKey::KeyCode($crate::key_assign::KeyCode::$code), 474 | ])]) 475 | }; 476 | } 477 | pub use keycode; 478 | 479 | #[macro_export] 480 | macro_rules! pad_button { 481 | ($id:literal, $button:ident) => { 482 | $crate::key_assign::KeyAssign(vec![$crate::key_assign::MultiKey(vec![ 483 | $crate::key_assign::SingleKey::GamepadButton($crate::key_assign::GamepadButton::new( 484 | $crate::key_assign::Gamepad::new($id), 485 | $crate::key_assign::GamepadButtonType::$button, 486 | )), 487 | ])]) 488 | }; 489 | } 490 | pub use pad_button; 491 | -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use bevy::{ 2 | diagnostic::{Diagnostics, FrameTimeDiagnosticsPlugin}, 3 | input::{mouse::MouseButtonInput, ButtonState}, 4 | prelude::*, 5 | render::texture::{ImageSampler, ImageSettings}, 6 | window::{PresentMode, WindowMode}, 7 | }; 8 | use bevy_easings::EasingsPlugin; 9 | use bevy_egui::{EguiContext, EguiPlugin}; 10 | use bevy_tiled_camera::TiledCameraPlugin; 11 | use log::error; 12 | 13 | use crate::{ 14 | config::{self, load_config, load_persistent_state}, 15 | core::{self, Emulator, GameScreen}, 16 | hotkey, menu, 17 | rewinding::{self}, 18 | }; 19 | 20 | pub async fn main() { 21 | let window_desc = WindowDescriptor { 22 | title: "MERU".to_string(), 23 | resizable: false, 24 | present_mode: PresentMode::AutoVsync, 25 | width: menu::MENU_WIDTH as f32, 26 | height: menu::MENU_HEIGHT as f32, 27 | #[cfg(target_arch = "wasm32")] 28 | canvas: { 29 | let url = url::Url::parse( 30 | &web_sys::window() 31 | .unwrap() 32 | .document() 33 | .unwrap() 34 | .url() 35 | .unwrap(), 36 | ) 37 | .unwrap(); 38 | if url.port() == Some(1334) { 39 | // on wasm-server-runner 40 | None 41 | } else { 42 | Some("#meru-canvas".to_string()) 43 | } 44 | }, 45 | ..Default::default() 46 | }; 47 | 48 | let mut app = App::new(); 49 | app.insert_resource(window_desc) 50 | .insert_resource(ClearColor(Color::rgb(0.0, 0.0, 0.0))) 51 | .init_resource::() 52 | .init_resource::() 53 | .insert_resource(Msaa { samples: 4 }) 54 | .insert_resource(bevy::log::LogSettings { 55 | level: bevy::utils::tracing::Level::WARN, 56 | filter: "".to_string(), 57 | }) 58 | .insert_resource(ImageSettings { 59 | default_sampler: ImageSampler::nearest_descriptor(), 60 | }) 61 | .add_plugins(DefaultPlugins) 62 | .add_plugin(FrameTimeDiagnosticsPlugin) 63 | .add_plugin(TiledCameraPlugin) 64 | .add_plugin(EasingsPlugin) 65 | .add_plugin(EguiPlugin) 66 | .add_plugin(hotkey::HotKeyPlugin) 67 | .add_plugin(menu::MenuPlugin) 68 | .add_plugin(core::EmulatorPlugin) 69 | .add_plugin(rewinding::RewindingPlugin) 70 | .add_plugin(FpsPlugin) 71 | .add_plugin(MessagePlugin) 72 | .add_event::() 73 | .add_system(window_control_event) 74 | .insert_resource(LastClicked(0.0)) 75 | .add_system(process_double_click) 76 | .add_startup_system(setup) 77 | .add_startup_stage("single-startup", SystemStage::single_threaded()) 78 | .add_startup_system_to_stage("single-startup", set_window_icon) 79 | .add_state(AppState::Menu); 80 | 81 | #[cfg(target_arch = "wasm32")] 82 | app.add_system(resize_canvas); 83 | 84 | let fut = async move { 85 | let config = match load_config().await { 86 | Ok(config) => config, 87 | Err(err) => { 88 | error!("Load config failed: {err}"); 89 | config::Config::default() 90 | } 91 | }; 92 | 93 | app.insert_resource(config); 94 | app.insert_resource(load_persistent_state().await?); 95 | 96 | app.run(); 97 | Ok::<(), anyhow::Error>(()) 98 | }; 99 | 100 | fut.await.unwrap(); 101 | } 102 | 103 | #[derive(Component)] 104 | struct PixelFont; 105 | 106 | fn setup( 107 | mut commands: Commands, 108 | mut fonts: ResMut>, 109 | mut egui_ctx: ResMut, 110 | ) { 111 | use bevy_tiled_camera::*; 112 | commands.spawn_bundle(TiledCameraBundle::pixel_cam([320, 240]).with_pixels_per_tile([1, 1])); 113 | 114 | let ctx = egui_ctx.ctx_mut(); 115 | 116 | let mut style = (*ctx.style()).clone(); 117 | 118 | for style in style.text_styles.iter_mut() { 119 | style.1.size *= 2.0; 120 | } 121 | 122 | ctx.set_style(style); 123 | 124 | let pixel_font = 125 | Font::try_from_bytes(include_bytes!("../assets/fonts/x12y16pxMaruMonica.ttf").to_vec()) 126 | .unwrap(); 127 | 128 | commands 129 | .spawn() 130 | .insert(fonts.add(pixel_font)) 131 | .insert(PixelFont); 132 | } 133 | 134 | #[cfg(target_os = "windows")] 135 | fn set_window_icon(windows: NonSend) { 136 | use winit::window::Icon; 137 | 138 | const ICON_DATA: &[u8] = include_bytes!("../assets/meru.ico"); 139 | const ICON_WIDTH: u32 = 64; 140 | const ICON_HEIGHT: u32 = 64; 141 | 142 | let primary = windows 143 | .get_window(bevy::window::WindowId::primary()) 144 | .unwrap(); 145 | 146 | let icon_rgba = image::load_from_memory_with_format(ICON_DATA, image::ImageFormat::Ico) 147 | .unwrap() 148 | .resize( 149 | ICON_WIDTH, 150 | ICON_HEIGHT, 151 | image::imageops::FilterType::Lanczos3, 152 | ) 153 | .into_rgba8() 154 | .into_raw(); 155 | 156 | let icon = Icon::from_rgba(icon_rgba, ICON_WIDTH, ICON_HEIGHT).unwrap(); 157 | primary.set_window_icon(Some(icon)); 158 | } 159 | 160 | #[cfg(not(target_os = "windows"))] 161 | fn set_window_icon() {} 162 | 163 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 164 | pub enum AppState { 165 | Menu, 166 | Running, 167 | Rewinding, 168 | } 169 | 170 | #[derive(Default)] 171 | pub struct UiState { 172 | pub state_save_slot: usize, 173 | } 174 | 175 | #[derive(Component)] 176 | pub struct ScreenSprite; 177 | 178 | #[derive(Default)] 179 | pub struct FullscreenState(pub bool); 180 | 181 | pub enum WindowControlEvent { 182 | ToggleFullscreen, 183 | ChangeScale(usize), 184 | Restore, 185 | } 186 | 187 | fn window_control_event( 188 | mut windows: ResMut, 189 | mut event: EventReader, 190 | mut fullscreen_state: ResMut, 191 | mut config: ResMut, 192 | app_state: Res>, 193 | emulator: Option>, 194 | ) { 195 | let running = app_state.current() == &AppState::Running; 196 | 197 | for event in event.iter() { 198 | match event { 199 | WindowControlEvent::ToggleFullscreen => { 200 | let window = windows.get_primary_mut().unwrap(); 201 | fullscreen_state.0 = !fullscreen_state.0; 202 | 203 | if fullscreen_state.0 { 204 | window.set_mode(WindowMode::BorderlessFullscreen); 205 | } else { 206 | window.set_mode(WindowMode::Windowed); 207 | } 208 | 209 | if let Some(emulator) = emulator.as_deref() { 210 | let window = windows.get_primary_mut().unwrap(); 211 | restore_window( 212 | emulator, 213 | app_state.current(), 214 | window, 215 | fullscreen_state.0, 216 | config.scaling, 217 | ); 218 | } 219 | } 220 | WindowControlEvent::ChangeScale(scale) => { 221 | config.scaling = *scale; 222 | if running { 223 | let window = windows.get_primary_mut().unwrap(); 224 | restore_window( 225 | emulator.as_deref().unwrap(), 226 | app_state.current(), 227 | window, 228 | fullscreen_state.0, 229 | config.scaling, 230 | ); 231 | } 232 | } 233 | WindowControlEvent::Restore => { 234 | let window = windows.get_primary_mut().unwrap(); 235 | restore_window( 236 | emulator.as_deref().unwrap(), 237 | app_state.current(), 238 | window, 239 | fullscreen_state.0, 240 | config.scaling, 241 | ); 242 | } 243 | } 244 | } 245 | } 246 | 247 | #[cfg(target_arch = "wasm32")] 248 | fn resize_canvas(mut windows: ResMut) { 249 | use wasm_bindgen::JsCast; 250 | 251 | let window = windows.get_primary_mut().unwrap(); 252 | 253 | let canvas = if let Some(canvas) = window.canvas() { 254 | canvas 255 | } else { 256 | return; 257 | }; 258 | 259 | let canvas = web_sys::window() 260 | .unwrap() 261 | .document() 262 | .unwrap() 263 | .query_selector(canvas) 264 | .unwrap() 265 | .unwrap() 266 | .dyn_into::() 267 | .unwrap(); 268 | 269 | let width = canvas.offset_width() as f32; 270 | let height = canvas.offset_height() as f32; 271 | 272 | if (window.width(), window.height()) != (width, height) { 273 | window.set_resolution(width, height); 274 | } 275 | } 276 | 277 | struct LastClicked(f64); 278 | 279 | fn process_double_click( 280 | time: Res