feat: migrate to turbo (#22)

* feat: migrate top turbo

* ci: ci test

* fix: fix codeql issues

* feat: ci test

* chore: lint

* chore: misc changes

* feat: rename vite helpers

* feat: use fetch to handle assets

* feat: update directory

* feat: fetch charword table

* feat: migrate download game data and detect missing voice files

* feat: symlink relative path

* feat: finish wrangler upload

* feat: migrate wrangler download

* feat: finish

* chore: auto update

* ci: update ci

* ci: update ci

---------

Co-authored-by: Halyul <Halyul@users.noreply.github.com>
This commit is contained in:
Haoyu Xu
2025-02-22 15:11:30 +08:00
committed by GitHub
parent 17c61ce5d4
commit d6e7bc20d3
352 changed files with 12911 additions and 9411 deletions

View File

@@ -1,15 +0,0 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended"
],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
}
}

View File

@@ -3,12 +3,12 @@ name: Build release and push to CF Pages
on:
push:
branches: [ main ]
pull_request:
branches:
- main
env:
CACHE_ZIP_FILENAME: cache.zip
ASSETS_FOLDER: data/operator
RELEASE_FOLDER: release
CACHE_BASE_KEY: akassets
DO_NOT_TRACK: 1
jobs:
build:
@@ -19,42 +19,15 @@ jobs:
uses: pnpm/action-setup@v4
with:
run_install: true
- name: Restore cached assets
id: cache-akassets-restore
uses: actions/cache@v4
with:
path: |
${{ env.CACHE_ZIP_FILENAME }}
key: ${{ env.CACHE_BASE_KEY }}-${{ hashFiles('offical_update.json') }}
restore-keys: |
${{ env.CACHE_BASE_KEY }}
- name: Unzip assets
run: |
if test -f ${{ env.CACHE_ZIP_FILENAME }}; then
unzip -qq ${{ env.CACHE_ZIP_FILENAME }} -d .
fi
shell: bash
- name: Download Assets
run: pnpm run cf:download
- name: Build all
run: pnpm run operator:build-all
timeout-minutes: 10
- name: Build directory
run: pnpm run directory:build
- name: Download Data
run: pnpm run download:data
- name: Build
run: pnpm run build
- name: Publish to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: aklive2d
directory: ${{ env.RELEASE_FOLDER}}
directory: dist
wranglerVersion: '3'
- name: Zip assets
run: zip -qq -9 -r ${{ env.CACHE_ZIP_FILENAME }} ${{ env.ASSETS_FOLDER }}
- name: Save assets
id: cache-akassets-save
uses: actions/cache/save@v4
with:
path: |
${{ env.CACHE_ZIP_FILENAME }}
key: ${{ env.CACHE_BASE_KEY }}-${{ hashFiles('offical_update.json') }}

View File

@@ -1,32 +0,0 @@
name: Update charwords
on:
schedule:
- cron: '0 0 * * *'
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v3
- name: Use PNPM
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Update charwords
run: pnpm run charwords:update
- name: Commit changes if any
uses: stefanzweifel/git-auto-commit-action@v4
with:
commit_message: "chore(charwords): auto update"

View File

@@ -1,32 +0,0 @@
name: Update music
on:
schedule:
- cron: '0 0 * * *'
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v3
- name: Use PNPM
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Update music mapping
run: pnpm run music
- name: Commit changes if any
uses: stefanzweifel/git-auto-commit-action@v4
with:
commit_message: "chore(music): auto update mapping"

View File

@@ -1,32 +0,0 @@
name: Update Offical Dyn Info
on:
schedule:
- cron: '30 10 * * *'
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v3
- name: Use PNPM
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Update offical dyn info
run: pnpm run offical_update
- name: Commit changes if any
uses: stefanzweifel/git-auto-commit-action@v4
with:
commit_message: "chore(offical update): auto update"

32
.github/workflows/update.yaml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: Update
env:
DO_NOT_TRACK: 1
on:
schedule:
- cron: '30 10 * * *'
pull_request:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v3
- name: Use PNPM
uses: pnpm/action-setup@v4
with:
run_install: true
- name: Update
run: pnpm run update
- name: Commit changes if any
uses: stefanzweifel/git-auto-commit-action@v4
with:
commit_message: "chore: auto update"

5
.gitignore vendored
View File

@@ -130,7 +130,6 @@ dist
.pnp.*
# custom
release/*
spine-runtimes/*
_*.json
.DS_Store
@@ -138,5 +137,5 @@ _*.json
*_v2/*
assets/*
temp/*
operator/*
data/operator/*
.turbo
data/*

View File

@@ -1,6 +0,0 @@
{
"map": true,
"plugins": {
"autoprefixer": {}
}
}

View File

@@ -1,3 +0,0 @@
{
"extends": "stylelint-config-standard-scss"
}

63
.vscode/launch.json vendored
View File

@@ -9,87 +9,100 @@
},
{
"type": "node-terminal",
"name": "Run Script: charword",
"name": "Run Script: build chen",
"request": "launch",
"command": "pnpm run charwords:update",
"env": {
"name": "chen"
},
"command": "pnpm run build",
"cwd": "${workspaceFolder}"
},
{
"type": "node-terminal",
"name": "Run Script: build mizuki_summer_feast",
"name": "Run Script: build all",
"request": "launch",
"command": "pnpm run build mizuki_summer_feast",
"command": "pnpm run build",
"cwd": "${workspaceFolder}"
},
{
"type": "node-terminal",
"name": "Run Script: build-all",
"name": "Run Script: update",
"request": "launch",
"command": "pnpm run operator:build-all",
"command": "pnpm run update",
"cwd": "${workspaceFolder}"
},
{
"type": "node-terminal",
"name": "Run Script: test",
"name": "Run Script: init",
"env": {
"name": "test",
"id": "202203231"
},
"request": "launch",
"command": "pnpm run test",
"command": "pnpm run init",
"cwd": "${workspaceFolder}"
},
{
"type": "node-terminal",
"name": "Run Script: dev",
"name": "Run Script: lint",
"request": "launch",
"command": "pnpm run dev kaltsit_remnant",
"command": "pnpm run lint",
"cwd": "${workspaceFolder}"
},
{
"type": "node-terminal",
"name": "Run Script: Directory dev",
"name": "Run Script: dev:directory",
"request": "launch",
"command": "pnpm run vite:directory:dev",
"command": "pnpm run dev:directory",
"cwd": "${workspaceFolder}"
},
{
"type": "node-terminal",
"name": "Run Script: charwords:update",
"name": "Run Script: preview:directory",
"request": "launch",
"command": "pnpm run charwords:update",
"command": "pnpm run preview:directory",
"cwd": "${workspaceFolder}"
},
{
"type": "node-terminal",
"name": "Run Script: charwords:build",
"name": "Run Script: dev:showcase",
"env": {
"name": "chen"
},
"request": "launch",
"command": "pnpm run charwords:build",
"command": "pnpm run dev:showcase",
"cwd": "${workspaceFolder}"
},
{
"type": "node-terminal",
"name": "Run Script: music",
"name": "Run Script: preview:showcase",
"env": {
"name": "chen"
},
"request": "launch",
"command": "pnpm run music",
"command": "pnpm run preview:showcase",
"cwd": "${workspaceFolder}"
},
{
"type": "node-terminal",
"name": "Run Script: offical_update",
"name": "Run Script: download:game",
"request": "launch",
"command": "pnpm run offical_update",
"command": "pnpm run download:game",
"cwd": "${workspaceFolder}"
},
{
"type": "node-terminal",
"name": "Run Script: cf:upload",
"name": "Run Script: upload",
"request": "launch",
"command": "pnpm run cf:upload",
"command": "pnpm run upload",
"cwd": "${workspaceFolder}"
},
{
"type": "node-terminal",
"name": "Run Script: cf:download",
"name": "Run Script: download:data",
"request": "launch",
"command": "pnpm run cf:download",
"command": "pnpm run download:data",
"cwd": "${workspaceFolder}"
}
},
]
}

View File

@@ -14,36 +14,32 @@ A list of supported operators can be found at [Directory](https://gura.ch/aklive
### Command Line Tool
``` bash
$ npm run generate {operator_name}
To generate operator assets for showcase page
$ pnpm run update
Update data from official website and github repo
```
``` bash
$ npm run dev {operator_name}
Live showcase page server for development
$ pnpm run lint
ESLint and StyleLint
```
``` bash
$ npm run build {operator_name}
$ pnpm run build
Build showcase webpage for all operators and directory page
```
``` bash
$ name=<name> pnpm run build
Build showcase webpage for an operator
```
``` bash
$ npm run build-all
To generate all operator assets for showcase page
```
``` bash
$ npm run init {operator_name}
$ name=<name> id=<id> pnpm run init
To initialize folder and config file for an operator
```
``` bash
$ npm run readme {operator_name}
To add operator info to README.md
$ name=<name> pnpm run dev:showcase
Run dev server for showcase webpage for an operator
```
``` bash
$ npm run directory
To generate directory.json
```
``` bash
$ npm run charword
To generate the latest charword_table.json
$ name=<name> pnpm run preview:showcase
Preview built showcase webpage for an operator
```
### Webpage & JavaScript
@@ -55,6 +51,7 @@ Using JS events to change settings is recommended.
## Config
### General Config
in `packages/config/config.yaml`
``` yaml
folder:
operator: ./operator/ # folder for operator assets
@@ -83,20 +80,38 @@ operators:
passager_dream_in_a_moment: !include config/passager_dream_in_a_moment.yaml
mizuki_summer_feast: !include config/mizuki_summer_feast.yaml
```
### Operator Config
### Operators Config
in `packages/operator/config.yaml`
```yaml
chen: !include config/chen.yaml
dusk: !include config/dusk.yaml
dusk_everything_is_a_miracle: !include config/dusk_everything_is_a_miracle.yaml
ling: !include config/ling.yaml
nearl: !include config/nearl.yaml
nian: !include config/nian.yaml
nian_unfettered_freedom: !include config/nian_unfettered_freedom.yaml
phatom_focus: !include config/phatom_focus.yaml
rosmontis: !include config/rosmontis.yaml
skadi: !include config/skadi.yaml
skadi_sublimation: !include config/skadi_sublimation.yaml
w: !include config/w.yaml
...
```
### Operator Config
in `packages/operator/config/<name>.yaml`
```yaml
link: chen # the link to access showcase page for this operator
type: operator # operator live2d or skin live2d
date: 2021/08 # release date
title: 'Arknights: Ch''en/Chen the Holungday - 明日方舟:假日威龙陈' # page title
filename: dyn_illust_char_1013_chen2 # live2d assets name
logo: logo_rhodes_override # operator logo
fallback_name: char_1013_chen2_2 # fallback image name
viewport_left: 0 # live2d view port settings
viewport_right: 0
viewport_top: 1
viewport_bottom: 1
viewport_top: 0
viewport_bottom: 0
invert_filter: false # operator logo invert filter
codename: # operator name
zh-CN: 假日威龙陈
en-US: Ch'en/Chen the Holungday
use_json: false # whether the spine skel is in json format
```
## LICENSE
@@ -104,12 +119,14 @@ The `LICENSE` file applies to all files unless listed specifically.
`LICENSE_SPINE` file applies to following files including adapted code for this repo:
- `src/libs/spine-player.css`
- `src/libs/spine-player.js`
- `apps/module/libs/spine-player.css`
- `apps/module/libs/spine-player.js`
`Copyright © 2017 - 2023 Arknights/Hypergryph Co., Ltd` applies to following files:
- all files under `operator` folder and its sub-folders
- all files under `packages/operator/data` folder and its sub-folders
- all files under `packages/music/data` folder and its sub-folders
- all files under `packages/background/data` folder and its sub-folders
## Instructions on Extracting In-Game Assets
I'm still struggling to find a command-line tool to extract in-game assets. But [AssetRipper](https://github.com/AssetRipper/AssetRipper) seems to have a command-line interface, I'm too lazy to have a deeper inverstigation.

View File

@@ -1,309 +0,0 @@
/* eslint-disable no-fallthrough */
/* eslint-disable no-undef */
import assert from 'assert'
import path from 'path'
import { fileURLToPath } from 'url'
import { fork } from 'child_process';
import getConfig from './libs/config.js'
import ProjectJson from './libs/project_json.js'
import EnvGenerator from './libs/env_generator.js'
import { write, rmdir, copy, writeSync, copyDir, readdirSync, exists, mkdir } from './libs/file.js'
import AssetsProcessor from './libs/assets_processor.js'
import init from './libs/initializer.js'
import directory from './libs/directory.js'
import Background from './libs/background.js'
import CharwordTable from './libs/charword_table.js';
import Music from './libs/music.js';
import OfficalInfo from './libs/offical_info.js';
import CFPages from './libs/cf_pages.js';
async function main() {
global.__projectRoot = path.dirname(fileURLToPath(import.meta.url))
const officalInfo = new OfficalInfo()
global.__config = getConfig(officalInfo)
global.__error = []
const OPERATOR_SOURCE_FOLDER = path.join(__projectRoot, __config.folder.operator)
const OPERATOR_SOURCE_DATA_FOLDER = path.join(__projectRoot, __config.folder.operator_data)
const OPERATOR_SHARE_FOLDER = path.join(OPERATOR_SOURCE_DATA_FOLDER, __config.folder.share)
const op = process.argv[2]
let OPERATOR_NAMES = process.argv.slice(3);
const charwordTable = new CharwordTable()
const musicTable = new Music()
/**
* Skip all, no need for OPERATOR_NAME
* build-all: build all assets
* directory: build directory
*/
switch (op) {
case 'operator:build-all':
for (const [key,] of Object.entries(__config.operators)) {
OPERATOR_NAMES.push(key)
}
break
case 'operator:preview':
assert(OPERATOR_NAMES.length !== 0, 'Please set the operator name.')
fork(path.join(__projectRoot, 'vite.config.js'), [op, OPERATOR_NAMES])
return
case 'charwords:update':
await charwordTable.process()
process.exit(0)
case 'music':
await musicTable.process()
process.exit(0)
case 'offical_update':
await officalInfo.update()
process.exit(0)
case 'cf:upload':
await (new CFPages()).upload()
process.exit(0)
case 'cf:download':
await (new CFPages()).download()
process.exit(0)
default:
break
}
assert(OPERATOR_NAMES.length !== 0, 'Please set the operator name.')
const background = new Background(OPERATOR_SHARE_FOLDER, OPERATOR_SOURCE_FOLDER)
await background.process()
const backgrounds = ['operator_bg.png', ...background.files]
const { musicToCopy, musicMapping } = musicTable.copy(OPERATOR_SHARE_FOLDER)
for (const e of musicToCopy) {
const musicPath = path.join(e.source, e.filename)
if (!exists(musicPath)) {
__error.push(`Music file ${e.filename} is not found in music folder.`)
}
}
for (const e of Object.keys(musicMapping)) {
if (!backgrounds.includes(e)) {
__error.push(`Background file ${e} is not found in background folder.`)
}
}
for (const OPERATOR_NAME of OPERATOR_NAMES) {
const OPERATOR_RELEASE_FOLDER = path.join(__projectRoot, __config.folder.release, OPERATOR_NAME)
const SHOWCASE_PUBLIC_ASSSETS_FOLDER = path.join(OPERATOR_RELEASE_FOLDER, "assets")
const EXTRACTED_FOLDER = path.join(OPERATOR_SOURCE_DATA_FOLDER, OPERATOR_NAME, 'extracted')
const VOICE_FOLDERS = __config.folder.voice.sub.map((sub) => path.join(OPERATOR_SOURCE_DATA_FOLDER, OPERATOR_NAME, __config.folder.voice.main, sub.name))
/**
* Skip assets generation part
* init: init folder and config for an operator
* readme: append a new line to README.md
*/
switch (op) {
case 'init':
init(OPERATOR_NAME, [EXTRACTED_FOLDER, ...VOICE_FOLDERS], officalInfo)
process.exit(0)
default:
break
}
rmdir(OPERATOR_RELEASE_FOLDER)
mkdir(path.join(OPERATOR_SOURCE_FOLDER, OPERATOR_NAME))
const charwordTableLookup = charwordTable.lookup(OPERATOR_NAME)
const voiceJson = {}
voiceJson.config = {
default_region: charwordTableLookup.config.default_region.replace("_", "-"),
regions: charwordTableLookup.config.regions.map((item) => item.replace("_", "-")),
}
voiceJson.voiceLangs = {}
voiceJson.subtitleLangs = {}
const subtitleInfo = Object.keys(charwordTableLookup.operator.info)
subtitleInfo.forEach((item) => {
if (Object.keys(charwordTableLookup.operator.info[item]).length > 0) {
const key = item.replace("_", "-")
voiceJson.subtitleLangs[key] = {}
for (const [id, subtitles] of Object.entries(charwordTableLookup.operator.voice[item])) {
const match = id.replace(/(.+?)([A-Z]\w+)/, '$2')
if (match === id) {
voiceJson.subtitleLangs[key].default = subtitles
} else {
voiceJson.subtitleLangs[key][match] = subtitles
}
}
voiceJson.voiceLangs[key] = {}
Object.values(charwordTableLookup.operator.info[item]).forEach((item) => {
voiceJson.voiceLangs[key] = { ...voiceJson.voiceLangs[key], ...item }
})
}
})
let voiceLangs = [], subtitleLangs = [];
try {
voiceLangs = Object.keys(voiceJson.voiceLangs["zh-CN"])
subtitleLangs = Object.keys(voiceJson.subtitleLangs)
writeSync(JSON.stringify(voiceJson), path.join(OPERATOR_SOURCE_FOLDER, OPERATOR_NAME, 'charword_table.json'))
} catch (e) {
console.log(`charword_table is not available`)
}
// check whether voice files has been added
const customVoiceName = voiceLangs.filter(i => !__config.folder.voice.sub.map(e => e.lang).includes(i))[0]
const voiceLangMapping = __config.folder.voice.sub.filter(e => {
return voiceLangs.includes(e.lang) || (e.lang === "CUSTOM" && typeof customVoiceName !== 'undefined')
}).map(e => {
return {
name: e.name,
lang: e.lang === "CUSTOM" ? customVoiceName : e.lang
}
})
for (const voiceSubFolderMapping of voiceLangMapping) {
const voiceSubFolder = path.join(OPERATOR_SOURCE_DATA_FOLDER, OPERATOR_NAME, __config.folder.voice.main, voiceSubFolderMapping.name)
if (readdirSync(voiceSubFolder).length === 0) {
__error.push(`Voice folder ${voiceSubFolderMapping.name} for ${OPERATOR_NAME} is empty.`)
}
}
const envPath = path.join(OPERATOR_SOURCE_FOLDER, OPERATOR_NAME, '.env')
writeSync((new EnvGenerator()).generate([
{
key: "insight_id",
value: __config.insight_id
},
{
key: "link",
value: __config.operators[OPERATOR_NAME].link
}, {
key: "title",
value: __config.operators[OPERATOR_NAME].title
}, {
key: "filename",
value: __config.operators[OPERATOR_NAME].filename.replace('#', '%23')
}, {
key: "logo_filename",
value: __config.operators[OPERATOR_NAME].logo
}, {
key: "fallback_filename",
value: __config.operators[OPERATOR_NAME].fallback_name.replace('#', '%23')
}, {
key: "viewport_left",
value: __config.operators[OPERATOR_NAME].viewport_left
}, {
key: "viewport_right",
value: __config.operators[OPERATOR_NAME].viewport_right
}, {
key: "viewport_top",
value: __config.operators[OPERATOR_NAME].viewport_top
}, {
key: "viewport_bottom",
value: __config.operators[OPERATOR_NAME].viewport_bottom
}, {
key: "invert_filter",
value: __config.operators[OPERATOR_NAME].invert_filter
}, {
key: "image_width",
value: 2048
}, {
key: "image_height",
value: 2048
}, {
key: "background_files",
value: JSON.stringify(backgrounds)
}, {
key: "background_folder",
value: __config.folder.background
}, {
key: "voice_folders",
value: JSON.stringify(__config.folder.voice)
}, {
key: "music_folder",
value: __config.folder.music
}, {
key: "music_mapping",
value: JSON.stringify(musicMapping)
}, {
key: "use_json",
value: __config.operators[OPERATOR_NAME].use_json
}
]), envPath)
const projectJson = new ProjectJson(OPERATOR_NAME, OPERATOR_SHARE_FOLDER, {
backgrounds,
voiceLangs,
subtitleLangs,
music: Object.keys(musicMapping)
})
projectJson.load().then((content) => {
write(JSON.stringify(content, null, 2), path.join(OPERATOR_RELEASE_FOLDER, 'project.json'))
})
const assetsProcessor = new AssetsProcessor(OPERATOR_NAME, OPERATOR_SHARE_FOLDER)
const assetContent = await assetsProcessor.process(EXTRACTED_FOLDER)
write(JSON.stringify(assetContent.assetsJson, null), path.join(OPERATOR_SOURCE_FOLDER, OPERATOR_NAME, `assets.json`))
// copy remaining files
const filesToCopy = [
...background.getFilesToCopy(SHOWCASE_PUBLIC_ASSSETS_FOLDER),
...musicToCopy.map(entry => {
return {
...entry,
target: path.join(SHOWCASE_PUBLIC_ASSSETS_FOLDER, __config.folder.music)
}
}),
{
filename: 'preview.jpg',
source: path.join(OPERATOR_SOURCE_DATA_FOLDER, OPERATOR_NAME),
target: path.join(OPERATOR_RELEASE_FOLDER)
},
{
filename: 'operator_bg.png',
source: path.join(OPERATOR_SHARE_FOLDER, __config.folder.background),
target: path.join(SHOWCASE_PUBLIC_ASSSETS_FOLDER, __config.folder.background)
},
{
filename: `${__config.operators[OPERATOR_NAME].logo}.png`,
source: path.join(OPERATOR_SHARE_FOLDER, 'logo'),
target: path.join(SHOWCASE_PUBLIC_ASSSETS_FOLDER)
},
{
filename: `${__config.operators[OPERATOR_NAME].fallback_name}.png`,
source: path.join(OPERATOR_SOURCE_FOLDER, OPERATOR_NAME),
target: path.join(SHOWCASE_PUBLIC_ASSSETS_FOLDER)
},
{
filename: `${__config.operators[OPERATOR_NAME].fallback_name}_portrait.png`,
source: path.join(OPERATOR_SOURCE_FOLDER, OPERATOR_NAME),
target: path.join(SHOWCASE_PUBLIC_ASSSETS_FOLDER)
}
]
filesToCopy.forEach((file) => {
copy(path.join(file.source, file.filename), path.join(file.target, file.filename))
})
const foldersToCopy = [
{
source: path.join(OPERATOR_SOURCE_DATA_FOLDER, OPERATOR_NAME, __config.folder.voice.main),
target: path.join(SHOWCASE_PUBLIC_ASSSETS_FOLDER, __config.folder.voice.main)
}
]
foldersToCopy.forEach((folder) => {
copyDir(folder.source, folder.target)
})
}
switch (op) {
case op.startsWith('directory'):
directory(OPERATOR_SOURCE_DATA_FOLDER, { backgrounds, musicMapping })
default:
break
}
if (__error.length > 0) {
const str = `${__error.length} error${__error.length > 1 ? 's were' : ' was'} found:\n${__error.join('\n')}`
throw new Error(str)
} else {
for (const OPERATOR_NAME of OPERATOR_NAMES) {
fork(path.join(__projectRoot, 'vite.config.js'), [op, OPERATOR_NAME])
}
}
}
main();

View File

@@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
data

View File

@@ -0,0 +1,3 @@
dist
data
auto_update

View File

@@ -0,0 +1,30 @@
import js from '@eslint/js'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import baseConfig from '@aklive2d/eslint-config'
/** @type {import('eslint').Config} */
export default [
...baseConfig,
{ ignores: ['dist'] },
{
settings: { react: { version: '18.3' } },
plugins: {
react,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
...reactHooks.configs.recommended.rules,
'react/jsx-no-target-blank': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]

86
apps/directory/index.html Normal file
View File

@@ -0,0 +1,86 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>%VITE_APP_TITLE%</title>
<script
id="counterscale-script"
src="%VITE_INSIGHT_URL%"
defer
></script>
<style>
.loader {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
opacity: 1;
}
.loader.loaded {
opacity: 0;
transition: opacity cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
}
.loader .icon,
.loader .flasher {
display: block;
margin: 0 auto;
width: 2rem;
height: 2rem;
border-left: 0.3rem solid;
border-bottom: 0.3rem solid;
border-right: 0.3rem solid;
border-top: 0.3rem solid;
transform: rotate(-45deg);
box-shadow:
inset 0 0 16px 2px rgb(200 14 0),
0 0 18px 5px rgb(200 14 0);
border-color: rgba(54, 0, 0, 87%);
}
.loader .flasher {
position: absolute;
box-shadow: unset;
animation: icon-flash 1.2s cubic-bezier(0.2, 0.6, 0.2, 1)
infinite;
}
@media (prefers-color-scheme: dark) {
.loader {
background-color: #131313;
}
}
@media (prefers-color-scheme: light) {
.loader {
background-color: #ececec;
}
}
@keyframes icon-flash {
100% {
opacity: 0;
width: 4rem;
height: 4rem;
border-color: rgb(0, 0, 0);
}
}
</style>
</head>
<body>
<div id="root"></div>
<div class="loader">
<span class="icon"></span>
<span class="flasher"></span>
</div>
<script type="module" src="/src/App.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"!/*": ["src/*"]
}
}
}

View File

@@ -0,0 +1,43 @@
{
"name": "@aklive2d/directory",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev:directory": "vite --clearScreen false",
"build": "mode=build node runner.js",
"preview:directory": "vite preview",
"lint": "eslint \"src/**/*.js\" \"src/**/*.jsx\" && stylelint \"src/**/*.css\" \"src/**/*.scss\" && prettier --check ."
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"jotai": "^2.11.3",
"react-router-dom": "^7.1.5",
"react-simple-typewriter": "^5.0.1",
"reset-css": "^5.0.2",
"@aklive2d/eslint-config": "workspace:*",
"@aklive2d/stylelint-config": "workspace:*",
"@aklive2d/postcss-config": "workspace:*",
"@aklive2d/config": "workspace:*",
"@aklive2d/libs": "workspace:*",
"@aklive2d/assets": "workspace:*",
"@aklive2d/operator": "workspace:*",
"@aklive2d/vite-helpers": "workspace:*",
"@aklive2d/showcase": "workspace:*",
"@aklive2d/module": "workspace:*",
"@aklive2d/prettier-config": "workspace:*"
},
"devDependencies": {
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react-swc": "^3.5.0",
"vite": "^6.1.0",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.18",
"prop-types": "^15.8.1",
"sass": "^1.84.0",
"autoprefixer": "^10.4.20"
}
}

View File

@@ -0,0 +1,5 @@
import baseConfig from '@aklive2d/postcss-config'
/** @type {import('postcss').Config} */
export default {
...baseConfig,
}

View File

@@ -0,0 +1,11 @@
import baseConfig from '@aklive2d/prettier-config'
/**
* @type {import("prettier").Config}
*/
const config = {
...baseConfig,
semi: false,
}
export default config

24
apps/directory/runner.js Normal file
View File

@@ -0,0 +1,24 @@
import { build as viteBuild } from 'vite'
import { envParser } from '@aklive2d/libs'
const build = async (namesToBuild) => {
if (!namesToBuild.length) {
// skip as directory can only build
// when all operators are built
await viteBuild()
}
}
async function main() {
const { name } = envParser.parse({
name: {
type: 'string',
short: 'n',
multiple: true,
default: [],
},
})
await build(name)
}
main()

View File

@@ -0,0 +1,23 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import Root from '@/routes/Root'
import Error from '@/routes/Error'
import routes from '@/routes'
import '@/App.scss'
import 'reset-css'
const router = createBrowserRouter([
{
path: '/',
element: <Root />,
errorElement: <Error />,
children: routes.filter((item) => item.routeable),
},
])
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
)

103
apps/directory/src/App.scss Normal file
View File

@@ -0,0 +1,103 @@
@import 'https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;500;700&family=Noto+Sans+KR:wght@400;500;700&family=Noto+Sans+SC:wght@400;500;700&family=Noto+Sans:wght@400;500;700&display=swap';
@import 'https://fonts.cdnfonts.com/css/bender';
@import 'https://fonts.cdnfonts.com/css/geometos';
@mixin light-theme() {
--text-color: rgb(0 0 0 / 87%);
--text-color-full: #000;
--secondary-text-color: #97958d;
--date-color: rgb(0 0 0 / 20%);
--border-color: #8f8f8f;
--link-highlight-color: #33b5e5;
--drawer-background-color: rgb(255 255 255 / 88%);
--root-background-color: #ececec;
--home-item-hover-background-color: rgb(188 188 188 / 30%);
--home-item-background-linear-gradient-color: rgb(0 0 0 / 10%);
--home-item-outline-color: rgb(41 41 41 / 30%);
--button-color: #999;
}
@mixin dark-theme() {
--text-color: rgb(255 255 255 / 87%);
--text-color-full: #fff;
--secondary-text-color: #686a72;
--date-color: rgb(255 255 255 / 20%);
--border-color: #707070;
--link-highlight-color: #33b5e5;
--drawer-background-color: rgb(0 0 0 / 88%);
--root-background-color: #131313;
--home-item-hover-background-color: rgb(67 67 67 / 30%);
--home-item-background-linear-gradient-color: rgb(255 255 255 / 10%);
--home-item-outline-color: rgb(214 214 214 / 30%);
--button-color: #666;
}
@media (prefers-color-scheme: dark) {
:root {
@include dark-theme;
}
}
@media (prefers-color-scheme: light) {
:root {
@include light-theme;
}
}
:root {
font-family:
Geometos, 'Noto Sans SC', 'Noto Sans JP', 'Noto Sans KR', 'Noto Sans',
sans-serif;
font-size: 16px;
line-height: 1.2em;
font-weight: 400;
color: var(--text-color);
background-color: var(--root-background-color);
min-height: 100vh;
@media only screen and (width <= 430px) {
& {
font-size: 12px;
}
}
@media only screen and (width <= 1600px) {
& {
font-size: 14px;
}
}
}
#root {
display: flex;
flex-flow: column nowrap;
justify-content: flex-start;
align-items: stretch;
min-height: 100vh;
}
:root::-webkit-scrollbar {
width: 12px;
height: 12px;
}
#root::-webkit-scrollbar-track {
background-color: transparent;
}
:root::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border: 5px solid transparent;
background-clip: padding-box;
transition: all 0.3s ease-in-out;
}
:root::-webkit-scrollbar-thumb:hover {
border: 4px solid transparent;
background-color: var(--text-color);
}
a {
color: var(--text-color);
text-decoration: none;
}

View File

@@ -0,0 +1,9 @@
import PropTypes from 'prop-types'
import classes from './scss/border.module.scss'
export default function Border(props) {
return <section className={classes.border}>{props.children}</section>
}
Border.propTypes = {
children: PropTypes.node,
}

View File

@@ -0,0 +1,28 @@
import PropTypes from 'prop-types'
export default function CharIcon(props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={props.viewBox}
style={props.style}
>
{props.type === 'operator' ? (
<g>
<path d="M89 17.5 30.4 57 24.3 71.4 82.9 32.6Z"></path>
<path d="M0 17.5 58.6 57 64.7 71.4 6.1 32.7Z"> </path>
<path d="M89 0 30.4 39.5 24.3 53.9 82.9 15.1Z"> </path>
<path d="M0 0 58.6 39.5 64.7 53.9 6.1 15.2Z"> </path>
</g>
) : (
<path d="M90.4 50.6l-39.8-23.5v-4c0-4.5-5-6.5-5-6.5a5.4 5.4 0 012.2-10.1c2.7 0 5.3 1.5 5.5 4.8.4 5.3 6.4 3.9 6.4-.3a11.7 11.7 0 00-12-11c-9 0-11.6 8.8-11.6 11.6a11.5 11.5 0 001.6 6.2c2.2 3.8 6.6 4.3 6.6 6.8v2.5L4.2 50.7c-4 2.3-4.7 7.3-3.8 10.3a9.1 9.1 0 009.1 6.4h75.2c5.9 0 8.6-3.4 9.5-6.3C95 58.1 95 53.4 90.4 50.6Zm-5.6 10.3h-75.2c-2.4.1-4-3.3-1.5-4.8l39.2-22.9 39 22.8A2.7 2.7 0 0184.7 60.8Z" />
)}
</svg>
)
}
CharIcon.propTypes = {
viewBox: PropTypes.string,
type: PropTypes.string,
style: PropTypes.object,
}

View File

@@ -0,0 +1,99 @@
import { useState } from 'react'
import PropTypes from 'prop-types'
import classes from './scss/dropdown.module.scss'
export default function Dropdown(props) {
const [hidden, setHidden] = useState(true)
const toggleDropdown = () => {
setHidden(!hidden)
}
return (
<>
<section
className={`${classes.dropdown} ${hidden ? '' : classes.active} ${props.className ? props.className : ''} ${props.left ? classes.left : ''}`}
>
<section
className={classes.text}
onClick={() => toggleDropdown()}
>
<span className={classes.content}>{props.text}</span>
<span
className={classes.icon}
style={props.iconStyle}
></span>
<section className={classes.popup}>
<span className={classes.text}>{props.altText}</span>
</section>
</section>
<ul className={classes.menu} style={props.activeColor}>
{props.menu.map((item) => {
switch (item.type) {
case 'date': {
return (
<section
key={item.name}
className={classes.date}
>
<section className={classes.line} />
<section className={classes.text}>
{item.name}
</section>
</section>
)
}
case 'custom': {
return item.component
}
default: {
return (
<li
key={item.name}
className={`${classes.item} ${item.name === props.text || (props.activeRule && props.activeRule(item)) ? classes.active : ''}`}
onClick={() => {
props.onClick(item)
toggleDropdown()
}}
style={
item.color
? { color: item.color }
: {}
}
>
{item.icon ? (
<section
className={classes['item-icon']}
>
{item.icon}
</section>
) : null}
<section className={classes.text}>
{item.name}
</section>
</li>
)
}
}
})}
</ul>
<section
className={classes.overlay}
hidden={hidden}
onClick={() => toggleDropdown()}
/>
</section>
</>
)
}
Dropdown.propTypes = {
className: PropTypes.string,
text: PropTypes.string,
menu: PropTypes.array,
onClick: PropTypes.func,
activeColor: PropTypes.object,
activeRule: PropTypes.func,
altText: PropTypes.string,
iconStyle: PropTypes.object,
left: PropTypes.bool,
}

View File

@@ -0,0 +1,48 @@
import { useState } from 'react'
import classes from './scss/popup.module.scss'
import ReturnButton from '@/component/return_button'
import Border from '@/component/border'
import PropTypes from 'prop-types'
export default function Popup(props) {
const [hidden, setHidden] = useState(true)
const toggle = () => {
setHidden(!hidden)
}
return (
<>
<section
className={`${classes.popup} ${hidden ? '' : classes.active}`}
>
<section className={classes.wrapper}>
<section className={classes.title}>
<section className={classes.text}>
{props.title}
</section>
<ReturnButton
onClick={toggle}
className={classes['return-button']}
/>
</section>
<Border />
<section className={classes.content}>
{props.children}
</section>
</section>
<section
className={`${classes.overlay} ${hidden ? '' : classes.active}`}
onClick={() => toggle()}
/>
</section>
<span className={classes['entry-text']} onClick={toggle}>
{props.title}
</span>
</>
)
}
Popup.propTypes = {
title: PropTypes.string,
children: PropTypes.node,
}

View File

@@ -0,0 +1,38 @@
import PropTypes from 'prop-types'
import classes from './scss/return_button.module.scss'
export default function ReturnButton(props) {
return (
<>
<section
className={`${classes['return-button']} ${props.className ? props.className : ''}`}
onClick={() => props.onClick()}
>
<section className={classes.wrapper}>
<section className={classes['arrow-left']}></section>
<section className={classes.bar}></section>
<section className={classes['arrow-right']}></section>
</section>
<section className={classes.wrapper}>
<section className={classes['arrow-left']}></section>
<section className={classes.bar}></section>
<section className={classes['arrow-right']}></section>
</section>
<section className={classes.wrapper}>
<section className={classes['arrow-left']}></section>
<section className={classes.bar}></section>
<section className={classes['arrow-right']}></section>
</section>
<section className={classes.wrapper}>
<section className={classes['arrow-left']}></section>
<section className={classes.bar}></section>
<section className={classes['arrow-right']}></section>
</section>
</section>
</>
)
}
ReturnButton.propTypes = {
onClick: PropTypes.func,
className: PropTypes.string,
}

View File

@@ -0,0 +1,30 @@
.border {
position: relative;
bottom: 1px;
border-bottom: 1px solid var(--text-color);
@media only screen and (width <= 430px) {
& {
margin: 0 1rem;
}
}
&::before,
&::after {
content: '';
display: block;
position: absolute;
width: 5px;
height: 5px;
top: -2px;
background-color: var(--text-color);
}
&::before {
right: 100%;
}
&::after {
left: 100%;
}
}

View File

@@ -0,0 +1,187 @@
.dropdown {
position: relative;
display: inline-block;
user-select: none;
z-index: 2;
padding: 0.5em;
cursor: pointer;
.text {
display: flex;
flex-direction: row;
align-items: center;
color: var(--text-color);
height: 2em;
min-width: 2em;
.popup {
opacity: 0;
position: absolute;
background-color: var(--root-background-color);
width: max-content;
height: max-content;
max-height: 61.8vh;
max-width: 61.8vw;
z-index: -1;
top: 2.5em;
right: 0;
cursor: auto;
transition: all cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
overflow: auto;
padding: 0.5rem;
border: 1px solid var(--border-color);
visibility: hidden;
.text {
font-size: 1rem;
}
}
&:hover {
.popup {
visibility: unset;
opacity: 1;
}
}
}
.content {
padding-right: 1.2em;
height: 1em;
}
.icon {
position: absolute;
bottom: 0.5em;
right: 0.6em;
width: 0.5em;
height: 0.5em;
display: inline-block;
vertical-align: middle;
border-left: 0.15em solid var(--text-color);
border-bottom: 0.15em solid var(--text-color);
border-right: 0.15em solid var(--text-color);
border-top: 0.15em solid var(--text-color);
transform: translateY(-0.7em) rotate(-45deg);
}
.menu {
scrollbar-gutter: stable;
opacity: 0;
position: absolute;
background-color: var(--root-background-color);
width: max-content;
max-height: 61.8vh;
max-width: 61.8vw;
z-index: -1;
top: 2.5em;
right: 0;
display: flex;
align-items: stretch;
flex-flow: column nowrap;
transition: all cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
overflow: auto;
padding: 0.5rem;
border: 1px solid var(--border-color);
visibility: hidden;
color: var(--link-highlight-color);
cursor: auto;
&.left {
left: 0;
right: unset;
}
.date {
font-family:
Bender, 'Noto Sans SC', 'Noto Sans JP', 'Noto Sans KR',
'Noto Sans', sans-serif;
font-weight: bold;
font-size: 1.5rem;
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-between;
width: 100%;
.line {
height: 1px;
flex-grow: 1;
background-color: var(--text-color);
margin: 0.5rem;
}
}
.item {
cursor: pointer;
padding: 0.5rem;
font-size: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
.text {
flex: 1;
transition: color cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
margin-left: 1rem;
}
.item-icon svg {
transition: fill cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
width: 1rem;
fill: var(--text-color);
}
&:hover,
&:focus,
&.active {
.text {
color: currentcolor;
}
.item-icon svg {
fill: currentcolor;
}
}
}
}
&.left {
/* stylelint-disable no-descending-specificity */
.popup,
.menu {
left: 0;
right: unset;
}
}
.overlay {
z-index: -1;
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
cursor: auto;
}
&.active,
&:hover {
.icon {
animation: icon-flash 2s cubic-bezier(0.65, 0.05, 0.36, 1) infinite;
}
}
&.active {
.menu {
visibility: visible;
opacity: 1;
z-index: 2;
}
}
}
@keyframes icon-flash {
50% {
opacity: 0.2;
}
}

View File

@@ -0,0 +1,96 @@
.entry-text {
cursor: pointer;
}
.popup {
position: fixed;
inset: 0;
overflow: hidden;
opacity: 0;
z-index: -1;
border: unset;
transition: all cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 1.2rem;
.wrapper {
display: flex;
flex-flow: column nowrap;
align-items: stretch;
max-width: 480px;
height: fit-content;
margin: 0 auto;
background-color: var(--root-background-color);
border: 1px solid var(--border-color);
padding: 2rem;
}
.title {
font-size: 3rem;
font-weight: 700;
display: flex;
flex-direction: row;
place-content: center space-between;
align-items: center;
text-transform: uppercase;
font-family:
Geometos, 'Noto Sans SC', 'Noto Sans JP', 'Noto Sans KR',
'Noto Sans', sans-serif;
.return-button {
color: var(--button-color);
transition: color cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
&:hover {
color: var(--text-color);
}
}
}
.text {
flex-grow: 1;
margin-right: 3rem;
}
.content {
line-height: 1.3em;
padding: 1rem 1rem 0;
user-select: text;
}
.overlay {
position: absolute;
inset: 0;
z-index: -1;
opacity: 0;
background-color: var(--root-background-color);
transition: all cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
&.active {
opacity: 0.5;
visibility: visible;
}
}
&.active {
opacity: 1;
z-index: 10;
}
@media (width <= 768px) {
.title {
font-size: 2rem;
}
.content {
font-size: 1rem;
}
.return-button {
transform: scale(0.8);
}
}
}

View File

@@ -0,0 +1,55 @@
.return-button {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 0.6rem 0;
width: 3rem;
cursor: pointer;
%arrow-shared {
border-top: 0.24rem solid transparent;
border-bottom: 0.24rem solid transparent;
}
.wrapper {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
&:nth-child(1) {
transform: translateY(-0.1rem) rotate(45deg);
}
&:nth-child(2) {
transform: translateY(-0.1rem) translate(90%, -100%) rotate(-45deg);
}
&:nth-child(3) {
transform: translateY(-0.1rem) translateY(150%) rotate(315deg);
}
&:nth-child(4) {
transform: translateY(-0.1rem) translate(90%, 50%) rotate(225deg);
}
}
.bar {
width: 1rem;
height: 0.4rem;
background-color: currentcolor;
transition: transform cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
}
.arrow-left {
@extend %arrow-shared;
border-right: 0.3rem solid currentcolor;
}
.arrow-right {
@extend %arrow-shared;
border-left: 0.3rem solid currentcolor;
}
}

View File

@@ -0,0 +1,78 @@
.search-box {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: stretch;
margin: 0.5rem;
.icon {
position: absolute;
width: 0.8rem;
height: 0.8rem;
display: inline-block;
vertical-align: middle;
border-left: 0.15rem solid var(--text-color-full);
border-bottom: 0.15rem solid var(--text-color-full);
border-right: 0.15rem solid var(--text-color-full);
border-top: 0.15rem solid var(--text-color-full);
transform: translate(0.2rem, 0.3rem) rotate(-45deg);
}
.icon-dot {
position: absolute;
background-color: var(--text-color-full);
width: 0.15rem;
height: 0.6rem;
transform: translate(1.2rem, 1.1rem) rotate(-45deg);
}
.input {
flex-grow: 1;
font-size: 1.5rem;
width: 100%;
margin-left: 2rem;
padding-right: 2rem;
background-color: transparent;
border: unset;
border-bottom: 0.15em solid var(--home-item-outline-color);
color: var(--text-color);
transition: all cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
}
.input:focus,
.input:hover {
outline: none;
border-bottom: 0.15em solid var(--text-color);
}
.icon-clear {
position: absolute;
right: 1rem;
width: 2rem;
height: 2rem;
cursor: pointer;
opacity: 0;
transition: all cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
visibility: hidden;
&.active {
opacity: 1;
visibility: unset;
}
.line {
position: absolute;
width: 2rem;
height: 0.2rem;
background-color: var(--text-color);
&:nth-child(1) {
transform: translate(0, 0.8rem) rotate(45deg);
}
&:nth-child(2) {
transform: translate(0, 0.8rem) rotate(-45deg);
}
}
}
}

View File

@@ -0,0 +1,64 @@
.switch {
position: relative;
user-select: none;
z-index: 2;
padding: 8px 36px 8px 8px;
cursor: pointer;
display: flex;
flex-direction: row;
align-items: center;
color: var(--secondary-text-color);
transition: color cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
.content {
padding-right: 8px;
}
.wrapper {
color: var(--secondary-text-color);
transition: all cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
.icon {
position: absolute;
bottom: 0.65em;
right: 18px;
width: 0.5em;
height: 0.5em;
display: inline-block;
vertical-align: middle;
border-left: 0.15em solid currentcolor;
border-bottom: 0.15em solid currentcolor;
border-right: 0.15em solid currentcolor;
border-top: 0.15em solid currentcolor;
transform: translate(0, -0.15em) rotate(-45deg);
transition:
right cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s,
background-color cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
}
.line {
position: absolute;
bottom: 1.1em;
right: 6px;
width: 18px;
height: 0.15em;
display: inline-block;
vertical-align: middle;
background-color: currentcolor;
z-index: -1;
}
}
&.active {
color: var(--text-color);
.wrapper {
color: var(--text-color-full);
.icon {
background-color: currentcolor;
right: 0;
}
}
}
}

View File

@@ -0,0 +1,43 @@
.totop-button {
position: fixed;
user-select: none;
z-index: 2;
cursor: pointer;
right: 2rem;
bottom: 1rem;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
width: 3rem;
height: 3rem;
opacity: 0;
visibility: hidden;
transition: opacity cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
&.show {
opacity: 1;
visibility: unset;
}
.bar {
position: absolute;
width: 100%;
height: 0.3rem;
background-color: var(--text-color-full);
will-change: auto;
}
.bar:nth-child(1) {
transform: rotateZ(45deg) scaleX(0.5) translateX(45%);
}
.bar:nth-child(2) {
transform: rotateZ(-45deg) scaleX(0.5) translateX(-45%);
}
.bar:nth-child(3),
.bar:nth-child(4) {
transform: translateY(450%) rotateZ(90deg) scaleX(0.5);
}
}

View File

@@ -0,0 +1,50 @@
import { useState } from 'react'
import PropTypes from 'prop-types'
import classes from './scss/search_box.module.scss'
import { useI18n } from '@/state/language'
export default function SearchBox(props) {
const { i18n } = useI18n()
const [searchField, setSearchField] = useState('')
const filterBySearch = (event) => {
const query = event.target.value
props.handleOnChange(query)
setSearchField(query)
}
return (
<>
<section
className={`${classes['search-box']} ${props.className ? props.className : ''}`}
>
<section className={classes.icon} />
<section className={classes['icon-dot']} />
<input
type="text"
className={classes.input}
placeholder={i18n(props.altText)}
onChange={filterBySearch}
value={searchField}
/>
<section
className={`${classes['icon-clear']} ${searchField === '' ? '' : classes.active}`}
onClick={() => {
setSearchField('')
props.handleOnChange('')
}}
>
<section className={classes.line} />
<section className={classes.line} />
</section>
</section>
</>
)
}
SearchBox.propTypes = {
className: PropTypes.string,
text: PropTypes.string,
altText: PropTypes.string,
handleOnChange: PropTypes.func,
searchField: PropTypes.string,
}

View File

@@ -0,0 +1,35 @@
import { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import classes from './scss/switch.module.scss'
import { useI18n } from '@/state/language'
export default function Switch(props) {
const [on, setOn] = useState(props.on)
const { i18n } = useI18n()
useEffect(() => {
setOn(props.on)
}, [props.on])
return (
<section
className={`${classes.switch} ${on ? classes.active : ''}`}
onClick={() => {
if (props.handleOnClick) {
props.handleOnClick(!on)
}
}}
>
<span className={classes.text}>{i18n(props.text)}</span>
<section className={classes.wrapper}>
<span className={classes.line}></span>
<span className={classes.icon}></span>
</section>
</section>
)
}
Switch.propTypes = {
on: PropTypes.bool,
text: PropTypes.string,
handleOnClick: PropTypes.func,
}

View File

@@ -0,0 +1,64 @@
import { useEffect, useState, useCallback } from 'react'
import PropTypes from 'prop-types'
import classes from './scss/totop_button.module.scss'
export default function ToTopButton(props) {
const [hidden, setHidden] = useState(true)
useEffect(() => {
const handleButton = () => {
const scrollBarPos = window.scrollY || 0
setHidden(!(scrollBarPos > 100))
}
window.addEventListener('scroll', handleButton)
return () => {
window.removeEventListener('scroll', handleButton)
}
}, [])
const smoothScroll = useCallback((target) => {
const targetElement = document.querySelector(target)
const targetPosition =
targetElement.getBoundingClientRect().top + window.scrollY
const startPosition = window.scrollY
const distance = targetPosition - startPosition
const duration = 1000
let start = null
window.requestAnimationFrame(step)
function step(timestamp) {
if (!start) start = timestamp
const progress = timestamp - start
window.scrollTo(
0,
easeInOutCubic(progress, startPosition, distance, duration)
)
if (progress < duration) window.requestAnimationFrame(step)
}
function easeInOutCubic(t, b, c, d) {
t /= d / 2
if (t < 1) return (c / 2) * t * t * t + b
t -= 2
return (c / 2) * (t * t * t + 2) + b
}
}, [])
return (
<>
<section
className={`${classes['totop-button']} ${hidden ? '' : classes.show} ${props.className ? props.className : ''}`}
onClick={() => {
smoothScroll('#root')
}}
>
<section className={classes.bar}></section>
<section className={classes.bar}></section>
<section className={classes.bar}></section>
<section className={classes.bar}></section>
</section>
</>
)
}
ToTopButton.propTypes = {
onClick: PropTypes.func,
className: PropTypes.string,
}

View File

@@ -0,0 +1,46 @@
import { useEffect, useRef } from 'react'
import PropTypes from 'prop-types'
export default function VoiceElement({ src, replay, handleAduioStateChange }) {
const audioRef = useRef(null)
useEffect(() => {
if (src) {
audioRef.current.src = src
audioRef.current.play()
} else {
audioRef.current.pause()
}
}, [src])
useEffect(() => {
if (replay) {
audioRef.current.currentTime = 0
audioRef.current.play()
}
}, [replay])
return (
<audio
ref={audioRef}
preload="auto"
autoPlay
onEnded={(e) => {
if (handleAduioStateChange) handleAduioStateChange(e, 'ended')
}}
onPlay={(e) => {
if (handleAduioStateChange) handleAduioStateChange(e, 'play')
}}
onPause={(e) => {
if (handleAduioStateChange) handleAduioStateChange(e, 'pause')
}}
>
<source type="audio/ogg" />
</audio>
)
}
VoiceElement.propTypes = {
src: PropTypes.string,
handleAduioStateChange: PropTypes.func,
replay: PropTypes.bool,
}

View File

@@ -0,0 +1,161 @@
{
"available": ["zh-CN", "en-US"],
"key": {
"dynamic_compile": {
"zh-CN": "动态集录",
"en-US": "Dynamic Compile"
},
"home": {
"zh-CN": "首页",
"en-US": "Home"
},
"official_page": {
"zh-CN": "官方页面",
"en-US": "Official Page"
},
"disclaimer": {
"zh-CN": "免责声明",
"en-US": "Disclaimer"
},
"disclaimer_content": {
"zh-CN": "本网站由 Halyul 设立并为明日方舟社区服务Halyul 声明本网站完全独立运营,与 上海鹰角网络科技有限公司, Esoteric Software LLC 或其任何关联实体并无任何联系。",
"en-US": "This website is set up and operated by Halyul for the benefit of the Arknights Community. Halyul hereby states that this website is dedicated, but not related to Hypergryph Co., Ltd, Esoteric Software LLC or any of its affiliated entity."
},
"privacy_policy": {
"zh-CN": "隐私政策",
"en-US": "Privacy Policy"
},
"contact_us": {
"zh-CN": "联系我们",
"en-US": "Contact Us"
},
"all": {
"zh-CN": "综合",
"en-US": "All"
},
"operator": {
"zh-CN": "精英2",
"en-US": "Elite 2"
},
"skin": {
"zh-CN": "时装",
"en-US": "Skin"
},
"voice": {
"zh-CN": "语音",
"en-US": "Voice"
},
"music": {
"zh-CN": "音乐",
"en-US": "Music"
},
"showcase": {
"zh-CN": "壁纸",
"en-US": "Wallpaper"
},
"directory": {
"zh-CN": "目录页",
"en-US": "Directory Page"
},
"animation": {
"zh-CN": "动画",
"en-US": "Animation"
},
"backgrounds": {
"zh-CN": "背景",
"en-US": "Backgrounds"
},
"CN_MANDARIN": {
"zh-CN": "普通话",
"en-US": "Mandarin"
},
"JP": {
"zh-CN": "日语",
"en-US": "Japanese"
},
"KR": {
"zh-CN": "韩语",
"en-US": "Korean"
},
"EN": {
"zh-CN": "英语",
"en-US": "English"
},
"ITA": {
"zh-CN": "意大利语",
"en-US": "Italian"
},
"CN_TOPOLECT": {
"zh-CN": "中文方言",
"en-US": "Chinese Topolect"
},
"steam_workshop": {
"zh-CN": "壁纸引擎版",
"en-US": "Wallpaper Engine Version"
},
"external_links": {
"zh-CN": "外部链接",
"en-US": "External Links"
},
"web_version": {
"zh-CN": "全功能网页版",
"en-US": "Full Feature Web Version"
},
"idle": {
"zh-CN": "待机",
"en-US": "Idle"
},
"interact": {
"zh-CN": "交互",
"en-US": "Interact"
},
"special": {
"zh-CN": "特殊",
"en-US": "Special"
},
"subtitle": {
"zh-CN": "字幕",
"en-US": "Subtitle"
},
"zh-CN": {
"zh-CN": "简体中文",
"en-US": "Chinese (Simplified)"
},
"en-US": {
"zh-CN": "英语",
"en-US": "English"
},
"zh-TW": {
"zh-CN": "繁体中文",
"en-US": "Chinese (Traditional)"
},
"ja-JP": {
"zh-CN": "日语",
"en-US": "Japanese"
},
"ko-KR": {
"zh-CN": "韩语",
"en-US": "Korean"
},
"switch_language": {
"zh-CN": "🌐 切换语言",
"en-US": "🌐 Switch Language"
},
"fast_navigation": {
"zh-CN": "🧭 快速导航",
"en-US": "🧭 Fast Navigation"
},
"return": {
"zh-CN": "↩️ 返回",
"en-US": "↩️ Return"
},
"search_by_name": {
"zh-CN": "名字搜索",
"en-US": "Search by Name"
},
"new_op_wait_to_update": {
"zh-CN": "个新干员等待更新",
"en-US": "New Operator(s) Waiting to Update"
}
}
}

View File

@@ -0,0 +1,197 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import { useNavigate, useRouteError } from 'react-router-dom'
import header from '@/scss/root/header.module.scss'
import classes from '@/scss/error/Error.module.scss'
import { useAtom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
import Switch from '@/component/switch'
import ReturnButton from '@/component/return_button'
import { Typewriter } from 'react-simple-typewriter'
import { useHeader } from '@/state/header'
import VoiceElement from '@/component/voice'
import { spine } from '@aklive2d/module'
import useInsight from '@/state/insight'
import buildConfig from '!/config.json'
const voiceOnAtom = atomWithStorage('voiceOn', false)
const config = buildConfig.error_files
const obj = config.files[Math.floor(Math.random() * config.files.length)]
const filename = obj.key.replace(/#/g, '%23')
const padding = obj.paddings
let lastVoiceState = 'ended'
export default function Error() {
// eslint-disable-next-line no-unused-vars
const _trackEvt = useInsight()
const error = useRouteError()
const navigate = useNavigate()
const { setTitle } = useHeader()
const [voiceOn, setVoiceOn] = useAtom(voiceOnAtom)
const [spineDone, _setSpineDone] = useState(false)
const spineRef = useRef(null)
const spineDoneRef = useRef(spineDone)
const voiceOnRef = useRef(voiceOn)
const [voiceSrc, setVoiceSrc] = useState(null)
const [voiceReplay, setVoiceReplay] = useState(false)
const [spinePlayer, setSpinePlayer] = useState(null)
const setSpineDone = (data) => {
spineDoneRef.current = data
_setSpineDone(data)
}
const content = useMemo(
() => [
'エラー発生。',
'发生错误。',
'Error occured.',
'에러 발생.',
'發生錯誤。',
],
[]
)
useEffect(() => {
console.log(error)
}, [error])
useEffect(() => {
setTitle(content[0])
}, [content, setTitle])
useEffect(() => {
if (!voiceOn) {
setVoiceSrc(null)
} else {
setVoiceSrc(`/${buildConfig.directory_folder}/error.ogg`)
if (spinePlayer) {
spinePlayer.animationState.setAnimation(0, 'Interact', false, 0)
spinePlayer.animationState.addAnimation(0, 'Relax', true, 0)
}
}
}, [spinePlayer, voiceOn])
useEffect(() => {
voiceOnRef.current = voiceOn
}, [voiceOn])
const playVoice = useCallback(() => {
if (lastVoiceState === 'ended' && voiceSrc !== null) {
setVoiceReplay(true)
}
}, [voiceSrc])
const handleAduioStateChange = useCallback((e, state) => {
lastVoiceState = state
if (state === 'ended') {
setVoiceReplay(false)
}
}, [])
useEffect(() => {
if (spineRef.current?.children.length === 0) {
setSpinePlayer(
new spine.SpinePlayer(spineRef.current, {
skelUrl: `./_assets/${filename}.skel`,
atlasUrl: `./_assets/${filename}.atlas`,
animation: 'Relax',
premultipliedAlpha: true,
alpha: true,
backgroundColor: '#00000000',
viewport: {
debugRender: false,
padLeft: `${padding.left}%`,
padRight: `${padding.right}%`,
padTop: `${padding.top}%`,
padBottom: `${padding.bottom}%`,
x: 0,
y: 0,
},
showControls: false,
touch: false,
fps: 60,
defaultMix: 0.3,
success: (player) => {
let isPlayingInteract = false
player.animationState.addListener({
end: (e) => {
if (e.animation.name == 'Interact') {
isPlayingInteract = false
}
},
})
setSpineDone(true)
const ani = () => {
if (isPlayingInteract) {
return
}
isPlayingInteract = true
player.animationState.setAnimation(
0,
'Interact',
false,
0
)
player.animationState.addAnimation(
0,
'Relax',
true,
0
)
if (voiceOnRef.current) playVoice()
}
ani()
player.canvas.onclick = () => {
ani()
}
player.canvas.onmouseenter = () => {
ani()
}
},
})
)
}
return () => {
if (spinePlayer) {
spinePlayer.dispose()
}
}
}, [playVoice, spinePlayer])
return (
<section className={classes.error}>
<header className={`${header.header} ${classes.header}`}>
<ReturnButton onClick={() => navigate(-1, { replace: true })} />
<Switch
key="voice"
text="voice"
on={voiceOn}
handleOnClick={() => setVoiceOn(!voiceOn)}
/>
</header>
<main className={classes.main}>
{content.map((item, index) => {
return (
<section key={index} className={classes.content}>
<Typewriter
words={[item]}
cursor
cursorStyle="|"
typeSpeed={100}
/>
</section>
)
})}
<section
className={`${classes.spine} ${spineDone ? classes.active : ''}`}
ref={spineRef}
/>
<VoiceElement
src={voiceSrc}
replay={voiceReplay}
handleAduioStateChange={handleAduioStateChange}
/>
</main>
</section>
)
}

View File

@@ -0,0 +1,307 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import PropTypes from 'prop-types'
import {
Outlet,
Link,
NavLink,
useNavigate,
ScrollRestoration,
} from 'react-router-dom'
import classes from '@/scss/root/Root.module.scss'
import header from '@/scss/root/header.module.scss'
import footer from '@/scss/root/footer.module.scss'
import drawer from '@/scss/root/drawer.module.scss'
import routes from '@/routes'
import { useConfig } from '@/state/config'
import { useHeader } from '@/state/header'
import { useAppbar } from '@/state/appbar'
import { useI18n, useLanguage } from '@/state/language'
import Dropdown from '@/component/dropdown'
import Popup from '@/component/popup'
import Border from '@/component/border'
import CharIcon from '@/component/char_icon'
import ToTopButton from '@/component/totop_button'
const currentYear = new Date().getFullYear()
export default function Root() {
const [drawerHidden, setDrawerHidden] = useState(true)
const { title, tabs, setCurrentTab, headerIcon } = useHeader()
const { extraArea } = useAppbar()
const { fetchOfficialUpdate } = useConfig()
const headerTabs = useMemo(() => {
return tabs?.map((item) => {
return <HeaderTabsElement key={item.key} item={item} />
})
}, [tabs])
const toggleDrawer = useCallback(
(value) => {
setDrawerHidden(value || !drawerHidden)
},
[drawerHidden]
)
useEffect(() => {
if (tabs.length > 0) {
setCurrentTab(tabs[0].key)
} else {
setCurrentTab(null)
}
}, [setCurrentTab, tabs])
useEffect(() => {
fetchOfficialUpdate()
}, [fetchOfficialUpdate])
useEffect(() => {
document.querySelector('.loader').classList.add('loaded')
setTimeout(() => {
document.querySelector('.loader').style.display = 'none'
}, 500)
}, [])
return (
<>
<header className={header.header}>
<section
className={`${header['nav-button']} ${drawerHidden ? '' : header.active}`}
onClick={() => toggleDrawer()}
>
<section className={header.bar} />
<section className={header.bar} />
<section className={header.bar} />
</section>
<HeaderButton />
<section className={header.spacer} />
<section className={header['extra-area']}>
{extraArea}
<LanguageDropdown />
</section>
</header>
<nav
className={`${drawer.drawer} ${drawerHidden ? '' : drawer.active}`}
>
<section className={drawer.links}>
<DrawerDestinations toggleDrawer={toggleDrawer} />
</section>
<section
className={`${drawer.overlay} ${drawerHidden ? '' : drawer.active}`}
onClick={() => toggleDrawer()}
/>
</nav>
<main className={classes.main}>
<section className={classes.header}>
<section className={classes.title}>
{headerIcon && (
<section className={classes.icon}>
<CharIcon
type={headerIcon}
viewBox={
headerIcon === 'operator'
? '0 0 88.969 71.469'
: '0 0 94.563 67.437'
}
/>
</section>
)}
{title}
</section>
<section className={classes.tab}>{headerTabs}</section>
</section>
<Border />
<ToTopButton />
<Outlet />
<ScrollRestoration />
</main>
<FooterElement />
</>
)
}
function FooterElement() {
const { i18n } = useI18n()
const navigate = useNavigate()
return useMemo(() => {
return (
<footer className={footer.footer}>
<section className={`${footer.links} ${footer.section}`}>
<section className={footer.item}>
<Popup
className={footer.link}
title={i18n('disclaimer')}
>
{i18n('disclaimer_content')}
</Popup>
</section>
<section className={footer.item}>
<Link
reloadDocument
to="https://gura.ch/pp"
target="_blank"
className={footer.link}
>
{i18n('privacy_policy')}
</Link>
</section>
<section className={footer.item}>
<Link
reloadDocument
to="https://gura.ch/aklive2d-gh"
target="_blank"
className={footer.link}
>
GitHub
</Link>
</section>
<section className={footer.item}>
<Popup
className={footer.link}
title={i18n('contact_us')}
>
ak#halyul.dev
</Popup>
</section>
</section>
<section
className={`${footer.copyright} ${footer.section}`}
onDoubleClick={() => {
navigate('/error')
}}
>
<span>
Spine Runtimes © 2013 - 2019 Esoteric Software LLC
</span>
<span>
Assets © 2017 - {currentYear} Arknights/Hypergryph Co.,
Ltd
</span>
<span>Source Code © 2021 - {currentYear} Halyul</span>
</section>
</footer>
)
}, [i18n, navigate])
}
function DrawerDestinations({ toggleDrawer }) {
const { i18n } = useI18n()
const { textDefaultLang, alternateLang } = useLanguage()
return routes
.filter((item) => item.inDrawer)
.map((item) => {
if (typeof item.element.type === 'string') {
return (
<Link
reloadDocument
key={item.name}
to={item.path}
target="_blank"
className={drawer.link}
onClick={() => toggleDrawer(false)}
>
<section>{i18n(item.name, textDefaultLang)}</section>
<section>{i18n(item.name, alternateLang)}</section>
</Link>
)
} else {
return (
<NavLink
to={item.path}
key={item.name}
className={({ isActive }) =>
`${drawer.link} ${isActive ? drawer.active : ''}`
}
onClick={() => toggleDrawer(false)}
>
<section>{i18n(item.name, textDefaultLang)}</section>
<section>{i18n(item.name, alternateLang)}</section>
</NavLink>
)
}
})
}
function LanguageDropdown() {
const { language, setLanguage } = useLanguage()
const { i18n, i18nValues } = useI18n()
return useMemo(() => {
return (
<Dropdown
text={i18n(language)}
altText={i18n('switch_language')}
menu={i18nValues.available.map((item) => {
return {
name: i18n(item),
value: item,
}
})}
onClick={(item) => {
setLanguage(item.value)
}}
/>
)
}, [i18n, i18nValues.available, language, setLanguage])
}
function HeaderTabsElement({ item }) {
const { currentTab, setCurrentTab } = useHeader()
const { i18n } = useI18n()
return (
<section
className={`${classes.item} ${currentTab === item.key ? classes.active : ''}`}
onClick={(e) => {
setCurrentTab(item.key)
item.onClick && item.onClick(e, currentTab)
}}
style={item.style}
>
<section className={classes['text-wrapper']}>
<span>{i18n(item.key)}</span>
</section>
</section>
)
}
HeaderTabsElement.propTypes = {
item: PropTypes.object.isRequired,
}
function HeaderButton() {
const navigate = useNavigate()
const { i18n } = useI18n()
const { fastNavigation } = useHeader()
if (fastNavigation.length > 0) {
return (
<Dropdown
menu={fastNavigation}
altText={i18n('fast_navigation')}
onClick={(item) => {
navigate(item.value)
}}
className={header['fast-navigate']}
iconStyle={{
borderWidth: '0.15em',
width: '1em',
transform:
'translateY(-0.4rem) translateX(-0.7rem) rotate(-45deg)',
height: '1em',
}}
left={true}
/>
)
} else {
return (
<section className={header['back-arrow']}>
<Link to="/" className={header.link}>
<section className={header.arrow1} />
<section className={header.arrow2} />
</Link>
</section>
)
}
}

View File

@@ -0,0 +1,29 @@
import Home from '@/routes/path/Home'
import Operator from '@/routes/path/Operator'
export default [
{
path: '/',
index: true,
name: 'home',
element: <Home />,
inDrawer: true,
routeable: true,
},
{
path: 'https://gura.ch/dynamicCompile',
index: false,
name: 'official_page',
element: <a />,
inDrawer: true,
routeable: false,
},
{
path: ':key',
index: false,
name: 'operator',
element: <Operator />,
inDrawer: false,
routeable: true,
},
]

View File

@@ -0,0 +1,451 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import PropTypes from 'prop-types'
import { NavLink, Link } from 'react-router-dom'
import classes from '@/scss/home/Home.module.scss'
import { useConfig } from '@/state/config'
import { useI18n } from '@/state/language'
import { useLanguage } from '@/state/language'
import { useHeader } from '@/state/header'
import { useAppbar } from '@/state/appbar'
import VoiceElement from '@/component/voice'
import { useAtom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
import CharIcon from '@/component/char_icon'
import Border from '@/component/border'
import useInsight from '@/state/insight'
import Switch from '@/component/switch'
import SearchBox from '@/component/search_box'
import buildConfig from '!/config.json'
const voiceOnAtom = atomWithStorage('voiceOn', false)
let lastVoiceState = 'ended'
export default function Home() {
// eslint-disable-next-line no-unused-vars
const _trackEvt = useInsight()
const { setTitle, setTabs, currentTab, setHeaderIcon, setFastNavigation } =
useHeader()
const { config, operators, officialUpdate } = useConfig()
const { i18n } = useI18n()
const [content, setContent] = useState([])
const [voiceOn] = useAtom(voiceOnAtom)
const [voiceSrc, setVoiceSrc] = useState(null)
const [voiceReplay, setVoiceReplay] = useState(false)
const { language } = useLanguage()
const [navigationList, setNavigationList] = useState([])
const [searchField, setSearchField] = useState('')
const [updatedList, setUpdatedList] = useState([])
useEffect(() => {
setTitle('dynamic_compile')
setTabs([
{
key: 'all',
},
{
key: 'operator',
},
{
key: 'skin',
},
])
setHeaderIcon(null)
}, [setHeaderIcon, setTabs, setTitle])
useEffect(() => {
setContent(config?.operators || [])
}, [config])
const handleAduioStateChange = useCallback((e, state) => {
lastVoiceState = state
if (state === 'ended') {
setVoiceReplay(false)
}
}, [])
const isShown = useCallback(
(type) => currentTab === 'all' || currentTab === type,
[currentTab]
)
const fastNavigateDict = useMemo(() => {
const dict = {}
operators.forEach((item) => {
if (!(item.date in dict)) {
dict[item.date] = []
}
dict[item.date].push({
codename: item.codename,
link: item.link,
type: item.type,
color: item.color,
})
})
return dict
}, [operators])
useEffect(() => {
const list = []
for (const [key, value] of Object.entries(fastNavigateDict)) {
const newValue = value.filter((item) => isShown(item.type))
if (newValue.length > 0) {
list.push({
name: key,
value: null,
type: 'date',
})
newValue.forEach((item) => {
list.push({
name: item.codename[language],
value: item.link,
type: 'item',
color: item.color,
icon: (
<CharIcon
type={item.type}
viewBox={
item.type === 'operator'
? '0 0 88.969 71.469'
: '0 0 94.563 67.437'
}
/>
),
})
})
}
}
setNavigationList(list)
setUpdatedList(list)
}, [fastNavigateDict, isShown, language])
useEffect(() => {
const list = navigationList.filter((item) => {
return (
item.name.toLowerCase().indexOf(searchField.toLowerCase()) !==
-1 || item.type === 'date'
)
})
const newList = []
for (let i = 0; i < list.length - 1; i++) {
const firstType = list[i].type
const secondType = list[i + 1].type
if (firstType === 'date' && secondType === 'date') {
continue
}
newList.push(list[i])
}
if (list.length > 0 && list[list.length - 1].type !== 'date') {
newList.push(list[list.length - 1])
}
setUpdatedList(newList)
}, [navigationList, searchField])
useEffect(() => {
setFastNavigation([
{
type: 'custom',
component: (
<SearchBox
key="search-box"
altText={'search_by_name'}
handleOnChange={(e) => {
setSearchField(e)
}}
searchField={searchField}
/>
),
},
...updatedList,
])
}, [searchField, setFastNavigation, updatedList])
const handleVoicePlay = useCallback(
(src) => {
if (!voiceOn) {
setVoiceSrc(null)
} else {
if (src === voiceSrc && lastVoiceState === 'ended') {
setVoiceReplay(true)
} else {
setVoiceSrc(src)
}
}
},
[voiceOn, voiceSrc]
)
return (
<section>
{officialUpdate.length > operators.length && (
<section>
<section
className={`${classes['official-update']} ${classes.group}`}
>
<section className={classes.info}>
<section className={classes.content}>
<section className={classes.text}>
{officialUpdate.length - operators.length}{' '}
{i18n('new_op_wait_to_update')}
</section>
<section
className={`${classes['styled-selection']}`}
>
{officialUpdate.dates
.reduce((acc, cur) => {
const op = officialUpdate[cur]
return [...acc, ...op]
}, [])
.slice(
0,
officialUpdate.length -
operators.length
)
.map((entry, index) => {
return (
<Link
reloadDocument
to={entry.link}
target="_blank"
style={{
color: entry.color,
}}
key={index}
>
<section
className={
classes.content
}
>
<section
className={
classes.option
}
>
<section
className={
classes.outline
}
/>
<section
className={`${classes.text} ${classes.container}`}
>
<section
className={
classes.type
}
>
<CharIcon
type={
entry.type
}
viewBox={
entry.type ===
'operator'
? '0 0 88.969 71.469'
: '0 0 94.563 67.437'
}
/>
</section>
<section
className={
classes.title
}
>
{
entry
.codename[
language
]
}
</section>
<section
className={
classes[
'arrow-icon'
]
}
>
<section
className={
classes.bar
}
></section>
<section
className={
classes.bar
}
></section>
<section
className={
classes.bar
}
></section>
<section
className={
classes.bar
}
></section>
</section>
</section>
</section>
</section>
</Link>
)
})}
</section>
</section>
</section>
<section className={classes.date}>
{officialUpdate.latest}
</section>
</section>
<Border />
</section>
)}
{content.map((v) => {
const length = v.filter((v) => isShown(v.type)).length
return (
<section key={v[0].date} hidden={length === 0}>
<section className={classes.group}>
<section className={classes['operator-group']}>
{v.map((item) => {
return (
<OperatorElement
key={item.link}
item={item}
hidden={!isShown(item.type)}
handleVoicePlay={handleVoicePlay}
/>
)
})}
</section>
<section className={classes.date}>
{v[0].date}
</section>
</section>
<Border />
</section>
)
})}
<VoiceSwitchElement
src={voiceSrc}
handleAduioStateChange={handleAduioStateChange}
replay={voiceReplay}
/>
</section>
)
}
function OperatorElement({ item, hidden, handleVoicePlay }) {
const { textDefaultLang, language, alternateLang } = useLanguage()
return useMemo(() => {
return (
<NavLink
to={`/${item.link}`}
className={classes.item}
hidden={hidden}
>
<section
onMouseEnter={() =>
handleVoicePlay(
`/${item.link}/assets/${buildConfig.voice_folders.main}/${buildConfig.app_voice_url}`
)
}
>
<section className={classes['background-filler']} />
<section className={classes.outline} />
<section className={classes.img}>
<ImageElement item={item} />
</section>
<section className={classes.info}>
<section className={classes.container}>
<section className={classes.title}>
{item.codename[language]}
</section>
<section className={classes.type}>
<CharIcon
type={item.type}
viewBox={
item.type === 'operator'
? '0 0 88.969 71.469'
: '0 0 94.563 67.437'
}
/>
</section>
</section>
<section className={classes.wrapper}>
<span className={classes.text}>
{
item.codename[
language.startsWith('en')
? alternateLang
: textDefaultLang
]
}
</span>
</section>
<section
className={classes.background}
style={{
color: item.color,
}}
/>
</section>
</section>
</NavLink>
)
}, [
item,
hidden,
language,
alternateLang,
textDefaultLang,
handleVoicePlay,
])
}
function VoiceSwitchElement({ src, replay, handleAduioStateChange }) {
const [voiceOn, setVoiceOn] = useAtom(voiceOnAtom)
const { setExtraArea } = useAppbar()
useEffect(() => {
setExtraArea([
<Switch
key="voice"
text="voice"
on={voiceOn}
handleOnClick={() => setVoiceOn(!voiceOn)}
/>,
])
}, [voiceOn, setExtraArea, setVoiceOn])
return (
<VoiceElement
src={src}
replay={replay}
handleAduioStateChange={handleAduioStateChange}
/>
)
}
VoiceSwitchElement.propTypes = {
src: PropTypes.string,
replay: PropTypes.bool,
handleAduioStateChange: PropTypes.func,
}
function ImageElement({ item }) {
const { language } = useLanguage()
return (
<img
src={`/${buildConfig.directory_folder}/${item.fallback_name.replace(/#/g, '%23')}_portrait.png`}
alt={item.codename[language]}
/>
)
}
ImageElement.propTypes = {
item: PropTypes.object.isRequired,
fallback_name: PropTypes.string,
codename: PropTypes.object,
}

View File

@@ -0,0 +1,694 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import classes from '@/scss/operator/Operator.module.scss'
import { useConfig } from '@/state/config'
import { useLanguage } from '@/state/language'
import { useHeader } from '@/state/header'
import { useAppbar } from '@/state/appbar'
import VoiceElement from '@/component/voice'
import useInsight from '@/state/insight'
import { spine } from '@aklive2d/module'
import Border from '@/component/border'
import { useI18n } from '@/state/language'
import Switch from '@/component/switch'
import { atom, useAtom } from 'jotai'
import buildConfig from '!/config.json'
const musicMapping = buildConfig.music_mapping
const getVoiceFoler = (lang) => {
const folderObject = buildConfig.voice_folders
const voiceFolder =
folderObject.sub.find((e) => e.lang === lang) ||
folderObject.sub.find((e) => e.name === 'custom')
return `${folderObject.main}/${voiceFolder.name}`
}
const defaultSpineAnimationName = 'Idle'
const backgroundAtom = atom(buildConfig.default_background)
const getPartialName = (type, input) => {
let part
switch (type) {
case 'name':
part = 5
break
case 'skin':
part = 1
break
default:
return input
}
return input.replace(/^(.+)( )(·|\/)( )(.+)$/, `$${part}`)
}
const getTabName = (item, language) => {
if (item.type === 'operator') {
return 'operator'
} else {
return getPartialName('skin', item.codename[language])
}
}
export default function Operator() {
const navigate = useNavigate()
const { operators } = useConfig()
const { language } = useLanguage()
const { key } = useParams()
const { setTitle, setTabs, setHeaderIcon, setFastNavigation } = useHeader()
const { setExtraArea } = useAppbar()
const [config, setConfig] = useState(null)
// eslint-disable-next-line no-unused-vars
const _trackEvt = useInsight(`/${key}`)
const spineRef = useRef(null)
const [spineAnimationName, setSpineAnimationName] = useState(
defaultSpineAnimationName
)
const { i18n } = useI18n()
const [spinePlayer, setSpinePlayer] = useState(null)
const [voiceLang, _setVoiceLang] = useState(null)
const [currentBackground, setCurrentBackground] = useAtom(backgroundAtom)
const [voiceConfig, setVoiceConfig] = useState(null)
const [subtitleLang, setSubtitleLang] = useState(null)
const [hideSubtitle, setHideSubtitle] = useState(true)
const [subtitleObj, _setSubtitleObj] = useState(null)
const [currentVoiceId, setCurrentVoiceId] = useState(null)
const voiceLangRef = useRef(voiceLang)
const subtitleObjRef = useRef(subtitleObj)
const configRef = useRef(config)
const [voiceSrc, setVoiceSrc] = useState(null)
const [isVoicePlaying, _setIsVoicePlaying] = useState(false)
const isVoicePlayingRef = useRef(isVoicePlaying)
const setVoiceLang = (value) => {
voiceLangRef.current = value
_setVoiceLang(value)
}
const setSubtitleObj = (value) => {
subtitleObjRef.current = value
_setSubtitleObj(value)
}
const setIsVoicePlaying = (value) => {
isVoicePlayingRef.current = value
_setIsVoicePlaying(value)
}
useEffect(() => {
setExtraArea([])
setFastNavigation([])
}, [setExtraArea, setFastNavigation])
useEffect(() => {
const config = operators.find((item) => item.link === key)
if (config) {
setConfig(config)
configRef.current = config
setSpineAnimationName(defaultSpineAnimationName)
setHeaderIcon(config.type)
if (spineRef.current?.children.length > 0) {
spineRef.current?.removeChild(spineRef.current?.children[0])
}
fetch(`/${key}/assets/charword_table.json`)
.then((res) => res.json())
.then((data) => {
setVoiceConfig(data)
})
}
}, [key, operators, setHeaderIcon])
const coverToTab = useCallback(
(item, language) => {
const key = getTabName(item, language)
return {
key: key,
style: {
color: item.color,
},
onClick: (e, tab) => {
if (tab === key) return
navigate(`/${item.link}`)
},
}
},
[navigate]
)
const otherEntries = useMemo(() => {
if (!config || !language) return null
return operators
.filter(
(item) => item.id === config.id && item.link !== config.link
)
.map((item) => {
return coverToTab(item, language)
})
}, [config, language, operators, coverToTab])
useEffect(() => {
if (config) {
setTabs([coverToTab(config, language), ...otherEntries])
}
}, [config, key, coverToTab, setTabs, otherEntries, language])
useEffect(() => {
if (config) {
setTitle(getPartialName('name', config.codename[language]))
}
}, [config, language, key, setTitle])
useEffect(() => {
if (spineRef.current?.children.length === 0 && configRef.current) {
const playerConfig = {
atlasUrl: `./${key}/assets/${configRef.current.filename.replace(/#/g, '%23')}.atlas`,
animation: spineAnimationName,
premultipliedAlpha: true,
alpha: true,
backgroundColor: '#00000000',
viewport: {
debugRender: false,
padLeft: `${configRef.current.viewport_left}%`,
padRight: `${configRef.current.viewport_right}%`,
padTop: `${configRef.current.viewport_top}%`,
padBottom: `${configRef.current.viewport_bottom}%`,
x: 0,
y: 0,
},
showControls: false,
touch: false,
fps: 60,
defaultMix: 0.3,
success: (player) => {
if (
player.skeleton.data.animations
.map((e) => e.name)
.includes('Start')
) {
player.animationState.setAnimation(0, 'Start', false, 0)
player.animationState.addAnimation(0, 'Idle', true, 0)
}
let lastVoiceId = null
let currentVoiceId = null
player.canvas.onclick = () => {
if (!voiceLangRef.current) return
const voiceId = () => {
const keys = Object.keys(subtitleObjRef.current)
const id =
keys[Math.floor(Math.random() * keys.length)]
return id === lastVoiceId ? voiceId() : id
}
const id = voiceId()
currentVoiceId = id
setCurrentVoiceId(id)
setVoiceSrc(
`/${configRef.current.link}/assets/${getVoiceFoler(voiceLangRef.current)}/${id}.ogg`
)
lastVoiceId = currentVoiceId
}
},
}
if (configRef.current.use_json) {
playerConfig.jsonUrl = `./${key}/assets/${configRef.current.filename.replace(/#/g, '%23')}.json`
} else {
playerConfig.skelUrl = `./${key}/assets/${configRef.current.filename.replace(/#/g, '%23')}.skel`
}
setSpinePlayer(
new spine.SpinePlayer(spineRef.current, playerConfig)
)
}
}, [spineAnimationName, setSpinePlayer, spinePlayer, key])
useEffect(() => {
return () => {
if (spinePlayer) {
spinePlayer.dispose()
}
}
}, [spinePlayer])
useEffect(() => {
if (voiceConfig && voiceLang) {
let subtitleObj = voiceConfig.subtitleLangs[subtitleLang || 'zh-CN']
let subtitleKey = 'default'
if (subtitleObj[voiceLang]) {
subtitleKey = voiceLang
}
setSubtitleObj(subtitleObj[subtitleKey])
}
}, [subtitleLang, voiceConfig, voiceLang])
const handleAduioStateChange = useCallback((e, state) => {
switch (state) {
case 'play':
setIsVoicePlaying(true)
break
default:
setIsVoicePlaying(false)
break
}
}, [])
useEffect(() => {
if (subtitleLang) {
if (isVoicePlaying) {
setHideSubtitle(false)
} else {
const autoHide = () => {
if (isVoicePlayingRef.current) return
setHideSubtitle(true)
}
setTimeout(autoHide, 5 * 1000)
return () => {
clearTimeout(autoHide)
}
}
} else {
setHideSubtitle(true)
}
}, [subtitleLang, isVoicePlaying])
useEffect(() => {
if (voiceLang && isVoicePlaying) {
const audioUrl = `/assets/${getVoiceFoler(voiceLang)}/${currentVoiceId}.ogg`
if (
voiceSrc !==
window.location.href.replace(/\/$/g, '') + audioUrl
) {
setVoiceSrc(`/${config.link}${audioUrl}`)
}
}
}, [voiceLang, isVoicePlaying, currentVoiceId, config, voiceSrc])
const playAnimationVoice = useCallback(
(animation) => {
if (voiceLangRef.current) {
let id = null
if (animation === 'Idle') id = 'CN_011'
if (animation === 'Interact') id = 'CN_034'
if (animation === 'Special') id = 'CN_042'
if (id) {
setCurrentVoiceId(id)
setVoiceSrc(
`/${key}/assets/${getVoiceFoler(voiceLangRef.current)}/${id}.ogg`
)
}
}
},
[key]
)
useEffect(() => {
if (!voiceLang) {
setVoiceSrc(null)
}
}, [voiceLang])
const setSpineAnimation = useCallback(
(animation) => {
playAnimationVoice(animation)
const entry = spinePlayer.animationState.setAnimation(
0,
animation,
true
)
entry.mixDuration = 0.3
setSpineAnimationName(animation)
},
[playAnimationVoice, spinePlayer]
)
const spineSettings = [
{
name: 'animation',
options: [
{
name: 'idle',
onClick: () => setSpineAnimation('Idle'),
activeRule: () => {
return spineAnimationName === 'Idle'
},
},
{
name: 'interact',
onClick: () => setSpineAnimation('Interact'),
activeRule: () => {
return spineAnimationName === 'Interact'
},
},
{
name: 'special',
onClick: () => setSpineAnimation('Special'),
activeRule: () => {
return spineAnimationName === 'Special'
},
},
],
},
{
name: 'voice',
options:
(voiceConfig &&
Object.keys(voiceConfig?.voiceLangs['zh-CN']).map(
(item) => {
return {
name: i18n(item),
onClick: () => {
if (voiceLang !== item) {
setVoiceLang(item)
} else {
setVoiceLang(null)
}
if (!isVoicePlayingRef.current) {
playAnimationVoice(spineAnimationName)
}
},
activeRule: () => {
return voiceLang === item
},
}
}
)) ||
[],
},
{
name: 'subtitle',
options:
(voiceConfig &&
Object.keys(voiceConfig?.subtitleLangs).map((item) => {
return {
name: i18n(item),
onClick: () => {
if (subtitleLang !== item) {
setSubtitleLang(item)
} else {
setSubtitleLang(null)
}
},
activeRule: () => {
return subtitleLang === item
},
}
})) ||
[],
},
{
name: 'music',
el: <MusicElement />,
},
{
name: 'backgrounds',
options:
buildConfig.background_files.map((item) => {
return {
name: item,
onClick: () => {
setCurrentBackground(item)
},
activeRule: () => {
return currentBackground === item
},
}
}) || [],
},
]
if (!buildConfig.available_operators.includes(key)) {
throw new Error('Operator not found')
}
return (
<section className={classes.operator}>
<section className={classes.main}>
<section
className={classes.settings}
style={{
color: config?.color,
}}
>
{spineSettings.map((item) => {
if (item.el) {
return <section key={item.name}>{item.el}</section>
}
if (item.options.length === 0) return null
return (
<section key={item.name}>
<section className={classes.title}>
<section className={classes.text}>
{i18n(item.name)}
</section>
</section>
<section
className={classes['styled-selection']}
>
{item.options.map((option) => {
return (
<section
className={`${classes.content} ${option.activeRule && option.activeRule() ? classes.active : ''}`}
onClick={(e) =>
option.onClick(e)
}
key={option.name}
>
<section
className={classes.option}
>
<section
className={
classes.outline
}
/>
<section
className={`${classes.text} ${classes['no-overflow']}`}
>
{i18n(option.name)}
</section>
<section
className={
classes['tick-icon']
}
/>
</section>
</section>
)
})}
</section>
</section>
)
})}
<section>
<section className={classes.title}>
<section className={classes.text}>
{i18n('external_links')}
</section>
</section>
<section className={classes['styled-selection']}>
<Link
reloadDocument
to={`./index.html?aklive2d`}
target="_blank"
style={{
color: config?.color,
}}
>
<section className={classes.content}>
<section className={classes.option}>
<section className={classes.outline} />
<section
className={`${classes.text} ${classes['no-overflow']}`}
>
{i18n('web_version')}
</section>
<section
className={classes['arrow-icon']}
>
<section
className={classes.bar}
></section>
<section
className={classes.bar}
></section>
<section
className={classes.bar}
></section>
<section
className={classes.bar}
></section>
</section>
</section>
</section>
</Link>
{config?.workshopId && (
<Link
reloadDocument
to={`https://steamcommunity.com/sharedfiles/filedetails/?id=${config.workshopId}`}
target="_blank"
style={{
color: config?.color,
}}
>
<section className={classes.content}>
<section className={classes.option}>
<section
className={classes.outline}
/>
<section
className={`${classes.text} ${classes['no-overflow']}`}
>
{i18n('steam_workshop')}
</section>
<section
className={
classes['arrow-icon']
}
>
<section
className={classes.bar}
></section>
<section
className={classes.bar}
></section>
<section
className={classes.bar}
></section>
<section
className={classes.bar}
></section>
</section>
</section>
</section>
</Link>
)}
</section>
</section>
</section>
<section
className={classes.container}
style={
currentBackground && {
backgroundImage: `url(/chen/assets/${buildConfig.background_folder}/${currentBackground})`,
}
}
>
{config && (
<img
src={`/${config.link}/assets/${config.logo}.png`}
alt={config?.codename[language]}
className={classes.logo}
style={
config.invert_filter
? {
filter: 'invert(1)',
}
: {}
}
/>
)}
<section ref={spineRef} className={classes.wrapper} />
{currentVoiceId && subtitleObj && (
<section
className={`${classes.voice} ${hideSubtitle ? '' : classes.active}`}
>
<section className={classes.type}>
{subtitleObj[currentVoiceId]?.title}
</section>
<section className={classes.subtitle}>
<span>{subtitleObj[currentVoiceId]?.text}</span>
<span className={classes.triangle} />
</section>
</section>
)}
</section>
</section>
<Border />
<VoiceElement
src={voiceSrc}
handleAduioStateChange={handleAduioStateChange}
/>
</section>
)
}
function MusicElement() {
const [enableMusic, setEnableMusic] = useState(false)
const { i18n } = useI18n()
const musicIntroRef = useRef(null)
const musicLoopRef = useRef(null)
const [background] = useAtom(backgroundAtom)
useEffect(() => {
if (musicIntroRef.current && musicIntroRef.current) {
musicIntroRef.current.volume = 0.5
musicLoopRef.current.volume = 0.5
}
}, [musicIntroRef, musicLoopRef])
useEffect(() => {
if (!enableMusic || background) {
musicIntroRef.current.pause()
musicLoopRef.current.pause()
}
}, [enableMusic, background])
useEffect(() => {
if (background && enableMusic) {
const introOgg = musicMapping[background].intro
const intro = `./chen/assets/${buildConfig.music_folder}/${introOgg}`
const loop = `./chen/assets/${buildConfig.music_folder}/${musicMapping[background].loop}`
musicLoopRef.current.src = loop
if (introOgg) {
musicIntroRef.current.src = intro || loop
} else {
musicLoopRef.current.play()
}
}
}, [background, enableMusic])
const handleIntroTimeUpdate = useCallback(() => {
if (
musicIntroRef.current.currentTime >=
musicIntroRef.current.duration - 0.3
) {
musicIntroRef.current.pause()
musicLoopRef.current.play()
}
}, [])
const handleLoopTimeUpdate = useCallback(() => {
if (
musicLoopRef.current.currentTime >=
musicLoopRef.current.duration - 0.3
) {
musicLoopRef.current.currentTime = 0
musicLoopRef.current.play()
}
}, [])
return (
<section>
<section
className={classes.title}
onClick={() => setEnableMusic(!enableMusic)}
>
<section className={classes.text}>{i18n('music')}</section>
<section className={classes.switch}>
<Switch on={enableMusic} />
</section>
</section>
<audio
ref={musicIntroRef}
preload="auto"
autoPlay
onTimeUpdate={() => handleIntroTimeUpdate()}
>
<source type="audio/ogg" />
</audio>
<audio
ref={musicLoopRef}
preload="auto"
onTimeUpdate={() => handleLoopTimeUpdate()}
>
<source type="audio/ogg" />
</audio>
</section>
)
}

View File

@@ -0,0 +1,20 @@
.main {
flex-grow: 1;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
padding-bottom: 3rem;
margin: 0 auto;
width: 70%;
max-width: 100rem;
padding-top: 5rem;
min-height: calc(100vh - 5rem - 3rem);
@media only screen and (width <= 430px) {
& {
width: 100vw;
margin-left: 0;
}
}
}

View File

@@ -0,0 +1,369 @@
.date {
margin: 1.5rem;
font-family:
Bender, 'Noto Sans SC', 'Noto Sans JP', 'Noto Sans KR', 'Noto Sans',
sans-serif;
font-weight: bold;
text-align: right;
color: var(--date-color);
font-size: 1.5rem;
letter-spacing: 0.1rem;
flex: auto;
user-select: none;
@media only screen and (width <= 430px) {
& {
margin: 0;
}
}
}
.container {
color: var(--text-color-full);
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 1.25rem;
font-weight: bold;
.title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3em;
height: auto;
}
.type {
display: flex;
flex-direction: row;
align-items: center;
svg {
width: 1.5rem;
fill: var(--text-color);
}
}
}
.group {
padding: 1rem;
display: flex;
align-items: flex-end;
flex-wrap: wrap;
user-select: none;
.operator-group {
display: flex;
flex-flow: row wrap;
@media only screen and (width <= 430px) {
& {
width: 100%;
padding-bottom: 1rem;
overflow-y: hidden;
flex-wrap: nowrap;
}
}
}
.item {
position: relative;
flex-shrink: 0;
cursor: pointer;
width: 12rem;
margin: 1.25rem;
background-image: repeating-linear-gradient(
90deg,
var(--home-item-background-linear-gradient-color) 0,
var(--home-item-background-linear-gradient-color) 1px,
transparent 1px,
transparent 5px
);
.background-filler {
border-right: 1px solid
var(--home-item-background-linear-gradient-color);
position: absolute;
inset: 0 -1px 0 0;
}
.outline {
display: block;
position: absolute;
opacity: 0;
visibility: hidden;
transition: all cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
width: 100%;
height: 100%;
left: -6px;
top: -6px;
border: var(--home-item-outline-color) 1px dashed;
padding: 6px;
&::before,
&::after {
content: '';
display: block;
position: absolute;
left: -3px;
height: 3px;
width: 100%;
border-left: var(--text-color) solid 3px;
border-right: var(--text-color) solid 3px;
}
&::before {
top: -3px;
}
&::after {
bottom: -3px;
}
}
.img {
transition: background-color cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
& img {
height: auto;
width: 100%;
display: block;
}
}
.info {
white-space: nowrap;
position: relative;
padding: 0.8rem 0.4rem;
line-height: 1.2em;
height: 36px;
.wrapper {
overflow: hidden;
text-overflow: ellipsis;
color: var(--secondary-text-color);
.text {
font-size: 0.75rem;
font-family:
Geometos, 'Noto Sans SC', 'Noto Sans JP',
'Noto Sans KR', 'Noto Sans', sans-serif;
margin-top: 1rem;
}
}
.background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
visibility: hidden;
transition: all cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
background-image: linear-gradient(
70deg,
transparent 40%,
currentcolor 150%
);
}
}
&:hover {
.img {
background-color: var(--home-item-hover-background-color);
}
.outline,
.info .background {
opacity: 1;
visibility: visible;
}
}
@media only screen and (width <= 430px) {
& {
width: 8.08rem;
margin: 1.08rem;
.outline,
.info .background {
opacity: 1;
visibility: visible;
}
}
}
}
@media only screen and (width <= 430px) {
& {
align-items: flex-start;
flex-direction: column-reverse;
padding-bottom: 0;
}
}
}
.styled-selection {
margin-bottom: 0.8rem;
.content {
padding: 0.8rem 0;
cursor: pointer;
transition: transform cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
.option {
white-space: nowrap;
pointer-events: none;
position: relative;
transform: translate3d(0, 0, 1px);
font-size: 1rem;
padding: 0.44rem 3.25rem 0.44rem 0.63rem;
background-color: var(--home-item-hover-background-color);
background-image: repeating-linear-gradient(
90deg,
var(--home-item-background-linear-gradient-color) 0 1px,
transparent 1px 4px
);
transition: transform cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
.outline {
width: 100%;
height: 100%;
left: -6px;
top: -6px;
border: var(--home-item-outline-color) 1px dashed;
padding: 6px;
&::before,
&::after {
content: '';
display: block;
position: absolute;
left: -2px;
height: 2px;
width: 100%;
border-left: var(--text-color) solid 2px;
border-right: var(--text-color) solid 2px;
}
&::before {
top: -2px;
}
&::after {
bottom: -2px;
}
}
&::before,
.outline {
content: '';
display: block;
position: absolute;
z-index: -1;
opacity: 0;
visibility: hidden;
transition:
opacity cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s,
visibility cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
}
&::before {
right: 0;
top: 0;
width: 60%;
height: 100%;
background-image: linear-gradient(
90deg,
transparent,
currentcolor
);
}
.tick-icon {
display: inline-block;
position: absolute;
z-index: 0;
right: 0.31rem;
top: 50%;
width: 0.5rem;
height: 1rem;
opacity: 0;
visibility: hidden;
transition:
opacity cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s,
visibility cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
border-right: var(--text-color) solid 0.25rem;
border-bottom: var(--text-color) solid 0.25rem;
transform: translate(-50%, -70%) rotate(45deg);
}
.arrow-icon {
display: inline-block;
position: absolute;
z-index: 0;
right: -0.1em;
top: 0.55em;
width: 2em;
height: 1em;
transform: rotate(90deg);
transition:
opacity cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s,
visibility cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
opacity: 0;
visibility: hidden;
.bar {
position: absolute;
width: 100%;
height: 0.25em;
background-color: var(--text-color-full);
will-change: auto;
}
.bar:nth-child(1) {
transform: rotateZ(45deg) scaleX(0.5) translateX(0.8em);
}
.bar:nth-child(2) {
transform: rotateZ(-45deg) scaleX(0.5) translateX(-0.8em);
}
.bar:nth-child(3),
.bar:nth-child(4) {
transform: translateY(1em) rotateZ(90deg) scaleX(0.5);
}
}
}
&:hover,
&.active {
transform: translate3d(6px, 0, 1px);
.option::before,
.option .outline {
opacity: 1;
visibility: visible;
}
.arrow-icon {
opacity: 1;
visibility: visible;
}
}
&.active {
.option .tick-icon {
opacity: 1;
visibility: visible;
}
}
}
}
.no-overflow {
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,30 @@
@use '@/scss/page_base';
.group {
flex-direction: column;
align-items: stretch;
.info {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding-left: 1rem;
word-break: break-word;
.content {
font-size: 1.5rem;
display: list-item;
}
}
.date {
margin-bottom: 1rem;
}
@media only screen and (width <= 430px) {
flex-direction: column-reverse;
align-items: flex-start;
padding-bottom: 1rem;
}
}

View File

@@ -0,0 +1,61 @@
@use '@/scss/main_share';
.error {
min-height: 100vh;
display: flex;
flex-direction: column;
font-size: 16px;
user-select: none;
.header {
padding: 1rem;
justify-content: space-between;
pointer-events: auto;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
padding-top: 6rem;
font-size: 3rem;
gap: 2rem;
padding-bottom: 0;
}
.spine {
max-width: 600px;
flex: 1;
visibility: hidden;
opacity: 0;
&.active {
visibility: visible;
opacity: 1;
}
}
@media (width <= 768px) {
.main {
padding-top: 6rem;
max-height: calc(100vh - 6rem);
}
.content {
font-size: 2rem;
}
}
@media (width <= 480px) {
.main {
padding-top: 4rem;
max-height: calc(100vh - 4rem);
}
.content {
font-size: 1.5rem;
}
}
}

View File

@@ -0,0 +1,23 @@
@use '@/scss/page_base';
.official-update {
.info {
display: flex;
flex-direction: column;
align-items: flex-start;
padding-left: 1rem;
word-break: break-word;
margin-top: 1.25rem;
.content {
font-size: 1.5rem;
max-width: 100%;
}
.container {
.title {
margin-left: 0.5rem;
}
}
}
}

View File

@@ -0,0 +1,142 @@
@use '@/scss/page_base';
.main {
padding: 3rem 0 2rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
.container {
background-position: center;
background-repeat: no-repeat;
background-size: cover;
width: 100%;
position: relative;
margin-bottom: 2rem;
user-select: none;
&::before {
content: '';
display: block;
padding-top: 100%;
}
.wrapper {
position: absolute;
inset: 0;
}
@media (width <= 1280px) {
width: 100%;
position: relative;
margin-bottom: 2rem;
&::before {
content: '';
display: block;
padding-top: 100%;
}
}
}
.settings {
margin-right: 1.5rem;
user-select: none;
margin-left: 1rem;
.title {
font-size: 1.25rem;
border-left: 3px solid currentcolor;
padding-left: 0.75rem;
margin-bottom: 0.8rem;
display: flex;
justify-content: space-between;
cursor: pointer;
.switch {
bottom: -10px;
position: relative;
}
}
}
.text {
color: var(--text-color);
line-height: 1.2;
}
.logo {
position: absolute;
top: 0;
left: 0;
width: 30%;
height: auto;
opacity: 0.3;
}
.voice {
position: absolute;
left: 0;
bottom: 20%;
z-index: 1;
max-width: 480px;
width: 85%;
opacity: 0;
margin: 16px;
transition: all 0.5s cubic-bezier(0.65, 0.05, 0.36, 1);
visibility: hidden;
font-family:
'Noto Sans SC', 'Noto Sans JP', 'Noto Sans KR', 'Noto Sans',
sans-serif;
.type {
background-color: #9e9e9e;
color: #000;
display: inline-block;
position: absolute;
top: -12px;
left: -8px;
padding: 2px 8px;
font-size: 14px;
max-width: 180px;
width: 65%;
box-shadow: 0 3px 6px #00000080;
z-index: 1;
}
.subtitle {
background-color: #000000a6;
color: #fff;
padding: 16px;
font-size: 18px;
box-shadow: 0 6px 12px #00000080;
position: relative;
word-break: break-word;
}
.triangle {
position: absolute;
bottom: 0;
right: 8px;
width: 0;
height: 0;
border-style: solid;
border-width: 8px;
border-color: white transparent transparent;
}
&.active {
opacity: 1;
visibility: visible;
}
@media (width <= 1280px) {
bottom: 0;
}
}
@media (width <= 1280px) {
flex-direction: column-reverse;
align-items: stretch;
}
}

View File

@@ -0,0 +1,95 @@
@use '@/scss/main_share';
.main {
.header {
display: flex;
flex-flow: row wrap;
justify-content: space-between;
align-items: flex-end;
position: relative;
@media only screen and (width <= 430px) {
& {
margin: 0 1rem;
}
}
.title {
font-size: 3rem;
font-weight: 700;
text-transform: uppercase;
line-height: 1.2em;
.icon {
margin-right: 1.5rem;
display: inline-block;
vertical-align: middle;
svg {
fill: var(--text-color);
height: 2.5rem;
}
}
@media (width <= 600px) {
font-size: 2.5rem;
}
@media (width <= 480px) {
font-size: 2rem;
}
}
.tab {
flex: auto;
white-space: pre;
user-select: none;
display: flex;
flex-direction: row;
justify-content: flex-end;
overflow: hidden;
z-index: 1;
.item {
font-size: 1.25rem;
line-height: 3em;
font-weight: 700;
padding: 0 1rem;
text-transform: uppercase;
border-bottom: 0.3rem solid transparent;
display: inline-block;
cursor: pointer;
text-decoration: none;
.text-wrapper {
color: var(--text-color);
transition: color cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
}
&.active,
&:hover {
color: var(--link-highlight-color);
.text-wrapper,
.text {
color: currentcolor;
}
}
&.active {
border-bottom-color: currentcolor;
}
@media only screen and (width <= 430px) {
line-height: 2em;
}
}
}
@media only screen and (width <= 430px) {
& {
padding-right: 0;
}
}
}
}

View File

@@ -0,0 +1,55 @@
.drawer {
position: fixed;
top: 0;
left: -15rem;
width: 15rem;
height: 100%;
z-index: 3;
pointer-events: none;
transition: left cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
text-align: center;
display: flex;
flex-direction: row;
align-items: flex-start;
user-select: none;
.links {
padding: 8rem 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
background-color: var(--drawer-background-color);
height: 100%;
width: 15rem;
.link {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
color: var(--text-color);
font-size: 0.8rem;
font-weight: 500;
transition: color cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
text-transform: uppercase;
&:hover,
&.active {
color: var(--link-highlight-color);
}
}
}
.overlay {
height: 100%;
flex-grow: 1;
z-index: 3;
}
&.active {
pointer-events: all;
left: 0;
width: 100vw;
}
}

View File

@@ -0,0 +1,42 @@
.footer {
user-select: none;
.section {
border-top: 1px solid var(--border-color);
padding: 1rem 0;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: nowrap;
font-family:
'Noto Sans SC', 'Noto Sans JP', 'Noto Sans KR', 'Noto Sans',
sans-serif;
}
.links {
flex-direction: row;
height: 2rem;
overflow-y: hidden;
.item {
text-align: center;
padding: 0 1rem;
border-left: 2px solid var(--border-color);
height: inherit;
display: flex;
flex-flow: column wrap;
justify-content: center;
align-items: center;
&:first-of-type {
border-left: none;
}
}
}
.copyright {
flex-direction: column;
gap: 0.5rem;
font-size: 12px;
}
}

View File

@@ -0,0 +1,106 @@
.header {
width: auto;
position: fixed;
left: 0;
top: 0;
right: 0;
padding: 1rem;
z-index: 4;
height: 3rem;
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: flex-start;
pointer-events: none;
.fast-navigate {
pointer-events: auto;
margin-left: 0.6rem;
height: 2rem;
width: 2rem;
}
.dropdown {
margin-left: auto;
}
.extra-area {
display: flex;
flex-direction: row;
align-items: center;
pointer-events: auto;
}
}
.nav-button {
padding: 0.5rem;
font-size: 2rem;
width: 2rem;
height: 2rem;
cursor: pointer;
display: flex;
justify-content: center;
align-items: flex-start;
flex-direction: column;
z-index: 2;
pointer-events: auto;
.bar {
width: 2rem;
height: 0.2rem;
background-color: var(--text-color);
transition: transform cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
&:nth-child(1) {
transform: translate(0, -200%);
}
&:nth-child(3) {
transform: translate(0, 200%);
}
}
&.active {
.bar {
&:nth-child(1) {
transform: translate(0, 100%) rotateZ(45deg) scaleX(0.5)
translate(-50%);
}
&:nth-child(2) {
transform: rotateZ(-45deg);
}
&:nth-child(3) {
transform: translate(0, -100%) rotateZ(45deg) scaleX(0.5)
translate(50%);
}
}
}
}
.spacer {
flex-grow: 1;
}
.back-arrow {
padding-left: 1rem;
height: 2rem;
width: 2rem;
pointer-events: auto;
.arrow1 {
height: 1rem;
width: 1rem;
border-left: 0.15rem solid;
border-bottom: 0.15rem solid;
transform: translateY(0.38rem) rotate(45deg);
}
.arrow2 {
width: 1.2rem;
height: 0.15rem;
background-color: currentcolor;
transform: translate(0.5rem, -0.25rem);
}
}

View File

@@ -0,0 +1,11 @@
import { atom, useAtom } from 'jotai'
const extraAreaAtom = atom([])
export function useAppbar() {
const [extraArea, setExtraArea] = useAtom(extraAreaAtom)
return {
extraArea,
setExtraArea,
}
}

View File

@@ -0,0 +1,31 @@
import CONFIG from '!/config.json'
import { useCallback } from 'react'
import { atom, useAtom } from 'jotai'
const officialUpdateAtom = atom({})
let operators = []
CONFIG.operators.forEach((item) => {
operators = [...operators, ...item]
})
const OPERATORS = operators
export function useConfig() {
const config = CONFIG
const operators = OPERATORS
const [officialUpdate, setOfficialUpdate] = useAtom(officialUpdateAtom)
const fetchOfficialUpdate = useCallback(async () => {
const res = await fetch(
'https://raw.githubusercontent.com/Halyul/aklive2d/main/official_update.json'
)
const data = await res.json().catch((e) => {
console.error(e)
return {
length: 0,
}
})
setOfficialUpdate(data)
}, [setOfficialUpdate])
return { config, operators, officialUpdate, fetchOfficialUpdate }
}

View File

@@ -0,0 +1,43 @@
import { useEffect } from 'react'
import { atom, useAtom } from 'jotai'
import { useI18n } from '@/state/language'
const keyAtom = atom('')
const titleAtom = atom('')
const tabsAtom = atom([])
const currentTabAtom = atom(null)
const appbarExtraAreaAtom = atom([])
const headerIconAtom = atom(null)
const fastNaviationAtom = atom([])
export function useHeader() {
const [key, setTitle] = useAtom(keyAtom)
const [title, setRealTitle] = useAtom(titleAtom)
const [tabs, setTabs] = useAtom(tabsAtom)
const [currentTab, setCurrentTab] = useAtom(currentTabAtom)
const [appbarExtraArea, setAppbarExtraArea] = useAtom(appbarExtraAreaAtom)
const [headerIcon, setHeaderIcon] = useAtom(headerIconAtom)
const [fastNavigation, setFastNavigation] = useAtom(fastNaviationAtom)
const { i18n } = useI18n()
useEffect(() => {
const newTitle = i18n(key)
document.title = `${newTitle} - ${import.meta.env.VITE_APP_TITLE}`
setRealTitle(newTitle)
}, [i18n, key, setRealTitle])
return {
title,
setTitle,
tabs,
setTabs,
currentTab,
setCurrentTab,
appbarExtraArea,
setAppbarExtraArea,
headerIcon,
setHeaderIcon,
fastNavigation,
setFastNavigation,
}
}

View File

@@ -0,0 +1,20 @@
import React from 'react'
import buildConfig from '!/config.json'
export default (path = null, skipPageView = false) => {
React.useEffect(() => {
if (!skipPageView && import.meta.env.MODE !== 'development') {
try {
window.counterscale = {
q: [
['set', 'siteId', buildConfig.insight_id],
['trackPageview', { path }],
],
}
window.counterscaleOnDemandTrack()
} catch (err) {
console.warn && console.warn(err.message)
}
}
}, [path, skipPageView])
}

View File

@@ -0,0 +1,38 @@
import { atom, useAtom, useAtomValue } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
import i18nObject from '@/i18n'
const language = i18nObject.available.includes(navigator.language)
? navigator.language
: 'en-US'
const textDefaultLang = 'en-US'
const languageAtom = atomWithStorage('language', language)
const alternateLangAtom = atom((get) => {
const language = get(languageAtom)
return language.startsWith('en') ? 'zh-CN' : language
})
export function useI18n() {
const language = useAtomValue(languageAtom)
return {
i18n: (key, preferredLanguage = language) => {
if (i18nObject.key[key]) {
return i18nObject.key[key][preferredLanguage]
}
return key
},
i18nValues: i18nObject,
}
}
export function useLanguage() {
const [language, setLanguage] = useAtom(languageAtom)
const alternateLang = useAtomValue(alternateLangAtom)
return {
textDefaultLang,
language,
setLanguage,
alternateLang,
}
}

View File

@@ -0,0 +1,5 @@
import baseConfig from '@aklive2d/stylelint-config'
/** @type {import('stylelint').Config} */
export default {
...baseConfig,
}

View File

@@ -0,0 +1,45 @@
import path from 'node:path'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import config from '@aklive2d/config'
import * as showcaseDirs from '@aklive2d/showcase'
import { copyDirectoryData } from '@aklive2d/vite-helpers'
// https://vite.dev/config/
export default defineConfig(async () => {
const dataDir = path.resolve(import.meta.dirname, config.dir_name.data)
const publicDir = path.resolve(showcaseDirs.DIST_DIR)
await copyDirectoryData({ dataDir, publicDir })
return {
envDir: dataDir,
plugins: [react()],
publicDir,
resolve: {
alias: {
'@': path.resolve('./src'),
'!': dataDir,
},
},
build: {
emptyOutDir: false,
outDir: publicDir,
rollupOptions: {
output: {
entryFileNames: `${config.directory.assets_dir}/[name]-[hash:8].js`,
chunkFileNames: `${config.directory.assets_dir}/[name]-[hash:8].js`,
assetFileNames: `${config.directory.assets_dir}/[name]-[hash:8].[ext]`,
manualChunks: (id) => {
if (id.includes('node_modules')) {
return 'vendor' // all other package goes here
} else if (
id.includes('data') &&
id.includes('.json')
) {
return 'assets'
}
},
},
},
},
}
})

View File

@@ -0,0 +1 @@
libs

View File

@@ -0,0 +1 @@
libs

4
apps/module/index.js Normal file
View File

@@ -0,0 +1,4 @@
import './libs/spine-player.css'
import spine from './libs/spine-player'
export { spine }

View File

@@ -2188,42 +2188,64 @@ var spine;
this.pathPrefix = pathPrefix;
}
AssetManager.prototype.downloadText = function (url, success, error) {
var request = new XMLHttpRequest();
request.overrideMimeType("text/html");
if (this.rawDataUris[url])
url = this.rawDataUris[url];
request.open("GET", url, true);
request.onload = function () {
if (request.status == 200) {
success(request.responseText);
fetch(url).then(function (response) {
if (!response.ok) {
error(response.status, response.statusText);
}
else {
error(request.status, request.responseText);
}
};
request.onerror = function () {
error(request.status, request.responseText);
};
request.send();
return response.text();
}).then(function (text) {
success(text);
});
// var request = new XMLHttpRequest();
// request.overrideMimeType("text/html");
// request.open("GET", url, true);
// request.onload = function () {
// if (request.status == 200) {
// success(request.responseText);
// }
// else {
// error(request.status, request.responseText);
// }
// };
// request.onerror = function () {
// error(request.status, request.responseText);
// };
// request.send();
};
AssetManager.prototype.downloadBinary = function (url, success, error) {
var request = new XMLHttpRequest();
if (this.rawDataUris[url])
url = this.rawDataUris[url];
request.open("GET", url, true);
request.responseType = "arraybuffer";
request.onload = function () {
if (request.status == 200) {
success(new Uint8Array(request.response));
fetch(url).then(function (response) {
if (!response.ok) {
error(response.status, response.statusText);
}
else {
error(request.status, request.responseText);
}
};
request.onerror = function () {
error(request.status, request.responseText);
};
request.send();
return response.arrayBuffer();
}).then(function (arrayBuffer) {
success(new Uint8Array(arrayBuffer));
});
// var request = new XMLHttpRequest();
// if (this.rawDataUris[url])
// url = this.rawDataUris[url];
// request.open("GET", url, true);
// request.responseType = "arraybuffer";
// request.onload = function () {
// if (request.status == 200) {
// success(new Uint8Array(request.response));
// }
// else {
// error(request.status, request.responseText);
// }
// };
// request.onerror = function () {
// error(request.status, request.responseText);
// };
// request.send();
};
AssetManager.prototype.setRawDataURI = function (path, data) {
this.rawDataUris[this.pathPrefix + path] = data;

7
apps/module/package.json Normal file
View File

@@ -0,0 +1,7 @@
{
"name": "@aklive2d/module",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "index.js"
}

26
apps/showcase/.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
data

View File

@@ -0,0 +1,3 @@
dist
data
auto_update

View File

@@ -0,0 +1 @@
spine-player.css

View File

@@ -0,0 +1,3 @@
import baseConfig from '@aklive2d/eslint-config'
/** @type {import('eslint').Config} */
export default [...baseConfig, { ignores: ['src/libs/*'] }]

22
apps/showcase/index.html Normal file
View File

@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width,minimum-scale=1,initial-scale=1,maximum-scale=1,user-scalable=no"
/>
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="renderer" content="webkit" />
<title>%VITE_APP_TITLE%</title>
<script
id="counterscale-script"
src="%VITE_INSIGHT_URL%"
defer
></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/index.js"></script>
</body>
</html>

16
apps/showcase/index.js Normal file
View File

@@ -0,0 +1,16 @@
import path from 'node:path'
import config from '@aklive2d/config'
export const DATA_DIR = path.resolve(import.meta.dirname, config.dir_name.data)
export const PUBLIC_DIR = path.resolve(DATA_DIR, config.dir_name.public)
export const PUBLIC_ASSETS_DIR = path.resolve(
PUBLIC_DIR,
config.dir_name.assets
)
export const OUT_DIR = path.resolve(import.meta.dirname, config.dir_name.dist)
export const DIST_DIR = path.resolve(
import.meta.dirname,
'..',
'..',
config.dir_name.dist
)

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"!/*": ["src/*"]
}
}
}

View File

@@ -0,0 +1,26 @@
{
"name": "@aklive2d/showcase",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "index.js",
"scripts": {
"dev:showcase": "vite --clearScreen false",
"build": "mode=build node runner.js",
"preview:showcase": "vite preview",
"lint": "eslint \"src/**/*.js\" && stylelint \"**/*.css\" && prettier --check ."
},
"devDependencies": {
"vite": "^6.1.0",
"@aklive2d/eslint-config": "workspace:*",
"@aklive2d/postcss-config": "workspace:*",
"@aklive2d/stylelint-config": "workspace:*",
"@aklive2d/config": "workspace:*",
"@aklive2d/libs": "workspace:*",
"@aklive2d/assets": "workspace:*",
"@aklive2d/operator": "workspace:*",
"@aklive2d/vite-helpers": "workspace:*",
"@aklive2d/module": "workspace:*",
"@aklive2d/prettier-config": "workspace:*"
}
}

View File

@@ -0,0 +1,11 @@
import baseConfig from '@aklive2d/prettier-config'
/**
* @type {import("prettier").Config}
*/
const config = {
...baseConfig,
semi: false,
}
export default config

38
apps/showcase/runner.js Normal file
View File

@@ -0,0 +1,38 @@
import path from 'node:path'
import { build as viteBuild } from 'vite'
import operators from '@aklive2d/operator'
import { envParser, file } from '@aklive2d/libs'
import { copyShowcaseData, copyProjectJSON } from '@aklive2d/vite-helpers'
import * as dirs from './index.js'
const build = async (namesToBuild) => {
const names = !namesToBuild.length ? Object.keys(operators) : namesToBuild
console.log('Generating assets for', names.length, 'operators')
for (const name of names) {
copyShowcaseData(name, {
dataDir: dirs.DATA_DIR,
publicAssetsDir: dirs.PUBLIC_ASSETS_DIR,
})
await viteBuild()
const releaseDir = path.join(dirs.DIST_DIR, name)
file.mv(dirs.OUT_DIR, releaseDir)
file.rm(dirs.DATA_DIR)
copyProjectJSON(name, {
releaseDir,
})
}
}
async function main() {
const { name } = envParser.parse({
name: {
type: 'string',
short: 'n',
multiple: true,
default: [],
},
})
await build(name)
}
main()

View File

@@ -5,4 +5,4 @@
background-color: white;
user-select: auto;
z-index: 999;
}
}

View File

@@ -0,0 +1,257 @@
import Voice from '@/components/voice'
import Fallback from '@/components/fallback'
import Music from '@/components/music'
import Player from '@/components/player'
import Background from '@/components/background'
import Logo from '@/components/logo'
import Insight from '@/components/insight'
import Events from '@/components/events'
import {
isWebGLSupported,
insertHTMLChild,
addEventListeners,
updateElementPosition,
} from '@/components/helper'
import '@/components/aklive2d.css'
export default class AKLive2D {
#el = document.createElement('div')
#appEl
#queries = new URLSearchParams(window.location.search)
#voice
#music
#player
#background
#logo
#configQ = []
#isInited = false
#isSelfInited = false
#isAllInited = false
#insight = new Insight()
constructor(appEl) {
console.log(
'All resources are extracted from Arknights. Github: https://gura.ch/aklive2d-gh'
)
window.addEventListener('contextmenu', (e) => e.preventDefault())
document.addEventListener('gesturestart', (e) => e.preventDefault())
this.#appEl = appEl
this.#logo = new Logo(this.#appEl)
this.#background = new Background(this.#appEl)
this.#voice = new Voice(this.#appEl)
this.#music = new Music(this.#appEl)
if (isWebGLSupported()) {
this.#player = new Player(this.#appEl)
} else {
new Fallback(this.#appEl)
}
addEventListeners([
{
event: Events.Player.Ready.name,
handler: () => this.#selfInited(),
},
{
event: Events.RegisterConfig.name,
handler: (e) => this.#registerConfig(e),
},
])
Promise.all(
[
this.#logo,
this.#background,
this.#voice,
this.#music,
this.#player,
].map(async (e) => e && (await e.init()))
).then(() => this.#allInited())
}
#registerConfig(e) {
if (!this.#isInited) {
this.#configQ.push(e.detail)
} else {
this.#applyConfig(e.detail)
}
}
#applyConfig(config = null) {
if (config) {
let targetObj
const target = config.target
switch (target) {
case 'player':
targetObj = this.#player
break
case 'background':
targetObj = this.#background
break
case 'logo':
targetObj = this.#logo
break
case 'music':
targetObj = this.#music
break
case 'voice':
targetObj = this.#voice
break
default:
return
}
targetObj.applyConfig(config.key, config.value)
} else {
this.#configQ.map((e) => this.#applyConfig(e))
}
return
}
get voice() {
return this.#voice
}
get music() {
return this.#music
}
get player() {
return this.#player
}
get background() {
return this.#background
}
get logo() {
return this.#logo
}
get events() {
return Events
}
get config() {
return {
player: this.#player.config,
background: this.#background.config,
logo: this.#logo.config,
music: this.#music.config,
voice: this.#voice.config,
}
}
get configStr() {
return JSON.stringify(this.config, null)
}
open() {
this.#el.hidden = false
}
close() {
this.#el.hidden = true
}
reset() {
this.#player.reset()
this.#background.reset()
this.#logo.reset()
this.#voice.reset()
this.#music.reset()
}
#allInited() {
this.#isAllInited = true
if (this.#isSelfInited) {
this.#success()
}
}
#selfInited() {
this.#isSelfInited = true
if (this.#isAllInited) {
this.#success()
}
}
#success() {
this.#isInited = true
this.#el.id = 'settings-box'
this.#el.hidden = true
this.#el.innerHTML = `
<div>
${this.#logo.HTML}
${this.#background.HTML}
${this.#player.HTML}
${this.#music.HTML}
${this.#voice.HTML}
<div>
<button type="button" id="settings-reset">Reset</button>
<button type="button" id="settings-close">Close</button>
<button type="button" id="settings-to-directory">Back to Directory</button>
</div>
</div>
`
insertHTMLChild(this.#appEl, this.#el)
addEventListeners([
{
id: 'settings-reset',
event: 'click',
handler: () => this.reset(),
},
{
id: 'settings-close',
event: 'click',
handler: () => this.close(),
},
{
id: 'settings-to-directory',
event: 'click',
handler: () => {
window.location.href = '/'
},
},
...this.#logo.listeners,
...this.#background.listeners,
...this.#player.listeners,
...this.#voice.listeners,
...this.#music.listeners,
...this.#insight.listeners,
])
this.#music.link(this.#background)
this.#background.link(this.#music)
this.#voice.link(this.#player)
this.#player.success()
this.#voice.success()
this.#music.success()
this.#insight.success()
this.#applyConfig()
if (
this.#queries.has('aklive2d') ||
import.meta.env.MODE === 'development'
) {
this.open()
}
this.#registerBackCompatibilityFns()
}
#registerBackCompatibilityFns() {
const _this = this
window.voice = _this.#voice
window.music = _this.#music
window.settings = {
elementPosition: updateElementPosition,
open: _this.open,
close: _this.close,
reset: _this.reset,
..._this.#player.backCompatibilityFns,
..._this.#logo.backCompatibilityFns,
..._this.#music.backCompatibilityFns,
..._this.#background.backCompatibilityFns,
}
}
}

View File

@@ -0,0 +1,23 @@
#background-box {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
height: 100%;
overflow: hidden;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
z-index: -2;
}
#background-box #video-src {
min-width: 100%;
min-height: 100%;
width: auto;
height: auto;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}

View File

@@ -0,0 +1,298 @@
import {
readFile,
updateHTMLOptions,
showRelatedHTML,
syncHTMLValue,
insertHTMLChild,
} from '@/components/helper'
import '@/components/background.css'
import buildConfig from '!/config.json'
export default class Background {
#el = document.createElement('div')
#parentEl
#videoEl
#default = {
location: `${import.meta.env.BASE_URL}assets/${buildConfig.background_folder}/`,
image: buildConfig.default_background,
}
#config = {
video: {
name: null,
volume: 100,
},
useVideo: false,
name: null,
}
#musicObj
constructor(el) {
this.#parentEl = el
this.#el.id = 'background-box'
this.image = this.#default.location + this.#default.image
this.#el.innerHTML = `
<video autoplay loop disablepictureinpicture id="video-src" />
`
insertHTMLChild(this.#parentEl, this.#el)
}
async init() {
this.#videoEl = document.getElementById('video-src')
}
resetImage() {
document.getElementById('custom-background').value = ''
document.getElementById('custom-background-clear').disabled = true
this.#config.name = null
this.image = this.#default.location + this.#default.image
}
resetVideo() {
this.#config.video.name = null
this.#videoEl.src = ''
document.getElementById('custom-video-background').value = ''
document.getElementById('custom-video-background-clear').disabled = true
}
reset() {
this.resetImage()
this.resetVideo()
}
link(musicObj) {
this.#musicObj = musicObj
}
get useVideo() {
return this.#config.useVideo
}
set useVideo(v) {
this.#config.useVideo = v
}
set image(v) {
this.#el.style.backgroundImage = `url("${v}")`
}
set video(v) {
if (!v) {
this.resetVideo()
return
}
const update = (url, v = null) => {
this.#config.video.name = {
isLocalFile: v !== null,
value: v ? v.name : url,
}
this.#videoEl.src = url
this.#videoEl.load()
document.getElementById('custom-video-background-clear').disabled =
false
}
if (typeof v === 'object') {
readFile(v, (blobURL) => update(blobURL, v))
} else {
update(v)
}
}
get volume() {
return this.#config.video.volume
}
set volume(v) {
v = parseInt(v)
this.#config.video.volume = v
this.#videoEl.volume = v / 100
}
get current() {
return this.#config.name || this.#default.image
}
set default(v) {
this.#default.image = v
this.#musicObj.music = v
this.image = this.#default.location + this.#default.image
}
set custom(v) {
if (!v) {
this.resetImage()
return
}
const update = (url, v = null) => {
this.#config.name = {
isLocalFile: v !== null,
value: v ? v.name : url,
}
this.image = url
document.getElementById('custom-background-clear').disabled = false
}
if (typeof v === 'object') {
readFile(v, (blobURL) => update(blobURL, v))
} else {
update(v)
}
}
get config() {
return {
default: this.#default.image,
...this.#config,
}
}
get backCompatibilityFns() {
const _this = this
return {
currentBackground: _this.current,
setBackgoundImage: (v) => (_this.image = v),
setDefaultBackground: (v) => (_this.default = v),
setBackground: (v) => (_this.custom = v),
resetBackground: _this.resetImage,
setVideo: (e) => (_this.video = e.target.files[0]),
getVideoVolume: () => _this.volume,
setVideoVolume: (v) => (_this.volume = v),
setVideoFromWE: (url) => (_this.video = url),
resetVideo: _this.resetVideo,
}
}
get HTML() {
return `
<div>
<div>
<label for="default-background-select">Choose a default background:</label>
<select name="default-backgrounds" id="default-background-select">
${updateHTMLOptions(buildConfig.background_files, null, this.#default.image)}
</select>
</div>
<div>
<label for="custom-background">Custom Background (Store Locally)</label>
<input type="file" id="custom-background" accept="image/*"/>
<button type="button" id="custom-background-clear" ${this.#config.name ? (this.#config.name.isLocalFile ? '' : 'disabled') : 'disabled'}>Clear</button>
</div>
<div>
<label for="custom-background-url">Custom Background URL:</label>
<input type="text" id="custom-background-url" name="custom-background-url" value="${this.#config.name ? this.#config.name.value : ''}">
<button type="button" id="custom-background-url-apply">Apply</button>
</div>
</div>
<div>
<label for="video">Video</label>
<input type="checkbox" id="video" name="video" ${this.useVideo ? 'checked' : ''}/>
<div id="video-realted" ${this.useVideo ? '' : 'hidden'}>
<div>
<label for="custom-video-background">Custom Video Background (Store Locally)</label>
<input type="file" id="custom-video-background" accept="video/*"/>
<button type="button" id="custom-video-background-clear" ${this.#config.video.name ? (this.#config.video.name.isLocalFile ? '' : 'disabled') : 'disabled'}>Clear</button>
</div>
<div>
<label for="custom-video-background-url">Custom Video Background URL:</label>
<input type="text" id="custom-video-background-url" name="custom-video-background-url" value="${this.#config.video.name ? this.#config.video.name.value : ''}">
<button type="button" id="custom-video-background-url-apply">Apply</button>
</div>
<div>
<label for="video-volume">Video Volume</label>
<input type="range" min="0" max="100" step="1" id="video-volume-slider" value="${this.volume}" />
<input type="number" id="video-volume-input" min="0" max="100" step="1" name="video-volume" value="${this.volume}" />
</div>
</div>
</div>
`
}
get listeners() {
return [
{
id: 'default-background-select',
event: 'change',
handler: (e) => {
this.default = e.currentTarget.value
},
},
{
id: 'custom-background',
event: 'change',
handler: (e) => (this.custom = e.target.files[0]),
},
{
id: 'custom-background-clear',
event: 'click',
handler: () => this.resetImage(),
},
{
id: 'custom-background-url-apply',
event: 'click',
handler: () =>
(this.custom = document.getElementById(
'custom-background-url'
).value),
},
{
id: 'video',
event: 'click',
handler: (e) => {
showRelatedHTML(e.currentTarget, 'video-realted')
this.useVideo = e.currentTarget.checked
if (!e.currentTarget.checked) this.resetVideo()
},
},
{
id: 'custom-video-background',
event: 'change',
handler: (e) => (this.video = e.target.files[0]),
},
{
id: 'custom-video-background-clear',
event: 'click',
handler: () => this.resetVideo(),
},
{
id: 'custom-video-background-url-apply',
event: 'click',
handler: () =>
(this.video = document.getElementById(
'custom-video-background-url'
).value),
},
{
id: 'video-volume-slider',
event: 'input',
handler: (e) => {
syncHTMLValue(e.currentTarget, 'video-volume-input')
this.volume = e.currentTarget.value
},
},
{
id: 'video-volume-input',
event: 'change',
handler: (e) => {
syncHTMLValue(e.currentTarget, 'video-volume-slider')
this.volume = e.currentTarget.value
},
},
]
}
applyConfig(key, value) {
switch (key) {
case 'default':
this.default = value
break
case 'custom':
this.custom = value
break
case 'video':
this.video = value
break
case 'volume':
this.volume = value
break
default:
return
}
}
}

View File

@@ -0,0 +1,11 @@
import { createCustomEvent } from '@/components/helper'
import { Events as Insight } from '@/components/insight'
import { Events as Player } from '@/components/player'
const RegisterConfig = createCustomEvent('register-config', true)
export default {
Insight,
Player,
RegisterConfig,
}

View File

@@ -4,7 +4,7 @@
align-items: center;
width: 100%;
height: 100%;
}
}
#fallback {
margin: auto;
@@ -13,4 +13,4 @@
background-size: cover;
width: 100%;
height: 100%;
}
}

View File

@@ -0,0 +1,40 @@
import { insertHTMLChild } from '@/components/helper'
import '@/components/fallback.css'
import buildConfig from '!/config.json'
export default class Fallback {
#el = document.createElement('div')
constructor(parentEl) {
alert('WebGL is unavailable. Fallback image will be used.')
const calculateScale = (width, height) => {
return {
x: window.innerWidth / width,
y: window.innerHeight / height,
}
}
const fallback = () => {
const el = document.getElementById('fallback-container')
const scale = calculateScale(
buildConfig.image_width,
buildConfig.image_height
)
el.style.width = '100%'
el.style.height =
buildConfig.image_height *
(scale.x > scale.y ? scale.y : scale.x) +
'px'
}
window.addEventListener('resize', fallback, true)
this.#el.id = 'fallback-box'
this.#el.innerHTML = `
<div id="fallback-container">
<div id="fallback"
style="background-image: url(./assets/${buildConfig.fallback_name}.png)"
/>
</div>
`
insertHTMLChild(parentEl, this.#el)
fallback()
}
}

View File

@@ -0,0 +1,101 @@
export const isWebGLSupported = () => {
try {
const canvas = document.createElement('canvas')
const ctx =
canvas.getContext('webgl') ||
canvas.getContext('experimental-webgl')
return ctx != null
} catch {
return false
}
}
export const insertHTMLChild = (parent, child) => {
parent.appendChild(child)
}
export const insertHTMLNodeBefore = (parent, sibling, child) => {
parent.insertBefore(child, sibling)
}
const getIntPx = (value) => parseInt(value.replace('px', ''))
export const updateElementPosition = (el, position) => {
const computedStyle = getComputedStyle(el)
const elWidth = getIntPx(computedStyle.width)
const elHeight = getIntPx(computedStyle.height)
const elMarginLeft = getIntPx(computedStyle.marginLeft)
const elMarginRight = getIntPx(computedStyle.marginRight)
const windowWidth = window.innerWidth
const windowHeight = window.innerHeight
const xRange = windowWidth - (elWidth + elMarginLeft + elMarginRight)
const yRange = windowHeight - elHeight
const xpx = (position.x * xRange) / 100
const ypx = (position.y * yRange) / 100
el.style.transform = `translate(${xpx}px, ${ypx}px)`
}
export const updateHTMLOptions = (array, id = null, selected = null) => {
const value = array.map(
(item) =>
`<option value="${item}" ${item === selected ? 'selected' : ''}>${item}</option>`
)
if (id) {
document.getElementById(id).innerHTML = value.join('')
}
return value
}
export const addEventListeners = (listeners) => {
listeners.forEach((listener) => {
if (listener.id) {
document
.getElementById(listener.id)
.addEventListener(listener.event, (e) => listener.handler(e))
} else {
document.addEventListener(listener.event, (e) =>
listener.handler(e)
)
}
})
}
export const showRelatedHTML = (e, relatedSettingsID, revert = false) => {
const eRelatedSettings = document.getElementById(relatedSettingsID)
const checked = revert ? !e.checked : e.checked
if (checked) {
eRelatedSettings.hidden = false
} else {
eRelatedSettings.hidden = true
}
}
export const getCurrentHTMLOptions = (id, value) => {
const e = document.getElementById(id)
const options = [...e]
const toSelecteIndex = options.findIndex(
(i) => options.find((o) => o.value === value) === i
)
e.selectedIndex = toSelecteIndex
}
export const syncHTMLValue = (source, targetID) => {
if (typeof source === 'string') source = document.getElementById(source)
document.getElementById(targetID).value = source.value
}
export const readFile = (file, callback = () => {}) => {
if (!file) return
callback(URL.createObjectURL(file.slice()), file.type)
}
export const createCustomEvent = (name, withArg = false) => {
const ret = {
name,
}
if (withArg) {
ret.handler = (detail) => new CustomEvent(name, { detail })
} else {
ret.handler = () => new Event(name)
}
return ret
}

View File

@@ -0,0 +1,46 @@
import { createCustomEvent } from '@/components/helper'
import buildConfig from '!/config.json'
export default class Insight {
#isInsightInited = false
success() {
this.insight(false)
}
insight(doNotTrack, isFromWallpaperEngine = false) {
if (this.#isInsightInited || import.meta.env.MODE === 'development')
return
this.#isInsightInited = true
if (doNotTrack) return
try {
const config = {
path: `/${buildConfig.link}`,
}
if (isFromWallpaperEngine)
config.hostname = 'file://wallpaperengine.local'
window.counterscale = {
q: [
['set', 'siteId', buildConfig.insight_id],
['trackPageview', config],
],
}
window.counterscaleOnDemandTrack()
} catch (e) {
console.warn && console.warn(e.message)
}
}
get listeners() {
return [
{
event: Events.Register.name,
handler: (e) => this.insight(e.detail, true),
},
]
}
}
export const Events = {
Register: createCustomEvent('insight-register', true),
}

View File

@@ -0,0 +1,6 @@
#logo-box {
position: fixed;
left: 0;
top: 0;
z-index: -1;
}

View File

@@ -0,0 +1,411 @@
import {
insertHTMLChild,
updateElementPosition,
readFile,
showRelatedHTML,
syncHTMLValue,
} from '@/components/helper'
import '@/components/logo.css'
import buildConfig from '!/config.json'
export default class Logo {
#el = document.createElement('div')
#imageEl
#parentEl
#default = {
location: `${import.meta.env.BASE_URL}assets/`,
image: `${buildConfig.logo_filename}.png`,
useInvertFilter: buildConfig.invert_filter === 'true',
ratio: 61.8,
opacity: 30,
hidden: false,
position: {
x: 0,
y: 0,
},
}
#config = {
hidden: this.#default.hidden,
ratio: this.#default.ratio,
opacity: this.#default.opacity,
position: { ...this.#default.position },
name: null,
}
constructor(el) {
this.#parentEl = el
this.#el.id = 'logo-box'
this.#el.innerHTML = `
<img src="${this.#default.location + this.#default.image}" id="logo" alt="operator logo" />
`
insertHTMLChild(this.#parentEl, this.#el)
}
async init() {
this.#imageEl = document.getElementById('logo')
this.#setInvertFilter(this.#default.useInvertFilter)
this.opacity = this.#default.opacity
this.#updateSizeOnWindowResize()
}
setImage(src, invertFilter = false) {
this.#imageEl.src = src
this.#resize()
this.#setInvertFilter(invertFilter)
}
resetPosition() {
this.position = { ...this.#default.position }
document.getElementById('logo-position-x-slider').value =
this.#default.position.x
document.getElementById('logo-position-x-input').value =
this.#default.position.x
document.getElementById('logo-position-y-slider').value =
this.#default.position.y
document.getElementById('logo-position-y-input').value =
this.#default.position.y
}
resetImage() {
this.#config.name = null
this.setImage(
this.#default.location + this.#default.image,
this.#default.useInvertFilter
)
document.getElementById('logo-image-clear').disabled = true
}
resetOpacity() {
this.opacity = this.#default.opacity
document.getElementById('logo-opacity-slider').value =
this.#default.opacity
document.getElementById('logo-opacity-input').value =
this.#default.opacity
}
resetHidden() {
this.hidden = this.#default.hidden
}
resetRatio() {
this.ratio = this.#default.ratio
document.getElementById('logo-ratio-slider').value = this.#default.ratio
document.getElementById('logo-ratio-input').value = this.#default.ratio
}
reset() {
this.resetPosition()
this.resetImage()
this.resetRatio()
this.resetOpacity()
this.resetHidden()
}
#resize(_this, value) {
_this = _this || this
_this.#imageEl.width =
((window.innerWidth / 2) * (value || _this.ratio)) / 100
updateElementPosition(_this.#imageEl, _this.#config.position)
}
#setInvertFilter(v) {
if (!v) {
this.#imageEl.style.filter = 'invert(0)'
} else {
this.#imageEl.style.filter = 'invert(1)'
}
}
#updateLogoPosition() {
updateElementPosition(this.#imageEl, this.#config.position)
}
#updateSizeOnWindowResize() {
const _this = this
const resize = () => {
_this.#resize(_this)
}
window.addEventListener('resize', resize, true)
resize()
}
set image(v) {
if (!v) {
this.resetImage()
return
}
const update = (url, v = null) => {
this.#config.name = {
isLocalFile: v !== null,
value: v ? v.name : url,
}
this.setImage(url, false)
document.getElementById('logo-image-clear').disabled = false
}
if (typeof v === 'object') {
readFile(v, (blobURL) => update(blobURL, v))
} else {
update(v)
}
}
get hidden() {
return this.#config.hidden
}
set hidden(v) {
this.#config.hidden = v
this.#imageEl.hidden = v
}
get ratio() {
return this.#config.ratio
}
set ratio(v) {
v = parseInt(v)
this.#config.ratio = v
this.#resize(this, v)
}
get opacity() {
return this.#config.opacity
}
set opacity(v) {
v = parseInt(v)
this.#imageEl.style.opacity = v / 100
this.#config.opacity = v
}
get x() {
return this.position.x
}
set x(v) {
this.position = {
x: v,
}
}
get y() {
return this.position.y
}
set y(v) {
this.position = {
y: v,
}
}
get position() {
return this.#config.position
}
set position(v) {
if (typeof v !== 'object') return
if (v.x) v.x = parseInt(v.x)
if (v.y) v.y = parseInt(v.y)
this.#config.position = { ...this.#config.position, ...v }
this.#updateLogoPosition()
}
get backCompatibilityFns() {
const _this = this
return {
setLogoDisplay: (v) => (_this.hidden = v),
setLogo: _this.setImage,
setLogoImage: (e) => (_this.image = e.target.files[0]),
resetLogoImage: _this.resetImage,
setLogoRatio: (v) => (_this.ratio = v),
setLogoOpacity: (v) => (_this.opacity = v),
logoPadding: (key, value) => {
switch (key) {
case 'x':
this.position = {
x: value,
}
break
case 'y':
this.position = {
y: value,
}
break
default:
this.position = value
break
}
},
logoReset: _this.resetPosition,
}
}
get config() {
return {
...this.#config,
}
}
get HTML() {
return `
<label for="operator-logo">Operator Logo</label>
<input type="checkbox" id="operator-logo" name="operator-logo" ${this.hidden ? '' : 'checked'}/>
<div id="operator-logo-realted" ${this.hidden ? 'hidden' : ''}>
<div>
<label for="logo-image">Logo Image (Store Locally)</label>
<input type="file" id="logo-image" accept="image/*"/>
<button type="button" id="logo-image-clear" ${this.#config.name ? (this.#config.name.isLocalFile ? '' : 'disabled') : 'disabled'}>Clear</button>
</div>
<div>
<label for="logo-image-url">Logo Image URL:</label>
<input type="text" id="logo-image-url" name="logo-image-url" value="${this.#config.name ? this.#config.name.value : ''}">
<button type="button" id="logo-image-url-apply">Apply</button>
</div>
<div>
<label for="logo-ratio">Logo Ratio</label>
<input type="range" min="0" max="100" step="0.1" id="logo-ratio-slider" value="${this.ratio}" />
<input type="number" id="logo-ratio-input" name="logo-ratio" value="${this.ratio}" />
</div>
<div>
<label for="logo-opacity">Logo Opacity</label>
<input type="range" min="0" max="100" data-css-class="logo" step="1" id="logo-opacity-slider" value="${this.opacity}" />
<input type="number" id="logo-opacity-input" name="logo-opacity" value="${this.opacity}" />
</div>
<div>
<label for="logo-position-x">Logo X Position</label>
<input type="range" min="0" max="100" id="logo-position-x-slider" value="${this.position.x}" />
<input type="number" id="logo-position-x-input" name="logo-position-x" value="${this.position.x}" />
</div>
<div>
<label for="logo-position-y">Logo Y Position</label>
<input type="range" min="0" max="100" id="logo-position-y-slider" value="${this.position.y}" />
<input type="number" id="logo-position-y-input" name="logo-position-y" value="${this.position.y}" />
</div>
</div>
`
}
get listeners() {
return [
{
id: 'operator-logo',
event: 'click',
handler: (e) => {
showRelatedHTML(e.currentTarget, 'operator-logo-realted')
this.hidden = !e.currentTarget.checked
},
},
{
id: 'logo-image',
event: 'change',
handler: (e) => (this.image = e.target.files[0]),
},
{
id: 'logo-image-clear',
event: 'click',
handler: () => this.resetImage(),
},
{
id: 'logo-image-url-apply',
event: 'click',
handler: () =>
(this.image =
document.getElementById('logo-image-url').value),
},
{
id: 'logo-ratio-slider',
event: 'input',
handler: (e) => {
syncHTMLValue(e.currentTarget, 'logo-ratio-input')
this.ratio = e.currentTarget.value
},
},
{
id: 'logo-ratio-input',
event: 'change',
handler: (e) => {
syncHTMLValue(e.currentTarget, 'logo-ratio-slider')
this.ratio = e.currentTarget.value
},
},
{
id: 'logo-opacity-slider',
event: 'input',
handler: (e) => {
syncHTMLValue(e.currentTarget, 'logo-opacity-input')
this.opacity = e.currentTarget.value
},
},
{
id: 'logo-opacity-input',
event: 'change',
handler: (e) => {
syncHTMLValue(e.currentTarget, 'logo-opacity-slider')
this.opacity = e.currentTarget.value
},
},
{
id: 'logo-position-x-slider',
event: 'input',
handler: (e) => {
syncHTMLValue(e.currentTarget, 'logo-position-x-input')
this.position = {
x: e.currentTarget.value,
}
},
},
{
id: 'logo-position-x-input',
event: 'change',
handler: (e) => {
syncHTMLValue(e.currentTarget, 'logo-position-x-slider')
this.position = {
x: e.currentTarget.value,
}
},
},
{
id: 'logo-position-y-slider',
event: 'input',
handler: (e) => {
syncHTMLValue(e.currentTarget, 'logo-position-y-input')
this.position = {
y: e.currentTarget.value,
}
},
},
{
id: 'logo-position-y-input',
event: 'change',
handler: (e) => {
syncHTMLValue(e.currentTarget, 'logo-position-y-slider')
this.position = {
y: e.currentTarget.value,
}
},
},
]
}
applyConfig(key, value) {
switch (key) {
case 'hidden':
this.hidden = value
break
case 'ratio':
this.ratio = value
break
case 'opacity':
this.opacity = value
break
case 'image':
this.image = value
break
case 'position':
this.position = value
break
default:
return
}
}
}

View File

@@ -0,0 +1,365 @@
import {
insertHTMLChild,
updateHTMLOptions,
showRelatedHTML,
syncHTMLValue,
readFile,
getCurrentHTMLOptions,
} from '@/components/helper'
import buildConfig from '!/config.json'
export default class Music {
#el = document.createElement('div')
#parentEl
#audio = {
intro: {
id: 'music-intro',
el: null,
},
loop: {
id: 'music-loop',
el: null,
},
}
#music = {
mapping: buildConfig.music_mapping,
location: buildConfig.music_folder,
current: null,
isUsingCustom: false,
list: [],
}
#config = {
useMusic: false,
timeOffset: 0.3,
volume: 50,
name: null,
}
#backgroundObj
constructor(el) {
this.#parentEl = el
this.#el.id = 'music-box'
this.#el.innerHTML = `
<audio id="${this.#audio.intro.id}" preload="auto">
<source type="audio/ogg" />
</audio>
<audio id="${this.#audio.loop.id}" preload="auto">
<source type="audio/ogg" />
</audio>
`
insertHTMLChild(this.#parentEl, this.#el)
}
async init() {
this.#music.list = Object.keys(this.#music.mapping)
this.#audio.intro.el = document.getElementById(this.#audio.intro.id)
this.#audio.loop.el = document.getElementById(this.#audio.loop.id)
this.#audio.intro.el.volume = this.#volume
this.#audio.loop.el.volume = this.#volume
this.#audio.intro.el.ontimeupdate = () => {
if (
this.#audio.intro.el.currentTime >=
this.#audio.intro.el.duration - this.#config.timeOffset
) {
this.#audio.intro.el.pause()
this.#audio.loop.el.currentTime = 0
this.#audio.loop.el.volume = this.#volume
}
}
this.#audio.loop.el.ontimeupdate = () => {
if (
this.#audio.loop.el.currentTime >=
this.#audio.loop.el.duration - this.#config.timeOffset
) {
this.#audio.loop.el.currentTime = 0
this.#audio.loop.el.play()
}
}
}
success() {
if (this.#music.current === null)
this.music = this.#backgroundObj.current
}
link(backgroundObj) {
this.#backgroundObj = backgroundObj
}
reset() {
document.getElementById('custom-music').value = ''
document.getElementById('custom-music-clear').disabled = true
this.#music.isUsingCustom = false
this.#config.name = null
if (this.#config.useMusic) {
this.#playMusic()
}
}
#setMusic(data, type) {
this.#audio.loop.el.src = data
this.#audio.loop.el.querySelector('source').type = type
this.#music.isUsingCustom = true
this.#playMusic()
}
#playMusic() {
if (!this.#music.isUsingCustom) {
const introOgg = this.#music.mapping[this.#music.current].intro
const intro = `./assets/${this.#music.location}/${introOgg}`
const loop = `./assets/${this.#music.location}/${this.#music.mapping[this.#music.current].loop}`
this.#audio.loop.el.src = loop
this.#audio.loop.el.querySelector('source').type = 'audio/ogg'
if (introOgg) {
this.#audio.intro.el.src = intro || loop
this.#audio.intro.el.querySelector('source').type = 'audio/ogg'
this.#audio.intro.el.play()
this.#audio.loop.el.volume = 0
this.#audio.loop.el.play()
} else {
this.#audio.loop.el.volume = this.#volume
this.#audio.loop.el.play()
}
} else {
this.#audio.intro.el.pause()
this.#audio.loop.el.volume = this.#volume
this.#audio.loop.el.play()
}
}
#stopMusic() {
this.#audio.intro.el.pause()
this.#audio.loop.el.pause()
}
get timeOffset() {
return this.#config.timeOffset
}
set timeOffset(value) {
value = value < 0 ? 0 : parseFloat(value)
this.#config.timeOffset = value
}
get volume() {
return this.#config.volume
}
get #volume() {
return this.#config.volume / 100
}
set volume(value) {
value = value < 0 ? 0 : value > 100 ? 100 : parseInt(value)
this.#config.volume = value
this.#audio.intro.el.volume = this.#volume
if (this.#audio.intro.el.paused)
this.#audio.loop.el.volume = this.#volume
}
get musics() {
return this.#music.list
}
get useMusic() {
return this.#config.useMusic
}
set useMusic(value) {
this.#config.useMusic = value
if (value) {
this.#playMusic()
} else {
this.#stopMusic()
}
}
get music() {
return this.#music.current
}
get isUsingCustom() {
return this.#music.isUsingCustom
}
set music(name) {
if (name !== null && name !== this.#music.current) {
this.#music.current = name
if (this.#config.useMusic && !this.#music.isUsingCustom) {
this.#audio.loop.el.pause()
this.#audio.intro.el.pause()
this.#playMusic()
}
getCurrentHTMLOptions('music-select', name)
}
}
set custom(url) {
if (!url) {
this.reset()
return
}
const update = (url, type, v = null) => {
this.#config.name = {
isLocalFile: v !== null,
value: v ? v.name : url,
}
this.#setMusic(url, type)
document.getElementById('custom-music-clear').disabled = false
}
if (typeof url === 'object') {
readFile(url, (blobURL, type) => update(blobURL, type, url))
} else {
update(url, url.split('.').pop())
}
}
get currentMusic() {
// Note: Back Compatibility
return this.music
}
changeMusic(name) {
// Note: Back Compatibility
this.music = name
}
get backCompatibilityFns() {
const _this = this
return {
setMusicFromWE: (url) => (_this.custom = url),
setMusic: (e) => (_this.custom = e.target.files[0]),
resetMusic: _this.reset,
}
}
get config() {
return {
default: this.#music.current,
...this.#config,
}
}
get HTML() {
return `
<div>
<label for="music">Music</label>
<input type="checkbox" id="music" name="music" ${this.useMusic ? 'checked' : ''}/>
<div id="music-realted" ${this.useMusic ? '' : 'hidden'}>
<div>
<label for="music-select">Choose theme music:</label>
<select name="music-select" id="music-select">
${updateHTMLOptions(this.musics)}
</select>
</div>
<div>
<label for="custom-music">Custom Music (Store Locally)</label>
<input type="file" id="custom-music" accept="audio/*"/>
<button type="button" id="custom-music-clear" ${this.#config.name ? (this.#config.name.isLocalFile ? '' : 'disabled') : 'disabled'}>Clear</button>
</div>
<div>
<label for="custom-music-url">Custom Music URL:</label>
<input type="text" id="custom-music-url" name="custom-music-url" value="${this.#config.name ? this.#config.name.value : ''}">
<button type="button" id="custom-music-url-apply">Apply</button>
</div>
<div>
<label for="music-volume">Music Volume</label>
<input type="range" min="0" max="100" step="1" id="music-volume-slider" value="${this.volume}" />
<input type="number" id="music-volume-input" min="0" max="100" step="1" name="music-volume" value="${this.volume}" />
</div>
<div>
<label for="music-switch-offset">Music Swtich Offset</label>
<input type="range" min="0" max="1" step="0.01" id="music-switch-offset-slider" value="${this.timeOffset}" />
<input type="number" id="music-switch-offset-input" min="0" max="1" step="0.01" name="music-switch-offset" value="${this.timeOffset}" />
</div>
</div>
</div>
`
}
get listeners() {
return [
{
id: 'music',
event: 'click',
handler: (e) => {
showRelatedHTML(e.currentTarget, 'music-realted')
this.useMusic = e.currentTarget.checked
},
},
{
id: 'music-select',
event: 'change',
handler: (e) => (this.music = e.currentTarget.value),
},
{
id: 'custom-music',
event: 'change',
handler: (e) => (this.custom = e.target.files[0]),
},
{
id: 'custom-music-clear',
event: 'click',
handler: () => this.reset(),
},
{
id: 'custom-music-url-apply',
event: 'click',
handler: () =>
(this.custom =
document.getElementById('custom-music-url').value),
},
{
id: 'music-volume-slider',
event: 'input',
handler: (e) => {
syncHTMLValue(e.currentTarget, 'music-volume-input')
this.volume = e.currentTarget.value
},
},
{
id: 'music-volume-input',
event: 'change',
handler: (e) => {
syncHTMLValue(e.currentTarget, 'music-volume-slider')
this.volume = e.currentTarget.value
},
},
{
id: 'music-switch-offset-slider',
event: 'input',
handler: (e) => {
syncHTMLValue(e.currentTarget, 'music-switch-offset-input')
this.timeOffset = e.currentTarget.value
},
},
{
id: 'music-switch-offset-input',
event: 'change',
handler: (e) => {
syncHTMLValue(e.currentTarget, 'music-switch-offset-slider')
this.timeOffset = e.currentTarget.value
},
},
]
}
applyConfig(key, value) {
switch (key) {
case 'music':
this.music = value
break
case 'use-music':
this.useMusic = value
break
case 'volume':
this.volume = value
break
case 'custom':
this.custom = value
break
default:
return
}
}
}

View File

@@ -1,4 +1,4 @@
#player-box {
width: 100%;
height: 100%;
}
}

View File

@@ -0,0 +1,588 @@
import {
insertHTMLChild,
updateHTMLOptions,
showRelatedHTML,
syncHTMLValue,
createCustomEvent,
} from '@/components/helper'
import { spine } from '@aklive2d/module'
import '@/components/player.css'
import buildConfig from '!/config.json'
export default class Player {
#el = document.createElement('div')
#parentEl
#showControls = new URLSearchParams(window.location.search).has('controls')
#resetTime = window.performance.now()
#isPlayingInteract = false
#spine
#default = {
fps: 60,
padding: {
left: parseInt(buildConfig.viewport_left),
right: parseInt(buildConfig.viewport_right),
top: parseInt(buildConfig.viewport_top),
bottom: parseInt(buildConfig.viewport_bottom),
},
scale: 1,
}
#config = {
fps: this.#default.fps,
useStartAnimation: true,
usePadding: false,
padding: {
...this.#default.padding,
},
scale: this.#default.scale,
}
constructor(el) {
this.#parentEl = el
this.#el.id = 'player-box'
insertHTMLChild(this.#parentEl, this.#el)
}
async init() {
const _this = this
const playerConfig = {
atlasUrl: `./assets/${buildConfig.filename}.atlas`,
premultipliedAlpha: true,
alpha: true,
backgroundColor: '#00000000',
viewport: {
debugRender: false,
padLeft: `${buildConfig.viewport_left}%`,
padRight: `${buildConfig.viewport_right}%`,
padTop: `${buildConfig.viewport_top}%`,
padBottom: `${buildConfig.viewport_bottom}%`,
x: 0,
y: 0,
},
showControls: _this.#showControls,
touch: _this.#showControls,
fps: 60,
defaultMix: 0,
success: function (widget) {
if (
widget.skeleton.data.animations
.map((e) => e.name)
.includes('Start') &&
_this.useStartAnimation
) {
widget.animationState.setAnimation(0, 'Start', false)
}
widget.animationState.addAnimation(0, 'Idle', true, 0)
widget.animationState.addListener({
end: (e) => {
if (e.animation.name == 'Interact') {
_this.#isPlayingInteract = false
}
},
complete: () => {
if (
window.performance.now() - _this.#resetTime >=
8 * 1000 &&
Math.random() < 0.3
) {
_this.#resetTime = window.performance.now()
let entry = widget.animationState.setAnimation(
0,
'Special',
false
)
entry.mixDuration = 0.3
widget.animationState.addAnimation(
0,
'Idle',
true,
0
)
}
},
})
widget.canvas.onclick = function () {
if (_this.#isPlayingInteract) {
return
}
_this.#isPlayingInteract = true
let entry = widget.animationState.setAnimation(
0,
'Interact',
false
)
entry.mixDuration = 0.3
widget.animationState.addAnimation(0, 'Idle', true, 0)
}
document.dispatchEvent(Events.Ready.handler())
},
}
if (buildConfig.use_json === 'true') {
playerConfig.jsonUrl = `./assets/${buildConfig.filename}.json`
} else {
playerConfig.skelUrl = `./assets/${buildConfig.filename}.skel`
}
this.#spine = new spine.SpinePlayer(this.#el, playerConfig)
}
success() {
this.#loadViewport()
updateHTMLOptions(
this.#spine.skeleton.data.animations.map((e) => e.name),
'animation-selection'
)
}
resetPadding() {
this.padding = { ...this.#default.padding }
document.getElementById('position-padding-left-slider').value =
this.#default.padding.left
document.getElementById('position-padding-left-input').value =
this.#default.padding.left
document.getElementById('position-padding-right-slider').value =
this.#default.padding.right
document.getElementById('position-padding-right-input').value =
this.#default.padding.right
document.getElementById('position-padding-top-slider').value =
this.#default.padding.top
document.getElementById('position-padding-top-input').value =
this.#default.padding.top
document.getElementById('position-padding-bottom-slider').value =
this.#default.padding.bottom
document.getElementById('position-padding-bottom-input').value =
this.#default.padding.bottom
}
resetScale() {
this.scale = this.#default.scale
}
resetFPS() {
this.fps = this.#default.fps
document.getElementById('fps-slider').value = this.#default.fps
document.getElementById('fps-input').value = this.#default.fps
}
reset() {
this.resetFPS()
this.resetPadding()
this.resetScale()
this.#spine.play()
}
#loadViewport() {
this.#spine.updateViewport({
padLeft: `${this.#config.padding.left}%`,
padRight: `${this.#config.padding.right}%`,
padTop: `${this.#config.padding.top}%`,
padBottom: `${this.#config.padding.bottom}%`,
})
}
get usePadding() {
return this.#config.usePadding
}
set usePadding(v) {
this.#config.usePadding = v
}
set useStartAnimation(v) {
this.#config.useStartAnimation = v
}
get useStartAnimation() {
return this.#config.useStartAnimation
}
get spine() {
return this.#spine
}
set fps(v) {
v = parseInt(v)
this.#config.fps = v
this.#spine.setFps(v)
}
get fps() {
return this.#config.fps
}
set scale(v) {
v = parseInt(v)
this.#config.scale = 1 / v
this.#spine.setOperatorScale(1 / v)
}
get scale() {
return this.#config.scale
}
get node() {
return this.#el
}
get padLeft() {
return this.padding.left
}
set padLeft(v) {
this.padding = {
left: v,
}
}
get padRight() {
return this.padding.right
}
set padRight(v) {
this.padding = {
right: v,
}
}
get padTop() {
return this.padding.top
}
set padTop(v) {
this.padding = {
top: v,
}
}
get padBottom() {
return this.padding.bottom
}
set padBottom(v) {
this.padding = {
bottom: v,
}
}
get padding() {
return this.#config.padding
}
set padding(v) {
if (!v) {
this.resetPadding()
return
}
if (typeof v !== 'object') return
if (v.left) v.left = parseInt(v.left)
if (v.right) v.right = parseInt(v.right)
if (v.top) v.top = parseInt(v.top)
if (v.bottom) v.bottom = parseInt(v.bottom)
this.#config.padding = { ...this.#config.padding, ...v }
this.#loadViewport()
}
get backCompatibilityFns() {
const _this = this
return {
spinePlayer: _this.#spine,
setFPS: (fps) => (_this.fps = fps),
loadViewport: _this.#loadViewport,
setScale: (v) => (this.scale = v),
scale: _this.scale,
positionPadding: (key, value) => {
switch (key) {
case 'left':
this.padding = {
left: value,
}
break
case 'right':
this.padding = {
right: value,
}
break
case 'top':
this.padding = {
top: value,
}
break
case 'bottom':
this.padding = {
bottom: value,
}
break
default:
this.#config.padding = value
break
}
},
positionReset: _this.resetPadding,
scaleReset: _this.resetScale,
useStartAnimation: _this.useStartAnimation,
}
}
get config() {
return { ...this.#config }
}
get HTML() {
return `
<div>
<div>
<label for="fps">FPS</label>
<input type="range" min="1" max="60" value="${this.fps}" step="1" id="fps-slider"/>
<input type="number" id="fps-input" min="1" max="60" name="fps" value="${this.fps}" />
</div>
<div>
<label for="animation-select">Animation:</label>
<select name="animation-select" id="animation-selection"></select>
</div>
<div>
<label for="use-start-animation">Use Start Animation</label>
<input type="checkbox" id="use-start-animation" name="use-start-animation" checked/>
</div>
<button type="button" id="player-play" disabled>Play</button>
<button type="button" id="player-pause">Pause</button>
<div>
<label for="scale">Scale</label>
<input type="range" min="0.1" max="10" step="0.1" id="scale-slider" value="${this.scale}" />
<input type="number" id="scale-input" name="scale" value="${this.scale}" step="0.1"/>
</div>
<div>
<label for="position">Position</label>
<input type="checkbox" id="position" name="position" ${this.usePadding ? 'checked' : ''}/>
<div id="position-realted" ${this.usePadding ? '' : 'hidden'}>
<div>
<label for="position-padding-left">Padding Left</label>
<input type="range" min="-100" max="100" id="position-padding-left-slider" value="${this.padding.left}" />
<input type="number" id="position-padding-left-input" name="position-padding-left" value="${this.padding.left}" />
</div>
<div>
<label for="position-padding-right">Padding Right</label>
<input type="range" min="-100" max="100" id="position-padding-right-slider" value="${this.padding.right}" />
<input type="number" id="position-padding-right-input" name="position-padding-right" value="${this.padding.right}" />
</div>
<div>
<label for="position-padding-top">Padding Top</label>
<input type="range" min="-100" max="100" id="position-padding-top-slider" value="${this.padding.top}" />
<input type="number" id="position-padding-top-input" name="position-padding-top" value="${this.padding.top}" />
</div>
<div>
<label for="position-padding-bottom">Padding Bottom</label>
<input type="range" min="-100" max="100" id="position-padding-bottom-slider" value="${this.padding.bottom}" />
<input type="number" id="position-padding-bottom-input" name="position-padding-bottom" value="${this.padding.bottom}" />
</div>
</div>
</div>
</div>
`
}
get listeners() {
return [
{
id: 'fps-slider',
event: 'change',
handler: (e) => {
syncHTMLValue(e.currentTarget, 'fps-input')
this.fps = e.currentTarget.value
},
},
{
id: 'fps-input',
event: 'change',
handler: (e) => {
syncHTMLValue(e.currentTarget, 'fps-slider')
this.fps = e.currentTarget.value
},
},
{
id: 'animation-selection',
event: 'change',
handler: (e) => {
this.spine.animationState.setAnimation(
0,
e.currentTarget.value,
false,
0
)
this.spine.animationState.addAnimation(0, 'Idle', true, 0)
},
},
{
id: 'use-start-animation',
event: 'click',
handler: (e) => {
this.useStartAnimation = e.currentTarget.checked
},
},
{
id: 'player-play',
event: 'click',
handler: (e) => {
this.spine.play()
e.currentTarget.disabled = true
document.getElementById('player-pause').disabled = false
},
},
{
id: 'player-pause',
event: 'click',
handler: (e) => {
this.spine.pause()
e.currentTarget.disabled = true
document.getElementById('player-play').disabled = false
},
},
{
id: 'scale-slider',
event: 'input',
handler: (e) => {
syncHTMLValue(e.currentTarget, 'scale-input')
this.scale = e.currentTarget.value
},
},
{
id: 'scale-input',
event: 'change',
handler: (e) => {
syncHTMLValue(e.currentTarget, 'scale-slider')
this.scale = e.currentTarget.value
},
},
{
id: 'position',
event: 'click',
handler: (e) => {
showRelatedHTML(e.currentTarget, 'position-realted')
this.usePadding = e.currentTarget.checked
if (!e.currentTarget.checked) this.resetPadding()
},
},
{
id: 'position-padding-left-slider',
event: 'input',
handler: (e) => {
syncHTMLValue(
e.currentTarget,
'position-padding-left-input'
)
this.padding = {
left: e.currentTarget.value,
}
},
},
{
id: 'position-padding-left-input',
event: 'change',
handler: (e) => {
syncHTMLValue(
e.currentTarget,
'position-padding-left-slider'
)
this.padding = {
left: e.currentTarget.value,
}
},
},
{
id: 'position-padding-right-slider',
event: 'input',
handler: (e) => {
syncHTMLValue(
e.currentTarget,
'position-padding-right-input'
)
this.padding = {
right: e.currentTarget.value,
}
},
},
{
id: 'position-padding-right-input',
event: 'change',
handler: (e) => {
syncHTMLValue(
e.currentTarget,
'position-padding-right-slider'
)
this.padding = {
right: e.currentTarget.value,
}
},
},
{
id: 'position-padding-top-slider',
event: 'input',
handler: (e) => {
syncHTMLValue(e.currentTarget, 'position-padding-top-input')
this.padding = {
top: e.currentTarget.value,
}
},
},
{
id: 'position-padding-top-input',
event: 'change',
handler: (e) => {
syncHTMLValue(
e.currentTarget,
'position-padding-top-slider'
)
this.padding = {
top: e.currentTarget.value,
}
},
},
{
id: 'position-padding-bottom-slider',
event: 'input',
handler: (e) => {
syncHTMLValue(
e.currentTarget,
'position-padding-bottom-input'
)
this.padding = {
bottom: e.currentTarget.value,
}
},
},
{
id: 'position-padding-bottom-input',
event: 'change',
handler: (e) => {
syncHTMLValue(
e.currentTarget,
'position-padding-bottom-slider'
)
this.padding = {
bottom: e.currentTarget.value,
}
},
},
]
}
applyConfig(key, value) {
switch (key) {
case 'fps':
this.fps = value
break
case 'scale':
this.scale = value
break
case 'position':
this.padding = value
break
case 'use-start-animation':
this.useStartAnimation = value
break
default:
return
}
}
}
export const Events = {
Ready: createCustomEvent('player-ready'),
}

View File

@@ -1,4 +1,4 @@
@import "https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;500;700&family=Noto+Sans+KR:wght@400;500;700&family=Noto+Sans+SC:wght@400;500;700&family=Noto+Sans:wght@400;500;700&display=swap";
@import 'https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;500;700&family=Noto+Sans+KR:wght@400;500;700&family=Noto+Sans+SC:wght@400;500;700&family=Noto+Sans:wght@400;500;700&display=swap';
#voice-box {
position: fixed;
@@ -8,7 +8,8 @@
width: 480px;
opacity: 0;
margin: 16px;
font-family: 'Noto Sans SC', 'Noto Sans JP', 'Noto Sans KR', 'Noto Sans', sans-serif;
font-family:
'Noto Sans SC', 'Noto Sans JP', 'Noto Sans KR', 'Noto Sans', sans-serif;
transition: opacity 0.5s cubic-bezier(0.65, 0.05, 0.36, 1);
}
@@ -71,4 +72,4 @@
color: white;
display: inline-block;
padding: 8px;
}
}

View File

@@ -0,0 +1,641 @@
import {
insertHTMLChild,
updateElementPosition,
updateHTMLOptions,
showRelatedHTML,
syncHTMLValue,
} from '@/components/helper'
import '@/components/voice.css'
import buildConfig from '!/config.json'
export default class Voice {
#el = document.createElement('div')
#parentEl
#charwordTable
#default = {
region: buildConfig.voice_default_region,
duration: {
idle: 10 * 60 * 1000,
next: 3 * 60 * 1000,
},
language: {
voice: null,
},
subtitle: {
x: 0,
y: 100,
},
}
#voice = {
id: {
current: null,
last: null,
},
listener: {
idle: -1,
next: -1,
},
lastClickToNext: false,
locations: null,
list: null,
}
#audio = {
id: 'voice-audio',
el: new Audio(),
isPlaying: false,
}
#config = {
useSubtitle: false,
useVoice: false,
useVoiceActor: false,
language: null,
subtitle: {
language: this.#default.region,
...this.#default.subtitle,
},
duration: { ...this.#default.duration },
}
#playerObj
constructor(el) {
this.#parentEl = el
this.#el.id = 'voice-box'
this.#el.hidden = true
this.#el.innerHTML = `
<audio id="${this.#audio.id}" autoplay>
<source type="audio/ogg" />
</audio>
<div class="voice-wrapper" id="voice-wrapper">
<div class="voice-title" id="voice-title"></div>
<div class="voice-subtitle">
<div id="voice-subtitle"></div>
<div class="voice-triangle"></div>
</div>
</div>
<div id="voice-actor-box" hidden>
<div class="voice-actor">
<span class="voice-actor-icon"></span>
<span id="voice-actor-name" class="voice-actor-name"></span>
</div>
</div>
`
insertHTMLChild(this.#parentEl, this.#el)
}
async init() {
const res = await fetch('./assets/charword_table.json')
this.#charwordTable = await res.json()
this.#voice.languages = Object.keys(
this.#charwordTable.voiceLangs[this.#default.region]
)
this.#default.language.voice = this.#voice.languages[0]
this.#config.language = this.#default.language.voice
this.#voice.locations = this.#getVoiceLocations()
this.#voice.list = Object.keys(this.#getVoices())
}
success() {
const audioEndedFunc = () => {
this.#audio.isPlaying = false
this.#setCurrentSubtitle(null)
this.#audio.lastClickToNext = false
}
this.#audio.el.addEventListener('ended', audioEndedFunc)
this.#playEntryVoice()
this.#initNextVoiceTimer()
this.#playerObj.node.addEventListener('click', () => {
this.#audio.lastClickToNext = true
this.#nextVoice()
})
document.addEventListener('mousemove', () => {
if (this.#voice.listener.idle === -1) {
this.#initIdleVoiceTimer()
}
})
}
link(playerObj) {
this.#playerObj = playerObj
}
resetPosition() {
this.position = { ...this.#default.subtitle }
document.getElementById('subtitle-padding-x-slider').value =
this.#default.subtitle.x
document.getElementById('subtitle-padding-x-input').value =
this.#default.subtitle.x
document.getElementById('subtitle-padding-y-slider').value =
this.#default.subtitle.y
document.getElementById('subtitle-padding-y-input').value =
this.#default.subtitle.y
}
reset() {
this.resetPosition()
}
#getVoiceLocations() {
const folders = buildConfig.voice_folders
const customVoiceName = this.#voice.languages.filter(
(i) => !folders.sub.map((e) => e.lang).includes(i)
)[0]
folders.sub = folders.sub.map((e) => {
return {
name: e.name,
lang: e.lang === 'CUSTOM' ? customVoiceName : e.lang,
}
})
return folders
}
#getVoices() {
return this.#charwordTable.subtitleLangs[this.#config.subtitle.language]
.default
}
#playEntryVoice() {
this.#playSpecialVoice('问候')
}
#playSpecialVoice(matcher) {
const voices = this.#getVoices()
const voiceId = Object.keys(voices).find(
(e) => voices[e].title === matcher
)
this.#playVoice(voiceId)
}
#playVoice(id) {
if (!this.useVoice) return
this.#voice.id.last = this.#voice.id.current
this.#voice.id.current = id
this.#audio.el.src = `./assets/${this.#getVoiceLocation()}/${id}.ogg`
let startPlayPromise = this.#audio.el.play()
if (startPlayPromise !== undefined) {
startPlayPromise
.then(() => {
this.#audio.isPlaying = true
this.#setCurrentSubtitle(id)
})
.catch(() => {
return
})
}
}
#getVoiceLocation() {
const locations = this.#voice.locations
return `${locations.main}/${locations.sub.find((e) => e.lang === this.#config.language).name}`
}
#setCurrentSubtitle(id) {
if (id === null) {
setTimeout(() => {
if (this.#audio.isPlaying) return
this.#toggleSubtitle(0)
}, 5 * 1000)
return
}
const subtitle = this.#getSubtitleById(id)
const title = subtitle.title
const content = subtitle.text
const cvInfo =
this.#charwordTable.voiceLangs[this.subtitleLanguage][
this.#config.language
]
document.getElementById('voice-title').innerText = title
document.getElementById('voice-subtitle').innerText = content
document.getElementById('voice-actor-name').innerText = cvInfo.join('')
if (this.#audio.isPlaying) {
this.#toggleSubtitle(1)
}
}
#toggleSubtitle(v) {
this.#el.style.opacity = v ? 1 : 0
}
#getSubtitleById(id) {
const obj =
this.#charwordTable.subtitleLangs[this.#config.subtitle.language]
let key = 'default'
if (obj[this.#config.language]) {
key = this.#config.language
}
return obj[key][id]
}
#getSubtitleLanguages() {
return Object.keys(this.#charwordTable.subtitleLangs)
}
#updateSubtitlePosition() {
updateElementPosition(this.#el, {
x: this.position.x,
y: this.position.y - 100,
})
}
#initNextVoiceTimer() {
this.#voice.listener.next = setInterval(() => {
if (!this.#voice.lastClickToNext) {
this.#nextVoice()
}
}, this.#config.duration.next)
}
#nextVoice() {
const getVoiceId = () => {
const id =
this.#voice.list[
Math.floor(Math.random() * this.#voice.list.length)
]
return id === this.#voice.id.last ? getVoiceId() : id
}
this.#playVoice(getVoiceId())
}
#initIdleVoiceTimer() {
this.#voice.listener.idle = setInterval(() => {
this.#playSpecialVoice('闲置')
clearInterval(this.#voice.listener.idle)
this.#voice.listener.idle = -1
}, this.#config.duration.idle)
}
set useSubtitle(show) {
this.#config.useSubtitle = show
this.#el.hidden = !show
}
get useSubtitle() {
return this.#config.useSubtitle
}
set useVoice(show) {
this.#config.useVoice = show
this.#playEntryVoice()
if (!show && this.#audio.isPlaying) {
this.#audio.el.pause()
}
this.#toggleSubtitle(0)
}
get useVoice() {
return this.#config.useVoice
}
set useVoiceActor(show) {
this.#config.useVoiceActor = show
document.getElementById('voice-actor-box').hidden = !show
}
get useVoiceActor() {
return this.#config.useVoiceActor
}
set subtitleLanguage(lang) {
if (this.#getSubtitleLanguages().includes(lang)) {
this.#config.subtitle.language = lang
} else {
this.#config.subtitle.language = this.#default.region
}
this.#setCurrentSubtitle(this.#voice.id.current)
}
get subtitleLanguage() {
return this.#config.subtitle.language
}
get subtitleLanguages() {
return this.#getSubtitleLanguages()
}
get x() {
return this.position.x
}
set x(v) {
this.position = {
x: v,
}
}
get y() {
return this.position.y
}
set y(v) {
this.position = {
y: v,
}
}
get position() {
return {
x: this.#config.subtitle.x,
y: this.#config.subtitle.y,
}
}
set position(v) {
if (typeof v !== 'object') return
if (v.x) v.x = parseInt(v.x)
if (v.y) v.y = parseInt(v.y)
this.#config.subtitle = { ...this.#config.subtitle, ...v }
console.log(v)
this.#updateSubtitlePosition()
}
set language(lang) {
if (this.#voice.languages.includes(lang)) {
this.#config.language = lang
} else {
this.#config.language = this.#default.language.voice
}
const availableSubtitleLang = this.#getSubtitleLanguages()
if (!availableSubtitleLang.includes(this.#config.subtitle.language)) {
this.#config.subtitle.language = availableSubtitleLang[0]
}
}
get language() {
return this.#config.language
}
get languages() {
return this.#voice.languages
}
get duration() {
return {
idle: this.#config.duration.idle / 60 / 1000,
next: this.#config.duration.next / 60 / 1000,
}
}
set duration(v) {
if (typeof v !== 'object') return
if (v.idle) {
clearInterval(this.#voice.listener.idle)
if (v.idle !== 0) {
this.#config.duration.idle = parseInt(v.idle) * 60 * 1000
this.#initIdleVoiceTimer()
}
}
if (v.next) {
clearInterval(this.#voice.listener.next)
if (v.next !== 0) {
this.#config.duration.next = parseInt(v.next) * 60 * 1000
this.#initNextVoiceTimer()
}
}
}
get durationIdle() {
return this.duration.idle
}
set durationIdle(duration) {
this.duration = {
idle: duration,
}
}
set durationNext(duration) {
this.duration = {
next: duration,
}
}
get durationNext() {
return this.duration.next
}
set subtitleX(x) {
// Note: Back Compatibility
this.position = {
x,
}
}
get subtitleX() {
// Note: Back Compatibility
return this.position.x
}
set subtitleY(y) {
// Note: Back Compatibility
this.position = {
y,
}
}
get subtitleY() {
// Note: Back Compatibility
return this.position.y
}
set idleDuration(duration) {
// Note: Back Compatibility
this.duration = {
idle: duration,
}
}
get idleDuration() {
// Note: Back Compatibility
return this.duration.idle
}
set nextDuration(duration) {
// Note: Back Compatibility
this.duration.next = duration
}
get nextDuration() {
// Note: Back Compatibility
return this.duration.next
}
get config() {
return { ...this.#config }
}
get HTML() {
return `
<div>
<label for="voice">Voice</label>
<input type="checkbox" id="voice" name="voice" ${this.useVoice ? 'checked' : ''}/>
<div id="voice-realted" ${this.useVoice ? '' : 'hidden'}>
<div>
<label for="voice-lang-select">Choose the language of voice:</label>
<select name="voice-lang" id="voice-lang-select">
${updateHTMLOptions(this.languages)}
</select>
</div>
<div>
<label for="voice-idle-duration">Idle Duration (min)</label>
<input type="number" id="voice-idle-duration-input" min="0" name="voice-idle-duration" value="${this.duration.idle}" />
</div>
<div>
<label for="voice-next-duration">Next Duration (min)</label>
<input type="number" id="voice-next-duration-input" name="voice-next-duration" value="${this.duration.next}" />
</div>
<div>
<label for="subtitle">Subtitle</label>
<input type="checkbox" id="subtitle" name="subtitle" ${this.useSubtitle ? 'checked' : ''}/>
<div id="subtitle-realted" ${this.useSubtitle ? '' : 'hidden'}>
<div>
<label for="subtitle-lang-select">Choose the language of subtitle:</label>
<select name="subtitle-lang" id="subtitle-lang-select">
${updateHTMLOptions(this.subtitleLanguages)}
</select>
</div>
<div>
<label for="subtitle-padding-x">Subtitle X Position</label>
<input type="range" min="0" max="100" id="subtitle-padding-x-slider" value="${this.position.x}" />
<input type="number" id="subtitle-padding-x-input" name="subtitle-padding-x" value="${this.position.x}" />
</div>
<div>
<label for="subtitle-padding-y">Subtitle Y Position</label>
<input type="range" min="0" max="100" id="subtitle-padding-y-slider" value="${this.position.y}" />
<input type="number" id="subtitle-padding-y-input" name="subtitle-padding-y" value="${this.position.y}" />
</div>
<div>
<label for="voice-actor">Voice Actor</label>
<input type="checkbox" id="voice-actor" name="voice-actor" ${this.useVoiceActor ? 'checked' : ''}/>
</div>
</div>
</div>
</div>
</div>
`
}
get listeners() {
return [
{
id: 'voice',
event: 'click',
handler: (e) => {
showRelatedHTML(e.currentTarget, 'voice-realted')
this.useVoice = e.currentTarget.checked
},
},
{
id: 'voice-lang-select',
event: 'change',
handler: (e) => {
this.language = e.currentTarget.value
},
},
{
id: 'voice-idle-duration-input',
event: 'change',
handler: (e) => {
this.duration = {
idle: e.currentTarget.value,
}
},
},
{
id: 'voice-next-duration-input',
event: 'change',
handler: (e) => {
this.duration = {
next: e.currentTarget.value,
}
},
},
{
id: 'subtitle',
event: 'click',
handler: (e) => {
showRelatedHTML(e.currentTarget, 'subtitle-realted')
this.useSubtitle = e.currentTarget.checked
},
},
{
id: 'subtitle-lang-select',
event: 'change',
handler: (e) => (this.subtitleLanguage = e.currentTarget.value),
},
{
id: 'subtitle-padding-x-slider',
event: 'input',
handler: (e) => {
syncHTMLValue(e.currentTarget, 'subtitle-padding-x-input')
this.position = {
x: e.currentTarget.value,
}
},
},
{
id: 'subtitle-padding-x-input',
event: 'change',
handler: (e) => {
syncHTMLValue(e.currentTarget, 'subtitle-padding-x-slider')
this.position = {
x: e.currentTarget.value,
}
},
},
{
id: 'subtitle-padding-y-slider',
event: 'input',
handler: (e) => {
syncHTMLValue(e.currentTarget, 'subtitle-padding-y-input')
this.position = {
y: e.currentTarget.value,
}
},
},
{
id: 'subtitle-padding-y-input',
event: 'change',
handler: (e) => {
syncHTMLValue(e.currentTarget, 'subtitle-padding-y-slider')
this.position = {
y: e.currentTarget.value,
}
},
},
{
id: 'voice-actor',
event: 'click',
handler: (e) => {
this.useVoiceActor = e.currentTarget.checked
},
},
]
}
applyConfig(key, value) {
switch (key) {
case 'use-voice':
this.useVoice = value
break
case 'language':
this.language = value
break
case 'duration':
this.duration = value
break
case 'use-subtitle':
this.useSubtitle = value
break
case 'subtitle-language':
this.subtitleLanguage = value
break
case 'subtitle-position':
this.position = value
break
case 'use-voice-actor':
this.useVoiceActor = value
break
default:
return
}
}
}

View File

@@ -0,0 +1,376 @@
import Events from '@/components/events'
window.wallpaperPropertyListener = {
applyGeneralProperties: function (properties) {
if (properties.fps) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'player',
key: 'fps',
value: properties.fps,
})
)
}
},
applyUserProperties: function (properties) {
console.log(properties)
if (properties.privacydonottrack) {
document.dispatchEvent(
Events.Insight.Register.handler(
!properties.privacydonottrack.value
)
)
}
if (properties.logo) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'logo',
key: 'hidden',
value: !properties.logo.value,
})
)
}
if (properties.logoratio) {
if (properties.logoratio.value) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'logo',
key: 'ratio',
value: properties.logoratio.value,
})
)
}
}
if (properties.logoopacity) {
if (properties.logoopacity.value) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'logo',
key: 'opacity',
value: properties.logoopacity.value,
})
)
}
}
if (properties.logoimage) {
if (properties.logoimage.value) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'logo',
key: 'image',
value: 'file:///' + properties.logoimage.value,
})
)
} else {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'logo',
key: 'image',
value: null,
})
)
}
}
if (properties.logox) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'logo',
key: 'position',
value: {
x: properties.logox.value,
},
})
)
}
if (properties.logoy) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'logo',
key: 'position',
value: {
y: properties.logoy.value,
},
})
)
}
if (properties.defaultbackground) {
if (properties.defaultbackground.value) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'background',
key: 'default',
value: properties.defaultbackground.value,
})
)
}
}
if (properties.background) {
if (properties.background.value) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'background',
key: 'custom',
value: `file:///${properties.background.value}`,
})
)
} else {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'background',
key: 'custom',
value: null,
})
)
}
}
if (properties.voicetitle) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'voice',
key: 'use-voice',
value: properties.voicetitle.value,
})
)
}
if (properties.voicelanguage) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'voice',
key: 'language',
value: properties.voicelanguage.value,
})
)
}
if (properties.voiceidle) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'voice',
key: 'duration',
value: {
idle: properties.voiceidle.value,
},
})
)
}
if (properties.voicenext) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'voice',
key: 'duration',
value: {
next: properties.voicenext.value,
},
})
)
}
if (properties.voicesubtitle) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'voice',
key: 'use-subtitle',
value: properties.voicesubtitle.value,
})
)
}
if (properties.voicesubtitlelanguage) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'voice',
key: 'subtitle-language',
value: properties.voicesubtitlelanguage.value,
})
)
}
if (properties.voicesubtitlex) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'voice',
key: 'subtitle-position',
value: {
x: properties.voicesubtitlex.value,
},
})
)
}
if (properties.voicesubtitley) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'voice',
key: 'subtitle-position',
value: {
y: properties.voicesubtitley.value,
},
})
)
}
if (properties.voiceactor) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'voice',
key: 'use-voice-actor',
value: properties.voiceactor.value,
})
)
}
if (properties.music_selection) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'music',
key: 'music',
value: properties.music_selection.value,
})
)
}
if (properties.music_title) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'music',
key: 'use-music',
value: properties.music_title.value,
})
)
}
if (properties.music_volume) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'music',
key: 'volume',
value: properties.music_volume.value,
})
)
}
if (properties.custom_music) {
if (properties.custom_music.value) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'music',
key: 'custom',
value: `file:///${properties.custom_music.value}`,
})
)
} else {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'music',
key: 'custom',
value: null,
})
)
}
}
if (properties.custom_video) {
if (properties.custom_video.value) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'background',
key: 'video',
value: `file:///${properties.custom_video.value}`,
})
)
} else {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'background',
key: 'video',
value: null,
})
)
}
}
if (properties.video_volume) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'background',
key: 'volume',
value: properties.video_volume.value,
})
)
}
if (properties.scale) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'player',
key: 'scale',
value: properties.scale.value,
})
)
}
if (properties.position) {
if (!properties.position.value) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'player',
key: 'position',
value: null,
})
)
} else {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'player',
key: 'position',
value: {
left: properties.paddingleft.value,
right: properties.paddingright.value,
top: properties.paddingtop.value,
bottom: properties.paddingbottom.value,
},
})
)
}
}
if (properties.paddingleft) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'player',
key: 'position',
value: {
left: properties.paddingleft.value,
},
})
)
}
if (properties.paddingright) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'player',
key: 'position',
value: {
right: properties.paddingright.value,
},
})
)
}
if (properties.paddingtop) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'player',
key: 'position',
value: {
top: properties.paddingtop.value,
},
})
)
}
if (properties.paddingbottom) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'player',
key: 'position',
value: {
bottom: properties.paddingbottom.value,
},
})
)
}
if (properties.useStartAnimation) {
document.dispatchEvent(
Events.RegisterConfig.handler({
target: 'player',
key: 'use-start-animation',
value: properties.useStartAnimation.value,
})
)
}
},
}

View File

@@ -0,0 +1,22 @@
html {
user-select: none;
height: 100%;
width: 100%;
overflow: hidden;
}
body,
#app {
margin: 0;
height: 100%;
width: 100%;
touch-action: none;
}
#widget-wrapper {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}

View File

@@ -0,0 +1,6 @@
import '@/index.css'
import '@/components/wallpaper_engine'
import AKLive2D from '@/components/aklive2d'
;(() => {
window.aklive2d = new AKLive2D(document.getElementById('app'))
})()

View File

@@ -0,0 +1,5 @@
import baseConfig from '@aklive2d/stylelint-config'
/** @type {import('stylelint').Config} */
export default {
...baseConfig,
}

View File

@@ -0,0 +1,57 @@
import { defineConfig } from 'vite'
import path from 'node:path'
import { has } from '@aklive2d/operator'
import { envParser, file } from '@aklive2d/libs'
import { copyShowcaseData } from '@aklive2d/vite-helpers'
import * as dirs from './index.js'
// https://vite.dev/config/
export default defineConfig(({ command, isPreview }) => {
let newOutDir = dirs.OUT_DIR
if (command === 'serve') {
const { name } = envParser.parse({
name: {
type: 'string',
short: 'n',
},
})
if (!name) {
throw new Error('Please set the operator name.')
}
if (!has(name)) {
throw new Error(`Invalid operator name: ${name}`)
}
if (isPreview) {
newOutDir = path.join(dirs.DIST_DIR, name)
} else {
file.rm(dirs.DATA_DIR)
copyShowcaseData(name, {
dataDir: dirs.DATA_DIR,
publicAssetsDir: dirs.PUBLIC_ASSETS_DIR,
})
}
}
return {
base: '',
resolve: {
alias: {
'@': path.resolve('./src'),
'!': dirs.DATA_DIR,
},
},
envDir: dirs.DATA_DIR,
publicDir: dirs.PUBLIC_DIR,
build: {
chunkSizeWarningLimit: 10000,
outDir: newOutDir,
rollupOptions: {
output: {
entryFileNames: `assets/[name].js`,
chunkFileNames: `assets/[name].js`,
assetFileNames: `assets/[name].[ext]`,
},
},
},
}
})

View File

@@ -1,99 +0,0 @@
akassets:
project_name: akassets
url: https://akassets.halyul.dev
insight_id: aklive2d
folder:
auto_update_data: ./data/auto_update
operator_data: ./data/operator/
directory_src: directory
showcase_src: showcase
operator: ./operator/
release: ./release/
background: background
music: music
directory: _assets
share: _share
voice:
main: voice
sub:
- name: jp
lang: JP
- name: cn
lang: CN_MANDARIN
- name: en
lang: EN
- name: kr
lang: KR
- name: custom
lang: CUSTOM
share:
title:
zh-CN: "明日方舟:"
en-US: "Arknights: "
directory:
title: AKLive2D
voice: jp/CN_037.ogg
error:
files: !include config/_directory.yaml
voice: CN_034.ogg
operators:
chen: !include config/chen.yaml
dusk: !include config/dusk.yaml
dusk_everything_is_a_miracle: !include config/dusk_everything_is_a_miracle.yaml
ling: !include config/ling.yaml
nearl: !include config/nearl.yaml
nian: !include config/nian.yaml
nian_unfettered_freedom: !include config/nian_unfettered_freedom.yaml
phatom_focus: !include config/phatom_focus.yaml
rosmontis: !include config/rosmontis.yaml
skadi: !include config/skadi.yaml
skadi_sublimation: !include config/skadi_sublimation.yaml
w: !include config/w.yaml
w_wonder: !include config/w_wonder.yaml
specter: !include config/specter.yaml
gavial: !include config/gavial.yaml
surtr_colorful_wonderland: !include config/surtr_colorful_wonderland.yaml
lee_trust_your_eyes: !include config/lee_trust_your_eyes.yaml
texas_the_omertosa: !include config/texas_the_omertosa.yaml
nearl_relight: !include config/nearl_relight.yaml
rosmontis_become_anew: !include config/rosmontis_become_anew.yaml
passager_dream_in_a_moment: !include config/passager_dream_in_a_moment.yaml
mizuki_summer_feast: !include config/mizuki_summer_feast.yaml
chongyue: !include config/chongyue.yaml
ling_it_does_wash_the_strings: !include config/ling_it_does_wash_the_strings.yaml
pozemka_snowy_plains_in_words: !include config/pozemka_snowy_plains_in_words.yaml
chen_ten_thousand_mountains: !include config/chen_ten_thousand_mountains.yaml
specter_born_as_one: !include config/specter_born_as_one.yaml
muelsyse: !include config/muelsyse.yaml
kaltsit_remnant: !include config/kaltsit_remnant.yaml
eyjafjalla_the_hvit_aska: !include config/eyjafjalla_the_hvit_aska.yaml
goldenglow_summer_flowers_fa394: !include config/goldenglow_summer_flowers_fa394.yaml
gavial_the_invincible_holiday_hd26: !include config/gavial_the_invincible_holiday_hd26.yaml
ling_towering_is_cliff_of_nostalgia: !include config/ling_towering_is_cliff_of_nostalgia.yaml
virtuosa: !include config/virtuosa.yaml
texas_the_omertosa_wingbreaker: !include config/texas_the_omertosa_wingbreaker.yaml
mwynar_w_dali: !include config/mwynar_w_dali.yaml
reed_the_frame_shadow_curator: !include config/reed_the_frame_shadow_curator.yaml
shu: !include config/shu.yaml
lin_heavenly_mirage: !include config/lin_heavenly_mirage.yaml
chongyue_alighting: !include config/chongyue_alighting.yaml
wisadel: !include config/wisadel.yaml
muelsyse_young_branch: !include config/muelsyse_young_branch.yaml
skadi_the_corrupting_heart_red_countess: !include config/skadi_the_corrupting_heart_red_countess.yaml
ines_under_the_flaming_dome: !include config/ines_under_the_flaming_dome.yaml
silverash_never_melting_ice: !include config/silverash_never_melting_ice.yaml
reed_the_frame_shadow_summer_flower: !include config/reed_the_frame_shadow_summer_flower.yaml
eyjafjalla_the_hvit_aska_a_picnic_before_a_long_trip: !include config/eyjafjalla_the_hvit_aska_a_picnic_before_a_long_trip.yaml
pepe: !include config/pepe.yaml
nightingale_iakhu_of_flows: !include config/nightingale_iakhu_of_flows.yaml
degenbrecher_the_shadow_of_dark_moon: !include config/degenbrecher_the_shadow_of_dark_moon.yaml
lappland_the_decadenza: !include config/lappland_the_decadenza.yaml
texas_the_omertosa_il_se_de_no: !include config/texas_the_omertosa_il_se_de_no.yaml
executor_the_ex_foedere_allmind_as_one: !include config/executor_the_ex_foedere_allmind_as_one.yaml
virtuosa_diversity_oneness: !include config/virtuosa_diversity_oneness.yaml
chongyue_allround_actor: !include config/chongyue_allround_actor.yaml
nian_thunderbolt_director: !include config/nian_thunderbolt_director.yaml
amiya_solo_around_the_world: !include config/amiya_solo_around_the_world.yaml
zuole_youthful_journey: !include config/zuole_youthful_journey.yaml
shu_spring_feast: !include config/shu_spring_feast.yaml
yu: !include config/yu.yaml

Some files were not shown because too many files have changed in this diff Show More