Last active
February 9, 2017 04:44
-
-
Save timetocode/f8aa033e10e044f63bc97907c393690e to your computer and use it in GitHub Desktop.
Example of using nengi.js. NOTE: the clientside rendering code is is skipped, and this example primarily highlights where your game code could use nengi.. the serverside game logic is more revealing (and messy)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var nengi = require('../../nengi') | |
var EDictionary = require('../../nengi/external/EDictionary') | |
var protocols = require('../protocols') | |
// Graphics code | |
var Squid = require('./entity/Squid') | |
var Fish = require('./entity/Fish') | |
var Shark = require('./entity/Shark') | |
var Ink = require('./effect/Ink') | |
var Blood = require('./effect/Blood') | |
var Background = require('./background/Background') | |
var HUD = require('./ui/HTMLHUD') | |
// Commands to send to server | |
var MousePosition = require('./command/MousePosition') | |
var Respawn = require('./command/Respawn') | |
var ChangeName = require('./command/ChangeName') | |
function Client(renderer, serverAddress, name) { | |
this.serverAddress = serverAddress | |
this.playerName = name | |
this.network = new nengi.Client(protocols) | |
this.isConnected = false | |
this.entities = new EDictionary() | |
this.myEntity = null | |
this.mouseX = 0 | |
this.mouseY = 0 | |
this.tick = 0 | |
// touch controls (mouse controls skipped to shorten code) | |
document.addEventListener('touchstart', e => { | |
var touch = e.changedTouches[0] | |
this.mouseX = touch.clientX | |
this.mouseY = touch.clientY | |
}) | |
document.addEventListener('touchmove', e => { | |
var touch = e.changedTouches[0] | |
this.mouseX = touch.clientX | |
this.mouseY = touch.clientY | |
}) | |
// all renderer + background code skipped | |
} | |
// connect to the server | |
Client.prototype.connect = function() { | |
this.network.connect(this.serverAddress, () => { | |
// when we connect, send our desired player name | |
var changeName = new ChangeName() | |
changeName.name = this.playerName | |
this.isConnected = true | |
this.network.addCommand(this.tick, changeName) | |
}) | |
} | |
// read data from the server | |
Client.prototype.processSnapshot = function(snapshot) { | |
/* | |
* Snapshots in nengi have 5 sections | |
* | |
* snapshot.createEntities: | |
* contains the full data needed to create an entity | |
* client should store the entity and render it | |
* example of one entity: Fish { | |
* id: 25, | |
* x: 250, | |
* y: 231, | |
* rotation: 1.12398321, | |
* weight: 5, | |
* protocol: { | |
* name: 'Fish', | |
* // result of protocol code skipped | |
* } | |
* } | |
* | |
* snapshot.updateEntities: | |
* this contains data to update an entity already visible to the client | |
* client should apply the change to the entity | |
* e.g. { id: 25, prop: 'x', value: 245 } // denotes that fish 25 now has an x of 245 | |
* | |
* snapshot.deleteEntities: | |
* contains a list of ids that are no longer visible to the client | |
* client should delete the entity and remove it from rendering | |
* e.g. 25 // denotes that fish 25 is dead or out of view | |
* | |
* snapshot.messages: | |
* contains a list of messages sent from the server | |
* messages are explicitly sent by the server | |
* (e.g. (message, client) -> client will receive message) | |
* client should invoke custom logic for the message | |
* in this case, it wil display the "you were eaten" dialog | |
* example of one message: EndGameStats { | |
* eatenBy: 'LeetH4x0rShark', | |
* fishEaten: 26, | |
* maxWeight: 2312, | |
* protocol: { | |
* name: 'EndGameStats', | |
* // result of protocol code skipped | |
* } | |
* } | |
* | |
* snapshot.localEvents: | |
* contains a list localEvents sent from the server | |
* localEvents are just like messages, but they are sent spatially | |
* (e.g. (lEvt) -> clients that can see lEvt.x, lEvt.y receive it) | |
* client should invoke custom logic for the localevent | |
* in this case, it creates a blood effect at the specific location | |
* example of one message: Blood { | |
* x: 324, | |
* y: 26, | |
* protocol: { | |
* name: 'Blood', | |
* // result of protocol code skipped | |
* } | |
* } | |
*/ | |
snapshot.createEntities.forEach(entity => { | |
if (entity.protocol.name === 'Fish') { | |
var fish = new Fish(entity) | |
this.entities.add(fish) | |
// skipped: add entity to renderer | |
} | |
if (entity.protocol.name === 'Shark') { | |
var fish = new Shark(entity) | |
this.entities.add(fish) | |
// skipped: add entity to renderer | |
} | |
}) | |
snapshot.updateEntities.forEach(update => { | |
var entity = this.entities.get(update.id) | |
entity[update.prop] = update.value | |
}) | |
snapshot.deleteEntities.forEach(id => { | |
var entity = this.entities.get(id) | |
this.entities.remove(entity) | |
// skipped: remove entity from renderer | |
}) | |
snapshot.messages.forEach(message => { | |
if (message.protocol.name === 'Identity') { | |
// this mesage tells us which entity our player controls | |
this.myEntity = this.entities.get(message.id) | |
} | |
if (message.protocol.name === 'Scores') { | |
// update the scores with data contained in 'message' | |
} | |
if (message.protocol.name === 'EndGameStats') { | |
// skipped: create the end game dialog "you were eaten by BLAH, play again?" | |
document.getElementById('buttonRespawn').addEventListener('click', () => { | |
// if clicked play again, send 'Respawn' command to server | |
var respawn = new Respawn() | |
this.network.addCommand(this.tick, respawn) | |
// skipped: hide the end game dialog | |
}) | |
} | |
}) | |
snapshot.localEvents.forEach(localEvent => { | |
if (localEvent.protocol.name === 'Blood'){ | |
// skipped: create and render the blood effect | |
} | |
if (localEvent.protocol.name === 'Ink') { | |
// skipped create and render the squid ink effect | |
} | |
}) | |
} | |
// run at 60 fps on the client | |
Client.prototype.update = function(delta, tick, now) { | |
if (!this.isConnected) { | |
return | |
} | |
// entity interpolatio: 100 ms | |
var renderTime = Date.now() - 100 | |
var serverState = this.network.interpolate(renderTime) | |
// process any queued up data from the server (e.g. if the browser was alt-tabbed) | |
serverState.late.forEach(lateSnapshot => { | |
this.processSnapshot(lateSnapshot) | |
}) | |
// process the most recent interpolated data | |
if (serverState.interpolated) { | |
this.processSnapshot(serverState.interpolated) | |
} | |
// send input from client to server | |
// in the case of sharkz.io this is just the mouse position | |
var mousePosition = new MousePosition() | |
mousePosition.x = this.mouseX - window.innerWidth * 0.5 | |
mousePosition.y = this.mouseY - window.innerHeight * 0.5 | |
this.network.addCommand(this.tick, mousePosition) | |
// sends all of the commands that have built up this frame... in most games | |
// this can be numerous commands, in sharkz.io it is just one | |
this.network.sendCommands(this.tick) | |
if (this.myEntity) { | |
// skipped: center camera on the entity controlled by the player | |
// skipped: move entity using input prediction instead of interpolation | |
} | |
// skipped: render everything | |
this.tick++ | |
} | |
module.exports = Client |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// an example of an entity on the client side.. in this case its a sprite in PIXI.js | |
function Fish(entity) { | |
PIXI.Container.call(this) | |
// setup the fish based on the entity data (see serverside.Fish.js, that's where this comes from) | |
this.id = entity.id | |
this.x = entity.x | |
this.y = entity.y | |
this.weight = entity.weight | |
this.rotation = entity.rotation | |
this.frameNumber = 0 | |
this.body = new PIXI.Sprite.fromFrame('shark0.png') | |
this.swimTimer = 0 | |
this.swimSpeed = (Math.random() * 10) + 5 | |
this.body.anchor.x = this.body.anchor.y = 0.5 | |
this.body.scale.x = this.body.scale.y = (this.weight/12) | |
this.body.tint = Math.random() * 0x1111ee | |
this.addChild(this.body) | |
} | |
Fish.prototype = Object.create(PIXI.Container.prototype) | |
Fish.prototype.constructor = Fish | |
// all this does is play an animation of the fish swimming | |
Fish.prototype.update = function(delta, tick, now) { | |
this.body.texture = PIXI.Texture.fromFrame('shark' + this.frameNumber +'.png') | |
//console.log('swim', this.swimSpeed, this.swimTimer) | |
this.swimTimer += this.swimSpeed | |
if (this.swimTimer >= 15) { | |
this.swimTimer = 0 | |
this.frameNumber++ | |
} | |
if (this.frameNumber > 15) { | |
this.frameNumber = 0 | |
} | |
} | |
module.exports = Fish |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var nengi = require('../../nengi') | |
var EDictionary = require('../../nengi/external/EDictionary') | |
var protocols = require('../protocols') | |
var Vector2 = require('../external/Vector2') | |
var Fish = require('./entity/Fish') | |
var Squid = require('./entity/Squid') | |
var Shark = require('./entity/Shark') | |
var Identity = require('./message/Identity') | |
var SharkStats = require('./message/SharkStats') | |
var EndGameStats = require('./message/EndGameStats') | |
var Scores = require('./message/Scores') | |
var Blood = require('./localEvent/Blood') | |
var SAT = require('../external/SAT') | |
var BoidSystem = require('./entity/BoidSys') | |
var MasterServerConnector = require('./MasterServerConnector') | |
function Server(id, name) { | |
// port which the server is running on, for listing on master server | |
this.id = id | |
this.name = name | |
this.instance = new nengi.Instance(protocols) | |
// server registers itself with the master game server (not nengi-related) | |
this.master = new MasterServerConnector(id, name) | |
this.master.connect(1337, '127.0.0.1', 'kitty', () => { | |
console.log('connected to master') | |
}) | |
this.entities = new EDictionary() | |
this.boundary = { | |
x: 0, | |
y: 0, | |
width: 5000, | |
height: 5000 | |
} | |
this.boidSystem = new BoidSystem({ | |
boundary: this.boundary, | |
velocityLimit: 24, | |
accelerationLimit: 1.5, | |
separationForce: 0.25, | |
separationDistance: 50, | |
cohesionForce: 0.3, | |
cohesionDistance: 400, | |
alignmentForce: 0.1, | |
alignmentDistance: 400 | |
}) | |
// fish are attracted slightly to the center of the arena | |
this.boidSystem.addAttractor({ | |
x: this.boundary.width * 0.5, | |
y: this.boundary.height * 0.5, | |
radius: 9999, | |
force: 0.05 | |
}) | |
this.fishCount = 150 | |
this.sharks = [] | |
this.fish = [] | |
this.squid = [] | |
this.scores = {} | |
this.instance.onConnect(client => { | |
// defualt client to seeing in a 2000x2000 area centered at 0,0 | |
client.view = { | |
x: 0, | |
y: 0, | |
halfWidth: 1000, | |
halfHeight: 1000 | |
} | |
this.spawnShark(client) | |
}) | |
this.instance.onDisconnect(client => { | |
if (client.entity.id !== -1) { | |
this.removeShark(client.entity) | |
} | |
}) | |
// send out the scoreboard every 2 seconds | |
setInterval(() => { | |
// skipped | |
}, 2000) | |
} | |
Server.prototype.spawnFish = function() { | |
//console.log('spawning fish') | |
var fishy = new Fish() | |
fishy.x = Math.random() * this.boundary.width | |
fishy.y = Math.random() * this.boundary.height | |
// adds fish to nengi instance | |
this.instance.addEntity(fishy) | |
// keep track of fish ourselves | |
this.fish.push(fishy) | |
// register fish with the boid system | |
this.boidSystem.add(fishy) | |
} | |
Server.prototype.removeFish = function(fish) { | |
var fish = this.instance.removeEntity(fish) | |
this.fish.splice(this.fish.indexOf(fish), 1) | |
this.boidSystem.remove(fish) | |
} | |
Server.prototype.spawnShark = function(client) { | |
var shark = new Shark() | |
shark.x = Math.random() * this.boundary.width | |
shark.y = Math.random() * this.boundary.height | |
shark.boundary = this.boundary | |
// wire together the nengi client and the shark entity, so that we can get | |
// to either from either without extra code | |
shark.name = 'Shark' + client.id | |
shark.client = client | |
client.entity = shark | |
client.mouseX = 0 | |
client.mouseY = 0 | |
this.instance.addEntity(shark) | |
this.scores[client.entity.id] = shark.weight | |
this.sharks.push(shark) | |
// the shark has a built in attractor with a negative value that scares fish | |
this.boidSystem.addAttractor(shark) | |
// send the player the id of the shark they control | |
var identity = new Identity() | |
identity.id = shark.id | |
this.instance.message(identity, client) | |
} | |
Server.prototype.removeShark = function(shark) { | |
this.boidSystem.attractors.splice(this.boidSystem.attractors.indexOf(shark), 1) | |
delete this.scores[shark.id] | |
this.sharks.splice(this.sharks.indexOf(shark), 1) | |
this.instance.removeEntity(shark) | |
// TODO there is a bug where one score never gets cleared | |
} | |
Server.prototype.update = function(delta) { | |
// commands from players sent from the client via nengi | |
var cmd = null | |
while (cmd = this.instance.getNextCommand()) { | |
var tick = cmd.tick | |
var client = cmd.client | |
var entity = cmd.client.entity | |
cmd.commands.forEach(command => { | |
// name change | |
if (command.protocol.name === 'ChangeName') { | |
client.entity.name = command.name | |
} | |
// mouse | |
if (command.protocol.name === 'MousePosition') { | |
// shark.move just aims the shark at x, y | |
// shark update has movent logic that chases the mouse | |
client.entity.move(command.x, command.y) | |
} | |
// respawning (clicking 'play again' after dying) | |
if (command.protocol.name === 'Respawn') { | |
if (client.entity.id === -1) { | |
this.spawnShark(client) | |
} | |
} | |
}) | |
} | |
this.instance.clients.forEach(client => { | |
var shark = client.entity | |
// keep the client's view centered on the shark | |
// nengi uses the view to determine what to send to the client | |
client.view.x = shark.x | |
client.view.y = shark.y | |
// every frame send the player an update about their rank/size | |
var sharkStatsMessage = new SharkStats() | |
sharkStatsMessage.rank = shark.rank | |
sharkStatsMessage.playerCount = this.sharks.length | |
this.instance.message(sharkStatsMessage, client) | |
this.scores[client.entity.id] = shark.weight | |
}) | |
// BRUTE FORCE collision logic; leave this for the nengi demo code as an example | |
// but in the production code, replace collisions with an efficient spatial structure | |
this.sharks.forEach(shark => { | |
var biteCollider = shark.getBiteCollider() | |
var sharkBodyCollider = shark.getBodyCollider() | |
// FISH collisions | |
var response = new SAT.Response() | |
var fishEaten = [] | |
this.fish.forEach(fishy => { | |
var fishyBodyCollider = fishy.getBodyCollider() | |
// DOES shark body touch fish body? they push eachother. Why did i make this? lol. | |
if (SAT.testPolygonPolygon(fishyBodyCollider, sharkBodyCollider, response)) { | |
var totalWeight = fishy.weight + shark.weight | |
var fishRatio = fishy.weight / totalWeight | |
var sharkRatio = shark.weight / totalWeight | |
//var fishyRatioOfTotalWeight | |
fishy.x -= response.overlapV.x * sharkRatio | |
fishy.y -= response.overlapV.y * sharkRatio | |
shark.x += response.overlapV.x * fishRatio | |
shark.y += response.overlapV.y * fishRatio | |
} | |
response.clear() | |
// DOES shark mouth touch fish? Eat the fish | |
if (SAT.testPolygonPolygon(fishyBodyCollider, biteCollider, response)) { | |
fishEaten.push(fishy) | |
} | |
response.clear() | |
}) | |
// we don't remove the fish up above because we're still going through their array | |
// so we just list the fish to remove and then remove them all in one batch here | |
fishEaten.forEach(fishy => { | |
this.removeFish(fishy) | |
// weight gain | |
shark.weight += fishy.weight/5 | |
// fish eaten stat | |
shark.fishEaten++ | |
}) | |
// SHARK collisions | |
this.sharks.forEach(otherShark => { | |
if (otherShark !== shark) { | |
var otherSharkBodyCollider = otherShark.getBodyCollider() | |
// Sharks can shove eachother around based on their size | |
if (SAT.testPolygonPolygon(otherSharkBodyCollider, sharkBodyCollider, response)) { | |
var totalWeight = otherShark.weight + shark.weight | |
var fishRatio = otherShark.weight / totalWeight | |
var sharkRatio = otherShark.weight / totalWeight | |
// var fishyRatioOfTotalWeight | |
otherShark.x -= response.overlapV.x * sharkRatio | |
otherShark.y -= response.overlapV.y * sharkRatio | |
shark.x += response.overlapV.x * fishRatio | |
shark.y += response.overlapV.y * fishRatio | |
} | |
// Sharks whose mouths overlap other sharks bodies can bite them | |
// shark.canBite() implements a 1 second cooldown on biting | |
if (shark.canBite()) { | |
if (SAT.testPolygonPolygon(otherSharkBodyCollider, biteCollider, response)) { | |
shark.bite() | |
shark.sharksBit++ | |
// this is how much weight the viticim loses | |
// and the attacker gains | |
var biteStrength = shark.weight * 0.25 | |
var food = biteStrength | |
if (biteStrength > otherShark.weight) { | |
food = otherShark.weight | |
} | |
shark.weight += food * 0.5 | |
otherShark.weight -= food | |
// create some blood in the water | |
var blood = new Blood() | |
blood.x = otherShark.x | |
blood.y = otherShark.y | |
blood.sourceId = otherShark.id | |
this.instance.addLocalEvent(blood) | |
// did one of the sharks die? getting bit to < 50 weight == death | |
if (otherShark.weight <= 50) { | |
otherShark.weight = 0 | |
this.removeShark(otherShark) | |
// send the dead shark's client a game over message | |
var gameOver = new EndGameStats() | |
gameOver.eatenBy = shark.name | |
gameOver.sharksBit = otherShark.sharksBit | |
gameOver.maxWeight = otherShark.maxWeight | |
gameOver.timeAlive = Date.now() - otherShark.bornTimestamp | |
if (otherShark.client) { | |
this.instance.message(gameOver, otherShark.client) | |
} | |
} | |
} | |
} | |
} | |
}) | |
// call the shark update (controls the bite timer, slow decay of weight, and continually moving towards the mouse) | |
shark.update(delta) | |
}) | |
// all the fish related boid movement logic | |
this.boidSystem.update() | |
// respawn fish up until a certian number | |
if (this.fish.length < this.fishCount) { | |
var deficit = this.fishCount - this.fish.length | |
for (var i = 0; i < deficit; i++) { | |
this.spawnFish() | |
} | |
} | |
/* | |
// squids are disabled at the moment | |
if (this.squid.length < 3) { | |
var deficit = 3 - this.squid.length | |
for (var i = 0; i < deficit; i++) { | |
this.spawnSquid() | |
} | |
} | |
*/ | |
// nengi update (which will create and send a snapshot of all relevant gamestate to all players) | |
this.instance.update() | |
} | |
module.exports = Server |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var nengi = require('../../../nengi') | |
var SAT = require('../../external/SAT') | |
// an example of what a nengi entity looks like on the server | |
function Fish() { | |
this.x = 0 | |
this.y = 0 | |
// used by the boids logic | |
this.vx = 0 | |
this.vy = 0 | |
this.ax = 0 | |
this.ay = 0 | |
this.rotation = 0 | |
this.weight = (Math.random() * 10) + 10 | |
} | |
Fish.prototype.getBodyCollider = function() { | |
var coef = (this.weight / 12) | |
var polygon = new SAT.Polygon(new SAT.Vector(0, 0), [ | |
new SAT.Vector(30 * coef, -9 * coef), | |
new SAT.Vector(30 * coef, 9 * coef), | |
new SAT.Vector(-20 * coef, -5 * coef), | |
new SAT.Vector(-20 * coef, 5 * coef), | |
]) | |
polygon.rotate(this.rotation) | |
polygon.translate(this.x, this.y) | |
return polygon | |
} | |
// this is what makes the fish work with nengi.. only the properties below are networked | |
Fish.prototype.protocol = new nengi.EntityProtocol({ | |
x: { type: nengi.UInt16, interp: true }, | |
y: { type: nengi.UInt16, interp: true }, | |
rotation: nengi.Float32, | |
weight: nengi.UInt8 | |
}) | |
module.exports = Fish |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment