diff --git a/js/format-c2m.js b/js/format-c2m.js index b41241e..485d5a4 100644 --- a/js/format-c2m.js +++ b/js/format-c2m.js @@ -654,8 +654,8 @@ function decompress(buf) { return out; } -export function parse_level(buf) { - let level = new util.StoredLevel; +export function parse_level(buf, number = 1) { + let level = new util.StoredLevel(number); let full_view = new DataView(buf); let next_section_start = 0; let extra_hints = []; diff --git a/js/format-dat.js b/js/format-dat.js index 912ce99..8b61c30 100644 --- a/js/format-dat.js +++ b/js/format-dat.js @@ -119,8 +119,8 @@ const TILE_ENCODING = { 0x6f: ['player', 'east'], }; -function parse_level(buf) { - let level = new util.StoredLevel; +function parse_level(buf, number) { + let level = new util.StoredLevel(number); // Map size is always fixed as 32x32 in CC1 level.size_x = 32; level.size_y = 32; @@ -304,7 +304,7 @@ export function parse_game(buf) { let level_buf = buf.slice(p + 2, p + 2 + length); p += 2 + length; - let level = parse_level(level_buf); + let level = parse_level(level_buf, l); game.levels.push(level); } diff --git a/js/format-util.js b/js/format-util.js index b97ad85..3404093 100644 --- a/js/format-util.js +++ b/js/format-util.js @@ -6,7 +6,8 @@ export class StoredCell extends Array { } export class StoredLevel { - constructor() { + constructor(number) { + this.number = number; // one-based this.title = ''; this.password = null; this.hint = ''; @@ -39,7 +40,8 @@ export class StoredLevel { } export class StoredGame { - constructor() { + constructor(identifier) { + this.identifier = identifier; this.levels = []; } } diff --git a/js/game.js b/js/game.js index d2e349c..0f2d2a4 100644 --- a/js/game.js +++ b/js/game.js @@ -176,6 +176,7 @@ export class Level { this.actors = []; this.chips_remaining = this.stored_level.chips_required; this.bonus_points = 0; + this.aid = 0; // Time if (this.stored_level.time_limit === 0) { @@ -185,6 +186,8 @@ export class Level { this.time_remaining = this.stored_level.time_limit * 20; } this.timer_paused = false; + // Note that this clock counts *up*, even on untimed levels, and is unaffected by CC2's + // clock alteration shenanigans this.tic_counter = 0; // 0 to 7, indicating the first tic that teeth can move on. // 0 is equivalent to even step; 4 is equivalent to odd step. @@ -932,6 +935,8 @@ export class Level { } undo() { + this.aid = Math.max(1, this.aid); + let entry = this.undo_stack.pop(); // Undo in reverse order! There's no redo, so it's okay to destroy this entry.reverse(); @@ -1018,6 +1023,21 @@ export class Level { throw new GameEnded; } + get_scorecard() { + if (this.state !== 'success') { + return null; + } + + let time = Math.floor((this.time_remaining ?? 0) / 20); + return { + time: time, + abstime: this.tic_counter, + bonus: this.bonus_points, + score: this.stored_level.number * 500 + time * 10 + this.bonus_points, + aid: this.aid, + }; + } + // Get the next direction a random force floor will use. They share global // state and cycle clockwise. get_force_floor_direction() { diff --git a/js/main.js b/js/main.js index 494f59b..86c493e 100644 --- a/js/main.js +++ b/js/main.js @@ -121,14 +121,7 @@ class PrimaryView { // TODO: -// - some kinda visual theme i guess lol // - level password, if any -// - bonus points (cc2 only, or maybe only if got any so far this level) -// - intro splash with list of available level packs -// - button: quit to splash -// - implement winning and show score for this level -// - show current score so far -// - about, help const ACTION_LABELS = { up: '⬆️\ufe0f', down: '⬇️\ufe0f', @@ -621,6 +614,15 @@ class Player extends PrimaryView { } load_level(stored_level) { + // Do this here because we care about the latest level played, not the latest level opened + // in the editor or whatever + let savefile = this.conductor.current_pack_savefile; + savefile.current_level = stored_level.number; + if (savefile.highest_level < stored_level.number) { + savefile.highest_level = stored_level.number; + } + this.conductor.save_savefile(); + this.level = new Level(stored_level, this.compat); this.level.sfx = this.sfx_player; this.renderer.set_level(this.level); @@ -937,9 +939,32 @@ class Player extends PrimaryView { overlay_keyhint = "press space to try again, or Z to rewind"; } else { + // We just beat the level! Hey, that's cool. + // Let's save the score while we're here. + let level_number = this.level.stored_level.number; + let level_index = level_number - 1; + let scorecard = this.level.get_scorecard(); + let savefile = this.conductor.current_pack_savefile; + let old_scorecard; + if (! savefile.scorecards[level_index] || + savefile.scorecards[level_index].score < scorecard.score) + { + old_scorecard = savefile.scorecards[level_index]; + + // Adjust the total score + savefile.total_score = savefile.total_score ?? 0; + if (old_scorecard) { + savefile.total_score -= old_scorecard.score; + } + savefile.total_score += scorecard.score; + + savefile.scorecards[level_index] = scorecard; + this.conductor.save_savefile(); + } + overlay_reason = 'success'; - let base = (this.conductor.level_index + 1) * 500; - let time = Math.ceil((this.level.time_remaining ?? 0) / 20) * 10; + let base = level_number * 500; + let time = scorecard.time * 10; // Pick a success message // TODO done on first try; took many tries let time_left_fraction = null; @@ -981,14 +1006,39 @@ class Player extends PrimaryView { mk('dd', base), mk('dt', "time bonus"), mk('dd', `+ ${time}`), - mk('dt', "score bonus"), - mk('dd', `+ ${this.level.bonus_points}`), + ); + // It should be impossible to ever have a bonus and then drop back to 0 with CC2 + // rules; thieves can halve it, but the amount taken is rounded down. + // That is to say, I don't need to track whether we ever got a score bonus + if (this.level.bonus_points) { + overlay_middle.append( + mk('dt', "score bonus"), + mk('dd', `+ ${this.level.bonus_points}`), + ); + } + else { + overlay_middle.append(mk('dt', ""), mk('dd', "")); + } + + // TODO show your time, bold time...? + overlay_middle.append( mk('dt.-sum', "level score"), - mk('dd.-sum', base + time + this.level.bonus_points), - mk('dt', "improvement"), - mk('dd', "(TODO)"), + mk('dd.-sum', `${scorecard.score} ${scorecard.aid === 0 ? '★' : ''}`), + ); + + if (old_scorecard) { + overlay_middle.append( + mk('dt', "improvement"), + mk('dd', `+ ${scorecard.score - old_scorecard.score}`), + ); + } + else { + overlay_middle.append(mk('dt', ""), mk('dd', "")); + } + + overlay_middle.append( mk('dt', "total score"), - mk('dd', "(TODO)"), + mk('dd', savefile.total_score), ); } } @@ -1594,18 +1644,22 @@ class Editor extends PrimaryView { const BUILTIN_LEVEL_PACKS = [{ path: 'levels/CCLP1.ccl', + 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.", }, { path: 'levels/CCLP4.ccl', + ident: 'cclp4', title: "Chip's Challenge Level Pack 4", desc: "Moderately difficult, but not unfair.", }, { 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.", }, { path: 'levels/CCLP3.ccl', + ident: 'cclp3', title: "Chip's Challenge Level Pack 3", desc: "A tough challenge, by and for veteran players.", }]; @@ -1617,10 +1671,21 @@ class Splash extends PrimaryView { // Populate the list of available level packs let pack_list = document.querySelector('#splash-stock-levels'); for (let packdef of BUILTIN_LEVEL_PACKS) { + let score; + let packinfo = conductor.stash.packs[packdef.ident]; + if (packinfo && packinfo.total_score !== undefined) { + // 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"; + } + let button = mk('button.button-big.level-pack-button', mk('h3', packdef.title), mk('p', packdef.desc), - mk('span.-score', "unplayed"), + mk('span.-score', score), ); button.addEventListener('click', ev => { this.fetch_pack(packdef.path, packdef.title); @@ -1638,7 +1703,7 @@ class Splash extends PrimaryView { upload_el.addEventListener('change', async ev => { let file = ev.target.files[0]; let buf = await file.arrayBuffer(); - this.load_file(buf); + this.load_file(buf, this.extract_identifier_from_path(file.name)); // TODO get title out of C2G when it's supported this.conductor.level_pack_name_el.textContent = file.name; }); @@ -1655,7 +1720,8 @@ class Splash extends PrimaryView { } stored_level.linear_cells[0].push({type: TILE_TYPES['player']}); - let stored_game = new format_util.StoredGame; + // FIXME definitely gonna need a name here chief + let stored_game = new format_util.StoredGame(null); stored_game.levels.push(stored_level); this.conductor.load_game(stored_game); @@ -1663,17 +1729,28 @@ class Splash extends PrimaryView { }); } + extract_identifier_from_path(path) { + let ident = path.match(/^(?:.*\/)?[.]*([^.]+)(?:[.]|$)/)[1]; + if (ident) { + return ident.toLowerCase(); + } + else { + return null; + } + } + + // TODO wait why aren't these just on conductor async fetch_pack(path, title) { // TODO indicate we're downloading something // TODO handle errors // TODO cancel a download if we start another one? let buf = await fetch(path); - this.load_file(buf); + this.load_file(buf, this.extract_identifier_from_path(path)); // TODO get title out of C2G when it's supported this.conductor.level_pack_name_el.textContent = title || path; } - load_file(buf) { + load_file(buf, identifier = null) { // TODO also support tile world's DAC when reading from local?? // TODO ah, there's more metadata in CCX, crapola let magic = String.fromCharCode.apply(null, new Uint8Array(buf.slice(0, 4))); @@ -1681,6 +1758,8 @@ class Splash extends PrimaryView { if (magic === 'CC2M' || magic === 'CCS ') { stored_game = new format_util.StoredGame; stored_game.levels.push(c2m.parse_level(buf)); + // Don't make a savefile for individual levels + identifier = null; } else if (magic === '\xac\xaa\x02\x00' || magic == '\xac\xaa\x02\x01') { stored_game = dat.parse_game(buf); @@ -1688,7 +1767,7 @@ class Splash extends PrimaryView { else { throw new Error("Unrecognized file format"); } - this.conductor.load_game(stored_game); + this.conductor.load_game(stored_game, identifier); this.conductor.switch_to_player(); } } @@ -1855,20 +1934,58 @@ class LevelBrowserOverlay extends DialogOverlay { constructor(conductor) { super(conductor); this.set_title("choose a level"); - let table = mk('table.level-browser'); + let thead = mk('thead', mk('tr', + mk('th', ""), + mk('th', "Level"), + mk('th', "Your time"), + mk('th', mk('abbr', { + title: "Actual time it took you to play the level, even on untimed levels, and ignoring any CC2 clock altering effects", + }, "Real time")), + mk('th', "Your score"), + )); + let tbody = mk('tbody'); + let table = mk('table.level-browser', thead, tbody); this.main.append(table); + let savefile = conductor.current_pack_savefile; + // TODO if i stop eagerloading everything in a .DAT then this will not make sense any more for (let [i, stored_level] of conductor.stored_game.levels.entries()) { - table.append(mk('tr', + let scorecard = savefile.scorecards[i]; + let score = "—", time = "—", abstime = "—"; + if (scorecard) { + score = scorecard.score.toLocaleString(); + if (scorecard.aid === 0) { + score += '★'; + } + + if (scorecard.time === 0) { + // This level is untimed + time = "n/a"; + } + else { + time = String(scorecard.time); + } + + // Express absolute time as mm:ss, with two decimals on the seconds (which should be + // able to exactly count a number of tics) + abstime = `${Math.floor(scorecard.abstime / 20 / 60)}:${(scorecard.abstime / 20 % 60).toFixed(2)}`; + } + + tbody.append(mk(i >= savefile.highest_level ? 'tr.--unvisited' : 'tr', {'data-index': i}, - mk('td', i + 1), - mk('td', stored_level.title), - // TODO score? - // TODO other stats?? - mk('td', '▶'), + mk('td.-number', i + 1), + mk('td.-title', stored_level.title), + mk('td.-time', time), + mk('td.-time', abstime), + mk('td.-score', score), + // TODO show your time? include 999 times for untimed levels (which i don't know at + // this point whoops but i guess if the time is zero then that answers that)? show + // your wallclock time also? + // TODO other stats?? num chips, time limit? don't know that without loading all + // the levels upfront though, which i currently do but want to stop doing )); } - table.addEventListener('click', ev => { + tbody.addEventListener('click', ev => { let tr = ev.target.closest('table.level-browser tr'); if (! tr) return; @@ -1886,6 +2003,7 @@ class LevelBrowserOverlay extends DialogOverlay { // Central dispatcher of what we're doing and what we've got loaded const STORAGE_KEY = "Lexy's Labyrinth"; +const STORAGE_PACK_PREFIX = "Lexy's Labyrinth: "; class Conductor { constructor(tileset) { this.stored_game = null; @@ -1899,6 +2017,9 @@ class Conductor { if (! this.stash.options) { this.stash.options = {}; } + if (! this.stash.packs) { + this.stash.packs = {}; + } // Handy aliases this.options = this.stash.options; @@ -1990,9 +2111,25 @@ class Conductor { document.body.setAttribute('data-mode', 'player'); } - load_game(stored_game) { + load_game(stored_game, identifier = null) { this.stored_game = stored_game; + this._pack_identifier = identifier; + this.current_pack_savefile = null; + if (identifier !== null) { + // TODO again, enforce something about the shape here + this.current_pack_savefile = JSON.parse(window.localStorage.getItem(STORAGE_PACK_PREFIX + identifier)); + } + if (! this.current_pack_savefile) { + this.current_pack_savefile = { + total_score: 0, + current_level: 1, + highest_level: 1, + // level scorecard: { time, abstime, bonus, score, aid } or null + scorecards: [], + }; + } + this.player.load_game(stored_game); this.editor.load_game(stored_game); @@ -2022,6 +2159,25 @@ class Conductor { save_stash() { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(this.stash)); } + + save_savefile() { + if (! this._pack_identifier) + 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 + 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; + } + packinfo.total_score = this.current_pack_savefile.total_score; + this.save_stash(); + } + } } diff --git a/style.css b/style.css index cee64a3..44f04dd 100644 --- a/style.css +++ b/style.css @@ -160,13 +160,40 @@ table.level-browser { margin-bottom: 1em; line-height: 1.25; border-spacing: 0; - cursor: pointer; +} +table.level-browser thead { + position: sticky; + top: -1em; /* counteract padding so cells don't appear above us */ + background: #f4f4f4; /* match dialog background */ +} +table.level-browser thead tr th { + border-bottom: 2px solid hsl(225, 20%, 60%); } table.level-browser td { - padding: 0 0.25em; + padding: 0.25em; } -table.level-browser tr:hover { - background: hsl(225, 60%, 90%); +table.level-browser td.-number { + color: #404040; + text-align: right; +} +table.level-browser td.-time { + text-align: right; +} +table.level-browser td.-score { + text-align: right; +} +table.level-browser tr.--unvisited { + color: #606060; + font-style: italic; +} +table.level-browser tbody tr { + cursor: pointer; +} +table.level-browser tbody tr:hover { + background: hsl(225, 60%, 85%); +} +table.level-browser tbody tr:nth-child(10n) td { + border-bottom: 2px solid hsl(225, 20%, 80%); } /* Options dialog */ @@ -534,10 +561,20 @@ dl.score-chart .-sum { color: hsl(225, 10%, 30%); } .time output.--warning { - color: hsl(30, 100%, 40%); + color: hsl(345, 60%, 60%); } .time output.--danger { - color: hsl(345, 60%, 60%); + color: hsl(330, 60%, 60%); + /* TODO this can get out of sync and keeps going at 0, but is a neat idea */ + /* animation: time-pulse 1s linear infinite; */ +} +@keyframes time-pulse { + 0% { + transform: scale(1.1); + } + 100% { + transform: scale(1); + } } .time output.--frozen { color: hsl(225, 10%, 30%);