├── .gitignore ├── README.md ├── src ├── player.js ├── steel-wall.js ├── sprite.js ├── animation.js ├── wall.js ├── event-emitter.js ├── bullet-explosion.js ├── base.js ├── tank-explosion.js ├── explosion.js ├── input.js ├── player-tank.js ├── game.js ├── brick-wall.js ├── stages.js ├── enemy-tank.js ├── game-object.js ├── bullet.js ├── tank.js ├── constants.js ├── view.js └── stage.js ├── index.css ├── index.js └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | /assets -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Battle City 2 | -------------------------------------------------------------------------------- /src/player.js: -------------------------------------------------------------------------------- 1 | export default class Player { 2 | score = 0; 3 | lives = 2; 4 | } -------------------------------------------------------------------------------- /index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | background-color: black; 5 | min-width: 100vw; 6 | min-height: 100vh; 7 | display: flex; 8 | justify-content: center; 9 | align-items: center; 10 | } -------------------------------------------------------------------------------- /src/steel-wall.js: -------------------------------------------------------------------------------- 1 | import { STEEL_WALL_SPRITES } from './constants.js'; 2 | import Wall from './wall.js'; 3 | 4 | export default class SteelWall extends Wall { 5 | constructor(args) { 6 | super(args); 7 | 8 | this.sprites = STEEL_WALL_SPRITES; 9 | } 10 | 11 | hit(bullet) { 12 | if (this.isDestroyed) return; 13 | } 14 | } -------------------------------------------------------------------------------- /src/sprite.js: -------------------------------------------------------------------------------- 1 | export default class Sprite { 2 | constructor(src) { 3 | this.src = src; 4 | this.image = new Image(); 5 | } 6 | 7 | async load() { 8 | return new Promise((resolve, reject) => { 9 | this.image.src = this.src; 10 | this.image.addEventListener('load', () => resolve(this)); 11 | }); 12 | } 13 | } -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import Input from './src/input.js'; 2 | import View from './src/view.js'; 3 | import Game from './src/game.js'; 4 | import Sprite from './src/sprite.js'; 5 | 6 | const canvas = document.querySelector('canvas'); 7 | const sprite = new Sprite('./assets/sprite.png'); 8 | 9 | const game = new Game({ 10 | input: new Input(), 11 | view: new View(canvas, sprite) 12 | }); 13 | 14 | game.init().then(() => game.start()); 15 | 16 | console.log(game); -------------------------------------------------------------------------------- /src/animation.js: -------------------------------------------------------------------------------- 1 | export default class Animation { 2 | constructor({ sprites = [], speed = 0 }) { 3 | this.sprites = sprites; 4 | this.speed = speed; 5 | this.frame = 0; 6 | this.frames = 0; 7 | } 8 | 9 | run(frameDelta) { 10 | this.frames += frameDelta; 11 | 12 | if (this.frames > this.speed) { 13 | this.frame = (this.frame + 1) % (this.sprites.length + 1); 14 | this.frames = 0; 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /src/wall.js: -------------------------------------------------------------------------------- 1 | import { TILE_SIZE } from './constants.js'; 2 | 3 | import GameObject from './game-object.js'; 4 | 5 | export default class Wall extends GameObject { 6 | constructor({ type, ...rest }) { 7 | super(rest); 8 | 9 | this.type = 'wall'; 10 | this.width = TILE_SIZE; 11 | this.height = TILE_SIZE; 12 | this.spriteIndex = 0; 13 | this.damage = 0; 14 | } 15 | 16 | get sprite() { 17 | return this.sprites[this.spriteIndex]; 18 | } 19 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Battle City 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/event-emitter.js: -------------------------------------------------------------------------------- 1 | export default class EventManager { 2 | events = new Map(); 3 | 4 | on(event, handler) { 5 | if (this.events.has(event)) { 6 | this.events.get(event).add(handler); 7 | } else { 8 | this.events.set(event, new Set([handler])); 9 | } 10 | } 11 | 12 | off(event, handler) { 13 | this.events.get(event)?.delete(handler); 14 | } 15 | 16 | emit(event, arg) { 17 | this.events.get(event)?.forEach(handler => handler(arg)); 18 | } 19 | } -------------------------------------------------------------------------------- /src/bullet-explosion.js: -------------------------------------------------------------------------------- 1 | import { BULLET_EXPLOSION_WIDTH, BULLET_EXPLOSION_HEIGHT, BULLET_EXPLOSION_ANIMATION_SPEED, BULLET_EXPLOSION_SPRITES } from './constants.js'; 2 | import Explosion from './explosion.js'; 3 | 4 | export default class BulletExplosion extends Explosion { 5 | constructor(args) { 6 | super(args); 7 | 8 | this.width = BULLET_EXPLOSION_WIDTH; 9 | this.height = BULLET_EXPLOSION_HEIGHT; 10 | this.sprites = BULLET_EXPLOSION_SPRITES; 11 | this.animationSpeed = BULLET_EXPLOSION_ANIMATION_SPEED; 12 | } 13 | } -------------------------------------------------------------------------------- /src/base.js: -------------------------------------------------------------------------------- 1 | import { BASE_POSITION, BASE_WIDTH, BASE_HEIGHT, BASE_SPRITES } from './constants.js'; 2 | import GameObject from './game-object.js'; 3 | 4 | export default class Base extends GameObject { 5 | constructor(args) { 6 | super(args); 7 | 8 | this.x = BASE_POSITION[0]; 9 | this.y = BASE_POSITION[1]; 10 | this.width = BASE_WIDTH; 11 | this.height = BASE_HEIGHT; 12 | this.sprites = BASE_SPRITES; 13 | this.destroyed = false; 14 | } 15 | 16 | get sprite() { 17 | return this.sprites[Number(this.destroyed)]; 18 | } 19 | 20 | hit() { 21 | this.emit('destroyed', this); 22 | } 23 | } -------------------------------------------------------------------------------- /src/tank-explosion.js: -------------------------------------------------------------------------------- 1 | import { TANK_EXPLOSION_ANIMATION_SPEED, TANK_EXPLOSION_SPRITES } from './constants.js'; 2 | import Explosion from './explosion.js'; 3 | 4 | export default class TankExplosion extends Explosion { 5 | constructor(args) { 6 | super(args); 7 | 8 | this.sprites = TANK_EXPLOSION_SPRITES; 9 | this.animationSpeed = TANK_EXPLOSION_ANIMATION_SPEED; 10 | } 11 | 12 | get x() { 13 | return super.x - this.width * 0.5; 14 | } 15 | 16 | get y() { 17 | return super.y - this.height * 0.5; 18 | } 19 | 20 | get width() { 21 | return this.sprite && this.sprite[2]; 22 | } 23 | 24 | get height() { 25 | return this.sprite && this.sprite[3]; 26 | } 27 | } -------------------------------------------------------------------------------- /src/explosion.js: -------------------------------------------------------------------------------- 1 | import GameObject from './game-object.js'; 2 | 3 | export default class Explosion extends GameObject { 4 | constructor(args) { 5 | super(args); 6 | 7 | this.type = 'explosion'; 8 | } 9 | 10 | get sprite() { 11 | return this.sprites[this.animationFrame]; 12 | } 13 | 14 | get isExploding() { 15 | return this.animationFrame < this.sprites.length; 16 | } 17 | 18 | update({ frameDelta }) { 19 | if (this.isExploding) { 20 | this.animate(frameDelta); 21 | } else { 22 | this.destroy(); 23 | } 24 | } 25 | 26 | animate(frameDelta) { 27 | this.frames += frameDelta; 28 | 29 | if (this.frames > this.animationSpeed) { 30 | this.animationFrame = (this.animationFrame + 1) % (this.sprites.length + 1); 31 | this.frames = 0; 32 | } 33 | } 34 | 35 | hit() { 36 | return; 37 | } 38 | 39 | destroy() { 40 | this.emit('destroyed', this); 41 | } 42 | } -------------------------------------------------------------------------------- /src/input.js: -------------------------------------------------------------------------------- 1 | export default class Input { 2 | constructor() { 3 | this.keys = new Set(); 4 | this.init(); 5 | } 6 | 7 | init() { 8 | document.addEventListener('keydown', event => { 9 | switch (event.code) { 10 | case 'ArrowUp': 11 | case 'ArrowRight': 12 | case 'ArrowDown': 13 | case 'ArrowLeft': 14 | case 'Space': 15 | case 'Enter': 16 | event.preventDefault(); 17 | this.keys.add(event.code); 18 | } 19 | }); 20 | 21 | document.addEventListener('keyup', event => { 22 | switch (event.code) { 23 | case 'ArrowUp': 24 | case 'ArrowRight': 25 | case 'ArrowDown': 26 | case 'ArrowLeft': 27 | case 'Space': 28 | case 'Enter': 29 | event.preventDefault(); 30 | this.keys.delete(event.code); 31 | } 32 | }); 33 | } 34 | 35 | has(...arg) { 36 | return Array.isArray(arg) ? 37 | arg.some(key => this.keys.has(key)) : 38 | this.keys.has(arg); 39 | } 40 | } -------------------------------------------------------------------------------- /src/player-tank.js: -------------------------------------------------------------------------------- 1 | import { Key, Direction, PLAYER1_TANK_POSITION, PLAYER1_TANK_SPRITES, TANK_SPEED } from './constants.js'; 2 | import Tank from './tank.js'; 3 | 4 | export default class PlayerTank extends Tank { 5 | constructor(args) { 6 | super(args); 7 | 8 | this.type = 'playerTank'; 9 | this.x = PLAYER1_TANK_POSITION[0]; 10 | this.y = PLAYER1_TANK_POSITION[1]; 11 | this.direction = Direction.UP; 12 | this.speed = TANK_SPEED; 13 | this.sprites = PLAYER1_TANK_SPRITES; 14 | } 15 | 16 | update({ input, frameDelta, world }) { 17 | if (input.has(Key.UP, Key.RIGHT, Key.DOWN, Key.LEFT)) { 18 | const direction = Tank.getDirectionForKeys(input.keys); 19 | const axis = Tank.getAxisForDirection(direction); 20 | const value = Tank.getValueForDirection(direction); 21 | 22 | this.turn(direction); 23 | this.move(axis, value); 24 | this.animate(frameDelta); 25 | 26 | const isOutOfBounds = world.isOutOfBounds(this); 27 | const hasCollision = world.hasCollision(this); 28 | 29 | if (isOutOfBounds || hasCollision) { 30 | this.move(axis, -value); 31 | } 32 | } 33 | 34 | if (input.keys.has(Key.SPACE)) { 35 | this.fire(); 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /src/game.js: -------------------------------------------------------------------------------- 1 | import stages from './stages.js'; 2 | import Stage from './stage.js'; 3 | 4 | export default class Game { 5 | constructor({ input, view }) { 6 | this.input = input; 7 | this.view = view; 8 | this.stages = stages; 9 | this.player1 = null; 10 | this.player2 = null; 11 | this.stage = null; 12 | this.stageIndex = 0; 13 | this.frames = 0; 14 | this.lastFrame = 0; 15 | 16 | this.loop = this.loop.bind(this); 17 | this.onGameOver = this.onGameOver.bind(this); 18 | } 19 | 20 | async init() { 21 | await this.view.init(); 22 | } 23 | 24 | start() { 25 | this.stage = new Stage(stages[this.stageIndex]); 26 | this.stage.number = this.stageIndex + 1; 27 | this.stage.on('gameOver', this.onGameOver); 28 | 29 | requestAnimationFrame(this.loop); 30 | } 31 | 32 | loop(currentFrame) { 33 | const frameDelta = currentFrame - this.lastFrame; 34 | 35 | this.stage.update(this.input, frameDelta); 36 | this.view.update(this.stage, this.player1, this.player2); 37 | this.frames = 0; 38 | 39 | this.lastFrame = currentFrame; 40 | 41 | requestAnimationFrame(this.loop); 42 | } 43 | 44 | onGameOver() { 45 | // show game over screen 46 | console.log('GAME OVER'); 47 | } 48 | } -------------------------------------------------------------------------------- /src/brick-wall.js: -------------------------------------------------------------------------------- 1 | import { Direction, BRICK_WALL_SPRITES, BRICK_WALL_SPRITE_MAP } from './constants.js'; 2 | import Wall from './wall.js'; 3 | 4 | export default class BrickWall extends Wall { 5 | constructor(args) { 6 | super(args); 7 | 8 | this.sprites = BRICK_WALL_SPRITES; 9 | this.state = 0b0000; 10 | this.isDestructable = true; 11 | this.isDestroyed = false; 12 | this.lastHitDirection = -1; 13 | } 14 | 15 | get sprite() { 16 | return this.sprites[BRICK_WALL_SPRITE_MAP[this.state]]; 17 | } 18 | 19 | update({ world }) { 20 | if (this.isDestroyed) { 21 | world.objects.delete(this); 22 | } 23 | } 24 | 25 | hit(bullet) { 26 | if (this.isDestroyed) return; 27 | 28 | this.damage += 1; 29 | 30 | if (this.damage === 2) { 31 | this.isDestroyed = true; 32 | } 33 | 34 | switch (bullet.direction) { 35 | case Direction.UP: 36 | this.state = this.state | 0b0001; 37 | break; 38 | case Direction.RIGHT: 39 | this.state = this.state | 0b0010; 40 | break; 41 | case Direction.DOWN: 42 | this.state = this.state | 0b0100; 43 | break; 44 | case Direction.LEFT: 45 | this.state = this.state | 0b1000; 46 | break; 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /src/stages.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | map: [ 4 | [, , , , , , , , , , , , , , , , , , , , , , , , ,], 5 | [, , , , , , , , , , , , , , , , , , , , , , , , ,], 6 | [, , 1, 1, , , 1, 1, , , 1, 1, , , 1, 1, , , 1, 1, , , 1, 1, ,], 7 | [, , 1, 1, , , 1, 1, , , 1, 1, , , 1, 1, , , 1, 1, , , 1, 1, ,], 8 | [, , 1, 1, , , 1, 1, , , 1, 1, , , 1, 1, , , 1, 1, , , 1, 1, ,], 9 | [, , 1, 1, , , 1, 1, , , 1, 1, , , 1, 1, , , 1, 1, , , 1, 1, ,], 10 | [, , 1, 1, , , 1, 1, , , 1, 1, 2, 2, 1, 1, , , 1, 1, , , 1, 1, ,], 11 | [, , 1, 1, , , 1, 1, , , 1, 1, 2, 2, 1, 1, , , 1, 1, , , 1, 1, ,], 12 | [, , 1, 1, , , 1, 1, , , 1, 1, , , 1, 1, , , 1, 1, , , 1, 1, ,], 13 | [, , 1, 1, , , 1, 1, , , , , , , , , , , 1, 1, , , 1, 1, ,], 14 | [, , 1, 1, , , 1, 1, , , , , , , , , , , 1, 1, , , 1, 1, ,], 15 | [, , , , , , , , , , 1, 1, , , 1, 1, , , , , , , , , ,], 16 | [, , , , , , , , , , 1, 1, , , 1, 1, , , , , , , , , ,], 17 | [1, 1, , , 1, 1, 1, 1, , , , , , , , , , , 1, 1, 1, 1, , , 1, 1], 18 | [2, 2, , , 1, 1, 1, 1, , , , , , , , , , , 1, 1, 1, 1, , , 2, 2], 19 | [, , , , , , , , , , 1, 1, , , 1, 1, , , , , , , , , ,], 20 | [, , , , , , , , , , 1, 1, 1, 1, 1, 1, , , , , , , , , ,], 21 | [, , 1, 1, , , 1, 1, , , 1, 1, 1, 1, 1, 1, , , 1, 1, , , 1, 1, ,], 22 | [, , 1, 1, , , 1, 1, , , 1, 1, , , 1, 1, , , 1, 1, , , 1, 1, ,], 23 | [, , 1, 1, , , 1, 1, , , 1, 1, , , 1, 1, , , 1, 1, , , 1, 1, ,], 24 | [, , 1, 1, , , 1, 1, , , 1, 1, , , 1, 1, , , 1, 1, , , 1, 1, ,], 25 | [, , 1, 1, , , 1, 1, , , , , , , , , , , 1, 1, , , 1, 1, ,], 26 | [, , 1, 1, , , 1, 1, , , , , , , , , , , 1, 1, , , 1, 1, ,], 27 | [, , 1, 1, , , 1, 1, , , , 1, 1, 1, 1, , , , 1, 1, , , 1, 1, ,], 28 | [, , , , , , , , , , , 1, , , 1, , , , , , , , , , ,], 29 | [, , , , , , , , , , , 1, , , 1, , , , , , , , , , ,] 30 | ], 31 | enemies: [] 32 | //enemies: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1] 33 | } 34 | ]; -------------------------------------------------------------------------------- /src/enemy-tank.js: -------------------------------------------------------------------------------- 1 | import { Direction, ENEMY_TANK_START_POSITIONS, ENEMY_TANK_SPRITES, ENEMY_TANK_SPEED, ENEMY_TANK_TURN_TIMER_THRESHOLD } from './constants.js'; 2 | import Tank from './tank.js'; 3 | 4 | export default class EnemyTank extends Tank { 5 | static createRandom() { 6 | const random = Math.floor(Math.random() * 3); 7 | const [x, y] = ENEMY_TANK_START_POSITIONS[random]; 8 | const sprites = ENEMY_TANK_SPRITES[0]; 9 | 10 | return new EnemyTank({ x, y, sprites }); 11 | } 12 | 13 | constructor(args) { 14 | super(args); 15 | 16 | this.type = 'enemyTank'; 17 | this.x = 0; 18 | this.y = 0; 19 | this.direction = Direction.DOWN; 20 | this.speed = ENEMY_TANK_SPEED; 21 | this.sprites = ENEMY_TANK_SPRITES[0]; 22 | 23 | this.turnTimer = 0; 24 | } 25 | 26 | setPosition(positionIndex) { 27 | this.x = ENEMY_TANK_START_POSITIONS[positionIndex][0]; 28 | this.y = ENEMY_TANK_START_POSITIONS[positionIndex][1]; 29 | } 30 | 31 | update({ world, frameDelta }) { 32 | if (this.isDestroyed) { 33 | this.explode(); 34 | this.destroy(); 35 | } 36 | 37 | const direction = this.direction; 38 | const axis = Tank.getAxisForDirection(direction); 39 | const value = Tank.getValueForDirection(direction); 40 | 41 | this.move(axis, value); 42 | this.fire(); 43 | this.animate(frameDelta); 44 | 45 | const isOutOfBounds = world.isOutOfBounds(this); 46 | const hasCollision = world.hasCollision(this); 47 | 48 | if (isOutOfBounds || hasCollision) { 49 | this.move(axis, -value); 50 | 51 | if (this.shouldTurn(frameDelta)) { 52 | this.turnRandomly(); 53 | } 54 | } 55 | } 56 | 57 | hit(bullet) { 58 | if (bullet.isFromEnemyTank) return; 59 | 60 | super.hit(); 61 | } 62 | 63 | shouldTurn(frameDelta) { 64 | this.turnTimer += frameDelta; 65 | 66 | return this.turnTimer > ENEMY_TANK_TURN_TIMER_THRESHOLD; 67 | } 68 | 69 | turnRandomly() { 70 | const randomDirection = Math.floor(Math.random() * 4); 71 | 72 | this.turnTimer = 0; 73 | this.turn(randomDirection); 74 | } 75 | } -------------------------------------------------------------------------------- /src/game-object.js: -------------------------------------------------------------------------------- 1 | import { Direction } from './constants.js'; 2 | import EventEmitter from './event-emitter.js'; 3 | 4 | export default class GameObject extends EventEmitter { 5 | static getDirectionForKeys(keys) { 6 | if (keys.has('ArrowUp')) { 7 | return Direction.UP; 8 | } else if (keys.has('ArrowRight')) { 9 | return Direction.RIGHT; 10 | } else if (keys.has('ArrowDown')) { 11 | return Direction.DOWN; 12 | } else if (keys.has('ArrowLeft')) { 13 | return Direction.LEFT; 14 | } 15 | } 16 | 17 | static getAxisForDirection(direction) { 18 | return direction % 2 === 0 ? 'y' : 'x'; 19 | } 20 | 21 | static getValueForDirection(direction) { 22 | switch (direction) { 23 | case Direction.UP: return -1; 24 | case Direction.RIGHT: return 1; 25 | case Direction.DOWN: return 1; 26 | case Direction.LEFT: return -1; 27 | } 28 | } 29 | 30 | static getSideForDirection(direction) { 31 | switch (direction) { 32 | case Direction.UP: return 'top'; 33 | case Direction.RIGHT: return 'right'; 34 | case Direction.DOWN: return 'bottom'; 35 | case Direction.LEFT: return 'left'; 36 | } 37 | } 38 | 39 | constructor({ x, y, width, height, sprites } = {}) { 40 | super(); 41 | 42 | this._x = x; 43 | this._y = y; 44 | this._width = width; 45 | this._height = height; 46 | this.sprites = sprites; 47 | this.animationFrame = 0; 48 | this.animationSpeed = 0; 49 | this.frames = 0; 50 | this.isDestructable = false; 51 | this.isDestroyed = false; 52 | } 53 | 54 | get x() { 55 | return this._x; 56 | } 57 | 58 | set x(value) { 59 | this._x = value; 60 | } 61 | 62 | get y() { 63 | return this._y; 64 | } 65 | 66 | set y(value) { 67 | this._y = value; 68 | } 69 | 70 | get width() { 71 | return this._width; 72 | } 73 | 74 | set width(value) { 75 | this._width = value; 76 | } 77 | 78 | get height() { 79 | return this._height; 80 | } 81 | 82 | set height(value) { 83 | this._height = value; 84 | } 85 | 86 | get top() { 87 | return this.y; 88 | } 89 | 90 | get right() { 91 | return this.x + this.width; 92 | } 93 | 94 | get bottom() { 95 | return this.y + this.height; 96 | } 97 | 98 | get left() { 99 | return this.x; 100 | } 101 | 102 | update() { 103 | 104 | } 105 | 106 | move(axis, value) { 107 | this[axis] += value * this.speed; 108 | } 109 | 110 | stop() { 111 | this.speed = 0; 112 | } 113 | } -------------------------------------------------------------------------------- /src/bullet.js: -------------------------------------------------------------------------------- 1 | import { BULLET_WIDTH, BULLET_HEIGHT, BULLET_SPRITES, Direction } from './constants.js'; 2 | import GameObject from './game-object.js'; 3 | import BulletExplosion from './bullet-explosion.js'; 4 | 5 | export default class Bullet extends GameObject { 6 | constructor({ tank, direction, speed, ...args }) { 7 | super(args); 8 | 9 | this.type = 'bullet'; 10 | this.width = BULLET_WIDTH; 11 | this.height = BULLET_HEIGHT; 12 | this.sprites = BULLET_SPRITES; 13 | this.direction = direction; 14 | this.speed = speed; 15 | this.tank = tank; 16 | this.explosion = null; 17 | } 18 | 19 | get sprite() { 20 | return this.sprites[this.direction]; 21 | } 22 | 23 | get isExploding() { 24 | return Boolean(this.explosion); 25 | } 26 | 27 | get isFromEnemyTank() { 28 | return this.tank?.type === 'enemyTank'; 29 | } 30 | 31 | get isFromPlayerTank() { 32 | return this.tank?.type === 'playerTank'; 33 | } 34 | 35 | update({ world }) { 36 | if (this.isExploding) return; 37 | 38 | const axis = GameObject.getAxisForDirection(this.direction); 39 | const value = GameObject.getValueForDirection(this.direction); 40 | 41 | this.move(axis, value); 42 | 43 | if (world.isOutOfBounds(this)) return this.stop(); 44 | 45 | const collision = world.getCollision(this); 46 | 47 | if (collision) { 48 | this.collide(collision.objects); 49 | } 50 | } 51 | 52 | collide(objects) { 53 | let shouldExplode = false; 54 | 55 | for (const object of objects) { 56 | if (!this.shouldCollide(object)) continue; 57 | 58 | object.hit(this); 59 | shouldExplode = this.shouldExplode(object); 60 | } 61 | 62 | if (shouldExplode) { 63 | this.stop(); 64 | this.explode(); 65 | } 66 | } 67 | 68 | shouldCollide(object) { 69 | return ( 70 | object.type === 'wall' || 71 | (object.type === 'playerTank' && this.isFromEnemyTank) || 72 | (object.type === 'enemyTank' && this.isFromPlayerTank) || 73 | (object.type === 'bullet' && this.isFromEnemyTank && object.isFromPlayerTank) || 74 | (object.type === 'bullet' && this.isFromPlayerTank && object.isFromEnemyTank) 75 | ); 76 | } 77 | 78 | shouldExplode(object) { 79 | return object.type !== 'bullet'; 80 | } 81 | 82 | hit() { 83 | this.stop(); 84 | this.destroy(); 85 | } 86 | 87 | explode() { 88 | const [x, y] = this.getExplosionStartingPosition(); 89 | 90 | this.explosion = new BulletExplosion({ x, y }); 91 | this.explosion.on('destroyed', () => this.destroy()); 92 | this.emit('explode', this.explosion); 93 | } 94 | 95 | destroy() { 96 | this.tank = null; 97 | this.explosion = null; 98 | this.emit('destroyed', this); 99 | } 100 | 101 | getExplosionStartingPosition() { 102 | switch (this.direction) { 103 | case Direction.UP: return [this.left - 10, this.top - 12]; 104 | case Direction.RIGHT: return [this.right - 16, this.top - 12]; 105 | case Direction.DOWN: return [this.left - 10, this.bottom - 16]; 106 | case Direction.LEFT: return [this.left - 16, this.top - 12]; 107 | } 108 | } 109 | } -------------------------------------------------------------------------------- /src/tank.js: -------------------------------------------------------------------------------- 1 | import { Direction, TILE_SIZE, TANK_WIDTH, TANK_HEIGHT, TANK_SPEED, TANK_TURN_THRESHOLD } from './constants.js'; 2 | import GameObject from './game-object.js'; 3 | import Bullet from './bullet.js'; 4 | import TankExplosion from './tank-explosion.js'; 5 | 6 | export default class Tank extends GameObject { 7 | constructor(args) { 8 | super(args); 9 | 10 | this.width = TANK_WIDTH; 11 | this.height = TANK_HEIGHT; 12 | this.speed = TANK_SPEED; 13 | this.bulletSpeed = 4; 14 | this.bullet = null; 15 | this.explosion = null; 16 | } 17 | 18 | get sprite() { 19 | return this.sprites[this.direction * 2 + this.animationFrame]; 20 | } 21 | 22 | get isExploding() { 23 | return Boolean(this.explosion?.isExploding); 24 | } 25 | 26 | turn(direction) { 27 | const prevDirection = this.direction; 28 | 29 | this.direction = direction; 30 | 31 | if (direction === Direction.UP || direction === Direction.DOWN) { 32 | const deltaRight = this.x % TILE_SIZE; 33 | const deltaLeft = TILE_SIZE - deltaRight; 34 | 35 | if (prevDirection === Direction.RIGHT) { 36 | if (deltaRight >= TANK_TURN_THRESHOLD) { 37 | this.x += deltaLeft; 38 | } else { 39 | this.x -= deltaRight; 40 | } 41 | } else if (prevDirection === Direction.LEFT) { 42 | if (deltaLeft >= TANK_TURN_THRESHOLD) { 43 | this.x -= deltaRight; 44 | } else { 45 | this.x += deltaLeft; 46 | } 47 | } 48 | } else { 49 | const deltaBottom = this.y % TILE_SIZE; 50 | const deltaTop = TILE_SIZE - deltaBottom; 51 | 52 | if (prevDirection === Direction.UP) { 53 | if (deltaTop >= TANK_TURN_THRESHOLD) { 54 | this.y -= deltaBottom; 55 | } else { 56 | this.y += deltaTop; 57 | } 58 | } else if (prevDirection === Direction.DOWN) { 59 | if (deltaBottom >= TANK_TURN_THRESHOLD) { 60 | this.y += deltaTop; 61 | } else { 62 | this.y -= deltaBottom; 63 | } 64 | } 65 | } 66 | } 67 | 68 | animate(frameDelta) { 69 | this.frames += frameDelta; 70 | 71 | if (this.frames > 20) { 72 | this.animationFrame ^= 1; 73 | this.frames = 0; 74 | } 75 | } 76 | 77 | fire() { 78 | if (!this.bullet) { 79 | const [x, y] = this.getBulletStartingPosition(); 80 | 81 | this.bullet = new Bullet({ 82 | x, 83 | y, 84 | tank: this, 85 | direction: this.direction, 86 | speed: this.bulletSpeed 87 | }); 88 | 89 | this.bullet.on('destroyed', () => { 90 | this.bullet = null; 91 | }); 92 | 93 | this.emit('fire', this.bullet); 94 | } 95 | } 96 | 97 | hit() { 98 | this.explode(); 99 | this.destroy(); 100 | } 101 | 102 | explode() { 103 | if (this.isExploding) return; 104 | 105 | const [x, y] = this.getExplosionStartingPosition(); 106 | 107 | this.explosion = new TankExplosion({ 108 | x, 109 | y 110 | }); 111 | 112 | this.emit('explode', this.explosion); 113 | } 114 | 115 | destroy() { 116 | this.isDestroyed = true; 117 | this.bullet = null; 118 | this.explosion = null; 119 | 120 | this.emit('destroyed', this); 121 | } 122 | 123 | getBulletStartingPosition() { 124 | switch (this.direction) { 125 | case Direction.UP: return [this.left + 12, this.top - 4]; 126 | case Direction.RIGHT: return [this.right - 8, this.top + 12]; 127 | case Direction.DOWN: return [this.left + 10, this.bottom - 8]; 128 | case Direction.LEFT: return [this.left, this.top + 12]; 129 | } 130 | } 131 | 132 | getExplosionStartingPosition() { 133 | return [ 134 | this.left + this.width * 0.5, 135 | this.top + this.height * 0.5 136 | ]; 137 | } 138 | } -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const NUMBER_OF_UNITS = 13; 2 | export const TILE_SIZE = 16; 3 | export const UNIT_SIZE = 32; 4 | export const STAGE_SIZE = NUMBER_OF_UNITS * UNIT_SIZE; 5 | 6 | export const Key = { 7 | UP: 'ArrowUp', 8 | RIGHT: 'ArrowRight', 9 | DOWN: 'ArrowDown', 10 | LEFT: 'ArrowLeft', 11 | SPACE: 'Space' 12 | }; 13 | 14 | export const Direction = { 15 | UP: 0, 16 | RIGHT: 1, 17 | DOWN: 2, 18 | LEFT: 3 19 | }; 20 | 21 | export const TerrainType = { 22 | BASE: 0, 23 | BRICK_WALL: 1, 24 | STEEL_WALL: 2, 25 | TREE: 3, 26 | WATER: 4, 27 | ICE: 5 28 | }; 29 | 30 | export const BASE_POSITION = [6 * UNIT_SIZE, 12 * UNIT_SIZE]; 31 | export const BASE_WIDTH = UNIT_SIZE; 32 | export const BASE_HEIGHT = UNIT_SIZE; 33 | export const BASE_SPRITES = [ 34 | [8 * UNIT_SIZE, 8 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE], 35 | [9 * UNIT_SIZE, 8 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE] 36 | ]; 37 | 38 | export const BRICK_WALL_SPRITES = [ 39 | [8 * UNIT_SIZE, 4 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE], // full 40 | [9 * UNIT_SIZE, 4 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE], // top 8/16 41 | [10 * UNIT_SIZE, 4 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE], // right 8/16 42 | [11 * UNIT_SIZE, 4 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE], // bottom 8/16 43 | [12 * UNIT_SIZE, 4 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE], // left 8/16 44 | [8 * UNIT_SIZE, 4 * UNIT_SIZE, TILE_SIZE, TILE_SIZE], // 1/4 45 | [13 * UNIT_SIZE, 4 * UNIT_SIZE, TILE_SIZE, TILE_SIZE], // top left 3/4 46 | [13.5 * UNIT_SIZE, 4 * UNIT_SIZE, TILE_SIZE, TILE_SIZE], // top right 3/4 47 | [13.5 * UNIT_SIZE, 4.5 * UNIT_SIZE, TILE_SIZE, TILE_SIZE], // bottom right 3/4 48 | [13 * UNIT_SIZE, 4.5 * UNIT_SIZE, TILE_SIZE, TILE_SIZE], // bottom left 3/4 49 | [14 * UNIT_SIZE, 4 * UNIT_SIZE, TILE_SIZE, TILE_SIZE], // top 2/4 50 | [14.5 * UNIT_SIZE, 4 * UNIT_SIZE, TILE_SIZE, TILE_SIZE], // right 2/4 51 | [14.5 * UNIT_SIZE, 4.5 * UNIT_SIZE, TILE_SIZE, TILE_SIZE], // bottom 2/4 52 | [14 * UNIT_SIZE, 4.5 * UNIT_SIZE, TILE_SIZE, TILE_SIZE], // left 2/4 53 | [15 * UNIT_SIZE, 4 * UNIT_SIZE, TILE_SIZE, TILE_SIZE], // top left 1/4 54 | [15.5 * UNIT_SIZE, 4 * UNIT_SIZE, TILE_SIZE, TILE_SIZE], // top right 1/4 55 | [15.5 * UNIT_SIZE, 4.5 * UNIT_SIZE, TILE_SIZE, TILE_SIZE], // bottom right 1/4 56 | [15 * UNIT_SIZE, 4.5 * UNIT_SIZE, TILE_SIZE, TILE_SIZE], // bottom left 1/4 57 | ]; 58 | export const BRICK_WALL_SPRITE_MAP = { 59 | '0': 5, 60 | '1': 10, 61 | '2': 11, 62 | '4': 12, 63 | '8': 13 64 | }; 65 | 66 | export const BULLET_WIDTH = 8; 67 | export const BULLET_HEIGHT = 8; 68 | export const BULLET_SPEED = 4; 69 | export const BULLET_SPRITES = [ 70 | [16 * UNIT_SIZE, 0 * UNIT_SIZE, BULLET_WIDTH, BULLET_HEIGHT], 71 | [16.5 * UNIT_SIZE, 0 * UNIT_SIZE, BULLET_WIDTH, BULLET_HEIGHT], 72 | [17 * UNIT_SIZE, 0 * UNIT_SIZE, BULLET_WIDTH, BULLET_HEIGHT], 73 | [17.5 * UNIT_SIZE, 0 * UNIT_SIZE, BULLET_WIDTH, BULLET_HEIGHT] 74 | ]; 75 | 76 | export const BULLET_EXPLOSION_WIDTH = UNIT_SIZE; 77 | export const BULLET_EXPLOSION_HEIGHT = UNIT_SIZE; 78 | export const BULLET_EXPLOSION_ANIMATION_SPEED = 50; 79 | export const BULLET_EXPLOSION_SPRITES = [ 80 | [16 * UNIT_SIZE, 2 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE], 81 | [17 * UNIT_SIZE, 2 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE], 82 | [18 * UNIT_SIZE, 2 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE] 83 | ]; 84 | 85 | export const ENEMY_TANK_SPEED = 1; 86 | export const ENEMY_TANK_TURN_TIMER_THRESHOLD = 200; 87 | export const ENEMY_TANK_START_POSITIONS = [ 88 | [6 * UNIT_SIZE, 0], 89 | [0 * UNIT_SIZE, 0], 90 | [12 * UNIT_SIZE, 0], 91 | ]; 92 | export const ENEMY_TANK_SPRITES = [ 93 | [ 94 | [0 * UNIT_SIZE, 4 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE], 95 | [1 * UNIT_SIZE, 4 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE], 96 | [2 * UNIT_SIZE, 4 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE], 97 | [3 * UNIT_SIZE, 4 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE], 98 | [4 * UNIT_SIZE, 4 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE], 99 | [5 * UNIT_SIZE, 4 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE], 100 | [6 * UNIT_SIZE, 4 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE], 101 | [7 * UNIT_SIZE, 4 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE] 102 | ] 103 | ]; 104 | 105 | export const PLAYER1_TANK_POSITION = [4 * UNIT_SIZE, 12 * UNIT_SIZE]; 106 | export const PLAYER1_TANK_SPRITES = [ 107 | [0 * UNIT_SIZE, 0 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE], 108 | [1 * UNIT_SIZE, 0 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE], 109 | [2 * UNIT_SIZE, 0 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE], 110 | [3 * UNIT_SIZE, 0 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE], 111 | [4 * UNIT_SIZE, 0 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE], 112 | [5 * UNIT_SIZE, 0 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE], 113 | [6 * UNIT_SIZE, 0 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE], 114 | [7 * UNIT_SIZE, 0 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE] 115 | ]; 116 | 117 | export const TANK_WIDTH = UNIT_SIZE; 118 | export const TANK_HEIGHT = UNIT_SIZE; 119 | export const TANK_SPEED = 2; 120 | export const TANK_TURN_THRESHOLD = TILE_SIZE * 0.5; 121 | export const TANK_ANIMATION_FRAME = 20; 122 | 123 | export const TANK_EXPLOSION_ANIMATION_SPEED = 100; 124 | export const TANK_EXPLOSION_SPRITES = [ 125 | [16 * UNIT_SIZE, 2 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE], 126 | [17 * UNIT_SIZE, 2 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE], 127 | [18 * UNIT_SIZE, 2 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE], 128 | [19 * UNIT_SIZE, 2 * UNIT_SIZE, UNIT_SIZE * 2, UNIT_SIZE * 2], 129 | [21 * UNIT_SIZE, 2 * UNIT_SIZE, UNIT_SIZE * 2, UNIT_SIZE * 2], 130 | [16 * UNIT_SIZE, 2 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE] 131 | ]; 132 | 133 | export const STEEL_WALL_SPRITES = [ 134 | [8 * UNIT_SIZE, 5 * UNIT_SIZE, TILE_SIZE, TILE_SIZE], // 1/4 135 | [8 * UNIT_SIZE, 5 * UNIT_SIZE, UNIT_SIZE, UNIT_SIZE], // full 136 | [9 * UNIT_SIZE, 5 * UNIT_SIZE, TILE_SIZE, TILE_SIZE], // top 137 | [10 * UNIT_SIZE, 5 * UNIT_SIZE, TILE_SIZE, TILE_SIZE], // right 138 | [11 * UNIT_SIZE, 5 * UNIT_SIZE, TILE_SIZE, TILE_SIZE], // bottom 139 | [12 * UNIT_SIZE, 5 * UNIT_SIZE, TILE_SIZE, TILE_SIZE] // left 140 | ]; -------------------------------------------------------------------------------- /src/view.js: -------------------------------------------------------------------------------- 1 | import { NUMBER_OF_UNITS, UNIT_SIZE, TILE_SIZE } from './constants.js'; 2 | 3 | const PLAYFIELD_X = UNIT_SIZE; 4 | const PLAYFIELD_Y = UNIT_SIZE; 5 | const PLAYFIELD_WIDTH = NUMBER_OF_UNITS * UNIT_SIZE; 6 | const PLAYFIELD_HEIGHT = NUMBER_OF_UNITS * UNIT_SIZE; 7 | const PANEL_X = PLAYFIELD_X + PLAYFIELD_WIDTH; 8 | const PANEL_Y = PLAYFIELD_Y; 9 | const PANEL_WIDTH = PANEL_X + UNIT_SIZE * 2; 10 | const PANEL_HEIGHT = PANEL_Y + PLAYFIELD_HEIGHT; 11 | 12 | export default class View { 13 | constructor(canvas, sprite) { 14 | this.canvas = canvas; 15 | this.context = canvas.getContext('2d'); 16 | this.context.imageSmoothingEnabled = false; 17 | this.sprite = sprite; 18 | } 19 | 20 | get width() { 21 | return this.canvas.width; 22 | } 23 | 24 | get height() { 25 | return this.canvas.height; 26 | } 27 | 28 | async init() { 29 | await this.sprite.load(); 30 | } 31 | 32 | update(stage, player1, player2) { 33 | this.clearScreen(); 34 | this.renderStage(stage); 35 | this.renderPanel(stage, player1, player2); 36 | this.renderGrid(); 37 | } 38 | 39 | clearScreen() { 40 | this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); 41 | } 42 | 43 | renderStage(stage) { 44 | this.context.fillStyle = '#636363'; 45 | this.context.fillRect(0, 0, this.width, this.height); 46 | 47 | this.context.fillStyle = '#000000'; 48 | this.context.fillRect(PLAYFIELD_X, PLAYFIELD_Y, PLAYFIELD_WIDTH, PLAYFIELD_HEIGHT); 49 | 50 | for (const object of stage.objects) { 51 | const { x, y, width, height, sprite } = object; 52 | 53 | if (!sprite) return; 54 | 55 | this.context.drawImage( 56 | this.sprite.image, 57 | ...sprite, 58 | PLAYFIELD_X + x, 59 | PLAYFIELD_Y + y, 60 | width, 61 | height 62 | ); 63 | 64 | if (object.debug) { 65 | this.context.strokeStyle = '#ff0000'; 66 | this.context.lineWidth = 2; 67 | this.context.strokeRect(x + 1, y + 1, width - 2, height - 2); 68 | object.debug = false; 69 | } 70 | } 71 | } 72 | 73 | renderPanel(stage, player1, player2) { 74 | this.renderEnemyTankIcons(stage.enemyTanks); 75 | this.renderPlayer1Lives(player1); 76 | this.renderStageNumber(stage); 77 | } 78 | 79 | renderGrid() { 80 | for (let y = 0; y < NUMBER_OF_UNITS; y++) { 81 | for (let x = 0; x < NUMBER_OF_UNITS; x++) { 82 | this.context.strokeStyle = '#ffffff'; 83 | this.context.lineWidth = .2; 84 | this.context.strokeRect( 85 | PLAYFIELD_X + (x * UNIT_SIZE + 1), 86 | PLAYFIELD_Y + (y * UNIT_SIZE + 1), 87 | UNIT_SIZE - 2, 88 | UNIT_SIZE - 2 89 | ); 90 | } 91 | } 92 | 93 | for (let y = 0; y < NUMBER_OF_UNITS * 2; y++) { 94 | for (let x = 0; x < NUMBER_OF_UNITS * 2; x++) { 95 | this.context.strokeStyle = '#ffffff'; 96 | this.context.lineWidth = .1; 97 | this.context.strokeRect( 98 | PLAYFIELD_X + (x * TILE_SIZE + 1), 99 | PLAYFIELD_Y + (y * TILE_SIZE + 1), 100 | TILE_SIZE - 2, 101 | TILE_SIZE - 2 102 | ); 103 | } 104 | } 105 | } 106 | 107 | renderEnemyTankIcons(enemyTanks) { 108 | this.context.fillStyle = '#000000'; 109 | 110 | for (let i = 0, x = 0, y = 0; i < enemyTanks.length; i++) { 111 | this.context.drawImage( 112 | this.sprite.image, 113 | UNIT_SIZE * 18, 114 | UNIT_SIZE * 5.5, 115 | TILE_SIZE, 116 | TILE_SIZE, 117 | PANEL_X + x * TILE_SIZE + 16, 118 | PANEL_Y + y * TILE_SIZE + 16, 119 | TILE_SIZE, 120 | TILE_SIZE 121 | ); 122 | 123 | if (x === 1) { 124 | x = 0; 125 | y++; 126 | } else { 127 | x++; 128 | } 129 | } 130 | } 131 | 132 | renderPlayer1Lives(player1) { 133 | this.context.drawImage( 134 | this.sprite.image, 135 | UNIT_SIZE * 16, 136 | UNIT_SIZE * 6, 137 | UNIT_SIZE, 138 | TILE_SIZE, 139 | PANEL_X + TILE_SIZE, 140 | PANEL_Y + PANEL_HEIGHT * 0.5, 141 | UNIT_SIZE, 142 | TILE_SIZE 143 | ); 144 | 145 | this.context.drawImage( 146 | this.sprite.image, 147 | UNIT_SIZE * 16, 148 | UNIT_SIZE * 6.5, 149 | TILE_SIZE, 150 | TILE_SIZE, 151 | PANEL_X + TILE_SIZE, 152 | PANEL_Y + PANEL_HEIGHT * 0.5 + TILE_SIZE, 153 | TILE_SIZE, 154 | TILE_SIZE 155 | ); 156 | 157 | this.context.drawImage( 158 | this.sprite.image, 159 | UNIT_SIZE * 19.5, 160 | UNIT_SIZE * 5, 161 | TILE_SIZE, 162 | TILE_SIZE, 163 | PANEL_X + TILE_SIZE * 2, 164 | PANEL_Y + PANEL_HEIGHT * 0.5 + TILE_SIZE, 165 | TILE_SIZE, 166 | TILE_SIZE 167 | ); 168 | } 169 | 170 | renderStageNumber(stage) { 171 | this.context.drawImage( 172 | this.sprite.image, 173 | UNIT_SIZE * 18, 174 | UNIT_SIZE * 6, 175 | UNIT_SIZE, 176 | UNIT_SIZE, 177 | PANEL_X + TILE_SIZE, 178 | PANEL_Y + PANEL_HEIGHT * 0.75, 179 | UNIT_SIZE, 180 | UNIT_SIZE 181 | ); 182 | 183 | this.context.drawImage( 184 | this.sprite.image, 185 | UNIT_SIZE * 19, 186 | UNIT_SIZE * 5, 187 | TILE_SIZE, 188 | TILE_SIZE, 189 | PANEL_X + TILE_SIZE * 2, 190 | PANEL_Y + PANEL_HEIGHT * 0.75 + UNIT_SIZE, 191 | TILE_SIZE, 192 | TILE_SIZE 193 | ); 194 | } 195 | } -------------------------------------------------------------------------------- /src/stage.js: -------------------------------------------------------------------------------- 1 | import { STAGE_SIZE, TILE_SIZE, TerrainType } from './constants.js'; 2 | import EventEmitter from './event-emitter.js'; 3 | import Base from './base.js'; 4 | import BrickWall from './brick-wall.js'; 5 | import SteelWall from './steel-wall.js'; 6 | import PlayerTank from './player-tank.js'; 7 | import EnemyTank from './enemy-tank.js'; 8 | 9 | export default class Stage extends EventEmitter { 10 | static createObject(type, args) { 11 | switch (type) { 12 | case TerrainType.BRICK_WALL: return new BrickWall(args); 13 | case TerrainType.STEEL_WALL: return new SteelWall(args); 14 | } 15 | } 16 | 17 | static createTerrain(map) { 18 | const objects = []; 19 | 20 | for (let i = 0; i < map.length; i++) { 21 | for (let j = 0; j < map.length; j++) { 22 | const value = map[j][i]; 23 | 24 | if (value) { 25 | const object = Stage.createObject(value, { 26 | x: i * TILE_SIZE, 27 | y: j * TILE_SIZE 28 | }); 29 | 30 | objects.push(object); 31 | } 32 | } 33 | } 34 | 35 | return objects; 36 | } 37 | 38 | static createEnemies(types) { 39 | return types.map(type => new EnemyTank({ type })); 40 | } 41 | 42 | get width() { 43 | return STAGE_SIZE; 44 | } 45 | 46 | get height() { 47 | return STAGE_SIZE; 48 | } 49 | 50 | get top() { 51 | return 0; 52 | } 53 | 54 | get right() { 55 | return this.width; 56 | } 57 | 58 | get bottom() { 59 | return this.height; 60 | } 61 | 62 | get left() { 63 | return 0; 64 | } 65 | 66 | constructor(data) { 67 | super(); 68 | 69 | this.base = new Base(); 70 | this.playerTank = new PlayerTank(); 71 | this.enemyTanks = Stage.createEnemies(data.enemies); 72 | this.terrain = Stage.createTerrain(data.map); 73 | this.enemyTankCount = 0; 74 | this.enemyTankTimer = 0; 75 | this.enemyTankPositionIndex = 0; 76 | 77 | this.objects = new Set([ 78 | this.base, 79 | this.playerTank, 80 | ...this.terrain 81 | ]); 82 | 83 | this.init(); 84 | } 85 | 86 | init() { 87 | this.base.on('destroyed', () => { 88 | this.emit('gameOver'); 89 | }); 90 | 91 | this.playerTank.on('fire', bullet => { 92 | this.objects.add(bullet); 93 | 94 | bullet.on('explode', explosion => { 95 | this.objects.add(explosion); 96 | 97 | explosion.on('destroyed', () => { 98 | this.objects.delete(explosion); 99 | }); 100 | }); 101 | 102 | bullet.on('destroyed', () => { 103 | this.objects.delete(bullet); 104 | }); 105 | }); 106 | 107 | this.playerTank.on('destroyed', tank => { 108 | this.objects.delete(tank); 109 | }); 110 | 111 | this.enemyTanks.map(enemyTank => { 112 | enemyTank.on('fire', bullet => { 113 | this.objects.add(bullet); 114 | 115 | bullet.on('explode', explosion => { 116 | this.objects.add(explosion); 117 | 118 | explosion.on('destroyed', () => { 119 | this.objects.delete(explosion); 120 | }); 121 | }); 122 | 123 | bullet.on('destroyed', () => { 124 | this.objects.delete(bullet); 125 | }); 126 | }); 127 | 128 | enemyTank.on('explode', explosion => { 129 | this.objects.add(explosion); 130 | 131 | explosion.on('destroyed', () => { 132 | this.objects.delete(explosion); 133 | }); 134 | }); 135 | 136 | enemyTank.on('destroyed', () => this.removeEnemyTank(enemyTank)); 137 | }); 138 | } 139 | 140 | update(input, frameDelta) { 141 | const state = { 142 | input, 143 | frameDelta, 144 | world: this 145 | }; 146 | 147 | if (this.shouldAddEnemyTank(frameDelta)) { 148 | this.addEnemyTank(); 149 | } 150 | 151 | this.objects.forEach(object => object.update(state)); 152 | } 153 | 154 | isOutOfBounds(object) { 155 | return ( 156 | object.top < this.top || 157 | object.right > this.right || 158 | object.bottom > this.bottom || 159 | object.left < this.left 160 | ); 161 | } 162 | 163 | hasCollision(object) { 164 | const collision = this.getCollision(object); 165 | 166 | return Boolean(collision); 167 | } 168 | 169 | getCollision(object) { 170 | const collisionObjects = this.getCollisionObjects(object); 171 | 172 | if (collisionObjects.size > 0) { 173 | return { objects: collisionObjects }; 174 | } 175 | } 176 | 177 | getCollisionObjects(object) { 178 | const objects = new Set(); 179 | 180 | for (const other of this.objects) { 181 | if (other !== object && this.haveCollision(object, other)) { 182 | objects.add(other); 183 | } 184 | } 185 | 186 | return objects; 187 | } 188 | 189 | haveCollision(a, b) { 190 | return ( 191 | a.left < b.right && 192 | a.right > b.left && 193 | a.top < b.bottom && 194 | a.bottom > b.top 195 | ); 196 | } 197 | 198 | shouldAddEnemyTank(frameDelta) { 199 | this.enemyTankTimer += frameDelta; 200 | 201 | return this.enemyTankTimer > 1000 && this.enemyTankCount < 4; 202 | } 203 | 204 | removeWall() { } 205 | 206 | addEnemyTank() { 207 | const tank = this.enemyTanks.shift(); 208 | 209 | if (tank) { 210 | tank.setPosition(this.enemyTankPositionIndex); 211 | 212 | this.enemyTankCount += 1; 213 | this.enemyTankTimer = 0; 214 | this.enemyTankPositionIndex = (this.enemyTankPositionIndex + 1) % 3; 215 | 216 | this.objects.add(tank); 217 | } 218 | } 219 | 220 | removeEnemyTank(enemyTank) { 221 | this.objects.delete(enemyTank); 222 | this.enemyTankCount -= 1; 223 | } 224 | } --------------------------------------------------------------------------------