Puzzle15: First playable version of the 15-puzzle
parent
f1559632ac
commit
f1c923d4b5
|
|
@ -0,0 +1 @@
|
|||
0.01: Initial version, UI mechanics ready, no real game play so far
|
||||
|
|
@ -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!
|
||||
|
|
@ -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="))
|
||||
|
|
@ -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 |
Loading…
Reference in New Issue