Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: fix broken targetTime control for long loading time #177

Merged
merged 5 commits into from
Jan 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# CHANGELOG

## 2.11.1
* 目標時刻を指定したリプレイ実行がローディングなどにかかる時間の影響を受けている問題を修正

## 2.11.0
* @akashic/akashic-engine@3.9.0 に追従

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@akashic/game-driver",
"version": "2.11.0",
"version": "2.11.1",
"description": "The driver module for the games using Akashic Engine",
"main": "index.js",
"typings": "lib/index.d.ts",
Expand Down
110 changes: 64 additions & 46 deletions src/GameLoop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,25 @@ export class GameLoop {

/**
* 時刻。
* 実時間ではなく、プレイ開始日時と経過フレーム数から計算される仮想的な時間であることに注意
* 実時間ではなく、プレイ開始日時と経過フレーム数から計算される仮想的な時間である
* この時間情報を元にタイムスタンプイベントの消化待ちを行う。
*
* _crrentTickTime と異なり、ローカルティックを消化している間も進行する。
*/
_currentTime: number;

/**
* ゲーム内時刻。
* 実時間ではなく、プレイ開始日時と非ローカルティックの消化状況から計算される仮想的な時間である。
* この時間情報を元に目標時刻への到達判定を行う。
*
* _currentTime と異なり、ローカルティックを消化している間は進行しない。
*/
_currentTickTime: number;

/**
* 1フレーム分の時間。FPSの逆数。
* _currentTime の計算に用いる。
* _currentTime, _currentTickTime の計算に用いる。
*/
_frameTime: number;

Expand Down Expand Up @@ -127,6 +138,7 @@ export class GameLoop {

constructor(param: GameLoopParameterObejct) {
this._currentTime = param.startedAt;
this._currentTickTime = this._currentTime;
this._frameTime = 1000 / param.game.fps;

if (param.errorHandler) {
Expand Down Expand Up @@ -215,6 +227,7 @@ export class GameLoop {
this._stopSkipping();
this._tickBuffer.setCurrentAge(startPoint.frame);
this._currentTime = startPoint.timestamp || startPoint.data.timestamp || 0; // data.timestamp は後方互換性のために存在。現在は使っていない。
this._currentTickTime = this._currentTime;
this._waitingNextTick = false; // 現在ageを変えた後、さらに後続のTickが足りないかどうかは_onFrameで判断する。
this._foundLatestTick = false; // 同上。
this._lastRequestedStartPointAge = -1; // 現在ageを変えた時はリセットしておく(場合によっては不要だが、安全のため)。
Expand Down Expand Up @@ -403,7 +416,7 @@ export class GameLoop {
_doLocalTick(): void {
const game = this._game;
const pevs = this._eventBuffer.readLocalEvents();
this._currentTime += this._frameTime;
this._currentTime += this._frameTime; // ここでは _currenTickTime は進まないことに注意 (ローカルティック消化では進まない)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

修正のキモ4: ローカルティック消化では _currentTickTime を進めません。

コメント通り。他修正箇所多数ですが、大半は _currentTime の代わりに _currenTtickTime を使うことによる変更です。

if (pevs) {
game.tick(false, Math.floor(this._omittedTickDuration / this._frameTime), pevs);
} else {
Expand All @@ -423,12 +436,12 @@ export class GameLoop {
} else {
const givenTargetTime = this._targetTimeFunc();
const targetTime = givenTargetTime + this._realTargetTimeOffset;
const prevTime = this._currentTime;
const prevTickTime = this._currentTickTime;
this._onFrameForTimedReplay(targetTime, frameArg);
// 目標時刻到達判定: 進めなくなり、あと1フレームで目標時刻を過ぎるタイミングを到達として通知する。
// 時間進行を進めていっても目標時刻 "以上" に進むことはないので「過ぎた」タイミングは使えない点に注意。
// (また、それでもなお (prevTime <= targetTime) の条件はなくせない点にも注意。巻き戻す時は (prevTime > targetTime) になる)
if ((prevTime === this._currentTime) && (prevTime <= targetTime) && (targetTime <= prevTime + this._frameTime))
if ((prevTickTime === this._currentTickTime) && (prevTickTime <= targetTime) && this._isImmediateBeforeOf(targetTime))
this.rawTargetTimeReachedTrigger.fire(givenTargetTime);
}
}
Expand All @@ -444,12 +457,12 @@ export class GameLoop {
_onFrameForTimedReplay(targetTime: number, frameArg: ClockFrameTriggerParameterObject): void {
let sceneChanged = false;
const game = this._game;
const timeGap = targetTime - this._currentTime;
const timeGap = targetTime - this._currentTickTime;
const frameGap = (timeGap / this._frameTime);

if ((frameGap > this._jumpTryThreshold || frameGap < 0) &&
(!this._waitingStartPoint) &&
(this._lastRequestedStartPointTime < this._currentTime)) {
(this._lastRequestedStartPointTime < this._currentTickTime)) {
// スナップショットを要求だけして続行する(スナップショットが来るまで進める限りは進む)。
this._waitingStartPoint = true;
this._lastRequestedStartPointTime = targetTime;
Expand Down Expand Up @@ -482,7 +495,7 @@ export class GameLoop {
}
if (this._omitInterpolatedTickOnReplay && this._sceneLocalMode === "interpolate-local") {
if (this._foundLatestTick) {
// 最新のティックが存在しない場合は現在時刻を目標時刻に合わせる
// これ以上新しいティックが存在しない場合は現在時刻を目標時刻に合わせる
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

別件: コメントがわかりにくかったので調整。

// (_doLocalTick() により現在時刻が this._frameTime 進むのでその直前まで進める)
this._currentTime = targetTime - this._frameTime;
}
Expand All @@ -494,42 +507,37 @@ export class GameLoop {
break;
}

let nextTickTime = this._tickBuffer.readNextTickTime();
if (nextTickTime == null)
nextTickTime = nextFrameTime;
if (targetTime < nextFrameTime) {
// 次フレームに進むと目標時刻を超過する=次フレーム時刻までは進めない=補間ティックは必要ない。
if (nextTickTime <= targetTime) {
// 特殊ケース: 目標時刻より手前に次ティックがあるので、目標時刻までは進んで次ティックは消化してしまう。
// (この処理がないと、特にリプレイで「最後のティックの0.1フレーム時間前」などに来たときに進めなくなってしまう。)
nextFrameTime = targetTime;
const nextTickTime = this._tickBuffer.readNextTickTime() ?? (this._currentTickTime + this._frameTime);
Copy link
Member Author

@xnv xnv Dec 26, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

修正のキモ1: tick に timestamp がない時の「次のティック時刻」 nextTickTime を正しく求めます。

これは「現在のティック時刻」 _currenTickTime を保持することで可能になりました。これまでは nextFrameTime (current time + 1/FPS) で求めており、これはロード時間を含めて進むゲーム外時間なので正しくありませんでした。


Copy link
Member Author

@xnv xnv Dec 26, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

修正のキモ2: 条件式を変更しています。ややこしいので整理します。

  • f': nextFrameTime (_currenTime + 1/FPS)
  • t': nextTickTime (次ティックのタイムスタンプ時刻、なければ _currenTickTime + 1/FPS)
  • g: targetTime

と置きます。f', t', g の大小関係は次の6通りで、それに応じた処理は概ね 3 種類です:

A) g  ≦ t' ≦ f' (目標時刻に到達している → 何もしない)
B) g  ≦ f' < t' (同上)
C) t' ≦ f' < g  (次ティック時刻に到達していて、次ティックも目標時刻より手前 → 次ティックを消化する)
D) t' < g  ≦ f' (同上)
E) f' < t' < g  (次ティック時刻の消化タイミング待ち → ローカルティックを消化する (か、次ティック時刻まで飛ぶ))
F) f' < g  ≦ t' (次ティック手前の目標時刻まで進みたい → ローカルティックを消化する (か、目標時刻まで飛ぶ))

if-then 式に整理すると

1. (g ≦ t') かつ (g ≦ f') ならば、A or B
2. そうでなく、(f' < t') ならば、 E or F (B は上で除外済みのため)
3. そうでなければ、 C or D

となり、これをコードにしたのがここから下の部分です。

修正前のコードと条件式が異なりますが、修正前はそもそも nextFrameTime (f') を t' と混同している、 ティックにタイムスタンプがない場合 nextTickTime (t') に f' が入る、という具合で、今にして思うとどうやって辻褄を合わせていたのか首を傾げる様相なので、比較しないでください。

if (targetTime <= nextTickTime && targetTime <= nextFrameTime) {
// 次ティックを消化すると目標時刻に到達・超過する: 次ティックは消化できない
// 次フレーム時刻も目標時刻に到達・超過する: ローカルティック補完も要らない
break;

} else if (nextFrameTime < nextTickTime) {
// 次フレーム時刻ではまだ次ティックを消化できない: ローカルティック補完するか、次ティック時刻まで一気に進む
if (this._omitInterpolatedTickOnReplay && this._skipping) {
// スキップ中、ティック補間不要なら即座に次ティック時刻(かその手前の目標時刻)まで進める。
// (_onFrameNormal()の対応箇所と異なり、ここでは「次ティック時刻の "次フレーム時刻"」に切り上げないことに注意。
// 時間ベースリプレイでは目標時刻 "以後" には進めないという制約がある。これを単純な実装で守るべく切り上げを断念している)
if (targetTime <= nextTickTime) {
// 次ティック時刻まで進めると目標時刻を超えてしまう: 目標時刻直前まで動いて抜ける(目標時刻直前までは来ないと目標時刻到達通知が永久にできない)
this._omittedTickDuration += targetTime - this._currentTickTime;
this._currentTime = Math.floor(targetTime / this._frameTime) * this._frameTime;
break;
}
nextFrameTime = nextTickTime;
this._omittedTickDuration += nextTickTime - this._currentTickTime;
} else {
break;
}
} else {
if (nextFrameTime < nextTickTime) {
if (this._omitInterpolatedTickOnReplay && this._skipping) {
// スキップ中、ティック補間不要なら即座に次ティック時刻(かその手前の目標時刻)まで進める。
// (_onFrameNormal()の対応箇所と異なり、ここでは「次ティック時刻の "次フレーム時刻"」に切り上げないことに注意。
// 時間ベースリプレイでは目標時刻 "以後" には進めないという制約がある。これを単純な実装で守るべく切り上げを断念している)
if (targetTime <= nextTickTime) {
// 次ティック時刻まで進めると目標時刻を超えてしまう: 目標時刻直前まで動いて抜ける(目標時刻直前までは来ないと目標時刻到達通知が永久にできない)
this._omittedTickDuration += targetTime - this._currentTime;
this._currentTime = Math.floor(targetTime / this._frameTime) * this._frameTime;
break;
}
nextFrameTime = nextTickTime;
this._omittedTickDuration += nextTickTime - this._currentTime;
} else {
if (this._sceneLocalMode === "interpolate-local") {
this._doLocalTick();
}
continue;
if (this._sceneLocalMode === "interpolate-local") {
this._doLocalTick();
}
continue;
}
}

this._currentTime = nextFrameTime;
this._currentTickTime = nextTickTime;
const tick = this._tickBuffer.consume();
let consumedAge = -1;
this._events.length = 0;
Expand Down Expand Up @@ -567,7 +575,7 @@ export class GameLoop {
}
}

if (this._skipping && (targetTime - this._currentTime < this._frameTime)) {
if (this._skipping && (targetTime - this._currentTime < this._frameTime) && this._isImmediateBeforeOf(targetTime)) {
this._stopSkipping();
// スキップ状態が解除された (≒等倍に戻った) タイミングで改めてすべてのティックを取得し直す
this._tickBuffer.dropAll();
Expand Down Expand Up @@ -668,7 +676,7 @@ export class GameLoop {
// ここでは常に (ageGap > 0) であることに注意。(0の時にskipに入ってもすぐ戻ってしまう)
const isTargetNear =
(currentAge === 0) && // 余計な関数呼び出しを避けるためにチェック
this._tickBuffer.isKnownLatestTickTimeNear(this._skipThresholdTime, this._currentTime, this._frameTime);
this._tickBuffer.isKnownLatestTickTimeNear(this._skipThresholdTime, this._currentTickTime, this._frameTime);
this._startSkipping(isTargetNear);
}

Expand All @@ -678,13 +686,13 @@ export class GameLoop {
for (; consumedFrame < loopCount; ++consumedFrame) {
// ティック時刻確認
let nextFrameTime = this._currentTime + this._frameTime;
const nextTickTime = this._tickBuffer.readNextTickTime();
if (nextTickTime != null && nextFrameTime < nextTickTime) {
const explicitNextTickTime = this._tickBuffer.readNextTickTime();
if (explicitNextTickTime != null && nextFrameTime < explicitNextTickTime) {
if (this._loopMode === LoopMode.Realtime || (this._omitInterpolatedTickOnReplay && this._skipping)) {
// リアルタイムモード(と早送り中のリプレイでティック補間しない場合)ではティック時刻を気にせず続行するが、
// リプレイモードに切り替えた時に矛盾しないよう時刻を補正する(当該ティック時刻まで待った扱いにする)。
nextFrameTime = Math.ceil(nextTickTime / this._frameTime) * this._frameTime;
this._omittedTickDuration += nextFrameTime - this._currentTime;
nextFrameTime = Math.ceil(explicitNextTickTime / this._frameTime) * this._frameTime;
this._omittedTickDuration += nextFrameTime - this._currentTickTime;
} else {
if (this._sceneLocalMode === "interpolate-local") {
this._doLocalTick();
Expand All @@ -695,6 +703,7 @@ export class GameLoop {
}

this._currentTime = nextFrameTime;
this._currentTickTime = explicitNextTickTime ?? (this._currentTickTime + this._frameTime);
const tick = this._tickBuffer.consume();
let consumedAge = -1;
this._events.length = 0;
Expand Down Expand Up @@ -786,8 +795,8 @@ export class GameLoop {
// 要求した時点と今で目標時刻(targetTime)が変わっている。得られたStartPointでは目標時刻より未来に飛んでしまう。
return;
}
const currentTime = this._currentTime;
if (currentTime <= targetTime && startPoint.timestamp < currentTime + (this._jumpIgnoreThreshold * this._frameTime)) {
const currentTickTime = this._currentTickTime;
if (currentTickTime <= targetTime && startPoint.timestamp < currentTickTime + (this._jumpIgnoreThreshold * this._frameTime)) {
// 今の目標時刻(targetTime)は過去でない一方、得られたStartPointは至近未来または過去のもの → 飛ぶ価値なし。
return;
}
Expand Down Expand Up @@ -865,5 +874,14 @@ export class GameLoop {
this._waitingNextTick = false;
this._clock.rawFrameTrigger.remove(this._onPollingTick, this);
}

_isImmediateBeforeOf(targetTime: number): boolean {
// 目標時刻への到達判定。次ティックがない場合は _foundLatestTick に委ねる、
// すなわち既存全ティックを消化した時は到達とみなす点に注意。あまり直観的でないが、こうでないと永久に
// rawTargetTimeReachedTrigger を fire できない可能性があり、後方互換性に影響がありうる。
return this._tickBuffer.hasNextTick() ?
(targetTime < (this._tickBuffer.readNextTickTime() ?? (this._currentTickTime + this._frameTime))) :
this._foundLatestTick;
}
Comment on lines +878 to +885
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

修正のキモ3: 目標時刻への到達 (直前かどうか) 判定用の関数を新設しています。

ゲーム内時間 (ティック時間) を目標時刻と比べる関係上、「次ティックがない」=次ティック時刻が分からない状況を考慮する必要が生じました。この場合は「最新ティックを見つけている (=最新に到達している)」ならば、その時点で目標時刻への到達 (直前) であるとして扱います。(コメントどおり)

}

2 changes: 1 addition & 1 deletion src/TickBuffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ export class TickBuffer {
*/
isKnownLatestTickTimeNear(timeThreshold: number, baseTime: number, frameTime: number): boolean {
// TODO コード整理して baseTime と frameTime の引数をなくす。
// 両者は GameLoop#_frameTime, _currentTime にそれぞれ対応している。このクラスがそれらを管理する方が自然。
// 両者は GameLoop#_frameTime, _currentTickTime にそれぞれ対応している。このクラスがそれらを管理する方が自然。
return this._calcKnownLatestTickTimeDelta(timeThreshold, baseTime, frameTime) < timeThreshold;
}

Expand Down
68 changes: 68 additions & 0 deletions src/__tests__/GameLoop.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1035,4 +1035,72 @@ describe("GameLoop", function () {
});
self.reset(zerothSp);
});

it("does not count loading time for target time handling", function (done: jest.DoneCallback) {
const startedAt = 100;

// 30FPS の 6 フレーム分 + 1。age 5 のティック時間ぴったり + 1。
// 目標時刻はその直前まで進む (直前までしか進まない) ので、+1 しないと age 5 は消化できないことに注意。
const targetTime = 201;

const zerothSp: amf.StartPoint = {
frame: 0,
timestamp: startedAt,
data: {
seed: 42,
startedAt
}
};
const amflow = new MemoryAmflowClient({
playId: "dummyPlayId",
tickList: [0, 10, []],
startPoints: [zerothSp]
});
const platform = new mockpf.Platform({ amflow });
const game = prepareGame({
title: FixtureGame.LocalTickGame,
playerId: "dummyPlayerId",
scriptLoadDelay: 2000
});
const eventBuffer = new EventBuffer({ amflow, game });
const self = new GameLoop({
amflow,
platform,
game,
eventBuffer,
executionMode: ExecutionMode.Passive,
configuration: {
loopMode: LoopMode.Replay,
targetTimeFunc: () => targetTime,
omitInterpolatedTickOnReplay: true
},
startedAt
});

let timer: any = null;
game.onResetTrigger.add(() => {
game.vars.onUpdate = () => { // LocalTickGame が毎 update コールしてくる関数
if (game.age === 5) {
// 本題: ここに到達できれば OK 。このテストではスクリプト (main.js) のロードを 2000ms 遅延させているので、
// ロード時間を含めて時間を測っていた場合 1 tick も消化しないまま目標時刻到達扱いになりここに来れない。
clearInterval(timer);
self.stop();
done();
}
};
});

self.start();
const looper = self._clock._looper as mockpf.Looper;
timer = setInterval(() => {
looper.fun(self._frameTime);
}, 1);

self.rawTargetTimeReachedTrigger.add(game._onRawTargetTimeReached, game);
game.handlerSet.setEventFilterFuncs({
addFilter: eventBuffer.addFilter.bind(eventBuffer),
removeFilter: eventBuffer.removeFilter.bind(eventBuffer)
});
self.reset(zerothSp);
}, 7000); // scriptLoadDelay が実時間で遅らせるのでデフォルトに加えて 2 秒猶予
});
17 changes: 14 additions & 3 deletions src/__tests__/helpers/MockResourceFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,8 +307,10 @@ class ScriptAsset extends pci.ScriptAsset {
return;
}
this._content = data;
if (!this.destroyed())
loader._onAssetLoad(this);
setTimeout(() => {
if (!this.destroyed())
loader._onAssetLoad(this);
}, this.resourceFactory._scriptLoadDelay);
});
}
}
Expand Down Expand Up @@ -341,15 +343,24 @@ export class AudioPlayer extends pci.AudioPlayer {
}
}

export interface ResourceFactoryParameterObject {
/**
* スクリプトアセットの読み込みを遅延する時間。ミリ秒。(ロード待ち時間関係の動作確認用)
*/
scriptLoadDelay?: number;
}

export class ResourceFactory extends pci.ResourceFactory {
scriptContents: {[key: string]: string};

_necessaryRetryCount: number;
_scriptLoadDelay: number;

constructor() {
constructor(param?: ResourceFactoryParameterObject) {
super();
this.scriptContents = {};
this._necessaryRetryCount = 0;
this._scriptLoadDelay = param?.scriptLoadDelay ?? 0;
}

// func が呼び出されている間だけ this._necessaryRetryCount を変更する。
Expand Down
3 changes: 2 additions & 1 deletion src/__tests__/helpers/prepareGame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface PrepareGameParameterObject {
title: FixtureGame;
playerId?: string;
player?: g.Player;
scriptLoadDelay?: number;
}

export function prepareGame(param: PrepareGameParameterObject): MockGame {
Expand All @@ -47,7 +48,7 @@ export function prepareGame(param: PrepareGameParameterObject): MockGame {
engineModule: g,
configuration: configuration,
handlerSet: new GameHandlerSet({ isSnapshotSaver: false }),
resourceFactory: new mockrf.ResourceFactory(),
resourceFactory: new mockrf.ResourceFactory({ scriptLoadDelay: param.scriptLoadDelay }),
assetBase: assetBase,
selfId: param.playerId,
player: param.player ? param.player : { id: param.playerId }
Expand Down