feat: add local achievements tab

This commit is contained in:
mikl 2026-08-10 21:29:56 +03:00
parent f3c2d40ab6
commit c4f18d7485
8 changed files with 223 additions and 4 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

52
src/css/achievements.css Normal file
View file

@ -0,0 +1,52 @@
.achievement-filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 16px 0;
}
.achievement-entry {
margin: 0.55rem 0;
padding: 0.75rem 0.9rem;
border: 1px solid rgba(89, 209, 218, 0.18);
border-radius: 3px;
background: rgba(0, 0, 0, 0.12);
line-height: 1.35;
}
.achievement-status {
display: inline-block;
width: 1.15rem;
margin-right: 0.2rem;
font-weight: 700;
vertical-align: -0.08rem;
}
.achievement-status-unlocked {
color: #73e6a4;
}
.achievement-status-missing {
color: #d98a8a;
}
.achievement-points {
float: right;
opacity: 0.75;
}
#tab-achievements h3 {
margin: 1.45rem 0 0.55rem;
padding-left: 0.25rem;
color: rgba(231, 251, 255, 0.72);
font-size: 1.15rem;
font-weight: 500;
}
#tab-achievements .code-little {
opacity: 0.55;
}
#tab-achievements .achievement-points {
color: rgba(231, 251, 255, 0.65);
}

View file

@ -81,6 +81,8 @@
<!-- <input id="save-area-file" class="" type="file" name="file" accept=".dat,.bak*" onchange="LoadSaveFile(this);" onclick="this.value = '';"> -->
<input id="save-area-file" class="input-file" type="file" name="file" accept=".dat,.bak*">
<label for="save-area-file" id="file-input-label" class="button button-large" title="Navigate to Hollow Knight's Save File Location and select a user*.dat File."><i class="icon-doc-text-inv"></i>Choose Save File...</label>
<input id="shared-area-file" class="input-file" type="file" name="shared-file" accept=".dat">
<label for="shared-area-file" id="shared-file-input-label" class="button button-large" title="Load shared.dat to show local achievements."><i class="icon-ok-squared"></i>Choose shared.dat...</label>
<button id="save-area-read" class="button button-large hidden" type="button" title="Press this button after pasting your Save Text from the Save Editor into the text box below.">Analyze Save Text</button>

154
src/js/achievements.js Normal file
View file

@ -0,0 +1,154 @@
import aesjs from "./aes-js.js";
const CSHARP_HEADER_LENGTH = 22;
const AES_KEY = new TextEncoder().encode("UKu52ePUBwetZ9wNX88o54dnfKRu0T1l");
const ACHIEVEMENTS = [
["FIRST_TOOL", "Equipped", "Tools", "Acquire your first Tool", 5],
["ALL_TOOLS", "Arsenal", "Tools", "Acquire all Tools", 15],
["FIRST_SILK_SKILL", "Bound", "Silk Skills", "Bind your first Silk Skill", 5],
["ALL_SILK_SKILLS", "Woven", "Silk Skills", "Bind all Silk Skills", 15],
["FIRST_CREST", "Claimed", "Crests", "Claim your first Crest", 5],
["ALL_CRESTS", "Consumed", "Crests", "Claim all Crests", 15],
["FIRST_MASK", "Protected", "Mask Shards", "Acquire 4 Mask Shards", 5],
["ALL_MASKS", "Masked", "Mask Shards", "Acquire all Mask Shards", 15],
["FIRST_SILK_SPOOL", "Restored", "Spool Fragments", "Acquire 2 Spool Fragments", 5],
["ALL_SILK_SPOOLS", "Extended", "Spool Fragments", "Acquire all Spool Fragments", 15],
["ALL_SILK_HEARTS", "Regenerated", "Silk Hearts", "Acquire all Silk Hearts", 15],
["ALL_MAPS", "Cartographer", "Exploration", "Acquire a map of each area", 15],
["BELLWAYS_FULL", "Connected", "Exploration", "Open all of Pharloom's Bellways", 15],
["BELLWAY_MELODY", "Bonded", "Quests", "Learn the Beastling Call", 15],
["TUBES_FULL", "Transported", "Exploration", "Open all of the Citadel's Ventrica Stations", 15],
["JOURNAL_HALF", "Keen Hunter", "Hunter", "Grant Nuu's wish", 10],
["JOURNAL_FULL", "True Hunter", "Hunter", "Receive the Hunter's Memento", 15],
["FLEAS_HALF", "Flea Finder", "Fleas", "Rescue half of Pharloom's lost fleas", 10],
["FLEAS_ALL", "Fleafriend", "Fleas", "Rescue all of Pharloom's lost fleas and receive their final gift", 15],
["DEFEATED_BELLBEAST", "Liberated", "Bosses", "Defeat the Bell Beast", 5],
["DEFEATED_LACE_1", "Pharloom's Welcome", "Bosses", "Defeat Lace in Deep Docks", 15],
["DEFEATED_SONG_GOLEM", "Servant", "Bosses", "Defeat Fourth Chorus", 15],
["DEFEATED_WIDOW", "Fanatic", "Bosses", "Defeat Widow", 15],
["DEFEATED_LAST_JUDGE_1", "Judge", "Bosses", "Defeat the Last Judge", 15],
["DEFEATED_COGWORK_DANCERS", "Last Dance", "Bosses", "Defeat the Cogwork Dancers", 15],
["DEFEATED_TROBBIO", "Tragedian", "Bosses", "Defeat Trobbio", 15],
["DEFEATED_LACE_2", "White Knight", "Bosses", "Defeat Lace in the Cradle", 15],
["DEFEATED_PHANTOM_1", "Grey Ghost", "Bosses", "Defeat Phantom", 15],
["DEFEATED_FIRST_SINNER", "Heretic", "Bosses", "Defeat First Sinner", 15],
["DEFEATED_CORAL_KING", "Tyrant", "Bosses", "Defeat Crust King Khann", 15],
["DEFEATED_FLOWER_QUEEN", "Seed", "Bosses", "Defeat Nyleth", 15],
["DEFEATED_HUNTER_QUEEN", "Diva", "Bosses", "Defeat Skarrsinger Karmelita", 15],
["DEFEATED_GREEN_PRINCE", "Lamenter", "Bosses", "Defeat the Clover Dancers", 15],
["FIRST_WISH", "Granted", "Quests", "Grant your first wish", 5],
["GOURMAND_WISH", "Glutton", "Quests", "Satiate the Grand Gourmand", 15],
["SHAKRA_FINAL_QUEST", "Trail's End", "Quests", "Grant Shakra's wish", 30],
["GARMOND_FINAL_QUEST", "Hero's Call", "Quests", "Defeat Lost Garmond", 30],
["PINSTRESS_WISH", "Fatal Resolve", "Quests", "Defeat Pinstress", 30],
["EVA_FINAL_QUEST", "Entwined", "Quests", "Bind Eva", 30],
["BELLHOME", "Resident", "Quests", "Acquire your own Bellhome", 5],
["CITADEL_SONG", "Harmonious", "Quests", "Learn the Citadel's Threefold song", 15],
["WHITE_FLOWER_GAINED", "Remembrance", "Quests", "Claim the Everbloom from within a distant memory", 15],
["ENDING_A", "Weaver Queen", "Endings", "Defeat Grand Mother Silk and bind her power", 15],
["ENDING_C", "Snared Silk", "Endings", "Defeat Grand Mother Silk and entrap her with the Soul Snare", 15],
["ENDING_D", "Twisted Child", "Endings", "Defeat Grand Mother Silk while cursed", 15],
["ENDING_E", "Sister of the Void", "Endings", "Defeat Lost Lace and free Pharloom", 15],
["HERALD_WISH", "Passing of the Age", "Endings", "Grant the Herald's wish and finish the game", 30],
["COMPLETION", "Completion", "Challenges", "Achieve 100% game completion and finish the game", 50],
["SPEEDRUN_1", "Speedrunner", "Challenges", "Complete the game in under 5 hours", 50],
["SPEED_COMPLETION", "Speed Completion", "Challenges", "Achieve 100% game completion and finish the game in under 30 hours", 70],
["STEEL_SOUL", "Steel Soul", "Challenges", "Finish the game in Steel Soul mode", 70],
["STEEL_SOUL_FULL", "Steel Heart", "Challenges", "Achieve 100% game completion and finish the game in Steel Soul mode", 70],
].map(([key, name, category, description, points]) => ({ key, name, category, description, points }));
let sharedState = null;
function decodeSharedFile(file) {
return file.arrayBuffer().then((arrayBuffer) => {
let bytes = new Uint8Array(arrayBuffer);
bytes = bytes.subarray(CSHARP_HEADER_LENGTH, bytes.length - 1);
let lengthCount = 0;
for (let i = 0; i < 5; i += 1) {
lengthCount += 1;
if ((bytes[i] & 0x80) === 0) break;
}
const base64 = new TextDecoder().decode(bytes.subarray(lengthCount));
const encrypted = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
const decrypted = new aesjs.ModeOfOperation.ecb(AES_KEY).decrypt(encrypted);
const padding = decrypted[decrypted.length - 1];
return JSON.parse(new TextDecoder().decode(decrypted.subarray(0, -padding)));
});
}
function pairsToMap(decoded) {
const map = new Map();
for (const pair of decoded?.pairs || []) {
map.set(pair.Key, String(pair.Value) === "1");
}
return map;
}
function achievementMarkup() {
const panel = document.getElementById("tab-achievements");
if (!panel) return;
if (!sharedState) {
panel.innerHTML = "<h2>Achievements</h2><p class='section-description'>Choose shared.dat to read the achievements shared by all save profiles.</p>";
return;
}
const unlocked = ACHIEVEMENTS.filter((achievement) => sharedState.get(achievement.key) === true);
const byCategory = new Map();
for (const achievement of ACHIEVEMENTS) {
if (!byCategory.has(achievement.category)) byCategory.set(achievement.category, []);
byCategory.get(achievement.category).push(achievement);
}
let html = `<h2>Achievements: ${unlocked.length} / ${ACHIEVEMENTS.length}</h2>`;
html += "<p class='section-description'>Local achievement state read from shared.dat. This is the game's own state, not a platform account status.</p>";
html += "<div class='achievement-filters'><button type='button' class='button tab-switch achievement-filter tab-active' data-filter='all'>All</button><button type='button' class='button tab-switch achievement-filter' data-filter='missing'>Missing</button><button type='button' class='button tab-switch achievement-filter' data-filter='unlocked'>Unlocked</button></div>";
for (const [category, entries] of byCategory) {
html += `<h3>${category}</h3>`;
for (const achievement of entries) {
const isUnlocked = sharedState.get(achievement.key) === true;
const state = isUnlocked ? "unlocked" : "missing";
const icon = isUnlocked ? "<i class='icon-ok-squared achievement-status achievement-status-unlocked'></i>" : "<i class='icon-cancel achievement-status achievement-status-missing'></i>";
html += `<div class='single-entry achievement-entry' data-achievement-state='${state}'>${icon} <strong>${achievement.name}</strong> <span class='achievement-points'>${achievement.points}G</span><br><span class='p-left-small'></span>${achievement.description}<br><span class='code-little'>${achievement.key}</span></div>`;
}
}
panel.innerHTML = html;
panel.querySelectorAll(".achievement-filter").forEach((button) => {
button.addEventListener("click", () => {
panel.querySelectorAll(".achievement-filter").forEach((item) => item.classList.remove("tab-active"));
button.classList.add("tab-active");
const filter = button.dataset.filter;
panel.querySelectorAll(".achievement-entry").forEach((entry) => {
entry.classList.toggle("hidden", filter !== "all" && entry.dataset.achievementState !== filter);
});
});
});
}
function loadSharedFile(file) {
decodeSharedFile(file).then((decoded) => {
sharedState = pairsToMap(decoded);
achievementMarkup();
const label = document.getElementById("shared-file-input-label");
if (label) label.textContent = `${file.name} loaded`;
}).catch((error) => {
sharedState = null;
achievementMarkup();
alert(`This does not seem to be a valid shared.dat file. ${error}`);
});
}
document.getElementById("shared-area-file")?.addEventListener("change", (event) => {
if (event.target.files?.[0]) loadSharedFile(event.target.files[0]);
});
document.addEventListener("click", (event) => {
if (event.target.closest("#button-switch-achievements")) achievementMarkup();
});
document.addEventListener("DOMContentLoaded", achievementMarkup);
export { loadSharedFile, achievementMarkup };

View file

@ -6,6 +6,7 @@ require("../css/fontello.css");
require("../css/simpleicon.css");
require("../css/reznoricon.css");
require("../css/style.css");
require("../css/achievements.css");
// required for meta tags (social) to be visible by Webpack
require("../img/thumbnail1200x628.jpg");
@ -23,3 +24,6 @@ require("./HKCheckCompletion.js");
// Load Save File for opening files, decoding, decryption
require("./LoadSaveFile.js");
// Local achievement/Feat state from shared.dat.
require("./achievements.js");

View file

@ -212,6 +212,7 @@ function GenerateInnerHTML(db) {
`<button id="button-switch-essentials" name="essentials" class="button tab-switch" type="button">Essentials %</button>`,
`<button id="button-switch-journal" name="journal" class="button tab-switch" type="button">Journal</button>`,
`<button id="button-switch-collectibles" name="collectibles" class="button tab-switch" type="button">Collectibles</button>`,
`<button id="button-switch-achievements" name="achievements" class="button tab-switch" type="button">Achievements</button>`,
//`<button id="button-switch-geocaches" name="geocaches" class="button tab-switch" type="button">Geo Caches</button>`,
//`<button id="button-switch-secrets" name="secrets" class="button tab-switch" type="button">Secrets</button>`,
//`<button id="button-switch-godhome" name="godhome" class="button tab-switch" type="button">Godmaster</button>`,
@ -679,6 +680,7 @@ function GenerateInnerHTML(db) {
case "huntersJournal": // Journal
case "flea": // Collectibles
case "essentialsKeys": // Essentials %
case "achievements": // Achievements
/*case "toolPouchUpgrades": // Main %
case "achievementsBosses": // Essentials %
@ -701,6 +703,11 @@ function GenerateInnerHTML(db) {
console.log(finalHTMLFill);
console.groupEnd(); */
/* ################################## Achievements tab ########################################################################### */
// Achievement definitions are loaded separately from shared.dat.
finalHTMLFill += `<div id="tab-achievements" class="large-section hidden"></div>`;
/* ################################## Horizontal line ############################################################################# */
finalHTMLFill += FLEUR_DIVIDE;