spine连接器和导出修改
This commit is contained in:
1 parent
535f8d7558
commit
0bde824c88
81 files changed
+11800
-1251
No files matched your search
@@ -0,0 +1,152 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { access, chmod, cp, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { constants } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const run = promisify(execFile)
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url))
|
||||
const projectDir = resolve(scriptDir, '..')
|
||||
const releaseRoot = join(projectDir, 'release', 'connector')
|
||||
const platformName = process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : process.platform
|
||||
const packageDir = join(releaseRoot, `${platformName}-${process.arch}`)
|
||||
const executableName = process.platform === 'win32' ? 'SpineParticleConnector.exe' : 'SpineParticleConnector'
|
||||
const executablePath = join(packageDir, executableName)
|
||||
const launcherSource = join(projectDir, 'connector', 'launchers', platformName)
|
||||
const nodeTarget = `node${process.versions.node.split('.')[0]}`
|
||||
|
||||
function seaUnavailable(error) {
|
||||
return /single executable application is disabled/i.test(String(error?.stderr || '') + String(error?.stdout || '') + String(error))
|
||||
}
|
||||
|
||||
async function downloadOfficialSeaNode(stagingDir) {
|
||||
const version = process.versions.node
|
||||
const architecture = process.arch === 'arm64' ? 'arm64' : process.arch === 'x64' ? 'x64' : null
|
||||
if (!architecture || !['darwin', 'win32'].includes(process.platform)) {
|
||||
throw new Error('当前系统的 Node 不支持 SEA。请安装 Node 官方发行版,或通过 SEA_NODE_BINARY 指定可用的 Node 可执行文件。')
|
||||
}
|
||||
|
||||
const osName = process.platform === 'darwin' ? 'darwin' : 'win'
|
||||
const extension = process.platform === 'darwin' ? 'tar.gz' : 'zip'
|
||||
const archiveName = `node-v${version}-${osName}-${architecture}.${extension}`
|
||||
const archivePath = join(stagingDir, archiveName)
|
||||
const url = `https://nodejs.org/dist/v${version}/${archiveName}`
|
||||
console.log(`当前 Node 禁用了 SEA,正在临时使用 Node 官方构建器:${url}`)
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(120_000) })
|
||||
if (!response.ok) throw new Error(`无法下载 Node 官方构建器(${response.status}):${url}`)
|
||||
await writeFile(archivePath, Buffer.from(await response.arrayBuffer()))
|
||||
|
||||
if (process.platform === 'darwin') await run('tar', ['-xzf', archivePath, '-C', stagingDir])
|
||||
else await run('powershell.exe', ['-NoProfile', '-Command', `Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' -DestinationPath '${stagingDir.replace(/'/g, "''")}' -Force`])
|
||||
|
||||
const binary = process.platform === 'darwin'
|
||||
? join(stagingDir, `node-v${version}-${osName}-${architecture}`, 'bin', 'node')
|
||||
: join(stagingDir, `node-v${version}-${osName}-${architecture}`, 'node.exe')
|
||||
await access(binary, constants.X_OK)
|
||||
return binary
|
||||
}
|
||||
|
||||
async function buildSea(configPath, stagingDir) {
|
||||
const configuredBinary = process.env.SEA_NODE_BINARY
|
||||
const candidates = configuredBinary ? [configuredBinary, process.execPath] : [process.execPath]
|
||||
let lastError
|
||||
for (const binary of candidates) {
|
||||
try {
|
||||
await run(binary, ['--build-sea', configPath])
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (!seaUnavailable(error)) throw error
|
||||
}
|
||||
}
|
||||
const officialBinary = await downloadOfficialSeaNode(stagingDir)
|
||||
try {
|
||||
await run(officialBinary, ['--build-sea', configPath])
|
||||
} catch (error) {
|
||||
throw new Error(`Node 官方构建器无法生成 SEA:${error instanceof Error ? error.message : error}`, { cause: lastError })
|
||||
}
|
||||
}
|
||||
|
||||
async function createPortableArchive() {
|
||||
const archivePath = join(releaseRoot, `SpineParticleConnector-${platformName}-${process.arch}.zip`)
|
||||
await rm(archivePath, { force: true })
|
||||
if (process.platform === 'darwin') {
|
||||
await run('zip', ['-q', '-r', archivePath, basename(packageDir)], { cwd: releaseRoot })
|
||||
} else if (process.platform === 'win32') {
|
||||
const escapedSource = packageDir.replace(/'/g, "''")
|
||||
const escapedTarget = archivePath.replace(/'/g, "''")
|
||||
await run('powershell.exe', ['-NoProfile', '-Command', `Compress-Archive -LiteralPath '${escapedSource}' -DestinationPath '${escapedTarget}' -Force`])
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
return archivePath
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const stagingDir = await mkdtemp(join(tmpdir(), 'spine-particle-sea-'))
|
||||
try {
|
||||
const bundlePath = join(stagingDir, 'spine-connector.cjs')
|
||||
const configPath = join(stagingDir, 'sea-config.json')
|
||||
|
||||
await build({
|
||||
entryPoints: [join(projectDir, 'connector', 'spine-connector.mjs')],
|
||||
outfile: bundlePath,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: nodeTarget,
|
||||
legalComments: 'none',
|
||||
})
|
||||
|
||||
// The release directory is generated output. Recreate this exact package folder
|
||||
// so a rebuild cannot retain files from an older connector version.
|
||||
await rm(packageDir, { recursive: true, force: true })
|
||||
await mkdir(packageDir, { recursive: true })
|
||||
await writeFile(configPath, `${JSON.stringify({
|
||||
main: bundlePath,
|
||||
mainFormat: 'commonjs',
|
||||
output: executablePath,
|
||||
disableExperimentalSEAWarning: true,
|
||||
useCodeCache: false,
|
||||
}, null, 2)}\n`, 'utf8')
|
||||
await buildSea(configPath, stagingDir)
|
||||
|
||||
if (process.platform !== 'win32') await chmod(executablePath, 0o755)
|
||||
if (process.platform === 'darwin') {
|
||||
// SEA modifies a copy of Node. Re-sign it locally so macOS will execute it.
|
||||
await run('codesign', ['--force', '--sign', '-', '--timestamp=none', executablePath])
|
||||
}
|
||||
|
||||
await cp(launcherSource, packageDir, { recursive: true, force: true })
|
||||
if (process.platform !== 'win32') {
|
||||
await Promise.all([
|
||||
chmod(join(packageDir, '启动Spine连接器.command'), 0o755),
|
||||
chmod(join(packageDir, '停止Spine连接器.command'), 0o755),
|
||||
])
|
||||
}
|
||||
await cp(join(projectDir, 'connector', 'DISTRIBUTION.md'), join(packageDir, '使用说明.md'), { force: true })
|
||||
await writeFile(join(packageDir, 'package.json'), `${JSON.stringify({
|
||||
name: 'SpineParticleConnector',
|
||||
connectorVersion: '1.0.0',
|
||||
platform: process.platform,
|
||||
architecture: process.arch,
|
||||
executable: basename(executablePath),
|
||||
builtWithNode: process.version,
|
||||
}, null, 2)}\n`, 'utf8')
|
||||
|
||||
const archivePath = await createPortableArchive()
|
||||
console.log(`已生成独立连接器:${packageDir}`)
|
||||
console.log(`可执行文件:${executablePath}`)
|
||||
if (archivePath) console.log(`分发压缩包:${archivePath}`)
|
||||
} finally {
|
||||
await rm(stagingDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`构建独立连接器失败:${error instanceof Error ? error.message : error}`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { access, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { constants } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const projectDir = resolve(fileURLToPath(new URL('..', import.meta.url)))
|
||||
const platformName = process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : process.platform
|
||||
const executableName = process.platform === 'win32' ? 'SpineParticleConnector.exe' : 'SpineParticleConnector'
|
||||
const executablePath = join(projectDir, 'release', 'connector', `${platformName}-${process.arch}`, executableName)
|
||||
const port = 27844
|
||||
|
||||
async function waitForHealth() {
|
||||
const deadline = Date.now() + 10_000
|
||||
let lastError = null
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/health`)
|
||||
if (response.ok) return response.json()
|
||||
lastError = new Error(`健康检查返回 ${response.status}`)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
}
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 150))
|
||||
}
|
||||
throw lastError || new Error('健康检查超时')
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await access(executablePath, constants.X_OK)
|
||||
const configDir = await mkdtemp(join(tmpdir(), 'spine-particle-sea-test-'))
|
||||
const processHandle = spawn(executablePath, [], {
|
||||
env: { ...process.env, SPINE_CONNECTOR_PORT: String(port), SPINE_CONNECTOR_CONFIG: join(configDir, 'config.json') },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
let output = ''
|
||||
processHandle.stdout.on('data', (chunk) => { output += chunk })
|
||||
processHandle.stderr.on('data', (chunk) => { output += chunk })
|
||||
|
||||
try {
|
||||
const health = await waitForHealth()
|
||||
if (health.connectorVersion !== '1.0.0' || !Array.isArray(health.supportedVersions) || !health.supportedVersions.includes('4.3')) {
|
||||
throw new Error(`独立连接器健康检查内容不正确:${JSON.stringify(health)}`)
|
||||
}
|
||||
console.log(`独立连接器验证通过:${executablePath}`)
|
||||
console.log(`支持版本:${health.supportedVersions.join(', ')}`)
|
||||
} finally {
|
||||
if (!processHandle.killed) processHandle.kill('SIGTERM')
|
||||
await new Promise((resolveDelay) => processHandle.once('exit', resolveDelay)).catch(() => {})
|
||||
if (!processHandle.killed) processHandle.kill('SIGKILL')
|
||||
await rm(configDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const bundled = await build({
|
||||
bundle: true,
|
||||
entryPoints: ['src/core/loopFrameBaker.ts'],
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
treeShaking: true,
|
||||
write: false,
|
||||
})
|
||||
const source = bundled.outputFiles[0].text
|
||||
const moduleUrl = `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`
|
||||
const { closeLoopFrameSeam } = await import(moduleUrl)
|
||||
|
||||
const first = [{
|
||||
boneName: 'p_0', spawnId: 7, x: 12, y: -4, rotation: 30,
|
||||
scaleX: 1.2, scaleY: 0.8, alpha: 0.75, colorHex: 0xffcc00,
|
||||
resourceId: 1, imageFrameIndex: 2, imageVisible: true, active: true,
|
||||
trailState: {
|
||||
bones: [{ boneName: 'trail_0', x: 1, y: 2, rotation: 3 }],
|
||||
activeBoneCount: 1, width: 5, alpha: 0.5, colorHex: 0xffffff, resourceId: 1,
|
||||
},
|
||||
}]
|
||||
const frames = [first, [{ ...first[0], spawnId: 99, x: 200, y: 300 }]]
|
||||
|
||||
closeLoopFrameSeam(frames)
|
||||
|
||||
if (JSON.stringify(frames[0]) !== JSON.stringify(frames[1])) {
|
||||
throw new Error('循环尾帧没有与首帧保持完全一致')
|
||||
}
|
||||
if (frames[0] === frames[1] || frames[0][0] === frames[1][0] || frames[0][0].trailState === frames[1][0].trailState) {
|
||||
throw new Error('循环闭合帧必须是深拷贝,不能共享可变状态')
|
||||
}
|
||||
|
||||
console.log('循环持续:首尾粒子身份、变换、生命周期表现及拖尾状态完全一致')
|
||||
@@ -0,0 +1,31 @@
|
||||
const connectorUrl = process.env.SPINE_CONNECTOR_URL || 'http://127.0.0.1:27843'
|
||||
const origin = process.env.SPINE_CONNECTOR_TEST_ORIGIN || 'http://127.0.0.1:5173'
|
||||
const versions = ['3.8', '4.0', '4.1', '4.2', '4.3']
|
||||
const results = []
|
||||
|
||||
for (const version of versions) {
|
||||
const skeleton = {
|
||||
skeleton: { spine: version, images: './images/', fps: 30 },
|
||||
bones: [{ name: 'root' }],
|
||||
slots: [],
|
||||
skins: version === '3.8' ? { default: {} } : [{ name: 'default', attachments: {} }],
|
||||
animations: { animation: {} },
|
||||
}
|
||||
const response = await fetch(`${connectorUrl}/v1/convert`, {
|
||||
method: 'POST',
|
||||
headers: { Origin: origin, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
projectName: `ConnectorVersion${version.replace('.', '')}`,
|
||||
spineVersion: version,
|
||||
skeletonJson: JSON.stringify(skeleton),
|
||||
images: [],
|
||||
}),
|
||||
})
|
||||
if (!response.ok) throw new Error(`Spine ${version} 转换失败:${await response.text()}`)
|
||||
const bytes = (await response.arrayBuffer()).byteLength
|
||||
if (!bytes) throw new Error(`Spine ${version} 返回了空工程`)
|
||||
results.push({ requested: version, editor: response.headers.get('x-spine-editor-version'), bytes })
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
@@ -0,0 +1,68 @@
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const root = new URL('../', import.meta.url)
|
||||
const connectorUrl = process.env.SPINE_CONNECTOR_URL || 'http://127.0.0.1:27843'
|
||||
const origin = process.env.SPINE_CONNECTOR_TEST_ORIGIN || 'http://127.0.0.1:5173'
|
||||
const imagePath = new URL('public/images/star.png', root)
|
||||
|
||||
const healthResponse = await fetch(`${connectorUrl}/v1/health`, { headers: { Origin: origin } })
|
||||
if (!healthResponse.ok) throw new Error(`连接器健康检查失败:HTTP ${healthResponse.status}`)
|
||||
const health = await healthResponse.json()
|
||||
if (!health.authorized) throw new Error('测试来源没有获得连接器授权')
|
||||
if (!health.spineFound) throw new Error('连接器没有找到本机 Spine')
|
||||
|
||||
const skeletonJson = JSON.stringify({
|
||||
skeleton: { spine: '4.2', images: './images/', fps: 30 },
|
||||
bones: [{ name: 'root' }, { name: 'particle', parent: 'root' }],
|
||||
slots: [{ name: 'particle', bone: 'particle', attachment: 'star' }],
|
||||
skins: [{ name: 'default', attachments: { particle: { star: { path: 'star', width: 64, height: 64 } } } }],
|
||||
animations: { animation: {} },
|
||||
})
|
||||
const image = await readFile(imagePath)
|
||||
const payload = {
|
||||
schemaVersion: 1,
|
||||
projectName: 'Spine粒子连接器测试',
|
||||
spineVersion: JSON.parse(skeletonJson).skeleton.spine,
|
||||
skeletonJson,
|
||||
images: [{ path: 'star.png', dataBase64: image.toString('base64') }],
|
||||
}
|
||||
const response = await fetch(`${connectorUrl}/v1/convert`, {
|
||||
method: 'POST',
|
||||
headers: { Origin: origin, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!response.ok) throw new Error(`转换失败:${await response.text()}`)
|
||||
|
||||
const temp = await mkdtemp(join(tmpdir(), 'spine-connector-verify-'))
|
||||
const output = join(temp, 'SpineParticleConnectorTest.zip')
|
||||
try {
|
||||
const archive = Buffer.from(await response.arrayBuffer())
|
||||
await writeFile(output, archive)
|
||||
const info = await stat(output)
|
||||
if (!info.isFile() || info.size === 0 || archive.readUInt32LE(0) !== 0x04034b50) throw new Error('连接器返回的不是有效 ZIP')
|
||||
const entries = []
|
||||
let offset = 0
|
||||
while (offset + 30 <= archive.length && archive.readUInt32LE(offset) === 0x04034b50) {
|
||||
const size = archive.readUInt32LE(offset + 18)
|
||||
const nameLength = archive.readUInt16LE(offset + 26)
|
||||
const extraLength = archive.readUInt16LE(offset + 28)
|
||||
const name = archive.subarray(offset + 30, offset + 30 + nameLength).toString('utf8')
|
||||
entries.push({ name, size })
|
||||
offset += 30 + nameLength + extraLength + size
|
||||
}
|
||||
if (!entries.some((entry) => entry.name.endsWith('.spine') && entry.size > 0)) throw new Error('ZIP 中缺少 .spine 工程')
|
||||
if (!entries.some((entry) => entry.name === 'images/star.png' && entry.size === image.length)) throw new Error('ZIP 中缺少 images/star.png')
|
||||
console.log(JSON.stringify({
|
||||
connectorVersion: health.connectorVersion,
|
||||
requestedSpineVersion: payload.spineVersion,
|
||||
actualSpineVersion: response.headers.get('x-spine-editor-version'),
|
||||
encodedFileName: response.headers.get('x-spine-file-name'),
|
||||
imageCount: payload.images.length,
|
||||
archiveBytes: info.size,
|
||||
entries,
|
||||
}, null, 2))
|
||||
} finally {
|
||||
await rm(temp, { recursive: true, force: true })
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const source = `
|
||||
import { buildSpineJson } from './src/export/spineJsonExporter.ts'
|
||||
import { validateSpineJson } from './src/export/spineJsonValidator.ts'
|
||||
|
||||
const resource = {
|
||||
id: 1, imageMode: 'single', sequenceFrames: [], textureName: 'star.png', textureFolder: '',
|
||||
anchorX: 0.5, anchorY: 0.5, blend: 'normal', independentAlpha: 'off',
|
||||
}
|
||||
const frames = Array.from({ length: 31 }, (_, frame) => [{
|
||||
active: true, boneName: 'particle', spawnId: 1, resourceId: 1, imageFrameIndex: 0,
|
||||
imageVisible: true, colorHex: 0xffffff, alpha: 1,
|
||||
x: frame, y: frame / 2, rotation: frame, scaleX: 1, scaleY: 1,
|
||||
worldX: frame, worldY: frame / 2, worldRotation: frame, worldScaleX: 1, worldScaleY: 1,
|
||||
}])
|
||||
const system = {
|
||||
id: 1, animation: 'animation', visible: true, frames,
|
||||
config: {
|
||||
name: 'verify', mode: 'burst', lifeMode: 'fixed', lifeMin: 1, lifeMax: 1, delay: 0,
|
||||
imageResources: [resource], trail: false, trailResources: [],
|
||||
},
|
||||
}
|
||||
const timeline = { fps: 30, animations: ['animation'] }
|
||||
|
||||
let failures = 0
|
||||
for (const version of ['3.8', '4.0', '4.1', '4.2', '4.3']) {
|
||||
const settings = {
|
||||
imagesPath: './images/', spineVersion: version, keyframeCurve: 'linear', fps: 30,
|
||||
omitFps: false, integerFrameAlignment: true, bonePool: true,
|
||||
excludeEmptyAnimations: false, fileName: 'verify.json',
|
||||
}
|
||||
const result = buildSpineJson({ systems: [system], timeline, settings })
|
||||
try {
|
||||
const validation = validateSpineJson(result.text, version)
|
||||
console.log('Spine ' + version + ':', validation.bones + ' bones,', validation.slots + ' slots,', validation.animations + ' animations')
|
||||
} catch (error) {
|
||||
failures++
|
||||
console.error('Spine ' + version + ': ' + (error instanceof Error ? error.message : String(error)))
|
||||
}
|
||||
}
|
||||
if (failures) process.exitCode = 1
|
||||
`
|
||||
|
||||
const result = await build({
|
||||
stdin: { contents: source, resolveDir: process.cwd(), sourcefile: 'verify-spine-json.ts', loader: 'ts' },
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
write: false,
|
||||
logLevel: 'silent',
|
||||
})
|
||||
|
||||
const code = Buffer.from(result.outputFiles[0].contents).toString('base64')
|
||||
await import(`data:text/javascript;base64,${code}`)
|
||||
@@ -0,0 +1,92 @@
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const bundled = await build({
|
||||
bundle: true,
|
||||
stdin: {
|
||||
contents: [
|
||||
"export { bakeLoopFrames, closeLoopFrameSeam } from './src/core/loopFrameBaker.ts'",
|
||||
"export { loopPlaybackRange, loopSegmentTiming } from './src/core/loopSegmentTiming.ts'",
|
||||
"export { normalizePlaybackRange, resizePlaybackRange } from './src/timeline/playbackRange.ts'",
|
||||
].join('\n'),
|
||||
resolveDir: process.cwd(),
|
||||
sourcefile: 'timeline-test-entry.ts',
|
||||
},
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
write: false,
|
||||
})
|
||||
const source = bundled.outputFiles[0].text
|
||||
const api = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`)
|
||||
|
||||
const timing = api.loopSegmentTiming({
|
||||
loopDurationFrames: 30,
|
||||
generateLoopStartAnimation: true, loopStartUseCustomDuration: true,
|
||||
loopStartDurationFrames: 20,
|
||||
generateLoopEndAnimation: true, loopEndUseCustomDuration: true,
|
||||
loopEndDurationFrames: 40,
|
||||
})
|
||||
if (JSON.stringify(timing) !== JSON.stringify({ start: 20, loop: 30, end: 40, total: 90 })) {
|
||||
throw new Error('开始、循环、结束三段时长计算错误')
|
||||
}
|
||||
|
||||
const automaticTiming = api.loopSegmentTiming({
|
||||
lifeMode: 'random', lifeMin: 0.5, lifeMax: 1.5,
|
||||
loopDurationFrames: 30,
|
||||
generateLoopStartAnimation: true, loopStartUseCustomDuration: false, loopStartDurationFrames: 20,
|
||||
generateLoopEndAnimation: true, loopEndUseCustomDuration: false, loopEndDurationFrames: 40,
|
||||
}, 30)
|
||||
if (automaticTiming.start !== 45 || automaticTiming.end !== 45) {
|
||||
throw new Error('未指定时长时应按最大粒子生命周期自动计算')
|
||||
}
|
||||
|
||||
const loopRange = api.loopPlaybackRange({
|
||||
delay: 0, loopDurationFrames: 80,
|
||||
generateLoopStartAnimation: true, loopStartUseCustomDuration: true, loopStartDurationFrames: 20,
|
||||
generateLoopEndAnimation: true, loopEndUseCustomDuration: true, loopEndDurationFrames: 20,
|
||||
}, 30)
|
||||
if (loopRange.start !== 20 || loopRange.end !== 100) {
|
||||
throw new Error('时间轴上的闭合自循环范围计算错误')
|
||||
}
|
||||
|
||||
const normalized = api.normalizePlaybackRange(10, 10, 100)
|
||||
if (normalized.start !== 10 || normalized.end !== 11) throw new Error('播放范围必须至少包含两个帧位置')
|
||||
const resized = api.resizePlaybackRange({ start: 5, end: 59 }, 60, 90)
|
||||
if (resized.start !== 5 || resized.end !== 89) throw new Error('全范围播放没有随总时长扩展')
|
||||
|
||||
function state(x) {
|
||||
return [{
|
||||
boneName: 'p_0', spawnId: 3, x, y: 0, rotation: 0,
|
||||
scaleX: 1, scaleY: 1, alpha: 1, colorHex: 0xffffff,
|
||||
resourceId: 1, imageFrameIndex: 0, imageVisible: true, active: true,
|
||||
}]
|
||||
}
|
||||
const emitter = {
|
||||
step: 0,
|
||||
activeCount: 1,
|
||||
reset() { this.step = 0; this.activeCount = 1 },
|
||||
restartEmissionCycle() { this.step = 0 },
|
||||
update(_dt, mode) { if (mode === 'off') this.activeCount = 0; else this.step++; return this.capture() },
|
||||
capture() { return this.activeCount ? state(this.step) : [] },
|
||||
apply() {},
|
||||
}
|
||||
const system = {
|
||||
config: {
|
||||
lifeMode: 'fixed', lifeMin: 1, lifeMax: 1, delay: 0,
|
||||
loopDurationFrames: 3,
|
||||
generateLoopStartAnimation: true, loopStartUseCustomDuration: true, loopStartDurationFrames: 2,
|
||||
generateLoopEndAnimation: true, loopEndUseCustomDuration: true, loopEndDurationFrames: 4,
|
||||
},
|
||||
}
|
||||
api.bakeLoopFrames(system, emitter, 3, 10)
|
||||
const frames = system.frames
|
||||
if (frames.length !== 10) throw new Error('组合动画总帧数错误')
|
||||
if (JSON.stringify(frames[2]) !== JSON.stringify(frames[5])) throw new Error('启动末帧、循环首尾帧没有精确衔接')
|
||||
if (frames[0].some((particle) => particle.imageVisible !== false || particle.alpha !== 0)) {
|
||||
throw new Error('启动段首帧应把衔接帧粒子反推到未出生状态')
|
||||
}
|
||||
if (frames.slice(0, 2).some((frame) => frame.some((particle) => particle.x > frames[2][0].x))) {
|
||||
throw new Error('启动段不应包含衔接帧之后的额外循环')
|
||||
}
|
||||
if (frames[9].length !== 0) throw new Error('结束段末帧应为空')
|
||||
|
||||
console.log('时间轴 A-B 范围与开始/循环/结束三段边界校验通过')
|
||||
Reference in new issue
Block a user