Commit 82cffffd authored by rswindell's avatar rswindell
Browse files

The long-await much-anticipated Minesweeper 2.0:

* Lost games now logged in losers.jsonl, viewable in the game, not shared
* The new 'L' command shows all local wins and losses, most recent first
* Use the CP437 "F with a hook" character for flag indicator
* Added support for an uncertain (?) block marker
* Multiple block-selector styles available, use Ctrl-S to cycle through
* TAB key toggles the area-highlight feature
* Center the game board vertifically in the terminal
* Blink the clock red when there are 5 minutes or less of time left
* (R)eveal command changed to (D)ig, it is a mine *field* after-all! :-0
* Disable more global hot keys while in the game: Ctrl-K/O/Z
* Save/restore user preferences for difficulty level, highlight, and selector
* Support for "Chording" a time-saving move that can be used to uncover all
  covered/unflagged blocks surrounding a previously-uncovered block with the
  correct number of flagged blocks surrounding it. Via the 'C' key.
* Don't prompt for difficulty level when first running the game.
parent 1b8917f2
nyhe4 Synchronet Minesweeper Help n
@CENTER@nyhe4 Synchronet Minesweeper Help n
The classic game, revisited for this Synchronet BBS!
https://en.wikipedia.org/wiki/Minesweeper_(video_game)
@CENTER@The classic game, recreated for this Synchronet BBS!
@CENTER@http://www.minesweeper.info/
The object of the game is to uncover all the safe areas (cells) on the game
board without uncovering (detonating) any mines (rhën), within the allowed time!
@CENTER@hYou're in a mine field!n
The object of the game is to uncover all the safe areas (blocks) in the mine
field without uncovering (detonating) any mines (rhën), within the allowed time!
It's a game of quick deduction, pattern-recognition, and a little bit o' luck!
hControlsn:
ù cMoven the cell selector h<i_nh>n around the game board using the arrow keys,
ù hcMoven the block selector h[i_nh]n around the game board using the arrow keys,
home/end/page-up/down or the numeric keypad (i.e. for diagonal movement).
ù cUncovern a cell (reveal its contents) using the 'hRn' key or the hspace-barn.
If you reveal/detonate a mine (rhi*n), sorry, game over: you lose, try again!
The first cell you uncover is guaranteed to be empty (safe).
If you reveal an empty cell, then all adjacent empty cells will also be
ù hcDign a block (reveal its contents) using the 'hDn' key or the hspace-barn.
If you dig-up/detonate a mine (rhi*n), sorry, game over: you lose, try again!
The first block you uncover is guaranteed to be empty (safe).
If you reveal an empty block, then all adjacent empty blocks will also be
uncovered (a depth-first search algorithm popular in coding interviews)!
Uncovered safe cells with a mine in any surrounding/adjacent cells will have
the total number of adjacent mines displayed (a digit, from 1 to 8).
These digits are your clue where the next mine or empty cell may be located.
When all empty cells are uncovered, the game is won!
Uncovered safe blocks with a mine in any surrounding/adjacent blocks will
have the total number of adjacent mines displayed (a digit, from c1n to c8n).
These digits are your clue where the next mine or empty block may be located.
When all empty blocks are uncovered, the game is won!
ù cFlagn a covered cell as having a suspected mine with the 'hFn' key.
You can also remove a flag with the 'F' key (it's a toggle).
If the game is lost, incorrectly-flagged safe cells are indicated with a 'rh!n'.
ù hcFlagn a covered block as having a suspected mine with the 'hFn' key.
You can also remove a flag ('rhŸn') or change it to an uncertainty indicator
('rh?n') with the 'F' key (it's a 3-way toggle).
If you lose, incorrectly-flagged safe blocks are indicated with a 'rh!n'.
@CENTER@hChordingn
Once you have flagged one or more blocks as likely containing a mine, you may
have the option of automatically uncovering all blocks surrounding a
previously uncovered block if the number of surrounding mines matches the
number of flagged blocks. This time-saving operation is called a cChordn and is
invoked with the 'hCn' key, when appropriate. Note: if you have an incorrectly
placed flag ('rhŸn') in the area, a Chord operation will detonate a mine!
ù cDisplayn a list of previous game-winners with the 'hWn' key.
ù hcDisplayn a list of top-ranked previous game-winners with the 'hWn' key.
You can view a log of all clocaln wins and losses with the 'hLn' key.
ù cStartn a new game with the the 'hNn' key. A new difficulty level may be chosen.
ù hcStartn a new game with the the 'hNn' key. A new difficulty level may be chosen.
Larger user terminals can support larger game boards for greater challenges!
ù cQuitn the game with the 'hQn' key.
ù hcQuitn the game with the 'hQn' key.
ù cRedrawn the game board with the hCtrl-Rn key combination.
ù hcRedrawn the game board with the hCtrl-Rn key combination.
If some of the navigation keys (e.g. arrow keys, home, end) do not work for
your terminal, you can try using these alternatives:
ù Up Arrow: Ctrl-^
ù Down Arrow: Ctrl-J
ù Left Arrow: Ctrl-]
ù Right Arrow: Ctrl-F
ù Home: Ctrl-B
ù End: Ctrl-E
ù Page-Up: Ctrl-P
ù Page-Down: Ctrl-N
... or use the numeric keypad.
ù hcTogglen the affected-area highlight feature with the hTABn key.
Cycle through the available block-selector styles using hCtrl-Sn.
hDifficulty Leveln:
......@@ -62,9 +66,25 @@ size, in columns and rows).
h5 30 x 30 180 20% n
If the target grid height cannot be achieved, a wider game board will be
generated / used. If the total target grid size (in cells) cannot be
generated / used. If the total target grid size (in blocks) cannot be
generated, fewer mines will be deployed to maintain the indicated mine density
(percentage) and the calculated difficulty level will be discounted
accordingly.
hAdditional Movement Optionsn:
If some of the navigation keys (e.g. arrow keys, home, end) do not work for
your terminal, you can try using these alternatives:
ù Up Arrow: Ctrl-^
ù Down Arrow: Ctrl-J
ù Left Arrow: Ctrl-]
ù Right Arrow: Ctrl-F
ù Home: Ctrl-B
ù End: Ctrl-E
ù Page-Up: Ctrl-P
ù Page-Down: Ctrl-N
... or use the numeric keypad.
$Id$
\ No newline at end of file
......@@ -2,52 +2,24 @@
// Minesweeper, the game
// Configure in SCFG->External Programs->Online Programs->Games->
// Available Online Programs...
//
// Name Synchronet Minesweeper
// Internal Code MSWEEPER
// Start-up Directory ../xtrn/minesweeper
// Command Line ?minesweeper
// Multiple Concurrent Users Yes
// Optionally, if you want the top-X winners displayed after exiting game, set:
// Clean-up Command Line ?minesweeper winners
// If you want the top-x winners displayed during logon:
//
// Name Synchronet Minesweeper Winners
// Internal Code MSWINNER
// Start-up Directory ../xtrn/minesweeper
// Command Line ?minesweeper winners
// Multiple Concurrent Users Yes
// Execute on Event Logon, Only
// Command-line arguments supported:
//
// "winners [num]" - display list of top-[num] winners and exit
// "nocls" - don't clear the screen upon exit
// <level> - set the initial game difficulty level (1-5, don't prompt)
// ctrl/modopts.ini [minesweeper] options (with default values):
// sub = syncdata
// timelimit = 60
// winners = 20
// difficulty = 0 ; 0 = prompt user, 1-5 to set default difficulty (no prompt)
// See readme.txt for instructions on installation, configuration, and use
"use strict";
const title = "Synchronet Minesweeper";
const ini_section = "minesweeper";
const REVISION = "$Revision$".split(' ')[1];
const author = "Digital Man";
const header_height = 4;
const winners_list = js.exec_dir + "winners.jsonl";
const losers_list = js.exec_dir + "losers.jsonl";
const help_file = js.exec_dir + "minesweeper.hlp";
const max_difficulty = 5;
const min_mine_density = 0.10;
const mine_density_multiplier = 0.025;
const char_flag = '\x01rF';
const char_flag = '\x01r\x9f';
const char_badflag = '\x01r\x01h!';
const char_unsure = '\x01r?';
const attr_empty = '\x01b'; //\x01h';
const char_empty = attr_empty + '\xFA';
const char_covered = attr_empty +'\xFE';
......@@ -55,50 +27,52 @@ const char_mine = '\x01r\x01h\xEB';
const char_detonated_mine = '\x01r\x01h\x01i\*';
const attr_count = "\x01c";
const winner_subject = "Winner";
const selectors = ["()", "[]", "<>", "{}", "--", " "];
require("sbbsdefs.js", "K_NONE");
var options=load({}, "modopts.js", "minesweeper");
var options=load({}, "modopts.js", ini_section);
if(!options)
options = {};
if(!options.timelimit)
options.timelimit = 60; // minutes
if(!options.timewarn)
options.timewarn = 5;
if(!options.winners)
options.winners = 20;
if(!options.selector)
options.selector = 0;
if(!options.highlight)
options.highlight = true;
if(!options.sub)
options.sub = load({}, "syncdata.js").find();
var json_lines = load({}, "json_lines.js");
var game = { rev: REVISION };
var userprops = bbs.mods.userprops;
if(!userprops)
userprops = load(bbs.mods.userprops = {}, "userprops.js");
var json_lines = bbs.mods.json_lines;
if(!json_lines)
json_lines = load(bbs.mods.json_lines = {}, "json_lines.js");
var game = {};
var board = [];
var selected = {x:0, y:0};
var gamewon = false;
var gameover = false;
var cell_width; // either 3 or 2
var selector = userprops.get(ini_section, "selector", options.selector);
var highlight = userprops.get(ini_section, "highlight", options.highlight);
var difficulty = userprops.get(ini_section, "difficulty", options.difficulty);
function reach(x, y)
log(LOG_DEBUG, title + " options: " + JSON.stringify(options));
function countmines(x, y)
{
var count = 0;
if(y) {
if(x && board[y-1][x-1].mine)
count++;
if(board[y-1][x].mine)
count++;
if(x + 1 < game.width && board[y-1][x+1].mine)
count++;
}
if(x && board[y][x-1].mine)
count++;
if(x + 1 < game.width && board[y][x+1].mine)
count++;
if(y + 1 < game.height) {
if(x && board[y+1][x-1].mine)
count++;
if(board[y+1][x].mine)
count++;
if(x + 1 < game.width && board[y + 1][x + 1].mine)
count++;
}
for(var yi = y - 1; yi <= y + 1; yi++)
for(var xi = x - 1; xi <= x + 1; xi++)
if((yi != y || xi != x) && mined(xi, yi))
count++;
return count;
}
......@@ -125,7 +99,7 @@ function place_mines()
}
for(var y = 0; y < game.height; y++) {
for(var x = 0; x < game.width; x++) {
board[y][x].count = reach(x, y);
board[y][x].count = countmines(x, y);
}
}
}
......@@ -164,11 +138,22 @@ function isgamewon()
alert(result);
console.pause();
}
gamewon = true;
gameover = true;
return true;
}
return false;
}
function lostgame(cause)
{
gameover = true;
game.end = time();
game.name = user.alias;
game.cause = cause;
json_lines.add(losers_list, game);
}
function calc_difficulty(game)
{
const game_cells = game.height * game.width;
......@@ -185,7 +170,7 @@ function calc_time(game)
return game.end - game.start;
}
function compare_game(g1, g2)
function compare_won_game(g1, g2)
{
var diff = calc_difficulty(g2) - calc_difficulty(g1);
if(diff)
......@@ -262,7 +247,7 @@ function show_winners()
console.attributes = WHITE;
console.print(format("Rank %-25s%-15s Lvl Time WxHxMines Date Rev\r\n", "User", ""));
list.sort(compare_game);
list.sort(compare_won_game);
for(var i = 0; i < list.length && i < options.winners && !console.aborted; i++) {
var game = list[i];
......@@ -286,6 +271,58 @@ function show_winners()
console.attributes = LIGHTGRAY;
}
function compare_game(g1, g2)
{
return g2.start - g1.start;
}
function show_log()
{
console.clear();
console.attributes = YELLOW|BG_BLUE|BG_HIGH;
console_center(" " + title + " Log ");
console.attributes = LIGHTGRAY;
var winners = json_lines.get(winners_list);
if(typeof winners != 'object')
winners = [];
var losers = json_lines.get(losers_list);
if(typeof losers != 'object')
losers = [];
var list = losers.concat(winners);
if(!list.length) {
alert("No winners or losers yet!");
return;
}
console.attributes = WHITE;
console.print(format("Date %-25s Lvl Time WxHxMines Rev Result\r\n", "User", ""));
list.sort(compare_game);
for(var i = 0; i < list.length && !console.aborted; i++) {
var game = list[i];
if(i&1)
console.attributes = LIGHTCYAN;
else
console.attributes = BG_CYAN;
console.print(format("%s %-25s %1.1f %s %3ux%2ux%-3u %3s %s\x01>\x01n\r\n"
,system.datestr(game.end)
,game.name
,calc_difficulty(game)
,secondstr(game.end - game.start)
,game.width
,game.height
,game.mines
,game.rev ? game.rev : ''
,game.cause ? ("Lost: " + game.cause) : "Won"
));
}
console.attributes = LIGHTGRAY;
}
function cell_val(x, y)
{
if(gameover && board[y][x].mine) {
......@@ -293,6 +330,8 @@ function cell_val(x, y)
return char_detonated_mine;
return char_mine;
}
if(board[y][x].unsure)
return char_unsure;
if(board[y][x].flagged) {
if(gameover && !board[y][x].mine)
return char_badflag;
......@@ -305,6 +344,16 @@ function cell_val(x, y)
return char_empty;
}
function highlighted(x, y)
{
if(selected.x == x && selected.y == y)
return true;
if(!highlight)
return false;
return (selected.x == x - 1 || selected.x == x || selected.x == x + 1)
&& (selected.y == y -1 || selected.y == y || selected.y == y + 1);
}
function draw_cell(x, y)
{
console.attributes = LIGHTGRAY;
......@@ -314,15 +363,43 @@ function draw_cell(x, y)
if(game.start && !gameover
&& !board[selected.y][selected.x].covered
&& board[selected.y][selected.x].count
&& (selected.x == x - 1 || selected.x == x || selected.x == x + 1)
&& (selected.y == y -1 || selected.y == y || selected.y == y + 1))
&& highlighted(x, y))
console.attributes |= HIGH;
if(selected.x == x && selected.y == y)
left = "<", right = "\x01n\x01h>"; //left = "\x01n\x01h<"
if(selected.x == x && selected.y == y) {
left = "\x01n\x01h" + selectors[selector%selectors.length][0];
right = "\x01n\x01h" + selectors[selector%selectors.length][1];
}
console.print(left + val + right);
}
function countflags()
// Return total number of surrounding flags
function countflagged(x, y)
{
var count = 0;
for(var yi = y - 1; yi <= y + 1; yi++)
for(var xi = x - 1; xi <= x + 1; xi++)
if((yi != y || xi != x) && flagged(xi, yi))
count++;
return count;
}
// Return total number of surrounding unflagged-covered cells
function countunflagged(x, y)
{
var count = 0;
for(var yi = y - 1; yi <= y + 1; yi++)
for(var xi = x - 1; xi <= x + 1; xi++)
if((yi != y || xi != x) && unflagged(xi, yi))
count++;
return count;
}
function totalflags()
{
if(!game.start)
return 0;
......@@ -371,12 +448,18 @@ function console_center(text)
console.crlf();
}
// global state variable used by draw_board()
var cmds_shown;
var top;
function draw_board(full)
{
const margin = Math.floor((console.screen_columns - (game.width * cell_width)) / 2);
top = Math.floor(Math.max(0, (console.screen_rows - (header_height + game.height)) - 1) / 2);
console.line_counter = 0;
console.home();
if(full) {
console.down(top);
console.right(margin - 1);
console.attributes = BG_BLUE|BG_HIGH;
console.print('\xDF');
......@@ -385,37 +468,48 @@ function draw_board(full)
console.print(' ');
console.print('\xDF');
console.creturn();
console.home();
show_title();
draw_border();
} else
console.down();
console.down(top + 1);
if(gamewon) {
console.attributes = YELLOW|BLINK;
console_center("Winner! Completed in " + secondstr(game.end - game.start).trim());
} else if(gameover) {
console.attributes = RED|HIGH|BLINK;
console_center("Game Over!");
console_center(((game.end - game.start) < options.timelimit * 60
? "Boom! " : "Time-out: ") + "Game Over");
} else {
var elapsed = 0;
if(game.start)
elapsed = time() - game.start;
var timeleft = (options.timelimit * 60) - elapsed;
console.attributes = LIGHTCYAN;
console_center(format("Mines: %-3d Lvl: %1.1f %s"
, game.mines - countflags(), calc_difficulty(game)
, secondstr((options.timelimit * 60) - elapsed))
);
console_center(format("Mines: %-3d Lvl: %1.1f %s%s"
, game.mines - totalflags(), calc_difficulty(game)
, game.start && !gameover && (timeleft / 60) <= options.timewarn ? "\x01r\x01h\x01i" : ""
, secondstr(timeleft)
));
}
if(full) {
var cmds = "";
if(!gameover) {
if(!board[selected.y][selected.x].covered) {
if(can_chord(selected.x, selected.y))
cmds += "\x01h\x01kDig \x01n\x01hC\x01nhord ";
else
cmds += "\x01h\x01kDig Flag ";
}
else
cmds += "\x01hD\x01nig \x01hF\x01nlag ";
}
cmds += "\x01n\x01hN\x01new \x01hQ\x01nuit";
if(full || cmds !== cmds_shown) {
draw_border();
console.attributes = LIGHTGRAY;
var cmds = "";
if(!gameover)
cmds += "\x01hR\x01neveal \x01hF\x01nlag ";
cmds += "\x01hN\x01new \x01hQ\x01nuit";
console_center(cmds);
draw_border();
console_center("\x01hW\x01ninners \x01hH\x01nelp");
console_center("\x01hW\x01ninners \x01hL\x01nog \x01hH\x01nelp");
cmds_shown = cmds;
} else if(!console.term_supports(USER_ANSI)) {
console.creturn();
console.down(2);
......@@ -427,7 +521,7 @@ function draw_board(full)
for(var x = 0; x < game.width; x++) {
if(full || board[y][x].changed !== false) {
if(console.term_supports(USER_ANSI))
console.gotoxy((x * cell_width) + margin + 1, header_height + y + 1);
console.gotoxy((x * cell_width) + margin + 1, header_height + y + top + 1);
else {
console.creturn();
console.right((x * cell_width) + margin);
......@@ -459,7 +553,7 @@ function draw_board(full)
}
if(redraw_selection) { // We need to draw/redraw the selected cell last in this case
if(console.term_supports(USER_ANSI))
console.gotoxy(margin + (selected.x * cell_width) + 1, header_height + selected.y + 1);
console.gotoxy(margin + (selected.x * cell_width) + 1, header_height + selected.y + top + 1);
else {
console.up(height - (selected.y + 1));
console.creturn();
......@@ -468,12 +562,12 @@ function draw_board(full)
draw_cell(selected.x, selected.y);
console.left(2);
}
console.gotoxy(margin + (selected.x * cell_width) + 2, header_height + selected.y + 1);
console.gotoxy(margin + (selected.x * cell_width) + 2, header_height + selected.y + top + 1);
}
function mined(x, y)
{
return board[y][x].mine;
return board[y] && board[y][x] && board[y][x].mine;
}
function start_game()
......@@ -482,58 +576,72 @@ function start_game()
game.start = time();
}
function uncover_cell(x, y)
{
if(!board[y] || !board[y][x])
return false;
if(board[y][x].flagged)
return false;
board[y][x].covered = false;
board[y][x].unsure = false;
board[y][x].changed = true;
return mined(x, y);
}
function flagged(x, y)
{
return board[y] && board[y][x] && board[y][x].flagged;
}
function unflagged(x, y)
{
return board[y] && board[y][x] && board[y][x].covered && !board[y][x].flagged;
}
// Returns true if mined (game over)
function uncover(x, y)
{
if(!game.start)
start_game();
if(board[y][x].flagged || !board[y][x].covered)
if(!board[y] || !board[y][x] || board[y][x].flagged || !board[y][x].covered)
return;
board[y][x].covered = false;
board[y][x].changed = true;
if(uncover_cell(x, y))
return true;
if(board[y][x].count)
return;
if(y) {
if(x) {
if(!mined(x - 1, y - 1))
uncover(x - 1, y - 1);
}
if(!mined(x, y - 1))
uncover(x, y - 1);
if(x + 1 < game.width) {
if(!mined(x + 1, y - 1))
uncover(x + 1, y - 1);
}
}
if(x) {
if(!mined(x - 1, y))
uncover(x - 1, y);
}
if(x + 1 < game.width) {
if(!mined(x + 1, y))
uncover(x + 1, y);
}
if(y + 1 < game.height) {