Initial commit

This commit is contained in:
joshii 2026-03-15 13:36:42 +01:00
commit 88cf9950ff
104 changed files with 13042 additions and 0 deletions

3
.babelrc Normal file
View file

@ -0,0 +1,3 @@
{
"presets": ["@babel/env"]
}

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
.DS_Store
node_modules

8
Dockerfile Normal file
View file

@ -0,0 +1,8 @@
FROM node:10-alpine
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --production=false --ignore-engines
COPY . .
RUN npx webpack
EXPOSE 3000
CMD ["node", "server/app.js"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 Dmytro Vasin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

53
README.md Normal file
View file

@ -0,0 +1,53 @@
## A Bomberman-style game with multiplayer option.
A Bomberman-style game with multiplayer option made with [Phaser.js](https://phaser.io/), [Node.js](https://nodejs.org/uk/), [Express.js](http://expressjs.com/), [Socket.io](https://socket.io/).
### Game description:
The game is designed for up to three players.
Games can be played on one of two maps.
![Maps](https://raw.githubusercontent.com/DmytroVasin/bomber/master/_readme/maps.png)
Player models user will receive randomly when he will enter the game.
The winning player is the last one standing.
Within the game, players can upgrade skills like:
( Change to drop - 50% when player break the block )
* ![Speed Up](https://raw.githubusercontent.com/DmytroVasin/bomber/master/_readme/speed.png) Speed: can increase to 3
* ![Bomb setting time](https://raw.githubusercontent.com/DmytroVasin/bomber/master/_readme/time.png) Bomb setting time: can be reduced to 0.5 seconds
* ![Power Up](https://raw.githubusercontent.com/DmytroVasin/bomber/master/_readme/power.png) Power: no limit
## Demo:
You can find a tutorial on how to make Bomberman-style games here: [Tutorial (need work)](https://github.com/DmytroVasin/bomber/blob/master/tutorial.md)
A demo of this game can be found on Heroku: [Bomberman with multiplayer - Demo](https://bomb-attack.herokuapp.com/)
Note: To play the game, you should open the browser in two separate windows. The game pauses when You open a new tab in the same window. Open game in different windows.
## Game: *Click to play*:
[![Preview](https://raw.githubusercontent.com/DmytroVasin/bomber/master/_readme/menu.png)](https://player.vimeo.com/video/246595375?autoplay=1)
## Menu: *Click to play*:
[![Preview](https://raw.githubusercontent.com/DmytroVasin/bomber/master/_readme/intro.png)](https://player.vimeo.com/video/247095838?autoplay=1)
## Setup:
The game requires Node and Yarn (npm) package manager. Make sure that you already have both installed on your system before trying to launch it.
Steps:
1. Clone the repository.
2. Run `yarn install` inside a newly created directory.
3. Start the server with the command `yarn run server` ( defined in the `package.json` file ). This will launch `webpack` in your development environment and then start the `node` server.
4. Check out the game at [http://localhost:3000](http://localhost:3000)
5. Enjoy!
## Notes:
You can use my code as a boilerplate if you want, but I would suggest you change the tile sizes. I've picked tiles that are 35x35 pixels, but tiles that are 32x32 would be more ideal. All free templates are based on this tile size, and it is also handily divisible by 2.
## To Debug Node process:
1. Open: chrome://inspect/#devices
2. Click 'Open dedicated DevTools for Node'
3. "server": "webpack --mode development && node --inspect server/app.js",

BIN
_readme/intro.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
_readme/maps.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
_readme/menu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 943 KiB

BIN
_readme/power.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
_readme/speed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
_readme/time.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

2390
client/bundle.js Normal file

File diff suppressed because it is too large Load diff

1
client/bundle.js.map Normal file

File diff suppressed because one or more lines are too long

18
client/css/base.css Normal file
View file

@ -0,0 +1,18 @@
* {
margin: 0;
padding: 0;
}
html {
background: #DEDEDE;
}
html, body, #game-wrapper, #game-container {
height: 100%;
}
#game-container {
display: flex;
align-items: center;
justify-content: center;
}

BIN
client/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

24
client/index.html Normal file
View file

@ -0,0 +1,24 @@
<!doctype html>
<html lang='en'>
<head>
<title>Bomb Attack</title>
<link rel='stylesheet' href='css/base.css'/>
<script src='lib/phase-slide.js'></script>
<script src='lib/phaser.min.js'></script>
<script src='/socket.io/socket.io.js'></script>
<script src='bundle.js'></script>
<script>
window.clientSocket = io.connect();
</script>
</head>
<body>
<div id='game-wrapper'>
<div id='game-container'>
</div>
</div>
</body>
</html>

28
client/js/app.js Normal file
View file

@ -0,0 +1,28 @@
import Boot from './states/boot';
import Preload from './states/preload';
import Menu from './states/menu';
import SelectMap from './states/select_map';
import PendingGame from './states/pending_game';
import Play from './states/play';
import Win from './states/win';
class Game extends Phaser.Game {
constructor() {
super(980, 630, Phaser.AUTO, 'game-container');
// Tell Phaser to use setTimeOut even if RAF(request animation frame) is available.
this.config['forceSetTimeOut'] = true
this.state.add('Boot', Boot);
this.state.add('Preload', Preload);
this.state.add('Menu', Menu);
this.state.add('SelectMap', SelectMap);
this.state.add('PendingGame', PendingGame);
this.state.add('Play', Play);
this.state.add('Win', Win);
this.state.start('Boot');
}
}
new Game();

View file

@ -0,0 +1,30 @@
import { TILE_SIZE, EXPLOSION_TIME } from '../utils/constants';
export default class Bomb extends Phaser.Sprite {
constructor(game, id, col, row) {
let centerCol = (col * TILE_SIZE) + TILE_SIZE / 2
let centerRow = (row * TILE_SIZE) + TILE_SIZE / 2
super(game, centerCol, centerRow, 'bomb_tileset');
this.scale.setTo(0.7);
this.anchor.setTo(0.5);
this.game = game
this.id = id;
this.game.physics.arcade.enable(this);
this.game.add.tween(this.scale).to({ x: 1.2, y: 1.2 }, EXPLOSION_TIME, Phaser.Easing.Linear.None, true);
this.body.immovable = true;
// TODO: https://phaser.io/docs/2.4.4/Phaser.AnimationManager.html#add
this.animations.add('bomb', [0,1,2,3,4,5,6,7,8,9,10,11,12,13], 6, true);
this.animations.play('bomb');
}
update() {
// this.game.debug.body(this);
}
}

View file

@ -0,0 +1,9 @@
import { TILE_SIZE } from '../utils/constants';
export default class Bone extends Phaser.Sprite {
constructor(game, col, row) {
super(game, (col * TILE_SIZE), (row * TILE_SIZE), 'bone_tileset');
}
}

View file

@ -0,0 +1,74 @@
import { TILE_SIZE, PING } from '../utils/constants';
import { Text } from '../helpers/elements';
export default class EnemyPlayer extends Phaser.Sprite {
constructor({ game, id, spawn, skin }) {
super(game, spawn.x, spawn.y, 'bomberman_' + skin);
this.game = game
this.id = id;
this.currentPosition = spawn;
this.lastMoveAt = 0;
this.game.physics.arcade.enable(this);
this.body.setSize(20, 20, 6, 6);
this.body.immovable = true;
this.animations.add('up', [9, 10, 11], 15, true);
this.animations.add('down', [0, 1, 2], 15, true);
this.animations.add('right', [6, 7, 8], 15, true);
this.animations.add('left', [3, 4, 5], 15, true);
this.defineSelf(skin)
}
update () {
// this.game.debug.body(this);
}
goTo(newPosition) {
this.lastMoveAt = this.game.time.now;
this.animateFace(newPosition);
this.game.add.tween(this).to(newPosition, PING, Phaser.Easing.Linear.None, true);
}
animateFace(newPosition) {
let face = 'down';
let diffX = newPosition.x - this.currentPosition.x;
let diffY = newPosition.y - this.currentPosition.y;
if (diffX < 0) {
face = 'left'
} else if (diffX > 0) {
face = 'right'
} else if (diffY < 0) {
face = 'up'
} else if (diffY > 0) {
face = 'down'
}
this.animations.play(face)
this.currentPosition = newPosition;
}
defineSelf(name) {
let playerText = new Text({
game: this.game,
x: TILE_SIZE / 2,
y: -10,
text: name,
style: {
font: '14px Areal',
fill: '#FFFFFF',
stroke: '#000000',
strokeThickness: 3
}
})
this.addChild(playerText);
}
}

View file

@ -0,0 +1,18 @@
import { TILE_SIZE } from '../utils/constants';
export default class FireBlast extends Phaser.Sprite {
constructor(game, cell) {
super(game, (cell.col * TILE_SIZE), (cell.row * TILE_SIZE), cell.type, 0);
this.game = game
this.animations.add('blast', [0, 1, 2, 3, 4]);
// 15 - framerate, loop, kill_on_complete
this.play('blast', 15, false, true);
this.game.physics.arcade.enable(this);
}
}

View file

@ -0,0 +1,51 @@
export default class Info {
constructor({ game, player }) {
this.game = game;
this.player = player;
this.style = { font: '14px Arial', fill: '#ffffff', align: 'left' }
this.redStyle = { font: '30px Arial', fill: '#ff0044', align: 'center' };
let bootsIcon = new Phaser.Image(this.game, 5, 2, 'placeholder_speed');
this.speedText = new Phaser.Text(this.game, 35, 7, this.speedLabel(), this.style);
bootsIcon.addChild(this.speedText)
this.game.add.existing(bootsIcon);
let powerIcon = new Phaser.Image(this.game, 110, 2, 'placeholder_power');
this.powerText = new Phaser.Text(this.game, 35, 7, this.powerLabel(), this.style);
powerIcon.addChild(this.powerText)
this.game.add.existing(powerIcon);
let delayIcon = new Phaser.Image(this.game, 215, 2, 'placeholder_time');
this.delayText = new Phaser.Text(this.game, 35, 7, this.delayLabel(), this.style);
delayIcon.addChild(this.delayText)
this.game.add.existing(delayIcon);
this.deadText = this.game.add.text(this.game.world.centerX, this.game.world.height - 30, 'You died :(', this.redStyle);
this.deadText.anchor.set(0.5);
this.deadText.visible = false
}
refreshStatistic() {
this.speedText.text = this.speedLabel();
this.powerText.text = this.powerLabel();
this.delayText.text = this.delayLabel();
}
showDeadInfo() {
this.deadText.visible = true
}
speedLabel() {
return this.player.speed
}
powerLabel() {
return `x ${this.player.power}`
}
delayLabel() {
return `${this.player.delay / 1000} sec.`
}
}

View file

@ -0,0 +1,177 @@
import {
PING, TILE_SIZE, MAX_SPEED, STEP_SPEED, INITIAL_SPEED, SPEED, POWER, DELAY,
MIN_DELAY, STEP_DELAY, INITIAL_DELAY, INITIAL_POWER, STEP_POWER
} from '../utils/constants';
import Info from './info';
import { SpoilNotification, Text } from '../helpers/elements';
export default class Player extends Phaser.Sprite {
constructor({ game, id, spawn, skin }) {
super(game, spawn.x, spawn.y, 'bomberman_' + skin);
this.game = game;
this.id = id;
this.prevPosition = { x: spawn.x, y: spawn.y };
this.delay = INITIAL_DELAY;
this.power = INITIAL_POWER;
this.speed = INITIAL_SPEED;
this._lastBombTime = 0;
this.game.add.existing(this);
this.game.physics.arcade.enable(this);
this.body.setSize(20, 20, 6, 6);
game.time.events.loop(PING , this.positionUpdaterLoop.bind(this));
this.animations.add('up', [9, 10, 11], 15, true);
this.animations.add('down', [0, 1, 2], 15, true);
this.animations.add('right', [6, 7, 8], 15, true);
this.animations.add('left', [3, 4, 5], 15, true);
this.info = new Info({ game: this.game, player: this });
this.defineKeyboard()
this.defineSelf(skin)
}
update() {
if (this.alive) {
this.handleMoves()
this.handleBombs()
}
// this.game.debug.body(this);
// this.game.debug.spriteInfo(this, 32, 32);
}
defineKeyboard() {
this.upKey = this.game.input.keyboard.addKey(Phaser.Keyboard.UP)
this.downKey = this.game.input.keyboard.addKey(Phaser.Keyboard.DOWN)
this.leftKey = this.game.input.keyboard.addKey(Phaser.Keyboard.LEFT)
this.rightKey = this.game.input.keyboard.addKey(Phaser.Keyboard.RIGHT)
this.spaceKey = this.game.input.keyboard.addKey(Phaser.Keyboard.SPACEBAR)
}
handleMoves() {
this.body.velocity.set(0);
let animationsArray = []
if (this.leftKey.isDown){
this.body.velocity.x = -this.speed;
animationsArray.push('left')
} else if (this.rightKey.isDown) {
this.body.velocity.x = this.speed;
animationsArray.push('right')
}
if (this.upKey.isDown) {
this.body.velocity.y = -this.speed;
animationsArray.push('up')
} else if (this.downKey.isDown) {
this.body.velocity.y = this.speed;
animationsArray.push('down')
}
let currentAnimation = animationsArray[0]
if (currentAnimation){
this.animations.play(currentAnimation)
return
}
this.animations.stop();
}
handleBombs() {
if (this.game.input.keyboard.isDown(Phaser.Keyboard.SPACEBAR)) {
let now = this.game.time.now;
if (now > this._lastBombTime) {
this._lastBombTime = now + this.delay;
clientSocket.emit('create bomb', { col: this.currentCol(), row: this.currentRow() });
}
}
}
currentCol() {
return Math.floor(this.body.position.x / TILE_SIZE)
}
currentRow() {
return Math.floor(this.body.position.y / TILE_SIZE)
}
positionUpdaterLoop() {
let newPosition = { x: this.position.x, y: this.position.y }
if (this.prevPosition.x !== newPosition.x || this.prevPosition.y !== newPosition.y) {
clientSocket.emit('update player position', newPosition);
this.prevPosition = newPosition;
}
}
becomesDead() {
this.info.showDeadInfo()
this.kill();
}
pickSpoil( spoil_type ){
if ( spoil_type === SPEED ){ this.increaseSpeed() }
if ( spoil_type === POWER ){ this.increasePower() }
if ( spoil_type === DELAY ){ this.increaseDelay() }
}
increaseSpeed(){
let asset = 'speed_up_no_bonus'
if (this.speed < MAX_SPEED) {
this.speed = this.speed + STEP_SPEED;
this.info.refreshStatistic();
asset = 'speed_up_bonus'
}
new SpoilNotification({ game: this.game, asset: asset, x: this.position.x, y: this.position.y })
}
increaseDelay(){
let asset = 'delay_up_no_bonus'
if (this.delay > MIN_DELAY){
this.delay -= STEP_DELAY;
this.info.refreshStatistic();
asset = 'delay_up_bonus'
}
new SpoilNotification({ game: this.game, asset: asset, x: this.position.x, y: this.position.y })
}
increasePower(){
let asset = 'power_up_bonus'
this.power += STEP_POWER;
this.info.refreshStatistic();
new SpoilNotification({ game: this.game, asset: asset, x: this.position.x, y: this.position.y })
}
defineSelf(name) {
let playerText = new Text({
game: this.game,
x: TILE_SIZE / 2,
y: -10,
text: `\u272E ${name} \u272E`,
style: {
font: '15px Areal',
fill: '#FFFFFF',
stroke: '#000000',
strokeThickness: 3
}
})
this.addChild(playerText);
}
}

View file

@ -0,0 +1,25 @@
import { SPEED, POWER, DELAY, TILE_SIZE } from '../utils/constants';
export default class Spoil extends Phaser.Sprite {
constructor(game, spoil) {
let spoil_type;
if (spoil.spoil_type === DELAY) {
spoil_type = 0
}
if (spoil.spoil_type === POWER) {
spoil_type = 1
}
if (spoil.spoil_type === SPEED) {
spoil_type = 2
}
super(game, (spoil.col * TILE_SIZE), (spoil.row * TILE_SIZE), 'spoil_tileset', spoil_type);
this.id = spoil.id
this.game.physics.arcade.enable(this);
}
}

View file

@ -0,0 +1,132 @@
export class Text extends Phaser.Text {
constructor({ game, x, y, text, style }) {
super(game, x, y, text, style);
this.anchor.setTo(0.5);
this.game.add.existing(this);
}
}
export class Button extends Phaser.Button {
constructor({ game, x, y, asset, callback, callbackContext, overFrame, outFrame, downFrame, upFrame }) {
super(game, x, y, asset, callback, callbackContext, overFrame, outFrame, downFrame, upFrame);
this.anchor.setTo(0.5);
this.game.add.existing(this);
}
}
export class TextButton extends Phaser.Button {
constructor({ game, x, y, asset, callback, callbackContext, overFrame, outFrame, downFrame, upFrame, label, style }) {
super(game, x, y, asset, callback, callbackContext, overFrame, outFrame, downFrame, upFrame);
this.anchor.setTo(0.5);
this.text = new Phaser.Text(this.game, 0, 0, label, style);
this.text.anchor.setTo(0.5);
this.addChild(this.text);
this.game.add.existing(this);
}
disable() {
this.setFrames(3, 3);
this.inputEnabled = false;
this.input.useHandCursor = false;
}
enable() {
this.setFrames(1, 0, 2);
this.inputEnabled = true;
this.input.useHandCursor = true;
}
}
export class GameSlots extends Phaser.Group {
constructor({ game, availableGames, callback, callbackContext, x, y, style }) {
super(game);
let game_slot_asset = 'slot_backdrop'
let game_enter_asset = 'list_icon'
let yOffset = y;
for (let availableGame of availableGames) {
let gameBox = new Phaser.Image(this.game, x, yOffset, game_slot_asset)
let button = new Phaser.Button(this.game, gameBox.width - 100, 12, game_enter_asset, callback.bind(callbackContext, { game_id: availableGame.id }), null, 1, 0, 2, 1);
let text = new Phaser.Text(this.game, 30, 25, `Join Game: ${availableGame.name}`, style);
gameBox.addChild(button);
gameBox.addChild(text);
this.add(gameBox);
yOffset += 105;
}
}
destroy() {
this.callAll('kill') // destroy
}
}
export class PlayerSlots extends Phaser.Group {
constructor({ game, max_players, players, x, y, asset_empty, asset_player, style }) {
super(game);
let xOffset = x;
for (let i = 0; i < max_players; i++) {
let slotBox
let slotName
let _player = players[i]
if (_player) {
slotBox = new Phaser.Image(this.game, xOffset, y, asset_player+_player.skin)
slotName = new Phaser.Text(this.game, slotBox.width/2, slotBox.height + 15, _player.skin, style);
slotName.anchor.setTo(0.5);
slotBox.addChild(slotName);
} else {
slotBox = new Phaser.Image(this.game, xOffset, y, asset_empty)
}
this.add(slotBox);
xOffset += 170;
}
}
destroy() {
this.callAll('kill')
}
}
export class SpoilNotification extends Phaser.Group {
constructor({ game, asset, x, y }) {
super(game)
this.picture = new Phaser.Image(this.game, x, y - 20, asset);
this.picture.anchor.setTo(0.5);
this.add(this.picture);
this.tween = this.game.add.tween(this.picture);
this.tween.to({ y: this.picture.y - 25, alpha: 0 }, 600);
this.tween.onComplete.add(this.finish, this);
this.tween.start()
}
finish() {
this.callAll('kill')
}
}

26
client/js/states/boot.js Normal file
View file

@ -0,0 +1,26 @@
import { Text } from '../helpers/elements';
class Boot extends Phaser.State {
create() {
// Make the game keep reacting to messages from the server even when the game window doesnt have focus.
// The game pauses when I open a new tab in the same window, but does not pause when I focus on another application
this.game.stage.disableVisibilityChange = true;
new Text({
game: this.game,
x: this.game.world.centerX,
y: this.game.world.centerY,
text: 'Loading...',
style: {
font: '30px Areal',
fill: '#FFFFFF'
}
})
this.state.start('Preload');
}
}
export default Boot;

90
client/js/states/menu.js Normal file
View file

@ -0,0 +1,90 @@
import { Text, TextButton, GameSlots } from '../helpers/elements';
class Menu extends Phaser.State {
init() {
this.slotsWithGame = null;
clientSocket.on('display pending games', this.displayPendingGames.bind(this));
}
create() {
let background = this.add.image(this.game.world.centerX, this.game.world.centerY, 'main_menu');
background.anchor.setTo(0.5);
new Text({
game: this.game,
x: this.game.world.centerX,
y: this.game.world.centerY - 215,
text: 'Main Menu',
style: {
font: '35px Areal',
fill: '#9ec0ba',
stroke: '#7f9995',
strokeThickness: 3
}
})
new TextButton({
game: this.game,
x: this.game.world.centerX,
y: this.game.world.centerY + 195,
asset: 'buttons',
callback: this.hostGameAction,
callbackContext: this,
overFrame: 1,
outFrame: 0,
downFrame: 2,
upFrame: 0,
label: 'New Game',
style: {
font: '20px Areal',
fill: '#000000'
}
});
clientSocket.emit('enter lobby', this.displayPendingGames.bind(this));
}
update() {
}
hostGameAction() {
clientSocket.emit('leave lobby');
this.state.start('SelectMap');
}
displayPendingGames(availableGames) {
// NOTE: That is not optimal way to preview slots,
// we should implement AddSlotToGroup, RemoveSlotFromGroup
// I triying to care about readability, not about performance.
if (this.slotsWithGame) {
this.slotsWithGame.destroy()
}
this.slotsWithGame = new GameSlots({
game: this.game,
availableGames: availableGames,
callback: this.joinGameAction,
callbackContext: this,
x: this.game.world.centerX - 220,
y: 160,
style: {
font: '35px Areal',
fill: '#efefef',
stroke: '#ae743a',
strokeThickness: 3
}
})
}
joinGameAction(game_id) {
clientSocket.emit('leave lobby');
// https://phaser.io/docs/2.6.2/Phaser.StateManager.html#start
this.state.start('PendingGame', true, false, game_id);
}
}
export default Menu;

View file

@ -0,0 +1,118 @@
import { Text, Button, TextButton, PlayerSlots } from '../helpers/elements';
class PendingGame extends Phaser.State {
init({ game_id }) {
this.slotsWithPlayer = null;
this.game_id = game_id;
clientSocket.on('update game', this.displayGameInfo.bind(this));
clientSocket.on('launch game', this.launchGame.bind(this));
clientSocket.emit('enter pending game', { game_id: this.game_id });
}
create() {
let background = this.add.image(this.game.world.centerX, this.game.world.centerY, 'main_menu');
background.anchor.setTo(0.5);
this.gameTitle = new Text({
game: this.game,
x: this.game.world.centerX,
y: this.game.world.centerY - 215,
text: '',
style: {
font: '35px Areal',
fill: '#9ec0ba',
stroke: '#6f7975',
strokeThickness: 3
}
})
this.startGameButton = new TextButton({
game: this.game,
x: this.game.world.centerX + 105,
y: this.game.world.centerY + 195,
asset: 'buttons',
callback: this.startGameAction,
callbackContext: this,
overFrame: 1,
outFrame: 0,
downFrame: 2,
upFrame: 0,
label: 'Start Game',
style: {
font: '20px Areal',
fill: '#000000'
}
});
this.startGameButton.disable()
new TextButton({
game: this.game,
x: this.game.world.centerX - 105,
y: this.game.world.centerY + 195,
asset: 'buttons',
callback: this.leaveGameAction,
callbackContext: this,
overFrame: 1,
outFrame: 0,
downFrame: 2,
upFrame: 0,
label: 'Leave Game',
style: {
font: '20px Areal',
fill: '#000000'
}
});
}
displayGameInfo({ current_game }) {
let players = Object.values(current_game.players);
this.gameTitle.text = current_game.name
if (this.slotsWithPlayer) {
this.slotsWithPlayer.destroy()
}
this.slotsWithPlayer = new PlayerSlots({
game: this.game,
max_players: current_game.max_players,
players: players,
x: this.game.world.centerX - 245,
y: this.game.world.centerY - 80,
asset_empty: 'bomberman_head_blank',
asset_player: 'bomberman_head_',
style: {
font: '20px Areal',
fill: '#48291c'
}
})
if(players.length > 1) {
this.startGameButton.enable();
} else {
this.startGameButton.disable();
}
}
leaveGameAction() {
clientSocket.emit('leave pending game');
this.state.start('Menu');
}
startGameAction() {
clientSocket.emit('start game');
}
launchGame(game) {
this.state.start('Play', true, false, game);
}
}
export default PendingGame;

165
client/js/states/play.js Normal file
View file

@ -0,0 +1,165 @@
import { findFrom, findAndDestroyFrom } from '../utils/utils';
import { TILESET, LAYER } from '../utils/constants';
import Player from '../entities/player';
import EnemyPlayer from '../entities/enemy_player';
import Bomb from '../entities/bomb';
import Spoil from '../entities/spoil';
import FireBlast from '../entities/fire_blast';
import Bone from '../entities/bone';
class Play extends Phaser.State {
init(game) {
this.currentGame = game
}
create() {
this.createMap();
this.createPlayers();
this.setEventHandlers();
this.game.time.events.loop(400 , this.stopAnimationLoop.bind(this));
}
update() {
this.game.physics.arcade.collide(this.player, this.blockLayer);
this.game.physics.arcade.collide(this.player, this.enemies);
this.game.physics.arcade.collide(this.player, this.bombs);
this.game.physics.arcade.overlap(this.player, this.spoils, this.onPlayerVsSpoil, null, this);
this.game.physics.arcade.overlap(this.player, this.blasts, this.onPlayerVsBlast, null, this);
}
createMap() {
this.map = this.add.tilemap(this.currentGame.map_name);
this.map.addTilesetImage(TILESET);
this.blockLayer = this.map.createLayer(LAYER);
this.blockLayer.resizeWorld();
this.map.setCollision(this.blockLayer.layer.properties.collisionTiles)
this.player = null;
this.bones = this.game.add.group();
this.bombs = this.game.add.group();
this.spoils = this.game.add.group();
this.blasts = this.game.add.group();
this.enemies = this.game.add.group();
this.game.physics.arcade.enable(this.blockLayer);
}
createPlayers() {
for (let player of Object.values(this.currentGame.players)) {
let setup = {
game: this.game,
id: player.id,
spawn: player.spawn,
skin: player.skin
}
if (player.id === clientSocket.id) {
this.player = new Player(setup);
} else {
this.enemies.add(new EnemyPlayer(setup))
}
}
}
setEventHandlers() {
clientSocket.on('move player', this.onMovePlayer.bind(this));
clientSocket.on('player win', this.onPlayerWin.bind(this));
clientSocket.on('show bomb', this.onShowBomb.bind(this));
clientSocket.on('detonate bomb', this.onDetonateBomb.bind(this));
clientSocket.on('spoil was picked', this.onSpoilWasPicked.bind(this));
clientSocket.on('show bones', this.onShowBones.bind(this));
clientSocket.on('player disconnect', this.onPlayerDisconnect.bind(this));
}
onPlayerVsSpoil(player, spoil) {
clientSocket.emit('pick up spoil', { spoil_id: spoil.id });
spoil.kill();
}
onPlayerVsBlast(player, blast) {
if (player.alive) {
clientSocket.emit('player died', { col: player.currentCol(), row: player.currentRow() });
player.becomesDead()
}
}
onMovePlayer({ player_id, x, y }) {
let enemy = findFrom(player_id, this.enemies);
if (!enemy) { return }
enemy.goTo({ x: x, y: y })
}
stopAnimationLoop() {
for (let enemy of this.enemies.children) {
if (enemy.lastMoveAt < this.game.time.now - 200) {
enemy.animations.stop();
}
}
}
onShowBomb({ bomb_id, col, row }) {
this.bombs.add(new Bomb(this.game, bomb_id, col, row));
}
onDetonateBomb({ bomb_id, blastedCells }) {
// Remove Bomb:
findAndDestroyFrom(bomb_id, this.bombs)
// Render Blast:
for (let cell of blastedCells) {
this.blasts.add(new FireBlast(this.game, cell));
};
// Destroy Tiles:
for (let cell of blastedCells) {
if (!cell.destroyed) { continue }
this.map.putTile(this.blockLayer.layer.properties.empty, cell.col, cell.row, this.blockLayer);
};
// Add Spoils:
for (let cell of blastedCells) {
if (!cell.destroyed) { continue }
if (!cell.spoil) { continue }
this.spoils.add(new Spoil(this.game, cell.spoil));
};
}
onSpoilWasPicked({ player_id, spoil_id, spoil_type }){
if (player_id === this.player.id){
this.player.pickSpoil(spoil_type)
}
findAndDestroyFrom(spoil_id, this.spoils)
}
onShowBones({ player_id, col, row }) {
this.bones.add(new Bone(this.game, col, row));
findAndDestroyFrom(player_id, this.enemies)
}
onPlayerWin(winner_skin) {
clientSocket.emit('leave game');
this.state.start('Win', true, false, winner_skin);
}
onPlayerDisconnect({ player_id }) {
findAndDestroyFrom(player_id, this.enemies);
if (this.enemies.children.length >= 1) { return }
this.onPlayerWin()
}
}
export default Play;

View file

@ -0,0 +1,79 @@
class Preload extends Phaser.State {
preload() {
// Menu:
this.load.image('main_menu', 'images/menu/main_menu.png');
this.load.image('slot_backdrop', 'images/menu/slot_backdrop.png');
this.load.spritesheet('buttons', 'images/menu/buttons.png', 200, 75);
this.load.spritesheet('check_icon', 'images/menu/accepts.png', 75, 75);
this.load.spritesheet('list_icon', 'images/menu/game_enter.png', 75, 75);
this.load.image('hot_map_preview', 'images/menu/hot_map_preview.png');
this.load.image('cold_map_preview', 'images/menu/cold_map_preview.png');
this.load.image('prev', 'images/menu/left_arrow.png');
this.load.image('next', 'images/menu/right_arrow.png');
// Map:
this.load.image('tiles', 'maps/tileset.png');
this.load.tilemap('hot_map', 'maps/hot_map.json', null, Phaser.Tilemap.TILED_JSON);
this.load.tilemap('cold_map', 'maps/cold_map.json', null, Phaser.Tilemap.TILED_JSON);
// Game:
this.load.spritesheet('explosion_center', 'images/game/explosion_center.png', 35, 35);
this.load.spritesheet('explosion_horizontal', 'images/game/explosion_horizontal.png', 35, 35);
this.load.spritesheet('explosion_vertical', 'images/game/explosion_vertical.png', 35, 35);
this.load.spritesheet('explosion_up', 'images/game/explosion_up.png', 35, 35);
this.load.spritesheet('explosion_right', 'images/game/explosion_right.png', 35, 35);
this.load.spritesheet('explosion_down', 'images/game/explosion_down.png', 35, 35);
this.load.spritesheet('explosion_left', 'images/game/explosion_left.png', 35, 35);
this.load.spritesheet('spoil_tileset', 'images/game/spoil_tileset.png', 35, 35);
this.load.spritesheet('bone_tileset', 'images/game/bone_tileset.png', 35, 35);
this.load.spritesheet('bomb_tileset', 'images/game/bombs.png', 35, 35);
this.load.image('speed_up_bonus', 'images/game/speed_up_bonus.png');
this.load.image('speed_up_no_bonus', 'images/game/speed_up_no_bonus.png');
this.load.image('delay_up_bonus', 'images/game/delay_up_bonus.png');
this.load.image('delay_up_no_bonus', 'images/game/delay_up_no_bonus.png');
this.load.image('power_up_bonus', 'images/game/power_up_bonus.png');
this.load.image('placeholder_power', 'images/game/placeholder_power.png');
this.load.image('placeholder_speed', 'images/game/placeholder_speed.png');
this.load.image('placeholder_time', 'images/game/placeholder_time.png');
// Skins:
this.load.image('bomberman_head_blank', 'images/game/chars/0-face.png');
this.load.image('bomberman_head_Theodora', 'images/game/chars/1-face.png');
this.load.image('bomberman_head_Ringo', 'images/game/chars/2-face.png');
this.load.image('bomberman_head_Jeniffer', 'images/game/chars/3-face.png');
this.load.image('bomberman_head_Godard', 'images/game/chars/4-face.png');
this.load.image('bomberman_head_Biarid', 'images/game/chars/5-face.png');
this.load.image('bomberman_head_Solia', 'images/game/chars/6-face.png');
this.load.image('bomberman_head_Kedan', 'images/game/chars/7-face.png');
this.load.image('bomberman_head_Nigob', 'images/game/chars/8-face.png');
this.load.image('bomberman_head_Baradir', 'images/game/chars/9-face.png');
this.load.image('bomberman_head_Raviel', 'images/game/chars/10-face.png');
this.load.image('bomberman_head_Valpo', 'images/game/chars/11-face.png');
this.load.spritesheet('bomberman_Theodora', 'images/game/chars/1-preview.png', 32, 32);
this.load.spritesheet('bomberman_Ringo', 'images/game/chars/2-preview.png', 32, 32);
this.load.spritesheet('bomberman_Jeniffer', 'images/game/chars/3-preview.png', 32, 32);
this.load.spritesheet('bomberman_Godard', 'images/game/chars/4-preview.png', 32, 32);
this.load.spritesheet('bomberman_Biarid', 'images/game/chars/5-preview.png', 32, 32);
this.load.spritesheet('bomberman_Solia', 'images/game/chars/6-preview.png', 32, 32);
this.load.spritesheet('bomberman_Kedan', 'images/game/chars/7-preview.png', 32, 32);
this.load.spritesheet('bomberman_Nigob', 'images/game/chars/8-preview.png', 32, 32);
this.load.spritesheet('bomberman_Baradir', 'images/game/chars/9-preview.png', 32, 32);
this.load.spritesheet('bomberman_Raviel', 'images/game/chars/10-preview.png', 32, 32);
this.load.spritesheet('bomberman_Valpo', 'images/game/chars/11-preview.png', 32, 32);
}
create() {
this.state.start('Menu');
}
}
export default Preload;

View file

@ -0,0 +1,67 @@
import { AVAILABLE_MAPS } from '../utils/constants';
import { Text, Button } from '../helpers/elements';
class SelectMap extends Phaser.State {
init() {
this.slider = new phaseSlider(this);
}
create() {
let background = this.add.image(this.game.world.centerX, this.game.world.centerY, 'main_menu');
background.anchor.setTo(0.5);
new Text({
game: this.game,
x: this.game.world.centerX,
y: this.game.world.centerY - 215,
text: 'Select Map',
style: {
font: '35px Areal',
fill: '#9ec0ba',
stroke: '#6f7975',
strokeThickness: 3
}
});
// WARN: https://github.com/netgfx/PhaseSlider/issues/1
let hotMapImage = new Phaser.Image(this.game, 0, 0, 'hot_map_preview');
let coldMapImage = new Phaser.Image(this.game, 0, 0, 'cold_map_preview');
this.slider.createSlider({
x: this.game.world.centerX - hotMapImage.width / 2,
y: this.game.world.centerY - coldMapImage.height / 2,
width: hotMapImage.width,
height: hotMapImage.height,
customHandlePrev: 'prev',
customHandleNext: 'next',
objects: [hotMapImage, coldMapImage]
});
new Button({
game: this.game,
x: this.game.world.centerX,
y: this.game.world.centerY + 195,
asset: 'check_icon',
callback: this.confirmStageSelection,
callbackContext: this,
overFrame: 1,
outFrame: 0,
downFrame: 2,
upFrame: 0,
})
}
confirmStageSelection() {
let map_name = AVAILABLE_MAPS[this.slider.getCurrentIndex()]
clientSocket.emit('create game', map_name, this.joinToNewGame.bind(this));
}
joinToNewGame(game_id) {
this.state.start('PendingGame', true, false, game_id);
}
}
export default SelectMap;

41
client/js/states/win.js Normal file
View file

@ -0,0 +1,41 @@
import { Text } from '../helpers/elements';
class Win extends Phaser.State {
init(winner_skin) {
this.skin = winner_skin
}
create() {
new Text({
game: this.game,
x: this.game.world.centerX,
y: this.game.world.centerY,
text: this.winnerText(),
style: {
font: '30px Areal',
fill: '#FFFFFF'
}
})
}
update() {
if( this.game.input.keyboard.isDown(Phaser.Keyboard.ENTER) ) {
this.returnToMenu();
}
}
returnToMenu() {
this.state.start('Menu');
}
winnerText() {
if (this.skin) {
return `Player: "${this.skin}" won! Press Enter to return to main menu.`
}
return 'Opponent left! Press Enter to return to main menu.'
}
}
export default Win;

View file

@ -0,0 +1,20 @@
export const AVAILABLE_MAPS = ['hot_map', 'cold_map']
export const TILESET = 'tiles';
export const LAYER = 'Blocks';
export const TILE_SIZE = 35;
export const EXPLOSION_TIME = 2000;
export const PING = 100;
export const SPEED = 0
export const POWER = 1
export const DELAY = 2
export const INITIAL_SPEED = 150
export const STEP_SPEED = 50
export const MAX_SPEED = 350
export const INITIAL_DELAY = 2000
export const STEP_DELAY = 500
export const MIN_DELAY = 500
export const INITIAL_POWER = 1
export const STEP_POWER = 1

15
client/js/utils/utils.js Normal file
View file

@ -0,0 +1,15 @@
export const findFrom = function(id, entities) {
for (let entity of entities.children) {
if (entity.id !== id) { continue }
return entity
}
return null;
}
export const findAndDestroyFrom = function(id, entities) {
let entity = findFrom(id, entities);
if (!entity) { return }
entity.destroy()
}

535
client/lib/phase-slide.js Normal file

File diff suppressed because one or more lines are too long

4
client/lib/phaser.min.js vendored Normal file

File diff suppressed because one or more lines are too long

69
client/maps/cold_map.json Normal file
View file

@ -0,0 +1,69 @@
{
"width": 28,
"height": 18,
"tilewidth": 35,
"tileheight": 35,
"layers": [
{
"name": "Blocks",
"width": 28,
"height": 18,
"x": 0,
"y": 0,
"data": [
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 2, 3, 3, 3, 3, 2, 1, 2, 3, 2, 3, 2, 3, 2, 3, 2, 3, 2, 3, 1, 2, 3, 3, 3, 2, 3, 1,
1, 3, 2, 1, 1, 3, 2, 2, 3, 2, 3, 2, 3, 2, 3, 2, 3, 2, 3, 2, 1, 3, 3, 2, 2, 2, 3, 1,
1, 2, 3, 3, 3, 3, 3, 2, 3, 1, 1, 1, 1, 3, 1, 1, 1, 1, 2, 3, 2, 3, 3, 1, 1, 1, 2, 1,
1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 1, 2, 3, 3, 3, 3, 3, 1,
1, 3, 3, 3, 2, 3, 3, 3, 3, 2, 2, 3, 3, 2, 1, 1, 3, 3, 2, 3, 1, 2, 3, 3, 2, 3, 2, 1,
1, 2, 2, 3, 1, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1, 2, 2, 1, 1, 3, 2, 1, 1, 1, 2, 1, 1,
1, 2, 2, 3, 2, 3, 3, 1, 3, 3, 1, 1, 3, 3, 2, 2, 3, 3, 2, 2, 3, 3, 2, 1, 3, 3, 3, 1,
1, 1, 1, 1, 2, 3, 3, 1, 3, 2, 3, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 1, 3, 2, 2, 1,
1, 3, 3, 3, 3, 3, 3, 1, 3, 2, 2, 2, 2, 3, 1, 1, 1, 1, 1, 2, 3, 3, 1, 2, 3, 2, 1, 1,
1, 3, 2, 1, 1, 2, 3, 1, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 2, 3, 2, 3, 2, 3, 2, 2, 1,
1, 1, 2, 2, 2, 2, 3, 1, 1, 1, 2, 2, 2, 1, 1, 2, 3, 2, 3, 2, 3, 2, 3, 2, 2, 3, 3, 1,
1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 3, 3, 2, 3, 2, 1, 1, 1, 1, 1, 2, 3, 3, 1,
1, 1, 1, 2, 2, 1, 1, 2, 2, 2, 3, 3, 2, 2, 2, 3, 3, 3, 3, 2, 3, 2, 3, 1, 1, 1, 1, 1,
1, 2, 3, 3, 3, 3, 3, 3, 3, 1, 2, 3, 1, 1, 1, 1, 2, 2, 3, 1, 2, 2, 3, 2, 2, 2, 2, 1,
1, 3, 2, 2, 1, 2, 3, 2, 3, 1, 3, 2, 3, 2, 3, 2, 3, 2, 3, 2, 3, 1, 1, 3, 3, 3, 3, 1,
1, 3, 3, 2, 3, 2, 1, 2, 3, 1, 3, 3, 2, 3, 2, 3, 2, 3, 3, 1, 3, 3, 3, 3, 2, 2, 2, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1
],
"opacity": 1,
"type": "tilelayer",
"visible": true,
"properties": {
"max_players": 3,
"collisionTiles": [1, 2],
"empty": 3,
"balk": 2,
"wall": 1,
"spawns": [
{ "col": 6, "row": 3 },
{ "col": 8, "row": 14 },
{ "col": 20, "row": 15 },
{ "col": 22, "row": 1 }
]
}
}
],
"renderorder": "right-down",
"tilesets": [
{
"name": "tiles",
"firstgid": 1,
"tilewidth": 35,
"tileheight": 35,
"imagewidth": 210,
"imageheight": 35,
"image": "tileset.png",
"margin": 0,
"properties": {},
"spacing": 0
}
],
"properties": {},
"orientation": "orthogonal",
"version": 1
}

26
client/maps/cold_map.tmx Normal file
View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.0.0" orientation="orthogonal" renderorder="right-down" width="28" height="18" tilewidth="35" tileheight="35" nextobjectid="1">
<tileset firstgid="1" source="untitled.tsx"/>
<layer name="Tile Layer 1" width="28" height="18">
<data encoding="csv">
4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,
4,6,5,4,6,6,5,6,6,5,6,5,5,6,5,6,5,6,6,6,6,5,6,4,6,5,6,4,
4,6,6,5,6,5,5,5,6,6,5,4,6,5,6,5,6,4,5,5,6,5,5,6,5,6,6,4,
4,4,6,6,5,6,5,4,5,6,6,4,4,4,6,6,4,4,6,6,4,6,6,5,6,6,4,4,
4,5,4,5,6,6,6,5,4,5,6,6,5,5,6,6,5,6,6,4,6,6,6,6,6,4,5,4,
4,6,5,5,5,4,6,6,5,4,5,6,6,6,6,4,6,6,4,5,5,5,4,5,4,5,6,4,
4,6,6,5,4,6,5,6,6,6,6,5,6,5,4,4,6,5,6,6,5,6,6,4,5,6,6,4,
4,6,5,6,5,6,6,4,5,6,4,5,5,5,6,6,6,5,5,5,6,5,6,6,6,5,6,4,
4,6,6,6,6,5,6,4,5,6,4,4,4,6,6,5,6,6,6,5,4,6,5,6,5,4,4,4,
4,6,4,4,5,6,6,4,5,6,5,5,6,5,5,4,4,4,6,6,4,5,6,5,6,6,6,4,
4,6,6,6,6,5,6,5,5,6,6,6,6,6,5,4,5,6,5,6,4,6,6,5,6,5,6,4,
4,6,5,5,4,6,6,6,6,5,6,4,4,6,6,6,5,6,5,6,5,5,5,4,5,6,5,4,
4,6,5,4,5,4,5,6,6,4,5,5,4,6,5,6,6,6,4,6,6,6,4,5,4,5,6,4,
4,5,4,6,5,6,5,6,4,6,6,6,6,6,5,6,6,6,6,4,6,6,5,6,5,4,5,4,
4,4,6,6,6,6,6,4,6,6,4,4,4,6,6,4,4,6,5,6,4,6,5,6,6,5,4,4,
4,6,6,5,5,5,6,6,5,5,4,6,5,5,5,5,4,5,6,5,6,5,6,6,6,6,6,4,
4,6,6,5,4,5,6,6,6,6,5,5,6,6,6,6,5,5,6,6,6,6,6,4,5,6,6,4,
4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4
</data>
</layer>
</map>

69
client/maps/hot_map.json Normal file
View file

@ -0,0 +1,69 @@
{
"width": 28,
"height": 18,
"tilewidth": 35,
"tileheight": 35,
"layers": [
{
"name": "Blocks",
"width": 28,
"height": 18,
"x": 0,
"y": 0,
"data": [
4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4,
4, 6, 5, 4, 6, 6, 5, 6, 6, 5, 6, 5, 5, 6, 5, 6, 5, 6, 6, 6, 6, 5, 6, 4, 6, 5, 6, 4,
4, 6, 6, 5, 6, 5, 5, 5, 6, 6, 5, 4, 6, 5, 6, 5, 6, 4, 5, 5, 6, 5, 5, 6, 5, 6, 6, 4,
4, 4, 6, 6, 5, 6, 5, 4, 5, 6, 6, 4, 4, 4, 6, 6, 4, 4, 6, 6, 4, 6, 6, 5, 6, 6, 4, 4,
4, 5, 4, 5, 6, 6, 6, 5, 4, 5, 6, 6, 5, 5, 6, 6, 5, 6, 6, 4, 6, 6, 6, 6, 6, 4, 5, 4,
4, 6, 5, 5, 5, 4, 6, 6, 5, 4, 5, 6, 6, 6, 6, 4, 6, 6, 4, 5, 5, 5, 4, 5, 4, 5, 6, 4,
4, 6, 6, 5, 4, 6, 5, 6, 6, 6, 6, 5, 6, 5, 4, 4, 6, 5, 6, 6, 5, 6, 6, 4, 5, 6, 6, 4,
4, 6, 5, 6, 5, 6, 6, 4, 5, 6, 4, 5, 5, 5, 6, 6, 6, 5, 5, 5, 6, 5, 6, 6, 6, 5, 6, 4,
4, 6, 6, 6, 6, 5, 6, 4, 5, 6, 4, 4, 4, 6, 6, 5, 6, 6, 6, 5, 4, 6, 5, 6, 5, 4, 4, 4,
4, 6, 4, 4, 5, 6, 6, 4, 5, 6, 5, 5, 6, 5, 5, 4, 4, 4, 6, 6, 4, 5, 6, 5, 6, 6, 6, 4,
4, 6, 6, 6, 6, 5, 6, 5, 5, 6, 6, 6, 6, 6, 5, 4, 5, 6, 5, 6, 4, 6, 6, 5, 6, 5, 6, 4,
4, 6, 5, 5, 4, 6, 6, 6, 6, 5, 6, 4, 4, 6, 6, 6, 5, 6, 5, 6, 5, 5, 5, 4, 5, 6, 5, 4,
4, 6, 5, 4, 5, 4, 5, 6, 6, 4, 5, 5, 4, 6, 5, 6, 6, 6, 4, 6, 6, 6, 4, 5, 4, 5, 6, 4,
4, 5, 4, 6, 5, 6, 5, 6, 4, 6, 6, 6, 6, 6, 5, 6, 6, 6, 6, 4, 6, 6, 5, 6, 5, 4, 5, 4,
4, 4, 6, 6, 6, 6, 6, 4, 6, 6, 4, 4, 4, 6, 6, 4, 4, 6, 5, 6, 4, 6, 5, 6, 6, 5, 4, 4,
4, 6, 6, 5, 5, 5, 6, 6, 5, 5, 4, 6, 5, 5, 5, 5, 4, 5, 6, 5, 6, 5, 6, 6, 6, 6, 6, 4,
4, 6, 6, 5, 4, 5, 6, 6, 6, 6, 5, 5, 6, 6, 6, 6, 5, 5, 6, 6, 6, 6, 6, 4, 5, 6, 6, 4,
4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4
],
"opacity": 1,
"type": "tilelayer",
"visible": true,
"properties": {
"max_players": 3,
"collisionTiles": [4, 5],
"empty": 6,
"balk": 5,
"wall": 4,
"spawns": [
{ "col": 6, "row": 4 },
{ "col": 7, "row": 15 },
{ "col": 22, "row": 3 },
{ "col": 23, "row": 13 }
]
}
}
],
"renderorder": "right-down",
"tilesets": [
{
"name": "tiles",
"firstgid": 1,
"tilewidth": 35,
"tileheight": 35,
"imagewidth": 210,
"imageheight": 35,
"image": "tileset.png",
"margin": 0,
"properties": {},
"spacing": 0
}
],
"properties": {},
"orientation": "orthogonal",
"version": 1
}

26
client/maps/hot_map.tmx Normal file
View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.0.0" orientation="orthogonal" renderorder="right-down" width="28" height="18" tilewidth="35" tileheight="35" nextobjectid="1">
<tileset firstgid="1" source="untitled.tsx"/>
<layer name="Tile Layer 1" width="28" height="18">
<data encoding="csv">
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
1,2,3,3,3,3,2,1,2,3,2,3,2,3,2,3,2,3,2,3,1,2,3,3,3,2,3,1,
1,3,2,1,1,3,2,2,3,2,3,2,3,2,3,2,3,2,3,2,1,3,3,2,2,2,3,1,
1,2,3,3,3,3,3,2,3,1,1,1,1,3,1,1,1,1,2,3,2,3,3,1,1,1,2,1,
1,1,1,1,2,2,2,2,3,3,3,2,3,3,3,3,3,2,3,3,1,2,3,3,3,3,3,1,
1,3,3,3,2,3,3,3,3,2,2,3,3,2,1,1,3,3,2,3,1,2,3,3,2,3,2,1,
1,2,2,3,1,1,1,1,2,2,1,1,1,1,1,1,2,2,1,1,3,2,1,1,1,2,1,1,
1,2,2,3,2,3,3,1,3,3,1,1,3,3,2,2,3,3,2,2,3,3,2,1,3,3,3,1,
1,1,1,1,2,3,3,1,3,2,3,3,2,3,3,3,3,3,3,3,3,3,1,1,3,2,2,1,
1,3,3,3,3,3,3,1,3,2,2,2,2,3,1,1,1,1,1,2,3,3,1,2,3,2,1,1,
1,3,2,1,1,2,3,1,3,3,3,3,3,3,1,3,3,3,3,2,3,2,3,2,3,2,2,1,
1,1,2,2,2,2,3,1,1,1,2,2,2,1,1,2,3,2,3,2,3,2,3,2,2,3,3,1,
1,3,3,3,3,3,3,3,3,3,3,2,3,2,3,3,2,3,2,1,1,1,1,1,2,3,3,1,
1,1,1,2,2,1,1,2,2,2,3,3,2,2,2,3,3,3,3,2,3,2,3,1,1,1,1,1,
1,2,3,3,3,3,3,3,3,1,2,3,1,1,1,1,2,2,3,1,2,2,3,2,2,2,2,1,
1,3,2,2,1,2,3,2,3,1,3,2,3,2,3,2,3,2,3,2,3,1,1,3,3,3,3,1,
1,3,3,2,3,2,1,2,3,1,3,3,2,3,2,3,2,3,3,1,3,3,3,3,2,2,2,1,
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
</data>
</layer>
</map>

BIN
client/maps/tileset.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

13
docker-compose.yml Normal file
View file

@ -0,0 +1,13 @@
services:
bomberman-vasin:
build: .
container_name: bomberman-vasin
restart: unless-stopped
ports:
- "3000"
networks:
- proxy
networks:
proxy:
external: true

40
package.json Normal file
View file

@ -0,0 +1,40 @@
{
"name": "bombattack",
"version": "0.0.1",
"description": "Bomberman like game",
"main": "server.js",
"scripts": {
"server": "webpack && node server/app.js",
"start": "node server/app.js"
},
"author": "Dmytro Vasin",
"repository": "github.com/DmytroVasin/bomber",
"license": "MIT",
"keywords": [
"phaser",
"socket.io",
"es6",
"webpack"
],
"engines": {
"node": "10.4.1",
"yarn": "1.16.0"
},
"dependencies": {
"express": "^4.16.2",
"faker": "^4.1.0",
"fs": "^0.0.1-security",
"path": "^0.12.7",
"serve-favicon": "^2.4.5",
"socket.io": "^2.0.4",
"uuid": "^3.1.0",
"yarn": "^1.16.0"
},
"devDependencies": {
"@babel/core": "^7.0.0-beta.31",
"@babel/preset-env": "^7.0.0-beta.31",
"babel-loader": "8.0.0-beta.0",
"webpack": "4.31.0",
"webpack-cli": "^3.3.2"
}
}

63
server/app.js Normal file
View file

@ -0,0 +1,63 @@
const express = require('express');
const socketIO = require('socket.io');
const favicon = require('serve-favicon');
const app = express();
const server = require('http').createServer(app);
const path = require('path');
const PORT = process.env.PORT || 3000;
app.use(express.static(path.join(__dirname, '..', 'client')));
app.use(favicon(path.join(__dirname, '..', 'client', 'favicon.ico')));
app.get('/', function (req, res) {
res.sendFile(path.join(__dirname, 'index'));
});
server.listen(PORT, function(){
console.log(`Express server listening on port ${PORT}`)
});
const Lobby = require('./lobby');
const Play = require('./play');
serverSocket = socketIO(server);
serverSocket.sockets.on('connection', function(client) {
console.log('New player has connected: ' + client.id);
client.on('enter lobby', Lobby.onEnterLobby);
client.on('leave lobby', Lobby.onLeaveLobby);
client.on('create game', Lobby.onCreateGame);
client.on('enter pending game', Lobby.onEnterPendingGame);
client.on('leave pending game', Lobby.onLeavePendingGame);
client.on('start game', Play.onStartGame);
client.on('update player position', Play.updatePlayerPosition);
client.on('create bomb', Play.createBomb);
client.on('pick up spoil', Play.onPickUpSpoil);
client.on('player died', Play.onPlayerDied);
client.on('leave game', Play.onLeaveGame);
client.on('disconnect', onClientDisconnect);
});
function onClientDisconnect() {
if (this.socket_game_id == null) {
console.log('Player was not be inside any game...');
return
}
console.log('Player was inside game...');
// If game is pending then use Lobby.
Lobby.onLeavePendingGame.call(this)
// If game is non-pending then use Play.
Play.onDisconnectFromGame.call(this)
}

35
server/constants.js Normal file
View file

@ -0,0 +1,35 @@
const TILE_SIZE = 35;
const EXPLOSION_TIME = 2000;
const SPOIL_CHANCE = 50;
const SPEED = 0;
const POWER = 1;
const DELAY = 2;
const EMPTY_CELL = 0;
const DESTRUCTIBLE_CELL = 2;
const NON_DESTRUCTIBLE_CELL = 1;
const INITIAL_POWER = 1
const STEP_POWER = 1
const SKINS = [
'Theodora', 'Ringo', 'Jeniffer', 'Godard',
'Biarid', 'Solia', 'Kedan', 'Nigob', 'Baradir', 'Raviel', 'Valpo'
]
module.exports = {
TILE_SIZE,
EXPLOSION_TIME,
SPOIL_CHANCE,
SPEED,
POWER,
DELAY,
EMPTY_CELL,
DESTRUCTIBLE_CELL,
NON_DESTRUCTIBLE_CELL,
INITIAL_POWER,
STEP_POWER,
SKINS
}

89
server/entity/bomb.js Normal file
View file

@ -0,0 +1,89 @@
const { EXPLOSION_TIME, DESTRUCTIBLE_CELL, NON_DESTRUCTIBLE_CELL, SPOIL_CHANCE } = require('../constants');
const { Spoil } = require('./spoil.js');
var uuidv4 = require('uuid/v4');
class Bomb {
constructor({ game, col, row, power }) {
this.id = uuidv4();
this.game = game;
this.power = power
this.explosion_time = EXPLOSION_TIME
this.col = col
this.row = row
this.blastedCells = [];
}
detonate() {
let row = this.row;
let col = this.col;
let power = this.power;
this.game.nullifyMapCell(row, col);
this.addToBlasted(row, col, 'center', false)
let explosionDirections = [
{ x: 0, y: -1, end: 'up', plumb: 'vertical' },
{ x: 1, y: 0, end: 'right', plumb: 'horizontal' },
{ x: 0, y: 1, end: 'down', plumb: 'vertical' },
{ x: -1, y: 0, end: 'left', plumb: 'horizontal' }
]
for (let direction of explosionDirections ) {
for(let i = 1; i <= power; i++) {
let currentRow = row + (direction.y * i);
let currentCol = col + (direction.x * i);
let cell = this.game.getMapCell(currentRow, currentCol);
let isWall = cell == NON_DESTRUCTIBLE_CELL
let isBalk = cell == DESTRUCTIBLE_CELL
let isLast = (i == power);
if (cell == DESTRUCTIBLE_CELL) {
this.game.nullifyMapCell(currentRow, currentCol);
}
if (isBalk || isWall || isLast) {
this.addToBlasted(currentRow, currentCol, direction.end, isBalk)
break;
}
this.addToBlasted(currentRow, currentCol, direction.plumb, isBalk)
}
}
return this.blastedCells;
}
addToBlasted(row, col, direction, destroyed) {
let spoil = this.craftSpoil(row, col);
this.blastedCells.push({
row: row,
col: col,
type: 'explosion_'+direction,
destroyed: destroyed,
spoil: spoil
})
}
craftSpoil(row, col) {
var randomNumber = Math.floor(Math.random() * 100)
if (randomNumber < SPOIL_CHANCE) {
let spoil = new Spoil(row, col)
this.game.addSpoil(spoil)
return spoil
}
return null;
}
}
exports.Bomb = Bomb;

136
server/entity/game.js Normal file
View file

@ -0,0 +1,136 @@
const { TILE_SIZE, EMPTY_CELL, DESTRUCTIBLE_CELL, NON_DESTRUCTIBLE_CELL, SKINS } = require('../constants');
var { Player } = require('./player');
var { Bomb } = require('./bomb.js');
var uuidv4 = require('uuid/v4');
var faker = require('faker');
class Game {
constructor({ map_name }) {
this.id = uuidv4();
this.name = faker.commerce.color()
this.map_name = map_name;
this.layer_info = require('../../client/maps/' + this.map_name + '.json').layers[0]
this.max_players = this.layer_info.properties.max_players
// NOTE: we can`t use new Map - because Socket.io do not support such format
this.players = {}
// NOTE: Copy objct - not reference
this.playerSkins = SKINS.slice()
// NOTE: Copy objct - not reference
this.playerSpawns = this.layer_info.properties.spawns.slice()
this.shadow_map = this.createMapData();
this.spoils = new Map();
this.bombs = new Map();
}
addPlayer(id) {
let skin = this.getAndRemoveSkin()
let [spawn, spawnOnGrid] = this.getAndRemoveSpawn()
let player = new Player({ id: id, skin: skin, spawn: spawn, spawnOnGrid: spawnOnGrid })
this.players[player.id] = player
}
removePlayer(id) {
let player = this.players[id];
this.playerSkins.push(player.skin)
this.playerSpawns.push(player.spawnOnGrid)
delete this.players[id];
}
isEmpty() {
return Object.keys(this.players).length === 0
}
isFull() {
return Object.keys(this.players).length === this.max_players
}
getAndRemoveSkin() {
// NOTE: we can user here simple .pop()
let index = Math.floor(Math.random() * this.playerSkins.length);
let randomSkin = this.playerSkins[index];
this.playerSkins.splice(index, 1);
return randomSkin;
}
getAndRemoveSpawn() {
let index = Math.floor(Math.random() * this.playerSpawns.length);
let spawnOnGrid = this.playerSpawns[index];
this.playerSpawns.splice(index, 1);
let spawn = { x: spawnOnGrid.col * TILE_SIZE, y: spawnOnGrid.row * TILE_SIZE };
return [spawn, spawnOnGrid];
}
createMapData() {
let tiles = this.layer_info.data
let width = this.layer_info.width
let height = this.layer_info.height
let empty = this.layer_info.properties.empty
let wall = this.layer_info.properties.wall
let balk = this.layer_info.properties.balk
let mapMatrix = [];
let i = 0;
for(let row = 0; row < height; row++) {
mapMatrix.push([]);
for(let col = 0; col < width; col++) {
mapMatrix[row][col] = EMPTY_CELL;
if(tiles[i] == balk) {
mapMatrix[row][col] = DESTRUCTIBLE_CELL;
} else if(tiles[i] == wall) {
mapMatrix[row][col] = NON_DESTRUCTIBLE_CELL;
}
i++;
}
}
return mapMatrix;
}
addBomb({ col, row, power }) {
let bomb = new Bomb({ game: this, col: col, row: row, power: power });
if ( this.bombs.get(bomb.id) ) {
return false
}
this.bombs.set(bomb.id, bomb);
return bomb
}
getMapCell(row, col) {
return this.shadow_map[row][col]
}
nullifyMapCell(row, col) {
this.shadow_map[row][col] = EMPTY_CELL
}
findSpoil(spoil_id){
return this.spoils.get(spoil_id)
}
addSpoil(spoil) {
this.spoils.set(spoil.id, spoil);
}
deleteSpoil(spoil_id){
this.spoils.delete(spoil_id)
}
}
exports.Game = Game;

28
server/entity/player.js Normal file
View file

@ -0,0 +1,28 @@
const { POWER, INITIAL_POWER, STEP_POWER } = require('../constants');
class Player {
constructor({ id, skin, spawn, spawnOnGrid }) {
this.id = id;
this.skin = skin;
this.spawn = spawn;
this.spawnOnGrid = spawnOnGrid;
this.isAlive = true;
this.power = INITIAL_POWER;
}
pickSpoil(spoil_type) {
if (spoil_type === POWER){
this.power += STEP_POWER
}
}
dead() {
this.isAlive = false;
}
}
exports.Player = Player;

21
server/entity/spoil.js Normal file
View file

@ -0,0 +1,21 @@
const { SPEED, POWER, DELAY } = require('../constants');
var uuidv4 = require('uuid/v4');
class Spoil {
constructor(row, col) {
this.id = uuidv4();
this.row = row;
this.col = col;
this.spoil_type = this.spoilType()
}
spoilType(){
return [SPEED, POWER, DELAY][Math.floor(Math.random() * 3)]
}
}
exports.Spoil = Spoil;

93
server/lobby.js Normal file
View file

@ -0,0 +1,93 @@
var { Game } = require('./entity/game');
var lobbyId = 'lobby_room';
var pendingGames = new Map();
var Lobby = {
onEnterLobby: function (callback) {
// this == socket
this.join(lobbyId);
callback( Lobby.availablePendingGames() )
},
onLeaveLobby: function () {
this.leave(lobbyId);
},
onCreateGame: function(map_name, callback) {
var newGame = new Game({ map_name: map_name });
pendingGames.set(newGame.id, newGame);
Lobby.updateLobbyGames()
callback({ game_id: newGame.id });
},
onEnterPendingGame: function ({ game_id }) {
let current_game = pendingGames.get(game_id);
this.join(current_game.id);
// NOTE: We save current_game_id inside Socket connection.
// We should knew it on disconnect
this.socket_game_id = current_game.id;
current_game.addPlayer(this.id);
if ( current_game.isFull() ){
Lobby.updateLobbyGames();
}
Lobby.updateCurrentGame(current_game)
},
onLeavePendingGame: function() {
let current_game = pendingGames.get(this.socket_game_id);
if (current_game) {
this.leave(current_game.id);
this.socket_game_id = null;
current_game.removePlayer(this.id);
if( current_game.isEmpty() ){
pendingGames.delete(current_game.id);
Lobby.updateLobbyGames();
return
}
if ( !current_game.isFull() ){
Lobby.updateLobbyGames();
}
Lobby.updateCurrentGame(current_game)
}
},
deletePendingGame: function(game_id) {
let game = pendingGames.get(game_id);
pendingGames.delete(game.id);
Lobby.updateLobbyGames();
return game
},
availablePendingGames: function() {
return [...pendingGames.values()].filter(item => item.isFull() === false );
},
updateLobbyGames: function() {
serverSocket.sockets.in(lobbyId).emit('display pending games', Lobby.availablePendingGames() );
},
updateCurrentGame: function(game) {
// Emit to ALL including ME
serverSocket.sockets.in(game.id).emit('update game', { current_game: game });
}
}
module.exports = Lobby;

Some files were not shown because too many files have changed in this diff Show more