黑暗武士初步实现

This commit is contained in:
2026-08-10 03:10:55 +08:00
parent e6abdcc0f8
commit 7d63093971
5 changed files with 757 additions and 12 deletions
@@ -0,0 +1,592 @@
<template>
<div
ref="settingRef"
class="dark-knight-skill-setting-shell select-none"
:class="{ collapsed }"
@click.stop
>
<button
v-if="collapsed"
type="button"
class="shortcut-setting-trigger ml-1px"
title="展开快捷键设置"
aria-label="展开快捷键设置"
@click="collapsed = false"
>
<img src="../img/shortcut.png" alt="快捷键设置" />
</button>
<div v-else class="dark-knight-skill-setting">
<div class="setting-title">
<span>添加快捷键</span>
<button
type="button"
class="minimize-button"
title="缩小快捷键设置"
aria-label="缩小快捷键设置"
@click="collapseSetting"
></button>
</div>
<div class="shortcut-grid">
<button
v-for="(key, index) in shortcutKeys"
:key="key"
type="button"
class="skill-slot"
:class="{ 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 class="slot-key">{{ key }}</span> -->
</button>
</div>
<div class="combo-title">连续技</div>
<!-- <div class="combo-markers" aria-hidden="true">
<span v-for="key in comboKeys" :key="key">✹</span>
</div> -->
<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="{
selected: isEditing('combo', column, stage - 1),
disabled: isStageDisabled(column, stage - 1),
}"
:disabled="isStageDisabled(column, stage - 1)"
:title="
isStageDisabled(column, stage - 1)
? `请先设置 ${stage - 1} 阶段`
: getSkill(arrangement.combos[column][stage - 1])?.name || `${stage}阶段 ${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 class="stage-label">{{ stage }}阶段</span>
<!-- <span class="slot-key">{{ key }}</span> -->
</button>
<span
v-if="stage < stageCount"
class="stage-arrow"
:class="{ connected: isStageConnected(column, stage - 1) }"
aria-hidden="true"
></span>
</template>
<CalcButton
small
class="reset-button"
:title="`重置 ${key} 连续技`"
@click="resetCombo(column)"
>
↻
</CalcButton>
</div>
</div>
<div v-if="editingSlot" class="skill-picker">
<div class="picker-header">
<span>选择主动技能</span>
<div class="picker-actions">
<button type="button" :disabled="!currentValue" @click="selectSkill('')">清空</button>
<button type="button" @click="editingSlot = null">×</button>
</div>
</div>
<div class="picker-grid">
<button
v-for="skill in selectableSkills"
:key="skill.id"
type="button"
class="picker-skill"
:class="{ 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>
</div>
</div>
</div>
</template>
<script setup lang="ts" name="DarkKnightSkillSetting">
import type { ISkill } from '@/api/info/type'
import type { ISkillShortcuts } from '@/stores/config'
import { getImageURL } from '@/utils/images'
import { onClickOutside, useEventListener } from '@vueuse/core'
import CalcButton from '@/components/calc/button/index.vue'
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
if (type === 'shortcut') return slot.type === 'shortcut' && slot.index === index
return 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) =>
!!arrangement.value.combos[column][stage] && !!arrangement.value.combos[column][stage + 1]
const openPicker = (type: EditingSlot['type'], index: number, stage?: number) => {
if (type === 'combo' && isStageDisabled(index, stage ?? 0)) return
editingSlot.value =
type === 'shortcut' ? { type, index } : { type, column: index, stage: stage ?? 0 }
}
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
? { type: 'combo', column: slot.column, stage: slot.stage + 1 }
: null
}
}
const resetCombo = (column: number) => {
const next: ISkillShortcuts = {
shortcuts: [...arrangement.value.shortcuts],
combos: arrangement.value.combos.map((items, index) =>
index === column ? Array.from({ length: stageCount }, () => '') : [...items],
),
}
emit('update:modelValue', next)
if (editingSlot.value?.type === 'combo' && editingSlot.value.column === column) {
editingSlot.value = { type: 'combo', column, stage: 0 }
}
}
const collapseSetting = () => {
editingSlot.value = null
collapsed.value = true
}
onClickOutside(settingRef, () => (editingSlot.value = null))
useEventListener(window, 'blur', () => (editingSlot.value = null))
</script>
<style lang="scss" scoped>
.dark-knight-skill-setting-shell {
position: relative;
width: auto;
flex: none;
&.collapsed {
width: 48px;
}
}
.dark-knight-skill-setting {
width: 100%;
box-sizing: border-box;
padding: 4px 8px 8px;
color: #8f7845;
background: linear-gradient(180deg, #11120f 0%, #080a09 100%);
border: 1px solid #383224;
// box-shadow: 0 4px 14px #000c;
}
.setting-title {
position: relative;
height: 20px;
margin: -3px -7px 6px;
display: flex;
align-items: center;
justify-content: center;
color: #9c854e;
font-size: 14px;
line-height: 20px;
// font-weight: 700;
background: linear-gradient(#2d281b, #13120d);
border-bottom: 1px solid #3a3424;
text-shadow: 0 1px 2px #000;
}
.minimize-button {
position: absolute;
right: 4px;
top: 3px;
width: 15px;
height: 14px;
padding: 0;
background: linear-gradient(#393b39, #202220);
border: 1px solid #554a32;
cursor: pointer;
&::after {
content: '';
position: absolute;
left: 3px;
right: 3px;
bottom: 3px;
height: 1px;
background: #c0aa70;
}
&:hover {
border-color: #8b743d;
}
}
.shortcut-setting-trigger {
width: 48px;
height: 48px;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(to bottom, #313330 0%, #0f110f 100%);
border: 1px solid #514531;
border-radius: 2px;
box-shadow:
0 0 0 1px #322e20,
0 3px 8px #000a;
cursor: pointer;
img {
width: 34px;
height: 34px;
object-fit: contain;
}
&:hover {
border-color: #8b743d;
}
}
.shortcut-grid,
.combo-grid,
.combo-markers {
display: grid;
grid-template-columns: repeat(6, 28px);
justify-content: center;
column-gap: 12px;
}
.shortcut-grid {
margin-bottom: 5px;
}
.skill-slot {
position: relative;
width: 28px;
height: 28px;
box-sizing: border-box;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
color: #5e5131;
background: #030504;
border: 1px solid #202320;
box-shadow: inset 0 0 0 1px #000;
cursor: pointer;
&:hover,
&.selected {
border-color: #876f36;
}
&.disabled {
color: #353832;
border-color: #151815;
opacity: 0.8;
cursor: not-allowed;
.stage-label {
color: #353832;
}
}
img {
width: 26px;
height: 26px;
object-fit: contain;
}
}
.slot-key {
position: absolute;
right: 1px;
bottom: -1px;
color: #fff4bd;
font-size: 12px;
font-weight: 800;
line-height: 13px;
transform: scale(0.8);
text-shadow:
-1px -1px 0 #e32610,
1px -1px 0 #e32610,
-1px 1px 0 #e32610,
1px 1px 0 #e32610;
}
.combo-title {
height: 20px;
display: flex;
align-items: center;
color: #aa8e4f;
font-size: 12px;
line-height: 20px;
// font-weight: 700;
}
.combo-markers {
height: 15px;
span {
color: #b7984e;
font-size: 15px;
line-height: 15px;
}
}
.combo-column {
display: flex;
flex-direction: column;
align-items: center;
}
.combo-slot {
height: 28px;
}
.stage-label {
color: #55482c;
font-size: 10px;
white-space: nowrap;
}
.stage-arrow {
position: relative;
width: 20px;
height: 11px;
&::before {
content: '';
position: absolute;
left: 7px;
top: 0;
width: 6px;
height: 6px;
background: #282a28;
}
&::after {
content: '';
position: absolute;
left: 3px;
top: 5px;
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-top: 6px solid #282a28;
}
&.connected::before {
background: #244b80;
}
&.connected::after {
border-top-color: #2356a0;
}
}
.reset-button {
margin-top: 4px;
font-size: 12px;
line-height: 12px;
height: 16px;
}
.skill-picker {
position: absolute;
right: calc(100% + 8px);
bottom: 0;
width: 210px;
padding: 5px;
box-sizing: border-box;
z-index: 30;
color: #aa9155;
background: #090b0a;
border: 1px solid #5c4b2c;
box-shadow: 0 5px 18px #000e;
}
.picker-header {
height: 23px;
padding: 0 3px 4px;
font-size: 12px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #302a1e;
}
.picker-actions {
display: flex;
gap: 4px;
button {
min-width: 22px;
height: 18px;
font-size: 11px;
color: #b9a269;
background: #20221f;
border: 1px solid #4c432e;
cursor: pointer;
&:disabled {
opacity: 0.35;
cursor: default;
}
}
}
.picker-grid {
max-height: 390px;
padding-top: 5px;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
overflow-y: auto;
}
.picker-skill {
min-width: 0;
padding: 3px 2px;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
color: #aa9155;
font-size: 10px;
background: #050706;
border: 1px solid #292c29;
cursor: pointer;
&:hover:not(:disabled),
&.active {
color: #e1c67e;
border-color: #8b743d;
}
&:disabled {
filter: grayscale(1);
opacity: 0.25;
cursor: not-allowed;
}
img {
width: 28px;
height: 28px;
}
span {
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

+117 -10
View File
@@ -1,6 +1,6 @@
<template>
<div class="flex relative" ref="skillTreeRef">
<div class="flex flex-col">
<div class="flex relative h-full" ref="skillTreeRef">
<div class="flex flex-col h-full">
<div class="flex bg-#101012 h-40px text-#6C5E4A font-bold">
<div
class="flex-1 text-15px flex items-center justify-center"
@@ -50,9 +50,43 @@
</div>
</div>
<div v-if="showDarkKnightSkillSetting" class="dark-knight-side-column">
<div class="skill-detail-region">
<button
v-if="curCurrentSkill.skillId && curCurrentSkill.level > 0 && !showSkillDetail"
type="button"
class="side-panel-trigger"
@click="showSkillDetail = true"
>
<img src="./components/img/detail.png" alt="技能详情" />
<span>技能详情</span>
</button>
<div
v-if="curCurrentSkill.skillId && curCurrentSkill.level > 0 && showSkillDetail"
ref="SkillDetailRef"
class="skill-detail-panel"
>
<SkillDetail :skillId="curCurrentSkill.skillId" :level="curCurrentSkill.level" />
</div>
</div>
<DarkKnightSkillSetting
:skills="infoStore.skills"
v-model="configStore.config.skillShortcuts"
class="ml-1px"
/>
</div>
<div
class="absolute right--65px"
v-if="curCurrentSkill.skillId && curCurrentSkill.level > 0 && !showSkillDetail"
class="absolute"
:style="detailButtonStyle"
v-if="
!showDarkKnightSkillSetting &&
curCurrentSkill.skillId &&
curCurrentSkill.level > 0 &&
!showSkillDetail
"
>
<div class="text-#AE8D5A p-1px box-border flex pl-2 w-auto">
<div
@@ -74,7 +108,12 @@
class="bg-#101012 w-300px h-full absolute"
:style="style"
ref="SkillDetailRef"
v-if="curCurrentSkill.skillId && curCurrentSkill.level > 0 && showSkillDetail"
v-if="
!showDarkKnightSkillSetting &&
curCurrentSkill.skillId &&
curCurrentSkill.level > 0 &&
showSkillDetail
"
>
<SkillDetail :skillId="curCurrentSkill.skillId" :level="curCurrentSkill.level"></SkillDetail>
</div>
@@ -87,14 +126,19 @@ import SkillVP from './components/SkillVP'
import SkillUP from './components/SkillUP'
import { useConfigStore } from '@/stores'
import SkillDetail from './components/SkillDetail'
import DarkKnightSkillSetting from './components/DarkKnightSkillSetting/index.vue'
import { onClickOutside } from '@vueuse/core'
const props = defineProps<{
defineProps<{
alter: string
}>()
const tab = ref(0)
const infoStore = useInfoStore()
const configStore = useConfigStore()
const skillTreeWidth = 550
const showDarkKnightSkillSetting = computed(() => infoStore.infos?.alter === 'dark_knight')
const curSkillId = ref()
const curCurrentSkill = computed(() => {
@@ -110,11 +154,15 @@ const showSkillDetail = ref(false)
const skillTreeRef = ref<HTMLHtmlElement>()
const SkillDetailRef = ref<HTMLHtmlElement>()
const detailButtonStyle = computed(() => ({
left: `${skillTreeRef.value?.clientWidth ?? skillTreeWidth}px`,
}))
const style = computed(() => {
return {
zIndex: 99,
height: `${skillTreeRef.value?.clientHeight ?? 0}px`,
left: `${(skillTreeRef.value?.clientWidth ?? 0) + 5}px`,
left: `${(skillTreeRef.value?.clientWidth ?? skillTreeWidth) + 5}px`,
}
})
@@ -122,9 +170,15 @@ const setCurrentSkill = (skillId: number) => {
curSkillId.value = infoStore.skills.find((skill) => skill.id === skillId)?.uuid || ''
}
onClickOutside(SkillDetailRef, (event) => {showSkillDetail.value = false}, {
ignore: [skillTreeRef],
})
onClickOutside(
SkillDetailRef,
() => {
showSkillDetail.value = false
},
{
ignore: [skillTreeRef],
},
)
</script>
<style lang="scss" scoped>
@@ -154,6 +208,59 @@ onClickOutside(SkillDetailRef, (event) => {showSkillDetail.value = false}, {
}
}
.dark-knight-side-column {
width: 310px;
height: 100%;
min-height: 0;
display: flex;
flex: none;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.skill-detail-region {
width: 100%;
min-height: 0;
display: flex;
flex: 1;
align-items: flex-start;
overflow: hidden;
}
.skill-detail-panel {
width: 100%;
height: 100%;
min-height: 0;
overflow-y: auto;
background: #101012;
}
.side-panel-trigger {
min-width: 48px;
padding: 5px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
color: #ae8d5a;
background: linear-gradient(to bottom, #313330 0%, #0f110f 100%);
border: 1px solid #514531;
border-radius: 2px;
box-shadow: 0 0 0 1px #322e20;
cursor: pointer;
img {
width: 30px;
height: 30px;
}
&:hover {
border-color: #8b743d;
}
}
.item-head {
background: linear-gradient(#2b2817, #171407);
border-top: 1px solid #423d2c;
+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,
}
})