legacy/outils/sokoban/Sokoban.js

import { j3pAddElt, j3pAjouteBouton, j3pElement, j3pEmpty, j3pShowError } from 'src/legacy/core/functions'
import { j3pBaseUrl } from 'src/lib/core/constantes'
import { hasProp } from 'src/lib/utils/object'

import Mobile from './Mobile'

const soundOn = 'Remettre le son'
const soundOff = 'Couper le son'

/**
 * Classe Sokoban pour gérer le jeu
 * @todo ajouter une consigne (utiliser les flèches, pousser les chats sur leur couffin)
 * @todo virer les sons, ou les changer si certains tiennent à les conserver
 * @todo ajouter des boutons de déplacement (tablettes)
 * @todo ajouter un historique pour pouvoir annuler
 * FIXME virer le setInterval et ne redessiner que lors d’un déplacement
 * @param {HTMLElement|string} container
 * @param options
 * @constructor
 */
function Sokoban (container, options) {
  if (typeof this !== 'object') throw Error('Constructeur qui doit être appelé avec new')
  /**
   * Le conteneur du plateau
   * @type {HTMLElement}
   */
  this.container = (typeof container === 'string') ? j3pElement(container) : container
  this.nom = 'j3p.sokoban'
  /**
   * Index du tableau courant dans tableaux
   * @type {number}
   */
  this.tableauCourant = options.tableauCourant || 0
  /**
   * Index du tableau débloqué le plus élevé dans la série
   * @type {number}
   */
  this.tableauMax = options.tableauMax || this.tableauCourant
  /**
   * Fichier js à charger (il doit être dans sectionsAnnexes/sokoban/)
   * @type {string}
   */
  this.fichier = options.fichier
  /** @type {Mobile[]} */
  this.chatsMobiles = []
  /** @type {Mobile} */
  this.persoMobile = null
  /** @type {CoordsList} */
  this.solCoords = []
  /** @type {CoordsList} */
  this.mursCoords = []
  /** @type {CoordLignCol} */
  this.avatarCoord = [0, 0] // les coordonnées de l’avatar
  /** @type {CoordsList} */
  this.barilsCoords = [] // liste
  /** @type {CoordsList} */
  this.mobilesCoords = [] // coordonnées des mobilesCoords
  /** @type {TableauSokoban[]} */
  this.tableaux = [] // contient tous les tableaux du fichier chargé
  /** @type {AnimationOptions} */
  this.animation = {
    dureeAnimation: options.dureeAnimation, // 6
    dureeDeplacement: options.dureeDeplacement, // 24
    delai: options.delai// 10
  }
  this.constantes = {
    bas: 0,
    gauche: 1,
    droite: 2,
    haut: 3,
    keys: {
      ArrowLeft: 1,
      ArrowUp: 3,
      ArrowRight: 2,
      ArrowDown: 0
    }
  }
  /** @type {SceneSokoban} */
  this.scene = []
  this.timerId = null
  /**
   * Nb de chats sur leur couffin
   * @type {number}
   */
  this.bienPlace = 0
  /** @type {number} */
  this.ancienBienPlace = 0
  /**
   * Url absolue du dossier assets (avec slash de fin)
   * @type {string}
   */
  this.baseAssetsUrl = j3pBaseUrl + 'static/sokoban/'
  this.hasSound = options.son
  // on ajoute le bouton pour couper le son s’il est autorisé,
  // sinon on ne le met pas et il n’y aura pas de son (pour éviter la cacophonie dans des salles infos)
  let decal = 100
  if (this.hasSound) {
    /**
     * Le son pour changer de tableau
     * @type {HTMLAudioElement}
     */
    this.sonPorte = j3pAddElt(this.container, 'audio')
    j3pAddElt(this.sonPorte, 'source', '', { src: this.baseAssetsUrl + 'porte.wav', type: 'audio/wav' })
    /**
     * Le son de réussite
     * @type {HTMLAudioElement}
     */
    this.sonOk = j3pAddElt(this.container, 'audio')
    j3pAddElt(this.sonOk, 'source', '', { src: this.baseAssetsUrl + 'miaou.wav', type: 'audio/wav' })
    // bouton son on/off
    this.toggleSoundBtn = j3pAjouteBouton(this.container, this.toggleSound.bind(this), { value: soundOff, className: 'MepBoutonsDG', style: { position: 'absolute', top: decal + 'px', left: '770px', width: '120px' } })
    decal += 50
  }
  this.canvas = j3pAddElt(this.container, 'canvas')
  this.canvas.width = 16 * 48
  this.canvas.height = 16 * 48

  // ex #idperso
  this.persoElt = j3pAddElt(this.canvas, 'img', '', { src: this.baseAssetsUrl + 'perso.png', style: { display: 'none' } })
  // ex #idchat1
  this.chatElt = j3pAddElt(this.canvas, 'img', '', { src: this.baseAssetsUrl + 'chat.png', style: { display: 'none' } })
  // bouton reset
  j3pAjouteBouton(this.container, this.reset.bind(this), { value: 'Recommencer', className: 'MepBoutonsDG', style: { position: 'absolute', top: decal + 'px', left: '770px', width: '120px' } })
  decal += 50
  // pour afficher l’état d’avancement dans la série
  const info1Debut = j3pAddElt(this.container, 'div', 'Tableau', { style: { color: '#996666', position: 'absolute', top: '20px', left: '790px', fontSize: '24px', textAlign: 'center' } })
  j3pAddElt(info1Debut, 'br')
  this.levelElt = j3pAddElt(info1Debut, 'span')
  // pour le feedback (bravo ou rien)
  this.feedbackElt = j3pAddElt(this.container, 'p', '', { style: { color: '#339966', position: 'absolute', top: '250px', left: '790px', fontSize: '30px' } })

  if (options.navigation) {
    const nextListener = this.change.bind(this, true)
    this.nextBtn = j3pAjouteBouton(this.container, nextListener, {
      value: 'Suivant',
      className: 'MepBoutonsDG',
      style: {
        display: 'none',
        position: 'absolute',
        top: decal + 'px',
        left: '770px',
        width: '120px'
      }
    })
    decal += 50

    const prevListener = this.change.bind(this, false)
    this.prevBtn = j3pAjouteBouton(this.container, prevListener, {
      value: 'Précédent',
      className: 'MepBoutonsDG',
      style: {
        display: 'none',
        position: 'absolute',
        top: decal + 'px',
        left: '770px',
        width: '120px'
      }
    })
  }

  window.addEventListener('keydown', (event) => {
    // on fait rien si c’est en cours de traitement ou pas encore init
    if (event.defaultPrevented || !this.persoMobile) return
    if (hasProp(this.constantes.keys, event.key)) {
      // on traite cet event, et on le marque au cas où on serait rappelé avec le même
      event.preventDefault()
      this.persoMobile.deplacer(this.constantes.keys[event.key], this)
    }
  }, true)

  this.chargeTableaux(options.fichier)
  // pour du debug on peut décommenter ça
  window.sokoban = this
}

Sokoban.prototype.reset = function () {
  this.solCoords = []
  this.mursCoords = []
  this.barilsCoords = []
  this.mobilesCoords = []// coordonnées des mobilesCoords
  this.scene = []
  this.feedbackElt.innerHTML = ''
  clearInterval(this.timerId)
  this.initBan()
  this.bienPlace = 0
  this.ancienBienPlace = 0
  this.resetNav()
}

/**
 * Change le plateau pour aller au suivant / précédent
 * @param {boolean} isAsc true pour passer au suivant, false pour le précédent
 */
Sokoban.prototype.change = function change (isAsc) {
  if (!isAsc && this.tableauCourant === 0) return
  if (isAsc && this.tableauCourant === this.tableaux.length - 1) return
  let delai = 10
  if (this.hasSound) {
    this.sonPorte.play()
    delai = 3000
  }
  setTimeout(() => {
    if (isAsc) this.tableauCourant++
    else this.tableauCourant--
    this.levelElt.innerText = (this.tableauCourant + 1) + ' / ' + this.tableaux.length
    j3pEmpty(this.feedbackElt)
    this.reset()
  }
  , delai)
}

/**
 * Affiche ou masque les boutons de navigation suivant le contexte
 */
Sokoban.prototype.resetNav = function resetNav () {
  if (this.nextBtn) {
    this.prevBtn.style.display = (this.tableauCourant > 0) ? 'block' : 'none'
    this.nextBtn.style.display = (this.tableauCourant < this.tableauMax) ? 'block' : 'none'
  }
}

Sokoban.prototype.chargeTableaux = function chargeTableaux (nomfichier) {
  function calculDecalages (tab) {
    const max = Math.max(...tab.map(elt => elt.length))
    return [Math.floor((16 - max) / 2), Math.floor((16 - tab.length) / 2)]
  }

  function cleanTableau (tab) {
    return tab
      // on vire ce qui suit // et tout ce qui n’est pas un caractère connu
      .map(function (line) {
        return line.replace(/\/\/.*/, '').replace(/[^#.@?& -]/, '')
      })
      // puis les lignes vides
      .filter(function (line) {
        return !(/^ *$/.test(line))
      })
  }

  function insereDecalages (tab, decal) {
    function insereespacesdevant (nb, ch) {
      return ' '.repeat(nb) + ch
    }

    tab = tab.map(elt => insereespacesdevant(decal[0], elt))
    const tab2 = []
    for (let k = 0; k < decal[1]; k++) {
      tab2.push('')
    }
    for (const elt of tab) {
      tab2.push(elt)
    }
    return tab2
  }

  let loadingPromise
  switch (nomfichier) {
    case 'serie0': loadingPromise = import('./series/serie0'); break
    case 'serie1': loadingPromise = import('./series/serie1'); break
    case 'serie2': loadingPromise = import('./series/serie2'); break
    default:
      throw Error(`${nomfichier} ne correspond à aucune série connue`)
  }
  loadingPromise.then(({ txt }) => {
    if (!txt) return j3pShowError(Error('Paramètres invalides'))
    this.tableaux = txt.split('===').map(function (rawTab) {
      const lignes = cleanTableau(rawTab.split('%'))
      const decalages = calculDecalages(lignes)
      return insereDecalages(lignes, decalages)
    })
    this.initBan()
    this.levelElt.innerText = (this.tableauCourant + 1) + ' / ' + this.tableaux.length
  })
}

Sokoban.prototype.initBan = function () {
  if (!this.tableaux.length) {
    console.error(Error('tableaux non chargés'))
    this.chargeTableaux('serie0') // ça rappellera init
    return
  }
  if (this.tableauCourant > this.tableaux.length - 1) {
    console.error(Error(`tableauCourant invalide ${this.tableauCourant}`))
    this.tableauCourant = this.tableaux.length - 1
  }
  if (!Number.isInteger(this.tableauCourant) || this.tableauCourant < 0) {
    console.error(Error(`tableauCourant invalide ${this.tableauCourant}`))
    this.tableauCourant = 0
  }
  const tableau = this.tableaux[this.tableauCourant]
  // décommenter pour afficher en console le tableau récupéré sous sa forme txt
  // console.log('Init tableau\n', tableau.join('\n'))
  tableau.forEach((line, l) => {
    this.scene[l] = []
    line.split('').forEach((char, c) => {
      /** @type CoordLignCol */
      const coord = [l, c]
      switch (char) {
        case '#': // un mur
          this.mursCoords.push(coord)
          break
        case '.': // un baril
          this.barilsCoords.push(coord)
          break
        case '-': // du solCoords sans rien dessus
          this.solCoords.push(coord)
          break
        case '@': // le perso
          this.solCoords.push(coord)
          this.avatarCoord = coord
          break
        case '&': // un mobile
          this.solCoords.push(coord)
          this.mobilesCoords.push(coord)
          break
        case '?': // un mobile à destination
          this.solCoords.push(coord)
          this.mobilesCoords.push(coord)
          this.barilsCoords.push(coord)
          break
        case ' ':
          break
        default:
          console.error(Error('Charactère "' + char + '" inconnu'))
      }
      this.scene[l][c] = char
    })
  })

  // dureeDeplacement * delai : duree en ms pour passer d’une case à une autre
  // dans l’ideal dureeAnimation = dureeDeplacement/8 ou /4 si delai<=10
  this.persoMobile = new Mobile(
    this,
    {
      x: this.avatarCoord[1],
      y: this.avatarCoord[0],
      direction: this.constantes.droite,
      decalageY: 0
    },
    this.animation,
    true
  )

  this.chatsMobiles = []
  for (const [y, x] of this.mobilesCoords) {
    this.chatsMobiles.push(new Mobile(this, { x, y, direction: 2, decalageY: -5 }, this.animation))
  }

  const ctx = this.canvas.getContext('2d')
  this.canvas.width = 16 * 48
  this.canvas.height = 16 * 48
  this.dessineCadre(ctx)
  this.dessineMurs(ctx)
  this.dessineSol(ctx)
  this.dessineBarils(ctx)

  this.timerId = setInterval(() => {
    ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
    this.dessineCadre(ctx)
    this.dessineMurs(ctx)
    this.dessineSol(ctx)
    this.dessineBarils(ctx)

    this.persoMobile.dessine(ctx)
    for (const chatMobile of this.chatsMobiles) {
      chatMobile.dessine(ctx)
    }
  }, this.animation.delai)
} // Sokoban

/**
 * Dessine le cadre
 * @param {CanvasRenderingContext2D} ctx
 */
Sokoban.prototype.dessineCadre = function (ctx) {
  ctx.beginPath()
  ctx.strokeStyle = 'black'
  ctx.lineWidth = '2'
  ctx.rect(0, 0, 16 * 48, 16 * 48)
  ctx.fillStyle = '#444444'
  ctx.fill()
  ctx.stroke()
}
/**
 * Ajoute les murs
 * @param {CanvasRenderingContext2D} ctx
 */
Sokoban.prototype.dessineMurs = function (ctx) {
  // FIXME comprendre pourquoi si on ajoute cet élément img dans le constructeur avec le perso et le chat => ça déconne (perso et chat plus affichés)
  if (!this.murImg) this.murImg = j3pAddElt(this.canvas, 'img', '', { src: this.baseAssetsUrl + 'mur.png', style: { display: 'none' } })
  for (const [x, y] of this.mursCoords) {
    ctx.drawImage(
      this.murImg,
      0, 0,
      48, 48,
      y * 48, x * 48,
      48, 48
    )
  }
}
/**
 * Ajoute les barilsCoords
 * @param {CanvasRenderingContext2D} ctx
 */
Sokoban.prototype.dessineBarils = function (ctx) {
  if (!this.solBarilImg) this.solBarilImg = j3pAddElt(this.canvas, 'img', '', { src: this.baseAssetsUrl + 'solBaril.png', style: { display: 'none' } })
  for (const [x, y] of this.barilsCoords) {
    // https://developer.mozilla.org/fr/docs/Web/API/CanvasRenderingContext2D/drawImage
    ctx.drawImage(
      this.solBarilImg,
      0, 16,
      48, 48,
      // pourquoi y en 0 et x en 1 ?
      y * 48, x * 48,
      48, 48
    )
  }
}
/**
 * Dessine le sol
 * @param {CanvasRenderingContext2D} ctx
 */
Sokoban.prototype.dessineSol = function (ctx) {
  if (!this.solImg) this.solImg = j3pAddElt(this.canvas, 'img', '', { src: this.baseAssetsUrl + 'sol4.png', style: { display: 'none' } })
  for (const [y, x] of this.solCoords) {
    ctx.drawImage(
      this.solImg,
      0, 16,
      48, 48,
      x * 48, y * 48,
      48, 48
    )
  }
}

Sokoban.prototype.libre = function (i, j, direction) {
  const trouvernumeromobiles = (i, j) => {
    return this.mobilesCoords.findIndex(([y, x]) => x === i && y === j)
  }

  const getCoordonneesAdjacentes = (x, y, direction) => {
    switch (direction) {
      case this.constantes.bas :
        y++
        break
      case this.constantes.gauche:
        x--
        break
      case this.constantes.droite :
        x++
        break
      case this.constantes.haut :
        y--
        break
    }
    return [x, y]
  }

  if (i < 0 || j < 0 || i >= 16 || j >= 16) {
    return false
  }
  if (this.scene[j][i] === '#') return false
  if ((this.scene[j][i] === '&') || (this.scene[j][i] === '?')) { // un mobile
    const prochaineCase = getCoordonneesAdjacentes(i, j, direction)
    // trouver le numéro du mobile devant
    // changer ses coordonnées en conséquence
    // normalement c’est tout
    if ((this.scene[prochaineCase[1]][prochaineCase[0]] === '#') || (this.scene[prochaineCase[1]][prochaineCase[0]] === '&') || (this.scene[prochaineCase[1]][prochaineCase[0]] === '?')) return false// un mur devant le mobile
    const num = trouvernumeromobiles(i, j)

    this.chatsMobiles[num].x = prochaineCase[0]
    this.chatsMobiles[num].y = prochaineCase[1]
    this.chatsMobiles[num].etat = 1
    this.chatsMobiles[num].direction = direction
    if (this.scene[prochaineCase[1]][prochaineCase[0]] === '.') this.scene[prochaineCase[1]][prochaineCase[0]] = '?'
    else this.scene[prochaineCase[1]][prochaineCase[0]] = '&'
    this.scene[j][i] = '@'
    this.mobilesCoords[num] = [prochaineCase[1], prochaineCase[0]]
  }
  this.scene[j][i] = '@'

  return true
}

Sokoban.prototype.correction = function () {
  this.bienPlace = 0

  for (const [by, bx] of this.barilsCoords) {
    for (const [my, mx] of this.mobilesCoords) {
      if (my === by && mx === bx) {
        this.bienPlace++
      }
    }
  }

  if (this.bienPlace > this.ancienBienPlace) {
    if (this.hasSound) this.sonOk.play()
    this.ancienBienPlace = this.bienPlace
  }
  if (this.bienPlace < this.ancienBienPlace) {
    this.ancienBienPlace--
  }
  return this.bienPlace === this.barilsCoords.length
}

Sokoban.prototype.toggleSound = function () {
  if (this.hasSound) {
    this.toggleSoundBtn.value = soundOn
    this.hasSound = false
  } else {
    this.toggleSoundBtn.value = soundOff
    this.hasSound = true
  }
}

export default Sokoban

/**
 * @typedef CoordLignCol
 * @type {Array}
 * @property {number} 0 index de la ligne
 * @property {number} 1 index de la colonne
 */
/**
 * @typedef CoordsList
 * @type {CoordLignCol[]}
 */
/**
 * @typedef TableauSokoban
 * @type {LigneTableauSokoban[]}
 */
/**
 * Une ligne de tableau, composée de caractères .#@? -
 * @typedef LigneTableauSokoban
 * @type {string}
 */
/**
 * La scène sokoban (tableau de tableaux de caractères)
 * @typedef SceneSokoban
 * @type {LigneSceneSokoban[]}
 */
/**
 * Une ligne de tableau, composée de caractères .#@? -, sous la forme Array
 * @typedef LigneSceneSokoban
 * @type {CharSokoban[]}
 */
/**
 * Un caractère parmi les 6 connus .#@? -
 * @typedef CharSokoban
 * @type {string}
 */