mirror of
https://github.com/genxium/DelayNoMore
synced 2025-10-19 13:39:18 +00:00
Compare commits
5 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
52be2a6a79 | ||
|
fa491b357d | ||
|
695eacaabc | ||
|
0324b584a5 | ||
|
70e552f5f0 |
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
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).
|
||||||
|
|
||||||
_(the following gif is sped up to 2x for file size reduction)_
|
_(the following gif is sped up to ~1.5x for file size reduction, kindly note that around ~11s countdown, the attack animation is resumed from a partial progress)_
|
||||||

|

|
||||||
|
|
||||||
Please also checkout [this demo video](https://pan.baidu.com/s/1fy0CuFKnVP_Gn2cDfrj6yg?pwd=q5uc) 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.
|
Please also checkout [this demo video](https://pan.baidu.com/s/1U1wb7KWyHorZElNWcS5HHA?pwd=30wh) 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.
|
||||||
|
|
||||||
The video mainly shows the following features.
|
The video mainly shows the following features.
|
||||||
- The backend receives inputs from frontend peers and broadcasts back for synchronization.
|
- The backend receives inputs from frontend peers and broadcasts back for synchronization.
|
||||||
|
@@ -1338,6 +1338,7 @@ func (pR *Room) applyInputFrameDownsyncDynamicsOnSingleRenderFrame(delayedInputF
|
|||||||
thatPlayerInNextFrame := nextRenderFramePlayers[playerId]
|
thatPlayerInNextFrame := nextRenderFramePlayers[playerId]
|
||||||
if 0 < thatPlayerInNextFrame.FramesToRecover {
|
if 0 < thatPlayerInNextFrame.FramesToRecover {
|
||||||
// No need to process inputs for this player, but there might be bullet pushbacks on this player
|
// No need to process inputs for this player, but there might be bullet pushbacks on this player
|
||||||
|
// Also note that in this case we keep "CharacterState" of this player from last render frame
|
||||||
playerCollider.X += bulletPushbacks[joinIndex-1].X
|
playerCollider.X += bulletPushbacks[joinIndex-1].X
|
||||||
playerCollider.Y += bulletPushbacks[joinIndex-1].Y
|
playerCollider.Y += bulletPushbacks[joinIndex-1].Y
|
||||||
// Update in the collision system
|
// Update in the collision system
|
||||||
@@ -1373,6 +1374,7 @@ func (pR *Room) applyInputFrameDownsyncDynamicsOnSingleRenderFrame(delayedInputF
|
|||||||
Logger.Debug(fmt.Sprintf("roomId=%v, playerId=%v triggered a falling-edge of btnA at currRenderFrame.id=%v, delayedInputFrame.id=%v", pR.Id, playerId, currRenderFrame.Id, delayedInputFrame.InputFrameId))
|
Logger.Debug(fmt.Sprintf("roomId=%v, playerId=%v triggered a falling-edge of btnA at currRenderFrame.id=%v, delayedInputFrame.id=%v", pR.Id, playerId, currRenderFrame.Id, delayedInputFrame.InputFrameId))
|
||||||
} else {
|
} else {
|
||||||
// No bullet trigger, process movement inputs
|
// No bullet trigger, process movement inputs
|
||||||
|
// Note that by now "0 == thatPlayerInNextFrame.FramesToRecover", we should change "CharacterState" to "WALKING" or "IDLE" depending on player inputs
|
||||||
if 0 != decodedInput.Dx || 0 != decodedInput.Dy {
|
if 0 != decodedInput.Dx || 0 != decodedInput.Dy {
|
||||||
thatPlayerInNextFrame.DirX = decodedInput.Dx
|
thatPlayerInNextFrame.DirX = decodedInput.Dx
|
||||||
thatPlayerInNextFrame.DirY = decodedInput.Dy
|
thatPlayerInNextFrame.DirY = decodedInput.Dy
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 5.3 MiB |
BIN
charts/melee_attack_fractional_anim_resume_spedup.gif
Normal file
BIN
charts/melee_attack_fractional_anim_resume_spedup.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.7 MiB |
@@ -32,7 +32,7 @@ cc.Class({
|
|||||||
this.speciesName = speciesName;
|
this.speciesName = speciesName;
|
||||||
this.effAnimNode = this.animNode.getChildByName(this.speciesName);
|
this.effAnimNode = this.animNode.getChildByName(this.speciesName);
|
||||||
this.animComp = this.effAnimNode.getComponent(dragonBones.ArmatureDisplay);
|
this.animComp = this.effAnimNode.getComponent(dragonBones.ArmatureDisplay);
|
||||||
this.animComp.playAnimation(ATK_CHARACTER_STATE.Idle1[1]);
|
this.animComp.playAnimation(ATK_CHARACTER_STATE.Idle1[1]); // [WARNING] This is the only exception ccc's wrapper is used!
|
||||||
this.effAnimNode.active = true;
|
this.effAnimNode.active = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -41,6 +41,7 @@ cc.Class({
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateCharacterAnim(rdfPlayer, prevRdfPlayer, forceAnimSwitch) {
|
updateCharacterAnim(rdfPlayer, prevRdfPlayer, forceAnimSwitch) {
|
||||||
|
const underlyingAnimationCtrl = this.animComp._armature.animation; // ALWAYS use the dragonBones api instead of ccc's wrapper!
|
||||||
// Update directions
|
// Update directions
|
||||||
if (this.animComp && this.animComp.node) {
|
if (this.animComp && this.animComp.node) {
|
||||||
if (0 > rdfPlayer.dirX) {
|
if (0 > rdfPlayer.dirX) {
|
||||||
@@ -53,13 +54,48 @@ cc.Class({
|
|||||||
// Update per character state
|
// Update per character state
|
||||||
let newCharacterState = rdfPlayer.characterState;
|
let newCharacterState = rdfPlayer.characterState;
|
||||||
let prevCharacterState = (null == prevRdfPlayer ? window.ATK_CHARACTER_STATE.Idle1[0] : prevRdfPlayer.characterState);
|
let prevCharacterState = (null == prevRdfPlayer ? window.ATK_CHARACTER_STATE.Idle1[0] : prevRdfPlayer.characterState);
|
||||||
|
const newAnimName = window.ATK_CHARACTER_STATE_ARR[newCharacterState][1];
|
||||||
|
const playingAnimName = underlyingAnimationCtrl.lastAnimationName;
|
||||||
|
const isPlaying = underlyingAnimationCtrl.isPlaying;
|
||||||
|
|
||||||
|
// As this function might be called after many frames of a rollback, it's possible that the playing animation was predicted, different from "prevCharacterState" but same as "newCharacterState". More granular checks are needed to determine whether we should interrupt the playing animation.
|
||||||
if (newCharacterState != prevCharacterState) {
|
if (newCharacterState != prevCharacterState) {
|
||||||
// Anim is edge-triggered
|
if (newAnimName == playingAnimName) {
|
||||||
const newAnimName = window.ATK_CHARACTER_STATE_ARR[newCharacterState][1];
|
if (ATK_CHARACTER_STATE.Idle1[0] == newCharacterState || ATK_CHARACTER_STATE.Walking[0] == newCharacterState) {
|
||||||
if (newAnimName != this.animComp.animationName) {
|
// No need to interrupt
|
||||||
this.animComp.playAnimation(newAnimName);
|
// console.warn(`JoinIndex=${rdfPlayer.joinIndex}, not interrupting ${newAnimName} while the playing anim is also ${playingAnimName}, player rdf changed from: ${null == prevRdfPlayer ? null : JSON.stringify(prevRdfPlayer)}, , to: ${JSON.stringify(rdfPlayer)}`);
|
||||||
console.log(`JoinIndex=${rdfPlayer.joinIndex}, Resetting anim to ${newAnimName}, state changed: (${prevCharacterState}, prevRdfPlayer is null? ${null == prevRdfPlayer}) -> (${newCharacterState})`);
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._interruptPlayingAnimAndPlayNewAnim(rdfPlayer, prevRdfPlayer, newCharacterState, newAnimName, underlyingAnimationCtrl);
|
||||||
|
} else {
|
||||||
|
// newCharacterState == prevCharacterState
|
||||||
|
if (newAnimName != playingAnimName) {
|
||||||
|
// the playing animation was falsely predicted
|
||||||
|
this._interruptPlayingAnimAndPlayNewAnim(rdfPlayer, prevRdfPlayer, newCharacterState, newAnimName, underlyingAnimationCtrl);
|
||||||
|
} else {
|
||||||
|
if (!(ATK_CHARACTER_STATE.Idle1[0] == newCharacterState || ATK_CHARACTER_STATE.Walking[0] == newCharacterState)) {
|
||||||
|
// yet there's still a chance that the playing anim is not put at the current frame
|
||||||
|
this._interruptPlayingAnimAndPlayNewAnim(rdfPlayer, prevRdfPlayer, newCharacterState, newAnimName, underlyingAnimationCtrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_interruptPlayingAnimAndPlayNewAnim(rdfPlayer, prevRdfPlayer, newCharacterState, newAnimName, underlyingAnimationCtrl) {
|
||||||
|
if (ATK_CHARACTER_STATE.Idle1[0] == newCharacterState || ATK_CHARACTER_STATE.Walking[0] == newCharacterState) {
|
||||||
|
// No "framesToRecover"
|
||||||
|
// console.warn(`JoinIndex=${rdfPlayer.joinIndex}, playing new ${newAnimName} from the beginning: while the playing anim is ${playAnimation}, player rdf changed from: ${null == prevRdfPlayer ? null : JSON.stringify(prevRdfPlayer)}, , to: ${JSON.stringify(rdfPlayer)}`);
|
||||||
|
underlyingAnimationCtrl.gotoAndPlayByFrame(newAnimName, 0, -1);
|
||||||
|
} else {
|
||||||
|
const animationData = underlyingAnimationCtrl._animations[newAnimName];
|
||||||
|
let fromAnimFrame = (animationData.frameCount - rdfPlayer.framesToRecover);
|
||||||
|
if (fromAnimFrame > 0) {
|
||||||
|
} else if (fromAnimFrame < 0) {
|
||||||
|
// For Atk1 or Atk2, it's possible that the "meleeBullet.recoveryFrames" is configured to be slightly larger than corresponding animation duration frames
|
||||||
|
fromAnimFrame = 0;
|
||||||
|
}
|
||||||
|
underlyingAnimationCtrl.gotoAndPlayByFrame(newAnimName, fromAnimFrame, 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
@@ -106,7 +106,7 @@ cc.Class({
|
|||||||
|
|
||||||
dumpToRenderCache: function(rdf) {
|
dumpToRenderCache: function(rdf) {
|
||||||
const self = this;
|
const self = this;
|
||||||
const minToKeepRenderFrameId = self.lastAllConfirmedRenderFrameId;
|
const minToKeepRenderFrameId = self.lastAllConfirmedRenderFrameId - 1; // Keep at least 1 prev render frame for anim triggering
|
||||||
while (0 < self.recentRenderCache.cnt && self.recentRenderCache.stFrameId < minToKeepRenderFrameId) {
|
while (0 < self.recentRenderCache.cnt && self.recentRenderCache.stFrameId < minToKeepRenderFrameId) {
|
||||||
self.recentRenderCache.pop();
|
self.recentRenderCache.pop();
|
||||||
}
|
}
|
||||||
@@ -537,7 +537,7 @@ cc.Class({
|
|||||||
window.initPersistentSessionClient(self.initAfterWSConnected, boundRoomId);
|
window.initPersistentSessionClient(self.initAfterWSConnected, boundRoomId);
|
||||||
} else {
|
} else {
|
||||||
self.showPopupInCanvas(self.gameRuleNode);
|
self.showPopupInCanvas(self.gameRuleNode);
|
||||||
// Deliberately left blank. -- YFLu
|
// Deliberately left blank. -- YFLu
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -610,28 +610,32 @@ cc.Class({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.renderFrameId = rdf.id;
|
if (null == self.renderFrameId || self.renderFrameId <= rdf.id) {
|
||||||
self.lastRenderFrameIdTriggeredAt = performance.now();
|
// In fact, not having "window.RING_BUFF_CONSECUTIVE_SET == dumpRenderCacheRet" should already imply that "self.renderFrameId <= rdf.id", but here we double check and log the anomaly
|
||||||
// In this case it must be true that "rdf.id > chaserRenderFrameId >= lastAllConfirmedRenderFrameId".
|
self.renderFrameId = rdf.id;
|
||||||
self.lastAllConfirmedRenderFrameId = rdf.id;
|
self.lastRenderFrameIdTriggeredAt = performance.now();
|
||||||
self.chaserRenderFrameId = rdf.id;
|
// In this case it must be true that "rdf.id > chaserRenderFrameId >= lastAllConfirmedRenderFrameId".
|
||||||
|
self.lastAllConfirmedRenderFrameId = rdf.id;
|
||||||
|
self.chaserRenderFrameId = rdf.id;
|
||||||
|
|
||||||
if (null != rdf.countdownNanos) {
|
const canvasNode = self.canvasNode;
|
||||||
self.countdownNanos = rdf.countdownNanos;
|
self.ctrl = canvasNode.getComponent("TouchEventsManager");
|
||||||
}
|
self.enableInputControls();
|
||||||
if (null != self.musicEffectManagerScriptIns) {
|
self.transitToState(ALL_MAP_STATES.VISUAL);
|
||||||
self.musicEffectManagerScriptIns.playBGM();
|
self.battleState = ALL_BATTLE_STATES.IN_BATTLE;
|
||||||
}
|
|
||||||
const canvasNode = self.canvasNode;
|
|
||||||
self.ctrl = canvasNode.getComponent("TouchEventsManager");
|
|
||||||
self.enableInputControls();
|
|
||||||
if (self.countdownToBeginGameNode && self.countdownToBeginGameNode.parent) {
|
|
||||||
self.countdownToBeginGameNode.parent.removeChild(self.countdownToBeginGameNode);
|
|
||||||
}
|
|
||||||
self.transitToState(ALL_MAP_STATES.VISUAL);
|
|
||||||
self.battleState = ALL_BATTLE_STATES.IN_BATTLE;
|
|
||||||
self.applyRoomDownsyncFrameDynamics(rdf, self.recentRenderCache.getByFrameId(rdf.id - 1));
|
|
||||||
|
|
||||||
|
if (self.countdownToBeginGameNode && self.countdownToBeginGameNode.parent) {
|
||||||
|
self.countdownToBeginGameNode.parent.removeChild(self.countdownToBeginGameNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null != self.musicEffectManagerScriptIns) {
|
||||||
|
self.musicEffectManagerScriptIns.playBGM();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`Anomaly when onRoomDownsyncFrame is called by rdf=${JSON.stringify(rdf)}, recentRenderCache=${self._stringifyRecentRenderCache(false)}, recentInputCache=${self._stringifyRecentInputCache(false)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [WARNING] Leave all graphical updates in "update(dt)" by "applyRoomDownsyncFrameDynamics"
|
||||||
return dumpRenderCacheRet;
|
return dumpRenderCacheRet;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -823,17 +827,17 @@ cc.Class({
|
|||||||
console.error("Error during Map.update", err);
|
console.error("Error during Map.update", err);
|
||||||
} finally {
|
} finally {
|
||||||
// Update countdown
|
// Update countdown
|
||||||
if (null != self.countdownNanos) {
|
self.countdownNanos = self.battleDurationNanos - self.renderFrameId * self.rollbackEstimatedDtNanos;
|
||||||
self.countdownNanos = self.battleDurationNanos - self.renderFrameId * self.rollbackEstimatedDtNanos;
|
if (self.countdownNanos <= 0) {
|
||||||
if (self.countdownNanos <= 0) {
|
self.onBattleStopped(self.playerRichInfoDict);
|
||||||
self.onBattleStopped(self.playerRichInfoDict);
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const countdownSeconds = parseInt(self.countdownNanos / 1000000000);
|
const countdownSeconds = parseInt(self.countdownNanos / 1000000000);
|
||||||
if (isNaN(countdownSeconds)) {
|
if (isNaN(countdownSeconds)) {
|
||||||
console.warn(`countdownSeconds is NaN for countdownNanos == ${self.countdownNanos}.`);
|
console.warn(`countdownSeconds is NaN for countdownNanos == ${self.countdownNanos}.`);
|
||||||
}
|
}
|
||||||
|
if (null != self.countdownLabel) {
|
||||||
self.countdownLabel.string = countdownSeconds;
|
self.countdownLabel.string = countdownSeconds;
|
||||||
}
|
}
|
||||||
++self.renderFrameId; // [WARNING] It's important to increment the renderFrameId AFTER all the operations above!!!
|
++self.renderFrameId; // [WARNING] It's important to increment the renderFrameId AFTER all the operations above!!!
|
||||||
@@ -1050,12 +1054,13 @@ cc.Class({
|
|||||||
const [offenderWx, offenderWy] = self.virtualGridToWorldPos(offender.virtualGridX, offender.virtualGridY);
|
const [offenderWx, offenderWy] = self.virtualGridToWorldPos(offender.virtualGridX, offender.virtualGridY);
|
||||||
const bulletWx = offenderWx + xfac * meleeBullet.hitboxOffset;
|
const bulletWx = offenderWx + xfac * meleeBullet.hitboxOffset;
|
||||||
const bulletWy = offenderWy;
|
const bulletWy = offenderWy;
|
||||||
const [bulletCx, bulletCy] = self.worldToPolygonColliderAnchorPos(bulletWx, bulletWy, meleeBullet.hitboxSize.x * 0.5, meleeBullet.hitboxSize.y * 0.5), pts = [[0, 0], [meleeBullet.hitboxSize.x, 0], [meleeBullet.hitboxSize.x, meleeBullet.hitboxSize.y], [0, meleeBullet.hitboxSize.y]];
|
const [bulletCx, bulletCy] = self.worldToPolygonColliderAnchorPos(bulletWx, bulletWy, meleeBullet.hitboxSize.x * 0.5, meleeBullet.hitboxSize.y * 0.5),
|
||||||
|
pts = [[0, 0], [meleeBullet.hitboxSize.x, 0], [meleeBullet.hitboxSize.x, meleeBullet.hitboxSize.y], [0, meleeBullet.hitboxSize.y]];
|
||||||
const newBulletCollider = collisionSys.createPolygon(bulletCx, bulletCy, pts);
|
const newBulletCollider = collisionSys.createPolygon(bulletCx, bulletCy, pts);
|
||||||
newBulletCollider.data = meleeBullet;
|
newBulletCollider.data = meleeBullet;
|
||||||
collisionSysMap.set(collisionBulletIndex, newBulletCollider);
|
collisionSysMap.set(collisionBulletIndex, newBulletCollider);
|
||||||
bulletColliders.set(collisionBulletIndex, newBulletCollider);
|
bulletColliders.set(collisionBulletIndex, newBulletCollider);
|
||||||
// console.log(`A meleeBullet is added to collisionSys at currRenderFrame.id=${currRenderFrame.id} as start-up frames ended and active frame is not yet ended: ${JSON.stringify(meleeBullet)}`);
|
// console.log(`A meleeBullet is added to collisionSys at currRenderFrame.id=${currRenderFrame.id} as start-up frames ended and active frame is not yet ended: ${JSON.stringify(meleeBullet)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -132,7 +132,7 @@ cc.Class({
|
|||||||
self.collisionSysMap.set(collisionBarrierIndex, newBarrier);
|
self.collisionSysMap.set(collisionBarrierIndex, newBarrier);
|
||||||
}
|
}
|
||||||
|
|
||||||
const startRdf = {
|
const startRdf = window.pb.protos.RoomDownsyncFrame.create({
|
||||||
id: window.MAGIC_ROOM_DOWNSYNC_FRAME_ID.BATTLE_START,
|
id: window.MAGIC_ROOM_DOWNSYNC_FRAME_ID.BATTLE_START,
|
||||||
players: {
|
players: {
|
||||||
10: {
|
10: {
|
||||||
@@ -160,7 +160,7 @@ cc.Class({
|
|||||||
dirY: 0,
|
dirY: 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
self.selfPlayerInfo = {
|
self.selfPlayerInfo = {
|
||||||
id: 10
|
id: 10
|
||||||
};
|
};
|
||||||
|
Reference in New Issue
Block a user