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