Skip to content

Instantly share code, notes, and snippets.

@timetocode
Last active February 9, 2017 04:44
Show Gist options
  • Save timetocode/f8aa033e10e044f63bc97907c393690e to your computer and use it in GitHub Desktop.
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)
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
// 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
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
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