├── LICENSE ├── Makefile ├── README.md ├── app ├── client │ ├── ase │ │ ├── concrete.ase │ │ ├── dirt.ase │ │ ├── export.sh │ │ ├── grass.ase │ │ ├── hat-bycocket.ase │ │ ├── hat-mohawk.ase │ │ ├── hat-nightcap.ase │ │ ├── hat-top.ase │ │ ├── man.ase │ │ ├── ui.ase │ │ ├── wall.ase │ │ └── water.ase │ ├── assets │ │ ├── hat-bycocket.json │ │ ├── hat-mohawk.json │ │ ├── hat-nightcap.json │ │ ├── hat-top.json │ │ ├── man.json │ │ ├── mountpoints.json │ │ ├── spritesheet.json │ │ ├── spritesheet.png │ │ └── ui.json │ ├── client.go │ ├── client2.go │ ├── input.go │ ├── network.go │ ├── player.go │ └── render.go ├── proxy │ └── proxy.go └── server │ ├── network.go │ ├── server.go │ └── sys.go ├── cmd ├── Makefile ├── client │ ├── Makefile │ ├── index.html │ ├── main.go │ └── wasm_exec.js ├── proxy │ └── main.go ├── run.sh ├── runclient.sh └── server │ └── main.go ├── go.mod ├── go.sum ├── mmo.go ├── serdes ├── binary_test.go ├── regex_test.go └── serdes.go └── stat └── stat.go /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 unitoftime 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build deploy 2 | 3 | build: 4 | echo "BUILDING" 5 | $(MAKE) -C ./cmd/ all 6 | 7 | deploy: 8 | echo "DEPLOYING" 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | `Note: I decided to try and turn this into a full-fledge game. Because of that, I started putting the gameplay part of the project in a private repository and stopped adding to this repository. I do still want to open source as much of my code as I can (without giving away the entire project), so I have several other open source repos that I moved a lot of code to:` 2 | 1. `github.com/unitoftime/glitch` - Rendering 3 | 2. `github.com/unitoftime/flow` - General game components features 4 | 3. `github.com/unitoftime/ecs` - ECS framework 5 | 4. You can also go here to see the repositories I have in my profile: [https://github.com/unitoftime](https://github.com/unitoftime) 6 | 7 | ### Welcome! 8 | If you are here, then you may have come from my tutorial series on YouTube. If not, you can go check it out: 9 | * [YouTube Playlist](https://www.youtube.com/playlist?list=PL_r0j2F4Hkj8KZ6jNJPCW3aDH--aWrn-T) 10 | * [YouTube Channel](https://www.youtube.com/channel/UCrcOrUcsMYRMqTfAy-IG0rg) 11 | 12 | If you have any feedback let me know! 13 | 14 | ### Compiling and Running 15 | Get the code 16 | ``` 17 | go get github.com/unitoftime/mmo 18 | ``` 19 | 20 | The current instructions are slightly complicated 21 | ``` 22 | cd cmd/ 23 | mkdir build 24 | 25 | make all 26 | # Everything should build - There will be one step where you generate a key, This is for the TLS connection between your client and proxy. You can leave all of the options blank (ie just hit enter until the key starts generating) 27 | 28 | bash run.sh 29 | # This will start the server, then the proxy, then launch a desktop client 30 | ``` 31 | 32 | If you want to test the wasm you'll have to host the `build/` folder at some url. I use a simple go webserver to host my folder. Also, when you access the hosted URL, the browser will complain that the key at `localhost:port` isn't a part of any Certificate Authority. This is because you just manually generated the key. You have to skip the security check. Chrome had a way for me to allow arbitrary keys for localhost connections, so I enabled that. 33 | 34 | You'll have to manually start the server and proxy binaries too: 35 | ``` 36 | # Shell 1 37 | cd cmd/build/ && ./server 38 | # Shell 2 39 | cd cmd/build/ && ./proxy 40 | # Shell 3 41 | # Whatever webserver command you use to serve it 42 | ``` 43 | 44 | ### Licensing 45 | 1. Code: MIT License. 46 | 2. Artwork: All rights reserved. 47 | -------------------------------------------------------------------------------- /app/client/ase/concrete.ase: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unitoftime/mmo/bf9f9cd2b1971606bc3815fef7a12f8d82859beb/app/client/ase/concrete.ase -------------------------------------------------------------------------------- /app/client/ase/dirt.ase: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unitoftime/mmo/bf9f9cd2b1971606bc3815fef7a12f8d82859beb/app/client/ase/dirt.ase -------------------------------------------------------------------------------- /app/client/ase/export.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # TODO - Convert to go file? generator.go? https://stackoverflow.com/questions/55598931/go-generate-multiline-command 4 | 5 | set -x # Activate debugging 6 | 7 | echo "Exporting Aseprite Files to ase/images" 8 | mkdir -p ase/images 9 | 10 | # Exporting with using tags and trimming 11 | # filenames=(menu_ui particles) 12 | # for file in ${filenames[@]} 13 | # do 14 | # aseprite -b ${file}.ase --trim --save-as export/${file}_{tag}{tagframe0}.png 15 | # mogrify -trim export/${file}_*.png 16 | # done 17 | 18 | # Exporting Animated Objects 19 | filenames=(man hat-top hat-bycocket hat-mohawk hat-nightcap) 20 | for file in ${filenames[@]} 21 | do 22 | # aseprite -b ${file}.ase --save-as images/${file}_{tag}{tagframe0}.png 23 | aseprite -b ase/${file}.ase --format json-array --ignore-layer=mount --list-tags --data assets/${file}.json --save-as "ase/images/${file}_{frame}.png" 24 | aseprite -b ase/${file}.ase --format json-array --layer=mount --list-tags --data assets/${file}.json --save-as "ase/mount/${file}_{frame}.png" 25 | done 26 | 27 | # Exporting static objects defined by tags 28 | filenames=(ui) 29 | for file in ${filenames[@]} 30 | do 31 | aseprite -b ase/${file}.ase --format json-array --list-tags --data assets/${file}.json --save-as "ase/images/${file}_{tag}{tagframe}.png" 32 | done 33 | 34 | # Exporting without using tags 35 | filenames=(dirt grass water concrete wall) 36 | for file in ${filenames[@]} 37 | do 38 | aseprite -b ase/${file}.ase --save-as ase/images/${file}{frame}.png 39 | done 40 | 41 | # Pack all images into a spritesheet 42 | packer --input ase/images --stats --output assets/spritesheet 43 | 44 | #go run github.com/unitoftime/packer/cmd/packer --input ase/mount --mountpoints --stats --output assets/mountpoints 45 | packer --input ase/mount --mountpoints --stats --output assets/mountpoints 46 | 47 | # Remove generated images 48 | rm -f ase/images/* 49 | rm -f ase/mount/* 50 | -------------------------------------------------------------------------------- /app/client/ase/grass.ase: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unitoftime/mmo/bf9f9cd2b1971606bc3815fef7a12f8d82859beb/app/client/ase/grass.ase -------------------------------------------------------------------------------- /app/client/ase/hat-bycocket.ase: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unitoftime/mmo/bf9f9cd2b1971606bc3815fef7a12f8d82859beb/app/client/ase/hat-bycocket.ase -------------------------------------------------------------------------------- /app/client/ase/hat-mohawk.ase: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unitoftime/mmo/bf9f9cd2b1971606bc3815fef7a12f8d82859beb/app/client/ase/hat-mohawk.ase -------------------------------------------------------------------------------- /app/client/ase/hat-nightcap.ase: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unitoftime/mmo/bf9f9cd2b1971606bc3815fef7a12f8d82859beb/app/client/ase/hat-nightcap.ase -------------------------------------------------------------------------------- /app/client/ase/hat-top.ase: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unitoftime/mmo/bf9f9cd2b1971606bc3815fef7a12f8d82859beb/app/client/ase/hat-top.ase -------------------------------------------------------------------------------- /app/client/ase/man.ase: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unitoftime/mmo/bf9f9cd2b1971606bc3815fef7a12f8d82859beb/app/client/ase/man.ase -------------------------------------------------------------------------------- /app/client/ase/ui.ase: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unitoftime/mmo/bf9f9cd2b1971606bc3815fef7a12f8d82859beb/app/client/ase/ui.ase -------------------------------------------------------------------------------- /app/client/ase/wall.ase: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unitoftime/mmo/bf9f9cd2b1971606bc3815fef7a12f8d82859beb/app/client/ase/wall.ase -------------------------------------------------------------------------------- /app/client/ase/water.ase: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unitoftime/mmo/bf9f9cd2b1971606bc3815fef7a12f8d82859beb/app/client/ase/water.ase -------------------------------------------------------------------------------- /app/client/assets/hat-bycocket.json: -------------------------------------------------------------------------------- 1 | { "frames": [ 2 | { 3 | "filename": "hat-bycocket 0.ase", 4 | "frame": { "x": 0, "y": 0, "w": 16, "h": 10 }, 5 | "rotated": false, 6 | "trimmed": false, 7 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 10 }, 8 | "sourceSize": { "w": 16, "h": 10 }, 9 | "duration": 125 10 | }, 11 | { 12 | "filename": "hat-bycocket 1.ase", 13 | "frame": { "x": 16, "y": 0, "w": 16, "h": 10 }, 14 | "rotated": false, 15 | "trimmed": false, 16 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 10 }, 17 | "sourceSize": { "w": 16, "h": 10 }, 18 | "duration": 100 19 | } 20 | ], 21 | "meta": { 22 | "app": "http://www.aseprite.org/", 23 | "version": "1.x-dev", 24 | "format": "RGBA8888", 25 | "size": { "w": 32, "h": 10 }, 26 | "scale": "1", 27 | "frameTags": [ 28 | { "name": "idle_left", "from": 0, "to": 0, "direction": "forward" }, 29 | { "name": "run_left", "from": 1, "to": 1, "direction": "forward" } 30 | ] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/client/assets/hat-mohawk.json: -------------------------------------------------------------------------------- 1 | { "frames": [ 2 | { 3 | "filename": "hat-mohawk 0.ase", 4 | "frame": { "x": 0, "y": 0, "w": 5, "h": 6 }, 5 | "rotated": false, 6 | "trimmed": false, 7 | "spriteSourceSize": { "x": 0, "y": 0, "w": 5, "h": 6 }, 8 | "sourceSize": { "w": 5, "h": 6 }, 9 | "duration": 125 10 | }, 11 | { 12 | "filename": "hat-mohawk 1.ase", 13 | "frame": { "x": 5, "y": 0, "w": 5, "h": 6 }, 14 | "rotated": false, 15 | "trimmed": false, 16 | "spriteSourceSize": { "x": 0, "y": 0, "w": 5, "h": 6 }, 17 | "sourceSize": { "w": 5, "h": 6 }, 18 | "duration": 100 19 | }, 20 | { 21 | "filename": "hat-mohawk 2.ase", 22 | "frame": { "x": 10, "y": 0, "w": 5, "h": 6 }, 23 | "rotated": false, 24 | "trimmed": false, 25 | "spriteSourceSize": { "x": 0, "y": 0, "w": 5, "h": 6 }, 26 | "sourceSize": { "w": 5, "h": 6 }, 27 | "duration": 100 28 | }, 29 | { 30 | "filename": "hat-mohawk 3.ase", 31 | "frame": { "x": 15, "y": 0, "w": 5, "h": 6 }, 32 | "rotated": false, 33 | "trimmed": false, 34 | "spriteSourceSize": { "x": 0, "y": 0, "w": 5, "h": 6 }, 35 | "sourceSize": { "w": 5, "h": 6 }, 36 | "duration": 100 37 | }, 38 | { 39 | "filename": "hat-mohawk 4.ase", 40 | "frame": { "x": 20, "y": 0, "w": 5, "h": 6 }, 41 | "rotated": false, 42 | "trimmed": false, 43 | "spriteSourceSize": { "x": 0, "y": 0, "w": 5, "h": 6 }, 44 | "sourceSize": { "w": 5, "h": 6 }, 45 | "duration": 100 46 | } 47 | ], 48 | "meta": { 49 | "app": "http://www.aseprite.org/", 50 | "version": "1.x-dev", 51 | "format": "RGBA8888", 52 | "size": { "w": 25, "h": 6 }, 53 | "scale": "1", 54 | "frameTags": [ 55 | { "name": "idle_left", "from": 0, "to": 0, "direction": "forward" }, 56 | { "name": "run_left", "from": 1, "to": 4, "direction": "forward" } 57 | ] 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/client/assets/hat-nightcap.json: -------------------------------------------------------------------------------- 1 | { "frames": [ 2 | { 3 | "filename": "hat-nightcap 0.ase", 4 | "frame": { "x": 0, "y": 0, "w": 16, "h": 18 }, 5 | "rotated": false, 6 | "trimmed": false, 7 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 18 }, 8 | "sourceSize": { "w": 16, "h": 18 }, 9 | "duration": 125 10 | }, 11 | { 12 | "filename": "hat-nightcap 1.ase", 13 | "frame": { "x": 16, "y": 0, "w": 16, "h": 18 }, 14 | "rotated": false, 15 | "trimmed": false, 16 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 18 }, 17 | "sourceSize": { "w": 16, "h": 18 }, 18 | "duration": 100 19 | } 20 | ], 21 | "meta": { 22 | "app": "http://www.aseprite.org/", 23 | "version": "1.x-dev", 24 | "format": "RGBA8888", 25 | "size": { "w": 32, "h": 18 }, 26 | "scale": "1", 27 | "frameTags": [ 28 | { "name": "idle_left", "from": 0, "to": 0, "direction": "forward" }, 29 | { "name": "run_left", "from": 1, "to": 1, "direction": "forward" } 30 | ] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/client/assets/hat-top.json: -------------------------------------------------------------------------------- 1 | { "frames": [ 2 | { 3 | "filename": "hat-top 0.ase", 4 | "frame": { "x": 0, "y": 0, "w": 12, "h": 8 }, 5 | "rotated": false, 6 | "trimmed": false, 7 | "spriteSourceSize": { "x": 0, "y": 0, "w": 12, "h": 8 }, 8 | "sourceSize": { "w": 12, "h": 8 }, 9 | "duration": 125 10 | }, 11 | { 12 | "filename": "hat-top 1.ase", 13 | "frame": { "x": 12, "y": 0, "w": 12, "h": 8 }, 14 | "rotated": false, 15 | "trimmed": false, 16 | "spriteSourceSize": { "x": 0, "y": 0, "w": 12, "h": 8 }, 17 | "sourceSize": { "w": 12, "h": 8 }, 18 | "duration": 100 19 | }, 20 | { 21 | "filename": "hat-top 2.ase", 22 | "frame": { "x": 24, "y": 0, "w": 12, "h": 8 }, 23 | "rotated": false, 24 | "trimmed": false, 25 | "spriteSourceSize": { "x": 0, "y": 0, "w": 12, "h": 8 }, 26 | "sourceSize": { "w": 12, "h": 8 }, 27 | "duration": 100 28 | }, 29 | { 30 | "filename": "hat-top 3.ase", 31 | "frame": { "x": 36, "y": 0, "w": 12, "h": 8 }, 32 | "rotated": false, 33 | "trimmed": false, 34 | "spriteSourceSize": { "x": 0, "y": 0, "w": 12, "h": 8 }, 35 | "sourceSize": { "w": 12, "h": 8 }, 36 | "duration": 100 37 | }, 38 | { 39 | "filename": "hat-top 4.ase", 40 | "frame": { "x": 48, "y": 0, "w": 12, "h": 8 }, 41 | "rotated": false, 42 | "trimmed": false, 43 | "spriteSourceSize": { "x": 0, "y": 0, "w": 12, "h": 8 }, 44 | "sourceSize": { "w": 12, "h": 8 }, 45 | "duration": 100 46 | } 47 | ], 48 | "meta": { 49 | "app": "http://www.aseprite.org/", 50 | "version": "1.x-dev", 51 | "format": "RGBA8888", 52 | "size": { "w": 60, "h": 8 }, 53 | "scale": "1", 54 | "frameTags": [ 55 | { "name": "idle_left", "from": 0, "to": 0, "direction": "forward" }, 56 | { "name": "run_left", "from": 1, "to": 4, "direction": "forward" } 57 | ] 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/client/assets/man.json: -------------------------------------------------------------------------------- 1 | { "frames": [ 2 | { 3 | "filename": "man 0.ase", 4 | "frame": { "x": 0, "y": 0, "w": 16, "h": 20 }, 5 | "rotated": false, 6 | "trimmed": false, 7 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 20 }, 8 | "sourceSize": { "w": 16, "h": 20 }, 9 | "duration": 125 10 | }, 11 | { 12 | "filename": "man 1.ase", 13 | "frame": { "x": 16, "y": 0, "w": 16, "h": 20 }, 14 | "rotated": false, 15 | "trimmed": false, 16 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 20 }, 17 | "sourceSize": { "w": 16, "h": 20 }, 18 | "duration": 150 19 | }, 20 | { 21 | "filename": "man 2.ase", 22 | "frame": { "x": 32, "y": 0, "w": 16, "h": 20 }, 23 | "rotated": false, 24 | "trimmed": false, 25 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 20 }, 26 | "sourceSize": { "w": 16, "h": 20 }, 27 | "duration": 150 28 | }, 29 | { 30 | "filename": "man 3.ase", 31 | "frame": { "x": 48, "y": 0, "w": 16, "h": 20 }, 32 | "rotated": false, 33 | "trimmed": false, 34 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 20 }, 35 | "sourceSize": { "w": 16, "h": 20 }, 36 | "duration": 125 37 | }, 38 | { 39 | "filename": "man 4.ase", 40 | "frame": { "x": 64, "y": 0, "w": 16, "h": 20 }, 41 | "rotated": false, 42 | "trimmed": false, 43 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 20 }, 44 | "sourceSize": { "w": 16, "h": 20 }, 45 | "duration": 100 46 | }, 47 | { 48 | "filename": "man 5.ase", 49 | "frame": { "x": 80, "y": 0, "w": 16, "h": 20 }, 50 | "rotated": false, 51 | "trimmed": false, 52 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 20 }, 53 | "sourceSize": { "w": 16, "h": 20 }, 54 | "duration": 100 55 | }, 56 | { 57 | "filename": "man 6.ase", 58 | "frame": { "x": 96, "y": 0, "w": 16, "h": 20 }, 59 | "rotated": false, 60 | "trimmed": false, 61 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 20 }, 62 | "sourceSize": { "w": 16, "h": 20 }, 63 | "duration": 100 64 | }, 65 | { 66 | "filename": "man 7.ase", 67 | "frame": { "x": 112, "y": 0, "w": 16, "h": 20 }, 68 | "rotated": false, 69 | "trimmed": false, 70 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 20 }, 71 | "sourceSize": { "w": 16, "h": 20 }, 72 | "duration": 100 73 | } 74 | ], 75 | "meta": { 76 | "app": "http://www.aseprite.org/", 77 | "version": "1.x-dev", 78 | "format": "RGBA8888", 79 | "size": { "w": 128, "h": 20 }, 80 | "scale": "1", 81 | "frameTags": [ 82 | { "name": "idle_left", "from": 0, "to": 3, "direction": "forward" }, 83 | { "name": "run_left", "from": 4, "to": 7, "direction": "forward" } 84 | ] 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /app/client/assets/mountpoints.json: -------------------------------------------------------------------------------- 1 | {"Frames":{"hat-bycocket_0.png":{"Filename":"hat-bycocket_0.png","MountPoints":{"0":{"X":1,"Y":-3}}},"hat-bycocket_1.png":{"Filename":"hat-bycocket_1.png","MountPoints":{"0":{"X":1,"Y":-3}}},"hat-mohawk_0.png":{"Filename":"hat-mohawk_0.png","MountPoints":{"0":{"X":0,"Y":-1}}},"hat-mohawk_1.png":{"Filename":"hat-mohawk_1.png","MountPoints":{"0":{"X":0,"Y":-1}}},"hat-mohawk_2.png":{"Filename":"hat-mohawk_2.png","MountPoints":{"0":{"X":0,"Y":-1}}},"hat-mohawk_3.png":{"Filename":"hat-mohawk_3.png","MountPoints":{"0":{"X":0,"Y":-1}}},"hat-mohawk_4.png":{"Filename":"hat-mohawk_4.png","MountPoints":{"0":{"X":0,"Y":-1}}},"hat-nightcap_0.png":{"Filename":"hat-nightcap_0.png","MountPoints":{"0":{"X":1,"Y":-3}}},"hat-nightcap_1.png":{"Filename":"hat-nightcap_1.png","MountPoints":{"0":{"X":1,"Y":-3}}},"hat-top_0.png":{"Filename":"hat-top_0.png","MountPoints":{"0":{"X":1,"Y":-3}}},"hat-top_1.png":{"Filename":"hat-top_1.png","MountPoints":{"0":{"X":1,"Y":-3}}},"hat-top_2.png":{"Filename":"hat-top_2.png","MountPoints":{"0":{"X":1,"Y":-3}}},"hat-top_3.png":{"Filename":"hat-top_3.png","MountPoints":{"0":{"X":1,"Y":-3}}},"hat-top_4.png":{"Filename":"hat-top_4.png","MountPoints":{"0":{"X":1,"Y":-3}}},"man_0.png":{"Filename":"man_0.png","MountPoints":{"16711680":{"X":1,"Y":4}}},"man_1.png":{"Filename":"man_1.png","MountPoints":{"16711680":{"X":1,"Y":3}}},"man_2.png":{"Filename":"man_2.png","MountPoints":{"16711680":{"X":1,"Y":2}}},"man_3.png":{"Filename":"man_3.png","MountPoints":{"16711680":{"X":1,"Y":3}}},"man_4.png":{"Filename":"man_4.png","MountPoints":{"16711680":{"X":1,"Y":5}}},"man_5.png":{"Filename":"man_5.png","MountPoints":{"16711680":{"X":1,"Y":6}}},"man_6.png":{"Filename":"man_6.png","MountPoints":{"16711680":{"X":1,"Y":4}}},"man_7.png":{"Filename":"man_7.png","MountPoints":{"16711680":{"X":1,"Y":4}}}}} -------------------------------------------------------------------------------- /app/client/assets/spritesheet.json: -------------------------------------------------------------------------------- 1 | {"ImageName":"spritesheet.png","Frames":{"concrete0.png":{"Frame":{"X":267,"Y":1,"W":16,"H":16},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"dirt0.png":{"Frame":{"X":343,"Y":1,"W":16,"H":16},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"grass0.png":{"Frame":{"X":324,"Y":1,"W":16,"H":16},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"hat-bycocket_0.png":{"Frame":{"X":381,"Y":1,"W":16,"H":10},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"hat-bycocket_1.png":{"Frame":{"X":362,"Y":1,"W":16,"H":10},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"hat-mohawk_0.png":{"Frame":{"X":507,"Y":1,"W":5,"H":6},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"hat-mohawk_1.png":{"Frame":{"X":499,"Y":1,"W":5,"H":6},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"hat-mohawk_2.png":{"Frame":{"X":491,"Y":1,"W":5,"H":6},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"hat-mohawk_3.png":{"Frame":{"X":483,"Y":1,"W":5,"H":6},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"hat-mohawk_4.png":{"Frame":{"X":475,"Y":1,"W":5,"H":6},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"hat-nightcap_0.png":{"Frame":{"X":172,"Y":1,"W":16,"H":18},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"hat-nightcap_1.png":{"Frame":{"X":191,"Y":1,"W":16,"H":18},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"hat-top_0.png":{"Frame":{"X":460,"Y":1,"W":12,"H":8},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"hat-top_1.png":{"Frame":{"X":445,"Y":1,"W":12,"H":8},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"hat-top_2.png":{"Frame":{"X":400,"Y":1,"W":12,"H":8},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"hat-top_3.png":{"Frame":{"X":430,"Y":1,"W":12,"H":8},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"hat-top_4.png":{"Frame":{"X":415,"Y":1,"W":12,"H":8},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"man_0.png":{"Frame":{"X":96,"Y":1,"W":16,"H":20},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"man_1.png":{"Frame":{"X":77,"Y":1,"W":16,"H":20},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"man_2.png":{"Frame":{"X":58,"Y":1,"W":16,"H":20},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"man_3.png":{"Frame":{"X":39,"Y":1,"W":16,"H":20},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"man_4.png":{"Frame":{"X":20,"Y":1,"W":16,"H":20},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"man_5.png":{"Frame":{"X":115,"Y":1,"W":16,"H":20},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"man_6.png":{"Frame":{"X":134,"Y":1,"W":16,"H":20},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"man_7.png":{"Frame":{"X":153,"Y":1,"W":16,"H":20},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"ui_button0.png":{"Frame":{"X":229,"Y":1,"W":16,"H":16},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"ui_button_hover0.png":{"Frame":{"X":210,"Y":1,"W":16,"H":16},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"ui_button_press0.png":{"Frame":{"X":248,"Y":1,"W":16,"H":16},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"ui_panel0.png":{"Frame":{"X":286,"Y":1,"W":16,"H":16},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"wall0.png":{"Frame":{"X":1,"Y":1,"W":16,"H":32},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}},"water0.png":{"Frame":{"X":305,"Y":1,"W":16,"H":16},"Rotated":false,"Trimmed":false,"SpriteSourceSize":{"X":0,"Y":0,"W":0,"H":0},"SourceSize":{"W":0,"H":0},"Pivot":{"X":0,"Y":0}}},"Meta":{"protocol":"github.com/unitoftime/packer"}} -------------------------------------------------------------------------------- /app/client/assets/spritesheet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unitoftime/mmo/bf9f9cd2b1971606bc3815fef7a12f8d82859beb/app/client/assets/spritesheet.png -------------------------------------------------------------------------------- /app/client/assets/ui.json: -------------------------------------------------------------------------------- 1 | { "frames": [ 2 | { 3 | "filename": "ui 0.ase", 4 | "frame": { "x": 0, "y": 0, "w": 16, "h": 16 }, 5 | "rotated": false, 6 | "trimmed": false, 7 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 16 }, 8 | "sourceSize": { "w": 16, "h": 16 }, 9 | "duration": 100 10 | }, 11 | { 12 | "filename": "ui 1.ase", 13 | "frame": { "x": 16, "y": 0, "w": 16, "h": 16 }, 14 | "rotated": false, 15 | "trimmed": false, 16 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 16 }, 17 | "sourceSize": { "w": 16, "h": 16 }, 18 | "duration": 100 19 | }, 20 | { 21 | "filename": "ui 2.ase", 22 | "frame": { "x": 32, "y": 0, "w": 16, "h": 16 }, 23 | "rotated": false, 24 | "trimmed": false, 25 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 16 }, 26 | "sourceSize": { "w": 16, "h": 16 }, 27 | "duration": 100 28 | }, 29 | { 30 | "filename": "ui 3.ase", 31 | "frame": { "x": 48, "y": 0, "w": 16, "h": 16 }, 32 | "rotated": false, 33 | "trimmed": false, 34 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 16 }, 35 | "sourceSize": { "w": 16, "h": 16 }, 36 | "duration": 100 37 | } 38 | ], 39 | "meta": { 40 | "app": "http://www.aseprite.org/", 41 | "version": "1.x-dev", 42 | "format": "RGBA8888", 43 | "size": { "w": 64, "h": 16 }, 44 | "scale": "1", 45 | "frameTags": [ 46 | { "name": "button", "from": 0, "to": 0, "direction": "forward" }, 47 | { "name": "button_hover", "from": 1, "to": 1, "direction": "forward" }, 48 | { "name": "button_press", "from": 2, "to": 2, "direction": "forward" }, 49 | { "name": "panel", "from": 3, "to": 3, "direction": "forward" } 50 | ] 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | //go:generate sh ase/export.sh 4 | 5 | import ( 6 | "os" 7 | "time" 8 | // "fmt" 9 | "embed" 10 | // "math" 11 | "strings" 12 | "flag" 13 | "crypto/tls" 14 | 15 | "github.com/zyedidia/generic/queue" 16 | 17 | "github.com/rs/zerolog" 18 | "github.com/rs/zerolog/log" 19 | 20 | "github.com/unitoftime/ecs" 21 | "github.com/unitoftime/glitch" 22 | "github.com/unitoftime/glitch/shaders" 23 | "github.com/unitoftime/glitch/ui" 24 | 25 | "github.com/unitoftime/flow/interp" 26 | "github.com/unitoftime/flow/asset" 27 | "github.com/unitoftime/flow/render" 28 | "github.com/unitoftime/flow/phy2" 29 | "github.com/unitoftime/flow/tile" 30 | "github.com/unitoftime/flow/net" 31 | 32 | "github.com/unitoftime/mmo" 33 | "github.com/unitoftime/mmo/serdes" 34 | ) 35 | 36 | //go:embed assets/* 37 | var fs embed.FS 38 | 39 | type Config struct { 40 | ProxyUri string 41 | Test bool 42 | } 43 | 44 | var skipMenu = flag.Bool("skip", false, "skip the login menu (for testing)") 45 | 46 | var globalConfig Config 47 | func Main(config Config) { 48 | globalConfig = config 49 | 50 | zerolog.TimeFieldFormat = zerolog.TimeFormatUnix 51 | log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) 52 | 53 | flag.Parse() 54 | 55 | glitch.Run(launch) 56 | } 57 | 58 | func launch() { 59 | win, err := glitch.NewWindow(1920, 1080, "MMO", glitch.WindowConfig{ 60 | Vsync: true, 61 | // Fullscreen: true, 62 | // Samples: 4, 63 | }) 64 | if err != nil { 65 | panic(err) 66 | } 67 | 68 | load := asset.NewLoad(fs) 69 | // load := asset.NewLoad(os.DirFS("http://localhost:8081")) 70 | spritesheet, err := load.Spritesheet("assets/spritesheet.json", false) // TODO - does this need to be false or true? 71 | if err != nil { 72 | panic(err) 73 | } 74 | 75 | atlas, err := glitch.DefaultAtlas() 76 | if err != nil { panic(err) } 77 | 78 | // Note: If you want fractional zooming, you can use pixelartshader, else if you do x2 zooming, you can use spriteshader 79 | // pixelArtShader, err := glitch.NewShader(shaders.PixelArtShader) 80 | // if err != nil { panic(err) } 81 | 82 | shader, err := glitch.NewShader(shaders.SpriteShader) 83 | if err != nil { panic(err) } 84 | 85 | if skipMenu == nil || (*skipMenu == false) { 86 | runMenu(win, load, spritesheet, shader, atlas) 87 | } else { 88 | runGame(win, load, spritesheet, shader, atlas) 89 | } 90 | } 91 | 92 | func runMenu(win *glitch.Window, load *asset.Load, spritesheet *asset.Spritesheet, shader *glitch.Shader, atlas *glitch.Atlas) { 93 | panelSprite, err := spritesheet.GetNinePanel("ui_panel0.png", glitch.R(2, 2, 2, 2)) 94 | if err != nil { panic(err) } 95 | buttonSprite, err := spritesheet.GetNinePanel("ui_button0.png", glitch.R(1, 1, 1, 1)) 96 | if err != nil { panic(err) } 97 | buttonHoverSprite, err := spritesheet.GetNinePanel("ui_button_hover0.png", glitch.R(1, 1, 1, 1)) 98 | if err != nil { panic(err) } 99 | buttonPressSprite, err := spritesheet.GetNinePanel("ui_button_press0.png", glitch.R(1, 1, 1, 1)) 100 | if err != nil { panic(err) } 101 | 102 | panelSprite.Scale = 8 103 | buttonSprite.Scale = 8 104 | buttonHoverSprite.Scale = 8 105 | buttonPressSprite.Scale = 8 106 | 107 | camera := glitch.NewCameraOrtho() 108 | camera.SetOrtho2D(win.Bounds()) 109 | camera.SetView2D(0, 0, 1.0, 1.0) 110 | group := ui.NewGroup(win, camera, atlas) 111 | 112 | quit := ecs.Signal{} 113 | quit.Set(false) 114 | renderSystems := []ecs.System{ 115 | ecs.System{"UpdateWindow", func(dt time.Duration) { 116 | if win.JustPressed(glitch.KeyBackspace) { 117 | quit.Set(true) 118 | } 119 | 120 | glitch.Clear(win, glitch.Black) 121 | 122 | { 123 | ui.Clear() 124 | group.Clear() 125 | camera.SetOrtho2D(win.Bounds()) 126 | camera.SetView2D(0, 0, 1.0, 1.0) 127 | 128 | menuRect := win.Bounds().SliceHorizontal(500).SliceVertical(500) 129 | group.Panel(panelSprite, menuRect) 130 | 131 | buttonHeight := float32(50) 132 | buttonWidth := float32(200) 133 | 134 | // Play button 135 | { 136 | buttonRect := menuRect.SliceHorizontal(buttonHeight).SliceVertical(buttonWidth).Moved(glitch.Vec2{0, buttonHeight}) 137 | if group.Button(buttonSprite, buttonHoverSprite, buttonPressSprite, buttonRect) { 138 | runGame(win, load, spritesheet, shader, atlas) 139 | } 140 | group.SetColor(glitch.RGBA{0, 0, 0, 1}) 141 | group.Text("Play", buttonRect.Unpad(buttonSprite.Border()), glitch.Vec2{0.5, 0.5}) 142 | } 143 | 144 | // Exit button 145 | { 146 | buttonRect := menuRect.SliceHorizontal(buttonHeight).SliceVertical(buttonWidth).Moved(glitch.Vec2{0, -buttonHeight}) 147 | if group.Button(buttonSprite, buttonHoverSprite, buttonPressSprite, buttonRect) { 148 | quit.Set(true) 149 | } 150 | group.SetColor(glitch.RGBA{0, 0, 0, 1}) 151 | group.Text("Exit", buttonRect.Unpad(buttonSprite.Border()), glitch.Vec2{0.5, 0.5}) 152 | } 153 | 154 | group.Draw() 155 | } 156 | 157 | win.Update() 158 | }}, 159 | } 160 | schedule := mmo.GetScheduler() 161 | schedule.AppendRender(renderSystems...) 162 | schedule.Run(&quit) 163 | } 164 | 165 | func runGame(win *glitch.Window, load *asset.Load, spritesheet *asset.Spritesheet, shader *glitch.Shader, atlas *glitch.Atlas) { 166 | pixelArtShader, err := glitch.NewShader(shaders.PixelArtShader) 167 | if err != nil { panic(err) } 168 | 169 | world := ecs.NewWorld() 170 | networkChannel := make(chan serdes.WorldUpdate, 1024) // TODO - arbitrary 1024 171 | 172 | // This is the player's ID, by default we set this to invalid 173 | playerData := NewPlayerData() 174 | 175 | // TODO - Do this for local testing (Right now I'm doing insecure skip verify) 176 | // Ref: https://github.com/jcbsmpsn/golang-https-example 177 | // cert, err := os.ReadFile("cert.pem") 178 | // if err != nil { 179 | // panic(err) 180 | // } 181 | // caCertPool := x509.NewCertPool() 182 | // caCertPool.AppendCertsFromPEM(caCert) 183 | // tlsConfig := &tls.Config{ 184 | // RootCAs: caCertPool, 185 | // } 186 | 187 | proxyNet := net.Config{ 188 | Url: globalConfig.ProxyUri, 189 | Serdes: serdes.New(), 190 | TlsConfig: &tls.Config{ 191 | InsecureSkipVerify: globalConfig.Test, // If test mode, then we don't care about the cert 192 | }, 193 | ReconnectHandler: func(sock *net.Socket) error { 194 | return ClientReceive(sock, playerData, networkChannel) 195 | }, 196 | } 197 | 198 | sock, err := proxyNet.Dial() 199 | if err != nil { 200 | panic(err) 201 | } 202 | sock.Packetloss = 0.0 203 | 204 | // Note: This requires a system to update the framebuffer if the window is resized. The system should essentially recreate the framebuffer with the new dimensions, This might be a good target for the framebuffer callback, but for now I'm just going to poll win.Bounds 205 | renderBounds := win.Bounds() 206 | frame := glitch.NewFrame(renderBounds, true) 207 | 208 | windowPass := glitch.NewRenderPass(pixelArtShader) 209 | 210 | pass := glitch.NewRenderPass(shader) 211 | pass.SoftwareSort = glitch.SoftwareSortY 212 | tilemapPass := glitch.NewRenderPass(shader) 213 | 214 | tilemap := mmo.LoadGame(world) 215 | 216 | grassTile, err := spritesheet.Get("grass0.png") 217 | if err != nil { panic(err) } 218 | dirtTile, err := spritesheet.Get("dirt0.png") 219 | if err != nil { panic(err) } 220 | waterTile, err := spritesheet.Get("water0.png") 221 | if err != nil { panic(err) } 222 | concreteTile, err := spritesheet.Get("concrete0.png") 223 | if err != nil { panic(err) } 224 | wallSprite, err := spritesheet.Get("wall0.png") 225 | if err != nil { panic(err) } 226 | 227 | tmapRender := render.NewTilemapRender(spritesheet, map[tile.TileType]*glitch.Sprite{ 228 | mmo.GrassTile: grassTile, 229 | mmo.DirtTile: dirtTile, 230 | mmo.WaterTile: waterTile, 231 | mmo.ConcreteTile: concreteTile, 232 | }, tilemapPass) 233 | 234 | tmapRender.Clear() 235 | tmapRender.Batch(tilemap) 236 | 237 | debugMode := false 238 | 239 | textInputMode := false 240 | 241 | screenCamera := glitch.NewCameraOrtho() 242 | screenCamera.SetOrtho2D(win.Bounds()) 243 | screenCamera.SetView2D(0, 0, 1.0, 1.0) 244 | group := ui.NewGroup(win, screenCamera, atlas) 245 | 246 | camera := render.NewCamera(win.Bounds(), 0, 0) 247 | camera.Zoom = 2.0 248 | 249 | updateQueue := queue.New[serdes.WorldUpdate]() 250 | 251 | quit := ecs.Signal{} 252 | quit.Set(false) 253 | 254 | inputSystems := []ecs.System{ 255 | ClientPollNetworkSystem(networkChannel, updateQueue), 256 | ClientPullFromUpdateQueue(world, updateQueue, playerData), 257 | ecs.System{"ManageEntityTimeout", func(dt time.Duration) { 258 | timeout := 5 * time.Second 259 | now := time.Now() 260 | ecs.Map(world, func(id ecs.Id, lastUpdate *LastUpdate) { 261 | if now.Sub(lastUpdate.Time) > timeout { 262 | ecs.Delete(world, id) 263 | } 264 | }) 265 | }}, 266 | ecs.System{"BodySetup", func(dt time.Duration) { 267 | ecs.Map(world, func(id ecs.Id, body *mmo.Body) { 268 | // TODO - is there a way to not have to poll these each frame? 269 | // Body to animation 270 | _, ok := ecs.Read[Animation](world, id) 271 | if !ok { 272 | ecs.Write(world, id, 273 | ecs.C(NewAnimation(load, spritesheet, *body)), 274 | ) 275 | } 276 | 277 | // Body to collider 278 | _, ok = ecs.Read[phy2.CircleCollider](world, id) 279 | if !ok { 280 | // TODO - hardcoded here and in network.go - Centralize character creation 281 | // TODO - arbitrary collider radius 6 282 | collider := phy2.NewCircleCollider(6) 283 | collider.Layer = mmo.BodyLayer 284 | collider.HitLayer = mmo.BodyLayer 285 | ecs.Write(world, id, 286 | ecs.C(collider), 287 | ecs.C(phy2.NewColliderCache()), 288 | ) 289 | } 290 | }) 291 | 292 | // Tile objects? 293 | ecs.Map(world, func(id ecs.Id, body *mmo.TileObject) { 294 | _, ok := ecs.Read[render.Sprite](world, id) 295 | if !ok { 296 | ecs.Write(world, id, 297 | ecs.C(render.NewSprite(wallSprite)), 298 | ) 299 | } 300 | }) 301 | }}, 302 | ecs.System{"MouseInput", func(dt time.Duration) { 303 | // TODO - move to other system 304 | _, scrollY := win.MouseScroll() 305 | 306 | const minZoom = 2 307 | const maxZoom = 16.0 308 | 309 | if scrollY > 0 { 310 | if camera.Zoom < maxZoom { 311 | camera.Zoom = camera.Zoom * 2 312 | } 313 | } else if scrollY < 0 { 314 | if camera.Zoom > minZoom { 315 | camera.Zoom = camera.Zoom / 2 316 | } 317 | } 318 | }}, 319 | ecs.System{"CaptureInput", func(dt time.Duration) { 320 | if !textInputMode { 321 | // Check if they want to leave 322 | if win.Pressed(glitch.KeyBackspace) { 323 | quit.Set(true) 324 | } 325 | 326 | CaptureInput(win, world) 327 | } else { 328 | // Clear current inputs 329 | ecs.Map2(world, func(id ecs.Id, keybinds *Keybinds, input *mmo.Input) { 330 | input.Left = false 331 | input.Right = false 332 | input.Up = false 333 | input.Down = false 334 | }) 335 | 336 | if win.JustPressed(glitch.KeyEscape) { 337 | textInputMode = false 338 | } 339 | } 340 | }}, 341 | ecs.System{"SetAnimationFromState", func(dt time.Duration) { 342 | ecs.Map2(world, func(id ecs.Id, pos *phy2.Pos, netPos *NetPos) { 343 | // Option 1 344 | /* 345 | netPos.Remaining -= dt 346 | 347 | interpFactor := 1 - (netPos.Remaining.Seconds() / netPos.Total.Seconds()) 348 | if interpFactor > 1 { // TODO - can I prevent this from going above, makes it stop for a second/frame 349 | interpFactor = 1 350 | } 351 | 352 | 353 | // Lerp the InterpTo point to the extrapolated point 354 | iFactor := 0.1 355 | netPos.InterpTo.X = interp.Linear.Float64(netPos.InterpTo.X, netPos.ExtrapolatedPos.X, iFactor) 356 | netPos.InterpTo.Y = interp.Linear.Float64(netPos.InterpTo.Y, netPos.ExtrapolatedPos.Y, iFactor) 357 | // netPos.InterpTo = netPos.ExtrapolatedPos 358 | 359 | // Lerp the entity 360 | pos.X = interp.Linear.Float64(netPos.InterpFrom.X, netPos.InterpTo.X, interpFactor) 361 | pos.Y = interp.Linear.Float64(netPos.InterpFrom.Y, netPos.InterpTo.Y, interpFactor) 362 | */ 363 | 364 | // Option 2 - Looks way better 365 | interpFactor := 0.1 // TODO - Could this dynamically scale based on connection quality? 366 | pos.X = interp.Linear.Float64(pos.X, netPos.ExtrapolatedPos.X, interpFactor) 367 | pos.Y = interp.Linear.Float64(pos.Y, netPos.ExtrapolatedPos.Y, interpFactor) 368 | }) 369 | 370 | minAnim := 2.0 //TODO - hardcoded 371 | ecs.Map4(world, func(id ecs.Id, input *mmo.Input, anim *Animation, pos *phy2.Pos, netPos *NetPos) { 372 | if input.Left && !input.Right { 373 | anim.Direction = "left" 374 | anim.SetAnimation("run_left") 375 | } else if input.Right && !input.Left { 376 | anim.Direction = "right" 377 | anim.SetAnimation("run_right") 378 | } else if input.Up || input.Down { 379 | anim.SetAnimation("run_" + anim.Direction) 380 | } else { 381 | // if phyT.DistanceTo(&netPos.PhyTrans) > minAnim { 382 | // return // Don't set idle because we are still interpolating to our destination 383 | // } 384 | // next := netPos.First() 385 | if pos.Sub(netPos.InterpTo).Len() > minAnim { 386 | return // Don't set idle because we are still interpolating to our destination 387 | } 388 | 389 | if input.Left && input.Right { 390 | anim.SetAnimation("idle_" + anim.Direction) 391 | } else { 392 | anim.SetAnimation("idle_" + anim.Direction) 393 | } 394 | } 395 | }) 396 | }}, 397 | } 398 | 399 | physicsSystems := CreateClientSystems(world, sock, playerData, tilemap) 400 | 401 | panelSprite, err := spritesheet.GetNinePanel("ui_panel0.png", glitch.R(2, 2, 2, 2)) 402 | if err != nil { panic(err) } 403 | panelSprite.Scale = 8 404 | textInputString := "" 405 | 406 | debugSprite, err := spritesheet.Get("ui_panel0.png") 407 | if err != nil { panic(err) } 408 | 409 | renderSystems := []ecs.System{ 410 | ecs.System{"UpdateFramebuffer", func(dt time.Duration) { 411 | renderBounds := win.Bounds() 412 | // TODO - how to determine 16? 413 | renderBounds = renderBounds.Pad(glitch.R(16,16,16,16)) // Pad out by 16 pixel so that camera can drift inside pixels 414 | if frame.Bounds() != renderBounds { 415 | frame = glitch.NewFrame(renderBounds, true) 416 | // log.Print("recreating fbo: ", frame.Bounds(), renderBounds, win.Bounds()) 417 | } 418 | }}, 419 | ecs.System{"UpdateCamera", func(dt time.Duration) { 420 | transform, ok := ecs.Read[phy2.Pos](world, playerData.Id()) 421 | if ok { 422 | // log.Print("Update Camera", transform) 423 | // sprite := comp[1].(*render.Sprite) 424 | // camera.Position = sprite.Position 425 | camera.Position = glitch.Vec2{float32(transform.X), float32(transform.Y)} 426 | } 427 | 428 | // ecs.Map2(world, func(id ecs.Id, _ *mmo.ClientOwned, transform *phy2.Transform) { 429 | // log.Println("Update Camera", transform) 430 | // // sprite := comp[1].(*render.Sprite) 431 | // // camera.Position = sprite.Position 432 | // camera.Position = glitch.Vec2{float32(transform.X), float32(transform.Y)} 433 | // }) 434 | 435 | camera.Update(win.Bounds()) 436 | // camera.Update(renderBounds) 437 | }}, 438 | ecs.System{"Draw", func(dt time.Duration) { 439 | glitch.Clear(win, glitch.RGBA{0, 0, 0, 1.0}) 440 | 441 | // win.SetMatrix(camera.Mat()) 442 | // tmapRender.Draw(win) 443 | 444 | pass.Clear() 445 | 446 | ecs.Map2(world, func(id ecs.Id, sprite *render.Sprite, pos *phy2.Pos) { 447 | sprite.Draw(pass, pos) 448 | }) 449 | 450 | PlayAnimations(pass, world, dt) 451 | 452 | // Debug. Draw neworking position buffer 453 | if debugMode { 454 | pass.SetLayer(0) 455 | ecs.Map2(world, func(id ecs.Id, pos *phy2.Pos, netPos *NetPos) { 456 | 457 | // npos := nt.PhyTrans 458 | // npos := nt.Last() 459 | // mat := glitch.Mat4Ident 460 | // mat.Scale(0.5, 0.5, 1.0).Translate(float32(npos.X), float32(npos.Y + npos.Height), 0) 461 | // debugSprite.Draw(pass, mat) 462 | 463 | // // Interp Replay buffer 464 | // nt.Map(func(t ServerTransform) { 465 | // mat := glitch.Mat4Ident 466 | // mat.Scale(0.5, 0.5, 1.0).Translate(float32(t.X), float32(t.Y), 0) 467 | // debugSprite.DrawColorMask(pass, mat, glitch.RGBA{0, 1, 0, 0.5}) 468 | // }) 469 | 470 | mat := glitch.Mat4Ident 471 | // mat.Scale(0.5, 0.5, 1.0).Translate(float32(netPos.ExtrapolationOffset.X), float32(netPos.ExtrapolationOffset.Y), 0) 472 | // debugSprite.DrawColorMask(pass, mat, glitch.RGBA{0, 0, 0, 1}) 473 | 474 | // mat = glitch.Mat4Ident 475 | // // mat.Scale(0.5, 0.5, 1.0).Translate(float32(netPos.InterpFrom.X + netPos.Extrapolation.X), float32(netPos.InterpFrom.Y + netPos.Extrapolation.Y + netPos.Extrapolation.Height), 0) 476 | // mat.Scale(0.5, 0.5, 1.0).Translate(float32(netPos.Extrapolation.X), float32(netPos.Extrapolation.Y), 0) 477 | // debugSprite.DrawColorMask(pass, mat, glitch.RGBA{1, 1, 1, 1}) 478 | 479 | mat = glitch.Mat4Ident 480 | mat.Scale(0.5, 0.5, 1.0).Translate(float32(netPos.InterpFrom.X), float32(netPos.InterpFrom.Y), 0) 481 | debugSprite.DrawColorMask(pass, mat, glitch.RGBA{1, 0, 0, 1}) 482 | 483 | mat = glitch.Mat4Ident 484 | mat.Scale(0.5, 0.5, 1.0).Translate(float32(netPos.InterpTo.X), float32(netPos.InterpTo.Y), 0) 485 | debugSprite.DrawColorMask(pass, mat, glitch.RGBA{0, 1, 0, 1}) 486 | }) 487 | 488 | // Last Server Position 489 | // ecs.Map(world, func(id ecs.Id, t *ServerTransform) { 490 | // mat := glitch.Mat4Ident 491 | // mat.Scale(0.5, 0.5, 1.0).Translate(float32(t.X), float32(t.Y + t.Height), 0) 492 | // debugSprite.DrawColorMask(pass, mat, glitch.RGBA{1, 0, 0, 1}) 493 | // }) 494 | 495 | inputBuffer := playerData.GetInputBuffer() 496 | netPos, _ := ecs.Read[NetPos](world, playerData.Id()) 497 | collider, _ := ecs.Read[phy2.CircleCollider](world, playerData.Id()) 498 | extPos := netPos.PreExtInterpTo 499 | for i := range inputBuffer { 500 | for ii := 0; ii < mmo.NetworkTickDivider; ii++ { 501 | mmo.MoveCharacter(&inputBuffer[i].Input, &extPos, &collider, tilemap, mmo.FixedTimeStep) 502 | } 503 | 504 | mat := glitch.Mat4Ident 505 | mat.Scale(0.25, 0.25, 1.0).Translate(float32(extPos.X), float32(extPos.Y), 0) 506 | debugSprite.DrawColorMask(pass, mat, glitch.RGBA{1, 1, 1, 1}) 507 | } 508 | } 509 | 510 | pass.SetLayer(glitch.DefaultLayer) 511 | 512 | // Draw speech bubbles 513 | { 514 | // TODO - move to physics system 515 | { 516 | commandList := make([]func(), 0) 517 | ecs.Map(world, func(id ecs.Id, speech *mmo.Speech) { 518 | if speech.HandleRender() { 519 | commandList = append(commandList, 520 | func() { 521 | // TODO - combine SpeechRender component with otherone in mmo.SetSpeech() 522 | ecs.Write(world, id, ecs.C(SpeechRender{ 523 | Text: atlas.Text(speech.Text), 524 | RemainingDuration: 5 * time.Second, 525 | })) 526 | }) 527 | } 528 | }) 529 | 530 | for _, c := range commandList { 531 | c() 532 | } 533 | } 534 | 535 | ecs.Map2(world, func(id ecs.Id, speech *SpeechRender, pos *phy2.Pos) { 536 | 537 | if speech.RemainingDuration < 0 { return } // Skip the display duration has ended 538 | speech.RemainingDuration -= dt 539 | 540 | scale := float32(0.4) 541 | mat := glitch.Mat4Ident 542 | mat.Scale(scale, scale, 1.0).Translate(float32(pos.X), float32(pos.Y), 0) 543 | bounds := speech.Text.Bounds() 544 | mat.Translate(scale * (-bounds.W()/2), 15, 0) // TODO - 15 should come from the body height of the character (plus the font descent, or maybe half text line height) 545 | 546 | col := glitch.RGBA{1, 1, 1, 1} 547 | pass.SetLayer(glitch.DefaultLayer - 1) // TODO setup layers for world UI 548 | speech.Text.DrawColorMask(pass, mat, col) 549 | }) 550 | } 551 | 552 | glitch.Clear(frame, glitch.RGBA{0, 0, 0, 0}) 553 | 554 | tilemapPass.SetUniform("projection", camera.Camera.Projection) 555 | tilemapPass.SetUniform("view", camera.Camera.ViewSnapped) 556 | tilemapPass.Draw(frame) 557 | 558 | pass.SetUniform("projection", camera.Camera.Projection) 559 | pass.SetUniform("view", camera.Camera.ViewSnapped) 560 | pass.Draw(frame) 561 | 562 | windowPass.Clear() 563 | windowPass.SetUniform("projection", camera.Camera.Projection) 564 | 565 | mat := glitch.Mat4Ident 566 | vvx, vvy, _ := camera.Camera.View.GetTranslation() 567 | vsx, vsy, _ := camera.Camera.ViewSnapped.GetTranslation() 568 | mat.Translate(float32(vvx - vsx), float32(vvy - vsy), 0) 569 | windowPass.SetUniform("view", mat) 570 | 571 | frame.Draw(windowPass, glitch.Mat4Ident) 572 | windowPass.Draw(win) 573 | 574 | // Draw UI 575 | ui.Clear() 576 | { 577 | group.Clear() 578 | screenCamera.SetOrtho2D(win.Bounds()) 579 | screenCamera.SetView2D(0, 0, 1.0, 1.0) 580 | 581 | paddingRect := glitch.R(-10,-10,-10,-10) 582 | connectedRect := win.Bounds() 583 | connectedRect = connectedRect.Pad(paddingRect) 584 | textScale := float32(0.5) 585 | if sock.Connected.Load() { 586 | group.SetColor(glitch.RGBA{0, 1, 0, 1}) 587 | group.FixedText("Connected", connectedRect, glitch.Vec2{1, 0}, textScale) 588 | rtt := playerData.RoundTripTimes() 589 | rttPoints := make([]glitch.Vec2, len(rtt)) 590 | for i := range rtt { 591 | rttPoints[i] = glitch.Vec2{ 592 | float32(i), 593 | float32(1000 * rtt[i].Seconds()), 594 | } 595 | } 596 | rttRect := connectedRect.Anchor(glitch.R(0, 0, 200, 100), glitch.Vec2{1, 1})//.Moved(glitch.Vec2{0, connectedRect.H()}) 597 | if debugMode { 598 | group.SetColor(glitch.RGBA{0, 0, 1, 1}) 599 | group.LineGraph(rttRect, rttPoints) 600 | } 601 | } else { 602 | group.SetColor(glitch.RGBA{1, 0, 0, 1}) 603 | group.FixedText("Disconnected", connectedRect, glitch.Vec2{1, 0}, textScale) 604 | } 605 | 606 | if !textInputMode && win.JustPressed(glitch.KeyEnter) { 607 | textInputMode = true 608 | } else if !textInputMode && win.JustPressed(glitch.KeySlash) { 609 | textInputMode = true 610 | textInputString = "/" 611 | } else if textInputMode { 612 | inputRect := win.Bounds() 613 | inputRect = inputRect.CutBottom(200) 614 | inputRect = inputRect.CutTop(100) 615 | inputRect = inputRect.SliceVertical(win.Bounds().W() / 3) 616 | // inputRect = inputRect.SliceVertical(win.Bounds().W() / 3) 617 | // inputRect = inputRect.SliceHorizontal(100) 618 | group.SetColor(glitch.RGBA{1, 1, 1, 1}) 619 | group.TextInput(panelSprite, &textInputString, inputRect, glitch.Vec2{0.5, 0.5}, textScale) 620 | if win.JustPressed(glitch.KeyEnter) { 621 | if strings.HasPrefix(textInputString, "/") { 622 | if strings.HasPrefix(textInputString, "/debug") { 623 | debugMode = !debugMode 624 | } else if strings.HasPrefix(textInputString, "/sim i") { 625 | sock.Packetloss = 0.15 626 | // sock.MinDelay = 160 * time.Millisecond 627 | // sock.MaxDelay = 240 * time.Millisecond 628 | } else if strings.HasPrefix(textInputString, "/sim l") { 629 | sock.Packetloss = 0.05 630 | // sock.MinDelay = 25 * time.Millisecond 631 | // sock.MaxDelay = 50 * time.Millisecond 632 | } else if strings.HasPrefix(textInputString, "/sim n") { 633 | sock.Packetloss = 0.0 634 | // sock.MinDelay = 0 635 | // sock.MaxDelay = 0 636 | } 637 | } else { 638 | // Write the player's speech bubble 639 | SetSpeech(world, atlas, playerData.Id(), textInputString) 640 | } 641 | 642 | textInputString = textInputString[:0] 643 | textInputMode = false 644 | } 645 | } 646 | 647 | group.Draw() 648 | } 649 | }}, 650 | ecs.System{"UpdateWindow", func(dt time.Duration) { 651 | win.Update() 652 | }}, 653 | } 654 | 655 | schedule := mmo.GetScheduler() 656 | 657 | // physicsSystems = append(physicsSystems, ecs.System{"UpdateWindow", func(dt time.Duration) { 658 | // syslog := schedule.Syslog() 659 | // for i := range syslog { 660 | // log.Print(syslog[i]) 661 | // } 662 | // }}) 663 | schedule.AppendInput(inputSystems...) 664 | schedule.AppendPhysics(physicsSystems...) 665 | schedule.AppendRender(renderSystems...) 666 | 667 | schedule.Run(&quit) 668 | // ecs.RunGame(inputSystems, physicsSystems, renderSystems, &quit) 669 | log.Print("Finished ecs.RunGame") 670 | 671 | // TODO - I'm not sure if this is the proper way to close because `ClientReceive` is still reading, so closing here will cause that to fail 672 | sock.Close() 673 | } 674 | -------------------------------------------------------------------------------- /app/client/client2.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "time" 5 | // "math" 6 | "errors" 7 | 8 | "github.com/rs/zerolog/log" 9 | 10 | "github.com/unitoftime/ecs" 11 | "github.com/unitoftime/glitch" 12 | 13 | "github.com/unitoftime/flow/ds" 14 | "github.com/unitoftime/flow/phy2" 15 | "github.com/unitoftime/flow/tile" 16 | "github.com/unitoftime/flow/net" 17 | 18 | "github.com/unitoftime/mmo" 19 | "github.com/unitoftime/mmo/serdes" 20 | ) 21 | 22 | // This is mostly for debug, but maybe its a good thing to track 23 | type ServerTransform struct { 24 | phy2.Pos 25 | Handled bool 26 | ServerTick uint16 27 | PlayerTick uint16 28 | } 29 | 30 | type NetPos struct { 31 | InterpFrom, InterpTo phy2.Pos // The current interpolation values to be using 32 | Remaining, Total time.Duration 33 | 34 | ExtrapolatedPos, PreExtInterpTo phy2.Pos // The interpolation destination before the extrap value was added 35 | } 36 | 37 | func CreateClientSystems(world *ecs.World, sock *net.Socket, playerData *PlayerData, tilemap *tile.Tilemap) []ecs.System { 38 | clientSystems := []ecs.System{ 39 | ecs.System{"ClientSendUpdate", func(dt time.Duration) { 40 | ClientSendUpdate(world, sock, playerData) 41 | }}, 42 | ecs.System{"InterpolateSpritePositions", func(dt time.Duration) { 43 | // TODO - hack. We needed a way to create the transform component for other players (because we did a change which makes us set NextTransform over the wire instead of transform. So those were never being set 44 | 45 | playerId := playerData.Id() 46 | ecs.Map(world, func(id ecs.Id, serverTransform *ServerTransform) { 47 | pos, ok := ecs.Read[phy2.Pos](world, id) 48 | if !ok { 49 | pos = phy2.Pos{} 50 | ecs.Write(world, id, ecs.C(pos)) 51 | } 52 | 53 | netPos, ok := ecs.Read[NetPos](world, id) 54 | 55 | if !serverTransform.Handled { 56 | // log.Print("New ServerTransform") 57 | serverTransform.Handled = true 58 | ecs.Write(world, id, ecs.C(serverTransform)) 59 | 60 | netPos.InterpFrom = pos 61 | netPos.InterpTo = serverTransform.Pos 62 | netPos.PreExtInterpTo = netPos.InterpTo 63 | netPos.ExtrapolatedPos = netPos.InterpTo // This will be modified by client-side prediction 64 | 65 | // TODO! - This should also be multiplied by the distance between the last and next ticks 66 | netPos.Total = time.Duration(mmo.NetworkTickDivider) * mmo.FixedTimeStep 67 | 68 | // Idea: Add like a single frame of time to the total so that we get there a little bit slower. Ideally this would smooth the transitions between frames b/c we'd always lag behind. 69 | // netPos.Total = netPos.Total + 32 * time.Millisecond 70 | 71 | netPos.Remaining = netPos.Total 72 | 73 | // Extrapolate with trimmed player input buffer 74 | if id == playerId { 75 | inputBuffer := playerData.GetInputBuffer() 76 | 77 | //TODO! - Collider is wrong here 78 | collider, ok := ecs.Read[phy2.CircleCollider](world, playerId) 79 | if !ok { return } // Skip if player doesn't have a collider 80 | 81 | for i := range inputBuffer { 82 | for ii := 0; ii < mmo.NetworkTickDivider; ii++ { 83 | mmo.MoveCharacter(&inputBuffer[i].Input, &netPos.ExtrapolatedPos, &collider, tilemap, mmo.FixedTimeStep) 84 | } 85 | } 86 | } 87 | } 88 | 89 | ecs.Write(world, id, ecs.C(netPos)) 90 | }) 91 | }}, 92 | } 93 | 94 | physicsSystems := []ecs.System{ 95 | ecs.System{"SetupColliders", func(dt time.Duration) { 96 | // Set the collider position 97 | ecs.Map2(world, func(id ecs.Id, netPos *NetPos, col *phy2.CircleCollider) { 98 | col.CenterX = netPos.InterpTo.X 99 | col.CenterY = netPos.InterpTo.Y 100 | }) 101 | }}, 102 | ecs.System{"CheckCollisions", func(dt time.Duration) { 103 | mmo.CheckCollisions(world) 104 | }}, 105 | } 106 | clientSystems = append(clientSystems, physicsSystems...) 107 | return clientSystems 108 | } 109 | 110 | var everyOther int 111 | func ClientSendUpdate(world *ecs.World, clientConn *net.Socket, playerData *PlayerData) { 112 | // TODO! - Not sure if this is okay 113 | everyOther = (everyOther + 1) % mmo.NetworkTickDivider 114 | if everyOther != 0 { 115 | return // skip 116 | } 117 | 118 | playerId := playerData.Id() 119 | // if clientConn is closed for some reason, then we won't be able to send 120 | // TODO - With the atomic this fast enough? 121 | connected := clientConn.Connected.Load() 122 | if !connected { return } // Exit early because we are not connected 123 | 124 | input, ok := ecs.Read[mmo.Input](world, playerId) 125 | if !ok { return } // If we can't find the players input just exit early 126 | 127 | playerTick := playerData.AppendInputTick(input) 128 | 129 | compSlice := []ecs.Component{ 130 | ecs.C(input), 131 | } 132 | 133 | // lastMsg := playerData.GetLastMessage() 134 | // // log.Print(lastMsg) 135 | // var messages []mmo.ChatMessage 136 | // if lastMsg != nil { 137 | // messages = []mmo.ChatMessage{ 138 | // mmo.ChatMessage{ 139 | // Username: "", // Note: Can't trust the username that the client sends 140 | // Message: lastMsg.Message, 141 | // }, 142 | // } 143 | // } 144 | 145 | // If we can't find a speech, that's okay 146 | speech, speechFound := ecs.Read[mmo.Speech](world, playerId) 147 | if speechFound { 148 | if speech.HandleSent() { 149 | compSlice = append(compSlice, ecs.C(speech)) 150 | ecs.Write(world, playerId, ecs.C(speech)) 151 | } 152 | } 153 | 154 | // log.Print(messages) 155 | 156 | update := serdes.WorldUpdate{ 157 | PlayerTick: playerTick, 158 | WorldData: map[ecs.Id][]ecs.Component{ 159 | playerId: compSlice, 160 | }, 161 | // Messages: messages, 162 | } 163 | // log.Print("ClientSendUpdate:", update) 164 | 165 | // log.Print(update) 166 | 167 | // Duplicate Sends to counter packet loss 168 | // TODO - Maybe make this more intricate, send the last N inputs in one big packet at 1/N the rate 169 | for i := 0; i < mmo.ClientInputResendRate; i++ { 170 | err := clientConn.Send(update) 171 | if err != nil { 172 | log.Warn().Err(err).Msg("ClientSendUpdate") 173 | } 174 | } 175 | 176 | // ecs.Map2(world, func(id ecs.Id, _ *ClientOwned, input *phy2.Input) { 177 | // update := serdes.WorldUpdate{ 178 | // WorldData: map[ecs.Id][]ecs.Component{ 179 | // id: []ecs.Component{ecs.C(*input)}, 180 | // }, 181 | // } 182 | // log.Println("ClientSendUpdate:", update) 183 | 184 | // err := clientConn.Send(update) 185 | // if err != nil { 186 | // log.Println(err) 187 | // } 188 | // }) 189 | } 190 | 191 | var AvgWorldUpdateTime time.Duration 192 | func ClientReceive(sock *net.Socket, playerData *PlayerData, networkChannel chan serdes.WorldUpdate) error { 193 | // lastWorldUpdate := time.Now() 194 | bufLen := 100 195 | worldUpdateTimes := ds.NewRingBuffer[time.Duration](bufLen) 196 | for i := 0; i < bufLen; i++ { 197 | worldUpdateTimes.Add(mmo.NetworkTickDivider * mmo.FixedTimeStep) 198 | } 199 | 200 | for { 201 | msg, err := sock.Recv() 202 | if errors.Is(err, net.ErrNetwork) { 203 | // Handle errors where we should stop (ie connection closed or something) 204 | log.Warn().Err(err).Msg("ClientReceive NetworkErr") 205 | return err 206 | } else if errors.Is(err, net.ErrSerdes) { 207 | // Handle errors where we should continue (ie serialization) 208 | log.Error().Err(err).Msg("ClientReceive SerdesErr") 209 | continue 210 | } 211 | if msg == nil { continue } 212 | 213 | switch t := msg.(type) { 214 | case serdes.WorldUpdate: 215 | // log.Print("Ticks: ", t.Tick, t.PlayerTick) 216 | // { 217 | // worldUpdateTimes.Add(time.Since(lastWorldUpdate)) 218 | // lastWorldUpdate = time.Now() 219 | // buf := worldUpdateTimes.Buffer() 220 | // AvgWorldUpdateTime = 0 221 | // for i := range buf { 222 | // AvgWorldUpdateTime += buf[i] 223 | // } 224 | // AvgWorldUpdateTime = AvgWorldUpdateTime / time.Duration(len(buf)) 225 | // // log.Print("AvgWorldUpdateTime: ", AvgWorldUpdateTime) 226 | // } 227 | 228 | // log.Print("Client-NewWorldUpdate") 229 | // playerData.SetTicks(t.Tick, t.PlayerTick) 230 | 231 | // Note: Because the client received this speech bubble update from the server, we will handle the HandleSent() so that the client doesn't try to resend it to the server. 232 | // This code just calls HandleSent() on the player's speech bubble if they just received their own speech bubble 233 | compSlice, ok := t.WorldData[playerData.Id()] 234 | if ok { 235 | newCompSlice := make([]ecs.Component, 0) 236 | // Pull out mmo.Speech for playerId 237 | for _, c := range compSlice { 238 | switch t := c.(type) { 239 | case ecs.CompBox[mmo.Speech]: 240 | msg := t.Get().Text 241 | log.Print("Client received a message for himself! ", msg) 242 | speech := mmo.Speech{ 243 | Text: msg, 244 | } 245 | speech.HandleSent() 246 | // TODO - speech.HandleRender() - Would I ever use this to have the server send messages to the client? 247 | // compSlice[i] = ecs.C(speech) 248 | newCompSlice = append(newCompSlice, ecs.C(speech)) 249 | case ecs.CompBox[mmo.Input]: 250 | // If the server sent us back our own input, we just want to drop it, because we own that component 251 | continue 252 | default: 253 | newCompSlice = append(newCompSlice, c) 254 | } 255 | } 256 | t.WorldData[playerData.Id()] = newCompSlice 257 | } 258 | 259 | for j, compSlice := range t.WorldData { 260 | for i, c := range compSlice { 261 | switch tt := c.(type) { 262 | case ecs.CompBox[phy2.Pos]: 263 | serverTransform := ServerTransform{ 264 | Pos: tt.Get(), 265 | Handled: false, 266 | ServerTick: t.Tick, 267 | PlayerTick: t.PlayerTick, 268 | } 269 | compSlice[i] = ecs.C(serverTransform) 270 | // nextTransform := NextTransform{ 271 | // PhyTrans: t.Get(), 272 | // Replayed: false, 273 | // } 274 | // serverTransform := ServerTransform(t.Get()) 275 | // compSlice[i] = ecs.C(nextTransform) 276 | // compSlice = append(compSlice, ecs.C(serverTransform)) 277 | } 278 | } 279 | t.WorldData[j] = compSlice 280 | 281 | // for i := range compSlice { 282 | // log.Printf("%T\n", compSlice[i]) 283 | // } 284 | } 285 | 286 | networkChannel <- t 287 | case serdes.ClientLoginResp: 288 | log.Print("serdes.ClientLoginResp", t) 289 | // TODO this might be needed in the future if I want to write any data on login resp 290 | // ecs.Write(world, ecs.Id(t.Id), ecs.C(mmo.Body{})) 291 | // networkChannel <- serdes.WorldUpdate{ 292 | // UserId: t.UserId, 293 | // WorldData: map[ecs.Id][]ecs.Component{ 294 | // ecs.Id(t.Id): []ecs.Component{ 295 | // ecs.C(mmo.Body{}), 296 | // }, 297 | // }, 298 | // } 299 | 300 | playerData.SetId(t.Id) 301 | 302 | networkChannel <- serdes.WorldUpdate{ 303 | UserId: t.UserId, 304 | WorldData: map[ecs.Id][]ecs.Component{ 305 | ecs.Id(t.Id): []ecs.Component{ 306 | ecs.C(mmo.Input{}), 307 | ecs.C(phy2.Pos{}), 308 | ecs.C(Keybinds{ 309 | Up: glitch.KeyW, 310 | Down: glitch.KeyS, 311 | Left: glitch.KeyA, 312 | Right: glitch.KeyD, 313 | }), 314 | }, 315 | }, 316 | } 317 | 318 | default: 319 | log.Error().Msg("Unknown message type") 320 | } 321 | } 322 | 323 | return nil 324 | } 325 | -------------------------------------------------------------------------------- /app/client/input.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "github.com/unitoftime/glitch" 5 | "github.com/unitoftime/ecs" 6 | 7 | "github.com/unitoftime/mmo" 8 | ) 9 | 10 | type Keybinds struct { 11 | Up, Down, Left, Right glitch.Key 12 | } 13 | 14 | func CaptureInput(win *glitch.Window, world *ecs.World) { 15 | // TODO - technically this should only run for the player Ids? 16 | ecs.Map2(world, func(id ecs.Id, keybinds *Keybinds, input *mmo.Input) { 17 | input.Left = false 18 | input.Right = false 19 | input.Up = false 20 | input.Down = false 21 | 22 | if win.Pressed(keybinds.Left) { 23 | input.Left = true 24 | } 25 | if win.Pressed(keybinds.Right) { 26 | input.Right = true 27 | } 28 | if win.Pressed(keybinds.Up) { 29 | input.Up = true 30 | } 31 | if win.Pressed(keybinds.Down) { 32 | input.Down = true 33 | } 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /app/client/network.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/zyedidia/generic/queue" 7 | "github.com/unitoftime/ecs" 8 | 9 | "github.com/unitoftime/mmo" 10 | "github.com/unitoftime/mmo/serdes" 11 | ) 12 | 13 | type LastUpdate struct { 14 | Time time.Time 15 | } 16 | 17 | func ClientPollNetworkSystem(networkChannel chan serdes.WorldUpdate, 18 | updateQueue *queue.Queue[serdes.WorldUpdate]) ecs.System { 19 | 20 | // Read everything from the channel and push it into the updateQueue 21 | sys := ecs.System{"PollNetwork", func(dt time.Duration) { 22 | MainLoop: 23 | for { 24 | select { 25 | case update := <-networkChannel: 26 | updateQueue.Enqueue(update) 27 | 28 | default: 29 | break MainLoop 30 | } 31 | } 32 | }} 33 | 34 | return sys 35 | } 36 | 37 | func ClientPullFromUpdateQueue(world *ecs.World, updateQueue *queue.Queue[serdes.WorldUpdate], playerData *PlayerData) ecs.System { 38 | // TODO! - dynamic based on connection 39 | targetQueueSize := mmo.ClientDefaultUpdateQueueSize 40 | 41 | var everyOther int 42 | 43 | // Read a single element from the update queue 44 | sys := ecs.System{"PullUpdateQueue", func(dt time.Duration) { 45 | if targetQueueSize > 0 { 46 | //TODO! - IMPORTANT If I pull out like tick 100, then next tick 102, I know that those should be (2 * 64ms) apart and not 64 ms apart. I somehow need to fix that problem for when dropped packets are recv'ed. Or I need to split the difference and enqueue another thing 47 | everyOther = (everyOther + 1) % mmo.NetworkTickDivider 48 | if everyOther != 0 { 49 | return // skip 50 | } 51 | 52 | // // TODO - keep track of size 53 | queueSize := 0 54 | updateQueue.Each(func(u serdes.WorldUpdate) { 55 | queueSize++ 56 | }) 57 | if queueSize > targetQueueSize { 58 | // log.Print("UpdateQueue Desynchronization (TooBig): ", queueSize, targetQueueSize) 59 | // We want the next tick to run a little bit faster. 60 | // TODO - Optimization Note: The bigger you make the addition, the faster it gets back to target length, but the more dramatic the entity needs to be sped up to interp that distance. I could also change the %4 to %8 to make the speedup even more minimal. Right now this doesn't seem to noticeable 61 | everyOther = (everyOther + 1) % mmo.NetworkTickDivider 62 | }// else if queueSize < targetQueueSize { 63 | // log.Print("UpdateQueue Desynchronization (TooSmall): ", queueSize, targetQueueSize) 64 | // everyOther = (everyOther + 3) % mmo.NetworkTickDivider // Go back one and rerun 65 | // return 66 | // } 67 | // log.Print("UpdateQueueSize: ", queueSize) 68 | } 69 | 70 | if updateQueue.Empty() { 71 | return 72 | } 73 | 74 | update := updateQueue.Dequeue() 75 | 76 | // Update our playerData tick information 77 | playerData.SetTicks(update.Tick, update.PlayerTick) 78 | 79 | for id, compList := range update.WorldData { 80 | compList = append(compList, ecs.C(LastUpdate{time.Now()})) 81 | ecs.Write(world, id, compList...) 82 | } 83 | 84 | // Delete all the entities in the deleteList 85 | if update.Delete != nil { 86 | for _, id := range update.Delete { 87 | ecs.Delete(world, id) 88 | } 89 | } 90 | }} 91 | 92 | return sys 93 | } 94 | -------------------------------------------------------------------------------- /app/client/player.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "time" 5 | "math" 6 | "sync" 7 | 8 | "github.com/unitoftime/flow/ds" 9 | 10 | "github.com/unitoftime/ecs" 11 | "github.com/unitoftime/mmo" 12 | ) 13 | 14 | type InputBufferItem struct { 15 | Input mmo.Input 16 | Time time.Time 17 | } 18 | 19 | // This represents global player data on the client 20 | type PlayerData struct { 21 | mu sync.RWMutex 22 | id ecs.Id 23 | playerTick uint16 24 | serverTick uint16 25 | lastMessage string 26 | inputBuffer []InputBufferItem 27 | roundTripTimes *ds.RingBuffer[time.Duration] 28 | } 29 | 30 | func NewPlayerData() *PlayerData { 31 | return &PlayerData{ 32 | id: ecs.InvalidEntity, 33 | inputBuffer: make([]InputBufferItem, 0), 34 | roundTripTimes: ds.NewRingBuffer[time.Duration](100), // TODO - configurable 35 | } 36 | } 37 | 38 | func (p *PlayerData) Id() ecs.Id { 39 | p.mu.RLock() 40 | ret := p.id 41 | p.mu.RUnlock() 42 | return ret 43 | } 44 | 45 | func (p *PlayerData) SetId(id ecs.Id) { 46 | p.mu.Lock() 47 | p.id = id 48 | p.mu.Unlock() 49 | } 50 | 51 | // func (p *PlayerData) Tick() uint16 { 52 | // p.mu.RLock() 53 | // ret := p.tick 54 | // p.mu.RUnlock() 55 | // return ret 56 | // } 57 | 58 | func (p *PlayerData) SetTicks(serverTick, serverUpdatePlayerTick uint16) { 59 | // fmt.Println("SetTicks: ", serverTick, serverUpdatePlayerTick, time.Now()) 60 | 61 | p.mu.Lock() 62 | defer p.mu.Unlock() 63 | 64 | // Set the last server tick we've received 65 | p.serverTick = serverTick 66 | 67 | // Cut off every player input tick that the server hasn't processed 68 | cut := int(p.playerTick - serverUpdatePlayerTick) 69 | // fmt.Println("InputBuffer", p.serverTick, p.playerTick, serverUpdatePlayerTick, len(p.inputBuffer)) 70 | for i := 0; i < len(p.inputBuffer)-cut; i++ { 71 | p.roundTripTimes.Add(time.Since(p.inputBuffer[i].Time)) 72 | } 73 | 74 | if cut >= 0 && cut <= len(p.inputBuffer) { 75 | // TODO - it'd be more efficient to use a queue 76 | copy(p.inputBuffer, p.inputBuffer[len(p.inputBuffer)-cut:]) 77 | p.inputBuffer = p.inputBuffer[:cut] 78 | // fmt.Println("Copied", n, len(p.inputBuffer)) 79 | } else { 80 | // fmt.Println("OOB") 81 | } 82 | } 83 | 84 | // Returns the player tick that this input is associated with 85 | func (p *PlayerData) AppendInputTick(input mmo.Input) uint16 { 86 | p.mu.Lock() 87 | defer p.mu.Unlock() 88 | 89 | p.playerTick = (p.playerTick + 1) % math.MaxUint16 90 | p.inputBuffer = append(p.inputBuffer, InputBufferItem{ 91 | Input: input, 92 | Time: time.Now(), 93 | }) 94 | return p.playerTick 95 | } 96 | 97 | func (p *PlayerData) GetInputBuffer() []InputBufferItem { 98 | return p.inputBuffer 99 | } 100 | 101 | func (p *PlayerData) RoundTripTimes() []time.Duration { 102 | return p.roundTripTimes.Buffer() 103 | } 104 | 105 | // Returns the message as sent to the server 106 | // TODO - if the player sends another message fast enough, it could blank out their first message 107 | // func (p *PlayerData) SendMessage(msg string) string { 108 | // msg = serdes.FilterChat(msg) 109 | // p.lastMessage = msg 110 | // return msg 111 | // } 112 | 113 | // // Returns the last message and clears the last message buffer, returns nil if no new message 114 | // func (p *PlayerData) GetLastMessage() *game.ChatMessage { 115 | // if p.lastMessage == "" { 116 | // return nil 117 | // } 118 | 119 | // msg := p.lastMessage 120 | // p.lastMessage = "" 121 | // return &game.ChatMessage{ 122 | // // Username: nil, // TODO - return username? 123 | // Message: msg, 124 | // } 125 | // } 126 | -------------------------------------------------------------------------------- /app/client/render.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "time" 5 | "github.com/unitoftime/glitch" 6 | "github.com/unitoftime/ecs" 7 | "github.com/unitoftime/flow/render" 8 | "github.com/unitoftime/flow/phy2" 9 | "github.com/unitoftime/flow/asset" 10 | 11 | "github.com/unitoftime/packer" // TODO - move packer to flow? 12 | 13 | "github.com/unitoftime/mmo" 14 | ) 15 | 16 | type SpeechRender struct { 17 | Text *glitch.Text 18 | RemainingDuration time.Duration 19 | } 20 | 21 | func SetSpeech(world *ecs.World, atlas *glitch.Atlas, id ecs.Id, message string) { 22 | message = mmo.FilterChat(message) 23 | 24 | ecs.Write(world, id, 25 | ecs.C(mmo.Speech{ 26 | Text: message, 27 | // handled: false, 28 | }), 29 | ecs.C(SpeechRender{ 30 | Text: atlas.Text(message), 31 | RemainingDuration: 5 * time.Second, // TODO - should this scale based on text length? @Conifer's Idea wow!!!! 32 | }), 33 | ) 34 | } 35 | 36 | type Animation struct { 37 | Direction string // indicates if we are going left or right 38 | Body *render.Animation 39 | Hat *render.Animation 40 | batch *glitch.Batch 41 | } 42 | 43 | func (a *Animation) SetAnimation(name string) { 44 | a.Body.SetAnimation(name) 45 | if a.Hat != nil { 46 | a.Hat.SetAnimation(name) 47 | } 48 | } 49 | 50 | func PlayAnimations(pass *glitch.RenderPass, world *ecs.World, dt time.Duration) { 51 | ecs.Map2(world, func(id ecs.Id, anim *Animation, pos *phy2.Pos) { 52 | if anim.batch == nil { 53 | anim.batch = glitch.NewBatch() 54 | } 55 | 56 | anim.Body.Update(dt) 57 | anim.Hat.Update(dt) 58 | 59 | // TODO - minor optimization opportunity: Don't batch every frame, only the frames that change 60 | anim.batch.Clear() 61 | anim.Body.Draw(anim.batch, &phy2.Pos{}) 62 | 63 | hatPoint := phy2.Pos{} 64 | 65 | frame := anim.Body.GetFrame() 66 | mountPoint := frame.Mount("hat") 67 | hatPoint.X += float64(mountPoint[0]) 68 | hatPoint.Y += float64(mountPoint[1]) 69 | 70 | hatFrame := anim.Hat.GetFrame() 71 | hatDestPoint := hatFrame.Mount("dest") 72 | hatPoint.X -= float64(hatDestPoint[0]) 73 | hatPoint.Y -= float64(hatDestPoint[1]) 74 | 75 | anim.Hat.Draw(anim.batch, &hatPoint) 76 | 77 | mat := glitch.Mat4Ident 78 | mat.Translate(float32(pos.X), float32(pos.Y), 0) 79 | anim.batch.Draw(pass, mat) 80 | }) 81 | } 82 | 83 | func mirrorAnim(anims map[string][]render.Frame, from, to string) { 84 | // TODO - as a note. Mirrored anims share the same mount points. This might not right/left mountpoints. It probably works fine for centered mount points 85 | mirroredAnim := make([]render.Frame, 0) 86 | for i, frame := range anims[from] { 87 | mirroredAnim = append(mirroredAnim, frame) 88 | mirroredAnim[i].MirrorY = true // TODO - this should probably be !MirrorY 89 | } 90 | anims[to] = mirroredAnim 91 | } 92 | 93 | func loadAnim(animAssets *asset.Animation, mountFrames packer.MountFrames) map[string][]render.Frame { 94 | manFrames := make(map[string][]render.Frame) 95 | for animName, frames := range animAssets.Frames { 96 | renderFrames := make([]render.Frame, 0) 97 | for _, frame := range frames { 98 | 99 | // Build the frame 100 | rFrame := render.NewFrame(frame.Sprite, frame.Duration) 101 | rFrame.MirrorY = frame.MirrorY 102 | 103 | // Add on any available mounting data 104 | mountData, ok := mountFrames.Frames[frame.Name] 105 | if ok { 106 | hatPoint, ok := mountData.MountPoints[0xFF0000] 107 | if ok { 108 | point := glitch.Vec2{float32(hatPoint.X), float32(hatPoint.Y)} 109 | rFrame.SetMount("hat", point) 110 | } 111 | 112 | // Destination Mount Point TODO - rename dest to something better 113 | destPoint, ok := mountData.MountPoints[0x000000] 114 | if ok { 115 | point := glitch.Vec2{float32(destPoint.X), float32(destPoint.Y)} 116 | rFrame.SetMount("dest", point) 117 | } 118 | } 119 | 120 | // Append it 121 | renderFrames = append(renderFrames, rFrame) 122 | } 123 | manFrames[animName] = renderFrames 124 | } 125 | return manFrames 126 | } 127 | 128 | func NewAnimation(load *asset.Load, spritesheet *asset.Spritesheet, body mmo.Body) Animation { 129 | mountFrames, err := load.Mountpoints("assets/mountpoints.json") 130 | if err != nil { 131 | panic(err) 132 | } 133 | 134 | // Load the body 135 | manAssets, err := load.AseAnimation(spritesheet, "assets/man.json") 136 | if err != nil { 137 | panic(err) 138 | } 139 | manFrames := loadAnim(manAssets, mountFrames) 140 | 141 | mirrorAnim(manFrames, "run_left", "run_right") 142 | mirrorAnim(manFrames, "idle_left", "idle_right") 143 | bodyAnim := render.NewAnimation("idle_left", manFrames) 144 | 145 | 146 | hats := []string{ 147 | "assets/hat-top.json", 148 | "assets/hat-mohawk.json", 149 | "assets/hat-nightcap.json", 150 | "assets/hat-bycocket.json", 151 | } 152 | hatFile := hats[body.Type] 153 | // Load the hat 154 | hatAssets, err := load.AseAnimation(spritesheet, hatFile) 155 | if err != nil { 156 | panic(err) 157 | } 158 | hatFrames := loadAnim(hatAssets, mountFrames) 159 | mirrorAnim(hatFrames, "run_left", "run_right") 160 | mirrorAnim(hatFrames, "idle_left", "idle_right") 161 | hatAnim := render.NewAnimation("idle_left", hatFrames) 162 | 163 | return Animation{ 164 | Direction: "left", 165 | Body: &bodyAnim, 166 | Hat: &hatAnim, 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /app/proxy/proxy.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/signal" 7 | "net/http" 8 | "crypto/tls" 9 | "sync" 10 | "time" 11 | "errors" 12 | 13 | "github.com/rs/zerolog" 14 | "github.com/rs/zerolog/log" 15 | 16 | "github.com/unitoftime/flow/net" 17 | 18 | "github.com/unitoftime/mmo" 19 | "github.com/unitoftime/mmo/stat" 20 | "github.com/unitoftime/mmo/serdes" 21 | "github.com/unitoftime/ecs" 22 | ) 23 | 24 | type Config struct { 25 | ServerUri string 26 | KeyFile string 27 | CertFile string 28 | Test bool 29 | } 30 | 31 | func Main(config Config) { 32 | logfile, err := os.OpenFile("proxy.log", os.O_RDWR|os.O_CREATE, 0755) 33 | if err != nil { panic(err) } 34 | defer logfile.Close() 35 | zerolog.TimeFieldFormat = zerolog.TimeFormatUnix 36 | // log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) 37 | log.Logger = log.Output(zerolog.ConsoleWriter{Out: logfile}) 38 | 39 | log.Print("Using Config: ", config) 40 | 41 | room := NewRoom() 42 | 43 | serverNet := net.Config{ 44 | Url: config.ServerUri, 45 | Serdes: serdes.New(), 46 | ReconnectHandler: func(sock *net.Socket) error { 47 | // After we reconnect the proxy to the server, we want to log all the players into the server who were waiting. 48 | room.mu.RLock() 49 | for userId := range room.Map { 50 | log.Debug().Uint64(stat.UserId, userId).Msg("Reconnect - Sending Login Message for") 51 | 52 | loginMsg := serdes.ClientLogin{userId} 53 | err := sock.Send(loginMsg) 54 | if err != nil { 55 | log.Error().Err(err).Uint64(stat.UserId, userId).Msg("Failed to send login message") 56 | } 57 | } 58 | room.mu.RUnlock() 59 | 60 | return room.HandleGameUpdates(sock) 61 | }, 62 | } 63 | 64 | sock, err := serverNet.Dial() 65 | if err != nil { 66 | panic(err) 67 | } 68 | 69 | // HTTPS Version 70 | certPem, err := os.ReadFile(config.CertFile) 71 | if err != nil { 72 | panic(err) 73 | } 74 | keyPem, err := os.ReadFile(config.KeyFile) 75 | if err != nil { 76 | panic(err) 77 | } 78 | cert, err := tls.X509KeyPair(certPem, keyPem) 79 | if err != nil { 80 | panic(err) 81 | } 82 | tlsConfig := &tls.Config{ 83 | Certificates: []tls.Certificate{cert}, 84 | } 85 | 86 | hostname := ":443" 87 | if config.Test { 88 | hostname = "localhost:7777" 89 | } 90 | 91 | wsConfig := net.Config{ 92 | Url: "webrtc://"+hostname, 93 | // Url: "wss://"+hostname, 94 | Serdes: serdes.New(), 95 | TlsConfig: tlsConfig, 96 | HttpServer: &http.Server{ 97 | TLSConfig: tlsConfig, 98 | ReadTimeout: 10 * time.Second, 99 | WriteTimeout: 10 * time.Second, 100 | }, 101 | OriginPatterns: []string{"localhost:8081", "mmo.unit.dev", "unit.dev", "www.unit.dev"}, 102 | } 103 | 104 | listener, err := wsConfig.Listen() 105 | if err != nil { 106 | panic(err) 107 | } 108 | log.Print("Starting Proxy", listener.Addr()) 109 | 110 | playerServer := &websocketServer{ 111 | listener: listener, 112 | serverConn: sock, 113 | room: room, 114 | } 115 | playerServer.Start() 116 | 117 | sigs := make(chan os.Signal, 1) 118 | signal.Notify(sigs, os.Interrupt) 119 | 120 | select{ 121 | case sig := <-sigs: 122 | log.Print(fmt.Sprintf("Terminating: %v", sig)) 123 | } 124 | } 125 | 126 | type ClientConnection struct { 127 | sock *net.Socket 128 | } 129 | 130 | type websocketServer struct { 131 | listener net.Listener 132 | serverConn *net.Socket 133 | room *Room 134 | } 135 | 136 | func (s *websocketServer) Start() { 137 | for { 138 | sock, err := s.listener.Accept() 139 | if err != nil { 140 | log.Warn().Err(err).Msg("Failed to accept connection") 141 | continue 142 | } 143 | 144 | log.Print("Accepting new connection") 145 | go ServeNetConn(sock, s.serverConn, s.room) 146 | } 147 | } 148 | 149 | // This is just to make sure different users get different login ids 150 | var userIdCounter uint64 151 | 152 | // Handles the websocket connection to a specific client in the room 153 | func ServeNetConn(sock *net.Socket, serverConn *net.Socket, room *Room) { 154 | defer func() { 155 | err := sock.Close() 156 | if err != nil { 157 | log.Error().Err(err).Msg("Error closing websocket connection") 158 | } 159 | }() 160 | 161 | timeoutSeconds := 60 * time.Second 162 | timeout := make(chan uint8, 1) 163 | const StopTimeout uint8 = 0 164 | const ContTimeout uint8 = 1 165 | 166 | // Login player 167 | room.mu.Lock() 168 | // TODO - Eventually This id should come from the login request which probably has a JWT which encodes the data. You probably don't need that in a lock 169 | userId := userIdCounter 170 | userIdCounter++ 171 | _, ok := room.Map[userId] 172 | if ok { 173 | log.Print("Duplicate Login Detected! Exiting.") 174 | room.mu.Unlock() 175 | return 176 | } 177 | 178 | // sock := net.NewConnectedSocket(conn, serdes.New()) 179 | room.Map[userId] = ClientConnection{sock} 180 | 181 | room.mu.Unlock() 182 | 183 | // Cleanup room once they leave 184 | defer func() { 185 | room.mu.Lock() 186 | delete(room.Map, userId) 187 | room.mu.Unlock() 188 | }() 189 | 190 | // Send login message to server 191 | log.Debug().Uint64(stat.UserId, userId).Msg("Sending Login Message") 192 | log.Print("ServerConn Status:", serverConn) 193 | err := serverConn.Send(serdes.ClientLogin{userId}) 194 | if err != nil { 195 | log.Warn().Err(err).Msg("Failed to forward login message") 196 | return 197 | } 198 | 199 | // Send logout message to server 200 | defer func() { 201 | sendUserLogoutToServer(serverConn, userId) 202 | }() 203 | 204 | // Read data from client and sends to game server 205 | go func() { 206 | for { 207 | msg, err := sock.Recv() 208 | if errors.Is(err, net.ErrNetwork) { 209 | timeout <- StopTimeout // Stop timeout because of a read error 210 | log.Warn().Err(err).Msg("Failed to receive") 211 | return 212 | } else if errors.Is(err, net.ErrSerdes) { 213 | // Handle errors where we should continue (ie serialization) 214 | log.Error().Err(err).Msg("Failed to serialize") 215 | continue 216 | } 217 | 218 | // Tick the timeout watcher so we don't timeout! 219 | timeout <- ContTimeout 220 | 221 | // If the message was empty, just continue to the next one 222 | if msg == nil { continue } 223 | 224 | switch t := msg.(type) { 225 | case serdes.WorldUpdate: 226 | // log.Printf("%v", t.Messages) 227 | // for i := range t.Messages { 228 | // t.Messages[i].Filter() // Run a chat filter 229 | // } 230 | 231 | // Filter chat messages 232 | for _, compSlice := range t.WorldData { 233 | for i, c := range compSlice { 234 | 235 | switch t := c.(type) { 236 | case ecs.CompBox[mmo.Speech]: 237 | filteredText := mmo.FilterChat(t.Get().Text) 238 | log.Print("Chat Speech: ", t.Get().Text) 239 | log.Print("Chat Filter: ", filteredText) 240 | compSlice[i] = ecs.C(mmo.Speech{ 241 | Text: filteredText, 242 | }) 243 | } 244 | } 245 | } 246 | 247 | t.UserId = userId 248 | 249 | err := serverConn.Send(t) 250 | if err != nil { 251 | log.Warn().Err(err).Msg("Failed to send") 252 | } 253 | default: 254 | panic("Unknown message type") 255 | } 256 | } 257 | }() 258 | 259 | // Manage Timeout 260 | ExitTimeout: 261 | for { 262 | select { 263 | case res := <-timeout: 264 | if res == StopTimeout { 265 | log.Print("Manually Stopping Timeout Manager") 266 | break ExitTimeout 267 | } 268 | case <-time.After(timeoutSeconds): 269 | log.Print("User timed out!") 270 | break ExitTimeout 271 | } 272 | } 273 | } 274 | 275 | // TODO - rename 276 | type Room struct { 277 | mu sync.RWMutex 278 | Map map[uint64]ClientConnection 279 | } 280 | 281 | func NewRoom() *Room { 282 | return &Room{ 283 | Map: make(map[uint64]ClientConnection), 284 | } 285 | } 286 | 287 | func (r *Room) GetClientConn(userId uint64) *ClientConnection { 288 | r.mu.RLock() 289 | clientConn, ok := r.Map[userId] 290 | r.mu.RUnlock() 291 | if !ok { 292 | log.Print("User Disconnected", userId) 293 | return nil 294 | } 295 | 296 | return &clientConn 297 | } 298 | 299 | // Read data from game server and send to client 300 | func (r *Room) HandleGameUpdates(serverConn *net.Socket) error { 301 | for { 302 | msg, err := serverConn.Recv() 303 | if errors.Is(err, net.ErrNetwork) { 304 | // Handle errors where we should stop (ie connection closed or something) 305 | log.Warn().Err(err).Msg("HandleGameUpdates NetError") 306 | return err 307 | } else if errors.Is(err, net.ErrSerdes) { 308 | // Handle errors where we should continue (ie serialization) 309 | log.Error().Err(err).Msg("HandleGameUpdates SerdesError") 310 | continue 311 | } 312 | if msg == nil { continue } 313 | 314 | // log.Printf("GameUpdate: %v", msg) 315 | 316 | switch t := msg.(type) { 317 | case serdes.WorldUpdate: 318 | clientConn := r.GetClientConn(t.UserId) 319 | if clientConn == nil { 320 | // TODO - minor hack: Just remind server that they're disconnected 321 | sendUserLogoutToServer(serverConn, t.UserId) 322 | continue 323 | } 324 | 325 | t.UserId = 0 // Clear userId (clients don't need to know user IDs) 326 | err := clientConn.sock.Send(t) 327 | if err != nil { 328 | log.Warn().Err(err).Msg("Error Sending WorldUpdate to user") 329 | // TODO - User disconnected? Remove from map? Why is server still sending to them? 330 | } 331 | case serdes.ClientLoginResp: 332 | clientConn := r.GetClientConn(t.UserId) 333 | if clientConn == nil { continue } 334 | 335 | err := clientConn.sock.Send(serdes.ClientLoginResp{t.UserId, ecs.Id(t.Id)}) 336 | if err != nil { 337 | log.Warn().Err(err).Msg("Error Sending login response to user") 338 | // TODO - User disconnected? Remove from map? Why is server still sending to them? 339 | } 340 | 341 | case serdes.ClientLogoutResp: 342 | log.Print("Received serdes.ClientLogoutResp") 343 | // Note: When the proxy's client connection handler function exits, it removes the user from the room. 344 | // TODO - Do I want to send a message to the user that says "Logout Successful"? 345 | default: 346 | log.Error(). 347 | Err(fmt.Errorf("Server Sent unknown message type %T", msg)). 348 | Msg("HandleGameUpdates") 349 | } 350 | } 351 | 352 | return nil 353 | } 354 | 355 | func sendUserLogoutToServer(sock *net.Socket, userId uint64) { 356 | err := sock.Send(serdes.ClientLogout{userId}) 357 | if err != nil { 358 | log.Warn().Err(err).Msg("Failed to send logout message") 359 | panic(err) 360 | } 361 | log.Printf("SendUserLogoutToServer: %d", userId) 362 | } 363 | -------------------------------------------------------------------------------- /app/server/network.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | "errors" 7 | "sync" 8 | "math" 9 | "math/rand" 10 | 11 | "github.com/rs/zerolog/log" 12 | 13 | "github.com/unitoftime/flow/net" 14 | 15 | "github.com/unitoftime/ecs" 16 | "github.com/unitoftime/flow/phy2" 17 | 18 | "github.com/unitoftime/mmo" 19 | "github.com/unitoftime/mmo/serdes" 20 | "github.com/unitoftime/mmo/stat" 21 | // "github.com/unitoftime/mmo/game" 22 | ) 23 | 24 | // -------------------------------------------------------------------------------- 25 | // - Server 26 | // -------------------------------------------------------------------------------- 27 | type DeleteList struct { 28 | mu sync.RWMutex 29 | list []ecs.Id 30 | } 31 | func NewDeleteList() *DeleteList { 32 | return &DeleteList{ 33 | list: make([]ecs.Id, 0), 34 | } 35 | } 36 | 37 | func (d *DeleteList) Append(id ecs.Id) { 38 | d.mu.Lock() 39 | d.list = append(d.list, id) 40 | d.mu.Unlock() 41 | } 42 | 43 | func (d *DeleteList) CopyAndClear() []ecs.Id { 44 | d.mu.Lock() 45 | // TODO - Optimization opportunity: You could have a front-buffer and a back-buffer then toggle which one is the write buffer and which is the read buffer. Then you don't have to copy. 46 | dListCopy := make([]ecs.Id, len(d.list)) 47 | copy(dListCopy, d.list) 48 | 49 | d.list = d.list[:0] 50 | d.mu.Unlock() 51 | return dListCopy 52 | } 53 | 54 | var everyOther int 55 | 56 | // var lastTime time.Time 57 | // var lastTime4 time.Time 58 | 59 | var AvgWorldUpdateTime time.Duration 60 | 61 | var lastWorldUpdate time.Time 62 | // var worldUpdateTimes *mmo.RingBuffer 63 | // func init() { 64 | // lastWorldUpdate = time.Now() 65 | // bufLen := 100 66 | // worldUpdateTimes = mmo.NewRingBuffer(bufLen) 67 | // for i := 0; i < bufLen; i++ { 68 | // worldUpdateTimes.Add(4 * mmo.FixedTimeStep) // TODO! - hardcoded 69 | // } 70 | // } 71 | 72 | // This calculates the update to send to all players, finds the proxy associated with them, and sends that update over the wire 73 | func ServerSendUpdate(world *ecs.World, server *Server, deleteList *DeleteList) { 74 | // log.Print("ServerSendUpdate-LastTime: ", time.Since(lastTime)) 75 | // lastTime = time.Now() 76 | everyOther = (everyOther + 1) % mmo.NetworkTickDivider 77 | if everyOther != 0 { 78 | return // skip 79 | } 80 | // log.Print("ServerSendUpdate-LastTime4: ", time.Since(lastTime4)) 81 | // lastTime4 = time.Now() 82 | 83 | 84 | dListCopy := deleteList.CopyAndClear() 85 | 86 | // Just delete everything that is gone 87 | for _, id := range dListCopy { 88 | ecs.Delete(world, id) 89 | } 90 | 91 | // Build the world update 92 | update := serdes.WorldUpdate{ 93 | Tick: server.tick, 94 | UserId: 0, 95 | WorldData: make(map[ecs.Id][]ecs.Component), 96 | Delete: dListCopy, 97 | } 98 | 99 | //Increment server tick 100 | server.tick = (server.tick + 1) % math.MaxUint16 101 | 102 | // TODO - [optional ecs feature] speech should be optional!!!! 103 | // TODO - When you do SOI code, and generate messages on a per player basis. You should also not include the speech bubble that the player just sent. 104 | // Add relevant data to the world update 105 | { 106 | ecs.Map4(world, func(id ecs.Id, pos *phy2.Pos, body *mmo.Body, speech *mmo.Speech, input *mmo.Input) { 107 | compList := []ecs.Component{ 108 | ecs.C(*pos), 109 | ecs.C(*body), 110 | ecs.C(*input), 111 | } 112 | 113 | if speech.HandleSent() { 114 | compList = append(compList, ecs.C(*speech)) 115 | } 116 | update.WorldData[id] = compList 117 | }) 118 | } 119 | 120 | // Send world update to all users 121 | { 122 | ecs.Map2(world, func(id ecs.Id, user *User, clientTick *ClientTick) { 123 | update.UserId = user.Id // Specify the user we want to send the update to 124 | // log.Println("ServerSendUpdate WorldUpdate:", update) 125 | 126 | proxy, ok := server.GetProxy(user.ProxyId) 127 | if !ok { 128 | log.Print("Missing Proxy for user!") 129 | // This means that the proxy was disconnected 130 | deleteList.Append(id) // This deletes the user (ie they logged out) 131 | return 132 | } 133 | 134 | // Set the player's update tick so they can synchronize 135 | update.PlayerTick = clientTick.Tick 136 | 137 | // log.Printf("SendUpdate", update) 138 | err := proxy.Send(update) 139 | if err != nil { 140 | log.Warn().Err(err).Msg("ServerSendUpdate") 141 | return 142 | } 143 | }) 144 | } 145 | 146 | // { 147 | // worldUpdateTimes.Add(time.Since(lastWorldUpdate)) 148 | // lastWorldUpdate = time.Now() 149 | // buf := worldUpdateTimes.Buffer() 150 | // AvgWorldUpdateTime = 0 151 | // for i := range buf { 152 | // AvgWorldUpdateTime += buf[i] 153 | // } 154 | // AvgWorldUpdateTime = AvgWorldUpdateTime / time.Duration(len(buf)) 155 | // log.Print("Server-AvgWorldUpdateTime: ", AvgWorldUpdateTime) 156 | // } 157 | } 158 | 159 | func ServeProxyConnection(serverConn *ServerConn, world *ecs.World, networkChannel chan serdes.WorldUpdate, deleteList *DeleteList) error { 160 | log.Print("Server: ServeProxyConnection") 161 | 162 | // Read data 163 | for { 164 | msg, err := serverConn.Recv() 165 | if errors.Is(err, net.ErrNetwork) { 166 | // Handle errors where we should stop (ie connection closed or something) 167 | log.Warn().Err(err).Msg("ServeProxyConnection NetworkErr") 168 | return err 169 | } else if errors.Is(err, net.ErrSerdes) { 170 | // Handle errors where we should continue (ie serialization) 171 | log.Error().Err(err).Msg("ServeProxyConnection SerdesErr") 172 | continue 173 | } 174 | if msg == nil { continue } 175 | 176 | // Interpret different messages 177 | switch t := msg.(type) { 178 | case serdes.WorldUpdate: 179 | id, ok := serverConn.GetUser(t.UserId) 180 | if !ok { 181 | log.Error().Uint64(stat.UserId, t.UserId). 182 | Msg("Proxy sent update for user that we don't have on the server") 183 | // Skip: We can't find the user 184 | continue 185 | } 186 | 187 | // TODO - requires client to put their input on spot 0. You probably want to change the message serialization type to just send one piece of entity data over. 188 | componentList := t.WorldData[id] 189 | if len(componentList) <= 0 { break } // Exit if no content 190 | 191 | compSlice := make([]ecs.Component, 0) 192 | // TODO - these should be in a loop. can't guarantee each component slot 193 | inputBox, ok := componentList[0].(ecs.CompBox[mmo.Input]) 194 | if !ok { continue } 195 | input := inputBox.Get() 196 | compSlice = append(compSlice, ecs.C(input)) 197 | 198 | if len(componentList) > 1 { 199 | speechBox, ok := componentList[1].(ecs.CompBox[mmo.Speech]) 200 | if !ok { continue } 201 | speech := speechBox.Get() 202 | compSlice = append(compSlice, ecs.C(speech)) 203 | } 204 | 205 | // We just send this field back to the player, we don't use it internally. This is for them to syncrhonize their client prediction. 206 | compSlice = append(compSlice, ecs.C(ClientTick{ 207 | Tick: t.PlayerTick, 208 | })) 209 | 210 | trustedUpdate := serdes.WorldUpdate{ 211 | WorldData: map[ecs.Id][]ecs.Component{ 212 | id: compSlice, 213 | }, 214 | // Messages: t.Messages, 215 | } 216 | // log.Print("TrustedUpdate:", trustedUpdate) 217 | 218 | networkChannel <- trustedUpdate 219 | 220 | case serdes.ClientLogin: 221 | log.Print("Server: serdes.ClientLogin") 222 | // Login player 223 | // TODO! - not threadsafe 224 | id := world.NewId() 225 | 226 | // TODO - hardcoded here and in client.go - Centralize character creation 227 | collider := phy2.NewCircleCollider(6) 228 | collider.Layer = mmo.BodyLayer 229 | collider.HitLayer = mmo.BodyLayer 230 | trustedLogin := serdes.WorldUpdate{ 231 | WorldData: map[ecs.Id][]ecs.Component{ 232 | id: []ecs.Component{ 233 | ecs.C(User{ 234 | Id: t.UserId, 235 | ProxyId: serverConn.proxyId, 236 | }), 237 | ecs.C(mmo.Input{}), 238 | ecs.C(mmo.Body{uint32(rand.Intn(mmo.NumBodyTypes))}), 239 | ecs.C(mmo.Speech{}), 240 | ecs.C(mmo.SpawnPoint()), 241 | ecs.C(collider), 242 | ecs.C(phy2.NewColliderCache()), 243 | }, 244 | }, 245 | } 246 | networkChannel <- trustedLogin 247 | 248 | serverConn.LoginUser(t.UserId, id) 249 | 250 | resp := serdes.ClientLoginResp{t.UserId, id} 251 | err := serverConn.Send(resp) 252 | if err != nil { 253 | log.Warn().Err(err).Msg(fmt.Sprintf("Failed to send: %v", resp)) 254 | } 255 | case serdes.ClientLogout: 256 | log.Printf("serdes.ClientLogout: %d", t.UserId) 257 | id, ok := serverConn.GetUser(t.UserId) 258 | if !ok { 259 | // Skip: User already logged out 260 | log.Printf("User already logged out: %d", t.UserId) 261 | continue 262 | } 263 | trustedLogout := serdes.WorldUpdate{ 264 | UserId: t.UserId, 265 | Delete: []ecs.Id{id}, 266 | } 267 | networkChannel <- trustedLogout 268 | 269 | serverConn.LogoutUser(t.UserId) 270 | 271 | deleteList.Append(id) 272 | 273 | resp := serdes.ClientLogoutResp{t.UserId, id} 274 | err := serverConn.Send(resp) 275 | if err != nil { 276 | log.Print("Failed to send", resp) 277 | } 278 | default: 279 | log.Error().Msg("Unknown message type") 280 | } 281 | } 282 | } 283 | 284 | 285 | //-------------------------------------------------------------------------------- 286 | type ServerConn struct { 287 | sock *net.Socket 288 | 289 | mu sync.RWMutex 290 | proxyId uint64 291 | loginMap map[uint64]ecs.Id 292 | } 293 | 294 | func (c *ServerConn) Send(msg any) error { 295 | return c.sock.Send(msg) 296 | } 297 | 298 | func (c *ServerConn) Recv() (any, error) { 299 | return c.sock.Recv() 300 | } 301 | 302 | func (c *ServerConn) LoginUser(userId uint64, ecsId ecs.Id) { 303 | c.mu.Lock() 304 | defer c.mu.Unlock() 305 | c.loginMap[userId] = ecsId 306 | } 307 | 308 | func (c *ServerConn) LogoutUser(userId uint64) { 309 | c.mu.Lock() 310 | defer c.mu.Unlock() 311 | delete(c.loginMap, userId) 312 | } 313 | 314 | func (c *ServerConn) GetUser(userId uint64) (ecs.Id, bool) { 315 | c.mu.RLock() 316 | defer c.mu.RUnlock() 317 | ret, ok := c.loginMap[userId] 318 | return ret, ok 319 | } 320 | 321 | // TODO - add more stats 322 | func (c *ServerConn) GetStats() int { 323 | c.mu.RLock() 324 | defer c.mu.RUnlock() 325 | ret := len(c.loginMap) 326 | return ret 327 | } 328 | 329 | type Server struct { 330 | listener net.Listener 331 | handler func(*ServerConn) error 332 | 333 | tick uint16 334 | 335 | connectionsMut sync.RWMutex // Sync for connections map 336 | connections map[uint64]*ServerConn // A map of proxyIds to Proxy connections 337 | } 338 | 339 | func NewServer(listener net.Listener, handler func(*ServerConn) error) *Server { 340 | server := Server{ 341 | listener: listener, 342 | connections: make(map[uint64]*ServerConn), 343 | handler: handler, 344 | } 345 | return &server 346 | } 347 | 348 | 349 | func (s *Server) Start() { 350 | // Debug: Print out server stats 351 | go func() { 352 | for { 353 | time.Sleep(10 * time.Second) 354 | s.connectionsMut.RLock() 355 | for proxyId, proxyConn := range s.connections { 356 | numActive := proxyConn.GetStats() 357 | log.Print(fmt.Sprintf("Proxy %d - %d active users", proxyId, numActive)) 358 | } 359 | s.connectionsMut.RUnlock() 360 | } 361 | }() 362 | 363 | counter := uint64(0) 364 | for { 365 | // Wait for a connection. 366 | sock, err := s.listener.Accept() 367 | if err != nil { 368 | log.Warn().Err(err).Msg("Failed to accept connection") 369 | continue 370 | } 371 | 372 | proxyId := counter 373 | serverConn := &ServerConn{ 374 | sock: sock, 375 | proxyId: proxyId, 376 | loginMap: make(map[uint64]ecs.Id), 377 | } 378 | 379 | s.AddProxy(proxyId, serverConn) 380 | 381 | counter++ 382 | go func() { 383 | err := s.handler(serverConn) 384 | if err != nil { 385 | log.Warn().Err(err).Msg("Server Handler finished") 386 | } 387 | 388 | // Once the handler exits remove the proxy 389 | s.RemoveProxy(proxyId) 390 | }() 391 | } 392 | } 393 | 394 | func (s *Server) GetProxy(proxyId uint64) (*ServerConn, bool) { 395 | s.connectionsMut.RLock() 396 | defer s.connectionsMut.RUnlock() 397 | 398 | c, ok := s.connections[proxyId] 399 | return c, ok 400 | } 401 | 402 | func (s *Server) AddProxy(proxyId uint64, conn *ServerConn) { 403 | s.connectionsMut.Lock() 404 | defer s.connectionsMut.Unlock() 405 | s.connections[proxyId] = conn 406 | } 407 | 408 | func (s *Server) RemoveProxy(proxyId uint64) { 409 | s.connectionsMut.Lock() 410 | defer s.connectionsMut.Unlock() 411 | delete(s.connections, proxyId) 412 | } 413 | 414 | 415 | //-------------------------------------------------------------------------------- 416 | // - Handle Capturing data from network 417 | //-------------------------------------------------------------------------------- 418 | 419 | // type LastUpdate struct { 420 | // Time time.Time 421 | // } 422 | 423 | // type EcsUpdate struct { 424 | // WorldData map[ecs.Id][]ecs.Component 425 | // Delete []ecs.Id 426 | // } 427 | 428 | // TODO - this kindof represents a greater pattern of trying to apply commands to the world in a threadsafe manner. Maybe integrate this into the ECS library: https://docs.rs/bevy/0.4.0/bevy/ecs/trait.Command.html 429 | func CreatePollNetworkSystem(world *ecs.World, networkChannel chan serdes.WorldUpdate) ecs.System { 430 | sys := ecs.System{"PollNetworkChannel", func(dt time.Duration) { 431 | 432 | MainLoop: 433 | for { 434 | select { 435 | case update := <-networkChannel: 436 | for id, compList := range update.WorldData { 437 | // compList = append(compList, ecs.C(LastUpdate{time.Now()})) 438 | ecs.Write(world, id, compList...) 439 | } 440 | 441 | // Delete all the entities in the deleteList 442 | if update.Delete != nil { 443 | for _, id := range update.Delete { 444 | ecs.Delete(world, id) 445 | } 446 | } 447 | 448 | default: 449 | break MainLoop 450 | } 451 | } 452 | }} 453 | 454 | return sys 455 | } 456 | 457 | -------------------------------------------------------------------------------- /app/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "os" 5 | "os/signal" 6 | 7 | "github.com/rs/zerolog" 8 | "github.com/rs/zerolog/log" 9 | 10 | "github.com/unitoftime/ecs" 11 | "github.com/unitoftime/flow/net" 12 | 13 | "github.com/unitoftime/mmo" 14 | "github.com/unitoftime/mmo/serdes" 15 | ) 16 | 17 | func Main() { 18 | zerolog.TimeFieldFormat = zerolog.TimeFormatUnix 19 | log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) 20 | 21 | // log.SetFlags(log.LstdFlags | log.Lshortfile) 22 | 23 | // Load Game 24 | world := ecs.NewWorld() 25 | tilemap := mmo.LoadGame(world) 26 | 27 | // This is the list of entities to get deleted 28 | deleteList := NewDeleteList() 29 | 30 | // TODO - make configurable 31 | networkChannel := make(chan serdes.WorldUpdate, 1024) 32 | 33 | // Start the networking layer 34 | url := "tcp://127.0.0.1:9000" 35 | log.Print("Starting Server", url) 36 | serverNet := net.Config{ 37 | Url: url, 38 | Serdes: serdes.New(), 39 | } 40 | listener, err := serverNet.Listen() 41 | if err != nil { 42 | panic(err) 43 | } 44 | 45 | server := NewServer(listener, func(conn *ServerConn) error { 46 | return ServeProxyConnection(conn, world, networkChannel, deleteList) 47 | }) 48 | 49 | serverSystems := CreateServerSystems(world, server, networkChannel, deleteList, tilemap) 50 | 51 | quit := ecs.Signal{} 52 | quit.Set(false) 53 | 54 | schedule := mmo.GetScheduler() 55 | schedule.AppendPhysics(serverSystems...) 56 | go schedule.Run(&quit) 57 | 58 | // go ecs.RunGameFixed(serverSystems, &quit) 59 | 60 | go server.Start() 61 | 62 | sigs := make(chan os.Signal, 1) 63 | signal.Notify(sigs, os.Interrupt) 64 | select{ 65 | case sig := <-sigs: 66 | log.Print("Terminating:", sig) 67 | } 68 | quit.Set(true) 69 | } 70 | -------------------------------------------------------------------------------- /app/server/sys.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/unitoftime/ecs" 7 | "github.com/unitoftime/flow/tile" 8 | "github.com/unitoftime/flow/phy2" 9 | 10 | "github.com/unitoftime/mmo" 11 | "github.com/unitoftime/mmo/serdes" 12 | ) 13 | 14 | // Represents a logged in user on the server 15 | type User struct { 16 | // Name string // TODO - remove and put into a component called "DisplayName" 17 | Id uint64 18 | ProxyId uint64 19 | } 20 | 21 | // This is the tick that the client says they are on 22 | type ClientTick struct { 23 | Tick uint16 // This is the tick that the player is currently on 24 | } 25 | 26 | func CreateServerSystems(world *ecs.World, server *Server, networkChannel chan serdes.WorldUpdate, deleteList *DeleteList, tilemap *tile.Tilemap) []ecs.System { 27 | serverSystems := []ecs.System{ 28 | CreatePollNetworkSystem(world, networkChannel), 29 | } 30 | 31 | // serverSystems = append(serverSystems, 32 | // CreatePhysicsSystems(world)...) 33 | serverSystems = append(serverSystems, 34 | ecs.System{"MoveCharacters", func(dt time.Duration) { 35 | ecs.Map3(world, func(id ecs.Id, input *mmo.Input, pos *phy2.Pos, collider *phy2.CircleCollider) { 36 | mmo.MoveCharacter(input, pos, collider, tilemap, dt) 37 | }) 38 | }}, 39 | ecs.System{"CheckCollisions", func(dt time.Duration) { 40 | // Set the collider position 41 | ecs.Map2(world, func(id ecs.Id, pos *phy2.Pos, col *phy2.CircleCollider) { 42 | col.CenterX = pos.X 43 | col.CenterY = pos.Y 44 | }) 45 | 46 | mmo.CheckCollisions(world) 47 | }}, 48 | ) 49 | 50 | serverSystems = append(serverSystems, []ecs.System{ 51 | ecs.System{"ServerSendUpdate", func(dt time.Duration) { 52 | ServerSendUpdate(world, server, deleteList) 53 | }}, 54 | }...) 55 | 56 | return serverSystems 57 | } 58 | -------------------------------------------------------------------------------- /cmd/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all client proxy server 2 | 3 | all: client proxy server 4 | mkdir -p build 5 | 6 | server: 7 | CGO_ENABLED=0 go build -o build/server ./server/ 8 | 9 | proxy: build/keygen 10 | CGO_ENABLED=0 go build -o build/proxy ./proxy/ 11 | 12 | build/keygen: 13 | openssl req -newkey rsa:2048 -x509 -nodes -days 365 -keyout build/privkey.pem -out build/cert.pem -config <(cat /etc/ssl/openssl.cnf <(printf '[SAN]\nsubjectAltName=DNS:localhost,IP:127.0.0.1')) 14 | # openssl req -newkey rsa:2048 -x509 -nodes -days 365 -keyout build/nginx-proxy.key -out build/nginx-proxy.crt -config <(cat /etc/ssl/openssl.cnf <(printf '[SAN]\nsubjectAltName=DNS:hostname,IP:127.0.0.1')) 15 | openssl dhparam -out build/dhparam.pem 2048 16 | touch build/keygen # This is just to make sure that it doesn't re-execute 17 | 18 | client: 19 | cd client && $(MAKE) wasm 20 | cp client/mmo.wasm build/mmo.wasm 21 | cp client/index.html build/ 22 | cp "$(shell go env GOROOT)/misc/wasm/wasm_exec.js" build/ 23 | 24 | lag-international: 25 | tc qdisc add dev lo root netem delay 50ms 40ms 5% 26 | lag-local: 27 | tc qdisc add dev lo root netem delay 25ms 5ms 25% 28 | lag-remove: 29 | tc qdisc del dev lo root netem 30 | 31 | #https://askubuntu.com/questions/444124/how-to-add-a-loopback-interface 32 | # IMPAIR=delay 25ms 5ms 25% loss 1% 5% duplicate 1% corrupt 0.1% reorder 1% 50% 33 | # lag-setup: 34 | # # route add -host 127.0.0.2 dev lo 35 | # tc qdisc add dev lo root handle 1: prio 36 | # tc filter add dev lo protocol ip parent 1:prio 10 u32 match ip protocol 17 oxff flowid 1:1 37 | # tc filter add dev lo protocol ip parent 1:prio 10 u32 match ip protocol 17 oxff flowid 1:2 38 | # tc filter add dev lo protocol ip parent 1:prio 10 u32 match ip protocol 17 oxff flowid 1:3 39 | # tc qdisc add dev lo parent 1:1 netem ${IMPAIR} 40 | # tc qdisc add dev lo parent 1:2 netem ${IMPAIR} 41 | # tc qdisc add dev lo parent 1:3 netem ${IMPAIR} 42 | 43 | # lag-international-single: 44 | # tc qdisc add dev lo root netem delay 200ms 40ms 25% loss 15.3% 25% duplicate 1% corrupt 0.1% reorder 5% 50% 45 | # lag-international: 46 | # tc qdisc add dev lo root netem delay 50ms 40ms 5% loss 2.5% 2.5% duplicate .5% corrupt 0.05% reorder 2% 5% 47 | # lag-local: 48 | # tc qdisc add dev lo root netem delay 25ms 5ms 25% loss 1% 5% duplicate 1% corrupt 0.1% reorder 1% 50% 49 | # lag-good: 50 | # tc qdisc add dev lo root netem delay 25ms 5ms 25% 51 | # lag-remove: 52 | # tc qdisc del dev lo root netem 53 | -------------------------------------------------------------------------------- /cmd/client/Makefile: -------------------------------------------------------------------------------- 1 | linux: 2 | go build -ldflags "-s" -v . 3 | 4 | wasm: 5 | # cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" . 6 | GOOS=js GOARCH=wasm go build -ldflags "-s" -o mmo.wasm 7 | 8 | windows: 9 | GOOS=windows GOARCH=386 CGO_ENABLED=1 CXX=i686-w64-mingw32-g++ CC=i686-w64-mingw32-gcc go build -ldflags "-s" -v . 10 | -------------------------------------------------------------------------------- /cmd/client/index.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |