Készítsünk "Térképes tilitoli" játékot

A feladat

Készítsünk egy olyan játékot, amelyben egy m*n darabra vágott térkép darabjait lehet tologatni oly módon, hogy egy darab hiányzik, és az üres helyre lehet valamelyik szomszédos elemet odatolni.

Mit is kell tudni a játéknak?

Megoldás lépései

0. Létrehozni a mozaikdarabokat

Valamilyen grafikai program segítségével a kiindulási térképet egyforma méretű téglalapokra kell vágni, és a darabokat úgy elnevezni, hogy a név tükrözze a darab helyét a "mátrixban".

A mostani pédában szereplő darabok a mozaik/ mappában vannak, m_{sor}_{oszlop}.jpg néven, ahol {sor} a sor száma, {oszlop} pedig az oszlopé. Pl.: mozaik/m_2_3.jpg. A képet 4 sorban soronként 7 darabra vágtam. Az egyes darabok mérete 100*110 pixel

1. A tartalmazó weboldal és a játéktér definiálása

<!DOCTYPE HTML>
<html>
 <head>
  <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-2">
  <title>Tilitoli - JavaScript példaprogram</title>
 </head>
 <body>
  <h1>Tilitoli - JavaScript példaprogram</h1>
  <div id="jatekter" 
	style="position: relative; width: 700px; height: 440px; background: #d3d3d3;"></div>
 </body>
</html>
1. Az üres weboldal a játéktérrel

2. Adattárolás, képdarabok betöltése

A játék működtetéséhez tudnunk kell, hogy melyik cellában melyik mozaikdarab van, és hogy melyik az üres cella. Kezdjük a globáls változók definiálásával:

<script>

var m=4; // mozaik sorainak száma
var n=7; // mozaik oszlopainak száma
var xs=100; // egy darab szélessége pixelben
var ys=110; // egy darab magassága pixelben
var ux=n-1; // üres darab x koordinátája
var uy=m-1; // üres darab y koordinátája
var mozaik=[]; // ez a tömb tárolja majd a mozaik darabjait. 
	       // mozaik[i][j] az i. sor j. oszlopában lévő képre mutat

</script>

A weboldal betöltésekor a mozaik tömbbe be kell töltenünk a képdarabokat és aztán meg is kell jelenítenünk azokat. Emiatt a body elem onload eseményéhez rendeljünk hozzá egy függvényt:

 <body onload="init()">

Természetesen az init() függvényt is definiálnunk kell:

function init() // ez a függvény hívódik meg az oldal betültése után
{
  // megkeressük a játéktérként funkcionáló div-et
  var jatekTer=document.getElementById('jatekter');

  // betöltjük, és eltároljuk a képeket
  for(var i=0;i<m;i++)
  {
    mozaik[i]=[]; // a kétdimenziós tömb a JavaScriptben tömbök tömbje.
    for(var j=0;j<n;j++)
      if (i<(m-1)||j<(n-1)) // ha nem a jobb alsó (üres) darabnál tartunk
      {
	mozaik[i][j]=new Image(); // létrehozzuk a képet
	with(mozaik[i][j])
	{
	  src='mozaik/m_'+i+'_'+j+'.jpg'; // hozzárendeljük a fájlnevet
	  style.position='absolute'; // és beállítjuk a helyzetét a játéktéren
	  style.top=ys*i+'px';
	  style.left=xs*j+'px';
	}
      }
  }
  mozaik[m-1][n-1]=null; // az üres helyet null érték jelzi

  // megjelenítjük a képeket
  for(i=0;i<m;i++)
    for(j=0;j<n;j++)
      if (mozaik[i][j]!=null)
	jatekTer.appendChild(mozaik[i][j]); // hozzáadjuk a játéktér objektumhoz
}
2. Az játéktér a betöltött mozaikdarabokkal

Mivel így nem igazán látszik a mozaikdarabok széle, adjunk nekik keretet. Mivel a keret vastagsága megnövelné a képek méretét, át is méretezzük azokat. (Természetesen az is jó megoldás lehet, hogy a játéktér mérete nagyobb, és a képeket a keret méretét is figyelembe véve helyezzük el.) A háromdimenziós hatás kedvéért a képek bal és felső széle világosabb, a jobb és alsó szegélye pedig sötétebb lesz:

	  style.width=(xs-4)+'px';style.height=(ys-4+'px');
	  style.borderLeft=style.borderTop='2px solid lightgray';
	  style.borderRight=style.borderBottom='2px solid darkgray';
2b. Az játéktér a betöltött mozaikdarabokkal, a mozaikok szélei kiemelve

3. Eseménykezelés

Most jön a játék "lelke": a mozaikdarabokra kattintva azoknak el kell csúszni, ha a szomszédjuk üres. Az eseménykezelőt a játéktérre élesítjük, és kiszámítjuk, hogy melyik mozaikdarabra esik. Ezután megvizsgáljuk, hogy szomszédos-e ez a mozaik az üres hellyel, és ha igen, akkor odébbmozgatjuk.

  // eseménykezelő függvény a klikkeléshez
  jatekTer.onclick=function(e)
	{
	  var ev=e||window.event; // Firefox és társai paraméterként adják az esemény objektumot,
				  // IE viszont a window.event objektumban
	  var ex=ev.pageX||ev.clientX+document.body.scrollLeft; // böngészőtől függő módon
	  var ey=ev.pageY||ev.clientY+document.body.scrollTop;  // kinyerjük a kattintás koordinátáit
	  ex-=jatekTer.offsetLeft; // kivonjuk a játéktér pozícióját
	  ey-=jatekTer.offsetTop;
	  var mx=Math.floor(ex/xs); // kiszámoljuk, hogy melyik mozaikra esik
	  var my=Math.floor(ey/ys);
          if (szomszedos(mx,my)) // ha az (my,mx) mozaik szomszédos az üressel,
	    mozgat(mx,my);       // akkor mozgatunk.
	  return false;
	}

Szükség lesz még két függvényre, amire hivatkozunk: az egyik a szomszédosságot vizsgálja, a másik pedig a mozgatást végzi.

function szomszedos(x,y) // megmondja, hogy egy (x,y) darab szomszédos-e (ux,uy)-nal
{
  // akkor szomszédosak, ha egyik koordinátájuk megegyezik, a másik megy csak eggyel tér el.
  with (Math)
    return (abs(x-ux)==1&&(y==uy))||(abs(y-uy)==1&&(x==ux)); 
}

function mozgat(x,y) // az (x,y) darabot átteszi az (ux,uy) helyre
{
  with(mozaik[y][x])
  {
    style.top=uy*ys+'px';
    style.left=ux*xs+'px'; // átállítjuk a koordinátákat
  }
  mozaik[uy][ux]=mozaik[y][x]; // áthelyezzük a tömbben is
  mozaik[y][x]=null; // töröljük a régi helyéről
  ux=x;uy=y; // mostantól (x,y) az üres hely;
}
3. Eseménykezelés

4. Kezdeti keverés

A játék persze úgy érdekes, hogyha az elején a gép kicsit megkeveri a táblát. Mivel nem akarunk olyan kezdőállást, amit esetleg nem lehet kirakni, a kezdeti keverés abból áll, hogy csinálunk valahány véletlenszerű mozgatást. Ezt a képek betöltése után, de még azok kirajzolása előtt kell megcsinálni. A véletlenszerű keverés úgy történik, hogy véletlenszerűen kiválasztunk egy irányt (jobbbra, balra, fel vagy le), megnézzük, hogy az üres helynek az adott irányban van-e szomszédja, és ha igen, azt eltoljuk. Ha nincs, akkor az ellenkező irányban lévő szomszéddal tesszük ugyanezt.

...

var keverSzam=50; // ennyi lépésből áll a kezdeti keverés

...

  // keverés - véletlenszerű mozgatások sorozata
  var kx,ky,r;
  for(i=0;i<keverSzam;i++)
  {
    r=Math.floor(Math.random()*4); // r-től függ a mozgatás iránya
    switch(r)
    {
    case 0: // jobbra (v. balra)
	kx=(ux<n-1)?(ux+1):(ux-1);ky=uy;
	break;
    case 1: // balra (v. jobbra)
	kx=(ux>0)?(ux-1):(ux+1);ky=uy;
	break;
    case 2: // le (v. fel)
	ky=(uy<m-1)?(uy+1):(uy-1);kx=ux;
	break;
    case 3: // fel (v. le)
	ky=(uy>0)?(uy-1):(uy+1);kx=ux;
	break;
    }
    mozgat(kx,ky);
  }
4. Kezdeti keverés

5. Animált mozgatás

Látványos megoldás lehet a mozgatás animálása, azaz, hogy a mozaikdarabok nem egyszerűen átkerülnek az egyik helyről a másikra, hanem fokozatosan "átcsúsznak". Ezt úgy oldhatjuk meg, hogy bizonyos időközönként odébbhelyezzük egy kicsit a darabot egészen addig, míg el nem éri az új helyét. A gyakorlatban kipróbálva a 10 részletben történő eltolás már elég.

A megvalósításhoz készítsünk egy új függvényt pl. animMozgat() néven. Az eredeti mozgató függvény is maradjon meg, mivel a kezdeti keverésnél azt használjuk.

Természetesen az klikkelésnél most már az animMozgat() függvényt hívjuk meg.

...

var animIdo=30; // animáció késleltetése ms-ban. egy tolás tízszer ennyi idő lesz

...

function animMozgat(x,y) // animált mozgatás. Az (x,y) darabot átteszi az (ux,uy) helyre 10 lépésben
{
  var animLepes=0; // hányadik lépésnél tartunk

  // most beállítunk egy időzítőt, mely "animIdo" ms-onként végrehajt egy függvényt.
  var tId=setInterval(function()
	{
	  if (animLepes<10) // ha még nem ez az utolsó lépés
	  {
	    animLepes++;
	    var nx=(x*(10-animLepes)+ux*animLepes)*xs/10; // kiszámoljuk az új helyet a kezdő- és végpozíció
	    var ny=(y*(10-animLepes)+uy*animLepes)*ys/10; // súlyozott átlagaként
	    with(mozaik[y][x])
	    {
	      style.top=ny+'px'; 
	      style.left=nx+'px'; // átállítjuk a koordinátákat
	    }
	  }
	  else // ha ez az utolsó lépés
	  {
	    clearInterval(tId); // töröljük az időzítőt
	    mozaik[uy][ux]=mozaik[y][x]; // áthelyezzük a tömbben is a mozaikot
	    mozaik[y][x]=null; // töröljük a régi helyéről
	    ux=x;uy=y; // mostantól (x,y) az üres hely;
	  }
	},animIdo);
}

...
          if (szomszedos(mx,my)) // ha az (my,mx) mozaik szomszédos az üressel,
	    animMozgat(mx,my);
...
5. Animált mozgatás

6. Utolsó simítások

Legyen végre füle és farka is a játéknak. Tegyünk egy "alaphelyzet" (==nem összekevert állapot) és egy "keverés és kezdés" gombot az oldalra, lehessen beállítani, hogy mennyire keverje meg a gép az elején és vegye észre a gép, ha kiraktuk a képet.

A gombokat egyszerűen definiáljuk HTML-ben:

  <input type="button" value="Alaphelyzet" onclick="alaphelyzet()" />
  <input type="button" value="Keverés és kezdés" onclick="kezdes()" />
  Keverés lépéseinek száma: <input type="text" id="kevSzam" value="30" size="2" />

Ha viszont van keverés gomb, akkor ne legyen automatikus a kezdeti keverés, csak a gomb megnyomásával történjen meg a dolog. Ehhez vegyük ki a keverést megvalósító sorokat az init() függvényből és tegyük bele a gomb megnyomásával aktivizálódó kezdes() függvénybe.

function kezdes() // kezdeti keverés
{
  // keverés - véletlenszerű mozgatások sorozata
  var kx,ky,r,i;
  for(i=0;i&tt;keverSzam;i++)
  {
    r=Math.floor(Math.random()*4); // r-től függ a mozgatás iránya
    switch(r)
    {
    case 0: // jobbra (v. balra)
	kx=(ux&tt;n-1)?(ux+1):(ux-1);ky=uy;
	break;
    case 1: // balra (v. jobbra)
	kx=(ux>0)?(ux-1):(ux+1);ky=uy;
	break;
    case 2: // le (v. fel)
	ky=(uy&tt;m-1)?(uy+1):(uy-1);kx=ux;
	break;
    case 3: // fel (v. le)
	ky=(uy>0)?(uy-1):(uy+1);kx=ux;
	break;
    }
    mozgat(kx,ky);
  }
}

Az alaphelyzetbe állításnál a legcélszerűbb az, ha kiürítjük a játékteret, és sorban újra lerakjuk a darabokat. Ez utóbbit egyszer már megírtuk az init() függvényben. Hogy ne kelljen kétszer leírni ugyanazt a kódot, helyezzük át az alaphelyzet() függvénybe, és azt hívjuk meg az init()-ből. Egy kicsit át is alakítjuk a kódot, mivel az alaphelyzet() függvénynek akkor is jól kell működnie, ha a játék elején hívódik meg, mikor a mozaik tömb még csak egy üres egydimenziós tömb, és akkor is, mikor a már feltöltött tömböt ki kell üríteni.

function alaphelyzet() // minden elem kerüljön az eredeti helyére
{
  // megkeressük a játéktérként funkcionáló div-et
  var jatekTer=document.getElementById('jatekter');

  // kiürítjük a játékteret
  jatekTer.innerHTML='';

  // kiürítjük a mozaik tömböt, hogy utána sorban visszarakhassuk a darabokat
  for(var i=0;i<m;i++)
  {
    if (typeof(mozaik[i])=='undefined')
      mozaik[i]=[]; // az első híváskor még nem léteznek a tömb sorai, ezért létrehozzuk
    else
      for(var j=0;j<n;j++) // ha már létezik, akkor pedig kiürítjük.
        mozaik[i][j]=null;
  }

  // betöltjük, és eltároljuk a képeket
  for(var i=0;i<m;i++)
    for(var j=0;j<n;j++)
      if (i<(m-1)||j<(n-1)) // ha nem a jobb alsó (üres) darabnál tartunk
      {
	mozaik[i][j]=new Image(); // létrehozzuk a képet
	with(mozaik[i][j])
	{
	  src='mozaik/m_'+i+'_'+j+'.jpg'; // hozzárendeljük a fájlnevet
	  style.position='absolute'; // és beállítjuk a helyzetét a játéktéren
	  style.top=ys*i+'px';
	  style.left=xs*j+'px';
	  style.width=(xs-4)+'px';style.height=(ys-4+'px');
	  style.borderLeft=style.borderTop='2px solid lightgray';
	  style.borderRight=style.borderBottom='2px solid darkgray';
	}
      }

  mozaik[m-1][n-1]=null; // az üres helyet null érték jelzi

  // megjelenítjük a képeket
  for(i=0;i<m;i++)
    for(j=0;j<n;j++)
      if (mozaik[i][j]!=null)
	jatekTer.appendChild(mozaik[i][j]); // hozzáadjuk a játéktér objektumhoz

  // üres hely a jobb alsó sarok
  ux=n-1;uy=m-1;
}

function init() // ez a függvény hívódik meg az oldal betültése után
{
  // megkeressük a játéktérként funkcionáló div-et
  var jatekTer=document.getElementById('jatekter');

  // alaphelyzet beállítása
  alaphelyzet();

  // eseménykezelő függvény a klikkeléshez
  jatekTer.onclick=function(e)
	{
	  var ev=e||window.event; // Firefox és társai paraméterként adják az esemény objektumot,
				  // IE viszont a window.event objektumban
	  var ex=ev.pageX||ev.clientX+document.body.scrollLeft; // böngészőtől függő módon
	  var ey=ev.pageY||ev.clientY+document.body.scrollTop;  // kinyerjük a kattintás koordinátáit
	  ex-=jatekTer.offsetLeft; // kivonjuk a játéktér pozícióját
	  ey-=jatekTer.offsetTop;
	  var mx=Math.floor(ex/xs); // kiszámoljuk, hogy melyik mozaikra esik
	  var my=Math.floor(ey/ys);
          if (szomszedos(mx,my)) // ha az (my,mx) mozaik szomszédos az üressel,
	    animMozgat(mx,my);
	  return false;
	}
}

Készítsünk egy olyan függvényt, ami megvizsgálja, hogy minden elem a helyén van-e...:

function kirakva() // megvizsgálja, hogy minden elem a helyén van-e
{
  for(var i=0;i<m;i++)
    for(var j=0;j<n;j++)
      if ((i!=uy||j!=ux)&&mozaik[i][j].src.indexOf(i+'_'+j)<0)
	return false; // ha csak egy is rossz helyen van, akkor nem vagyunk készen
  return true; // ha minden a helyén volt, akkor készen vagyunk
}

... és hívjuk meg minden egyes animált mozgatás befejeztével, és ha igaz a függvényérték, gratuláljunk:

	    if (kirakva()) // szólunk, ha sikerült kirakni.
	      alert('Gratulálunk!\nSikerült kirakni.');
6. Az utolsó simítások

Egy apró hiba maradt még a játékban: az animácó közben is működik az egérkezelés. Emiatt, ha valaki gyors egymásutánban kattint ugyanarra a helyre, furcsa dolgok történhetnek. Ennek kiküszöbölésére az anim nevű globális változót állítsuk true-ra az animáció ideje alatt, és ilyenkor ne csináljon semmit az egéresemény-kezelő függvény:

...
var anim=false; // jelzi, ha éppen folyamatban van az animáció; ezalatt nem működhet az egéresemény-kezelés
...
function animMozgat(x,y) // animált mozgatás. Az (x,y) darabot átteszi az (ux,uy) helyre 10 lépésben
{
  var animLepes=0; // hányadik lépésnél tartunk
  anim=true; // jelzés az egérkezelésnek, hogy n csináljon semmit, amíg nem végeztünk
...
	    clearInterval(tId); // töröljük az időzítőt
		anim=false; // animáció vége
...
  jatekTer.onclick=function(e)
	{
	  if (anim)
	    return false; // az animáció alatt nem működik az egérkezelés
...
7. "Duplakatt bug" kiküszöbölve