Puzzle15: First playable version of the 15-puzzle

master
Dirk Hillbrecht (home) 2021-12-29 07:30:47 +01:00
parent f1559632ac
commit f1c923d4b5
6 changed files with 381 additions and 0 deletions

1
apps/puzzle15/ChangeLog Normal file
View File

@ -0,0 +1 @@
0.01: Initial version, UI mechanics ready, no real game play so far

18
apps/puzzle15/README.md Normal file
View File

@ -0,0 +1,18 @@
# Puzzle15 - A 15-puzzle for the Bangle.js 2
Puzzle15 implements a 15-puzzle on the screen of the Bangle.js 2 smart watch.
A "15-puzzle" is a single-player game. 15 "stones" are numbered from 1 to 15 and placed randomly on a 4 by 4 playing field.
So, one place on the field is free.
The target of the game is to move the stones around (using the "gap" on the playing field) to bring them in natural order.
_This is work in progress!_
## How to play
After starting the game, the playing field is shown with the stones in the corrct order.
Push the stones around with drag gestures (left to right, right to left, top to bottom, bottom to top) to shuffle them.
Then, use the same gestures to restore the order.
So far, there are no other capabililties or options.
Have fun!

View File

@ -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="))

View File

@ -0,0 +1,361 @@
// A 15-puzzle game for the Bangle.js 2 clock
// (C) Dirk Hillbrecht 2022
// Released unter the terms of the MIT license
// *** Global settings
// Note: These could be changed by settings later...
// Minimum number of pixels to interpret it as drag gesture
const dragThreshold = 10;
// Number of steps in button move animation
const animationSteps = 5;
// Milliseconds to wait between move animation steps
const animationWaitMillis = 70;
// Size of the playing field
const buttonsPerLine = 4;
// *** 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 one button
const buttonsize = Math.floor(Math.min(fieldw / (buttonsPerLine + 1), fieldh / buttonsPerLine)) - 2;
// Actual left start of the playing field (so that it is centered)
const leftstart = (fieldw - ((buttonsPerLine + 1) * buttonsize + 8)) / 2;
// Actual top start of the playing field (so that it is centered)
const topstart = 24 + ((fieldh - (buttonsPerLine * buttonsize + 6)) / 2);
// *** 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;
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);
}
}
// *** 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 + buttonsize / 2) + 1;
this.centery = (top + buttonsize / 2) + 2;
}
// 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));
}
}
// 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 + (buttonsPerLine % 2 == 0 ? (Math.floor((number - 1) / buttonsPerLine)) : 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.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.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 + buttonsize, this.maxtop + buttonsize);
}
}
// 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
// TODO: More game mechanics (shuffling, 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.fields[i] = Field.forIndex(i);
this.buttons[i] = new Stone((i + 1) % (buttonsPerLine * buttonsPerLine),i);
}
}
// 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]);
}
// 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);
}
rightOf(index) {
return (index % buttonsPerLine === (buttonsPerLine - 1) ? -1 : index + 1);
}
topOf(index) {
return (index >= buttonsPerLine ? index - buttonsPerLine : -1);
}
bottomOf(index) {
return (index < (buttonsPerLine - 1) * buttonsPerLine ? index + buttonsPerLine : -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)
return i;
return -1;
}
// Moves the stone at the field with the index found by the startfunc operation
// into the gap field.
moveTo0(startfunc, animator) {
let endidx = this.indexOf0(); // Target field (the gap)
if (endidx === -1) {
animator.endTask();
return;
}
let startidx = startfunc(endidx); // Start field (relative to the gap)
if (startidx === -1) {
animator.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);
}
// Move the stone right fro the gap into the gap
moveRight(animator) {
this.moveTo0(this.leftOf, animator);
}
moveLeft(animator) {
this.moveTo0(this.rightOf, animator);
}
moveUp(animator) {
this.moveTo0(this.bottomOf, animator);
}
moveDown(animator) {
this.moveTo0(this.topOf, animator);
}
// 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))
return false;
return true;
}
}
// *** Main program
// We need a worker...
var worker = new Worker();
// ...and the board
var board = new Board();
// UI: Accumulation of current drag operation
var currentdrag = {
x: 0,
y: 0
};
// 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;
}
}
// Clear the screen once, at startup
g.clear();
// Drop mode as this is a game
Bangle.setUI(undefined);
// Load widgets
Bangle.loadWidgets();
Bangle.drawWidgets();
// Draw the board initially
board.draw();
// Start the interaction
Bangle.on("drag", handledrag);
// end of file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB