This commit is contained in:
2026-08-19 19:45:04 +08:00
parent 4de893ca94
commit cd24d68481
37 changed files with 1937 additions and 746 deletions
+2
View File
@@ -108,6 +108,8 @@ export interface ISkill {
name: string
desc: string
}[]
/** 后端下发的技能树坐标:[列, 行]。缔造者与黑暗武士使用扩展网格。 */
cellPosition: number[]
uuid: string
upType: 'damage' | 'heal'
}
@@ -47,6 +47,6 @@ const props = withDefaults(
}
.ui-data-row.is-disabled {
color: var(--ui-control-disabled-foreground);
color: var(--ui-unavailable-foreground);
}
</style>
@@ -12,8 +12,17 @@ const props = withDefaults(
disabled?: boolean
expandedLabel?: string
collapsedLabel?: string
compact?: boolean
showState?: boolean
indicatorPosition?: 'start' | 'end'
}>(),
{ expandedLabel: '已展开', collapsedLabel: '未展开' },
{
expandedLabel: '已展开',
collapsedLabel: '未展开',
compact: false,
showState: true,
indicatorPosition: 'start',
},
)
const open = defineModel<boolean>('open', { default: true })
@@ -23,12 +32,21 @@ const open = defineModel<boolean>('open', { default: true })
<CollapsibleRoot
v-model:open="open"
:disabled="disabled"
:class="cn('ui-data-section', props.class)"
:class="
cn(
'ui-data-section',
compact && 'ui-data-section--compact',
indicatorPosition === 'end' && 'ui-data-section--indicator-end',
props.class,
)
"
>
<CollapsibleTrigger class="ui-data-section__trigger">
<img :src="open ? expandedIcon : collapsedIcon" alt="" aria-hidden="true" />
<span class="ui-data-section__title">{{ title }}</span>
<span class="ui-data-section__state">{{ open ? expandedLabel : collapsedLabel }}</span>
<span v-if="showState" class="ui-data-section__state">
{{ open ? expandedLabel : collapsedLabel }}
</span>
</CollapsibleTrigger>
<CollapsibleContent class="ui-data-section__content">
<slot />
@@ -99,4 +117,12 @@ const open = defineModel<boolean>('open', { default: true })
.ui-data-section__content {
padding: var(--ui-disclosure-content-padding-block) var(--ui-field-padding-inline);
}
.ui-data-section--compact .ui-data-section__trigger {
min-height: var(--ui-control-height-compact);
}
.ui-data-section--indicator-end .ui-data-section__trigger > img {
order: 3;
}
</style>
@@ -207,4 +207,11 @@ function fastChange(direction: 1 | -1) {
.ui-number-field__fast-step button + button span {
top: 2px;
}
@media (max-width: 520px) {
.ui-number-field {
height: 50px;
grid-template-columns: minmax(0, 1fr) 25px;
}
}
</style>
@@ -66,7 +66,7 @@ const page = defineModel<number>('page', { default: 1 })
border: 1px solid var(--ui-pagination-border);
outline: none;
background: var(--ui-pagination-background);
color: var(--ui-control-disabled-foreground);
color: var(--ui-muted-foreground);
cursor: pointer;
}
@@ -82,6 +82,7 @@ const page = defineModel<number>('page', { default: 1 })
}
.ui-pagination__button:disabled {
color: var(--ui-control-disabled-foreground);
cursor: not-allowed;
opacity: 0.45;
}
@@ -31,7 +31,10 @@ const visibleMarkers = computed(() => {
<template>
<ProgressRoot v-bind="delegatedProps" :class="cn('ui-progress', props.class)">
<ProgressIndicator class="ui-progress__indicator" :style="{ width: `${percentage}%` }" />
<ProgressIndicator
class="ui-progress__indicator"
:style="{ transform: `scaleX(${percentage / 100})` }"
/>
<span
v-for="milestone in visibleMarkers"
:key="milestone"
@@ -52,10 +55,12 @@ const visibleMarkers = computed(() => {
}
.ui-progress__indicator {
width: 100%;
height: 100%;
background: var(--ui-progress-fill);
box-shadow: inset 0 0 0 1px var(--ui-progress-fill-inset);
transition: width 180ms ease-out;
transform-origin: left center;
transition: transform 180ms ease-out;
}
.ui-progress__marker {
@@ -68,4 +73,10 @@ const visibleMarkers = computed(() => {
border-left: 4px solid transparent;
transform: translateX(-50%);
}
@media (prefers-reduced-motion: reduce) {
.ui-progress__indicator {
transition: none;
}
}
</style>
@@ -26,7 +26,13 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<template>
<RatingRoot v-bind="forwarded" :class="cn('ui-rating', props.class)" :aria-label="props.label">
<RatingItem v-for="item in props.length" :key="item" :item="item" class="ui-rating__item">
<RatingItem
v-for="item in props.length"
:key="item"
:item="item"
class="ui-rating__item"
:aria-label="`${props.label}:${item} / ${props.length}`"
>
<img :src="emptyStar" alt="" />
<RatingItemIndicator :step="item" class="ui-rating__indicator">
<img :src="activeStar" alt="" />
@@ -31,7 +31,7 @@ function selectRow(row: UiShopItem) {
</script>
<template>
<div :class="cn('ui-shop-list', props.class)" role="listbox" aria-label="物品列表">
<div :class="cn('ui-shop-list', props.class)" role="grid" aria-label="物品列表">
<div class="ui-shop-list__surface">
<div
v-for="(row, index) in rows"
@@ -42,7 +42,7 @@ function selectRow(row: UiShopItem) {
'is-selected': selected === row.id,
'is-disabled': row.disabled,
}"
role="option"
role="row"
:aria-selected="selected === row.id"
:aria-disabled="row.disabled || undefined"
:tabindex="row.disabled ? -1 : 0"
@@ -51,11 +51,11 @@ function selectRow(row: UiShopItem) {
@keydown.space.prevent="selectRow(row)"
>
<i class="ui-shop-row__frame" aria-hidden="true" />
<div class="ui-shop-row__icon"><img :src="row.image" alt="" /></div>
<div>{{ row.reinforce }}</div>
<div>{{ row.amplify }}</div>
<div :class="row.rarity && `quality-${row.rarity}`">{{ row.name }}</div>
<div>
<div class="ui-shop-row__icon" role="gridcell"><img :src="row.image" alt="" /></div>
<div role="gridcell">{{ row.reinforce }}</div>
<div role="gridcell">{{ row.amplify }}</div>
<div role="gridcell" :class="row.rarity && `quality-${row.rarity}`">{{ row.name }}</div>
<div role="gridcell">
<button
type="button"
class="ui-shop-row__inspect"
@@ -65,8 +65,10 @@ function selectRow(row: UiShopItem) {
<img :src="inspectIcon" alt="" />
</button>
</div>
<div :class="row.statusTone && `status-${row.statusTone}`">{{ row.status }}</div>
<div class="ui-shop-row__owner">{{ row.owner }}</div>
<div role="gridcell" :class="row.statusTone && `status-${row.statusTone}`">
{{ row.status }}
</div>
<div class="ui-shop-row__owner" role="gridcell">{{ row.owner }}</div>
</div>
</div>
</div>
@@ -305,6 +307,6 @@ function selectRow(row: UiShopItem) {
color: var(--ui-success);
}
.status-danger {
color: var(--ui-destructive);
color: var(--ui-danger-foreground);
}
</style>
@@ -12,7 +12,7 @@ import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<TabsListProps & { class?: HTMLAttributes['class']; marker?: 'arrow' | 'line' }>(),
{ marker: 'arrow' },
{ marker: 'line' },
)
const delegatedProps = computed(() => {
const { class: _class, marker: _marker, ...delegated } = props
@@ -22,6 +22,12 @@ const delegatedProps = computed(() => {
const listElement = ref<HTMLElement | ComponentPublicInstance>()
const markerLeft = ref('0px')
const markerReady = ref(false)
const itemWidth = ref('auto')
const groupWidth = ref('max-content')
const geometryStyle = computed(() => ({
'--ui-tab-item-width': itemWidth.value,
'--ui-tabs-group-width': groupWidth.value,
}))
let resizeObserver: ResizeObserver | undefined
let mutationObserver: MutationObserver | undefined
@@ -42,19 +48,60 @@ function positionMarker(animate = true) {
requestAnimationFrame(() => (markerReady.value = true))
}
function equalizeItems() {
const list = getListElement()
const triggers = list ? [...list.querySelectorAll<HTMLElement>('[role="tab"]')] : []
if (!list || !triggers.length) return
const labels = triggers.map(
(trigger) => trigger.querySelector<HTMLElement>('.ui-tabs-trigger__label') ?? trigger,
)
const widestLabel = Math.max(
0,
...labels.map((label) => Math.max(label.scrollWidth, label.getBoundingClientRect().width)),
)
const listStyle = getComputedStyle(list)
const triggerStyle = getComputedStyle(triggers[0])
const groupPadding =
Number.parseFloat(listStyle.paddingLeft) + Number.parseFloat(listStyle.paddingRight)
const itemPadding =
Number.parseFloat(triggerStyle.paddingLeft) + Number.parseFloat(triggerStyle.paddingRight)
const parentWidth = list.parentElement?.clientWidth ?? list.clientWidth
const availableWidth = Math.max(0, parentWidth - groupPadding)
if (!availableWidth) return
const equalizedItemWidth = Math.min(
Math.ceil(widestLabel + itemPadding),
availableWidth / triggers.length,
)
itemWidth.value = `${equalizedItemWidth}px`
groupWidth.value = `${equalizedItemWidth * triggers.length + groupPadding}px`
}
async function updateGeometry(animate = true) {
equalizeItems()
await nextTick()
positionMarker(animate)
}
onMounted(async () => {
await nextTick()
positionMarker(false)
document.fonts?.ready.then(() => positionMarker(false))
resizeObserver = new ResizeObserver(() => positionMarker(false))
await updateGeometry(false)
document.fonts?.ready.then(() => updateGeometry(false))
resizeObserver = new ResizeObserver(() => updateGeometry(false))
const list = getListElement()
if (list) resizeObserver.observe(list)
mutationObserver = new MutationObserver(() => positionMarker())
if (list) {
resizeObserver.observe(list)
if (list.parentElement) resizeObserver.observe(list.parentElement)
}
mutationObserver = new MutationObserver(() => updateGeometry())
if (list) {
mutationObserver.observe(list, {
subtree: true,
attributes: true,
attributeFilter: ['data-state'],
childList: true,
characterData: true,
})
}
})
@@ -66,7 +113,12 @@ onBeforeUnmount(() => {
</script>
<template>
<TabsList ref="listElement" v-bind="delegatedProps" :class="cn('ui-tabs-list', props.class)">
<TabsList
ref="listElement"
v-bind="delegatedProps"
:class="cn('ui-tabs-list', props.class)"
:style="geometryStyle"
>
<slot />
<span
aria-hidden="true"
@@ -84,19 +136,22 @@ onBeforeUnmount(() => {
.ui-tabs-list {
position: relative;
display: flex;
width: 100%;
box-sizing: border-box;
width: var(--ui-tabs-group-width, max-content);
max-width: 100%;
height: var(--ui-tabs-height);
align-items: center;
padding: 5px 7px;
justify-content: flex-start;
padding: 4px 6px;
background: var(--ui-tab-list-background);
}
.ui-tabs-list::after {
content: '';
position: absolute;
right: 7px;
top: 35px;
left: 7px;
right: 6px;
top: 28px;
left: 6px;
height: 1px;
background: var(--ui-tab-divider);
}
@@ -23,18 +23,20 @@ const delegatedProps = computed(() => {
position: relative;
z-index: 1;
display: inline-flex;
box-sizing: border-box;
width: var(--ui-tab-item-width, auto);
height: var(--ui-tab-height);
align-items: center;
justify-content: center;
min-width: 0;
flex: 1 1 0;
padding: 0 12px;
flex: 0 0 var(--ui-tab-item-width, auto);
padding: 0 10px;
border: 0;
outline: none;
background: var(--ui-tab-background);
color: var(--ui-tab-foreground);
font:
16px / var(--ui-control-line-height) 'DCalc',
14px / var(--ui-control-line-height) 'DCalc',
SimSun,
sans-serif;
text-shadow: 1px 1px 0 var(--ui-dialog-titlebar-text-shadow);
@@ -44,10 +46,10 @@ const delegatedProps = computed(() => {
.ui-tabs-trigger:not(:last-of-type)::before {
content: '';
position: absolute;
top: 5px;
top: 4px;
right: 0;
width: 1px;
height: 19px;
height: 15px;
background: linear-gradient(180deg, transparent, var(--ui-tab-separator) 22% 78%, transparent);
}
@@ -72,7 +74,11 @@ const delegatedProps = computed(() => {
position: relative;
z-index: 1;
display: inline-block;
max-width: 100%;
overflow: hidden;
line-height: var(--ui-control-line-height);
text-overflow: ellipsis;
white-space: nowrap;
transform: translateY(var(--ui-control-text-offset-y));
}
</style>
@@ -16,8 +16,20 @@ export default defineComponent({
const { state, execute, isLoading } = useAsyncState(
async () => {
const res = await api.getSkillFromCode(skillCode.value)
// console.log(res)
configStore.config.skills = res.skills
const shortcuts = (res.shortcuts ?? []) as { skill_id: number; slot_index: number }[]
const combos = (res.combos ?? []) as {
slot_index: number
skills: { skill_id: number }[]
}[]
const importedShortcuts = res.job?.name === 'dark_knight' ? shortcuts.slice(6) : shortcuts
configStore.config.skillShortcuts = {
shortcuts: importedShortcuts.map((item) => item.skill_id?.toString() ?? ''),
combos:
res.job?.name === 'dark_knight'
? combos.map((item) => item.skills.map((skill) => skill.skill_id?.toString() ?? ''))
: [],
}
const { alert } = useDialog()
visible.value = false
await alert({
@@ -58,8 +58,12 @@
<div class="equipment-groups">
<template v-for="group in equs" :key="group.name">
<section v-if="group.equs.length" class="equipment-group">
<h2 class="equipment-group__heading">{{ group.name }}</h2>
<UiDataSection
v-if="group.equs.length"
class="equipment-group"
:title="group.name"
compact
>
<div class="equipment-grid">
<button
v-for="(equipment, index) in group.equs"
@@ -82,7 +86,7 @@
<EquipmentIcon :inactive="!isActive(equipment)" :equipment="equipment" />
</button>
</div>
</section>
</UiDataSection>
</template>
<div v-if="!hasEquipmentResults" class="equipment-empty">没有符合当前条件的装备</div>
@@ -109,6 +113,7 @@
v-model:open="baseOpen"
class="equipment-detail-section"
title="基础信息"
compact
>
<div class="equipment-detail-content">
<Info :equipment="curEquInfo" />
@@ -120,6 +125,7 @@
v-model:open="suitOpen"
class="equipment-detail-section"
title="套装信息"
compact
>
<LevelControls
:rarity="curSuitLv.rarity"
@@ -137,6 +143,7 @@
v-model:open="oathOpen"
class="equipment-detail-section"
title="誓约信息"
compact
>
<LevelControls
:rarity="curOathSkillLv.rarity"
@@ -531,21 +538,8 @@ function chooseEqu(equipment?: IEquipment) {
margin-top: var(--ui-control-gap);
}
.equipment-group__heading {
box-sizing: border-box;
display: flex;
min-height: var(--ui-disclosure-header-min-block-size);
align-items: center;
justify-content: center;
margin: 0 0 var(--ui-autocomplete-trigger-gap);
padding-inline: var(--ui-field-padding-inline);
border-top: 1px solid var(--ui-disclosure-border);
border-bottom: 1px solid var(--ui-disclosure-divider);
background: var(--ui-disclosure-header-background);
color: var(--ui-settings-section-foreground);
font-size: var(--ui-control-font-size-default);
font-weight: 400;
line-height: var(--ui-control-line-height);
.equipment-group :deep(.ui-data-section__content) {
padding: var(--ui-autocomplete-trigger-gap);
}
.equipment-grid {
@@ -1,6 +1,6 @@
<template>
<div class="flex flex-wrap equ-profile">
<div class="equ-profile-item">
<div class="equ-profile-item equ-profile-item--wide">
<div class="row-name">迷雾系统</div>
<ForgeSlider
v-model="sundryData['fog']"
@@ -11,7 +11,7 @@
:step="1"
/>
</div>
<div class="equ-profile-item">
<div class="equ-profile-item equ-profile-item--wide">
<div class="row-name">冒险团</div>
<ForgeSlider
v-model="sundryData['adventure']"
@@ -22,7 +22,7 @@
:step="1"
/>
</div>
<div class="equ-profile-item">
<div class="equ-profile-item equ-profile-item--wide">
<div class="row-name">结婚系统</div>
<ForgeSelect
v-for="(key, index) in ['marriage_house', 'marriage_ring']"
@@ -40,7 +40,7 @@
:options="sundryOptions('contract')"
/>
</div>
<div class="equ-profile-item">
<div class="equ-profile-item equ-profile-item--wide">
<div class="row-name">收集箱</div>
<ForgeSelect
v-for="(key, index) in ['collection_type', 'collection_num_0', 'collection_num_1']"
+128 -25
View File
@@ -1,33 +1,59 @@
<template>
<main class="forge-page">
<UiDataSection title="装备/时装/徽章打造(点击部位切换)">
<TabsRoot v-model="partTab">
<UiTabsList class="forge-tabs" aria-label="打造类型">
<UiTabsTrigger :value="0">装备</UiTabsTrigger>
<UiTabsTrigger :value="2">誓约</UiTabsTrigger>
<UiTabsTrigger :value="1">时装</UiTabsTrigger>
<UiTabsTrigger :value="3">徽章</UiTabsTrigger>
</UiTabsList>
</TabsRoot>
<Equipment v-if="partTab === 0" />
<Avatars v-else-if="partTab === 1" />
<Oaths v-else-if="partTab === 2" />
<Emblems v-else />
</UiDataSection>
<div class="forge-page__editor">
<UiDataSection title="装备/时装/徽章打造(点击部位切换)">
<TabsRoot v-model="partTab">
<UiTabsList class="forge-tabs" aria-label="打造类型">
<UiTabsTrigger :value="0">装备</UiTabsTrigger>
<UiTabsTrigger :value="2">誓约</UiTabsTrigger>
<UiTabsTrigger :value="1">时装</UiTabsTrigger>
<UiTabsTrigger :value="3">徽章</UiTabsTrigger>
</UiTabsList>
</TabsRoot>
<Equipment v-if="partTab === 0" />
<Avatars v-else-if="partTab === 1" />
<Oaths v-else-if="partTab === 2" />
<Emblems v-else />
</UiDataSection>
<UiDataSection title="辟邪玉">
<Jades />
</UiDataSection>
<UiDataSection title="辟邪玉">
<Jades />
</UiDataSection>
<UiDataSection title="其它">
<Sundry />
</UiDataSection>
<UiDataSection title="其它">
<Sundry />
</UiDataSection>
</div>
<aside class="forge-page__summary" aria-label="打造摘要">
<header>打造摘要</header>
<UiDataRow
><span>已穿戴装备</span><strong>{{ equippedCount }}</strong></UiDataRow
>
<UiDataRow
><span>已配置增幅</span><strong>{{ reinforcedCount }}</strong></UiDataRow
>
<UiDataRow
><span>已配置附魔</span><strong>{{ enchantedCount }}</strong></UiDataRow
>
<UiDataRow
><span>已配置融合</span><strong>{{ fusionCount }}</strong></UiDataRow
>
<UiDataRow
><span>已配置时装</span><strong>{{ avatarCount }}</strong></UiDataRow
>
<UiDataRow
><span>辟邪玉槽位</span><strong>{{ jadeCount }}</strong></UiDataRow
>
<p>左侧继续使用现有打造组件;摘要只读取当前配置,不改变计算数据。</p>
</aside>
</main>
</template>
<script setup lang="ts">
import { UiDataSection } from '@/components/ui/data-section'
import { UiDataRow, UiDataSection } from '@/components/ui/data-section'
import { TabsRoot, UiTabsList, UiTabsTrigger } from '@/components/ui/tabs'
import { useConfigStore } from '@/stores'
import Avatars from './components/Avatars.vue'
import Emblems from './components/Emblems.vue'
import Equipment from './components/Equipments.vue'
@@ -36,21 +62,90 @@ import Oaths from './components/Oaths.vue'
import Sundry from './components/Sundry.vue'
const partTab = ref(0)
const configStore = useConfigStore()
const equipmentValues = computed(() => Object.values(configStore.config.equips))
const equippedCount = computed(() => equipmentValues.value.filter((item) => item.id).length)
const reinforcedCount = computed(
() => equipmentValues.value.filter((item) => item.reinforce > 0 || item.refine > 0).length,
)
const enchantedCount = computed(
() => equipmentValues.value.filter((item) => Boolean(item.enchant)).length,
)
const fusionCount = computed(
() =>
equipmentValues.value.filter((item) => Boolean(item.fusion) || item.weaponFusion > 0).length,
)
const avatarCount = computed(
() => Object.values(configStore.config.avatar).filter((item) => Boolean(item.id)).length,
)
const jadeCount = computed(
() => Object.values(configStore.config.jades).filter((item) => item.id >= 0).length,
)
</script>
<style lang="scss">
.forge-page {
box-sizing: border-box;
width: 100%;
max-width: 760px;
max-width: 840px;
min-width: 0;
padding-right: clamp(4px, 0.8vw, 12px);
display: grid;
align-content: start;
grid-template-columns: minmax(0, 1fr) minmax(170px, 0.3fr);
gap: clamp(6px, 0.8vw, 12px);
overflow: auto;
overflow: hidden;
container-type: inline-size;
.forge-page__editor,
.forge-page__summary {
min-width: 0;
min-height: 0;
}
.forge-page__editor {
display: grid;
align-content: start;
gap: clamp(6px, 0.8vw, 12px);
overflow: auto;
overscroll-behavior: contain;
}
.forge-page__summary {
border: 1px solid var(--ui-composition-border);
background: var(--ui-composition-background);
overflow: auto;
> header {
display: flex;
min-height: var(--ui-disclosure-header-min-block-size);
align-items: center;
padding-inline: var(--ui-field-padding-inline);
border-bottom: 1px solid var(--ui-disclosure-divider);
background: var(--ui-disclosure-header-background);
color: var(--ui-settings-section-foreground);
}
.ui-data-row {
justify-content: space-between;
padding-inline: var(--ui-field-padding-inline);
}
strong {
color: var(--ui-disclosure-row-selected-foreground);
font:
400 12px Consolas,
monospace;
}
p {
margin: 0;
padding: var(--ui-control-padding-inline-default);
color: var(--ui-muted-foreground);
font-size: 11px;
line-height: 1.5;
}
}
.forge-tabs {
margin-bottom: clamp(8px, 1.2cqi, 14px);
}
@@ -136,5 +231,13 @@ const partTab = ref(0)
grid-column: 1 / -1;
}
}
@container (max-width: 650px) {
grid-template-columns: minmax(0, 1fr);
.forge-page__summary {
display: none;
}
}
}
</style>
+92 -61
View File
@@ -10,37 +10,37 @@
@update:model-value="navigateCharacterTab"
>
<UiTabsList aria-label="角色配置导航">
<UiTabsTrigger :value="`/character/equipment/${props.alter}`">装备设置</UiTabsTrigger>
<UiTabsTrigger :value="`/character/skill/${props.alter}`">技能设置</UiTabsTrigger>
<UiTabsTrigger :value="`/character/forge/${props.alter}`">打造设置</UiTabsTrigger>
<UiTabsTrigger :value="`/character/equipment/${props.alter}`">装备</UiTabsTrigger>
<UiTabsTrigger :value="`/character/skill/${props.alter}`">技能</UiTabsTrigger>
<UiTabsTrigger :value="`/character/forge/${props.alter}`">打造</UiTabsTrigger>
</UiTabsList>
</TabsRoot>
<div class="character-shell__tools" aria-label="计算工具">
<label class="character-toggle">
<UiSwitch v-model="configStore.config.DSB" />
<span>系统奶</span>
</label>
<label class="character-toggle">
<UiSwitch v-model="configStore.config.BUFF" />
<span>组队</span>
</label>
<UiButton size="icon" title="打造导入" aria-label="打造导入" @click="showImport">
<img data-icon class="character-toolbar-icon" :src="importIcon" alt="" />
</UiButton>
<UiButton size="icon" title="技能导入" aria-label="技能导入" @click="showImportSkill">
<img data-icon class="character-toolbar-icon" :src="exchangeIcon" alt="" />
</UiButton>
<UiButton :disabled="isExecuting" @click="recalculate">开始计算</UiButton>
</div>
</header>
<div class="character-shell__workspace">
<section v-if="!!infoStore.infos?.alter" class="character-shell__config">
<RouterView></RouterView>
</section>
<div class="character-shell__stage" aria-hidden="true"></div>
<aside class="character-shell__result" aria-label="计算结果">
<Result></Result>
<Result :show-overview="route.name === 'equipment'" />
</aside>
</div>
<footer class="character-shell__footer">
<label class="character-toggle">
<UiSwitch v-model="configStore.config.DSB" />
<span>系统奶</span>
</label>
<label class="character-toggle">
<UiSwitch v-model="configStore.config.BUFF" />
<span>组队</span>
</label>
<UiButton size="icon" title="打造导入" aria-label="打造导入" @click="showImport">
<img data-icon class="character-toolbar-icon" :src="importIcon" alt="" />
</UiButton>
<UiButton size="icon" title="技能导入" aria-label="技能导入" @click="showImportSkill">
<img data-icon class="character-toolbar-icon" :src="exchangeIcon" alt="" />
</UiButton>
</footer>
</div>
<ImportDialog v-model:open="importVisible" title="打造导入">
@@ -110,6 +110,11 @@ const showImportSkill = () => {
importSkillVisible.value = true
}
const recalculate = () => {
if (isExecuting.value) return
void configStore.result.execute()
}
const route = useRoute()
const router = useRouter()
@@ -150,13 +155,12 @@ onUnmounted(() => {
--ui-control-height-compact: clamp(24px, 1.25vw, 26px);
--ui-control-height-default: clamp(28px, 1.5vw, 32px);
--ui-control-height-large: clamp(42px, 2.3vw, 48px);
--ui-tabs-height: clamp(40px, 2.05vw, 44px);
--ui-tab-height: clamp(30px, 1.5vw, 32px);
--ui-tabs-height: 32px;
--ui-tab-height: 24px;
--ui-control-gap: clamp(5px, 0.4vw, 8px);
--ui-item-slot-size: clamp(30px, 1.6vw, 34px);
--ui-item-image-size: clamp(28px, 1.45vw, 30px);
--ui-character-config-inline-size: clamp(340px, 21vw, 480px);
--ui-character-footer-min-block-size: clamp(38px, 2vw, 42px);
--ui-character-config-inline-size: clamp(360px, 28vw, 520px);
position: relative;
isolation: isolate;
@@ -181,8 +185,7 @@ onUnmounted(() => {
pointer-events: none;
}
.character-shell__header,
.character-shell__footer {
.character-shell__header {
position: relative;
z-index: 3;
flex: 0 0 auto;
@@ -195,12 +198,24 @@ onUnmounted(() => {
display: flex;
min-height: var(--ui-tabs-height);
align-items: center;
justify-content: space-between;
gap: var(--ui-control-gap);
padding-inline-end: var(--ui-character-workspace-padding);
border-bottom-width: var(--ui-component-stroke-size);
border-bottom-style: solid;
}
.character-shell__navigation {
width: 100%;
min-width: 0;
overflow-x: auto;
overscroll-behavior-inline: contain;
}
.character-shell__tools {
display: flex;
min-width: max-content;
align-items: center;
gap: var(--ui-control-gap);
}
.character-shell__workspace {
@@ -212,8 +227,7 @@ onUnmounted(() => {
flex: 1;
grid-template-columns:
minmax(0, var(--ui-character-config-inline-size))
minmax(0, 1fr)
max-content;
minmax(0, 1fr);
gap: var(--ui-character-workspace-gap);
padding: var(--ui-character-workspace-padding);
overflow: hidden;
@@ -234,42 +248,15 @@ onUnmounted(() => {
max-height: 100%;
}
.character-shell__stage {
position: relative;
min-width: 0;
pointer-events: none;
}
.character-shell__stage::before {
content: '';
position: absolute;
inset: 7% -12% 4%;
background: var(--ui-character-stage-glow);
filter: blur(var(--ui-character-stage-glow-blur));
opacity: var(--ui-character-stage-glow-opacity);
}
.character-shell__result {
position: relative;
z-index: 2;
overflow: visible;
}
.character-shell__footer {
box-sizing: border-box;
display: flex;
min-height: var(--ui-character-footer-min-block-size);
align-items: center;
flex-wrap: wrap;
gap: var(--ui-control-gap);
padding: var(--ui-character-footer-padding);
border-top-width: var(--ui-component-stroke-size);
border-top-style: solid;
overflow: hidden;
}
.character-shell--skill,
.character-shell--forge {
--ui-character-config-inline-size: clamp(400px, 36vw, 760px);
--ui-character-config-inline-size: clamp(700px, 54vw, 840px);
}
@media (min-width: 1600px) {
@@ -295,14 +282,58 @@ onUnmounted(() => {
@media (max-width: 1179px) {
.character-shell {
--ui-character-config-inline-size: clamp(332px, 34vw, 360px);
--ui-character-config-inline-size: clamp(332px, 34vw, 380px);
--ui-character-workspace-gap: 4px;
--ui-character-workspace-padding: 4px;
}
.character-shell--skill,
.character-shell--forge {
--ui-character-config-inline-size: clamp(380px, 40vw, 420px);
--ui-character-config-inline-size: clamp(620px, 58vw, 700px);
}
}
@media (max-width: 900px) {
.character-shell {
--ui-character-config-inline-size: min(100%, 520px);
}
.character-shell--skill,
.character-shell--forge {
--ui-character-config-inline-size: min(100%, 760px);
}
.character-shell__header {
align-items: stretch;
flex-direction: column;
padding-inline-end: 0;
}
.character-shell__tools {
min-height: var(--ui-control-height-large);
justify-content: flex-end;
padding-inline: var(--ui-character-workspace-padding);
border-top: var(--ui-component-stroke-size) solid var(--ui-character-chrome-border);
}
.character-shell__workspace {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(560px, calc(100dvh - 96px)) minmax(1124px, auto);
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
}
.character-shell__config,
.character-shell__result {
width: 100%;
height: 100%;
overflow: hidden;
}
.character-shell__config > * {
max-width: min(100%, var(--ui-character-config-inline-size));
margin-inline: auto;
}
}
</style>
@@ -2,9 +2,12 @@
<section
v-if="configStore.result.state"
class="result-shell"
:class="{ 'result-shell--buffer': configStore.result.state.buffer }"
:class="{
'result-shell--buffer': configStore.result.state.buffer,
'result-shell--without-overview': !showOverview,
}"
>
<div class="result-overview">
<div v-if="showOverview" class="result-overview">
<div class="result-overview__actions">
<UiButton class="flex-1" @click="setStandard">设为基准</UiButton>
<UiButton class="flex-1" variant="outline" @click="clearStandard">清空基准</UiButton>
@@ -54,6 +57,13 @@ const configStore = useConfigStore()
const infoStore = useInfoStore()
const type = ref<'equ' | 'oath'>('equ')
withDefaults(
defineProps<{
showOverview?: boolean
}>(),
{ showOverview: true },
)
const setStandard = () => {
if (!configStore?.result?.state?.uuid) return
infoStore.setStandard(configStore.result.state, configStore.skillCountConfig)
@@ -68,20 +78,22 @@ const clearStandard = () => {
.result-shell {
position: relative;
display: grid;
width: calc(
var(--ui-character-result-info-inline-size) + var(--ui-character-result-summary-inline-size) +
var(--ui-character-result-toggle-inline-size)
);
width: 100%;
min-width: 0;
height: 100%;
min-height: 0;
grid-template-columns:
var(--ui-character-result-info-inline-size)
var(--ui-character-result-summary-inline-size);
minmax(0, 1fr);
gap: var(--ui-character-result-toggle-inline-size);
overflow: visible;
}
.result-shell--without-overview {
grid-template-columns: minmax(0, 1fr);
gap: 0;
}
.result-overview {
display: grid;
min-width: 0;
@@ -109,15 +121,18 @@ const clearStandard = () => {
min-height: 0;
}
@media (min-width: 1600px) {
@media (min-width: 1180px) {
.result-shell {
width: calc(
var(--ui-character-result-info-inline-size) + var(--ui-character-result-full-inline-size) +
var(--ui-character-workspace-gap)
);
grid-template-columns:
var(--ui-character-result-info-inline-size)
var(--ui-character-result-full-inline-size);
gap: var(--ui-character-workspace-gap);
}
}
@media (max-width: 900px) {
.result-shell {
height: 100%;
min-height: 100%;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: repeat(2, minmax(560px, 1fr));
gap: var(--ui-character-workspace-gap);
}
}
@@ -118,7 +118,7 @@
</div>
<footer class="damage-panel__total" :class="toneClass(totalTone)">
<span>{{ totalStandard ? "相对基准" : "总伤害" }}</span>
<span>{{ totalStandard ? '相对基准' : '总伤害' }}</span>
<strong>{{ showTotalText }}</strong>
</footer>
</div>
@@ -126,166 +126,166 @@
</template>
<script setup lang="ts" name="Skill">
import type { IResultSkill, IResultSkillCount } from "@/api/calc/type";
import { UiNumberField } from "@/components/ui/number-field";
import { useConfigStore } from "@/stores";
import { getImageURL } from "@/utils/images";
import type { IResultSkill, IResultSkillCount } from '@/api/calc/type'
import { UiNumberField } from '@/components/ui/number-field'
import { useConfigStore } from '@/stores'
import { getImageURL } from '@/utils/images'
type SortKey = "learnLv" | "cd" | "dps" | "total";
type Tone = "positive" | "negative" | undefined;
type SortKey = 'learnLv' | 'cd' | 'dps' | 'total'
type Tone = 'positive' | 'negative' | undefined
const props = defineProps<{
data: IResultSkill[];
data: IResultSkill[]
standard?: {
skills: IResultSkill[];
skillCount: IResultSkillCount[];
};
}>();
skills: IResultSkill[]
skillCount: IResultSkillCount[]
}
}>()
const expanded = ref(false);
const sort = ref<SortKey>("learnLv");
const order = ref<"asc" | "desc">("desc");
const config = useConfigStore();
const expanded = ref(false)
const sort = ref<SortKey>('learnLv')
const order = ref<'asc' | 'desc'>('desc')
const config = useConfigStore()
watchEffect(() => {
props.data.forEach((skill) => {
const exists = config.skillCountConfig.some(
(item) => item.name === skill.name && item.mode === skill.mode,
);
if (!exists) config.skillCountConfig.push({ name: skill.name, mode: skill.mode, count: 1 });
});
});
)
if (!exists) config.skillCountConfig.push({ name: skill.name, mode: skill.mode, count: 1 })
})
})
function skillCountValue(skill: IResultSkill) {
return (
config.skillCountConfig.find((item) => item.name === skill.name && item.mode === skill.mode)
?.count ?? 1
);
)
}
function setSkillCount(skill: IResultSkill, value: number | undefined) {
const count = typeof value === "number" && Number.isFinite(value) ? value : 0;
const count = typeof value === 'number' && Number.isFinite(value) ? value : 0
const target = config.skillCountConfig.find(
(item) => item.name === skill.name && item.mode === skill.mode,
);
if (target) target.count = count;
else config.skillCountConfig.push({ name: skill.name, mode: skill.mode, count });
)
if (target) target.count = count
else config.skillCountConfig.push({ name: skill.name, mode: skill.mode, count })
}
const totalDamage = computed(() =>
props.data.reduce((sum, skill) => sum + skill.damage * skillCountValue(skill), 0),
);
)
const totalStandard = computed(() => {
if (!props.standard) return undefined;
if (!props.standard) return undefined
return props.standard.skills.reduce((sum, skill) => {
const count = props.standard?.skillCount.find(
(item) => item.name === skill.name && item.mode === skill.mode,
)?.count;
return sum + skill.damage * (count ?? 0);
}, 0);
});
)?.count
return sum + skill.damage * (count ?? 0)
}, 0)
})
function standardValues(skill: IResultSkill) {
const standardSkill = props.standard?.skills.find(
(item) => item.name === skill.name && item.mode === skill.mode,
);
)
const standardCount = props.standard?.skillCount.find(
(item) => item.name === skill.name && item.mode === skill.mode,
)?.count;
)?.count
if (!standardSkill || standardSkill.damage <= 0 || standardSkill.cd <= 0 || !standardCount) {
return undefined;
return undefined
}
return { skill: standardSkill, count: standardCount };
return { skill: standardSkill, count: standardCount }
}
function comparisonTone(value: number): Tone {
return value >= 0 ? "positive" : "negative";
return value >= 0 ? 'positive' : 'negative'
}
function formatPercent(value: number) {
return `${(value * 100).toFixed(2)}%`;
return `${(value * 100).toFixed(2)}%`
}
function sortMetric(skill: IResultSkill, key: SortKey) {
if (key === "total") return skill.damage * skillCountValue(skill);
if (key === "dps") return skill.cd > 0 ? skill.damage / skill.cd : 0;
return Number(skill[key] ?? 0);
if (key === 'total') return skill.damage * skillCountValue(skill)
if (key === 'dps') return skill.cd > 0 ? skill.damage / skill.cd : 0
return Number(skill[key] ?? 0)
}
const sortedSkills = computed(() =>
[...props.data].sort((left, right) => {
const leftValue = sortMetric(left, sort.value);
const rightValue = sortMetric(right, sort.value);
return order.value === "asc" ? leftValue - rightValue : rightValue - leftValue;
const leftValue = sortMetric(left, sort.value)
const rightValue = sortMetric(right, sort.value)
return order.value === 'asc' ? leftValue - rightValue : rightValue - leftValue
}),
);
)
const rows = computed(() =>
sortedSkills.value.map((skill) => {
const count = skillCountValue(skill);
const currentTotal = skill.damage * count;
const currentDps = skill.cd > 0 ? skill.damage / skill.cd : 0;
const standard = standardValues(skill);
let dps = Math.round(currentDps).toLocaleString();
let total = Math.round(currentTotal).toLocaleString();
let dpsTone: Tone;
let totalTone: Tone;
const count = skillCountValue(skill)
const currentTotal = skill.damage * count
const currentDps = skill.cd > 0 ? skill.damage / skill.cd : 0
const standard = standardValues(skill)
let dps = Math.round(currentDps).toLocaleString()
let total = Math.round(currentTotal).toLocaleString()
let dpsTone: Tone
let totalTone: Tone
if (standard) {
const standardDps = standard.skill.damage / standard.skill.cd;
const standardTotal = standard.skill.damage * standard.count;
const dpsDelta = standardDps === 0 ? 0 : (currentDps - standardDps) / standardDps;
const totalDelta = standardTotal === 0 ? 0 : (currentTotal - standardTotal) / standardTotal;
dps = formatPercent(dpsDelta);
total = formatPercent(totalDelta);
dpsTone = comparisonTone(dpsDelta);
totalTone = comparisonTone(totalDelta);
const standardDps = standard.skill.damage / standard.skill.cd
const standardTotal = standard.skill.damage * standard.count
const dpsDelta = standardDps === 0 ? 0 : (currentDps - standardDps) / standardDps
const totalDelta = standardTotal === 0 ? 0 : (currentTotal - standardTotal) / standardTotal
dps = formatPercent(dpsDelta)
total = formatPercent(totalDelta)
dpsTone = comparisonTone(dpsDelta)
totalTone = comparisonTone(totalDelta)
}
const share = totalDamage.value === 0 ? 0 : currentTotal / totalDamage.value;
const share = totalDamage.value === 0 ? 0 : currentTotal / totalDamage.value
return {
key: `${skill.name}-${skill.mode ?? ""}`,
key: `${skill.name}-${skill.mode ?? ''}`,
skill,
displayName: `${skill.name}${skill.mode ? ` [${skill.mode}]` : ""}`,
displayName: `${skill.name}${skill.mode ? ` [${skill.mode}]` : ''}`,
count,
cd: typeof skill.cd === "number" ? `${skill.cd.toFixed(1)}s` : "-",
cd: typeof skill.cd === 'number' ? `${skill.cd.toFixed(1)}s` : '-',
dps,
total,
dpsTone,
totalTone,
share: formatPercent(share),
};
}
}),
);
)
const showTotal = computed(() => {
if (!totalStandard.value) return totalDamage.value;
return ((totalDamage.value - totalStandard.value) / totalStandard.value) * 100;
});
if (!totalStandard.value) return totalDamage.value
return ((totalDamage.value - totalStandard.value) / totalStandard.value) * 100
})
const showTotalText = computed(() =>
totalStandard.value
? `${showTotal.value.toFixed(2)}%`
: Math.round(showTotal.value).toLocaleString(),
);
)
const totalTone = computed<Tone>(() =>
totalStandard.value ? comparisonTone(showTotal.value) : undefined,
);
)
function toneClass(tone: Tone) {
return {
"is-positive": tone === "positive",
"is-negative": tone === "negative",
};
'is-positive': tone === 'positive',
'is-negative': tone === 'negative',
}
}
function sortValue(key: SortKey) {
if (sort.value === key) order.value = order.value === "asc" ? "desc" : "asc";
if (sort.value === key) order.value = order.value === 'asc' ? 'desc' : 'asc'
else {
sort.value = key;
order.value = "desc";
sort.value = key
order.value = 'desc'
}
}
</script>
@@ -293,6 +293,7 @@ function sortValue(key: SortKey) {
<style scoped lang="scss">
.damage-panel {
position: relative;
container: damage-result / inline-size;
width: 100%;
min-width: 0;
height: 100%;
@@ -572,6 +573,38 @@ button.damage-panel__heading {
}
}
@media (max-width: 900px) {
.damage-panel__toggle {
display: none;
}
}
@container damage-result (min-width: 620px) {
.damage-panel__toggle {
display: none;
}
.damage-panel.is-expanded .damage-panel__surface {
position: relative;
inset: auto;
width: 100%;
}
.damage-panel__grid {
grid-template-columns:
minmax(180px, 1fr)
var(--ui-character-result-cd-inline-size)
var(--ui-character-result-count-inline-size)
minmax(var(--ui-character-result-value-min-inline-size), 0.9fr)
minmax(var(--ui-character-result-value-min-inline-size), 1fr)
var(--ui-character-result-share-inline-size);
}
.damage-panel__full-column {
display: flex;
}
}
@media (min-width: 1600px) {
.damage-panel__toggle {
display: none;
@@ -0,0 +1,435 @@
<template>
<aside ref="settingRef" class="dark-knight-setting" :class="{ 'is-collapsed': collapsed }">
<UiButton
v-if="collapsed"
size="icon"
class="dark-knight-setting__launcher"
title="展开技能排列"
aria-label="展开技能排列"
@click="collapsed = false"
>
<img data-icon :src="shortcutIcon" alt="" />
</UiButton>
<template v-else>
<header class="dark-knight-setting__header">
<span>技能排列</span>
<UiButton size="icon" variant="ghost" aria-label="收起技能排列" @click="collapse">
−
</UiButton>
</header>
<section class="dark-knight-setting__section">
<h3>快捷栏</h3>
<div class="shortcut-grid">
<button
v-for="(key, index) in shortcutKeys"
:key="key"
type="button"
class="skill-slot"
:class="{ 'is-selected': isEditing('shortcut', index) }"
:title="getSkill(arrangement.shortcuts[index])?.name || `快捷键 ${key}`"
@click="openPicker('shortcut', index)"
>
<img
v-if="getSkill(arrangement.shortcuts[index])"
:src="getImageURL(getSkill(arrangement.shortcuts[index])!.icon)"
:alt="getSkill(arrangement.shortcuts[index])!.name"
/>
<span v-else>{{ key }}</span>
</button>
</div>
</section>
<section class="dark-knight-setting__section dark-knight-setting__combos">
<h3>连续技</h3>
<div class="combo-grid">
<div v-for="(key, column) in comboKeys" :key="key" class="combo-column">
<template v-for="stage in stageCount" :key="stage">
<button
type="button"
class="skill-slot combo-slot"
:class="{
'is-selected': isEditing('combo', column, stage - 1),
'is-disabled': isStageDisabled(column, stage - 1),
}"
:disabled="isStageDisabled(column, stage - 1)"
:title="comboTitle(column, stage - 1, key)"
@click="openPicker('combo', column, stage - 1)"
>
<img
v-if="getSkill(arrangement.combos[column][stage - 1])"
:src="getImageURL(getSkill(arrangement.combos[column][stage - 1])!.icon)"
:alt="getSkill(arrangement.combos[column][stage - 1])!.name"
/>
<span v-else>{{ stage }}</span>
</button>
<span
v-if="stage < stageCount"
class="stage-arrow"
:class="{ 'is-connected': isStageConnected(column, stage - 1) }"
aria-hidden="true"
></span>
</template>
<UiButton
size="sm"
variant="outline"
:aria-label="`重置 ${key} 连续技`"
@click="resetCombo(column)"
>
↻
</UiButton>
</div>
</div>
</section>
<section v-if="editingSlot" class="skill-picker" aria-label="选择主动技能">
<header>
<span>选择主动技能</span>
<div>
<UiButton size="sm" variant="outline" :disabled="!currentValue" @click="selectSkill('')"
>清空</UiButton
>
<UiButton
size="icon"
variant="ghost"
aria-label="关闭技能选择"
@click="editingSlot = null"
>×</UiButton
>
</div>
</header>
<div class="skill-picker__grid">
<button
v-for="skill in selectableSkills"
:key="skill.id"
type="button"
class="skill-picker__item"
:class="{ 'is-active': currentValue === skill.id.toString() }"
:disabled="isSkillUsed(skill.id.toString())"
:title="skill.name"
@click="selectSkill(skill.id.toString())"
>
<img :src="getImageURL(skill.icon)" :alt="skill.name" />
<span>{{ skill.name }}</span>
</button>
</div>
</section>
</template>
</aside>
</template>
<script setup lang="ts">
import type { ISkill } from '@/api/info/type'
import { UiButton } from '@/components/ui/button'
import type { ISkillShortcuts } from '@/stores/config'
import { getImageURL } from '@/utils/images'
import { onClickOutside, useEventListener } from '@vueuse/core'
import shortcutIcon from '../img/shortcut.png'
type EditingSlot =
| { type: 'shortcut'; index: number }
| { type: 'combo'; column: number; stage: number }
const shortcutKeys = ['Q', 'W', 'E', 'R', 'T', 'Y']
const comboKeys = ['A', 'S', 'D', 'F', 'G', 'H']
const stageCount = 5
const excludedSkillNames = new Set(['后跳', '受身蹲伏', '跃翔', '黑暗释放'])
const excludedAwakeLevels = new Set([85, 100])
const props = defineProps<{
skills: ISkill[]
modelValue?: ISkillShortcuts
}>()
const emit = defineEmits<{
'update:modelValue': [value: ISkillShortcuts]
}>()
const settingRef = ref<HTMLElement>()
const editingSlot = ref<EditingSlot | null>(null)
const collapsed = ref(false)
const arrangement = computed<ISkillShortcuts>(() => ({
shortcuts: Array.from(
{ length: shortcutKeys.length },
(_, index) => props.modelValue?.shortcuts?.[index] ?? '',
),
combos: Array.from({ length: comboKeys.length }, (_, column) =>
Array.from(
{ length: stageCount },
(_, stage) => props.modelValue?.combos?.[column]?.[stage] ?? '',
),
),
}))
const skillMap = computed(() => new Map(props.skills.map((skill) => [skill.id.toString(), skill])))
const selectableSkills = computed(() =>
[...props.skills]
.filter(
(skill) =>
skill.type === 'active' &&
!excludedSkillNames.has(skill.name) &&
!excludedAwakeLevels.has(skill.learnLv),
)
.sort((left, right) => {
const [leftColumn = -1, leftRow = -1] = left.cellPosition ?? []
const [rightColumn = -1, rightRow = -1] = right.cellPosition ?? []
return leftRow - rightRow || leftColumn - rightColumn || left.learnLv - right.learnLv
}),
)
const selectedSkillIds = computed(
() =>
new Set([...arrangement.value.shortcuts, ...arrangement.value.combos.flat()].filter(Boolean)),
)
const currentValue = computed(() => {
const slot = editingSlot.value
if (!slot) return ''
return slot.type === 'shortcut'
? arrangement.value.shortcuts[slot.index]
: arrangement.value.combos[slot.column][slot.stage]
})
const getSkill = (skillId: string) => skillMap.value.get(skillId)
const isEditing = (type: EditingSlot['type'], index: number, stage?: number) => {
const slot = editingSlot.value
if (!slot) return false
return type === 'shortcut'
? slot.type === 'shortcut' && slot.index === index
: slot.type === 'combo' && slot.column === index && slot.stage === stage
}
const isStageDisabled = (column: number, stage: number) =>
stage > 0 && !arrangement.value.combos[column][stage - 1]
const isStageConnected = (column: number, stage: number) =>
Boolean(arrangement.value.combos[column][stage] && arrangement.value.combos[column][stage + 1])
const comboTitle = (column: number, stage: number, key: string) =>
isStageDisabled(column, stage)
? `请先设置 ${stage} 阶段`
: getSkill(arrangement.value.combos[column][stage])?.name || `${stage + 1}阶段 ${key}`
const openPicker = (type: EditingSlot['type'], index: number, stage = 0) => {
if (type === 'combo' && isStageDisabled(index, stage)) return
editingSlot.value = type === 'shortcut' ? { type, index } : { type, column: index, stage }
}
const isSkillUsed = (skillId: string) =>
selectedSkillIds.value.has(skillId) && currentValue.value !== skillId
const selectSkill = (skillId: string) => {
const slot = editingSlot.value
if (!slot || isSkillUsed(skillId)) return
const next: ISkillShortcuts = {
shortcuts: [...arrangement.value.shortcuts],
combos: arrangement.value.combos.map((column) => [...column]),
}
if (slot.type === 'shortcut') next.shortcuts[slot.index] = skillId
else {
next.combos[slot.column][slot.stage] = skillId
if (!skillId) next.combos[slot.column].fill('', slot.stage)
}
emit('update:modelValue', next)
if (slot.type === 'combo' && skillId) {
editingSlot.value = slot.stage < stageCount - 1 ? { ...slot, stage: slot.stage + 1 } : null
}
}
const resetCombo = (column: number) => {
emit('update:modelValue', {
shortcuts: [...arrangement.value.shortcuts],
combos: arrangement.value.combos.map((items, index) =>
index === column ? Array.from({ length: stageCount }, () => '') : [...items],
),
})
if (editingSlot.value?.type === 'combo' && editingSlot.value.column === column) {
editingSlot.value = { type: 'combo', column, stage: 0 }
}
}
const collapse = () => {
editingSlot.value = null
collapsed.value = true
}
onClickOutside(settingRef, () => (editingSlot.value = null))
useEventListener(window, 'blur', () => (editingSlot.value = null))
</script>
<style scoped lang="scss">
.dark-knight-setting {
position: relative;
box-sizing: border-box;
width: 100%;
min-width: 0;
border: 1px solid var(--ui-disclosure-border);
background: var(--ui-disclosure-background);
color: var(--ui-disclosure-state);
&.is-collapsed {
width: var(--ui-control-height-large);
border: 0;
background: transparent;
}
}
.dark-knight-setting__launcher img {
width: 30px;
height: 30px;
object-fit: contain;
}
.dark-knight-setting__header,
.skill-picker header {
display: flex;
min-height: var(--ui-disclosure-header-min-block-size);
align-items: center;
justify-content: space-between;
padding-inline: var(--ui-field-padding-inline);
border-bottom: 1px solid var(--ui-disclosure-divider);
background: var(--ui-disclosure-header-background);
}
.dark-knight-setting__header .ui-button {
--ui-control-height-default: 20px;
}
.dark-knight-setting__section {
padding: var(--ui-control-gap);
}
.dark-knight-setting__section h3 {
margin: 0 0 var(--ui-control-gap);
color: var(--ui-settings-section-foreground);
font-size: 12px;
font-weight: 400;
}
.shortcut-grid,
.combo-grid {
display: grid;
grid-template-columns: repeat(6, 28px);
justify-content: center;
gap: 5px;
}
.combo-column {
display: flex;
align-items: center;
flex-direction: column;
}
.skill-slot {
display: grid;
width: 28px;
height: 28px;
padding: 0;
place-items: center;
border: 1px solid var(--ui-border);
outline: 0;
background: var(--ui-item-slot-background);
color: var(--ui-muted-foreground);
font: inherit;
cursor: pointer;
box-shadow: inset 0 0 0 1px #000;
img {
width: 26px;
height: 26px;
object-fit: contain;
}
&:hover,
&:focus-visible,
&.is-selected {
border-color: var(--ui-ring);
}
&.is-disabled {
opacity: 0.35;
cursor: not-allowed;
}
}
.stage-arrow {
position: relative;
width: 20px;
height: 11px;
}
.stage-arrow::before {
content: '';
position: absolute;
top: 0;
left: 7px;
width: 6px;
height: 6px;
background: var(--ui-muted);
}
.stage-arrow::after {
content: '';
position: absolute;
top: 5px;
left: 3px;
border-top: 6px solid var(--ui-muted);
border-right: 7px solid transparent;
border-left: 7px solid transparent;
}
.stage-arrow.is-connected::before {
background: var(--ui-primary);
}
.stage-arrow.is-connected::after {
border-top-color: var(--ui-primary);
}
.combo-column > .ui-button {
--ui-control-height-compact: 18px;
width: 28px;
min-width: 28px;
margin-top: 4px;
padding-inline: 0;
}
.skill-picker {
position: absolute;
z-index: var(--ui-popup-z-index);
right: 0;
bottom: 0;
box-sizing: border-box;
width: min(232px, 100%);
border: 1px solid var(--ui-popover-border);
background: var(--ui-popover-background);
box-shadow: 0 5px 18px var(--ui-popover-shadow);
}
.skill-picker header > div {
display: flex;
gap: 4px;
}
.skill-picker__grid {
display: grid;
max-height: 390px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 4px;
padding: 5px;
overflow-y: auto;
}
.skill-picker__item {
display: flex;
min-width: 0;
padding: 3px 2px;
align-items: center;
flex-direction: column;
gap: 2px;
border: 1px solid var(--ui-option-divider);
outline: 0;
background: var(--ui-popover-background);
color: var(--ui-popover-foreground);
font: inherit;
cursor: pointer;
img {
width: 28px;
height: 28px;
}
span {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:hover:not(:disabled),
&:focus-visible,
&.is-active {
border-color: var(--ui-ring);
color: var(--ui-option-highlight-foreground);
}
&:disabled {
filter: grayscale(1);
opacity: 0.3;
cursor: not-allowed;
}
}
</style>
@@ -1,6 +1,7 @@
import { asyncComputed } from '@vueuse/core'
import api from '@/api'
import './style.scss'
import { UiDataSection } from '@/components/ui/data-section'
import { TabsRoot, UiTabsList, UiTabsTrigger } from '@/components/ui/tabs'
export default defineComponent({
@@ -86,32 +87,48 @@ export default defineComponent({
const renderSkillDetail = () => (
<>
<div class="skill_title">基本信息</div>
<div class="skill-detail__stack">
{basic.value.map(
(item) =>
!!item.value && (
<div class="item skill-detail__row">
<span class="label">{item.label}</span>
<span class="value">{item.value}</span>
</div>
),
)}
</div>
<div class="skill_title">技能描述</div>
<div class="skill-detail__description-frame">
<div class="skill-detail__scroll-content">
{desc.value?.map((item) => (
// item.startsWith('[') || item.startsWith('-') || item.startsWith('<') ? (
// <div class="min-h-10px">{item}</div>
// ) :
<div class="skill-detail__description-line">{item}</div>
))}
<UiDataSection
class="skill-detail__section"
title="基本信息"
compact
showState={false}
indicatorPosition="end"
>
<div class="skill-detail__stack">
{basic.value.map(
(item) =>
!!item.value && (
<div class="item skill-detail__row">
<span class="label">{item.label}</span>
<span class="value">{item.value}</span>
</div>
),
)}
</div>
</div>
</UiDataSection>
<UiDataSection
class="skill-detail__section"
title="技能描述"
compact
showState={false}
indicatorPosition="end"
>
<div class="skill-detail__description-frame">
<div class="skill-detail__scroll-content">
{desc.value?.map((item) => (
<div class="skill-detail__description-line">{item}</div>
))}
</div>
</div>
</UiDataSection>
{detail.value.length > 0 && (
<>
<div class="skill_title">技能属性</div>
<UiDataSection
class="skill-detail__section skill-detail__section--grow"
title="技能属性"
compact
showState={false}
indicatorPosition="end"
>
<div class="skill-detail__flex-scroll">
<div class="skill-detail__stack">
{detail.value.map((item) => (
@@ -119,7 +136,7 @@ export default defineComponent({
))}
</div>
</div>
</>
</UiDataSection>
)}
</>
)
@@ -132,10 +149,17 @@ export default defineComponent({
<>
<div class="skill-detail__stack">
<div class="skill_vp_title">{item.name}</div>
<div class="skill_title">技能描述</div>
<div class="skill-detail__evolution-scroll">
<div class="skill-detail__evolution-copy">{item.descDetail}</div>
</div>
<UiDataSection
class="skill-detail__section"
title="技能描述"
compact
showState={false}
indicatorPosition="end"
>
<div class="skill-detail__evolution-scroll">
<div class="skill-detail__evolution-copy">{item.descDetail}</div>
</div>
</UiDataSection>
</div>
</>
))}
@@ -46,6 +46,29 @@
overflow-y: auto;
}
.skill-detail__section {
flex: 0 0 auto;
border-color: #3e4040;
}
.skill-detail__section--grow {
min-height: 0;
flex: 1;
}
.skill-detail__section .ui-data-section__content {
min-height: 0;
padding: 0;
}
.skill-detail__section--grow .ui-data-section__content {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
overflow: hidden;
}
.skill-detail__evolution {
height: auto;
}
@@ -136,16 +159,6 @@
}
}
.skill_title {
border: 1px solid #3e4040;
color: #c1a238;
background: linear-gradient(180deg, #2b292b 0%, #0b0b0b 100%);
height: 20px;
display: flex;
align-items: center;
padding: 0px 5px;
}
.item {
background-color: #171616;
padding: 0px 5px;
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -1,13 +1,17 @@
import { getImageURL } from '@/utils/images'
import type { ISkill } from '@/api/info/type'
import { useVModel } from '@vueuse/core'
import { UiButton } from '@/components/ui/button'
import { getImageURL } from '@/utils/images'
import { useVModel } from '@vueuse/core'
import addIcon from './img/add.svg'
import maxIcon from './img/max.svg'
import minIcon from './img/min.svg'
import subIcon from './img/sub.svg'
import './style.scss'
const rowPrefixes = ['通用', 1, '', 10, '', 20, '', 30, '', 40, 50, 60, 70, 80, 90, 100]
const defaultColumnCount = 11
const altersWithoutRowPrefix = new Set(['creator', 'dark_knight'])
export default defineComponent({
name: 'SkillTree',
props: {
@@ -21,269 +25,259 @@ export default defineComponent({
},
bindAwake: {
type: Number,
default: () => 50,
default: 50,
},
alter: {
type: String,
default: '',
},
},
emits: ['chooseSkill'],
emits: ['chooseSkill', 'update:lvInfo', 'update:bindAwake'],
setup(props, { emit }) {
const skills = computed(() => props.skills)
const lvInfo = useVModel(props, 'lvInfo')
const bindAwake = useVModel(props, 'bindAwake')
// const emit = defineEmits(['chooseSkill'])
const activeSkill = ref(-1)
const getSkill = (lvStart: number, lvEnd: number, position: number) => {
if (position >= 0)
return skills.value.find(
(item) =>
item.line > lvStart - 5 &&
item.line <= Math.max(lvEnd - 5, 1) &&
item.position == position,
)
else
return skills.value.filter(
(item) => item.line > lvStart - 5 && item.line <= Math.max(lvEnd - 5, 1),
)
const skillGrid = computed(() => {
const grid = new Map<string, ISkill>()
const occupiedRows = new Set<number>()
let maxColumn = defaultColumnCount - 1
props.skills.forEach((skill) => {
const [column, row] = skill.cellPosition ?? []
if (!Number.isInteger(column) || !Number.isInteger(row) || column < 0 || row < 0) return
grid.set(`${row}:${column}`, skill)
occupiedRows.add(row)
maxColumn = Math.max(maxColumn, column)
})
return {
grid,
columns: Array.from({ length: maxColumn + 1 }, (_, index) => index),
rows: Array.from(occupiedRows).sort((left, right) => left - right),
}
})
const showRowPrefix = computed(() => !altersWithoutRowPrefix.has(props.alter))
const isExtraAlter = computed(() => altersWithoutRowPrefix.has(props.alter))
const getRowPrefix = (row: number) => {
const lastRow = skillGrid.value.rows.at(-1) ?? -1
if (lastRow >= rowPrefixes.length && row >= rowPrefixes.length - 1) {
return row === lastRow ? rowPrefixes.at(-1) : ''
}
return rowPrefixes[row]
}
const lvList = [15, 20, 25, 30, 35, 40, 45, 50, 60, 70, 80, 90, 100]
const getLastPositionedSkill = (learnLv: number) =>
props.skills
.filter((skill) => skill.learnLv === learnLv && skill.type === 'active' && skill.bind)
.sort((left, right) => {
const [leftColumn = -1, leftRow = -1] = left.cellPosition ?? []
const [rightColumn = -1, rightRow = -1] = right.cellPosition ?? []
return leftRow - rightRow || leftColumn - rightColumn
})
.at(-1)
const columns = Array.from({ length: 11 }, (_, index) => index)
const awakeSkill1 = computed(() =>
props.skills.find((skill) => skill.learnLv === 50 && skill.type === 'active' && skill.bind),
)
const awakeSkill2 = computed(() =>
props.skills.find((skill) => skill.learnLv === 85 && skill.type === 'active' && skill.bind),
)
const extraAwakeSkill2 = computed(() =>
props.alter === 'creator'
? props.skills.find((skill) => skill.learnLv === 85 && skill.name === '生命之树')
: getLastPositionedSkill(85),
)
const extraAwakeSkill3 = computed(() => getLastPositionedSkill(100))
const activeSkill = ref<number>(-1)
watchEffect(() => {
if (isExtraAlter.value && bindAwake.value !== 85) bindAwake.value = 85
})
const chooseSkill = (skill?: ISkill, operation: string = 'add') => {
const actionSkillLevel = (skill: ISkill, operation: string) => {
const key = skill.id.toString()
const current = lvInfo.value[key] ?? { lv: 0, vp: 0, up: 0 }
if (!lvInfo.value[key]) lvInfo.value[key] = current
if (operation === 'add') current.lv = Math.min(current.lv + 1, skill.maxLv)
if (operation === 'addMax') current.lv = skill.maxLearnLv
if (operation === 'sub') current.lv = Math.max(current.lv - 1, 0)
if (operation === 'subMax') lvInfo.value[key] = { lv: 0, vp: 0, up: 0 }
}
const chooseSkill = (skill?: ISkill, operation = 'add') => {
if (!skill) {
activeSkill.value = -1
return
}
if (activeSkill.value == skill.id) actionSkillLv(skill, operation)
if (activeSkill.value === skill.id) actionSkillLevel(skill, operation)
else activeSkill.value = skill.id
emit('chooseSkill', skill.id)
}
const awakeSkill_1 = computed(() =>
skills.value.find((item) => item.learnLv == 50 && item.type == 'active' && item.bind),
)
const awakeSkill_2 = computed(() =>
skills.value.find((item) => item.learnLv == 85 && item.type == 'active' && item.bind),
)
const hasAwakeBinding = (skill: ISkill) =>
isExtraAlter.value
? skill.id === extraAwakeSkill3.value?.id
: skill.learnLv === 100 && skill.bind
const actionSkillLv = (skill: ISkill, operation: string) => {
if (operation == 'add') {
lvInfo.value[skill.id.toString()].lv = Math.min(
lvInfo.value[skill.id.toString()]?.lv + 1,
skill.maxLv,
)
return
const renderSkillAction = (skill: ISkill) => {
const level = lvInfo.value[skill.id.toString()]?.lv ?? 0
const action = (event: Event, operation: string) => {
event.stopPropagation()
actionSkillLevel(skill, operation)
}
if (operation == 'addMax') {
lvInfo.value[skill.id.toString()].lv = skill.maxLearnLv
return
}
if (operation == 'sub') {
lvInfo.value[skill.id.toString()].lv = Math.max(
lvInfo.value[skill.id.toString()]?.lv - 1,
0,
)
return
}
if (operation == 'subMax') {
lvInfo.value[skill.id.toString()] = { lv: 0, vp: 0, up: 0 }
return
}
emit('chooseSkill', skill.id)
}
const chooseBindAwake = (lv: number) => {
bindAwake.value = lv
}
const renderSkill = (skill: ISkill, index: number) => {
const info = lvInfo.value?.[skill.id.toString()]
let color = 'white'
if (info?.lv < skill.maxLearnLv) color = '#32E432'
if (info?.lv > skill.maxLearnLv) color = '#58E0FC'
return (
<>
<div class="skill-tree__slot">
<div class="skill-tree__enhancements">
{info?.vp > 0 && <div class={`vp_${info.vp}`}></div>}
{info?.up > 0 && <div class={`up_${info.up}`}></div>}
</div>
{activeSkill.value == skill.id && renderSkillAction(skill)}
<div
class={['skill-tree__skill', skill.learnLv == 100 && skill.bind ? '' : 'skill']}
onClick={(e) => {
e.stopPropagation()
chooseSkill(skill)
}}
onContextmenu={(e) => {
e.stopPropagation()
e.preventDefault()
chooseSkill(skill, 'sub')
}}
key={index}
<div class="skill-active skill-tree__actions">
<div class="skill-tree__action-list">
<UiButton
size="icon"
class="skill-action-button"
aria-label="技能等级归零"
disabled={level === 0}
onClick={(event: Event) => action(event, 'subMax')}
>
<img
class={['skill-tree__icon', skill.type === 'passive' ? 'passive' : '']}
src={getImageURL(skill.icon)}
alt={skill.name}
></img>
<div class="skill-tree__level" style={{ color }}>
{info?.lv ?? 0}
</div>
</div>
{skill.learnLv == 100 && skill.bind && (
<>
<div class={`skill-tree__awake awake_${bindAwake.value}`}>
<div class="skill-tree__awake-option" onClick={() => chooseBindAwake(50)}>
<img
class="skill-tree__icon skill-tree__awake-icon"
src={getImageURL(awakeSkill_1.value?.icon ?? '')}
></img>
<div class="skill-tree__awake-label">1次</div>
</div>
<div class="skill-tree__awake-option" onClick={() => chooseBindAwake(85)}>
<img
class="skill-tree__icon skill-tree__awake-icon"
src={getImageURL(awakeSkill_2.value?.icon ?? '')}
></img>
<div class="skill-tree__awake-label">2次</div>
</div>
</div>
</>
)}
<img data-icon src={minIcon} alt="" />
</UiButton>
<UiButton
size="icon"
class="skill-action-button"
aria-label="技能等级减一"
disabled={level === 0}
onClick={(event: Event) => action(event, 'sub')}
>
<img data-icon src={subIcon} alt="" />
</UiButton>
<UiButton
size="icon"
class="skill-action-button"
aria-label="技能等级加一"
onClick={(event: Event) => action(event, 'add')}
>
<img data-icon src={addIcon} alt="" />
</UiButton>
<UiButton
size="icon"
class="skill-action-button"
aria-label="技能等级加满"
disabled={level >= skill.maxLearnLv}
onClick={(event: Event) => action(event, 'addMax')}
>
<img data-icon src={maxIcon} alt="" />
</UiButton>
</div>
</>
</div>
)
}
const renderSkillAction = (skill: ISkill) => (
<>
<>
<div class="skill-active skill-tree__actions">
<div class="skill-tree__action-list">
<UiButton
size="icon"
class="skill-action-button"
aria-label="技能等级归零"
disabled={lvInfo.value[skill.id.toString()]?.lv == 0}
onClick={(e: Event) => {
e.stopPropagation()
actionSkillLv(skill, 'subMax')
}}
>
<img data-icon src={minIcon} alt="" />
</UiButton>
<UiButton
size="icon"
class="skill-action-button"
aria-label="技能等级减一"
disabled={lvInfo.value[skill.id.toString()]?.lv == 0}
onClick={(e: Event) => {
e.stopPropagation()
actionSkillLv(skill, 'sub')
}}
>
<img data-icon src={subIcon} alt="" />
</UiButton>
<UiButton
size="icon"
class="skill-action-button"
aria-label="技能等级加一"
onClick={(e: Event) => {
e.stopPropagation()
actionSkillLv(skill, 'add')
}}
>
<img data-icon src={addIcon} alt="" />
</UiButton>
<UiButton
size="icon"
class="skill-action-button"
aria-label="技能等级加满"
disabled={lvInfo.value[skill.id.toString()]?.lv >= skill.maxLearnLv}
onClick={(e: Event) => {
e.stopPropagation()
actionSkillLv(skill, 'addMax')
}}
>
<img data-icon src={maxIcon} alt="" />
</UiButton>
{/* <img
src={`${new URL('./img/min.svg', import.meta.url).href}`}
onClick={(e: Event) => {
e.stopPropagation()
actionSkillLv(skill, 'subMax')
}}
></img>
<img
src={`${new URL('./img/sub.svg', import.meta.url).href}`}
onClick={(e: Event) => {
e.stopPropagation()
actionSkillLv(skill, 'sub')
}}
></img>
<img
src={`${new URL('./img/add.svg', import.meta.url).href}`}
onClick={(e: Event) => {
e.stopPropagation()
actionSkillLv(skill, 'add')
}}
></img>
<img
src={`${new URL('./img/max.svg', import.meta.url).href}`}
onClick={(e: Event) => {
e.stopPropagation()
actionSkillLv(skill, 'addMax')
}}
></img> */}
</div>
</div>
</>
</>
const renderNormalAwakeBinding = () => (
<div class={`skill-tree__awake awake_${bindAwake.value}`}>
<button
type="button"
class="skill-tree__awake-option"
aria-pressed={bindAwake.value === 50}
onClick={() => (bindAwake.value = 50)}
>
<img
class="skill-tree__icon skill-tree__awake-icon"
src={getImageURL(awakeSkill1.value?.icon ?? '')}
alt={awakeSkill1.value?.name ?? '一觉'}
/>
<span class="skill-tree__awake-label">1次</span>
</button>
<button
type="button"
class="skill-tree__awake-option"
aria-pressed={bindAwake.value === 85}
onClick={() => (bindAwake.value = 85)}
>
<img
class="skill-tree__icon skill-tree__awake-icon"
src={getImageURL(awakeSkill2.value?.icon ?? '')}
alt={awakeSkill2.value?.name ?? '二觉'}
/>
<span class="skill-tree__awake-label">2次</span>
</button>
</div>
)
return () => (
<>
<div class="skill-tree" onClick={() => (activeSkill.value = -1)}>
{((getSkill(0, 15, -1) ?? []) as ISkill[]).length > 0 && (
<>
<div class="skill-tree-line">
<div class="skill-tree__row-label">
其<br />余
</div>
{((getSkill(0, 15, -1) ?? []) as ISkill[])?.map((skill, index) =>
renderSkill(skill, index),
)}
</div>
</>
)}
{lvList.map((lv, index) =>
((getSkill(lv, lvList[index + 1] ?? 150, -1) as ISkill[])?.length ?? 0) > 0 ? (
<div class="skill-tree-line" key={index}>
{lv == 1 || lv % 10 == 0 ? (
<div class="skill-tree__row-label">{lv}</div>
) : (
<div class="skill-tree__placeholder"></div>
)}
{columns.map((column) => {
const skill = getSkill(lv, lvList[index + 1] ?? 150, column) as ISkill
return skill ? (
renderSkill(skill, index)
) : (
<div class="skill-tree__placeholder"></div>
)
})}
<div class="skill-tree__row-end" onClick={() => (activeSkill.value = -1)}></div>
</div>
) : (
<></>
),
)}
const renderExtraAwakeBinding = () => (
<div class="skill-tree__extra-awake">
<div class="skill-tree__extra-awake-option">
<img
class="skill-tree__icon"
src={getImageURL(extraAwakeSkill2.value?.icon ?? '')}
alt={extraAwakeSkill2.value?.name ?? '二觉'}
/>
<span>2次</span>
</div>
</>
</div>
)
const renderSkill = (skill: ISkill) => {
const info = lvInfo.value[skill.id.toString()]
const color =
(info?.lv ?? 0) < skill.maxLearnLv
? '#32e432'
: (info?.lv ?? 0) > skill.maxLearnLv
? '#58e0fc'
: 'white'
return (
<div class="skill-tree__slot" key={skill.id}>
<div class="skill-tree__enhancements">
{(info?.vp ?? 0) > 0 && <span class={`vp_${info.vp}`}></span>}
{(info?.up ?? 0) > 0 && <span class={`up_${info.up}`}></span>}
</div>
{activeSkill.value === skill.id && renderSkillAction(skill)}
<button
type="button"
class={['skill-tree__skill', hasAwakeBinding(skill) ? '' : 'skill']}
aria-label={`${skill.name},当前等级 ${info?.lv ?? 0}`}
onClick={(event) => {
event.stopPropagation()
chooseSkill(skill)
}}
onContextmenu={(event) => {
event.stopPropagation()
event.preventDefault()
chooseSkill(skill, 'sub')
}}
>
<img
class={['skill-tree__icon', skill.type === 'passive' ? 'passive' : '']}
src={getImageURL(skill.icon)}
alt=""
/>
<span class="skill-tree__level" style={{ color }}>
{info?.lv ?? 0}
</span>
</button>
{hasAwakeBinding(skill) &&
(isExtraAlter.value ? renderExtraAwakeBinding() : renderNormalAwakeBinding())}
</div>
)
}
return () => (
<div class="skill-tree" onClick={() => (activeSkill.value = -1)}>
{skillGrid.value.rows.map((row) => (
<div class="skill-tree-line" key={row}>
<div class="skill-tree__row-label">{showRowPrefix.value ? getRowPrefix(row) : ''}</div>
{skillGrid.value.columns.map((column) => {
const skill = skillGrid.value.grid.get(`${row}:${column}`)
return skill ? (
renderSkill(skill)
) : (
<div class="skill-tree__placeholder" key={column} />
)
})}
<div class="skill-tree__row-end" />
</div>
))}
</div>
)
},
})
@@ -1,6 +1,7 @@
.skill-tree {
/* Preserve the original 11-column tree rhythm while allowing its host to scroll on narrow screens. */
min-width: 550px;
/* Fill the standard tree viewport; expanded special-job grids keep their intrinsic width and scroll. */
width: max-content;
min-width: 100%;
}
.skill-tree-line {
@@ -48,6 +49,10 @@
align-items: center;
flex-direction: column;
cursor: pointer;
border: 0;
background-color: transparent;
color: inherit;
font: inherit;
}
.skill-tree__icon {
@@ -89,6 +94,38 @@
align-items: center;
flex-direction: column;
cursor: pointer;
border: 0;
background: transparent;
color: inherit;
font: inherit;
}
.skill-tree__extra-awake {
position: absolute;
top: -3px;
left: 0;
width: 117px;
height: 54px;
background: url('./img/extra_awake_85.png') 0 0 / 100% 100% no-repeat;
}
.skill-tree__extra-awake-option {
position: absolute;
z-index: 2;
top: 3px;
left: 78px;
display: flex;
width: 36px;
height: 48px;
box-sizing: border-box;
padding-block: 3px;
align-items: center;
flex-direction: column;
color: #ffc701;
span {
margin-top: 1px;
}
}
.skill-tree__awake-icon {
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

+211 -201
View File
@@ -1,168 +1,145 @@
<template>
<div ref="skillTreeRef" class="skill-page">
<div class="skill-page__panel">
<div class="skill-page__tabs">
<div class="skill-page__tab" @click="tab = 0" :class="{ active: tab === 0 }">技能加点</div>
<div class="skill-page__tab-divider">|</div>
<div class="skill-page__tab" @click="tab = 1" :class="{ active: tab === 1 }">技能定制</div>
</div>
<main class="skill-page" :class="{ 'skill-page--dark-knight': showDarkKnightSkillSetting }">
<section class="skill-page__panel" aria-label="技能配置">
<TabsRoot v-model="tab" class="skill-page__tabs">
<UiTabsList aria-label="技能配置类别">
<UiTabsTrigger value="tree">技能加点</UiTabsTrigger>
<UiTabsTrigger value="customize">技能定制</UiTabsTrigger>
</UiTabsList>
</TabsRoot>
<div class="skill-page__viewport">
<div v-if="tab === 0" class="skill-page__tree-scroll">
<div v-if="tab === 'tree'" class="skill-page__tree-scroll">
<SkillTree
:skills="infoStore.skills"
v-model:lvInfo="configStore.config.skills"
v-model:bindAwake="configStore.config.bindAwake"
@chooseSkill="setCurrentSkill"
></SkillTree>
:alter="infoStore.infos?.alter"
v-model:lv-info="configStore.config.skills"
v-model:bind-awake="configStore.config.bindAwake"
@choose-skill="setCurrentSkill"
/>
</div>
<div v-if="tab === 1" class="skill-page__customize">
<div class="item-head" 角色等级>技能突破</div>
<SkillUP
class="skill-page__customize-section"
:skills="infoStore.skills"
v-model:lvInfo="configStore.config.skills"
v-model:bindAwake="configStore.config.bindAwake"
:buffer="infoStore.infos?.buffer"
></SkillUP>
<div class="item-head" 角色等级>技能进化</div>
<div class="skill-page__customize-section skill-page__vp-scroll">
<SkillVP
:skills="infoStore.skills"
v-model:lvInfo="configStore.config.skills"
v-model:bindAwake="configStore.config.bindAwake"
@chooseSkill="setCurrentSkill"
></SkillVP>
</div>
</div>
</div>
</div>
<div
class="skill-page__detail-launcher"
v-if="curCurrentSkill.skillId && curCurrentSkill.level > 0 && !showSkillDetail"
>
<div class="skill-page__detail-launcher-frame">
<div class="skill-page__detail-launcher-border">
<div
class="skill-page__detail-launcher-button"
style="background: linear-gradient(to bottom, #313330 0%, #0f110f 100%)"
@click="showSkillDetail = true"
>
<img src="./components/img/detail.png" alt="" />
技能详情
</div>
<div v-else class="skill-page__customize">
<UiDataSection title="技能突破" class="skill-page__customize-section">
<SkillUP
:skills="infoStore.skills"
v-model:lv-info="configStore.config.skills"
v-model:bind-awake="configStore.config.bindAwake"
:buffer="infoStore.infos?.buffer"
/>
</UiDataSection>
<UiDataSection title="技能进化" class="skill-page__customize-section">
<div class="skill-page__vp-scroll">
<SkillVP
:skills="infoStore.skills"
v-model:lv-info="configStore.config.skills"
v-model:bind-awake="configStore.config.bindAwake"
@choose-skill="setCurrentSkill"
/>
</div>
</UiDataSection>
</div>
</div>
</div>
</div>
<div
class="skill-page__detail"
:style="style"
ref="SkillDetailRef"
v-if="curCurrentSkill.skillId && curCurrentSkill.level > 0 && showSkillDetail"
>
<SkillDetail :skillId="curCurrentSkill.skillId" :level="curCurrentSkill.level"></SkillDetail>
</div>
</section>
<aside class="skill-page__context" aria-label="技能详情">
<header class="skill-page__context-header">
<span>技能详情</span>
<span v-if="currentSkill" class="skill-page__context-level">
Lv{{ currentSkillLevel }} / {{ currentSkill.maxLearnLv }}
</span>
</header>
<div v-if="currentSkill && currentSkillLevel > 0" class="skill-page__detail">
<div class="skill-page__selected-skill">
<img :src="getImageURL(currentSkill.icon)" :alt="currentSkill.name" />
<div>
<strong>{{ currentSkill.name }}</strong>
<span>{{ currentSkill.type === 'passive' ? '被动技能' : '主动技能' }}</span>
</div>
</div>
<div class="skill-page__detail-scroll">
<SkillDetail :skill-id="currentSkill.uuid" :level="currentSkillLevel" />
</div>
</div>
<div v-else class="skill-page__empty">
<img src="./components/img/detail.png" alt="" />
<span>在技能树中选择技能查看详情</span>
</div>
<DarkKnightSkillSetting
v-if="showDarkKnightSkillSetting"
:skills="infoStore.skills"
v-model="configStore.config.skillShortcuts"
class="skill-page__dark-knight-setting"
/>
</aside>
</main>
</template>
<script lang="ts" setup name="Index">
import { useInfoStore } from '@/stores/info'
import SkillTree from './components/SkillTree'
import SkillVP from './components/SkillVP'
import SkillUP from './components/SkillUP'
import { useConfigStore } from '@/stores'
<script setup lang="ts">
import { UiDataSection } from '@/components/ui/data-section'
import { TabsRoot, UiTabsList, UiTabsTrigger } from '@/components/ui/tabs'
import { useConfigStore, useInfoStore } from '@/stores'
import { getImageURL } from '@/utils/images'
import DarkKnightSkillSetting from './components/DarkKnightSkillSetting/index.vue'
import SkillDetail from './components/SkillDetail'
import { onClickOutside } from '@vueuse/core'
const props = defineProps<{
import SkillTree from './components/SkillTree'
import SkillUP from './components/SkillUP'
import SkillVP from './components/SkillVP'
defineProps<{
alter: string
}>()
const tab = ref(0)
const tab = ref<'tree' | 'customize'>('tree')
const infoStore = useInfoStore()
const configStore = useConfigStore()
const currentSkillId = ref<number>()
const curSkillId = ref()
const curCurrentSkill = computed(() => {
const skill = infoStore.skills.find((skill) => skill.uuid === curSkillId.value)
return {
skillId: skill?.uuid ?? '',
level: configStore.config.skills[skill?.id || 0]?.lv || 0,
}
})
const showSkillDetail = ref(false)
const skillTreeRef = ref<HTMLHtmlElement>()
const SkillDetailRef = ref<HTMLHtmlElement>()
const style = computed(() => {
return {
zIndex: 99,
height: `${skillTreeRef.value?.clientHeight ?? 0}px`,
left: `${(skillTreeRef.value?.clientWidth ?? 0) + 5}px`,
}
})
const showDarkKnightSkillSetting = computed(() => infoStore.infos?.alter === 'dark_knight')
const currentSkill = computed(() =>
infoStore.skills.find((skill) => skill.id === currentSkillId.value),
)
const currentSkillLevel = computed(() =>
currentSkill.value ? (configStore.config.skills[currentSkill.value.id]?.lv ?? 0) : 0,
)
const setCurrentSkill = (skillId: number) => {
curSkillId.value = infoStore.skills.find((skill) => skill.id === skillId)?.uuid || ''
currentSkillId.value = skillId
}
onClickOutside(
SkillDetailRef,
(event) => {
showSkillDetail.value = false
},
{
ignore: [skillTreeRef],
},
)
</script>
<style lang="scss" scoped>
<style scoped lang="scss">
.skill-page {
position: relative;
display: flex;
display: grid;
width: 100%;
min-width: 0;
height: 100%;
min-height: 0;
grid-template-columns: minmax(0, 1fr) minmax(210px, 0.38fr);
gap: var(--ui-character-workspace-gap);
overflow: hidden;
container-type: inline-size;
}
.skill-page__panel,
.skill-page__context {
min-width: 0;
min-height: 0;
border: 1px solid var(--ui-composition-border);
background: var(--ui-skill-page-background, var(--ui-composition-background));
}
.skill-page__panel {
display: flex;
width: 100%;
min-width: 0;
height: 100%;
flex-direction: column;
background: var(--ui-skill-page-background, #101012);
overflow: hidden;
}
.skill-page__tabs {
display: flex;
min-height: 40px;
flex: 0 0 40px;
color: var(--ui-skill-page-muted, #6c5e4a);
font-weight: 700;
}
.skill-page__tab,
.skill-page__tab-divider {
display: flex;
align-items: center;
justify-content: center;
}
.skill-page__tab {
position: relative;
min-width: 0;
flex: 1;
font-size: 15px;
cursor: pointer;
}
.skill-page__tab-divider {
flex: 0 0 auto;
font-size: 18px;
font-weight: 400;
border-bottom: 1px solid var(--ui-disclosure-divider);
}
.skill-page__viewport,
@@ -174,6 +151,7 @@ onClickOutside(
}
.skill-page__viewport {
display: flex;
overflow: hidden;
}
@@ -182,105 +160,137 @@ onClickOutside(
width: 100%;
height: 100%;
overflow: auto;
overscroll-behavior: contain;
}
.skill-page__customize {
display: flex;
display: grid;
width: 100%;
height: 100%;
flex-direction: column;
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: var(--ui-control-gap);
padding: var(--ui-control-gap);
overflow: hidden;
}
.skill-page__customize-section {
min-height: 0;
flex: 1;
overflow: hidden;
}
.skill-page__customize-section :deep(.ui-data-section__content),
.skill-page__vp-scroll {
width: 100%;
height: 100%;
min-height: 0;
overflow: auto;
}
.skill-page__detail-launcher {
position: absolute;
z-index: 20;
top: 40px;
right: -64px;
}
.skill-page__detail-launcher-frame {
box-sizing: border-box;
.skill-page__context {
display: flex;
padding: 1px 1px 1px 2px;
color: var(--ui-skill-page-accent, #ae8d5a);
}
.skill-page__detail-launcher-border {
box-sizing: border-box;
display: flex;
padding: 1px 1px 1px 0;
border: 1px solid #322e20;
border-left: 0;
}
.skill-page__detail-launcher-button {
display: flex;
padding: 5px;
align-items: center;
flex-direction: column;
justify-content: center;
border: 1px solid #514531;
border-radius: 2px;
background: linear-gradient(to bottom, #313330 0%, #0f110f 100%);
cursor: pointer;
overflow: hidden;
}
img {
width: 30px;
height: 30px;
}
.skill-page__context-header {
display: flex;
min-height: var(--ui-disclosure-header-min-block-size);
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: var(--ui-control-gap);
padding-inline: var(--ui-field-padding-inline);
border-bottom: 1px solid var(--ui-disclosure-divider);
background: var(--ui-disclosure-header-background);
color: var(--ui-settings-section-foreground);
}
.skill-page__context-level {
color: var(--ui-disclosure-state);
font-family: Consolas, monospace;
font-size: 10px;
}
.skill-page__detail {
position: absolute;
width: min(300px, 45%);
height: 100%;
background: var(--ui-skill-page-background, #101012);
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
}
.active {
color: #ccc088;
position: relative;
.skill-page__selected-skill {
display: grid;
grid-template-columns: var(--ui-control-height-large) minmax(0, 1fr);
align-items: center;
gap: var(--ui-control-gap);
padding: var(--ui-control-padding-inline-default);
border-bottom: 1px solid var(--ui-disclosure-divider);
&::before {
content: '';
position: absolute;
width: 80px;
height: 15px;
opacity: 0.35;
background: #bf8d20;
filter: blur(7px);
z-index: 2;
img {
width: var(--ui-control-height-large);
height: var(--ui-control-height-large);
object-fit: contain;
}
&::after {
content: '';
position: absolute;
background: url('@/assets/img/active.png') no-repeat;
width: 148px;
height: 16px;
bottom: 2px;
transform: translate(0, 50%);
strong,
span {
display: block;
}
strong {
overflow: hidden;
color: var(--ui-settings-section-foreground);
font-weight: 400;
text-overflow: ellipsis;
white-space: nowrap;
}
span {
margin-top: 4px;
color: var(--ui-muted-foreground);
font-size: 10px;
}
}
.item-head {
background: linear-gradient(#2b2817, #171407);
border-top: 1px solid #423d2c;
border-bottom: 1px solid #211d15;
.skill-page__detail-scroll {
min-height: 0;
flex: 1;
overflow: auto;
overscroll-behavior: contain;
}
.skill-page__empty {
display: flex;
min-height: 150px;
flex: 1;
align-items: center;
flex-direction: column;
justify-content: center;
gap: var(--ui-control-gap);
padding: var(--ui-control-padding-inline-default);
color: var(--ui-muted-foreground);
text-align: center;
// 以下后面注释掉
height: 20px;
line-height: 20px;
font-weight: 14px;
img {
width: 34px;
height: 34px;
object-fit: contain;
opacity: 0.72;
}
}
.skill-page__dark-knight-setting {
max-height: 58%;
flex: 0 0 auto;
overflow: visible;
}
@container (max-width: 650px) {
.skill-page {
grid-template-columns: minmax(0, 1fr);
}
.skill-page__context {
display: none;
}
}
</style>
@@ -538,8 +538,8 @@ function removeFavorite(item: UiFavoriteDrawerItem) {
<UiTabsList aria-label="物品目录"
><UiTabsTrigger value="equipment">装备</UiTabsTrigger
><UiTabsTrigger value="consumable">消耗品</UiTabsTrigger
><UiTabsTrigger value="material">材料</UiTabsTrigger
><UiTabsTrigger value="quest">任务</UiTabsTrigger></UiTabsList
><UiTabsTrigger value="material">制作材料</UiTabsTrigger
><UiTabsTrigger value="quest">任务物品</UiTabsTrigger></UiTabsList
>
<UiTabsContent value="equipment">展示武器、防具与首饰词条。</UiTabsContent>
<UiTabsContent value="consumable">展示药剂、宝珠与特殊消耗品。</UiTabsContent>
@@ -990,7 +990,7 @@ footer {
background: #101111;
color: var(--ui-muted-foreground);
font:
9px/1.4 Consolas,
11px/1.4 Consolas,
monospace;
}
.catalog-content {
@@ -1197,7 +1197,7 @@ label > span,
color: var(--dcalc-success);
}
.status-danger {
color: var(--dcalc-danger);
color: var(--ui-danger-foreground);
}
.table-icon {
display: block;
@@ -1319,9 +1319,9 @@ footer {
display: flex;
justify-content: space-between;
margin-top: 14px;
color: #81755f;
color: var(--ui-muted-foreground);
font:
9px/1 Consolas,
11px/1 Consolas,
monospace;
letter-spacing: 1.2px;
}
+47 -1
View File
@@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { useInfoStore } from './info'
import api from '@/api'
import type { IEquipment } from '@/api/info/type'
import type { IEquipment, ISkill } from '@/api/info/type'
import type { IResultSkillCount } from '@/api/calc/type'
// | '上衣'
// | '头肩'
@@ -42,6 +42,11 @@ export interface IConfigEmblem {
items: string[]
}
export interface ISkillShortcuts {
shortcuts: string[]
combos: string[][]
}
export interface IConfig {
skills: Record<string, { lv: number; vp: number; up: number }>
equips: Record<string, IConfigEquip>
@@ -73,6 +78,7 @@ export interface IConfig {
options: Record<string, number>
avatarLink: string
emblems: Record<string, IConfigEmblem>
skillShortcuts: ISkillShortcuts
}
const defaultEqusConfig = {
@@ -113,6 +119,39 @@ const createDefaultEmblemConfig = (): IConfigEmblem => ({
const defaultEmblemConfig = createDefaultEmblemConfig()
const createDefaultSkillShortcuts = (): ISkillShortcuts => ({
shortcuts: Array.from({ length: 6 }, () => ''),
combos: Array.from({ length: 6 }, () => Array.from({ length: 5 }, () => '')),
})
const normalizeSkillShortcuts = (
arrangement?: Partial<ISkillShortcuts>,
skills: ISkill[] = [],
): ISkillShortcuts => {
const validSkillIds = new Set(skills.map((skill) => skill.id.toString()))
const skillIdByUuid = new Map(skills.map((skill) => [skill.uuid, skill.id.toString()]))
const normalizeSkillId = (value: unknown) => {
const key =
typeof value === 'number' ? value.toString() : typeof value === 'string' ? value : ''
if (!key) return ''
return validSkillIds.has(key) ? key : (skillIdByUuid.get(key) ?? '')
}
return {
shortcuts: Array.from({ length: 6 }, (_, index) =>
normalizeSkillId(arrangement?.shortcuts?.[index]),
),
combos: Array.from({ length: 6 }, (_, column) => {
const combo = Array.from({ length: 5 }, (_, stage) =>
normalizeSkillId(arrangement?.combos?.[column]?.[stage]),
)
const firstEmptyStage = combo.findIndex((skillId) => !skillId)
if (firstEmptyStage >= 0) combo.fill('', firstEmptyStage)
return combo
}),
}
}
export const useConfigStore = defineStore('configStore', () => {
const config = ref<IConfig>({
skills: {},
@@ -128,6 +167,7 @@ export const useConfigStore = defineStore('configStore', () => {
options: {},
avatarLink: '',
emblems: {},
skillShortcuts: createDefaultSkillShortcuts(),
})
const skillCountConfig = ref<IResultSkillCount[]>([])
@@ -161,6 +201,7 @@ export const useConfigStore = defineStore('configStore', () => {
options: {},
avatarLink: '',
emblems: {},
skillShortcuts: createDefaultSkillShortcuts(),
}
}
if (!config.value?.equips) config.value.equips = {}
@@ -172,6 +213,10 @@ export const useConfigStore = defineStore('configStore', () => {
if (!config.value?.options) config.value.options = {}
if (!config.value?.oaths) config.value.oaths = {}
if (!config.value?.emblems) config.value.emblems = {}
config.value.skillShortcuts = normalizeSkillShortcuts(
config.value.skillShortcuts,
infoStore.skills,
)
const part = [...infoStore.parts, '宠物', '副武器'].filter(
(a) => !config.value?.equips.hasOwnProperty(a),
@@ -291,5 +336,6 @@ export const useConfigStore = defineStore('configStore', () => {
createDefaultOathConfig,
defaultEmblemConfig,
createDefaultEmblemConfig,
createDefaultSkillShortcuts,
}
})
+2
View File
@@ -19,6 +19,8 @@
--ui-accent-foreground: #fee97d;
--ui-destructive: var(--dcalc-danger);
--ui-destructive-foreground: #fff4f4;
--ui-danger-foreground: #ef8883;
--ui-unavailable-foreground: #8f8b83;
--ui-border: var(--dcalc-brass-dark);
--ui-input: #5b472a;
--ui-ring: var(--dcalc-focus);
@@ -19,6 +19,8 @@
--ui-accent-foreground: #ffffff;
--ui-destructive: #a73f45;
--ui-destructive-foreground: #fff4f4;
--ui-danger-foreground: #f08b94;
--ui-unavailable-foreground: #8da0bc;
--ui-border: #40577a;
--ui-input: #354b6e;
--ui-ring: #6fb5e6;
+17 -6
View File
@@ -62,8 +62,8 @@
--ui-popup-z-index: 80;
--ui-field-padding-inline: 9px;
--ui-option-height: 27px;
--ui-tabs-height: 40px;
--ui-tab-height: 30px;
--ui-tabs-height: 32px;
--ui-tab-height: 24px;
/*
* Component geometry defaults. Every value is consumed through var(), so a component instance
@@ -132,10 +132,10 @@
100dvh - 16px - var(--ui-dialog-header-block-size) - var(--ui-dialog-footer-min-block-size)
);
--ui-dialog-body-padding: 12px;
--ui-tab-marker-inline-size: 39px;
--ui-tab-marker-block-size: 5px;
--ui-tab-marker-arrow-inline-size: 9px;
--ui-tab-marker-arrow-block-size: 4px;
--ui-tab-marker-inline-size: 33px;
--ui-tab-marker-block-size: 4px;
--ui-tab-marker-arrow-inline-size: 7px;
--ui-tab-marker-arrow-block-size: 3px;
--ui-number-fast-inline-size: 26px;
--ui-number-fast-block-size: 28px;
--ui-number-fast-step-block-size: 14px;
@@ -300,3 +300,14 @@
--ui-shop-action-block-size: 24px;
--ui-shop-action-icon-size: 16px;
}
@media (max-width: 520px) {
:root {
/* Preserve pixel artwork while giving standalone touch controls a 24px minimum hit box. */
--ui-slider-control-size: 24px;
--ui-select-icon-inline-size: 24px;
--ui-select-icon-block-size: 24px;
--ui-number-fast-block-size: 48px;
--ui-number-fast-step-block-size: 24px;
}
}