b0c83af092
Complete replacement of the tactical-shooter project with the netfox-cs-sample (MIT) — a CS 1.6 inspired multiplayer FPS built with Godot 4 and netfox. ## What's new - Full CS-style gameplay: teams (T/CT), rounds, economy, buy menu - 6 weapons: Knife, Glock, USP, AK-47, M4A1, AWP - Bomb plant/defuse with 2 bombsites - Flashbang & smoke grenades - Proper netfox rollback netcode at 64 tick - Network popup UI for host/join - HUD, crosshair, round timer, scoreboard - All netfox singletons registered as autoloads (works in exported builds) ## Architecture - Listen-server (host from client, no dedicated server binary) - Multiplayer-fps game lives at examples/multiplayer-fps/ - Netfox addons registered as autoloads for exported build compat - Godot 4.7 with Forward+ renderer ## Removed - Old headless-server architecture (client_main, server_main, player.gd, etc.) - Custom netfox bootstrap with ENet fallback - Old ChaffGames FPS template (2,420 lines, 844 KB) - SimulationServer GDExtension stub - Godot-jolt physics (netfox sample uses default Godot physics) - Duplicate weapon_data.gd, anti_cheat.gd, round_manager.gd, etc. - Server browser API Python venv (87 MB) - test_range map and modular assets ## Preserved - Git history - Server config at config/default_server_config.cfg - Windows export preset - Build directory (gitignored) Co-authored-by: naxIO <naxIO@users.noreply.github.com>
167 lines
5.9 KiB
Markdown
167 lines
5.9 KiB
Markdown
# RewindableAction
|
|
|
|
!!!warning
|
|
RewindableActions are *experimental*, meaning the API may change in
|
|
breaking ways, and may be less stable than other features.
|
|
|
|
Once the class matures and finds its final form, the *experimental* mark
|
|
will be removed. Feedback is welcome in the meanwhile!
|
|
|
|
Synchronizes events that happen over the network, by letting peers predict the
|
|
event happening, and then adjusting the game based on the host's response.
|
|
|
|
For example, *RewindableActions* could be use to synchronize gun shots
|
|
implemented as part of the rollback tick loop. This is implemented in the
|
|
[rollback-fps] example.
|
|
|
|
## Using RewindableActions
|
|
|
|
To use *RewindableActions*, add them as nodes to your scenes. Once that's done,
|
|
grab a reference to them from your scripts as you would for any other node -
|
|
e.g. by using its NodePath, or by @export-ing it as a variable:
|
|
|
|
```gdscript
|
|
@onready var rewindable_action := $RewindableAction as RewindableAction
|
|
@export var rewindable_action: RewindableAction
|
|
```
|
|
|
|
### Predicting events
|
|
|
|
All peers ( both hosts and clients ) should run the same simulation in their
|
|
`_rollback_tick` implementations. During the rollback tick, peers should
|
|
determine whether they think an event happens by calling
|
|
`RewindableAction.set_active()` - e.g. if they think the gun was fired they should
|
|
call `RewindableAction.set_active(true)`, otherwise call
|
|
`RewindableAction.set_active(false)`.
|
|
|
|
The *RewindableAction* will keep track of the changes caused by `set_active()`.
|
|
Clients ( i.e. peers *not* owning the *RewindableAction* ) will wait for the
|
|
host ( i.e. peer owning the *RewindableAction* ) to broadcast the ground truth,
|
|
noting when did the event happen, and when did it not.
|
|
|
|
!!!note
|
|
Not calling `set_active()` on a specific tick means no prediction for that tick
|
|
will be synchronized, potentially leading to desyncs.
|
|
|
|
### Performing events
|
|
|
|
With the above, *RewindableAction* will synchronize *when* something happens,
|
|
but *what* should happen is up to the game logic.
|
|
|
|
For each rollback tick, to figure out what should happen, the `get_status()`
|
|
method will return one of the following values:
|
|
|
|
`RewindableAction.INACTIVE`
|
|
: The event hasn't happened yet.
|
|
|
|
`RewindableAction.ACTIVE`
|
|
: The event has already happened, and this is not the first time its logic
|
|
will run.
|
|
|
|
`RewindableAction.CONFIRMING`
|
|
: The event was just set to active in this tick.
|
|
|
|
`RewindableAction.CANCELLING`
|
|
: The event was just set to inactive in this tick.
|
|
|
|
See the following graph for a better understanding of how a *RewindableAction*
|
|
transitions from one state to another:
|
|
|
|
```puml
|
|
@startuml
|
|
|
|
[*] --> INACTIVE
|
|
INACTIVE --> CONFIRMING: set_active(true)
|
|
INACTIVE --> CONFIRMING: Host confirms
|
|
CONFIRMING --> ACTIVE: Tick is ran again
|
|
ACTIVE --> CANCELLING: set_active(false)
|
|
ACTIVE --> CANCELLING: Host declines
|
|
CANCELLING --> INACTIVE: Tick is ran again
|
|
|
|
@enduml
|
|
```
|
|
|
|
Keeping with the gunfire example, if the status is `ACTIVE` or `CONFIRMING`,
|
|
make sure to perform the firing logic - e.g. do a hitscan, and decrease the
|
|
health of the player hit. In other words, make sure to update the *game state*.
|
|
|
|
If the state is `CONFIRMING`, implement logic that may spawn other objects (
|
|
e.g. a bullet hole when hitting a wall ).
|
|
|
|
If the state is `CANCELLING`, undo any logic ran in `CONFIRMING`.
|
|
|
|
Usually no extra code is necessary for `INACTIVE`, since the game state update
|
|
can simply be skipped, and other related code is ran in
|
|
`CONFIRMING`/`CANCELLING`.
|
|
|
|
### Reacting to status changes
|
|
|
|
Without [mutations], nodes are not always re-ran for every tick during
|
|
rollback. To make sure that rollback code is ran when the *RewindableAction*'s
|
|
status changes, use `mutate()` to register the appropriate nodes to be
|
|
*mutated* if the action's status changes, e.g.:
|
|
|
|
```gdscript
|
|
@onready var rewindable_action := $RewindableAction as RewindableAction
|
|
|
|
func _ready():
|
|
rewindable_action.mutate(self)
|
|
|
|
func _rollback_tick(delta, tick, is_fresh):
|
|
rewindable_action.set_active(...)
|
|
```
|
|
|
|
### Remembering things between tick loops
|
|
|
|
*RewindableActions* also provide the concept of *context*. This is any
|
|
arbitrary value that the *RewindableAction* will remember for the given tick,
|
|
even throughout tick loops.
|
|
|
|
The *context* value can be set and retrieved by the user.
|
|
|
|
For example, *context* can be used for weapons to remember the projectile they
|
|
have spawned. If the action transitions to `CANCELLING`, the *context* can be
|
|
used to remember which projectile was spawned in that tick, and in turn, which
|
|
projectile needs to be destroyed.
|
|
|
|
Use `has_context()` to check if there's any context set, `get_context()` to
|
|
retrieve it, `set_context()` to update the *context* value, and
|
|
`erase_context()` to forget it.
|
|
|
|
## Handling visuals and effects
|
|
|
|
Performing events ( e.g. a gunshot ) often includes not just updates to the
|
|
game state ( like decreasing health ), but also visual- and audio effects to
|
|
communicate what's happening to the player.
|
|
|
|
Since a rollback tick loop may run multiple ticks in a single frame, simply
|
|
playing sounds and other effects from the rollback tick loop can end up
|
|
spamming particles and playing the same sound effects many times on the same
|
|
frame.
|
|
|
|
Instead, one approach would be to check whether the event has happened at the
|
|
end of each tick loop, and if so, play the appropriate sounds and run the
|
|
appropriate effects.
|
|
|
|
Use `has_confirmed()` to check if the action has been confirmed since the
|
|
beginning of the last tick loop ( i.e. had the `CONFIRMING` status ), and
|
|
`has_cancelled()` to check if the action has been cancelled.
|
|
|
|
For example:
|
|
|
|
```gdscript
|
|
@onready var fire_action := $"Fire Action" as RewindableAction
|
|
|
|
func _ready():
|
|
NetworkTime.after_tick_loop.connect(_after_loop)
|
|
# ...
|
|
|
|
func _after_loop():
|
|
if fire_action.has_confirmed():
|
|
sound.play()
|
|
```
|
|
|
|
[rollback tick loop]: ../guides/network-rollback.md
|
|
[rollback-fps]: https://github.com/foxssake/netfox/tree/main/examples/rollback-fps
|
|
[mutations]: ../tutorials/modifying-objects-during-rollback.md
|