├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md ├── assets ├── back.png ├── cali.ogg ├── cali_hit.ogg ├── click.ogg ├── delete.png ├── download.png ├── drag.ogg ├── edit.png ├── ending.mp3 ├── error.png ├── flick.ogg ├── info.png ├── leaderboard.png ├── ok.png ├── player.jpg ├── proceed.png ├── question.png ├── rank │ ├── A.png │ ├── B.png │ ├── C.png │ ├── F.png │ ├── FC.png │ ├── S.png │ ├── V.png │ ├── blue.png │ ├── golden.png │ ├── green.png │ ├── phi.png │ ├── rainbow.png │ ├── red.png │ └── white.png ├── respack │ ├── click.png │ ├── click_mh.png │ ├── drag.png │ ├── drag_mh.png │ ├── flick.png │ ├── flick_mh.png │ ├── hit_fx.png │ ├── hold.png │ ├── hold_mh.png │ └── info.yml ├── resume.png ├── retry.png ├── tool.png └── warn.png ├── build_wasm.sh ├── project.yml ├── prpr-client-main ├── Cargo.toml └── src │ └── main.rs ├── prpr-client ├── Cargo.toml ├── locales │ ├── en-US │ │ ├── about.ftl │ │ ├── account.ftl │ │ ├── chart_order.ftl │ │ ├── local.ftl │ │ ├── main_scene.ftl │ │ ├── message.ftl │ │ ├── online.ftl │ │ ├── settings.ftl │ │ └── song.ftl │ └── zh-CN │ │ ├── about.ftl │ │ ├── account.ftl │ │ ├── chart_order.ftl │ │ ├── local.ftl │ │ ├── main_scene.ftl │ │ ├── message.ftl │ │ ├── online.ftl │ │ ├── settings.ftl │ │ └── song.ftl └── src │ ├── cloud.rs │ ├── cloud │ ├── file.rs │ ├── images.rs │ ├── structs.rs │ └── user.rs │ ├── data.rs │ ├── lib.rs │ ├── page.rs │ ├── page │ ├── about.rs │ ├── account.rs │ ├── local.rs │ ├── message.rs │ ├── online.rs │ └── settings.rs │ ├── scene.rs │ └── scene │ ├── chart_order.rs │ ├── main.rs │ └── song.rs ├── prpr-player ├── Cargo.toml └── src │ └── main.rs ├── prpr-render ├── Cargo.toml └── src │ ├── main.rs │ └── scene.rs ├── prpr ├── Cargo.toml ├── locales │ ├── en-US │ │ ├── chart_info.ftl │ │ ├── dialog.ftl │ │ ├── ending.ftl │ │ ├── game.ftl │ │ └── scene.ftl │ └── zh-CN │ │ ├── chart_info.ftl │ │ ├── dialog.ftl │ │ ├── ending.ftl │ │ ├── game.ftl │ │ └── scene.ftl └── src │ ├── config.rs │ ├── core.rs │ ├── core │ ├── anim.rs │ ├── chart.rs │ ├── effect.rs │ ├── line.rs │ ├── note.rs │ ├── object.rs │ ├── render.rs │ ├── resource.rs │ ├── shaders │ │ ├── chromatic.glsl │ │ ├── circle_blur.glsl │ │ ├── fisheye.glsl │ │ ├── glitch.glsl │ │ ├── grayscale.glsl │ │ ├── noise.glsl │ │ ├── pixel.glsl │ │ ├── radial_blur.glsl │ │ ├── shockwave.glsl │ │ └── vignette.glsl │ ├── tween.rs │ └── video.rs │ ├── ext.rs │ ├── fs.rs │ ├── info.rs │ ├── judge.rs │ ├── l10n.rs │ ├── lib.rs │ ├── objc.rs │ ├── parse.rs │ ├── parse │ ├── extra.rs │ ├── pec.rs │ ├── pgr.rs │ └── rpe.rs │ ├── particle.rs │ ├── particles.glsl │ ├── scene.rs │ ├── scene │ ├── ending.rs │ ├── fxaa.glsl │ ├── game.rs │ └── loading.rs │ ├── task.rs │ ├── time.rs │ ├── tips.txt │ ├── ui.rs │ └── ui │ ├── billboard.rs │ ├── chart_info.rs │ ├── dialog.rs │ ├── scroll.rs │ ├── shading.rs │ └── text.rs └── rustfmt.toml /.gitignore: -------------------------------------------------------------------------------- 1 | inner.rs 2 | 3 | /.env 4 | /prpr/build.rs 5 | /target 6 | /assets/font.ttf 7 | /assets/charts 8 | /assets/texture 9 | /wbindgen 10 | /dist 11 | Cargo.lock 12 | /conf.yml 13 | /cache 14 | /data 15 | /.cargo 16 | /config.txt 17 | 18 | # Mac 19 | 20 | ._* 21 | /run_ios.sh 22 | /prebuild_ios.sh 23 | /build_ios.sh 24 | /build 25 | /*.ipa 26 | /prpr.app 27 | /Assets.xcassets 28 | /LaunchScreen.storyboard 29 | /prpr.xcodeproj 30 | /dist 31 | /project.yml 32 | /prpr.scent 33 | /.cargo 34 | 35 | # Test 36 | 37 | log* 38 | FeatDoc* 39 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "prpr", 4 | "prpr-client", 5 | "prpr-client-main", 6 | "prpr-player", 7 | "prpr-render", 8 | ] 9 | resolver = "2" 10 | 11 | [profile.release] 12 | opt-level = 2 13 | strip = true 14 | 15 | [profile.dev.package."*"] 16 | opt-level = 2 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # prpr - PhigRos Player, written in Rust 2 | 3 | [中文文档](https://mivik.moe/prpr-docs) 4 | 5 | [Resource Pack Collection](https://prprblog.kevin2106.top/) 6 | 7 | 测试 QQ 群:660488396 8 | 9 | [Apple Testflight Link](https://testflight.apple.com/join/1ABHkbR8) 10 | 11 | ## Usage 12 | 13 | To begin with, clone the repo: 14 | 15 | ```shell 16 | git clone https://github.com/Mivik/prpr.git && cd prpr 17 | ``` 18 | 19 | For compactness's sake, `font.ttf` used to render the text is not included in this repo. As the fallback, `prpr` will use the default pixel font. You could fetch `font.ttf` from [https://mivik.moe/prpr/font.ttf]. 20 | 21 | ```shell 22 | wget https://mivik.moe/prpr/font.ttf -O assets/font.ttf 23 | ``` 24 | 25 | Finally, run `prpr` with your chart's path. 26 | 27 | ```shell 28 | # .pez file can be recognized 29 | cargo run --release --bin prpr-player mychart.pez 30 | 31 | # ... or unzipped folder 32 | cargo run --release --bin prpr-player ./mychart/ 33 | 34 | # Run with configuration file 35 | cargo run --release --bin prpr-player ./mychart/ conf.yml 36 | ``` 37 | 38 | ## Chart information 39 | 40 | `info.txt` and `info.csv` are supported. But if `info.yml` is provided, the other two will be ignored. 41 | 42 | The specifications of `info.yml` are as below. 43 | 44 | ```yml 45 | id: (string) (default: none) 46 | 47 | name: (string) (default: 'UK') 48 | difficulty: (float) (default: 10) 49 | level: (string) (default: 'UK Lv.?') 50 | charter: (string) (default: 'UK') 51 | composer: (string) (default: 'UK') 52 | illustrator: (string) (default: 'UK') 53 | 54 | chart: (string, the path of the chart file) (default: 'chart.json') 55 | format: (string, the format of the chart) (default: 'rpe', available: 'rpe', 'pgr', 'pec') 56 | music: (string, the path of the music file) (default: 'music.mp3') 57 | illustration: (string, the path of the illustration) (default: 'background.png') 58 | 59 | previewTime: (float, preview time of the music) (default: 0) 60 | aspectRatio: (float, the aspect ratio of the screen (w / h)) (default: 16 / 9) 61 | lineLength: (float, half the length of the judge line) (default: 6) 62 | tip: (string) (default: 'Tip: 欢迎来到 prpr!') 63 | 64 | intro: (string, introduction to this chart) (default: empty) 65 | tags: ([string], tags of this chart) (default: []) 66 | ``` 67 | 68 | ## Global configuration 69 | 70 | The optional second parameter of `prpr-player` is the path to the configuration file. The specifications are as below. 71 | 72 | ```yml 73 | adjustTime: (bool, whether automatical time alignment adjustment should be enabled) (default: true) 74 | aggresive: (bool, enables aggresive optimization, may cause inconsistent render result) (default: true) 75 | aspectRatio: (float, overrides the aspect ratio of chart) (default: none) 76 | autoplay: (bool, enables the auto play mode) (default: true) 77 | challengeColor: (enum, the color of the challenge mode badge, one of 'white', 'green', 'blue', 'red', 'golden', 'rainbow') (default: golden) 78 | challengeRank: (int, the rank in the challenge mode badge) (default: 45) 79 | disableEffect: (bool, whether to disable effects) (default: false) 80 | fixAspectRatio: (bool, forces to keep the aspect ratio specified in chart) (default: false) 81 | fxaa: (bool, whether FXAA is enabled) (default: false) 82 | interactive: (bool, whether the GUI is interactive) (default: true) 83 | multipleHint: (bool, whether to highlight notes with the same time) (default: true) 84 | noteScale: (float, scale of note size) (default: 1) 85 | offset: (float, global chart offset) (default: 0) 86 | particle: (bool, should particle be enabled or not) (default: false) 87 | playerName: (string, the name of the player) (default: 'Mivik') 88 | playerRks: (float, the ranking score of the player) (default: 15) 89 | sampleCount: (float, MSAA sampling count) (default: 4) 90 | resPackPath: (string, optional, the path to the custom resource pack (can be folder or ZIP archive)) (default: none) 91 | speed: (float, the speed of the chart) (default: 1) 92 | volumeMusic: (float, the volume of the music) (default: 1) 93 | volumeSfx: (float, the volume of sound effects) (default: 1) 94 | ``` 95 | 96 | ## Acknowledgement 97 | 98 | Some assets come from [@lchzh3473](https://github.com/lchzh3473). 99 | 100 | Thanks [@inokana](https://github.com/GBTP) for hints on implementation! 101 | 102 | ## License 103 | 104 | This project is licensed under [GNU General Public License v3.0](LICENSE). 105 | 106 | The resource assets under `assets/respack` are from [https://github.com/MisaLiu/phi-chart-render], and are licensed under [CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/). Assets used here are compressed and some of them are resized for easier usage. 107 | -------------------------------------------------------------------------------- /assets/back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/back.png -------------------------------------------------------------------------------- /assets/cali.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/cali.ogg -------------------------------------------------------------------------------- /assets/cali_hit.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/cali_hit.ogg -------------------------------------------------------------------------------- /assets/click.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/click.ogg -------------------------------------------------------------------------------- /assets/delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/delete.png -------------------------------------------------------------------------------- /assets/download.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/download.png -------------------------------------------------------------------------------- /assets/drag.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/drag.ogg -------------------------------------------------------------------------------- /assets/edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/edit.png -------------------------------------------------------------------------------- /assets/ending.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/ending.mp3 -------------------------------------------------------------------------------- /assets/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/error.png -------------------------------------------------------------------------------- /assets/flick.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/flick.ogg -------------------------------------------------------------------------------- /assets/info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/info.png -------------------------------------------------------------------------------- /assets/leaderboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/leaderboard.png -------------------------------------------------------------------------------- /assets/ok.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/ok.png -------------------------------------------------------------------------------- /assets/player.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/player.jpg -------------------------------------------------------------------------------- /assets/proceed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/proceed.png -------------------------------------------------------------------------------- /assets/question.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/question.png -------------------------------------------------------------------------------- /assets/rank/A.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/rank/A.png -------------------------------------------------------------------------------- /assets/rank/B.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/rank/B.png -------------------------------------------------------------------------------- /assets/rank/C.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/rank/C.png -------------------------------------------------------------------------------- /assets/rank/F.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/rank/F.png -------------------------------------------------------------------------------- /assets/rank/FC.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/rank/FC.png -------------------------------------------------------------------------------- /assets/rank/S.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/rank/S.png -------------------------------------------------------------------------------- /assets/rank/V.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/rank/V.png -------------------------------------------------------------------------------- /assets/rank/blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/rank/blue.png -------------------------------------------------------------------------------- /assets/rank/golden.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/rank/golden.png -------------------------------------------------------------------------------- /assets/rank/green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/rank/green.png -------------------------------------------------------------------------------- /assets/rank/phi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/rank/phi.png -------------------------------------------------------------------------------- /assets/rank/rainbow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/rank/rainbow.png -------------------------------------------------------------------------------- /assets/rank/red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/rank/red.png -------------------------------------------------------------------------------- /assets/rank/white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/rank/white.png -------------------------------------------------------------------------------- /assets/respack/click.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/respack/click.png -------------------------------------------------------------------------------- /assets/respack/click_mh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/respack/click_mh.png -------------------------------------------------------------------------------- /assets/respack/drag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/respack/drag.png -------------------------------------------------------------------------------- /assets/respack/drag_mh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/respack/drag_mh.png -------------------------------------------------------------------------------- /assets/respack/flick.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/respack/flick.png -------------------------------------------------------------------------------- /assets/respack/flick_mh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/respack/flick_mh.png -------------------------------------------------------------------------------- /assets/respack/hit_fx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/respack/hit_fx.png -------------------------------------------------------------------------------- /assets/respack/hold.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/respack/hold.png -------------------------------------------------------------------------------- /assets/respack/hold_mh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/respack/hold_mh.png -------------------------------------------------------------------------------- /assets/respack/info.yml: -------------------------------------------------------------------------------- 1 | name: Default 2 | author: "Mivik & MisaLiu" 3 | hitFx: [5, 6] 4 | holdAtlas: [50, 50] 5 | holdAtlasMH: [50, 110] 6 | -------------------------------------------------------------------------------- /assets/resume.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/resume.png -------------------------------------------------------------------------------- /assets/retry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/retry.png -------------------------------------------------------------------------------- /assets/tool.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/tool.png -------------------------------------------------------------------------------- /assets/warn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mivik/prpr/c8e9773d91235e23853212e7a931892a0b8a84c3/assets/warn.png -------------------------------------------------------------------------------- /build_wasm.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | HELP_STRING=$(cat <<- END 6 | usage: build_wasm.sh 7 | 8 | Build script for combining a Macroquad project with wasm-bindgen, 9 | allowing integration with the greater wasm-ecosystem. 10 | 11 | example: ./build_wasm.sh 12 | 13 | This'll go through the following steps: 14 | 15 | 1. Build as target 'wasm32-unknown-unknown'. 16 | 2. Create the directory 'dist' if it doesn't already exist. 17 | 3. Run wasm-bindgen with output into the 'dist' directory. 18 | 4. Apply patches to the output js file (detailed here: https://github.com/not-fl3/macroquad/issues/212#issuecomment-835276147). 19 | 5. Generate coresponding 'index.html' file. 20 | 21 | Author: Tom Solberg 22 | Edit: Nik codes 23 | Edit: Nobbele 24 | Version: 0.2 25 | END 26 | ) 27 | 28 | 29 | die () { 30 | echo >&2 "Error: $@" 31 | echo >&2 32 | echo >&2 "$HELP_STRING" 33 | exit 1 34 | } 35 | 36 | # Parse primary commands 37 | while [[ $# -gt 0 ]] 38 | do 39 | key="$1" 40 | case $key in 41 | --release) 42 | RELEASE=yes 43 | shift 44 | ;; 45 | 46 | -h|--help) 47 | echo "$HELP_STRING" 48 | exit 0 49 | ;; 50 | 51 | *) 52 | POSITIONAL+=("$1") 53 | shift 54 | ;; 55 | esac 56 | done 57 | 58 | # Restore positionals 59 | set -- "${POSITIONAL[@]}" 60 | 61 | PROJECT_NAME="prpr" 62 | 63 | HTML=$(cat <<- END 64 | 65 | 66 | 67 | 68 | ${PROJECT_NAME} 69 | 87 | 88 | 89 | 95 | 96 | 97 | 163 |
164 |

Game can't play audio unless a button has been clicked.

165 | 166 |
167 | 185 | 186 | 187 | END 188 | ) 189 | 190 | # Build 191 | cargo build --target wasm32-unknown-unknown --release --bin prpr-player 192 | 193 | # Generate bindgen outputs 194 | mkdir -p dist 195 | wasm-bindgen target/wasm32-unknown-unknown/release/$PROJECT_NAME-player.wasm --out-dir dist --out-name prpr --target web --no-typescript 196 | 197 | # Shim to tie the thing together 198 | sed -i "s/import \* as __wbg_star0 from 'env';//" dist/$PROJECT_NAME.js 199 | sed -i "s/let wasm;/let wasm; export const set_wasm = (w) => wasm = w;/" dist/$PROJECT_NAME.js 200 | sed -i "s/imports\['env'\] = __wbg_star0;/return imports.wbg\;/" dist/$PROJECT_NAME.js 201 | sed -i "s/const imports = getImports();/return getImports();/" dist/$PROJECT_NAME.js 202 | 203 | # Create index from the HTML variable 204 | echo "$HTML" > dist/index.html -------------------------------------------------------------------------------- /project.yml: -------------------------------------------------------------------------------- 1 | name: prpr 2 | options: 3 | bundleIdPrefix: com.mivik 4 | configs: 5 | Debug: debug 6 | Release: release 7 | targets: 8 | cargo_ios: 9 | type: "" 10 | platform: iOS 11 | legacy: 12 | toolPath: "/Users/sjfhsjfh/.cargo/bin/cargo" 13 | arguments: "build --release --target aarch64-apple-ios --bin prpr-client-main" 14 | workingDirectory: "." 15 | prpr: 16 | sources: 17 | - path: target/aarch64-apple-ios/release/prpr-client-main 18 | buildPhase: 19 | copyFiles: 20 | destination: executables 21 | - path: prpr.app/LaunchScreen.storyboardc 22 | buildPhase: resources 23 | - path: assets 24 | type: folder 25 | buildPhase: resources 26 | type: application 27 | platform: iOS 28 | deploymentTarget: "15.6.1" 29 | scheme: 30 | environmentVariables: 31 | - variable: RUST_BACKTRACE 32 | value: 1 33 | isEnabled: true 34 | - variable: RUST_LOG 35 | value: info 36 | isEnabled: true 37 | - variable: METAL_DEVICE_WRAPPER_TYPE 38 | value: 1 39 | isEnabled: true 40 | dependencies: 41 | - target: cargo_ios 42 | embed: false 43 | info: 44 | path: prpr.app/Info.plist 45 | properties: 46 | UILaunchStoryboardName: LaunchScreen 47 | -------------------------------------------------------------------------------- /prpr-client-main/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "prpr-client-main" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | prpr-client = { path = "../prpr-client" } 8 | -------------------------------------------------------------------------------- /prpr-client-main/src/main.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | prpr_client::quad_main(); 3 | } 4 | -------------------------------------------------------------------------------- /prpr-client/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "prpr-client" 3 | version = "0.3.2" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["lib", "cdylib"] 8 | 9 | [features] 10 | closed = ["prpr/closed"] 11 | 12 | [dependencies] 13 | anyhow = "1.0" 14 | base64 = "0.20" 15 | chrono = { version = "0.4.23", features = ["serde"] } 16 | futures-util = "0.3.25" 17 | hex = "0.4.3" 18 | image = "*" 19 | lyon = "*" 20 | macroquad = { git = "https://github.com/Mivik/prpr-macroquad", default-features = false } 21 | md5 = "0.7" 22 | once_cell = "*" 23 | openssl = { version = "*", features = ["vendored"] } 24 | pollster = "0.2.5" 25 | prpr = { path = "../prpr" } 26 | regex = "1.7.0" 27 | reqwest = { version = "0.11", features = ["stream"] } 28 | serde = { version = "*", features = ["derive"] } 29 | serde_json = "*" 30 | sha2 = "*" 31 | tokio = { version = "*", features = ["rt-multi-thread", "sync"] } 32 | uuid7 = "0.3.4" 33 | 34 | [target.'cfg(target_os = "android")'.dependencies] 35 | ndk-sys = "0.2" 36 | ndk-context = "0.1" 37 | sasa = { git = "https://github.com/Mivik/sasa", default-features = false, features = ["oboe"] } 38 | 39 | [target.'cfg(not(target_os = "android"))'.dependencies] 40 | sasa = { git = "https://github.com/Mivik/sasa" } 41 | 42 | [target.'cfg(target_os = "ios")'.dependencies] 43 | objc = "*" 44 | objc-foundation = "*" 45 | -------------------------------------------------------------------------------- /prpr-client/locales/en-US/about.ftl: -------------------------------------------------------------------------------- 1 | label = About 2 | 3 | about = 4 | prpr-client v{ $version } 5 | prpr is a Phigros simulator designed to provide a unified platform for homemade play. Please consciously abide by the relevant requirements of the community, do not use PRPR maliciously, and do not arbitrarily produce or disseminate low-quality works. 6 | 7 | The default Material Skins used in this software (including note materials and percussion effects) are derived from @MisaLiu's phi-chart-render (https://github.com/MisaLiu/phi-chart-render), signed under the CC BY-NC 4.0 license (https://creativecommons.org/licenses/by-nc/4.0/). During the development of this software, these materials were resized and compressed for use. 8 | 9 | Great thanks to @sjfhsjfh, @helloyanis, @inokana, @kagari939 for their help and contributions! 10 | 11 | prpr is open source software under the GNU General Public License v3.0. 12 | Test Group:660488396 13 | GitHub: https://github.com/Mivik/prpr 14 | Support prpr: https://afdian.net/a/mivik 15 | -------------------------------------------------------------------------------- /prpr-client/locales/en-US/account.ftl: -------------------------------------------------------------------------------- 1 | label = Account 2 | 3 | email = Email 4 | username = Username 5 | password = Password 6 | 7 | back = Back 8 | register = Register 9 | registering = Registering 10 | login = Login 11 | logging-in = Logging in 12 | logout = Logout 13 | edit-name = Modify name 14 | 15 | not-logged-in = [Not logged in] 16 | 17 | logged-out = Logged out 18 | 19 | pictrue-read-failed = Unable to read the picture 20 | pictrue-load-failed = Unable to load image 21 | avatar-import-failed = Failed to import avatar 22 | avatar-upload-failed = Failed to upload avatar 23 | avatar-delete-old-failed = Failed to delete the original avatar 24 | avatar-update-failed = Failed to update avatar 25 | 26 | name-length-req = Username length should be between 4 and 20 27 | name-has-illegal-char = Username contains illegal characters 28 | pwd-length-req = Password length should be between 6 and 26 29 | illegal-email = Illegal email 30 | 31 | email-sent = An verification email has been sent, please verify and log in 32 | 33 | action-success = { $action -> 34 | [login] Logged in successfully 35 | [register] Registered successfully 36 | [edit-name] Name modified 37 | [set-avatar] Avatar updated 38 | [update] Info updated 39 | *[other] _ 40 | } 41 | action-failed = { $action -> 42 | [login] Failed to log in 43 | [register] Failed to register 44 | [edit-name] Failed to modify username 45 | [set-avatar] Failed to upload avatar 46 | [update] Failed to update info 47 | *[other] _ 48 | } 49 | -------------------------------------------------------------------------------- /prpr-client/locales/en-US/chart_order.ftl: -------------------------------------------------------------------------------- 1 | time = Time 2 | rev-time = Time (rev) 3 | name = Name 4 | rev-name = Name (rev) 5 | -------------------------------------------------------------------------------- /prpr-client/locales/en-US/local.ftl: -------------------------------------------------------------------------------- 1 | label = Local 2 | 3 | import-failed = Failed to import 4 | import-success = Imported successfully 5 | 6 | not-loaded = Not yet finished loading 7 | -------------------------------------------------------------------------------- /prpr-client/locales/en-US/main_scene.ftl: -------------------------------------------------------------------------------- 1 | welcome = Welcome back 2 | 3 | cannot-find-chart = Beatmap not found 4 | 5 | delete-success = Deleted successfully 6 | delete-failed = Failed to delete 7 | -------------------------------------------------------------------------------- /prpr-client/locales/en-US/message.ftl: -------------------------------------------------------------------------------- 1 | label = Messages 2 | 3 | load-failed = Failed to load message 4 | 5 | updated = Updated on { $time } 6 | -------------------------------------------------------------------------------- /prpr-client/locales/en-US/online.ftl: -------------------------------------------------------------------------------- 1 | label = Online 2 | 3 | loading = Loading 4 | loaded = Charts loaded 5 | load-failed = Loading failed 6 | 7 | page-indicator = Page { $now }/{ $total } 8 | prev-page = Prev page 9 | next-page = Next page 10 | -------------------------------------------------------------------------------- /prpr-client/locales/en-US/settings.ftl: -------------------------------------------------------------------------------- 1 | label = Settings 2 | 3 | switch-language = 中文 4 | theme-prompt = Theme: { $name } 5 | 6 | respack-loaded = Resource pack loaded 7 | respack-load-failed = Failed to load resource pack 8 | respack-save-failed = Failed to save resource pack 9 | 10 | autoplay = Autoplay 11 | double-tips = Double tips 12 | fixed-aspect-ratio = Fixed aspect ratio 13 | time-adjustment = Automatic time adjustment 14 | particles = Particles 15 | aggressive-opt = Aggressive optimization 16 | low-perf-mode = Low performance mode 17 | player-rks = Player RKS 18 | offset = Offset (s) 19 | speed = Speed 20 | note-size = Note size 21 | music-vol = Music volume 22 | sfx-vol = Sound effects volume 23 | chal-color = Challenge mode color 24 | chal-colors = White,Green,Blue,Red,Gold,Rainbow 25 | chal-level = Challenge mode level 26 | double-click-pause = Double click to pause 27 | 28 | respack = Respack 29 | reset = Reset 30 | audio-buffer = Audio buffer 31 | 32 | default = [Default] 33 | invalid-input = Invalid input 34 | reset-all = Restore default settings 35 | confirm-reset = Sure? 36 | reset-all-done = Settings are restored 37 | save-failed = Failed to save config 38 | -------------------------------------------------------------------------------- /prpr-client/locales/en-US/song.ftl: -------------------------------------------------------------------------------- 1 | 2 | load-chart-info-failed = Failed to load beatmap information 3 | 4 | text-part = 5 | { $intro } 6 | { $tags } 7 | 8 | Difficulty:{ $level } ({ $difficulty }) 9 | Composer:{ $composer } 10 | Illustrator:{ $illustrator } 11 | 12 | guest = Guest 13 | 14 | load-illu-failed = Failed to load illustration 15 | load-chart-failed = Failed to load the beatmap 16 | 17 | ldb = Leaderboard 18 | ldb-loading = Loading… 19 | ldb-rank = #{ $rank } 20 | ldb-upload-error = Error(Code { $code }):{ $error } 21 | ldb-server-no-resp = Server did not respond 22 | ldb-load-failed = Failed to load leaderboard 23 | 24 | tools = Tools 25 | adjust-offset = Adjust offset 26 | exercise-mode = Exercise mode 27 | 28 | save-success = Saved successfully 29 | save-failed = Save failed 30 | 31 | already-downloaded = Already downloaded 32 | downloading = Downloading 33 | request-failed = Request failed 34 | download-cancelled = Download cancelled 35 | download-success = Downloaded 36 | download-failed = Failed to download 37 | 38 | edit-cancel = Cancel 39 | edit-upload = Upload 40 | edit-save = Save 41 | edit-saving = Saving… 42 | edit-builtin = You cannot change built-in beatmaps 43 | edit-fix-chart = Fix beatmap 44 | edit-load-file-failed = Failed to load file 45 | edit-save-config-failed = Failed to save configuration 46 | edit-save-failed = Failed to save file 47 | 48 | fix-chart-success = Repair successful 49 | fix-chart-failed = Repair failed 50 | 51 | upload-login-first = Please login first! 52 | upload-builtin = Built-in beatmaps cannot be uploaded 53 | upload-downloaded = Downloaded beatmaps cannot be uploaded 54 | upload-rules = Upload rules 55 | upload-rules-content = 56 | Before uploading, you need to confirm that: 57 | 1. The beatmap was created by me, or the act of uploading it has been approved by the scorer. If it is the second case, it needs to be indicated in the beatmap introduction that it is uploaded on behalf of a person; 58 | 2. If the beatmap is created by yourself, it is recommended to use a highly recognizable avatar and ID; If the avatar ID you use in prpr is quite different from the self-made score video publishing platform (specifically, BiliBili), additional annotations are required; 59 | 3. ! It is forbidden to violate the rules and upload the beatmap without the consent of the scorer, otherwise you may face the penalty of permanent ban on uploading; 60 | 4. The content of the beatmap (including music, illustrations, text, etc.) must comply with other laws and regulations of the People's Republic of China, and must not violate the law or contain bad information. 61 | upload-cancel = Cancel 62 | upload-confirm = Confirm 63 | uploading = Uploading 64 | upload-read-file-failed = Failed to read file 65 | upload-chart-too-large = The beatmap file is too large 66 | upload-illu-too-large = The illustration file is too large 67 | upload-read-chart-failed = Failed to read beatmap 68 | upload-read-illu-failed = Failed to read illustration 69 | uploading-chart = Beatmap… 70 | upload-chart-failed = Failed to upload beatmap 71 | uploading-illu = Illustration… 72 | upload-illu-failed = Failed to upload illustration 73 | upload-saving = Saving… 74 | upload-save-failed = Failed to upload 75 | upload-success = Uploaded successfully, please wait for review! 76 | upload-failed = Upload failed 77 | 78 | review-del = Review: Delete 79 | review-deny = Review: Deny 80 | review-approve = Review: Approve 81 | review-exec = Executing 82 | review-suc = Executed successfully 83 | review-wait = Please wait until the last task is done 84 | review-del-confirm = Are you sure? 85 | -------------------------------------------------------------------------------- /prpr-client/locales/zh-CN/about.ftl: -------------------------------------------------------------------------------- 1 | label = 关于 2 | 3 | about = 4 | prpr-client v{ $version } 5 | prpr 是一款 Phigros 模拟器,旨在为自制谱游玩提供一个统一化的平台。请自觉遵守社群相关要求,不恶意使用 prpr,不随意制作或传播低质量作品。 6 | 7 | 本软件使用的默认材质皮肤(包括音符材质和打击特效)来自于 @MisaLiu 的 phi-chart-render(https://github.com/MisaLiu/phi-chart-render),在 CC BY-NC 4.0 协议(https://creativecommons.org/licenses/by-nc/4.0/)下署名。在本软件的开发过程中,这些材质被调整尺寸并压缩以便使用。 8 | 9 | 感谢 @sjfhsjfh, @helloyanis, @inokana, @kagari939 等为本项目做出的贡献! 10 | 11 | prpr 是开源软件,遵循 GNU General Public License v3.0 协议。 12 | 测试群:660488396 13 | GitHub:https://github.com/Mivik/prpr 14 | 欢迎在爱发电上支持 prpr 的开发:https://afdian.net/a/mivik 15 | -------------------------------------------------------------------------------- /prpr-client/locales/zh-CN/account.ftl: -------------------------------------------------------------------------------- 1 | label = 账户 2 | 3 | email = 邮箱 4 | username = 用户名 5 | password = 密码 6 | 7 | back = 返回 8 | register = 注册 9 | registering = 注册中 10 | login = 登录 11 | logging-in = 登录中 12 | logout = 退出登录 13 | edit-name = 修改名称 14 | 15 | not-logged-in = [尚未登录] 16 | 17 | logged-out = 退出登录成功 18 | 19 | pictrue-read-failed = 无法读取图片 20 | pictrue-load-failed = 无法加载图片 21 | avatar-import-failed = 导入头像失败 22 | avatar-upload-failed = 上传头像失败 23 | avatar-delete-old-failed = 删除原头像失败 24 | avatar-update-failed = 更新头像失败 25 | 26 | name-length-req = 用户名长度应介于 4-20 之间 27 | name-has-illegal-char = 用户名包含非法字符 28 | pwd-length-req = 密码长度应介于 6-26 之间 29 | illegal-email = 邮箱不合法 30 | 31 | email-sent = 验证信息已发送到邮箱,请验证后登录 32 | 33 | action-success = { $action -> 34 | [login] 登录成功 35 | [register] 注册成功 36 | [edit-name] 更新名称成功 37 | [set-avatar] 更新头像成功 38 | [update] 更新数据成功 39 | *[other] _ 40 | } 41 | action-failed = { $action -> 42 | [login] 登录失败 43 | [register] 注册失败 44 | [edit-name] 更新名称失败 45 | [set-avatar] 更新头像失败 46 | [update] 更新数据失败 47 | *[other] _ 48 | } 49 | -------------------------------------------------------------------------------- /prpr-client/locales/zh-CN/chart_order.ftl: -------------------------------------------------------------------------------- 1 | time = 从新到旧 2 | rev-time = 从旧到新 3 | name = 名字正序 4 | rev-name = 名字倒序 5 | -------------------------------------------------------------------------------- /prpr-client/locales/zh-CN/local.ftl: -------------------------------------------------------------------------------- 1 | label = 本地 2 | 3 | import-failed = 导入失败 4 | import-success = 导入成功 5 | 6 | not-loaded = 尚未加载完成 7 | -------------------------------------------------------------------------------- /prpr-client/locales/zh-CN/main_scene.ftl: -------------------------------------------------------------------------------- 1 | welcome = 欢迎回来 2 | 3 | cannot-find-chart = 找不到谱面 4 | 5 | delete-success = 已删除 6 | delete-failed = 删除失败 7 | -------------------------------------------------------------------------------- /prpr-client/locales/zh-CN/message.ftl: -------------------------------------------------------------------------------- 1 | label = 消息 2 | 3 | load-failed = 加载消息失败 4 | 5 | updated = 更新于 { $time } 6 | -------------------------------------------------------------------------------- /prpr-client/locales/zh-CN/online.ftl: -------------------------------------------------------------------------------- 1 | label = 在线 2 | 3 | loading = 正在加载 4 | loaded = 加载完成 5 | load-failed = 加载失败 6 | 7 | page-indicator = 第 { $now }/{ $total } 页 8 | prev-page = 上一页 9 | next-page = 下一页 10 | -------------------------------------------------------------------------------- /prpr-client/locales/zh-CN/settings.ftl: -------------------------------------------------------------------------------- 1 | label = 设置 2 | 3 | switch-language = English 4 | theme-prompt = 主题:{ $name } 5 | 6 | respack-loaded = 资源包加载成功 7 | respack-load-failed = 加载资源包失败 8 | respack-save-failed = 保存资源包失败 9 | 10 | autoplay = 自动游玩 11 | double-tips = 双押提示 12 | fixed-aspect-ratio = 固定宽高比 13 | time-adjustment = 自动对齐时间 14 | particles = 粒子效果 15 | aggressive-opt = 激进优化 16 | low-perf-mode = 低性能模式 17 | player-rks = 玩家 RKS 18 | offset = 偏移(s) 19 | speed = 速度 20 | note-size = 音符大小 21 | music-vol = 音乐音量 22 | sfx-vol = 音效音量 23 | chal-color = 挑战模式颜色 24 | chal-colors = 白,绿,蓝,红,金,彩 25 | chal-level = 挑战模式等级 26 | double-click-pause = 双击暂停 27 | 28 | respack = 资源包 29 | reset = 重置 30 | audio-buffer = 音频缓冲区 31 | 32 | default = [默认] 33 | invalid-input = 输入非法 34 | reset-all = 恢复默认设定 35 | confirm-reset = 确定? 36 | reset-all-done = 设定恢复成功 37 | save-failed = 保存设定失败 38 | -------------------------------------------------------------------------------- /prpr-client/locales/zh-CN/song.ftl: -------------------------------------------------------------------------------- 1 | 2 | load-chart-info-failed = 加载谱面信息失败 3 | 4 | text-part = 5 | { $intro } 6 | { $tags } 7 | 8 | 难度:{ $level } ({ $difficulty }) 9 | 曲师:{ $composer } 10 | 插图:{ $illustrator } 11 | 12 | guest = 游客 13 | 14 | load-illu-failed = 加载插图失败 15 | load-chart-failed = 加载谱面失败 16 | 17 | ldb = 排行榜 18 | ldb-loading = 加载中… 19 | ldb-rank = #{ $rank } 20 | ldb-upload-error = 错误(代码 { $code }):{ $error } 21 | ldb-server-no-resp = 服务器无响应 22 | ldb-load-failed = 加载排行榜失败 23 | 24 | tools = 功能 25 | adjust-offset = 调整延迟 26 | exercise-mode = 分段练习 27 | 28 | save-success = 保存成功 29 | save-failed = 保存失败 30 | 31 | already-downloaded = 已经下载过 32 | downloading = 正在下载 33 | request-failed = 请求失败 34 | download-cancelled = 已取消 35 | download-success = 下载完成 36 | download-failed = 下载失败 37 | 38 | edit-cancel = 取消 39 | edit-upload = 上传 40 | edit-save = 保存 41 | edit-saving = 保存中… 42 | edit-builtin = 不能更改内置谱面 43 | edit-fix-chart = 自动修复谱面 44 | edit-load-file-failed = 加载文件失败 45 | edit-save-config-failed = 写入配置文件失败 46 | edit-save-failed = 保存文件失败 47 | 48 | fix-chart-success = 修复成功 49 | fix-chart-failed = 修复失败 50 | 51 | upload-login-first = 请先登录! 52 | upload-builtin = 不能上传内置谱面 53 | upload-downloaded = 不能上传下载的谱面 54 | upload-rules = 上传须知 55 | upload-rules-content = 56 | 在上传前,你需要确认: 57 | 1. 谱面为本人创作,或上传行为已经通过谱师本人同意。如果是第二种情况,需要在谱面简介中注明是代人上传; 58 | 2. 如果是谱面是本人创作,建议使用辨识度高的头像和 ID;如果你在 prpr 使用的头像 ID 与自制谱视频发布平台(具体而言,BiliBili)有较大出入,需要额外标注; 59 | 3. !禁止违反规则未经谱师同意随意上传谱面,否则可能面临永久禁止上传的惩罚; 60 | 4. 谱面内容(包括音乐、插图、文字等)须符合中华人民共和国其他法律法规,不得违法或包含不良信息。 61 | upload-cancel = 再想想 62 | upload-confirm = 确认上传 63 | uploading = 上传中… 64 | upload-read-file-failed = 读取文件失败 65 | upload-chart-too-large = 谱面文件过大 66 | upload-illu-too-large = 插图文件过大 67 | upload-read-chart-failed = 读取谱面失败 68 | upload-read-illu-failed = 读取谱面失败 69 | uploading-chart = 上传谱面中… 70 | upload-chart-failed = 上传谱面失败 71 | uploading-illu = 上传插图中… 72 | upload-illu-failed = 上传插图失败 73 | upload-saving = 保存中… 74 | upload-save-failed = 上传失败 75 | upload-success = 上传成功,请等待审核! 76 | upload-failed = 上传失败 77 | 78 | review-del = 审核:删除 79 | review-deny = 审核:拒绝 80 | review-approve = 审核:通过 81 | review-exec = 执行中 82 | review-suc = 执行成功 83 | review-wait = 请等待上一次操作完成 84 | review-del-confirm = 你确定吗? 85 | -------------------------------------------------------------------------------- /prpr-client/src/cloud/file.rs: -------------------------------------------------------------------------------- 1 | use super::UploadToken; 2 | use anyhow::{bail, Context, Result}; 3 | use reqwest::header; 4 | use serde::Serialize; 5 | use serde_json::{json, Value}; 6 | 7 | const SIZE: usize = 4 * 1024 * 1024; 8 | 9 | pub(super) async fn upload_qiniu(token: UploadToken, data: &[u8]) -> Result<()> { 10 | #[derive(Serialize)] 11 | #[serde(rename_all = "camelCase")] 12 | struct QiniuPart { 13 | part_number: usize, 14 | etag: String, 15 | } 16 | let encoded_name = base64::encode(token.key.as_bytes()); 17 | let auth = format!("UpToken {}", token.token); 18 | let client = reqwest::Client::new(); 19 | let prefix = format!("{}/buckets/{}/objects/{encoded_name}/uploads", token.upload_url, token.bucket); 20 | let upload_id = { 21 | let resp = client 22 | .post(&prefix) 23 | .header(header::AUTHORIZATION, &auth) 24 | .send() 25 | .await 26 | .context("Failed to request upload id")?; 27 | let status = resp.status(); 28 | let text = resp.text().await.context("Failed to receive text")?; 29 | if !status.is_success() { 30 | bail!("Failed to request upload id: {text}"); 31 | } 32 | let value: Value = serde_json::from_str(&text)?; 33 | value["uploadId"].as_str().unwrap().to_owned() 34 | }; 35 | let prefix = format!("{prefix}/{upload_id}"); 36 | let mut parts = Vec::new(); 37 | for (id, chunk) in data.chunks(SIZE).enumerate() { 38 | let id = id + 1; 39 | let resp = client 40 | .put(format!("{prefix}/{}", id)) 41 | .header(header::AUTHORIZATION, &auth) 42 | .header(header::CONTENT_TYPE, "application/octet-stream") 43 | .header("Content-MD5", base64::encode(md5::compute(chunk).0)) 44 | .body(chunk.to_owned()) 45 | .send() 46 | .await?; 47 | let status = resp.status(); 48 | let text = resp.text().await.context("Failed to receive text")?; 49 | if !status.is_success() { 50 | bail!("Failed to upload file: {text}"); 51 | } 52 | let value: Value = serde_json::from_str(&text)?; 53 | parts.push(QiniuPart { 54 | part_number: id, 55 | etag: value["etag"].as_str().unwrap().to_owned(), 56 | }); 57 | } 58 | let resp = client 59 | .post(prefix) 60 | .header(header::AUTHORIZATION, &auth) 61 | .header(header::CONTENT_TYPE, "application/json") 62 | .body(serde_json::to_string(&json!({ "parts": parts }))?) 63 | .send() 64 | .await?; 65 | if !resp.status().is_success() { 66 | bail!("Failed to upload file: {}", resp.text().await?); 67 | } 68 | Ok(()) 69 | } 70 | -------------------------------------------------------------------------------- /prpr-client/src/cloud/images.rs: -------------------------------------------------------------------------------- 1 | use super::LCFile; 2 | use crate::dir; 3 | use anyhow::{Context, Result}; 4 | use image::imageops::thumbnail; 5 | use image::DynamicImage; 6 | use prpr::ext::SafeTexture; 7 | use std::future::Future; 8 | use std::path::Path; 9 | 10 | pub const THUMBNAIL_WIDTH: u32 = 347; 11 | pub const THUMBNAIL_HEIGHT: u32 = 200; 12 | 13 | pub struct Images; 14 | impl Images { 15 | pub fn into_texture(tex: (DynamicImage, Option)) -> (SafeTexture, SafeTexture) { 16 | match tex { 17 | (thumb, Some(full)) => (thumb.into(), full.into()), 18 | (thumb, None) => { 19 | let tex: SafeTexture = thumb.into(); 20 | (tex.clone(), tex) 21 | } 22 | } 23 | } 24 | 25 | pub fn thumbnail(image: &DynamicImage) -> DynamicImage { 26 | let width = (image.width() as f32 / image.height() as f32 * THUMBNAIL_HEIGHT as f32).ceil() as u32; 27 | DynamicImage::ImageRgba8(thumbnail(image, width, THUMBNAIL_HEIGHT)) 28 | } 29 | 30 | pub async fn load_lc_thumbnail(file: &LCFile) -> Result { 31 | Self::local_or_else(format!("{}/{}.thumb", dir::cache_image()?, file.id), async { 32 | let bytes = reqwest::get(&format!("{}?imageView/0/w/{THUMBNAIL_WIDTH}/h/{THUMBNAIL_HEIGHT}", file.url)) 33 | .await? 34 | .bytes() 35 | .await?; 36 | Ok(image::load_from_memory(&bytes)?) 37 | }) 38 | .await 39 | } 40 | 41 | pub async fn load_lc(file: &LCFile) -> Result { 42 | Self::local_or_else(format!("{}/{}", dir::cache_image()?, file.id), async { 43 | let bytes = reqwest::get(&file.url).await?.bytes().await?; 44 | Ok(image::load_from_memory(&bytes)?) 45 | }) 46 | .await 47 | } 48 | 49 | pub async fn local_or_else(path: impl AsRef, task: impl Future>) -> Result { 50 | let path = path.as_ref(); 51 | Ok(if path.exists() { 52 | image::load_from_memory(&tokio::fs::read(path).await.context("Failed to read image")?)? 53 | } else { 54 | let image = task.await?; 55 | image.save_with_format(path, image::ImageFormat::Jpeg).context("Failed to save image")?; 56 | image 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /prpr-client/src/cloud/structs.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use super::LCObject; 4 | use crate::data::BriefChartInfo; 5 | use chrono::{DateTime, Utc}; 6 | use serde::{Deserialize, Serialize}; 7 | 8 | #[derive(Clone, Serialize, Deserialize)] 9 | enum FileTypeField { 10 | File, 11 | } 12 | 13 | #[derive(Clone, Serialize, Deserialize)] 14 | pub struct LCFile { 15 | #[serde(rename = "__type")] 16 | type_: Option, 17 | #[serde(rename = "objectId")] 18 | pub id: String, 19 | pub url: String, 20 | } 21 | 22 | impl LCFile { 23 | pub fn new(id: String, url: String) -> Self { 24 | Self { 25 | type_: Some(FileTypeField::File), 26 | id, 27 | url, 28 | } 29 | } 30 | } 31 | 32 | #[derive(Clone, Debug, Serialize, Deserialize)] 33 | enum PointerTypeField { 34 | Pointer, 35 | } 36 | 37 | #[derive(Clone, Debug, Serialize, Deserialize)] 38 | #[serde(rename_all = "camelCase")] 39 | pub struct Pointer { 40 | #[serde(rename = "__type")] 41 | type_: Option, 42 | #[serde(rename = "objectId")] 43 | pub id: String, 44 | pub class_name: Option, 45 | } 46 | 47 | impl From for Pointer { 48 | fn from(id: String) -> Self { 49 | Self { 50 | type_: Some(PointerTypeField::Pointer), 51 | id, 52 | class_name: None, 53 | } 54 | } 55 | } 56 | 57 | impl Pointer { 58 | pub fn with_class(mut self) -> Self { 59 | self.class_name = Some(T::CLASS_NAME.to_owned()); 60 | self 61 | } 62 | 63 | pub fn with_class_name(mut self, name: impl Into) -> Self { 64 | self.class_name = Some(name.into()); 65 | self 66 | } 67 | } 68 | 69 | #[derive(Clone, Serialize, Deserialize)] 70 | #[serde(rename_all = "camelCase")] 71 | pub struct User { 72 | #[serde(rename = "objectId")] 73 | pub id: String, 74 | #[serde(rename = "username")] 75 | pub name: String, 76 | pub session_token: Option, 77 | pub avatar: Option, 78 | pub short_id: String, 79 | pub email: String, 80 | } 81 | 82 | impl LCObject for User { 83 | const CLASS_NAME: &'static str = "_User"; 84 | } 85 | 86 | #[derive(Clone, Serialize, Deserialize)] 87 | #[serde(rename_all = "camelCase")] 88 | pub struct LCChartItem { 89 | #[serde(rename = "objectId")] 90 | pub id: Option, 91 | 92 | #[serde(flatten)] 93 | pub info: BriefChartInfo, 94 | 95 | #[serde(rename = "ACL")] 96 | pub acl: HashMap, 97 | 98 | pub file: LCFile, 99 | pub illustration: LCFile, 100 | pub checksum: Option, 101 | } 102 | 103 | impl LCObject for LCChartItem { 104 | const CLASS_NAME: &'static str = "Chart"; 105 | } 106 | 107 | #[derive(Clone, Deserialize)] 108 | #[serde(rename_all = "camelCase")] 109 | pub struct Message { 110 | pub title: String, 111 | pub content: String, 112 | pub author: String, 113 | pub updated_at: DateTime, 114 | } 115 | 116 | impl LCObject for Message { 117 | const CLASS_NAME: &'static str = "Message"; 118 | } 119 | 120 | #[derive(Clone, Debug, Deserialize)] 121 | pub struct LCDate { 122 | pub iso: DateTime, 123 | } 124 | 125 | impl From for DateTime { 126 | fn from(value: LCDate) -> Self { 127 | value.iso 128 | } 129 | } 130 | 131 | #[derive(Clone, Deserialize)] 132 | #[serde(rename_all = "camelCase")] 133 | pub struct LCRecord { 134 | pub chart: Pointer, 135 | pub player: User, 136 | pub score: u32, 137 | pub accuracy: f32, 138 | pub max_combo: u32, 139 | pub perfect: u32, 140 | pub good: u32, 141 | pub bad: u32, 142 | pub miss: u32, 143 | pub time: LCDate, 144 | } 145 | 146 | impl LCObject for LCRecord { 147 | const CLASS_NAME: &'static str = "Record"; 148 | } 149 | 150 | #[derive(Deserialize)] 151 | pub struct LCFunctionResult { 152 | #[serde(default)] 153 | pub code: u32, 154 | pub error: Option, 155 | pub result: Option, 156 | } 157 | -------------------------------------------------------------------------------- /prpr-client/src/cloud/user.rs: -------------------------------------------------------------------------------- 1 | use super::{Client, Images, User}; 2 | use anyhow::Result; 3 | use image::{DynamicImage, GenericImage, Rgba}; 4 | use macroquad::prelude::warn; 5 | use once_cell::sync::Lazy; 6 | use prpr::{ext::SafeTexture, task::Task}; 7 | use std::{collections::HashMap, sync::Mutex}; 8 | 9 | static TASKS: Lazy>>>> = Lazy::new(Mutex::default); 10 | static RESULTS: Lazy)>>> = Lazy::new(Mutex::default); 11 | 12 | pub struct UserManager; 13 | 14 | impl UserManager { 15 | pub fn clear_cache(user_id: &str) { 16 | RESULTS.lock().unwrap().remove(user_id); 17 | } 18 | 19 | pub fn cache(user: User) { 20 | let mut tasks = TASKS.lock().unwrap(); 21 | if tasks.contains_key(&user.id) { 22 | return; 23 | } 24 | tasks.insert( 25 | user.id.clone(), 26 | Task::new(async move { 27 | RESULTS.lock().unwrap().insert(user.id, (user.name.clone(), None)); 28 | let image = if let Some(avatar) = user.avatar { 29 | Images::load_lc(&avatar).await? 30 | } else { 31 | let mut image = image::DynamicImage::new_rgba8(1, 1); 32 | image.put_pixel(0, 0, Rgba([0, 0, 0, 255])); 33 | image 34 | }; 35 | Ok(image) 36 | }), 37 | ); 38 | } 39 | 40 | pub fn request(user_id: &str) { 41 | let mut tasks = TASKS.lock().unwrap(); 42 | if tasks.contains_key(user_id) { 43 | return; 44 | } 45 | let id = user_id.to_owned(); 46 | tasks.insert( 47 | id.clone(), 48 | Task::new(async move { 49 | let user = Client::fetch::(id.clone()).await?; 50 | RESULTS.lock().unwrap().insert(id, (user.name.clone(), None)); 51 | let image = if let Some(avatar) = user.avatar { 52 | Images::load_lc(&avatar).await? 53 | } else { 54 | let mut image = image::DynamicImage::new_rgba8(1, 1); 55 | image.put_pixel(0, 0, Rgba([0, 0, 0, 255])); 56 | image 57 | }; 58 | Ok(image) 59 | }), 60 | ); 61 | } 62 | 63 | pub fn get_name(user_id: &str) -> Option { 64 | let names = RESULTS.lock().unwrap(); 65 | if let Some((name, _)) = names.get(user_id) { 66 | return Some(name.clone()); 67 | } 68 | None 69 | } 70 | 71 | pub fn get_avatar(user_id: &str) -> Option { 72 | let mut guard = TASKS.lock().unwrap(); 73 | if let Some(task) = guard.get_mut(user_id) { 74 | if let Some(result) = task.take() { 75 | match result { 76 | Err(err) => { 77 | warn!("Failed to fetch user info: {:?}", err); 78 | guard.remove(user_id); 79 | } 80 | Ok(image) => { 81 | RESULTS.lock().unwrap().get_mut(user_id).unwrap().1 = Some(image.into()); 82 | } 83 | } 84 | } 85 | } else { 86 | drop(guard); 87 | } 88 | RESULTS.lock().unwrap().get(user_id).and_then(|it| it.1.clone()) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /prpr-client/src/data.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | cloud::{Pointer, User}, 3 | dir, 4 | page::ChartItem, 5 | }; 6 | use anyhow::Result; 7 | use chrono::{DateTime, Utc}; 8 | use prpr::{config::Config, info::ChartInfo}; 9 | use serde::{Deserialize, Serialize}; 10 | use std::{collections::HashSet, ops::DerefMut, path::Path}; 11 | 12 | pub const THEMES: [(&str, u32, u32); 8] = [ 13 | ("Default", 0xffa2a2a2, 0xffa2a2a2), 14 | ("Aurora", 0xff303f9f, 0xff1976d2), 15 | ("Flamingo", 0xffd500f9, 0xffffa000), 16 | ("Forest", 0xff2e7d32, 0xff4db6ac), 17 | ("Lively", 0xffbf360c, 0xfff57c00), 18 | ("Magical", 0xff7b1fa2, 0xfffc3dff), 19 | ("Ocean", 0xff303f9f, 0xff1976d2), 20 | ("Spring", 0xfffff9c4, 0xff64ffda), 21 | ]; 22 | 23 | #[derive(Clone, Serialize, Deserialize)] 24 | #[serde(rename_all = "camelCase")] 25 | pub struct BriefChartInfo { 26 | pub id: Option, 27 | pub uploader: Option, 28 | pub name: String, 29 | pub level: String, 30 | pub difficulty: f32, 31 | pub preview_time: f32, 32 | pub intro: String, 33 | pub tags: Vec, 34 | pub composer: String, 35 | pub illustrator: String, 36 | } 37 | 38 | impl From for BriefChartInfo { 39 | fn from(info: ChartInfo) -> Self { 40 | Self { 41 | id: info.id, 42 | uploader: None, 43 | name: info.name, 44 | level: info.level, 45 | difficulty: info.difficulty, 46 | preview_time: info.preview_time, 47 | intro: info.intro, 48 | tags: info.tags, 49 | composer: info.composer, 50 | illustrator: info.illustrator, 51 | } 52 | } 53 | } 54 | 55 | impl BriefChartInfo { 56 | pub fn into_full(self) -> ChartInfo { 57 | ChartInfo { 58 | id: self.id, 59 | name: self.name, 60 | level: self.level, 61 | difficulty: self.difficulty, 62 | preview_time: self.preview_time, 63 | intro: self.intro, 64 | tags: self.tags, 65 | composer: self.composer, 66 | illustrator: self.illustrator, 67 | ..Default::default() 68 | } 69 | } 70 | } 71 | 72 | #[derive(Serialize, Deserialize)] 73 | pub struct LocalChart { 74 | #[serde(flatten)] 75 | pub info: BriefChartInfo, 76 | pub path: String, 77 | } 78 | 79 | #[derive(Default, Serialize, Deserialize)] 80 | #[serde(default)] 81 | pub struct Data { 82 | pub me: Option, 83 | pub charts: Vec, 84 | pub config: Config, 85 | pub message_check_time: Option>, 86 | pub language: Option, 87 | pub theme: usize, 88 | } 89 | 90 | impl Data { 91 | pub async fn init(&mut self) -> Result<()> { 92 | let charts = dir::charts()?; 93 | self.charts.retain(|it| Path::new(&format!("{}/{}", charts, it.path)).exists()); 94 | let occurred: HashSet<_> = self.charts.iter().map(|it| it.path.clone()).collect(); 95 | for entry in std::fs::read_dir(dir::custom_charts()?)? { 96 | let entry = entry?; 97 | let filename = entry.file_name(); 98 | let filename = filename.to_str().unwrap(); 99 | let filename = format!("custom/{filename}"); 100 | if occurred.contains(&filename) { 101 | continue; 102 | } 103 | let path = entry.path(); 104 | let Ok(mut fs) = prpr::fs::fs_from_file(&path) else { 105 | continue; 106 | }; 107 | let result = prpr::fs::load_info(fs.deref_mut()).await; 108 | if let Ok(info) = result { 109 | self.charts.push(LocalChart { 110 | info: BriefChartInfo { id: None, ..info.into() }, 111 | path: filename, 112 | }); 113 | } 114 | } 115 | if let Some(res_pack_path) = &mut self.config.res_pack_path { 116 | if res_pack_path.starts_with('/') { 117 | // for compatibility 118 | *res_pack_path = "chart.zip".to_owned(); 119 | } 120 | } 121 | Ok(()) 122 | } 123 | 124 | pub fn find_chart(&self, chart: &ChartItem) -> Option { 125 | self.charts.iter().position(|it| it.path == chart.path) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /prpr-client/src/page.rs: -------------------------------------------------------------------------------- 1 | mod about; 2 | pub use about::AboutPage; 3 | 4 | mod account; 5 | pub use account::AccountPage; 6 | 7 | mod local; 8 | pub use local::LocalPage; 9 | 10 | mod message; 11 | pub use message::MessagePage; 12 | 13 | mod online; 14 | pub use online::OnlinePage; 15 | 16 | mod settings; 17 | pub use settings::SettingsPage; 18 | 19 | use crate::{ 20 | cloud::{Images, LCFile}, 21 | data::BriefChartInfo, 22 | dir, get_data, 23 | scene::ChartOrder, 24 | }; 25 | use anyhow::Result; 26 | use image::DynamicImage; 27 | use lyon::{ 28 | math as lm, 29 | path::{builder::BorderRadii, Path, Winding}, 30 | }; 31 | use macroquad::prelude::*; 32 | use prpr::{ 33 | ext::{SafeTexture, BLACK_TEXTURE}, 34 | fs, 35 | task::Task, 36 | ui::{Scroll, Ui}, 37 | }; 38 | use std::{borrow::Cow, ops::DerefMut, sync::atomic::AtomicBool}; 39 | 40 | const ROW_NUM: u32 = 4; 41 | const CARD_HEIGHT: f32 = 0.3; 42 | const CARD_PADDING: f32 = 0.02; 43 | const SIDE_PADDING: f32 = 0.02; 44 | 45 | pub static SHOULD_UPDATE: AtomicBool = AtomicBool::new(false); 46 | 47 | pub fn illustration_task(path: String) -> Task)>> { 48 | Task::new(async move { 49 | let mut fs = fs::fs_from_file(std::path::Path::new(&format!("{}/{}", dir::charts()?, path)))?; 50 | let info = fs::load_info(fs.deref_mut()).await?; 51 | let image = image::load_from_memory(&fs.load_file(&info.illustration).await?)?; 52 | let thumbnail = 53 | Images::local_or_else(format!("{}/{}", dir::cache_image_local()?, path.replace('/', "_")), async { Ok(Images::thumbnail(&image)) }) 54 | .await?; 55 | Ok((thumbnail, Some(image))) 56 | }) 57 | } 58 | 59 | fn get_touched(pos: (f32, f32)) -> Option { 60 | let row = (pos.1 / CARD_HEIGHT) as i32; 61 | if row < 0 { 62 | return None; 63 | } 64 | let width = (2. - SIDE_PADDING * 2.) / ROW_NUM as f32; 65 | let column = (pos.0 / width) as i32; 66 | if column < 0 || column >= ROW_NUM as i32 { 67 | return None; 68 | } 69 | let x = pos.0 - width * column as f32; 70 | if x < CARD_PADDING || x + CARD_PADDING >= width { 71 | return None; 72 | } 73 | let y = pos.1 - CARD_HEIGHT * row as f32; 74 | if y < CARD_PADDING || y + CARD_PADDING >= CARD_HEIGHT { 75 | return None; 76 | } 77 | let id = row as u32 * ROW_NUM + column as u32; 78 | Some(id) 79 | } 80 | 81 | fn trigger_grid(phase: TouchPhase, choose: &mut Option, id: Option) -> bool { 82 | match phase { 83 | TouchPhase::Started => { 84 | *choose = id; 85 | false 86 | } 87 | TouchPhase::Moved | TouchPhase::Stationary => { 88 | if *choose != id { 89 | *choose = None; 90 | } 91 | false 92 | } 93 | TouchPhase::Cancelled => { 94 | *choose = None; 95 | false 96 | } 97 | TouchPhase::Ended => choose.take() == id && id.is_some(), 98 | } 99 | } 100 | 101 | pub fn load_local(tex: &SafeTexture, order: &(ChartOrder, bool)) -> Vec { 102 | let mut res: Vec<_> = get_data() 103 | .charts 104 | .iter() 105 | .map(|it| ChartItem { 106 | info: it.info.clone(), 107 | path: it.path.clone(), 108 | illustration: (tex.clone(), tex.clone()), 109 | illustration_task: Some(illustration_task(it.path.clone())), 110 | }) 111 | .collect(); 112 | order.0.apply(&mut res); 113 | if order.1 { 114 | res.reverse(); 115 | } 116 | res 117 | } 118 | 119 | pub struct ChartItem { 120 | pub info: BriefChartInfo, 121 | pub path: String, 122 | pub illustration: (SafeTexture, SafeTexture), 123 | pub illustration_task: Option)>>>, 124 | } 125 | 126 | pub struct SharedState { 127 | pub t: f32, 128 | pub content_size: (f32, f32), 129 | pub tex: SafeTexture, 130 | 131 | pub charts_local: Vec, 132 | pub charts_online: Vec, 133 | 134 | pub transit: Option<(Option, u32, f32, Rect, bool, bool)>, // online, id, start_time, rect, delete, public 135 | } 136 | 137 | impl SharedState { 138 | pub async fn new() -> Result { 139 | let tex: SafeTexture = Texture2D::from_image(&load_image("player.jpg").await?).into(); 140 | let charts_local = load_local(&tex, &(ChartOrder::Default, false)); 141 | Ok(Self { 142 | t: 0., 143 | content_size: (0., 0.), 144 | tex, 145 | 146 | charts_local, 147 | charts_online: Vec::new(), 148 | 149 | transit: None, 150 | }) 151 | } 152 | 153 | fn update_charts(charts: &mut [ChartItem]) { 154 | for chart in charts { 155 | if let Some(task) = &mut chart.illustration_task { 156 | if let Some(image) = task.take() { 157 | chart.illustration = if let Ok(image) = image { 158 | Images::into_texture(image) 159 | } else { 160 | (BLACK_TEXTURE.clone(), BLACK_TEXTURE.clone()) 161 | }; 162 | chart.illustration_task = None; 163 | } 164 | } 165 | } 166 | } 167 | 168 | fn render_charts(ui: &mut Ui, content_size: (f32, f32), scroll: &mut Scroll, charts: &mut [ChartItem], extra: Option<&[(LCFile, bool)]>) { 169 | scroll.size(content_size); 170 | let sy = scroll.y_scroller.offset(); 171 | scroll.render(ui, |ui| { 172 | let cw = content_size.0 / ROW_NUM as f32; 173 | let ch = CARD_HEIGHT; 174 | let p = CARD_PADDING; 175 | let path = { 176 | let mut path = Path::builder(); 177 | path.add_rounded_rectangle(&lm::Box2D::new(lm::point(p, p), lm::point(cw - p, ch - p)), &BorderRadii::new(0.01), Winding::Positive); 178 | path.build() 179 | }; 180 | let start_line = (sy / ch) as u32; 181 | let end_line = ((sy + content_size.1) / ch).ceil() as u32; 182 | let range = (start_line * ROW_NUM)..((end_line + 1) * ROW_NUM); 183 | ui.hgrids(content_size.0, ch, ROW_NUM, charts.len() as u32, |ui, id| { 184 | if !range.contains(&id) { 185 | return; 186 | } 187 | let chart = &mut charts[id as usize]; 188 | ui.fill_path(&path, (*chart.illustration.0, Rect::new(0., 0., cw, ch))); 189 | ui.fill_path(&path, (Color::new(0., 0., 0., 0.4), (0., 0.), Color::new(0., 0., 0., 0.8), (0., ch))); 190 | ui.text(&chart.info.name) 191 | .pos(p + 0.01, ch - p - 0.02) 192 | .max_width(cw - p * 2.) 193 | .anchor(0., 1.) 194 | .size(0.6) 195 | .draw(); 196 | if extra.map_or(false, |it| !it[id as usize].1) { 197 | ui.text("*").pos(cw - p, p).anchor(1., 0.).draw(); 198 | } 199 | }) 200 | }); 201 | } 202 | } 203 | 204 | pub trait Page { 205 | fn label(&self) -> Cow<'static, str>; 206 | fn has_new(&self) -> bool { 207 | false 208 | } 209 | 210 | fn update(&mut self, focus: bool, state: &mut SharedState) -> Result<()>; 211 | fn touch(&mut self, touch: &Touch, state: &mut SharedState) -> Result; 212 | fn render(&mut self, ui: &mut Ui, state: &mut SharedState) -> Result<()>; 213 | fn pause(&mut self) -> Result<()> { 214 | Ok(()) 215 | } 216 | fn resume(&mut self) -> Result<()> { 217 | Ok(()) 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /prpr-client/src/page/about.rs: -------------------------------------------------------------------------------- 1 | prpr::tl_file!("about"); 2 | 3 | use super::{Page, SharedState, SIDE_PADDING}; 4 | use anyhow::Result; 5 | use macroquad::prelude::Touch; 6 | use prpr::ui::{Scroll, Ui}; 7 | use std::borrow::Cow; 8 | 9 | pub struct AboutPage { 10 | scroll: Scroll, 11 | } 12 | 13 | impl AboutPage { 14 | pub fn new() -> Self { 15 | Self { scroll: Scroll::new() } 16 | } 17 | } 18 | 19 | impl Page for AboutPage { 20 | fn label(&self) -> Cow<'static, str> { 21 | tl!("label") 22 | } 23 | 24 | fn update(&mut self, _focus: bool, state: &mut SharedState) -> Result<()> { 25 | self.scroll.update(state.t); 26 | Ok(()) 27 | } 28 | fn touch(&mut self, touch: &Touch, state: &mut SharedState) -> Result { 29 | if self.scroll.touch(touch, state.t) { 30 | return Ok(true); 31 | } 32 | Ok(false) 33 | } 34 | fn render(&mut self, ui: &mut Ui, state: &mut SharedState) -> Result<()> { 35 | ui.dx(0.02); 36 | ui.dy(0.01); 37 | self.scroll.size(state.content_size); 38 | self.scroll.render(ui, |ui| { 39 | let r = ui 40 | .text(tl!("about", "version" => env!("CARGO_PKG_VERSION"))) 41 | .multiline() 42 | .max_width((1. - SIDE_PADDING) * 2. - 0.02) 43 | .size(0.5) 44 | .draw(); 45 | (r.w, r.h + 0.02) 46 | }); 47 | Ok(()) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /prpr-client/src/page/local.rs: -------------------------------------------------------------------------------- 1 | prpr::tl_file!("local"); 2 | 3 | use super::{get_touched, load_local, trigger_grid, Page, SharedState, CARD_HEIGHT, ROW_NUM, SHOULD_UPDATE}; 4 | use crate::{ 5 | data::{BriefChartInfo, LocalChart}, 6 | dir, get_data_mut, save_data, 7 | scene::{ChartOrderBox, CHARTS_BAR_HEIGHT}, 8 | }; 9 | use anyhow::{Context, Result}; 10 | use macroquad::prelude::*; 11 | use prpr::{ 12 | ext::SafeTexture, 13 | fs, 14 | scene::{request_file, return_file, show_error, show_message, take_file}, 15 | task::Task, 16 | ui::{RectButton, Scroll, Ui}, 17 | }; 18 | use std::{borrow::Cow, ops::DerefMut, path::Path, sync::atomic::Ordering}; 19 | 20 | pub struct LocalPage { 21 | scroll: Scroll, 22 | choose: Option, 23 | 24 | order_box: ChartOrderBox, 25 | 26 | import_button: RectButton, 27 | import_task: Task>, 28 | } 29 | 30 | impl LocalPage { 31 | pub async fn new(icon_play: SafeTexture) -> Result { 32 | Ok(Self { 33 | scroll: Scroll::new(), 34 | choose: None, 35 | 36 | order_box: ChartOrderBox::new(icon_play), 37 | 38 | import_button: RectButton::new(), 39 | import_task: Task::pending(), 40 | }) 41 | } 42 | } 43 | 44 | impl Page for LocalPage { 45 | fn label(&self) -> Cow<'static, str> { 46 | tl!("label") 47 | } 48 | 49 | fn update(&mut self, _focus: bool, state: &mut SharedState) -> Result<()> { 50 | let t = state.t; 51 | self.scroll.update(t); 52 | if SHOULD_UPDATE.fetch_and(false, Ordering::SeqCst) { 53 | state.charts_local = load_local(&state.tex, self.order_box.to_order()); 54 | } 55 | SharedState::update_charts(&mut state.charts_local); 56 | if let Some((id, file)) = take_file() { 57 | if id == "chart" || id == "_import" { 58 | async fn import(from: String) -> Result { 59 | let name = uuid7::uuid7().to_string(); 60 | let file = Path::new(&dir::custom_charts()?).join(&name); 61 | std::fs::copy(from, &file).context("Failed to save")?; 62 | let mut fs = fs::fs_from_file(std::path::Path::new(&file))?; 63 | let info = fs::load_info(fs.deref_mut()).await?; 64 | Ok(LocalChart { 65 | info: BriefChartInfo { 66 | id: Option::None, 67 | ..info.into() 68 | }, 69 | path: format!("custom/{name}"), 70 | }) 71 | } 72 | self.import_task = Task::new(import(file)); 73 | } else { 74 | return_file(id, file); 75 | } 76 | } 77 | if let Some(result) = self.import_task.take() { 78 | match result { 79 | Err(err) => { 80 | show_error(err.context(tl!("import-failed"))); 81 | } 82 | Ok(chart) => { 83 | get_data_mut().charts.push(chart); 84 | save_data()?; 85 | state.charts_local = load_local(&state.tex, self.order_box.to_order()); 86 | show_message(tl!("import-success")); 87 | } 88 | } 89 | } 90 | Ok(()) 91 | } 92 | 93 | fn touch(&mut self, touch: &Touch, state: &mut SharedState) -> Result { 94 | if self.order_box.touch(touch) { 95 | state.charts_local = load_local(&state.tex, self.order_box.to_order()); 96 | return Ok(true); 97 | } 98 | if self.import_button.touch(touch) { 99 | request_file("chart"); 100 | return Ok(true); 101 | } 102 | let t = state.t; 103 | if self.scroll.touch(touch, t) { 104 | self.choose = None; 105 | return Ok(true); 106 | } else if let Some(pos) = self.scroll.position(touch) { 107 | let id = get_touched(pos); 108 | let trigger = trigger_grid(touch.phase, &mut self.choose, id); 109 | if trigger { 110 | let id = id.unwrap(); 111 | if let Some(chart) = state.charts_local.get(id as usize) { 112 | if chart.illustration_task.is_none() { 113 | state.transit = Some((None, id, t, Rect::default(), false, true)); 114 | } else { 115 | show_message(tl!("not-loaded")); 116 | } 117 | return Ok(true); 118 | } 119 | } 120 | } 121 | Ok(false) 122 | } 123 | 124 | fn render(&mut self, ui: &mut Ui, state: &mut SharedState) -> Result<()> { 125 | let r = self.order_box.render(ui); 126 | ui.dy(r.h); 127 | let content_size = (state.content_size.0, state.content_size.1 - CHARTS_BAR_HEIGHT); 128 | SharedState::render_charts(ui, content_size, &mut self.scroll, &mut state.charts_local, None); 129 | if let Some((None, id, _, rect, ..)) = &mut state.transit { 130 | let width = content_size.0; 131 | *rect = ui.rect_to_global(Rect::new( 132 | (*id % ROW_NUM) as f32 * width / ROW_NUM as f32, 133 | (*id / ROW_NUM) as f32 * CARD_HEIGHT - self.scroll.y_scroller.offset(), 134 | width / ROW_NUM as f32, 135 | CARD_HEIGHT, 136 | )); 137 | } 138 | { 139 | let pad = 0.03; 140 | let rad = 0.06; 141 | let r = Rect::new(content_size.0 - pad - rad * 2., content_size.1 - pad - rad * 2., rad * 2., rad * 2.); 142 | let ct = r.center(); 143 | ui.fill_circle(ct.x, ct.y, rad, ui.accent()); 144 | self.import_button.set(ui, r); 145 | ui.text("+").pos(ct.x, ct.y).anchor(0.5, 0.5).size(1.4).no_baseline().draw(); 146 | } 147 | Ok(()) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /prpr-client/src/page/message.rs: -------------------------------------------------------------------------------- 1 | prpr::tl_file!("message"); 2 | 3 | use super::{Page, SharedState}; 4 | use crate::{ 5 | cloud::{Client, Message}, 6 | get_data, get_data_mut, save_data, 7 | }; 8 | use anyhow::Result; 9 | use chrono::{Local, Utc}; 10 | use macroquad::prelude::*; 11 | use prpr::{ 12 | scene::show_error, 13 | task::Task, 14 | ui::{RectButton, Scroll, Ui}, 15 | }; 16 | use std::borrow::Cow; 17 | 18 | pub struct MessagePage { 19 | list_scroll: Scroll, 20 | content_scroll: Scroll, 21 | load_task: Option>>>, 22 | 23 | messages: Vec<(Message, RectButton)>, 24 | focus: Option, 25 | has_new: bool, 26 | } 27 | 28 | impl MessagePage { 29 | pub fn new() -> Self { 30 | Self { 31 | list_scroll: Scroll::new(), 32 | content_scroll: Scroll::new(), 33 | load_task: Some(Task::new(Client::messages())), 34 | 35 | messages: Vec::new(), 36 | focus: None, 37 | has_new: false, 38 | } 39 | } 40 | } 41 | 42 | impl Page for MessagePage { 43 | fn label(&self) -> Cow<'static, str> { 44 | tl!("label") 45 | } 46 | fn has_new(&self) -> bool { 47 | self.has_new 48 | } 49 | 50 | fn update(&mut self, _focus: bool, state: &mut SharedState) -> Result<()> { 51 | let t = state.t; 52 | if self.list_scroll.y_scroller.pulled && self.load_task.is_none() { 53 | self.has_new = false; 54 | self.focus = None; 55 | self.messages.clear(); 56 | self.load_task = Some(Task::new(Client::messages())); 57 | } 58 | self.list_scroll.update(t); 59 | self.content_scroll.update(t); 60 | if let Some(task) = self.load_task.as_mut() { 61 | if let Some(msgs) = task.take() { 62 | match msgs { 63 | Ok(msgs) => { 64 | self.has_new = msgs 65 | .first() 66 | .map_or(false, |it| get_data().message_check_time.map_or(true, |check| check < it.updated_at)); 67 | self.messages = msgs.into_iter().map(|it| (it, RectButton::new())).collect(); 68 | } 69 | Err(err) => { 70 | show_error(err.context(tl!("load-failed"))); 71 | } 72 | } 73 | self.load_task = None; 74 | } 75 | } 76 | Ok(()) 77 | } 78 | 79 | fn touch(&mut self, touch: &Touch, state: &mut super::SharedState) -> Result { 80 | let t = state.t; 81 | if self.list_scroll.touch(touch, t) { 82 | return Ok(true); 83 | } 84 | for (id, (_, btn)) in self.messages.iter_mut().enumerate() { 85 | if btn.touch(touch) { 86 | self.focus = if self.focus == Some(id) { None } else { Some(id) }; 87 | self.content_scroll.y_scroller.set_offset(0.); 88 | if self.has_new { 89 | get_data_mut().message_check_time = Some(Utc::now()); 90 | save_data()?; 91 | self.has_new = false; 92 | } 93 | return Ok(true); 94 | } 95 | } 96 | if self.content_scroll.touch(touch, t) { 97 | return Ok(true); 98 | } 99 | Ok(false) 100 | } 101 | 102 | fn render(&mut self, ui: &mut Ui, state: &mut SharedState) -> Result<()> { 103 | let width = 0.4; 104 | self.list_scroll.size((width, state.content_size.1 - 0.01)); 105 | ui.fill_rect(self.list_scroll.rect(), Color::new(0., 0., 0., 0.3)); 106 | self.list_scroll.render(ui, |ui| { 107 | let pd = 0.02; 108 | let vpad = 0.015; 109 | let mut h = vpad; 110 | for (id, (msg, btn)) in self.messages.iter_mut().enumerate() { 111 | ui.dy(vpad); 112 | h += vpad; 113 | let r = Rect::new(pd, 0., width - pd * 2., 0.07); 114 | ui.fill_rect(r, Color::new(1., 1., 1., if Some(id) == self.focus { 0.3 } else { 0.5 })); 115 | ui.text(&msg.title) 116 | .pos(r.x + 0.01, r.center().y) 117 | .anchor(0., 0.5) 118 | .no_baseline() 119 | .size(0.4) 120 | .max_width(r.w) 121 | .draw(); 122 | btn.set(ui, r); 123 | ui.dy(r.h); 124 | h += r.h; 125 | } 126 | (width, h) 127 | }); 128 | let dx = width + 0.02; 129 | ui.dx(dx); 130 | let width = state.content_size.0 - dx; 131 | self.content_scroll.size((width, state.content_size.1 - 0.01)); 132 | if let Some(focus) = self.focus { 133 | let msg = &self.messages[focus].0; 134 | ui.fill_rect(self.content_scroll.rect(), Color::new(0., 0., 0., 0.3)); 135 | self.content_scroll.render(ui, |ui| { 136 | let mut h = 0.; 137 | let pd = 0.02; 138 | ui.dx(pd); 139 | macro_rules! dy { 140 | ($dy:expr) => {{ 141 | let dy = $dy; 142 | h += dy; 143 | ui.dy(dy); 144 | }}; 145 | } 146 | dy!(0.02); 147 | let r = ui.text(&msg.title).size(0.9).draw(); 148 | dy!(r.h + 0.02); 149 | ui.fill_rect(Rect::new(0., 0., width - pd * 2., 0.007), WHITE); 150 | dy!(0.007 + 0.01); 151 | let c = Color::new(1., 1., 1., 0.6); 152 | let r = ui.text(&msg.author).size(0.3).color(c).draw(); 153 | let r = ui 154 | .text(&tl!("updated", "time" => msg.updated_at.with_timezone(&Local).format("%Y-%m-%d %H:%M").to_string())) 155 | .size(0.3) 156 | .pos(r.w + 0.01, 0.) 157 | .color(c) 158 | .draw(); 159 | dy!(r.h + 0.018); 160 | let r = ui.text(&msg.content).size(0.5).max_width(width - pd * 2.).multiline().draw(); 161 | dy!(r.h + 0.027); 162 | (width, h) 163 | }); 164 | } 165 | Ok(()) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /prpr-client/src/page/online.rs: -------------------------------------------------------------------------------- 1 | prpr::tl_file!("online"); 2 | 3 | use super::{get_touched, trigger_grid, ChartItem, Page, SharedState, CARD_HEIGHT, ROW_NUM}; 4 | use crate::{ 5 | cloud::{Client, Images, LCChartItem, LCFile, QueryResult}, 6 | data::BriefChartInfo, 7 | scene::{ChartOrder, ChartOrderBox, CHARTS_BAR_HEIGHT}, 8 | }; 9 | use anyhow::Result; 10 | use macroquad::prelude::{Rect, Touch}; 11 | use prpr::{ 12 | ext::SafeTexture, 13 | scene::{show_error, show_message}, 14 | task::Task, 15 | ui::{MessageHandle, Scroll, Ui}, 16 | }; 17 | use std::borrow::Cow; 18 | 19 | const PAGE_NUM: usize = 28; 20 | 21 | pub struct OnlinePage { 22 | focus: bool, 23 | 24 | scroll: Scroll, 25 | choose: Option, 26 | 27 | order_box: ChartOrderBox, 28 | 29 | page: usize, 30 | total_page: usize, 31 | 32 | task_load: Task, usize)>>, 33 | extra: Vec<(LCFile, bool)>, 34 | first_time: bool, 35 | loading: Option, 36 | } 37 | 38 | impl OnlinePage { 39 | pub fn new(icon_play: SafeTexture) -> Self { 40 | Self { 41 | focus: false, 42 | 43 | scroll: Scroll::new(), 44 | choose: None, 45 | 46 | order_box: ChartOrderBox::new(icon_play), 47 | 48 | page: 0, 49 | total_page: 0, 50 | 51 | task_load: Task::pending(), 52 | extra: Vec::new(), 53 | first_time: true, 54 | loading: None, 55 | } 56 | } 57 | 58 | fn refresh(&mut self, state: &mut SharedState) { 59 | if self.loading.is_some() { 60 | return; 61 | } 62 | state.charts_online.clear(); 63 | self.loading = Some(show_message(tl!("loading")).handle()); 64 | let order = self.order_box.to_order(); 65 | let page = self.page; 66 | self.task_load = Task::new({ 67 | let tex = state.tex.clone(); 68 | async move { 69 | let result: QueryResult = Client::query() 70 | .order(match order { 71 | (ChartOrder::Default, false) => "-updatedAt", 72 | (ChartOrder::Default, true) => "updatedAt", 73 | (ChartOrder::Name, false) => "name", 74 | (ChartOrder::Name, true) => "-name", 75 | }) 76 | .limit(PAGE_NUM) 77 | .skip(page * PAGE_NUM) 78 | .with_count() 79 | .return_acl() 80 | .send() 81 | .await?; 82 | let total_page = (result.count.unwrap() - 1) / PAGE_NUM + 1; 83 | let charts = result 84 | .results 85 | .into_iter() 86 | .map(|it| { 87 | let illu = it.illustration.clone(); 88 | ( 89 | ChartItem { 90 | info: BriefChartInfo { 91 | id: it.id, 92 | ..it.info.clone() 93 | }, 94 | path: it.file.url, 95 | illustration: (tex.clone(), tex.clone()), 96 | illustration_task: Some(Task::new(async move { 97 | let image = Images::load_lc_thumbnail(&illu).await?; 98 | Ok((image, None)) 99 | })), 100 | }, 101 | (it.illustration, 102 | it.acl.contains_key("*")), 103 | ) 104 | }) 105 | .collect::>(); 106 | Ok((charts, total_page)) 107 | } 108 | }); 109 | } 110 | } 111 | 112 | impl Page for OnlinePage { 113 | fn label(&self) -> Cow<'static, str> { 114 | tl!("label") 115 | } 116 | 117 | fn update(&mut self, focus: bool, state: &mut SharedState) -> Result<()> { 118 | if !self.focus && focus && self.first_time { 119 | self.first_time = false; 120 | self.refresh(state); 121 | } 122 | SharedState::update_charts(&mut state.charts_online); 123 | self.focus = focus; 124 | 125 | let t = state.t; 126 | if self.scroll.y_scroller.pulled { 127 | self.refresh(state); 128 | } 129 | self.scroll.update(t); 130 | if let Some(charts) = self.task_load.take() { 131 | self.loading.take().unwrap().cancel(); 132 | match charts { 133 | Ok((charts, total_page)) => { 134 | show_message(tl!("loaded")).ok().duration(1.); 135 | self.total_page = total_page; 136 | (state.charts_online, self.extra) = charts.into_iter().unzip(); 137 | } 138 | Err(err) => { 139 | self.first_time = true; 140 | show_error(err.context(tl!("load-failed"))); 141 | } 142 | } 143 | } 144 | Ok(()) 145 | } 146 | 147 | fn touch(&mut self, touch: &Touch, state: &mut SharedState) -> Result { 148 | let t = state.t; 149 | if self.loading.is_none() && self.order_box.touch(touch) { 150 | self.page = 0; 151 | self.refresh(state); 152 | return Ok(true); 153 | } 154 | if self.scroll.touch(touch, t) { 155 | self.choose = None; 156 | return Ok(true); 157 | } else if let Some(pos) = self.scroll.position(touch) { 158 | let id = get_touched(pos); 159 | let trigger = trigger_grid(touch.phase, &mut self.choose, id); 160 | if trigger { 161 | let id = id.unwrap() as usize; 162 | if id < state.charts_online.len() { 163 | let path = format!("download/{}", state.charts_online[id].info.id.as_ref().unwrap()); 164 | if let Some(index) = state.charts_local.iter().position(|it| it.path == path) { 165 | let that = &state.charts_local[index].illustration.1; 166 | if *that != state.tex { 167 | state.charts_online[id].illustration.1 = that.clone(); 168 | } 169 | } 170 | state.transit = Some((Some(self.extra[id].0.clone()), id as u32, t, Rect::default(), false, self.extra[id].1)); 171 | return Ok(true); 172 | } 173 | } 174 | } 175 | Ok(false) 176 | } 177 | 178 | fn render(&mut self, ui: &mut Ui, state: &mut SharedState) -> Result<()> { 179 | let r = self.order_box.render(ui); 180 | 181 | ui.scope(|ui| { 182 | ui.dx(r.w + 0.02); 183 | let tr = ui 184 | .text(tl!("page-indicator", "now" => self.page + 1, "total" => self.total_page)) 185 | .size(0.6) 186 | .pos(0., r.h / 2.) 187 | .anchor(0., 0.5) 188 | .no_baseline() 189 | .draw(); 190 | if self.loading.is_none() { 191 | ui.dx(tr.w + 0.02); 192 | let r = Rect::new(0., 0.01, 0.2, r.h - 0.02); 193 | if self.page != 0 { 194 | if ui.button("prev_page", r, tl!("prev-page")) { 195 | self.page -= 1; 196 | self.scroll.y_scroller.set_offset(0.); 197 | self.refresh(state); 198 | } 199 | ui.dx(r.w + 0.01); 200 | } 201 | if self.page + 1 < self.total_page && ui.button("next_page", r, tl!("next-page")) { 202 | self.page += 1; 203 | self.scroll.y_scroller.set_offset(0.); 204 | self.refresh(state); 205 | } 206 | } 207 | }); 208 | ui.dy(r.h); 209 | let content_size = (state.content_size.0, state.content_size.1 - CHARTS_BAR_HEIGHT); 210 | SharedState::render_charts(ui, content_size, &mut self.scroll, &mut state.charts_online, Some(&self.extra)); 211 | if let Some((Some(_), id, _, rect, ..)) = &mut state.transit { 212 | let width = content_size.0; 213 | *rect = ui.rect_to_global(Rect::new( 214 | (*id % ROW_NUM) as f32 * width / ROW_NUM as f32, 215 | (*id / ROW_NUM) as f32 * CARD_HEIGHT - self.scroll.y_scroller.offset(), 216 | width / ROW_NUM as f32, 217 | CARD_HEIGHT, 218 | )); 219 | } 220 | Ok(()) 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /prpr-client/src/scene.rs: -------------------------------------------------------------------------------- 1 | mod main; 2 | pub use main::{MainScene, CHARTS_BAR_HEIGHT}; 3 | 4 | mod song; 5 | pub use song::SongScene; 6 | 7 | mod chart_order; 8 | pub use chart_order::{ChartOrder, ChartOrderBox}; 9 | -------------------------------------------------------------------------------- /prpr-client/src/scene/chart_order.rs: -------------------------------------------------------------------------------- 1 | prpr::tl_file!("chart_order"); 2 | 3 | use crate::page::ChartItem; 4 | use macroquad::prelude::*; 5 | use prpr::{ 6 | ext::{RectExt, SafeTexture}, 7 | ui::{RectButton, Ui}, 8 | }; 9 | 10 | use super::main::CHARTS_BAR_HEIGHT; 11 | 12 | pub enum ChartOrder { 13 | Default, 14 | Name, 15 | } 16 | 17 | impl ChartOrder { 18 | pub fn apply(&self, charts: &mut [ChartItem]) { 19 | self.apply_delegate(charts, |it| it) 20 | } 21 | 22 | pub fn apply_delegate(&self, charts: &mut [T], f: impl Fn(&T) -> &ChartItem) { 23 | match self { 24 | Self::Default => { 25 | charts.reverse(); 26 | } 27 | Self::Name => { 28 | charts.sort_by(|x, y| f(x).info.name.cmp(&f(y).info.name)); 29 | } 30 | } 31 | } 32 | } 33 | 34 | const ORDER_NUM: usize = 4; 35 | const ORDER_LABELS: [&str; ORDER_NUM] = ["time", "rev-time", "name", "rev-name"]; 36 | static ORDERS: [(ChartOrder, bool); ORDER_NUM] = [ 37 | (ChartOrder::Default, false), 38 | (ChartOrder::Default, true), 39 | (ChartOrder::Name, false), 40 | (ChartOrder::Name, true), 41 | ]; 42 | 43 | pub struct ChartOrderBox { 44 | icon_play: SafeTexture, 45 | button: RectButton, 46 | index: usize, 47 | } 48 | 49 | impl ChartOrderBox { 50 | pub fn new(icon_play: SafeTexture) -> Self { 51 | Self { 52 | icon_play, 53 | button: RectButton::new(), 54 | index: 0, 55 | } 56 | } 57 | 58 | pub fn touch(&mut self, touch: &Touch) -> bool { 59 | if self.button.touch(touch) { 60 | self.index += 1; 61 | if self.index == ORDER_NUM { 62 | self.index = 0; 63 | } 64 | return true; 65 | } 66 | false 67 | } 68 | 69 | pub fn render(&mut self, ui: &mut Ui) -> Rect { 70 | ui.scope(|ui| { 71 | let h = CHARTS_BAR_HEIGHT - 0.02; 72 | let r = Rect::new(0., 0.01, 0.25, h); 73 | self.button.set(ui, r); 74 | ui.fill_rect(r, Color::new(1., 1., 1., if self.button.touching() { 0.1 } else { 0.4 })); 75 | let icon = Rect::new(0.02, h / 2. + 0.01, 0., 0.).feather(0.02); 76 | ui.fill_rect(icon, (*self.icon_play, icon)); 77 | ui.dx(icon.w); 78 | ui.text(tl!(ORDER_LABELS[self.index])) 79 | .pos(0., h / 2. + 0.01) 80 | .anchor(0., 0.5) 81 | .no_baseline() 82 | .size(0.5) 83 | .draw(); 84 | Rect::new(0., 0., 0.25, CHARTS_BAR_HEIGHT) 85 | }) 86 | } 87 | 88 | pub fn to_order(&self) -> &'static (ChartOrder, bool) { 89 | &ORDERS[self.index] 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /prpr-player/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "prpr-player" 3 | version = "0.3.2" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | anyhow = "1.0" 8 | fastblur = "*" 9 | image = "*" 10 | macroquad = { git = "https://github.com/Mivik/prpr-macroquad", default-features = false } 11 | prpr = { path = "../prpr" } 12 | serde_yaml = "0.9" 13 | tokio = { version = "1.26", default-features = false } 14 | 15 | [target.'cfg(target_arch = "wasm32")'.dependencies] 16 | web-sys = "*" 17 | wasm-bindgen = "*" 18 | -------------------------------------------------------------------------------- /prpr-player/src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use macroquad::prelude::*; 3 | use prpr::{ 4 | build_conf, 5 | core::init_assets, 6 | fs, 7 | scene::{show_error, GameMode, LoadingScene, NextScene, Scene}, 8 | time::TimeManager, 9 | ui::{FontArc, TextPainter, Ui}, 10 | Main, 11 | }; 12 | use std::ops::DerefMut; 13 | 14 | struct BaseScene(Option, bool); 15 | impl Scene for BaseScene { 16 | fn on_result(&mut self, _tm: &mut TimeManager, result: Box) -> Result<()> { 17 | show_error(result.downcast::().unwrap().context("加载谱面失败")); 18 | self.1 = true; 19 | Ok(()) 20 | } 21 | fn enter(&mut self, _tm: &mut TimeManager, _target: Option) -> Result<()> { 22 | if self.0.is_none() && !self.1 { 23 | self.0 = Some(NextScene::Exit); 24 | } 25 | Ok(()) 26 | } 27 | fn update(&mut self, _tm: &mut TimeManager) -> Result<()> { 28 | Ok(()) 29 | } 30 | fn render(&mut self, _tm: &mut TimeManager, _ui: &mut Ui) -> Result<()> { 31 | Ok(()) 32 | } 33 | fn next_scene(&mut self, _tm: &mut TimeManager) -> prpr::scene::NextScene { 34 | self.0.take().unwrap_or_default() 35 | } 36 | } 37 | 38 | #[macroquad::main(build_conf)] 39 | async fn main() -> Result<()> { 40 | init_assets(); 41 | 42 | #[cfg(target_arch = "wasm32")] 43 | let (mut fs, config) = { 44 | fn js_err(err: wasm_bindgen::JsValue) -> anyhow::Error { 45 | anyhow::Error::msg(format!("{err:?}")) 46 | } 47 | let params = web_sys::UrlSearchParams::new_with_str(&web_sys::window().unwrap().location().search().map_err(js_err)?).map_err(js_err)?; 48 | let name = params.get("chart").unwrap_or_else(|| "nc".to_string()); 49 | ( 50 | fs::fs_from_assets(format!("charts/{name}/"))?, 51 | Some(prpr::config::Config { 52 | autoplay: false, 53 | ..Default::default() 54 | }), 55 | ) 56 | }; 57 | #[cfg(any(target_os = "android", target_os = "ios"))] 58 | let (mut fs, config) = (fs::fs_from_assets("charts/moment/")?, None); 59 | #[cfg(all(not(target_arch = "wasm32"), not(target_os = "android"), not(target_os = "ios")))] 60 | let (mut fs, config) = { 61 | let mut args = std::env::args(); 62 | let program = args.next().unwrap(); 63 | let Some(path) = args.next() else { 64 | anyhow::bail!("Usage: {program} "); 65 | }; 66 | let mut config = None; 67 | if let Some(config_path) = args.next() { 68 | config = Some(serde_yaml::from_str(&std::fs::read_to_string(config_path).context("Cannot read from config file")?)?); 69 | } 70 | (fs::fs_from_file(std::path::Path::new(&path))?, config) 71 | }; 72 | 73 | let _guard = { 74 | #[cfg(not(target_arch = "wasm32"))] 75 | { 76 | let rt = tokio::runtime::Builder::new_multi_thread() 77 | .worker_threads(4) 78 | .enable_all() 79 | .build() 80 | .unwrap(); 81 | let rt = Box::leak(Box::new(rt)); 82 | rt.enter() 83 | } 84 | #[cfg(target_arch = "wasm32")] 85 | { 86 | () 87 | } 88 | }; 89 | 90 | let font = FontArc::try_from_vec(load_file("font.ttf").await?)?; 91 | let mut painter = TextPainter::new(font); 92 | 93 | let info = fs::load_info(fs.deref_mut()).await?; 94 | let config = config.unwrap_or_default(); 95 | 96 | let mut fps_time = -1; 97 | 98 | let tm = TimeManager::default(); 99 | let ctm = TimeManager::from_config(&config); // strange variable name... 100 | let mut main = Main::new( 101 | Box::new(BaseScene( 102 | Some(NextScene::Overlay(Box::new(LoadingScene::new(GameMode::Normal, info, config, fs, (None, None), None, None).await?))), 103 | false, 104 | )), 105 | ctm, 106 | None, 107 | ) 108 | .await?; 109 | 'app: loop { 110 | let frame_start = tm.real_time(); 111 | main.update()?; 112 | main.render(&mut Ui::new(&mut painter))?; 113 | if main.should_exit() { 114 | break 'app; 115 | } 116 | 117 | let t = tm.real_time(); 118 | let fps_now = t as i32; 119 | if fps_now != fps_time { 120 | fps_time = fps_now; 121 | info!("| {}", (1. / (t - frame_start)) as u32); 122 | } 123 | 124 | next_frame().await; 125 | } 126 | Ok(()) 127 | } 128 | -------------------------------------------------------------------------------- /prpr-render/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "prpr-render" 3 | version = "0.3.2" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | anyhow = "1.0" 8 | macroquad = { git = "https://github.com/Mivik/prpr-macroquad", default-features = false } 9 | prpr = { path = "../prpr" } 10 | serde_yaml = "0.9" 11 | tokio = "*" 12 | sasa = { git = "https://github.com/Mivik/sasa" } 13 | -------------------------------------------------------------------------------- /prpr-render/src/scene.rs: -------------------------------------------------------------------------------- 1 | use crate::{VideoConfig, INFO_EDIT, VIDEO_CONFIG}; 2 | use anyhow::{bail, Result}; 3 | use macroquad::prelude::*; 4 | use prpr::{ 5 | config::Config, 6 | ext::{poll_future, screen_aspect, LocalTask}, 7 | fs::{FileSystem, PatchedFileSystem}, 8 | info::ChartInfo, 9 | scene::{show_error, show_message, GameMode, LoadingScene, NextScene, Scene}, 10 | time::TimeManager, 11 | ui::{render_chart_info, ChartInfoEdit, Scroll, Ui}, 12 | }; 13 | 14 | pub struct MainScene { 15 | target: Option, 16 | 17 | scroll: Scroll, 18 | edit: ChartInfoEdit, 19 | config: Config, 20 | fs: Box, 21 | next_scene: Option, 22 | v_config: VideoConfig, 23 | 24 | loading_scene_task: LocalTask>, 25 | } 26 | 27 | impl MainScene { 28 | pub fn new(target: Option, info: ChartInfo, config: Config, fs: Box) -> Self { 29 | Self { 30 | target, 31 | 32 | scroll: Scroll::new(), 33 | edit: ChartInfoEdit::new(info), 34 | config, 35 | fs, 36 | next_scene: None, 37 | v_config: VideoConfig::default(), 38 | 39 | loading_scene_task: None, 40 | } 41 | } 42 | } 43 | 44 | impl Scene for MainScene { 45 | fn on_result(&mut self, _tm: &mut TimeManager, result: Box) -> Result<()> { 46 | show_error(result.downcast::().unwrap().context("加载谱面失败")); 47 | Ok(()) 48 | } 49 | 50 | fn touch(&mut self, tm: &mut TimeManager, touch: &Touch) -> Result { 51 | Ok(self.scroll.touch(touch, tm.now() as _)) 52 | } 53 | 54 | fn update(&mut self, tm: &mut TimeManager) -> Result<()> { 55 | self.scroll.update(tm.now() as _); 56 | if let Some(future) = &mut self.loading_scene_task { 57 | if let Some(scene) = poll_future(future.as_mut()) { 58 | self.loading_scene_task = None; 59 | self.next_scene = Some(NextScene::Overlay(Box::new(scene?))); 60 | } 61 | } 62 | Ok(()) 63 | } 64 | 65 | fn render(&mut self, _tm: &mut TimeManager, ui: &mut Ui) -> Result<()> { 66 | set_camera(&Camera2D { 67 | zoom: vec2(1., -screen_aspect()), 68 | render_target: self.target, 69 | ..Default::default() 70 | }); 71 | clear_background(GRAY); 72 | let width = 1.; 73 | ui.scope(|ui| { 74 | ui.dx(-1.); 75 | ui.dy(-ui.top); 76 | let h = 0.1; 77 | let pad = 0.01; 78 | self.scroll.size((width, ui.top * 2. - h)); 79 | let dx = width / 2.; 80 | let mut r = Rect::new(pad, ui.top * 2. - h + pad, dx - pad * 2., h - pad * 2.); 81 | if ui.button("preview", r, "预览") { 82 | let info = self.edit.info.clone(); 83 | let config = self.config.clone(); 84 | let fs = self.fs.clone_box(); 85 | let edit = self.edit.clone(); 86 | self.loading_scene_task = Some(Box::pin(async move { 87 | LoadingScene::new( 88 | GameMode::Normal, 89 | info, 90 | config, 91 | Box::new(PatchedFileSystem(fs, edit.to_patches().await?)), 92 | (None, None), 93 | None, 94 | None, 95 | ) 96 | .await 97 | })); 98 | } 99 | r.x += dx; 100 | if ui.button("render", r, "渲染") { 101 | *INFO_EDIT.lock().unwrap() = Some(self.edit.clone()); 102 | *VIDEO_CONFIG.lock().unwrap() = Some(self.v_config.clone()); 103 | self.next_scene = Some(NextScene::Exit); 104 | } 105 | self.scroll.render(ui, |ui| { 106 | ui.dy(pad); 107 | let r = ui.text("注:可以通过鼠标拖动屏幕来查看更下面的配置项").size(0.4).draw(); 108 | ui.dy(r.h + pad); 109 | let (w, mut h) = render_chart_info(ui, &mut self.edit, width); 110 | ui.scope(|ui| { 111 | ui.dy(h); 112 | h += r.h + pad * 2.; 113 | let width = ui.text("一二三四").size(0.4).measure().w; 114 | ui.dx(width); 115 | let res = self.v_config.resolution; 116 | let mut string = format!("{}x{}", res.0, res.1); 117 | let r = ui.input("分辨率", &mut string, 0.8); 118 | match || -> Result<(u32, u32)> { 119 | if let Some((w, h)) = string.split_once(['x', 'X', '×', '*']) { 120 | Ok((w.parse::()?, h.parse::()?)) 121 | } else { 122 | bail!("格式应当为 “宽x高”") 123 | } 124 | }() { 125 | Err(_) => { 126 | show_message("输入非法"); 127 | } 128 | Ok(value) => { 129 | self.v_config.resolution = value; 130 | } 131 | } 132 | ui.dy(r.h + pad); 133 | h += r.h + pad; 134 | 135 | let mut string = self.v_config.fps.to_string(); 136 | let old = string.clone(); 137 | let r = ui.input("FPS", &mut string, 0.8); 138 | if string != old { 139 | match string.parse::() { 140 | Err(_) => { 141 | show_message("输入非法"); 142 | } 143 | Ok(value) => { 144 | self.v_config.fps = value; 145 | } 146 | } 147 | } 148 | ui.dy(r.h + pad); 149 | h += r.h + pad; 150 | 151 | let r = ui.input("码率", &mut self.v_config.bitrate, 0.8); 152 | ui.dy(r.h + pad); 153 | h += r.h + pad; 154 | 155 | let mut string = format!("{:.2}", self.v_config.ending_length); 156 | let old = string.clone(); 157 | let r = ui.input("结算时间", &mut string, 0.8); 158 | if string != old { 159 | match string.parse::() { 160 | Err(_) => { 161 | show_message("输入非法"); 162 | } 163 | Ok(value) => { 164 | if !value.is_finite() || value < 0. { 165 | show_message("输入非法"); 166 | } 167 | self.v_config.ending_length = value; 168 | } 169 | } 170 | } 171 | ui.dy(r.h + pad); 172 | h += r.h + pad; 173 | 174 | let r = ui.checkbox("启用硬件加速", &mut self.v_config.hardware_accel); 175 | ui.dy(r.h + pad); 176 | h += r.h + pad; 177 | }); 178 | (w, h) 179 | }); 180 | }); 181 | Ok(()) 182 | } 183 | 184 | fn next_scene(&mut self, _tm: &mut TimeManager) -> NextScene { 185 | self.next_scene.take().unwrap_or_default() 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /prpr/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "prpr" 3 | version = "0.3.2" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["lib", "cdylib"] 8 | 9 | [features] 10 | closed = [] 11 | 12 | [dependencies] 13 | anyhow = "1.0" 14 | async-trait = "0.1" 15 | base64 = "0.21.0" 16 | cfg-if = "1.0.0" 17 | chardetng = "0.1.17" 18 | chrono = "0.4.23" 19 | concat-string = "1.0.1" 20 | csv = "1.1.6" 21 | fastblur = "0.1.1" 22 | fluent = "0.16.0" 23 | fluent-syntax = "0.11.0" 24 | glyph_brush = "0.7.5" 25 | image = "0.24" 26 | intl-memoizer = "0.5.1" 27 | lru = "0.9.0" 28 | lyon = "1.0.1" 29 | macroquad = { git = "https://github.com/Mivik/prpr-macroquad", default-features = false } 30 | miniquad = { git = "https://github.com/Mivik/prpr-miniquad" } 31 | nalgebra = "*" 32 | once_cell = "1.16.0" 33 | ordered-float = "3.4.0" 34 | phf = { version = "0.11.1", features = ["macros"] } 35 | rand = "0.8.5" 36 | rayon = "=1.6.0" 37 | regex = "1.7.0" 38 | serde = { version = "1.0", features = ["derive"] } 39 | serde_json = "1.0" 40 | serde_yaml = "0.9" 41 | symphonia = { version = "0.5", features = ["flac", "mp3", "ogg", "vorbis", "wav", "pcm"] } 42 | sys-locale = "0.2.3" 43 | tempfile = "3.3.0" 44 | unic-langid = { version = "0.9.1", features = ["macros"] } 45 | zip = { version = "0.6.3", default-features = false, features = ["deflate"] } 46 | 47 | hmac = "0.12.1" 48 | miniz_oxide = "0.7.1" 49 | obfstr = "0.4.1" 50 | sha2 = "0.10.6" 51 | subtle = "2.4.1" 52 | 53 | [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] 54 | rfd = "0.10" 55 | 56 | [target.'cfg(target_os = "ios")'.dependencies] 57 | objc = "*" 58 | objc-foundation = "0.1.1" 59 | objc_id = "*" 60 | block = "0.1.6" 61 | 62 | [target.'cfg(target_os = "android")'.dependencies] 63 | ndk-context = "0.1" 64 | sasa = { git = "https://github.com/Mivik/sasa", default-features = false, features = ["oboe"] } 65 | 66 | [target.'cfg(not(target_os = "android"))'.dependencies] 67 | sasa = { git = "https://github.com/Mivik/sasa" } 68 | 69 | [target.'cfg(not(target_arch = "wasm32"))'.dependencies] 70 | tokio = { version = "1.23", features = ["rt-multi-thread", "fs"] } 71 | 72 | [target.'cfg(target_arch = "wasm32")'.dependencies] 73 | tokio = { version = "1.23", default-features = false } 74 | web-sys = { version = "0.3", features = [ 75 | "Location", 76 | "Performance", 77 | "UrlSearchParams", 78 | "Window", 79 | ] } 80 | js-sys = "*" 81 | wasm-bindgen = "*" 82 | wasm-bindgen-futures = "0.4" 83 | 84 | [build-dependencies] 85 | walkdir = "2.3.2" 86 | -------------------------------------------------------------------------------- /prpr/locales/en-US/chart_info.ftl: -------------------------------------------------------------------------------- 1 | edit-chart = Edit beatmap 2 | level-displayed = Level 3 | chart-name = Name 4 | author = Map maker 5 | composer = Composer 6 | illustrator = Illustrator 7 | diff = Difficulty 8 | preview-time = Preview start 9 | offset = Offset(s) 10 | aspect-ratio = Aspect ratio 11 | ps = P.S. 12 | aspect-hint = Aspect ratio can be either real number or texts like "w:h" 13 | dim = Background dim 14 | chart-file = Beatmap 15 | music-file = Music 16 | illu-file = Illustration 17 | tip = Tip 18 | intro = Introduction 19 | tags = Tags 20 | tag-exists = Tag already exists 21 | 22 | illegal-input = Illegal input 23 | -------------------------------------------------------------------------------- /prpr/locales/en-US/dialog.ftl: -------------------------------------------------------------------------------- 1 | notice = Notice 2 | ok = OK 3 | 4 | error = Error 5 | error-copy = Copy error 6 | error-copied = Copied 7 | -------------------------------------------------------------------------------- /prpr/locales/en-US/ending.ftl: -------------------------------------------------------------------------------- 1 | uploading = Uploading result… 2 | uploaded = Result uploaded 3 | upload-failed = Failed to upload 4 | upload-cancel = Cancel upload 5 | upload-retry = Retry 6 | 7 | still-uploading = Uploading result, please wait… 8 | -------------------------------------------------------------------------------- /prpr/locales/en-US/game.ftl: -------------------------------------------------------------------------------- 1 | to = to 2 | adjust-offset = Adjust offset 3 | offset-cancel = Cancel 4 | offset-reset = Reset 5 | offset-save = Save 6 | 7 | ex-time-out-of-range = Time is out of range 8 | ex-invalid-format = Invalid format 9 | ex-time-set = Time changed 10 | -------------------------------------------------------------------------------- /prpr/locales/en-US/scene.ftl: -------------------------------------------------------------------------------- 1 | input = Input 2 | input-msg = Please input text 3 | input-hint = text 4 | 5 | read-file-failed = Failed to read file 6 | pasted = Pasted from clipboard 7 | -------------------------------------------------------------------------------- /prpr/locales/zh-CN/chart_info.ftl: -------------------------------------------------------------------------------- 1 | edit-chart = 编辑谱面 2 | level-displayed = 显示难度 3 | chart-name = 谱面名 4 | author = 作者 5 | composer = 曲师 6 | illustrator = 画师 7 | diff = 难度 8 | preview-time = 预览时间 9 | offset = 偏移(s) 10 | aspect-ratio = 宽高比 11 | ps = 注: 12 | aspect-hint = 宽高比可以直接填小数,也可以是 w:h 的形式(英文半角冒号) 13 | dim = 背景昏暗 14 | chart-file = 谱面文件 15 | music-file = 音乐文件 16 | illu-file = 插图文件 17 | tip = Tip 18 | intro = 简介 19 | tags = 标签 20 | tag-exists = 标签已存在 21 | 22 | illegal-input = 输入非法 23 | -------------------------------------------------------------------------------- /prpr/locales/zh-CN/dialog.ftl: -------------------------------------------------------------------------------- 1 | notice = 提示 2 | ok = 确定 3 | 4 | error = 错误 5 | error-copy = 复制错误详情 6 | error-copied = 复制成功 7 | -------------------------------------------------------------------------------- /prpr/locales/zh-CN/ending.ftl: -------------------------------------------------------------------------------- 1 | uploading = 成绩上传中 2 | uploaded = 成绩上传成功 3 | upload-failed = 成绩上传失败 4 | upload-cancel = 取消上传 5 | upload-retry = 重试 6 | 7 | still-uploading = 尚在上传成绩 8 | -------------------------------------------------------------------------------- /prpr/locales/zh-CN/game.ftl: -------------------------------------------------------------------------------- 1 | to = 至 2 | adjust-offset = 调整延迟 3 | offset-cancel = 取消 4 | offset-reset = 重置 5 | offset-save = 保存 6 | 7 | ex-time-out-of-range = 时间不在范围内 8 | ex-invalid-format = 格式有误 9 | ex-time-set = 设置成功 10 | -------------------------------------------------------------------------------- /prpr/locales/zh-CN/scene.ftl: -------------------------------------------------------------------------------- 1 | input = 输入 2 | input-msg = 请输入文字 3 | input-hint = 文字 4 | 5 | read-file-failed = 读取文件失败 6 | pasted = 从剪贴板加载成功 7 | -------------------------------------------------------------------------------- /prpr/src/config.rs: -------------------------------------------------------------------------------- 1 | use once_cell::sync::Lazy; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | pub static TIPS: Lazy> = Lazy::new(|| include_str!("tips.txt").split('\n').map(str::to_owned).collect()); 5 | 6 | #[derive(Clone, Deserialize, Serialize)] 7 | #[serde(rename_all = "camelCase")] 8 | pub enum ChallengeModeColor { 9 | White, 10 | Green, 11 | Blue, 12 | Red, 13 | Golden, 14 | Rainbow, 15 | } 16 | 17 | #[derive(Clone, Deserialize, Serialize)] 18 | #[serde(default)] 19 | #[serde(rename_all = "camelCase")] 20 | pub struct Config { 21 | pub adjust_time: bool, 22 | pub aggressive: bool, 23 | pub aspect_ratio: Option, 24 | pub audio_buffer_size: Option, 25 | pub autoplay: bool, 26 | pub challenge_color: ChallengeModeColor, 27 | pub challenge_rank: u32, 28 | pub debug: bool, 29 | pub disable_effect: bool, 30 | pub double_click_to_pause: bool, 31 | pub fix_aspect_ratio: bool, 32 | pub fxaa: bool, 33 | pub interactive: bool, 34 | pub multiple_hint: bool, 35 | pub note_scale: f32, 36 | pub offset: f32, 37 | pub particle: bool, 38 | pub player_name: String, 39 | pub player_rks: f32, 40 | pub sample_count: u32, 41 | pub res_pack_path: Option, 42 | pub speed: f32, 43 | pub volume_music: f32, 44 | pub volume_sfx: f32, 45 | } 46 | 47 | impl Default for Config { 48 | fn default() -> Self { 49 | Self { 50 | adjust_time: true, 51 | aggressive: true, 52 | aspect_ratio: None, 53 | audio_buffer_size: None, 54 | autoplay: false, 55 | challenge_color: ChallengeModeColor::Golden, 56 | challenge_rank: 45, 57 | debug: false, 58 | disable_effect: false, 59 | double_click_to_pause: true, 60 | fix_aspect_ratio: false, 61 | fxaa: false, 62 | interactive: true, 63 | multiple_hint: true, 64 | note_scale: 1.0, 65 | offset: 0., 66 | res_pack_path: None, 67 | particle: true, 68 | player_name: "Mivik".to_string(), 69 | player_rks: 15., 70 | sample_count: 4, 71 | speed: 1., 72 | volume_music: 1., 73 | volume_sfx: 1., 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /prpr/src/core.rs: -------------------------------------------------------------------------------- 1 | pub use macroquad::color::Color; 2 | 3 | pub const NOTE_WIDTH_RATIO_BASE: f32 = 0.13175016; 4 | pub const HEIGHT_RATIO: f32 = 0.83175; 5 | 6 | pub const EPS: f32 = 1e-5; 7 | 8 | pub const JUDGE_LINE_PERFECT_COLOR: Color = Color::new(1., 0.921875, 0.623, 0.8823529); 9 | pub const JUDGE_LINE_GOOD_COLOR: Color = Color::new(0.7058823, 0.8823529, 1., 0.9215686); 10 | 11 | pub type Point = nalgebra::Point2; 12 | pub type Vector = nalgebra::Vector2; 13 | pub type Matrix = nalgebra::Matrix3; 14 | 15 | mod anim; 16 | pub use anim::{Anim, AnimFloat, AnimVector, Keyframe}; 17 | 18 | mod chart; 19 | pub use chart::{Chart, ChartExtra, ChartSettings}; 20 | 21 | mod effect; 22 | pub use effect::{Effect, Uniform}; 23 | 24 | mod line; 25 | pub use line::{JudgeLine, JudgeLineCache, JudgeLineKind, UIElement}; 26 | 27 | mod note; 28 | use macroquad::prelude::set_pc_assets_folder; 29 | pub use note::{BadNote, Note, NoteKind, RenderConfig}; 30 | 31 | mod object; 32 | pub use object::{CtrlObject, Object}; 33 | 34 | mod render; 35 | pub use render::{copy_fbo, MSRenderTarget}; 36 | 37 | mod resource; 38 | pub use resource::{ParticleEmitter, Resource, ResourcePack, DPI_VALUE}; 39 | 40 | mod tween; 41 | pub use tween::{easing_from, BezierTween, ClampedTween, StaticTween, TweenFunction, TweenId, TweenMajor, TweenMinor, Tweenable, TWEEN_FUNCTIONS}; 42 | 43 | mod video; 44 | pub use video::Video; 45 | 46 | pub fn init_assets() { 47 | if let Ok(mut exe) = std::env::current_exe() { 48 | while exe.pop() { 49 | if exe.join("assets").exists() { 50 | std::env::set_current_dir(exe).unwrap(); 51 | break; 52 | } 53 | } 54 | } 55 | set_pc_assets_folder("assets"); 56 | } 57 | 58 | #[derive(serde::Deserialize)] 59 | pub struct Triple(i32, u32, u32); 60 | impl Default for Triple { 61 | fn default() -> Self { 62 | Self(0, 0, 1) 63 | } 64 | } 65 | 66 | impl Triple { 67 | pub fn beats(&self) -> f32 { 68 | self.0 as f32 + self.1 as f32 / self.2 as f32 69 | } 70 | } 71 | 72 | #[derive(Default)] // the default is a dummy 73 | pub struct BpmList { 74 | elements: Vec<(f32, f32, f32)>, // (beats, time, bpm) 75 | cursor: usize, 76 | } 77 | 78 | impl BpmList { 79 | pub fn new(ranges: Vec<(f32, f32)> /*(beat, bpm)*/) -> Self { 80 | let mut elements = Vec::new(); 81 | let mut time = 0.0; 82 | let mut last_beats = 0.0; 83 | let mut last_bpm: Option = None; 84 | for (now_beats, bpm) in ranges { 85 | if let Some(bpm) = last_bpm { 86 | time += (now_beats - last_beats) * (60. / bpm); 87 | } 88 | last_beats = now_beats; 89 | last_bpm = Some(bpm); 90 | elements.push((now_beats, time, bpm)); 91 | } 92 | BpmList { elements, cursor: 0 } 93 | } 94 | 95 | pub fn time_beats(&mut self, beats: f32) -> f32 { 96 | while let Some(kf) = self.elements.get(self.cursor + 1) { 97 | if kf.0 > beats { 98 | break; 99 | } 100 | self.cursor += 1; 101 | } 102 | while self.cursor != 0 && self.elements[self.cursor].0 > beats { 103 | self.cursor -= 1; 104 | } 105 | let (start_beats, time, bpm) = &self.elements[self.cursor]; 106 | time + (beats - start_beats) * (60. / bpm) 107 | } 108 | 109 | pub fn time(&mut self, triple: &Triple) -> f32 { 110 | self.time_beats(triple.beats()) 111 | } 112 | 113 | pub fn beat(&mut self, time: f32) -> f32 { 114 | while let Some(kf) = self.elements.get(self.cursor + 1) { 115 | if kf.1 > time { 116 | break; 117 | } 118 | self.cursor += 1; 119 | } 120 | while self.cursor != 0 && self.elements[self.cursor].1 > time { 121 | self.cursor -= 1; 122 | } 123 | let (beats, start_time, bpm) = &self.elements[self.cursor]; 124 | beats + (time - start_time) / (60. / bpm) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /prpr/src/core/anim.rs: -------------------------------------------------------------------------------- 1 | use super::{StaticTween, TweenFunction, TweenId, Tweenable, Vector}; 2 | use std::rc::Rc; 3 | 4 | #[derive(Clone)] 5 | pub struct Keyframe { 6 | pub time: f32, 7 | pub value: T, 8 | pub tween: Rc, 9 | } 10 | 11 | impl Keyframe { 12 | pub fn new(time: f32, value: T, tween: TweenId) -> Self { 13 | Self { 14 | time, 15 | value, 16 | tween: StaticTween::get_rc(tween), 17 | } 18 | } 19 | } 20 | 21 | #[derive(Clone)] 22 | pub struct Anim { 23 | pub time: f32, 24 | pub keyframes: Box<[Keyframe]>, 25 | pub cursor: usize, 26 | pub next: Option>>, 27 | } 28 | 29 | impl Default for Anim { 30 | fn default() -> Self { 31 | Self { 32 | time: 0.0, 33 | keyframes: [].into(), 34 | cursor: 0, 35 | next: None, 36 | } 37 | } 38 | } 39 | 40 | impl Anim { 41 | pub fn new(keyframes: Vec>) -> Self { 42 | assert!(!keyframes.is_empty()); 43 | // assert_eq!(keyframes[0].time, 0.0); 44 | // assert_eq!(keyframes.last().unwrap().tween, 0); 45 | Self { 46 | keyframes: keyframes.into_boxed_slice(), 47 | time: 0.0, 48 | cursor: 0, 49 | next: None, 50 | } 51 | } 52 | 53 | pub fn fixed(value: T) -> Self { 54 | Self { 55 | keyframes: Box::new([Keyframe::new(0.0, value, 0)]), 56 | time: 0.0, 57 | cursor: 0, 58 | next: None, 59 | } 60 | } 61 | 62 | pub fn is_default(&self) -> bool { 63 | self.keyframes.is_empty() && self.next.is_none() 64 | } 65 | 66 | pub fn chain(elements: Vec>) -> Self { 67 | if elements.is_empty() { 68 | return Self::default(); 69 | } 70 | let mut elements: Vec<_> = elements.into_iter().map(Box::new).collect(); 71 | elements.last_mut().unwrap().next = None; 72 | while elements.len() > 1 { 73 | let last = elements.pop().unwrap(); 74 | elements.last_mut().unwrap().next = Some(last); 75 | } 76 | *elements.into_iter().next().unwrap() 77 | } 78 | 79 | pub fn dead(&self) -> bool { 80 | self.cursor + 1 >= self.keyframes.len() 81 | } 82 | 83 | pub fn set_time(&mut self, time: f32) { 84 | if self.keyframes.is_empty() || time == self.time { 85 | self.time = time; 86 | return; 87 | } 88 | while let Some(kf) = self.keyframes.get(self.cursor + 1) { 89 | if kf.time > time { 90 | break; 91 | } 92 | self.cursor += 1; 93 | } 94 | while self.cursor != 0 && self.keyframes[self.cursor].time > time { 95 | self.cursor -= 1; 96 | } 97 | self.time = time; 98 | if let Some(next) = &mut self.next { 99 | next.set_time(time); 100 | } 101 | } 102 | 103 | fn now_opt_inner(&self) -> Option { 104 | if self.keyframes.is_empty() { 105 | return None; 106 | } 107 | Some(if self.cursor == self.keyframes.len() - 1 { 108 | self.keyframes[self.cursor].value.clone() 109 | } else { 110 | let kf1 = &self.keyframes[self.cursor]; 111 | let kf2 = &self.keyframes[self.cursor + 1]; 112 | let t = (self.time - kf1.time) / (kf2.time - kf1.time); 113 | T::tween(&kf1.value, &kf2.value, kf1.tween.y(t)) 114 | }) 115 | } 116 | 117 | pub fn now_opt(&self) -> Option { 118 | let Some(now) = self.now_opt_inner() else { 119 | return None; 120 | }; 121 | Some(if let Some(next) = &self.next { 122 | T::add(&now, &next.now_opt().unwrap()) 123 | } else { 124 | now 125 | }) 126 | } 127 | 128 | pub fn map_value(&mut self, mut f: impl FnMut(T) -> T) { 129 | self.keyframes.iter_mut().for_each(|it| it.value = f(it.value.clone())); 130 | if let Some(next) = &mut self.next { 131 | next.map_value(f); 132 | } 133 | } 134 | } 135 | 136 | impl Anim { 137 | pub fn now(&self) -> T { 138 | self.now_opt().unwrap_or_default() 139 | } 140 | } 141 | 142 | pub type AnimFloat = Anim; 143 | #[derive(Default)] 144 | pub struct AnimVector(pub AnimFloat, pub AnimFloat); 145 | 146 | impl AnimVector { 147 | pub fn fixed(v: Vector) -> Self { 148 | Self(AnimFloat::fixed(v.x), AnimFloat::fixed(v.y)) 149 | } 150 | 151 | pub fn set_time(&mut self, time: f32) { 152 | self.0.set_time(time); 153 | self.1.set_time(time); 154 | } 155 | 156 | pub fn now(&self) -> Vector { 157 | Vector::new(self.0.now(), self.1.now()) 158 | } 159 | 160 | pub fn now_with_def(&self, x: f32, y: f32) -> Vector { 161 | Vector::new(self.0.now_opt().unwrap_or(x), self.1.now_opt().unwrap_or(y)) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /prpr/src/core/chart.rs: -------------------------------------------------------------------------------- 1 | use super::{BpmList, Effect, JudgeLine, Matrix, Resource, UIElement, Vector, Video}; 2 | use crate::{judge::JudgeStatus, ui::Ui}; 3 | use macroquad::prelude::*; 4 | use std::cell::RefCell; 5 | 6 | #[derive(Default)] 7 | pub struct ChartExtra { 8 | pub effects: Vec, 9 | pub global_effects: Vec, 10 | pub videos: Vec