diff options
author | Juan J. Martinez <jjm@usebox.net> | 2025-02-09 17:25:59 +0000 |
---|---|---|
committer | Juan J. Martinez <jjm@usebox.net> | 2025-02-09 17:25:59 +0000 |
commit | 6f5fa0b5240d6edd3bce72ea1549e4cd9b0ce2bc (patch) | |
tree | 44ebe6f5cfb1eb9214c5877342c7914de07f215a /js | |
download | js-twin-shooter-6f5fa0b5240d6edd3bce72ea1549e4cd9b0ce2bc.tar.gz js-twin-shooter-6f5fa0b5240d6edd3bce72ea1549e4cd9b0ce2bc.zip |
Initial import
Diffstat (limited to 'js')
-rw-r--r-- | js/game.js | 293 | ||||
-rw-r--r-- | js/main.js | 11 | ||||
-rw-r--r-- | js/mygame.js | 535 |
3 files changed, 839 insertions, 0 deletions
diff --git a/js/game.js b/js/game.js new file mode 100644 index 0000000..6527584 --- /dev/null +++ b/js/game.js @@ -0,0 +1,293 @@ +const floor = Math.floor; +const max = Math.max; +const min = Math.min; + +class Game { + constructor(canvas, width, height) { + this.canvas = canvas; + this.canvas.width = width; + this.canvas.height = height; + this.ctx = this.canvas.getContext("2d"); + + // override this with your data to be loaded: + // + // { id: "file.png" } + // + // Supports .png, .ogg and .json files. + // + // and also override dataSize with: + // + // this.dataSize = Object.keys(this.data).length + // + this.data = {}; + this.dataSize = 0; + + // override to change keys + this.controls = { + 80: "pause", + 32: "start", + 37: "left", + 38: "up", + 39: "right", + 40: "down", + 87: "f_up", + 83: "f_down", + 65: "f_left", + 68: "f_right" + }; + + // will store the resouces indexed by id + this.res = {}; + this.resSize = 0; + + // override if you want to change the limit + // defaults to 8 "channels" + this.playLimit = 8; + this.playCount = 0; + + // test to see if a key is down or not + this.keys = { + pause: false, + start: false, + left: false, + up: false, + right: false, + down: false, + f_up: false, + f_down: false, + f_left: false, + f_right: false + }; + + this.gamepads = {}; + + this.minFps = 60; + this.then = -1 / this.minFps; + + this.loadingError = undefined; + } + + start() { + this.canvas.style.background = "rgb(21, 21, 21)"; + this.resize(); + + window.onresize = (ev) => { + this.resize(); + }; + + window.addEventListener("gamepadconnected", (e) => { + console.log( + "Gamepad connected at index %d: %s. %d buttons, %d axes.", + e.gamepad.index, + e.gamepad.id, + e.gamepad.buttons.length, + e.gamepad.axes.length, + ); + this.gamepads[e.gamepad.index] = e.gamepad; + }); + window.addEventListener("gamepaddisconnected", (e) => { + console.log( + "Gamepad disconnected from index %d: %s", + e.gamepad.index, + e.gamepad.id, + ); + delete this.gamepads[e.gamepad.index]; + }); + + this.loader(); + } + + // override this + init() {} + // override this + update(dt) {} + // override this + draw() {} + + _update(dt) {} + _draw() {} + + resize() { + const scale = floor(window.innerHeight / this.canvas.height); + this.canvas.style["imageRendering"] = "pixelated"; + this.canvas.style["transformOrigin"] = "top"; + this.canvas.style["transform"] = `scale(${scale})`; + } + + loop(now) { + + Object.keys(this.gamepads).forEach((g) => { + const gp = this.gamepads[g]; + + this.keys["f_up"] = gp.buttons[2].pressed; + this.keys["f_down"] = gp.buttons[0].pressed; + this.keys["f_left"] = gp.buttons[3].pressed; + this.keys["f_right"] = gp.buttons[1].pressed; + + if (gp.buttons[14].pressed) { + this.keys["left"] = true; + this.keys["right"] = false; + } else { + if (gp.buttons[15].pressed) { + this.keys["left"] = false; + this.keys["right"] = true; + } else { + this.keys["left"] = false; + this.keys["right"] = false; + } + } + + if (gp.buttons[12].pressed) { + this.keys["up"] = true; + this.keys["down"] = false; + } else { + if (gp.buttons[13].pressed) { + this.keys["up"] = false; + this.keys["down"] = true; + } else { + this.keys["up"] = false; + this.keys["down"] = false; + } + } + }); + + let dt = min(1000 / this.minFps, now - this.then); + this._update(dt/1000); + + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.ctx.save(); + this._draw(); + this.ctx.restore(); + + this.then = now; + requestAnimationFrame((now) => { + this.loop(now) + }); + } + + loader() { + this._draw = this.drawLoading; + this.loop(0); + + const onError = (ev) => { + console.log(ev); + this.loadingError = true; + }; + + for (const id in this.data) { + if (this.data[id].indexOf(".png") != -1) { + this.res[id] = new Image(); + this.res[id].src = this.data[id]; + this.res[id].onerror = onError + this.res[id].onload = (ev) => { + this.res[id].onload = null; + ev.currentTarget.removeEventListener("error", onError); + this.resSize += 1; + }; + continue; + } + if (this.data[id].indexOf(".ogg") != -1) { + this.res[id] = new Audio(); + this.res[id].src = this.data[id]; + this.res[id].autoplay = false; + this.res[id].addEventListener("error", onError); + this.res[id].oncanplaythrough = (ev) => { + this.res[id].oncanplaythrough = null; + ev.currentTarget.removeEventListener("error", onError); + this.resSize += 1; + }; + continue; + } + if (this.data[id].indexOf(".json") != -1) { + let xhr = new window.XMLHttpRequest(); + xhr.open("GET", this.data[id]); + xhr.responseType = "json"; + xhr.addEventListener("error", onError); + xhr.onload = (ev) => { + ev.currentTarget.removeEventListener("error", onError); + xhr.onload = null; + if (xhr.status == 200) { + this.res[id] = xhr.response; + this.resSize += 1; + } else { + console.log(ev); + this.loadingError = true; + } + }; + xhr.send(); + continue; + } + } + } + + // won't use drawStart/drawEnd because may render regular fonts + drawLoading() { + // height of the loading bar + const h = 6; + + if (this.loadingError == true) { + this.ctx.fillStyle = "rgb(255, 128, 128)"; + this.ctx.font = "8px monospace"; + this.ctx.fillText( + "ERROR Loading Resources", + floor(this.ctx.canvas.width * 0.1), + floor(this.ctx.canvas.height / 2 - h - h / 2) + ); + } + + this.ctx.fillStyle = "rgb(128, 128, 128)"; + this.ctx.fillRect(floor(this.ctx.canvas.width * 0.1), floor(this.ctx.canvas.height / 2) - h, this.ctx.canvas.width - floor(this.ctx.canvas.width * 0.2), h); + this.ctx.fillStyle = "rgb(255, 255, 255)"; + this.ctx.fillRect(floor(this.ctx.canvas.width * 0.1), floor(this.ctx.canvas.height / 2) - h, ( + floor(this.ctx.canvas.resSize * (this.ctx.canvas.width - floor(this.ctx.canvas.width * 0.2)) / this.ctx.canvas.dataSize) + ), h); + + // we don't do this on update because we want + // the progress bar to finish drawing + if (this.resSize == this.dataSize) { + console.log("Loader done"); + + document.addEventListener("keydown", (ev) => { + this.keyDown(ev) + }, false); + document.addEventListener("keyup", (ev) => { + this.keyUp(ev) + }, false); + + this.init(); + this._update = this.update; + this._draw = this.draw; + } + } + + playSnd(playable, loop, clone) { + if (this.playCount < this.playLimit) { + this.playCount++; + + if (clone || false) { + playable = playable.cloneNode(true) + } + playable.onended = (ev) => { + playable.onended = null; + this.playCount--; + }; + playable.loop = loop || false; + playable.play(); + } + } + + keyDown(ev) { + let key = this.controls[ev.keyCode]; + if (key != undefined) { + this.keys[key] = true; + ev.preventDefault(); + } + } + + keyUp(ev) { + let key = this.controls[ev.keyCode]; + if (key != undefined) { + this.keys[key] = false; + } + } +} diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..808ff4a --- /dev/null +++ b/js/main.js @@ -0,0 +1,11 @@ +window.addEventListener("DOMContentLoaded", (ev) => { + var canvas = document.getElementById("game"); + if (canvas.getContext) { + var game = new MyGame(canvas); + game.start(); + } else { + canvas.style.backgroundColor = "rgb(21, 21, 21)"; + canvas.insertAdjacentHTML("afterend", "<p>No Canvas 2D support found, please try a different browser.</p>"); + console.log("No canvas 2D support found"); + } +}); diff --git a/js/mygame.js b/js/mygame.js new file mode 100644 index 0000000..e4453ff --- /dev/null +++ b/js/mygame.js @@ -0,0 +1,535 @@ +const CANVAS_SIZE = { w: 160, h: 160 }; +const FONT_SIZE = { w: 8, h: 8}; +const SCORE_W = 6; + +const DIR_RIGHT = 0; +const DIR_LEFT = 1; +const WALK_FRAMES = [ 0, 1, 0, 2 ]; +const FDELAY = 8; + +const F_UP = 0; +const F_DOWN = 1; +const F_RIGHT = 2; +const F_LEFT = 3; + +const COOLDOWN = 10; + +const SPAWN_EFFECT_DELAY = 32; + +const DAMAGE_SND_DELAY = 16; + +const MAX_LIFE = 20; + +const WAVES = [ + { delay: 128, count: 8 }, + { delay: 100, count: 8 }, + { delay: 80, count: 16 }, + { delay: 60, count: 32 }, + { delay: 50, count: 64 }, + { delay: 40, count: 64 }, + { delay: 20, count: 96 }, + { delay: 15, count: 96 } +]; + +function mkCanvasText(font, text) { + let c = document.createElement("canvas"); + c.width = text.length * FONT_SIZE.w; + c.height = FONT_SIZE.h; + let ctx = c.getContext("2d"); + + ctx.clearRect(0, 0, c.width, c.height); + for(let i = 0; i < text.length; i++) { + /* our font starts with space, that's ascii 32 */ + let index = text.charCodeAt(i) - 32; + ctx.drawImage(font, + 0, index * FONT_SIZE.h, FONT_SIZE.w, FONT_SIZE.h, + i * FONT_SIZE.w, 0, FONT_SIZE.w, FONT_SIZE.h); + } + return c; +} + + +class Entity { + constructor() { + this.alive = true; + this.x = 0; + this.y = 0; + this.size = { w: 0, h: 0, r: 0 }; + } + + collisionWith(other) { + let x = (other.x + other.size.w/2) - (this.x + this.size.w/2), + y = (other.y + other.size.h/2) - (this.y + this.size.h/2), + r = this.size.r + other.size.r; + if (x*x + y*y <= r*r) { + return true; + } + return false; + } +} + +class Enemy extends Entity { + constructor() { + super(); + } +} + +class Slime extends Enemy { + constructor(x, y, sprites, target) { + super(); + this.size = { w: 16, h: 16, r: 8 }; + this.x = x; + this.y = y; + this.target = target; + this.sprites = sprites; + this.frame = 0; + this.fdelay = 0; + } + + update(dt) { + this.fdelay++ + + if (this.fdelay & 1) { + let ix = 0, iy = 0; + let t = this.target(); + if (t.is_alive) { + if (this.x > t.x) { + ix--; + } else if (this.x < t.x) { + ix++; + } + + if (this.y > t.y) { + iy--; + } else if (this.y < t.y) { + iy++; + } + + this.x += ix; + this.y += iy; + } + } + + if (this.fdelay == FDELAY) { + this.fdelay = 0; + this.frame++; + if (this.frame == 3) { + this.frame = 0; + } + } + } + + draw(ctx) { + ctx.drawImage( + this.sprites, + this.frame * this.size.w, 0, this.size.w, this.size.w, + this.x, this.y, this.size.w, this.size.h); + } +} + +class Spawn extends Entity { + constructor(x, y, enemy, spawn) { + super(); + this.x = x; + this.y = y; + this.fdelay = SPAWN_EFFECT_DELAY; + this.delay = 0; + this.enemy = enemy; + this.spawn = spawn; + } + + update(dt) { + this.delay++; + if (this.delay == this.fdelay) { + this.spawn(this.enemy); + this.alive = false; + } + } + + draw(ctx) { + ctx.beginPath(); + ctx.arc(this.x, this.y, 1 + this.fdelay - this.delay, 0, 2 * Math.PI); + ctx.strokeStyle = `rgba(255, 255, 255, ${this.delay / (this.fdelay * 2)})`; + ctx.stroke(); + } +} + +class Explo extends Entity { + constructor(x, y, sprites) { + super(); + this.size = { w: 16, h: 16 }; + this.x = x; + this.y = y; + this.sprites = sprites; + this.frame = 0; + this.fdelay = 0; + } + + update(dt) { + this.fdelay++; + if (this.fdelay == FDELAY) + { + this.fdelay = 0; + this.frame++; + if (this.frame == 4) { + this.alive = false; + } + } + } + + draw(ctx) { + ctx.drawImage( + this.sprites, + this.frame * this.size.w, 0, this.size.w, this.size.w, + this.x, this.y, this.size.w, this.size.h); + } +} + +class Bullet extends Entity { + constructor(x, y, sprites, dir) { + super(); + this.size = { w: 16, h: 16, r: 2 }; + this.x = x; + this.y = y; + this.dir = dir; + this.sprites = sprites; + } + + update(dt) { + switch (this.dir) { + case F_UP: + this.y -= 2; + break; + case F_DOWN: + this.y += 2; + break; + case F_RIGHT: + this.x += 2; + break; + case F_LEFT: + this.x -= 2; + break; + } + + if (this.x < 0 || this.x > CANVAS_SIZE.w - this.size.w + || this.y < 0 || this.y > CANVAS_SIZE.h - this.size.h) { + this.alive = false; + } + } + + draw(ctx) { + ctx.drawImage( + this.sprites, + this.dir * this.size.w, 0, this.size.w, this.size.w, + this.x, this.y, this.size.w, this.size.h); + } +} + +class Player extends Entity { + constructor(x, y, sprites, ctl, add_bullet) { + super(); + this.size = { w: 16, h: 16, frames: 3, r: 8 }; + this.x = x; + this.y = y; + this.sprites = sprites; + this.ctl = ctl; + this.dir = DIR_RIGHT; + this.frame = 0; + this.fdelay = 0; + this.cooldown = 0; + this.add_bullet = add_bullet; + this.life = MAX_LIFE; + } + + is_alive() { + return this.life != 0; + } + + is_fire(d) { + const all = ["f_up", "f_down", "f_right", "f_left"]; + return this.ctl[d] && !all.filter((e) => e != d).some((e) => this.ctl[e]); + } + + update(dt) { + let ix = 0, iy = 0, walking = false; + if (this.ctl["up"]) { + if (this.y > 0) { + iy--; + } + walking = true; + } + if (this.ctl["down"]) { + if (this.y < CANVAS_SIZE.h - this.size.h) { + iy++; + } + walking = true; + } + if (this.ctl["left"]) { + if (this.x > 0) { + ix--; + } + walking = true; + } + if (this.ctl["right"]) { + if (this.x < CANVAS_SIZE.w - this.size.w) { + ix++; + } + walking = true; + } + + if (this.cooldown) { + this.cooldown--; + } else { + if (this.is_fire("f_right")) { + this.cooldown = COOLDOWN; + this.add_bullet(this.x + 4, this.y + 2, F_RIGHT); + } else if (this.is_fire("f_left")) { + this.cooldown = COOLDOWN; + this.add_bullet(this.x - 4, this.y + 2, F_LEFT); + } else if (this.is_fire("f_up")) { + this.cooldown = COOLDOWN; + this.add_bullet(this.x, this.y - 2, F_UP); + } else if (this.is_fire("f_down")) { + this.cooldown = COOLDOWN; + this.add_bullet(this.x, this.y + 4, F_DOWN); + } + } + + this.fdelay++; + if (this.fdelay == FDELAY) + { + this.fdelay = 0; + if (!walking) { + if (this.frame != 0) { + this.frame = 0; + } + } else { + this.frame++; + if (this.frame == WALK_FRAMES.length) { + this.frame = 0; + } + } + } + + if (ix > 0) + this.dir = DIR_RIGHT; + if (ix < 0) + this.dir = DIR_LEFT; + + this.y += iy; + this.x += ix; + } + + draw(ctx) { + ctx.drawImage( + this.sprites, + WALK_FRAMES[this.frame] * this.size.w + this.dir * this.size.w * this.size.frames, 0, this.size.w, this.size.w, + this.x, this.y, this.size.w, this.size.h); + } +} + +class MyGame extends Game { + constructor(canvas) { + super(canvas, CANVAS_SIZE.w, CANVAS_SIZE.h); + + this.data = { + player: "img/player.png", + bullets: "img/bullets.png", + slime: "img/slime.png", + explo: "img/explo.png", + spawn_snd: "snd/spawn.ogg", + explo_snd: "snd/explo.ogg", + fire_snd: "snd/fire.ogg", + damage_snd: "snd/damage.ogg", + font: "img/font.png" + }; + this.dataSize = Object.keys(this.data).length; + + this.player = undefined; + this.enemies = []; + this.effects = []; + this.player_fire = []; + + this.wave = 0; + this.wave_count = 0; + this.score = 0; + + this.damage_snd_delay = 0; + this.next_enemy = WAVES[this.wave].delay; + } + + updateScore(add) { + this.score += add; + let s = Array(Math.max(SCORE_W - String(this.score).length + 1, 0)).join(0) + this.score; + this.res["score"] = mkCanvasText(this.res["font"], s); + } + + updateWave() { + this.res["wave"] = mkCanvasText(this.res["font"], `WAVE ${this.wave + 1}`); + } + + + new_game() { + this.damage_snd_delay = 0; + this.next_enemy = WAVES[0].delay; + + this.enemies = []; + this.effects = []; + this.player_fire = []; + + this.wave = 0; + this.wave_count = 0; + + /* render the score */ + this.score = 0; + this.updateScore(0); + + /* and the wave */ + this.updateWave(); + + this.player = new Player(CANVAS_SIZE.w / 2 - 4, CANVAS_SIZE.h / 2 - 4, this.res["player"], this.keys, (x, y, d) => { + this.player_fire.push(new Bullet(x, y, this.res["bullets"], d)); + this.playSnd(this.res["fire_snd"], false, true); + }); + } + + init() { + this.res["game_over"] = mkCanvasText(this.res["font"], "Game Over"); + this.res["space_restart"] = mkCanvasText(this.res["font"], "(space to restart)"); + + this.new_game(); + } + + update(dt) { + if (this.player.is_alive()) { + this.player.update(dt); + } else { + if (this.keys["start"]) { + this.new_game(); + } + } + + this.enemies = this.enemies.filter((e) => { + if (e.alive) { + e.update(dt); + } + return e.alive; + }); + this.effects = this.effects.filter((e) => { + if (e.alive) { + e.update(dt); + } + return e.alive; + }); + this.player_fire = this.player_fire.filter((e) => { + if (e.alive) { + e.update(dt); + if (e.alive) { + this.enemies.find((other) => { + if (e.collisionWith(other)) { + e.alive = false; + other.alive = false; + this.effects.push(new Explo(other.x, other.y, this.res["explo"])); + this.playSnd(this.res["explo_snd"], false, true); + this.updateScore(10); + this.wave_count++; + return true; + } + return false; + }); + } + } + return e.alive; + }); + + let hit = false; + if (this.player.is_alive()) { + this.enemies.forEach((e) => { + if (e.collisionWith(this.player)) { + if (this.player.life > 0.1) { + this.player.life -= 0.1; + } else { + this.effects.push(new Explo(this.player.x, this.player.y, this.res["explo"])); + this.playSnd(this.res["explo_snd"], false, true); + this.player.life = 0; + } + hit = true; + } + }); + if (hit == true) { + if (this.damage_snd_delay < DAMAGE_SND_DELAY) { + this.damage_snd_delay++; + } else { + this.damage_snd_delay = 0; + this.playSnd(this.res["damage_snd"], false, true); + } + } else { + this.damage_snd_delay = 0; + if (this.player.life < MAX_LIFE) { + this.player.life += 0.01; + } + } + + if (this.next_enemy) { + this.next_enemy--; + } else { + if (this.wave_count < WAVES[this.wave].count) { + this.next_enemy = WAVES[this.wave].delay; + let x = Math.floor(Math.random() * CANVAS_SIZE.w), + y = Math.floor(Math.random() * CANVAS_SIZE.h); + let enemy = new Slime(x, y, this.res["slime"], () => { + return { x: this.player.x, y: this.player.y, is_alive: this.player.is_alive() }; + }); + this.effects.push(new Spawn(x + enemy.size.w/2, y + enemy.size.h/2, enemy, (e) => { + this.enemies.push(e); + })); + this.playSnd(this.res["spawn_snd"], false, true); + } else { + if (this.wave < WAVES.length) { + this.wave++; + this.updateWave(); + } + this.wave_count = 0; + } + } + } + } + + draw() { + this.enemies.forEach((e) => { + if (e.alive) { + e.draw(this.ctx); + } + }); + this.effects.forEach((e) => { + e.draw(this.ctx); + }); + this.player_fire.forEach((e) => { + e.draw(this.ctx); + }); + + if (this.player.is_alive()) { + this.player.draw(this.ctx); + } + + /* health bar */ + this.ctx.beginPath(); + this.ctx.fillStyle = "black"; + this.ctx.fillRect(4, 4, 42, 8); + this.ctx.fillStyle = "green"; + this.ctx.fillRect(5, 5, Math.floor(this.player.life * 40 / MAX_LIFE), 6); + this.ctx.stroke(); + + /* score */ + this.ctx.drawImage(this.res["score"], CANVAS_SIZE.w - SCORE_W * FONT_SIZE.w - 4, 4); + + /* wave */ + this.ctx.drawImage(this.res["wave"], 4, CANVAS_SIZE.h - 8 - 4); + + /* game over */ + if (this.player.life == 0) { + this.ctx.drawImage(this.res["game_over"], CANVAS_SIZE.w / 2 - 36, CANVAS_SIZE.h / 2 - 6); + this.ctx.drawImage(this.res["space_restart"], CANVAS_SIZE.w / 2 - 76, CANVAS_SIZE.h / 2 + 4); + } + } +} |