mirror of
https://github.com/genxium/DelayNoMore
synced 2025-10-16 20:16:37 +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).
|
||||
|
||||
_(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 backend receives inputs from frontend peers and broadcasts back for synchronization.
|
||||
|
@@ -1338,6 +1338,7 @@ func (pR *Room) applyInputFrameDownsyncDynamicsOnSingleRenderFrame(delayedInputF
|
||||
thatPlayerInNextFrame := nextRenderFramePlayers[playerId]
|
||||
if 0 < thatPlayerInNextFrame.FramesToRecover {
|
||||
// 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.Y += bulletPushbacks[joinIndex-1].Y
|
||||
// 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))
|
||||
} else {
|
||||
// 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 {
|
||||
thatPlayerInNextFrame.DirX = decodedInput.Dx
|
||||
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.effAnimNode = this.animNode.getChildByName(this.speciesName);
|
||||
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;
|
||||
},
|
||||
|
||||
@@ -41,6 +41,7 @@ cc.Class({
|
||||
},
|
||||
|
||||
updateCharacterAnim(rdfPlayer, prevRdfPlayer, forceAnimSwitch) {
|
||||
const underlyingAnimationCtrl = this.animComp._armature.animation; // ALWAYS use the dragonBones api instead of ccc's wrapper!
|
||||
// Update directions
|
||||
if (this.animComp && this.animComp.node) {
|
||||
if (0 > rdfPlayer.dirX) {
|
||||
@@ -53,13 +54,48 @@ cc.Class({
|
||||
// Update per character state
|
||||
let newCharacterState = rdfPlayer.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) {
|
||||
// Anim is edge-triggered
|
||||
const newAnimName = window.ATK_CHARACTER_STATE_ARR[newCharacterState][1];
|
||||
if (newAnimName != this.animComp.animationName) {
|
||||
this.animComp.playAnimation(newAnimName);
|
||||
console.log(`JoinIndex=${rdfPlayer.joinIndex}, Resetting anim to ${newAnimName}, state changed: (${prevCharacterState}, prevRdfPlayer is null? ${null == prevRdfPlayer}) -> (${newCharacterState})`);
|
||||
if (newAnimName == playingAnimName) {
|
||||
if (ATK_CHARACTER_STATE.Idle1[0] == newCharacterState || ATK_CHARACTER_STATE.Walking[0] == newCharacterState) {
|
||||
// No need to interrupt
|
||||
// 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)}`);
|
||||
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) {
|
||||
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) {
|
||||
self.recentRenderCache.pop();
|
||||
}
|
||||
@@ -537,7 +537,7 @@ cc.Class({
|
||||
window.initPersistentSessionClient(self.initAfterWSConnected, boundRoomId);
|
||||
} else {
|
||||
self.showPopupInCanvas(self.gameRuleNode);
|
||||
// Deliberately left blank. -- YFLu
|
||||
// Deliberately left blank. -- YFLu
|
||||
}
|
||||
},
|
||||
|
||||
@@ -610,28 +610,32 @@ cc.Class({
|
||||
}
|
||||
}
|
||||
|
||||
self.renderFrameId = rdf.id;
|
||||
self.lastRenderFrameIdTriggeredAt = performance.now();
|
||||
// In this case it must be true that "rdf.id > chaserRenderFrameId >= lastAllConfirmedRenderFrameId".
|
||||
self.lastAllConfirmedRenderFrameId = rdf.id;
|
||||
self.chaserRenderFrameId = rdf.id;
|
||||
if (null == self.renderFrameId || self.renderFrameId <= rdf.id) {
|
||||
// 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
|
||||
self.renderFrameId = rdf.id;
|
||||
self.lastRenderFrameIdTriggeredAt = performance.now();
|
||||
// In this case it must be true that "rdf.id > chaserRenderFrameId >= lastAllConfirmedRenderFrameId".
|
||||
self.lastAllConfirmedRenderFrameId = rdf.id;
|
||||
self.chaserRenderFrameId = rdf.id;
|
||||
|
||||
if (null != rdf.countdownNanos) {
|
||||
self.countdownNanos = rdf.countdownNanos;
|
||||
}
|
||||
if (null != self.musicEffectManagerScriptIns) {
|
||||
self.musicEffectManagerScriptIns.playBGM();
|
||||
}
|
||||
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));
|
||||
const canvasNode = self.canvasNode;
|
||||
self.ctrl = canvasNode.getComponent("TouchEventsManager");
|
||||
self.enableInputControls();
|
||||
self.transitToState(ALL_MAP_STATES.VISUAL);
|
||||
self.battleState = ALL_BATTLE_STATES.IN_BATTLE;
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
@@ -823,17 +827,17 @@ cc.Class({
|
||||
console.error("Error during Map.update", err);
|
||||
} finally {
|
||||
// Update countdown
|
||||
if (null != self.countdownNanos) {
|
||||
self.countdownNanos = self.battleDurationNanos - self.renderFrameId * self.rollbackEstimatedDtNanos;
|
||||
if (self.countdownNanos <= 0) {
|
||||
self.onBattleStopped(self.playerRichInfoDict);
|
||||
return;
|
||||
}
|
||||
self.countdownNanos = self.battleDurationNanos - self.renderFrameId * self.rollbackEstimatedDtNanos;
|
||||
if (self.countdownNanos <= 0) {
|
||||
self.onBattleStopped(self.playerRichInfoDict);
|
||||
return;
|
||||
}
|
||||
|
||||
const countdownSeconds = parseInt(self.countdownNanos / 1000000000);
|
||||
if (isNaN(countdownSeconds)) {
|
||||
console.warn(`countdownSeconds is NaN for countdownNanos == ${self.countdownNanos}.`);
|
||||
}
|
||||
const countdownSeconds = parseInt(self.countdownNanos / 1000000000);
|
||||
if (isNaN(countdownSeconds)) {
|
||||
console.warn(`countdownSeconds is NaN for countdownNanos == ${self.countdownNanos}.`);
|
||||
}
|
||||
if (null != self.countdownLabel) {
|
||||
self.countdownLabel.string = countdownSeconds;
|
||||
}
|
||||
++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 bulletWx = offenderWx + xfac * meleeBullet.hitboxOffset;
|
||||
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);
|
||||
newBulletCollider.data = meleeBullet;
|
||||
collisionSysMap.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);
|
||||
}
|
||||
|
||||
const startRdf = {
|
||||
const startRdf = window.pb.protos.RoomDownsyncFrame.create({
|
||||
id: window.MAGIC_ROOM_DOWNSYNC_FRAME_ID.BATTLE_START,
|
||||
players: {
|
||||
10: {
|
||||
@@ -160,7 +160,7 @@ cc.Class({
|
||||
dirY: 0,
|
||||
},
|
||||
}
|
||||
};
|
||||
});
|
||||
self.selfPlayerInfo = {
|
||||
id: 10
|
||||
};
|
||||
|
Reference in New Issue
Block a user