├── 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 |
--------------------------------------------------------------------------------
/src/assets/img/check.svg:
--------------------------------------------------------------------------------
1 |
3 |
--------------------------------------------------------------------------------
/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 |
8 |
--------------------------------------------------------------------------------
/src/assets/img/close.svg:
--------------------------------------------------------------------------------
1 |
3 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/src/assets/img/tune.svg:
--------------------------------------------------------------------------------
1 |
3 |
--------------------------------------------------------------------------------
/src/assets/img/file-plus.svg:
--------------------------------------------------------------------------------
1 |
3 |
8 |
--------------------------------------------------------------------------------
/src/assets/img/shape.svg:
--------------------------------------------------------------------------------
1 |
3 |
--------------------------------------------------------------------------------
/src/assets/img/video-multiple.svg:
--------------------------------------------------------------------------------
1 |
3 |
8 |
--------------------------------------------------------------------------------
/src/assets/img/image-multiple.svg:
--------------------------------------------------------------------------------
1 |
3 |
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 |
8 |
--------------------------------------------------------------------------------
/src/assets/img/console.svg:
--------------------------------------------------------------------------------
1 |
3 |
--------------------------------------------------------------------------------
/src/assets/img/info.svg:
--------------------------------------------------------------------------------
1 |
3 |
8 |
--------------------------------------------------------------------------------
/src/assets/img/refresh.svg:
--------------------------------------------------------------------------------
1 |
3 |
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 |
--------------------------------------------------------------------------------
/src/assets/img/flare.svg:
--------------------------------------------------------------------------------
1 |
3 |
--------------------------------------------------------------------------------
/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 |
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 |
2 |
10 |
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 |
2 | {{ fps }}
3 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
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 |
2 |
3 |
10 |
11 |
12 |
13 |
14 |
15 |
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 |
2 |
3 |
4 | {{ $t('high_quality') }}
5 |
6 |
7 | {{ $t('enabled') }}
8 |
9 | {{ $t('amount') }}
10 |
11 |
12 | {{ $t('drop_rate') }}
13 |
14 |
15 |
16 | {{ $t('enabled') }}
17 |
18 | {{ $t('amount') }}
19 |
20 |
21 |
22 |
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 |
2 |
3 |
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 |
2 |
3 |
4 |
5 |
{{ subtitle.sub.text }}
6 |
7 |
8 |
9 |
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 |
2 |
11 |
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 | 
3 | [](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 | [](https://steamcommunity.com/sharedfiles/filedetails/?id=1078208425)
5 | 
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 |
2 |
3 |
2 ? ($event.target.dataset.alt = 'Weeb Live2D') : 0">{{ title }}
4 |
{{ $t('subtitle') }}
5 |
6 | v{{ ver }}
7 | {{ time }}
8 | Now on GitHub
9 |
10 |
11 |
{{ $t('desc') }}
12 |
13 |
14 |
{{ $t('important_changes') + ' v' + importantChangesVersion }}
15 |
{{ importantChanges }}
16 |
17 |
18 |
19 |
{{ $t('changelog') }}
20 |
· {{ log }}
21 |
22 |
23 |
24 |
25 |
Live2D Models
26 |
MAINICHI COMPILE HEART ©IDEA FACTORY/COMPILE HEART
27 |
Programming
28 |
今天的风儿好喧嚣
Take it Easy!
29 |
Localization
30 |
31 | Shiro
R3M11X(ΦωΦ)
afs
星空月之夜
32 | JaviHunt
小莫
Raul
MrPortal
Lord Lionnel
33 |
34 |
Special Thanks
35 |
Eisa
36 |
37 |
38 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | - {{ option.text }}
14 |
15 |
16 |
17 |
18 |
{{ selection }}
19 |
20 |
21 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | {{ displayValue }}
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
131 |
132 |
209 |
--------------------------------------------------------------------------------