From 06ecc360a63f97cd282533bb0173453a33b6f31f Mon Sep 17 00:00:00 2001 From: Habbatul Date: Wed, 2 Jul 2025 06:13:28 +0700 Subject: [PATCH] Refactoring the game into peer-to-peer multiplayer using WebRTC: - Implement WebRTC for peer-to-peer communication - Refactor the main game logic to allow players to discover and interact with each other (player) - Renew how to achieve draw order - Change folder structure for asset - Other, read at README.md --- .github/workflows/deploy.yml | 156 +++++------ README.md | 12 +- .../asset}/Jersey10-Regular.ttf | Bin .../Dark_totem_dark_shadow2.png | Bin .../Dark_totem_dark_shadow3.png | Bin .../asset_obstacle}/Gates_dark_shadow3.png | Bin .../asset_obstacle}/Water_ruins2.png | Bin .../asset_sprite}/idle_npc/Asya_Idle_full.png | Bin .../idle_npc/Elicia_Idle_full.png | Bin .../asset_sprite}/idle_npc/Sena_Idle_full.png | Bin .../player/Unarmed_Walk_full.png | Bin .../asset_world}/main-world.png | Bin go.mod | 37 ++- go.sum | 78 +++++- main.go | 153 ++++++++--- object/camera.go | 4 + object/player.go | 8 +- object/remotePlayer.go | 99 +++++++ object/world.go | 2 +- server/wsclient.go | 258 ++++++++++++++++++ 20 files changed, 657 insertions(+), 150 deletions(-) rename {asset => game_asset/asset}/Jersey10-Regular.ttf (100%) rename {asset_obstacle => game_asset/asset_obstacle}/Dark_totem_dark_shadow2.png (100%) rename {asset_obstacle => game_asset/asset_obstacle}/Dark_totem_dark_shadow3.png (100%) rename {asset_obstacle => game_asset/asset_obstacle}/Gates_dark_shadow3.png (100%) rename {asset_obstacle => game_asset/asset_obstacle}/Water_ruins2.png (100%) rename {asset_sprite => game_asset/asset_sprite}/idle_npc/Asya_Idle_full.png (100%) rename {asset_sprite => game_asset/asset_sprite}/idle_npc/Elicia_Idle_full.png (100%) rename {asset_sprite => game_asset/asset_sprite}/idle_npc/Sena_Idle_full.png (100%) rename {asset_sprite => game_asset/asset_sprite}/player/Unarmed_Walk_full.png (100%) rename {asset_world => game_asset/asset_world}/main-world.png (100%) create mode 100644 object/remotePlayer.go create mode 100644 server/wsclient.go diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 731699a..351985c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,78 +1,78 @@ -name: Build and Deploy to GitHub Pages - -on: - push: - branches: - - master - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Set up Go - uses: actions/setup-go@v4 - with: - go-version: '1.22' - - - name: Install Go WASM Toolchain - run: | - echo "Installing WASM toolchain" - mkdir -p build - cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" ./build/ - - - name: Build Ebiten game to WASM - run: | - GOOS=js GOARCH=wasm go build -o build/main.wasm - - - name: Copy assets to build folder - run: | - mkdir -p build/ - cp -r asset build/ - cp -r asset_obstacle build/ - cp -r asset_sprite build/ - cp -r asset_world build/ - - - name: Create HTML launcher - run: | - cat < build/index.html - - - - - - hanPixel - - - -
Loading The Game, Please Wait...
- - - - - EOF - - - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@v3 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./build +#name: Build and Deploy to GitHub Pages +# +#on: +# push: +# branches: +# - master +# +#jobs: +# build: +# runs-on: ubuntu-latest +# +# steps: +# - name: Checkout code +# uses: actions/checkout@v3 +# +# - name: Set up Go +# uses: actions/setup-go@v4 +# with: +# go-version: '1.22' +# +# - name: Install Go WASM Toolchain +# run: | +# echo "Installing WASM toolchain" +# mkdir -p build +# cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" ./build/ +# +# - name: Build Ebiten game to WASM +# run: | +# GOOS=js GOARCH=wasm go build -o build/main.wasm +# +# - name: Copy assets to build folder +# run: | +# mkdir -p build/ +# cp -r asset build/ +# cp -r asset_obstacle build/ +# cp -r asset_sprite build/ +# cp -r asset_world build/ +# +# - name: Create HTML launcher +# run: | +# cat < build/index.html +# +# +# +# +# +# hanPixel +# +# +# +#
Loading The Game, Please Wait...
+# +# +# +# +# EOF +# +# - name: Deploy to GitHub Pages +# uses: peaceiris/actions-gh-pages@v3 +# with: +# github_token: ${{ secrets.GITHUB_TOKEN }} +# publish_dir: ./build diff --git a/README.md b/README.md index f318015..203dfc7 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,13 @@ ## 📺 About the Project -**hanPixel** is a pixel-art portfolio game created using [Ebiten](https://ebiten.org/), a 2D game library for Go. This project was built from scratch to showcase fundamental game mechanics. **While some implementations are not fully modular** (maybe it's hard to read and change), the project serves as a demonstration of basic foundational game dev concepts. +**hanPixel** is a pixel-art portfolio game created using [Ebiten](https://ebiten.org/), a 2D game library for Go. This project was built from scratch to showcase fundamental game mechanics. **While some implementations are not fully modular** (maybe it's hard to read and change), the project serves as a demonstration of basic foundational game dev concepts. Now all players can do multiplayer via peer-to-peer, but since STUN is used, there is a possibility that some players may not be able to establish a direct connection due to strict NAT types. ### ✨ Features +* **Online Multiplayer (Peer-to-Peer)** + Utilizes WebRTC with STUN for peer-to-peer multiplayer. A signaling server is required, accessible at [hanPixel_SignalingServer](https://github.com/Habbatul/hanPixel_SignalingServer). + * **Collision Detection.** Detects collisions between the player, NPCs, and obstacles. @@ -33,6 +36,13 @@ * **Keyboard & Mouse Support (Desktop).** Full control support for desktop environments. + +### ✨ Tech Stack + +- pion/webrtc +- coder/websocket +- hajimehoshi/ebiten + --- ## 🧪 Run Locally on Your PC diff --git a/asset/Jersey10-Regular.ttf b/game_asset/asset/Jersey10-Regular.ttf similarity index 100% rename from asset/Jersey10-Regular.ttf rename to game_asset/asset/Jersey10-Regular.ttf diff --git a/asset_obstacle/Dark_totem_dark_shadow2.png b/game_asset/asset_obstacle/Dark_totem_dark_shadow2.png similarity index 100% rename from asset_obstacle/Dark_totem_dark_shadow2.png rename to game_asset/asset_obstacle/Dark_totem_dark_shadow2.png diff --git a/asset_obstacle/Dark_totem_dark_shadow3.png b/game_asset/asset_obstacle/Dark_totem_dark_shadow3.png similarity index 100% rename from asset_obstacle/Dark_totem_dark_shadow3.png rename to game_asset/asset_obstacle/Dark_totem_dark_shadow3.png diff --git a/asset_obstacle/Gates_dark_shadow3.png b/game_asset/asset_obstacle/Gates_dark_shadow3.png similarity index 100% rename from asset_obstacle/Gates_dark_shadow3.png rename to game_asset/asset_obstacle/Gates_dark_shadow3.png diff --git a/asset_obstacle/Water_ruins2.png b/game_asset/asset_obstacle/Water_ruins2.png similarity index 100% rename from asset_obstacle/Water_ruins2.png rename to game_asset/asset_obstacle/Water_ruins2.png diff --git a/asset_sprite/idle_npc/Asya_Idle_full.png b/game_asset/asset_sprite/idle_npc/Asya_Idle_full.png similarity index 100% rename from asset_sprite/idle_npc/Asya_Idle_full.png rename to game_asset/asset_sprite/idle_npc/Asya_Idle_full.png diff --git a/asset_sprite/idle_npc/Elicia_Idle_full.png b/game_asset/asset_sprite/idle_npc/Elicia_Idle_full.png similarity index 100% rename from asset_sprite/idle_npc/Elicia_Idle_full.png rename to game_asset/asset_sprite/idle_npc/Elicia_Idle_full.png diff --git a/asset_sprite/idle_npc/Sena_Idle_full.png b/game_asset/asset_sprite/idle_npc/Sena_Idle_full.png similarity index 100% rename from asset_sprite/idle_npc/Sena_Idle_full.png rename to game_asset/asset_sprite/idle_npc/Sena_Idle_full.png diff --git a/asset_sprite/player/Unarmed_Walk_full.png b/game_asset/asset_sprite/player/Unarmed_Walk_full.png similarity index 100% rename from asset_sprite/player/Unarmed_Walk_full.png rename to game_asset/asset_sprite/player/Unarmed_Walk_full.png diff --git a/asset_world/main-world.png b/game_asset/asset_world/main-world.png similarity index 100% rename from asset_world/main-world.png rename to game_asset/asset_world/main-world.png diff --git a/go.mod b/go.mod index 27fe7fa..e1190e5 100644 --- a/go.mod +++ b/go.mod @@ -1,20 +1,41 @@ module goHan -go 1.22.0 +go 1.23.0 -toolchain go1.22.2 +toolchain go1.23.10 require ( + github.com/coder/websocket v1.8.13 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 - github.com/hajimehoshi/ebiten/v2 v2.8.4 - golang.org/x/image v0.20.0 + github.com/hajimehoshi/ebiten/v2 v2.8.6 + github.com/pion/webrtc/v4 v4.1.2 + golang.org/x/image v0.25.0 ) require ( - github.com/ebitengine/gomobile v0.0.0-20240911145611-4856209ac325 // indirect + github.com/ebitengine/gomobile v0.0.0-20250209143333-6071a2a2351c // indirect github.com/ebitengine/hideconsole v1.0.0 // indirect - github.com/ebitengine/purego v0.8.0 // indirect + github.com/ebitengine/purego v0.8.2 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/jezek/xgb v1.1.1 // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.25.0 // indirect + github.com/pion/datachannel v1.5.10 // indirect + github.com/pion/dtls/v3 v3.0.6 // indirect + github.com/pion/ice/v4 v4.0.10 // indirect + github.com/pion/interceptor v0.1.40 // indirect + github.com/pion/logging v0.2.3 // indirect + github.com/pion/mdns/v2 v2.0.7 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/rtcp v1.2.15 // indirect + github.com/pion/rtp v1.8.18 // indirect + github.com/pion/sctp v1.8.39 // indirect + github.com/pion/sdp/v3 v3.0.13 // indirect + github.com/pion/srtp/v3 v3.0.5 // indirect + github.com/pion/stun/v3 v3.0.0 // indirect + github.com/pion/transport/v3 v3.0.7 // indirect + github.com/pion/turn/v4 v4.0.0 // indirect + github.com/wlynxg/anet v0.0.5 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect ) diff --git a/go.sum b/go.sum index 5145613..4a4346f 100644 --- a/go.sum +++ b/go.sum @@ -1,24 +1,74 @@ -github.com/ebitengine/gomobile v0.0.0-20240911145611-4856209ac325 h1:Gk1XUEttOk0/hb6Tq3WkmutWa0ZLhNn/6fc6XZpM7tM= -github.com/ebitengine/gomobile v0.0.0-20240911145611-4856209ac325/go.mod h1:ulhSQcbPioQrallSuIzF8l1NKQoD7xmMZc5NxzibUMY= +github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= +github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ebitengine/gomobile v0.0.0-20250209143333-6071a2a2351c h1:nCxkoQoJMcVLc5aoMp3ULbfyEMcQjxopBKgNQVBQFXE= +github.com/ebitengine/gomobile v0.0.0-20250209143333-6071a2a2351c/go.mod h1:yMh1VvLL71zDgHlVlIXXJIGmv36QcJ9ZD2gtIGYAp3I= github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE= github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A= -github.com/ebitengine/purego v0.8.0 h1:JbqvnEzRvPpxhCJzJJ2y0RbiZ8nyjccVUrSM3q+GvvE= -github.com/ebitengine/purego v0.8.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= +github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hajimehoshi/bitmapfont/v3 v3.2.0 h1:0DISQM/rseKIJhdF29AkhvdzIULqNIIlXAGWit4ez1Q= github.com/hajimehoshi/bitmapfont/v3 v3.2.0/go.mod h1:8gLqGatKVu0pwcNCJguW3Igg9WQqVXF0zg/RvrGQWyg= -github.com/hajimehoshi/ebiten/v2 v2.8.4 h1:BzXkcyYX046SRZFkzF2KaCaHiBjwCaufUPCAOK59JSw= -github.com/hajimehoshi/ebiten/v2 v2.8.4/go.mod h1:SXx/whkvpfsavGo6lvZykprerakl+8Uo1X8d2U5aAnA= +github.com/hajimehoshi/ebiten/v2 v2.8.6 h1:Dkd/sYI0TYyZRCE7GVxV59XC+WCi2BbGAbIBjXeVC1U= +github.com/hajimehoshi/ebiten/v2 v2.8.6/go.mod h1:cCQ3np7rdmaJa1ZnvslraVlpxNb3wCjEnAP1LHNyXNA= github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -golang.org/x/image v0.20.0 h1:7cVCUjQwfL18gyBJOmYvptfSHS8Fb3YUDtfLIZ7Nbpw= -golang.org/x/image v0.20.0/go.mod h1:0a88To4CYVBAHp5FXJm8o7QbUl37Vd85ply1vyD8auM= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= +github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= +github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E= +github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU= +github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4= +github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= +github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4= +github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic= +github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI= +github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90= +github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= +github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= +github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= +github.com/pion/rtp v1.8.18 h1:yEAb4+4a8nkPCecWzQB6V/uEU18X1lQCGAQCjP+pyvU= +github.com/pion/rtp v1.8.18/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk= +github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE= +github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= +github.com/pion/sdp/v3 v3.0.13 h1:uN3SS2b+QDZnWXgdr69SM8KB4EbcnPnPf2Laxhty/l4= +github.com/pion/sdp/v3 v3.0.13/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= +github.com/pion/srtp/v3 v3.0.5 h1:8XLB6Dt3QXkMkRFpoqC3314BemkpMQK2mZeJc4pUKqo= +github.com/pion/srtp/v3 v3.0.5/go.mod h1:r1G7y5r1scZRLe2QJI/is+/O83W2d+JoEsuIexpw+uM= +github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= +github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= +github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= +github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= +github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= +github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= +github.com/pion/webrtc/v4 v4.1.2 h1:mpuUo/EJ1zMNKGE79fAdYNFZBX790KE7kQQpLMjjR54= +github.com/pion/webrtc/v4 v4.1.2/go.mod h1:xsCXiNAmMEjIdFxAYU0MbB3RwRieJsegSB2JZsGN+8U= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= +golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 70ee6a0..f636617 100644 --- a/main.go +++ b/main.go @@ -6,16 +6,19 @@ import ( "github.com/hajimehoshi/ebiten/v2" "goHan/object" "goHan/object/helper" + "goHan/server" "image/color" "log" + "sort" ) type Game struct { - player *object.Player - camera *object.Camera - world *object.World - obstacles []*object.Obstacle - silentNpcs []*object.SilentNpc + player *object.Player + camera *object.Camera + world *object.World + obstacles []*object.Obstacle + silentNpcs []*object.SilentNpc + remotePlayers map[string]*object.RemotePlayer } const ( @@ -29,19 +32,20 @@ func NewGame() *Game { camera: object.NewCamera(0, 0, screenWidth, screenHeight, 2.8), world: object.NewWorld(650, 400), obstacles: []*object.Obstacle{ - object.NewObstacle(200, 100, "asset_obstacle/Gates_dark_shadow3.png"), - object.NewObstacle(350, 150, "asset_obstacle/Water_ruins2.png"), - object.NewObstacle(450, 250, "asset_obstacle/Dark_totem_dark_shadow2.png"), - object.NewObstacle(280, 260, "asset_obstacle/Dark_totem_dark_shadow3.png"), + object.NewObstacle(200, 100, "game_asset/asset_obstacle/Gates_dark_shadow3.png"), + object.NewObstacle(350, 150, "game_asset/asset_obstacle/Water_ruins2.png"), + object.NewObstacle(450, 250, "game_asset/asset_obstacle/Dark_totem_dark_shadow2.png"), + object.NewObstacle(280, 260, "game_asset/asset_obstacle/Dark_totem_dark_shadow3.png"), }, silentNpcs: []*object.SilentNpc{ - object.NewSilentNpc(64, 64, 3, 12, "asset_sprite/idle_npc/Asya_Idle_full.png", 100, 140, + object.NewSilentNpc(64, 64, 3, 12, "game_asset/asset_sprite/idle_npc/Asya_Idle_full.png", 100, 140, []string{"[[left]][[red]]Asya:\n[[white]]Welcome to our world my friend\n\n[[center]][[green]][Klick Box]", "[[red]]Asya:\n[[white]]This is my brother portofolio's game\n\n[[center]][[green]][Klick Box]"}), - object.NewSilentNpc(64, 64, 7, 12, "asset_sprite/idle_npc/Elicia_Idle_full.png", 173, 257, + object.NewSilentNpc(64, 64, 7, 12, "game_asset/asset_sprite/idle_npc/Elicia_Idle_full.png", 173, 257, []string{"[[left]][[red]]Elicia:\n[[white]]@hq.han is my partner. He likes programming a lot\n\n[[center]][[green]][Klick Box]", "[[red]]Elicia:\n[[white]]Don't forget to give likes to this repo hihi\n\n[[center]][[green]][Klick Box]"}), - object.NewSilentNpc(64, 64, 3, 12, "asset_sprite/idle_npc/Sena_Idle_full.png", 386, 290, + object.NewSilentNpc(64, 64, 3, 12, "game_asset/asset_sprite/idle_npc/Sena_Idle_full.png", 386, 290, []string{"[[left]][[red]]Sena:\n[[white]]@hq.han is very talented and skillful programmer\n\n[[center]][[green]][Klick Box]", "[[red]]Sena:\n[[white]]He can code even without LLM and AI Code Generator\n\n[[center]][[green]][Klick Box]"}), }, + remotePlayers: make(map[string]*object.RemotePlayer), } } @@ -53,55 +57,114 @@ func (g *Game) Update() error { } g.camera.Update(g.player) helper.HandleInput() - //ngatasi bugh 2 kali panggil pakek flag + //ngatasi bugh 2 kali panggil (textbox) pakek flag helper.ResetInputFlag() + + //new features multiplayer + if ebiten.IsKeyPressed(ebiten.KeyM) && server.LocalPlayerID == "" { + if err := server.StartWebRTC(); err != nil { + log.Println("WebRTC start error:", err) + } + } + + // Real-time P2P sync + if server.LocalPlayerID != "" { + // Kirim posisi lokal + server.SendPosition(g.player.GetX(), g.player.GetY()) + + // Ambil snapshot posisi remote + remotePos := server.GetRemotePositions() + + // Update / tambah remote players + for id, pos := range remotePos { + if rp, ok := g.remotePlayers[id]; ok { + rp.UpdateAnimation(pos.X, pos.Y) + rp.SetX(pos.X) + rp.SetY(pos.Y) + } else { + g.remotePlayers[id] = object.NewRemotePlayer(pos.X, pos.Y) + log.Printf("New remote player %s at (%.2f, %.2f)", id, pos.X, pos.Y) + } + } + + // Hapus remote players yg sudah tidak ada + for id := range g.remotePlayers { + if _, exists := remotePos[id]; !exists { + delete(g.remotePlayers, id) + log.Printf("Remote player %s removed", id) + } + } + } + return nil } func (g *Game) Draw(screen *ebiten.Image) { g.world.Draw(screen, g.camera) - playerScreenY := (-g.camera.GetY() + g.player.GetY()) * g.camera.GetZoomFactor() - - //mekanisme draw order - var behind []interface { - Draw(screen *ebiten.Image, camera *object.Camera) - } - var front []interface { - Draw(screen *ebiten.Image, camera *object.Camera) + // Struct bantu untuk menyimpan draw function dan Y untuk urutan gambar + type drawableEntity struct { + drawFunc func(screen *ebiten.Image, camera *object.Camera) + drawOrderY float64 } - for _, silentNpc := range g.silentNpcs { - //aturnya cukup bdi pembagi silentNpc.GetFrameHeight() - npcCenterY := ((-g.camera.GetY() + silentNpc.GetY()) + float64(silentNpc.GetFrameHeight())/2) * g.camera.GetZoomFactor() - if playerScreenY > npcCenterY { - behind = append(behind, silentNpc) - } else { - front = append(front, silentNpc) - } + var entities []drawableEntity + + // Obstacle + for _, obs := range g.obstacles { + threshold := obs.GetHeight() - obs.GetHeight()/2.6 + drawY := obs.GetY() - obs.GetHeight()/2 + threshold + entities = append(entities, drawableEntity{ + drawFunc: func(screen *ebiten.Image, camera *object.Camera) { + obs.Draw(screen, camera) + }, + drawOrderY: drawY, + }) } - for _, obstacle := range g.obstacles { - //aturnya cukup bdi pembagi obstacle.GetHeight() - thresholdLocalY := obstacle.GetHeight() - obstacle.GetHeight()/2.6 - obstacleWorldThresholdY := obstacle.GetY() - obstacle.GetHeight()/2 + thresholdLocalY - obstacleScreenThresholdY := (-g.camera.GetY() + obstacleWorldThresholdY) * g.camera.GetZoomFactor() - if playerScreenY > obstacleScreenThresholdY { - behind = append(behind, obstacle) - } else { - front = append(front, obstacle) - } + // Silent NPC + for _, npc := range g.silentNpcs { + drawY := npc.GetY() + float64(npc.GetFrameHeight())/2 + entities = append(entities, drawableEntity{ + drawFunc: func(screen *ebiten.Image, camera *object.Camera) { + npc.Draw(screen, camera) + }, + drawOrderY: drawY, + }) } - for _, d := range behind { - d.Draw(screen, g.camera) + // Remote Players + for _, rp := range g.remotePlayers { + drawY := rp.GetY() + float64(8)/2 + entities = append(entities, drawableEntity{ + drawFunc: func(screen *ebiten.Image, camera *object.Camera) { + rp.Draw(screen, camera) + }, + drawOrderY: drawY, + }) } - g.player.Draw(screen, g.camera) - for _, d := range front { - d.Draw(screen, g.camera) + // Local Player + playerDrawY := g.player.GetY() + float64(8)/2 + entities = append(entities, drawableEntity{ + drawFunc: func(screen *ebiten.Image, camera *object.Camera) { + g.player.Draw(screen, camera) + }, + drawOrderY: playerDrawY, + }) + + // urutkan semua berdasarkan Y-nya (semakin kecil Y, semakin belakang) + sort.SliceStable(entities, func(i, j int) bool { + y1 := (-g.camera.GetY() + entities[i].drawOrderY) * g.camera.GetZoomFactor() + y2 := (-g.camera.GetY() + entities[j].drawOrderY) * g.camera.GetZoomFactor() + return y1 < y2 + }) + + for _, e := range entities { + e.drawFunc(screen, g.camera) } + // gambar UI atau dialog terakhir helper.DrawText(screen) } @@ -109,7 +172,7 @@ func (g *Game) Layout(int, int) (int, int) { return screenWidth, screenHeight } -//go:embed asset/Jersey10-Regular.ttf +//go:embed game_asset/asset/Jersey10-Regular.ttf var fontBytes []byte func main() { diff --git a/object/camera.go b/object/camera.go index 888c345..265b244 100644 --- a/object/camera.go +++ b/object/camera.go @@ -29,3 +29,7 @@ func (c *Camera) GetZoomFactor() float64 { func (c *Camera) GetY() float64 { return c.y } + +func (c *Camera) GetX() float64 { + return c.x +} diff --git a/object/player.go b/object/player.go index 607799f..a2894f7 100644 --- a/object/player.go +++ b/object/player.go @@ -9,7 +9,7 @@ import ( "math" ) -const ( +var ( speed = 1.2 frameWidth = 64 frameHeight = 64 @@ -26,7 +26,7 @@ type Player struct { } func NewPlayer(screenWidth, screenHeight float64) *Player { - img, _, err := ebitenutil.NewImageFromFile("asset_sprite/player/Unarmed_Walk_full.png") + img, _, err := ebitenutil.NewImageFromFile("game_asset/asset_sprite/player/Unarmed_Walk_full.png") if err != nil { log.Fatal(err) } @@ -53,6 +53,7 @@ func (p *Player) Update(world *World, obstacles []*Obstacle, silentNpcs []*Silen dx = speed direction = 2 } + if dx != 0 || dy != 0 { length := math.Hypot(dx, dy) dx, dy = dx/length*speed, dy/length*speed @@ -84,6 +85,7 @@ func (p *Player) Update(world *World, obstacles []*Obstacle, silentNpcs []*Silen dx = worldX - p.x dy = worldY - p.y + //ketika pakek mouse & touch, kemungkinan dx atau dy itu selalu !=0 karena sangat susah presisi length := math.Hypot(dx, dy) if length != 0 { dx = dx / length * speed @@ -160,7 +162,7 @@ func (p *Player) Draw(screen *ebiten.Image, camera *Camera) { scaleFactor := camera.zoomFactor op.GeoM.Scale(scaleFactor, scaleFactor) - op.GeoM.Translate(-frameWidth/2*scaleFactor, -frameHeight/2*scaleFactor) + op.GeoM.Translate(float64(-frameWidth)/2*scaleFactor, float64(-frameHeight)/2*scaleFactor) op.GeoM.Translate((p.x-camera.x)*camera.zoomFactor, (p.y-camera.y)*camera.zoomFactor) screen.DrawImage(p.image.SubImage(sourceRect).(*ebiten.Image), op) diff --git a/object/remotePlayer.go b/object/remotePlayer.go new file mode 100644 index 0000000..83d8566 --- /dev/null +++ b/object/remotePlayer.go @@ -0,0 +1,99 @@ +package object + +import ( + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/ebitenutil" + "image" + "log" + "math" +) + +type RemotePlayer struct { + x, y float64 + frameIndex int + image *ebiten.Image + lastX, lastY float64 + timer float64 + direction int +} + +func (rp *RemotePlayer) UpdateAnimation(newX, newY float64) { + const frameCount = 6 + + dx := newX - rp.lastX + dy := newY - rp.lastY + + if dx == 0 && dy == 0 { + rp.frameIndex = rp.direction*6 + 3 + return + } + + length := math.Hypot(dx, dy) + if length != 0 { + dx = dx / length * speed + dy = dy / length * speed + } + + if dx > 0 && math.Abs(dy) < math.Abs(dx) { + rp.direction = 2 + } else if dx < 0 && math.Abs(dy) < math.Abs(dx) { + rp.direction = 1 + } else if dy > 0 && math.Abs(dx) < math.Abs(dy) { + rp.direction = 0 + } else if dy < 0 && math.Abs(dx) < math.Abs(dy) { + rp.direction = 3 + } + + rp.timer += 0.1 + if rp.timer >= 0.5 { + rp.frameIndex = rp.direction*6 + (rp.frameIndex+1)%frameCount + rp.timer = 0 + } +} + +func NewRemotePlayer(x, y float64) *RemotePlayer { + img, _, err := ebitenutil.NewImageFromFile("game_asset/asset_sprite/player/Unarmed_Walk_full.png") + if err != nil { + log.Println("RemotePlayer image load error:", err) + img = ebiten.NewImage(64, 64) // fallback blank + } + return &RemotePlayer{ + x: x, + y: y, + image: img, + } +} + +func (rp *RemotePlayer) Draw(screen *ebiten.Image, camera *Camera) { + const ( + frameWidth = 64 + frameHeight = 64 + framesPerRow = 6 + ) + + frameX := (rp.frameIndex % framesPerRow) * frameWidth + frameY := (rp.frameIndex / framesPerRow) * frameHeight + sourceRect := image.Rect(frameX, frameY, frameX+frameWidth, frameY+frameHeight) + + op := &ebiten.DrawImageOptions{} + scaleFactor := camera.zoomFactor + op.GeoM.Scale(scaleFactor, scaleFactor) + op.GeoM.Translate(-frameWidth/2*scaleFactor, -frameHeight/2*scaleFactor) + op.GeoM.Translate((rp.x-camera.x)*scaleFactor, (rp.y-camera.y)*scaleFactor) + + screen.DrawImage(rp.image.SubImage(sourceRect).(*ebiten.Image), op) +} + +func (rp *RemotePlayer) GetY() float64 { + return rp.y +} + +func (rp *RemotePlayer) SetX(x float64) { + rp.lastX = rp.x + rp.x = x +} + +func (rp *RemotePlayer) SetY(y float64) { + rp.lastY = rp.y + rp.y = y +} diff --git a/object/world.go b/object/world.go index 054fc37..c482f9d 100644 --- a/object/world.go +++ b/object/world.go @@ -12,7 +12,7 @@ type World struct { } func NewWorld(width, height int) *World { - bg, _, err := ebitenutil.NewImageFromFile("asset_world/main-world.png") + bg, _, err := ebitenutil.NewImageFromFile("game_asset/asset_world/main-world.png") if err != nil { log.Fatal(err) } diff --git a/server/wsclient.go b/server/wsclient.go new file mode 100644 index 0000000..0218eb3 --- /dev/null +++ b/server/wsclient.go @@ -0,0 +1,258 @@ +package server + +import ( + "context" + "encoding/json" + "log" + "sync" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + "github.com/pion/webrtc/v4" +) + +var ( + signalConn *websocket.Conn + peerConns = make(map[string]*webrtc.PeerConnection) + dataChans = make(map[string]*webrtc.DataChannel) + LocalPlayerID string + remotePositions = make(map[string]Position) + positionsMux sync.Mutex + connsMux sync.Mutex +) + +type Position struct { + ID string `json:"id"` + X float64 `json:"x"` + Y float64 `json:"y"` +} + +type SignalMessage struct { + Type string `json:"type,omitempty"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + SDP *webrtc.SessionDescription `json:"sdp,omitempty"` + ICE *webrtc.ICECandidateInit `json:"ice,omitempty"` + Peers []string `json:"peers,omitempty"` +} + +func StartWebRTC() error { + ctx := context.Background() + var err error + + signalConn, _, err = websocket.Dial(ctx, "ws://localhost:8080/signal", nil) + if err != nil { + return err + } + + var initMsg SignalMessage + if err = wsjson.Read(ctx, signalConn, &initMsg); err != nil { + return err + } + + LocalPlayerID = initMsg.From + + log.Println(LocalPlayerID) + log.Println("Assigned LocalPlayerID:", LocalPlayerID) + + go signalingLoop(ctx) + + return nil +} + +func signalingLoop(ctx context.Context) { + for { + + var msg SignalMessage + if signalConn != nil { + //log.Println(signalConn) + if err := wsjson.Read(ctx, signalConn, &msg); err != nil { + return + } + + } else { + log.Println(signalConn) + } + + log.Println(msg.Type) + + switch msg.Type { + case "peers": + for _, id := range msg.Peers { + if id == LocalPlayerID { + continue + } + createPeerConnection(id, "offer") + offer, err := peerConns[id].CreateOffer(nil) + if err != nil { + return + } + peerConns[id].SetLocalDescription(offer) + sendSignal(SignalMessage{Type: "offer", From: LocalPlayerID, To: id, SDP: &offer}) + } + case "offer": + createPeerConnection(msg.From, "answer") + setRemoteDescription(msg.From, msg.SDP) + + answer, err := peerConns[msg.From].CreateAnswer(nil) + if err != nil { + return + } + if err = peerConns[msg.From].SetLocalDescription(answer); err != nil { + return + } + sendSignal(SignalMessage{Type: "answer", From: LocalPlayerID, To: msg.From, SDP: &answer}) + + case "answer": + setRemoteDescription(msg.From, msg.SDP) + + case "candidate": + addIceCandidate(msg.From, msg.ICE) + + } + } +} + +func createPeerConnection(remoteID string, typeMessage string) error { + connsMux.Lock() + defer connsMux.Unlock() + if _, exist := peerConns[remoteID]; exist { + return nil + } + + pc, err := webrtc.NewPeerConnection(webrtc.Configuration{ + ICEServers: []webrtc.ICEServer{{ + URLs: []string{"stun:stun.l.google.com:19302"}, + }}, + }) + if err != nil { + return err + } + + pc.OnICECandidate(func(candidate *webrtc.ICECandidate) { + if candidate == nil { + return + } + + ice := candidate.ToJSON() + sendSignal(SignalMessage{Type: "candidate", From: LocalPlayerID, To: remoteID, ICE: &ice}) + }) + + pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { + switch state { + case webrtc.PeerConnectionStateFailed, webrtc.PeerConnectionStateDisconnected, webrtc.PeerConnectionStateClosed: + removePeer(remoteID) + log.Println("Koneksi state :", remoteID, " hilang karena :", state.String()) + } + }) + + if typeMessage == "offer" { + dc, err := pc.CreateDataChannel("position", nil) + if err != nil { + return err + } + setupDataChannel(remoteID, dc) + dataChans[remoteID] = dc + } else { + pc.OnDataChannel(func(dc *webrtc.DataChannel) { + setupDataChannel(remoteID, dc) + dataChans[remoteID] = dc + }) + } + + peerConns[remoteID] = pc + return nil +} + +func sendSignal(msg SignalMessage) { + ctx := context.Background() + if signalConn == nil { + return + } + if err := wsjson.Write(ctx, signalConn, msg); err != nil { + log.Println("error waktu mengirim signal Type:", msg.Type, "; error:", err) + } +} + +func removePeer(remoteID string) { + connsMux.Lock() + defer connsMux.Unlock() + + if dc, exist := dataChans[remoteID]; exist { + dc.Close() + delete(dataChans, remoteID) + } + if pc, exist := peerConns[remoteID]; exist { + pc.Close() + delete(peerConns, remoteID) + } + + positionsMux.Lock() + delete(remotePositions, remoteID) + positionsMux.Unlock() +} + +func setupDataChannel(remoteID string, dc *webrtc.DataChannel) { + dc.OnOpen(func() { + log.Println("data channel dengan ", remoteID, " dibuka") + }) + + dc.OnMessage(func(msg webrtc.DataChannelMessage) { + var pos Position + if err := json.Unmarshal(msg.Data, &pos); err != nil { + log.Println("unmarshal pesan data channel gagal") + return + } + positionsMux.Lock() + remotePositions[pos.ID] = pos + positionsMux.Unlock() + }) +} + +func setRemoteDescription(remoteID string, sdp *webrtc.SessionDescription) { + connsMux.Lock() + defer connsMux.Unlock() + + if pc, exist := peerConns[remoteID]; exist { + if err := pc.SetRemoteDescription(*sdp); err != nil { + log.Println("error set remote description : ", err) + } + } +} + +func addIceCandidate(remoteID string, ice *webrtc.ICECandidateInit) { + connsMux.Lock() + defer connsMux.Unlock() + + if pc, exist := peerConns[remoteID]; exist { + if err := pc.AddICECandidate(*ice); err != nil { + log.Println("error tmbah ice candidate : ", err) + } + } +} + +func SendPosition(x float64, y float64) { + pos := &Position{ID: LocalPlayerID, X: x, Y: y} + data, _ := json.Marshal(pos) + connsMux.Lock() + defer connsMux.Unlock() + + for peerID, datChan := range dataChans { + if datChan.ReadyState() == webrtc.DataChannelStateOpen { + err := datChan.SendText(string(data)) + if err != nil { + log.Println("peer dengan id :", peerID, "gagal mengirim") + } + } + } +} + +func GetRemotePositions() map[string]Position { + positionsMux.Lock() + defer positionsMux.Unlock() + positionCpy := make(map[string]Position) + for key, val := range remotePositions { + positionCpy[key] = val + } + return positionCpy +}