diff --git a/apps.json b/apps.json index 5cc311413..c213fdaa6 100644 --- a/apps.json +++ b/apps.json @@ -5389,5 +5389,24 @@ "storage": [ {"name":"touchmenu.boot.js","url":"touchmenu.boot.js"} ] + }, + { + "id": "puzzle15", + "name": "15 puzzle", + "version": "0.05", + "description": "A 15 puzzle game with drag gesture interface", + "readme":"README.md", + "icon": "puzzle15.app.png", + "screenshots": [{"url":"screenshot.png"}], + "type": "app", + "tags": "game", + "supports": ["BANGLEJS2"], + "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 new file mode 100644 index 000000000..0950b7ae0 --- /dev/null +++ b/apps/puzzle15/ChangeLog @@ -0,0 +1,5 @@ +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 +0.05: Central game end function diff --git a/apps/puzzle15/README.md b/apps/puzzle15/README.md new file mode 100644 index 000000000..16c0c4593 --- /dev/null +++ b/apps/puzzle15/README.md @@ -0,0 +1,57 @@ +# Puzzle15 - A 15-puzzle for the Bangle.js 2 + +This is a Bangle.js 2 adoption of the famous 15 puzzle. + +## The game + +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 + +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. + +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 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. + +## The main menu + +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/introscreen.png b/apps/puzzle15/introscreen.png new file mode 100644 index 000000000..766cd61fa Binary files /dev/null and b/apps/puzzle15/introscreen.png differ diff --git a/apps/puzzle15/puzzle15.app-icon.js b/apps/puzzle15/puzzle15.app-icon.js new file mode 100644 index 000000000..04fb4a665 --- /dev/null +++ b/apps/puzzle15/puzzle15.app-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwgn/AC3+7oAD7e7AAW8BQndBQe79/9DomgHocH74KD/RJE34Xax4XDtvoC4fJ54XDluAC4f2z4XDzm/C4ett4XD34OBF4e/I4m+C4f8r4XChHuC5U98oXEF4cP7/AC5O9mYXC/2/F4cGtwvE/SsBC4Ws7gvD7YCBL4ULO4i/u1QAD7QED1e6AoetCAnf/YeE1wpD/lgBQcKIAgXG14LD/twC5kL3Z+BC4P+LgIXBg272wXD7wXEh7eCC4PWzIXChHtOoIXB/WX54XDh3KmAXC1oLBI4UD+AXC+/rdIIvD5wvD3O4C4cJ4AXC/dUI4kJhgMBC4Ov+AXDh9QC4X2/gvEhvvoAXC81dC4duR4f8wSncC6v8u4AD3ndAAXcy4KDtYKD7vf/oGE2wRDvPNBQfLFAnP/o2EVIIACg7yBAATZBAAe/C7P9g4XCx+wn/6C4Op//AC4MK+cI/+QC4X2/fPC4PM2HKh8H7vpewIXBhvThV5+AXC+/5C4UL2HHC4Pf/P/AIJHB6cAj2wC4X+3AXPhADBF4fX94XB1va1vOC4PXAIX6hfrxvb0CPD7p3C1e6hW2C4LOBAIIXB3eJ3YXEX78GM4IAC9QXG1QAD7QEDJYIFD14oE//7DwgME/twBQcPC70G6EG5dQ1/8VYPtC4ObgfM5IXHr/whvO4Gvy6LBtX9vfugnr3AXHkXggGOC4P97/43X9ukOgnv6BfIC4Oe2AXC6+nI4MOgfI9QXJhssF4f91AXCgnA9IXHr3u1HusGv3Ob//s/t693l3xHJX9v+3YAD7oAE5YKD34XFAC4=")) diff --git a/apps/puzzle15/puzzle15.app.js b/apps/puzzle15/puzzle15.app.js new file mode 100644 index 000000000..eec21933c --- /dev/null +++ b/apps/puzzle15/puzzle15.app.js @@ -0,0 +1,771 @@ +// A 15-puzzle game for the Bangle.js 2 clock +// (C) Dirk Hillbrecht 2022 +// Released unter the terms of the MIT license + +// 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 stone move animation +const animationSteps = 6; + +// Milliseconds to wait between move animation steps +const animationWaitMillis = 30; + +// 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; + + +// *** 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; + +// Actual top start of the playing field (so that it is centered) +var topstart; + +// Number of stones on the board (needed at several occasions) +var stonesPerBoard; + +// 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 +class FifoNode { + constructor(payload) { + this.payload = payload; + this.next = null; + } +} + +// 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 + this.first = new FifoNode(payload); + this.last = this.first; + } else { + let newlast = new FifoNode(payload); + this.last.next = newlast; + this.last = newlast; + } + } + + // Returns the first element in the queue, null if it is empty + remove() { + if (this.first === null) + return null; + let oldfirst = this.first; + this.first = this.first.next; + if (this.first === null) + 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 +// Executes tasks given by addTask. +// Tasks must call Worker.endTask() when they are finished, for this they get the worker passed as parameter. +// If a task is given with addTask() while another task is still running, +// it is queued and executed once the currently running task and all +// 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 + this.tasks.add(task); + else { // No other task is running: Execute directly + this.busy = true; + task(this); + } + } + + // Called by the task once it finished + endTask() { + if (this.tasks.isEmpty()) // No more tasks queued: Become idle + this.busy = false; + else // Call the next task immediately + this.tasks.remove()(this); + } + +} + +// Evaluate "drag" events from the UI and call handlers for drags or clicks +// The UI sends a drag as a series of events indicating partial movements +// of the finger. +// This class combines such parts to a long drag from start to end +// If the drag is short, it is interpreted as click, +// 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; + this.clickThreshold = (clickThreshold === undefined ? 3 : clickThreshold); + this.dragThreshold = (dragThreshold === undefined ? 10 : dragThreshold); + this.dx = 0; + 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) + return; + this.dx += e.dx; // Always accumulate + this.dy += e.dy; + if (e.b === 0) { // Drag event ended: Evaluate full drag + if (Math.abs(this.dx) < this.clickThreshold && Math.abs(this.dy) < this.clickThreshold) + this.clickHandler({ + x: e.x - this.dx, + y: e.y - this.dy + }); // take x and y from the drag start + else if (Math.abs(this.dx) > this.dragThreshold || Math.abs(this.dy) > this.dragThreshold) + this.dragHandler({ + x: e.x - this.dx, + y: e.y - this.dy, + dx: this.dx, + dy: this.dy + }); + this.dx = 0; // Clear the drag accumulator + this.dy = 0; + } + } + + // Attach the drag evaluator to the UI + attach() { + Bangle.on("drag", e => this.handleRawDrag(e)); + } +} + +// *** Mid-level game mechanics + +// Representation of a position where a stone is set. +// Stones can be moved from field to field. +// The playing field consists of a fixed set of fields forming a square. +// 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 + 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 + 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 % stonesPerLine) * (stonesize + 2), + topstart + (Math.floor(index / stonesPerLine)) * (stonesize + 2)); + + } + // Special field for the result "stone" + static forResult() { + return new Field(leftstart + (stonesPerLine * (stonesize + 2)), + topstart + ((stonesPerLine - 1) * (stonesize + 2))); + } + + // Special field for the menu + static forMenu() { + return new Field(leftstart + (stonesPerLine * (stonesize + 2)), + topstart); + } + +} + +// Representation of a moveable stone of the game. +// Stones are moved from field to field to solve the puzzle +// Stones are numbered from 0 to the maximum number ot stones. +// 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) { + this.number = number; + this.targetindex = targetindex; + // gap: Does not draw anything + if (number === 0) + this.draw = function(field) {}; + else if ((number + (stonesPerLine % 2 == 0 ? (Math.floor((number - 1) / stonesPerLine)) : 0)) % 2 == 0) { + // Black stone + this.draw = function(field) { + 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", (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; + } +} + +// 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); + this.mintop = Math.min(startfield.top, endfield.top); + 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 + 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) { + super(startfield, endfield); + this.stone = stone; + this.startfield = startfield; + 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( + (this.minleft === this.maxleft ? this.minleft : + this.stepCoo(this.startfield.left, this.endfield.left, step)), + (this.mintop === this.maxtop ? this.mintop : + this.stepCoo(this.startfield.top, this.endfield.top, step))); + } + + // Perform one animation step + animateStep(step, worker) { + this.clearArea(); + this.stone.draw(this.stepField(step)); + if (step < this.steps) // still steps left: Issue next step + setTimeout(function(t) { + t.animateStep(step + 1, worker); + }, animationWaitMillis, this); + 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 +class Board { + + // 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 < 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 and development + setSolved() { + 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.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 < stonesPerBoard; i++) + nrs[i] = 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 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]; + count -= 1; + } + // 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.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.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 % 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 % 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 >= 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 < (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.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 / stonesPerLine); + } + + // 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) { + worker.endTask(); + return; + } + let startidx = startfunc(endidx); // Start field (relative to the gap) + if (startidx === -1) { + worker.endTask(); + return; + } + // 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 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.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 < stonesPerBoard - 1; outer++) { + let outernr = this.stones[outer].number; + if (outernr === 0) + continue; + 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 (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; + let solved = this.isSolved(); + if (solved) + g.setColor(0, 1, 0); + else + g.setColor(1, 0, 0); + 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 + stonesize, field.top + stonesize); + g.setFont("Vector", 14).setFontAlign(0, 0).drawString(this.moveCount, field.centerx, field.centery); + if (worker !== null) + worker.endTask(); + if (solved) + setTimeout(() => { + 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 + stonesize, field.top + stonesize); + g.setColor(0, 0, 0); + g.drawRect(field.left, field.top, field.left + stonesize, field.top + stonesize); + let l = field.left + 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++) + for (j = -2; j < 3; j++) { + if (i === 0 && j === 0) + continue; + g.drawString(text, x + i, y + j); + } + 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) { + if (splashMode === "off") + afterSplash(); + else { + 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"); + 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)); + } +} + + +// *** Global flow control + +// Initialize the game with an explicit number of stones per line +function initGame(bpl) { + setStonesPreLine(bpl); + newGame(); +} + +// Start a new game with the same number of stones per line as before +function newGame() { + board = new Board(); + continueGame(); +} + +// Continue the currently running game +function continueGame() { + E.showMenu(); + board.draw(); + dragger.setEnabled(true); +} + +// Show message on game end, allows to restart new game +function gameEnd(moveCount) { + dragger.setEnabled(false); + E.showPrompt("You solved the\n" + stonesPerLine + "x" + stonesPerLine + " puzzle in\n" + moveCount + " move" + (moveCount === 1 ? "" : "s") + ".", { + title: "Puzzle solved", + buttons: { + "Again": newGame, + "Menu": () => showMenu(false), + "Exit": exitGame + } + }).then(v => { + E.showPrompt(); + setTimeout(v, 10); + }); +} + +// 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 = { + "": { + "title": "15 Puzzle" + } + }; + if (withContinue) + mainmenu.Continue = continueGame; + mainmenu["Start 3x3"] = () => initGame(3); + mainmenu["Start 4x4"] = () => initGame(4); + mainmenu["Start 5x5"] = () => initGame(5); + mainmenu.About = () => showAbout(withContinue); + mainmenu.Exit = exitGame; + 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 (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)) : + (e.dy > 0 ? e => board.moveDown(e) : e => board.moveUp(e))); + worker.addTask(e => board.drawResult(e)); +} + +// exit the game, clear screen first to prevent ghost images +function exitGame() { + g.clear(true); + setTimeout(load, 300); +} + + +// *** 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(); + 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 diff --git a/apps/puzzle15/puzzle15.app.png b/apps/puzzle15/puzzle15.app.png new file mode 100644 index 000000000..f95366a46 Binary files /dev/null and b/apps/puzzle15/puzzle15.app.png differ 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 diff --git a/apps/puzzle15/screenshot.png b/apps/puzzle15/screenshot.png new file mode 100644 index 000000000..3a79a3ae6 Binary files /dev/null and b/apps/puzzle15/screenshot.png differ