Isomeetra
Ruumilisuse (3D) simuleerimiseks kasutatakse mängudes sageli isomeetrilist (ülalt poolviltu - kaamera on telgede x (horisontaalselt paremale) , y (üles), z(ekraanilt vaataja suunas) pööratud vastavalt 60,0,45 kraadi) vaadet, milles mänguvälakul olev ristkülik muutub rombiks; x-y tasandi objektid (pikslid) esitatakse transformeeritult 2:1, s.t. y-telje ühele ühikule vastab 2 ühikut x-teljel
Isomeetrilises mängus on teljed (maapinnal) tavaliselt pööratud, kas 450 või 600, s.t. tegelased liiguvad poolviltu; sellepärast kasutatakse liikumissuundade tähistustena suundi kaardil: NE (NorthEast - kirre), SE (kagu), SW (edel), NW (loe).
Tegelane luuakse sama funktsiooniga nagu varem:
function animated(img,dx,dy,suunad, n_fr,suund,pos,sp){ //suunad - suunad, pos - animatsioon (1)/seisab(0) this.image = img; this.width = dx; //kaadri laius this.height = dy; this.suunad = suunad; this.dx = dx; //this.width; this.dy = dy; //this.height/3; //32 this.n_frames = n_fr; //number of frames this.actualFrame = 0; this.speed = sp; //in how many *game* frames a frame is shown this.step = 0; this.y = 0; this.x = 0; this.suund = suund; this.pos = pos; this.setPosition = function(X, Y){ this.x = X; this.y = Y; } this.draw = function(){ if (this.pos == 0) //paigal, esimene kaader ctx.drawImage(this.image,this.suunad[this.suund][0],this.suunad[this.suund][1],this.dx,this.dy,this.x,this.y,this.dx,this.dy); else { // walking ctx.drawImage(this.image,this.suunad[this.suund][0] + this.dx*this.actualFrame,this.suunad[this.suund][1] ,this.dx,this.dy,this.x,this.y,this.dx,this.dy); this.step ++; if (this.step == this.speed) { this.actualFrame++; if (this.actualFrame == this.n_frames) this.actualFrame = 1; this.step = 0; } } } }
Spriidilehel on ühe kaadri mõõtmed 58x96 px, seega kangelase loob käsk
hero = new animated(images.hero,58,96,{"NW":[0,0],"NE":[174,0],"SW":[0,96],"SE":[174,96]},3,"SE",0,24);
Kuna metsas on palju ohte, lisame kangelasele omadused, mis mõõdavad tema tervist ja elu:
hero.dead = false; hero.health = 100;
Objektide sügavus
Ekraanil on (näib) tagapool olevat see, mis on ekraanil kõrgemal. Kõrgemal, s.t. püstsuunas asetust määrab objekti koordinaat 'y' - objekt, mille y-koordinaat on väiksem, peab asetsema tagapool (olema ekraanile joonistatud varem).
Viga tekib sellest, et me järjestame (sügavuti, eemale-lähemale) mitte objektide tippude (y-koordinaat on objekti tipp), vaid nende asendi põhjal maapinnal, s.t. nende alumiste äärte põhjal ; alumine äär on määratud suurusega
object.y + object.height
Et selline algoritm toimiks, peavad kõik objetid (kangelane, puud, seened,...) olema joonistaud samale kanvaale.
Seega - jätame ära eraldi kanvaa puude jaoks, ja lisame skripti massiivi mängu kõigi objektide jaoks
allObjects = [];
NB! Javascripti massiivid on viitade põhjal, s.t. nendes pole 'päris' objekte, ainult viidad neile, seega näiteks objektide ümberjärjestamine massiivis ei muuda objekti; objekti kustutamine (delete) tekitab vaid massiivi augu, sellepärast toimub objekti eemaldamine massivist käsuga splice.
Mängu uute objektide lisamisel lisame need ka massiivi allObjects:
allObjects.push(hero); ...Objektide ekraanile joonistamise eel järjestame massiivi allObjects objektide alumise ääre, s.t. object.y + object.heigth järgi; järjestuse kirjeldab abifunktsioon compare:
function compare(obj1,obj2) { if (obj1.y + obj1.height < obj2.y + obj2.height) return -1; if (obj1.y + obj1.height >= obj2.y + obj2.height) return 1; return 0; } function drawObjects(){ allObjects.sort(compare); //järjestamine funktsiooni compare põhjal ! for (var i = 0; i < allObjects.length; i++) { allObjects[i].draw();} }
Objektide nimetamise (info)loogika
Mängu loomine algab piltide laadimisega:
var sources = { trees: "images/trees_512x128.png", hero: "images/mario_iso_58x96.png", koll:"images/koll_64x64x4.png", mushrooms: "images/seened0.png", };
Piltidest tehakse klassifunktsioonide spriteSheet abil objektid seened, puud ja klassifunktsiooni animated abil objektid hero, koll:
function spriteSheet(img,coords){ this.coords = coords; //coords = [[x0,y0,w0,h0],...[xn,yn,wn,hn]] this.sheet = img; this.x = 0; this.y = 0; this.width = this.coords[0][2]; //width of the first image this.height = this.coords[0][3]; //height of the first image this.nr = this.coords.length; //how many images? console.log(this.width,this.height,this.nr); this.draw = function(n,x,y){ ctx.drawImage(img,this.coords[n][0],this.coords[n][1],this.width,this.height,x,y,this.width,this.height); } } trees = new spriteSheet(images.trees,[[0,0,128,128],[128,0,128,128],[256,0,128,128],[384,0,128,128]]); seened = new spriteSheet(images.mushrooms,[[0,0,32,32],[33, 0, 32, 32],[0,33,32,32],[33, 33, 32, 32]]);
Kuna ka puid ja seeni on ekraanile joonistamise jaoks järjestada, tuleb ka neist teha Javascripti objektid. Nendele kangelase (ja hiljem ka kolli) objektidega sama formaadi saamiseks loome veel ühe abiklassi sheet_obj :
function sheet_obj(sheet,n,x,y){ this.obj = sheet; this.type = sheet.type; //puu, seen this.n = n; this.x = x; this.y = y; this.width = sheet.width; this.height = sheet.height; this.draw = function(){ sheet.draw(this.n, this.x, this.y); } }
Puud
Et puud näeks välja isomeetrilised, joonistame nad kaldridadena. Tüüpiline isomeetrilise vaate 2D-formaat on: horisontaalsuunas 2 ühikut vastab vertikaalsuunas ühele ühikule. Seega kui puude spriidileht on loodud klassifunktsioonigafunction spriteSheet(img,coords){ this.coords = coords; //coords = [[x0,y0,w0,h0],...[xn,yn,wn,hn]] this.sheet = img; this.x = 0; this.y = 0; this.width = this.coords[0][2]; //width of the first image this.height = this.coords[0][3]; //height of the first image this.nr = this.coords.length; //how many images? console.log(this.width,this.height,this.nr); this.draw = function(n,x,y){ ctx.drawImage(img,this.coords[n][0],this.coords[n][1],this.width,this.height,x,y,this.width,this.height); } } trees = new spriteSheet(images.trees,[[0,0,128,128],[128,0,128,128],[256,0,128,128],[384,0,128,128]]);võib puu laiuse ja kõrguse võtta mooduliks ja joonistada puud, nihutades paarituid ridu poole laiusühiku võrra:
w0 = trees.width; // width of the first tree h0 = trees.height; // height of the first tree vIso = Math.ceil(w/w0); rIso = Math.ceil(2*h/h0); drawTrees(); ... function drawTrees() { console.log(rIso,vIso); for (var j=0; j < rIso; j++){ treeLine(j); } } function treeLine(r){ //var n = tree.nr; //how many trees var delta = (r%2)*w0; for (var i=0; i < vIso; i++){ var x = i*2*w0+delta; var y = r*h0/2; var ind = Math.floor(Math.random()*trees.nr); var puu = new sheet_obj(trees,ind,x,y); puu.draw(); allObjects.push(puu); } }
Tulemusena peaks ekraanile ilmuma viltused puude read.
Seened
Seente paigutamisel on lihtsam lähtuda puude paigutamisel kasutatud püst- ja rõhtsuunalistest moodulitest, kuid paigutada seened puude vahele, s.t. nihutada mitte paarituid, vaid paaris ridu. Kuna etteantud arvu seeni on raske ühtlaselt paigutada, kasutame iga koha juures tõenäosust - kas siia panna seen või mitte? Mooduli järgi paigutamisel ei teki seente ega ka seente ja puude kokkupõrkeid.
seened = new spriteSheet(images.mushrooms,[[0,0,32,32],[33, 0, 32, 32],[0,33,32,32],[33, 33, 32, 32]]); seened.type = "seen"; seeni = 0; collected = 0; placeSeened(); ... function placeSeened(){ console.log('paigutan'+rIso,vIso,w0); for (var j = 0; j < rIso; j++){ var delta = ((j+1)%2)*w0; for (var i = 0; i < vIso; i++){ if (Math.random() > 0.85){ //mida väiksem, seda rohkem seeni ilmub var x = i*w0+delta; var y = j*h0/2; var ind = Math.floor(Math.random()*seened.nr); //console.log(ind,x,y); var seen = new sheet_obj(seened,ind,x,y); seen.draw(); //Math.floor(Math.random()*n),x, y); //i*w0,0); allObjects.push(seen); seeni ++; } } } //console.log(seeni); }
Koll
Metsas on mitmesuguseid ohte - näiteks hulgub seal ringi kuri koll, kes sööb seenelisi.
See on vaba programmiga Sculptris loodud väike 3D kujund, millest siis on salvestatud 4 vaadet spriidileheks.
Koll ja kangelane hero luuakse sama klassifunktsiooniga animated, kuid kollil on veel üks muutuja state - kas koll liigub või ei (animatsiooni pole, seega ei saa kasutada hero muutujat pos):
koll = new animated(images.koll,64,64,{"NW":[0,0],"NE":[64,0],"SW":[0,64],"SE":[64,64]},1,"SE",0,24); koll.x = w/2; koll.y = h/2; koll.type = "koll"; koll.state = 0; //ei liigu allObjects.push(koll);
Kolli AI
Koll on arukas - ta hakkab kangelast jälitama, kui see satub talle piisavalt lähedale; kangelase kokkupõrge kolliga tapab kangelase. Objektide kaugust üksteisest mängudes tavaliselt ei mõõdeta analüütilisest geomeetriast tuntud valemiga sqrt((x2-x1)*(x2-x1) + (y2-y1)*(y2-y1)), sest ruutjuure leidmine on 'kallis', s.t. aeglane operatsioon; selle asemel kasutatakse objektide koordinaatide vahede absoluutväärtuste summat. Järgnev funktsioon paneb kolli kangelast jälitama, kui nende vaheline kaugus on väiksem kui kolmandik ekraani pikkus+laius mõõtmest:
koll.AI = function(delta){ //console.log('koll vaatab!'); if ((Math.abs(hero.x - koll.x) + Math.abs(hero.y - koll.y)) < (w+h)/3) { koll.state = 1; //liigub if ((koll.x < hero.x) && (koll.y < hero.y)){ koll.suund = "SE"; } else if ((koll.x > hero.x) && (koll.y < hero.y)) koll.suund = "SW"; else if ((koll.x > hero.x) && (koll.y > hero.y)) koll.suund = "NW"; else if ((koll.x < hero.x) && (koll.y > hero.y)) koll.suund = "NE"; } else koll.state = 0;; //console.log(koll.suund); }
Liikumine
Kangelase ja kolli liikumist kirjeldab funktsioon update, millele on parameetrid nende liikumiskiiruse muutmiseks - sellega saab teha mängu kergemaks/raskemaks.Funktsioon update koosneb kolmest osast - esimeses juhitakse kangelast, teises kolli ja kolmandas kontrollitakse kangelase ja kolli kokkupõrkeid puude ja seentega. Kui koll või kangelane põrkavad kokku puuga, ei saa nad seda sammu sooritada ja nende esialgne asend taastatakse, sellepärast salvestatakse algul nende esialgne (vana) asend, et puuga kokkupõrke korral saaks taastada esialgse asendi.
Funktsiooni update esimene osa juhib kangelase liikumist:
function update(heroModifier,kollModifier) { //hero var oldHeroX = hero.x; var oldHeroY = hero.y; if (38 in keysDown || 87 in keysDown) { // Player holding up if (hero.y > 0 && hero.x < w-hero.width) {hero.suund = "NE"; hero.x += 2*hero.speed * heroModifier; hero.y -= hero.speed * heroModifier; hero.pos = 1;} if (hero.y < 0 ) //can not go out! {hero.y = 0; hero.pos = 0;} if ( hero.x > w-hero.width) {hero.x = w-hero.width; hero.pos = 0;} } if ((39 in keysDown) || (68 in keysDown)) { // Player holding right if (hero.y < h - hero.height && hero.x < w - hero.width) {hero.suund = "SE"; hero.x += 2* hero.speed * heroModifier; hero.y += hero.speed * heroModifier; hero.pos = 1;} if (hero.y > h - hero.height ) {hero.y = h - hero.height; hero.pos = 0;} if ( hero.x > w- hero.width) {hero.x = w - hero.width; hero.pos=0;} } if ((37 in keysDown) || (65 in keysDown)) { // Player holding left if (hero.x > 0 && hero.y > 0 ) {hero.suund = "NW"; hero.x -= 2*hero.speed * heroModifier; hero.y -= hero.speed * heroModifier; hero.pos = 1;} if (hero.x < 0 ) {hero.x = 0; hero.pos = 0;} if ( hero.y < 0 ) {hero.y = 0; hero.pos=0;} } if (40 in keysDown || 83 in keysDown) { // Player holding down if (hero.x > 0 && hero.y < h - hero.height) {hero.suund = "SW"; hero.x -= 2*hero.speed * heroModifier; hero.y += hero.speed * heroModifier; hero.pos = 1;} if (hero.x < 0 ) {hero.x = 0; hero.pos = 0;} if ( hero.y > h - hero.height) {hero.y = h - hero.height; hero.pos = 0;} } if (!(37 in keysDown) && !(38 in keysDown) && !(39 in keysDown) && !(40 in keysDown) && !(83 in keysDown) && !(65 in keysDown) && !(68 in keysDown) && !(87 in keysDown) ) hero.pos = 0; //peatub
Funktsiooni update teine, kolli liikumist kirjeldav osa on analoogiline kangelase liikumist kirjeldavaga, kuid lihtsam - siin ei kontrollita klaviatuuri, koll on 'iseliikuja'. Ka kolli liikumise kiirust saam modifitseerida ja sellega muuta mängu kergemaks-raskemaks:Kolli liikumist
//koll var oldKollX = koll.x; var oldKollY = koll.y; if (koll.state == 1) { //liigub! switch (koll.suund) { case 'SE': if (koll.x < w - koll.width - 2 * koll.speed * kollModifier) koll.x += 2 *koll.speed * kollModifier; if (koll.y < h - koll.height - koll.speed * kollModifier) koll.y += koll.speed * kollModifier; break; case 'SW': if (koll.x > 2 * koll.speed * kollModifier) koll.x += - 2 *koll.speed * kollModifier; if (koll.y < h - koll.height - koll.speed * kollModifier) koll.y += koll.speed * kollModifier; break; case 'NW': if (koll.x > 2 * koll.speed * kollModifier) koll.x += - 2 *koll.speed * kollModifier; if (koll.y > koll.speed * kollModifier) koll.y += - koll.speed * kollModifier; break; case 'NE': if (koll.x < w - koll.width - 2 * koll.speed * kollModifier) koll.x += 2 *koll.speed * kollModifier; if (koll.y > koll.speed * kollModifier) koll.y += -koll.speed * kollModifier; break; } }
Funktsiooni update kolmandas osas kontrollitakse kokkupõrkeid, kuid siin ei kontrollita enam kogu spriidi nelinurka (puu latv ei tohiks takistada kangelase liikumist puu taga), vaid ainult objektide projektsiooniga maapinnale (nende varju) tekitatud nelinurki - maske.
Mask
Kui spriidid on suuremad, ei saa nende kokkupõrkeid arvutada kogu spriidikujutise nelinurga põhjal - see näeks välja väga veider, kangelane ei pääse paljudest objektidest (puudest) mööda.
Kokkupõrgete arvutamiseks tuleb kasutada maski - objektide projektsiooni maapinnale (varju), näiteks Mario kaadri mask võiks olla tema jalgade ümber olev nelinurk 54x24px.
Kangelase mask kirjeldatakse kohe pärast kangelase loomist. Mask on objekt, sellel on omadused .x, .y, .width, .height. Omadused mask.x, mask.y muutuvad, kui kangelase asend (omadused hero.x, hero.y) muutuvad, sellepärast lisame maskile ka funktsiooni nende muutmiseks:
hero.mask = {}; //mask on objekt! hero.mask.width = hero.width/2; hero.mask.height = hero.height/3; hero.mask.reset = function() { hero.mask.x = hero.x + hero.width/4; hero.mask.y = hero.y + hero.height - hero.mask.height; }Kolli mask määratakse pärast kolli loomist analoogiliselt; kuna koll on ümmargune, ei erine tema mask erit1 palju tema spriidist, on vaid madalam:
koll.mask = {}; koll.mask.width = koll.width; koll.mask.height = koll.height/2; koll.mask.reset = function() { koll.mask.x = koll.x; koll.mask.y = koll.y+koll.height/2; }Puude ja seente mask määratakse samuti pärast puude/seente loomist; kuna seened on väikesed, ei erine nede mask nende spriidist
var puu = new sheet_obj(trees,ind,x,y); puu.mask = {}; puu.mask.width = puu.width/6; puu.mask.x = puu.x + (puu.width - puu.mask.width)/2 ; puu.mask.height = puu.height/6; puu.mask.y = puu.y + puu.height - puu.mask.height; ... var seen = new sheet_obj(seened,ind,x,y); seen.mask = {}; seen.mask.x = seen.x; seen.mask.width = seen.width; seen.mask.y = seen.y; seen.mask.height = seen.height;
Kokkupõrked (collisions)
Kangelase (ka kolli) liikumisel tuleb kontrollida kokkupõrkeid. Kangelase kokkupõrkel seenega korjab kangelane seene, kokkupõrkel kolliga - saab surma, kokkupõrge puuga ei võimalda samas suunas edasi liikumist. Kõik mängu objektid on salvestatud massiivis allObjects, seega tuleb (funktsiooni update lõpus) teha tsükkel üle kõigi selle massiivi objektid; millise objektidga on tegemist, määrab objekti omadus type. Kokkupõrkeid kontrollitakse funktsiooni update kolmandas osas; string userText kirjeldab infot, mis joonistatakse mängija jaoks mänguväljale (mitte mingisse eraldi olevasse menüüaknasse, see on nn. HUD -:heads-up display):
// Collisions !! for(var i = allObjects.length - 1; i >= 0; i--){ switch (allObjects[i].type){ case 'hero': //continue; break; case 'koll' : if (collision(hero,koll)){ userText = "SAID SURMA !"; koll.state = 0; hero.dead = true; //reset(); } break; case 'seen': if (collision(hero,allObjects[i])){ allObjects.splice(i,1); ++collected; seeni += -1; hero.health += 1; userText = "Korjatud "+collected + " seent, metsas on veel "+seeni + "Tervis: "+hero.health; } break; case 'puu': if (collision1(hero,allObjects[i])){ hero.x = oldHeroX; hero.y = oldHeroY; hero.mask.reset(); } /* if (collision(koll,allObjects[i])){ koll.x = oldKollX; koll.y = oldKollY; koll.mask.reset(); //koll.suund = nextSuund(koll.suund); } */ break; } }
Kolli kokkupõrkel puuga pöörab koll kas paremale või vasakule (kuid mitte tagasi!); uue suuna arvutab funktsioon
function nextSuund(suund){ var uusSuund; if (suund == 'NE' || suund == 'SW') uusSuund = (Math.random()>0.5)?'NW':'SE'; else uusSuund = (Math.random()>0.5)?'NE':'SW'; return uusSuund; }
Kokkupõrked arvutatakse maski abil:
function collision1(obj1,obj2){ return obj2.mask.x <= (obj1.mask.x + obj1.mask.width) && obj1.mask.x <= (obj2.mask.x + obj2.mask.width) && obj2.mask.y <= (obj1.mask.y + obj1.mask.height) && obj1.mask.y <= (obj2.mask.y + obj2.mask.height); }
Info väljastamine mängijale
Et mängija surm ilmuks mõjuvamalt, väljastatakse see suurelt ja punasena:function showScore(txt){ // Score if (hero.dead){ ctx.fillStyle = "rgb(250, 0, 0)"; ctx.font = "bold 36px Arial"; } else { ctx.fillStyle = "rgb(250, 250, 250)"; ctx.font = "14px Arial"; ctx.textAlign = "left"; ctx.textBaseline = "top"; } ctx.fillText(txt, w0/2, h0); //text, x,y };
Peafunktsioon
Kogu mängu käivitav funktsioon on täiesti analoogiline varem kasutatud funktsiooniga:
var main = function () { render(); var now = Date.now(); var delta = now - then; //console.log(collected,seeni); if ( seeni > 0 && !hero.dead ) { koll.AI(0.1); update(delta / 100, delta/1000); then = now; setTimeout(main,30); //repeat } else { var time=Math.round(((now-startTime)/1000)) showScore(userText); } };
Kolli laserrelv
Kollil võib olla veel üks relv - ta tulistab kangelast, kui see on nähtav, s.t. asub kolliga samas puude vahelises koridoris. Puude NW-SE ridade tõus (y2-y1)/(x2-x1) = 0.5 , seega koll tulistab laseriga, kui
- ta näeb kangelast, s.t. nende vaheline kaugus on väiksem kui tema nähemisraadius - siin varem 2*(w+h)/3
- nendevahelise sirge tõus on 'peaaegu' 0.5 (erineb sellest vähem kui ette antud nurk, näit 0.1 radiaani).
Laseriga relvastatud kolli arukuse funktsioon sisaldab nüüd ka kolli tulistamist:
koll.AI = function(angle){ if ((Math.abs(hero.x - koll.x) + Math.abs(hero.y - koll.y)) < (w+h)/3) { koll.state = 1; //liigub if ((koll.x < hero.x ) && (koll.y < hero.y)){ koll.suund = "SE"; if (Math.abs((koll.y - hero.y)/(koll.x - hero.x) + 0.5) < angle ) { koll.fire = true; } else koll.fire = false; } else if ((koll.x > hero.x) && (koll.y < hero.y)) { koll.suund = "SW"; if (Math.abs((koll.y - hero.y)/(koll.x - hero.x) + 0.5) < angle ) { koll.fire = true; } else koll.fire = false; } else if ((koll.x > hero.x ) && (koll.y > hero.y )) { koll.suund = "NW"; if (Math.abs((koll.y - hero.y)/(koll.x - hero.x) - 0.5) < angle ) { koll.fire = true; } else koll.fire = false; } else if ((koll.x < hero.x) && (koll.y > hero.y)) { koll.suund = "NE"; if (Math.abs((koll.y - hero.y)/(koll.x - hero.x)+ 0.5) < angle ) { koll.fire = true; } else koll.fire = false; } } else koll.state = 0;; //console.log(koll.suund); }
Vastavalt tuleb täiendada ka objektide ekraanile joonistamist: kõigepeal kolli laseri kiir (et see ilmuks kolli alt, mitte pealt!) ja siis kõik ülejäänud objektid:
function drawObjects(){ if (koll.fire){ ctx.beginPath(); ctx.moveTo(koll.x + koll.width/2, koll.y + koll.height/2); ctx.lineTo(hero.x + hero.width/2, hero.y + hero.height/2); ctx.stroke(); hero.health += -2; //laser tapab!!! if (hero.health < 0) { hero.dead = true; userText = "Pole enam tervist..."; } } allObjects.sort(compare); for (var i = 0; i < allObjects.length; i++) { allObjects[i].draw();} }