├── babel.config.js ├── public ├── custom.js ├── sheet │ ├── leaves.png │ └── leaves.json └── index.html ├── assets ├── preview.jpg ├── project.json ├── locales │ ├── zh-chs.json5 │ └── en-us.json5 └── bridge.html ├── src ├── assets │ └── img │ │ ├── snowflake.png │ │ ├── plus.svg │ │ ├── check.svg │ │ ├── star.svg │ │ ├── close.svg │ │ ├── image.svg │ │ ├── tune.svg │ │ ├── file-plus.svg │ │ ├── shape.svg │ │ ├── video-multiple.svg │ │ ├── image-multiple.svg │ │ ├── folder-plus.svg │ │ ├── console.svg │ │ ├── info.svg │ │ ├── refresh.svg │ │ ├── tshirt.svg │ │ ├── flare.svg │ │ └── settings.svg ├── core │ ├── utils │ │ ├── date.ts │ │ ├── math.ts │ │ ├── EventEmitter.d.ts │ │ ├── string.ts │ │ ├── misc.ts │ │ ├── net.ts │ │ ├── log.ts │ │ ├── EventEmitter.js │ │ ├── dom.ts │ │ └── Draggable.ts │ ├── mka │ │ ├── Player.ts │ │ ├── Ticker.ts │ │ └── Mka.ts │ └── live2d │ │ ├── FocusController.ts │ │ ├── Live2DLoader.ts │ │ ├── Live2DPhysics.ts │ │ ├── Live2DEyeBlink.ts │ │ ├── ExpressionManager.ts │ │ ├── Live2DExpression.ts │ │ ├── live2d.d.ts │ │ ├── MotionManager.ts │ │ └── Live2DPose.ts ├── plugins │ └── vue-i18n.ts ├── module │ ├── config │ │ ├── reusable │ │ │ ├── vars.styl │ │ │ ├── ConfigBindingMixin.ts │ │ │ ├── ToggleSwitch.vue │ │ │ ├── FileInput.vue │ │ │ ├── LongClickAction.vue │ │ │ ├── Scrollable.vue │ │ │ ├── Select.vue │ │ │ └── Slider.vue │ │ ├── Overlay.vue │ │ ├── settings │ │ │ ├── EffectsSettings.vue │ │ │ └── AboutSettings.vue │ │ ├── SettingsPanel.ts │ │ └── ConfigModule.ts │ ├── snow │ │ ├── pixi-snow │ │ │ ├── snow.frag │ │ │ ├── snow.vert │ │ │ └── Snow.ts │ │ ├── SnowModule.ts │ │ └── SnowPlayer.ts │ ├── live2d-event │ │ ├── hit-event.ts │ │ ├── Live2DEventModule.ts │ │ └── greeting-event.ts │ ├── index.ts │ ├── live2d-motion │ │ ├── SoundManager.ts │ │ ├── Live2DMotionModule.ts │ │ ├── VueLive2DMotion.vue │ │ └── SubtitleManager.ts │ ├── wallpaper │ │ ├── WallpaperEngine.d.ts │ │ ├── WallpaperModule.ts │ │ └── WEInterface.ts │ ├── live2d │ │ ├── Live2DTransform.ts │ │ ├── ModelConfig.ts │ │ ├── Live2DPlayer.ts │ │ └── MouseHandler.ts │ ├── leaves │ │ └── LeavesModule.ts │ └── background │ │ ├── Background.vue │ │ └── BackgroundModule.ts ├── main.js ├── VueApp.vue ├── shim.d.ts ├── App.ts └── defaults.ts ├── .gitignore ├── tsconfig.json ├── scripts ├── build.js ├── project-json-generator.js ├── load-env.js └── setup.js ├── LICENSE ├── package.json ├── vue.config.js └── README.md /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@vue/app'], 3 | }; 4 | -------------------------------------------------------------------------------- /public/custom.js: -------------------------------------------------------------------------------- 1 | function setup(app) { 2 | // put your code here! 3 | } 4 | -------------------------------------------------------------------------------- /assets/preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guansss/nep-live2d/HEAD/assets/preview.jpg -------------------------------------------------------------------------------- /public/sheet/leaves.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guansss/nep-live2d/HEAD/public/sheet/leaves.png -------------------------------------------------------------------------------- /src/assets/img/snowflake.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guansss/nep-live2d/HEAD/src/assets/img/snowflake.png -------------------------------------------------------------------------------- /src/core/utils/date.ts: -------------------------------------------------------------------------------- 1 | const date = new Date(); 2 | const year = date.getFullYear(); 3 | 4 | export function isInRange(from: string, to: string) { 5 | return ( 6 | date.getTime() >= new Date(`${year}-${from}`).getTime() && date.getTime() <= new Date(`${year}-${to}`).getTime() 7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /src/plugins/vue-i18n.ts: -------------------------------------------------------------------------------- 1 | import { I18N, LOCALE } from '@/defaults'; 2 | import Vue from 'vue'; 3 | import VueI18n from 'vue-i18n'; 4 | 5 | Vue.use(VueI18n); 6 | 7 | const i18n = new VueI18n({ 8 | fallbackLocale: LOCALE, 9 | silentFallbackWarn: true, 10 | messages: I18N, 11 | }); 12 | 13 | export default i18n; 14 | -------------------------------------------------------------------------------- /src/assets/img/plus.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 6 | -------------------------------------------------------------------------------- /src/assets/img/check.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 6 | -------------------------------------------------------------------------------- /src/core/utils/math.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * These functions can be slightly faster than the ones in Lodash. 3 | */ 4 | 5 | export function clamp(num: number, lower: number, upper: number) { 6 | return num < lower ? lower : num > upper ? upper : num; 7 | } 8 | 9 | export function rand(min: number, max: number) { 10 | return Math.random() * (max - min) + min; 11 | } 12 | -------------------------------------------------------------------------------- /src/module/config/reusable/vars.styl: -------------------------------------------------------------------------------- 1 | $themeColor = #555 2 | $backgroundColor = #FFFD 3 | 4 | $card 5 | overflow hidden 6 | box-shadow 0 3px 1px -2px #0003, 0 2px 2px 0px #0002, 0 1px 5px 0px #0002 7 | transition box-shadow .15s ease-out 8 | 9 | $card-hover 10 | &:hover 11 | box-shadow 0 5px 5px -3px #0003, 0 8px 10px 1px #0002, 0 3px 14px 2px #0002 12 | -------------------------------------------------------------------------------- /src/assets/img/star.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 7 | 8 | -------------------------------------------------------------------------------- /src/assets/img/close.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /wallpaper 2 | 3 | .DS_Store 4 | node_modules 5 | /dist 6 | 7 | # local env files 8 | .env.local 9 | .env.*.local 10 | 11 | # Editor directories and files 12 | .idea 13 | .vscode 14 | *.suo 15 | *.ntvs* 16 | *.njsproj 17 | *.sln 18 | *.sw* 19 | 20 | # Template from: https://github.com/github/gitignore 21 | 22 | # Logs 23 | logs 24 | *.log 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | -------------------------------------------------------------------------------- /src/assets/img/image.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 7 | -------------------------------------------------------------------------------- /src/assets/img/tune.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 7 | -------------------------------------------------------------------------------- /src/assets/img/file-plus.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 7 | 8 | -------------------------------------------------------------------------------- /src/assets/img/shape.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 7 | -------------------------------------------------------------------------------- /src/assets/img/video-multiple.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 7 | 8 | -------------------------------------------------------------------------------- /src/assets/img/image-multiple.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 7 | 8 | -------------------------------------------------------------------------------- /src/core/utils/EventEmitter.d.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'eventemitter3'; 2 | 3 | export default class PatchedEventEmitter extends EventEmitter { 4 | /** 5 | * Posts a sticky event, acts like the ones from EventBus but accepts a function, so the listener can only have at most one argument. 6 | * 7 | * @see http://greenrobot.org/eventbus/documentation/configuration/sticky-events/ 8 | */ 9 | sticky(event: string, ...args: any[]): this; 10 | } 11 | -------------------------------------------------------------------------------- /src/assets/img/folder-plus.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 7 | 8 | -------------------------------------------------------------------------------- /src/assets/img/console.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 7 | -------------------------------------------------------------------------------- /src/assets/img/info.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 7 | 8 | -------------------------------------------------------------------------------- /src/assets/img/refresh.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 7 | 8 | -------------------------------------------------------------------------------- /src/module/snow/pixi-snow/snow.frag: -------------------------------------------------------------------------------- 1 | precision highp float; 2 | 3 | uniform sampler2D texture; 4 | 5 | varying float v_alpha; 6 | varying float v_rotation; 7 | 8 | void main() { 9 | vec2 rotated = vec2( 10 | cos(v_rotation) * (gl_PointCoord.x - 0.5) + sin(v_rotation) * (gl_PointCoord.y - 0.5) + 0.5, 11 | cos(v_rotation) * (gl_PointCoord.y - 0.5) - sin(v_rotation) * (gl_PointCoord.x - 0.5) + 0.5 12 | ); 13 | 14 | vec4 snowflake = texture2D(texture, rotated); 15 | 16 | gl_FragColor = snowflake * v_alpha; 17 | } 18 | -------------------------------------------------------------------------------- /src/assets/img/tshirt.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 7 | -------------------------------------------------------------------------------- /src/assets/img/flare.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 7 | -------------------------------------------------------------------------------- /src/core/mka/Player.ts: -------------------------------------------------------------------------------- 1 | import Mka from '@/core/mka/Mka'; 2 | 3 | export default abstract class Player { 4 | readonly mka?: Mka; 5 | 6 | readonly enabled: boolean = false; 7 | readonly paused: boolean = false; 8 | 9 | /** 10 | * @returns True if the content is actually updated. 11 | */ 12 | update(): boolean { 13 | return false; 14 | } 15 | 16 | attach() {} 17 | 18 | detach() {} 19 | 20 | enable() {} 21 | 22 | disable() {} 23 | 24 | resume() {} 25 | 26 | pause() {} 27 | 28 | destroy() {} 29 | } 30 | 31 | export type InternalPlayer = { -readonly [P in keyof Player]: Player[P] }; 32 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Live2D Wallpaper 8 | 9 | 10 |
Loading...
12 |
13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/module/live2d-event/hit-event.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from '@pixi/utils'; 2 | import Live2DSprite from '../live2d/Live2DSprite'; 3 | 4 | export default function registerHitEvent(sprite: Live2DSprite) { 5 | (sprite as EventEmitter).on('hit', (hitAreaName: string) => { 6 | const expressionManager = sprite.model.motionManager.expressionManager; 7 | 8 | switch (hitAreaName) { 9 | case 'head': 10 | expressionManager && expressionManager.setRandomExpression(); 11 | break; 12 | 13 | case 'body': 14 | case 'belly': 15 | sprite.model.motionManager.startRandomMotion('tapBody'); 16 | break; 17 | } 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /src/core/utils/string.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generates a random ID with 5 characters. 3 | * 4 | * @see https://stackoverflow.com/a/8084248 5 | */ 6 | export function randomID() { 7 | return (Math.random() + 1) 8 | .toString(36) 9 | .substring(2, 7) 10 | .toUpperCase(); 11 | } 12 | 13 | /** 14 | * Generates a random CSS HSL color by given string. 15 | * 16 | * @see https://stackoverflow.com/a/21682946 17 | */ 18 | export function randomHSLColor(str: string, s = '100%', l = '30%') { 19 | let hash = 0, 20 | i, 21 | chr; 22 | for (i = 0; i < str.length; i++) { 23 | chr = str.charCodeAt(i); 24 | hash = (hash << 5) - hash + chr; 25 | hash |= 0; // Convert to 32bit integer 26 | } 27 | 28 | return `hsl(${hash % 360},${s},${l})`; 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "incremental": true, 5 | "tsBuildInfoFile": ".tsbuildinfo", 6 | "target": "esnext", 7 | "module": "esnext", 8 | "strict": true, 9 | "importHelpers": true, 10 | "moduleResolution": "node", 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "sourceMap": true, 14 | "baseUrl": ".", 15 | "types": [ 16 | "webpack-env", 17 | "node" 18 | ], 19 | "paths": { 20 | "@/*": [ 21 | "src/*" 22 | ] 23 | }, 24 | "lib": [ 25 | "esnext", 26 | "dom", 27 | "dom.iterable", 28 | "scripthost" 29 | ] 30 | }, 31 | "include": [ 32 | "src/**/*.ts", 33 | "src/**/*.vue" 34 | ], 35 | "exclude": [ 36 | "node_modules" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import chalk from 'chalk'; 3 | import { generate as generateProjectJSON } from './project-json-generator'; 4 | 5 | (async function setup() { 6 | console.log(chalk.black.bgBlue(' BUILD '), 'Wallpaper Engine'); 7 | 8 | try { 9 | copyFiles('assets/preview.jpg', 'dist/preview.jpg'); 10 | 11 | setupProjectJSON(); 12 | } catch (e) { 13 | console.warn(e); 14 | } 15 | })(); 16 | 17 | function copyFiles(from, to) { 18 | fs.copyFileSync(from, to); 19 | console.log(chalk.black.bgGreen(' WRITE '), chalk.green(to)); 20 | } 21 | 22 | function setupProjectJSON() { 23 | const jsonPath = 'dist/project.json'; 24 | const projectJSON = generateProjectJSON(); 25 | 26 | fs.writeFileSync(jsonPath, projectJSON); 27 | console.log(chalk.black.bgGreen(' WRITE '), chalk.green(jsonPath)); 28 | } 29 | -------------------------------------------------------------------------------- /src/module/index.ts: -------------------------------------------------------------------------------- 1 | import { ModuleConstructor } from '@/App'; 2 | import BackgroundModule from '@/module/background/BackgroundModule'; 3 | import ConfigModule from '@/module/config/ConfigModule'; 4 | import LeavesModule from '@/module/leaves/LeavesModule'; 5 | import Live2DEventModule from '@/module/live2d-event/Live2DEventModule'; 6 | import Live2DMotionModule from '@/module/live2d-motion/Live2DMotionModule'; 7 | import Live2DModule from '@/module/live2d/Live2DModule'; 8 | import SnowModule from '@/module/snow/SnowModule'; 9 | import ThemeModule from '@/module/theme/ThemeModule'; 10 | import WallpaperModule from '@/module/wallpaper/WallpaperModule'; 11 | 12 | export default [ 13 | ConfigModule, 14 | BackgroundModule, 15 | Live2DModule, 16 | LeavesModule, 17 | SnowModule, 18 | Live2DEventModule, 19 | Live2DMotionModule, 20 | WallpaperModule, 21 | ThemeModule, 22 | ] as ModuleConstructor[]; 23 | -------------------------------------------------------------------------------- /src/module/snow/pixi-snow/snow.vert: -------------------------------------------------------------------------------- 1 | precision highp float; 2 | 3 | attribute vec4 a_position; 4 | attribute vec3 a_rotation; 5 | attribute vec3 a_speed; 6 | attribute float a_size; 7 | attribute float a_alpha; 8 | 9 | uniform float time; 10 | uniform mat4 projection; 11 | uniform vec3 worldSize; 12 | uniform float gravity; 13 | uniform float wind; 14 | 15 | varying float v_alpha; 16 | varying float v_rotation; 17 | 18 | void main() { 19 | 20 | v_alpha = a_alpha; 21 | v_rotation = a_rotation.x + time * a_rotation.y; 22 | 23 | vec3 pos = a_position.xyz; 24 | 25 | pos.x = mod(pos.x + time + wind * a_speed.x, worldSize.x * 2.0) - worldSize.x; 26 | pos.y = mod(pos.y - time * a_speed.y * gravity, worldSize.y * 2.0) - worldSize.y; 27 | 28 | pos.x += sin(time * a_speed.z) * a_rotation.z; 29 | pos.z += cos(time * a_speed.z) * a_rotation.z; 30 | 31 | gl_Position = projection * vec4(pos.xyz, a_position.w); 32 | gl_PointSize = a_size / gl_Position.w; 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 guansss 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/assets/img/settings.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 7 | 8 | -------------------------------------------------------------------------------- /src/module/live2d-event/Live2DEventModule.ts: -------------------------------------------------------------------------------- 1 | import { App, Module } from '@/App'; 2 | import { Config } from '@/module/config/ConfigModule'; 3 | import greet from '@/module/live2d-event/greeting-event'; 4 | import registerHitEvent from '@/module/live2d-event/hit-event'; 5 | import Live2DModule from '@/module/live2d/Live2DModule'; 6 | import Live2DSprite from '@/module/live2d/Live2DSprite'; 7 | import { DisplayObject } from '@pixi/display'; 8 | 9 | export default class Live2DEventModule implements Module { 10 | name = 'Live2DEvent'; 11 | 12 | config?: Config; 13 | 14 | constructor(app: App) { 15 | const live2dModule = app.modules['Live2D']; 16 | 17 | if (!(live2dModule && live2dModule instanceof Live2DModule)) return; 18 | 19 | live2dModule.player.container.on('childAdded', (obj: DisplayObject) => { 20 | if (obj instanceof Live2DSprite) { 21 | this.processSprite(obj); 22 | } 23 | }); 24 | 25 | app.on('configReady', (config: Config) => { 26 | this.config = config; 27 | app.emit('config', 'live2d.greet', true, true); 28 | }); 29 | } 30 | 31 | processSprite(sprite: Live2DSprite) { 32 | registerHitEvent(sprite); 33 | 34 | if (this.config && this.config.get('live2d.greet', false)) { 35 | greet(sprite); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/module/live2d-motion/SoundManager.ts: -------------------------------------------------------------------------------- 1 | import { error } from '@/core/utils/log'; 2 | import { clamp } from '@/core/utils/math'; 3 | import { VOLUME } from '@/defaults'; 4 | 5 | const TAG = 'SoundManager'; 6 | 7 | export default class SoundManager { 8 | private _volume = VOLUME; 9 | 10 | get volume(): number { 11 | return this._volume; 12 | } 13 | 14 | set volume(value: number) { 15 | this._volume = clamp(value, 0, 1) || 0; 16 | this.audios.forEach(audio => (audio.volume = this._volume)); 17 | } 18 | 19 | tag = SoundManager.name; 20 | 21 | audios: HTMLAudioElement[] = []; 22 | 23 | playSound(file: string): Promise { 24 | const audio = new Audio(file); 25 | audio.volume = this._volume; 26 | 27 | this.audios.push(audio); 28 | 29 | return new Promise((resolve, reject) => { 30 | audio.addEventListener('ended', () => { 31 | this.audios.splice(this.audios.indexOf(audio)); 32 | resolve(); 33 | }); 34 | audio.addEventListener('error', reject); 35 | 36 | const playResult = audio.play(); 37 | 38 | if (playResult) { 39 | playResult.catch(e => { 40 | error(TAG, e); 41 | reject(e); 42 | }); 43 | } 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/module/wallpaper/WallpaperEngine.d.ts: -------------------------------------------------------------------------------- 1 | // make sure user properties are included in "/assets/project-json/base.json" 2 | type WEUserPropertyNames = 'schemecolor' | WEFilePropertyNames; 3 | type WEGeneralPropertyNames = 'language'; 4 | 5 | type WEUserProperties = Record; 6 | type WEGeneralProperties = Record; 7 | 8 | // merged user properties and general properties 9 | type WEProperties = WEUserProperties & WEGeneralProperties; 10 | 11 | type WEFilePropertyNames = 'imgDir' | 'vidDir'; 12 | type WEFiles = Partial>; 13 | 14 | declare interface Window { 15 | // see http://steamcommunity.com/sharedfiles/filedetails/?id=795674740 16 | wallpaperRequestRandomFileForProperty?( 17 | name: T, 18 | response: (...args: any) => void, 19 | ): void; 20 | 21 | wallpaperPropertyListener: { 22 | applyUserProperties(props: T): void; 23 | applyGeneralProperties(props: T): void; 24 | 25 | userDirectoryFilesAddedOrChanged(propName: T, files: string[]): void; 26 | userDirectoryFilesRemoved(propName: T, files: string[]): void; 27 | 28 | setPaused(paused: boolean): void; 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /src/module/live2d/Live2DTransform.ts: -------------------------------------------------------------------------------- 1 | import Live2DModel, { LOGICAL_HEIGHT, LOGICAL_WIDTH } from '@/core/live2d/Live2DModel'; 2 | import { Matrix, Transform } from '@pixi/math'; 3 | 4 | export default class Live2DTransform extends Transform { 5 | drawingMatrix = new Matrix(); 6 | 7 | needsUpdate = false; 8 | 9 | constructor(readonly model: Live2DModel) { 10 | super(); 11 | } 12 | 13 | /** @override */ 14 | updateTransform(parentTransform: Transform) { 15 | const lastWorldID = this._worldID; 16 | 17 | super.updateTransform(parentTransform); 18 | 19 | if (this._worldID !== lastWorldID) { 20 | this.needsUpdate = true; 21 | } 22 | } 23 | 24 | /** 25 | * Generates a matrix for Live2D model to draw. 26 | */ 27 | getDrawingMatrix(gl: WebGLRenderingContext): Matrix { 28 | if (this.needsUpdate) { 29 | this.needsUpdate = false; 30 | 31 | this.drawingMatrix 32 | .copyFrom(this.worldTransform) 33 | 34 | // convert to Live2D coordinate 35 | .scale(LOGICAL_WIDTH / gl.drawingBufferWidth, LOGICAL_HEIGHT / gl.drawingBufferHeight) 36 | 37 | // move the Live2D origin from center to top-left 38 | .translate(-LOGICAL_WIDTH / 2, -LOGICAL_HEIGHT / 2) 39 | 40 | .append(this.model.matrix); 41 | } 42 | 43 | return this.drawingMatrix; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/module/config/reusable/ConfigBindingMixin.ts: -------------------------------------------------------------------------------- 1 | import ConfigModule from '@/module/config/ConfigModule'; 2 | import Vue from 'vue'; 3 | import { Component, Prop, Watch } from 'vue-property-decorator'; 4 | 5 | type SettingsComponent = { configModule?: ConfigModule }; 6 | 7 | const NOT_FOUND = Symbol(); 8 | 9 | @Component 10 | export default class ConfigBindingMixin extends Vue { 11 | @Prop({ default: '', type: String }) readonly config!: string; 12 | 13 | // v-model 14 | readonly value!: any; 15 | 16 | created() { 17 | if (this.config) { 18 | const configModule = (this.$parent as SettingsComponent).configModule!; 19 | 20 | this.updateValue(configModule.getConfig(this.config, this.value)); 21 | 22 | configModule.app.on('config:' + this.config, this.updateValue, this); 23 | } 24 | } 25 | 26 | @Watch('value') 27 | valueChanged(value: any) { 28 | const configModule = (this.$parent as SettingsComponent).configModule!; 29 | 30 | if (this.config && value !== configModule.getConfig(this.config, NOT_FOUND)) { 31 | configModule.setConfig(this.config, value); 32 | } 33 | } 34 | 35 | updateValue(value: any) { 36 | this.$emit('change', value); 37 | } 38 | 39 | beforeDestroy() { 40 | if (this.config) { 41 | (this.$parent as SettingsComponent).configModule!.app.off('config:' + this.config, this.updateValue); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueApp from './VueApp'; 3 | import { App } from './App'; 4 | import Modules from './module'; 5 | import i18n from '@/plugins/vue-i18n'; 6 | 7 | Vue.config.productionTip = false; 8 | 9 | document.getElementById('message').remove(); 10 | 11 | function startup() { 12 | const mainApp = new Vue({ 13 | i18n, 14 | 15 | render: h => h(VueApp, { ref: 'vueApp' }), 16 | 17 | mounted() { 18 | const app = new App(/** @type {VueApp} */ this.$refs.vueApp); 19 | 20 | app.once('reload', () => { 21 | mainApp.$destroy(); 22 | app.destroy(); 23 | startup(); 24 | }); 25 | 26 | // reload after resetting 27 | app.once('reset', () => setTimeout(() => app.emit('reload'), 0)); 28 | 29 | Modules.forEach(Module => app.use(Module)); 30 | 31 | if (!document.getElementById('custom')) { 32 | const script = document.createElement('script'); 33 | script.id = 'custom'; 34 | script.src = 'custom.js'; 35 | script.onload = () => { 36 | if (window.setup && !app.destroyed) { 37 | window.setup(app); 38 | } 39 | }; 40 | 41 | document.head.appendChild(script); 42 | } else { 43 | window.setup && window.setup(app); 44 | } 45 | }, 46 | }).$mount('#app'); 47 | } 48 | 49 | startup(); 50 | -------------------------------------------------------------------------------- /src/module/config/reusable/ToggleSwitch.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 25 | 26 | 63 | -------------------------------------------------------------------------------- /scripts/project-json-generator.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const JSON5 = require('json5'); 3 | const pickBy = require('lodash/pickBy'); 4 | const projectJSON = require('../assets/project.json'); 5 | const env = require('./load-env')(); 6 | 7 | function generate(devMode) { 8 | const locales = readLocales(); 9 | 10 | // accept only the properties start with "ui_" 11 | Object.keys(locales).forEach(locale => { 12 | locales[locale] = pickBy(locales[locale], (value, key) => key.startsWith('ui_')); 13 | }); 14 | 15 | projectJSON.general.localization = locales; 16 | 17 | if (process.env.npm_package_version.includes('beta')) { 18 | projectJSON.title = '[BETA] ' + projectJSON.title; 19 | } 20 | if (devMode) { 21 | projectJSON.title = '[DEV] ' + projectJSON.title; 22 | } else if (env.WORKSHOP_ID) { 23 | projectJSON['workshopid'] = env.WORKSHOP_ID; 24 | } 25 | 26 | return JSON.stringify(projectJSON); 27 | } 28 | 29 | function readLocales() { 30 | const locales = {}; 31 | 32 | fs.readdirSync('assets/locales').forEach(file => { 33 | const locale = file.slice(0, file.indexOf('.json')); 34 | let content = fs.readFileSync('assets/locales/' + file, 'utf8'); 35 | 36 | content = populate(content); 37 | locales[locale] = JSON5.parse(content); 38 | }); 39 | 40 | return locales; 41 | } 42 | 43 | function populate(text) { 44 | text = text.replace(/{NAME}/g, projectJSON.title); 45 | text = text.replace(/{VERSION}/g, process.env.npm_package_version); 46 | 47 | return text; 48 | } 49 | 50 | module.exports.generate = generate; 51 | module.exports.readLocales = readLocales; 52 | -------------------------------------------------------------------------------- /src/core/utils/misc.ts: -------------------------------------------------------------------------------- 1 | import camelCase from 'lodash/camelCase'; 2 | 3 | // check if running in Wallpaper Engine 4 | export const inWallpaperEngine = !!window.wallpaperRequestRandomFileForProperty; 5 | 6 | // when this page is redirected from "bridge.html", a `redirect` will be set in URL's search parameters 7 | export const redirectedFromBridge = !!new URLSearchParams(location.search.slice(1)).get('redirect'); 8 | 9 | /** 10 | * Deep clones a JSON object, converting all the property names to camel case. 11 | * @param value - JSON object. 12 | * @returns Cloned object. 13 | */ 14 | export function cloneWithCamelCase(value: any): any { 15 | if (Array.isArray(value)) { 16 | return value.map(cloneWithCamelCase); 17 | } 18 | 19 | if (value && typeof value === 'object') { 20 | const clone: any = {}; 21 | 22 | for (const key of Object.keys(value)) { 23 | clone[camelCase(key)] = cloneWithCamelCase(value[key]); 24 | } 25 | 26 | return clone; 27 | } 28 | 29 | return value; 30 | } 31 | 32 | /** 33 | * Copies text to clipboard. 34 | * 35 | * @see https://stackoverflow.com/a/30810322 36 | */ 37 | export function copy(text: string) { 38 | const textArea = document.createElement('textarea') as HTMLTextAreaElement; 39 | document.body.appendChild(textArea); 40 | 41 | textArea.style.position = 'fixed'; 42 | textArea.style.opacity = '0'; 43 | textArea.value = text; 44 | textArea.focus(); 45 | textArea.select(); 46 | 47 | try { 48 | document.execCommand('copy'); 49 | } catch (e) {} 50 | 51 | document.body.removeChild(textArea); 52 | } 53 | 54 | export function nop(): any {} 55 | -------------------------------------------------------------------------------- /src/core/live2d/FocusController.ts: -------------------------------------------------------------------------------- 1 | const EPSILON = 0.01; // Minimum distance to respond 2 | 3 | const MAX_SPEED = 40 / 7.5; 4 | const ACCELERATION_TIME = 1 / (0.15 * 1000); 5 | 6 | export default class FocusController { 7 | targetX = 0; 8 | targetY = 0; 9 | x = 0; 10 | y = 0; 11 | 12 | vx = 0; 13 | vy = 0; 14 | 15 | /** 16 | * Focus in range [-1, 1]. 17 | */ 18 | focus(x: number, y: number) { 19 | this.targetX = x; 20 | this.targetY = y; 21 | } 22 | 23 | update(dt: DOMHighResTimeStamp) { 24 | const dx = this.targetX - this.x; 25 | const dy = this.targetY - this.y; 26 | 27 | if (Math.abs(dx) < EPSILON && Math.abs(dy) < EPSILON) return; 28 | 29 | const d = Math.sqrt(dx ** 2 + dy ** 2); 30 | const maxSpeed = MAX_SPEED / (1000 / dt); 31 | 32 | let ax = maxSpeed * (dx / d) - this.vx; 33 | let ay = maxSpeed * (dy / d) - this.vy; 34 | 35 | const a = Math.sqrt(ax ** 2 + ay ** 2); 36 | const maxA = maxSpeed * ACCELERATION_TIME * dt; 37 | 38 | if (a > maxA) { 39 | ax *= maxA / a; 40 | ay *= maxA / a; 41 | } 42 | 43 | this.vx += ax; 44 | this.vy += ay; 45 | 46 | const v = Math.sqrt(this.vx ** 2 + this.vy ** 2); 47 | const maxV = 0.5 * (Math.sqrt(maxA ** 2 + 8 * maxA * d) - maxA); 48 | 49 | if (v > maxV) { 50 | this.vx *= maxV / v; 51 | this.vy *= maxV / v; 52 | } 53 | 54 | this.x += this.vx; 55 | this.y += this.vy; 56 | 57 | // so many sqrt's here, it's painful... 58 | // I tried hard to reduce the amount of them, but finally failed :( 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /scripts/load-env.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} ENV 3 | * @property {string} WALLPAPER_PATH - Path to the wallpaper directory 4 | * @property {string} WORKSHOP_ID - Workshop ID 5 | */ 6 | 7 | const chalk = require('chalk'); 8 | const fs = require('fs'); 9 | const dotenv = require('dotenv'); 10 | 11 | const ENV_FILE = '.env.local'; 12 | 13 | const ENV_VARS = { 14 | WALLPAPER_PATH(v) { 15 | if (!fs.lstatSync(v).isDirectory()) throw 'Path is not a directory'; 16 | }, 17 | WORKSHOP_ID(v) {}, 18 | }; 19 | 20 | /** 21 | * @return {ENV} 22 | */ 23 | function loadEnv() { 24 | if (!fs.existsSync(ENV_FILE)) { 25 | console.error(`Cannot find env file "${ENV_FILE}"`); 26 | return; 27 | } 28 | 29 | const env = dotenv.config({ path: ENV_FILE, debug: true }).parsed; 30 | let checkPassed = true; 31 | 32 | Object.entries(ENV_VARS).forEach(([key, validator]) => { 33 | const value = env[key]; 34 | 35 | let message = `[${key}=${value || ''}] `; 36 | 37 | if (!value) { 38 | checkPassed = false; 39 | message += '?'; 40 | } else { 41 | try { 42 | validator(value); 43 | } catch (e) { 44 | checkPassed = false; 45 | message += e; 46 | } 47 | } 48 | 49 | console.log(chalk[checkPassed ? 'green' : 'red'](message)); 50 | }); 51 | 52 | if (!checkPassed) { 53 | console.log(chalk.bgRed.black(' CHECK ENV FAILED '), 'Pleas check your', chalk.bold('.env.local'), 'file'); 54 | } else { 55 | console.log(chalk.bgGreen.black(' CHECK ENV PASSED ')); 56 | return env; 57 | } 58 | } 59 | 60 | module.exports = loadEnv; 61 | -------------------------------------------------------------------------------- /src/module/config/Overlay.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 58 | 59 | 68 | -------------------------------------------------------------------------------- /src/core/live2d/Live2DLoader.ts: -------------------------------------------------------------------------------- 1 | import Live2DPhysics from '@/core/live2d/Live2DPhysics'; 2 | import Live2DPose from '@/core/live2d/Live2DPose'; 3 | import ModelSettings from '@/core/live2d/ModelSettings'; 4 | import { log } from '@/core/utils/log'; 5 | import { getArrayBuffer, getJSON } from '@/core/utils/net'; 6 | import { dirname } from 'path'; 7 | import { parse as urlParse } from 'url'; 8 | 9 | const TAG = 'Live2DLoader'; 10 | 11 | export async function loadModelSettings(file?: string) { 12 | if (!file) throw 'Missing model settings file'; 13 | 14 | log(TAG, `Loading model settings:`, file); 15 | 16 | const url = urlParse(file); 17 | const baseDir = dirname(url.pathname || '') + '/'; 18 | const json = await getJSON(file); 19 | 20 | if (!json) { 21 | throw new TypeError('Empty response'); 22 | } 23 | 24 | return new ModelSettings(json, baseDir); 25 | } 26 | 27 | export async function loadModel(file?: string) { 28 | if (!file) throw 'Missing model file'; 29 | 30 | log(TAG, `Loading model:`, file); 31 | 32 | const buffer = await getArrayBuffer(file); 33 | const model = Live2DModelWebGL.loadModel(buffer); 34 | 35 | const error = Live2D.getError(); 36 | if (error) throw error; 37 | 38 | return model; 39 | } 40 | 41 | export async function loadPose(file: string, internalModel: Live2DModelWebGL) { 42 | log(TAG, 'Loading pose:', file); 43 | 44 | const json = await getJSON(file); 45 | return new Live2DPose(internalModel, json); 46 | } 47 | 48 | export async function loadPhysics(file: string, internalModel: Live2DModelWebGL) { 49 | log(TAG, 'Loading physics:', file); 50 | 51 | const json = await getJSON(file); 52 | return new Live2DPhysics(internalModel!, json); 53 | } 54 | -------------------------------------------------------------------------------- /src/VueApp.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 36 | 70 | -------------------------------------------------------------------------------- /src/module/snow/SnowModule.ts: -------------------------------------------------------------------------------- 1 | import { App, Module } from '@/App'; 2 | import { HIGH_QUALITY, SNOW_NUMBER } from '@/defaults'; 3 | import { Config } from '@/module/config/ConfigModule'; 4 | import Snow from '@/module/snow/pixi-snow/Snow'; 5 | import SnowPlayer from '@/module/snow/SnowPlayer'; 6 | import debounce from 'lodash/debounce'; 7 | 8 | export default class SnowModule implements Module { 9 | name = 'Snow'; 10 | 11 | player?: SnowPlayer; 12 | 13 | number = SNOW_NUMBER; 14 | highQuality = HIGH_QUALITY; 15 | 16 | constructor(readonly app: App) { 17 | app.on('config:snow.on', (enabled: boolean) => { 18 | if (enabled) this.setup(); 19 | 20 | if (enabled) app.mka.enablePlayer('snow'); 21 | else app.mka.disablePlayer('snow'); 22 | }) 23 | .on( 24 | 'config:snow.number', 25 | debounce((value: number) => { 26 | this.number = value; 27 | 28 | this.player && (this.player.number = value); 29 | }, 200), 30 | ) 31 | .on('config:hq', (highQuality: boolean) => { 32 | this.highQuality = highQuality; 33 | this.player && (this.player.layering = highQuality); 34 | }) 35 | .on('configReady', (config: Config) => { 36 | app.emit('config', 'snow.on', false, true); 37 | app.emit('config', 'snow.number', this.number, true); 38 | }); 39 | } 40 | 41 | private setup() { 42 | if (!this.player) { 43 | this.player = new SnowPlayer(); 44 | this.player.number = this.number; 45 | this.player.layering = this.highQuality; 46 | 47 | this.app.mka.addPlayer('snow', this.player); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/module/config/reusable/FileInput.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 36 | 37 | 58 | -------------------------------------------------------------------------------- /src/module/live2d-event/greeting-event.ts: -------------------------------------------------------------------------------- 1 | import { SEASONS } from '@/defaults'; 2 | import Live2DSprite from '../live2d/Live2DSprite'; 3 | 4 | const activeSeason = SEASONS.find(season => season.active); 5 | const activeSeasonValue = activeSeason && activeSeason.value; 6 | 7 | export default function greet(sprite: Live2DSprite) { 8 | const definitions = sprite.model.motionManager.definitions['greet']; 9 | 10 | if (definitions && definitions.length !== 0) { 11 | let index = -1; 12 | 13 | if (activeSeasonValue) { 14 | index = definitions.findIndex(def => def.season === activeSeasonValue); 15 | } 16 | 17 | if (index === -1) { 18 | const indices: number[] = []; 19 | const hours = new Date().getHours(); 20 | let min = 24; 21 | let dt; 22 | 23 | // find a timed greeting motion closest the current hours 24 | for (let i = definitions.length - 1; i >= 0; i--) { 25 | if (!isNaN(definitions[i].time as number)) { 26 | dt = hours - definitions[i].time!; 27 | 28 | if (dt >= 0 && dt <= min) { 29 | if (dt === min) { 30 | indices.push(i); 31 | } else { 32 | min = dt; 33 | indices.splice(0, indices.length, i); 34 | } 35 | } 36 | } 37 | } 38 | 39 | if (indices.length !== 0) { 40 | index = indices[~~(Math.random() * indices.length)]; 41 | } else { 42 | // start a random non-seasonal greeting motion 43 | const nonSeasonalIndices = Array.from(definitions.keys()).filter(i => !definitions[i].season); 44 | 45 | if (nonSeasonalIndices.length !== 0) { 46 | index = nonSeasonalIndices[~~(Math.random() * nonSeasonalIndices.length)]; 47 | } 48 | } 49 | } 50 | 51 | if (index !== -1) sprite.model.motionManager.startMotionByPriority('greet', index).then(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/core/utils/net.ts: -------------------------------------------------------------------------------- 1 | import { error, log } from './log'; 2 | 3 | interface RequestOptions { 4 | method?: 'GET' | 'POST'; 5 | body?: any; 6 | responseType?: XMLHttpRequestResponseType; 7 | } 8 | 9 | const TAG = 'Net'; 10 | 11 | export async function getJSON(url: string) { 12 | const result = await request(url, { responseType: 'json' }); 13 | 14 | log(TAG, `[${url}] (JSON)`, result); 15 | 16 | return result; 17 | } 18 | 19 | export async function postJSON(url: string, json: any) { 20 | log(TAG, 'Post JSON', url, json); 21 | 22 | const result = await request(url, { 23 | method: 'POST', 24 | body: json, 25 | responseType: 'json', 26 | }); 27 | 28 | log(TAG, `[${url}] (POST)`, result); 29 | 30 | return result; 31 | } 32 | 33 | export async function getArrayBuffer(url: string) { 34 | const arrayBuffer = await request(url, { responseType: 'arraybuffer' }); 35 | 36 | if (!arrayBuffer) throw new TypeError('Empty response'); 37 | 38 | log(TAG, `[${url}] (ArrayBuffer[${arrayBuffer.byteLength}])`); 39 | 40 | return arrayBuffer; 41 | } 42 | 43 | async function request(url: string, options: RequestOptions = {}): Promise { 44 | log(TAG, `[${url}]`); 45 | 46 | // DON'T use fetch because it refuses to load local files 47 | const xhr = new XMLHttpRequest(); 48 | xhr.open(options.method || 'GET', url); 49 | 50 | xhr.responseType = options.responseType || ''; 51 | xhr.setRequestHeader('Content-Type', 'application/json'); 52 | // xhr.setRequestHeader('Access-Control-Allow-Origin', '*'); 53 | 54 | const res = await new Promise((resolve, reject) => { 55 | // DONT't use onload() because it will never be called when the file is not found while running in WE 56 | xhr.onloadend = () => resolve(xhr.response); 57 | xhr.onerror = () => reject(new TypeError('Request failed')); 58 | 59 | xhr.send(options.body && JSON.stringify(options.body)); 60 | }); 61 | 62 | // status 0 for loading a local file 63 | if (!(xhr.status === 0 || xhr.status === 200)) { 64 | error(TAG, `[${url}] Failed with (${xhr.status})`); 65 | } 66 | 67 | return res as T; 68 | } 69 | -------------------------------------------------------------------------------- /src/core/mka/Ticker.ts: -------------------------------------------------------------------------------- 1 | import { FPS_MAX } from '@/defaults'; 2 | 3 | const FILTER_STRENGTH = 20; 4 | 5 | namespace Ticker { 6 | const start = performance.now(); 7 | 8 | export let now = start; 9 | export let elapsed = now - start; 10 | 11 | let before = start; 12 | export let delta = now - before; 13 | 14 | // see https://stackoverflow.com/a/19772220 15 | let then = start; 16 | let adjustedDelta = now - then; 17 | 18 | export let paused = false; 19 | let pauseTime = now; 20 | export let elapsedSincePause = now - pauseTime; 21 | 22 | // see https://stackoverflow.com/a/5111475 23 | let maxFps = FPS_MAX; 24 | let frameInterval = 1000 / maxFps; 25 | let actualFrameInterval = frameInterval; 26 | 27 | export function getMaxFPS() { 28 | return maxFps; 29 | } 30 | 31 | export function setMaxFPS(fps: number) { 32 | maxFps = fps; 33 | frameInterval = 1000 / fps; 34 | } 35 | 36 | export function getFPS() { 37 | return ~~(1000 / actualFrameInterval); 38 | } 39 | 40 | export function pause() { 41 | const time = performance.now(); 42 | 43 | // pause can be triggered multiple times in WE, probably when interacting with multiple monitors 44 | if (paused) { 45 | elapsedSincePause += time - pauseTime; 46 | } 47 | 48 | paused = true; 49 | pauseTime = time; 50 | } 51 | 52 | export function resume(time: DOMHighResTimeStamp) { 53 | paused = false; 54 | before = then = now = time; 55 | elapsedSincePause = now - pauseTime; 56 | } 57 | 58 | /** 59 | * @returns True if this tick is available for animation. 60 | */ 61 | export function tick(time: DOMHighResTimeStamp): boolean { 62 | now = time; 63 | elapsed = time - start; 64 | 65 | delta = time - before; 66 | adjustedDelta = time - then; 67 | 68 | if (adjustedDelta > frameInterval) { 69 | before = time; 70 | then = time - (adjustedDelta % frameInterval); 71 | 72 | actualFrameInterval += (delta - actualFrameInterval) / FILTER_STRENGTH; 73 | 74 | return true; 75 | } 76 | 77 | return false; 78 | } 79 | } 80 | 81 | export default Ticker; 82 | -------------------------------------------------------------------------------- /src/module/config/reusable/LongClickAction.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 70 | 71 | 88 | -------------------------------------------------------------------------------- /assets/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "contentrating": "Everyone", 3 | "description": "", 4 | "file": "index.html", 5 | "general": { 6 | "localization": {}, 7 | "properties": { 8 | "schemecolor": { 9 | "order": 0, 10 | "text": "ui_browse_properties_scheme_color", 11 | "type": "color", 12 | "value": "0.67 0.278 0.737" 13 | }, 14 | "info": { 15 | "order": 1, 16 | "text": "ui_info" 17 | }, 18 | "l1": { 19 | "order": 2, 20 | "text": "__________________________________" 21 | }, 22 | "_imgDir": { 23 | "order": 3, 24 | "text": "ui_img_dir" 25 | }, 26 | "imgDir": { 27 | "order": 4, 28 | "type": "directory", 29 | "mode": "fetchall" 30 | }, 31 | "l2": { 32 | "order": 5, 33 | "text": "__________________________________" 34 | }, 35 | "_vidDir": { 36 | "order": 6, 37 | "text": "ui_vid_dir" 38 | }, 39 | "vidDir": { 40 | "order": 7, 41 | "fileType": "video", 42 | "text": "(webm/ogv)", 43 | "type": "directory", 44 | "mode": "fetchall" 45 | }, 46 | "l3": { 47 | "order": 8, 48 | "text": "__________________________________" 49 | }, 50 | "switch": { 51 | "order": 100, 52 | "text": "ui_switch", 53 | "type": "bool", 54 | "value": false 55 | }, 56 | "b1": { 57 | "order": 101 58 | }, 59 | "reset": { 60 | "order": 102, 61 | "text": "ui_reset", 62 | "type": "bool", 63 | "value": false 64 | }, 65 | "volume": { 66 | "condition": "false", 67 | "max": 10, 68 | "min": 0, 69 | "order": 999, 70 | "text": "A hidden property to retrieve the value from old 1.x versions. Since the configuration mechanism has been changed in 2.0, it's important to preserve the volume to prevent bothering users who have previously muted this wallpaper.", 71 | "type": "slider", 72 | "value": 0.5 73 | } 74 | } 75 | }, 76 | "preview": "preview.jpg", 77 | "tags": ["Anime"], 78 | "title": "Neptune Live2D", 79 | "type": "web", 80 | "visibility": "public" 81 | } 82 | -------------------------------------------------------------------------------- /src/core/live2d/Live2DPhysics.ts: -------------------------------------------------------------------------------- 1 | interface PhysicsHairDefinition { 2 | label: string; 3 | setup: { 4 | length: number; 5 | regist: number; 6 | mass: number; 7 | }; 8 | src: { 9 | id: string; 10 | ptype: string; 11 | scale: number; 12 | weight: number; 13 | }[]; 14 | targets: { 15 | id: string; 16 | ptype: string; 17 | scale: number; 18 | weight: number; 19 | }[]; 20 | } 21 | 22 | const SRC_TYPE_MAP = { 23 | x: PhysicsHair.Src.SRC_TO_X, 24 | y: PhysicsHair.Src.SRC_TO_Y, 25 | angle: PhysicsHair.Src.SRC_TO_G_ANGLE, 26 | } as { 27 | [key: string]: string; 28 | }; 29 | 30 | const TARGET_TYPE_MAP = { 31 | x: PhysicsHair.Src.SRC_TO_X, 32 | y: PhysicsHair.Src.SRC_TO_Y, 33 | angle: PhysicsHair.Src.SRC_TO_G_ANGLE, 34 | } as { 35 | [key: string]: string; 36 | }; 37 | 38 | export default class Live2DPhysics { 39 | internalModel: Live2DModelWebGL; 40 | 41 | physicsHairs: PhysicsHair[] = []; 42 | 43 | constructor(internalModel: Live2DModelWebGL, json: any) { 44 | this.internalModel = internalModel; 45 | 46 | if (json['physics_hair']) { 47 | this.physicsHairs = (json['physics_hair'] as PhysicsHairDefinition[]).map(definition => { 48 | const physicsHair = new PhysicsHair(); 49 | 50 | physicsHair.setup(definition.setup.length, definition.setup.regist, definition.setup.mass); 51 | 52 | definition.src.forEach(({ id, ptype, scale, weight }) => { 53 | const type = SRC_TYPE_MAP[ptype]; 54 | 55 | if (type) { 56 | physicsHair.addSrcParam(type, id, scale, weight); 57 | } 58 | }); 59 | 60 | definition.targets.forEach(({ id, ptype, scale, weight }) => { 61 | const type = TARGET_TYPE_MAP[ptype]; 62 | 63 | if (type) { 64 | physicsHair.addTargetParam(type, id, scale, weight); 65 | } 66 | }); 67 | 68 | return physicsHair; 69 | }); 70 | } 71 | } 72 | 73 | update(elapsed: DOMHighResTimeStamp) { 74 | this.physicsHairs.forEach(physicsHair => physicsHair.update(this.internalModel, elapsed)); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/core/utils/log.ts: -------------------------------------------------------------------------------- 1 | import { inWallpaperEngine } from '@/core/utils/misc'; 2 | import { sayHello as pixiSayHello } from '@pixi/utils'; 3 | 4 | export interface LogRecord { 5 | tag: string; 6 | message: string; 7 | error: boolean; 8 | count: number; 9 | } 10 | 11 | const debug = !inWallpaperEngine; 12 | 13 | export const logs = (() => { 14 | const arr: any = []; 15 | arr.limit = 200; 16 | return arr; 17 | })() as LogRecord[] & { limit: number }; 18 | 19 | let lastLog: LogRecord; 20 | 21 | const consoleLog = console.log; 22 | const consoleError = console.error; 23 | 24 | // say hello before messing up the native console! 25 | pixiSayHello('WebGL'); 26 | 27 | // this can be useful to catch third-party logs 28 | console.log = (...args: any[]) => log('Log', ...args); 29 | console.warn = (...args: any[]) => error('Warn', ...args); 30 | console.error = (...args: any[]) => error('Error', ...args); 31 | 32 | window.onerror = (message, source, lineno, colno, err) => 33 | error( 34 | 'Uncaught', 35 | `${err && err.toString()} 36 | Msg: ${message} 37 | Src: ${source} 38 | Ln: ${lineno} 39 | Col ${colno}`, 40 | ); 41 | 42 | window.onunhandledrejection = (event: PromiseRejectionEvent) => error('Rejection', event.reason); 43 | 44 | logs.push = (log: LogRecord) => { 45 | // when this log is identical to last log, we increase the counter rather than push it to the array 46 | if (lastLog && lastLog.tag === log.tag && lastLog.message === log.message) { 47 | lastLog.count++; 48 | return logs.length; 49 | } 50 | 51 | lastLog = log; 52 | 53 | if (logs.length === logs.limit) { 54 | logs.shift(); 55 | } 56 | 57 | return Array.prototype.push.call(logs, log); 58 | }; 59 | 60 | export function log(tag: string, ...messages: any[]) { 61 | logs.push({ 62 | tag, 63 | message: messages.map(m => m && m.toString()).join(' '), 64 | error: false, 65 | count: 1, 66 | }); 67 | 68 | if (debug) consoleLog(`[${tag.replace('\n', '')}]`, ...messages); 69 | } 70 | 71 | export function error(tag: string, ...messages: any[]) { 72 | logs.push({ 73 | tag, 74 | message: messages.map(m => m && m.toString()).join(' '), 75 | error: true, 76 | count: 1, 77 | }); 78 | 79 | if (debug) consoleError(`[${tag.replace('\n', '')}]`, ...messages); 80 | } 81 | -------------------------------------------------------------------------------- /public/sheet/leaves.json: -------------------------------------------------------------------------------- 1 | { 2 | "frames": { 3 | "1": { 4 | "frame": {"x": 0, "y": 0, "w": 150, "h": 150}, 5 | "rotated": false, 6 | "trimmed": false, 7 | "spriteSourceSize": {"x": 0, "y": 0, "w": 150, "h": 150}, 8 | "sourceSize": {"w": 150, "h": 150} 9 | }, 10 | "2": { 11 | "frame": {"x": 150, "y": 0, "w": 150, "h": 150}, 12 | "rotated": false, 13 | "trimmed": false, 14 | "spriteSourceSize": {"x": 0, "y": 0, "w": 150, "h": 150}, 15 | "sourceSize": {"w": 150, "h": 150} 16 | }, 17 | "3": { 18 | "frame": {"x": 300, "y": 0, "w": 150, "h": 150}, 19 | "rotated": false, 20 | "trimmed": false, 21 | "spriteSourceSize": {"x": 0, "y": 0, "w": 150, "h": 150}, 22 | "sourceSize": {"w": 150, "h": 150} 23 | }, 24 | "4": { 25 | "frame": {"x": 0, "y": 150, "w": 150, "h": 150}, 26 | "rotated": false, 27 | "trimmed": false, 28 | "spriteSourceSize": {"x": 0, "y": 0, "w": 150, "h": 150}, 29 | "sourceSize": {"w": 150, "h": 150} 30 | }, 31 | "5": { 32 | "frame": {"x": 150, "y": 150, "w": 150, "h": 150}, 33 | "rotated": false, 34 | "trimmed": false, 35 | "spriteSourceSize": {"x": 0, "y": 0, "w": 150, "h": 150}, 36 | "sourceSize": {"w": 150, "h": 150} 37 | }, 38 | "6": { 39 | "frame": {"x": 300, "y": 150, "w": 150, "h": 150}, 40 | "rotated": false, 41 | "trimmed": false, 42 | "spriteSourceSize": {"x": 0, "y": 0, "w": 150, "h": 150}, 43 | "sourceSize": {"w": 150, "h": 150} 44 | }, 45 | "7": { 46 | "frame": {"x": 0, "y": 300, "w": 150, "h": 150}, 47 | "rotated": false, 48 | "trimmed": false, 49 | "spriteSourceSize": {"x": 0, "y": 0, "w": 150, "h": 150}, 50 | "sourceSize": {"w": 150, "h": 150} 51 | }, 52 | "8": { 53 | "frame": {"x": 150, "y": 300, "w": 150, "h": 150}, 54 | "rotated": false, 55 | "trimmed": false, 56 | "spriteSourceSize": {"x": 0, "y": 0, "w": 150, "h": 150}, 57 | "sourceSize": {"w": 150, "h": 150} 58 | } 59 | }, 60 | "meta": { 61 | "app": "https://www.codeandweb.com/texturepacker", 62 | "version": "1.0", 63 | "image": "leaves.png", 64 | "format": "RGBA8888", 65 | "size": {"w": 450, "h": 450}, 66 | "scale": "1", 67 | "smartupdate": "$TexturePacker:SmartUpdate:475748e0843c69a680572489f1c936bb:42052252feaa7ff712adc709a35bf246:1749a3175affe20552888b05b9894b78$" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nep-live2d", 3 | "version": "2.1.1", 4 | "description": "Live2D wallpaper for Neptune", 5 | "main": "index.js", 6 | "repository": "https://github.com/guansss/nep-live2d.git", 7 | "author": "guansss", 8 | "license": "MIT", 9 | "scripts": { 10 | "setup": "babel-node --presets @babel/env scripts/setup.js", 11 | "serve": "vue-cli-service serve src/main.js", 12 | "build": "vue-cli-service build src/main.js & babel-node --presets @babel/env scripts/build.js", 13 | "lint": "vue-cli-service lint" 14 | }, 15 | "dependencies": { 16 | "autobind-decorator": "^2.4.0", 17 | "eventemitter3": "^4.0.0", 18 | "lodash": "^4.17.15", 19 | "pixi.js": "^5.2.1", 20 | "raw-loader": "^3.1.0", 21 | "semver": "^7.1.3", 22 | "vue": "^2.6.10", 23 | "vue-clickaway": "^2.2.2", 24 | "vue-i18n": "^8.15.0" 25 | }, 26 | "devDependencies": { 27 | "@babel/node": "^7.5.5", 28 | "@babel/preset-typescript": "^7.3.3", 29 | "@types/lodash": "^4.14.136", 30 | "@types/node": "^12.6.8", 31 | "@vue/cli-plugin-babel": "^3.9.2", 32 | "@vue/cli-plugin-eslint": "^3.9.2", 33 | "@vue/cli-plugin-typescript": "^3.9.0", 34 | "@vue/cli-service": "^3.9.3", 35 | "@vue/eslint-config-prettier": "^5.0.0", 36 | "@vue/eslint-config-typescript": "^4.0.0", 37 | "babel-eslint": "^10.0.2", 38 | "body-parser": "latest", 39 | "chalk": "^2.4.2", 40 | "dotenv": "^8.0.0", 41 | "eslint": "^6.1.0", 42 | "eslint-plugin-prettier": "^3.1.0", 43 | "eslint-plugin-vue": "^5.2.3", 44 | "json5": "^2.1.1", 45 | "shelljs": "^0.8.3", 46 | "stylus": "^0.54.5", 47 | "stylus-loader": "^3.0.2", 48 | "typescript": "^3.5.3", 49 | "vue-property-decorator": "^8.2.1", 50 | "vue-svg-loader": "^0.12.0", 51 | "vue-template-compiler": "^2.6.10" 52 | }, 53 | "eslintConfig": { 54 | "root": true, 55 | "env": { 56 | "node": true 57 | }, 58 | "extends": [ 59 | "plugin:vue/essential", 60 | "@vue/prettier", 61 | "@vue/typescript" 62 | ], 63 | "rules": { 64 | "no-console": "off", 65 | "no-inner-declarations": "off" 66 | }, 67 | "parserOptions": { 68 | "parser": "@typescript-eslint/parser" 69 | } 70 | }, 71 | "prettier": { 72 | "printWidth": 120, 73 | "tabWidth": 4, 74 | "singleQuote": true, 75 | "trailingComma": "all" 76 | }, 77 | "postcss": { 78 | "plugins": { 79 | "autoprefixer": {} 80 | } 81 | }, 82 | "browserslist": [ 83 | "Chrome >= 67" 84 | ] 85 | } 86 | -------------------------------------------------------------------------------- /src/module/leaves/LeavesModule.ts: -------------------------------------------------------------------------------- 1 | import { App, Module } from '@/App'; 2 | import { HIGH_QUALITY, LEAVES_DROP_RATE, LEAVES_NUMBER } from '@/defaults'; 3 | import LeavesPlayer from '@/module/leaves/LeavesPlayer'; 4 | import { Renderer } from '@pixi/core'; 5 | import { Loader } from '@pixi/loaders'; 6 | import { ParticleRenderer } from '@pixi/particles'; 7 | import { SpritesheetLoader } from '@pixi/spritesheet'; 8 | import debounce from 'lodash/debounce'; 9 | 10 | Renderer.registerPlugin('particle', ParticleRenderer as any); 11 | Loader.registerPlugin(SpritesheetLoader); 12 | 13 | export interface Config { 14 | leaves: { 15 | enabled?: boolean; 16 | number?: number; 17 | }; 18 | } 19 | 20 | export default class LeavesModule implements Module { 21 | name = 'Leaves'; 22 | 23 | player?: LeavesPlayer; 24 | 25 | number = LEAVES_NUMBER; 26 | dropRate = LEAVES_NUMBER; 27 | highQuality = HIGH_QUALITY; 28 | 29 | constructor(readonly app: App) { 30 | app.on('config:leaves.on', (enabled: boolean) => { 31 | if (enabled) this.setup(); 32 | 33 | if (enabled) app.mka.enablePlayer('leaves'); 34 | else app.mka.disablePlayer('leaves'); 35 | }) 36 | .on( 37 | 'config:leaves.number', 38 | debounce((value: number) => { 39 | this.number = value; 40 | this.player && (this.player.number = value); 41 | }, 200), 42 | ) 43 | .on('config:leaves.rate', (value: number) => { 44 | this.dropRate = value; 45 | this.player && (this.player.dropRate = value); 46 | }) 47 | .on('config:hq', (highQuality: boolean) => { 48 | this.highQuality = highQuality; 49 | this.player && (this.player.layering = highQuality); 50 | }) 51 | .on('configReady', (config: Config) => { 52 | app.emit('config', 'leaves.on', false, true); 53 | app.emit('config', 'leaves.number', this.number, true); 54 | app.emit('config', 'leaves.rate', LEAVES_DROP_RATE, true); 55 | }); 56 | } 57 | 58 | private setup() { 59 | if (!this.player) { 60 | this.player = new LeavesPlayer(); 61 | this.player.number = this.number; 62 | this.player.dropRate = this.dropRate; 63 | this.player.layering = this.highQuality; 64 | 65 | this.app.mka.addPlayer('leaves', this.player); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/module/live2d/ModelConfig.ts: -------------------------------------------------------------------------------- 1 | import { SafeArea } from '@/core/utils/dom'; 2 | import Live2DSprite from '@/module/live2d/Live2DSprite'; 3 | 4 | export interface ModelConfig { 5 | readonly file: string | string[]; 6 | id: number; 7 | enabled: boolean; 8 | scale: number; 9 | x: number; 10 | y: number; 11 | order: number; 12 | locale?: string; // the subtitle locale 13 | preview?: string; // image file 14 | } 15 | 16 | export const DEFAULT_MODEL_CONFIG: Omit = { 17 | enabled: true, 18 | scale: 1 / innerHeight, 19 | x: 0.5, 20 | y: 0.5, 21 | }; 22 | 23 | export namespace ModelConfigUtils { 24 | export let containerWidth = SafeArea.area.width; 25 | export let containerHeight = SafeArea.area.height; 26 | 27 | export function toStorageValues>(config: T): T { 28 | const _config = Object.assign({}, config); 29 | 30 | if (_config.scale !== undefined) _config.scale /= containerHeight; 31 | if (_config.x !== undefined) _config.x /= containerWidth; 32 | if (_config.y !== undefined) _config.y /= containerHeight; 33 | 34 | return _config; 35 | } 36 | 37 | export function toActualValues>(config: T): T { 38 | const _config = Object.assign({}, config); 39 | 40 | if (_config.scale !== undefined) _config.scale *= containerHeight; 41 | if (_config.x !== undefined) _config.x *= containerWidth; 42 | if (_config.y !== undefined) _config.y *= containerHeight; 43 | 44 | return _config; 45 | } 46 | 47 | export function configureSprite(sprite: Live2DSprite, config: Partial) { 48 | const _config = toActualValues(config); 49 | 50 | if (!isNaN(_config.scale!)) { 51 | sprite.scale.x = sprite.scale.y = _config.scale!; 52 | } 53 | 54 | if (!isNaN(_config.x!)) sprite.x = _config.x!; 55 | if (!isNaN(_config.y!)) sprite.y = _config.y!; 56 | if (!isNaN(_config.order!)) sprite.zIndex = _config.order!; 57 | } 58 | 59 | /** 60 | * Makes a Live2D model path using the name of its model settings file. 61 | * 62 | * @example 63 | * makeModelPath('neptune.model.json') 64 | * // => 'neptune/neptune.model.json' 65 | */ 66 | export function makeModelPath(fileName: string) { 67 | const separatorIndex = fileName.indexOf('.'); 68 | const dir = fileName.slice(0, separatorIndex > 0 ? separatorIndex : undefined); 69 | return `${dir}/${fileName}`; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/module/config/settings/EffectsSettings.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /src/core/live2d/Live2DEyeBlink.ts: -------------------------------------------------------------------------------- 1 | import { clamp, rand } from '@/core/utils/math'; 2 | 3 | enum EyeState { 4 | Idle, 5 | Closing, 6 | Closed, 7 | Opening, 8 | } 9 | 10 | export default class Live2DEyeBlink { 11 | leftParam: number; 12 | rightParam: number; 13 | 14 | blinkInterval: DOMHighResTimeStamp = 4000; 15 | closingDuration: DOMHighResTimeStamp = 100; 16 | closedDuration: DOMHighResTimeStamp = 50; 17 | openingDuration: DOMHighResTimeStamp = 150; 18 | 19 | eyeState = EyeState.Idle; 20 | eyeParamValue = 1; 21 | closedTimer = 0; 22 | nextBlinkTimeLeft = this.blinkInterval; 23 | 24 | constructor(readonly internalModel: Live2DModelWebGL) { 25 | this.leftParam = internalModel.getParamIndex('PARAM_EYE_L_OPEN'); 26 | this.rightParam = internalModel.getParamIndex('PARAM_EYE_R_OPEN'); 27 | } 28 | 29 | setEyeParams(value: number) { 30 | this.eyeParamValue = clamp(value, 0, 1); 31 | this.internalModel.setParamFloat(this.leftParam, this.eyeParamValue); 32 | this.internalModel.setParamFloat(this.rightParam, this.eyeParamValue); 33 | } 34 | 35 | update(dt: DOMHighResTimeStamp) { 36 | switch (this.eyeState) { 37 | case EyeState.Idle: 38 | this.nextBlinkTimeLeft -= dt; 39 | 40 | if (this.nextBlinkTimeLeft < 0) { 41 | this.eyeState = EyeState.Closing; 42 | this.nextBlinkTimeLeft = 43 | this.blinkInterval + 44 | this.closingDuration + 45 | this.closedDuration + 46 | this.openingDuration + 47 | rand(0, 2000); 48 | } 49 | break; 50 | 51 | case EyeState.Closing: 52 | this.setEyeParams(this.eyeParamValue + dt / this.closingDuration); 53 | 54 | if (this.eyeParamValue <= 0) { 55 | this.eyeState = EyeState.Closed; 56 | this.closedTimer = 0; 57 | } 58 | break; 59 | 60 | case EyeState.Closed: 61 | this.closedTimer += dt; 62 | 63 | if (this.closedTimer >= this.closedDuration) { 64 | this.eyeState = EyeState.Opening; 65 | } 66 | break; 67 | 68 | case EyeState.Opening: 69 | this.setEyeParams(this.eyeParamValue + dt / this.openingDuration); 70 | 71 | if (this.eyeParamValue >= 1) { 72 | this.eyeState = EyeState.Idle; 73 | } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/core/live2d/ExpressionManager.ts: -------------------------------------------------------------------------------- 1 | import Live2DExpression from '@/core/live2d/Live2DExpression'; 2 | import { ExpressionDefinition } from '@/core/live2d/ModelSettings'; 3 | import { error, log } from '@/core/utils/log'; 4 | import { getJSON } from '@/core/utils/net'; 5 | import sample from 'lodash/sample'; 6 | 7 | export default class ExpressionManager extends MotionQueueManager { 8 | tag: string; 9 | 10 | readonly definitions: ExpressionDefinition[]; 11 | readonly expressions: Live2DExpression[] = []; 12 | 13 | defaultExpression: Live2DExpression; 14 | currentExpression: Live2DExpression; 15 | 16 | constructor(name: string, readonly internalModel: Live2DModelWebGL, definitions: ExpressionDefinition[]) { 17 | super(); 18 | 19 | this.tag = `ExpressionManager\n(${name})`; 20 | this.definitions = definitions; 21 | 22 | this.defaultExpression = new Live2DExpression(internalModel, {}, '(default)'); 23 | this.currentExpression = this.defaultExpression; 24 | 25 | this.loadExpressions().then(); 26 | this.stopAllMotions(); 27 | } 28 | 29 | private async loadExpressions() { 30 | for (const { name, file } of this.definitions) { 31 | try { 32 | const json = await getJSON(file); 33 | this.expressions.push(new Live2DExpression(this.internalModel, json, name)); 34 | } catch (e) { 35 | error(this.tag, `Failed to load expression [${name}]: ${file}`, e); 36 | } 37 | } 38 | 39 | this.expressions.push(this.defaultExpression); // at least put a normal expression 40 | } 41 | 42 | setRandomExpression() { 43 | if (this.expressions.length == 1) { 44 | this.setExpression(this.expressions[0]); 45 | } else { 46 | let expression; 47 | 48 | // prevent showing same expression twice 49 | do { 50 | expression = sample(this.expressions); 51 | } while (expression == this.currentExpression); 52 | 53 | this.setExpression(expression!); 54 | } 55 | } 56 | 57 | resetExpression() { 58 | this.startMotion(this.defaultExpression); 59 | } 60 | 61 | restoreExpression() { 62 | this.startMotion(this.currentExpression); 63 | } 64 | 65 | setExpression(expression: Live2DExpression) { 66 | log(this.tag, 'Set expression:', expression.name); 67 | 68 | this.currentExpression = expression; 69 | this.startMotion(expression); 70 | } 71 | 72 | update() { 73 | if (!this.isFinished()) { 74 | return this.updateParam(this.internalModel); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /assets/locales/zh-chs.json5: -------------------------------------------------------------------------------- 1 | { 2 | // =================================================================================================================== 3 | // 这些日志只应该被项目维护者所更新,请不要修改 4 | "changelog_important": "2.1.0\n\ 5 | 由于坐标计算方式的变更,某些 Live2D 模型会发生位置偏移,需要手动调整其位置,对此造成的不便敬请谅解\n\n\ 6 | 现在支持通过文件夹导入任意 Live2D 2.1 标准模型\n\n\ 7 | 关于自定义模型的更多信息,请访问创意工坊页面", 8 | "changelog_logs": [ 9 | "增加了禁用问候语的选项", 10 | "修复了某些 Live2D 模型会导致报错的问题" 11 | ], 12 | // =================================================================================================================== 13 | 14 | "ui_info": "{NAME} v{VERSION}", 15 | "ui_img_dir": "背景图片", 16 | "ui_vid_dir": "背景视频", 17 | "ui_switch": "切换菜单
如果找不到菜单开关,请点击这个按钮", 18 | "ui_reset": "重置设定
如果无法在壁纸里完成重置,请点击这个按钮", 19 | 20 | "language_name": "中文", 21 | 22 | "v2_note": "已升级到 2.0!", 23 | 24 | "confirm": "确定", 25 | "cancel": "取消", 26 | "save": "保存", 27 | "discard": "放弃", 28 | 29 | "reset_confirm": "确定要重置所有设定吗?", 30 | 31 | "Default": "默认", 32 | "Halloween": "万圣节", 33 | "Christmas": "圣诞节", 34 | "New Year": "新年", 35 | 36 | "general": "通用", 37 | "character": "人物", 38 | "background": "背景", 39 | "effect": "特效", 40 | "console": "控制台", 41 | "about": "关于", 42 | 43 | "theme": "主题", 44 | "save_theme": "保存当前主题为:", 45 | "unsaved_theme": "未保存的主题", 46 | "has_unsaved_theme": "当前的自定义主题尚未保存,是否保存?", 47 | "my_theme": "我的主题", 48 | "misc": "杂项", 49 | "leaves": "树叶", 50 | "snow": "雪花", 51 | 52 | "enabled": "启用", 53 | "seasonal_theming": "季节主题", 54 | "volume": "音量", 55 | "safe_area": "安全区域", 56 | "show_fps": "显示FPS", 57 | "max_fps": "最大FPS", 58 | "_ui_language": "UI语言", 59 | 60 | "files_exceed_limit": "文件数量超出限制", 61 | "dragging": "拖拽", 62 | "focus_on_press": "按下鼠标跟随", 63 | "focus_timeout": "跟随持续时间", 64 | "start_up_greeting": "问候语", 65 | "subtitle_enabled": "启用字幕", 66 | "subtitle_bottom": "底部字幕", 67 | "details": "详情", 68 | "model_loading": "加载中...\n\n如果无限加载的话,可以通过控制台里的日志来查看详情", 69 | "model_not_found": "无法从该位置加载模型,请检查文件", 70 | "scale": "缩放", 71 | "language": "语言", 72 | 73 | "fill_type": "填充类型", 74 | "fill": "填充", 75 | "cover": "覆盖", 76 | "built_in": "内置", 77 | "no_bg_dir": "未选择目录,请在 Wallpaper Engine 中设置{0}", 78 | 79 | "overall": "综合", 80 | "high_quality": "高品质", 81 | "amount": "数量", 82 | "drop_rate": "掉落速率", 83 | 84 | "cmd": "命令", 85 | "copy_logs": "复制日志", 86 | "copy_storage": "复制内部存储", 87 | "reset": "重置设定", 88 | 89 | "subtitle": "终极的Live2D壁纸", 90 | "important_changes": "重要更新于", 91 | "changelog": "更新日志 v{VERSION}", 92 | "desc": "关于指南以及更多信息,请访问创意工坊页面" 93 | } 94 | -------------------------------------------------------------------------------- /assets/bridge.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 21 | 22 | 23 |
Waiting...
24 | 25 | 91 | 92 | -------------------------------------------------------------------------------- /src/module/background/Background.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 81 | 82 | 86 | 87 | 96 | -------------------------------------------------------------------------------- /src/shim.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'semver/functions/*' { 2 | const F: (...args: any[]) => {}; 3 | export default F; 4 | } 5 | 6 | /** 7 | * Vue shims 8 | */ 9 | declare module '*.vue' { 10 | import Vue from 'vue'; 11 | import 'vue-i18n'; 12 | export default Vue; 13 | } 14 | 15 | declare module 'vue-clickaway' { 16 | export const directive: Function; 17 | } 18 | 19 | /** 20 | * PIXI shims 21 | * @see https://github.com/pixijs/pixi.js/issues/5397 22 | */ 23 | declare module '@pixi/constants' { 24 | export { BLEND_MODES, DRAW_MODES } from 'pixi.js'; 25 | } 26 | 27 | declare module '@pixi/core' { 28 | import { State } from 'pixi.js'; 29 | 30 | class ExposedState extends State { 31 | static for2d(): State; // this is not defined in pixi's types 32 | } 33 | 34 | export { ExposedState as State }; 35 | export { 36 | Buffer, 37 | Geometry, 38 | Renderer, 39 | AbstractBatchRenderer as BatchRenderer, // use a name trick because BatchRenderer is not defined in pixi's types 40 | Shader, 41 | Texture, 42 | BaseTexture, 43 | } from 'pixi.js'; 44 | } 45 | 46 | declare module '@pixi/loaders' { 47 | export { Loader } from 'pixi.js'; 48 | } 49 | 50 | declare module '@pixi/utils' { 51 | import { utils } from 'pixi.js'; 52 | export import EventEmitter = utils.EventEmitter; 53 | export import sayHello = utils.sayHello; 54 | } 55 | 56 | declare module '@pixi/app' { 57 | export { Application } from 'pixi.js'; 58 | } 59 | 60 | declare module '@pixi/display' { 61 | export { DisplayObject, Container } from 'pixi.js'; 62 | } 63 | 64 | declare module '@pixi/particles' { 65 | export { ParticleContainer, ParticleRenderer } from 'pixi.js'; 66 | } 67 | 68 | declare module '@pixi/spritesheet' { 69 | export { SpritesheetLoader } from 'pixi.js'; 70 | } 71 | 72 | declare module '@pixi/sprite' { 73 | export { Sprite } from 'pixi.js'; 74 | } 75 | 76 | declare module '@pixi/mesh' { 77 | export { Mesh } from 'pixi.js'; 78 | } 79 | 80 | declare module '@pixi/math' { 81 | export { Matrix, Point, Rectangle, Bounds, Transform } from 'pixi.js'; 82 | } 83 | 84 | /** 85 | * Webpack import shims 86 | * @see https://stackoverflow.com/questions/43638454/webpack-typescript-image-import?rq=1 87 | */ 88 | declare module '*.png' { 89 | const value: string; 90 | export default value; 91 | } 92 | 93 | declare module '*.svg' { 94 | import Vue from 'vue'; 95 | export default Vue; 96 | } 97 | 98 | declare module '*.vert' { 99 | const value: string; 100 | export default value; 101 | } 102 | 103 | declare module '*.frag' { 104 | const value: string; 105 | export default value; 106 | } 107 | -------------------------------------------------------------------------------- /src/core/utils/EventEmitter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * eventemitter3 patched with sticky events. 3 | * 4 | * @see http://greenrobot.org/eventbus/documentation/configuration/sticky-events/ 5 | */ 6 | 7 | import EventEmitter from 'eventemitter3'; 8 | 9 | class PatchedEventEmitter extends EventEmitter { 10 | _stickies = new Events(); 11 | } 12 | 13 | let prefix = '~'; 14 | 15 | function Events() {} 16 | 17 | if (Object.create) { 18 | Events.prototype = Object.create(null); 19 | if (!new Events().__proto__) prefix = false; 20 | } 21 | 22 | function EE(fn, context, once) { 23 | this.fn = fn; 24 | this.context = context; 25 | this.once = once || false; 26 | } 27 | 28 | function addListener(emitter, event, fn, context, once) { 29 | if (typeof fn !== 'function') { 30 | throw new TypeError('The listener must be a function'); 31 | } 32 | 33 | const evt = prefix ? prefix + event : event; 34 | 35 | // immediately call the listener when it matches a sticky event 36 | if (emitter._stickies[evt]) { 37 | fn.apply(context || emitter, emitter._stickies[evt]); 38 | 39 | // don't save this listener if it's once event 40 | if (once) return emitter; 41 | } 42 | 43 | const listener = new EE(fn, context || emitter, once); 44 | 45 | if (!emitter._events[evt]) (emitter._events[evt] = listener), emitter._eventsCount++; 46 | else if (!emitter._events[evt].fn) emitter._events[evt].push(listener); 47 | else emitter._events[evt] = [emitter._events[evt], listener]; 48 | 49 | return emitter; 50 | } 51 | 52 | PatchedEventEmitter.prototype.on = function on(event, fn, context) { 53 | return addListener(this, event, fn, context, false); 54 | }; 55 | 56 | PatchedEventEmitter.prototype.once = function once(event, fn, context) { 57 | return addListener(this, event, fn, context, true); 58 | }; 59 | 60 | PatchedEventEmitter.prototype.addListener = PatchedEventEmitter.prototype.on; 61 | 62 | PatchedEventEmitter.prototype.sticky = function sticky(event, a1, a2, a3, a4, a5) { 63 | const evt = prefix ? prefix + event : event; 64 | 65 | this._stickies[evt] = Array.prototype.slice.call(arguments, 1); 66 | 67 | // immediately emit this event to call existing listeners 68 | switch (arguments.length) { 69 | case 1: 70 | return this.emit(event); 71 | case 2: 72 | return this.emit(event, a1); 73 | case 3: 74 | return this.emit(event, a1, a2); 75 | case 4: 76 | return this.emit(event, a1, a2, a3); 77 | case 5: 78 | return this.emit(event, a1, a2, a3, a4); 79 | case 6: 80 | return this.emit(event, a1, a2, a3, a4, a5); 81 | } 82 | 83 | return this.emit(event, this._stickies[evt]); 84 | }; 85 | 86 | export default PatchedEventEmitter; 87 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const merge = require('lodash/merge'); 3 | const projectJSON = require('./assets/project.json'); 4 | const { readLocales } = require('./scripts/project-json-generator'); 5 | 6 | module.exports = { 7 | productionSourceMap: false, 8 | publicPath: '', // use empty to make generated files be able to load by 'file://' scheme 9 | 10 | devServer: { 11 | contentBase: path.resolve('wallpaper'), 12 | historyApiFallback: false, 13 | 14 | // see https://webpack.js.org/configuration/dev-server/#devserverbefore 15 | before(app) { 16 | // handle the transfer of Wallpaper Engine properties 17 | // see "/assets/bridge.html" 18 | 19 | // I MUST BE CRAZY TO DO THIS 20 | 21 | const props = { 22 | userProps: {}, 23 | generalProps: {}, 24 | files: {}, 25 | }; 26 | 27 | // receive properties by POST 28 | app.post('/props', require('body-parser').json(), (req, res, next) => { 29 | // save props to local variables 30 | merge(props.userProps, req.body.userProps); 31 | merge(props.generalProps, req.body.generalProps); 32 | 33 | // DON'T use merge on files because merging will keep the removed files! 34 | Object.assign(props.files, req.body.files); 35 | 36 | res.json(props); 37 | }); 38 | 39 | // return properties by GET 40 | app.get('/props', (req, res, next) => { 41 | res.json(props); 42 | }); 43 | }, 44 | 45 | after(app) { 46 | // override default 404 behaviour so it won't send us an HTML 404 page 47 | app.use((req, res, next) => { 48 | res.status(404).end(); 49 | }); 50 | }, 51 | }, 52 | 53 | chainWebpack(config) { 54 | // inject some properties 55 | // see https://github.com/webpack/webpack/issues/237 56 | // and https://stackoverflow.com/questions/53076540/vue-js-webpack-problem-cant-add-plugin-to-vue-config-js-with-configurewebpack 57 | config.plugin('define').tap(args => { 58 | args[0]['process.env'].NAME = JSON.stringify(projectJSON.title); 59 | args[0]['process.env'].VERSION = JSON.stringify(process.env.npm_package_version); 60 | args[0]['process.env'].BUILT_TIME = JSON.stringify(Date.now()); 61 | args[0]['process.env'].I18N = JSON.stringify(readLocales()); 62 | return args; 63 | }); 64 | 65 | // load SVGs 66 | const svgRule = config.module.rule('svg'); 67 | svgRule.uses.clear(); 68 | svgRule.use('vue-svg-loader').loader('vue-svg-loader'); 69 | }, 70 | }; 71 | -------------------------------------------------------------------------------- /src/core/live2d/Live2DExpression.ts: -------------------------------------------------------------------------------- 1 | import { cloneWithCamelCase } from '@/core/utils/misc'; 2 | 3 | const enum ParamCalcType { 4 | Set = 'set', 5 | Add = 'add', 6 | Mult = 'mult', 7 | } 8 | 9 | interface Param { 10 | id: number; 11 | value: number; 12 | type: ParamCalcType; 13 | } 14 | 15 | const DEFAULT_FADING_DURATION = 500; 16 | 17 | export default class Live2DExpression extends AMotion { 18 | name?: string; 19 | 20 | readonly params: Param[] = []; 21 | 22 | constructor(readonly internalModel: Live2DModelWebGL, json: object, name?: string) { 23 | super(); 24 | 25 | this.name = name; 26 | this.load(cloneWithCamelCase(json)); 27 | } 28 | 29 | private load(json: any) { 30 | this.setFadeIn(json.fadeIn > 0 ? json.fadeIn : DEFAULT_FADING_DURATION); 31 | this.setFadeOut(json.fadeOut > 0 ? json.fadeOut : DEFAULT_FADING_DURATION); 32 | 33 | if (Array.isArray(json.params)) { 34 | json.params.forEach((paramDef: any) => { 35 | let value = parseFloat(paramDef.val); 36 | 37 | if (!paramDef.id || !value) { 38 | // skip if missing essential properties 39 | return; 40 | } 41 | 42 | const id = this.internalModel.getParamIndex(paramDef.id); 43 | const type = paramDef.calc || ParamCalcType.Add; 44 | 45 | if (type === ParamCalcType.Add) { 46 | const defaultValue = parseFloat(paramDef.def) || 0; 47 | value -= defaultValue; 48 | } else if (type === ParamCalcType.Mult) { 49 | const defaultValue = parseFloat(paramDef.def) || 1; 50 | value /= defaultValue; 51 | } 52 | 53 | this.params.push({ id, value, type }); 54 | }); 55 | } 56 | } 57 | 58 | /** @override */ 59 | updateParamExe(model: Live2DModelWebGL, time: DOMTimeStamp, weight: number, motionQueueEnt: unknown) { 60 | this.params.forEach(param => { 61 | // this algorithm seems to be broken for newer Neptunia series models, have no idea 62 | // 63 | // switch (param.type) { 64 | // case ParamCalcType.Set: 65 | // model.setParamFloat(param.id, param.value, weight); 66 | // break; 67 | // case ParamCalcType.Add: 68 | // model.addToParamFloat(param.id, param.value * weight); 69 | // break; 70 | // case ParamCalcType.Mult: 71 | // model.multParamFloat(param.id, param.value, weight); 72 | // break; 73 | // } 74 | 75 | // this works fine for any model 76 | model.setParamFloat(param.id, param.value * weight); 77 | }); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/core/utils/dom.ts: -------------------------------------------------------------------------------- 1 | import { inWallpaperEngine } from '@/core/utils/misc'; 2 | 3 | declare global { 4 | interface Screen { 5 | // these properties are supposed to exist but somehow missing in Typescript 6 | availTop: number; 7 | availLeft: number; 8 | } 9 | } 10 | 11 | const safeWidth = inWallpaperEngine ? screen.availWidth : innerWidth; 12 | const safeHeight = inWallpaperEngine ? screen.availHeight : innerHeight; 13 | const safeTop = inWallpaperEngine ? screen.availTop : 0; 14 | const safeLeft = inWallpaperEngine ? screen.availLeft : 0; 15 | const safeBottom = inWallpaperEngine ? screen.height - (screen.availHeight + screen.availTop) : 0; 16 | const safeRight = inWallpaperEngine ? screen.width - (screen.availWidth + screen.availLeft) : 0; 17 | 18 | export namespace SafeArea { 19 | export const SAFE = { 20 | width: safeWidth, 21 | height: safeHeight, 22 | top: safeTop, 23 | right: safeRight, 24 | bottom: safeBottom, 25 | left: safeLeft, 26 | 27 | widthPX: safeWidth + 'px', 28 | heightPX: safeHeight + 'px', 29 | topPX: safeTop + 'px', 30 | rightPX: safeRight + 'px', 31 | bottomPX: safeBottom + 'px', 32 | leftPX: safeLeft + 'px', 33 | }; 34 | 35 | export const UNSAFE = { 36 | width: innerWidth, 37 | height: innerHeight, 38 | top: 0, 39 | right: 0, 40 | bottom: 0, 41 | left: 0, 42 | 43 | widthPX: innerWidth + 'px', 44 | heightPX: innerHeight + 'px', 45 | topPX: '0', 46 | rightPX: '0', 47 | bottomPX: '0', 48 | leftPX: '0', 49 | }; 50 | 51 | export let area = SAFE; 52 | 53 | export function setSafe(safe: boolean) { 54 | if (safe) { 55 | area = SAFE; 56 | document.documentElement.style.setProperty('--safeTop', SAFE.topPX); 57 | document.documentElement.style.setProperty('--safeRight', SAFE.rightPX); 58 | document.documentElement.style.setProperty('--safeLeft', SAFE.leftPX); 59 | document.documentElement.style.setProperty('--safeBottom', SAFE.bottomPX); 60 | } else { 61 | area = UNSAFE; 62 | document.documentElement.style.removeProperty('--safeTop'); 63 | document.documentElement.style.removeProperty('--safeRight'); 64 | document.documentElement.style.removeProperty('--safeLeft'); 65 | document.documentElement.style.removeProperty('--safeBottom'); 66 | } 67 | } 68 | } 69 | 70 | export const SCREEN_ASPECT_RATIO = (() => { 71 | // https://stackoverflow.com/a/1186465 72 | 73 | function gcd(a: number, b: number): number { 74 | return b == 0 ? a : gcd(b, a % b); 75 | } 76 | 77 | const w = screen.width; 78 | const h = screen.height; 79 | const r = gcd(w, h); 80 | 81 | return w / r + ':' + h / r; 82 | })(); 83 | -------------------------------------------------------------------------------- /src/module/background/BackgroundModule.ts: -------------------------------------------------------------------------------- 1 | import { App, Module } from '@/App'; 2 | import { nop } from '@/core/utils/misc'; 3 | import Background from '@/module/background/Background.vue'; 4 | 5 | export interface Config { 6 | bg?: { 7 | src: string; 8 | volume?: number; 9 | fill?: boolean; 10 | }; 11 | } 12 | 13 | export function isVideo(src: string) { 14 | // only webm ang ogv can be accepted in Wallpaper Engine, mp4 is added for test purpose 15 | // see https://steamcommunity.com/app/431960/discussions/2/1644304412672544283/ 16 | return /(mp4|webm|ogv)$/.test(src); 17 | } 18 | 19 | export default class BackgroundModule implements Module { 20 | name = 'Background'; 21 | 22 | componentPromise?: Promise; 23 | 24 | src = ''; 25 | fill = false; 26 | volume = 0; 27 | 28 | constructor(readonly app: App) { 29 | app.on('config:bg.fill', this.setFill, this) 30 | .on('config:bg.volume', this.setVolume, this) 31 | .on('config:bg.src', this.setBackground, this) 32 | .on('configReady', (config: Config) => { 33 | if (config.bg) { 34 | this.setVolume(config.bg.volume).then(); 35 | this.setFill(!!config.bg.fill).then(); 36 | this.setBackground(config.bg.src).then(); 37 | } 38 | }); 39 | 40 | document.body.style.transition = 'background .2s'; 41 | } 42 | 43 | async setBackground(src: string) { 44 | this.src = src; 45 | 46 | if (isVideo(src)) { 47 | if (!this.componentPromise) { 48 | this.componentPromise = this.app.addComponent(Background, { module: () => this }); 49 | } 50 | 51 | await this.componentPromise; 52 | this.applyVideo(src); 53 | this.applyVolume(this.volume); 54 | this.fillVideo(this.fill); 55 | } else { 56 | document.body.style.backgroundImage = `url("${src}")`; 57 | 58 | if (this.componentPromise) { 59 | // clear the video background 60 | await this.componentPromise; 61 | this.applyVideo(); 62 | } 63 | } 64 | } 65 | 66 | async setFill(fill: boolean) { 67 | this.fill = fill; 68 | 69 | if (isVideo(this.src)) { 70 | if (this.componentPromise) { 71 | await this.componentPromise; 72 | this.fillVideo(this.fill); 73 | } 74 | } else { 75 | document.body.style.backgroundSize = fill ? '100% 100%' : 'cover'; 76 | } 77 | } 78 | 79 | async setVolume(volume?: number) { 80 | this.volume = volume || 0; 81 | 82 | if (isVideo(this.src) && this.componentPromise) { 83 | await this.componentPromise; 84 | this.applyVolume(this.volume); 85 | } 86 | } 87 | 88 | /** @abstract */ 89 | applyVideo: (src?: string) => void = nop; 90 | 91 | /** @abstract */ 92 | applyVolume: (volume: number) => void = nop; 93 | 94 | /** @abstract */ 95 | fillVideo: (fill: boolean) => void = nop; 96 | } 97 | -------------------------------------------------------------------------------- /src/App.ts: -------------------------------------------------------------------------------- 1 | import Mka from '@/core/mka/Mka'; 2 | import Ticker from '@/core/mka/Ticker'; 3 | import { SafeArea } from '@/core/utils/dom'; 4 | import EventEmitter from '@/core/utils/EventEmitter'; 5 | import { error } from '@/core/utils/log'; 6 | import { FPS_MAX, HIGH_QUALITY, LOCALE, SAFE_AREA_MODE } from '@/defaults'; 7 | import { Config } from '@/module/config/ConfigModule'; 8 | import VueApp from '@/VueApp.vue'; 9 | import { Vue, VueConstructor } from 'vue/types/vue'; 10 | 11 | export interface ModuleConstructor { 12 | new(app: App): Module; 13 | } 14 | 15 | export interface Module { 16 | name: string; 17 | } 18 | 19 | const TAG = 'App'; 20 | 21 | export class App extends EventEmitter { 22 | destroyed = false; 23 | 24 | readonly mka: Mka; 25 | 26 | readonly modules: { [name: string]: Module } = {}; 27 | 28 | constructor(readonly vueApp: VueApp) { 29 | super(); 30 | 31 | const canvas = (vueApp as any).canvas as HTMLCanvasElement; 32 | this.mka = new Mka(canvas); 33 | 34 | this.on('pause', () => this.mka.pause()) 35 | .on('resume', () => this.mka.resume()) 36 | .on('we:schemecolor', (color: string) => { 37 | const rgb = color 38 | .split(' ') 39 | .map(float => ~~(parseFloat(float) * 255)) 40 | .join(','); 41 | document.documentElement.style.setProperty('--accentColor', `rgb(${rgb})`); 42 | }) 43 | .on('we:reset', (reset: boolean, initial?: boolean) => { 44 | if (!initial && confirm(vueApp.$t('reset_confirm') as string)) { 45 | this.emit('reset'); 46 | } 47 | }) 48 | .on('config:locale', (locale: string) => (vueApp.$i18n.locale = locale)) 49 | .on('config:fpsMax', (maxFPS: number) => Ticker.setMaxFPS(maxFPS)) 50 | .on('config:safe', (safe: boolean) => { 51 | SafeArea.setSafe(safe); 52 | setTimeout(() => this.mka.pixiApp.resize(), 0); 53 | }) 54 | .on('configReady', (config: Config) => { 55 | this.emit('config', 'locale', LOCALE, true); 56 | this.emit('config', 'fpsMax', FPS_MAX, true); 57 | this.emit('config', 'safe', SAFE_AREA_MODE, true); 58 | this.emit('config', 'hq', HIGH_QUALITY, true); 59 | 60 | this.on('we:language', (locale: string) => { 61 | const shouldSave = !config.get('locale', undefined, true); 62 | this.emit('config', 'locale', locale, !shouldSave); 63 | }); 64 | }); 65 | } 66 | 67 | use(M: ModuleConstructor) { 68 | try { 69 | const module = new M(this); 70 | this.modules[module.name] = module; 71 | } catch (e) { 72 | error(TAG, `Failed to create module ${M.name}`, e); 73 | } 74 | } 75 | 76 | async addComponent(componentClass: VueConstructor, props?: any) { 77 | return (this.vueApp as any).addChild(componentClass, props) as Vue; 78 | } 79 | 80 | destroy() { 81 | this.destroyed = true; 82 | this.emit('destroy'); 83 | this.vueApp.$destroy(); 84 | this.mka.destroy(); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/module/live2d-motion/Live2DMotionModule.ts: -------------------------------------------------------------------------------- 1 | import { App, Module } from '@/App'; 2 | import { Config } from '@/module/config/ConfigModule'; 3 | import SoundManager from '@/module/live2d-motion/SoundManager'; 4 | import SubtitleManager from '@/module/live2d-motion/SubtitleManager'; 5 | import VueLive2DMotion from '@/module/live2d-motion/VueLive2DMotion.vue'; 6 | import Live2DModule from '@/module/live2d/Live2DModule'; 7 | import Live2DSprite from '@/module/live2d/Live2DSprite'; 8 | import { ModelConfig } from '@/module/live2d/ModelConfig'; 9 | import { EventEmitter } from '@pixi/utils'; 10 | 11 | /** 12 | * Enhances Live2D motion by sounds and subtitles. 13 | */ 14 | export default class Live2DMotionModule implements Module { 15 | name = 'Live2DMotion'; 16 | 17 | config?: Config; 18 | 19 | soundManager = new SoundManager(); 20 | subtitleManager = new SubtitleManager(); 21 | 22 | originalVolume = 0; 23 | 24 | constructor(readonly app: App) { 25 | const live2dModule = app.modules['Live2D'] as Live2DModule; 26 | 27 | if (!live2dModule) return; 28 | 29 | app.on('config:volume', (volume: number) => (this.soundManager.volume = volume)) 30 | .on('config:locale', (locale: string) => (this.subtitleManager.defaultLocale = locale)) 31 | .on('live2dLoaded', (id: number, sprite: Live2DSprite) => this.processSprite(sprite)) 32 | .on('configReady', (config: Config) => { 33 | this.config = config; 34 | app.emit('config', 'volume', this.soundManager.volume, true); 35 | app.emit('config', 'sub.on', true, true); 36 | }) 37 | .on('pause', () => { 38 | // lower the volume in background 39 | this.originalVolume = this.soundManager.volume; 40 | this.soundManager.volume *= 0.3; 41 | }) 42 | .on('resume', () => (this.soundManager.volume = this.originalVolume)); 43 | 44 | app.addComponent(VueLive2DMotion, { module: () => this }).then(); 45 | } 46 | 47 | processSprite(sprite: Live2DSprite) { 48 | const subtitleFile = sprite.model.modelSettings.subtitle; 49 | 50 | if (subtitleFile) { 51 | this.subtitleManager.loadSubtitle(subtitleFile).then(languages => { 52 | languages && this.app.emit('live2dSubtitleLoaded', sprite.id, languages); 53 | }); 54 | } 55 | 56 | (sprite as EventEmitter).on('motion', async (group: string, index: number) => { 57 | const motionDefinition = sprite.model.modelSettings.motions[group][index]; 58 | 59 | let audioPromise: Promise | undefined; 60 | 61 | if (motionDefinition.sound) { 62 | audioPromise = this.soundManager.playSound(motionDefinition.sound); 63 | } 64 | 65 | if (subtitleFile && motionDefinition.subtitle && this.config && this.config.get('sub.on', true)) { 66 | const modelConfig = 67 | this.config && 68 | this.config.get('live2d.models', []).find(model => model.id === sprite.id); 69 | const locale = modelConfig && modelConfig.locale; 70 | 71 | this.subtitleManager.showSubtitle(subtitleFile, motionDefinition.subtitle, locale, audioPromise).then(); 72 | } 73 | }); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /scripts/setup.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | import readline from 'readline'; 4 | import chalk from 'chalk'; 5 | 6 | import loadEnv from './load-env'; 7 | import { generate as generateProjectJSON } from './project-json-generator'; 8 | 9 | const env = loadEnv(); 10 | 11 | if (!env) { 12 | console.error('Missing environment variables'); 13 | process.exit(1); 14 | } 15 | 16 | const OVERWRITE_MESSAGE = 'File already exists, overwrite it?'; 17 | 18 | const rl = readline.createInterface(process.stdin, process.stdout); 19 | 20 | (async function setup() { 21 | console.log(chalk.black.bgBlue(' SETUP ')); 22 | 23 | try { 24 | console.log(chalk.blue('Making files in', env.WALLPAPER_PATH)); 25 | 26 | await copyFiles( 27 | ['./assets/bridge.html', path.join(env.WALLPAPER_PATH, 'index.html')], 28 | ['./assets/preview.jpg', path.join(env.WALLPAPER_PATH, 'preview.jpg')], 29 | ); 30 | 31 | await setupProjectJSON(); 32 | } catch (e) { 33 | console.warn(e); 34 | } 35 | 36 | rl.close(); 37 | })(); 38 | 39 | async function copyFiles(...filePairs) { 40 | async function copyFile(from, to) { 41 | if (await checkConflict(to, from)) { 42 | fs.copyFileSync(from, to); 43 | console.log(chalk.black.bgGreen(' WRITE '), chalk.green(to)); 44 | } 45 | } 46 | 47 | for (const [from, to] of filePairs) { 48 | await copyFile(from, to); 49 | } 50 | } 51 | 52 | async function setupProjectJSON() { 53 | const projectJSON = generateProjectJSON(true); 54 | 55 | for (const jsonPath of [ 56 | path.join(env.WALLPAPER_PATH, 'project.json'), 57 | path.join(__dirname, '../wallpaper/project.json'), 58 | ]) { 59 | if (await checkConflictByContent(jsonPath, projectJSON)) { 60 | fs.writeFileSync(jsonPath, projectJSON); 61 | console.log(chalk.black.bgGreen(' WRITE '), chalk.green(jsonPath)); 62 | } 63 | } 64 | } 65 | 66 | /** 67 | * @return {Promise} True if dst should be overwritten. 68 | */ 69 | async function checkConflict(dst, src) { 70 | if (fs.existsSync(dst)) { 71 | if (!fs.readFileSync(dst).equals(fs.readFileSync(src))) { 72 | console.log(chalk.black.bgRed(' CONFLICT '), chalk.red(dst)); 73 | await confirm(OVERWRITE_MESSAGE); 74 | return true; 75 | } 76 | 77 | console.log(chalk.black.bgGreen(' SKIP '), chalk.green(dst)); 78 | return false; 79 | } 80 | return true; 81 | } 82 | 83 | /** 84 | * @return {Promise} True if dst should be overwritten. 85 | */ 86 | async function checkConflictByContent(dst, content) { 87 | if (fs.existsSync(dst)) { 88 | if (fs.readFileSync(dst, 'utf-8') !== content) { 89 | console.log(chalk.black.bgRed(' CONFLICT '), chalk.red(dst)); 90 | await confirm(OVERWRITE_MESSAGE); 91 | return true; 92 | } 93 | 94 | console.log(chalk.black.bgGreen(' SKIP '), chalk.green(dst)); 95 | return false; 96 | } 97 | return true; 98 | } 99 | 100 | async function confirm(message) { 101 | const result = await new Promise(resolve => rl.question(message + ' y/[n]: ', resolve)); 102 | 103 | if (result !== 'y') throw 'Action canceled'; 104 | } 105 | -------------------------------------------------------------------------------- /src/module/live2d-motion/VueLive2DMotion.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 69 | 70 | 125 | -------------------------------------------------------------------------------- /src/core/live2d/live2d.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Declares types of the exposed variables from Live2D library. 3 | * 4 | * Since Live2D library is not open-source, these types come from inference or guess. 5 | * Many of them are `unknown` though. 6 | */ 7 | 8 | declare class Live2D { 9 | static setGL(gl: WebGLRenderingContext, index?: number): void; 10 | 11 | static getError(): unknown | undefined; 12 | } 13 | 14 | declare class Live2DModelWebGL { 15 | static loadModel(buffer: ArrayBuffer): Live2DModelWebGL; 16 | 17 | private constructor(); 18 | 19 | drawParamWebGL: DrawParamWebGL; 20 | 21 | /** 22 | * @returns The width of model's Live2D drawing canvas but NOT the html canvas element. 23 | */ 24 | getCanvasWidth(): number; 25 | 26 | /** 27 | * @returns The height of model's Live2D drawing canvas but NOT the html canvas element. 28 | */ 29 | getCanvasHeight(): number; 30 | 31 | setTexture(index: number, texture: WebGLTexture): void; 32 | 33 | setMatrix(matrix: ArrayLike): void; 34 | 35 | setParamFloat(id: string | number, value: number, weight?: number): unknown; 36 | 37 | addToParamFloat(id: string | number, value: number, weight?: number): unknown; 38 | 39 | multParamFloat(id: string | number, value: number, weight?: number): unknown; 40 | 41 | setPartsOpacity(id: string | number, value: number): unknown; 42 | 43 | getPartsOpacity(id: string | number): number; 44 | 45 | getParamFloat(id: string | number): number; 46 | 47 | getParamIndex(id: string): number; 48 | 49 | getPartsDataIndex(id: string): number; 50 | 51 | getDrawDataIndex(id: string): number; 52 | 53 | getTransformedPoints(index: number): number[]; 54 | 55 | loadParam(): void; 56 | 57 | saveParam(): void; 58 | 59 | update(): void; 60 | 61 | draw(): void; 62 | } 63 | 64 | // this class is not exposed from Live2D library, never use it outside 65 | declare class DrawParamWebGL { 66 | gl: WebGLRenderingContext; 67 | glno: number; 68 | 69 | setGL(gl: WebGLRenderingContext): void; 70 | } 71 | 72 | declare class AMotion { 73 | setFadeIn(time: number): unknown; 74 | 75 | setFadeOut(time: number): unknown; 76 | 77 | updateParamExe(model: Live2DModelWebGL, time: DOMTimeStamp, weight: number, MotionQueueEnt: unknown): unknown; 78 | } 79 | 80 | declare class Live2DMotion extends AMotion { 81 | private constructor(); 82 | 83 | static loadMotion(buffer: ArrayBuffer): Live2DMotion; 84 | } 85 | 86 | declare class MotionQueueManager { 87 | motions: unknown[]; 88 | 89 | /** 90 | * @returns The size of internal motion arrays. 91 | */ 92 | startMotion(motion: AMotion, neverUsedArg?: boolean): number; 93 | 94 | stopAllMotions(): void; 95 | 96 | isFinished(): boolean; 97 | 98 | /** 99 | * @returns True if parameters are updated by any motion. 100 | */ 101 | updateParam(model: Live2DModelWebGL): boolean; 102 | } 103 | 104 | declare class PhysicsHair { 105 | static Src: { 106 | SRC_TO_X: string; 107 | SRC_TO_Y: string; 108 | SRC_TO_G_ANGLE: string; 109 | }; 110 | static Target: { 111 | TARGET_FROM_ANGLE: string; 112 | TARGET_FROM_ANGLE_V: string; 113 | }; 114 | 115 | setup(length: number, regist: number, mass: number): unknown; 116 | 117 | addSrcParam(type: string, id: string, scale: number, weight: number): unknown; 118 | 119 | addTargetParam(type: string, id: string, scale: number, weight: number): unknown; 120 | 121 | update(model: Live2DModelWebGL, time: DOMTimeStamp): unknown; 122 | } 123 | 124 | declare class PartsDataID { 125 | static getID(id: string): string; 126 | } 127 | -------------------------------------------------------------------------------- /src/core/utils/Draggable.ts: -------------------------------------------------------------------------------- 1 | import autobind from 'autobind-decorator'; 2 | 3 | declare global { 4 | interface MouseEvent { 5 | _movementX: number; 6 | _movementY: number; 7 | } 8 | } 9 | 10 | /** 11 | * Dragging experience in Wallpaper Engine is pretty awful, so we always need to mimic the dragging behaviour for elements by ourselves. 12 | */ 13 | export default class Draggable { 14 | dragged = false; 15 | 16 | lastMouseX = 0; 17 | lastMouseY = 0; 18 | 19 | /** 20 | * @param element - Element that should be listened for drag event. 21 | * @param target - Element that should respond to the dragging with movement, if unset, only the listeners will be called. 22 | * @param deadZone - Maximum dragging distance to keep element static. 23 | * @param exact - Only trigger dragging if events are dispatched exactly from this element. 24 | */ 25 | constructor(public element: HTMLElement, public target?: HTMLElement, public deadZone = 0, public exact = true) { 26 | element.addEventListener('mousedown', this.mousedown); 27 | } 28 | 29 | @autobind 30 | private mousedown(e: MouseEvent) { 31 | if ((!this.exact || e.target === e.currentTarget) && this.onStart(e)) { 32 | this.lastMouseX = e.clientX; 33 | this.lastMouseY = e.clientY; 34 | 35 | document.addEventListener('mousemove', this.mousemove, { passive: true }); 36 | document.addEventListener('mouseup', this.mouseup); 37 | document.addEventListener('mouseout', this.mouseout); 38 | } 39 | } 40 | 41 | @autobind 42 | private mousemove(e: MouseEvent) { 43 | // `e.movementX` and `e.movementY` are always 0 in Wallpaper Engine, 44 | // need to calculate them manually. 45 | e._movementX = e.clientX - this.lastMouseX; 46 | e._movementY = e.clientY - this.lastMouseY; 47 | 48 | if ( 49 | this.dragged || 50 | e._movementX > this.deadZone || 51 | e._movementX < -this.deadZone || 52 | e._movementY > this.deadZone || 53 | e._movementY < -this.deadZone 54 | ) { 55 | this.dragged = true; 56 | this.lastMouseX = e.clientX; 57 | this.lastMouseY = e.clientY; 58 | 59 | if (this.onDrag(e) && this.target) { 60 | this.target.style.left = this.target.offsetLeft + e._movementX + 'px'; 61 | this.target.style.top = this.target.offsetTop + e._movementY + 'px'; 62 | } 63 | } 64 | } 65 | 66 | @autobind 67 | private mouseup(e: MouseEvent) { 68 | this.onEnd(e); 69 | 70 | this.dragged = false; 71 | this.releaseGlobalListeners(); 72 | } 73 | 74 | @autobind 75 | private mouseout(e: MouseEvent) { 76 | if (e.target === document.documentElement) { 77 | this.mouseup(e); 78 | } 79 | } 80 | 81 | private releaseGlobalListeners() { 82 | document.removeEventListener('mousemove', this.mousemove); 83 | document.removeEventListener('mouseup', this.mouseup); 84 | document.removeEventListener('mouseout', this.mouseout); 85 | } 86 | 87 | release() { 88 | this.element.removeEventListener('mousedown', this.mousedown); 89 | this.releaseGlobalListeners(); 90 | } 91 | 92 | /** 93 | * @return True to start dragging, false to prevent dragging. 94 | */ 95 | onStart = (e: MouseEvent) => true; 96 | 97 | /** 98 | * @return True to move the target element (if set), false to prevent moving. 99 | */ 100 | onDrag = (e: MouseEvent) => true; 101 | 102 | onEnd = (e: MouseEvent) => {}; 103 | } 104 | -------------------------------------------------------------------------------- /src/module/config/reusable/Scrollable.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 91 | 92 | 129 | -------------------------------------------------------------------------------- /src/defaults.ts: -------------------------------------------------------------------------------- 1 | import { isInRange } from '@/core/utils/date'; 2 | import { clamp } from '@/core/utils/math'; 3 | import { Theme, THEME_VERSION } from '@/module/theme/ThemeModule'; 4 | import VueI18n from 'vue-i18n'; 5 | 6 | export const LOCALE = 'en-us'; 7 | export const I18N = (process.env.I18N as any) as VueI18n.LocaleMessages; 8 | 9 | interface Season { 10 | active: boolean; 11 | value: string; 12 | } 13 | 14 | export const HALLOWEEN = { value: 'Halloween', active: isInRange('10-25', '11-5') }; 15 | export const CHRISTMAS = { value: 'Christmas', active: isInRange('12-20', '12-31') }; 16 | export const NEW_YEAR = { value: 'NewYear', active: isInRange('1-1', '1-10') }; 17 | 18 | export const SEASONS: Season[] = [HALLOWEEN, CHRISTMAS, NEW_YEAR]; 19 | 20 | export const BG_DIRECTORY = 'img'; 21 | 22 | export const THEMES: Theme[] = [ 23 | { 24 | v: THEME_VERSION, 25 | name: 'Default', 26 | snow: false, 27 | leaves: true, 28 | bg: { 29 | src: BG_DIRECTORY + '/bg_forest.jpg', 30 | }, 31 | models: [ 32 | { 33 | file: 'neptune/neptune.model.json', 34 | scale: 0.0004141, 35 | x: 0.75, 36 | y: 0.55, 37 | order: 0, 38 | }, 39 | ], 40 | }, 41 | { 42 | v: THEME_VERSION, 43 | name: 'Halloween', 44 | season: HALLOWEEN.value, 45 | snow: true, 46 | leaves: false, 47 | bg: { 48 | src: BG_DIRECTORY + '/bg_halloween.jpg', 49 | }, 50 | models: [ 51 | { 52 | file: 'neptune/neptune.model.json', 53 | scale: 0.0004141, 54 | x: 0.75, 55 | y: 0.55, 56 | order: 0, 57 | }, 58 | ], 59 | }, 60 | { 61 | v: THEME_VERSION, 62 | name: 'Christmas', 63 | season: CHRISTMAS.value, 64 | snow: true, 65 | leaves: false, 66 | bg: { 67 | src: BG_DIRECTORY + '/bg_lowee.jpg', 68 | }, 69 | models: [ 70 | { 71 | file: 'nepsanta/nepsanta.model.json', 72 | scale: 0.0004141, 73 | x: 0.75, 74 | y: 0.55, 75 | order: 0, 76 | }, 77 | ], 78 | }, 79 | ]; 80 | 81 | // A basis to divide the saved indices of selected theme between built-in themes and custom themes. Avoid using unified 82 | // indices for both built-in and custom themes because the amount of built-in themes may increase in future updates. 83 | export const THEME_CUSTOM_OFFSET = 100; 84 | 85 | export const BACKGROUNDS = THEMES.map(theme => theme.bg.src); 86 | 87 | export const SAFE_AREA_MODE = true; 88 | 89 | export const FPS_MAX = 60; 90 | export const FPS_MAX_LIMIT = 300; 91 | 92 | export const VOLUME = 0.05; 93 | 94 | export const Z_INDEX_LIVE2D = 100; 95 | export const Z_INDEX_LEAVES = 120; 96 | export const Z_INDEX_LEAVES_BACK = 90; 97 | export const Z_INDEX_SNOW = 110; 98 | export const Z_INDEX_SNOW_BACK = 80; 99 | 100 | export const LIVE2D_DIRECTORY = 'live2d'; 101 | export const LIVE2D_SCALE_MAX = 1.5; 102 | 103 | export const FOCUS_TIMEOUT = 2000; 104 | export const FOCUS_TIMEOUT_MAX = 10000; 105 | 106 | export const HIGH_QUALITY = true; 107 | 108 | export const SNOW_NUMBER_MIN = 10; 109 | export const SNOW_NUMBER_MAX = 9999; 110 | export const SNOW_NUMBER = clamp(~~((innerWidth * innerHeight) / 2000), SNOW_NUMBER_MIN, SNOW_NUMBER_MAX); 111 | 112 | export const LEAVES_NUMBER_MIN = 1; 113 | export const LEAVES_NUMBER_MAX = 500; 114 | export const LEAVES_NUMBER = clamp(~~(innerWidth / 10), LEAVES_NUMBER_MIN, LEAVES_NUMBER_MAX); 115 | export const LEAVES_DROP_RATE_MIN = 500; 116 | export const LEAVES_DROP_RATE_MAX = 8000; 117 | export const LEAVES_DROP_RATE = 2000; 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nep-live2d 2 | ![GitHub package.json version](https://img.shields.io/github/package-json/v/guansss/nep-live2d?style=flat-square) 3 | [![Codacy Badge](https://img.shields.io/codacy/grade/2a104c4ef281488bafe5404adb27ee28?style=flat-square&logo=codacy)](https://www.codacy.com/manual/guansss/nep-live2d?utm_source=github.com&utm_medium=referral&utm_content=guansss/nep-live2d&utm_campaign=Badge_Grade) 4 | [![Steam Subscriptions](https://img.shields.io/steam/subscriptions/1078208425?style=flat-square&logo=steam&color=blue)](https://steamcommunity.com/sharedfiles/filedetails/?id=1078208425) 5 | ![Made with](https://img.shields.io/badge/made%20with-%E2%99%A5-ff69b4?style=flat-square) 6 | 7 | Beta versions can be found in the [Beta Test Channel](https://steamcommunity.com/workshop/filedetails/discussion/1078208425/1484358860953044356/) 8 | 9 | The project is based on Live2D WebGL SDK 2.1, and thus models of newer or older version are not supported. 10 | 11 | ## Setup 12 | 13 | #### Dependencies 14 | 15 | It's recommended to use *Yarn* as package manager, *npm* is fine though. 16 | 17 | ``` sh 18 | yarn install 19 | ``` 20 | 21 | #### Source files 22 | 23 | Due to copyright restrictions, the files of backgrounds, Live2D SDK and Live2D models are not provided, you need to supply them by yourself. 24 | 25 | 1. (Optional) Download [Live2D WebGL SDK 2.1](http://sites.cybernoids.jp/cubism-sdk2/webgl2-1) and take `live2d.min.js` within. 26 | 27 | 2. Create `wallpaper` directory at project root. 28 | 29 | 3. Copy following files from the distribution of this wallpaper and paste into `wallpaper`. (In Wallpaper Engine, right click on the preview of this wallpaper and select *Open in Explorer*.) 30 | 31 | ``` sh 32 | . 33 | └── /wallpaper 34 | ├── /img 35 | │ ├── bg_forest.jpg 36 | │ ├── bg_halloween.jpg 37 | │ └── bg_lowee.jpg 38 | ├── /live2d (take entire folder) 39 | └── live2d.min.js 40 | ``` 41 | 42 | ## Serving 43 | 44 | ### Sering for browsers 45 | 46 | ``` sh 47 | yarn serve 48 | ``` 49 | 50 | ### Serving for Wallpaper Engine 51 | 52 | By redirecting the running wallpaper to the server, we are able to use the `liveReload` and Hot Module Replacement (HMR) features of Webpack dev server, which are extremely useful for development. 53 | 54 | To achieve that, a script was made to generate a bridge HTML file, there are a few steps to prepare before using this script: 55 | 56 | 1. Create a folder in `myproject` directory of Wallpaper Engine, for example: 57 | ``` 58 | C:\Program Files (x86)\Steam\steamapps\common\wallpaper_engine\projects\myprojects\live2d 59 | ``` 60 | 61 | 2. Go back to this project, create `.env.local` file at project root, add `WALLPAPER_PATH` variable which describes the destination of output files. 62 | 63 | ``` sh 64 | WALLPAPER_PATH=C:\Program Files (x86)\Steam\steamapps\common\wallpaper_engine\projects\myprojects\live2d 65 | ``` 66 | 67 | For more information about the format of this file, see [dotenv](https://github.com/motdotla/dotenv). 68 | 69 | 3. Run following command. You may be asked for confirmations to overwrite existing files. 70 | 71 | ``` sh 72 | yarn setup 73 | ``` 74 | 75 | 4. Check the Wallpaper Engine browser, a new wallpaper should appear with `[DEV]` prefix. 76 | 77 | This preparation should be done only once, but any time you think the generated files are supposed be updated, you need to run `yarn setup` again. 78 | 79 | Now, just like serving for browsers, run `yarn serve`, and then select the wallpaper, everything will work as it should be in browsers. 80 | 81 | ## Building 82 | 83 | ``` sh 84 | yarn build 85 | ``` 86 | 87 | If you are updating an existing Workshop project instead of creating a new one, you need to specify a `WORKSHOP_ID` in `.env.local` before building the project. 88 | 89 | ``` sh 90 | WORKSHOP_ID=123456 91 | ``` 92 | 93 | When publishing to Workshop, don't forget to copy files in `/wallpaper` and paste them into your project. 94 | -------------------------------------------------------------------------------- /src/module/live2d-motion/SubtitleManager.ts: -------------------------------------------------------------------------------- 1 | import { error } from '@/core/utils/log'; 2 | import { getJSON } from '@/core/utils/net'; 3 | 4 | export type SubtitleJSON = Language[]; 5 | 6 | export interface Language { 7 | locale: string; 8 | name: string; 9 | author?: string; 10 | description?: string; 11 | font?: string; 12 | style?: string; 13 | subtitles: Subtitle[]; 14 | } 15 | 16 | export interface Subtitle { 17 | name: string; 18 | text: string; 19 | style?: string; 20 | duration?: number; 21 | } 22 | 23 | const TAG = 'SubtitleManager'; 24 | 25 | export const FALLBACK_LOCALE = 'default'; 26 | 27 | export default class SubtitleManager { 28 | defaultLocale = FALLBACK_LOCALE; 29 | 30 | subtitles: { [file: string]: SubtitleJSON } = {}; 31 | 32 | tasks: { [file: string]: Promise } = {}; 33 | 34 | async loadSubtitle(file: string): Promise { 35 | if (this.subtitles[file]) return this.subtitles[file]; 36 | 37 | this.tasks[file] = (async () => { 38 | try { 39 | const languages = (await getJSON(file)) as SubtitleJSON; 40 | 41 | languages.forEach(language => { 42 | language.locale = language.locale.toLowerCase(); 43 | 44 | language.style = (language.style || '') + ';'; 45 | 46 | // append `font` to `style` 47 | language.style += language.font ? 'font-family:' + language.font : ''; 48 | 49 | // apply language style to each subtitle 50 | language.subtitles.forEach(subtitle => { 51 | subtitle.style = language.style + ';' + (subtitle.style || ''); 52 | }); 53 | }); 54 | 55 | this.subtitles[file] = languages; 56 | 57 | return languages; 58 | } catch (e) { 59 | error(TAG, `Failed to load subtitles from ${file}`, e); 60 | } finally { 61 | delete this.tasks[file]; 62 | } 63 | })(); 64 | 65 | return this.tasks[file]; 66 | } 67 | 68 | async getSubtitle(file: string, name: string, locale?: string): Promise { 69 | if (this.tasks[file]) { 70 | await this.tasks[file]; 71 | } 72 | 73 | const json = this.subtitles[file]; 74 | if (!json) return; 75 | 76 | // want a pyramid? 77 | for (const loc of [locale, this.defaultLocale, FALLBACK_LOCALE]) { 78 | if (loc) { 79 | for (const language of json) { 80 | if (language.locale.includes(loc)) { 81 | for (const subtitle of language.subtitles) { 82 | if (subtitle.name === name) { 83 | return subtitle; 84 | } 85 | } 86 | } 87 | } 88 | } 89 | } 90 | } 91 | 92 | async showSubtitle(file: string, name: string, locale?: string, timingPromise?: Promise) { 93 | const start = Date.now(); 94 | 95 | const subtitle = await this.getSubtitle(file, name, locale); 96 | 97 | if (subtitle) { 98 | if (!isNaN(subtitle.duration!)) { 99 | const id = this.show(subtitle); 100 | const remains = subtitle.duration! - (Date.now() - start); 101 | setTimeout(() => this.dismiss(id), remains); 102 | } else if (timingPromise) { 103 | const id = this.show(subtitle); 104 | timingPromise.then(() => this.dismiss(id)).catch(() => this.dismiss(id)); 105 | } 106 | 107 | return subtitle; 108 | } 109 | } 110 | 111 | // to be overridden 112 | show(subtitle: Subtitle) { 113 | return -1; 114 | } 115 | 116 | dismiss(id: number) {} 117 | } 118 | -------------------------------------------------------------------------------- /src/module/wallpaper/WallpaperModule.ts: -------------------------------------------------------------------------------- 1 | import { App, Module } from '@/App'; 2 | import { error } from '@/core/utils/log'; 3 | import { inWallpaperEngine, redirectedFromBridge } from '@/core/utils/misc'; 4 | import { getJSON, postJSON } from '@/core/utils/net'; 5 | import { LOCALE } from '@/defaults'; 6 | import { WEInterface } from '@/module/wallpaper/WEInterface'; 7 | import debounce from 'lodash/debounce'; 8 | 9 | export default class WallpaperModule implements Module { 10 | name = 'Wallpaper'; 11 | 12 | constructor(readonly app: App) { 13 | WEInterface.setEventEmitter(app); 14 | 15 | this.init().then(); 16 | } 17 | 18 | async init() { 19 | if (inWallpaperEngine) { 20 | if (redirectedFromBridge) { 21 | await setupRemote(); 22 | 23 | // must use a debounce because property can change rapidly, for example when using a color picker 24 | this.app.on('we:*', debounce(updateRemoteProperty, 100)); 25 | 26 | this.app.on('weFilesUpdate:*', updateRemoteFiles).on('weFilesRemove:*', updateRemoteFiles); 27 | } 28 | } else { 29 | await setupDefault(); 30 | } 31 | } 32 | } 33 | 34 | async function setupDefault() { 35 | // supply general properties that does not exist in project.json 36 | window.wallpaperPropertyListener.applyGeneralProperties({ 37 | language: LOCALE, 38 | }); 39 | 40 | try { 41 | // actually loading "wallpaper/project.json", but "wallpaper" is the content root of DevServer, 42 | // so we need to call "project.json" 43 | const project = (await getJSON('project.json')) as { general: { properties: WEUserProperties } }; 44 | 45 | if (project) { 46 | project.general.properties.imgDir.value = 'C:\\fakepath\\img'; 47 | project.general.properties.vidDir.value = 'C:\\fakepath\\vid'; 48 | 49 | window.wallpaperPropertyListener.applyUserProperties(project.general.properties); 50 | } else { 51 | // noinspection ExceptionCaughtLocallyJS 52 | throw 'Empty response'; 53 | } 54 | } catch (e) { 55 | error('WE', 'Failed to load project.json, have you run `yarn setup` ?', e); 56 | } 57 | } 58 | 59 | async function setupRemote() { 60 | try { 61 | const props = await getJSON('/props'); 62 | 63 | if (props) { 64 | props.generalProps && window.wallpaperPropertyListener.applyGeneralProperties(props.generalProps); 65 | props.userProps && window.wallpaperPropertyListener.applyUserProperties(props.userProps); 66 | 67 | if (props.files) { 68 | Object.entries(props.files).forEach(([propName, files]) => { 69 | // files can be null if the directory is unset 70 | if (files) { 71 | window.wallpaperPropertyListener.userDirectoryFilesAddedOrChanged( 72 | propName as keyof WEFiles, 73 | files as string[], 74 | ); 75 | } 76 | }); 77 | } 78 | } else { 79 | // noinspection ExceptionCaughtLocallyJS 80 | throw 'Empty response'; 81 | } 82 | } catch (e) { 83 | error('WE', 'Failed to retrieve Wallpaper Engine properties from Webpack DevServer:', e); 84 | } 85 | } 86 | 87 | // update remote properties so they can be retrieved after HMR 88 | function updateRemoteProperty(propName: string, value: string) { 89 | postJSON('/props', { 90 | // no need to care about putting the property in userProps or generalProps, because they will finally be merged 91 | userProps: { [propName]: { value } }, 92 | }).catch(); 93 | } 94 | 95 | function updateRemoteFiles(propName: string, files: string[], allFiles: string[]) { 96 | postJSON('/props', { 97 | files: { [propName]: allFiles.map(file => file.slice('file://'.length)) }, 98 | }).catch(); 99 | } 100 | -------------------------------------------------------------------------------- /assets/locales/en-us.json5: -------------------------------------------------------------------------------- 1 | { 2 | // =================================================================================================================== 3 | // these logs should only be updated by the project maintainer, please do NOT put them in languages other than English and Chinese 4 | "changelog_important": "2.1.0\n\ 5 | Due to some changes in coordinate calculation, some Live2D models will offset to incorrect positions, you may need to reposition them. Sorry for the inconvenience.\ 6 | Any regular model of Live2D 2.1 is now supported to import by folder.\n\n\ 7 | For more information about using custom models, please visit the Workshop page.", 8 | "changelog_logs": [ 9 | "Added option to disable start-up greeting", 10 | "Fixed rendering error caused by certain Live2D models" 11 | ], 12 | // =================================================================================================================== 13 | 14 | // translations begin with "ui_" will be used in project.json 15 | "ui_info": "{NAME} v{VERSION}", 16 | "ui_img_dir": "Background Images", 17 | "ui_vid_dir": "Background Videos", 18 | "ui_switch": "Toggle Menu
Press this if you can't find the switch.", 19 | "ui_reset": "Reset Settings
Press this if you can't do reset in the wallpaper.", 20 | 21 | // displayed in language selector 22 | "language_name": "English", 23 | 24 | "v2_note": "Now comes version 2.0!", 25 | 26 | // dialog actions 27 | "confirm": "Confirm", 28 | "cancel": "Cancel", 29 | "save": "Save", 30 | "discard": "Discard", 31 | 32 | "reset_confirm": "Are you sure to reset all settings?", 33 | 34 | // theme names 35 | "Default": "Default", 36 | "Halloween": "Halloween", 37 | "Christmas": "Christmas", 38 | "New Year": "New Year", 39 | 40 | // tabs 41 | "general": "General", 42 | "character": "Character", 43 | "background": "Background", 44 | "effect": "Effect", 45 | "console": "Console", 46 | "about": "About", 47 | 48 | // section titles 49 | "theme": "Theme", 50 | "save_theme": "Save current theme as:", 51 | "unsaved_theme": "Unsaved Theme", 52 | "has_unsaved_theme": "Your custom theme is unsaved, do you want to save it?", 53 | "my_theme": "My Theme", 54 | "misc": "Miscellaneous", 55 | "leaves": "Leaves", 56 | "snow": "Snow", 57 | 58 | // general settings 59 | "enabled": "Enabled", 60 | "seasonal_theming": "Seasonal Theming", 61 | "volume": "Volume", 62 | "safe_area": "Safe Area", 63 | "show_fps": "Show FPS", 64 | "max_fps": "Max FPS", 65 | "_ui_language": "UI Language", 66 | 67 | // character settings 68 | "files_exceed_limit": "Amount of files exceeds the limit.", 69 | "dragging": "Dragging", 70 | "focus_on_press": "Focus on Press", 71 | "focus_timeout": "Focus Timeout", 72 | "start_up_greeting": "Start-up Greeting", 73 | "subtitle_enabled": "Subtitle Enabled", 74 | "subtitle_bottom": "Subtitle at Bottom", 75 | "details": "Details", 76 | "model_loading": "Loading...\n\nIf this keeps showing, you can check the logs in console for details.", 77 | "model_not_found": "Failed to load model from this location, please check the files.", 78 | "scale": "Scale", 79 | "language": "Language", 80 | 81 | // background settings 82 | "fill_type": "Fill Type", 83 | "fill": "Fill", 84 | "cover": "Cover", 85 | "built_in": "Built-in", 86 | "no_bg_dir": "No directory. Please specify {0} in Wallpaper Engine", 87 | 88 | // effect settings 89 | "overall": "Overall", 90 | "high_quality": "High Quality", 91 | "amount": "Amount", 92 | "drop_rate": "Drop Rate", 93 | 94 | // console 95 | "cmd": "Command", 96 | "copy_logs": "Copy Logs", 97 | "copy_storage": "Copy Storage", 98 | "reset": "Reset Settings", 99 | 100 | // about 101 | "subtitle": "The Ultimate Live2D Wallpaper", 102 | "important_changes": "Important Changes", 103 | "changelog": "Changelog v{VERSION}", 104 | "desc": "For tutorial and more information, please visit the Workshop page." 105 | } 106 | -------------------------------------------------------------------------------- /src/module/wallpaper/WEInterface.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from '@/core/utils/EventEmitter'; 2 | import union from 'lodash/union'; 3 | 4 | export interface App { 5 | on(event: 'we:*', fn: (name: string, value: string | number) => void, context?: any): this; 6 | 7 | on(event: 'we:{T}', fn: (value: string) => void, context?: any): this; 8 | 9 | on( 10 | event: 'weFilesUpdate:{T}', 11 | fn: (files: string[], allFiles: string[]) => void, 12 | context?: any, 13 | ): this; 14 | 15 | on( 16 | event: 'weFilesRemove:{T}', 17 | fn: (files: string[], allFiles: string[]) => void, 18 | context?: any, 19 | ): this; 20 | } 21 | 22 | export namespace WEInterface { 23 | export const PREFIX = 'we:'; 24 | export const PREFIX_FILES_UPDATE = 'weFilesUpdate:'; 25 | export const PREFIX_FILES_REMOVE = 'weFilesRemove:'; 26 | 27 | export const props: Partial = {}; 28 | 29 | export const propFiles: WEFiles = {}; 30 | 31 | let eventEmitter: EventEmitter; 32 | 33 | export function setEventEmitter(emitter: EventEmitter) { 34 | eventEmitter = emitter; 35 | 36 | // immediately emit all properties and files 37 | Object.keys(props).forEach(name => emitProp(name as keyof WEProperties, true)); 38 | 39 | (Object.entries(propFiles) as [keyof WEFiles, string[]][]).forEach(([propName, files]) => 40 | emitFilesUpdate(propName, files), 41 | ); 42 | } 43 | 44 | export function getPropValue(name: T): string | undefined { 45 | const prop = props[name]; 46 | 47 | if (prop !== undefined && prop !== null) { 48 | return typeof prop === 'string' ? prop : prop.value; 49 | } 50 | } 51 | 52 | function updateProps(_props: Partial) { 53 | const initial = Object.keys(_props).length > 1; 54 | 55 | Object.assign(props, _props); 56 | Object.keys(_props).forEach(name => emitProp(name as keyof WEProperties, initial)); 57 | } 58 | 59 | function updateFiles(propName: T, files: string[]) { 60 | if (propFiles[propName]) { 61 | propFiles[propName] = union(propFiles[propName], files); 62 | } else { 63 | propFiles[propName] = files; 64 | } 65 | 66 | emitFilesUpdate(propName, files); 67 | } 68 | 69 | function removeFiles(propName: T, files: string[]) { 70 | if (propFiles[propName]) { 71 | propFiles[propName] = propFiles[propName]!.filter(file => !files.includes(file)); 72 | } else { 73 | // make sure the array exists even when it's empty 74 | propFiles[propName] = []; 75 | } 76 | 77 | emitFilesRemove(propName, files); 78 | } 79 | 80 | function emitProp(name: T, initial?: boolean) { 81 | if (eventEmitter) { 82 | const value = getPropValue(name); 83 | 84 | if (value !== undefined) { 85 | eventEmitter.sticky(PREFIX + name, value, initial); 86 | eventEmitter.emit(PREFIX + '*', name, value, initial); 87 | } 88 | } 89 | } 90 | 91 | function emitFilesUpdate(propName: T, files: string[]) { 92 | if (eventEmitter) { 93 | eventEmitter.sticky(PREFIX_FILES_UPDATE + propName, files, propFiles[propName]); 94 | eventEmitter.emit(PREFIX_FILES_UPDATE + '*', propName, files, propFiles[propName]); 95 | } 96 | } 97 | 98 | function emitFilesRemove(propName: T, files: string[]) { 99 | if (eventEmitter) { 100 | eventEmitter.emit(PREFIX_FILES_REMOVE + propName, files, propFiles[propName]); 101 | eventEmitter.emit(PREFIX_FILES_REMOVE + '*', propName, files, propFiles[propName]); 102 | } 103 | } 104 | 105 | window.wallpaperPropertyListener = { 106 | applyUserProperties: updateProps, 107 | applyGeneralProperties: updateProps, 108 | 109 | userDirectoryFilesAddedOrChanged(propName, files) { 110 | // add prefixes to raw file paths 111 | updateFiles(propName, files.map(file => 'file://' + unescape(file))); 112 | }, 113 | userDirectoryFilesRemoved(propName, files) { 114 | removeFiles(propName, files.map(file => 'file://' + unescape(file))); 115 | }, 116 | 117 | setPaused(paused) { 118 | eventEmitter && eventEmitter.sticky(paused ? 'pause' : 'resume'); 119 | }, 120 | }; 121 | } 122 | -------------------------------------------------------------------------------- /src/module/live2d/Live2DPlayer.ts: -------------------------------------------------------------------------------- 1 | import Mka from '@/core/mka/Mka'; 2 | import Player from '@/core/mka/Player'; 3 | import { Z_INDEX_LIVE2D } from '@/defaults'; 4 | import Live2DSprite from '@/module/live2d/Live2DSprite'; 5 | import MouseHandler from '@/module/live2d/MouseHandler'; 6 | import { Container } from '@pixi/display'; 7 | 8 | const MOUSE_HANDLING_ELEMENT = document.documentElement; 9 | 10 | interface DraggableLive2DSprite extends Live2DSprite { 11 | dragging?: boolean; 12 | } 13 | 14 | export default class Live2DPlayer extends Player { 15 | readonly container = new Container(); 16 | readonly sprites: Live2DSprite[] = []; 17 | 18 | mouseHandler!: MouseHandler; 19 | 20 | constructor(mka: Mka) { 21 | super(); 22 | 23 | this.setupMouseHandler(); 24 | 25 | this.container.zIndex = Z_INDEX_LIVE2D; 26 | this.container.sortableChildren = true; 27 | mka.pixiApp.stage.addChild(this.container); 28 | } 29 | 30 | setupMouseHandler() { 31 | const self = this; 32 | 33 | this.mouseHandler = new (class extends MouseHandler { 34 | focus(x: number, y: number) { 35 | let oneHighlighted = false; 36 | 37 | // start focusing and hover testing by the z order, from top to bottom 38 | for (let i = self.container.children.length - 1; i >= 0; i--) { 39 | // need to ensure the container only contains Live2DSprites 40 | const sprite = self.container.children[i] as Live2DSprite; 41 | 42 | sprite.focus(x, y); 43 | 44 | if (this.draggable && !oneHighlighted && sprite.getBounds(true).contains(x, y)) { 45 | oneHighlighted = true; 46 | sprite.highlight(true); 47 | } else { 48 | sprite.highlight(false); 49 | } 50 | } 51 | } 52 | 53 | clearFocus() { 54 | super.clearFocus(); 55 | 56 | self.sprites.forEach(sprite => sprite.model.focusController.focus(0, 0)); 57 | } 58 | 59 | click(x: number, y: number) { 60 | // TODO: Don't do this here! 61 | if (self.mka) self.mka.pixiApp.stage.emit('hit', x, y); 62 | 63 | self.sprites.forEach(sprite => { 64 | if (sprite.getBounds(true).contains(x, y)) { 65 | sprite.hit(x, y); 66 | } 67 | }); 68 | } 69 | 70 | dragStart(x: number, y: number) { 71 | // start hit testing by the z order, from top to bottom 72 | for (let i = self.container.children.length - 1; i >= 0; i--) { 73 | // need to ensure the container only contains Live2DSprites 74 | const sprite = self.container.children[i] as DraggableLive2DSprite; 75 | 76 | if (sprite.getBounds(true).contains(x, y)) { 77 | sprite.dragging = true; 78 | 79 | // break it so only one sprite will be dragged 80 | break; 81 | } 82 | } 83 | } 84 | 85 | dragMove(x: number, y: number, dx: number, dy: number) { 86 | for (const sprite of self.container.children as DraggableLive2DSprite[]) { 87 | if (sprite.dragging) { 88 | sprite.x += dx; 89 | sprite.y += dy; 90 | } 91 | sprite.highlight(!!sprite.dragging); 92 | } 93 | } 94 | 95 | dragEnd() { 96 | for (const sprite of self.container.children as DraggableLive2DSprite[]) { 97 | if (sprite.dragging) { 98 | sprite.dragging = false; 99 | self.dragEnded(sprite); 100 | } 101 | } 102 | } 103 | })(MOUSE_HANDLING_ELEMENT); 104 | } 105 | 106 | async addSprite(file: string | string[]) { 107 | const sprite = await Live2DSprite.create(file); 108 | this.sprites.push(sprite); 109 | this.container.addChild(sprite); 110 | 111 | return sprite; 112 | } 113 | 114 | removeSprite(sprite: Live2DSprite) { 115 | this.container.removeChild(sprite); 116 | this.sprites.splice(this.sprites.indexOf(sprite), 1); 117 | sprite.destroy(); 118 | } 119 | 120 | /** @override */ 121 | update() { 122 | return this.container.children.length !== 0; 123 | } 124 | 125 | destroy() { 126 | this.mouseHandler.destroy(); 127 | super.destroy(); 128 | } 129 | 130 | // to be overridden 131 | dragEnded(sprite: Live2DSprite) {} 132 | } 133 | -------------------------------------------------------------------------------- /src/module/snow/SnowPlayer.ts: -------------------------------------------------------------------------------- 1 | import snowflake from '@/assets/img/snowflake.png'; 2 | import Player from '@/core/mka/Player'; 3 | import Ticker from '@/core/mka/Ticker'; 4 | import { Z_INDEX_SNOW, Z_INDEX_SNOW_BACK } from '@/defaults'; 5 | import Snow, { DEFAULT_OPTIONS } from '@/module/snow/pixi-snow/Snow'; 6 | 7 | const TAG = 'SnowPlayer'; 8 | 9 | const MAX_SIZE = 1; 10 | const MIDDLE_SIZE = 0.4; 11 | const MIN_SIZE = 0.1; 12 | 13 | export default class SnowPlayer extends Player { 14 | private snow?: Snow; 15 | private backSnow?: Snow; 16 | 17 | private _number = DEFAULT_OPTIONS.number; 18 | private _layering = false; 19 | 20 | get number() { 21 | return this._number; 22 | } 23 | 24 | set number(value: number) { 25 | this._number = value; 26 | this.setup(); 27 | } 28 | 29 | get layering() { 30 | return this._layering; 31 | } 32 | 33 | set layering(value: boolean) { 34 | this._layering = value; 35 | this.setup(); 36 | } 37 | 38 | private setup() { 39 | if (!this.enabled) return; 40 | 41 | if (!this.snow) { 42 | this.snow = this.createSnow(MIN_SIZE, MAX_SIZE, this._number); 43 | this.snow.zIndex = Z_INDEX_SNOW; 44 | } 45 | 46 | if (this._layering) { 47 | this.snow.number = ~~(this._number / 2); 48 | 49 | if (!this.backSnow) { 50 | this.backSnow = this.createSnow(MIN_SIZE, MIDDLE_SIZE, this.snow.number); 51 | this.backSnow.zIndex = Z_INDEX_SNOW_BACK; 52 | 53 | // reset the size 54 | this.snow.options.minSize = MIDDLE_SIZE; 55 | this.snow.options.maxSize = MAX_SIZE; 56 | this.snow.setup(); 57 | } 58 | 59 | this.backSnow.number = this.snow.number; 60 | } else { 61 | this.snow.number = this._number; 62 | 63 | if (this.backSnow) { 64 | this.destroySnow(this.backSnow); 65 | this.backSnow = undefined; 66 | 67 | // reset the size 68 | this.snow.options.minSize = MIN_SIZE; 69 | this.snow.options.maxSize = MAX_SIZE; 70 | this.snow.setup(); 71 | } 72 | } 73 | 74 | if (this.mka) { 75 | const pixiApp = this.mka.pixiApp; 76 | 77 | const width = pixiApp.renderer.width; 78 | const height = pixiApp.renderer.height; 79 | 80 | if (width !== this.snow.width || height !== this.snow.height) { 81 | this.snow.resize(width, height); 82 | } 83 | 84 | if (!pixiApp.stage.children.includes(this.snow)) { 85 | pixiApp.stage.addChild(this.snow); 86 | } 87 | 88 | if (this.backSnow) { 89 | if (width !== this.backSnow.width || height !== this.backSnow.height) { 90 | this.backSnow.resize(width, height); 91 | } 92 | 93 | if (!pixiApp.stage.children.includes(this.backSnow)) { 94 | pixiApp.stage.addChild(this.backSnow); 95 | } 96 | } 97 | } 98 | } 99 | 100 | private createSnow(minSize: number, maxSize: number, number: number): Snow { 101 | let width = 100; 102 | let height = 100; 103 | 104 | if (this.mka) { 105 | const renderer = this.mka.pixiApp.renderer; 106 | width = renderer.width; 107 | height = renderer.height; 108 | } 109 | 110 | return new Snow(snowflake, { width, height, minSize, maxSize, number }); 111 | } 112 | 113 | private destroySnow(snow: Snow) { 114 | if (this.mka && this.mka.pixiApp.stage.children.includes(snow)) { 115 | this.mka.pixiApp.stage.removeChild(snow); 116 | } 117 | 118 | snow.destroy(); 119 | } 120 | 121 | attach() { 122 | this.setup(); 123 | } 124 | 125 | detach() { 126 | this.destroy(); 127 | } 128 | 129 | enable() { 130 | this.setup(); 131 | } 132 | 133 | disable() { 134 | this.destroy(); 135 | } 136 | 137 | update(): boolean { 138 | let updated = false; 139 | 140 | if (this.snow) { 141 | this.snow.update(Ticker.delta, Ticker.now); 142 | 143 | updated = true; 144 | } 145 | 146 | if (this.backSnow) { 147 | this.backSnow.update(Ticker.delta, Ticker.now); 148 | 149 | updated = true; 150 | } 151 | 152 | if (updated) { 153 | Snow.wind.update(Ticker.delta); 154 | } 155 | 156 | return updated; 157 | } 158 | 159 | destroy() { 160 | if (this.snow) { 161 | this.destroySnow(this.snow); 162 | this.snow = undefined; 163 | } 164 | 165 | if (this.backSnow) { 166 | this.destroySnow(this.backSnow); 167 | this.backSnow = undefined; 168 | } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/core/mka/Mka.ts: -------------------------------------------------------------------------------- 1 | import Player, { InternalPlayer } from '@/core/mka/Player'; 2 | import Ticker from '@/core/mka/Ticker'; 3 | import { error, log } from '@/core/utils/log'; 4 | import { Application as PIXIApplication } from '@pixi/app'; 5 | import { BatchRenderer, Renderer } from '@pixi/core'; 6 | import autobind from 'autobind-decorator'; 7 | 8 | Renderer.registerPlugin('batch', BatchRenderer as any); 9 | 10 | const TAG = 'Mka'; 11 | 12 | export default class Mka { 13 | private _paused = false; 14 | 15 | get paused() { 16 | return this._paused; 17 | } 18 | 19 | readonly pixiApp: PIXIApplication; 20 | 21 | get gl() { 22 | // @ts-ignore 23 | return this.pixiApp.renderer.gl; 24 | } 25 | 26 | private readonly players: { [name: string]: InternalPlayer } = {}; 27 | 28 | /** 29 | * ID returned by `requestAnimationFrame()` 30 | */ 31 | private rafId = 0; 32 | 33 | constructor(canvas: HTMLCanvasElement) { 34 | this.pixiApp = new PIXIApplication({ 35 | view: canvas, 36 | resizeTo: canvas, 37 | transparent: true, 38 | }); 39 | 40 | this.pixiApp.stage.sortableChildren = true; 41 | 42 | this.rafId = requestAnimationFrame(this.tick); 43 | } 44 | 45 | addPlayer(name: string, player: Player, enabled = true) { 46 | if (this.players[name]) { 47 | log(TAG, `Player "${name}" already exists, ignored.`); 48 | return; 49 | } 50 | 51 | log(TAG, `Add player "${name}"`); 52 | this.players[name] = player; 53 | this.players[name].mka = this; 54 | player.attach(); 55 | 56 | if (enabled) { 57 | this.enablePlayer(name); 58 | } 59 | } 60 | 61 | getPlayer(name: string) { 62 | return this.players[name] as Player; 63 | } 64 | 65 | enablePlayer(name: string) { 66 | const player = this.players[name]; 67 | 68 | if (player && !player.enabled) { 69 | player.enabled = true; 70 | player.enable(); 71 | } 72 | } 73 | 74 | disablePlayer(name: string) { 75 | const player = this.players[name]; 76 | 77 | if (player && player.enabled) { 78 | player.enabled = false; 79 | player.disable(); 80 | } 81 | } 82 | 83 | @autobind 84 | private tick(now: DOMHighResTimeStamp) { 85 | if (!this._paused) { 86 | if (Ticker.tick(now)) { 87 | this.forEachPlayer(player => { 88 | if (player.enabled && !player.paused) { 89 | player.update(); 90 | } 91 | }); 92 | 93 | this.pixiApp.render(); 94 | } 95 | this.rafId = requestAnimationFrame(this.tick); 96 | } 97 | } 98 | 99 | pause() { 100 | this._paused = true; 101 | cancelAnimationFrame(this.rafId); 102 | 103 | Ticker.pause(); 104 | 105 | this.forEachPlayer(player => { 106 | if (player.enabled) { 107 | player.paused = true; 108 | player.pause(); 109 | } 110 | }); 111 | } 112 | 113 | resume() { 114 | this._paused = false; 115 | 116 | // postpone the resuming to prevent discrepancy of timestamp (https://stackoverflow.com/a/38360320) 117 | // note that we must wrap it with two rAF here, because in WE, the first rAF will possibly be invoked with 118 | // timestamp of the animation frame that is right after the pausing frame, and thus the Ticker.timeSincePause 119 | // will be broken. 120 | this.rafId = requestAnimationFrame(() => { 121 | this.rafId = requestAnimationFrame(now => { 122 | Ticker.resume(now); 123 | 124 | this.forEachPlayer(player => { 125 | if (player.enabled) { 126 | player.paused = false; 127 | player.resume(); 128 | } 129 | }); 130 | 131 | this.tick(now); 132 | }); 133 | }); 134 | } 135 | 136 | forEachPlayer(fn: (player: InternalPlayer, name: string) => void) { 137 | for (const [name, player] of Object.entries(this.players)) { 138 | try { 139 | fn(player, name); 140 | } catch (e) { 141 | error(TAG, `(${name})`, e); 142 | } 143 | } 144 | } 145 | 146 | destroy() { 147 | if (this.rafId) { 148 | cancelAnimationFrame(this.rafId); 149 | } 150 | 151 | Object.entries(this.players).forEach(([name, player]) => { 152 | log(TAG, `Destroying player "${name}"...`); 153 | 154 | // don't break the loop when error occurs 155 | try { 156 | player.destroy(); 157 | } catch (e) { 158 | error(TAG, e.message, e.stack); 159 | } 160 | }); 161 | 162 | this.pixiApp.destroy(); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/core/live2d/MotionManager.ts: -------------------------------------------------------------------------------- 1 | import ExpressionManager from '@/core/live2d/ExpressionManager'; 2 | import { ExpressionDefinition, MotionDefinition } from '@/core/live2d/ModelSettings'; 3 | import { error, log } from '@/core/utils/log'; 4 | import { getArrayBuffer } from '@/core/utils/net'; 5 | 6 | export enum Priority { 7 | None = 0, 8 | Idle = 1, 9 | Normal = 2, 10 | Force = 3, 11 | } 12 | 13 | enum Group { 14 | Idle = 'idle', 15 | } 16 | 17 | const DEFAULT_FADE_TIMEOUT = 500; 18 | 19 | export default class MotionManager extends MotionQueueManager { 20 | tag: string; 21 | 22 | readonly internalModel: Live2DModelWebGL; 23 | 24 | definitions: { [group: string]: MotionDefinition[] }; 25 | motionGroups: { [group: string]: Live2DMotion[] } = {}; 26 | 27 | expressionManager?: ExpressionManager; 28 | 29 | currentPriority = Priority.None; 30 | reservePriority = Priority.None; 31 | 32 | constructor( 33 | name: string, 34 | model: Live2DModelWebGL, 35 | motionDefinitions: { [group: string]: MotionDefinition[] }, 36 | expressionDefinitions?: ExpressionDefinition[], 37 | ) { 38 | super(); 39 | 40 | this.tag = `MotionManager\n(${name})`; 41 | this.internalModel = model; 42 | this.definitions = motionDefinitions; 43 | 44 | if (expressionDefinitions) { 45 | this.expressionManager = new ExpressionManager(name, model!, expressionDefinitions); 46 | } 47 | 48 | this.loadMotions().then(); 49 | 50 | this.stopAllMotions(); 51 | } 52 | 53 | private async loadMotions() { 54 | // initialize all motion groups with empty arrays 55 | Object.keys(this.definitions).forEach(group => (this.motionGroups[group] = [])); 56 | 57 | // preload idle motions 58 | if (this.definitions[Group.Idle]) { 59 | for (let i = this.definitions[Group.Idle].length - 1; i >= 0; i--) { 60 | this.loadMotion(Group.Idle, i).then(); 61 | } 62 | } 63 | } 64 | 65 | private async loadMotion(group: string, index: number) { 66 | const definition = this.definitions[group] && this.definitions[group][index]; 67 | 68 | if (!definition) { 69 | error(this.tag, `Motion not found at ${index} in group "${group}"`); 70 | return; 71 | } 72 | 73 | log(this.tag, `Loading motion [${definition.name}]`); 74 | 75 | try { 76 | const buffer = await getArrayBuffer(definition.file); 77 | const motion = Live2DMotion.loadMotion(buffer); 78 | motion.setFadeIn(definition.fadeIn! > 0 ? definition.fadeIn! : DEFAULT_FADE_TIMEOUT); 79 | motion.setFadeOut(definition.fadeOut! > 0 ? definition.fadeOut! : DEFAULT_FADE_TIMEOUT); 80 | 81 | this.motionGroups[group][index] = motion; 82 | return motion; 83 | } catch (e) { 84 | error(this.tag, `Failed to load motion [${definition.name}]: ${definition.file}`, e); 85 | } 86 | } 87 | 88 | async startMotionByPriority(group: string, index: number, priority: Priority = Priority.Normal): Promise { 89 | if (priority !== Priority.Force && (priority <= this.currentPriority || priority <= this.reservePriority)) { 90 | log(this.tag, 'Cannot start motion because another motion of same or higher priority is running'); 91 | return false; 92 | } 93 | 94 | this.reservePriority = priority; 95 | 96 | const motion = 97 | (this.motionGroups[group] && this.motionGroups[group][index]) || (await this.loadMotion(group, index)); 98 | if (!motion) return false; 99 | 100 | if (priority === this.reservePriority) { 101 | this.reservePriority = Priority.None; 102 | } 103 | 104 | this.currentPriority = priority; 105 | 106 | log(this.tag, 'Start motion:', this.definitions[group][index].file); 107 | 108 | if (priority > Priority.Idle) { 109 | this.expressionManager && this.expressionManager.resetExpression(); 110 | } 111 | 112 | this.startMotion(motion); 113 | 114 | return true; 115 | } 116 | 117 | startRandomMotion(group: string, priority: Priority = Priority.Normal) { 118 | const groupDefinitions = this.definitions[group]; 119 | 120 | if (groupDefinitions && groupDefinitions.length > 0) { 121 | const index = Math.floor(Math.random() * groupDefinitions.length); 122 | this.startMotionByPriority(group, index, priority).then(); 123 | } 124 | } 125 | 126 | update() { 127 | if (this.isFinished()) { 128 | if (this.currentPriority > Priority.Idle) { 129 | this.expressionManager && this.expressionManager.restoreExpression(); 130 | } 131 | this.currentPriority = Priority.None; 132 | this.startRandomMotion(Group.Idle, Priority.Idle); 133 | } 134 | 135 | const updated = this.updateParam(this.internalModel); 136 | 137 | this.expressionManager && this.expressionManager.update(); 138 | 139 | return updated; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/core/live2d/Live2DPose.ts: -------------------------------------------------------------------------------- 1 | import clamp from 'lodash/clamp'; 2 | 3 | interface PartsDefinition { 4 | group: { 5 | id: string; 6 | link?: string[]; 7 | }[]; 8 | } 9 | 10 | class Live2DPartsParam { 11 | readonly id: string; 12 | 13 | paramIndex = -1; 14 | partsIndex = -1; 15 | link: Live2DPartsParam[] = []; 16 | 17 | constructor(id: string) { 18 | this.id = id; 19 | } 20 | 21 | initIndex(internalModel: Live2DModelWebGL) { 22 | this.paramIndex = internalModel.getParamIndex('VISIBLE:' + this.id); 23 | this.partsIndex = internalModel.getPartsDataIndex(PartsDataID.getID(this.id)); 24 | internalModel.setParamFloat(this.paramIndex, 1); 25 | } 26 | } 27 | 28 | export default class Live2DPose { 29 | readonly internalModel: Live2DModelWebGL; 30 | 31 | opacityAnimDuration: DOMHighResTimeStamp = 500; 32 | 33 | partsGroups: Live2DPartsParam[][] = []; 34 | 35 | constructor(internalModel: Live2DModelWebGL, json: any) { 36 | this.internalModel = internalModel; 37 | 38 | if (json['parts_visible']) { 39 | this.partsGroups = (json['parts_visible'] as PartsDefinition[]).map(({ group }) => 40 | group.map(({ id, link }) => { 41 | const parts = new Live2DPartsParam(id); 42 | 43 | if (link) { 44 | parts.link = link.map(l => new Live2DPartsParam(l)); 45 | } 46 | 47 | return parts; 48 | }), 49 | ); 50 | 51 | this.init(); 52 | } 53 | } 54 | 55 | init() { 56 | this.partsGroups.forEach(group => { 57 | group.forEach(parts => { 58 | parts.initIndex(this.internalModel); 59 | 60 | if (parts.paramIndex >= 0) { 61 | const visible = this.internalModel.getParamFloat(parts.paramIndex) !== 0; 62 | this.internalModel.setPartsOpacity(parts.partsIndex, visible ? 1 : 0); 63 | this.internalModel.setParamFloat(parts.paramIndex, visible ? 1 : 0); 64 | 65 | if (parts.link.length > 0) { 66 | parts.link.forEach(p => p.initIndex(this.internalModel)); 67 | } 68 | } 69 | }); 70 | }); 71 | } 72 | 73 | normalizePartsOpacityGroup(partsGroup: Live2DPartsParam[], dt: DOMHighResTimeStamp) { 74 | const internalModel = this.internalModel; 75 | const phi = 0.5; 76 | const maxBackOpacity = 0.15; 77 | let visibleOpacity = 1; 78 | 79 | let visibleIndex = partsGroup.findIndex( 80 | ({ paramIndex, partsIndex }) => partsIndex >= 0 && internalModel.getParamFloat(paramIndex) !== 0, 81 | ); 82 | 83 | if (visibleIndex >= 0) { 84 | const parts = partsGroup[visibleIndex]; 85 | const originalOpacity = internalModel.getPartsOpacity(parts.partsIndex); 86 | 87 | visibleOpacity = clamp(originalOpacity + dt / this.opacityAnimDuration, 0, 1); 88 | } else { 89 | visibleIndex = 0; 90 | visibleOpacity = 1; 91 | } 92 | 93 | partsGroup.forEach(({ partsIndex }, index) => { 94 | if (partsIndex >= 0) { 95 | if (visibleIndex == index) { 96 | internalModel.setPartsOpacity(partsIndex, visibleOpacity); 97 | } else { 98 | let opacity = internalModel.getPartsOpacity(partsIndex); 99 | 100 | // I can't understand this part, so just leave it original 101 | let a1; 102 | if (visibleOpacity < phi) { 103 | a1 = (visibleOpacity * (phi - 1)) / phi + 1; 104 | } else { 105 | a1 = ((1 - visibleOpacity) * phi) / (1 - phi); 106 | } 107 | let backOp = (1 - a1) * (1 - visibleOpacity); 108 | if (backOp > maxBackOpacity) { 109 | a1 = 1 - maxBackOpacity / (1 - visibleOpacity); 110 | } 111 | if (opacity > a1) { 112 | opacity = a1; 113 | } 114 | 115 | internalModel.setPartsOpacity(partsIndex, opacity); 116 | } 117 | } 118 | }); 119 | } 120 | 121 | copyOpacity(partsGroup: Live2DPartsParam[]) { 122 | const internalModel = this.internalModel; 123 | 124 | partsGroup.forEach(({ partsIndex, link }) => { 125 | if (partsIndex >= 0 && link) { 126 | const opacity = internalModel.getPartsOpacity(partsIndex); 127 | 128 | link.forEach(({ partsIndex }) => { 129 | if (partsIndex >= 0) { 130 | internalModel.setPartsOpacity(partsIndex, opacity); 131 | } 132 | }); 133 | } 134 | }); 135 | } 136 | 137 | update(dt: DOMHighResTimeStamp) { 138 | this.partsGroups.forEach(partGroup => { 139 | this.normalizePartsOpacityGroup(partGroup, dt); 140 | this.copyOpacity(partGroup); 141 | }); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/module/config/settings/AboutSettings.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 77 | 78 | 176 | -------------------------------------------------------------------------------- /src/module/config/SettingsPanel.ts: -------------------------------------------------------------------------------- 1 | import CloseSVG from '@/assets/img/close.svg'; 2 | import RefreshSVG from '@/assets/img/refresh.svg'; 3 | import { nop } from '@/core/utils/misc'; 4 | import { I18N, LOCALE } from '@/defaults'; 5 | import ConfigModule from '@/module/config/ConfigModule'; 6 | import FloatingPanelMixin from '@/module/config/FloatingPanelMixin'; 7 | import Scrollable from '@/module/config/reusable/Scrollable.vue'; 8 | import AboutSettings from '@/module/config/settings/AboutSettings.vue'; 9 | import BackgroundSettings from '@/module/config/settings/BackgroundSettings.vue'; 10 | import CharacterSettings from '@/module/config/settings/CharacterSettings.vue'; 11 | import ConsoleSettings from '@/module/config/settings/ConsoleSettings.vue'; 12 | import EffectsSettings from '@/module/config/settings/EffectsSettings.vue'; 13 | import GeneralSettings from '@/module/config/settings/GeneralSettings.vue'; 14 | import versionLessThan from 'semver/functions/lt'; 15 | import { Component, Mixins, Prop, Ref } from 'vue-property-decorator'; 16 | import { Vue } from 'vue/types/vue'; 17 | 18 | @Component({ 19 | components: { CloseSVG, RefreshSVG, Scrollable }, 20 | }) 21 | export default class SettingsPanel extends Mixins(FloatingPanelMixin) { 22 | @Prop() readonly module!: () => ConfigModule; 23 | 24 | @Ref('settings') readonly panel!: HTMLDivElement; 25 | @Ref('content') readonly content!: HTMLDivElement; 26 | @Ref('tabs') readonly handle!: HTMLDivElement; 27 | @Ref('resizer') readonly resizer!: HTMLDivElement; 28 | @Ref('page') readonly pageComponent!: Vue; 29 | 30 | get configModule() { 31 | return this.module(); 32 | } 33 | 34 | readonly pages = [ 35 | GeneralSettings, 36 | CharacterSettings, 37 | BackgroundSettings, 38 | EffectsSettings, 39 | ConsoleSettings, 40 | AboutSettings, 41 | ]; 42 | 43 | selectedPage = 0; 44 | 45 | title? = ''; 46 | 47 | get currentPage() { 48 | return this.pages[this.selectedPage]; 49 | } 50 | 51 | dialog = { 52 | visible: false, 53 | message: '', 54 | confirm: '', 55 | cancel: '', 56 | onFinish: nop as (confirmed: boolean, canceled: boolean) => boolean | undefined, 57 | }; 58 | 59 | created() { 60 | this.switchTop = this.configModule.getConfig('settings.switchTop', this.switchTop); 61 | this.switchLeft = this.configModule.getConfig('settings.switchLeft', this.switchLeft); 62 | 63 | this.panelTop = this.configModule.getConfig('settings.panelTop', this.panelTop); 64 | this.panelLeft = this.configModule.getConfig('settings.panelLeft', this.panelLeft); 65 | this.panelWidth = this.configModule.getConfig('settings.panelWidth', this.panelWidth); 66 | this.panelHeight = this.configModule.getConfig('settings.panelHeight', this.panelHeight); 67 | } 68 | 69 | mounted() { 70 | this.configModule.app 71 | .once('init', (prevVersion?: string) => { 72 | if (!prevVersion) { 73 | // show tip at first launch 74 | this.title = process.env.NAME; 75 | } else { 76 | // test the specific version of important changes 77 | const changes = I18N[LOCALE]['changelog_important'] as string | undefined; 78 | const version = changes && changes.substring(0, changes.indexOf('\n')); 79 | 80 | if (version && versionLessThan(prevVersion, version)) { 81 | this.selectPage(this.pages.indexOf(AboutSettings)); 82 | this.open(); 83 | } 84 | } 85 | }) 86 | .on('we:switch', this.toggle, this); 87 | } 88 | 89 | toggle(_: boolean, initial?: boolean) { 90 | if (!initial) { 91 | this.expanded ? this.close() : this.open(); 92 | } 93 | } 94 | 95 | selectPage(index: number) { 96 | this.selectedPage = index; 97 | } 98 | 99 | refresh() { 100 | this.configModule.app.emit('reload'); 101 | } 102 | 103 | showDialog( 104 | message: string, 105 | confirm?: string, 106 | cancel?: string, 107 | onFinish?: (confirmed: boolean, canceled: boolean) => boolean | undefined, 108 | ) { 109 | this.dialog.visible = true; 110 | this.dialog.message = message; 111 | this.dialog.confirm = confirm || (this.$t('confirm') as string); 112 | this.dialog.cancel = cancel || (this.$t('cancel') as string); 113 | this.dialog.onFinish = onFinish || nop; 114 | } 115 | 116 | switchMoveEnded() { 117 | this.configModule.setConfig('settings.switchTop', this.switchTop); 118 | this.configModule.setConfig('settings.switchLeft', this.switchLeft); 119 | } 120 | 121 | panelMoveEnded() { 122 | this.configModule.setConfig('settings.panelTop', this.panelTop); 123 | this.configModule.setConfig('settings.panelLeft', this.panelLeft); 124 | } 125 | 126 | panelResizeEnded() { 127 | this.configModule.setConfig('settings.panelWidth', this.panelWidth); 128 | this.configModule.setConfig('settings.panelHeight', this.panelHeight); 129 | } 130 | 131 | beforeDestroy() { 132 | this.configModule.app.off('we:switch', this.toggle); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/module/config/ConfigModule.ts: -------------------------------------------------------------------------------- 1 | import { App, Module } from '@/App'; 2 | import { error } from '@/core/utils/log'; 3 | import Overlay from '@/module/config/Overlay.vue'; 4 | import SettingsPanel from '@/module/config/SettingsPanel.vue'; 5 | import get from 'lodash/get'; 6 | import merge from 'lodash/merge'; 7 | import set from 'lodash/set'; 8 | 9 | const UNSET = Symbol(); 10 | 11 | export class Config { 12 | [key: string]: any; 13 | 14 | // runtime object won't be saved into localStorage 15 | runtime: { [key: string]: any } = {}; 16 | 17 | get(path: string, defaultValue: T, storageOnly?: boolean): Readonly { 18 | let savedValue = get(this, path, UNSET); 19 | let runtimeValue = storageOnly ? UNSET : get(this.runtime, path, UNSET); 20 | 21 | if (savedValue === UNSET) return runtimeValue === UNSET ? defaultValue : runtimeValue; 22 | if (runtimeValue === UNSET) return savedValue; 23 | 24 | // saved value has a higher priority than runtime value 25 | return typeof savedValue === 'object' ? merge(runtimeValue, savedValue) : savedValue; 26 | } 27 | } 28 | 29 | export interface App { 30 | on(event: 'init', fn: () => void, context?: any): this; 31 | 32 | on(event: 'configReady', fn: (config: Config) => void, context?: any): this; 33 | 34 | // Will be emitted on each update of config 35 | on(event: 'config:*', fn: (path: string, value: any, oldValue: any, config: Config) => void, context?: any): this; 36 | 37 | on(event: 'config:{path}', fn: (value: any, oldValue: any, config: Config) => void, context?: any): this; 38 | 39 | on(event: 'config', fn: (path: string, value: any, runtime?: boolean) => void, context?: any): this; 40 | 41 | emit(event: 'init', prevVersion: string | undefined, config: Config): this; 42 | 43 | emit(event: 'configReady', config: Config): this; 44 | 45 | emit(event: 'config:*', path: string, value: any, oldValue: any, runtime: boolean | undefined, config: Config): this; 46 | 47 | emit(event: 'config:{path}', value: any, oldValue: any, config: Config): this; 48 | 49 | emit(event: 'config', path: string, value: any, runtime?: boolean): this; 50 | } 51 | 52 | const TAG = 'ConfigModule'; 53 | 54 | export default class ConfigModule implements Module { 55 | name = 'Config'; 56 | 57 | storageKey = 'config'; 58 | 59 | readonly config = new Config(); 60 | 61 | constructor(readonly app: App) { 62 | this.read(); 63 | 64 | app.on('config', this.setConfig, this).on('reset', this.reset, this); 65 | 66 | app.sticky('configReady', this.config); 67 | 68 | app.addComponent(SettingsPanel, { module: () => this }).then(); 69 | app.addComponent(Overlay, { module: () => this }).then(); 70 | 71 | const prevVersion = localStorage.v; 72 | 73 | if (prevVersion !== process.env.VERSION) { 74 | if (!prevVersion) { 75 | // inherit volume from old 1.x versions 76 | app.once('we:volume', (value: number) => app.emit('config', 'volume', value / 10)); 77 | } else { 78 | this.backup(prevVersion); 79 | } 80 | 81 | app.sticky('init', prevVersion, this.config); 82 | localStorage.v = process.env.VERSION; 83 | } 84 | } 85 | 86 | setConfig(path: string, value: any, runtime?: boolean) { 87 | const target = runtime ? this.config.runtime : this.config; 88 | 89 | const oldValue = this.config.get(path, undefined); 90 | 91 | set(target, path, value); 92 | 93 | if (!runtime) this.save(); 94 | 95 | const newValue = this.config.get(path, undefined); 96 | 97 | this.app.sticky('config:' + path, newValue, oldValue, runtime, this.config); 98 | this.app.emit('config:*', path, newValue, oldValue, runtime, this.config); 99 | } 100 | 101 | getConfig(path: string, defaultValue: T): Readonly { 102 | return this.config.get(path, defaultValue); 103 | } 104 | 105 | /** 106 | * A handy method for batching queries. 107 | * @param receiver 108 | * @param pairs - Pairs of path and default value. 109 | * @example 110 | * getConfigs(receiver, 'obj.number', 0, 'obj.bool', false) 111 | */ 112 | getConfigs(receiver: (path: string, value: any) => void, pairs: string | any[]) { 113 | for (let i = 0; i < pairs.length; i += 2) { 114 | receiver(pairs[i], this.getConfig(pairs[i] as string, pairs[i + 1])); 115 | } 116 | } 117 | 118 | read() { 119 | try { 120 | const json = localStorage.getItem(this.storageKey); 121 | 122 | if (json) { 123 | Object.assign(this.config, JSON.parse(json)); 124 | } 125 | } catch (e) { 126 | error(TAG, e); 127 | } 128 | } 129 | 130 | save(): boolean { 131 | try { 132 | const saving = Object.assign({}, this.config); 133 | delete saving.runtime; 134 | localStorage.setItem(this.storageKey, JSON.stringify(saving)); 135 | return true; 136 | } catch (e) { 137 | error(TAG, e); 138 | } 139 | return false; 140 | } 141 | 142 | backup(version: string) { 143 | localStorage.setItem(this.storageKey + '-' + version, localStorage.getItem(this.storageKey) || ''); 144 | } 145 | 146 | reset() { 147 | this.backup(localStorage.v); 148 | 149 | localStorage.removeItem('v'); 150 | localStorage.removeItem(this.storageKey); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/module/config/reusable/Select.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 115 | 116 | 205 | -------------------------------------------------------------------------------- /src/module/snow/pixi-snow/Snow.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Based on https://codepen.io/bsehovac/pen/GPwXxq 3 | */ 4 | 5 | import { DRAW_MODES } from '@pixi/constants'; 6 | import { Buffer, Geometry, Shader, State, Texture } from '@pixi/core'; 7 | import { Mesh } from '@pixi/mesh'; 8 | import frag from 'raw-loader!./snow.frag'; 9 | import vert from 'raw-loader!./snow.vert'; 10 | 11 | const TAG = 'Snow'; 12 | 13 | export const DEFAULT_OPTIONS = { 14 | number: 100, 15 | width: 100, 16 | height: 100, 17 | minSize: 75, 18 | maxSize: 150, 19 | }; 20 | 21 | export class Wind { 22 | current = 0; 23 | force = 0.1; 24 | target = 0.1; 25 | min = 0.02; 26 | max = 0.2; 27 | easing = 0.003; 28 | 29 | update(dt: DOMHighResTimeStamp) { 30 | this.force += (this.target - this.force) * this.easing; 31 | this.current += this.force * (dt * 0.2); 32 | 33 | if (Math.random() > 0.995) { 34 | this.target = (this.min + Math.random() * (this.max - this.min)) * (Math.random() > 0.5 ? -1 : 1); 35 | } 36 | } 37 | } 38 | 39 | export default class Snow extends Mesh { 40 | static wind = new Wind(); 41 | 42 | options = DEFAULT_OPTIONS; 43 | 44 | private _number: number; 45 | 46 | private _width = 0; 47 | private _height = 0; 48 | 49 | get number() { 50 | return this._number; 51 | } 52 | 53 | set number(value: number) { 54 | if (value !== this._number) { 55 | this._number = value; 56 | this.setup(); 57 | } else { 58 | this._number = value; 59 | } 60 | } 61 | 62 | constructor(readonly textureSource: string, options: Partial) { 63 | super( 64 | new Geometry() 65 | .addAttribute('a_position', Buffer.from([]), 3) 66 | .addAttribute('a_rotation', Buffer.from([]), 3) 67 | .addAttribute('a_speed', Buffer.from([]), 3) 68 | .addAttribute('a_size', Buffer.from([]), 1) 69 | .addAttribute('a_alpha', Buffer.from([]), 1), 70 | Shader.from(vert, frag, { 71 | texture: Texture.from(textureSource), 72 | time: 0, 73 | worldSize: [0, 0, 0], 74 | gravity: 100, 75 | wind: 0, 76 | projection: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], 77 | }), 78 | State.for2d(), 79 | DRAW_MODES.POINTS, 80 | ); 81 | 82 | Object.assign(this.options, options); 83 | 84 | this._width = this.options.width; 85 | this._height = this.options.height; 86 | this._number = this.options.number; 87 | 88 | this.setup(); 89 | } 90 | 91 | setup() { 92 | const boundWidth = this._width; 93 | const boundHeight = this._height; 94 | 95 | // z in range from -80 to 80, camera distance is 100 96 | // max height at z of -80 is 110 97 | const height = 110; 98 | const width = (boundWidth / boundHeight) * height; 99 | const depth = 80; 100 | 101 | const maxSize = 5000 * this.options.maxSize; 102 | const minSize = 5000 * this.options.minSize; 103 | 104 | const position = []; 105 | const rotation = []; 106 | const speed = []; 107 | const size = []; 108 | const alpha = []; 109 | 110 | for (let i = (boundWidth / boundHeight) * this.number; i > 0; i--) { 111 | // prettier-ignore 112 | position.push( 113 | -width + Math.random() * width * 2, 114 | -height + Math.random() * height * 2, 115 | Math.random() * depth * 2, 116 | ); 117 | 118 | // prettier-ignore 119 | rotation.push( 120 | Math.random() * 2 * Math.PI, 121 | Math.random() * 20, 122 | Math.random() * 10, 123 | ); // angle, speed, sinusoid 124 | 125 | // prettier-ignore 126 | speed.push( 127 | 0.7 + Math.random(), 128 | 0.7 + Math.random(), 129 | Math.random() * 10, 130 | ); // x, y, sinusoid 131 | 132 | size.push((maxSize - minSize) * Math.random() + minSize); 133 | 134 | alpha.push(0.2 + Math.random() * 0.3); 135 | } 136 | 137 | this.geometry.getBuffer('a_position').update(new Float32Array(position)); 138 | this.geometry.getBuffer('a_rotation').update(new Float32Array(rotation)); 139 | this.geometry.getBuffer('a_speed').update(new Float32Array(speed)); 140 | this.geometry.getBuffer('a_size').update(new Float32Array(size)); 141 | this.geometry.getBuffer('a_alpha').update(new Float32Array(alpha)); 142 | 143 | const aspect = boundWidth / boundHeight; 144 | const fov = 60; 145 | const near = 1; 146 | const far = 10000; 147 | const z = 100; 148 | 149 | const fovRad = fov * (Math.PI / 180); 150 | const f = Math.tan(Math.PI * 0.5 - 0.5 * fovRad); 151 | const rangeInv = 1.0 / (near - far); 152 | 153 | // prettier-ignore 154 | this.shader.uniforms.projection = [ 155 | f / aspect, 0, 0, 0, 156 | 0, f, 0, 0, 157 | 0, 0, (near + far) * rangeInv, -1, 158 | 0, 0, near * far * rangeInv * 2 + z, z, 159 | ]; 160 | 161 | this.shader.uniforms.worldSize = [width, height, depth]; 162 | } 163 | 164 | resize(width: number, height: number) { 165 | this._width = width; 166 | this._height = height; 167 | this.setup(); 168 | } 169 | 170 | _calculateBounds() { 171 | this._bounds.addFrame(this.transform, 0, 0, this._width, this._height); 172 | } 173 | 174 | update(dt: DOMHighResTimeStamp, now: DOMHighResTimeStamp) { 175 | this.shader.uniforms.time = now / 5000; 176 | this.shader.uniforms.wind = Snow.wind.current; 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/module/live2d/MouseHandler.ts: -------------------------------------------------------------------------------- 1 | import { FOCUS_TIMEOUT } from '@/defaults'; 2 | import autobind from 'autobind-decorator'; 3 | import debounce from 'lodash/debounce'; 4 | import Cancelable = _.Cancelable; 5 | 6 | const DEAD_ZONE = 2; 7 | 8 | export default class MouseHandler { 9 | readonly element: HTMLElement; 10 | 11 | /** Only focus when mouse is pressed */ 12 | private focusOnPress = false; 13 | 14 | /** How long will the focus last. Setting 0 or negative value is equivalent to `Infinity` */ 15 | private loseFocusTimeout: DOMHighResTimeStamp = FOCUS_TIMEOUT; 16 | 17 | private pressed = false; 18 | private dragging = false; 19 | 20 | focusing = !this.focusOnPress; 21 | 22 | draggable = false; 23 | lastX = 0; 24 | lastY = 0; 25 | 26 | constructor(element: HTMLElement) { 27 | this.element = element; 28 | 29 | this.addGeneralListeners(); 30 | 31 | if (!this.focusOnPress) { 32 | this.addMouseMoveListener(); 33 | } 34 | 35 | this.updateLoseFocus(); 36 | } 37 | 38 | addGeneralListeners() { 39 | this.element.addEventListener('mousedown', this.mouseDown); 40 | this.element.addEventListener('mouseup', this.mouseUp); 41 | this.element.addEventListener('mouseleave', this.mouseLeave); 42 | } 43 | 44 | removeGeneralListeners() { 45 | this.element.removeEventListener('mousedown', this.mouseDown); 46 | this.element.removeEventListener('mouseup', this.mouseUp); 47 | this.element.removeEventListener('mouseleave', this.mouseLeave); 48 | } 49 | 50 | addMouseMoveListener() { 51 | this.element.addEventListener('mousemove', this.mouseMove, { passive: true }); 52 | } 53 | 54 | removeMouseMoveListener() { 55 | this.element.removeEventListener('mousemove', this.mouseMove); 56 | } 57 | 58 | @autobind 59 | mouseDown(e: MouseEvent) { 60 | // only handle left mouse button 61 | if (e.button !== 0) return; 62 | 63 | this.pressed = true; 64 | this.focusing = true; 65 | this.lastX = e.clientX; 66 | this.lastY = e.clientY; 67 | 68 | this.focus(e.clientX, e.clientY); 69 | 70 | this.addMouseMoveListener(); 71 | 72 | if (!this.focusOnPress) { 73 | this.cancelLoseFocus(); 74 | } 75 | } 76 | 77 | @autobind 78 | mouseMove(e: MouseEvent) { 79 | const movedX = e.clientX - this.lastX; 80 | const movedY = e.clientY - this.lastY; 81 | 82 | this.focusing = true; 83 | this.focus(e.clientX, e.clientY); 84 | 85 | if (this.pressed) { 86 | if ( 87 | this.dragging || 88 | movedX > DEAD_ZONE || 89 | movedX < -DEAD_ZONE || 90 | movedY > DEAD_ZONE || 91 | movedY < -DEAD_ZONE 92 | ) { 93 | if (this.draggable) { 94 | if (!this.dragging) { 95 | this.dragStart(this.lastX, this.lastY); 96 | } else { 97 | this.dragMove(e.clientX, e.clientY, movedX, movedY); 98 | } 99 | 100 | this.lastX = e.clientX; 101 | this.lastY = e.clientY; 102 | } 103 | 104 | this.dragging = true; 105 | } 106 | } else if (!this.focusOnPress) { 107 | this.loseFocus(); 108 | } 109 | } 110 | 111 | @autobind 112 | mouseUp(e: MouseEvent) { 113 | // only handle left mouse button 114 | if (e.button !== 0) return; 115 | 116 | if (this.pressed) { 117 | this.pressed = false; 118 | 119 | if (this.focusOnPress) { 120 | this.removeMouseMoveListener(); 121 | this.clearFocus(); 122 | } 123 | 124 | if (this.dragging) { 125 | this.dragging = false; 126 | this.dragEnd(); 127 | } else { 128 | this.click(e.clientX, e.clientY); 129 | } 130 | } 131 | } 132 | 133 | /** 134 | * Will be triggered when cursor leaves the element 135 | */ 136 | @autobind 137 | mouseLeave(e: MouseEvent) { 138 | if (!this.focusOnPress) { 139 | this.clearFocus(); 140 | this.mouseUp(e); 141 | } else if (this.dragging) { 142 | this.mouseUp(e); 143 | } 144 | } 145 | 146 | clearFocus() { 147 | this.focusing = false; 148 | } 149 | 150 | // must be initialized by calling `updateLoseFocus()` in constructor 151 | loseFocus!: (() => void) & Cancelable; 152 | 153 | private updateLoseFocus() { 154 | if (this.loseFocus) { 155 | this.cancelLoseFocus(); 156 | } 157 | this.loseFocus = debounce(() => this.clearFocus(), this.loseFocusTimeout); 158 | } 159 | 160 | cancelLoseFocus() { 161 | this.loseFocus.cancel(); 162 | } 163 | 164 | setFocusOnPress(enabled: boolean) { 165 | if (this.focusOnPress != enabled) { 166 | this.focusOnPress = enabled; 167 | 168 | if (enabled) { 169 | this.removeMouseMoveListener(); 170 | this.cancelLoseFocus(); 171 | this.clearFocus(); 172 | } else { 173 | this.focusing = true; 174 | this.addMouseMoveListener(); 175 | } 176 | } 177 | } 178 | 179 | setLoseFocusTimeout(value: DOMHighResTimeStamp) { 180 | this.loseFocusTimeout = value > 0 ? value : Infinity; 181 | if (value === Infinity) { 182 | this.cancelLoseFocus(); 183 | } else { 184 | this.updateLoseFocus(); 185 | } 186 | } 187 | 188 | destroy() { 189 | this.cancelLoseFocus(); 190 | this.removeGeneralListeners(); 191 | this.removeMouseMoveListener(); 192 | } 193 | 194 | // to be overridden 195 | focus(x: number, y: number) {} 196 | 197 | click(x: number, y: number) {} 198 | 199 | dragStart(x: number, y: number) {} 200 | 201 | dragMove(x: number, y: number, dx: number, dy: number) {} 202 | 203 | dragEnd() {} 204 | } 205 | -------------------------------------------------------------------------------- /src/module/config/reusable/Slider.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 131 | 132 | 209 | --------------------------------------------------------------------------------