diff --git a/apps.json b/apps.json index 7aeae8749..4a3d8c7c8 100644 --- a/apps.json +++ b/apps.json @@ -5393,7 +5393,7 @@ { "id": "puzzle15", "name": "15 puzzle", - "version": "0.01", + "version": "0.02", "description": "A 15 puzzle game with drag gesture interface", "readme":"README.md", "icon": "puzzle15.app.png", diff --git a/apps/puzzle15/ChangeLog b/apps/puzzle15/ChangeLog index 2f5d93cad..bc5a4422b 100644 --- a/apps/puzzle15/ChangeLog +++ b/apps/puzzle15/ChangeLog @@ -1 +1,2 @@ 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... diff --git a/apps/puzzle15/puzzle15.app.js b/apps/puzzle15/puzzle15.app.js index 9a5864b9a..801cdf759 100644 --- a/apps/puzzle15/puzzle15.app.js +++ b/apps/puzzle15/puzzle15.app.js @@ -8,14 +8,14 @@ // 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 -const animationSteps = 5; +const animationSteps = 6; // Milliseconds to wait between move animation steps -const animationWaitMillis = 70; - -// Size of the playing field -const buttonsPerLine = 4; +const animationWaitMillis = 30; // *** Global settings derived by device characteristics @@ -25,14 +25,29 @@ 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 -const buttonsize = Math.floor(Math.min(fieldw / (buttonsPerLine + 1), fieldh / buttonsPerLine)) - 2; +var buttonsize; // Actual left start of the playing field (so that it is centered) -const leftstart = (fieldw - ((buttonsPerLine + 1) * buttonsize + 8)) / 2; +var leftstart; // Actual top start of the playing field (so that it is centered) -const topstart = 24 + ((fieldh - (buttonsPerLine * buttonsize + 6)) / 2); +var topstart; + +// Number of buttons on the board (needed at several occasions) +var buttonsPerBoard; + +// 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); +} // *** Low level helper classes @@ -67,7 +82,7 @@ class Fifo { remove() { if (this.first === null) return null; - oldfirst = this.first; + let oldfirst = this.first; this.first = this.first.next; if (this.first === null) this.last = null; @@ -110,6 +125,56 @@ class Worker { } } +// 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. @@ -127,6 +192,11 @@ class Field { this.centerx = (left + buttonsize / 2) + 1; this.centery = (top + buttonsize / 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); + } // 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. @@ -134,6 +204,16 @@ class Field { return new Field(leftstart + (index % buttonsPerLine) * (buttonsize + 2), topstart + (Math.floor(index / buttonsPerLine)) * (buttonsize + 2)); } + // Special field for the result "stone" + static forResult() { + return new Field(leftstart + (buttonsPerLine * (buttonsize + 2)), + topstart + ((buttonsPerLine - 1) * (buttonsize + 2))); + } + // Special field for the menu + static forMenu() { + return new Field(leftstart + (buttonsPerLine * (buttonsize + 2)), + topstart); + } } // Representation of a moveable stone of the game. @@ -217,11 +297,11 @@ class Mover extends Clearer { animateStep(step, worker) { this.clearArea(); this.stone.draw(this.stepField(step)); - if (step < this.steps) // still steps left: Issue next 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 + else // all steps done: Inform the worker worker.endTask(); } // Start the animation, this method is called by the worker @@ -232,22 +312,54 @@ class Mover extends Clearer { // Representation of the playing field // Knows to draw the field and to move a stone into a gap -// TODO: More game mechanics (shuffling, solving,...) +// TODO: More game mechanics (solving,...) class Board { // Generates the actual playing field with all fields and buttons constructor() { this.fields = []; - this.buttons = []; - for (i = 0; i < (buttonsPerLine * buttonsPerLine); i++) { + this.resultField = Field.forResult(); + this.menuField = Field.forMenu(); + for (i = 0; i < buttonsPerBoard; i++) this.fields[i] = Field.forIndex(i); - this.buttons[i] = new Stone((i + 1) % (buttonsPerLine * buttonsPerLine),i); + this.setShuffled(); + } + // Set the board into the "solved" position + setSolved() { + this.buttons = []; + for (i = 0; i < buttonsPerBoard; i++) + this.buttons[i] = new Stone((i + 1) % buttonsPerBoard, i); + this.moveCount = 0; + } + setShuffled() { + let nrs = []; + for (i = 0; i < buttonsPerBoard; i++) + nrs[i] = i; + this.buttons = []; + let count = buttonsPerBoard; + for (i = 0; i < buttonsPerBoard; i++) { + let curridx = Math.floor(Math.random() * count); + let currnr = nrs[curridx]; + this.buttons[i] = new Stone(currnr, (currnr + (buttonsPerBoard - 1)) % buttonsPerBoard); + for (j = curridx + 1; j < count; j++) + nrs[j - 1] = nrs[j]; + count -= 1; } + 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; + } + 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.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) @@ -270,36 +382,44 @@ class Board { 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); + } // Moves the stone at the field with the index found by the startfunc operation // into the gap field. - moveTo0(startfunc, animator) { + moveTo0(startfunc, worker) { let endidx = this.indexOf0(); // Target field (the gap) if (endidx === -1) { - animator.endTask(); + worker.endTask(); return; } - let startidx = startfunc(endidx); // Start field (relative to the gap) + let startidx = startfunc(endidx); // Start field (relative to the gap) if (startidx === -1) { - animator.endTask(); + worker.endTask(); return; } let moved = this.buttons[startidx]; this.buttons[startidx] = this.buttons[endidx]; this.buttons[endidx] = moved; - new Mover(moved, this.fields[startidx], this.fields[endidx], animationSteps).animate(animator); + this.moveCount += 1; + new Mover(moved, this.fields[startidx], this.fields[endidx], animationSteps).animate(worker); } // Move the stone right fro the gap into the gap - moveRight(animator) { - this.moveTo0(this.leftOf, animator); + moveRight(worker) { + this.moveTo0(this.leftOf, worker); } - moveLeft(animator) { - this.moveTo0(this.rightOf, animator); + moveLeft(worker) { + this.moveTo0(this.rightOf, worker); } - moveUp(animator) { - this.moveTo0(this.bottomOf, animator); + moveUp(worker) { + this.moveTo0(this.bottomOf, worker); } - moveDown(animator) { - this.moveTo0(this.topOf, animator); + moveDown(worker) { + this.moveTo0(this.topOf, worker); } // Check if the board is solved (all stones at the right position) isSolved() { @@ -308,54 +428,152 @@ class Board { 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; + if (outernr === 0) + continue; + for (inner = outer + 1; inner < buttonsPerBoard; inner++) { + let innernr = this.buttons[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 + 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; + if (this.isSolved()) + g.setColor(0, 1, 0); + else + g.setColor(1, 0, 0); + g.fillRect(field.left, field.top, field.left + buttonsize, field.top + buttonsize); + g.setColor(0, 0, 0); + g.drawRect(field.left, field.top, field.left + buttonsize, field.top + buttonsize); + g.setFont("Vector", 14).setFontAlign(0, 0).drawString(this.moveCount, field.centerx, field.centery); + if (worker !== null) + worker.endTask(); + } + // 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.setColor(0, 0, 0); + g.drawRect(field.left, field.top, field.left + buttonsize, field.top + buttonsize); + let l = field.left + 8; + let r = field.left + buttonsize - 8; + let t = field.top + 5; + for (i = 0; i < 3; i++) + g.fillRect(l, t + (i * 7), r, t + (i * 7) + 3); + } } +/* +// Main class, containing the complete game logic +class Puzzle15 { + constructor() { + this.worker=new Worker(); + this.board=new Board(); + } +} +*/ + // *** Main program // We need a worker... var worker = new Worker(); + +setButtonsPerLine(3); // ...and the board var board = new Board(); -// UI: Accumulation of current drag operation -var currentdrag = { - x: 0, - y: 0 -}; +var dragger; + +function initGame(bpl) { + setButtonsPerLine(bpl); + board = new Board(); + board.draw(); + dragger.setEnabled(true); +} + +function showMenu() { + var mainmenu = { + "": { + "title": "15 Puzzle" + }, + "< Back": () => { + E.showMenu(); + dragger.setEnabled(true); + board.draw(); + }, // remove the menu + "Start 3x3": function() { + E.showMenu(); + initGame(3); + }, + "Start 4x4": function() { + E.showMenu(); + initGame(4); + }, + "Start 5x5": function() { + E.showMenu(); + initGame(5); + } + }; + dragger.setEnabled(false); + + E.showMenu(mainmenu); +} + +function handleclick(e) { + if (board.menuField.contains(e.x, e.y)) { + console.log("GGG - handleclick, dragger: " + dragger); + g.reset(); + showMenu(); + console.log("showing menu ended"); + } +} // Handle a drag event function handledrag(e) { - if (e.b === 0) { // Drag event ended: Evaluate drag and start move operation - if (Math.abs(currentdrag.x) > Math.abs(currentdrag.y)) { // Horizontal drag - if (currentdrag.x > dragThreshold) - worker.addTask(e => board.moveRight(e)); - else if (currentdrag.x < -dragThreshold) - worker.addTask(e => board.moveLeft(e)); - } else { // Vertical drag - if (currentdrag.y > dragThreshold) - worker.addTask(e => board.moveDown(e)); - else if (currentdrag.y < -dragThreshold) - worker.addTask(e => board.moveUp(e)); - } - currentdrag.x = 0; // Clear the drag accumulator - currentdrag.y = 0; - } else { // Drag still running: Accumulate drag shifts - currentdrag.x += e.dx; - currentdrag.y += e.dy; - } + 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)); } // Clear the screen once, at startup g.clear(); -// Drop mode as this is a game -Bangle.setUI(undefined); + +// Clock mode allows short-press on button to exit +Bangle.setUI("clock"); // Load widgets Bangle.loadWidgets(); Bangle.drawWidgets(); // Draw the board initially board.draw(); + +dragger = new Dragger(handleclick, handledrag, clickThreshold, dragThreshold); + +showMenu(); // Start the interaction -Bangle.on("drag", handledrag); +dragger.attach(); + +console.log("GGG - main program, dragger: " + dragger); // end of file \ No newline at end of file