diff --git a/index.html b/index.html index ff93803..a57e304 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,8 @@ - - Tauri + React + Typescript + KCD2 Overlay diff --git a/public/tauri.svg b/public/tauri.svg deleted file mode 100644 index 31b62c9..0000000 --- a/public/tauri.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/App.css b/src/App.css index 85f7a4a..6e59c81 100644 --- a/src/App.css +++ b/src/App.css @@ -1,116 +1,454 @@ -.logo.vite:hover { - filter: drop-shadow(0 0 2em #747bff); -} - -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafb); -} :root { - font-family: Inter, Avenir, Helvetica, Arial, sans-serif; - font-size: 16px; - line-height: 24px; - font-weight: 400; - - color: #0f0f0f; - background-color: #f6f6f6; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - -webkit-text-size-adjust: 100%; + --accent: #c9a227; + --accent-dim: #8a7020; + --fg: #e8e2d5; + --fg-dim: #9a937f; + --bg-item: rgba(255, 255, 255, 0.045); + --bg-item-hover: rgba(255, 255, 255, 0.09); + --border: rgba(201, 162, 39, 0.25); + font-family: "Segoe UI", "Noto Sans", system-ui, sans-serif; + font-size: 14px; + color-scheme: dark; } -.container { +* { + box-sizing: border-box; +} + +html, +body, +#root { margin: 0; - padding-top: 10vh; + height: 100%; + background: transparent; + overflow: hidden; +} + +.overlay { + --overlay-alpha: 0.92; display: flex; flex-direction: column; - justify-content: center; - text-align: center; + height: 100vh; + color: var(--fg); + background: rgba(18, 15, 10, var(--overlay-alpha)); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; } -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: 0.75s; -} - -.logo.tauri:hover { - filter: drop-shadow(0 0 2em #24c8db); -} - -.row { +/* ---------- titlebar ---------- */ +.titlebar { display: flex; - justify-content: center; + align-items: center; + gap: 12px; + padding: 8px 12px; + background: rgba(0, 0, 0, 0.35); + border-bottom: 1px solid var(--border); + user-select: none; + cursor: grab; } -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; +.title { + font-weight: 600; + color: var(--accent); + letter-spacing: 0.4px; } -a:hover { - color: #535bf2; +.hotkey-hint { + margin-left: auto; + color: var(--fg-dim); + font-size: 12px; } -h1 { - text-align: center; -} - -input, -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - color: #0f0f0f; - background-color: #ffffff; - transition: border-color 0.25s; - box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); -} - -button { +.hide-btn { + background: none; + border: none; + color: var(--fg-dim); + font-size: 15px; cursor: pointer; + padding: 2px 8px; + border-radius: 4px; } -button:hover { - border-color: #396cd8; +.hide-btn:hover { + color: #fff; + background: rgba(255, 60, 60, 0.35); } -button:active { - border-color: #396cd8; - background-color: #e8e8e8; + +/* ---------- tabs ---------- */ +.tabbar { + display: flex; + gap: 2px; + padding: 6px 10px 0; + border-bottom: 1px solid var(--border); +} + +.tab { + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--fg-dim); + padding: 6px 12px; + cursor: pointer; + font-size: 13.5px; +} + +.tab:hover { + color: var(--fg); +} + +.tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.content { + flex: 1; + overflow-y: auto; + padding: 10px 14px; +} + +.tab-body { + display: flex; + flex-direction: column; + gap: 4px; +} + +.group-title { + font-size: 13px; + text-transform: uppercase; + letter-spacing: 1.2px; + color: var(--accent); + border-bottom: 1px solid var(--border); + padding-bottom: 4px; + margin: 14px 0 8px; +} + +section:first-child .group-title { + margin-top: 0; +} + +/* ---------- search & filters ---------- */ +.filter-bar { + display: flex; + gap: 8px; + margin-bottom: 6px; +} + +.search-input { + flex: 1; } input, -button { +select { + background: rgba(0, 0, 0, 0.4); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--fg); + padding: 6px 9px; + font-size: 13px; outline: none; } -#greet-input { - margin-right: 5px; +input:focus, +select:focus { + border-color: var(--accent); } -@media (prefers-color-scheme: dark) { - :root { - color: #f6f6f6; - background-color: #2f2f2f; - } - - a:hover { - color: #24c8db; - } - - input, - button { - color: #ffffff; - background-color: #0f0f0f98; - } - button:active { - background-color: #0f0f0f69; - } +input[type="range"] { + padding: 0; +} + +.result-count { + color: var(--fg-dim); + font-size: 12px; + margin: 2px 0 6px; +} + +/* ---------- list items ---------- */ +.list-item { + background: var(--bg-item); + border-radius: 6px; + margin-bottom: 3px; +} + +.list-item-head { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + background: none; + border: none; + color: var(--fg); + padding: 7px 10px; + cursor: pointer; + text-align: left; + font-size: 13.5px; + border-radius: 6px; +} + +.list-item-head:hover { + background: var(--bg-item-hover); +} + +.item-name { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.item-id, +.item-category { + margin-left: auto; + color: var(--fg-dim); + font-size: 11.5px; + font-family: monospace; + white-space: nowrap; +} + +.item-category { + font-family: inherit; +} + +.badge { + font-size: 10.5px; + padding: 2px 7px; + border-radius: 9px; + white-space: nowrap; + flex-shrink: 0; +} + +.badge-mod { + background: rgba(120, 80, 200, 0.35); + color: #cbb5f5; +} + +.badge-dev { + background: rgba(50, 130, 80, 0.35); + color: #a5e0b8; +} + +/* ---------- command detail ---------- */ +.command-detail { + padding: 4px 12px 12px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.command-desc { + color: var(--fg); + white-space: pre-line; + font-size: 13px; + margin: 8px 0; +} + +.buff-params { + color: var(--fg-dim); + font-size: 12px; + font-family: monospace; + margin: 4px 0; +} + +.entry-id { + color: var(--fg-dim); + font-size: 11.5px; + font-family: monospace; + margin: 4px 0 8px; +} + +.param-fields { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 8px 0; +} + +.param-field { + display: flex; + flex-direction: column; + gap: 3px; +} + +.param-field input, +.param-field select { + width: 140px; +} + +.param-field input[type="number"] { + width: 100px; +} + +.param-name { + font-size: 11px; + color: var(--fg-dim); +} + +.param-required { + color: var(--accent); +} + +.command-row { + display: flex; + align-items: center; + gap: 8px; + margin: 5px 0; +} + +.command-row-label { + font-size: 11px; + color: var(--fg-dim); + min-width: 105px; + flex-shrink: 0; +} + +.command-preview { + flex: 1; + background: rgba(0, 0, 0, 0.45); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 5px; + padding: 6px 9px; + font-size: 12.5px; + overflow-x: auto; + white-space: nowrap; + color: #d7ecc0; +} + +.copy-btn { + background: var(--accent-dim); + border: 1px solid var(--accent); + color: #fff8e0; + border-radius: 6px; + padding: 7px 14px; + cursor: pointer; + font-size: 12.5px; + white-space: nowrap; + flex-shrink: 0; +} + +.copy-btn:hover:not(:disabled) { + background: var(--accent); + color: #1c1810; +} + +.copy-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.copy-btn.copied { + background: #2e7d46; + border-color: #4caf72; +} + +.copy-btn-small { + padding: 5px 10px; +} + +.examples { + margin-top: 10px; + border-top: 1px dashed rgba(255, 255, 255, 0.1); + padding-top: 8px; +} + +.example-caption { + color: var(--fg-dim); + font-size: 12px; + margin-top: 6px; +} + +/* ---------- quick tab ---------- */ +.quick-row { + display: flex; + align-items: center; + gap: 12px; + background: var(--bg-item); + border-radius: 6px; + padding: 9px 12px; + margin-bottom: 4px; +} + +.quick-row-main { + flex: 1; + min-width: 0; +} + +.quick-row-head { + display: flex; + align-items: center; + gap: 10px; +} + +.quick-label { + font-weight: 600; +} + +.quick-desc { + color: var(--fg-dim); + font-size: 12.5px; + margin: 3px 0; +} + +.quick-row .command-preview { + display: block; + margin-top: 4px; +} + +/* ---------- settings ---------- */ +.setting-row { + display: flex; + align-items: center; + gap: 12px; + padding: 7px 0; +} + +.setting-label { + min-width: 190px; +} + +.setting-error { + color: #ff8a80; + font-size: 12px; +} + +.hotkey-btn { + background: rgba(0, 0, 0, 0.4); + border: 1px solid var(--border); + color: var(--accent); + border-radius: 6px; + padding: 6px 14px; + cursor: pointer; + font-family: monospace; +} + +.hotkey-btn:hover { + border-color: var(--accent); +} + +.notes { + color: var(--fg-dim); + font-size: 13px; + line-height: 1.7; + padding-left: 18px; +} + +.notes code { + background: rgba(0, 0, 0, 0.45); + padding: 1px 5px; + border-radius: 4px; + color: #d7ecc0; +} + +/* ---------- scrollbars ---------- */ +::-webkit-scrollbar { + width: 9px; + height: 9px; +} + +::-webkit-scrollbar-thumb { + background: rgba(201, 162, 39, 0.35); + border-radius: 5px; +} + +::-webkit-scrollbar-track { + background: transparent; } diff --git a/src/App.tsx b/src/App.tsx index 8286a76..efe0a79 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,51 +1,76 @@ -import { useState } from "react"; -import reactLogo from "./assets/react.svg"; -import { invoke } from "@tauri-apps/api/core"; +import { useEffect, useState } from "react"; +import type { Settings } from "./types"; +import { getHotkey, hideWindow, storeGet, storeSet } from "./lib/tauri"; +import { QuickTab } from "./tabs/QuickTab"; +import { CommandsTab } from "./tabs/CommandsTab"; +import { BuffsTab, ItemsTab, PerksTab, SkillsTab } from "./tabs/databases"; +import { SettingsTab } from "./tabs/SettingsTab"; import "./App.css"; -function App() { - const [greetMsg, setGreetMsg] = useState(""); - const [name, setName] = useState(""); +const TABS = ["Quick", "Commands", "Items", "Perks", "Buffs", "Skills", "Settings"] as const; +type Tab = (typeof TABS)[number]; - async function greet() { - // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ - setGreetMsg(await invoke("greet", { name })); +const DEFAULT_SETTINGS: Settings = { flavor: "cheatmod", opacity: 0.92, startHidden: false }; + +export default function App() { + const [tab, setTab] = useState("Quick"); + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [hotkey, setHotkeyHint] = useState(""); + + useEffect(() => { + (async () => { + const [flavor, opacity, startHidden, hk] = await Promise.all([ + storeGet("flavor"), + storeGet("opacity"), + storeGet("start_hidden"), + getHotkey(), + ]); + setSettings({ + flavor: flavor === "devmode" ? "devmode" : "cheatmod", + opacity: typeof opacity === "number" ? opacity : DEFAULT_SETTINGS.opacity, + startHidden: startHidden ?? DEFAULT_SETTINGS.startHidden, + }); + setHotkeyHint(hk); + })(); + }, []); + + function patchSettings(patch: Partial) { + setSettings((prev) => { + const next = { ...prev, ...patch }; + if (patch.flavor !== undefined) void storeSet("flavor", next.flavor); + if (patch.opacity !== undefined) void storeSet("opacity", next.opacity); + if (patch.startHidden !== undefined) void storeSet("start_hidden", next.startHidden); + return next; + }); } return ( -
-

Welcome to Tauri + React

- - -

Click on the Tauri, Vite, and React logos to learn more.

- -
{ - e.preventDefault(); - greet(); - }} - > - setName(e.currentTarget.value)} - placeholder="Enter a name..." - /> - -
-

{greetMsg}

-
+
+
+ + ⚔ KCD2 Overlay + + {hotkey && {hotkey} to toggle} + +
+ +
+ {tab === "Quick" && } + {tab === "Commands" && } + {tab === "Items" && } + {tab === "Perks" && } + {tab === "Buffs" && } + {tab === "Skills" && } + {tab === "Settings" && } +
+
); } - -export default App; diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx new file mode 100644 index 0000000..33279e9 --- /dev/null +++ b/src/components/CopyButton.tsx @@ -0,0 +1,37 @@ +import { useRef, useState } from "react"; +import { copyText } from "../lib/tauri"; + +export function CopyButton({ command, small }: { command: string; small?: boolean }) { + const [copied, setCopied] = useState(false); + const timer = useRef | undefined>(undefined); + const incomplete = command.includes("<"); + + async function onClick() { + await copyText(command); + setCopied(true); + clearTimeout(timer.current); + timer.current = setTimeout(() => setCopied(false), 1200); + } + + return ( + + ); +} + +/** A read-only command preview with its copy button. */ +export function CommandRow({ command, label }: { command: string; label?: string }) { + return ( +
+ {label && {label}} + {command} + +
+ ); +} diff --git a/src/components/ParamFields.tsx b/src/components/ParamFields.tsx new file mode 100644 index 0000000..90c9025 --- /dev/null +++ b/src/components/ParamFields.tsx @@ -0,0 +1,62 @@ +import type { TemplateParam } from "../types"; + +interface Props { + params: TemplateParam[]; + values: Record; + onChange: (name: string, value: string) => void; +} + +export function ParamFields({ params, values, onChange }: Props) { + if (params.length === 0) return null; + return ( +
+ {params.map((p) => ( + + ))} +
+ ); +} + +/** Initial values for a param list, using declared defaults. */ +export function defaultValues(params: TemplateParam[]): Record { + const values: Record = {}; + for (const p of params) { + if (p.default !== undefined) values[p.name] = String(p.default); + else if (p.type === "select" && p.required && p.options?.length) values[p.name] = p.options[0]; + } + return values; +} + +export function Badge({ source }: { source: string }) { + return source === "cheatmod" ? ( + + Cheat mod + + ) : ( + + devmode + + ); +} diff --git a/src/lib/commands.ts b/src/lib/commands.ts new file mode 100644 index 0000000..d2fd160 --- /dev/null +++ b/src/lib/commands.ts @@ -0,0 +1,40 @@ +import type { AnyCommand, CommandArg, ModCommand, VanillaCommand } from "../types"; +import modCommands from "../data/commands-mod.json"; +import vanillaCommands from "../data/commands-vanilla.json"; + +export const allCommands: AnyCommand[] = [ + ...(vanillaCommands as VanillaCommand[]), + ...(modCommands as ModCommand[]), +].sort((a, b) => a.name.localeCompare(b.name)); + +export const allCategories = [...new Set(allCommands.map((c) => c.category))].sort(); + +export function isModCommand(c: AnyCommand): c is ModCommand { + return c.source === "cheatmod"; +} + +/** Fills `{placeholder}` slots in a vanilla command template. */ +export function buildFromTemplate(template: string, values: Record): string { + return template.replace(/\{(\w+)\}/g, (_, key) => values[key]?.trim() || `<${key}>`); +} + +/** Builds a Cheat-mod command: name followed by `arg:value` pairs that have values. */ +export function buildFromArgs(name: string, args: CommandArg[], values: Record): string { + const parts = [name]; + for (const arg of args) { + const v = values[arg.name]?.trim(); + if (v) parts.push(`${arg.name}:${v}`); + else if (arg.required) parts.push(`${arg.name}:<${arg.name}>`); + } + return parts.join(" "); +} + +/** Token search: every whitespace-separated token must appear in name or description. */ +export function matches(query: string, ...fields: (string | undefined)[]): boolean { + const haystack = fields.filter(Boolean).join(" ").toLowerCase(); + return query + .toLowerCase() + .split(/\s+/) + .filter(Boolean) + .every((token) => haystack.includes(token)); +} diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts new file mode 100644 index 0000000..8aeaa2e --- /dev/null +++ b/src/lib/tauri.ts @@ -0,0 +1,67 @@ +// Thin wrappers around Tauri APIs with browser fallbacks, so `npm run dev` +// in a plain browser stays usable for UI work. + +export const inTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; + +export async function copyText(text: string): Promise { + if (inTauri) { + const { writeText } = await import("@tauri-apps/plugin-clipboard-manager"); + await writeText(text); + } else { + await navigator.clipboard.writeText(text); + } +} + +export async function hideWindow(): Promise { + if (!inTauri) return; + const { invoke } = await import("@tauri-apps/api/core"); + await invoke("hide_window"); +} + +export async function getHotkey(): Promise { + if (!inTauri) return "ctrl+shift+k"; + const { invoke } = await import("@tauri-apps/api/core"); + return invoke("get_hotkey"); +} + +export async function setHotkey(hotkey: string): Promise { + if (!inTauri) return; + const { invoke } = await import("@tauri-apps/api/core"); + await invoke("set_hotkey", { hotkey }); +} + +type StoreValue = string | number | boolean; + +export async function storeGet(key: string): Promise { + if (inTauri) { + const { load } = await import("@tauri-apps/plugin-store"); + const store = await load("settings.json"); + return (await store.get(key)) ?? undefined; + } + const raw = localStorage.getItem(key); + return raw === null ? undefined : (JSON.parse(raw) as T); +} + +export async function storeSet(key: string, value: StoreValue): Promise { + if (inTauri) { + const { load } = await import("@tauri-apps/plugin-store"); + const store = await load("settings.json"); + await store.set(key, value); + await store.save(); + } else { + localStorage.setItem(key, JSON.stringify(value)); + } +} + +export async function getAutostart(): Promise { + if (!inTauri) return false; + const { isEnabled } = await import("@tauri-apps/plugin-autostart"); + return isEnabled(); +} + +export async function setAutostart(enabled: boolean): Promise { + if (!inTauri) return; + const { enable, disable } = await import("@tauri-apps/plugin-autostart"); + if (enabled) await enable(); + else await disable(); +} diff --git a/src/tabs/CommandsTab.tsx b/src/tabs/CommandsTab.tsx new file mode 100644 index 0000000..304c03c --- /dev/null +++ b/src/tabs/CommandsTab.tsx @@ -0,0 +1,112 @@ +import { useMemo, useState } from "react"; +import type { AnyCommand, Source, TemplateParam } from "../types"; +import { allCategories, allCommands, buildFromArgs, buildFromTemplate, isModCommand, matches } from "../lib/commands"; +import { CommandRow, CopyButton } from "../components/CopyButton"; +import { Badge, ParamFields, defaultValues } from "../components/ParamFields"; + +const RESULT_CAP = 150; + +function CommandDetail({ cmd }: { cmd: AnyCommand }) { + const params: TemplateParam[] = isModCommand(cmd) + ? cmd.args.map((a) => ({ + name: a.name, + required: a.required, + desc: a.desc, + type: a.type === "number" ? "number" : a.type === "boolean" ? "select" : "string", + options: a.type === "boolean" ? ["true", "false"] : undefined, + })) + : cmd.params; + const [values, setValues] = useState(() => defaultValues(params)); + + const command = isModCommand(cmd) + ? buildFromArgs(cmd.name, cmd.args, values) + : buildFromTemplate(cmd.template, values); + + return ( +
+ {cmd.desc &&

{cmd.desc}

} + setValues((v) => ({ ...v, [name]: value }))} + /> +
+ {command} + +
+ {isModCommand(cmd) && cmd.examples.length > 0 && ( +
+ {cmd.examples.map((ex, i) => ( +
+ {ex.caption &&
{ex.caption}
} + +
+ ))} +
+ )} +
+ ); +} + +export function CommandsTab() { + const [query, setQuery] = useState(""); + const [category, setCategory] = useState(null); + const [source, setSource] = useState(null); + const [openName, setOpenName] = useState(null); + + const filtered = useMemo( + () => + allCommands.filter( + (c) => + (!category || c.category === category) && + (!source || c.source === source) && + (!query || matches(query, c.name, c.desc, c.category)), + ), + [query, category, source], + ); + const shown = filtered.slice(0, RESULT_CAP); + + return ( +
+
+ setQuery(e.target.value)} + /> + + +
+
+ {filtered.length} command{filtered.length === 1 ? "" : "s"} + {filtered.length > RESULT_CAP ? ` — showing first ${RESULT_CAP}, refine your search` : ""} +
+ {shown.map((cmd) => ( +
+ + {openName === cmd.name && } +
+ ))} +
+ ); +} diff --git a/src/tabs/DatabaseTab.tsx b/src/tabs/DatabaseTab.tsx new file mode 100644 index 0000000..eae7828 --- /dev/null +++ b/src/tabs/DatabaseTab.tsx @@ -0,0 +1,81 @@ +import { useMemo, useState } from "react"; +import type { DbEntry, TemplateParam } from "../types"; +import { matches } from "../lib/commands"; +import { CommandRow } from "../components/CopyButton"; +import { ParamFields, defaultValues } from "../components/ParamFields"; + +const RESULT_CAP = 150; + +export interface BuiltCommand { + label: string; + command: string; +} + +interface Props { + entries: DbEntry[]; + placeholder: string; + params: TemplateParam[]; + /** Builds the copyable command variants for one entry. */ + build: (entry: DbEntry, values: Record) => BuiltCommand[]; +} + +function EntryDetail({ entry, params, build }: { entry: DbEntry } & Pick) { + const [values, setValues] = useState(() => defaultValues(params)); + return ( +
+ {entry.desc &&

{entry.desc}

} + {entry.params &&

Effects: {entry.params}

} +

ID: {entry.id}

+ setValues((v) => ({ ...v, [name]: value }))} + /> + {build(entry, values).map((row) => ( + + ))} +
+ ); +} + +export function DatabaseTab({ entries, placeholder, params, build }: Props) { + const [query, setQuery] = useState(""); + const [openId, setOpenId] = useState(null); + + const filtered = useMemo( + () => (query ? entries.filter((e) => matches(query, e.name, e.desc, e.id)) : entries), + [entries, query], + ); + const shown = filtered.slice(0, RESULT_CAP); + + return ( +
+
+ setQuery(e.target.value)} + autoFocus + /> +
+
+ {filtered.length} result{filtered.length === 1 ? "" : "s"} + {filtered.length > RESULT_CAP ? ` — showing first ${RESULT_CAP}, refine your search` : ""} +
+ {shown.map((entry) => ( +
+ + {openId === entry.id && } +
+ ))} +
+ ); +} diff --git a/src/tabs/QuickTab.tsx b/src/tabs/QuickTab.tsx new file mode 100644 index 0000000..71034d3 --- /dev/null +++ b/src/tabs/QuickTab.tsx @@ -0,0 +1,50 @@ +import { useState } from "react"; +import type { QuickEntry, QuickGroup, Source } from "../types"; +import quickCheats from "../data/quick-cheats.json"; +import { buildFromTemplate } from "../lib/commands"; +import { CopyButton } from "../components/CopyButton"; +import { Badge, ParamFields, defaultValues } from "../components/ParamFields"; + +function QuickRow({ entry, flavor }: { entry: QuickEntry; flavor: Source }) { + const [values, setValues] = useState(() => defaultValues(entry.params)); + + // Preferred flavor first, but fall back to whichever variant exists. + const source: Source = entry.variants[flavor] ? flavor : flavor === "cheatmod" ? "devmode" : "cheatmod"; + const template = entry.variants[source]!; + const command = buildFromTemplate(template, values); + + return ( +
+
+
+ {entry.label} + +
+
{entry.desc}
+ setValues((v) => ({ ...v, [name]: value }))} + /> + {command} +
+ +
+ ); +} + +export function QuickTab({ flavor }: { flavor: Source }) { + const groups = quickCheats as QuickGroup[]; + return ( +
+ {groups.map((group) => ( +
+

{group.group}

+ {group.entries.map((entry) => ( + + ))} +
+ ))} +
+ ); +} diff --git a/src/tabs/SettingsTab.tsx b/src/tabs/SettingsTab.tsx new file mode 100644 index 0000000..adff5e1 --- /dev/null +++ b/src/tabs/SettingsTab.tsx @@ -0,0 +1,146 @@ +import { useEffect, useState } from "react"; +import type { Settings, Source } from "../types"; +import { getAutostart, getHotkey, inTauri, setAutostart, setHotkey } from "../lib/tauri"; + +interface Props { + settings: Settings; + onChange: (patch: Partial) => void; +} + +/** Turns a keydown event into a shortcut string the global-shortcut plugin accepts. */ +function eventToShortcut(e: KeyboardEvent): string | null { + const mods: string[] = []; + if (e.ctrlKey) mods.push("ctrl"); + if (e.altKey) mods.push("alt"); + if (e.shiftKey) mods.push("shift"); + if (e.metaKey) mods.push("super"); + + let key: string | null = null; + if (/^Key([A-Z])$/.test(e.code)) key = e.code.slice(3); + else if (/^Digit(\d)$/.test(e.code)) key = e.code.slice(5); + else if (/^F\d{1,2}$/.test(e.code)) key = e.code; + + if (!key) return null; + // A bare letter/digit would hijack normal typing; F-keys may stand alone. + if (mods.length === 0 && !key.startsWith("F")) return null; + return [...mods, key].join("+"); +} + +function HotkeyRecorder() { + const [hotkey, setLocal] = useState("…"); + const [recording, setRecording] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + getHotkey().then(setLocal); + }, []); + + useEffect(() => { + if (!recording) return; + const onKey = async (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.key === "Escape") { + setRecording(false); + return; + } + const shortcut = eventToShortcut(e); + if (!shortcut) return; // modifier-only or unsupported key; keep listening + setRecording(false); + try { + await setHotkey(shortcut); + setLocal(shortcut); + setError(null); + } catch (err) { + setError(String(err)); + } + }; + window.addEventListener("keydown", onKey, true); + return () => window.removeEventListener("keydown", onKey, true); + }, [recording]); + + return ( +
+ Toggle hotkey + + {error && {error}} +
+ ); +} + +export function SettingsTab({ settings, onChange }: Props) { + const [autostart, setAutostartState] = useState(false); + + useEffect(() => { + getAutostart().then(setAutostartState); + }, []); + + return ( +
+

Overlay

+ +
+ Background opacity + onChange({ opacity: Number(e.target.value) / 100 })} + /> + {Math.round(settings.opacity * 100)}% +
+
+ Preferred command flavor + +
+
+ +
+
+ +
+ +

Setup notes

+
    +
  • + Run KCD2 in borderless window mode, or the overlay cannot appear above it. +
  • +
  • + devmode commands: add -devmode to the game's launch options; open the console with{" "} + ~. +
  • +
  • + Cheat mod commands need the free "Cheat" mod from Nexus Mods (mod ID 114). +
  • +
  • + On Linux/Wayland the hotkey may not register — bind{" "} + kcd2-overlay --toggle to a compositor shortcut instead. +
  • +
+
+ ); +} diff --git a/src/tabs/databases.tsx b/src/tabs/databases.tsx new file mode 100644 index 0000000..5cbcfa2 --- /dev/null +++ b/src/tabs/databases.tsx @@ -0,0 +1,88 @@ +import type { DbEntry, TemplateParam } from "../types"; +import itemsData from "../data/items.json"; +import perksData from "../data/perks.json"; +import buffsData from "../data/buffs.json"; +import skillsData from "../data/skills.json"; +import { DatabaseTab, BuiltCommand } from "./DatabaseTab"; + +const items = itemsData as DbEntry[]; +const perks = perksData as DbEntry[]; +const buffs = buffsData as DbEntry[]; +const skills = skillsData as DbEntry[]; + +const num = (v: string | undefined, fallback: number) => { + const n = Number(v); + return Number.isFinite(n) && v !== "" && v !== undefined ? n : fallback; +}; + +const itemParams: TemplateParam[] = [ + { name: "amount", type: "number", default: 1, desc: "How many to add" }, + { name: "condition", type: "number", default: 100, desc: "Condition 0–100" }, +]; + +function buildItem(entry: DbEntry, values: Record): BuiltCommand[] { + const amount = num(values.amount, 1); + const condition = num(values.condition, 100); + return [ + { + label: "Cheat mod", + command: `cheat_add_item exact:${entry.id} amount:${amount} condition:${condition}`, + }, + { + label: "devmode (Lua)", + command: `#player.inventory:CreateItem('${entry.id}', ${condition / 100}, ${amount})`, + }, + { + label: "devmode (1×)", + command: `wh_cheat_addItem ${entry.id}`, + }, + ]; +} + +function buildPerk(entry: DbEntry): BuiltCommand[] { + return [ + { + label: "Cheat mod", + command: `cheat_add_perk i_know_what_i_am_doing:true exact:${entry.id}`, + }, + { + label: "devmode (Lua)", + command: `#player.soul:AddPerk('${entry.id}')`, + }, + ]; +} + +function buildBuff(entry: DbEntry): BuiltCommand[] { + return [ + { label: "Cheat mod (add)", command: `cheat_add_buff exact:${entry.id}` }, + { label: "Cheat mod (remove)", command: `cheat_remove_buff exact:${entry.id}` }, + { label: "devmode (Lua)", command: `#player.soul:AddBuff('${entry.id}')` }, + ]; +} + +const skillParams: TemplateParam[] = [ + { name: "level", type: "number", default: 20, desc: "Target level (1–30)" }, +]; + +function buildSkill(entry: DbEntry, values: Record): BuiltCommand[] { + const level = num(values.level, 20); + return [ + { + label: "Cheat mod", + command: `cheat_set_skill_level any:${entry.name.toLowerCase()} level:${level}`, + }, + ]; +} + +export const ItemsTab = () => ( + +); +export const PerksTab = () => ( + +); +export const BuffsTab = () => ( + +); +export const SkillsTab = () => ( + +); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..1b1d8ce --- /dev/null +++ b/src/types.ts @@ -0,0 +1,69 @@ +export type Source = "cheatmod" | "devmode"; + +export interface CommandArg { + name: string; + required: boolean; + type: string; // "string" | "number" | "boolean" + desc: string; +} + +export interface CommandExample { + caption: string; + command: string; +} + +/** A command from the Cheat mod docs (built as `name arg:value ...`). */ +export interface ModCommand { + name: string; + desc: string; + args: CommandArg[]; + examples: CommandExample[]; + category: string; + source: "cheatmod"; +} + +export interface TemplateParam { + name: string; + type: "number" | "string" | "select"; + required?: boolean; + desc?: string; + default?: string | number; + options?: string[]; +} + +/** A hand-curated vanilla command (built from a `{placeholder}` template). */ +export interface VanillaCommand { + name: string; + template: string; + desc: string; + params: TemplateParam[]; + category: string; + source: "devmode"; +} + +export type AnyCommand = ModCommand | VanillaCommand; + +export interface DbEntry { + id: string; + name: string; + desc: string; + params?: string; // buffs only: effect formula +} + +export interface QuickEntry { + label: string; + desc: string; + params: TemplateParam[]; + variants: Partial>; +} + +export interface QuickGroup { + group: string; + entries: QuickEntry[]; +} + +export interface Settings { + flavor: Source; + opacity: number; // 0.5–1 + startHidden: boolean; +}