spine连接器和导出修改

This commit is contained in:
tianmo committed 2026-09-05 16:20:23 +08:00
1 parent 535f8d7558
commit 0bde824c88
81 files changed
+11800 -1251

No files matched your search

+152
View File
@@ -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
})
+59
View File
@@ -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
})
+35
View File
@@ -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))
+68
View File
@@ -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 })
}
+55
View File
@@ -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}`)
+92
View File
@@ -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 范围与开始/循环/结束三段边界校验通过')