diff --git a/apps.json b/apps.json index f6ef51546..3a390938d 100644 --- a/apps.json +++ b/apps.json @@ -5393,7 +5393,7 @@ { "id": "puzzle15", "name": "15 puzzle", - "version": "0.03", + "version": "0.04", "description": "A 15 puzzle game with drag gesture interface", "readme":"README.md", "icon": "puzzle15.app.png", @@ -5404,7 +5404,9 @@ "allow_emulator": true, "storage": [ {"name":"puzzle15.app.js","url":"puzzle15.app.js"}, + {"name":"puzzle15.settings.js","url":"puzzle15.settings.js"}, {"name":"puzzle15.img","url":"puzzle15.app-icon.js","evaluate":true} - ] + ], + "data": [{"name":"puzzle15.json"}] } ] diff --git a/apps/puzzle15/ChangeLog b/apps/puzzle15/ChangeLog index d7458c968..bd6af53d8 100644 --- a/apps/puzzle15/ChangeLog +++ b/apps/puzzle15/ChangeLog @@ -1,3 +1,4 @@ 0.01: Initial version, UI mechanics ready, no real game play so far 0.02: Lots of enhancements, menu system not yet functional, but packaging should be now... 0.03: Menu logic now generally functioning, splash screen added. The first really playable version! +0.04: Settings dialog, about screen diff --git a/apps/puzzle15/README.md b/apps/puzzle15/README.md index eddeb3b94..16c0c4593 100644 --- a/apps/puzzle15/README.md +++ b/apps/puzzle15/README.md @@ -4,13 +4,16 @@ This is a Bangle.js 2 adoption of the famous 15 puzzle. ## The game -A board of n x n fields is filled with n²-1 numbered stones. +A board of _n_ by _n_ fields is filled with _n^2-1_ numbered stones. So, one field, the "gap", is left free. + Bring them in the correct order so that the gap is finally at the bottom right of the playing field. The less moves you need, the better you are. +If _n_ is 4, the number of stones is _16-1=15_. Hence the name of the game. + ## How to play -Select whether you want to play on a board with 3 x 3, 4 x 4, or 5 x 5 fields. +If you start the game, it shows a splash screen and then generates a shuffled 4x4 board with a 15 puzzle. Move the stones with drag gestures on the screen. If you want to move the stone below the gap upward, drag from the bottom of the screen upward. The drag gestures can be performed anywhere on the screen, there is no need to start or end them on the stone to be moved. @@ -18,10 +21,37 @@ The drag gestures can be performed anywhere on the screen, there is no need to s If you managed to order the stones correctly, a success message appears. You can continue with another game, go to the game's main menu, or quit the game entirely. -There is a menu button right of the board. It opens the game's main menu. +There is a grey menu button right of the board containing the well-known three-bar menu symbol ("Hamburger menu"). +It opens the game's main menu directly from within the game. -## Implemenation notes +## The main menu -The game engine always generates 15 puzzles which can be solved. +Puzzle15 has a main menu which can be reached from the in-game menu button or the end-of-game message window. +It features the following options: + +* **Continue** - Continue the currently running game. _This option is only shown if the main menu is opened during an open game._ +* **Start 3x3**, **Start 4x4**, **Start 5x5** - Start a new game on a board with the respective dimension. Any currently open game is dropped. +* **About** Show a small "About" info box. +* **Exit** Exit Puzzle15 and return to the default watch face. + +## Game settings + +The game has some global settings which can be accessed on the usual way through the Bangle.js' app settings user interface. +Currently it has the following options: + +* **Splash** - Define whether the game should open with a splash screen. **long** shows the splash screen for five seconds, **short** shows it for two seconds. **off** starts the app _without_ a splash screen, it directly comes up with whatever the "Start with" option says. +* **Start with** - What should happen after the splash screen (or, if it is disabled, directly at app start): **3x3**, **4x4** and **5x5** start the game with a board of the respective dimension, **menu** shows the main menu which allows to select the board size. + +## Implementation notes + +The game engine always generates puzzles which can be solved. + +Solvability is detected by counting inversions, +i.e. pairs of stones where the stone at the earlier field (row-wise, left to right, top to bottom) has a number _greater than_ the stone on the later field, with all pairs of stones compared. +The algorithm is described at https://www.geeksforgeeks.org/check-instance-15-puzzle-solvable/ . + +## The splash screen + +The Splash screen shows a part of the illustration "The 14-15-puzzle in puzzleland" from Sam Loyd. Other than Puzzle15, it depicts a 15 puzzle with the stones "14" and "15" swapped. This puzzle is indeed *not* solvable. Have fun! diff --git a/apps/puzzle15/puzzle15.app.js b/apps/puzzle15/puzzle15.app.js index c5c60bb0f..e66f9b9bb 100644 --- a/apps/puzzle15/puzzle15.app.js +++ b/apps/puzzle15/puzzle15.app.js @@ -2,36 +2,37 @@ // (C) Dirk Hillbrecht 2022 // Released unter the terms of the MIT license -// *** Global settings -// Note: These could be changed by settings later... - +// The intro screen as large base-64-encoded binary data const introscreen = E.toArrayBuffer(atob("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")); +// *** Global constants from which several other settings are derived + // Minimum number of pixels to interpret it as drag gesture const dragThreshold = 10; // Maximum number of pixels to interpret a click from a drag event series const clickThreshold = 3; -// Number of steps in button move animation +// Number of steps in stone move animation const animationSteps = 6; // Milliseconds to wait between move animation steps const animationWaitMillis = 30; -// *** Global settings derived by device characteristics - // Total width of the playing field (full screen width) const fieldw = g.getWidth(); // Total height of the playing field (screen height minus widget zones) const fieldh = g.getHeight() - 48; -// Size of the playing field -var buttonsPerLine; -// Size of one button -var buttonsize; +// *** Global game characteristics + +// Size of the playing field +var stonesPerLine; + +// Size of one field +var stonesize; // Actual left start of the playing field (so that it is centered) var leftstart; @@ -39,18 +40,51 @@ var leftstart; // Actual top start of the playing field (so that it is centered) var topstart; -// Number of buttons on the board (needed at several occasions) -var buttonsPerBoard; +// Number of stones on the board (needed at several occasions) +var stonesPerBoard; -// Set the buttons per line globally and all derived values, too -function setButtonsPerLine(bPL) { - buttonsPerLine = bPL; - buttonsize = Math.floor(Math.min(fieldw / (buttonsPerLine + 1), fieldh / buttonsPerLine)) - 2; - leftstart = (fieldw - ((buttonsPerLine + 1) * buttonsize + 8)) / 2; - topstart = 24 + ((fieldh - (buttonsPerLine * buttonsize + 6)) / 2); - buttonsPerBoard = (buttonsPerLine * buttonsPerLine); +// Set the stones per line globally and all derived values, too +function setStonesPreLine(bPL) { + stonesPerLine = bPL; + stonesize = Math.floor(Math.min(fieldw / (stonesPerLine + 1), fieldh / stonesPerLine)) - 2; + leftstart = (fieldw - ((stonesPerLine + 1) * stonesize + 8)) / 2; + topstart = 24 + ((fieldh - (stonesPerLine * stonesize + 6)) / 2); + stonesPerBoard = (stonesPerLine * stonesPerLine); } + +// *** Global app settings + +var SETTINGSFILE = "puzzle15.json"; + +// variables defined from settings +var splashMode; +var startWith; + +/* For development purposes +require('Storage').writeJSON(SETTINGSFILE, { + splashMode: "off", + startWith: "5x5", +}); +/* */ + +/* OR (also for development purposes) +require('Storage').erase(SETTINGSFILE); +/* */ + +// Helper method for loading the settings +function def(value, def) { + return (value !== undefined ? value : def); +} + +// Load settings +function loadSettings() { + var settings = require('Storage').readJSON(SETTINGSFILE, true) || {}; + splashMode = def(settings.splashMode, "long"); + startWith = def(settings.startWith, "4x4"); +} + + // *** Low level helper classes // One node of a first-in-first-out storage @@ -64,11 +98,13 @@ class FifoNode { // Simple first-in-first-out (fifo) storage // Needed to keep the stone movements in order class Fifo { + // Initialize an empty Fifo constructor() { this.first = null; this.last = null; } + // Add an element to the end of the internal fifo queue add(payload) { if (this.last === null) { // queue is empty @@ -80,6 +116,7 @@ class Fifo { this.last = newlast; } } + // Returns the first element in the queue, null if it is empty remove() { if (this.first === null) @@ -90,10 +127,12 @@ class Fifo { this.last = null; return oldfirst.payload; } + // Returns if the fifo is empty, i.e. it does not hold any elements isEmpty() { return (this.first === null); } + } // Helper class to keep track of tasks @@ -104,11 +143,13 @@ class Fifo { // previously scheduled tasks have finished. // Tasks must be functions with the Worker as first and only parameter. class Worker { + // Create an empty worker constructor() { this.tasks = new Fifo(); this.busy = false; } + // Add a task to the worker addTask(task) { if (this.busy) // other task is running: Queue this task @@ -118,6 +159,7 @@ class Worker { task(this); } } + // Called by the task once it finished endTask() { if (this.tasks.isEmpty()) // No more tasks queued: Become idle @@ -125,6 +167,7 @@ class Worker { else // Call the next task immediately this.tasks.remove()(this); } + } // Evaluate "drag" events from the UI and call handlers for drags or clicks @@ -135,6 +178,7 @@ class Worker { // otherwise as drag. // The approprate method is called with the data of the drag. class Dragger { + constructor(clickHandler, dragHandler, clickThreshold, dragThreshold) { this.clickHandler = clickHandler; this.dragHandler = dragHandler; @@ -144,10 +188,12 @@ class Dragger { this.dy = 0; this.enabled = true; } + // Enable or disable the Dragger setEnabled(b) { this.enabled = b; } + // Handle a raw drag event from the UI handleRawDrag(e) { if (!this.enabled) @@ -171,6 +217,7 @@ class Dragger { this.dy = 0; } } + // Attach the drag evaluator to the UI attach() { Bangle.on("drag", e => this.handleRawDrag(e)); @@ -185,37 +232,43 @@ class Dragger { // During an animation, a series of interim field instances is generated // which represents the locations of a stone during the animation. class Field { + // Generate a field with a left and a top coordinate. // Note that these coordinates are "cooked", i.e. they contain all offsets // needed place the elements globally correct on the screen constructor(left, top) { this.left = left; this.top = top; - this.centerx = (left + buttonsize / 2) + 1; - this.centery = (top + buttonsize / 2) + 2; + this.centerx = (left + stonesize / 2) + 1; + this.centery = (top + stonesize / 2) + 2; } + // Returns whether this field contains the given coordinate contains(x, y) { - return (this.left < x && this.left + buttonsize > x && - this.top < y && this.top + buttonsize > y); + return (this.left < x && this.left + stonesize > x && + this.top < y && this.top + stonesize > y); } + // Generate a field for the given playing field index. // Playing field indexes start at top left with "0" // and go from left to right line by line from top to bottom. static forIndex(index) { - return new Field(leftstart + (index % buttonsPerLine) * (buttonsize + 2), - topstart + (Math.floor(index / buttonsPerLine)) * (buttonsize + 2)); + return new Field(leftstart + (index % stonesPerLine) * (stonesize + 2), + topstart + (Math.floor(index / stonesPerLine)) * (stonesize + 2)); + } // Special field for the result "stone" static forResult() { - return new Field(leftstart + (buttonsPerLine * (buttonsize + 2)), - topstart + ((buttonsPerLine - 1) * (buttonsize + 2))); + return new Field(leftstart + (stonesPerLine * (stonesize + 2)), + topstart + ((stonesPerLine - 1) * (stonesize + 2))); } + // Special field for the menu static forMenu() { - return new Field(leftstart + (buttonsPerLine * (buttonsize + 2)), + return new Field(leftstart + (stonesPerLine * (stonesize + 2)), topstart); } + } // Representation of a moveable stone of the game. @@ -224,6 +277,7 @@ class Field { // Stone "0" represents the gap on the playing field. // The main knowledge of a Stone instance is how to draw itself. class Stone { + // Create stone with the given number // The constructor creates the "draw()" function which is used to draw the stone constructor(number, targetindex) { @@ -232,22 +286,23 @@ class Stone { // gap: Does not draw anything if (number === 0) this.draw = function(field) {}; - else if ((number + (buttonsPerLine % 2 == 0 ? (Math.floor((number - 1) / buttonsPerLine)) : 0)) % 2 == 0) { + else if ((number + (stonesPerLine % 2 == 0 ? (Math.floor((number - 1) / stonesPerLine)) : 0)) % 2 == 0) { // Black stone this.draw = function(field) { - g.setFont("Vector", 20).setFontAlign(0, 0).setColor(0, 0, 0); - g.fillRect(field.left, field.top, field.left + buttonsize, field.top + buttonsize); + g.setFont("Vector", (stonesPerLine === 5 ? 16 : 20)).setFontAlign(0, 0).setColor(0, 0, 0); + g.fillRect(field.left, field.top, field.left + stonesize, field.top + stonesize); g.setColor(1, 1, 1).drawString(number, field.centerx, field.centery); }; } else { // White stone this.draw = function(field) { - g.setFont("Vector", 20).setFontAlign(0, 0).setColor(0, 0, 0); - g.drawRect(field.left, field.top, field.left + buttonsize, field.top + buttonsize); + g.setFont("Vector", (stonesPerLine === 5 ? 16 : 20)).setFontAlign(0, 0).setColor(0, 0, 0); + g.drawRect(field.left, field.top, field.left + stonesize, field.top + stonesize); g.drawString(number, field.centerx, field.centery); }; } } + // Returns whether this stone is on its target index isOnTarget(index) { return index === this.targetindex; @@ -256,6 +311,7 @@ class Stone { // Helper class which knows how to clear the rectangle opened up by the two given fields class Clearer { + // Create a clearer for the area between the two given fields constructor(startfield, endfield) { this.minleft = Math.min(startfield.left, endfield.left); @@ -263,16 +319,19 @@ class Clearer { this.maxleft = Math.max(startfield.left, endfield.left); this.maxtop = Math.max(startfield.top, endfield.top); } + // Clear the area defined by this clearer clearArea() { g.setColor(1, 1, 1); g.fillRect(this.minleft, this.mintop, - this.maxleft + buttonsize, this.maxtop + buttonsize); + this.maxleft + stonesize, this.maxtop + stonesize); } + } // Helper class which moves a stone between two fields class Mover extends Clearer { + // Create a mover which moves the given stone from startfield to endfield // and animate the move in the given number of steps constructor(stone, startfield, endfield, steps) { @@ -282,11 +341,13 @@ class Mover extends Clearer { this.endfield = endfield; this.steps = steps; } + // Create the coordinate between start and end for the given step // Computation uses sinus for a smooth movement stepCoo(start, end, step) { return start + ((end - start) * ((1 + Math.sin((step / this.steps) * Math.PI - (Math.PI / 2))) / 2)); } + // Compute the interim field for the stone to place during the animation stepField(step) { return new Field( @@ -295,6 +356,7 @@ class Mover extends Clearer { (this.mintop === this.maxtop ? this.mintop : this.stepCoo(this.startfield.top, this.endfield.top, step))); } + // Perform one animation step animateStep(step, worker) { this.clearArea(); @@ -306,53 +368,60 @@ class Mover extends Clearer { else // all steps done: Inform the worker worker.endTask(); } + // Start the animation, this method is called by the worker animate(worker) { this.animateStep(1, worker); } + } // Representation of the playing field // Knows to draw the field and to move a stone into a gap -// TODO: More game mechanics (solving,...) class Board { - // Generates the actual playing field with all fields and buttons + + // Generates the actual playing field with all fields and stones constructor() { this.fields = []; this.resultField = Field.forResult(); this.menuField = Field.forMenu(); - for (i = 0; i < buttonsPerBoard; i++) + for (i = 0; i < stonesPerBoard; i++) this.fields[i] = Field.forIndex(i); this.setShuffled(); //this.setAlmostSolved(); // to test the game end } + + /* // Set the board into the "solved" position. Useful for showcasing setSolved() { - this.buttons = []; - for (i = 0; i < buttonsPerBoard; i++) - this.buttons[i] = new Stone((i + 1) % buttonsPerBoard, i); + this.stones = []; + for (i = 0; i < stonesPerBoard; i++) + this.stones[i] = new Stone((i + 1) % stonesPerBoard, i); this.moveCount = 0; } + // Initialize an almost solved playing field. Useful for tests and development setAlmostSolved() { this.setSolved(); - b = this.buttons[this.buttons.length - 1]; - this.buttons[this.buttons.length - 1] = this.buttons[this.buttons.length - 2]; - this.buttons[this.buttons.length - 2] = b; + b = this.stones[this.stones.length - 1]; + this.stones[this.stones.length - 1] = this.stones[this.stones.length - 2]; + this.stones[this.stones.length - 2] = b; } + */ + // Initialize a shuffled field. The fields are always solvable. setShuffled() { let nrs = []; // numbers of the stones - for (i = 0; i < buttonsPerBoard; i++) + for (i = 0; i < stonesPerBoard; i++) nrs[i] = i; - this.buttons = []; - let count = buttonsPerBoard; - for (i = 0; i < buttonsPerBoard; i++) { + this.stones = []; + let count = stonesPerBoard; + for (i = 0; i < stonesPerBoard; i++) { // Take a random number of the (remaining) numbers let curridx = Math.floor(Math.random() * count); let currnr = nrs[curridx]; - // Initialize the next button with that random number - this.buttons[i] = new Stone(currnr, (currnr + (buttonsPerBoard - 1)) % buttonsPerBoard); + // Initialize the next stone with that random number + this.stones[i] = new Stone(currnr, (currnr + (stonesPerBoard - 1)) % stonesPerBoard); // Remove the number just taken from the list of numbers for (j = curridx + 1; j < count; j++) nrs[j - 1] = nrs[j]; @@ -361,52 +430,68 @@ class Board { // not solvable: Swap the first and second stone which are not the gap. // This will always result in a solvable board. if (!this.isSolvable()) { - let a = (this.buttons[0].number === 0 ? 2 : 0); - let b = (this.buttons[1].number === 0 ? 2 : 1); - let bx = this.buttons[a]; - this.buttons[a] = this.buttons[b]; - this.buttons[b] = bx; + let a = (this.stones[0].number === 0 ? 2 : 0); + let b = (this.stones[1].number === 0 ? 2 : 1); + let bx = this.stones[a]; + this.stones[a] = this.stones[b]; + this.stones[b] = bx; } this.moveCount = 0; } + // Draws the complete playing field draw() { new Clearer(this.fields[0], this.fields[this.fields.length - 1]).clearArea(); for (i = 0; i < this.fields.length; i++) - this.buttons[i].draw(this.fields[i]); + this.stones[i].draw(this.fields[i]); this.drawResult(null); this.drawMenu(); } + // returns the index of the field left of the field with the given index, // -1 if there is none (index indicates already a leftmost field on the board) leftOf(index) { - return (index % buttonsPerLine === 0 ? -1 : index - 1); + return (index % stonesPerLine === 0 ? -1 : index - 1); } + + // returns the index of the field right of the field with the given index, + // -1 if there is none (index indicates already a rightmost field on the board) rightOf(index) { - return (index % buttonsPerLine === (buttonsPerLine - 1) ? -1 : index + 1); + return (index % stonesPerLine === (stonesPerLine - 1) ? -1 : index + 1); } + + // returns the index of the field top of the field with the given index, + // -1 if there is none (index indicates already a topmost field on the board) topOf(index) { - return (index >= buttonsPerLine ? index - buttonsPerLine : -1); + return (index >= stonesPerLine ? index - stonesPerLine : -1); } + + // returns the index of the field bottom of the field with the given index, + // -1 if there is none (index indicates already a bottommost field on the board) bottomOf(index) { - return (index < (buttonsPerLine - 1) * buttonsPerLine ? index + buttonsPerLine : -1); + return (index < (stonesPerLine - 1) * stonesPerLine ? index + stonesPerLine : -1); } + // Return the index of the gap in the field, -1 if there is none (should never happel) indexOf0() { - for (i = 0; i < this.buttons.length; i++) - if (this.buttons[i].number === 0) + for (i = 0; i < this.stones.length; i++) + if (this.stones[i].number === 0) return i; return -1; } + // Returns the row in which the gap is, 0 is upmost rowOf0() { let idx = this.indexOf0(); if (idx < 0) return -1; - return Math.floor(idx / buttonsPerLine); + return Math.floor(idx / stonesPerLine); } - // Moves the stone at the field with the index found by the startfunc operation - // into the gap field. + + // Searches the gap on the field and then moves one of the adjacent stones into it. + // The stone is selected by the given startfunc which returns the index + // of the selected adjacent field. + // Startfunc is one of (left|right|top|bottom)Of. moveTo0(startfunc, worker) { let endidx = this.indexOf0(); // Target field (the gap) if (endidx === -1) { @@ -418,58 +503,71 @@ class Board { worker.endTask(); return; } - let moved = this.buttons[startidx]; - this.buttons[startidx] = this.buttons[endidx]; - this.buttons[endidx] = moved; + // Replace in the internal representation + let moved = this.stones[startidx]; + this.stones[startidx] = this.stones[endidx]; + this.stones[endidx] = moved; this.moveCount += 1; + // Move on screen using an animation effect. new Mover(moved, this.fields[startidx], this.fields[endidx], animationSteps).animate(worker); } - // Move the stone right fro the gap into the gap + + // Move the stone right from the gap into the gap moveRight(worker) { this.moveTo0(this.leftOf, worker); } + + // Move the stone left from the gap into the gap moveLeft(worker) { this.moveTo0(this.rightOf, worker); } + + // Move the stone above the gap into the gap moveUp(worker) { this.moveTo0(this.bottomOf, worker); } + + // Move the stone below the gap into the gap moveDown(worker) { this.moveTo0(this.topOf, worker); } + // Check if the board is solved (all stones at the right position) isSolved() { - for (i = 0; i < this.buttons.length; i++) - if (!this.buttons[i].isOnTarget(i)) + for (i = 0; i < this.stones.length; i++) + if (!this.stones[i].isOnTarget(i)) return false; return true; } + // counts the inversions on the board // see https://www.geeksforgeeks.org/check-instance-15-puzzle-solvable/ getInversionCount() { let inversions = 0; - for (outer = 0; outer < buttonsPerBoard - 1; outer++) { - let outernr = this.buttons[outer].number; + for (outer = 0; outer < stonesPerBoard - 1; outer++) { + let outernr = this.stones[outer].number; if (outernr === 0) continue; - for (inner = outer + 1; inner < buttonsPerBoard; inner++) { - let innernr = this.buttons[inner].number; + for (inner = outer + 1; inner < stonesPerBoard; inner++) { + let innernr = this.stones[inner].number; if (innernr > 0 && outernr > innernr) inversions++; } } return inversions; } + // return whether the puzzle is solvable // see https://www.geeksforgeeks.org/check-instance-15-puzzle-solvable/ isSolvable() { let invs = this.getInversionCount(); - if (buttonsPerLine % 2 !== 0) // odd number of rows/columns + if (stonesPerLine % 2 !== 0) // odd number of rows/columns return (invs % 2 === 0); else { return ((invs + this.rowOf0()) % 2 !== 0); } } + // draw the result field, pass null as argument if not called from worker drawResult(worker) { let field = this.resultField; @@ -478,9 +576,9 @@ class Board { g.setColor(0, 1, 0); else g.setColor(1, 0, 0); - g.fillRect(field.left, field.top, field.left + buttonsize, field.top + buttonsize); + g.fillRect(field.left, field.top, field.left + stonesize, field.top + stonesize); g.setColor(0, 0, 0); - g.drawRect(field.left, field.top, field.left + buttonsize, field.top + buttonsize); + g.drawRect(field.left, field.top, field.left + stonesize, field.top + stonesize); g.setFont("Vector", 14).setFontAlign(0, 0).drawString(this.moveCount, field.centerx, field.centery); if (worker !== null) worker.endTask(); @@ -489,21 +587,29 @@ class Board { gameEnd(this.moveCount); }, 500); } + // draws the menu button drawMenu() { let field = this.menuField; g.setColor(0.5, 0.5, 0.5); - g.fillRect(field.left, field.top, field.left + buttonsize, field.top + buttonsize); + g.fillRect(field.left, field.top, field.left + stonesize, field.top + stonesize); g.setColor(0, 0, 0); - g.drawRect(field.left, field.top, field.left + buttonsize, field.top + buttonsize); + g.drawRect(field.left, field.top, field.left + stonesize, field.top + stonesize); let l = field.left + 8; - let r = field.left + buttonsize - 8; + let r = field.left + stonesize - 8; let t = field.top + 5; for (i = 0; i < 3; i++) g.fillRect(l, t + (i * 6), r, t + (i * 6) + 2); } + } + +// *** Global helper methods + +// draw some text with some surrounding to increase contrast +// text is drawn at given (x,y) position with textcol. +// frame is drawn 2 pixels around (x,y) in each direction in framecol. function framedText(text, x, y, textcol, framecol) { g.setColor(framecol); for (i = -2; i < 3; i++) @@ -515,36 +621,32 @@ function framedText(text, x, y, textcol, framecol) { g.setColor(textcol).drawString(text, x, y); } +// Show the splash screen at program start, call afterSplash afterwards. +// If spash mode is "off", call afterSplash directly. function showSplash(afterSplash) { - g.reset(); - g.drawImage(introscreen, 0, 0); - setTimeout(() => { - g.setFont("Vector", 40).setFontAlign(0, 0); - framedText("15", g.getWidth() / 2, g.getHeight() / 2 - g.getFontHeight() * 0.66, "#f00", "#fff"); + if (splashMode === "off") + afterSplash(); + else { + g.reset(); + g.drawImage(introscreen, 0, 0); setTimeout(() => { g.setFont("Vector", 40).setFontAlign(0, 0); - framedText("Puzzle", g.getWidth() / 2, g.getHeight() / 2 + g.getFontHeight() * 0.66, "#f00", "#fff"); - setTimeout(afterSplash, 2000); - }, 1000); - }, 2000); + framedText("15", g.getWidth() / 2, g.getHeight() / 2 - g.getFontHeight() * 0.66, "#f00", "#fff"); + setTimeout(() => { + g.setFont("Vector", 40).setFontAlign(0, 0); + framedText("Puzzle", g.getWidth() / 2, g.getHeight() / 2 + g.getFontHeight() * 0.66, "#f00", "#fff"); + setTimeout(afterSplash, (splashMode === "long" ? 2000 : 1000)); + }, (splashMode === "long" ? 1000 : 1)); + }, (splashMode === "long" ? 2000 : 1000)); + } } -// *** Main program -g.reset(); - -// We need a worker... -var worker = new Worker(); - -setButtonsPerLine(3); -// ...and the board -var board = new Board(); - -var dragger; +// *** Global flow control // Initialize the game with an explicit number of stones per line function initGame(bpl) { - setButtonsPerLine(bpl); + setStonesPreLine(bpl); newGame(); } @@ -564,7 +666,7 @@ function continueGame() { // Show message on game end, allows to restart new game function gameEnd(moveCount) { dragger.setEnabled(false); - E.showPrompt("You solved the\n" + buttonsPerLine + "x" + buttonsPerLine + " puzzle in\n" + moveCount + " move" + (moveCount === 1 ? "" : "s") + ".", { + E.showPrompt("You solved the\n" + stonesPerLine + "x" + stonesPerLine + " puzzle in\n" + moveCount + " move" + (moveCount === 1 ? "" : "s") + ".", { title: "Puzzle solved", buttons: { "Again": newGame, @@ -577,6 +679,17 @@ function gameEnd(moveCount) { }); } +// A tiny about screen +function showAbout(doContinue) { + E.showAlert("Author: Dirk Hillbrecht\nLicense: MIT", "Puzzle15").then(() => { + if (doContinue) + continueGame(); + else + showMenu(false); + }); +} + +// Show the in-game menu allowing to start a new game function showMenu(withContinue) { var mainmenu = { "": { @@ -588,18 +701,20 @@ function showMenu(withContinue) { mainmenu["Start 3x3"] = () => initGame(3); mainmenu["Start 4x4"] = () => initGame(4); mainmenu["Start 5x5"] = () => initGame(5); + mainmenu.About = () => showAbout(withContinue); mainmenu.Exit = () => load(); dragger.setEnabled(false); g.clear(true); E.showMenu(mainmenu); } +// Handle a "click" event (only needed for menu button) function handleclick(e) { if (board.menuField.contains(e.x, e.y)) setTimeout(() => showMenu(true), 10); } -// Handle a drag event +// Handle a drag event (moving the stones around) function handledrag(e) { worker.addTask(Math.abs(e.dx) > Math.abs(e.dy) ? (e.dx > 0 ? e => board.moveRight(e) : e => board.moveLeft(e)) : @@ -607,18 +722,44 @@ function handledrag(e) { worker.addTask(e => board.drawResult(e)); } -dragger = new Dragger(handleclick, handledrag, clickThreshold, dragThreshold); -// Start the interaction +// *** Main program + +g.clear(true); + +// Load global app settings +loadSettings(); + +// We need a worker... +var worker = new Worker(); + +// Board will be initialized after the splash screen has been shown +var board; + +// Dragger is needed for interaction during the game +var dragger = new Dragger(handleclick, handledrag, clickThreshold, dragThreshold); + +// Disable dragger as board is not yet initialized +dragger.setEnabled(false); + +// Nevertheless attach it so that it is ready once the game starts dragger.attach(); +// Start the game by handling the splash screen sequence showSplash(() => { // Clock mode allows short-press on button to exit Bangle.setUI("clock"); // Load widgets Bangle.loadWidgets(); Bangle.drawWidgets(); - showMenu(false); -}, 5000); + if (startWith === "3x3") + initGame(3); + else if (startWith === "4x4") + initGame(4); + else if (startWith === "5x5") + initGame(5); + else + showMenu(false); +}); -// end of file \ No newline at end of file +// end of file diff --git a/apps/puzzle15/puzzle15.settings.js b/apps/puzzle15/puzzle15.settings.js new file mode 100644 index 000000000..352ec4315 --- /dev/null +++ b/apps/puzzle15/puzzle15.settings.js @@ -0,0 +1,50 @@ +// Settings menu for the Puzzle15 app + +(function(back) { + var FILE = "puzzle15.json"; + // Load settings + var settings = Object.assign({ + splashMode: "long", + startWith: "4x4" + }, require('Storage').readJSON(FILE, true) || {}); + + function writeSettings() { + require('Storage').writeJSON(FILE, settings); + } + + // Helper method which uses int-based menu item for set of string values + function stringItems(startvalue, writer, values) { + return { + value: (startvalue === undefined ? 0 : values.indexOf(startvalue)), + format: v => values[v], + min: 0, + max: values.length - 1, + wrap: true, + step: 1, + onchange: v => { + writer(values[v]); + writeSettings(); + } + }; + } + + // Helper method which breaks string set settings down to local settings object + function stringInSettings(name, values) { + return stringItems(settings[name], v => settings[name] = v, values); + } + + var mainmenu = { + "": { + "title": "15 Puzzle" + }, + "< Back": () => back(), + "Splash": stringInSettings("splashMode", ["long", "short", "off"]), + "Start with": stringInSettings("startWith", ["3x3", "4x4", "5x5", "menu"]) + }; + + // Actually display the menu + E.showMenu(mainmenu); + +}); + +// end of file \ No newline at end of file