feat: automated most of operator config detection
This commit is contained in:
117
packages/operator/libs/builder.ts
Normal file
117
packages/operator/libs/builder.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import path from 'node:path'
|
||||
import config from '@aklive2d/config'
|
||||
import { file, alphaComposite } from '@aklive2d/libs'
|
||||
import operators, {
|
||||
DIST_DIR,
|
||||
OPERATOR_SOURCE_FOLDER,
|
||||
generateAssetsJson,
|
||||
} from '../index.ts'
|
||||
import { getExtractedFolder, getDistFolder } from './utils.ts'
|
||||
import type { PortraitHub, PortraitJson } from '../types.ts'
|
||||
|
||||
export const build = async (namesToBuild: string[]) => {
|
||||
const names = !namesToBuild.length ? Object.keys(operators) : namesToBuild
|
||||
console.log('Generating assets for', names.length, 'operators')
|
||||
for (const name of names) {
|
||||
await generateAssets(name)
|
||||
copyVoices(name)
|
||||
}
|
||||
copyLogos()
|
||||
}
|
||||
|
||||
const copyVoices = (name: string) => {
|
||||
file.symlinkAll(
|
||||
path.join(OPERATOR_SOURCE_FOLDER, name, config.dir_name.voice.main),
|
||||
path.join(DIST_DIR, config.dir_name.voice.main, name)
|
||||
)
|
||||
}
|
||||
|
||||
const copyLogos = () => {
|
||||
file.symlink(
|
||||
path.join(OPERATOR_SOURCE_FOLDER, config.module.operator.logos_assets),
|
||||
path.join(DIST_DIR, config.module.operator.logos)
|
||||
)
|
||||
}
|
||||
|
||||
const generateAssets = async (name: string) => {
|
||||
const extractedDir = getExtractedFolder(name)
|
||||
const outDir = getDistFolder(name)
|
||||
file.rmdir(outDir)
|
||||
file.mkdir(outDir)
|
||||
|
||||
const fallback_name = operators[name].fallback_name
|
||||
const fallbackFilename = `${fallback_name}.png`
|
||||
const alphaCompositeFilename = `${path.parse(fallbackFilename).name}[alpha].png`
|
||||
if (file.exists(path.join(extractedDir, alphaCompositeFilename))) {
|
||||
const fallbackBuffer = await alphaComposite.process(
|
||||
fallbackFilename,
|
||||
alphaCompositeFilename,
|
||||
extractedDir
|
||||
)
|
||||
file.writeSync(
|
||||
fallbackBuffer,
|
||||
path.join(getDistFolder(name), fallbackFilename)
|
||||
)
|
||||
} else {
|
||||
await file.copy(
|
||||
path.join(extractedDir, fallbackFilename),
|
||||
path.join(getDistFolder(name), fallbackFilename)
|
||||
)
|
||||
}
|
||||
|
||||
// generate portrait
|
||||
const portraitDir = path.join(
|
||||
OPERATOR_SOURCE_FOLDER,
|
||||
config.module.operator.portraits
|
||||
)
|
||||
const portraitHubContent = file.readSync(
|
||||
path.join(
|
||||
portraitDir,
|
||||
config.module.operator.MonoBehaviour,
|
||||
'portrait_hub.json'
|
||||
)
|
||||
) as string
|
||||
if (!portraitHubContent) throw new Error('portrait_hub.json not found')
|
||||
const portraitHub: PortraitHub = JSON.parse(portraitHubContent)
|
||||
const fallback_name_lowerCase = fallback_name.toLowerCase()
|
||||
const portraitItem = portraitHub._sprites.find(
|
||||
(item) => item.name.toLowerCase() === fallback_name_lowerCase
|
||||
)
|
||||
if (!portraitItem) throw new Error(`portrait ${fallback_name} not found`)
|
||||
const portraitAtlas = portraitItem.atlas
|
||||
const portraitJsonText = file.readSync(
|
||||
path.join(
|
||||
portraitDir,
|
||||
config.module.operator.MonoBehaviour,
|
||||
`portraits#${portraitAtlas}.json`
|
||||
)
|
||||
) as string
|
||||
if (!portraitJsonText)
|
||||
throw new Error(`portrait ${fallback_name} json not found`)
|
||||
const portraitJson: PortraitJson = JSON.parse(portraitJsonText)
|
||||
const item = portraitJson._sprites.find(
|
||||
(item) => item.name.toLowerCase() === fallback_name_lowerCase
|
||||
)
|
||||
if (!item) throw new Error(`portrait ${fallback_name} not found`)
|
||||
const rect = {
|
||||
...item.rect,
|
||||
rotate: item.rotate,
|
||||
}
|
||||
const protraitFilename = `portraits#${portraitAtlas}.png`
|
||||
const portraitBuffer = await alphaComposite.process(
|
||||
protraitFilename,
|
||||
`${path.parse(protraitFilename).name}a.png`,
|
||||
path.join(portraitDir, config.module.operator.Texture2D)
|
||||
)
|
||||
const croppedBuffer = await alphaComposite.crop(portraitBuffer, rect)
|
||||
file.writeSync(
|
||||
croppedBuffer,
|
||||
path.join(getDistFolder(name), `${fallback_name}_portrait.png`)
|
||||
)
|
||||
|
||||
await generateAssetsJson(
|
||||
operators[name].filename,
|
||||
extractedDir,
|
||||
getDistFolder(name)
|
||||
)
|
||||
}
|
||||
55
packages/operator/libs/initer.ts
Normal file
55
packages/operator/libs/initer.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import path from 'node:path'
|
||||
import { stringify } from 'yaml'
|
||||
import { yaml, file } from '@aklive2d/libs'
|
||||
import config from '@aklive2d/config'
|
||||
import { mapping as officialInfoMapping } from '@aklive2d/official-info'
|
||||
import { CONFIG_PATH, skinTable, CONFIG_FOLDER } from '../index.ts'
|
||||
import {
|
||||
getVoiceFolders,
|
||||
getExtractedFolder,
|
||||
findSkinEntry,
|
||||
findCodename,
|
||||
} from './utils.ts'
|
||||
|
||||
export const init = (name: string, id: string) => {
|
||||
const voiceFolders = getVoiceFolders(name)
|
||||
const extractedFolder = getExtractedFolder(name)
|
||||
const operatorConfigFolder = CONFIG_FOLDER
|
||||
const foldersToCreate = [extractedFolder, ...voiceFolders]
|
||||
|
||||
const template = yaml.read(
|
||||
path.resolve(operatorConfigFolder, config.module.operator.template_yaml)
|
||||
)
|
||||
foldersToCreate.forEach((dir) => {
|
||||
file.mkdir(dir)
|
||||
})
|
||||
const currentOpertor = officialInfoMapping[id]
|
||||
if (currentOpertor === undefined) {
|
||||
throw new Error('Invalid operator id')
|
||||
}
|
||||
template.official_id = currentOpertor.id
|
||||
try {
|
||||
const entryName =
|
||||
currentOpertor.type === 'skin'
|
||||
? currentOpertor.codename['zh-CN'].split(' · ')[0]
|
||||
: currentOpertor.codename['en-US']
|
||||
const skinEntry = findSkinEntry(
|
||||
skinTable,
|
||||
entryName,
|
||||
currentOpertor.type
|
||||
)
|
||||
template.codename = findCodename(skinEntry, currentOpertor)
|
||||
} catch (e: unknown) {
|
||||
console.log(e as string)
|
||||
template.codename = currentOpertor.codename
|
||||
}
|
||||
|
||||
file.writeSync(
|
||||
stringify(template),
|
||||
path.resolve(operatorConfigFolder, `${name}.yaml`)
|
||||
)
|
||||
file.appendSync(
|
||||
`${name}: !include ${config.module.operator.config}/${name}.yaml\n`,
|
||||
CONFIG_PATH
|
||||
)
|
||||
}
|
||||
25
packages/operator/libs/updater.ts
Normal file
25
packages/operator/libs/updater.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import path from 'node:path'
|
||||
import { githubDownload } from '@aklive2d/downloader'
|
||||
import config from '@aklive2d/config'
|
||||
import { AUTO_UPDATE_FOLDER } from '../index.ts'
|
||||
|
||||
export const update = async () => {
|
||||
const character_table_json = path.resolve(
|
||||
AUTO_UPDATE_FOLDER,
|
||||
config.module.operator.character_table_json
|
||||
)
|
||||
const skin_table_json = path.resolve(
|
||||
AUTO_UPDATE_FOLDER,
|
||||
config.module.operator.skin_table_json
|
||||
)
|
||||
await githubDownload(
|
||||
`https://api.github.com/repos/Kengxxiao/ArknightsGameData/commits?path=zh_CN/gamedata/excel/character_table.json`,
|
||||
`https://raw.githubusercontent.com/Kengxxiao/ArknightsGameData/master/zh_CN/gamedata/excel/character_table.json`,
|
||||
character_table_json
|
||||
)
|
||||
await githubDownload(
|
||||
`https://api.github.com/repos/Kengxxiao/ArknightsGameData/commits?path=zh_CN/gamedata/excel/skin_table.json`,
|
||||
`https://raw.githubusercontent.com/Kengxxiao/ArknightsGameData/master/zh_CN/gamedata/excel/skin_table.json`,
|
||||
skin_table_json
|
||||
)
|
||||
}
|
||||
204
packages/operator/libs/utils.ts
Normal file
204
packages/operator/libs/utils.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import path from 'node:path'
|
||||
import unidecode from 'unidecode'
|
||||
import config from '@aklive2d/config'
|
||||
import { DIST_DIR, OPERATOR_SOURCE_FOLDER } from '../index.ts'
|
||||
import { file } from '@aklive2d/libs'
|
||||
import {
|
||||
CharacterTableJson,
|
||||
SkinTableJson,
|
||||
OperatorEntryType,
|
||||
SkinTableJsonCharSkinEntry,
|
||||
Codename,
|
||||
} from '../types.ts'
|
||||
import type { OfficialInfoOperatorConfig } from '@aklive2d/official-info/types'
|
||||
|
||||
export const getExtractedFolder = (name: string) => {
|
||||
return path.join(OPERATOR_SOURCE_FOLDER, name, config.dir_name.extracted)
|
||||
}
|
||||
|
||||
export const getDistFolder = (name: string) => {
|
||||
return path.join(DIST_DIR, config.module.operator.operator, name)
|
||||
}
|
||||
|
||||
export const getVoiceFolders = (name: string) => {
|
||||
return config.dir_name.voice.sub.map((sub) =>
|
||||
path.join(
|
||||
OPERATOR_SOURCE_FOLDER,
|
||||
name,
|
||||
config.dir_name.voice.main,
|
||||
sub.name
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export const findLogo = (characterTable: CharacterTableJson, id: string) => {
|
||||
const SEARCH_ORDER = ['teamId', 'groupId', 'nationId'] as const
|
||||
let logo: string | null = null
|
||||
const entry = characterTable[id]
|
||||
if (!entry) throw new Error(`Character ${id} not found`)
|
||||
for (const key of SEARCH_ORDER) {
|
||||
logo = entry[key]
|
||||
if (logo) break
|
||||
}
|
||||
if (logo === null) throw new Error(`Logo not found for character ${id}`)
|
||||
let invert_filter = true
|
||||
if (logo === 'rhodes') {
|
||||
logo = 'rhodes_override'
|
||||
invert_filter = false
|
||||
}
|
||||
logo = `logo_${logo}`
|
||||
const logoFiles = file
|
||||
.readdirSync(
|
||||
path.resolve(
|
||||
OPERATOR_SOURCE_FOLDER,
|
||||
config.module.operator.logos_assets
|
||||
)
|
||||
)
|
||||
.map((e) => {
|
||||
const name = path.parse(e).name
|
||||
return {
|
||||
file: name,
|
||||
name: name.toLowerCase(),
|
||||
}
|
||||
})
|
||||
const logoFile = logoFiles.find((f) => f.name === logo)
|
||||
if (!logoFile)
|
||||
throw new Error(`Logo file not found for character ${id}, ${logo}`)
|
||||
logo = logoFile.file
|
||||
|
||||
return {
|
||||
logo,
|
||||
invert_filter,
|
||||
}
|
||||
}
|
||||
|
||||
export const findSkinEntry = (
|
||||
skinTableJson: SkinTableJson,
|
||||
name: string,
|
||||
type: OperatorEntryType
|
||||
) => {
|
||||
const OVERWRITE_ENTRIES = {
|
||||
WISADEL: "Wiš'adel",
|
||||
} as const
|
||||
type OverwriteKeys = keyof typeof OVERWRITE_ENTRIES
|
||||
|
||||
name = OVERWRITE_ENTRIES[name as OverwriteKeys] ?? name
|
||||
const dynSkinEntries = Object.values(skinTableJson.charSkins).filter(
|
||||
(entry) => entry.dynIllustId !== null
|
||||
)
|
||||
let entry: SkinTableJsonCharSkinEntry | undefined
|
||||
if (type === 'operator') {
|
||||
entry = dynSkinEntries.find(
|
||||
(e) => e.displaySkin.modelName?.toLowerCase() === name.toLowerCase()
|
||||
)
|
||||
} else if (type === 'skin') {
|
||||
entry = dynSkinEntries.find(
|
||||
(e) => e.displaySkin.skinName?.toLowerCase() === name.toLowerCase()
|
||||
)
|
||||
} else {
|
||||
throw new Error(`Invalid type: ${type}`)
|
||||
}
|
||||
if (!entry) throw new Error(`Skin entry not found for ${name}`)
|
||||
return entry
|
||||
}
|
||||
|
||||
/**
|
||||
* Name from Official Info sometimes is incorrect, can only be used as a
|
||||
* reference
|
||||
* @param skinEntry
|
||||
* @param officialInfo
|
||||
* @returns
|
||||
*/
|
||||
export const findCodename = (
|
||||
skinEntry: SkinTableJsonCharSkinEntry,
|
||||
officialInfo: OfficialInfoOperatorConfig
|
||||
) => {
|
||||
const UPPER_CASE_EXCEPTION_WORDS = [
|
||||
'the',
|
||||
'is',
|
||||
'of',
|
||||
'and',
|
||||
'for',
|
||||
'a',
|
||||
'an',
|
||||
'are',
|
||||
'in',
|
||||
'as',
|
||||
]
|
||||
const codename: Codename = { 'zh-CN': '', 'en-US': '' }
|
||||
const regexp = /[^(\w) ]/
|
||||
let modelNameNormalized = skinEntry.displaySkin.modelName
|
||||
const hasSpecialCharInModelName = regexp.test(
|
||||
skinEntry.displaySkin.modelName
|
||||
)
|
||||
if (hasSpecialCharInModelName) {
|
||||
modelNameNormalized = unidecode(modelNameNormalized).replace(regexp, '')
|
||||
const modelNameArray = skinEntry.displaySkin.modelName.split(' ')
|
||||
const modelNameNormalizedArray = modelNameNormalized.split(' ')
|
||||
modelNameArray.forEach((word, index) => {
|
||||
if (word !== modelNameNormalizedArray[index]) {
|
||||
modelNameNormalizedArray[index] =
|
||||
`${word}/${modelNameNormalizedArray[index]}`
|
||||
}
|
||||
})
|
||||
modelNameNormalized = modelNameNormalizedArray.join(' ')
|
||||
}
|
||||
if (skinEntry.displaySkin.skinName) {
|
||||
let engSkinName = officialInfo.codename['en-US']
|
||||
const engkinNameArray = engSkinName.split(' ')
|
||||
engkinNameArray.forEach((word, index) => {
|
||||
if (/^[a-zA-Z]+$/.test(word)) {
|
||||
word = word.toLowerCase()
|
||||
if (UPPER_CASE_EXCEPTION_WORDS.includes(word)) {
|
||||
engkinNameArray[index] = word
|
||||
} else {
|
||||
engkinNameArray[index] =
|
||||
word[0].toUpperCase() + word.slice(1)
|
||||
}
|
||||
}
|
||||
})
|
||||
engSkinName = engkinNameArray.join(' ')
|
||||
codename['zh-CN'] = officialInfo.codename['zh-CN'].replace(/ +$/, '')
|
||||
codename['en-US'] = `${engSkinName} / ${modelNameNormalized}`
|
||||
} else {
|
||||
codename['zh-CN'] = officialInfo.codename['zh-CN'].replace(/ +$/, '')
|
||||
codename['en-US'] = modelNameNormalized
|
||||
}
|
||||
return codename
|
||||
}
|
||||
|
||||
export const getActualFilename = (filename: string, dir: string) => {
|
||||
const files = file.readdirSync(dir)
|
||||
const actualFilename = files.find((e) => {
|
||||
const name = path.parse(e).name
|
||||
return filename.startsWith(name) && !name.endsWith('_Start')
|
||||
})
|
||||
return actualFilename ? path.parse(actualFilename).name : filename
|
||||
}
|
||||
|
||||
export const findSkel = (name: string, dir: string) => {
|
||||
const files = file.readdirSync(dir)
|
||||
const skel = files.find((e) => {
|
||||
const actualName = path.parse(e)
|
||||
return (
|
||||
name.startsWith(actualName.name) &&
|
||||
!actualName.name.endsWith('_Start') &&
|
||||
actualName.ext === '.skel'
|
||||
)
|
||||
})
|
||||
const json = files.find((e) => {
|
||||
const actualName = path.parse(e)
|
||||
return (
|
||||
name.startsWith(actualName.name) &&
|
||||
!actualName.name.endsWith('_Start') &&
|
||||
actualName.ext === '.json'
|
||||
)
|
||||
})
|
||||
if (skel) {
|
||||
return skel
|
||||
} else if (json) {
|
||||
return json
|
||||
} else {
|
||||
throw new Error('No skel or json file found')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user