Puzzle15: v0.02 with shuffled board init, menu-based control flow etc.

Now it actually starts making sense…
master
Dirk Hillbrecht (home) 2022-01-01 19:03:35 +01:00
parent d5a4e7b93e
commit 43e87247d7
3 changed files with 274 additions and 55 deletions

View File

@ -5393,7 +5393,7 @@
{ {
"id": "puzzle15", "id": "puzzle15",
"name": "15 puzzle", "name": "15 puzzle",
"version": "0.01", "version": "0.02",
"description": "A 15 puzzle game with drag gesture interface", "description": "A 15 puzzle game with drag gesture interface",
"readme":"README.md", "readme":"README.md",
"icon": "puzzle15.app.png", "icon": "puzzle15.app.png",

View File

@ -1 +1,2 @@
0.01: Initial version, UI mechanics ready, no real game play so far 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...

View File

@ -8,14 +8,14 @@
// Minimum number of pixels to interpret it as drag gesture // Minimum number of pixels to interpret it as drag gesture
const dragThreshold = 10; 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 button move animation
const animationSteps = 5; const animationSteps = 6;
// Milliseconds to wait between move animation steps // Milliseconds to wait between move animation steps
const animationWaitMillis = 70; const animationWaitMillis = 30;
// Size of the playing field
const buttonsPerLine = 4;
// *** Global settings derived by device characteristics // *** 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) // Total height of the playing field (screen height minus widget zones)
const fieldh = g.getHeight() - 48; const fieldh = g.getHeight() - 48;
// Size of the playing field
var buttonsPerLine;
// Size of one button // 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) // 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) // 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 // *** Low level helper classes
@ -67,7 +82,7 @@ class Fifo {
remove() { remove() {
if (this.first === null) if (this.first === null)
return null; return null;
oldfirst = this.first; let oldfirst = this.first;
this.first = this.first.next; this.first = this.first.next;
if (this.first === null) if (this.first === null)
this.last = 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 // *** Mid-level game mechanics
// Representation of a position where a stone is set. // Representation of a position where a stone is set.
@ -127,6 +192,11 @@ class Field {
this.centerx = (left + buttonsize / 2) + 1; this.centerx = (left + buttonsize / 2) + 1;
this.centery = (top + buttonsize / 2) + 2; 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. // Generate a field for the given playing field index.
// Playing field indexes start at top left with "0" // Playing field indexes start at top left with "0"
// and go from left to right line by line from top to bottom. // 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), return new Field(leftstart + (index % buttonsPerLine) * (buttonsize + 2),
topstart + (Math.floor(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. // Representation of a moveable stone of the game.
@ -217,11 +297,11 @@ class Mover extends Clearer {
animateStep(step, worker) { animateStep(step, worker) {
this.clearArea(); this.clearArea();
this.stone.draw(this.stepField(step)); 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) { setTimeout(function(t) {
t.animateStep(step + 1, worker); t.animateStep(step + 1, worker);
}, animationWaitMillis, this); }, animationWaitMillis, this);
else // all steps done: Inform the worker else // all steps done: Inform the worker
worker.endTask(); worker.endTask();
} }
// Start the animation, this method is called by the worker // Start the animation, this method is called by the worker
@ -232,22 +312,54 @@ class Mover extends Clearer {
// Representation of the playing field // Representation of the playing field
// Knows to draw the field and to move a stone into a gap // 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 { class Board {
// Generates the actual playing field with all fields and buttons // Generates the actual playing field with all fields and buttons
constructor() { constructor() {
this.fields = []; this.fields = [];
this.buttons = []; this.resultField = Field.forResult();
for (i = 0; i < (buttonsPerLine * buttonsPerLine); i++) { this.menuField = Field.forMenu();
for (i = 0; i < buttonsPerBoard; i++)
this.fields[i] = Field.forIndex(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 // Draws the complete playing field
draw() { draw() {
new Clearer(this.fields[0], this.fields[this.fields.length - 1]).clearArea(); new Clearer(this.fields[0], this.fields[this.fields.length - 1]).clearArea();
for (i = 0; i < this.fields.length; i++) for (i = 0; i < this.fields.length; i++)
this.buttons[i].draw(this.fields[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, // 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) // -1 if there is none (index indicates already a leftmost field on the board)
@ -270,36 +382,44 @@ class Board {
return i; return i;
return -1; 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 // Moves the stone at the field with the index found by the startfunc operation
// into the gap field. // into the gap field.
moveTo0(startfunc, animator) { moveTo0(startfunc, worker) {
let endidx = this.indexOf0(); // Target field (the gap) let endidx = this.indexOf0(); // Target field (the gap)
if (endidx === -1) { if (endidx === -1) {
animator.endTask(); worker.endTask();
return; return;
} }
let startidx = startfunc(endidx); // Start field (relative to the gap) let startidx = startfunc(endidx); // Start field (relative to the gap)
if (startidx === -1) { if (startidx === -1) {
animator.endTask(); worker.endTask();
return; return;
} }
let moved = this.buttons[startidx]; let moved = this.buttons[startidx];
this.buttons[startidx] = this.buttons[endidx]; this.buttons[startidx] = this.buttons[endidx];
this.buttons[endidx] = moved; 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 // Move the stone right fro the gap into the gap
moveRight(animator) { moveRight(worker) {
this.moveTo0(this.leftOf, animator); this.moveTo0(this.leftOf, worker);
} }
moveLeft(animator) { moveLeft(worker) {
this.moveTo0(this.rightOf, animator); this.moveTo0(this.rightOf, worker);
} }
moveUp(animator) { moveUp(worker) {
this.moveTo0(this.bottomOf, animator); this.moveTo0(this.bottomOf, worker);
} }
moveDown(animator) { moveDown(worker) {
this.moveTo0(this.topOf, animator); this.moveTo0(this.topOf, worker);
} }
// Check if the board is solved (all stones at the right position) // Check if the board is solved (all stones at the right position)
isSolved() { isSolved() {
@ -308,54 +428,152 @@ class Board {
return false; return false;
return true; 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 // *** Main program
// We need a worker... // We need a worker...
var worker = new Worker(); var worker = new Worker();
setButtonsPerLine(3);
// ...and the board // ...and the board
var board = new Board(); var board = new Board();
// UI: Accumulation of current drag operation var dragger;
var currentdrag = {
x: 0, function initGame(bpl) {
y: 0 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 // Handle a drag event
function handledrag(e) { function handledrag(e) {
if (e.b === 0) { // Drag event ended: Evaluate drag and start move operation worker.addTask(Math.abs(e.dx) > Math.abs(e.dy) ?
if (Math.abs(currentdrag.x) > Math.abs(currentdrag.y)) { // Horizontal drag (e.dx > 0 ? e => board.moveRight(e) : e => board.moveLeft(e)) :
if (currentdrag.x > dragThreshold) (e.dy > 0 ? e => board.moveDown(e) : e => board.moveUp(e)));
worker.addTask(e => board.moveRight(e)); worker.addTask(e => board.drawResult(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;
}
} }
// Clear the screen once, at startup // Clear the screen once, at startup
g.clear(); 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 // Load widgets
Bangle.loadWidgets(); Bangle.loadWidgets();
Bangle.drawWidgets(); Bangle.drawWidgets();
// Draw the board initially // Draw the board initially
board.draw(); board.draw();
dragger = new Dragger(handleclick, handledrag, clickThreshold, dragThreshold);
showMenu();
// Start the interaction // Start the interaction
Bangle.on("drag", handledrag); dragger.attach();
console.log("GGG - main program, dragger: " + dragger);
// end of file // end of file