Improve the pack handling experience somewhat

- Include links for the stock packs

- Show completion amount and total time for played packs

- Expose a list of all other packs the player has played

- Allow forgetting a pack

- Jump to the current level when reopening a pack

- Highlight the current level in the level browser, and scroll to it
This commit is contained in:
Eevee (Evelyn Woods) 2020-12-24 05:36:57 -07:00
parent a8800838d4
commit 1968420027
3 changed files with 197 additions and 39 deletions

View File

@ -85,6 +85,7 @@
</div>
</main>
<script>
// FIXME not quite good enough; we'll get loading forever if there's a syntax error in the js
document.querySelector('#failed').setAttribute('hidden', '');
document.querySelector('#loading').removeAttribute('hidden');
</script>

View File

@ -1668,21 +1668,25 @@ const BUILTIN_LEVEL_PACKS = [{
ident: 'cclp1',
title: "Chip's Challenge Level Pack 1",
desc: "Designed and recommended for new players, starting with gentle introductory levels. A prequel to the other packs.",
url: 'https://wiki.bitbusters.club/Chip%27s_Challenge_Level_Pack_1',
}, {
path: 'levels/CCLP4.ccl',
ident: 'cclp4',
title: "Chip's Challenge Level Pack 4",
desc: "Moderately difficult, but not unfair.",
url: 'https://wiki.bitbusters.club/Chip%27s_Challenge_Level_Pack_4',
}, {
path: 'levels/CCLXP2.ccl',
ident: 'cclxp2',
title: "Chip's Challenge Level Pack 2-X",
desc: "The first community pack released, tricky and rough around the edges.",
url: 'https://wiki.bitbusters.club/Chip%27s_Challenge_Level_Pack_2_(Lynx)',
}, {
path: 'levels/CCLP3.ccl',
ident: 'cclp3',
title: "Chip's Challenge Level Pack 3",
desc: "A tough challenge, by and for veteran players.",
url: 'https://wiki.bitbusters.club/Chip%27s_Challenge_Level_Pack_3',
/*
* TODO: this is tricky. it's a massive hodgepodge of levels mostly made by individual people...
}, {
@ -1786,34 +1790,22 @@ class Splash extends PrimaryView {
super(conductor, document.body.querySelector('main#splash'));
// Populate the list of available level packs
let pack_list = document.querySelector('#splash-stock-levels');
let stock_pack_list = mk('ul.played-pack-list');
document.querySelector('#splash-stock-levels').append(stock_pack_list);
this.played_pack_elements = {};
let stock_pack_idents = new Set;
for (let packdef of BUILTIN_LEVEL_PACKS) {
let score;
let packinfo = conductor.stash.packs[packdef.ident];
if (packinfo && packinfo.total_score !== undefined) {
if (packinfo.total_score === null) {
// Whoops, some NaNs got in here :(
score = "computing...";
}
else {
// TODO tack on a star if the game is "beaten"? what's that mean? every level
// beaten i guess?
score = packinfo.total_score.toLocaleString();
}
}
else {
score = "unplayed";
}
stock_pack_idents.add(packdef.ident);
stock_pack_list.append(this._create_pack_element(packdef.ident, packdef));
}
let button = mk('button.button-big.level-pack-button',
mk('h3', packdef.title),
mk('p', packdef.desc),
mk('span.-score', score),
);
button.addEventListener('click', ev => {
this.conductor.fetch_pack(packdef.path, packdef.title);
});
pack_list.append(button);
// Populate the list of other packs you've played
let custom_pack_list = mk('ul.played-pack-list');
this.root.querySelector('#splash-upload-levels').append(custom_pack_list);
for (let [ident, packinfo] of Object.entries(this.conductor.stash.packs)) {
if (stock_pack_idents.has(ident))
continue;
custom_pack_list.append(this._create_pack_element(ident));
}
// File loading: allow providing either a single file, multiple files, OR an entire
@ -1900,6 +1892,86 @@ class Splash extends PrimaryView {
}
}
_create_pack_element(ident, packdef = null) {
let title = packdef ? packdef.title : ident;
let button = mk('button.button-big.level-pack-button', {type: 'button'},
mk('h3', title));
if (packdef) {
button.addEventListener('click', ev => {
this.conductor.fetch_pack(packdef.path, packdef.title);
});
}
else {
button.disabled = true;
}
let li = mk('li.--unplayed', {'data-ident': ident}, button);
let forget_button = mk('button.-forget', {type: 'button'}, "Forget");
forget_button.addEventListener('click', ev => {
new ConfirmOverlay(this.conductor, `Clear all your progress for ${title}? This can't be undone.`, () => {
delete this.conductor.stash.packs[ident];
localStorage.removeItem(STORAGE_PACK_PREFIX + ident);
this.conductor.save_stash();
if (packdef) {
this.update_pack_score(ident);
}
else {
li.remove();
}
}).open();
});
if (packdef) {
li.append(mk('p', packdef.desc, " ", mk('a', {href: packdef.url}, "More...")));
}
li.append(mk('div.-progress',
mk('span.-score'),
mk('span.-time'),
mk('span.-levels'),
forget_button,
));
this.played_pack_elements[ident] = li;
this.update_pack_score(ident);
return li;
}
update_pack_score(ident) {
let li = this.played_pack_elements[ident];
let packinfo = this.conductor.stash.packs[ident];
li.classList.toggle('--unplayed', ! packinfo);
if (! packinfo)
return;
let progress = li.querySelector('.-progress');
let score;
if (packinfo.total_score === null) {
// Whoops, some NaNs got in here :(
score = "computing...";
}
else {
// TODO tack on a star if the game is "beaten"? what's that mean? every level
// beaten i guess?
score = packinfo.total_score.toLocaleString();
}
progress.querySelector('.-score').textContent = score;
// This stuff isn't available in old saves
if (packinfo.total_levels === undefined) {
progress.querySelector('.-time').textContent = "";
progress.querySelector('.-levels').textContent = "";
}
else {
// TODO not used: total_abstime, aidless_levels
progress.querySelector('.-time').textContent = util.format_duration(packinfo.total_time);
let levels = `${packinfo.cleared_levels}/${packinfo.total_levels}`;
if (packinfo.cleared_levels === packinfo.total_levels) {
levels += '★';
}
progress.querySelector('.-levels').textContent = levels;
}
}
// Look for something we can load, and load it
async search_multi_source(source) {
// TODO not entiiirely kosher, but not sure if we should have an api for this or what
@ -2570,6 +2642,9 @@ class LevelBrowserOverlay extends DialogOverlay {
// the levels upfront though, which i currently do but want to stop doing
);
if (i === this.conductor.level_index) {
tr.classList.add('--current');
}
// TODO sigh, does not actually indicate visited in C2G world
if (i >= savefile.highest_level) {
tr.classList.add('--unvisited');
@ -2592,20 +2667,41 @@ class LevelBrowserOverlay extends DialogOverlay {
}
});
this.tbody = tbody;
this.add_button("nevermind", ev => {
this.close();
});
}
open() {
super.open();
this.tbody.childNodes[this.conductor.level_index].scrollIntoView({block: 'center'});
}
}
// Central dispatcher of what we're doing and what we've got loaded
// We store several kinds of things in localStorage:
// Main storage:
// packs
// packs:
// total_score
// total_time
// total_levels
// cleared_levels
// aidless_levels
// options
// compat: (either a ruleset string or an object of individual flags)
const STORAGE_KEY = "Lexy's Labyrinth";
// Records for playing a pack
// Records for a pack that has been played
// total_score
// highest_level
// current_level
// scorecards: []?
// time
// abstime
// bonus
// score
// aid
const STORAGE_PACK_PREFIX = "Lexy's Labyrinth: ";
// Metadata for an edited pack
// - list of the levels they own and basic metadata like name
@ -2766,11 +2862,34 @@ class Conductor {
if (identifier !== null) {
// TODO again, enforce something about the shape here
this.current_pack_savefile = JSON.parse(window.localStorage.getItem(STORAGE_PACK_PREFIX + identifier));
let changed = false;
if (this.current_pack_savefile && this.current_pack_savefile.total_score === null) {
// Fix some NaNs that slipped in
this.current_pack_savefile.total_score = this.current_pack_savefile.scorecards
.map(scorecard => scorecard ? scorecard.score : 0)
.reduce((a, b) => a + b, 0);
changed = true;
}
if (this.current_pack_savefile && this.current_pack_savefile.cleared_levels === undefined) {
// Populate some more recently added fields
this.current_pack_savefile.total_levels = stored_game.level_metadata.length;
this.current_pack_savefile.total_time = 0;
this.current_pack_savefile.total_abstime = 0;
this.current_pack_savefile.cleared_levels = 0;
this.current_pack_savefile.aidless_levels = 0;
for (let scorecard of this.current_pack_savefile.scorecards) {
if (! scorecard)
continue;
this.current_pack_savefile.total_time += scorecard.time;
this.current_pack_savefile.total_abstime += scorecard.abstime;
this.current_pack_savefile.cleared_levels += 1;
if (scorecard.aid === 0) {
this.current_pack_savefile.aidless_levels += 1;
}
}
changed = true;
}
if (changed) {
this.save_savefile();
}
}
@ -2787,7 +2906,7 @@ class Conductor {
this.player.load_game(stored_game);
this.editor.load_game(stored_game);
return this.change_level(0);
return this.change_level((this.current_pack_savefile.current_level ?? 1) - 1);
}
change_level(level_index) {
@ -2846,18 +2965,25 @@ class Conductor {
if (! this._pack_identifier)
return;
// Don't save if there's nothing to save
if (! this.current_pack_savefile.cleared_levels && this.current_pack_savefile.current_level === 1)
return;
window.localStorage.setItem(STORAGE_PACK_PREFIX + this._pack_identifier, JSON.stringify(this.current_pack_savefile));
// Also remember the total score in the stash, if it changed, so we can read it without
// having to parse every single one of these things
// Also remember some stats in the stash, if it changed, so we can read it without having to
// parse every single one of these things
let packinfo = this.stash.packs[this._pack_identifier];
if (! packinfo || packinfo.total_score !== this.current_pack_savefile.total_score) {
if (! packinfo) {
packinfo = {};
this.stash.packs[this._pack_identifier] = packinfo;
if (! packinfo) {
packinfo = {};
this.stash.packs[this._pack_identifier] = packinfo;
}
let keys = ['total_score', 'total_time', 'total_abstime', 'total_levels', 'cleared_levels', 'aidless_levels'];
if (keys.some(key => packinfo[key] !== this.current_pack_savefile[key])) {
for (let key of keys) {
packinfo[key] = this.current_pack_savefile[key];
}
packinfo.total_score = this.current_pack_savefile.total_score;
this.save_stash();
this.splash.update_pack_score(this._pack_identifier);
}
}

View File

@ -20,6 +20,7 @@ body {
--button-bg-color: hsl(225, 10%, 25%);
--button-bg-hover-color: hsl(225, 15%, 30%);
--generic-bg-hover-on-white: hsl(225, 60%, 85%);
--generic-bg-selected-on-white: hsl(225, 60%, 90%);
}
/* Generic element styling */
@ -290,6 +291,9 @@ table.level-browser td.-time {
table.level-browser td.-score {
text-align: right;
}
table.level-browser tr.--current {
background: var(--generic-bg-selected-on-white);
}
table.level-browser tr.--unvisited {
color: #606060;
font-style: italic;
@ -302,7 +306,7 @@ table.level-browser tbody tr {
cursor: pointer;
}
table.level-browser tbody tr:hover {
background: var(--generic-hover-bg-on-white);
background: var(--generic-bg-hover-on-white);
}
table.level-browser tbody tr:nth-child(10n) td {
border-bottom: 2px solid hsl(225, 20%, 80%);
@ -316,7 +320,7 @@ ul.compat-flags > li {
padding: 0.125em;
}
ul.compat-flags > li.-checked {
background: hsl(225, 60%, 90%);
background: var(--generic-bg-selected-on-white);
}
ul.compat-flags > li:hover {
background: var(--generic-bg-hover-on-white);
@ -640,6 +644,33 @@ pre.stack-trace {
}
}
.played-pack-list {
}
.played-pack-list > li {
margin-bottom: 1em;
}
.played-pack-list p {
font-size: 0.833em;
font-style: italic;
}
.played-pack-list .-progress {
display: flex;
align-items: center;
gap: 0.5em;
text-align: right;
}
.played-pack-list > li.--unplayed .-progress {
display: none;
}
.played-pack-list .-progress > .-score,
.played-pack-list .-progress > .-time,
.played-pack-list .-progress > .-levels {
flex: 2;
}
.played-pack-list .-progress > button {
flex: 1;
}
button.level-pack-button {
display: grid;
grid: