Compare commits

...

6 Commits

Author SHA1 Message Date
genxium
b8e757064d Further enhanced frontend input processing and fixed frame data logging. 2023-01-17 17:38:18 +08:00
genxium
71b9e72592 Minor fix. 2023-01-17 15:16:31 +08:00
genxium
21b48b7c0d Updated frontend input generation. 2023-01-17 13:07:26 +08:00
genxium
fbfca965e6 Minor update. 2023-01-17 12:49:49 +08:00
genxium
b27b567c77 Fixed frontend action triggers. 2023-01-17 12:48:51 +08:00
genxium
e9119530f1 Updated README for UDP discussion. 2023-01-16 23:25:40 +08:00
13 changed files with 124 additions and 54 deletions

View File

@@ -47,3 +47,23 @@ renderFrameId | toApplyInputFrameId
..., ..., ..., 368 | 90
369, 370, 371, 372 | 91
373, 374, 375, ... | 92
# Would using UDP instead of TCP yield better synchronization performance?
Yes, but with non-trivial efforts.
## Neat advantage using UDP
Let's check an actual use case. As soon as an inputFrame becomes all-confirmed, the server should downsync it to all active players -- and upon reception loss of the packet containing this "all-confirmed downsync inputFrame" to a certain player, the server MUST retransmit another packet containing the same inputFrame to that player.
To apply UDP on this use case, additional `ack & retransmission mechanism` would be required, which is a moderately difficult task -- don't just pick a 3rd party lib using TCP flow-control alike `sliding window mechanism`, e.g. [RUDP](https://www.geeksforgeeks.org/reliable-user-datagram-protocol-rudp/)! Here's why.
Assume that the server is downsyncing `sequence of packets[#1, #2, #3, #4, #5, #6, #7, #8, #9, #10]`, when using TCP we get the advantage that each active player is guaranteed to receive that same sequence in the same order -- however in a bad, lossy network when `packet#2` got lost several times for a certain player whose reception window size is just 5, it has to wait for the arrival of `packet#2` at `[_, #3, #4, #5, #6]`, thus unable to process `[#7, #8, #9, #10]` which could contain `unpredictable inputFrame` while `#2` being `correct prediction` for that player.
That's so neat but still an advantage for using UDP! Yet if the TCP flow-control alike `sliding window mechanism` is employed on UDP, such advantage'd be compromised.
To summarize, if UDP is used we need
- an `ack & retransmission mechanism` built on top of it to guarantee reception of critical packets for active players, and
- reception order is not necessary to be reserved (mimic [markConfirmationIfApplicable](https://github.com/genxium/DelayNoMore/blob/v0.9.14/battle_srv/models/room.go#L1085) to maintain `lastAllConfirmedInputFrameId`), but
- TCP flow-control alike `sliding window mechanism` should be avoided to gain advantage over TCP.
## Additional hassles to care about using UDP
When using UDP, it's also necessary to verify authorization of each incoming packet, e.g. by simple time limited symmetric key, due to being connectionless.

View File

@@ -1,6 +1,6 @@
# Preface
This project is a demo for a websocket-based rollback netcode inspired by [GGPO](https://github.com/pond3r/ggpo/blob/master/doc/README.md).
This project is a demo for a websocket-based rollback netcode inspired by [GGPO](https://github.com/pond3r/ggpo/blob/master/doc/README.md). As lots of feedbacks ask for a discussion on using UDP instead, I tried to summarize my personal opinion about it in [ConcerningEdgeCases](./ConcerningEdgeCases.md) -- not necessarily correct but that's indeed a question to face :)
The following video is recorded over INTERNET using an input delay of 4 frames and it feels SMOOTH when playing! Please also checkout [this demo video](https://pan.baidu.com/s/1ML6hNupaPHPJRd5rcTvQvw?pwd=8ruc) to see how this demo carries out a full 60fps synchronization with the help of _batched input upsync/downsync_ for satisfying network I/O performance.

View File

@@ -58,7 +58,7 @@ func toPbRoomDownsyncFrame(rdf *battle.RoomDownsyncFrame) *pb.RoomDownsyncFrame
BulletLocalId: last.BattleAttr.BulletLocalId,
OriginatedRenderFrameId: last.BattleAttr.OriginatedRenderFrameId,
OffenderJoinIndex: last.BattleAttr.OffenderJoinIndex,
TeamId: last.BattleAttr.TeamId,
TeamId: last.BattleAttr.TeamId,
StartupFrames: last.Bullet.StartupFrames,
CancellableStFrame: last.Bullet.CancellableStFrame,
@@ -81,11 +81,11 @@ func toPbRoomDownsyncFrame(rdf *battle.RoomDownsyncFrame) *pb.RoomDownsyncFrame
BlowUp: last.Bullet.BlowUp,
SpeciesId: last.Bullet.SpeciesId,
ExplosionFrames: last.Bullet.ExplosionFrames,
SpeciesId: last.Bullet.SpeciesId,
ExplosionFrames: last.Bullet.ExplosionFrames,
BlState: last.BlState,
FramesInBlState: last.FramesInBlState,
BlState: last.BlState,
FramesInBlState: last.FramesInBlState,
}
ret.MeleeBullets[i] = pbBullet
}
@@ -95,7 +95,7 @@ func toPbRoomDownsyncFrame(rdf *battle.RoomDownsyncFrame) *pb.RoomDownsyncFrame
BulletLocalId: last.BattleAttr.BulletLocalId,
OriginatedRenderFrameId: last.BattleAttr.OriginatedRenderFrameId,
OffenderJoinIndex: last.BattleAttr.OffenderJoinIndex,
TeamId: last.BattleAttr.TeamId,
TeamId: last.BattleAttr.TeamId,
StartupFrames: last.Bullet.StartupFrames,
CancellableStFrame: last.Bullet.CancellableStFrame,
@@ -118,11 +118,11 @@ func toPbRoomDownsyncFrame(rdf *battle.RoomDownsyncFrame) *pb.RoomDownsyncFrame
BlowUp: last.Bullet.BlowUp,
SpeciesId: last.Bullet.SpeciesId,
ExplosionFrames: last.Bullet.ExplosionFrames,
SpeciesId: last.Bullet.SpeciesId,
ExplosionFrames: last.Bullet.ExplosionFrames,
BlState: last.BlState,
FramesInBlState: last.FramesInBlState,
BlState: last.BlState,
FramesInBlState: last.FramesInBlState,
VirtualGridX: last.VirtualGridX,
VirtualGridY: last.VirtualGridY,

View File

@@ -1342,7 +1342,7 @@ func (pR *Room) doBattleMainLoopPerTickBackendDynamicsWithProperLocking(prevRend
snapshotStFrameId = refSnapshotStFrameId
}
inputsBufferSnapshot := pR.produceInputsBufferSnapshotWithCurDynamicsRenderFrameAsRef(unconfirmedMask, snapshotStFrameId, pR.LastAllConfirmedInputFrameId+1)
Logger.Debug(fmt.Sprintf("[forceConfirmation] roomId=%v, room.RenderFrameId=%v, room.CurDynamicsRenderFrameId=%v, room.LastAllConfirmedInputFrameId=%v, unconfirmedMask=%v", pR.Id, pR.RenderFrameId, pR.CurDynamicsRenderFrameId, pR.LastAllConfirmedInputFrameId, unconfirmedMask))
//Logger.Warn(fmt.Sprintf("[forceConfirmation] roomId=%v, room.RenderFrameId=%v, room.CurDynamicsRenderFrameId=%v, room.LastAllConfirmedInputFrameId=%v, unconfirmedMask=%v", pR.Id, pR.RenderFrameId, pR.CurDynamicsRenderFrameId, pR.LastAllConfirmedInputFrameId, unconfirmedMask))
pR.downsyncToAllPlayers(inputsBufferSnapshot)
}
}
@@ -1457,8 +1457,8 @@ func (pR *Room) downsyncToSinglePlayer(playerId int32, player *Player, refRender
pbRefRenderFrame.SpeciesIdList = pR.SpeciesIdList
pR.sendSafely(pbRefRenderFrame, toSendInputFrameDownsyncsSnapshot, DOWNSYNC_MSG_ACT_FORCED_RESYNC, playerId, false)
//Logger.Warn(fmt.Sprintf("Sent refRenderFrameId=%v & inputFrameIds [%d, %d), for roomId=%v, playerId=%d, playerJoinIndex=%d, renderFrameId=%d, curDynamicsRenderFrameId=%d, playerLastSentInputFrameId=%d: InputsBuffer=%v", refRenderFrameId, toSendInputFrameIdSt, toSendInputFrameIdEd, pR.Id, playerId, player.JoinIndex, pR.RenderFrameId, pR.CurDynamicsRenderFrameId, player.LastSentInputFrameId, pR.InputsBufferString(false)))
if shouldResync1 {
Logger.Warn(fmt.Sprintf("Sent refRenderFrameId=%v & inputFrameIds [%d, %d), for roomId=%v, playerId=%d, playerJoinIndex=%d, renderFrameId=%d, curDynamicsRenderFrameId=%d, playerLastSentInputFrameId=%d: shouldResync1=%v, shouldResync2=%v, shouldResync3=%v, playerBattleState=%d", refRenderFrameId, toSendInputFrameIdSt, toSendInputFrameIdEd, pR.Id, playerId, player.JoinIndex, pR.RenderFrameId, pR.CurDynamicsRenderFrameId, player.LastSentInputFrameId, shouldResync1, shouldResync2, shouldResync3, playerBattleState))
if shouldResync1 || shouldResync3 {
Logger.Debug(fmt.Sprintf("Sent refRenderFrameId=%v & inputFrameIds [%d, %d), for roomId=%v, playerId=%d, playerJoinIndex=%d, renderFrameId=%d, curDynamicsRenderFrameId=%d, playerLastSentInputFrameId=%d: shouldResync1=%v, shouldResync2=%v, shouldResync3=%v, playerBattleState=%d", refRenderFrameId, toSendInputFrameIdSt, toSendInputFrameIdEd, pR.Id, playerId, player.JoinIndex, pR.RenderFrameId, pR.CurDynamicsRenderFrameId, player.LastSentInputFrameId, shouldResync1, shouldResync2, shouldResync3, playerBattleState))
}
} else {
pR.sendSafely(nil, toSendInputFrameDownsyncsSnapshot, DOWNSYNC_MSG_ACT_INPUT_BATCH, playerId, false)

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,7 @@
"_name": "Fireball1Explosion",
"_objFlags": 0,
"_native": "",
"_duration": 0.1,
"_duration": 0.26666666666666666,
"sample": 60,
"speed": 1,
"wrapMode": 1,
@@ -18,31 +18,31 @@
}
},
{
"frame": 0.016666666666666666,
"frame": 0.05,
"value": {
"__uuid__": "c6a5994f-251d-4191-a550-dfef979bab59"
}
},
{
"frame": 0.03333333333333333,
"frame": 0.11666666666666667,
"value": {
"__uuid__": "417e58d9-e364-47f7-9364-f31ad3452adc"
}
},
{
"frame": 0.05,
"frame": 0.15,
"value": {
"__uuid__": "8b566f26-b34d-4da6-bdaa-078358a5b685"
}
},
{
"frame": 0.06666666666666667,
"frame": 0.2,
"value": {
"__uuid__": "6ec5f75d-307e-4292-b667-cbbb5a52c2f6"
}
},
{
"frame": 0.08333333333333333,
"frame": 0.25,
"value": {
"__uuid__": "d89977f1-d927-4a08-9591-9feb1daf68c8"
}

View File

@@ -440,7 +440,7 @@
"array": [
0,
0,
217.50722273720794,
210.57636167057314,
0,
0,
0,

View File

@@ -464,7 +464,7 @@
"array": [
0,
0,
211.36523796872766,
210.57636167057314,
0,
0,
0,

View File

@@ -134,7 +134,7 @@ cc.Class({
previousSelfInput = (null == previousInputFrameDownsync ? null : previousInputFrameDownsync.InputList[joinIndex - 1]);
if (null != existingInputFrame) {
// This could happen upon either [type#1] or [type#2] forceConfirmation, where "refRenderFrame" is accompanied by some "inputFrameDownsyncs". The check here also guarantees that we don't override history
console.log(`noDelayInputFrameId=${inputFrameId} already exists in recentInputCache: recentInputCache=${self._stringifyRecentInputCache(false)}`);
//console.log(`noDelayInputFrameId=${inputFrameId} already exists in recentInputCache: recentInputCache=${self._stringifyRecentInputCache(false)}`);
return [previousSelfInput, existingInputFrame.InputList[joinIndex - 1]];
}
@@ -916,13 +916,19 @@ batchInputFrameIdRange=[${batch[0].inputFrameId}, ${batch[batch.length - 1].inpu
}
try {
let st = performance.now();
const noDelayInputFrameId = gopkgs.ConvertToNoDelayInputFrameId(self.renderFrameId);
let prevSelfInput = null,
currSelfInput = null;
const noDelayInputFrameId = gopkgs.ConvertToNoDelayInputFrameId(self.renderFrameId);
if (gopkgs.ShouldGenerateInputFrameUpsync(self.renderFrameId)) {
[prevSelfInput, currSelfInput] = self.getOrPrefabInputFrameUpsync(noDelayInputFrameId);
}
const delayedInputFrameId = gopkgs.ConvertToDelayedInputFrameId(self.renderFrameId);
if (null == self.recentInputCache.GetByFrameId(delayedInputFrameId)) {
// Possible edge case after resync
self.getOrPrefabInputFrameUpsync(delayedInputFrameId);
}
let t0 = performance.now();
if (self.shouldSendInputFrameUpsyncBatch(prevSelfInput, currSelfInput, self.lastUpsyncInputFrameId, noDelayInputFrameId)) {
// TODO: Is the following statement run asynchronously in an implicit manner? Should I explicitly run it asynchronously?
@@ -1195,13 +1201,26 @@ othersForcedDownsyncRenderFrame=${JSON.stringify(othersForcedDownsyncRenderFrame
throw `Couldn't find renderFrame for i=${i} to rollback (are you using Firefox?), self.renderFrameId=${self.renderFrameId}, lastAllConfirmedInputFrameId=${self.lastAllConfirmedInputFrameId}, might've been interruptted by onRoomDownsyncFrame`;
}
const j = gopkgs.ConvertToDelayedInputFrameId(i);
const delayedInputFrame = self.recentInputCache.GetByFrameId(j); // Don't make prediction here, the inputFrameDownsyncs in recentInputCache was already predicted while prefabbing
if (null == delayedInputFrame) {
// Shouldn't happen!
throw `Failed to get cached delayedInputFrame for i=${i}, j=${j}, renderFrameId=${self.renderFrameId}, lastUpsyncInputFrameId=${self.lastUpsyncInputFrameId}, lastAllConfirmedInputFrameId=${self.lastAllConfirmedInputFrameId}, chaserRenderFrameId=${self.chaserRenderFrameId}; recentRenderCache=${self._stringifyRecentRenderCache(false)}, recentInputCache=${self._stringifyRecentInputCache(false)}`;
}
const delayedInputFrame = self.recentInputCache.GetByFrameId(j);
/*
const prevJ = gopkgs.ConvertToDelayedInputFrameId(i - 1);
const prevDelayedInputFrame = self.recentInputCache.GetByFrameId(prevJ);
const prevBtnALevel = (null == prevDelayedInputFrame ? 0 : ((prevDelayedInputFrame.InputList[self.selfPlayerInfo.JoinIndex - 1] >> 4) & 1));
const btnALevel = ((delayedInputFrame.InputList[self.selfPlayerInfo.JoinIndex - 1] >> 4) & 1);
if (
ATK_CHARACTER_STATE.Atk1[0] == currRdf.PlayersArr[self.selfPlayerInfo.JoinIndex - 1].CharacterState
||
ATK_CHARACTER_STATE.Atk2[0] == currRdf.PlayersArr[self.selfPlayerInfo.JoinIndex - 1].CharacterState
) {
console.log(`rdf.Id=${i}, (btnALevel,j)=(${btnALevel},${j}), (prevBtnALevel,prevJ) is (${prevBtnALevel},${prevJ}), in cancellable atk!`);
}
if (btnALevel > 0) {
if (btnALevel > prevBtnALevel) {
console.log(`rdf.Id=${i}, rising edge of btnA triggered`);
}
}
*/
const jPrev = gopkgs.ConvertToDelayedInputFrameId(i - 1);
if (self.frameDataLoggingEnabled) {
const actuallyUsedInputClone = delayedInputFrame.InputList.slice();
const inputFrameDownsyncClone = {

View File

@@ -106,8 +106,13 @@ cc.Class({
this.cachedBtnDownLevel = 0;
this.cachedBtnLeftLevel = 0;
this.cachedBtnRightLevel = 0;
this.realtimeBtnALevel = 0;
this.cachedBtnALevel = 0;
this.btnAEdgeTriggerLock = false;
this.realtimeBtnBLevel = 0;
this.cachedBtnBLevel = 0;
this.btnBEdgeTriggerLock = false;
this.canvasNode = this.mapNode.parent;
this.mainCameraNode = this.canvasNode.getChildByName("Main Camera"); // Cannot drag and assign the `mainCameraNode` from CocosCreator EDITOR directly, otherwise it'll cause an infinite loading time, till v2.1.0.
@@ -163,30 +168,30 @@ cc.Class({
if (self.btnA) {
self.btnA.on(cc.Node.EventType.TOUCH_START, function(evt) {
self.cachedBtnALevel = 1;
self._triggerEdgeBtnA(true);
evt.target.runAction(cc.scaleTo(0.1, 0.3));
});
self.btnA.on(cc.Node.EventType.TOUCH_END, function(evt) {
self.cachedBtnALevel = 0;
self._triggerEdgeBtnA(false);
evt.target.runAction(cc.scaleTo(0.1, 1.0));
});
self.btnA.on(cc.Node.EventType.TOUCH_CANCEL, function(evt) {
self.cachedBtnALevel = 0;
self._triggerEdgeBtnA(false);
evt.target.runAction(cc.scaleTo(0.1, 1.0));
});
}
if (self.btnB) {
self.btnB.on(cc.Node.EventType.TOUCH_START, function(evt) {
self.cachedBtnBLevel = 1;
self._triggerEdgeBtnB(true);
evt.target.runAction(cc.scaleTo(0.1, 0.3));
});
self.btnB.on(cc.Node.EventType.TOUCH_END, function(evt) {
self.cachedBtnBLevel = 0;
self._triggerEdgeBtnB(false);
evt.target.runAction(cc.scaleTo(0.1, 1.0));
});
self.btnB.on(cc.Node.EventType.TOUCH_CANCEL, function(evt) {
self.cachedBtnBLevel = 0;
self._triggerEdgeBtnB(false);
evt.target.runAction(cc.scaleTo(0.1, 1.0));
});
}
@@ -223,10 +228,10 @@ cc.Class({
self.cachedBtnRightLevel = 1;
break;
case cc.macro.KEY.h:
self.cachedBtnALevel = 1;
self._triggerEdgeBtnA(true);
break;
case cc.macro.KEY.j:
self.cachedBtnBLevel = 1;
self._triggerEdgeBtnB(true);
break;
default:
break;
@@ -248,10 +253,10 @@ cc.Class({
self.cachedBtnRightLevel = 0;
break;
case cc.macro.KEY.h:
self.cachedBtnALevel = 0;
self._triggerEdgeBtnA(false);
break;
case cc.macro.KEY.j:
self.cachedBtnBLevel = 0;
self._triggerEdgeBtnB(false);
break;
default:
break;
@@ -464,6 +469,12 @@ cc.Class({
const discretizedDir = this.discretizeDirection(this.stickhead.x, this.stickhead.y, this.joyStickEps).encodedIdx; // There're only 9 dirs, thus using only the lower 4-bits
const btnALevel = (this.cachedBtnALevel << 4);
const btnBLevel = (this.cachedBtnBLevel << 5);
this.cachedBtnALevel = this.realtimeBtnALevel;
this.cachedBtnBLevel = this.realtimeBtnBLevel;
this.btnAEdgeTriggerLock = false;
this.btnBEdgeTriggerLock = false;
return (btnBLevel + btnALevel + discretizedDir);
},
@@ -482,4 +493,20 @@ cc.Class({
btnBLevel: btnBLevel,
});
},
_triggerEdgeBtnA(rising) {
this.realtimeBtnALevel = (rising ? 1 : 0);
if (!this.btnAEdgeTriggerLock && (1 - this.realtimeBtnALevel) == this.cachedBtnALevel) {
this.cachedBtnALevel = this.realtimeBtnALevel;
this.btnAEdgeTriggerLock = true;
}
},
_triggerEdgeBtnB(rising) {
this.realtimeBtnBLevel = (rising ? 1 : 0);
if (!this.btnBEdgeTriggerLock && (1 - this.realtimeBtnBLevel) == this.cachedBtnBLevel) {
this.cachedBtnBLevel = this.realtimeBtnBLevel;
this.btnBEdgeTriggerLock = true;
}
},
});

View File

@@ -68,7 +68,7 @@
"shelter_z_reducer",
"shelter"
],
"last-module-event-record-time": 1673325961305,
"last-module-event-record-time": 1673930863015,
"simulator-orientation": false,
"simulator-resolution": {
"height": 640,

View File

@@ -489,15 +489,19 @@ func deriveOpPattern(currPlayerDownsync, thatPlayerInNextFrame *PlayerDownsync,
}
patternId := PATTERN_ID_NO_OP
if decodedInput.BtnALevel > prevBtnALevel {
if 0 > effDy {
patternId = 3
} else if 0 < effDy {
patternId = 2
} else {
patternId = 1
}
}
if 0 < decodedInput.BtnALevel {
if decodedInput.BtnALevel > prevBtnALevel {
if 0 > effDy {
patternId = 3
} else if 0 < effDy {
patternId = 2
} else {
patternId = 1
}
} else {
patternId = 4 // Holding
}
}
return patternId, jumpedOrNot, effDx, effDy
}

View File

@@ -302,7 +302,7 @@ var skills = map[int]*Skill{
HitboxOffsetY: int32(0),
HitboxSizeX: int32(float64(24) * WORLD_TO_VIRTUAL_GRID_RATIO),
HitboxSizeY: int32(float64(32) * WORLD_TO_VIRTUAL_GRID_RATIO),
CancellableStFrame: int32(13),
CancellableStFrame: int32(8),
CancellableEdFrame: int32(30),
CancelTransit: map[int]int{
@@ -337,7 +337,7 @@ var skills = map[int]*Skill{
HitboxOffsetY: int32(0),
HitboxSizeX: int32(float64(24) * WORLD_TO_VIRTUAL_GRID_RATIO),
HitboxSizeY: int32(float64(32) * WORLD_TO_VIRTUAL_GRID_RATIO),
CancellableStFrame: int32(22),
CancellableStFrame: int32(19),
CancellableEdFrame: int32(36),
CancelTransit: map[int]int{
1: 6,
@@ -500,7 +500,7 @@ var skills = map[int]*Skill{
HitboxSizeX: int32(float64(48) * WORLD_TO_VIRTUAL_GRID_RATIO),
HitboxSizeY: int32(float64(32) * WORLD_TO_VIRTUAL_GRID_RATIO),
BlowUp: false,
ExplosionFrames: 5,
ExplosionFrames: 15,
SpeciesId: int32(1),
},
},