从 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:
1 parent
20d5d2baab
commit
ae2992d7f8
151 files changed
+1936
-32
No files matched your search
@@ -10,6 +10,15 @@ export * from './i18n'
|
||||
export * from './i18n-debug'
|
||||
export * from './page-leave-safety'
|
||||
export * from './scroll-indicator'
|
||||
export type { ArmorPreviewConfig } from './skin-rendering/armor-preview-types'
|
||||
export {
|
||||
ARMOR_SLOTS,
|
||||
ARMOR_TRIM_MATERIALS,
|
||||
ARMOR_TRIM_PATTERNS,
|
||||
armorMaterialsForSlot,
|
||||
cloneArmorPreviewConfig,
|
||||
createDefaultArmorPreviewConfig,
|
||||
} from './skin-rendering/armor-preview-types'
|
||||
export * from './sticky-observer'
|
||||
export * from './terminal'
|
||||
export * from './use-batch-drop'
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user