├── .editorconfig ├── .eslintrc.json ├── .gitattributes ├── .github ├── issue-checker.yml └── workflows │ ├── codeql.yml │ ├── issue-checker.yml │ ├── npm-publish-beta.yml │ └── npm-publish.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmrc ├── .prettierrc.json ├── .vscode └── extensions.json ├── LICENSE ├── README.md ├── docs ├── contribute.md ├── img │ ├── player.png │ └── text-position.png ├── layers.md └── layers │ ├── characterLayer.md │ └── effectLayer.md ├── index.html ├── lib ├── BaStoryPlayer.vue ├── eventBus.ts ├── index.ts ├── layers │ ├── bgLayer │ │ └── index.ts │ ├── characterLayer │ │ ├── actionPlayer.ts │ │ ├── emotionPlayer.ts │ │ ├── fxPlayer.ts │ │ ├── index.ts │ │ └── options │ │ │ ├── actionOptions.ts │ │ │ ├── emotionOptions.ts │ │ │ └── fxOptions.ts │ ├── effectLayer │ │ ├── bgEffectHandlers.ts │ │ ├── effectFunctions │ │ │ ├── BG_Dust_L.ts │ │ │ ├── BG_Flash_Sound.ts │ │ │ ├── BG_FocusLine.ts │ │ │ ├── BG_Love_L.ts │ │ │ ├── BG_Love_L_BGOff.ts │ │ │ ├── BG_Rain_L.ts │ │ │ ├── BG_SandStorm_L.ts │ │ │ ├── BG_Shining_L.ts │ │ │ ├── BG_Shining_L_BGOff.ts │ │ │ └── BG_UnderFire.ts │ │ ├── emitterConfigs │ │ │ ├── dust_fire.json │ │ │ ├── fire.json │ │ │ ├── fireline.json │ │ │ ├── focusline.json │ │ │ ├── love_heart.json │ │ │ ├── love_ring.json │ │ │ ├── rain.json │ │ │ ├── shining_flare.json │ │ │ ├── shining_ring.json │ │ │ └── smoke.json │ │ ├── emitterUtils.ts │ │ ├── index.ts │ │ └── resourcesUtils.ts │ ├── l2dLayer │ │ ├── L2D.ts │ │ └── l2dConfig.ts │ ├── soundLayer │ │ └── index.ts │ ├── textLayer │ │ ├── BaDialog.vue │ │ ├── assets │ │ │ ├── text-next.webp │ │ │ ├── title_border__lower_left.svg │ │ │ ├── title_border__lower_right.svg │ │ │ ├── title_border__upper_left.svg │ │ │ └── title_border__upper_right.svg │ │ ├── index.d.ts │ │ └── responsive-video-background.d.ts │ ├── translationLayer │ │ ├── index.ts │ │ └── utils.ts │ └── uiLayer │ │ ├── BaUI.vue │ │ ├── README.md │ │ ├── assets │ │ ├── Common_Btn_Normal_Y_S_Pt.webp │ │ ├── Conquest_2nd_Eventlobby_GaugeBg.png │ │ ├── Conquest_2nd_Eventlobby_GaugeFront.png │ │ ├── Deco_GachaItemBg.webp │ │ ├── Image_AngleBtn_Deco.png │ │ ├── UITex_BGPoliLight_1.svg │ │ ├── close.svg │ │ ├── fast-forward.svg │ │ ├── icon-hide.svg │ │ ├── icon-show.svg │ │ ├── menu.svg │ │ ├── pan-arrow.svg │ │ └── title-banner.svg │ │ ├── components │ │ ├── BaButton.vue │ │ ├── BaChatLog │ │ │ ├── BaChatLog.vue │ │ │ └── BaChatMessage.vue │ │ ├── BaDialog.vue │ │ └── BaSelector.vue │ │ ├── index.ts │ │ ├── userInteract.ts │ │ └── utils.ts ├── main.ts ├── stores │ └── index.ts ├── types │ ├── bgLayer.ts │ ├── characterLayer.ts │ ├── common.ts │ ├── effectLayer.ts │ ├── events.ts │ ├── excels.ts │ ├── l2d.ts │ ├── resources.ts │ └── store.ts └── utils.ts ├── package.json ├── pnpm-lock.yaml ├── src ├── App.vue ├── assets │ ├── scss │ │ └── index.scss │ └── vue.svg ├── components │ ├── ModifyEmotionOption.vue │ └── TestEffect.vue ├── data │ ├── LocalizeScenarioExcelTable.json │ ├── prologue1.1.json │ └── yuuka.json ├── main.ts ├── utils.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | indent_style = space 4 | indent_size = 2 5 | end_of_line = lf -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "parser": "vue-eslint-parser", 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:vue/vue3-essential", 12 | "@vue/eslint-config-typescript", 13 | "prettier" 14 | ], 15 | "plugins": ["vue", "import", "sort-exports", "@typescript-eslint"], 16 | "rules": { 17 | "indent": ["error", 2, { "SwitchCase": 1 }], 18 | "eqeqeq": ["error", "always"], 19 | "max-len": ["error", { "code": 120 }], 20 | "linebreak-style": ["error", "unix"], 21 | "quotes": ["error", "double"], 22 | "semi": ["warn", "always"], 23 | "space-before-function-paren": ["warn", { 24 | "anonymous": "never", 25 | "named": "never", 26 | "asyncArrow": "always" 27 | }], 28 | "comma-dangle": [ 29 | "error", 30 | { 31 | "arrays": "always-multiline", 32 | "objects": "always-multiline", 33 | "imports": "always-multiline", 34 | "exports": "always-multiline", 35 | "functions": "ignore" 36 | } 37 | ], 38 | "sort-exports/sort-exports": ["error", { "sortDir": "asc" }], 39 | "sort-imports": [ 40 | "error", 41 | { 42 | "ignoreCase": false, 43 | "ignoreDeclarationSort": true, 44 | "ignoreMemberSort": false, 45 | "memberSyntaxSortOrder": ["all", "single", "multiple", "none"] 46 | } 47 | ] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | * text=auto 3 | 4 | # Explicitly declare text files you want to always be normalized and converted 5 | # to native line endings on checkout. 6 | 7 | # Declare files that will always have CRLF line endings on checkout. 8 | *.vue text eol=lf 9 | *.ts text eol=lf 10 | *.js text eol=lf 11 | *.scss text eol=lf 12 | 13 | # Denote all files that are truly binary and should not be modified. 14 | *.png binary 15 | *.jpg binary 16 | *.webp binary 17 | *.svg binary 18 | *.woff binary 19 | *.woff2 binary 20 | package.json !binary 21 | .prettierrc.json !binary -------------------------------------------------------------------------------- /.github/issue-checker.yml: -------------------------------------------------------------------------------- 1 | # For labels 2 | labels: 3 | - name: bug 4 | mode: add 5 | content: bug 6 | regexes: '[Bb]ug' 7 | - name: enhancement 8 | mode: add 9 | content: enhancement 10 | regexes: '[Ee]nhancement|[Ff]eature [Rr]equest|功能请求|新功能|(能|可以?|行)不(行|能|可以)' 11 | - name: UI 12 | mode: add 13 | content: UI 14 | regexes: '[Uu][Ii]|界面' 15 | - name: momotalk 16 | mode: add 17 | content: 'module: momotalk' 18 | regexes: '([Mm][Oo]){2}\s{0,}[Tt][Aa][Ll][Kk]' 19 | - name: story 20 | mode: add 21 | content: 'module: story' 22 | regexes: '([Ss][Tt][Oo][Rr][Yy])|故事|剧情' 23 | - name: safari 24 | mode: add 25 | content: safari 26 | regexes: '[Ss][Aa][Ff][Aa][Rr][Ii]' 27 | - name: chrome 28 | mode: add 29 | content: chrome 30 | regexes: '[Cc][Hh][Rr][Oo][Mm][Ee]|谷歌' 31 | - name: firefox 32 | mode: add 33 | content: firefox 34 | regexes: '[Ff][Ii][Rr][Ee][Ff][Oo][Xx]|火狐' 35 | - name: duplicate 36 | mode: add 37 | content: duplicate 38 | regexes: '[Dd]uplicate of #\d+|[和与跟]\s?#\d+\s?是?重复' 39 | - name: wontfix 40 | mode: add 41 | content: wontfix 42 | regexes: '没有(这个)?(想法|计划)' 43 | - name: remove-question-tag 44 | content: 45 | regexes: '(? 6 | 过时内容 7 | 8 | # 剧情播放器组件仓库 9 | 10 | 本仓库是剧情播放器组件仓库, 用于播放碧蓝档案游戏剧情. 11 | 12 | 在进入全屏后, 如果宽高比小于 16/9, 则维持宽高比, 若大于, 则锁定为 16/9. 13 | 全屏时会自动检测是否横屏并自动旋转. 14 | 15 | # 使用 16 | 17 | ```html 18 | 22 | ``` 23 | 24 | # props 25 | 26 | ## story 27 | 28 | type: `StoryRawUnit[]` 29 | 30 | 剧情原始数据数组. 31 | 32 | ## dataUrl 33 | 34 | type: `string` 35 | 36 | 资源服务器地址, 用于获取立绘语音等游戏资源. 各资源的具体路径请参照`lib/utils.ts`中的`getResourcesUrl`. 37 | 38 | ## width 39 | 40 | type: `number` 41 | 42 | 播放器宽度, 单位是 px, 可变. 43 | 44 | ## height 45 | 46 | 播放器高度, 单位是 px, 可变. 注意请不要设置偏离 16/9 太多的宽高比, 可能导致播放器表现变差. 47 | 48 | ## storySummary 49 | 50 | type: 51 | 52 | ```ts 53 | export interface StorySummary { 54 | /** 55 | * 章节名 56 | */ 57 | chapterName: string; 58 | /** 59 | * 简介 60 | */ 61 | summary: string; 62 | } 63 | ``` 64 | 65 | ## language 66 | 67 | type: `'Cn'|'Jp'|'En'|'Tw'` 68 | 69 | 语言选项 70 | 71 | ## startFullScreen 72 | 73 | type: `boolean` 74 | 75 | 是否立即全屏, 用于移动端. 76 | 77 | ## useMp3 78 | 79 | type: `boolean` 80 | 81 | 使用 mp3 代替 ogg 格式音频, 用于解决 safari 浏览器的音频解码问题. 82 | 83 | ## useSuperSampling 84 | 85 | type: `boolean` 86 | 87 | 是否使用超分素材, 目前该功能尚未实现, 选项无实际效果. 88 | 89 | # event 90 | 91 | ## end 92 | 93 | 播放结束时发送 94 | 95 | ## error 96 | 97 | 发生错误时发送(播放错误或资源加载错误) 98 | 99 | # 贡献说明 100 | 101 | 请参照[贡献指南](./docs/contribute.md) 102 | 103 | 104 | -------------------------------------------------------------------------------- /docs/contribute.md: -------------------------------------------------------------------------------- 1 | # 概括 2 | 首先非常欢迎, 也非常感谢您愿意为这个兴趣使然的项目添砖加瓦. 3 | 4 | 为方便与项目管理, 请开启新分支进行代码贡献. 5 | 6 | 剧情播放器采用了pixi.js+gsap+vue的技术栈, 编程语言主要为ts. 在贡献代码之前推荐先阅读这些框架与语言的官方文档确保有一定的基础. 7 | 8 | 本剧情播放器采用了事件总线的设计模式, 并将功能模块化, 抽象为一个个层方便协助开发与后续维护. 9 | 10 | 整体架构: 11 | ![baPlayer](./img/player.png) 12 | # 各层简易说明 13 | ## 本体 14 | 本体主要用于更改当前剧情信息. 15 | 16 | 同时, 本体还负责发出事件, 它是各层协同工作的桥梁. 17 | ## store 18 | store充当一个统一的数据交换中心, 获取资源和与其他层资源交互都在这里进行. 19 | ## 特效层 20 | 21 | 特效层用于播放除人物相关特效外的特效 22 | ## 人物层 23 | 24 | 人物层负责处理人物的显示, 人物特效, 人物动作. 25 | 26 | ## 背景层 27 | 28 | 背景层负责背景. 它在改变的同时会更新背景实例信息. 29 | 30 | ## l2d层 31 | l2d层负责l2d的显示 32 | ## UI层 33 | 34 | UI层负责UI的相关功能 35 | ## 文字层 36 | 37 | 文字层负责有对话框文字, 无对话框文字, 选项的显示. 38 | 39 | 文字层同时需要更新历史剧情信息与选项选择信息. 40 | 41 | 42 | # 各层代码贡献说明 43 | 请阅读[接口与事件文档](./layers.md), 在其中获取各层需要处理的事件与数据并实现. 44 | 45 | 目前尚未完成的层: 46 | - 声音层 47 | - 文字层(半成品) 48 | - L2D层(未知) 49 | - 特效层 50 | - UI层 51 | 52 | 对于内容庞大, 持续更新的层, 请根据该层实现者的文档进行代码贡献. 53 | 54 | [人物层](./layers/characterLayer.md) 55 | 56 | [特效层](./layers/effectLayer.md) -------------------------------------------------------------------------------- /docs/img/player.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ba-archive/ba-story-player/5efd761d8f48f4be4510d2e0b1a0e585a21d779a/docs/img/player.png -------------------------------------------------------------------------------- /docs/img/text-position.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ba-archive/ba-story-player/5efd761d8f48f4be4510d2e0b1a0e585a21d779a/docs/img/text-position.png -------------------------------------------------------------------------------- /docs/layers.md: -------------------------------------------------------------------------------- 1 | # 通用 2 | 每层需提供一个init函数用于注册event handler(如果是组件类型则需自行使用eventBus注册). 3 | ## 资源访问 4 | 通过`@/stores`的`usePlayerStore`获取资源工具类, 注意请获取整个对象而不是某个属性, 不然可能会导致数据过时. 5 | 可访问getter: 6 | `app`: pixi-Application实例 7 | ## 事件管理 8 | 通过`eventBus.ts`导入`eventBus`. 9 | 10 | ## 接收事件 11 | `hide`: 清除当前内容 12 | 13 | # 人物层 14 | 人物层负责处理人物的显示, 人物特效, 人物动作. 15 | ## 接收事件 16 | `showCharacter` 17 | 展示人物 18 | 参数: 19 | ```ts 20 | interface ShowCharacter{ 21 | characters: Character[], 22 | characterEffect: CharacterEffect[], 23 | } 24 | interface Character{ 25 | position: number, 26 | CharacterName: number, 27 | face: number, 28 | highlight: boolean 29 | } 30 | 31 | interface CharacterEffect{ 32 | target: number //注意指的是position 33 | type:'emotion'|'action'|'signal'|'fx' 34 | effect: string, 35 | async: boolean 36 | } 37 | ``` 38 | 具体的人物特效请参考[剧情播放器人物特效索引表](https://github.com/ba-archive/blue-archive-story-viewer/issues/32) 39 | ## 发出事件 40 | `characterDone`: 人物各种处理已完成 41 | `playEmotionAudio`: 要求播放人物情绪特效语音 42 | ## 可使用的getter 43 | `characterSpineData`: 根据CharacterName获取spineData 44 | `emotionResources`: 获取人物情绪图片url, 返回一个string数组, 图片的排列按从底部到顶部, 从左到右排列. 45 | ## 需要处理的state 46 | `currentCharacterList: CharacterInstance[]`: 当前显示的人物与其其其 47 | 48 | # 背景层 49 | 背景层负责背景的显示 50 | ## 接收事件 51 | `showBg`: 展示背景, 带一个string参数表示背景图片url 52 | 53 | ## 需要处理的state 54 | `bgInstance`: 背景实例, 通过`setBgInstance`访问 55 | 56 | # 声音层 57 | 声音层负责背景音乐, 效果音, 语音等的播放 58 | ## 接受事件 59 | `playAudio`: 播放bgm, sound, 或voiceJP 60 | 61 | 参数: 62 | ```ts 63 | export interface PlayAudio { 64 | bgm?: { 65 | url?: string 66 | bgmArgs: BGMExcelTableItem 67 | } 68 | soundUrl?: string 69 | voiceJPUrl?: string 70 | } 71 | ``` 72 | `playEmotionAudio`: 播放人物情绪动作特效音, 参数是一个string代表人物的情绪动作 73 | 74 | ## 可使用getter 75 | `otherSoundUrl`: 获取其他声音资源url 76 | 77 | `emotionSoundUrl`: 获取emotion特效对应的特效音 78 | 79 | # UI层 80 | UI层负责UI的相关功能 81 | ## 发出事件 82 | `skip`: 跳过剧情 83 | 84 | `auto`: 启动自动模式 85 | 86 | `stopAuto`: 自动模式停止 87 | 88 | `hideDialog`: 隐藏对话框 89 | 90 | `playOtherSound`: 参数: `select`. 播放选择时的特效音 91 | ## 接受事件 92 | `hidemenu`: 隐藏ui 93 | 94 | `showmenu`: 显示ui 95 | 96 | `option`: 显示选项 97 | 参数: ShowOption[] 98 | ```ts 99 | interface Option { 100 | SelectionGroup: number, 101 | text: string 102 | } 103 | ``` 104 | 105 | ## 可使用getter 106 | `logText`: 已播放剧情语句 107 | ```ts 108 | export interface LogText { 109 | /** 110 | * user: 用户选项 111 | * character: 人物对话, 有头像 112 | * none: 无所属对话, 此时name不存在 113 | */ 114 | type: 'user' | 'character' | 'none' 115 | text: string 116 | /** 117 | * 人物名 118 | */ 119 | name?: string 120 | /** 121 | * 头像地址 122 | */ 123 | avatarUrl?: string 124 | } 125 | ``` 126 | `storySummary`: 剧情梗概 127 | ```ts 128 | export interface StorySummary { 129 | /** 130 | * 章节名 131 | */ 132 | chapterName: string, 133 | /** 134 | * 简介 135 | */ 136 | summary: string 137 | } 138 | ``` 139 | # 文字层 140 | 文字层剧情里没有交互的内容的显示(图片, 文字, 视频等) 141 | ## 发出事件 142 | `titleDone`: 标题显示完成 143 | 144 | `stDone`: st文字显示完成 145 | ## 接收事件 146 | `showTitle`: 显示标题, 接受一个string参数作为标题. 147 | 148 | 示例: [体香1](https://www.bilibili.com/video/BV1qY411f72B?t=10.4) 149 | 150 | `showPlace`: 显示地点, 接受一个string参数作为地点 151 | 152 | 示例: [体香1](https://www.bilibili.com/video/BV1qY411f72B?t=14.0) 153 | 154 | `showText`: 显示普通对话框文字 155 | 参数 156 | ```ts 157 | export interface ShowText { 158 | text: Text[] 159 | speaker?: Speaker 160 | } 161 | interface Text { 162 | content: string 163 | effect: TextEffect[] 164 | waitTime?: number 165 | } 166 | interface TextEffect { 167 | name: 'color'|'fontsize'|'ruby', 168 | value: string[], 169 | textIndex: number 170 | } 171 | ``` 172 | 173 | `st`: 显示无对话框文字 174 | 参数: 175 | ```ts 176 | interface StText{ 177 | text: Text[] 178 | stArgs:[number[],'serial'|'instant',number] 179 | } 180 | 181 | ``` 182 | stArgs: 183 | 第一个参数代表位置, 一般由两个数组成 184 | 位置示意图: 185 | ![](./img/text-position.png) 186 | 注意100单位长度的位置应大于等于文字默认高度 187 | 188 | 第二个参数代表效果, serial打字机效果, instant立即全部显示, smooth表示fade in 189 | 190 | 第三个参数目前尚不明确 191 | 192 | 参数例子:[[-1200,-530],'serial',60](体香1 L2d第一句) 193 | 194 | `clearSt`: 清除无对话框文字 195 | 196 | 197 | `hideDialog`: 隐藏对话框 198 | 199 | `popupImage`: 显示弹出的图片, 参数是图片url 200 | 201 | `popupVideo`: 显示弹出的视频, 参数是视频地址 202 | 203 | `toBeContinue`: 显示未完待续 204 | 205 | `nextEpisode`: 显示下一章节 206 | type 207 | ```js 208 | { 209 | title: string 210 | text: string 211 | } 212 | ``` 213 | ## 发出事件 214 | `next`: 进入下一语句 215 | 216 | `select`: 选择后加入下一剧情语句, 需要带一个number类型的参数 217 | 218 | ## 需要处理的state 219 | `logText`: 已播放剧情语句, 通过`setLogText`进行修改. 220 | # 特效层 221 | 特效层用于播放除人物相关特效外的特效 222 | ## 接收事件 223 | `playEffect`: 播放特效 224 | ## 发出事件 225 | `effectDone`: 特效播放完成时发出的事件 226 | # L2D层 227 | L2D层用于播放L2D 228 | ## live2D 动画 229 | 关于查看 spine 中有的动画, 参考 https://www.bilibili.com/read/cv18073492 230 | ## 发出事件 231 | `animationDone`: 当前l2d动画播放完成 232 | ## 接收事件 233 | `playL2D`: 加载L2D, 播放l2d初始动画 234 | 235 | `changeAnimation`: 更换动画, 接受一个string参数作为动画名 236 | 237 | `endL2D`: 停止L2D 238 | ## 可使用getter 239 | `l2dSpineData`: 获取l2d的spine数据 240 | -------------------------------------------------------------------------------- /docs/layers/characterLayer.md: -------------------------------------------------------------------------------- 1 | 本层提供一个可视化工具方便实现. 2 | 3 | 本层功能分支请采用`feat/characterLayer/**`的格式命名. 4 | 5 | 本层的特效主要分为三个部分`emotion`, `action`, `fx`. 6 | 7 | 贡献流程: 8 | 1. 请先在[剧情播放器特效](https://docs.qq.com/sheet/DQ3doZ0NoUFZ6V0VJ?tab=BB08J2)文档中寻找未实现的特效, 并在实现者加上自己的github id. 9 | 2. 确保自己实现的特效已经在`lib/types/characterLayer.ts`的`EffectsWord`中存在定义, 若不存在请在相应的`Word`部分添加(如`EmotionWord`) 10 | 3. 在同个文件下寻找到自己实现的特效部分的参数定义(如`ActionOptions`), 填入参数定义.其中请注意`emotion`的参数定义为`BasicEmotionOptions` 11 | 4. 请在`lib/layer/characterLayer/options/`文件夹下寻找自己实习部分的参数文件(如actionOptions.ts), 填入参数和参数的解释. 12 | 5. 在同个文件夹下找到想要功能的实现文件(如`emotionPlayer.ts`), 然后在该文件夹export的对象中添加仿照其他功能实现函数添加属性并添加代码. 13 | 6. 实现可视化工具调整参数到满意, 提交功能分支并开启pull request等待审查合并. -------------------------------------------------------------------------------- /docs/layers/effectLayer.md: -------------------------------------------------------------------------------- 1 | 本层中需要协力完成的部分是`BGEffect` 2 | 3 | 本层提供一个简易的可视化工具方便开发. 4 | 5 | # 基本流程 6 | 1. 在[bgEffect特效表](https://docs.qq.com/sheet/DQ1pFSmdHbFRNd3Fu?tab=BB08J2)找到未完成的特效并在实现者中填入自己的github id. 7 | 2. 根据[图像资源获取](#图片资源获取)获取图像资源 8 | 3. (可选) 中`@/types/effectLayer.ts`的`BGEffectHandlerOptions`中填入参数定义, 例子: 9 | ```js 10 | let BGEffectHandlerOptions={ 11 | 'BG_Test':{ 12 | testOptions1: number 13 | } 14 | } 15 | ``` 16 | 4. 在`@/layers/effectLayer/effectFunctions/`文件夹中添加自己的实现并设置为默认导出, 需要命名为实现的特效名, 例子: 17 | ```js 18 | //BG_test.ts 19 | import { usePlayerStore } from "@/stores" 20 | import { BGEffectHandlerFunction } from "@/types/effectLayer" 21 | import { Emitter, EmitterConfigV2, upgradeConfig } from "@pixi/particle-emitter" 22 | import { emitterConfigs, emitterContainer, emitterStarter } from "../emitterUtils" 23 | 24 | const handler: BGEffectHandlerFunction<'BG_Test'> = async function (resources, setting, options) { 25 | ... 26 | return removeFunction 27 | } 28 | 29 | export default handler 30 | //请符合BGEffectHandlerFunction规范 31 | //注意需要返回一个特效移除函数 32 | ``` 33 | 34 | 5. 功能测试无异常后在[bgEffect特效表](https://docs.qq.com/sheet/DQ1pFSmdHbFRNd3Fu?tab=BB08J2)中自己github id后加入`(已完成)` 35 | 36 | # 特效文档完善 37 | [bgEffect特效表](https://docs.qq.com/sheet/DQ1pFSmdHbFRNd3Fu?tab=BB08J2)中可能没有效果示例位置, 可按如下方法获取并填入完善: 38 | 39 | 1. 在[BGEffect table](https://github.com/aizawey479/ba-data/blob/jp/Excel/ScenarioBGEffectExcelTable.json)中搜索得到当前effect的id(也可在可视化工具中获取) 40 | 2. 在[主线剧情](https://github.com/aizawey479/ba-data/blob/jp/Excel/ScenarioScriptMain2ExcelTable.json)中搜索得到effect出现位置(地址main后数字可替换) 41 | 3. 在[威威的视频](https://www.bilibili.com/list/7045822?sid=1061322&desc=1&oid=765681436&bvid=BV1Zr4y1v7ZT)中找到找到对应出现位置, 复制视频详细位置信息填入表中 42 | 43 | # 图片资源获取 44 | 请在[特效资源后端资源地址](https://yuuka.diyigemt.com/files/ba-all-data/effectTexture/)(账号密码群中自行获取)中根据特效名寻找素材. 如找不到或原素材使用困难请自行上网寻找素材. 45 | 46 | 找到后请填入`@/stores/index.ts`中的`bgEffectImgTable`, 例子: 47 | ```js 48 | let bgEffectImgTable: BGEffectImgTable = { 49 | 'BG_ScrollT_0.5': ['img1.png','img2.png'], 50 | } 51 | ``` 52 | 53 | # utils 54 | ## emitter 55 | 提供常量 56 | - emitterContainr 放置emitter的container 57 | 58 | 提供函数 59 | - emitterStarter 开启emitter并返回终止函数 60 | 61 | 设置路径 62 | 63 | 请将emitter设置的json放入`@/layers/effectLayer/emitterConfigs/`, 然后使用`emitterConfigs(filename)`获取config. 64 | ## resources 65 | 提供函数 66 | - sprite2TransParent 将黑色背景转为透明 67 | - loadSpriteSheet 自动分割图片并生成spritesheet 68 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | player 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /lib/eventBus.ts: -------------------------------------------------------------------------------- 1 | import mitt from "mitt"; 2 | import { Events } from "@/types/events"; 3 | 4 | const eventBus = mitt(); 5 | export default eventBus; 6 | -------------------------------------------------------------------------------- /lib/layers/bgLayer/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 初始化背景层, 订阅player的剧情信息. 3 | */ 4 | import { Sprite, LoaderResource, Application } from "pixi.js"; 5 | import gsap from "gsap"; 6 | 7 | import { BgLayer } from "@/types/bgLayer"; 8 | import { Dict } from "@/types/common"; 9 | import { usePlayerStore } from "@/stores"; 10 | import eventBus from "@/eventBus"; 11 | 12 | export function bgInit() { 13 | return BgLayerInstance.init(); 14 | } 15 | 16 | const BgLayerInstance: BgLayer = { 17 | /** 18 | * 注册/销毁实例 19 | */ 20 | init() { 21 | this.initEvent(); 22 | }, 23 | dispose() { 24 | this.disposeEvent(); 25 | }, 26 | 27 | /** 28 | * 注册/销毁事件监听 29 | */ 30 | initEvent() { 31 | this.handleShowBg = this.handleShowBg.bind(this); 32 | 33 | eventBus.on("showBg", this.handleShowBg); 34 | eventBus.on("resize", this.handleResize); 35 | }, 36 | disposeEvent() { 37 | eventBus.off("showBg", this.handleShowBg); 38 | }, 39 | 40 | /** 41 | * 事件监听处理函数 42 | */ 43 | handleShowBg({ url, overlap }) { 44 | const { 45 | app: { loader }, 46 | } = usePlayerStore(); 47 | 48 | loader.load((loader, resources) => { 49 | const instance = this.getBgSpriteFromResource(resources, url); 50 | 51 | if (instance) { 52 | if (overlap) { 53 | this.loadBgOverlap(instance, overlap); 54 | } else { 55 | this.loadBg(instance); 56 | } 57 | } 58 | }); 59 | }, 60 | 61 | handleResize() { 62 | const { bgInstance, app } = usePlayerStore(); 63 | if (bgInstance) { 64 | const { x, y, scale } = calcBackgroundImageSize(bgInstance, app); 65 | bgInstance.position.set(x, y); 66 | bgInstance.scale.set(scale); 67 | } 68 | }, 69 | 70 | /** 71 | * 方法 72 | */ 73 | getBgSpriteFromResource(resources: Dict, name: string) { 74 | const { app } = usePlayerStore(); 75 | let sprite: Sprite | null = null; 76 | 77 | if (!resources[name]) { 78 | console.error(`can't find resource: ${name}`); 79 | return; 80 | } 81 | 82 | sprite = new Sprite(resources[name].texture); 83 | 84 | const { x, y, scale } = calcBackgroundImageSize(sprite, app); 85 | sprite.position.set(x, y); 86 | sprite.scale.set(scale); 87 | return sprite; 88 | }, 89 | loadBg(instance: Sprite) { 90 | const { app, bgInstance: oldInstance, setBgInstance } = usePlayerStore(); 91 | 92 | instance.zIndex = -100; // 背景层应该在特效, 人物层之下 93 | app.stage.addChild(instance); 94 | setBgInstance(instance); 95 | 96 | oldInstance && app.stage.removeChild(oldInstance); 97 | }, 98 | async loadBgOverlap(instance: Sprite, overlap: number) { 99 | const { app, bgInstance: oldInstance, setBgInstance } = usePlayerStore(); 100 | let tl = gsap.timeline(); 101 | instance.zIndex = -99; 102 | 103 | app.stage.addChild(instance); 104 | setBgInstance(instance); 105 | 106 | await tl.fromTo( 107 | instance, 108 | { alpha: 0 }, 109 | { alpha: 1, duration: overlap / 1000 } 110 | ); 111 | eventBus.emit("bgOverLapDone"); 112 | 113 | oldInstance && app.stage.removeChild(oldInstance); 114 | }, 115 | }; 116 | 117 | const StandardWith = 1902; 118 | const StandardWithPadding = 64; 119 | /** 120 | * 计算图片 cover 样式尺寸 - utils 121 | */ 122 | export function calcBackgroundImageSize(background: Sprite, app: Application) { 123 | // 计算规则 124 | // 1.优先满足纵向宽度 125 | // 2.带上padding, 大小为1920px: 64px 126 | // **不能用stage的height和width** 他们可以超出视口 127 | const viewportWidth = app.screen.width; 128 | const viewportHeight = app.screen.height; 129 | const rawWidth = background.width / background.scale.x; 130 | const rawHeight = background.height / background.scale.y; 131 | const padding = (rawWidth / StandardWith) * StandardWithPadding; 132 | const finalWidth = viewportWidth + padding * 2; 133 | const scale = finalWidth / rawWidth; 134 | const finalHeight = rawHeight * scale; 135 | const x = -((finalWidth - viewportWidth) / 2); 136 | const y = -((finalHeight - viewportHeight) / 2); 137 | 138 | return { x, y, scale }; 139 | } 140 | -------------------------------------------------------------------------------- /lib/layers/characterLayer/fxPlayer.ts: -------------------------------------------------------------------------------- 1 | import { usePlayerStore } from "@/stores"; 2 | import { 3 | CharacterEffectInstance, 4 | CharacterFXPlayer, 5 | PositionOffset, 6 | } from "@/types/characterLayer"; 7 | import { Sprite } from "pixi.js"; 8 | import fxOptions from "./options/fxOptions"; 9 | import gsap from "gsap"; 10 | import { getStandardWidth } from "."; 11 | import { AdjustmentFilter } from "@pixi/filter-adjustment"; 12 | 13 | const CharacterFXPlayerInstance: CharacterFXPlayer = { 14 | init() { 15 | return; 16 | }, 17 | dispose(): void {}, 18 | getHandlerFunction(type) { 19 | return Reflect.get(this, type); 20 | }, 21 | processEffect(type, instance) { 22 | const fn = this.getHandlerFunction(type); 23 | if (!fn) { 24 | return Promise.reject("该effect不存在或未实现"); 25 | } 26 | const { fxImages, app } = usePlayerStore(); 27 | let fxImageSprites: Sprite[] = []; 28 | let currentFxImgs = fxImages(type); 29 | if (!currentFxImgs) { 30 | Promise.reject(`fx中${type}对应的图像资源不存在`); 31 | } 32 | for (let imageResource of currentFxImgs!) { 33 | let tempSprite = Sprite.from(imageResource); 34 | tempSprite.visible = false; 35 | instance.instance.addChild(tempSprite); 36 | fxImageSprites.push(tempSprite); 37 | } 38 | return fn(instance, fxOptions[type], fxImageSprites) as Promise; 39 | }, 40 | shot(instance, options, sprites) { 41 | let scale = (options.scale * getStandardWidth()) / sprites[0].width; 42 | 43 | let tl = gsap.timeline(); 44 | for (let [index, sequence] of options.shotSequence.entries()) { 45 | let img = Sprite.from(sprites[sequence.startImg].texture); 46 | img.scale.set(scale * sequence.scale); 47 | img.angle = sequence.angle; 48 | img.zIndex = 10; 49 | let adjustmentFilter = new AdjustmentFilter({ 50 | brightness: 3, 51 | alpha: 0.5, 52 | }); 53 | img.filters = [adjustmentFilter]; 54 | img.visible = false; 55 | instance.instance.addChild(img); 56 | setPos(instance, img, sequence.pos); 57 | tl.to( 58 | img, 59 | { 60 | duration: options.shotDuration, 61 | onComplete() { 62 | if (sequence.endRed) { 63 | adjustmentFilter.green = 0.3; 64 | adjustmentFilter.blue = 0; 65 | } else { 66 | img.texture = sprites[sequence.endImg!].texture; 67 | } 68 | setTimeout(() => (img.visible = false), 10); 69 | }, 70 | onStart() { 71 | img.visible = true; 72 | }, 73 | }, 74 | index * 0.07 75 | ); 76 | } 77 | 78 | return timelinePromise(tl, sprites); 79 | }, 80 | }; 81 | 82 | /** 83 | * 设置图片相对于人物位置 84 | * @param instance 85 | * @param img 86 | * @param pos 87 | * @returns 88 | */ 89 | function setPos( 90 | instance: CharacterEffectInstance, 91 | img: Sprite, 92 | pos: PositionOffset 93 | ) { 94 | let standardWidth = getStandardWidth(); 95 | let finalPos = { 96 | x: standardWidth * pos.x, 97 | y: standardWidth * pos.y, 98 | }; 99 | img.position = finalPos; 100 | 101 | return pos; 102 | } 103 | 104 | /** 105 | * timeline执行后生成一个promise并自动回收sprite 106 | * @param timeLine 执行的timeline 107 | * @param destroyImgs 要回收的sprite对象数组 108 | * @returns 生成的promise 109 | */ 110 | function timelinePromise( 111 | timeLine: gsap.core.Timeline, 112 | destroyImgs: Sprite[], 113 | callback?: () => any 114 | ) { 115 | return new Promise((resolve, reject) => { 116 | timeLine 117 | .then(() => { 118 | resolve(); 119 | for (let img of destroyImgs) { 120 | img.destroy(); 121 | } 122 | }) 123 | .catch(reason => reject(reason)); 124 | }); 125 | } 126 | 127 | export default CharacterFXPlayerInstance; 128 | -------------------------------------------------------------------------------- /lib/layers/characterLayer/options/actionOptions.ts: -------------------------------------------------------------------------------- 1 | import { ActionOptions, OptionDescriptions } from "@/types/characterLayer"; 2 | 3 | /** 4 | * 立绘移动速度 5 | */ 6 | export const moveSpeed = 2.4; 7 | 8 | export let actionDescriptions: OptionDescriptions["action"] = { 9 | a: {}, 10 | d: { 11 | duration: "消失动画的时间", 12 | }, 13 | dl: { 14 | speed: "移动速度", 15 | }, 16 | dr: { 17 | speed: "移动速度", 18 | }, 19 | ar: { 20 | speed: "移动速度", 21 | }, 22 | al: { 23 | speed: "移动速度", 24 | }, 25 | hophop: { 26 | yOffset: "跳动的高度", 27 | duration: "跳动一次的时间", 28 | }, 29 | greeting: { 30 | yOffset: "移动的高度", 31 | duration: "移动所花的时间", 32 | }, 33 | shake: { 34 | shakeAnimation: "晃动动画的参数, 包括起始点终结点, 时长, 重复次数", 35 | }, 36 | m1: {}, 37 | m2: {}, 38 | m3: {}, 39 | m4: {}, 40 | m5: {}, 41 | stiff: { 42 | shakeAnimation: "晃动动画的参数, 包括起始点终结点, 时长, 重复次数", 43 | }, 44 | closeup: { 45 | scale: "相对于原来大小的缩放比例", 46 | }, 47 | jump: { 48 | yOffset: "跳动的高度", 49 | duration: "跳动一次的时间", 50 | }, 51 | falldownR: { 52 | rightAngle: "向右旋转的角度", 53 | leftAngle: "向左旋转的角度", 54 | firstRotateDuration: "第一个向右旋转的时间", 55 | falldownDuration: "向下移动的时间", 56 | anchor: "旋转原点", 57 | xOffset: "在x轴方向移动的距离", 58 | leftRotationPercent: "左转动画在下降动画中的时间比例", 59 | }, 60 | falldownL: { 61 | rightAngle: "向右旋转的角度", 62 | leftAngle: "向左旋转的角度", 63 | firstRotateDuration: "第一个向右旋转的时间", 64 | falldownDuration: "向下移动的时间", 65 | anchor: "旋转原点", 66 | xOffset: "在x轴方向移动的距离", 67 | leftRotationPercent: "左转动画在下降动画中的时间比例", 68 | }, 69 | hide: {}, 70 | }; 71 | 72 | let actionOptions: ActionOptions = { 73 | a: {}, 74 | d: { 75 | duration: 0.6, 76 | }, 77 | dl: { 78 | speed: moveSpeed, 79 | }, 80 | dr: { 81 | speed: moveSpeed, 82 | }, 83 | ar: { 84 | speed: moveSpeed, 85 | }, 86 | al: { 87 | speed: moveSpeed, 88 | }, 89 | hophop: { 90 | yOffset: 0.05, 91 | duration: 0.25, 92 | }, 93 | greeting: { 94 | yOffset: -0.04, 95 | duration: 0.4, 96 | }, 97 | shake: { 98 | shakeAnimation: { 99 | from: -0.03, 100 | to: 0.02, 101 | duration: 0.08, 102 | repeat: 2, 103 | }, 104 | }, 105 | m1: {}, 106 | m2: {}, 107 | m3: {}, 108 | m4: {}, 109 | m5: {}, 110 | stiff: { 111 | shakeAnimation: { 112 | from: -0.01, 113 | to: 0.01, 114 | duration: 0.08, 115 | repeat: 4, 116 | }, 117 | }, 118 | closeup: { 119 | scale: 1.5, 120 | }, 121 | jump: { 122 | yOffset: 0.03, 123 | duration: 0.25, 124 | }, 125 | falldownR: { 126 | rightAngle: 10, 127 | anchor: { 128 | x: 0.4, 129 | y: 0.5, 130 | }, 131 | leftAngle: -3, 132 | firstRotateDuration: 0.4, 133 | falldownDuration: 0.55, 134 | xOffset: 0.13, 135 | leftRotationPercent: 0.2, 136 | }, 137 | falldownL: { 138 | rightAngle: 3, 139 | leftAngle: -10, 140 | anchor: { 141 | x: 0.4, 142 | y: 0.5, 143 | }, 144 | firstRotateDuration: 0.4, 145 | falldownDuration: 0.55, 146 | xOffset: -0.13, 147 | leftRotationPercent: 0.2, 148 | }, 149 | hide: {}, 150 | }; 151 | 152 | export default actionOptions; 153 | -------------------------------------------------------------------------------- /lib/layers/characterLayer/options/fxOptions.ts: -------------------------------------------------------------------------------- 1 | import { FXOptions, OptionDescriptions } from "@/types/characterLayer"; 2 | 3 | export let fxOptionsDescriptions: OptionDescriptions["fx"] = { 4 | shot: { 5 | scale: "图片缩放大小", 6 | shotDuration: "每次射击显示效果的时长", 7 | shotSequence: "每次击中效果的设置", 8 | }, 9 | }; 10 | 11 | let fxOptions: FXOptions = { 12 | shot: { 13 | scale: 0.2, 14 | shotDuration: 0.1, 15 | shotSequence: [ 16 | { 17 | startImg: 1, 18 | endRed: true, 19 | pos: { 20 | x: -0.3, 21 | y: -1.6, 22 | }, 23 | angle: -20, 24 | scale: 1, 25 | }, 26 | { 27 | startImg: 2, 28 | endRed: true, 29 | pos: { 30 | x: 0.2, 31 | y: -1.7, 32 | }, 33 | angle: 10, 34 | scale: 1, 35 | }, 36 | { 37 | startImg: 0, 38 | endRed: true, 39 | pos: { 40 | x: -0.6, 41 | y: -2.1, 42 | }, 43 | angle: -30, 44 | scale: 1.2, 45 | }, 46 | { 47 | startImg: 1, 48 | endRed: false, 49 | endImg: 2, 50 | pos: { 51 | x: 0.7, 52 | y: -2.4, 53 | }, 54 | angle: 80, 55 | scale: 1.2, 56 | }, 57 | ], 58 | }, 59 | }; 60 | 61 | export default fxOptions; 62 | -------------------------------------------------------------------------------- /lib/layers/effectLayer/bgEffectHandlers.ts: -------------------------------------------------------------------------------- 1 | import { usePlayerStore } from "@/stores"; 2 | import { 3 | BGEffectHandlerFunction, 4 | BGEffectHandlerOptions, 5 | CurrentBGEffect, 6 | } from "@/types/effectLayer"; 7 | import { BGEffectExcelTableItem, BGEffectType } from "@/types/excels"; 8 | import { Sprite } from "pixi.js"; 9 | 10 | const effectFunctionsRaw = import.meta.glob<{ 11 | default: BGEffectHandlerFunction; 12 | }>("./effectFunctions/*", { eager: true }); 13 | function getEffectFunctions(functionName: string) { 14 | const effectFunction = Reflect.get( 15 | effectFunctionsRaw, 16 | `./effectFunctions/${functionName}.ts` 17 | ); 18 | if (!effectFunction) { 19 | return undefined; 20 | } 21 | return effectFunction.default; 22 | } 23 | 24 | /** 25 | * 当前播放的BGEffect 26 | */ 27 | let currentBGEffect: CurrentBGEffect; 28 | 29 | /** 30 | * 播放对应的BGEffect 31 | * @param bgEffectItem 32 | * @returns 33 | */ 34 | export async function playBGEffect(bgEffectItem: BGEffectExcelTableItem) { 35 | console.log(bgEffectHandlers); 36 | const effect = bgEffectItem.Effect; 37 | //此特效正在播放, 无需处理, 先移除保证开发便利 38 | // if (effect === currentBGEffect?.effect) { 39 | // return 40 | // } 41 | await removeBGEffect(); 42 | let resources = usePlayerStore().bgEffectImgMap.get(effect); 43 | if (resources) { 44 | let imgs: Sprite[] = []; 45 | for (let resource of resources) { 46 | imgs.push(Sprite.from(resource)); 47 | } 48 | let handler = bgEffectHandlers[effect]; 49 | let removeFunction: any; 50 | try { 51 | removeFunction = await Reflect.apply(handler, undefined, [ 52 | imgs, 53 | bgEffectItem, 54 | bgEffectHandlerOptions[effect], 55 | ]); 56 | } catch (e) { 57 | console.error(`执行 ${effect} 时发生错误`, e); 58 | } 59 | currentBGEffect = { 60 | effect, 61 | removeFunction, 62 | resources: imgs, 63 | }; 64 | } 65 | } 66 | 67 | /** 68 | * 移除当前的BGEffect 69 | */ 70 | export async function removeBGEffect() { 71 | if (currentBGEffect) { 72 | await currentBGEffect.removeFunction(); 73 | for (let resource of currentBGEffect.resources) { 74 | resource.destroy(); 75 | } 76 | currentBGEffect = undefined; 77 | } 78 | } 79 | 80 | const bgEffects = [ 81 | "BG_ScrollT_0.5", 82 | "BG_Filter_Red", 83 | "BG_Wave_F", 84 | "BG_Flash", 85 | "BG_UnderFire_R", 86 | "BG_Love_L", 87 | "BG_ScrollB_0.5", 88 | "BG_Rain_L", 89 | "BG_UnderFire", 90 | "BG_WaveShort_F", 91 | "BG_SandStorm_L", 92 | "", 93 | "BG_ScrollT_1.5", 94 | "BG_Shining_L", 95 | "BG_ScrollB_1.0", 96 | "BG_Love_L_BGOff", 97 | "BG_Dust_L", 98 | "BG_ScrollL_0.5", 99 | "BG_ScrollL_1.0", 100 | "BG_Ash_Black", 101 | "BG_Mist_L", 102 | "BG_Flash_Sound", 103 | "BG_ScrollL_1.5", 104 | "BG_FocusLine", 105 | "BG_ScrollR_1.5", 106 | "BG_Shining_L_BGOff", 107 | "BG_ScrollT_1.0", 108 | "BG_ScrollB_1.5", 109 | "BG_Filter_Red_BG", 110 | "BG_Ash_Red", 111 | "BG_Fireworks_L_BGOff_02", 112 | "BG_ScrollR_0.5", 113 | "BG_Snow_L", 114 | "BG_Fireworks_L_BGOff_01", 115 | "BG_ScrollR_1.0", 116 | ]; 117 | export let bgEffectHandlers: Record< 118 | string, 119 | BGEffectHandlerFunction 120 | > = {}; 121 | for (const effect of bgEffects) { 122 | const handler = getEffectFunctions(effect); 123 | if (handler) { 124 | Reflect.set(bgEffectHandlers, effect, handler); 125 | } else { 126 | Reflect.set(bgEffectHandlers, effect, async () => { 127 | throw new Error("未找到该bgEffect实现"); 128 | }); 129 | } 130 | } 131 | 132 | /** 133 | * 处理函数的对应参数 134 | */ 135 | export const bgEffectHandlerOptions: BGEffectHandlerOptions = { 136 | BG_FocusLine: {}, 137 | "": {}, 138 | "BG_ScrollT_0.5": {}, 139 | BG_Filter_Red: {}, 140 | BG_Wave_F: {}, 141 | BG_Flash: {}, 142 | BG_UnderFire_R: {}, 143 | BG_Love_L: {}, 144 | "BG_ScrollB_0.5": {}, 145 | BG_Rain_L: { 146 | frequency: 0.05, 147 | }, 148 | BG_UnderFire: {}, 149 | BG_WaveShort_F: {}, 150 | BG_SandStorm_L: {}, 151 | "BG_ScrollT_1.5": {}, 152 | BG_Shining_L: {}, 153 | "BG_ScrollB_1.0": {}, 154 | BG_Love_L_BGOff: {}, 155 | BG_Dust_L: {}, 156 | "BG_ScrollL_0.5": {}, 157 | "BG_ScrollL_1.0": {}, 158 | BG_Ash_Black: {}, 159 | BG_Mist_L: {}, 160 | BG_Flash_Sound: {}, 161 | "BG_ScrollL_1.5": {}, 162 | "BG_ScrollR_1.5": {}, 163 | BG_Shining_L_BGOff: {}, 164 | "BG_ScrollT_1.0": {}, 165 | "BG_ScrollB_1.5": {}, 166 | BG_Filter_Red_BG: {}, 167 | BG_Ash_Red: {}, 168 | BG_Fireworks_L_BGOff_02: {}, 169 | "BG_ScrollR_0.5": {}, 170 | BG_Snow_L: {}, 171 | BG_Fireworks_L_BGOff_01: {}, 172 | "BG_ScrollR_1.0": {}, 173 | }; 174 | -------------------------------------------------------------------------------- /lib/layers/effectLayer/effectFunctions/BG_Dust_L.ts: -------------------------------------------------------------------------------- 1 | import { usePlayerStore } from "@/stores"; 2 | import { EmitterConfigV3, Emitter } from "@pixi/particle-emitter"; 3 | import { Container, Sprite, TilingSprite } from "pixi.js"; 4 | import { 5 | emitterConfigs, 6 | emitterContainer, 7 | emitterStarter, 8 | } from "../emitterUtils"; 9 | import { 10 | getEmitterType, 11 | loadSpriteSheet, 12 | sprite2TransParent, 13 | } from "../resourcesUtils"; 14 | 15 | export default async function BG_Dust_L(resources: Sprite[]) { 16 | // 原理是三个平铺图片不断移动, 加上火光粒子效果 17 | const { app } = usePlayerStore(); 18 | const appWidth = app.view.width; 19 | const appHeight = app.view.height; 20 | let smokeAnimationsName = "dust_smoke"; 21 | 22 | let smokeSpritesheet = await loadSpriteSheet( 23 | resources[0], 24 | { x: 1, y: 2 }, 25 | smokeAnimationsName 26 | ); 27 | const smokeTexture = Reflect.get( 28 | smokeSpritesheet.animations, 29 | smokeAnimationsName 30 | )[0]; 31 | const smokeTextureTilingL = new TilingSprite(smokeTexture); 32 | const smokeTextureTilingR = new TilingSprite(smokeTexture); 33 | const smokeTextureTilingR1 = new TilingSprite(smokeTexture); 34 | // 算出一个当前渲染中最长的长度 35 | const smokeWidth = Math.sqrt(appWidth * appWidth + appHeight * appHeight); 36 | // 高度应该是当前分切图片的高度 37 | const smokeHeight = smokeTexture.height; 38 | const scale = (appHeight / smokeHeight) * 0.6; 39 | [smokeTextureTilingL, smokeTextureTilingR, smokeTextureTilingR1].forEach( 40 | i => { 41 | // 避免 tiling 产生的像素 42 | i.clampMargin = 1.5; 43 | i.rotation = 0.55; 44 | i.tint = 0x4c413f; 45 | i.width = smokeWidth; 46 | i.height = smokeHeight; 47 | i.scale.set(scale); 48 | app.stage.addChild(i); 49 | } 50 | ); 51 | smokeTextureTilingL.x = -(appWidth * 0.01); 52 | smokeTextureTilingL.y = appHeight - smokeHeight * scale; 53 | // 放大, 避免下方出现空隙 54 | smokeTextureTilingR.rotation = -0.35; 55 | smokeTextureTilingR.scale.set(1.2 * scale); 56 | smokeTextureTilingR.x = appWidth / 2 - appWidth * 0.08; 57 | smokeTextureTilingR.y = appHeight - appHeight * 0.08; 58 | // 角度高一点, 错乱一点, 避免和 R 一致, R1 是后边的图, 并且R1要在人物层之后 59 | smokeTextureTilingR1.rotation = -0.75; 60 | smokeTextureTilingR1.zIndex = -1; 61 | smokeTextureTilingR1.x = appWidth / 2 - appWidth * 0.05; 62 | smokeTextureTilingR1.y = appHeight - appHeight * 0.02; 63 | let smokeRemover = emitterStarter({ 64 | update: () => { 65 | // 向左 66 | smokeTextureTilingL.tilePosition.x -= 1; 67 | smokeTextureTilingR.tilePosition.x += 1; 68 | smokeTextureTilingR1.tilePosition.x += 1; 69 | }, 70 | destroy: () => { 71 | [smokeTextureTilingL, smokeTextureTilingR, smokeTextureTilingR1].forEach( 72 | i => { 73 | app.stage.removeChild(i); 74 | } 75 | ); 76 | }, 77 | } as any); 78 | // 火光粒子特效 79 | let fireContainer = new Container(); 80 | emitterContainer.addChild(fireContainer); 81 | fireContainer.zIndex = 100; 82 | const transParentSprite = resources[1]; 83 | let fireConfig: EmitterConfigV3 = { 84 | ...(emitterConfigs("dust_fire") as EmitterConfigV3), 85 | }; 86 | fireConfig.pos = { 87 | x: 30, 88 | y: appHeight - 20, 89 | }; 90 | let fireAnimationsName = "dust_fire"; 91 | let fireSpritesheet = await loadSpriteSheet( 92 | transParentSprite, 93 | { x: 1, y: 3 }, 94 | fireAnimationsName 95 | ); 96 | const fireTextures = Reflect.get( 97 | fireSpritesheet.animations, 98 | fireAnimationsName 99 | ); 100 | // 塞入随机 texture 中 101 | fireConfig.behaviors[2].config.textures.push(...fireTextures); 102 | const baseRatio = (0.05 * appWidth) / fireTextures[0].width; 103 | const scaleConfig = getEmitterType(fireConfig, "scale").config; 104 | scaleConfig.scale.list[0].value = baseRatio; 105 | scaleConfig.scale.list[1].value = baseRatio * 0.8; 106 | scaleConfig.scale.list[2].value = baseRatio; 107 | const speedConfig = getEmitterType(fireConfig, "moveSpeedStatic").config; 108 | speedConfig.min = appHeight * 0.4; 109 | speedConfig.max = appHeight * 0.65; 110 | let fireEmitter = new Emitter(fireContainer, fireConfig); 111 | setTimeout(() => { 112 | fireEmitter.maxParticles = 15; 113 | }, 1500); 114 | let fireRemover = emitterStarter(fireEmitter); 115 | return async () => { 116 | await smokeRemover(); 117 | await fireRemover(); 118 | }; 119 | } 120 | -------------------------------------------------------------------------------- /lib/layers/effectLayer/effectFunctions/BG_Flash_Sound.ts: -------------------------------------------------------------------------------- 1 | import { Sprite, AnimatedSprite } from "pixi.js"; 2 | import { loadSpriteSheet } from "../resourcesUtils"; 3 | import { usePlayerStore } from "@/stores"; 4 | import { gsap } from "gsap"; 5 | import { AdvancedBloomFilter } from "@pixi/filter-advanced-bloom"; 6 | import eventBus from "@/eventBus"; 7 | 8 | export default async function flash_sound(resources: Sprite[]) { 9 | const animationName = "lightning"; 10 | const lightningLineSpriteSheet = await loadSpriteSheet( 11 | resources[0], 12 | { x: 1, y: 4 }, 13 | animationName 14 | ); 15 | const lightningAnimation = new AnimatedSprite( 16 | lightningLineSpriteSheet.animations[animationName] 17 | ); 18 | lightningAnimation.animationSpeed = 0.3; 19 | 20 | const app = usePlayerStore().app; 21 | const scale = app.screen.width / lightningAnimation.width; 22 | const scaleYSmall = scale * 0.2; 23 | const scaleYLarge = scale * 0.4; 24 | lightningAnimation.scale.set(scale, scaleYSmall); 25 | lightningAnimation.x = app.screen.width; 26 | lightningAnimation.y = (app.screen.height * 5) / 12; 27 | lightningAnimation.anchor.set(0, 0.5); 28 | const bloomFilter = new AdvancedBloomFilter({ brightness: 1, blur: 4 }); 29 | lightningAnimation.filters = [bloomFilter]; 30 | app.stage.addChild(lightningAnimation); 31 | 32 | eventBus.emit("playBgEffectSound", "BG_Flash_Sound"); 33 | const tl = gsap.timeline(); 34 | await tl 35 | .to(lightningAnimation, { 36 | pixi: { x: 0, scaleY: scaleYLarge }, 37 | duration: 7 / 60, 38 | }) 39 | .to(lightningAnimation, { 40 | onStart: () => { 41 | lightningAnimation.visible = false; 42 | }, 43 | duration: 9 / 60, 44 | }) 45 | .to(lightningAnimation, { 46 | onStart: () => { 47 | lightningAnimation.visible = true; 48 | lightningAnimation.play(); 49 | }, 50 | pixi: { scaleY: scaleYSmall }, 51 | duration: 41 / 60, 52 | }); 53 | 54 | lightningAnimation.destroy(); 55 | 56 | return async () => {}; 57 | } 58 | -------------------------------------------------------------------------------- /lib/layers/effectLayer/effectFunctions/BG_FocusLine.ts: -------------------------------------------------------------------------------- 1 | import eventBus from "@/eventBus"; 2 | import { usePlayerStore } from "@/stores"; 3 | import { Emitter, EmitterConfigV3, Particle } from "@pixi/particle-emitter"; 4 | import { BehaviorOrder } from "@pixi/particle-emitter/lib/behaviors"; 5 | import { Container, Sprite } from "pixi.js"; 6 | import { emitterConfigs, emitterStarter } from "../emitterUtils"; 7 | import { getEmitterType, sprite2TransParent } from "../resourcesUtils"; 8 | 9 | export default async function BG_FocusLine(resources: Sprite[]) { 10 | // 原理是线条 emitter 11 | eventBus.emit("playBgEffectSound", "BG_FocusLine"); 12 | const { app } = usePlayerStore(); 13 | const appWidth = app.view.width; 14 | const appHeight = app.view.height; 15 | let emitterContainer = new Container(); 16 | app.stage.addChild(emitterContainer); 17 | emitterContainer.zIndex = -1; 18 | const centerPoint = [appWidth / 2, appHeight / 2].map(i => parseInt(i + "")); 19 | class FocusLine { 20 | public static type = "focusLine"; 21 | public order = 5; // 代表延迟执行, 可能是 emitter 包的问题, 引入定义报错 22 | /** 第一个点是两条线段的连接点 */ 23 | getAngle = (pointArr: { x: number; y: number }[]) => { 24 | const [p1, p2, p3] = pointArr; 25 | const x1 = p2.x - p1.x; 26 | const x2 = p3.x - p1.x; 27 | const y1 = p2.y - p1.y; 28 | const y2 = p3.y - p1.y; 29 | const dot = x1 * x2 + y1 * y2; 30 | const det = x1 * y2 - y1 * x2; 31 | const angle = (Math.atan2(det, dot) / Math.PI) * 180; 32 | return (angle + 360) % 360; 33 | }; 34 | initParticles(first: Particle): void { 35 | let next = first; 36 | while (next) { 37 | const { x, y } = next; 38 | const angle = this.getAngle([ 39 | { x, y }, 40 | { x: 1, y }, 41 | { x: centerPoint[0], y: centerPoint[1] }, 42 | ]); 43 | let transAngle = 0; // 最左边不用转 44 | if (x > 0 && y === 0) { 45 | transAngle = 180; 46 | } 47 | if (x > 0 && y === appHeight) { 48 | transAngle = 180; 49 | } 50 | if (x === appWidth && y > 0) { 51 | transAngle = 180; 52 | } 53 | next.angle = angle - transAngle; 54 | next.width = 55 | Math.random() * (appWidth * (0.24 - 0.037)) + appWidth * 0.037; // 0.037 - 0.24 之间 56 | next = next.next; 57 | } 58 | } 59 | } 60 | Emitter.registerBehavior(FocusLine); 61 | let emitterConfig: EmitterConfigV3 = { 62 | ...(emitterConfigs("focusline") as EmitterConfigV3), 63 | }; 64 | const sprite = sprite2TransParent(resources[0]); 65 | getEmitterType(emitterConfig, "textureRandom").config.textures.push( 66 | sprite.texture 67 | ); 68 | const shapeData = getEmitterType(emitterConfig, "spawnShape").config.data[0]; 69 | shapeData[1].y = appHeight; 70 | shapeData[2].x = appWidth; 71 | shapeData[2].y = appHeight; 72 | shapeData[3].x = appWidth; 73 | const ringEmitter = new Emitter(emitterContainer, emitterConfig); 74 | const ringRemover = emitterStarter(ringEmitter); 75 | return async () => { 76 | await ringRemover(); 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /lib/layers/effectLayer/effectFunctions/BG_Love_L.ts: -------------------------------------------------------------------------------- 1 | import { usePlayerStore } from "@/stores"; 2 | import { Emitter, EmitterConfigV3 } from "@pixi/particle-emitter"; 3 | import { Container, filters, Rectangle, Sprite, Texture } from "pixi.js"; 4 | import { emitterConfigs, emitterStarter } from "../emitterUtils"; 5 | import { getEmitterType, sprite2TransParent } from "../resourcesUtils"; 6 | 7 | export default async function BG_Love_L(resources: Sprite[]) { 8 | // 原理是波纹, 粉红爱心, 渐变背景 9 | const { app } = usePlayerStore(); 10 | const appWidth = app.view.width; 11 | const appHeight = app.view.height; 12 | const alphaFilter = new filters.AlphaFilter(0.5); 13 | const backSprite = resources[2]; 14 | backSprite.tint = 0xffc0cb; 15 | backSprite.width = appWidth; 16 | backSprite.height = appHeight; 17 | backSprite.filters = [alphaFilter]; 18 | backSprite.zIndex = -1; 19 | app.stage.addChild(backSprite); 20 | // 心心特效 21 | let emitterContainer = new Container(); 22 | app.stage.addChild(emitterContainer); 23 | emitterContainer.zIndex = -1; 24 | let heartConfig: EmitterConfigV3 = { 25 | ...(emitterConfigs("love_heart") as EmitterConfigV3), 26 | }; 27 | heartConfig.pos = { 28 | x: appWidth / 2, 29 | y: appHeight / 2, 30 | }; 31 | // 塞入 texture 中 32 | getEmitterType(heartConfig, "textureRandom").config.textures.push( 33 | resources[0].texture 34 | ); 35 | const heartTextureWidth = resources[0].texture.width; 36 | const heartBaseRatio = (0.074 * appWidth) / heartTextureWidth; // 0.074 量出来的, 此时定为emiter时会达到的最大值 37 | const scaleConfig = getEmitterType(heartConfig, "scale").config; 38 | scaleConfig.scale.list[0].value = heartBaseRatio * 0.8; 39 | scaleConfig.scale.list[1].value = heartBaseRatio; 40 | scaleConfig.scale.list[2].value = heartBaseRatio * 0.95; 41 | const curEmitter = new Emitter(emitterContainer, heartConfig); 42 | const heartRemover = emitterStarter(curEmitter); 43 | let ringConfig: EmitterConfigV3 = { 44 | ...(emitterConfigs("love_ring") as EmitterConfigV3), 45 | }; 46 | const ringSprite = sprite2TransParent(resources[1]); 47 | getEmitterType(ringConfig, "textureRandom").config.textures.push( 48 | ringSprite.texture 49 | ); 50 | getEmitterType(ringConfig, "spawnShape").config.data.w = appWidth; 51 | getEmitterType(ringConfig, "spawnShape").config.data.h = appHeight; 52 | const ringTextureWidth = resources[1].texture.width; 53 | const ringBaseRatio = (0.28 * appWidth) / ringTextureWidth; 54 | const ringScaleConfig = getEmitterType(ringConfig, "scale").config; 55 | ringScaleConfig.scale.list[0].value = ringBaseRatio * 0.9; 56 | ringScaleConfig.scale.list[1].value = ringBaseRatio; 57 | const ringEmitter = new Emitter(emitterContainer, ringConfig); 58 | const ringRemover = emitterStarter(ringEmitter); 59 | return async () => { 60 | await heartRemover(); 61 | await ringRemover(); 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /lib/layers/effectLayer/effectFunctions/BG_Love_L_BGOff.ts: -------------------------------------------------------------------------------- 1 | import { usePlayerStore } from "@/stores"; 2 | import { Emitter, EmitterConfigV3 } from "@pixi/particle-emitter"; 3 | import { Container, filters, Rectangle, Sprite, Texture } from "pixi.js"; 4 | import { emitterConfigs, emitterStarter } from "../emitterUtils"; 5 | import { getEmitterType, sprite2TransParent } from "../resourcesUtils"; 6 | 7 | export default async function BG_Love_L_BGOff(resources: Sprite[]) { 8 | // 原理是波纹, 粉红爱心, 渐变背景 9 | const { app } = usePlayerStore(); 10 | const appWidth = app.view.width; 11 | const appHeight = app.view.height; 12 | const backSprite = resources[2]; 13 | backSprite.x = -0.2 * appWidth; 14 | backSprite.y = -0.2 * appHeight; 15 | backSprite.width = appWidth * 1.4; 16 | backSprite.height = appHeight * 1.4; 17 | backSprite.zIndex = -1; 18 | app.stage.addChild(backSprite); 19 | // 心心特效 20 | let emitterContainer = new Container(); 21 | app.stage.addChild(emitterContainer); 22 | emitterContainer.zIndex = -1; 23 | let heartConfig: EmitterConfigV3 = { 24 | ...(emitterConfigs("love_heart") as EmitterConfigV3), 25 | }; 26 | heartConfig.pos = { 27 | x: appWidth / 2, 28 | y: appHeight / 2, 29 | }; 30 | // 塞入 texture 中 31 | getEmitterType(heartConfig, "textureRandom").config.textures.push( 32 | resources[0].texture 33 | ); 34 | const heartTextureWidth = resources[0].texture.width; 35 | const heartBaseRatio = (0.074 * appWidth) / heartTextureWidth; // 0.074 量出来的, 此时定为emiter时会达到的最大值 36 | const scaleConfig = getEmitterType(heartConfig, "scale").config; 37 | scaleConfig.scale.list[0].value = heartBaseRatio * 0.8; 38 | scaleConfig.scale.list[1].value = heartBaseRatio; 39 | scaleConfig.scale.list[2].value = heartBaseRatio * 0.95; 40 | const curEmitter = new Emitter(emitterContainer, heartConfig); 41 | const heartRemover = emitterStarter(curEmitter); 42 | let ringConfig: EmitterConfigV3 = { 43 | ...(emitterConfigs("love_ring") as EmitterConfigV3), 44 | }; 45 | const ringSprite = sprite2TransParent(resources[1]); 46 | getEmitterType(ringConfig, "textureRandom").config.textures.push( 47 | ringSprite.texture 48 | ); 49 | getEmitterType(ringConfig, "spawnShape").config.data.w = appWidth; 50 | getEmitterType(ringConfig, "spawnShape").config.data.h = appHeight; 51 | getEmitterType(ringConfig, "colorStatic").config.color = "#ffe7d8"; 52 | const ringTextureWidth = resources[1].texture.width; 53 | const ringBaseRatio = (0.28 * appWidth) / ringTextureWidth; 54 | const ringScaleConfig = getEmitterType(ringConfig, "scale").config; 55 | ringScaleConfig.scale.list[0].value = ringBaseRatio * 0.9; 56 | ringScaleConfig.scale.list[1].value = ringBaseRatio; 57 | const ringEmitter = new Emitter(emitterContainer, ringConfig); 58 | const ringRemover = emitterStarter(ringEmitter); 59 | return async () => { 60 | await heartRemover(); 61 | await ringRemover(); 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /lib/layers/effectLayer/effectFunctions/BG_Rain_L.ts: -------------------------------------------------------------------------------- 1 | import { usePlayerStore } from "@/stores"; 2 | import { BGEffectHandlerFunction } from "@/types/effectLayer"; 3 | import { 4 | Emitter, 5 | EmitterConfigV2, 6 | upgradeConfig, 7 | } from "@pixi/particle-emitter"; 8 | import { 9 | emitterConfigs, 10 | emitterContainer, 11 | emitterStarter, 12 | } from "../emitterUtils"; 13 | 14 | const handler: BGEffectHandlerFunction<"BG_Rain_L"> = async function ( 15 | resources, 16 | setting, 17 | options 18 | ) { 19 | let newRainConfig: EmitterConfigV2 = { ...emitterConfigs("rain") }; 20 | const app = usePlayerStore().app; 21 | newRainConfig.spawnRect!.w = app.view.width; 22 | newRainConfig.spawnRect!.h = app.view.height; 23 | newRainConfig.frequency = options.frequency; 24 | let emitter = new Emitter( 25 | emitterContainer, 26 | upgradeConfig(newRainConfig, [resources[0].texture]) 27 | ); 28 | return emitterStarter(emitter); 29 | }; 30 | 31 | export default handler; 32 | -------------------------------------------------------------------------------- /lib/layers/effectLayer/effectFunctions/BG_SandStorm_L.ts: -------------------------------------------------------------------------------- 1 | import { usePlayerStore } from "@/stores"; 2 | import { filters, Sprite, TilingSprite } from "pixi.js"; 3 | import { emitterStarter } from "../emitterUtils"; 4 | import { loadSpriteSheet } from "../resourcesUtils"; 5 | 6 | export default async function BG_SandStorm_L(resources: Sprite[]) { 7 | // 原理是两个平铺图片不断移动 8 | const { app } = usePlayerStore(); 9 | const appWidth = app.view.width; 10 | const appHeight = app.view.height; 11 | let animationsName = "sandStorm"; 12 | let spritesheet = await loadSpriteSheet( 13 | resources[0], 14 | { x: 1, y: 4 }, 15 | animationsName 16 | ); 17 | const texture = Reflect.get(spritesheet.animations, animationsName)[3]; 18 | const TextureTilingBack = new TilingSprite(texture); 19 | const TextureTilingFront = new TilingSprite(texture); 20 | // 算出一个当前渲染中最长的长度 21 | const width = Math.sqrt(appWidth * appWidth + appHeight * appHeight); 22 | const height = texture.height; 23 | const scale = appHeight / height; 24 | const blurFilter = new filters.BlurFilter(); 25 | [TextureTilingBack, TextureTilingFront].forEach(i => { 26 | // 避免 tiling 产生的像素 27 | i.clampMargin = 1.5; 28 | i.tint = 0xb98c56; 29 | i.width = width; 30 | i.height = height; 31 | i.filters = [blurFilter]; 32 | i.scale.set(scale); 33 | app.stage.addChild(i); 34 | }); 35 | TextureTilingBack.y = appHeight * 0.4; 36 | TextureTilingBack.zIndex = -1; 37 | TextureTilingFront.tilePosition.x = appWidth * 0.05; // 错开一点 38 | TextureTilingFront.y = appHeight * 0.75; 39 | let Remover = emitterStarter({ 40 | update: () => { 41 | TextureTilingBack.tilePosition.x += 1.3; 42 | TextureTilingFront.tilePosition.x += 1.3; 43 | }, 44 | destroy: () => { 45 | app.stage.removeChild(TextureTilingBack); 46 | app.stage.removeChild(TextureTilingFront); 47 | }, 48 | } as any); 49 | return async () => { 50 | await Remover(); 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /lib/layers/effectLayer/effectFunctions/BG_Shining_L.ts: -------------------------------------------------------------------------------- 1 | import { usePlayerStore } from "@/stores"; 2 | import { Emitter, EmitterConfigV3 } from "@pixi/particle-emitter"; 3 | import { Container, filters, Rectangle, Sprite, Texture } from "pixi.js"; 4 | import { emitterConfigs, emitterStarter } from "../emitterUtils"; 5 | import { getEmitterType, sprite2TransParent } from "../resourcesUtils"; 6 | 7 | export default async function BG_Shining_L(resources: Sprite[]) { 8 | // 原理是波纹 9 | const { app } = usePlayerStore(); 10 | const appWidth = app.view.width; 11 | const appHeight = app.view.height; 12 | // 底图 13 | const backSprite = resources[2]; 14 | backSprite.x = -0.2 * appWidth; 15 | backSprite.y = -0.2 * appHeight; 16 | backSprite.width = appWidth * 1.4; 17 | backSprite.height = appHeight * 1.4; 18 | backSprite.zIndex = -1; 19 | // 紫色覆盖 20 | const alphaFilter = new filters.AlphaFilter(0.8); 21 | const backPurpleSprite = resources[3]; 22 | backPurpleSprite.tint = 0xd59ffb; 23 | backPurpleSprite.width = appWidth; 24 | backPurpleSprite.height = appHeight; 25 | backPurpleSprite.filters = [alphaFilter]; 26 | backPurpleSprite.zIndex = -1; 27 | app.stage.addChild(backSprite); 28 | app.stage.addChild(backPurpleSprite); 29 | // 波纹特效 30 | let emitterContainer = new Container(); 31 | app.stage.addChild(emitterContainer); 32 | emitterContainer.zIndex = -1; 33 | let ringConfig: EmitterConfigV3 = { 34 | ...(emitterConfigs("love_ring") as EmitterConfigV3), 35 | }; 36 | const ringSprite = sprite2TransParent(resources[0]); 37 | getEmitterType(ringConfig, "textureRandom").config.textures.push( 38 | ringSprite.texture 39 | ); 40 | getEmitterType(ringConfig, "colorStatic").config.color = "#ffffff"; 41 | getEmitterType(ringConfig, "spawnShape").config.data.w = appWidth; 42 | getEmitterType(ringConfig, "spawnShape").config.data.h = appHeight; 43 | const ringTextureWidth = resources[0].texture.width; 44 | const ringBaseRatio = (0.4 * appWidth) / ringTextureWidth; 45 | const ringScaleConfig = getEmitterType(ringConfig, "scale").config; 46 | ringScaleConfig.scale.list[0].value = ringBaseRatio * 0.9; 47 | ringScaleConfig.scale.list[1].value = ringBaseRatio; 48 | const ringEmitter = new Emitter(emitterContainer, ringConfig); 49 | const ringRemover = emitterStarter(ringEmitter); 50 | const flareSprite = sprite2TransParent(resources[1]); 51 | const flareConfig: EmitterConfigV3 = { 52 | ...(emitterConfigs("shining_flare") as EmitterConfigV3), 53 | }; 54 | getEmitterType(flareConfig, "textureRandom").config.textures.push( 55 | flareSprite.texture 56 | ); 57 | getEmitterType(flareConfig, "spawnShape").config.data.w = appWidth; 58 | getEmitterType(flareConfig, "spawnShape").config.data.h = appHeight; 59 | const flareEmitter = new Emitter(emitterContainer, flareConfig); 60 | const flareRemover = emitterStarter(flareEmitter); 61 | return async () => { 62 | await ringRemover(); 63 | await flareRemover(); 64 | app.stage.removeChild(backSprite); 65 | app.stage.removeChild(backPurpleSprite); 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /lib/layers/effectLayer/effectFunctions/BG_Shining_L_BGOff.ts: -------------------------------------------------------------------------------- 1 | import { usePlayerStore } from "@/stores"; 2 | import { Emitter, EmitterConfigV3 } from "@pixi/particle-emitter"; 3 | import { Container, filters, Rectangle, Sprite, Texture } from "pixi.js"; 4 | import { emitterConfigs, emitterStarter } from "../emitterUtils"; 5 | import { getEmitterType, sprite2TransParent } from "../resourcesUtils"; 6 | 7 | export default async function BG_Shining_L_BGOff(resources: Sprite[]) { 8 | // 原理是波纹 9 | const { app } = usePlayerStore(); 10 | const appWidth = app.view.width; 11 | const appHeight = app.view.height; 12 | // 紫色覆盖 13 | const alphaFilter = new filters.AlphaFilter(0.4); 14 | const backPurpleSprite = resources[2]; 15 | backPurpleSprite.tint = 0xd59ffb; 16 | backPurpleSprite.width = appWidth; 17 | backPurpleSprite.height = appHeight; 18 | backPurpleSprite.filters = [alphaFilter]; 19 | backPurpleSprite.zIndex = -1; 20 | app.stage.addChild(backPurpleSprite); 21 | // 波纹特效 22 | let emitterContainer = new Container(); 23 | app.stage.addChild(emitterContainer); 24 | let ringConfig: EmitterConfigV3 = { 25 | ...(emitterConfigs("shining_ring") as EmitterConfigV3), 26 | }; 27 | const ringSprite = sprite2TransParent(resources[0]); 28 | getEmitterType(ringConfig, "textureRandom").config.textures.push( 29 | ringSprite.texture 30 | ); 31 | getEmitterType(ringConfig, "spawnShape").config.data.w = appWidth; 32 | getEmitterType(ringConfig, "spawnShape").config.data.h = appHeight; 33 | const ringTextureWidth = resources[0].texture.width; 34 | const ringBaseRatio = (0.7 * appWidth) / ringTextureWidth; 35 | const ringScaleConfig = getEmitterType(ringConfig, "scale").config; 36 | ringScaleConfig.scale.list[0].value = ringBaseRatio * 0.9; 37 | ringScaleConfig.scale.list[1].value = ringBaseRatio; 38 | // 闪光 39 | const flareSprite = sprite2TransParent(resources[1]); 40 | const flareConfig: EmitterConfigV3 = { 41 | ...(emitterConfigs("shining_flare") as EmitterConfigV3), 42 | }; 43 | getEmitterType(flareConfig, "textureRandom").config.textures.push( 44 | flareSprite.texture 45 | ); 46 | getEmitterType(flareConfig, "spawnShape").config.data.w = appWidth; 47 | getEmitterType(flareConfig, "spawnShape").config.data.h = appHeight; 48 | const flareEmitter = new Emitter(emitterContainer, flareConfig); 49 | const flareRemover = emitterStarter(flareEmitter); 50 | const ringEmitter = new Emitter(emitterContainer, ringConfig); 51 | const ringRemover = emitterStarter(ringEmitter); 52 | return async () => { 53 | await ringRemover(); 54 | await flareRemover(); 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /lib/layers/effectLayer/effectFunctions/BG_UnderFire.ts: -------------------------------------------------------------------------------- 1 | import eventBus from "@/eventBus"; 2 | import { usePlayerStore } from "@/stores"; 3 | import { BGEffectHandlerFunction } from "@/types/effectLayer"; 4 | import { Emitter, EmitterConfigV3 } from "@pixi/particle-emitter"; 5 | import { Container } from "pixi.js"; 6 | import { 7 | emitterConfigs, 8 | emitterContainer, 9 | emitterStarter, 10 | } from "../emitterUtils"; 11 | import { loadSpriteSheet } from "../resourcesUtils"; 12 | 13 | const handler: BGEffectHandlerFunction<"BG_UnderFire"> = async function ( 14 | resources, 15 | setting, 16 | options 17 | ) { 18 | let { height: appHeight, width: appWidth } = usePlayerStore().app.screen; 19 | let ininX = (appWidth * 7) / 8; 20 | let ininY = (appHeight * 7) / 8; 21 | eventBus.emit("playOtherSounds", "bg_underfire"); 22 | 23 | //烟雾效果, 通过spritesheet实现烟雾散开 24 | let smokeContainer = new Container(); 25 | emitterContainer.addChild(smokeContainer); 26 | smokeContainer.zIndex = 1; 27 | let smokeConifg: EmitterConfigV3 = { 28 | ...(emitterConfigs("smoke") as EmitterConfigV3), 29 | }; 30 | smokeConifg.pos = { 31 | x: ininX, 32 | y: ininY, 33 | }; 34 | let smokeAnimationsName = "smoke"; 35 | let smokeSpritesheet = await loadSpriteSheet( 36 | resources[0], 37 | { x: 3, y: 3 }, 38 | smokeAnimationsName 39 | ); 40 | smokeConifg.behaviors[0].config.anim.textures = Reflect.get( 41 | smokeSpritesheet.animations, 42 | smokeAnimationsName 43 | ); 44 | let smokeImageHeight = resources[0].height / 3; 45 | //根据高度算缩放比例 46 | let smokeScale = ((2 / 5) * appHeight) / smokeImageHeight; 47 | Reflect.set(smokeConifg.behaviors[1].config, "min", smokeScale); 48 | Reflect.set(smokeConifg.behaviors[1].config, "max", smokeScale + 0.5); 49 | let smokeMoveSpeed = (1 / 2) * appHeight; 50 | Reflect.set(smokeConifg.behaviors[2].config, "min", smokeMoveSpeed); 51 | Reflect.set(smokeConifg.behaviors[2].config, "max", smokeMoveSpeed + 10); 52 | let smokeEmitter = new Emitter(smokeContainer, smokeConifg); 53 | let smokeRemover = emitterStarter(smokeEmitter); 54 | 55 | //火焰效果, emitter随机从三个素材中选一个发出 56 | let fireContainer = new Container(); 57 | emitterContainer.addChild(fireContainer); 58 | fireContainer.zIndex = 2; 59 | let fireConfig: EmitterConfigV3 = { 60 | ...(emitterConfigs("fire") as EmitterConfigV3), 61 | }; 62 | fireConfig.pos = { 63 | x: ininX, 64 | y: ininY, 65 | }; 66 | let fireImgs = resources.slice(1, 4); 67 | let fireScale = ((1 / 3) * appHeight) / fireImgs[0].height; 68 | Reflect.set(fireConfig.behaviors[0].config.scale, "list", [ 69 | { 70 | value: fireScale, 71 | time: 0, 72 | }, 73 | { 74 | value: fireScale - 0.1, 75 | time: 1, 76 | }, 77 | ]); 78 | for (let i = 0; i < 3; ++i) { 79 | //textureRandom behaviors 80 | fireConfig.behaviors[2].config.textures.push(fireImgs[i].texture); 81 | } 82 | let fireEmitter = new Emitter(fireContainer, fireConfig); 83 | let fireRemover = emitterStarter(fireEmitter); 84 | 85 | let firelineContainer = new Container(); 86 | emitterContainer.addChild(firelineContainer); 87 | firelineContainer.zIndex = 0; 88 | let firelineConfig: EmitterConfigV3 = { 89 | ...(emitterConfigs("fireline") as EmitterConfigV3), 90 | }; 91 | let firelineImage = resources[4]; 92 | firelineConfig.behaviors[0].config.texture = firelineImage.texture; 93 | firelineConfig.pos = { 94 | x: ininX, 95 | y: ininY, 96 | }; 97 | let firelineScale = ((1 / 16) * appHeight) / firelineImage.height; 98 | Reflect.set(firelineConfig.behaviors[1].config, "min", firelineScale - 0.2); 99 | Reflect.set(firelineConfig.behaviors[1].config, "max", firelineScale); 100 | let firelineMoveSpeed = 2 * appHeight; 101 | Reflect.set( 102 | firelineConfig.behaviors[2].config, 103 | "min", 104 | firelineMoveSpeed * 0.95 105 | ); 106 | Reflect.set(firelineConfig.behaviors[2].config, "max", firelineMoveSpeed); 107 | let fireLineEmitter = new Emitter(firelineContainer, firelineConfig); 108 | let firelineRemover = emitterStarter(fireLineEmitter); 109 | 110 | let posX = smokeEmitter.spawnPos.x; 111 | let posY = smokeEmitter.spawnPos.y; 112 | 113 | //原点向左移动, 移出屏幕后停止 114 | await new Promise(resolve => { 115 | let underfirePlay = setInterval(async () => { 116 | if (posX <= -ininX) { 117 | clearInterval(underfirePlay); 118 | await smokeRemover(); 119 | await fireRemover(); 120 | await firelineRemover(); 121 | resolve(); 122 | return; 123 | } 124 | posX -= usePlayerStore().app.screen.width / 5; 125 | smokeEmitter.updateSpawnPos(posX, posY); 126 | fireEmitter.updateSpawnPos(posX, posY); 127 | fireLineEmitter.updateSpawnPos(posX, posY); 128 | }, 100); 129 | }); 130 | 131 | return () => Promise.resolve(); 132 | }; 133 | 134 | export default handler; 135 | -------------------------------------------------------------------------------- /lib/layers/effectLayer/emitterConfigs/dust_fire.json: -------------------------------------------------------------------------------- 1 | { 2 | "lifetime": { 3 | "min": 4, 4 | "max": 8 5 | }, 6 | "frequency": 0.1, 7 | "maxParticles": 10, 8 | "addAtBack": false, 9 | "pos": { 10 | "x": 0, 11 | "y": 0 12 | }, 13 | "behaviors": [ 14 | { 15 | "type": "scale", 16 | "config": { 17 | "scale": { 18 | "list": [ 19 | { 20 | "value": 0.3, 21 | "time": 0 22 | }, 23 | { 24 | "value": 0.15, 25 | "time": 0.5 26 | }, 27 | { 28 | "value": 0.3, 29 | "time": 1 30 | } 31 | ] 32 | }, 33 | "minMult": 0.5 34 | } 35 | }, 36 | { 37 | "type": "rotation", 38 | "config": { 39 | "accel": 0, 40 | "minSpeed": 0, 41 | "maxSpeed": 0, 42 | "minStart": 275, 43 | "maxStart": 353 44 | } 45 | }, 46 | { 47 | "type": "textureRandom", 48 | "config": { 49 | "textures": [] 50 | } 51 | }, 52 | { 53 | "type": "spawnShape", 54 | "config": { 55 | "type": "rect", 56 | "data": { 57 | "x": 0, 58 | "y": 0, 59 | "w": 260, 60 | "h": 0 61 | } 62 | } 63 | }, 64 | { 65 | "type": "moveSpeedStatic", 66 | "config": { 67 | "min": 300, 68 | "max": 350 69 | } 70 | }, 71 | { 72 | "type": "alpha", 73 | "config": { 74 | "alpha": { 75 | "list": [ 76 | { "value": 1, "time": 0 }, 77 | { "value": 1, "time": 0.9 }, 78 | { "value": 0, "time": 1 } 79 | ] 80 | } 81 | } 82 | } 83 | ] 84 | } 85 | -------------------------------------------------------------------------------- /lib/layers/effectLayer/emitterConfigs/fire.json: -------------------------------------------------------------------------------- 1 | { 2 | "lifetime": { 3 | "min": 0.15, 4 | "max": 0.2 5 | }, 6 | "frequency": 0.07, 7 | "maxParticles": 1000, 8 | "addAtBack": false, 9 | "pos": { 10 | "x": 0, 11 | "y": 0 12 | }, 13 | "behaviors": [ 14 | { 15 | "type": "scale", 16 | "config": { 17 | "scale": { 18 | "list": [] 19 | }, 20 | "minMult": 0.5 21 | } 22 | }, 23 | { 24 | "type": "rotation", 25 | "config": { 26 | "accel": 0, 27 | "minSpeed": 0, 28 | "maxSpeed": 0, 29 | "minStart": -45, 30 | "maxStart": 45 31 | } 32 | }, 33 | { 34 | "type": "textureRandom", 35 | "config": { 36 | "textures": [] 37 | } 38 | }, 39 | { 40 | "type": "spawnShape", 41 | "config": { 42 | "type": "rect", 43 | "data": { 44 | "x": 0, 45 | "y": 0, 46 | "w": 60, 47 | "h": 0 48 | } 49 | } 50 | }, 51 | { 52 | "type": "colorStatic", 53 | "config": { 54 | "color": "#fa8057" 55 | } 56 | } 57 | ] 58 | } 59 | -------------------------------------------------------------------------------- /lib/layers/effectLayer/emitterConfigs/fireline.json: -------------------------------------------------------------------------------- 1 | { 2 | "lifetime": { 3 | "min": 1, 4 | "max": 1.5 5 | }, 6 | "frequency": 0.1, 7 | "maxParticles": 1000, 8 | "addAtBack": false, 9 | "pos": { 10 | "x": 0, 11 | "y": 0 12 | }, 13 | "behaviors": [ 14 | { 15 | "type": "textureSingle", 16 | "config": { 17 | "texture": null 18 | } 19 | }, 20 | { 21 | "type": "scaleStatic", 22 | "config": { 23 | "min": 0.4, 24 | "max": 0.6 25 | } 26 | }, 27 | { 28 | "type": "moveSpeedStatic", 29 | "config": { 30 | "min": 800, 31 | "max": 850 32 | } 33 | }, 34 | { 35 | "type": "rotation", 36 | "config": { 37 | "accel": 0, 38 | "minSpeed": 0, 39 | "maxSpeed": 0, 40 | "minStart": 230, 41 | "maxStart": 300 42 | } 43 | }, 44 | { 45 | "type": "spawnShape", 46 | "config": { 47 | "type": "rect", 48 | "data": { 49 | "x": 0, 50 | "y": 0, 51 | "w": 60, 52 | "h": 0 53 | } 54 | } 55 | } 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /lib/layers/effectLayer/emitterConfigs/focusline.json: -------------------------------------------------------------------------------- 1 | { 2 | "lifetime": { 3 | "min": 0.1, 4 | "max": 0.2 5 | }, 6 | "frequency": 0.005, 7 | "maxParticles": 90, 8 | "addAtBack": false, 9 | "pos": { 10 | "x": 0, 11 | "y": 0 12 | }, 13 | "behaviors": [ 14 | { 15 | "type": "textureRandom", 16 | "config": { 17 | "textures": [] 18 | } 19 | }, 20 | { 21 | "type": "spawnShape", 22 | "config": { 23 | "type": "polygonalChain", 24 | "data": [ 25 | [ 26 | { "x": 0, "y": 0 }, 27 | { "x": 0, "y": 1000 }, 28 | { "x": 1000, "y": 1000 }, 29 | { "x": 1000, "y": 0 }, 30 | { "x": 0, "y": 0 } 31 | ] 32 | ] 33 | } 34 | }, 35 | { 36 | "type": "colorStatic", 37 | "config": { 38 | "color": "#000000" 39 | } 40 | }, 41 | { 42 | "type": "focusLine" 43 | } 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /lib/layers/effectLayer/emitterConfigs/love_heart.json: -------------------------------------------------------------------------------- 1 | { 2 | "lifetime": { 3 | "min": 2, 4 | "max": 3 5 | }, 6 | "frequency": 0.5, 7 | "maxParticles": 10, 8 | "addAtBack": false, 9 | "pos": { 10 | "x": 0, 11 | "y": 0 12 | }, 13 | "behaviors": [ 14 | { 15 | "type": "noRotation", 16 | "config": { 17 | "rotation": 0 18 | } 19 | }, 20 | { 21 | "type": "scale", 22 | "config": { 23 | "scale": { 24 | "list": [ 25 | { 26 | "value": 1, 27 | "time": 0 28 | }, 29 | { 30 | "value": 0.8, 31 | "time": 0.5 32 | }, 33 | { 34 | "value": 1, 35 | "time": 1 36 | } 37 | ] 38 | }, 39 | "minMult": 0.5 40 | } 41 | }, 42 | { 43 | "type": "rotation", 44 | "config": { 45 | "accel": 0, 46 | "minSpeed": 0, 47 | "maxSpeed": 0, 48 | "minStart": 180, 49 | "maxStart": 360 50 | } 51 | }, 52 | { 53 | "type": "textureRandom", 54 | "config": { 55 | "textures": [] 56 | } 57 | }, 58 | { 59 | "type": "moveSpeedStatic", 60 | "config": { 61 | "min": 150, 62 | "max": 200 63 | } 64 | }, 65 | { 66 | "type": "colorStatic", 67 | "config": { 68 | "color": "#fea399" 69 | } 70 | }, 71 | { 72 | "type": "alpha", 73 | "config": { 74 | "alpha": { 75 | "list": [ 76 | { "value": 1, "time": 0 }, 77 | { "value": 0.3, "time": 0.5 }, 78 | { "value": 1, "time": 0.9 }, 79 | { "value": 0, "time": 1 } 80 | ] 81 | } 82 | } 83 | } 84 | ] 85 | } 86 | -------------------------------------------------------------------------------- /lib/layers/effectLayer/emitterConfigs/love_ring.json: -------------------------------------------------------------------------------- 1 | { 2 | "lifetime": { 3 | "min": 4, 4 | "max": 5 5 | }, 6 | "frequency": 0.65, 7 | "maxParticles": 4, 8 | "addAtBack": false, 9 | "pos": { 10 | "x": 0, 11 | "y": 0 12 | }, 13 | "behaviors": [ 14 | { 15 | "type": "scale", 16 | "config": { 17 | "scale": { 18 | "list": [ 19 | { 20 | "value": 1, 21 | "time": 0 22 | }, 23 | { 24 | "value": 1.2, 25 | "time": 1 26 | } 27 | ] 28 | }, 29 | "minMult": 0.5 30 | } 31 | }, 32 | { 33 | "type": "textureRandom", 34 | "config": { 35 | "textures": [] 36 | } 37 | }, 38 | { 39 | "type": "moveSpeedStatic", 40 | "config": { 41 | "min": 0, 42 | "max": 0 43 | } 44 | }, 45 | { 46 | "type": "alpha", 47 | "config": { 48 | "alpha": { 49 | "list": [ 50 | { "value": 0, "time": 0 }, 51 | { "value": 1, "time": 0.1 }, 52 | { "value": 1, "time": 0.9 }, 53 | { "value": 0, "time": 1 } 54 | ] 55 | } 56 | } 57 | }, 58 | { 59 | "type": "spawnShape", 60 | "config": { 61 | "type": "rect", 62 | "data": { 63 | "x": 0, 64 | "y": 0, 65 | "w": 0, 66 | "h": 0 67 | } 68 | } 69 | }, 70 | { 71 | "type": "colorStatic", 72 | "config": { 73 | "color": "#ffc0cb" 74 | } 75 | } 76 | ] 77 | } 78 | -------------------------------------------------------------------------------- /lib/layers/effectLayer/emitterConfigs/rain.json: -------------------------------------------------------------------------------- 1 | { 2 | "alpha": { 3 | "start": 0.5, 4 | "end": 0.5 5 | }, 6 | "scale": { 7 | "start": 1, 8 | "end": 1, 9 | "minimumScaleMultiplier": 1 10 | }, 11 | "color": { 12 | "start": "#ffffff", 13 | "end": "#ffffff" 14 | }, 15 | "speed": { 16 | "start": 1300, 17 | "end": 3000, 18 | "minimumSpeedMultiplier": 1 19 | }, 20 | "acceleration": { 21 | "x": 0, 22 | "y": 0 23 | }, 24 | "maxSpeed": 0, 25 | "startRotation": { 26 | "min": 90, 27 | "max": 90 28 | }, 29 | "noRotation": false, 30 | "rotationSpeed": { 31 | "min": 0, 32 | "max": 0 33 | }, 34 | "lifetime": { 35 | "min": 2, 36 | "max": 2 37 | }, 38 | "blendMode": "normal", 39 | "frequency": 0.04, 40 | "emitterLifetime": -1, 41 | "maxParticles": 1000, 42 | "pos": { 43 | "x": 0, 44 | "y": 0 45 | }, 46 | "addAtBack": false, 47 | "spawnType": "rect", 48 | "spawnRect": { 49 | "x": 0, 50 | "y": 0, 51 | "w": 900, 52 | "h": 20 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /lib/layers/effectLayer/emitterConfigs/shining_flare.json: -------------------------------------------------------------------------------- 1 | { 2 | "lifetime": { 3 | "min": 1, 4 | "max": 1.5 5 | }, 6 | "frequency": 0.2, 7 | "maxParticles": 8, 8 | "addAtBack": false, 9 | "pos": { 10 | "x": 0, 11 | "y": 0 12 | }, 13 | "behaviors": [ 14 | { 15 | "type": "scale", 16 | "config": { 17 | "scale": { 18 | "list": [ 19 | { 20 | "value": 0.1, 21 | "time": 0 22 | }, 23 | { 24 | "value": 1, 25 | "time": 0.5 26 | }, 27 | { 28 | "value": 0, 29 | "time": 1 30 | } 31 | ] 32 | }, 33 | "minMult": 0.5 34 | } 35 | }, 36 | { 37 | "type": "textureRandom", 38 | "config": { 39 | "textures": [] 40 | } 41 | }, 42 | { 43 | "type": "spawnShape", 44 | "config": { 45 | "type": "rect", 46 | "data": { 47 | "x": 0, 48 | "y": 0, 49 | "w": 0, 50 | "h": 0 51 | } 52 | } 53 | }, 54 | { 55 | "type": "colorStatic", 56 | "config": { 57 | "color": "#ffffff" 58 | } 59 | }, 60 | { 61 | "type": "rotationStatic", 62 | "config": { 63 | "min": 45, 64 | "max": 45 65 | } 66 | } 67 | ] 68 | } 69 | -------------------------------------------------------------------------------- /lib/layers/effectLayer/emitterConfigs/shining_ring.json: -------------------------------------------------------------------------------- 1 | { 2 | "lifetime": { 3 | "min": 4, 4 | "max": 5 5 | }, 6 | "frequency": 1.5, 7 | "maxParticles": 3, 8 | "addAtBack": false, 9 | "pos": { 10 | "x": 0, 11 | "y": 0 12 | }, 13 | "behaviors": [ 14 | { 15 | "type": "scale", 16 | "config": { 17 | "scale": { 18 | "list": [ 19 | { 20 | "value": 1, 21 | "time": 0 22 | }, 23 | { 24 | "value": 1.2, 25 | "time": 1 26 | } 27 | ] 28 | }, 29 | "minMult": 0.5 30 | } 31 | }, 32 | { 33 | "type": "textureRandom", 34 | "config": { 35 | "textures": [] 36 | } 37 | }, 38 | { 39 | "type": "alpha", 40 | "config": { 41 | "alpha": { 42 | "list": [ 43 | { "value": 0, "time": 0 }, 44 | { "value": 0.7, "time": 0.1 }, 45 | { "value": 1, "time": 0.4 }, 46 | { "value": 0, "time": 1 } 47 | ] 48 | } 49 | } 50 | }, 51 | { 52 | "type": "spawnShape", 53 | "config": { 54 | "type": "rect", 55 | "data": { 56 | "x": 0, 57 | "y": 0, 58 | "w": 0, 59 | "h": 0 60 | } 61 | } 62 | }, 63 | { 64 | "type": "colorStatic", 65 | "config": { 66 | "color": "#ffffff" 67 | } 68 | } 69 | ] 70 | } 71 | -------------------------------------------------------------------------------- /lib/layers/effectLayer/emitterConfigs/smoke.json: -------------------------------------------------------------------------------- 1 | { 2 | "lifetime": { 3 | "min": 0.2, 4 | "max": 0.3 5 | }, 6 | "frequency": 0.08, 7 | "maxParticles": 1000, 8 | "addAtBack": false, 9 | "pos": { 10 | "x": 0, 11 | "y": 0 12 | }, 13 | "behaviors": [ 14 | { 15 | "type": "animatedSingle", 16 | "config": { 17 | "anim": { 18 | "framerate": 25, 19 | "textures": [] 20 | } 21 | } 22 | }, 23 | { 24 | "type": "scaleStatic", 25 | "config": { 26 | "min": 2.5, 27 | "max": 3 28 | } 29 | }, 30 | { 31 | "type": "moveSpeedStatic", 32 | "config": { 33 | "min": 300, 34 | "max": 310 35 | } 36 | }, 37 | { 38 | "type": "color", 39 | "config": { 40 | "color": { 41 | "list": [ 42 | { 43 | "value": "#9A9388", 44 | "time": 0 45 | }, 46 | { 47 | "value": "#9A9388", 48 | "time": 0.5 49 | }, 50 | { 51 | "value": "#9A9388", 52 | "time": 1 53 | } 54 | ] 55 | } 56 | } 57 | }, 58 | { 59 | "type": "rotation", 60 | "config": { 61 | "accel": 0, 62 | "minSpeed": 0, 63 | "maxSpeed": 50, 64 | "minStart": 260, 65 | "maxStart": 280 66 | } 67 | }, 68 | { 69 | "type": "spawnShape", 70 | "config": { 71 | "type": "rect", 72 | "data": { 73 | "x": 0, 74 | "y": 0, 75 | "w": 60, 76 | "h": 0 77 | } 78 | } 79 | } 80 | ] 81 | } 82 | -------------------------------------------------------------------------------- /lib/layers/effectLayer/emitterUtils.ts: -------------------------------------------------------------------------------- 1 | import { EffectRemoveFunction } from "@/types/effectLayer"; 2 | import { 3 | Emitter, 4 | EmitterConfigV2, 5 | EmitterConfigV3, 6 | } from "@pixi/particle-emitter"; 7 | import { Container } from "pixi.js"; 8 | 9 | /** 10 | * 给emitter用的container 11 | */ 12 | export const emitterContainer = new Container(); 13 | emitterContainer.zIndex = 15; 14 | emitterContainer.sortableChildren = true; 15 | 16 | const emitterConfigsRaw = import.meta.glob( 17 | "./emitterConfigs/*.json", 18 | { eager: true } 19 | ); 20 | /** 21 | * 获取emitter config 22 | * @param filename 文件名, 不需要加.json后缀 23 | * @returns 24 | */ 25 | export function emitterConfigs(filename: string) { 26 | let config = Reflect.get( 27 | emitterConfigsRaw, 28 | `./emitterConfigs/${filename}.json` 29 | ); 30 | if (!config) { 31 | throw new Error("emitter参数获取失败, 文件名错误或文件不存在"); 32 | } 33 | return config; 34 | } 35 | 36 | /** 37 | * emitter工具函数, 会自动启动emitter并返回一个终止函数 38 | * @param emitter 39 | * @param stopCallback 终止函数中调用的函数 40 | * @returns 终止函数, 功能是停止当前emitter并回收 41 | */ 42 | export function emitterStarter( 43 | emitter: Emitter, 44 | stopCallback?: () => void 45 | ): EffectRemoveFunction { 46 | let elapsed = Date.now(); 47 | let stopFlag = false; 48 | // Update function every frame 49 | let update = function () { 50 | if (stopFlag) { 51 | return; 52 | } 53 | // Update the next frame 54 | requestAnimationFrame(update); 55 | 56 | var now = Date.now(); 57 | // The emitter requires the elapsed 58 | // number of seconds since the last update 59 | emitter.update((now - elapsed) * 0.001); 60 | elapsed = now; 61 | }; 62 | 63 | let stop = async function () { 64 | stopFlag = true; 65 | emitter.emit = false; 66 | emitter.destroy(); 67 | if (stopCallback) { 68 | stopCallback(); 69 | } 70 | }; 71 | 72 | // Start emitting 73 | emitter.emit = true; 74 | 75 | // Start the update 76 | update(); 77 | 78 | return stop; 79 | } 80 | -------------------------------------------------------------------------------- /lib/layers/effectLayer/resourcesUtils.ts: -------------------------------------------------------------------------------- 1 | import { EmitterConfigV3 } from "@pixi/particle-emitter"; 2 | import { BaseTexture, ImageResource, Sprite, Spritesheet } from "pixi.js"; 3 | /** 4 | * 把黑白png图片的黑色转为透明度 5 | * https://blog.csdn.net/jdk137/article/details/106216318 6 | * @param img Sprite 7 | * @returns 8 | */ 9 | export function sprite2TransParent(img: Sprite) { 10 | // 这里的 source 代表着dom中的image节点, 所以图片加载类型不是通过url的话就不行 11 | const texture = img.texture.baseTexture as BaseTexture; 12 | const { realWidth, realHeight } = img.texture.baseTexture; 13 | let imgSource = texture.resource.source; 14 | let canvas = document.createElement("canvas"); 15 | // document.body.appendChild(canvas) 16 | canvas.setAttribute("width", realWidth + ""); 17 | canvas.setAttribute("height", realHeight + ""); 18 | let ctx = canvas.getContext("2d")!; 19 | ctx.drawImage(imgSource, 0, 0); 20 | 21 | let pixel = ctx.getImageData(0, 0, realWidth, realHeight); 22 | let data = pixel.data; 23 | for (var i = 0; i < data.length; i += 4) { 24 | data[i + 3] = Math.round((data[i] + data[i + 1] + data[i + 2]) / 3); 25 | data[i] = 255; 26 | data[i + 1] = 255; 27 | data[i + 2] = 255; 28 | } 29 | ctx.putImageData(pixel, 0, 0); 30 | const dataUrl = ctx.canvas.toDataURL("image/png"); 31 | // download(dataUrl) 32 | const newSprite = Sprite.from(dataUrl); 33 | return newSprite; 34 | } 35 | function download(b64: string) { 36 | var a = document.createElement("a"); 37 | a.href = b64; 38 | a.download = "result.png"; //设定下载名称 39 | var evt = document.createEvent("MouseEvents"); 40 | evt.initEvent("click", true, true); 41 | a.dispatchEvent(evt); 42 | } 43 | 44 | /** 45 | * 根据给定的信息, 加载spriteSheet 46 | * @param img spriteSheet原图片Sprite 47 | * @param quantity x, y方向上小图片的个数 48 | * @param animationsName 该图片组成的动画的名字, 用于访问资源 49 | */ 50 | export async function loadSpriteSheet( 51 | img: Sprite, 52 | quantity: { x: number; y: number }, 53 | animationsName: string 54 | ): Promise { 55 | // Create object to store sprite sheet data 56 | let atlasData = { 57 | frames: {}, 58 | meta: { 59 | scale: "1", 60 | }, 61 | animations: {} as Record, 62 | }; 63 | Reflect.set(atlasData.animations, animationsName, []); 64 | 65 | img.scale.set(1); 66 | let xNum = quantity.x; 67 | let yNum = quantity.y; 68 | let width = img.width / xNum; 69 | let height = img.height / yNum; 70 | for (let i = 0; i < xNum * yNum; ++i) { 71 | Reflect.set(atlasData.frames, `${animationsName}${i}`, { 72 | frame: { 73 | x: width * (i % xNum), 74 | y: height * Math.trunc(i / xNum), 75 | w: width, 76 | h: height, 77 | }, 78 | sourceSize: { w: width, h: height }, 79 | spriteSourceSize: { x: 0, y: 0, w: width, h: height }, 80 | }); 81 | atlasData.animations[animationsName].push(`${animationsName}${i}`); 82 | } 83 | 84 | // Create the SpriteSheet from data and image 85 | const spritesheet = new Spritesheet(img.texture, atlasData); 86 | 87 | // Generate all the Textures asynchronously 88 | await spritesheet.parse(); 89 | 90 | return spritesheet; 91 | } 92 | /** 93 | * 获取 emitter config behaviors 中的配置 94 | */ 95 | export function getEmitterType(config: EmitterConfigV3, type: string) { 96 | return config.behaviors.find(i => { 97 | return i.type === type; 98 | })!; 99 | } 100 | -------------------------------------------------------------------------------- /lib/layers/l2dLayer/l2dConfig.ts: -------------------------------------------------------------------------------- 1 | import { IL2dConfig } from "@/types/l2d"; 2 | 3 | export const l2dConfig: IL2dConfig = { 4 | CH0184_home: { 5 | name: "CH0184_home", 6 | playQue: [ 7 | { 8 | name: "CH0184_home", 9 | animation: "Start_Idle_01", 10 | fade: true, 11 | }, 12 | { 13 | name: "CH0184_home/CH0184_00/CH0184_00", 14 | animation: "Start_Idle_01", 15 | fade: true, 16 | }, 17 | { 18 | name: "CH0184_home", 19 | animation: "Start_Idle_02", 20 | }, 21 | ], 22 | otherSpine: ["CH0184_home/CH0184_00/CH0184_00"], 23 | }, 24 | CH0198_home: { 25 | name: "CH0198_home", 26 | playQue: [ 27 | { 28 | name: "CH0198_home", 29 | animation: "Start_Idle_01", 30 | fadeTime: 4.8, 31 | fade: true, 32 | }, 33 | { 34 | name: "CH0198_home", 35 | animation: "Idle_01", 36 | }, 37 | ], 38 | spineSettings: { 39 | CH0198_home: { 40 | scale: 1.5, 41 | }, 42 | }, 43 | }, 44 | CH0087_home: { 45 | name: "CH0087_home", 46 | playQue: [ 47 | { 48 | name: "CH0087_home", 49 | animation: "Start_Idle_01", 50 | fadeTime: 4.8, 51 | fade: true, 52 | }, 53 | { 54 | name: "CH0087_home", 55 | animation: "Idle_01", 56 | }, 57 | ], 58 | spineSettings: { 59 | CH0087_home: { 60 | scale: 1.3, 61 | }, 62 | }, 63 | }, 64 | CH0107_home: { 65 | name: "CH0107_home", 66 | playQue: [ 67 | { 68 | name: "CH0107_home", 69 | animation: "Start_Idle_01", 70 | fadeTime: 4.2, 71 | secondFadeTime: 8.2, 72 | fade: true, 73 | }, 74 | { 75 | name: "CH0107_home", 76 | animation: "Idle_01", 77 | }, 78 | ], 79 | spineSettings: { 80 | CH0107_home: { 81 | scale: 1.3, 82 | }, 83 | }, 84 | }, 85 | CH0086_home: { 86 | name: "CH0087_home", 87 | playQue: [ 88 | { 89 | name: "CH0087_home", 90 | animation: "Start_Idle_01", 91 | fadeTime: 4.3, 92 | fade: true, 93 | }, 94 | { 95 | name: "CH0087_home", 96 | animation: "Idle_01", 97 | }, 98 | ], 99 | spineSettings: { 100 | CH0087_home: { 101 | scale: 2, 102 | }, 103 | }, 104 | }, 105 | CH0200_home: { 106 | name: "CH0200_home", 107 | playQue: [ 108 | { 109 | name: "CH0200_home", 110 | animation: "Start_Idle_01", 111 | fadeTime: 4.3, 112 | fade: true, 113 | }, 114 | { 115 | name: "CH0200_home", 116 | animation: "Idle_01", 117 | }, 118 | ], 119 | }, 120 | CH0203_home: { 121 | name: "CH0203_home", 122 | playQue: [ 123 | { 124 | name: "CH0203_home", 125 | animation: "Start_Idle_01", 126 | fadeTime: 5.2, 127 | fade: true, 128 | sounds: [{ fileName: "maidYuzuCat", time: 0 }], 129 | }, 130 | { 131 | name: "CH0203_home", 132 | animation: "Idle_01", 133 | }, 134 | ], 135 | }, 136 | CH0211_home: { 137 | name: "CH0211_home", 138 | playQue: [ 139 | { 140 | name: "CH0211_home", 141 | animation: "Start_Idle_01", 142 | fadeTime: 5.1, 143 | fade: true, 144 | }, 145 | { 146 | name: "CH0211_home", 147 | animation: "Idle_01", 148 | }, 149 | ], 150 | }, 151 | CH0092_home: { 152 | name: "CH0092_home", 153 | playQue: [ 154 | { 155 | name: "CH0092_home", 156 | animation: "Start_Idle_01", 157 | fadeTime: 5.1, 158 | secondFadeTime: 14.7, 159 | fade: true, 160 | }, 161 | { 162 | name: "CH0092_home", 163 | animation: "Idle_01", 164 | }, 165 | ], 166 | spineSettings: { 167 | CH0092_home: { 168 | scale: 1.7, 169 | }, 170 | }, 171 | }, 172 | Nonomi_home: { 173 | name: "Nonomi_home", 174 | playQue: [ 175 | { 176 | name: "Nonomi_home", 177 | animation: "Start_Idle_01", 178 | fadeTime: 3.8, 179 | fade: true, 180 | }, 181 | { 182 | name: "Nonomi_home", 183 | animation: "Idle_01", 184 | }, 185 | ], 186 | }, 187 | }; 188 | -------------------------------------------------------------------------------- /lib/layers/soundLayer/index.ts: -------------------------------------------------------------------------------- 1 | import eventBus from "@/eventBus"; 2 | import { PlayAudio } from "@/types/events"; 3 | import { Sound } from "@pixi/sound"; 4 | import { usePlayerStore } from "@/stores"; 5 | 6 | const audioMap = new Map(); 7 | /** 8 | * 获取url对于的Sound对象, 缓存不存在则新建 9 | * @param url 10 | */ 11 | function getAudio(url: string): Sound { 12 | const audio = audioMap.get(url); 13 | if (audio) { 14 | return audio; 15 | } else { 16 | const newAudio = Sound.from({ 17 | url, 18 | autoPlay: false, 19 | }); 20 | audioMap.set(url, newAudio); 21 | return newAudio; 22 | } 23 | } 24 | 25 | /** 26 | * 预加载与解析声音资源 27 | * @param audioUrls 声音地址数组 28 | */ 29 | export async function preloadSound(audioUrls: string[]) { 30 | const audioLoadPromises: Promise[] = []; 31 | for (const audioUrl of audioUrls) { 32 | audioLoadPromises.push( 33 | new Promise(resolve => { 34 | audioMap.set( 35 | audioUrl, 36 | Sound.from({ 37 | url: audioUrl, 38 | preload: true, 39 | autoPlay: false, 40 | loaded(err, resource) { 41 | eventBus.emit("oneResourceLoaded", { 42 | type: err ? "fail" : "success", 43 | resourceName: audioUrl, 44 | }); 45 | resolve(); 46 | }, 47 | }) 48 | ); 49 | }) 50 | ); 51 | } 52 | await Promise.all(audioLoadPromises); 53 | } 54 | 55 | /** 56 | * 初始化声音层, 订阅player的剧情信息. 57 | */ 58 | export function soundInit() { 59 | let bgm: Sound | undefined = undefined; 60 | let sfx: Sound | undefined = undefined; 61 | let voice: Sound | undefined = undefined; 62 | let emotionSound: Sound | undefined = undefined; 63 | 64 | /** 65 | * 声音层的全局设置, 包括BGM音量, 效果音量和语音音量 66 | */ 67 | let soundSettings = new (class SoundSettings { 68 | BGMvolume = 0.3; 69 | SFXvolume = 1; 70 | Voicevolume = 1; 71 | })(); 72 | 73 | /** 74 | * @description 播放声音 75 | * @param playAudioInfo 76 | */ 77 | function playAudio(playAudioInfo: PlayAudio) { 78 | if (playAudioInfo.bgm) { 79 | // 替换BGM 80 | if (bgm) { 81 | bgm.stop(); 82 | } // 如果有正在播放的BGM则停止当前播放, 替换为下一个BGM 83 | bgm = getAudio(playAudioInfo.bgm.url); 84 | bgm.volume = soundSettings.BGMvolume; 85 | bgm.play({ 86 | // 第一次是非loop播放, 播放到LoopStartTime为止 87 | loop: false, 88 | start: 0, 89 | end: playAudioInfo.bgm?.bgmArgs.LoopEndTime, 90 | complete: function () { 91 | // 第一次播放结束后进入loop 92 | bgm?.play({ 93 | loop: true, 94 | start: playAudioInfo.bgm?.bgmArgs.LoopStartTime, 95 | end: playAudioInfo.bgm?.bgmArgs.LoopEndTime, 96 | }); 97 | }, 98 | }); // 这样写真的好吗... 99 | } 100 | 101 | if (playAudioInfo.soundUrl) { 102 | if (sfx) { 103 | sfx.stop(); 104 | } 105 | sfx = getAudio(playAudioInfo.soundUrl); 106 | sfx.volume = soundSettings.SFXvolume; 107 | sfx.play({ 108 | complete: () => { 109 | console.log("Finish Playing Sound!"); 110 | }, 111 | }); 112 | } 113 | 114 | if (playAudioInfo.voiceJPUrl) { 115 | if (voice) { 116 | voice.stop(); 117 | } 118 | voice = getAudio(playAudioInfo.voiceJPUrl); 119 | voice.volume = soundSettings.Voicevolume; 120 | voice.play({ 121 | complete: () => { 122 | eventBus.emit("playVoiceJPDone", playAudioInfo.voiceJPUrl || ""); 123 | }, 124 | }); 125 | } 126 | } 127 | 128 | // 当想要播放VoiceJP的时候, 可以直接 129 | // eventBus.emit('playAudio', {voiceJPUrl: url}) 130 | // 这样就可以了x 131 | 132 | eventBus.on("playAudio", (playAudioInfo: PlayAudio) => { 133 | console.log( 134 | `Get playAudioInfo: ${ 135 | playAudioInfo.soundUrl || 136 | playAudioInfo.voiceJPUrl || 137 | playAudioInfo.bgm?.url 138 | }` 139 | ); 140 | playAudio(playAudioInfo); 141 | }); 142 | 143 | eventBus.on("playEmotionAudio", (emotype: string) => { 144 | if (emotionSound) { 145 | emotionSound.stop(); 146 | } 147 | emotionSound = getAudio(usePlayerStore().emotionSoundUrl(emotype)); 148 | emotionSound.play({ 149 | volume: soundSettings.SFXvolume, 150 | }); 151 | }); 152 | 153 | eventBus.on("playOtherSounds", sound => { 154 | console.log("Play Select Sound!"); 155 | playAudio({ soundUrl: usePlayerStore().otherSoundUrl(sound) }); 156 | }); 157 | 158 | eventBus.on("playBgEffectSound", bgEffect => { 159 | playAudio({ soundUrl: usePlayerStore().bgEffectSoundUrl(bgEffect) }); 160 | }); 161 | 162 | eventBus.on("dispose", () => soundDispose()); 163 | eventBus.on("stop", () => soundDispose()); 164 | eventBus.on("continue", () => bgm?.play()); 165 | eventBus.on("playAudioWithConfig", ({ url, config }) => { 166 | getAudio(url).play(config); 167 | }); 168 | } 169 | 170 | export function soundDispose() { 171 | for (const sound of audioMap.values()) { 172 | sound.stop(); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /lib/layers/textLayer/assets/text-next.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ba-archive/ba-story-player/5efd761d8f48f4be4510d2e0b1a0e585a21d779a/lib/layers/textLayer/assets/text-next.webp -------------------------------------------------------------------------------- /lib/layers/textLayer/assets/title_border__lower_left.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/layers/textLayer/assets/title_border__lower_right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/layers/textLayer/assets/title_border__upper_left.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/layers/textLayer/assets/title_border__upper_right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/layers/textLayer/index.d.ts: -------------------------------------------------------------------------------- 1 | import Typed from "typed.js"; 2 | declare module "typed.js" { 3 | declare interface TypedExtend extends Typed { 4 | strings: string[]; // 要打印的内容 5 | pause: { 6 | // 暂停时保存的回复参数 7 | curStrPos: number; // 暂停前字符串指针位置 8 | curString: string; // 暂停前打印的字符串 9 | status: boolean; // 是否在暂停 10 | typewrite: boolean; // 是否是在打印, 如果为false表示暂停前在删除 11 | }; 12 | strPos: number; // 开始位置, 已废弃 13 | options: TypedOptions; // 初始化参数 14 | startDelay: number; // 开始时延, 已废弃 15 | typingComplete: boolean; // 是否打印完成, 如果为true会忽略start()方法 16 | timeout: Nodejs.Timeout; // 打字机内部循环handler 17 | isSt: boolean; // 是否在显示特效字, 为true会在clearSt事件前将拿到的st特效append上去 18 | } 19 | declare interface TypedOptions { 20 | /** 21 | * strings to be typed 22 | */ 23 | strings?: string[]; 24 | /** 25 | * ID or instance of HTML element of element containing string children 26 | */ 27 | stringsElement?: string | Element; 28 | /** 29 | * type speed in milliseconds 30 | */ 31 | typeSpeed?: number; 32 | /** 33 | * time before typing starts in milliseconds 34 | */ 35 | startDelay?: number; 36 | /** 37 | * backspacing speed in milliseconds 38 | */ 39 | backSpeed?: number; 40 | /** 41 | * only backspace what doesn't match the previous string 42 | */ 43 | smartBackspace?: boolean; 44 | /** 45 | * shuffle the strings 46 | */ 47 | shuffle?: boolean; 48 | /** 49 | * time before backspacing in milliseconds 50 | */ 51 | backDelay?: number; 52 | /** 53 | * Fade out instead of backspace 54 | */ 55 | fadeOut?: boolean; 56 | /** 57 | * css class for fade animation 58 | */ 59 | fadeOutClass?: string; 60 | /** 61 | * Fade out delay in milliseconds 62 | */ 63 | fadeOutDelay?: number; 64 | /** 65 | * loop strings 66 | */ 67 | loop?: boolean; 68 | /** 69 | * amount of loops 70 | */ 71 | loopCount?: number; 72 | /** 73 | * show cursor 74 | */ 75 | showCursor?: boolean; 76 | /** 77 | * character for cursor 78 | */ 79 | cursorChar?: string; 80 | /** 81 | * insert CSS for cursor and fadeOut into HTML 82 | */ 83 | autoInsertCss?: boolean; 84 | /** 85 | * attribute for typing Ex: input placeholder, value, or just HTML text 86 | */ 87 | attr?: string; 88 | /** 89 | * bind to focus and blur if el is text input 90 | */ 91 | bindInputFocusEvents?: boolean; 92 | /** 93 | * 'html' or 'null' for plaintext 94 | */ 95 | contentType?: string; 96 | /** 97 | * All typing is complete 98 | */ 99 | onComplete?(self: Typed): void; 100 | /** 101 | * Before each string is typed 102 | */ 103 | preStringTyped?(arrayPos: number, self: Typed): void; 104 | /** 105 | * After each string is typed 106 | */ 107 | onStringTyped?(arrayPos: number, self: Typed): void; 108 | /** 109 | * During looping, after last string is typed 110 | */ 111 | onLastStringBackspaced?(self: Typed): void; 112 | /** 113 | * Typing has been stopped 114 | */ 115 | onTypingPaused?(arrayPos: number, self: Typed): void; 116 | /** 117 | * Typing has been started after being stopped 118 | */ 119 | onTypingResumed?(arrayPos: number, self: Typed): void; 120 | /** 121 | * After reset 122 | */ 123 | onReset?(self: Typed): void; 124 | /** 125 | * After stop 126 | */ 127 | onStop?(arrayPos: number, self: Typed): void; 128 | /** 129 | * After start 130 | */ 131 | onStart?(arrayPos: number, self: Typed): void; 132 | /** 133 | * After destroy 134 | */ 135 | onDestroy?(self: Typed): void; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /lib/layers/textLayer/responsive-video-background.d.ts: -------------------------------------------------------------------------------- 1 | declare module "vue-responsive-video-background-player"; 2 | -------------------------------------------------------------------------------- /lib/layers/uiLayer/README.md: -------------------------------------------------------------------------------- 1 | # UILayer (UI 层) 2 | 3 | ## TODOs 4 | 5 | - onUnmounted unregister event 6 | -------------------------------------------------------------------------------- /lib/layers/uiLayer/assets/Common_Btn_Normal_Y_S_Pt.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ba-archive/ba-story-player/5efd761d8f48f4be4510d2e0b1a0e585a21d779a/lib/layers/uiLayer/assets/Common_Btn_Normal_Y_S_Pt.webp -------------------------------------------------------------------------------- /lib/layers/uiLayer/assets/Conquest_2nd_Eventlobby_GaugeBg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ba-archive/ba-story-player/5efd761d8f48f4be4510d2e0b1a0e585a21d779a/lib/layers/uiLayer/assets/Conquest_2nd_Eventlobby_GaugeBg.png -------------------------------------------------------------------------------- /lib/layers/uiLayer/assets/Conquest_2nd_Eventlobby_GaugeFront.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ba-archive/ba-story-player/5efd761d8f48f4be4510d2e0b1a0e585a21d779a/lib/layers/uiLayer/assets/Conquest_2nd_Eventlobby_GaugeFront.png -------------------------------------------------------------------------------- /lib/layers/uiLayer/assets/Deco_GachaItemBg.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ba-archive/ba-story-player/5efd761d8f48f4be4510d2e0b1a0e585a21d779a/lib/layers/uiLayer/assets/Deco_GachaItemBg.webp -------------------------------------------------------------------------------- /lib/layers/uiLayer/assets/Image_AngleBtn_Deco.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ba-archive/ba-story-player/5efd761d8f48f4be4510d2e0b1a0e585a21d779a/lib/layers/uiLayer/assets/Image_AngleBtn_Deco.png -------------------------------------------------------------------------------- /lib/layers/uiLayer/assets/UITex_BGPoliLight_1.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/layers/uiLayer/assets/close.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/layers/uiLayer/assets/fast-forward.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/layers/uiLayer/assets/icon-hide.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/layers/uiLayer/assets/icon-show.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/layers/uiLayer/assets/menu.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/layers/uiLayer/assets/pan-arrow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/layers/uiLayer/assets/title-banner.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/layers/uiLayer/components/BaButton.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 40 | 41 | 98 | -------------------------------------------------------------------------------- /lib/layers/uiLayer/components/BaChatLog/BaChatLog.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 42 | 43 | 83 | -------------------------------------------------------------------------------- /lib/layers/uiLayer/components/BaChatLog/BaChatMessage.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 62 | 63 | 213 | -------------------------------------------------------------------------------- /lib/layers/uiLayer/components/BaDialog.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 80 | 81 | 173 | -------------------------------------------------------------------------------- /lib/layers/uiLayer/components/BaSelector.vue: -------------------------------------------------------------------------------- 1 | 120 | 121 | 158 | 159 | 235 | -------------------------------------------------------------------------------- /lib/layers/uiLayer/index.ts: -------------------------------------------------------------------------------- 1 | export function uiInit() {} 2 | -------------------------------------------------------------------------------- /lib/layers/uiLayer/userInteract.ts: -------------------------------------------------------------------------------- 1 | import eventBus from "@/eventBus"; 2 | import { eventEmitter, storyHandler } from "@/index"; 3 | import { usePlayerStore } from "@/stores"; 4 | const keyStatus = {} as any; 5 | 6 | // 进行下一步的定时器 7 | let nexting: any; 8 | const keyEvent = (e: KeyboardEvent) => { 9 | keyStatus[e.key] = true; 10 | if (Object.values(keyStatus).filter(i => i).length >= 2) { 11 | return; 12 | } 13 | // 显示历史 log 不允许操作 14 | if (eventEmitter.isStoryLogShow || !isPlayerFocus()) { 15 | return; 16 | } 17 | switch (e.key) { 18 | case "Enter": 19 | case " ": 20 | if (!nexting) { 21 | interactNext(); 22 | nexting = setInterval(() => { 23 | interactNext(); 24 | }, 1000); 25 | } 26 | break; 27 | case "ArrowUp": 28 | eventBus.emit("showStoryLog", true); 29 | break; 30 | case "Control": 31 | // 限流 32 | storyHandler.isSkip = true; 33 | if (!nexting) { 34 | interactNext(); 35 | nexting = setInterval(() => { 36 | eventBus.emit("skipping"); 37 | interactNext(); 38 | }, 200); 39 | } 40 | } 41 | }; 42 | const keyUpEvent = (e: KeyboardEvent) => { 43 | keyStatus[e.key] = false; 44 | clearInterval(nexting); 45 | nexting = undefined; 46 | switch (e.key) { 47 | case "Control": 48 | storyHandler.isSkip = false; 49 | } 50 | }; 51 | let isScrollBottom = false; 52 | const wheelEvent = (e: WheelEvent & { [key: string]: any }) => { 53 | const uiScrollElem = document.querySelector(".ba-chat-content"); 54 | const delta = e.wheelDelta ? e.wheelDelta : -e.detail; 55 | if (delta > 0) { 56 | isScrollBottom = false; 57 | } 58 | if ( 59 | delta < 0 && 60 | (uiScrollElem?.scrollTop || 0) + (uiScrollElem?.clientHeight || 0) >= 61 | (uiScrollElem?.scrollHeight || 0) - 6 62 | ) { 63 | // 避免滑到底部就立刻关了, 再滚一次才生效 64 | if (isScrollBottom) { 65 | eventBus.emit("showStoryLog", false); 66 | isScrollBottom = false; 67 | return; 68 | } 69 | isScrollBottom = true; 70 | } 71 | if (eventEmitter.isStoryLogShow || !isPlayerFocus()) { 72 | return; 73 | } 74 | if (delta < 0) { 75 | interactNext(); 76 | } else { 77 | eventBus.emit("showStoryLog", true); 78 | } 79 | }; 80 | 81 | eventBus.on("loaded", () => { 82 | document.addEventListener("keydown", keyEvent); 83 | document.addEventListener("keyup", keyUpEvent); 84 | document 85 | .querySelector("#player") 86 | ?.addEventListener("wheel", wheelEvent as any); 87 | }); 88 | eventBus.on("dispose", () => { 89 | document.removeEventListener("keydown", keyEvent); 90 | document.removeEventListener("keyup", keyUpEvent); 91 | document 92 | .querySelector("#player") 93 | ?.addEventListener("wheel", wheelEvent as any); 94 | }); 95 | 96 | function getLastDataFromIndex(index: number) { 97 | const allStory = usePlayerStore().allStoryUnit; 98 | const recentStory = allStory.slice(0, index + 1).reverse(); 99 | const lastCharacterIdx = recentStory.findIndex(currentStoryUnit => { 100 | return currentStoryUnit.characters?.length; 101 | }); 102 | const lastCharacter = recentStory[lastCharacterIdx]; 103 | const characters = lastCharacter?.characters || []; 104 | if (lastCharacter) { 105 | // 拼装人物层展示情况 106 | recentStory.slice(lastCharacterIdx + 1).some(story => { 107 | if (story.characters?.length) { 108 | const filterSamePosition = story.characters.filter(character => { 109 | return !characters.find(j => j.position === character.position); 110 | }); 111 | filterSamePosition.forEach(character => { 112 | character.highlight = false; 113 | character.effects = []; 114 | }); 115 | characters.push(...filterSamePosition); 116 | } 117 | return story.hide === "all"; 118 | }); 119 | } 120 | const lastBg = recentStory.find(currentStoryUnit => { 121 | return currentStoryUnit.bg; 122 | }); 123 | const lastBgm = recentStory.find(currentStoryUnit => { 124 | return currentStoryUnit.audio?.bgm; 125 | }); 126 | return { lastBg, lastBgm, lastCharacter }; 127 | } 128 | 129 | export const changeStoryIndex = (index?: number) => { 130 | index = parseInt(index + ""); 131 | if (typeof index !== "number") return; 132 | index -= 1; 133 | if (index < 0) index = 0; 134 | eventBus.emit("removeEffect"); 135 | const { lastCharacter, lastBg, lastBgm } = getLastDataFromIndex(index); 136 | const { 137 | lastCharacter: curCharacter, 138 | lastBg: curBg, 139 | lastBgm: curBgm, 140 | } = getLastDataFromIndex(storyHandler.currentStoryIndex); 141 | const isSameCharacter = 142 | JSON.stringify(lastCharacter?.characters?.map(i => i.CharacterName)) === 143 | JSON.stringify(curCharacter?.characters?.map(i => i.CharacterName)); 144 | const isSameBgm = 145 | JSON.stringify(lastBgm?.audio?.bgm) === JSON.stringify(curBgm?.audio?.bgm); 146 | // 如果和跳转前相同就不去除了, 避免闪动 147 | if (!isSameCharacter) { 148 | eventBus.emit("hideCharacter"); 149 | } 150 | setTimeout(() => { 151 | // 在 hideCharacter 后触发 152 | eventEmitter.showCharacter(lastCharacter); 153 | }, 4); 154 | !isSameBgm && eventEmitter.playAudio(lastBgm); 155 | eventEmitter.showBg(lastBg); 156 | storyHandler.currentStoryIndex = index; 157 | eventBus.emit("next"); 158 | }; 159 | 160 | function interactNext() { 161 | const currentStoryUnit = storyHandler.currentStoryUnit; 162 | if ( 163 | currentStoryUnit?.textAbout?.options || 164 | currentStoryUnit?.textAbout?.titleInfo || 165 | eventEmitter.l2dPlaying || 166 | currentStoryUnit.l2d 167 | ) { 168 | console.log("不允许下一步", storyHandler.currentStoryUnit); 169 | return; 170 | } 171 | eventBus.emit("next"); 172 | } 173 | function isPlayerFocus() { 174 | return document.activeElement?.className.includes("baui"); 175 | } 176 | function focusPlayer() { 177 | // 选择后继续能跳过 178 | (document.querySelector(".baui") as any).focus(); 179 | } 180 | eventBus.on("select", () => { 181 | focusPlayer(); 182 | }); 183 | eventBus.on("isStoryLogShow", e => { 184 | // 选择后继续能跳过 185 | !e && focusPlayer(); 186 | }); 187 | -------------------------------------------------------------------------------- /lib/layers/uiLayer/utils.ts: -------------------------------------------------------------------------------- 1 | import gsap from "gsap"; 2 | 3 | // 按钮激活动画 4 | function effectBtnMouseDown(duration = 0.15, scale = 0.94) { 5 | return (ev: Event) => { 6 | console.log("effectBtnMouseDown"); 7 | gsap.to(ev.currentTarget, { 8 | duration: duration, 9 | scale: scale, 10 | ease: "power3.out", 11 | force3D: true, 12 | }); 13 | }; 14 | } 15 | 16 | // 按钮失活动画 17 | function effectBtnMouseUp(duration = 0.3, scale = 1) { 18 | return (ev: Event) => { 19 | console.log("effectBtnMouseUp"); 20 | gsap.to(ev.currentTarget, { 21 | duration: duration, 22 | scale: scale, 23 | force3D: true, 24 | }); 25 | }; 26 | } 27 | 28 | /** 29 | * 按钮动画 30 | * @args 控制动画参数 31 | * args.durationDown: 按下去的动画时间 32 | * args.scaleDown: 按下按钮,按钮的 scale 变化量 33 | * args.durationUp: 松开按钮的动画时间 34 | * args.scaleUp: 松开按钮的 scale 变化量 35 | */ 36 | function buttonAnimation( 37 | elem: { 38 | cssSelector?: string; 39 | elem?: Element; 40 | elems?: Element[]; 41 | }, 42 | args: { 43 | scaleDown: number; 44 | durationDown: number; 45 | scaleUp: number; 46 | durationUp: number; 47 | } = { durationDown: 0.15, scaleDown: 0.95, durationUp: 0.3, scaleUp: 1 } 48 | ) { 49 | let elems; 50 | if (elem.cssSelector) { 51 | elems = document.querySelectorAll(elem.cssSelector); 52 | } else if (elem.elem) { 53 | elems = [elem.elem]; 54 | } else if (elem.elems) { 55 | elems = elem.elems; 56 | } else { 57 | return; 58 | } 59 | console.log("buttonAnimation: ", elem, elems); 60 | elems.forEach(elem => { 61 | elem.addEventListener( 62 | "mousedown", 63 | effectBtnMouseDown(args.durationUp, args.scaleDown) 64 | ); 65 | elem.addEventListener( 66 | "touchstart", 67 | effectBtnMouseDown(args.durationUp, args.scaleDown) 68 | ); 69 | elem.addEventListener( 70 | "mouseup", 71 | effectBtnMouseUp(args.durationUp, args.scaleUp) 72 | ); 73 | elem.addEventListener( 74 | "touchend", 75 | effectBtnMouseUp(args.durationUp, args.scaleUp) 76 | ); 77 | elem.addEventListener( 78 | "mouseleave", 79 | effectBtnMouseUp(args.durationUp, args.scaleUp) 80 | ); 81 | }); 82 | } 83 | 84 | export { buttonAnimation, effectBtnMouseDown, effectBtnMouseUp }; 85 | -------------------------------------------------------------------------------- /lib/main.ts: -------------------------------------------------------------------------------- 1 | import BaStoryPlayer, { PlayerProps } from "./BaStoryPlayer.vue"; 2 | 3 | export default BaStoryPlayer; 4 | export type { PlayerProps }; 5 | -------------------------------------------------------------------------------- /lib/stores/index.ts: -------------------------------------------------------------------------------- 1 | import { BGEffectImgTable } from "@/types/effectLayer"; 2 | import { 3 | Actions, 4 | GetterFunctions, 5 | Getters, 6 | LogText, 7 | PrivateStates, 8 | PublicStates, 9 | } from "@/types/store"; 10 | import { getResourcesUrl } from "@/utils"; 11 | import { ref } from "vue"; 12 | import { storyHandler } from ".."; 13 | 14 | // let characterNameTable = { 15 | // '유우카 체육복ND': 3715128518, 16 | // '???': 0, 17 | // '린': 2690570743, 18 | // '유우카': 4283125014, 19 | // '하스미': 3571276574, 20 | // '치나츠': 1867911819, 21 | // '스즈미': 1034441153, 22 | // '통신모모카': 3025942184 23 | // } 24 | 25 | let emotionResourcesTable = { 26 | Heart: ["Emoticon_Balloon_N.png", "Emoticon_Heart.png"], 27 | Respond: ["Emoticon_Action.png"], 28 | Music: ["Emoticon_Note.png"], 29 | Twinkle: ["Emoticon_Twinkle.png"], 30 | Upset: ["Emoticon_Balloon_N.png", "Emoticon_Anxiety.png"], 31 | Sweat: ["Emoticon_Sweat_1.png", "Emoticon_Sweat_2.png"], 32 | Dot: ["Emoticon_Balloon_N.png", "Emoticon_Idea.png"], 33 | Exclaim: ["Emoticon_ExclamationMark.png"], 34 | Surprise: ["Emoticon_Exclamation.png", "Emoticon_Question.png"], 35 | Question: ["Emoticon_QuestionMark.png"], 36 | Shy: ["Emoticon_Balloon_N.png", "Emoticon_Shy.png"], 37 | Angry: ["Emoticon_Aggro.png"], 38 | Chat: ["Emoticon_Chat.png"], 39 | Sad: ["Emoji_Sad.png"], 40 | Steam: ["Emoji_Steam.png"], 41 | Sigh: ["Emoji_Sigh.png"], 42 | Bulb: ["Emoticon_Balloon_N.png", "Emoji_Bulb_1.png", "Emoji_Bulb_2.png"], 43 | Tear: ["Emoji_Tear_1.png", "Emoji_Tear_2.png"], 44 | Zzz: ["Emoji_Zzz.png"], 45 | // TODO: Upset, Music, Think, Bulb, Sigh, Steam, Zzz, Tear 46 | }; 47 | 48 | let fxImageTable = { 49 | shot: ["fire1.png", "fire2.png", "fire3.png"], 50 | }; 51 | 52 | /** 53 | * 请在此处填入需要的图片资源的名称 54 | */ 55 | let bgEffectImgTable: BGEffectImgTable = { 56 | "": [], 57 | "BG_ScrollT_0.5": [], 58 | BG_Filter_Red: [], 59 | BG_Wave_F: [], 60 | BG_Flash: [], 61 | BG_UnderFire_R: [], 62 | BG_Love_L: [ 63 | "FX_TEX_Img_Heart_01.png", 64 | "FX_TEX_SCN_Ring_02.png", 65 | "Gacha/FX_TEX_GT_Circle_Blur_inv.png", 66 | ], 67 | "BG_ScrollB_0.5": [], 68 | BG_Rain_L: ["HardRain.png"], 69 | BG_UnderFire: [ 70 | "FX_TEX_Smoke_17.png", 71 | "fire1.png", 72 | "fire2.png", 73 | "fire3.png", 74 | "HardRain.png", 75 | ], 76 | BG_WaveShort_F: [], 77 | BG_SandStorm_L: ["FX_TEX_Smoke_10a.png"], 78 | "BG_ScrollT_1.5": [], 79 | BG_Shining_L: [ 80 | "FX_TEX_SCN_Ring_02.png", 81 | "FX_TEX_Flare_23.png", 82 | "FX_TEX_SCN_Circle_Love.png", 83 | "Gacha/FX_TEX_GT_Circle_Blur_inv.png", 84 | ], 85 | "BG_ScrollB_1.0": [], 86 | BG_Love_L_BGOff: [ 87 | "FX_TEX_Img_Heart_01.png", 88 | "FX_TEX_SCN_Ring_02.png", 89 | "FX_TEX_SCN_Circle_Love.png", 90 | ], 91 | BG_Dust_L: ["FX_TEX_Smoke_Scroll_23.png", "dust_spark.png"], 92 | "BG_ScrollL_0.5": [], 93 | "BG_ScrollL_1.0": [], 94 | BG_Ash_Black: [], 95 | BG_Mist_L: [], 96 | BG_Flash_Sound: ["FX_TEX_Lightning_Line_16.png"], 97 | "BG_ScrollL_1.5": [], 98 | BG_FocusLine: ["FX_TEX_SCN_FocusLine5.png"], 99 | "BG_ScrollR_1.5": [], 100 | BG_Shining_L_BGOff: [ 101 | "FX_TEX_SCN_Ring_02.png", 102 | "FX_TEX_Flare_23.png", 103 | "Gacha/FX_TEX_GT_Circle_Blur_inv.png", 104 | ], 105 | "BG_ScrollT_1.0": [], 106 | "BG_ScrollB_1.5": [], 107 | BG_Filter_Red_BG: [], 108 | BG_Ash_Red: [], 109 | BG_Fireworks_L_BGOff_02: [], 110 | "BG_ScrollR_0.5": [], 111 | BG_Snow_L: [], 112 | BG_Fireworks_L_BGOff_01: [], 113 | "BG_ScrollR_1.0": [], 114 | }; 115 | 116 | let privateState: PrivateStates = { 117 | language: "Cn", 118 | userName: "", 119 | dataUrl: "", 120 | app: null, 121 | l2dSpineUrl: "", 122 | curL2dConfig: null, 123 | translator: "", 124 | storySummary: { 125 | chapterName: "", 126 | summary: "", 127 | }, 128 | 129 | allStoryUnit: [], 130 | 131 | //文字层 132 | logText: ref([]), 133 | 134 | //背景层 135 | bgInstance: null, 136 | 137 | //资源管理 138 | BGNameExcelTable: new Map(), 139 | CharacterNameExcelTable: new Map(), 140 | BGMExcelTable: new Map(), 141 | BGEffectExcelTable: new Map(), 142 | TransitionExcelTable: new Map(), 143 | EmotionExcelTable: new Map(), 144 | emotionResourcesTable: new Map(Object.entries(emotionResourcesTable)), 145 | fxImageTable: new Map(Object.entries(fxImageTable)), 146 | bgEffectImgMap: new Map(Object.entries(bgEffectImgTable)), 147 | }; 148 | 149 | let getterFunctions: GetterFunctions = { 150 | app() { 151 | if (privateState === null) { 152 | throw new Error("app实例不存在"); 153 | } 154 | return privateState.app!; 155 | }, 156 | 157 | characterSpineData: () => (CharacterName: number) => { 158 | return privateState.app?.loader.resources[String(CharacterName)].spineData; 159 | }, 160 | 161 | /** 162 | * 获取情绪动画的图片url, 按从底部到顶部, 从左到右排列资源. 163 | */ 164 | emotionResources: () => (emotionName: string) => { 165 | return privateState.emotionResourcesTable.get(emotionName); 166 | }, 167 | 168 | /** 169 | * 获取情绪动画的图片url, 按从底部到顶部, 从左到右排列资源. 170 | */ 171 | fxImages: () => (fxName: string) => { 172 | return privateState.fxImageTable.get(fxName); 173 | }, 174 | 175 | /** 176 | * 获取emotion的对应声音资源的url, 传入的参数是emotion的名字 177 | */ 178 | emotionSoundUrl: () => emotionName => { 179 | return getResourcesUrl( 180 | "emotionSound", 181 | `SFX_Emoticon_Motion_${emotionName}` 182 | ); 183 | }, 184 | 185 | otherSoundUrl: () => sound => { 186 | return getResourcesUrl("otherSound", sound); 187 | }, 188 | 189 | bgEffectSoundUrl: () => bgeffect => { 190 | return getResourcesUrl("bgEffectSounds", bgeffect); 191 | }, 192 | 193 | l2dSpineData() { 194 | const resource = 195 | privateState.app?.loader.resources[privateState.l2dSpineUrl]; 196 | if (resource) return resource.spineData; 197 | }, 198 | }; 199 | 200 | let actions: Actions = { 201 | setBgInstance(instance) { 202 | privateState.bgInstance = instance; 203 | }, 204 | updateLogText(newLog) { 205 | if ("SelectionGroup" in newLog) { 206 | privateState.logText.value.push({ 207 | type: "user", 208 | text: newLog.text.map(it => it.content).join(""), 209 | index: newLog.index, 210 | name: privateState.userName, 211 | }); 212 | } else { 213 | let text = ""; 214 | for (let textPart of newLog.text) { 215 | text += textPart.content; 216 | } 217 | if (newLog.speaker) { 218 | privateState.logText.value.push({ 219 | type: "character", 220 | text, 221 | avatarUrl: newLog.avatarUrl, 222 | name: newLog.speaker.name, 223 | index: newLog.index, 224 | }); 225 | } else { 226 | privateState.logText.value.push({ 227 | type: "none", 228 | text, 229 | index: newLog.index, 230 | }); 231 | } 232 | } 233 | // 日志不会超过当前的故事进度, 并且不重复 234 | privateState.logText.value = privateState.logText.value.reduce( 235 | (acc, cur) => { 236 | if (cur.index && cur.index <= storyHandler.currentStoryIndex) { 237 | const find = acc.find(i => i.index === cur.index); 238 | if (!find) { 239 | acc.push(cur); 240 | } 241 | } 242 | return acc; 243 | }, 244 | [] as LogText[] 245 | ); 246 | }, 247 | setL2DSpineUrl(url) { 248 | privateState.l2dSpineUrl = url; 249 | }, 250 | setL2DConfig(val) { 251 | privateState.curL2dConfig = val; 252 | }, 253 | setTranslator(translator: string) { 254 | privateState.translator = translator; 255 | }, 256 | }; 257 | 258 | let store = { 259 | currentCharacterMap: new Map(), 260 | ...actions, 261 | }; 262 | 263 | for (let getter of Object.keys(getterFunctions) as Array< 264 | keyof GetterFunctions 265 | >) { 266 | Reflect.defineProperty(store, getter, { 267 | get: () => getterFunctions[getter](), 268 | }); 269 | } 270 | 271 | for (let state of Object.keys(privateState) as Array) { 272 | if (!["app"].includes(state)) { 273 | Reflect.defineProperty(store, state, { 274 | get: () => privateState[state], 275 | }); 276 | } 277 | } 278 | 279 | /** 280 | * 资源调用接口 281 | * @returns 资源调用工具对象 282 | */ 283 | export let usePlayerStore = () => { 284 | return store as unknown as PublicStates & 285 | Getters & 286 | Readonly & 287 | Actions; 288 | }; 289 | 290 | /** 291 | * 返回可修改的privateState, 仅本体在初始化时可调用 292 | */ 293 | export let initPrivateState = () => privateState; 294 | -------------------------------------------------------------------------------- /lib/types/bgLayer.ts: -------------------------------------------------------------------------------- 1 | import { LoaderResource, Sprite } from "pixi.js"; 2 | 3 | import { Dict } from "@/types/common"; 4 | import { BgParams } from "@/types/events"; 5 | 6 | export interface BgLayer { 7 | /** 8 | * 初始化 BgLayer 实例函数 9 | */ 10 | init(): void; 11 | /** 12 | * 销毁 BgLayer 实例函数 13 | */ 14 | dispose(): void; 15 | /** 16 | * 初始化实例事件 17 | */ 18 | initEvent(): void; 19 | /** 20 | * 销毁实例事件 21 | */ 22 | disposeEvent(): void; 23 | /** 24 | * showBg 事件处理函数 25 | * @param params 背景图片参数 26 | */ 27 | handleShowBg(params: BgParams): void; 28 | /** 29 | * 处理canvas尺寸变化 30 | */ 31 | handleResize(): void; 32 | /** 33 | * 从 Loader Resource 获取背景 Sprite 34 | * @param resources loader resources 35 | * @param name 背景图片名 36 | */ 37 | getBgSpriteFromResource( 38 | resources: Dict, 39 | name: string 40 | ): Sprite | undefined; 41 | /** 42 | * 直接加载背景 43 | */ 44 | loadBg(instance: Sprite): void; 45 | /** 46 | * 基于 bgoverlap 特效加载背景 47 | */ 48 | loadBgOverlap(instance: Sprite, overlap: number): void; 49 | } 50 | -------------------------------------------------------------------------------- /lib/types/common.ts: -------------------------------------------------------------------------------- 1 | import type { Spine } from "pixi-spine"; 2 | import { 3 | PlayAudio, 4 | PlayEffect, 5 | ShowOption, 6 | ShowText, 7 | ShowTitleOption, 8 | StArgs, 9 | } from "./events"; 10 | import { TransitionTableItem } from "./excels"; 11 | import { Language, StorySummary } from "./store"; 12 | export interface Character { 13 | /** 14 | * 人物位置 15 | */ 16 | position: number; 17 | /** 18 | * 人物CharacterName, 请通过它获取人物spinedata 19 | */ 20 | CharacterName: number; 21 | /** 22 | * 人物spinedata的url 23 | */ 24 | spineUrl: string; 25 | /** 26 | * 人物表情 27 | */ 28 | face: string; 29 | /** 30 | * 人物是否高亮 31 | */ 32 | highlight: boolean; 33 | /** 34 | * 人物是否是全息投影状态 35 | */ 36 | signal: boolean; 37 | /** 38 | * 人物特效 39 | */ 40 | effects: CharacterEffect[]; 41 | } 42 | export interface CharacterEffect { 43 | type: CharacterEffectType; 44 | effect: string; 45 | async: boolean; 46 | arg?: string; 47 | } 48 | export type CharacterEffectType = "emotion" | "action" | "fx"; 49 | export interface CharacterInstance { 50 | CharacterName: number; 51 | /** 52 | * 角色当前所在位置 1,2,3,4,5 53 | * 54 | * 会根据m1m2m3m4m5动态更新 55 | */ 56 | position: number; 57 | /** 58 | * nx脚本里那个初始位置, 永远不会改变 59 | * 60 | * 配合CharacterName唯一确定一个spine 61 | */ 62 | initPosition: number; 63 | /** 64 | * 当前人物表情 65 | * 66 | * 用来判断是否触发眨眼动画 67 | */ 68 | currentFace: string; 69 | /** 70 | * 眨眼定时器的handler 71 | */ 72 | winkObject?: WinkObject; 73 | instance: Spine; 74 | isShow: () => boolean; 75 | isOnStage: () => boolean; 76 | isHeightLight: () => boolean; 77 | } 78 | export type Dict = { 79 | [key: string]: T; 80 | }; 81 | export type Effect = 82 | | { 83 | type: "wait"; 84 | /** 85 | * 单位为ms 86 | */ 87 | args: number; 88 | } 89 | | { 90 | type: "zmc"; 91 | args: ZmcArgs; 92 | } 93 | | { 94 | type: "bgshake"; 95 | }; 96 | export interface Option { 97 | SelectionGroup: number; 98 | text: { 99 | TextJp: string; 100 | TextCn?: string; 101 | TextTw?: string; 102 | TextEn?: string; 103 | }; 104 | } 105 | export type PlayerConfigs = PlayerProps & { height: number }; 106 | export type PlayerProps = { 107 | story: TranslatedStoryUnit; 108 | dataUrl: string; 109 | width: number; 110 | height: number; 111 | language: Language; 112 | userName: string; 113 | storySummary: StorySummary; 114 | startFullScreen?: boolean; 115 | useMp3?: boolean; 116 | useSuperSampling?: "2" | "4" | ""; 117 | /** 跳转至传入的 index */ 118 | changeIndex?: number; 119 | }; 120 | export interface Speaker { 121 | /** 122 | * 人物姓名 123 | */ 124 | name: string; 125 | /** 126 | * 人物所属 127 | */ 128 | nickName: string; 129 | } 130 | export interface StoryRawUnit { 131 | GroupId: number; 132 | SelectionGroup: number; 133 | BGMId: number; 134 | Sound: string; 135 | Transition: number; 136 | BGName: number; 137 | BGEffect: number; 138 | PopupFileName: string; 139 | ScriptKr: string; 140 | TextJp: string; 141 | TextCn?: string; 142 | TextTw?: string; 143 | TextEn?: string; 144 | VoiceJp: string; 145 | } 146 | export type StoryType = 147 | | "title" 148 | | "place" 149 | | "text" 150 | | "option" 151 | | "st" 152 | | "effectOnly" 153 | | "continue" 154 | | "nextEpisode"; 155 | export interface StoryUnit { 156 | //rawUnit中的属性 157 | GroupId: number; 158 | SelectionGroup: number; 159 | PopupFileName: string; 160 | type: StoryType; 161 | audio?: PlayAudio; 162 | /** 163 | * 渐变 164 | */ 165 | transition?: TransitionTableItem; 166 | /** 167 | * 背景图片 168 | */ 169 | bg?: { 170 | url: string; 171 | /** 172 | * 以覆盖原来背景的方式显示, 值为渐变时间 173 | */ 174 | overlap?: number; 175 | }; 176 | l2d?: { 177 | spineUrl: string; 178 | animationName: string; 179 | }; 180 | effect: PlayEffect; 181 | characters: Character[]; 182 | /** 183 | * 文字相关的属性, 包括选项, 对话, st文字, 章节名, 人物名 184 | */ 185 | textAbout: { 186 | options?: ShowOption[]; 187 | /** 188 | * 地点, 标题, 章节名, 第一个地点下面的译者信息(如果有) 189 | */ 190 | titleInfo?: ShowTitleOption; 191 | /** 192 | * 显示的对话文字 193 | */ 194 | showText: ShowText; 195 | st?: { 196 | stArgs?: StArgs; 197 | clearSt?: boolean; 198 | middle?: boolean; 199 | }; 200 | }; 201 | fight?: number; 202 | hide?: "menu" | "all"; 203 | show?: "menu"; 204 | video?: { 205 | videoPath: string; 206 | soundPath: string; 207 | }; 208 | } 209 | export interface Text { 210 | /** 211 | * 文本 212 | */ 213 | content: string; 214 | /** 215 | * 显示文本前等待的时间 216 | */ 217 | waitTime?: number; 218 | /** 219 | * 文字特效 220 | */ 221 | effects: TextEffect[]; 222 | } 223 | export interface TextEffect { 224 | name: TextEffectName; 225 | /** 226 | * 特效参数 227 | */ 228 | value: string[]; 229 | } 230 | /** 231 | * 文字特效类型, 232 | * `color`颜色 233 | * `fontsize` 字体大小 234 | * `ruby` 日文注音 235 | */ 236 | export type TextEffectName = "color" | "fontsize" | "ruby"; 237 | export type TranslatedStoryUnit = { 238 | GroupId: number; 239 | translator: string; 240 | content: StoryRawUnit[]; 241 | }; 242 | export interface WinkAnimationObject { 243 | _pause: boolean; 244 | start(): void; 245 | pause(): void; 246 | } 247 | export interface WinkObject { 248 | handler: number; 249 | animationObject?: WinkAnimationObject; 250 | } 251 | /** 252 | * zmc参数, 当duration为10时代表是move起始 253 | */ 254 | export type ZmcArgs = 255 | | { 256 | type: "move"; 257 | position: [number, number]; 258 | size: number; 259 | duration: number; 260 | } 261 | | { 262 | type: "instant"; 263 | position: [number, number]; 264 | size: number; 265 | }; 266 | -------------------------------------------------------------------------------- /lib/types/effectLayer.ts: -------------------------------------------------------------------------------- 1 | import { Sprite } from "pixi.js"; 2 | import { BGEffectExcelTableItem, BGEffectType } from "./excels"; 3 | 4 | export type BGEffectImgTable = Record; 5 | 6 | export type EffectRemoveFunction = () => Promise; 7 | 8 | export type CurrentBGEffect = 9 | | { 10 | effect: BGEffectType; 11 | removeFunction: EffectRemoveFunction; 12 | resources: Sprite[]; 13 | } 14 | | undefined; 15 | 16 | export interface BGEffectHandlerOptions { 17 | BG_FocusLine: {}; 18 | "": {}; 19 | "BG_ScrollT_0.5": {}; 20 | BG_Filter_Red: {}; 21 | BG_Wave_F: {}; 22 | BG_Flash: {}; 23 | BG_UnderFire_R: {}; 24 | BG_Love_L: {}; 25 | "BG_ScrollB_0.5": {}; 26 | BG_Rain_L: { 27 | frequency: number; 28 | }; 29 | BG_UnderFire: {}; 30 | BG_WaveShort_F: {}; 31 | BG_SandStorm_L: {}; 32 | "BG_ScrollT_1.5": {}; 33 | BG_Shining_L: {}; 34 | "BG_ScrollB_1.0": {}; 35 | BG_Love_L_BGOff: {}; 36 | BG_Dust_L: {}; 37 | "BG_ScrollL_0.5": {}; 38 | "BG_ScrollL_1.0": {}; 39 | BG_Ash_Black: {}; 40 | BG_Mist_L: {}; 41 | BG_Flash_Sound: {}; 42 | "BG_ScrollL_1.5": {}; 43 | "BG_ScrollR_1.5": {}; 44 | BG_Shining_L_BGOff: {}; 45 | "BG_ScrollT_1.0": {}; 46 | "BG_ScrollB_1.5": {}; 47 | BG_Filter_Red_BG: {}; 48 | BG_Ash_Red: {}; 49 | BG_Fireworks_L_BGOff_02: {}; 50 | "BG_ScrollR_0.5": {}; 51 | BG_Snow_L: {}; 52 | BG_Fireworks_L_BGOff_01: {}; 53 | "BG_ScrollR_1.0": {}; 54 | } 55 | 56 | /** 57 | * BGEffect处理函数 58 | */ 59 | export type BGEffectHandlerFunction = ( 60 | resources: Sprite[], 61 | setting: BGEffectExcelTableItem, 62 | options: BGEffectHandlerOptions[type] 63 | ) => Promise; 64 | 65 | /** 66 | * 类型与处理函数的对应 67 | */ 68 | export type BGEffectHandlers = { 69 | [key in BGEffectType]: BGEffectHandlerFunction; 70 | }; 71 | -------------------------------------------------------------------------------- /lib/types/events.ts: -------------------------------------------------------------------------------- 1 | import { Character, Effect, Speaker, Text } from "./common"; 2 | import { PlayOptions } from "@pixi/sound"; 3 | import { 4 | BGEffectExcelTableItem, 5 | BGEffectType, 6 | BGMExcelTableItem, 7 | TransitionTableItem, 8 | } from "./excels"; 9 | import { OtherSounds } from "./resources"; 10 | 11 | export type Events = { 12 | //通用 13 | /** 14 | * 清除当前内容 15 | */ 16 | hide: undefined; 17 | /** 18 | * 参数是原来的宽度大小 19 | */ 20 | resize: number; 21 | /** 22 | * 注销 23 | */ 24 | dispose: undefined; 25 | /** 26 | * 暂停 27 | */ 28 | stop: undefined; 29 | /** 30 | * 继续播放bgm 31 | */ 32 | continue: undefined; 33 | 34 | //特效层 35 | 36 | /** 37 | * 播放特效 38 | */ 39 | playEffect: PlayEffect; 40 | /** 41 | * 移除当前特效 42 | */ 43 | removeEffect: undefined; 44 | effectDone: undefined; 45 | transitionIn: TransitionTableItem; 46 | transitionInDone: undefined; 47 | transitionOut: TransitionTableItem; 48 | transitionOutDone: undefined; 49 | 50 | //人物层 51 | /** 52 | * 展示人物 53 | */ 54 | showCharacter: ShowCharacter; 55 | /** 56 | * 隐藏角色 57 | */ 58 | hideCharacter: undefined; 59 | /** 60 | * 人物已处理完毕 61 | */ 62 | characterDone: undefined; 63 | /** 64 | * l2d 动画播放状态, 当前动画是否播放完成 65 | */ 66 | l2dAnimationDone: { done: boolean; animation: string }; 67 | 68 | //背景层 69 | /** 70 | * 展示背景图片 71 | */ 72 | showBg: BgParams; 73 | /** 74 | * bgOverLap已完成 75 | */ 76 | bgOverLapDone: undefined; 77 | 78 | /** 79 | * 播放bgm, sound或voiceJP 80 | */ 81 | playAudio: PlayAudio; 82 | /** 83 | * 播放人物情绪动作特效音 84 | */ 85 | playEmotionAudio: string; 86 | /** 87 | * 播放选项选择特效音 88 | */ 89 | playOtherSounds: OtherSounds; 90 | playBgEffectSound: BGEffectType; 91 | /** 92 | * 播放voiceJP结束提示 93 | */ 94 | playVoiceJPDone: string; 95 | /** 96 | * 根据指定的设置播放sound 97 | */ 98 | playAudioWithConfig: { 99 | url: string; 100 | config: PlayOptions; 101 | }; 102 | 103 | //UI层 104 | /** 105 | * 跳过剧情 106 | */ 107 | skip: undefined; 108 | /** 按contrl时跳过剧情 */ 109 | skipping: undefined; 110 | /** 111 | * 自动模式 112 | */ 113 | auto: undefined; 114 | /** 115 | * 停止自动模式 116 | */ 117 | stopAuto: undefined; 118 | /** 119 | * 隐藏对话框 120 | */ 121 | hideDialog: undefined; 122 | hidemenu: undefined; 123 | showmenu: undefined; 124 | // 显示历史 125 | showStoryLog: boolean; 126 | // 当前历史log是否显示 127 | isStoryLogShow: boolean; 128 | 129 | //文字层 130 | /** 131 | * 展示标题 132 | */ 133 | showTitle: ShowTitleOption; 134 | /** 135 | * 标题展示完成 136 | */ 137 | titleDone: undefined; 138 | /** 139 | * 展示地点 140 | */ 141 | showPlace: string; 142 | /** 143 | * 展示在地点下方的译者信息 144 | */ 145 | showPlaceTranslator: string; 146 | /** 147 | * 显示普通对话框文字 148 | */ 149 | showText: ShowText; 150 | /** 151 | * 显示无对话框文字 152 | */ 153 | st: StText; 154 | /** 155 | * 清除无对话框文字和对话框 156 | */ 157 | clearSt: undefined; 158 | /** 159 | * st动画播放完成 160 | */ 161 | stDone: undefined; 162 | /** 163 | * 对话框内容播放完成 164 | * **实际上st动画播放完成也会触发** 165 | */ 166 | textDone: undefined; 167 | /** 168 | * 显示选项 169 | */ 170 | option: ShowOption[]; 171 | /** 172 | * 进入下一剧情语句 173 | */ 174 | next: undefined; 175 | /** 176 | * 根据选项加入下一剧情语句 177 | */ 178 | select: number; 179 | /** 180 | * 弹出图片, 参数是图片url 181 | */ 182 | popupImage: string; 183 | /** 184 | * 弹出视频, 参数是视频url 185 | */ 186 | popupVideo: string; 187 | /** 188 | * 隐藏popup 189 | */ 190 | hidePopup: undefined; 191 | 192 | /** 193 | * 显示未完待续 194 | */ 195 | toBeContinue: undefined; 196 | toBeContinueDone: undefined; 197 | /** 198 | * 显示下章节 199 | */ 200 | nextEpisode: ShowTitleOption; 201 | nextEpisodeDone: undefined; 202 | 203 | //L2D层 204 | /** 205 | * 加载L2D 206 | */ 207 | playL2D: undefined; 208 | /** 209 | * 更换动画 210 | */ 211 | changeAnimation: string; 212 | /** 213 | * 结束l2d播放 214 | */ 215 | endL2D: undefined; 216 | 217 | /** 218 | * 用户点击 219 | */ 220 | click: undefined; 221 | /** 222 | * 开始播放加载动画 223 | * @param string 资源地址 224 | */ 225 | startLoading: string; 226 | /** 227 | * 某个资源加载完成或失败 228 | */ 229 | oneResourceLoaded: ResourceLoadState; 230 | /** 231 | * 所有资源加载完成 232 | */ 233 | loaded: undefined; 234 | }; 235 | 236 | export interface BgParams { 237 | /** 238 | * 背景图片 url 239 | */ 240 | url: string; 241 | /** 242 | * 以覆盖原来背景的方式显示新背景, 值为渐变时间 243 | */ 244 | overlap?: number; 245 | } 246 | 247 | export interface ShowCharacter { 248 | /** 249 | * 角色列表 250 | */ 251 | characters: Character[]; 252 | /** 253 | * 角色特效 254 | */ 255 | // characterEffects: CharacterEffect[] 256 | } 257 | 258 | export interface PlayAudio { 259 | bgm?: { 260 | url: string; 261 | bgmArgs: BGMExcelTableItem; 262 | }; 263 | soundUrl?: string; 264 | voiceJPUrl?: string; 265 | } 266 | 267 | export interface ShowText { 268 | /** 269 | * 文本 270 | */ 271 | text: Text[]; 272 | /** 273 | * 说话的人, 包括名字和所属 274 | */ 275 | speaker?: Speaker; 276 | /** 277 | * 人物头像, 填logText时使用 278 | */ 279 | avatarUrl?: string; 280 | /** storyUnit 位置 */ 281 | index?: number; 282 | } 283 | 284 | /** 285 | * st特效参数, 第一个为位置, 第二个为显示效果 286 | */ 287 | export type StArgs = [number[], "serial" | "instant" | "smooth", number]; 288 | 289 | export interface StText { 290 | /** 291 | * 文本 292 | */ 293 | text: Text[]; 294 | /** 295 | * st的参数, 目前只需要注意第二个参数, serial打字机效果, instant立即全部显示. 296 | */ 297 | stArgs: StArgs; 298 | middle: boolean; 299 | } 300 | 301 | export interface ShowOption { 302 | /** 303 | * 剧情原始结构SelectionGroup, 请作为next的参数 304 | */ 305 | SelectionGroup: number; 306 | /** 307 | * 选项文本 308 | */ 309 | text: Text[]; 310 | /** 当前剧情进度 */ 311 | index: number; 312 | } 313 | 314 | export interface PlayEffect { 315 | BGEffect?: BGEffectExcelTableItem; 316 | otherEffect: Effect[]; 317 | } 318 | 319 | export interface ShowTitleOption { 320 | title: Text[]; 321 | subtitle?: string; 322 | translator?: string; 323 | } 324 | 325 | export interface ResourceLoadState { 326 | type: "success" | "fail"; 327 | resourceName: string; 328 | } 329 | -------------------------------------------------------------------------------- /lib/types/excels.ts: -------------------------------------------------------------------------------- 1 | export interface BGNameExcelTableItem { 2 | Name: number; 3 | ProductionStep: string; 4 | BGFileName: string; 5 | BGType: "Spine" | "Image"; 6 | AnimationRoot: string; 7 | AnimationName: string; 8 | SpineScale: number; 9 | SpineLocalPosX: number; 10 | SpineLocalPosY: number; 11 | } 12 | 13 | export interface CharacterNameExcelTableItem { 14 | CharacterName: number; 15 | ProductionStep: string; 16 | NameKR: string; 17 | NicknameKR: string; 18 | NameJP: string; 19 | NicknameJP: string; 20 | NameCN?: string; 21 | NicknameCN?: string; 22 | Shape: string; 23 | SpinePrefabName: string; 24 | SmallPortrait: string; 25 | } 26 | 27 | export interface BGMExcelTableItem { 28 | Id: number; 29 | ProductionStep: string; 30 | Path: string; 31 | Volume: number; 32 | LoopStartTime: number; 33 | LoopEndTime: number; 34 | LoopTranstionTime: number; 35 | LoopOffsetTime: number; 36 | } 37 | 38 | export type TransitionTypes = "bgoverlap" | "fade" | "fade_white"; 39 | 40 | export interface TransitionTableItem { 41 | Name: number; 42 | TransitionOut: TransitionTypes; 43 | TransitionOutDuration: number; 44 | TransitionOutResource: null | string; 45 | TransitionIn: TransitionTypes; 46 | TransitionInDuration: number; 47 | TransitionInResource: null | string; 48 | } 49 | 50 | export type BGEffectType = 51 | | "BG_ScrollT_0.5" 52 | | "BG_Filter_Red" 53 | | "BG_Wave_F" 54 | | "BG_Flash" 55 | | "BG_UnderFire_R" 56 | | "BG_Love_L" 57 | | "BG_ScrollB_0.5" 58 | | "BG_Rain_L" 59 | | "BG_UnderFire" 60 | | "BG_WaveShort_F" 61 | | "BG_SandStorm_L" 62 | | "" 63 | | "BG_ScrollT_1.5" 64 | | "BG_Shining_L" 65 | | "BG_ScrollB_1.0" 66 | | "BG_Love_L_BGOff" 67 | | "BG_Dust_L" 68 | | "BG_ScrollL_0.5" 69 | | "BG_ScrollL_1.0" 70 | | "BG_Ash_Black" 71 | | "BG_Mist_L" 72 | | "BG_Flash_Sound" 73 | | "BG_ScrollL_1.5" 74 | | "BG_FocusLine" 75 | | "BG_ScrollR_1.5" 76 | | "BG_Shining_L_BGOff" 77 | | "BG_ScrollT_1.0" 78 | | "BG_ScrollB_1.5" 79 | | "BG_Filter_Red_BG" 80 | | "BG_Ash_Red" 81 | | "BG_Fireworks_L_BGOff_02" 82 | | "BG_ScrollR_0.5" 83 | | "BG_Snow_L" 84 | | "BG_Fireworks_L_BGOff_01" 85 | | "BG_ScrollR_1.0"; 86 | 87 | export interface BGEffectExcelTableItem { 88 | Name: number; 89 | Effect: BGEffectType; 90 | Scroll: "None" | "Vertical" | "Horizontal"; 91 | ScrollTime: number; 92 | ScrollFrom: number; 93 | ScrollTo: number; 94 | } 95 | -------------------------------------------------------------------------------- /lib/types/l2d.ts: -------------------------------------------------------------------------------- 1 | export type IL2dPlayQue = { 2 | name: string; 3 | animation: string; 4 | fadeTime?: number; 5 | secondFadeTime?: number; 6 | sounds?: { 7 | fileName: string; 8 | time: number; 9 | /** 10 | * 声音大小, 默认为2 11 | */ 12 | volume?: number; 13 | }[]; 14 | /** 和后一个动画是否fade */ 15 | fade?: boolean; 16 | }; 17 | export type IL2dConfig = { 18 | [key: string]: { 19 | name: string; 20 | playQue: IL2dPlayQue[]; 21 | spineSettings?: { 22 | [key: string]: { 23 | scale?: number; // 对单个 spine 文件进行设置 24 | }; 25 | }; 26 | /** 实际上是请求的路径 */ 27 | otherSpine?: string[]; 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /lib/types/resources.ts: -------------------------------------------------------------------------------- 1 | export type ResourcesTypes = 2 | | "emotionImg" 3 | | "emotionSound" 4 | | "fx" 5 | | "l2dSpine" 6 | | "l2dVoice" 7 | | "excel" 8 | | "bgm" 9 | | "sound" 10 | | "voiceJp" 11 | | "characterSpine" 12 | | "bg" 13 | | "otherSound" 14 | | "otherL2dSpine" 15 | | "bgEffectImgs" 16 | | "avatar" 17 | | "video" 18 | | "popupImage" 19 | | "bgEffectSounds"; 20 | 21 | // select:按钮点击音效,back:返回音效 22 | export type OtherSounds = "select" | "bg_underfire" | "back"; 23 | export type OtherSoundsUrls = { 24 | [key in OtherSounds]: string; 25 | }; 26 | -------------------------------------------------------------------------------- /lib/types/store.ts: -------------------------------------------------------------------------------- 1 | import { Application, Sprite } from "pixi.js"; 2 | import { Ref } from "vue"; 3 | import { CharacterInstance, StoryUnit } from "./common"; 4 | import { ShowOption, ShowText } from "./events"; 5 | import { 6 | BGEffectExcelTableItem, 7 | BGEffectType, 8 | BGMExcelTableItem, 9 | BGNameExcelTableItem, 10 | CharacterNameExcelTableItem, 11 | TransitionTableItem, 12 | } from "./excels"; 13 | import { IL2dConfig } from "./l2d"; 14 | import { OtherSounds } from "./resources"; 15 | 16 | export type Language = "Cn" | "Jp" | "En" | "Tw"; 17 | 18 | /** 19 | * 仅可通过函数修改的state 20 | */ 21 | export interface PrivateStates { 22 | /** 23 | * pixi.js app实例 24 | */ 25 | app: Application | null; 26 | /** 27 | * 后端资源前缀 28 | */ 29 | dataUrl: string; 30 | /** 31 | * 用户名, 如xx老师 32 | */ 33 | userName: string; 34 | language: Language; 35 | /** 36 | * 当前故事, 由一个个单元结合而成 37 | */ 38 | allStoryUnit: StoryUnit[]; 39 | /** 40 | * 用于查找l2d spinedata 41 | */ 42 | l2dSpineUrl: string; 43 | /** 当前剧情下的 l2d 特殊播放配置 */ 44 | curL2dConfig: null | IL2dConfig[keyof IL2dConfig]; 45 | 46 | /** 47 | * 译者信息, 仅在无title且无place的情况下有值 48 | */ 49 | translator: string; 50 | 51 | //背景层 52 | /** 53 | * 背景实例 54 | */ 55 | bgInstance: null | Sprite; 56 | 57 | //文字层 58 | /** 59 | * 已经展示过的语句的集合, 用于ui层显示日志 60 | */ 61 | logText: Ref; 62 | /** 63 | * 故事简要概述 64 | */ 65 | storySummary: StorySummary; 66 | 67 | //资源管理 68 | /** 69 | * 根据BGName获取资源信息, 包括l2d和背景图片 70 | */ 71 | BGNameExcelTable: Map; 72 | /** 73 | * 根据CharacterName获取角色name和nickName(名字与所属) 74 | */ 75 | CharacterNameExcelTable: Map; 76 | /** 77 | * 获取BGEffect 78 | */ 79 | BGEffectExcelTable: Map; 80 | 81 | /** 82 | * 根据bgm id获取bgm资源信息 83 | */ 84 | BGMExcelTable: Map; 85 | /** 86 | * 根据transition标识获取transition相关信息 87 | */ 88 | TransitionExcelTable: Map; 89 | /** 90 | * 根据emotionName获取对于英文名 91 | */ 92 | EmotionExcelTable: Map; 93 | /** 94 | * 根据emotion名获取emotion图片信息 95 | */ 96 | emotionResourcesTable: Map; 97 | fxImageTable: Map; 98 | bgEffectImgMap: Map; 99 | } 100 | 101 | /** 102 | * 可直接修改的state 103 | */ 104 | export interface PublicStates { 105 | /** 106 | * 人物层用于保存所有已创建的spine数据的map 107 | * 108 | * 注意, CharacterName只能唯一确定一个spine对象, 但是不能确定一个显示在player上的spine 109 | * 110 | * 在存在量产杂鱼的情况下, 需要结合initPosition来确定 111 | */ 112 | currentCharacterMap: Map; 113 | } 114 | 115 | export interface BasicGetters { 116 | app: Application; 117 | 118 | /** 119 | * 获取角色spineData 120 | */ 121 | characterSpineData: ( 122 | CharacterName: number 123 | ) => import("@pixi-spine/base").ISkeletonData | undefined; 124 | /** 125 | * 获取情绪图像资源 126 | * @param emotionName 情绪名 127 | * @returns 情绪资源图片url数组, 按从底而上, 从左到右排列 128 | */ 129 | emotionResources: (emotionName: string) => string[] | undefined; 130 | /** 131 | * 获取fx特效图像资源 132 | * @param fxName 133 | * @returns 图像资源url数组 134 | */ 135 | fxImages: (fxName: string) => string[] | undefined; 136 | 137 | emotionSoundUrl: (emotionName: string) => string; 138 | /** 139 | * 获取其他特效音url 140 | * @param type 特效音类型, 如select 141 | * @returns 142 | */ 143 | otherSoundUrl: (type: OtherSounds) => string; 144 | bgEffectSoundUrl: (bgEffect: BGEffectType) => string; 145 | /** 146 | * 获取L2D资源 147 | */ 148 | l2dSpineData: import("@pixi-spine/base").ISkeletonData | undefined; 149 | } 150 | 151 | export type GetterFunctions = { 152 | [Getter in keyof BasicGetters]: () => BasicGetters[Getter]; 153 | }; 154 | export type Getters = Readonly; 155 | 156 | export interface Actions { 157 | setBgInstance: (sprite: Sprite) => void; 158 | /** 159 | * 更新logText的值, 即已经显示过的文字和选项 160 | * @param newLog 新加入log的值, 可为对话或选项 161 | * @returns 162 | */ 163 | updateLogText: (newLog: ShowText | ShowOption) => void; 164 | /** 165 | * 设置l2d的spine数据地址便于l2d层获取spinedata 166 | * @param url 167 | * @returns 168 | */ 169 | setL2DSpineUrl: (url: string) => void; 170 | /** 171 | * 设置当前l2d特殊配置 172 | * @param val l2dConfig 173 | * @returns 174 | */ 175 | setL2DConfig: (val: IL2dConfig[keyof IL2dConfig]) => void; 176 | 177 | /** 178 | * 设置译者信息, 仅在无title且无place的情况下调用 179 | * @param translator 180 | */ 181 | setTranslator(translator: string): void; 182 | } 183 | 184 | export interface LogText { 185 | /** 186 | * user: 用户选项 187 | * character: 人物对话, 有头像 188 | * none: 无所属对话, 此时name不存在 189 | */ 190 | type: "user" | "character" | "none"; 191 | text: string; 192 | /** 193 | * 人物名 194 | */ 195 | name?: string; 196 | /** 197 | * 头像地址 198 | */ 199 | avatarUrl?: string; 200 | /** storyUnit 位置 */ 201 | index?: number; 202 | } 203 | 204 | export interface StorySummary { 205 | /** 206 | * 章节名 207 | */ 208 | chapterName: string; 209 | /** 210 | * 简介 211 | */ 212 | summary: string; 213 | } 214 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { OtherSoundsUrls, ResourcesTypes } from "@/types/resources"; 2 | 3 | let dataUrl = ""; 4 | let otherSoundMap: OtherSoundsUrls; 5 | /** 6 | * ogg类型的音频是否用其他音频类型代替 7 | */ 8 | let oggAudioType = "ogg"; 9 | let superSampling = ""; 10 | 11 | /** 12 | * 设置数据站点 13 | * @param url 14 | */ 15 | export function setDataUrl(url: string): void { 16 | dataUrl = url; 17 | otherSoundMap = { 18 | select: `${dataUrl}/Audio/Sound/UI_Button_Touch.wav`, 19 | bg_underfire: `${dataUrl}/Audio/Sound/UI_FX_BG_UnderFire.wav`, 20 | back: `${dataUrl}/Audio/Sound/UI_Button_Back.wav`, 21 | }; 22 | } 23 | 24 | /** 25 | * 设置ogg类型音频的替代音频类型 26 | */ 27 | export function setOggAudioType(audioType: "mp3") { 28 | oggAudioType = audioType; 29 | } 30 | 31 | export function setSuperSampling(type: string) { 32 | superSampling = `-${type}x`; 33 | } 34 | 35 | /** 36 | * 获取其他特效音资源, 用于本体资源加载 37 | * @returns 38 | */ 39 | export function getOtherSoundUrls(): string[] { 40 | return Object.values(otherSoundMap); 41 | } 42 | 43 | /** 44 | * 根据资源类型和参数获取资源地址, 可根据服务器实际情况修改 45 | * @param type 46 | * @param arg 47 | * @returns 48 | */ 49 | export function getResourcesUrl(type: ResourcesTypes, arg: string): string { 50 | switch (type) { 51 | case "emotionImg": 52 | return `${dataUrl}/emotions/${arg}`; 53 | case "emotionSound": 54 | return `${dataUrl}/Audio/Sound/${arg}.wav`; 55 | case "fx": 56 | return `${dataUrl}/effectTexture/${arg}`; 57 | case "l2dVoice": 58 | //arg "sound/CH0184_MemorialLobby_1_1" 59 | const voiceDirectory = arg.replace( 60 | /.*\/([A-Z0-9]*)_MemorialLobby.*/i, 61 | "JP_$1" 62 | ); 63 | const voiceFilename = arg.split("/").pop(); 64 | return `${dataUrl}/Audio/VoiceJp/Character_voice/${voiceDirectory}/${voiceFilename}.${oggAudioType}`; 65 | case "l2dSpine": 66 | return `${dataUrl}/spine/${arg}/${arg}.skel`; 67 | case "otherL2dSpine": 68 | return `${dataUrl}/spine/${arg}.skel`; 69 | case "excel": 70 | return `${dataUrl}/data/${arg}`; 71 | case "bgm": 72 | return `${dataUrl}/${arg}.${oggAudioType}`; 73 | case "sound": 74 | return `${dataUrl}/Audio/Sound/${arg}.wav`; 75 | case "voiceJp": 76 | return `${dataUrl}/Audio/VoiceJp/${arg}.${oggAudioType}`; 77 | case "characterSpine": 78 | //arg UIs/03_Scenario/02_Character/CharacterSpine_hasumi 79 | let temp = String(arg).split("/"); 80 | let id = temp.pop(); 81 | id = id?.replace("CharacterSpine_", ""); 82 | if (id?.endsWith("ND")) { 83 | id = id.slice(0, id.length - 2); 84 | } 85 | let filename = `${id}_spr`; //hasumi_spr 86 | if (superSampling) { 87 | return `${dataUrl}/spine/${filename}/${filename}${superSampling}/${filename}.skel`; 88 | } 89 | return `${dataUrl}/spine/${filename}/${filename}.skel`; 90 | case "bg": 91 | // UIs/03_Scenario/01_Background/BG_WinterRoad.jpg 92 | if (superSampling && /01_Background/.test(arg)) { 93 | const pathArr = arg.split("/"); 94 | const lastFileName = pathArr.pop(); 95 | const dir = pathArr.join("/"); 96 | return `${dataUrl}/${dir}/01_Background${superSampling}/${lastFileName}.png`; 97 | } 98 | return `${dataUrl}/${arg}.jpg`; 99 | case "otherSound": 100 | return Reflect.get(otherSoundMap, arg) || ""; 101 | case "bgEffectImgs": 102 | return `${dataUrl}/effectTexture/${arg}`; 103 | case "bgEffectSounds": 104 | return `${dataUrl}/Audio/Sound/UI_FX_${arg}.wav`; 105 | case "avatar": 106 | //arg: UIs/01_Common/01_Character/Student_Portrait_Hasumi 107 | return `${dataUrl}/${arg}.png`; 108 | case "popupImage": 109 | return `${dataUrl}/UIs/03_Scenario/04_ScenarioImage/${arg}.png`; 110 | default: 111 | return ""; 112 | } 113 | } 114 | 115 | /** 116 | * 字面意思, 深拷贝json 117 | */ 118 | export function deepCopyObject(object: T): T { 119 | return JSON.parse(JSON.stringify(object)); 120 | } 121 | 122 | /* 123 | * wait in promise 124 | */ 125 | export function wait(milliseconds: number) { 126 | return new Promise(resolve => setTimeout(resolve, milliseconds)); 127 | } 128 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ba-story-player", 3 | "version": "0.8.0", 4 | "license": "GPL-3.0", 5 | "type": "module", 6 | "files": [ 7 | "dist" 8 | ], 9 | "main": "./dist/ba-story-player.umd.cjs", 10 | "module": "./dist/ba-story-player.js", 11 | "types": "./dist/lib/main.d.ts", 12 | "repository": { 13 | "url": "https://github.com/ba-archive/ba-story-player" 14 | }, 15 | "exports": { 16 | ".": { 17 | "import": "./dist/ba-story-player.js", 18 | "require": "./dist/ba-story-player.umd.js" 19 | }, 20 | "./dist/style.css": "./dist/style.css" 21 | }, 22 | "scripts": { 23 | "dev": "vite --host", 24 | "build": "vite build && vue-tsc --emitDeclarationOnly", 25 | "preview": "vite preview", 26 | "format": "prettier --config .prettierrc.json -uw ./lib/ ./src/", 27 | "check": "eslint --fix --config ./.eslintrc.json ./lib/ --ext .js,.ts,.vue && vue-tsc --noEmit", 28 | "prepare": "husky install" 29 | }, 30 | "lint-staged": { 31 | "lib/**/*.{ts,js,vue}": [ 32 | "prettier --config .prettierrc.json --write", 33 | "git add" 34 | ], 35 | "src/**/*.{ts,js,vue}": [ 36 | "prettier --config .prettierrc.json --write", 37 | "git add" 38 | ] 39 | }, 40 | "dependencies": { 41 | "@pixi-spine/base": "^3.1.0", 42 | "@pixi/filter-adjustment": "^4.0.0", 43 | "@pixi/filter-advanced-bloom": "^4.0.0", 44 | "@pixi/filter-color-overlay": "^4.0.0", 45 | "@pixi/filter-crt": "^4.0.0", 46 | "@pixi/filter-motion-blur": "^4.2.0", 47 | "@pixi/particle-emitter": "^5.0.8", 48 | "@pixi/sound": "<5.0.0", 49 | "@vueuse/core": "^9.13.0", 50 | "gsap": "^3.11.3", 51 | "mitt": "^3.0.0", 52 | "pixi-spine": "^3.1.0", 53 | "pixi.js": "^6.0.0", 54 | "typed.js": "^2.0.12", 55 | "vue-responsive-video-background-player": "^2.3.1", 56 | "xxhashjs": "^0.2.2" 57 | }, 58 | "peerDependencies": { 59 | "axios": "^1.1.3", 60 | "vue": "^3.2.41" 61 | }, 62 | "devDependencies": { 63 | "@types/node": "^18.11.9", 64 | "@types/xxhashjs": "^0.2.2", 65 | "@typescript-eslint/eslint-plugin": "^5.55.0", 66 | "@vitejs/plugin-vue": "^3.2.0", 67 | "@vue/eslint-config-typescript": "^11.0.2", 68 | "eslint": "^8.36.0", 69 | "eslint-config-prettier": "^8.7.0", 70 | "eslint-plugin-import": "^2.27.5", 71 | "eslint-plugin-sort-exports": "^0.8.0", 72 | "eslint-plugin-vue": "^9.9.0", 73 | "husky": "^8.0.3", 74 | "prettier": "^2.8.4", 75 | "rollup-plugin-node-externals": "^5.1.2", 76 | "sass": "^1.57.1", 77 | "staged": "^0.0.0", 78 | "typescript": "^4.6.4", 79 | "vite": "^3.2.3", 80 | "vue-tsc": "^1.0.9" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 115 | 116 | 169 | 170 | 183 | -------------------------------------------------------------------------------- /src/assets/scss/index.scss: -------------------------------------------------------------------------------- 1 | $main-color: #3f88f2; 2 | $text-layer-z-index: 50; 3 | 4 | #player__background { 5 | position: relative; 6 | .transition-cover { 7 | position: absolute; 8 | width: 100%; 9 | height: 100%; 10 | left: 0; 11 | top: 0; 12 | background-position-y: 0; 13 | background-size: 120% 100%; 14 | background: linear-gradient(90deg, transparent, black 3%) no-repeat; 15 | z-index: 9999; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/assets/vue.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/ModifyEmotionOption.vue: -------------------------------------------------------------------------------- 1 | 135 | 136 |