diff --git a/electron/main/ipc/cursorPacks.ts b/electron/main/ipc/cursorPacks.ts index e012e85d..36ee9f20 100644 --- a/electron/main/ipc/cursorPacks.ts +++ b/electron/main/ipc/cursorPacks.ts @@ -1,9 +1,11 @@ import { ipcMain, app } from 'electron'; import { promises as fs } from 'fs'; import { basename, dirname, extname, join } from 'path'; -import type { CursorPacksState, CursorPreview } from '../../../src/types/electron'; +import type { CursorImageFile, CursorPacksState, CursorPreview } from '../../../src/types/electron'; import { + createCursorPack, deleteCursorPack, + exportCursorZip, getCursorPacks, getCursorPreview, installCursorArchive, @@ -56,3 +58,14 @@ ipcMain.handle('cursors:import-files', async (_, paths: string[]): Promise => { + const deadlockPath = requireDeadlockPath(); + const [pack] = await createCursorPack(deadlockPath, name, files); + if (!pack) throw new Error('None of the cursor images could be used.'); + return setActiveCursorPack(deadlockPath, pack.id); +}); + +ipcMain.handle('cursors:export', (_, destPath: string, files: CursorImageFile[]): Promise => + exportCursorZip(getActiveDeadlockPath(), destPath, files) +); diff --git a/electron/main/services/cursorFiles.test.ts b/electron/main/services/cursorFiles.test.ts index 5f784c65..1b10cd5c 100644 --- a/electron/main/services/cursorFiles.test.ts +++ b/electron/main/services/cursorFiles.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { bmpWithExplicitAlpha, groupCursorFiles, isCursorFileName, isUsableCursorFile } from './cursorFiles'; +import { + bmpWithExplicitAlpha, + formatCursorRes, + groupCursorFiles, + isCursorFileName, + isUsableCursorFile, + parseCursorRes, +} from './cursorFiles'; describe('isCursorFileName', () => { it('accepts the stock cursor set, size variants and cursor.res', () => { @@ -24,6 +31,45 @@ describe('isUsableCursorFile', () => { }); }); +describe('cursor.res', () => { + const stock = `// This file is only needed when using SDL, since it opens the cursors +// as BMP files instead of loading .ani files. +"resource/cursor/cursor.res" +{ + cursor_default + { + "hotx" "0" + "hoty" "0" + } + + // Aim with the tip of the thumb. + cursor_commend + { + "hotx" "22" + "hoty" "2" + } + +} +`; + + it('reads the stock file', () => { + expect(parseCursorRes(stock)).toEqual( + new Map([ + ['cursor_default', { x: 0, y: 0 }], + ['cursor_commend', { x: 22, y: 2 }], + ]) + ); + }); + + it('writes a file that reads back the same', () => { + const hotspots = new Map([ + ['cursor', { x: 9, y: 4 }], + ['cursor_commend', { x: 22, y: 2 }], + ]); + expect(parseCursorRes(formatCursorRes(hotspots))).toEqual(hotspots); + }); +}); + function rgbBmp(pixels: number[]): Buffer { const bmp = Buffer.alloc(54 + pixels.length); bmp.write('BM', 0, 'latin1'); diff --git a/electron/main/services/cursorFiles.ts b/electron/main/services/cursorFiles.ts index ebf5e858..a040c3ab 100644 --- a/electron/main/services/cursorFiles.ts +++ b/electron/main/services/cursorFiles.ts @@ -6,6 +6,8 @@ * optional cursor.res holding the hotspots. */ +import type { CursorHotspot } from '../../../src/types/electron'; + const CURSOR_IMAGE_RE = /^cursor(?:_[a-z0-9]+)*\.bmp$/; export const MAX_CURSOR_FILE_BYTES = 4 * 1024 * 1024; @@ -21,6 +23,29 @@ export function isUsableCursorFile(fileName: string, bytes: Uint8Array): boolean return bytes[0] === 0x42 && bytes[1] === 0x4d; } +/** + * The hotspots in a cursor.res, keyed by cursor name. The game looks a BMP up + * by its file stem (cursor_ping.bmp reads `cursor_ping`) and uses 0, 0 for a + * cursor the file does not list. + */ +export function parseCursorRes(text: string): Map { + const hotspots = new Map(); + const body = text.replace(/\/\/[^\n]*/g, ''); + for (const [, name, entry] of body.matchAll(/"?([\w.-]+)"?\s*\{([^{}]*)\}/g)) { + const x = /"hotx"\s+"(-?\d+)"/i.exec(entry); + const y = /"hoty"\s+"(-?\d+)"/i.exec(entry); + hotspots.set(name.toLowerCase(), { x: x ? Number(x[1]) : 0, y: y ? Number(y[1]) : 0 }); + } + return hotspots; +} + +export function formatCursorRes(hotspots: ReadonlyMap): string { + const entries = [...hotspots].map( + ([name, { x, y }]) => `\t${name}\n\t{\n\t\t"hotx"\t\t"${x}"\n\t\t"hoty"\t\t"${y}"\n\t}\n` + ); + return `"resource/cursor/cursor.res"\n{\n${entries.join('\n')}}\n`; +} + const FILE_HEADER = 14; const V5_HEADER = 124; diff --git a/electron/main/services/cursorPacks.test.ts b/electron/main/services/cursorPacks.test.ts index 3ca09b35..72df40f8 100644 --- a/electron/main/services/cursorPacks.test.ts +++ b/electron/main/services/cursorPacks.test.ts @@ -12,7 +12,9 @@ vi.mock('../utils/paths', () => ({ })); import { + createCursorPack, deleteCursorPack, + exportCursorZip, gameCursorsDir, getCursorPacks, getCursorPreview, @@ -20,6 +22,7 @@ import { reconcileCursorPack, setActiveCursorPack, } from './cursorPacks'; +import { parseCursorRes } from './cursorFiles'; let root: string; let deadlockPath: string; @@ -166,6 +169,59 @@ describe('cursor packs', () => { expect(await deleteCursorPack(deadlockPath, pack.id)).toEqual({ packs: [], activeId: null }); }); + const built = (fileName: string, content: string, hotspot = { x: 0, y: 0 }) => ({ + fileName, + bytes: new Uint8Array(Buffer.from(content)), + hotspot, + }); + + it('creates a pack from built images, keeping only valid cursor files', async () => { + const [pack] = await createCursorPack(deadlockPath, 'Mine', [ + built('Cursor.bmp', 'BM-mine'), + built('cursor_ping.bmp', 'not a bitmap'), + built('../cursor_shop.bmp', 'BM-escape'), + built('cursor.res', '"evil" {}'), + ]); + expect(pack.name).toBe('Mine'); + expect(pack.files).toEqual(['cursor.bmp', 'cursor.res']); + + await setActiveCursorPack(deadlockPath, pack.id); + expect(read('cursor.bmp')).toBe('BM-mine'); + expect(read('cursor_ping.bmp')).toBe('BM-stock-ping'); + }); + + it('writes the built hotspots into the stock cursor.res', async () => { + writeFileSync( + join(cursors, 'cursor.res'), + '"resource/cursor/cursor.res"\n{\n\tcursor_commend\n\t{\n\t\t"hotx" "22"\n\t\t"hoty" "2"\n\t}\n}\n' + ); + const [pack] = await createCursorPack(deadlockPath, 'Mine', [built('cursor.bmp', 'BM-mine', { x: 9, y: 4 })]); + await setActiveCursorPack(deadlockPath, pack.id); + + expect(parseCursorRes(read('cursor.res'))).toEqual( + new Map([ + ['cursor_commend', { x: 22, y: 2 }], + ['cursor', { x: 9, y: 4 }], + ]) + ); + await setActiveCursorPack(deadlockPath, null); + expect(parseCursorRes(read('cursor.res')).has('cursor')).toBe(false); + }); + + it('exports built images as a zip that imports back as the same pack', async () => { + const dest = join(root, 'Mine.zip'); + await exportCursorZip(deadlockPath, dest, [built('cursor.bmp', 'BM-mine'), built('notes.txt', 'BM-nope')]); + expect(new AdmZip(dest).getEntries().map((e) => e.entryName)).toEqual(['cursor.bmp', 'cursor.res']); + + const [pack] = await installCursorArchive(dest, mkdtempSync(join(root, 'work-')), { name: 'Mine' }); + expect(pack.files).toEqual(['cursor.bmp', 'cursor.res']); + }); + + it('creates nothing when no image is usable', async () => { + expect(await createCursorPack(deadlockPath, 'Mine', [built('cursor.bmp', '')])).toEqual([]); + expect((await getCursorPacks()).packs).toEqual([]); + }); + it('installs sibling folders as separate variants', async () => { const packs = await install({ 'Large/cursor.bmp': 'BM-l', 'Small/cursor.bmp': 'BM-s' }); expect(packs.map((p) => p.name)).toEqual(['Kitty Paw (Large)', 'Kitty Paw (Small)']); diff --git a/electron/main/services/cursorPacks.ts b/electron/main/services/cursorPacks.ts index 9b4834df..dd6bfb3b 100644 --- a/electron/main/services/cursorPacks.ts +++ b/electron/main/services/cursorPacks.ts @@ -1,9 +1,17 @@ import { createHash, randomUUID } from 'crypto'; import { existsSync, promises as fs } from 'fs'; import { basename, join } from 'path'; -import type { CursorPack, CursorPacksState, CursorPreview } from '../../../src/types/electron'; +import AdmZip from 'adm-zip'; +import type { CursorImageFile, CursorPack, CursorPacksState, CursorPreview } from '../../../src/types/electron'; import { getUserDataPath } from '../utils/paths'; -import { bmpWithExplicitAlpha, groupCursorFiles, isCursorFileName, isUsableCursorFile } from './cursorFiles'; +import { + bmpWithExplicitAlpha, + formatCursorRes, + groupCursorFiles, + isCursorFileName, + isUsableCursorFile, + parseCursorRes, +} from './cursorFiles'; import { getCitadelPath } from './deadlock'; import { extractArchive, type ExtractedVpk } from './extract'; @@ -178,6 +186,11 @@ async function readPreviewDir(dir: string, names: readonly string[]): Promise + state.activeId === null && deadlockPath ? gameCursorsDir(deadlockPath) : stockDir(); + /** Data URLs of a pack's cursor images, or of the stock set when `id` is null. */ export async function getCursorPreview(deadlockPath: string | null, id: string | null): Promise { const state = await loadState(); @@ -185,9 +198,7 @@ export async function getCursorPreview(deadlockPath: string | null, id: string | const pack = state.packs.find((p) => p.id === id); return pack ? readPreviewDir(packDir(id), pack.files) : {}; } - // With no pack applied the game folder holds the stock set; otherwise the - // backup taken before the first apply does. - const dir = state.activeId === null && deadlockPath ? gameCursorsDir(deadlockPath) : stockDir(); + const dir = stockSourceDir(state, deadlockPath); if (!existsSync(dir)) return {}; const names = (await fs.readdir(dir)).map((n) => n.toLowerCase()).filter(isCursorFileName).sort(); return readPreviewDir(dir, names); @@ -199,34 +210,31 @@ export interface CursorPackSource { gameBananaFileId?: number; } -async function installGroups( - extracted: readonly ExtractedVpk[], - source: CursorPackSource -): Promise { - const groups = groupCursorFiles(extracted); - if (groups.length === 0) return []; +interface CursorSet { + variant?: string; + /** Lowercased destination name -> usable file bytes. */ + files: Map; +} + +async function installSets(sets: readonly CursorSet[], source: CursorPackSource): Promise { + // A lone cursor.res changes nothing visible. + const usable = sets.filter(({ files }) => [...files.keys()].some((name) => name.endsWith('.bmp'))); + if (usable.length === 0) return []; return exclusive(async () => { const state = await loadState(); const installed: CursorPack[] = []; - for (const group of groups) { - const files = new Map(); - for (const [name, path] of group.files) { - const bytes = await fs.readFile(path); - if (isUsableCursorFile(name, bytes)) files.set(name, bytes); - } - if (![...files.keys()].some((name) => name.endsWith('.bmp'))) continue; - - const variant = group.variant?.replace(/[_-]+/g, ' ').trim(); - const name = variant ? `${source.name} (${variant})` : source.name; + for (const { variant, files } of usable) { + const label = variant?.replace(/[_-]+/g, ' ').trim(); + const name = label ? `${source.name} (${label})` : source.name; // Reinstalling the same GameBanana file replaces its pack in place. const existing = source.gameBananaFileId === undefined ? undefined : state.packs.find((p) => - p.gameBananaFileId === source.gameBananaFileId && p.variant === group.variant); + p.gameBananaFileId === source.gameBananaFileId && p.variant === variant); const pack: CursorPack = { id: existing?.id ?? randomUUID(), name, - variant: group.variant, + variant, files: [...files.keys()].sort(), installedAt: new Date().toISOString(), gameBananaId: source.gameBananaId, @@ -243,6 +251,66 @@ async function installGroups( }); } +async function installGroups( + extracted: readonly ExtractedVpk[], + source: CursorPackSource +): Promise { + const sets: CursorSet[] = []; + for (const group of groupCursorFiles(extracted)) { + const files = new Map(); + for (const [name, path] of group.files) { + const bytes = await fs.readFile(path); + if (isUsableCursorFile(name, bytes)) files.set(name, bytes); + } + sets.push({ variant: group.variant, files }); + } + return installSets(sets, source); +} + +/** + * BMPs built in the renderer plus a cursor.res with their hotspots. The game + * reads every cursor's hotspot from that one file, so it starts from the stock + * copy: cursors left alone keep theirs (the commend one is not at 0, 0). + */ +async function builtCursorFiles( + deadlockPath: string | null, + images: readonly CursorImageFile[] +): Promise> { + const files = new Map(); + const stockRes = join(stockSourceDir(await loadState(), deadlockPath), 'cursor.res'); + const hotspots = parseCursorRes(existsSync(stockRes) ? await fs.readFile(stockRes, 'utf-8') : ''); + for (const { fileName, bytes, hotspot } of images) { + const lower = fileName.toLowerCase(); + if (!lower.endsWith('.bmp') || !isCursorFileName(lower) || !isUsableCursorFile(lower, bytes)) continue; + files.set(lower, Buffer.from(bytes)); + hotspots.set(lower.slice(0, -'.bmp'.length), hotspot); + } + if (files.size > 0) files.set('cursor.res', Buffer.from(formatCursorRes(hotspots))); + return files; +} + +/** Install BMPs built in the renderer (the Create cursor dialog) as one pack. */ +export async function createCursorPack( + deadlockPath: string | null, + name: string, + images: readonly CursorImageFile[] +): Promise { + return installSets([{ files: await builtCursorFiles(deadlockPath, images) }], { name }); +} + +/** Zip BMPs built in the renderer so they can be shared and imported elsewhere. */ +export async function exportCursorZip( + deadlockPath: string | null, + destPath: string, + images: readonly CursorImageFile[] +): Promise { + const files = await builtCursorFiles(deadlockPath, images); + if (files.size === 0) throw new Error('None of the cursor images could be used.'); + const zip = new AdmZip(); + for (const [name, bytes] of files) zip.addFile(name, bytes); + await fs.writeFile(destPath, zip.toBuffer()); +} + /** * Install the cursor sets inside an archive that shipped no VPK. Resolves to * the installed packs, empty when the archive holds no cursor images either. diff --git a/electron/preload/index.ts b/electron/preload/index.ts index eb7b1420..f33327f7 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -73,6 +73,7 @@ import type { UpdateStatus, LockerImageVariant, CropRect, + CursorImageFile, } from '../../src/types/electron'; import type { AppearanceSurface } from '../../src/types/mod'; import type { DeadworksConnectProgress } from '../../src/types/deadworks'; @@ -652,6 +653,9 @@ contextBridge.exposeInMainWorld('electronAPI', { deleteCursorPack: (id: string) => ipcRenderer.invoke('cursors:delete', id), getCursorPreview: (id: string | null) => ipcRenderer.invoke('cursors:preview', id), importCursorPack: (paths: string[]) => ipcRenderer.invoke('cursors:import-files', paths), + createCursorPack: (name: string, files: CursorImageFile[]) => ipcRenderer.invoke('cursors:create', name, files), + exportCursorImages: (destPath: string, files: CursorImageFile[]) => + ipcRenderer.invoke('cursors:export', destPath, files), // Updater updater: { diff --git a/src/components/locker/CardCropper.tsx b/src/components/locker/CardCropper.tsx index 43c298c4..2e8bc2f8 100644 --- a/src/components/locker/CardCropper.tsx +++ b/src/components/locker/CardCropper.tsx @@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next'; import { Loader2, AlertCircle, Crop, ZoomIn, RotateCcw } from 'lucide-react'; import { Modal, ModalBody, ModalFooter } from '../common/Modal'; import { Button, ModalHeader } from '../common/ui'; +import { detectCursorHotspot } from '../../lib/cursorHotspot'; +import type { CursorHotspot } from '../../types/electron'; interface CardCropperProps { /** Source image to crop (any size), as a data URL. */ @@ -12,15 +14,37 @@ interface CardCropperProps { targetHeight: number; /** Human label for the variant being cropped (e.g. "Card", "Minimap"). */ variantLabel: string; + /** Cursor art: zooming out may leave part of the frame transparent, and a + * crosshair marks the click point, detected from the image and moved by a + * click. Starts with the whole image flush top-left like the stock cursors. */ + cursor?: boolean; onCancel: () => void; - /** Receives the cropped image as a PNG data URL at exactly target size. */ - onCrop: (dataUrl: string) => void; + /** Receives the cropped image as a PNG data URL at exactly target size, and + * (cursor mode) the click point in its pixels. */ + onCrop: (dataUrl: string, hotspot: CursorHotspot) => void; } /** Longest edge of the editing viewport, in CSS px. The viewport keeps the * target aspect; the source image is scaled to cover it and pans/zooms within. */ const BOX = 360; const MAX_ZOOM = 5; +// Detection runs on a copy this size at most; the hotspot only needs to land +// within a pixel of a ~60 px cursor. +const DETECT_EDGE = 256; + +/** The detected click point of a cursor image, in its natural pixels. */ +function detectSourceHotspot(img: HTMLImageElement): CursorHotspot { + const s = Math.min(1, DETECT_EDGE / Math.max(img.naturalWidth, img.naturalHeight)); + const w = Math.max(1, Math.round(img.naturalWidth * s)); + const h = Math.max(1, Math.round(img.naturalHeight * s)); + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d')!; + ctx.drawImage(img, 0, 0, w, h); + const p = detectCursorHotspot(w, h, ctx.getImageData(0, 0, w, h).data); + return { x: (p.x + 0.5) / s, y: (p.y + 0.5) / s }; +} /** * Crop-to-aspect editor for a custom hero-card upload. @@ -37,6 +61,7 @@ export default function CardCropper({ targetWidth, targetHeight, variantLabel, + cursor = false, onCancel, onCrop, }: CardCropperProps) { @@ -51,22 +76,27 @@ export default function CardCropper({ const [zoom, setZoom] = useState(1); // Top-left of the drawn image relative to the viewport, in CSS px (<= 0). const [offset, setOffset] = useState({ x: 0, y: 0 }); + // Cursor click point in source natural pixels, so it moves with the image. + const [hotspot, setHotspot] = useState({ x: 0, y: 0 }); const drag = useRef<{ startX: number; startY: number; ox: number; oy: number } | null>(null); // Scale at which the source just covers the viewport (zoom 1 == cover). const coverScale = img ? Math.max(viewW / img.naturalWidth, viewH / img.naturalHeight) : 1; + const minZoom = cursor && img ? Math.min(viewW / img.naturalWidth, viewH / img.naturalHeight) / coverScale : 1; const drawnW = img ? img.naturalWidth * coverScale * zoom : viewW; const drawnH = img ? img.naturalHeight * coverScale * zoom : viewH; + // Cover keeps the frame filled. Fit only keeps the image from leaving it. const clamp = useCallback( - (x: number, y: number) => ({ - x: Math.min(0, Math.max(viewW - drawnW, x)), - y: Math.min(0, Math.max(viewH - drawnH, y)), - }), - [viewW, viewH, drawnW, drawnH] + (x: number, y: number, w: number, h: number) => + cursor + ? { x: Math.min(viewW, Math.max(-w, x)), y: Math.min(viewH, Math.max(-h, y)) } + : { x: Math.min(0, Math.max(viewW - w, x)), y: Math.min(0, Math.max(viewH - h, y)) }, + [cursor, viewW, viewH] ); - // Load the source image to learn its natural size, then center it at cover. + // Load the source image to learn its natural size, then center it at cover + // (or, for a cursor, place it whole in the top-left corner). useEffect(() => { let active = true; const el = new Image(); @@ -74,8 +104,14 @@ export default function CardCropper({ if (!active) return; setImg(el); const cs = Math.max(viewW / el.naturalWidth, viewH / el.naturalHeight); - setOffset({ x: (viewW - el.naturalWidth * cs) / 2, y: (viewH - el.naturalHeight * cs) / 2 }); - setZoom(1); + if (cursor) { + setOffset({ x: 0, y: 0 }); + setZoom(Math.min(viewW / el.naturalWidth, viewH / el.naturalHeight) / cs); + setHotspot(detectSourceHotspot(el)); + } else { + setOffset({ x: (viewW - el.naturalWidth * cs) / 2, y: (viewH - el.naturalHeight * cs) / 2 }); + setZoom(1); + } setError(null); }; el.onerror = () => { @@ -85,12 +121,12 @@ export default function CardCropper({ return () => { active = false; }; - }, [imageDataUrl, viewW, viewH, t]); + }, [imageDataUrl, cursor, viewW, viewH, t]); // Zoom around the viewport center so the framed subject stays put. const applyZoom = useCallback( (nextZoom: number) => { - const z = Math.min(MAX_ZOOM, Math.max(1, nextZoom)); + const z = Math.min(MAX_ZOOM, Math.max(minZoom, nextZoom)); if (!img) { setZoom(z); return; @@ -99,15 +135,10 @@ export default function CardCropper({ const cy = (viewH / 2 - offset.y) / (coverScale * zoom); const nx = viewW / 2 - cx * coverScale * z; const ny = viewH / 2 - cy * coverScale * z; - const newDrawnW = img.naturalWidth * coverScale * z; - const newDrawnH = img.naturalHeight * coverScale * z; setZoom(z); - setOffset({ - x: Math.min(0, Math.max(viewW - newDrawnW, nx)), - y: Math.min(0, Math.max(viewH - newDrawnH, ny)), - }); + setOffset(clamp(nx, ny, img.naturalWidth * coverScale * z, img.naturalHeight * coverScale * z)); }, - [img, offset, zoom, coverScale, viewW, viewH] + [img, offset, zoom, minZoom, coverScale, viewW, viewH, clamp] ); const onPointerDown = (e: React.PointerEvent) => { @@ -119,13 +150,31 @@ export default function CardCropper({ if (!drag.current) return; const next = clamp( drag.current.ox + (e.clientX - drag.current.startX), - drag.current.oy + (e.clientY - drag.current.startY) + drag.current.oy + (e.clientY - drag.current.startY), + drawnW, + drawnH ); setOffset(next); }; - const onPointerUp = () => { + const onPointerUp = (e: React.PointerEvent) => { + const start = drag.current; + drag.current = null; + // A click that did not pan places the cursor's click point. + if (!cursor || !start || Math.hypot(e.clientX - start.startX, e.clientY - start.startY) > 3) return; + const scale = coverScale * zoom; + setHotspot({ x: (e.nativeEvent.offsetX - offset.x) / scale, y: (e.nativeEvent.offsetY - offset.y) / scale }); + }; + const onPointerCancel = () => { drag.current = null; }; + + // The click point in output pixels: the pixel it falls in, kept inside the + // image since SDL rejects a hotspot outside the cursor. + const scale = coverScale * zoom; + const outHotspot = { + x: Math.min(targetWidth - 1, Math.max(0, Math.floor(((offset.x + hotspot.x * scale) * targetWidth) / viewW))), + y: Math.min(targetHeight - 1, Math.max(0, Math.floor(((offset.y + hotspot.y * scale) * targetHeight) / viewH))), + }; const onWheel = (e: React.WheelEvent) => { e.preventDefault(); applyZoom(zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1)); @@ -133,7 +182,6 @@ export default function CardCropper({ const handleApply = () => { if (!img) return; - const scale = coverScale * zoom; // The viewport maps to this rect in source-image natural coordinates. const srcX = -offset.x / scale; const srcY = -offset.y / scale; @@ -149,13 +197,17 @@ export default function CardCropper({ } ctx.imageSmoothingQuality = 'high'; ctx.drawImage(img, srcX, srcY, srcW, srcH, 0, 0, targetWidth, targetHeight); - onCrop(canvas.toDataURL('image/png')); + onCrop(canvas.toDataURL('image/png'), outHotspot); }; return ( {t('locker.crop.outputSize', { width: targetWidth, height: targetHeight })}} onClose={onCancel} @@ -172,11 +224,11 @@ export default function CardCropper({
{img ? ( @@ -192,6 +244,26 @@ export default function CardCropper({
)} + {cursor && img && ( + + + + + + + + + + + )}
@@ -199,7 +271,7 @@ export default function CardCropper({ applyZoom(1)} + onClick={() => applyZoom(minZoom)} title={t('locker.crop.resetZoom')} className="cursor-pointer rounded-md border border-border/60 p-1 text-text-secondary transition-colors hover:border-hl/20 hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-50" > @@ -232,7 +304,7 @@ export default function CardCropper({

)}

- {t('locker.crop.instructions')} + {cursor ? t('locker.crop.fitInstructions') : t('locker.crop.instructions')}

)} diff --git a/src/components/locker/CursorCreateModal.tsx b/src/components/locker/CursorCreateModal.tsx new file mode 100644 index 00000000..381c0dae --- /dev/null +++ b/src/components/locker/CursorCreateModal.tsx @@ -0,0 +1,240 @@ +import { useEffect, useId, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Check, Download, Upload, X } from 'lucide-react'; +import { Modal, ModalBody, ModalFooter } from '../common/Modal'; +import { Button, IconButton, ModalHeader } from '../common/ui'; +import { FormField, Input } from '../common/forms'; +import { readImageDataUrl, revealPath, showOpenDialog, showSaveDialog } from '../../lib/api'; +import { encodeCursorBmp } from '../../lib/cursorBmp'; +import { useCursorPackStore } from '../../stores/cursorPackStore'; +import { showToast } from '../../stores/toastStore'; +import type { CursorHotspot, CursorImageFile, CursorPreview } from '../../types/electron'; +import CardCropper from './CardCropper'; + +interface Slot { + file: string; + width: number; + height: number; +} + +// The stock sizes. The game draws a cursor at whatever size its BMP is, so +// these only set the scale. A slot left empty keeps the game's own cursor. +const SLOTS: readonly Slot[] = [ + { file: 'cursor.bmp', width: 48, height: 58 }, + { file: 'cursor_ping.bmp', width: 48, height: 58 }, + { file: 'cursor_shop.bmp', width: 60, height: 60 }, + { file: 'cursor_commend.bmp', width: 88, height: 88 }, +]; + +async function pngToBmp(dataUrl: string): Promise { + const img = new Image(); + img.src = dataUrl; + await img.decode(); + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const ctx = canvas.getContext('2d')!; + ctx.drawImage(img, 0, 0); + return encodeCursorBmp(canvas.width, canvas.height, ctx.getImageData(0, 0, canvas.width, canvas.height).data); +} + +/** + * Build a cursor pack from the user's own images: one tile per cursor the game + * ships, each framed with the cropper at that cursor's size. Creating applies it. + */ +export default function CursorCreateModal({ onClose }: { onClose: () => void }) { + const { t } = useTranslation(); + const titleId = useId(); + const { busy, create } = useCursorPackStore(); + const [name, setName] = useState(''); + const [stock, setStock] = useState({}); + // Framed PNG data URL per cursor file name, at the slot's exact size. + const [picks, setPicks] = useState>({}); + const [cropping, setCropping] = useState<{ slot: Slot; sourceDataUrl: string } | null>(null); + const [exporting, setExporting] = useState(false); + + const labels: Record = { + 'cursor.bmp': t('locker.cursors.create.pointer'), + 'cursor_ping.bmp': t('locker.cursors.create.ping'), + 'cursor_shop.bmp': t('locker.cursors.create.shop'), + 'cursor_commend.bmp': t('locker.cursors.create.commend'), + }; + + useEffect(() => { + let live = true; + window.electronAPI + .getCursorPreview(null) + .then((preview) => { + if (live) setStock(preview); + }) + .catch(() => {}); + return () => { + live = false; + }; + }, []); + + const pickImage = async (slot: Slot) => { + try { + const path = await showOpenDialog({ + title: t('locker.cursors.create.chooseImage', { cursor: labels[slot.file] }), + filters: [{ name: t('locker.cursors.create.imageFilter'), extensions: ['png', 'webp', 'gif', 'jpg', 'jpeg'] }], + }); + if (!path) return; + setCropping({ slot, sourceDataUrl: await readImageDataUrl(path) }); + } catch (err) { + showToast(err instanceof Error ? err.message : String(err), { tone: 'error' }); + } + }; + + const clear = (file: string) => + setPicks((prev) => { + const next = { ...prev }; + delete next[file]; + return next; + }); + + const hasPicks = Object.keys(picks).length > 0; + const packName = name.trim() || t('locker.cursors.create.defaultName'); + const buildFiles = (): Promise => + Promise.all( + Object.entries(picks).map(async ([fileName, { dataUrl, hotspot }]) => ({ + fileName, + bytes: await pngToBmp(dataUrl), + hotspot, + })) + ); + + const submit = async () => { + try { + await create(packName, await buildFiles()); + showToast(t('locker.cursors.applied', { name: packName }), { tone: 'success' }); + onClose(); + } catch (err) { + showToast(err instanceof Error ? err.message : String(err), { tone: 'error' }); + } + }; + + const exportZip = async () => { + try { + const destPath = await showSaveDialog({ + title: t('locker.cursors.create.exportTitle', { name: packName }), + defaultPath: `${packName.replace(/[\\/:*?"<>|]+/g, '').trim() || 'cursor'}.zip`, + filters: [{ name: t('locker.cursors.create.exportFilter'), extensions: ['zip'] }], + }); + if (!destPath) return; + setExporting(true); + await window.electronAPI.exportCursorImages(destPath, await buildFiles()); + showToast(t('locker.cursors.create.exported', { path: destPath }), { tone: 'success' }); + void revealPath(destPath); + } catch (err) { + showToast(err instanceof Error ? err.message : String(err), { tone: 'error' }); + } finally { + setExporting(false); + } + }; + + return ( + + + + + setName(e.target.value)} + /> + +
+ {SLOTS.map((slot) => { + const pick = picks[slot.file]; + const art = pick?.dataUrl ?? stock[slot.file]; + return ( +
+
+ + {pick && ( + clear(slot.file)} + className="absolute right-1.5 top-1.5 bg-bg-secondary" + /> + )} +
+
{labels[slot.file]}
+
+ {slot.width} x {slot.height} +
+
+ ); + })} +
+

{t('locker.cursors.create.hint')}

+
+ + + + + + {cropping && ( + setCropping(null)} + onCrop={(dataUrl, hotspot) => { + setPicks((prev) => ({ ...prev, [cropping.slot.file]: { dataUrl, hotspot } })); + setCropping(null); + }} + /> + )} +
+ ); +} diff --git a/src/components/locker/CursorPackPane.tsx b/src/components/locker/CursorPackPane.tsx index 6f72c8d3..957fca38 100644 --- a/src/components/locker/CursorPackPane.tsx +++ b/src/components/locker/CursorPackPane.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -import { Check, Search, Trash2, Upload } from 'lucide-react'; +import { Check, ImagePlus, Search, Trash2, Upload } from 'lucide-react'; import { Button, IconButton, Tag } from '../common/ui'; import { ConfirmModal } from '../common/PageComponents'; import { showOpenDialogMulti } from '../../lib/api'; @@ -9,6 +9,7 @@ import { useAppStore } from '../../stores/appStore'; import { useCursorPackStore } from '../../stores/cursorPackStore'; import { showToast } from '../../stores/toastStore'; import type { CursorPack, CursorPreview } from '../../types/electron'; +import CursorCreateModal from './CursorCreateModal'; // Session cache: previews are read off disk once per pack install. const previewCache = new Map(); @@ -196,10 +197,11 @@ export default function CursorPackPane() { ); } -/** Header action for the Cursor tab: pick an archive or loose BMPs and apply them. */ -export function CursorImportButton() { +/** Header actions for the Cursor tab: build a pack from images, or import an archive or loose BMPs. */ +export function CursorPaneActions() { const { t } = useTranslation(); const { busy, importPaths } = useCursorPackStore(); + const [creating, setCreating] = useState(false); const pick = async () => { const paths = await showOpenDialogMulti({ @@ -216,8 +218,14 @@ export function CursorImportButton() { }; return ( - +
+ + + {creating && setCreating(false)} />} +
); } diff --git a/src/lib/cursorBmp.test.ts b/src/lib/cursorBmp.test.ts new file mode 100644 index 00000000..673b14f4 --- /dev/null +++ b/src/lib/cursorBmp.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { encodeCursorBmp } from './cursorBmp'; + +describe('encodeCursorBmp', () => { + it('writes a 32-bit bottom-up BGRA bitmap', () => { + // 2x2: top row red, green; bottom row blue, half-transparent white. + const rgba = new Uint8Array([255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 128]); + const bmp = encodeCursorBmp(2, 2, rgba); + const view = new DataView(bmp.buffer); + + expect(String.fromCharCode(bmp[0], bmp[1])).toBe('BM'); + expect(view.getUint32(2, true)).toBe(54 + 16); + expect(view.getUint32(10, true)).toBe(54); + expect(view.getInt32(18, true)).toBe(2); + expect(view.getInt32(22, true)).toBe(2); + expect(view.getUint16(28, true)).toBe(32); + expect(view.getUint32(30, true)).toBe(0); + expect([...bmp.subarray(54)]).toEqual([ + 255, 0, 0, 255, 255, 255, 255, 128, + 0, 0, 255, 255, 0, 255, 0, 255, + ]); + }); +}); diff --git a/src/lib/cursorBmp.ts b/src/lib/cursorBmp.ts new file mode 100644 index 00000000..5a59a8f4 --- /dev/null +++ b/src/lib/cursorBmp.ts @@ -0,0 +1,37 @@ +const HEADER_BYTES = 54; + +/** + * Encode RGBA pixels the way Deadlock's stock cursors are stored: a 40-byte + * BITMAPINFOHEADER, 32 bits, BI_RGB, bottom-up BGRA rows. SDL reads the fourth + * byte as alpha, so transparency survives. + */ +export function encodeCursorBmp(width: number, height: number, rgba: Uint8ClampedArray | Uint8Array): Uint8Array { + const pixelBytes = width * height * 4; + const out = new Uint8Array(HEADER_BYTES + pixelBytes); + const view = new DataView(out.buffer); + out[0] = 0x42; + out[1] = 0x4d; + view.setUint32(2, out.length, true); + view.setUint32(10, HEADER_BYTES, true); + view.setUint32(14, 40, true); + view.setInt32(18, width, true); + view.setInt32(22, height, true); + view.setUint16(26, 1, true); + view.setUint16(28, 32, true); + view.setUint32(34, pixelBytes, true); + view.setInt32(38, 2835, true); + view.setInt32(42, 2835, true); + + for (let y = 0; y < height; y++) { + const row = HEADER_BYTES + (height - 1 - y) * width * 4; + for (let x = 0; x < width; x++) { + const src = (y * width + x) * 4; + const dst = row + x * 4; + out[dst] = rgba[src + 2]; + out[dst + 1] = rgba[src + 1]; + out[dst + 2] = rgba[src]; + out[dst + 3] = rgba[src + 3]; + } + } + return out; +} diff --git a/src/lib/cursorHotspot.test.ts b/src/lib/cursorHotspot.test.ts new file mode 100644 index 00000000..668c6002 --- /dev/null +++ b/src/lib/cursorHotspot.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { detectCursorHotspot } from './cursorHotspot'; + +function paint(width: number, height: number, inside: (x: number, y: number) => boolean) { + const rgba = new Uint8Array(width * height * 4); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (inside(x, y)) rgba[(y * width + x) * 4 + 3] = 255; + } + } + return detectCursorHotspot(width, height, rgba); +} + +const near = (actual: { x: number; y: number }, x: number, y: number, slack = 2) => { + expect(Math.abs(actual.x - x), `x ${actual.x} vs ${x}`).toBeLessThanOrEqual(slack); + expect(Math.abs(actual.y - y), `y ${actual.y} vs ${y}`).toBeLessThanOrEqual(slack); +}; + +describe('detectCursorHotspot', () => { + it('finds the tip of an arrow wherever it sits in the frame', () => { + // Stock-shaped arrow: tip, straight left side, diagonal right side. + const arrow = (ox: number, oy: number) => (x: number, y: number) => + x >= ox && y >= oy && x - ox <= (y - oy) * 0.75 && y - oy <= 40 - (x - ox) * 0.4; + near(paint(48, 58, arrow(0, 0)), 0, 0); + near(paint(48, 58, arrow(9, 6)), 9, 6); + }); + + it('finds a fingertip pointing straight up', () => { + const hand = (x: number, y: number) => + (x >= 26 && x <= 34 && y >= 4 && y <= 30) || (x >= 12 && x <= 46 && y >= 26 && y <= 58); + near(paint(60, 60, hand), 30, 4, 4); + }); + + it('ignores a tail cut off by the image edge and a ping bubble', () => { + // A pointed wand running off the bottom-right corner. + const wand = (x: number, y: number) => Math.abs(x - y) <= Math.min(10, (x + y - 8) * 0.6); + near(paint(57, 58, wand), 4, 4, 3); + const withBubble = (x: number, y: number) => wand(x, y) || (x >= 38 && x <= 55 && y <= 10); + near(paint(57, 58, withBubble), 4, 4, 3); + }); + + it('does not mistake a straight diagonal wand for a reticle', () => { + near(paint(48, 48, (x, y) => Math.abs(x - y) <= 3), 0, 0, 3); + }); + + it('aims with the middle of a crosshair or ring', () => { + near(paint(41, 41, (x, y) => Math.abs(x - 20) <= 1 || Math.abs(y - 20) <= 1), 20, 20, 0); + const ring = (x: number, y: number) => Math.abs(Math.hypot(x - 30, y - 24) - 16) <= 2; + near(paint(64, 64, ring), 30, 24, 1); + }); + + it('aims with the middle of round art with no corner', () => { + const blob = (x: number, y: number) => ((x - 20) / 18) ** 2 + ((y - 30) / 12) ** 2 <= 1 || Math.hypot(x - 34, y - 22) <= 8; + const hit = paint(48, 48, blob); + expect(hit.x).toBeGreaterThan(10); + expect(hit.y).toBeGreaterThan(15); + }); + + it('falls back to the top-left corner for empty or fully opaque art', () => { + expect(paint(32, 32, () => false)).toEqual({ x: 0, y: 0 }); + expect(paint(32, 32, () => true)).toEqual({ x: 0, y: 0 }); + }); +}); diff --git a/src/lib/cursorHotspot.ts b/src/lib/cursorHotspot.ts new file mode 100644 index 00000000..82a9767d --- /dev/null +++ b/src/lib/cursorHotspot.ts @@ -0,0 +1,122 @@ +import type { CursorHotspot } from '../types/electron'; + +interface Point { + x: number; + y: number; +} + +const ALPHA_MIN = 128; +// No hull corner sharper than this means round art (a paw, a blob): aim with its middle. +const BLUNT_DEG = 130; +// Penalty for a corner at the far bottom-right against one at the top-left. +// Pointers aim up-left, and a sharp corner elsewhere is usually a tail cut off +// by the image edge (a paw's arm) or an accessory (the ping bubble), so this +// outweighs sharpness: it only decides between corners near the top-left. +const TOP_LEFT_BIAS_DEG = 180; + +const cross = (o: Point, a: Point, b: Point) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x); + +function convexHull(points: Point[]): Point[] { + const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y); + const half = (pts: Point[]) => { + const out: Point[] = []; + for (const p of pts) { + while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], p) <= 0) out.pop(); + out.push(p); + } + out.pop(); + return out; + }; + return [...half(sorted), ...half(sorted.reverse())]; +} + +/** The point `dist` along the hull outline from vertex `i`, walking in `dir`. */ +function alongHull(hull: Point[], i: number, dir: 1 | -1, dist: number): Point { + let cur = hull[i]; + let j = i; + let left = dist; + for (let step = 0; step < hull.length; step++) { + j = (j + dir + hull.length) % hull.length; + const next = hull[j]; + const len = Math.hypot(next.x - cur.x, next.y - cur.y); + if (len >= left) return { x: cur.x + ((next.x - cur.x) * left) / len, y: cur.y + ((next.y - cur.y) * left) / len }; + left -= len; + cur = next; + } + return cur; +} + +/** + * Guess where a cursor image clicks: the sharpest corner of its outline, + * leaning top-left like the stock pointers, or the middle of a reticle + * (crosshair, ring) or of art with no corner at all. Corners are + * measured over a stretch of outline rather than per pixel, so a rounded + * fingertip still reads as a tip. Art with no transparency gets the stock 0, 0. + */ +export function detectCursorHotspot(width: number, height: number, rgba: ArrayLike): CursorHotspot { + const mask = new Uint8Array(width * height); + const edges: Point[] = []; + let opaque = 0; + let minX = width; + let minY = height; + let maxX = -1; + let maxY = -1; + for (let y = 0; y < height; y++) { + let left = -1; + let right = -1; + for (let x = 0; x < width; x++) { + if (rgba[(y * width + x) * 4 + 3] < ALPHA_MIN) continue; + mask[y * width + x] = 1; + opaque++; + if (left < 0) left = x; + right = x; + } + if (left < 0) continue; + edges.push({ x: left, y }, { x: right, y }); + minX = Math.min(minX, left); + maxX = Math.max(maxX, right); + minY = Math.min(minY, y); + maxY = y; + } + if (opaque === 0 || opaque === width * height) return { x: 0, y: 0 }; + + // Reticles look the same turned a quarter. A half turn is not enough: a + // straight wand or sword survives that too. + const cx = (minX + maxX) / 2; + const cy = (minY + maxY) / 2; + const center = { x: Math.round(cx), y: Math.round(cy) }; + let symmetric = 0; + for (let y = minY; y <= maxY; y++) { + for (let x = minX; x <= maxX; x++) { + if (!mask[y * width + x]) continue; + const rx = Math.round(cx - (y - cy)); + const ry = Math.round(cy + (x - cx)); + if (rx >= 0 && rx < width && ry >= 0 && ry < height && mask[ry * width + rx]) symmetric++; + } + } + if (symmetric / opaque >= 0.85) return center; + + const hull = convexHull(edges); + if (hull.length < 3) return center; + const reach = Math.max(2, 0.15 * Math.max(maxX - minX, maxY - minY)); + const span = maxX - minX + (maxY - minY) || 1; + let best = center; + let bestScore = Infinity; + let sharpest = 180; + for (let i = 0; i < hull.length; i++) { + const v = hull[i]; + const a = alongHull(hull, i, -1, reach); + const b = alongHull(hull, i, 1, reach); + const cos = + ((a.x - v.x) * (b.x - v.x) + (a.y - v.y) * (b.y - v.y)) / + (Math.hypot(a.x - v.x, a.y - v.y) * Math.hypot(b.x - v.x, b.y - v.y)); + const angle = (Math.acos(Math.max(-1, Math.min(1, cos))) * 180) / Math.PI; + sharpest = Math.min(sharpest, angle); + const score = angle + (TOP_LEFT_BIAS_DEG * (v.x - minX + (v.y - minY))) / span; + if (score < bestScore) { + bestScore = score; + best = v; + } + } + return sharpest <= BLUNT_DEG ? best : center; +} diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 8fd76b4f..6374658e 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -2052,6 +2052,8 @@ "imageLoadFailed": "That image could not be loaded. Try a different PNG or JPG.", "noCanvasContext": "Could not render the crop (no 2D canvas context).", "title": "Crop {{variant}} card", + "fitTitle": "Frame the {{variant}} image", + "fitInstructions": "Drag to move, scroll or use the slider to resize. Whatever the image doesn't cover stays transparent. The crosshair marks where clicks land: it finds the tip on its own, and a click moves it.", "resetZoom": "Reset zoom", "upscaleWarning": "Source is {{sourceWidth}} x {{sourceHeight}}, smaller than the {{targetWidth}} x {{targetHeight}} target, so it will be upscaled and may look soft.", "instructions": "Drag to reposition, scroll or use the slider to zoom. The frame is locked to the card's aspect so the result is not stretched.", @@ -2237,7 +2239,27 @@ "empty": "Cursor mods swap the game's mouse pointer. Download one from Browse or import its BMP files, then pick it here.", "deleteNamed": "Delete {{name}}", "deleteTitle": "Delete {{name}}?", - "deleteMessage": "This removes the cursor pack from Grimoire. If it is the active one, the default cursors come back." + "deleteMessage": "This removes the cursor pack from Grimoire. If it is the active one, the default cursors come back.", + "create": { + "open": "Create", + "title": "Create a cursor", + "subtitle": "Pick an image for each cursor you want to change. The rest stay as they are.", + "nameLabel": "Name", + "defaultName": "My cursor", + "pointer": "Pointer", + "ping": "Ping", + "shop": "Shop", + "commend": "Commend", + "chooseImage": "Choose an image for {{cursor}}", + "imageFilter": "Images", + "clear": "Clear {{cursor}}", + "hint": "Transparent PNGs work best. The tip of your pointer is found on its own, and you can move it while framing the image.", + "submit": "Create and apply", + "export": "Export", + "exportTitle": "Export {{name}}", + "exportFilter": "Zip archive", + "exported": "Exported to {{path}}" + } } }, "profiles": { diff --git a/src/locales/manifest.json b/src/locales/manifest.json index 6f6ccef5..a6afa030 100644 --- a/src/locales/manifest.json +++ b/src/locales/manifest.json @@ -1,6 +1,6 @@ { "sourceLanguage": "en", - "totalKeys": 2539, + "totalKeys": 2559, "languages": [ { "code": "bg", @@ -11,7 +11,7 @@ { "code": "en", "name": "English", - "translatedKeys": 2539, + "translatedKeys": 2559, "pct": 100 }, { diff --git a/src/pages/Locker.tsx b/src/pages/Locker.tsx index 3f4e99e3..04f826ef 100644 --- a/src/pages/Locker.tsx +++ b/src/pages/Locker.tsx @@ -15,7 +15,7 @@ import { getActiveDeadlockPath, shouldBlurNsfw } from '../lib/appSettings'; import { getAssetPath } from '../lib/assetPath'; import HeroSkinsPanel from '../components/locker/HeroSkinsPanel'; import GlobalModPicker from '../components/locker/GlobalModPicker'; -import CursorPackPane, { CursorImportButton } from '../components/locker/CursorPackPane'; +import CursorPackPane, { CursorPaneActions } from '../components/locker/CursorPackPane'; import CategoryModPicker from '../components/locker/CategoryModPicker'; import { CreateCategoryModal, ManageCategoriesModal } from '../components/locker/ManageCategoriesModal'; import { @@ -2308,7 +2308,7 @@ function LockerGlobalView({ groups, hideNsfw, onBack, onToggle, onSetGlobalType, {t('locker.page.modCount', { count: countForTab(activeType) })} - {activeType === CURSOR_TAB && } + {activeType === CURSOR_TAB && } {isPriorityTab && (