feat: adapt Exile-ui for linux
This commit is contained in:
24
src/app.css
Normal file
24
src/app.css
Normal file
@ -0,0 +1,24 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #14110d;
|
||||
--panel: #1d1812;
|
||||
--border: #3a2f22;
|
||||
--text: #d8cdbb;
|
||||
--muted: #8c8170;
|
||||
--accent: #c8a24a;
|
||||
--accent2: #66b2ff;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: "Fontin SmallCaps", "Segoe UI", system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
13
src/app.html
Normal file
13
src/app.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Exile UI</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
374
src/lib/Layouts.svelte
Normal file
374
src/lib/Layouts.svelte
Normal file
@ -0,0 +1,374 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
getStatus,
|
||||
getConfig,
|
||||
toggleLayout,
|
||||
setLayoutSize,
|
||||
startLayoutDrag,
|
||||
endLayoutDrag,
|
||||
type Status,
|
||||
type Config,
|
||||
} from "$lib/api";
|
||||
import {
|
||||
loadManifest,
|
||||
childrenOf,
|
||||
imageUrl,
|
||||
isDeadEnd,
|
||||
rotationBlocked,
|
||||
type LayoutManifest,
|
||||
} from "$lib/layouts";
|
||||
|
||||
let manifest = $state<LayoutManifest>({});
|
||||
let status = $state<Status | null>(null);
|
||||
let config = $state<Config | null>(null);
|
||||
|
||||
// Decision-tree cursor + per-zone view state (reset whenever the zone changes).
|
||||
let path = $state("");
|
||||
let excluded = $state<string[]>([]);
|
||||
let rot = $state(0); // degrees, multiples of 90
|
||||
let flipH = $state(false);
|
||||
let flipV = $state(false);
|
||||
|
||||
let lastArea = "";
|
||||
function syncArea(s: Status | null) {
|
||||
const id = s?.area_id ?? "";
|
||||
if (id !== lastArea) {
|
||||
lastArea = id;
|
||||
path = "";
|
||||
excluded = [];
|
||||
rot = 0;
|
||||
flipH = false;
|
||||
flipV = false;
|
||||
}
|
||||
}
|
||||
|
||||
const areaId = $derived(status?.area_id ?? "");
|
||||
const areaName = $derived(status?.area_name || areaId || "—");
|
||||
const paths = $derived(manifest[areaId] ?? []);
|
||||
const hasZone = $derived(paths.length > 0);
|
||||
const candidates = $derived(
|
||||
childrenOf(paths, path).filter((p) => !excluded.includes(p)),
|
||||
);
|
||||
const resolved = $derived(path !== "" && childrenOf(paths, path).length === 0);
|
||||
const blocked = $derived(rotationBlocked(areaId));
|
||||
const transform = $derived(
|
||||
`rotate(${rot}deg) scaleX(${flipH ? -1 : 1}) scaleY(${flipV ? -1 : 1})`,
|
||||
);
|
||||
const crumbs = $derived(path === "" ? [] : path.split("_"));
|
||||
|
||||
function pick(p: string) {
|
||||
path = p;
|
||||
}
|
||||
function back() {
|
||||
const seg = path.split("_");
|
||||
seg.pop();
|
||||
path = seg.join("_");
|
||||
}
|
||||
function exclude(p: string) {
|
||||
if (!excluded.includes(p)) excluded = [...excluded, p];
|
||||
}
|
||||
function rotate(dir: number) {
|
||||
rot = (((rot + dir * 90) % 360) + 360) % 360;
|
||||
}
|
||||
function resetOrientation() {
|
||||
rot = 0;
|
||||
flipH = false;
|
||||
flipV = false;
|
||||
}
|
||||
|
||||
// Keep the OS window sized to the rendered content.
|
||||
let boxW = $state(0);
|
||||
let boxH = $state(0);
|
||||
$effect(() => {
|
||||
if (boxW > 0 && boxH > 0) setLayoutSize(Math.ceil(boxW), Math.ceil(boxH));
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
let alive = true;
|
||||
// The layout window is created hidden, and its `listen` event subscription
|
||||
// never resolves in that state (unlike the always-visible overlay), so live
|
||||
// status events don't arrive here. The `get_status` command does work, so we
|
||||
// poll it and only react when the area actually changes (cheap; no flicker).
|
||||
(async () => {
|
||||
manifest = await loadManifest();
|
||||
config = await getConfig();
|
||||
while (alive) {
|
||||
const s = await getStatus();
|
||||
if (!status || s.area_id !== status.area_id) {
|
||||
status = s;
|
||||
syncArea(s);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
});
|
||||
|
||||
const imgSize = $derived(config?.layout_size ?? 360);
|
||||
|
||||
// Drag the window by its title bar. WebKitGTK's `data-tauri-drag-region` and
|
||||
// Tauri's `setPosition` don't move this window under KWin, so the Rust side
|
||||
// follows the mouse with xdotool between start/end (driven by pointerdown/up,
|
||||
// which fire reliably — only pointermove is flaky here).
|
||||
function startDrag(e: PointerEvent) {
|
||||
if (e.button !== 0 || (e.target as HTMLElement).closest("button")) return;
|
||||
e.preventDefault();
|
||||
startLayoutDrag();
|
||||
window.addEventListener("pointerup", endDrag, { once: true });
|
||||
window.addEventListener("blur", endDrag, { once: true });
|
||||
}
|
||||
function endDrag() {
|
||||
window.removeEventListener("pointerup", endDrag);
|
||||
window.removeEventListener("blur", endDrag);
|
||||
endLayoutDrag();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="viewer" bind:clientWidth={boxW} bind:clientHeight={boxH}>
|
||||
<div class="bar" role="toolbar" tabindex="-1" onpointerdown={startDrag}>
|
||||
<span class="title">🗺 {areaName}</span>
|
||||
<button class="close" title="Fermer (hotkey)" onclick={() => toggleLayout()}>✕</button>
|
||||
</div>
|
||||
|
||||
{#if !hasZone}
|
||||
<div class="empty">Pas de layout répertorié pour cette zone.</div>
|
||||
{:else}
|
||||
<!-- Main image: the deepest committed pick, with the zone's orientation. -->
|
||||
<div class="stage" style="width:{imgSize}px;height:{imgSize}px;">
|
||||
{#if path !== ""}
|
||||
<img src={imageUrl(areaId, path)} alt={path} style="transform:{transform};" />
|
||||
{:else}
|
||||
<div class="hint">Clique le layout qui correspond à ce que tu vois en jeu.</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Orientation controls (the whole zone is placed at a random rotation). -->
|
||||
{#if !blocked}
|
||||
<div class="ctrls">
|
||||
<button title="Pivoter à gauche" onclick={() => rotate(-1)}>⟲</button>
|
||||
<button title="Pivoter à droite" onclick={() => rotate(1)}>⟳</button>
|
||||
<button class:on={flipH} title="Miroir horizontal" onclick={() => (flipH = !flipH)}>⇋</button>
|
||||
<button class:on={flipV} title="Miroir vertical" onclick={() => (flipV = !flipV)}>⇅</button>
|
||||
<button title="Réinitialiser l'orientation" onclick={resetOrientation}>↺</button>
|
||||
<span class="deg">{rot}°</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Breadcrumb / back navigation. -->
|
||||
{#if path !== ""}
|
||||
<div class="crumbs">
|
||||
<button class="link" onclick={() => (path = "")}>début</button>
|
||||
{#each crumbs as seg, i}
|
||||
<span class="sep">›</span>
|
||||
<button
|
||||
class="link"
|
||||
onclick={() => (path = crumbs.slice(0, i + 1).join("_"))}
|
||||
>{seg === "x" ? "?" : seg}</button>
|
||||
{/each}
|
||||
<button class="back" onclick={back}>◀ retour</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Candidate thumbnails at the current branch. -->
|
||||
{#if candidates.length}
|
||||
<div class="label">{resolved ? "" : path === "" ? "Layouts possibles :" : "Affine :"}</div>
|
||||
<div class="thumbs">
|
||||
{#each candidates as c}
|
||||
<button
|
||||
class="thumb"
|
||||
class:dead={isDeadEnd(c)}
|
||||
title={isDeadEnd(c) ? "Aucun de ceux-ci / non répertorié" : c}
|
||||
onclick={() => pick(c)}
|
||||
oncontextmenu={(e) => {
|
||||
e.preventDefault();
|
||||
exclude(c);
|
||||
}}
|
||||
>
|
||||
<img src={imageUrl(areaId, c)} alt={c} style="transform:{transform};" />
|
||||
<span class="cap">{isDeadEnd(c) ? "?" : c.split("_").slice(-1)[0]}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="tip">clic = choisir · clic droit = exclure</div>
|
||||
{:else if resolved}
|
||||
<div class="label done">Layout identifié.</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.viewer {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
background: #0f0c08;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
width: max-content;
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 3px 6px 3px 10px;
|
||||
background: var(--accent);
|
||||
color: #14110d;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
.bar .title {
|
||||
font-weight: 700;
|
||||
font-size: 0.9em;
|
||||
text-transform: capitalize;
|
||||
flex: 1;
|
||||
}
|
||||
.bar .close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #14110d;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.empty {
|
||||
padding: 18px;
|
||||
color: var(--muted);
|
||||
font-size: 0.9em;
|
||||
text-align: center;
|
||||
}
|
||||
.stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 8px auto 4px;
|
||||
background: #000;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
.stage img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.stage .hint {
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
}
|
||||
.ctrls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.ctrls button {
|
||||
background: #2a2218;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.ctrls button:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.ctrls button.on {
|
||||
border-color: var(--accent);
|
||||
background: rgba(200, 162, 74, 0.18);
|
||||
}
|
||||
.ctrls .deg {
|
||||
color: var(--muted);
|
||||
font-size: 0.8em;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.crumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.crumbs .sep {
|
||||
color: var(--muted);
|
||||
}
|
||||
.crumbs .link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--accent2);
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.crumbs .back {
|
||||
margin-left: auto;
|
||||
background: #2a2218;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.label {
|
||||
padding: 2px 10px 0;
|
||||
font-size: 0.82em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.label.done {
|
||||
color: #7cfc00;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.thumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
max-width: 460px;
|
||||
}
|
||||
.thumb {
|
||||
position: relative;
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
padding: 0;
|
||||
background: #000;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
.thumb:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.thumb.dead {
|
||||
opacity: 0.6;
|
||||
border-style: dashed;
|
||||
}
|
||||
.thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.thumb .cap {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: var(--accent);
|
||||
font-size: 0.75em;
|
||||
padding: 0 4px;
|
||||
border-top-left-radius: 4px;
|
||||
}
|
||||
.tip {
|
||||
padding: 0 8px 8px;
|
||||
font-size: 0.72em;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
239
src/lib/Overlay.svelte
Normal file
239
src/lib/Overlay.svelte
Normal file
@ -0,0 +1,239 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
getGuide,
|
||||
getConfig,
|
||||
getStatus,
|
||||
onStep,
|
||||
onStatus,
|
||||
onConfig,
|
||||
onFocus,
|
||||
onOverlayToggle,
|
||||
onOverlayLocked,
|
||||
onGuideReload,
|
||||
getTimer,
|
||||
onTimer,
|
||||
fmtTime,
|
||||
setOverlayHeight,
|
||||
restoreOverlayPosition,
|
||||
type GuideData,
|
||||
type Config,
|
||||
type Status,
|
||||
type RunTimer,
|
||||
} from "$lib/api";
|
||||
import StepView from "$lib/StepView.svelte";
|
||||
|
||||
let guide = $state<GuideData | null>(null);
|
||||
let config = $state<Config | null>(null);
|
||||
let status = $state<Status | null>(null);
|
||||
let timer = $state<RunTimer | null>(null);
|
||||
let focused = $state(false);
|
||||
let userHidden = $state(false);
|
||||
let locked = $state(true);
|
||||
// Measured height of the rendered overlay box; drives the OS window height so
|
||||
// the window is only as tall as its content (else the WM clamps it on-screen
|
||||
// and the overlay jumps to the middle when placed/restored near a screen edge).
|
||||
let boxHeight = $state(0);
|
||||
|
||||
// Whether the overlay content should be shown. When unlocked (editing the
|
||||
// position) it is always shown; otherwise it follows the focus/toggle rules.
|
||||
const visible = $derived.by(() => {
|
||||
if (!config) return false;
|
||||
if (!locked) return true;
|
||||
if (userHidden) return false;
|
||||
return config.overlay_only_when_focused ? focused : true;
|
||||
});
|
||||
|
||||
const current = $derived(config?.current_step ?? 0);
|
||||
const currentStep = $derived(guide?.steps[current] ?? null);
|
||||
const lookahead = $derived.by(() => {
|
||||
if (!guide || !config) return [];
|
||||
const n = config.lookahead;
|
||||
return guide.steps.slice(current + 1, current + 1 + n);
|
||||
});
|
||||
|
||||
// Recommended level for the area this step heads to.
|
||||
const recommendation = $derived.by(() => {
|
||||
if (!guide || !currentStep?.target_area) return null;
|
||||
return guide.areas[currentStep.target_area]?.recommendation ?? null;
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const unlisten: Array<() => void> = [];
|
||||
(async () => {
|
||||
guide = await getGuide();
|
||||
config = await getConfig();
|
||||
status = await getStatus();
|
||||
timer = await getTimer();
|
||||
focused = status.game_focused;
|
||||
unlisten.push(
|
||||
await onStep((i) => {
|
||||
if (config) config = { ...config, current_step: i };
|
||||
}),
|
||||
);
|
||||
unlisten.push(await onStatus((s) => (status = s)));
|
||||
unlisten.push(await onConfig((c) => (config = c)));
|
||||
unlisten.push(await onFocus((f) => (focused = f)));
|
||||
unlisten.push(await onOverlayToggle(() => (userHidden = !userHidden)));
|
||||
unlisten.push(await onOverlayLocked((l) => (locked = l)));
|
||||
unlisten.push(await onGuideReload(async () => (guide = await getGuide())));
|
||||
unlisten.push(await onTimer((t) => (timer = t)));
|
||||
})();
|
||||
return () => unlisten.forEach((u) => u());
|
||||
});
|
||||
|
||||
// Keep the OS window height matched to the rendered content.
|
||||
let positionRestored = false;
|
||||
$effect(() => {
|
||||
if (visible && boxHeight > 0) {
|
||||
setOverlayHeight(Math.ceil(boxHeight) + 2);
|
||||
// Once the window is shrunk to content, restore its saved position (the WM
|
||||
// would clamp a still-tall window on-screen if we did this any earlier).
|
||||
if (!positionRestored) {
|
||||
positionRestored = true;
|
||||
setTimeout(() => restoreOverlayPosition(), 150);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if guide && config && visible}
|
||||
<div class="overlay" class:editing={!locked} style="font-size:{config.overlay_font_size}px;" bind:clientHeight={boxHeight}>
|
||||
{#if !locked}
|
||||
<div class="dragbar" data-tauri-drag-region>⠿ déplacer · reverrouille dans les réglages pour sauver</div>
|
||||
{/if}
|
||||
<div class="header">
|
||||
<span class="act">Act {currentStep ? currentStep.section + 1 : "?"}</span>
|
||||
{#if status}
|
||||
<span class="lvl">Lv {status.level || "?"}</span>
|
||||
{#if status.area_name}<span class="area">{status.area_name}</span>{/if}
|
||||
{/if}
|
||||
{#if recommendation && config.show_recommendation}
|
||||
<span class="rec">rec. {recommendation}</span>
|
||||
{/if}
|
||||
<span class="prog">{current + 1}/{guide.steps.length}</span>
|
||||
</div>
|
||||
|
||||
{#if config.timer_enabled && timer && timer.active}
|
||||
<div class="timer" class:paused={timer.manual_pause || timer.auto_paused || timer.afk_paused}>
|
||||
<span class="t-total">⏱ {fmtTime(timer.total_seconds + timer.act_seconds)}</span>
|
||||
<span class="t-act">acte {timer.current_act} · {fmtTime(timer.act_seconds)}</span>
|
||||
{#if timer.finished}
|
||||
<span class="t-flag done">terminé</span>
|
||||
{:else if timer.manual_pause}
|
||||
<span class="t-flag">pause</span>
|
||||
{:else if timer.afk_paused}
|
||||
<span class="t-flag">{focused ? "AFK" : "fenêtre"}</span>
|
||||
{:else if timer.auto_paused}
|
||||
<span class="t-flag">ville</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if timer.splits.some((s) => s > 0)}
|
||||
<div class="splits">
|
||||
{#each timer.splits as s, i}
|
||||
{#if s > 0}<span class="split">A{i} {fmtTime(s)}</span>{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if currentStep}
|
||||
<StepView step={currentStep} areas={guide.areas} current={true} showOptionals={config.show_optionals} />
|
||||
{/if}
|
||||
{#each lookahead as step}
|
||||
<StepView {step} areas={guide.areas} showOptionals={config.show_optionals} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
background: rgba(15, 12, 8, 0.82);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.overlay.editing {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(200, 162, 74, 0.4);
|
||||
}
|
||||
.dragbar {
|
||||
background: var(--accent);
|
||||
color: #14110d;
|
||||
font-size: 0.8em;
|
||||
text-align: center;
|
||||
padding: 2px;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.header .act {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.header .lvl {
|
||||
color: #cfa;
|
||||
}
|
||||
.header .area {
|
||||
color: var(--accent2);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.header .rec {
|
||||
color: var(--muted);
|
||||
}
|
||||
.header .prog {
|
||||
margin-left: auto;
|
||||
color: var(--muted);
|
||||
}
|
||||
.timer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.timer .t-total {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.timer .t-act {
|
||||
color: var(--text);
|
||||
}
|
||||
.timer.paused .t-total,
|
||||
.timer.paused .t-act {
|
||||
color: var(--muted);
|
||||
}
|
||||
.timer .t-flag {
|
||||
margin-left: auto;
|
||||
color: #e0c84a;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.timer .t-flag.done {
|
||||
color: #7CFC00;
|
||||
}
|
||||
.splits {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 2px 10px 4px;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.78em;
|
||||
}
|
||||
.splits .split {
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
488
src/lib/Settings.svelte
Normal file
488
src/lib/Settings.svelte
Normal file
@ -0,0 +1,488 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
getConfig,
|
||||
saveConfig,
|
||||
getStatus,
|
||||
getGuide,
|
||||
detectLogs,
|
||||
stepDelta,
|
||||
setOverlayLocked,
|
||||
toggleOverlay,
|
||||
toggleLayout,
|
||||
saveOverlayGeometry,
|
||||
getTimer,
|
||||
timerStart,
|
||||
timerPause,
|
||||
timerReset,
|
||||
createProfile,
|
||||
deleteProfile,
|
||||
selectProfile,
|
||||
onStatus,
|
||||
onStep,
|
||||
onConfig,
|
||||
onGuideReload,
|
||||
onTimer,
|
||||
fmtTime,
|
||||
type Config,
|
||||
type Status,
|
||||
type GuideData,
|
||||
type RunTimer,
|
||||
} from "$lib/api";
|
||||
|
||||
let config = $state<Config | null>(null);
|
||||
let status = $state<Status | null>(null);
|
||||
let guide = $state<GuideData | null>(null);
|
||||
let timer = $state<RunTimer | null>(null);
|
||||
let detected = $state<string[]>([]);
|
||||
let overlayUnlocked = $state(false);
|
||||
|
||||
// --- character profiles ---
|
||||
let newProfileName = $state("");
|
||||
let newProfileLeagueStart = $state(true);
|
||||
|
||||
async function refreshDetect() {
|
||||
detected = await detectLogs();
|
||||
}
|
||||
|
||||
async function addProfile() {
|
||||
const name = newProfileName.trim();
|
||||
if (!name) return;
|
||||
await createProfile(name, newProfileLeagueStart);
|
||||
newProfileName = "";
|
||||
// config (profiles + active_character) is pushed back via config://update
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (config) await saveConfig($state.snapshot(config));
|
||||
}
|
||||
|
||||
async function toggleLock() {
|
||||
overlayUnlocked = !overlayUnlocked;
|
||||
await setOverlayLocked(!overlayUnlocked); // locked = click-through
|
||||
if (!overlayUnlocked) await saveOverlayGeometry();
|
||||
if (overlayUnlocked) config = await getConfig();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const unlisten: Array<() => void> = [];
|
||||
(async () => {
|
||||
config = await getConfig();
|
||||
status = await getStatus();
|
||||
guide = await getGuide();
|
||||
timer = await getTimer();
|
||||
await refreshDetect();
|
||||
unlisten.push(await onStatus((s) => (status = s)));
|
||||
unlisten.push(
|
||||
await onStep((i) => {
|
||||
if (config) config = { ...config, current_step: i };
|
||||
}),
|
||||
);
|
||||
unlisten.push(await onConfig((c) => (config = c)));
|
||||
unlisten.push(await onGuideReload(async () => (guide = await getGuide())));
|
||||
unlisten.push(await onTimer((t) => (timer = t)));
|
||||
})();
|
||||
return () => unlisten.forEach((u) => u());
|
||||
});
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<h1>Exile UI <small>· Act-Tracker (PoE2)</small></h1>
|
||||
|
||||
{#if status}
|
||||
<section class="status">
|
||||
<div><b>{status.character || "—"}</b> ({status.class || "?"}) · Lv {status.level || "?"}</div>
|
||||
<div>
|
||||
Zone : <span class="hl">{status.area_name || status.area_id || "—"}</span>
|
||||
{#if status.area_level}· niveau zone {status.area_level}{/if}
|
||||
</div>
|
||||
<div class="muted">
|
||||
Jeu focus : {status.game_focused ? "✔️" : "✖️"} · areaID : {status.area_id || "—"}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if config && guide}
|
||||
<section>
|
||||
<h2>Guide</h2>
|
||||
<div class="row">
|
||||
<button onclick={() => stepDelta(-1)}>◀ Précédent</button>
|
||||
<span class="prog">Étape {config.current_step + 1} / {guide.steps.length}</span>
|
||||
<button onclick={() => stepDelta(1)}>Suivant ▶</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button onclick={() => toggleOverlay()}>Afficher/Masquer overlay</button>
|
||||
<button onclick={toggleLock}>
|
||||
{overlayUnlocked ? "🔒 Verrouiller (click-through)" : "🔓 Déverrouiller (déplacer)"}
|
||||
</button>
|
||||
</div>
|
||||
{#if overlayUnlocked}
|
||||
<p class="muted">Overlay déverrouillé : déplace/redimensionne la fenêtre, puis reverrouille pour sauvegarder.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Profils <small>· un profil = un personnage</small></h2>
|
||||
<p class="muted">
|
||||
Crée un profil par personnage (le nom doit correspondre au nom du perso
|
||||
en jeu). La progression du guide est mémorisée <b>par profil</b>, et le
|
||||
profil devient actif tout seul quand le log détecte ce personnage.
|
||||
</p>
|
||||
|
||||
<div class="status">
|
||||
Profil actif :
|
||||
<span class="hl">{config.active_character || "— aucun —"}</span>
|
||||
{#if status?.character && status.character !== config.active_character}
|
||||
· perso en jeu : <span class="hl">{status.character}</span>
|
||||
{#if !config.profiles.some((p) => p.name === status?.character)}
|
||||
<span class="muted">(pas de profil)</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if config.profiles.length}
|
||||
<div class="char-list">
|
||||
{#each config.profiles as p}
|
||||
<div class="char" class:active={config.active_character === p.name}>
|
||||
<button class="char-pick" onclick={() => selectProfile(p.name)}>
|
||||
<b>{p.name}</b>
|
||||
<span class="muted">
|
||||
étape {p.current_step + 1} · {p.league_start ? "league-start" : "standard"}
|
||||
</span>
|
||||
</button>
|
||||
<button class="del" title="Supprimer ce profil" onclick={() => deleteProfile(p.name)}>✕</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted">Aucun profil pour l'instant.</p>
|
||||
{/if}
|
||||
|
||||
<div class="row">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newProfileName}
|
||||
placeholder="nom du personnage"
|
||||
style="flex:1"
|
||||
onkeydown={(e) => e.key === "Enter" && addProfile()}
|
||||
/>
|
||||
<label class="ck" style="white-space:nowrap">
|
||||
<input type="checkbox" bind:checked={newProfileLeagueStart} />
|
||||
league-start
|
||||
</label>
|
||||
<button onclick={addProfile} disabled={!newProfileName.trim()}>+ Créer</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Layouts de zones <small>· identificateur de layout (PoE2)</small></h2>
|
||||
<p class="muted">
|
||||
Ouvre une fenêtre interactive affichant les layouts possibles de la zone
|
||||
courante. Clique celui qui correspond à ce que tu vois pour l'affiner, et
|
||||
pivote/miroir l'image pour l'aligner sur l'orientation en jeu (chaque
|
||||
instance de zone est tournée aléatoirement).
|
||||
</p>
|
||||
<label class="ck">
|
||||
<input type="checkbox" bind:checked={config.feature_layouts} onchange={save} />
|
||||
Activer le viewer de layouts
|
||||
</label>
|
||||
{#if config.feature_layouts}
|
||||
<div class="row">
|
||||
<button onclick={() => toggleLayout()}>Afficher / masquer la fenêtre layouts</button>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<label>Hotkey « layouts »
|
||||
<input type="text" bind:value={config.hotkey_layout} onchange={save} />
|
||||
</label>
|
||||
<label>Taille image (px)
|
||||
<input type="number" min="160" max="800" bind:value={config.layout_size} onchange={save} />
|
||||
</label>
|
||||
</div>
|
||||
<p class="muted">
|
||||
La fenêtre est interactive (elle prend le focus). Si KWin ne la garde pas
|
||||
au-dessus du jeu, ajoute une règle de fenêtre pour « Exile UI Layouts ».
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Timer de campagne</h2>
|
||||
<label class="ck">
|
||||
<input type="checkbox" bind:checked={config.timer_enabled} onchange={save} />
|
||||
Activer le timer
|
||||
</label>
|
||||
<label class="ck">
|
||||
<input type="checkbox" bind:checked={config.timer_pause_in_town} onchange={save} />
|
||||
Mettre en pause automatiquement en ville / hideout
|
||||
</label>
|
||||
<label class="ck">
|
||||
<input type="checkbox" bind:checked={config.timer_afk_enabled} onchange={save} />
|
||||
Anti-AFK : mettre en pause si la souris ne bouge plus
|
||||
</label>
|
||||
{#if config.timer_afk_enabled}
|
||||
<label>Délai avant pause AFK (secondes)
|
||||
<input type="number" min="10" bind:value={config.timer_afk_seconds} onchange={save} />
|
||||
</label>
|
||||
{/if}
|
||||
<label class="ck">
|
||||
<input type="checkbox" bind:checked={config.timer_pause_unfocused} onchange={save} />
|
||||
Mettre en pause quand PoE2 n'a pas le focus (alt-tab)
|
||||
</label>
|
||||
{#if config.timer_enabled}
|
||||
{#if timer && timer.active}
|
||||
<div class="status timer-box">
|
||||
<div>
|
||||
Total : <b class="hl">{fmtTime(timer.total_seconds + timer.act_seconds)}</b>
|
||||
· acte {timer.current_act} : {fmtTime(timer.act_seconds)}
|
||||
{#if timer.finished}<span class="flag done">terminé</span>
|
||||
{:else if timer.manual_pause}<span class="flag">en pause</span>
|
||||
{:else if timer.afk_paused}<span class="flag">{status?.game_focused ? "AFK" : "fenêtre (pas de focus)"}</span>
|
||||
{:else if timer.auto_paused}<span class="flag">pause auto (ville)</span>{/if}
|
||||
</div>
|
||||
{#if timer.run_name}<div class="muted">Run : {timer.run_name}</div>{/if}
|
||||
{#if timer.splits.some((s) => s > 0)}
|
||||
<div class="splits">
|
||||
{#each timer.splits as s, i}
|
||||
{#if s > 0}<span class="split">A{i}: {fmtTime(s)}</span>{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted">Aucun run en cours. Le timer démarre tout seul en entrant dans la première zone de campagne, ou via « Démarrer ».</p>
|
||||
{/if}
|
||||
<div class="row">
|
||||
<button onclick={() => timerStart()}>▶ Démarrer / redémarrer</button>
|
||||
<button onclick={() => timerPause()} disabled={!timer || !timer.active}>
|
||||
{timer && timer.manual_pause ? "Reprendre" : "⏸ Pause"}
|
||||
</button>
|
||||
<button onclick={() => timerReset()} disabled={!timer || !timer.active}>↺ Reset</button>
|
||||
</div>
|
||||
<label>Hotkey « pause timer »
|
||||
<input type="text" bind:value={config.hotkey_timer_pause} onchange={save} />
|
||||
</label>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Fichier de log (Client.txt)</h2>
|
||||
<div class="row">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={config.log_path}
|
||||
placeholder="/chemin/vers/Path of Exile 2/logs/Client.txt"
|
||||
style="flex:1"
|
||||
/>
|
||||
<button onclick={save}>Enregistrer</button>
|
||||
</div>
|
||||
{#if detected.length}
|
||||
<label>Détectés :
|
||||
<select onchange={(e) => { if (config) { config.log_path = (e.target as HTMLSelectElement).value; save(); } }}>
|
||||
<option value="">— choisir —</option>
|
||||
{#each detected as d}<option value={d}>{d}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
{:else}
|
||||
<p class="muted">Aucun log détecté automatiquement. Renseigne le chemin manuellement.</p>
|
||||
{/if}
|
||||
<button onclick={refreshDetect}>Re-scanner</button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Réglages</h2>
|
||||
<label class="ck">
|
||||
<input type="checkbox" bind:checked={config.overlay_only_when_focused} onchange={save} />
|
||||
Afficher l'overlay uniquement quand le jeu est au premier plan
|
||||
</label>
|
||||
<label class="ck">
|
||||
<input type="checkbox" bind:checked={config.league_start} onchange={save} />
|
||||
Mode « league-start » (profil) — sinon branche non-league-start du guide
|
||||
</label>
|
||||
<label class="ck">
|
||||
<input type="checkbox" bind:checked={config.show_optionals} onchange={save} />
|
||||
Afficher le contenu optionnel (profil) — loot/quêtes/rencontres « + »
|
||||
</label>
|
||||
<label class="ck">
|
||||
<input type="checkbox" bind:checked={config.show_recommendation} onchange={save} />
|
||||
Afficher les niveaux recommandés par zone
|
||||
</label>
|
||||
<label>Titre fenêtre du jeu (détection focus)
|
||||
<input type="text" bind:value={config.poe_window_match} onchange={save} />
|
||||
</label>
|
||||
<div class="grid">
|
||||
<label>Largeur overlay (px)
|
||||
<input type="number" bind:value={config.overlay_width} onchange={save} />
|
||||
</label>
|
||||
<label>Taille police (px)
|
||||
<input type="number" bind:value={config.overlay_font_size} onchange={save} />
|
||||
</label>
|
||||
<label>Étapes à anticiper
|
||||
<input type="number" min="0" max="6" bind:value={config.lookahead} onchange={save} />
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<label>Hotkey « suivant »
|
||||
<input type="text" bind:value={config.hotkey_next} onchange={save} />
|
||||
</label>
|
||||
<label>Hotkey « précédent »
|
||||
<input type="text" bind:value={config.hotkey_prev} onchange={save} />
|
||||
</label>
|
||||
<label>Hotkey « afficher/masquer »
|
||||
<input type="text" bind:value={config.hotkey_toggle} onchange={save} />
|
||||
</label>
|
||||
</div>
|
||||
<p class="muted">Format hotkey : ex. <code>Alt+X</code>, <code>Control+Shift+G</code>, <code>F8</code>.</p>
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
background: var(--bg);
|
||||
min-height: 100vh;
|
||||
padding: 16px 22px;
|
||||
max-width: 760px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1.4em;
|
||||
color: var(--accent);
|
||||
}
|
||||
h1 small {
|
||||
color: var(--muted);
|
||||
font-size: 0.6em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.05em;
|
||||
color: var(--accent2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
section {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.status {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.hl {
|
||||
color: var(--accent2);
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin: 6px 0;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
label.ck {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
select {
|
||||
width: 100%;
|
||||
background: #100d09;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 5px 7px;
|
||||
}
|
||||
label.ck input {
|
||||
width: auto;
|
||||
}
|
||||
button {
|
||||
background: #2a2218;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.prog {
|
||||
color: var(--muted);
|
||||
}
|
||||
.timer-box {
|
||||
margin: 8px 0;
|
||||
}
|
||||
.splits {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.split {
|
||||
background: #100d09;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.flag {
|
||||
margin-left: 8px;
|
||||
color: #e0c84a;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.flag.done {
|
||||
color: #7cfc00;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
code {
|
||||
background: #100d09;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.char-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.char {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
}
|
||||
.char.active .char-pick {
|
||||
border-color: var(--accent);
|
||||
background: rgba(200, 162, 74, 0.12);
|
||||
}
|
||||
button.char-pick {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
gap: 10px;
|
||||
}
|
||||
button.del {
|
||||
color: #e05555;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
</style>
|
||||
92
src/lib/StepView.svelte
Normal file
92
src/lib/StepView.svelte
Normal file
@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import type { Area, Step } from "./api";
|
||||
import { renderLine } from "./markup";
|
||||
|
||||
let {
|
||||
step,
|
||||
areas,
|
||||
current = false,
|
||||
showOptionals = true,
|
||||
}: {
|
||||
step: Step;
|
||||
areas: Record<string, Area>;
|
||||
current?: boolean;
|
||||
showOptionals?: boolean;
|
||||
} = $props();
|
||||
|
||||
const lines = $derived(
|
||||
step.lines
|
||||
.map((l) => renderLine(l, areas))
|
||||
.filter((l) => showOptionals || l.kind !== "optional"),
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="step" class:current>
|
||||
{#each lines as line}
|
||||
<div class="line {line.kind}" class:indent={line.indent > 0}>
|
||||
{@html line.html}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.step {
|
||||
padding: 6px 10px;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.step.current {
|
||||
border-left-color: var(--accent);
|
||||
background: rgba(200, 162, 74, 0.08);
|
||||
}
|
||||
.line {
|
||||
line-height: 1.35;
|
||||
margin: 1px 0;
|
||||
}
|
||||
.line.hint {
|
||||
color: var(--muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.line.optional {
|
||||
color: #9fb88f;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.line.info {
|
||||
color: #d8a0a0;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.line.indent {
|
||||
padding-left: 16px;
|
||||
}
|
||||
:global(.step .area) {
|
||||
color: var(--accent2);
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
:global(.step .chip) {
|
||||
display: inline-block;
|
||||
padding: 0 5px;
|
||||
margin: 0 1px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.82em;
|
||||
vertical-align: middle;
|
||||
background: #2a2218;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
:global(.step .chip.quest) {
|
||||
background: #2a2030;
|
||||
border-color: #5a4a6a;
|
||||
color: #c9a6e0;
|
||||
}
|
||||
:global(.step .chip.img) {
|
||||
background: #23282e;
|
||||
border-color: #3a4654;
|
||||
color: #a9c4dd;
|
||||
}
|
||||
:global(.step .icon) {
|
||||
height: 1.25em;
|
||||
width: auto;
|
||||
vertical-align: -0.25em;
|
||||
margin: 0 1px;
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
</style>
|
||||
140
src/lib/api.ts
Normal file
140
src/lib/api.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
|
||||
export interface Config {
|
||||
log_path: string | null;
|
||||
poe_window_match: string;
|
||||
overlay_only_when_focused: boolean;
|
||||
overlay_x: number;
|
||||
overlay_y: number;
|
||||
overlay_width: number;
|
||||
overlay_font_size: number;
|
||||
hotkey_next: string;
|
||||
hotkey_prev: string;
|
||||
hotkey_toggle: string;
|
||||
hotkey_timer_pause: string;
|
||||
hotkey_layout: string;
|
||||
feature_layouts: boolean;
|
||||
layout_x: number;
|
||||
layout_y: number;
|
||||
layout_size: number;
|
||||
timer_enabled: boolean;
|
||||
timer_pause_in_town: boolean;
|
||||
timer_afk_enabled: boolean;
|
||||
timer_afk_seconds: number;
|
||||
timer_pause_unfocused: boolean;
|
||||
current_step: number;
|
||||
show_optionals: boolean;
|
||||
league_start: boolean;
|
||||
lookahead: number;
|
||||
show_recommendation: boolean;
|
||||
active_character: string | null;
|
||||
profiles: Profile[];
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
name: string;
|
||||
current_step: number;
|
||||
league_start: boolean;
|
||||
show_optionals: boolean;
|
||||
}
|
||||
|
||||
export interface Status {
|
||||
character: string;
|
||||
class: string;
|
||||
level: number;
|
||||
area_id: string;
|
||||
area_name: string;
|
||||
area_level: number;
|
||||
area_seed: string;
|
||||
act: number;
|
||||
game_focused: boolean;
|
||||
}
|
||||
|
||||
export interface Area {
|
||||
id: string;
|
||||
name: string;
|
||||
recommendation: string | null;
|
||||
group: number;
|
||||
}
|
||||
|
||||
export interface Step {
|
||||
lines: string[];
|
||||
section: number;
|
||||
target_area: string | null;
|
||||
}
|
||||
|
||||
export interface GuideData {
|
||||
steps: Step[];
|
||||
areas: Record<string, Area>;
|
||||
section_count: number;
|
||||
}
|
||||
|
||||
export interface RunTimer {
|
||||
active: boolean;
|
||||
finished: boolean;
|
||||
manual_pause: boolean;
|
||||
auto_paused: boolean;
|
||||
afk_paused: boolean;
|
||||
total_seconds: number;
|
||||
act_seconds: number;
|
||||
current_act: number;
|
||||
splits: number[];
|
||||
run_name: string | null;
|
||||
}
|
||||
|
||||
export const getConfig = () => invoke<Config>("get_config");
|
||||
export const saveConfig = (cfg: Config) => invoke("save_config", { new: cfg });
|
||||
export const getStatus = () => invoke<Status>("get_status");
|
||||
export const getGuide = () => invoke<GuideData>("get_guide");
|
||||
export const getGems = () => invoke<Record<string, Record<string, number[]>>>("get_gems");
|
||||
export const detectLogs = () => invoke<string[]>("detect_logs");
|
||||
export const getTimer = () => invoke<RunTimer>("get_timer");
|
||||
export const timerStart = () => invoke("timer_start");
|
||||
export const timerPause = () => invoke("timer_pause");
|
||||
export const timerReset = () => invoke("timer_reset");
|
||||
export const stepDelta = (delta: number) => invoke("step_delta", { delta });
|
||||
export const stepGoto = (newIndex: number) => invoke("step_goto", { new: newIndex });
|
||||
export const createProfile = (name: string, leagueStart: boolean) =>
|
||||
invoke("create_profile", { name, leagueStart });
|
||||
export const deleteProfile = (name: string) => invoke("delete_profile", { name });
|
||||
export const selectProfile = (name: string) => invoke("select_profile", { name });
|
||||
export const setOverlayLocked = (locked: boolean) => invoke("set_overlay_locked", { locked });
|
||||
export const toggleOverlay = () => invoke("toggle_overlay");
|
||||
export const saveOverlayGeometry = () => invoke("save_overlay_geometry");
|
||||
export const setOverlayHeight = (height: number) => invoke("set_overlay_height", { height });
|
||||
export const restoreOverlayPosition = () => invoke("restore_overlay_position");
|
||||
export const toggleLayout = () => invoke("toggle_layout");
|
||||
export const setLayoutSize = (width: number, height: number) =>
|
||||
invoke("set_layout_size", { width, height });
|
||||
export const saveLayoutGeometry = () => invoke("save_layout_geometry");
|
||||
export const startLayoutDrag = () => invoke("start_layout_drag");
|
||||
export const endLayoutDrag = () => invoke("end_layout_drag");
|
||||
|
||||
// Event helpers ------------------------------------------------------------
|
||||
export const onStatus = (cb: (s: Status) => void): Promise<UnlistenFn> =>
|
||||
listen<Status>("status://update", (e) => cb(e.payload));
|
||||
export const onStep = (cb: (i: number) => void): Promise<UnlistenFn> =>
|
||||
listen<number>("tracker://step", (e) => cb(e.payload));
|
||||
export const onConfig = (cb: (c: Config) => void): Promise<UnlistenFn> =>
|
||||
listen<Config>("config://update", (e) => cb(e.payload));
|
||||
export const onFocus = (cb: (f: boolean) => void): Promise<UnlistenFn> =>
|
||||
listen<boolean>("focus://update", (e) => cb(e.payload));
|
||||
export const onOverlayToggle = (cb: () => void): Promise<UnlistenFn> =>
|
||||
listen("overlay://toggle", () => cb());
|
||||
export const onOverlayLocked = (cb: (locked: boolean) => void): Promise<UnlistenFn> =>
|
||||
listen<boolean>("overlay://locked", (e) => cb(e.payload));
|
||||
export const onGuideReload = (cb: () => void): Promise<UnlistenFn> =>
|
||||
listen("guide://reload", () => cb());
|
||||
export const onTimer = (cb: (t: RunTimer) => void): Promise<UnlistenFn> =>
|
||||
listen<RunTimer>("timer://update", (e) => cb(e.payload));
|
||||
|
||||
/** Format seconds as h:mm:ss or m:ss. */
|
||||
export function fmtTime(total: number): string {
|
||||
const s = Math.floor(total);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
|
||||
}
|
||||
75
src/lib/layouts.ts
Normal file
75
src/lib/layouts.ts
Normal file
@ -0,0 +1,75 @@
|
||||
// Zone-layout decision tree, ported from the AHK `act-decoder`.
|
||||
//
|
||||
// The bundled PoE2 layout images (static/layouts/) are named `<areaID> <path>.jpg`
|
||||
// where the path is a decision tree encoded as underscore-separated segments:
|
||||
// "1", "2", "3" = the candidate layouts you first see entering a zone
|
||||
// "3_1", "3_2" = refinements of "3" revealed as you explore deeper
|
||||
// "x", "x_x" = "none of these / not catalogued" dead-ends
|
||||
// You pick the candidate matching what you see in-game, then keep refining until
|
||||
// no children remain. (On disk the space is stored as "~" for URL-safe serving.)
|
||||
|
||||
export type LayoutManifest = Record<string, string[]>; // areaID -> sorted paths
|
||||
|
||||
let manifestPromise: Promise<LayoutManifest> | null = null;
|
||||
|
||||
/** Load (once) the areaID -> paths manifest generated at build time. */
|
||||
export function loadManifest(): Promise<LayoutManifest> {
|
||||
if (!manifestPromise) {
|
||||
manifestPromise = fetch("/layouts/index.json")
|
||||
.then((r) => (r.ok ? r.json() : {}))
|
||||
.catch(() => ({}));
|
||||
}
|
||||
return manifestPromise;
|
||||
}
|
||||
|
||||
/** URL of the image for a given area + decision path. */
|
||||
export function imageUrl(areaId: string, path: string): string {
|
||||
return `/layouts/${areaId}~${path}.jpg`;
|
||||
}
|
||||
|
||||
const depth = (p: string): number => (p === "" ? 0 : p.split("_").length);
|
||||
|
||||
/** Order paths numerically per segment, "x" sorting last. */
|
||||
function sortPaths(a: string, b: string): number {
|
||||
const sa = a.split("_");
|
||||
const sb = b.split("_");
|
||||
const n = Math.min(sa.length, sb.length);
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (sa[i] !== sb[i]) {
|
||||
const na = sa[i] === "x" ? Infinity : parseInt(sa[i], 10);
|
||||
const nb = sb[i] === "x" ? Infinity : parseInt(sb[i], 10);
|
||||
return na - nb;
|
||||
}
|
||||
}
|
||||
return sa.length - sb.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct children of `path` ("" = root) among `paths`: entries one segment
|
||||
* deeper that extend `path`.
|
||||
*/
|
||||
export function childrenOf(paths: string[], path: string): string[] {
|
||||
const want = depth(path) + 1;
|
||||
const prefix = path === "" ? "" : path + "_";
|
||||
return paths
|
||||
.filter((p) => p !== path && depth(p) === want && (path === "" || p.startsWith(prefix)))
|
||||
.sort(sortPaths);
|
||||
}
|
||||
|
||||
/** A path is an "x" (none-of-these / not catalogued) marker. */
|
||||
export function isDeadEnd(path: string): boolean {
|
||||
return path.split("_")[0] === "x";
|
||||
}
|
||||
|
||||
// Zones whose layout placement is fixed (or where rotation gave wrong results in
|
||||
// the original), so the viewer hides its rotation controls. Ported from the AHK
|
||||
// `rota_block`; keys there may be "<areaID>" or "<areaID> <path>" — we block the
|
||||
// whole area if any key matches (a single orientation is applied per zone).
|
||||
const ROTA_BLOCK_AREAS = new Set([
|
||||
"g1_2", "g1_4", "g1_7", "g1_8", "g1_11", "g1_12", "g1_14", "g1_15",
|
||||
"g2_2", "g2_4_3", "g2_6", "g3_11", "g3_16",
|
||||
]);
|
||||
|
||||
export function rotationBlocked(areaId: string): boolean {
|
||||
return ROTA_BLOCK_AREAS.has(areaId);
|
||||
}
|
||||
129
src/lib/markup.ts
Normal file
129
src/lib/markup.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import type { Area } from "./api";
|
||||
|
||||
// Named colors used by the guide markup; anything else that is 3/6 hex chars
|
||||
// is treated as a hex color.
|
||||
const COLOR_NAMES: Record<string, string> = {
|
||||
red: "#e05555",
|
||||
lime: "#7CFC00",
|
||||
aqua: "#55e0e0",
|
||||
yellow: "#e0c84a",
|
||||
green: "#5fbf5f",
|
||||
white: "#ffffff",
|
||||
orange: "#e08a3c",
|
||||
};
|
||||
|
||||
function resolveColor(name: string): string {
|
||||
if (COLOR_NAMES[name]) return COLOR_NAMES[name];
|
||||
if (/^[0-9a-fA-F]{6}$/.test(name) || /^[0-9a-fA-F]{3}$/.test(name)) return "#" + name;
|
||||
return "inherit";
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// Icon names available under static/icons/leveling/ (from the original AHK
|
||||
// "leveling tracker" assets). Only these render as real <img>; anything else
|
||||
// falls back to a text chip so unknown markup never shows a broken image.
|
||||
const ICONS = new Set([
|
||||
"0", "1", "2", "3", "4", "5", "6", "7",
|
||||
"arena", "artificer", "b-rune", "checkpoint", "craft", "exa", "flasks",
|
||||
"gcp", "hideout", "in-out", "in-out2", "jeweller", "lab", "portal",
|
||||
"quest", "quest_2", "regal", "ring", "rune", "skill", "skill2", "skip",
|
||||
"spirit", "spirit2", "support", "support2", "town", "waypoint",
|
||||
]);
|
||||
|
||||
// Underscores stand in for spaces in plain guide text.
|
||||
function plain(text: string, color: string): string {
|
||||
const t = escapeHtml(text.replace(/_/g, " "));
|
||||
if (!t) return "";
|
||||
if (color === "inherit") return t;
|
||||
return `<span style="color:${color}">${t}</span>`;
|
||||
}
|
||||
|
||||
export interface RenderedLine {
|
||||
html: string;
|
||||
kind: "normal" | "hint" | "optional" | "info";
|
||||
indent: number;
|
||||
}
|
||||
|
||||
const TOKEN_RE = /\(([a-z]+)(?::([^)]*))?\)|areaid([a-z0-9_]+)/gi;
|
||||
|
||||
/** Render a single guide line (with embedded markup) into HTML + metadata. */
|
||||
export function renderLine(raw: string, areas: Record<string, Area>): RenderedLine {
|
||||
let line = raw;
|
||||
|
||||
// Classify the line for styling.
|
||||
let kind: RenderedLine["kind"] = "normal";
|
||||
let indent = 0;
|
||||
const lower = line.toLowerCase();
|
||||
if (lower.startsWith("(hint)")) {
|
||||
kind = "hint";
|
||||
line = line.slice("(hint)".length);
|
||||
// leading underscores after (hint) indicate indentation
|
||||
const m = line.match(/^_+/);
|
||||
if (m) {
|
||||
indent = 1;
|
||||
line = line.slice(m[0].length);
|
||||
}
|
||||
} else if (lower.startsWith("optional:")) {
|
||||
kind = "optional";
|
||||
} else if (lower.includes("info:")) {
|
||||
kind = "info";
|
||||
}
|
||||
|
||||
// Pull out the trailing " ;; area name" annotation.
|
||||
let areaName: string | null = null;
|
||||
const sc = line.indexOf(";;");
|
||||
if (sc >= 0) {
|
||||
areaName = line.slice(sc + 2).trim();
|
||||
line = line.slice(0, sc).trim();
|
||||
}
|
||||
|
||||
let out = "";
|
||||
let color = "inherit";
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
TOKEN_RE.lastIndex = 0;
|
||||
while ((m = TOKEN_RE.exec(line)) !== null) {
|
||||
out += plain(line.slice(last, m.index), color);
|
||||
last = m.index + m[0].length;
|
||||
|
||||
const kindTok = m[1]?.toLowerCase();
|
||||
const val = m[2] ?? "";
|
||||
const areaId = m[3];
|
||||
|
||||
if (areaId !== undefined) {
|
||||
const name = areaName || areas[areaId]?.name || areaId.replace(/_/g, " ");
|
||||
out += `<span class="area">${escapeHtml(name)}</span>`;
|
||||
areaName = null; // consume once
|
||||
} else if (kindTok === "color") {
|
||||
color = resolveColor(val.trim());
|
||||
} else if (kindTok === "img") {
|
||||
const name = val.trim();
|
||||
const label = name.replace(/_/g, " ");
|
||||
if (ICONS.has(name.toLowerCase())) {
|
||||
out += `<img class="icon" src="/icons/leveling/${encodeURIComponent(
|
||||
name.toLowerCase()
|
||||
)}.png" alt="${escapeHtml(label)}" title="${escapeHtml(label)}" />`;
|
||||
} else {
|
||||
out += `<span class="chip img" data-img="${escapeHtml(name)}">${escapeHtml(
|
||||
label
|
||||
)}</span>`;
|
||||
}
|
||||
} else if (kindTok === "quest") {
|
||||
out += `<span class="chip quest">${escapeHtml(val.replace(/_/g, " "))}</span>`;
|
||||
} else if (kindTok === "emph") {
|
||||
// ignored: treated as a plain emphasis marker
|
||||
}
|
||||
// (hint) handled at line level above
|
||||
}
|
||||
out += plain(line.slice(last), color);
|
||||
|
||||
// Any leftover area name annotation with no areaid token: append it.
|
||||
if (areaName) {
|
||||
out += ` <span class="area">${escapeHtml(areaName)}</span>`;
|
||||
}
|
||||
|
||||
return { html: out, kind, indent };
|
||||
}
|
||||
6
src/routes/+layout.svelte
Normal file
6
src/routes/+layout.svelte
Normal file
@ -0,0 +1,6 @@
|
||||
<script lang="ts">
|
||||
import "../app.css";
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
5
src/routes/+layout.ts
Normal file
5
src/routes/+layout.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// Tauri doesn't have a Node.js server to do proper SSR
|
||||
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
|
||||
// See: https://svelte.dev/docs/kit/single-page-apps
|
||||
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
|
||||
export const ssr = false;
|
||||
17
src/routes/+page.svelte
Normal file
17
src/routes/+page.svelte
Normal file
@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import Settings from "$lib/Settings.svelte";
|
||||
import Overlay from "$lib/Overlay.svelte";
|
||||
import Layouts from "$lib/Layouts.svelte";
|
||||
|
||||
// All windows load the same SPA entry; pick the view by window label.
|
||||
const label = getCurrentWindow().label;
|
||||
</script>
|
||||
|
||||
{#if label === "overlay"}
|
||||
<Overlay />
|
||||
{:else if label === "layout"}
|
||||
<Layouts />
|
||||
{:else}
|
||||
<Settings />
|
||||
{/if}
|
||||
Reference in New Issue
Block a user