├── .gitignore ├── .vscode └── extensions.json ├── LICENSE.md ├── README.md ├── README_zh.md ├── docs ├── .vitepress │ ├── config.ts │ ├── config │ │ ├── en.ts │ │ └── zh.ts │ ├── theme │ │ ├── code.css │ │ └── index.ts │ └── utils │ │ └── index.ts ├── guide │ ├── cheat.md │ ├── controller.md │ ├── directives.md │ ├── events.md │ ├── getting-started.md │ ├── methods.md │ ├── props.md │ └── replay.md ├── index.md ├── public │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── nes-vue.svg │ └── site.webmanifest └── zh │ ├── guide │ ├── cheat.md │ ├── controller.md │ ├── directives.md │ ├── events.md │ ├── getting-started.md │ ├── methods.md │ ├── props.md │ └── replay.md │ └── index.md ├── eslint.config.js ├── index.html ├── package.json ├── playground ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── LICENSE.md ├── README.md ├── index.html ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public │ ├── Mighty Final Fight (USA).nes │ ├── Mitsume ga Tooru (Japan).nes │ ├── Super Mario Bros (JU).nes │ ├── Super Mario Bros 3.nes │ ├── happylee-supermariobros,warped.fm2 │ ├── icons │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── browserconfig.xml │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── mstile-150x150.png │ │ ├── safari-pinned-tab.svg │ │ └── site.webmanifest │ ├── jy,aiqiyou-mitsumegatooru.fm2 │ ├── lordtom,maru,tompa-smb3-warps.fm2 │ ├── nes-vue.es.js │ ├── vite.svg │ └── xipov3-mightyfinalfight.fm2 ├── src │ ├── App.vue │ ├── assets │ │ └── vue.svg │ ├── auto-imports.d.ts │ ├── components.d.ts │ ├── components │ │ ├── Icon │ │ │ ├── IconDownload.vue │ │ │ ├── IconGitee.vue │ │ │ ├── IconGithub.vue │ │ │ ├── IconMoon.vue │ │ │ └── IconSun.vue │ │ └── MainHeader.vue │ ├── css │ │ └── app.scss │ ├── download │ │ ├── files.ts │ │ ├── index.ts │ │ └── roms.ts │ ├── layouts │ │ └── MainLayout.vue │ ├── main.ts │ ├── pages │ │ ├── 404.vue │ │ └── index.vue │ ├── router │ │ └── index.ts │ ├── stores │ │ ├── dark.ts │ │ └── index.ts │ ├── template.ts │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── pnpm-lock.yaml ├── public ├── Super Mario Bros (JU).nes ├── favicon.ico ├── happylee-supermariobros,warped.fm2 ├── nintendo-svgrepo-com.svg └── vite.svg ├── src ├── App.vue ├── animation │ └── index.ts ├── audio │ └── index.ts ├── components │ └── NesVue.vue ├── composables │ ├── use-controller.ts │ └── use-instance.ts ├── db │ └── index.ts ├── dev │ └── NesDemo.vue ├── directives │ └── v-gamepad.ts ├── env.d.ts ├── index.ts ├── main.ts ├── nes │ └── index.ts ├── types.ts └── utils │ └── index.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | /**/cache 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/settings.json 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "vue.volar", 4 | "dbaeumer.vscode-eslint" 5 | ] 6 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Taiyuuki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sub license, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

nes-vue

2 | 3 |

4 | A NES (FC)🎮 emulator component for Vue 3. 5 |

6 | 7 | 8 |

9 | GitHub package.json version 10 |

11 | 12 | :cn:[中文](./README_zh.md) 13 | 14 | ## 🚀Playground 15 | 16 | [Vue SFC Playgournd](https://play.vuejs.org/#eNqtV9lu2zgU/RWOX+wAtpw0LVB40k7SQTuYImmDLH2J8iBLlMVEIjUi5dgI/O9zuEmy46xoH1zxrueuZO57R2UZzGvam/QOZFyxUhFJVV1+DjkrSlEpck8qmg5JLIqyVjQhK5JWoiB9KPU7Qj+o/FXThsupHFmJkMeCS0VmUUGPGT4+kauQExL2MqVKORmPVcSWdX3LghlTWT0NmBg79fF5XdKKnEQVE+RLJSQZfL/cCcANe8O3G9l/rYUTNsvUknxjPMrxiwMZXJ4fvRrJCVOyLihyQS6EqGqEE5URd3ZCfu2TlRbvurkyPy/0kUVlucwpHUkddqGjniLo4V1UlTQJYNkhJmRX/3dtDq/xkIsqUaIYwnY9xEcZjWQx3R9pD3LNwWjvbR4WrBTz/VFh0p7qrKf683eAv1kOI/YfW4oa5k01ZpHStdhivFMQ3b2XZ8coCKZh4Hv5avd6x0vAgePyOs8bssx1FR+QEyajaU4dS1U1bVhMnka1xKRZXhrlsmWWmnUcTWkOtp/JwWCHfPrcKAbzKMco/kX6p3m07JMJPjSjDyshT2seKyY4SamKs2/FOyjf63CbzoNl139XPtCA8YQufqYDhGPN298dJIjo0B0ResjSIAj0F9zpXAYqo9xBNI70Pxe/gwqHOkrNXEFrtYazothI/wCIB9q6M6yB8bMRfMeiiqTOA+OzTd66H6lEud2N5lgvm7B14R7YoTmN1ZkovCHXO43SRg4ft/ssdluyVmijZRpIJdgXR+cPI0OVtKoNbouzrfEhG48ZO28y9eZ6dGFrC94PS8lg3ahjdCHoQK3/FaGwuUXC2jQiW3H+sU4xqA7G9m7ErYiDogX8KIoTIQcJm5M4j6T8FPamYhH2DBkMt3F8x6MwkHCL3pImdZWD5vqjQ79jicrA+bCHldRQM6qXIMjvP+625ENZxzE17v1Ee+bYIhwD4hasigOKB2ubdh0r+rR1wwESNFzuLc2bWhM8jLOIz7RsMwie6ZzBnSh1gf2RkPkoFZU1Txhv3gqtVYRvyrEJAfRbutykNo4Iucf7RRTYFChZTAd9fdX2h6Tf32lpL7g2rApZmZ4xIYxtDD6BYxutP05rpdDBh3HO4lsNzy+xJuWEnGmaV7cKj6n73dTR1qP2MmXT8mEPg4t3Ac6muybvKRJGJm7zJKC7z44PJK9z4fjgn/Vnt82z1vUWIBAkv1hCxcsTYW3PRzITdyC1+2QjO0/a9lNxMO6MM44mSfgMMMt2e5RCMl3pCcYCcmxO/3QrWwc6Qb/mjNPRNBfxLThmiQV6uqw6XkkzxkfAMyEfyoWVwGOFWzBDd18MYQeX+ZpOpefdaHmz+jVhJGwNyd7urjeJDnTQe8OefZaPiqgMbqTgeN0brdAxMPgTvxrNTjKLCrT2CRUnHKoJzdm8wsyoMS8LPw2He8HHYA8JlCBbUoAFe+NWG8CsgEFJXE94t20g0I8WltPqpxmfdSRRnou774ambx73GINORuPbLfQbiX2rUZ/q+armiKHhKeQQ42XYX89/0AW+G2Yhklr34hNMTKfIa7OmjNiXmieA3ZEzaP81CUXzXcivC0W59EH5K3zl/jxAkv5+IvQW7n6w32Rx9T8laIlV) 17 | 18 | [NES Vue Playground](https://taiyuuki.github.io/nes-vue) 19 | 20 | ## Features 21 | 22 | - [x] Support for multiplayer. 23 | - [x] Support for gamepad. 24 | - [x] Support for turbo button. 25 | - [x] Support for saving and loading. 26 | - [x] Support for playing TAS recordings. (`*.fm2`) 27 | - [x] Support for cheat codes. 28 | 29 | ## Usage 30 | 31 | ### install 32 | 33 | ```shell 34 | npm install nes-vue --save 35 | ``` 36 | 37 | And then 38 | 39 | ```vue 40 | 43 | 48 | ``` 49 | Refer to [documentations](https://nes-vue-docs.netlify.app/) for more details. -------------------------------------------------------------------------------- /README_zh.md: -------------------------------------------------------------------------------- 1 |

nes-vue

2 | 3 |

4 | 用于 Vue 3 的 NES (FC)🎮 游戏模拟器组件。 5 |

6 | 7 | 8 |

9 | GitHub package.json version 10 |

11 | 12 | ## 🚀游乐场 13 | 14 | [Vue SFC Playgournd](https://play.vuejs.org/#eNqtV9lu2zgU/RWOX+wAtpw0LVB40k7SQTuYImmDLH2J8iBLlMVEIjUi5dgI/O9zuEmy46xoH1zxrueuZO57R2UZzGvam/QOZFyxUhFJVV1+DjkrSlEpck8qmg5JLIqyVjQhK5JWoiB9KPU7Qj+o/FXThsupHFmJkMeCS0VmUUGPGT4+kauQExL2MqVKORmPVcSWdX3LghlTWT0NmBg79fF5XdKKnEQVE+RLJSQZfL/cCcANe8O3G9l/rYUTNsvUknxjPMrxiwMZXJ4fvRrJCVOyLihyQS6EqGqEE5URd3ZCfu2TlRbvurkyPy/0kUVlucwpHUkddqGjniLo4V1UlTQJYNkhJmRX/3dtDq/xkIsqUaIYwnY9xEcZjWQx3R9pD3LNwWjvbR4WrBTz/VFh0p7qrKf683eAv1kOI/YfW4oa5k01ZpHStdhivFMQ3b2XZ8coCKZh4Hv5avd6x0vAgePyOs8bssx1FR+QEyajaU4dS1U1bVhMnka1xKRZXhrlsmWWmnUcTWkOtp/JwWCHfPrcKAbzKMco/kX6p3m07JMJPjSjDyshT2seKyY4SamKs2/FOyjf63CbzoNl139XPtCA8YQufqYDhGPN298dJIjo0B0ResjSIAj0F9zpXAYqo9xBNI70Pxe/gwqHOkrNXEFrtYazothI/wCIB9q6M6yB8bMRfMeiiqTOA+OzTd66H6lEud2N5lgvm7B14R7YoTmN1ZkovCHXO43SRg4ft/ssdluyVmijZRpIJdgXR+cPI0OVtKoNbouzrfEhG48ZO28y9eZ6dGFrC94PS8lg3ahjdCHoQK3/FaGwuUXC2jQiW3H+sU4xqA7G9m7ErYiDogX8KIoTIQcJm5M4j6T8FPamYhH2DBkMt3F8x6MwkHCL3pImdZWD5vqjQ79jicrA+bCHldRQM6qXIMjvP+625ENZxzE17v1Ee+bYIhwD4hasigOKB2ubdh0r+rR1wwESNFzuLc2bWhM8jLOIz7RsMwie6ZzBnSh1gf2RkPkoFZU1Txhv3gqtVYRvyrEJAfRbutykNo4Iucf7RRTYFChZTAd9fdX2h6Tf32lpL7g2rApZmZ4xIYxtDD6BYxutP05rpdDBh3HO4lsNzy+xJuWEnGmaV7cKj6n73dTR1qP2MmXT8mEPg4t3Ac6muybvKRJGJm7zJKC7z44PJK9z4fjgn/Vnt82z1vUWIBAkv1hCxcsTYW3PRzITdyC1+2QjO0/a9lNxMO6MM44mSfgMMMt2e5RCMl3pCcYCcmxO/3QrWwc6Qb/mjNPRNBfxLThmiQV6uqw6XkkzxkfAMyEfyoWVwGOFWzBDd18MYQeX+ZpOpefdaHmz+jVhJGwNyd7urjeJDnTQe8OefZaPiqgMbqTgeN0brdAxMPgTvxrNTjKLCrT2CRUnHKoJzdm8wsyoMS8LPw2He8HHYA8JlCBbUoAFe+NWG8CsgEFJXE94t20g0I8WltPqpxmfdSRRnou774ambx73GINORuPbLfQbiX2rUZ/q+armiKHhKeQQ42XYX89/0AW+G2Yhklr34hNMTKfIa7OmjNiXmieA3ZEzaP81CUXzXcivC0W59EH5K3zl/jxAkv5+IvQW7n6w32Rx9T8laIlV) 15 | 16 | [NES Vue Playground](https://taiyuuki.github.io/nes-vue) 17 | 18 | ## 功能 19 | 20 | - [x] 支持双人 21 | - [x] 支持手柄 22 | - [x] 支持连发键 23 | - [x] 支持保存、读取 24 | - [x] 支持回放TAS录像(*.fm2文件) 25 | - [x] 支持金手指(作弊码) 26 | 27 | ## 使用 28 | 29 | ### 安装 30 | 31 | ```shell 32 | npm install nes-vue --save 33 | ``` 34 | 35 | 然后: 36 | 37 | ```vue 38 | 41 | 46 | ``` 47 | 48 | 更多组件API请查看 [文档](https://nes-vue-docs.netlify.app/zh/) -------------------------------------------------------------------------------- /docs/.vitepress/config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress' 2 | import root from './config/en' 3 | import zh from './config/zh' 4 | import { copyright } from './utils' 5 | 6 | const giteeSVG = ` 7 | 9 | ` 10 | 11 | const path = process.env.GITEE ? '/nes-vue-docs' : '' 12 | 13 | // https://vitepress.dev/reference/site-config 14 | export default defineConfig({ 15 | base: process.env.GITEE ? '/nes-vue-docs/' : void 0, 16 | title: 'NES Vue', 17 | description: 'NES Vue documents', 18 | 19 | head: [ 20 | ['link', { rel: 'icon', href: path + '/favicon-32x32.png' }], 21 | ['link', { rel: 'icon', href: path + '/favicon-16x16.png' }], 22 | ['link', { rel: 'apple-touch-icon', href: path + '/apple-touch-icon.png' }], 23 | ['link', { rel: 'manifest', href: path + '/site.webmanifest' }], 24 | ['meta', { name: 'msapplication-TileColor', content: '#da532c' }], 25 | ['meta', { name: 'theme-color', content: '#ffffff' }], 26 | ], 27 | 28 | locales: { 29 | root, 30 | zh, 31 | }, 32 | 33 | markdown: { 34 | theme: { 35 | light: 'light-plus', 36 | dark: 'github-dark', 37 | }, 38 | }, 39 | 40 | themeConfig: { 41 | logo: '/nes-vue.svg', 42 | outline: { 43 | level: [2, 3], 44 | }, 45 | footer: { 46 | message: 'Released under the MIT License.', 47 | copyright: copyright(), 48 | }, 49 | socialLinks: [ 50 | { icon: 'github', link: 'https://github.com/taiyuuki/nes-vue' }, 51 | { 52 | icon: { 53 | svg: giteeSVG, 54 | }, 55 | link: 'https://gitee.com/taiyuuki/nes-vue', 56 | }, 57 | ], 58 | search: { 59 | provider: 'local', 60 | }, 61 | }, 62 | }) 63 | -------------------------------------------------------------------------------- /docs/.vitepress/config/en.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | label: 'English', 3 | lang: 'en', 4 | themeConfig: { 5 | nav: [ 6 | { text: 'Home', link: '/' }, 7 | { text: 'API', link: '/guide/getting-started' }, 8 | ], 9 | 10 | sidebar: [ 11 | { 12 | text: 'Guide', 13 | items: [ 14 | { text: 'Getting Started', link: '/guide/getting-started' }, 15 | { text: 'Props', link: '/guide/props' }, 16 | { text: 'Controller', link: '/guide/controller' }, 17 | { text: 'Directives', link: '/guide/directives' }, 18 | { text: 'Events', link: '/guide/events' }, 19 | { text: 'Methods', link: '/guide/methods' }, 20 | { text: 'TAS Video', link: '/guide/replay' }, 21 | { text: 'Cheat Code', link: '/guide/cheat' }, 22 | ], 23 | }, 24 | ], 25 | 26 | outlineTitle: 'Outline', 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /docs/.vitepress/config/zh.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | label: '简体中文', 3 | lang: 'zh-CN', 4 | themeConfig: { 5 | // https://vitepress.dev/reference/default-theme-config 6 | nav: [ 7 | { text: '主页', link: '/zh/index' }, 8 | { text: 'API', link: '/zh/guide/getting-started' }, 9 | ], 10 | 11 | sidebar: [ 12 | { 13 | text: '指南', 14 | items: [ 15 | { text: '开始使用', link: '/zh/guide/getting-started' }, 16 | { text: '属性', link: '/zh/guide/props' }, 17 | { text: '控制器', link: '/zh/guide/controller' }, 18 | { text: '指令', link: '/zh/guide/directives' }, 19 | { text: '事件', link: '/zh/guide/events' }, 20 | { text: '方法', link: '/zh/guide/methods' }, 21 | { text: '播放录像', link: '/zh/guide/replay' }, 22 | { text: '金手指', link: '/zh/guide/cheat' }, 23 | ], 24 | }, 25 | ], 26 | 27 | outlineTitle: '目录', 28 | 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/code.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --vp-c-brand: #f03752; 3 | --vp-button-brand-bg: #f03752; 4 | --vp-button-brand-border: #f04b22; 5 | --vp-button-brand-hover-bg: #41b883; 6 | --vp-button-brand-hover-border: #41b349; 7 | --vp-button-brand-active-bg: #1e9eb3; 8 | --vp-button-brand-active-border: #2b73af; 9 | 10 | --code-base: #eef7f2; 11 | --code-gradient: #fffef9; 12 | --code-counter: #131124; 13 | --code-border: #41b349; 14 | } 15 | 16 | :root.dark { 17 | --code-base: #132c33; 18 | --code-gradient: #1c0d1a; 19 | --code-counter: #baccd9; 20 | } 21 | 22 | html { 23 | overflow-y: auto; 24 | } 25 | 26 | header.VPNav.VPNav { 27 | width: 100vw; 28 | } 29 | 30 | .vp-doc div[class*='language-'] { 31 | overflow-x: initial; 32 | } 33 | 34 | div:not(.language-shell)>pre.shiki { 35 | position: relative; 36 | margin: 0; 37 | padding: 10px 10px 10px 1.25em; 38 | overflow: auto; 39 | white-space: pre; 40 | text-align: left; 41 | word-break: break-all; 42 | background: var(--code-base); 43 | background-image: 44 | linear-gradient(410deg, 45 | var(--code-gradient) 25%, 46 | transparent 25%, 47 | transparent 75%, 48 | var(--code-gradient) 75%, 49 | var(--code-gradient)), 50 | linear-gradient(410deg, 51 | var(--code-gradient) 25%, 52 | transparent 25%, 53 | transparent 75%, 54 | var(--code-gradient) 75%, 55 | var(--code-gradient)); 56 | background-position: 0 0, 2px 2px; 57 | background-size: 4px 4px; 58 | border-right: 5px solid var(--code-border); 59 | border-left: 5px solid var(--code-border); 60 | border-radius: 5px; 61 | outline: none; 62 | } 63 | 64 | div:not(.language-shell)>pre.shiki::before { 65 | position: absolute; 66 | top: 10px; 67 | bottom: 10px; 68 | left: 0.5em; 69 | padding-right: 0.5em; 70 | overflow: hidden; 71 | color: var(--code-counter); 72 | text-align: right; 73 | content: "01\A 02\A 03\A 04\A 05\A 06\A 07\A 08\A 09\A 10\A 11\A 12\A 13\A 14\A 15\A 16\A 17\A 18\A 19\A 20\A 21\A 22\A 23\A 24\A 25\A 26\A 27\A 28\A 29\A 30\A 31\A 32\A 33\A 34\A 35\A 36\A 37\A 38\A 39\A 40\A 41\A 42\A 43\A 44\A 45\A 46\A 47\A 48\A 49\A 50\A 51\A 52\A 53\A 54\A 55\A 56\A 57\A 58\A 59\A 60\A 61\A 62\A 63\A 64\A 65\A 66\A 67\A 68\A 69\A 70\A 71\A 72\A 73\A 74\A 75\A 76\A 77\A 78\A 79\A 80\A 81\A 82\A 83\A 84\A 85\A 86\A 87\A 88\A 89\A 90\A 91\A 92\A 93\A 94\A 95\A 96\A 97\A 98\A 99"; 74 | } 75 | 76 | div:not(.language-shell)>pre.shiki::after { 77 | position: absolute; 78 | top: 0px; 79 | bottom: 0px; 80 | left: 1em; 81 | padding-right: 0.5em; 82 | border-right: 1px dashed var(--code-counter); 83 | content: ' \A'; 84 | } 85 | 86 | .image-container img { 87 | max-width: 320px; 88 | max-height: 320px; 89 | } -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | // .vitepress/theme/index.js 2 | import DefaultTheme from 'vitepress/theme' 3 | import './code.css' 4 | 5 | export default DefaultTheme 6 | -------------------------------------------------------------------------------- /docs/.vitepress/utils/index.ts: -------------------------------------------------------------------------------- 1 | function copyright() { 2 | const start = 2023 3 | const now = new Date().getFullYear() 4 | const year = now === start ? '2023' : `${start}-${now}` 5 | return `Copyright © ${year} Taiyuuki` 6 | } 7 | 8 | export { copyright } 9 | -------------------------------------------------------------------------------- /docs/guide/cheat.md: -------------------------------------------------------------------------------- 1 | # Cheat Code 2 | 3 | Start with v1.8.0, nes-vue supports cheat code. 4 | 5 | ## Usage 6 | 7 | Just like the cheat code in VirtuaNES, The format of cheat code is `xxxx-yz-vv`, where `xxxx` is memory address, `y` is type, `z` is length and `vv` is value. for example, one cheat code of Super Mario Bros is `079F-01-01`: 8 | 9 | ::: code-group 10 | ```vue [vue-js] 11 | 32 | 33 | 42 | ``` 43 | 44 | ```vue [vue-ts] 45 | 68 | 69 | 78 | ``` 79 | ::: -------------------------------------------------------------------------------- /docs/guide/controller.md: -------------------------------------------------------------------------------- 1 | # Controller 2 | 3 | `p1` and `p2` properties can customize controller's corresponding keyboard key values as follows: 4 | 5 | ```js 6 | p1 = { 7 | UP: 'KeyW', 8 | DOWN: 'KeyS', 9 | LEFT: 'KeyA', 10 | RIGHT: 'KeyD', 11 | A: 'KeyK', 12 | B: 'KeyJ', 13 | C: 'KeyI', 14 | D: 'KeyU', 15 | SELECT: 'Digit2', 16 | START: 'Digit1' 17 | } 18 | p2 = { 19 | UP: 'ArrowUp', 20 | DOWN: 'ArrowDown', 21 | LEFT: 'ArrowLeft', 22 | RIGHT: 'ArrowRight', 23 | A: 'Numpad2', 24 | B: 'Numpad1', 25 | C: 'Numpad5', 26 | D: 'Numpad4' 27 | } 28 | ``` 29 | 30 | The values are [KeyboardEvent.code](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code), Each field is optional, and if a field is missing, the default value will be used. 31 | 32 | ## Control the game in other ways 33 | 34 | Strongly recommended to use [v-gamepad](/zh/guide/directives#v-gamepad) directive, which can bind any HTML element to game controller. 35 | 36 | Otherwise, you can manually trigger `document`'s `keydown` and `keyup` events to control the game, but it is recommended to do this when `v-gamepad` cannot satisfy your needs, here is an example: 37 | 38 | ```vue 39 | 49 | 50 | 56 | ``` 57 | 58 | Touching the `button` element will triggers the game controller's `RIGHT` button. 59 | 60 | Using `v-gamepad` directive can achieve the same effect, and the performance is relatively better: 61 | 62 | ```vue 63 | 66 | 67 | 73 | ``` 74 | 75 | Detailed usage please refer to [v-gamepad](/guide/directives#v-gamepad). 76 | 77 | ## Gamepad 78 | 79 | The component has built-in support for the gamepad, which does not require additional configuration. 80 | -------------------------------------------------------------------------------- /docs/guide/directives.md: -------------------------------------------------------------------------------- 1 | # Directives 2 | 3 | ## v-gamepad 4 | 5 | `nes-vue` provides a `v-gamepad` directive that binds HTML elements to game controllers. For example, click on the `button` element to trigger `B`. 6 | 7 | ```vue 8 | 11 | 12 | 18 | ``` 19 | 20 | Attribute value can be `'UP'`、`'DOWN'`、`'LEFT'`、`'RIGHT'`、`'A'`、`'B'`、`'C'`、`'D'`、`'SELECT'` or `'START'`. **Note that there must be quotation marks when using literal values.** 21 | 22 | You can also bind multiple buttons on the same element. A typical example is to bind `A` and `B` buttons on the `button` element: 23 | 24 | ```vue 25 | 28 | 29 | 35 | ``` 36 | 37 | ### Arguments and modifiers 38 | 39 | By default, `v-gamepad` is bound to the mouse event (`mousedown` and `mouseup`), which controls player 1. 40 | 41 | To bind the touch event, you can add the `touch` modifier (default is `mouse`): 42 | 43 | ```vue 44 | 47 | 48 | 54 | ``` 55 | 56 | To bind the player 2, you can add the `p2` modifier (default is `p1`): 57 | 58 | ```vue 59 | 62 | 63 | 69 | ``` 70 | -------------------------------------------------------------------------------- /docs/guide/events.md: -------------------------------------------------------------------------------- 1 | # events 2 | 3 | ### @fps 4 | 5 | ```ts 6 | @fps -> function(fps: number) 7 | ``` 8 | 9 | Emitted per second while the game is running. 10 | 11 | ```vue 12 | 17 | 18 | 24 | ``` 25 | 26 | ### @success 27 | 28 | ```ts 29 | @success -> function() 30 | ``` 31 | 32 | Emitted when the ROM is loaded successfully. 33 | 34 | ### @saved 35 | 36 | ```ts 37 | @saved -> function({id, message, target}) 38 | ``` 39 | 40 | Emitted when the state has been saved. 41 | 42 | ### @loaded 43 | 44 | ```ts 45 | @loaded -> function({id, message, target}) 46 | ``` 47 | 48 | Emitted when the state has been loaded. 49 | 50 | ### @removed 51 | 52 | ```ts 53 | @removed -> function(id) 54 | ``` 55 | 56 | Emitted when the saved state has been removed. 57 | 58 | ### @error 59 | 60 | ```ts 61 | @error -> funciont({code, message}) 62 | ``` 63 | 64 | Emitted when error occurs, the code is a`number`: 65 | 66 | * 0:ROM load error. 67 | * 1:Save state error. 68 | * 2:Load state error. 69 | * 3:Play fm2 video error. -------------------------------------------------------------------------------- /docs/guide/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ## Installation 4 | 5 | ```shell 6 | npm i nes-vue 7 | ``` 8 | ## Import Component 9 | 10 | ```vue 11 | 14 | 21 | ``` 22 | 23 | ## Playground 24 | 25 | [Playground](https://taiyuuki.github.io/nes-vue) -------------------------------------------------------------------------------- /docs/guide/methods.md: -------------------------------------------------------------------------------- 1 | # Methods 2 | 3 | All methods are mounted on component instances. If you use TypeScript, you can obtain the `NesVueInstance` from `nes-vue`. 4 | 5 | ```vue 6 | 13 | 14 | 20 | ``` 21 | 22 | ## start 23 | 24 | Start NES game. 25 | 26 | ```ts 27 | start(url?: string) => void 28 | ``` 29 | 30 | 31 | ::: tip 32 | In most cases, clicking on the text ([label](/guide/props#label)) in the middle of the screen will start the game without the `start` method. 33 | ::: 34 | 35 | ::: warning 36 | If you have to switch the game via the `start` method, you must use the **v-model** directive to bind the url, so that nes-vue can update it. 37 | ::: 38 | 39 | ## reset 40 | 41 | Restart the game. 42 | 43 | ```ts 44 | reset() => void 45 | ``` 46 | 47 | ## stop 48 | 49 | Stop the game. 50 | 51 | ```ts 52 | stop() => void 53 | ``` 54 | 55 | ## pause 56 | 57 | The game is paused and can be resumed. 58 | 59 | ```ts 60 | pause() => void 61 | ``` 62 | 63 | ## play 64 | 65 | Resume the game. 66 | 67 | ```ts 68 | play() => void 69 | ``` 70 | 71 | ## save 72 | 73 | Save game state. 74 | 75 | ::: warning 76 | Can only be loaded while the game is running, and ensure that the running game is consistent with the saved game. 77 | ::: 78 | 79 | ```ts 80 | save(id: string) => void 81 | ``` 82 | 83 | By default, the game state is saved in indexedDB, you can also save it in localStorage via [storage](/guide/props#storage) property. 84 | 85 | Each game state archive requires about 200KB, if you need to save more data, it’s recommended to use indexedDB. 86 | 87 | ## load 88 | 89 | Load game state. 90 | 91 | ```ts 92 | load(id: string) => void 93 | ``` 94 | 95 | example: 96 | 97 | ::: code-group 98 | ```vue [vue-js] 99 | 116 | 117 | 127 | ``` 128 | ```vue [vue-ts] 129 | 148 | 149 | 159 | ``` 160 | ::: 161 | 162 | ## remove 163 | 164 | Remove game state. 165 | 166 | ```ts 167 | remove(id: string) => void 168 | ``` 169 | 170 | ## clear 171 | 172 | Clear all saved state. 173 | 174 | ```ts 175 | clear() => void 176 | ``` 177 | 178 | ## screenshot 179 | 180 | ```ts 181 | screenshot(download?: boolean, imageName?: string) => HTMLImageElement 182 | ``` 183 | 184 | `screenshot(true)` will start downloading the screenshot inside the browser. 185 | 186 | The return value is an image element of the screenshot. 187 | 188 | ## fm2URL 189 | 190 | ```ts 191 | fm2URL(url: string) => Promise 192 | ``` 193 | 194 | Fetch the `*.fm2` file, refer to [Replay](/guide/replay). 195 | 196 | ## fm2Text 197 | 198 | ```ts 199 | fm2Text(text: string) => Promise 200 | ``` 201 | 202 | Read the `*.fm2` file's text content, refer to [Replay](/guide/replay). 203 | 204 | ## fm2Play 205 | 206 | ```ts 207 | fm2Play() => void 208 | ``` 209 | 210 | Play fm2 video, refer to [Replay](/guide/replay). 211 | 212 | ## fm2Stop 213 | 214 | ```ts 215 | fm2Stop() => void 216 | ``` 217 | 218 | Stop fm2 video, refer to [Replay](/guide/replay). 219 | 220 | ## cheatCode 221 | 222 | ```ts 223 | cheatCode(code: string) => void 224 | ``` 225 | 226 | Enable cheat code, refer to [Cheat Code](/guide/cheat). 227 | 228 | ## cancelCheatCode 229 | 230 | ```ts 231 | cancelCheatCode(code: string) => void 232 | ``` 233 | 234 | Disable cheat code, refer to [Cheat Code](/guide/cheat). 235 | 236 | ## cancelCheatCodeAll 237 | 238 | ```ts 239 | cancelCheatCodeAll() => void 240 | ``` 241 | 242 | Disable all cheat code, refer to [Cheat Code](/guide/cheat). -------------------------------------------------------------------------------- /docs/guide/props.md: -------------------------------------------------------------------------------- 1 | # Props 2 | 3 | Except for the `url`, all other properties are optional. 4 | 5 | ```vue 6 | 11 | ``` 12 | 13 | ## All properties 14 | 15 | ### url 16 | 17 | * Type `string` 18 | 19 | URL of the nes ROM. **Required!** 20 | 21 | If you want to switch to another game, just bind the `url` with a reactive value, and change it: 22 | 23 | ```vue 24 | 33 | 34 | 38 | ``` 39 | 40 | ### width 41 | 42 | * Type `string | number` 43 | * Default `256` 44 | 45 | Game screen width, units can be included, default is px. 46 | 47 | ### height 48 | 49 | * Type `string | number` 50 | * Default `240` 51 | 52 | Game screen height, units can be included, default is px. 53 | 54 | ::: tip 55 | Maintain a ratio of 256×240 for width and height. 56 | ::: 57 | 58 | ```vue 59 | 66 | ``` 67 | 68 | ### label 69 | 70 | * Type `string` 71 | * Default `"Game Start"` 72 | 73 | Text of the game screen,click on it to start the game。 74 | 75 | ### gain 76 | 77 | * Type `number` 78 | * Default `100` 79 | 80 | The game volume between [0, 100]. 81 | 82 | ### no-clip 83 | 84 | * Type `boolean` 85 | * Default `false` 86 | 87 | Background clipping, false=BG invisible in left 8-pixel column, true=No clipping. 88 | 89 | Clip mode can solve the problem that the edges of some game BG are not fully displayed, and the background will not have black edges, but it will also cause the edge materials of most game BG to flicker. Please use it as appropriate. 90 | 91 | ### auto-start 92 | 93 | * Type `boolean` 94 | * Default `false` 95 | 96 | Auto start when the component on mounted. 97 | 98 | :::warning 99 | nes-vue uses the AudioContext API for audio playback, and due to the browser's security policy, the game will only run after user interaction (such as mouse clicks), so use this property with caution. 100 | If you want to use this property, place nes-vue in a component that will not load until user's clicked. 101 | ::: 102 | 103 | ### turbo 104 | 105 | * Type `number` 106 | * Default `16` 107 | 108 | Mashing speed per second, between [5, 20]. 109 | 110 | ### storage 111 | 112 | * Type `boolean` 113 | * Default `false` 114 | 115 | Use `localStorage` to save the game state, default is indexedDB, see [Methods - save](/guide/methods#save). 116 | 117 | ### db-name 118 | 119 | * Type `string` 120 | * Default `"nes-vue"` 121 | 122 | The name of the object store for indexedDB. 123 | 124 | ### p1 125 | 126 | * Type `object` 127 | * Default see[Controller](/guide/controller) 128 | 129 | Player 1 controller. 130 | 131 | ### p2 132 | 133 | * Type `object` 134 | * Default see[Controller](/guide/controller) 135 | 136 | Player 2 controller. 137 | 138 | ### debugger 139 | 140 | * Type boolean 141 | * Default false 142 | 143 | The error message is output in the console. 144 | -------------------------------------------------------------------------------- /docs/guide/replay.md: -------------------------------------------------------------------------------- 1 | # Replay recording video 2 | 3 | Starting from v1.5.0, the function of playing TAS videos (`*.fm2` files) has been added, which can be downloaded from [TASVideos](https://tasvideos.org/) . 4 | 5 | There are two ways to play the `*.fm2` here. 6 | 7 | ## Fetch fm2 file 8 | 9 | The first is to fetch `*.fm2` files through a URL. 10 | 11 | ::: code-group 12 | ```vue [vue-js] 13 | 27 | 28 | 37 | ``` 38 | 39 | ```vue [vue-ts] 40 | 56 | 57 | 66 | ``` 67 | ::: 68 | 69 | ## Read the plain text string 70 | 71 | The second is to directly read the plain text string of the `*.fm2` file. 72 | 73 | ::: code-group 74 | ```vue [vue-js] 75 | 87 | 88 | 97 | ``` 98 | 99 | ```vue [vue-ts] 100 | 114 | 115 | 124 | ``` 125 | 126 | ::: 127 | 128 | ## Note 129 | 130 | There are several points to note: 131 | 132 | * Please ensure that the game version used in the `*.fm2` is completely consistent with the game ROM version, including Japanese, American, European, modified, and translated versions. 133 | 134 | * Due to differences in the start frame, some game recording videos may require manual adjustment. A second parameter is provided to fine-tune the frame count. 135 | 136 | ```ts 137 | nes.value.fm2Play(-1) // 1 frame in advance 138 | nes.value.fm2Play(2) // Delay by 2 frames 139 | ``` 140 | 141 | The specific number of frames that need to be adjusted can only be tested by yourself. 142 | 143 | * Even with identical game versions, the start frame is completely aligned, and as the game progresses, errors may occur due to differences in the implementation of the emulator. In this case, manual adjustment of the `*.fm2` file is the only way to correct it. 144 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: "NES Vue" 7 | text: "A NES emulator for Vue 3" 8 | image: 9 | src: /nes-vue.svg 10 | alt: NESVue 11 | actions: 12 | - theme: alt 13 | text: Playground 14 | link: https://taiyuuki.github.io/nes-vue 15 | - theme: brand 16 | text: Getting Started 17 | link: /guide/getting-started 18 | 19 | features: 20 | - title: 💡Easy To Use 21 | details: Simply import and use it. 22 | - title: 💾Save 23 | details: Support saving and loading. 24 | - title: 📺TAS 25 | details: Supports playing TAS videos. 26 | - title: 🎮Gamepad 27 | details: Support gamepad. 28 | - title: 🔧Cheat Code 29 | details: Support cheat code. 30 | - title: 👯Multiplayer 31 | details: Support for dual players。 32 | --- -------------------------------------------------------------------------------- /docs/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taiyuuki/nes-vue/2ecceb265b47867bb83fee28a430ff3ccc0b0312/docs/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /docs/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taiyuuki/nes-vue/2ecceb265b47867bb83fee28a430ff3ccc0b0312/docs/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /docs/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taiyuuki/nes-vue/2ecceb265b47867bb83fee28a430ff3ccc0b0312/docs/public/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taiyuuki/nes-vue/2ecceb265b47867bb83fee28a430ff3ccc0b0312/docs/public/favicon-16x16.png -------------------------------------------------------------------------------- /docs/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taiyuuki/nes-vue/2ecceb265b47867bb83fee28a430ff3ccc0b0312/docs/public/favicon-32x32.png -------------------------------------------------------------------------------- /docs/public/nes-vue.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /docs/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "NES Vue", 3 | "short_name": "NES Vue", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } -------------------------------------------------------------------------------- /docs/zh/guide/cheat.md: -------------------------------------------------------------------------------- 1 | # 金手指 2 | 3 | v1.8.0开始,nes-vue支持金手指功能。 4 | 5 | ## 使用 6 | 7 | 金手指格式为`xxxx-yz-vv`,例如`079F-01-01`: 8 | 9 | ::: code-group 10 | ```vue [vue-js] 11 | 32 | 33 | 42 | ``` 43 | 44 | ```vue [vue-ts] 45 | 68 | 69 | 78 | ``` 79 | ::: 80 | 81 | ## 金手指格式说明 82 | 83 | nes-vue采用的是兼容`VirtuaNES`的金手指格式,例如`079F-01-01`,其中`079F`表示内存地址,中间`01`的`0`表示修改类型,中间`01`的`1`表示数值长度,右侧的`01`表示数值。 84 | 85 | 修改类型取值范围是:0-3,其中`0`表示始终修改,`1`表示只修改一次,`2`表示保证值不大于,`3`表示保证值不小于。 86 | 87 | 更详细的信息请自行查阅`VirtuaNES`的金手指相关内容。 -------------------------------------------------------------------------------- /docs/zh/guide/controller.md: -------------------------------------------------------------------------------- 1 | # 控制器 2 | 3 | [`p1`](/zh/guide/props#p1)和[`p2`](/zh/guide/props#p2)属性可以自定义控制器对应的键盘按键,默认值如下: 4 | 5 | ```js 6 | p1 = { 7 | UP: 'KeyW', 8 | DOWN: 'KeyS', 9 | LEFT: 'KeyA', 10 | RIGHT: 'KeyD', 11 | A: 'KeyK', 12 | B: 'KeyJ', 13 | C: 'KeyI', 14 | D: 'KeyU', 15 | SELECT: 'Digit2', 16 | START: 'Digit1' 17 | } 18 | p2 = { 19 | UP: 'ArrowUp', 20 | DOWN: 'ArrowDown', 21 | LEFT: 'ArrowLeft', 22 | RIGHT: 'ArrowRight', 23 | A: 'Numpad2', 24 | B: 'Numpad1', 25 | C: 'Numpad5', 26 | D: 'Numpad4' 27 | } 28 | ``` 29 | 30 | 属性值是 [KeyboardEvent.code](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code),每个字段都是可选的,如果缺省会使用默认值。 31 | 32 | ## 用其他方式操作游戏 33 | 34 | 强烈推荐使用 [v-gamepad](/zh/guide/directives#v-gamepad) 指令,它可以给任意HTML元素绑定游戏控制器。 35 | 36 | 除此之外,你还可以手动触发document上的`keydown`和`keyup`事件来操作游戏,但建议只在`v-gamepad`无法满足你的需求时这么做,这里提供一个示例: 37 | 38 | ```vue 39 | 49 | 50 | 56 | ``` 57 | 58 | 触摸`button`元素即可触发游戏控制器的`RIGHT`按钮。 59 | 60 | 使用`v-gamepad`指令可以达到和上面同样的效果,且性能相对会更好: 61 | 62 | ```vue 63 | 66 | 67 | 73 | ``` 74 | 75 | 详细用法请查看[v-gamepad](/zh/guide/directives#v-gamepad)。 76 | 77 | ## 手柄 78 | 79 | 组件内置了对手柄的支持,无需额外配置,即插即用。 -------------------------------------------------------------------------------- /docs/zh/guide/directives.md: -------------------------------------------------------------------------------- 1 | # 指令 2 | 3 | ## v-gamepad 4 | 5 | `nes-vue` 提供了 `v-gamepad` 指令,可以给HTML元素绑定游戏控制器。例如: 6 | 7 | ```vue 8 | 11 | 12 | 18 | ``` 19 | 20 | 这样,点击`button`元素即可触发游戏控制器的`B`按钮。 21 | 22 | 属性值可以是`'UP'`、`'DOWN'`、`'LEFT'`、`'RIGHT'`、`'A'`、`'B'`、`'C'`、`'D'`、`'SELECT'`或`'START'`。**注意,如果使用的是字面值,必须添加引号**。 23 | 24 | 你也可以在同一个元素上绑定多个按键,典型的例子就是在`button`元素上绑定`A`和`B`按钮: 25 | 26 | ```vue 27 | 30 | 31 | 37 | ``` 38 | 39 | ### 参数和修饰符 40 | 41 | 默认情况下,`v-gamepad` 绑定的是鼠标点击事件(`mousedown` 和 `mouseup`),控制玩家P1。 42 | 43 | 如果要绑定移动端的触摸事件,需要添加`touch`参数(默认是`mouse`): 44 | 45 | ```vue 46 | 49 | 50 | 56 | ``` 57 | 58 | 如果要控制P2,需要添加`p2`修饰符(默认是`p1`): 59 | 60 | ```vue 61 | 64 | 65 | 71 | ``` -------------------------------------------------------------------------------- /docs/zh/guide/events.md: -------------------------------------------------------------------------------- 1 | # 事件 2 | 3 | ### @fps 4 | 5 | ```ts 6 | @fps -> function(fps: number) 7 | ``` 8 | 9 | 获取游戏FPS,每秒触发一次。 10 | 11 | ```vue 12 | 17 | 18 | 24 | ``` 25 | 26 | ### @success 27 | 28 | ```ts 29 | @success -> function() 30 | ``` 31 | 32 | ROM加载成功时触发。 33 | 34 | ### @saved 35 | 36 | ```ts 37 | @saved -> function({id, message, target}) 38 | ``` 39 | 40 | 存档后触发。 41 | 42 | ### @loaded 43 | 44 | ```ts 45 | @loaded -> function({id, message, target}) 46 | ``` 47 | 48 | 读档后触发。 49 | 50 | ### @removed 51 | 52 | ```ts 53 | @removed -> function(id) 54 | ``` 55 | 56 | 删除存档后触发。 57 | 58 | ### @error 59 | 60 | ```ts 61 | @error -> funciont({code, message}) 62 | ``` 63 | 64 | 发生错误时触发,其中code是一个`number`: 65 | 66 | * 0:表示ROM读取错误 67 | * 1:表示存档错误 68 | * 2:表示读档错误 69 | * 3:表示播放录像错误 -------------------------------------------------------------------------------- /docs/zh/guide/getting-started.md: -------------------------------------------------------------------------------- 1 | # 开始使用 2 | 3 | ## 安装 4 | 5 | ```shell 6 | npm i nes-vue 7 | ``` 8 | ## 引入组件 9 | 10 | ```vue 11 | 14 | 22 | ``` 23 | 24 | ## 游乐场 25 | 26 | 在线体验:[Playground](https://taiyuuki.github.io/nes-vue) -------------------------------------------------------------------------------- /docs/zh/guide/methods.md: -------------------------------------------------------------------------------- 1 | # 方法 2 | 3 | 所有方法都挂载于组件实例上,如果你使用TS,可以通过`nes-vue`提供的`NesVueInstance`获取实例类型。 4 | 5 | ```vue 6 | 13 | 14 | 20 | ``` 21 | 22 | ## start 23 | 24 | 开始游戏 25 | 26 | ```ts 27 | start(url?: string) => void 28 | ``` 29 | 30 | `start`的主要作用是让停止的游戏开始运行,一般**不需要url参数** 。 31 | 32 | 如果携带参数,且参数与当前游戏的URL不一致,就会切换游戏,但强烈建议不要这么做,切换游戏建议用[props](/zh/guide/props#url),重启游戏建议用[reset](#reset) 33 | 34 | ::: tip 35 | 在大多数情况下,点击屏幕中间的文字(也就是[label](/zh/guide/props#label))即可开始游戏,不需要用`start`这个方法。 36 | ::: 37 | 38 | ::: warning 39 | 如果你一定要用`start`来切换游戏, 那就必须使用 **v-model** 指令绑定url属性,这样在切换游戏后,nes-vue组件才会更新url的值。 40 | ::: 41 | 42 | ## reset 43 | 44 | 重启游戏。 45 | 46 | ```ts 47 | reset() => void 48 | ``` 49 | 50 | ## stop 51 | 52 | 停止游戏。 53 | 54 | ```ts 55 | stop() => void 56 | ``` 57 | 58 | ## pause 59 | 60 | 游戏暂停,可恢复。 61 | 62 | ```ts 63 | pause() => void 64 | ``` 65 | 66 | ## play 67 | 68 | 恢复游戏。 69 | 70 | ```ts 71 | play() => void 72 | ``` 73 | 74 | ## save 75 | 76 | 存档 77 | 78 | ::: warning 79 | 只有在游戏运行时才能存档、读档,读档还需要保证存档与游戏的一致性。 80 | ::: 81 | 82 | ```ts 83 | save(id: string) => void 84 | ``` 85 | 86 | 默认情况下,存档是保存在 indexedDB,你可以设置[storage](/zh/guide/props#storage)属性让其保存在localStorage。 87 | 88 | 每个存档大约200kB,如果需要保存较多的数据,建议使用默认的 indexedDB。 89 | 90 | ## load 91 | 92 | 读档 93 | 94 | ```ts 95 | load(id: string) => void 96 | ``` 97 | 98 | 存档、读档示例: 99 | 100 | ::: code-group 101 | ```vue [vue-js] 102 | 119 | 120 | 130 | ``` 131 | ```vue [vue-ts] 132 | 151 | 152 | 162 | ``` 163 | ::: 164 | 165 | ## remove 166 | 167 | 删除存档。 168 | 169 | ```ts 170 | remove(id: string) => void 171 | ``` 172 | 173 | ## clear 174 | 175 | 清空所有存档。 176 | 177 | ```ts 178 | clear() => void 179 | ``` 180 | 181 | ## screenshot 182 | 183 | 截图 184 | 185 | ```ts 186 | screenshot(download?: boolean, imageName?: string) => HTMLImageElement 187 | ``` 188 | 189 | 调用`screenshot(true)` 会在浏览器中开始下载游戏截图。 190 | 191 | 返回值是截图的image元素。 192 | 193 | ## fm2URL 194 | 195 | ```ts 196 | fm2URL(url: string) => Promise 197 | ``` 198 | 199 | 读取`*.fm2`录像文件,见[播放录像](/zh/guide/replay)。 200 | 201 | ## fm2Text 202 | 203 | ```ts 204 | fm2Text(text: string) => Promise 205 | ``` 206 | 207 | 读取`*.fm2`录像文件文本,见[播放录像](/zh/guide/replay)。 208 | 209 | ## fm2Play 210 | 211 | ```ts 212 | fm2Play() => void 213 | ``` 214 | 215 | 开始播放录像,见[播放录像](/zh/guide/replay)。 216 | 217 | ## fm2Stop 218 | 219 | ```ts 220 | fm2Stop() => void 221 | ``` 222 | 223 | 停止播放录像,见[播放录像](/zh/guide/replay)。 224 | 225 | ## cheatCode 226 | 227 | ```ts 228 | cheatCode(code: string) => void 229 | ``` 230 | 231 | 添加金手指。见[金手指](/zh/guide/cheat)。 232 | 233 | ## cancelCheatCode 234 | 235 | ```ts 236 | cancelCheatCode(code: string) => void 237 | ``` 238 | 239 | 取消添加的金手指。见[金手指](/zh/guide/cheat)。 240 | 241 | ## cancelCheatCodeAll 242 | 243 | ```ts 244 | cancelCheatCodeAll() => void 245 | ``` 246 | 247 | 取消所有添加的金手指。见[金手指](/zh/guide/cheat)。 -------------------------------------------------------------------------------- /docs/zh/guide/props.md: -------------------------------------------------------------------------------- 1 | # 属性 2 | 3 | 除`url`外,其他属性都是可选属性。 4 | 5 | ```vue 6 | 11 | ``` 12 | 13 | ## 全部属性 14 | 15 | ### url 16 | 17 | * Type `string` 18 | 19 | NES游戏的ROM地址,**必须!** 20 | 21 | 如果要切换游戏,只需用响应式数据绑定url,然后修改url的值即可: 22 | 23 | ```vue 24 | 33 | 34 | 38 | ``` 39 | 40 | ### width 41 | 42 | * Type `string | number` 43 | * 默认值 `256` 44 | 45 | 游戏画面宽度,可以有单位,默认是px。 46 | 47 | ### height 48 | 49 | * Type `string | number` 50 | * 默认值 `240` 51 | 52 | 游戏画面高度,可以有单位,默认是px。 53 | 54 | ::: tip 55 | 注意保持width和height的比例为256×240。 56 | ::: 57 | 58 | ```vue 59 | 66 | ``` 67 | 68 | ### label 69 | 70 | * Type `string` 71 | * 默认值 `"Game Start"` 72 | 73 | 游戏运行前画面上的显示文字,点击文字开始游戏。 74 | 75 | ### gain 76 | 77 | * Type `number` 78 | * 默认值 `100` 79 | 80 | 游戏音量,介于[0, 100]之间。 81 | 82 | ### no-clip 83 | 84 | * Type `boolean` 85 | * 默认值 `false` 86 | 87 | 是否剪切画面边缘,false=游戏画面的边缘将剪切8像素,true=不剪切。 88 | 89 | 设置此属性可以解决部分游戏画面边缘显示不全的问题,且画面紧凑没有黑边,但也会造成很多游戏画面边缘材质闪烁,请酌情使用。 90 | 91 | ### auto-start 92 | 93 | * Type `boolean` 94 | * 默认值 `false` 95 | 96 | 组件挂载后自动开始游戏。 97 | 98 | :::warning 99 | nes-vue使用AudioContext API实现音频播放,由于浏览器的安全策略,游戏只会在用户发生交互(例如鼠标点击)后才会运行,所以请谨慎使用这个属性。 100 | 如果要使用这个属性,请将nes-vue置于用户点击后才会加载的组件中。 101 | ::: 102 | 103 | ### turbo 104 | 105 | * Type `number` 106 | * 默认值 `16` 107 | 108 | 连发键每秒频率 介于[5, 25]之间。 109 | 110 | ### storage 111 | 112 | * Type `boolean` 113 | * 默认值 `false` 114 | 115 | 设置此属性,游戏存档会使用localStorage保存,默认是indexedDB, 详情见[方法 - save](/zh/guide/methods#save)。 116 | 117 | ### db-name 118 | 119 | * Type `string` 120 | * 默认值 `"nes-vue"` 121 | 122 | indexedDB数据库名称 123 | 124 | ### p1 125 | 126 | * Type `object` 127 | * 默认值 见[控制器](/zh/guide/controller) 128 | 129 | 玩家1控制器 130 | 131 | ### p2 132 | 133 | * Type `object` 134 | * 默认值 见[控制器](/zh/guide/controller) 135 | 136 | 玩家2控制器 137 | 138 | ### debugger 139 | 140 | * Type boolean 141 | * 默认值 false 142 | 143 | 错误信息输出到控制台。 144 | 145 | -------------------------------------------------------------------------------- /docs/zh/guide/replay.md: -------------------------------------------------------------------------------- 1 | # 播放录像 2 | 3 | 从 v1.5.0 开始,新增播放 `*.fm2` 录像文件的功能,录像文件可以在 [TASVideos](https://tasvideos.org/) 下载。 4 | 5 | 这里提供两种方式来播放 `*.fm2` 文件。 6 | 7 | ## 请求fm2文件 8 | 9 | 第一种:通过URL读取*.fm2文件 10 | 11 | ::: code-group 12 | ```vue [vue-js] 13 | 27 | 28 | 37 | ``` 38 | 39 | ```vue [vue-ts] 40 | 56 | 57 | 66 | ``` 67 | 68 | ::: 69 | 70 | ## 读取纯文本 71 | 72 | 第二种:直接读取 `*.fm2` 文件的纯文本形式的字符串 73 | 74 | ::: code-group 75 | ```vue [vue-js] 76 | 89 | 90 | 99 | ``` 100 | 101 | ```vue [vue-ts] 102 | 117 | 118 | 127 | ``` 128 | ::: 129 | 130 | ## 注意事项 131 | 132 | 关于录像播放,这里有几点需要注意: 133 | 134 | * 请确保录像使用的游戏版本与游戏ROM的版本完全一致,日版、美版、欧版、修改版、翻译版,不能混同。 135 | 136 | * 不同的游戏录像由于开始帧的位置差异,可能需要手动调整,这里提供了第二个参数来微调帧数。 137 | 138 | ```ts 139 | nes.value.fm2Play(-1) // 提前1帧 140 | nes.value.fm2Play(2) // 延迟2帧 141 | ``` 142 | 143 | 具体需要调整多少,只能靠自己测试。 144 | 145 | * 即便完全相同的游戏版本,开始帧也完全对齐,随着游戏的进行,也可能会出现差错,这是模拟器的实现差异造成的,在这种情况下,只能靠手动调整 `*.fm2` 文件来修正,没有其他办法。 146 | -------------------------------------------------------------------------------- /docs/zh/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: "NES Vue" 7 | text: "用于Vue 3的NES模拟器组件" 8 | image: 9 | src: /nes-vue.svg 10 | alt: NESVue 11 | actions: 12 | - theme: alt 13 | text: 在线游乐场 14 | link: https://taiyuuki.github.io/nes-vue 15 | - theme: brand 16 | text: 开始使用 17 | link: /zh/guide/getting-started 18 | 19 | features: 20 | - title: 💡易用 21 | details: 简单引入就可以使用。 22 | - title: 💾存档 23 | details: 支持存档、读档。 24 | - title: 📺TAS 25 | details: 支持播放TAS录像。 26 | - title: 🎮手柄 27 | details: 支持手柄。 28 | - title: 🔧金手指 29 | details: 支持金手指。 30 | - title: 👯双人 31 | details: 支持双人玩家。 32 | --- 33 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import tyk_eslint from '@taiyuuki/eslint-config' 2 | 3 | export default tyk_eslint({ 4 | ts: true, 5 | vue: true, 6 | ignores: [ 7 | '**/docs', 8 | '**/playground', 9 | ], 10 | rules: { '@typescript-eslint/no-explicit-any': 'off' }, 11 | }) 12 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | nes-vue demo 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nes-vue", 3 | "version": "1.9.0", 4 | "description": "A NES(FC) emulator component for Vue 3", 5 | "module": "./dist/nes-vue.es.js", 6 | "main": "./dist/nes-vue.umd.js", 7 | "type": "module", 8 | "license": "MIT", 9 | "files": [ 10 | "dist" 11 | ], 12 | "exports": { 13 | ".": { 14 | "import": "./dist/nes-vue.es.js", 15 | "types": "./dist/index.d.ts" 16 | } 17 | }, 18 | "keywords": [ 19 | "fc", 20 | "nes", 21 | "jsnes", 22 | "vue", 23 | "nes emulator", 24 | "fc emulator" 25 | ], 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/taiyuuki/nes-vue.git" 29 | }, 30 | "pnpm": { 31 | "peerDependencyRules": { 32 | "ignoreMissing": [ 33 | "@algolia/client-search", 34 | "search-insights" 35 | ] 36 | } 37 | }, 38 | "scripts": { 39 | "lint": "eslint --ext .js,.ts,.vue ./ --fix", 40 | "dev": "vite", 41 | "build": "vue-tsc --noEmit && vite build", 42 | "test": "vitest", 43 | "patch": "bump patch", 44 | "minor": "bump minor", 45 | "publish": "pnpm publish --registry https://registry.npmjs.org", 46 | "docs:dev": "vitepress dev docs", 47 | "docs:build": "vitepress build docs", 48 | "docs:build-gitee": "set GITEE=1 && pnpx vitepress build docs", 49 | "docs:preview": "vitepress preview docs" 50 | }, 51 | "dependencies": { 52 | "@nesjs/core": "^1.0.9" 53 | }, 54 | "devDependencies": { 55 | "@taiyuuki/eslint-config": "^1.4.18", 56 | "@taiyuuki/utils": "^0.5.3", 57 | "@types/node": "^22.14.1", 58 | "@vitejs/plugin-vue": "^5.2.3", 59 | "eslint": "^9.24.0", 60 | "nes-vue": "link:./dist/nes-vue.e.js", 61 | "rollup-plugin-delete": "^3.0.1", 62 | "typescript": "^5.8.3", 63 | "vite": "^6.2.6", 64 | "vite-plugin-dts": "^4.5.3", 65 | "vitepress": "1.0.0-beta.1", 66 | "vitest": "^3.1.1", 67 | "vue": "^3.5.13", 68 | "vue-tsc": "^2.2.8" 69 | } 70 | } -------------------------------------------------------------------------------- /playground/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /playground/.eslintignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules 3 | /src-ssr -------------------------------------------------------------------------------- /playground/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "@taiyuuki/eslint-config-vue-unimport", 4 | 5 | "rules": { 6 | "import/no-unresolved": 7 | ["error", { 8 | "ignore": [ 9 | "~pages", 10 | "virtual:generated-layouts", 11 | "virtual:generated-pages", 12 | "virtual:vue-component-preview", 13 | "" 14 | ] 15 | }] 16 | } 17 | 18 | } -------------------------------------------------------------------------------- /playground/LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 taiyuuki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sub license, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /playground/README.md: -------------------------------------------------------------------------------- 1 | ### NesVue Playground 2 | 3 | ### Install 4 | 5 | ```shell 6 | pnpm install 7 | ``` 8 | 9 | ### Run 10 | 11 | ```shell 12 | pnpm dev 13 | ``` 14 | 15 | -------------------------------------------------------------------------------- /playground/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | NesVue Playground 14 | 15 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nes-vue-playground", 3 | "version": "0.0.1", 4 | "description": "nes-vue playground", 5 | "author": "taiyuuki ", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/taiyuuki/nes-vue" 9 | }, 10 | "bugs": "https://github.com/taiyuuki/nes-vue/issues", 11 | "homepage": "https://github.com/taiyuuki/nes-vue#readme", 12 | "private": true, 13 | "scripts": { 14 | "lint": "eslint --ext .js,.ts,.vue ./ --fix", 15 | "dev": "vite", 16 | "build": "vue-tsc && vite build", 17 | "preview": "vite preview" 18 | }, 19 | "dependencies": { 20 | "@taiyuuki/utils": "^0.5.3", 21 | "@vue/repl": "^4.5.1", 22 | "jszip": "^3.10.1", 23 | "pinia": "^3.0.2", 24 | "pinia-plugin-persistedstate": "^4.2.0", 25 | "vue": "3.5.13", 26 | "vue-router": "^4.5.0" 27 | }, 28 | "devDependencies": { 29 | "@taiyuuki/eslint-config-vue-unimport": "0.0.5", 30 | "@types/node": "^22.14.1", 31 | "@vitejs/plugin-vue": "^5.2.3", 32 | "autoprefixer": "^10.4.21", 33 | "eslint": "^9.24.0", 34 | "eslint-plugin-import": "^2.31.0", 35 | "postcss": "^8.5.3", 36 | "postcss-html": "^1.8.0", 37 | "sass": "^1.86.3", 38 | "typescript": "^5.8.3", 39 | "unplugin-auto-import": "^19.1.2", 40 | "unplugin-vue-components": "^28.5.0", 41 | "vite": "^6.2.6", 42 | "vite-plugin-pages": "^0.33.0", 43 | "vite-plugin-vue-layouts": "^0.11.0", 44 | "vue-tsc": "^2.2.8" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /playground/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('autoprefixer')({ 4 | overrideBrowserslist: [ 5 | 'last 4 Chrome versions', 6 | 'last 4 Firefox versions', 7 | 'last 4 Edge versions', 8 | 'last 4 Safari versions', 9 | 'last 4 Android versions', 10 | 'last 4 ChromeAndroid versions', 11 | 'last 4 FirefoxAndroid versions', 12 | 'last 4 iOS versions', 13 | ], 14 | }), 15 | ], 16 | } 17 | -------------------------------------------------------------------------------- /playground/public/Mighty Final Fight (USA).nes: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taiyuuki/nes-vue/2ecceb265b47867bb83fee28a430ff3ccc0b0312/playground/public/Mighty Final Fight (USA).nes -------------------------------------------------------------------------------- /playground/public/Mitsume ga Tooru (Japan).nes: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taiyuuki/nes-vue/2ecceb265b47867bb83fee28a430ff3ccc0b0312/playground/public/Mitsume ga Tooru (Japan).nes -------------------------------------------------------------------------------- /playground/public/Super Mario Bros (JU).nes: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taiyuuki/nes-vue/2ecceb265b47867bb83fee28a430ff3ccc0b0312/playground/public/Super Mario Bros (JU).nes -------------------------------------------------------------------------------- /playground/public/Super Mario Bros 3.nes: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taiyuuki/nes-vue/2ecceb265b47867bb83fee28a430ff3ccc0b0312/playground/public/Super Mario Bros 3.nes -------------------------------------------------------------------------------- /playground/public/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taiyuuki/nes-vue/2ecceb265b47867bb83fee28a430ff3ccc0b0312/playground/public/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /playground/public/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taiyuuki/nes-vue/2ecceb265b47867bb83fee28a430ff3ccc0b0312/playground/public/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /playground/public/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taiyuuki/nes-vue/2ecceb265b47867bb83fee28a430ff3ccc0b0312/playground/public/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /playground/public/icons/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #00aba9 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /playground/public/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taiyuuki/nes-vue/2ecceb265b47867bb83fee28a430ff3ccc0b0312/playground/public/icons/favicon-16x16.png -------------------------------------------------------------------------------- /playground/public/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taiyuuki/nes-vue/2ecceb265b47867bb83fee28a430ff3ccc0b0312/playground/public/icons/favicon-32x32.png -------------------------------------------------------------------------------- /playground/public/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taiyuuki/nes-vue/2ecceb265b47867bb83fee28a430ff3ccc0b0312/playground/public/icons/favicon.ico -------------------------------------------------------------------------------- /playground/public/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taiyuuki/nes-vue/2ecceb265b47867bb83fee28a430ff3ccc0b0312/playground/public/icons/mstile-150x150.png -------------------------------------------------------------------------------- /playground/public/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 23 | 26 | 32 | 40 | 45 | 51 | 53 | 55 | 57 | 65 | 70 | 76 | 78 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /playground/public/icons/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "./android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "./android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } -------------------------------------------------------------------------------- /playground/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /playground/src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /playground/src/assets/vue.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /playground/src/auto-imports.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* prettier-ignore */ 3 | // @ts-nocheck 4 | // noinspection JSUnusedGlobalSymbols 5 | // Generated by unplugin-auto-import 6 | // biome-ignore lint: disable 7 | export {} 8 | declare global { 9 | const $: typeof import('vue/macros')['$'] 10 | const $$: typeof import('vue/macros')['$$'] 11 | const $computed: typeof import('vue/macros')['$computed'] 12 | const $customRef: typeof import('vue/macros')['$customRef'] 13 | const $ref: typeof import('vue/macros')['$ref'] 14 | const $shallowRef: typeof import('vue/macros')['$shallowRef'] 15 | const $toRef: typeof import('vue/macros')['$toRef'] 16 | const EffectScope: typeof import('vue')['EffectScope'] 17 | const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate'] 18 | const computed: typeof import('vue')['computed'] 19 | const createApp: typeof import('vue')['createApp'] 20 | const createPinia: typeof import('pinia')['createPinia'] 21 | const customRef: typeof import('vue')['customRef'] 22 | const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] 23 | const defineComponent: typeof import('vue')['defineComponent'] 24 | const defineStore: typeof import('pinia')['defineStore'] 25 | const effectScope: typeof import('vue')['effectScope'] 26 | const getActivePinia: typeof import('pinia')['getActivePinia'] 27 | const getCurrentInstance: typeof import('vue')['getCurrentInstance'] 28 | const getCurrentScope: typeof import('vue')['getCurrentScope'] 29 | const h: typeof import('vue')['h'] 30 | const inject: typeof import('vue')['inject'] 31 | const isProxy: typeof import('vue')['isProxy'] 32 | const isReactive: typeof import('vue')['isReactive'] 33 | const isReadonly: typeof import('vue')['isReadonly'] 34 | const isRef: typeof import('vue')['isRef'] 35 | const mapActions: typeof import('pinia')['mapActions'] 36 | const mapGetters: typeof import('pinia')['mapGetters'] 37 | const mapState: typeof import('pinia')['mapState'] 38 | const mapStores: typeof import('pinia')['mapStores'] 39 | const mapWritableState: typeof import('pinia')['mapWritableState'] 40 | const markRaw: typeof import('vue')['markRaw'] 41 | const nextTick: typeof import('vue')['nextTick'] 42 | const onActivated: typeof import('vue')['onActivated'] 43 | const onBeforeMount: typeof import('vue')['onBeforeMount'] 44 | const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave'] 45 | const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate'] 46 | const onBeforeUnmount: typeof import('vue')['onBeforeUnmount'] 47 | const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] 48 | const onDeactivated: typeof import('vue')['onDeactivated'] 49 | const onErrorCaptured: typeof import('vue')['onErrorCaptured'] 50 | const onMounted: typeof import('vue')['onMounted'] 51 | const onRenderTracked: typeof import('vue')['onRenderTracked'] 52 | const onRenderTriggered: typeof import('vue')['onRenderTriggered'] 53 | const onScopeDispose: typeof import('vue')['onScopeDispose'] 54 | const onServerPrefetch: typeof import('vue')['onServerPrefetch'] 55 | const onUnmounted: typeof import('vue')['onUnmounted'] 56 | const onUpdated: typeof import('vue')['onUpdated'] 57 | const onWatcherCleanup: typeof import('vue')['onWatcherCleanup'] 58 | const provide: typeof import('vue')['provide'] 59 | const reactive: typeof import('vue')['reactive'] 60 | const readonly: typeof import('vue')['readonly'] 61 | const ref: typeof import('vue')['ref'] 62 | const resolveComponent: typeof import('vue')['resolveComponent'] 63 | const resolveDirective: typeof import('vue')['resolveDirective'] 64 | const setActivePinia: typeof import('pinia')['setActivePinia'] 65 | const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix'] 66 | const shallowReactive: typeof import('vue')['shallowReactive'] 67 | const shallowReadonly: typeof import('vue')['shallowReadonly'] 68 | const shallowRef: typeof import('vue')['shallowRef'] 69 | const storeToRefs: typeof import('pinia')['storeToRefs'] 70 | const toRaw: typeof import('vue')['toRaw'] 71 | const toRef: typeof import('vue')['toRef'] 72 | const toRefs: typeof import('vue')['toRefs'] 73 | const toValue: typeof import('vue')['toValue'] 74 | const triggerRef: typeof import('vue')['triggerRef'] 75 | const unref: typeof import('vue')['unref'] 76 | const useAttrs: typeof import('vue')['useAttrs'] 77 | const useCssModule: typeof import('vue')['useCssModule'] 78 | const useCssVars: typeof import('vue')['useCssVars'] 79 | const useId: typeof import('vue')['useId'] 80 | const useLink: typeof import('vue-router')['useLink'] 81 | const useModel: typeof import('vue')['useModel'] 82 | const useRoute: typeof import('vue-router')['useRoute'] 83 | const useRouter: typeof import('vue-router')['useRouter'] 84 | const useSlots: typeof import('vue')['useSlots'] 85 | const useTemplateRef: typeof import('vue')['useTemplateRef'] 86 | const watch: typeof import('vue')['watch'] 87 | const watchEffect: typeof import('vue')['watchEffect'] 88 | const watchPostEffect: typeof import('vue')['watchPostEffect'] 89 | const watchSyncEffect: typeof import('vue')['watchSyncEffect'] 90 | } 91 | // for type re-export 92 | declare global { 93 | // @ts-ignore 94 | export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' 95 | import('vue') 96 | } 97 | -------------------------------------------------------------------------------- /playground/src/components.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // @ts-nocheck 3 | // Generated by unplugin-vue-components 4 | // Read more: https://github.com/vuejs/core/pull/3399 5 | // biome-ignore lint: disable 6 | export {} 7 | 8 | /* prettier-ignore */ 9 | declare module 'vue' { 10 | export interface GlobalComponents { 11 | IconDownload: typeof import('./components/Icon/IconDownload.vue')['default'] 12 | IconGitee: typeof import('./components/Icon/IconGitee.vue')['default'] 13 | IconGithub: typeof import('./components/Icon/IconGithub.vue')['default'] 14 | IconMoon: typeof import('./components/Icon/IconMoon.vue')['default'] 15 | IconSun: typeof import('./components/Icon/IconSun.vue')['default'] 16 | MainHeader: typeof import('./components/MainHeader.vue')['default'] 17 | RouterLink: typeof import('vue-router')['RouterLink'] 18 | RouterView: typeof import('vue-router')['RouterView'] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /playground/src/components/Icon/IconDownload.vue: -------------------------------------------------------------------------------- 1 | 42 | -------------------------------------------------------------------------------- /playground/src/components/Icon/IconGitee.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /playground/src/components/Icon/IconGithub.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /playground/src/components/Icon/IconMoon.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /playground/src/components/Icon/IconSun.vue: -------------------------------------------------------------------------------- 1 | 44 | -------------------------------------------------------------------------------- /playground/src/components/MainHeader.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 65 | 66 | 98 | -------------------------------------------------------------------------------- /playground/src/css/app.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: Emoji; 3 | src: 4 | local("Apple Color Emojiji"), 5 | local("Segoe Emoji"), 6 | local("Noto Color Emoji"); 7 | unicode-range: U+1F000-1F644, U+203C-3299; 8 | } 9 | 10 | body { 11 | margin: 0; 12 | padding: 0; 13 | font-family: 14 | system-ui, 15 | -apple-system, 16 | "Segoe UI", 17 | Roboto, 18 | Emoji, 19 | Helvetica, 20 | Arial, 21 | sans-serif; 22 | } 23 | 24 | .dark { 25 | color-scheme: dark; 26 | } 27 | -------------------------------------------------------------------------------- /playground/src/download/files.ts: -------------------------------------------------------------------------------- 1 | import { repoURL } from 'src/template' 2 | 3 | export const html = ` 4 | 5 | 6 | 7 | 8 | 9 | Vite App 10 | 11 | 12 |
13 | 14 | 15 | 16 | ` 17 | 18 | export const pkg = `{ 19 | "name": "nes-vue-demo", 20 | "version": "0.0.0", 21 | "scripts": { 22 | "dev": "vite", 23 | "build": "vite build", 24 | "serve": "vite preview" 25 | }, 26 | "dependencies": { 27 | "nes-vue": "^1.5.0", 28 | "vue": "^3.3.0" 29 | }, 30 | "devDependencies": { 31 | "@vitejs/plugin-vue": "^3.2.0", 32 | "@vue/compiler-sfc": "^3.2.0", 33 | "vite": "^3.2.7" 34 | } 35 | }` 36 | 37 | export const main = `import { createApp } from 'vue' 38 | import App from './App.vue' 39 | 40 | createApp(App).mount('#app') 41 | ` 42 | 43 | export const viteConfig = `import { defineConfig } from 'vite' 44 | import vue from '@vitejs/plugin-vue' 45 | 46 | // https://vitejs.dev/config/ 47 | export default defineConfig({ 48 | plugins: [vue()] 49 | }) 50 | ` 51 | 52 | export const readme = `# NesVue Demo 53 | 54 | This is a [nes-vue](${repoURL}) project demo. 55 | 56 | To start: 57 | 58 | \`\`\`sh 59 | npm install 60 | npm run dev 61 | 62 | # if using yarn: 63 | yarn 64 | yarn dev 65 | \`\`\` 66 | ` 67 | -------------------------------------------------------------------------------- /playground/src/download/index.ts: -------------------------------------------------------------------------------- 1 | import { download_blob, url_to_blob } from '@taiyuuki/utils' 2 | import type { ReplStore } from '@vue/repl' 3 | import { fm2s, roms } from './roms' 4 | import { html, main, pkg, readme, viteConfig } from './files' 5 | 6 | export async function downloadProject(store: ReplStore) { 7 | if (!confirm('Download project files?')) { 8 | return 9 | } 10 | const JSzip = (await import('jszip')).default 11 | 12 | const zip = new JSzip() 13 | const srcFolder = zip.folder('src')! 14 | const publicFolder = zip.folder('public')! 15 | 16 | const files = store.getFiles() 17 | 18 | // base files 19 | zip.file('index.html', html) 20 | zip.file('package.json', pkg) 21 | zip.file('vite.config.js', viteConfig) 22 | zip.file('README.md', readme) 23 | srcFolder.file('main.js', main) 24 | 25 | // playground files 26 | for (const file in files) { 27 | srcFolder.file(file, files[file]) 28 | } 29 | 30 | // .nes files 31 | for await (const rom of roms) { 32 | const romBlob = await url_to_blob(rom) 33 | publicFolder.file(rom, romBlob) 34 | } 35 | 36 | // .fm2 files 37 | for await (const fm2 of fm2s) { 38 | const fm2Blob = await url_to_blob(fm2[0]) 39 | publicFolder.file(fm2[0], fm2Blob) 40 | } 41 | 42 | const project = await zip.generateAsync({ type: 'blob' }) 43 | download_blob(project, 'nes-vue-demo') 44 | } 45 | -------------------------------------------------------------------------------- /playground/src/download/roms.ts: -------------------------------------------------------------------------------- 1 | export const roms = [ 2 | 'Super Mario Bros (JU).nes', 3 | 'Super Mario Bros 3.nes', 4 | 'Mighty Final Fight (USA).nes', 5 | 'Mitsume ga Tooru (Japan).nes', 6 | ] 7 | 8 | export const fm2s: [string, number][] = [ 9 | ['happylee-supermariobros,warped.fm2', 0], 10 | ['lordtom,maru,tompa-smb3-warps.fm2', -1], 11 | ['xipov3-mightyfinalfight.fm2', 0], 12 | ['jy,aiqiyou-mitsumegatooru.fm2', 0], 13 | ] 14 | -------------------------------------------------------------------------------- /playground/src/layouts/MainLayout.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /playground/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import route from './router' 4 | import pinia from './stores' 5 | import './css/app.scss' 6 | 7 | createApp(App).use(pinia).use(route).mount('#app') 8 | -------------------------------------------------------------------------------- /playground/src/pages/404.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /playground/src/pages/index.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 20 | 21 | 26 | -------------------------------------------------------------------------------- /playground/src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createMemoryHistory, 3 | createRouter, 4 | createWebHashHistory, 5 | } from 'vue-router' 6 | 7 | import { setupLayouts } from 'virtual:generated-layouts' 8 | import generatedRoutes from 'virtual:generated-pages' 9 | 10 | const routes = setupLayouts(generatedRoutes) 11 | routes.push({ 12 | path: '/:catchAll(.*)*', 13 | component: () => import('pages/404.vue'), 14 | }) 15 | // hash mode 16 | const createHistory = import.meta.env.SSR ? createMemoryHistory : createWebHashHistory 17 | 18 | const route = createRouter({ 19 | scrollBehavior: () => ({ left: 0, top: 0 }), 20 | routes, 21 | history: createHistory(), 22 | }) 23 | export default route 24 | -------------------------------------------------------------------------------- /playground/src/stores/dark.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | export const useDark = defineStore('dark', { 4 | state: () => ({ on: false }), 5 | persist: true, 6 | }) 7 | -------------------------------------------------------------------------------- /playground/src/stores/index.ts: -------------------------------------------------------------------------------- 1 | import { createPinia } from 'pinia' 2 | import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' 3 | 4 | const pinia = createPinia() 5 | pinia.use(piniaPluginPersistedstate) 6 | 7 | export default pinia 8 | -------------------------------------------------------------------------------- /playground/src/template.ts: -------------------------------------------------------------------------------- 1 | import { fm2s, roms } from './download/roms' 2 | 3 | const template = ` 4 | 5 | 47 | ` 48 | 49 | let script = ` 4 | 5 | 10 | 11 | 21 | -------------------------------------------------------------------------------- /src/animation/index.ts: -------------------------------------------------------------------------------- 1 | 2 | const WIDTH = 256 3 | const HEIGHT = 240 4 | let animationframeID: number 5 | let framebuffer_u8!: Uint8ClampedArray, 6 | framebuffer_u32!: Uint32Array 7 | let canvas_ctx!: CanvasRenderingContext2D 8 | const imageData = new ImageData(WIDTH, HEIGHT) 9 | 10 | // const playback = new Playback 11 | 12 | function onFrame(u32: number[]) { 13 | for (let i = 0; i < 256 * 240; i += 1) { 14 | framebuffer_u32[i] = 0xFF000000 | u32[i] 15 | } 16 | } 17 | 18 | function putImageData() { 19 | imageData.data.set(framebuffer_u8) 20 | canvas_ctx.putImageData(imageData, 0, 0) 21 | } 22 | 23 | function animationFrame(cvs: HTMLCanvasElement) { 24 | canvas_ctx = cvs.getContext('2d')! 25 | canvas_ctx.fillStyle = 'black' 26 | canvas_ctx.fillRect(0, 0, WIDTH, HEIGHT) 27 | const buffer = new ArrayBuffer(imageData.data.length) 28 | framebuffer_u8 = new Uint8ClampedArray(buffer) 29 | framebuffer_u32 = new Uint32Array(buffer) 30 | 31 | // playback.clearDB() 32 | animationframeID = requestAnimationFrame(onAnimationFrame) 33 | 34 | function onAnimationFrame() { 35 | cancelAnimationFrame(animationframeID) 36 | animationframeID = requestAnimationFrame(onAnimationFrame) 37 | putImageData() 38 | } 39 | } 40 | 41 | // function rewind() { 42 | // const frame = nes.frameCounter - 1 43 | // if (frame in playback.frameData) { 44 | // const frameData = decompressArray(playback.action(frame)) 45 | // for (let i = 0; i < frameData.length; i++) { 46 | // framebuffer_u32[i] = frameData[i] 47 | // } 48 | // putImageData() 49 | // nes.frameCounter-- 50 | // } 51 | // else { 52 | // playback.load((data) => { 53 | // loadNesData(data, () => { 54 | // console.error('Failed to load nes data') 55 | // }) 56 | 57 | // rewind() 58 | // }) 59 | // } 60 | // } 61 | 62 | // function forward() { 63 | // const frame = nes.frameCounter + 1 64 | // if (frame in playback.frameData) { 65 | // const frameData = decompressArray(playback.action(frame)) 66 | // for (let i = 0; i < frameData.length; i++) { 67 | // framebuffer_u32[i] = frameData[i] 68 | // } 69 | // putImageData() 70 | // nes.frameCounter++ 71 | // } 72 | // else { 73 | // nesFrame() 74 | // } 75 | // } 76 | 77 | // function frameAction() { 78 | // const frame = nes.frameCounter + 1 79 | // if (frame in playback.frameData) { 80 | // forward() 81 | // setTimeout(frameAction, 1000 / 60) 82 | // } 83 | // else { 84 | // resume() 85 | // } 86 | // } 87 | 88 | function fitInParent(cvs: HTMLCanvasElement) { 89 | const parent = cvs.parentNode as HTMLElement 90 | const parentWidth = parent.clientWidth 91 | const parentHeight = parent.clientHeight 92 | const parentRatio = parentWidth / parentHeight 93 | const desiredRatio = WIDTH / HEIGHT 94 | if (desiredRatio < parentRatio) { 95 | cvs.style.height = `${parentHeight}px` 96 | cvs.style.width = `${Math.round(parentHeight + desiredRatio)}px` 97 | } 98 | else { 99 | cvs.style.width = `${parentWidth}px` 100 | cvs.style.height = `${Math.round(parentWidth / desiredRatio)}px` 101 | } 102 | } 103 | 104 | function animationStop() { 105 | cancelAnimationFrame(animationframeID) 106 | } 107 | 108 | function cut(cvs: HTMLCanvasElement) { 109 | const image = new Image() 110 | image.src = cvs.toDataURL('image/png') 111 | 112 | return image 113 | } 114 | 115 | export { 116 | WIDTH, 117 | HEIGHT, 118 | onFrame, 119 | animationFrame, 120 | animationStop, 121 | fitInParent, 122 | 123 | // rewind, 124 | // forward, 125 | cut, 126 | 127 | // frameAction, 128 | } 129 | -------------------------------------------------------------------------------- /src/audio/index.ts: -------------------------------------------------------------------------------- 1 | import { nesFrame } from 'src/nes' 2 | import { math_between } from '@taiyuuki/utils' 3 | 4 | let audio_ctx = new AudioContext() 5 | let script_processor: ScriptProcessorNode 6 | let gain = 1 7 | const AUDIO_BUFFERING = 512 8 | const SAMPLE_COUNT = 4 * 1024 9 | const SAMPLE_MASK = SAMPLE_COUNT - 1 10 | const audio_samples_L = new Float32Array(SAMPLE_COUNT) 11 | const audio_samples_R = new Float32Array(SAMPLE_COUNT) 12 | let audio_write_cursor = 0 13 | let audio_read_cursor = 0 14 | 15 | function audio_remain() { 16 | return audio_write_cursor - audio_read_cursor & SAMPLE_MASK 17 | } 18 | 19 | function onAudioSample(left: number, right: number) { 20 | audio_samples_L[audio_write_cursor] = left 21 | audio_samples_R[audio_write_cursor] = right 22 | audio_write_cursor = audio_write_cursor + 1 & SAMPLE_MASK 23 | } 24 | 25 | function getSampleRate() { 26 | if (!window.AudioContext) { 27 | return 44100 28 | } 29 | const myCtx = new window.AudioContext() 30 | const sampleRate = myCtx.sampleRate 31 | myCtx.close() 32 | 33 | return sampleRate 34 | } 35 | 36 | function audioFrame() { 37 | audio_ctx = new AudioContext() 38 | script_processor = audio_ctx.createScriptProcessor(AUDIO_BUFFERING, 0, 2) 39 | script_processor.onaudioprocess = (event: AudioProcessingEvent) => { 40 | const dst = event.outputBuffer 41 | const len = dst.length 42 | if (audio_remain() < AUDIO_BUFFERING) { 43 | nesFrame() 44 | } 45 | const dst_l = dst.getChannelData(0) 46 | const dst_r = dst.getChannelData(1) 47 | for (let i = 0; i < len; i++) { 48 | const src_idx = audio_read_cursor + i & SAMPLE_MASK 49 | dst_l[i] = audio_samples_L[src_idx] * gain 50 | dst_r[i] = audio_samples_R[src_idx] * gain 51 | } 52 | 53 | audio_read_cursor = audio_read_cursor + len & SAMPLE_MASK 54 | } 55 | script_processor.connect(audio_ctx.destination) 56 | } 57 | 58 | function audioStop() { 59 | script_processor.disconnect(audio_ctx.destination) 60 | script_processor.onaudioprocess = null 61 | script_processor = {} as ScriptProcessorNode 62 | 63 | if ('close' in audio_ctx) { 64 | audio_ctx.close() 65 | } 66 | } 67 | 68 | /** 69 | * 🎮: Pause 70 | */ 71 | function suspend() { 72 | audio_ctx.suspend() 73 | } 74 | 75 | /** 76 | * 🎮: Play 77 | */ 78 | function resume() { 79 | audio_ctx.resume() 80 | } 81 | 82 | function setGain(n: number) { 83 | gain = math_between(n, 0, 100) / 100 84 | } 85 | 86 | export { 87 | onAudioSample, 88 | nesFrame, 89 | audioFrame, 90 | audioStop, 91 | getSampleRate, 92 | setGain, 93 | suspend, 94 | resume, 95 | } 96 | -------------------------------------------------------------------------------- /src/components/NesVue.vue: -------------------------------------------------------------------------------- 1 | 529 | 530 | 533 | 534 | 555 | -------------------------------------------------------------------------------- /src/composables/use-controller.ts: -------------------------------------------------------------------------------- 1 | import { math_between, object_keys } from '@taiyuuki/utils' 2 | import type { NesVueProps, Player } from 'src/types' 3 | import { controllerState } from 'src/nes' 4 | import { fillFalse, gpFilter } from 'src/utils' 5 | import type { ComputedRef } from 'vue' 6 | import { computed, onBeforeUnmount, onMounted, watch } from 'vue' 7 | 8 | // threshold of level. 9 | const THRESHOLD = 0.3 10 | 11 | // buttonDown - 0x41 12 | // buttonUp - 0x40 13 | // controllerState - [A, B, SELECT, START, UP, DOWN, LEFT, RIGHT] 14 | // for example: [0x40, 0x40, 0x40, 0x41, 0x40, 0x40, 0x40, 0x40] 15 | // means: Start button is pressed. 16 | 17 | const KEYS_INDEX = { 18 | A: 0, 19 | B: 1, 20 | SELECT: 2, 21 | START: 3, 22 | UP: 4, 23 | DOWN: 5, 24 | LEFT: 6, 25 | RIGHT: 7, 26 | C: 8, 27 | D: 9, 28 | } 29 | 30 | export const P1_DEFAULT = { 31 | UP: 'KeyW', 32 | DOWN: 'KeyS', 33 | LEFT: 'KeyA', 34 | RIGHT: 'KeyD', 35 | A: 'KeyK', 36 | B: 'KeyJ', 37 | C: 'KeyI', 38 | D: 'KeyU', 39 | SELECT: 'Digit2', 40 | START: 'Digit1', 41 | } 42 | 43 | export const P2_DEFAULT = { 44 | UP: 'ArrowUp', 45 | DOWN: 'ArrowDown', 46 | LEFT: 'ArrowLeft', 47 | RIGHT: 'ArrowRight', 48 | A: 'Numpad2', 49 | B: 'Numpad1', 50 | C: 'Numpad5', 51 | D: 'Numpad4', 52 | SELECT: 'NumpadDecimal', 53 | START: 'NumpadEnter', 54 | } 55 | 56 | let interval = 1000 / (2 * 16) 57 | 58 | export function emitControllerState(eventCode: string, state: 0x40 | 0x41) { 59 | controllerState.emit(eventCode, state, interval) 60 | } 61 | 62 | class GamepadManager { 63 | animationFrame: number 64 | axesHolding: Record 65 | btnHolding: Record 66 | gamepad_btns: ComputedRef<{ p1: string[], p2: string[] }> 67 | 68 | constructor(gamepad_btns: ComputedRef<{ p1: string[], p2: string[] }>) { 69 | window.addEventListener('gamepadconnected', this.connectHandler.bind(this, true)) 70 | window.addEventListener('gamepaddisconnected', this.connectHandler.bind(this, false)) 71 | this.animationFrame = requestAnimationFrame(this.frame.bind(this)) 72 | this.btnHolding = { 73 | p1: fillFalse(20), 74 | p2: fillFalse(20), 75 | } 76 | this.axesHolding = { 77 | p1: fillFalse(4), 78 | p2: fillFalse(4), 79 | } 80 | this.gamepad_btns = gamepad_btns 81 | } 82 | 83 | get gamepads() { 84 | return gpFilter(navigator.getGamepads()) 85 | } 86 | 87 | connectHandler(state: boolean, e: GamepadEvent) { 88 | if (state) { 89 | this.gamepads[e.gamepad.index] = e.gamepad 90 | } 91 | else if (this.gamepads.length === 0) { 92 | this.close() 93 | } 94 | } 95 | 96 | axesHandler(player: Player, check: boolean, aindex: number, bindex: number) { 97 | const hold = this.axesHolding[player]?.[aindex] 98 | if (check) { 99 | if (!hold) { 100 | emitControllerState(this.gamepad_btns.value[player][bindex], 0x41) 101 | this.axesHolding[player][aindex] = true 102 | } 103 | } 104 | else if (hold) { 105 | emitControllerState(this.gamepad_btns.value[player][bindex], 0x40) 106 | this.axesHolding[player][aindex] = false 107 | } 108 | } 109 | 110 | btnHandler(player: Player, btn: GamepadButton, bindex: number) { 111 | const hold = this.btnHolding[player]?.[bindex] 112 | if (btn.pressed) { 113 | if (hold) { 114 | return 115 | } 116 | emitControllerState(this.gamepad_btns.value[player][bindex], 0x41) 117 | this.btnHolding[player][bindex] = true 118 | } 119 | else if (hold) { 120 | emitControllerState(this.gamepad_btns.value[player][bindex], 0x40) 121 | this.btnHolding[player][bindex] = false 122 | } 123 | } 124 | 125 | frame() { 126 | for (let gindex = 0; gindex < this.gamepads.length; gindex++) { 127 | if (gindex > 1) { 128 | break 129 | } 130 | const player = `p${gindex + 1}` as Player 131 | const gamepad = this.gamepads[gindex] 132 | 133 | gamepad.buttons.forEach(this.btnHandler.bind(this, player)) 134 | 135 | const lr = gamepad.axes[0] 136 | const tb = gamepad.axes[1] 137 | 138 | this.axesHandler(player, lr > THRESHOLD, 0, 15) 139 | this.axesHandler(player, lr < -THRESHOLD, 1, 14) 140 | this.axesHandler(player, tb > THRESHOLD, 2, 13) 141 | this.axesHandler(player, tb < -THRESHOLD, 3, 12) 142 | } 143 | } 144 | 145 | run() { 146 | this.frame() 147 | cancelAnimationFrame(this.animationFrame) 148 | this.animationFrame = requestAnimationFrame(this.run.bind(this)) 149 | } 150 | 151 | close() { 152 | this.btnHolding.p1.fill(false) 153 | this.btnHolding.p2.fill(false) 154 | this.axesHolding.p1.fill(false) 155 | this.axesHolding.p2.fill(false) 156 | cancelAnimationFrame(this.animationFrame) 157 | } 158 | } 159 | 160 | export function useController(props: NesVueProps): (eventCode: string, state: 0x40 | 0x41)=> void { 161 | const p1 = computed(() => Object.assign(P1_DEFAULT, props.p1)) 162 | const p2 = computed(() => Object.assign(P2_DEFAULT, props.p2)) 163 | 164 | function setTurboInterval() { 165 | interval = 1000 / (2 * math_between(props.turbo as number, 5, 20)) 166 | } 167 | 168 | function setController() { 169 | controllerState.init() 170 | object_keys(KEYS_INDEX).forEach(key => { 171 | const index = KEYS_INDEX[key] 172 | controllerState.on(p1.value[key], { 173 | p: 1, 174 | index, 175 | }) 176 | controllerState.on(p2.value[key], { 177 | p: key === 'SELECT' || key === 'START' ? 1 : 2, 178 | index, 179 | }) 180 | }) 181 | } 182 | 183 | setController() 184 | setTurboInterval() 185 | 186 | watch(() => props.p1, setController, { deep: true }) 187 | watch(() => props.p2, setController, { deep: true }) 188 | watch(() => props.turbo, setTurboInterval) 189 | 190 | const gamepad_btns = computed(() => { 191 | return { 192 | p1: [ 193 | p1.value.A, 194 | p1.value.C, 195 | p1.value.B, 196 | p1.value.D, 197 | '', 198 | '', 199 | '', 200 | '', 201 | p1.value.SELECT, 202 | p1.value.START, 203 | '', 204 | '', 205 | p1.value.UP, 206 | p1.value.DOWN, 207 | p1.value.LEFT, 208 | p1.value.RIGHT, 209 | ], 210 | p2: [ 211 | p2.value.A, 212 | p2.value.C, 213 | p2.value.B, 214 | p2.value.D, 215 | '', 216 | '', 217 | '', 218 | '', 219 | p1.value.SELECT, 220 | p1.value.START, 221 | '', 222 | '', 223 | p2.value.UP, 224 | p2.value.DOWN, 225 | p2.value.LEFT, 226 | p2.value.RIGHT, 227 | ], 228 | } 229 | }) 230 | 231 | const gamepad = new GamepadManager(gamepad_btns) 232 | 233 | onMounted(() => { 234 | gamepad.run() 235 | }) 236 | 237 | onBeforeUnmount(() => { 238 | gamepad.close() 239 | }) 240 | 241 | return emitControllerState 242 | } 243 | -------------------------------------------------------------------------------- /src/composables/use-instance.ts: -------------------------------------------------------------------------------- 1 | import type { Component, Ref } from 'vue' 2 | import { ref } from 'vue' 3 | 4 | export const useInstance = Component>() => ref() as Ref> 5 | 6 | export const useElement = () => ref() as Ref 7 | -------------------------------------------------------------------------------- /src/db/index.ts: -------------------------------------------------------------------------------- 1 | import { DB } from '@taiyuuki/utils' 2 | 3 | function createDB(dbName: string, storeName: string): DB { 4 | return new DB(dbName, storeName) 5 | } 6 | 7 | type DBInstance = InstanceType> 8 | 9 | export type { DBInstance } 10 | 11 | export { createDB } 12 | -------------------------------------------------------------------------------- /src/dev/NesDemo.vue: -------------------------------------------------------------------------------- 1 | 79 | 80 | 156 | 157 | 184 | -------------------------------------------------------------------------------- /src/directives/v-gamepad.ts: -------------------------------------------------------------------------------- 1 | import type { Directive } from 'vue' 2 | import type { ControllerKey } from 'src/types' 3 | import { P1_DEFAULT, P2_DEFAULT, emitControllerState } from 'src/composables/use-controller' 4 | import { key_in, object_keys } from '@taiyuuki/utils' 5 | 6 | const _events: Record void)[]>> = {} 7 | 8 | function addEvent(el: HTMLElement, eventId: string, type: string, fn: (e: E)=> void, options?: AddEventListenerOptions | boolean) { 9 | el.addEventListener(type, fn, options) 10 | _events[eventId] = _events[eventId] || {} 11 | _events[eventId][type] = _events[eventId][type] || [] 12 | _events[eventId][type].push(fn) 13 | } 14 | 15 | function removeEventAll(el: HTMLElement, eventId: string) { 16 | if (_events[eventId]) { 17 | object_keys(_events[eventId]).forEach(type => { 18 | _events[eventId][type].forEach(fn => { 19 | el.removeEventListener(type, fn) 20 | }) 21 | delete _events[eventId][type] 22 | }) 23 | delete _events[eventId] 24 | } 25 | } 26 | 27 | function resolveKeys(key: ControllerKey | ControllerKey[]) { 28 | if (typeof key === 'string') { 29 | key = [key] 30 | } 31 | 32 | return Array.from(new Set(key)).map(key => key.toUpperCase()) 33 | .sort() as ControllerKey[] 34 | } 35 | 36 | /** 37 | * v-gamepad directive 38 | * @example 39 | * ```vue 40 | * 43 | * 44 | * 50 | * ``` 51 | */ 52 | export const vGamepad: Directive = (target, binding) => { 53 | if (!binding.value) { 54 | throw '[nes-vue] v-gamepad value is required' 55 | } 56 | const arg = (binding.arg ?? '').toLowerCase() 57 | const checkPlayer = binding.modifiers.p2 || binding.modifiers.P2 58 | const player = checkPlayer ? P2_DEFAULT : P1_DEFAULT 59 | if (binding.oldValue) { 60 | const oldKeys = resolveKeys(binding.oldValue).filter(key => key_in(key, player)) 61 | const oldEventId = `gamepad-${`${arg + (checkPlayer ? 'p2' : 'p1')}-${oldKeys.join('-')}`}` 62 | removeEventAll(target, oldEventId) 63 | } 64 | const keys = resolveKeys(binding.value).filter(key => key_in(key, player)) 65 | const eventId = `gamepad-${`${arg + (checkPlayer ? 'p2' : 'p1')}-${keys.join('-')}`}` 66 | if (keys.length) { 67 | if (arg === 'touch') { 68 | addEvent(target, eventId, 'touchstart', () => { 69 | keys.forEach(key => { 70 | emitControllerState(player[key], 0x41) 71 | }) 72 | }) 73 | addEvent(target, eventId, 'touchend', () => { 74 | keys.forEach(key => { 75 | emitControllerState(player[key], 0x40) 76 | }) 77 | }) 78 | addEvent(target, eventId, 'touchcancel', () => { 79 | keys.forEach(key => { 80 | emitControllerState(player[key], 0x40) 81 | }) 82 | }) 83 | } else { 84 | addEvent(target, eventId, 'mousedown', () => { 85 | keys.forEach(key => { 86 | emitControllerState(player[key], 0x41) 87 | }) 88 | }) 89 | addEvent(target, eventId, 'mouseup', () => { 90 | keys.forEach(key => { 91 | emitControllerState(player[key], 0x40) 92 | }) 93 | }) 94 | addEvent(target, eventId, 'mouseleave', () => { 95 | keys.forEach(key => { 96 | emitControllerState(player[key], 0x40) 97 | }) 98 | }) 99 | if (arg && arg !== 'mouse') { 100 | console.warn('[nes-vue] argument should be mouse or touch, changed to default: mouse') 101 | } 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.vue' { 4 | import type { DefineComponent } from 'vue' 5 | 6 | const component: DefineComponent 7 | export default component 8 | } 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import NesVue from 'src/components/NesVue.vue' 2 | import { nes } from 'src/nes' 3 | import { vGamepad } from 'src/directives/v-gamepad' 4 | 5 | export type NesVueInstance = InstanceType 6 | export type { 7 | Controller, 8 | NesVueProps, 9 | EmitErrorObj, 10 | SavedOrLoaded, 11 | } from 'src/types' 12 | export { NesVue, nes, vGamepad } 13 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | 4 | createApp(App).mount('#app') 5 | -------------------------------------------------------------------------------- /src/nes/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import { NES } from '@nesjs/core' 3 | import { onFrame } from 'src/animation' 4 | import { getSampleRate, onAudioSample } from 'src/audio' 5 | import type { ControllerStateType, EmitErrorObj, SaveData } from 'src/types' 6 | 7 | const nes = new NES({ 8 | onFrame, 9 | onAudioSample, 10 | sampleRate: getSampleRate(), 11 | }) 12 | 13 | const rom = { buffer: null as Uint8Array | null } 14 | 15 | function nesFrame() { 16 | nes.frame() 17 | } 18 | 19 | function getNesData(url: string) { 20 | return { 21 | path: url, 22 | data: nes.toJSON(), 23 | } 24 | } 25 | 26 | function loadNesData(saveData: SaveData, emitError: (error: EmitErrorObj)=> void, url?: string) { 27 | if (url && saveData.path !== url) { 28 | return emitError({ 29 | code: 2, 30 | message: `Load Error: The saved data is inconsistent with the current game, saved: ${saveData.path}, current: ${url}.`, 31 | }) 32 | } 33 | if (!rom.buffer) { 34 | return emitError({ 35 | code: 3, 36 | message: 'Load Error: NES ROM is not loaded.', 37 | }) 38 | } 39 | try { 40 | nes.fromJSON(saveData.data) 41 | } 42 | catch (e) { 43 | console.error(e) 44 | emitError({ 45 | code: 3, 46 | message: 'Load Error: The saved data is invalid.', 47 | }) 48 | } 49 | } 50 | 51 | class ControllerState { 52 | _events: Record 53 | _auto: Record> 58 | 59 | constructor() { 60 | this._events = {} 61 | this._auto = { 62 | 1: { 63 | 8: { 64 | timeout: 0, 65 | beDown: false, 66 | once: true, 67 | }, 68 | 9: { 69 | timeout: 0, 70 | beDown: false, 71 | once: true, 72 | }, 73 | }, 74 | 2: { 75 | 8: { 76 | timeout: 0, 77 | beDown: false, 78 | once: true, 79 | }, 80 | 9: { 81 | timeout: 0, 82 | beDown: false, 83 | once: true, 84 | }, 85 | }, 86 | } 87 | } 88 | 89 | on(keyCode: string, state: ControllerStateType) { 90 | if (!this._events[keyCode]) { 91 | this._events[keyCode] = [] 92 | } 93 | this._events[keyCode].push(state) 94 | } 95 | 96 | emit(keyboadCode: string, stateValue: 0x40 | 0x41, interval: number) { 97 | this._events[keyboadCode]?.forEach(event => { 98 | const p = event.p as 1 | 2 99 | const state = nes.controllers[p].state 100 | if (event.index <= 7) { 101 | state[event.index] = stateValue 102 | } 103 | else { 104 | const auto = this._auto[p][event.index] 105 | if (stateValue === 0x41) { 106 | if (auto.once) { 107 | state[event.index - 8] = 0x41 108 | auto.timeout = window.setInterval(() => { 109 | state[event.index - 8] = auto.beDown ? 0x41 : 0x40 110 | auto.beDown = !auto.beDown 111 | }, interval) 112 | auto.once = false 113 | } 114 | } 115 | else { 116 | clearInterval(auto.timeout) 117 | state[event.index - 8] = 0x40 118 | auto.once = true 119 | auto.beDown = false 120 | } 121 | } 122 | }) 123 | } 124 | 125 | getState(keyCode: string) { 126 | return this._events[keyCode] 127 | } 128 | 129 | init() { 130 | this._events = {} 131 | } 132 | } 133 | 134 | const controllerState = new ControllerState() 135 | 136 | export { 137 | nes, 138 | nesFrame, 139 | getNesData, 140 | loadNesData, 141 | rom, 142 | controllerState, 143 | } 144 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface EmitErrorObj { 2 | code: number 3 | message: string 4 | } 5 | 6 | export interface Controller { 7 | UP: string 8 | DOWN: string 9 | LEFT: string 10 | RIGHT: string 11 | A: string 12 | B: string 13 | C: string 14 | D: string 15 | SELECT: string 16 | START: string 17 | } 18 | 19 | export type ControllerKey = keyof Controller 20 | 21 | export interface ControllerStateType { 22 | p: number 23 | index: number 24 | } 25 | 26 | export interface SavedOrLoaded { 27 | id: string 28 | message: string 29 | target: 'indexedDB' | 'localStorage' 30 | } 31 | 32 | export interface NesVueProps { 33 | url: string 34 | autoStart?: boolean 35 | width?: number | string 36 | height?: number | string 37 | label?: string 38 | gain?: number 39 | clip?: boolean 40 | storage?: boolean 41 | debugger?: boolean 42 | turbo?: number 43 | p1?: Partial 44 | p2?: Partial 45 | } 46 | 47 | export interface NesVueEmits { 48 | (e: 'fps', fps: number): void 49 | (e: 'success'): void 50 | (e: 'error', error: EmitErrorObj): void 51 | (e: 'saved', saved: SavedOrLoaded): void 52 | (e: 'loaded', loaded: SavedOrLoaded): void 53 | (e: 'update:url', path: string): void 54 | (e: 'removed', id: string): void 55 | } 56 | 57 | export interface Automatic { 58 | timeout: number 59 | beDown: boolean 60 | once: boolean 61 | } 62 | 63 | export type Player = 'p1' | 'p2' 64 | 65 | export interface FrameData { [frame: number]: number[] } 66 | 67 | export interface SaveData { 68 | path: string 69 | data: { 70 | data: Uint8Array | string, 71 | compress: boolean 72 | } 73 | } 74 | 75 | export interface PlaybackData { 76 | length: number 77 | frameList: number[] 78 | frameData: FrameData 79 | nes: SaveData 80 | } 81 | 82 | export interface TasState { 83 | [frame: number]: { 84 | p1: number[] 85 | p2: number[] 86 | } 87 | } 88 | 89 | export type CheatCodeMap = Record 90 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | 2 | export function fillFalse(num: number): boolean[] { 3 | return Array(num).fill(false) 4 | } 5 | 6 | export function gpFilter(arr: T[]): NonNullable[] { 7 | return arr.filter(Boolean) as NonNullable[] 8 | } 9 | 10 | export function toHexNumber(str: string) { 11 | return Number(`0x${str}`) 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "useDefineForClassFields": true, 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "strict": true, 8 | "jsx": "preserve", 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "esModuleInterop": true, 13 | "declaration": true, 14 | "lib": [ 15 | "esnext", 16 | "dom" 17 | ], 18 | "skipLibCheck": true, 19 | "baseUrl": "./", 20 | "paths": { 21 | "src/*": [ 22 | "src/*" 23 | ], 24 | } 25 | }, 26 | "include": [ 27 | "src/**/*.ts", 28 | "src/**/*.d.ts", 29 | "src/**/*.tsx", 30 | "src/**/*.vue" 31 | ], 32 | "references": [ 33 | { 34 | "path": "./tsconfig.node.json" 35 | } 36 | ] 37 | } -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": [ 9 | "vite.config.ts" 10 | ] 11 | } -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { defineConfig } from 'vite' 3 | import vue from '@vitejs/plugin-vue' 4 | import rollupDelete from 'rollup-plugin-delete' 5 | import dts from 'vite-plugin-dts' 6 | 7 | function resolve(dir: string) { 8 | return path.join(__dirname, dir) 9 | } 10 | 11 | // https://vitejs.dev/config/ 12 | export default defineConfig({ 13 | plugins: [ 14 | dts({ 15 | outDir: 'dist', 16 | staticImport: true, 17 | insertTypesEntry: true, 18 | rollupTypes: true, 19 | }), 20 | vue(), 21 | ], 22 | resolve: { 23 | alias: { 24 | '@': resolve('src'), 25 | 'src': resolve('src'), 26 | 'common': resolve('src/common'), 27 | 'components': resolve('src/components'), 28 | 'composables': resolve('src/composables'), 29 | }, 30 | }, 31 | build: { 32 | lib: { 33 | entry: resolve('src/index.ts'), 34 | name: 'NesVue', 35 | fileName: format => `nes-vue.${format}.js`, 36 | }, 37 | rollupOptions: { 38 | external: ['vue'], 39 | output: { 40 | // 为外部依赖提供全局变量 41 | globals: { NesVue: 'NesVue' }, 42 | }, 43 | plugins: [ 44 | rollupDelete({ 45 | targets: ['dist/*.{ico,txt,svg,nes,NES,fm2}'], 46 | hook: 'generateBundle', 47 | }), 48 | ], 49 | }, 50 | }, 51 | }) 52 | --------------------------------------------------------------------------------