多国语言支持

This commit is contained in:
tianmo committed 2026-10-22 18:37:30 +08:00
1 parent b98c0333e6
commit 04de5e5a8e
23 files changed
+7795 -1279

No files matched your search

+297
View File
@@ -0,0 +1,297 @@
import { readFile, writeFile } from 'node:fs/promises'
const overrides = {
'zh-TW': {
common: {
close: '關閉', reset: '重設', add: '新增', remove: '移除', delete: '刪除', clear: '清除',
cancel: '取消', confirm: '確定', none: '無', enabled: '啟用', disabled: '停用',
fixed: '固定', random: '隨機', curve: '曲線', followExport: '跟隨匯出',
linear: '線性', bezier: '貝茲', above: '上方', below: '下方', left: '左側', right: '右側',
seconds: '秒', frames: '幀',
},
toolbar: {
settings: '系統設定', undo: '復原上一步 (Ctrl+Z)', pan: '拖曳畫布 (Q)', translate: '位移 (W)',
rotate: '旋轉 (E)', scale: '縮放 (R)', zoomIn: '放大', zoomOut: '縮小', fitView: '適合畫布',
toggleGrid: '切換網格', resetView: '重設視圖', background: '背景圖', backgroundSettings: '背景圖顯示設定',
changeBackground: '更換圖像', uploadBackground: '上傳圖像', clearBackground: '清除背景',
noBackground: '未上傳圖像', opacity: '透明度', help: '使用說明', openHelp: '查看使用說明',
},
config: { title: '設定管理', save: '儲存設定', load: '載入設定', selectPreset: '選擇預設', noPresets: '無預設檔案' },
image: { fixed: '固定', sequence: '序列幀', onceHide: '單次-尾幀消失', onceHold: '單次-尾幀固定', loopForward: '正向循環', loopReverse: '反向循環', weight: '權重 %', pivot: '貼圖軸點' },
emission: {
title: '發射模式', stream: '持續', burst: '爆發', normalStream: '一般持續', loopStream: '循環持續',
rate: '發射速率', duration: '發射時長', burstCount: '單次數量', delay: '延遲時間',
shapeTitle: '發射器形狀', point: '點', circle: '圓形', rect: '矩形', cone: '錐形', path: '路徑',
},
attributes: { title: '粒子屬性', initialParams: '粒子初始參數', lifecycleTitle: '粒子生命週期變化參數' },
appearance: { title: '外觀與資源', opacity: '透明度', renderLayer: '渲染層級(Spine 插槽)', imageResources: '圖片資源', independentAlpha: '獨立透明度', off: '關閉', normal: '正常', add: '相加', multiply: '相乘', screen: '濾色' },
scene: { title: '場景物件', particle: '粒子', subEmitter: '子發射器', collision: '碰撞', path: '路徑', positionX: 'X 位置', positionY: 'Y 位置', offsetX: 'X 偏移', offsetY: 'Y 偏移', rotation: '旋轉', scaleX: 'X 縮放', scaleY: 'Y 縮放', xPosition: 'X 位置', yPosition: 'Y 位置', scaleXShort: 'X 縮放', scaleYShort: 'Y 縮放', seed: '隨機種子' },
modifiers: {
title: '修改器', damping: '阻尼', enableNoise: '開啟移動雜訊', enableGravity: '開啟重力', enableWind: '開啟風力',
enableForceField: '開啟力場', enableVortex: '開啟漩渦', enableAttraction: '開啟吸附', enableTrail: '開啟拖尾',
enableFollow: '開啟發射器跟隨', enableCollision: '開啟碰撞', enableSubEmitter: '開啟子發射器',
},
timeline: {
continuousLoop: '持續循環播放', abLoop: '獨立 A/B 區間循環', abLoopShort: 'AB 循環',
emission: '持續發射', particleLife: '粒子存在', spineAnimation: 'Spine 動畫', ownerAnimation: '所屬動畫', frameStatus: '幀 {current} / {total}',
},
export: { title: '匯出設定', spineVersion: 'Spine 版本', fps: '匯出 FPS', exportJson: '匯出 Spine JSON', exportImages: '匯出圖片 ZIP', connectorReady: '連接器可用 · Spine 已就緒', connectorOffline: '本機 Spine 連接器未啟動', spineNotFound: '連接器已啟動,但找不到 Spine', notAuthorized: '連接器已啟動,但目前網站尚未授權', checking: '偵測中', offline: '未啟動', notFound: '找不到 Spine', unauthorized: '未授權', connectorBusy: '正在重新計算並產生 Spine 專案', connectorGenerate: '使用本機 Spine 自動產生 {version} 專案' },
spine: { title: 'Spine 屬性', resources: 'Spine 資源', animations: 'Spine 動畫', hierarchy: '階層樹', xPosition: 'X 位置', yPosition: 'Y 位置', rotation: '旋轉', startTime: '開始時間', scaleX: 'X 縮放', scaleY: 'Y 縮放' },
help: { title: '使用說明', commonOps: '常用操作', shortcuts: '快速鍵', modules: '功能模組詳解' },
presets: { particleSweep: '1.粒子掃過', barBlocks: '2.長條方塊', forceTrail: '3.力場拖尾', lightningSequence: '4.雷電序列幀', texturePivot: '5.貼圖軸點', subEmitter: '6.子發射器' },
},
ja: {
common: {
close: '閉じる', reset: 'リセット', add: '追加', remove: '削除', delete: '削除', clear: 'クリア',
cancel: 'キャンセル', confirm: '確定', none: 'なし', enabled: '有効', disabled: '無効',
fixed: '固定', random: 'ランダム', curve: 'カーブ', followExport: 'エクスポート設定に従う',
linear: 'リニア', bezier: 'ベジェ', above: '上', below: '下', left: '左', right: '右',
seconds: '秒', frames: 'フレーム',
},
toolbar: {
settings: '設定', undo: '元に戻す (Ctrl+Z)', pan: 'キャンバス移動 (Q)', translate: '移動 (W)',
rotate: '回転 (E)', scale: '拡大縮小 (R)', zoomIn: '拡大', zoomOut: '縮小', fitView: 'キャンバスに合わせる',
toggleGrid: 'グリッド切替', resetView: 'ビューをリセット', background: '背景', backgroundSettings: '背景画像設定',
changeBackground: '画像を変更', uploadBackground: '画像をアップロード', clearBackground: '背景をクリア',
noBackground: '画像なし', opacity: '不透明度', help: 'ヘルプ', openHelp: 'ヘルプを開く',
},
config: { title: '設定管理', save: '設定を保存', load: '設定を読み込む', selectPreset: 'プリセットを選択', noPresets: 'プリセットなし' },
image: { fixed: '固定', sequence: 'シーケンス', onceHide: '1回・最終フレームで非表示', onceHold: '1回・最終フレームを保持', loopForward: '正方向ループ', loopReverse: '逆方向ループ', weight: '重み %', pivot: 'テクスチャピボット' },
emission: {
title: 'エミッションモード', stream: 'ストリーム', burst: 'バースト', normalStream: '通常ストリーム', loopStream: 'ループストリーム',
rate: '放出レート', duration: '放出時間', burstCount: '一度の放出数', delay: '遅延時間',
shapeTitle: 'エミッター形状', point: '点', circle: '円', rect: '矩形', cone: '円錐', path: 'パス',
},
attributes: { title: 'パーティクル属性', initialParams: 'パーティクル初期値', lifecycleTitle: 'ライフタイム中の変化' },
appearance: { title: '外観とリソース', opacity: '不透明度', renderLayer: 'レンダー階層(Spine スロット)', imageResources: '画像リソース', independentAlpha: '独立した不透明度', off: 'オフ', normal: '通常', add: '加算', multiply: '乗算', screen: 'スクリーン' },
scene: { title: 'シーンオブジェクト', particle: 'パーティクル', subEmitter: 'サブエミッター', collision: 'コリジョン', path: 'パス', positionX: 'X 位置', positionY: 'Y 位置', offsetX: 'X オフセット', offsetY: 'Y オフセット', rotation: '回転', scaleX: 'X スケール', scaleY: 'Y スケール', xPosition: 'X 位置', yPosition: 'Y 位置', scaleXShort: 'X スケール', scaleYShort: 'Y スケール', seed: 'ランダムシード' },
modifiers: {
title: 'モディファイア', damping: '減衰', enableNoise: '移動ノイズを有効化', enableGravity: '重力を有効化', enableWind: '風を有効化',
enableForceField: 'フォースフィールドを有効化', enableVortex: '渦を有効化', enableAttraction: '吸着を有効化', enableTrail: 'トレイルを有効化',
enableFollow: 'エミッター追従を有効化', enableCollision: 'コリジョンを有効化', enableSubEmitter: 'サブエミッターを有効化',
},
timeline: {
continuousLoop: '連続ループ再生', abLoop: '独立した A/B 区間ループ', abLoopShort: 'AB ループ',
emission: '放出中', particleLife: 'パーティクル存在', spineAnimation: 'Spine アニメーション', ownerAnimation: '所属アニメーション', frameStatus: 'フレーム {current} / {total}',
},
export: { title: 'エクスポート設定', spineVersion: 'Spine バージョン', fps: 'エクスポート FPS', exportJson: 'Spine JSON を書き出す', exportImages: '画像 ZIP を書き出す', connectorReady: 'コネクタ使用可能 · Spine 準備完了', connectorOffline: 'ローカル Spine コネクタが起動していません', spineNotFound: 'コネクタは起動していますが Spine が見つかりません', notAuthorized: 'コネクタは起動していますが、このサイトは未認証です', checking: '確認中', offline: '未起動', notFound: 'Spine が見つかりません', unauthorized: '未認証', connectorBusy: 'Spine プロジェクトを再計算して生成中', connectorGenerate: 'ローカル Spine で Spine {version} プロジェクトを生成' },
spine: { title: 'Spine プロパティ', resources: 'Spine リソース', animations: 'Spine アニメーション', hierarchy: '階層ツリー', xPosition: 'X 位置', yPosition: 'Y 位置', rotation: '回転', startTime: '開始時間', scaleX: 'X スケール', scaleY: 'Y スケール' },
help: { title: 'ヘルプ', commonOps: 'よく使う操作', shortcuts: 'ショートカット', modules: '機能モジュール' },
presets: { particleSweep: '1.パーティクルスイープ', barBlocks: '2.バーブロック', forceTrail: '3.フォースフィールドトレイル', lightningSequence: '4.雷シーケンス', texturePivot: '5.テクスチャピボット', subEmitter: '6.サブエミッター' },
},
ko: {
common: {
close: '닫기', reset: '초기화', add: '추가', remove: '제거', delete: '삭제', clear: '지우기',
cancel: '취소', confirm: '확인', none: '없음', enabled: '사용', disabled: '사용 안 함',
fixed: '고정', random: '랜덤', curve: '곡선', followExport: '내보내기 설정 따르기',
linear: '선형', bezier: '베지어', above: '위', below: '아래', left: '왼쪽', right: '오른쪽',
seconds: '초', frames: '프레임',
},
toolbar: {
settings: '설정', undo: '실행 취소 (Ctrl+Z)', pan: '캔버스 이동 (Q)', translate: '이동 (W)',
rotate: '회전 (E)', scale: '크기 조절 (R)', zoomIn: '확대', zoomOut: '축소', fitView: '캔버스에 맞추기',
toggleGrid: '그리드 전환', resetView: '보기 초기화', background: '배경', backgroundSettings: '배경 이미지 설정',
changeBackground: '이미지 교체', uploadBackground: '이미지 업로드', clearBackground: '배경 지우기',
noBackground: '이미지 없음', opacity: '불투명도', help: '도움말', openHelp: '도움말 열기',
},
config: { title: '설정 관리', save: '설정 저장', load: '설정 불러오기', selectPreset: '프리셋 선택', noPresets: '프리셋 없음' },
image: { fixed: '고정', sequence: '시퀀스', onceHide: '1회·마지막 프레임에서 숨김', onceHold: '1회·마지막 프레임 유지', loopForward: '정방향 루프', loopReverse: '역방향 루프', weight: '가중치 %', pivot: '텍스처 피벗' },
emission: {
title: '이미션 모드', stream: '스트림', burst: '버스트', normalStream: '일반 스트림', loopStream: '루프 스트림',
rate: '이미션 속도', duration: '이미션 시간', burstCount: '1회 생성 수', delay: '지연 시간',
shapeTitle: '이미터 모양', point: '점', circle: '원', rect: '사각형', cone: '원뿔', path: '경로',
},
attributes: { title: '파티클 속성', initialParams: '파티클 초기값', lifecycleTitle: '수명에 따른 변화' },
appearance: { title: '외형과 리소스', opacity: '불투명도', renderLayer: '렌더링 레이어(Spine 슬롯)', imageResources: '이미지 리소스', independentAlpha: '독립 불투명도', off: '끄기', normal: '일반', add: '더하기', multiply: '곱하기', screen: '스크린' },
scene: { title: '장면 오브젝트', particle: '파티클', subEmitter: '하위 이미터', collision: '충돌', path: '경로', positionX: 'X 위치', positionY: 'Y 위치', offsetX: 'X 오프셋', offsetY: 'Y 오프셋', rotation: '회전', scaleX: 'X 크기', scaleY: 'Y 크기', xPosition: 'X 위치', yPosition: 'Y 위치', scaleXShort: 'X 크기', scaleYShort: 'Y 크기', seed: '랜덤 시드' },
modifiers: {
title: '모디파이어', damping: '감쇠', enableNoise: '이동 노이즈 사용', enableGravity: '중력 사용', enableWind: '바람 사용',
enableForceField: '힘장 사용', enableVortex: '소용돌이 사용', enableAttraction: '흡인 사용', enableTrail: '트레일 사용',
enableFollow: '이미터 추적 사용', enableCollision: '충돌 사용', enableSubEmitter: '하위 이미터 사용',
},
timeline: {
continuousLoop: '연속 루프 재생', abLoop: '독립 A/B 구간 루프', abLoopShort: 'AB 루프',
emission: '이미션 중', particleLife: '파티클 존재', spineAnimation: 'Spine 애니메이션', ownerAnimation: '소속 애니메이션', frameStatus: '프레임 {current} / {total}',
},
export: { title: '내보내기 설정', spineVersion: 'Spine 버전', fps: '내보내기 FPS', exportJson: 'Spine JSON 내보내기', exportImages: '이미지 ZIP 내보내기', connectorReady: '커넥터 사용 가능 · Spine 준비됨', connectorOffline: '로컬 Spine 커넥터가 실행 중이 아닙니다', spineNotFound: '커넥터는 실행 중이지만 Spine을 찾을 수 없습니다', notAuthorized: '커넥터는 실행 중이지만 이 사이트는 승인되지 않았습니다', checking: '확인 중', offline: '오프라인', notFound: 'Spine 없음', unauthorized: '승인되지 않음', connectorBusy: 'Spine 프로젝트를 다시 계산하고 생성 중', connectorGenerate: '로컬 Spine으로 Spine {version} 프로젝트 생성' },
spine: { title: 'Spine 속성', resources: 'Spine 리소스', animations: 'Spine 애니메이션', hierarchy: '계층 트리', xPosition: 'X 위치', yPosition: 'Y 위치', rotation: '회전', startTime: '시작 시간', scaleX: 'X 크기', scaleY: 'Y 크기' },
help: { title: '도움말', commonOps: '자주 쓰는 작업', shortcuts: '단축키', modules: '기능 모듈' },
presets: { particleSweep: '1.파티클 스윕', barBlocks: '2.바 블록', forceTrail: '3.힘장 트레일', lightningSequence: '4.번개 시퀀스', texturePivot: '5.텍스처 피벗', subEmitter: '6.하위 이미터' },
},
fr: {
common: {
close: 'Fermer', reset: 'Réinitialiser', add: 'Ajouter', remove: 'Retirer', delete: 'Supprimer', clear: 'Effacer',
cancel: 'Annuler', confirm: 'Confirmer', none: 'Aucun', enabled: 'Activé', disabled: 'Désactivé',
fixed: 'Fixe', random: 'Aléatoire', curve: 'Courbe', followExport: 'Suivre l’export',
linear: 'Linéaire', bezier: 'Bézier', above: 'Au-dessus', below: 'En dessous', left: 'Gauche', right: 'Droite',
seconds: 's', frames: 'images',
},
toolbar: {
settings: 'Paramètres', undo: 'Annuler (Ctrl+Z)', pan: 'Déplacer le canevas (Q)', translate: 'Déplacement (W)',
rotate: 'Rotation (E)', scale: 'Échelle (R)', zoomIn: 'Zoom avant', zoomOut: 'Zoom arrière', fitView: 'Ajuster le canevas',
toggleGrid: 'Afficher/masquer la grille', resetView: 'Réinitialiser la vue', background: 'Arrière-plan', backgroundSettings: 'Paramètres de l’arrière-plan',
changeBackground: 'Remplacer l’image', uploadBackground: 'Importer une image', clearBackground: 'Effacer l’arrière-plan',
noBackground: 'Aucune image', opacity: 'Opacité', help: 'Aide', openHelp: 'Ouvrir l’aide',
},
config: { title: 'Configuration', save: 'Enregistrer', load: 'Charger', selectPreset: 'Choisir un préréglage', noPresets: 'Aucun préréglage' },
image: { fixed: 'Image fixe', sequence: 'Séquence', onceHide: 'Une fois, masquer à la fin', onceHold: 'Une fois, conserver la dernière image', loopForward: 'Boucle avant', loopReverse: 'Boucle arrière', weight: 'Poids %', pivot: 'Pivot de texture' },
emission: {
title: 'Mode d’émission', stream: 'Continu', burst: 'Rafale', normalStream: 'Continu standard', loopStream: 'Boucle continue',
rate: 'Taux d’émission', duration: 'Durée d’émission', burstCount: 'Quantité par rafale', delay: 'Délai',
shapeTitle: 'Forme de l’émetteur', point: 'Point', circle: 'Cercle', rect: 'Rectangle', cone: 'Cône', path: 'Chemin',
},
attributes: { title: 'Attributs des particules', initialParams: 'Valeurs initiales', lifecycleTitle: 'Évolution pendant la durée de vie' },
appearance: { title: 'Apparence et ressources', opacity: 'Opacité', renderLayer: 'Calque de rendu (slot Spine)', imageResources: 'Ressources d’image', independentAlpha: 'Opacité indépendante', off: 'Désactivé', normal: 'Normal', add: 'Ajout', multiply: 'Multiplication', screen: 'Écran' },
scene: { title: 'Objets de scène', particle: 'Particules', subEmitter: 'Sous-émetteur', collision: 'Collision', path: 'Chemin', positionX: 'Position X', positionY: 'Position Y', offsetX: 'Décalage X', offsetY: 'Décalage Y', rotation: 'Rotation', scaleX: 'Échelle X', scaleY: 'Échelle Y', xPosition: 'Position X', yPosition: 'Position Y', scaleXShort: 'Échelle X', scaleYShort: 'Échelle Y', seed: 'Graine aléatoire' },
modifiers: {
title: 'Modificateurs', damping: 'Amortissement', enableNoise: 'Activer le bruit de mouvement', enableGravity: 'Activer la gravité', enableWind: 'Activer le vent',
enableForceField: 'Activer le champ de force', enableVortex: 'Activer le tourbillon', enableAttraction: 'Activer l’attraction', enableTrail: 'Activer la traînée',
enableFollow: 'Activer le suivi de l’émetteur', enableCollision: 'Activer la collision', enableSubEmitter: 'Activer les sous-émetteurs',
},
timeline: {
continuousLoop: 'Lecture en boucle continue', abLoop: 'Boucle indépendante de plage A/B', abLoopShort: 'Boucle AB',
emission: 'Émission', particleLife: 'Particules actives', spineAnimation: 'Animation Spine', ownerAnimation: 'Animation associée', frameStatus: 'Image {current} / {total}',
},
export: { title: 'Paramètres d’export', spineVersion: 'Version de Spine', fps: 'FPS d’export', exportJson: 'Exporter Spine JSON', exportImages: 'Exporter les images ZIP', connectorReady: 'Connecteur disponible · Spine prêt', connectorOffline: 'Le connecteur Spine local n’est pas démarré', spineNotFound: 'Le connecteur est démarré, mais Spine est introuvable', notAuthorized: 'Le connecteur est démarré, mais ce site n’est pas autorisé', checking: 'Vérification', offline: 'Hors ligne', notFound: 'Spine introuvable', unauthorized: 'Non autorisé', connectorBusy: 'Recalcul et génération du projet Spine', connectorGenerate: 'Générer un projet Spine {version} avec Spine local' },
spine: { title: 'Propriétés Spine', resources: 'Ressources Spine', animations: 'Animations Spine', hierarchy: 'Hiérarchie', xPosition: 'Position X', yPosition: 'Position Y', rotation: 'Rotation', startTime: 'Heure de début', scaleX: 'Échelle X', scaleY: 'Échelle Y' },
help: { title: 'Aide', commonOps: 'Actions courantes', shortcuts: 'Raccourcis', modules: 'Guide des modules' },
presets: { particleSweep: '1.Balayage de particules', barBlocks: '2.Blocs rectangulaires', forceTrail: '3.Traînée de champ de force', lightningSequence: '4.Séquence d’éclairs', texturePivot: '5.Pivot de texture', subEmitter: '6.Sous-émetteur' },
},
de: {
common: {
close: 'Schließen', reset: 'Zurücksetzen', add: 'Hinzufügen', remove: 'Entfernen', delete: 'Löschen', clear: 'Leeren',
cancel: 'Abbrechen', confirm: 'Bestätigen', none: 'Keine', enabled: 'Aktiviert', disabled: 'Deaktiviert',
fixed: 'Fest', random: 'Zufällig', curve: 'Kurve', followExport: 'Exporteinstellung folgen',
linear: 'Linear', bezier: 'Bézier', above: 'Über', below: 'Unter', left: 'Links', right: 'Rechts',
seconds: 's', frames: 'Frames',
},
toolbar: {
settings: 'Einstellungen', undo: 'Rückgängig (Ctrl+Z)', pan: 'Canvas verschieben (Q)', translate: 'Verschieben (W)',
rotate: 'Drehen (E)', scale: 'Skalieren (R)', zoomIn: 'Vergrößern', zoomOut: 'Verkleinern', fitView: 'An Canvas anpassen',
toggleGrid: 'Raster umschalten', resetView: 'Ansicht zurücksetzen', background: 'Hintergrund', backgroundSettings: 'Hintergrundeinstellungen',
changeBackground: 'Bild ersetzen', uploadBackground: 'Bild hochladen', clearBackground: 'Hintergrund löschen',
noBackground: 'Kein Bild', opacity: 'Deckkraft', help: 'Hilfe', openHelp: 'Hilfe öffnen',
},
config: { title: 'Konfiguration', save: 'Konfiguration speichern', load: 'Konfiguration laden', selectPreset: 'Voreinstellung wählen', noPresets: 'Keine Voreinstellungen' },
image: { fixed: 'Einzelbild', sequence: 'Sequenz', onceHide: 'Einmal, am Ende ausblenden', onceHold: 'Einmal, letztes Bild halten', loopForward: 'Vorwärts-Schleife', loopReverse: 'Rückwärts-Schleife', weight: 'Gewicht %', pivot: 'Textur-Pivot' },
emission: {
title: 'Emissionsmodus', stream: 'Kontinuierlich', burst: 'Burst', normalStream: 'Normaler Stream', loopStream: 'Loop-Stream',
rate: 'Emissionsrate', duration: 'Emissionsdauer', burstCount: 'Menge pro Burst', delay: 'Verzögerung',
shapeTitle: 'Emitterform', point: 'Punkt', circle: 'Kreis', rect: 'Rechteck', cone: 'Kegel', path: 'Pfad',
},
attributes: { title: 'Partikeleigenschaften', initialParams: 'Initialwerte', lifecycleTitle: 'Änderung über die Lebensdauer' },
appearance: { title: 'Aussehen und Ressourcen', opacity: 'Deckkraft', renderLayer: 'Renderebene (Spine-Slot)', imageResources: 'Bildressourcen', independentAlpha: 'Unabhängige Deckkraft', off: 'Aus', normal: 'Normal', add: 'Addieren', multiply: 'Multiplizieren', screen: 'Bildschirm' },
scene: { title: 'Szenenobjekte', particle: 'Partikel', subEmitter: 'Sub-Emitter', collision: 'Kollision', path: 'Pfad', positionX: 'X-Position', positionY: 'Y-Position', offsetX: 'X-Versatz', offsetY: 'Y-Versatz', rotation: 'Drehung', scaleX: 'X-Skalierung', scaleY: 'Y-Skalierung', xPosition: 'X-Position', yPosition: 'Y-Position', scaleXShort: 'X-Skalierung', scaleYShort: 'Y-Skalierung', seed: 'Zufallsseed' },
modifiers: {
title: 'Modifikatoren', damping: 'Dämpfung', enableNoise: 'Bewegungsrauschen aktivieren', enableGravity: 'Schwerkraft aktivieren', enableWind: 'Wind aktivieren',
enableForceField: 'Kraftfeld aktivieren', enableVortex: 'Wirbel aktivieren', enableAttraction: 'Anziehung aktivieren', enableTrail: 'Schweif aktivieren',
enableFollow: 'Emitter-Verfolgung aktivieren', enableCollision: 'Kollision aktivieren', enableSubEmitter: 'Sub-Emitter aktivieren',
},
timeline: {
continuousLoop: 'Dauerhaft in Schleife abspielen', abLoop: 'Unabhängige A/B-Bereichsschleife', abLoopShort: 'AB-Schleife',
emission: 'Emission', particleLife: 'Aktive Partikel', spineAnimation: 'Spine-Animation', ownerAnimation: 'Zugehörige Animation', frameStatus: 'Frame {current} / {total}',
},
export: { title: 'Exporteinstellungen', spineVersion: 'Spine-Version', fps: 'Export-FPS', exportJson: 'Spine JSON exportieren', exportImages: 'Bilder-ZIP exportieren', connectorReady: 'Connector verfügbar · Spine bereit', connectorOffline: 'Der lokale Spine-Connector läuft nicht', spineNotFound: 'Der Connector läuft, aber Spine wurde nicht gefunden', notAuthorized: 'Der Connector läuft, aber diese Website ist nicht autorisiert', checking: 'Prüfung', offline: 'Offline', notFound: 'Spine nicht gefunden', unauthorized: 'Nicht autorisiert', connectorBusy: 'Spine-Projekt wird neu berechnet und erstellt', connectorGenerate: 'Spine-{version}-Projekt mit lokalem Spine erstellen' },
spine: { title: 'Spine-Eigenschaften', resources: 'Spine-Ressourcen', animations: 'Spine-Animationen', hierarchy: 'Hierarchie', xPosition: 'X-Position', yPosition: 'Y-Position', rotation: 'Drehung', startTime: 'Startzeit', scaleX: 'X-Skalierung', scaleY: 'Y-Skalierung' },
help: { title: 'Hilfe', commonOps: 'Häufige Aktionen', shortcuts: 'Tastenkürzel', modules: 'Modulhandbuch' },
presets: { particleSweep: '1.Partikel-Sweep', barBlocks: '2.Balkenblöcke', forceTrail: '3.Kraftfeldschweif', lightningSequence: '4.Blitzsequenz', texturePivot: '5.Textur-Pivot', subEmitter: '6.Sub-Emitter' },
},
'pt-BR': {
common: {
close: 'Fechar', reset: 'Redefinir', add: 'Adicionar', remove: 'Remover', delete: 'Excluir', clear: 'Limpar',
cancel: 'Cancelar', confirm: 'Confirmar', none: 'Nenhum', enabled: 'Ativado', disabled: 'Desativado',
fixed: 'Fixo', random: 'Aleatório', curve: 'Curva', followExport: 'Seguir exportação',
linear: 'Linear', bezier: 'Bézier', above: 'Acima', below: 'Abaixo', left: 'Esquerda', right: 'Direita',
seconds: 's', frames: 'quadros',
},
toolbar: {
settings: 'Configurações', undo: 'Desfazer (Ctrl+Z)', pan: 'Mover tela (Q)', translate: 'Mover (W)',
rotate: 'Girar (E)', scale: 'Escalar (R)', zoomIn: 'Ampliar', zoomOut: 'Reduzir', fitView: 'Ajustar à tela',
toggleGrid: 'Alternar grade', resetView: 'Redefinir visualização', background: 'Fundo', backgroundSettings: 'Configurações do fundo',
changeBackground: 'Trocar imagem', uploadBackground: 'Enviar imagem', clearBackground: 'Limpar fundo',
noBackground: 'Nenhuma imagem', opacity: 'Opacidade', help: 'Ajuda', openHelp: 'Abrir ajuda',
},
config: { title: 'Gerenciamento de configuração', save: 'Salvar configuração', load: 'Carregar configuração', selectPreset: 'Selecionar predefinição', noPresets: 'Nenhuma predefinição' },
image: { fixed: 'Imagem única', sequence: 'Sequência', onceHide: 'Uma vez, ocultar no final', onceHold: 'Uma vez, manter último quadro', loopForward: 'Loop para frente', loopReverse: 'Loop reverso', weight: 'Peso %', pivot: 'Pivô da textura' },
emission: {
title: 'Modo de emissão', stream: 'Contínuo', burst: 'Explosão', normalStream: 'Contínuo normal', loopStream: 'Loop contínuo',
rate: 'Taxa de emissão', duration: 'Duração da emissão', burstCount: 'Quantidade por explosão', delay: 'Atraso',
shapeTitle: 'Forma do emissor', point: 'Ponto', circle: 'Círculo', rect: 'Retângulo', cone: 'Cone', path: 'Caminho',
},
attributes: { title: 'Atributos das partículas', initialParams: 'Valores iniciais', lifecycleTitle: 'Alterações durante o tempo de vida' },
appearance: { title: 'Aparência e recursos', opacity: 'Opacidade', renderLayer: 'Camada de renderização (slot Spine)', imageResources: 'Recursos de imagem', independentAlpha: 'Opacidade independente', off: 'Desligado', normal: 'Normal', add: 'Adição', multiply: 'Multiplicação', screen: 'Tela' },
scene: { title: 'Objetos da cena', particle: 'Partículas', subEmitter: 'Subemissor', collision: 'Colisão', path: 'Caminho', positionX: 'Posição X', positionY: 'Posição Y', offsetX: 'Deslocamento X', offsetY: 'Deslocamento Y', rotation: 'Rotação', scaleX: 'Escala X', scaleY: 'Escala Y', xPosition: 'Posição X', yPosition: 'Posição Y', scaleXShort: 'Escala X', scaleYShort: 'Escala Y', seed: 'Semente aleatória' },
modifiers: {
title: 'Modificadores', damping: 'Amortecimento', enableNoise: 'Ativar ruído de movimento', enableGravity: 'Ativar gravidade', enableWind: 'Ativar vento',
enableForceField: 'Ativar campo de força', enableVortex: 'Ativar vórtice', enableAttraction: 'Ativar atração', enableTrail: 'Ativar rastro',
enableFollow: 'Ativar seguimento do emissor', enableCollision: 'Ativar colisão', enableSubEmitter: 'Ativar subemissores',
},
timeline: {
continuousLoop: 'Reprodução em loop contínuo', abLoop: 'Loop independente do intervalo A/B', abLoopShort: 'Loop AB',
emission: 'Emissão', particleLife: 'Partículas ativas', spineAnimation: 'Animação Spine', ownerAnimation: 'Animação associada', frameStatus: 'Quadro {current} / {total}',
},
export: { title: 'Configurações de exportação', spineVersion: 'Versão do Spine', fps: 'FPS de exportação', exportJson: 'Exportar Spine JSON', exportImages: 'Exportar imagens ZIP', connectorReady: 'Conector disponível · Spine pronto', connectorOffline: 'O conector local do Spine não está em execução', spineNotFound: 'O conector está ativo, mas o Spine não foi encontrado', notAuthorized: 'O conector está ativo, mas este site não está autorizado', checking: 'Verificando', offline: 'Offline', notFound: 'Spine não encontrado', unauthorized: 'Não autorizado', connectorBusy: 'Recalculando e gerando o projeto Spine', connectorGenerate: 'Gerar projeto Spine {version} com o Spine local' },
spine: { title: 'Propriedades do Spine', resources: 'Recursos Spine', animations: 'Animações Spine', hierarchy: 'Hierarquia', xPosition: 'Posição X', yPosition: 'Posição Y', rotation: 'Rotação', startTime: 'Tempo inicial', scaleX: 'Escala X', scaleY: 'Escala Y' },
help: { title: 'Ajuda', commonOps: 'Ações comuns', shortcuts: 'Atalhos', modules: 'Guia dos módulos' },
presets: { particleSweep: '1.Varredura de partículas', barBlocks: '2.Blocos retangulares', forceTrail: '3.Rastro de campo de força', lightningSequence: '4.Sequência de raios', texturePivot: '5.Pivô da textura', subEmitter: '6.Subemissor' },
},
es: {
common: {
close: 'Cerrar', reset: 'Restablecer', add: 'Añadir', remove: 'Quitar', delete: 'Eliminar', clear: 'Limpiar',
cancel: 'Cancelar', confirm: 'Confirmar', none: 'Ninguno', enabled: 'Activado', disabled: 'Desactivado',
fixed: 'Fijo', random: 'Aleatorio', curve: 'Curva', followExport: 'Seguir exportación',
linear: 'Lineal', bezier: 'Bézier', above: 'Arriba', below: 'Abajo', left: 'Izquierda', right: 'Derecha',
seconds: 's', frames: 'fotogramas',
},
toolbar: {
settings: 'Ajustes', undo: 'Deshacer (Ctrl+Z)', pan: 'Mover lienzo (Q)', translate: 'Mover (W)',
rotate: 'Girar (E)', scale: 'Escalar (R)', zoomIn: 'Acercar', zoomOut: 'Alejar', fitView: 'Ajustar al lienzo',
toggleGrid: 'Mostrar/ocultar cuadrícula', resetView: 'Restablecer vista', background: 'Fondo', backgroundSettings: 'Ajustes del fondo',
changeBackground: 'Cambiar imagen', uploadBackground: 'Subir imagen', clearBackground: 'Borrar fondo',
noBackground: 'Sin imagen', opacity: 'Opacidad', help: 'Ayuda', openHelp: 'Abrir ayuda',
},
config: { title: 'Gestión de configuración', save: 'Guardar configuración', load: 'Cargar configuración', selectPreset: 'Seleccionar preajuste', noPresets: 'Sin preajustes' },
image: { fixed: 'Imagen única', sequence: 'Secuencia', onceHide: 'Una vez, ocultar al final', onceHold: 'Una vez, mantener último fotograma', loopForward: 'Bucle hacia delante', loopReverse: 'Bucle inverso', weight: 'Peso %', pivot: 'Pivote de textura' },
emission: {
title: 'Modo de emisión', stream: 'Continuo', burst: 'Ráfaga', normalStream: 'Continuo normal', loopStream: 'Bucle continuo',
rate: 'Tasa de emisión', duration: 'Duración de emisión', burstCount: 'Cantidad por ráfaga', delay: 'Retardo',
shapeTitle: 'Forma del emisor', point: 'Punto', circle: 'Círculo', rect: 'Rectángulo', cone: 'Cono', path: 'Ruta',
},
attributes: { title: 'Atributos de partículas', initialParams: 'Valores iniciales', lifecycleTitle: 'Cambios durante la vida útil' },
appearance: { title: 'Apariencia y recursos', opacity: 'Opacidad', renderLayer: 'Capa de renderizado (slot de Spine)', imageResources: 'Recursos de imagen', independentAlpha: 'Opacidad independiente', off: 'Desactivado', normal: 'Normal', add: 'Suma', multiply: 'Multiplicación', screen: 'Pantalla' },
scene: { title: 'Objetos de escena', particle: 'Partículas', subEmitter: 'Subemisor', collision: 'Colisión', path: 'Ruta', positionX: 'Posición X', positionY: 'Posición Y', offsetX: 'Desplazamiento X', offsetY: 'Desplazamiento Y', rotation: 'Rotación', scaleX: 'Escala X', scaleY: 'Escala Y', xPosition: 'Posición X', yPosition: 'Posición Y', scaleXShort: 'Escala X', scaleYShort: 'Escala Y', seed: 'Semilla aleatoria' },
modifiers: {
title: 'Modificadores', damping: 'Amortiguación', enableNoise: 'Activar ruido de movimiento', enableGravity: 'Activar gravedad', enableWind: 'Activar viento',
enableForceField: 'Activar campo de fuerza', enableVortex: 'Activar vórtice', enableAttraction: 'Activar atracción', enableTrail: 'Activar estela',
enableFollow: 'Activar seguimiento del emisor', enableCollision: 'Activar colisión', enableSubEmitter: 'Activar subemisores',
},
timeline: {
continuousLoop: 'Reproducción en bucle continuo', abLoop: 'Bucle independiente del rango A/B', abLoopShort: 'Bucle AB',
emission: 'Emisión', particleLife: 'Partículas activas', spineAnimation: 'Animación de Spine', ownerAnimation: 'Animación asociada', frameStatus: 'Fotograma {current} / {total}',
},
export: { title: 'Ajustes de exportación', spineVersion: 'Versión de Spine', fps: 'FPS de exportación', exportJson: 'Exportar Spine JSON', exportImages: 'Exportar imágenes ZIP', connectorReady: 'Conector disponible · Spine listo', connectorOffline: 'El conector local de Spine no está iniciado', spineNotFound: 'El conector está iniciado, pero no se encontró Spine', notAuthorized: 'El conector está iniciado, pero este sitio no está autorizado', checking: 'Comprobando', offline: 'Sin conexión', notFound: 'Spine no encontrado', unauthorized: 'No autorizado', connectorBusy: 'Recalculando y generando el proyecto de Spine', connectorGenerate: 'Generar proyecto Spine {version} con Spine local' },
spine: { title: 'Propiedades de Spine', resources: 'Recursos de Spine', animations: 'Animaciones de Spine', hierarchy: 'Jerarquía', xPosition: 'Posición X', yPosition: 'Posición Y', rotation: 'Rotación', startTime: 'Tiempo inicial', scaleX: 'Escala X', scaleY: 'Escala Y' },
help: { title: 'Ayuda', commonOps: 'Acciones comunes', shortcuts: 'Atajos', modules: 'Guía de módulos' },
presets: { particleSweep: '1.Barrido de partículas', barBlocks: '2.Bloques rectangulares', forceTrail: '3.Estela de campo de fuerza', lightningSequence: '4.Secuencia de rayos', texturePivot: '5.Pivote de textura', subEmitter: '6.Subemisor' },
},
}
function merge(target, source) {
for (const [key, value] of Object.entries(source)) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
target[key] ||= {}
merge(target[key], value)
} else {
target[key] = value
}
}
}
for (const [locale, values] of Object.entries(overrides)) {
const path = `src/i18n/locales/${locale}.json`
const messages = JSON.parse(await readFile(path, 'utf8'))
merge(messages, values)
await writeFile(path, `${JSON.stringify(messages, null, 2)}\n`)
}
console.log('Applied terminology overrides for all added locales.')
+136
View File
@@ -0,0 +1,136 @@
import { readFile, writeFile } from 'node:fs/promises'
const source = JSON.parse(await readFile('src/i18n/locales/zh-CN.json', 'utf8'))
const localeConfigs = {
'zh-TW': { title: 'Spine粒子編輯器-Yeomai' },
ja: { title: 'Spineパーティクルエディター-Yeomai' },
ko: { title: 'Spine 파티클 에디터-Yeomai' },
fr: { title: 'Spine Particle Editor-Yeomai' },
de: { title: 'Spine Particle Editor-Yeomai' },
'pt-BR': { title: 'Spine Particle Editor-Yeomai' },
es: { title: 'Spine Particle Editor-Yeomai' },
}
const nativeLanguageLabels = {
zhCN: '中文',
zhTW: '繁體中文',
en: 'English',
ja: '日本語',
ko: '한국어',
fr: 'Français',
de: 'Deutsch',
ptBR: 'Português',
es: 'Español',
}
const protectedTerms = [
'SpineParticle', 'Spine', 'Yeomai', 'JSON', 'FPS', 'ZIP', 'PNG', 'JPEG',
'WebP', 'WebGL', 'Bézier', 'Bezier', 'Mesh', 'Atlas', 'Ctrl', 'macOS',
'Windows', 'Universal', 'A/B', 'AB',
]
function flatten(value, path = '', result = []) {
for (const [key, child] of Object.entries(value)) {
const nextPath = path ? `${path}.${key}` : key
if (child && typeof child === 'object' && !Array.isArray(child)) flatten(child, nextPath, result)
else result.push({ path: nextPath, value: child })
}
return result
}
function setPath(target, path, value) {
const parts = path.split('.')
let current = target
for (let index = 0; index < parts.length - 1; index++) {
current = current[parts[index]] ||= {}
}
current[parts.at(-1)] = value
}
function protectText(text) {
const values = []
const escapedTerms = protectedTerms
.sort((a, b) => b.length - a.length)
.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
const pattern = new RegExp(`\\{[a-zA-Z0-9_]+\\}|\\b(?:${escapedTerms.join('|')})\\b`, 'g')
const protectedText = String(text).replace(pattern, (match) => {
const token = `ZXQV${values.length}QXZ`
values.push(match)
return token
})
return { protectedText, values }
}
function restoreText(text, values) {
return String(text).replace(/ZXQV(\d+)QXZ/g, (_match, index) => values[Number(index)] ?? '')
}
async function translateBatch(texts, target) {
const params = new URLSearchParams({
client: 'dict-chrome-ex',
sl: 'zh-CN',
tl: target,
})
for (const text of texts) params.append('q', text)
const response = await fetch(`https://clients5.google.com/translate_a/t?${params}`, {
signal: AbortSignal.timeout(30000),
})
if (!response.ok) throw new Error(`Translation HTTP ${response.status}`)
const body = await response.json()
if (!Array.isArray(body)) throw new Error('Unexpected translation response')
if (body.length !== texts.length) {
throw new Error(`Translation count mismatch: expected ${texts.length}, got ${body.length}`)
}
return body.map((value) => typeof value === 'string' ? value : JSON.stringify(value))
}
async function translateLocale(code, config) {
const entries = flatten(source).filter(({ path }) => !path.startsWith('language.') && path !== 'app.title')
const translated = {}
const batches = []
let batch = []
let encodedLength = 0
for (const entry of entries) {
const protectedEntry = protectText(entry.value)
const item = { ...entry, ...protectedEntry }
const itemLength = encodeURIComponent(item.protectedText).length + 3
if (batch.length && encodedLength + itemLength > 7000) {
batches.push(batch)
batch = []
encodedLength = 0
}
batch.push(item)
encodedLength += itemLength
}
if (batch.length) batches.push(batch)
for (let index = 0; index < batches.length; index++) {
const current = batches[index]
let values
for (let attempt = 0; attempt < 4; attempt++) {
try {
values = await translateBatch(current.map((entry) => entry.protectedText), code)
break
} catch (error) {
if (attempt === 3) throw error
await new Promise((resolve) => setTimeout(resolve, 1800 * (attempt + 1)))
}
}
current.forEach((entry, itemIndex) => {
setPath(translated, entry.path, restoreText(values[itemIndex], entry.values))
})
process.stdout.write(`${code}: ${index + 1}/${batches.length}\n`)
await new Promise((resolve) => setTimeout(resolve, 500))
}
translated.app.title = config.title
translated.language = nativeLanguageLabels
await writeFile(`src/i18n/locales/${code}.json`, `${JSON.stringify(translated, null, 2)}\n`)
}
for (const [code, config] of Object.entries(localeConfigs)) {
await translateLocale(code, config)
}
console.log('Generated locale drafts. Review and override key terminology before release.')
+53 -14
View File
@@ -1,8 +1,12 @@
import { readFile } from 'node:fs/promises'
import { build } from 'esbuild'
const zh = JSON.parse(await readFile('src/i18n/locales/zh-CN.json', 'utf8'))
const en = JSON.parse(await readFile('src/i18n/locales/en.json', 'utf8'))
const localeCodes = ['zh-CN', 'zh-TW', 'en', 'ja', 'ko', 'fr', 'de', 'pt-BR', 'es']
const localeMessages = Object.fromEntries(await Promise.all(localeCodes.map(async (code) => [
code,
JSON.parse(await readFile(`src/i18n/locales/${code}.json`, 'utf8')),
])))
const zh = localeMessages['zh-CN']
function flattenKeys(value, prefix = '') {
const keys = []
@@ -18,14 +22,41 @@ function flattenKeys(value, prefix = '') {
}
const zhKeys = flattenKeys(zh)
const enKeys = flattenKeys(en)
const missingInEnglish = zhKeys.filter((key) => !enKeys.includes(key))
const missingInChinese = enKeys.filter((key) => !zhKeys.includes(key))
if (missingInEnglish.length || missingInChinese.length) {
throw new Error([
missingInEnglish.length ? `Missing in English: ${missingInEnglish.join(', ')}` : '',
missingInChinese.length ? `Missing in Chinese: ${missingInChinese.join(', ')}` : '',
].filter(Boolean).join('\n'))
function getPath(value, path) {
return path.split('.').reduce((current, key) => current?.[key], value)
}
function placeholders(value) {
return [...String(value).matchAll(/\{[a-zA-Z0-9_]+\}/g)].map((match) => match[0]).sort()
}
for (const code of localeCodes.slice(1)) {
const messages = localeMessages[code]
const keys = flattenKeys(messages)
const missing = zhKeys.filter((key) => !keys.includes(key))
const extra = keys.filter((key) => !zhKeys.includes(key))
if (missing.length || extra.length) {
throw new Error([
missing.length ? `${code} missing: ${missing.join(', ')}` : '',
extra.length ? `${code} extra: ${extra.join(', ')}` : '',
].filter(Boolean).join('\n'))
}
for (const key of zhKeys) {
if (!String(getPath(messages, key) ?? '').trim()) {
throw new Error(`${code}.${key} is empty`)
}
const expected = placeholders(getPath(zh, key))
const actual = placeholders(getPath(messages, key))
if (JSON.stringify(expected) !== JSON.stringify(actual)) {
throw new Error(`${code}.${key} placeholders differ: ${expected.join(',')} != ${actual.join(',')}`)
}
}
if (JSON.stringify(messages).includes('ZXQV') || /\uE000|\uE001|\uE002/.test(JSON.stringify(messages))) {
throw new Error(`${code} contains unresolved translation tokens`)
}
if (!String(messages.app?.title || '').includes('Yeomai')) {
throw new Error(`${code} title must keep the Yeomai brand`)
}
}
const bundled = await build({
@@ -42,12 +73,20 @@ const i18nModule = await import(
const cases = [
[null, ['zh-CN'], 'zh-CN'],
[null, ['zh-Hant-TW'], 'zh-CN'],
[null, ['zh-Hant-TW'], 'zh-TW'],
[null, ['zh-HK'], 'zh-TW'],
[null, ['en-US'], 'en'],
[null, ['fr-FR'], 'en'],
[null, ['ko-KR', 'en-US'], 'en'],
[null, ['ja-JP'], 'ja'],
[null, ['ko-KR'], 'ko'],
[null, ['fr-FR'], 'fr'],
[null, ['de-DE'], 'de'],
[null, ['pt-PT'], 'pt-BR'],
[null, ['es-MX'], 'es'],
[null, ['it-IT'], 'en'],
[null, ['it-IT', 'de-DE'], 'de'],
['en', ['zh-CN'], 'en'],
['zh-CN', ['en-US'], 'zh-CN'],
['ja', ['en-US'], 'ja'],
]
for (const [saved, browserLanguages, expected] of cases) {
const actual = i18nModule.resolveAppLocale(saved, browserLanguages)
@@ -56,4 +95,4 @@ for (const [saved, browserLanguages, expected] of cases) {
}
}
console.log(`i18n 校验通过:${zhKeys.length} 个消息键,中英语言包一致,自动语言与缓存优先级正确`)
console.log(`i18n 校验通过:${localeCodes.length} 种语言、${zhKeys.length} 个消息键,键与占位符一致,自动语言与缓存优先级正确`)