feat: automated most of operator config detection

This commit is contained in:
Haoyu Xu
2025-05-06 14:04:17 +08:00
parent ea7947d900
commit f3f84068da
89 changed files with 667 additions and 849 deletions

View 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)
)
}

View 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
)
}

View 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
)
}

View 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')
}
}