中英双语
This commit is contained in:
1 parent
ea26af26ee
commit
b98c0333e6
50 files changed
+4082
-2049
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import type { ParticleSystem } from '../store/particleStore'
|
||||
import type { SpineExportVersion } from './spineExportSettings'
|
||||
import { translate } from '../i18n'
|
||||
|
||||
const CONNECTOR_URL = 'http://127.0.0.1:27843'
|
||||
|
||||
@@ -64,7 +65,7 @@ function imagePath(folder: string, name: string, contentType: string) {
|
||||
async function sourceToImage(source: string, folder: string, name: string): Promise<ConnectorImage | null> {
|
||||
if (!source) return null
|
||||
const response = await fetch(source)
|
||||
if (!response.ok) throw new Error(`无法读取导出图片:${name}`)
|
||||
if (!response.ok) throw new Error(translate('errors.readExportImageFailed', { name }))
|
||||
const blob = await response.blob()
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer())
|
||||
let binary = ''
|
||||
@@ -147,13 +148,13 @@ export async function convertWithSpineConnector(input: {
|
||||
request.onprogress = (event) => {
|
||||
onProgress?.({ phase: 'download', progress: event.lengthComputable && event.total > 0 ? event.loaded / event.total : null })
|
||||
}
|
||||
request.onerror = () => reject(new SpineConnectorError('未检测到本地 Spine 连接器,请先启动连接器', 'CONNECTOR_OFFLINE'))
|
||||
request.onerror = () => reject(new SpineConnectorError(translate('errors.connectorOffline'), 'CONNECTOR_OFFLINE'))
|
||||
request.onload = async () => {
|
||||
const responseBlob = request.response as Blob
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
let errorBody: ConnectorErrorBody = {}
|
||||
try { errorBody = JSON.parse(await responseBlob.text()) as ConnectorErrorBody } catch { /* 使用 HTTP 回退信息 */ }
|
||||
reject(new SpineConnectorError(errorBody.message || `Spine 连接器返回错误:HTTP ${request.status}`, errorBody.code, errorBody.pairUrl))
|
||||
reject(new SpineConnectorError(translate('errors.connectorHttpError', { status: request.status }), errorBody.code, errorBody.pairUrl))
|
||||
return
|
||||
}
|
||||
const encodedFileName = request.getResponseHeader('X-Spine-File-Name')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ParticleSystem } from '../store/particleStore'
|
||||
import { collectSpineExportImages } from './spineConnectorClient'
|
||||
import { createZipArchive } from './zipArchive'
|
||||
import { translate } from '../i18n'
|
||||
|
||||
function decodeBase64(value: string) {
|
||||
const binary = atob(value)
|
||||
@@ -23,7 +24,7 @@ function safeFileName(value: string) {
|
||||
|
||||
export async function buildSpineImagesArchive(input: { systems: ParticleSystem[]; imagesPath: string }) {
|
||||
const images = await collectSpineExportImages(input.systems)
|
||||
if (!images.length) throw new Error('当前可见粒子系统没有可导出的图片')
|
||||
if (!images.length) throw new Error(translate('errors.noExportableImages'))
|
||||
const folder = imagesDirectory(input.imagesPath)
|
||||
const blob = createZipArchive(images.map((image) => ({
|
||||
name: `${folder}/${image.path}`,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { reduceBezierSamples, reduceNumericSamples, roundSpine, unwrapDegrees, t
|
||||
import { buildWeightedTrailMesh } from './spineMeshBuilder'
|
||||
import { loopPlaybackRange, loopSegmentTiming } from '../core/loopSegmentTiming'
|
||||
import { collectSubEmitterIds } from '../core/subEmitterIds'
|
||||
import { translate } from '../i18n'
|
||||
|
||||
type Track = { key: string; name: string; states: Array<ParticleState | null> }
|
||||
type ExportInput = { systems: ParticleSystem[]; timeline: TimelineState; settings: SpineExportSettings }
|
||||
@@ -241,7 +242,7 @@ function resourceAttachment(resource: ParticleImageResource, frameIndex: number,
|
||||
const file = frame?.name || resource.textureName || `image_${resource.id}`
|
||||
const texture = frame?.texture || resource.texture
|
||||
const size = textureSize(texture)
|
||||
if (!size.width || !size.height) warnings.push({ code: 'missing-image', message: `图片“${file}”缺失或尺寸不可读,已保留路径。` })
|
||||
if (!size.width || !size.height) warnings.push({ code: 'missing-image', message: translate('errors.missingImageWarning', { file }) })
|
||||
const width = size.width || 1, height = size.height || 1
|
||||
return {
|
||||
path: normalizePath(resource.textureFolder, file),
|
||||
@@ -346,7 +347,7 @@ export function buildSpineJson(input: ExportInput): SpineExportResult {
|
||||
? frameLength
|
||||
: Math.min(frameLength, particleDurationFrames(system, sourceFps))
|
||||
const frames = (system.frames || []).slice(0, sourceCount)
|
||||
if (!frames.length) warnings.push({ code: 'empty-cache', message: `“${system.config.name}”没有可用帧缓存。` })
|
||||
if (!frames.length) warnings.push({ code: 'empty-cache', message: translate('errors.emptyCacheWarning', { name: system.config.name }) })
|
||||
const prefix = uniqueName(safeName(system.config.name, `system_${system.id}`), usedNames)
|
||||
document.bones.push({ name: prefix, parent: 'root' })
|
||||
const tracks = collectTracks(frames, settings.bonePool, usedNames, prefix)
|
||||
@@ -400,7 +401,7 @@ export function buildSpineJson(input: ExportInput): SpineExportResult {
|
||||
const indices = trailNames.map((name) => document.bones.findIndex((bone) => bone.name === name))
|
||||
const path = normalizePath('', resource.textureName || `trail_${resource.id}`)
|
||||
;(skin[slotName] ||= {})[`trail_${resource.id}`] = buildWeightedTrailMesh(system.config, resource, indices, path)
|
||||
if (!resource.texture) warnings.push({ code: 'missing-image', message: `拖尾图片“${resource.textureName || resource.id}”缺失,已保留路径。` })
|
||||
if (!resource.texture) warnings.push({ code: 'missing-image', message: translate('errors.missingTrailImageWarning', { file: resource.textureName || resource.id }) })
|
||||
}
|
||||
}
|
||||
// Spine 按 slots 数组顺序绘制。保持每个粒子的拖尾在图片下方。
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { normalizeSpine38JsonFor42 } from '../spine/spine38Compatibility'
|
||||
import { normalizeSpineVersion, supportedSpineVersionsText, type SupportedSpineVersion } from '../spine/spineVersion'
|
||||
import type { SpineJsonMap } from './spineJsonTypes'
|
||||
import { translate } from '../i18n'
|
||||
|
||||
class ExportValidationAttachmentLoader42 implements AttachmentLoader42 {
|
||||
newRegionAttachment(_skin: Skin42, name: string, path: string, _sequence: any) { return new RegionAttachment42(name, path) }
|
||||
@@ -61,16 +62,16 @@ function validateMesh(mesh: Record<string, any>, attachmentName: string, boneCou
|
||||
const uvs = mesh.uvs
|
||||
const vertices = mesh.vertices
|
||||
const triangles = mesh.triangles
|
||||
if (!Array.isArray(uvs) || !uvs.length || uvs.length % 2) throw new Error(`拖尾网格“${attachmentName}”的 UV 数据无效`)
|
||||
if (!Array.isArray(vertices) || !vertices.length) throw new Error(`拖尾网格“${attachmentName}”缺少顶点数据`)
|
||||
if (!Array.isArray(uvs) || !uvs.length || uvs.length % 2) throw new Error(translate('errors.invalidTrailUV', { name: attachmentName }))
|
||||
if (!Array.isArray(vertices) || !vertices.length) throw new Error(translate('errors.missingTrailVertices', { name: attachmentName }))
|
||||
const vertexCount = uvs.length / 2
|
||||
if (!Array.isArray(triangles) || triangles.some((index) => !Number.isInteger(index) || index < 0 || index >= vertexCount)) {
|
||||
throw new Error(`拖尾网格“${attachmentName}”的三角形索引越界`)
|
||||
throw new Error(translate('errors.trailIndexOutOfRange', { name: attachmentName }))
|
||||
}
|
||||
const hull = Number(mesh.hull) || 0
|
||||
if (!Number.isInteger(hull) || hull < 0 || hull > vertexCount) throw new Error(`拖尾网格“${attachmentName}”的外轮廓数量无效`)
|
||||
if (!Number.isInteger(hull) || hull < 0 || hull > vertexCount) throw new Error(translate('errors.invalidTrailHull', { name: attachmentName }))
|
||||
if (Array.isArray(mesh.edges) && mesh.edges.some((index) => !Number.isInteger(index) || index < 0 || index >= vertexCount * 2 || index % 2)) {
|
||||
throw new Error(`拖尾网格“${attachmentName}”的外轮廓边索引越界`)
|
||||
throw new Error(translate('errors.trailHullIndexOutOfRange', { name: attachmentName }))
|
||||
}
|
||||
// 未加权 Mesh 每个顶点只有 x/y;拖尾必须是逐顶点的骨骼权重编码。
|
||||
if (vertices.length === uvs.length) return
|
||||
@@ -78,21 +79,21 @@ function validateMesh(mesh: Record<string, any>, attachmentName: string, boneCou
|
||||
let decodedVertices = 0
|
||||
while (offset < vertices.length) {
|
||||
const influences = vertices[offset++]
|
||||
if (!Number.isInteger(influences) || influences < 1) throw new Error(`拖尾网格“${attachmentName}”包含无效骨骼权重数量`)
|
||||
if (!Number.isInteger(influences) || influences < 1) throw new Error(translate('errors.invalidInfluenceCount', { name: attachmentName }))
|
||||
let weightSum = 0
|
||||
for (let influence = 0; influence < influences; influence++) {
|
||||
const boneIndex = vertices[offset++]
|
||||
const x = vertices[offset++]
|
||||
const y = vertices[offset++]
|
||||
const weight = vertices[offset++]
|
||||
if (!Number.isInteger(boneIndex) || boneIndex < 0 || boneIndex >= boneCount) throw new Error(`拖尾网格“${attachmentName}”引用了不存在的骨骼`)
|
||||
if (![x, y, weight].every(Number.isFinite) || weight < 0) throw new Error(`拖尾网格“${attachmentName}”包含无效权重数据`)
|
||||
if (!Number.isInteger(boneIndex) || boneIndex < 0 || boneIndex >= boneCount) throw new Error(translate('errors.missingBoneReference', { name: attachmentName }))
|
||||
if (![x, y, weight].every(Number.isFinite) || weight < 0) throw new Error(translate('errors.invalidWeight', { name: attachmentName }))
|
||||
weightSum += weight
|
||||
}
|
||||
if (Math.abs(weightSum - 1) > 0.001) throw new Error(`拖尾网格“${attachmentName}”的顶点权重之和不为 1`)
|
||||
if (Math.abs(weightSum - 1) > 0.001) throw new Error(translate('errors.weightSum', { name: attachmentName }))
|
||||
decodedVertices++
|
||||
}
|
||||
if (offset !== vertices.length || decodedVertices !== vertexCount) throw new Error(`拖尾网格“${attachmentName}”的顶点数量与 UV 不一致`)
|
||||
if (offset !== vertices.length || decodedVertices !== vertexCount) throw new Error(translate('errors.vertexCountMismatch', { name: attachmentName }))
|
||||
}
|
||||
|
||||
function validateEditorImportStructure(root: Record<string, any>, version: SupportedSpineVersion) {
|
||||
@@ -100,12 +101,12 @@ function validateEditorImportStructure(root: Record<string, any>, version: Suppo
|
||||
const boneByName = new Map(bones.map((bone) => [String(bone.name || ''), bone]))
|
||||
const weightedMeshSlots = new Set<string>()
|
||||
for (const bone of bones) {
|
||||
if (bone.inherit != null && !VALID_INHERIT.has(bone.inherit)) throw new Error(`骨骼“${bone.name || ''}”的 inherit 值“${bone.inherit}”不符合 Spine ${version} JSON 格式`)
|
||||
if (bone.inherit != null && !VALID_INHERIT.has(bone.inherit)) throw new Error(translate('errors.invalidInherit', { name: bone.name || '', inherit: bone.inherit, version }))
|
||||
// 拖尾骨骼命名:系统名_序号_trail_资源id_骨骼序号,必须直接挂在粒子系统骨骼下。
|
||||
if (/_trail_\d+_\d+$/.test(String(bone.name || ''))) {
|
||||
const parent = boneByName.get(String(bone.parent || ''))
|
||||
if (!parent || /_trail_\d+_\d+$/.test(String(parent.name || ''))) {
|
||||
throw new Error(`拖尾骨骼“${bone.name || ''}”必须直接挂在粒子系统骨骼下,不能组成骨骼链`)
|
||||
throw new Error(translate('errors.trailBoneParent', { name: bone.name || '' }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,34 +126,34 @@ function validateEditorImportStructure(root: Record<string, any>, version: Suppo
|
||||
for (const slotName of weightedMeshSlots) {
|
||||
const attachmentKeys = animation?.slots?.[slotName]?.attachment
|
||||
if (Array.isArray(attachmentKeys) && attachmentKeys.some((key) => !key?.name)) {
|
||||
throw new Error(`加权拖尾插槽“${slotName}”不能使用空附件,未出现或死亡阶段应将对应骨骼缩放设为 0`)
|
||||
throw new Error(translate('errors.trailSlotEmpty', { name: slotName }))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateSpine38Structure(root: Record<string, any>) {
|
||||
if (String(root?.skeleton?.spine || '') !== '3.8') throw new Error('Spine 3.8 导出的版本标识必须为 3.8')
|
||||
if (String(root?.skeleton?.spine || '') !== '3.8') throw new Error(translate('errors.spine38Version'))
|
||||
for (const bone of root.bones || []) {
|
||||
if (bone.inherit != null) throw new Error(`Spine 3.8 骨骼“${bone.name || ''}”应使用 transform,不能使用 inherit`)
|
||||
if (bone.inherit != null) throw new Error(translate('errors.spine38Inherit', { name: bone.name || '' }))
|
||||
}
|
||||
for (const animation of Object.values(root.animations || {}) as SpineJsonMap[]) {
|
||||
for (const slot of Object.values(animation.slots || {}) as SpineJsonMap[]) {
|
||||
if (slot.rgba || slot.rgba2) throw new Error('Spine 3.8 插槽颜色时间轴必须使用 color / twoColor')
|
||||
if (slot.rgba || slot.rgba2) throw new Error(translate('errors.spine38SlotColor'))
|
||||
for (const frame of slot.attachment || []) {
|
||||
if (!Object.prototype.hasOwnProperty.call(frame, 'name')) throw new Error('Spine 3.8 附件时间轴的隐藏帧必须显式写入 name: null')
|
||||
if (!Object.prototype.hasOwnProperty.call(frame, 'name')) throw new Error(translate('errors.spine38AttachmentName'))
|
||||
}
|
||||
}
|
||||
for (const bone of Object.values(animation.bones || {}) as SpineJsonMap[]) {
|
||||
for (const frame of bone.rotate || []) {
|
||||
if (frame.value != null || frame.angle == null) throw new Error('Spine 3.8 旋转时间轴必须使用 angle')
|
||||
if (frame.value != null || frame.angle == null) throw new Error(translate('errors.spine38RotationAngle'))
|
||||
}
|
||||
}
|
||||
const stack: unknown[] = [animation]
|
||||
while (stack.length) {
|
||||
const value = stack.pop()
|
||||
if (!value || typeof value !== 'object') continue
|
||||
if (!Array.isArray(value) && Array.isArray((value as SpineJsonMap).curve)) throw new Error('Spine 3.8 不支持 4.x 数组曲线格式')
|
||||
if (!Array.isArray(value) && Array.isArray((value as SpineJsonMap).curve)) throw new Error(translate('errors.spine38Curve'))
|
||||
stack.push(...Object.values(value))
|
||||
}
|
||||
}
|
||||
@@ -165,14 +166,14 @@ function validateSpine38Structure(root: Record<string, any>) {
|
||||
export function validateSpineJson(text: string, expectedVersion?: SupportedSpineVersion) {
|
||||
const root = JSON.parse(text)
|
||||
const version = normalizeSpineVersion(root?.skeleton?.spine)
|
||||
if (!version) throw new Error(`导出版本不受支持,仅支持 Spine ${supportedSpineVersionsText()}`)
|
||||
if (expectedVersion && version !== expectedVersion) throw new Error(`导出版本不一致:设置为 Spine ${expectedVersion},JSON 标记为 Spine ${version}`)
|
||||
if (!version) throw new Error(translate('errors.unsupportedExportVersion', { versions: supportedSpineVersionsText() }))
|
||||
if (expectedVersion && version !== expectedVersion) throw new Error(translate('errors.exportVersionMismatch', { expected: expectedVersion, actual: version }))
|
||||
validateEditorImportStructure(root, version)
|
||||
if (version === '3.8') validateSpine38Structure(root)
|
||||
const runtimeRoot = version === '3.8' ? normalizeSpine38JsonFor42(JSON.parse(JSON.stringify(root))) : root
|
||||
const data = version === '4.3'
|
||||
? new SkeletonJson43(new ExportValidationAttachmentLoader43()).readSkeletonData(runtimeRoot)
|
||||
: new SkeletonJson42(new ExportValidationAttachmentLoader42()).readSkeletonData(runtimeRoot)
|
||||
if (!data.bones.length) throw new Error('导出结果缺少骨骼')
|
||||
if (!data.bones.length) throw new Error(translate('errors.exportMissingBones'))
|
||||
return { version, bones: data.bones.length, slots: data.slots.length, animations: data.animations.length }
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { translate } from '../i18n'
|
||||
|
||||
export interface ZipArchiveFile {
|
||||
name: string
|
||||
data: Uint8Array
|
||||
@@ -17,7 +19,7 @@ function crc32(data: Uint8Array) {
|
||||
|
||||
function safeArchivePath(value: string) {
|
||||
const parts = String(value || '').replace(/\\/g, '/').split('/').filter((part) => part && part !== '.')
|
||||
if (!parts.length || parts.some((part) => part === '..')) throw new Error('ZIP 文件路径无效')
|
||||
if (!parts.length || parts.some((part) => part === '..')) throw new Error(translate('errors.invalidZipPath'))
|
||||
return parts.join('/')
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user