// 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(" "));

// *** 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