]> git.plutz.net Git - flarejs/blob - engine.js
process INCLUDE directive when loading file
[flarejs] / engine.js
1 var gamedata = {
2   // associative array for gamedata files
3   load: function(def) { return this[def] ? this[def] : this[def] = new Textfile(def); }
4 }
5
6 var gfx = {
7   // associative array for game graphics
8   load: function(def) {
9     if (!this[def]) {
10       this[def] = document.createElement("img");
11       this[def].setAttribute("src", def);
12     }
13     return this[def];
14   }
15 }
16
17 // map object is global
18 var map;
19
20 // quest status
21 var qstatus = {};
22
23 function Textfile(txtfile) {
24   // fetch and parse gamedata file into structured object
25   var lines, fetch = new XMLHttpRequest();
26   fetch.open("GET", txtfile, false);
27   fetch.send();
28
29   function merge(obj, file) {
30     for (each in file) {
31       if (Array.isArray(obj[each]) && Array.isArray(file[each])) obj[each] = [ ...obj[each], ...file[each] ];
32       else if (Array.isArray(obj[each])) obj[each] = [ ...obj[each], file[each] ];
33       else if (Array.isArray(file[each])) obj[each] = [ obj[each], ...file[each] ];
34       else if (each in obj) obj[each] = [ obj[each], file[each] ];
35       else obj[each] = file[each];
36     }
37   }
38
39   var line, key, value, ref = this, section;
40   for (line of fetch.responseText.split('\n')) switch(true) {
41     // INCLUDE file
42     case /^INCLUDE .+$/.test(line):
43       //console.log("Including: " + line.split(/ /)[1]);
44       merge(ref, new Textfile(line.split(/ /)[1]));
45       break;
46     // section headers that always become array elements
47     case /^\[(npc|event|layer|dialog)\]$/.test(line):
48       section = line.split(/[\]\[]/)[1]; ref = {};
49       if                     (!this[section]) this[section] = [ref];
50       else if ( Array.isArray(this[section])) this[section].push(ref);
51       break;
52     // general section headers (may or may not be unique)
53     case /^\[.*\]$/.test(line):
54       section = line.split(/[\]\[]/)[1]; ref = {};
55       if                     (!this[section]) this[section] = ref;
56       else if (!Array.isArray(this[section])) this[section] = [this[section],ref];
57       else if ( Array.isArray(this[section])) this[section].push(ref);
58       break;
59     // Clear section on empty line
60     case /^ *$/.test(line):
61       ref = this;
62       break;
63     // frame definition from animation files
64     // frame=#frame,direction,srcX,srcY,width,height,dstX,dstY
65     case /^frame=[0-9]+,[0-7](,[0-9-]+){6}$/.test(line):
66       key = line.split(/[=,]/).slice(1,3);  value = line.split(/,/).slice(-6);
67       if (!ref.frame) ref.frame = [];
68       if (!ref.frame[key[0]]) ref.frame[key[0]] = [];
69       ref.frame[key[0]][key[1]] = value.map(x => parseInt(x));
70       break;
71     // order of gfx draw from engine/hero_layers.txt
72     // layer=direction,list,of,limbs,...
73     case /^layer=[0-9]+,/.test(line):
74       key = line.split(/[=,]/)[1];  value = line.split(/,/).slice(1);
75       if (!ref.layer) ref.layer = []; ref.layer[key] = value;
76       break;
77     // empty data= line starting map data in map files
78     case /^data=$/.test(line):
79       ref.data = [];
80       break;
81     // map data array
82     case /^[0-9,]+,$/.test(line):
83       value = line.split(/,/).slice(0,-1);
84       ref.data = ref.data.concat(value);
85       break;
86     // map data array (last line)
87     case /^[0-9,]+$/.test(line):
88       value = line.split(/,/);
89       ref.data = ref.data.concat(value);
90       ref.data = ref.data.map(x => parseInt(x));
91       break;
92     // tile description from tiledef file
93     // tile=#tile,srcX,srcY,width,height,dstX,dstY
94     case /^tile=[0-9]+,/.test(line):
95       key = line.split(/[=,]/)[1];  value = line.split(/,/).slice(1);
96       if (!ref.tile) ref.tile = []; ref.tile[key] = value.map(x => parseInt(x));
97       break;
98     // animated tile from tiledef file
99     // animation=#tile;(srcX,srcY,duration;)...
100     case /^animation=[0-9]+;.*;$/.test(line):
101       key = line.split(/[=;]/)[1];  value = line.split(/;/).slice(1,-1);
102       value = value.map(x => x.split(/,/));
103       value.forEach(x => x[2] = parseInt(x[2]));
104       if (!ref.animation) ref.animation = []; ref.animation[key] = value;
105       break;
106     // frame duration from animation file (im millisec)
107     case /^duration=[0-9]+ms$/.test(line):
108       ref.duration = line.split(/=|ms$/)[1];
109       break;
110     // frame duration from animation file (in whole seconds)
111     case /^duration=[0-9]+s$/.test(line):
112       ref.duration = line.split(/=|s$/)[1] * 1000;
113       break;
114     // mapmod events
115     case /^mapmod=.*$/.test(line):
116       ref.mapmod = line.split(/[=]/)[1].split(/[;]/).map( m => m.split(/[,]/).map( n => n * 1 ? n * 1 : n) );
117       break;
118     // status array used in events
119     case /^(requires_status|requires_not_status|set_status|unset_status)=.*$/.test(line):
120       key = line.split(/[=]/)[0]; value = line.split(/[=]/)[1];
121       ref[key] = value.split(/[,]/);
122       break;
123     // general array type, will not be aggregated
124     case /^[^#].*=.*,.*$/.test(line):
125       key = line.split(/[=]/)[0]; value = line.split(/[=]/)[1].split(/,/);
126       ref[key] = value.map(m => m * 1 ? m * 1 : m);  // parse numerics
127       break;
128     // general key=value
129     case /^[^#].*=.+$/.test(line):
130       key = line.split(/[=]/)[0];
131       value = line.split(/=/).slice(1).join("=");
132       value = value * 1 ? value * 1 : value;  // try to parse as numeric
133       if                     (!ref[key]) ref[key] = value;
134       else if (!Array.isArray(ref[key])) ref[key] = [ref[key],value];
135       else if ( Array.isArray(ref[key])) ref[key].push(value);
136       break;
137   }
138 }
139
140 function Map(textdef) {
141   // object for map operations (drawing coorinate transformation, etc.)
142   this.info = gamedata.load(textdef);
143   const tileset = gamedata.load(this.info.header.tileset);
144   const frametime = performance.now();
145   gfx.load(tileset.img);
146
147   const  h = this.info.header.height,      w = this.info.header.width;
148   const th = this.info.header.tileheight, tw = this.info.header.tilewidth;
149   var posx = canvas.canvas.width / 2, posy = canvas.canvas.height / 2 - h * th/2;
150
151   canvas.fillStyle = "rgba("+this.info.header.background_color+")";
152
153   // precalculated tile positions
154   // assign x/y coordinates to each tile index
155   // looks dumb but is faster than on the fly isometric calculations
156   const dx = [], dy = [];
157   for ( let y = 0; y < h; y++ ) for ( let x = 0; x < w; x++ ) {
158     dx[y * w + x] = (w + x - y) * tw / 2;
159     dy[y * w + x] = (x + y) * th / 2;
160   }
161
162   const npcs = [];
163   if (this.info.npc) for (let def of this.info.npc) {
164     let loc = def.location[1] * w + def.location[0];
165     npcs[loc] = new Npc(def.filename)
166     npcs[loc].place(dx[loc], dy[loc]);
167     this.info.layer.find(l => l.type == "collision").data[loc] = 1;
168   }
169
170   this.triggers = [];
171   for ( let ev of this.info.event.filter(e => e.activate == "on_trigger") ) {
172     for (let x = ev.location[0]; x < ev.location[0] + ev.location[2]; x++)
173       for (let y = ev.location[1]; y < ev.location[1] + ev.location[3]; y++) {
174         let loc = y * w + x;
175         if (!this.triggers[loc]) this.triggers[loc] = [];
176         this.triggers[loc].push(ev);
177       }
178   }
179
180   // tile index for pixel position on map
181   this.tileAt = function(x, y) {
182     var r = (y + th / 2) / th; var c = (x - w * tw /2) / tw;
183     var nx = r + c |0; var ny = r - c |0;
184     return ny * w + nx;
185   }
186   this.tileX = (x,y) => this.tileAt(x,y) % w;
187   this.tileY = (x,y) => this.tileAt(x,y) / w |0;
188
189   // vice versa the pixel coordinates of a given map tile
190   this.positionOf = (tl, ty = -1) => (~ty) ? [dx[ty * w + tl], dy[ty * w + tl]] : [ dx[tl], dy[tl] ];
191   this.xOf = (tl, ty = -1) => (~ty) ? dx[ty * w + tl] : dx[tl];
192   this.yOf = (tl, ty = -1) => (~ty) ? dy[ty * w + tl] : dy[tl]; 
193
194   // map pixel for given screen pixel
195   this.mapX = x => x - posx;
196   this.mapY = y => y - posy;
197
198   // center map to pixel position (by setting drawing offsets)
199   this.center = function(x, y) {
200     posx = canvas.canvas.width  / 2 - x;
201     posy = canvas.canvas.height / 2 - y;
202     return this;
203   }
204
205   // draw the entire map, including all mobs
206   // mobs is an array of all heros, enemies, loot, npcs, etc.
207   this.draw = function(mobs) {
208     const bg = this.info.layer.find(l => l.type == "background").data;
209     const ob = this.info.layer.find(l => l.type == "object").data;
210     var i, mm = [];
211
212     // mobs only have x/y pixel positions,
213     // to draw them in order with map tiles we set up
214     // an array with current tile positions of mobs
215     mobs.forEach(m => {
216       let i = this.tileAt(m.position[0], m.position[1]);
217       mm[i] = mobs.filter(m => i == this.tileAt(m.position[0], m.position[1]));
218     });
219     canvas.fillRect(0,0, canvas.canvas.width, canvas.canvas.height);
220
221     // draw background layer first
222     for ( i = 0; i < h * w; i++ ) draw_tile(bg[i], posx + dx[i], posy + dy[i]);
223
224     // draw object layer and mobs
225     for ( i = 0; i < h * w; i++ ) {
226       draw_tile(ob[i], posx + dx[i], posy + dy[i]);
227       if (mm[i]) mm[i].forEach(m => m.draw(posx + m.position[0], posy + m.position[1]));
228       if (npcs[i]) npcs[i].draw(posx + dx[i], posy + dy[i]);
229     }
230   }
231
232   // draw a single map tile at screen position x/y
233   // tile may be animated, if so defined in tiledef
234   function draw_tile(tile, x, y) {
235     const t = tileset.tile[tile];
236     const f = tileset.animation[tile];
237     x = x |0; y = y |0;
238
239     if (t && f) {
240       frame = ((performance.now() - frametime) / f[0][2] |0) % f.length;
241       if ( x + t[2] - t[4] > 0 && y + t[3] - t[5] > 0 && x - t[4] < canvas.canvas.width && y - t[5] < canvas.canvas.height)
242       canvas.drawImage(gfx[tileset.img], f[frame][0], f[frame][1], t[2], t[3],
243                                                x - t[4], y - t[5], t[2], t[3]);
244     } else if (t) {
245       if ( x + t[2] - t[4] > 0 && y + t[3] - t[5] > 0 && x - t[4] < canvas.canvas.width && y - t[5] < canvas.canvas.height)
246       canvas.drawImage(gfx[tileset.img], t[0], t[1], t[2], t[3],
247                                  x - t[4], y - t[5], t[2], t[3]);
248     }
249   }
250 }
251
252 function Sprite(textdef) {
253   // sprite object, mostly for gfx representation
254   // may be hero, enemy, loot, npc, etc.
255   this.position = [0, 0];
256   const info = gamedata.load(textdef);
257   var direction = 0;
258   var animation = "stance";
259   var previous_animation = "";
260   var frametime = performance.now();
261   gfx.load(info.image)
262
263   // some simplified npc files do not define animation frames
264   // render size and offset are given instead, and we
265   // assume, that we can just loop horizontally over the image
266   if (!info[animation].frame){
267     info[animation].frame = [];
268     let rs = info.render_size, ro = info.render_offset;
269
270     for (let i = 0; i < info[animation].frames; i++ )
271       info[animation].frame[i] = [[ i * rs[0], 0, rs[0], rs[1], ro[0], ro[1]]];
272   }
273
274   // place sprite on x/y pixel coordinates
275   // e.g. when spawning, walking, etc.
276   this.place = function(x, y) {
277     this.position[0] = x, this.position[1] = y;
278     return this;
279   }
280
281   // change facing direction of sprite
282   this.direct = function(d) { direction = d % 8; return this; }
283
284   // set animation cycle for drawing
285   // play_once animations will automatically fall back
286   // to previous loop after completion
287   this.animate = function(a) {
288     if ( a != animation ) {
289       previous_animation = animation;
290       animation = a;
291       frametime = performance.now();
292     }
293     return this;
294   }
295
296   // draw this sprite to screen position x/y
297   this.draw = function(x, y){
298     var f, a = info[animation];
299     var f, frame = ( performance.now() - frametime ) * a.frames / a.duration | 0;
300
301     // determine current animation frame
302     switch(a.type){
303       case "looped":
304         frame = frame % a.frames;
305         break;
306       case "play_once":
307         if ( frame >= a.frames ){
308           animation = previous_animation;
309           previous_animation = "";
310           frametime = performance.now();
311           a = info[animation];
312           frame = 0;
313         }
314         break;
315       case "back_forth":
316         frame = frame % (a.frames * 2 - 2);
317         if ( frame >= a.frames ){
318          frame = a.frames - frame % a.frames - 1;
319         }
320         break;
321       default: break;
322     }
323     f = a.frame[frame][direction];
324
325     canvas.drawImage(gfx[info.image], f[0], f[1], f[2], f[3],
326                      x - f[4], y - f[5],
327                      f[2], f[3]);
328   }
329
330   // shortcut functions for specific animations
331   this.block  = () => this.animate("block" );
332   this.cast   = () => this.animate("cast"  );
333   this.die    = () => this.animate("die"   );
334   this.hit    = () => this.animate("hit"   );
335   this.run    = () => this.animate("run"   );
336   this.shoot  = () => this.animate("shoot" );
337   this.stance = () => this.animate("stance");
338   this.swing  = () => this.animate("swing" );
339 }
340
341 function Npc(textdef){
342   this.info = gamedata.load(textdef);
343   const avatar = new Sprite(this.info.gfx);
344
345   this.place = function(x, y) {
346     avatar.place(x,y);
347     return this;
348   }
349
350   this.draw = function(x, y) {
351     avatar.draw(x, y);
352     return this;
353   }
354 }
355
356 function Hero(gender = "female", hair = "short"){
357   // Object for player character
358   // unique in single player
359   this.position = [0,0];
360   this.stats = gamedata.load("engine/stats.txt");
361   const layers = gamedata.load("engine/hero_layers.txt");
362   var direction = 0, animation = "stance";
363   hair = (gender == "female")?"long":hair;
364
365   // hero consists of multiple sprites, one for each clothing/item overlay
366   var limbs = {
367     head : new Sprite("animations/avatar/"+gender+"/head_"+hair+".txt"),
368     chest: new Sprite("animations/avatar/"+gender+"/cloth_shirt.txt"),
369     hands: new Sprite("animations/avatar/"+gender+"/default_hands.txt"),
370     legs : new Sprite("animations/avatar/"+gender+"/cloth_pants.txt"),
371     feet : new Sprite("animations/avatar/"+gender+"/default_feet.txt"),
372     main : null, // main hand, e.g. melee weapon, magic weapon
373     off  : null  // off hand, e.g. shield, ranged weapon
374   }
375
376   // "dress" the player, i.e. assign clothing/item to limb
377   this.dress = function(limb, item) {
378     limbs[limb] = new Sprite("animations/avatar/"+gender+"/"+item+".txt")
379     limbs[limb].place(this.position[0], this.position[1]).direct(direction);
380     this.animate(animation);
381     return this;
382   }
383
384   // // Wrapper functions for Sprite class // //
385
386   // place all sprites beloning to hero (when spawning, walking, teleporting, etc.)
387   this.place   = function(x,y) {
388     this.position[0] = x, this.position[1] = y;
389     for (var limb in limbs) limbs[limb] && limbs[limb].place(x,y);
390     return this;
391   }
392   // change facing direction of hero (i.e. of all sprites)
393   this.direct  = function(d)   {
394     direction = d;
395     for (var limb in limbs) limbs[limb] && limbs[limb].direct(d);
396     return this;
397   }
398   // start animation cycle
399   this.animate = function(anim){
400     animation = anim;
401     for (var limb in limbs) limbs[limb] && limbs[limb].animate(anim);
402     return this;
403   }
404   this.draw    = function(x, y){
405     layers.layer[direction].forEach(limb => limbs[limb] && limbs[limb].draw(x, y));
406     return this;
407   }
408
409   // shortcuts for animation
410   this.block  = () => this.animate("block" );
411   this.cast   = () => this.animate("cast"  );
412   this.die    = () => this.animate("die"   );
413   this.hit    = () => this.animate("hit"   );
414   this.run    = () => this.animate("run"   );
415   this.shoot  = () => this.animate("shoot" );
416   this.stance = () => this.animate("stance");
417   this.swing  = () => this.animate("swing" );
418 }
419
420 function Controls(hero){
421   // processes keyboard / touch / mouse
422   // causes according player actions
423   // single player only, conrol will be assigned to server in multi player
424   var kbdmap = {
425        up: 87,    altup: 38,
426      down: 83,  altdown: 40,
427      left: 65,  altleft: 37,
428     right: 68, altright: 39,
429   }
430   var keys = [], click = [];
431
432   loadevents();
433
434   window.addEventListener("keydown", e => keys[e.keyCode] = true );
435   window.addEventListener("keyup"  , e => keys[e.keyCode] = false);
436   window.addEventListener("click", m => click = [ map.mapX(m.clientX), map.mapY(m.clientY) ] );
437   setInterval(() => input(), 33.33)
438
439   // cause player to walk, processes blocked terrain and player speed
440   // x/y are factors of speed and direction
441   //     i.e. +/-1 for diagonal movement
442   //     and  +/-1.4 for horizontal/vertical movement
443   function translate(x, y){
444     var sx = map.info.header.tilewidth  * hero.stats.speed / 33.33;
445     var sy = map.info.header.tileheight * hero.stats.speed / 33.33;
446     var dx = x * sx, hx = hero.position[0];
447     var dy = y * sy, hy = hero.position[1];
448     var f = 2.1;
449     const col = map.info.layer.find(l => l.type == "collision").data;
450
451     if (col[map.tileAt(hx + dx, hy + dy)] == 0 )
452             hero.place(hx + dx, hy + dy);
453     else if ( dy == 0 && col[map.tileAt(hx + dx / f, hy + sy / 1.5)] == 0 )
454                              hero.place(hx + dx / f, hy + sy / 1.5);
455     else if ( dy == 0 && col[map.tileAt(hx + dx / f, hy - sy / 1.5)] == 0 )
456                              hero.place(hx + dx / f, hy - sy / 1.5);
457     else if ( dx == 0 && col[map.tileAt(hx + sx / 1.5, hy + dy / f)] == 0 )
458                              hero.place(hx + sx / 1.5, hy + dy / f);
459     else if ( dx == 0 && col[map.tileAt(hx - sx / 1.5, hy + dy / f)] == 0 )
460                              hero.place(hx - sx / 1.5, hy + dy / f);
461     else player.stance();
462
463     map.center(hero.position[0], hero.position[1]);
464     triggers(hero.position[0], hero.position[1]);
465   }
466
467   // Process on_trigger events at pixel position x/y
468   function triggers(x, y) {
469     const i = map.tileAt(x,y);
470
471     if ( map.triggers[i] ) events (
472       map.triggers[i].filter( t => (!t.requires_status)     ? true : t.requires_status.find(s => qstatus[s])
473                     ).filter( t => (!t.requires_not_status) ? true : !t.requires_not_status.find(s => qstatus[s]) )
474     );
475   }
476
477   function loadevents() {
478     if ( map.info.event ) events(
479       map.info.event.filter( ev => ev.activate == "on_load"
480         ).filter( t => (!t.requires_status)     ? true : t.requires_status.find(s => qstatus[s])
481         ).filter( t => (!t.requires_not_status) ? true : !t.requires_not_status.find(s => qstatus[s]) )
482     )
483   }
484
485   function events( events ) {
486     var ev, item;
487
488     for ( ev of events ) {
489       // game status modification
490       if ( ev.set_status ) for (item of ev.set_status) {
491         qstatus[item] = true;
492       }
493       if ( ev.unset_status ) for (item of ev.unset_status) {
494         qstatus[item] = false;
495       }
496       if ( item = ev.msg ) {
497         console.log(item);
498       }
499       // intramap (i.e. teleporters)
500       if ( item = ev.intramap ){
501         hero.place( map.xOf(item[0], item[1]), map.yOf(item[0], item[1]) );
502         map.center( map.xOf(item[0], item[1]), map.yOf(item[0], item[1]) );
503       }
504       // mapmod (e.g. opening doors, activating platforms, changing terrain, ...)
505       if ( ev.mapmod ) for (item of ev.mapmod) {
506           map.info.layer.find(l => l.type == item[0]).data[item[2] * map.info.header.width + item[1]] = item[3];
507       }
508       // intermap (i.e. enter new area)
509       if ( item = ev.intermap ){
510         map = new Map(item[0]);
511         hero.place( map.xOf(item[1], item[2]), map.yOf(item[1], item[2]) );
512         map.center( map.xOf(item[1], item[2]), map.yOf(item[1], item[2]) );
513         loadevents();
514         break;  // further events would not be valid on new map
515       }
516     }
517   }
518
519   // process input and decide on according action
520   function input(){
521     // facing directions, indexed by OR of key press combinations
522     const dir   = [ -1, 0, 4, -1, 2, 1, 3, 2, 6, 7, 5, 6, -1, 0, 4, -1 ];
523     // translation speed and direction, indexed by facing direction of movement
524     const trans = [ [-1.4,0], [-1,-1], [0,-1.4], [1,-1], [1.4,0], [1,1], [0,1.4], [-1,1] ];
525     var k = 0;
526
527     // OR of direction key presses
528     k += (keys[kbdmap.left]  || keys[kbdmap.altleft])  ? 1 : 0;
529     k += (keys[kbdmap.right] || keys[kbdmap.altright]) ? 2 : 0;
530     k += (keys[kbdmap.up]    || keys[kbdmap.altup])    ? 4 : 0;
531     k += (keys[kbdmap.down]  || keys[kbdmap.altdown])  ? 8 : 0;
532
533     if (~dir[k]) {
534       hero.direct(dir[k]).run();
535       translate(trans[dir[k]][0], trans[dir[k]][1]);
536     } else hero.stance();
537
538     if (click[0]) {
539         let hx = map.tileX(hero.position[0], hero.position[1]);
540         let hy = map.tileY(hero.position[0], hero.position[1]);
541         let cx = map.tileX(click[0], click[1]);
542         let cy = map.tileY(click[0], click[1]);
543         if (   (cx == hx - 1 || cx == hx + 1 || cx == hx)
544             && (cy == hy - 1 || cy == hy + 1 || cy == hy))
545         triggers(click[0], click[1]);
546       click = [];
547     }
548   }
549 }
550
551 document.querySelector("body").setAttribute("style", "margin: 0; padding: 0;");
552 canvas = document.createElement("canvas").getContext("2d");
553 canvas.canvas.setAttribute("style", "display: block; border: 0; margin: 0; padding: 0;");
554 document.querySelector("body").appendChild(canvas.canvas);
555
556 canvas.canvas.width  = window.innerWidth;
557 canvas.canvas.height = window.innerHeight;
558 window.addEventListener("resize", function(){
559   canvas.canvas.width  = window.innerWidth;
560   canvas.canvas.height = window.innerHeight;
561 });
562
563 // player = new Sprite("animations/enemies/zombie.txt"); player.stats = { speed: 3 };
564 player = new Hero();
565 map = new Map("maps/perdition_harbor.txt");
566 player.place(map.xOf(map.info.header.hero_pos[0], map.info.header.hero_pos[1]),
567              map.yOf(map.info.header.hero_pos[0], map.info.header.hero_pos[1]));
568 player.direct(6).stance();
569
570 map.center(player.position[0], player.position[1]);
571 c = new Controls(player);
572
573 setInterval (() => map.draw([player]), 33.33 );