├── 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 | MMO Client 5 | 6 | 14 | 15 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /cmd/client/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "fmt" 6 | "runtime" 7 | "runtime/pprof" 8 | "flag" 9 | 10 | "github.com/unitoftime/mmo/app/client" 11 | ) 12 | 13 | var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to `file`") 14 | var memprofile = flag.String("memprofile", "", "write memory profile to `file`") 15 | 16 | func main() { 17 | if *cpuprofile != "" { 18 | f, err := os.Create(*cpuprofile) 19 | if err != nil { 20 | panic(fmt.Sprintf("could not create CPU profile: %v", err)) 21 | } 22 | defer f.Close() // error handling omitted for example 23 | if err := pprof.StartCPUProfile(f); err != nil { 24 | panic(fmt.Sprintf("could not start CPU profile: %v", err)) 25 | } 26 | defer pprof.StopCPUProfile() 27 | } 28 | 29 | // TODO - catch panics, exits and finish exporting mem and cpu prof 30 | client.Main(client.Config{ 31 | ProxyUri: "webrtc://localhost:7777", 32 | // ProxyUri: "wss://localhost:7777", 33 | // ProxyUri: "wss://mmo.unit.dev:443", 34 | Test: true, 35 | }) 36 | 37 | if *memprofile != "" { 38 | f, err := os.Create(*memprofile) 39 | if err != nil { 40 | panic(fmt.Sprintf("could not create memory profile: %v", err)) 41 | } 42 | defer f.Close() // error handling omitted for example 43 | runtime.GC() // get up-to-date statistics 44 | if err := pprof.WriteHeapProfile(f); err != nil { 45 | panic(fmt.Sprintf("could not write memory profile: %v", err)) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /cmd/client/wasm_exec.js: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | "use strict"; 6 | 7 | (() => { 8 | const enosys = () => { 9 | const err = new Error("not implemented"); 10 | err.code = "ENOSYS"; 11 | return err; 12 | }; 13 | 14 | if (!globalThis.fs) { 15 | let outputBuf = ""; 16 | globalThis.fs = { 17 | constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused 18 | writeSync(fd, buf) { 19 | outputBuf += decoder.decode(buf); 20 | const nl = outputBuf.lastIndexOf("\n"); 21 | if (nl != -1) { 22 | console.log(outputBuf.substr(0, nl)); 23 | outputBuf = outputBuf.substr(nl + 1); 24 | } 25 | return buf.length; 26 | }, 27 | write(fd, buf, offset, length, position, callback) { 28 | if (offset !== 0 || length !== buf.length || position !== null) { 29 | callback(enosys()); 30 | return; 31 | } 32 | const n = this.writeSync(fd, buf); 33 | callback(null, n); 34 | }, 35 | chmod(path, mode, callback) { callback(enosys()); }, 36 | chown(path, uid, gid, callback) { callback(enosys()); }, 37 | close(fd, callback) { callback(enosys()); }, 38 | fchmod(fd, mode, callback) { callback(enosys()); }, 39 | fchown(fd, uid, gid, callback) { callback(enosys()); }, 40 | fstat(fd, callback) { callback(enosys()); }, 41 | fsync(fd, callback) { callback(null); }, 42 | ftruncate(fd, length, callback) { callback(enosys()); }, 43 | lchown(path, uid, gid, callback) { callback(enosys()); }, 44 | link(path, link, callback) { callback(enosys()); }, 45 | lstat(path, callback) { callback(enosys()); }, 46 | mkdir(path, perm, callback) { callback(enosys()); }, 47 | open(path, flags, mode, callback) { callback(enosys()); }, 48 | read(fd, buffer, offset, length, position, callback) { callback(enosys()); }, 49 | readdir(path, callback) { callback(enosys()); }, 50 | readlink(path, callback) { callback(enosys()); }, 51 | rename(from, to, callback) { callback(enosys()); }, 52 | rmdir(path, callback) { callback(enosys()); }, 53 | stat(path, callback) { callback(enosys()); }, 54 | symlink(path, link, callback) { callback(enosys()); }, 55 | truncate(path, length, callback) { callback(enosys()); }, 56 | unlink(path, callback) { callback(enosys()); }, 57 | utimes(path, atime, mtime, callback) { callback(enosys()); }, 58 | }; 59 | } 60 | 61 | if (!globalThis.process) { 62 | globalThis.process = { 63 | getuid() { return -1; }, 64 | getgid() { return -1; }, 65 | geteuid() { return -1; }, 66 | getegid() { return -1; }, 67 | getgroups() { throw enosys(); }, 68 | pid: -1, 69 | ppid: -1, 70 | umask() { throw enosys(); }, 71 | cwd() { throw enosys(); }, 72 | chdir() { throw enosys(); }, 73 | } 74 | } 75 | 76 | if (!globalThis.crypto) { 77 | throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)"); 78 | } 79 | 80 | if (!globalThis.performance) { 81 | throw new Error("globalThis.performance is not available, polyfill required (performance.now only)"); 82 | } 83 | 84 | if (!globalThis.TextEncoder) { 85 | throw new Error("globalThis.TextEncoder is not available, polyfill required"); 86 | } 87 | 88 | if (!globalThis.TextDecoder) { 89 | throw new Error("globalThis.TextDecoder is not available, polyfill required"); 90 | } 91 | 92 | const encoder = new TextEncoder("utf-8"); 93 | const decoder = new TextDecoder("utf-8"); 94 | 95 | globalThis.Go = class { 96 | constructor() { 97 | this.argv = ["js"]; 98 | this.env = {}; 99 | this.exit = (code) => { 100 | if (code !== 0) { 101 | console.warn("exit code:", code); 102 | } 103 | }; 104 | this._exitPromise = new Promise((resolve) => { 105 | this._resolveExitPromise = resolve; 106 | }); 107 | this._pendingEvent = null; 108 | this._scheduledTimeouts = new Map(); 109 | this._nextCallbackTimeoutID = 1; 110 | 111 | const setInt64 = (addr, v) => { 112 | this.mem.setUint32(addr + 0, v, true); 113 | this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true); 114 | } 115 | 116 | const getInt64 = (addr) => { 117 | const low = this.mem.getUint32(addr + 0, true); 118 | const high = this.mem.getInt32(addr + 4, true); 119 | return low + high * 4294967296; 120 | } 121 | 122 | const loadValue = (addr) => { 123 | const f = this.mem.getFloat64(addr, true); 124 | if (f === 0) { 125 | return undefined; 126 | } 127 | if (!isNaN(f)) { 128 | return f; 129 | } 130 | 131 | const id = this.mem.getUint32(addr, true); 132 | return this._values[id]; 133 | } 134 | 135 | const storeValue = (addr, v) => { 136 | const nanHead = 0x7FF80000; 137 | 138 | if (typeof v === "number" && v !== 0) { 139 | if (isNaN(v)) { 140 | this.mem.setUint32(addr + 4, nanHead, true); 141 | this.mem.setUint32(addr, 0, true); 142 | return; 143 | } 144 | this.mem.setFloat64(addr, v, true); 145 | return; 146 | } 147 | 148 | if (v === undefined) { 149 | this.mem.setFloat64(addr, 0, true); 150 | return; 151 | } 152 | 153 | let id = this._ids.get(v); 154 | if (id === undefined) { 155 | id = this._idPool.pop(); 156 | if (id === undefined) { 157 | id = this._values.length; 158 | } 159 | this._values[id] = v; 160 | this._goRefCounts[id] = 0; 161 | this._ids.set(v, id); 162 | } 163 | this._goRefCounts[id]++; 164 | let typeFlag = 0; 165 | switch (typeof v) { 166 | case "object": 167 | if (v !== null) { 168 | typeFlag = 1; 169 | } 170 | break; 171 | case "string": 172 | typeFlag = 2; 173 | break; 174 | case "symbol": 175 | typeFlag = 3; 176 | break; 177 | case "function": 178 | typeFlag = 4; 179 | break; 180 | } 181 | this.mem.setUint32(addr + 4, nanHead | typeFlag, true); 182 | this.mem.setUint32(addr, id, true); 183 | } 184 | 185 | const loadSlice = (addr) => { 186 | const array = getInt64(addr + 0); 187 | const len = getInt64(addr + 8); 188 | return new Uint8Array(this._inst.exports.mem.buffer, array, len); 189 | } 190 | 191 | const loadSliceOfValues = (addr) => { 192 | const array = getInt64(addr + 0); 193 | const len = getInt64(addr + 8); 194 | const a = new Array(len); 195 | for (let i = 0; i < len; i++) { 196 | a[i] = loadValue(array + i * 8); 197 | } 198 | return a; 199 | } 200 | 201 | const loadString = (addr) => { 202 | const saddr = getInt64(addr + 0); 203 | const len = getInt64(addr + 8); 204 | return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len)); 205 | } 206 | 207 | const timeOrigin = Date.now() - performance.now(); 208 | this.importObject = { 209 | go: { 210 | // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) 211 | // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported 212 | // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). 213 | // This changes the SP, thus we have to update the SP used by the imported function. 214 | 215 | // func wasmExit(code int32) 216 | "runtime.wasmExit": (sp) => { 217 | sp >>>= 0; 218 | const code = this.mem.getInt32(sp + 8, true); 219 | this.exited = true; 220 | delete this._inst; 221 | delete this._values; 222 | delete this._goRefCounts; 223 | delete this._ids; 224 | delete this._idPool; 225 | this.exit(code); 226 | }, 227 | 228 | // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) 229 | "runtime.wasmWrite": (sp) => { 230 | sp >>>= 0; 231 | const fd = getInt64(sp + 8); 232 | const p = getInt64(sp + 16); 233 | const n = this.mem.getInt32(sp + 24, true); 234 | fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); 235 | }, 236 | 237 | // func resetMemoryDataView() 238 | "runtime.resetMemoryDataView": (sp) => { 239 | sp >>>= 0; 240 | this.mem = new DataView(this._inst.exports.mem.buffer); 241 | }, 242 | 243 | // func nanotime1() int64 244 | "runtime.nanotime1": (sp) => { 245 | sp >>>= 0; 246 | setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); 247 | }, 248 | 249 | // func walltime() (sec int64, nsec int32) 250 | "runtime.walltime": (sp) => { 251 | sp >>>= 0; 252 | const msec = (new Date).getTime(); 253 | setInt64(sp + 8, msec / 1000); 254 | this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true); 255 | }, 256 | 257 | // func scheduleTimeoutEvent(delay int64) int32 258 | "runtime.scheduleTimeoutEvent": (sp) => { 259 | sp >>>= 0; 260 | const id = this._nextCallbackTimeoutID; 261 | this._nextCallbackTimeoutID++; 262 | this._scheduledTimeouts.set(id, setTimeout( 263 | () => { 264 | this._resume(); 265 | while (this._scheduledTimeouts.has(id)) { 266 | // for some reason Go failed to register the timeout event, log and try again 267 | // (temporary workaround for https://github.com/golang/go/issues/28975) 268 | console.warn("scheduleTimeoutEvent: missed timeout event"); 269 | this._resume(); 270 | } 271 | }, 272 | getInt64(sp + 8) + 1, // setTimeout has been seen to fire up to 1 millisecond early 273 | )); 274 | this.mem.setInt32(sp + 16, id, true); 275 | }, 276 | 277 | // func clearTimeoutEvent(id int32) 278 | "runtime.clearTimeoutEvent": (sp) => { 279 | sp >>>= 0; 280 | const id = this.mem.getInt32(sp + 8, true); 281 | clearTimeout(this._scheduledTimeouts.get(id)); 282 | this._scheduledTimeouts.delete(id); 283 | }, 284 | 285 | // func getRandomData(r []byte) 286 | "runtime.getRandomData": (sp) => { 287 | sp >>>= 0; 288 | crypto.getRandomValues(loadSlice(sp + 8)); 289 | }, 290 | 291 | // func finalizeRef(v ref) 292 | "syscall/js.finalizeRef": (sp) => { 293 | sp >>>= 0; 294 | const id = this.mem.getUint32(sp + 8, true); 295 | this._goRefCounts[id]--; 296 | if (this._goRefCounts[id] === 0) { 297 | const v = this._values[id]; 298 | this._values[id] = null; 299 | this._ids.delete(v); 300 | this._idPool.push(id); 301 | } 302 | }, 303 | 304 | // func stringVal(value string) ref 305 | "syscall/js.stringVal": (sp) => { 306 | sp >>>= 0; 307 | storeValue(sp + 24, loadString(sp + 8)); 308 | }, 309 | 310 | // func valueGet(v ref, p string) ref 311 | "syscall/js.valueGet": (sp) => { 312 | sp >>>= 0; 313 | const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); 314 | sp = this._inst.exports.getsp() >>> 0; // see comment above 315 | storeValue(sp + 32, result); 316 | }, 317 | 318 | // func valueSet(v ref, p string, x ref) 319 | "syscall/js.valueSet": (sp) => { 320 | sp >>>= 0; 321 | Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); 322 | }, 323 | 324 | // func valueDelete(v ref, p string) 325 | "syscall/js.valueDelete": (sp) => { 326 | sp >>>= 0; 327 | Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16)); 328 | }, 329 | 330 | // func valueIndex(v ref, i int) ref 331 | "syscall/js.valueIndex": (sp) => { 332 | sp >>>= 0; 333 | storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); 334 | }, 335 | 336 | // valueSetIndex(v ref, i int, x ref) 337 | "syscall/js.valueSetIndex": (sp) => { 338 | sp >>>= 0; 339 | Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24)); 340 | }, 341 | 342 | // func valueCall(v ref, m string, args []ref) (ref, bool) 343 | "syscall/js.valueCall": (sp) => { 344 | sp >>>= 0; 345 | try { 346 | const v = loadValue(sp + 8); 347 | const m = Reflect.get(v, loadString(sp + 16)); 348 | const args = loadSliceOfValues(sp + 32); 349 | const result = Reflect.apply(m, v, args); 350 | sp = this._inst.exports.getsp() >>> 0; // see comment above 351 | storeValue(sp + 56, result); 352 | this.mem.setUint8(sp + 64, 1); 353 | } catch (err) { 354 | sp = this._inst.exports.getsp() >>> 0; // see comment above 355 | storeValue(sp + 56, err); 356 | this.mem.setUint8(sp + 64, 0); 357 | } 358 | }, 359 | 360 | // func valueInvoke(v ref, args []ref) (ref, bool) 361 | "syscall/js.valueInvoke": (sp) => { 362 | sp >>>= 0; 363 | try { 364 | const v = loadValue(sp + 8); 365 | const args = loadSliceOfValues(sp + 16); 366 | const result = Reflect.apply(v, undefined, args); 367 | sp = this._inst.exports.getsp() >>> 0; // see comment above 368 | storeValue(sp + 40, result); 369 | this.mem.setUint8(sp + 48, 1); 370 | } catch (err) { 371 | sp = this._inst.exports.getsp() >>> 0; // see comment above 372 | storeValue(sp + 40, err); 373 | this.mem.setUint8(sp + 48, 0); 374 | } 375 | }, 376 | 377 | // func valueNew(v ref, args []ref) (ref, bool) 378 | "syscall/js.valueNew": (sp) => { 379 | sp >>>= 0; 380 | try { 381 | const v = loadValue(sp + 8); 382 | const args = loadSliceOfValues(sp + 16); 383 | const result = Reflect.construct(v, args); 384 | sp = this._inst.exports.getsp() >>> 0; // see comment above 385 | storeValue(sp + 40, result); 386 | this.mem.setUint8(sp + 48, 1); 387 | } catch (err) { 388 | sp = this._inst.exports.getsp() >>> 0; // see comment above 389 | storeValue(sp + 40, err); 390 | this.mem.setUint8(sp + 48, 0); 391 | } 392 | }, 393 | 394 | // func valueLength(v ref) int 395 | "syscall/js.valueLength": (sp) => { 396 | sp >>>= 0; 397 | setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); 398 | }, 399 | 400 | // valuePrepareString(v ref) (ref, int) 401 | "syscall/js.valuePrepareString": (sp) => { 402 | sp >>>= 0; 403 | const str = encoder.encode(String(loadValue(sp + 8))); 404 | storeValue(sp + 16, str); 405 | setInt64(sp + 24, str.length); 406 | }, 407 | 408 | // valueLoadString(v ref, b []byte) 409 | "syscall/js.valueLoadString": (sp) => { 410 | sp >>>= 0; 411 | const str = loadValue(sp + 8); 412 | loadSlice(sp + 16).set(str); 413 | }, 414 | 415 | // func valueInstanceOf(v ref, t ref) bool 416 | "syscall/js.valueInstanceOf": (sp) => { 417 | sp >>>= 0; 418 | this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0); 419 | }, 420 | 421 | // func copyBytesToGo(dst []byte, src ref) (int, bool) 422 | "syscall/js.copyBytesToGo": (sp) => { 423 | sp >>>= 0; 424 | const dst = loadSlice(sp + 8); 425 | const src = loadValue(sp + 32); 426 | if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) { 427 | this.mem.setUint8(sp + 48, 0); 428 | return; 429 | } 430 | const toCopy = src.subarray(0, dst.length); 431 | dst.set(toCopy); 432 | setInt64(sp + 40, toCopy.length); 433 | this.mem.setUint8(sp + 48, 1); 434 | }, 435 | 436 | // func copyBytesToJS(dst ref, src []byte) (int, bool) 437 | "syscall/js.copyBytesToJS": (sp) => { 438 | sp >>>= 0; 439 | const dst = loadValue(sp + 8); 440 | const src = loadSlice(sp + 16); 441 | if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) { 442 | this.mem.setUint8(sp + 48, 0); 443 | return; 444 | } 445 | const toCopy = src.subarray(0, dst.length); 446 | dst.set(toCopy); 447 | setInt64(sp + 40, toCopy.length); 448 | this.mem.setUint8(sp + 48, 1); 449 | }, 450 | 451 | "debug": (value) => { 452 | console.log(value); 453 | }, 454 | } 455 | }; 456 | } 457 | 458 | async run(instance) { 459 | if (!(instance instanceof WebAssembly.Instance)) { 460 | throw new Error("Go.run: WebAssembly.Instance expected"); 461 | } 462 | this._inst = instance; 463 | this.mem = new DataView(this._inst.exports.mem.buffer); 464 | this._values = [ // JS values that Go currently has references to, indexed by reference id 465 | NaN, 466 | 0, 467 | null, 468 | true, 469 | false, 470 | globalThis, 471 | this, 472 | ]; 473 | this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id 474 | this._ids = new Map([ // mapping from JS values to reference ids 475 | [0, 1], 476 | [null, 2], 477 | [true, 3], 478 | [false, 4], 479 | [globalThis, 5], 480 | [this, 6], 481 | ]); 482 | this._idPool = []; // unused ids that have been garbage collected 483 | this.exited = false; // whether the Go program has exited 484 | 485 | // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. 486 | let offset = 4096; 487 | 488 | const strPtr = (str) => { 489 | const ptr = offset; 490 | const bytes = encoder.encode(str + "\0"); 491 | new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes); 492 | offset += bytes.length; 493 | if (offset % 8 !== 0) { 494 | offset += 8 - (offset % 8); 495 | } 496 | return ptr; 497 | }; 498 | 499 | const argc = this.argv.length; 500 | 501 | const argvPtrs = []; 502 | this.argv.forEach((arg) => { 503 | argvPtrs.push(strPtr(arg)); 504 | }); 505 | argvPtrs.push(0); 506 | 507 | const keys = Object.keys(this.env).sort(); 508 | keys.forEach((key) => { 509 | argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); 510 | }); 511 | argvPtrs.push(0); 512 | 513 | const argv = offset; 514 | argvPtrs.forEach((ptr) => { 515 | this.mem.setUint32(offset, ptr, true); 516 | this.mem.setUint32(offset + 4, 0, true); 517 | offset += 8; 518 | }); 519 | 520 | // The linker guarantees global data starts from at least wasmMinDataAddr. 521 | // Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr. 522 | const wasmMinDataAddr = 4096 + 8192; 523 | if (offset >= wasmMinDataAddr) { 524 | throw new Error("total length of command line and environment variables exceeds limit"); 525 | } 526 | 527 | this._inst.exports.run(argc, argv); 528 | if (this.exited) { 529 | this._resolveExitPromise(); 530 | } 531 | await this._exitPromise; 532 | } 533 | 534 | _resume() { 535 | if (this.exited) { 536 | throw new Error("Go program has already exited"); 537 | } 538 | this._inst.exports.resume(); 539 | if (this.exited) { 540 | this._resolveExitPromise(); 541 | } 542 | } 543 | 544 | _makeFuncWrapper(id) { 545 | const go = this; 546 | return function () { 547 | const event = { id: id, this: this, args: arguments }; 548 | go._pendingEvent = event; 549 | go._resume(); 550 | return event.result; 551 | }; 552 | } 553 | } 554 | })(); 555 | -------------------------------------------------------------------------------- /cmd/proxy/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/unitoftime/mmo/app/proxy" 5 | ) 6 | 7 | func main() { 8 | proxy.Main(proxy.Config{ 9 | ServerUri: "tcp://127.0.0.1:9000", 10 | Test: true, 11 | CertFile: "./build/cert.pem", 12 | KeyFile: "./build/privkey.pem", 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /cmd/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | trap "trap - SIGTERM && kill -- -$$" SIGINT SIGTERM EXIT 4 | set -e 5 | 6 | go run ./server & 7 | sleep 2 8 | go run ./proxy & 9 | #sleep 2 10 | go run ./client --skip & 11 | go run ./client --skip 12 | -------------------------------------------------------------------------------- /cmd/runclient.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | trap "trap - SIGTERM && kill -- -$$" SIGINT SIGTERM EXIT 4 | 5 | cd client 6 | for i in {0..10} 7 | do 8 | echo "NewClient" ${i} 9 | go run . -skip & 10 | done 11 | 12 | go run . -skip 13 | 14 | -------------------------------------------------------------------------------- /cmd/server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/unitoftime/mmo/app/server" 5 | ) 6 | 7 | func main() { 8 | server.Main() 9 | } 10 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/unitoftime/mmo 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/rs/zerolog v1.28.0 7 | github.com/unitoftime/binary v1.0.20 8 | github.com/unitoftime/ecs v0.0.0-20221201205552-1cf0cf60ad4f 9 | github.com/unitoftime/flow v0.0.0-20221206183408-0f16e69e884b 10 | github.com/unitoftime/glitch v0.0.0-20221125145215-98f80d8b228d 11 | github.com/unitoftime/packer v0.0.0-20221103211833-11c7601528ba 12 | github.com/zyedidia/generic v1.2.0 13 | ) 14 | 15 | require ( 16 | github.com/faiface/mainthread v0.0.0-20171120011319-8b78f0a41ae3 // indirect 17 | github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6 // indirect 18 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20221017161538-93cebf72946b // indirect 19 | github.com/go-gl/mathgl v1.0.0 // indirect 20 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect 21 | github.com/google/go-cmp v0.5.9 // indirect 22 | github.com/google/uuid v1.3.0 // indirect 23 | github.com/gopherjs/gopherjs v1.17.2 // indirect 24 | github.com/gorilla/websocket v1.5.0 // indirect 25 | github.com/klauspost/compress v1.15.12 // indirect 26 | github.com/mattn/go-colorable v0.1.13 // indirect 27 | github.com/mattn/go-isatty v0.0.16 // indirect 28 | github.com/ojrac/opensimplex-go v1.0.2 // indirect 29 | github.com/pion/datachannel v1.5.2 // indirect 30 | github.com/pion/dtls/v2 v2.1.5 // indirect 31 | github.com/pion/ice/v2 v2.2.12 // indirect 32 | github.com/pion/interceptor v0.1.12 // indirect 33 | github.com/pion/logging v0.2.2 // indirect 34 | github.com/pion/mdns v0.0.5 // indirect 35 | github.com/pion/randutil v0.1.0 // indirect 36 | github.com/pion/rtcp v1.2.10 // indirect 37 | github.com/pion/rtp v1.7.13 // indirect 38 | github.com/pion/sctp v1.8.3 // indirect 39 | github.com/pion/sdp/v3 v3.0.6 // indirect 40 | github.com/pion/srtp/v2 v2.0.10 // indirect 41 | github.com/pion/stun v0.3.5 // indirect 42 | github.com/pion/transport v0.14.1 // indirect 43 | github.com/pion/turn/v2 v2.0.9 // indirect 44 | github.com/pion/udp v0.1.1 // indirect 45 | github.com/pion/webrtc/v3 v3.1.49 // indirect 46 | github.com/smallnest/goframe v1.0.0 // indirect 47 | github.com/ungerik/go3d v0.0.0-20220309204530-55ced4bcb334 // indirect 48 | github.com/unitoftime/gl v0.0.0-20221010144157-ddeda43df375 // indirect 49 | github.com/unitoftime/glfw v0.0.0-20221109201015-17c636a346cf // indirect 50 | golang.org/x/crypto v0.3.0 // indirect 51 | golang.org/x/image v0.2.0 // indirect 52 | golang.org/x/net v0.3.0 // indirect 53 | golang.org/x/sys v0.3.0 // indirect 54 | google.golang.org/protobuf v1.28.1 // indirect 55 | gopkg.in/yaml.v3 v3.0.1 // indirect 56 | honnef.co/go/js/dom v0.0.0-20221001195520-26252dedbe70 // indirect 57 | nhooyr.io/websocket v1.8.7 // indirect 58 | ) 59 | -------------------------------------------------------------------------------- /mmo.go: -------------------------------------------------------------------------------- 1 | package mmo 2 | 3 | import ( 4 | // "fmt" 5 | "time" 6 | "math" 7 | "regexp" 8 | 9 | "github.com/rs/zerolog/log" 10 | 11 | "github.com/unitoftime/ecs" 12 | "github.com/unitoftime/flow/tile" 13 | "github.com/unitoftime/flow/phy2" 14 | "github.com/unitoftime/flow/pgen" 15 | ) 16 | 17 | type Input struct { 18 | Up, Down, Left, Right bool 19 | } 20 | 21 | // This defines the ratio of physics ticks to network ticks. 22 | // TODO - right now I do a % NetworkTickDivider. It'd be nice to make that more systematic 23 | const NetworkTickDivider = 4 // The number of physics ticks before we send a network update 24 | const ClientInputResendRate = 2 // The number of times the client resends his input to counter packet loss 25 | const ClientDefaultUpdateQueueSize = 2 // TODO - make this dynamic 26 | 27 | const FixedTimeStep time.Duration = 16 * time.Millisecond 28 | 29 | 30 | var seed int64 = 12345 31 | var mapSize int = 100 32 | var tileSize int = 16 33 | 34 | const ( 35 | NoLayer phy2.CollisionLayer = 0 36 | BodyLayer phy2.CollisionLayer = 1 << iota 37 | WallLayer 38 | ) 39 | 40 | func SpawnPoint() phy2.Pos { 41 | spawnPoint := phy2.Pos{ 42 | X: float64(tileSize*mapSize/2), 43 | Y: float64(tileSize*mapSize/2), 44 | } 45 | return spawnPoint 46 | } 47 | 48 | func LoadGame(world *ecs.World) *tile.Tilemap { 49 | // Create Tilemap 50 | tmap := CreateTilemap(seed, mapSize, tileSize) 51 | 52 | walls := []tile.TilePosition{ 53 | // North wall 54 | tile.TilePosition{mapSize/2 + 5, mapSize/2 + 5}, 55 | tile.TilePosition{mapSize/2 + 4, mapSize/2 + 5}, 56 | tile.TilePosition{mapSize/2 + 3, mapSize/2 + 5}, 57 | tile.TilePosition{mapSize/2 + 2, mapSize/2 + 5}, 58 | tile.TilePosition{mapSize/2 + 1, mapSize/2 + 5}, 59 | tile.TilePosition{mapSize/2 + 0, mapSize/2 + 5}, 60 | 61 | tile.TilePosition{mapSize/2 - 5, mapSize/2 + 5}, 62 | tile.TilePosition{mapSize/2 - 4, mapSize/2 + 5}, 63 | tile.TilePosition{mapSize/2 - 3, mapSize/2 + 5}, 64 | tile.TilePosition{mapSize/2 - 2, mapSize/2 + 5}, 65 | tile.TilePosition{mapSize/2 - 1, mapSize/2 + 5}, 66 | 67 | // South wall(ish) 68 | tile.TilePosition{mapSize/2 + 5, mapSize/2 - 5}, 69 | tile.TilePosition{mapSize/2 + 4, mapSize/2 - 5}, 70 | tile.TilePosition{mapSize/2 + 3, mapSize/2 - 5}, 71 | tile.TilePosition{mapSize/2 - 5, mapSize/2 - 5}, 72 | tile.TilePosition{mapSize/2 - 4, mapSize/2 - 5}, 73 | tile.TilePosition{mapSize/2 - 3, mapSize/2 - 5}, 74 | 75 | // East Wall 76 | tile.TilePosition{mapSize/2 + 5, mapSize/2 + 4}, 77 | tile.TilePosition{mapSize/2 + 5, mapSize/2 + 3}, 78 | tile.TilePosition{mapSize/2 + 5, mapSize/2 + 2}, 79 | tile.TilePosition{mapSize/2 + 5, mapSize/2 + 1}, 80 | tile.TilePosition{mapSize/2 + 5, mapSize/2 + 0}, 81 | 82 | tile.TilePosition{mapSize/2 + 5, mapSize/2 - 1}, 83 | tile.TilePosition{mapSize/2 + 5, mapSize/2 - 2}, 84 | tile.TilePosition{mapSize/2 + 5, mapSize/2 - 3}, 85 | tile.TilePosition{mapSize/2 + 5, mapSize/2 - 4}, 86 | tile.TilePosition{mapSize/2 + 5, mapSize/2 - 5}, 87 | 88 | // West Wall 89 | tile.TilePosition{mapSize/2 - 5, mapSize/2 + 4}, 90 | tile.TilePosition{mapSize/2 - 5, mapSize/2 + 3}, 91 | tile.TilePosition{mapSize/2 - 5, mapSize/2 + 2}, 92 | tile.TilePosition{mapSize/2 - 5, mapSize/2 + 1}, 93 | tile.TilePosition{mapSize/2 - 5, mapSize/2 + 0}, 94 | 95 | tile.TilePosition{mapSize/2 - 5, mapSize/2 - 1}, 96 | tile.TilePosition{mapSize/2 - 5, mapSize/2 - 2}, 97 | tile.TilePosition{mapSize/2 - 5, mapSize/2 - 3}, 98 | tile.TilePosition{mapSize/2 - 5, mapSize/2 - 4}, 99 | tile.TilePosition{mapSize/2 - 5, mapSize/2 - 5}, 100 | } 101 | for _, pos := range walls { 102 | addWall(world, tmap, pos) 103 | } 104 | 105 | tmap.RecalculateEntities(world) 106 | 107 | return tmap 108 | } 109 | 110 | func addWall(world *ecs.World, tilemap *tile.Tilemap, pos tile.TilePosition) { 111 | posX, posY := tilemap.TileToPosition(pos) 112 | 113 | id := world.NewId() 114 | 115 | // TODO - make square collider 116 | collider := phy2.NewCircleCollider(8) 117 | collider.Layer = WallLayer 118 | collider.HitLayer = BodyLayer 119 | 120 | ecs.Write(world, id, 121 | ecs.C(TileObject{}), 122 | ecs.C(tile.Collider{1,1}), 123 | ecs.C(phy2.Pos{ 124 | X: math.Round(float64(posX)), 125 | Y: math.Round(float64(posY)), 126 | }), 127 | ecs.C(collider), 128 | ecs.C(phy2.NewColliderCache()), 129 | ) 130 | } 131 | 132 | const ( 133 | GrassTile tile.TileType = iota 134 | DirtTile 135 | WaterTile 136 | ConcreteTile 137 | ) 138 | 139 | func CreateTilemap(seed int64, mapSize, tileSize int) *tile.Tilemap { 140 | octaves := []pgen.Octave{ 141 | pgen.Octave{0.01, 0.6}, 142 | pgen.Octave{0.05, 0.3}, 143 | pgen.Octave{0.1, 0.07}, 144 | pgen.Octave{0.2, 0.02}, 145 | pgen.Octave{0.4, 0.01}, 146 | } 147 | exponent := 0.8 148 | terrain := pgen.NewNoiseMap(seed, octaves, exponent) 149 | 150 | waterLevel := 0.5 151 | beachLevel := waterLevel + 0.1 152 | 153 | islandExponent := 2.0 154 | tiles := make([][]tile.Tile, mapSize, mapSize) 155 | for x := range tiles { 156 | tiles[x] = make([]tile.Tile, mapSize, mapSize) 157 | for y := range tiles[x] { 158 | 159 | height := terrain.Get(x, y) 160 | 161 | // Modify height to represent an island 162 | { 163 | dx := float64(x)/float64(mapSize) - 0.5 164 | dy := float64(y)/float64(mapSize) - 0.5 165 | d := math.Sqrt(dx * dx + dy * dy) * 2 166 | d = math.Pow(d, islandExponent) 167 | height = (1 - d + height) / 2 168 | } 169 | 170 | if height < waterLevel { 171 | tiles[x][y] = tile.Tile{WaterTile, 0, ecs.InvalidEntity} 172 | } else if height < beachLevel { 173 | tiles[x][y] = tile.Tile{DirtTile, 0, ecs.InvalidEntity} 174 | } else { 175 | tiles[x][y] = tile.Tile{GrassTile, 0, ecs.InvalidEntity} 176 | } 177 | 178 | mid := mapSize/2 179 | if x <= mid+5 && x >= mid-5 && y <= mid+5 && y >= mid-5 { 180 | tiles[x][y] = tile.Tile{ConcreteTile, 0, ecs.InvalidEntity} 181 | } 182 | } 183 | } 184 | tmap := tile.New(tiles, [2]int{tileSize, tileSize}, tile.FlatRectMath{}) 185 | 186 | return tmap 187 | } 188 | 189 | func MoveCharacter(input *Input, transform *phy2.Pos, collider *phy2.CircleCollider, tilemap *tile.Tilemap, dt time.Duration) { 190 | // Note: 100 good starting point, 200 seemed like a good max 191 | speed := 125 * dt.Seconds() 192 | 193 | tile, ok := tilemap.Get(tilemap.PositionToTile(float32(transform.X), float32(transform.Y))) 194 | if ok { 195 | if tile.Type == WaterTile { 196 | // Slow the player down if they're on water tile 197 | speed = speed / 2.0 198 | } 199 | } 200 | 201 | if input.Left { 202 | transform.X -= speed 203 | } 204 | if input.Right { 205 | transform.X += speed 206 | } 207 | if input.Up { 208 | transform.Y += speed 209 | } 210 | if input.Down { 211 | transform.Y -= speed 212 | } 213 | 214 | tilePos := tilemap.GetOverlappingTiles(transform.X, transform.Y, collider) 215 | for i := range tilePos { 216 | tile, ok := tilemap.Get(tilePos[i]) 217 | 218 | // If no tile exists there or there is any entity positioned on this tile, 219 | // then just assume its collidable 220 | if !ok || tile.Entity != ecs.InvalidEntity { 221 | // *transform = oldTransform 222 | // minX, minY, maxX, maxY := tilemap.BoundsAt(tilePos[i]) 223 | posX, posY := tilemap.TileToPosition(tilePos[i]) 224 | 225 | // resolveW := collider.Radius + float64(tilemap.TileSize[0]/2) 226 | // resolveH := collider.Radius + float64(tilemap.TileSize[1]/2) 227 | 228 | dx := transform.X - float64(posX) 229 | dy := transform.Y - float64(posY) 230 | 231 | // Check if they are even still colliding (this fact may change after one tile gets resolved) 232 | // TODO - this should cleanup, I need some rect and circle primitives to do these checks with. Basically if the distance in the X and the Y are both larger than the circle radius plus the tileSize/2. Then the circle is already outside the bounds 233 | // log.Print("Math ", math.Abs(dx), collider.Radius + float64(tilemap.TileSize[0]/2), math.Abs(dy), collider.Radius + float64(tilemap.TileSize[1]/2)) 234 | // TODO - Should I add any thresholding here? I think most of the time the floats are like exactly the same 235 | if math.Abs(dx) >= collider.Radius + float64(tilemap.TileSize[0]/2) || math.Abs(dy) >= collider.Radius + float64(tilemap.TileSize[1]/2) { 236 | continue // Skip if the circle is no longer overlapping this tile 237 | } 238 | 239 | // clamp 240 | if dx > float64(tilemap.TileSize[0]/2) { 241 | dx = float64(tilemap.TileSize[0]/2) 242 | } else if dx < -float64(tilemap.TileSize[0]/2) { 243 | dx = -float64(tilemap.TileSize[0]/2) 244 | } 245 | if dy > float64(tilemap.TileSize[1]/2) { 246 | dy = float64(tilemap.TileSize[1]/2) 247 | } else if dy < -float64(tilemap.TileSize[1]/2) { 248 | dy = -float64(tilemap.TileSize[1]/2) 249 | } 250 | 251 | // Closest point 252 | point := phy2.V2(dx + float64(posX), dy + float64(posY)) 253 | center := phy2.V2(transform.X, transform.Y) 254 | 255 | dv := point.Sub(center) 256 | response := dv.Norm().Scaled(dv.Len() - collider.Radius) 257 | 258 | // Resolve 259 | newCenter := center.Add(response) 260 | if math.Abs(response.X) >= math.Abs(response.Y) { 261 | transform.X = newCenter.X 262 | } else { 263 | transform.Y = newCenter.Y 264 | } 265 | } 266 | } 267 | } 268 | 269 | func CheckCollisions(world *ecs.World) { 270 | // Detect all collisions 271 | ecs.Map2(world, func(idA ecs.Id, colA *phy2.CircleCollider, cacheA *phy2.ColliderCache) { 272 | cacheA.Clear() 273 | ecs.Map2(world, func(idB ecs.Id, colB *phy2.CircleCollider, cacheB *phy2.ColliderCache) { 274 | if idA == idB { return } // Skip if collider is the same entity 275 | 276 | if !colA.LayerMask(colB.Layer) { return } // Skip if layer mask doesn't match 277 | 278 | // Check if there is a collision 279 | if colA.Collides(1.0, colB) { 280 | cacheA.Add(idB) 281 | } 282 | }) 283 | }) 284 | 285 | // // Resolve Collisions 286 | // ecs.Map2(world, func(id ecs.Id, transform *phy2.Transform, collider *phy2.CircleCollider, cache *phy2.ColliderCache) { 287 | // for _, targetId := range cache.Current { 288 | // targetCollider := ecs.Read[phy2.CircleCollider](world, targetId) 289 | // } 290 | // }) 291 | } 292 | 293 | func GetScheduler() *ecs.Scheduler { 294 | schedule := ecs.NewScheduler() 295 | schedule.SetFixedTimeStep(FixedTimeStep) 296 | return schedule 297 | } 298 | 299 | type TileObject struct { 300 | } 301 | 302 | const NumBodyTypes = 4 303 | type Body struct { 304 | Type uint32 305 | } 306 | 307 | type Speech struct { 308 | Text string 309 | handledSent, handledRender bool 310 | } 311 | 312 | // handles the speech, returns true if the speech wasn't already handled 313 | func (s *Speech) HandleSent() bool { 314 | if s.handledSent { 315 | return false 316 | } 317 | 318 | s.handledSent = true 319 | return true 320 | } 321 | 322 | func (s *Speech) HandleRender() bool { 323 | if s.handledRender { 324 | return false 325 | } 326 | 327 | s.handledRender = true 328 | return true 329 | } 330 | 331 | 332 | // This should probably be somewhere else 333 | func FilterChat(msg string) string { 334 | match, err := regexp.MatchString(`^[\w!@#$%^&*()[{\]}'";:<>,.\/\?~\-_,.+=\\ ]+$`, msg) 335 | if err != nil { 336 | log.Error().Err(err).Msg("Regex Matching error") 337 | return "[This message was delete by moderator.]" 338 | } 339 | if match { 340 | return msg 341 | } else { 342 | return "[This message was delete by moderator.]" 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /serdes/binary_test.go: -------------------------------------------------------------------------------- 1 | package serdes 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "reflect" 7 | 8 | "github.com/unitoftime/flow/phy2" 9 | "github.com/unitoftime/flow/net" 10 | 11 | "github.com/unitoftime/mmo" 12 | "github.com/unitoftime/ecs" 13 | ) 14 | 15 | func TestMarshalUnmarshal(t *testing.T) { 16 | encoder := New() 17 | 18 | // cUnion := NewUnion(phy2.Pos{}, phy2.Input{}, game.Body{}, game.Speech{}) 19 | 20 | { 21 | // dat, err := MarshalBinary(ClientLogin{0xAEAE}) 22 | dat, err := encoder.Marshal(ClientLogin{0xAEAE}) 23 | if err != nil { panic(err) } 24 | 25 | fmt.Printf("ClientLogin: %x\n", dat) 26 | 27 | // v, err := UnmarshalBinary(dat) 28 | v, err := encoder.Unmarshal(dat) 29 | if err != nil { panic(err) } 30 | fmt.Printf("%T: %x\n", v, v) 31 | } 32 | { 33 | dat, err := encoder.Marshal(ClientLoginResp{0xAEAE, ecs.Id(0xAAAA)}) 34 | // dat, err := MarshalBinary(ClientLoginResp{0xAEAE, ecs.Id(0xAAAA)}) 35 | if err != nil { panic(err) } 36 | 37 | fmt.Printf("%x\n", dat) 38 | 39 | // v, err := UnmarshalBinary(dat) 40 | v, err := encoder.Unmarshal(dat) 41 | if err != nil { panic(err) } 42 | fmt.Printf("%T: %x\n", v, v) 43 | } 44 | 45 | { 46 | // dat, err := MarshalBinary(ClientLogout{0xAEAE}) 47 | dat, err := encoder.Marshal(ClientLogout{0xAEAE}) 48 | if err != nil { panic(err) } 49 | 50 | fmt.Printf("%x\n", dat) 51 | 52 | // v, err := UnmarshalBinary(dat) 53 | v, err := encoder.Unmarshal(dat) 54 | if err != nil { panic(err) } 55 | fmt.Printf("%T: %x\n", v, v) 56 | } 57 | { 58 | // dat, err := MarshalBinary(ClientLogoutResp{0xAEAE, ecs.Id(0xAAAA)}) 59 | dat, err := encoder.Marshal(ClientLogoutResp{0xAEAE, ecs.Id(0xAAAA)}) 60 | if err != nil { panic(err) } 61 | 62 | fmt.Printf("%x\n", dat) 63 | 64 | // v, err := UnmarshalBinary(dat) 65 | v, err := encoder.Unmarshal(dat) 66 | if err != nil { panic(err) } 67 | fmt.Printf("%T: %x\n", v, v) 68 | } 69 | 70 | // World update 71 | { 72 | // TODO - Seems like the binary package i'm using doesn't work if I don't pass a pointer here. (because I have a pointer receiver on MarshalBinary() 73 | // dat, err := MarshalBinary(&WorldUpdate{ 74 | // t1, err := cUnion.Make(phy2.Pos{1,2,3}) 75 | // if err != nil { panic(err) } 76 | // i1, err := cUnion.Make(phy2.Input{}) 77 | // if err != nil { panic(err) } 78 | // t2, err := cUnion.Make(phy2.Pos{4,5,6}) 79 | // if err != nil { panic(err) } 80 | // i3, err := cUnion.Make(phy2.Input{true,true,true,true}) 81 | // if err != nil { panic(err) } 82 | 83 | dat, err := encoder.Marshal(WorldUpdate{ 84 | Tick: 1111, 85 | PlayerTick: 2222, 86 | UserId: 3333, 87 | WorldData: map[ecs.Id][]ecs.Component{ 88 | 1: []ecs.Component{ecs.C(phy2.Pos{1,2}), ecs.C(mmo.Input{})}, 89 | 2: []ecs.Component{ecs.C(phy2.Pos{4,5})}, 90 | 3: []ecs.Component{ecs.C(mmo.Input{true,true,true,true})}, 91 | }, 92 | // WorldData: map[uint32][]Union{ 93 | // 1: []Union{t1, i1}, 94 | // 2: []Union{t2}, 95 | // 3: []Union{i3}, 96 | // }, 97 | Delete: []ecs.Id{1,2,3,4,5}, 98 | }) 99 | if err != nil { panic(err) } 100 | // 005b5aaeddba050301000000020018000000000000f03f00000000000000400000000000000840010400000000020000000100180000000000001040000000000000144000000000000018400300000001010401010101050102030405 101 | // 005b5aaeddba0503030000000101040101010101000000020018000000000000f03f0000000000000040000000000000084001040000000002000000010018000000000000104000000000000014400000000000001840050102030405 102 | fmt.Printf("Message: %x\n", []byte("test")) 103 | fmt.Printf("Dat: %x\n", dat) 104 | 105 | // v, err := UnmarshalBinary(dat) 106 | v, err := encoder.Unmarshal(dat) 107 | if err != nil { panic(err) } 108 | fmt.Printf("%T: %v\n", v, v) 109 | } 110 | } 111 | 112 | //-------------------------------------------------------------------------------- 113 | 114 | func TestConvert(t *testing.T) { 115 | l := ClientLogin{0xAEAE} 116 | i := any(l) 117 | 118 | ty := reflect.TypeOf(i) 119 | v := reflect.ValueOf(i) 120 | ptr := reflect.PointerTo(ty) 121 | fmt.Println("Data:", l, i, ty, v) 122 | fmt.Println("Convert:", ty.ConvertibleTo(ptr)) 123 | 124 | newVal := reflect.New(v.Type()) 125 | newVal.Elem().Set(v) 126 | fmt.Printf("Other: %T %v\n ", newVal.Interface(), newVal.Interface()) 127 | } 128 | 129 | 130 | // func TestBinaryEncoding(t *testing.T) { 131 | // // msg := Message{ 132 | // // Type: ClientLoginType, 133 | // // Data: ClientLogin{0xAEAE}, 134 | // // } 135 | // { 136 | // dat, err := MarshalBinary(ClientLogin{0xAEAE}) 137 | // if err != nil { panic(err) } 138 | 139 | // fmt.Printf("ClientLogin: %x\n", dat) 140 | 141 | // v, err := UnmarshalBinary(dat) 142 | // if err != nil { panic(err) } 143 | // fmt.Printf("%T: %x\n", v, v) 144 | // } 145 | // { 146 | // dat, err := MarshalBinary(ClientLoginResp{0xAEAE, ecs.Id(0xAAAA)}) 147 | // if err != nil { panic(err) } 148 | 149 | // fmt.Printf("%x\n", dat) 150 | 151 | // v, err := UnmarshalBinary(dat) 152 | // if err != nil { panic(err) } 153 | // fmt.Printf("%T: %x\n", v, v) 154 | // } 155 | 156 | // { 157 | // dat, err := MarshalBinary(ClientLogout{0xAEAE}) 158 | // if err != nil { panic(err) } 159 | 160 | // fmt.Printf("%x\n", dat) 161 | 162 | // v, err := UnmarshalBinary(dat) 163 | // if err != nil { panic(err) } 164 | // fmt.Printf("%T: %x\n", v, v) 165 | // } 166 | // { 167 | // dat, err := MarshalBinary(ClientLogoutResp{0xAEAE, ecs.Id(0xAAAA)}) 168 | // if err != nil { panic(err) } 169 | 170 | // fmt.Printf("%x\n", dat) 171 | 172 | // v, err := UnmarshalBinary(dat) 173 | // if err != nil { panic(err) } 174 | // fmt.Printf("%T: %x\n", v, v) 175 | // } 176 | 177 | // // World update 178 | // { 179 | // // TODO - Seems like the binary package i'm using doesn't work if I don't pass a pointer here. (because I have a pointer receiver on MarshalBinary() 180 | // dat, err := MarshalBinary(&WorldUpdate{ 181 | // UserId: 0xAEAEAE, 182 | // WorldData: map[ecs.Id][]ecs.Component{ 183 | // 1: []ecs.Component{ecs.C(phy2.Pos{1,2,3}), ecs.C(phy2.Input{})}, 184 | // 2: []ecs.Component{ecs.C(phy2.Pos{4,5,6})}, 185 | // 3: []ecs.Component{ecs.C(phy2.Input{true,true,true,true})}, 186 | // }, 187 | // Delete: []ecs.Id{1,2,3,4,5}, 188 | // // Messages: []ChatMessage{ 189 | // // ChatMessage{"user", "message"}, 190 | // // ChatMessage{"user2", "message2"}, 191 | // // }, 192 | // }) 193 | // if err != nil { panic(err) } 194 | // // 005b5aaeddba050301000000020018000000000000f03f00000000000000400000000000000840010400000000020000000100180000000000001040000000000000144000000000000018400300000001010401010101050102030405 195 | // // 005b5aaeddba0503030000000101040101010101000000020018000000000000f03f0000000000000040000000000000084001040000000002000000010018000000000000104000000000000014400000000000001840050102030405 196 | // fmt.Printf("Message: %x\n", []byte("test")) 197 | // fmt.Printf("Dat: %x\n", dat) 198 | 199 | // v, err := UnmarshalBinary(dat) 200 | // if err != nil { panic(err) } 201 | // fmt.Printf("%T: %v\n", v, v) 202 | // } 203 | // } 204 | 205 | func TestBinaryEncodingUnions(t *testing.T) { 206 | union := net.NewUnion(WorldUpdate{}, ClientLogin{}, ClientLoginResp{}, ClientLogout{}, ClientLogoutResp{}) 207 | 208 | // cUnion := NewUnion(phy2.Pos{}, phy2.Input{}, game.Body{}, game.Speech{}) 209 | 210 | { 211 | // dat, err := MarshalBinary(ClientLogin{0xAEAE}) 212 | dat, err := union.Serialize(ClientLogin{0xAEAE}) 213 | if err != nil { panic(err) } 214 | 215 | fmt.Printf("ClientLogin: %x\n", dat) 216 | 217 | // v, err := UnmarshalBinary(dat) 218 | v, err := union.Deserialize(dat) 219 | if err != nil { panic(err) } 220 | fmt.Printf("%T: %x\n", v, v) 221 | } 222 | { 223 | dat, err := union.Serialize(ClientLoginResp{0xAEAE, ecs.Id(0xAAAA)}) 224 | // dat, err := MarshalBinary(ClientLoginResp{0xAEAE, ecs.Id(0xAAAA)}) 225 | if err != nil { panic(err) } 226 | 227 | fmt.Printf("%x\n", dat) 228 | 229 | // v, err := UnmarshalBinary(dat) 230 | v, err := union.Deserialize(dat) 231 | if err != nil { panic(err) } 232 | fmt.Printf("%T: %x\n", v, v) 233 | } 234 | 235 | { 236 | // dat, err := MarshalBinary(ClientLogout{0xAEAE}) 237 | dat, err := union.Serialize(ClientLogout{0xAEAE}) 238 | if err != nil { panic(err) } 239 | 240 | fmt.Printf("%x\n", dat) 241 | 242 | // v, err := UnmarshalBinary(dat) 243 | v, err := union.Deserialize(dat) 244 | if err != nil { panic(err) } 245 | fmt.Printf("%T: %x\n", v, v) 246 | } 247 | { 248 | // dat, err := MarshalBinary(ClientLogoutResp{0xAEAE, ecs.Id(0xAAAA)}) 249 | dat, err := union.Serialize(ClientLogoutResp{0xAEAE, ecs.Id(0xAAAA)}) 250 | if err != nil { panic(err) } 251 | 252 | fmt.Printf("%x\n", dat) 253 | 254 | // v, err := UnmarshalBinary(dat) 255 | v, err := union.Deserialize(dat) 256 | if err != nil { panic(err) } 257 | fmt.Printf("%T: %x\n", v, v) 258 | } 259 | 260 | // World update 261 | { 262 | // TODO - Seems like the binary package i'm using doesn't work if I don't pass a pointer here. (because I have a pointer receiver on MarshalBinary() 263 | // dat, err := MarshalBinary(&WorldUpdate{ 264 | // t1, err := cUnion.Make(phy2.Pos{1,2,3}) 265 | // if err != nil { panic(err) } 266 | // i1, err := cUnion.Make(phy2.Input{}) 267 | // if err != nil { panic(err) } 268 | // t2, err := cUnion.Make(phy2.Pos{4,5,6}) 269 | // if err != nil { panic(err) } 270 | // i3, err := cUnion.Make(phy2.Input{true,true,true,true}) 271 | // if err != nil { panic(err) } 272 | 273 | dat, err := union.Serialize(WorldUpdate{ 274 | Tick: 1111, 275 | PlayerTick: 2222, 276 | UserId: 3333, 277 | WorldData: map[ecs.Id][]ecs.Component{ 278 | 1: []ecs.Component{ecs.C(phy2.Pos{1,2}), ecs.C(mmo.Input{})}, 279 | 2: []ecs.Component{ecs.C(phy2.Pos{4,5})}, 280 | 3: []ecs.Component{ecs.C(mmo.Input{true,true,true,true})}, 281 | }, 282 | // WorldData: map[uint32][]Union{ 283 | // 1: []Union{t1, i1}, 284 | // 2: []Union{t2}, 285 | // 3: []Union{i3}, 286 | // }, 287 | Delete: []ecs.Id{1,2,3,4,5}, 288 | }) 289 | if err != nil { panic(err) } 290 | // 005b5aaeddba050301000000020018000000000000f03f00000000000000400000000000000840010400000000020000000100180000000000001040000000000000144000000000000018400300000001010401010101050102030405 291 | // 005b5aaeddba0503030000000101040101010101000000020018000000000000f03f0000000000000040000000000000084001040000000002000000010018000000000000104000000000000014400000000000001840050102030405 292 | fmt.Printf("Message: %x\n", []byte("test")) 293 | fmt.Printf("Dat: %x\n", dat) 294 | 295 | // v, err := UnmarshalBinary(dat) 296 | v, err := union.Deserialize(dat) 297 | if err != nil { panic(err) } 298 | fmt.Printf("%T: %v\n", v, v) 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /serdes/regex_test.go: -------------------------------------------------------------------------------- 1 | package serdes 2 | 3 | // import ( 4 | // "fmt" 5 | // "testing" 6 | // ) 7 | 8 | // func TestChatFilter(t *testing.T) { 9 | // fmt.Println(FilterChat("Hello World")) 10 | 11 | // fmt.Println(FilterChat("^*(!@)$^!(*")) 12 | 13 | // fmt.Println(FilterChat(`hello world!@#$%^&*() [{]}'";:<>,./+=?~-_,.`)) 14 | // fmt.Println(FilterChat(`☀hello world!@#$%^&*() [{]}'";:<>,./+=?~-_,.`)) 15 | // } 16 | -------------------------------------------------------------------------------- /serdes/serdes.go: -------------------------------------------------------------------------------- 1 | package serdes 2 | 3 | import ( 4 | "github.com/unitoftime/binary" 5 | "github.com/unitoftime/ecs" 6 | "github.com/unitoftime/flow/net" 7 | 8 | "github.com/unitoftime/flow/phy2" 9 | "github.com/unitoftime/mmo" 10 | ) 11 | 12 | // type MessageRouter struct { 13 | // handlers map[reflect.Type]func(any) (error) 14 | // } 15 | 16 | // type ComponentRouter struct { 17 | // handlers map[reflect.Type]func(any) (ecs.Component, error) 18 | // } 19 | 20 | // Quick test 21 | // binary: 187 Kb/s 22 | // Gob: 283 Kb/s 23 | // Flatbu: 304 Kb/s 24 | // Json: 411 Kb/s 25 | 26 | // TODO! - should I just have one big union object that everything is in? That'll greatly simplify a recursive serializer. Kindoflike gob where if you hit an interface you just try to unionize it. Then when you pull it out you do the opposite... 27 | var componentUnion *net.UnionBuilder 28 | func init() { 29 | // componentUnion = NewUnion(phy2.Transform{}, phy2.Input{}, game.Body{}, game.Speech{}) 30 | componentUnion = net.NewUnion(ecs.C(phy2.Pos{}), ecs.C(mmo.Input{}), ecs.C(mmo.Body{}), ecs.C(mmo.Speech{})) 31 | } 32 | 33 | // TODO - for delta encoding of things that have to be different like ecs.Ids, if you encode the number as 0 then that could indicate that "we needed more bytes to encode the delta" 34 | type WorldUpdate struct { 35 | Tick uint16 36 | PlayerTick uint16 37 | UserId uint64 38 | WorldData map[ecs.Id][]ecs.Component 39 | // WorldData EntityMap // TODO - might be nice to reduce the BinWorldUpdate to just the entity map 40 | Delete []ecs.Id 41 | } 42 | type BinWorldUpdate struct { 43 | Tick uint16 44 | PlayerTick uint16 45 | UserId uint64 46 | WorldData map[uint32][]net.Union 47 | Delete []ecs.Id 48 | } 49 | 50 | func (w WorldUpdate) MarshalBinary() ([]byte, error) { 51 | wu := BinWorldUpdate{ 52 | Tick: w.Tick, 53 | PlayerTick: w.PlayerTick, 54 | UserId: w.UserId, 55 | // WorldData: make(map[ecs.Id][]BinaryComponent), // TODO the binary serdes package I'm using doesn't support ecs.Id as a key panic: reflect.Value.SetMapIndex: value of type uint32 is not assignable to type ecs.Id [recovered] panic: reflect.Value.SetMapIndex: value of type uint32 is not assignable to type ecs.Id 56 | WorldData: make(map[uint32][]net.Union), 57 | Delete: w.Delete, 58 | } 59 | for id := range w.WorldData { 60 | cSlice := make([]net.Union, 0) 61 | for _, c := range w.WorldData[id] { 62 | union, err := componentUnion.Make(c) 63 | if err != nil { return nil, err } 64 | cSlice = append(cSlice, union) 65 | } 66 | wu.WorldData[uint32(id)] = cSlice 67 | } 68 | 69 | return binary.Marshal(wu) 70 | } 71 | 72 | func (w *WorldUpdate) UnmarshalBinary(data []byte) error { 73 | wu := BinWorldUpdate{} 74 | err := binary.Unmarshal(data, &wu) 75 | if err != nil { return nil } 76 | 77 | w.Tick = wu.Tick 78 | w.PlayerTick = wu.PlayerTick 79 | w.UserId = wu.UserId 80 | w.Delete = wu.Delete 81 | // w.Messages = wu.Messages 82 | if w.WorldData == nil { 83 | w.WorldData = make(map[ecs.Id][]ecs.Component) 84 | } 85 | 86 | for id := range wu.WorldData { 87 | cSlice := make([]ecs.Component, 0) 88 | for _, union := range wu.WorldData[id] { 89 | anyComp, err := componentUnion.Unmake(union) 90 | if err != nil { return err } 91 | // comp := toComponent(anyComp) 92 | comp, ok := anyComp.(ecs.Component) 93 | if ok { 94 | cSlice = append(cSlice, comp) 95 | } 96 | } 97 | w.WorldData[ecs.Id(id)] = cSlice 98 | } 99 | 100 | return nil 101 | } 102 | 103 | type ClientLogin struct { 104 | UserId uint64 105 | } 106 | 107 | type ClientLoginResp struct { 108 | UserId uint64 109 | Id ecs.Id 110 | } 111 | 112 | type ClientLogout struct { 113 | UserId uint64 114 | } 115 | type ClientLogoutResp struct { 116 | UserId uint64 117 | Id ecs.Id 118 | } 119 | 120 | type Serdes struct { 121 | union *net.UnionBuilder 122 | } 123 | 124 | func New() *Serdes { 125 | return &Serdes{ 126 | union: net.NewUnion(WorldUpdate{}, ClientLogin{}, ClientLoginResp{}, ClientLogout{}, ClientLogoutResp{}), 127 | } 128 | } 129 | 130 | func (s *Serdes) Marshal(v any) ([]byte, error) { 131 | return s.union.Serialize(v) 132 | } 133 | 134 | func (s *Serdes) Unmarshal(dat []byte) (any, error) { 135 | return s.union.Deserialize(dat) 136 | } 137 | -------------------------------------------------------------------------------- /stat/stat.go: -------------------------------------------------------------------------------- 1 | package stat 2 | 3 | // This holds a list of stat keys that can be emitted from various areas in the MMO 4 | 5 | const( 6 | UserId = "UserId" 7 | ) 8 | --------------------------------------------------------------------------------