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,60 @@
<script setup lang="ts">
import { RightArrowIcon, ShieldIcon } from '@modrinth/assets'
import { computed } from 'vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import type { ArmorPreviewConfig } from '#ui/composables/skin-rendering'
const messages = defineMessages({
armorPreview: { id: 'skin.preview.armor.open', defaultMessage: 'Armor and trims' },
})
const { open = false, panelId } = defineProps<{
/** Whether the panel this button toggles is currently open. */
open?: boolean
/** id of the panel this toggle controls, wired to `aria-controls`. */
panelId?: string
}>()
const emit = defineEmits<{
(e: 'open' | 'close'): void
}>()
// Kept for API compatibility with the upstream component; the model is owned by
// the panel (ArmorTrimTab) rather than this toggle.
defineModel<ArmorPreviewConfig>({ required: false })
const { formatMessage } = useVIntl()
/**
* The shield flips about its centre axis into a right arrow while the panel is
* open, so the same button reads as "back to the list" and closes it.
*/
const isFlipped = computed(() => open)
</script>
<template>
<div class="pointer-events-auto">
<button
class="flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 shadow-md transition-[filter,transform] duration-200 hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness] active:scale-95 [&>svg]:size-5 [&>svg]:shrink-0"
:aria-label="formatMessage(messages.armorPreview)"
:aria-expanded="open"
:aria-controls="panelId"
@click="open ? emit('close') : emit('open')"
>
<span class="relative size-5 shrink-0">
<ShieldIcon
aria-hidden="true"
class="absolute inset-0 size-5 transition-[transform,opacity] duration-200 ease-out"
:class="isFlipped ? 'scale-x-0 opacity-0' : 'scale-x-100'"
/>
<RightArrowIcon
aria-hidden="true"
class="absolute inset-0 size-5 transition-[transform,opacity] duration-200 ease-out"
:class="isFlipped ? 'scale-x-100' : 'scale-x-0 opacity-0'"
/>
</span>
<span>{{ formatMessage(messages.armorPreview) }}</span>
</button>
</div>
</template>
@@ -0,0 +1,334 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import {
ARMOR_SLOTS,
ARMOR_TRIM_MATERIALS,
ARMOR_TRIM_PATTERNS,
type ArmorMaterial,
armorMaterialsForSlot,
type ArmorPreviewConfig,
type ArmorSlot,
type ArmorTrimMaterial,
type ArmorTrimPattern,
} from '#ui/composables/skin-rendering'
import {
getArmorItemIcon,
getArmorSlotIcon,
getTrimMaterialIcon,
getTrimPatternIcon,
} from '#ui/composables/skin-rendering/armor-preview-assets'
const messages = defineMessages({
armorPiece: { id: 'skin.preview.armor.piece', defaultMessage: 'Armor piece' },
armorMaterial: { id: 'skin.preview.armor.material', defaultMessage: 'Armor material' },
trimPattern: { id: 'skin.preview.armor.trim-pattern', defaultMessage: 'Trim pattern' },
trimMaterial: { id: 'skin.preview.armor.trim-material', defaultMessage: 'Trim material' },
unequipped: { id: 'skin.preview.armor.unequipped', defaultMessage: 'Unequipped' },
removePiece: {
id: 'skin.preview.armor.remove-piece',
defaultMessage: 'Remove {slot}',
},
removeTrim: { id: 'skin.preview.armor.remove-trim', defaultMessage: 'No armor trim' },
slotState: {
id: 'skin.preview.armor.slot-state',
defaultMessage: '{slot}: {state}',
},
equipMaterial: {
id: 'skin.preview.armor.equip-material',
defaultMessage: '{material} {slot}',
},
helmet: { id: 'skin.preview.armor.slot.helmet', defaultMessage: 'Helmet' },
chestplate: { id: 'skin.preview.armor.slot.chestplate', defaultMessage: 'Chestplate' },
leggings: { id: 'skin.preview.armor.slot.leggings', defaultMessage: 'Leggings' },
boots: { id: 'skin.preview.armor.slot.boots', defaultMessage: 'Boots' },
leather: { id: 'skin.preview.armor.material.leather', defaultMessage: 'Leather' },
chainmail: { id: 'skin.preview.armor.material.chainmail', defaultMessage: 'Chainmail' },
copper: { id: 'skin.preview.armor.material.copper', defaultMessage: 'Copper' },
gold: { id: 'skin.preview.armor.material.gold', defaultMessage: 'Gold' },
iron: { id: 'skin.preview.armor.material.iron', defaultMessage: 'Iron' },
diamond: { id: 'skin.preview.armor.material.diamond', defaultMessage: 'Diamond' },
netherite: { id: 'skin.preview.armor.material.netherite', defaultMessage: 'Netherite' },
turtle: { id: 'skin.preview.armor.material.turtle', defaultMessage: 'Turtle Shell' },
bolt: { id: 'skin.preview.armor.trim.bolt', defaultMessage: 'Bolt Armor Trim' },
coast: { id: 'skin.preview.armor.trim.coast', defaultMessage: 'Coast Armor Trim' },
dune: { id: 'skin.preview.armor.trim.dune', defaultMessage: 'Dune Armor Trim' },
eye: { id: 'skin.preview.armor.trim.eye', defaultMessage: 'Eye Armor Trim' },
flow: { id: 'skin.preview.armor.trim.flow', defaultMessage: 'Flow Armor Trim' },
host: { id: 'skin.preview.armor.trim.host', defaultMessage: 'Host Armor Trim' },
raiser: { id: 'skin.preview.armor.trim.raiser', defaultMessage: 'Raiser Armor Trim' },
rib: { id: 'skin.preview.armor.trim.rib', defaultMessage: 'Rib Armor Trim' },
sentry: { id: 'skin.preview.armor.trim.sentry', defaultMessage: 'Sentry Armor Trim' },
shaper: { id: 'skin.preview.armor.trim.shaper', defaultMessage: 'Shaper Armor Trim' },
silence: { id: 'skin.preview.armor.trim.silence', defaultMessage: 'Silence Armor Trim' },
snout: { id: 'skin.preview.armor.trim.snout', defaultMessage: 'Snout Armor Trim' },
spire: { id: 'skin.preview.armor.trim.spire', defaultMessage: 'Spire Armor Trim' },
tide: { id: 'skin.preview.armor.trim.tide', defaultMessage: 'Tide Armor Trim' },
vex: { id: 'skin.preview.armor.trim.vex', defaultMessage: 'Vex Armor Trim' },
ward: { id: 'skin.preview.armor.trim.ward', defaultMessage: 'Ward Armor Trim' },
wayfinder: { id: 'skin.preview.armor.trim.wayfinder', defaultMessage: 'Wayfinder Armor Trim' },
wild: { id: 'skin.preview.armor.trim.wild', defaultMessage: 'Wild Armor Trim' },
amethyst: { id: 'skin.preview.armor.trim-material.amethyst', defaultMessage: 'Amethyst' },
emerald: { id: 'skin.preview.armor.trim-material.emerald', defaultMessage: 'Emerald' },
lapis: { id: 'skin.preview.armor.trim-material.lapis', defaultMessage: 'Lapis Lazuli' },
quartz: { id: 'skin.preview.armor.trim-material.quartz', defaultMessage: 'Quartz' },
redstone: { id: 'skin.preview.armor.trim-material.redstone', defaultMessage: 'Redstone' },
resin: { id: 'skin.preview.armor.trim-material.resin', defaultMessage: 'Resin Brick' },
})
const model = defineModel<ArmorPreviewConfig>({ required: true })
const { formatMessage } = useVIntl()
const selectedSlot = ref<ArmorSlot>('helmet')
const selectedPiece = computed(() => model.value[selectedSlot.value])
function slotLabel(slot: ArmorSlot): string {
return formatMessage(messages[slot])
}
function materialLabel(material: ArmorMaterial | ArmorTrimMaterial): string {
return formatMessage(messages[material])
}
function patternLabel(pattern: ArmorTrimPattern): string {
return formatMessage(messages[pattern])
}
function slotTooltip(slot: ArmorSlot): string {
const material = model.value[slot].material
return formatMessage(messages.slotState, {
slot: slotLabel(slot),
state: material ? materialLabel(material) : formatMessage(messages.unequipped),
})
}
function materialTooltip(material: ArmorMaterial): string {
return formatMessage(messages.equipMaterial, {
material: materialLabel(material),
slot: slotLabel(selectedSlot.value),
})
}
function updateSelectedPiece(patch: Partial<ArmorPreviewConfig[ArmorSlot]>): void {
model.value = {
...model.value,
[selectedSlot.value]: {
...model.value[selectedSlot.value],
...patch,
},
}
}
function setMaterial(material: ArmorMaterial | null): void {
updateSelectedPiece({ material })
}
function setTrimPattern(trimPattern: ArmorTrimPattern | null): void {
updateSelectedPiece({ trimPattern })
}
function setTrimMaterial(trimMaterial: ArmorTrimMaterial): void {
updateSelectedPiece({ trimMaterial })
}
</script>
<template>
<div class="armor-preview-panel">
<section class="armor-preview-section">
<h3>{{ formatMessage(messages.armorPiece) }}</h3>
<div class="armor-preview-options armor-preview-options--four">
<button
v-for="slot in ARMOR_SLOTS"
:key="slot"
v-tooltip="slotTooltip(slot)"
class="armor-preview-option"
:class="{ 'armor-preview-option--selected': selectedSlot === slot }"
:aria-label="slotTooltip(slot)"
:aria-pressed="selectedSlot === slot"
@click="selectedSlot = slot"
>
<img
alt=""
:src="
model[slot].material
? getArmorItemIcon(model[slot].material, slot)
: getArmorSlotIcon(slot)
"
/>
</button>
</div>
</section>
<section class="armor-preview-section">
<h3>{{ formatMessage(messages.armorMaterial) }}</h3>
<div class="armor-preview-options">
<button
v-tooltip="formatMessage(messages.removePiece, { slot: slotLabel(selectedSlot) })"
class="armor-preview-option"
:class="{ 'armor-preview-option--selected': !selectedPiece.material }"
:aria-label="formatMessage(messages.removePiece, { slot: slotLabel(selectedSlot) })"
:aria-pressed="!selectedPiece.material"
@click="setMaterial(null)"
>
<img alt="" :src="getArmorSlotIcon(selectedSlot)" />
</button>
<button
v-for="material in armorMaterialsForSlot(selectedSlot)"
:key="material"
v-tooltip="materialTooltip(material)"
class="armor-preview-option"
:class="{ 'armor-preview-option--selected': selectedPiece.material === material }"
:aria-label="materialTooltip(material)"
:aria-pressed="selectedPiece.material === material"
@click="setMaterial(material)"
>
<img alt="" :src="getArmorItemIcon(material, selectedSlot)" />
</button>
</div>
</section>
<section v-if="selectedPiece.material" class="armor-preview-section">
<h3>{{ formatMessage(messages.trimPattern) }}</h3>
<div class="armor-preview-options">
<button
v-tooltip="formatMessage(messages.removeTrim)"
class="armor-preview-option"
:class="{ 'armor-preview-option--selected': !selectedPiece.trimPattern }"
:aria-label="formatMessage(messages.removeTrim)"
:aria-pressed="!selectedPiece.trimPattern"
@click="setTrimPattern(null)"
>
<img alt="" :src="getArmorSlotIcon(selectedSlot)" />
</button>
<button
v-for="pattern in ARMOR_TRIM_PATTERNS"
:key="pattern"
v-tooltip="patternLabel(pattern)"
class="armor-preview-option"
:class="{
'armor-preview-option--selected': selectedPiece.trimPattern === pattern,
}"
:aria-label="patternLabel(pattern)"
:aria-pressed="selectedPiece.trimPattern === pattern"
@click="setTrimPattern(pattern)"
>
<img alt="" :src="getTrimPatternIcon(pattern)" />
</button>
</div>
</section>
<section
v-if="selectedPiece.material && selectedPiece.trimPattern"
class="armor-preview-section"
>
<h3>{{ formatMessage(messages.trimMaterial) }}</h3>
<div class="armor-preview-options">
<button
v-for="material in ARMOR_TRIM_MATERIALS"
:key="material"
v-tooltip="materialLabel(material)"
class="armor-preview-option"
:class="{
'armor-preview-option--selected': selectedPiece.trimMaterial === material,
}"
:aria-label="materialLabel(material)"
:aria-pressed="selectedPiece.trimMaterial === material"
@click="setTrimMaterial(material)"
>
<img alt="" :src="getTrimMaterialIcon(material)" />
</button>
</div>
</section>
</div>
</template>
<style scoped lang="scss">
.armor-preview-option {
box-sizing: border-box;
height: 2.5rem;
width: 2.5rem;
display: grid;
place-items: center;
padding: 0.375rem;
cursor: pointer;
color: var(--color-text-default);
background: var(--surface-3);
border: 1px solid var(--surface-5);
border-radius: 6px;
box-shadow: 0 2px 5px rgb(0 0 0 / 20%);
transition:
background-color 120ms ease,
border-color 120ms ease,
transform 120ms ease;
}
.armor-preview-option:hover {
background: var(--surface-4);
border-color: var(--color-brand);
}
.armor-preview-option:active {
transform: scale(0.94);
}
.armor-preview-option:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
}
.armor-preview-option--selected {
background: var(--color-brand-highlight);
border-color: var(--color-brand);
}
.armor-preview-option > svg {
flex-shrink: 0;
width: 1.35rem;
height: 1.35rem;
}
.armor-preview-option > img {
width: 1.75rem;
height: 1.75rem;
object-fit: contain;
image-rendering: pixelated;
filter: drop-shadow(0 2px 2px rgb(0 0 0 / 35%));
}
.armor-preview-panel {
box-sizing: border-box;
width: min(19rem, calc(100vw - 2rem));
max-height: min(39rem, calc(100vh - 8rem));
overflow-y: auto;
}
.armor-preview-section + .armor-preview-section {
padding-top: 0.75rem;
margin-top: 0.75rem;
border-top: 1px solid var(--surface-4);
}
.armor-preview-section h3 {
margin: 0 0 0.5rem;
font-size: 0.75rem;
font-weight: 600;
line-height: 1rem;
color: var(--color-text-tertiary);
}
.armor-preview-options {
display: grid;
grid-template-columns: repeat(6, 2.5rem);
gap: 0.375rem;
}
.armor-preview-options--four {
grid-template-columns: repeat(4, 2.5rem);
}
@media (max-width: 520px) {
.armor-preview-panel {
max-height: calc(100vh - 6rem);
}
}
</style>
@@ -17,12 +17,17 @@
</span>
</div>
<div
v-if="$slots.subtitle"
class="absolute left-0 right-0 z-10 flex items-center justify-center pointer-events-none"
v-if="$slots.subtitle || showsArmorControls"
class="absolute left-0 right-0 z-10 flex max-h-[calc(100%_-_1rem)] min-h-0 items-center justify-center overflow-y-auto pointer-events-none"
:style="subtitlePositionStyle"
>
<div ref="subtitleElement" class="pointer-events-auto" @click="ignoreControlClick">
<div
ref="subtitleElement"
class="flex max-w-[calc(100vw-2rem)] flex-wrap items-center justify-center gap-2 pointer-events-auto"
@click="ignoreControlClick"
>
<slot name="subtitle" />
<ArmorPreviewControls v-if="showsArmorControls" v-model="armorConfig" />
</div>
</div>
<div
@@ -116,12 +121,15 @@ import {
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import type {
ArmorPreviewConfig,
SkinPreviewAnimationConfig,
SkinPreviewFitPadding,
SkinPreviewFraming,
SkinPreviewTuple,
} from '#ui/composables/skin-rendering'
import {
createDefaultArmorPreviewConfig,
useArmorPreview,
useSkinPreviewAnimation,
useSkinPreviewControls,
useSkinPreviewFit,
@@ -130,6 +138,7 @@ import {
} from '#ui/composables/skin-rendering'
import { useDynamicFontSize } from '../../composables'
import ArmorPreviewControls from './ArmorPreviewControls.vue'
import { createRadialSpotlightShader, syncDamageFlashShader } from './skin-preview-shader'
const { formatMessage } = useVIntl()
@@ -157,6 +166,12 @@ const props = withDefaults(
fov?: number
initialRotation?: number
animationConfig?: SkinPreviewAnimationConfig
armorPreview?: boolean
/**
* Whether the preview draws the armour controls itself. A page that lays
* them out alongside its own controls turns this off and hosts them.
*/
armorControls?: boolean
}>(),
{
variant: 'CLASSIC',
@@ -173,6 +188,8 @@ const props = withDefaults(
randomAnimationInterval: 8000,
transitionDuration: 0.2,
}),
armorPreview: false,
armorControls: true,
},
)
@@ -197,6 +214,7 @@ const isDarkPreviewTheme = computed(() => {
const classList = document.documentElement.classList
return classList.contains('dark-mode') || classList.contains('oled-mode')
})
const showsArmorControls = computed(() => Boolean(props.armorPreview) && props.armorControls)
function getSubtitleLayoutRoot(element: HTMLElement) {
const elementChildren = Array.from(element.children).filter(
@@ -286,6 +304,18 @@ const { isModelLoaded, isTextureLoaded, modelCenter, modelSize, scene } = useSki
cleanupAnimationState,
})
const armorConfig = defineModel<ArmorPreviewConfig>('armorConfig', {
default: createDefaultArmorPreviewConfig,
})
// Auto-fit is anchored to the load-time pose. Measuring again after an async
// armor update could capture an arbitrary animation frame and jump on resize.
useArmorPreview({
scene,
config: armorConfig,
enabled: toRef(props, 'armorPreview'),
})
function syncDamageFlashShaderMaterials() {
syncDamageFlashShader(scene.value, damageFlashIntensity.value)
}
+2
View File
@@ -1,3 +1,5 @@
export { default as ArmorPreviewControls } from './ArmorPreviewControls.vue'
export { default as ArmorTrimPanel } from './ArmorTrimPanel.vue'
export { default as CapeButton } from './CapeButton.vue'
export { default as CapeLikeTextButton } from './CapeLikeTextButton.vue'
export { default as SkinButton } from './SkinButton.vue'