feat: OCR

This commit is contained in:
Alexander Drozdov
2023-01-26 14:26:22 +02:00
parent 3d6df64613
commit 6b4473b8d9
21 changed files with 641 additions and 62 deletions
+13
View File
@@ -8,6 +8,7 @@ export interface HostConfig {
disableUpdateDownload: boolean
logLevel: string
windowTitle: string
language: string
}
export interface ShortcutAction {
@@ -17,6 +18,9 @@ export interface ShortcutAction {
type: 'copy-item'
focusOverlay?: boolean
target: string
} | {
type: 'ocr-text'
target: 'heist-gems'
} | {
type: 'trigger-event'
target: string
@@ -69,6 +73,7 @@ export type IpcEvent =
IpcHostConfig |
IpcWidgetAction |
IpcItemText |
IpcOcrText |
IpcConfigChanged |
IpcUserAction
@@ -142,6 +147,14 @@ type IpcItemText =
focusOverlay: boolean
}>
type IpcOcrText =
Event<'MAIN->CLIENT::ocr-text', {
target: string
pressTime: number
ocrTime: number
paragraphs: string[]
}>
type IpcGameLog =
Event<'MAIN->CLIENT::game-log', {
lines: string[]
+9
View File
@@ -19,6 +19,14 @@ const electronRunner = (() => {
}
})()
const visionBuild = esbuild.build({
entryPoints: ['src/vision/link-worker.ts'],
bundle: true,
platform: 'node',
outfile: 'dist/vision.js',
watch: isDev
})
const mainBuild = esbuild.build({
entryPoints: ['src/main.ts'],
bundle: true,
@@ -36,6 +44,7 @@ const mainBuild = esbuild.build({
})
Promise.all([
visionBuild,
mainBuild
])
.then(() => { if (isDev) electronRunner.restart() })
+1 -1
View File
@@ -6,7 +6,7 @@ files:
- "package.json"
- from: "dist"
to: "."
filter: ["main.js"]
filter: ["main.js", "vision.js"]
- from: "../renderer/dist"
to: "."
extraMetadata:
+3 -1
View File
@@ -16,7 +16,7 @@
},
"main": "dist/main.js",
"dependencies": {
"electron-overlay-window": "3.0.0",
"electron-overlay-window": "3.2.0",
"uiohook-napi": "1.5.x"
},
"devDependencies": {
@@ -27,6 +27,8 @@
"@types/ini": "^1.3.30",
"@types/node": "16.x.x",
"@types/ws": "^8.5.3",
"@wokwi/bmp-ts": "^3.0.0",
"comlink": "^4.3.1",
"electron": "19.1.9",
"electron-builder": "23.3.3",
"electron-updater": "^5.0.1",
+2 -2
View File
@@ -36,10 +36,10 @@ app.on('ready', async () => {
async () => {
const overlay = new OverlayWindow(eventPipe, logger, poeWindow, httpProxy)
new OverlayVisibility(eventPipe, overlay, gameConfig)
const shortcuts = new Shortcuts(logger, overlay, poeWindow, gameConfig, eventPipe)
const shortcuts = await Shortcuts.create(logger, overlay, poeWindow, gameConfig, eventPipe)
eventPipe.onEventAnyClient('CLIENT->MAIN::update-host-config', (cfg) => {
overlay.updateOpts(cfg.overlayKey, cfg.windowTitle)
shortcuts.updateActions(cfg.shortcuts, cfg.stashScroll, cfg.restoreClipboard)
shortcuts.updateActions(cfg.shortcuts, cfg.stashScroll, cfg.restoreClipboard, cfg.language)
gameLogWatcher.restart(cfg.clientLog)
gameConfig.readConfig(cfg.gameConfig)
appUpdater.updateOpts(!cfg.disableUpdateDownload)
+39 -3
View File
@@ -4,6 +4,7 @@ import { isModKey, KeyToElectron, mergeTwoHotkeys } from '../../../ipc/KeyToCode
import { typeInChat, stashSearch } from './text-box'
import { WidgetAreaTracker } from '../windowing/WidgetAreaTracker'
import { HostClipboard } from './HostClipboard'
import { OcrWorker } from '../vision/link-main'
import type { ShortcutAction } from '../../../ipc/types'
import type { Logger } from '../RemoteLogger'
import type { OverlayWindow } from '../windowing/OverlayWindow'
@@ -20,12 +21,25 @@ export class Shortcuts {
private areaTracker: WidgetAreaTracker
private clipboard: HostClipboard
constructor (
static async create (
logger: Logger,
overlay: OverlayWindow,
poeWindow: GameWindow,
gameConfig: GameConfig,
server: ServerEvents
) {
const ocrWorker = await OcrWorker.create()
const shortcuts = new Shortcuts(logger, overlay, poeWindow, gameConfig, server, ocrWorker)
return shortcuts
}
private constructor (
private logger: Logger,
private overlay: OverlayWindow,
private poeWindow: GameWindow,
private gameConfig: GameConfig,
private server: ServerEvents
private server: ServerEvents,
private ocrWorker: OcrWorker
) {
this.areaTracker = new WidgetAreaTracker(server, overlay)
this.clipboard = new HostClipboard(logger)
@@ -69,9 +83,10 @@ export class Shortcuts {
})
}
updateActions (actions: ShortcutAction[], stashScroll: boolean, restoreClipboard: boolean) {
updateActions (actions: ShortcutAction[], stashScroll: boolean, restoreClipboard: boolean, language: string) {
this.stashScroll = stashScroll
this.clipboard.updateOptions(restoreClipboard)
this.ocrWorker.updateOptions(language)
const copyItemShortcut = mergeTwoHotkeys('Ctrl + C', this.gameConfig.showModsKey)
if (copyItemShortcut !== 'Ctrl + C') {
@@ -154,6 +169,27 @@ export class Shortcuts {
(entry.keepModKeys) ? entry.shortcut.split(' + ').filter(key => isModKey(key)) : undefined,
this.gameConfig.showModsKey
)
} else if (entry.action.type === 'ocr-text' && entry.action.target === 'heist-gems') {
if (process.platform !== 'win32') return
const { action } = entry
const pressTime = Date.now()
const imageData = this.poeWindow.screenshot()
this.ocrWorker.findHeistGems({
width: this.poeWindow.bounds.width,
height: this.poeWindow.bounds.height,
data: imageData
}).then(result => {
this.server.sendEventTo('last-active', {
name: 'MAIN->CLIENT::ocr-text',
payload: {
target: action.target,
pressTime,
ocrTime: result.elapsed,
paragraphs: result.recognized.map(p => p.text)
}
})
}).catch(() => {})
}
})
+125
View File
@@ -0,0 +1,125 @@
import fs from 'fs/promises'
import Bmp from '@wokwi/bmp-ts'
import * as Bindings from './wasm-bindings'
import { cv, tessApi } from './wasm-bindings'
import {
findNonZeroWeights,
groupWeightedPoints,
findLines,
hsvToU8,
timeIt,
ImageData
} from './utils'
const REFERENCE_HEIGHT = 600
const TEMPLATE_THRESHOLD = 0.75
const LINE_Y_DIST_TOLERANCE = 4
const TEXT_HSV_MIN = hsvToU8(173, 31, 31)
const TEXT_HSV_MAX = hsvToU8(180, 100, 100)
interface OcrResult {
elapsed: number
matches: number
clusters: number
linesMin: number
linesMax: number
recognized: Array<{ text: string, confidence: number }>
}
export class HeistGemFinder {
private constructor (
private readonly needleMat: any,
private readonly hsvMin: any,
private readonly hsvMax: any
) {}
static async create (binDir: string): Promise<HeistGemFinder> {
const needleImg = Bmp.decode(await fs.readFile(binDir + '/heist-lock.bmp'), { toRGBA: true })
const needleMat = Bindings.cvMatFromImage(needleImg)
cv.cvtColor(needleMat, needleMat, cv.COLOR_RGBA2GRAY)
const hsvMin = new cv.Mat(3, 1, cv.CV_8U)
hsvMin.data.set(TEXT_HSV_MIN)
const hsvMax = new cv.Mat(3, 1, cv.CV_8U)
hsvMax.data.set(TEXT_HSV_MAX)
return new HeistGemFinder(needleMat, hsvMin, hsvMax)
}
ocrScreenshot (screenshot: ImageData): OcrResult {
let elapsed = 0
const colorMat = Bindings.cvMatFromImage(screenshot)
const scale = screenshot.height / REFERENCE_HEIGHT
const graySize = new cv.Size(Math.floor(screenshot.width / scale), REFERENCE_HEIGHT)
const grayMat = new cv.Mat()
elapsed += timeIt(() => {
if (scale > 2.1) {
cv.resize(colorMat, grayMat, new cv.Size(graySize.width * 2, graySize.height * 2), 0, 0, cv.INTER_LINEAR)
cv.resize(grayMat, grayMat, new cv.Size(graySize.width, graySize.height), 0, 0, cv.INTER_LINEAR)
} else {
cv.resize(colorMat, grayMat, new cv.Size(graySize.width, graySize.height), 0, 0, cv.INTER_LINEAR)
}
cv.cvtColor(grayMat, grayMat, cv.COLOR_BGR2GRAY)
})
const { needleMat } = this
const matchesMat = grayMat
let matches: ReturnType<typeof findNonZeroWeights>
elapsed += timeIt(() => {
cv.matchTemplate(grayMat, needleMat, matchesMat, cv.TM_CCOEFF_NORMED)
cv.threshold(matchesMat, matchesMat, TEMPLATE_THRESHOLD, 1, cv.THRESH_TOZERO)
matches = findNonZeroWeights(matchesMat)
})
matchesMat.delete()
const clusteredMatches = groupWeightedPoints(matches!, Math.hypot(needleMat.cols, needleMat.rows))
const lines = findLines(clusteredMatches, LINE_Y_DIST_TOLERANCE)
const recognizedLines: OcrResult['recognized'] = []
for (const line of lines) {
const topLeft = new cv.Point(
(Math.min(line[0].x, line[1].x) + needleMat.cols) * scale,
(Math.min(line[0].y, line[1].y) - 1) * scale)
const bottomRight = new cv.Point(
Math.max(line[0].x, line[1].x) * scale,
(Math.max(line[0].y, line[1].y) + needleMat.rows) * scale)
const roiSize = new cv.Size(bottomRight.x - topLeft.x, bottomRight.y - topLeft.y)
const roiRect = new cv.Rect(topLeft, roiSize)
const roiColor = colorMat.roi(roiRect)
const roiHsv = new cv.Mat()
elapsed += timeIt(() => {
cv.cvtColor(roiColor, roiHsv, cv.COLOR_BGR2HSV_FULL)
cv.inRange(roiHsv, this.hsvMin, this.hsvMax, roiHsv)
cv.bitwise_not(roiHsv, roiHsv)
})
roiColor.delete()
Bindings.ocrSetImage(roiHsv.data, roiHsv.cols, roiHsv.rows, roiHsv.channels())
roiHsv.delete()
tessApi.SetVariable('tessedit_pageseg_mode', '7') // single line mode
elapsed += timeIt(() => {
tessApi.Recognize()
})
const text = tessApi.GetUTF8Text().trim()
const confidence = tessApi.MeanTextConf()
if (text.length > 0 && confidence > 30) {
recognizedLines.push({ text, confidence })
}
}
colorMat.delete()
const linesWeight = lines.flatMap(([p0, p1]) => ([p0.weight, p1.weight]))
const results = {
elapsed,
matches: matches!.length,
clusters: clusteredMatches.length,
linesMin: Math.min(...linesWeight),
linesMax: Math.max(...linesWeight),
recognized: recognizedLines
}
// console.log(results)
return results
}
}
+42
View File
@@ -0,0 +1,42 @@
import { Worker } from 'worker_threads'
import * as Comlink from 'comlink'
import nodeEndpoint from 'comlink/dist/umd/node-adapter'
import type { WorkerAPI } from './link-worker'
import type { ImageData } from './utils'
import { app } from 'electron'
import path from 'path'
export class OcrWorker {
private binDir = path.join(app.getPath('userData'), 'apt-data/cv-ocr')
private api: Comlink.Remote<WorkerAPI>
private lang = ''
private constructor () {
const worker = new Worker(__dirname + '/vision.js')
this.api = Comlink.wrap<WorkerAPI>(nodeEndpoint(worker))
}
static async create () {
const worker = new OcrWorker()
try {
await worker.api.init(worker.binDir)
} catch {}
return worker
}
async updateOptions (lang: string) {
try {
if (lang !== this.lang) {
await this.api.changeLanguage(lang, this.binDir)
}
} catch {} finally {
this.lang = lang
}
}
async findHeistGems (image: ImageData) {
const result = await this.api.findHeistGems(
Comlink.transfer(image, [image.data.buffer]))
return result
}
}
+28
View File
@@ -0,0 +1,28 @@
import { parentPort } from 'worker_threads'
import * as Comlink from 'comlink'
import nodeEndpoint from 'comlink/dist/umd/node-adapter'
import * as Bindings from './wasm-bindings'
import { HeistGemFinder } from './HeistGemFinder'
import { ImageData } from './utils'
let _heistGems: HeistGemFinder
let _changeLangPromise = Promise.resolve()
const WorkerBody = {
async init (binDir: string) {
await Bindings.init(binDir)
_heistGems = await HeistGemFinder.create(binDir)
},
async changeLanguage (lang: string, binDir: string) {
await _changeLangPromise
_changeLangPromise = Bindings.changeLanguage(lang, binDir)
await _changeLangPromise
},
async findHeistGems (screenshot: ImageData) {
await _changeLangPromise
return _heistGems.ocrScreenshot(screenshot)
}
}
Comlink.expose(WorkerBody, nodeEndpoint(parentPort!))
export type WorkerAPI = Comlink.Remote<typeof WorkerBody>
+75
View File
@@ -0,0 +1,75 @@
import { cv } from './wasm-bindings'
export interface ImageData {
width: number
height: number
data: Uint8Array
}
export interface WeightedPoint {
x: number
y: number
weight: number
}
export type LinePoints = [WeightedPoint, WeightedPoint]
export function findNonZeroWeights (matchResult: any): WeightedPoint[] {
const locations = new cv.Mat()
cv.findNonZero(matchResult, locations)
const weights = Array<WeightedPoint>(locations.rows)
for (let i = 0; i < locations.rows; ++i) {
const x = locations.intAt(i, 0)
const y = locations.intAt(i, 1)
const weight = matchResult.floatAt(y, x)
weights[i] = { x, y, weight }
}
locations.delete()
return weights
}
export function groupWeightedPoints (weights: WeightedPoint[], radius: number): WeightedPoint[] {
// similar to non-maximum suppression
const maxWeighted: WeightedPoint[] = []
for (const point of weights) {
const closeIdx = maxWeighted.findIndex(maxPoint => {
const dist = Math.hypot(point.x - maxPoint.x, point.y - maxPoint.y)
return dist < radius
})
if (closeIdx === -1) {
maxWeighted.push(point)
} else if (point.weight > maxWeighted[closeIdx].weight) {
maxWeighted[closeIdx] = point
}
}
return maxWeighted
}
export function findLines (points: WeightedPoint[], yTolerance: number): LinePoints[] {
points.sort((a, b) => a.x - b.x)
const lines: LinePoints[] = []
for (let idxA = 0; idxA < points.length; ++idxA) {
for (let idxB = idxA + 1; idxB < points.length; ++idxB) {
const pointA = points[idxA]
const pointB = points[idxB]
if (Math.abs(pointA.y - pointB.y) > yTolerance) continue
lines.push([pointA, pointB])
break
}
}
return lines
}
export function hsvToU8 (h: number, s: number, v: number) {
return [
Math.round(h * 255 / 360),
Math.round(s * 255 / 100),
Math.round(v * 255 / 100),
]
}
export function timeIt (syncFn: () => void): number {
const startTime = performance.now()
syncFn()
return performance.now() - startTime
}
+56
View File
@@ -0,0 +1,56 @@
import fs from 'fs/promises'
import type { ImageData } from './utils'
let tessModule: any
export let tessApi: any
export let cv: any
const langMap = new Map([
['en', 'eng'],
['ru', 'rus'],
// ['cmn-Hant', 'chi_tra'],
])
export async function init (binDir: string) {
if (process.platform !== 'win32') {
// so far only tested on Windows with BGRA images
throw new Error('Unsupported platform')
}
const tessInstantiate = (await import('file://' + binDir + '/tesseract-core-simd.js')).default
tessModule = await tessInstantiate()
tessApi = new tessModule.TessBaseAPI()
const cvPromise = (await import('file://' + binDir + '/opencv.js')).default
cv = await cvPromise
}
export async function changeLanguage (lang: string, binDir: string) {
if (!langMap.has(lang)) {
throw new Error('Unsupported language')
}
lang = langMap.get(lang)!
const langData = await fs.readFile(binDir + `/${lang}.traineddata`)
tessModule.FS.writeFile(`${lang}.traineddata`, langData)
if (tessApi.Init(null, lang, tessModule.OEM_DEFAULT)) {
throw new Error('Could not initialize tesseract.')
}
tessModule.FS.unlink(`${lang}.traineddata`)
}
export function ocrSetImage (data: Uint8Array, width: number, height: number, bpp: number) {
const imgPtr = tessModule._malloc(data.byteLength)
tessModule.HEAPU8.set(data, imgPtr)
if (bpp === 0) {
tessApi.SetImage(imgPtr, width, height, 0, Math.ceil(width / 8))
} else {
tessApi.SetImage(imgPtr, width, height, bpp, width * bpp)
}
tessModule._free(imgPtr)
}
export function cvMatFromImage (img: ImageData) {
const mat = new cv.Mat(img.height, img.width, cv.CV_8UC4)
mat.data.set(img.data)
return mat
}
+4
View File
@@ -44,4 +44,8 @@ export class GameWindow extends EventEmitter {
cb(e.hasAccess)
})
}
screenshot () {
return OverlayController.screenshot()
}
}
+21 -4
View File
@@ -216,6 +216,11 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.6.tgz#87846192fd51b693368fad3e99123169225621d4"
integrity sha512-vmYJF0REqDyyU0gviezF/KHq/fYaUbFhkcNbQCuPGFQj6VTbXuHZoxs/Y7mutWe73C8AC6l9fFu8mSYiBAqkGA==
"@types/node@^10.11.7":
version "10.17.60"
resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.60.tgz#35f3d6213daed95da7f0f73e75bcc6980e90597b"
integrity sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==
"@types/plist@^3.0.1":
version "3.0.2"
resolved "https://registry.yarnpkg.com/@types/plist/-/plist-3.0.2.tgz#61b3727bba0f5c462fe333542534a0c3e19ccb01"
@@ -253,6 +258,13 @@
dependencies:
"@types/yargs-parser" "*"
"@wokwi/bmp-ts@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@wokwi/bmp-ts/-/bmp-ts-3.0.0.tgz#826b635268f32c69784089a1b56843ffc1dfb455"
integrity sha512-fBoRZQEd8RHdFq4gaS6eU69fYZjqGIz8myYPLm0gJSosxqHWpge8/3+6yCyc8b8afcb/ptjSlnEVP93t00fWDQ==
dependencies:
"@types/node" "^10.11.7"
abort-controller@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392"
@@ -676,6 +688,11 @@ combined-stream@^1.0.8:
dependencies:
delayed-stream "~1.0.0"
comlink@^4.3.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/comlink/-/comlink-4.3.1.tgz#0c6b9d69bcd293715c907c33fe8fc45aecad13c5"
integrity sha512-+YbhUdNrpBZggBAHWcgQMLPLH1KDF3wJpeqrCKieWQ8RL7atmgsgTQko1XEBK6PsecfopWNntopJ+ByYG1lRaA==
commander@2.9.0:
version "2.9.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.9.0.tgz#9c99094176e12240cb22d6c5146098400fe0f7d4"
@@ -937,10 +954,10 @@ electron-osx-sign@^0.6.0:
minimist "^1.2.0"
plist "^3.0.1"
electron-overlay-window@3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/electron-overlay-window/-/electron-overlay-window-3.0.0.tgz#d34c4d97190f26a99d46271a61031faefdf10fa3"
integrity sha512-7mexgNUEgAJaxorG3aa1k9YIr1V9LkMjt7ZhPfG4Eq1AfQEgWyfHUrndQDw75/JohnyNFPzG/fVaCimDi4mcVA==
electron-overlay-window@3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/electron-overlay-window/-/electron-overlay-window-3.2.0.tgz#caedec2b9418e8260c9b9debdda4ab00e242f76d"
integrity sha512-EqUucgLJw3QnnMn0S2yEDC3MVcAEE7Aa87VSdhnepeCvKKKc7nwgo/Trfl6ritLXrbH3lSIdOGdMcS9g3M4jGA==
dependencies:
node-gyp-build "4.x.x"
throttle-debounce "5.x.x"
+1
View File
@@ -16,6 +16,7 @@
"apexcharts": "^3.23.1",
"dot-prop": "7.x.x",
"fast-deep-equal": "^3.1.3",
"fastest-levenshtein": "^1.0.16",
"luxon": "3.x.x",
"object-hash": "^3.0.0",
"sockette": "^2.0.6",
+21
View File
@@ -12,6 +12,8 @@ export let ITEM_BY_TRANSLATED = (ns: BaseType['namespace'], name: string): BaseT
export let ITEM_BY_REF = (ns: BaseType['namespace'], name: string): BaseType[] | undefined => undefined
export let ITEMS_ITERATOR = function * (includes: string, andIncludes?: string[]): Generator<BaseType> {}
export let ALTQ_GEM_NAMES = function * (): Generator<string> {}
export let STAT_BY_MATCH_STR = (name: string): { matcher: StatMatcher, stat: Stat } | undefined => undefined
export let STAT_BY_REF = (name: string): Stat | undefined => undefined
export let STATS_ITERATOR = function * (includes: string, andIncludes?: string[]): Generator<Stat> {}
@@ -52,6 +54,24 @@ function ndjsonFindLines<T> (ndjson: string) {
}
}
function itemNamesFromLines (items: Generator<BaseType>) {
let cached = ''
return function * (): Generator<string> {
if (!cached.length) {
for (const item of items) {
cached += (item.name + '\n')
}
}
let start = 0
while (start !== cached.length) {
const end = cached.indexOf('\n', start)
yield cached.slice(start, end)
start = end + 1
}
}
}
async function loadItems (language: string) {
const ndjson = await (await fetch(`${import.meta.env.BASE_URL}data/${language}/items.ndjson`)).text()
const INDEX_WIDTH = 2
@@ -80,6 +100,7 @@ async function loadItems (language: string) {
ITEM_BY_TRANSLATED = commonFind(indexNames, 'name')
ITEM_BY_REF = commonFind(indexRefNames, 'refName')
ITEMS_ITERATOR = ndjsonFindLines<BaseType>(ndjson)
ALTQ_GEM_NAMES = itemNamesFromLines(ITEMS_ITERATOR('altQuality":["Anomalous'))
}
async function loadStats (language: string) {
+14 -2
View File
@@ -499,9 +499,11 @@ function upgradeConfig (_config: Config): Config {
if (config.configVersion < 15) {
const priceCheck = config.widgets.find(w => w.wmType === 'price-check') as widget.PriceCheckWidget
priceCheck.builtinBrowser = false
const itemSearch = config.widgets.find(w => w.wmType === 'item-search') as widget.ItemSearchWidget
itemSearch.ocrGemsKey = null
config.configVersion = 15
}
@@ -610,6 +612,15 @@ function getConfigForHost (): HostConfig {
}
})
}
} else if (widget.wmType === 'item-search') {
const itemSearch = widget as widget.ItemSearchWidget
if (itemSearch.ocrGemsKey) {
actions.push({
shortcut: itemSearch.ocrGemsKey,
keepModKeys: true,
action: { type: 'ocr-text', target: 'heist-gems' }
})
}
}
}
@@ -622,6 +633,7 @@ function getConfigForHost (): HostConfig {
overlayKey: config.overlayKey,
disableUpdateDownload: config.disableUpdateDownload,
logLevel: config.logLevel,
windowTitle: config.windowTitle
windowTitle: config.windowTitle,
language: config.language
}
}
+139 -49
View File
@@ -1,10 +1,11 @@
<template>
<widget :config="config" move-handles="tl" readonly :removable="false">
<div class="widget-default-style p-1" style="min-width: 24rem;">
<div v-if="starred.length"
class="mb-1 flex gap-x-2 py-1 pl-1 pr-2 bg-gray-800 rounded">
<div v-for="item in starred" :key="item.name"
class="flex flex-col">
<widget :config="config" :move-handles="['tl', 'bl']" :removable="false" :inline-edit="false">
<div class="widget-default-style flex flex-col p-1 gap-1" style="min-width: 24rem;">
<transition-group v-if="starred.length" tag="div"
:enter-active-class="$style.starredItemEnter"
class="flex gap-x-1 py-1 pr-1 bg-gray-800 rounded">
<div v-for="item in starred" :key="item.name + item.discr"
:class="$style.starredItem">
<item-quick-price
:item-img="item.icon"
:price="item.price"
@@ -14,59 +15,67 @@
<div v-if="item.discr"
class="ml-1 truncate" style="max-width: 7rem;">{{ t(item.discr) }}</div>
</div>
</div>
<div class="flex gap-x-1 bg-gray-800 p-1 rounded-t">
<input type="text" :placeholder="t('Search by name…')" class="rounded bg-gray-900 px-1 flex-1"
v-model="searchValue">
<button @click="clearItems" class="btn"><i class="fas fa-times" /> {{ t('Reset items') }}</button>
</div>
<div class="flex bg-gray-800 gap-x-2 px-2 mb-px1 py-1">
<span>{{ t('Heist target:') }}</span>
<div class="flex gap-x-1">
<button :class="{ 'border': (typeFilter === 'gem') }" class="rounded px-2 bg-gray-900"
@click="typeFilter = 'gem'">{{ t('Skill Gem') }}</button>
<button :class="{ 'border': (typeFilter === 'replica') }" class="rounded px-2 bg-gray-900"
@click="typeFilter = 'replica'">{{ t('Replicas') }}, <span class="line-through text-gray-600">Base items</span></button>
</transition-group>
<ui-timeout v-if="!showSearch"
ref="showTimeout"
@timeout="makeInvisible"
class="self-center" :ms="4000" />
<div v-else class="bg-gray-800 rounded">
<div class="flex gap-x-1 p-1">
<input type="text" :placeholder="t('Search by name…')" class="rounded bg-gray-900 px-1 flex-1"
v-model="searchValue">
<button @click="clearItems" class="btn"><i class="fas fa-times" /> {{ t('Reset items') }}</button>
</div>
</div>
<div class="flex flex-col bg-gray-800 rounded-b">
<div v-for="item in (results || [])" :key="item.name">
<div class="flex" :class="$style.itemWrapper">
<div class="w-8 h-8 flex items-center justify-center">
<img :src="item.icon" class="max-w-full max-h-full overflow-hidden">
</div>
<div>
<div class="h-8 flex items-center px-1">{{ item.name }}</div>
<div v-if="item.gem" class="flex gap-x-1">
<button v-for="altQuality in item.gem.altQuality" :key="altQuality"
@click="selectItem(item, { altQuality })"
>{{ t(altQuality) }}</button>
<div class="flex gap-x-2 px-2 mb-px1 py-1">
<span>{{ t('Heist target:') }}</span>
<div class="flex gap-x-1">
<button :class="{ 'border': (typeFilter === 'gem') }" class="rounded px-2 bg-gray-900"
@click="typeFilter = 'gem'">{{ t('Skill Gem') }}</button>
<button :class="{ 'border': (typeFilter === 'replica') }" class="rounded px-2 bg-gray-900"
@click="typeFilter = 'replica'">{{ t('Replicas') }}, <span class="line-through text-gray-600">Base items</span></button>
</div>
</div>
<div class="flex flex-col">
<div v-for="item in (results || [])" :key="item.name">
<div class="flex" :class="$style.itemWrapper">
<div class="w-8 h-8 flex items-center justify-center">
<img :src="item.icon" class="max-w-full max-h-full overflow-hidden">
</div>
<div v-else-if="item.unique" class="flex gap-x-1">
<button @click="selectItem(item, { unique: true })"
>{{ t('Select') }}</button>
<div>
<div class="h-8 flex items-center px-1">{{ item.name }}</div>
<div v-if="item.gem" class="flex gap-x-1">
<button v-for="altQuality in item.gem.altQuality" :key="altQuality"
@click="selectItem(item, { altQuality })"
>{{ t(altQuality) }}</button>
</div>
<div v-else-if="item.unique" class="flex gap-x-1">
<button @click="selectItem(item, { unique: true })"
>{{ t('Select') }}</button>
</div>
</div>
</div>
</div>
<div v-if="results === false"
class="text-center p-8 max-w-xs"><i class="fas fa-search" /> {{ t('too_many') }}</div>
<div v-else-if="!results.length"
class="text-center p-8 max-w-xs"><i class="fas fa-exclamation-triangle" /> {{ t('not_found') }}</div>
</div>
<div v-if="results === false"
class="text-center p-8 max-w-xs"><i class="fas fa-search" /> {{ t('too_many') }}</div>
<div v-else-if="!results.length"
class="text-center p-8 max-w-xs"><i class="fas fa-exclamation-triangle" /> {{ t('not_found') }}</div>
</div>
</div>
</widget>
</template>
<script lang="ts">
import { defineComponent, PropType, shallowRef, ref, computed } from 'vue'
import { defineComponent, PropType, shallowRef, ref, computed, nextTick, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import { ItemSearchWidget } from './interfaces'
import { distance } from 'fastest-levenshtein'
import { ItemSearchWidget, WidgetManager } from './interfaces'
import ItemQuickPrice from '@/web/ui/ItemQuickPrice.vue'
import Widget from './Widget.vue'
import { BaseType, ITEMS_ITERATOR } from '@/assets/data'
import { BaseType, ITEMS_ITERATOR, CLIENT_STRINGS as _$, ALTQ_GEM_NAMES, ITEM_BY_TRANSLATED } from '@/assets/data'
import { AppConfig } from '@/web/Config'
import { findPriceByQuery, autoCurrency } from '@/web/background/Prices'
import { Host } from '@/web/background/IPC'
interface SelectedItem {
name: string
@@ -80,6 +89,11 @@ function useSelectedItems () {
const items = ref<SelectedItem[]>([])
function addItem (newItem: SelectedItem) {
if (items.value.some(item =>
item.name === newItem.name &&
item.discr === newItem.discr
)) return false
if (items.value.length < 5) {
items.value.push(newItem)
items.value.sort((a, b) => {
@@ -88,6 +102,7 @@ function useSelectedItems () {
} else {
items.value = [newItem]
}
return true
}
function clearItems () {
@@ -133,6 +148,35 @@ function findItems (opts: {
return out.slice(0, MAX_RESULTS_VISIBLE)
}
function fuzzyFindHeistGem (badStr: string) {
badStr = badStr.toLowerCase()
const qualities = [
['Anomalous', _$.QUALITY_ANOMALOUS.toString().slice(2, -2)],
['Divergent', _$.QUALITY_DIVERGENT.toString().slice(2, -2)],
['Phantasmal', _$.QUALITY_PHANTASMAL.toString().slice(2, -2)]
]
let bestMatch: { name: string, altQuality: string }
let minDist = Infinity
for (const name of ALTQ_GEM_NAMES()) {
for (const [altQuality, reStr] of qualities) {
const exactStr = reStr.replace('(.*)', name).toLowerCase()
if (Math.abs(exactStr.length - badStr.length) > 5) {
continue
}
const dist = distance(badStr, exactStr)
if (dist < minDist) {
bestMatch = { name, altQuality }
if (dist === 0) return bestMatch
minDist = dist
}
}
}
return bestMatch!
}
export default defineComponent({
components: { Widget, ItemQuickPrice },
props: {
@@ -141,13 +185,34 @@ export default defineComponent({
required: true
}
},
setup () {
setup (props) {
const wm = inject<WidgetManager>('wm')!
const { t } = useI18n()
const showTimeout = shallowRef<{ reset:() => void } | null>(null)
nextTick(() => {
props.config.wmFlags = ['invisible-on-blur']
})
const searchValue = shallowRef('')
const { items: starred, addItem, clearItems } = useSelectedItems()
const typeFilter = shallowRef<'gem' | 'replica'>('gem')
function selectItem (item: BaseType, opts: { altQuality?: string, unique?: true }) {
Host.onEvent('MAIN->CLIENT::ocr-text', (e) => {
if (e.target !== 'heist-gems') return
for (const para of e.paragraphs) {
const res = fuzzyFindHeistGem(para)
selectItem(
ITEM_BY_TRANSLATED('GEM', res.name)![0],
{ altQuality: res.altQuality, withTimeout: true }
)
}
})
function selectItem (item: BaseType, opts: { altQuality?: string, unique?: true, withTimeout?: true }) {
let price: ReturnType<typeof findPriceByQuery>
if (opts.altQuality) {
price = findPriceByQuery({
@@ -162,18 +227,20 @@ export default defineComponent({
variant: item.unique!.base
})
}
addItem({
const isAdded = addItem({
name: item.name,
icon: item.icon,
discr: opts.altQuality,
chaos: price?.chaos,
price: (price != null) ? autoCurrency(price.chaos) : undefined
})
if (isAdded && opts.withTimeout) {
showTimeout.value?.reset()
props.config.wmFlags = []
}
searchValue.value = ''
}
const { t } = useI18n()
return {
t,
searchValue,
@@ -198,8 +265,16 @@ export default defineComponent({
}
}),
selectItem,
clearItems,
starred
clearItems () {
clearItems()
props.config.wmFlags = ['invisible-on-blur']
},
starred,
showSearch: wm.active,
showTimeout,
makeInvisible () {
props.config.wmFlags = ['invisible-on-blur']
}
}
}
})
@@ -225,6 +300,21 @@ export default defineComponent({
@apply bg-gray-700;
}
}
.starredItem {
display: flex;
flex-direction: column;
@apply rounded px-1;
}
@keyframes starredItemEnter {
0% { @apply bg-transparent; }
50% { @apply bg-gray-700; }
100% { @apply bg-transparent; }
}
.starredItemEnter {
animation: starredItemEnter 0.8s linear;
}
</style>
<i18n>
+1
View File
@@ -85,4 +85,5 @@ export interface ImageStripWidget extends Widget {
export interface ItemSearchWidget extends Widget {
anchor: Anchor
ocrGemsKey: string | null
}
@@ -65,6 +65,7 @@ import SettingsDebug from './debug.vue'
import SettingsMaps from './maps/maps.vue'
import SettingsStashSearch from './stash-search.vue'
import SettingsStopwatch from './stopwatch.vue'
import SettingsItemSearch from './item-search.vue'
function shuffle<T> (array: T[]): T[] {
let currentIndex = array.length
@@ -190,6 +191,8 @@ function menuByType (type?: string) {
return [[SettingsItemcheck, SettingsMaps]]
case 'price-check':
return [[SettingsPricecheck]]
case 'item-search':
return [[SettingsItemSearch]]
default:
return [
[SettingsHotkeys, SettingsChat],
@@ -351,6 +354,7 @@ function flatJoin<T, J> (arr: T[][], joinEl: () => J) {
"Price check": "Прайс-чек",
"Maps": "Карты",
"Item info": "Проверка предмета",
"Item search": "Поиск предметов",
"Debug": "Debug",
"Quit": "Выход",
"Chat": "Чат",
+38
View File
@@ -0,0 +1,38 @@
<template>
<div class="max-w-md p-2">
<div class="mb-4 flex">
<label class="flex-1">{{ t('Perform an OCR for a Skill Gem') }}</label>
<hotkey-input v-model="ocrGemsKey" class="w-48" />
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { useI18n } from 'vue-i18n'
import { configProp, configModelValue, findWidget } from './utils'
import { ItemSearchWidget } from '@/web/overlay/interfaces'
import HotkeyInput from './HotkeyInput.vue'
export default defineComponent({
name: 'Item search',
components: { HotkeyInput },
props: configProp(),
setup (props) {
const { t } = useI18n()
return {
t,
ocrGemsKey: configModelValue(() => findWidget<ItemSearchWidget>('item-search', props.config)!, 'ocrGemsKey')
}
}
})
</script>
<i18n>
{
"ru": {
"Perform an OCR for a Skill Gem": "Выполнить OCR для гема"
}
}
</i18n>
+5
View File
@@ -1218,6 +1218,11 @@ fast-levenshtein@^2.0.6:
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
fastest-levenshtein@^1.0.16:
version "1.0.16"
resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz#210e61b6ff181de91ea9b3d1b84fdedd47e034e5"
integrity sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==
fastq@^1.6.0:
version "1.13.0"
resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c"