├── .gitignore ├── .vscode ├── settings.json └── tasks.json ├── README.md ├── assets └── .keep ├── project.flow ├── server.hxml └── src ├── BuildInfo.hx ├── Main.hx ├── game ├── GameState.hx ├── Object.hx └── World.hx └── mp ├── Command.hx ├── Message.hx └── Server.hx /.gitignore: -------------------------------------------------------------------------------- 1 | /bin/ 2 | /client.hxml -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "haxe.displayConfigurations": [ 3 | ["client.hxml"], 4 | ["server.hxml"] 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "problemMatcher": "$haxe", 4 | "tasks": [ 5 | { 6 | "taskName": "build web", 7 | "command": "haxelib", 8 | "args": ["run","flow","build","web"], 9 | "isBuildCommand": true 10 | }, 11 | { 12 | "taskName": "build desktop", 13 | "command": "haxelib", 14 | "args": ["run","flow","build"], 15 | "isBuildCommand": true 16 | }, 17 | { 18 | "taskName": "build server", 19 | "command": "haxe", 20 | "args": ["server.hxml"], 21 | "isBuildCommand": true 22 | }, 23 | { 24 | "taskName": "generate client.hxml (web)", 25 | "isShellCommand": true, 26 | "command": "haxelib run flow info web --hxml > client.hxml" 27 | }, 28 | { 29 | "taskName": "generate client.hxml (desktop)", 30 | "isShellCommand": true, 31 | "command": "haxelib run flow info --hxml > client.hxml" 32 | } 33 | ] 34 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HKOSCon2017 Haxe Game Workshop 2 | 3 | [![Join the chat at https://gitter.im/hkoscon2017-haxe-game/Lobby](https://badges.gitter.im/hkoscon2017-haxe-game/Lobby.svg)](https://gitter.im/hkoscon2017-haxe-game/Lobby?utm_source=badge&utm_medium=badge&utm_content=badge) 4 | 5 | Workshop info: [Build a cross-platform game in Haxe](https://hkoscon.org/2017/topics/build-a-cross-platform-game-in-haxe/) 6 | 7 | This is an [agar.io](https://agar.io/) clone to demonstrate the capability of Haxe in building cross platform games, 8 | where codes are shared among multiple game platforms (web, mac, windows, android & ios), 9 | as well as between game client and game server for multiplayer games. 10 | 11 | Demo: https://kevinresol.github.io/hkoscon2017-haxe-game/ (single player mode) 12 | 13 | ## Preparation 14 | 15 | Participants should have programming experience with at least one programming language. Proficiency with JavaScript, Java, or C# is ideal, but experience with other languages such as C/C++, Python, or Ruby is also sufficient. Participants should have some familiarity using the command line. Participants should bring their own laptop computer, with either Windows, Mac, or Linux installed. 16 | 17 | Please follow the instruction listed below before the workshop, such that you can progress smoothly. 18 | 19 | ### Install Haxe 20 | 21 | Get Haxe from http://haxe.org/download/. 22 | 23 | ### Install Node.js 24 | 25 | Get Node.js from https://nodejs.org/, and optionally [yarn](https://yarnpkg.com/). 26 | 27 | ### Install Git (used by snowfall) 28 | 29 | Get [Git](https://git-scm.com/) and make it available in the command line. i.e. `git --version` should print something like `git version 2.7.4`. 30 | 31 | ### Install Haxe Libraries 32 | 33 | Note: if it is the first time you are using `haxelib`, you will have to run `haxelib setup` first. 34 | 35 | 36 | * [Luxe](https://luxeengine.com). According to the instruction at https://luxeengine.com/get/: 37 | 38 | ```bash 39 | haxelib install snowfall 40 | haxelib run snowfall update luxe 41 | ``` 42 | 43 | * [haxe-ws](https://github.com/soywiz/haxe-ws) 44 | 45 | ```bash 46 | haxelib install haxe-ws 47 | ``` 48 | 49 | * [hxnodejs](https://github.com/HaxeFoundation/hxnodejs) 50 | 51 | ```bash 52 | haxelib install hxnodejs 53 | ``` 54 | 55 | 56 | ### Install Visual Studio Code 57 | 58 | Although in theory you can use any [IDE or text editor](https://haxe.org/documentation/introduction/editors-and-ides.html), we recommend using [Visual Studio Code](https://code.visualstudio.com/) with the [Haxe Extension Pack](https://marketplace.visualstudio.com/items?itemName=vshaxe.haxe-extension-pack), which offers the best Haxe support at the moment. 59 | 60 | ### Install C++ development tools 61 | 62 | (Optional, for building native targets, e.g. mac, windows, linux, ios, android) 63 | Depending on your OS, [Visual Studio](https://www.visualstudio.com/) (Windows), [XCode](https://developer.apple.com/xcode/) (Mac), or gcc (Linux). 64 | 65 | ## Notes 66 | 67 | We will introduce Haxe and go through creating a simple multi-player game during the workshop together. The instruction will be given during the workshop. Below are some notes for future reference. 68 | 69 | ### quick links 70 | 71 | * Haxe Manual: http://haxe.org/manual/introduction.html 72 | * Haxe API docs: https://devdocs.io/ (or http://api.haxe.org/) 73 | * Try Haxe: https://try.haxe.org/ 74 | * Luxe engine beginners guide: https://luxeengine.com/guide/ 75 | * Luxe engine API docs: https://luxeengine.com/docs/api/ 76 | * `haxe-ws` library: http://lib.haxe.org/p/haxe-ws 77 | * `ws` npm package: https://www.npmjs.com/package/ws#usage-examples 78 | 79 | ### Server 80 | 81 | ```bash 82 | haxe server.hxml 83 | cd bin/server 84 | npm install ws # or `yarn add ws` 85 | node server.js 86 | ``` 87 | 88 | ### Client 89 | 90 | ```bash 91 | haxelib run flow run web 92 | haxelib run flow run mac 93 | haxelib run flow run windows 94 | ``` 95 | 96 | By default the game client is built for multiplayer mode (yes, multiplayer/singleplayer is determined at compile-time for simplicity). 97 | To build for single player mode, simply go to `project.flow` and comment out the `MULTIPLAYER` flag: 98 | 99 | ``` 100 | // defines : ["MULTIPLAYER"], 101 | ``` 102 | 103 | 104 | To give proper code completion, VS Code needs a hxml file, which can be generated by 105 | ``` 106 | haxelib run flow info [web|windows|mac|linux] --hxml > client.hxml 107 | ``` 108 | See https://github.com/vshaxe/vshaxe/wiki/Framework-Notes#snow. 109 | 110 | ### Shared Code 111 | 112 | The `World` class contains the core game logic. 113 | When the game is set to single-player mode, the `World` is run in the client. 114 | In other words, the same piece of Haxe code for the `World` class is compiled into different platforms 115 | (web, mac, windows, android & ios). 116 | When the game is in multi-player mode, the `World` is run on the server. Again, the same piece of 117 | Haxe code for the `World` class is compiled into the server language (nodejs for our choice here). 118 | 119 | The `Command` and `Message` enums represents the protocol between the client and server in multiplayer 120 | mode. The same piece of code is used in both client and server. 121 | 122 | ## Feedback / Questions 123 | 124 | Feel free to [open issues](https://github.com/kevinresol/hkoscon2017-haxe-game/issues) or contact us directly. 125 | 126 | ## License 127 | 128 |

129 | 131 | CC0 132 | 133 |
134 | To the extent possible under law, 135 | 136 | Andy Li & Kevin Leung 137 | has waived all copyright and related or neighboring rights to 138 | "Build a cross-platform game in Haxe" workshop. 139 | This work is published from: 140 | 142 | Hong Kong. 143 |

144 | -------------------------------------------------------------------------------- /assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinresol/hkoscon2017-haxe-game/93395c87e2b20e3306d826f4ffb798f6c89dbb1d/assets/.keep -------------------------------------------------------------------------------- /project.flow: -------------------------------------------------------------------------------- 1 | { 2 | 3 | project : { 4 | 5 | //These first two are required 6 | name : 'empty', 7 | version : '1.0.0', 8 | author : 'luxeengine', 9 | 10 | //This configures your app. 11 | //The package is important, it's used 12 | //for save locations, initializing mobile project files etc 13 | app : { 14 | name : 'HaxeAgar', 15 | package : 'org.hkoscon' 16 | }, 17 | 18 | //This configures the build process 19 | build : { 20 | dependencies : { 21 | luxe : '*', 22 | "haxe-ws" : "*" 23 | }, 24 | defines : ["MULTIPLAYER"], 25 | flags : [ 26 | "-dce", "full", 27 | "--macro", "keep('phoenix.Vector')" 28 | ] 29 | }, 30 | 31 | //Copies over all the assets to the output 32 | files : { 33 | assets : 'assets/' 34 | } 35 | 36 | } //project 37 | 38 | } 39 | 40 | -------------------------------------------------------------------------------- /server.hxml: -------------------------------------------------------------------------------- 1 | -cp src 2 | -main mp.Server 3 | -js bin/server/server.js 4 | -lib hxnodejs -------------------------------------------------------------------------------- /src/BuildInfo.hx: -------------------------------------------------------------------------------- 1 | import haxe.macro.*; 2 | 3 | using DateTools; 4 | 5 | class BuildInfo { 6 | macro static public function getBuildDate():ExprOf { 7 | var date = Date.now(); 8 | var str = date.format("%Y-%m-%d %H:%M:%S"); 9 | return macro $v{str}; 10 | } 11 | } -------------------------------------------------------------------------------- /src/Main.hx: -------------------------------------------------------------------------------- 1 | 2 | import luxe.GameConfig; 3 | import luxe.Input; 4 | import luxe.Color; 5 | import luxe.Vector; 6 | import luxe.tween.Actuate; 7 | import game.*; 8 | using Lambda; 9 | 10 | #if MULTIPLAYER 11 | import haxe.Serializer; 12 | import haxe.Unserializer; 13 | import mp.Command; 14 | import mp.Message; 15 | #end 16 | 17 | class Main extends luxe.Game { 18 | 19 | var world:World; 20 | var state:GameState; 21 | var connected = false; 22 | var id:Null = null; 23 | #if MULTIPLAYER 24 | var ws:haxe.net.WebSocket; 25 | #end 26 | 27 | override function config(config:GameConfig) { 28 | 29 | // https://luxeengine.com/guide/#gettingstarted 30 | config.window.title = 'Haxe Agar'; 31 | config.window.width = 960; 32 | config.window.height = 640; 33 | config.window.fullscreen = false; 34 | config.render.antialiasing = 8; 35 | 36 | return config; 37 | 38 | } //config 39 | 40 | override function ready() { 41 | trace("built at " + BuildInfo.getBuildDate()); 42 | 43 | #if MULTIPLAYER 44 | ws = haxe.net.WebSocket.create("ws://127.0.0.1:8888"); 45 | ws.onopen = function() ws.sendString(Serializer.run(Join)); 46 | ws.onmessageString = function(msg) { 47 | var msg:Message = Unserializer.run(msg); 48 | switch msg { 49 | case Joined(id): this.id = id; 50 | case State(state): this.state = state; 51 | } 52 | } 53 | #else 54 | world = new World(); 55 | id = world.createPlayer().id; 56 | #end 57 | 58 | } //ready 59 | 60 | override function onkeyup(event:KeyEvent) { 61 | 62 | if(event.keycode == Key.escape) { 63 | Luxe.shutdown(); 64 | } 65 | 66 | } //onkeyup 67 | 68 | override function update(delta:Float) { 69 | #if MULTIPLAYER 70 | ws.process(); 71 | if(state == null) return; // not ready 72 | #else 73 | state = world.update(); 74 | #end 75 | 76 | // handle move 77 | var player = state.objects.find(function(o) return o.id == id); 78 | if(player != null) { 79 | // move player 80 | var mid = Luxe.screen.mid; 81 | if(touched) { 82 | var dir = Math.atan2(cursor.y - mid.y, cursor.x - mid.x); 83 | #if MULTIPLAYER 84 | if(player.speed == 0) ws.sendString(Serializer.run(StartMove)); 85 | ws.sendString(Serializer.run(SetDirection(dir))); 86 | #else 87 | player.speed = 3; 88 | player.dir = dir; 89 | #end 90 | } else { 91 | #if MULTIPLAYER 92 | if(player.speed != 0) ws.sendString(Serializer.run(StopMove)); 93 | #else 94 | player.speed = 0; 95 | #end 96 | } 97 | 98 | // update camera 99 | var scale = player.size / 40; 100 | Actuate.tween(Luxe.camera.scale, 1.0, {x: scale, y: scale}); 101 | Luxe.camera.pos.set_xy(player.x - mid.x, player.y - mid.y); 102 | } 103 | 104 | for(object in state.objects) { 105 | Luxe.draw.circle({ 106 | x: object.x, 107 | y: object.y, 108 | r: object.size, 109 | color: new Color().rgb(object.color), 110 | immediate: true, // see luxe.Visual & luxe.Sprite for persistent display objects 111 | depth: object.depth, 112 | }); 113 | } 114 | } //update 115 | 116 | var touched:Bool = false; 117 | var cursor = new Vector(); 118 | override function onmousedown(e) { 119 | touched = true; 120 | cursor.set_xy(e.x, e.y); 121 | } 122 | 123 | override function onmousemove(e) { 124 | cursor.set_xy(e.x, e.y); 125 | } 126 | 127 | override function onmouseup(e) { 128 | touched = false; 129 | } 130 | 131 | override function ontouchdown(e) { 132 | touched = true; 133 | cursor.set_xy(e.x, e.y); 134 | } 135 | 136 | override function ontouchmove(e:TouchEvent) { 137 | cursor.set_xy(e.x * Luxe.screen.size.x, e.y * Luxe.screen.size.y); 138 | } 139 | 140 | override function ontouchup(e) { 141 | touched = false; 142 | } 143 | 144 | } //Main 145 | -------------------------------------------------------------------------------- /src/game/GameState.hx: -------------------------------------------------------------------------------- 1 | package game; 2 | 3 | typedef GameState = { 4 | objects:Array, 5 | removed:Array, 6 | } -------------------------------------------------------------------------------- /src/game/Object.hx: -------------------------------------------------------------------------------- 1 | package game; 2 | 3 | @:keep 4 | enum ObjectType { 5 | Player; 6 | Ai; 7 | Food; 8 | } 9 | 10 | typedef Object = { 11 | id:Int, 12 | type:ObjectType, 13 | color:Int, 14 | size:Float, 15 | dir:Float, 16 | speed:Float, 17 | depth:Float, 18 | x:Float, 19 | y:Float, 20 | } -------------------------------------------------------------------------------- /src/game/World.hx: -------------------------------------------------------------------------------- 1 | package game; 2 | 3 | 4 | class World { 5 | // list of active game objects 6 | public var objects:Array = []; 7 | 8 | // objects spawns within this rectangle 9 | var size:{width:Int, height:Int}; 10 | 11 | // counter for the object IDs 12 | var count:Int = 0; 13 | 14 | public function new() { 15 | size = { 16 | width: 2000, 17 | height: 2000, 18 | } 19 | 20 | for(i in 0...10) createAi(); 21 | for(i in 0...50) createFood(); 22 | } 23 | 24 | public function insert(object:Object) { 25 | objects.push(object); 26 | return object; 27 | } 28 | 29 | public inline function remove(object:Object) { 30 | objects.remove(object); 31 | } 32 | 33 | public function createPlayer() { 34 | return insert({ 35 | id: count++, 36 | type: Player, 37 | color: 0xfffffff, 38 | size: 40, 39 | dir: Math.random() * Math.PI * 2, 40 | speed: 3, 41 | x: Std.random(size.width), 42 | y: Std.random(size.height), 43 | depth: 3, 44 | }); 45 | } 46 | 47 | public function createAi() { 48 | return insert({ 49 | id: count++, 50 | type: Ai, 51 | color: Std.random(1 << 24), 52 | size: 40, 53 | dir: Math.random() * Math.PI * 2, 54 | speed: 1, 55 | x: Std.random(size.width), 56 | y: Std.random(size.height), 57 | depth: 2, 58 | }); 59 | } 60 | 61 | public function createFood() { 62 | return insert({ 63 | id: count++, 64 | type: Food, 65 | color: Std.random(1 << 24), 66 | size: 10, 67 | dir: Math.random() * Math.PI * 2, 68 | speed: 0, 69 | x: Std.random(size.width), 70 | y: Std.random(size.height), 71 | depth: 1, 72 | }); 73 | } 74 | 75 | public function update():GameState { 76 | 77 | for(object in objects) if(object.speed != 0) { 78 | 79 | // randomize AI direction 80 | if(object.type == Ai && Math.random() < 0.1) object.dir += Math.random() - 0.5; 81 | 82 | // update object positions by their speed and direction 83 | object.x += Math.cos(object.dir) * object.speed; 84 | object.y += Math.sin(object.dir) * object.speed; 85 | } 86 | 87 | // detect collisions and make larger objects consume smaller objects 88 | var removed = []; 89 | 90 | for(object in objects) { 91 | for(other in objects) { 92 | if(object.size > other.size) { 93 | var dx = object.x - other.x; 94 | var dy = object.y - other.y; 95 | 96 | // distance < radius 97 | if(dx * dx + dy * dy < object.size * object.size) { 98 | // we don't want to modify the array we are iterating 99 | removed.push(other); 100 | 101 | // size increases after consuming the target 102 | object.size += other.size * 0.1; 103 | } 104 | } 105 | } 106 | } 107 | 108 | for(object in removed) { 109 | 110 | // actually remove the objects 111 | remove(object); 112 | 113 | // replenish food 114 | if(object.type == Food) createFood(); 115 | 116 | } 117 | 118 | return { 119 | objects: objects, 120 | removed: removed, 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/mp/Command.hx: -------------------------------------------------------------------------------- 1 | package mp; 2 | 3 | // sent from client to server 4 | enum Command { 5 | Join; 6 | SetDirection(dir:Float); 7 | StartMove; 8 | StopMove; 9 | } -------------------------------------------------------------------------------- /src/mp/Message.hx: -------------------------------------------------------------------------------- 1 | package mp; 2 | 3 | import game.*; 4 | 5 | // sent from server to client 6 | enum Message { 7 | Joined(id:Int); 8 | State(state:GameState); 9 | } -------------------------------------------------------------------------------- /src/mp/Server.hx: -------------------------------------------------------------------------------- 1 | package mp; 2 | 3 | import mp.Message; 4 | import mp.Command; 5 | import game.*; 6 | import haxe.*; 7 | import js.node.events.EventEmitter; 8 | 9 | using Lambda; 10 | 11 | class Server { 12 | static function main() { 13 | Sys.println("built at " + BuildInfo.getBuildDate()); 14 | 15 | // websocket server 16 | var clients:Array = []; 17 | var world = new World(); 18 | var ws = new WebSocketServer({port:8888}); 19 | ws.on('connection', function(cnx:Connection) { 20 | var client = new Client(cnx); 21 | clients.push(client); 22 | 23 | cnx.on('message', function(msg) { 24 | var command:Command = Unserializer.run(msg); 25 | switch command { 26 | case Join: 27 | trace(command, client.player); 28 | if(client.player == null) 29 | client.player = world.createPlayer(); 30 | 31 | var msg = Serializer.run(Joined(client.player.id)); 32 | client.connection.send(msg); 33 | 34 | case SetDirection(dir): 35 | if(client.player != null) client.player.dir = dir; 36 | 37 | case StartMove: 38 | if(client.player != null) client.player.speed = 3; 39 | 40 | case StopMove: 41 | if(client.player != null) client.player.speed = 0; 42 | } 43 | }); 44 | 45 | cnx.on('close', function(_) { 46 | if(client.player != null) 47 | world.remove(client.player); 48 | clients.remove(client); 49 | }); 50 | }); 51 | 52 | // game loop 53 | var timer = new haxe.Timer(32); 54 | timer.run = function() { 55 | var state = world.update(); 56 | 57 | // clean up the client-player association 58 | for(object in state.removed) { 59 | switch clients.find(function(c) return c.player != null && c.player.id == object.id) { 60 | case null: // hmm.... 61 | case client: client.player = null; 62 | } 63 | } 64 | 65 | // boardcast the game state 66 | var msg = Serializer.run(State(state)); 67 | for(client in clients) 68 | try { 69 | client.connection.send(msg); 70 | } catch (e:Dynamic) {} 71 | } 72 | } 73 | } 74 | 75 | class Client { 76 | public var connection(default, null):Connection; 77 | public var player:Object; 78 | 79 | public function new(connection) 80 | this.connection = connection; 81 | } 82 | 83 | 84 | extern class Connection extends EventEmitter { 85 | function send(m:String):Void; 86 | } 87 | 88 | @:jsRequire('ws','Server') 89 | extern class WebSocketServer extends EventEmitter { 90 | function new(?config:Dynamic); 91 | } 92 | --------------------------------------------------------------------------------