Compare commits
10
Commits
044f9779e0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7db7fd1866 | ||
|
|
7da27df0d5 | ||
|
|
ab00fb685a | ||
|
|
fc70aa66fc | ||
|
|
d2e66a79fc | ||
|
|
fa84bb6eb1 | ||
|
|
325965aa56 | ||
|
|
b667da52f7 | ||
|
|
7fc73c1f89 | ||
|
|
9e8fd5919b |
No files matched your search
@@ -0,0 +1 @@
|
||||
.DS_Store
|
||||
@@ -0,0 +1,18 @@
|
||||
& {
|
||||
$saves = Join-Path $env:USERPROFILE 'AppData\LocalLow\Endnight\SonsOfTheForest\Saves'
|
||||
if (-not (Test-Path -LiteralPath $saves)) {
|
||||
Write-Host "Sons of the Forest saves folder not found: $saves" -ForegroundColor Red
|
||||
return
|
||||
}
|
||||
$latest = Get-ChildItem -LiteralPath $saves -Filter 'SaveData.zip' -File -Recurse -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
if (-not $latest) {
|
||||
Write-Host "No SaveData.zip found under $saves" -ForegroundColor Red
|
||||
return
|
||||
}
|
||||
Write-Host "Latest save: $($latest.FullName)" -ForegroundColor Green
|
||||
Write-Host "Last saved: $($latest.LastWriteTime)"
|
||||
Write-Host "Opening Explorer. Drag SaveData.zip onto the sotf-inventory page."
|
||||
Start-Process explorer.exe -ArgumentList "/select,`"$($latest.FullName)`""
|
||||
}
|
||||
@@ -14,6 +14,14 @@ Live page: https://justinoros.github.io/sotf-inventory/
|
||||
6. Click Save to download the new `SaveData.zip`.
|
||||
7. Replace the old `SaveData.zip` in your save folder with the new one and start the game.
|
||||
|
||||
Can't find your save? Close the game, open PowerShell and paste:
|
||||
|
||||
```
|
||||
irm https://raw.githubusercontent.com/JustinOros/sotf-inventory/main/Find-SaveData.ps1 | iex
|
||||
```
|
||||
|
||||
It opens Explorer with your most recent `SaveData.zip` selected.
|
||||
|
||||
A loose `PlayerInventorySaveData.json` also works if you prefer to edit the extracted file.
|
||||
|
||||
## Notes
|
||||
@@ -33,6 +41,7 @@ Push the repo, then go to Settings, Pages, and set the source to the `main` bran
|
||||
* `styles.css` theme
|
||||
* `app.js` load, edit, and save logic
|
||||
* `items.js` item IDs, names, categories, and max counts
|
||||
* `Find-SaveData.ps1` opens Explorer at your latest `SaveData.zip`
|
||||
|
||||
## Credits
|
||||
|
||||
|
||||
@@ -2,24 +2,31 @@
|
||||
const DISCLAIMER_KEY = "sotf-inventory-disclaimer-v1";
|
||||
const SAVES_PATH = "%USERPROFILE%\\AppData\\LocalLow\\Endnight\\SonsOfTheForest\\Saves";
|
||||
const INVENTORY_FILE = "PlayerInventorySaveData.json";
|
||||
const KEEP_ON_CLEAR = new Set([351, 379, 380, 402, 412, 413, 483, 486, 552, 589]);
|
||||
const FIRST_PAGE_BLUEPRINT = 665;
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const els = {
|
||||
dropZone: $("dropZone"),
|
||||
dropTarget: $("dropTarget"),
|
||||
loaderTitle: $("loaderTitle"),
|
||||
loaderStatus: $("loaderStatus"),
|
||||
browseBtn: $("browseBtn"),
|
||||
fileInput: $("fileInput"),
|
||||
copyPathBtn: $("copyPathBtn"),
|
||||
copyCmdBtn: $("copyCmdBtn"),
|
||||
helperCmd: $("helperCmd"),
|
||||
search: $("search"),
|
||||
category: $("category"),
|
||||
ownedOnly: $("ownedOnly"),
|
||||
changedOnly: $("changedOnly"),
|
||||
resultCount: $("resultCount"),
|
||||
rows: $("rows"),
|
||||
table: $("inventory"),
|
||||
emptyState: $("emptyState"),
|
||||
changeSummary: $("changeSummary"),
|
||||
resetBtn: $("resetBtn"),
|
||||
clearBtn: $("clearBtn"),
|
||||
saveBtn: $("saveBtn"),
|
||||
disclaimer: $("disclaimer"),
|
||||
disclaimerOk: $("disclaimerOk"),
|
||||
@@ -38,6 +45,7 @@
|
||||
query: "",
|
||||
category: "",
|
||||
ownedOnly: false,
|
||||
changedOnly: false,
|
||||
source: null
|
||||
};
|
||||
|
||||
@@ -87,6 +95,7 @@
|
||||
const list = [...state.items.values()].filter((i) => {
|
||||
if (state.category && i.category !== state.category) return false;
|
||||
if (state.ownedOnly && i.count === 0 && !i.equipped) return false;
|
||||
if (state.changedOnly && i.count === i.original) return false;
|
||||
if (!q) return true;
|
||||
return i.name.toLowerCase().includes(q) || (qNum !== null && i.id === qNum);
|
||||
});
|
||||
@@ -157,12 +166,17 @@
|
||||
return [...state.items.values()].filter((i) => i.count !== i.original);
|
||||
}
|
||||
|
||||
function clearableItems() {
|
||||
return [...state.items.values()].filter((i) => !i.equipped && !KEEP_ON_CLEAR.has(i.id));
|
||||
}
|
||||
|
||||
function updateSummary() {
|
||||
const n = changedItems().length;
|
||||
els.changeSummary.textContent = n === 0 ? "No changes" : `${n} item${n === 1 ? "" : "s"} changed`;
|
||||
els.changeSummary.classList.toggle("has-changes", n > 0);
|
||||
els.resetBtn.disabled = n === 0;
|
||||
els.saveBtn.disabled = !state.source || n === 0;
|
||||
els.clearBtn.disabled = !clearableItems().some((i) => i.count > 0);
|
||||
els.saveBtn.disabled = !state.source;
|
||||
els.saveBtn.title = state.source ? "" : "Load a save first";
|
||||
}
|
||||
|
||||
@@ -199,7 +213,60 @@
|
||||
return { outer, inner, innerIsString };
|
||||
}
|
||||
|
||||
function applyInventory(inner) {
|
||||
function isBlueprintPage(item) {
|
||||
return item.category === "Blueprint" && item.id >= FIRST_PAGE_BLUEPRINT;
|
||||
}
|
||||
|
||||
async function readWrapped(zip, fileName, key) {
|
||||
const entry = Object.values(zip.files).find((f) => !f.dir && f.name.split("/").pop() === fileName);
|
||||
if (!entry) return null;
|
||||
try {
|
||||
const outer = JSON.parse(await entry.async("string"));
|
||||
const raw = outer && outer.Data && outer.Data[key];
|
||||
if (raw === undefined) return null;
|
||||
const innerIsString = typeof raw === "string";
|
||||
const inner = innerIsString ? JSON.parse(raw) : raw;
|
||||
return { path: entry.name, key, outer, inner, innerIsString };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeWrapped(zip, w) {
|
||||
w.outer.Data[w.key] = w.innerIsString ? JSON.stringify(w.inner) : w.inner;
|
||||
zip.file(w.path, JSON.stringify(w.outer));
|
||||
}
|
||||
|
||||
function unlockedPages(playerState) {
|
||||
const ids = new Set();
|
||||
const entries = playerState && Array.isArray(playerState.inner._entries) ? playerState.inner._entries : [];
|
||||
for (const e of entries) {
|
||||
const m = /^DiscoverablePageUnlocked_(\d+)$/.exec(e && e.Name);
|
||||
if (m && e.BoolValue === true) ids.add(Number(m[1]));
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function describeGame(gameState) {
|
||||
if (!gameState) return "";
|
||||
const g = gameState.inner;
|
||||
const parts = [];
|
||||
if (Number.isFinite(g.GameDays)) {
|
||||
const hh = String(g.GameHours || 0).padStart(2, "0");
|
||||
const mm = String(g.GameMinutes || 0).padStart(2, "0");
|
||||
parts.push(`Day ${g.GameDays} at ${hh}:${mm}`);
|
||||
}
|
||||
if (g.GameType) parts.push(`${g.GameType} mode`);
|
||||
if (g.CrashSite) parts.push(`${g.CrashSite} crash site`);
|
||||
let text = parts.join(", ");
|
||||
if (g.SaveTime) {
|
||||
const d = new Date(String(g.SaveTime).replace(/(\.\d{3})\d+/, "$1"));
|
||||
if (!Number.isNaN(d.getTime())) text += `${text ? ". " : ""}Last saved ${d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })}`;
|
||||
}
|
||||
return text ? `${text}.` : "";
|
||||
}
|
||||
|
||||
function applyInventory(inner, pages) {
|
||||
buildCatalog();
|
||||
const addUnknown = (id, count) => {
|
||||
const name = SOTF_NAMES[id] || `Unknown item ${id}`;
|
||||
@@ -221,6 +288,12 @@
|
||||
item.equipped = true;
|
||||
}
|
||||
}
|
||||
if (pages) {
|
||||
for (const id of pages) {
|
||||
const item = state.items.get(id);
|
||||
if (item && isBlueprintPage(item)) item.count = Math.max(item.count, 1);
|
||||
}
|
||||
}
|
||||
for (const item of state.items.values()) item.original = item.count;
|
||||
ensureUnknownCategory();
|
||||
}
|
||||
@@ -242,14 +315,19 @@
|
||||
text = await entry.async("string");
|
||||
}
|
||||
const parsed = parseInventory(text);
|
||||
applyInventory(parsed.inner);
|
||||
state.source = { kind: isJson ? "json" : "zip", zip, path, fileName: isJson ? INVENTORY_FILE : "SaveData.zip", ...parsed };
|
||||
const hotkeys = zip ? await readWrapped(zip, "HotkeysSaveData.json", "Hotkeys") : null;
|
||||
const playerState = zip ? await readWrapped(zip, "PlayerStateSaveData.json", "PlayerState") : null;
|
||||
const gameState = zip ? await readWrapped(zip, "GameStateSaveData.json", "GameState") : null;
|
||||
const pages = playerState && Array.isArray(playerState.inner._entries) ? unlockedPages(playerState) : null;
|
||||
applyInventory(parsed.inner, pages);
|
||||
state.source = { kind: isJson ? "json" : "zip", zip, path, fileName: isJson ? INVENTORY_FILE : "SaveData.zip", hotkeys, playerState: pages ? playerState : null, gameState, ...parsed };
|
||||
state.sortKey = "count";
|
||||
state.sortDir = -1;
|
||||
const owned = [...state.items.values()].filter((i) => i.count > 0).length;
|
||||
els.dropZone.classList.add("loaded");
|
||||
els.loaderTitle.textContent = `Loaded ${file.name}`;
|
||||
els.loaderStatus.textContent = `${owned} item types in your inventory. Drop another file to switch saves.`;
|
||||
const info = describeGame(gameState);
|
||||
els.loaderStatus.textContent = `${info ? info + " " : ""}${owned} item types in your inventory. Drop another file to switch saves.`;
|
||||
render();
|
||||
toast(`Loaded ${file.name}`);
|
||||
} catch (err) {
|
||||
@@ -258,12 +336,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
function buildInventoryText() {
|
||||
const { outer, inner, innerIsString } = state.source;
|
||||
function applyChanges() {
|
||||
const src = state.source;
|
||||
const { inner } = src;
|
||||
const blocks = inner.ItemInstanceManagerData.ItemBlocks;
|
||||
const pagesInState = !!src.playerState;
|
||||
const removed = new Set();
|
||||
const pageChanges = [];
|
||||
for (const item of changedItems()) {
|
||||
if (item.equipped) continue;
|
||||
const page = pagesInState && isBlueprintPage(item);
|
||||
if (page) pageChanges.push(item);
|
||||
const matches = blocks.filter((b) => Number(b.ItemId) === item.id);
|
||||
if (item.count === 0) {
|
||||
removed.add(item.id);
|
||||
@@ -271,7 +354,7 @@
|
||||
continue;
|
||||
}
|
||||
if (matches.length === 0) {
|
||||
blocks.push({ ItemId: item.id, TotalCount: item.count, UniqueItems: [] });
|
||||
if (!page) blocks.push({ ItemId: item.id, TotalCount: item.count, UniqueItems: [] });
|
||||
continue;
|
||||
}
|
||||
const [first, ...rest] = matches;
|
||||
@@ -285,8 +368,36 @@
|
||||
if (Array.isArray(slots) && removed.size) {
|
||||
inner.QuickSelect.Slots = slots.filter((s) => !(s && removed.has(Number(s.ItemId))));
|
||||
}
|
||||
outer.Data.PlayerInventory = innerIsString ? JSON.stringify(inner) : inner;
|
||||
return JSON.stringify(outer);
|
||||
src.outer.Data.PlayerInventory = src.innerIsString ? JSON.stringify(inner) : inner;
|
||||
const touched = [];
|
||||
if (src.hotkeys && removed.size) {
|
||||
const h = src.hotkeys.inner;
|
||||
for (const key of ["RightHandItemIds", "LeftHandItemIds"]) {
|
||||
if (Array.isArray(h[key])) h[key] = h[key].map((id) => (removed.has(Number(id)) ? -1 : id));
|
||||
}
|
||||
touched.push(src.hotkeys);
|
||||
}
|
||||
if (src.playerState && pageChanges.length) {
|
||||
const entries = src.playerState.inner._entries;
|
||||
const upsert = (name) => {
|
||||
const e = entries.find((x) => x && x.Name === name);
|
||||
if (e) e.BoolValue = true;
|
||||
else entries.push({ Name: name, BoolValue: true });
|
||||
};
|
||||
for (const item of pageChanges) {
|
||||
const unlockName = `DiscoverablePageUnlocked_${item.id}`;
|
||||
if (item.count > 0) {
|
||||
upsert(unlockName);
|
||||
upsert(`hasOwned_${String(item.id).padStart(8, "0")}`);
|
||||
} else {
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
if (entries[i] && entries[i].Name === unlockName) entries.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
touched.push(src.playerState);
|
||||
}
|
||||
return { inventoryText: JSON.stringify(src.outer), touched };
|
||||
}
|
||||
|
||||
function download(blob, name) {
|
||||
@@ -302,11 +413,12 @@
|
||||
|
||||
async function doSave() {
|
||||
try {
|
||||
const text = buildInventoryText();
|
||||
const { inventoryText: text, touched } = applyChanges();
|
||||
const src = state.source;
|
||||
let blob;
|
||||
if (src.kind === "zip") {
|
||||
src.zip.file(src.path, text);
|
||||
for (const w of touched) writeWrapped(src.zip, w);
|
||||
blob = await src.zip.generateAsync({ type: "blob", compression: "DEFLATE", compressionOptions: { level: 6 } });
|
||||
} else {
|
||||
blob = new Blob([text], { type: "application/json" });
|
||||
@@ -347,12 +459,21 @@
|
||||
els.copyPathBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(SAVES_PATH);
|
||||
toast("Saves folder path copied. Paste it into the file dialog's address bar, then open your SteamID and save folders.");
|
||||
toast("Path copied. Paste it into the file dialog's address bar, then open your SteamID and save folders.");
|
||||
} catch {
|
||||
toast("Couldn't copy. Select the path and copy it manually.", true);
|
||||
}
|
||||
});
|
||||
|
||||
els.copyCmdBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(els.helperCmd.textContent.trim());
|
||||
toast("Command copied. Paste it into PowerShell and press Enter.");
|
||||
} catch {
|
||||
toast("Couldn't copy. Select the command and copy it manually.", true);
|
||||
}
|
||||
});
|
||||
|
||||
els.browseBtn.addEventListener("click", browse);
|
||||
els.fileInput.addEventListener("change", () => loadFile(els.fileInput.files[0]));
|
||||
|
||||
@@ -375,8 +496,9 @@
|
||||
els.dropZone.classList.remove("dragging");
|
||||
loadFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
els.dropZone.addEventListener("keydown", (e) => {
|
||||
if (e.target === els.dropZone && (e.key === "Enter" || e.key === " ")) {
|
||||
els.dropTarget.addEventListener("click", browse);
|
||||
els.dropTarget.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
browse();
|
||||
}
|
||||
@@ -389,6 +511,7 @@
|
||||
});
|
||||
els.category.addEventListener("change", () => { state.category = els.category.value; render(); });
|
||||
els.ownedOnly.addEventListener("change", () => { state.ownedOnly = els.ownedOnly.checked; render(); });
|
||||
els.changedOnly.addEventListener("change", () => { state.changedOnly = els.changedOnly.checked; render(); });
|
||||
|
||||
els.table.querySelector("thead").addEventListener("click", (e) => {
|
||||
const th = e.target.closest("th[data-sort]");
|
||||
@@ -442,6 +565,12 @@
|
||||
render();
|
||||
});
|
||||
|
||||
els.clearBtn.addEventListener("click", () => {
|
||||
for (const item of clearableItems()) item.count = 0;
|
||||
render();
|
||||
toast("Inventory cleared. Equipped items and starting tools were kept. Use Undo changes to restore.");
|
||||
});
|
||||
|
||||
els.saveBtn.addEventListener("click", () => {
|
||||
if (!state.source) return;
|
||||
els.saveFileName.textContent = state.source.fileName;
|
||||
|
||||
+51
-12
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>SOTF Inventory Editor</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌲</text></svg>">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
@@ -16,23 +17,56 @@
|
||||
<h1>Inventory</h1>
|
||||
<p class="subtitle">A save editor for Sons of the Forest. Load your save, set your counts, download the new file.</p>
|
||||
</div>
|
||||
<svg class="treeline" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<pattern id="trees" width="120" height="90" patternUnits="userSpaceOnUse">
|
||||
<path transform="scale(1 1.5)" fill="currentColor" d="M0 60 L0 44 L8 30 L5 30 L12 16 L9 16 L16 2 L23 16 L20 16 L27 30 L24 30 L32 44 L32 60 Z M30 60 L38 40 L35 40 L42 26 L39 26 L46 14 L53 26 L50 26 L57 40 L54 40 L62 60 Z M58 60 L66 36 L63 36 L71 20 L68 20 L76 6 L84 20 L81 20 L89 36 L86 36 L94 60 Z M90 60 L97 44 L94 44 L101 30 L98 30 L105 20 L112 30 L109 30 L116 44 L113 44 L120 60 Z"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#trees)"/>
|
||||
</svg>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="loader" id="dropZone" tabindex="0" aria-label="Load a save file">
|
||||
<div class="loader-text">
|
||||
<h2 id="loaderTitle">Drop your SaveData.zip here</h2>
|
||||
<section class="loader" id="dropZone" aria-label="Load a save file">
|
||||
<div class="loader-head">
|
||||
<h2 id="loaderTitle">Load your save</h2>
|
||||
<p id="loaderStatus">No save loaded. You can browse and edit items, but saving needs a loaded save.</p>
|
||||
</div>
|
||||
<div class="loader-actions">
|
||||
<button type="button" class="btn" id="browseBtn">Browse</button>
|
||||
<input type="file" id="fileInput" accept=".zip,.json" hidden>
|
||||
</div>
|
||||
<div class="path-hint">
|
||||
<span class="path-label">Your save file is here</span>
|
||||
<code class="path"><span>%USERPROFILE%\AppData\LocalLow\Endnight\SonsOfTheForest\Saves\</span><var>SteamID</var><span>\SinglePlayer\</span><var>SaveID</var><span>\SaveData.zip</span></code>
|
||||
<p class="path-note"><var>SteamID</var> is the long number folder. <var>SaveID</var> is one folder per save; sort by date modified to find your latest. Hosted co-op saves are under Multiplayer instead of SinglePlayer.</p>
|
||||
<button type="button" class="btn-link" id="copyPathBtn">Copy Saves folder path</button>
|
||||
|
||||
<div class="options">
|
||||
<div class="option">
|
||||
<span class="option-tag">Option A</span>
|
||||
<h3>Browse to your file</h3>
|
||||
<p>Your save is here:</p>
|
||||
<code class="path"><span>%USERPROFILE%\<wbr>AppData\<wbr>LocalLow\<wbr>Endnight\<wbr>SonsOfTheForest\<wbr>Saves\<wbr></span><var>SteamID</var><span>\<wbr>SinglePlayer\<wbr></span><var>SaveID</var><span>\<wbr>SaveData.zip</span></code>
|
||||
<p class="hint"><var>SteamID</var> is the long number folder. Pick the newest <var>SaveID</var> folder. Co-op host saves are in Multiplayer.</p>
|
||||
<div class="option-actions">
|
||||
<button type="button" class="btn" id="browseBtn">Browse</button>
|
||||
<button type="button" class="btn-link copy-btn" id="copyPathBtn"><svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><rect x="5" y="5" width="9" height="9" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M11 5V3.5A1.5 1.5 0 0 0 9.5 2h-6A1.5 1.5 0 0 0 2 3.5v6A1.5 1.5 0 0 0 3.5 11H5" fill="none" stroke="currentColor" stroke-width="1.5"/></svg><span>Copy path</span></button>
|
||||
</div>
|
||||
<input type="file" id="fileInput" accept=".zip,.json" hidden>
|
||||
</div>
|
||||
|
||||
<div class="option">
|
||||
<span class="option-tag">Option B</span>
|
||||
<h3>Use this PowerShell script</h3>
|
||||
<p>Close the game, open PowerShell and paste:</p>
|
||||
<code class="path" id="helperCmd">irm https://<wbr>raw.githubusercontent.com/<wbr>JustinOros/<wbr>sotf-inventory/<wbr>main/<wbr>Find-SaveData.ps1 | iex</code>
|
||||
<p class="hint">Explorer opens with your latest SaveData.zip selected. Drag it onto this page.</p>
|
||||
<div class="option-actions">
|
||||
<button type="button" class="btn copy-btn" id="copyCmdBtn"><svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><rect x="5" y="5" width="9" height="9" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M11 5V3.5A1.5 1.5 0 0 0 9.5 2h-6A1.5 1.5 0 0 0 2 3.5v6A1.5 1.5 0 0 0 3.5 11H5" fill="none" stroke="currentColor" stroke-width="1.5"/></svg><span>Copy command</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option option-drop" id="dropTarget" tabindex="0" role="button" aria-label="Drop SaveData.zip here or press Enter to browse">
|
||||
<span class="option-tag">Option C</span>
|
||||
<h3>Drag and drop</h3>
|
||||
<div class="drop-well">
|
||||
<svg viewBox="0 0 24 24" width="36" height="36" aria-hidden="true"><path d="M12 3v12m0 0-4.5-4.5M12 15l4.5-4.5M4 17v2.5A1.5 1.5 0 0 0 5.5 21h13a1.5 1.5 0 0 0 1.5-1.5V17" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span>Drop your SaveData.zip here</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -45,6 +79,10 @@
|
||||
<input type="checkbox" id="ownedOnly">
|
||||
<span>Owned only</span>
|
||||
</label>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="changedOnly">
|
||||
<span>Changed only</span>
|
||||
</label>
|
||||
<span class="count-label" id="resultCount"></span>
|
||||
</section>
|
||||
|
||||
@@ -68,6 +106,7 @@
|
||||
<footer class="savebar">
|
||||
<span id="changeSummary">No changes</span>
|
||||
<div class="savebar-actions">
|
||||
<button type="button" class="btn-link" id="clearBtn" title="Sets every item to 0 except equipped items and starting tools">Clear inventory</button>
|
||||
<button type="button" class="btn-link" id="resetBtn" disabled>Undo changes</button>
|
||||
<button type="button" class="btn btn-primary" id="saveBtn" disabled>Save</button>
|
||||
</div>
|
||||
|
||||
+117
-45
@@ -1,4 +1,5 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--spruce: #0f1512;
|
||||
--spruce-2: #161f1a;
|
||||
--spruce-3: #1f2a23;
|
||||
@@ -35,14 +36,14 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero::after {
|
||||
content: "";
|
||||
.treeline {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -1px;
|
||||
width: 100%;
|
||||
height: 90px;
|
||||
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 60' preserveAspectRatio='none'%3E%3Cpath fill='%230f1512' d='M0 60 L0 44 L8 30 L5 30 L12 16 L9 16 L16 2 L23 16 L20 16 L27 30 L24 30 L32 44 L32 60 Z M30 60 L38 40 L35 40 L42 26 L39 26 L46 14 L53 26 L50 26 L57 40 L54 40 L62 60 Z M58 60 L66 36 L63 36 L71 20 L68 20 L76 6 L84 20 L81 20 L89 36 L86 36 L94 60 Z M90 60 L97 44 L94 44 L101 30 L98 30 L105 20 L112 30 L109 30 L116 44 L113 44 L120 60 Z'/%3E%3C/svg%3E") repeat-x bottom / 120px 90px;
|
||||
color: var(--spruce);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hero-inner {
|
||||
@@ -73,80 +74,150 @@ h1 {
|
||||
main {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
padding: 2.5rem 1.5rem 0;
|
||||
}
|
||||
|
||||
.loader {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 1rem 1.5rem;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border: 1px dashed var(--moss);
|
||||
padding: 1.75rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
background: var(--spruce-2);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.loader.dragging {
|
||||
border-color: var(--bone);
|
||||
border-style: solid;
|
||||
background: var(--spruce-3);
|
||||
}
|
||||
|
||||
.loader.loaded {
|
||||
border-style: solid;
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.loader h2 {
|
||||
.loader-head h2 {
|
||||
font-family: var(--display);
|
||||
font-weight: 400;
|
||||
font-size: 1.75rem;
|
||||
font-size: 1.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.loader p {
|
||||
.loader-head p {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--fog);
|
||||
}
|
||||
|
||||
.path-hint {
|
||||
grid-column: 1 / -1;
|
||||
.loader.loaded .loader-head h2 { color: #fff; }
|
||||
|
||||
.options {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
justify-items: start;
|
||||
font-size: 0.875rem;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1.25rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
padding: 1.25rem;
|
||||
background: var(--spruce);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.option-tag {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--moss);
|
||||
}
|
||||
|
||||
.option h3 {
|
||||
font-family: var(--display);
|
||||
font-weight: 400;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1.2;
|
||||
margin: -0.35rem 0 0.2rem;
|
||||
}
|
||||
|
||||
.option p {
|
||||
margin: 0;
|
||||
color: var(--fog);
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 0.925rem;
|
||||
}
|
||||
|
||||
.option .hint {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.option-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
|
||||
.path {
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 0.95rem;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.55;
|
||||
color: var(--bone);
|
||||
background: var(--spruce);
|
||||
background: var(--spruce-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 3px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 0.55rem 0.7rem;
|
||||
overflow-wrap: break-word;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.path-hint var {
|
||||
.option var {
|
||||
font-style: normal;
|
||||
color: #e0a39a;
|
||||
border-bottom: 1px dotted var(--blood-hi);
|
||||
}
|
||||
|
||||
.path-note {
|
||||
margin: 0;
|
||||
max-width: 60em;
|
||||
.copy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-link.copy-btn { padding: 0; }
|
||||
|
||||
.copy-btn svg { flex: none; }
|
||||
|
||||
.btn.copy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-link.copy-btn span {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.option-drop {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drop-well {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
min-height: 11rem;
|
||||
text-align: center;
|
||||
color: var(--fog);
|
||||
border: 2px dashed var(--moss);
|
||||
border-radius: 4px;
|
||||
transition: border-color 0.15s, background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.option-drop:hover .drop-well,
|
||||
.loader.dragging .drop-well {
|
||||
border-color: var(--bone);
|
||||
color: var(--bone);
|
||||
background: var(--spruce-3);
|
||||
}
|
||||
|
||||
.loader.dragging { border-color: var(--moss); }
|
||||
|
||||
.btn {
|
||||
font: 600 1rem var(--body);
|
||||
color: var(--bone);
|
||||
@@ -438,8 +509,9 @@ dialog p { margin: 0 0 0.9rem; line-height: 1.55; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.hero { padding: 2.5rem 1rem 5.5rem; }
|
||||
main { padding: 0 1rem; }
|
||||
.loader { grid-template-columns: 1fr; }
|
||||
main { padding: 1.5rem 1rem 0; }
|
||||
.options { grid-template-columns: 1fr; }
|
||||
.drop-well { min-height: 7rem; }
|
||||
.col-cat, .cell-cat, .col-id, .cell-id { display: none; }
|
||||
.col-count { width: auto; }
|
||||
.stepper input { width: 4rem; }
|
||||
|
||||
Reference in new issue
Block a user