Phase 7: netfox + godot-jolt stack upgrade
Stack installed: - netfox v1.35.3 (core + extras + noray + internals) - godot-jolt v0.16.0-stable Architecture: - Server: ENet transport (works headless, no netfox deps) - Client/Editor: netfox rollback (RollbackSynchronizer, TickInterpolator) New/modified: - docs/migration-netfox-plan.md — migration architecture - scripts/network/network_manager.gd — netfox-aware ENet fallback - scripts/network/player.gd — clean base player - client/characters/player_netfox.gd — rollback player w/ WeaponManager - client/characters/input/player_net_input.gd — BaseNetInput subclass - client/characters/character/fps_character_controller.gd — netfox input feed - client/weapons/ — weapon data, registry, TacticalWeaponHitscan, WeaponManager - client/scripts/round_replicator.gd — client-side round state bridge - server/scripts/round_manager.gd — improved state machine - server/scripts/plugin_api/plugin_manager.gd — refined plugin system - config: enemy_tag, ally_tag for meatball targeting Removed: old C++ SimulationServer GDExtension (replaced by netfox rollback)
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
# Contributors
|
||||
|
||||
This addon, and the entirety of [netfox] is a shared effort of [Fox's Sake
|
||||
Studio], and the community. The following is the list of community contributors
|
||||
involved with netfox:
|
||||
|
||||
* Alberto Klocker <albertok@gmail.com>
|
||||
* Andrew Davis <jonandrewdavis@gmail.com>
|
||||
* Bryan Lee <42545742+bryanmylee@users.noreply.github.com>
|
||||
* Dan Schuman <danschuman@gmail.com>
|
||||
* Dustie <77035922+DustieDog@users.noreply.github.com>
|
||||
* Eric Volpone <ericvolpone@gmail.com>
|
||||
* Gordon MacPherson <gordon@gordonite.tech>
|
||||
* Jake Cattrall <krazyjakee@gmail.com>
|
||||
* Jon Stevens <1030863+jonstvns@users.noreply.github.com>
|
||||
* Joseph Michael Ware <9at25jnr3@mozmail.com>
|
||||
* Matias Kumpulainen <kumpulainen.matias@gmail.com>
|
||||
* Nicolas Batty <nicolas.batty@gmail.com>
|
||||
* Ricky <77863853+RickyYCheng@users.noreply.github.com>
|
||||
* Riordan-DC <44295008+Riordan-DC@users.noreply.github.com>
|
||||
* Russ <622740+russ-@users.noreply.github.com>
|
||||
* Ryan Roden-Corrent <github@rcorre.net>
|
||||
* TheYellowArchitect <dmalandris@uth.gr>
|
||||
* TheYellowArchitect <hello@theyellowarchitect.com>
|
||||
* camperotactico <alberto.vgdd@gmail.com>
|
||||
* gk98s <89647115+gk98s@users.noreply.github.com>
|
||||
* zibetnu <9at25jnr3@mozmail.com>
|
||||
|
||||
[netfox]: https://github.com/foxssake/netfox
|
||||
[Fox's Sake Studio]: https://github.com/foxssake/
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
Copyright 2023 Gálffy Tamás
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,34 @@
|
||||
# netfox.extras
|
||||
|
||||
High-level, game-specific extras for [netfox]!
|
||||
|
||||
## Features
|
||||
|
||||
* 🔫 Networked weapons
|
||||
* ⌨️ Rollback-aware base class for input
|
||||
|
||||
## Overview
|
||||
|
||||
This addon is pretty much a catch-all project for things that are useful, but
|
||||
are not core to netfox.
|
||||
|
||||
## Install
|
||||
|
||||
See the root [README](../../README.md).
|
||||
|
||||
## Usage
|
||||
|
||||
See the [docs](https://foxssake.github.io/netfox/).
|
||||
|
||||
## License
|
||||
|
||||
netfox.extras is under the [MIT license](LICENSE).
|
||||
|
||||
## Issues
|
||||
|
||||
In case of any issues, comments, or questions, please feel free to [open an issue]!
|
||||
|
||||
[netfox]: https://github.com/foxssake/netfox
|
||||
[source]: https://github.com/foxssake/netfox/archive/refs/heads/main.zip
|
||||
[open an issue]: https://github.com/foxssake/netfox/issues
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
extends Node
|
||||
class_name BaseNetInput
|
||||
|
||||
## Base class for Input nodes used with rollback.
|
||||
|
||||
func _ready():
|
||||
NetworkTime.before_tick_loop.connect(func():
|
||||
if is_multiplayer_authority():
|
||||
_gather()
|
||||
)
|
||||
|
||||
## Method for gathering input.
|
||||
##
|
||||
## This method is supposed to be overridden with your input logic. The input
|
||||
## data itself may be gathered outside of this method ( e.g. gathering it over
|
||||
## multiple _process calls ), but this is the point where the input variables
|
||||
## must be set.
|
||||
##
|
||||
## [i]Note:[/i] This is only called for the local player's input nodes.
|
||||
func _gather():
|
||||
pass
|
||||
@@ -0,0 +1 @@
|
||||
uid://cpiuf65vuu0oa
|
||||
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
sodipodi:docname="network-rigid-body-2d.svg"
|
||||
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1" />
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="22.627417"
|
||||
inkscape:cx="-6.098796"
|
||||
inkscape:cy="12.705825"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1043"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg1"
|
||||
showgrid="true">
|
||||
<inkscape:grid
|
||||
id="grid1"
|
||||
units="px"
|
||||
originx="0"
|
||||
originy="0"
|
||||
spacingx="1"
|
||||
spacingy="1"
|
||||
empcolor="#0099e5"
|
||||
empopacity="0.30196078"
|
||||
color="#0099e5"
|
||||
opacity="0.14901961"
|
||||
empspacing="5"
|
||||
enabled="true"
|
||||
visible="true" />
|
||||
</sodipodi:namedview>
|
||||
<path
|
||||
fill="#8da5f3"
|
||||
d="M 8,1 A 7,7 0 0 0 8,15 7,7 0 0 0 8,1 Z M 9.951,2.334 A 6,6 0 0 1 14.001,8 h -7 A 2,2 0 0 0 6.051,6.3 C 7.397,4.271 8.655,3.037 9.951,2.333 Z m -6.828,2.18 c 0.146,0.65 0.358,1.283 0.684,1.884 A 2,2 0 0 0 3,8 H 2 A 6,6 0 0 1 3.123,4.514 Z M 3.8,9.6 a 2,2 0 0 0 2.4,0 c 1.472,2.027 2.728,3.264 3.8,4 A 6,6 0 0 1 2.85,11.1 c 0.25,-0.6 0.5,-1 0.95,-1.5 z"
|
||||
id="path1"
|
||||
style="stroke-width:1" />
|
||||
<g
|
||||
id="g9"
|
||||
transform="matrix(0.25,0,0,0.25,0,0.825)">
|
||||
<path
|
||||
id="path1_00000101101429107098172700000017609064003007162547_"
|
||||
class="st1"
|
||||
d="M 32,40.2 23.8,56.6 15.6,40.2 Z"
|
||||
style="fill:#5fff97" />
|
||||
<path
|
||||
id="path2_00000126280959979031010980000006949781421479937947_"
|
||||
class="st2"
|
||||
d="M 48.4,56.7 40.2,40.3 32,56.7 Z"
|
||||
style="fill:#ff5f5f" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
sodipodi:docname="network-rigid-body-3d.svg"
|
||||
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1" />
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="21.364726"
|
||||
inkscape:cx="21.109561"
|
||||
inkscape:cy="8.776148"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1043"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg1" />
|
||||
<path
|
||||
fill="#fc7f7f"
|
||||
d="M 8,1 A 7,7 0 0 0 8,15 7,7 0 0 0 8,1 Z M 9.951,2.334 A 6,6 0 0 1 14.001,8 h -7 A 2,2 0 0 0 6.051,6.3 C 7.397,4.271 8.655,3.037 9.951,2.333 Z m -6.828,2.18 c 0.146,0.65 0.358,1.283 0.684,1.884 A 2,2 0 0 0 3,8 H 2 A 6,6 0 0 1 3.123,4.514 Z M 3.8,9.6 a 2,2 0 0 0 2.4,0 c 1.472,2.027 2.728,3.264 3.8,4 A 6,6 0 0 1 2.85,11.1 c 0.25,-0.6 0.5,-1 0.95,-1.5 z"
|
||||
id="path1"
|
||||
style="stroke-width:1" />
|
||||
<g
|
||||
id="g9"
|
||||
transform="matrix(0.25,0,0,0.25,0,0.825)">
|
||||
<path
|
||||
id="path1_00000101101429107098172700000017609064003007162547_"
|
||||
class="st1"
|
||||
d="M 32,40.2 23.8,56.6 15.6,40.2 Z"
|
||||
style="fill:#5fff97" />
|
||||
<path
|
||||
id="path2_00000126280959979031010980000006949781421479937947_"
|
||||
class="st2"
|
||||
d="M 48.4,56.7 40.2,40.3 32,56.7 Z"
|
||||
style="fill:#ff5f5f" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,91 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
version="1.1"
|
||||
id="Layer_1"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 16 16"
|
||||
xml:space="preserve"
|
||||
sodipodi:docname="rewindable-state-machine.svg"
|
||||
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
|
||||
width="16"
|
||||
height="16"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs1"><inkscape:perspective
|
||||
sodipodi:type="inkscape:persp3d"
|
||||
inkscape:vp_x="0 : -47.5 : 1"
|
||||
inkscape:vp_y="0 : 15.625 : 0"
|
||||
inkscape:vp_z="1 : -47.5 : 1"
|
||||
inkscape:persp3d-origin="0.5 : -47.666667 : 1"
|
||||
id="perspective1" /></defs><sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="24.411443"
|
||||
inkscape:cx="2.5397925"
|
||||
inkscape:cy="9.4832576"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1043"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="Layer_1" />
|
||||
<style
|
||||
type="text/css"
|
||||
id="style1">
|
||||
.st0{fill:none;stroke:#E0E0E0;stroke-width:6.875;stroke-linecap:round;stroke-linejoin:round;}
|
||||
.st1{fill:#5FFF97;}
|
||||
.st2{fill:#FF5F5F;}
|
||||
.st3{opacity:0.8;}
|
||||
</style>
|
||||
|
||||
<path
|
||||
sodipodi:type="star"
|
||||
style="fill:none;stroke:#e0e0e0;stroke-width:10.42235363;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path1"
|
||||
inkscape:flatsided="true"
|
||||
sodipodi:sides="6"
|
||||
sodipodi:cx="8.0625"
|
||||
sodipodi:cy="8.0625"
|
||||
sodipodi:r1="27.830996"
|
||||
sodipodi:r2="24.102348"
|
||||
sodipodi:arg1="0.53701091"
|
||||
sodipodi:arg2="1.0606097"
|
||||
inkscape:rounded="-9.1940344e-17"
|
||||
inkscape:randomized="0"
|
||||
d="M 31.97605,22.3 7.6892381,35.890992 -16.224312,21.653492 -15.85105,-6.1750005 8.4357619,-19.765992 32.349312,-5.5284919 Z"
|
||||
transform="matrix(0.19189523,0,0,0.19189524,6.4528447,6.4528446)" />
|
||||
|
||||
|
||||
<circle
|
||||
style="fill:none;stroke:#e0e0e0;stroke-width:1;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path9"
|
||||
cx="7.9999995"
|
||||
cy="7.8039598"
|
||||
r="2.5" /><circle
|
||||
style="fill:none;stroke:#e0e0e0;stroke-width:1;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:0.66"
|
||||
id="circle9"
|
||||
cx="6.9999995"
|
||||
cy="7.8039598"
|
||||
r="2.5" /><circle
|
||||
style="fill:none;stroke:#e0e0e0;stroke-width:1;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:0.33"
|
||||
id="circle10"
|
||||
cx="5.9999995"
|
||||
cy="7.8039598"
|
||||
r="2.5" /><g
|
||||
id="g9"
|
||||
transform="matrix(0.25,0,0,0.25,0,-0.19604137)"><path
|
||||
id="path1_00000101101429107098172700000017609064003007162547_"
|
||||
class="st1"
|
||||
d="M 32,40.2 23.8,56.6 15.6,40.2 Z" /><path
|
||||
id="path2_00000126280959979031010980000006949781421479937947_"
|
||||
class="st2"
|
||||
d="M 48.4,56.7 40.2,40.3 32,56.7 Z" /></g></svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,124 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
version="1.1"
|
||||
id="Layer_1"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 16 16"
|
||||
xml:space="preserve"
|
||||
sodipodi:docname="rewindable-state.svg"
|
||||
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
|
||||
width="16"
|
||||
height="16"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs1"><inkscape:perspective
|
||||
sodipodi:type="inkscape:persp3d"
|
||||
inkscape:vp_x="0 : -47.5 : 1"
|
||||
inkscape:vp_y="0 : 15.625 : 0"
|
||||
inkscape:vp_z="1 : -47.5 : 1"
|
||||
inkscape:persp3d-origin="0.5 : -47.666667 : 1"
|
||||
id="perspective1" /></defs><sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="32"
|
||||
inkscape:cx="6.28125"
|
||||
inkscape:cy="8.328125"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1043"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="Layer_1" />
|
||||
<style
|
||||
type="text/css"
|
||||
id="style1">
|
||||
.st0{fill:none;stroke:#E0E0E0;stroke-width:6.875;stroke-linecap:round;stroke-linejoin:round;}
|
||||
.st1{fill:#5FFF97;}
|
||||
.st2{fill:#FF5F5F;}
|
||||
.st3{opacity:0.8;}
|
||||
</style>
|
||||
|
||||
<g
|
||||
id="g8"
|
||||
transform="matrix(0.25,0,0,0.25,10.94454,-0.19604083)"
|
||||
style="stroke:#e0e0e0;stroke-opacity:0.33;stroke-width:8;stroke-dasharray:none"><path
|
||||
sodipodi:type="star"
|
||||
style="fill:none;stroke:#e0e0e0;stroke-width:10.42235363;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:0.33"
|
||||
id="path7"
|
||||
inkscape:flatsided="true"
|
||||
sodipodi:sides="6"
|
||||
sodipodi:cx="8.0625"
|
||||
sodipodi:cy="8.0625"
|
||||
sodipodi:r1="27.830996"
|
||||
sodipodi:r2="24.102348"
|
||||
sodipodi:arg1="0.53701091"
|
||||
sodipodi:arg2="1.0606097"
|
||||
inkscape:rounded="-9.1940344e-17"
|
||||
inkscape:randomized="0"
|
||||
d="M 31.97605,22.3 7.6892381,35.890992 -16.224312,21.653492 -15.85105,-6.1750005 8.4357619,-19.765992 32.349312,-5.5284919 Z"
|
||||
transform="matrix(0.76758094,0,0,0.76758094,-21.96678,26.595542)" /><path
|
||||
style="fill:none;stroke:#e0e0e0;stroke-width:8;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:0.33"
|
||||
d="m -34.133744,21.855729 18.355585,10.928434 -0.286508,21.360621"
|
||||
id="path8" /></g><g
|
||||
id="g6"
|
||||
transform="matrix(0.25,0,0,0.25,11.94454,-0.19604083)"
|
||||
style="stroke:#e0e0e0;stroke-opacity:0.66;stroke-width:8;stroke-dasharray:none"><path
|
||||
sodipodi:type="star"
|
||||
style="fill:none;stroke:#e0e0e0;stroke-width:10.42235363;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:0.66"
|
||||
id="path5"
|
||||
inkscape:flatsided="true"
|
||||
sodipodi:sides="6"
|
||||
sodipodi:cx="8.0625"
|
||||
sodipodi:cy="8.0625"
|
||||
sodipodi:r1="27.830996"
|
||||
sodipodi:r2="24.102348"
|
||||
sodipodi:arg1="0.53701091"
|
||||
sodipodi:arg2="1.0606097"
|
||||
inkscape:rounded="-9.1940344e-17"
|
||||
inkscape:randomized="0"
|
||||
d="M 31.97605,22.3 7.6892381,35.890992 -16.224312,21.653492 -15.85105,-6.1750005 8.4357619,-19.765992 32.349312,-5.5284919 Z"
|
||||
transform="matrix(0.76758094,0,0,0.76758094,-21.96678,26.595542)" /><path
|
||||
style="fill:none;stroke:#e0e0e0;stroke-width:8;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:0.66"
|
||||
d="m -34.133744,21.855729 18.355585,10.928434 -0.286508,21.360621"
|
||||
id="path6" /></g><g
|
||||
id="g4"
|
||||
transform="matrix(0.25,0,0,0.25,12.94454,-0.19604083)"
|
||||
style="stroke-width:8;stroke-dasharray:none"><path
|
||||
sodipodi:type="star"
|
||||
style="fill:none;stroke:#e0e0e0;stroke-width:10.42235363;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path1"
|
||||
inkscape:flatsided="true"
|
||||
sodipodi:sides="6"
|
||||
sodipodi:cx="8.0625"
|
||||
sodipodi:cy="8.0625"
|
||||
sodipodi:r1="27.830996"
|
||||
sodipodi:r2="24.102348"
|
||||
sodipodi:arg1="0.53701091"
|
||||
sodipodi:arg2="1.0606097"
|
||||
inkscape:rounded="-9.1940344e-17"
|
||||
inkscape:randomized="0"
|
||||
d="M 31.97605,22.3 7.6892381,35.890992 -16.224312,21.653492 -15.85105,-6.1750005 8.4357619,-19.765992 32.349312,-5.5284919 Z"
|
||||
transform="matrix(0.76758094,0,0,0.76758094,-21.96678,26.595542)" /><path
|
||||
style="fill:none;stroke:#e0e0e0;stroke-width:8;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m -34.133744,21.855729 18.355585,10.928434 -0.286508,21.360621"
|
||||
id="path4" /></g><path
|
||||
id="path1_00000101101429107098172700000017609064003007162547_"
|
||||
class="st1"
|
||||
d="m 7.9999998,9.8539592 -2.05,4.0999998 -2.0499999,-4.0999998 z"
|
||||
style="stroke-width:2;stroke-dasharray:none" />
|
||||
<path
|
||||
id="path2_00000126280959979031010980000006949781421479937947_"
|
||||
class="st2"
|
||||
d="M 12.1,13.978959 10.05,9.8789592 7.9999998,13.978959 Z"
|
||||
style="stroke-width:2;stroke-dasharray:none" />
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,187 @@
|
||||
@tool
|
||||
extends EditorPlugin
|
||||
|
||||
const ROOT = "res://addons/netfox.extras"
|
||||
|
||||
var SETTINGS = [
|
||||
NetfoxLogger._make_setting("netfox/logging/netfox_extras_log_level"),
|
||||
|
||||
# Window Tiler Settings
|
||||
{
|
||||
"name": "netfox/extras/auto_tile_windows",
|
||||
"value": false,
|
||||
"type": TYPE_BOOL
|
||||
},
|
||||
{
|
||||
"name": "netfox/extras/screen",
|
||||
"value": 0,
|
||||
"type": TYPE_INT
|
||||
},
|
||||
{
|
||||
"name": "netfox/extras/borderless",
|
||||
"value": false,
|
||||
"type": TYPE_BOOL
|
||||
},
|
||||
|
||||
# Autoconnect settings
|
||||
{
|
||||
"name": "netfox/autoconnect/enabled",
|
||||
"value": false,
|
||||
"type": TYPE_BOOL
|
||||
},
|
||||
{
|
||||
"name": "netfox/autoconnect/host",
|
||||
"value": "127.0.0.1",
|
||||
"type": TYPE_STRING
|
||||
},
|
||||
{
|
||||
"name": "netfox/autoconnect/port",
|
||||
"value": 9999,
|
||||
"type": TYPE_INT,
|
||||
"hint": PROPERTY_HINT_RANGE,
|
||||
"hint_string": "1,65535,hide_slider"
|
||||
},
|
||||
{
|
||||
"name": "netfox/autoconnect/use_compression",
|
||||
"value": false,
|
||||
"type": TYPE_BOOL
|
||||
},
|
||||
{
|
||||
"name": "netfox/autoconnect/simulated_latency_ms",
|
||||
"value": 0.0,
|
||||
"type": TYPE_INT,
|
||||
"hint": PROPERTY_HINT_RANGE,
|
||||
"hint_string": "0,200,or_greater"
|
||||
},
|
||||
{
|
||||
"name": "netfox/autoconnect/simulated_packet_loss_chance",
|
||||
"value": 0.0,
|
||||
"type": TYPE_FLOAT,
|
||||
"hint": PROPERTY_HINT_RANGE,
|
||||
"hint_string": "0,1"
|
||||
}
|
||||
]
|
||||
|
||||
const AUTOLOADS = [
|
||||
{
|
||||
"name": "WindowTiler",
|
||||
"path": ROOT + "/window-tiler.gd"
|
||||
},
|
||||
{
|
||||
"name": "NetworkSimulator",
|
||||
"path": ROOT + "/network-simulator.gd"
|
||||
}
|
||||
]
|
||||
|
||||
const TYPES: Array[Dictionary] = [
|
||||
{
|
||||
"name": "RewindableStateMachine",
|
||||
"base": "Node",
|
||||
"script": ROOT + "/state-machine/rewindable-state-machine.gd",
|
||||
"icon": ROOT + "/icons/rewindable-state-machine.svg"
|
||||
},
|
||||
{
|
||||
"name": "RewindableState",
|
||||
"base": "Node",
|
||||
"script": ROOT + "/state-machine/rewindable-state.gd",
|
||||
"icon": ROOT + "/icons/rewindable-state.svg"
|
||||
},
|
||||
{
|
||||
"name": "NetworkRigidBody2D",
|
||||
"base": "RigidBody2D",
|
||||
"script": ROOT + "/physics/network-rigid-body-2d.gd",
|
||||
"icon": ROOT + "/icons/network-rigid-body-2d.svg"
|
||||
},
|
||||
{
|
||||
"name": "NetworkRigidBody3D",
|
||||
"base": "RigidBody3D",
|
||||
"script": ROOT + "/physics/network-rigid-body-3d.gd",
|
||||
"icon": ROOT + "/icons/network-rigid-body-3d.svg"
|
||||
},
|
||||
]
|
||||
|
||||
const PhysicsDriverToggles := preload("res://addons/netfox.extras/physics/physics-driver-toggles.gd")
|
||||
|
||||
var _tool_menu_items := [] as Array[String]
|
||||
|
||||
func _enter_tree():
|
||||
for setting in SETTINGS:
|
||||
add_setting(setting)
|
||||
|
||||
for autoload in AUTOLOADS:
|
||||
add_autoload_singleton(autoload.name, autoload.path)
|
||||
|
||||
for type in TYPES:
|
||||
add_custom_type(type.name, type.base, load(type.script), load(type.icon))
|
||||
|
||||
_render_tool_menu()
|
||||
|
||||
func _exit_tree():
|
||||
if ProjectSettings.get_setting("netfox/general/clear_settings", false):
|
||||
for setting in SETTINGS:
|
||||
remove_setting(setting)
|
||||
|
||||
for autoload in AUTOLOADS:
|
||||
remove_autoload_singleton(autoload.name)
|
||||
|
||||
for type in TYPES:
|
||||
remove_custom_type(type.name)
|
||||
|
||||
_free_tool_menu()
|
||||
|
||||
func add_setting(setting: Dictionary):
|
||||
if ProjectSettings.has_setting(setting.name):
|
||||
return
|
||||
|
||||
ProjectSettings.set_setting(setting.name, setting.value)
|
||||
ProjectSettings.set_initial_value(setting.name, setting.value)
|
||||
ProjectSettings.add_property_info({
|
||||
"name": setting.get("name"),
|
||||
"type": setting.get("type"),
|
||||
"hint": setting.get("hint", PROPERTY_HINT_NONE),
|
||||
"hint_string": setting.get("hint_string", "")
|
||||
})
|
||||
|
||||
func remove_setting(setting: Dictionary):
|
||||
if not ProjectSettings.has_setting(setting.name):
|
||||
return
|
||||
|
||||
ProjectSettings.clear(setting.name)
|
||||
|
||||
func _render_tool_menu():
|
||||
_free_tool_menu()
|
||||
for driver_toggle in PhysicsDriverToggles.all():
|
||||
var prefix := "Enable" if not driver_toggle.is_enabled() else "Disable"
|
||||
var item := "%s %s physics driver" % [prefix, driver_toggle.get_name()]
|
||||
|
||||
_tool_menu_items.append(item)
|
||||
|
||||
add_tool_menu_item(item, func():
|
||||
# Toggle physics driver, then re-render menu to show changes
|
||||
_call_physics_driver_toggle(driver_toggle)
|
||||
_render_tool_menu()
|
||||
)
|
||||
|
||||
func _free_tool_menu():
|
||||
for item in _tool_menu_items:
|
||||
remove_tool_menu_item(item)
|
||||
_tool_menu_items.clear()
|
||||
|
||||
func _call_physics_driver_toggle(driver_toggle: PhysicsDriverToggles.PhysicsDriverToggle):
|
||||
var error_messages := driver_toggle.toggle()
|
||||
if not error_messages.is_empty():
|
||||
var error_text := "\n".join(error_messages)
|
||||
|
||||
var dialog := AcceptDialog.new()
|
||||
dialog.title = "Physics driver toggle failed!"
|
||||
dialog.dialog_text = error_text
|
||||
|
||||
get_editor_interface().popup_dialog_centered(dialog)
|
||||
else:
|
||||
var dialog := AcceptDialog.new()
|
||||
dialog.title = "Physics driver toggle success!"
|
||||
dialog.dialog_text = ("%s physics driver was successfully toggled! " +
|
||||
"You might need to refresh your script or reload project.") %\
|
||||
[driver_toggle.get_name()]
|
||||
|
||||
get_editor_interface().popup_dialog_centered(dialog)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ee1cb3bj7opr
|
||||
@@ -0,0 +1,238 @@
|
||||
extends Node
|
||||
|
||||
## Network Simulator
|
||||
##
|
||||
## Auto connects launched instances and simulates network conditions like
|
||||
## latency and packet loss. To use simply add this node to your scene tree and
|
||||
## hook up the signals.
|
||||
|
||||
## Signal emitted on the instance that successfully started a server.
|
||||
## Can be used for custom initialization logic, e.g. automatic login during
|
||||
## testing.
|
||||
signal server_created
|
||||
|
||||
## Signal emitted on instances that successfully connected as a client.
|
||||
## Can be used for custom initialization logic, e.g. automatic login during
|
||||
## testing.
|
||||
signal client_connected
|
||||
|
||||
## Enable to automatically host and connect on start
|
||||
var enabled: bool = false
|
||||
## Server listening address. Use [code]*[/code] for all interfaces, or
|
||||
#3 [code]127.0.0.1[/code] for localhost.
|
||||
var hostname: String = "127.0.0.1"
|
||||
|
||||
## Server port to listen on, UDP proxy will use port + 1 if simulating latency
|
||||
## or packet loss
|
||||
var server_port: int = 9999
|
||||
|
||||
## Use ENet's built-in range encoding for compression
|
||||
var use_compression: bool = true
|
||||
|
||||
## Simulated latency in milliseconds. Total ping time will be double this value
|
||||
## (to and from)
|
||||
var latency_ms: int = 0
|
||||
## Simulated packet loss percentage
|
||||
var packet_loss_percent: float = 0.0
|
||||
|
||||
static var _logger: NetfoxLogger = NetfoxLogger._for_extras("NetworkSimulator")
|
||||
|
||||
var _enet_peer: ENetMultiplayerPeer = ENetMultiplayerPeer.new()
|
||||
|
||||
# UDP proxy
|
||||
var _proxy_thread: Thread
|
||||
var _proxy_loop_enabled := true
|
||||
var _udp_proxy_server: PacketPeerUDP
|
||||
var _udp_proxy_port: int
|
||||
var _rng_packet_loss: RandomNumberGenerator = RandomNumberGenerator.new()
|
||||
|
||||
# Connection tracking
|
||||
var _client_peers: Dictionary = {} # port to PacketPeerUDP
|
||||
var _client_to_server_queue: Array[QueueEntry] = []
|
||||
var _server_to_client_queue: Array[QueueEntry] = []
|
||||
|
||||
class QueueEntry:
|
||||
var packet_data: PackedByteArray
|
||||
var queued_at: int
|
||||
var source_port: int # Which client port this came from
|
||||
|
||||
func _init(packet: PackedByteArray, timestamp: int, port: int) -> void:
|
||||
self.packet_data = packet
|
||||
self.queued_at = timestamp
|
||||
self.source_port = port
|
||||
|
||||
func _ready() -> void:
|
||||
# Check if enabled
|
||||
if not OS.has_feature("editor"):
|
||||
_logger.debug("Running outside editor, disabling")
|
||||
return
|
||||
|
||||
_load_project_settings()
|
||||
if not enabled:
|
||||
_logger.debug("Feature disabled")
|
||||
return
|
||||
|
||||
for env_var in ["CI", "NETFOX_CI", "NETFOX_NO_AUTOCONNECT"]:
|
||||
if OS.get_environment(env_var) != "":
|
||||
_logger.debug("Environment variable %s set, disabling", [env_var])
|
||||
return
|
||||
|
||||
await get_tree().process_frame
|
||||
_udp_proxy_port = server_port + 1
|
||||
|
||||
var status = _try_and_host()
|
||||
if status == Error.ERR_CANT_CREATE:
|
||||
_try_and_join()
|
||||
elif status != OK:
|
||||
_logger.error("Autoconnect failed with error - %s", [error_string(status)])
|
||||
|
||||
if use_compression:
|
||||
_enet_peer.host.compress(ENetConnection.COMPRESS_RANGE_CODER)
|
||||
|
||||
multiplayer.multiplayer_peer = _enet_peer
|
||||
|
||||
func _is_proxy_required() -> bool:
|
||||
return latency_ms > 0 or packet_loss_percent > 0.0
|
||||
|
||||
func _try_and_host() -> Error:
|
||||
var status = _enet_peer.create_server(server_port)
|
||||
if status == OK:
|
||||
if _is_proxy_required():
|
||||
_start_udp_proxy()
|
||||
server_created.emit()
|
||||
_logger.info("Server started on port %s", [server_port])
|
||||
return status
|
||||
|
||||
func _try_and_join() -> Error:
|
||||
var connect_port = server_port
|
||||
if _is_proxy_required():
|
||||
connect_port = _udp_proxy_port
|
||||
var status = _enet_peer.create_client(hostname, connect_port)
|
||||
if status == OK:
|
||||
client_connected.emit()
|
||||
_logger.info("Client connected to %s:%s", [hostname, connect_port])
|
||||
return status
|
||||
|
||||
# Starts a UDP proxy server to simulate network conditions
|
||||
# This will listen on _udp_proxy_port and forward packets to the server_port
|
||||
# Runs on its own thread to avoid blocking the main thread
|
||||
func _start_udp_proxy() -> void:
|
||||
_proxy_thread = Thread.new()
|
||||
_udp_proxy_server = PacketPeerUDP.new()
|
||||
|
||||
var bind_status = _udp_proxy_server.bind(_udp_proxy_port, hostname)
|
||||
if bind_status != OK:
|
||||
_logger.error("Failed to bind UDP proxy port: ", bind_status)
|
||||
return
|
||||
|
||||
_proxy_thread.start(_process_loop)
|
||||
|
||||
func _process_packets() -> void:
|
||||
var current_time: int = Time.get_ticks_msec()
|
||||
var send_threshold: int = current_time - latency_ms
|
||||
|
||||
_read_client_to_server_packets(current_time)
|
||||
_process_client_to_server_packets(send_threshold)
|
||||
|
||||
if not _client_peers.is_empty():
|
||||
_read_server_to_client_packets(current_time)
|
||||
_process_server_to_client_queue(send_threshold)
|
||||
|
||||
func _process_loop():
|
||||
while _proxy_loop_enabled:
|
||||
_process_packets()
|
||||
OS.delay_msec(1)
|
||||
|
||||
func _load_project_settings() -> void:
|
||||
enabled = ProjectSettings.get_setting(&"netfox/autoconnect/enabled", false)
|
||||
hostname = ProjectSettings.get_setting(&"netfox/autoconnect/host", "127.0.0.1")
|
||||
server_port = ProjectSettings.get_setting(&"netfox/autoconnect/port", 9999)
|
||||
use_compression = ProjectSettings.get_setting(&"netfox/autoconnect/use_compression", false)
|
||||
latency_ms = ProjectSettings.get_setting(&"netfox/autoconnect/simulated_latency_ms", 0)
|
||||
packet_loss_percent = ProjectSettings.get_setting(&"netfox/autoconnect/simulated_packet_loss_chance", 0.0)
|
||||
|
||||
func _is_data_available() -> bool:
|
||||
if _udp_proxy_server.get_available_packet_count() > 0:
|
||||
return true
|
||||
|
||||
if not _client_to_server_queue.is_empty() or not _server_to_client_queue.is_empty():
|
||||
return true
|
||||
|
||||
# Check if any client peers have packets waiting
|
||||
for client_peer in _client_peers.values():
|
||||
if client_peer.get_available_packet_count() > 0:
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
func _read_client_to_server_packets(current_time: int) -> void:
|
||||
while _udp_proxy_server.get_available_packet_count() > 0:
|
||||
var packet = _udp_proxy_server.get_packet()
|
||||
var err = _udp_proxy_server.get_packet_error()
|
||||
|
||||
if err != OK:
|
||||
_logger.error("UDP proxy incoming packet error: ", err)
|
||||
continue
|
||||
|
||||
var from_port = _udp_proxy_server.get_packet_port()
|
||||
_register_client_if_new(from_port)
|
||||
|
||||
_client_to_server_queue.push_back(QueueEntry.new(packet, current_time, from_port))
|
||||
|
||||
func _register_client_if_new(port: int) -> void:
|
||||
if _client_peers.has(port):
|
||||
return
|
||||
|
||||
# Create a dedicated peer for this client
|
||||
var client_peer = PacketPeerUDP.new()
|
||||
client_peer.set_dest_address(hostname, server_port)
|
||||
_client_peers[port] = client_peer
|
||||
|
||||
func _process_client_to_server_packets(send_threshold: int) -> void:
|
||||
var packets_to_keep: Array[QueueEntry] = []
|
||||
|
||||
for entry in _client_to_server_queue:
|
||||
if send_threshold < entry.queued_at:
|
||||
packets_to_keep.append(entry)
|
||||
else:
|
||||
if _should_send_packet():
|
||||
var peer = _client_peers[entry.source_port] as PacketPeerUDP
|
||||
peer.put_packet(entry.packet_data)
|
||||
|
||||
_client_to_server_queue = packets_to_keep
|
||||
|
||||
func _read_server_to_client_packets(current_time: int) -> void:
|
||||
for client_port in _client_peers.keys():
|
||||
var client_peer = _client_peers[client_port] as PacketPeerUDP
|
||||
|
||||
while client_peer.get_available_packet_count() > 0:
|
||||
var packet = client_peer.get_packet()
|
||||
var err = client_peer.get_packet_error()
|
||||
|
||||
if err != OK:
|
||||
_logger.error("UDP proxy server-to-client packet error from port %s : %s", [client_port, err])
|
||||
continue
|
||||
|
||||
_server_to_client_queue.push_back(QueueEntry.new(packet, current_time, client_port))
|
||||
|
||||
func _process_server_to_client_queue(send_threshold: int) -> void:
|
||||
var packets_to_keep: Array[QueueEntry] = []
|
||||
|
||||
for entry in _server_to_client_queue:
|
||||
if send_threshold < entry.queued_at:
|
||||
packets_to_keep.append(entry)
|
||||
else:
|
||||
if _should_send_packet():
|
||||
_udp_proxy_server.set_dest_address(hostname, entry.source_port)
|
||||
_udp_proxy_server.put_packet(entry.packet_data)
|
||||
|
||||
_server_to_client_queue = packets_to_keep
|
||||
|
||||
# Send packet or simulate loss
|
||||
func _should_send_packet() -> bool:
|
||||
return packet_loss_percent <= 0.0 or _rng_packet_loss.randf() >= (packet_loss_percent / 100.0)
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if _proxy_thread and _proxy_thread.is_started():
|
||||
_proxy_loop_enabled = false
|
||||
_proxy_thread.wait_to_finish()
|
||||
@@ -0,0 +1 @@
|
||||
uid://1y2ey1e6qjwr
|
||||
@@ -0,0 +1,72 @@
|
||||
extends PhysicsDriver
|
||||
|
||||
class_name PhysicsDriver2D
|
||||
|
||||
# Physics driver based on netfox ticks
|
||||
# Requires a custom build of Godot with https://github.com/godotengine/godot/pull/76462
|
||||
|
||||
var scene_collision_objects: Array = []
|
||||
var collision_objects_snapshots: Dictionary[int, Dictionary] = {}
|
||||
|
||||
func _init_physics_space() -> void:
|
||||
physics_space = get_viewport().world_2d.space
|
||||
PhysicsServer2D.space_set_active(physics_space, false)
|
||||
|
||||
get_tree().node_added.connect(node_added)
|
||||
get_tree().node_removed.connect(node_removed)
|
||||
scan_tree()
|
||||
|
||||
func _physics_step(delta) -> void:
|
||||
PhysicsServer2D.space_flush_queries(physics_space)
|
||||
PhysicsServer2D.space_step(physics_space, delta)
|
||||
|
||||
func _snapshot_space(tick: int) -> void:
|
||||
var rid_states: Dictionary[RID, Array] = {}
|
||||
for element in scene_collision_objects:
|
||||
if element is CharacterBody2D:
|
||||
element.force_update_transform() # force colliders to update
|
||||
|
||||
var rid = element.get_rid()
|
||||
rid_states[rid] = get_body_states(rid)
|
||||
snapshots[tick] = rid_states
|
||||
|
||||
func _rollback_space(tick) -> void:
|
||||
if snapshots.has(tick):
|
||||
var rid_states = snapshots[tick]
|
||||
for rid in rid_states.keys():
|
||||
set_body_states(rid, rid_states[rid])
|
||||
|
||||
for body in scene_collision_objects:
|
||||
if body is CharacterBody2D or body is AnimatableBody2D:
|
||||
body.force_update_transform() # force colliders to update
|
||||
|
||||
func get_body_states(rid: RID) -> Array:
|
||||
var body_state: Array = [Vector3.ZERO, Quaternion.IDENTITY, Vector3.ZERO, Vector3.ZERO]
|
||||
body_state[0] = PhysicsServer2D.body_get_state(rid, PhysicsServer2D.BODY_STATE_TRANSFORM)
|
||||
body_state[1] = PhysicsServer2D.body_get_state(rid, PhysicsServer2D.BODY_STATE_LINEAR_VELOCITY)
|
||||
body_state[2] = PhysicsServer2D.body_get_state(rid, PhysicsServer2D.BODY_STATE_ANGULAR_VELOCITY)
|
||||
body_state[3] = PhysicsServer2D.body_get_state(rid, PhysicsServer2D.BODY_STATE_SLEEPING)
|
||||
return body_state
|
||||
|
||||
func set_body_states(rid: RID, body_state: Array) -> void:
|
||||
PhysicsServer2D.body_set_state(rid, PhysicsServer2D.BODY_STATE_TRANSFORM, body_state[0])
|
||||
PhysicsServer2D.body_set_state(rid, PhysicsServer2D.BODY_STATE_LINEAR_VELOCITY, body_state[1])
|
||||
PhysicsServer2D.body_set_state(rid, PhysicsServer2D.BODY_STATE_ANGULAR_VELOCITY, body_state[2])
|
||||
PhysicsServer2D.body_set_state(rid, PhysicsServer2D.BODY_STATE_SLEEPING, body_state[3])
|
||||
|
||||
func scan_tree():
|
||||
scene_collision_objects.clear()
|
||||
scene_collision_objects = get_all_children(get_node('/root'))
|
||||
|
||||
func get_all_children(in_node: Node) -> Array:
|
||||
var nodes = []
|
||||
nodes = in_node.find_children("*", "PhysicsBody2D", true, false)
|
||||
return nodes
|
||||
|
||||
func node_added(node: Node) -> void:
|
||||
if node is PhysicsBody2D:
|
||||
scene_collision_objects.append(node)
|
||||
|
||||
func node_removed(node: Node) -> void:
|
||||
if node is PhysicsBody2D:
|
||||
scene_collision_objects.erase(node)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c8p8gymii2y5v
|
||||
@@ -0,0 +1,71 @@
|
||||
extends PhysicsDriver
|
||||
class_name PhysicsDriver3D
|
||||
|
||||
# Physics driver based on netfox ticks
|
||||
# Requires a custom build of Godot with https://github.com/godotengine/godot/pull/76462
|
||||
|
||||
# Maps ticks ( int ) to global snapshots ( Dictionary<RID, Array> )
|
||||
var scene_collision_objects: Array = []
|
||||
|
||||
func _init_physics_space() -> void:
|
||||
physics_space = get_viewport().world_3d.space
|
||||
PhysicsServer3D.space_set_active(physics_space, false)
|
||||
|
||||
get_tree().node_added.connect(node_added)
|
||||
get_tree().node_removed.connect(node_removed)
|
||||
scan_tree()
|
||||
|
||||
func _physics_step(delta) -> void:
|
||||
PhysicsServer3D.space_flush_queries(physics_space)
|
||||
PhysicsServer3D.space_step(physics_space, delta)
|
||||
|
||||
func _snapshot_space(tick: int) -> void:
|
||||
# Maps RIDs to physics state ( Array )
|
||||
var rid_states := {}
|
||||
for element in scene_collision_objects:
|
||||
var rid = element.get_rid()
|
||||
rid_states[rid] = get_body_states(rid)
|
||||
|
||||
snapshots[tick] = rid_states
|
||||
|
||||
func _rollback_space(tick) -> void:
|
||||
if snapshots.has(tick):
|
||||
var rid_states = snapshots[tick]
|
||||
for rid in rid_states.keys():
|
||||
set_body_states(rid, rid_states[rid])
|
||||
|
||||
for body in scene_collision_objects:
|
||||
if body is CharacterBody3D or body is AnimatableBody3D:
|
||||
body.force_update_transform()
|
||||
|
||||
|
||||
func get_body_states(rid: RID) -> Array:
|
||||
var body_state: Array = [Vector3.ZERO, Quaternion.IDENTITY, Vector3.ZERO, Vector3.ZERO]
|
||||
body_state[0] = PhysicsServer3D.body_get_state(rid, PhysicsServer3D.BODY_STATE_TRANSFORM)
|
||||
body_state[1] = PhysicsServer3D.body_get_state(rid, PhysicsServer3D.BODY_STATE_LINEAR_VELOCITY)
|
||||
body_state[2] = PhysicsServer3D.body_get_state(rid, PhysicsServer3D.BODY_STATE_ANGULAR_VELOCITY)
|
||||
body_state[3] = PhysicsServer3D.body_get_state(rid, PhysicsServer3D.BODY_STATE_SLEEPING)
|
||||
return body_state
|
||||
|
||||
func set_body_states(rid: RID, body_state: Array) -> void:
|
||||
PhysicsServer3D.body_set_state(rid, PhysicsServer3D.BODY_STATE_TRANSFORM, body_state[0])
|
||||
PhysicsServer3D.body_set_state(rid, PhysicsServer3D.BODY_STATE_LINEAR_VELOCITY, body_state[1])
|
||||
PhysicsServer3D.body_set_state(rid, PhysicsServer3D.BODY_STATE_ANGULAR_VELOCITY, body_state[2])
|
||||
PhysicsServer3D.body_set_state(rid, PhysicsServer3D.BODY_STATE_SLEEPING, body_state[3])
|
||||
|
||||
func scan_tree():
|
||||
scene_collision_objects.clear()
|
||||
scene_collision_objects = get_all_children(get_node('/root'))
|
||||
|
||||
func get_all_children(in_node: Node) -> Array:
|
||||
var nodes = []
|
||||
nodes = in_node.find_children("*", "PhysicsBody3D", true, false)
|
||||
return nodes
|
||||
|
||||
func node_added(node: Node) -> void:
|
||||
if node is PhysicsBody3D:
|
||||
scene_collision_objects.append(node)
|
||||
|
||||
func node_removed(node: Node) -> void:
|
||||
if node is PhysicsBody3D:
|
||||
scene_collision_objects.erase(node)
|
||||
@@ -0,0 +1 @@
|
||||
uid://wr5ur5dqrkni
|
||||
@@ -0,0 +1,44 @@
|
||||
@icon("res://addons/netfox.extras/icons/network-rigid-body-2d.svg")
|
||||
extends RigidBody2D
|
||||
class_name NetworkRigidBody2D
|
||||
|
||||
## A rollback / state synchronizer class for RigidBody2D.
|
||||
## Set state property path to physics_state to synchronize the state of this body.
|
||||
|
||||
@onready var direct_state = PhysicsServer2D.body_get_direct_state(get_rid())
|
||||
|
||||
var physics_state: Array:
|
||||
get: return get_state()
|
||||
set(v): set_state(v)
|
||||
|
||||
enum {
|
||||
ORIGIN,
|
||||
ROT,
|
||||
LIN_VEL,
|
||||
ANG_VEL,
|
||||
SLEEPING
|
||||
}
|
||||
|
||||
func _notification(notification: int):
|
||||
if notification == NOTIFICATION_READY:
|
||||
add_to_group("network_rigid_body")
|
||||
|
||||
func get_state() -> Array:
|
||||
var body_state: Array = [Vector3.ZERO, Quaternion.IDENTITY, Vector3.ZERO, Vector3.ZERO, false]
|
||||
body_state[ORIGIN] = direct_state.transform.origin
|
||||
body_state[ROT] = direct_state.transform.get_rotation()
|
||||
body_state[LIN_VEL] = direct_state.linear_velocity
|
||||
body_state[ANG_VEL] = direct_state.angular_velocity
|
||||
body_state[SLEEPING] = direct_state.sleeping
|
||||
return body_state
|
||||
|
||||
func set_state(remote_state: Array) -> void:
|
||||
direct_state.transform = Transform2D(remote_state[ROT], remote_state[ORIGIN])
|
||||
direct_state.linear_velocity = remote_state[LIN_VEL]
|
||||
direct_state.angular_velocity = remote_state[ANG_VEL]
|
||||
direct_state.sleeping = remote_state[SLEEPING]
|
||||
|
||||
## Override and apply any logic, forces or impulses to the rigid body as you would in physics_process
|
||||
## The physics engine will run its simulation during rollback_tick with other nodes
|
||||
func _physics_rollback_tick(_delta, _tick):
|
||||
pass
|
||||
@@ -0,0 +1 @@
|
||||
uid://c8hw7ol53m55g
|
||||
@@ -0,0 +1,46 @@
|
||||
@icon("res://addons/netfox.extras/physics/network-rigid-body-3d.gd")
|
||||
extends RigidBody3D
|
||||
class_name NetworkRigidBody3D
|
||||
|
||||
## A rollback / state synchronizer class for RigidBody3D.
|
||||
## Set state property path to physics_state to synchronize the state of this body.
|
||||
|
||||
@onready var direct_state = PhysicsServer3D.body_get_direct_state(get_rid())
|
||||
|
||||
var physics_state: Array:
|
||||
get: return get_state()
|
||||
set(v): set_state(v)
|
||||
|
||||
enum {
|
||||
ORIGIN,
|
||||
QUAT,
|
||||
LIN_VEL,
|
||||
ANG_VEL,
|
||||
SLEEPING
|
||||
}
|
||||
|
||||
func _notification(notification: int):
|
||||
if notification == NOTIFICATION_READY:
|
||||
add_to_group("network_rigid_body")
|
||||
|
||||
func get_state() -> Array:
|
||||
var body_state: Array = [Vector3.ZERO, Quaternion.IDENTITY, Vector3.ZERO, Vector3.ZERO, false]
|
||||
body_state[ORIGIN] = direct_state.transform.origin
|
||||
body_state[QUAT] = direct_state.transform.basis.get_rotation_quaternion()
|
||||
body_state[LIN_VEL] = direct_state.linear_velocity
|
||||
body_state[ANG_VEL] = direct_state.angular_velocity
|
||||
body_state[SLEEPING] = direct_state.sleeping
|
||||
return body_state
|
||||
|
||||
func set_state(remote_state: Array) -> void:
|
||||
direct_state.transform.origin = remote_state[ORIGIN]
|
||||
direct_state.transform.basis = Basis(remote_state[QUAT])
|
||||
direct_state.linear_velocity = remote_state[LIN_VEL]
|
||||
direct_state.angular_velocity = remote_state[ANG_VEL]
|
||||
direct_state.sleeping = remote_state[SLEEPING]
|
||||
|
||||
|
||||
## Override and apply any logic, forces or impulses to the rigid body as you would in physics_process
|
||||
## The physics engine will run its simulation during rollback_tick with other nodes
|
||||
func _physics_rollback_tick(_delta, _tick):
|
||||
pass
|
||||
@@ -0,0 +1 @@
|
||||
uid://bklxcdyxgbjg2
|
||||
@@ -0,0 +1,88 @@
|
||||
extends Object
|
||||
|
||||
class PhysicsDriverToggle:
|
||||
const INACTIVE_SUFFIX := ".off"
|
||||
|
||||
func get_name() -> String:
|
||||
return "???"
|
||||
|
||||
func get_files() -> Array[String]:
|
||||
return []
|
||||
|
||||
func get_error_messages() -> Array[String]:
|
||||
return []
|
||||
|
||||
func is_enabled() -> bool:
|
||||
return get_files().any(func(it): return FileAccess.file_exists(it))
|
||||
|
||||
func toggle() -> Array[String]:
|
||||
var errors := get_error_messages()
|
||||
if not errors.is_empty():
|
||||
return errors
|
||||
|
||||
var enable := not is_enabled()
|
||||
|
||||
var uid_files := get_files().map(func(it): return it + ".uid")
|
||||
var renames = (get_files() + uid_files).map(func(it):
|
||||
if enable: return [it + INACTIVE_SUFFIX, it]
|
||||
else: return [it, it + INACTIVE_SUFFIX]
|
||||
)
|
||||
|
||||
for rename in renames:
|
||||
var result := DirAccess.rename_absolute(rename[0], rename[1])
|
||||
if result != OK:
|
||||
errors.append(
|
||||
"Failed rename \"%s\" -> \"%s\"; reason: %s" %
|
||||
[rename[0], rename[1], error_string(result)]
|
||||
)
|
||||
return errors
|
||||
|
||||
class Rapier2DPhysicsDriverToggle extends PhysicsDriverToggle:
|
||||
func get_name() -> String:
|
||||
return "Rapier2D"
|
||||
|
||||
func get_files() -> Array[String]:
|
||||
return [
|
||||
"res://addons/netfox.extras/physics/rapier_driver_2d.gd",
|
||||
]
|
||||
|
||||
func get_error_messages() -> Array[String]:
|
||||
if not ClassDB.class_exists("RapierPhysicsServer2D"):
|
||||
return ["Rapier physics is not available! Is the extension installed?"]
|
||||
return []
|
||||
|
||||
class Rapier3DPhysicsDriverToggle extends PhysicsDriverToggle:
|
||||
func get_name() -> String:
|
||||
return "Rapier3D"
|
||||
|
||||
func get_files() -> Array[String]:
|
||||
return [
|
||||
"res://addons/netfox.extras/physics/rapier_driver_3d.gd",
|
||||
]
|
||||
|
||||
func get_error_messages() -> Array[String]:
|
||||
if not ClassDB.class_exists("RapierPhysicsServer3D"):
|
||||
return ["Rapier physics is not available! Is the extension installed?"]
|
||||
return []
|
||||
|
||||
class GodotPhysicsDriverToggle extends PhysicsDriverToggle:
|
||||
func get_name() -> String:
|
||||
return "Godot"
|
||||
|
||||
func get_files() -> Array[String]:
|
||||
return [
|
||||
"res://addons/netfox.extras/physics/godot_driver_3d.gd",
|
||||
"res://addons/netfox.extras/physics/godot_driver_2d.gd"
|
||||
]
|
||||
|
||||
func get_error_messages() -> Array[String]:
|
||||
if not PhysicsServer3D.has_method("space_step") or not PhysicsServer2D.has_method("space_step"):
|
||||
return ["Physics stepping is not available! Is this the right Godot build?"]
|
||||
return []
|
||||
|
||||
static func all() -> Array[PhysicsDriverToggle]:
|
||||
return [
|
||||
Rapier2DPhysicsDriverToggle.new(),
|
||||
Rapier3DPhysicsDriverToggle.new(),
|
||||
GodotPhysicsDriverToggle.new()
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
uid://bu4ppfj0ovkbr
|
||||
@@ -0,0 +1,86 @@
|
||||
extends Node
|
||||
|
||||
class_name PhysicsDriver
|
||||
|
||||
# Physics driver based on netfox ticks
|
||||
# Step physics in time with netfox and participates in rollback
|
||||
|
||||
var physics_space: RID
|
||||
var snapshots: Dictionary = {}
|
||||
|
||||
# Number of physics steps to take per network tick
|
||||
@export var physics_factor: int = 2
|
||||
# Snapshot and Rollback entire physics space.
|
||||
@export var rollback_physics_space: bool = true
|
||||
|
||||
func _enter_tree():
|
||||
#regular ticks
|
||||
NetworkTime.before_tick.connect(before_tick)
|
||||
NetworkTime.after_tick_loop.connect(after_tick_loop)
|
||||
|
||||
#rollback ticks
|
||||
if rollback_physics_space:
|
||||
NetworkRollback.on_prepare_tick.connect(on_prepare_tick)
|
||||
NetworkRollback.on_process_tick.connect(on_process_tick)
|
||||
|
||||
func _exit_tree():
|
||||
NetworkTime.before_tick.disconnect(before_tick)
|
||||
NetworkTime.after_tick_loop.disconnect(after_tick_loop)
|
||||
|
||||
#rollback ticks
|
||||
if NetworkRollback.on_prepare_tick.is_connected(on_prepare_tick):
|
||||
NetworkRollback.on_prepare_tick.disconnect(on_prepare_tick)
|
||||
NetworkRollback.on_process_tick.disconnect(on_process_tick)
|
||||
|
||||
func _ready() -> void:
|
||||
_init_physics_space()
|
||||
|
||||
# Emitted before a tick is run.
|
||||
func before_tick(_delta: float, tick: int) -> void:
|
||||
_snapshot_space(tick)
|
||||
step_physics(_delta)
|
||||
|
||||
func on_prepare_tick(tick: int) -> void:
|
||||
if NetworkRollback._rollback_from == tick:
|
||||
# First tick of rollback loop, rewind
|
||||
_rollback_space(tick)
|
||||
else:
|
||||
# Subsequent ticks are re-writing history.
|
||||
_snapshot_space(tick)
|
||||
|
||||
func on_process_tick(_tick: int) -> void:
|
||||
step_physics(NetworkTime.ticktime)
|
||||
|
||||
func after_tick_loop() -> void:
|
||||
# Remove old snapshots
|
||||
for i in snapshots.keys():
|
||||
if i < NetworkRollback.history_start:
|
||||
snapshots.erase(i)
|
||||
|
||||
func step_physics(_delta: float) -> void:
|
||||
# Break up physics into smaller steps if needed
|
||||
var frac_delta = _delta / physics_factor
|
||||
var rollback_participants = get_tree().get_nodes_in_group("network_rigid_body")
|
||||
for i in range(physics_factor):
|
||||
for net_rigid_body in rollback_participants:
|
||||
net_rigid_body._physics_rollback_tick(frac_delta, NetworkTime.tick)
|
||||
|
||||
_physics_step(frac_delta)
|
||||
|
||||
## Override this method to initialize the physics space.
|
||||
func _init_physics_space() -> void:
|
||||
pass
|
||||
|
||||
## Override this method to take one step in the physics space.
|
||||
## [br][br]
|
||||
## It should also flush and update all Godot nodes
|
||||
func _physics_step(_delta) -> void:
|
||||
pass
|
||||
|
||||
## Override this method to record the current state of the physics space.
|
||||
func _snapshot_space(_tick: int) -> void:
|
||||
pass
|
||||
|
||||
## Override this method to restore the physics space to a previous state.
|
||||
func _rollback_space(_tick) -> void:
|
||||
pass
|
||||
@@ -0,0 +1 @@
|
||||
uid://dcws6qk4hxtun
|
||||
@@ -0,0 +1,18 @@
|
||||
extends PhysicsDriver
|
||||
|
||||
class_name RapierDriver2D
|
||||
|
||||
func _init_physics_space() -> void:
|
||||
physics_space = get_viewport().world_2d.space
|
||||
PhysicsServer2D.space_set_active(physics_space, false)
|
||||
|
||||
func _physics_step(delta) -> void:
|
||||
RapierPhysicsServer2D.space_step(physics_space, delta)
|
||||
RapierPhysicsServer2D.space_flush_queries(physics_space)
|
||||
|
||||
func _snapshot_space(tick: int) -> void:
|
||||
snapshots[tick] = RapierPhysicsServer2D.export_binary(physics_space)
|
||||
|
||||
func _rollback_space(tick) -> void:
|
||||
if snapshots.has(tick):
|
||||
RapierPhysicsServer2D.import_binary(physics_space, snapshots[tick])
|
||||
@@ -0,0 +1 @@
|
||||
uid://bcc28dvk0pufe
|
||||
@@ -0,0 +1,18 @@
|
||||
extends PhysicsDriver
|
||||
|
||||
class_name RapierDriver3D
|
||||
|
||||
func _init_physics_space() -> void:
|
||||
physics_space = get_viewport().world_3d.space
|
||||
PhysicsServer3D.space_set_active(physics_space, false)
|
||||
|
||||
func _physics_step(delta) -> void:
|
||||
RapierPhysicsServer3D.space_step(physics_space, delta)
|
||||
RapierPhysicsServer3D.space_flush_queries(physics_space)
|
||||
|
||||
func _snapshot_space(tick: int) -> void:
|
||||
snapshots[tick] = RapierPhysicsServer3D.export_binary(physics_space)
|
||||
|
||||
func _rollback_space(tick) -> void:
|
||||
if snapshots.has(tick):
|
||||
RapierPhysicsServer3D.import_binary(physics_space, snapshots[tick])
|
||||
@@ -0,0 +1 @@
|
||||
uid://bo4lplbeepibj
|
||||
@@ -0,0 +1,7 @@
|
||||
[plugin]
|
||||
|
||||
name="netfox.extras"
|
||||
description="Game-specific utilities for Netfox"
|
||||
author="Tamas Galffy and contributors"
|
||||
version="1.35.3"
|
||||
script="netfox-extras.gd"
|
||||
@@ -0,0 +1,71 @@
|
||||
extends RefCounted
|
||||
class_name RewindableRandomNumberGenerator
|
||||
|
||||
## Provides methods for generating pseudo-random numbers in the rollback tick
|
||||
## loop.
|
||||
##
|
||||
## Using a regular [RandomNumberGenerator] in [code]_rollback_tick()[/code]
|
||||
## would generate different numbers on each peer. It also generates different
|
||||
## numbers when resimulating the same tick.
|
||||
## [br][br]
|
||||
## This class solves all of the above, making it suitable for use during
|
||||
## rollback.
|
||||
## [br][br]
|
||||
## The seed must be provided on instantiation, and must be the same on all peers
|
||||
## for the random number generator to work properly.
|
||||
##
|
||||
## @tutorial(RewindableRandomNumberGenerator Guide): https://foxssake.github.io/netfox/latest/netfox.extras/guides/rewindable-random-number-generator/
|
||||
|
||||
var _rng: RandomNumberGenerator
|
||||
var _last_reset_tick := -1
|
||||
var _last_reset_rollback_tick := -1
|
||||
|
||||
static var _logger := NetfoxLogger._for_extras("RewindableRandomNumberGenerator")
|
||||
|
||||
func _init(p_seed: int):
|
||||
_rng = RandomNumberGenerator.new()
|
||||
_rng.set_seed(p_seed)
|
||||
|
||||
## Returns a pseudo-random float between [code]0.0[/code] and [code]1.0[/code]
|
||||
## (inclusive).
|
||||
func randf() -> float:
|
||||
_ensure_state()
|
||||
return _rng.randf()
|
||||
|
||||
## Returns a pseudo-random float between [code]from[/code] and [code]to[/code]
|
||||
## (inclusive).
|
||||
func randf_range(from: float, to: float) -> float:
|
||||
_ensure_state()
|
||||
return _rng.randf_range(from, to)
|
||||
|
||||
## Returns a normally-distributed, pseudo-random floating-point number from the
|
||||
## specified [code]mean[/code] and a standard [code]deviation[/code]. This is
|
||||
## also known as a Gaussian distribution.
|
||||
func randfn(mean: float = 0.0, deviation: float = 1.0) -> float:
|
||||
_ensure_state()
|
||||
return _rng.randfn(mean, deviation)
|
||||
|
||||
## Returns a pseudo-random 32-bit unsigned integer between [code]0[/code] and
|
||||
## [code]4294967295[/code] (inclusive).
|
||||
func randi() -> int:
|
||||
_ensure_state()
|
||||
return _rng.randi()
|
||||
|
||||
## Returns a pseudo-random 32-bit unsigned integer between [code]from[/code] and
|
||||
## [code]to[/code] (inclusive).
|
||||
func randi_range(from: int, to: int) -> int:
|
||||
_ensure_state()
|
||||
return _rng.randi_range(from, to)
|
||||
|
||||
func _ensure_state() -> void:
|
||||
if NetworkTime.tick == _last_reset_tick and NetworkRollback.tick == _last_reset_rollback_tick:
|
||||
# State already has been set
|
||||
return
|
||||
|
||||
if NetworkRollback.is_rollback():
|
||||
_rng.state = hash([_rng.seed, NetworkRollback.tick])
|
||||
else:
|
||||
_rng.state = hash([_rng.seed, NetworkTime.tick])
|
||||
|
||||
_last_reset_rollback_tick = NetworkRollback.tick
|
||||
_last_reset_tick = NetworkTime.tick
|
||||
@@ -0,0 +1 @@
|
||||
uid://cqj8glnpkt5po
|
||||
@@ -0,0 +1,187 @@
|
||||
@tool
|
||||
@icon("res://addons/netfox.extras/icons/rewindable-state-machine.svg")
|
||||
extends Node
|
||||
class_name RewindableStateMachine
|
||||
|
||||
## A state machine that can be used with rollback.
|
||||
##
|
||||
## It relies on [RollbackSynchronizer] to manage its state. State transitions
|
||||
## are only triggered by gameplay code, and not by rollback reverting to an
|
||||
## earlier state.
|
||||
## [br][br]
|
||||
## For this node to work correctly, a [RollbackSynchronizer] must be added as
|
||||
## a sibling, and it must have the [RewindableStateMachine]'s [member state]
|
||||
## property configured as a state property.
|
||||
## [br][br]
|
||||
## To implement states, extend the [RewindableState] class and add it as a child
|
||||
## node.
|
||||
##
|
||||
## @tutorial(RewindableStateMachine Guide): https://foxssake.github.io/netfox/latest/netfox.extras/guides/rewindable-state-machine/
|
||||
|
||||
## Name of the current state.
|
||||
##
|
||||
## Can be an empty string if no state is active. Only modify directly if you
|
||||
## need to skip [method transition]'s callbacks.
|
||||
@export var state: StringName = "":
|
||||
get: return _state_object.name if _state_object != null else ""
|
||||
set(v): _set_state(v)
|
||||
|
||||
## Emitted during state transitions.
|
||||
##
|
||||
## This signal can be used to run gameplay code on state changes.
|
||||
## [br][br]
|
||||
## This signal is emitted whenever a transition happens during rollback, which
|
||||
## means it may be emitted multiple times for the same transition if it gets
|
||||
## resimulated during rollback.
|
||||
## [br][br]
|
||||
## [b]State changes are not necessarily emitted on all peers.[/b]
|
||||
## See: [url=https://foxssake.github.io/netfox/netfox.extras/guides/rewindable-state-machine/#caveats]RewindableStateMachine caveats[/url]
|
||||
signal on_state_changed(old_state: RewindableState, new_state: RewindableState)
|
||||
|
||||
## Emitted after the displayed state has changed.
|
||||
##
|
||||
## This signal can be used to update visuals based on state changes.
|
||||
## [br][br]
|
||||
## This signal is emitted whenever the state after a tick loop has changed.
|
||||
signal on_display_state_changed(old_state: RewindableState, new_state: RewindableState)
|
||||
|
||||
static var _logger: NetfoxLogger = NetfoxLogger._for_extras("RewindableStateMachine")
|
||||
|
||||
var _state_object: RewindableState = null
|
||||
var _previous_state_object: RewindableState = null
|
||||
var _available_states: Dictionary = {}
|
||||
var _prevent_transition: bool = false
|
||||
var _prevent_callable: Callable = func(): _prevent_transition = true
|
||||
|
||||
## Transition to a new state specified by [param new_state_name] and return
|
||||
## true.
|
||||
##
|
||||
## Finds the given state by name and transitions to it if possible. The new
|
||||
## state's [method RewindableState.can_enter] callback decides if it can be
|
||||
## entered from the current state.
|
||||
## [br][br]
|
||||
## Upon transitioning, [method RewindableState.exit] is called on the old state,
|
||||
## and [method RewindableState.enter] is called on the new state. In addition,
|
||||
## [signal on_state_changed] is emitted.
|
||||
## [br][br]
|
||||
## Does nothing if transitioning to the currently active state. Emits a warning
|
||||
## and does nothing when transitioning to an unknown state.
|
||||
func transition(new_state_name: StringName) -> bool:
|
||||
# Check if target state is valid
|
||||
if state == new_state_name:
|
||||
return false
|
||||
|
||||
if not _available_states.has(new_state_name):
|
||||
_logger.warning("Attempted to transition from state '%s' into unknown state '%s'", [state, new_state_name])
|
||||
return false
|
||||
|
||||
var from_state = _state_object
|
||||
var new_state: RewindableState = _available_states[new_state_name]
|
||||
_prevent_transition = false
|
||||
|
||||
# Validate transition
|
||||
if from_state:
|
||||
if !new_state.can_enter(_state_object):
|
||||
return false
|
||||
|
||||
# Emit exit signal, allow handlers to prevent transition
|
||||
_state_object.on_exit.emit(new_state, NetworkRollback.tick, _prevent_callable)
|
||||
if _prevent_transition: return false
|
||||
|
||||
new_state.on_enter.emit(from_state, NetworkRollback.tick, _prevent_callable)
|
||||
if _prevent_transition: return false
|
||||
|
||||
# Transition valid, run callbacks
|
||||
if is_instance_valid(from_state):
|
||||
from_state.exit(new_state, NetworkRollback.tick)
|
||||
new_state.enter(from_state, NetworkRollback.tick)
|
||||
|
||||
# Set new state
|
||||
_state_object = new_state
|
||||
on_state_changed.emit(from_state, new_state)
|
||||
|
||||
return true
|
||||
|
||||
## Update the internal cache of known states
|
||||
## [br][br]
|
||||
## Automatically called on ready and when a child node is added or removed. Call
|
||||
## manually to force an update.
|
||||
func update_states() -> void:
|
||||
_available_states.clear()
|
||||
|
||||
for child in find_children("*", "RewindableState", false):
|
||||
_available_states[child.name] = child
|
||||
|
||||
func _notification(what: int):
|
||||
# Use notification instead of _ready, so users can write their own _ready
|
||||
# callback without having to call super()
|
||||
if Engine.is_editor_hint(): return
|
||||
|
||||
match what:
|
||||
NOTIFICATION_CHILD_ORDER_CHANGED:
|
||||
update_states()
|
||||
NOTIFICATION_ENTER_TREE:
|
||||
# Compare states after tick loop
|
||||
NetworkTime.after_tick_loop.connect(_after_tick_loop)
|
||||
update_states()
|
||||
NOTIFICATION_EXIT_TREE:
|
||||
# Disconnect handlers
|
||||
NetworkTime.after_tick_loop.disconnect(_after_tick_loop)
|
||||
|
||||
func _get_configuration_warnings():
|
||||
const MISSING_SYNCHRONIZER_ERROR := \
|
||||
"RewindableStateMachine is not managed by a RollbackSynchronizer! Add it as a sibling node to fix this."
|
||||
const INVALID_SYNCHRONIZER_CONFIG_ERROR := \
|
||||
"RollbackSynchronizer configuration is invalid, it can't manage this state machine!" +\
|
||||
"\nNote: You may need to reload this scene after fixing for this warning to disappear."
|
||||
const MISSING_PROPERTY_ERROR := \
|
||||
"State is not managed by RollbackSynchronizer! Add `state` property to the synchronizer to fix this. " +\
|
||||
"\nNote: You may need to reload this scene after fixing for this warning to disappear."
|
||||
|
||||
# Check if there's a rollback synchronizer
|
||||
var rollback_synchronizer_node = get_parent().find_children("*", "RollbackSynchronizer", false).pop_front()
|
||||
if not rollback_synchronizer_node:
|
||||
return [MISSING_SYNCHRONIZER_ERROR]
|
||||
|
||||
var rollback_synchronizer := rollback_synchronizer_node as RollbackSynchronizer
|
||||
|
||||
# Check if its configuration is valid
|
||||
# TODO: Expose this as a property?
|
||||
if not rollback_synchronizer.root:
|
||||
return [INVALID_SYNCHRONIZER_CONFIG_ERROR]
|
||||
|
||||
# Check if it manages our `state` property
|
||||
for state_property_path in rollback_synchronizer.state_properties:
|
||||
var property = PropertyEntry.parse(rollback_synchronizer.root, state_property_path)
|
||||
if property.node == self and property.property == "state":
|
||||
return []
|
||||
|
||||
return [MISSING_PROPERTY_ERROR]
|
||||
|
||||
func _rollback_tick(delta: float, tick: int, is_fresh: bool) -> void:
|
||||
if _state_object:
|
||||
_state_object.tick(delta, tick, is_fresh)
|
||||
_state_object.on_tick.emit(delta, tick, is_fresh)
|
||||
|
||||
func _after_tick_loop():
|
||||
if _state_object != _previous_state_object:
|
||||
on_display_state_changed.emit(_previous_state_object, _state_object)
|
||||
|
||||
if _previous_state_object:
|
||||
_previous_state_object.on_display_exit.emit(_state_object, NetworkTime.tick)
|
||||
_previous_state_object.display_exit(_state_object, NetworkTime.tick)
|
||||
|
||||
_state_object.on_display_enter.emit(_previous_state_object, NetworkTime.tick)
|
||||
_state_object.display_enter(_previous_state_object, NetworkTime.tick)
|
||||
|
||||
_previous_state_object = _state_object
|
||||
|
||||
func _set_state(new_state: StringName) -> void:
|
||||
if not new_state:
|
||||
return
|
||||
|
||||
if not _available_states.has(new_state):
|
||||
_logger.warning("Attempted to jump to unknown state: %s", [new_state])
|
||||
return
|
||||
|
||||
_state_object = _available_states[new_state]
|
||||
@@ -0,0 +1 @@
|
||||
uid://byrgwv2o7hstx
|
||||
@@ -0,0 +1,114 @@
|
||||
@tool
|
||||
@icon("res://addons/netfox.extras/icons/rewindable-state.svg")
|
||||
extends Node
|
||||
class_name RewindableState
|
||||
|
||||
## Base class for states to be used with [RewindableStateMachine].
|
||||
##
|
||||
## Provides multiple callback methods for a state's lifecycle, which can be
|
||||
## overridden by extending classes.
|
||||
## [br][br]
|
||||
## Must have a [RewindableStateMachine] as a parent.
|
||||
##
|
||||
## @tutorial(RewindableStateMachine Guide): https://foxssake.github.io/netfox/latest/netfox.extras/guides/rewindable-state-machine/
|
||||
|
||||
## Emitted when entering the state
|
||||
signal on_enter(previous_state: RewindableState, tick: int, prevent: Callable)
|
||||
|
||||
## Emitted on every rollback tick while the state is active
|
||||
signal on_tick(delta: float, tick: int, is_fresh: bool)
|
||||
|
||||
## Emitted when exiting the state
|
||||
signal on_exit(next_state: RewindableState, tick: int, prevent: Callable)
|
||||
|
||||
## Emitted before displaying this state
|
||||
signal on_display_enter(previous_state: RewindableState, tick: int)
|
||||
|
||||
## Emitted before displaying another state
|
||||
signal on_display_exit(next_state: RewindableState, tick: int)
|
||||
|
||||
## The [RewindableStateMachine] this state belongs to.
|
||||
## [br][br]
|
||||
## [i]read-only[/i]
|
||||
var state_machine: RewindableStateMachine:
|
||||
get: return _state_machine
|
||||
|
||||
var _state_machine: RewindableStateMachine
|
||||
|
||||
## Callback to run a single tick.
|
||||
##
|
||||
## This method is called by the [RewindableStateMachine] during the rollback
|
||||
## tick loop to update game state.
|
||||
## [br][br]
|
||||
## [i]override[/i] to implement game logic
|
||||
func tick(delta: float, tick: int, is_fresh: bool) -> void:
|
||||
pass
|
||||
|
||||
## Callback for entering the state.
|
||||
##
|
||||
## This method is called whenever the state machine enters this state.
|
||||
## [br][br]
|
||||
## It is best practice to only modify game state here, i.e. properties that are
|
||||
## configured as state in a [RollbackSynchronizer].
|
||||
## [br][br]
|
||||
## [i]override[/i] to implement game logic reacting to state transitions
|
||||
func enter(previous_state: RewindableState, tick: int) -> void:
|
||||
pass
|
||||
|
||||
## Callback for exiting the state.
|
||||
##
|
||||
## This method is called whenever the state machine exits this state.
|
||||
## [br][br]
|
||||
## It is best practice to only modify game state here, i.e. properties that are
|
||||
## configured as state in a [RollbackSynchronizer].
|
||||
## [br][br]
|
||||
## [i]override[/i] to implement game logic reacting to state transitions
|
||||
func exit(next_state: RewindableState, tick: int) -> void:
|
||||
pass
|
||||
|
||||
## Callback for validating state transitions.
|
||||
##
|
||||
## Whenever the [RewindableStateMachine] attempts to enter this state, it will
|
||||
## call this method to ensure that the transition is valid.
|
||||
## [br][br]
|
||||
## If this method returns true, the transition is valid and the state machine
|
||||
## will enter this state. Otherwise, the transition is invalid, and nothing
|
||||
## happens.
|
||||
## [br][br]
|
||||
## [i]override[/i] to implement custom transition validation logic
|
||||
func can_enter(previous_state: RewindableState) -> bool:
|
||||
# Add your validation logic here
|
||||
# Return true if the state machine can transition to the next state
|
||||
return true
|
||||
|
||||
## Callback for displaying the state.
|
||||
##
|
||||
## After each tick loop, the [RewindableStateMachine] checks the final state,
|
||||
## i.e. the state that will be active until the next tick loop. If that state
|
||||
## has changed [b]to[/b] this one, the [RewindableStateMachine] will call this
|
||||
## method.
|
||||
## [br][br]
|
||||
## [i]override[/i] to implement visuals / effects reacting to state transitions
|
||||
func display_enter(previous_state: RewindableState, tick: int) -> void:
|
||||
pass
|
||||
|
||||
## Callback for displaying a different state.
|
||||
##
|
||||
## After each tick loop, the [RewindableStateMachine] checks the final state,
|
||||
## i.e. the state that will be active until the next tick loop. If that state
|
||||
## has changed [b]from[/b] this one, the [RewindableStateMachine] will call this
|
||||
## method.
|
||||
## [br][br]
|
||||
## [i]override[/i] to implement visuals / effects reacting to state transitions
|
||||
func display_exit(next_state: RewindableState, tick: int) -> void:
|
||||
pass
|
||||
|
||||
func _get_configuration_warnings():
|
||||
return [] if get_parent() is RewindableStateMachine else ["This state should be a child of a RewindableStateMachine."]
|
||||
|
||||
func _notification(what: int):
|
||||
# Use notification instead of _ready, so users can write their own _ready
|
||||
# callback without having to call super()
|
||||
if what == NOTIFICATION_READY:
|
||||
if _state_machine == null and get_parent() is RewindableStateMachine:
|
||||
_state_machine = get_parent()
|
||||
@@ -0,0 +1 @@
|
||||
uid://usyufdtn83hc
|
||||
@@ -0,0 +1,75 @@
|
||||
extends Node2D
|
||||
class_name NetworkWeapon2D
|
||||
|
||||
## A 2D-specific implementation of [NetworkWeapon].
|
||||
|
||||
## Distance to consider too large during reconciliation checks.
|
||||
@export var distance_threshold: float = 1.0
|
||||
|
||||
var _weapon: _NetworkWeaponProxy
|
||||
|
||||
func can_fire() -> bool:
|
||||
return _weapon.can_fire()
|
||||
|
||||
func fire() -> Node2D:
|
||||
return _weapon.fire()
|
||||
|
||||
func get_fired_tick() -> int:
|
||||
return _weapon.get_fired_tick()
|
||||
|
||||
func _init():
|
||||
_weapon = _NetworkWeaponProxy.new()
|
||||
add_child(_weapon, true, INTERNAL_MODE_BACK)
|
||||
_weapon.owner = self
|
||||
|
||||
_weapon.c_can_fire = _can_fire
|
||||
_weapon.c_can_peer_use = _can_peer_use
|
||||
_weapon.c_after_fire = _after_fire
|
||||
_weapon.c_spawn = _spawn
|
||||
_weapon.c_get_data = _get_data
|
||||
_weapon.c_apply_data = _apply_data
|
||||
_weapon.c_is_reconcilable = _is_reconcilable
|
||||
_weapon.c_reconcile = _reconcile
|
||||
|
||||
|
||||
## See [NetworkWeapon]
|
||||
func _can_fire() -> bool:
|
||||
return false
|
||||
|
||||
## See [NetworkWeapon]
|
||||
func _can_peer_use(peer_id: int) -> bool:
|
||||
return true
|
||||
|
||||
## See [NetworkWeapon]
|
||||
func _after_fire(projectile: Node2D):
|
||||
pass
|
||||
|
||||
## See [NetworkWeapon]
|
||||
func _spawn() -> Node2D:
|
||||
return null
|
||||
|
||||
func _get_data(projectile: Node2D) -> Dictionary:
|
||||
return {
|
||||
"global_transform": projectile.global_transform
|
||||
}
|
||||
|
||||
func _apply_data(projectile: Node2D, data: Dictionary):
|
||||
projectile.global_transform = data["global_transform"]
|
||||
|
||||
func _is_reconcilable(projectile: Node2D, request_data: Dictionary, local_data: Dictionary) -> bool:
|
||||
var req_transform = request_data["global_transform"] as Transform2D
|
||||
var loc_transform = local_data["global_transform"] as Transform2D
|
||||
|
||||
var request_pos = req_transform.origin
|
||||
var local_pos = loc_transform.origin
|
||||
|
||||
return request_pos.distance_to(local_pos) < distance_threshold
|
||||
|
||||
func _reconcile(projectile: Node2D, local_data: Dictionary, remote_data: Dictionary):
|
||||
var local_transform = local_data["global_transform"] as Transform2D
|
||||
var remote_transform = remote_data["global_transform"] as Transform2D
|
||||
|
||||
var relative_transform = projectile.global_transform * local_transform.inverse()
|
||||
var final_transform = remote_transform * relative_transform
|
||||
|
||||
projectile.global_transform = final_transform
|
||||
@@ -0,0 +1 @@
|
||||
uid://c8pw311ku24we
|
||||
@@ -0,0 +1,75 @@
|
||||
extends Node3D
|
||||
class_name NetworkWeapon3D
|
||||
|
||||
## A 3D-specific implementation of [NetworkWeapon].
|
||||
|
||||
## Distance to consider too large during reconciliation checks.
|
||||
@export var distance_threshold: float = 1.0
|
||||
|
||||
var _weapon: _NetworkWeaponProxy
|
||||
|
||||
func can_fire() -> bool:
|
||||
return _weapon.can_fire()
|
||||
|
||||
func fire() -> Node3D:
|
||||
return _weapon.fire()
|
||||
|
||||
func get_fired_tick() -> int:
|
||||
return _weapon.get_fired_tick()
|
||||
|
||||
func _init():
|
||||
_weapon = _NetworkWeaponProxy.new()
|
||||
add_child(_weapon, true, INTERNAL_MODE_BACK)
|
||||
_weapon.owner = self
|
||||
|
||||
_weapon.c_can_fire = _can_fire
|
||||
_weapon.c_can_peer_use = _can_peer_use
|
||||
_weapon.c_after_fire = _after_fire
|
||||
_weapon.c_spawn = _spawn
|
||||
_weapon.c_get_data = _get_data
|
||||
_weapon.c_apply_data = _apply_data
|
||||
_weapon.c_is_reconcilable = _is_reconcilable
|
||||
_weapon.c_reconcile = _reconcile
|
||||
|
||||
|
||||
## See [NetworkWeapon]
|
||||
func _can_fire() -> bool:
|
||||
return false
|
||||
|
||||
## See [NetworkWeapon]
|
||||
func _can_peer_use(peer_id: int) -> bool:
|
||||
return true
|
||||
|
||||
## See [NetworkWeapon]
|
||||
func _after_fire(projectile: Node3D):
|
||||
pass
|
||||
|
||||
## See [NetworkWeapon]
|
||||
func _spawn() -> Node3D:
|
||||
return null
|
||||
|
||||
func _get_data(projectile: Node3D) -> Dictionary:
|
||||
return {
|
||||
"global_transform": projectile.global_transform
|
||||
}
|
||||
|
||||
func _apply_data(projectile: Node3D, data: Dictionary):
|
||||
projectile.global_transform = data["global_transform"]
|
||||
|
||||
func _is_reconcilable(projectile: Node3D, request_data: Dictionary, local_data: Dictionary) -> bool:
|
||||
var req_transform = request_data["global_transform"] as Transform3D
|
||||
var loc_transform = local_data["global_transform"] as Transform3D
|
||||
|
||||
var request_pos = req_transform.origin
|
||||
var local_pos = loc_transform.origin
|
||||
|
||||
return request_pos.distance_to(local_pos) < distance_threshold
|
||||
|
||||
func _reconcile(projectile: Node3D, local_data: Dictionary, remote_data: Dictionary):
|
||||
var local_transform = local_data["global_transform"] as Transform3D
|
||||
var remote_transform = remote_data["global_transform"] as Transform3D
|
||||
|
||||
var relative_transform = projectile.global_transform * local_transform.inverse()
|
||||
var final_transform = remote_transform * relative_transform
|
||||
|
||||
projectile.global_transform = final_transform
|
||||
@@ -0,0 +1 @@
|
||||
uid://c1xyp6tx4hf3p
|
||||
@@ -0,0 +1,127 @@
|
||||
extends Node3D
|
||||
class_name NetworkWeaponHitscan3D
|
||||
|
||||
## A 3D-specific implementation of a networked hitscan (raycast) weapon.
|
||||
|
||||
## Maximum distance to cast the ray
|
||||
@export var max_distance: float = 1000.0
|
||||
|
||||
## Mask used to detect raycast hits
|
||||
@export_flags_3d_physics var collision_mask: int = 0xFFFFFFFF
|
||||
|
||||
## Colliders excluded from raycast hits
|
||||
@export var exclude: Array[RID] = []
|
||||
|
||||
var _weapon: _NetworkWeaponProxy
|
||||
|
||||
## Try to fire the weapon and return the projectile.
|
||||
## [br][br]
|
||||
## Returns true if the weapon was fired.
|
||||
func fire() -> bool:
|
||||
if not can_fire():
|
||||
return false
|
||||
|
||||
_apply_data(_get_data())
|
||||
_after_fire()
|
||||
return true
|
||||
|
||||
## Check whether this weapon can be fired.
|
||||
func can_fire() -> bool:
|
||||
return _weapon.can_fire()
|
||||
|
||||
func _init():
|
||||
_weapon = _NetworkWeaponProxy.new()
|
||||
add_child(_weapon, true, INTERNAL_MODE_BACK)
|
||||
_weapon.owner = self
|
||||
|
||||
_weapon.c_can_fire = _can_fire
|
||||
_weapon.c_can_peer_use = _can_peer_use
|
||||
_weapon.c_after_fire = _after_fire
|
||||
_weapon.c_spawn = _spawn
|
||||
_weapon.c_get_data = _get_data
|
||||
_weapon.c_apply_data = _apply_data
|
||||
_weapon.c_is_reconcilable = _is_reconcilable
|
||||
_weapon.c_reconcile = _reconcile
|
||||
|
||||
## Override this method with your own can fire logic.
|
||||
## [br][br]
|
||||
## See [NetworkWeapon].
|
||||
func _can_fire() -> bool:
|
||||
return true
|
||||
|
||||
## Override this method to check if a given peer can use this weapon.
|
||||
## [br][br]
|
||||
## See [NetworkWeapon].
|
||||
func _can_peer_use(peer_id: int) -> bool:
|
||||
return true
|
||||
|
||||
## Override this method to run any logic needed after successfully firing the
|
||||
## weapon.
|
||||
## [br][br]
|
||||
## See [NetworkWeapon].
|
||||
func _after_fire():
|
||||
pass
|
||||
|
||||
func _spawn():
|
||||
# No projectile is spawned for a hitscan weapon.
|
||||
pass
|
||||
|
||||
func _get_data() -> Dictionary:
|
||||
# Collect data needed to synchronize the firing event.
|
||||
return {
|
||||
"origin": global_transform.origin,
|
||||
"direction": -global_transform.basis.z # Assuming forward direction.
|
||||
}
|
||||
|
||||
func _apply_data(data: Dictionary):
|
||||
# Reproduces the firing event on all peers.
|
||||
var origin = data["origin"] as Vector3
|
||||
var direction = data["direction"] as Vector3
|
||||
|
||||
# Perform the raycast from origin in the given direction.
|
||||
var space_state = get_world_3d().direct_space_state
|
||||
|
||||
# Create a PhysicsRayQueryParameters3D object.
|
||||
var ray_params = PhysicsRayQueryParameters3D.new()
|
||||
ray_params.from = origin
|
||||
ray_params.to = origin + direction * max_distance
|
||||
|
||||
# Set collision masks or exclude objects:
|
||||
ray_params.collision_mask = collision_mask
|
||||
ray_params.exclude = exclude
|
||||
|
||||
var result = space_state.intersect_ray(ray_params)
|
||||
|
||||
if result:
|
||||
# Handle the hit result, such as spawning hit effects.
|
||||
_on_hit(result)
|
||||
|
||||
# Play firing effects on all peers.
|
||||
_on_fire()
|
||||
|
||||
func _is_reconcilable(request_data: Dictionary, local_data: Dictionary) -> bool:
|
||||
# Always reconcilable
|
||||
return true
|
||||
|
||||
func _reconcile(local_data: Dictionary, remote_data: Dictionary):
|
||||
# Nothing to do on reconcile
|
||||
pass
|
||||
|
||||
## Override to implement raycast hit logic.
|
||||
## [br][br]
|
||||
## The parameter is the result of a
|
||||
## [method PhysicsDirectSpaceState3D.intersect_ray] call.
|
||||
func _on_hit(result: Dictionary):
|
||||
# Implement hit effect logic here.
|
||||
# var hit_position = result.position
|
||||
# var hit_normal = result.normal
|
||||
# var collider = result.collider
|
||||
|
||||
# For example, you might emit a signal or instantiate a hit effect scene:
|
||||
# emit_signal("hit_detected", hit_position, hit_normal, collider)
|
||||
pass
|
||||
|
||||
## Override to implement firing effects, like muzzle flash or sound.
|
||||
func _on_fire():
|
||||
# Implement firing effect logic here.
|
||||
pass
|
||||
@@ -0,0 +1 @@
|
||||
uid://daf6uabdxsfwq
|
||||
@@ -0,0 +1,35 @@
|
||||
extends NetworkWeapon
|
||||
class_name _NetworkWeaponProxy
|
||||
|
||||
var c_can_fire: Callable
|
||||
var c_can_peer_use: Callable
|
||||
var c_after_fire: Callable
|
||||
var c_spawn: Callable
|
||||
var c_get_data: Callable
|
||||
var c_apply_data: Callable
|
||||
var c_is_reconcilable: Callable
|
||||
var c_reconcile: Callable
|
||||
|
||||
func _can_fire() -> bool:
|
||||
return c_can_fire.call()
|
||||
|
||||
func _can_peer_use(peer_id: int) -> bool:
|
||||
return c_can_peer_use.call(peer_id)
|
||||
|
||||
func _after_fire(projectile: Node):
|
||||
c_after_fire.call(projectile)
|
||||
|
||||
func _spawn() -> Node:
|
||||
return c_spawn.call()
|
||||
|
||||
func _get_data(projectile: Node) -> Dictionary:
|
||||
return c_get_data.call(projectile)
|
||||
|
||||
func _apply_data(projectile: Node, data: Dictionary):
|
||||
c_apply_data.call(projectile, data)
|
||||
|
||||
func _is_reconcilable(projectile: Node, request_data: Dictionary, local_data: Dictionary) -> bool:
|
||||
return c_is_reconcilable.call(projectile, request_data, local_data)
|
||||
|
||||
func _reconcile(projectile: Node, local_data: Dictionary, remote_data: Dictionary):
|
||||
c_reconcile.call(projectile, local_data, remote_data)
|
||||
@@ -0,0 +1 @@
|
||||
uid://76n8gu7udnum
|
||||
@@ -0,0 +1,223 @@
|
||||
extends Node
|
||||
class_name NetworkWeapon
|
||||
|
||||
## Base class for creating responsive weapons, by spawning projectiles locally,
|
||||
## but keeping control on the server.
|
||||
|
||||
var _projectiles: Dictionary = {}
|
||||
var _projectile_data: Dictionary = {}
|
||||
var _reconcile_buffer: Array = []
|
||||
var _rng: RandomNumberGenerator = RandomNumberGenerator.new()
|
||||
var _fired_tick: int = -1
|
||||
|
||||
static var _logger: NetfoxLogger = NetfoxLogger._for_extras("NetworkWeapon")
|
||||
|
||||
func _ready():
|
||||
_rng.randomize()
|
||||
NetworkTime.before_tick_loop.connect(_before_tick_loop)
|
||||
|
||||
## Check whether this weapon can be fired.
|
||||
func can_fire() -> bool:
|
||||
return _can_fire()
|
||||
|
||||
## Try to fire the weapon and return the projectile.
|
||||
## [br][br]
|
||||
## Returns null if the weapon can't be fired.
|
||||
func fire() -> Node:
|
||||
if not can_fire():
|
||||
return null
|
||||
|
||||
var id: String = _generate_id()
|
||||
var projectile = _spawn()
|
||||
_save_projectile(projectile, id)
|
||||
var data = _projectile_data[id]
|
||||
|
||||
if not is_multiplayer_authority():
|
||||
_request_projectile.rpc_id(get_multiplayer_authority(), id, NetworkTime.tick, data)
|
||||
else:
|
||||
_accept_projectile.rpc(id, NetworkTime.tick, data)
|
||||
|
||||
_logger.debug("Calling after fire hook for %s", [projectile.name])
|
||||
_fired_tick = NetworkTime.tick
|
||||
_after_fire(projectile)
|
||||
|
||||
return projectile
|
||||
|
||||
## Get the tick when the weapon was fired.
|
||||
## [br][br]
|
||||
## Whenever a weapon gets fired, it takes time for that event to be transmitted
|
||||
## to the server. To account for this latency, the exact tick is sent along
|
||||
## with other data, so weapon implementations can compensate for the latency.
|
||||
## [br][br]
|
||||
## One way to use this is to manually simulate the projectile after it's
|
||||
## created:
|
||||
## [codeblock]
|
||||
## func _after_fire(projectile: Node3D):
|
||||
## last_fire = get_fired_tick()
|
||||
## sound.play()
|
||||
##
|
||||
## for t in range(get_fired_tick(), NetworkTime.tick):
|
||||
## if projectile.is_queued_for_deletion():
|
||||
## break
|
||||
## projectile._tick(NetworkTime.ticktime, t)
|
||||
## [/codeblock]
|
||||
func get_fired_tick() -> int:
|
||||
return _fired_tick
|
||||
|
||||
## Override this method with your own can fire logic.
|
||||
## [br][br]
|
||||
## This can be used to implement e.g. firing cooldowns and ammo checks.
|
||||
func _can_fire() -> bool:
|
||||
return false
|
||||
|
||||
## Override this method to check if a given peer can use this weapon.
|
||||
## [br][br]
|
||||
## Usually this should check if the weapon's owner is trying to fire it, but
|
||||
## for some special cases this can be some different logic, e.g. weapons that
|
||||
## can be used by any player on a given team.
|
||||
func _can_peer_use(peer_id: int) -> bool:
|
||||
return true
|
||||
|
||||
## Override this method to run any logic needed after successfully firing the
|
||||
## weapon.
|
||||
## [br][br]
|
||||
## This can be used to e.g. reset the firing cooldown or deduct ammo.
|
||||
func _after_fire(projectile: Node):
|
||||
pass
|
||||
|
||||
## Override this method to spawn and initialize a projectile.
|
||||
## [br][br]
|
||||
## Make sure to return the projectile spawned!
|
||||
func _spawn() -> Node:
|
||||
return null
|
||||
|
||||
## Override this method to extract projectile data that should be synchronized
|
||||
## over the network.
|
||||
## [br][br]
|
||||
## This will be captured both locally and on the server, and will be used for
|
||||
## reconciliation.
|
||||
func _get_data(projectile: Node) -> Dictionary:
|
||||
return {}
|
||||
|
||||
## Override this method to apply projectile data that should be synchronized
|
||||
## over the network.
|
||||
## [br][br]
|
||||
## This is used in cases where some other client fires a weapon and the server
|
||||
## instructs us to spawn a projectile for it.
|
||||
func _apply_data(projectile: Node, data: Dictionary):
|
||||
pass
|
||||
|
||||
## Override this method to check if two projectile states can be reconciled.
|
||||
## [br][br]
|
||||
## This can be used to prevent cheating, for example by not allowing the client
|
||||
## to say it's firing from the other side of the map compared to its actual
|
||||
## position.
|
||||
## [br][br]
|
||||
## When this method returns false, the server will decline the projectile
|
||||
## request.
|
||||
func _is_reconcilable(projectile: Node, request_data: Dictionary, local_data: Dictionary) -> bool:
|
||||
return true
|
||||
|
||||
## Override this method to reconcile the initial local and remote projectile
|
||||
## state.
|
||||
## [br][br]
|
||||
## Let's say the projectile travels in a straight line from its origin, but we
|
||||
## receive a different origin from the server. In this reconciliation step,
|
||||
## the projectile's position can be adjusted to account for the different origin.
|
||||
## [br][br]
|
||||
## Unless the use case is niche, the best practice is to consider the server's
|
||||
## state as authorative.
|
||||
func _reconcile(projectile: Node, local_data: Dictionary, remote_data: Dictionary):
|
||||
pass
|
||||
|
||||
func _save_projectile(projectile: Node, id: String, data: Dictionary = {}):
|
||||
_projectiles[id] = projectile
|
||||
projectile.name += " " + id
|
||||
projectile.set_multiplayer_authority(get_multiplayer_authority())
|
||||
|
||||
if data.is_empty():
|
||||
data = _get_data(projectile)
|
||||
|
||||
_projectile_data[id] = data
|
||||
|
||||
func _before_tick_loop():
|
||||
# Reconcile projectiles
|
||||
for recon in _reconcile_buffer:
|
||||
var projectile = recon[0]
|
||||
var local_data = recon[1]
|
||||
var response_data = recon[2]
|
||||
var projectile_id = recon[3]
|
||||
|
||||
if is_instance_valid(projectile):
|
||||
_reconcile(projectile, local_data, response_data)
|
||||
else:
|
||||
_logger.warning("Projectile %s vanished by the time of reconciliation!", [projectile_id])
|
||||
|
||||
_reconcile_buffer.clear()
|
||||
|
||||
func _generate_id(length: int = 12, charset: String = "abcdefghijklmnopqrstuvwxyz0123456789") -> String:
|
||||
var result = ""
|
||||
|
||||
# Generate a random ID
|
||||
for i in range(length):
|
||||
var idx = _rng.randi_range(0, charset.length() - 1)
|
||||
result += charset[idx]
|
||||
|
||||
return result
|
||||
|
||||
@rpc("any_peer", "reliable", "call_remote")
|
||||
func _request_projectile(id: String, tick: int, request_data: Dictionary):
|
||||
var sender = multiplayer.get_remote_sender_id()
|
||||
|
||||
# Reject if sender can't use this input
|
||||
_fired_tick = tick
|
||||
if not _can_peer_use(sender) or not _can_fire():
|
||||
_decline_projectile.rpc_id(sender, id)
|
||||
_logger.error("Projectile %s rejected! Peer %s can't use this weapon now", [id, sender])
|
||||
return
|
||||
|
||||
# Validate incoming data
|
||||
var projectile = _spawn()
|
||||
var local_data: Dictionary = _get_data(projectile)
|
||||
|
||||
if not _is_reconcilable(projectile, request_data, local_data):
|
||||
projectile.queue_free()
|
||||
_decline_projectile.rpc_id(sender, id)
|
||||
_logger.error("Projectile %s rejected! Can't reconcile states: [%s, %s]", [id, request_data, local_data])
|
||||
return
|
||||
|
||||
_save_projectile(projectile, id, local_data)
|
||||
_accept_projectile.rpc(id, tick, local_data)
|
||||
_after_fire(projectile)
|
||||
|
||||
@rpc("authority", "reliable", "call_local")
|
||||
func _accept_projectile(id: String, tick: int, response_data: Dictionary):
|
||||
if multiplayer.get_unique_id() == multiplayer.get_remote_sender_id():
|
||||
# Projectile is local, nothing to do
|
||||
return
|
||||
|
||||
_logger.info("Accepting projectile %s from %s", [id, multiplayer.get_remote_sender_id()])
|
||||
|
||||
if _projectiles.has(id):
|
||||
var projectile = _projectiles[id]
|
||||
var local_data = _projectile_data[id]
|
||||
_reconcile_buffer.push_back([projectile, local_data, response_data, id])
|
||||
else:
|
||||
_fired_tick = tick
|
||||
var projectile = _spawn()
|
||||
_apply_data(projectile, response_data)
|
||||
_projectile_data.erase(id)
|
||||
_save_projectile(projectile, id, response_data)
|
||||
_after_fire(projectile)
|
||||
|
||||
@rpc("authority", "reliable", "call_remote")
|
||||
func _decline_projectile(id: String):
|
||||
if not _projectiles.has(id):
|
||||
return
|
||||
|
||||
var p = _projectiles[id]
|
||||
if is_instance_valid(p):
|
||||
p.queue_free()
|
||||
|
||||
_projectiles.erase(id)
|
||||
_projectile_data.erase(id)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cill8uva6thl5
|
||||
@@ -0,0 +1,138 @@
|
||||
extends Node
|
||||
|
||||
# Settings
|
||||
var _is_enabled: bool = ProjectSettings.get_setting(&"netfox/extras/auto_tile_windows", false)
|
||||
var _is_borderless: bool = ProjectSettings.get_setting(&"netfox/extras/borderless", false)
|
||||
var _tile_screen: int = ProjectSettings.get_setting(&"netfox/extras/screen", 0)
|
||||
|
||||
# Hash the game name, so we always get a valid filename
|
||||
var _prefix: String = "netfox-window-tiler-%x" % [ProjectSettings.get_setting(&"application/config/name").hash()]
|
||||
|
||||
var _sid: String = "%x" % [hash(int(Time.get_unix_time_from_system() / 2.))]
|
||||
var _uid: String = "%d" % [Time.get_unix_time_from_system() * 1000_0000.]
|
||||
|
||||
static var _logger: NetfoxLogger = NetfoxLogger._for_extras("WindowTiler")
|
||||
|
||||
func _ready() -> void:
|
||||
# Running on a non-editor (export template) build
|
||||
if OS.has_feature("template"):
|
||||
return
|
||||
|
||||
# Running in headless mode
|
||||
if DisplayServer.get_name() == "headless":
|
||||
return
|
||||
|
||||
# Running in CI
|
||||
for env_var in ["CI", "NETFOX_CI"]:
|
||||
if OS.get_environment(env_var) != "":
|
||||
_logger.debug("Environment variable %s set, disabling", [env_var])
|
||||
return
|
||||
|
||||
# Cleanup in case some files were left
|
||||
_cleanup()
|
||||
|
||||
# Running embedded in editor
|
||||
if _is_embedded():
|
||||
return
|
||||
|
||||
# Don't tile if disabled
|
||||
if not _is_enabled:
|
||||
return
|
||||
|
||||
_logger.debug("Tiling with sid: %s, uid: %s", [_sid, _uid])
|
||||
|
||||
var err = _make_lock(_sid, _uid)
|
||||
if err != Error.OK:
|
||||
_logger.warning("Failed to create lock for tiling, reason: %s", [error_string(err)])
|
||||
return
|
||||
|
||||
# Search for locks, stop once no new locks are found
|
||||
var locks = []
|
||||
await get_tree().create_timer(0.25).timeout
|
||||
for i in range(20):
|
||||
await get_tree().create_timer(0.1).timeout
|
||||
var new_locks = _list_lock_ids()
|
||||
|
||||
if locks == new_locks:
|
||||
break
|
||||
|
||||
locks = new_locks
|
||||
|
||||
var tile_count = locks.size()
|
||||
var idx = locks.find(_uid)
|
||||
|
||||
_logger.debug("Tiling as idx %d / %d - %s in %s", [idx, tile_count, _uid, locks])
|
||||
_tile_window(idx, tile_count)
|
||||
|
||||
func _is_embedded() -> bool:
|
||||
if Engine.has_method("is_embedded_in_editor"):
|
||||
return Engine.call("is_embedded_in_editor")
|
||||
return false
|
||||
|
||||
func _make_lock(sid: String, uid: String) -> Error:
|
||||
var path = "%s/%s-%s-%s" % [OS.get_cache_dir(), _prefix, sid, uid]
|
||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||
|
||||
if file == null:
|
||||
return FileAccess.get_open_error()
|
||||
|
||||
file.close()
|
||||
return Error.OK
|
||||
|
||||
func _list_lock_ids() -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
var dir := DirAccess.open(OS.get_cache_dir())
|
||||
|
||||
if dir:
|
||||
for f in dir.get_files():
|
||||
if f.begins_with(_prefix):
|
||||
result.append(_get_uid(f))
|
||||
|
||||
return result
|
||||
|
||||
func _cleanup():
|
||||
var result: Array[String] = []
|
||||
var dir := DirAccess.open(OS.get_cache_dir())
|
||||
|
||||
if dir:
|
||||
for f in dir.get_files():
|
||||
if f.begins_with(_prefix) and _get_sid(f) != _sid:
|
||||
_logger.trace("Cleaned up lock: %s", [f])
|
||||
dir.remove(OS.get_cache_dir() + "/" + f)
|
||||
|
||||
func _get_sid(filename: String) -> String:
|
||||
return filename.substr(_prefix.length() + 1).get_slice("-", 0)
|
||||
|
||||
func _get_uid(filename: String) -> String:
|
||||
return filename.substr(_prefix.length() + 1).get_slice("-", 1)
|
||||
|
||||
func _tile_window(i: int, total: int) -> void:
|
||||
var screen = _tile_screen
|
||||
var screen_rect = DisplayServer.screen_get_usable_rect(screen)
|
||||
|
||||
var window: Window = get_tree().get_root()
|
||||
window.set_current_screen(screen)
|
||||
|
||||
if total == 1:
|
||||
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MAXIMIZED)
|
||||
return
|
||||
|
||||
window.borderless = _is_borderless
|
||||
|
||||
# Divide up the screen
|
||||
var windows_per_row = int(ceil(sqrt(total)))
|
||||
var windows_per_col = int(ceil(total / float(windows_per_row)))
|
||||
var window_size = Vector2(
|
||||
screen_rect.size.x / windows_per_row, screen_rect.size.y / windows_per_col
|
||||
)
|
||||
|
||||
window.set_size(window_size)
|
||||
|
||||
# Position of the window based on index.
|
||||
var row = i / windows_per_row
|
||||
var col = i % windows_per_row
|
||||
|
||||
var x = screen_rect.position.x + col * window_size.x
|
||||
var y = screen_rect.position.y + row * window_size.y
|
||||
|
||||
window.set_position(Vector2(x, y))
|
||||
@@ -0,0 +1 @@
|
||||
uid://cmqjajhjgffm1
|
||||
Reference in New Issue
Block a user