feat: 移植 Axolotl 皮肤盔甲预览(Armor Preview)
Sync source to CNB / Sync Git ref (push) Canceled after 0s

从 Axolotl 1.9.7 移植皮肤编辑器的盔甲/盔甲纹饰预览功能,
让玩家在皮肤选择页可试穿盔甲并调整纹饰。

新增:
- 前端组件 ArmorTrimTab / SkinListSkeleton 及 skin-armor-preview helper
- packages/ui 组件 ArmorPreviewControls / ArmorTrimPanel
- packages/ui armor 预览 composable(armor-preview-assets/types、use-armor-preview)
- packages/ui webgl 盔甲渲染(armor-geometry / armor-preview-object / armor-rendering)
- packages/ui/src/assets/minecraft-armor 盔甲素材

改动:
- SkinPreviewRenderer 增加 armorPreview/armorControls props、
  armorConfig model 与 useArmorPreview 集成
- Skins.vue 增加盔甲 tab、状态逻辑与模板接入
- 补齐 skin/composables/utils 的导出

适配说明:ArmorPreviewControls 原版依赖 reka-ui 的 popover,
Starlight 无该依赖且只用 external 模式,故改写为 external-only,
去除 reka-ui 与 overlays.css 依赖。
This commit is contained in:
AxTps committed 2026-10-07 20:01:19 +08:00
1 parent 20d5d2baab
commit ae2992d7f8
151 files changed
+1936 -32

No files matched your search

@@ -0,0 +1,105 @@
import type {
ArmorMaterial,
ArmorSlot,
ArmorTrimMaterial,
ArmorTrimPattern,
} from './armor-preview-types'
const assetModules = import.meta.glob<string>('../../assets/minecraft-armor/**/*.png', {
eager: true,
query: '?url',
import: 'default',
})
function asset(path: string): string {
const url = assetModules[`../../assets/minecraft-armor/${path}.png`]
if (!url) throw new Error(`Missing Minecraft armor preview asset: ${path}`)
return url
}
const armorTextureName: Record<ArmorMaterial, string> = {
leather: 'leather',
chainmail: 'chainmail',
copper: 'copper',
gold: 'gold',
iron: 'iron',
diamond: 'diamond',
netherite: 'netherite',
turtle: 'turtle_scute',
}
const armorItemName: Record<ArmorMaterial, string> = {
leather: 'leather_default',
chainmail: 'chainmail',
copper: 'copper',
gold: 'golden',
iron: 'iron',
diamond: 'diamond',
netherite: 'netherite',
turtle: 'turtle',
}
const trimIngredientName: Record<ArmorTrimMaterial, string> = {
amethyst: 'amethyst_shard',
copper: 'copper_ingot',
diamond: 'diamond',
emerald: 'emerald',
gold: 'gold_ingot',
iron: 'iron_ingot',
lapis: 'lapis_lazuli',
netherite: 'netherite_ingot',
quartz: 'quartz',
redstone: 'redstone',
resin: 'resin_brick',
}
const darkerTrimMaterials = new Set<ArmorTrimMaterial>([
'copper',
'diamond',
'gold',
'iron',
'netherite',
])
export type ArmorTextureLayer = 'humanoid' | 'leggings'
export function getArmorTextureUrl(material: ArmorMaterial, layer: ArmorTextureLayer): string {
return asset(`entity/${layer}/${armorTextureName[material]}`)
}
export function getLeatherOverlayUrl(layer: ArmorTextureLayer): string {
return asset(`entity/${layer}/leather_overlay`)
}
export function getArmorItemIcon(material: ArmorMaterial, slot: ArmorSlot): string {
if (material === 'turtle') return asset('item/turtle_helmet')
return asset(`item/${armorItemName[material]}_${slot}`)
}
export function getArmorSlotIcon(slot: ArmorSlot): string {
return asset(`slot/${slot}`)
}
export function getTrimPatternIcon(pattern: ArmorTrimPattern): string {
return asset(`item/${pattern}_armor_trim_smithing_template`)
}
export function getTrimMaterialIcon(material: ArmorTrimMaterial): string {
return asset(`item/${trimIngredientName[material]}`)
}
export function getTrimPatternTextureUrl(
pattern: ArmorTrimPattern,
layer: ArmorTextureLayer,
): string {
return asset(`trim/${layer}/${pattern}`)
}
export function getTrimPaletteUrl(material: ArmorTrimMaterial, darker: boolean): string {
const suffix = darker && darkerTrimMaterials.has(material) ? '_darker' : ''
return asset(`trim/palettes/${material}${suffix}`)
}
export function getTrimSourcePaletteUrl(): string {
return asset('trim/palettes/trim_palette')
}
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { ref } from 'vue'
import {
ARMOR_SLOTS,
ARMOR_TRIM_MATERIALS,
ARMOR_TRIM_PATTERNS,
armorMaterialsForSlot,
cloneArmorPreviewConfig,
createDefaultArmorPreviewConfig,
} from './armor-preview-types.ts'
test('armor preview exposes every supported slot, trim pattern, and trim material', () => {
assert.deepEqual(ARMOR_SLOTS, ['helmet', 'chestplate', 'leggings', 'boots'])
assert.equal(ARMOR_TRIM_PATTERNS.length, 18)
assert.equal(ARMOR_TRIM_MATERIALS.length, 11)
})
test('armor slots start unequipped and keep independent selections', () => {
const config = createDefaultArmorPreviewConfig()
for (const slot of ARMOR_SLOTS) {
assert.equal(config[slot].material, null)
assert.equal(config[slot].trimPattern, null)
assert.equal(config[slot].trimMaterial, 'iron')
}
config.helmet.material = 'diamond'
assert.equal(config.chestplate.material, null)
})
test('turtle armor is only available for the helmet slot', () => {
assert.ok(armorMaterialsForSlot('helmet').includes('turtle'))
assert.ok(!armorMaterialsForSlot('chestplate').includes('turtle'))
assert.ok(!armorMaterialsForSlot('leggings').includes('turtle'))
assert.ok(!armorMaterialsForSlot('boots').includes('turtle'))
})
test('clones a config that lives behind a reactive ref, deeply', () => {
const config = createDefaultArmorPreviewConfig()
config.helmet.material = 'iron'
config.helmet.trimPattern = 'bolt'
// The value a caller holds has been through `ref()`, which `structuredClone`
// refuses to copy at all.
const clone = cloneArmorPreviewConfig(ref(config).value)
assert.deepEqual(clone, config)
assert.notEqual(clone, config)
assert.notEqual(clone.helmet, config.helmet)
clone.helmet.material = null
assert.equal(config.helmet.material, 'iron')
})
@@ -0,0 +1,99 @@
export const ARMOR_SLOTS = ['helmet', 'chestplate', 'leggings', 'boots'] as const
export type ArmorSlot = (typeof ARMOR_SLOTS)[number]
export const ARMOR_MATERIALS = [
'leather',
'chainmail',
'copper',
'gold',
'iron',
'diamond',
'netherite',
'turtle',
] as const
export type ArmorMaterial = (typeof ARMOR_MATERIALS)[number]
export const ARMOR_TRIM_PATTERNS = [
'bolt',
'coast',
'dune',
'eye',
'flow',
'host',
'raiser',
'rib',
'sentry',
'shaper',
'silence',
'snout',
'spire',
'tide',
'vex',
'ward',
'wayfinder',
'wild',
] as const
export type ArmorTrimPattern = (typeof ARMOR_TRIM_PATTERNS)[number]
export const ARMOR_TRIM_MATERIALS = [
'amethyst',
'copper',
'diamond',
'emerald',
'gold',
'iron',
'lapis',
'netherite',
'quartz',
'redstone',
'resin',
] as const
export type ArmorTrimMaterial = (typeof ARMOR_TRIM_MATERIALS)[number]
export interface ArmorPiecePreview {
material: ArmorMaterial | null
trimPattern: ArmorTrimPattern | null
trimMaterial: ArmorTrimMaterial
}
export type ArmorPreviewConfig = Record<ArmorSlot, ArmorPiecePreview>
const DEFAULT_TRIM_MATERIAL: ArmorTrimMaterial = 'iron'
export function createDefaultArmorPreviewConfig(): ArmorPreviewConfig {
return Object.fromEntries(
ARMOR_SLOTS.map((slot) => [
slot,
{
material: null,
trimPattern: null,
trimMaterial: DEFAULT_TRIM_MATERIAL,
},
]),
) as ArmorPreviewConfig
}
/**
* Copies a config piece by piece. `structuredClone` would read better, but a
* config that has been through `ref()` or `reactive()` is a proxy and it refuses
* to clone one, which is exactly the value a caller holds.
*/
export function cloneArmorPreviewConfig(config: ArmorPreviewConfig): ArmorPreviewConfig {
return Object.fromEntries(
ARMOR_SLOTS.map((slot) => {
const piece = config[slot]
return [
slot,
{
material: piece?.material ?? null,
trimPattern: piece?.trimPattern ?? null,
trimMaterial: piece?.trimMaterial ?? DEFAULT_TRIM_MATERIAL,
},
]
}),
) as ArmorPreviewConfig
}
export function armorMaterialsForSlot(slot: ArmorSlot): readonly ArmorMaterial[] {
return slot === 'helmet' ? ARMOR_MATERIALS : ARMOR_MATERIALS.filter((item) => item !== 'turtle')
}
@@ -0,0 +1,55 @@
import type * as THREE from 'three'
import { onUnmounted, type Ref, watch } from 'vue'
import {
applyPreparedArmorPreview,
prepareArmorPreview,
removeArmorPreview,
} from '../../utils/webgl/armor-rendering'
import { ARMOR_SLOTS, type ArmorPreviewConfig } from './armor-preview-types'
function cloneArmorConfig(config: ArmorPreviewConfig): ArmorPreviewConfig {
return Object.fromEntries(
ARMOR_SLOTS.map((slot) => [slot, { ...config[slot] }]),
) as ArmorPreviewConfig
}
export function useArmorPreview({
scene,
config,
enabled,
}: {
scene: Ref<THREE.Object3D | null>
config: Ref<ArmorPreviewConfig>
enabled: Ref<boolean>
}) {
let applyVersion = 0
watch(
[scene, enabled, () => JSON.stringify(config.value)],
async ([root, isEnabled]) => {
const version = ++applyVersion
if (!root) return
if (!isEnabled) {
removeArmorPreview(root)
return
}
const snapshot = cloneArmorConfig(config.value)
try {
const prepared = await prepareArmorPreview(snapshot)
if (version !== applyVersion || scene.value !== root) return
applyPreparedArmorPreview(root, snapshot, prepared)
} catch (error) {
console.error('Failed to update armor preview:', error)
}
},
{ immediate: true },
)
onUnmounted(() => {
applyVersion++
if (scene.value) removeArmorPreview(scene.value)
})
}