├── .github ├── FUNDING.yml └── workflows │ └── webpack.yml ├── .gitignore ├── LICENSE ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── public ├── assets │ ├── Drag.png │ ├── DragHL.png │ ├── Flick.png │ ├── FlickHL.png │ ├── Hold.png │ ├── HoldEnd.png │ ├── HoldHL.png │ ├── HoldHead.png │ ├── HoldHeadHL.png │ ├── JudgeLine.png │ ├── Tap.png │ ├── TapHL.png │ ├── clickRaw128.png │ ├── pauseButton.png │ └── sounds │ │ ├── Hitsound-Drag.ogg │ │ ├── Hitsound-Flick.ogg │ │ ├── Hitsound-Tap.ogg │ │ └── result │ │ ├── at.ogg │ │ ├── ez.ogg │ │ ├── hd.ogg │ │ ├── in.ogg │ │ ├── sp.ogg │ │ └── sp_glitch.ogg ├── icons │ ├── 192.png │ ├── 64.png │ ├── afdian.png │ ├── favicon.ico │ ├── github.png │ └── patreon.png └── skin.example.zip ├── src ├── audio │ ├── clock.js │ ├── index.js │ ├── timer.js │ └── unmute.js ├── chart │ ├── convert │ │ ├── index.js │ │ ├── official.js │ │ ├── phiedit.js │ │ ├── rephiedit.js │ │ └── utils.js │ ├── eventlayer.js │ ├── index.js │ ├── judgeline.js │ └── note.js ├── effect │ ├── index.js │ ├── reader │ │ ├── index.js │ │ └── prpr.js │ └── shader │ │ ├── index.js │ │ └── presets │ │ ├── chromatic.glsl │ │ ├── circle_blur.glsl │ │ ├── fisheye.glsl │ │ ├── glitch.glsl │ │ ├── grayscale.glsl │ │ ├── index.js │ │ ├── noise.glsl │ │ ├── pixel.glsl │ │ ├── radial_blur.glsl │ │ ├── shockwave.glsl │ │ └── vignette.glsl ├── game │ ├── callback.js │ ├── index.js │ └── ticker.js ├── index.js ├── judgement │ ├── index.js │ ├── input │ │ ├── callback.js │ │ ├── index.js │ │ └── point.js │ ├── point.js │ └── score.js ├── main.js ├── phizone │ └── index.js ├── style │ ├── fonts │ │ ├── A-OTF_Shin_Go_Pr6N_H.ttf │ │ ├── MiSans-Regular.ttf │ │ └── index.css │ └── index.css └── verify.js └── vite.config.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | patreon: 'HIMlaoS_Misa' 4 | custom: [ 'https://afdian.net/a/MisaLiu' ] 5 | -------------------------------------------------------------------------------- /.github/workflows/webpack.yml: -------------------------------------------------------------------------------- 1 | name: Build test page to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | workflow_dispatch: 7 | inputs: 8 | tags: 9 | description: 'Run workflow manually' 10 | 11 | permissions: 12 | contents: read 13 | pages: write 14 | id-token: write 15 | 16 | jobs: 17 | deploy: 18 | environment: 19 | name: github-pages 20 | url: ${{ steps.deployment.outputs.page_url }} 21 | 22 | runs-on: ubuntu-latest 23 | 24 | strategy: 25 | matrix: 26 | node-version: [16.x] 27 | 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v3 31 | 32 | - name: Use Node.js ${{ matrix.node-version }} 33 | uses: actions/setup-node@v3 34 | with: 35 | node-version: ${{ matrix.node-version }} 36 | 37 | - name: Setup Pages 38 | uses: actions/configure-pages@v2 39 | 40 | - name: Build webpack 41 | run: | 42 | npm install 43 | npm run build 44 | 45 | - name: Upload sourcemap to Sentry 46 | uses: getsentry/action-release@v1 47 | env: 48 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} 49 | SENTRY_ORG: ${{ secrets.SENTRY_ORG }} 50 | SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} 51 | with: 52 | environment: production 53 | version: ${{ github.sha }} 54 | sourcemaps: './dist ./dist/assets' 55 | set_commits: skip 56 | ignore_missing: true 57 | 58 | - name: Upload artifact 59 | uses: actions/upload-pages-artifact@v1 60 | with: 61 | path: './dist' 62 | 63 | - name: Deploy to GitHub Pages 64 | id: deployment 65 | uses: actions/deploy-pages@v1 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.vscode 3 | /dist 4 | .sentryclirc -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # **This project is now deprecated** 2 | 3 | Read [this](https://github.com/MisaLiu/phi-chart-render/issues/19) for more infomation (Chinese). 4 | 5 | # Phi-Chart-Render 6 | 7 | A *Phigros* chart render based on [Pixi.js](https://pixijs.com) 8 | 9 | This project is still working. 10 | 11 | ## Currently supported chart features 12 | 13 | * Official charts 14 | * [x] Basic support 15 | * [x] Custom judgeline texture *(Need test)* 16 | 17 | * PhiEdit charts 18 | * [x] Basic support 19 | * [x] BPM List *(Need test)* 20 | * [x] Custom judgeline texture *(Need test)* 21 | * [x] Negative alpha *(Need test)* 22 | * note features 23 | * [x] Basic support 24 | * [x] Fake note support 25 | * [x] Note scale support 26 | 27 | * Re:PhiEdit charts 28 | * [x] BPM List *(Need test)* 29 | * [x] Event Layers support *(Need test)* 30 | * [x] Custom judgeline texture *(Need test)* 31 | * [x] Judgeline cover type 32 | * [x] Parent judgeline 33 | * [x] Easing start/end point 34 | * [x] judgeline z order *(Need test)* 35 | * [x] Bezier event easing *(Need test)* 36 | * [ ] Event link group *(?)* 37 | * [ ] BPM factor *(?)* 38 | * [ ] ~~attachUI (wont support)~~ 39 | * Extend events 40 | * [x] Scale X 41 | * [x] Scale Y 42 | * [x] Text 43 | * [x] Color 44 | * [x] Incline 45 | * [ ] ~~Draw (wont support)~~ 46 | * Note controls *(Need test)* 47 | * [x] Alpha 48 | * [x] Scale 49 | * [ ] Skew 50 | * [x] X 51 | * [ ] Y 52 | * note features 53 | * [x] Basic support 54 | * [x] Fake note support 55 | * [x] Note scale 56 | * [x] Note alpha 57 | * [x] yOffset 58 | * [x] visible time 59 | 60 | * prpr features 61 | * [x] Shaders *(Need test)* 62 | * [ ] Videos 63 | 64 | ## Development 65 | 66 | You must have a Node.js enviorment to helping development. 67 | 68 | 1. `git clone https://github.com/MisaLiu/phi-chart-render` 69 | 2. `npm install` 70 | 3. `npm run dev` 71 | 72 | ## Thanks 73 | 74 | * [pixijs](https://github.com/pixijs/pixijs) 75 | * [@lchzh3473](https://github.com/lchzh3473) 76 | * [@IcedDog](https://github.com/IcedDog) 77 | * [@luch4736](https://github.com/luch4736) 78 | * [@Naptie](https://github.com/Naptie) 79 | * [@Greenball233](https://github.com/Greenball233) 80 | * [@inokana](https://github.com/GBTP) 81 | * [@totorowldox](https://github.com/totorowldox) 82 | * [osugame.online](http://osugame.online/) 83 | * [bemuse.ninja](https://bemuse.ninja/) 84 | * [All contributors](https://github.com/MisaLiu/phi-chart-render/graphs/contributors) 85 | * And you 86 | 87 | ## License 88 | ``` 89 | phi-chart-render - A Phigros chart render based on Pixi.js 90 | Copyright (C) 2022 HIMlaoS_Misa 91 | 92 | This program is free software: you can redistribute it and/or modify 93 | it under the terms of the GNU General Public License as published by 94 | the Free Software Foundation, either version 3 of the License, or 95 | (at your option) any later version. 96 | 97 | This program is distributed in the hope that it will be useful, 98 | but WITHOUT ANY WARRANTY; without even the implied warranty of 99 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 100 | GNU General Public License for more details. 101 | 102 | You should have received a copy of the GNU General Public License 103 | along with this program. If not, see . 104 | ``` 105 | 106 | All assets used in this project are licensed under [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) 107 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | phi-chart-render 5 | 6 | 7 | 8 | 9 | 10 | 11 | 13 | 141 | 142 | 143 |
144 |
145 | For iOS users:
146 | Please use Quark Browser for better experience (not ad) . 147 |
148 |
149 |
150 | Due to some performance issue on FireFox Android, consider using Kiwi Browser or Via or Google Chrome. 151 |
152 |
153 | 致中国大陆用户的公告 154 |
155 |
Loading scripts...
156 |
157 |
158 |
159 |
File
160 |
Visual
161 |
Sound
162 |
Other
163 |
PhiZone
164 |
165 |
166 |
167 |
168 |
Please wait until assets loaded...
169 |
170 |
171 |
172 | 173 |
174 |
175 |  Download example skin pack
176 |
No skinpack selected.
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 | 185 |
186 | 192 |
193 |
194 |
195 | 196 |
197 |
198 |
199 |
(recommended 100ms for Chrome/Chrome based browsers)
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
214 | 215 | 216 |
217 |
No download task
218 |
219 |
220 |
221 |
222 | 223 |
224 | Current version: <%= GIT_VERSION %>
225 | GitHub homepage: https://github.com/MisaLiu/phi-chart-render
226 | Report bugs Here 227 |
228 | Consider support me on Patreon if you like!
229 | 喜欢的话可以到 爱发电 支持一下! 230 |
231 | We uses Sentry.io and Google Analytics to tracking errors and analyze user usage data. 232 | 233 | 234 |
235 | 236 |
237 | 238 |
239 |
240 |
241 | 242 |
243 |
Song name
244 |
Artist
245 |
SP Lv.?
246 |
247 | 248 |
S
249 |
FULL COMBO
250 | 251 |
252 |
1000000
253 |
Accuracy 100.00%
254 |
255 | 256 |
257 |
258 |
259 |
260 |
261 | 262 |
263 |
264 |
265 |
Perfect
266 |
100
267 |
268 |
269 |
Good
270 |
0
271 |
272 |
273 |
Bad
274 |
0
275 |
276 |
277 |
Miss
278 |
0
279 |
280 |
281 |
Max Combo 100
282 |
283 | 284 |
285 | 286 | 287 | 288 |
289 |
290 | 291 |
292 |
Game paused
293 |
294 | 295 | 296 | 297 | 298 |
299 |
300 | 301 |
302 |
An error has just occurred, if you're sure this is not caused by you, please report it on GitHub.
303 |
Test Text Not A Real Error
304 |
The infomation about this error will upload to Sentry.io for analyze and debug.
305 |
306 | 307 | 308 | 309 | 310 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phi-chart-render", 3 | "description": "A Phigros chart render based on Pixi.js", 4 | "version": "0.0.1-beta15", 5 | "author": "MisaLiu", 6 | "files": [ 7 | "src" 8 | ], 9 | "main": "src/main.js", 10 | "scripts": { 11 | "dev": "npx vite", 12 | "dev:prod": "npx vite --mode production", 13 | "build": "npx vite build", 14 | "build:dev": "npx vite build --mode development" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/MisaLiu/phi-chart-render.git" 19 | }, 20 | "license": "LGPL-3.0-or-later", 21 | "bugs": { 22 | "url": "https://github.com/MisaLiu/phi-chart-render/issues" 23 | }, 24 | "homepage": "https://github.com/MisaLiu/phi-chart-render", 25 | "devDependencies": { 26 | "@sentry/browser": "^7.38.0", 27 | "@sentry/tracing": "^7.38.0", 28 | "fontfaceobserver": "^2.3.0", 29 | "git-rev-sync": "^3.0.2", 30 | "jszip": "^3.10.1", 31 | "pica": "^9.0.1", 32 | "stackblur-canvas": "^2.5.0", 33 | "vite": "^4.4.9", 34 | "vite-plugin-html": "^3.2.0", 35 | "vite-plugin-pwa": "^0.16.4" 36 | }, 37 | "dependencies": { 38 | "bezier-easing": "^2.1.0", 39 | "md5-js": "^0.0.3", 40 | "oggmented": "^1.0.1", 41 | "pixi.js": "^7.2.4", 42 | "unify-mp3-timing": "^1.0.3" 43 | }, 44 | "browserslist": [ 45 | "last 10 versions", 46 | "not dead", 47 | "> 0.2%" 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /public/assets/Drag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/assets/Drag.png -------------------------------------------------------------------------------- /public/assets/DragHL.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/assets/DragHL.png -------------------------------------------------------------------------------- /public/assets/Flick.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/assets/Flick.png -------------------------------------------------------------------------------- /public/assets/FlickHL.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/assets/FlickHL.png -------------------------------------------------------------------------------- /public/assets/Hold.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/assets/Hold.png -------------------------------------------------------------------------------- /public/assets/HoldEnd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/assets/HoldEnd.png -------------------------------------------------------------------------------- /public/assets/HoldHL.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/assets/HoldHL.png -------------------------------------------------------------------------------- /public/assets/HoldHead.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/assets/HoldHead.png -------------------------------------------------------------------------------- /public/assets/HoldHeadHL.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/assets/HoldHeadHL.png -------------------------------------------------------------------------------- /public/assets/JudgeLine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/assets/JudgeLine.png -------------------------------------------------------------------------------- /public/assets/Tap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/assets/Tap.png -------------------------------------------------------------------------------- /public/assets/TapHL.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/assets/TapHL.png -------------------------------------------------------------------------------- /public/assets/clickRaw128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/assets/clickRaw128.png -------------------------------------------------------------------------------- /public/assets/pauseButton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/assets/pauseButton.png -------------------------------------------------------------------------------- /public/assets/sounds/Hitsound-Drag.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/assets/sounds/Hitsound-Drag.ogg -------------------------------------------------------------------------------- /public/assets/sounds/Hitsound-Flick.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/assets/sounds/Hitsound-Flick.ogg -------------------------------------------------------------------------------- /public/assets/sounds/Hitsound-Tap.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/assets/sounds/Hitsound-Tap.ogg -------------------------------------------------------------------------------- /public/assets/sounds/result/at.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/assets/sounds/result/at.ogg -------------------------------------------------------------------------------- /public/assets/sounds/result/ez.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/assets/sounds/result/ez.ogg -------------------------------------------------------------------------------- /public/assets/sounds/result/hd.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/assets/sounds/result/hd.ogg -------------------------------------------------------------------------------- /public/assets/sounds/result/in.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/assets/sounds/result/in.ogg -------------------------------------------------------------------------------- /public/assets/sounds/result/sp.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/assets/sounds/result/sp.ogg -------------------------------------------------------------------------------- /public/assets/sounds/result/sp_glitch.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/assets/sounds/result/sp_glitch.ogg -------------------------------------------------------------------------------- /public/icons/192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/icons/192.png -------------------------------------------------------------------------------- /public/icons/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/icons/64.png -------------------------------------------------------------------------------- /public/icons/afdian.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/icons/afdian.png -------------------------------------------------------------------------------- /public/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/icons/favicon.ico -------------------------------------------------------------------------------- /public/icons/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/icons/github.png -------------------------------------------------------------------------------- /public/icons/patreon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/icons/patreon.png -------------------------------------------------------------------------------- /public/skin.example.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/public/skin.example.zip -------------------------------------------------------------------------------- /src/audio/clock.js: -------------------------------------------------------------------------------- 1 | import unmuteAudio from './unmute'; 2 | 3 | // Reference: https://github.com/bemusic/bemuse/blob/68e0d5213b56502b3f5812f1d28c8d7075762717/bemuse/src/game/clock.js#L14 4 | export default class Clock 5 | { 6 | constructor(AudioContext) 7 | { 8 | unmuteAudio(AudioContext); 9 | 10 | this.time = 0; 11 | 12 | this._audioCtx = AudioContext; 13 | this._offsets = []; 14 | this._sum = 0; 15 | 16 | this.update(); 17 | } 18 | 19 | update() 20 | { 21 | const realTime = performance.now() / 1000; 22 | const delta = realTime - this._audioCtx.currentTime; 23 | 24 | this._offsets.push(delta); 25 | this._sum += delta; 26 | 27 | while (this._offsets.length > 60) 28 | { 29 | this._sum -= this._offsets.shift(); 30 | } 31 | 32 | this.time = realTime - this._sum / this._offsets.length; 33 | } 34 | } -------------------------------------------------------------------------------- /src/audio/index.js: -------------------------------------------------------------------------------- 1 | import oggmentedAudioContext from 'oggmented'; 2 | import Mp3Parser from 'unify-mp3-timing'; 3 | import unmuteAudio from './unmute'; 4 | import AudioTimer from './timer'; 5 | import { number as verifyNum } from '@/verify'; 6 | 7 | 8 | 9 | const GlobalAudioCtxConfig = { latencyHint: 'interactive' }; 10 | const AudioCtx = window.AudioContext || window.webkitAudioContext; 11 | const GlobalAudioCtx = (new Audio().canPlayType('audio/ogg') == '') ? new oggmentedAudioContext(GlobalAudioCtxConfig) : new AudioCtx(GlobalAudioCtxConfig); 12 | GlobalAudioCtx.addEventListener('statechange', () => 13 | { 14 | if (GlobalAudioCtx.state === 'running') 15 | { 16 | console.log('[WAudio] Resume AudioContext success'); 17 | 18 | window.removeEventListener('mousedown', ResumeGlobalAudioContext); 19 | window.removeEventListener('touchstart', ResumeGlobalAudioContext); 20 | } 21 | }); 22 | 23 | 24 | export default class WAudio 25 | { 26 | constructor(src, loop = false, offset = 0, volume = 1, speed = 1, onend = undefined) 27 | { 28 | this.source = src; 29 | this.loop = loop; 30 | this.onend = onend; 31 | this._offset = verifyNum(offset, 0); 32 | this._volume = verifyNum(volume, 1); 33 | this._speed = verifyNum(speed, 1); 34 | this._gain = GlobalAudioCtx.createGain(); 35 | 36 | this._gain.gain.value = this._volume; 37 | this._gain.connect(GlobalAudioCtx.destination); 38 | } 39 | 40 | static from(src, loop) 41 | { 42 | return new Promise(async (res, rej) => 43 | { 44 | try { 45 | let { startOffset, buffer } = parseAudio(src); // Reference: https://github.com/111116/webosu/blob/b4c0ba419a6ba33d5b2e35d1d977b656befcac25/scripts/osu-audio.js#L107 46 | let track = await GlobalAudioCtx.decodeAudioData(buffer || src); 47 | if (!track) rej('Unsupported source type'); 48 | let audio = new WAudio(track, loop, startOffset); 49 | res(audio); 50 | } catch (e) { 51 | rej(e); 52 | } 53 | }); 54 | } 55 | 56 | reset() 57 | { 58 | if (this._buffer) 59 | { 60 | this._buffer.onended = undefined; 61 | this._buffer.stop(); 62 | this._buffer.disconnect(); 63 | this._buffer = null; 64 | } 65 | 66 | if (this._timer) 67 | { 68 | this._timer.stop(); 69 | this._timer = null; 70 | } 71 | } 72 | 73 | play(withTimer = false) 74 | { 75 | if (withTimer && !this._timer) this._timer = new AudioTimer(GlobalAudioCtx, this._offset, this._speed); 76 | this._buffer = GlobalAudioCtx.createBufferSource(); 77 | this._buffer.buffer = this.source; 78 | this._buffer.loop = this.loop; 79 | this._buffer.connect(this._gain); 80 | 81 | this._gain.gain.value = this._volume; 82 | this._buffer.playbackRate.value = this._speed; 83 | 84 | if (this._timer) 85 | { 86 | this._timer.speed = this._speed; 87 | this._buffer.start(0, (this._timer.status !== 3 && this._timer.time > 0 ? this._timer.time : 0)); 88 | this._timer.start(GlobalAudioCtx.currentTime); 89 | } 90 | else 91 | { 92 | this._buffer.start(0, 0); 93 | } 94 | 95 | this._buffer.onended = () => 96 | { 97 | if (this._timer) this._timer.stop(); 98 | if (this.onend instanceof Function) this.onend(); 99 | }; 100 | } 101 | 102 | pause() 103 | { 104 | if (this._timer) this._timer.pause(); 105 | if (!this._buffer) return; 106 | 107 | this._buffer.onended = undefined; 108 | this._buffer.stop(); 109 | } 110 | 111 | stop() 112 | { 113 | this.pause(); 114 | if (this._timer) this._timer.stop(); 115 | } 116 | 117 | seek(duration) 118 | { 119 | if (!this._timer) return; 120 | 121 | let playedBeforeSeek = false; 122 | 123 | if (this._timer.status === 3) return; 124 | if (this._timer.status === 1) 125 | { 126 | playedBeforeSeek = true; 127 | this._buffer.onended = undefined; 128 | this._buffer.stop(); 129 | } 130 | 131 | this._timer.seek(duration); 132 | if (playedBeforeSeek) this.play(); 133 | } 134 | 135 | get isPaused() 136 | { 137 | return this._timer.status === 2; 138 | } 139 | 140 | get isStoped() 141 | { 142 | return this._timer.status === 3; 143 | } 144 | 145 | get duration() 146 | { 147 | return this.source.duration; 148 | } 149 | 150 | get currentTime() 151 | { 152 | return this._timer ? this._timer.time : NaN; 153 | } 154 | 155 | get progress() 156 | { 157 | return this.currentTime / this.source.duration; 158 | } 159 | 160 | get volume() 161 | { 162 | return this._volume; 163 | } 164 | 165 | set volume(value) 166 | { 167 | this._volume = verifyNum(value, 1); 168 | if (this._buffer) this._gain.gain.value = this._volume; 169 | } 170 | 171 | get speed() 172 | { 173 | return this._speed; 174 | } 175 | 176 | set speed(value) 177 | { 178 | this._speed = verifyNum(value, 1); 179 | if (this._timer) this._timer.speed = this._speed; 180 | if (this._buffer) this._buffer.playbackRate.value = this._speed; 181 | } 182 | 183 | static get AudioContext() 184 | { 185 | return GlobalAudioCtx; 186 | } 187 | 188 | static get globalLatency() 189 | { 190 | return (!isNaN(GlobalAudioCtx.baseLatency) ? GlobalAudioCtx.baseLatency : 0) + (!isNaN(GlobalAudioCtx.outputLatency) ? GlobalAudioCtx.outputLatency : 0); 191 | } 192 | } 193 | 194 | 195 | 196 | function parseAudio(arrayBuffer) 197 | { 198 | if (!detectIfIsMp3(arrayBuffer)) return { startOffset: 19 }; 199 | 200 | let mp3Tags = Mp3Parser.readTags(new DataView(arrayBuffer)); 201 | 202 | if (mp3Tags.length === 3 && mp3Tags[1]._section.type === 'Xing') 203 | { 204 | let uintArray = new Uint8Array(arrayBuffer.byteLength - mp3Tags[1]._section.byteLength); 205 | let offsetAfterTag = mp3Tags[1]._section.offset + mp3Tags[1]._section.byteLength; 206 | 207 | uintArray.set(new Uint8Array(arrayBuffer, 0, mp3Tags[1]._section.offset), 0); 208 | uintArray.set(new Uint8Array(arrayBuffer, offsetAfterTag, arrayBuffer.byteLength - offsetAfterTag), mp3Tags[0]._section.offset); 209 | 210 | return { startOffset: predictMp3Offset(mp3Tags), buffer: uintArray.buffer }; 211 | } 212 | 213 | return { startOffset: predictMp3Offset(mp3Tags) }; 214 | } 215 | 216 | function detectIfIsMp3(arrayBuffer) 217 | { 218 | const Mp3FileHeads = [ [ 0x49, 0x44, 0x33 ], [ 0xFF, 0xFB, 0x50 ] ]; 219 | let uintArray = new Uint8Array(arrayBuffer); 220 | 221 | for (const Mp3FileHead of Mp3FileHeads) 222 | { 223 | if ( 224 | uintArray[0] === Mp3FileHead[0] && 225 | uintArray[1] === Mp3FileHead[1] && 226 | uintArray[2] === Mp3FileHead[2] 227 | ) { 228 | return true; 229 | } 230 | } 231 | 232 | return false; 233 | } 234 | 235 | function predictMp3Offset(tags) 236 | { 237 | const printWarn = (msg) => console.warn('Cannot predict MP3 offset:', msg); 238 | const defaultOffset = 22; 239 | 240 | if (!tags || !tags.length) 241 | { 242 | printWarn('MP3 tags not found'); 243 | return defaultOffset; 244 | } 245 | 246 | const frameTag = tags[tags.length-1]; 247 | let vbrTag; 248 | let sampleRate; 249 | 250 | if (frameTag._section.sampleLength != 1152) 251 | { 252 | printWarn('Unexpected sample length'); 253 | return defaultOffset; 254 | } 255 | 256 | for (const tag of tags) 257 | { 258 | if (tag._section.type === 'Xing') vbrTag = tag; 259 | } 260 | 261 | if (!vbrTag) return defaultOffset; 262 | 263 | if (!vbrTag.identifier) 264 | { 265 | printWarn('vbr tag identifier missing'); 266 | return defaultOffset; 267 | } 268 | 269 | if (!vbrTag.vbrinfo || vbrTag.vbrinfo.ENC_DELAY !== 576) 270 | { 271 | printWarn('vbr ENC_DELAY value unexpected'); 272 | return defaultOffset; 273 | } 274 | 275 | sampleRate = vbrTag.header.samplingRate; 276 | if (sampleRate === 32000) return 89 - 1152000 / sampleRate; 277 | if (sampleRate === 44100) return 68 - 1152000 / sampleRate; 278 | if (sampleRate === 48000) return 68 - 1152000 / sampleRate; 279 | 280 | printWarn('sampleRate unexpected'); 281 | return defaultOffset; 282 | } 283 | 284 | 285 | window.addEventListener('load', () => 286 | { 287 | if (GlobalAudioCtx.state !== 'running') 288 | { 289 | window.addEventListener('mousedown', ResumeGlobalAudioContext); 290 | window.addEventListener('touchstart', ResumeGlobalAudioContext); 291 | } 292 | 293 | //ResumeGlobalAudioContext(); 294 | }); 295 | 296 | function ResumeGlobalAudioContext() 297 | { 298 | console.log('[WAudio] Trying resume AudioContext...'); 299 | unmuteAudio(GlobalAudioCtx); 300 | } 301 | -------------------------------------------------------------------------------- /src/audio/timer.js: -------------------------------------------------------------------------------- 1 | import Clock from './clock'; 2 | import { number as verifyNum } from '@/verify'; 3 | 4 | 5 | 6 | export default class AudioTimer 7 | { 8 | constructor(AudioContext, offset = 0, speed = 1) 9 | { 10 | this.startTime = NaN; 11 | this.pausedTime = NaN; 12 | this.status = 3; 13 | 14 | this._clock = new Clock(AudioContext); 15 | this._offset = verifyNum(offset) / 1000; 16 | this._speed = verifyNum(speed); 17 | this._lastSpeedChangedProgress = 0; 18 | } 19 | 20 | now() 21 | { 22 | return this._clock.time - this._offset; 23 | } 24 | 25 | start() 26 | { 27 | if (this.status === 2) this.startTime = this.now() - (this.pausedTime - this.startTime); 28 | else this.startTime = this.now(); 29 | 30 | this.status = 1; 31 | this.pausedTime = NaN; 32 | } 33 | 34 | pause() 35 | { 36 | if (this.status === 1) 37 | { 38 | this.pausedTime = this.now(); 39 | this.status = 2; 40 | } 41 | else if (this.status === 2) 42 | { 43 | this.startTime = this.now() - (this.pausedTime - this.startTime); 44 | this.pausedTime = NaN; 45 | this.status = 1; 46 | } 47 | } 48 | 49 | stop() 50 | { 51 | if (this.status === 3) return; 52 | 53 | this.startTime = NaN; 54 | this.pausedTime = NaN; 55 | this._lastSpeedChangedProgress = 0; 56 | 57 | this.status = 3; 58 | } 59 | 60 | seek(duration) 61 | { 62 | if (this.status === 3) return; 63 | this.startTime -= duration; 64 | if (isNaN(this.pausedTime) && this.now() - (this.startTime - this._lastSpeedChangedProgress) < 0) this.startTime = this.now(); 65 | else if (!isNaN(this.pausedTime) && this.startTime > this.pausedTime) this.startTime = this.pausedTime; 66 | } 67 | 68 | get speed() 69 | { 70 | return this._speed; 71 | } 72 | 73 | set speed(value) 74 | { 75 | if (this.status !== 3) this._lastSpeedChangedProgress += ((this.status === 1 ? this.now() : this.pausedTime) - this.startTime) * this._speed; 76 | this.startTime = this.now(); 77 | if (this.status === 2) this.pausedTime = this.now(); 78 | this._speed = verifyNum(value); 79 | } 80 | 81 | get time() 82 | { 83 | this._clock.update(); 84 | return ((isNaN(this.pausedTime) ? this.now() - this.startTime : this.pausedTime - this.startTime) * this._speed + this._lastSpeedChangedProgress); 85 | } 86 | 87 | static get TimerDiff() 88 | { 89 | return AudioContextTimerDiff; 90 | } 91 | } 92 | 93 | -------------------------------------------------------------------------------- /src/audio/unmute.js: -------------------------------------------------------------------------------- 1 | // Reference: https://github.com/bemusic/bemuse/blob/68e0d5213b56502b3f5812f1d28c8d7075762717/bemuse/src/sampling-master/index.js#L276 2 | export default function unmuteAudio(ctx) 3 | { 4 | const gain = ctx.createGain(); 5 | const osc = ctx.createOscillator(); 6 | 7 | osc.frequency.value = 440; 8 | 9 | osc.start(ctx.currentTime + 0.1); 10 | osc.stop(ctx.currentTime + 0.1); 11 | 12 | gain.connect(ctx.destination); 13 | gain.disconnect(); 14 | 15 | ctx.resume() 16 | .catch((e) => { 17 | console.info('[WAudio] Failed to resume AudioContext', e); 18 | } 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/chart/convert/index.js: -------------------------------------------------------------------------------- 1 | import Official from './official'; 2 | import PhiEdit from './phiedit'; 3 | import RePhiEdit from './rephiedit'; 4 | 5 | export { 6 | Official, 7 | PhiEdit, 8 | RePhiEdit 9 | }; -------------------------------------------------------------------------------- /src/chart/convert/official.js: -------------------------------------------------------------------------------- 1 | import Chart from '../index'; 2 | import Judgeline from '../judgeline'; 3 | import EventLayer from '../eventlayer'; 4 | import Note from '../note'; 5 | import utils from './utils'; 6 | 7 | export default function OfficialChartConverter(_chart) 8 | { 9 | let chart = new Chart(); 10 | let rawChart = convertOfficialVersion(_chart); 11 | let notes = []; 12 | let sameTimeNoteCount = {}; 13 | let bpmList = []; 14 | let newBpmList = []; 15 | 16 | chart.offset = rawChart.offset; 17 | 18 | rawChart.judgeLineList.forEach((_judgeline, index) => 19 | { 20 | let judgeline = new Judgeline({ id: index }); 21 | let events = new EventLayer(); 22 | let judgelineNotes = []; 23 | 24 | _judgeline.speedEvents.forEach((e) => 25 | { 26 | events.speed.push({ 27 | startTime : calcRealTime(e.startTime, _judgeline.bpm), 28 | endTime : calcRealTime(e.endTime, _judgeline.bpm), 29 | value : e.value 30 | }); 31 | }); 32 | _judgeline.judgeLineMoveEvents.forEach((e) => 33 | { 34 | events.moveX.push({ 35 | startTime : calcRealTime(e.startTime, _judgeline.bpm), 36 | endTime : calcRealTime(e.endTime, _judgeline.bpm), 37 | start : e.start - 0.5, 38 | end : e.end - 0.5 39 | }); 40 | events.moveY.push({ 41 | startTime : calcRealTime(e.startTime, _judgeline.bpm), 42 | endTime : calcRealTime(e.endTime, _judgeline.bpm), 43 | start : e.start2 - 0.5, 44 | end : e.end2 - 0.5 45 | }); 46 | }); 47 | _judgeline.judgeLineRotateEvents.forEach((e) => 48 | { 49 | events.rotate.push({ 50 | startTime : calcRealTime(e.startTime, _judgeline.bpm), 51 | endTime : calcRealTime(e.endTime, _judgeline.bpm), 52 | start : -(Math.PI / 180) * e.start, 53 | end : -(Math.PI / 180) * e.end 54 | }); 55 | }); 56 | _judgeline.judgeLineDisappearEvents.forEach((e) => 57 | { 58 | events.alpha.push({ 59 | startTime : calcRealTime(e.startTime, _judgeline.bpm), 60 | endTime : calcRealTime(e.endTime, _judgeline.bpm), 61 | start : e.start, 62 | end : e.end 63 | }); 64 | }); 65 | 66 | judgeline.eventLayers.push(events); 67 | judgeline.sortEvent(); 68 | 69 | judgeline.eventLayers[0].moveX = utils.arrangeSameValueEvent(judgeline.eventLayers[0].moveX); 70 | judgeline.eventLayers[0].moveY = utils.arrangeSameValueEvent(judgeline.eventLayers[0].moveY); 71 | judgeline.eventLayers[0].rotate = utils.arrangeSameValueEvent(judgeline.eventLayers[0].rotate); 72 | judgeline.eventLayers[0].alpha = utils.arrangeSameValueEvent(judgeline.eventLayers[0].alpha); 73 | 74 | judgeline.calcFloorPosition(); 75 | 76 | _judgeline.notesAbove.forEach((rawNote, rawNoteIndex) => 77 | { 78 | rawNote.judgeline = judgeline; 79 | rawNote.id = rawNoteIndex; 80 | rawNote.bpm = _judgeline.bpm; 81 | rawNote.isAbove = true; 82 | // let note = pushNote(rawNote, judgeline, rawNoteIndex, _judgeline.bpm, true); 83 | judgelineNotes.push(rawNote); 84 | }); 85 | _judgeline.notesBelow.forEach((rawNote, rawNoteIndex) => 86 | { 87 | rawNote.judgeline = judgeline; 88 | rawNote.id = rawNoteIndex; 89 | rawNote.bpm = _judgeline.bpm; 90 | rawNote.isAbove = false; 91 | // let note = pushNote(rawNote, judgeline, rawNoteIndex, _judgeline.bpm, false); 92 | judgelineNotes.push(rawNote); 93 | }); 94 | 95 | judgelineNotes.sort((a, b) => a.time - b.time); 96 | judgelineNotes.forEach((note, noteIndex) => 97 | { 98 | sameTimeNoteCount[note.time] = !sameTimeNoteCount[note.time] ? 1 : sameTimeNoteCount[note.time] + 1; 99 | note.id = noteIndex; 100 | }); 101 | 102 | notes.push(...judgelineNotes); 103 | 104 | chart.judgelines.push(judgeline); 105 | }); 106 | 107 | notes.sort((a, b) => a.time - b.time); 108 | notes.forEach((note) => 109 | { 110 | if (sameTimeNoteCount[note.time] > 1) note.isMulti = true; 111 | chart.notes.push(pushNote(note)); 112 | }); 113 | chart.notes.sort((a, b) => a.time - b.time); 114 | 115 | notes.sort((a, b) => a.time - b.time); 116 | notes.forEach((note) => 117 | { 118 | if (bpmList.length <= 0) 119 | { 120 | bpmList.push({ 121 | startTime : note.time, 122 | endTime : note.time, 123 | bpm : note.bpm, 124 | holdBetween : ((-1.2891 * note.bpm) + 396.71) / 1000 125 | }); 126 | } 127 | else 128 | { 129 | bpmList[bpmList.length - 1].endTime = note.time; 130 | 131 | if (bpmList[bpmList.length - 1].bpm != note.bpm) 132 | { 133 | bpmList.push({ 134 | startTime : note.time, 135 | endTime : note.time, 136 | bpm : note.bpm, 137 | holdBetween : ((-1.2891 * note.bpm) + 396.71) / 1000 138 | }); 139 | } 140 | } 141 | }); 142 | bpmList.sort((a, b) => a.startTime - b.startTime); 143 | 144 | if (bpmList.length > 0) 145 | { 146 | bpmList[0].startTime = 1 - 1000; 147 | bpmList[bpmList.length - 1].endTime = 1e4; 148 | } 149 | else 150 | { 151 | bpmList.push({ 152 | startTime : 1 - 1000, 153 | endTime : 1e4, 154 | bpm : 120, 155 | holdBetween : 0.242018 156 | }); 157 | } 158 | 159 | chart.bpmList = bpmList.slice(); 160 | 161 | return chart; 162 | 163 | function pushNote(rawNote) 164 | { 165 | rawNote.time = calcRealTime(rawNote.time, rawNote.bpm); 166 | rawNote.holdTime = calcRealTime(rawNote.holdTime, rawNote.bpm); 167 | rawNote.holdEndTime = rawNote.time + rawNote.holdTime; 168 | 169 | { // 考虑到 js 精度,此处重新计算 Note 的 floorPosition 值 170 | let noteStartSpeedEvent = rawNote.judgeline.getFloorPosition(rawNote.time); 171 | rawNote.floorPosition = noteStartSpeedEvent ? noteStartSpeedEvent.floorPosition + noteStartSpeedEvent.value * (rawNote.time - noteStartSpeedEvent.startTime) : 0; 172 | 173 | if (rawNote.type == 3) 174 | { 175 | let noteEndSpeedEvent = rawNote.judgeline.getFloorPosition(rawNote.holdEndTime); 176 | rawNote.holdLength = rawNote.holdTime * rawNote.speed /*(noteEndSpeedEvent ? noteEndSpeedEvent.floorPosition + noteEndSpeedEvent.value * (rawNote.holdEndTime - noteEndSpeedEvent.startTime) : 0) - rawNote.floorPosition */; 177 | } 178 | else 179 | { 180 | rawNote.holdLength = 0; 181 | } 182 | } 183 | 184 | return new Note({ 185 | id : rawNote.id, 186 | lineId : rawNote.lineId, 187 | type : rawNote.type, 188 | time : rawNote.time, 189 | holdTime : rawNote.holdTime, 190 | holdLength : rawNote.holdLength, 191 | positionX : rawNote.positionX, 192 | floorPosition : rawNote.floorPosition, 193 | speed : rawNote.speed, 194 | isAbove : rawNote.isAbove, 195 | isMulti : rawNote.isMulti, 196 | useOfficialSpeed : true, 197 | judgeline : rawNote.judgeline 198 | }); 199 | } 200 | }; 201 | 202 | 203 | function convertOfficialVersion(chart) 204 | { 205 | let newChart = JSON.parse(JSON.stringify(chart)); 206 | 207 | switch (newChart.formatVersion) 208 | { 209 | case 1: 210 | { 211 | newChart.formatVersion = 3; 212 | for (const i of newChart.judgeLineList) 213 | { 214 | let floorPosition = 0; 215 | 216 | for (const x of i.speedEvents) 217 | { 218 | if (x.startTime < 0) x.startTime = 0; 219 | x.floorPosition = floorPosition; 220 | floorPosition += (x.endTime - x.startTime) * x.value / i.bpm * 1.875; 221 | } 222 | 223 | for (const x of i.judgeLineDisappearEvents) 224 | { 225 | x.start2 = 0; 226 | x.end2 = 0; 227 | } 228 | 229 | for (const x of i.judgeLineMoveEvents) 230 | { 231 | x.start2 = x.start % 1e3 / 520; 232 | x.end2 = x.end % 1e3 / 520; 233 | x.start = parseInt(x.start / 1e3) / 880; 234 | x.end = parseInt(x.end / 1e3) / 880; 235 | } 236 | 237 | for (const x of i.judgeLineRotateEvents) 238 | { 239 | x.start2 = 0; 240 | x.end2 = 0; 241 | } 242 | } 243 | } 244 | case 3: { 245 | break; 246 | } 247 | default: 248 | throw new Error('Unsupported chart version: ' + newChart.formatVersion); 249 | } 250 | 251 | return newChart; 252 | } 253 | 254 | function calcRealTime(time, bpm) { 255 | return time / bpm * 1.875; 256 | } -------------------------------------------------------------------------------- /src/chart/convert/utils.js: -------------------------------------------------------------------------------- 1 | import { number as verifyNum } from '@/verify'; 2 | import Bezier from 'bezier-easing'; 3 | 4 | const calcBetweenTime = 0.125; // 1/32 5 | 6 | /** 7 | * 将一个事件的拍数数组转换为拍数小数 8 | * 9 | * @param {Object} event 欲转换的事件 10 | * @return {Object} 转换出的事件 11 | */ 12 | function calculateEventBeat(event) 13 | { 14 | event.startTime = parseFloat((event.startTime[0] + (event.startTime[1] / event.startTime[2])).toFixed(3)); 15 | event.endTime = parseFloat((event.endTime[0] + (event.endTime[1] / event.endTime[2])).toFixed(3)); 16 | return event; 17 | } 18 | 19 | /** 20 | * 将一组事件的拍数数组转换为拍数小数 21 | * 22 | * @param {Array} events 欲转换的事件组 23 | * @return {Array} 转换出的事件组 24 | */ 25 | function calculateEventsBeat(events) 26 | { 27 | events.forEach((event) => 28 | { 29 | event = calculateEventBeat(event); 30 | }); 31 | return events; 32 | } 33 | 34 | /** 35 | * 计算在某时间下某一事件的返回值 36 | * 37 | * @param {Object} event 欲用于计算值的事件 38 | * @param {Array} Easings 该事件配套的缓动函数组 39 | * @param {Number} currentTime 欲计算的时间 40 | * @param {Number} [easingsOffset] 缓动函数偏移,默认为 `1` 41 | * @return {Number} 返回在指定时间下当前事件的值 42 | */ 43 | function valueCalculator(event, Easings, currentTime, easingsOffset = 1) 44 | { 45 | if (event.start == event.end) return event.start; 46 | if (event.startTime > currentTime) throw new Error('currentTime must bigger than startTime'); 47 | if (event.endTime < currentTime) throw new Error('currentTime must smaller than endTime'); 48 | 49 | let timePercentEnd = (currentTime - event.startTime) / (event.endTime - event.startTime); 50 | let timePercentStart = 1 - timePercentEnd; 51 | 52 | if (event.bezier === 1) 53 | { 54 | let bezier = Bezier(event.bezierPoints[0], event.bezierPoints[1], event.bezierPoints[2], event.bezierPoints[3]); 55 | return event.start * bezier(timePercentStart) + event.end * bezier(timePercentEnd); 56 | } 57 | else 58 | { 59 | let easeFunction = Easings[event.easingType - easingsOffset] ? Easings[event.easingType - easingsOffset] : Easings[0]; 60 | let easePercent = easeFunction(verifyNum(event.easingLeft, 0, 0, 1) * timePercentStart + verifyNum(event.easingRight, 1, 0, 1) * timePercentEnd); 61 | let easePercentStart = easeFunction(verifyNum(event.easingLeft, 0, 0, 1)); 62 | let easePercentEnd = easeFunction(verifyNum(event.easingRight, 1, 0, 1)); 63 | 64 | easePercent = (easePercent - easePercentStart) / (easePercentEnd - easePercentStart); 65 | 66 | return event.start * (1 - easePercent) + event.end * easePercent; 67 | } 68 | 69 | } 70 | 71 | /** 72 | * 计算一组事件/Note的绝对时间 73 | * 74 | * @param {Array} _bpmList 一组已计算好绝对时间的 BPM 列表,传入前请先倒序排序 75 | * @param {Array} _events 欲计算绝对时间的事件/Note数组 76 | * @return {Array} 已经计算好时间的事件/Note数组 77 | */ 78 | function calculateRealTime(_bpmList, _events) 79 | { 80 | let bpmList = _bpmList.slice(); 81 | let events = _events.slice(); 82 | 83 | events.forEach((event) => 84 | { 85 | for (let bpmIndex = 0, bpmLength = bpmList.length; bpmIndex < bpmLength; bpmIndex++) 86 | { 87 | let bpm = bpmList[bpmIndex]; 88 | 89 | if (bpm.startBeat > event.endTime) continue; 90 | event.endTime = bpm.startTime + ((event.endTime - bpm.startBeat) * bpm.beatTime); 91 | 92 | for (let nextBpmIndex = bpmIndex; nextBpmIndex < bpmLength; nextBpmIndex++) 93 | { 94 | let nextBpm = bpmList[nextBpmIndex]; 95 | 96 | if (nextBpm.startBeat > event.startTime) continue; 97 | event.startTime = nextBpm.startTime + ((event.startTime - nextBpm.startBeat) * nextBpm.beatTime); 98 | break; 99 | } 100 | 101 | break; 102 | } 103 | }); 104 | 105 | return events.slice(); 106 | } 107 | 108 | /** 109 | * 拆分事件缓动 110 | * 111 | * @param {Object} event 欲拆分缓动的事件 112 | * @param {Array} Easings 该事件配套的缓动函数组 113 | * @param {Number} [easingsOffset] 缓动函数偏移,默认为 `1` 114 | * @param {Boolean} [forceLinear] 强制拆分 linear 缓动 115 | * @return {Array} 已拆分完缓动的事件数组 116 | */ 117 | function calculateEventEase(event, Easings, easingsOffset = 1, forceLinear = false) 118 | { 119 | let result = []; 120 | let timeBetween = event.endTime - event.startTime; 121 | 122 | if (!event) return []; 123 | 124 | if ( 125 | ( 126 | event.bezier == 1 || 127 | ( 128 | event.easingType && Easings[event.easingType - easingsOffset] && (event.easingType - easingsOffset !== 0 || forceLinear) && 129 | event.easingType <= Easings.length 130 | ) 131 | ) && 132 | event.start != event.end 133 | ) { 134 | for (let timeIndex = 0, timeCount = Math.ceil(timeBetween / calcBetweenTime); timeIndex < timeCount; timeIndex++) 135 | { 136 | let currentTime = event.startTime + (timeIndex * calcBetweenTime); 137 | let nextTime = event.startTime + ((timeIndex + 1) * calcBetweenTime) <= event.endTime ? event.startTime + ((timeIndex + 1) * calcBetweenTime) : event.endTime; 138 | 139 | result.push({ 140 | startTime : currentTime, 141 | endTime : nextTime, 142 | start : valueCalculator(event, Easings, currentTime, easingsOffset), 143 | end : valueCalculator(event, Easings, nextTime, easingsOffset) 144 | }); 145 | } 146 | } 147 | else 148 | { 149 | result.push({ 150 | startTime: event.startTime, 151 | endTime: event.endTime, 152 | start: event.start, 153 | end: event.end 154 | }); 155 | } 156 | 157 | return result; 158 | } 159 | 160 | /** 161 | * 计算一组 BPM 的 HoldBetween 值 162 | * 163 | * @param {Array} _bpmList 欲用于计算的 BPM 数组 164 | * @return {Array} 计算完毕的 BPM 数组 165 | */ 166 | function calculateHoldBetween(_bpmList) 167 | { 168 | let bpmList = _bpmList.slice(); 169 | let result = []; 170 | 171 | bpmList.sort((a, b) => a.startTime - b.startTime); 172 | bpmList.forEach((bpm) => 173 | { 174 | if (result.length <= 0) 175 | { 176 | result.push({ 177 | startTime : bpm.startTime, 178 | endTime : bpm.startTime, 179 | bpm : bpm.bpm, 180 | holdBetween : ((-1.2891 * bpm.bpm) + 396.71) / 1000 181 | }); 182 | } 183 | else 184 | { 185 | result[result.length - 1].endTime = bpm.startTime; 186 | 187 | if (result[result.length - 1].bpm != bpm.bpm) 188 | { 189 | result.push({ 190 | startTime : bpm.startTime, 191 | endTime : bpm.startTime, 192 | bpm : bpm.bpm, 193 | holdBetween : ((-1.2891 * bpm.bpm) + 396.71) / 1000 194 | }); 195 | } 196 | } 197 | }); 198 | 199 | result.sort((a, b) => a.startTime - b.startTime); 200 | 201 | if (result.length > 0) 202 | { 203 | result[0].startTime = 1 - 1000; 204 | result[result.length - 1].endTime = 1e4; 205 | } 206 | else 207 | { 208 | result.push({ 209 | startTime : 1 - 1000, 210 | endTime : 1e4, 211 | bpm : 120, 212 | holdBetween : 0.242018 213 | }); 214 | } 215 | 216 | return result; 217 | } 218 | 219 | /** 220 | * 合并一组事件中值相同的事件 221 | * 222 | * @param {Array} _events 欲合并相同值的事件组 223 | * @return {Array} 已合并相同值的事件组 224 | */ 225 | function arrangeSameValueEvent(_events) 226 | { 227 | if (!_events || _events.length <= 0) return []; 228 | 229 | let events = _events.slice(); 230 | let result = [ events.shift() ]; 231 | 232 | for (const event of events) 233 | { 234 | if ( 235 | result[result.length - 1].start == result[result.length - 1].end && 236 | event.start == event.end && 237 | result[result.length - 1].start == event.start 238 | ) { 239 | result[result.length - 1].endTime = event.endTime; 240 | } 241 | else 242 | { 243 | result.push(event); 244 | } 245 | } 246 | 247 | return result.slice(); 248 | } 249 | 250 | /** 251 | * 合并一组速度事件中值相同的事件 252 | * 253 | * @param {Array} events 欲合并相同值的速度事件组 254 | * @return {Array} 已合并相同值的速度事件组 255 | */ 256 | function arrangeSameSingleValueEvent(events) 257 | { 258 | if (!events || events.length <= 0) return []; 259 | 260 | let newEvents = []; 261 | for (let i of events) { 262 | let lastEvent = newEvents[newEvents.length - 1]; 263 | 264 | if (!lastEvent || lastEvent.value != i.value) { 265 | newEvents.push(i); 266 | } else { 267 | lastEvent.endTime = i.endTime; 268 | } 269 | } 270 | 271 | return newEvents.slice(); 272 | } 273 | 274 | export default { 275 | CalcBetweenTime: calcBetweenTime, 276 | 277 | calculateEventBeat, 278 | calculateEventsBeat, 279 | 280 | valueCalculator, 281 | calculateRealTime, 282 | 283 | calculateEventEase, 284 | calculateHoldBetween, 285 | 286 | arrangeSameValueEvent, 287 | arrangeSameSingleValueEvent 288 | } -------------------------------------------------------------------------------- /src/chart/eventlayer.js: -------------------------------------------------------------------------------- 1 | export default class EventLayer 2 | { 3 | constructor() 4 | { 5 | this.speed = []; 6 | this.moveX = []; 7 | this.moveY = []; 8 | this.alpha = []; 9 | this.rotate = []; 10 | 11 | this._speed = 0; 12 | this._posX = 0; 13 | this._posY = 0; 14 | this._alpha = 0; 15 | this._rotate = 0; 16 | } 17 | 18 | sort() 19 | { 20 | const sorter = (a, b) => a.startTime - b.startTime; 21 | this.speed.sort(sorter); 22 | this.moveX.sort(sorter); 23 | this.moveY.sort(sorter); 24 | this.alpha.sort(sorter); 25 | this.rotate.sort(sorter); 26 | } 27 | 28 | calcTime(currentTime) 29 | { 30 | this._posX = valueCalculator(this.moveX, currentTime, this._posX); 31 | this._posY = valueCalculator(this.moveY, currentTime, this._posY); 32 | this._alpha = valueCalculator(this.alpha, currentTime, this._alpha); 33 | this._rotate = valueCalculator(this.rotate, currentTime, this._rotate); 34 | 35 | for (let i = 0, length = this.speed.length; i < length; i++) 36 | { 37 | let event = this.speed[i]; 38 | if (event.endTime < currentTime) continue; 39 | if (event.startTime > currentTime) break; 40 | 41 | this._speed = event.value; 42 | } 43 | } 44 | } 45 | 46 | function valueCalculator(events, currentTime, originValue = 0) 47 | { 48 | for (let i = 0, length = events.length; i < length; i++) 49 | { 50 | let event = events[i]; 51 | if (event.endTime < currentTime) continue; 52 | if (event.startTime > currentTime) break; 53 | if (event.start == event.end) return event.start 54 | 55 | let timePercentEnd = (currentTime - event.startTime) / (event.endTime - event.startTime); 56 | let timePercentStart = 1 - timePercentEnd; 57 | 58 | return event.start * timePercentStart + event.end * timePercentEnd; 59 | } 60 | return originValue; 61 | } -------------------------------------------------------------------------------- /src/chart/index.js: -------------------------------------------------------------------------------- 1 | import { number as verifyNum } from '@/verify'; 2 | import * as Convert from './convert'; 3 | import md5Hash from 'md5-js'; 4 | import { Sprite, Graphics, Text } from 'pixi.js'; 5 | 6 | export default class Chart 7 | { 8 | constructor(params = {}) 9 | { 10 | this.judgelines = []; 11 | this.notes = []; 12 | this.bpmList = []; 13 | this.offset = verifyNum(params.offset, 0); 14 | this.isLineTextureReaded = false; 15 | 16 | this.music = params.music ? params.music : null; 17 | this.bg = params.bg ? params.bg : null; 18 | 19 | this.info = { 20 | name : params.name, 21 | artist : params.artist, 22 | author : params.author, 23 | bgAuthor : params.bgAuthor, 24 | difficult : params.difficult, 25 | md5 : params.md5 26 | }; 27 | 28 | this.sprites = {}; 29 | } 30 | 31 | static from(rawChart, _chartInfo = {}, _chartLineTexture = []) 32 | { 33 | let chart; 34 | let chartInfo = _chartInfo; 35 | let chartMD5; 36 | 37 | if (typeof rawChart == 'object') 38 | { 39 | if (!isNaN(Number(rawChart.formatVersion))) 40 | { 41 | chart = Convert.Official(rawChart); 42 | } 43 | else if (rawChart.META && !isNaN(Number(rawChart.META.RPEVersion))) 44 | { 45 | chart = Convert.RePhiEdit(rawChart); 46 | chartInfo = chart.info; 47 | } 48 | 49 | try { 50 | chartMD5 = md5Hash(JSON.stringify(rawChart)); 51 | } catch (e) { 52 | console.warn('Failed to calculate chart MD5.'); 53 | console.error(e); 54 | chartMD5 = null 55 | } 56 | } 57 | else if (typeof rawChart == 'string') 58 | { 59 | chart = Convert.PhiEdit(rawChart); 60 | try { 61 | chartMD5 = md5Hash(rawChart); 62 | } catch (e) { 63 | console.warn('Failed to calculate chart MD5.'); 64 | console.error(e); 65 | chartMD5 = null 66 | } 67 | } 68 | 69 | if (!chart) throw new Error('Unsupported chart format'); 70 | 71 | chart.info = { 72 | name : chartInfo.name, 73 | artist : chartInfo.artist, 74 | author : chartInfo.author, 75 | bgAuthor : chartInfo.bgAuthor, 76 | difficult : chartInfo.difficult, 77 | md5 : chartMD5 78 | }; 79 | 80 | chart.judgelines.forEach((judgeline) => 81 | { 82 | judgeline.eventLayers.forEach((eventLayer) => 83 | { 84 | /* eventLayer.speed = utils.arrangeSameSingleValueEvent(eventLayer.speed); */ 85 | eventLayer.moveX = arrangeLineEvents(eventLayer.moveX); 86 | eventLayer.moveY = arrangeLineEvents(eventLayer.moveY); 87 | eventLayer.rotate = arrangeLineEvents(eventLayer.rotate); 88 | eventLayer.alpha = arrangeLineEvents(eventLayer.alpha); 89 | }); 90 | 91 | for (const name in judgeline.extendEvent) 92 | { 93 | if (name !== 'color' && name !== 'text') 94 | judgeline.extendEvent[name] = arrangeLineEvents(judgeline.extendEvent[name]); 95 | else 96 | judgeline.extendEvent[name] = arrangeSingleValueLineEvents(judgeline.extendEvent[name]); 97 | } 98 | 99 | judgeline.sortEvent(); 100 | }); 101 | 102 | chart.readLineTextureInfo(_chartLineTexture); 103 | 104 | chart.judgelines.sort((a, b) => 105 | { 106 | if (a.parentLine && b.parentLine) 107 | { 108 | return a.parentLine.id - b.parentLine.id; 109 | } 110 | else if (a.parentLine) 111 | { 112 | return 1; 113 | } 114 | else if (b.parentLine) 115 | { 116 | return -1; 117 | } 118 | else 119 | { 120 | return a.id - b.id; 121 | } 122 | }); 123 | 124 | // console.log(chart); 125 | return chart; 126 | } 127 | 128 | readLineTextureInfo(infos = []) 129 | { 130 | if (this.isLineTextureReaded) return; 131 | if (infos.length <= 0) return; 132 | 133 | let isReaded = false; 134 | 135 | infos.forEach((lineInfo) => 136 | { 137 | if (!this.judgelines[lineInfo.LineId]) return; 138 | 139 | this.judgelines[lineInfo.LineId].texture = lineInfo.Image; 140 | this.judgelines[lineInfo.LineId].useOfficialScale = true; 141 | this.judgelines[lineInfo.LineId].scaleX = !isNaN(lineInfo.Horz) ? parseFloat(lineInfo.Horz) : 1; 142 | this.judgelines[lineInfo.LineId].scaleY = !isNaN(lineInfo.Vert) ? parseFloat(lineInfo.Vert) : 1; 143 | 144 | this.judgelines[lineInfo.LineId].extendEvent.scaleX.push({ 145 | startTime: 1 - 1000, 146 | endTime: 1000, 147 | start: this.judgelines[lineInfo.LineId].scaleX, 148 | end: this.judgelines[lineInfo.LineId].scaleX 149 | }); 150 | 151 | this.judgelines[lineInfo.LineId].extendEvent.scaleY.push({ 152 | startTime: 1 - 1000, 153 | endTime: 1000, 154 | start: this.judgelines[lineInfo.LineId].scaleY, 155 | end: this.judgelines[lineInfo.LineId].scaleY 156 | }); 157 | 158 | isReaded = true; 159 | }); 160 | 161 | if (isReaded) this.isLineTextureReaded = true; 162 | } 163 | 164 | createSprites(stage, size, textures, uiStage = null, zipFiles = {}, speed = 1, bgDim = 0.5, multiNoteHL = true, debug = false) 165 | { 166 | let linesWithZIndex = []; 167 | 168 | if (this.bg) 169 | { 170 | this.sprites.bg = new Sprite(this.bg); 171 | 172 | let bgCover = new Graphics(); 173 | 174 | bgCover.beginFill(0x000000); 175 | bgCover.drawRect(0, 0, this.sprites.bg.texture.width, this.sprites.bg.texture.height); 176 | bgCover.endFill(); 177 | 178 | bgCover.position.x = -this.sprites.bg.width / 2; 179 | bgCover.position.y = -this.sprites.bg.height / 2; 180 | bgCover.alpha = bgDim; 181 | 182 | this.sprites.bg.addChild(bgCover); 183 | this.sprites.bg.anchor.set(0.5); 184 | this.sprites.bg.cover = bgCover; 185 | 186 | stage.addChild(this.sprites.bg); 187 | } 188 | 189 | this.judgelines.forEach((judgeline, index) => 190 | { 191 | judgeline.createSprite(textures, zipFiles, debug); 192 | 193 | judgeline.sprite.position.x = size.width / 2; 194 | judgeline.sprite.position.y = size.height / 2; 195 | judgeline.sprite.zIndex = 10 + index; 196 | 197 | if (!isNaN(judgeline.zIndex)) linesWithZIndex.push(judgeline); 198 | 199 | stage.addChild(judgeline.sprite); 200 | if (judgeline.debugSprite) 201 | { 202 | judgeline.debugSprite.zIndex = 999 + judgeline.sprite.zIndex; 203 | stage.addChild(judgeline.debugSprite); 204 | } 205 | 206 | if (judgeline.texture && judgeline.useOfficialScale) 207 | { 208 | let oldScaleY = judgeline.extendEvent.scaleY[0].start; 209 | 210 | judgeline.extendEvent.scaleY[0].start = judgeline.extendEvent.scaleY[0].end = (1080 / judgeline.sprite.texture.height) * (oldScaleY * (oldScaleY < 0 ? -1 : 1)); 211 | judgeline.extendEvent.scaleX[0].start = judgeline.extendEvent.scaleX[0].end = judgeline.extendEvent.scaleY[0].start * judgeline.extendEvent.scaleX[0].start; 212 | 213 | judgeline.useOfficialScale = false; 214 | } 215 | }); 216 | 217 | linesWithZIndex.sort((a, b) => a.zIndex - b.zIndex); 218 | linesWithZIndex.forEach((judgeline, index) => 219 | { 220 | judgeline.sprite.zIndex = 10 + this.judgelines.length + index; 221 | if (judgeline.debugSprite) judgeline.debugSprite.zIndex = 999 + judgeline.sprite.zIndex; 222 | }); 223 | 224 | this.notes.forEach((note, index) => 225 | { 226 | note.createSprite(textures, zipFiles, multiNoteHL, debug); 227 | 228 | note.sprite.zIndex = 10 + (this.judgelines.length + linesWithZIndex.length) + (note.type === 3 ? index : index + 10); 229 | 230 | stage.addChild(note.sprite); 231 | if (note.debugSprite) 232 | { 233 | note.debugSprite.zIndex = 999 + note.sprite.zIndex; 234 | stage.addChild(note.debugSprite); 235 | } 236 | }); 237 | 238 | this.sprites.info = {}; 239 | 240 | this.sprites.info.songName = new Text((this.info.name || 'Untitled') + ((Math.round(speed * 100) !== 100) ? ' (x' + speed.toFixed(2) + ')' : ''), { 241 | fontFamily: 'A-OTF Shin Go Pr6N H', 242 | fill: 0xFFFFFF 243 | }); 244 | this.sprites.info.songName.anchor.set(0, 1); 245 | this.sprites.info.songName.zIndex = 99999; 246 | 247 | if (uiStage) uiStage.addChild(this.sprites.info.songName); 248 | else stage.addChild(this.sprites.info.songName); 249 | 250 | 251 | this.sprites.info.songDiff = new Text((this.info.difficult || 'SP Lv.?'), { 252 | fontFamily: 'MiSans', 253 | fill: 0xFFFFFF 254 | }); 255 | this.sprites.info.songDiff.anchor.set(0, 1); 256 | this.sprites.info.songDiff.zIndex = 99999; 257 | 258 | if (uiStage) uiStage.addChild(this.sprites.info.songDiff); 259 | else stage.addChild(this.sprites.info.songDiff); 260 | } 261 | 262 | resizeSprites(size, isEnded) 263 | { 264 | this.renderSize = size; 265 | 266 | if (this.sprites.bg) 267 | { 268 | let bgScaleWidth = this.renderSize.width / this.sprites.bg.texture.width; 269 | let bgScaleHeight = this.renderSize.height / this.sprites.bg.texture.height; 270 | let bgScale = bgScaleWidth > bgScaleHeight ? bgScaleWidth : bgScaleHeight; 271 | 272 | this.sprites.bg.scale.set(bgScale); 273 | this.sprites.bg.position.set(this.renderSize.width / 2, this.renderSize.height / 2); 274 | } 275 | 276 | if (this.judgelines && this.judgelines.length > 0) 277 | { 278 | this.judgelines.forEach((judgeline) => 279 | { 280 | if (!judgeline.sprite) return; 281 | 282 | if (judgeline.isText) 283 | { 284 | judgeline.sprite.style.fontSize = 68 * this.renderSize.heightPercent; 285 | judgeline.baseScaleX = judgeline.baseScaleY = 1; 286 | } 287 | else if (judgeline.texture) 288 | { 289 | judgeline.baseScaleX = judgeline.baseScaleY = this.renderSize.heightPercent; 290 | } 291 | else 292 | { 293 | judgeline.baseScaleX = (4000 / judgeline.sprite.texture.width) * (this.renderSize.width / 1350); 294 | judgeline.baseScaleY = ((this.renderSize.lineScale * 18.75 * 0.008) / judgeline.sprite.texture.height); 295 | } 296 | 297 | judgeline.sprite.scale.set(judgeline.scaleX * judgeline.baseScaleX, judgeline.scaleY * judgeline.baseScaleY); 298 | 299 | judgeline.sprite.position.x = judgeline.x * this.renderSize.width; 300 | judgeline.sprite.position.y = judgeline.y * this.renderSize.height; 301 | 302 | for (const name in judgeline.noteControls) 303 | { 304 | for (const control of judgeline.noteControls[name]) 305 | { 306 | control.y = control._y * size.height 307 | } 308 | } 309 | 310 | if (isEnded) judgeline.sprite.alpha = 0; 311 | if (judgeline.debugSprite) judgeline.debugSprite.scale.set(this.renderSize.heightPercent); 312 | }); 313 | } 314 | 315 | if (this.notes && this.notes.length > 0) 316 | { 317 | this.notes.forEach((note) => 318 | { 319 | if (note.type === 3) 320 | { 321 | let holdLength = note.holdLength * (note.useOfficialSpeed ? 1 : note.speed) * this.renderSize.noteSpeed / this.renderSize.noteScale 322 | note.sprite.children[1].height = holdLength; 323 | note.sprite.children[2].position.y = -holdLength; 324 | } 325 | 326 | note.sprite.baseScale = this.renderSize.noteScale; 327 | note.sprite.scale.set(this.renderSize.noteScale * note.xScale, this.renderSize.noteScale); 328 | if (isEnded) note.sprite.alpha = 0; 329 | if (note.debugSprite) note.debugSprite.scale.set(this.renderSize.heightPercent); 330 | }); 331 | } 332 | 333 | this.sprites.info.songName.style.fontSize = size.heightPercent * 27; 334 | this.sprites.info.songName.position.x = size.heightPercent * 57; 335 | this.sprites.info.songName.position.y = size.height - size.heightPercent * 66; 336 | 337 | this.sprites.info.songDiff.style.fontSize = size.heightPercent * 20; 338 | this.sprites.info.songDiff.position.x = size.heightPercent * 57; 339 | this.sprites.info.songDiff.position.y = size.height - size.heightPercent * 42; 340 | } 341 | 342 | reset() 343 | { 344 | this.holdBetween = this.bpmList[0].holdBetween; 345 | 346 | this.judgelines.forEach((judgeline) => 347 | { 348 | judgeline.reset(); 349 | }); 350 | this.notes.forEach((note) => 351 | { 352 | note.reset(); 353 | }); 354 | } 355 | 356 | destroySprites() 357 | { 358 | this.judgelines.forEach((judgeline) => 359 | { 360 | if (!judgeline.sprite) return; 361 | judgeline.reset(); 362 | judgeline.sprite.destroy(); 363 | judgeline.sprite = undefined; 364 | 365 | if (judgeline.debugSprite) 366 | { 367 | judgeline.debugSprite.destroy(true); 368 | judgeline.debugSprite = undefined; 369 | } 370 | }); 371 | this.notes.forEach((note) => 372 | { 373 | if (!note.sprite) return; 374 | note.reset(); 375 | note.sprite.destroy(); 376 | note.sprite = undefined; 377 | 378 | if (note.debugSprite) 379 | { 380 | note.debugSprite.destroy(true); 381 | note.debugSprite = undefined; 382 | } 383 | }); 384 | 385 | if (this.sprites.bg) 386 | { 387 | this.sprites.bg.destroy(); 388 | this.sprites.bg = undefined; 389 | } 390 | 391 | this.sprites.info.songName.destroy(); 392 | this.sprites.info.songName = undefined; 393 | 394 | this.sprites.info.songDiff.destroy(); 395 | this.sprites.info.songDiff = undefined; 396 | 397 | this.sprites.info = undefined; 398 | } 399 | 400 | get totalNotes() { 401 | return this.notes.length; 402 | } 403 | 404 | get totalRealNotes() { 405 | let result = 0; 406 | this.notes.forEach((note) => { 407 | if (!note.isFake) result++; 408 | }); 409 | return result; 410 | } 411 | 412 | get totalFakeNotes() { 413 | let result = 0; 414 | this.notes.forEach((note) => { 415 | if (note.isFake) result++; 416 | }); 417 | return result; 418 | } 419 | } 420 | 421 | 422 | function arrangeLineEvents(events) { 423 | let oldEvents = events.slice(); 424 | let newEvents2 = []; 425 | let newEvents = [{ // 以 -99 开始 426 | startTime : -99, 427 | endTime : 0, 428 | start : oldEvents[0] ? oldEvents[0].start : 0, 429 | end : oldEvents[0] ? oldEvents[0].start : 0 430 | }]; 431 | 432 | if (events.length <= 0) return []; 433 | 434 | oldEvents.push({ // 以 1000 结束 435 | startTime : 0, 436 | endTime : 1e3, 437 | start : oldEvents[oldEvents.length - 1] ? oldEvents[oldEvents.length - 1].end : 0, 438 | end : oldEvents[oldEvents.length - 1] ? oldEvents[oldEvents.length - 1].end : 0 439 | }); 440 | 441 | // 保证时间连续性 442 | for (let oldEvent of oldEvents) { 443 | let lastNewEvent = newEvents[newEvents.length - 1]; 444 | 445 | if (oldEvent.endTime < oldEvent.startTime) 446 | { 447 | let newStartTime = oldEvent.endTime, 448 | newEndTime = oldEvent.startTime; 449 | 450 | oldEvent.startTime = newStartTime; 451 | oldEvent.endTime = newEndTime; 452 | } 453 | 454 | if (lastNewEvent.endTime < oldEvent.startTime) 455 | { 456 | newEvents.push({ 457 | startTime : lastNewEvent.endTime, 458 | endTime : oldEvent.startTime, 459 | start : lastNewEvent.end, 460 | end : lastNewEvent.end 461 | }, oldEvent); 462 | } 463 | else if (lastNewEvent.endTime == oldEvent.startTime) 464 | { 465 | newEvents.push(oldEvent); 466 | } 467 | else if (lastNewEvent.endTime > oldEvent.startTime) 468 | { 469 | if (lastNewEvent.endTime < oldEvent.endTime) 470 | { 471 | newEvents.push({ 472 | startTime : lastNewEvent.endTime, 473 | endTime : oldEvent.endTime, 474 | start : oldEvent.start + (oldEvent.end - oldEvent.start) * ((lastNewEvent.endTime - oldEvent.startTime) / (oldEvent.endTime - oldEvent.startTime)) + (lastNewEvent.end - oldEvent.start), 475 | end : oldEvent.end 476 | }); 477 | } 478 | } 479 | } 480 | 481 | // 合并相同变化率事件 482 | newEvents2 = [ newEvents.shift() ]; 483 | for (let newEvent of newEvents) 484 | { 485 | let lastNewEvent2 = newEvents2[newEvents2.length - 1]; 486 | let duration1 = lastNewEvent2.endTime - lastNewEvent2.startTime; 487 | let duration2 = newEvent.endTime - newEvent.startTime; 488 | 489 | if (newEvent.startTime == newEvent.endTime) 490 | { 491 | // 忽略此分支 492 | } 493 | else if ( 494 | lastNewEvent2.end == newEvent.start && 495 | (lastNewEvent2.end - lastNewEvent2.start) * duration2 == (newEvent.end - newEvent.start) * duration1 496 | ) { 497 | newEvents2[newEvents2.length - 1].endTime = newEvent.endTime; 498 | newEvents2[newEvents2.length - 1].end = newEvent.end; 499 | } 500 | else 501 | { 502 | newEvents2.push(newEvent); 503 | } 504 | } 505 | 506 | return newEvents.slice(); 507 | } 508 | 509 | 510 | function arrangeSingleValueLineEvents(events) { 511 | let oldEvents = events.slice(); 512 | let newEvents = [ oldEvents.shift() ]; 513 | 514 | if (events.length <= 0) return []; 515 | 516 | // 保证时间连续性 517 | for (let oldEvent of oldEvents) 518 | { 519 | let lastNewEvent = newEvents[newEvents.length - 1]; 520 | 521 | if (oldEvent.endTime < oldEvent.startTime) 522 | { 523 | let newStartTime = oldEvent.endTime, 524 | newEndTime = oldEvent.startTime; 525 | 526 | oldEvent.startTime = newStartTime; 527 | oldEvent.endTime = newEndTime; 528 | } 529 | 530 | if (lastNewEvent.value == oldEvent.value) 531 | { 532 | lastNewEvent.endTime = oldEvent.endTime; 533 | } 534 | else if (lastNewEvent.endTime < oldEvent.startTime || lastNewEvent.endTime > oldEvent.startTime) 535 | { 536 | lastNewEvent.endTime = oldEvent.startTime; 537 | newEvents.push(oldEvent); 538 | } 539 | else 540 | { 541 | newEvents.push(oldEvent); 542 | } 543 | } 544 | 545 | return newEvents.slice(); 546 | } 547 | -------------------------------------------------------------------------------- /src/chart/judgeline.js: -------------------------------------------------------------------------------- 1 | import * as verify from '@/verify'; 2 | import utils from './convert/utils'; 3 | import { Sprite, Container, Text, Graphics } from 'pixi.js'; 4 | 5 | export default class Judgeline 6 | { 7 | constructor(params) 8 | { 9 | this.id = verify.number(params.id, -1, 0); 10 | this.texture = params.texture ? params.texture : null; 11 | this.parentLine = params.parentLine || params.parentLine === 0 ? params.parentLine : null; 12 | this.zIndex = verify.number(params.zIndex, NaN); 13 | this.isCover = verify.bool(params.isCover, true); 14 | this.useOfficialScale = false; 15 | 16 | this.eventLayers = []; 17 | this.floorPositions = []; 18 | this.extendEvent = { 19 | color: [], 20 | scaleX: [], 21 | scaleY: [], 22 | text: [], 23 | incline: [] 24 | }; 25 | this.noteControls = { 26 | alpha: [], 27 | scale: [], 28 | x: [], 29 | /* y: [] */ 30 | }; 31 | this.isText = false; 32 | 33 | this.sprite = undefined; 34 | 35 | this.reset(); 36 | } 37 | 38 | reset() 39 | { 40 | this.speed = 1; 41 | this.x = 0.5; 42 | this.y = 0.5; 43 | this.alpha = 1; 44 | this.deg = 0; 45 | this.sinr = 0; 46 | this.cosr = 1; 47 | 48 | this.floorPosition = 0; 49 | 50 | this.baseScaleX = 3; 51 | this.baseScaleY = 2.88; 52 | 53 | if (this.extendEvent.scaleX.length > 0 && this.extendEvent.scaleX[0].startTime <= 0) this.scaleX = this.extendEvent.scaleX[0].start; 54 | else this.scaleX = 1; 55 | if (this.extendEvent.scaleY.length > 0 && this.extendEvent.scaleY[0].startTime <= 0) this.scaleY = this.extendEvent.scaleY[0].start; 56 | else this.scaleY = 1; 57 | 58 | this.inclineSinr = NaN; 59 | this.color = NaN; 60 | 61 | if (this.sprite) 62 | { 63 | this.sprite.alpha = 1; 64 | this.sprite.angle = 0; 65 | this.sprite.scale.set(1); 66 | 67 | if (this.isText) 68 | { 69 | this.sprite.text = ''; 70 | } 71 | } 72 | } 73 | 74 | sortEvent(withEndTime = false) 75 | { 76 | this.eventLayers.forEach((eventLayer) => 77 | { 78 | eventLayer.sort(); 79 | }); 80 | 81 | for (const name in this.extendEvent) 82 | { 83 | this.extendEvent[name].sort((a, b) => a.startTime - b.startTime); 84 | } 85 | 86 | for (const name in this.eventLayers[0]) 87 | { 88 | if (name == 'speed' || !(this.eventLayers[0][name] instanceof Array)) continue; 89 | if (this.eventLayers[0][name].length <= 0) continue; 90 | if (this.eventLayers[0][name][0].startTime <= 0) continue; 91 | this.eventLayers[0][name].unshift({ 92 | startTime : 1 - 100, 93 | endTime : this.eventLayers[0][name][0].startTime, 94 | start : 0, 95 | end : 0 96 | }); 97 | } 98 | 99 | for (const name in this.noteControls) 100 | { 101 | this.noteControls[name].sort((a, b) => b.y - a.y); 102 | } 103 | } 104 | 105 | calcFloorPosition() 106 | { 107 | if (this.eventLayers.length <= 0) throw new Error('No event layer in this judgeline'); 108 | 109 | let noSpeedEventsLayerCount = 0; 110 | this.eventLayers.forEach((eventLayer) => 111 | { 112 | eventLayer.speed = utils.arrangeSameSingleValueEvent(eventLayer.speed); 113 | if (eventLayer.speed.length < 1) noSpeedEventsLayerCount++; 114 | }); 115 | 116 | if (noSpeedEventsLayerCount == this.eventLayers.length) 117 | { 118 | console.warn('Line ' + this.id + ' don\'t have any speed event, use default speed.'); 119 | this.eventLayers[0].speed.push({ 120 | startTime: 0, 121 | endTime: 1e4, 122 | start: 1, 123 | end: 1 124 | }); 125 | } 126 | 127 | let sameTimeSpeedEventAlreadyExist = {}; 128 | let currentFloorPosition = 0; 129 | let floorPositions = []; 130 | 131 | this.floorPositions = []; 132 | 133 | this.eventLayers.forEach((eventLayer, eventLayerIndex) => 134 | { 135 | eventLayer.speed.forEach((event, eventIndex) => 136 | { 137 | event.endTime = eventLayer.speed[eventIndex + 1] ? eventLayer.speed[eventIndex + 1].startTime : 1e4; 138 | 139 | let eventTime = (event.startTime).toFixed(3); 140 | 141 | if (!sameTimeSpeedEventAlreadyExist[eventTime]) 142 | { 143 | floorPositions.push({ 144 | startTime : event.startTime, 145 | endTime : NaN, 146 | floorPosition : NaN 147 | }); 148 | } 149 | 150 | sameTimeSpeedEventAlreadyExist[eventTime] = true; 151 | }); 152 | 153 | if (eventLayerIndex === 0 && eventLayer.speed[0].startTime > 0) 154 | { 155 | eventLayer.speed.unshift({ 156 | startTime : 1 - 100, 157 | endTime : eventLayer.speed[0] ? eventLayer.speed[0].startTime : 1e4, 158 | value : eventLayer.speed[0] ? eventLayer.speed[0].value : 1 159 | }); 160 | } 161 | }); 162 | 163 | floorPositions.sort((a, b) => a.startTime - b.startTime); 164 | 165 | floorPositions.unshift({ 166 | startTime : 1 - 1000, 167 | endTime : floorPositions[0] ? floorPositions[0].startTime : 1e4, 168 | floorPosition : 1 - 1000 169 | }); 170 | currentFloorPosition += floorPositions[0].endTime; 171 | 172 | for (let floorPositionIndex = 1; floorPositionIndex < floorPositions.length; floorPositionIndex++) 173 | { 174 | let currentEvent = floorPositions[floorPositionIndex]; 175 | let nextEvent = floorPositionIndex < floorPositions.length - 1 ? floorPositions[floorPositionIndex + 1] : { startTime: 1e4 }; 176 | let currentTime = currentEvent.startTime; 177 | 178 | floorPositions[floorPositionIndex].floorPosition = currentFloorPosition; 179 | floorPositions[floorPositionIndex].endTime = nextEvent.startTime; 180 | 181 | currentFloorPosition += (nextEvent.startTime - currentEvent.startTime) * this._calcSpeedValue(currentTime); 182 | } 183 | 184 | this.floorPositions = floorPositions; 185 | } 186 | 187 | getFloorPosition(time) 188 | { 189 | if (this.floorPositions.length <= 0) throw new Error('No floorPosition created, please call calcFloorPosition() first'); 190 | 191 | let result = {}; 192 | 193 | for (const floorPosition of this.floorPositions) 194 | { 195 | if (floorPosition.endTime < time) continue; 196 | if (floorPosition.startTime > time) break; 197 | 198 | result.startTime = floorPosition.startTime; 199 | result.endTime = floorPosition.endTime; 200 | result.floorPosition = floorPosition.floorPosition; 201 | } 202 | 203 | result.value = this._calcSpeedValue(time); 204 | 205 | return result; 206 | } 207 | 208 | _calcSpeedValue(time) 209 | { 210 | let result = 0; 211 | 212 | this.eventLayers.forEach((eventLayer) => 213 | { 214 | let currentValue = 0; 215 | 216 | for (const event of eventLayer.speed) 217 | { 218 | if (event.endTime < time) continue; 219 | if (event.startTime > time) break; 220 | currentValue = event.value; 221 | } 222 | 223 | result += currentValue; 224 | }); 225 | 226 | return result; 227 | } 228 | 229 | createSprite(texture, zipFiles, debug = false) 230 | { 231 | if (this.sprite) return this.sprite; 232 | 233 | if (!this.isText) 234 | { 235 | this.sprite = new Sprite(zipFiles[this.texture] ? zipFiles[this.texture] : texture.judgeline); 236 | 237 | if (this.texture) 238 | { 239 | this.baseScaleX = this.baseScaleY = 1; 240 | } 241 | } 242 | else 243 | { 244 | this.sprite = new Text('', { 245 | fontFamily: 'MiSans', 246 | align: 'center', 247 | fill: 0xFFFFFF 248 | }); 249 | } 250 | 251 | this.sprite.anchor.set(0.5); 252 | this.sprite.alpha = 1; 253 | 254 | // For debug propose 255 | if (debug) 256 | { 257 | let lineInfoContainer = new Container(); 258 | let lineId = new Text(this.id, { 259 | fontSize: 48, 260 | fill: 0xFF00A0 261 | }); 262 | let linePosBlock = new Graphics() 263 | .beginFill(0xFF00A0) 264 | .drawRect(-22, -22, 44, 44) 265 | .endFill(); 266 | 267 | lineId.anchor.set(0.5); 268 | lineId.position.set(0, -36 - lineId.width / 2); 269 | 270 | /* 271 | lineId.cacheAsBitmap = true; 272 | linePosBlock.cacheAsBitmap = true; 273 | */ 274 | 275 | lineInfoContainer.addChild(lineId); 276 | lineInfoContainer.addChild(linePosBlock); 277 | 278 | this.debugSprite = lineInfoContainer; 279 | } 280 | 281 | if (this.extendEvent.scaleX.length > 0 && this.extendEvent.scaleX[0].startTime <= 0) 282 | { 283 | this.scaleX = this.extendEvent.scaleX[0].start; 284 | } 285 | if (this.extendEvent.scaleY.length > 0 && this.extendEvent.scaleY[0].startTime <= 0) 286 | { 287 | this.scaleY = this.extendEvent.scaleY[0].start; 288 | } 289 | 290 | return this.sprite; 291 | } 292 | 293 | calcTime(currentTime, size) 294 | { 295 | this.speed = 0; 296 | this.x = 0; 297 | this.y = 0; 298 | this.alpha = 0; 299 | this.deg = 0; 300 | 301 | for (let i = 0, length = this.eventLayers.length; i < length; i++) 302 | { 303 | let eventLayer = this.eventLayers[i]; 304 | eventLayer.calcTime(currentTime); 305 | 306 | this.speed += eventLayer._speed; 307 | this.x += eventLayer._posX; 308 | this.y += eventLayer._posY; 309 | this.alpha += eventLayer._alpha; 310 | this.deg += eventLayer._rotate; 311 | } 312 | 313 | for (let i = 0, length = this.floorPositions.length; i < length; i++) 314 | { 315 | let event = this.floorPositions[i]; 316 | if (event.endTime < currentTime) continue; 317 | if (event.startTime > currentTime) break; 318 | 319 | this.floorPosition = (currentTime - event.startTime) * this.speed + event.floorPosition; 320 | }; 321 | 322 | for (let i = 0, length = this.extendEvent.scaleX.length; i < length; i++) 323 | { 324 | let event = this.extendEvent.scaleX[i]; 325 | if (event.endTime < currentTime) continue; 326 | if (event.startTime > currentTime) break; 327 | 328 | let timePercentEnd = (currentTime - event.startTime) / (event.endTime - event.startTime); 329 | let timePercentStart = 1 - timePercentEnd; 330 | 331 | this.scaleX = event.start * timePercentStart + event.end * timePercentEnd; 332 | this.sprite.scale.x = this.scaleX * this.baseScaleX; 333 | } 334 | 335 | for (let i = 0, length = this.extendEvent.scaleY.length; i < length; i++) 336 | { 337 | let event = this.extendEvent.scaleY[i]; 338 | if (event.endTime < currentTime) continue; 339 | if (event.startTime > currentTime) break; 340 | 341 | let timePercentEnd = (currentTime - event.startTime) / (event.endTime - event.startTime); 342 | let timePercentStart = 1 - timePercentEnd; 343 | 344 | this.scaleY = event.start * timePercentStart + event.end * timePercentEnd; 345 | this.sprite.scale.y = this.scaleY * this.baseScaleY; 346 | } 347 | 348 | for (let i = 0, length = this.extendEvent.text.length; i < length; i++) 349 | { 350 | let event = this.extendEvent.text[i]; 351 | if (event.endTime < currentTime) continue; 352 | if (event.startTime > currentTime) break; 353 | 354 | this.sprite.text = event.value; 355 | } 356 | 357 | for (let i = 0, length = this.extendEvent.color.length; i < length; i++) 358 | { 359 | let event = this.extendEvent.color[i]; 360 | if (event.endTime < currentTime) continue; 361 | if (event.startTime > currentTime) break; 362 | 363 | this.color = this.sprite.tint = event.value; 364 | } 365 | 366 | for (let i = 0, length = this.extendEvent.incline.length; i < length; i++) 367 | { 368 | let event = this.extendEvent.incline[i]; 369 | if (event.endTime < currentTime) continue; 370 | if (event.startTime > currentTime) break; 371 | 372 | let timePercentEnd = (currentTime - event.startTime) / (event.endTime - event.startTime); 373 | let timePercentStart = 1 - timePercentEnd; 374 | 375 | this.inclineSinr = Math.sin(event.start * timePercentStart + event.end * timePercentEnd); 376 | } 377 | 378 | this.cosr = Math.cos(this.deg); 379 | this.sinr = Math.sin(this.deg); 380 | 381 | if (this.parentLine) 382 | { 383 | let newPosX = (this.x * this.parentLine.cosr + this.y * this.parentLine.sinr) * 0.918554 + this.parentLine.x, 384 | newPosY = (this.y * this.parentLine.cosr - this.x * this.parentLine.sinr) * 1.088662 + this.parentLine.y; 385 | 386 | this.x = newPosX; 387 | this.y = newPosY; 388 | } 389 | 390 | this.sprite.position.x = (this.x + 0.5) * size.width; 391 | this.sprite.position.y = (0.5 - this.y) * size.height; 392 | this.sprite.alpha = this.alpha >= 0 ? this.alpha : 0; 393 | this.sprite.rotation = this.deg; 394 | this.sprite.visible = (this.alpha > 0); 395 | 396 | /* 397 | if (this.sprite.alpha <= 0) this.sprite.visible = false; 398 | else this.sprite.visible = true; 399 | */ 400 | 401 | /* 402 | this.sprite.width = this._width * this.scaleX; 403 | this.sprite.height = this._height * this.scaleY; 404 | */ 405 | 406 | if (this.debugSprite) 407 | { 408 | this.debugSprite.position = this.sprite.position; 409 | this.debugSprite.rotation = this.sprite.rotation; 410 | this.debugSprite.alpha = 0.2 + (this.sprite.alpha * 0.8); 411 | } 412 | } 413 | 414 | calcNoteControl(y, valueType, defaultValue) 415 | { 416 | for (let i = 0, length = this.noteControls[valueType].length; i < length; i++) 417 | { 418 | if (this.noteControls[valueType][i].y < y) return this.noteControls[valueType][i - 1].value; 419 | } 420 | return defaultValue; 421 | } 422 | } 423 | 424 | -------------------------------------------------------------------------------- /src/chart/note.js: -------------------------------------------------------------------------------- 1 | import * as verify from '@/verify'; 2 | import { Sprite, Container, Text, Graphics } from 'pixi.js'; 3 | 4 | export default class Note 5 | { 6 | constructor(params) 7 | { 8 | this.id = verify.number(params.id, -1, 0); 9 | this.type = verify.number(params.type, 1, 1, 4); 10 | this.time = verify.number(params.time, -1); // Note 开始时间 11 | this.holdTime = this.type === 3 ? verify.number(params.holdTime, 0) : 0; // Note 按住需要经过的时间,仅 Hold 12 | this.holdTimeLength = this.type === 3 ? parseFloat(this.time + this.holdTime) : 0; // Note 按完的时间,自动计算,仅 Hold 13 | this.speed = verify.number(params.speed, 1); 14 | this.floorPosition = verify.number(params.floorPosition, this.time); 15 | this.holdLength = this.type === 3 ? verify.number(params.holdLength, 0) : 0; 16 | this.endPosition = parseFloat(this.floorPosition + this.holdLength); 17 | this.positionX = verify.number(params.positionX, 0); 18 | this.basicAlpha = verify.number(params.basicAlpha, 1, 0, 1); 19 | this.visibleTime = verify.number(params.visibleTime, NaN, 0, 999998); 20 | this.yOffset = verify.number(params.yOffset, 0); 21 | this.xScale = verify.number(params.xScale, 1, 0); 22 | this.isAbove = verify.bool(params.isAbove, true); 23 | this.isFake = verify.bool(params.isFake, false); 24 | this.isMulti = verify.bool(params.isMulti, false); 25 | this.useOfficialSpeed = verify.bool(params.useOfficialSpeed, false); 26 | this.texture = (params.texture && params.texture != '') ? params.texture : null; 27 | this.hitsound = (params.hitsound && params.hitsound != '') ? params.hitsound : null; 28 | this.judgeline = params.judgeline; 29 | 30 | this.sprite = undefined; 31 | 32 | if (!this.judgeline) throw new Error('Note must have a judgeline'); 33 | 34 | this.reset(); 35 | } 36 | 37 | reset() 38 | { 39 | this.isScored = false; 40 | this.isScoreAnimated = false; 41 | this.isHolding = false; 42 | this.lastHoldTime = NaN; 43 | this.score = 0; 44 | this.scoreTime = 0; 45 | 46 | if (this.sprite) this.sprite.alpha = this.basicAlpha; 47 | } 48 | 49 | createSprite(texture, zipFiles, multiHL = true, debug = false) 50 | { 51 | if (this.sprite) return this.sprite; 52 | 53 | switch (this.type) 54 | { 55 | case 1: 56 | { 57 | this.sprite = new Sprite( 58 | this.texture && this.texture != '' ? 59 | zipFiles[this.texture] : 60 | texture['tap' + (this.isMulti && multiHL ? 'HL' : '')] 61 | ); 62 | break; 63 | } 64 | case 2: 65 | { 66 | this.sprite = new Sprite( 67 | this.texture && this.texture != '' ? 68 | zipFiles[this.texture] : 69 | texture['drag' + (this.isMulti && multiHL ? 'HL' : '')] 70 | ); 71 | break; 72 | } 73 | case 3: 74 | { 75 | if (this.texture && this.texture != '') 76 | { 77 | this.sprite = new Sprite(zipFiles[this.texture]); 78 | this.sprite.anchor.set(0.5, 1); 79 | this.sprite.height = this.holdLength; 80 | } 81 | else 82 | { 83 | this.sprite = new Container(); 84 | 85 | let head = new Sprite(texture['holdHead' + (this.isMulti && multiHL ? 'HL' : '')]); 86 | let body = new Sprite(texture['holdBody' + (this.isMulti && multiHL ? 'HL' : '')]); 87 | let end = new Sprite(texture['holdEnd']); 88 | 89 | head.anchor.set(0.5); 90 | body.anchor.set(0.5, 1); 91 | end.anchor.set(0.5, 1); 92 | 93 | body.height = this.holdLength; 94 | 95 | head.position.set(0, head.height / 2); 96 | body.position.set(0, 0); 97 | end.position.set(0, -body.height); 98 | 99 | this.sprite.addChild(head); 100 | this.sprite.addChild(body); 101 | this.sprite.addChild(end); 102 | } 103 | break; 104 | } 105 | case 4: 106 | { 107 | this.sprite = new Sprite( 108 | this.texture && this.texture != '' ? 109 | zipFiles[this.texture] : 110 | texture['flick' + (this.isMulti && multiHL ? 'HL' : '')] 111 | ); 112 | break; 113 | } 114 | default : 115 | { 116 | throw new Error('Unsupported note type: ' + this.type); 117 | } 118 | } 119 | 120 | if (this.type !== 3) this.sprite.anchor.set(0.5); 121 | if (!this.isAbove) this.sprite.angle = 180; 122 | this.sprite.alpha = this.basicAlpha; 123 | this.sprite.visible = false; 124 | this.sprite.outScreen = true; 125 | 126 | if (this.hitsound) 127 | { 128 | this.hitsound = zipFiles[this.hitsound]; 129 | } 130 | 131 | // For debug propose 132 | if (debug) 133 | { 134 | let noteInfoContainer = new Container(); 135 | let noteId = new Text(this.judgeline.id + (this.isAbove ? '+' : '-') + this.id, { 136 | fontSize: 48, 137 | fill: 0x00E6FF 138 | }); 139 | let notePosBlock = new Graphics() 140 | .beginFill(0x00E6FF) 141 | .drawRect(-22, -22, 44, 44) 142 | .endFill(); 143 | 144 | noteId.anchor.set(0.5); 145 | noteId.position.set(0, -36 - noteId.height / 2); 146 | noteId.angle = this.isAbove ? 0 : 180; 147 | 148 | /* 149 | noteId.cacheAsBitmap = true; 150 | notePosBlock.cacheAsBitmap = true; 151 | */ 152 | 153 | noteInfoContainer.addChild(noteId); 154 | noteInfoContainer.addChild(notePosBlock); 155 | 156 | this.debugSprite = noteInfoContainer; 157 | } 158 | 159 | return this.sprite; 160 | } 161 | 162 | calcTime(currentTime, size) 163 | { 164 | let _yOffset = size.height * this.yOffset, 165 | yOffset = _yOffset * (this.isAbove ? -1 : 1), 166 | originX = size.widthPercent * this.positionX, 167 | _originY = (this.floorPosition - this.judgeline.floorPosition) * (this.type === 3 && this.useOfficialSpeed ? 1 : this.speed) * size.noteSpeed + _yOffset, 168 | originY = _originY * (this.isAbove ? -1 : 1), 169 | 170 | realX = originY * this.judgeline.sinr * -1, 171 | realY = originY * this.judgeline.cosr, 172 | 173 | _holdLength = this.type === 3 ? (this.useOfficialSpeed ? (this.holdTimeLength - currentTime) : (this.endPosition - this.judgeline.floorPosition)) * this.speed * size.noteSpeed : _originY, 174 | holdLength = this.type === 3 ? _holdLength * (this.isAbove ? -1 : 1) : originY; 175 | 176 | if (!isNaN(this.judgeline.inclineSinr) && this.type !== 3) 177 | { 178 | let inclineValue = 1 - ((this.judgeline.inclineSinr * _originY) / 360); 179 | this.sprite.scale.set(inclineValue * this.sprite.baseScale * this.xScale, inclineValue * this.sprite.baseScale); 180 | originX *= inclineValue; 181 | } 182 | 183 | if (this.type !== 3) 184 | { 185 | // _originY *= this.judgeline.calcNoteControl(_originY, 'y', 1); 186 | originX *= this.judgeline.calcNoteControl(_originY, 'x', 1); 187 | } 188 | 189 | if (this.type === 3) // Hold 长度计算 190 | { 191 | if (this.time <= currentTime && this.holdTimeLength > currentTime) 192 | { 193 | realX = realY = 0; 194 | 195 | this.sprite.children[0].visible = false; 196 | this.sprite.children[1].height = _holdLength / size.noteScale; 197 | this.sprite.children[2].position.y = this.sprite.children[1].height * -1; 198 | } 199 | else 200 | { 201 | this.sprite.children[0].visible = true; 202 | } 203 | } 204 | 205 | // Note 落在判定线时的绝对位置计算 206 | this.sprite.judgelineX = originX * this.judgeline.cosr + this.judgeline.sprite.position.x; 207 | this.sprite.judgelineY = originX * this.judgeline.sinr + this.judgeline.sprite.position.y; 208 | 209 | // Note 的绝对位置计算 210 | realX += this.sprite.judgelineX; 211 | realY += this.sprite.judgelineY; 212 | 213 | // Note 落在判定线时的绝对位置计算(补 y 轴偏移) 214 | this.sprite.judgelineX += yOffset * this.judgeline.sinr * -1; 215 | this.sprite.judgelineY += yOffset * this.judgeline.cosr; 216 | 217 | // Note 是否在舞台可视范围内 218 | this.sprite.outScreen = !isInArea({ 219 | startX : realX, 220 | endX : originX * this.judgeline.cosr - holdLength * this.judgeline.sinr + this.judgeline.sprite.position.x, 221 | startY : realY, 222 | endY : holdLength * this.judgeline.cosr + originX * this.judgeline.sinr + this.judgeline.sprite.position.y 223 | }, size); 224 | 225 | // 推送计算结果到精灵 226 | this.sprite.visible = !this.sprite.outScreen; 227 | if (this.debugSprite) this.debugSprite.visible = !this.sprite.outScreen; 228 | 229 | this.sprite.position.x = realX; 230 | this.sprite.position.y = realY; 231 | 232 | this.sprite.angle = this.judgeline.sprite.angle + (this.isAbove ? 0 : 180); 233 | 234 | // Note 在舞台可视范围之内时做进一步计算 235 | if (!this.sprite.outScreen) 236 | { 237 | if (this.type !== 3) 238 | { 239 | let noteCtrlScale = this.judgeline.calcNoteControl(_originY, 'scale', 1); 240 | this.sprite.scale.set(this.sprite.baseScale * this.xScale * noteCtrlScale, this.sprite.baseScale * noteCtrlScale); 241 | this.sprite.alpha = this.isScoreAnimated ? 0 : this.basicAlpha * this.judgeline.calcNoteControl(_originY, 'alpha', 1); 242 | } 243 | else 244 | { 245 | this.sprite.alpha = this.isScored && this.score <= 1 ? 0.5 : this.basicAlpha * this.judgeline.calcNoteControl(_originY, 'alpha', 1); 246 | } 247 | 248 | this.sprite.visible = (this.sprite.alpha > 0); 249 | 250 | // Note 特殊位置是否可视控制 251 | if (this.type !== 3 && this.time > currentTime && _originY < 0 && this.judgeline.isCover) this.sprite.visible = false; 252 | if (this.type !== 3 && this.isFake && this.time <= currentTime) this.sprite.visible = false; 253 | if ( 254 | this.type === 3 && 255 | ( 256 | (this.time > currentTime && _originY < 0 && this.judgeline.isCover) || // 时间未开始时 Hold 在判定线对面 257 | (this.holdTimeLength <= currentTime) // Hold 已经被按完 258 | ) 259 | ) this.sprite.visible = false; 260 | 261 | if (!isNaN(this.visibleTime) && this.time - currentTime > this.visibleTime) this.sprite.visible = false; 262 | 263 | if (this.judgeline.alpha < 0) 264 | { 265 | if (this.judgeline.alpha >= -1) this.sprite.visible = false; 266 | else if (this.judgeline.alpha >= -2) 267 | { 268 | if (this.originY > 0) this.sprite.visible = false; 269 | else if (this.originY < 0) this.sprite.visible = true; 270 | } 271 | } 272 | 273 | if (this.debugSprite) 274 | { 275 | this.debugSprite.position = this.sprite.position; 276 | this.debugSprite.angle = this.sprite.angle; 277 | this.debugSprite.alpha = 0.2 + (this.sprite.visible ? (this.sprite.alpha * 0.8) : 0); 278 | 279 | if (this.time > currentTime) 280 | { 281 | if (!this.sprite.visible) 282 | { 283 | this.sprite.visible = true; 284 | this.sprite.alpha = 0.2; 285 | } 286 | else 287 | { 288 | this.sprite.alpha = this.basicAlpha; 289 | } 290 | } 291 | } 292 | } 293 | } 294 | }; 295 | 296 | 297 | function isInArea(sprite, area) 298 | { 299 | let startX = sprite.startX <= sprite.endX ? sprite.startX : sprite.endX, 300 | endX = sprite.startX <= sprite.endX ? sprite.endX : sprite.startX, 301 | startY = sprite.startY <= sprite.endY ? sprite.startY : sprite.endY, 302 | endY = sprite.startY <= sprite.endY ? sprite.endY : sprite.startY; 303 | /* 304 | if ( 305 | ( 306 | isInValueArea(sprite.startX, area.startX, area.endX) || 307 | isInValueArea(sprite.endX, area.startX, area.endX) 308 | ) && 309 | ( 310 | isInValueArea(sprite.startY, area.startY, area.endY) || 311 | isInValueArea(sprite.endY, area.startY, area.endY) 312 | ) 313 | ) { 314 | return true; 315 | } 316 | else 317 | { 318 | return false; 319 | } 320 | */ 321 | 322 | if ( 323 | ( 324 | startX >= area.startX && startY >= area.startY && 325 | endX <= area.endX && endY <= area.endY 326 | ) || 327 | ( 328 | endX >= area.startX && endY >= area.startY && 329 | startX <= area.endX && startY <= area.endY 330 | ) 331 | ) { 332 | return true; 333 | } 334 | else 335 | { 336 | return false; 337 | } 338 | } -------------------------------------------------------------------------------- /src/effect/index.js: -------------------------------------------------------------------------------- 1 | import { bool as verifyBool } from '@/verify'; 2 | import * as Reader from './reader'; 3 | 4 | 5 | export default class Effect 6 | { 7 | constructor(params) 8 | { 9 | this.shader = params.shader; 10 | this.startTime = params.startTime; 11 | this.endTime = params.endTime; 12 | this.isGlobal = verifyBool(params.isGlobal, false); 13 | this.vars = {}; 14 | 15 | this.reset(); 16 | } 17 | 18 | reset() 19 | { 20 | this._currentValue = (this.shader !== null && typeof this.shader !== 'string') ? this.shader.defaultValues : {}; 21 | } 22 | 23 | static from(json) 24 | { 25 | let result; 26 | 27 | if (typeof json === 'object') 28 | { 29 | result = Reader.PrprEffectReader(json); 30 | } 31 | 32 | if (!result || result.length <= 0) 33 | { 34 | throw new Error('Unsupported file format'); 35 | } 36 | 37 | return result; 38 | } 39 | 40 | calcTime(currentTime, screenSize) 41 | { 42 | if (this.shader === null) return; 43 | 44 | const { vars, shader, _currentValue } = this; 45 | 46 | for (const name in vars) 47 | { 48 | const values = vars[name]; 49 | if (typeof values === 'object') _currentValue[name] = valueCalculator(values, currentTime, shader.defaultValues[name]); 50 | else _currentValue[name] = values; 51 | } 52 | 53 | shader.update({ ..._currentValue, time: currentTime, screenSize: screenSize }); 54 | } 55 | } 56 | 57 | function valueCalculator(values, currentTime, defaultValue) 58 | { 59 | for (let i = 0, length = values.length; i < length; i++) 60 | { 61 | const value = values[i]; 62 | if (value.endTime < currentTime) continue; 63 | if (value.startTime > currentTime) break; 64 | if (value.start === value.end) return value.start; 65 | 66 | let timePercentEnd = (currentTime - value.startTime) / (value.endTime - value.startTime); 67 | let timePercentStart = 1 - timePercentEnd; 68 | 69 | return value.start * timePercentStart + value.end * timePercentEnd; 70 | } 71 | 72 | return defaultValue; 73 | } 74 | 75 | // The thing that needs to be done: 76 | // 1. Calculate values in ./game/ticker (Now pre-calced) 77 | // 2. Integrate effects into the chart (./chart/index) 78 | // 3. Update uniforms in ./game/index 79 | // If there's anything left that's probably bugfixing. 80 | 81 | // Effects should act on Game rather than Chart since 82 | // the filter is loaded by Game and effected on Containers 83 | 84 | // I guess now it's all done -------------------------------------------------------------------------------- /src/effect/reader/index.js: -------------------------------------------------------------------------------- 1 | export { default as PrprEffectReader } from './prpr'; -------------------------------------------------------------------------------- /src/effect/reader/prpr.js: -------------------------------------------------------------------------------- 1 | import Effect from '../index' 2 | import utils from '@/chart/convert/utils'; 3 | 4 | const Easing = [ 5 | (x) => x, 6 | (x) => Math.sin((x * Math.PI) / 2), 7 | (x) => 1 - Math.cos((x * Math.PI) / 2), 8 | (x) => 1 - (1 - x) * (1 - x), 9 | (x) => x * x, 10 | (x) => -(Math.cos(Math.PI * x) - 1) / 2, 11 | (x) => x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2, 12 | (x) => 1 - Math.pow(1 - x, 3), 13 | (x) => x * x * x, 14 | (x) => 1 - Math.pow(1 - x, 4), 15 | (x) => x * x * x * x, 16 | (x) => x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2, 17 | (x) => x < 0.5 ? 8 * x * x * x * x : 1 - Math.pow(-2 * x + 2, 4) / 2, 18 | (x) => 1 - Math.pow(1 - x, 5), 19 | (x) => x * x * x * x * x, 20 | (x) => x === 1 ? 1 : 1 - Math.pow(2, -10 * x), 21 | (x) => x === 0 ? 0 : Math.pow(2, 10 * x - 10), 22 | (x) => Math.sqrt(1 - Math.pow(x - 1, 2)), 23 | (x) => 1 - Math.sqrt(1 - Math.pow(x, 2)), 24 | (x) => 1 + 2.70158 * Math.pow(x - 1, 3) + 1.70158 * Math.pow(x - 1, 2), 25 | (x) => 2.70158 * x * x * x - 1.70158 * x * x, 26 | (x) => x < 0.5 ? (1 - Math.sqrt(1 - Math.pow(2 * x, 2))) / 2 : (Math.sqrt(1 - Math.pow(-2 * x + 2, 2)) + 1) / 2, 27 | (x) => x < 0.5 ? (Math.pow(2 * x, 2) * ((2.594910 + 1) * 2 * x - 2.594910)) / 2 : (Math.pow(2 * x - 2, 2) * ((2.594910 + 1) * (x * 2 - 2) + 2.594910) + 2) / 2, 28 | (x) => x === 0 ? 0 : x === 1 ? 1 : Math.pow(2, -10 * x) * Math.sin((x * 10 - 0.75) * ((2 * Math.PI) / 3)) + 1, 29 | (x) => x === 0 ? 0 : x === 1 ? 1 : -Math.pow(2, 10 * x - 10) * Math.sin((x * 10 - 10.75) * ((2 * Math.PI) / 3)), 30 | (x) => x < 1 / 2.75 ? 7.5625 * x * x : x < 2 / 2.75 ? 7.5625 * (x -= 1.5 / 2.75) * x + 0.75 : x < 2.5 / 2.75 ? 7.5625 * (x -= 2.25 / 2.75) * x + 0.9375 : 7.5625 * (x -= 2.625 / 2.75) * x + 0.984375, 31 | (x) => 1 - Easing[25](1 - x), 32 | (x) => x < 0.5 ? (1 - Easing[25](1 - 2 * x)) / 2 : (1 + Easing[25](2 * x - 1)) / 2 33 | ]; 34 | 35 | export default function PrprEffectReader(effect) 36 | { 37 | let effectList = []; 38 | let rawEffects = [ ...effect.effects ]; 39 | let bpmList = [ ...effect.bpm ]; 40 | 41 | { // 将 Beat 计算为对应的时间(秒) 42 | let currentBeatRealTime = 0.5; // 当前每个 Beat 的实际时长(秒) 43 | let bpmChangedBeat = 0; // 当前 BPM 是在什么时候被更改的(Beat) 44 | let bpmChangedTime = 0; // 当前 BPM 是在什么时候被更改的(秒) 45 | 46 | bpmList.forEach((bpm, index) => 47 | { 48 | bpm.endTime = bpmList[index + 1] ? bpmList[index + 1].time : [ 1e4, 0, 1 ]; 49 | 50 | bpm.startBeat = bpm.time[0] + bpm.time[1] / bpm.time[2]; 51 | bpm.endBeat = bpm.endTime[0] + bpm.endTime[1] / bpm.endTime[2]; 52 | 53 | bpmChangedTime += currentBeatRealTime * (bpm.startBeat - bpmChangedBeat); 54 | bpm.startTime = bpmChangedTime; 55 | bpm.endTime = currentBeatRealTime * (bpm.endBeat - bpmChangedBeat); 56 | 57 | bpmChangedBeat += (bpm.beat - bpmChangedBeat); 58 | 59 | currentBeatRealTime = 60 / bpm.bpm; 60 | bpm.beatTime = 60 / bpm.bpm; 61 | }); 62 | 63 | bpmList.sort((a, b) => b.beat - a.beat); 64 | } 65 | 66 | if (bpmList.length <= 0) 67 | { 68 | bpmList.push({ 69 | startBeat : 0, 70 | endBeat : 1e4, 71 | startTime : 0, 72 | endTime : 1e6 - 1, 73 | bpm : 120, 74 | beatTime : 0.5 75 | }); 76 | } 77 | 78 | utils.calculateRealTime(bpmList, calculateEffectsBeat(rawEffects)) 79 | .forEach((_effect) => 80 | { 81 | let effect = new Effect({ 82 | startTime: _effect.startTime, 83 | endTime: _effect.endTime, 84 | shader: _effect.shader, 85 | isGlobal: _effect.global || false, 86 | vars: {}, 87 | }); 88 | 89 | for (const name in _effect.vars) 90 | { 91 | let _values = _effect.vars[name]; 92 | 93 | if (_values instanceof Array) 94 | { 95 | let _timedValues = []; 96 | let values = []; 97 | 98 | utils.calculateEventsBeat(_values) 99 | .sort((a, b) => a.startTime - b.startTime || b.endTime - a.startTime) 100 | .forEach((_value, index, arr) => 101 | { 102 | let prevValue = arr[index - 1]; 103 | 104 | if (!prevValue) _timedValues.push(_value); 105 | else if (_value.startTime == prevValue.startTime) 106 | { 107 | if (_value.endTime >= prevValue.endTime) _timedValues[_timedValues.length - 1] = _value; 108 | } 109 | else _timedValues.push(_value); 110 | } 111 | ); 112 | 113 | for (const _value of _timedValues) 114 | { 115 | values.push(...utils.calculateRealTime(bpmList, utils.calculateEventEase(_value, Easing))); 116 | } 117 | values.sort((a, b) => a.startTime - b.startTime || b.endTime - a.startTime); 118 | effect.vars[name] = values; 119 | } 120 | else 121 | { 122 | effect.vars[name] = _values; 123 | } 124 | } 125 | 126 | effectList.push(effect); 127 | } 128 | ); 129 | 130 | effectList.sort((a, b) => a.startTime - b.startTime); 131 | 132 | return effectList; 133 | } 134 | 135 | 136 | 137 | function calculateEffectBeat(effect) 138 | { 139 | effect.startTime = parseFloat((effect.start[0] + (effect.start[1] / effect.start[2])).toFixed(3)); 140 | effect.endTime = parseFloat((effect.end[0] + (effect.end[1] / effect.end[2])).toFixed(3)); 141 | return effect; 142 | } 143 | 144 | function calculateEffectsBeat(effects) 145 | { 146 | effects.forEach((effect) => 147 | { 148 | effect = calculateEffectBeat(effect); 149 | }); 150 | return effects; 151 | } -------------------------------------------------------------------------------- /src/effect/shader/index.js: -------------------------------------------------------------------------------- 1 | import * as presets from './presets'; 2 | import { Filter } from 'pixi.js'; 3 | 4 | const defaultValueReg = /uniform\s+(\w+)\s+(\w+);\s+\/\/\s+%([^%]+)%/g; 5 | 6 | export default class Shader extends Filter { 7 | constructor(_shaderText, name) 8 | { 9 | const shaderText = "// " + _shaderText.replaceAll('uv', 'vTextureCoord').replaceAll('screenTexture', 'uSampler'); 10 | const defaultValues = {}; 11 | let uniforms = { 12 | time: 0, 13 | screenSize: [ 0, 0 ], 14 | UVScale: [ 0, 0 ] 15 | }; 16 | 17 | [ ...shaderText.matchAll(defaultValueReg) ].map((uniform) => 18 | { 19 | const type = uniform[1]; 20 | const name = uniform[2]; 21 | const value = uniform[3]; 22 | 23 | switch (type) 24 | { 25 | case 'float': 26 | { 27 | defaultValues[name] = parseFloat(value); 28 | break; 29 | } 30 | case 'vec2': 31 | case 'vec4': 32 | { 33 | defaultValues[name] = value.split(',').map(v => parseFloat(v.trim())); 34 | break; 35 | } 36 | default: 37 | { 38 | throw Error('Unknown type: ' + typeName); 39 | } 40 | } 41 | } 42 | ); 43 | 44 | uniforms = { ...defaultValues, ...uniforms }; 45 | super(null, shaderText, uniforms); 46 | 47 | for (const name in uniforms) this.uniforms[name] = uniforms[name]; 48 | this.defaultValues = defaultValues; 49 | 50 | this.name = name; 51 | } 52 | 53 | static from(shaderText, name) 54 | { 55 | const canvas = document.createElement('canvas'); 56 | const gl = canvas.getContext('webgl'); 57 | 58 | if (!gl) throw 'Your browser doesn\'t support WebGL.'; 59 | 60 | // Clear canvas 61 | gl.clearColor(0.0, 0.0, 0.0, 1.0); 62 | gl.clear(gl.COLOR_BUFFER_BIT); 63 | 64 | // Init shader 65 | const shader = gl.createShader(gl.FRAGMENT_SHADER); 66 | gl.shaderSource(shader, shaderText); 67 | gl.compileShader(shader); 68 | 69 | if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) 70 | { 71 | throw `An error occurred compiling the shaders.\n${gl.getShaderInfoLog(shader)}`; 72 | } 73 | 74 | return new Shader(shaderText, name); 75 | } 76 | 77 | static get presets() 78 | { 79 | return presets; 80 | } 81 | 82 | update(uniforms) { 83 | for (const name in uniforms) this.uniforms[name] = uniforms[name]; 84 | } 85 | } -------------------------------------------------------------------------------- /src/effect/shader/presets/chromatic.glsl: -------------------------------------------------------------------------------- 1 | #version 100 2 | // Adapted from https://godotshaders.com/shader/chromatic-abberation/ 3 | precision mediump float; 4 | 5 | varying lowp vec2 uv; 6 | uniform sampler2D screenTexture; 7 | 8 | uniform float sampleCount; // %3% int 1..64 9 | uniform float power; // %0.01% 10 | 11 | vec3 chromatic_slice(float t) { 12 | vec3 res = vec3(1.0 - t, 1.0 - abs(t - 1.0), t - 1.0); 13 | return max(res, 0.0); 14 | } 15 | 16 | void main() { 17 | vec3 sum = vec3(0.0); 18 | vec3 c = vec3(0.0); 19 | vec2 offset = (uv - vec2(0.5)) * vec2(1, -1); 20 | int sample_count = int(sampleCount); 21 | for (int i = 0; i < 64; ++i) { 22 | if (i >= sample_count) break; 23 | float t = 2.0 * float(i) / float(sample_count - 1); // range 0.0->2.0 24 | vec3 slice = vec3(1.0 - t, 1.0 - abs(t - 1.0), t - 1.0); 25 | slice = max(slice, 0.0); 26 | sum += slice; 27 | vec2 slice_offset = (t - 1.0) * power * offset; 28 | c += slice * texture2D(screenTexture, uv + slice_offset).rgb; 29 | } 30 | gl_FragColor.rgb = c / sum; 31 | } 32 | -------------------------------------------------------------------------------- /src/effect/shader/presets/circle_blur.glsl: -------------------------------------------------------------------------------- 1 | #version 100 2 | // Adapted from https://godotshaders.com/shader/artsy-circle-blur-type-thingy/ 3 | precision mediump float; 4 | 5 | varying lowp vec2 uv; 6 | uniform vec2 screenSize; 7 | uniform sampler2D screenTexture; 8 | 9 | uniform float size; // %10.0% 10 | 11 | void main() { 12 | vec4 c = texture2D(screenTexture, uv); 13 | float length = dot(c, c); 14 | vec2 pixel_size = 1.0 / screenSize; 15 | for (float x = -size; x < size; x++) { 16 | for (float y = -size; y < size; ++y) { 17 | if (x * x + y * y > size * size) continue; 18 | vec4 new_c = texture2D(screenTexture, uv + pixel_size * vec2(x, y)); 19 | float new_length = dot(new_c, new_c); 20 | if (new_length > length) { 21 | length = new_length; 22 | c = new_c; 23 | } 24 | } 25 | } 26 | gl_FragColor = c; 27 | } 28 | -------------------------------------------------------------------------------- /src/effect/shader/presets/fisheye.glsl: -------------------------------------------------------------------------------- 1 | #version 100 2 | // Adapted from https://www.shadertoy.com/view/4s2GRR 3 | precision mediump float; 4 | 5 | varying lowp vec2 uv; 6 | uniform vec2 screenSize; 7 | uniform sampler2D screenTexture; 8 | 9 | uniform float power; // %-0.1% 10 | 11 | void main() { 12 | vec2 p = vec2(uv.x, uv.y * screenSize.y / screenSize.x); 13 | float aspect = screenSize.x / screenSize.y; 14 | vec2 m = vec2(0.5, 0.5 / aspect); 15 | vec2 d = p - m; 16 | float r = sqrt(dot(d, d)); 17 | 18 | float new_power = (2.0 * 3.141592 / (2.0 * sqrt(dot(m, m)))) * power; 19 | 20 | float bind = new_power > 0.0? sqrt(dot(m, m)): (aspect < 1.0? m.x: m.y); 21 | 22 | vec2 nuv; 23 | if (new_power > 0.0) 24 | nuv = m + normalize(d) * tan(r * new_power) * bind / tan(bind * new_power); 25 | else 26 | nuv = m + normalize(d) * atan(r * -new_power * 10.0) * bind / atan(-new_power * bind * 10.0); 27 | 28 | gl_FragColor = texture2D(screenTexture, vec2(nuv.x, nuv.y * aspect)); 29 | } 30 | -------------------------------------------------------------------------------- /src/effect/shader/presets/glitch.glsl: -------------------------------------------------------------------------------- 1 | #version 100 2 | // Adapted from https://godotshaders.com/shader/glitch-effect-shader/ 3 | precision highp float; 4 | 5 | varying lowp vec2 uv; 6 | uniform sampler2D screenTexture; 7 | uniform float time; 8 | 9 | uniform float power; // %0.03% 10 | uniform float rate; // %0.6% 0..1 11 | uniform float speed; // %5.0% 12 | uniform float blockCount; // %30.5% 13 | uniform float colorRate; // %0.01% 0..1 14 | 15 | float my_trunc(float x) { 16 | return x < 0.0? -floor(-x): floor(x); 17 | } 18 | 19 | float random(float seed) { 20 | return fract(543.2543 * sin(dot(vec2(seed, seed), vec2(3525.46, -54.3415)))); 21 | } 22 | 23 | void main() { 24 | float enable_shift = float(random(my_trunc(time * speed)) < rate); 25 | 26 | vec2 fixed_uv = uv; 27 | fixed_uv.x += (random((my_trunc(uv.y * blockCount) / blockCount) + time) - 0.5) * power * enable_shift; 28 | 29 | vec4 pixel_color = texture2D(screenTexture, fixed_uv); 30 | pixel_color.r = mix( 31 | pixel_color.r, 32 | texture2D(screenTexture, fixed_uv + vec2(colorRate, 0.0)).r, 33 | enable_shift 34 | ); 35 | pixel_color.b = mix( 36 | pixel_color.b, 37 | texture2D(screenTexture, fixed_uv + vec2(-colorRate, 0.0)).b, 38 | enable_shift 39 | ); 40 | gl_FragColor = pixel_color; 41 | } 42 | -------------------------------------------------------------------------------- /src/effect/shader/presets/grayscale.glsl: -------------------------------------------------------------------------------- 1 | # version 100 2 | // Adapted from https://www.shadertoy.com/view/lsdXDH 3 | precision mediump float; 4 | 5 | varying lowp vec2 uv; 6 | uniform sampler2D screenTexture; 7 | 8 | uniform float factor; // %1.0% 0..1 9 | 10 | void main() { 11 | vec3 color = texture2D(screenTexture, uv).xyz; 12 | vec3 lum = vec3(0.299, 0.587, 0.114); 13 | vec3 gray = vec3(dot(lum, color)); 14 | gl_FragColor = vec4(mix(color, gray, factor), 1.0); 15 | } 16 | -------------------------------------------------------------------------------- /src/effect/shader/presets/index.js: -------------------------------------------------------------------------------- 1 | export { default as chromatic } from './chromatic.glsl?raw'; 2 | export { default as circleBlur } from './circle_blur.glsl?raw'; 3 | export { default as fisheye } from './fisheye.glsl?raw'; 4 | export { default as glitch } from './glitch.glsl?raw'; 5 | export { default as grayscale } from './grayscale.glsl?raw'; 6 | export { default as noise } from './noise.glsl?raw'; 7 | export { default as pixel } from './pixel.glsl?raw'; 8 | export { default as radialBlur } from './radial_blur.glsl?raw'; 9 | export { default as shockwave } from './shockwave.glsl?raw'; 10 | export { default as vignette } from './vignette.glsl?raw'; 11 | -------------------------------------------------------------------------------- /src/effect/shader/presets/noise.glsl: -------------------------------------------------------------------------------- 1 | #version 100 2 | // Adapted from https://godotshaders.com/shader/screen-noise-effect-shader/ 3 | precision mediump float; 4 | 5 | varying lowp vec2 uv; 6 | uniform sampler2D screenTexture; 7 | 8 | uniform float seed; // %81.0% 9 | uniform float power; // %0.03% 0..1 10 | 11 | vec2 random(vec2 pos) { 12 | return fract(sin(vec2(dot(pos, vec2(12.9898,78.233)), dot(pos, vec2(-148.998,-65.233)))) * 43758.5453); 13 | } 14 | 15 | void main() { 16 | vec2 new_uv = uv + (random(uv + vec2(seed, 0.0)) - vec2(0.5, 0.5)) * power; 17 | gl_FragColor = texture2D(screenTexture, new_uv); 18 | } 19 | -------------------------------------------------------------------------------- /src/effect/shader/presets/pixel.glsl: -------------------------------------------------------------------------------- 1 | // #version 100 2 | // Adapted from https://godotshaders.com/shader/pixelate-2/ 3 | precision mediump float; 4 | 5 | varying lowp vec2 uv; 6 | uniform vec2 screenSize; 7 | uniform sampler2D screenTexture; 8 | 9 | uniform float size; // %10.0% 10 | 11 | void main() { 12 | vec2 factor = screenSize / size; 13 | float x = floor(uv.x * factor.x + 0.5) / factor.x; 14 | float y = floor(uv.y * factor.y + 0.5) / factor.y; 15 | gl_FragColor = texture2D(screenTexture, vec2(x, y)); 16 | } -------------------------------------------------------------------------------- /src/effect/shader/presets/radial_blur.glsl: -------------------------------------------------------------------------------- 1 | #version 100 2 | // Adapted from https://godotshaders.com/shader/radical-blur-shader/ 3 | precision mediump float; 4 | 5 | varying lowp vec2 uv; 6 | uniform sampler2D screenTexture; 7 | 8 | uniform float centerX; // %0.5% 0..1 9 | uniform float centerY; // %0.5% 0..1 10 | uniform float power; // %0.01% 0..1 11 | uniform float sampleCount; // %6% int 1..64 12 | 13 | void main() { 14 | vec2 direction = uv - vec2(centerX, centerY); 15 | vec3 c = vec3(0.0); 16 | float f = 1.0 / sampleCount; 17 | vec2 screen_uv = uv / 2.0 + vec2(0.5, 0.5); 18 | for (float i = 0.0; i < 64.0; ++i) { 19 | if (i >= sampleCount) break; 20 | c += texture2D(screenTexture, uv - power * direction * i).rgb * f; 21 | } 22 | gl_FragColor.rgb = c; 23 | } 24 | -------------------------------------------------------------------------------- /src/effect/shader/presets/shockwave.glsl: -------------------------------------------------------------------------------- 1 | #version 100 2 | // Adapted from https://www.shadertoy.com/view/llj3Dz 3 | precision mediump float; 4 | 5 | varying lowp vec2 uv; 6 | uniform vec2 screenSize; 7 | uniform sampler2D screenTexture; 8 | 9 | uniform float progress; // %0.2% 0..1 10 | uniform float centerX; // %0.5% 0..1 11 | uniform float centerY; // %0.5% 0..1 12 | uniform float width; // %0.1% 13 | uniform float distortion; // %0.8% 14 | uniform float expand; // %10.0% 15 | 16 | void main() { 17 | float aspect = screenSize.y / screenSize.x; 18 | 19 | vec2 center = vec2(centerX, centerY); 20 | center.y = (center.y - 0.5) * aspect + 0.5; 21 | 22 | vec2 tex_coord = uv; 23 | tex_coord.y = (tex_coord.y - 0.5) * aspect + 0.5; 24 | float dist = distance(tex_coord, center); 25 | 26 | if (progress - width <= dist && dist <= progress + width) { 27 | float diff = dist - progress; 28 | float scale_diff = 1.0 - pow(abs(diff * expand), distortion); 29 | float dt = diff * scale_diff; 30 | 31 | vec2 dir = normalize(tex_coord - center); 32 | 33 | tex_coord += ((dir * dt) / (progress * dist * 40.0)); 34 | gl_FragColor = texture2D(screenTexture, vec2(tex_coord.x, (tex_coord.y - 0.5) / aspect + 0.5)); 35 | 36 | gl_FragColor += (gl_FragColor * scale_diff) / (progress * dist * 40.0); 37 | } else { 38 | gl_FragColor = texture2D(screenTexture, vec2(tex_coord.x, (tex_coord.y - 0.5) / aspect + 0.5)); 39 | } 40 | } -------------------------------------------------------------------------------- /src/effect/shader/presets/vignette.glsl: -------------------------------------------------------------------------------- 1 | #version 100 2 | // Adapted from https://www.shadertoy.com/view/lsKSWR 3 | precision mediump float; 4 | 5 | varying lowp vec2 uv; 6 | uniform vec2 screenSize; 7 | uniform sampler2D screenTexture; 8 | 9 | uniform vec4 color; // %0.0, 0.0, 0.0, 1.0% 10 | uniform float extend; // %0.25% 0..1 11 | uniform float radius; // %15.0% 12 | 13 | void main() { 14 | vec2 new_uv = uv * (1.0 - uv.yx); 15 | float vig = new_uv.x * new_uv.y * radius; 16 | vig = pow(vig, extend); 17 | gl_FragColor = mix(color, texture2D(screenTexture, uv), vig); 18 | } 19 | -------------------------------------------------------------------------------- /src/game/callback.js: -------------------------------------------------------------------------------- 1 | 2 | function onKeyPressCallback(e) 3 | { 4 | let keyCode = e.keyCode, 5 | isHoldCtrl = e.ctrlKey, 6 | isHoldShift = e.shiftKey, 7 | skipTime = 0; 8 | 9 | if (this._isPaused) return; 10 | if (!this._settings.autoPlay) return; 11 | if (this._animateStatus !== 1) return; 12 | 13 | switch (keyCode) 14 | { 15 | case 37: { 16 | skipTime = -2; 17 | break; 18 | } 19 | case 39: { 20 | skipTime = 2; 21 | break; 22 | } 23 | default: { 24 | return; 25 | } 26 | } 27 | 28 | if (isHoldCtrl && isHoldShift) skipTime *= 5; 29 | else if (isHoldCtrl) skipTime *= 2; 30 | else if (isHoldShift) skipTime *= 0.5; 31 | 32 | { 33 | let currentTime = this.chart.music.currentTime; 34 | let calcedNoteCount = 0; 35 | 36 | for (const note of this.chart.notes) 37 | { 38 | if (note.isFake) continue; 39 | if (note.score <= 0) break; 40 | if (note.time < currentTime) 41 | { 42 | calcedNoteCount++; 43 | continue; 44 | } 45 | 46 | note.reset(); 47 | } 48 | 49 | this.judgement.score.perfect = this.judgement.score.judgedNotes = this.judgement.score.combo = this.judgement.score.maxCombo = calcedNoteCount; 50 | this.judgement.score._score = (this.judgement.score.scorePerNote + this.judgement.score.scorePerCombo) * calcedNoteCount; 51 | 52 | if (this.judgement.score.sprites) 53 | { 54 | this.judgement.score.sprites.combo.number.text = this.judgement.score.combo; 55 | 56 | this.judgement.score.sprites.acc.text = 'ACCURACY ' + (this.judgement.score.acc * 100).toFixed(2) + '%'; 57 | this.judgement.score.sprites.score.text = fillZero((this.judgement.score.score).toFixed(0), 7); 58 | 59 | this.judgement.score.sprites.combo.text.position.x = this.judgement.score.sprites.combo.number.width + this.render.sizer.heightPercent * 6; 60 | } 61 | 62 | function fillZero(num, length = 3) 63 | { 64 | let result = num + ''; 65 | while (result.length < length) 66 | { 67 | result = '0' + result; 68 | } 69 | return result; 70 | } 71 | } 72 | 73 | this.chart.music.seek(skipTime); 74 | } 75 | 76 | function pauseBtnClickCallback() 77 | { 78 | let pauseButton = this.sprites.pauseButton; 79 | pauseButton.clickCount++; 80 | if (pauseButton.clickCount >= 2 && Date.now() - pauseButton.lastClickTime <= 2000) 81 | { 82 | this.pause(); 83 | 84 | pauseButton.lastRenderTime = Date.now(); 85 | pauseButton.isEndRendering = true; 86 | pauseButton.clickCount = 0; 87 | } 88 | pauseButton.lastClickTime = Date.now(); 89 | } 90 | 91 | function gameEndCallback() 92 | { 93 | this._animateStatus = 2; 94 | this._gameEndTime = Date.now(); 95 | this.sprites.fakeJudgeline.visible = true; 96 | 97 | this.judgement.clickParticleContainer.removeChildren() 98 | 99 | if (this._settings.showAPStatus) 100 | { 101 | if (this.judgement.score.APType === 1) this.sprites.fakeJudgeline.tint = 0xB4E1FF; 102 | else if (this.judgement.score.APType === 0) this.sprites.fakeJudgeline.tint = 0xFFFFFF; 103 | } 104 | 105 | for (const judgeline of this.chart.judgelines) 106 | { 107 | if (!judgeline.sprite) continue; 108 | 109 | judgeline.sprite.alpha = 0; 110 | if (judgeline.debugSprite) judgeline.debugSprite.visible = false; 111 | }; 112 | for (const note of this.chart.notes) 113 | { 114 | if (!note.sprite) continue; 115 | 116 | note.sprite.alpha = 0; 117 | if (note.debugSprite) note.debugSprite.visible = false; 118 | }; 119 | 120 | if (this.judgement.input.sprite) this.judgement.input.sprite.clear(); 121 | } 122 | 123 | function runCallback(type) 124 | { 125 | if (!this.functions[type]) return; 126 | this.functions[type].forEach((callback) => callback(this)); 127 | } 128 | 129 | export { 130 | onKeyPressCallback, 131 | pauseBtnClickCallback, 132 | gameEndCallback, 133 | runCallback 134 | } -------------------------------------------------------------------------------- /src/game/ticker.js: -------------------------------------------------------------------------------- 1 | 2 | function calcTick() 3 | { 4 | { // 为暂停按钮计算渐变 5 | let pauseButton = this.sprites.pauseButton; 6 | if (pauseButton.clickCount === 1) 7 | { 8 | if (pauseButton.alpha < 1) 9 | { // 按钮刚被点击一次 10 | pauseButton.alpha = 0.5 + (0.5 * ((Date.now() - pauseButton.lastClickTime) / 200)); 11 | } 12 | else if (pauseButton.alpha >= 1 && Date.now() - pauseButton.lastClickTime >= 2000) 13 | { // 按钮刚被点击一次,且 2s 后没有进一步操作 14 | pauseButton.clickCount = 0; 15 | pauseButton.lastRenderTime = Date.now(); 16 | pauseButton.isEndRendering = true; 17 | } 18 | else if (pauseButton.alpha >= 1) 19 | { // 按钮被点击一次,且 200ms 后不透明度已到 1 20 | pauseButton.alpha = 1; 21 | pauseButton.lastRenderTime = Date.now(); 22 | } 23 | } 24 | else if (pauseButton.clickCount === 0 && pauseButton.isEndRendering) 25 | { 26 | if (pauseButton.alpha > 0.5) 27 | { 28 | pauseButton.alpha = 1 - (0.5 * ((Date.now() - pauseButton.lastRenderTime) / 200)); 29 | } 30 | else if (pauseButton.alpha <= 0.5) 31 | { 32 | pauseButton.alpha = 0.5; 33 | pauseButton.lastRenderTime = Date.now(); 34 | pauseButton.isEndRendering = false; 35 | } 36 | } 37 | } 38 | 39 | switch (this._animateStatus) 40 | { 41 | case 0: 42 | { 43 | this._calcGameAnimateTick(true); 44 | break; 45 | } 46 | case 1: 47 | { 48 | let { chart, effects, judgement, functions, processors, sprites, render, _settings: settings } = this; 49 | let currentTime = chart.music.currentTime - (chart.offset + settings.offset); 50 | 51 | for (let i = 0, length = chart.bpmList.length; i < length; i++) 52 | { 53 | let bpm = chart.bpmList[i]; 54 | 55 | if (bpm.endTime < currentTime) continue; 56 | if (bpm.startTime > currentTime) break; 57 | 58 | judgement._holdBetween = bpm.holdBetween; 59 | }; 60 | 61 | for (let i = 0, length = chart.judgelines.length; i < length; i++) 62 | { 63 | const judgeline = chart.judgelines[i]; 64 | judgeline.calcTime(currentTime, render.sizer); 65 | for (let x = 0, length = processors.judgeline.length; x < length; x++) processors.judgeline[x](judgeline, currentTime); 66 | }; 67 | for (let i = 0, length = chart.notes.length; i < length; i++) 68 | { 69 | const note = chart.notes[i]; 70 | note.calcTime(currentTime, render.sizer); 71 | for (let x = 0, length = processors.note.length; x < length; x++) processors.note[x](note, currentTime); 72 | judgement.calcNote(currentTime, note); 73 | }; 74 | 75 | if (!this._isPaused) 76 | { 77 | judgement.calcTick(); 78 | for (let x = 0, length = functions.tick.length; x < length; x++) functions.tick[x](this, currentTime); 79 | 80 | if (settings.shader) 81 | { 82 | render.gameContainer.filters = []; 83 | render.stage.filters = []; 84 | 85 | for (let i = 0, length = effects.length; i < length; i++) 86 | { 87 | const effect = effects[i]; 88 | if (effect.shader === null) continue; 89 | if (effect.endTime < currentTime) continue; 90 | if (effect.startTime > currentTime) break; 91 | 92 | effect.calcTime(currentTime, render.sizer.shaderScreenSize); 93 | if (effect.isGlobal) render.stage.filters.push(effect.shader); 94 | else render.gameContainer.filters.push(effect.shader); 95 | } 96 | } 97 | } 98 | 99 | sprites.progressBar.scale.x = chart.music.progress * sprites.progressBar.baseScaleX; 100 | 101 | break; 102 | } 103 | case 2: 104 | { 105 | this._calcGameAnimateTick(false); 106 | break; 107 | } 108 | case 3: 109 | { 110 | break; 111 | } 112 | } 113 | } 114 | 115 | function calcGameAnimateTick(isStart = true) 116 | { 117 | let _progress = (Date.now() - (isStart ? this._gameStartTime : this._gameEndTime)) / 1500, 118 | progress = (isStart ? 1 - Math.pow(1 - _progress, 4) : Math.pow(1 - _progress, 4)); 119 | let sprites = { 120 | score: this.judgement.score.sprites, 121 | chart: this.chart.sprites 122 | }; 123 | 124 | // Combo、准度、分数、暂停按钮和进度条 125 | sprites.score.combo.container.position.y = -(sprites.score.combo.container.height + sprites.score.acc.height) + ((sprites.score.combo.container.height + sprites.score.acc.height + (this.render.sizer.heightPercent * 41)) * progress); 126 | sprites.score.acc.position.y = sprites.score.combo.container.position.y + (this.render.sizer.heightPercent * 72); 127 | sprites.score.score.position.y = -(sprites.score.score.height) + ((sprites.score.score.height + (this.render.sizer.heightPercent * 61)) * progress); 128 | this.sprites.pauseButton.position.y = -(this.sprites.pauseButton.height) + ((this.sprites.pauseButton.height + (this.render.sizer.heightPercent * (61 + 14))) * progress); 129 | this.sprites.progressBar.position.y = -(this.render.sizer.heightPercent * 12) * (1 - progress); 130 | 131 | // 谱面信息 132 | sprites.chart.info.songName.position.y = (this.render.sizer.height + sprites.chart.info.songName.height) - ((sprites.chart.info.songName.height + (this.render.sizer.heightPercent * 66)) * progress); 133 | sprites.chart.info.songDiff.position.y = sprites.chart.info.songName.position.y + (this.render.sizer.heightPercent * 24); 134 | 135 | // 假判定线过场动画 136 | this.sprites.fakeJudgeline.width = this.render.sizer.width * progress; 137 | 138 | // 背景图亮度 139 | if (this.chart.sprites.bg && this.chart.sprites.bg.cover) this.chart.sprites.bg.cover.alpha = this._settings.bgDim * progress; 140 | 141 | if (_progress >= 1) 142 | { 143 | if (isStart) 144 | { 145 | this._animateStatus = 1; 146 | this.resize(true, false); 147 | 148 | setTimeout(async () => 149 | { 150 | this.chart.music.play(true); 151 | 152 | for (const judgeline of this.chart.judgelines) 153 | { 154 | if (!judgeline.sprite) continue; 155 | 156 | judgeline.sprite.alpha = 1; 157 | if (judgeline.debugSprite) judgeline.debugSprite.visible = true; 158 | }; 159 | for (const note of this.chart.notes) 160 | { 161 | if (note.sprite) note.sprite.alpha = note.basicAlpha; 162 | }; 163 | 164 | this._isPaused = false; 165 | this._isEnded = false; 166 | this.sprites.fakeJudgeline.visible = false; 167 | 168 | this._runCallback('start'); 169 | }, 200); 170 | } 171 | else 172 | { 173 | this._animateStatus = 3; 174 | this._isPaused = true; 175 | this._isEnded = true; 176 | this._runCallback('end'); 177 | } 178 | } 179 | } 180 | 181 | export { 182 | calcTick, 183 | calcGameAnimateTick 184 | } -------------------------------------------------------------------------------- /src/judgement/index.js: -------------------------------------------------------------------------------- 1 | import * as verify from '@/verify'; 2 | import Input from './input'; 3 | import Score from './score'; 4 | import InputPoint from './input/point'; 5 | import JudgePoint from './point'; 6 | import { ParticleContainer, AnimatedSprite, Texture, Sprite } from 'pixi.js'; 7 | 8 | const particleCountPerClickAnim = 4; 9 | 10 | const AllJudgeTimes = { 11 | bad : 180, 12 | good : 160, 13 | perfect : 80, 14 | 15 | badChallenge : 90, 16 | goodChallenge : 75, 17 | perfectChallenge : 40 18 | }; 19 | 20 | var ClickAnimatePointCache; 21 | (async () => 22 | { 23 | const pointSize = 26; 24 | const canvas = document.createElement('canvas'); 25 | const ctx = canvas.getContext('2d', { alpha: true }); 26 | 27 | canvas.width = canvas.height = pointSize * 2; 28 | ctx.clearRect(0, 0, pointSize * 2, pointSize * 2); 29 | ctx.beginPath(); 30 | ctx.arc(pointSize, pointSize, pointSize, 0, Math.PI * 2); 31 | ctx.fillStyle = '#FFFFFF'; 32 | ctx.fill(); 33 | 34 | const result = Texture.from(await createImageBitmap(canvas)); 35 | result.defaultAnchor.set(0.5); 36 | 37 | Texture.addToCache(result, 'clickAnimatePoint'); 38 | 39 | ClickAnimatePointCache = result; 40 | })(); 41 | 42 | export default class Judgement 43 | { 44 | constructor(params = {}) 45 | { 46 | this.chart = params.chart; 47 | this.stage = params.stage; 48 | this.textures = params.assets.textures; 49 | this.sounds = params.assets.sounds; 50 | 51 | if (!params.stage) throw new Error('You cannot do judgement without a stage'); 52 | if (!params.chart) throw new Error('You cannot do judgement without a chart'); 53 | 54 | this._autoPlay = verify.bool(params.autoPlay, false); 55 | this._hitsound = verify.bool(params.hitsound, true); 56 | this._hitsoundVolume = verify.number(params.hitsoundVolume, 1, 0, 1); 57 | 58 | this.score = new Score(this.chart.totalRealNotes, verify.bool(params.showAPStatus, true), verify.bool(params.challangeMode, false), this._autoPlay); 59 | this.input = new Input({ canvas: params.canvas, autoPlay: this._autoPlay }); 60 | 61 | /* ===== 判定用时间计算 ===== */ 62 | this.judgeTimes = { 63 | perfect : (!params.challangeMode ? AllJudgeTimes.perfect : AllJudgeTimes.perfectChallenge) / 1000, 64 | good : (!params.challangeMode ? AllJudgeTimes.good : AllJudgeTimes.goodChallenge) / 1000, 65 | bad : (!params.challangeMode ? AllJudgeTimes.bad : AllJudgeTimes.badChallenge) / 1000 66 | }; 67 | 68 | this.calcTick = this.calcTick.bind(this); 69 | this.calcNote = calcNoteJudge.bind(this); 70 | 71 | this.reset(); 72 | } 73 | 74 | reset() 75 | { 76 | this.judgePoints = []; 77 | this.score.reset(); 78 | this.input.reset(); 79 | 80 | this._holdBetween = 0.15; 81 | 82 | if (this.clickParticleContainer) this.clickParticleContainer.removeChildren(); 83 | } 84 | 85 | createSprites(showInputPoint = true) 86 | { 87 | this.clickParticleContainer = new ParticleContainer(1500, { 88 | vertices: true, 89 | position: true, 90 | scale: true, 91 | tint: true 92 | }); 93 | this.clickParticleContainer.zIndex = 99999; 94 | this.stage.addChild(this.clickParticleContainer); 95 | 96 | this.score.createSprites(this.stage); 97 | this.input.createSprite(this.stage, showInputPoint); 98 | 99 | this._clickAnimBaseScale = { 100 | normal : 256 / this.textures.normal[0].baseTexture.width, 101 | bad : 256 / this.textures.bad[0].baseTexture.width 102 | }; 103 | // this.stage.addChild(this.input.sprite); 104 | } 105 | 106 | resizeSprites(size, isEnded) 107 | { 108 | this.renderSize = size; 109 | this.score.resizeSprites(size, isEnded); 110 | this.input.resizeSprites(size, isEnded); 111 | } 112 | 113 | calcTick() 114 | { 115 | this.createJudgePoints(); 116 | 117 | this.input.calcTick(); 118 | 119 | for (let i = 0, length = this.clickParticleContainer.children.length; i < length; i++) 120 | { 121 | const particle = this.clickParticleContainer.children[i]; 122 | if (!particle) break; 123 | const currentTimeProgress = (Date.now() - particle.startTime) / 500; 124 | 125 | if (currentTimeProgress >= 1) 126 | { 127 | // this.clickParticleContainer.removeChild(particle); 128 | particle.destroy(false); 129 | continue; 130 | } 131 | 132 | particle.alpha = 1 - currentTimeProgress; 133 | 134 | particle.scale.set((((0.2078 * currentTimeProgress - 1.6524) * currentTimeProgress + 1.6399) * currentTimeProgress + 0.4988) * particle.baseScale); 135 | particle.distance = particle._distance * (9 * currentTimeProgress / (8 * currentTimeProgress + 1)) * 0.6 * particle.baseScale; 136 | 137 | particle.position.x = particle.distance * particle.cosr - particle.distance * particle.sinr + particle.basePos.x; 138 | particle.position.y = particle.distance * particle.cosr + particle.distance * particle.sinr + particle.basePos.y; 139 | } 140 | } 141 | 142 | createJudgePoints() 143 | { 144 | this.judgePoints.length = 0; 145 | 146 | if (!this._autoPlay) 147 | { 148 | for (let i = 0, length = this.input.inputs.length; i < length; i++) 149 | { 150 | let inputPoint = this.input.inputs[i]; 151 | 152 | if (!inputPoint.isTapped) this.judgePoints.push(new JudgePoint(inputPoint, 1)); 153 | if (inputPoint.isActive) this.judgePoints.push(new JudgePoint(inputPoint, 3)); 154 | if (inputPoint.isFlickable && !inputPoint.isFlicked) this.judgePoints.push(new JudgePoint(inputPoint, 2)); 155 | } 156 | } 157 | } 158 | 159 | pushNoteJudge(note) 160 | { 161 | this.score.pushJudge(note.score, this.chart.judgelines); 162 | if (note.score >= 2) 163 | { 164 | this.createClickAnimate(note); 165 | if (note.score >= 3) this.playHitsound(note); 166 | } 167 | } 168 | 169 | createClickAnimate(note) 170 | { 171 | let anim = new AnimatedSprite(note.score >= 3 ? this.textures.normal : this.textures.bad), 172 | baseScale = this.renderSize.noteScale * 5.6; 173 | 174 | if (note.score >= 3 && note.type != 3) anim.position.set(note.sprite.judgelineX, note.sprite.judgelineY); 175 | else anim.position.copyFrom(note.sprite.position); 176 | 177 | anim.scale.set((note.score >= 3 ? this._clickAnimBaseScale.normal : this._clickAnimBaseScale.bad) * baseScale); 178 | anim.tint = note.score === 4 ? 0xFFECA0 : note.score === 3 ? 0xB4E1FF : 0x6c4343; 179 | 180 | anim.loop = false; 181 | 182 | if (note.score >= 3) 183 | { 184 | let currentParticleCount = 0; 185 | while (currentParticleCount < particleCountPerClickAnim) 186 | { 187 | let particle = new Sprite(ClickAnimatePointCache); 188 | 189 | particle.tint = note.score === 4 ? 0xFFECA0 : 0xB4E1FF; 190 | 191 | particle.startTime = Date.now(); 192 | particle.basePos = anim.position; 193 | particle.baseScale = baseScale; 194 | 195 | particle.distance = particle._distance = Math.random() * 100 + 250; 196 | particle.direction = Math.floor(Math.random() * 360); 197 | particle.sinr = Math.sin(particle.direction); 198 | particle.cosr = Math.cos(particle.direction); 199 | 200 | this.clickParticleContainer.addChild(particle); 201 | 202 | currentParticleCount++; 203 | } 204 | } 205 | else 206 | { 207 | anim.angle = note.sprite.angle; 208 | } 209 | 210 | anim.onFrameChange = function () { 211 | this.alpha = 1 - (this.currentFrame / this.totalFrames); 212 | }; 213 | anim.onComplete = function () { 214 | this.destroy(false); 215 | }; 216 | 217 | this.stage.addChild(anim); 218 | anim.play(); 219 | 220 | return anim; 221 | } 222 | 223 | playHitsound(note) 224 | { 225 | if (!this._hitsound) return; 226 | if (note.hitsound) note.hitsound.play(); 227 | else 228 | { 229 | switch (note.type) 230 | { 231 | case 1: 232 | case 3: 233 | { 234 | this.sounds.tap.play(); 235 | break; 236 | } 237 | case 2: 238 | { 239 | this.sounds.drag.play(); 240 | break; 241 | } 242 | case 4: 243 | { 244 | this.sounds.flick.play(); 245 | break; 246 | } 247 | } 248 | } 249 | } 250 | 251 | destroySprites() 252 | { 253 | this.reset(); 254 | 255 | this.clickParticleContainer.destroy({ children: true, texture: false, baseTexture: false }); 256 | 257 | this.input.destroySprites(); 258 | this.score.destroySprites(); 259 | } 260 | } 261 | 262 | function calcNoteJudge(currentTime, note) 263 | { 264 | if (note.isFake) return; // 忽略假 Note 265 | if (note.isScored && note.isScoreAnimated) return; // 已记分忽略 266 | if (note.time - this.judgeTimes.bad > currentTime) return; // 不在记分范围内忽略 267 | 268 | if (!note.isScored) 269 | { 270 | if (note.type !== 3 && note.time + this.judgeTimes.bad < currentTime) 271 | { 272 | note.isScored = true; 273 | note.score = 1; 274 | note.scoreTime = NaN; 275 | 276 | this.score.pushJudge(0, this.chart.judgelines); 277 | 278 | note.sprite.alpha = 0; 279 | note.isScoreAnimated = true; 280 | 281 | return; 282 | } 283 | else if (note.type === 3 && note.time + this.judgeTimes.good < currentTime) 284 | { 285 | note.isScored = true; 286 | note.score = 1; 287 | note.scoreTime = NaN; 288 | 289 | this.score.pushJudge(0, this.chart.judgelines); 290 | 291 | note.sprite.alpha = 0.5; 292 | note.isScoreAnimated = true; 293 | 294 | return; 295 | } 296 | } 297 | 298 | 299 | let timeBetween = note.time - currentTime, 300 | timeBetweenReal = timeBetween > 0 ? timeBetween : timeBetween * -1, 301 | judgeline = note.judgeline, 302 | notePosition = note.sprite.position; 303 | 304 | if (note.type !== 3 && !note.isScoreAnimated && note.time <= currentTime) 305 | { 306 | note.sprite.alpha = 1 + (timeBetween / this.judgeTimes.bad); 307 | } 308 | 309 | // 自动模式则自行添加判定点 310 | if (this._autoPlay) 311 | { 312 | let input = { x: notePosition.x, y: notePosition.y, isFlicked: false }; 313 | 314 | if (note.type === 1) { 315 | if (timeBetween <= 0) this.judgePoints.push(new JudgePoint(input, 1)); 316 | } else if (note.type === 2) { 317 | if (timeBetween <= this.judgeTimes.bad) this.judgePoints.push(new JudgePoint(input, 3)); 318 | } else if (note.type === 3) { 319 | if (!note.isScored && timeBetween <= 0) this.judgePoints.push(new JudgePoint(input, 1)); 320 | else if (note.isScored && currentTime - note.lastHoldTime >= this._holdBetween) this.judgePoints.push(new JudgePoint(input, 3)); 321 | } else if (note.type === 4) { 322 | if (timeBetween <= this.judgeTimes.bad) this.judgePoints.push(new JudgePoint(input, 2)); 323 | } 324 | } 325 | 326 | switch (note.type) 327 | { 328 | case 1: 329 | { 330 | for (let i = 0, length = this.judgePoints.length; i < length; i++) 331 | { 332 | if ( 333 | this.judgePoints[i].type === 1 && 334 | this.judgePoints[i].isInArea(notePosition.x, notePosition.y, judgeline.cosr, judgeline.sinr, this.renderSize.noteWidth) 335 | ) { 336 | if (timeBetweenReal <= this.judgeTimes.bad) 337 | { 338 | note.isScored = true; 339 | note.scoreTime = timeBetween; 340 | 341 | if (timeBetweenReal <= this.judgeTimes.perfect) note.score = 4; 342 | else if (timeBetweenReal <= this.judgeTimes.good) note.score = 3; 343 | else note.score = 2; 344 | } 345 | 346 | if (note.isScored) 347 | { 348 | this.pushNoteJudge(note); 349 | note.sprite.alpha = 0; 350 | note.isScoreAnimated = true; 351 | 352 | this.judgePoints.splice(i, 1); 353 | break; 354 | } 355 | } 356 | } 357 | 358 | break; 359 | } 360 | case 2: 361 | { 362 | if (note.isScored && !note.isScoreAnimated && timeBetween <= 0) 363 | { 364 | this.pushNoteJudge(note); 365 | note.sprite.alpha = 0; 366 | note.isScoreAnimated = true; 367 | } 368 | else if (!note.isScored) 369 | { 370 | for (let i = 0, length = this.judgePoints.length; i < length; i++) 371 | { 372 | if ( 373 | this.judgePoints[i].isInArea(notePosition.x, notePosition.y, judgeline.cosr, judgeline.sinr, this.renderSize.noteWidth) && 374 | timeBetweenReal <= this.judgeTimes.good 375 | ) { 376 | note.isScored = true; 377 | note.score = 4; 378 | note.scoreTime = NaN; 379 | break; 380 | } 381 | } 382 | } 383 | 384 | break; 385 | } 386 | case 3: 387 | { 388 | if (note.isScored) 389 | { 390 | if (currentTime - note.lastHoldTime >= this._holdBetween) 391 | { 392 | this.createClickAnimate(note); 393 | } 394 | 395 | if (note.holdTimeLength - currentTime <= this.judgeTimes.bad) 396 | { 397 | this.score.pushJudge(note.score, this.chart.judgelines); 398 | note.isScoreAnimated = true; 399 | break; 400 | } 401 | 402 | if (currentTime - note.lastHoldTime >= this._holdBetween) 403 | { 404 | note.lastHoldTime = currentTime; 405 | note.isHolding = false; 406 | } 407 | } 408 | 409 | for (let i = 0, length = this.judgePoints.length; i < length; i++) 410 | { 411 | if ( 412 | !note.isScored && 413 | this.judgePoints[i].type === 1 && 414 | this.judgePoints[i].isInArea(notePosition.x, notePosition.y, judgeline.cosr, judgeline.sinr, this.renderSize.noteWidth) && 415 | timeBetweenReal <= this.judgeTimes.good 416 | ) { 417 | note.isScored = true; 418 | note.scoreTime = timeBetween; 419 | 420 | if (timeBetweenReal <= this.judgeTimes.perfect) note.score = 4; 421 | else note.score = 3; 422 | 423 | this.createClickAnimate(note); 424 | this.playHitsound(note); 425 | 426 | note.isHolding = true; 427 | note.lastHoldTime = currentTime; 428 | 429 | this.judgePoints.splice(i, 1); 430 | break; 431 | } 432 | else if (this.judgePoints[i].isInArea(notePosition.x, notePosition.y, judgeline.cosr, judgeline.sinr, this.renderSize.noteWidth)) 433 | { 434 | note.isHolding = true; 435 | } 436 | } 437 | 438 | if (!this.paused && note.isScored && !note.isHolding) 439 | { 440 | note.score = 1; 441 | note.scoreTime = NaN; 442 | 443 | this.score.pushJudge(1, this.chart.judgelines); 444 | 445 | note.sprite.alpha = 0.5; 446 | note.isScoreAnimated = true; 447 | } 448 | 449 | break; 450 | } 451 | case 4: 452 | { 453 | if (note.isScored && !note.isScoreAnimated && timeBetween <= 0) 454 | { 455 | this.pushNoteJudge(note); 456 | note.sprite.alpha = 0; 457 | note.isScoreAnimated = true; 458 | } 459 | else if (!note.isScored) 460 | { 461 | for (let i = 0, length = this.judgePoints.length; i < length; i++) 462 | { 463 | if ( 464 | this.judgePoints[i].type === 2 && 465 | this.judgePoints[i].isInArea(notePosition.x, notePosition.y, judgeline.cosr, judgeline.sinr, this.renderSize.noteWidth) && 466 | timeBetweenReal <= this.judgeTimes.good 467 | ) { 468 | note.isScored = true; 469 | note.score = 4; 470 | note.scoreTime = NaN; 471 | 472 | this.judgePoints[i].input.isFlicked = true; 473 | this.judgePoints.splice(i, 1); 474 | 475 | break; 476 | } 477 | } 478 | } 479 | 480 | break; 481 | } 482 | } 483 | } -------------------------------------------------------------------------------- /src/judgement/input/callback.js: -------------------------------------------------------------------------------- 1 | function touchStart(e) 2 | { 3 | e.preventDefault(); 4 | for (const i of e.changedTouches) 5 | { 6 | const { clientX, clientY, identifier } = i; 7 | this.addInput('touch', identifier, clientX - this.renderSize.widthOffset, clientY); 8 | } 9 | } 10 | 11 | function touchMove(e) 12 | { 13 | e.preventDefault(); 14 | for (const i of e.changedTouches) 15 | { 16 | const { clientX, clientY, identifier } = i; 17 | this.moveInput('touch', identifier, clientX - this.renderSize.widthOffset, clientY); 18 | } 19 | } 20 | 21 | function touchEnd(e) 22 | { 23 | e.preventDefault(); 24 | for (const i of e.changedTouches) 25 | { 26 | this.removeInput('touch', i.identifier); 27 | } 28 | } 29 | 30 | function mouseStart(e) 31 | { 32 | e.preventDefault(); 33 | const { clientX, clientY, button } = e; 34 | this.addInput('mouse', button, clientX - this.renderSize.widthOffset, clientY); 35 | } 36 | 37 | function mouseMove(e) 38 | { 39 | const { clientX, clientY, button } = e; 40 | this.moveInput('mouse', button, clientX - this.renderSize.widthOffset, clientY); 41 | } 42 | 43 | function mouseEnd(e) 44 | { 45 | this.removeInput('mouse', e.button); 46 | } 47 | 48 | export default { 49 | touchStart, 50 | touchMove, 51 | touchEnd, 52 | mouseStart, 53 | mouseMove, 54 | mouseEnd 55 | } -------------------------------------------------------------------------------- /src/judgement/input/index.js: -------------------------------------------------------------------------------- 1 | import ListenerCallback from './callback'; 2 | import InputPoint from './point'; 3 | import { Graphics } from 'pixi.js'; 4 | 5 | 6 | 7 | export default class Input 8 | { 9 | constructor(params = {}) 10 | { 11 | if (!params.canvas) throw new Error('You cannot add inputs without a canvas'); 12 | 13 | this.inputs = []; 14 | 15 | for (const name in ListenerCallback) 16 | { 17 | this['_' + name] = ListenerCallback[name].bind(this); 18 | } 19 | 20 | this.addListenerToCanvas(params.canvas); 21 | this.reset(); 22 | } 23 | 24 | addListenerToCanvas(canvas) 25 | { 26 | if (!(canvas instanceof HTMLCanvasElement)) throw new Error('This is not a canvas'); 27 | 28 | const passiveIfSupported = { passive: false }; 29 | 30 | canvas.addEventListener('touchstart', this._touchStart, passiveIfSupported); 31 | canvas.addEventListener('touchmove', this._touchMove, passiveIfSupported); 32 | canvas.addEventListener('touchend', this._touchEnd, passiveIfSupported); 33 | canvas.addEventListener('touchcancel', this._touchEnd, passiveIfSupported); 34 | 35 | // 鼠标适配,其实并不打算做 36 | canvas.addEventListener('mousedown', this._mouseStart, passiveIfSupported); 37 | canvas.addEventListener('mousemove', this._mouseMove); 38 | canvas.addEventListener('mouseup', this._mouseEnd); 39 | 40 | // canvas.addEventListener('contextmenu', this._noCanvasMenu, passiveIfSupported); 41 | } 42 | 43 | removeListenerFromCanvas(canvas) 44 | { 45 | if (!(canvas instanceof HTMLCanvasElement)) throw new Error('This is not a canvas'); 46 | 47 | canvas.removeEventListener('touchstart', this._touchStart); 48 | canvas.removeEventListener('touchmove', this._touchMove); 49 | canvas.removeEventListener('touchend', this._touchEnd); 50 | canvas.removeEventListener('touchcancel', this._touchEnd); 51 | 52 | // 鼠标适配,其实并不打算做 53 | canvas.removeEventListener('mousedown', this._mouseStart); 54 | canvas.removeEventListener('mousemove', this._mouseMove); 55 | canvas.removeEventListener('mouseup', this._mouseEnd); 56 | 57 | // canvas.removeEventListener('contextmenu', this._noCanvasMenu); 58 | } 59 | 60 | reset() 61 | { 62 | this.inputs.length = 0; 63 | } 64 | 65 | createSprite(stage, showInputPoint = true) 66 | { 67 | if (showInputPoint) 68 | { 69 | this.sprite = new Graphics(); 70 | this.sprite.zIndex = 99999; 71 | stage.addChild(this.sprite); 72 | } 73 | } 74 | 75 | addInput(type, id, x, y) 76 | { 77 | const { inputs } = this; 78 | let idx = inputs.findIndex(point => point.type === type && point.id === id); 79 | if (idx !== -1) inputs.splice(idx, 1); 80 | inputs.push(new InputPoint(type, id, x, y)); 81 | } 82 | 83 | moveInput(type, id, x, y) 84 | { 85 | const { inputs } = this; 86 | let point = inputs.find(point => point.type === type && point.id === id); 87 | if (point) point.move(x, y); 88 | } 89 | 90 | removeInput(type, id) 91 | { 92 | const { inputs } = this; 93 | let point = inputs.find(point => point.type === type && point.id === id); 94 | if (point) point.isActive = false; 95 | } 96 | 97 | calcTick() 98 | { 99 | const { inputs } = this; 100 | 101 | if (this.sprite) this.sprite.clear(); 102 | 103 | for (let i = 0, length = inputs.length; i < length; i++) 104 | { 105 | let point = inputs[i]; 106 | 107 | if (this.sprite) 108 | { 109 | this.sprite 110 | .beginFill(!point.isTapped ? 0xFFFF00 : point.isMoving ? 0x00FFFF : 0xFF00FF) 111 | .drawCircle(point.x, point.y, this._inputPointSize) 112 | .endFill(); 113 | } 114 | 115 | if (point.isActive) 116 | { 117 | point.isTapped = true; 118 | point.isMoving = false; 119 | } 120 | else 121 | { 122 | inputs.splice(i--, 1); 123 | length -= 1; 124 | } 125 | } 126 | } 127 | 128 | resizeSprites(size) 129 | { 130 | this.renderSize = size; 131 | this._inputPointSize = this.renderSize.heightPercent * 30; 132 | } 133 | 134 | destroySprites() 135 | { 136 | if (this.sprite) 137 | { 138 | this.sprite.destroy(); 139 | this.sprite = undefined; 140 | } 141 | } 142 | } -------------------------------------------------------------------------------- /src/judgement/input/point.js: -------------------------------------------------------------------------------- 1 | export default class InputPoint 2 | { 3 | constructor(type, id, x, y) 4 | { 5 | this.type = type; 6 | this.id = id; 7 | 8 | this.x = x; 9 | this.y = y; 10 | 11 | this.isActive = true; 12 | this.isTapped = false; 13 | this.isMoving = false; 14 | this.isFlickable = false; 15 | this.isFlicked = false; 16 | 17 | this._deltaX = 0; 18 | this._deltaY = 0; 19 | this._lastDeltaX = 0; 20 | this._lastDeltaY = 0; 21 | this._currentTime = performance.now(); 22 | this._deltaTime = this._currentTime; 23 | } 24 | 25 | move(x, y) 26 | { 27 | this._lastDeltaX = this._deltaX; 28 | this._lastDeltaY = this._deltaY; 29 | 30 | this._deltaX = x - this.x; 31 | this._deltaY = y - this.y; 32 | 33 | this.x = x; 34 | this.y = y; 35 | 36 | this.isMoving = true; 37 | 38 | { 39 | let currentTime = performance.now(); 40 | 41 | this._deltaTime = currentTime - this._currentTime; 42 | this._currentTime = currentTime; 43 | } 44 | 45 | { 46 | let moveSpeed = (this._deltaX * this._lastDeltaX + this._deltaY * this._lastDeltaY) / Math.sqrt(this._lastDeltaX ** 2 + this._lastDeltaY ** 2) / this._deltaTime; 47 | 48 | if (this.isFlickable && moveSpeed < 0.50) 49 | { 50 | this.isFlickable = false; 51 | this.isFlicked = false; 52 | } 53 | else if (!this.isFlickable && moveSpeed > 1.00) 54 | { 55 | this.isFlickable = true; 56 | } 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /src/judgement/point.js: -------------------------------------------------------------------------------- 1 | export default class JudgePoint 2 | { 3 | constructor(input, type = 1) 4 | { 5 | this.x = input.x; 6 | this.y = input.y; 7 | this.input = input; 8 | this.type = type; // 1: tap, 2: flick, 3: hold 9 | } 10 | 11 | isInArea(x, y, cosr, sinr, hw) 12 | { 13 | return Math.abs((this.x - x) * cosr + (this.y - y) * sinr) <= hw; 14 | } 15 | } -------------------------------------------------------------------------------- /src/judgement/score.js: -------------------------------------------------------------------------------- 1 | import { Text, Container } from 'pixi.js'; 2 | 3 | export default class Score 4 | { 5 | constructor(notesCount = 0, showAPStatus = true, isChallengeMode = false, autoPlay = false) 6 | { 7 | this._notesCount = Number(notesCount); 8 | this._showAPStatus = !!showAPStatus; 9 | this._autoPlay = !!autoPlay; 10 | 11 | if (isNaN((this._notesCount)) || this._notesCount <= 0) 12 | { 13 | console.warn('Invaild note count, Won\'t calculate score.'); 14 | this._notesCount = 0; 15 | } 16 | 17 | this.scorePerNote = isChallengeMode ? 1000000 / notesCount : 900000 / notesCount; 18 | this.scorePerCombo = isChallengeMode ? 0 : 100000 / notesCount; 19 | 20 | this.renderSize = {}; 21 | 22 | this.reset(); 23 | } 24 | 25 | reset() 26 | { 27 | this._score = 0; 28 | this.score = 0; 29 | this.acc = 0; 30 | this.combo = 0; 31 | this.maxCombo = 0; 32 | 33 | this.judgedNotes = 0; 34 | this.perfect = 0; 35 | this.good = 0; 36 | this.bad = 0; 37 | this.miss = 0; 38 | 39 | this.judgeLevel = -1; 40 | this.APType = 2; 41 | this.levelPassed = false; 42 | 43 | if (this.sprites) 44 | { 45 | this.sprites.combo.number.text = '0'; 46 | this.sprites.acc.text = 'ACCURACY 0.00%'; 47 | this.sprites.score.text = '0000000'; 48 | 49 | this.sprites.combo.text.position.x = this.sprites.combo.number.width + this.renderSize.heightPercent * 6; 50 | } 51 | } 52 | 53 | createSprites(stage) 54 | { 55 | if (this.sprites) return; 56 | 57 | this.sprites = {}; 58 | 59 | this.sprites.combo = {}; 60 | this.sprites.combo.container = new Container(); 61 | this.sprites.combo.container.zIndex = 99999; 62 | 63 | this.sprites.combo.number = new Text('0', { 64 | fontFamily: 'A-OTF Shin Go Pr6N H', 65 | fill: 0xFFFFFF 66 | }); 67 | this.sprites.combo.number.alpha = 0.81; 68 | this.sprites.combo.text = new Text((this._autoPlay ? 'AUT' + 'OPL' + 'AY' : 'COMBO'), { 69 | fontFamily: 'MiSans', 70 | fill: 0xFFFFFF 71 | }); 72 | this.sprites.combo.text.alpha = 0.55; 73 | this.sprites.combo.container.addChild(this.sprites.combo.number, this.sprites.combo.text); 74 | stage.addChild(this.sprites.combo.container); 75 | 76 | this.sprites.acc = new Text('ACCURACY 0.00%', { 77 | fontFamily: 'MiSans', 78 | fill: 0xFFFFFF 79 | }); 80 | this.sprites.acc.alpha = 0.63; 81 | this.sprites.acc.zIndex = 99999; 82 | stage.addChild(this.sprites.acc); 83 | 84 | this.sprites.score = new Text('0000000', { 85 | fontFamily: 'A-OTF Shin Go Pr6N H', 86 | fill: 0xFFFFFF 87 | }); 88 | this.sprites.score.alpha = 0.58; 89 | this.sprites.score.anchor.set(1, 0); 90 | this.sprites.score.zIndex = 99999; 91 | stage.addChild(this.sprites.score); 92 | } 93 | 94 | resizeSprites(size, isEnded) 95 | { 96 | this.renderSize = size; 97 | 98 | if (!this.sprites) return; 99 | 100 | this.sprites.combo.number.style.fontSize = size.heightPercent * 60; 101 | this.sprites.combo.text.style.fontSize = size.heightPercent * 30; 102 | 103 | this.sprites.acc.style.fontSize = size.heightPercent * 20; 104 | 105 | this.sprites.score.style.fontSize = size.heightPercent * 50; 106 | 107 | if (!isEnded) 108 | { 109 | this.sprites.combo.container.position.x = size.heightPercent * 72; 110 | this.sprites.combo.container.position.y = size.heightPercent * 41; 111 | this.sprites.combo.text.position.x = this.sprites.combo.number.width + size.heightPercent * 6; 112 | this.sprites.combo.text.position.y = size.heightPercent * 30; 113 | 114 | this.sprites.acc.position.x = size.heightPercent * 72; 115 | this.sprites.acc.position.y = size.heightPercent * 113; 116 | 117 | this.sprites.score.position.x = size.width - size.heightPercent * 139; 118 | this.sprites.score.position.y = size.heightPercent * 61; 119 | } 120 | else 121 | { 122 | this.sprites.combo.container.position.y = size.height; 123 | this.sprites.acc.position.y = size.height; 124 | this.sprites.score.position.y = size.height; 125 | } 126 | } 127 | 128 | pushJudge(type = 0, judgelines = []) 129 | { 130 | if (!this._autoPlay) 131 | { 132 | if (type > 2) 133 | { 134 | this.combo += 1; 135 | if (this.combo > this.maxCombo) this.maxCombo = this.combo; 136 | 137 | if (type === 4) this.perfect += 1; 138 | else { 139 | this.good += 1; 140 | if (this.APType >= 2) 141 | { 142 | this.APType = 1; 143 | 144 | if (this._showAPStatus) 145 | { 146 | for (const judgeline of judgelines) 147 | { 148 | if (!isNaN(judgeline.color)) return; 149 | if (!judgeline.sprite) return; 150 | judgeline.sprite.tint = 0xB4E1FF; 151 | }; 152 | } 153 | } 154 | } 155 | 156 | this._score += this.scorePerNote + (this.combo >= this.maxCombo ? this.scorePerCombo * (type === 4 ? 1 : 0.65) : 0); 157 | } 158 | else 159 | { 160 | if (type === 2)this.bad += 1; 161 | else this.miss += 1; 162 | 163 | if (this.APType >= 1) 164 | { 165 | this.APType = 0; 166 | 167 | if (this._showAPStatus) 168 | { 169 | for (const judgeline of judgelines) 170 | { 171 | if (!isNaN(judgeline.color)) return; 172 | if (!judgeline.sprite) return; 173 | judgeline.sprite.tint = 0xFFFFFF; 174 | }; 175 | } 176 | } 177 | 178 | this.combo = 0; 179 | } 180 | } 181 | else 182 | { 183 | this.perfect += 1; 184 | this.combo += 1; 185 | this.maxCombo = this.combo; 186 | this._score += this.scorePerNote + this.scorePerCombo; 187 | } 188 | 189 | this.judgedNotes++; 190 | this.score = Math.round(this._score); 191 | this.acc = (this.perfect + this.good * 0.65) / this.judgedNotes; 192 | 193 | if (this.score >= 1000000) this.judgeLevel = 6; 194 | else if (this.score >= 960000) this.judgeLevel = 5; 195 | else if (this.score >= 920000) this.judgeLevel = 4; 196 | else if (this.score >= 880000) this.judgeLevel = 3; 197 | else if (this.score >= 820000) this.judgeLevel = 2; 198 | else if (this.score >= 700000) this.judgeLevel = 1; 199 | else this.judgeLevel = 0; 200 | 201 | if (this.judgeLevel >= 1) this.levelPassed = true; 202 | 203 | if (this.sprites) 204 | { 205 | this.sprites.combo.number.text = this.combo; 206 | 207 | this.sprites.acc.text = 'ACCURACY ' + (this.acc * 100).toFixed(2) + '%'; 208 | this.sprites.score.text = fillZero((this.score).toFixed(0), 7); 209 | 210 | this.sprites.combo.text.position.x = this.sprites.combo.number.width + this.renderSize.heightPercent * 6; 211 | } 212 | } 213 | 214 | destroySprites() 215 | { 216 | if (!this.sprites) return; 217 | 218 | this.sprites.combo.number.destroy(); 219 | this.sprites.combo.text.destroy(); 220 | this.sprites.combo.container.destroy(); 221 | this.sprites.combo = undefined; 222 | 223 | this.sprites.acc.destroy(); 224 | this.sprites.acc = undefined; 225 | 226 | this.sprites.score.destroy(); 227 | this.sprites.score = undefined; 228 | 229 | this.sprites = undefined; 230 | } 231 | } 232 | 233 | 234 | 235 | function fillZero(num, length = 3) 236 | { 237 | let result = num + ''; 238 | while (result.length < length) 239 | { 240 | result = '0' + result; 241 | } 242 | return result; 243 | } -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | export { default as Game } from './game'; 2 | export { default as Chart } from './chart'; 3 | 4 | export { default as WAudio } from './audio'; 5 | export { default as Effect } from './effect'; 6 | export { default as Shader } from './effect/shader'; 7 | -------------------------------------------------------------------------------- /src/phizone/index.js: -------------------------------------------------------------------------------- 1 | import { text as verifyText } from '@/verify'; 2 | import { loadChartFiles } from '../index'; 3 | 4 | const doms = { 5 | linkInput: document.querySelector('input#phizone-chart-link'), 6 | chartDownload: document.querySelector('button#phizone-download-chart'), 7 | downloadProgress: document.querySelector('div#phizone-download-progress') 8 | }; 9 | 10 | const PhiZoneLinkReg = /^https:\/\/[\d\w]+\.phi\.zone\/charts\/(\d+)/; 11 | 12 | doms.linkInput.addEventListener('keydown', (e) => 13 | { 14 | if (e.key === 'Enter') doms.chartDownload.dispatchEvent(new Event('click')); 15 | }); 16 | 17 | doms.chartDownload.addEventListener('click', () => 18 | { 19 | let downloadId = doms.linkInput.value; 20 | 21 | if (!downloadId || downloadId == '') 22 | { 23 | alert('Please enter a chart link/ID!'); 24 | doms.linkInput.focus(); 25 | return; 26 | } 27 | 28 | if (PhiZoneLinkReg.test(downloadId)) 29 | { 30 | downloadId = parseInt(PhiZoneLinkReg.exec(downloadId)[1]); 31 | } 32 | else downloadId = parseInt(downloadId); 33 | 34 | if (isNaN(downloadId) || downloadId <= 0) 35 | { 36 | alert('Please enter a valid chart link/ID!'); 37 | doms.linkInput.focus(); 38 | return; 39 | } 40 | 41 | doms.downloadProgress.innerText = 'Getting chart info...'; 42 | 43 | fetch('https://api.phi.zone/charts/' + downloadId + '/?query_song=1&query_owner=1') 44 | .then(res => res.json()) 45 | .then(res => 46 | { 47 | let resUrls = {}; 48 | let infos = {}; 49 | 50 | if (res.id !== downloadId) 51 | { 52 | doms.downloadProgress.innerText = 'Cannot get chart info: ' + res.detail; 53 | return; 54 | } 55 | 56 | if (!verifyText(res.chart, null) || !res.song || !verifyText(res.song.illustration, null) || !verifyText(res.song.song, null)) 57 | { 58 | doms.downloadProgress.innerText = 'Cannot get chart info: server didn\'t provide any link'; 59 | return; 60 | } 61 | 62 | resUrls = { 63 | chart: res.chart, 64 | song: res.song.song, 65 | illustration: res.song.illustration 66 | }; 67 | 68 | infos = { 69 | name : res.song.name, 70 | artist : res.song.composer, 71 | author : res.charter.replace(/\[PZUser:\d+:(.+)\]/, '\$1'), 72 | bgAuthor : res.song.illustrator, 73 | difficult : 'Lv.' + res.level + ' ' + Math.floor(res.difficulty) 74 | }; 75 | 76 | downloadFiles(resUrls, infos); 77 | } 78 | ); 79 | }); 80 | 81 | 82 | async function downloadFiles(urls, infos) 83 | { 84 | let fileName = { 85 | chart: urls.chart.split('/'), 86 | song: urls.song.split('/'), 87 | bg: urls.illustration.split('/') 88 | }; 89 | 90 | fileName.chart = decodeURIComponent(fileName.chart[fileName.chart.length - 1]); 91 | fileName.song = decodeURIComponent(fileName.song[fileName.song.length - 1]); 92 | fileName.bg = decodeURIComponent(fileName.bg[fileName.bg.length - 1]); 93 | 94 | let settingsFile = `Name: ${infos.name}\r\n` + 95 | `Level: ${infos.difficult}\r\n` + 96 | `Charter: ${infos.author}\r\n` + 97 | `Chart: ${fileName.chart}\r\n` + 98 | `Song: ${fileName.song}\r\n` + 99 | `Picture: ${fileName.bg}`; 100 | 101 | let chart = await downloadFile(urls.chart, (progress) => { doms.downloadProgress.innerText = 'Downloading chart (' + Math.floor(progress * 100) + '%)'; }); 102 | let song = await downloadFile(urls.song, (progress) => { doms.downloadProgress.innerText = 'Downloading song (' + Math.floor(progress * 100) + '%)'; }); 103 | let bg = await downloadFile(urls.illustration, (progress) => { doms.downloadProgress.innerText = 'Downloading bg (' + Math.floor(progress * 100) + '%)'; }); 104 | 105 | doms.downloadProgress.innerText = 'All files are downloaded, head to \'File\' to select chart.'; 106 | 107 | loadChartFiles([ 108 | new File([chart], fileName.chart, { type: chart.type, lastModified: Date.now() }), 109 | new File([song], fileName.song, { type: song.type, lastModified: Date.now() }), 110 | new File([bg], fileName.bg, { type: bg.type, lastModified: Date.now() }), 111 | new File([new Blob([settingsFile])], 'Settings.txt', { type: 'text/plain', lastModified: Date.now() }) 112 | ]); 113 | 114 | function downloadFile(url, onProgressChange) 115 | { 116 | return new Promise((res, rej) => 117 | { 118 | let xhr = new XMLHttpRequest(); 119 | 120 | xhr.responseType = 'blob'; 121 | 122 | xhr.onreadystatechange = () => 123 | { 124 | if (xhr.readyState === 4) 125 | { 126 | if (xhr.status === 200) 127 | { 128 | res(xhr.response); 129 | } 130 | } 131 | }; 132 | 133 | xhr.onprogress = (e) => 134 | { 135 | if (typeof onProgressChange === 'function') 136 | { 137 | onProgressChange(e.loaded / e.total); 138 | } 139 | }; 140 | 141 | xhr.onerror = (e) => { rej(e) }; 142 | 143 | xhr.open('GET', url); 144 | xhr.send(); 145 | }); 146 | } 147 | } -------------------------------------------------------------------------------- /src/style/fonts/A-OTF_Shin_Go_Pr6N_H.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/src/style/fonts/A-OTF_Shin_Go_Pr6N_H.ttf -------------------------------------------------------------------------------- /src/style/fonts/MiSans-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MisaLiu/phi-chart-render/b392ea2b4ee4afe591cfbbcae9132a8519d9b038/src/style/fonts/MiSans-Regular.ttf -------------------------------------------------------------------------------- /src/style/fonts/index.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: A-OTF Shin Go Pr6N H; 3 | src: url('./A-OTF_Shin_Go_Pr6N_H.ttf'); 4 | } 5 | 6 | @font-face { 7 | font-family: MiSans; 8 | src: url('./MiSans-Regular.ttf'); 9 | } -------------------------------------------------------------------------------- /src/style/index.css: -------------------------------------------------------------------------------- 1 | @import url('./fonts/index.css'); 2 | 3 | html, body { 4 | padding: 0; 5 | margin: 0; 6 | overflow: hidden; 7 | 8 | --height-percent: 1; 9 | --width-offset: 0px; 10 | } 11 | 12 | div.file-select { 13 | position: absolute; 14 | top: 0; 15 | left: 0; 16 | max-width: 100%; 17 | max-height: 100%; 18 | padding: 10px; 19 | color: #000; 20 | background-color: #FFF; 21 | border: 1px solid #999; 22 | overflow: auto; 23 | box-sizing: border-box; 24 | z-index: 10; 25 | } 26 | 27 | .tab .bar { 28 | display: flex; 29 | position: relative; 30 | z-index: 1; 31 | word-wrap: none; 32 | overflow-x: auto; 33 | overflow-y: hidden; 34 | flex-direction: row; 35 | flex-wrap: nowrap; 36 | } 37 | .tab .bar > * { 38 | padding: 2px 8px; 39 | background-color: #eee; 40 | border: 1px solid #666; 41 | border-right: unset; 42 | box-sizing: border-box; 43 | -webkit-user-select: none; 44 | -moz-user-select: none; 45 | -ms-user-select: none; 46 | user-select: none; 47 | cursor: pointer; 48 | } 49 | .tab .bar > *:first-child { 50 | border-radius: 4px 0 0 0; 51 | } 52 | .tab .bar > *:last-child { 53 | border-right: 1px solid #666; 54 | border-radius: 0 4px 0 0; 55 | } 56 | .tab .bar > *.active { 57 | background-color: transparent; 58 | border-bottom: 1px solid #FFF; 59 | } 60 | 61 | .tab .content { 62 | position: relative; 63 | top: -1px; 64 | padding: 4px 8px; 65 | border: 1px solid #666; 66 | border-radius: 0 0 4px 4px; 67 | box-sizing: border-box; 68 | } 69 | .tab .content > * { 70 | display: none; 71 | } 72 | .tab .content > *:first-child { 73 | display: block; 74 | } 75 | 76 | button#fullscreen { 77 | position: absolute; 78 | top: 0; 79 | right: 0; 80 | } 81 | 82 | canvas.canvas-game { 83 | position: absolute; 84 | top: 0; 85 | left: 0; 86 | z-index: 1; 87 | } 88 | 89 | div.game-paused { 90 | display: none; 91 | position: absolute; 92 | top: 50%; 93 | left: 50%; 94 | padding: calc(18px * var(--height-percent)); 95 | transform: translate(-50%, -50%); 96 | background-color: rgba(0, 0, 0, 0.2); 97 | color: #FFF; 98 | backdrop-filter: blur(20px); 99 | border-radius: 14px; 100 | box-shadow: 0px 0px 24px #000; 101 | box-sizing: border-box; 102 | z-index: 10; 103 | } 104 | div.game-paused .title { 105 | font-size: calc(70px * var(--height-percent)); 106 | text-align: center; 107 | } 108 | 109 | div.game-paused .action { 110 | display: flex; 111 | margin-top: calc(28px * var(--height-percent)); 112 | flex-direction: row; 113 | justify-content: center; 114 | align-content: center; 115 | } 116 | div.game-paused .action * { 117 | font-size: calc(38px * var(--height-percent)); 118 | } 119 | 120 | div.game-paused { 121 | padding: calc(30px * var(--height-percent)); 122 | } 123 | 124 | div.error-window { 125 | display: none; 126 | position: absolute; 127 | top: 0; 128 | right: 0; 129 | max-width: 100%; 130 | max-height: 100%; 131 | padding: 8px; 132 | color: white; 133 | background-color: #F33; 134 | border: 1px solid #A00; 135 | box-sizing: border-box; 136 | z-index: 20; 137 | } 138 | div.error-window a { 139 | color: #FFF; 140 | } 141 | div.error-window button.close { 142 | margin-left: 6px; 143 | float: right; 144 | } 145 | div.error-window pre { 146 | margin-top: 8px; 147 | margin-bottom: 8px; 148 | padding: 8px; 149 | background: #D60000; 150 | box-sizing: border-box; 151 | overflow-x: auto; 152 | } 153 | div.error-window pre, 154 | div.error-window pre * { 155 | font-family: Menlo, Monaco, 'Courier New', monospace !important; 156 | } 157 | 158 | .play-result { 159 | display: block; 160 | position: absolute; 161 | top: 0; 162 | left: 0; 163 | width: 100%; 164 | height: 100%; 165 | color: #FFF; 166 | overflow: hidden; 167 | z-index: 10; 168 | pointer-events: none; 169 | touch-action: none; 170 | } 171 | 172 | .play-result .bg-shadow-cover { 173 | position: absolute; 174 | top: 100%; 175 | left: 0; 176 | width: 100%; 177 | height: 1px; 178 | box-shadow: 0px 0px 0px 0 transparent; 179 | } 180 | .play-result .bg-shadow-cover.top { 181 | top: 0%; 182 | } 183 | 184 | .play-result .song-info { 185 | position: absolute; 186 | top: calc(106px * var(--height-percent)); 187 | left: 110%; 188 | padding-left: calc(16px * var(--height-percent)); 189 | box-sizing: border-box; 190 | } 191 | .play-result .song-info::before { 192 | content: ''; 193 | display: block; 194 | position: absolute; 195 | top: 0; 196 | left: 0; 197 | bottom: 0; 198 | width: calc(8px * var(--height-percent)); 199 | background: #FFF; 200 | border-radius: calc(6px * var(--height-percent)); 201 | } 202 | 203 | .play-result .song-info .title { 204 | font-family: 'A-OTF Shin Go Pr6N H'; 205 | font-size: calc(60px * var(--height-percent)); 206 | font-weight: bolder; 207 | line-height: calc(68px * var(--height-percent)); 208 | } 209 | .play-result .song-info .subtitle { 210 | font-size: calc(25px * var(--height-percent)); 211 | font-weight: bold; 212 | line-height: calc(26px * var(--height-percent)); 213 | } 214 | .play-result .song-info .subtitle.diff { 215 | font-size: calc(20px * var(--height-percent)); 216 | font-weight: normal; 217 | } 218 | 219 | .play-result .judge-icon { 220 | position: absolute; 221 | top: calc(304px * var(--height-percent)); 222 | left: 120%; 223 | font-size: calc(204px * var(--height-percent)); 224 | 225 | /* 以下内容皆为测试专用 */ 226 | color: #EBCF0E; 227 | font-weight: bold; 228 | text-shadow: 0px 0px 20px #EBCF0E; 229 | } 230 | .play-result .extra-info { 231 | position: absolute; 232 | top: calc(540px * var(--height-percent)); 233 | left: 110%; 234 | font-family: 'A-OTF Shin Go Pr6N H' !important; 235 | font-size: calc(26px * var(--height-percent)); 236 | line-height: calc(50px * var(--height-percent)); 237 | text-transform: uppercase; 238 | } 239 | 240 | .play-result .info-bar { 241 | position: absolute; 242 | left: 110%; 243 | width: calc(520px * var(--height-percent)); 244 | height: calc(130px * var(--height-percent)); 245 | padding: calc(24px * var(--height-percent)) calc(54px * var(--height-percent)); 246 | background: rgba(0, 0, 0, 0.15); 247 | border-radius: calc(16px * var(--height-percent)); 248 | box-sizing: border-box; 249 | -webkit-backdrop-filter: blur(30px); 250 | backdrop-filter: blur(30px); 251 | } 252 | .play-result .info-bar::after { 253 | content: ''; 254 | display: block; 255 | position: absolute; 256 | top: calc(30px * var(--height-percent)); 257 | left: calc(26px * var(--height-percent)); 258 | width: calc(4px * var(--height-percent)); 259 | height: calc(100% - (60px * var(--height-percent))); 260 | background: #FFF; 261 | border-radius: calc(6px * var(--height-percent)); 262 | } 263 | 264 | .play-result .info-bar.score { 265 | bottom: calc(333px * var(--height-percent)); 266 | } 267 | .play-result .info-bar.score .score { 268 | font-family: 'A-OTF Shin Go Pr6N H'; 269 | font-size: calc(50px * var(--height-percent)); 270 | line-height: calc(56px * var(--height-percent)); 271 | } 272 | .play-result .info-bar.score .acc { 273 | font-family: 'A-OTF Shin Go Pr6N H'; 274 | font-size: calc(20px * var(--height-percent)); 275 | line-height: calc(18px * var(--height-percent)); 276 | } 277 | 278 | .play-result .info-bar.acc-bar { 279 | height: calc(38px * var(--height-percent)); 280 | left: calc(646px * var(--height-percent) + var(--width-offset)); 281 | bottom: calc(333px * var(--height-percent)); 282 | padding: calc(8px * var(--height-percent)); 283 | opacity: 0; 284 | pointer-events: none; 285 | touch-action: none; 286 | -webkit-transition: opacity 0.15s linear; 287 | -moz-transition: opacity 0.15s linear; 288 | -ms-transition: opacity 0.15s linear; 289 | transition: opacity 0.15s linear; 290 | } 291 | .play-result .info-bar.acc-bar.show { 292 | opacity: 1; 293 | } 294 | .play-result .info-bar.acc-bar::after { 295 | display: none; 296 | } 297 | 298 | .play-result .info-bar.acc-bar .judge-histogram { 299 | position: relative; 300 | width: 100%; 301 | height: calc(22px * var(--height-percent)); 302 | background-color: rgba(0, 0, .0, 0.6); 303 | } 304 | .play-result .info-bar.acc-bar .judge-histogram > * { 305 | position: absolute; 306 | width: 1%; 307 | height: 100%; 308 | left: calc(100% - var(--pos)); 309 | background-color: green; 310 | transform: translateX(50%); 311 | } 312 | .play-result .info-bar.acc-bar .judge-histogram > .center { 313 | background-color: red; 314 | opacity: 0.4; 315 | 316 | --pos: 50%; 317 | } 318 | 319 | .play-result .info-bar.detail { 320 | bottom: calc(184px * var(--height-percent)); 321 | } 322 | .play-result .info-bar.detail .detail { 323 | display: flex; 324 | margin-top: calc(6px * var(--height-percent)); 325 | flex-direction: row; 326 | flex-wrap: nowrap; 327 | align-content: center; 328 | align-items: center; 329 | } 330 | .play-result .info-bar.detail .detail .detail-single { 331 | flex: 1; 332 | } 333 | .play-result .info-bar.detail .detail .detail-single .type { 334 | font-family: 'A-OTF Shin Go Pr6N H'; 335 | font-size: calc(25px * var(--height-percent)); 336 | line-height: calc(22px * var(--height-percent)); 337 | letter-spacing: calc(-2px * var(--height-percent)); 338 | } 339 | .play-result .info-bar.detail .detail .detail-single .value { 340 | font-size: calc(16px * var(--height-percent)); 341 | } 342 | .play-result .info-bar.detail .max-combo { 343 | margin-top: calc(8px * var(--height-percent)); 344 | font-size: calc(16px * var(--height-percent)); 345 | } 346 | 347 | .play-result .actions { 348 | display: flex; 349 | position: absolute; 350 | bottom: calc(72px * var(--height-percent)); 351 | right: 110%; 352 | flex-direction: row; 353 | align-content: flex-end; 354 | justify-content: flex-start; 355 | align-items: flex-end; 356 | gap: calc(24px * var(--height-percent)); 357 | } 358 | 359 | .play-result .actions button { 360 | min-width: calc(128px * var(--height-percent)); 361 | height: calc(62px * var(--height-percent)); 362 | padding: 0 calc(26px * var(--height-percent)); 363 | background: rgba(0, 0, 0, 0.4); 364 | color: #FFF; 365 | font-size: calc(24px * var(--height-percent)); 366 | border: none; 367 | outline: none; 368 | border-radius: calc(36px * var(--height-percent)); 369 | -webkit-transition: background 0.15s linear , color 0.15s linear; 370 | -moz-transition: background 0.15s linear , color 0.15s linear; 371 | -ms-transition: background 0.15s linear , color 0.15s linear; 372 | transition: background 0.15s linear , color 0.15s linear; 373 | } 374 | .play-result .actions button.big { 375 | min-width: calc(216px * var(--height-percent)); 376 | height: calc(90px * var(--height-percent)); 377 | padding: 0 calc(50px * var(--height-percent)); 378 | font-size: calc(36px * var(--height-percent)); 379 | border-radius: calc(52px * var(--height-percent)); 380 | } 381 | .play-result .actions button:hover { 382 | background: #FFF; 383 | color: #000; 384 | } 385 | .play-result .actions button:active { 386 | background: #bbb; 387 | } 388 | .play-result .actions button.highlight { 389 | background: #FFF; 390 | color: #4e4e4e; 391 | } 392 | .play-result .actions button.highlight:hover { 393 | color: #000; 394 | } 395 | .play-result .actions button.highlight:active { 396 | background: #bbb; 397 | } 398 | 399 | .play-result.show { 400 | pointer-events: all; 401 | touch-action: auto; 402 | } 403 | .play-result.show .bg-shadow-cover { 404 | display: block; 405 | box-shadow: 0px -1px 170px calc(140px * var(--height-percent)) #000; 406 | -webkit-transition: box-shadow 0.5s cubic-bezier(0, 0, 0, 1); 407 | -moz-transition: box-shadow 0.5s cubic-bezier(0, 0, 0, 1); 408 | -ms-transition: box-shadow 0.5s cubic-bezier(0, 0, 0, 1); 409 | transition: box-shadow 0.5s cubic-bezier(0, 0, 0, 1); 410 | } 411 | .play-result.show .song-info { 412 | left: calc(106px * var(--height-percent) + var(--width-offset)); 413 | -webkit-transition: left 0.5s cubic-bezier(0, 0, 0, 1); 414 | -moz-transition: left 0.5s cubic-bezier(0, 0, 0, 1); 415 | -ms-transition: left 0.5s cubic-bezier(0, 0, 0, 1); 416 | transition: left 0.5s cubic-bezier(0, 0, 0, 1); 417 | } 418 | .play-result.show .judge-icon { 419 | left: calc(110px * var(--height-percent) + var(--width-offset)); 420 | -webkit-transition: left 0.5s cubic-bezier(0, 0, 0, 1) 0.2s; 421 | -moz-transition: left 0.5s cubic-bezier(0, 0, 0, 1) 0.2s; 422 | -ms-transition: left 0.5s cubic-bezier(0, 0, 0, 1) 0.2s; 423 | transition: left 0.5s cubic-bezier(0, 0, 0, 1) 0.2s; 424 | } 425 | .play-result.show .extra-info { 426 | left: calc(112px * var(--height-percent) + var(--width-offset)); 427 | -webkit-transition: left 0.5s cubic-bezier(0, 0, 0, 1) 0.2s; 428 | -moz-transition: left 0.5s cubic-bezier(0, 0, 0, 1) 0.2s; 429 | -ms-transition: left 0.5s cubic-bezier(0, 0, 0, 1) 0.2s; 430 | transition: left 0.5s cubic-bezier(0, 0, 0, 1) 0.2s; 431 | } 432 | .play-result.show .info-bar.score { 433 | left: calc(106px * var(--height-percent) + var(--width-offset)); 434 | -webkit-transition: left 0.5s cubic-bezier(0, 0, 0, 1) 0.4s; 435 | -moz-transition: left 0.5s cubic-bezier(0, 0, 0, 1) 0.4s; 436 | -ms-transition: left 0.5s cubic-bezier(0, 0, 0, 1) 0.4s; 437 | transition: left 0.5s cubic-bezier(0, 0, 0, 1) 0.4s; 438 | } 439 | .play-result.show .info-bar.detail { 440 | left: calc(106px * var(--height-percent) + var(--width-offset)); 441 | -webkit-transition: left 0.5s cubic-bezier(0, 0, 0, 1) 0.6s; 442 | -moz-transition: left 0.5s cubic-bezier(0, 0, 0, 1) 0.6s; 443 | -ms-transition: left 0.5s cubic-bezier(0, 0, 0, 1) 0.6s; 444 | transition: left 0.5s cubic-bezier(0, 0, 0, 1) 0.6s; 445 | } 446 | .play-result.show .actions { 447 | right: calc(68px * var(--height-percent) + var(--width-offset)); 448 | -webkit-transition: right 0.5s cubic-bezier(0, 0, 0, 1) 0.8s; 449 | -moz-transition: right 0.5s cubic-bezier(0, 0, 0, 1) 0.8s; 450 | -ms-transition: right 0.5s cubic-bezier(0, 0, 0, 1) 0.8s; 451 | transition: right 0.5s cubic-bezier(0, 0, 0, 1) 0.8s; 452 | } 453 | 454 | .font-loaded div.file-select, 455 | .font-loaded div.file-select *, 456 | .font-loaded div.play-result, 457 | .font-loaded div.play-result *, 458 | .font-loaded div.error-window, 459 | .font-loaded div.error-window * { 460 | font-family: 'MiSans'; 461 | } 462 | .font-loaded div.file-select a { 463 | font-family: 'A-OTF Shin Go Pr6N H'; 464 | line-height: 20px; 465 | } 466 | 467 | div.debug-value { 468 | position: absolute; 469 | top: 0; 470 | right: 0; 471 | color: white; 472 | } 473 | 474 | 475 | @media screen and (prefers-color-scheme: dark) { 476 | html, body, div.file-select { 477 | background-color: #202124; 478 | color: #d6d6d6; 479 | } 480 | 481 | .tab .bar > * { 482 | background-color: #4a4a4a; 483 | border: 1px solid #ebebeb; 484 | border-right: unset; 485 | } 486 | .tab .bar > *.active { 487 | border-bottom: 1px solid #202124; 488 | } 489 | 490 | .tab .content, div.file-select { 491 | border: 1px solid #ebebeb; 492 | } 493 | } -------------------------------------------------------------------------------- /src/verify.js: -------------------------------------------------------------------------------- 1 | function bool(bool, defaultValue = false) 2 | { 3 | return (typeof bool === 'boolean') ? !!bool : defaultValue; 4 | } 5 | 6 | function number(number, defaultValue = 0, min = -Infinity, max = Infinity) 7 | { 8 | return (!isNaN(number) && min <= parseFloat(number) && parseFloat(number) <= max ? parseFloat(number) : defaultValue); 9 | } 10 | 11 | function text(text, defaultValue = '') 12 | { 13 | return ((typeof text === 'string') && text != '') ? text : defaultValue; 14 | } 15 | 16 | export { 17 | bool, 18 | number, 19 | text 20 | } -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import config from './package.json'; 2 | import { defineConfig } from 'vite'; 3 | import { createHtmlPlugin } from 'vite-plugin-html'; 4 | import { VitePWA } from 'vite-plugin-pwa'; 5 | import git from 'git-rev-sync'; 6 | import path from 'path'; 7 | 8 | const CurrentVersion = 'v' + config.version + '-' + git.short(); 9 | 10 | // https://vitejs.dev/config/ 11 | export default defineConfig({ 12 | base: '/phi-chart-render/', 13 | plugins: [ 14 | createHtmlPlugin({ 15 | inject: { 16 | data: { 17 | GIT_VERSION: CurrentVersion 18 | } 19 | } 20 | }), 21 | VitePWA({ 22 | injectRegister: 'auto', 23 | registerType: 'autoUpdate', 24 | workbox: { 25 | cleanupOutdatedCaches: true, 26 | runtimeCaching: [ 27 | { 28 | urlPattern: /\/phi-chart-render(.*?)\.(png|ogg|ico|ttf)/, 29 | handler: 'StaleWhileRevalidate', 30 | options: { 31 | cacheName: 'assets-cache', 32 | }, 33 | } 34 | ], 35 | }, 36 | minify: true, 37 | manifest: { 38 | id: 'misaliu-phi-chart-render', 39 | name: 'phi-chart-render', 40 | short_name: 'phi-chart-render', 41 | description: 'A Phigros chart render based on Pixi.js', 42 | scope: '/phi-chart-render/', 43 | display: 'standalone', 44 | orientation: 'landscape', 45 | background_color: '#000000', 46 | includeAssets: [ './icons/favicon.ico' ], 47 | icons: [ 48 | { 49 | src: './icons/64.png', 50 | sizes: '64x64', 51 | type: 'image/png' 52 | }, 53 | { 54 | src: './icons/192.png', 55 | sizes: '192x192', 56 | type: 'image/png' 57 | } 58 | ] 59 | } 60 | }), 61 | ], 62 | define: { 63 | GIT_VERSION: JSON.stringify(CurrentVersion) 64 | }, 65 | resolve: { 66 | alias: { 67 | '@': path.resolve(__dirname, './src') 68 | } 69 | }, 70 | server: { 71 | host: '0.0.0.0', 72 | port: 9000, 73 | open: true 74 | }, 75 | build: { 76 | sourcemap: true 77 | } 78 | }); 79 | 80 | 81 | --------------------------------------------------------------------------------