Track scores, including your total score for a pack
This commit is contained in:
parent
ac59f7b15d
commit
537e011f2a
@ -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 = [];
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
20
js/game.js
20
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() {
|
||||
|
||||
216
js/main.js
216
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
49
style.css
49
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%);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user