mirror of
https://github.com/tobspr/shapez.io.git
synced 2026-03-02 03:39:21 +00:00
Refactor item processor to use charges and thus be more correct, even at low tick rates
This commit is contained in:
@@ -104,8 +104,12 @@ export default {
|
||||
// Renders information about wire networks
|
||||
// renderWireNetworkInfos: true,
|
||||
// -----------------------------------------------------------------------------------
|
||||
// Disables ejector animations and processing
|
||||
// disableEjectorProcessing: true,
|
||||
// Disables ejector animations and processing
|
||||
// disableEjectorProcessing: true,
|
||||
// -----------------------------------------------------------------------------------
|
||||
// Allows manual ticking
|
||||
// manualTickOnly: true,
|
||||
// -----------------------------------------------------------------------------------
|
||||
|
||||
/* dev:end */
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,144 +1,150 @@
|
||||
import { enumDirection, enumInvertedDirections, Vector } from "../../core/vector";
|
||||
import { types } from "../../savegame/serialization";
|
||||
import { BaseItem } from "../base_item";
|
||||
import { Component } from "../component";
|
||||
|
||||
/** @typedef {{
|
||||
* pos: Vector,
|
||||
* directions: enumDirection[],
|
||||
* filter?: ItemType
|
||||
* }} ItemAcceptorSlot */
|
||||
|
||||
/**
|
||||
* Contains information about a slot plus its location
|
||||
* @typedef {{
|
||||
* slot: ItemAcceptorSlot,
|
||||
* index: number,
|
||||
* acceptedDirection: enumDirection
|
||||
* }} ItemAcceptorLocatedSlot */
|
||||
|
||||
/** @typedef {{
|
||||
* pos: Vector,
|
||||
* directions: enumDirection[],
|
||||
* filter?: ItemType
|
||||
* }} ItemAcceptorSlotConfig */
|
||||
|
||||
export class ItemAcceptorComponent extends Component {
|
||||
static getId() {
|
||||
return "ItemAcceptor";
|
||||
}
|
||||
|
||||
duplicateWithoutContents() {
|
||||
const slotsCopy = [];
|
||||
for (let i = 0; i < this.slots.length; ++i) {
|
||||
const slot = this.slots[i];
|
||||
slotsCopy.push({
|
||||
pos: slot.pos.copy(),
|
||||
directions: slot.directions.slice(),
|
||||
filter: slot.filter,
|
||||
});
|
||||
}
|
||||
|
||||
return new ItemAcceptorComponent({
|
||||
slots: slotsCopy,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {Array<ItemAcceptorSlotConfig>} param0.slots The slots from which we accept items
|
||||
*/
|
||||
constructor({ slots = [] }) {
|
||||
super();
|
||||
|
||||
/**
|
||||
* Fixes belt animations
|
||||
* @type {Array<{ item: BaseItem, slotIndex: number, animProgress: number, direction: enumDirection }>}
|
||||
*/
|
||||
this.itemConsumptionAnimations = [];
|
||||
|
||||
this.setSlots(slots);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Array<ItemAcceptorSlotConfig>} slots
|
||||
*/
|
||||
setSlots(slots) {
|
||||
/** @type {Array<ItemAcceptorSlot>} */
|
||||
this.slots = [];
|
||||
for (let i = 0; i < slots.length; ++i) {
|
||||
const slot = slots[i];
|
||||
this.slots.push({
|
||||
pos: slot.pos,
|
||||
directions: slot.directions,
|
||||
|
||||
// Which type of item to accept (shape | color | all) @see ItemType
|
||||
filter: slot.filter,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if this acceptor can accept a new item at slot N
|
||||
* @param {number} slotIndex
|
||||
* @param {BaseItem=} item
|
||||
*/
|
||||
canAcceptItem(slotIndex, item) {
|
||||
const slot = this.slots[slotIndex];
|
||||
return !slot.filter || slot.filter === item.getItemType();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an item has been accepted so that
|
||||
* @param {number} slotIndex
|
||||
* @param {enumDirection} direction
|
||||
* @param {BaseItem} item
|
||||
*/
|
||||
onItemAccepted(slotIndex, direction, item) {
|
||||
this.itemConsumptionAnimations.push({
|
||||
item,
|
||||
slotIndex,
|
||||
direction,
|
||||
animProgress: 0.0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to find a slot which accepts the current item
|
||||
* @param {Vector} targetLocalTile
|
||||
* @param {enumDirection} fromLocalDirection
|
||||
* @returns {ItemAcceptorLocatedSlot|null}
|
||||
*/
|
||||
findMatchingSlot(targetLocalTile, fromLocalDirection) {
|
||||
// We need to invert our direction since the acceptor specifies *from* which direction
|
||||
// it accepts items, but the ejector specifies *into* which direction it ejects items.
|
||||
// E.g.: Ejector ejects into "right" direction but acceptor accepts from "left" direction.
|
||||
const desiredDirection = enumInvertedDirections[fromLocalDirection];
|
||||
|
||||
// Go over all slots and try to find a target slot
|
||||
for (let slotIndex = 0; slotIndex < this.slots.length; ++slotIndex) {
|
||||
const slot = this.slots[slotIndex];
|
||||
|
||||
// Make sure the acceptor slot is on the right position
|
||||
if (!slot.pos.equals(targetLocalTile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the acceptor slot accepts items from our direction
|
||||
for (let i = 0; i < slot.directions.length; ++i) {
|
||||
// const localDirection = targetStaticComp.localDirectionToWorld(slot.directions[l]);
|
||||
if (desiredDirection === slot.directions[i]) {
|
||||
return {
|
||||
slot,
|
||||
index: slotIndex,
|
||||
acceptedDirection: desiredDirection,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
import { enumDirection, enumInvertedDirections, Vector } from "../../core/vector";
|
||||
import { types } from "../../savegame/serialization";
|
||||
import { BaseItem } from "../base_item";
|
||||
import { Component } from "../component";
|
||||
|
||||
/** @typedef {{
|
||||
* pos: Vector,
|
||||
* directions: enumDirection[],
|
||||
* filter?: ItemType
|
||||
* }} ItemAcceptorSlot */
|
||||
|
||||
/**
|
||||
* Contains information about a slot plus its location
|
||||
* @typedef {{
|
||||
* slot: ItemAcceptorSlot,
|
||||
* index: number,
|
||||
* acceptedDirection: enumDirection
|
||||
* }} ItemAcceptorLocatedSlot */
|
||||
|
||||
/** @typedef {{
|
||||
* pos: Vector,
|
||||
* directions: enumDirection[],
|
||||
* filter?: ItemType
|
||||
* }} ItemAcceptorSlotConfig */
|
||||
|
||||
export class ItemAcceptorComponent extends Component {
|
||||
static getId() {
|
||||
return "ItemAcceptor";
|
||||
}
|
||||
|
||||
duplicateWithoutContents() {
|
||||
const slotsCopy = [];
|
||||
for (let i = 0; i < this.slots.length; ++i) {
|
||||
const slot = this.slots[i];
|
||||
slotsCopy.push({
|
||||
pos: slot.pos.copy(),
|
||||
directions: slot.directions.slice(),
|
||||
filter: slot.filter,
|
||||
});
|
||||
}
|
||||
|
||||
return new ItemAcceptorComponent({
|
||||
slots: slotsCopy,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {Array<ItemAcceptorSlotConfig>} param0.slots The slots from which we accept items
|
||||
*/
|
||||
constructor({ slots = [] }) {
|
||||
super();
|
||||
|
||||
/**
|
||||
* Fixes belt animations
|
||||
* @type {Array<{
|
||||
* item: BaseItem,
|
||||
* slotIndex: number,
|
||||
* animProgress: number,
|
||||
* direction: enumDirection
|
||||
* }>}
|
||||
*/
|
||||
this.itemConsumptionAnimations = [];
|
||||
|
||||
this.setSlots(slots);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Array<ItemAcceptorSlotConfig>} slots
|
||||
*/
|
||||
setSlots(slots) {
|
||||
/** @type {Array<ItemAcceptorSlot>} */
|
||||
this.slots = [];
|
||||
for (let i = 0; i < slots.length; ++i) {
|
||||
const slot = slots[i];
|
||||
this.slots.push({
|
||||
pos: slot.pos,
|
||||
directions: slot.directions,
|
||||
|
||||
// Which type of item to accept (shape | color | all) @see ItemType
|
||||
filter: slot.filter,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if this acceptor can accept a new item at slot N
|
||||
* @param {number} slotIndex
|
||||
* @param {BaseItem=} item
|
||||
*/
|
||||
canAcceptItem(slotIndex, item) {
|
||||
const slot = this.slots[slotIndex];
|
||||
return !slot.filter || slot.filter === item.getItemType();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an item has been accepted so that
|
||||
* @param {number} slotIndex
|
||||
* @param {enumDirection} direction
|
||||
* @param {BaseItem} item
|
||||
* @param {number} remainingProgress World space remaining progress, can be set to set the start position of the item
|
||||
*/
|
||||
onItemAccepted(slotIndex, direction, item, remainingProgress = 0.0) {
|
||||
this.itemConsumptionAnimations.push({
|
||||
item,
|
||||
slotIndex,
|
||||
direction,
|
||||
animProgress: Math.min(1, remainingProgress * 2),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to find a slot which accepts the current item
|
||||
* @param {Vector} targetLocalTile
|
||||
* @param {enumDirection} fromLocalDirection
|
||||
* @returns {ItemAcceptorLocatedSlot|null}
|
||||
*/
|
||||
findMatchingSlot(targetLocalTile, fromLocalDirection) {
|
||||
// We need to invert our direction since the acceptor specifies *from* which direction
|
||||
// it accepts items, but the ejector specifies *into* which direction it ejects items.
|
||||
// E.g.: Ejector ejects into "right" direction but acceptor accepts from "left" direction.
|
||||
const desiredDirection = enumInvertedDirections[fromLocalDirection];
|
||||
|
||||
// Go over all slots and try to find a target slot
|
||||
for (let slotIndex = 0; slotIndex < this.slots.length; ++slotIndex) {
|
||||
const slot = this.slots[slotIndex];
|
||||
|
||||
// Make sure the acceptor slot is on the right position
|
||||
if (!slot.pos.equals(targetLocalTile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the acceptor slot accepts items from our direction
|
||||
for (let i = 0; i < slot.directions.length; ++i) {
|
||||
// const localDirection = targetStaticComp.localDirectionToWorld(slot.directions[l]);
|
||||
if (desiredDirection === slot.directions[i]) {
|
||||
return {
|
||||
slot,
|
||||
index: slotIndex,
|
||||
acceptedDirection: desiredDirection,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,17 @@ export const enumItemProcessorRequirements = {
|
||||
filter: "filter",
|
||||
};
|
||||
|
||||
/** @typedef {{
|
||||
* item: BaseItem,
|
||||
* requiredSlot?: number,
|
||||
* preferredSlot?: number
|
||||
* }} EjectorItemToEject */
|
||||
|
||||
/** @typedef {{
|
||||
* remainingTime: number,
|
||||
* items: Array<EjectorItemToEject>,
|
||||
* }} EjectorCharge */
|
||||
|
||||
export class ItemProcessorComponent extends Component {
|
||||
static getId() {
|
||||
return "ItemProcessor";
|
||||
@@ -37,20 +48,6 @@ export class ItemProcessorComponent extends Component {
|
||||
static getSchema() {
|
||||
return {
|
||||
nextOutputSlot: types.uint,
|
||||
inputSlots: types.array(
|
||||
types.structured({
|
||||
item: typeItemSingleton,
|
||||
sourceSlot: types.uint,
|
||||
})
|
||||
),
|
||||
itemsToEject: types.array(
|
||||
types.structured({
|
||||
item: typeItemSingleton,
|
||||
requiredSlot: types.nullable(types.uint),
|
||||
preferredSlot: types.nullable(types.uint),
|
||||
})
|
||||
),
|
||||
secondsUntilEject: types.float,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,21 +98,15 @@ export class ItemProcessorComponent extends Component {
|
||||
* What we are currently processing, empty if we don't produce anything rn
|
||||
* requiredSlot: Item *must* be ejected on this slot
|
||||
* preferredSlot: Item *can* be ejected on this slot, but others are fine too if the one is not usable
|
||||
* @type {Array<{item: BaseItem, requiredSlot?: number, preferredSlot?: number}>}
|
||||
* @type {Array<EjectorCharge>}
|
||||
*/
|
||||
this.itemsToEject = [];
|
||||
|
||||
/**
|
||||
* How long it takes until we are done with the current items
|
||||
* @type {number}
|
||||
*/
|
||||
this.secondsUntilEject = 0;
|
||||
this.ongoingCharges = [];
|
||||
|
||||
/**
|
||||
* How much processing time we have left from the last tick
|
||||
* @type {number}
|
||||
*/
|
||||
this.bonusFromLastTick = 0;
|
||||
this.bonusTime = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -125,6 +125,24 @@ export class GameCore {
|
||||
// @ts-ignore
|
||||
window.globalRoot = root;
|
||||
}
|
||||
|
||||
// @todo Find better place
|
||||
if (G_IS_DEV && globalConfig.debug.manualTickOnly) {
|
||||
this.root.gameState.inputReciever.keydown.add(key => {
|
||||
if (key.keyCode === 84) {
|
||||
// 'T'
|
||||
|
||||
// Extract current real time
|
||||
this.root.time.updateRealtimeNow();
|
||||
|
||||
// Perform logic ticks
|
||||
this.root.time.performTicks(this.root.dynamicTickrate.deltaMs, this.boundInternalTick);
|
||||
|
||||
// Update analytics
|
||||
root.productionAnalytics.update();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -244,11 +262,13 @@ export class GameCore {
|
||||
// Camera is always updated, no matter what
|
||||
root.camera.update(deltaMs);
|
||||
|
||||
// Perform logic ticks
|
||||
this.root.time.performTicks(deltaMs, this.boundInternalTick);
|
||||
if (!(G_IS_DEV && globalConfig.debug.manualTickOnly)) {
|
||||
// Perform logic ticks
|
||||
this.root.time.performTicks(deltaMs, this.boundInternalTick);
|
||||
|
||||
// Update analytics
|
||||
root.productionAnalytics.update();
|
||||
// Update analytics
|
||||
root.productionAnalytics.update();
|
||||
}
|
||||
|
||||
// Update automatic save after everything finished
|
||||
root.automaticSave.update();
|
||||
|
||||
@@ -1,124 +1,125 @@
|
||||
import { GameRoot } from "./root";
|
||||
import { createLogger } from "../core/logging";
|
||||
import { globalConfig } from "../core/config";
|
||||
|
||||
const logger = createLogger("dynamic_tickrate");
|
||||
|
||||
const fpsAccumulationTime = 1000;
|
||||
|
||||
export class DynamicTickrate {
|
||||
/**
|
||||
*
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
|
||||
this.currentTickStart = null;
|
||||
this.capturedTicks = [];
|
||||
this.averageTickDuration = 0;
|
||||
|
||||
this.accumulatedFps = 0;
|
||||
this.accumulatedFpsLastUpdate = 0;
|
||||
|
||||
this.averageFps = 60;
|
||||
|
||||
this.setTickRate(60);
|
||||
|
||||
if (G_IS_DEV && globalConfig.debug.renderForTrailer) {
|
||||
this.setTickRate(300);
|
||||
}
|
||||
}
|
||||
|
||||
onFrameRendered() {
|
||||
++this.accumulatedFps;
|
||||
|
||||
const now = performance.now();
|
||||
const timeDuration = now - this.accumulatedFpsLastUpdate;
|
||||
if (timeDuration > fpsAccumulationTime) {
|
||||
const avgFps = (this.accumulatedFps / fpsAccumulationTime) * 1000;
|
||||
this.averageFps = avgFps;
|
||||
this.accumulatedFps = 0;
|
||||
this.accumulatedFpsLastUpdate = now;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
if (G_IS_DEV && globalConfig.debug.renderForTrailer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const desiredFps = this.root.app.settings.getDesiredFps();
|
||||
this.setTickRate(Math.round(Math.min(desiredFps, this.currentTickRate * 1.2)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decreases the tick rate marginally
|
||||
*/
|
||||
decreaseTickRate() {
|
||||
if (G_IS_DEV && globalConfig.debug.renderForTrailer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const desiredFps = this.root.app.settings.getDesiredFps();
|
||||
this.setTickRate(Math.round(Math.max(desiredFps / 2, this.currentTickRate * 0.8)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Call whenever a tick began
|
||||
*/
|
||||
beginTick() {
|
||||
assert(this.currentTickStart === null, "BeginTick called twice");
|
||||
this.currentTickStart = performance.now();
|
||||
|
||||
if (this.capturedTicks.length > this.currentTickRate * 2) {
|
||||
// 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;
|
||||
|
||||
this.averageTickDuration = average;
|
||||
|
||||
const desiredFps = this.root.app.settings.getDesiredFps();
|
||||
|
||||
if (this.averageFps > desiredFps * 0.9) {
|
||||
// if (average < maxTickDuration) {
|
||||
this.increaseTickRate();
|
||||
} else if (this.averageFps < desiredFps * 0.7) {
|
||||
this.decreaseTickRate();
|
||||
}
|
||||
|
||||
this.capturedTicks = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call whenever a tick ended
|
||||
*/
|
||||
endTick() {
|
||||
assert(this.currentTickStart !== null, "EndTick called without BeginTick");
|
||||
const duration = performance.now() - this.currentTickStart;
|
||||
this.capturedTicks.push(duration);
|
||||
this.currentTickStart = null;
|
||||
}
|
||||
}
|
||||
import { GameRoot } from "./root";
|
||||
import { createLogger } from "../core/logging";
|
||||
import { globalConfig } from "../core/config";
|
||||
|
||||
const logger = createLogger("dynamic_tickrate");
|
||||
|
||||
const fpsAccumulationTime = 1000;
|
||||
|
||||
export class DynamicTickrate {
|
||||
/**
|
||||
*
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
|
||||
this.currentTickStart = null;
|
||||
this.capturedTicks = [];
|
||||
this.averageTickDuration = 0;
|
||||
|
||||
this.accumulatedFps = 0;
|
||||
this.accumulatedFpsLastUpdate = 0;
|
||||
|
||||
this.averageFps = 60;
|
||||
|
||||
this.setTickRate(this.root.app.settings.getDesiredFps());
|
||||
|
||||
if (G_IS_DEV && globalConfig.debug.renderForTrailer) {
|
||||
this.setTickRate(300);
|
||||
}
|
||||
}
|
||||
|
||||
onFrameRendered() {
|
||||
++this.accumulatedFps;
|
||||
|
||||
const now = performance.now();
|
||||
const timeDuration = now - this.accumulatedFpsLastUpdate;
|
||||
if (timeDuration > fpsAccumulationTime) {
|
||||
const avgFps = (this.accumulatedFps / fpsAccumulationTime) * 1000;
|
||||
this.averageFps = avgFps;
|
||||
this.accumulatedFps = 0;
|
||||
this.accumulatedFpsLastUpdate = now;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
if (G_IS_DEV && globalConfig.debug.renderForTrailer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const desiredFps = this.root.app.settings.getDesiredFps();
|
||||
this.setTickRate(Math.round(Math.min(desiredFps, this.currentTickRate * 1.2)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decreases the tick rate marginally
|
||||
*/
|
||||
decreaseTickRate() {
|
||||
if (G_IS_DEV && globalConfig.debug.renderForTrailer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const desiredFps = this.root.app.settings.getDesiredFps();
|
||||
this.setTickRate(Math.round(Math.max(desiredFps / 2, this.currentTickRate * 0.8)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Call whenever a tick began
|
||||
*/
|
||||
beginTick() {
|
||||
assert(this.currentTickStart === null, "BeginTick called twice");
|
||||
this.currentTickStart = performance.now();
|
||||
|
||||
if (this.capturedTicks.length > this.currentTickRate * 2) {
|
||||
// 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;
|
||||
|
||||
this.averageTickDuration = average;
|
||||
|
||||
const desiredFps = this.root.app.settings.getDesiredFps();
|
||||
|
||||
// Disabled for now: Dynamicall adjusting tick rate
|
||||
// if (this.averageFps > desiredFps * 0.9) {
|
||||
// // if (average < maxTickDuration) {
|
||||
// this.increaseTickRate();
|
||||
// } else if (this.averageFps < desiredFps * 0.7) {
|
||||
// this.decreaseTickRate();
|
||||
// }
|
||||
|
||||
this.capturedTicks = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call whenever a tick ended
|
||||
*/
|
||||
endTick() {
|
||||
assert(this.currentTickStart !== null, "EndTick called without BeginTick");
|
||||
const duration = performance.now() - this.currentTickStart;
|
||||
this.capturedTicks.push(duration);
|
||||
this.currentTickStart = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,10 @@ export class GameSystemManager {
|
||||
|
||||
// Order is important!
|
||||
|
||||
// IMPORTANT: Item acceptor must be before the belt, because it may not tick after the belt
|
||||
// has put in the item into the acceptor animation, otherwise its off
|
||||
add("itemAcceptor", ItemAcceptorSystem);
|
||||
|
||||
add("belt", BeltSystem);
|
||||
|
||||
add("undergroundBelt", UndergroundBeltSystem);
|
||||
@@ -134,11 +138,6 @@ export class GameSystemManager {
|
||||
|
||||
add("constantSignal", ConstantSignalSystem);
|
||||
|
||||
// IMPORTANT: Must be after belt system since belt system can change the
|
||||
// orientation of an entity after it is placed -> the item acceptor cache
|
||||
// then would be invalid
|
||||
add("itemAcceptor", ItemAcceptorSystem);
|
||||
|
||||
// WIRES section
|
||||
add("lever", LeverSystem);
|
||||
|
||||
|
||||
@@ -1,82 +1,80 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { DrawParameters } from "../../core/draw_parameters";
|
||||
import { fastArrayDelete } from "../../core/utils";
|
||||
import { enumDirectionToVector } from "../../core/vector";
|
||||
import { ItemAcceptorComponent } from "../components/item_acceptor";
|
||||
import { GameSystemWithFilter } from "../game_system_with_filter";
|
||||
import { MapChunkView } from "../map_chunk_view";
|
||||
|
||||
export class ItemAcceptorSystem extends GameSystemWithFilter {
|
||||
constructor(root) {
|
||||
super(root, [ItemAcceptorComponent]);
|
||||
}
|
||||
|
||||
update() {
|
||||
const progress = this.root.dynamicTickrate.deltaSeconds * 2; // * 2 because its only a half tile
|
||||
|
||||
for (let i = 0; i < this.allEntities.length; ++i) {
|
||||
const entity = this.allEntities[i];
|
||||
const aceptorComp = entity.components.ItemAcceptor;
|
||||
const animations = aceptorComp.itemConsumptionAnimations;
|
||||
|
||||
// Process item consumption animations to avoid items popping from the belts
|
||||
for (let animIndex = 0; animIndex < animations.length; ++animIndex) {
|
||||
const anim = animations[animIndex];
|
||||
anim.animProgress +=
|
||||
progress * this.root.hubGoals.getBeltBaseSpeed() * globalConfig.itemSpacingOnBelts;
|
||||
if (anim.animProgress > 1) {
|
||||
// Original
|
||||
// animations.splice(animIndex, 1);
|
||||
|
||||
// Faster variant
|
||||
fastArrayDelete(animations, animIndex);
|
||||
|
||||
animIndex -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {MapChunkView} chunk
|
||||
*/
|
||||
drawChunk(parameters, chunk) {
|
||||
const contents = chunk.containedEntitiesByLayer.regular;
|
||||
for (let i = 0; i < contents.length; ++i) {
|
||||
const entity = contents[i];
|
||||
const acceptorComp = entity.components.ItemAcceptor;
|
||||
if (!acceptorComp) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const staticComp = entity.components.StaticMapEntity;
|
||||
for (let animIndex = 0; animIndex < acceptorComp.itemConsumptionAnimations.length; ++animIndex) {
|
||||
const { item, slotIndex, animProgress, direction } = acceptorComp.itemConsumptionAnimations[
|
||||
animIndex
|
||||
];
|
||||
|
||||
const slotData = acceptorComp.slots[slotIndex];
|
||||
const realSlotPos = staticComp.localTileToWorld(slotData.pos);
|
||||
|
||||
if (!chunk.tileSpaceRectangle.containsPoint(realSlotPos.x, realSlotPos.y)) {
|
||||
// Not within this chunk
|
||||
continue;
|
||||
}
|
||||
|
||||
const fadeOutDirection = enumDirectionToVector[staticComp.localDirectionToWorld(direction)];
|
||||
const finalTile = realSlotPos.subScalars(
|
||||
fadeOutDirection.x * (animProgress / 2 - 0.5),
|
||||
fadeOutDirection.y * (animProgress / 2 - 0.5)
|
||||
);
|
||||
|
||||
item.drawItemCenteredClipped(
|
||||
(finalTile.x + 0.5) * globalConfig.tileSize,
|
||||
(finalTile.y + 0.5) * globalConfig.tileSize,
|
||||
parameters,
|
||||
globalConfig.defaultItemDiameter
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { DrawParameters } from "../../core/draw_parameters";
|
||||
import { fastArrayDelete } from "../../core/utils";
|
||||
import { enumDirectionToVector } from "../../core/vector";
|
||||
import { ItemAcceptorComponent } from "../components/item_acceptor";
|
||||
import { GameSystemWithFilter } from "../game_system_with_filter";
|
||||
import { MapChunkView } from "../map_chunk_view";
|
||||
|
||||
export class ItemAcceptorSystem extends GameSystemWithFilter {
|
||||
constructor(root) {
|
||||
super(root, [ItemAcceptorComponent]);
|
||||
}
|
||||
|
||||
update() {
|
||||
const progress =
|
||||
this.root.dynamicTickrate.deltaSeconds *
|
||||
2 *
|
||||
this.root.hubGoals.getBeltBaseSpeed() *
|
||||
globalConfig.itemSpacingOnBelts; // * 2 because its only a half tile
|
||||
|
||||
for (let i = 0; i < this.allEntities.length; ++i) {
|
||||
const entity = this.allEntities[i];
|
||||
const aceptorComp = entity.components.ItemAcceptor;
|
||||
const animations = aceptorComp.itemConsumptionAnimations;
|
||||
|
||||
// Process item consumption animations to avoid items popping from the belts
|
||||
for (let animIndex = 0; animIndex < animations.length; ++animIndex) {
|
||||
const anim = animations[animIndex];
|
||||
anim.animProgress += progress;
|
||||
if (anim.animProgress > 1) {
|
||||
fastArrayDelete(animations, animIndex);
|
||||
animIndex -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {MapChunkView} chunk
|
||||
*/
|
||||
drawChunk(parameters, chunk) {
|
||||
const contents = chunk.containedEntitiesByLayer.regular;
|
||||
for (let i = 0; i < contents.length; ++i) {
|
||||
const entity = contents[i];
|
||||
const acceptorComp = entity.components.ItemAcceptor;
|
||||
if (!acceptorComp) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const staticComp = entity.components.StaticMapEntity;
|
||||
for (let animIndex = 0; animIndex < acceptorComp.itemConsumptionAnimations.length; ++animIndex) {
|
||||
const { item, slotIndex, animProgress, direction } = acceptorComp.itemConsumptionAnimations[
|
||||
animIndex
|
||||
];
|
||||
|
||||
const slotData = acceptorComp.slots[slotIndex];
|
||||
const realSlotPos = staticComp.localTileToWorld(slotData.pos);
|
||||
|
||||
if (!chunk.tileSpaceRectangle.containsPoint(realSlotPos.x, realSlotPos.y)) {
|
||||
// Not within this chunk
|
||||
continue;
|
||||
}
|
||||
|
||||
const fadeOutDirection = enumDirectionToVector[staticComp.localDirectionToWorld(direction)];
|
||||
const finalTile = realSlotPos.subScalars(
|
||||
fadeOutDirection.x * (animProgress / 2 - 0.5),
|
||||
fadeOutDirection.y * (animProgress / 2 - 0.5)
|
||||
);
|
||||
|
||||
item.drawItemCenteredClipped(
|
||||
(finalTile.x + 0.5) * globalConfig.tileSize,
|
||||
(finalTile.y + 0.5) * globalConfig.tileSize,
|
||||
parameters,
|
||||
globalConfig.defaultItemDiameter
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { BaseItem } from "../base_item";
|
||||
import { enumColorMixingResults, enumColors } from "../colors";
|
||||
import {
|
||||
@@ -12,6 +11,11 @@ import { BOOL_TRUE_SINGLETON, isTruthyItem } from "../items/boolean_item";
|
||||
import { ColorItem, COLOR_ITEM_SINGLETONS } from "../items/color_item";
|
||||
import { ShapeItem } from "../items/shape_item";
|
||||
|
||||
/**
|
||||
* We need to allow queuing charges, otherwise the throughput will stall
|
||||
*/
|
||||
const MAX_QUEUED_CHARGES = 2;
|
||||
|
||||
export class ItemProcessorSystem extends GameSystemWithFilter {
|
||||
constructor(root) {
|
||||
super(root, [ItemProcessorComponent]);
|
||||
@@ -24,60 +28,64 @@ export class ItemProcessorSystem extends GameSystemWithFilter {
|
||||
const processorComp = entity.components.ItemProcessor;
|
||||
const ejectorComp = entity.components.ItemEjector;
|
||||
|
||||
// First of all, process the current recipe
|
||||
const newSecondsUntilEject =
|
||||
processorComp.secondsUntilEject - this.root.dynamicTickrate.deltaSeconds;
|
||||
const currentCharge = processorComp.ongoingCharges[0];
|
||||
|
||||
processorComp.secondsUntilEject = Math.max(0, newSecondsUntilEject);
|
||||
if (currentCharge) {
|
||||
// Process next charge
|
||||
if (currentCharge.remainingTime > 0.0) {
|
||||
currentCharge.remainingTime -= this.root.dynamicTickrate.deltaSeconds;
|
||||
if (currentCharge.remainingTime < 0.0) {
|
||||
// Add bonus time, this is the time we spent too much
|
||||
processorComp.bonusTime += -currentCharge.remainingTime;
|
||||
}
|
||||
}
|
||||
|
||||
if (newSecondsUntilEject < 0) {
|
||||
processorComp.bonusFromLastTick -= newSecondsUntilEject;
|
||||
}
|
||||
// Check if it finished
|
||||
if (currentCharge.remainingTime <= 0.0) {
|
||||
const itemsToEject = currentCharge.items;
|
||||
|
||||
if (G_IS_DEV && globalConfig.debug.instantProcessors) {
|
||||
processorComp.secondsUntilEject = 0;
|
||||
}
|
||||
// Go over all items and try to eject them
|
||||
for (let j = 0; j < itemsToEject.length; ++j) {
|
||||
const { item, requiredSlot, preferredSlot } = itemsToEject[j];
|
||||
|
||||
// Check if we have any finished items we can eject
|
||||
if (
|
||||
processorComp.secondsUntilEject === 0 && // it was processed in time
|
||||
processorComp.itemsToEject.length > 0 // we have some items left to eject
|
||||
) {
|
||||
for (let itemIndex = 0; itemIndex < processorComp.itemsToEject.length; ++itemIndex) {
|
||||
const { item, requiredSlot, preferredSlot } = processorComp.itemsToEject[itemIndex];
|
||||
|
||||
let slot = null;
|
||||
if (requiredSlot !== null && requiredSlot !== undefined) {
|
||||
// We have a slot override, check if that is free
|
||||
if (ejectorComp.canEjectOnSlot(requiredSlot)) {
|
||||
slot = requiredSlot;
|
||||
}
|
||||
} else if (preferredSlot !== null && preferredSlot !== undefined) {
|
||||
// We have a slot preference, try using it but otherwise use a free slot
|
||||
if (ejectorComp.canEjectOnSlot(preferredSlot)) {
|
||||
slot = preferredSlot;
|
||||
let slot = null;
|
||||
if (requiredSlot !== null && requiredSlot !== undefined) {
|
||||
// We have a slot override, check if that is free
|
||||
if (ejectorComp.canEjectOnSlot(requiredSlot)) {
|
||||
slot = requiredSlot;
|
||||
}
|
||||
} else if (preferredSlot !== null && preferredSlot !== undefined) {
|
||||
// We have a slot preference, try using it but otherwise use a free slot
|
||||
if (ejectorComp.canEjectOnSlot(preferredSlot)) {
|
||||
slot = preferredSlot;
|
||||
} else {
|
||||
slot = ejectorComp.getFirstFreeSlot();
|
||||
}
|
||||
} else {
|
||||
// We can eject on any slot
|
||||
slot = ejectorComp.getFirstFreeSlot();
|
||||
}
|
||||
} else {
|
||||
// We can eject on any slot
|
||||
slot = ejectorComp.getFirstFreeSlot();
|
||||
|
||||
if (slot !== null) {
|
||||
// Alright, we can actually eject
|
||||
if (!ejectorComp.tryEject(slot, item)) {
|
||||
assert(false, "Failed to eject");
|
||||
} else {
|
||||
itemsToEject.splice(j, 1);
|
||||
j -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (slot !== null) {
|
||||
// Alright, we can actually eject
|
||||
if (!ejectorComp.tryEject(slot, item)) {
|
||||
assert(false, "Failed to eject");
|
||||
} else {
|
||||
processorComp.itemsToEject.splice(itemIndex, 1);
|
||||
itemIndex -= 1;
|
||||
}
|
||||
// If the charge was entirely emptied to the outputs, start the next charge
|
||||
if (itemsToEject.length === 0) {
|
||||
processorComp.ongoingCharges.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have an empty queue and can start a new charge
|
||||
if (processorComp.itemsToEject.length === 0) {
|
||||
if (processorComp.ongoingCharges.length < MAX_QUEUED_CHARGES) {
|
||||
if (this.canProcess(entity)) {
|
||||
this.startNewCharge(entity);
|
||||
}
|
||||
@@ -236,12 +244,6 @@ export class ItemProcessorSystem extends GameSystemWithFilter {
|
||||
itemsBySlot[items[i].sourceSlot] = items[i];
|
||||
}
|
||||
|
||||
const baseSpeed = this.root.hubGoals.getProcessorBaseSpeed(processorComp.type);
|
||||
|
||||
// Substract one tick because we already process it this frame
|
||||
processorComp.secondsUntilEject = Math.max(0, 1 / baseSpeed - processorComp.bonusFromLastTick);
|
||||
processorComp.bonusFromLastTick = 0;
|
||||
|
||||
/** @type {Array<{item: BaseItem, requiredSlot?: number, preferredSlot?: number}>} */
|
||||
const outItems = [];
|
||||
|
||||
@@ -544,6 +546,35 @@ export class ItemProcessorSystem extends GameSystemWithFilter {
|
||||
}
|
||||
}
|
||||
|
||||
processorComp.itemsToEject = outItems;
|
||||
// Queue Charge
|
||||
const baseSpeed = this.root.hubGoals.getProcessorBaseSpeed(processorComp.type);
|
||||
const originalTime = 1 / baseSpeed;
|
||||
|
||||
const bonusTimeToApply = Math.min(originalTime, processorComp.bonusTime);
|
||||
const timeToProcess = originalTime - bonusTimeToApply;
|
||||
|
||||
// Substract one tick because we already process it this frame
|
||||
// if (processorComp.bonusTime > originalTime) {
|
||||
// if (processorComp.type === enumItemProcessorTypes.reader) {
|
||||
// console.log(
|
||||
// "Bonus time",
|
||||
// round4Digits(processorComp.bonusTime),
|
||||
// "Original time",
|
||||
// round4Digits(originalTime),
|
||||
// "Overcomit by",
|
||||
// round4Digits(processorComp.bonusTime - originalTime),
|
||||
// "->",
|
||||
// round4Digits(timeToProcess),
|
||||
// "reduced by",
|
||||
// round4Digits(bonusTimeToApply)
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
processorComp.bonusTime -= bonusTimeToApply;
|
||||
|
||||
processorComp.ongoingCharges.push({
|
||||
items: outItems,
|
||||
remainingTime: timeToProcess,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,82 +1,82 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { DrawParameters } from "../../core/draw_parameters";
|
||||
import { GameSystem } from "../game_system";
|
||||
import { MapChunkView } from "../map_chunk_view";
|
||||
|
||||
export class StaticMapEntitySystem extends GameSystem {
|
||||
constructor(root) {
|
||||
super(root);
|
||||
|
||||
/** @type {Set<number>} */
|
||||
this.drawnUids = new Set();
|
||||
|
||||
this.root.signals.gameFrameStarted.add(this.clearUidList, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the uid list when a new frame started
|
||||
*/
|
||||
clearUidList() {
|
||||
this.drawnUids.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the static entities
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {MapChunkView} chunk
|
||||
*/
|
||||
drawChunk(parameters, chunk) {
|
||||
if (G_IS_DEV && globalConfig.debug.doNotRenderStatics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contents = chunk.containedEntitiesByLayer.regular;
|
||||
for (let i = 0; i < contents.length; ++i) {
|
||||
const entity = contents[i];
|
||||
|
||||
const staticComp = entity.components.StaticMapEntity;
|
||||
const sprite = staticComp.getSprite();
|
||||
if (sprite) {
|
||||
// Avoid drawing an entity twice which has been drawn for
|
||||
// another chunk already
|
||||
if (this.drawnUids.has(entity.uid)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.drawnUids.add(entity.uid);
|
||||
staticComp.drawSpriteOnBoundsClipped(parameters, sprite, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the static wire entities
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {MapChunkView} chunk
|
||||
*/
|
||||
drawWiresChunk(parameters, chunk) {
|
||||
if (G_IS_DEV && globalConfig.debug.doNotRenderStatics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const drawnUids = new Set();
|
||||
const contents = chunk.wireContents;
|
||||
for (let y = 0; y < globalConfig.mapChunkSize; ++y) {
|
||||
for (let x = 0; x < globalConfig.mapChunkSize; ++x) {
|
||||
const entity = contents[x][y];
|
||||
if (entity) {
|
||||
if (drawnUids.has(entity.uid)) {
|
||||
continue;
|
||||
}
|
||||
drawnUids.add(entity.uid);
|
||||
const staticComp = entity.components.StaticMapEntity;
|
||||
|
||||
const sprite = staticComp.getSprite();
|
||||
if (sprite) {
|
||||
staticComp.drawSpriteOnBoundsClipped(parameters, sprite, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { DrawParameters } from "../../core/draw_parameters";
|
||||
import { GameSystem } from "../game_system";
|
||||
import { MapChunkView } from "../map_chunk_view";
|
||||
|
||||
export class StaticMapEntitySystem extends GameSystem {
|
||||
constructor(root) {
|
||||
super(root);
|
||||
|
||||
/** @type {Set<number>} */
|
||||
this.drawnUids = new Set();
|
||||
|
||||
this.root.signals.gameFrameStarted.add(this.clearUidList, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the uid list when a new frame started
|
||||
*/
|
||||
clearUidList() {
|
||||
this.drawnUids.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the static entities
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {MapChunkView} chunk
|
||||
*/
|
||||
drawChunk(parameters, chunk) {
|
||||
if (G_IS_DEV && globalConfig.debug.doNotRenderStatics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contents = chunk.containedEntitiesByLayer.regular;
|
||||
for (let i = 0; i < contents.length; ++i) {
|
||||
const entity = contents[i];
|
||||
|
||||
const staticComp = entity.components.StaticMapEntity;
|
||||
const sprite = staticComp.getSprite();
|
||||
if (sprite) {
|
||||
// Avoid drawing an entity twice which has been drawn for
|
||||
// another chunk already
|
||||
if (this.drawnUids.has(entity.uid)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.drawnUids.add(entity.uid);
|
||||
staticComp.drawSpriteOnBoundsClipped(parameters, sprite, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the static wire entities
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {MapChunkView} chunk
|
||||
*/
|
||||
drawWiresChunk(parameters, chunk) {
|
||||
if (G_IS_DEV && globalConfig.debug.doNotRenderStatics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const drawnUids = new Set();
|
||||
const contents = chunk.wireContents;
|
||||
for (let y = 0; y < globalConfig.mapChunkSize; ++y) {
|
||||
for (let x = 0; x < globalConfig.mapChunkSize; ++x) {
|
||||
const entity = contents[x][y];
|
||||
if (entity) {
|
||||
if (drawnUids.has(entity.uid)) {
|
||||
continue;
|
||||
}
|
||||
drawnUids.add(entity.uid);
|
||||
const staticComp = entity.components.StaticMapEntity;
|
||||
|
||||
const sprite = staticComp.getSprite();
|
||||
if (sprite) {
|
||||
staticComp.drawSpriteOnBoundsClipped(parameters, sprite, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,10 +123,9 @@ export const autosaveIntervals = [
|
||||
},
|
||||
];
|
||||
|
||||
const refreshRateOptions = ["60", "75", "100", "120", "144", "165", "250", "500"];
|
||||
const refreshRateOptions = ["30", "60", "120", "180", "240"];
|
||||
|
||||
if (G_IS_DEV) {
|
||||
refreshRateOptions.unshift("30");
|
||||
refreshRateOptions.unshift("10");
|
||||
refreshRateOptions.unshift("5");
|
||||
refreshRateOptions.push("1000");
|
||||
@@ -511,7 +510,7 @@ export class ApplicationSettings extends ReadWriteProxy {
|
||||
}
|
||||
|
||||
getCurrentVersion() {
|
||||
return 23;
|
||||
return 24;
|
||||
}
|
||||
|
||||
/** @param {{settings: SettingsStorage, version: number}} data */
|
||||
@@ -614,6 +613,11 @@ export class ApplicationSettings extends ReadWriteProxy {
|
||||
data.version = 23;
|
||||
}
|
||||
|
||||
if (data.version < 24) {
|
||||
data.settings.refreshRate = "60";
|
||||
data.version = 24;
|
||||
}
|
||||
|
||||
return ExplainedResult.good();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user