Support dynamic tick rates

pull/33/head
tobspr 4 years ago
parent 65529cce1a
commit ca0e17f3dd

@ -0,0 +1,10 @@
#ingame_HUD_DebugInfo {
position: absolute;
@include S(bottom, 5px);
@include S(left, 5px);
font-size: 15px;
display: flex;
line-height: 15px;
flex-direction: column;
}

@ -39,6 +39,7 @@
@import "ingame_hud/pinned_shapes";
@import "ingame_hud/notifications";
@import "ingame_hud/settings_menu";
@import "ingame_hud/debug_info";
// prettier-ignore
$elements:
@ -57,6 +58,7 @@ ingame_HUD_GameMenu,
ingame_HUD_KeybindingOverlay,
ingame_HUD_Notifications,
ingame_HUD_MassSelector,
ingame_HUD_DebugInfo,
// Overlays
ingame_HUD_BetaOverlay,

@ -23,13 +23,8 @@ export const globalConfig = {
statisticsGraphSlices: 100,
analyticsSliceDurationSeconds: 10,
// [Calculated] physics step size
physicsDeltaMs: 0,
physicsDeltaSeconds: 0,
// Update physics at N fps, independent of rendering
// physicsUpdateRate: 55,
physicsUpdateRate: 120,
minimumTickRate: 30,
maximumTickRate: 500,
// Map
mapChunkSize: 32,
@ -76,7 +71,7 @@ export const globalConfig = {
debug: {
/* dev:start */
fastGameEnter: true,
// fastGameEnter: true,
noArtificialDelays: true,
// disableSavegameWrite: true,
showEntityBounds: false,
@ -111,7 +106,4 @@ export const IS_MOBILE = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
// Automatic calculations
globalConfig.physicsDeltaMs = 1000.0 / globalConfig.physicsUpdateRate;
globalConfig.physicsDeltaSeconds = 1.0 / globalConfig.physicsUpdateRate;
globalConfig.minerSpeedItemsPerSecond = globalConfig.beltSpeedItemsPerSecond / 5;

@ -62,7 +62,7 @@ export class UndergroundBeltComponent extends Component {
return false;
}
this.pendingItems.push([item, 1 / beltSpeed / globalConfig.itemSpacingOnBelts]);
this.pendingItems.push([item, 0]);
return true;
}
@ -88,7 +88,8 @@ export class UndergroundBeltComponent extends Component {
// NOTICE:
// This corresponds to the item ejector - it needs 0.5 additional tiles to eject the item.
// So instead of adding 1 we add 0.5 only.
const travelDuration = (travelDistance + 0.5) / beltSpeed / globalConfig.itemSpacingOnBelts;
// Additionally it takes 1 tile for the acceptor which we just add on top.
const travelDuration = (travelDistance + 1.5) / beltSpeed / globalConfig.itemSpacingOnBelts;
this.pendingItems.push([item, travelDuration]);

@ -32,6 +32,7 @@ import { GameTime } from "./time/game_time";
import { ProductionAnalytics } from "./production_analytics";
import { randomInt } from "../core/utils";
import { defaultBuildingVariant } from "./meta_building";
import { DynamicTickrate } from "./dynamic_tickrate";
const logger = createLogger("ingame/core");
@ -53,16 +54,6 @@ export class GameCore {
/** @type {GameRoot} */
this.root = null;
/**
* Time budget (seconds) for logic updates
*/
this.logicTimeBudget = 0;
/**
* Time budget (seconds) for user interface updates
*/
this.uiTimeBudget = 0;
/**
* Set to true at the beginning of a logic update and cleared when its finished.
* This is to prevent doing a recursive logic update which can lead to unexpected
@ -97,6 +88,9 @@ export class GameCore {
// This isn't nice, but we need it right here
root.gameState.keyActionMapper = new KeyActionMapper(root, this.root.gameState.inputReciever);
// Needs to come first
root.dynamicTickrate = new DynamicTickrate(root);
// Init classes
root.camera = new Camera(root);
root.map = new MapView(root);
@ -250,17 +244,6 @@ export class GameCore {
// Perform logic ticks
this.root.time.performTicks(deltaMs, this.boundInternalTick);
// Update UI particles
this.uiTimeBudget += deltaMs;
const maxUiSteps = 3;
if (this.uiTimeBudget > globalConfig.physicsDeltaMs * maxUiSteps) {
this.uiTimeBudget = globalConfig.physicsDeltaMs;
}
while (this.uiTimeBudget >= globalConfig.physicsDeltaMs) {
this.uiTimeBudget -= globalConfig.physicsDeltaMs;
// root.uiParticleMgr.update();
}
// Update analytics
root.productionAnalytics.update();
@ -288,6 +271,9 @@ export class GameCore {
updateLogic() {
const root = this.root;
root.dynamicTickrate.beginTick();
this.duringLogicUpdate = true;
// Update entities, this removes destroyed entities
@ -296,6 +282,8 @@ export class GameCore {
// IMPORTANT: At this point, the game might be game over. Stop if this is the case
if (!this.root) {
logger.log("Root destructed, returning false");
root.dynamicTickrate.endTick();
return false;
}
@ -303,7 +291,7 @@ export class GameCore {
// root.particleMgr.update();
this.duringLogicUpdate = false;
root.dynamicTickrate.endTick();
return true;
}

@ -0,0 +1,104 @@
import { GameRoot } from "./root";
import { createLogger } from "../core/logging";
import { globalConfig } from "../core/config";
import { performanceNow, Math_min, Math_round, Math_max } from "../core/builtins";
import { round3Digits } from "../core/utils";
const logger = createLogger("dynamic_tickrate");
export class DynamicTickrate {
/**
*
* @param {GameRoot} root
*/
constructor(root) {
this.root = root;
this.setTickRate(120);
this.currentTickStart = null;
this.capturedTicks = [];
this.averageTickDuration = 0;
// Exposed
this.deltaSeconds = 0;
this.deltaMs = 0;
}
/**
* Sets the tick rate to N updates per second
* @param {number} rate
*/
setTickRate(rate) {
logger.log("Applying tick-rate of", rate);
this.currentTickRate = rate;
this.deltaMs = 1000.0 / this.currentTickRate;
this.deltaSeconds = 1.0 / this.currentTickRate;
}
/**
* Increases the tick rate marginally
*/
increaseTickRate() {
this.setTickRate(Math_round(Math_min(globalConfig.maximumTickRate, this.currentTickRate * 1.1)));
}
/**
* Decreases the tick rate marginally
*/
decreaseTickRate() {
this.setTickRate(Math_round(Math_min(globalConfig.maximumTickRate, this.currentTickRate * 0.9)));
}
/**
* Call whenever a tick began
*/
beginTick() {
assert(this.currentTickStart === null, "BeginTick called twice");
this.currentTickStart = performanceNow();
if (this.capturedTicks.length > this.currentTickRate * 4) {
// Take only a portion of the ticks
this.capturedTicks.sort();
this.capturedTicks.splice(0, 10);
this.capturedTicks.splice(this.capturedTicks.length - 11, 10);
let average = 0;
for (let i = 0; i < this.capturedTicks.length; ++i) {
average += this.capturedTicks[i];
}
average /= this.capturedTicks.length;
// Calculate tick duration to cover X% of the frame
const ticksPerFrame = this.currentTickRate / 60;
const maxFrameDurationMs = 8;
const maxTickDuration = maxFrameDurationMs / ticksPerFrame;
// const maxTickDuration = (1000 / this.currentTickRate) * 0.75;
logger.log(
"Average time per tick:",
round3Digits(average) + "ms",
"allowed are",
maxTickDuration
);
this.averageTickDuration = average;
if (average < maxTickDuration) {
this.increaseTickRate();
} else {
this.decreaseTickRate();
}
this.capturedTicks = [];
}
}
/**
* Call whenever a tick ended
*/
endTick() {
assert(this.currentTickStart !== null, "EndTick called without BeginTick");
const duration = performanceNow() - this.currentTickStart;
this.capturedTicks.push(duration);
this.currentTickStart = null;
}
}

@ -21,6 +21,7 @@ import { HUDPinnedShapes } from "./parts/pinned_shapes";
import { ShapeDefinition } from "../shape_definition";
import { HUDNotifications, enumNotificationType } from "./parts/notifications";
import { HUDSettingsMenu } from "./parts/settings_menu";
import { HUDDebugInfo } from "./parts/debug_info";
export class GameHUD {
/**
@ -57,6 +58,7 @@ export class GameHUD {
settingsMenu: new HUDSettingsMenu(this.root),
// betaOverlay: new HUDBetaOverlay(this.root),
debugInfo: new HUDDebugInfo(this.root),
};
this.signals = {

@ -0,0 +1,19 @@
import { BaseHUDPart } from "../base_hud_part";
import { makeDiv, round3Digits } from "../../../core/utils";
export class HUDDebugInfo extends BaseHUDPart {
createElements(parent) {
this.element = makeDiv(parent, "ingame_HUD_DebugInfo", []);
this.tickRateElement = makeDiv(this.element, null, ["tickRate"], "Ticks /s: 120");
this.tickDurationElement = makeDiv(this.element, null, ["tickDuration"], "Update time: 0.5ms");
}
initialize() {}
update() {
this.tickRateElement.innerText = "Tickrate: " + this.root.dynamicTickrate.currentTickRate;
this.tickDurationElement.innerText =
"Avg. Dur: " + round3Digits(this.root.dynamicTickrate.averageTickDuration) + "ms";
}
}

@ -26,6 +26,7 @@ import { ProductionAnalytics } from "./production_analytics";
import { Entity } from "./entity";
import { ShapeDefinition } from "./shape_definition";
import { BaseItem } from "./base_item";
import { DynamicTickrate } from "./dynamic_tickrate";
/* typehints:end */
const logger = createLogger("game/root");
@ -115,6 +116,9 @@ export class GameRoot {
/** @type {ProductionAnalytics} */
this.productionAnalytics = null;
/** @type {DynamicTickrate} */
this.dynamicTickrate = null;
this.signals = {
// Entities
entityAdded: /** @type {TypedSignal<[Entity]>} */ (new Signal()),

@ -109,81 +109,101 @@ export class BeltSystem extends GameSystemWithFilter {
this.forEachMatchingEntityOnScreen(parameters, this.drawEntityItems.bind(this));
}
update() {
/**
* Updates a given entity
* @param {Entity} entity
* @param {Set} processedEntities
*/
updateBelt(entity, processedEntities) {
if (processedEntities.has(entity.uid)) {
return;
}
processedEntities.add(entity.uid);
// Divide by item spacing on belts since we use throughput and not speed
const beltSpeed =
this.root.hubGoals.getBeltBaseSpeed() *
globalConfig.physicsDeltaSeconds *
this.root.dynamicTickrate.deltaSeconds *
globalConfig.itemSpacingOnBelts;
const beltComp = entity.components.Belt;
const staticComp = entity.components.StaticMapEntity;
const items = beltComp.sortedItems;
for (let i = 0; i < this.allEntities.length; ++i) {
const entity = this.allEntities[i];
const beltComp = entity.components.Belt;
const staticComp = entity.components.StaticMapEntity;
const items = beltComp.sortedItems;
if (items.length === 0) {
// Fast out for performance
continue;
}
if (items.length === 0) {
// Fast out for performance
return;
}
const ejectorComp = entity.components.ItemEjector;
let maxProgress = 1;
const ejectorComp = entity.components.ItemEjector;
let maxProgress = 1;
// When ejecting, we can not go further than the item spacing since it
// will be on the corner
if (ejectorComp.isAnySlotEjecting()) {
maxProgress = 1 - globalConfig.itemSpacingOnBelts;
} else {
// Find follow up belt to make sure we don't clash items
const followUpDirection = staticComp.localDirectionToWorld(beltComp.direction);
const followUpVector = enumDirectionToVector[followUpDirection];
const followUpTile = staticComp.origin.add(followUpVector);
const followUpEntity = this.root.map.getTileContent(followUpTile);
if (followUpEntity) {
const followUpBeltComp = followUpEntity.components.Belt;
if (followUpBeltComp) {
const spacingOnBelt = followUpBeltComp.getDistanceToFirstItemCenter();
maxProgress = Math_min(1, 1 - globalConfig.itemSpacingOnBelts + spacingOnBelt);
}
// When ejecting, we can not go further than the item spacing since it
// will be on the corner
if (ejectorComp.isAnySlotEjecting()) {
maxProgress = 1 - globalConfig.itemSpacingOnBelts;
} else {
// Find follow up belt to make sure we don't clash items
const followUpDirection = staticComp.localDirectionToWorld(beltComp.direction);
const followUpVector = enumDirectionToVector[followUpDirection];
const followUpTile = staticComp.origin.add(followUpVector);
const followUpEntity = this.root.map.getTileContent(followUpTile);
if (followUpEntity) {
const followUpBeltComp = followUpEntity.components.Belt;
if (followUpBeltComp) {
// Update follow up belt first
this.updateBelt(followUpEntity, processedEntities);
const spacingOnBelt = followUpBeltComp.getDistanceToFirstItemCenter();
maxProgress = Math_min(1, 1 - globalConfig.itemSpacingOnBelts + spacingOnBelt);
}
}
}
let speedMultiplier = 1;
if (beltComp.direction !== enumDirection.top) {
// Shaped belts are longer, thus being quicker
speedMultiplier = 1.41;
}
let speedMultiplier = 1;
if (beltComp.direction !== enumDirection.top) {
// Shaped belts are longer, thus being quicker
speedMultiplier = 1.41;
}
for (let itemIndex = items.length - 1; itemIndex >= 0; --itemIndex) {
const itemAndProgress = items[itemIndex];
const newProgress = itemAndProgress[0] + speedMultiplier * beltSpeed;
if (newProgress >= 1.0) {
// Try to give this item to a new belt
const freeSlot = ejectorComp.getFirstFreeSlot();
if (freeSlot === null) {
// So, we don't have a free slot - damned!
itemAndProgress[0] = 1.0;
maxProgress = 1 - globalConfig.itemSpacingOnBelts;
} else {
// We got a free slot, remove this item and keep it on the ejector slot
if (!ejectorComp.tryEject(freeSlot, itemAndProgress[1])) {
assert(false, "Ejection failed");
}
items.splice(itemIndex, 1);
maxProgress = 1;
}
for (let itemIndex = items.length - 1; itemIndex >= 0; --itemIndex) {
const itemAndProgress = items[itemIndex];
const newProgress = itemAndProgress[0] + speedMultiplier * beltSpeed;
if (newProgress >= 1.0) {
// Try to give this item to a new belt
const freeSlot = ejectorComp.getFirstFreeSlot();
if (freeSlot === null) {
// So, we don't have a free slot - damned!
itemAndProgress[0] = 1.0;
maxProgress = 1 - globalConfig.itemSpacingOnBelts;
} else {
itemAndProgress[0] = Math_min(newProgress, maxProgress);
maxProgress = itemAndProgress[0] - globalConfig.itemSpacingOnBelts;
// We got a free slot, remove this item and keep it on the ejector slot
if (!ejectorComp.tryEject(freeSlot, itemAndProgress[1])) {
assert(false, "Ejection failed");
}
items.splice(itemIndex, 1);
maxProgress = 1;
}
} else {
itemAndProgress[0] = Math_min(newProgress, maxProgress);
maxProgress = itemAndProgress[0] - globalConfig.itemSpacingOnBelts;
}
}
}
update() {
const processedEntities = new Set();
for (let i = 0; i < this.allEntities.length; ++i) {
const entity = this.allEntities[i];
this.updateBelt(entity, processedEntities);
}
}
/**
*
* @param {DrawParameters} parameters

@ -31,7 +31,7 @@ export class ItemAcceptorSystem extends GameSystemWithFilter {
for (let animIndex = 0; animIndex < aceptorComp.itemConsumptionAnimations.length; ++animIndex) {
const anim = aceptorComp.itemConsumptionAnimations[animIndex];
anim.animProgress +=
globalConfig.physicsDeltaSeconds *
this.root.dynamicTickrate.deltaSeconds *
this.root.hubGoals.getBeltBaseSpeed() *
2 *
globalConfig.itemSpacingOnBelts;

@ -14,7 +14,7 @@ export class ItemEjectorSystem extends GameSystemWithFilter {
update() {
const effectiveBeltSpeed = this.root.hubGoals.getBeltBaseSpeed() * globalConfig.itemSpacingOnBelts;
const progressGrowth = (effectiveBeltSpeed / 0.5) * globalConfig.physicsDeltaSeconds;
const progressGrowth = (effectiveBeltSpeed / 0.5) * this.root.dynamicTickrate.deltaSeconds;
// Try to find acceptors for every ejector
for (let i = 0; i < this.allEntities.length; ++i) {

@ -23,7 +23,7 @@ export class ItemProcessorSystem extends GameSystemWithFilter {
// First of all, process the current recipe
processorComp.secondsUntilEject = Math_max(
0,
processorComp.secondsUntilEject - globalConfig.physicsDeltaSeconds
processorComp.secondsUntilEject - this.root.dynamicTickrate.deltaSeconds
);
// Check if we have any finished items we can eject

@ -31,7 +31,7 @@ export class UndergroundBeltSystem extends GameSystemWithFilter {
// Decrease remaining time of all items in belt
for (let k = 0; k < undergroundComp.pendingItems.length; ++k) {
const item = undergroundComp.pendingItems[k];
item[1] = Math_max(0, item[1] - globalConfig.physicsDeltaSeconds);
item[1] = Math_max(0, item[1] - this.root.dynamicTickrate.deltaSeconds);
}
if (undergroundComp.mode === enumUndergroundBeltMode.sender) {

@ -6,7 +6,7 @@ import { types, BasicSerializableObject } from "../../savegame/serialization";
import { RegularGameSpeed } from "./regular_game_speed";
import { BaseGameSpeed } from "./base_game_speed";
import { PausedGameSpeed } from "./paused_game_speed";
import { performanceNow } from "../../core/builtins";
import { performanceNow, Math_max } from "../../core/builtins";
import { FastForwardGameSpeed } from "./fast_forward_game_speed";
import { gGameSpeedRegistry } from "../../core/global_registries";
import { globalConfig } from "../../core/config";
@ -102,7 +102,7 @@ export class GameTime extends BasicSerializableObject {
* Internal method to generate new logic time budget
* @param {number} deltaMs
*/
înternalAddDeltaToBudget(deltaMs) {
internalAddDeltaToBudget(deltaMs) {
// Only update if game is supposed to update
if (this.root.hud.shouldPauseGame()) {
this.logicTimeBudget = 0;
@ -112,9 +112,13 @@ export class GameTime extends BasicSerializableObject {
}
// Check for too big pile of updates -> reduce it to 1
const maxLogicSteps = this.speed.getMaxLogicStepsInQueue();
if (this.logicTimeBudget > globalConfig.physicsDeltaMs * maxLogicSteps) {
this.logicTimeBudget = globalConfig.physicsDeltaMs * maxLogicSteps;
const maxLogicSteps = Math_max(
3,
(this.speed.getMaxLogicStepsInQueue() * this.root.dynamicTickrate.currentTickRate) / 60
);
if (this.logicTimeBudget > this.root.dynamicTickrate.deltaMs * maxLogicSteps) {
// logger.warn("Skipping logic time steps since more than", maxLogicSteps, "are in queue");
this.logicTimeBudget = this.root.dynamicTickrate.deltaMs * maxLogicSteps;
}
}
@ -124,13 +128,13 @@ export class GameTime extends BasicSerializableObject {
* @param {function():boolean} updateMethod
*/
performTicks(deltaMs, updateMethod) {
this.înternalAddDeltaToBudget(deltaMs);
this.internalAddDeltaToBudget(deltaMs);
const speedAtStart = this.root.time.getSpeed();
// Update physics & logic
while (this.logicTimeBudget >= globalConfig.physicsDeltaMs) {
this.logicTimeBudget -= globalConfig.physicsDeltaMs;
while (this.logicTimeBudget >= this.root.dynamicTickrate.deltaMs) {
this.logicTimeBudget -= this.root.dynamicTickrate.deltaMs;
if (!updateMethod()) {
// Gameover happened or so, do not update anymore
@ -138,7 +142,7 @@ export class GameTime extends BasicSerializableObject {
}
// Step game time
this.timeSeconds = quantizeFloat(this.timeSeconds + globalConfig.physicsDeltaSeconds);
this.timeSeconds = quantizeFloat(this.timeSeconds + this.root.dynamicTickrate.deltaSeconds);
// Game time speed changed, need to abort since our logic steps are no longer valid
if (speedAtStart.getId() !== this.speed.getId()) {

@ -179,7 +179,12 @@ export class MainMenuState extends GameState {
this.trackClicks(qs(".mainContainer .importButton"), this.requestImportSavegame);
if (G_IS_DEV && globalConfig.debug.fastGameEnter) {
this.onPlayButtonClicked();
const games = this.app.savegameMgr.getSavegamesMetaData();
if (games.length > 0) {
this.resumeGame(games[0]);
} else {
this.onPlayButtonClicked();
}
}
// Initialize video

Loading…
Cancel
Save