feat: add local achievements tab
This commit is contained in:
parent
f3c2d40ab6
commit
c4f18d7485
8 changed files with 223 additions and 4 deletions
52
src/css/achievements.css
Normal file
52
src/css/achievements.css
Normal 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);
|
||||
}
|
||||
|
|
@ -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
154
src/js/achievements.js
Normal 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 };
|
||||
|
|
@ -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");
|
||||
|
|
@ -22,4 +23,7 @@ require("./page-functions.js");
|
|||
require("./HKCheckCompletion.js");
|
||||
|
||||
// Load Save File for opening files, decoding, decryption
|
||||
require("./LoadSaveFile.js");
|
||||
require("./LoadSaveFile.js");
|
||||
|
||||
// Local achievement/Feat state from shared.dat.
|
||||
require("./achievements.js");
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue