├── public ├── .nojekyll ├── _headers └── favicon.ico ├── pnpm-workspace.yaml ├── src ├── assets │ ├── actWS.webp │ ├── frame.png │ ├── blubook.webp │ ├── item_icon_frame.png │ └── screenshots │ │ ├── dd.webp │ │ ├── dnc.webp │ │ ├── food.webp │ │ ├── radar.webp │ │ ├── time.webp │ │ ├── timeline.webp │ │ ├── teamWatch.webp │ │ ├── zoneMacro.webp │ │ ├── keySkillTimer.webp │ │ ├── limitBreakTip.webp │ │ ├── castingMonitor.webp │ │ ├── castingToChinese.webp │ │ ├── keigennRecord2.webp │ │ └── instancedAreaInfo.webp ├── common │ ├── font │ │ ├── FFXIV_Lodestone_SSF.ttf │ │ ├── AccidentalPresidency.ttf │ │ └── FFXIV_Lodestone_SSF.woff │ └── markdown │ │ ├── zoneMacro.md │ │ └── timeline.md ├── types │ ├── lang.d.ts │ ├── partyPlayer.d.ts │ ├── macro.d.ts │ ├── food.d.ts │ ├── action.d.ts │ ├── keySkill.d.ts │ ├── PostNamazu.ts │ ├── combatant.d.ts │ ├── timeline.ts │ ├── keigennRecord2.d.ts │ ├── fflogs.d.ts │ └── fflogs2.d.ts ├── utils │ ├── iconToSrc.ts │ ├── tts.ts │ ├── keigennRecord2.ts │ ├── debounceRef.ts │ ├── deepClone.ts │ ├── checkReferrer.ts │ ├── postNamazu.ts │ ├── dynamicValue.ts │ ├── clipboard.ts │ ├── getInitialLocale.ts │ ├── keigenn.ts │ ├── mapCoordinates.ts │ ├── compareSaveAction.ts │ ├── cacheManager.ts │ ├── chineseToIcon.ts │ ├── xivapi.ts │ └── flags.ts ├── resources │ ├── actionChineseTemp.ts │ ├── actionChinese.ts │ ├── action2ClassJobLevel.ts │ ├── actionCategory.ts │ ├── contentFinderCondition.ts │ ├── status.ts │ ├── zoneInfo.ts │ └── timelineTemplate.ts ├── pages │ ├── timelineHelp.vue │ ├── index.vue │ ├── 404.vue │ ├── mpTick.vue │ ├── time.vue │ ├── castingMonitor.vue │ ├── instancedAreaInfo.vue │ ├── enmity.vue │ ├── dsrp6.vue │ ├── showBarrier.vue │ ├── aether.vue │ └── lbTick.vue ├── App.vue ├── composables │ ├── useDev.ts │ ├── useActReady.ts │ ├── useDemo.ts │ ├── useIndexedDB.ts │ ├── useLang.ts │ ├── useZone.ts │ └── useWebSocket.ts ├── store │ ├── partySort.ts │ └── keigennRecord2.ts ├── css │ └── hover.css ├── env.d.ts ├── components │ ├── common │ │ ├── ActWrapper.vue │ │ ├── LanguageSwitcher.vue │ │ ├── ThemeToggle.vue │ │ ├── TestLog.vue │ │ └── DragJob.vue │ ├── keigennRecord2 │ │ ├── Amount.vue │ │ ├── StatusShow.vue │ │ └── Target.vue │ ├── castingMonitor │ │ ├── Header.vue │ │ └── Main.vue │ └── timeline │ │ └── SettingsDialog.vue ├── router │ └── index.ts ├── main.ts ├── mock │ └── demoLimitBreak.ts └── styles │ └── job.scss ├── .gitmodules ├── .vscode ├── extensions.json └── settings.json ├── .gitignore ├── .prettierrc ├── uno.config.ts ├── scripts ├── paths.ts ├── world.ts ├── all.ts ├── action.ts ├── status.ts ├── chinese2Icon.ts ├── contentFinderCondition.ts ├── map.ts ├── aetheryte.ts ├── meals.ts └── aethercurrent.ts ├── patches ├── 0002-hash-mode.patch ├── 0004-party-type.patch ├── 0003-event-type.patch └── 0001-postNamazu.patch ├── README.md ├── README.zh-TW.md ├── README.ja.md ├── LICENSE ├── tsconfig.json ├── README.en.md ├── .github └── workflows │ └── ci.yml ├── eslint.config.ts ├── index.html ├── package.json └── vite.config.ts /public/.nojekyll: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/_headers: -------------------------------------------------------------------------------- 1 | /* 2 | Permissions-Policy: interest-cohort=() 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Souma-Sumire/ff14-overlay-vue/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | onlyBuiltDependencies: 2 | - '@parcel/watcher' 3 | - esbuild 4 | - vue-demi 5 | -------------------------------------------------------------------------------- /src/assets/actWS.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Souma-Sumire/ff14-overlay-vue/HEAD/src/assets/actWS.webp -------------------------------------------------------------------------------- /src/assets/frame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Souma-Sumire/ff14-overlay-vue/HEAD/src/assets/frame.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "cactbot"] 2 | path = cactbot 3 | url = https://github.com/OverlayPlugin/cactbot.git 4 | -------------------------------------------------------------------------------- /src/assets/blubook.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Souma-Sumire/ff14-overlay-vue/HEAD/src/assets/blubook.webp -------------------------------------------------------------------------------- /src/assets/item_icon_frame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Souma-Sumire/ff14-overlay-vue/HEAD/src/assets/item_icon_frame.png -------------------------------------------------------------------------------- /src/assets/screenshots/dd.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Souma-Sumire/ff14-overlay-vue/HEAD/src/assets/screenshots/dd.webp -------------------------------------------------------------------------------- /src/assets/screenshots/dnc.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Souma-Sumire/ff14-overlay-vue/HEAD/src/assets/screenshots/dnc.webp -------------------------------------------------------------------------------- /src/assets/screenshots/food.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Souma-Sumire/ff14-overlay-vue/HEAD/src/assets/screenshots/food.webp -------------------------------------------------------------------------------- /src/assets/screenshots/radar.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Souma-Sumire/ff14-overlay-vue/HEAD/src/assets/screenshots/radar.webp -------------------------------------------------------------------------------- /src/assets/screenshots/time.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Souma-Sumire/ff14-overlay-vue/HEAD/src/assets/screenshots/time.webp -------------------------------------------------------------------------------- /src/assets/screenshots/timeline.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Souma-Sumire/ff14-overlay-vue/HEAD/src/assets/screenshots/timeline.webp -------------------------------------------------------------------------------- /src/assets/screenshots/teamWatch.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Souma-Sumire/ff14-overlay-vue/HEAD/src/assets/screenshots/teamWatch.webp -------------------------------------------------------------------------------- /src/assets/screenshots/zoneMacro.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Souma-Sumire/ff14-overlay-vue/HEAD/src/assets/screenshots/zoneMacro.webp -------------------------------------------------------------------------------- /src/common/font/FFXIV_Lodestone_SSF.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Souma-Sumire/ff14-overlay-vue/HEAD/src/common/font/FFXIV_Lodestone_SSF.ttf -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "antfu.unocss", 5 | "vue.volar" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /src/assets/screenshots/keySkillTimer.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Souma-Sumire/ff14-overlay-vue/HEAD/src/assets/screenshots/keySkillTimer.webp -------------------------------------------------------------------------------- /src/assets/screenshots/limitBreakTip.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Souma-Sumire/ff14-overlay-vue/HEAD/src/assets/screenshots/limitBreakTip.webp -------------------------------------------------------------------------------- /src/common/font/AccidentalPresidency.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Souma-Sumire/ff14-overlay-vue/HEAD/src/common/font/AccidentalPresidency.ttf -------------------------------------------------------------------------------- /src/common/font/FFXIV_Lodestone_SSF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Souma-Sumire/ff14-overlay-vue/HEAD/src/common/font/FFXIV_Lodestone_SSF.woff -------------------------------------------------------------------------------- /src/types/lang.d.ts: -------------------------------------------------------------------------------- 1 | type Lang = 'zhCn' | 'en' | 'ja' | 'zhTw' 2 | 3 | type CLang = 'en' | 'ja' | 'cn' 4 | 5 | export { type Lang, type CLang } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | components.d.ts 7 | auto-imports.d.ts 8 | .eslintrc-auto-import.json -------------------------------------------------------------------------------- /src/assets/screenshots/castingMonitor.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Souma-Sumire/ff14-overlay-vue/HEAD/src/assets/screenshots/castingMonitor.webp -------------------------------------------------------------------------------- /src/assets/screenshots/castingToChinese.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Souma-Sumire/ff14-overlay-vue/HEAD/src/assets/screenshots/castingToChinese.webp -------------------------------------------------------------------------------- /src/assets/screenshots/keigennRecord2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Souma-Sumire/ff14-overlay-vue/HEAD/src/assets/screenshots/keigennRecord2.webp -------------------------------------------------------------------------------- /src/assets/screenshots/instancedAreaInfo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Souma-Sumire/ff14-overlay-vue/HEAD/src/assets/screenshots/instancedAreaInfo.webp -------------------------------------------------------------------------------- /src/utils/iconToSrc.ts: -------------------------------------------------------------------------------- 1 | import { completeIcon } from '@/resources/status' 2 | 3 | function iconToSrc(icon: number) { 4 | return `//cafemaker.wakingsands.com/i/${completeIcon(icon)}_hr1.png` 5 | } 6 | export { iconToSrc } 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "overrides": [ 5 | { 6 | "files": "src/resources/dd.ts", 7 | "options": { 8 | "printWidth": 1000 9 | } 10 | } 11 | ] 12 | } -------------------------------------------------------------------------------- /src/resources/actionChineseTemp.ts: -------------------------------------------------------------------------------- 1 | const decChinese: Record = { 2 | // 3601: '测试', 3 | } 4 | 5 | export function getActionChineseTemp(actionDecId: number): string | undefined { 6 | return decChinese[actionDecId] 7 | } 8 | -------------------------------------------------------------------------------- /uno.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, presetAttributify, presetWind3 } from 'unocss' 2 | 3 | export default defineConfig({ 4 | presets: [ 5 | presetAttributify({ 6 | /* preset options */ 7 | }), 8 | presetWind3(), 9 | ], 10 | }) 11 | -------------------------------------------------------------------------------- /src/pages/timelineHelp.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | 13 | -------------------------------------------------------------------------------- /src/composables/useDev.ts: -------------------------------------------------------------------------------- 1 | import { useUrlSearchParams } from '@vueuse/core' 2 | import { computed } from 'vue' 3 | 4 | const params = useUrlSearchParams('hash') 5 | const dev = computed(() => params.dev === '1') 6 | 7 | function useDev() { 8 | return dev 9 | } 10 | 11 | export { useDev } 12 | -------------------------------------------------------------------------------- /scripts/paths.ts: -------------------------------------------------------------------------------- 1 | const csvPaths = { 2 | cn: 'E:/Github/ffxiv-datamining-hexcode-mixed/chs/', 3 | ja: 'E:/Github/ffxiv-datamining-hexcode-mixed/ja/', 4 | en: 'E:/Github/ffxiv-datamining-hexcode-mixed/en/', 5 | souma: 'E:/Github/FFXIVChnTextPatch-Souma/resource/rawexd/', 6 | } 7 | 8 | export { csvPaths } 9 | -------------------------------------------------------------------------------- /src/pages/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/types/partyPlayer.d.ts: -------------------------------------------------------------------------------- 1 | interface Player { 2 | id: string 3 | name: string 4 | inParty: boolean 5 | job: number 6 | } 7 | 8 | interface PlayerRuntime extends Player { 9 | rp?: string 10 | } 11 | 12 | type Role = 'tank' | 'healer' | 'dps' 13 | 14 | export { type Player, type PlayerRuntime, type Role } 15 | -------------------------------------------------------------------------------- /src/resources/actionChinese.ts: -------------------------------------------------------------------------------- 1 | import actionChinese from './actionChinese.json' 2 | 3 | const actionChineseMap = new Map(Object.entries(actionChinese)) 4 | 5 | function getActionChinese(id: number): string | undefined { 6 | return actionChineseMap.get(id.toString()) 7 | } 8 | 9 | export { actionChinese, getActionChinese } 10 | -------------------------------------------------------------------------------- /src/utils/tts.ts: -------------------------------------------------------------------------------- 1 | import { callOverlayHandler } from '../../cactbot/resources/overlay_plugin_api' 2 | 3 | function tts(text: string): Promise { 4 | if (text === '') { 5 | return Promise.resolve(undefined) 6 | } 7 | return callOverlayHandler({ 8 | call: 'cactbotSay', 9 | text, 10 | }) 11 | } 12 | 13 | export { tts } 14 | -------------------------------------------------------------------------------- /src/resources/action2ClassJobLevel.ts: -------------------------------------------------------------------------------- 1 | import action2ClassJobLevel from './action2ClassJobLevel.json' 2 | 3 | const action2ClassJobLevelMap = new Map(Object.entries(action2ClassJobLevel)) 4 | 5 | function actionId2ClassJobLevel(id: number): string | undefined { 6 | return action2ClassJobLevelMap.get(id.toString()) 7 | } 8 | 9 | export { actionId2ClassJobLevel } 10 | -------------------------------------------------------------------------------- /src/store/partySort.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | const usePartySortStore = defineStore('partySort', { 4 | state: () => ({ 5 | arr: useStorage('cactbotRuntime-sortArr', [ 6 | 21, 32, 37, 19, 24, 33, 40, 28, 34, 30, 39, 22, 20, 41, 23, 31, 38, 25, 7 | 27, 35, 42, 36, 8 | ] as number[]), 9 | }), 10 | }) 11 | 12 | export { usePartySortStore } 13 | -------------------------------------------------------------------------------- /src/types/macro.d.ts: -------------------------------------------------------------------------------- 1 | import type { WayMarkObj } from './PostNamazu' 2 | 3 | interface Macro { 4 | Name: string 5 | Editable?: boolean 6 | Deletability?: boolean 7 | } 8 | export interface MacroInfoMacro extends Macro { 9 | Text: string 10 | } 11 | export interface MacroInfoPlace extends Macro { 12 | Place: WayMarkObj 13 | } 14 | export type ZoneIdInfo = Record 15 | -------------------------------------------------------------------------------- /src/css/hover.css: -------------------------------------------------------------------------------- 1 | .hvr-grow { 2 | display: inline-block; 3 | vertical-align: middle; 4 | transform: translateZ(0); 5 | box-shadow: 0 0 1px rgba(0, 0, 0, 0); 6 | backface-visibility: hidden; 7 | -moz-osx-font-smoothing: grayscale; 8 | transition-duration: 0.3s; 9 | transition-property: transform; 10 | } 11 | 12 | .hvr-grow:hover, 13 | .hvr-grow:focus, 14 | .hvr-grow:active { 15 | transform: scale(1.1); 16 | } -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | declare module '*.vue' { 4 | import type { DefineComponent } from 'vue' 5 | 6 | const component: DefineComponent 7 | export default component 8 | } 9 | declare module '*.md' { 10 | import type { ComponentOptions } from 'vue' 11 | 12 | const Component: ComponentOptions 13 | export default Component 14 | } 15 | -------------------------------------------------------------------------------- /src/types/food.d.ts: -------------------------------------------------------------------------------- 1 | export interface Food { 2 | expiredMillisecond: number 3 | durationSeconds: number 4 | name: string 5 | hq: boolean 6 | params: { 7 | Params: string 8 | Value: string 9 | Max: string 10 | 'Value{HQ}': string 11 | 'Max{HQ}': string 12 | }[] 13 | level: number 14 | } 15 | 16 | export interface Players { 17 | id: string 18 | name: string 19 | food: Food | undefined 20 | jobName: string 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/keigennRecord2.ts: -------------------------------------------------------------------------------- 1 | import type { RowVO } from '@/types/keigennRecord2' 2 | 3 | export const invincibleEffect = [ 4 | Number(409).toString(16).toUpperCase(), // 死斗 5 | Number(810).toString(16).toUpperCase(), // 行尸走肉 6 | Number(811).toString(16).toUpperCase(), // 死而不僵 7 | Number(1836).toString(16).toUpperCase(), // 超火流星 8 | ] 9 | 10 | export function isLethal(row: RowVO): boolean { 11 | return ( 12 | row.currentHp - row.amount < 0 && 13 | !row.keigenns.find((k) => invincibleEffect.includes(k.effectId)) 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/types/action.d.ts: -------------------------------------------------------------------------------- 1 | export type IActionData = [ 2 | string, 3 | number, 4 | number, 5 | number, 6 | number, 7 | boolean, 8 | number, 9 | number, 10 | number, 11 | boolean, 12 | boolean, 13 | ] 14 | export interface IAction { 15 | Id: number 16 | Name: string 17 | Icon: number 18 | ActionCategory: number 19 | ClassJob: string 20 | ClassJobLevel: number 21 | IsRoleAction: boolean 22 | Cast100ms: number 23 | Recast100ms: number 24 | MaxCharges: number 25 | IsPvP: boolean 26 | IsPlayerAction: boolean 27 | Url?: string 28 | } 29 | -------------------------------------------------------------------------------- /src/resources/actionCategory.ts: -------------------------------------------------------------------------------- 1 | export const ActionCategoryTargetMap: Record = { 2 | 0: '', 3 | 1: '自动攻击', 4 | 2: '魔法', 5 | 3: '战技', 6 | 4: '能力', 7 | 5: '道具', 8 | 6: '采集能力', 9 | 7: '制作能力', 10 | 8: '任务', 11 | 9: '极限技', 12 | 10: '系统', 13 | 11: '系统', 14 | 12: '坐骑', 15 | 13: '特殊技能', 16 | 14: '道具操作', 17 | 15: '极限技', 18 | 16: '', 19 | 17: '弩炮', 20 | 18: '', 21 | } 22 | 23 | export const AttackTypeTargetMap: Record = { 24 | 0: '', 25 | 1: '斩击', 26 | 2: '突刺', 27 | 3: '打击', 28 | 4: '射击', 29 | 5: '魔法', 30 | 6: '吐息', 31 | 7: '音波', 32 | 8: '极限技', 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/debounceRef.ts: -------------------------------------------------------------------------------- 1 | import { customRef } from 'vue' 2 | 3 | export function debounceRef(value: T, duration = 1000) { 4 | let timer: number | null = null 5 | 6 | return customRef((track, trigger) => { 7 | return { 8 | get() { 9 | track() 10 | return value 11 | }, 12 | set(val) { 13 | if (timer) clearTimeout(timer) 14 | 15 | // biome-ignore lint/style/noParameterAssign: 16 | value = val 17 | timer = window.setTimeout(() => { 18 | trigger() 19 | timer = null // 清除定时器引用 20 | }, duration) 21 | }, 22 | } 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /src/resources/contentFinderCondition.ts: -------------------------------------------------------------------------------- 1 | // contentFinderCondition.csv A5: =B5&":"&D5&"," 2 | import contentFinderCondition from './contentFinderCondition.json' 3 | 4 | const map: Record = {} 5 | for (const key in contentFinderCondition) 6 | map[contentFinderCondition[key as keyof typeof contentFinderCondition]] = 7 | Number(key) 8 | 9 | export function getMapIDByTerritoryType(territoryType: number): number { 10 | return map[territoryType.toString()] ?? territoryType 11 | } 12 | export function getTerritoryTypeByMapID(mapID: number): number { 13 | return Number( 14 | contentFinderCondition[ 15 | mapID.toString() as keyof typeof contentFinderCondition 16 | ], 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/common/markdown/zoneMacro.md: -------------------------------------------------------------------------------- 1 | # 帮助 2 | 3 | ## 自述 4 | 5 | 本项目意在使玩家不再受困于FFXIV客户端本身的限制,提供一种自由的保存/使用 `用户宏`/`场景标记预设`的方式。 6 | 7 | ## 使用 8 | 9 | 直接在ACT悬浮窗中添加本项目的链接即可。 10 | 11 | ## 依赖 12 | 13 | [鲶鱼精邮差](https://github.com/Natsukage/PostNamazu/releases) 14 | 15 | ## 数据来源 16 | 17 | 默认自带的标点、宏大部分改编于以下来源 18 | 19 | * [FFXIVWaymarkPresets/wiki](https://github.com/Em-Six/FFXIVWaymarkPresets/wiki) 20 | * [FF14标点大全](https://docs.qq.com/sheet/DY0ttR2xQT1Vjc2V4?tab=BB08J2) 21 | * [NGA副本迷宫开门到打完系列](https://nga.178.com/read.php?pid=369819381) 22 | * [素素攻略站](https://www.ffxiv.cn/v2/) 23 | * [game8](https://game8.jp/ff14/) 24 | * [Waymark Present 标点合集](https://bbs.tggfl.com/topic/223/ff14%E5%8D%AB%E6%9C%88-waymark-present-%E6%A0%87%E7%82%B9%E5%90%88%E9%9B%86) 25 | * 哔哩哔哩投稿 26 | 27 | ## 感谢 28 | 29 | [@Natsukage](https://github.com/Natsukage) 30 | -------------------------------------------------------------------------------- /src/utils/deepClone.ts: -------------------------------------------------------------------------------- 1 | export const deepClone = (() => { 2 | if (typeof structuredClone === 'function') return structuredClone 3 | 4 | return (obj: T) => { 5 | if (obj === null || typeof obj !== 'object') return obj 6 | 7 | if (obj instanceof Date) return new Date(obj.getTime()) as T 8 | 9 | if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags) as T 10 | 11 | if (Array.isArray(obj)) return obj.map((item) => deepClone(item)) as T 12 | 13 | if (Object.getPrototypeOf(obj) === Object.prototype) { 14 | const result: Record = {} 15 | for (const key in obj) { 16 | if (Object.prototype.hasOwnProperty.call(obj, key)) { 17 | result[key] = deepClone((obj as Record)[key]) 18 | } 19 | } 20 | return result as T 21 | } 22 | 23 | return obj as T 24 | } 25 | })() 26 | -------------------------------------------------------------------------------- /scripts/world.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import csv from 'csv-parser' 3 | import iconv from 'iconv-lite' 4 | import { csvPaths } from './paths.js' 5 | 6 | const worlds: Set = new Set() 7 | let index = 0 8 | 9 | await new Promise((resolve, reject) => { 10 | fs.createReadStream(`${csvPaths.cn}World.csv`) 11 | .pipe(iconv.decodeStream('utf8')) 12 | .pipe(csv({ headers: false })) 13 | .on('data', (row: string[]) => { 14 | index++ 15 | if (index <= 5) { 16 | return 17 | } 18 | if (row[2]) { 19 | worlds.add(row[2]) 20 | } 21 | }) 22 | .on('end', resolve) 23 | .on('error', reject) 24 | }) 25 | 26 | const worldsArray = Array.from(worlds) 27 | const worldsString = `export const worlds = ${JSON.stringify(worldsArray, null, 2)};\n` 28 | const outputPath = 'src/resources/worlds.ts' 29 | 30 | fs.outputFileSync(outputPath, worldsString) 31 | -------------------------------------------------------------------------------- /scripts/all.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'node:child_process' 2 | import path from 'node:path' 3 | import { fileURLToPath } from 'node:url' 4 | 5 | const __filename = fileURLToPath(import.meta.url) 6 | const __dirname = path.dirname(__filename) 7 | 8 | const scripts: string[] = [ 9 | 'aethercurrent.ts', 10 | 'aetheryte.ts', 11 | 'action.ts', 12 | 'chinese2Icon.ts', 13 | 'map.ts', 14 | 'status.ts', 15 | 'contentFinderCondition.ts', 16 | 'meals.ts', 17 | // "world.ts", 18 | ] 19 | 20 | console.log('--- Running scripts ---') 21 | 22 | for (const script of scripts) { 23 | const scriptPath = path.join(__dirname, script) 24 | console.log(`Running ${script}...`) 25 | 26 | try { 27 | execSync(`npx tsx ${scriptPath}`, { stdio: 'inherit' }) 28 | } catch (error) { 29 | console.error(`❌ Error running ${script}:`, error) 30 | process.exit(1) 31 | } 32 | } 33 | 34 | console.log('--- All scripts completed! ---') 35 | -------------------------------------------------------------------------------- /src/utils/checkReferrer.ts: -------------------------------------------------------------------------------- 1 | const blockedHosts = ['shimo.im', 'vfiles.gtimg.cn', 'pan.baidu.com'] 2 | 3 | export function checkReferrer() { 4 | const ref = document.referrer 5 | if (!ref) return 6 | 7 | const url = new URL(ref) 8 | const hit = blockedHosts.some((host) => url.hostname.includes(host)) 9 | if (hit) { 10 | document.body.innerHTML = '' 11 | document.body.style.height = '100vh' 12 | document.body.style.display = 'flex' 13 | document.body.style.justifyContent = 'center' 14 | document.body.style.alignItems = 'center' 15 | document.body.style.color = '#d00' 16 | 17 | const content = document.createElement('div') 18 | content.innerHTML = ` 19 |

闲鱼小店死个妈!

20 |

本页面由 Souma 编写,永久免费,从未授权任何人进行售卖。

21 |

如果你是付费获得的链接,请立刻退款并举报卖家。

22 | ` 23 | document.body.appendChild(content) 24 | 25 | throw new Error('Blocked by referrer check') 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /scripts/action.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import csv from 'csv-parser' 3 | import iconv from 'iconv-lite' 4 | import { csvPaths } from './paths' 5 | 6 | type IdToNameMap = { 7 | [key: string]: string 8 | } 9 | 10 | const id2Name: IdToNameMap = {} 11 | const filePath = `${csvPaths.souma}Action.csv` 12 | 13 | await new Promise((resolve, reject) => { 14 | fs.createReadStream(filePath) 15 | .pipe(iconv.decodeStream('utf8')) 16 | .pipe(csv({ headers: false })) 17 | .on('data', (row: string[]) => { 18 | if ( 19 | ['key', '#', 'offset', 'int32', '0'].includes(row[0]!) || 20 | row[1] === '' 21 | ) { 22 | return 23 | } 24 | id2Name[row[0]!] = row[1]! 25 | }) 26 | .on('end', () => { 27 | resolve() 28 | }) 29 | .on('error', (error) => { 30 | reject(error) 31 | }) 32 | }) 33 | 34 | fs.outputJsonSync('src/resources/actionChinese.json', id2Name, { spaces: 2 }) 35 | -------------------------------------------------------------------------------- /src/types/keySkill.d.ts: -------------------------------------------------------------------------------- 1 | import type { FFIcon } from '@/types/fflogs' 2 | 3 | type DynamicValue = number | string 4 | 5 | interface KeySkill { 6 | // 此key是对于技能来说的,而不是技能的具体实例 7 | key: string 8 | id: DynamicValue 9 | tts: string 10 | duration: DynamicValue 11 | recast1000ms: DynamicValue 12 | job: number[] 13 | line: number 14 | minLevel: number 15 | } 16 | 17 | interface KeySkillEntity { 18 | // 此key是对于技能来说的,而不是技能的具体实例 19 | skillKey: string 20 | // 此key是对于某个具体玩家的技能实例来说的,同一个技能可能有多个实例 21 | instanceKey: string 22 | id: number 23 | tts: string 24 | duration: number 25 | recast1000ms: number 26 | job: number[] 27 | line: number 28 | minLevel: number 29 | src: string 30 | owner: { 31 | id: string 32 | name: string 33 | job: number 34 | jobIcon: FFIcon 35 | jobName: string 36 | hasDuplicate: { 37 | skill: boolean 38 | job: boolean 39 | } 40 | } 41 | } 42 | 43 | export { DynamicValue, KeySkill, KeySkillEntity } 44 | -------------------------------------------------------------------------------- /src/composables/useActReady.ts: -------------------------------------------------------------------------------- 1 | import { onMounted, ref } from 'vue' 2 | import { callOverlayHandler } from '../../cactbot/resources/overlay_plugin_api' 3 | import { useDev } from './useDev' 4 | 5 | const params = useUrlSearchParams('hash') 6 | const dev = useDev() 7 | 8 | export function useActReady() { 9 | const actReady = ref(false) 10 | 11 | async function checkAct(): Promise { 12 | if (dev.value) { 13 | actReady.value = true 14 | return 15 | } 16 | if (params.OVERLAY_WS !== undefined) { 17 | // 使用WS参数时,不检测。使用场景:OBS添加需要ACT的悬浮窗,且此时未启动ACT时。 18 | actReady.value = true 19 | return 20 | } 21 | await new Promise((resolve) => { 22 | callOverlayHandler({ call: 'getLanguage' }).then(() => { 23 | actReady.value = true 24 | resolve() 25 | }) 26 | setTimeout(() => { 27 | if (!actReady.value) checkAct() 28 | }, 3000) 29 | }) 30 | } 31 | 32 | onMounted(checkAct) 33 | 34 | return actReady 35 | } 36 | -------------------------------------------------------------------------------- /src/composables/useDemo.ts: -------------------------------------------------------------------------------- 1 | import { onMounted, onUnmounted, ref } from 'vue' 2 | 3 | const demo = ref(false) 4 | let initialized = false 5 | let listeners = 0 6 | 7 | function handleOverlayStateUpdate(e: CustomEvent<{ isLocked: boolean }>) { 8 | demo.value = e?.detail?.isLocked === false 9 | } 10 | 11 | function useDemo() { 12 | onMounted(() => { 13 | listeners++ 14 | if (!initialized) { 15 | demo.value = 16 | document.getElementById('unlocked')?.style?.display === 'flex' 17 | document.addEventListener( 18 | 'onOverlayStateUpdate', 19 | handleOverlayStateUpdate, 20 | ) 21 | initialized = true 22 | } 23 | }) 24 | 25 | onUnmounted(() => { 26 | listeners-- 27 | if (listeners <= 0 && initialized) { 28 | document.removeEventListener( 29 | 'onOverlayStateUpdate', 30 | handleOverlayStateUpdate, 31 | ) 32 | initialized = false 33 | } 34 | }) 35 | 36 | return demo 37 | } 38 | 39 | export { useDemo } 40 | -------------------------------------------------------------------------------- /patches/0002-hash-mode.patch: -------------------------------------------------------------------------------- 1 | From b750eb2b60edc25d2224361676a3d17268ec5445 Mon Sep 17 00:00:00 2001 2 | From: Souma-Sumire <553469159@qq.com> 3 | Date: Wed, 15 May 2024 02:57:41 +0800 4 | Subject: [PATCH] hash mode 5 | 6 | --- 7 | resources/overlay_plugin_api.ts | 2 +- 8 | 1 file changed, 1 insertion(+), 1 deletion(-) 9 | 10 | diff --git a/cactbot/resources/overlay_plugin_api.ts b/cactbot/resources/overlay_plugin_api.ts 11 | index b08e4d0b7..b5e8f34a9 100644 12 | --- a/cactbot/resources/overlay_plugin_api.ts 13 | +++ b/cactbot/resources/overlay_plugin_api.ts 14 | @@ -209,7 +209,7 @@ export const init = (): void => { 15 | return; 16 | 17 | if (typeof window !== 'undefined') { 18 | - wsUrl = new URLSearchParams(window.location.search).get('OVERLAY_WS'); 19 | + wsUrl = new URLSearchParams(window.location.href.split('?')[1]).get('OVERLAY_WS'); 20 | if (wsUrl !== null) { 21 | const connectWs = function(wsUrl: string) { 22 | ws = new WebSocket(wsUrl); 23 | -- 24 | 2.41.0.windows.1 25 | 26 | -------------------------------------------------------------------------------- /src/types/PostNamazu.ts: -------------------------------------------------------------------------------- 1 | import type { PostNamazuCall } from '../../cactbot/types/event' 2 | 3 | export interface WayMarkInfo { 4 | X: number 5 | Y: number 6 | Z: number 7 | Active: boolean 8 | ID?: number 9 | } 10 | export type WayMarkKeys = 11 | | 'A' 12 | | 'B' 13 | | 'C' 14 | | 'D' 15 | | 'One' 16 | | 'Two' 17 | | 'Three' 18 | | 'Four' 19 | 20 | export type WayMarkObj = { [key in WayMarkKeys]: WayMarkInfo } 21 | export type PPJSON = WayMarkObj & { Name: string; MapID: number } 22 | 23 | export type QueueArr = { 24 | c: Exclude | 'qid' | 'stop' 25 | p: string 26 | d?: number 27 | }[] 28 | 29 | export type Slot = 30 | | 1 31 | | 2 32 | | 3 33 | | 4 34 | | 5 35 | | 6 36 | | 7 37 | | 8 38 | | 9 39 | | 10 40 | | 11 41 | | 12 42 | | 13 43 | | 14 44 | | 15 45 | | 16 46 | | 17 47 | | 18 48 | | 19 49 | | 20 50 | | 21 51 | | 22 52 | | 23 53 | | 24 54 | | 25 55 | | 26 56 | | 27 57 | | 28 58 | | 29 59 | | 30 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ff14-overlay-vue 2 | 3 | [English](README.en.md) | [日本語](README.ja.md) | [繁體中文](README.zh-TW.md) 4 | 5 | ## 部署地址 6 | 7 | - 国内:[呆萌服务器](https://souma.diemoe.net/#/) 8 | - 国外 / 备用:[Github Pages](https://souma-sumire.github.io/ff14-overlay-vue/#/) 9 | 10 | ### 加入 ACT 悬浮窗 11 | 12 | 1. 打开 ACT > OverlayPlugin 插件 13 | 2. 点击 “新建” 14 | 3. 设置如下: 15 | - 名称:任意名称 16 | - 预设:选择“自定义悬浮窗” 17 | - 类型:选择“数据统计” 18 | 19 | ## 开发指南 20 | 21 | ### 一、环境准备 22 | 23 | 1. [Node.js(LTS 版本)](https://nodejs.org/en/download) 24 | 2. [pnpm 包管理器](https://pnpm.io/installation) 25 | 26 | ### 二、启动项目 27 | 28 | ```bash 29 | pnpm install 30 | git submodule update --init --recursive 31 | git submodule update --remote --recursive 32 | git apply patches/0001-postNamazu.patch patches/0002-hash-mode.patch patches/0003-event-type.patch patches/0004-party-type.patch 33 | pnpm dev 34 | pnpm vite build 35 | ``` 36 | 37 | 大部分页面,可以通过 dev 参数来进入测试模式。例如: 38 | `http://localhost:3000/ff14-overlay-vue/#/keySkillTimer?dev=1` 39 | -------------------------------------------------------------------------------- /README.zh-TW.md: -------------------------------------------------------------------------------- 1 | # ff14-overlay-vue 2 | 3 | [简体中文](README.md) | [English](README.en.md) | [日本語](README.ja.md) 4 | 5 | ## 部署位置 6 | 7 | - 海外:[GitHub Pages](https://souma-sumire.github.io/ff14-overlay-vue/#/) 8 | - 中國大陸:[呆萌伺服器](https://souma.diemoe.net/#/) 9 | 10 | ### 加入 ACT 浮動視窗 11 | 12 | 1. 打開 **ACT > OverlayPlugin** 插件 13 | 2. 點擊 **「新增」** 14 | 3. 設定如下: 15 | - **名稱:** 可任意命名 16 | - **預設:** 選擇「自訂浮動視窗」 17 | - **類型:** 選擇「資料統計」 18 | 19 | ## 開發指南 20 | 21 | ### 一、環境準備 22 | 23 | 1. [Node.js(LTS 版本)](https://nodejs.org/en/download) 24 | 2. [pnpm 套件管理器](https://pnpm.io/installation) 25 | 26 | ### 二、啟動專案 27 | 28 | ```bash 29 | pnpm install 30 | git submodule update --init --recursive 31 | git submodule update --remote --recursive 32 | git apply patches/0001-postNamazu.patch patches/0002-hash-mode.patch patches/0003-event-type.patch patches/0004-party-type.patch 33 | pnpm dev 34 | pnpm vite build 35 | ``` 36 | 37 | 大多數頁面都可以透過 `dev` 參數進入測試模式,例如: 38 | `http://localhost:3000/ff14-overlay-vue/#/keySkillTimer?dev=1` 39 | -------------------------------------------------------------------------------- /README.ja.md: -------------------------------------------------------------------------------- 1 | # ff14-overlay-vue 2 | 3 | [简体中文](README.md) | [English](README.en.md) | [繁體中文](README.zh-TW.md) 4 | 5 | ## デプロイ先 6 | 7 | - 海外:[GitHub Pages](https://souma-sumire.github.io/ff14-overlay-vue/#/) 8 | - 国内サーバー:[ダイモエサーバー](https://souma.diemoe.net/#/) 9 | 10 | ### ACT オーバーレイに追加する 11 | 12 | 1. **ACT > OverlayPlugin** を開く 13 | 2. **「新規作成」** をクリック 14 | 3. 次のように設定します: 15 | - **名前:** 任意の名前 16 | - **プリセット:** 「カスタムオーバーレイ」を選択 17 | - **タイプ:** 「データ統計」を選択 18 | 19 | ## 開発ガイド 20 | 21 | ### 1. 環境の準備 22 | 23 | 1. [Node.js(LTS バージョン)](https://nodejs.org/en/download) 24 | 2. [pnpm パッケージマネージャー](https://pnpm.io/installation) 25 | 26 | ### 2. プロジェクトの起動 27 | 28 | ```bash 29 | pnpm install 30 | git submodule update --init --recursive 31 | git submodule update --remote --recursive 32 | git apply patches/0001-postNamazu.patch patches/0002-hash-mode.patch patches/0003-event-type.patch patches/0004-party-type.patch 33 | pnpm dev 34 | pnpm vite build 35 | ``` 36 | 37 | ほとんどのページは、`dev` パラメータを使うことでテストモードで開けます。 38 | 例:`http://localhost:3000/ff14-overlay-vue/#/keySkillTimer?dev=1` 39 | -------------------------------------------------------------------------------- /src/components/common/ActWrapper.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 29 | 30 | 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Souma 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | 7 | "baseUrl": "./", 8 | "module": "ESNext", 9 | "moduleResolution": "Bundler", 10 | "paths": { 11 | "@": ["src"], 12 | "@/*": ["src/*"] 13 | }, 14 | "resolveJsonModule": true, 15 | "types": ["vite/client", "element-plus/global"], 16 | 17 | "allowJs": true, 18 | "strict": true, 19 | "strictFunctionTypes": true, 20 | "strictNullChecks": true, 21 | "strictPropertyInitialization": true, 22 | "alwaysStrict": true, 23 | 24 | "noFallthroughCasesInSwitch": true, 25 | "noImplicitAny": true, 26 | "noImplicitThis": true, 27 | "noUncheckedIndexedAccess": true, 28 | "noUnusedLocals": true, 29 | "noUnusedParameters": true, 30 | "outDir": "./dist", 31 | "sourceMap": true, 32 | "esModuleInterop": true, 33 | "forceConsistentCasingInFileNames": true, 34 | "verbatimModuleSyntax": true, 35 | "skipLibCheck": true 36 | }, 37 | "include": ["src", "scripts", "./*.config.ts"], 38 | "exclude": ["node_modules", "dist", "cactbot"] 39 | } 40 | -------------------------------------------------------------------------------- /README.en.md: -------------------------------------------------------------------------------- 1 | # ff14-overlay-vue 2 | 3 | [简体中文](README.md) | [日本語](README.ja.md) | [繁體中文](README.zh-TW.md) 4 | 5 | ## Deployment 6 | 7 | - Global: [GitHub Pages](https://souma-sumire.github.io/ff14-overlay-vue/#/) 8 | - China Mainland: [Daimoe Server](https://souma.diemoe.net/#/) 9 | 10 | ### Add to ACT Overlay 11 | 12 | 1. Open **ACT > OverlayPlugin** 13 | 2. Click **"New"** 14 | 3. Configure as follows: 15 | - **Name:** Any name you like 16 | - **Preset:** Choose “Custom Overlay” 17 | - **Type:** Select “Data Statistics” 18 | 19 | ## Development Guide 20 | 21 | ### 1. Environment Setup 22 | 23 | 1. [Node.js (LTS version)](https://nodejs.org/en/download) 24 | 2. [pnpm package manager](https://pnpm.io/installation) 25 | 26 | ### 2. Start the Project 27 | 28 | ```bash 29 | pnpm install 30 | git submodule update --init --recursive 31 | git submodule update --remote --recursive 32 | git apply patches/0001-postNamazu.patch patches/0002-hash-mode.patch patches/0003-event-type.patch patches/0004-party-type.patch 33 | pnpm dev 34 | pnpm vite build 35 | ``` 36 | 37 | Most pages can be opened in test mode using the `dev` parameter, for example: 38 | `http://localhost:3000/ff14-overlay-vue/#/keySkillTimer?dev=1` 39 | -------------------------------------------------------------------------------- /patches/0004-party-type.patch: -------------------------------------------------------------------------------- 1 | From 94e8f3b926c5ec08b79af18275325e46453df76d Mon Sep 17 00:00:00 2001 2 | From: Souma-Sumire <553469159@qq.com> 3 | Date: Sun, 26 Oct 2025 19:33:17 +0800 4 | Subject: [PATCH] party type 5 | 6 | --- 7 | types/event.d.ts | 17 +++++++++++++++++ 8 | 1 file changed, 17 insertions(+) 9 | 10 | diff --git a/cactbot/types/event.d.ts b/cactbot/types/event.d.ts 11 | index d99c42d18..808c2d645 100644 12 | --- a/cactbot/types/event.d.ts 13 | +++ b/cactbot/types/event.d.ts 14 | @@ -7,12 +7,29 @@ declare global { 15 | 'onOverlayStateUpdate': CustomEvent<{ isLocked: boolean }>; 16 | } 17 | } 18 | +export enum PartyType { 19 | + Solo, 20 | + Party, 21 | + AllianceA, 22 | + AllianceB, 23 | + AllianceC, 24 | + AllianceD, 25 | + AllianceE, 26 | + AllianceF, 27 | +} 28 | + 29 | export interface Party { 30 | id: string; 31 | name: string; 32 | worldId: number; 33 | job: number; 34 | inParty: boolean; 35 | + contentId: number; 36 | + flags: number; 37 | + level: number; 38 | + objectId: number; 39 | + partyType: PartyType; 40 | + territoryType: number; 41 | } 42 | 43 | export type BardSongType = 'Ballad' | 'Paeon' | 'Minuet' | 'None'; 44 | -- 45 | 2.45.1.windows.1 46 | 47 | -------------------------------------------------------------------------------- /src/resources/status.ts: -------------------------------------------------------------------------------- 1 | import STATUS from './status.json' 2 | 3 | const _STATUS = STATUS as unknown as Record 4 | 5 | for (const key in STATUS) { 6 | const element = STATUS[key as keyof typeof STATUS] 7 | _STATUS[key] = [ 8 | element[0]!, 9 | Number.parseInt(element[1]!), 10 | Number.parseInt(element[2]!), 11 | ] 12 | } 13 | 14 | export function completeIcon(icon: number): string { 15 | let head = [...'000000'] 16 | const iconStr = icon.toString() 17 | if (iconStr.length > 3) { 18 | const temp = [...iconStr].slice(0, iconStr.length - 3).concat(...'000') 19 | head = [...head.slice(0, 6 - temp.length), ...temp] 20 | } 21 | let foot = [...'000000'] 22 | foot = [...foot.slice(0, 6 - iconStr.length), ...iconStr] 23 | return `${head.join('')}/${foot.join('')}` 24 | } 25 | 26 | export function stackUrl(url: string, stack: number) { 27 | return stack > 1 && stack <= 16 28 | ? url.substring(0, 7) + 29 | ( 30 | Array.from({ length: 6 }).join('0') + 31 | (Number.parseInt(url.substring(7)) + stack - 1) 32 | ).slice(-6) 33 | : url 34 | } 35 | // A: =B5&": ["""&C5&""", "&E5&"]," 36 | export const statusData: Record = _STATUS 37 | -------------------------------------------------------------------------------- /src/utils/postNamazu.ts: -------------------------------------------------------------------------------- 1 | import type { PPJSON, QueueArr, Slot, WayMarkObj } from '@/types/PostNamazu' 2 | import { callOverlayHandler } from '../../cactbot/resources/overlay_plugin_api' 3 | import { getMapIDByTerritoryType } from '../resources/contentFinderCondition' 4 | 5 | export function doTextCommand(text: string) { 6 | return callOverlayHandler({ 7 | call: 'PostNamazu', 8 | c: 'DoTextCommand', 9 | p: text, 10 | }) 11 | } 12 | export function doWayMarks(json: WayMarkObj, localOnly: boolean = true) { 13 | return callOverlayHandler({ 14 | call: 'PostNamazu', 15 | c: 'DoWaymarks', 16 | p: JSON.stringify({ ...json, LocalOnly: localOnly }), 17 | }) 18 | } 19 | 20 | export function doInsertPreset( 21 | mapID: number, 22 | json: WayMarkObj, 23 | slot: Slot = 1, 24 | ) { 25 | const ppJson: PPJSON = { 26 | ...json, 27 | MapID: getMapIDByTerritoryType(mapID), 28 | Name: `Slot${slot}`, 29 | } 30 | return callOverlayHandler({ 31 | call: 'PostNamazu', 32 | c: 'DoInsertPreset', 33 | p: JSON.stringify(ppJson), 34 | }) 35 | } 36 | 37 | export function doQueueActions(queue: QueueArr) { 38 | return callOverlayHandler({ 39 | call: 'PostNamazu', 40 | c: 'DoQueueActions', 41 | p: JSON.stringify(queue), 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /src/composables/useIndexedDB.ts: -------------------------------------------------------------------------------- 1 | import type { IDBPDatabase } from 'idb' 2 | import { openDB } from 'idb' 3 | 4 | const DB_NAME = 'souma' 5 | let dbPromise: Promise | null = null 6 | 7 | async function getDB(storeName: string) { 8 | if (!dbPromise) { 9 | dbPromise = openDB(DB_NAME, 1, { 10 | upgrade(db) { 11 | if (!db.objectStoreNames.contains(storeName)) { 12 | db.createObjectStore(storeName, { keyPath: 'key' }) 13 | } 14 | }, 15 | }) 16 | } 17 | return dbPromise 18 | } 19 | 20 | export function useIndexedDB(storeName: string) { 21 | const withDB = async (fn: (db: IDBPDatabase) => Promise) => 22 | fn(await getDB(storeName)) 23 | 24 | return { 25 | getAll: () => withDB((db) => db.getAll(storeName)), 26 | get: (key: string) => withDB((db) => db.get(storeName, key)), 27 | set: (item: T) => withDB((db) => db.put(storeName, item)), 28 | bulkSet: (items: T[]) => 29 | withDB(async (db) => { 30 | const tx = db.transaction(storeName, 'readwrite') 31 | for (const item of items) tx.store.put(item) 32 | await tx.done 33 | }), 34 | remove: (key: string) => withDB((db) => db.delete(storeName, key)), 35 | clear: () => withDB((db) => db.clear(storeName)), 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/types/combatant.d.ts: -------------------------------------------------------------------------------- 1 | export interface CombatantState { 2 | OwnerID: number 3 | Type: number 4 | MonsterType: number 5 | Status: number 6 | AggressionStatus: number 7 | IsTargetable: boolean 8 | Name: string 9 | Radius: number 10 | BNpcID: number 11 | CurrentMP: number 12 | IsCasting1: number 13 | BNpcNameID: number 14 | TransformationId: number 15 | WeaponId: number 16 | TargetID: number 17 | ModelStatus: number 18 | ID: number 19 | Job: number 20 | CurrentHP: number 21 | MaxHP: number 22 | PosX: number 23 | PosY: number 24 | PosZ: number 25 | Heading: number 26 | Distance: null 27 | EffectiveDistance: null 28 | Effects: CombatantStateEffect[] 29 | MaxMP: number 30 | Level: number 31 | WorldID: number 32 | CurrentWorldID: number 33 | NPCTargetID: number 34 | CurrentGP: number 35 | MaxGP: number 36 | CurrentCP: number 37 | MaxCP: number 38 | PCTargetID: number 39 | IsCasting2: number 40 | CastBuffID: number 41 | CastTargetID: number 42 | CastGroundTargetX: number 43 | CastGroundTargetY: number 44 | CastGroundTargetZ: number 45 | CastDurationCurrent: number 46 | CastDurationMax: number 47 | PartyType: number 48 | WorldName: string 49 | } 50 | 51 | export interface CombatantStateEffect { 52 | BuffID: number 53 | Stack: number 54 | Timer: number 55 | ActorID: number 56 | isOwner: boolean 57 | } 58 | -------------------------------------------------------------------------------- /patches/0003-event-type.patch: -------------------------------------------------------------------------------- 1 | From c1cfef19d20234f204c0f5580a273740f71b9290 Mon Sep 17 00:00:00 2001 2 | From: Souma-Sumire <553469159@qq.com> 3 | Date: Tue, 14 Oct 2025 14:42:45 +0800 4 | Subject: [PATCH] event type 5 | 6 | --- 7 | types/event.d.ts | 26 ++++++++++++++++++++++++++ 8 | 1 file changed, 26 insertions(+) 9 | 10 | diff --git a/cactbot/types/event.d.ts a/cactbot/types/event.d.ts 11 | index 34dc54879..ce54fef79 100644 12 | --- a/cactbot/types/event.d.ts 13 | +++ a/cactbot/types/event.d.ts 14 | @@ -213,6 +213,32 @@ export type EnmityTargetCombatant = { 15 | ActorID: number; 16 | isOwner: boolean; 17 | }[]; 18 | + 19 | + BNpcID: number; 20 | + CurrentMP: number; 21 | + IsCasting1: number; 22 | + BNpcNameID: number; 23 | + TransformationId: number; 24 | + WeaponId: number; 25 | + Heading: number; 26 | + MaxMP: number; 27 | + Level: number; 28 | + WorldID: number; 29 | + CurrentWorldID: number; 30 | + NPCTargetID: number; 31 | + CurrentGP: number; 32 | + MaxGP: number; 33 | + CurrentCP: number; 34 | + MaxCP: number; 35 | + PCTargetID: number; 36 | + IsCasting2: number; 37 | + CastBuffID: number; 38 | + CastTargetID: number; 39 | + CastGroundTargetX: number; 40 | + CastGroundTargetY: number; 41 | + CastGroundTargetZ: number; 42 | + CastDurationCurrent: number; 43 | + CastDurationMax: number; 44 | }; 45 | 46 | export interface EventMap { 47 | -- 48 | 2.45.1.windows.1 49 | 50 | -------------------------------------------------------------------------------- /src/types/timeline.ts: -------------------------------------------------------------------------------- 1 | import type { Job } from 'cactbot/types/job' 2 | 3 | export interface ITimeline { 4 | name: string 5 | condition: ITimelineCondition 6 | timeline: string 7 | codeFight: string 8 | create: string 9 | } 10 | 11 | export interface ITimelineCondition { 12 | zoneId: string // 0=true 13 | jobs: Job[] // JobEnum.NONE=true 14 | } 15 | 16 | export interface ITimelineLine { 17 | time: number 18 | action?: string 19 | sync?: RegExp 20 | syncOnce?: boolean 21 | battleOnce?: boolean 22 | syncAlready?: boolean 23 | show: boolean 24 | windowBefore: number 25 | windowAfter: number 26 | jump?: number 27 | alertAlready: boolean 28 | tts?: string 29 | label?: string 30 | } 31 | 32 | export enum TimelineConfigEnum { 33 | 显示范围 = 'displayDuration', 34 | 变色时间 = 'discoloration', 35 | 零后持续 = 'hold', 36 | 战前准备 = 'preBattle', 37 | TTS提前量 = 'ttsAdvance', 38 | } 39 | 40 | export type TimelineConfigTranslate = Record 41 | export type TimelineConfigValues = Record 42 | export type ShowStyleTranslate = Record 43 | export type ShowStyle = Record 44 | export enum ShowStyleConfigEnum { 45 | 总宽度 = '--timeline-width', 46 | 未到来缩放 = '--normal-scale', 47 | 即将到来缩放 = '--up-coming-scale', 48 | 字体尺寸 = '--font-size', 49 | 变色动画时间 = '--tras-duration', 50 | 未到来不透明度 = '--opacity', 51 | } 52 | -------------------------------------------------------------------------------- /src/composables/useLang.ts: -------------------------------------------------------------------------------- 1 | import type { CLang, Lang } from '@/types/lang' 2 | import { useI18n } from 'vue-i18n' 3 | 4 | const params = useUrlSearchParams('hash') 5 | const urlLang = params.lang?.toString() as Lang 6 | const localeRef = ref(urlLang || 'en') 7 | 8 | function useLang() { 9 | const i18n = useI18n() 10 | i18n.locale.value = urlLang || i18n.locale.value || 'en' 11 | watch(i18n.locale, (locale) => { 12 | localeRef.value = locale as Lang 13 | }) 14 | function setLang(lang: Lang) { 15 | i18n.locale.value = lang 16 | localeRef.value = lang 17 | } 18 | 19 | return { 20 | ...i18n, 21 | setLang, 22 | } 23 | } 24 | 25 | const l: Record = { 26 | en: 'en', 27 | ja: 'ja', 28 | zhCn: 'cn', 29 | zhTw: 'cn', 30 | } 31 | 32 | function localeToCactbotLang(locale: Lang): CLang { 33 | return l[locale] || 'en' 34 | } 35 | 36 | function getLocaleMessage(text: Record): string { 37 | const l = localeToCactbotLang(localeRef.value) 38 | return text[l as keyof typeof text] ?? text.zhCn 39 | } 40 | function getCactbotLocaleMessage(text: Partial>): string { 41 | if (!text) return 'Unknown' 42 | return ( 43 | text[localeRef.value as keyof typeof text] ?? 44 | text.cn ?? 45 | `${text.en} / ${text.ja}` 46 | ) 47 | } 48 | 49 | export { 50 | useLang, 51 | localeToCactbotLang, 52 | getLocaleMessage, 53 | getCactbotLocaleMessage, 54 | } 55 | -------------------------------------------------------------------------------- /src/pages/404.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 15 | 16 | 70 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | paths-ignore: 9 | - '*.md' 10 | pull_request: 11 | branches: 12 | - main 13 | paths-ignore: 14 | - '*.md' 15 | types: 16 | - closed 17 | 18 | permissions: 19 | pages: write 20 | id-token: write 21 | contents: write 22 | 23 | jobs: 24 | build-and-deploy: 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - name: Checkout Repository 29 | uses: actions/checkout@v4 30 | with: 31 | submodules: recursive 32 | fetch-depth: 0 33 | 34 | - name: Update Submodules 35 | run: git submodule update --remote --recursive 36 | 37 | - name: Set up Node.js 38 | uses: actions/setup-node@v4 39 | with: 40 | node-version: 20.x 41 | 42 | - name: Install Dependencies 43 | run: | 44 | npm install -g pnpm 45 | pnpm install 46 | 47 | - name: Patch 48 | run: git apply patches/0001-postNamazu.patch patches/0002-hash-mode.patch patches/0003-event-type.patch patches/0004-party-type.patch 49 | 50 | - name: Build 51 | run: pnpm run github-build 52 | 53 | - name: Deploy to GitHub Pages 54 | uses: peaceiris/actions-gh-pages@v4 55 | with: 56 | github_token: ${{ secrets.GITHUB_TOKEN }} 57 | publish_dir: ./dist 58 | -------------------------------------------------------------------------------- /src/types/keigennRecord2.d.ts: -------------------------------------------------------------------------------- 1 | import type { FFIcon } from '@/types/fflogs' 2 | import type { DamageEffect, DamageType } from '@/utils/flags' 3 | 4 | export interface RowVO { 5 | key: string 6 | time: string 7 | id?: string 8 | actionCN: string 9 | action: string 10 | source: string 11 | target: string 12 | targetId: string 13 | job: string 14 | jobIcon: FFIcon 15 | jobEnum: number 16 | hasDuplicate: boolean 17 | amount: number 18 | keigenns: Status[] 19 | currentHp: number 20 | maxHp: number 21 | effect: DamageEffect 22 | type: DamageType 23 | shield: string 24 | povId: string 25 | reduction: number 26 | } 27 | 28 | export interface Status { 29 | type: KeigennType 30 | name: string 31 | count: number 32 | effect: string 33 | effectId: string 34 | source: string 35 | sourceId: string 36 | target: string 37 | targetId: string 38 | expirationTimestamp: number 39 | performance: PerformanceType 40 | fullIcon: string 41 | isPov: boolean 42 | remainingDuration?: string 43 | } 44 | 45 | export interface Encounter { 46 | key: string 47 | zoneName: string 48 | duration: string 49 | table: RowVO[] 50 | timestamp: number 51 | } 52 | 53 | export type KeigennType = 'multiplier' | 'absorbed' 54 | 55 | export interface PerformanceType { 56 | physics: number 57 | magic: number 58 | darkness: number 59 | } 60 | 61 | export interface Keigenn { 62 | id: number 63 | fullIcon: string 64 | type: KeigennType 65 | performance: PerformanceType 66 | isFriendly: boolean 67 | name: string 68 | } 69 | -------------------------------------------------------------------------------- /src/utils/dynamicValue.ts: -------------------------------------------------------------------------------- 1 | import { getActionChinese } from '@/resources/actionChinese' 2 | import { chineseToIcon } from './chineseToIcon' 3 | import { compareSame } from './compareSaveAction' 4 | import { iconToSrc } from './iconToSrc' 5 | 6 | function parseDynamicValue(value: string | number, level: number): number { 7 | if (typeof value === 'number') return value 8 | 9 | const numericAttempt = Number(value) 10 | if (!Number.isNaN(numericAttempt)) return numericAttempt 11 | 12 | try { 13 | if (!/^[\s\w=>()*+\-/.,:; number 20 | const result = fn(level) 21 | 22 | if (typeof result !== 'number' || Number.isNaN(result)) { 23 | throw new TypeError(`Function returned non-number: ${result}`) 24 | } 25 | 26 | return result 27 | } catch (e) { 28 | console.error(`解析动态值无法处理值 "${value}":`, e) 29 | return 0 30 | } 31 | } 32 | 33 | function idToSrc(id: number | string) { 34 | if (typeof id === 'string') { 35 | id = parseDynamicValue(id, 999) 36 | } 37 | 38 | const chinese = getActionChinese(id) || getActionChinese(compareSame(id)) 39 | if (!chinese) { 40 | // console.warn(`找不到动作中文: ${id}`) 41 | return '' 42 | } 43 | const icon = chineseToIcon(chinese) 44 | if (!icon) { 45 | // console.warn(`找不到动作图标: ${chinese}, icon: ${icon}`) 46 | return '' 47 | } 48 | return iconToSrc(icon) 49 | } 50 | export { idToSrc, parseDynamicValue } 51 | -------------------------------------------------------------------------------- /src/utils/clipboard.ts: -------------------------------------------------------------------------------- 1 | function copyToClipboard(text: string): Promise { 2 | return new Promise((resolve, reject) => { 3 | const textarea = document.createElement('textarea') 4 | textarea.value = text 5 | document.body.appendChild(textarea) 6 | textarea.select() 7 | try { 8 | document.execCommand('copy') 9 | resolve() 10 | } catch (err) { 11 | reject(err) 12 | } finally { 13 | document.body.removeChild(textarea) 14 | } 15 | }) 16 | } 17 | 18 | function copyImage(src: string): Promise { 19 | return new Promise((resolve, reject) => { 20 | const img = document.createElement('img') 21 | img.src = src 22 | 23 | img.onload = () => { 24 | const div = document.createElement('div') 25 | div.style.position = 'fixed' 26 | div.style.left = '-9999px' 27 | div.appendChild(img) 28 | document.body.appendChild(div) 29 | 30 | const range = document.createRange() 31 | range.selectNode(img) 32 | const selection = window.getSelection() 33 | if (!selection) { 34 | reject(new Error('无法获取 selection 对象')) 35 | return 36 | } 37 | selection.removeAllRanges() 38 | selection.addRange(range) 39 | 40 | const ok = document.execCommand('copy') 41 | document.body.removeChild(div) 42 | selection.removeAllRanges() 43 | 44 | if (ok) resolve() 45 | else reject(new Error('execCommand copy 失败')) 46 | } 47 | 48 | img.onerror = () => reject(new Error('图片加载失败')) 49 | }) 50 | } 51 | 52 | export { copyImage, copyToClipboard } 53 | -------------------------------------------------------------------------------- /scripts/status.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import csv from 'csv-parser' 3 | import iconv from 'iconv-lite' 4 | import { csvPaths } from './paths' 5 | 6 | type FileValues = Record 7 | type StatusResult = Record 8 | 9 | const fileValues: FileValues = {} 10 | 11 | function readFile(fileName: string, filePath: string): Promise { 12 | return new Promise((resolve, reject) => { 13 | fs.createReadStream(filePath) 14 | .pipe(iconv.decodeStream('utf8')) 15 | .pipe(csv({ headers: false })) 16 | .on('data', (row: string[]) => { 17 | if (!fileValues[fileName]) { 18 | fileValues[fileName] = [] 19 | } 20 | if (row[0] === '' && row[1] === '0' && row[2] === '0') { 21 | return 22 | } 23 | fileValues[fileName].push(row) 24 | }) 25 | .on('end', resolve) 26 | .on('error', reject) 27 | }) 28 | } 29 | 30 | await Promise.all([ 31 | readFile('status.csv', `${csvPaths.souma}status.csv`), 32 | readFile('status_ja.csv', `${csvPaths.ja}status.csv`), 33 | ]) 34 | 35 | const result: StatusResult = {} 36 | const soumaData = fileValues['status.csv']! 37 | const jaData = fileValues['status_ja.csv']! 38 | 39 | soumaData.forEach((row) => { 40 | if (['key', '#', 'offset', 'int32', '0'].includes(row[0]!)) { 41 | return 42 | } 43 | 44 | const jaRow = jaData.find((r) => r[0] === row[0]) 45 | if (jaRow && row[1]) { 46 | result[row[0]!] = [row[1]!, jaRow[3]!, jaRow[6]!] 47 | } 48 | }) 49 | 50 | const outputPath = 'src/resources/status.json' 51 | fs.outputJsonSync(outputPath, result, { spaces: 2 }) 52 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Enable the ESlint flat config support 3 | // (remove this if your ESLint extension above v3.0.5) 4 | "eslint.experimental.useFlatConfig": true, 5 | 6 | // Disable the default formatter, use eslint instead 7 | "prettier.enable": true, 8 | "editor.formatOnSave": false, 9 | 10 | // Auto fix 11 | "editor.codeActionsOnSave": { 12 | "source.fixAll.eslint": "explicit", 13 | "source.organizeImports": "never" 14 | }, 15 | 16 | // Silent the stylistic rules in you IDE, but still auto fix them 17 | "eslint.rules.customizations": [ 18 | { "rule": "style/*", "severity": "off" }, 19 | { "rule": "format/*", "severity": "off" }, 20 | { "rule": "*-indent", "severity": "off" }, 21 | { "rule": "*-spacing", "severity": "off" }, 22 | { "rule": "*-spaces", "severity": "off" }, 23 | { "rule": "*-order", "severity": "off" }, 24 | { "rule": "*-dangle", "severity": "off" }, 25 | { "rule": "*-newline", "severity": "off" }, 26 | { "rule": "*quotes", "severity": "off" }, 27 | { "rule": "*semi", "severity": "off" } 28 | ], 29 | 30 | // Enable eslint for all supported languages 31 | "eslint.validate": [ 32 | "javascript", 33 | "javascriptreact", 34 | "typescript", 35 | "typescriptreact", 36 | "vue", 37 | "html", 38 | "markdown", 39 | "json", 40 | "jsonc", 41 | "yaml", 42 | "toml", 43 | "xml", 44 | "gql", 45 | "graphql", 46 | "astro" 47 | ], 48 | "i18n-ally.localesPaths": [ 49 | "src/locales" 50 | ], 51 | "i18n-ally.keystyle": "nested" 52 | } 53 | -------------------------------------------------------------------------------- /src/utils/getInitialLocale.ts: -------------------------------------------------------------------------------- 1 | import { useUrlSearchParams } from '@vueuse/core' 2 | import en from '../locales/en.json' 3 | import ja from '../locales/ja.json' 4 | import zhCn from '../locales/zhCn.json' 5 | import zhTw from '../locales/zhTw.json' 6 | import type { Lang } from '@/types/lang' 7 | 8 | const SUPPORTED_LOCALES = ['zhCn', 'en', 'ja', 'zhTw'] as Lang[] 9 | 10 | const messages = { 11 | en, 12 | ja, 13 | zhCn, 14 | zhTw, 15 | } 16 | 17 | function getBrowserLocale(): Lang { 18 | const browserLangs = navigator.languages || [navigator.language] 19 | 20 | for (const browserLang of browserLangs) { 21 | const lang = browserLang.toLowerCase() 22 | 23 | switch (lang) { 24 | case 'zh-cn': 25 | case 'zh': 26 | if (SUPPORTED_LOCALES.includes('zhCn')) return 'zhCn' as Lang 27 | break 28 | case 'zh-tw': 29 | case 'zh-hk': 30 | if (SUPPORTED_LOCALES.includes('zhTw')) return 'zhTw' as Lang 31 | break 32 | case 'ja': 33 | case 'ja-jp': 34 | if (SUPPORTED_LOCALES.includes('ja')) return 'ja' as Lang 35 | break 36 | case 'en': 37 | case 'en-us': 38 | if (SUPPORTED_LOCALES.includes('en')) return 'en' as Lang 39 | break 40 | } 41 | } 42 | 43 | return 'zhCn' as Lang 44 | } 45 | 46 | function getInitialLocale(): Lang { 47 | const params = useUrlSearchParams('hash') 48 | const urlLang = params.lang?.toString() 49 | 50 | if (urlLang && SUPPORTED_LOCALES.includes(urlLang as Lang)) { 51 | return urlLang as Lang 52 | } 53 | 54 | return getBrowserLocale() ?? 'en' as Lang 55 | } 56 | 57 | export { getInitialLocale, messages } 58 | -------------------------------------------------------------------------------- /patches/0001-postNamazu.patch: -------------------------------------------------------------------------------- 1 | From a0cb4b0989127c1ee820c9a7c1db35253c0486a1 Mon Sep 17 00:00:00 2001 2 | From: Souma-Sumire <553469159@qq.com> 3 | Date: Sat, 23 Aug 2025 23:47:51 +0800 4 | Subject: [PATCH] PostNamazu 5 | 6 | --- 7 | cactbot/types/event.d.ts | 26 ++++++++++++++++++++++++++ 8 | 1 file changed, 26 insertions(+) 9 | 10 | diff --git a/cactbot/types/event.d.ts b/cactbot/types/event.d.ts 11 | index d99c42d18..34dc54879 100644 12 | --- a/cactbot/types/event.d.ts 13 | +++ b/cactbot/types/event.d.ts 14 | @@ -524,6 +524,30 @@ type CactbotChooseDirectoryHandler = (msg: { 15 | call: 'cactbotChooseDirectory'; 16 | }) => { data: string } | undefined; 17 | 18 | +export type PostNamazuCall = 19 | + | 'DoTextCommand' 20 | + | 'command' 21 | + | 'DoWaymarks' 22 | + | 'place' 23 | + | 'mark' 24 | + | 'DoInsertPreset' 25 | + | 'preset' 26 | + | 'DoQueueActions'; 27 | + 28 | +type PostNamazuHandler = (msg: { 29 | + call: 'PostNamazu'; 30 | + c: PostNamazuCall; 31 | + p: string; 32 | + d?: number; 33 | +}) => Promise; 34 | + 35 | +type GetLanguageHandler = (msg: { call: 'getLanguage' }) => { 36 | + language: string; 37 | + languageId: string; 38 | + region: string; 39 | + regionId: string; 40 | +}; 41 | + 42 | export type OverlayHandlerAll = { 43 | 'broadcast': BroadcastHandler; 44 | 'subscribe': SubscribeHandler; 45 | @@ -537,6 +561,8 @@ export type OverlayHandlerAll = { 46 | 'cactbotSaveData': CactbotSaveDataHandler; 47 | 'cactbotLoadData': CactbotLoadDataHandler; 48 | 'cactbotChooseDirectory': CactbotChooseDirectoryHandler; 49 | + 'postNamazu': PostNamazuHandler; 50 | + 'getLanguage': GetLanguageHandler; 51 | }; 52 | 53 | export type OverlayHandlerTypes = keyof OverlayHandlerAll; 54 | -- 55 | 2.45.1.windows.1 56 | 57 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHashHistory } from 'vue-router' 2 | import routes from '~pages' 3 | 4 | const router = createRouter({ 5 | history: createWebHashHistory(), 6 | routes: [ 7 | ...routes, 8 | { 9 | path: '/:pathMatch(.*)*', 10 | name: 'NotFound', 11 | component: () => import('@/pages/404.vue'), 12 | }, 13 | ], 14 | }) 15 | 16 | const routeTitles = new Map( 17 | Object.entries({ 18 | aether: '简易风脉地图', 19 | blubook: '青魔法书图鉴', 20 | cactbotRuntime: '职能分配', 21 | castingMonitor: '施法监控', 22 | castingToChinese: '读条汉化', 23 | dsrp6: '绝龙诗 P6 修血简易记录', 24 | enmity: '开盾提醒', 25 | fflogsUploaderDownload: '今天 FFLogsUploader 更新了吗?', 26 | ffxivAxisWebFont: 'FFXIV Axis 字体', 27 | food: '食物警察', 28 | hexcode: 'hexcode 简易工具', 29 | hunt: '有车吗?', 30 | instancedAreaInfo: '我 TM 现在在几线?', 31 | keigennRecord2: '减伤监控 2', 32 | obs2: 'OBS 自动录制 2', 33 | radar: '雷达', 34 | showBarrier: '盾值显示', 35 | startPages: '主页导航', 36 | test: '自助测试', 37 | time: '显示时间', 38 | timeline: '自定义时间轴', 39 | timelineHelp: '自定义时间轴帮助', 40 | timelineSettings: '自定义时间轴编辑', 41 | zoneMacro: '副本宏', 42 | }) 43 | ) 44 | for (const route of router.getRoutes()) { 45 | route.meta.title = routeTitles.get(route.name?.toString() ?? '') ?? route.name 46 | } 47 | 48 | router.afterEach((to, from) => { 49 | document.title = to.meta.title?.toString() ?? '' 50 | if ( 51 | (from.name === 'startPages' && to.name !== 'startPages') || 52 | (to.name === 'startPages' && from.name && from.name !== 'startPages') 53 | ) { 54 | window.location.reload() 55 | } 56 | // 子路由改名 57 | if (to.fullPath === '/pt') { 58 | router.push({ replace: true, path: 'dd' }) 59 | } 60 | }) 61 | 62 | export default router 63 | -------------------------------------------------------------------------------- /scripts/chinese2Icon.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import csv from 'csv-parser' 3 | import iconv from 'iconv-lite' 4 | import { csvPaths } from './paths' 5 | 6 | type IdToMap = Record 7 | type IdToName = IdToMap 8 | type IdToClassJobLevel = IdToMap 9 | type IdToIcon = IdToMap 10 | 11 | function readCsv( 12 | filePath: string, 13 | handler: (row: string[]) => void, 14 | ): Promise { 15 | return new Promise((resolve, reject) => { 16 | fs.createReadStream(filePath) 17 | .pipe(iconv.decodeStream('utf8')) 18 | .pipe(csv({ headers: false })) 19 | .on('data', (row: string[]) => { 20 | if (!['key', '#', 'offset', 'int32', '0'].includes(row[0]!)) { 21 | handler(row) 22 | } 23 | }) 24 | .on('end', () => resolve()) 25 | .on('error', (error) => reject(error)) 26 | }) 27 | } 28 | 29 | const id2Name: IdToName = {} 30 | const id2ClassJobLevel: IdToClassJobLevel = {} 31 | const id2Icon: IdToIcon = {} 32 | 33 | await Promise.all([ 34 | readCsv(`${csvPaths.souma}Action.csv`, (row) => { 35 | if (row[1] !== '') { 36 | id2Name[row[0]!] = row[1]! 37 | } 38 | }), 39 | readCsv(`${csvPaths.ja}Action.csv`, (row) => { 40 | const level = Number(row[13]) 41 | if (level > 0 && row[3] !== '405') { 42 | id2ClassJobLevel[row[0]!] = row[13]! 43 | id2Icon[row[0]!] = row[3]! 44 | } 45 | }), 46 | ]) 47 | const chineseToIcon = Object.fromEntries( 48 | Object.entries(id2Name) 49 | .filter(([id]) => id2ClassJobLevel[id]) 50 | .map(([id, name]) => [name, id2Icon[id]]), 51 | ) 52 | 53 | fs.outputJsonSync('src/resources/chinese2Icon.json', chineseToIcon, { 54 | spaces: 2, 55 | }) 56 | fs.outputJsonSync('src/resources/action2ClassJobLevel.json', id2ClassJobLevel, { 57 | spaces: 2, 58 | }) 59 | -------------------------------------------------------------------------------- /eslint.config.ts: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import tseslint from 'typescript-eslint' 4 | import pluginVue from 'eslint-plugin-vue' 5 | import { defineConfig } from 'eslint/config' 6 | 7 | import fs from 'fs' 8 | import path from 'path' 9 | import { fileURLToPath } from 'url' 10 | 11 | const __filename = fileURLToPath(import.meta.url) 12 | const __dirname = path.dirname(__filename) 13 | 14 | const autoImportConfigPath = path.resolve( 15 | __dirname, 16 | '.eslintrc-auto-import.json' 17 | ) 18 | const autoImportGlobals: Record = JSON.parse( 19 | fs.readFileSync(autoImportConfigPath, 'utf-8') 20 | ).globals 21 | 22 | export default defineConfig([ 23 | { 24 | ignores: ['dist/', 'node_modules/', 'cactbot/'], 25 | }, 26 | { 27 | files: ['**/*.{js,mjs,cjs,ts,mts,cts,vue}'], 28 | plugins: { js }, 29 | extends: ['js/recommended'], 30 | languageOptions: { globals: { ...globals.browser, ...autoImportGlobals } }, 31 | rules: { 32 | '@typescript-eslint/no-unused-vars': [ 33 | 'error', 34 | { 35 | args: 'all', 36 | argsIgnorePattern: '^_', 37 | caughtErrors: 'all', 38 | caughtErrorsIgnorePattern: '^_', 39 | destructuredArrayIgnorePattern: '^_', 40 | varsIgnorePattern: '^_', 41 | ignoreRestSiblings: true, 42 | }, 43 | ], 44 | }, 45 | }, 46 | tseslint.configs.recommended, 47 | pluginVue.configs['flat/essential'], 48 | { 49 | files: ['**/*.vue'], 50 | languageOptions: { parserOptions: { parser: tseslint.parser } }, 51 | rules: { 52 | 'vue/multi-word-component-names': 'off', 53 | }, 54 | }, 55 | { 56 | files: ['**/*.{ts,vue}'], 57 | rules: { 58 | '@typescript-eslint/no-explicit-any': 'off', 59 | }, 60 | }, 61 | ]) 62 | -------------------------------------------------------------------------------- /src/components/common/LanguageSwitcher.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 54 | 55 | 72 | -------------------------------------------------------------------------------- /src/components/common/ThemeToggle.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 27 | 28 | 74 | -------------------------------------------------------------------------------- /scripts/contentFinderCondition.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import csv from 'csv-parser' 3 | import iconv from 'iconv-lite' 4 | import { csvPaths } from './paths' 5 | 6 | interface FileValues { 7 | [fileName: string]: string[][] 8 | } 9 | 10 | interface ContentFinderConditionResult { 11 | [id: string]: string 12 | } 13 | 14 | const fileValues: FileValues = {} 15 | 16 | function readFile(fileName: string, filePath: string): Promise { 17 | return new Promise((resolve, reject) => { 18 | fs.createReadStream(filePath) 19 | .pipe(iconv.decodeStream('utf8')) 20 | .pipe(csv({ headers: false })) 21 | .on('data', (row: string[]) => { 22 | fileValues[fileName] = fileValues[fileName] || [] 23 | fileValues[fileName].push(row) 24 | }) 25 | .on('end', () => { 26 | resolve() 27 | }) 28 | .on('error', (error) => { 29 | reject(error) 30 | }) 31 | }) 32 | } 33 | 34 | await Promise.all([ 35 | readFile( 36 | 'contentFinderCondition.csv', 37 | `${csvPaths.ja}contentFinderCondition.csv`, 38 | ), 39 | readFile( 40 | 'contentFinderCondition_cn.csv', 41 | `${csvPaths.cn}contentFinderCondition.csv`, 42 | ), 43 | ]) 44 | 45 | const result: ContentFinderConditionResult = {} 46 | const jaData = fileValues['contentFinderCondition.csv']! 47 | const cnData = fileValues['contentFinderCondition_cn.csv']! 48 | 49 | jaData.forEach((row) => { 50 | // 跳过不必要的行 51 | if (['key', '#', 'offset', 'int32'].includes(row[0]!)) { 52 | return 53 | } 54 | 55 | const cnRow = cnData!.find((r) => r[0] === row[0])! 56 | const cn = cnRow?.[2] 57 | const ja = row[2] 58 | 59 | // 使用空值合并运算符(??)来简化逻辑 60 | const key = (ja && ja !== '0' ? ja : cn) ?? ja 61 | 62 | if (key) { 63 | result[row[0]!] = key 64 | } 65 | }) 66 | 67 | const outputPath = 'src/resources/contentFinderCondition.json' 68 | fs.outputJsonSync(outputPath, result, { spaces: 2 }) 69 | -------------------------------------------------------------------------------- /src/pages/mpTick.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 48 | 49 | 83 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createHead } from '@vueuse/head' 2 | import { ElMessage } from 'element-plus' 3 | import { createPinia } from 'pinia' 4 | import { createApp } from 'vue' 5 | import { createI18n } from 'vue-i18n' 6 | import VueLazyload from 'vue-lazyload' 7 | import App from './App.vue' 8 | import router from './router' 9 | import 'element-plus/dist/index.css' 10 | import 'element-plus/theme-chalk/dark/css-vars.css' 11 | import 'virtual:uno.css' 12 | import { checkReferrer } from './utils/checkReferrer' 13 | import { getInitialLocale, messages } from './utils/getInitialLocale' 14 | import type { Lang } from './types/lang' 15 | 16 | const initialLocale: Lang = getInitialLocale() 17 | 18 | const app = createApp(App) 19 | const head = createHead() 20 | const pinia = createPinia() 21 | 22 | const i18n = createI18n({ 23 | legacy: false, 24 | locale: initialLocale, 25 | fallbackLocale: 'zhCn', 26 | messages: messages, 27 | warnHtmlMessage: false, 28 | }) 29 | 30 | function handleError(error: Error): void { 31 | console.error(error) 32 | ElMessage.error({ 33 | dangerouslyUseHTMLString: true, 34 | message: `
${
35 |       error.stack || error.message
36 |     }
`, 37 | duration: 5000, 38 | showClose: true, 39 | }) 40 | } 41 | 42 | // 全局错误处理 43 | app.config.errorHandler = (err: unknown) => { 44 | handleError(err instanceof Error ? err : new Error(String(err))) 45 | } 46 | 47 | // 未捕获的Promise错误处理 48 | window.addEventListener( 49 | 'unhandledrejection', 50 | (event: PromiseRejectionEvent) => { 51 | handleError( 52 | event.reason instanceof Error 53 | ? event.reason 54 | : new Error(String(event.reason)) 55 | ) 56 | } 57 | ) 58 | 59 | app.use(router) 60 | app.use(head) 61 | app.use(pinia) 62 | app.use(VueLazyload) 63 | app.use(i18n) 64 | 65 | checkReferrer() 66 | app.mount('#app') 67 | 68 | const { protocol, hostname, href } = window.location 69 | const isLocal = ['localhost', '127.0.0.1', '::1'].includes(hostname) 70 | 71 | if (protocol === 'http:' && !isLocal) { 72 | window.location.href = href.replace('http:', 'https:') 73 | } 74 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 18 | 19 | 20 |
21 | 22 | 27 |

🔓 请调整至合适大小,随后在ACT勾选"锁定悬浮窗"以开始使用。

28 | 35 | 36 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /src/utils/keigenn.ts: -------------------------------------------------------------------------------- 1 | import type { DamageType } from './flags' 2 | import type { Keigenn, Status } from '@/types/keigennRecord2' 3 | import { keigenns } from '../resources/keigenn' 4 | import { completeIcon, statusData } from '../resources/status' 5 | 6 | const keigennMap: Map = new Map() 7 | 8 | for (const keigenn of keigenns) { 9 | const icon = statusData[keigenn.id]![1] 10 | keigenn.fullIcon = completeIcon(icon) 11 | keigennMap.set( 12 | keigenn.id.toString(16).toUpperCase().padStart(2, '0'), 13 | keigenn as Keigenn 14 | ) 15 | } 16 | 17 | export function getKeigenn(decId: string): Keigenn | undefined { 18 | return keigennMap.get(decId) 19 | } 20 | 21 | export function multiplierEffect( 22 | status: Status, 23 | damageType: DamageType 24 | ): 'useful' | 'unuseful' | 'half-useful' { 25 | if (status.type === 'absorbed') { 26 | // 护盾类技能永远有效 27 | return 'useful' 28 | } else if (damageType === 'dot' || damageType === 'darkness') { 29 | // DOT、真实伤害永远无效 30 | return 'unuseful' 31 | } else if ( 32 | // 真无敌 33 | (status.performance.darkness === 0 && 34 | status.performance.magic === 0 && 35 | status.performance.physics === 0) || 36 | // 死斗、行尸走肉 37 | (status.performance.darkness === 1 && 38 | status.performance.magic === 1 && 39 | status.performance.physics === 1) 40 | ) { 41 | return 'useful' 42 | } 43 | 44 | const other = damageType === 'physics' ? 'magic' : 'physics' 45 | // 半效减伤 46 | const a = (100 - status.performance[damageType] * 100) * 2 47 | const b = 100 - status.performance[other] * 100 48 | if (a === b) return 'half-useful' 49 | 50 | // 1=没减伤,0=完全100%减伤 51 | return status.performance[damageType] === 1 ? 'unuseful' : 'useful' 52 | } 53 | 54 | const regFriendly = 55 | /(?:耐性|防御力)(?:大幅)?(?:降低|提升|低下|下降)|受伤(?:加重|减轻)|体力(?:增加|衰减|减少)|伤害屏障/ 56 | const regEnemy = /(?:精神|力量|灵巧|智力){1,2}(?:大幅)?降低/ 57 | 58 | function createMap(regExp: RegExp, isFriendly: boolean) { 59 | return Object.entries(statusData).reduce((map, [key, [name, icon]]) => { 60 | if (regExp.test(name)) map.set(key, { name, icon, isFriendly }) 61 | 62 | return map 63 | }, new Map()) 64 | } 65 | 66 | export const universalVulnerableFriendly = createMap(regFriendly, true) 67 | export const universalVulnerableEnemy = createMap(regEnemy, false) 68 | -------------------------------------------------------------------------------- /src/utils/mapCoordinates.ts: -------------------------------------------------------------------------------- 1 | class Vector2 { 2 | x: number 3 | y: number 4 | 5 | constructor(x = 0, y = 0) { 6 | this.x = x 7 | this.y = y 8 | } 9 | 10 | add(vector: Vector2): Vector2 { 11 | return new Vector2(this.x + vector.x, this.y + vector.y) 12 | } 13 | 14 | subtract(vector: Vector2): Vector2 { 15 | return new Vector2(this.x - vector.x, this.y - vector.y) 16 | } 17 | 18 | divide(scalar: number): Vector2 { 19 | return new Vector2(this.x / scalar, this.y / scalar) 20 | } 21 | 22 | multiply(scalar: number): Vector2 { 23 | return new Vector2(this.x * scalar, this.y * scalar) 24 | } 25 | 26 | static get One(): Vector2 { 27 | return new Vector2(1, 1) 28 | } 29 | } 30 | 31 | // 将世界坐标转换为 2D 地图纹理坐标(以像素为单位) 32 | function getPixelCoordinates( 33 | worldXZCoordinates: Vector2, 34 | mapOffset: Vector2, 35 | mapSizeFactor: number, 36 | ): Vector2 { 37 | const offsetVector = worldXZCoordinates.add(mapOffset) 38 | const scaledVector = offsetVector.divide(100).multiply(mapSizeFactor) 39 | const finalVector = scaledVector.add(new Vector2(1024, 1024)) 40 | return finalVector 41 | } 42 | 43 | // 将 2D 地图纹理坐标(以像素为单位)转换为世界坐标 44 | function getWorldCoordinates( 45 | pixelCoordinates: Vector2, 46 | mapOffset: Vector2, 47 | mapSizeFactor: number, 48 | ): Vector2 { 49 | const adjustedVector = pixelCoordinates.subtract(new Vector2(1024, 1024)) 50 | const scaledVector = adjustedVector.divide(mapSizeFactor) 51 | const finalVector = scaledVector.multiply(100).subtract(mapOffset) 52 | return finalVector 53 | } 54 | 55 | // 将地图纹理像素坐标转换为游戏内 2D 地图坐标 56 | function getGameMapCoordinates( 57 | mapPixelCoordinates: Vector2, 58 | mapSizeFactor: number, 59 | ): Vector2 { 60 | return mapPixelCoordinates.divide(mapSizeFactor).multiply(2).add(Vector2.One) 61 | } 62 | 63 | // 将世界坐标转换为游戏内 2D 地图坐标 64 | function worldToMapCoordinates( 65 | worldXZCoordinates: Vector2, 66 | mapOffset: Vector2, 67 | mapSizeFactor: number, 68 | ): Vector2 { 69 | const pixelCoordinates = getPixelCoordinates( 70 | worldXZCoordinates, 71 | mapOffset, 72 | mapSizeFactor, 73 | ) 74 | return getGameMapCoordinates(pixelCoordinates, mapSizeFactor) 75 | } 76 | export { 77 | getGameMapCoordinates, 78 | getPixelCoordinates, 79 | getWorldCoordinates, 80 | Vector2, 81 | worldToMapCoordinates, 82 | } 83 | -------------------------------------------------------------------------------- /src/utils/compareSaveAction.ts: -------------------------------------------------------------------------------- 1 | const compareSameGroup = { 2 | 16483: 0, // 根本用不出来的燕回返→0 3 | 16484: 16486, // 回返彼岸花→回返纷乱雪月花 4 | 16485: 16486, // 回返天下五剑→回返纷乱雪月花 5 | 25779: 16487, // 无名照破→照破 6 | 7496: 16481, // 必杀剑·红莲→必杀剑·闪影 7 | 2874: 0, // 虹吸弹 8 | 2890: 0, // 弹射 9 | 2872: 16500, // 热弹→空气锚 10 | 16003: 16192, /// 0色标准舞步结束→双色标准舞步结束 11 | 16191: 16192, // 单色标准舞步→双色标准舞步结束 12 | 16004: 16196, // 0色技巧舞步结束→四色技巧舞步结束 13 | 16193: 16196, // 单色技巧舞步结束→四色技巧舞步结束 14 | 16194: 16196, // 双色技巧舞步结束→四色技巧舞步结束 15 | 16195: 16196, // 三色技巧舞步结束→四色技巧舞步结束 16 | 3551: 25751, // 原初的直觉→原初的血气 17 | 16464: 25751, // 原初的勇猛→原初的血气 18 | 25752: 7387, // 造山运动→动乱 19 | 16510: 16508, // 能量抽取→能量吸收 20 | 3581: 16549, // 龙神附体→不死鸟附体 21 | 16494: 3562, // 影噬箭→侧风诱导箭 22 | 16499: 16498, // 毒菌冲击→钻头 23 | 16527: 7515, // 交剑→移转 24 | 38: 7389, // 狂暴→原初的解放 25 | 7415: 16502, // 超档车式炮塔→超档后式人偶 26 | 92: 16478, // 跳跃→高跳 27 | 23287: 23290, // 如意大旋风→月下彼岸花 28 | 18324: 18325, // 类星体→正义飞踢 29 | 23272: 23285, // 天使的点心→马特拉魔术 30 | 23280: 23285, // 龙之力→马特拉魔术 31 | 23273: 23275, // 玄结界→斗灵弹 32 | 11426: 11427, // 飞翎雨→地火喷发 33 | 11428: 11429, // 山崩→轰雷 34 | 52: 0, // 战壕→0 35 | 48: 0, // 守护→0 36 | 3629: 0, // 深恶痛绝→0 37 | 3580: 0, // 三重灾祸→0 38 | 7429: 0, // 龙神迸发→0 39 | 7449: 0, // 死亡轮回→0 40 | 16516: 0, // 不死鸟迸发→0 41 | 3631: 0, // 血债→0 42 | 16142: 0, // 王室亲卫→0 43 | 28: 0, // 钢铁信念→0 44 | 2259: 0, // 天之印→0 45 | 2261: 0, // 地之印→0 46 | 2262: 0, // 缩地→0 47 | 2263: 0, // 人之印→0 48 | 3582: 0, // 死星核爆→0 49 | 110: 0, // 失血箭→0 50 | 117: 0, // 死亡箭雨→0 51 | 16161: 25758, // 石之心→刚玉之心 52 | 16144: 16165, // 危险领域→爆破领域 53 | 3641: 3643, // 吸血深渊→精雕怒斩 54 | 25755: 0, // 暗魂(吸血深渊的二段) 55 | 16546: 0, // 慰籍 56 | 16548: 0, // 炽天的幕帘 57 | 802: 0, // 仙光的拥抱 58 | 8324: 0, // 星体爆轰 59 | 24307: 24313, // 炎稠液质 60 | 2246: 0, // 断绝,梦幻三段的派生技能 61 | 24402: 24401, // 镰刀位移 62 | 24403: 0, // 镰刀的位移派生技 63 | 17206: 0, // 滚轮冲 64 | 24288: 24302, // 自愈→自然愈合 65 | 24289: 24313, // 白黏液→黑黏液 66 | 17: 36920, // 预警→极限防御 67 | 44: 36923, // 复仇→天谴 68 | 3636: 36927, // 暗影墙→暗影守护 69 | 16148: 36935, // 星云→大星云 70 | 2248: 36957, // 夺取→毒盛之术 71 | } as const 72 | 73 | const compareSameMap = new Map( 74 | Object.entries(compareSameGroup).map(([k, v]) => [Number(k), v]), 75 | ) 76 | 77 | function compareSame(id: number) { 78 | return compareSameMap.get(id) || id 79 | } 80 | 81 | export { compareSame } 82 | -------------------------------------------------------------------------------- /src/store/keigennRecord2.ts: -------------------------------------------------------------------------------- 1 | import { useUrlSearchParams } from '@vueuse/core' 2 | import { defineStore } from 'pinia' 3 | import { computed } from 'vue' 4 | 5 | const params = useUrlSearchParams('hash') 6 | 7 | export const useKeigennRecord2Store = defineStore('keigennRecord2', { 8 | state: () => { 9 | return { 10 | userOptions: { 11 | scale: computed(() => parseParams(params.scale as string, 1)), // 缩放倍率 12 | opacity: computed(() => parseParams(params.opacity as string, 0.9)), // 透明度 13 | targetType: computed(() => 14 | parseParams(params.targetType as 'icon' | 'job', 'icon') 15 | ), // 显示目标图标 16 | iconType: computed(() => parseParams(params.iconType as string, 2)), // 目标图标类型 17 | parseAA: computed(() => parseParams(params.parseAA as string, true)), // 解析自动攻击(旧结果不会跟随改变) 18 | parseDoT: computed(() => parseParams(params.parseDoT as string, false)), // 解析DoT(旧结果不会跟随改变) 19 | minimize: computed(() => parseParams(params.minimize as string, false)), // 启动时迷你化 20 | actionCN: computed(() => parseParams(params.actionCN as string, true)), // action显示中文化 21 | statusCN: computed(() => parseParams(params.statusCN as string, true)), // status显示中文化 22 | }, 23 | isBrowser: false, 24 | } 25 | }, 26 | getters: { 27 | icon4k(state) { 28 | return state.userOptions.scale >= 2 || window.devicePixelRatio >= 2 29 | ? '_hr1' 30 | : '' 31 | }, 32 | }, 33 | actions: { 34 | checkIsBrowser() { 35 | this.isBrowser = 36 | !window.OverlayPluginApi && !params.OVERLAY_WS && !params.HOST_PORT 37 | if (this.isBrowser) setTimeout(() => this.checkIsBrowser(), 1000) 38 | }, 39 | formatterName(v: string) { 40 | const global = /^([A-Z])\S+ ([A-Z])\S+/ 41 | if (global.test(v)) { 42 | return v.replace(global, '$1.$2') 43 | } 44 | return `${v.at(0)}.${v.at(-1)}` 45 | }, 46 | }, 47 | }) 48 | 49 | function parseParams(v: string, def: T): T { 50 | if (typeof def === 'boolean') { 51 | if (v === '0' || v?.toLocaleLowerCase() === 'false') return false as T 52 | if (v === '1' || v?.toLocaleLowerCase() === 'true') return true as T 53 | return def 54 | } 55 | if (typeof def === 'number') return Number.isNaN(+v) ? def : (+v as T) 56 | 57 | if (typeof def === 'string') { 58 | return v as T 59 | } 60 | 61 | return def 62 | } 63 | -------------------------------------------------------------------------------- /src/common/markdown/timeline.md: -------------------------------------------------------------------------------- 1 | # 时间轴语法指南 2 | 3 | ## 显式语句(实际悬浮窗可见内容) 4 | 5 | ```text 6 | 时间 "提示文本" [tts "语音文本"] 7 | ``` 8 | 9 | ### 参数说明 10 | 11 | | 参数 | 格式 | 说明 | 12 | |------------|-----------------------|----------------------------------------------------------------------| 13 | | **时间** | `整数`/`浮点数`/`时:分` | 例:`120`、`84.38`、`01:35` | 14 | | **提示文本** | 双引号包裹 | 特殊语法:`<技能名>`显示图标,`<技能名>~`显示为`[图标]技能名`
常用技能名支持简写,详见规则:[chineseToIcon.ts](https://github.com/Souma-Sumire/ff14-overlay-vue/blob/main/src/utils/chineseToIcon.ts) | 15 | | **语音文本**| `tts "内容"`(可选) | 在时间点前1秒触发TTS语音。简写语法:`tts`(不写内容),内容为提示文本的尖括号内容 | 16 | 17 | ### 示例 18 | 19 | ```text 20 | 60 "机制来了" # 显示文字 21 | 120 "<天赐祝福>~" # 显示技能图标+文字 22 | 150 "高频平A" tts "注意奶T" # 显示文字+语音提醒 23 | 200 "<至黑之夜><献奉><暗影卫>开减伤" tts # 显示多图标 + tts简写,实际播报:“至黑之夜, 献奉, 暗影卫” 24 | ``` 25 | 26 | --- 27 | 28 | ## 匹配语句(用于同步时间,平时看不到) 29 | 30 | 通常无需手动编写。FFLOGS导入会自动生成,完整规则见 [timelineSpecialRules.ts](https://github.com/Souma-Sumire/ff14-overlay-vue/blob/main/src/services/timelineSpecialRules.ts)。 31 | 32 | 若某些副本未自动适配或未正确同步,则需要用户(你)手动编写。 33 | 34 | ### 语法 35 | 36 | ```text 37 | 时间 "注释" 日志类型 { id: "十六进制ID" } [window 前,后] [jump 目标时间] [once] 38 | ``` 39 | 40 | #### 日志类型 41 | 42 | | 类型 | 参数格式 | 说明 | 43 | |---------------|----------------------------|-----------------------------| 44 | | `Ability` | `{ id: "十六进制ID" }` | 技能判定事件 | 45 | | `StartsUsing` | `{ id: "十六进制ID" }` | 读条开始事件 | 46 | 47 | #### 可选参数 48 | 49 | | 参数 | 格式 | 默认值 | 说明 | 50 | |--------------|-------------------|----------|-----------------------------------| 51 | | `window` | `向前秒数,向后秒数` | `2.5,2.5` | 匹配时间范围:`[时间-前, 时间+后]` | 52 | | `jump` | 秒数 | - | 匹配成功后跳转到指定时间点 | 53 | | `once` | | - | 整场战斗中,此正则仅匹配一次 | 54 | 55 | ### 示例 56 | 57 | ```text 58 | 00:10.0 "空间斩" StartsUsing { id: "A3DA" } window 2,4 # BOSS读条A3DA技能且时间处于8~14秒,修正时间至第10秒 59 | 736.5 "Beetle Avatar" Ability { id: "E82" } window 130,10 jump 413.9 # 匹配成功后跳转到413.9秒,而不是736.5秒 60 | 00:21.1 "unknown_a38f" Ability { id: "A38F" } window 9999,9999 once # 全场战斗仅触发一次 61 | ``` 62 | 63 | --- 64 | -------------------------------------------------------------------------------- /src/resources/zoneInfo.ts: -------------------------------------------------------------------------------- 1 | import type { LocaleText } from '../../cactbot/types/trigger' 2 | import data from '../../cactbot/resources/zone_info' 3 | 4 | type ZoneInfoType = { 5 | [zoneId: number]: { 6 | readonly exVersion: number 7 | readonly contentType?: number 8 | readonly name: LocaleText 9 | readonly offsetX: number 10 | readonly offsetY: number 11 | readonly sizeFactor: number 12 | readonly weatherRate: number 13 | } 14 | } 15 | 16 | const ZoneInfo: ZoneInfoType = Object.assign( 17 | { 18 | 967: { 19 | exVersion: 3, 20 | name: { 21 | cn: '帝国海上基地干船坞', 22 | de: 'Trockendock von Castrum Marinum', 23 | en: 'Castrum Marinum Drydocks', 24 | fr: 'Cale sèche de Castrum Marinum', 25 | ja: 'カステッルム・マリヌム・ドライドック', 26 | ko: '카스트룸 마리눔 건선거', 27 | }, 28 | offsetX: -100, 29 | offsetY: -100, 30 | sizeFactor: 400, 31 | weatherRate: 0, 32 | }, 33 | 1032: { 34 | contentType: 6, 35 | exVersion: 0, 36 | name: { 37 | cn: '水晶冲突(角力学校)', 38 | de: 'Crystalline Conflict: Die Palästra', 39 | en: 'Crystalline Conflict (The Palaistra)', 40 | fr: 'Crystalline Conflict (Le Palestre)', 41 | ja: 'クリスタルコンフリクト(パライストラ)', 42 | ko: '크리스탈라인 컨플릭트(팔라이스트라)', 43 | }, 44 | offsetX: 0, 45 | offsetY: 0, 46 | sizeFactor: 400, 47 | weatherRate: 0, 48 | }, 49 | 1033: { 50 | contentType: 6, 51 | exVersion: 0, 52 | name: { 53 | cn: '水晶冲突(火山之心)', 54 | de: 'Crystalline Conflict: Das Herz des Vulkans', 55 | en: 'Crystalline Conflict (The Volcanic Heart)', 56 | fr: 'Crystalline Conflict (Le Cœur volcanique)', 57 | ja: 'クリスタルコンフリクト(ヴォルカニック・ハート)', 58 | ko: '크리스탈라인 컨플릭트(화산심장)', 59 | }, 60 | offsetX: 0, 61 | offsetY: 0, 62 | sizeFactor: 400, 63 | weatherRate: 0, 64 | }, 65 | 1034: { 66 | contentType: 6, 67 | exVersion: 0, 68 | name: { 69 | cn: '水晶冲突(九霄云上)', 70 | de: 'Crystalline Conflict: Wolke Sieben', 71 | en: 'Crystalline Conflict (Cloud Nine)', 72 | fr: 'Crystalline Conflict (Le Petit Nuage)', 73 | ja: 'クリスタルコンフリクト(クラウドナイン)', 74 | ko: '크리스탈라인 컨플릭트(절정의 구름)', 75 | }, 76 | offsetX: 0, 77 | offsetY: 0, 78 | sizeFactor: 400, 79 | weatherRate: 0, 80 | }, 81 | }, 82 | data, 83 | ) 84 | 85 | export { ZoneInfo, type ZoneInfoType } 86 | -------------------------------------------------------------------------------- /scripts/map.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import csv from 'csv-parser' 3 | import iconv from 'iconv-lite' 4 | import { csvPaths } from './paths.js' 5 | 6 | type FileValues = { 7 | [fileName: string]: string[][] 8 | } 9 | 10 | interface MapName { 11 | id?: string 12 | name: { 13 | cn?: string 14 | en?: string 15 | ja?: string 16 | souma?: string 17 | } 18 | } 19 | 20 | interface MapResult { 21 | [territoryTypeId: string]: MapName 22 | } 23 | 24 | function readFile( 25 | fileName: string, 26 | filePath: string, 27 | fileValues: FileValues 28 | ): Promise { 29 | return new Promise((resolve, reject) => { 30 | fs.createReadStream(filePath) 31 | .pipe(iconv.decodeStream('utf8')) 32 | .pipe(csv({ headers: false })) 33 | .on('data', (row: string[]) => { 34 | fileValues[fileName] = fileValues[fileName] || [] 35 | fileValues[fileName].push(row) 36 | }) 37 | .on('end', () => { 38 | resolve() 39 | }) 40 | .on('error', (error) => { 41 | reject(error) 42 | }) 43 | }) 44 | } 45 | 46 | const fileValues: FileValues = {} 47 | const fileNames = ['TerritoryType.csv', 'Map.csv'] 48 | 49 | const allFiles = [ 50 | ...fileNames.map((fileName) => ({ 51 | name: fileName, 52 | path: `${csvPaths.ja}${fileName}`, 53 | })), 54 | { name: 'PlaceName.csv', path: `${csvPaths.ja}PlaceName.csv` }, 55 | { name: 'PlaceName_en.csv', path: `${csvPaths.en}PlaceName.csv` }, 56 | { name: 'PlaceName_cn.csv', path: `${csvPaths.cn}PlaceName.csv` }, 57 | ] 58 | 59 | await Promise.all( 60 | allFiles.map((file) => readFile(file.name, file.path, fileValues)) 61 | ) 62 | 63 | const res: MapResult = {} 64 | const territoryTypes = fileValues['TerritoryType.csv']! 65 | const maps = fileValues['Map.csv']! 66 | 67 | territoryTypes.forEach((row) => { 68 | if (['offset', 'key'].includes(row[0]!)) { 69 | return 70 | } 71 | 72 | const mapId = row[7] 73 | const mapData = maps.find((v) => v[0] === mapId)! 74 | const id = mapData?.[7] 75 | 76 | if (!id) { 77 | return 78 | } 79 | 80 | const placeNameId = row[6] 81 | 82 | const getPlaceName = (fileName: string) => { 83 | const file = fileValues[fileName] 84 | const placeNameRow = file?.find((v) => v[0] === placeNameId) 85 | return placeNameRow?.[1] 86 | } 87 | 88 | const ja = getPlaceName('PlaceName.csv') 89 | const en = getPlaceName('PlaceName_en.csv') 90 | const cn = getPlaceName('PlaceName_cn.csv') 91 | 92 | res[row[0]!] = { id, name: { cn, en, ja } } 93 | }) 94 | 95 | const outputPath = 'src/resources/map.json' 96 | fs.outputJsonSync(outputPath, res, { spaces: 2 }) 97 | -------------------------------------------------------------------------------- /src/pages/time.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 59 | 60 | 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-project", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "packageManager": "pnpm@10.18.2+sha512.9fb969fa749b3ade6035e0f109f0b8a60b5d08a1a87fdf72e337da90dcc93336e2280ca4e44f2358a649b83c17959e9993e777c2080879f3801e6f0d999ad3dd", 6 | "scripts": { 7 | "dev": "vite", 8 | "tsc": "tsc --noEmit", 9 | "github-build": "vite build", 10 | "build": "npm run submodule && npm run tsx && vite build", 11 | "preview": "vite preview", 12 | "submodule": "git submodule update --remote", 13 | "lint": "eslint .", 14 | "lint:fix": "eslint . --fix", 15 | "tsx": "npx tsx scripts/all.ts" 16 | }, 17 | "dependencies": { 18 | "@element-plus/icons-vue": "^2.3.2", 19 | "@vueuse/core": "^14.0.0", 20 | "@vueuse/head": "^2.0.0", 21 | "element-plus": "^2.11.5", 22 | "github-markdown-css": "^5.8.1", 23 | "idb": "^8.0.3", 24 | "json5": "^2.2.3", 25 | "lz-string": "^1.5.0", 26 | "moment": "^2.30.1", 27 | "obs-websocket-js": "^5.0.6", 28 | "pinia": "^3.0.3", 29 | "vue": "^3.5.22", 30 | "vue-draggable-plus": "^0.6.0", 31 | "vue-i18n": "^11.1.12", 32 | "vue-lazyload": "^3.0.0", 33 | "vue-router": "^4.6.3" 34 | }, 35 | "devDependencies": { 36 | "@babel/core": "^7.28.4", 37 | "@eslint/js": "^9.38.0", 38 | "@johnsoncodehk/html2pug": "^1.0.0", 39 | "@jridgewell/sourcemap-codec": "^1.5.5", 40 | "@types/eslint": "^9.6.1", 41 | "@types/fs-extra": "^11.0.4", 42 | "@typescript-eslint/eslint-plugin": "^8.46.2", 43 | "@typescript-eslint/parser": "^8.46.2", 44 | "@unocss/eslint-plugin": "^66.5.4", 45 | "@vitejs/plugin-vue": "^6.0.1", 46 | "@volar/pug-language-service": "^1.0.24", 47 | "@volar/typescript-language-service": "^1.0.1", 48 | "@vue/compiler-sfc": "^3.5.22", 49 | "@vue/language-service": "^3.1.1", 50 | "csv-parser": "^3.2.0", 51 | "csv-writer": "^1.6.0", 52 | "eslint": "^9.38.0", 53 | "eslint-plugin-vue": "^10.5.1", 54 | "fs-extra": "^11.3.2", 55 | "globals": "^16.4.0", 56 | "iconv-lite": "^0.7.0", 57 | "jiti": "^2.6.1", 58 | "lint-staged": "^16.2.5", 59 | "opencc-js": "^1.0.5", 60 | "prettier-eslint": "^16.4.2", 61 | "sass": "^1.93.2", 62 | "sass-loader": "^16.0.5", 63 | "terser": "^5.44.0", 64 | "tsx": "^4.20.6", 65 | "typescript": "^5.9.3", 66 | "typescript-eslint": "^8.46.2", 67 | "unocss": "^66.5.4", 68 | "unplugin-auto-import": "^20.2.0", 69 | "unplugin-vue-components": "^30.0.0", 70 | "vite": "^7.1.11", 71 | "vite-plugin-compression": "^0.5.1", 72 | "vite-plugin-md": "^0.22.5", 73 | "vite-plugin-pages": "^0.33.1", 74 | "vite-plugin-sass-dts": "^1.3.34", 75 | "vite-plugin-style-import": "^2.0.0", 76 | "vue-eslint-parser": "^10.2.0" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/components/keigennRecord2/Amount.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 59 | 60 | 102 | -------------------------------------------------------------------------------- /src/mock/demoLimitBreak.ts: -------------------------------------------------------------------------------- 1 | import type { EventMap } from 'cactbot/types/event' 2 | 3 | function testLimitBreak(handleLogLine: EventMap['LogLine'], speed: number = 2) { 4 | const timers: number[] = [] 5 | 6 | function testLogLine(testLog: string[][]) { 7 | let timeoutAll = 0 8 | 9 | // 立即执行第一条 10 | handleLogLine({ line: testLog[0]!, type: 'LogLine', rawLine: '' }) 11 | 12 | for (let i = 1; i < testLog.length; i++) { 13 | const timeA = new Date(testLog[i]![1]!) 14 | const timeB = new Date(testLog[i - 1]![1]!) 15 | const diff = timeA.getTime() - timeB.getTime() 16 | timeoutAll += diff 17 | 18 | const timer = window.setTimeout(() => { 19 | handleLogLine({ line: testLog[i]!, type: 'LogLine', rawLine: '' }) 20 | }, timeoutAll / speed) 21 | 22 | timers.push(timer) 23 | } 24 | } 25 | 26 | testLogLine([ 27 | ['36', '2021-12-28T21:38:30.3580000+08:00', '0000'], 28 | ['36', '2021-12-28T21:38:33.3580000+08:00', '00DC'], 29 | ['36', '2021-12-28T21:38:34.8290000+08:00', '0208'], 30 | ['36', '2021-12-28T21:38:36.3460000+08:00', '02E4'], 31 | ['36', '2021-12-28T21:38:37.8630000+08:00', '0410'], 32 | ['36', '2021-12-28T21:38:39.3360000+08:00', '04EC'], 33 | ['36', '2021-12-28T21:38:42.3720000+08:00', '05C8'], 34 | ['36', '2021-12-28T21:38:45.3620000+08:00', '06A4'], 35 | ['36', '2021-12-28T21:38:47.8140000+08:00', '07D0'], 36 | ['36', '2021-12-28T21:38:47.8140000+08:00', '08FC'], 37 | ['36', '2021-12-28T21:38:47.8140000+08:00', '0A28'], 38 | ['36', '2021-12-28T21:38:47.8140000+08:00', '0B54'], 39 | ['36', '2021-12-28T21:38:47.8140000+08:00', '0C80'], 40 | ['36', '2021-12-28T21:38:48.3510000+08:00', '0D5C'], 41 | ['36', '2021-12-28T21:38:50.6220000+08:00', '0E88'], 42 | ['36', '2021-12-28T21:38:50.8900000+08:00', '0FB4'], 43 | ['36', '2021-12-28T21:38:51.0230000+08:00', '10E0'], 44 | ['36', '2021-12-28T21:38:51.1570000+08:00', '120C'], 45 | ['36', '2021-12-28T21:38:51.3360000+08:00', '12E8'], 46 | ['36', '2021-12-28T21:38:54.3690000+08:00', '13C4'], 47 | ['36', '2021-12-28T21:38:57.3570000+08:00', '14A0'], 48 | ['36', '2021-12-28T21:38:58.3360000+08:00', '15CC'], 49 | ['36', '2021-12-28T21:38:58.3360000+08:00', '16F8'], 50 | ['36', '2021-12-28T21:38:58.3360000+08:00', '1824'], 51 | ['36', '2021-12-28T21:38:58.3360000+08:00', '1950'], 52 | ['36', '2021-12-28T21:38:58.3360000+08:00', '1A7C'], 53 | ['36', '2021-12-28T21:39:00.3410000+08:00', '1B58'], 54 | ['36', '2021-12-28T21:39:03.3690000+08:00', '1C34'], 55 | ['36', '2021-12-28T21:39:04.2630000+08:00', '1D60'], 56 | ['36', '2021-12-28T21:39:06.3600000+08:00', '1E3C'], 57 | ]) 58 | return () => { 59 | timers.forEach(clearTimeout) 60 | timers.length = 0 61 | } 62 | } 63 | 64 | export { testLimitBreak } 65 | -------------------------------------------------------------------------------- /src/components/castingMonitor/Header.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 58 | 59 | 93 | -------------------------------------------------------------------------------- /src/pages/castingMonitor.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 61 | 62 | 67 | 68 | 99 | -------------------------------------------------------------------------------- /src/components/keigennRecord2/StatusShow.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 45 | 46 | 105 | -------------------------------------------------------------------------------- /scripts/aetheryte.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import csv from 'csv-parser' 3 | import iconv from 'iconv-lite' 4 | import { csvPaths } from './paths' 5 | 6 | interface FileValues { 7 | [fileName: string]: string[][] 8 | } 9 | 10 | interface AetheryteData { 11 | x: number 12 | y: number 13 | territory: string 14 | placeName: { 15 | en?: string 16 | ja?: string 17 | cn?: string 18 | } 19 | } 20 | 21 | function readFile( 22 | fileName: string, 23 | filePath: string, 24 | fileValues: FileValues, 25 | ): Promise { 26 | return new Promise((resolve, reject) => { 27 | fs.createReadStream(filePath) 28 | .pipe(iconv.decodeStream('utf8')) 29 | .pipe(csv({ headers: false })) 30 | .on('data', (row: string[]) => { 31 | fileValues[fileName] = fileValues[fileName] || [] 32 | fileValues[fileName].push(row) 33 | }) 34 | .on('end', () => { 35 | resolve() 36 | }) 37 | .on('error', (error) => { 38 | reject(error) 39 | }) 40 | }) 41 | } 42 | 43 | const fileNames = ['MapMarker.csv', 'Aetheryte.csv'] 44 | const fileValues: FileValues = {} 45 | 46 | const allFiles = [ 47 | ...fileNames.map((fileName) => ({ 48 | name: fileName, 49 | path: `${csvPaths.ja}${fileName}`, 50 | })), 51 | { name: 'PlaceName_EN.csv', path: `${csvPaths.en}PlaceName.csv` }, 52 | { name: 'PlaceName_JA.csv', path: `${csvPaths.ja}PlaceName.csv` }, 53 | { name: 'PlaceName_CN.csv', path: `${csvPaths.cn}PlaceName.csv` }, 54 | ] 55 | 56 | await Promise.all( 57 | allFiles.map((file) => readFile(file.name, file.path, fileValues)), 58 | ) 59 | 60 | const DATA_TYPE = '3' 61 | 62 | const aetherytes: AetheryteData[] = fileValues['MapMarker.csv']!.filter( 63 | (row) => row[8] === DATA_TYPE && row[4] !== '0', 64 | ) 65 | .map((row) => { 66 | const dataKey = row[9] 67 | const aetheryteRow = fileValues['Aetheryte.csv']?.find( 68 | (aetheryte) => aetheryte[0] === dataKey, 69 | ) 70 | if (!aetheryteRow) return null 71 | 72 | const territory = aetheryteRow[11] 73 | const placeNameId = row[4] 74 | 75 | const findPlaceName = (file: string) => { 76 | const placeRow = fileValues[file]?.find( 77 | (place) => place[0] === placeNameId, 78 | ) 79 | return placeRow ? placeRow[1] : undefined 80 | } 81 | 82 | const placeNameEN = findPlaceName('PlaceName_EN.csv') 83 | const placeNameJA = findPlaceName('PlaceName_JA.csv') 84 | const placeNameCN = findPlaceName('PlaceName_CN.csv') 85 | 86 | return { 87 | x: Number(row[1]), 88 | y: Number(row[2]), 89 | territory, 90 | placeName: { 91 | en: placeNameEN, 92 | ja: placeNameJA, 93 | cn: placeNameCN, 94 | }, 95 | } as AetheryteData 96 | }) 97 | .filter((row): row is AetheryteData => row !== null) 98 | 99 | fs.outputJsonSync('src/resources/aetherytes.json', aetherytes, { spaces: 2 }) 100 | -------------------------------------------------------------------------------- /src/types/fflogs.d.ts: -------------------------------------------------------------------------------- 1 | export type FFIcon = 2 | | 'NONE' 3 | | 'Gladiator' 4 | | 'Pugilist' 5 | | 'Marauder' 6 | | 'Lancer' 7 | | 'Archer' 8 | | 'Conjurer' 9 | | 'Thaumaturge' 10 | | 'Carpenter' 11 | | 'Blacksmith' 12 | | 'Armorer' 13 | | 'Goldsmith' 14 | | 'Leatherworker' 15 | | 'Weaver' 16 | | 'Alchemist' 17 | | 'Culinarian' 18 | | 'Miner' 19 | | 'Botanist' 20 | | 'Fisher' 21 | | 'Paladin' 22 | | 'Monk' 23 | | 'Warrior' 24 | | 'Dragoon' 25 | | 'Bard' 26 | | 'WhiteMage' 27 | | 'BlackMage' 28 | | 'Arcanist' 29 | | 'Summoner' 30 | | 'Scholar' 31 | | 'Rogue' 32 | | 'Ninja' 33 | | 'Machinist' 34 | | 'DarkKnight' 35 | | 'Astrologian' 36 | | 'Samurai' 37 | | 'RedMage' 38 | | 'BlueMage' 39 | | 'Gunbreaker' 40 | | 'Dancer' 41 | | 'Reaper' 42 | | 'Sage' 43 | | 'Viper' 44 | | 'Pictomancer' 45 | 46 | export interface Friendlies { 47 | guid: number 48 | icon: string 49 | id: number 50 | name: string 51 | server: string 52 | fights: [{ id: number }] 53 | } 54 | export type FFlogsType = 'begincast' | 'cast' 55 | export type FFlogsView = 56 | | 'summary' 57 | | 'damage-done' 58 | | 'damage-taken' 59 | | 'healing' 60 | | 'casts' 61 | | 'summons' 62 | | 'buffs' 63 | | 'debuffs' 64 | | 'deaths' 65 | | 'threat' 66 | | 'resources' 67 | | 'interrupts' 68 | | 'dispels' 69 | 70 | export type FFlogsStance = { 71 | time: number 72 | type: FFlogsType 73 | actionName: string 74 | actionId: number 75 | sourceIsFriendly: boolean 76 | url: string 77 | window?: Array 78 | syncOnce?: boolean 79 | battleOnce?: boolean 80 | sourceID: number 81 | }[] 82 | 83 | export interface FFlogsQuery { 84 | code: string 85 | fightIndex: number 86 | start: number 87 | end: number 88 | friendlies: Friendlies[] 89 | abilityFilterEvents: FFlogsStance 90 | abilityFilterCandidate: FFlogsStance 91 | abilityFilterSelected: number[] 92 | abilityFilterEventsAfterFilterRawTimeline: string 93 | zoneID: number 94 | player?: Friendlies 95 | bossIDs: number[] 96 | enemies: { 97 | name: string 98 | id: number 99 | guid: number 100 | type: string 101 | icon: string 102 | }[] 103 | battleSyncedIDs: number[] 104 | } 105 | 106 | export interface FFlogsApiV1ReportEvents { 107 | ability: { name: string; guid: number; type: number; abilityIcon: string } 108 | fight: number 109 | sourceID: number 110 | sourceIsFriendly: boolean 111 | sourceResources: { 112 | absorb: number 113 | facing: number 114 | hitPoints: number 115 | maxHitPoints: number 116 | mp: number 117 | maxMP: number 118 | tp: number 119 | maxTP: number 120 | x: number 121 | y: number 122 | } 123 | targetId: number 124 | targetIsFriendly: boolean 125 | targetResources: { 126 | facing: number 127 | hitPoints: number 128 | maxHitPoints: number 129 | mp: number 130 | maxMP: number 131 | tp: number 132 | maxTP: number 133 | x: number 134 | y: number 135 | } 136 | timestamp: number 137 | type: FFlogsType 138 | } 139 | -------------------------------------------------------------------------------- /src/utils/cacheManager.ts: -------------------------------------------------------------------------------- 1 | interface CacheEntry { 2 | data: T 3 | expire: number 4 | } 5 | 6 | interface CacheManagerOptions { 7 | key?: string 8 | maxSize?: number // in bytes 9 | cleanupRatio?: number // 0.0 ~ 1.0 10 | } 11 | 12 | export class CacheManager { 13 | private storageKey: string 14 | private maxSize: number 15 | private cleanupRatio: number 16 | 17 | constructor(options?: CacheManagerOptions) { 18 | this.storageKey = options?.key ?? 'cache:all' 19 | this.maxSize = options?.maxSize ?? 2 * 1024 * 1024 // 2MB 20 | this.cleanupRatio = options?.cleanupRatio ?? 0.25 21 | } 22 | 23 | private loadAll(): Record> { 24 | try { 25 | const raw = localStorage.getItem(this.storageKey) 26 | return raw ? JSON.parse(raw) : {} 27 | } catch { 28 | return {} 29 | } 30 | } 31 | 32 | private saveAll(data: Record>): void { 33 | try { 34 | localStorage.setItem(this.storageKey, JSON.stringify(data)) 35 | } catch { 36 | this.cleanupOldEntries(data) 37 | try { 38 | localStorage.setItem(this.storageKey, JSON.stringify(data)) 39 | } catch (e) { 40 | console.warn('[CacheManager] Failed to save after cleanup', e) 41 | } 42 | } 43 | } 44 | 45 | private getTotalSize(data: Record>): number { 46 | return new Blob([JSON.stringify(data)]).size 47 | } 48 | 49 | private cleanupOldEntries(data: Record>): void { 50 | const entries = Object.entries(data) 51 | const sortable = entries.map(([key, value]) => ({ 52 | key, 53 | expire: value?.expire ?? 0, 54 | })) 55 | 56 | sortable.sort((a, b) => a.expire - b.expire) 57 | const count = Math.ceil(entries.length * this.cleanupRatio) 58 | for (let i = 0; i < count; i++) { 59 | delete data[sortable[i]!.key] 60 | } 61 | } 62 | 63 | public set(key: string, value: T, ttl = 259_200_000): void { 64 | const all = this.loadAll() 65 | all[key] = { 66 | data: value, 67 | expire: Date.now() + ttl, 68 | } 69 | 70 | if (this.getTotalSize(all) > this.maxSize) { 71 | this.cleanupOldEntries(all) 72 | } 73 | 74 | this.saveAll(all) 75 | } 76 | 77 | public get(key: string): T | null { 78 | const all = this.loadAll() 79 | const entry = all[key] 80 | if (!entry) return null 81 | 82 | if (entry.expire < Date.now()) { 83 | delete all[key] 84 | this.saveAll(all) 85 | return null 86 | } 87 | 88 | return entry.data 89 | } 90 | 91 | public clearExpired(): void { 92 | const all = this.loadAll() 93 | const now = Date.now() 94 | let changed = false 95 | 96 | for (const key in all) { 97 | if (!all[key]!.expire || all[key]!.expire < now) { 98 | delete all[key] 99 | changed = true 100 | } 101 | } 102 | 103 | if (changed) { 104 | this.saveAll(all) 105 | } 106 | } 107 | 108 | public clearAll(): void { 109 | localStorage.removeItem(this.storageKey) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import vue from '@vitejs/plugin-vue' 3 | import Unocss from 'unocss/vite' 4 | import AutoImport from 'unplugin-auto-import/vite' 5 | import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' 6 | import Components from 'unplugin-vue-components/vite' 7 | import { defineConfig } from 'vite' 8 | import viteCompression from 'vite-plugin-compression' 9 | import Markdown from 'vite-plugin-md' 10 | import Pages from 'vite-plugin-pages' 11 | import sassDts from 'vite-plugin-sass-dts' 12 | import { 13 | createStyleImportPlugin, 14 | VxeTableResolve, 15 | } from 'vite-plugin-style-import' 16 | 17 | // https://vitejs.dev/config/ 18 | export default defineConfig({ 19 | base: '/ff14-overlay-vue/', 20 | build: { 21 | outDir: './dist', 22 | // emptyOutDir: true, // 构建时清空outDir目录 23 | minify: 'terser', 24 | terserOptions: { 25 | compress: { 26 | drop_console: true, 27 | drop_debugger: true, 28 | }, 29 | }, 30 | rollupOptions: { 31 | output: { 32 | chunkFileNames: 'assets/[name]-[hash].js', 33 | entryFileNames: 'assets/[name]-[hash].js', 34 | assetFileNames: 'assets/[name]-[hash].[ext]', 35 | }, 36 | onwarn(warning, warn) { 37 | if ( 38 | warning.code === 'EVAL' && 39 | warning.id?.includes('cactbot/resources/user_config.ts') 40 | ) { 41 | return 42 | } 43 | warn(warning) 44 | }, 45 | }, 46 | chunkSizeWarningLimit: 2000, 47 | reportCompressedSize: false, 48 | sourcemap: false, 49 | }, 50 | plugins: [ 51 | vue({ 52 | include: [/\.vue$/, /\.md$/], 53 | }), 54 | Markdown(), 55 | AutoImport({ 56 | imports: ['vue', '@vueuse/core'], 57 | dts: './src/types/auto-imports.d.ts', 58 | resolvers: [ElementPlusResolver()], 59 | eslintrc: { 60 | enabled: true, 61 | filepath: './.eslintrc-auto-import.json', 62 | globalsPropValue: true, 63 | }, 64 | }), 65 | Components({ 66 | resolvers: [ElementPlusResolver()], 67 | deep: true, 68 | dts: './src/types/components.d.ts', 69 | directoryAsNamespace: true, 70 | }), 71 | viteCompression({ 72 | algorithm: 'gzip', 73 | ext: '.gz', 74 | threshold: 1024, 75 | deleteOriginFile: false, 76 | }), 77 | Unocss(), 78 | Pages(), 79 | createStyleImportPlugin({ 80 | resolves: [VxeTableResolve()], 81 | }), 82 | sassDts(), 83 | ], 84 | define: { 85 | __VUE_OPTIONS_API__: false, 86 | }, 87 | resolve: { 88 | alias: { 89 | '@': path.resolve(__dirname, './src'), 90 | }, 91 | }, 92 | server: { 93 | host: true, 94 | port: 3000, 95 | open: true, 96 | cors: true, 97 | strictPort: false, 98 | hmr: { 99 | overlay: false, 100 | }, 101 | }, 102 | preview: { 103 | port: 5000, 104 | open: true, 105 | cors: true, 106 | }, 107 | optimizeDeps: { 108 | include: ['vue', 'vue-router', 'pinia', 'element-plus', '@vueuse/core'], 109 | }, 110 | }) 111 | -------------------------------------------------------------------------------- /src/components/common/TestLog.vue: -------------------------------------------------------------------------------- 1 | 62 | 63 | 74 | 75 | 115 | -------------------------------------------------------------------------------- /src/composables/useZone.ts: -------------------------------------------------------------------------------- 1 | import type { EventMap } from '../../cactbot/types/event' 2 | import { onMounted, onUnmounted, ref } from 'vue' 3 | import { ZoneInfo } from '@/resources/zoneInfo' 4 | import ContentType from '../../cactbot/resources/content_type' 5 | import { 6 | addOverlayListener, 7 | removeOverlayListener, 8 | } from '../../cactbot/resources/overlay_plugin_api' 9 | 10 | const CONTENT_TYPES = [ 11 | 'Savage', // 零式 12 | 'Extreme', // 歼殛战 13 | 'Chaotic', // 诛灭战 14 | 'Ultimate', // 绝境战 15 | 'DeepDungeonExtras', // 妖宫诗想 16 | 'OccultCrescent', // 新月岛 17 | 'SaveTheQueen', // 神佑女王(BZY) 18 | 'Dungeons', // 四人副本 19 | 'Raids', // 大型任务 20 | 'Trials', // 讨伐任务 21 | 'VCDungeonFinder', // 多变迷宫 22 | 'DeepDungeons', // 深层迷宫 23 | 'Guildhests', // 行会令 24 | 'DisciplesOfTheLand', // 出海垂钓、云冠群岛 25 | 'Eureka', // 尤雷卡 26 | 'SocietyQuests', // 宇宙探索 27 | 'GrandCompany', // 金蝶游乐场 28 | 'QuestBattles', // 任务剧情 29 | 'TreasureHunt', // 挖宝 30 | 'Pvp', // PVP 31 | 'Default', // 其他 32 | ] as const 33 | 34 | type ContentUsedType = (typeof CONTENT_TYPES)[number] 35 | 36 | function getZoneType(zoneInfo: (typeof ZoneInfo)[number]): ContentUsedType { 37 | const contentType = zoneInfo.contentType 38 | const zoneNameFr = zoneInfo.name?.fr 39 | const zoneNameEn = zoneInfo.name?.en 40 | 41 | switch (contentType) { 42 | case ContentType.ChaoticAllianceRaid: 43 | return 'Chaotic' 44 | case ContentType.UltimateRaids: 45 | return 'Ultimate' 46 | case ContentType.Pvp: 47 | return 'Pvp' 48 | case ContentType.Dungeons: 49 | return 'Dungeons' 50 | case ContentType.DeepDungeons: 51 | return 'DeepDungeons' 52 | case ContentType.VCDungeonFinder: 53 | return 'VCDungeonFinder' 54 | case ContentType.Trials: 55 | if (zoneNameFr?.includes('(extrême)')) return 'Extreme' 56 | return 'Trials' 57 | case ContentType.Raids: 58 | if (zoneNameEn?.includes('(Savage)')) return 'Savage' 59 | return 'Raids' 60 | case ContentType.DisciplesOfTheLand: 61 | return 'DisciplesOfTheLand' 62 | case ContentType.Eureka: 63 | return 'Eureka' 64 | case ContentType.GrandCompany: 65 | return 'GrandCompany' 66 | case ContentType.QuestBattles: 67 | return 'QuestBattles' 68 | case ContentType.TreasureHunt: 69 | return 'TreasureHunt' 70 | case ContentType.SocietyQuests: 71 | return 'SocietyQuests' 72 | case ContentType.OccultCrescent: 73 | return 'OccultCrescent' 74 | case ContentType.SaveTheQueen: 75 | return 'SaveTheQueen' 76 | case ContentType.DeepDungeonExtras: 77 | return 'DeepDungeonExtras' 78 | default: 79 | return 'Default' 80 | } 81 | } 82 | 83 | function useZone() { 84 | const zoneType = ref('Default') 85 | const zoneID = ref(0) 86 | 87 | const handleChangeZone: EventMap['ChangeZone'] = (e) => { 88 | zoneID.value = e.zoneID 89 | const zoneInfo = ZoneInfo[zoneID.value] 90 | if (!zoneInfo) { 91 | zoneType.value = 'Default' 92 | return 93 | } 94 | zoneType.value = getZoneType(zoneInfo) 95 | } 96 | 97 | onMounted(() => { 98 | addOverlayListener('ChangeZone', handleChangeZone) 99 | }) 100 | 101 | onUnmounted(() => { 102 | removeOverlayListener('ChangeZone', handleChangeZone) 103 | }) 104 | 105 | return { 106 | zoneID, 107 | zoneType, 108 | } 109 | } 110 | 111 | export { CONTENT_TYPES, type ContentUsedType, useZone } 112 | -------------------------------------------------------------------------------- /src/pages/instancedAreaInfo.vue: -------------------------------------------------------------------------------- 1 | 91 | 92 | 102 | 103 | 117 | -------------------------------------------------------------------------------- /src/types/fflogs2.d.ts: -------------------------------------------------------------------------------- 1 | export interface FflogsjsonReport { 2 | lang: string 3 | fights: Fight[] 4 | friendlies: Friendly[] 5 | enemies: Enemy[] 6 | enemyPets: unknown[] 7 | phases: unknown[] 8 | logVersion: number 9 | gameVersion: number 10 | title: string 11 | owner: string 12 | start: number 13 | end: number 14 | zone: number 15 | } 16 | export interface Fight { 17 | id: number 18 | boss: number 19 | start_time: number 20 | end_time: number 21 | name: string 22 | zoneID: number 23 | zoneName: string 24 | zoneCounter: number 25 | size?: number 26 | difficulty?: number 27 | kill?: boolean 28 | partial?: number 29 | inProgress?: boolean 30 | standardComposition?: boolean 31 | hasEcho?: boolean 32 | bossPercentage?: number 33 | fightPercentage?: number 34 | lastPhaseAsAbsoluteIndex?: number 35 | lastPhaseForPercentageDisplay?: number 36 | maps?: { 37 | mapID: number 38 | mapName: string 39 | mapFile: string 40 | }[] 41 | originalBoss?: number 42 | } 43 | export interface Friendly { 44 | name: string 45 | id: number 46 | guid: number 47 | type: string 48 | server?: string 49 | icon: string 50 | fights: Fight[] 51 | } 52 | export interface Enemy { 53 | name: string 54 | id: number 55 | guid: number 56 | type: string 57 | icon: string 58 | fights: Fight[] 59 | } 60 | export interface FflogsjsonCast { 61 | events: CastEvent[] 62 | count: number 63 | auraAbilities: AuraAbilities[] 64 | nextPageTimestamp?: number 65 | } 66 | export interface AuraAbilities { 67 | name: string 68 | guid: number 69 | type: number 70 | abilityIcon: string 71 | } 72 | export interface CastEvent { 73 | timestamp: number 74 | type: string 75 | sourceID: number 76 | sourceIsFriendly: boolean 77 | targetID?: number 78 | targetIsFriendly: boolean 79 | ability: Ability 80 | fight: number 81 | duration?: number 82 | target?: Target 83 | sourceResources?: SourceResources 84 | targetResources?: SourceResources 85 | } 86 | 87 | export interface SourceResources { 88 | hitPoints: number 89 | maxHitPoints: number 90 | mp: number 91 | maxMP: number 92 | tp: number 93 | maxTP: number 94 | x: number 95 | y: number 96 | facing: number 97 | absorb: number 98 | } 99 | 100 | export interface Target { 101 | name: string 102 | id: number 103 | guid: number 104 | type: string 105 | icon: string 106 | } 107 | 108 | export interface Ability { 109 | name: string 110 | guid: number 111 | type: number 112 | abilityIcon: string 113 | } 114 | export interface FflogsjsonDamageTaken { 115 | events: TakenEvent[] 116 | count: number 117 | auraAbilities: Ability[] 118 | nextPageTimestamp?: number 119 | } 120 | export interface TakenEvent { 121 | timestamp: number 122 | type: string 123 | sourceID: number 124 | sourceIsFriendly: boolean 125 | targetID: number 126 | targetIsFriendly: boolean 127 | ability: Ability 128 | fight: number 129 | buffs?: string 130 | hitType: number 131 | amount: number 132 | multiplier?: number 133 | packetID?: number 134 | sourceResources?: SourceResources 135 | targetResources: TargetResources 136 | absorbed?: number 137 | sourceInstance?: number 138 | unmitigatedAmount?: number 139 | mitigated?: number 140 | blocked?: number 141 | unpaired?: boolean 142 | } 143 | interface TargetResources { 144 | hitPoints: number 145 | maxHitPoints: number 146 | mp: number 147 | maxMP: number 148 | tp: number 149 | maxTP: number 150 | x: number 151 | y: number 152 | facing: number 153 | absorb: number 154 | } 155 | export enum AbilityDamageType { 156 | 特殊 = 32, 157 | DOT = 64, 158 | 物理 = 128, 159 | 魔法 = 1024, 160 | } 161 | -------------------------------------------------------------------------------- /scripts/meals.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import csv from 'csv-parser' 3 | import iconv from 'iconv-lite' 4 | import { csvPaths } from './paths' 5 | 6 | type CsvRow = string[] 7 | type DataMap = Record 8 | type Item = DataMap<{ 9 | Name?: string 10 | ItemAction?: string 11 | Level?: string 12 | ItemUICategory?: 'Meal' | 'Medicine' 13 | }> 14 | type ItemFood = CsvRow 15 | type BaseParam = DataMap 16 | type ItemAction = DataMap 17 | type ParamValue = { 18 | Params: string 19 | Value: string 20 | Max: string 21 | 'Value{HQ}': string 22 | 'Max{HQ}': string 23 | } 24 | type Meal = DataMap<{ 25 | Name: string 26 | ParamsValues: ParamValue[] 27 | Level: string 28 | }> 29 | type Medicine = Meal 30 | 31 | async function readCSV( 32 | path: string, 33 | onRow: (row: CsvRow) => void, 34 | ): Promise { 35 | return new Promise((resolve, reject) => { 36 | fs.createReadStream(path) 37 | .pipe(iconv.decodeStream('utf8')) 38 | .pipe(csv({ headers: false })) 39 | .on('data', onRow) 40 | .on('end', resolve) 41 | .on('error', reject) 42 | }) 43 | } 44 | 45 | const item: Item = {} 46 | const itemAction: ItemAction = {} 47 | const itemFood: DataMap = {} 48 | const baseParam: BaseParam = {} 49 | 50 | await readCSV(`${csvPaths.en}BaseParam.csv`, (row) => { 51 | baseParam[row[0]!] = row[2]! 52 | }) 53 | 54 | await readCSV(`${csvPaths.ja}ItemFood.csv`, (row) => { 55 | itemFood[row[0]!] = row 56 | }) 57 | 58 | await readCSV(`${csvPaths.ja}ItemAction.csv`, (row) => { 59 | itemAction[row[0]!] = row[7]! 60 | }) 61 | 62 | await readCSV(`${csvPaths.cn}Item.csv`, (row) => { 63 | if (!['key', 'offset', '#', 'int32'].includes(row[0]!)) { 64 | item[row[0]!] = { ...(item[row[0]!] || {}), Name: row[1] } 65 | } 66 | }) 67 | 68 | await readCSV(`${csvPaths.ja}Item.csv`, (row) => { 69 | if (['46', '44'].includes(row[16]!) && itemAction[row[31]!] !== '0') { 70 | item[row[0]!] = { 71 | ...(item[row[0]!] || {}), 72 | ItemAction: itemAction[row[31]!], 73 | Level: row[12], 74 | ItemUICategory: row[16] === '46' ? 'Meal' : 'Medicine', 75 | } 76 | } 77 | }) 78 | 79 | const meals: Meal = {} 80 | const medicine: Medicine = {} 81 | 82 | for (const itemValue of Object.values(item)) { 83 | if ( 84 | !itemValue.Name || 85 | !itemValue.ItemAction || 86 | !itemValue.Level || 87 | !itemValue.ItemUICategory 88 | ) { 89 | continue 90 | } 91 | 92 | const food = itemFood[itemValue.ItemAction] 93 | if (!food) { 94 | continue 95 | } 96 | 97 | const paramsValues: ParamValue[] = [0, 1, 2] 98 | .map((i) => { 99 | const offset = i * 6 100 | const params = baseParam[food[2 + offset]!] 101 | if (!params) { 102 | return null 103 | } 104 | return { 105 | Params: params, 106 | Value: food[4 + offset], 107 | Max: food[5 + offset], 108 | 'Value{HQ}': food[6 + offset], 109 | 'Max{HQ}': food[7 + offset], 110 | } 111 | }) 112 | .filter((p): p is ParamValue => p !== null) 113 | 114 | const target = itemValue.ItemUICategory === 'Meal' ? meals : medicine 115 | target[itemValue.ItemAction] = { 116 | Name: itemValue.Name, 117 | ParamsValues: paramsValues, 118 | Level: itemValue.Level, 119 | } 120 | } 121 | 122 | await fs.outputJson('src/resources/meals.json', meals, { spaces: 2 }) 123 | await fs.outputJson('src/resources/medicine.json', medicine, { spaces: 2 }) 124 | 125 | /* 126 | 在1A(NetworkBuff) 拿到count 转十进制 其中0x10000是HQ位 127 | 以酸柠檬腌鱼HQ举例,他的count是10671,真实ID是67。 128 | 获取属性:去ItemFood作为Key找到其三组BaseParam、Value、Max、Value{HQ}、Max{HQ},其中BaseParam的数值通过查表BaseParam.csv获取中文属性字符串。 129 | 获取名字:用671去ItemAction.csv的Data[1]得到他的key2617,再用key去Item.csv作为ItemAction得到ID44842的酸柠檬腌鱼。 130 | */ 131 | -------------------------------------------------------------------------------- /src/components/castingMonitor/Main.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 25 | 26 | 166 | -------------------------------------------------------------------------------- /src/pages/enmity.vue: -------------------------------------------------------------------------------- 1 | 112 | 113 | 118 | 119 | 139 | -------------------------------------------------------------------------------- /src/components/keigennRecord2/Target.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 58 | 59 | 165 | -------------------------------------------------------------------------------- /src/composables/useWebSocket.ts: -------------------------------------------------------------------------------- 1 | import { ElMessageBox } from 'element-plus' 2 | import { onMounted, ref, watch } from 'vue' 3 | import actWS from '@/assets/actWS.webp' 4 | import { callOverlayHandler } from '../../cactbot/resources/overlay_plugin_api' 5 | import { useI18n } from 'vue-i18n' 6 | 7 | function isIE() { 8 | const userAgent = window.navigator.userAgent 9 | const isMSIE = userAgent.includes('MSIE ') // IE 10 及以下 10 | const isTrident = userAgent.includes('Trident/') // IE 11 11 | return isMSIE || isTrident 12 | } 13 | 14 | function addOverlayWsParam() { 15 | const currentUrl = window.location.href 16 | const [basePart, hashPart = ''] = currentUrl.split('#') 17 | const [hashPath, hashQuery = ''] = hashPart.split('?') 18 | const searchParams = new URLSearchParams(hashQuery) 19 | let newHashQuery = '' 20 | searchParams.forEach((value, key) => { 21 | if (key !== 'OVERLAY_WS') { 22 | newHashQuery += `${key}=${encodeURIComponent(value)}&` 23 | } 24 | }) 25 | newHashQuery += `OVERLAY_WS=ws://127.0.0.1:10501/ws` 26 | 27 | let newUrl = basePart! 28 | if (hashPath) { 29 | newUrl += `#${hashPath}` 30 | if (newHashQuery) { 31 | newUrl += `?${newHashQuery}` 32 | } 33 | } 34 | 35 | window.location.href = newUrl 36 | location.reload() 37 | } 38 | 39 | export function useWebSocket( 40 | config: { 41 | allowClose: boolean 42 | addWsParam: boolean 43 | } = { allowClose: false, addWsParam: true } 44 | ) { 45 | const { t } = useI18n() 46 | const wsConnected = ref(undefined as boolean | undefined) 47 | const userIgnoredWarning = ref(false) 48 | const useType = ref('overlay' as 'overlay' | 'websocket') 49 | let timer: number | null = null 50 | 51 | function check() { 52 | Promise.race([ 53 | callOverlayHandler({ call: 'getLanguage' }), 54 | new Promise((_resolve, reject) => { 55 | setTimeout(() => { 56 | reject(new Error('Timeout')) 57 | }, 1000) 58 | }), 59 | ]) 60 | .then(() => { 61 | if (timer) { 62 | clearTimeout(timer) 63 | } 64 | if (window.location.href.includes('OVERLAY_WS')) { 65 | wsConnected.value = true 66 | useType.value = 'websocket' 67 | } 68 | }) 69 | .catch(() => { 70 | wsConnected.value = false 71 | useType.value = 'overlay' 72 | }) 73 | } 74 | 75 | if (window.location.href.includes('OVERLAY_WS')) { 76 | timer = window.setInterval(() => { 77 | check() 78 | }, 3000) 79 | } 80 | 81 | function handleDisconnection() { 82 | if (!userIgnoredWarning.value) { 83 | ElMessageBox.close() 84 | 85 | const message = t('websocket.disconnectMsg', { actWS: actWS }) 86 | const title = config.allowClose 87 | ? t('websocket.disconnectTitleCloseable') 88 | : t('websocket.disconnectTitleRequired') 89 | 90 | ElMessageBox.alert(message, title, { 91 | dangerouslyUseHTMLString: true, 92 | closeOnClickModal: false, 93 | showClose: false, 94 | closeOnPressEscape: false, 95 | closeOnHashChange: false, 96 | showCancelButton: config.allowClose, 97 | showConfirmButton: false, 98 | cancelButtonText: t('websocket.ignoreWarningBtn'), 99 | buttonSize: 'small', 100 | }).catch(() => { 101 | userIgnoredWarning.value = true 102 | if (timer) clearInterval(timer) 103 | }) 104 | } 105 | } 106 | 107 | onMounted(() => { 108 | if (isIE()) { 109 | ElMessageBox.alert( 110 | t('websocket.ieBrowserWarning'), 111 | t('websocket.ieBrowserTitle'), 112 | { 113 | type: 'error', 114 | showConfirmButton: false, 115 | showClose: false, 116 | } 117 | ) 118 | return 119 | } 120 | watch(wsConnected, (value) => { 121 | if (value) { 122 | ElMessageBox.close() 123 | } else { 124 | handleDisconnection() 125 | } 126 | }) 127 | if (!window.location.href.includes('OVERLAY_WS') && config.addWsParam) { 128 | addOverlayWsParam() 129 | } 130 | check() 131 | }) 132 | 133 | return { wsConnected, useType } 134 | } 135 | -------------------------------------------------------------------------------- /src/resources/timelineTemplate.ts: -------------------------------------------------------------------------------- 1 | export const p8sTimeline = `# 新建之后先限制一个职业 2 | 00:00.0 "--同步化--" sync /^.{14} \\w+ 1[56]:[^:]*:[^:]+:7947:/ window 40,0 3 | 00:07.1 "创世真炎" sync /^.{14} \\w+ 14:4.{7}:[^:]+:7944:/ window 50,20 4 | # 00:12.2 判定 "创世真炎" 5 | # 00:15.3 读条 "八分核爆之念" 6 | # 00:18.3 判定 "八分核爆之念" 7 | # 00:21.5 读条 "灼炎创火" 8 | # 00:24.5 判定 "灼炎创火" 9 | # 00:32.9 读条 "创兽炎舞" 10 | # 00:39.9 判定 "创兽炎舞" 11 | # 00:43.1 读条 "炎蛇炮" 12 | # 00:48.2 判定 "炎蛇炮" 13 | # 00:51.4 判定 "炎蛇炮" 14 | 15 | # 蛇轴(时间统一加1分钟或60秒) 16 | 01:00.9 "变异创身" sync /^.{14} \\w+ 14:4.{7}:[^:]+:794C:/ window 100,100 jump 120.9 17 | # 02:03.9 判定 "变异创身" 18 | # 02:11.2 判定 "回旋蛇踢" 19 | # 02:14.4 读条 "戈尔贡的诅咒" 20 | # 02:17.3 判定 "戈尔贡的诅咒" 21 | # 02:20.5 读条 "潜入阴影" 22 | # 02:23.5 判定 "潜入阴影" 23 | # 02:56.7 读条 "爆热波动" 24 | # 03:01.7 判定 "爆热波动" 25 | # 03:17.2 读条 "创造幻影" 26 | # 03:20.2 判定 "创造幻影" 27 | # 03:23.3 读条 "创造命令" 28 | # 03:26.3 判定 "创造命令" 29 | # 03:29.5 读条 "多重灼炎" 30 | # 03:34.5 判定 "多重灼炎" 31 | # 03:38.6 判定 "多重灼炎" 32 | # 03:41.7 读条 "四分核爆" 33 | # 03:46.7 判定 "四分核爆" 34 | # 03:58.9 读条 "创世真炎" 35 | # 04:03.9 判定 "创世真炎" 36 | # 04:13.4 读条 "变异创身" 37 | # 04:16.4 判定 "变异创身" 38 | # 04:23.8 读条 "双足狂怒" 39 | # 04:28.8 判定 "双足狂怒" 40 | # 04:30.9 判定 "双足狂怒" 41 | # 04:33.0 判定 "双足狂怒" 42 | # 04:35.1 判定 "双足狂怒" 43 | # 04:37.3 读条 "致命践踏" 44 | # 04:42.3 判定 "致命践踏" 45 | # 04:42.5 判定 "致命践踏" 46 | # 04:44.8 判定 "致命践踏" 47 | # 04:47.1 判定 "致命践踏" 48 | # 04:49.4 判定 "致命践踏" 49 | # 05:03.7 读条 "八分核爆之念" 50 | # 05:06.7 判定 "八分核爆之念" 51 | # 05:09.9 读条 "四重火炎风暴" 52 | # 05:12.9 判定 "四重火炎风暴" 53 | # 05:19.1 读条 "喷炎升蛇" 54 | # 05:22.1 判定 "喷炎升蛇" 55 | # 05:32.3 读条 "四分核爆" 56 | # 05:37.3 判定 "四分核爆" 57 | # 05:43.8 读条 "创兽炎舞" 58 | # 05:50.8 判定 "创兽炎舞" 59 | # 05:53.9 读条 "炎蛇炮" 60 | # 05:59.0 判定 "炎蛇炮" 61 | # 06:02.3 判定 "炎蛇炮" 62 | # 06:11.7 读条 "变异创身" 63 | # 06:14.7 判定 "变异创身" 64 | # 06:22.0 判定 "回旋蛇踢" 65 | # 06:25.1 读条 "戈尔贡的诅咒" 66 | # 06:28.1 判定 "戈尔贡的诅咒" 67 | # 06:31.3 读条 "潜入阴影" 68 | # 06:34.3 判定 "潜入阴影" 69 | # 06:53.5 读条 "创造幻影" 70 | # 06:56.5 判定 "创造幻影" 71 | # 07:16.0 读条 "炎蛇炮" 72 | # 07:21.0 判定 "炎蛇炮" 73 | # 07:24.2 判定 "炎蛇炮" 74 | # 07:33.6 读条 "变异创身" 75 | # 07:36.6 判定 "变异创身" 76 | # 07:44.4 读条 "践踏冲击" 77 | # 07:49.4 判定 "践踏冲击" 78 | # 07:52.9 读条 "二分核爆之念" 79 | # 07:55.9 判定 "二分核爆之念" 80 | # 07:59.1 读条 "炽热践踏" 81 | # 08:11.1 判定 "炽热践踏" 82 | # 08:15.8 判定 "践踏冲击" 83 | # 08:19.7 判定 "踏火寻迹" 84 | # 08:22.4 判定 "践踏碎击" 85 | # 08:35.1 读条 "创世真炎" 86 | # 08:40.1 判定 "创世真炎" 87 | 88 | # 兽轴(时间统一加10分钟或600秒) 89 | 01:00.8 "变异创身" sync /^.{14} \\w+ 14:4.{7}:[^:]+:794B:/ window 100,100 jump 660.8 90 | # 11:03.8 判定 "变异创身" 91 | # 11:11.3 读条 "双足狂怒" 92 | # 11:16.2 判定 "双足狂怒" 93 | # 11:18.4 判定 "双足狂怒" 94 | # 11:20.5 判定 "双足狂怒" 95 | # 11:22.6 判定 "双足狂怒" 96 | # 11:24.8 读条 "致命践踏" 97 | # 11:29.7 判定 "致命践踏" 98 | # 11:30.0 判定 "致命践踏" 99 | # 11:32.3 判定 "致命践踏" 100 | # 11:34.6 判定 "致命践踏" 101 | # 11:36.8 判定 "致命践踏" 102 | # 11:51.2 读条 "创造幻影" 103 | # 11:54.2 判定 "创造幻影" 104 | # 11:57.4 读条 "创造命令" 105 | # 12:00.4 判定 "创造命令" 106 | # 12:03.5 读条 "多重灼炎" 107 | # 12:08.5 判定 "多重灼炎" 108 | # 12:12.7 判定 "多重灼炎" 109 | # 12:15.8 读条 "四分核爆" 110 | # 12:20.8 判定 "四分核爆" 111 | # 12:33.0 读条 "创世真炎" 112 | # 12:38.0 判定 "创世真炎" 113 | # 12:47.5 读条 "变异创身" 114 | # 12:50.4 判定 "变异创身" 115 | # 12:57.7 判定 "回旋蛇踢" 116 | # 13:00.9 读条 "戈尔贡的诅咒" 117 | # 13:03.9 判定 "戈尔贡的诅咒" 118 | # 13:07.0 读条 "潜入阴影" 119 | # 13:10.0 判定 "潜入阴影" 120 | # 13:43.3 读条 "爆热波动" 121 | # 13:48.3 判定 "爆热波动" 122 | # 14:03.7 读条 "八分核爆之念" 123 | # 14:06.7 判定 "八分核爆之念" 124 | # 14:09.9 读条 "四重火炎风暴" 125 | # 14:12.9 判定 "四重火炎风暴" 126 | # 14:19.1 读条 "喷炎升蛇" 127 | # 14:22.1 判定 "喷炎升蛇" 128 | # 14:32.3 读条 "八分核爆" 129 | # 14:37.3 判定 "八分核爆" 130 | # 14:43.8 读条 "创兽炎舞" 131 | # 14:50.8 判定 "创兽炎舞" 132 | # 14:54.0 读条 "炎蛇炮" 133 | # 14:59.1 判定 "炎蛇炮" 134 | # 15:02.3 判定 "炎蛇炮" 135 | # 15:11.7 读条 "变异创身" 136 | # 15:14.7 判定 "变异创身" 137 | # 15:22.5 读条 "践踏冲击" 138 | # 15:27.4 判定 "践踏冲击" 139 | # 15:31.0 读条 "二分核爆之念" 140 | # 15:34.0 判定 "二分核爆之念" 141 | # 15:37.2 读条 "炽热践踏" 142 | # 15:49.2 判定 "炽热践踏" 143 | # 15:53.9 判定 "践踏碎击" 144 | # 15:57.7 判定 "踏火寻迹" 145 | # 16:00.4 判定 "践踏冲击" 146 | # 16:13.2 读条 "炎蛇炮" 147 | # 16:18.2 判定 "炎蛇炮" 148 | # 16:21.4 判定 "炎蛇炮" 149 | # 16:30.8 读条 "变异创身" 150 | # 16:33.8 判定 "变异创身" 151 | # 16:41.1 判定 "回旋蛇踢" 152 | # 16:44.3 读条 "戈尔贡的诅咒" 153 | # 16:47.3 判定 "戈尔贡的诅咒" 154 | # 16:50.4 读条 "潜入阴影" 155 | # 16:53.4 判定 "潜入阴影" 156 | # 17:12.7 读条 "创造幻影" 157 | # 17:15.7 判定 "创造幻影" 158 | # 17:35.1 读条 "创世真炎" 159 | # 17:40.1 判定 "创世真炎" 160 | ` 161 | -------------------------------------------------------------------------------- /src/utils/chineseToIcon.ts: -------------------------------------------------------------------------------- 1 | import chinese2IconRaw from '@/resources/chinese2Icon.json' 2 | 3 | const chineseToIconMap: Record = chinese2IconRaw 4 | 5 | const userActionMap = new Map([ 6 | ['任务指令', 123], 7 | ['冲刺', 104], 8 | ['坐骑', 118], 9 | ['攻击', 101], 10 | ]) 11 | 12 | const chineseAbbreviationMap = new Map([ 13 | // PLD 14 | ['战逃', '战逃反应'], 15 | ['盾猛', '盾牌猛击'], 16 | ['无敌', '神圣领域'], 17 | ['幕帘', '圣光幕帘'], 18 | ['深仁', '深仁厚泽'], 19 | ['大翅膀', '武装戍卫'], 20 | ['大预警', '极致防御'], 21 | ['安魂', '绝对统治'], 22 | 23 | // WAR 24 | ['FC', '裂石飞环'], 25 | ['直觉', '原初的直觉'], 26 | ['泰然', '泰然自若'], 27 | ['解放', '原初的解放'], 28 | ['勇猛', '原初的勇猛'], 29 | ['血气', '原初的血气'], 30 | ['大复仇', '戮罪'], 31 | 32 | // DRK 33 | ['活死人', '行尸走肉'], 34 | ['布道', '暗黑布道'], 35 | ['黑盾', '至黑之夜'], 36 | 37 | // GNB 38 | ['超火', '超火流星'], 39 | ['刚玉', '刚玉之心'], 40 | 41 | // Caster 42 | ['即刻', '即刻咏唱'], 43 | ['病毒', '昏乱'], 44 | ['R3', '毁坏'], 45 | 46 | // WHM 47 | ['神速', '神速咏唱'], 48 | ['天赐', '天赐祝福'], 49 | ['白盾', '神祝祷'], 50 | ['红花', '苦难之心'], 51 | ['蓝花', '狂喜之心'], 52 | ['铃铛', '礼仪之铃'], 53 | 54 | // SCH 55 | ['小仙女', '朝日召唤'], 56 | ['低语', '仙光的低语'], 57 | ['单盾', '鼓舞激励之策'], 58 | ['小群盾', '士气高扬之策'], 59 | ['幻光', '异想的幻光'], 60 | ['活性法', '生命活性法'], 61 | ['野战', '野战治疗阵'], 62 | ['不屈', '不屈不挠之策'], 63 | ['扩散', '展开战术'], 64 | ['扩散盾', '展开战术'], 65 | ['应急', '应急战术'], 66 | ['绿帽', '深谋远虑之策'], 67 | ['融光', '以太契约'], 68 | ['祥光', '异想的祥光'], 69 | ['大天使', '炽天召唤'], 70 | ['炽天使', '炽天召唤'], 71 | ['回生法', '生命回生法'], 72 | ['跑快快', '疾风怒涛之计'], 73 | ['PKK', '疾风怒涛之计'], 74 | ['群盾', '意气轩昂之策'], 75 | 76 | // AST 77 | ['先天', '先天禀赋'], 78 | ['抽卡A', '星极抽卡'], 79 | ['抽卡B', '灵极抽卡'], 80 | ['合图', '星位合图'], 81 | ['命轮', '命运之轮'], 82 | ['天星', '天星冲日'], 83 | ['炸地星', '星体爆轰'], 84 | ['地星爆炸', '星体爆轰'], 85 | ['交错', '天星交错'], 86 | 87 | // SGE 88 | ['贤单盾', '均衡诊断'], 89 | ['贤小群盾', '均衡预后'], 90 | ['贤活性法', '灵橡清汁'], 91 | ['贤野战', '坚角清汁'], 92 | ['贤不屈', '寄生清汁'], 93 | ['贤低语', '自生II'], 94 | ['白牛', '白牛清汁'], 95 | ['贤绿帽', '白牛清汁'], 96 | ['海马', '输血'], 97 | ['群海马', '泛输血'], 98 | 99 | // MNK 100 | ['红莲', '红莲极意'], 101 | 102 | // DRG 103 | ['连祷', '战斗连祷'], 104 | 105 | // NIN 106 | ['背刺', '介毒之术'], 107 | ['生杀', '生杀予夺'], 108 | 109 | // SAM 110 | ['明镜', '明镜止水'], 111 | ['震天', '必杀剑·震天'], 112 | ['晓天', '必杀剑·晓天'], 113 | ['夜天', '必杀剑·夜天'], 114 | ['九天', '必杀剑·九天'], 115 | ['红莲', '必杀剑·红莲'], 116 | ['闪影', '必杀剑·闪影'], 117 | ['意气', '意气冲天'], 118 | ['斩浪', '奥义斩浪'], 119 | ['波切', '奥义斩浪'], 120 | ['回返波切', '回返斩浪'], 121 | 122 | // RPR 123 | 124 | // VPR 125 | 126 | // BRD 127 | ['猛者', '猛者强击'], 128 | ['贤者歌', '贤者的叙事谣'], 129 | ['光阴神', '光阴神的礼赞凯歌'], 130 | ['军神歌', '军神的赞美歌'], 131 | ['战歌', '战斗之声'], 132 | ['旅神歌', '放浪神的小步舞曲'], 133 | ['九天', '九天连箭'], 134 | ['伶牙', '伶牙俐齿'], 135 | ['侧风', '侧风诱导箭'], 136 | ['大地神', '大地神的抒情恋歌'], 137 | 138 | // MCH 139 | 140 | // DNC 141 | ['小舞', '标准舞步'], 142 | ['华尔兹', '治疗之华尔兹'], 143 | ['桑巴', '防守之桑巴'], 144 | ['探戈', '进攻之探戈'], 145 | ['大舞', '技巧舞步'], 146 | ['百花', '百花争艳'], 147 | ['即兴', '即兴表演'], 148 | 149 | // BLM 150 | ['火1', '火炎'], 151 | ['火2', '烈炎'], 152 | ['火3', '爆炎'], 153 | ['火4', '炽炎'], 154 | ['冰1', '冰结'], 155 | ['冰2', '冰冻'], 156 | ['冰3', '冰封'], 157 | ['冰4', '冰澈'], 158 | ['雷1', '闪雷'], 159 | ['雷2', '震雷'], 160 | ['雷3', '暴雷'], 161 | ['雷4', '霹雷'], 162 | ['高雷1', '高闪雷'], 163 | ['高雷2', '高震雷'], 164 | ['高火2', '高烈炎'], 165 | ['高冰2', '高冰冻'], 166 | ['三连', '三连咏唱'], 167 | ['激情', '激情咏唱'], 168 | ['星灵', '星灵移位'], 169 | ['转魔', '魔泉'], 170 | ['P', '悖论'], 171 | ['D', '绝望'], 172 | ['FS', '耀星'], 173 | 174 | // SMN 175 | ['火神', '火神召唤II'], 176 | ['土神', '土神召唤II'], 177 | ['风神', '风神召唤II'], 178 | ['灵护', '灼热之光'], 179 | ['风圈', '螺旋气流'], 180 | 181 | // RDM 182 | ['倍增', '魔元化'], 183 | 184 | // PCT 185 | ['坦培拉', '坦培拉涂层'], 186 | ['蛋壳', '坦培拉涂层'], 187 | ['减色', '坦培拉涂层'], 188 | ['白豆', '神圣之白'], 189 | ['黑豆', '彗星之黑'], 190 | ]) 191 | 192 | function chineseToIcon(chinese: string): number | undefined { 193 | chinese = chineseAbbreviationMap.get(chinese.toUpperCase()) ?? chinese 194 | 195 | const iconStr = 196 | userActionMap.get(chinese)?.toString() ?? chineseToIconMap[chinese] 197 | const iconNum = Number(iconStr) 198 | 199 | if (!iconStr || Number.isNaN(iconNum) || iconNum === 0) { 200 | return undefined 201 | } 202 | return iconNum 203 | } 204 | 205 | export { chineseToIcon, chineseToIconMap } 206 | -------------------------------------------------------------------------------- /src/components/timeline/SettingsDialog.vue: -------------------------------------------------------------------------------- 1 | 71 | 72 | 143 | 144 | 174 | -------------------------------------------------------------------------------- /src/utils/xivapi.ts: -------------------------------------------------------------------------------- 1 | const params = new URLSearchParams(window.location.href.split('?')[1]) 2 | 3 | const api = params.get('api') 4 | 5 | const siteList = { 6 | cafe: 'cafemaker.wakingsands.com', 7 | xivapi: 'xivapi.com', 8 | } 9 | 10 | export const hostCache = new Map() 11 | 12 | export const site: { first: string; second: string } = { 13 | first: `https://${api?.toLowerCase() === 'xivapi' ? siteList.xivapi : siteList.cafe}`, 14 | second: `https://${api?.toLowerCase() === 'xivapi' ? siteList.cafe : siteList.xivapi}`, 15 | } 16 | 17 | interface CachedAction { 18 | name: string 19 | action: unknown 20 | expirationTime: number 21 | } 22 | 23 | const ACTION_CACHE_KEY = 'souma-action-cache' 24 | const cachedActionData: CachedAction[] = JSON.parse( 25 | localStorage.getItem(ACTION_CACHE_KEY) || '[]', 26 | ) as CachedAction[] 27 | const currentTime = Date.now() 28 | const MAX_CACHE_LENGTH = 1000 29 | const updatedActionData = cachedActionData.filter( 30 | (v, index) => v.expirationTime >= currentTime && index < MAX_CACHE_LENGTH, 31 | ) 32 | localStorage.setItem(ACTION_CACHE_KEY, JSON.stringify(updatedActionData)) 33 | 34 | const ICON_REGEX = /(\d{6})\/(\d{6})\.png$/ 35 | 36 | export async function parseAction( 37 | type: string, 38 | actionId: number, 39 | columns: (keyof XivApiJson)[] = ['ID', 'Icon', 'ActionCategoryTargetID'], 40 | ): Promise { 41 | if (hostCache.has(actionId)) { 42 | return hostCache.get(actionId) 43 | } 44 | const urls = generateActionUrls(type, actionId, columns) 45 | try { 46 | const result = await requestPromise(urls, { mode: 'cors' }) 47 | hostCache.set(actionId, result) 48 | return result 49 | } catch (error) { 50 | console.error(`Failed to parse action: ${error}`) 51 | return Promise.resolve({ 52 | ActionCategoryTargetID: 0, 53 | ID: actionId, 54 | Icon: '/i/000000/000405.png', 55 | }) 56 | } 57 | } 58 | 59 | function generateActionUrls( 60 | type: string, 61 | actionId: number, 62 | columns: (keyof XivApiJson)[], 63 | ) { 64 | const urlTemplate = `${site.first}/${type}/${actionId}?columns=${columns.join( 65 | ',', 66 | )}` 67 | return [urlTemplate, urlTemplate.replace(site.first, site.second)] 68 | } 69 | 70 | export async function getFullImgSrc(icon: string, itemIsHQ = false) { 71 | const url = `${site.first}${icon}` 72 | return url.replace( 73 | ICON_REGEX, 74 | (_match, p1, p2) => `${p1}/${itemIsHQ ? 'hq/' : ''}${p2}.png`, 75 | ) 76 | } 77 | 78 | export async function getImgSrcByActionId(id: number): Promise { 79 | const res = await parseAction('action', id, ['Icon']) 80 | 81 | return getFullImgSrc(res.Icon) 82 | } 83 | 84 | async function timeoutPromise( 85 | promise: Promise, 86 | timeout: number, 87 | ): Promise { 88 | let timeoutId: number 89 | const timeoutPromise = new Promise((_, reject) => { 90 | timeoutId = window.setTimeout(() => { 91 | reject(new Error('Promise timed out')) 92 | }, timeout) 93 | }) 94 | return Promise.race([promise, timeoutPromise]).finally(() => { 95 | clearTimeout(timeoutId) 96 | }) 97 | } 98 | 99 | async function requestPromise( 100 | urls: string[], 101 | options?: RequestInit, 102 | ): Promise { 103 | const _options = Object.assign({ cache: 'force-cache' }, options) 104 | for (const url of urls) { 105 | const response = await timeoutPromise(fetch(url, _options), 3000) 106 | if (response.ok) { 107 | const json = await response.json() 108 | const host = new URL(url).host 109 | return { ...json, Host: host } 110 | } 111 | } 112 | throw new Error('All fetch attempts failed.') 113 | } 114 | 115 | const imgCache = new Map() 116 | 117 | export function getImgSrc(src: string): string { 118 | if (imgCache.has(src)) { 119 | return imgCache.get(src) as string 120 | } 121 | return `${site.first}${src}` 122 | } 123 | 124 | export function handleImgError(event: Event) { 125 | const target = event.target as HTMLImageElement 126 | const path = target.src.match(/(?<=\.\w+)\/.+$/)?.[0] 127 | if (!path || target.src === '') return 128 | if (/pictomancer\.png$/.test(target.src)) { 129 | target.src = '//souma.diemoe.net/resources/img/pictomancer.png' 130 | } else if (/viper\.png$/.test(target.src)) { 131 | target.src = '//souma.diemoe.net/resources/img/viper.png' 132 | } else if (target.src.includes(site.first)) { 133 | target.src = target.src.replace(site.first, site.second) 134 | } else { 135 | target.src = '' 136 | } 137 | imgCache.set(path, target.src) 138 | } 139 | -------------------------------------------------------------------------------- /src/utils/flags.ts: -------------------------------------------------------------------------------- 1 | type DamageEffect = 2 | | 'dodge' // 闪避 3 | | 'damage done' // 击中 4 | | 'blocked damage' // 格挡 5 | | 'parried damage' // 招架 6 | | 'instant death' // 即死 7 | | 'heal' // 治疗 8 | | 'crit heal' // 暴击治疗 9 | 10 | type DamageProperties = 11 | | 'damage' // 普通 12 | | 'crit damage' // 暴击 13 | | 'direct hit damage' // 直击 14 | | 'crit direct hit damage' // 直暴; 15 | 16 | type DamageType = 17 | | 'physics' // 物理 18 | | 'magic' // 魔法 19 | | 'darkness' // 暗黑; 20 | | 'dot' 21 | 22 | 23 | function processFlags(flag: string) { 24 | const effect = processEffect(flag) 25 | const properties = processProperties(flag) 26 | const type = processType(flag) 27 | return { effect, properties, type } 28 | } 29 | 30 | // 0x01 = dodge 31 | // 0x03 = damage done 32 | // 0x05 = blocked damage 33 | // 0x06 = parried damage 34 | // 0x33 = instant death 35 | // 0x000004 = heal 36 | // 0x200004 = crit heal 37 | 38 | function processEffect(flag: string): DamageEffect { 39 | switch (true) { 40 | case flag.endsWith('33'): 41 | return 'instant death' 42 | case /2\w{4}4$/.test(flag): 43 | return 'crit heal' 44 | case flag.endsWith('1'): 45 | return 'dodge' 46 | case flag.endsWith('3'): 47 | return 'damage done' 48 | case flag.endsWith('4'): 49 | return 'heal' 50 | case flag.endsWith('5'): 51 | return 'blocked damage' 52 | case flag.endsWith('6'): 53 | return 'parried damage' 54 | default: 55 | throw new Error(`Unknown effect flag ${flag}`) 56 | } 57 | } 58 | 59 | // 0x2000 = crit damage 60 | // 0x4000 = direct hit damage 61 | // 0x6000 = crit direct hit damage 62 | 63 | function processProperties(flag: string): DamageProperties { 64 | switch (true) { 65 | case /2\w{3}$/.test(flag): 66 | return 'crit damage' 67 | case /4\w{3}$/.test(flag): 68 | return 'direct hit damage' 69 | case /6\w{3}$/.test(flag): 70 | return 'crit direct hit damage' 71 | default: 72 | return 'damage' 73 | } 74 | } 75 | 76 | // 0x50000 = magic 77 | // 0x60000 = darkness 78 | // 740003 = Shot (physics?)? 79 | 80 | function processType(flag: string): DamageType { 81 | switch (true) { 82 | case /7?[1-4]\w{3}[35]$/.test(flag): 83 | case /[16]$/.test(flag): 84 | return 'physics' 85 | case /^E$/.test(flag): 86 | case /5\w{4}$/.test(flag): 87 | return 'magic' 88 | case /^(?:\d0)?3$/.test(flag): 89 | case /6\w{4}$/.test(flag): 90 | return 'darkness' 91 | default: 92 | console.warn(`Unknown type flag "${flag}", defaulting to 'physics'`) 93 | return 'physics' 94 | } 95 | } 96 | // 0x10000 = 真无敌 97 | // 0x4000 = 巨额伤害 98 | 99 | // Unknown: 100 | 101 | // 0x300 102 | 103 | // 0x600 = 似乎意味着存在护盾 104 | 105 | const kShiftFlagValues = ['3E', '113', '213', '313'] 106 | const kShiftFlagValues2 = ['A10', 'E'] 107 | const kHealFlags = ['04'] 108 | const kFlagInstantDeath = '36' 109 | const kAttackFlags = ['01', '03', '05', '06', kFlagInstantDeath] 110 | 111 | function processAbilityLine(splitLine: string[]) { 112 | const flagIdx = 8 113 | let offset = 0 114 | let flags = splitLine[flagIdx] ?? '' 115 | let damage = splitLine[flagIdx + 1] ?? '' 116 | if (kShiftFlagValues.includes(flags)) { 117 | offset += 2 118 | flags = splitLine[flagIdx + offset] ?? flags 119 | damage = splitLine[flagIdx + offset + 1] ?? damage 120 | } 121 | if (kShiftFlagValues2.includes(flags)) { 122 | offset += 2 123 | flags = splitLine[flagIdx + offset] ?? flags 124 | damage = splitLine[flagIdx + offset + 1] ?? damage 125 | } 126 | const amount = UnscrambleDamage(damage) 127 | const lowByte = `00${flags}`.slice(-2) 128 | return { 129 | amount, 130 | lowByte, 131 | flags, 132 | isHeal: kHealFlags.includes(lowByte), 133 | isAttack: kAttackFlags.includes(lowByte), 134 | } 135 | } 136 | 137 | function UnscrambleDamage(field?: string): number { 138 | if (field === undefined) return 0 139 | const len = field.length 140 | if (len <= 4) return 0 141 | // Get the left two bytes as damage. 142 | let damage = Number.parseInt(field.slice(0, len - 4), 16) 143 | // Check for third byte == 0x40. 144 | if (field[len - 4] === '4') { 145 | // Wrap in the 4th byte as extra damage. See notes above. 146 | const rightDamage = Number.parseInt(field.slice(len - 2, len), 16) 147 | damage = damage - rightDamage + (rightDamage << 16) 148 | } 149 | return damage 150 | } 151 | 152 | export { 153 | type DamageEffect, 154 | type DamageProperties, 155 | type DamageType, 156 | processAbilityLine, 157 | processFlags, 158 | UnscrambleDamage, 159 | } 160 | -------------------------------------------------------------------------------- /src/pages/dsrp6.vue: -------------------------------------------------------------------------------- 1 | 66 | 67 | 101 | 102 | 107 | 108 | 145 | -------------------------------------------------------------------------------- /src/components/common/DragJob.vue: -------------------------------------------------------------------------------- 1 | 92 | 93 | 142 | 143 | 165 | -------------------------------------------------------------------------------- /scripts/aethercurrent.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import csv from 'csv-parser' 3 | import iconv from 'iconv-lite' 4 | import { csvPaths } from './paths' 5 | 6 | interface FileValues { 7 | [fileName: string]: string[][] 8 | } 9 | 10 | interface AetherCurrentResult { 11 | x: string 12 | y: string 13 | z: string 14 | territory: string 15 | name: { ja?: string; cn?: string; en?: string } 16 | game: { x: number; y: number } 17 | id: string 18 | exVersion: string 19 | data: number 20 | } 21 | 22 | class Vector2 { 23 | x: number 24 | y: number 25 | 26 | constructor(x = 0, y = 0) { 27 | this.x = x 28 | this.y = y 29 | } 30 | 31 | add(vector: Vector2): Vector2 { 32 | return new Vector2(this.x + vector.x, this.y + vector.y) 33 | } 34 | 35 | divide(scalar: number): Vector2 { 36 | if (scalar === 0) throw new Error('Cannot divide by zero') 37 | return new Vector2(this.x / scalar, this.y / scalar) 38 | } 39 | 40 | multiply(scalar: number): Vector2 { 41 | return new Vector2(this.x * scalar, this.y * scalar) 42 | } 43 | } 44 | 45 | function getPixelCoordinates( 46 | worldXZCoordinates: Vector2, 47 | mapOffset: Vector2, 48 | mapSizeFactor: number, 49 | ): Vector2 { 50 | const offsetVector = worldXZCoordinates.add(mapOffset) 51 | const scaledVector = offsetVector.divide(100).multiply(mapSizeFactor) 52 | const finalVector = scaledVector.add(new Vector2(1024, 1024)) 53 | return finalVector 54 | } 55 | 56 | function readFile( 57 | fileName: string, 58 | filePath: string, 59 | fileValues: FileValues, 60 | ): Promise { 61 | return new Promise((resolve, reject) => { 62 | fs.createReadStream(filePath) 63 | .pipe(iconv.decodeStream('utf8')) 64 | .pipe(csv({ headers: false })) 65 | .on('data', (row: string[]) => { 66 | fileValues[fileName] = fileValues[fileName] || [] 67 | fileValues[fileName].push(row) 68 | }) 69 | .on('end', () => { 70 | resolve() 71 | }) 72 | .on('error', (err) => { 73 | reject(err) 74 | }) 75 | }) 76 | } 77 | 78 | const fileNames = [ 79 | 'EObjName.csv', 80 | 'Level.csv', 81 | 'TerritoryType.csv', 82 | 'PlaceName.csv', 83 | 'Map.csv', 84 | 'EObj.csv', 85 | ] 86 | const fileValues: FileValues = {} 87 | 88 | const allFiles = [ 89 | ...fileNames.map((fileName) => ({ 90 | name: fileName, 91 | path: csvPaths.ja + fileName, 92 | })), 93 | { name: 'PlaceName_CN.csv', path: `${csvPaths.cn}PlaceName.csv` }, 94 | { name: 'PlaceName_EN.csv', path: `${csvPaths.en}PlaceName.csv` }, 95 | ] 96 | 97 | await Promise.all( 98 | allFiles.map((file) => readFile(file.name, file.path, fileValues)), 99 | ) 100 | 101 | const aethercurrentNames = ['风脉泉', 'aether current', '風脈の泉'] 102 | const ids: string[] = fileValues['EObjName.csv']!.filter((row) => 103 | aethercurrentNames.includes(row[1]!), 104 | ).map((row) => row[0]!) 105 | 106 | const idsData: Record = Object.fromEntries( 107 | fileValues['EObj.csv']!.filter((row) => ids.includes(row[0]!)).map((row) => [ 108 | row[0], 109 | row[10], 110 | ]), 111 | ) 112 | 113 | const result = fileValues['Level.csv']!.filter((row) => ids.includes(row[7]!)) 114 | .map((row) => { 115 | const territory = row[10] 116 | const territoryType = fileValues['TerritoryType.csv']!.find( 117 | (t) => t[0] === territory, 118 | ) 119 | if (!territoryType) return null 120 | 121 | const placeNameId = territoryType[6] 122 | 123 | const findPlaceName = (file: string) => { 124 | const placeNameRow = fileValues[file]?.find((p) => p[0] === placeNameId) 125 | return placeNameRow?.[1] 126 | } 127 | 128 | const ja = findPlaceName('PlaceName.csv') 129 | const cn = findPlaceName('PlaceName_CN.csv') 130 | const en = findPlaceName('PlaceName_EN.csv') 131 | 132 | const map = fileValues['Map.csv']!.find((m) => m[12] === placeNameId) 133 | if (!map) return null 134 | 135 | const mapSizeFactor = Number(map[8]) 136 | const mapOffsetX = Number(map[9]) 137 | const mapOffsetY = Number(map[10]) 138 | 139 | const x = Number(row[1]) 140 | const z = Number(row[3]) 141 | 142 | const worldXZCoordinates = new Vector2(x, z) 143 | const mapOffset = new Vector2(mapOffsetX, mapOffsetY) 144 | const pixelCoordinates = getPixelCoordinates( 145 | worldXZCoordinates, 146 | mapOffset, 147 | mapSizeFactor, 148 | ) 149 | 150 | const id = map[7] 151 | const exVersion = `${+territoryType[30]! + 2}.0` 152 | const data = Number(idsData[row[7]!]) 153 | 154 | return { 155 | x: row[1], 156 | y: row[2], 157 | z: row[3], 158 | territory, 159 | name: { ja, cn, en }, 160 | game: { x: pixelCoordinates.x, y: pixelCoordinates.y }, 161 | id, 162 | exVersion, 163 | data, 164 | } as AetherCurrentResult 165 | }) 166 | .filter((row): row is AetherCurrentResult => row !== null) 167 | 168 | fs.outputJsonSync('src/resources/aethercurrent.json', result, { spaces: 2 }) 169 | -------------------------------------------------------------------------------- /src/pages/showBarrier.vue: -------------------------------------------------------------------------------- 1 | 112 | 113 | 142 | 143 | 168 | -------------------------------------------------------------------------------- /src/pages/aether.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 117 | 118 | 187 | -------------------------------------------------------------------------------- /src/styles/job.scss: -------------------------------------------------------------------------------- 1 | .job { 2 | color: white; 3 | } 4 | 5 | .gladiator { 6 | text-shadow: -1px 0 1.5px rgb(123, 154, 162), 0 1.5px 1.5px rgb(123, 154, 162), 1px 0 1.5px rgb(123, 154, 162), 0 -1.5px 1.5px rgb(123, 154, 162); 7 | } 8 | 9 | .pugilist { 10 | text-shadow: -1px 0 1.5px rgb(179, 137, 21), 0 1.5px 1.5px rgb(179, 137, 21), 1px 0 1.5px rgb(179, 137, 21), 0 -1.5px 1.5px rgb(179, 137, 21); 11 | } 12 | 13 | .marauder { 14 | text-shadow: -1px 0 1.5px rgb(169, 26, 22), 0 1.5px 1.5px rgb(169, 26, 22), 1px 0 1.5px rgb(169, 26, 22), 0 -1.5px 1.5px rgb(169, 26, 22); 15 | } 16 | 17 | .lancer { 18 | text-shadow: -1px 0 1.5px rgb(55, 82, 216), 0 1.5px 1.5px rgb(55, 82, 216), 1px 0 1.5px rgb(55, 82, 216), 0 -1.5px 1.5px rgb(55, 82, 216); 19 | } 20 | 21 | .archer { 22 | text-shadow: -1px 0 1.5px rgb(145, 186, 94), 0 1.5px 1.5px rgb(145, 186, 94), 1px 0 1.5px rgb(145, 186, 94), 0 -1.5px 1.5px rgb(145, 186, 94); 23 | } 24 | 25 | .conjurer { 26 | text-shadow: -1px 0 1.5px rgb(95, 95, 95), 0 1.5px 1.5px rgb(95, 95, 95), 1px 0 1.5px rgb(95, 95, 95), 0 -1.5px 1.5px rgb(95, 95, 95); 27 | } 28 | 29 | .thaumaturge { 30 | text-shadow: -1px 0 1.5px rgb(103, 69, 152), 0 1.5px 1.5px rgb(103, 69, 152), 1px 0 1.5px rgb(103, 69, 152), 0 -1.5px 1.5px rgb(103, 69, 152); 31 | } 32 | 33 | .paladin { 34 | text-shadow: -1px 0 1.5px rgb(123, 154, 162), 0 1.5px 1.5px rgb(123, 161, 162), 1px 0 1.5px rgb(123, 154, 162), 0 -1.5px 1.5px rgb(123, 154, 162); 35 | } 36 | 37 | .monk { 38 | text-shadow: -1px 0 1.5px rgb(179, 137, 21), 0 1.5px 1.5px rgb(179, 137, 21), 1px 0 1.5px rgb(179, 137, 21), 0 -1.5px 1.5px rgb(179, 137, 21); 39 | } 40 | 41 | .warrior { 42 | text-shadow: -1px 0 1.5px rgb(169, 26, 22), 0 1.5px 1.5px rgb(169, 26, 22), 1px 0 1.5px rgb(169, 26, 22), 0 -1.5px 1.5px rgb(169, 26, 22); 43 | } 44 | 45 | .dragoon { 46 | text-shadow: -1px 0 1.5px rgb(55, 82, 216), 0 1.5px 1.5px rgb(55, 82, 216), 1px 0 1.5px rgb(55, 82, 216), 0 -1.5px 1.5px rgb(55, 82, 216); 47 | } 48 | 49 | .bard { 50 | text-shadow: -1px 0 1.5px rgb(145, 186, 94), 0 1.5px 1.5px rgb(145, 186, 94), 1px 0 1.5px rgb(145, 186, 94), 0 -1.5px 1.5px rgb(145, 186, 94); 51 | } 52 | 53 | .whitemage { 54 | text-shadow: -1px 0 1.5px rgb(95, 95, 95), 0 1.5px 1.5px rgb(95, 95, 95), 1px 0 1.5px rgb(95, 95, 95), 0 -1.5px 1.5px rgb(95, 95, 95); 55 | } 56 | 57 | .blackmage { 58 | text-shadow: -1px 0 1.5px rgb(103, 69, 152), 0 1.5px 1.5px rgb(103, 69, 152), 1px 0 1.5px rgb(103, 69, 152), 0 -1.5px 1.5px rgb(103, 69, 152); 59 | } 60 | 61 | .arcanist { 62 | text-shadow: -1px 0 1.5px rgb(50, 103, 11), 0 1.5px 1.5px rgb(50, 103, 11), 1px 0 1.5px rgb(50, 103, 11), 0 -1.5px 1.5px rgb(50, 103, 11); 63 | } 64 | 65 | .summoner { 66 | text-shadow: -1px 0 1.5px rgb(50, 103, 11), 0 1.5px 1.5px rgb(50, 103, 11), 1px 0 1.5px rgb(50, 103, 11), 0 -1.5px 1.5px rgb(50, 103, 11); 67 | } 68 | 69 | .scholar { 70 | text-shadow: -1px 0 1.5px rgb(50, 48, 123), 0 1.5px 1.5px rgb(50, 48, 123), 1px 0 1.5px rgb(50, 48, 123), 0 -1.5px 1.5px rgb(50, 48, 123); 71 | } 72 | 73 | .rogue { 74 | text-shadow: -1px 0 1.5px rgb(238, 46, 72), 0 1.5px 1.5px rgb(238, 46, 72), 1px 0 1.5px rgb(238, 46, 72), 0 -1.5px 1.5px rgb(238, 46, 72); 75 | } 76 | 77 | .ninja { 78 | text-shadow: -1px 0 1.5px rgb(238, 46, 72), 0 1.5px 1.5px rgb(238, 46, 72), 1px 0 1.5px rgb(238, 46, 72), 0 -1.5px 1.5px rgb(238, 46, 72); 79 | } 80 | 81 | .machinist { 82 | text-shadow: -1px 0 1.5px rgb(20, 138, 169), 0 1.5px 1.5px rgb(20, 138, 169), 1px 0 1.5px rgb(20, 138, 169), 0 -1.5px 1.5px rgb(20, 138, 169); 83 | } 84 | 85 | .darkknight { 86 | text-shadow: -1px 0 1.5px rgb(104, 37, 49), 0 1.5px 1.5px rgb(104, 37, 49), 1px 0 1.5px rgb(104, 37, 49), 0 -1.5px 1.5px rgb(104, 37, 49); 87 | } 88 | 89 | .astrologian { 90 | text-shadow: -1px 0 1.5px rgb(177, 86, 28), 0 1.5px 1.5px rgb(177, 86, 28), 1px 0 1.5px rgb(177, 86, 28), 0 -1.5px 1.5px rgb(177, 86, 28); 91 | } 92 | 93 | .samurai { 94 | text-shadow: -1px 0 1.5px rgb(228, 90, 15), 0 1.5px 1.5px rgb(228, 90, 15), 1px 0 1.5px rgb(228, 90, 15), 0 -1.5px 1.5px rgb(228, 90, 15); 95 | } 96 | 97 | .redmage { 98 | text-shadow: -1px 0 1.5px rgb(172, 41, 151), 0 1.5px 1.5px rgb(172, 41, 151), 1px 0 1.5px rgb(172, 41, 151), 0 -1.5px 1.5px rgb(172, 41, 151); 99 | } 100 | 101 | .bluemage { 102 | text-shadow: -1px 0 1.5px rgb(24, 61, 154), 0 1.5px 1.5px rgb(24, 61, 154), 1px 0 1.5px rgb(24, 61, 154), 0 -1.5px 1.5px rgb(24, 61, 154); 103 | } 104 | 105 | .gunbreaker { 106 | text-shadow: -1px 0 1.5px rgb(121, 109, 48), 0 1.5px 1.5px rgb(121, 109, 48), 1px 0 1.5px rgb(121, 109, 48), 0 -1.5px 1.5px rgb(121, 109, 48); 107 | } 108 | 109 | .dancer { 110 | text-shadow: -1px 0 1.5px rgb(139, 110, 110), 0 1.5px 1.5px rgb(139, 110, 110), 1px 0 1.5px rgb(139, 110, 110), 0 -1.5px 1.5px rgb(139, 110, 110); 111 | } 112 | 113 | .reaper { 114 | text-shadow: -1px 0 1.5px rgb(76, 25, 76), 0 1.5px 1.5px rgb(76, 25, 76), 1px 0 1.5px rgb(76, 25, 76), 0 -1.5px 1.5px rgb(76, 25, 76); 115 | } 116 | 117 | .sage { 118 | text-shadow: -1px 0 1.5px rgb(52, 81, 160), 0 1.5px 1.5px rgb(52, 81, 160), 1px 0 1.5px rgb(52, 81, 160), 0 -1.5px 1.5px rgb(52, 81, 160); 119 | } 120 | 121 | .viper { 122 | text-shadow: -1px 0 1.5px #108210, 0 1.5px 1.5px #108210, 1px 0 1.5px #108210, 0 -1.5px 1.5px #108210; 123 | } 124 | 125 | .pictomancer { 126 | text-shadow: -1px 0 1.5px #fc92e1, 0 1.5px 1.5px #fc92e1, 1px 0 1.5px #fc92e1, 0 -1.5px 1.5px #fc92e1; 127 | } -------------------------------------------------------------------------------- /src/pages/lbTick.vue: -------------------------------------------------------------------------------- 1 | 108 | 109 | 136 | 137 | 210 | --------------------------------------------------------------------------------