From ae2992d7f82dc83fb14d37345f78b84df0d56329 Mon Sep 17 00:00:00 2001 From: Ap_Tx <3045929398@qq.com> Date: Wed, 7 Oct 2026 20:01:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=A7=BB=E6=A4=8D=20Axolotl=20?= =?UTF-8?q?=E7=9A=AE=E8=82=A4=E7=9B=94=E7=94=B2=E9=A2=84=E8=A7=88=EF=BC=88?= =?UTF-8?q?Armor=20Preview=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从 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 依赖。 --- .../src/components/ui/skin/ArmorTrimTab.vue | 123 +++++++ .../components/ui/skin/SkinListSkeleton.vue | 28 ++ .../src/helpers/skin-armor-preview.test.ts | 60 +++ .../src/helpers/skin-armor-preview.ts | 47 +++ apps/app-frontend/src/pages/Skins.vue | 172 +++++++-- .../entity/humanoid/chainmail.png | Bin 0 -> 251 bytes .../entity/humanoid/copper.png | Bin 0 -> 423 bytes .../entity/humanoid/diamond.png | Bin 0 -> 438 bytes .../minecraft-armor/entity/humanoid/gold.png | Bin 0 -> 434 bytes .../minecraft-armor/entity/humanoid/iron.png | Bin 0 -> 424 bytes .../entity/humanoid/leather.png | Bin 0 -> 413 bytes .../entity/humanoid/leather_overlay.png | Bin 0 -> 170 bytes .../entity/humanoid/netherite.png | Bin 0 -> 467 bytes .../entity/humanoid/turtle_scute.png | Bin 0 -> 253 bytes .../entity/leggings/chainmail.png | Bin 0 -> 167 bytes .../entity/leggings/copper.png | Bin 0 -> 258 bytes .../entity/leggings/diamond.png | Bin 0 -> 272 bytes .../minecraft-armor/entity/leggings/gold.png | Bin 0 -> 261 bytes .../minecraft-armor/entity/leggings/iron.png | Bin 0 -> 243 bytes .../entity/leggings/leather.png | Bin 0 -> 254 bytes .../entity/leggings/leather_overlay.png | Bin 0 -> 180 bytes .../entity/leggings/netherite.png | Bin 0 -> 258 bytes .../minecraft-armor/item/amethyst_shard.png | Bin 0 -> 183 bytes .../bolt_armor_trim_smithing_template.png | Bin 0 -> 225 bytes .../minecraft-armor/item/chainmail_boots.png | Bin 0 -> 149 bytes .../item/chainmail_chestplate.png | Bin 0 -> 165 bytes .../minecraft-armor/item/chainmail_helmet.png | Bin 0 -> 135 bytes .../item/chainmail_leggings.png | Bin 0 -> 148 bytes .../coast_armor_trim_smithing_template.png | Bin 0 -> 267 bytes .../minecraft-armor/item/copper_boots.png | Bin 0 -> 171 bytes .../item/copper_chestplate.png | Bin 0 -> 189 bytes .../minecraft-armor/item/copper_helmet.png | Bin 0 -> 160 bytes .../minecraft-armor/item/copper_ingot.png | Bin 0 -> 196 bytes .../minecraft-armor/item/copper_leggings.png | Bin 0 -> 177 bytes .../assets/minecraft-armor/item/diamond.png | Bin 0 -> 207 bytes .../minecraft-armor/item/diamond_boots.png | Bin 0 -> 175 bytes .../item/diamond_chestplate.png | Bin 0 -> 189 bytes .../minecraft-armor/item/diamond_helmet.png | Bin 0 -> 165 bytes .../minecraft-armor/item/diamond_leggings.png | Bin 0 -> 177 bytes .../dune_armor_trim_smithing_template.png | Bin 0 -> 224 bytes .../assets/minecraft-armor/item/emerald.png | Bin 0 -> 182 bytes .../item/eye_armor_trim_smithing_template.png | Bin 0 -> 211 bytes .../flow_armor_trim_smithing_template.png | Bin 0 -> 210 bytes .../minecraft-armor/item/gold_ingot.png | Bin 0 -> 187 bytes .../minecraft-armor/item/golden_boots.png | Bin 0 -> 169 bytes .../item/golden_chestplate.png | Bin 0 -> 189 bytes .../minecraft-armor/item/golden_helmet.png | Bin 0 -> 158 bytes .../minecraft-armor/item/golden_leggings.png | Bin 0 -> 174 bytes .../host_armor_trim_smithing_template.png | Bin 0 -> 203 bytes .../minecraft-armor/item/iron_boots.png | Bin 0 -> 143 bytes .../minecraft-armor/item/iron_chestplate.png | Bin 0 -> 161 bytes .../minecraft-armor/item/iron_helmet.png | Bin 0 -> 134 bytes .../minecraft-armor/item/iron_ingot.png | Bin 0 -> 157 bytes .../minecraft-armor/item/iron_leggings.png | Bin 0 -> 151 bytes .../minecraft-armor/item/lapis_lazuli.png | Bin 0 -> 181 bytes .../item/leather_default_boots.png | Bin 0 -> 200 bytes .../item/leather_default_chestplate.png | Bin 0 -> 245 bytes .../item/leather_default_helmet.png | Bin 0 -> 202 bytes .../item/leather_default_leggings.png | Bin 0 -> 209 bytes .../minecraft-armor/item/netherite_boots.png | Bin 0 -> 171 bytes .../item/netherite_chestplate.png | Bin 0 -> 216 bytes .../minecraft-armor/item/netherite_helmet.png | Bin 0 -> 161 bytes .../minecraft-armor/item/netherite_ingot.png | Bin 0 -> 208 bytes .../item/netherite_leggings.png | Bin 0 -> 176 bytes .../assets/minecraft-armor/item/quartz.png | Bin 0 -> 223 bytes .../raiser_armor_trim_smithing_template.png | Bin 0 -> 215 bytes .../assets/minecraft-armor/item/redstone.png | Bin 0 -> 179 bytes .../minecraft-armor/item/resin_brick.png | Bin 0 -> 198 bytes .../item/rib_armor_trim_smithing_template.png | Bin 0 -> 227 bytes .../sentry_armor_trim_smithing_template.png | Bin 0 -> 207 bytes .../shaper_armor_trim_smithing_template.png | Bin 0 -> 208 bytes .../silence_armor_trim_smithing_template.png | Bin 0 -> 254 bytes .../snout_armor_trim_smithing_template.png | Bin 0 -> 208 bytes .../spire_armor_trim_smithing_template.png | Bin 0 -> 209 bytes .../tide_armor_trim_smithing_template.png | Bin 0 -> 221 bytes .../minecraft-armor/item/turtle_helmet.png | Bin 0 -> 152 bytes .../item/vex_armor_trim_smithing_template.png | Bin 0 -> 213 bytes .../ward_armor_trim_smithing_template.png | Bin 0 -> 218 bytes ...wayfinder_armor_trim_smithing_template.png | Bin 0 -> 216 bytes .../wild_armor_trim_smithing_template.png | Bin 0 -> 252 bytes .../src/assets/minecraft-armor/slot/boots.png | Bin 0 -> 103 bytes .../minecraft-armor/slot/chestplate.png | Bin 0 -> 104 bytes .../assets/minecraft-armor/slot/helmet.png | Bin 0 -> 108 bytes .../assets/minecraft-armor/slot/leggings.png | Bin 0 -> 93 bytes .../minecraft-armor/trim/humanoid/bolt.png | Bin 0 -> 265 bytes .../minecraft-armor/trim/humanoid/coast.png | Bin 0 -> 166 bytes .../minecraft-armor/trim/humanoid/dune.png | Bin 0 -> 218 bytes .../minecraft-armor/trim/humanoid/eye.png | Bin 0 -> 181 bytes .../minecraft-armor/trim/humanoid/flow.png | Bin 0 -> 247 bytes .../minecraft-armor/trim/humanoid/host.png | Bin 0 -> 177 bytes .../minecraft-armor/trim/humanoid/raiser.png | Bin 0 -> 192 bytes .../minecraft-armor/trim/humanoid/rib.png | Bin 0 -> 163 bytes .../minecraft-armor/trim/humanoid/sentry.png | Bin 0 -> 170 bytes .../minecraft-armor/trim/humanoid/shaper.png | Bin 0 -> 169 bytes .../minecraft-armor/trim/humanoid/silence.png | Bin 0 -> 401 bytes .../minecraft-armor/trim/humanoid/snout.png | Bin 0 -> 195 bytes .../minecraft-armor/trim/humanoid/spire.png | Bin 0 -> 208 bytes .../minecraft-armor/trim/humanoid/tide.png | Bin 0 -> 196 bytes .../minecraft-armor/trim/humanoid/vex.png | Bin 0 -> 187 bytes .../minecraft-armor/trim/humanoid/ward.png | Bin 0 -> 204 bytes .../trim/humanoid/wayfinder.png | Bin 0 -> 201 bytes .../minecraft-armor/trim/humanoid/wild.png | Bin 0 -> 207 bytes .../minecraft-armor/trim/leggings/bolt.png | Bin 0 -> 166 bytes .../minecraft-armor/trim/leggings/coast.png | Bin 0 -> 134 bytes .../minecraft-armor/trim/leggings/dune.png | Bin 0 -> 142 bytes .../minecraft-armor/trim/leggings/eye.png | Bin 0 -> 141 bytes .../minecraft-armor/trim/leggings/flow.png | Bin 0 -> 144 bytes .../minecraft-armor/trim/leggings/host.png | Bin 0 -> 140 bytes .../minecraft-armor/trim/leggings/raiser.png | Bin 0 -> 138 bytes .../minecraft-armor/trim/leggings/rib.png | Bin 0 -> 119 bytes .../minecraft-armor/trim/leggings/sentry.png | Bin 0 -> 126 bytes .../minecraft-armor/trim/leggings/shaper.png | Bin 0 -> 130 bytes .../minecraft-armor/trim/leggings/silence.png | Bin 0 -> 243 bytes .../minecraft-armor/trim/leggings/snout.png | Bin 0 -> 133 bytes .../minecraft-armor/trim/leggings/spire.png | Bin 0 -> 139 bytes .../minecraft-armor/trim/leggings/tide.png | Bin 0 -> 149 bytes .../minecraft-armor/trim/leggings/vex.png | Bin 0 -> 120 bytes .../minecraft-armor/trim/leggings/ward.png | Bin 0 -> 150 bytes .../trim/leggings/wayfinder.png | Bin 0 -> 148 bytes .../minecraft-armor/trim/leggings/wild.png | Bin 0 -> 137 bytes .../trim/palettes/amethyst.png | Bin 0 -> 90 bytes .../minecraft-armor/trim/palettes/copper.png | Bin 0 -> 90 bytes .../trim/palettes/copper_darker.png | Bin 0 -> 90 bytes .../minecraft-armor/trim/palettes/diamond.png | Bin 0 -> 90 bytes .../trim/palettes/diamond_darker.png | Bin 0 -> 90 bytes .../minecraft-armor/trim/palettes/emerald.png | Bin 0 -> 90 bytes .../minecraft-armor/trim/palettes/gold.png | Bin 0 -> 91 bytes .../trim/palettes/gold_darker.png | Bin 0 -> 90 bytes .../minecraft-armor/trim/palettes/iron.png | Bin 0 -> 90 bytes .../trim/palettes/iron_darker.png | Bin 0 -> 89 bytes .../minecraft-armor/trim/palettes/lapis.png | Bin 0 -> 90 bytes .../trim/palettes/netherite.png | Bin 0 -> 90 bytes .../trim/palettes/netherite_darker.png | Bin 0 -> 90 bytes .../minecraft-armor/trim/palettes/quartz.png | Bin 0 -> 90 bytes .../trim/palettes/redstone.png | Bin 0 -> 90 bytes .../minecraft-armor/trim/palettes/resin.png | Bin 0 -> 90 bytes .../trim/palettes/trim_palette.png | Bin 0 -> 68 bytes .../components/skin/ArmorPreviewControls.vue | 60 +++ .../ui/src/components/skin/ArmorTrimPanel.vue | 334 +++++++++++++++++ .../components/skin/SkinPreviewRenderer.vue | 36 +- packages/ui/src/components/skin/index.ts | 2 + packages/ui/src/composables/index.ts | 9 + .../skin-rendering/armor-preview-assets.ts | 105 ++++++ .../armor-preview-types.test.ts | 56 +++ .../skin-rendering/armor-preview-types.ts | 99 +++++ .../skin-rendering/use-armor-preview.ts | 55 +++ packages/ui/src/utils/index.ts | 1 + .../ui/src/utils/webgl/armor-geometry.test.ts | 222 ++++++++++++ packages/ui/src/utils/webgl/armor-geometry.ts | 209 +++++++++++ .../src/utils/webgl/armor-preview-object.ts | 7 + .../ui/src/utils/webgl/armor-rendering.ts | 343 ++++++++++++++++++ 151 files changed, 1936 insertions(+), 32 deletions(-) create mode 100644 apps/app-frontend/src/components/ui/skin/ArmorTrimTab.vue create mode 100644 apps/app-frontend/src/components/ui/skin/SkinListSkeleton.vue create mode 100644 apps/app-frontend/src/helpers/skin-armor-preview.test.ts create mode 100644 apps/app-frontend/src/helpers/skin-armor-preview.ts create mode 100644 packages/ui/src/assets/minecraft-armor/entity/humanoid/chainmail.png create mode 100644 packages/ui/src/assets/minecraft-armor/entity/humanoid/copper.png create mode 100644 packages/ui/src/assets/minecraft-armor/entity/humanoid/diamond.png create mode 100644 packages/ui/src/assets/minecraft-armor/entity/humanoid/gold.png create mode 100644 packages/ui/src/assets/minecraft-armor/entity/humanoid/iron.png create mode 100644 packages/ui/src/assets/minecraft-armor/entity/humanoid/leather.png create mode 100644 packages/ui/src/assets/minecraft-armor/entity/humanoid/leather_overlay.png create mode 100644 packages/ui/src/assets/minecraft-armor/entity/humanoid/netherite.png create mode 100644 packages/ui/src/assets/minecraft-armor/entity/humanoid/turtle_scute.png create mode 100644 packages/ui/src/assets/minecraft-armor/entity/leggings/chainmail.png create mode 100644 packages/ui/src/assets/minecraft-armor/entity/leggings/copper.png create mode 100644 packages/ui/src/assets/minecraft-armor/entity/leggings/diamond.png create mode 100644 packages/ui/src/assets/minecraft-armor/entity/leggings/gold.png create mode 100644 packages/ui/src/assets/minecraft-armor/entity/leggings/iron.png create mode 100644 packages/ui/src/assets/minecraft-armor/entity/leggings/leather.png create mode 100644 packages/ui/src/assets/minecraft-armor/entity/leggings/leather_overlay.png create mode 100644 packages/ui/src/assets/minecraft-armor/entity/leggings/netherite.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/amethyst_shard.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/bolt_armor_trim_smithing_template.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/chainmail_boots.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/chainmail_chestplate.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/chainmail_helmet.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/chainmail_leggings.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/coast_armor_trim_smithing_template.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/copper_boots.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/copper_chestplate.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/copper_helmet.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/copper_ingot.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/copper_leggings.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/diamond.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/diamond_boots.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/diamond_chestplate.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/diamond_helmet.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/diamond_leggings.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/dune_armor_trim_smithing_template.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/emerald.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/eye_armor_trim_smithing_template.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/flow_armor_trim_smithing_template.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/gold_ingot.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/golden_boots.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/golden_chestplate.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/golden_helmet.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/golden_leggings.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/host_armor_trim_smithing_template.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/iron_boots.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/iron_chestplate.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/iron_helmet.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/iron_ingot.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/iron_leggings.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/lapis_lazuli.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/leather_default_boots.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/leather_default_chestplate.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/leather_default_helmet.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/leather_default_leggings.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/netherite_boots.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/netherite_chestplate.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/netherite_helmet.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/netherite_ingot.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/netherite_leggings.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/quartz.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/raiser_armor_trim_smithing_template.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/redstone.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/resin_brick.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/rib_armor_trim_smithing_template.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/sentry_armor_trim_smithing_template.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/shaper_armor_trim_smithing_template.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/silence_armor_trim_smithing_template.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/snout_armor_trim_smithing_template.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/spire_armor_trim_smithing_template.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/tide_armor_trim_smithing_template.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/turtle_helmet.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/vex_armor_trim_smithing_template.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/ward_armor_trim_smithing_template.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/wayfinder_armor_trim_smithing_template.png create mode 100644 packages/ui/src/assets/minecraft-armor/item/wild_armor_trim_smithing_template.png create mode 100644 packages/ui/src/assets/minecraft-armor/slot/boots.png create mode 100644 packages/ui/src/assets/minecraft-armor/slot/chestplate.png create mode 100644 packages/ui/src/assets/minecraft-armor/slot/helmet.png create mode 100644 packages/ui/src/assets/minecraft-armor/slot/leggings.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/humanoid/bolt.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/humanoid/coast.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/humanoid/dune.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/humanoid/eye.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/humanoid/flow.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/humanoid/host.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/humanoid/raiser.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/humanoid/rib.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/humanoid/sentry.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/humanoid/shaper.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/humanoid/silence.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/humanoid/snout.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/humanoid/spire.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/humanoid/tide.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/humanoid/vex.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/humanoid/ward.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/humanoid/wayfinder.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/humanoid/wild.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/leggings/bolt.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/leggings/coast.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/leggings/dune.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/leggings/eye.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/leggings/flow.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/leggings/host.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/leggings/raiser.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/leggings/rib.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/leggings/sentry.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/leggings/shaper.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/leggings/silence.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/leggings/snout.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/leggings/spire.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/leggings/tide.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/leggings/vex.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/leggings/ward.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/leggings/wayfinder.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/leggings/wild.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/palettes/amethyst.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/palettes/copper.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/palettes/copper_darker.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/palettes/diamond.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/palettes/diamond_darker.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/palettes/emerald.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/palettes/gold.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/palettes/gold_darker.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/palettes/iron.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/palettes/iron_darker.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/palettes/lapis.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/palettes/netherite.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/palettes/netherite_darker.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/palettes/quartz.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/palettes/redstone.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/palettes/resin.png create mode 100644 packages/ui/src/assets/minecraft-armor/trim/palettes/trim_palette.png create mode 100644 packages/ui/src/components/skin/ArmorPreviewControls.vue create mode 100644 packages/ui/src/components/skin/ArmorTrimPanel.vue create mode 100644 packages/ui/src/composables/skin-rendering/armor-preview-assets.ts create mode 100644 packages/ui/src/composables/skin-rendering/armor-preview-types.test.ts create mode 100644 packages/ui/src/composables/skin-rendering/armor-preview-types.ts create mode 100644 packages/ui/src/composables/skin-rendering/use-armor-preview.ts create mode 100644 packages/ui/src/utils/webgl/armor-geometry.test.ts create mode 100644 packages/ui/src/utils/webgl/armor-geometry.ts create mode 100644 packages/ui/src/utils/webgl/armor-preview-object.ts create mode 100644 packages/ui/src/utils/webgl/armor-rendering.ts diff --git a/apps/app-frontend/src/components/ui/skin/ArmorTrimTab.vue b/apps/app-frontend/src/components/ui/skin/ArmorTrimTab.vue new file mode 100644 index 0000000..6ff9263 --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin/ArmorTrimTab.vue @@ -0,0 +1,123 @@ + + + diff --git a/apps/app-frontend/src/components/ui/skin/SkinListSkeleton.vue b/apps/app-frontend/src/components/ui/skin/SkinListSkeleton.vue new file mode 100644 index 0000000..0bf8fc3 --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin/SkinListSkeleton.vue @@ -0,0 +1,28 @@ + + + diff --git a/apps/app-frontend/src/helpers/skin-armor-preview.test.ts b/apps/app-frontend/src/helpers/skin-armor-preview.test.ts new file mode 100644 index 0000000..531ac14 --- /dev/null +++ b/apps/app-frontend/src/helpers/skin-armor-preview.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { loadSkinArmorPreview, saveSkinArmorPreview } from './skin-armor-preview.ts' + +const originalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage') + +function withStorage(run: (values: Map) => void): void { + const values = new Map() + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + }, + }) + try { + run(values) + } finally { + if (originalStorage) Object.defineProperty(globalThis, 'localStorage', originalStorage) + else delete (globalThis as { localStorage?: Storage }).localStorage + } +} + +test('armor preview selections survive storage round trips', () => { + withStorage(() => { + const config = loadSkinArmorPreview() + config.helmet.material = 'diamond' + config.helmet.trimPattern = 'sentry' + config.helmet.trimMaterial = 'gold' + saveSkinArmorPreview(config) + + assert.deepEqual(loadSkinArmorPreview(), config) + }) +}) + +test('invalid stored armor values fall back per slot and field', () => { + withStorage((values) => { + values.set( + 'axolotl:skin-armor-preview', + JSON.stringify({ + helmet: { material: 'diamond', trimPattern: 'invalid', trimMaterial: 'gold' }, + boots: { material: 'turtle', trimPattern: 'sentry', trimMaterial: 'invalid' }, + }), + ) + + const config = loadSkinArmorPreview() + assert.deepEqual(config.helmet, { + material: 'diamond', + trimPattern: null, + trimMaterial: 'gold', + }) + assert.deepEqual(config.boots, { + material: null, + trimPattern: 'sentry', + trimMaterial: 'iron', + }) + assert.equal(config.chestplate.material, null) + }) +}) diff --git a/apps/app-frontend/src/helpers/skin-armor-preview.ts b/apps/app-frontend/src/helpers/skin-armor-preview.ts new file mode 100644 index 0000000..a206611 --- /dev/null +++ b/apps/app-frontend/src/helpers/skin-armor-preview.ts @@ -0,0 +1,47 @@ +import { + ARMOR_SLOTS, + ARMOR_TRIM_MATERIALS, + ARMOR_TRIM_PATTERNS, + armorMaterialsForSlot, + type ArmorPreviewConfig, + createDefaultArmorPreviewConfig, +} from '@modrinth/ui' + +const STORAGE_KEY = 'axolotl:skin-armor-preview' + +function includes(values: readonly T[], value: unknown): value is T { + return typeof value === 'string' && values.some((item) => item === value) +} + +export function loadSkinArmorPreview(): ArmorPreviewConfig { + const config = createDefaultArmorPreviewConfig() + try { + const saved = JSON.parse(globalThis.localStorage?.getItem(STORAGE_KEY) ?? 'null') + if (!saved || typeof saved !== 'object' || Array.isArray(saved)) return config + + for (const slot of ARMOR_SLOTS) { + const piece = saved[slot] + if (!piece || typeof piece !== 'object' || Array.isArray(piece)) continue + if (piece.material === null || includes(armorMaterialsForSlot(slot), piece.material)) { + config[slot].material = piece.material + } + if (piece.trimPattern === null || includes(ARMOR_TRIM_PATTERNS, piece.trimPattern)) { + config[slot].trimPattern = piece.trimPattern + } + if (includes(ARMOR_TRIM_MATERIALS, piece.trimMaterial)) { + config[slot].trimMaterial = piece.trimMaterial + } + } + } catch { + // Invalid or unavailable local storage falls back to a fresh preview. + } + return config +} + +export function saveSkinArmorPreview(config: ArmorPreviewConfig): void { + try { + globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(config)) + } catch { + // The preview remains usable when local storage is unavailable. + } +} diff --git a/apps/app-frontend/src/pages/Skins.vue b/apps/app-frontend/src/pages/Skins.vue index e9d8008..e55a35c 100644 --- a/apps/app-frontend/src/pages/Skins.vue +++ b/apps/app-frontend/src/pages/Skins.vue @@ -9,9 +9,13 @@ import { SpinnerIcon, } from '@modrinth/assets' import { + type ArmorPreviewConfig, + ArmorPreviewControls, ButtonStyled, + cloneArmorPreviewConfig, commonMessages, ConfirmModal, + createDefaultArmorPreviewConfig, defineMessages, injectNotificationManager, NavTabs, @@ -24,12 +28,14 @@ import { invoke } from '@tauri-apps/api/core' import type { DragDropEvent } from '@tauri-apps/api/webview' import { getCurrentWebview } from '@tauri-apps/api/webview' import { computedAsync } from '@vueuse/core' -import type { Ref } from 'vue' +import type { ComponentPublicInstance, Ref } from 'vue' import { computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue' import { onBeforeRouteLeave, useRouter } from 'vue-router' import type AccountsCard from '@/components/ui/AccountsCard.vue' +import ArmorTrimTab from '@/components/ui/skin/ArmorTrimTab.vue' import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue' +import SkinListSkeleton from '@/components/ui/skin/SkinListSkeleton.vue' import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.vue' import { openSkinSiteLogin, @@ -45,6 +51,7 @@ import { useNetworkStatus } from '@/composables/useNetworkStatus' import { check_reachable, get_default_user, users } from '@/helpers/auth' import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts' import { skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts' +import { loadSkinArmorPreview, saveSkinArmorPreview } from '@/helpers/skin-armor-preview' import type { Cape, Skin, SkinTextureUrl } from '@/helpers/skins.ts' import { determineModelType, @@ -252,6 +259,8 @@ const editSkinModal = useTemplateRef('editSkinModal') const addSkinFileInput = useTemplateRef('addSkinFileInput') const skinSectionList = useTemplateRef('skinSectionList') const skinPreviewArea = useTemplateRef('skinPreviewArea') +const armorTrimTab = useTemplateRef('armorTrimTab') +const armorPreviewControls = useTemplateRef('armorPreviewControls') const { formatMessage } = useVIntl() const router = useRouter() @@ -391,7 +400,8 @@ const isAddSkinButtonDragActive = ref(false) const deleteSkinModal = ref() const skinToDelete = ref(null) -const skinListTab = ref<'saved' | 'default'>('saved') +const lastSkinListTab = ref<'saved' | 'default'>('saved') +const skinListTab = ref<'saved' | 'default' | 'armor'>('saved') const skinListTabLinks = computed(() => [ { @@ -403,6 +413,70 @@ const skinListTabLinks = computed(() => [ label: formatMessage(messages.defaultTab), }, ]) +const ARMOR_TRIM_PANEL_ID = 'skins-armor-trim-panel' +const savedArmorPreview = ref(loadSkinArmorPreview()) +const armorPreviewConfig = ref(cloneArmorPreviewConfig(savedArmorPreview.value)) + +function saveArmorPreview() { + savedArmorPreview.value = cloneArmorPreviewConfig(armorPreviewConfig.value) + saveSkinArmorPreview(savedArmorPreview.value) +} + +function resetArmorPreviewToSaved() { + armorPreviewConfig.value = cloneArmorPreviewConfig(savedArmorPreview.value) +} + +function restoreDefaultArmorPreview() { + armorPreviewConfig.value = createDefaultArmorPreviewConfig() +} + +/** A third tab behind the two list tabs, opened from the preview's action row. */ +function openArmorTab() { + if (skinListTab.value !== 'armor') lastSkinListTab.value = skinListTab.value + + skinListTab.value = 'armor' +} + +/** + * Whether an element belongs to the armour surface: somewhere inside the open + * armour tab or the button that toggles it. Only those hold a focus ring that + * would be left behind when the tab closes. + */ +function isArmorPreviewElement(element: Element) { + const tabElement: unknown = armorTrimTab.value?.$el + const toggleElement: unknown = armorPreviewControls.value?.$el + + return ( + (tabElement instanceof Element && tabElement.contains(element)) || + (toggleElement instanceof Element && toggleElement.contains(element)) + ) +} + +function closeArmorTab() { + // Leaving the tab drops whatever was not saved on purpose. + resetArmorPreviewToSaved() + skinListTab.value = lastSkinListTab.value + + // Escape and the toggle both leave the armour button holding focus, which + // keeps its focus ring drawn until something else is clicked. Only drop that + // ring when the focus sits on the armour surface, never on an unrelated + // control that happened to hold it when Escape was pressed. + const focused = document.activeElement + if (focused instanceof HTMLElement && isArmorPreviewElement(focused)) focused.blur() +} + +/** + * The tab bar keeps the last list tab highlighted while the armour tab shows, so + * picking one of them leaves the armour tab. That transition has to drop the + * draft exactly like the toggle and Escape paths do, or it lingers on the model + * while the panel is gone. + */ +function selectSkinListTab(index: number) { + if (skinListTab.value === 'armor') resetArmorPreviewToSaved() + + lastSkinListTab.value = index === 0 ? 'saved' : 'default' + skinListTab.value = lastSkinListTab.value +} function confirmDeleteSkin(skin: Skin) { if (isSkinManagementReadOnly.value) return @@ -1211,6 +1285,9 @@ await loadSkins() class="ml-5 mt-4 flex h-[calc(80vh-1rem)] items-center justify-center max-[700px]:h-[calc(50vh-1rem)]" > {{ formatMessage(messages.editSkinButton) }} + @@ -1265,14 +1352,10 @@ await loadSkins()
- + +
+ + +
+ +
@@ -1382,4 +1483,17 @@ await loadSkins() grid-template-columns: 1fr; } } +// The armour tab crossfades with the skin list it replaces. +.armor-tab-enter-active, +.armor-tab-leave-active { + transition: + opacity 180ms ease, + transform 180ms ease; +} + +.armor-tab-enter-from, +.armor-tab-leave-to { + opacity: 0; + transform: translateY(0.5rem); +} diff --git a/packages/ui/src/assets/minecraft-armor/entity/humanoid/chainmail.png b/packages/ui/src/assets/minecraft-armor/entity/humanoid/chainmail.png new file mode 100644 index 0000000000000000000000000000000000000000..b4e4aaceae8461583af4c8506b4fdda075eeee1a GIT binary patch literal 251 zcmVY)YpY@vSy>J3_sq6dE(sFlDYvsaED~(wf zeA%Lw^lDJv&?qwFO2N=jDfybF{Mw=ZQnkbF{tL2J1rJlMBN+ey002ovPDHLkV1j)0 BY)AkA literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/entity/humanoid/copper.png b/packages/ui/src/assets/minecraft-armor/entity/humanoid/copper.png new file mode 100644 index 0000000000000000000000000000000000000000..eccf78520ca941a779feb55a35c36264514d5e77 GIT binary patch literal 423 zcmV;Y0a*TtP)Y9qV<)fvCz5YD07dPk$`q3@3 z=4uNB1@ZEB-bGOIu9SS?A<6qWNrcez@^#(m+f~ItSN$)#e&Z}p)ouWto2}bTb%s14 zu7Ma|y-AX%x-`Z0yJ$9Db}T8nFWpRgO*F|*6*HOHUFiBCCwV1S+T4egn$|}vXW9-y z1o03-W`JQ6=WNlAVZ_BlZZr&@QWgf2G+TK3b>M|h8N3Kw$yLqfW^rssdupW}JzX`C z5k?x}1>lwQ0no0S=y9||V2`wG*LSz7`fzy5Gc*(PjUicm^FM+yx(L60g&&FRZBfT+ RC&K^$002ovPDHLkV1l0Ew@v^6 literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/entity/humanoid/diamond.png b/packages/ui/src/assets/minecraft-armor/entity/humanoid/diamond.png new file mode 100644 index 0000000000000000000000000000000000000000..b3dc020c005ac839f2d68159d3d4883bc49a054e GIT binary patch literal 438 zcmV;n0ZIOeP)Ea2EM(7r9ivYmFfRR9100d!JMQvg8b*k%9#0Ygbd zK~#9!y_M0f+b|469jhaC`2Qa~wDDH##mySv3UG7~AbcRDc>F06AQ|5l$E=e0USPKE z8v-eJx99J1ZPl4`=7Wr=I?`H>KH zNFMHXg=u$R5pXvoj4^wRB+%`ox}W`$2MfCwBoMdm#Tx5TWPD$Nj2|olpZy}cH1bj4 zD0aKR=b34Caefv^a&b3K`iBEqXPXaGP>d0Aav(Fzf;xEhS=sFdIVw5ClbI!zLpk)X zUhmsb=<+0>=#GU_QF91Imko?0&UxUvOionX$3d2NQ?E-?z|^!x7VnkL{y;4^`TuA gNJ!2H|NRU54f#+LJU<+2hX4Qo07*qoM6N<$g8xy)*#H0l literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/entity/humanoid/gold.png b/packages/ui/src/assets/minecraft-armor/entity/humanoid/gold.png new file mode 100644 index 0000000000000000000000000000000000000000..bbd30114b8f2f58116d927d1024e94800f2f4170 GIT binary patch literal 434 zcmV;j0ZsmiP)*gf^Q9N@Ar)0Q&Ybqs<40004WQchC7!G6osu0ZQh^{kfGgc$?U-*4-gDv7XOSfhFr$D2E$_)D-*U$rC!O0fzQY1`a-sp zcHpyA^~anf0a4R5FjWov1Hk7bU@BZB0a>0<5&#c6wdr6X!UN#8pvW;BOWG-X`Vhwp z+3BE0&UAWN1#Px~ZNT4Nt_KedpPGj6WC-$l4dTJpG~KSgeCaG%p^5)hRd15TR^ z;yupKihtv>wal`aSsnqB43v^ZE8@IcGQ>e`YcM9Zl|iG8XI>44nc!fUrK^#PVk`s_ zNOkH`a@843V+YA3m&`D5cS>7uYJG|3vuZ7w!&`^CdSuy~c8}Ph7>Fq(ivDkWMx6KB c!*5672a=Y8y9~ahmjD0&07*qoM6N<$f^3|{4*&oF literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/entity/humanoid/iron.png b/packages/ui/src/assets/minecraft-armor/entity/humanoid/iron.png new file mode 100644 index 0000000000000000000000000000000000000000..9c54e7e2730c07af9b90ea59a52b942ce40354e7 GIT binary patch literal 424 zcmV;Z0ayNsP)R!uY4@P-_fLVj#!kWRxYaF=n`o+qGol>bM@iXvo{2W}hghhx|Db#1p z%D}+Oz`b+g4}czvfbVEi1ax~KMF1k$(jvk_qyd0iP}Nk-EuRcM?A7Oqy|}?Z&`)-m z6?a=8IEd%B>n?(mcjx2_4@o|*Ng{-Sm#=G2-?l0S+UkGN4I5{9s&)gY+-%)$sx#&Z zaSg=y>P?b7)ut(~-$k+hykkkxeyOJ4E22n#s+h^l?n2cE*~<$t(&RpjRJ1-CIn#Cw zB8Z0wG6OX0IcJM@3?nWca-(7Nl(I1Dq}jsLuLUoB%HT!dPOfS;H;ZF4nNusx;P)w{)8o}uZPZ#2p3oBt7v(MI^~EBpXFPH3W* S+I?~W00004%P){{It-bv%F;XBvjUz#}Aqu$zR8#CihTLH2>8>H~4M z`VAoVT_DY3>;i7u@h*Ubp|_dwMwE`2pFtRej*~ihu^VA~kS`)Qazdor2+BSKt3^69 zwjs18T5DeT@R$*FN}SJe5oyrAk*l0mEZc0WTHU$*H`!fL2g-u;VINA00000NkvXX Hu0mjf^MHYaZfQbGYfA+A6=%Rw{AMj^^VA;?6* zOGlzL;n_!^C}T;GUoeBivm0qZj+>{8V@SoVWCrGI3~YZIZZ-t5`f#y`7;s&PVVuk~ zkt;%`plWi1=Nh&nyIL3Ib68I+m@|Rh$(d7w!$fs~Ld4V!qKgwa7#R5Pu=1UXTK5NN OB7>)^pUXO@geCy6BQGHU literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/entity/humanoid/netherite.png b/packages/ui/src/assets/minecraft-armor/entity/humanoid/netherite.png new file mode 100644 index 0000000000000000000000000000000000000000..11c961e847166a3aa51c54d546ca1ca7febdab81 GIT binary patch literal 467 zcmV;^0WAKBP)#T~S0%NjX11Fgr3QCnO&r5fKpz3JP+!7>fV^00DGTPE!Ct=GbNc00Co3L_t(| z+J%y_Zi6rwg#T7es*a#OK(R?P$C%6sj@6X_c2?TRc&*yP-#6&mM=pdcK;L1WPJ^@V3sM$VhMssZ#RZ~D0s=RrcD`~VTq@DnXVA9*oOao%2)WuEiHvJD+ zRd@-QF#wvL<1Xnz0PtUMXnK)kgX8zl81RxSfJm-aj3s0Za1gti(L=yUhPWWZP51$z zZr$_hRYG&bn2i#6Iuk-hfgiDm9}(ALJUeB)kO`xq;Q9k`X-1=Ep10^d@F_tgo978H@y}huJ?@$1b^Ti|A zm(~9Nf0$#d!a;|Jyc@pogw0WOe#NJpm^G~+DP*(HqzAL6GN_nxCnsl3D@^)xTwdb9 z%6vuXlLtFHRdW=jbFK=iv|RBM`X1+aqr>Xbg}V#f7Hz#QmwVFo)rlQ1-#rsd-rQT7 zBAXoh>SWRFwXdgr?L0mE*KFp~*|u~2mUD*hS7na;QOo%V=r9IPS3j3^P6HYaZfQv3lvA+A7r@7}#zwrp9lbjg&7 zQ(VmFPX-DxmIV0)GdMiEkp|?rdAc};RNP9IU|q~0s=;OWfW4`SMKmC-b+e(w4uc0w zXM7Y6oH^6LE#bh(tapHil{u4%TSUV^fZ>b>!}8FIwZ(2sDynP@p%ZxCGoK1n0-DI+ M>FVdQ&MBb@0I>TnE&u=k literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/entity/leggings/copper.png b/packages/ui/src/assets/minecraft-armor/entity/leggings/copper.png new file mode 100644 index 0000000000000000000000000000000000000000..fc15738462c1592e583c325ec240c0023708c8d3 GIT binary patch literal 258 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH!VDw>HYaZfQW60^A+A9B_u*}SW;Q*q3A>i- zaX!UiSFG)_AamA*bIpKqj3q&S!3+-1ZlnP@Gdx`!Ln>~)op73$S&_%(veP9euMQSo zmojGVIfBRTB-_1JzbSpGDl_5uB>lQeXMU8uyrlDvTj3U~LLy^N;k*T34mFo*C@V6) zG~=BlsJ(!RMU?Z%3WrTkP71sbo8kNQNh!B}V~(3_!y9RVPd|L$H+#?27Jv6!ch--J znHi7&R?0;DdH&Ymuek=lcV08w)!d3ZY9Sek_k$Pf^W8BL)X3g`4(LV(Pgg&ebxsLQ E0B`_f;Q#;t literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/entity/leggings/diamond.png b/packages/ui/src/assets/minecraft-armor/entity/leggings/diamond.png new file mode 100644 index 0000000000000000000000000000000000000000..a9d69e8b44a31f667ad1c4123b07a9daa88b3b1c GIT binary patch literal 272 zcmV+r0q_2aP)DBGn42eT$n&(Hr>!rvwXGSgYxD*) WX*-|3S7)360000HYaZfQqloFA+A9B|NsC0{_p+!zwG~q zsQ))?zwVHEu*u-^G^1OkJWW7p#*!evUHYaZfQlbGqA+A9B|Ns9_pFX{L^XA2i z7Y`jew0-;bygVT@paf${kY6x^!?PP{Ku(XRi(^Q|t+y983LZA#V7;(r!kP(!MHx*F z>xB&*1nU0_7L`4>PS$-8f8kF!L%(%--^Q4RdtwS37Kj(J;w{OBm pUp+Z914$dhry>cfH=aN2TJILEC!||6Hyr3L22WQ%mvv4FO#sNdUfuuz literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/entity/leggings/leather.png b/packages/ui/src/assets/minecraft-armor/entity/leggings/leather.png new file mode 100644 index 0000000000000000000000000000000000000000..527b5269f84c24dc99756fd3c960cbc3317dc951 GIT binary patch literal 254 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH!VDw>HYaZfQlbGqA+A9B;r;v9uU|iU^yt>D zTUV`GHGls6x?Rd%Kncc@AirP+hi5m^fSl=`E{-7;x86?J$$Qv<$8~d)u@ggPV?)e= zW!0UFegz!=r_AD|exOI}gZ_;VjQ=a{>@xMf#l%s^_+lqR)&Kbl+jU}dG!&JYZ<~oO z3SOVU#KS6iWv0UypEnXY+&=3BCg(aHYaZfQlbGqA+A8W*h4eRK{LrlA<9A_ z$V9nH978H@z3sWk#pKAte0z!K z-T(g+HM``R3SO@^7g)x>TUZ%rx9cR; zn^U@V=JR`;cd}hKEqC=}ox9m*uT{m}J^bt2KR%`kmVz5G3YUPcWAJqKb6Mw<&;$T8 CtzH=b literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/amethyst_shard.png b/packages/ui/src/assets/minecraft-armor/item/amethyst_shard.png new file mode 100644 index 0000000000000000000000000000000000000000..caca1e30906bf478c3a38cf993cadcc1c969db1c GIT binary patch literal 183 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPGa2=EDU1=9cjUj29a+4%*ZH}`$+ z%{r6szdFRSOUfp=4JgN0666=m;PC858j$1b>EaktaVz-%-;GO`6na!tOFJ4SFS2ww z;A$bGlkj8?SK1PZnaji)SYmjBFFP`=T&R`6*tThczy=2A%195!mWgtFhST<%FlGfy ctvbqZa1!4d7xvF}K!X`PUHx3vIVCg!0Dw3@CjbBd literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/bolt_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/bolt_armor_trim_smithing_template.png new file mode 100644 index 0000000000000000000000000000000000000000..e741cf958b5d7e33db8ee397f993608ff48c7bf7 GIT binary patch literal 225 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbL!N`Oy@E0Avf@x%M%$;VAO*Q%or zW&0@1+Oa9geR-6_1W&_eJM~00k@>pw9f0Z>OM?7@862M7NCR@RJzX3_DsK6nauhnC zz~LNtbP3Cz|Fza)vZwX>@4xcc`oFMe5x2yGPdU63Sf*4i5MFROd7W$udu&jS!}9Wz zN=1$qzD$`jMQh?u*_107JLMHJF{Z^hHa=mQm(HHsc5R}zhUThTO)kB&k-u3bm$Nj! TPH}w#w3fls)z4*}Q$iB}<|<6` literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/chainmail_boots.png b/packages/ui/src/assets/minecraft-armor/item/chainmail_boots.png new file mode 100644 index 0000000000000000000000000000000000000000..d7d832aae5a57bfc467f84ad4727874002abfc41 GIT binary patch literal 149 zcmeAS@N?(olHy`uVBq!ia0vp^0wBx*Bp9q_EZ7UAm`Z~Df*BafCZDwc^87qq978H@ z%{}kP%izf2eDT%~rGPMd52wRBZu)XGH$1$sM1Svs==b&dLARa7zqeakg6#Kn L^>bP0l+XkKFt<1e literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/chainmail_helmet.png b/packages/ui/src/assets/minecraft-armor/item/chainmail_helmet.png new file mode 100644 index 0000000000000000000000000000000000000000..d07206acbcdecb9625604c095d94758e59581a98 GIT binary patch literal 135 zcmeAS@N?(olHy`uVBq!ia0vp^0wBx*Bp9q_EZ7UAm`Z~Df*BafCZDwc@*F%}978H@ zB`b)(Xkrwqzs0L?W}3^R|L%ep_ZB{8i|AExV!gm}@{Kfy=&nf$4NI7m8dBO+Borc< f(>%S50yr5M)WoGIp7k q6M2Okb~qbJ>rChsU|{0#oZ`-yYN=%t{m|?@$U09~KbLh*2~7YSrY`CL literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/coast_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/coast_armor_trim_smithing_template.png new file mode 100644 index 0000000000000000000000000000000000000000..b5c23c7adf5c33b39e8eddbfbbab93252ce695c0 GIT binary patch literal 267 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbL!S%6Q7E0Avf@#EmZgR9rA@;-TT z+JZH`lY1(gdKG5v$jUEEN=^$6k8yDHv$nQ2FmO>(Gm@1xk&;&Ne6V&FP$y$akY6x^ z!?PP{AY(jT978H@nf5Vq9Z=w4h+ekrS=RsmZ_d2vc);g%`rw&IW%}1u4b(qL&xmAs zHK$I7*=*7Kwr>jE8le~7HVev6(^#e5Y+$c(hwZ|xed#7s+yv@VJkps?X|4;j;yyTM zXM4E8;d@)>vD@v}$%)#VGGl>QPq;tBhmEy+R;#=#bgO7P&DHlmDUI!sbEoEepmP{J MUHx3vIVCg!0K5=o%m4rY literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/copper_boots.png b/packages/ui/src/assets/minecraft-armor/item/copper_boots.png new file mode 100644 index 0000000000000000000000000000000000000000..11c0cb45b1a0609f89ee958dbca9dedc5c54bd18 GIT binary patch literal 171 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`MV>B>Ar_~T6C~;s;@qFv^E?(l zyz=M!sh9u%Ka=z8y^8XW`4cR{m>s${NHPfRp7z>t4&w_h$43f=i+Bz!n~=i%?1$u} zJcdn5IcyC_Hk?endM9CJMSeJK?61!&LPfdJ{MGU-%Rhy6q~LTNNnPtT hKlb!Ws|j=8uh)KH&v?)C-bs-044$rjF6*2UngCc|LW%$Y literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/copper_helmet.png b/packages/ui/src/assets/minecraft-armor/item/copper_helmet.png new file mode 100644 index 0000000000000000000000000000000000000000..fde1812ac1539e5d976ff5a5c0995524625191eb GIT binary patch literal 160 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`>7Fi*Ar_~T6C~y-#JNASH}l!@ z%(AZS|N5s@3m&j}AN}uV)bzje(x3FS%m4R>`!#3IU{ok4n~>H z@iUy`s@dhJnQNlxpyvcq%~%rT7tG-B>_!@p6YlBa7*cU->Ul$%CD? z{xN^SGkfFN|NieSlIn5H5!g5FHRCbfX~e~Ew8fA?tl&l50kyu%3}1e&_|pd@3621tdMIEIFr~3in`=&~9NU zxFR}f&8kVqERX1TuG)EYzjA`yBS)rBW*^V}o?UO?%d~3ewo)^oJq(_%elF{r5}E+T CmrkPq literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/diamond_boots.png b/packages/ui/src/assets/minecraft-armor/item/diamond_boots.png new file mode 100644 index 0000000000000000000000000000000000000000..820291b9556ae35b7268f8b3e4f830d985806bd6 GIT binary patch literal 175 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbL!M1W6-E0E?;Rp7HX^Ll$z;poFVdQ&MBb@0HS?8WdHyG literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/diamond_chestplate.png b/packages/ui/src/assets/minecraft-armor/item/diamond_chestplate.png new file mode 100644 index 0000000000000000000000000000000000000000..0e746053e318d9d7c401db0dc6edfbe4f6e75bf7 GIT binary patch literal 189 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbL!M1W6-E0F&G|Np|@FTCE~R5-d- zYSnT+dovDI1);O0VL&;?k|4ie28U-i(tw;`PZ!6Kid((s9r+p@I9ME)u>bhqf7VJZ zyNCVvkt@AF0@u%Tm+yRNXXPk+(Tm4nRdmC`BNJyfWF7RWbUJ$^Yfs`KUbfr^te@@$ j?9ysZ%|Cth`+9YwRZIc5#l+VFjc4$5^>bP0l+XkKw68=d literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/diamond_helmet.png b/packages/ui/src/assets/minecraft-armor/item/diamond_helmet.png new file mode 100644 index 0000000000000000000000000000000000000000..0ad541531047cf1c315943a65cdfea387b93eecd GIT binary patch literal 165 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPGa2=EDU1=9cj|6lm~h1c7g3P-m} zty<1!Z^ogjAau4g3@FD~666=m;PC858jxe{>EaktaVz-%_l--JgbwtGBtI_L&@g$z z#%#up#3eb8n9?McFKus-OndrN)xb@2#zVeTgC)iWQ;Y>z8Q#6*-Ix9FqbSfA22WQ% Jmvv4FO#sSxIlTY? literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/diamond_leggings.png b/packages/ui/src/assets/minecraft-armor/item/diamond_leggings.png new file mode 100644 index 0000000000000000000000000000000000000000..53440022bfec4a8193ea36856a304846ac8b123c GIT binary patch literal 177 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPGa2=EDU1=9cj|6lm~h1c7g3P-m} zty<1!Z^ogjAau4g3@FD~666=m;PC858j$1e>EaktaVz-%+a(}yG?~3wS9zhDN3XE)M-oGecl$B>F!zGnjY zSQL4fE<}1SeDU}F?!eh+J{;$MxKr_KjWXMZrI9QO`n33j-Ok^ z?O@*OeYVRoFH zRr&LOJG;{p&O_1S7V~cQ#BJY~5^40>Y*O|bHJ%$2Dpy^0IkSknWlgW~^_SPSEx-2a z@Rmb;F|{6M0{@+vjGZbO4TSDKo-*Of!X+BJ2lC>b6KEm44$rjF6*2U FngB?)QC$E4 literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/gold_ingot.png b/packages/ui/src/assets/minecraft-armor/item/gold_ingot.png new file mode 100644 index 0000000000000000000000000000000000000000..d623b0ca51808dfd81bf96cf109e2970088e7e61 GIT binary patch literal 187 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbL!bbwEYE0F#V1b-j={Tl!4n%B#X zqIaeVZ%Pp?)nK~Rd2t<3nz1CvFPOpM*^M+H$IsKnF{I+w)box)4F)_;fj%A&|EZU$ ztqq(0clU9=IVyiugXB|}t3K;u6Zt7Nu}z|1iJjLldDXE9=JhKBmn`(klWJJiu4Vpw f(^b!J2aihImoYW;F;s5?n#|zo>gTe~DWM4fTw6np literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/golden_boots.png b/packages/ui/src/assets/minecraft-armor/item/golden_boots.png new file mode 100644 index 0000000000000000000000000000000000000000..982ca4d1c2bbb15ea6566f99b16582e5e999467d GIT binary patch literal 169 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`1)eUBAr_~T6C~;uXokJ9=XorA zc%>xEzkm9_^Vc8$K7WEm7_&py21y2?>aTipESN8dHXU(Ha*=HCRXoYI=Z=QgF2)mJ z>BDO6p`Yhw9+F^dI8q?Rw3#u|coUO^d@OGlqk-!WCeBFSdk18q7#Q}boSytOIx-h% OH-o3EpUXO@geCxn^fkfop_#ql) iEj#DwONX+3`_~z*VhXVRp?L^sJcFmJpUXO@geCyFOGHTk literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/golden_helmet.png b/packages/ui/src/assets/minecraft-armor/item/golden_helmet.png new file mode 100644 index 0000000000000000000000000000000000000000..d56a41b8ecdc69747bcf8c852f17bf3068f6cf2b GIT binary patch literal 158 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`sh%#5Ar_~T6C~y>&q{w|NE=N`;7pfBgG=jkERtr~hSV6yP;xQ1QDUFo99ws6y|HS&1_=m>nc; zzE|*7xZP>Su#8hSIENuHkzsSI4s$|VLNJSQOQJ{u1A}SDn#0Ah4tIeTGI+ZBxvX<{9 literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/host_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/host_armor_trim_smithing_template.png new file mode 100644 index 0000000000000000000000000000000000000000..8b472b3d1df23e6f80843cf5b34d2c8a70a75287 GIT binary patch literal 203 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbL!bbwEYE08u-R`xX1j&L`xiuLh6 zd2&iwlESPVilU-3e!l|gV=M{s3ubV5b|VeQiSu-E45_#k+kKGthyl;xq`<;G|BtjE zI3}uFFS6q5jWvlj_a|STsdmj_nf?)<8#&TTcC9PBJ)cqYmDOXH5{5Z@bC0q-V))12 x)Z9DoxZ!~xT#PH4}1FHpYlm%oWatnJJH z|K|U^|GB3v*4)7;*0QlbF}Q$d-2(|T*5e%7GumPqoj4XINKI*1o68$Mp*Mi(D&xd9 qE{4nwju=J{uCsxY0t|N@5oFM`tx8_Vv&|4_1B0ilpUXO@geCwivMvn( literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/iron_chestplate.png b/packages/ui/src/assets/minecraft-armor/item/iron_chestplate.png new file mode 100644 index 0000000000000000000000000000000000000000..7d59690c3dc6ca56cc8d5d8d7d9d3d2f594eb7b9 GIT binary patch literal 161 zcmeAS@N?(olHy`uVBq!ia0vp^0wBx*Bp9q_EZ7UAm`Z~Df*BafCZDwc@}fLl978H@ z_4Zo|HZTY}1FVMd5x4$7`>GHq! zY^^u{tNu@(xbNeCdx2&VpTi-GxJC9cPG)n=;p%gI!|s)u9Lg}M$?*`E!Ci(+%?&sH hHVRB)*~l))z%WNGv;1G;UTdJi44$rjF6*2UngBjQE~5Yd literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/iron_ingot.png b/packages/ui/src/assets/minecraft-armor/item/iron_ingot.png new file mode 100644 index 0000000000000000000000000000000000000000..9e1709ecfa401bb177dc276af843146f3d497db2 GIT binary patch literal 157 zcmeAS@N?(olHy`uVBq!ia0vp^0wBx*Bp9q_EZ7UAm`Z~Df*BafCZDwc^1?h_978H@ zO+9ZYc))gTe~DWM4f DAeA*Y literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/iron_leggings.png b/packages/ui/src/assets/minecraft-armor/item/iron_leggings.png new file mode 100644 index 0000000000000000000000000000000000000000..bbff7c89b09d909603d7434d2245582294133840 GIT binary patch literal 151 zcmeAS@N?(olHy`uVBq!ia0vp^0wBx*Bp9q_EZ7UAm`Z~Df*BafCZDwc@&Y_v978H@ z^`1Y-dq9DQ6CfC$8Grr x3kBg$;hro14!9j+KhjXjZm3|!P#kCbi{W{f*72}!(PkhUJzf1=);T3K0RRcbGdKVM literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/lapis_lazuli.png b/packages/ui/src/assets/minecraft-armor/item/lapis_lazuli.png new file mode 100644 index 0000000000000000000000000000000000000000..f70fa8d7bee39269b04cccf8bcb2650b4a050cf0 GIT binary patch literal 181 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbL!M1W6-E08Xk{wk{JkxATPncx*t zw*5j5-K;9feZ2Em0p%D=g8YIR9G=}s19H4QT^vIyZuRzUWISRZa46@(!GF^AU&}7J z&b@Nr*O@au^ZQb&?%q=Svdi@YXY`B+b&uIUS`0s(-rm9bU%>Lmx@(>D^}AeemuB4l bI#>R_8skR(n+Z%nV;MYM{an^LB{Ts5Y2iYf literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/leather_default_boots.png b/packages/ui/src/assets/minecraft-armor/item/leather_default_boots.png new file mode 100644 index 0000000000000000000000000000000000000000..19683e5c8497fd7be617df76c8b0dcbcaac86bd2 GIT binary patch literal 200 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9G0FF=@aYjsdIP_W9= z#WBR9H#tF~?tn|+7ki$^!iQJx)I7n`BkEfDUsy=}W&=}s>?0lxhhPpN_D;c62S%}0 z6={L=wQjo@)jYboY`z%qFmxsw%dAVtnqw-_EYdbhD@lSui23BfD+?r&n6@%T8gF8f skdNi%w>_nNoP|SnS{&Dp|2a;V|18?G zIq>!5u%t~4a5v?SitXL`2Kwl^l!|7qEW zG=_BFmJ10p7;Giwe(BdAj+`7jelf*L}xX4%>ut8#))gs4o*s6XCh!v5`^k wNQ literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/leather_default_leggings.png b/packages/ui/src/assets/minecraft-armor/item/leather_default_leggings.png new file mode 100644 index 0000000000000000000000000000000000000000..071f52a0e57ae580631eeb6d79b969b0ec0b15cf GIT binary patch literal 209 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9G0FF=@aYjsdIP_Wt4 z#WBR9H#tF~m~D}} zM<9J|o8Wx~GqXaT1suGEIcy2S$FmrW)Ku&&7#L>mUHG;9i9|loISihzelF{r5}E)B CRYIKr literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/netherite_boots.png b/packages/ui/src/assets/minecraft-armor/item/netherite_boots.png new file mode 100644 index 0000000000000000000000000000000000000000..e5ef07373467b8711132f5ef36401971402b4fbb GIT binary patch literal 171 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`MV>B>Ar-fhCDINAI6vOswdYWM z%qIoQ2~un|T&FgjI?(oDlCZeQk$i`o2pvD>8r#}V;{$ik$t@C4gTe~DWM4f-kdmG literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/netherite_chestplate.png b/packages/ui/src/assets/minecraft-armor/item/netherite_chestplate.png new file mode 100644 index 0000000000000000000000000000000000000000..5181bacd5064f9648a89dd26b0b0d31e855b1e36 GIT binary patch literal 216 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPGa3-AeX1=3|%WwBwgzMj7BcJ}6` z=BB#3D)I{IGP2@=f~srlf`KX+OM?7@862M7NCR>*JY5_^DsCkoV0rWODG$%nr>s10 z)E@HiEIsGYwz`tpy^v#q;Io*Ed6T$OG}t=DS8*{)UUm{`jWJd?FetE+)LhMw$#yQl zbzy+PVO5hWtY;WjG8$i+6LQIcr)}oSMB{_o3eU~4WIS2Ku<46L!vVfGQ9wHxJYD@< J);T3K0RXMcL&yLC literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/netherite_helmet.png b/packages/ui/src/assets/minecraft-armor/item/netherite_helmet.png new file mode 100644 index 0000000000000000000000000000000000000000..ca34f3be4bb13bc003d75555ea17fb2695b68404 GIT binary patch literal 161 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`8J;eVAr-fhC6+n3#yzw*^VyPC zZvFfJzIppJZiIw?-@jwQgZf3L(^IGZ?dR=om1GcVmu@=G5HeeER^ki|=7hSo9SjH6 zPclq!n!)XmGl|E+jHRuU+hc|94IYM0VOik_(Stlm608yo3~W(V1~((l+yL6i;OXk; Jvd$@?2>?NUHFN*~ literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/netherite_ingot.png b/packages/ui/src/assets/minecraft-armor/item/netherite_ingot.png new file mode 100644 index 0000000000000000000000000000000000000000..9842303ea1d5c78ce8e93ab1dcddb54de3474779 GIT binary patch literal 208 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbL!Vt`MGE08WOERG6~^7ZufaP{zU zbhfs%wlOj?)YMX!krfmaoS--F6;KglNswPKgTu2MX+Tb_r;B4q#jUC5ErkvkaIiQY z*{Jj9|3N(!*0PLEavl@xgTIEleo!sn9K0?h^z0m^rGjGHPDlBzjM%(6!g))`vmja3 znZgV*6A!eo$Q*Vl>*f&tZB_kw%9E?QZ~lFJ#M~;xEcy9vX*JLy22WQ%mvv4FO#nj$ BL!STu literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/netherite_leggings.png b/packages/ui/src/assets/minecraft-armor/item/netherite_leggings.png new file mode 100644 index 0000000000000000000000000000000000000000..af45363b3631abf9349a8c06d81f8e695803f45e GIT binary patch literal 176 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`<(@8%Ar_~T6C_F;T;m?v>*?re z{FuMs*^Lc1{-;K!Ch#ORZ&<%vUt;#FSC7nD(ik?WrM$^Y_n*UfLds!=4s$}6qr{Vv zMZ66++tvsiQ9H>{(z@eFW-^1$gA02bd}Wq1zWB&%+>l_^&}w1EyrDDMltEHhNM444 ZVam(J%#k^|@jxdqc)I$ztaD0e0ssolJ$V2C literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/quartz.png b/packages/ui/src/assets/minecraft-armor/item/quartz.png new file mode 100644 index 0000000000000000000000000000000000000000..45854571c5b7a103f3e926dfcfd3d3d691aeb067 GIT binary patch literal 223 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbL!T!2rAE0F&F_0y}T_nzLrcK6D$ zE2nmCThh0A!Nkt$;{4bsalNo8qf{naJkV}2#RWSk02C*b_=?2GH4>%1oCf|=_{}xyM_b~g)!_398 Tr{5d_TFv0;>gTe~DWM4fRjgI7 literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/raiser_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/raiser_armor_trim_smithing_template.png new file mode 100644 index 0000000000000000000000000000000000000000..5f0c8806d548cd4daa9c43ab0e8a2dadddb3dfb5 GIT binary patch literal 215 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbL!Vt`MGE0Avf@x%M%$th(?3bS@p z#rm*UPK|Ik_cYWtRaRCM6}{^Br5mV-u_VYZn8D%MjWi%9#nZ(xq~ccSX-B@p20Y9M zue@s8^6&qHrVYUsd)_+iy5?}*a)o(dXzR1fs{$Ej&30ip`EE-i|4mNTP3Z@kk`&IC zIPkbVbhvfu_+R@CMT>6pYy9?lnPXLOx=~f}{k<#K7$eKSzmjL{V(~VeW#9<3kHOQ` K&t;ucLK6V>3{W8e literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/redstone.png b/packages/ui/src/assets/minecraft-armor/item/redstone.png new file mode 100644 index 0000000000000000000000000000000000000000..6d1df15a49a625930b2a6ac57a50897e9c77c4c5 GIT binary patch literal 179 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPGa2=EDU1=9Z+7*_E!7BMiyuroNa zGMKQj>M}4aOkXh(D92b5nIYrv2al zBa3t0n+qwnT^;)D`HCL{raJryW!qr&$m`RlQ?eSWH&5w27S>%JGP5l&|HIt{=QpbN tEx2)YUgr+if~|KJwQrj~Kz-<4D-& zpE=*&F&3_U#ojHrBsyY-royJm8%!zl+Y=)tFG;_ds;cl@bn&aT^Z5F99SsdV8-H1d W`6b)BtARj^89ZJ6T-G@yGywqj2}}b3 literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/sentry_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/sentry_armor_trim_smithing_template.png new file mode 100644 index 0000000000000000000000000000000000000000..871898d11a171238efbb7b3f3662a9d26f1cfaaa GIT binary patch literal 207 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbL!T!2rAE09ij@xuG$$-bUCg;_hw zO0vRYLOonOO^qy-_!@p6YuHb7*cU7^mHKK0R;}0K<+mS zzV9zkPMY=Z{+TdGX6!|GRr{48T*+sqn!u=?k`DePQ@gJa744$rjF6*2UngGw+ BO{D+; literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/shaper_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/shaper_armor_trim_smithing_template.png new file mode 100644 index 0000000000000000000000000000000000000000..c07cba846135ab2b1444ef3ec9e62cf82b9d5602 GIT binary patch literal 208 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbL!T!2rAE09ij@xuG$$th(?3bS@p z#rj0Jn|m5+n<^_Sii#RNQu+f_!B`UH7tG-B>_!?$xu=U`NX4zt?t{EX40sOD+Og>E zUvWE*kBS=gDodU#eB^oj(U;4$Kf|LV%-{AbJNE`?7lWs(pUXO@geCy+ CcTE=n literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/silence_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/silence_armor_trim_smithing_template.png new file mode 100644 index 0000000000000000000000000000000000000000..ea30a23778a1e4c2b25e7c4c48c623aa859d9809 GIT binary patch literal 254 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbL!aez;VE0Avf@x%M%NrhQE^78W7 zE2l<8L^y=!c>DM}IyzdIn;RM!FgpgQsHm`N83{@&@(PJ-mj9Rz)W%p6rPj+V`~recZtw$DFqQ=Q1v5B2yO9RuBzU?whE&`NJ>@9WV8G)PxY%LV z@Aw-FeczsVxBp?3a_QGyAJv-}Ov|_zEWfCdxy|VG+&%IWlzgX{YVtzM!pZzSLT@0SCelF{r5}E); Cu}Ay> literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/spire_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/spire_armor_trim_smithing_template.png new file mode 100644 index 0000000000000000000000000000000000000000..ddd74bfac112c367c74a863c0611c96dca0075e2 GIT binary patch literal 209 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbL!Vt`MGE0Eqbb(i;Wvi^rF0e^}ugzlnL> z#ocolrggmJJCHKN_d9z~Px^L`Mjo%Oto*8GXTh!cy%EecZwxJEfHpCBy85}Sb4q9e E0QV|Q2mk;8 literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/tide_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/tide_armor_trim_smithing_template.png new file mode 100644 index 0000000000000000000000000000000000000000..290b69c7fd60ab9f1ae80c1e62bf87a1071d7055 GIT binary patch literal 221 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbL!N`Oy@E09ij@nY)v<4s5Rc%M9( zw{u<8`Xz}=XNAt{SD3ZKu`$c2AnNEJ<#eDr#*!evU5$k^#CcxJ*ymp)F0zo8Yo|C#5u0j*;2boFyt=akR{01H<% AZ2$lO literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/vex_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/vex_armor_trim_smithing_template.png new file mode 100644 index 0000000000000000000000000000000000000000..7d2ee01ea817f98c10c3fef92d20c02db1910fbe GIT binary patch literal 213 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbL!T!2rAE09ij@xuG$$-bUCg;_hw zO0vRYLOonOO^qy-_!@plj`Z>7*cU7^t3nM5d#jEz|Sj> z?fc*AuHmz0!|LT}w(L>Gmp`gDacud&mV;qS`x7R`(7Epp@h*MPYO=KaeVfn@%MH7w zSu*|JN=}K1*w;MCMtAeO_5;C}UvF7q(mZY1tbHa0FXt?;zjU1W>a>lf^MDpIc)I$z JtaD0e0svdkQ5gUL literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/ward_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/ward_armor_trim_smithing_template.png new file mode 100644 index 0000000000000000000000000000000000000000..57d2cea9237c9fa33180f2aa6da891fd2f284a02 GIT binary patch literal 218 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbL!Vt`MGE0Avf@x%M%NrhQE^78W7 zE2l<8M0or7J32aAn47DpsQAyj!wpo#SQ6wH%;50sMjDWl?&;zfQgJKvj3*zn0f+O& zDF@AV{%_QFnX={0zsW~uG4H737yRh167{M~jdMb2N1mLD#@jr`w27WJ4qv=hu?Z&f z%?ds1@av249mdp|V&9oPzniRh#dN3fa`tu)V~Na5FWuSY-rD|U6-rmdKI;Vst0HNnfH2?qr literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/wayfinder_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/wayfinder_armor_trim_smithing_template.png new file mode 100644 index 0000000000000000000000000000000000000000..cd59380659189229bc373125635121be6fa7af48 GIT binary patch literal 216 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbL!Vt`MGE0Avf@x%M%$th(?3bS@p z#rm*UPK|Ik_cYWtRaRCM6}{^Br5mV-u_VYZn8D%MjWi%9)zif>q~ccSsX(E_1{}Jm+|9+p?ss!r}V4^s1+-3oW;p9Q_h-_Tlzbt9I}IvVp}+-^{HaXd#2A LtDnm{r-UW|FI`X6 literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/item/wild_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/wild_armor_trim_smithing_template.png new file mode 100644 index 0000000000000000000000000000000000000000..9386839fb97f1a10c1df8e23a29250bd09006573 GIT binary patch literal 252 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbL!Zh%jSE0Avf@x%M%$wiA6b+mU> z^@kR>1}V(ik(ZmBROuKLYZ(+2CmOpaMO$9)nzXQ6%R}zG waA;A{E$KUDGfwZ3+2is=;jp)hO??f6(;`;2C&vF;fDU2sboFyt=akR{02_8*p8x;= literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/slot/boots.png b/packages/ui/src/assets/minecraft-armor/slot/boots.png new file mode 100644 index 0000000000000000000000000000000000000000..e7793ce21fb7579076b35cdbe086066b3ac07220 GIT binary patch literal 103 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!60wlNoGJgf6^gLZ0Ln>}1FHq+A%iqKy)@Gt+ z*&*U!VIbAZo_-*5Mh#1=W@A8uoC$~Ft|NjBIV)`?r>sqJ2I^$+boFyt=akR{0M<+! AR{#J2 literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/slot/chestplate.png b/packages/ui/src/assets/minecraft-armor/slot/chestplate.png new file mode 100644 index 0000000000000000000000000000000000000000..2891f4401bb494c759b2a1c0542b76cd4cc60c16 GIT binary patch literal 104 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!60wlNoGJgf6^gUf1Ln>}1FOcK-+s|dFvN5y4 zxNx@DP9_WH?Iww=9DK`6Y(*7V1ZjTuv;OXk;vd$@?2>|jl B8wvmb literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/slot/helmet.png b/packages/ui/src/assets/minecraft-armor/slot/helmet.png new file mode 100644 index 0000000000000000000000000000000000000000..f7d08aae1ed99d116d0b344c5cac81bd764d1519 GIT binary patch literal 108 zcmeAS@N?(olHy`uVBq!ia0vp^0wBx;Bp3=bcE|uJrjj7PUF! z$qwv`6devVPHbfCkeR?KD9FLB!>!5^(ZG_;FsY28SkTIUFelF{r5}E*o Cycq%j literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/slot/leggings.png b/packages/ui/src/assets/minecraft-armor/slot/leggings.png new file mode 100644 index 0000000000000000000000000000000000000000..cd97b293cef338508740ebc8baccc6e6f5f0aa59 GIT binary patch literal 93 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!60wlNoGJgf6R6JcALn>}1FOcK-+s|dV#L)DD q2jhvdIa~ry48j;1Pvj^m3o_`Hy6;?6ZEXou&*16m=d#Wzp$Py0{}~hj literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/bolt.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/bolt.png new file mode 100644 index 0000000000000000000000000000000000000000..e639166b19d8c293f26c957d72ad5146e34a98c2 GIT binary patch literal 265 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH!VDw>HYaZfQv3lvA+A9Bz<~n`7A$CJXh=v% zc$-=A1}MZ>666=m;PC858j!QX)5S5Q;?~7SzNlxMU3+LKe) ze^uU{@pE_dFWWxnP4i~2nRlIwm&bueNV3oQ)ZTP9B{3Q1gP+@+4{qQ%$g-ig^y$JK zlKQFh9jux~4s4vX;(^T#jji?1)Mo#xXDa_K#K0MxEgf;@*V~)=yK4DPOkRHBFwo5m Mp00i_>zopr01$;~)Bpeg literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/coast.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/coast.png new file mode 100644 index 0000000000000000000000000000000000000000..da651f5f881c452d6e88e85c2a69d48da6377d33 GIT binary patch literal 166 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH0VEjOzbQrlDW;MjzhDN2vdL#HfV_B57srr_ zTW_yy@5{;jFHFq2PL3{BvzLP$ N;OXk;vd$@?2>=$uI!pin literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/dune.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/dune.png new file mode 100644 index 0000000000000000000000000000000000000000..185b347d522ba4ee017132c9b5c8f02c52432a1c GIT binary patch literal 218 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH#0(_wf1V}|q<8{+LR^9Lg9i@|95}E52)5;e zq!~+s{DK)Ap4~_Ta%wzX978H@B}*J}FlF^!(9qxLXv};rwJR^7?$(Azemw(e7aQvX zKB~VhFR(@!uySMzI*3k42x!y}OL(A@;F0j4h4V#(!HgF^)(05AH!!esC^8-tV_PVe z!njnKhf8B!z=MPh6B;*(&yT9FK}9=eth|$ByUXMxqceOb eru{kpu!dp2k;%e4x_c*s+~eu$=d#Wzp$Pzm#6w5` literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/flow.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/flow.png new file mode 100644 index 0000000000000000000000000000000000000000..a299e31b611a42f90c77986fee6595430117b8ba GIT binary patch literal 247 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH#0(_wf1V}|q<8{+LR^9Lg9i@|95}E52)5;e zq!~+s{DK)Ap4~_Ta%OtEIEGZ*N;a_)ff>h6JYe`Foz2j~Z?rS@mDK7D r$_3I(BgTe~DWM4f3^ZD% literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/host.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/host.png new file mode 100644 index 0000000000000000000000000000000000000000..44b322628ac31370ad35965e0e3bc718090fad88 GIT binary patch literal 177 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH0VEjOzbQrlDW;MjzhDN2vdL#HfV?bE7srr_ zTW_ZvW~G*@L?Xz>Jwt;Wg@ zL4G>l3hO;?E%XMalRnqUx;gr?-brY}gt+;G; Zp2?NdY2iA4FENl?JYD@<);T3K0RSV#JJbLG literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/raiser.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/raiser.png new file mode 100644 index 0000000000000000000000000000000000000000..9436d628df4cc6dbdc72f23a48810e3eaf98ace9 GIT binary patch literal 192 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH0VEjOzbQrlDW;MjzhDN2vdL#HfV@gi7srr_ zTW_x%aF0b){OHg8Xyi!1vOm>|!>s%hCb|3V5An*x*P7On+d zx{e<1Ot~dyJA0mo_SrAEGwGar@{~(kj%hHikz+OxULU1Z{HW+v@9L_nfw~Mrm8(lX o?t0A3@mh~DC0g-*#R>f{OlLSv6Q_41dVrkf>FVdQ&MBb@0JPCTzyJUM literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/rib.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/rib.png new file mode 100644 index 0000000000000000000000000000000000000000..1d79054cda2a48d9cc2883b56f725dfc10a3c306 GIT binary patch literal 163 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH#0(_wf1V}|q<8{+LR^7#LqkJCLV|;X!xg`f z4M09)NswPKgTu2MX+Vyvr;B4q#jRus9fe<+%)wtSvzXn{`m{Xn0r&5h2TmAFIcUeu z=EkHYBwGy&>%;y9p~p^=tlj+)a)@fCe&ny85}S Ib4q9e0Gt^#^8f$< literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/sentry.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/sentry.png new file mode 100644 index 0000000000000000000000000000000000000000..b93170f3b1279e6ba1f9954da8e662a68946fc23 GIT binary patch literal 170 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH0VEjOzbQrlDW;MjzhDN2vdL#HfV^Z+7srr_ zTW_aqXwaCF8`)mv-c2r zO&*pTZ~XqdpoC%5>_>rNnNwVq7y@QY-_tkQ=$zMv#6L=FCeG_)*w|YCCqX=9xxv9l S_pPfyZt!&Vb6Mw<&;$T4#z1@k literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/silence.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/silence.png new file mode 100644 index 0000000000000000000000000000000000000000..6c6d471399dc05e2c94ad7d5c9b5a7f8dff30f55 GIT binary patch literal 401 zcmV;C0dD?@P)K%Kp-F>0001TLj)}V0004WQchCUxKnit}elP{5pu|m%cLL(tQNmdILNT5Qy5;Kx9pRBcj zmbaF3m>`pITM7*itakC|)aH>`JQ59$RKq8>H3Kjs+YG>PWJV*+>j1Kv%nHl%DOX1xaFFNRk}`IE98Lkd$1tpy7~AYk}NC8{X2Z z7G`9yW+1LW_0{j8oYG~gDsSIE>e4~52yX8l6 v>MQNuw6raI!;v<8XYb7kw%z~zc0B$8BclgPh@d$800000NkvXXu0mjf^nRrY literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/snout.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/snout.png new file mode 100644 index 0000000000000000000000000000000000000000..4eb5fa1efafa964824250dbede60485af14de34d GIT binary patch literal 195 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH0VEjOzbQrlDW;MjzhDN2vdL#HfV>({7srr_ zTW>EP-} zYIbEuapiF+nq+_Tmvc+l~Edd|WduG(K<` zF;C^%v~*U1`io!6;VrR}%Zvh2J-*tk5Rexa;124at}a;rE1dUJNCKDHod~vnwyI@m srE3)#_kHuf)9@~DWi-F#$5|EZ^4FVdQ&MBb@0R4eOA^-pY literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/vex.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/vex.png new file mode 100644 index 0000000000000000000000000000000000000000..7560fcf398707745a8dd337e4f0d5bc3f1945455 GIT binary patch literal 187 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH0VEjOzbQrlDW;MjzhDN2vdL#HfV>h<7srr_ zTW>FHEP=wcBmpM~v{)LQ@XR|=GP`s5VI9QRLQTA~sj8Y#q? zq81`%q^9C_OZoTIl4sSPeYQ()JpNVA9qX{MOLp~@jSFnfoiUiWcZt4<#&>?5W9&yZ z^CZg}30ey@&QK28HYaZfQv3lvA+A9B!Gi|}4jfppU_nDe z16Ll~MW7I4NswPKgTu2MX+Tbfr;B4q#jUp&Hu4@e;BmQlc_OpGyaz1$4jhsV4&Ux; z21q(^zB!in=a#=&`*Fu>PkFR# xcxvymugkCc%(L)~^PP7@KTLUrpn2anM$R^l>L0Z=H-UCCc)I$ztaD0e0ss`hO2PmD literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/wild.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/wild.png new file mode 100644 index 0000000000000000000000000000000000000000..39fbdb6d2a610345db36a130ac4e7a51b042439c GIT binary patch literal 207 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH!3-p)I`?e@QbGYfA+A9B!Gi|}4jfppU_nDe zLqbBr`$KCafuf8hL4Lsu4$p3+0Xf;8E{-7;x87d1=4&wEVGj6T^j}+9l!cjFH>iD; zzvQMHvp43xUU5UnfoalQr}ibUu10v~9N;?^X>{#yEW^LIf>Oy0KUItcmb&bG_w~yY z)knKyF7IiRoWK8~`;rgcEh{}tICtF?x_xuysV|N4lA?@)@~6E)ZeZ|q^>bP0l+XkK DkZDh9 literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/bolt.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/bolt.png new file mode 100644 index 0000000000000000000000000000000000000000..dd6839fd12bea31ae5d1131b358bae8a3623afbf GIT binary patch literal 166 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH0VEjOzbQrlDW;MjzhDN2vdL#HfV_B57srr_ zTW=?9PACseUodF4bU{umw-KPMaM6Y{+xipr+83F~!k&!E@FQ1ulz| zb`@ZFboam!|d-Os!3a@Ptv3{ttgVDHNsnP$~V^NR; NJYD@<);T3K0RVP@HfjI> literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/coast.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/coast.png new file mode 100644 index 0000000000000000000000000000000000000000..fdb7895a4e2fa3dd3361603497a36ee96887b466 GIT binary patch literal 134 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH0VEjOzbQrlDW;MjzhDN2vdL#HfINFo7srr_ zTW>Eq@*Xe{X-ZrYc4~6dlz6V7X=e{!3YOki@PKF4s#U8UgqSMU8pgUDYEuy0mv{cg gi}`zQIu@Og=fAN@zA>_DQ#8l|Pgg&ebxsLQ02LiB1ONa4 literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/dune.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/dune.png new file mode 100644 index 0000000000000000000000000000000000000000..34445c8f32bca5a83f8f21c46ce40b58cff6a77c GIT binary patch literal 142 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH#0(_wf1V}|q<8{+LR^9Lg9i@|95}E52)5;e zq!~+s{DK)Ap4~_Tatu6O978H@B}=fDFszq1_^_}mB4fXU=!4r1%DXLvFR+BDNQ5X) k_;Fc%L+tGX?at;53Ln{z$!bn61L|e)boFyt=akR{04qo>CIA2c literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/eye.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/eye.png new file mode 100644 index 0000000000000000000000000000000000000000..71469bea5347111420e2f346adc2ab34d55c910f GIT binary patch literal 141 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH0VEjOzbQrlDW;MjzhDN2vdL#HfIK%(7srr_ zTW>FJ6l`!1a0>KVJ|&)OX^y7ay^L3FeEN!PA)%rCIVTi6eJ=R<_6DF!92w6UDV$) m(bYmkA|mjOkf7p08-}%Y?3$h=4b;RO9z9~1 maFKz9laG&+m)E3Nn<3<^^uoKFCWZhFWbkzLb6Mw<&;$VdRV1(g literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/raiser.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/raiser.png new file mode 100644 index 0000000000000000000000000000000000000000..d5757933d8c439375f00e84ea58185b124549277 GIT binary patch literal 138 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH0VEjOzbQrlDW;MjzhDN2vdL#HfIMeU7srr_ zTW>EI@*W5fI2_Qpj6M{oS_bXX)KVUFiKSOzmbHC)x#OXI? kO=Q%W$N8k}M!erX2D#0mi6XKBmqC_zy85}Sb4q9e02(qYB>(^b literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/rib.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/rib.png new file mode 100644 index 0000000000000000000000000000000000000000..2b1a873ceab5fe9f5172f2708dc4d41be4a079cc GIT binary patch literal 119 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH!2%>XrflN}Qg)s$jv*Dd-d@_s%b>u+?C5q{ zL$39#JZGn#C@*XM%Sn3QCa8E$66sQE`S(BQADswa$Wn TDtl6ZCNX%r`njxgN@xNAKnW$B literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/sentry.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/sentry.png new file mode 100644 index 0000000000000000000000000000000000000000..edd6a28d8157a33e051d8aab054b8c87653aeef4 GIT binary patch literal 126 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH0VEjOzbQrlDW;MjzhDN2vdL#HfIM?g7srr_ zTgeivixWh8ngR_@9ANVLDxYA%kf^G{aC1o$GrOD(!>kPt4m7fI@;d0v?33s{zL1xp YONnpc`I{-eKqDADUHx3vIVCg!0AMB}>;M1& literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/shaper.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/shaper.png new file mode 100644 index 0000000000000000000000000000000000000000..1b5ac700a91bf6189a0c27c577189b40c9bcbd58 GIT binary patch literal 130 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH0VEjOzbQrlDW;MjzhDN2vdL#HfIMqY7srr_ zTW>FHe7ie3);GM(-KK4(@O7PyRpRWa#Yt^l brw+g8^PVN^D9q5^3o_i()z4*}Q$iB}+UhAu literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/silence.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/silence.png new file mode 100644 index 0000000000000000000000000000000000000000..8ecca4da4c0661ee8dacb3be2ef64ec5bb939359 GIT binary patch literal 243 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH!3-p)I`?e@QW60^A+A9Bz<~n`7A$CJXh=v% zaBy%?P*4C0stDd`1X7G8L4Lsu4$p3+0XdzXE{-7;x87bf>|-|IaJhK?OvC&7$5Y=} z1?};A8O|We@t|_v^Ph^%Di$hvH-k6}7(}#Kd+S}F`n@3 nXBN1B_wk$BS1zZXKmWtH+l@)P?y-0t&`}JYu6{1-oD!M;KlR-p|HEgD74O9OvB eILNHDm6>5jl9-}fAHy!7NerH@elF{r5}E)CxFb#g literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/spire.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/spire.png new file mode 100644 index 0000000000000000000000000000000000000000..e983ddd426220258c13ef6cf4abdcd71ea32c767 GIT binary patch literal 139 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH0VEjOzbQrlDW;MjzhDN2vdL#HfIJsZ7srr_ zTW>FIKg;)j=Jof?R%m{)y!R%^7Ef0{mvv4FO#tfnFZcie literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/tide.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/tide.png new file mode 100644 index 0000000000000000000000000000000000000000..c29985fe171ae4c87d0b328d8342e3f5ec0b627f GIT binary patch literal 149 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH0VEjOzbQrlDW;MjzhDN2vdL#HfIL4>7srr_ zTgeivixWh8ngSbGgcMkQE@1TGS;EMzz{2`~K_W1Psr8qnSU`>g^R@}O4r#(2?JPdV v2RRO^>8G9OUdqfbcYx_DgVF3XOGbvGMN$uUOlRu>n#|zo>gTe~DWM4fSL-Of literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/vex.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/vex.png new file mode 100644 index 0000000000000000000000000000000000000000..211d69ca43911f1b445418ad9b3d1a668f05f035 GIT binary patch literal 120 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH0VEjOzbQrlDW;MjzhDN2vdL#HfILG_7srr_ zTgeivixWh8ngSabrNja<4lsG~Ryb^AFJEOpe3l$jj@ahFq+m%RlRc5hR3F_F<$n-t(jrLg$>HQr-XUy ulW(06OO$NbalnCl=fww4lY}#Ba^ErvMaefBcie9WS?B5M=d#Wzp$PyQaWLfo literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/wild.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/wild.png new file mode 100644 index 0000000000000000000000000000000000000000..2c195185984abfc6ea78ce6ca6976fc171534565 GIT binary patch literal 137 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH0VEjOzbQrlDW;MjzhDN2vdL#HfIKHp7srr_ zTW>EI@*Xe{I2z#0JEgfOfaj_6%0)^YR;IhQSDGgG7`{vT;j(#UY<6g|>Qe`Ehe>;` jZoMwEH;`H2D}S@xy$ZQTz8=3lAUix={an^LB{Ts5H7+d9 literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/amethyst.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/amethyst.png new file mode 100644 index 0000000000000000000000000000000000000000..ddec960fab9a85eaf0ecbe5ad84c44e0ae9fe13f GIT binary patch literal 90 zcmeAS@N?(olHy`uVBq!ia0vp^96-#-!2~38k}5=il%l7LV+codvc=8+m0@Q#%A5~% oOndA1DmBe0Qp@Jxqnv9Deb+hOyqNnS5U7~J)78&qol`;+0LYXZUH||9 literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/copper.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/copper.png new file mode 100644 index 0000000000000000000000000000000000000000..42c2b614522ad9e1fde82cc51836b31dcc3c383a GIT binary patch literal 90 zcmeAS@N?(olHy`uVBq!ia0vp^96-#-!2~38k}5=il%l7LV+coda>bn~n^g~&PuuOc owAXE2r_-KoE&WXmYgx=0%5~XOz6VQx0V-zjboFyt=akR{0I>oYrvLx| literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/copper_darker.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/copper_darker.png new file mode 100644 index 0000000000000000000000000000000000000000..2009171129cf15403cdcbb64668198fb2d8a48b7 GIT binary patch literal 90 zcmeAS@N?(olHy`uVBq!ia0vp^96-#-!2~38k}5=il%l7LV@SoVmdKI;Vst0L?2I`Tzg` literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/diamond.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/diamond.png new file mode 100644 index 0000000000000000000000000000000000000000..044b4e2673f0596962e732c2052895ffb542ade6 GIT binary patch literal 90 zcmeAS@N?(olHy`uVBq!ia0vp^96-#-!2~38k}5=il%l7LV@SoVWV^rrwbS#Cgo{^~ od2g@Z#n&^-t>@FJ|E?+w?eQE(wBtAW0~IrPy85}Sb4q9e0LkncGXMYp literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/diamond_darker.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/diamond_darker.png new file mode 100644 index 0000000000000000000000000000000000000000..cbf5ed436c2d33bb6f9fa1d936c95991df92cfeb GIT binary patch literal 90 zcmeAS@N?(olHy`uVBq!ia0vp^96-#-!2~38k}5=il%l7LV+codvcTOv6SB`aO*FVdQ&MBb@0C@oyxBvhE literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/emerald.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/emerald.png new file mode 100644 index 0000000000000000000000000000000000000000..bc983abed8c35fb3d53006b708ad3b362e89ea0a GIT binary patch literal 90 zcmeAS@N?(olHy`uVBq!ia0vp^96-#-!2~38k}5=il%l7LV+coda>lQ1)81EfJYX_z n%o5$FqC$x*C$8GIZA%jqLy-g9w_VR>fD|)$y85}Sb4q9e0P}Pk=l}o! literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/iron_darker.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/iron_darker.png new file mode 100644 index 0000000000000000000000000000000000000000..6e3007ceea482d4baf43e3bb3607fb4880f71b0d GIT binary patch literal 89 zcmeAS@N?(olHy`uVBq!ia0vp^96-#-!2~38k}5=il!B*=V@SoV)owjaX nQ(N1FmX-}WECdQad}L$@OlFzb#?t5nRLkJ$>gTe~DWM4fy;B*w literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/lapis.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/lapis.png new file mode 100644 index 0000000000000000000000000000000000000000..9c578f0d4835c55a6e5c56adefc3fba6d983a791 GIT binary patch literal 90 zcmeAS@N?(olHy`uVBq!ia0vp^96-#-!2~38k}5=il%l7LV+coda>1F|dXKH$FK&)H oVG$ajKhs^gEBM^h&@(&?p?6qM*d0F32UN`9>FVdQ&MBb@0EW{TrT_o{ literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/netherite.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/netherite.png new file mode 100644 index 0000000000000000000000000000000000000000..03a6e283bd1929b609b89758456449c4ce710c3f GIT binary patch literal 90 zcmeAS@N?(olHy`uVBq!ia0vp^96-#-!2~38k}5=il%l7LV+codatBv`(Z)??SB$TO ohlfv_JXuIs_`$=6De3GCcHYd};u3p~0~IrPy85}Sb4q9e0CKh%4gdfE literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/netherite_darker.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/netherite_darker.png new file mode 100644 index 0000000000000000000000000000000000000000..ad8f5172eff6a2c2141cc134847f09a4a4be74a8 GIT binary patch literal 90 zcmeAS@N?(olHy`uVBq!ia0vp^96-#-!2~38k}5=il%l7LV+cod@`cM6t^@>LoiH_Y o;-pF9;?qUN#2!9>{_F`0gIXn1S9f1e5l}IMr>mdKI;Vst0K2Ri_W%F@ literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/quartz.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/quartz.png new file mode 100644 index 0000000000000000000000000000000000000000..1275d27c42a2efa5a525998919e1497d61a7b5ad GIT binary patch literal 90 zcmeAS@N?(olHy`uVBq!ia0vp^96-#-!2~38k}5=il%l7LV@SoVnTqxkas5 o-Q2mdb>*5VGd65cV7KOGXwhMJV9&lI2vp4A>FVdQ&MBb@0N?x>rvLx| literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/redstone.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/redstone.png new file mode 100644 index 0000000000000000000000000000000000000000..fb70bf883be7c9acf9d263f1df7c5d60ad38e838 GIT binary patch literal 90 zcmeAS@N?(olHy`uVBq!ia0vp^96-#-!2~38k}5=il%l7LV+coda>cR(8^arucS%&Y nDkN)}CkyD$mSo?oqbx!pkfA3S3j3^P6xYD!z o$bBuP?*d1Wz}geL!e>?Pgg&ebxsLQ0PZCky8r+H literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/trim_palette.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/trim_palette.png new file mode 100644 index 0000000000000000000000000000000000000000..6c7c20f4acb769cfad2c4ebe84d561cd25ab4452 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^96-znA{dTkZ}1*D$a$$WLNt`q}#I PC`j7V)z4*}Q$iB}v*-_} literal 0 HcmV?d00001 diff --git a/packages/ui/src/components/skin/ArmorPreviewControls.vue b/packages/ui/src/components/skin/ArmorPreviewControls.vue new file mode 100644 index 0000000..72e071e --- /dev/null +++ b/packages/ui/src/components/skin/ArmorPreviewControls.vue @@ -0,0 +1,60 @@ + + + \ No newline at end of file diff --git a/packages/ui/src/components/skin/ArmorTrimPanel.vue b/packages/ui/src/components/skin/ArmorTrimPanel.vue new file mode 100644 index 0000000..567cca1 --- /dev/null +++ b/packages/ui/src/components/skin/ArmorTrimPanel.vue @@ -0,0 +1,334 @@ + + + + + diff --git a/packages/ui/src/components/skin/SkinPreviewRenderer.vue b/packages/ui/src/components/skin/SkinPreviewRenderer.vue index 6aa12bb..a4f8738 100644 --- a/packages/ui/src/components/skin/SkinPreviewRenderer.vue +++ b/packages/ui/src/components/skin/SkinPreviewRenderer.vue @@ -17,12 +17,17 @@
-
+
+
(), { 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('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) } diff --git a/packages/ui/src/components/skin/index.ts b/packages/ui/src/components/skin/index.ts index 7fff3d0..ddfef53 100644 --- a/packages/ui/src/components/skin/index.ts +++ b/packages/ui/src/components/skin/index.ts @@ -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' diff --git a/packages/ui/src/composables/index.ts b/packages/ui/src/composables/index.ts index d49723c..8a74269 100644 --- a/packages/ui/src/composables/index.ts +++ b/packages/ui/src/composables/index.ts @@ -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' diff --git a/packages/ui/src/composables/skin-rendering/armor-preview-assets.ts b/packages/ui/src/composables/skin-rendering/armor-preview-assets.ts new file mode 100644 index 0000000..57c9ffc --- /dev/null +++ b/packages/ui/src/composables/skin-rendering/armor-preview-assets.ts @@ -0,0 +1,105 @@ +import type { + ArmorMaterial, + ArmorSlot, + ArmorTrimMaterial, + ArmorTrimPattern, +} from './armor-preview-types' + +const assetModules = import.meta.glob('../../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 = { + leather: 'leather', + chainmail: 'chainmail', + copper: 'copper', + gold: 'gold', + iron: 'iron', + diamond: 'diamond', + netherite: 'netherite', + turtle: 'turtle_scute', +} + +const armorItemName: Record = { + leather: 'leather_default', + chainmail: 'chainmail', + copper: 'copper', + gold: 'golden', + iron: 'iron', + diamond: 'diamond', + netherite: 'netherite', + turtle: 'turtle', +} + +const trimIngredientName: Record = { + 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([ + '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') +} diff --git a/packages/ui/src/composables/skin-rendering/armor-preview-types.test.ts b/packages/ui/src/composables/skin-rendering/armor-preview-types.test.ts new file mode 100644 index 0000000..c13f7dc --- /dev/null +++ b/packages/ui/src/composables/skin-rendering/armor-preview-types.test.ts @@ -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') +}) diff --git a/packages/ui/src/composables/skin-rendering/armor-preview-types.ts b/packages/ui/src/composables/skin-rendering/armor-preview-types.ts new file mode 100644 index 0000000..ca6714a --- /dev/null +++ b/packages/ui/src/composables/skin-rendering/armor-preview-types.ts @@ -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 + +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') +} diff --git a/packages/ui/src/composables/skin-rendering/use-armor-preview.ts b/packages/ui/src/composables/skin-rendering/use-armor-preview.ts new file mode 100644 index 0000000..4fd0287 --- /dev/null +++ b/packages/ui/src/composables/skin-rendering/use-armor-preview.ts @@ -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 + config: Ref + enabled: Ref +}) { + 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) + }) +} diff --git a/packages/ui/src/utils/index.ts b/packages/ui/src/utils/index.ts index 1762465..e7c1598 100644 --- a/packages/ui/src/utils/index.ts +++ b/packages/ui/src/utils/index.ts @@ -15,3 +15,4 @@ export * from './tag-messages' export * from './truncate' export * from './version-compatibility' export * from './vue-children' +export * from './webgl/skin-rendering' diff --git a/packages/ui/src/utils/webgl/armor-geometry.test.ts b/packages/ui/src/utils/webgl/armor-geometry.test.ts new file mode 100644 index 0000000..3f7d9e5 --- /dev/null +++ b/packages/ui/src/utils/webgl/armor-geometry.test.ts @@ -0,0 +1,222 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import * as THREE from 'three' + +import { createArmorGeometry } from './armor-geometry.ts' + +function geometrySize(geometry: THREE.BufferGeometry): THREE.Vector3 { + geometry.computeBoundingBox() + return geometry.boundingBox!.getSize(new THREE.Vector3()) +} + +function geometryBounds(geometry: THREE.BufferGeometry): THREE.Box3 { + geometry.computeBoundingBox() + return geometry.boundingBox! +} + +function uvBoundsForNormal( + geometry: THREE.BufferGeometry, + normalKey: string, +): { minU: number; maxU: number; minV: number; maxV: number } { + const normal = geometry.getAttribute('normal') as THREE.BufferAttribute + const uv = geometry.getAttribute('uv') as THREE.BufferAttribute + const values: Array<[number, number]> = [] + for (let index = 0; index < normal.count; index++) { + const key = `${Math.round(normal.getX(index))},${Math.round(normal.getY(index))},${Math.round(normal.getZ(index))}` + if (key === normalKey) values.push([uv.getX(index) * 64, uv.getY(index) * 32]) + } + assert.ok(values.length > 0, `missing face with normal ${normalKey}`) + return { + minU: Math.min(...values.map(([u]) => u)), + maxU: Math.max(...values.map(([u]) => u)), + minV: Math.min(...values.map(([, v]) => v)), + maxV: Math.max(...values.map(([, v]) => v)), + } +} + +function assertUvBounds( + actual: ReturnType, + expected: ReturnType, +): void { + for (const key of ['minU', 'maxU', 'minV', 'maxV'] as const) { + assert.ok(Math.abs(actual[key] - expected[key]) < 1e-5, `${key}: ${actual[key]}`) + } +} + +function assertUvHorizontalBounds( + geometry: THREE.BufferGeometry, + normalKey: string, + minU: number, + maxU: number, +): void { + const actual = uvBoundsForNormal(geometry, normalKey) + assert.ok(Math.abs(actual.minU - minU) < 1e-5, `minU: ${actual.minU}`) + assert.ok(Math.abs(actual.maxU - maxU) < 1e-5, `maxU: ${actual.maxU}`) +} + +function uAtFaceEdge( + geometry: THREE.BufferGeometry, + normalKey: string, + axis: 'x' | 'z', + edge: 'min' | 'max', +): number { + const position = geometry.getAttribute('position') as THREE.BufferAttribute + const normal = geometry.getAttribute('normal') as THREE.BufferAttribute + const uv = geometry.getAttribute('uv') as THREE.BufferAttribute + const candidates: Array<{ coordinate: number; u: number }> = [] + for (let index = 0; index < normal.count; index++) { + const key = `${Math.round(normal.getX(index))},${Math.round(normal.getY(index))},${Math.round(normal.getZ(index))}` + if (key !== normalKey) continue + candidates.push({ + coordinate: axis === 'x' ? position.getX(index) : position.getZ(index), + u: uv.getX(index) * 64, + }) + } + assert.ok(candidates.length > 0, `missing face with normal ${normalKey}`) + const target = Math[edge](...candidates.map(({ coordinate }) => coordinate)) + const values = candidates + .filter(({ coordinate }) => Math.abs(coordinate - target) < 1e-7) + .map(({ u }) => u) + assert.ok(values.length > 0) + assert.ok(values.every((u) => Math.abs(u - values[0]) < 1e-5)) + return values[0] +} + +test('outer armor expands every side by one model pixel', () => { + const source = new THREE.BoxGeometry(8 / 16, 12 / 16, 4 / 16) + const size = geometrySize(createArmorGeometry(source, 'outer', 'body')) + + assert.ok(Math.abs(size.x - 10 / 16) < 1e-7) + assert.ok(Math.abs(size.y - 14 / 16) < 1e-7) + assert.ok(Math.abs(size.z - 6 / 16) < 1e-7) +}) + +test('leggings legs use the inner-model dilation with the vanilla leg reduction', () => { + const source = new THREE.BoxGeometry(4 / 16, 12 / 16, 4 / 16) + const size = geometrySize(createArmorGeometry(source, 'leggings', 'rightLeg')) + + assert.ok(Math.abs(size.x - 4.8 / 16) < 1e-7) + assert.ok(Math.abs(size.y - 12.8 / 16) < 1e-7) + assert.ok(Math.abs(size.z - 4.8 / 16) < 1e-7) +}) + +test('slim player arms expand outwards to the classic armor bounds', () => { + const rightSource = new THREE.BoxGeometry(3 / 16, 12 / 16, 4 / 16).translate(11 / 32, 0, 0) + const leftSource = new THREE.BoxGeometry(3 / 16, 12 / 16, 4 / 16).translate(-11 / 32, 0, 0) + const right = geometryBounds(createArmorGeometry(rightSource, 'outer', 'rightArm')) + const left = geometryBounds(createArmorGeometry(leftSource, 'outer', 'leftArm')) + + assert.ok(Math.abs(right.min.x - 3 / 16) < 1e-7) + assert.ok(Math.abs(right.max.x - 9 / 16) < 1e-7) + assert.ok(Math.abs(left.min.x + 9 / 16) < 1e-7) + assert.ok(Math.abs(left.max.x + 3 / 16) < 1e-7) + assert.ok(Math.abs(right.max.x - right.min.x - 6 / 16) < 1e-7) + assert.ok(Math.abs(left.max.x - left.min.x - 6 / 16) < 1e-7) + assert.ok(Math.abs(right.max.y - right.min.y - 14 / 16) < 1e-7) + assert.ok(Math.abs(right.max.z - right.min.z - 6 / 16) < 1e-7) +}) + +test('remaps player UV height from 64x64 skins to 64x32 armor textures', () => { + const source = new THREE.BoxGeometry(8 / 16, 8 / 16, 8 / 16) + const sourceUv = source.getAttribute('uv') as THREE.BufferAttribute + const expected = Array.from({ length: sourceUv.count }, (_, index) => sourceUv.getY(index) * 2) + const armorUv = createArmorGeometry(source, 'outer', 'head').getAttribute( + 'uv', + ) as THREE.BufferAttribute + + for (let index = 0; index < armorUv.count; index++) { + assert.ok(Math.abs(armorUv.getY(index) - expected[index]) < 1e-7) + } +}) + +test('armor arms use vanilla mirrored UV regions on their physical outer and inner faces', () => { + const rightSource = new THREE.BoxGeometry(4 / 16, 12 / 16, 4 / 16) + const leftSource = rightSource.clone() + const leftPosition = leftSource.getAttribute('position') as THREE.BufferAttribute + const leftNormal = leftSource.getAttribute('normal') as THREE.BufferAttribute + const leftUv = leftSource.getAttribute('uv') as THREE.BufferAttribute + for (let index = 0; index < leftPosition.count; index++) { + leftPosition.setX(index, -leftPosition.getX(index)) + leftNormal.setX(index, -leftNormal.getX(index)) + // The authored GLTF left arm mirrors both its physical X coordinate and + // each face's source U direction relative to the right arm. + leftUv.setX(index, 1 - leftUv.getX(index)) + } + + const right = createArmorGeometry(rightSource, 'outer', 'rightArm') + const left = createArmorGeometry(leftSource, 'outer', 'leftArm') + + // In the player GLTF, +X is the right arm's outside and -X is the left + // arm's outside. Vanilla mirror() maps both outside faces to U 40..44. + assertUvBounds(uvBoundsForNormal(right, '1,0,0'), { + minU: 40, + maxU: 44, + minV: 20, + maxV: 32, + }) + assertUvBounds(uvBoundsForNormal(left, '-1,0,0'), { + minU: 40, + maxU: 44, + minV: 20, + maxV: 32, + }) + assertUvBounds(uvBoundsForNormal(right, '-1,0,0'), { + minU: 48, + maxU: 52, + minV: 20, + maxV: 32, + }) + assertUvBounds(uvBoundsForNormal(left, '1,0,0'), { + minU: 48, + maxU: 52, + minV: 20, + maxV: 32, + }) + + for (const normal of ['0,0,-1', '0,0,1', '0,-1,0', '0,1,0']) { + assertUvBounds(uvBoundsForNormal(left, normal), uvBoundsForNormal(right, normal)) + } + + // Both physical outer edges point to the start of the shared front region. + assert.ok(Math.abs(uAtFaceEdge(right, '0,0,-1', 'x', 'max') - 44) < 1e-5) + assert.ok(Math.abs(uAtFaceEdge(left, '0,0,-1', 'x', 'min') - 44) < 1e-5) + assert.ok(Math.abs(uAtFaceEdge(right, '0,0,-1', 'x', 'min') - 48) < 1e-5) + assert.ok(Math.abs(uAtFaceEdge(left, '0,0,-1', 'x', 'max') - 48) < 1e-5) + + // The mirrored left outside face has the same front-to-back orientation as + // the right outside face after ModelPart.Polygon reverses its vertices. + assert.ok(Math.abs(uAtFaceEdge(right, '1,0,0', 'z', 'min') - 44) < 1e-5) + assert.ok(Math.abs(uAtFaceEdge(left, '-1,0,0', 'z', 'min') - 44) < 1e-5) + assert.ok(Math.abs(uAtFaceEdge(right, '1,0,0', 'z', 'max') - 40) < 1e-5) + assert.ok(Math.abs(uAtFaceEdge(left, '-1,0,0', 'z', 'max') - 40) < 1e-5) +}) + +test('left leg mirrors every face and swaps its physical side UV regions', () => { + const source = new THREE.BoxGeometry(4 / 16, 12 / 16, 4 / 16) + const sourceUv = source.getAttribute('uv') as THREE.BufferAttribute + const expectedV = Array.from({ length: sourceUv.count }, (_, index) => sourceUv.getY(index) * 2) + for (let index = 0; index < sourceUv.count; index++) { + sourceUv.setXY(index, sourceUv.getX(index) + 16 / 64, sourceUv.getY(index) + 32 / 64) + } + + for (const layer of ['outer', 'leggings'] as const) { + const geometry = createArmorGeometry(source, layer, 'leftLeg') + const armorUv = geometry.getAttribute('uv') as THREE.BufferAttribute + + for (let index = 0; index < armorUv.count; index++) { + assert.ok(Math.abs(armorUv.getY(index) - expectedV[index]) < 1e-7) + } + + for (const [normal, minU, maxU] of [ + ['0,0,-1', 4, 8], + ['0,0,1', 12, 16], + ['0,-1,0', 8, 12], + ['0,1,0', 4, 8], + ['-1,0,0', 0, 4], + ['1,0,0', 8, 12], + ] as const) { + assertUvHorizontalBounds(geometry, normal, minU, maxU) + } + } +}) diff --git a/packages/ui/src/utils/webgl/armor-geometry.ts b/packages/ui/src/utils/webgl/armor-geometry.ts new file mode 100644 index 0000000..ffdcec3 --- /dev/null +++ b/packages/ui/src/utils/webgl/armor-geometry.ts @@ -0,0 +1,209 @@ +import * as THREE from 'three' + +export type ArmorGeometryLayer = 'outer' | 'leggings' +export type ArmorBodyPart = 'head' | 'body' | 'rightArm' | 'leftArm' | 'rightLeg' | 'leftLeg' + +const MODEL_PIXEL_SIZE = 1 / 16 +const OUTER_ARMOR_DILATION = MODEL_PIXEL_SIZE +const LEGGINGS_ARMOR_DILATION = MODEL_PIXEL_SIZE / 2 +const LEG_DEFORMATION_REDUCTION = MODEL_PIXEL_SIZE / 10 +const CLASSIC_ARM_WIDTH = 4 * MODEL_PIXEL_SIZE + +interface UvBounds { + minU: number + maxU: number + minV: number + maxV: number +} + +const ARMOR_ARM_UV_RECTS: Record = { + '0,0,-1': { minU: 44, maxU: 48, minV: 20, maxV: 32 }, + '0,0,1': { minU: 52, maxU: 56, minV: 20, maxV: 32 }, + '0,-1,0': { minU: 48, maxU: 52, minV: 16, maxV: 20 }, + '0,1,0': { minU: 44, maxU: 48, minV: 16, maxV: 20 }, + '-1,0,0': { minU: 48, maxU: 52, minV: 20, maxV: 32 }, + '1,0,0': { minU: 40, maxU: 44, minV: 20, maxV: 32 }, +} + +const ARMOR_LEG_UV_RECTS: Record = { + '0,0,-1': { minU: 4, maxU: 8 }, + '0,0,1': { minU: 12, maxU: 16 }, + '0,-1,0': { minU: 8, maxU: 12 }, + '0,1,0': { minU: 4, maxU: 8 }, + '-1,0,0': { minU: 8, maxU: 12 }, + '1,0,0': { minU: 0, maxU: 4 }, +} + +function armorLegUvRect(normal: string, bodyPart: ArmorBodyPart): UvBounds | undefined { + if (bodyPart !== 'leftLeg') return ARMOR_LEG_UV_RECTS[normal] + if (normal === '-1,0,0') return ARMOR_LEG_UV_RECTS['1,0,0'] + if (normal === '1,0,0') return ARMOR_LEG_UV_RECTS['-1,0,0'] + return ARMOR_LEG_UV_RECTS[normal] +} + +function armorArmUvRect(normal: string, bodyPart: ArmorBodyPart): UvBounds | undefined { + if (bodyPart !== 'leftArm') return ARMOR_ARM_UV_RECTS[normal] + + // Vanilla builds the left arm with CubeListBuilder.mirror(). In addition to + // reversing every face below, mirroring swaps the two X-facing regions: the + // physical outside of either arm uses U 40..44 and the inside uses U 48..52. + if (normal === '-1,0,0') return ARMOR_ARM_UV_RECTS['1,0,0'] + if (normal === '1,0,0') return ARMOR_ARM_UV_RECTS['-1,0,0'] + return ARMOR_ARM_UV_RECTS[normal] +} + +function isArm(bodyPart: ArmorBodyPart): boolean { + return bodyPart === 'rightArm' || bodyPart === 'leftArm' +} + +function isLeg(bodyPart: ArmorBodyPart): boolean { + return bodyPart === 'rightLeg' || bodyPart === 'leftLeg' +} + +function normalKey(normal: THREE.BufferAttribute, index: number): string { + return `${Math.round(normal.getX(index))},${Math.round(normal.getY(index))},${Math.round(normal.getZ(index))}` +} + +function remapArmUv( + uv: THREE.BufferAttribute, + normal: THREE.BufferAttribute, + bodyPart: ArmorBodyPart, +): void { + const sourceBounds = new Map() + for (let index = 0; index < uv.count; index++) { + const key = normalKey(normal, index) + if (!armorArmUvRect(key, bodyPart)) continue + + const u = uv.getX(index) + const v = uv.getY(index) + const bounds = sourceBounds.get(key) + if (bounds) { + bounds.minU = Math.min(bounds.minU, u) + bounds.maxU = Math.max(bounds.maxU, u) + bounds.minV = Math.min(bounds.minV, v) + bounds.maxV = Math.max(bounds.maxV, v) + } else { + sourceBounds.set(key, { minU: u, maxU: u, minV: v, maxV: v }) + } + } + + for (let index = 0; index < uv.count; index++) { + const key = normalKey(normal, index) + const source = sourceBounds.get(key) + const target = armorArmUvRect(key, bodyPart) + if (!source || !target) continue + + const uRange = source.maxU - source.minU + const vRange = source.maxV - source.minV + const sourceURatio = uRange > 0 ? (uv.getX(index) - source.minU) / uRange : 0 + const uRatio = bodyPart === 'leftArm' ? 1 - sourceURatio : sourceURatio + const vRatio = vRange > 0 ? (uv.getY(index) - source.minV) / vRange : 0 + uv.setXY( + index, + THREE.MathUtils.lerp(target.minU, target.maxU, uRatio) / 64, + THREE.MathUtils.lerp(target.minV, target.maxV, vRatio) / 32, + ) + } + uv.needsUpdate = true +} + +function remapArmorUv( + uv: THREE.BufferAttribute, + normal: THREE.BufferAttribute | undefined, + bodyPart: ArmorBodyPart, +): void { + if (isArm(bodyPart) && normal) { + // Armor arms always use the classic four-pixel layout, including on slim + // players. Rebuild each face's UVs instead of stretching the slim atlas. + remapArmUv(uv, normal, bodyPart) + return + } + const leftLegSourceBounds = new Map() + if (bodyPart === 'leftLeg' && normal) { + for (let index = 0; index < uv.count; index++) { + const key = normalKey(normal, index) + if (!ARMOR_LEG_UV_RECTS[key]) continue + const u = uv.getX(index) - 16 / 64 + const v = uv.getY(index) - 32 / 64 + const bounds = leftLegSourceBounds.get(key) + if (bounds) { + bounds.minU = Math.min(bounds.minU, u) + bounds.maxU = Math.max(bounds.maxU, u) + bounds.minV = Math.min(bounds.minV, v) + bounds.maxV = Math.max(bounds.maxV, v) + } else { + leftLegSourceBounds.set(key, { minU: u, maxU: u, minV: v, maxV: v }) + } + } + } + + for (let index = 0; index < uv.count; index++) { + let u = uv.getX(index) + let v = uv.getY(index) + + // Vanilla armor textures are 64x32. The left arm and leg reuse the right + // limb regions, while player skins store their left-limb pixels in the + // lower half of a 64x64 texture. + if (bodyPart === 'leftLeg') { + u -= 16 / 64 + v -= 32 / 64 + const normalKeyValue = normal ? normalKey(normal, index) : undefined + const target = normalKeyValue && armorLegUvRect(normalKeyValue, bodyPart) + const source = normalKeyValue && leftLegSourceBounds.get(normalKeyValue) + if (target && source) { + const ratio = + source.maxU > source.minU ? (u - source.minU) / (source.maxU - source.minU) : 0 + u = (target.minU + (1 - ratio) * (target.maxU - target.minU)) / 64 + } + } + + uv.setXY(index, u, v * 2) + } + uv.needsUpdate = true +} + +export function createArmorGeometry( + source: THREE.BufferGeometry, + layer: ArmorGeometryLayer, + bodyPart: ArmorBodyPart, +): THREE.BufferGeometry { + const geometry = source.clone() + const position = geometry.getAttribute('position') as THREE.BufferAttribute | undefined + if (!position) throw new Error('Armor source geometry has no position attribute') + + const bounds = new THREE.Box3().setFromBufferAttribute(position) + const sourceCenter = bounds.getCenter(new THREE.Vector3()) + const targetCenter = sourceCenter.clone() + const sourceSize = bounds.getSize(new THREE.Vector3()) + let dilation = layer === 'leggings' ? LEGGINGS_ARMOR_DILATION : OUTER_ARMOR_DILATION + if (isLeg(bodyPart)) dilation -= LEG_DEFORMATION_REDUCTION + const baseSize = sourceSize.clone() + if (isArm(bodyPart) && sourceSize.x < CLASSIC_ARM_WIDTH) { + const missingWidth = CLASSIC_ARM_WIDTH - sourceSize.x + baseSize.x = CLASSIC_ARM_WIDTH + // Slim arms share their inner edge with classic arms. The extra model + // pixel therefore belongs on the outside, not equally on both sides. + targetCenter.x += bodyPart === 'rightArm' ? missingWidth / 2 : -missingWidth / 2 + } + + const targetSize = baseSize.addScalar(dilation * 2) + const scale = new THREE.Vector3( + targetSize.x / sourceSize.x, + targetSize.y / sourceSize.y, + targetSize.z / sourceSize.z, + ) + const vertex = new THREE.Vector3() + for (let index = 0; index < position.count; index++) { + vertex.fromBufferAttribute(position, index).sub(sourceCenter).multiply(scale).add(targetCenter) + position.setXYZ(index, vertex.x, vertex.y, vertex.z) + } + position.needsUpdate = true + + const uv = geometry.getAttribute('uv') as THREE.BufferAttribute | undefined + const normal = geometry.getAttribute('normal') as THREE.BufferAttribute | undefined + if (uv) remapArmorUv(uv, normal, bodyPart) + + geometry.computeBoundingBox() + geometry.computeBoundingSphere() + return geometry +} diff --git a/packages/ui/src/utils/webgl/armor-preview-object.ts b/packages/ui/src/utils/webgl/armor-preview-object.ts new file mode 100644 index 0000000..e6048d4 --- /dev/null +++ b/packages/ui/src/utils/webgl/armor-preview-object.ts @@ -0,0 +1,7 @@ +import type * as THREE from 'three' + +export const ARMOR_PREVIEW_MARKER = 'armorPreviewOwned' + +export function isArmorPreviewMesh(mesh: THREE.Mesh): boolean { + return Boolean(mesh.userData[ARMOR_PREVIEW_MARKER]) +} diff --git a/packages/ui/src/utils/webgl/armor-rendering.ts b/packages/ui/src/utils/webgl/armor-rendering.ts new file mode 100644 index 0000000..fd1da5b --- /dev/null +++ b/packages/ui/src/utils/webgl/armor-rendering.ts @@ -0,0 +1,343 @@ +import * as THREE from 'three' + +import { + type ArmorTextureLayer, + getArmorTextureUrl, + getLeatherOverlayUrl, + getTrimPaletteUrl, + getTrimPatternTextureUrl, + getTrimSourcePaletteUrl, +} from '../../composables/skin-rendering/armor-preview-assets' +import type { + ArmorMaterial, + ArmorPreviewConfig, + ArmorSlot, + ArmorTrimMaterial, +} from '../../composables/skin-rendering/armor-preview-types' +import { type ArmorBodyPart, type ArmorGeometryLayer, createArmorGeometry } from './armor-geometry' +import { ARMOR_PREVIEW_MARKER } from './armor-preview-object' + +const DEFAULT_LEATHER_COLOR = 0xa06540 + +interface ArmorPartDefinition { + sourceName: string + bodyPart: ArmorBodyPart +} + +interface PreparedArmorPiece { + baseTexture: THREE.Texture + overlayTexture?: THREE.Texture + trimTexture?: THREE.Texture +} + +export type PreparedArmorPreview = Partial> + +const ARMOR_PARTS: Record = { + helmet: [{ sourceName: 'Head_2', bodyPart: 'head' }], + chestplate: [ + { sourceName: 'Body_2', bodyPart: 'body' }, + { sourceName: 'Right_Arm_2', bodyPart: 'rightArm' }, + { sourceName: 'Left_Arm_2', bodyPart: 'leftArm' }, + ], + leggings: [ + { sourceName: 'Body_2', bodyPart: 'body' }, + { sourceName: 'Right_Leg_2', bodyPart: 'rightLeg' }, + { sourceName: 'Left_Leg_2', bodyPart: 'leftLeg' }, + ], + boots: [ + { sourceName: 'Right_Leg_2', bodyPart: 'rightLeg' }, + { sourceName: 'Left_Leg_2', bodyPart: 'leftLeg' }, + ], +} + +const textureCache = new Map>() +const imageCache = new Map>() +const trimTextureCache = new Map>() + +function textureLayerForSlot(slot: ArmorSlot): ArmorTextureLayer { + return slot === 'leggings' ? 'leggings' : 'humanoid' +} + +function geometryLayerForSlot(slot: ArmorSlot): ArmorGeometryLayer { + return slot === 'leggings' ? 'leggings' : 'outer' +} + +function loadTexture(url: string): Promise { + const cached = textureCache.get(url) + if (cached) return cached + + const promise = new THREE.TextureLoader().loadAsync(url).then((texture) => { + texture.colorSpace = THREE.SRGBColorSpace + texture.flipY = false + texture.magFilter = THREE.NearestFilter + texture.minFilter = THREE.NearestFilter + texture.generateMipmaps = false + return texture + }) + textureCache.set(url, promise) + return promise +} + +function loadImage(url: string): Promise { + const cached = imageCache.get(url) + if (cached) return cached + + const promise = new Promise((resolve, reject) => { + const image = new Image() + image.crossOrigin = 'anonymous' + image.onload = () => resolve(image) + image.onerror = () => reject(new Error(`Failed to load armor preview image: ${url}`)) + image.src = url + }) + imageCache.set(url, promise) + return promise +} + +function imagePixels(image: CanvasImageSource, width: number, height: number): ImageData { + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const context = canvas.getContext('2d', { willReadFrequently: true }) + if (!context) throw new Error('Unable to read armor preview texture pixels') + context.drawImage(image, 0, 0, width, height) + return context.getImageData(0, 0, width, height) +} + +function closestPaletteIndex( + red: number, + green: number, + blue: number, + palette: Uint8ClampedArray, +): number { + let closest = 0 + let closestDistance = Number.POSITIVE_INFINITY + for (let index = 0; index < palette.length; index += 4) { + const redDistance = red - palette[index] + const greenDistance = green - palette[index + 1] + const blueDistance = blue - palette[index + 2] + const distance = + redDistance * redDistance + greenDistance * greenDistance + blueDistance * blueDistance + if (distance < closestDistance) { + closest = index + closestDistance = distance + } + } + return closest +} + +async function createTrimTexture(patternUrl: string, paletteUrl: string): Promise { + const cacheKey = `${patternUrl}|${paletteUrl}` + const cached = trimTextureCache.get(cacheKey) + if (cached) return cached + + const promise = Promise.all([ + loadImage(patternUrl), + loadImage(getTrimSourcePaletteUrl()), + loadImage(paletteUrl), + ]).then(([patternImage, sourcePaletteImage, targetPaletteImage]) => { + const width = patternImage.naturalWidth + const height = patternImage.naturalHeight + const pattern = imagePixels(patternImage, width, height) + const sourcePalette = imagePixels( + sourcePaletteImage, + sourcePaletteImage.naturalWidth, + sourcePaletteImage.naturalHeight, + ).data + const targetPalette = imagePixels( + targetPaletteImage, + targetPaletteImage.naturalWidth, + targetPaletteImage.naturalHeight, + ).data + + for (let index = 0; index < pattern.data.length; index += 4) { + if (pattern.data[index + 3] === 0) continue + const paletteIndex = closestPaletteIndex( + pattern.data[index], + pattern.data[index + 1], + pattern.data[index + 2], + sourcePalette, + ) + pattern.data[index] = targetPalette[paletteIndex] + pattern.data[index + 1] = targetPalette[paletteIndex + 1] + pattern.data[index + 2] = targetPalette[paletteIndex + 2] + } + + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const context = canvas.getContext('2d') + if (!context) throw new Error('Unable to create armor trim texture') + context.putImageData(pattern, 0, 0) + + const texture = new THREE.CanvasTexture(canvas) + texture.colorSpace = THREE.SRGBColorSpace + texture.flipY = false + texture.magFilter = THREE.NearestFilter + texture.minFilter = THREE.NearestFilter + texture.generateMipmaps = false + texture.needsUpdate = true + return texture + }) + trimTextureCache.set(cacheKey, promise) + return promise +} + +function usesDarkerTrim(material: ArmorMaterial, trimMaterial: ArmorTrimMaterial): boolean { + return ( + material !== 'leather' && + material !== 'chainmail' && + material !== 'turtle' && + material === trimMaterial + ) +} + +async function prepareArmorPiece( + slot: ArmorSlot, + selection: ArmorPreviewConfig[ArmorSlot], +): Promise { + if (!selection.material) return undefined + const layer = textureLayerForSlot(slot) + const tasks: Array> = [ + loadTexture(getArmorTextureUrl(selection.material, layer)), + selection.material === 'leather' + ? loadTexture(getLeatherOverlayUrl(layer)) + : Promise.resolve(undefined), + ] + + if (selection.trimPattern) { + tasks.push( + createTrimTexture( + getTrimPatternTextureUrl(selection.trimPattern, layer), + getTrimPaletteUrl( + selection.trimMaterial, + usesDarkerTrim(selection.material, selection.trimMaterial), + ), + ), + ) + } else { + tasks.push(Promise.resolve(undefined)) + } + + const [baseTexture, overlayTexture, trimTexture] = await Promise.all(tasks) + return { baseTexture: baseTexture!, overlayTexture, trimTexture } +} + +export async function prepareArmorPreview( + config: ArmorPreviewConfig, +): Promise { + const entries = await Promise.all( + (Object.entries(config) as Array<[ArmorSlot, ArmorPreviewConfig[ArmorSlot]]>).map( + async ([slot, selection]) => [slot, await prepareArmorPiece(slot, selection)] as const, + ), + ) + return Object.fromEntries(entries.filter((entry) => entry[1])) as PreparedArmorPreview +} + +function createArmorMaterial( + texture: THREE.Texture, + options: { color?: number; polygonOffset?: number; renderOrder: number }, +): THREE.MeshStandardMaterial { + const material = new THREE.MeshStandardMaterial({ + map: texture, + alphaTest: 0.1, + color: options.color ?? 0xffffff, + depthTest: true, + depthWrite: true, + flatShading: true, + metalness: 0, + roughness: 1, + side: THREE.FrontSide, + toneMapped: false, + transparent: false, + }) + if (options.polygonOffset !== undefined) { + material.polygonOffset = true + material.polygonOffsetFactor = options.polygonOffset + material.polygonOffsetUnits = options.polygonOffset + } + material.userData.armorPreviewRenderOrder = options.renderOrder + return material +} + +function attachArmorMesh( + source: THREE.Mesh, + geometry: THREE.BufferGeometry, + material: THREE.MeshStandardMaterial, + name: string, +): void { + const mesh = new THREE.Mesh(geometry, material) + mesh.name = name + mesh.position.copy(source.position) + mesh.quaternion.copy(source.quaternion) + mesh.scale.copy(source.scale) + mesh.renderOrder = material.userData.armorPreviewRenderOrder as number + mesh.frustumCulled = source.frustumCulled + mesh.userData[ARMOR_PREVIEW_MARKER] = true + source.parent?.add(mesh) +} + +function attachArmorPart( + source: THREE.Mesh, + slot: ArmorSlot, + bodyPart: ArmorBodyPart, + selection: ArmorPreviewConfig[ArmorSlot], + prepared: PreparedArmorPiece, +): void { + const geometry = createArmorGeometry(source.geometry, geometryLayerForSlot(slot), bodyPart) + attachArmorMesh( + source, + geometry, + createArmorMaterial(prepared.baseTexture, { + color: selection.material === 'leather' ? DEFAULT_LEATHER_COLOR : undefined, + renderOrder: 2, + }), + `Armor_${slot}_${source.name}`, + ) + + if (prepared.overlayTexture) { + attachArmorMesh( + source, + geometry.clone(), + createArmorMaterial(prepared.overlayTexture, { polygonOffset: -1, renderOrder: 3 }), + `Armor_${slot}_${source.name}_Overlay`, + ) + } + if (prepared.trimTexture) { + attachArmorMesh( + source, + geometry.clone(), + createArmorMaterial(prepared.trimTexture, { polygonOffset: -2, renderOrder: 4 }), + `Armor_${slot}_${source.name}_Trim`, + ) + } +} + +export function removeArmorPreview(root: THREE.Object3D): void { + const owned: THREE.Mesh[] = [] + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (mesh.isMesh && mesh.userData[ARMOR_PREVIEW_MARKER]) owned.push(mesh) + }) + for (const mesh of owned) { + mesh.parent?.remove(mesh) + mesh.geometry.dispose() + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + materials.forEach((material) => material.dispose()) + } +} + +export function applyPreparedArmorPreview( + root: THREE.Object3D, + config: ArmorPreviewConfig, + prepared: PreparedArmorPreview, +): void { + removeArmorPreview(root) + for (const [slot, piece] of Object.entries(prepared) as Array<[ArmorSlot, PreparedArmorPiece]>) { + const selection = config[slot] + for (const part of ARMOR_PARTS[slot]) { + const source = root.getObjectByName(part.sourceName) as THREE.Mesh | undefined + if (!source?.isMesh || !source.parent) continue + attachArmorPart(source, slot, part.bodyPart, selection, piece) + } + } +}