mirror of
https://github.com/tobspr/shapez.io.git
synced 2026-03-02 03:39:21 +00:00
Initial commit
This commit is contained in:
80
src/js/game/automatic_save.js
Normal file
80
src/js/game/automatic_save.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { GameRoot } from "./root";
|
||||
import { globalConfig, IS_DEBUG } from "../core/config";
|
||||
import { Math_max } from "../core/builtins";
|
||||
|
||||
// How important it is that a savegame is created
|
||||
/**
|
||||
* @enum {number}
|
||||
*/
|
||||
export const enumSavePriority = {
|
||||
regular: 2,
|
||||
asap: 100,
|
||||
};
|
||||
|
||||
// Internals
|
||||
let MIN_INTERVAL_SECS = 15;
|
||||
|
||||
if (G_IS_DEV && IS_DEBUG) {
|
||||
// // Testing
|
||||
// MIN_INTERVAL_SECS = 1;
|
||||
// MAX_INTERVAL_SECS = 1;
|
||||
MIN_INTERVAL_SECS = 9999999;
|
||||
}
|
||||
|
||||
export class AutomaticSave {
|
||||
constructor(root) {
|
||||
/** @type {GameRoot} */
|
||||
this.root = root;
|
||||
|
||||
// Store the current maximum save importance
|
||||
this.saveImportance = enumSavePriority.regular;
|
||||
|
||||
this.lastSaveAttempt = -1000;
|
||||
}
|
||||
|
||||
setSaveImportance(importance) {
|
||||
this.saveImportance = Math_max(this.saveImportance, importance);
|
||||
}
|
||||
|
||||
doSave() {
|
||||
if (G_IS_DEV && globalConfig.debug.disableSavegameWrite) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.root.gameState.doSave();
|
||||
this.saveImportance = enumSavePriority.regular;
|
||||
}
|
||||
|
||||
update() {
|
||||
if (!this.root.gameInitialized) {
|
||||
// Bad idea
|
||||
return;
|
||||
}
|
||||
// Check when the last save was, but make sure that if it fails, we don't spam
|
||||
const lastSaveTime = Math_max(this.lastSaveAttempt, this.root.savegame.getRealLastUpdate());
|
||||
|
||||
let secondsSinceLastSave = (Date.now() - lastSaveTime) / 1000.0;
|
||||
let shouldSave = false;
|
||||
|
||||
switch (this.saveImportance) {
|
||||
case enumSavePriority.asap:
|
||||
// High always should save
|
||||
shouldSave = true;
|
||||
break;
|
||||
|
||||
case enumSavePriority.regular:
|
||||
// Could determine if there is a good / bad point here
|
||||
shouldSave = secondsSinceLastSave > MIN_INTERVAL_SECS;
|
||||
break;
|
||||
|
||||
default:
|
||||
assert(false, "Unknown save prio: " + this.saveImportance);
|
||||
break;
|
||||
}
|
||||
if (shouldSave) {
|
||||
// log(this, "Saving automatically");
|
||||
this.lastSaveAttempt = Date.now();
|
||||
this.doSave();
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/js/game/base_item.js
Normal file
33
src/js/game/base_item.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { DrawParameters } from "../core/draw_parameters";
|
||||
import { BasicSerializableObject, types } from "../savegame/serialization";
|
||||
|
||||
/**
|
||||
* Class for items on belts etc. Not an entity for performance reasons
|
||||
*/
|
||||
export class BaseItem extends BasicSerializableObject {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
static getId() {
|
||||
return "base_item";
|
||||
}
|
||||
|
||||
/** @returns {object} */
|
||||
static getSchema() {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the item at the given position
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {number=} size
|
||||
*/
|
||||
draw(x, y, parameters, size) {}
|
||||
|
||||
getBackgroundColorAsResource() {
|
||||
return "#eaebec";
|
||||
}
|
||||
}
|
||||
204
src/js/game/buildings/belt_base.js
Normal file
204
src/js/game/buildings/belt_base.js
Normal file
@@ -0,0 +1,204 @@
|
||||
import { Loader } from "../../core/loader";
|
||||
import { enumAngleToDirection, enumDirection, Vector } from "../../core/vector";
|
||||
import { BeltComponent } from "../components/belt";
|
||||
import { ItemAcceptorComponent } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { ReplaceableMapEntityComponent } from "../components/replaceable_map_entity";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { GameRoot } from "../root";
|
||||
|
||||
export const arrayBeltVariantToRotation = [enumDirection.top, enumDirection.left, enumDirection.right];
|
||||
|
||||
export class MetaBeltBaseBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("belt");
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#777";
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(
|
||||
new BeltComponent({
|
||||
direction: enumDirection.top, // updated later
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
direction: enumDirection.top, // updated later
|
||||
},
|
||||
],
|
||||
instantEject: true,
|
||||
})
|
||||
);
|
||||
// Make this entity replaceabel
|
||||
entity.addComponent(new ReplaceableMapEntityComponent());
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Entity} entity
|
||||
* @param {number} rotationVariant
|
||||
*/
|
||||
updateRotationVariant(entity, rotationVariant) {
|
||||
entity.components.Belt.direction = arrayBeltVariantToRotation[rotationVariant];
|
||||
entity.components.ItemEjector.slots[0].direction = arrayBeltVariantToRotation[rotationVariant];
|
||||
|
||||
entity.components.StaticMapEntity.spriteKey = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes optimal belt rotation variant
|
||||
* @param {GameRoot} root
|
||||
* @param {Vector} tile
|
||||
* @param {number} rotation
|
||||
* @return {{ rotation: number, rotationVariant: number }}
|
||||
*/
|
||||
computeOptimalDirectionAndRotationVariantAtTile(root, tile, rotation) {
|
||||
const topDirection = enumAngleToDirection[rotation];
|
||||
const rightDirection = enumAngleToDirection[(rotation + 90) % 360];
|
||||
const bottomDirection = enumAngleToDirection[(rotation + 180) % 360];
|
||||
const leftDirection = enumAngleToDirection[(rotation + 270) % 360];
|
||||
|
||||
const { ejectors, acceptors } = root.logic.getEjectorsAndAcceptorsAtTile(tile);
|
||||
|
||||
let hasBottomEjector = false;
|
||||
let hasLeftEjector = false;
|
||||
let hasRightEjector = false;
|
||||
|
||||
let hasTopAcceptor = false;
|
||||
let hasLeftAcceptor = false;
|
||||
let hasRightAcceptor = false;
|
||||
|
||||
// Check all ejectors
|
||||
for (let i = 0; i < ejectors.length; ++i) {
|
||||
const ejector = ejectors[i];
|
||||
|
||||
if (ejector.toDirection === topDirection) {
|
||||
hasBottomEjector = true;
|
||||
} else if (ejector.toDirection === leftDirection) {
|
||||
hasLeftEjector = true;
|
||||
} else if (ejector.toDirection === rightDirection) {
|
||||
hasRightEjector = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check all acceptors
|
||||
for (let i = 0; i < acceptors.length; ++i) {
|
||||
const acceptor = acceptors[i];
|
||||
if (acceptor.fromDirection === bottomDirection) {
|
||||
hasTopAcceptor = true;
|
||||
} else if (acceptor.fromDirection === rightDirection) {
|
||||
hasLeftAcceptor = true;
|
||||
} else if (acceptor.fromDirection === leftDirection) {
|
||||
hasRightAcceptor = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Soo .. if there is any ejector below us we always prioritize
|
||||
// this ejector
|
||||
if (!hasBottomEjector) {
|
||||
// When something ejects to us from the left and nothing from the right,
|
||||
// do a curve from the left to the top
|
||||
if (hasLeftEjector && !hasRightEjector) {
|
||||
return {
|
||||
rotation: (rotation + 270) % 360,
|
||||
rotationVariant: 2,
|
||||
};
|
||||
}
|
||||
|
||||
// When something ejects to us from the right and nothing from the left,
|
||||
// do a curve from the right to the top
|
||||
if (hasRightEjector && !hasLeftEjector) {
|
||||
return {
|
||||
rotation: (rotation + 90) % 360,
|
||||
rotationVariant: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// When there is a top acceptor, ignore sides
|
||||
// NOTICE: This makes the belt prefer side turns *way* too much!
|
||||
// if (!hasTopAcceptor) {
|
||||
// // When there is an acceptor to the right but no acceptor to the left,
|
||||
// // do a turn to the right
|
||||
// if (hasRightAcceptor && !hasLeftAcceptor) {
|
||||
// return {
|
||||
// rotation,
|
||||
// rotationVariant: 2,
|
||||
// };
|
||||
// }
|
||||
|
||||
// // When there is an acceptor to the left but no acceptor to the right,
|
||||
// // do a turn to the left
|
||||
// if (hasLeftAcceptor && !hasRightAcceptor) {
|
||||
// return {
|
||||
// rotation,
|
||||
// rotationVariant: 1,
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
|
||||
return {
|
||||
rotation,
|
||||
rotationVariant: 0,
|
||||
};
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Belt";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Transports items, hold and drag to place multiple, press 'R' to rotate.";
|
||||
}
|
||||
|
||||
getPreviewSprite(rotationVariant) {
|
||||
switch (arrayBeltVariantToRotation[rotationVariant]) {
|
||||
case enumDirection.top: {
|
||||
return Loader.getSprite("sprites/belt/forward_0.png");
|
||||
}
|
||||
case enumDirection.left: {
|
||||
return Loader.getSprite("sprites/belt/left_0.png");
|
||||
}
|
||||
case enumDirection.right: {
|
||||
return Loader.getSprite("sprites/belt/right_0.png");
|
||||
}
|
||||
default: {
|
||||
assertAlways(false, "Invalid belt rotation variant");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getStayInPlacementMode() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can be overridden
|
||||
*/
|
||||
internalGetBeltDirection(rotationVariant) {
|
||||
return enumDirection.top;
|
||||
}
|
||||
}
|
||||
71
src/js/game/buildings/cutter.js
Normal file
71
src/js/game/buildings/cutter.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { enumItemAcceptorItemFilter, ItemAcceptorComponent } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { GameRoot } from "../root";
|
||||
import { enumHubGoalRewards } from "../tutorial_goals";
|
||||
|
||||
export class MetaCutterBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("cutter");
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#7dcda2";
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
return new Vector(2, 1);
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Cut Half";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Cuts shapes from top to bottom and outputs both halfs. <strong>If you use only one part, be sure to destroy the other part or it will stall!</strong>";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
getIsUnlocked(root) {
|
||||
return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_cutter_and_trash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(
|
||||
new ItemProcessorComponent({
|
||||
inputsPerCharge: 1,
|
||||
processorType: enumItemProcessorTypes.cutter,
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [
|
||||
{ pos: new Vector(0, 0), direction: enumDirection.top },
|
||||
{ pos: new Vector(1, 0), direction: enumDirection.top },
|
||||
],
|
||||
})
|
||||
);
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
125
src/js/game/buildings/hub.js
Normal file
125
src/js/game/buildings/hub.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { enumItemAcceptorItemFilter, ItemAcceptorComponent } from "../components/item_acceptor";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { ItemProcessorComponent, enumItemProcessorTypes } from "../components/item_processor";
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { UnremovableComponent } from "../components/unremovable";
|
||||
import { HubComponent } from "../components/hub";
|
||||
|
||||
export class MetaHubBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("hub");
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
return new Vector(4, 4);
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#eb5555";
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Hub";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Your central hub, deliver shapes to it to unlock new buildings.";
|
||||
}
|
||||
|
||||
isRotateable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(new HubComponent());
|
||||
entity.addComponent(
|
||||
new ItemProcessorComponent({
|
||||
inputsPerCharge: 1,
|
||||
processorType: enumItemProcessorTypes.hub,
|
||||
})
|
||||
);
|
||||
entity.addComponent(new UnremovableComponent());
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.top, enumDirection.left],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(1, 0),
|
||||
directions: [enumDirection.top],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(2, 0),
|
||||
directions: [enumDirection.top],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(3, 0),
|
||||
directions: [enumDirection.top, enumDirection.right],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(0, 3),
|
||||
directions: [enumDirection.bottom, enumDirection.left],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(1, 3),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(2, 3),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(3, 3),
|
||||
directions: [enumDirection.bottom, enumDirection.right],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(0, 1),
|
||||
directions: [enumDirection.left],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(0, 2),
|
||||
directions: [enumDirection.left],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(0, 3),
|
||||
directions: [enumDirection.left],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(3, 1),
|
||||
directions: [enumDirection.right],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(3, 2),
|
||||
directions: [enumDirection.right],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(3, 3),
|
||||
directions: [enumDirection.right],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
36
src/js/game/buildings/miner.js
Normal file
36
src/js/game/buildings/miner.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { MinerComponent } from "../components/miner";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
|
||||
export class MetaMinerBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("miner");
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Extract";
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#b37dcd";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Place over a shape or color to extract it. Six extractors fill exactly one belt.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(new MinerComponent({}));
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [{ pos: new Vector(0, 0), direction: enumDirection.top }],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
73
src/js/game/buildings/mixer.js
Normal file
73
src/js/game/buildings/mixer.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { ItemAcceptorComponent, enumItemAcceptorItemFilter } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { GameRoot } from "../root";
|
||||
import { enumHubGoalRewards } from "../tutorial_goals";
|
||||
|
||||
export class MetaMixerBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("mixer");
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
return new Vector(2, 1);
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Mix Colors";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Mixes two colors using additive blending.";
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#cdbb7d";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
getIsUnlocked(root) {
|
||||
return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_mixer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(
|
||||
new ItemProcessorComponent({
|
||||
inputsPerCharge: 2,
|
||||
processorType: enumItemProcessorTypes.mixer,
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [{ pos: new Vector(0, 0), direction: enumDirection.top }],
|
||||
})
|
||||
);
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.color,
|
||||
},
|
||||
{
|
||||
pos: new Vector(1, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.color,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
73
src/js/game/buildings/painter.js
Normal file
73
src/js/game/buildings/painter.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { enumItemAcceptorItemFilter, ItemAcceptorComponent } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { enumHubGoalRewards } from "../tutorial_goals";
|
||||
import { GameRoot } from "../root";
|
||||
|
||||
export class MetaPainterBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("painter");
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
return new Vector(2, 1);
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Dye";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Colors the whole shape on the left input with the color from the right input.";
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#cd9b7d";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
getIsUnlocked(root) {
|
||||
return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_painter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(
|
||||
new ItemProcessorComponent({
|
||||
inputsPerCharge: 2,
|
||||
processorType: enumItemProcessorTypes.painter,
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [{ pos: new Vector(0, 0), direction: enumDirection.top }],
|
||||
})
|
||||
);
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(1, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.color,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
64
src/js/game/buildings/rotater.js
Normal file
64
src/js/game/buildings/rotater.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { ItemAcceptorComponent, enumItemAcceptorItemFilter } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { enumHubGoalRewards } from "../tutorial_goals";
|
||||
import { GameRoot } from "../root";
|
||||
|
||||
export class MetaRotaterBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("rotater");
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Rotate";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Rotates shapes clockwise by 90 degrees.";
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#7dc6cd";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
getIsUnlocked(root) {
|
||||
return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_rotater);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(
|
||||
new ItemProcessorComponent({
|
||||
inputsPerCharge: 1,
|
||||
processorType: enumItemProcessorTypes.rotater,
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [{ pos: new Vector(0, 0), direction: enumDirection.top }],
|
||||
})
|
||||
);
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
80
src/js/game/buildings/splitter.js
Normal file
80
src/js/game/buildings/splitter.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { ItemAcceptorComponent } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { GameRoot } from "../root";
|
||||
import { enumHubGoalRewards } from "../tutorial_goals";
|
||||
|
||||
export class MetaSplitterBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("splitter");
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
return new Vector(2, 1);
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Distribute";
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#444";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Accepts up to two inputs and evenly distributes them on the outputs. Can also be used to merge two inputs into one output.";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
getIsUnlocked(root) {
|
||||
return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_splitter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
},
|
||||
{
|
||||
pos: new Vector(1, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemProcessorComponent({
|
||||
inputsPerCharge: 1,
|
||||
processorType: enumItemProcessorTypes.splitter,
|
||||
|
||||
beltUnderlays: [
|
||||
{ pos: new Vector(0, 0), direction: enumDirection.top },
|
||||
{ pos: new Vector(1, 0), direction: enumDirection.top },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [
|
||||
{ pos: new Vector(0, 0), direction: enumDirection.top },
|
||||
{ pos: new Vector(1, 0), direction: enumDirection.top },
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
73
src/js/game/buildings/stacker.js
Normal file
73
src/js/game/buildings/stacker.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { ItemAcceptorComponent, enumItemAcceptorItemFilter } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { GameRoot } from "../root";
|
||||
import { enumHubGoalRewards } from "../tutorial_goals";
|
||||
|
||||
export class MetaStackerBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("stacker");
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Combine";
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#9fcd7d";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Combines both items. If they can not be merged, the right item is placed above the left item.";
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
return new Vector(2, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
getIsUnlocked(root) {
|
||||
return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_stacker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(
|
||||
new ItemProcessorComponent({
|
||||
inputsPerCharge: 2,
|
||||
processorType: enumItemProcessorTypes.stacker,
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [{ pos: new Vector(0, 0), direction: enumDirection.top }],
|
||||
})
|
||||
);
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(1, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
73
src/js/game/buildings/trash.js
Normal file
73
src/js/game/buildings/trash.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { ItemAcceptorComponent } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { enumHubGoalRewards } from "../tutorial_goals";
|
||||
import { GameRoot } from "../root";
|
||||
|
||||
export class MetaTrashBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("trash");
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Destroyer";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Accepts inputs from all sides and destroys them. Forever.";
|
||||
}
|
||||
|
||||
isRotateable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#cd7d86";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
getIsUnlocked(root) {
|
||||
return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_cutter_and_trash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(
|
||||
new ItemProcessorComponent({
|
||||
inputsPerCharge: 1,
|
||||
processorType: enumItemProcessorTypes.trash,
|
||||
})
|
||||
);
|
||||
|
||||
// Required, since the item processor needs this.
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [],
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [
|
||||
enumDirection.top,
|
||||
enumDirection.right,
|
||||
enumDirection.bottom,
|
||||
enumDirection.left,
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
158
src/js/game/buildings/underground_belt.js
Normal file
158
src/js/game/buildings/underground_belt.js
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Loader } from "../../core/loader";
|
||||
import { enumDirection, Vector, enumAngleToDirection, enumDirectionToVector } from "../../core/vector";
|
||||
import { ItemAcceptorComponent } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { enumUndergroundBeltMode, UndergroundBeltComponent } from "../components/underground_belt";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { GameRoot } from "../root";
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { enumHubGoalRewards } from "../tutorial_goals";
|
||||
|
||||
/** @enum {string} */
|
||||
export const arrayUndergroundRotationVariantToMode = [
|
||||
enumUndergroundBeltMode.sender,
|
||||
enumUndergroundBeltMode.receiver,
|
||||
];
|
||||
|
||||
export class MetaUndergroundBeltBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("underground_belt");
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Tunnel";
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#555";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Allows to tunnel resources under buildings and belts.";
|
||||
}
|
||||
|
||||
getFlipOrientationAfterPlacement() {
|
||||
return true;
|
||||
}
|
||||
|
||||
getStayInPlacementMode() {
|
||||
return true;
|
||||
}
|
||||
|
||||
getPreviewSprite(rotationVariant) {
|
||||
switch (arrayUndergroundRotationVariantToMode[rotationVariant]) {
|
||||
case enumUndergroundBeltMode.sender:
|
||||
return Loader.getSprite("sprites/buildings/underground_belt_entry.png");
|
||||
case enumUndergroundBeltMode.receiver:
|
||||
return Loader.getSprite("sprites/buildings/underground_belt_exit.png");
|
||||
default:
|
||||
assertAlways(false, "Invalid rotation variant");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
getIsUnlocked(root) {
|
||||
return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_tunnel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
// Required, since the item processor needs this.
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [],
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(new UndergroundBeltComponent({}));
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
* @param {Vector} tile
|
||||
* @param {number} rotation
|
||||
* @return {{ rotation: number, rotationVariant: number, connectedEntities?: Array<Entity> }}
|
||||
*/
|
||||
computeOptimalDirectionAndRotationVariantAtTile(root, tile, rotation) {
|
||||
const searchDirection = enumAngleToDirection[rotation];
|
||||
const searchVector = enumDirectionToVector[searchDirection];
|
||||
|
||||
const targetRotation = (rotation + 180) % 360;
|
||||
|
||||
for (let searchOffset = 1; searchOffset <= globalConfig.undergroundBeltMaxTiles; ++searchOffset) {
|
||||
tile = tile.addScalars(searchVector.x, searchVector.y);
|
||||
|
||||
const contents = root.map.getTileContent(tile);
|
||||
if (contents) {
|
||||
const undergroundComp = contents.components.UndergroundBelt;
|
||||
if (undergroundComp) {
|
||||
const staticComp = contents.components.StaticMapEntity;
|
||||
if (staticComp.rotationDegrees === targetRotation) {
|
||||
if (undergroundComp.mode !== enumUndergroundBeltMode.sender) {
|
||||
// If we encounter an underground receiver on our way which is also faced in our direction, we don't accept that
|
||||
break;
|
||||
}
|
||||
// console.log("GOT IT! rotation is", rotation, "and target is", staticComp.rotationDegrees);
|
||||
|
||||
return {
|
||||
rotation: targetRotation,
|
||||
rotationVariant: 1,
|
||||
connectedEntities: [contents],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rotation,
|
||||
rotationVariant: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Entity} entity
|
||||
* @param {number} rotationVariant
|
||||
*/
|
||||
updateRotationVariant(entity, rotationVariant) {
|
||||
entity.components.StaticMapEntity.spriteKey = this.getPreviewSprite(rotationVariant).spriteName;
|
||||
|
||||
switch (arrayUndergroundRotationVariantToMode[rotationVariant]) {
|
||||
case enumUndergroundBeltMode.sender: {
|
||||
entity.components.UndergroundBelt.mode = enumUndergroundBeltMode.sender;
|
||||
entity.components.ItemEjector.setSlots([]);
|
||||
entity.components.ItemAcceptor.setSlots([
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
case enumUndergroundBeltMode.receiver: {
|
||||
entity.components.UndergroundBelt.mode = enumUndergroundBeltMode.receiver;
|
||||
entity.components.ItemAcceptor.setSlots([]);
|
||||
entity.components.ItemEjector.setSlots([
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
direction: enumDirection.top,
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
assertAlways(false, "Invalid rotation variant");
|
||||
}
|
||||
}
|
||||
}
|
||||
870
src/js/game/camera.js
Normal file
870
src/js/game/camera.js
Normal file
@@ -0,0 +1,870 @@
|
||||
import {
|
||||
Math_abs,
|
||||
Math_ceil,
|
||||
Math_floor,
|
||||
Math_min,
|
||||
Math_random,
|
||||
performanceNow,
|
||||
Math_max,
|
||||
} from "../core/builtins";
|
||||
import { Rectangle } from "../core/rectangle";
|
||||
import { Signal, STOP_PROPAGATION } from "../core/signal";
|
||||
import { clamp, lerp } from "../core/utils";
|
||||
import { mixVector, Vector } from "../core/vector";
|
||||
import { globalConfig } from "../core/config";
|
||||
import { GameRoot } from "./root";
|
||||
import { BasicSerializableObject, types } from "../savegame/serialization";
|
||||
import { clickDetectorGlobals } from "../core/click_detector";
|
||||
import { createLogger } from "../core/logging";
|
||||
|
||||
const logger = createLogger("camera");
|
||||
|
||||
export const USER_INTERACT_MOVE = "move";
|
||||
export const USER_INTERACT_ZOOM = "zoom";
|
||||
export const USER_INTERACT_TOUCHEND = "touchend";
|
||||
|
||||
const velocitySmoothing = 0.5;
|
||||
const velocityFade = 0.98;
|
||||
const velocityStrength = 0.4;
|
||||
const velocityMax = 20;
|
||||
|
||||
export class Camera extends BasicSerializableObject {
|
||||
constructor(root) {
|
||||
super();
|
||||
|
||||
/** @type {GameRoot} */
|
||||
this.root = root;
|
||||
|
||||
// Zoom level, 2 means double size
|
||||
|
||||
// Find optimal initial zoom
|
||||
|
||||
this.zoomLevel = this.findInitialZoom();
|
||||
this.clampZoomLevel();
|
||||
|
||||
/** @type {Vector} */
|
||||
this.center = new Vector(0, 0);
|
||||
|
||||
// Input handling
|
||||
this.currentlyMoving = false;
|
||||
this.lastMovingPosition = null;
|
||||
this.cameraUpdateTimeBucket = 0.0;
|
||||
this.didMoveSinceTouchStart = false;
|
||||
this.currentlyPinching = false;
|
||||
this.lastPinchPositions = null;
|
||||
|
||||
this.keyboardForce = new Vector();
|
||||
|
||||
// Signal which gets emitted once the user changed something
|
||||
this.userInteraction = new Signal();
|
||||
|
||||
/** @type {Vector} */
|
||||
this.currentShake = new Vector(0, 0);
|
||||
|
||||
/** @type {Vector} */
|
||||
this.currentPan = new Vector(0, 0);
|
||||
|
||||
// Set desired pan (camera movement)
|
||||
/** @type {Vector} */
|
||||
this.desiredPan = new Vector(0, 0);
|
||||
|
||||
// Set desired camera center
|
||||
/** @type {Vector} */
|
||||
this.desiredCenter = null;
|
||||
|
||||
// Set desired camera zoom
|
||||
/** @type {number} */
|
||||
this.desiredZoom = null;
|
||||
|
||||
/** @type {Vector} */
|
||||
this.touchPostMoveVelocity = new Vector(0, 0);
|
||||
|
||||
// Handlers
|
||||
this.downPreHandler = new Signal(/* pos */);
|
||||
this.movePreHandler = new Signal(/* pos */);
|
||||
this.pinchPreHandler = new Signal(/* pos */);
|
||||
this.upPostHandler = new Signal(/* pos */);
|
||||
|
||||
this.internalInitEvents();
|
||||
this.clampZoomLevel();
|
||||
this.bindKeys();
|
||||
}
|
||||
|
||||
// Serialization
|
||||
static getId() {
|
||||
return "Camera";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return {
|
||||
zoomLevel: types.float,
|
||||
center: types.vector,
|
||||
};
|
||||
}
|
||||
|
||||
deserialize(data) {
|
||||
const errorCode = super.deserialize(data);
|
||||
if (errorCode) {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
// Safety
|
||||
this.clampZoomLevel();
|
||||
}
|
||||
|
||||
// Simple geters & setters
|
||||
|
||||
addScreenShake(amount) {
|
||||
const currentShakeAmount = this.currentShake.length();
|
||||
const scale = 1 / (1 + 3 * currentShakeAmount);
|
||||
this.currentShake.x = this.currentShake.x + 2 * (Math_random() - 0.5) * scale * amount;
|
||||
this.currentShake.y = this.currentShake.y + 2 * (Math_random() - 0.5) * scale * amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a point in world space to focus on
|
||||
* @param {Vector} center
|
||||
*/
|
||||
setDesiredCenter(center) {
|
||||
this.desiredCenter = center.copy();
|
||||
this.currentlyMoving = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if this camera is currently moving by a non-user interaction
|
||||
*/
|
||||
isCurrentlyMovingToDesiredCenter() {
|
||||
return this.desiredCenter !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the camera pan, every frame the camera will move by this amount
|
||||
* @param {Vector} pan
|
||||
*/
|
||||
setPan(pan) {
|
||||
this.desiredPan = pan.copy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a good initial zoom level
|
||||
*/
|
||||
findInitialZoom() {
|
||||
return 3;
|
||||
const desiredWorldSpaceWidth = 20 * globalConfig.tileSize;
|
||||
const zoomLevelX = this.root.gameWidth / desiredWorldSpaceWidth;
|
||||
const zoomLevelY = this.root.gameHeight / desiredWorldSpaceWidth;
|
||||
|
||||
const finalLevel = Math_min(zoomLevelX, zoomLevelY);
|
||||
assert(
|
||||
Number.isFinite(finalLevel) && finalLevel > 0,
|
||||
"Invalid zoom level computed for initial zoom: " + finalLevel
|
||||
);
|
||||
return finalLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all animations
|
||||
*/
|
||||
clearAnimations() {
|
||||
this.touchPostMoveVelocity.x = 0;
|
||||
this.touchPostMoveVelocity.y = 0;
|
||||
this.desiredCenter = null;
|
||||
this.desiredPan.x = 0;
|
||||
this.desiredPan.y = 0;
|
||||
this.currentPan.x = 0;
|
||||
this.currentPan.y = 0;
|
||||
this.currentlyPinching = false;
|
||||
this.currentlyMoving = false;
|
||||
this.lastMovingPosition = null;
|
||||
this.didMoveSinceTouchStart = false;
|
||||
this.desiredZoom = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the user is currently interacting with the camera
|
||||
* @returns {boolean} true if the user interacts
|
||||
*/
|
||||
isCurrentlyInteracting() {
|
||||
if (this.currentlyPinching) {
|
||||
return true;
|
||||
}
|
||||
if (this.currentlyMoving) {
|
||||
// Only interacting if moved at least once
|
||||
return this.didMoveSinceTouchStart;
|
||||
}
|
||||
if (this.touchPostMoveVelocity.lengthSquare() > 1) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if in the next frame the viewport will change
|
||||
* @returns {boolean} true if it willchange
|
||||
*/
|
||||
viewportWillChange() {
|
||||
return this.desiredCenter !== null || this.desiredZoom !== null || this.isCurrentlyInteracting();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels all interactions, that is user interaction and non user interaction
|
||||
*/
|
||||
cancelAllInteractions() {
|
||||
this.touchPostMoveVelocity = new Vector(0, 0);
|
||||
this.desiredCenter = null;
|
||||
this.currentlyMoving = false;
|
||||
this.currentlyPinching = false;
|
||||
this.desiredZoom = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns effective viewport width
|
||||
*/
|
||||
getViewportWidth() {
|
||||
return this.root.gameWidth / this.zoomLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns effective viewport height
|
||||
*/
|
||||
getViewportHeight() {
|
||||
return this.root.gameHeight / this.zoomLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns effective world space viewport left
|
||||
*/
|
||||
getViewportLeft() {
|
||||
return this.center.x - this.getViewportWidth() / 2 + (this.currentShake.x * 10) / this.zoomLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns effective world space viewport right
|
||||
*/
|
||||
getViewportRight() {
|
||||
return this.center.x + this.getViewportWidth() / 2 + (this.currentShake.x * 10) / this.zoomLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns effective world space viewport top
|
||||
*/
|
||||
getViewportTop() {
|
||||
return this.center.y - this.getViewportHeight() / 2 + (this.currentShake.x * 10) / this.zoomLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns effective world space viewport bottom
|
||||
*/
|
||||
getViewportBottom() {
|
||||
return this.center.y + this.getViewportHeight() / 2 + (this.currentShake.x * 10) / this.zoomLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the visible world space rect
|
||||
* @returns {Rectangle}
|
||||
*/
|
||||
getVisibleRect() {
|
||||
return Rectangle.fromTRBL(
|
||||
Math_floor(this.getViewportTop()),
|
||||
Math_ceil(this.getViewportRight()),
|
||||
Math_ceil(this.getViewportBottom()),
|
||||
Math_floor(this.getViewportLeft())
|
||||
);
|
||||
}
|
||||
|
||||
getIsMapOverlayActive() {
|
||||
return this.zoomLevel < globalConfig.mapChunkOverviewMinZoom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches all event listeners
|
||||
*/
|
||||
internalInitEvents() {
|
||||
this.eventListenerTouchStart = this.onTouchStart.bind(this);
|
||||
this.eventListenerTouchEnd = this.onTouchEnd.bind(this);
|
||||
this.eventListenerTouchMove = this.onTouchMove.bind(this);
|
||||
this.eventListenerMousewheel = this.onMouseWheel.bind(this);
|
||||
this.eventListenerMouseDown = this.onMouseDown.bind(this);
|
||||
this.eventListenerMouseMove = this.onMouseMove.bind(this);
|
||||
this.eventListenerMouseUp = this.onMouseUp.bind(this);
|
||||
|
||||
this.root.canvas.addEventListener("touchstart", this.eventListenerTouchStart);
|
||||
this.root.canvas.addEventListener("touchend", this.eventListenerTouchEnd);
|
||||
this.root.canvas.addEventListener("touchcancel", this.eventListenerTouchEnd);
|
||||
this.root.canvas.addEventListener("touchmove", this.eventListenerTouchMove);
|
||||
|
||||
this.root.canvas.addEventListener("wheel", this.eventListenerMousewheel);
|
||||
this.root.canvas.addEventListener("mousedown", this.eventListenerMouseDown);
|
||||
this.root.canvas.addEventListener("mousemove", this.eventListenerMouseMove);
|
||||
this.root.canvas.addEventListener("mouseup", this.eventListenerMouseUp);
|
||||
this.root.canvas.addEventListener("mouseout", this.eventListenerMouseUp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up all event listeners
|
||||
*/
|
||||
cleanup() {
|
||||
this.root.canvas.removeEventListener("touchstart", this.eventListenerTouchStart);
|
||||
this.root.canvas.removeEventListener("touchend", this.eventListenerTouchEnd);
|
||||
this.root.canvas.removeEventListener("touchcancel", this.eventListenerTouchEnd);
|
||||
this.root.canvas.removeEventListener("touchmove", this.eventListenerTouchMove);
|
||||
|
||||
this.root.canvas.removeEventListener("wheel", this.eventListenerMousewheel);
|
||||
this.root.canvas.removeEventListener("mousedown", this.eventListenerMouseDown);
|
||||
this.root.canvas.removeEventListener("mousemove", this.eventListenerMouseMove);
|
||||
this.root.canvas.removeEventListener("mouseup", this.eventListenerMouseUp);
|
||||
this.root.canvas.removeEventListener("mouseout", this.eventListenerMouseUp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the arrow keys
|
||||
*/
|
||||
bindKeys() {
|
||||
const mapper = this.root.gameState.keyActionMapper;
|
||||
mapper.getBinding("map_move_up").add(() => (this.keyboardForce.y = -1));
|
||||
mapper.getBinding("map_move_down").add(() => (this.keyboardForce.y = 1));
|
||||
mapper.getBinding("map_move_right").add(() => (this.keyboardForce.x = 1));
|
||||
mapper.getBinding("map_move_left").add(() => (this.keyboardForce.x = -1));
|
||||
|
||||
mapper.getBinding("center_map").add(() => (this.desiredCenter = new Vector(0, 0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts from screen to world space
|
||||
* @param {Vector} screen
|
||||
* @returns {Vector} world space
|
||||
*/
|
||||
screenToWorld(screen) {
|
||||
const centerSpace = screen.subScalars(this.root.gameWidth / 2, this.root.gameHeight / 2);
|
||||
return centerSpace.divideScalar(this.zoomLevel).add(this.center);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts from world to screen space
|
||||
* @param {Vector} world
|
||||
* @returns {Vector} screen space
|
||||
*/
|
||||
worldToScreen(world) {
|
||||
const screenSpace = world.sub(this.center).multiplyScalar(this.zoomLevel);
|
||||
return screenSpace.addScalars(this.root.gameWidth / 2, this.root.gameHeight / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if a point is on screen
|
||||
* @param {Vector} point
|
||||
* @returns {boolean} true if its on screen
|
||||
*/
|
||||
isWorldPointOnScreen(point) {
|
||||
const rect = this.getVisibleRect();
|
||||
return rect.containsPoint(point.x, point.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if we can further zoom in
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canZoomIn() {
|
||||
const maxLevel = this.root.app.platformWrapper.getMaximumZoom();
|
||||
return this.zoomLevel <= maxLevel - 0.01;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if we can further zoom out
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canZoomOut() {
|
||||
const minLevel = this.root.app.platformWrapper.getMinimumZoom();
|
||||
return this.zoomLevel >= minLevel + 0.01;
|
||||
}
|
||||
|
||||
// EVENTS
|
||||
|
||||
/**
|
||||
* Checks if the mouse event is too close after a touch event and thus
|
||||
* should get ignored
|
||||
*/
|
||||
checkPreventDoubleMouse() {
|
||||
if (performanceNow() - clickDetectorGlobals.lastTouchTime < 1000.0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mousedown handler
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
onMouseDown(event) {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
}
|
||||
|
||||
if (!this.checkPreventDoubleMouse()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.touchPostMoveVelocity = new Vector(0, 0);
|
||||
if (event.which === 1) {
|
||||
this.combinedSingleTouchStartHandler(event.clientX, event.clientY);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mousemove handler
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
onMouseMove(event) {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
}
|
||||
|
||||
if (!this.checkPreventDoubleMouse()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.which === 1) {
|
||||
this.combinedSingleTouchMoveHandler(event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
// Clamp everything afterwards
|
||||
this.clampZoomLevel();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mouseup handler
|
||||
* @param {MouseEvent=} event
|
||||
*/
|
||||
onMouseUp(event) {
|
||||
if (event) {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.checkPreventDoubleMouse()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.combinedSingleTouchStopHandler(event.clientX, event.clientY);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mousewheel event
|
||||
* @param {WheelEvent} event
|
||||
*/
|
||||
onMouseWheel(event) {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
}
|
||||
|
||||
const delta = Math.sign(event.deltaY) * -0.15;
|
||||
assert(Number.isFinite(delta), "Got invalid delta in mouse wheel event: " + event.deltaY);
|
||||
assert(Number.isFinite(this.zoomLevel), "Got invalid zoom level *before* wheel: " + this.zoomLevel);
|
||||
this.zoomLevel *= 1 + delta;
|
||||
assert(Number.isFinite(this.zoomLevel), "Got invalid zoom level *after* wheel: " + this.zoomLevel);
|
||||
|
||||
this.clampZoomLevel();
|
||||
this.desiredZoom = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch start handler
|
||||
* @param {TouchEvent} event
|
||||
*/
|
||||
onTouchStart(event) {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
}
|
||||
|
||||
clickDetectorGlobals.lastTouchTime = performanceNow();
|
||||
this.touchPostMoveVelocity = new Vector(0, 0);
|
||||
|
||||
if (event.touches.length === 1) {
|
||||
const touch = event.touches[0];
|
||||
this.combinedSingleTouchStartHandler(touch.clientX, touch.clientY);
|
||||
} else if (event.touches.length === 2) {
|
||||
if (this.pinchPreHandler.dispatch() === STOP_PROPAGATION) {
|
||||
// Something prevented pinching
|
||||
return false;
|
||||
}
|
||||
|
||||
const touch1 = event.touches[0];
|
||||
const touch2 = event.touches[1];
|
||||
this.currentlyMoving = false;
|
||||
this.currentlyPinching = true;
|
||||
this.lastPinchPositions = [
|
||||
new Vector(touch1.clientX, touch1.clientY),
|
||||
new Vector(touch2.clientX, touch2.clientY),
|
||||
];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch move handler
|
||||
* @param {TouchEvent} event
|
||||
*/
|
||||
onTouchMove(event) {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
}
|
||||
|
||||
clickDetectorGlobals.lastTouchTime = performanceNow();
|
||||
|
||||
if (event.touches.length === 1) {
|
||||
const touch = event.touches[0];
|
||||
this.combinedSingleTouchMoveHandler(touch.clientX, touch.clientY);
|
||||
} else if (event.touches.length === 2) {
|
||||
if (this.currentlyPinching) {
|
||||
const touch1 = event.touches[0];
|
||||
const touch2 = event.touches[1];
|
||||
|
||||
const newPinchPositions = [
|
||||
new Vector(touch1.clientX, touch1.clientY),
|
||||
new Vector(touch2.clientX, touch2.clientY),
|
||||
];
|
||||
|
||||
// Get distance of taps last time and now
|
||||
const lastDistance = this.lastPinchPositions[0].distance(this.lastPinchPositions[1]);
|
||||
const thisDistance = newPinchPositions[0].distance(newPinchPositions[1]);
|
||||
|
||||
// IMPORTANT to do math max here to avoid NaN and causing an invalid zoom level
|
||||
const difference = thisDistance / Math_max(0.001, lastDistance);
|
||||
|
||||
// Find old center of zoom
|
||||
let oldCenter = this.lastPinchPositions[0].centerPoint(this.lastPinchPositions[1]);
|
||||
|
||||
// Find new center of zoom
|
||||
let center = newPinchPositions[0].centerPoint(newPinchPositions[1]);
|
||||
|
||||
// Compute movement
|
||||
let movement = oldCenter.sub(center);
|
||||
this.center.x += movement.x / this.zoomLevel;
|
||||
this.center.y += movement.y / this.zoomLevel;
|
||||
|
||||
// Compute zoom
|
||||
center = center.sub(new Vector(this.root.gameWidth / 2, this.root.gameHeight / 2));
|
||||
|
||||
// Apply zoom
|
||||
assert(
|
||||
Number.isFinite(difference),
|
||||
"Invalid pinch difference: " +
|
||||
difference +
|
||||
"(last=" +
|
||||
lastDistance +
|
||||
", new = " +
|
||||
thisDistance +
|
||||
")"
|
||||
);
|
||||
this.zoomLevel *= difference;
|
||||
|
||||
// Stick to pivot point
|
||||
const correcture = center.multiplyScalar(difference - 1).divideScalar(this.zoomLevel);
|
||||
|
||||
this.center = this.center.add(correcture);
|
||||
this.lastPinchPositions = newPinchPositions;
|
||||
this.userInteraction.dispatch(USER_INTERACT_MOVE);
|
||||
|
||||
// Since we zoomed, abort any programmed zooming
|
||||
if (this.desiredZoom) {
|
||||
this.desiredZoom = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp everything afterwards
|
||||
this.clampZoomLevel();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch end and cancel handler
|
||||
* @param {TouchEvent=} event
|
||||
*/
|
||||
onTouchEnd(event) {
|
||||
if (event) {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
clickDetectorGlobals.lastTouchTime = performanceNow();
|
||||
if (event.changedTouches.length === 0) {
|
||||
logger.warn("Touch end without changed touches");
|
||||
}
|
||||
|
||||
const touch = event.changedTouches[0];
|
||||
this.combinedSingleTouchStopHandler(touch.clientX, touch.clientY);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal touch start handler
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
*/
|
||||
combinedSingleTouchStartHandler(x, y) {
|
||||
const pos = new Vector(x, y);
|
||||
if (this.downPreHandler.dispatch(pos) === STOP_PROPAGATION) {
|
||||
// Somebody else captured it
|
||||
return;
|
||||
}
|
||||
|
||||
this.touchPostMoveVelocity = new Vector(0, 0);
|
||||
this.currentlyMoving = true;
|
||||
this.lastMovingPosition = pos;
|
||||
this.didMoveSinceTouchStart = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal touch move handler
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
*/
|
||||
combinedSingleTouchMoveHandler(x, y) {
|
||||
const pos = new Vector(x, y);
|
||||
if (this.movePreHandler.dispatch(pos) === STOP_PROPAGATION) {
|
||||
// Somebody else captured it
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.currentlyMoving) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let delta = this.lastMovingPosition.sub(pos).divideScalar(this.zoomLevel);
|
||||
if (G_IS_DEV && globalConfig.debug.testCulling) {
|
||||
// When testing culling, we see everything from the same distance
|
||||
delta = delta.multiplyScalar(this.zoomLevel * -2);
|
||||
}
|
||||
|
||||
this.didMoveSinceTouchStart = this.didMoveSinceTouchStart || delta.length() > 0;
|
||||
this.center = this.center.add(delta);
|
||||
|
||||
this.touchPostMoveVelocity = this.touchPostMoveVelocity
|
||||
.multiplyScalar(velocitySmoothing)
|
||||
.add(delta.multiplyScalar(1 - velocitySmoothing));
|
||||
|
||||
this.lastMovingPosition = pos;
|
||||
this.userInteraction.dispatch(USER_INTERACT_MOVE);
|
||||
|
||||
// Since we moved, abort any programmed moving
|
||||
if (this.desiredCenter) {
|
||||
this.desiredCenter = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal touch stop handler
|
||||
*/
|
||||
combinedSingleTouchStopHandler(x, y) {
|
||||
if (this.currentlyMoving || this.currentlyPinching) {
|
||||
this.currentlyMoving = false;
|
||||
this.currentlyPinching = false;
|
||||
this.lastMovingPosition = null;
|
||||
this.lastPinchPositions = null;
|
||||
this.userInteraction.dispatch(USER_INTERACT_TOUCHEND);
|
||||
this.didMoveSinceTouchStart = false;
|
||||
}
|
||||
this.upPostHandler.dispatch(new Vector(x, y));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps the camera zoom level within the allowed range
|
||||
*/
|
||||
clampZoomLevel() {
|
||||
if (G_IS_DEV && globalConfig.debug.disableZoomLimits) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = this.root.app.platformWrapper;
|
||||
|
||||
assert(Number.isFinite(this.zoomLevel), "Invalid zoom level *before* clamp: " + this.zoomLevel);
|
||||
this.zoomLevel = clamp(this.zoomLevel, wrapper.getMinimumZoom(), wrapper.getMaximumZoom());
|
||||
assert(Number.isFinite(this.zoomLevel), "Invalid zoom level *after* clamp: " + this.zoomLevel);
|
||||
|
||||
if (this.desiredZoom) {
|
||||
this.desiredZoom = clamp(this.desiredZoom, wrapper.getMinimumZoom(), wrapper.getMaximumZoom());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the camera
|
||||
* @param {number} dt Delta time in milliseconds
|
||||
*/
|
||||
update(dt) {
|
||||
dt = Math_min(dt, 33);
|
||||
this.cameraUpdateTimeBucket += dt;
|
||||
|
||||
// Simulate movement of N FPS
|
||||
const updatesPerFrame = 4;
|
||||
const physicsStepSizeMs = 1000.0 / (60.0 * updatesPerFrame);
|
||||
|
||||
let now = this.root.time.systemNow() - 3 * physicsStepSizeMs;
|
||||
|
||||
while (this.cameraUpdateTimeBucket > physicsStepSizeMs) {
|
||||
now += physicsStepSizeMs;
|
||||
this.cameraUpdateTimeBucket -= physicsStepSizeMs;
|
||||
|
||||
this.internalUpdatePanning(now, physicsStepSizeMs);
|
||||
this.internalUpdateZooming(now, physicsStepSizeMs);
|
||||
this.internalUpdateCentering(now, physicsStepSizeMs);
|
||||
this.internalUpdateShake(now, physicsStepSizeMs);
|
||||
this.internalUpdateKeyboardForce(now, physicsStepSizeMs);
|
||||
}
|
||||
this.clampZoomLevel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares a context to transform it
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
*/
|
||||
transform(context) {
|
||||
if (G_IS_DEV && globalConfig.debug.testCulling) {
|
||||
context.transform(1, 0, 0, 1, 100, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
this.clampZoomLevel();
|
||||
const zoom = this.zoomLevel;
|
||||
|
||||
context.transform(
|
||||
// Scale, skew, rotate
|
||||
zoom,
|
||||
0,
|
||||
0,
|
||||
zoom,
|
||||
|
||||
// Translate
|
||||
-zoom * this.getViewportLeft(),
|
||||
-zoom * this.getViewportTop()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal shake handler
|
||||
* @param {number} now Time now in seconds
|
||||
* @param {number} dt Delta time
|
||||
*/
|
||||
internalUpdateShake(now, dt) {
|
||||
this.currentShake = this.currentShake.multiplyScalar(0.92);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal pan handler
|
||||
* @param {number} now Time now in seconds
|
||||
* @param {number} dt Delta time
|
||||
*/
|
||||
internalUpdatePanning(now, dt) {
|
||||
const baseStrength = velocityStrength * this.root.app.platformWrapper.getTouchPanStrength();
|
||||
|
||||
this.touchPostMoveVelocity = this.touchPostMoveVelocity.multiplyScalar(velocityFade);
|
||||
|
||||
// Check influence of past points
|
||||
if (!this.currentlyMoving && !this.currentlyPinching) {
|
||||
const len = this.touchPostMoveVelocity.length();
|
||||
if (len >= velocityMax) {
|
||||
this.touchPostMoveVelocity.x = (this.touchPostMoveVelocity.x * velocityMax) / len;
|
||||
this.touchPostMoveVelocity.y = (this.touchPostMoveVelocity.y * velocityMax) / len;
|
||||
}
|
||||
|
||||
this.center = this.center.add(this.touchPostMoveVelocity.multiplyScalar(baseStrength));
|
||||
|
||||
// Panning
|
||||
this.currentPan = mixVector(this.currentPan, this.desiredPan, 0.06);
|
||||
this.center = this.center.add(this.currentPan.multiplyScalar((0.5 * dt) / this.zoomLevel));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the non user interaction zooming
|
||||
* @param {number} now Time now in seconds
|
||||
* @param {number} dt Delta time
|
||||
*/
|
||||
internalUpdateZooming(now, dt) {
|
||||
if (!this.currentlyPinching && this.desiredZoom !== null) {
|
||||
const diff = this.zoomLevel - this.desiredZoom;
|
||||
if (Math_abs(diff) > 0.05) {
|
||||
let fade = 0.94;
|
||||
if (diff > 0) {
|
||||
// Zoom out faster than in
|
||||
fade = 0.9;
|
||||
}
|
||||
|
||||
assert(Number.isFinite(this.desiredZoom), "Desired zoom is NaN: " + this.desiredZoom);
|
||||
assert(Number.isFinite(fade), "Zoom fade is NaN: " + fade);
|
||||
this.zoomLevel = this.zoomLevel * fade + this.desiredZoom * (1 - fade);
|
||||
assert(Number.isFinite(this.zoomLevel), "Zoom level is NaN after fade: " + this.zoomLevel);
|
||||
} else {
|
||||
this.desiredZoom = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the non user interaction centering
|
||||
* @param {number} now Time now in seconds
|
||||
* @param {number} dt Delta time
|
||||
*/
|
||||
internalUpdateCentering(now, dt) {
|
||||
if (!this.currentlyMoving && this.desiredCenter !== null) {
|
||||
const diff = this.center.direction(this.desiredCenter);
|
||||
const length = diff.length();
|
||||
const tolerance = 1 / this.zoomLevel;
|
||||
if (length > tolerance) {
|
||||
const movement = diff.multiplyScalar(Math_min(1, dt * 0.008));
|
||||
this.center.x += movement.x;
|
||||
this.center.y += movement.y;
|
||||
} else {
|
||||
this.desiredCenter = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the keyboard forces
|
||||
* @param {number} now
|
||||
* @param {number} dt Delta time
|
||||
*/
|
||||
internalUpdateKeyboardForce(now, dt) {
|
||||
if (!this.currentlyMoving && this.desiredCenter == null) {
|
||||
const limitingDimension = Math_min(this.root.gameWidth, this.root.gameHeight);
|
||||
|
||||
const moveAmount = ((limitingDimension / 2048) * dt) / this.zoomLevel;
|
||||
|
||||
let forceX = 0;
|
||||
let forceY = 0;
|
||||
|
||||
const actionMapper = this.root.gameState.keyActionMapper;
|
||||
if (actionMapper.getBinding("map_move_up").currentlyDown) {
|
||||
forceY -= 1;
|
||||
}
|
||||
|
||||
if (actionMapper.getBinding("map_move_down").currentlyDown) {
|
||||
forceY += 1;
|
||||
}
|
||||
|
||||
if (actionMapper.getBinding("map_move_left").currentlyDown) {
|
||||
forceX -= 1;
|
||||
}
|
||||
|
||||
if (actionMapper.getBinding("map_move_right").currentlyDown) {
|
||||
forceX += 1;
|
||||
}
|
||||
|
||||
this.center.x += moveAmount * forceX;
|
||||
this.center.y += moveAmount * forceY;
|
||||
}
|
||||
}
|
||||
}
|
||||
70
src/js/game/canvas_click_interceptor.js
Normal file
70
src/js/game/canvas_click_interceptor.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { STOP_PROPAGATION } from "../core/signal";
|
||||
import { GameRoot } from "./root";
|
||||
import { ClickDetector } from "../core/click_detector";
|
||||
import { createLogger } from "../core/logging";
|
||||
|
||||
const logger = createLogger("canvas_click_interceptor");
|
||||
|
||||
export class CanvasClickInterceptor {
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
|
||||
this.root.signals.postLoadHook.add(this.initialize, this);
|
||||
this.root.signals.aboutToDestruct.add(this.cleanup, this);
|
||||
|
||||
/** @type {Array<object>} */
|
||||
this.interceptors = [];
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.clickDetector = new ClickDetector(this.root.canvas, {
|
||||
applyCssClass: null,
|
||||
captureTouchmove: false,
|
||||
targetOnly: true,
|
||||
preventDefault: true,
|
||||
maxDistance: 13,
|
||||
clickSound: null,
|
||||
});
|
||||
this.clickDetector.click.add(this.onCanvasClick, this);
|
||||
this.clickDetector.rightClick.add(this.onCanvasRightClick, this);
|
||||
|
||||
if (this.root.hud.parts.buildingPlacer) {
|
||||
this.interceptors.push(this.root.hud.parts.buildingPlacer);
|
||||
}
|
||||
|
||||
logger.log("Registered", this.interceptors.length, "interceptors");
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this.clickDetector) {
|
||||
this.clickDetector.cleanup();
|
||||
}
|
||||
this.interceptors = [];
|
||||
}
|
||||
|
||||
onCanvasClick(position, event, cancelAction = false) {
|
||||
if (!this.root.gameInitialized) {
|
||||
logger.warn("Skipping click outside of game initiaization!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.root.hud.hasBlockingOverlayOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.interceptors.length; ++i) {
|
||||
const interceptor = this.interceptors[i];
|
||||
if (interceptor.onCanvasClick(position, cancelAction) === STOP_PROPAGATION) {
|
||||
// log(this, "Interceptor", interceptor.constructor.name, "catched click");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onCanvasRightClick(position, event) {
|
||||
this.onCanvasClick(position, event, true);
|
||||
}
|
||||
}
|
||||
167
src/js/game/colors.js
Normal file
167
src/js/game/colors.js
Normal file
@@ -0,0 +1,167 @@
|
||||
/** @enum {string} */
|
||||
export const enumColors = {
|
||||
red: "red",
|
||||
green: "green",
|
||||
blue: "blue",
|
||||
|
||||
yellow: "yellow",
|
||||
purple: "purple",
|
||||
cyan: "cyan",
|
||||
|
||||
white: "white",
|
||||
uncolored: "uncolored",
|
||||
};
|
||||
|
||||
/** @enum {string} */
|
||||
export const enumColorToShortcode = {
|
||||
[enumColors.red]: "r",
|
||||
[enumColors.green]: "g",
|
||||
[enumColors.blue]: "b",
|
||||
|
||||
[enumColors.yellow]: "y",
|
||||
[enumColors.purple]: "p",
|
||||
[enumColors.cyan]: "c",
|
||||
|
||||
[enumColors.white]: "w",
|
||||
[enumColors.uncolored]: "u",
|
||||
};
|
||||
|
||||
/** @enum {enumColors} */
|
||||
export const enumShortcodeToColor = {};
|
||||
for (const key in enumColorToShortcode) {
|
||||
enumShortcodeToColor[enumColorToShortcode[key]] = key;
|
||||
}
|
||||
|
||||
/** @enum {string} */
|
||||
export const enumColorsToHexCode = {
|
||||
[enumColors.red]: "#ff666a",
|
||||
[enumColors.green]: "#78ff66",
|
||||
[enumColors.blue]: "#66a7ff",
|
||||
|
||||
// red + green
|
||||
[enumColors.yellow]: "#fcf52a",
|
||||
|
||||
// red + blue
|
||||
[enumColors.purple]: "#dd66ff",
|
||||
|
||||
// blue + green
|
||||
[enumColors.cyan]: "#87fff5",
|
||||
|
||||
// blue + green + red
|
||||
[enumColors.white]: "#ffffff",
|
||||
|
||||
[enumColors.uncolored]: "#aaaaaa",
|
||||
};
|
||||
|
||||
const c = enumColors;
|
||||
/** @enum {Object.<string, string>} */
|
||||
export const enumColorMixingResults = {
|
||||
// 255, 0, 0
|
||||
[c.red]: {
|
||||
[c.green]: c.yellow,
|
||||
[c.blue]: c.purple,
|
||||
|
||||
[c.yellow]: c.yellow,
|
||||
[c.purple]: c.purple,
|
||||
[c.cyan]: c.white,
|
||||
|
||||
[c.white]: c.white,
|
||||
},
|
||||
|
||||
// 0, 255, 0
|
||||
[c.green]: {
|
||||
[c.blue]: c.cyan,
|
||||
|
||||
[c.yellow]: c.yellow,
|
||||
[c.purple]: c.white,
|
||||
[c.cyan]: c.cyan,
|
||||
|
||||
[c.white]: c.white,
|
||||
},
|
||||
|
||||
// 0, 255, 0
|
||||
[c.blue]: {
|
||||
[c.yellow]: c.white,
|
||||
[c.purple]: c.purple,
|
||||
[c.cyan]: c.cyan,
|
||||
|
||||
[c.white]: c.white,
|
||||
},
|
||||
|
||||
// 255, 255, 0
|
||||
[c.yellow]: {
|
||||
[c.purple]: c.white,
|
||||
[c.cyan]: c.white,
|
||||
},
|
||||
|
||||
// 255, 0, 255
|
||||
[c.purple]: {
|
||||
[c.cyan]: c.white,
|
||||
},
|
||||
|
||||
// 0, 255, 255
|
||||
[c.cyan]: {},
|
||||
|
||||
//// SPECIAL COLORS
|
||||
|
||||
// 255, 255, 255
|
||||
[c.white]: {
|
||||
// auto
|
||||
},
|
||||
|
||||
// X, X, X
|
||||
[c.uncolored]: {
|
||||
// auto
|
||||
},
|
||||
};
|
||||
|
||||
// Create same color lookups
|
||||
for (const color in enumColors) {
|
||||
enumColorMixingResults[color][color] = color;
|
||||
|
||||
// Anything with white is white again
|
||||
enumColorMixingResults[color][c.white] = c.white;
|
||||
|
||||
// Anything with uncolored is the same color
|
||||
enumColorMixingResults[color][c.uncolored] = color;
|
||||
}
|
||||
|
||||
// Create reverse lookup and check color mixing lookups
|
||||
for (const colorA in enumColorMixingResults) {
|
||||
for (const colorB in enumColorMixingResults[colorA]) {
|
||||
const resultColor = enumColorMixingResults[colorA][colorB];
|
||||
if (!enumColorMixingResults[colorB]) {
|
||||
enumColorMixingResults[colorB] = {
|
||||
[colorA]: resultColor,
|
||||
};
|
||||
} else {
|
||||
const existingResult = enumColorMixingResults[colorB][colorA];
|
||||
if (existingResult && existingResult !== resultColor) {
|
||||
assertAlways(
|
||||
false,
|
||||
"invalid color mixing configuration, " +
|
||||
colorA +
|
||||
" + " +
|
||||
colorB +
|
||||
" is " +
|
||||
resultColor +
|
||||
" but " +
|
||||
colorB +
|
||||
" + " +
|
||||
colorA +
|
||||
" is " +
|
||||
existingResult
|
||||
);
|
||||
}
|
||||
enumColorMixingResults[colorB][colorA] = resultColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const colorA in enumColorMixingResults) {
|
||||
for (const colorB in enumColorMixingResults) {
|
||||
if (!enumColorMixingResults[colorA][colorB]) {
|
||||
assertAlways(false, "Color mixing of", colorA, "with", colorB, "is not defined");
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/js/game/component.js
Normal file
38
src/js/game/component.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { BasicSerializableObject } from "../savegame/serialization";
|
||||
|
||||
export class Component extends BasicSerializableObject {
|
||||
/**
|
||||
* Returns the components unique id
|
||||
* @returns {string}
|
||||
*/
|
||||
static getId() {
|
||||
abstract;
|
||||
return "unknown-component";
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return the schema used for serialization
|
||||
*/
|
||||
static getSchema() {
|
||||
return {};
|
||||
}
|
||||
|
||||
/* dev:start */
|
||||
|
||||
/**
|
||||
* Fixes typeof DerivedComponent is not assignable to typeof Component, compiled out
|
||||
* in non-dev builds
|
||||
*/
|
||||
constructor(...args) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string representing the components data, only in dev builds
|
||||
* @returns {string}
|
||||
*/
|
||||
getDebugString() {
|
||||
return null;
|
||||
}
|
||||
/* dev:end */
|
||||
}
|
||||
38
src/js/game/component_registry.js
Normal file
38
src/js/game/component_registry.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { gComponentRegistry } from "../core/global_registries";
|
||||
import { StaticMapEntityComponent } from "./components/static_map_entity";
|
||||
import { BeltComponent } from "./components/belt";
|
||||
import { ItemEjectorComponent } from "./components/item_ejector";
|
||||
import { ItemAcceptorComponent } from "./components/item_acceptor";
|
||||
import { MinerComponent } from "./components/miner";
|
||||
import { ItemProcessorComponent } from "./components/item_processor";
|
||||
import { ReplaceableMapEntityComponent } from "./components/replaceable_map_entity";
|
||||
import { UndergroundBeltComponent } from "./components/underground_belt";
|
||||
import { UnremovableComponent } from "./components/unremovable";
|
||||
import { HubComponent } from "./components/hub";
|
||||
|
||||
export function initComponentRegistry() {
|
||||
gComponentRegistry.register(StaticMapEntityComponent);
|
||||
gComponentRegistry.register(BeltComponent);
|
||||
gComponentRegistry.register(ItemEjectorComponent);
|
||||
gComponentRegistry.register(ItemAcceptorComponent);
|
||||
gComponentRegistry.register(MinerComponent);
|
||||
gComponentRegistry.register(ItemProcessorComponent);
|
||||
gComponentRegistry.register(ReplaceableMapEntityComponent);
|
||||
gComponentRegistry.register(UndergroundBeltComponent);
|
||||
gComponentRegistry.register(UnremovableComponent);
|
||||
gComponentRegistry.register(HubComponent);
|
||||
|
||||
// IMPORTANT ^^^^^ REGENERATE SAVEGAME SCHEMA AFTERWARDS
|
||||
// IMPORTANT ^^^^^ ALSO UPDATE ENTITY COMPONENT STORAG
|
||||
|
||||
// Sanity check - If this is thrown, you (=me, lol) forgot to add a new component here
|
||||
|
||||
assert(
|
||||
// @ts-ignore
|
||||
require.context("./components", false, /.*\.js/i).keys().length ===
|
||||
gComponentRegistry.getNumEntries(),
|
||||
"Not all components are registered"
|
||||
);
|
||||
|
||||
console.log("📦 There are", gComponentRegistry.getNumEntries(), "components");
|
||||
}
|
||||
92
src/js/game/components/belt.js
Normal file
92
src/js/game/components/belt.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Component } from "../component";
|
||||
import { types } from "../../savegame/serialization";
|
||||
import { gItemRegistry } from "../../core/global_registries";
|
||||
import { BaseItem } from "../base_item";
|
||||
import { Vector, enumDirection } from "../../core/vector";
|
||||
import { Math_PI, Math_sin, Math_cos } from "../../core/builtins";
|
||||
import { globalConfig } from "../../core/config";
|
||||
|
||||
export class BeltComponent extends Component {
|
||||
static getId() {
|
||||
return "Belt";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return {
|
||||
direction: types.string,
|
||||
sortedItems: types.array(types.pair(types.ufloat, types.obj(gItemRegistry))),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {enumDirection=} param0.direction The direction of the belt
|
||||
*/
|
||||
constructor({ direction = enumDirection.top }) {
|
||||
super();
|
||||
|
||||
this.direction = direction;
|
||||
|
||||
/** @type {Array<[number, BaseItem]>} */
|
||||
this.sortedItems = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts from belt space (0 = start of belt ... 1 = end of belt) to the local
|
||||
* belt coordinates (-0.5|-0.5 to 0.5|0.5)
|
||||
* @param {number} progress
|
||||
* @returns {Vector}
|
||||
*/
|
||||
transformBeltToLocalSpace(progress) {
|
||||
switch (this.direction) {
|
||||
case enumDirection.top:
|
||||
return new Vector(0, 0.5 - progress);
|
||||
|
||||
case enumDirection.right: {
|
||||
const arcProgress = progress * 0.5 * Math_PI;
|
||||
return new Vector(0.5 - 0.5 * Math_cos(arcProgress), 0.5 - 0.5 * Math_sin(arcProgress));
|
||||
}
|
||||
case enumDirection.left: {
|
||||
const arcProgress = progress * 0.5 * Math_PI;
|
||||
return new Vector(-0.5 + 0.5 * Math_cos(arcProgress), 0.5 - 0.5 * Math_sin(arcProgress));
|
||||
}
|
||||
default:
|
||||
assertAlways(false, "Invalid belt direction: " + this.direction);
|
||||
return new Vector(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the belt can currently accept an item from the given direction
|
||||
* @param {enumDirection} direction
|
||||
*/
|
||||
canAcceptNewItem(direction) {
|
||||
const firstItem = this.sortedItems[0];
|
||||
if (!firstItem) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return firstItem[0] > globalConfig.itemSpacingOnBelts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes a new item to the belt
|
||||
* @param {BaseItem} item
|
||||
* @param {enumDirection} direction
|
||||
*/
|
||||
takeNewItem(item, direction) {
|
||||
this.sortedItems.unshift([0, item]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns how much space there is to the first item
|
||||
*/
|
||||
getDistanceToFirstItemCenter() {
|
||||
const firstItem = this.sortedItems[0];
|
||||
if (!firstItem) {
|
||||
return 1;
|
||||
}
|
||||
return firstItem[0];
|
||||
}
|
||||
}
|
||||
25
src/js/game/components/hub.js
Normal file
25
src/js/game/components/hub.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Component } from "../component";
|
||||
import { ShapeDefinition } from "../shape_definition";
|
||||
|
||||
export class HubComponent extends Component {
|
||||
static getId() {
|
||||
return "Hub";
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
/**
|
||||
* Shape definitions in queue to be analyzed and counted towards the goal
|
||||
* @type {Array<ShapeDefinition>}
|
||||
*/
|
||||
this.definitionsToAnalyze = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ShapeDefinition} definition
|
||||
*/
|
||||
queueShapeDefinition(definition) {
|
||||
this.definitionsToAnalyze.push(definition);
|
||||
}
|
||||
}
|
||||
129
src/js/game/components/item_acceptor.js
Normal file
129
src/js/game/components/item_acceptor.js
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Component } from "../component";
|
||||
import { Vector, enumDirection, enumDirectionToAngle, enumInvertedDirections } from "../../core/vector";
|
||||
import { BaseItem } from "../base_item";
|
||||
import { ShapeItem } from "../items/shape_item";
|
||||
import { ColorItem } from "../items/color_item";
|
||||
|
||||
/**
|
||||
* @enum {string?}
|
||||
*/
|
||||
export const enumItemAcceptorItemFilter = {
|
||||
shape: "shape",
|
||||
color: "color",
|
||||
none: null,
|
||||
};
|
||||
|
||||
/** @typedef {{
|
||||
* pos: Vector,
|
||||
* directions: enumDirection[],
|
||||
* filter?: enumItemAcceptorItemFilter
|
||||
* }} ItemAcceptorSlot */
|
||||
|
||||
export class ItemAcceptorComponent extends Component {
|
||||
static getId() {
|
||||
return "ItemAcceptor";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return {
|
||||
// slots: "TODO",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {Array<{pos: Vector, directions: enumDirection[], filter?: enumItemAcceptorItemFilter}>} param0.slots The slots from which we accept items
|
||||
*/
|
||||
constructor({ slots }) {
|
||||
super();
|
||||
|
||||
this.setSlots(slots);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Array<{pos: Vector, directions: enumDirection[], filter?: enumItemAcceptorItemFilter}>} slots
|
||||
*/
|
||||
setSlots(slots) {
|
||||
/** @type {Array<{pos: Vector, directions: enumDirection[], filter?: enumItemAcceptorItemFilter}>} */
|
||||
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 enumItemAcceptorItemFilter
|
||||
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];
|
||||
switch (slot.filter) {
|
||||
case enumItemAcceptorItemFilter.shape: {
|
||||
return item instanceof ShapeItem;
|
||||
}
|
||||
case enumItemAcceptorItemFilter.color: {
|
||||
return item instanceof ColorItem;
|
||||
}
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to find a slot which accepts the current item
|
||||
* @param {Vector} targetLocalTile
|
||||
* @param {enumDirection} fromLocalDirection
|
||||
* @returns {{
|
||||
* slot: ItemAcceptorSlot,
|
||||
* index: number,
|
||||
* acceptedDirection: enumDirection
|
||||
* }|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];
|
||||
|
||||
// const acceptorLocalPosition = targetStaticComp.applyRotationToVector(
|
||||
// slot.pos
|
||||
// );
|
||||
|
||||
// const acceptorGlobalPosition = acceptorLocalPosition.add(targetStaticComp.origin);
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// && this.canAcceptItem(slotIndex, ejectingItem)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
162
src/js/game/components/item_ejector.js
Normal file
162
src/js/game/components/item_ejector.js
Normal file
@@ -0,0 +1,162 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { Vector, enumDirection, enumDirectionToVector } from "../../core/vector";
|
||||
import { BaseItem } from "../base_item";
|
||||
import { Component } from "../component";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* pos: Vector,
|
||||
* direction: enumDirection,
|
||||
* item: BaseItem,
|
||||
* progress: number?
|
||||
* }} ItemEjectorSlot
|
||||
*/
|
||||
|
||||
export class ItemEjectorComponent extends Component {
|
||||
static getId() {
|
||||
return "ItemEjector";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return {
|
||||
// slots: "TODO"
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {Array<{pos: Vector, direction: enumDirection}>} param0.slots The slots to eject on
|
||||
* @param {boolean=} param0.instantEject If the ejection is instant
|
||||
*/
|
||||
constructor({ slots, instantEject = false }) {
|
||||
super();
|
||||
|
||||
// How long items take to eject
|
||||
this.instantEject = instantEject;
|
||||
|
||||
this.setSlots(slots);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{pos: Vector, direction: enumDirection}>} slots The slots to eject on
|
||||
*/
|
||||
setSlots(slots) {
|
||||
/** @type {Array<ItemEjectorSlot>} */
|
||||
this.slots = [];
|
||||
for (let i = 0; i < slots.length; ++i) {
|
||||
const slot = slots[i];
|
||||
this.slots.push({
|
||||
pos: slot.pos,
|
||||
direction: slot.direction,
|
||||
item: null,
|
||||
progress: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the amount of slots
|
||||
*/
|
||||
getNumSlots() {
|
||||
return this.slots.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns where this slot ejects to
|
||||
* @param {number} index
|
||||
* @returns {Vector}
|
||||
*/
|
||||
getSlotTargetLocalTile(index) {
|
||||
const slot = this.slots[index];
|
||||
const directionVector = enumDirectionToVector[slot.direction];
|
||||
return slot.pos.add(directionVector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether any slot ejects to the given local tile
|
||||
* @param {Vector} tile
|
||||
*/
|
||||
anySlotEjectsToLocalTile(tile) {
|
||||
for (let i = 0; i < this.slots.length; ++i) {
|
||||
if (this.getSlotTargetLocalTile(i).equals(tile)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if slot # is currently ejecting
|
||||
* @param {number} slotIndex
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isSlotEjecting(slotIndex) {
|
||||
assert(slotIndex >= 0 && slotIndex < this.slots.length, "Invalid ejector slot: " + slotIndex);
|
||||
return !!this.slots[slotIndex].item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if we can eject on a given slot
|
||||
* @param {number} slotIndex
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canEjectOnSlot(slotIndex) {
|
||||
assert(slotIndex >= 0 && slotIndex < this.slots.length, "Invalid ejector slot: " + slotIndex);
|
||||
return !this.slots[slotIndex].item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first free slot on this ejector or null if there is none
|
||||
* @returns {number?}
|
||||
*/
|
||||
getFirstFreeSlot() {
|
||||
for (let i = 0; i < this.slots.length; ++i) {
|
||||
if (this.canEjectOnSlot(i)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if any slot is ejecting
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isAnySlotEjecting() {
|
||||
for (let i = 0; i < this.slots.length; ++i) {
|
||||
if (this.slots[i].item) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if any slot is free
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasAnySlotFree() {
|
||||
for (let i = 0; i < this.slots.length; ++i) {
|
||||
if (this.canEjectOnSlot(i)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to eject a given item
|
||||
* @param {number} slotIndex
|
||||
* @param {BaseItem} item
|
||||
* @returns {boolean}
|
||||
*/
|
||||
tryEject(slotIndex, item) {
|
||||
if (!this.canEjectOnSlot(slotIndex)) {
|
||||
return false;
|
||||
}
|
||||
this.slots[slotIndex].item = item;
|
||||
this.slots[slotIndex].progress = this.instantEject ? 1 : 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
106
src/js/game/components/item_processor.js
Normal file
106
src/js/game/components/item_processor.js
Normal file
@@ -0,0 +1,106 @@
|
||||
import { BaseItem } from "../base_item";
|
||||
import { Component } from "../component";
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
|
||||
/** @enum {string} */
|
||||
export const enumItemProcessorTypes = {
|
||||
splitter: "splitter",
|
||||
cutter: "cutter",
|
||||
rotater: "rotater",
|
||||
stacker: "stacker",
|
||||
trash: "trash",
|
||||
mixer: "mixer",
|
||||
painter: "painter",
|
||||
hub: "hub",
|
||||
};
|
||||
|
||||
export class ItemProcessorComponent extends Component {
|
||||
static getId() {
|
||||
return "ItemProcessor";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return {
|
||||
// TODO
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {enumItemProcessorTypes} param0.processorType Which type of processor this is
|
||||
* @param {number} param0.inputsPerCharge How many items this machine needs until it can start working
|
||||
* @param {Array<{pos: Vector, direction: enumDirection}>=} param0.beltUnderlays Where to render belt underlays
|
||||
*
|
||||
*/
|
||||
constructor({ processorType = enumItemProcessorTypes.splitter, inputsPerCharge, beltUnderlays = [] }) {
|
||||
super();
|
||||
|
||||
// Which slot to emit next, this is only a preference and if it can't emit
|
||||
// it will take the other one. Some machines ignore this (e.g. the splitter) to make
|
||||
// sure the outputs always match
|
||||
this.nextOutputSlot = 0;
|
||||
|
||||
// Type of the processor
|
||||
this.type = processorType;
|
||||
|
||||
// How many inputs we need for one charge
|
||||
this.inputsPerCharge = inputsPerCharge;
|
||||
|
||||
// Which belt underlays to render
|
||||
this.beltUnderlays = beltUnderlays;
|
||||
|
||||
/**
|
||||
* Our current inputs
|
||||
* @type {Array<{ item: BaseItem, sourceSlot: number }>}
|
||||
*/
|
||||
this.inputSlots = [];
|
||||
|
||||
/**
|
||||
* 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}>}
|
||||
*/
|
||||
this.itemsToEject = [];
|
||||
|
||||
/**
|
||||
* How long it takes until we are done with the current items
|
||||
*/
|
||||
this.secondsUntilEject = 0;
|
||||
|
||||
/**
|
||||
* Fixes belt animations
|
||||
* @type {Array<{ item: BaseItem, slotIndex: number, animProgress: number, direction: enumDirection}>}
|
||||
*/
|
||||
this.itemConsumptionAnimations = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to take the item
|
||||
* @param {BaseItem} item
|
||||
*/
|
||||
tryTakeItem(item, sourceSlot, sourceDirection) {
|
||||
if (this.inputSlots.length >= this.inputsPerCharge) {
|
||||
// Already full
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that we only take one item per slot
|
||||
for (let i = 0; i < this.inputSlots.length; ++i) {
|
||||
const slot = this.inputSlots[i];
|
||||
if (slot.sourceSlot === sourceSlot) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this.inputSlots.push({ item, sourceSlot });
|
||||
this.itemConsumptionAnimations.push({
|
||||
item,
|
||||
slotIndex: sourceSlot,
|
||||
direction: sourceDirection,
|
||||
animProgress: 0.0,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
23
src/js/game/components/miner.js
Normal file
23
src/js/game/components/miner.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { types } from "../../savegame/serialization";
|
||||
import { Component } from "../component";
|
||||
|
||||
export class MinerComponent extends Component {
|
||||
static getId() {
|
||||
return "Miner";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return {
|
||||
lastMiningTime: types.ufloat,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} param0
|
||||
*/
|
||||
constructor({}) {
|
||||
super();
|
||||
this.lastMiningTime = 0;
|
||||
}
|
||||
}
|
||||
11
src/js/game/components/replaceable_map_entity.js
Normal file
11
src/js/game/components/replaceable_map_entity.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Component } from "../component";
|
||||
|
||||
/**
|
||||
* Marks an entity as replaceable, so that when other buildings are placed above him it
|
||||
* simply gets deleted
|
||||
*/
|
||||
export class ReplaceableMapEntityComponent extends Component {
|
||||
static getId() {
|
||||
return "ReplaceableMapEntity";
|
||||
}
|
||||
}
|
||||
184
src/js/game/components/static_map_entity.js
Normal file
184
src/js/game/components/static_map_entity.js
Normal file
@@ -0,0 +1,184 @@
|
||||
import { Math_radians } from "../../core/builtins";
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { DrawParameters } from "../../core/draw_parameters";
|
||||
import { Rectangle } from "../../core/rectangle";
|
||||
import { AtlasSprite } from "../../core/sprites";
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { types } from "../../savegame/serialization";
|
||||
import { Component } from "../component";
|
||||
|
||||
export class StaticMapEntityComponent extends Component {
|
||||
static getId() {
|
||||
return "StaticMapEntity";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return {
|
||||
origin: types.tileVector,
|
||||
tileSize: types.tileVector,
|
||||
rotationDegrees: types.uint,
|
||||
spriteKey: types.string,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {Vector=} param0.origin Origin (Top Left corner) of the entity
|
||||
* @param {Vector=} param0.tileSize Size of the entity in tiles
|
||||
* @param {number=} param0.rotationDegrees Rotation in degrees. Must be multiple of 90
|
||||
* @param {string=} param0.spriteKey Optional sprite
|
||||
* @param {string=} param0.silhouetteColor Optional silhouette color override
|
||||
*/
|
||||
constructor({
|
||||
origin = new Vector(),
|
||||
tileSize = new Vector(1, 1),
|
||||
rotationDegrees = 0,
|
||||
spriteKey = null,
|
||||
silhouetteColor = null,
|
||||
}) {
|
||||
super();
|
||||
assert(
|
||||
rotationDegrees % 90 === 0,
|
||||
"Rotation of static map entity must be multiple of 90 (was " + rotationDegrees + ")"
|
||||
);
|
||||
|
||||
this.origin = origin;
|
||||
this.tileSize = tileSize;
|
||||
this.spriteKey = spriteKey;
|
||||
this.rotationDegrees = rotationDegrees;
|
||||
this.silhouetteColor = silhouetteColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the effective rectangle of this entity in tile space
|
||||
* @returns {Rectangle}
|
||||
*/
|
||||
getTileSpaceBounds() {
|
||||
switch (this.rotationDegrees) {
|
||||
case 0:
|
||||
return new Rectangle(this.origin.x, this.origin.y, this.tileSize.x, this.tileSize.y);
|
||||
case 90:
|
||||
return new Rectangle(
|
||||
this.origin.x - this.tileSize.y + 1,
|
||||
this.origin.y,
|
||||
this.tileSize.y,
|
||||
this.tileSize.x
|
||||
);
|
||||
case 180:
|
||||
return new Rectangle(
|
||||
this.origin.x - this.tileSize.x + 1,
|
||||
this.origin.y - this.tileSize.y + 1,
|
||||
this.tileSize.x,
|
||||
this.tileSize.y
|
||||
);
|
||||
case 270:
|
||||
return new Rectangle(
|
||||
this.origin.x,
|
||||
this.origin.y - this.tileSize.x + 1,
|
||||
this.tileSize.y,
|
||||
this.tileSize.x
|
||||
);
|
||||
default:
|
||||
assert(false, "Invalid rotation");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the given vector/rotation from local space to world space
|
||||
* @param {Vector} vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
applyRotationToVector(vector) {
|
||||
return vector.rotateFastMultipleOf90(this.rotationDegrees);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the given vector/rotation from world space to local space
|
||||
* @param {Vector} vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
unapplyRotationToVector(vector) {
|
||||
return vector.rotateFastMultipleOf90(360 - this.rotationDegrees);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the given direction from local space
|
||||
* @param {enumDirection} direction
|
||||
* @returns {enumDirection}
|
||||
*/
|
||||
localDirectionToWorld(direction) {
|
||||
return Vector.transformDirectionFromMultipleOf90(direction, this.rotationDegrees);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the given direction from world to local space
|
||||
* @param {enumDirection} direction
|
||||
* @returns {enumDirection}
|
||||
*/
|
||||
worldDirectionToLocal(direction) {
|
||||
return Vector.transformDirectionFromMultipleOf90(direction, 360 - this.rotationDegrees);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms from local tile space to global tile space
|
||||
* @param {Vector} localTile
|
||||
* @returns {Vector}
|
||||
*/
|
||||
localTileToWorld(localTile) {
|
||||
const result = this.applyRotationToVector(localTile);
|
||||
result.addInplace(this.origin);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms from world space to local space
|
||||
* @param {Vector} worldTile
|
||||
*/
|
||||
worldToLocalTile(worldTile) {
|
||||
const localUnrotated = worldTile.sub(this.origin);
|
||||
return this.unapplyRotationToVector(localUnrotated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a sprite over the whole space of the entity
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {AtlasSprite} sprite
|
||||
* @param {number=} extrudePixels How many pixels to extrude the sprite
|
||||
* @param {boolean=} clipping Whether to clip
|
||||
*/
|
||||
drawSpriteOnFullEntityBounds(parameters, sprite, extrudePixels = 0, clipping = true) {
|
||||
const worldX = this.origin.x * globalConfig.tileSize;
|
||||
const worldY = this.origin.y * globalConfig.tileSize;
|
||||
|
||||
if (this.rotationDegrees === 0) {
|
||||
// Early out, is faster
|
||||
sprite.drawCached(
|
||||
parameters,
|
||||
worldX - extrudePixels * this.tileSize.x,
|
||||
worldY - extrudePixels * this.tileSize.y,
|
||||
globalConfig.tileSize * this.tileSize.x + 2 * extrudePixels * this.tileSize.x,
|
||||
globalConfig.tileSize * this.tileSize.y + 2 * extrudePixels * this.tileSize.y,
|
||||
clipping
|
||||
);
|
||||
} else {
|
||||
const rotationCenterX = worldX + globalConfig.halfTileSize;
|
||||
const rotationCenterY = worldY + globalConfig.halfTileSize;
|
||||
|
||||
parameters.context.translate(rotationCenterX, rotationCenterY);
|
||||
parameters.context.rotate(Math_radians(this.rotationDegrees));
|
||||
|
||||
sprite.drawCached(
|
||||
parameters,
|
||||
-globalConfig.halfTileSize - extrudePixels * this.tileSize.x,
|
||||
-globalConfig.halfTileSize - extrudePixels * this.tileSize.y,
|
||||
globalConfig.tileSize * this.tileSize.x + 2 * extrudePixels * this.tileSize.x,
|
||||
globalConfig.tileSize * this.tileSize.y + 2 * extrudePixels * this.tileSize.y,
|
||||
false
|
||||
);
|
||||
|
||||
parameters.context.rotate(-Math_radians(this.rotationDegrees));
|
||||
parameters.context.translate(-rotationCenterX, -rotationCenterY);
|
||||
}
|
||||
}
|
||||
}
|
||||
88
src/js/game/components/underground_belt.js
Normal file
88
src/js/game/components/underground_belt.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import { BaseItem } from "../base_item";
|
||||
import { Component } from "../component";
|
||||
import { globalConfig } from "../../core/config";
|
||||
|
||||
/** @enum {string} */
|
||||
export const enumUndergroundBeltMode = {
|
||||
sender: "sender",
|
||||
receiver: "receiver",
|
||||
};
|
||||
|
||||
export class UndergroundBeltComponent extends Component {
|
||||
static getId() {
|
||||
return "UndergroundBelt";
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {enumUndergroundBeltMode=} param0.mode As which type of belt the entity acts
|
||||
*/
|
||||
constructor({ mode = enumUndergroundBeltMode.sender }) {
|
||||
super();
|
||||
|
||||
this.mode = mode;
|
||||
|
||||
/**
|
||||
* Used on both receiver and sender.
|
||||
* Reciever: Used to store the next item to transfer, and to block input while doing this
|
||||
* Sender: Used to store which items are currently "travelling"
|
||||
* @type {Array<[BaseItem, number]>} Format is [Item, remaining seconds until transfer/ejection]
|
||||
*/
|
||||
this.pendingItems = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to accept an item from an external source like a regular belt or building
|
||||
* @param {BaseItem} item
|
||||
* @param {number} beltSpeed How fast this item travels
|
||||
*/
|
||||
tryAcceptExternalItem(item, beltSpeed) {
|
||||
if (this.mode !== enumUndergroundBeltMode.sender) {
|
||||
// Only senders accept external items
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.pendingItems.length > 0) {
|
||||
// We currently have a pending item
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("Takes", 1 / beltSpeed);
|
||||
this.pendingItems.push([item, 1 / beltSpeed]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to accept a tunneled item
|
||||
* @param {BaseItem} item
|
||||
* @param {number} travelDistance How many tiles this item has to travel
|
||||
* @param {number} beltSpeed How fast this item travels
|
||||
*/
|
||||
tryAcceptTunneledItem(item, travelDistance, beltSpeed) {
|
||||
if (this.mode !== enumUndergroundBeltMode.receiver) {
|
||||
// Only receivers can accept tunneled items
|
||||
return false;
|
||||
}
|
||||
|
||||
// Notice: We assume that for all items the travel distance is the same
|
||||
const maxItemsInTunnel = (1 + travelDistance) / globalConfig.itemSpacingOnBelts;
|
||||
if (this.pendingItems.length >= maxItemsInTunnel) {
|
||||
// Simulate a real belt which gets full at some point
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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;
|
||||
console.log(travelDistance, "->", travelDuration);
|
||||
|
||||
this.pendingItems.push([item, travelDuration]);
|
||||
|
||||
// Sort so we can only look at the first ones
|
||||
this.pendingItems.sort((a, b) => a[1] - b[1]);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
7
src/js/game/components/unremovable.js
Normal file
7
src/js/game/components/unremovable.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Component } from "../component";
|
||||
|
||||
export class UnremovableComponent extends Component {
|
||||
static getId() {
|
||||
return "Unremovable";
|
||||
}
|
||||
}
|
||||
434
src/js/game/core.js
Normal file
434
src/js/game/core.js
Normal file
@@ -0,0 +1,434 @@
|
||||
/* typehints:start */
|
||||
import { InGameState } from "../states/ingame";
|
||||
import { Application } from "../application";
|
||||
/* typehints:end */
|
||||
|
||||
import { BufferMaintainer } from "../core/buffer_maintainer";
|
||||
import { disableImageSmoothing, enableImageSmoothing, registerCanvas } from "../core/buffer_utils";
|
||||
import { Math_random } from "../core/builtins";
|
||||
import { globalConfig } from "../core/config";
|
||||
import { getDeviceDPI, resizeHighDPICanvas } from "../core/dpi_manager";
|
||||
import { DrawParameters } from "../core/draw_parameters";
|
||||
import { gMetaBuildingRegistry } from "../core/global_registries";
|
||||
import { createLogger } from "../core/logging";
|
||||
import { PerlinNoise } from "../core/perlin_noise";
|
||||
import { Vector } from "../core/vector";
|
||||
import { Savegame } from "../savegame/savegame";
|
||||
import { SavegameSerializer } from "../savegame/savegame_serializer";
|
||||
import { AutomaticSave } from "./automatic_save";
|
||||
import { MetaHubBuilding } from "./buildings/hub";
|
||||
import { Camera } from "./camera";
|
||||
import { CanvasClickInterceptor } from "./canvas_click_interceptor";
|
||||
import { EntityManager } from "./entity_manager";
|
||||
import { GameSystemManager } from "./game_system_manager";
|
||||
import { HubGoals } from "./hub_goals";
|
||||
import { GameHUD } from "./hud/hud";
|
||||
import { KeyActionMapper } from "./key_action_mapper";
|
||||
import { GameLogic } from "./logic";
|
||||
import { MapView } from "./map_view";
|
||||
import { GameRoot } from "./root";
|
||||
import { ShapeDefinitionManager } from "./shape_definition_manager";
|
||||
import { SoundProxy } from "./sound_proxy";
|
||||
import { GameTime } from "./time/game_time";
|
||||
|
||||
const logger = createLogger("ingame/core");
|
||||
|
||||
// Store the canvas so we can reuse it later
|
||||
/** @type {HTMLCanvasElement} */
|
||||
let lastCanvas = null;
|
||||
/** @type {CanvasRenderingContext2D} */
|
||||
let lastContext = null;
|
||||
|
||||
/**
|
||||
* The core manages the root and represents the whole game. It wraps the root, since
|
||||
* the root class is just a data holder.
|
||||
*/
|
||||
export class GameCore {
|
||||
/** @param {Application} app */
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
|
||||
/** @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
|
||||
* behaviour.
|
||||
*/
|
||||
this.duringLogicUpdate = false;
|
||||
|
||||
// Cached
|
||||
this.boundInternalTick = this.updateLogic.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the root object which stores all game related data. The state
|
||||
* is required as a back reference (used sometimes)
|
||||
* @param {InGameState} parentState
|
||||
* @param {Savegame} savegame
|
||||
*/
|
||||
initializeRoot(parentState, savegame) {
|
||||
// Construct the root element, this is the data representation of the game
|
||||
this.root = new GameRoot(this.app);
|
||||
this.root.gameState = parentState;
|
||||
this.root.savegame = savegame;
|
||||
this.root.gameWidth = this.app.screenWidth;
|
||||
this.root.gameHeight = this.app.screenHeight;
|
||||
|
||||
// Initialize canvas element & context
|
||||
this.internalInitCanvas();
|
||||
|
||||
// Members
|
||||
const root = this.root;
|
||||
|
||||
// This isn't nice, but we need it right here
|
||||
root.gameState.keyActionMapper = new KeyActionMapper(root, this.root.gameState.inputReciever);
|
||||
|
||||
// Init classes
|
||||
root.camera = new Camera(root);
|
||||
root.map = new MapView(root);
|
||||
root.logic = new GameLogic(root);
|
||||
root.hud = new GameHUD(root);
|
||||
root.time = new GameTime(root);
|
||||
root.canvasClickInterceptor = new CanvasClickInterceptor(root);
|
||||
root.automaticSave = new AutomaticSave(root);
|
||||
root.soundProxy = new SoundProxy(root);
|
||||
|
||||
// Init managers
|
||||
root.entityMgr = new EntityManager(root);
|
||||
root.systemMgr = new GameSystemManager(root);
|
||||
root.shapeDefinitionMgr = new ShapeDefinitionManager(root);
|
||||
root.mapNoiseGenerator = new PerlinNoise(Math.random()); // TODO: Save seed
|
||||
root.hubGoals = new HubGoals(root);
|
||||
root.buffers = new BufferMaintainer(root);
|
||||
|
||||
// root.particleMgr = new ParticleManager(root);
|
||||
// root.uiParticleMgr = new ParticleManager(root);
|
||||
|
||||
// Initialize the hud once everything is loaded
|
||||
this.root.hud.initialize();
|
||||
|
||||
// Initial resize event, it might be possible that the screen
|
||||
// resized later during init tho, which is why will emit it later
|
||||
// again anyways
|
||||
this.resize(this.app.screenWidth, this.app.screenHeight);
|
||||
|
||||
if (G_IS_DEV) {
|
||||
// @ts-ignore
|
||||
window.globalRoot = root;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a new game, this means creating a new map and centering on the
|
||||
* plaerbase
|
||||
* */
|
||||
initNewGame() {
|
||||
logger.log("Initializing new game");
|
||||
this.root.gameIsFresh = true;
|
||||
|
||||
gMetaBuildingRegistry
|
||||
.findByClass(MetaHubBuilding)
|
||||
.createAndPlaceEntity(this.root, new Vector(-2, -2), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inits an existing game by loading the raw savegame data and deserializing it.
|
||||
* Also runs basic validity checks.
|
||||
*/
|
||||
initExistingGame() {
|
||||
logger.log("Initializing existing game");
|
||||
const serializer = new SavegameSerializer();
|
||||
|
||||
try {
|
||||
const status = serializer.deserialize(this.root.savegame.getCurrentDump(), this.root);
|
||||
if (!status.isGood()) {
|
||||
logger.error("savegame-deserialize-failed:" + status.reason);
|
||||
return false;
|
||||
}
|
||||
} catch (ex) {
|
||||
logger.error("Exception during deserialization:", ex);
|
||||
return false;
|
||||
}
|
||||
this.root.gameIsFresh = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the render canvas
|
||||
*/
|
||||
internalInitCanvas() {
|
||||
let canvas, context;
|
||||
if (!lastCanvas) {
|
||||
logger.log("Creating new canvas");
|
||||
canvas = document.createElement("canvas");
|
||||
canvas.id = "ingame_Canvas";
|
||||
canvas.setAttribute("opaque", "true");
|
||||
canvas.setAttribute("webkitOpaque", "true");
|
||||
canvas.setAttribute("mozOpaque", "true");
|
||||
this.root.gameState.getDivElement().appendChild(canvas);
|
||||
context = canvas.getContext("2d", { alpha: false });
|
||||
|
||||
lastCanvas = canvas;
|
||||
lastContext = context;
|
||||
} else {
|
||||
logger.log("Reusing canvas");
|
||||
if (lastCanvas.parentElement) {
|
||||
lastCanvas.parentElement.removeChild(lastCanvas);
|
||||
}
|
||||
this.root.gameState.getDivElement().appendChild(lastCanvas);
|
||||
|
||||
canvas = lastCanvas;
|
||||
context = lastContext;
|
||||
|
||||
lastContext.clearRect(0, 0, lastCanvas.width, lastCanvas.height);
|
||||
}
|
||||
|
||||
// globalConfig.smoothing.smoothMainCanvas = getDeviceDPI() < 1.5;
|
||||
// globalConfig.smoothing.smoothMainCanvas = true;
|
||||
|
||||
canvas.classList.toggle("smoothed", globalConfig.smoothing.smoothMainCanvas);
|
||||
|
||||
// Oof, use :not() instead
|
||||
canvas.classList.toggle("unsmoothed", !globalConfig.smoothing.smoothMainCanvas);
|
||||
|
||||
if (globalConfig.smoothing.smoothMainCanvas) {
|
||||
enableImageSmoothing(context);
|
||||
} else {
|
||||
disableImageSmoothing(context);
|
||||
}
|
||||
|
||||
this.root.canvas = canvas;
|
||||
this.root.context = context;
|
||||
|
||||
registerCanvas(canvas, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destructs the root, freeing all resources
|
||||
*/
|
||||
destruct() {
|
||||
if (lastCanvas && lastCanvas.parentElement) {
|
||||
lastCanvas.parentElement.removeChild(lastCanvas);
|
||||
}
|
||||
|
||||
this.root.destruct();
|
||||
delete this.root;
|
||||
this.root = null;
|
||||
this.app = null;
|
||||
}
|
||||
|
||||
tick(deltaMs) {
|
||||
const root = this.root;
|
||||
|
||||
if (root.hud.parts.processingOverlay.hasTasks() || root.hud.parts.processingOverlay.isRunning()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Extract current real time
|
||||
root.time.updateRealtimeNow();
|
||||
|
||||
// Camera is always updated, no matter what
|
||||
root.camera.update(deltaMs);
|
||||
|
||||
// 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 automatic save after everything finished
|
||||
root.automaticSave.update();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
shouldRender() {
|
||||
if (this.root.queue.requireRedraw) {
|
||||
return true;
|
||||
}
|
||||
if (this.root.hud.shouldPauseRendering()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Do not render
|
||||
if (!this.app.isRenderable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
updateLogic() {
|
||||
const root = this.root;
|
||||
this.duringLogicUpdate = true;
|
||||
|
||||
// Update entities, this removes destroyed entities
|
||||
root.entityMgr.update();
|
||||
|
||||
// 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");
|
||||
return false;
|
||||
}
|
||||
|
||||
root.systemMgr.update();
|
||||
// root.particleMgr.update();
|
||||
|
||||
this.duringLogicUpdate = false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
resize(w, h) {
|
||||
this.root.gameWidth = w;
|
||||
this.root.gameHeight = h;
|
||||
resizeHighDPICanvas(this.root.canvas, w, h, globalConfig.smoothing.smoothMainCanvas);
|
||||
this.root.signals.resized.dispatch(w, h);
|
||||
this.root.queue.requireRedraw = true;
|
||||
}
|
||||
|
||||
postLoadHook() {
|
||||
logger.log("Dispatching post load hook");
|
||||
this.root.signals.postLoadHook.dispatch();
|
||||
|
||||
if (!this.root.gameIsFresh) {
|
||||
// Also dispatch game restored hook on restored savegames
|
||||
this.root.signals.gameRestored.dispatch();
|
||||
}
|
||||
|
||||
this.root.gameInitialized = true;
|
||||
}
|
||||
|
||||
draw() {
|
||||
const root = this.root;
|
||||
const systems = root.systemMgr.systems;
|
||||
|
||||
const taskRunner = root.hud.parts.processingOverlay;
|
||||
if (taskRunner.hasTasks()) {
|
||||
if (!taskRunner.isRunning()) {
|
||||
taskRunner.process();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.shouldRender()) {
|
||||
// Always update hud tho
|
||||
root.hud.update();
|
||||
return;
|
||||
}
|
||||
|
||||
// Update buffers as the very first
|
||||
root.buffers.update();
|
||||
|
||||
root.queue.requireRedraw = false;
|
||||
|
||||
// Gather context and save all state
|
||||
const context = root.context;
|
||||
context.save();
|
||||
|
||||
// Compute optimal zoom level and atlas scale
|
||||
const zoomLevel = root.camera.zoomLevel;
|
||||
const effectiveZoomLevel =
|
||||
(zoomLevel / globalConfig.assetsDpi) * getDeviceDPI() * globalConfig.assetsSharpness;
|
||||
|
||||
let desiredAtlasScale = "0.1";
|
||||
if (effectiveZoomLevel > 0.75) {
|
||||
desiredAtlasScale = "1";
|
||||
} else if (effectiveZoomLevel > 0.5) {
|
||||
desiredAtlasScale = "0.75";
|
||||
} else if (effectiveZoomLevel > 0.25) {
|
||||
desiredAtlasScale = "0.5";
|
||||
} else if (effectiveZoomLevel > 0.1) {
|
||||
desiredAtlasScale = "0.25";
|
||||
}
|
||||
|
||||
// Construct parameters required for drawing
|
||||
const params = new DrawParameters({
|
||||
context: context,
|
||||
visibleRect: root.camera.getVisibleRect(),
|
||||
desiredAtlasScale,
|
||||
zoomLevel,
|
||||
root: root,
|
||||
});
|
||||
|
||||
if (G_IS_DEV && (globalConfig.debug.testCulling || globalConfig.debug.hideFog)) {
|
||||
context.clearRect(0, 0, root.gameWidth, root.gameHeight);
|
||||
}
|
||||
|
||||
// Transform to world space
|
||||
root.camera.transform(context);
|
||||
|
||||
assert(context.globalAlpha === 1.0, "Global alpha not 1 on frame start");
|
||||
|
||||
// Update hud
|
||||
root.hud.update();
|
||||
|
||||
// Main rendering order
|
||||
// -----
|
||||
|
||||
root.map.drawBackground(params);
|
||||
// systems.mapResources.draw(params);
|
||||
|
||||
if (!this.root.camera.getIsMapOverlayActive()) {
|
||||
systems.itemProcessor.drawUnderlays(params);
|
||||
systems.belt.draw(params);
|
||||
systems.itemEjector.draw(params);
|
||||
systems.itemProcessor.draw(params);
|
||||
}
|
||||
|
||||
root.map.drawForeground(params);
|
||||
if (!this.root.camera.getIsMapOverlayActive()) {
|
||||
systems.hub.draw(params);
|
||||
}
|
||||
|
||||
if (G_IS_DEV) {
|
||||
root.map.drawStaticEntities(params);
|
||||
}
|
||||
|
||||
// END OF GAME CONTENT
|
||||
// -----
|
||||
|
||||
// Finally, draw the hud. Nothing should come after that
|
||||
root.hud.draw(params);
|
||||
|
||||
assert(context.globalAlpha === 1.0, "Global alpha not 1 on frame end before restore");
|
||||
|
||||
// Restore to screen space
|
||||
context.restore();
|
||||
|
||||
// Draw overlays, those are screen space
|
||||
root.hud.drawOverlays(params);
|
||||
|
||||
assert(context.globalAlpha === 1.0, "context.globalAlpha not 1 on frame end");
|
||||
|
||||
if (G_IS_DEV && globalConfig.debug.simulateSlowRendering) {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 1e8; ++i) {
|
||||
sum += i;
|
||||
}
|
||||
if (Math_random() > 0.95) {
|
||||
console.log(sum);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
222
src/js/game/entity.js
Normal file
222
src/js/game/entity.js
Normal file
@@ -0,0 +1,222 @@
|
||||
/* typehints:start */
|
||||
import { GameRoot } from "./root";
|
||||
import { DrawParameters } from "../core/draw_parameters";
|
||||
import { Component } from "./component";
|
||||
/* typehints:end */
|
||||
|
||||
import { globalConfig } from "../core/config";
|
||||
import { Vector, enumDirectionToVector, enumDirectionToAngle } from "../core/vector";
|
||||
import { BasicSerializableObject, types } from "../savegame/serialization";
|
||||
import { EntityComponentStorage } from "./entity_components";
|
||||
import { Loader } from "../core/loader";
|
||||
import { drawRotatedSprite } from "../core/draw_utils";
|
||||
import { Math_radians } from "../core/builtins";
|
||||
// import { gFactionRegistry, gComponentRegistry } from "../core/global_registries";
|
||||
// import { EntityComponentStorage } from "./entity_components";
|
||||
|
||||
export class Entity extends BasicSerializableObject {
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
super();
|
||||
|
||||
/**
|
||||
* Handle to the global game root
|
||||
*/
|
||||
this.root = root;
|
||||
|
||||
/**
|
||||
* The metaclass of the entity, should be set by subclasses
|
||||
*/
|
||||
this.meta = null;
|
||||
|
||||
/**
|
||||
* The components of the entity
|
||||
*/
|
||||
this.components = new EntityComponentStorage();
|
||||
|
||||
/**
|
||||
* Whether this entity was registered on the @see EntityManager so far
|
||||
*/
|
||||
this.registered = false;
|
||||
|
||||
/**
|
||||
* Internal entity unique id, set by the @see EntityManager
|
||||
*/
|
||||
this.uid = 0;
|
||||
|
||||
/* typehints:start */
|
||||
|
||||
/**
|
||||
* Stores if this entity is destroyed, set by the @see EntityManager
|
||||
* @type {boolean} */
|
||||
this.destroyed;
|
||||
|
||||
/**
|
||||
* Stores if this entity is queued to get destroyed in the next tick
|
||||
* of the @see EntityManager
|
||||
* @type {boolean} */
|
||||
this.queuedForDestroy;
|
||||
|
||||
/**
|
||||
* Stores the reason why this entity was destroyed
|
||||
* @type {string} */
|
||||
this.destroyReason;
|
||||
|
||||
/* typehints:end */
|
||||
}
|
||||
|
||||
static getId() {
|
||||
return "Entity";
|
||||
}
|
||||
|
||||
/**
|
||||
* @see BasicSerializableObject.getSchema
|
||||
* @returns {import("../savegame/serialization").Schema}
|
||||
*/
|
||||
static getSchema() {
|
||||
return {
|
||||
uid: types.uint,
|
||||
// components: types.keyValueMap(types.objData(gComponentRegistry), false)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the entity is still alive
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isAlive() {
|
||||
return !this.destroyed && !this.queuedForDestroy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the meta class of the entity.
|
||||
* @returns {object}
|
||||
*/
|
||||
getMetaclass() {
|
||||
assert(this.meta, "Entity has no metaclass");
|
||||
return this.meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal destroy callback
|
||||
*/
|
||||
internalDestroyCallback() {
|
||||
assert(!this.destroyed, "Can not destroy entity twice");
|
||||
this.destroyed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new component, only possible until the entity is registered on the entity manager,
|
||||
* after that use @see EntityManager.addDynamicComponent
|
||||
* @param {Component} componentInstance
|
||||
* @param {boolean} force Used by the entity manager. Internal parameter, do not change
|
||||
*/
|
||||
addComponent(componentInstance, force = false) {
|
||||
assert(force || !this.registered, "Entity already registered, use EntityManager.addDynamicComponent");
|
||||
const id = /** @type {typeof Component} */ (componentInstance.constructor).getId();
|
||||
assert(!this.components[id], "Component already present");
|
||||
this.components[id] = componentInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a given component, only possible until the entity is registered on the entity manager,
|
||||
* after that use @see EntityManager.removeDynamicComponent
|
||||
* @param {typeof Component} componentClass
|
||||
*/
|
||||
removeComponent(componentClass) {
|
||||
assert(!this.registered, "Entity already registered, use EntityManager.removeDynamicComponent");
|
||||
const id = componentClass.getId();
|
||||
assert(this.components[id], "Component does not exist on entity");
|
||||
delete this.components[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the entity, to override use @see Entity.drawImpl
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
draw(parameters) {
|
||||
const context = parameters.context;
|
||||
const staticComp = this.components.StaticMapEntity;
|
||||
|
||||
if (G_IS_DEV && staticComp && globalConfig.debug.showEntityBounds) {
|
||||
if (staticComp) {
|
||||
const transformed = staticComp.getTileSpaceBounds();
|
||||
context.strokeStyle = "rgba(255, 0, 0, 0.5)";
|
||||
context.lineWidth = 2;
|
||||
// const boundsSize = 20;
|
||||
context.beginPath();
|
||||
context.rect(
|
||||
transformed.x * globalConfig.tileSize,
|
||||
transformed.y * globalConfig.tileSize,
|
||||
transformed.w * globalConfig.tileSize,
|
||||
transformed.h * globalConfig.tileSize
|
||||
);
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
if (G_IS_DEV && staticComp && globalConfig.debug.showAcceptorEjectors) {
|
||||
const ejectorComp = this.components.ItemEjector;
|
||||
|
||||
if (ejectorComp) {
|
||||
const ejectorSprite = Loader.getSprite("sprites/debug/ejector_slot.png");
|
||||
for (let i = 0; i < ejectorComp.slots.length; ++i) {
|
||||
const slot = ejectorComp.slots[i];
|
||||
const slotTile = staticComp.localTileToWorld(slot.pos);
|
||||
const direction = staticComp.localDirectionToWorld(slot.direction);
|
||||
const directionVector = enumDirectionToVector[direction];
|
||||
const angle = Math_radians(enumDirectionToAngle[direction]);
|
||||
|
||||
context.globalAlpha = slot.item ? 1 : 0.2;
|
||||
drawRotatedSprite({
|
||||
parameters,
|
||||
sprite: ejectorSprite,
|
||||
x: (slotTile.x + 0.5 + directionVector.x * 0.37) * globalConfig.tileSize,
|
||||
y: (slotTile.y + 0.5 + directionVector.y * 0.37) * globalConfig.tileSize,
|
||||
angle,
|
||||
size: globalConfig.tileSize * 0.25,
|
||||
});
|
||||
}
|
||||
}
|
||||
const acceptorComp = this.components.ItemAcceptor;
|
||||
|
||||
if (acceptorComp) {
|
||||
const acceptorSprite = Loader.getSprite("sprites/debug/acceptor_slot.png");
|
||||
for (let i = 0; i < acceptorComp.slots.length; ++i) {
|
||||
const slot = acceptorComp.slots[i];
|
||||
const slotTile = staticComp.localTileToWorld(slot.pos);
|
||||
for (let k = 0; k < slot.directions.length; ++k) {
|
||||
const direction = staticComp.localDirectionToWorld(slot.directions[k]);
|
||||
const directionVector = enumDirectionToVector[direction];
|
||||
const angle = Math_radians(enumDirectionToAngle[direction] + 180);
|
||||
context.globalAlpha = 0.4;
|
||||
drawRotatedSprite({
|
||||
parameters,
|
||||
sprite: acceptorSprite,
|
||||
x: (slotTile.x + 0.5 + directionVector.x * 0.37) * globalConfig.tileSize,
|
||||
y: (slotTile.y + 0.5 + directionVector.y * 0.37) * globalConfig.tileSize,
|
||||
angle,
|
||||
size: globalConfig.tileSize * 0.25,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.globalAlpha = 1;
|
||||
}
|
||||
// this.drawImpl(parameters);
|
||||
}
|
||||
|
||||
///// Helper interfaces
|
||||
|
||||
///// Interface to override by subclasses
|
||||
|
||||
/**
|
||||
* override, should draw the entity
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
drawImpl(parameters) {
|
||||
abstract;
|
||||
}
|
||||
}
|
||||
57
src/js/game/entity_components.js
Normal file
57
src/js/game/entity_components.js
Normal file
@@ -0,0 +1,57 @@
|
||||
/* typehints:start */
|
||||
import { StaticMapEntityComponent } from "./components/static_map_entity";
|
||||
import { BeltComponent } from "./components/belt";
|
||||
import { ItemEjectorComponent } from "./components/item_ejector";
|
||||
import { ItemAcceptorComponent } from "./components/item_acceptor";
|
||||
import { MinerComponent } from "./components/miner";
|
||||
import { ItemProcessorComponent } from "./components/item_processor";
|
||||
import { ReplaceableMapEntityComponent } from "./components/replaceable_map_entity";
|
||||
import { UndergroundBeltComponent } from "./components/underground_belt";
|
||||
import { UnremovableComponent } from "./components/unremovable";
|
||||
import { HubComponent } from "./components/hub";
|
||||
/* typehints:end */
|
||||
|
||||
/**
|
||||
* Typedefs for all entity components. These are not actually present on the entity,
|
||||
* thus they are undefined by default
|
||||
*/
|
||||
export class EntityComponentStorage {
|
||||
constructor() {
|
||||
// TODO: Figure out if its faster to declare all components here and not
|
||||
// compile them out (In theory, should make it a fast object in V8 engine)
|
||||
|
||||
/* typehints:start */
|
||||
|
||||
/** @type {StaticMapEntityComponent} */
|
||||
this.StaticMapEntity;
|
||||
|
||||
/** @type {BeltComponent} */
|
||||
this.Belt;
|
||||
|
||||
/** @type {ItemEjectorComponent} */
|
||||
this.ItemEjector;
|
||||
|
||||
/** @type {ItemAcceptorComponent} */
|
||||
this.ItemAcceptor;
|
||||
|
||||
/** @type {MinerComponent} */
|
||||
this.Miner;
|
||||
|
||||
/** @type {ItemProcessorComponent} */
|
||||
this.ItemProcessor;
|
||||
|
||||
/** @type {ReplaceableMapEntityComponent} */
|
||||
this.ReplaceableMapEntity;
|
||||
|
||||
/** @type {UndergroundBeltComponent} */
|
||||
this.UndergroundBelt;
|
||||
|
||||
/** @type {UnremovableComponent} */
|
||||
this.Unremovable;
|
||||
|
||||
/** @type {HubComponent} */
|
||||
this.Hub;
|
||||
|
||||
/* typehints:end */
|
||||
}
|
||||
}
|
||||
239
src/js/game/entity_manager.js
Normal file
239
src/js/game/entity_manager.js
Normal file
@@ -0,0 +1,239 @@
|
||||
import { arrayDeleteValue, newEmptyMap } from "../core/utils";
|
||||
import { Component } from "./component";
|
||||
import { GameRoot } from "./root";
|
||||
import { Entity } from "./entity";
|
||||
import { BasicSerializableObject, types } from "../savegame/serialization";
|
||||
import { createLogger } from "../core/logging";
|
||||
|
||||
const logger = createLogger("entity_manager");
|
||||
|
||||
// Manages all entities
|
||||
|
||||
// TODO & NOTICE: We use arrayDeleteValue instead of fastArrayDeleteValue since that does not preserve the order
|
||||
// This is slower but we need it for the street path generation
|
||||
|
||||
export class EntityManager extends BasicSerializableObject {
|
||||
constructor(root) {
|
||||
super();
|
||||
|
||||
/** @type {GameRoot} */
|
||||
this.root = root;
|
||||
|
||||
/** @type {Array<Entity>} */
|
||||
this.entities = [];
|
||||
|
||||
// We store a seperate list with entities to destroy, since we don't destroy
|
||||
// them instantly
|
||||
/** @type {Array<Entity>} */
|
||||
this.destroyList = [];
|
||||
|
||||
// Store a map from componentid to entities - This is used by the game system
|
||||
// for faster processing
|
||||
/** @type {Object.<string, Array<Entity>>} */
|
||||
this.componentToEntity = newEmptyMap();
|
||||
|
||||
// Store the next uid to use
|
||||
this.nextUid = 10000;
|
||||
}
|
||||
|
||||
static getId() {
|
||||
return "EntityManager";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return {
|
||||
nextUid: types.uint,
|
||||
};
|
||||
}
|
||||
|
||||
getStatsText() {
|
||||
return this.entities.length + " entities [" + this.destroyList.length + " to kill]";
|
||||
}
|
||||
|
||||
// Main update
|
||||
update() {
|
||||
this.processDestroyList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new entity
|
||||
* @param {Entity} entity
|
||||
* @param {number=} uid Optional predefined uid
|
||||
*/
|
||||
registerEntity(entity, uid = null) {
|
||||
assert(this.entities.indexOf(entity) < 0, `RegisterEntity() called twice for entity ${entity}`);
|
||||
assert(!entity.destroyed, `Attempting to register destroyed entity ${entity}`);
|
||||
|
||||
if (G_IS_DEV && uid !== null) {
|
||||
assert(!this.findByUid(uid, false), "Entity uid already taken: " + uid);
|
||||
}
|
||||
|
||||
if (uid !== null) {
|
||||
assert(uid >= 0 && uid < Number.MAX_SAFE_INTEGER, "Invalid uid passed: " + uid);
|
||||
}
|
||||
|
||||
this.entities.push(entity);
|
||||
|
||||
// Register into the componentToEntity map
|
||||
for (const componentId in entity.components) {
|
||||
if (entity.components[componentId]) {
|
||||
if (this.componentToEntity[componentId]) {
|
||||
this.componentToEntity[componentId].push(entity);
|
||||
} else {
|
||||
this.componentToEntity[componentId] = [entity];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Give each entity a unique id
|
||||
entity.uid = uid ? uid : this.generateUid();
|
||||
entity.registered = true;
|
||||
|
||||
this.root.signals.entityAdded.dispatch(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts all entitiy lists after a resync
|
||||
*/
|
||||
sortEntityLists() {
|
||||
this.entities.sort((a, b) => a.uid - b.uid);
|
||||
this.destroyList.sort((a, b) => a.uid - b.uid);
|
||||
|
||||
for (const key in this.componentToEntity) {
|
||||
this.componentToEntity[key].sort((a, b) => a.uid - b.uid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new uid
|
||||
* @returns {number}
|
||||
*/
|
||||
generateUid() {
|
||||
return this.nextUid++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call to attach a new component after the creation of the entity
|
||||
* @param {Entity} entity
|
||||
* @param {Component} component
|
||||
*/
|
||||
attachDynamicComponent(entity, component) {
|
||||
entity.addComponent(component, true);
|
||||
const componentId = /** @type {typeof Component} */ (component.constructor).getId();
|
||||
if (this.componentToEntity[componentId]) {
|
||||
this.componentToEntity[componentId].push(entity);
|
||||
} else {
|
||||
this.componentToEntity[componentId] = [entity];
|
||||
}
|
||||
this.root.signals.entityGotNewComponent.dispatch(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an entity buy its uid, kinda slow since it loops over all entities
|
||||
* @param {number} uid
|
||||
* @param {boolean=} errorWhenNotFound
|
||||
* @returns {Entity}
|
||||
*/
|
||||
findByUid(uid, errorWhenNotFound = true) {
|
||||
const arr = this.entities;
|
||||
for (let i = 0, len = arr.length; i < len; ++i) {
|
||||
const entity = arr[i];
|
||||
if (entity.uid === uid) {
|
||||
if (entity.queuedForDestroy || entity.destroyed) {
|
||||
if (errorWhenNotFound) {
|
||||
logger.warn("Entity with UID", uid, "not found (destroyed)");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
if (errorWhenNotFound) {
|
||||
logger.warn("Entity with UID", uid, "not found");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all entities having the given component
|
||||
* @param {typeof Component} componentHandle
|
||||
* @returns {Array<Entity>} entities
|
||||
*/
|
||||
getAllWithComponent(componentHandle) {
|
||||
return this.componentToEntity[componentHandle.getId()] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all of a given class. This is SLOW!
|
||||
* @param {object} entityClass
|
||||
* @returns {Array<Entity>} entities
|
||||
*/
|
||||
getAllOfClass(entityClass) {
|
||||
// FIXME: Slow
|
||||
const result = [];
|
||||
for (let i = 0; i < this.entities.length; ++i) {
|
||||
const entity = this.entities[i];
|
||||
if (entity instanceof entityClass) {
|
||||
result.push(entity);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters all components of an entity from the component to entity mapping
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
unregisterEntityComponents(entity) {
|
||||
for (const componentId in entity.components) {
|
||||
if (entity.components[componentId]) {
|
||||
arrayDeleteValue(this.componentToEntity[componentId], entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Processes the entities to destroy and actually destroys them
|
||||
/* eslint-disable max-statements */
|
||||
processDestroyList() {
|
||||
for (let i = 0; i < this.destroyList.length; ++i) {
|
||||
const entity = this.destroyList[i];
|
||||
|
||||
// Remove from entities list
|
||||
arrayDeleteValue(this.entities, entity);
|
||||
|
||||
// Remove from componentToEntity list
|
||||
this.unregisterEntityComponents(entity);
|
||||
|
||||
entity.registered = false;
|
||||
entity.internalDestroyCallback();
|
||||
|
||||
this.root.signals.entityDestroyed.dispatch(entity);
|
||||
}
|
||||
|
||||
this.destroyList = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues an entity for destruction
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
destroyEntity(entity) {
|
||||
if (entity.destroyed) {
|
||||
logger.error("Tried to destroy already destroyed entity:", entity.uid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entity.queuedForDestroy) {
|
||||
logger.error("Trying to destroy entity which is already queued for destroy!", entity.uid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.destroyList.indexOf(entity) < 0) {
|
||||
this.destroyList.push(entity);
|
||||
entity.queuedForDestroy = true;
|
||||
this.root.signals.entityQueuedForDestroy.dispatch(entity);
|
||||
} else {
|
||||
assert(false, "Trying to destroy entity twice");
|
||||
}
|
||||
}
|
||||
}
|
||||
57
src/js/game/game_loading_overlay.js
Normal file
57
src/js/game/game_loading_overlay.js
Normal file
@@ -0,0 +1,57 @@
|
||||
/* typehints:start */
|
||||
import { Application } from "../application";
|
||||
/* typehints:end */
|
||||
|
||||
export class GameLoadingOverlay {
|
||||
/**
|
||||
*
|
||||
* @param {Application} app
|
||||
* @param {HTMLElement} parent
|
||||
*/
|
||||
constructor(app, parent) {
|
||||
this.app = app;
|
||||
this.parent = parent;
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
this.element = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the overlay if its currently visible
|
||||
*/
|
||||
removeIfAttached() {
|
||||
if (this.element) {
|
||||
this.element.remove();
|
||||
this.element = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the loading overlay is attached
|
||||
*/
|
||||
isAttached() {
|
||||
return this.element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a super basic overlay
|
||||
*/
|
||||
showBasic() {
|
||||
assert(!this.element, "Loading overlay already visible, cant show again");
|
||||
this.element = document.createElement("div");
|
||||
this.element.classList.add("gameLoadingOverlay");
|
||||
this.parent.appendChild(this.element);
|
||||
this.internalAddSpinnerAndText(this.element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a text with 'loading' and a spinner
|
||||
* @param {HTMLElement} element
|
||||
*/
|
||||
internalAddSpinnerAndText(element) {
|
||||
const inner = document.createElement("span");
|
||||
inner.classList.add("prefab_LoadingTextWithAnim");
|
||||
inner.innerText = "Loading";
|
||||
element.appendChild(inner);
|
||||
}
|
||||
}
|
||||
43
src/js/game/game_system.js
Normal file
43
src/js/game/game_system.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/* typehints:start */
|
||||
import { GameRoot } from "./root";
|
||||
import { DrawParameters } from "../core/draw_parameters";
|
||||
/* typehints:end */
|
||||
|
||||
/**
|
||||
* A game system processes all entities which match a given schema, usually a list of
|
||||
* required components. This is the core of the game logic.
|
||||
*/
|
||||
export class GameSystem {
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
}
|
||||
|
||||
///// PUBLIC API /////
|
||||
|
||||
/**
|
||||
* Updates the game system, override to perform logic
|
||||
*/
|
||||
update() {}
|
||||
|
||||
/**
|
||||
* Override, do not call this directly, use startDraw()
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
draw(parameters) {}
|
||||
|
||||
/**
|
||||
* Should refresh all caches
|
||||
*/
|
||||
refreshCaches() {}
|
||||
|
||||
/**
|
||||
* @see GameSystem.draw Wrapper arround the draw method
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
startDraw(parameters) {
|
||||
this.draw(parameters);
|
||||
}
|
||||
}
|
||||
104
src/js/game/game_system_manager.js
Normal file
104
src/js/game/game_system_manager.js
Normal file
@@ -0,0 +1,104 @@
|
||||
/* typehints:start */
|
||||
import { GameRoot } from "./root";
|
||||
/* typehints:end */
|
||||
|
||||
import { createLogger } from "../core/logging";
|
||||
import { BeltSystem } from "./systems/belt";
|
||||
import { ItemEjectorSystem } from "./systems/item_ejector";
|
||||
import { MapResourcesSystem } from "./systems/map_resources";
|
||||
import { MinerSystem } from "./systems/miner";
|
||||
import { ItemProcessorSystem } from "./systems/item_processor";
|
||||
import { UndergroundBeltSystem } from "./systems/underground_belt";
|
||||
import { HubSystem } from "./systems/hub";
|
||||
import { StaticMapEntitySystem } from "./systems/static_map_entity";
|
||||
|
||||
const logger = createLogger("game_system_manager");
|
||||
|
||||
export class GameSystemManager {
|
||||
/**
|
||||
*
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
|
||||
this.systems = {
|
||||
/* typehints:start */
|
||||
/** @type {BeltSystem} */
|
||||
belt: null,
|
||||
|
||||
/** @type {ItemEjectorSystem} */
|
||||
itemEjector: null,
|
||||
|
||||
/** @type {MapResourcesSystem} */
|
||||
mapResources: null,
|
||||
|
||||
/** @type {MinerSystem} */
|
||||
miner: null,
|
||||
|
||||
/** @type {ItemProcessorSystem} */
|
||||
itemProcessor: null,
|
||||
|
||||
/** @type {UndergroundBeltSystem} */
|
||||
undergroundBelt: null,
|
||||
|
||||
/** @type {HubSystem} */
|
||||
hub: null,
|
||||
|
||||
/** @type {StaticMapEntitySystem} */
|
||||
staticMapEntities: null,
|
||||
|
||||
/* typehints:end */
|
||||
};
|
||||
this.systemUpdateOrder = [];
|
||||
|
||||
this.internalInitSystems();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes all systems
|
||||
*/
|
||||
internalInitSystems() {
|
||||
const add = (id, systemClass) => {
|
||||
this.systems[id] = new systemClass(this.root);
|
||||
this.systemUpdateOrder.push(id);
|
||||
};
|
||||
|
||||
// Order is important!
|
||||
|
||||
add("belt", BeltSystem);
|
||||
|
||||
add("itemEjector", ItemEjectorSystem);
|
||||
|
||||
add("miner", MinerSystem);
|
||||
|
||||
add("mapResources", MapResourcesSystem);
|
||||
|
||||
add("itemProcessor", ItemProcessorSystem);
|
||||
|
||||
add("undergroundBelt", UndergroundBeltSystem);
|
||||
|
||||
add("hub", HubSystem);
|
||||
|
||||
add("staticMapEntities", StaticMapEntitySystem);
|
||||
|
||||
logger.log("📦 There are", this.systemUpdateOrder.length, "game systems");
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates all systems
|
||||
*/
|
||||
update() {
|
||||
for (let i = 0; i < this.systemUpdateOrder.length; ++i) {
|
||||
const system = this.systems[this.systemUpdateOrder[i]];
|
||||
system.update();
|
||||
}
|
||||
}
|
||||
|
||||
refreshCaches() {
|
||||
for (let i = 0; i < this.systemUpdateOrder.length; ++i) {
|
||||
const system = this.systems[this.systemUpdateOrder[i]];
|
||||
system.refreshCaches();
|
||||
}
|
||||
}
|
||||
}
|
||||
175
src/js/game/game_system_with_filter.js
Normal file
175
src/js/game/game_system_with_filter.js
Normal file
@@ -0,0 +1,175 @@
|
||||
/* typehints:start */
|
||||
import { Component } from "./component";
|
||||
import { GameRoot } from "./root";
|
||||
import { Entity } from "./entity";
|
||||
/* typehints:end */
|
||||
|
||||
import { GameSystem } from "./game_system";
|
||||
import { arrayDelete } from "../core/utils";
|
||||
import { DrawParameters } from "../core/draw_parameters";
|
||||
import { globalConfig } from "../core/config";
|
||||
import { Math_floor, Math_ceil } from "../core/builtins";
|
||||
|
||||
export class GameSystemWithFilter extends GameSystem {
|
||||
/**
|
||||
* Constructs a new game system with the given component filter. It will process
|
||||
* all entities which have *all* of the passed components
|
||||
* @param {GameRoot} root
|
||||
* @param {Array<typeof Component>} requiredComponents
|
||||
*/
|
||||
constructor(root, requiredComponents) {
|
||||
super(root);
|
||||
this.requiredComponents = requiredComponents;
|
||||
this.requiredComponentIds = requiredComponents.map(component => component.getId());
|
||||
|
||||
/**
|
||||
* All entities which match the current components
|
||||
* @type {Array<Entity>}
|
||||
*/
|
||||
this.allEntities = [];
|
||||
|
||||
this.root.signals.entityAdded.add(this.internalPushEntityIfMatching, this);
|
||||
this.root.signals.entityGotNewComponent.add(this.internalReconsiderEntityToAdd, this);
|
||||
this.root.signals.entityQueuedForDestroy.add(this.internalPopEntityIfMatching, this);
|
||||
|
||||
this.root.signals.postLoadHook.add(this.internalPostLoadHook, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a function for each matching entity on the screen, useful for drawing them
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {function} callback
|
||||
*/
|
||||
forEachMatchingEntityOnScreen(parameters, callback) {
|
||||
const cullRange = parameters.visibleRect.toTileCullRectangle();
|
||||
if (this.allEntities.length < 100) {
|
||||
// So, its much quicker to simply perform per-entity checking
|
||||
|
||||
for (let i = 0; i < this.allEntities.length; ++i) {
|
||||
const entity = this.allEntities[i];
|
||||
if (cullRange.containsRect(entity.components.StaticMapEntity.getTileSpaceBounds())) {
|
||||
callback(parameters, entity);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const top = cullRange.top();
|
||||
const right = cullRange.right();
|
||||
const bottom = cullRange.bottom();
|
||||
const left = cullRange.left();
|
||||
|
||||
const border = 1;
|
||||
const minY = top - border;
|
||||
const maxY = bottom + border;
|
||||
const minX = left - border;
|
||||
const maxX = right + border - 1;
|
||||
|
||||
const map = this.root.map;
|
||||
|
||||
let seenUids = new Set();
|
||||
|
||||
const chunkStartX = Math_floor(minX / globalConfig.mapChunkSize);
|
||||
const chunkStartY = Math_floor(minY / globalConfig.mapChunkSize);
|
||||
|
||||
const chunkEndX = Math_ceil(maxX / globalConfig.mapChunkSize);
|
||||
const chunkEndY = Math_ceil(maxY / globalConfig.mapChunkSize);
|
||||
|
||||
const requiredComponents = this.requiredComponentIds;
|
||||
|
||||
// Render y from top down for proper blending
|
||||
for (let chunkX = chunkStartX; chunkX <= chunkEndX; ++chunkX) {
|
||||
for (let chunkY = chunkStartY; chunkY <= chunkEndY; ++chunkY) {
|
||||
const chunk = map.getChunk(chunkX, chunkY, false);
|
||||
if (!chunk) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// BIG TODO: CULLING ON AN ENTITY BASIS
|
||||
|
||||
const entities = chunk.containedEntities;
|
||||
entityLoop: for (let i = 0; i < entities.length; ++i) {
|
||||
const entity = entities[i];
|
||||
|
||||
// Avoid drawing twice
|
||||
if (seenUids.has(entity.uid)) {
|
||||
continue;
|
||||
}
|
||||
seenUids.add(entity.uid);
|
||||
|
||||
for (let i = 0; i < requiredComponents.length; ++i) {
|
||||
if (!entity.components[requiredComponents[i]]) {
|
||||
continue entityLoop;
|
||||
}
|
||||
}
|
||||
callback(parameters, entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
internalPushEntityIfMatching(entity) {
|
||||
for (let i = 0; i < this.requiredComponentIds.length; ++i) {
|
||||
if (!entity.components[this.requiredComponentIds[i]]) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
assert(this.allEntities.indexOf(entity) < 0, "entity already in list: " + entity);
|
||||
this.internalRegisterEntity(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
internalReconsiderEntityToAdd(entity) {
|
||||
for (let i = 0; i < this.requiredComponentIds.length; ++i) {
|
||||
if (!entity.components[this.requiredComponentIds[i]]) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this.allEntities.indexOf(entity) >= 0) {
|
||||
return;
|
||||
}
|
||||
this.internalRegisterEntity(entity);
|
||||
}
|
||||
|
||||
refreshCaches() {
|
||||
this.allEntities.sort((a, b) => a.uid - b.uid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes all target entities after the game has loaded
|
||||
*/
|
||||
internalPostLoadHook() {
|
||||
this.refreshCaches();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
internalRegisterEntity(entity) {
|
||||
this.allEntities.push(entity);
|
||||
|
||||
if (this.root.gameInitialized) {
|
||||
// Sort entities by uid so behaviour is predictable
|
||||
this.allEntities.sort((a, b) => a.uid - b.uid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
internalPopEntityIfMatching(entity) {
|
||||
const index = this.allEntities.indexOf(entity);
|
||||
if (index >= 0) {
|
||||
arrayDelete(this.allEntities, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
330
src/js/game/hub_goals.js
Normal file
330
src/js/game/hub_goals.js
Normal file
@@ -0,0 +1,330 @@
|
||||
import { BasicSerializableObject } from "../savegame/serialization";
|
||||
import { GameRoot } from "./root";
|
||||
import { ShapeDefinition, enumSubShape } from "./shape_definition";
|
||||
import { enumColors } from "./colors";
|
||||
import { randomChoice, clamp, randomInt, findNiceIntegerValue } from "../core/utils";
|
||||
import { tutorialGoals, enumHubGoalRewards } from "./tutorial_goals";
|
||||
import { createLogger } from "../core/logging";
|
||||
import { globalConfig } from "../core/config";
|
||||
import { Math_random } from "../core/builtins";
|
||||
import { UPGRADES } from "./upgrades";
|
||||
import { enumItemProcessorTypes } from "./components/item_processor";
|
||||
|
||||
const logger = createLogger("hub_goals");
|
||||
|
||||
export class HubGoals extends BasicSerializableObject {
|
||||
static getId() {
|
||||
return "HubGoals";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
super();
|
||||
|
||||
this.root = root;
|
||||
|
||||
this.level = 1;
|
||||
|
||||
/**
|
||||
* Which story rewards we already gained
|
||||
*/
|
||||
this.gainedRewards = {};
|
||||
|
||||
/**
|
||||
* Mapping from shape hash -> amount
|
||||
* @type {Object<string, number>}
|
||||
*/
|
||||
this.storedShapes = {};
|
||||
|
||||
/**
|
||||
* Stores the levels for all upgrades
|
||||
* @type {Object<string, number>}
|
||||
*/
|
||||
this.upgradeLevels = {};
|
||||
|
||||
/**
|
||||
* Stores the improvements for all upgrades
|
||||
* @type {Object<string, number>}
|
||||
*/
|
||||
this.upgradeImprovements = {};
|
||||
for (const key in UPGRADES) {
|
||||
this.upgradeImprovements[key] = UPGRADES[key].baseValue || 1;
|
||||
}
|
||||
|
||||
this.createNextGoal();
|
||||
|
||||
// Allow quickly switching goals in dev mode with key "C"
|
||||
if (G_IS_DEV) {
|
||||
this.root.gameState.inputReciever.keydown.add(key => {
|
||||
if (key.keyCode === 67) {
|
||||
// Key: c
|
||||
this.onGoalCompleted();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns how much of the current shape is stored
|
||||
* @param {ShapeDefinition} definition
|
||||
* @returns {number}
|
||||
*/
|
||||
getShapesStored(definition) {
|
||||
return this.storedShapes[definition.getHash()] || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns how much of the current goal was already delivered
|
||||
*/
|
||||
getCurrentGoalDelivered() {
|
||||
return this.getShapesStored(this.currentGoal.definition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current level of a given upgrade
|
||||
* @param {string} upgradeId
|
||||
*/
|
||||
getUpgradeLevel(upgradeId) {
|
||||
return this.upgradeLevels[upgradeId] || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given reward is already unlocked
|
||||
* @param {enumHubGoalRewards} reward
|
||||
*/
|
||||
isRewardUnlocked(reward) {
|
||||
if (G_IS_DEV && globalConfig.debug.allBuildingsUnlocked) {
|
||||
return true;
|
||||
}
|
||||
return !!this.gainedRewards[reward];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the given definition, by either accounting it towards the
|
||||
* goal or otherwise granting some points
|
||||
* @param {ShapeDefinition} definition
|
||||
*/
|
||||
handleDefinitionDelivered(definition) {
|
||||
const hash = definition.getHash();
|
||||
this.storedShapes[hash] = (this.storedShapes[hash] || 0) + 1;
|
||||
|
||||
// Check if we have enough for the next level
|
||||
const targetHash = this.currentGoal.definition.getHash();
|
||||
if (
|
||||
this.storedShapes[targetHash] >= this.currentGoal.required ||
|
||||
(G_IS_DEV && globalConfig.debug.rewardsInstant)
|
||||
) {
|
||||
this.onGoalCompleted();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the next goal
|
||||
*/
|
||||
createNextGoal() {
|
||||
const storyIndex = this.level - 1;
|
||||
if (storyIndex < tutorialGoals.length) {
|
||||
const { shape, required, reward } = tutorialGoals[storyIndex];
|
||||
this.currentGoal = {
|
||||
/** @type {ShapeDefinition} */
|
||||
definition: this.root.shapeDefinitionMgr.registerOrReturnHandle(
|
||||
ShapeDefinition.fromShortKey(shape)
|
||||
),
|
||||
required,
|
||||
reward,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const reward = enumHubGoalRewards.no_reward;
|
||||
|
||||
this.currentGoal = {
|
||||
/** @type {ShapeDefinition} */
|
||||
definition: this.createRandomShape(),
|
||||
required: 1000 + findNiceIntegerValue(this.level * 47.5),
|
||||
reward,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the level was completed
|
||||
*/
|
||||
onGoalCompleted() {
|
||||
const reward = this.currentGoal.reward;
|
||||
this.gainedRewards[reward] = (this.gainedRewards[reward] || 0) + 1;
|
||||
this.root.signals.storyGoalCompleted.dispatch(this.level, reward);
|
||||
|
||||
this.root.app.gameAnalytics.handleLevelCompleted(this.level);
|
||||
++this.level;
|
||||
this.createNextGoal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a given upgrade can be unlocked
|
||||
* @param {string} upgradeId
|
||||
*/
|
||||
canUnlockUpgrade(upgradeId) {
|
||||
const handle = UPGRADES[upgradeId];
|
||||
const currentLevel = this.getUpgradeLevel(upgradeId);
|
||||
|
||||
if (currentLevel >= handle.tiers.length) {
|
||||
// Max level
|
||||
return false;
|
||||
}
|
||||
|
||||
if (G_IS_DEV && globalConfig.debug.upgradesNoCost) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const tierData = handle.tiers[currentLevel];
|
||||
|
||||
for (let i = 0; i < tierData.required.length; ++i) {
|
||||
const requirement = tierData.required[i];
|
||||
if ((this.storedShapes[requirement.shape] || 0) < requirement.amount) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to unlock the given upgrade
|
||||
* @param {string} upgradeId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
tryUnlockUgprade(upgradeId) {
|
||||
if (!this.canUnlockUpgrade(upgradeId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const handle = UPGRADES[upgradeId];
|
||||
const currentLevel = this.getUpgradeLevel(upgradeId);
|
||||
|
||||
const tierData = handle.tiers[currentLevel];
|
||||
if (!tierData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (G_IS_DEV && globalConfig.debug.upgradesNoCost) {
|
||||
// Dont take resources
|
||||
} else {
|
||||
for (let i = 0; i < tierData.required.length; ++i) {
|
||||
const requirement = tierData.required[i];
|
||||
|
||||
// Notice: Don't have to check for hash here
|
||||
this.storedShapes[requirement.shape] -= requirement.amount;
|
||||
}
|
||||
}
|
||||
|
||||
this.upgradeLevels[upgradeId] = (this.upgradeLevels[upgradeId] || 0) + 1;
|
||||
this.upgradeImprovements[upgradeId] += tierData.improvement;
|
||||
|
||||
this.root.signals.upgradePurchased.dispatch(upgradeId);
|
||||
|
||||
this.root.app.gameAnalytics.handleUpgradeUnlocked(upgradeId, currentLevel);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {ShapeDefinition}
|
||||
*/
|
||||
createRandomShape() {
|
||||
const layerCount = clamp(this.level / 50, 2, 4);
|
||||
/** @type {Array<import("./shape_definition").ShapeLayer>} */
|
||||
let layers = [];
|
||||
|
||||
// @ts-ignore
|
||||
const randomColor = () => randomChoice(Object.values(enumColors));
|
||||
// @ts-ignore
|
||||
const randomShape = () => randomChoice(Object.values(enumSubShape));
|
||||
|
||||
let anyIsMissingTwo = false;
|
||||
|
||||
for (let i = 0; i < layerCount; ++i) {
|
||||
/** @type {import("./shape_definition").ShapeLayer} */
|
||||
const layer = [null, null, null, null];
|
||||
|
||||
for (let quad = 0; quad < 4; ++quad) {
|
||||
layer[quad] = {
|
||||
subShape: randomShape(),
|
||||
color: randomColor(),
|
||||
};
|
||||
}
|
||||
|
||||
// Sometimes shapes are missing
|
||||
if (Math_random() > 0.85) {
|
||||
layer[randomInt(0, 3)] = null;
|
||||
}
|
||||
|
||||
// Sometimes they actually are missing *two* ones!
|
||||
// Make sure at max only one layer is missing it though, otherwise we could
|
||||
// create an uncreateable shape
|
||||
if (Math_random() > 0.95 && !anyIsMissingTwo) {
|
||||
layer[randomInt(0, 3)] = null;
|
||||
anyIsMissingTwo = true;
|
||||
}
|
||||
|
||||
layers.push(layer);
|
||||
}
|
||||
|
||||
const definition = new ShapeDefinition({ layers });
|
||||
return this.root.shapeDefinitionMgr.registerOrReturnHandle(definition);
|
||||
}
|
||||
|
||||
////////////// HELPERS
|
||||
|
||||
/**
|
||||
* Belt speed
|
||||
* @returns {number} items / sec
|
||||
*/
|
||||
getBeltBaseSpeed() {
|
||||
return globalConfig.beltSpeedItemsPerSecond * this.upgradeImprovements.belt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Underground belt speed
|
||||
* @returns {number} items / sec
|
||||
*/
|
||||
getUndergroundBeltBaseSpeed() {
|
||||
return globalConfig.beltSpeedItemsPerSecond * this.upgradeImprovements.belt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Miner speed
|
||||
* @returns {number} items / sec
|
||||
*/
|
||||
getMinerBaseSpeed() {
|
||||
return globalConfig.minerSpeedItemsPerSecond * this.upgradeImprovements.miner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processor speed
|
||||
* @param {enumItemProcessorTypes} processorType
|
||||
* @returns {number} items / sec
|
||||
*/
|
||||
getProcessorBaseSpeed(processorType) {
|
||||
switch (processorType) {
|
||||
case enumItemProcessorTypes.trash:
|
||||
return 1e30;
|
||||
case enumItemProcessorTypes.splitter:
|
||||
return (2 / globalConfig.beltSpeedItemsPerSecond) * this.upgradeImprovements.splitter;
|
||||
case enumItemProcessorTypes.cutter:
|
||||
case enumItemProcessorTypes.rotater:
|
||||
case enumItemProcessorTypes.stacker:
|
||||
case enumItemProcessorTypes.mixer:
|
||||
case enumItemProcessorTypes.painter:
|
||||
return (
|
||||
(1 / globalConfig.beltSpeedItemsPerSecond) *
|
||||
this.upgradeImprovements.processor *
|
||||
globalConfig.buildingSpeeds[processorType]
|
||||
);
|
||||
default:
|
||||
assertAlways(false, "invalid processor type");
|
||||
}
|
||||
|
||||
return 1 / globalConfig.beltSpeedItemsPerSecond;
|
||||
}
|
||||
}
|
||||
175
src/js/game/hud/base_hud_part.js
Normal file
175
src/js/game/hud/base_hud_part.js
Normal file
@@ -0,0 +1,175 @@
|
||||
/* typehints:start */
|
||||
import { GameRoot } from "../root";
|
||||
import { DrawParameters } from "../../core/draw_parameters";
|
||||
/* typehints:end */
|
||||
|
||||
import { ClickDetector } from "../../core/click_detector";
|
||||
import { KeyActionMapper } from "../key_action_mapper";
|
||||
|
||||
export class BaseHUDPart {
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
|
||||
/** @type {Array<ClickDetector>} */
|
||||
this.clickDetectors = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Should create all require elements
|
||||
* @param {HTMLElement} parent
|
||||
*/
|
||||
createElements(parent) {}
|
||||
|
||||
/**
|
||||
* Should initialize the element, called *after* the elements have been created
|
||||
*/
|
||||
initialize() {
|
||||
abstract;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should update any required logic
|
||||
*/
|
||||
update() {}
|
||||
|
||||
/**
|
||||
* Should draw the hud
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
draw(parameters) {}
|
||||
|
||||
/**
|
||||
* Should draw any overlays (screen space)
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
drawOverlays(parameters) {}
|
||||
|
||||
/**
|
||||
* Should return true if the widget has a modal dialog opened and thus
|
||||
* the game does not need to update / redraw
|
||||
* @returns {boolean}
|
||||
*/
|
||||
shouldPauseRendering() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return false if the game should be paused
|
||||
* @returns {boolean}
|
||||
*/
|
||||
shouldPauseGame() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return true if this overlay is open and currently blocking any user interaction
|
||||
*/
|
||||
isBlockingOverlay() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up the hud element, if overridden make sure to call super.cleanups
|
||||
*/
|
||||
cleanup() {
|
||||
if (this.clickDetectors) {
|
||||
for (let i = 0; i < this.clickDetectors.length; ++i) {
|
||||
this.clickDetectors[i].cleanup();
|
||||
}
|
||||
this.clickDetectors = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Should close the element, in case its supported
|
||||
*/
|
||||
close() {}
|
||||
|
||||
// Helpers
|
||||
|
||||
/**
|
||||
* Calls closeMethod if an overlay is opened
|
||||
* @param {function=} closeMethod
|
||||
*/
|
||||
closeOnOverlayOpen(closeMethod = null) {
|
||||
this.root.hud.signals.overlayOpened.add(overlay => {
|
||||
if (overlay !== this) {
|
||||
(closeMethod || this.close).call(this);
|
||||
}
|
||||
}, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to construct a new click detector
|
||||
* @param {Element} element The element to listen on
|
||||
* @param {function} handler The handler to call on this object
|
||||
* @param {import("../../core/click_detector").ClickDetectorConstructorArgs=} args Click detector arguments
|
||||
*
|
||||
*/
|
||||
trackClicks(element, handler, args = {}) {
|
||||
const detector = new ClickDetector(element, args);
|
||||
detector.click.add(handler, this);
|
||||
this.registerClickDetector(detector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new click detector
|
||||
* @param {ClickDetector} detector
|
||||
*/
|
||||
registerClickDetector(detector) {
|
||||
this.clickDetectors.push(detector);
|
||||
if (G_IS_DEV) {
|
||||
// @ts-ignore
|
||||
detector._src = "hud-" + this.constructor.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes this element when its background is clicked
|
||||
* @param {HTMLElement} element
|
||||
* @param {function} closeMethod
|
||||
*/
|
||||
closeOnBackgroundClick(element, closeMethod = null) {
|
||||
const bgClickDetector = new ClickDetector(element, {
|
||||
preventDefault: true,
|
||||
targetOnly: true,
|
||||
applyCssClass: null,
|
||||
consumeEvents: true,
|
||||
clickSound: null,
|
||||
});
|
||||
|
||||
// If the state defines a close method, use that as fallback
|
||||
// @ts-ignore
|
||||
bgClickDetector.touchend.add(closeMethod || this.close, this);
|
||||
this.registerClickDetector(bgClickDetector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forwards the game speed keybindings so you can toggle pause / Fastforward
|
||||
* in the building tooltip and such
|
||||
* @param {KeyActionMapper} sourceMapper
|
||||
*/
|
||||
forwardGameSpeedKeybindings(sourceMapper) {
|
||||
sourceMapper.forward(this.root.gameState.keyActionMapper, [
|
||||
"gamespeed_pause",
|
||||
"gamespeed_fastforward",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forwards the map movement keybindings so you can move the map with the
|
||||
* arrow keys
|
||||
* @param {KeyActionMapper} sourceMapper
|
||||
*/
|
||||
forwardMapMovementKeybindings(sourceMapper) {
|
||||
sourceMapper.forward(this.root.gameState.keyActionMapper, [
|
||||
"map_move_up",
|
||||
"map_move_right",
|
||||
"map_move_down",
|
||||
"map_move_left",
|
||||
]);
|
||||
}
|
||||
}
|
||||
79
src/js/game/hud/dynamic_dom_attach.js
Normal file
79
src/js/game/hud/dynamic_dom_attach.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { GameRoot } from "../root";
|
||||
|
||||
// Automatically attaches and detaches elements from the dom
|
||||
// Also supports detaching elements after a given time, useful if there is a
|
||||
// hide animation like for the tooltips
|
||||
|
||||
// Also attaches a class name if desired
|
||||
|
||||
export class DynamicDomAttach {
|
||||
constructor(root, element, { timeToKeepSeconds = 0, attachClass = null } = {}) {
|
||||
/** @type {GameRoot} */
|
||||
this.root = root;
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
this.element = element;
|
||||
this.parent = this.element.parentElement;
|
||||
|
||||
this.attachClass = attachClass;
|
||||
|
||||
this.timeToKeepSeconds = timeToKeepSeconds;
|
||||
this.lastVisibleTime = 0;
|
||||
|
||||
// We start attached, so detach the node first
|
||||
this.attached = true;
|
||||
this.internalDetach();
|
||||
|
||||
this.internalIsClassAttached = false;
|
||||
this.classAttachTimeout = null;
|
||||
}
|
||||
|
||||
internalAttach() {
|
||||
if (!this.attached) {
|
||||
this.parent.appendChild(this.element);
|
||||
assert(this.element.parentElement === this.parent, "Invalid parent #1");
|
||||
this.attached = true;
|
||||
}
|
||||
}
|
||||
|
||||
internalDetach() {
|
||||
if (this.attached) {
|
||||
assert(this.element.parentElement === this.parent, "Invalid parent #2");
|
||||
this.element.parentElement.removeChild(this.element);
|
||||
this.attached = false;
|
||||
}
|
||||
}
|
||||
|
||||
isAttached() {
|
||||
return this.attached;
|
||||
}
|
||||
|
||||
update(isVisible) {
|
||||
if (isVisible) {
|
||||
this.lastVisibleTime = this.root ? this.root.time.realtimeNow() : 0;
|
||||
this.internalAttach();
|
||||
} else {
|
||||
if (!this.root || this.root.time.realtimeNow() - this.lastVisibleTime >= this.timeToKeepSeconds) {
|
||||
this.internalDetach();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.attachClass && isVisible !== this.internalIsClassAttached) {
|
||||
// State changed
|
||||
this.internalIsClassAttached = isVisible;
|
||||
|
||||
if (this.classAttachTimeout) {
|
||||
clearTimeout(this.classAttachTimeout);
|
||||
this.classAttachTimeout = null;
|
||||
}
|
||||
|
||||
if (isVisible) {
|
||||
this.classAttachTimeout = setTimeout(() => {
|
||||
this.element.classList.add(this.attachClass);
|
||||
}, 15);
|
||||
} else {
|
||||
this.element.classList.remove(this.attachClass);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
187
src/js/game/hud/hud.js
Normal file
187
src/js/game/hud/hud.js
Normal file
@@ -0,0 +1,187 @@
|
||||
/* typehints:start */
|
||||
import { GameRoot } from "../root";
|
||||
/* typehints:end */
|
||||
|
||||
import { Signal } from "../../core/signal";
|
||||
import { DrawParameters } from "../../core/draw_parameters";
|
||||
import { HUDProcessingOverlay } from "./parts/processing_overlay";
|
||||
import { HUDBuildingsToolbar } from "./parts/buildings_toolbar";
|
||||
import { HUDBuildingPlacer } from "./parts/building_placer";
|
||||
import { HUDBetaOverlay } from "./parts/beta_overlay";
|
||||
import { HUDKeybindingOverlay } from "./parts/keybinding_overlay";
|
||||
import { HUDUnlockNotification } from "./parts/unlock_notification";
|
||||
import { HUDGameMenu } from "./parts/game_menu";
|
||||
import { HUDShop } from "./parts/shop";
|
||||
import { IS_MOBILE } from "../../core/config";
|
||||
|
||||
export class GameHUD {
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the hud parts
|
||||
*/
|
||||
initialize() {
|
||||
this.signals = {
|
||||
overlayOpened: new Signal(/* overlay */),
|
||||
};
|
||||
|
||||
this.parts = {
|
||||
processingOverlay: new HUDProcessingOverlay(this.root),
|
||||
|
||||
buildingsToolbar: new HUDBuildingsToolbar(this.root),
|
||||
buildingPlacer: new HUDBuildingPlacer(this.root),
|
||||
|
||||
unlockNotification: new HUDUnlockNotification(this.root),
|
||||
|
||||
gameMenu: new HUDGameMenu(this.root),
|
||||
|
||||
shop: new HUDShop(this.root),
|
||||
|
||||
// betaOverlay: new HUDBetaOverlay(this.root),
|
||||
};
|
||||
|
||||
this.signals = {
|
||||
selectedPlacementBuildingChanged: new Signal(/* metaBuilding|null */),
|
||||
};
|
||||
|
||||
if (!IS_MOBILE) {
|
||||
this.parts.keybindingOverlay = new HUDKeybindingOverlay(this.root);
|
||||
}
|
||||
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const key in this.parts) {
|
||||
this.parts[key].createElements(frag);
|
||||
}
|
||||
|
||||
document.body.appendChild(frag);
|
||||
|
||||
for (const key in this.parts) {
|
||||
this.parts[key].initialize();
|
||||
}
|
||||
this.internalInitSignalConnections();
|
||||
|
||||
this.root.gameState.keyActionMapper.getBinding("toggle_hud").add(this.toggleUi, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to close all overlays
|
||||
*/
|
||||
closeAllOverlays() {
|
||||
for (const key in this.parts) {
|
||||
this.parts[key].close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the game logic should be paused
|
||||
*/
|
||||
shouldPauseGame() {
|
||||
for (const key in this.parts) {
|
||||
if (this.parts[key].shouldPauseGame()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the rendering can be paused
|
||||
*/
|
||||
shouldPauseRendering() {
|
||||
for (const key in this.parts) {
|
||||
if (this.parts[key].shouldPauseRendering()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the rendering can be paused
|
||||
*/
|
||||
hasBlockingOverlayOpen() {
|
||||
if (this.root.camera.getIsMapOverlayActive()) {
|
||||
return true;
|
||||
}
|
||||
for (const key in this.parts) {
|
||||
if (this.parts[key].isBlockingOverlay()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the ui
|
||||
*/
|
||||
toggleUi() {
|
||||
document.body.classList.toggle("uiHidden");
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes connections between parts
|
||||
*/
|
||||
internalInitSignalConnections() {
|
||||
const p = this.parts;
|
||||
p.buildingsToolbar.sigBuildingSelected.add(p.buildingPlacer.startSelection, p.buildingPlacer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates all parts
|
||||
*/
|
||||
update() {
|
||||
if (!this.root.gameInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const key in this.parts) {
|
||||
this.parts[key].update();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws all parts
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
draw(parameters) {
|
||||
const partsOrder = ["buildingPlacer"];
|
||||
|
||||
for (let i = 0; i < partsOrder.length; ++i) {
|
||||
if (this.parts[partsOrder[i]]) {
|
||||
this.parts[partsOrder[i]].draw(parameters);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws all part overlays
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
drawOverlays(parameters) {
|
||||
const partsOrder = [];
|
||||
|
||||
for (let i = 0; i < partsOrder.length; ++i) {
|
||||
if (this.parts[partsOrder[i]]) {
|
||||
this.parts[partsOrder[i]].drawOverlays(parameters);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up everything
|
||||
*/
|
||||
cleanup() {
|
||||
for (const key in this.parts) {
|
||||
this.parts[key].cleanup();
|
||||
}
|
||||
|
||||
for (const key in this.signals) {
|
||||
this.signals[key].removeAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/js/game/hud/parts/beta_overlay.js
Normal file
10
src/js/game/hud/parts/beta_overlay.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { BaseHUDPart } from "../base_hud_part";
|
||||
import { makeDiv } from "../../../core/utils";
|
||||
|
||||
export class HUDBetaOverlay extends BaseHUDPart {
|
||||
createElements(parent) {
|
||||
this.element = makeDiv(parent, "ingame_HUD_BetaOverlay", [], "CLOSED BETA");
|
||||
}
|
||||
|
||||
initialize() {}
|
||||
}
|
||||
492
src/js/game/hud/parts/building_placer.js
Normal file
492
src/js/game/hud/parts/building_placer.js
Normal file
@@ -0,0 +1,492 @@
|
||||
import { BaseHUDPart } from "../base_hud_part";
|
||||
import { MetaBuilding } from "../../meta_building";
|
||||
import { DrawParameters } from "../../../core/draw_parameters";
|
||||
import { globalConfig } from "../../../core/config";
|
||||
import { StaticMapEntityComponent } from "../../components/static_map_entity";
|
||||
import { STOP_PROPAGATION, Signal } from "../../../core/signal";
|
||||
import {
|
||||
Vector,
|
||||
enumDirectionToAngle,
|
||||
enumInvertedDirections,
|
||||
enumDirectionToVector,
|
||||
} from "../../../core/vector";
|
||||
import { pulseAnimation, makeDiv } from "../../../core/utils";
|
||||
import { DynamicDomAttach } from "../dynamic_dom_attach";
|
||||
import { TrackedState } from "../../../core/tracked_state";
|
||||
import { Math_abs, Math_radians } from "../../../core/builtins";
|
||||
import { Loader } from "../../../core/loader";
|
||||
import { drawRotatedSprite } from "../../../core/draw_utils";
|
||||
import { Entity } from "../../entity";
|
||||
|
||||
export class HUDBuildingPlacer extends BaseHUDPart {
|
||||
initialize() {
|
||||
/** @type {TypedTrackedState<MetaBuilding?>} */
|
||||
this.currentMetaBuilding = new TrackedState(this.onSelectedMetaBuildingChanged, this);
|
||||
this.currentBaseRotation = 0;
|
||||
|
||||
/** @type {Entity} */
|
||||
this.fakeEntity = null;
|
||||
|
||||
const keyActionMapper = this.root.gameState.keyActionMapper;
|
||||
keyActionMapper.getBinding("building_abort_placement").add(() => this.currentMetaBuilding.set(null));
|
||||
keyActionMapper.getBinding("back").add(() => this.currentMetaBuilding.set(null));
|
||||
|
||||
keyActionMapper.getBinding("rotate_while_placing").add(this.tryRotate, this);
|
||||
|
||||
this.domAttach = new DynamicDomAttach(this.root, this.element, {});
|
||||
|
||||
this.root.camera.downPreHandler.add(this.onMouseDown, this);
|
||||
this.root.camera.movePreHandler.add(this.onMouseMove, this);
|
||||
this.root.camera.upPostHandler.add(this.abortDragging, this);
|
||||
|
||||
this.currentlyDragging = false;
|
||||
|
||||
/**
|
||||
* The tile we last dragged onto
|
||||
* @type {Vector}
|
||||
* */
|
||||
this.lastDragTile = null;
|
||||
|
||||
/**
|
||||
* The tile we initially dragged from
|
||||
* @type {Vector}
|
||||
*/
|
||||
this.initialDragTile = null;
|
||||
}
|
||||
|
||||
createElements(parent) {
|
||||
this.element = makeDiv(parent, "ingame_HUD_building_placer", [], ``);
|
||||
|
||||
this.buildingLabel = makeDiv(this.element, null, ["buildingLabel"], "Extract");
|
||||
this.buildingDescription = makeDiv(this.element, null, ["description"], "");
|
||||
}
|
||||
|
||||
/**
|
||||
* mouse down pre handler
|
||||
* @param {Vector} pos
|
||||
*/
|
||||
onMouseDown(pos) {
|
||||
if (this.root.camera.getIsMapOverlayActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.currentMetaBuilding.get()) {
|
||||
this.currentlyDragging = true;
|
||||
this.lastDragTile = this.root.camera.screenToWorld(pos).toTileSpace();
|
||||
|
||||
// Place initial building
|
||||
this.tryPlaceCurrentBuildingAt(this.lastDragTile);
|
||||
|
||||
return STOP_PROPAGATION;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* mouse move pre handler
|
||||
* @param {Vector} pos
|
||||
*/
|
||||
onMouseMove(pos) {
|
||||
if (this.root.camera.getIsMapOverlayActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.currentMetaBuilding.get() && this.lastDragTile) {
|
||||
const oldPos = this.lastDragTile;
|
||||
const newPos = this.root.camera.screenToWorld(pos).toTileSpace();
|
||||
|
||||
if (!oldPos.equals(newPos)) {
|
||||
const delta = newPos.sub(oldPos);
|
||||
// - Using bresenhams algorithmus
|
||||
|
||||
let x0 = oldPos.x;
|
||||
let y0 = oldPos.y;
|
||||
let x1 = newPos.x;
|
||||
let y1 = newPos.y;
|
||||
|
||||
var dx = Math_abs(x1 - x0);
|
||||
var dy = Math_abs(y1 - y0);
|
||||
var sx = x0 < x1 ? 1 : -1;
|
||||
var sy = y0 < y1 ? 1 : -1;
|
||||
var err = dx - dy;
|
||||
|
||||
while (true) {
|
||||
this.tryPlaceCurrentBuildingAt(new Vector(x0, y0));
|
||||
if (x0 === x1 && y0 === y1) break;
|
||||
var e2 = 2 * err;
|
||||
if (e2 > -dy) {
|
||||
err -= dy;
|
||||
x0 += sx;
|
||||
}
|
||||
if (e2 < dx) {
|
||||
err += dx;
|
||||
y0 += sy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.lastDragTile = newPos;
|
||||
|
||||
return STOP_PROPAGATION;
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
// ALways update since the camera might have moved
|
||||
const mousePos = this.root.app.mousePosition;
|
||||
if (mousePos) {
|
||||
this.onMouseMove(mousePos);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* aborts any dragging op
|
||||
*/
|
||||
abortDragging() {
|
||||
this.currentlyDragging = true;
|
||||
this.lastDragTile = null;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {MetaBuilding} metaBuilding
|
||||
*/
|
||||
startSelection(metaBuilding) {
|
||||
this.currentMetaBuilding.set(metaBuilding);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {MetaBuilding} metaBuilding
|
||||
*/
|
||||
onSelectedMetaBuildingChanged(metaBuilding) {
|
||||
this.root.hud.signals.selectedPlacementBuildingChanged.dispatch(metaBuilding);
|
||||
if (metaBuilding) {
|
||||
this.buildingLabel.innerHTML = metaBuilding.getName();
|
||||
this.buildingDescription.innerHTML = metaBuilding.getDescription();
|
||||
|
||||
this.fakeEntity = new Entity(null);
|
||||
metaBuilding.setupEntityComponents(this.fakeEntity, null);
|
||||
this.fakeEntity.addComponent(
|
||||
new StaticMapEntityComponent({
|
||||
origin: new Vector(0, 0),
|
||||
rotationDegrees: 0,
|
||||
tileSize: metaBuilding.getDimensions().copy(),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
this.currentlyDragging = false;
|
||||
this.fakeEntity = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to rotate
|
||||
*/
|
||||
tryRotate() {
|
||||
const selectedBuilding = this.currentMetaBuilding.get();
|
||||
if (selectedBuilding) {
|
||||
this.currentBaseRotation = (this.currentBaseRotation + 90) % 360;
|
||||
const staticComp = this.fakeEntity.components.StaticMapEntity;
|
||||
staticComp.rotationDegrees = this.currentBaseRotation;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to delete the building under the mouse
|
||||
*/
|
||||
deleteBelowCursor() {
|
||||
const mousePosition = this.root.app.mousePosition;
|
||||
if (!mousePosition) {
|
||||
// Not on screen
|
||||
return;
|
||||
}
|
||||
|
||||
const worldPos = this.root.camera.screenToWorld(mousePosition);
|
||||
const tile = worldPos.toTileSpace();
|
||||
const contents = this.root.map.getTileContent(tile);
|
||||
if (contents) {
|
||||
this.root.logic.tryDeleteBuilding(contents);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas click handler
|
||||
* @param {Vector} mousePos
|
||||
* @param {boolean} cancelAction
|
||||
*/
|
||||
onCanvasClick(mousePos, cancelAction = false) {
|
||||
if (cancelAction) {
|
||||
if (this.currentMetaBuilding.get()) {
|
||||
this.currentMetaBuilding.set(null);
|
||||
} else {
|
||||
this.deleteBelowCursor();
|
||||
}
|
||||
return STOP_PROPAGATION;
|
||||
}
|
||||
|
||||
if (!this.currentMetaBuilding.get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
return STOP_PROPAGATION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to place the current building at the given tile
|
||||
* @param {Vector} tile
|
||||
*/
|
||||
tryPlaceCurrentBuildingAt(tile) {
|
||||
if (this.root.camera.zoomLevel < globalConfig.mapChunkOverviewMinZoom) {
|
||||
// Dont allow placing in overview mode
|
||||
return;
|
||||
}
|
||||
// Transform to world space
|
||||
|
||||
const metaBuilding = this.currentMetaBuilding.get();
|
||||
|
||||
const { rotation, rotationVariant } = metaBuilding.computeOptimalDirectionAndRotationVariantAtTile(
|
||||
this.root,
|
||||
tile,
|
||||
this.currentBaseRotation
|
||||
);
|
||||
|
||||
if (
|
||||
this.root.logic.tryPlaceBuilding({
|
||||
origin: tile,
|
||||
rotation,
|
||||
rotationVariant,
|
||||
building: this.currentMetaBuilding.get(),
|
||||
})
|
||||
) {
|
||||
// Succesfully placed
|
||||
|
||||
if (metaBuilding.getFlipOrientationAfterPlacement()) {
|
||||
this.currentBaseRotation = (180 + this.currentBaseRotation) % 360;
|
||||
}
|
||||
|
||||
if (!metaBuilding.getStayInPlacementMode() && !this.root.app.inputMgr.shiftIsDown) {
|
||||
// Stop placement
|
||||
this.currentMetaBuilding.set(null);
|
||||
}
|
||||
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
draw(parameters) {
|
||||
if (this.root.camera.zoomLevel < globalConfig.mapChunkOverviewMinZoom) {
|
||||
// Dont allow placing in overview mode
|
||||
this.domAttach.update(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.domAttach.update(this.currentMetaBuilding.get());
|
||||
const metaBuilding = this.currentMetaBuilding.get();
|
||||
|
||||
if (!metaBuilding) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mousePosition = this.root.app.mousePosition;
|
||||
if (!mousePosition) {
|
||||
// Not on screen
|
||||
return;
|
||||
}
|
||||
|
||||
const worldPos = this.root.camera.screenToWorld(mousePosition);
|
||||
const tile = worldPos.toTileSpace();
|
||||
|
||||
// Compute best rotation variant
|
||||
const {
|
||||
rotation,
|
||||
rotationVariant,
|
||||
connectedEntities,
|
||||
} = metaBuilding.computeOptimalDirectionAndRotationVariantAtTile(
|
||||
this.root,
|
||||
tile,
|
||||
this.currentBaseRotation
|
||||
);
|
||||
|
||||
// Check if there are connected entities
|
||||
if (connectedEntities) {
|
||||
for (let i = 0; i < connectedEntities.length; ++i) {
|
||||
const connectedEntity = connectedEntities[i];
|
||||
const connectedWsPoint = connectedEntity.components.StaticMapEntity.getTileSpaceBounds()
|
||||
.getCenter()
|
||||
.toWorldSpace();
|
||||
|
||||
const startWsPoint = tile.toWorldSpaceCenterOfTile();
|
||||
|
||||
const startOffset = connectedWsPoint
|
||||
.sub(startWsPoint)
|
||||
.normalize()
|
||||
.multiplyScalar(globalConfig.tileSize * 0.3);
|
||||
const effectiveStartPoint = startWsPoint.add(startOffset);
|
||||
const effectiveEndPoint = connectedWsPoint.sub(startOffset);
|
||||
|
||||
parameters.context.globalAlpha = 0.6;
|
||||
|
||||
// parameters.context.lineCap = "round";
|
||||
parameters.context.strokeStyle = "#7f7";
|
||||
parameters.context.lineWidth = 10;
|
||||
parameters.context.beginPath();
|
||||
parameters.context.moveTo(effectiveStartPoint.x, effectiveStartPoint.y);
|
||||
parameters.context.lineTo(effectiveEndPoint.x, effectiveEndPoint.y);
|
||||
parameters.context.stroke();
|
||||
parameters.context.globalAlpha = 1;
|
||||
// parameters.context.lineCap = "square";
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronize rotation and origin
|
||||
const staticComp = this.fakeEntity.components.StaticMapEntity;
|
||||
staticComp.origin = tile;
|
||||
staticComp.rotationDegrees = rotation;
|
||||
metaBuilding.updateRotationVariant(this.fakeEntity, rotationVariant);
|
||||
|
||||
// Check if we could place the buildnig
|
||||
const canBuild = this.root.logic.checkCanPlaceBuilding(tile, rotation, metaBuilding);
|
||||
|
||||
// Determine the bounds and visualize them
|
||||
const entityBounds = staticComp.getTileSpaceBounds();
|
||||
const drawBorder = 2;
|
||||
parameters.context.globalAlpha = 0.5;
|
||||
if (canBuild) {
|
||||
parameters.context.fillStyle = "rgba(0, 255, 0, 0.2)";
|
||||
} else {
|
||||
parameters.context.fillStyle = "rgba(255, 0, 0, 0.2)";
|
||||
}
|
||||
parameters.context.fillRect(
|
||||
entityBounds.x * globalConfig.tileSize - drawBorder,
|
||||
entityBounds.y * globalConfig.tileSize - drawBorder,
|
||||
entityBounds.w * globalConfig.tileSize + 2 * drawBorder,
|
||||
entityBounds.h * globalConfig.tileSize + 2 * drawBorder
|
||||
);
|
||||
|
||||
// Draw ejectors
|
||||
if (canBuild) {
|
||||
this.drawMatchingAcceptorsAndEjectors(parameters);
|
||||
}
|
||||
|
||||
// HACK to draw the entity sprite
|
||||
const previewSprite = metaBuilding.getPreviewSprite(rotationVariant);
|
||||
parameters.context.globalAlpha = 0.8 + pulseAnimation(this.root.time.realtimeNow(), 1) * 0.1;
|
||||
staticComp.origin = worldPos.divideScalar(globalConfig.tileSize).subScalars(0.5, 0.5);
|
||||
staticComp.drawSpriteOnFullEntityBounds(parameters, previewSprite);
|
||||
staticComp.origin = tile;
|
||||
parameters.context.globalAlpha = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
drawMatchingAcceptorsAndEjectors(parameters) {
|
||||
const acceptorComp = this.fakeEntity.components.ItemAcceptor;
|
||||
const ejectorComp = this.fakeEntity.components.ItemEjector;
|
||||
const staticComp = this.fakeEntity.components.StaticMapEntity;
|
||||
|
||||
const goodArrowSprite = Loader.getSprite("sprites/misc/slot_good_arrow.png");
|
||||
const badArrowSprite = Loader.getSprite("sprites/misc/slot_bad_arrow.png");
|
||||
|
||||
// Just ignore this code ...
|
||||
|
||||
if (acceptorComp) {
|
||||
const slots = acceptorComp.slots;
|
||||
for (let acceptorSlotIndex = 0; acceptorSlotIndex < slots.length; ++acceptorSlotIndex) {
|
||||
const slot = slots[acceptorSlotIndex];
|
||||
const acceptorSlotWsTile = staticComp.localTileToWorld(slot.pos);
|
||||
const acceptorSlotWsPos = acceptorSlotWsTile.toWorldSpaceCenterOfTile();
|
||||
|
||||
for (
|
||||
let acceptorDirectionIndex = 0;
|
||||
acceptorDirectionIndex < slot.directions.length;
|
||||
++acceptorDirectionIndex
|
||||
) {
|
||||
const direction = slot.directions[acceptorDirectionIndex];
|
||||
const worldDirection = staticComp.localDirectionToWorld(direction);
|
||||
|
||||
const sourceTile = acceptorSlotWsTile.add(enumDirectionToVector[worldDirection]);
|
||||
const sourceEntity = this.root.map.getTileContent(sourceTile);
|
||||
|
||||
let sprite = goodArrowSprite;
|
||||
let alpha = 0.5;
|
||||
|
||||
if (sourceEntity) {
|
||||
sprite = badArrowSprite;
|
||||
const sourceEjector = sourceEntity.components.ItemEjector;
|
||||
const sourceStaticComp = sourceEntity.components.StaticMapEntity;
|
||||
const ejectorAcceptLocalTile = sourceStaticComp.worldToLocalTile(acceptorSlotWsTile);
|
||||
if (sourceEjector && sourceEjector.anySlotEjectsToLocalTile(ejectorAcceptLocalTile)) {
|
||||
sprite = goodArrowSprite;
|
||||
}
|
||||
alpha = 1.0;
|
||||
}
|
||||
|
||||
parameters.context.globalAlpha = alpha;
|
||||
drawRotatedSprite({
|
||||
parameters,
|
||||
sprite,
|
||||
x: acceptorSlotWsPos.x,
|
||||
y: acceptorSlotWsPos.y,
|
||||
angle: Math_radians(enumDirectionToAngle[enumInvertedDirections[worldDirection]]),
|
||||
size: 13,
|
||||
offsetY: 15,
|
||||
});
|
||||
parameters.context.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ejectorComp) {
|
||||
const slots = ejectorComp.slots;
|
||||
for (let ejectorSlotIndex = 0; ejectorSlotIndex < slots.length; ++ejectorSlotIndex) {
|
||||
const slot = ejectorComp.slots[ejectorSlotIndex];
|
||||
|
||||
const ejectorSlotWsTile = staticComp.localTileToWorld(
|
||||
ejectorComp.getSlotTargetLocalTile(ejectorSlotIndex)
|
||||
);
|
||||
const ejectorSLotWsPos = ejectorSlotWsTile.toWorldSpaceCenterOfTile();
|
||||
const ejectorSlotWsDirection = staticComp.localDirectionToWorld(slot.direction);
|
||||
|
||||
const destEntity = this.root.map.getTileContent(ejectorSlotWsTile);
|
||||
|
||||
let sprite = goodArrowSprite;
|
||||
let alpha = 0.5;
|
||||
if (destEntity) {
|
||||
alpha = 1;
|
||||
const destAcceptor = destEntity.components.ItemAcceptor;
|
||||
const destStaticComp = destEntity.components.StaticMapEntity;
|
||||
|
||||
if (destAcceptor) {
|
||||
const destLocalTile = destStaticComp.worldToLocalTile(ejectorSlotWsTile);
|
||||
const destLocalDir = destStaticComp.worldDirectionToLocal(ejectorSlotWsDirection);
|
||||
if (destAcceptor.findMatchingSlot(destLocalTile, destLocalDir)) {
|
||||
sprite = goodArrowSprite;
|
||||
} else {
|
||||
sprite = badArrowSprite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parameters.context.globalAlpha = alpha;
|
||||
drawRotatedSprite({
|
||||
parameters,
|
||||
sprite,
|
||||
x: ejectorSLotWsPos.x,
|
||||
y: ejectorSLotWsPos.y,
|
||||
angle: Math_radians(enumDirectionToAngle[ejectorSlotWsDirection]),
|
||||
size: 13,
|
||||
offsetY: 15,
|
||||
});
|
||||
parameters.context.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
128
src/js/game/hud/parts/buildings_toolbar.js
Normal file
128
src/js/game/hud/parts/buildings_toolbar.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import { BaseHUDPart } from "../base_hud_part";
|
||||
import { makeDiv } from "../../../core/utils";
|
||||
import { gMetaBuildingRegistry } from "../../../core/global_registries";
|
||||
import { MetaBuilding } from "../../meta_building";
|
||||
import { Signal } from "../../../core/signal";
|
||||
import { MetaSplitterBuilding } from "../../buildings/splitter";
|
||||
import { MetaMinerBuilding } from "../../buildings/miner";
|
||||
import { MetaCutterBuilding } from "../../buildings/cutter";
|
||||
import { MetaRotaterBuilding } from "../../buildings/rotater";
|
||||
import { MetaStackerBuilding } from "../../buildings/stacker";
|
||||
import { MetaMixerBuilding } from "../../buildings/mixer";
|
||||
import { MetaPainterBuilding } from "../../buildings/painter";
|
||||
import { MetaTrashBuilding } from "../../buildings/trash";
|
||||
import { MetaBeltBaseBuilding } from "../../buildings/belt_base";
|
||||
import { MetaUndergroundBeltBuilding } from "../../buildings/underground_belt";
|
||||
import { globalConfig } from "../../../core/config";
|
||||
import { TrackedState } from "../../../core/tracked_state";
|
||||
|
||||
const toolbarBuildings = [
|
||||
MetaBeltBaseBuilding,
|
||||
MetaMinerBuilding,
|
||||
MetaUndergroundBeltBuilding,
|
||||
MetaSplitterBuilding,
|
||||
MetaCutterBuilding,
|
||||
MetaRotaterBuilding,
|
||||
MetaStackerBuilding,
|
||||
MetaMixerBuilding,
|
||||
MetaPainterBuilding,
|
||||
MetaTrashBuilding,
|
||||
];
|
||||
|
||||
export class HUDBuildingsToolbar extends BaseHUDPart {
|
||||
constructor(root) {
|
||||
super(root);
|
||||
|
||||
/** @type {Object.<string, { metaBuilding: MetaBuilding, status: boolean, element: HTMLElement}>} */
|
||||
this.buildingUnlockStates = {};
|
||||
|
||||
this.sigBuildingSelected = new Signal();
|
||||
|
||||
this.trackedIsVisisible = new TrackedState(this.onVisibilityChanged, this);
|
||||
}
|
||||
|
||||
onVisibilityChanged(visible) {
|
||||
this.element.classList.toggle("visible", visible);
|
||||
}
|
||||
|
||||
/**
|
||||
* Should create all require elements
|
||||
* @param {HTMLElement} parent
|
||||
*/
|
||||
createElements(parent) {
|
||||
this.element = makeDiv(parent, "ingame_HUD_buildings_toolbar", [], "");
|
||||
}
|
||||
|
||||
initialize() {
|
||||
const actionMapper = this.root.gameState.keyActionMapper;
|
||||
|
||||
const items = makeDiv(this.element, null, ["buildings"]);
|
||||
const iconSize = 32;
|
||||
|
||||
for (let i = 0; i < toolbarBuildings.length; ++i) {
|
||||
const metaBuilding = gMetaBuildingRegistry.findByClass(toolbarBuildings[i]);
|
||||
const binding = actionMapper.getBinding("building_" + metaBuilding.getId());
|
||||
|
||||
const dimensions = metaBuilding.getDimensions();
|
||||
const itemContainer = makeDiv(items, null, ["building"]);
|
||||
itemContainer.setAttribute("data-tilewidth", dimensions.x);
|
||||
itemContainer.setAttribute("data-tileheight", dimensions.y);
|
||||
|
||||
const label = makeDiv(itemContainer, null, ["label"]);
|
||||
label.innerText = metaBuilding.getName();
|
||||
|
||||
const tooltip = makeDiv(
|
||||
itemContainer,
|
||||
null,
|
||||
["tooltip"],
|
||||
`
|
||||
<span class="title">${metaBuilding.getName()}</span>
|
||||
<span class="desc">${metaBuilding.getDescription()}</span>
|
||||
<span class="tutorialImage" data-icon="building_tutorials/${metaBuilding.getId()}.png"></span>
|
||||
`
|
||||
);
|
||||
|
||||
const sprite = metaBuilding.getPreviewSprite(0);
|
||||
|
||||
const spriteWrapper = makeDiv(itemContainer, null, ["iconWrap"]);
|
||||
spriteWrapper.innerHTML = sprite.getAsHTML(iconSize * dimensions.x, iconSize * dimensions.y);
|
||||
|
||||
binding.appendLabelToElement(itemContainer);
|
||||
binding.add(() => this.selectBuildingForPlacement(metaBuilding));
|
||||
|
||||
this.trackClicks(itemContainer, () => this.selectBuildingForPlacement(metaBuilding), {});
|
||||
|
||||
this.buildingUnlockStates[metaBuilding.id] = {
|
||||
metaBuilding,
|
||||
element: itemContainer,
|
||||
status: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
this.trackedIsVisisible.set(!this.root.camera.getIsMapOverlayActive());
|
||||
|
||||
for (const buildingId in this.buildingUnlockStates) {
|
||||
const handle = this.buildingUnlockStates[buildingId];
|
||||
const newStatus = handle.metaBuilding.getIsUnlocked(this.root);
|
||||
if (handle.status !== newStatus) {
|
||||
handle.status = newStatus;
|
||||
handle.element.classList.toggle("unlocked", newStatus);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {MetaBuilding} metaBuilding
|
||||
*/
|
||||
selectBuildingForPlacement(metaBuilding) {
|
||||
if (!metaBuilding.getIsUnlocked(this.root)) {
|
||||
this.root.soundProxy.playUiError();
|
||||
return;
|
||||
}
|
||||
|
||||
this.sigBuildingSelected.dispatch(metaBuilding);
|
||||
}
|
||||
}
|
||||
37
src/js/game/hud/parts/game_menu.js
Normal file
37
src/js/game/hud/parts/game_menu.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { BaseHUDPart } from "../base_hud_part";
|
||||
import { makeDiv } from "../../../core/utils";
|
||||
|
||||
export class HUDGameMenu extends BaseHUDPart {
|
||||
initialize() {}
|
||||
createElements(parent) {
|
||||
this.element = makeDiv(parent, "ingame_HUD_GameMenu");
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
id: "shop",
|
||||
label: "Upgrades",
|
||||
handler: () => this.root.hud.parts.shop.show(),
|
||||
keybinding: "menu_open_shop",
|
||||
},
|
||||
{
|
||||
id: "stats",
|
||||
label: "Stats",
|
||||
handler: () => null,
|
||||
keybinding: "menu_open_stats",
|
||||
},
|
||||
];
|
||||
|
||||
buttons.forEach(({ id, label, handler, keybinding }) => {
|
||||
const button = document.createElement("button");
|
||||
button.setAttribute("data-button-id", id);
|
||||
this.element.appendChild(button);
|
||||
this.trackClicks(button, handler);
|
||||
|
||||
if (keybinding) {
|
||||
const binding = this.root.gameState.keyActionMapper.getBinding(keybinding);
|
||||
binding.add(handler);
|
||||
binding.appendLabelToElement(button);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
73
src/js/game/hud/parts/keybinding_overlay.js
Normal file
73
src/js/game/hud/parts/keybinding_overlay.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { BaseHUDPart } from "../base_hud_part";
|
||||
import { makeDiv } from "../../../core/utils";
|
||||
import { getStringForKeyCode } from "../../key_action_mapper";
|
||||
import { TrackedState } from "../../../core/tracked_state";
|
||||
|
||||
export class HUDKeybindingOverlay extends BaseHUDPart {
|
||||
initialize() {
|
||||
this.shiftDownTracker = new TrackedState(this.onShiftStateChanged, this);
|
||||
}
|
||||
|
||||
onShiftStateChanged(shiftDown) {
|
||||
this.element.classList.toggle("shiftDown", shiftDown);
|
||||
}
|
||||
|
||||
createElements(parent) {
|
||||
const mapper = this.root.gameState.keyActionMapper;
|
||||
|
||||
const getKeycode = id => {
|
||||
return getStringForKeyCode(mapper.getBinding(id).keyCode);
|
||||
};
|
||||
|
||||
this.element = makeDiv(
|
||||
parent,
|
||||
"ingame_HUD_KeybindingOverlay",
|
||||
[],
|
||||
`
|
||||
<div class="binding">
|
||||
<code class="keybinding">${getKeycode("center_map")}</code>
|
||||
<label>Center</label>
|
||||
</div>
|
||||
|
||||
<div class="binding">
|
||||
<code class="keybinding leftMouse"></code><i></i>
|
||||
<code class="keybinding">${getKeycode("map_move_up")}</code>
|
||||
<code class="keybinding">${getKeycode("map_move_left")}</code>
|
||||
<code class="keybinding">${getKeycode("map_move_down")}</code>
|
||||
<code class="keybinding">${getKeycode("map_move_right")}</code>
|
||||
<label>Move</label>
|
||||
</div>
|
||||
|
||||
<div class="binding noPlacementOnly">
|
||||
<code class="keybinding rightMouse"></code>
|
||||
<label>Delete</label>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="binding placementOnly">
|
||||
<code class="keybinding rightMouse"></code> <i></i>
|
||||
<code class="keybinding">${getKeycode("building_abort_placement")}</code>
|
||||
<label>Stop placement</label>
|
||||
</div>
|
||||
|
||||
<div class="binding placementOnly">
|
||||
<code class="keybinding">${getKeycode("rotate_while_placing")}</code>
|
||||
<label>Rotate Building</label>
|
||||
</div>
|
||||
|
||||
<div class="binding placementOnly shift">
|
||||
<code class="keybinding">SHIFT</code>
|
||||
<label>Place Multiple</label>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
onSelectedBuildingForPlacementChanged(selectedMetaBuilding) {
|
||||
this.element.classList.toggle("placementActive", !!selectedMetaBuilding);
|
||||
}
|
||||
|
||||
update() {
|
||||
this.shiftDownTracker.set(this.root.app.inputMgr.shiftIsDown);
|
||||
}
|
||||
}
|
||||
117
src/js/game/hud/parts/processing_overlay.js
Normal file
117
src/js/game/hud/parts/processing_overlay.js
Normal file
@@ -0,0 +1,117 @@
|
||||
import { DynamicDomAttach } from "../dynamic_dom_attach";
|
||||
import { BaseHUDPart } from "../base_hud_part";
|
||||
import { performanceNow } from "../../../core/builtins";
|
||||
import { makeDiv } from "../../../core/utils";
|
||||
import { Signal } from "../../../core/signal";
|
||||
import { InputReceiver } from "../../../core/input_receiver";
|
||||
import { createLogger } from "../../../core/logging";
|
||||
|
||||
const logger = createLogger("hud/processing_overlay");
|
||||
|
||||
export class HUDProcessingOverlay extends BaseHUDPart {
|
||||
constructor(root) {
|
||||
super(root);
|
||||
this.tasks = [];
|
||||
this.computeTimeout = null;
|
||||
|
||||
this.root.signals.performAsync.add(this.queueTask, this);
|
||||
|
||||
this.allTasksFinished = new Signal();
|
||||
this.inputReceiver = new InputReceiver("processing-overlay");
|
||||
|
||||
this.root.signals.aboutToDestruct.add(() =>
|
||||
this.root.app.inputMgr.destroyReceiver(this.inputReceiver)
|
||||
);
|
||||
}
|
||||
|
||||
createElements(parent) {
|
||||
this.element = makeDiv(
|
||||
parent,
|
||||
"rg_HUD_ProcessingOverlay",
|
||||
["hudElement"],
|
||||
`
|
||||
<span class="prefab_LoadingTextWithAnim">
|
||||
Computing
|
||||
</span>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.domWatcher = new DynamicDomAttach(this.root, this.element, {
|
||||
timeToKeepSeconds: 0,
|
||||
});
|
||||
}
|
||||
|
||||
queueTask(task, name) {
|
||||
if (!this.root.gameInitialized) {
|
||||
// Tasks before the game started can be done directlry
|
||||
task();
|
||||
return;
|
||||
}
|
||||
// if (name) {
|
||||
// console.warn("QUEUE", name);
|
||||
// }
|
||||
|
||||
task.__name = name;
|
||||
this.tasks.push(task);
|
||||
}
|
||||
|
||||
hasTasks() {
|
||||
return this.tasks.length > 0;
|
||||
}
|
||||
|
||||
isRunning() {
|
||||
return this.computeTimeout !== null;
|
||||
}
|
||||
|
||||
processSync() {
|
||||
const now = performanceNow();
|
||||
while (this.tasks.length > 0) {
|
||||
const workload = this.tasks[0];
|
||||
workload.call();
|
||||
this.tasks.shift();
|
||||
}
|
||||
const duration = performanceNow() - now;
|
||||
if (duration > 100) {
|
||||
logger.log("Tasks done slow (SYNC!) within", (performanceNow() - now).toFixed(2), "ms");
|
||||
}
|
||||
}
|
||||
|
||||
process() {
|
||||
this.root.app.inputMgr.makeSureAttachedAndOnTop(this.inputReceiver);
|
||||
|
||||
this.domWatcher.update(true);
|
||||
if (this.tasks.length === 0) {
|
||||
logger.warn("No tasks but still called process");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.computeTimeout) {
|
||||
assert(false, "Double compute queued");
|
||||
clearTimeout(this.computeTimeout);
|
||||
}
|
||||
|
||||
this.computeTimeout = setTimeout(() => {
|
||||
const now = performanceNow();
|
||||
while (this.tasks.length > 0) {
|
||||
const workload = this.tasks[0];
|
||||
workload.call();
|
||||
this.tasks.shift();
|
||||
}
|
||||
const duration = performanceNow() - now;
|
||||
if (duration > 100) {
|
||||
logger.log("Tasks done slow within", (performanceNow() - now).toFixed(2), "ms");
|
||||
}
|
||||
|
||||
this.domWatcher.update(false);
|
||||
|
||||
this.root.app.inputMgr.makeSureDetached(this.inputReceiver);
|
||||
|
||||
clearTimeout(this.computeTimeout);
|
||||
this.computeTimeout = null;
|
||||
|
||||
this.allTasksFinished.dispatch();
|
||||
});
|
||||
}
|
||||
}
|
||||
181
src/js/game/hud/parts/shop.js
Normal file
181
src/js/game/hud/parts/shop.js
Normal file
@@ -0,0 +1,181 @@
|
||||
import { BaseHUDPart } from "../base_hud_part";
|
||||
import { makeDiv, removeAllChildren, formatBigNumber } from "../../../core/utils";
|
||||
import { UPGRADES, TIER_LABELS } from "../../upgrades";
|
||||
import { ShapeDefinition } from "../../shape_definition";
|
||||
import { DynamicDomAttach } from "../dynamic_dom_attach";
|
||||
import { InputReceiver } from "../../../core/input_receiver";
|
||||
import { KeyActionMapper } from "../../key_action_mapper";
|
||||
import { Math_min } from "../../../core/builtins";
|
||||
|
||||
export class HUDShop extends BaseHUDPart {
|
||||
createElements(parent) {
|
||||
this.background = makeDiv(parent, "ingame_HUD_Shop", ["ingameDialog"]);
|
||||
|
||||
// DIALOG Inner / Wrapper
|
||||
this.dialogInner = makeDiv(this.background, null, ["dialogInner"]);
|
||||
this.title = makeDiv(this.dialogInner, null, ["title"], `Upgrades`);
|
||||
this.closeButton = makeDiv(this.title, null, ["closeButton"]);
|
||||
this.trackClicks(this.closeButton, this.close);
|
||||
this.contentDiv = makeDiv(this.dialogInner, null, ["content"]);
|
||||
|
||||
this.upgradeToElements = {};
|
||||
|
||||
// Upgrades
|
||||
for (const upgradeId in UPGRADES) {
|
||||
const { label } = UPGRADES[upgradeId];
|
||||
const handle = {};
|
||||
handle.requireIndexToElement = [];
|
||||
|
||||
// Wrapper
|
||||
handle.elem = makeDiv(this.contentDiv, null, ["upgrade"]);
|
||||
handle.elem.setAttribute("data-upgrade-id", upgradeId);
|
||||
|
||||
// Title
|
||||
const title = makeDiv(handle.elem, null, ["title"], label);
|
||||
|
||||
// Title > Tier
|
||||
handle.elemTierLabel = makeDiv(title, null, ["tier"], "Tier ?");
|
||||
|
||||
// Icon
|
||||
handle.icon = makeDiv(handle.elem, null, ["icon"]);
|
||||
handle.icon.setAttribute("data-icon", "upgrades/" + upgradeId + ".png");
|
||||
|
||||
// Description
|
||||
handle.elemDescription = makeDiv(handle.elem, null, ["description"], "??");
|
||||
handle.elemRequirements = makeDiv(handle.elem, null, ["requirements"]);
|
||||
|
||||
// Buy button
|
||||
handle.buyButton = document.createElement("button");
|
||||
handle.buyButton.classList.add("buy", "styledButton");
|
||||
handle.buyButton.innerText = "Upgrade";
|
||||
handle.elem.appendChild(handle.buyButton);
|
||||
|
||||
this.trackClicks(handle.buyButton, () => this.tryUnlockNextTier(upgradeId));
|
||||
|
||||
// Assign handle
|
||||
this.upgradeToElements[upgradeId] = handle;
|
||||
}
|
||||
}
|
||||
|
||||
rerenderFull() {
|
||||
for (const upgradeId in this.upgradeToElements) {
|
||||
const handle = this.upgradeToElements[upgradeId];
|
||||
const { description, tiers } = UPGRADES[upgradeId];
|
||||
// removeAllChildren(handle.elem);
|
||||
|
||||
const currentTier = this.root.hubGoals.getUpgradeLevel(upgradeId);
|
||||
const tierHandle = tiers[currentTier];
|
||||
|
||||
// Set tier
|
||||
handle.elemTierLabel.innerText = "Tier " + TIER_LABELS[currentTier];
|
||||
handle.elemTierLabel.setAttribute("data-tier", currentTier);
|
||||
|
||||
// Cleanup
|
||||
handle.requireIndexToElement = [];
|
||||
removeAllChildren(handle.elemRequirements);
|
||||
|
||||
handle.elem.classList.toggle("maxLevel", !tierHandle);
|
||||
|
||||
if (!tierHandle) {
|
||||
// Max level
|
||||
handle.elemDescription.innerText = "Maximum level";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Set description
|
||||
handle.elemDescription.innerText = description(tierHandle.improvement);
|
||||
|
||||
tierHandle.required.forEach(({ shape, amount }) => {
|
||||
const requireDiv = makeDiv(handle.elemRequirements, null, ["requirement"]);
|
||||
|
||||
const shapeDef = this.root.shapeDefinitionMgr.registerOrReturnHandle(
|
||||
ShapeDefinition.fromShortKey(shape)
|
||||
);
|
||||
const shapeCanvas = shapeDef.generateAsCanvas(120);
|
||||
shapeCanvas.classList.add();
|
||||
requireDiv.appendChild(shapeCanvas);
|
||||
|
||||
const progressContainer = makeDiv(requireDiv, null, ["amount"]);
|
||||
const progressBar = document.createElement("label");
|
||||
progressBar.classList.add("progressBar");
|
||||
progressContainer.appendChild(progressBar);
|
||||
|
||||
const progressLabel = document.createElement("label");
|
||||
progressContainer.appendChild(progressLabel);
|
||||
|
||||
handle.requireIndexToElement.push({
|
||||
progressLabel,
|
||||
progressBar,
|
||||
definition: shapeDef,
|
||||
required: amount,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
renderCountsAndStatus() {
|
||||
for (const upgradeId in this.upgradeToElements) {
|
||||
const handle = this.upgradeToElements[upgradeId];
|
||||
for (let i = 0; i < handle.requireIndexToElement.length; ++i) {
|
||||
const { progressLabel, progressBar, definition, required } = handle.requireIndexToElement[i];
|
||||
|
||||
const haveAmount = this.root.hubGoals.getShapesStored(definition);
|
||||
const progress = Math_min(haveAmount / required, 1.0);
|
||||
|
||||
progressLabel.innerText = formatBigNumber(haveAmount) + " / " + formatBigNumber(required);
|
||||
progressBar.style.width = progress * 100.0 + "%";
|
||||
progressBar.classList.toggle("complete", progress >= 1.0);
|
||||
}
|
||||
|
||||
handle.buyButton.classList.toggle("buyable", this.root.hubGoals.canUnlockUpgrade(upgradeId));
|
||||
}
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.domAttach = new DynamicDomAttach(this.root, this.background, {
|
||||
attachClass: "visible",
|
||||
});
|
||||
|
||||
this.inputReciever = new InputReceiver("shop");
|
||||
this.keyActionMapper = new KeyActionMapper(this.root, this.inputReciever);
|
||||
|
||||
this.keyActionMapper.getBinding("back").add(this.close, this);
|
||||
this.keyActionMapper.getBinding("menu_open_shop").add(this.close, this);
|
||||
|
||||
this.close();
|
||||
|
||||
this.rerenderFull();
|
||||
this.root.signals.upgradePurchased.add(this.rerenderFull, this);
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
document.body.classList.remove("ingameDialogOpen");
|
||||
}
|
||||
|
||||
show() {
|
||||
this.visible = true;
|
||||
document.body.classList.add("ingameDialogOpen");
|
||||
// this.background.classList.add("visible");
|
||||
this.root.app.inputMgr.makeSureAttachedAndOnTop(this.inputReciever);
|
||||
this.update();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.visible = false;
|
||||
document.body.classList.remove("ingameDialogOpen");
|
||||
this.root.app.inputMgr.makeSureDetached(this.inputReciever);
|
||||
this.update();
|
||||
}
|
||||
|
||||
update() {
|
||||
this.domAttach.update(this.visible);
|
||||
if (this.visible) {
|
||||
this.renderCountsAndStatus();
|
||||
}
|
||||
}
|
||||
|
||||
tryUnlockNextTier(upgradeId) {
|
||||
// Nothing
|
||||
this.root.hubGoals.tryUnlockUgprade(upgradeId);
|
||||
}
|
||||
}
|
||||
122
src/js/game/hud/parts/unlock_notification.js
Normal file
122
src/js/game/hud/parts/unlock_notification.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import { BaseHUDPart } from "../base_hud_part";
|
||||
import { makeDiv } from "../../../core/utils";
|
||||
import { DynamicDomAttach } from "../dynamic_dom_attach";
|
||||
import { gMetaBuildingRegistry } from "../../../core/global_registries";
|
||||
import { MetaBuilding } from "../../meta_building";
|
||||
import { MetaSplitterBuilding } from "../../buildings/splitter";
|
||||
import { MetaCutterBuilding } from "../../buildings/cutter";
|
||||
import { enumHubGoalRewards } from "../../tutorial_goals";
|
||||
import { MetaTrashBuilding } from "../../buildings/trash";
|
||||
import { MetaMinerBuilding } from "../../buildings/miner";
|
||||
import { MetaPainterBuilding } from "../../buildings/painter";
|
||||
import { MetaMixerBuilding } from "../../buildings/mixer";
|
||||
import { MetaRotaterBuilding } from "../../buildings/rotater";
|
||||
import { MetaStackerBuilding } from "../../buildings/stacker";
|
||||
import { MetaUndergroundBeltBuilding } from "../../buildings/underground_belt";
|
||||
import { globalConfig } from "../../../core/config";
|
||||
|
||||
export class HUDUnlockNotification extends BaseHUDPart {
|
||||
initialize() {
|
||||
this.visible = false;
|
||||
|
||||
this.domAttach = new DynamicDomAttach(this.root, this.element, {
|
||||
timeToKeepSeconds: 0,
|
||||
});
|
||||
|
||||
if (!(G_IS_DEV && globalConfig.debug.disableUnlockDialog)) {
|
||||
this.root.signals.storyGoalCompleted.add(this.showForLevel, this);
|
||||
}
|
||||
}
|
||||
|
||||
shouldPauseGame() {
|
||||
return this.visible;
|
||||
}
|
||||
|
||||
createElements(parent) {
|
||||
this.element = makeDiv(parent, "ingame_HUD_UnlockNotification", []);
|
||||
|
||||
const dialog = makeDiv(this.element, null, ["dialog"]);
|
||||
|
||||
this.elemTitle = makeDiv(dialog, null, ["title"], ``);
|
||||
this.elemSubTitle = makeDiv(dialog, null, ["subTitle"], `Completed`);
|
||||
|
||||
this.elemContents = makeDiv(
|
||||
dialog,
|
||||
null,
|
||||
["contents"],
|
||||
`
|
||||
Ready for the next one?
|
||||
`
|
||||
);
|
||||
|
||||
this.btnClose = document.createElement("button");
|
||||
this.btnClose.classList.add("close", "styledButton");
|
||||
this.btnClose.innerText = "Next level";
|
||||
dialog.appendChild(this.btnClose);
|
||||
|
||||
this.trackClicks(this.btnClose, this.close);
|
||||
}
|
||||
|
||||
showForLevel(level, reward) {
|
||||
this.elemTitle.innerText = "Level " + ("" + level).padStart(2, "0");
|
||||
|
||||
let html = `<span class='reward'>Unlocked ${reward}!</span>`;
|
||||
|
||||
const addBuildingExplanation = metaBuildingClass => {
|
||||
const metaBuilding = gMetaBuildingRegistry.findByClass(metaBuildingClass);
|
||||
html += `<div class="buildingExplanation" data-icon="building_tutorials/${metaBuilding.getId()}.png"></div>`;
|
||||
};
|
||||
|
||||
switch (reward) {
|
||||
case enumHubGoalRewards.reward_cutter_and_trash: {
|
||||
addBuildingExplanation(MetaCutterBuilding);
|
||||
addBuildingExplanation(MetaTrashBuilding);
|
||||
break;
|
||||
}
|
||||
case enumHubGoalRewards.reward_mixer: {
|
||||
addBuildingExplanation(MetaMixerBuilding);
|
||||
break;
|
||||
}
|
||||
|
||||
case enumHubGoalRewards.reward_painter: {
|
||||
addBuildingExplanation(MetaPainterBuilding);
|
||||
break;
|
||||
}
|
||||
|
||||
case enumHubGoalRewards.reward_rotater: {
|
||||
addBuildingExplanation(MetaRotaterBuilding);
|
||||
break;
|
||||
}
|
||||
|
||||
case enumHubGoalRewards.reward_splitter: {
|
||||
addBuildingExplanation(MetaSplitterBuilding);
|
||||
break;
|
||||
}
|
||||
|
||||
case enumHubGoalRewards.reward_stacker: {
|
||||
addBuildingExplanation(MetaStackerBuilding);
|
||||
break;
|
||||
}
|
||||
|
||||
case enumHubGoalRewards.reward_tunnel: {
|
||||
addBuildingExplanation(MetaUndergroundBeltBuilding);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// addBuildingExplanation(MetaSplitterBuilding);
|
||||
// addBuildingExplanation(MetaCutterBuilding);
|
||||
|
||||
this.elemContents.innerHTML = html;
|
||||
|
||||
this.visible = true;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.visible = false;
|
||||
}
|
||||
|
||||
update() {
|
||||
this.domAttach.update(this.visible);
|
||||
}
|
||||
}
|
||||
6
src/js/game/item_registry.js
Normal file
6
src/js/game/item_registry.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { gItemRegistry } from "../core/global_registries";
|
||||
import { ShapeItem } from "./items/shape_item";
|
||||
|
||||
export function initItemRegistry() {
|
||||
gItemRegistry.register(ShapeItem);
|
||||
}
|
||||
90
src/js/game/items/color_item.js
Normal file
90
src/js/game/items/color_item.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { DrawParameters } from "../../core/draw_parameters";
|
||||
import { createLogger } from "../../core/logging";
|
||||
import { extendSchema } from "../../savegame/serialization";
|
||||
import { BaseItem } from "../base_item";
|
||||
import { enumColorsToHexCode, enumColors } from "../colors";
|
||||
import { makeOffscreenBuffer } from "../../core/buffer_utils";
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { round1Digit } from "../../core/utils";
|
||||
import { Math_max, Math_round } from "../../core/builtins";
|
||||
import { smoothenDpi } from "../../core/dpi_manager";
|
||||
|
||||
/** @enum {string} */
|
||||
const enumColorToMapBackground = {
|
||||
[enumColors.red]: "#ffbfc1",
|
||||
[enumColors.green]: "#cbffc4",
|
||||
[enumColors.blue]: "#bfdaff",
|
||||
};
|
||||
|
||||
export class ColorItem extends BaseItem {
|
||||
static getId() {
|
||||
return "color";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return extendSchema(BaseItem.getCachedSchema(), {
|
||||
// TODO
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} color
|
||||
*/
|
||||
constructor(color) {
|
||||
super();
|
||||
this.color = color;
|
||||
|
||||
this.bufferGenerator = this.internalGenerateColorBuffer.bind(this);
|
||||
}
|
||||
|
||||
getBackgroundColorAsResource() {
|
||||
return enumColorToMapBackground[this.color];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} size
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
draw(x, y, parameters, size = 12) {
|
||||
const dpi = smoothenDpi(globalConfig.shapesSharpness * parameters.zoomLevel);
|
||||
|
||||
const key = size + "/" + dpi;
|
||||
const canvas = parameters.root.buffers.getForKey(
|
||||
key,
|
||||
this.color,
|
||||
size,
|
||||
size,
|
||||
dpi,
|
||||
this.bufferGenerator
|
||||
);
|
||||
parameters.context.drawImage(canvas, x - size / 2, y - size / 2, size, size);
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
* @param {number} dpi
|
||||
*/
|
||||
internalGenerateColorBuffer(canvas, context, w, h, dpi) {
|
||||
context.translate((w * dpi) / 2, (h * dpi) / 2);
|
||||
context.scale((dpi * w) / 12, (dpi * h) / 12);
|
||||
|
||||
context.fillStyle = enumColorsToHexCode[this.color];
|
||||
context.strokeStyle = "rgba(100,102, 110, 1)";
|
||||
context.lineWidth = 2;
|
||||
context.beginCircle(2, -1, 3);
|
||||
context.stroke();
|
||||
context.fill();
|
||||
context.beginCircle(-2, -1, 3);
|
||||
context.stroke();
|
||||
context.fill();
|
||||
context.beginCircle(0, 2, 3);
|
||||
context.closePath();
|
||||
context.stroke();
|
||||
context.fill();
|
||||
}
|
||||
}
|
||||
42
src/js/game/items/shape_item.js
Normal file
42
src/js/game/items/shape_item.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { BaseItem } from "../base_item";
|
||||
import { DrawParameters } from "../../core/draw_parameters";
|
||||
import { extendSchema } from "../../savegame/serialization";
|
||||
import { ShapeDefinition } from "../shape_definition";
|
||||
import { createLogger } from "../../core/logging";
|
||||
|
||||
const logger = createLogger("shape_item");
|
||||
|
||||
export class ShapeItem extends BaseItem {
|
||||
static getId() {
|
||||
return "shape";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return extendSchema(BaseItem.getCachedSchema(), {
|
||||
// TODO
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ShapeDefinition} definition
|
||||
*/
|
||||
constructor(definition) {
|
||||
super();
|
||||
// logger.log("New shape item for shape definition", definition.generateId(), "created");
|
||||
|
||||
/**
|
||||
* This property must not be modified on runtime, you have to clone the class in order to change the definition
|
||||
*/
|
||||
this.definition = definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {number=} size
|
||||
*/
|
||||
draw(x, y, parameters, size) {
|
||||
this.definition.draw(x, y, parameters, size);
|
||||
}
|
||||
}
|
||||
383
src/js/game/key_action_mapper.js
Normal file
383
src/js/game/key_action_mapper.js
Normal file
@@ -0,0 +1,383 @@
|
||||
/* typehints:start */
|
||||
import { GameRoot } from "./root";
|
||||
import { InputReceiver } from "../core/input_receiver";
|
||||
import { Application } from "../application";
|
||||
/* typehints:end */
|
||||
|
||||
import { Signal, STOP_PROPAGATION } from "../core/signal";
|
||||
import { IS_MOBILE } from "../core/config";
|
||||
|
||||
function key(str) {
|
||||
return str.toUpperCase().charCodeAt(0);
|
||||
}
|
||||
|
||||
// TODO: Configurable
|
||||
export const defaultKeybindings = {
|
||||
general: {
|
||||
confirm: { keyCode: 13 }, // enter
|
||||
back: { keyCode: 27, builtin: true }, // escape
|
||||
},
|
||||
|
||||
ingame: {
|
||||
map_move_up: { keyCode: key("W") },
|
||||
map_move_right: { keyCode: key("D") },
|
||||
map_move_down: { keyCode: key("S") },
|
||||
map_move_left: { keyCode: key("A") },
|
||||
toggle_hud: { keyCode: 113 },
|
||||
|
||||
center_map: { keyCode: 32 },
|
||||
|
||||
menu_open_shop: { keyCode: key("F") },
|
||||
menu_open_stats: { keyCode: key("G") },
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
building_belt: { keyCode: key("1") },
|
||||
building_miner: { keyCode: key("2") },
|
||||
building_underground_belt: { keyCode: key("3") },
|
||||
building_splitter: { keyCode: key("4") },
|
||||
building_cutter: { keyCode: key("5") },
|
||||
building_rotater: { keyCode: key("6") },
|
||||
building_stacker: { keyCode: key("7") },
|
||||
building_mixer: { keyCode: key("8") },
|
||||
building_painter: { keyCode: key("9") },
|
||||
building_trash: { keyCode: key("0") },
|
||||
|
||||
building_abort_placement: { keyCode: key("Q") },
|
||||
|
||||
rotate_while_placing: { keyCode: key("R") },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a keycode -> string
|
||||
* @param {number} code
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getStringForKeyCode(code) {
|
||||
switch (code) {
|
||||
case 8:
|
||||
return "⌫";
|
||||
case 9:
|
||||
return "TAB";
|
||||
case 13:
|
||||
return "⏎";
|
||||
case 16:
|
||||
return "⇪";
|
||||
case 17:
|
||||
return "CTRL";
|
||||
case 18:
|
||||
return "ALT";
|
||||
case 19:
|
||||
return "PAUSE";
|
||||
case 20:
|
||||
return "CAPS";
|
||||
case 27:
|
||||
return "ESC";
|
||||
case 32:
|
||||
return "SPACE";
|
||||
case 33:
|
||||
return "PGUP";
|
||||
case 34:
|
||||
return "PGDOWN";
|
||||
case 35:
|
||||
return "END";
|
||||
case 36:
|
||||
return "HOME";
|
||||
case 37:
|
||||
return "⬅";
|
||||
case 38:
|
||||
return "⬆";
|
||||
case 39:
|
||||
return "➡";
|
||||
case 40:
|
||||
return "⬇";
|
||||
case 44:
|
||||
return "PRNT";
|
||||
case 45:
|
||||
return "INS";
|
||||
case 46:
|
||||
return "DEL";
|
||||
case 93:
|
||||
return "SEL";
|
||||
case 96:
|
||||
return "NUM 0";
|
||||
case 97:
|
||||
return "NUM 1";
|
||||
case 98:
|
||||
return "NUM 2";
|
||||
case 99:
|
||||
return "NUM 3";
|
||||
case 100:
|
||||
return "NUM 4";
|
||||
case 101:
|
||||
return "NUM 5";
|
||||
case 102:
|
||||
return "NUM 6";
|
||||
case 103:
|
||||
return "NUM 7";
|
||||
case 104:
|
||||
return "NUM 8";
|
||||
case 105:
|
||||
return "NUM 9";
|
||||
case 106:
|
||||
return "*";
|
||||
case 107:
|
||||
return "+";
|
||||
case 109:
|
||||
return "-";
|
||||
case 110:
|
||||
return ".";
|
||||
case 111:
|
||||
return "/";
|
||||
case 112:
|
||||
return "F1";
|
||||
case 113:
|
||||
return "F2";
|
||||
case 114:
|
||||
return "F3";
|
||||
case 115:
|
||||
return "F4";
|
||||
case 116:
|
||||
return "F4";
|
||||
case 117:
|
||||
return "F5";
|
||||
case 118:
|
||||
return "F6";
|
||||
case 119:
|
||||
return "F7";
|
||||
case 120:
|
||||
return "F8";
|
||||
case 121:
|
||||
return "F9";
|
||||
case 122:
|
||||
return "F10";
|
||||
case 123:
|
||||
return "F11";
|
||||
case 124:
|
||||
return "F12";
|
||||
|
||||
case 144:
|
||||
return "NUMLOCK";
|
||||
case 145:
|
||||
return "SCRLOCK";
|
||||
case 182:
|
||||
return "COMP";
|
||||
case 183:
|
||||
return "CALC";
|
||||
case 186:
|
||||
return ";";
|
||||
case 187:
|
||||
return "=";
|
||||
case 188:
|
||||
return ",";
|
||||
case 189:
|
||||
return "-";
|
||||
case 189:
|
||||
return ".";
|
||||
case 191:
|
||||
return "/";
|
||||
case 219:
|
||||
return "[";
|
||||
case 220:
|
||||
return "\\";
|
||||
case 221:
|
||||
return "]";
|
||||
case 222:
|
||||
return "'";
|
||||
}
|
||||
|
||||
// TODO
|
||||
return String.fromCharCode(code);
|
||||
}
|
||||
|
||||
export class Keybinding {
|
||||
/**
|
||||
*
|
||||
* @param {Application} app
|
||||
* @param {object} param0
|
||||
* @param {number} param0.keyCode
|
||||
* @param {boolean=} param0.builtin
|
||||
*/
|
||||
constructor(app, { keyCode, builtin = false }) {
|
||||
assert(keyCode && Number.isInteger(keyCode), "Invalid key code: " + keyCode);
|
||||
this.app = app;
|
||||
this.keyCode = keyCode;
|
||||
this.builtin = builtin;
|
||||
|
||||
this.currentlyDown = false;
|
||||
|
||||
this.signal = new Signal();
|
||||
this.toggled = new Signal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an event listener
|
||||
* @param {function() : void} receiver
|
||||
* @param {object=} scope
|
||||
*/
|
||||
add(receiver, scope = null) {
|
||||
this.signal.add(receiver, scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} elem
|
||||
* @returns {HTMLElement} the created element, or null if the keybindings are not shown
|
||||
* */
|
||||
appendLabelToElement(elem) {
|
||||
if (IS_MOBILE) {
|
||||
return null;
|
||||
}
|
||||
const spacer = document.createElement("code");
|
||||
spacer.classList.add("keybinding");
|
||||
spacer.innerHTML = getStringForKeyCode(this.keyCode);
|
||||
elem.appendChild(spacer);
|
||||
return spacer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the key code as a nice string
|
||||
*/
|
||||
getKeyCodeString() {
|
||||
return getStringForKeyCode(this.keyCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remvoes all signal receivers
|
||||
*/
|
||||
clearSignalReceivers() {
|
||||
this.signal.removeAll();
|
||||
}
|
||||
}
|
||||
|
||||
export class KeyActionMapper {
|
||||
/**
|
||||
*
|
||||
* @param {GameRoot} root
|
||||
* @param {InputReceiver} inputReciever
|
||||
*/
|
||||
constructor(root, inputReciever) {
|
||||
this.root = root;
|
||||
inputReciever.keydown.add(this.handleKeydown, this);
|
||||
inputReciever.keyup.add(this.handleKeyup, this);
|
||||
|
||||
/** @type {Object.<string, Keybinding>} */
|
||||
this.keybindings = {};
|
||||
|
||||
// const overrides = root.app.settings.getKeybindingOverrides();
|
||||
|
||||
for (const category in defaultKeybindings) {
|
||||
for (const key in defaultKeybindings[category]) {
|
||||
let payload = Object.assign({}, defaultKeybindings[category][key]);
|
||||
// if (overrides[key]) {
|
||||
// payload.keyCode = overrides[key];
|
||||
// }
|
||||
|
||||
this.keybindings[key] = new Keybinding(this.root.app, payload);
|
||||
}
|
||||
}
|
||||
|
||||
inputReciever.pageBlur.add(this.onPageBlur, this);
|
||||
inputReciever.destroyed.add(this.cleanup, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all keybindings starting with the given id
|
||||
* @param {string} pattern
|
||||
* @returns {Array<Keybinding>}
|
||||
*/
|
||||
getKeybindingsStartingWith(pattern) {
|
||||
let result = [];
|
||||
for (const key in this.keybindings) {
|
||||
if (key.startsWith(pattern)) {
|
||||
result.push(this.keybindings[key]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forwards the given events to the other mapper (used in tooltips)
|
||||
* @param {KeyActionMapper} receiver
|
||||
* @param {Array<string>} bindings
|
||||
*/
|
||||
forward(receiver, bindings) {
|
||||
for (let i = 0; i < bindings.length; ++i) {
|
||||
const key = bindings[i];
|
||||
this.keybindings[key].signal.add((...args) => receiver.keybindings[key].signal.dispatch(...args));
|
||||
}
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
for (const key in this.keybindings) {
|
||||
this.keybindings[key].signal.removeAll();
|
||||
}
|
||||
}
|
||||
|
||||
onPageBlur() {
|
||||
// Reset all down states
|
||||
// Find mapping
|
||||
for (const key in this.keybindings) {
|
||||
/** @type {Keybinding} */
|
||||
const binding = this.keybindings[key];
|
||||
binding.currentlyDown = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal keydown handler
|
||||
* @param {object} param0
|
||||
* @param {number} param0.keyCode
|
||||
* @param {boolean} param0.shift
|
||||
* @param {boolean} param0.alt
|
||||
*/
|
||||
handleKeydown({ keyCode, shift, alt }) {
|
||||
let stop = false;
|
||||
|
||||
// Find mapping
|
||||
for (const key in this.keybindings) {
|
||||
/** @type {Keybinding} */
|
||||
const binding = this.keybindings[key];
|
||||
if (binding.keyCode === keyCode /* && binding.shift === shift && binding.alt === alt */) {
|
||||
binding.currentlyDown = true;
|
||||
|
||||
/** @type {Signal} */
|
||||
const signal = this.keybindings[key].signal;
|
||||
if (signal.dispatch() === STOP_PROPAGATION) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (stop) {
|
||||
return STOP_PROPAGATION;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal keyup handler
|
||||
* @param {object} param0
|
||||
* @param {number} param0.keyCode
|
||||
* @param {boolean} param0.shift
|
||||
* @param {boolean} param0.alt
|
||||
*/
|
||||
handleKeyup({ keyCode, shift, alt }) {
|
||||
for (const key in this.keybindings) {
|
||||
/** @type {Keybinding} */
|
||||
const binding = this.keybindings[key];
|
||||
if (binding.keyCode === keyCode) {
|
||||
binding.currentlyDown = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a given keybinding
|
||||
* @param {string} id
|
||||
* @returns {Keybinding}
|
||||
*/
|
||||
getBinding(id) {
|
||||
assert(this.keybindings[id], "Keybinding " + id + " not known!");
|
||||
return this.keybindings[id];
|
||||
}
|
||||
}
|
||||
209
src/js/game/logic.js
Normal file
209
src/js/game/logic.js
Normal file
@@ -0,0 +1,209 @@
|
||||
import { GameRoot } from "./root";
|
||||
import { Entity } from "./entity";
|
||||
import { Vector, enumDirectionToVector, enumDirection } from "../core/vector";
|
||||
import { MetaBuilding } from "./meta_building";
|
||||
import { StaticMapEntityComponent } from "./components/static_map_entity";
|
||||
import { Math_abs } from "../core/builtins";
|
||||
import { Rectangle } from "../core/rectangle";
|
||||
import { createLogger } from "../core/logging";
|
||||
|
||||
const logger = createLogger("ingame/logic");
|
||||
|
||||
/**
|
||||
* Typing helper
|
||||
* @typedef {Array<{
|
||||
* entity: Entity,
|
||||
* slot: import("./components/item_ejector").ItemEjectorSlot,
|
||||
* fromTile: Vector,
|
||||
* toDirection: enumDirection
|
||||
* }>} EjectorsAffectingTile
|
||||
*/
|
||||
|
||||
/**
|
||||
* Typing helper
|
||||
* @typedef {Array<{
|
||||
* entity: Entity,
|
||||
* slot: import("./components/item_acceptor").ItemAcceptorSlot,
|
||||
* toTile: Vector,
|
||||
* fromDirection: enumDirection
|
||||
* }>} AcceptorsAffectingTile
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* acceptors: AcceptorsAffectingTile,
|
||||
* ejectors: EjectorsAffectingTile
|
||||
* }} AcceptorsAndEjectorsAffectingTile
|
||||
*/
|
||||
|
||||
export class GameLogic {
|
||||
/**
|
||||
*
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Vector} origin
|
||||
* @param {number} rotation
|
||||
* @param {MetaBuilding} building
|
||||
*/
|
||||
isAreaFreeToBuild(origin, rotation, building) {
|
||||
const checker = new StaticMapEntityComponent({
|
||||
origin,
|
||||
tileSize: building.getDimensions(),
|
||||
rotationDegrees: rotation,
|
||||
});
|
||||
|
||||
const rect = checker.getTileSpaceBounds();
|
||||
|
||||
for (let x = rect.x; x < rect.x + rect.w; ++x) {
|
||||
for (let y = rect.y; y < rect.y + rect.h; ++y) {
|
||||
const contents = this.root.map.getTileContentXY(x, y);
|
||||
if (contents && !contents.components.ReplaceableMapEntity) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Vector} origin
|
||||
* @param {number} rotation
|
||||
* @param {MetaBuilding} building
|
||||
*/
|
||||
checkCanPlaceBuilding(origin, rotation, building) {
|
||||
if (!building.getIsUnlocked(this.root)) {
|
||||
return false;
|
||||
}
|
||||
return this.isAreaFreeToBuild(origin, rotation, building);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {Vector} param0.origin
|
||||
* @param {number} param0.rotation
|
||||
* @param {number} param0.rotationVariant
|
||||
* @param {MetaBuilding} param0.building
|
||||
*/
|
||||
tryPlaceBuilding({ origin, rotation, rotationVariant, building }) {
|
||||
if (this.checkCanPlaceBuilding(origin, rotation, building)) {
|
||||
// Remove any removeable entities below
|
||||
const checker = new StaticMapEntityComponent({
|
||||
origin,
|
||||
tileSize: building.getDimensions(),
|
||||
rotationDegrees: rotation,
|
||||
});
|
||||
|
||||
const rect = checker.getTileSpaceBounds();
|
||||
|
||||
for (let x = rect.x; x < rect.x + rect.w; ++x) {
|
||||
for (let y = rect.y; y < rect.y + rect.h; ++y) {
|
||||
const contents = this.root.map.getTileContentXY(x, y);
|
||||
if (contents && contents.components.ReplaceableMapEntity) {
|
||||
if (!this.tryDeleteBuilding(contents)) {
|
||||
logger.error("Building has replaceable component but is also unremovable");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
building.createAndPlaceEntity(this.root, origin, rotation, rotationVariant);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given building can get removed
|
||||
* @param {Entity} building
|
||||
*/
|
||||
canDeleteBuilding(building) {
|
||||
return building.components.StaticMapEntity && !building.components.Unremovable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to delete the given building
|
||||
* @param {Entity} building
|
||||
*/
|
||||
tryDeleteBuilding(building) {
|
||||
if (!this.canDeleteBuilding(building)) {
|
||||
return false;
|
||||
}
|
||||
this.root.map.removeStaticEntity(building);
|
||||
this.root.entityMgr.destroyEntity(building);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the acceptors and ejectors which affect the current tile
|
||||
* @param {Vector} tile
|
||||
* @returns {AcceptorsAndEjectorsAffectingTile}
|
||||
*/
|
||||
getEjectorsAndAcceptorsAtTile(tile) {
|
||||
/** @type {EjectorsAffectingTile} */
|
||||
let ejectors = [];
|
||||
/** @type {AcceptorsAffectingTile} */
|
||||
let acceptors = [];
|
||||
|
||||
for (let dx = -1; dx <= 1; ++dx) {
|
||||
for (let dy = -1; dy <= 1; ++dy) {
|
||||
if (Math_abs(dx) + Math_abs(dy) !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entity = this.root.map.getTileContentXY(tile.x + dx, tile.y + dy);
|
||||
if (entity) {
|
||||
const staticComp = entity.components.StaticMapEntity;
|
||||
const itemEjector = entity.components.ItemEjector;
|
||||
if (itemEjector) {
|
||||
for (let ejectorSlot = 0; ejectorSlot < itemEjector.slots.length; ++ejectorSlot) {
|
||||
const slot = itemEjector.slots[ejectorSlot];
|
||||
const wsTile = staticComp.localTileToWorld(slot.pos);
|
||||
const wsDirection = staticComp.localDirectionToWorld(slot.direction);
|
||||
const targetTile = wsTile.add(enumDirectionToVector[wsDirection]);
|
||||
if (targetTile.equals(tile)) {
|
||||
ejectors.push({
|
||||
entity,
|
||||
slot,
|
||||
fromTile: wsTile,
|
||||
toDirection: wsDirection,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const itemAcceptor = entity.components.ItemAcceptor;
|
||||
if (itemAcceptor) {
|
||||
for (let acceptorSlot = 0; acceptorSlot < itemAcceptor.slots.length; ++acceptorSlot) {
|
||||
const slot = itemAcceptor.slots[acceptorSlot];
|
||||
const wsTile = staticComp.localTileToWorld(slot.pos);
|
||||
for (let k = 0; k < slot.directions.length; ++k) {
|
||||
const direction = slot.directions[k];
|
||||
const wsDirection = staticComp.localDirectionToWorld(direction);
|
||||
|
||||
const sourceTile = wsTile.add(enumDirectionToVector[wsDirection]);
|
||||
if (sourceTile.equals(tile)) {
|
||||
acceptors.push({
|
||||
entity,
|
||||
slot,
|
||||
toTile: wsTile,
|
||||
fromDirection: wsDirection,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ejectors, acceptors };
|
||||
}
|
||||
}
|
||||
207
src/js/game/map.js
Normal file
207
src/js/game/map.js
Normal file
@@ -0,0 +1,207 @@
|
||||
/* typehints:start */
|
||||
import { GameRoot } from "./root";
|
||||
/* typehints:end */
|
||||
|
||||
import { globalConfig } from "../core/config";
|
||||
import { Vector } from "../core/vector";
|
||||
import { Entity } from "./entity";
|
||||
import { Math_floor } from "../core/builtins";
|
||||
import { createLogger } from "../core/logging";
|
||||
import { BaseItem } from "./base_item";
|
||||
import { MapChunkView } from "./map_chunk_view";
|
||||
|
||||
const logger = createLogger("map");
|
||||
|
||||
export class BaseMap {
|
||||
/**
|
||||
*
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
|
||||
/**
|
||||
* Mapping of 'X|Y' to chunk
|
||||
* @type {Map<string, MapChunkView>} */
|
||||
this.chunksById = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the given chunk by index
|
||||
* @param {number} chunkX
|
||||
* @param {number} chunkY
|
||||
*/
|
||||
getChunk(chunkX, chunkY, createIfNotExistent = false) {
|
||||
// TODO: Better generation
|
||||
const chunkIdentifier = chunkX + "|" + chunkY;
|
||||
let storedChunk;
|
||||
|
||||
if ((storedChunk = this.chunksById.get(chunkIdentifier))) {
|
||||
return storedChunk;
|
||||
}
|
||||
|
||||
if (createIfNotExistent) {
|
||||
const instance = new MapChunkView(this.root, chunkX, chunkY);
|
||||
this.chunksById.set(chunkIdentifier, instance);
|
||||
return instance;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or creates a new chunk if not existent for the given tile
|
||||
* @param {number} tileX
|
||||
* @param {number} tileY
|
||||
* @returns {MapChunkView}
|
||||
*/
|
||||
getOrCreateChunkAtTile(tileX, tileY) {
|
||||
const chunkX = Math_floor(tileX / globalConfig.mapChunkSize);
|
||||
const chunkY = Math_floor(tileY / globalConfig.mapChunkSize);
|
||||
return this.getChunk(chunkX, chunkY, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a chunk if not existent for the given tile
|
||||
* @param {number} tileX
|
||||
* @param {number} tileY
|
||||
* @returns {MapChunkView?}
|
||||
*/
|
||||
getChunkAtTileOrNull(tileX, tileY) {
|
||||
const chunkX = Math_floor(tileX / globalConfig.mapChunkSize);
|
||||
const chunkY = Math_floor(tileY / globalConfig.mapChunkSize);
|
||||
return this.getChunk(chunkX, chunkY, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given tile is within the map bounds
|
||||
* @param {Vector} tile
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isValidTile(tile) {
|
||||
if (G_IS_DEV) {
|
||||
assert(tile instanceof Vector, "tile is not a vector");
|
||||
}
|
||||
return Number.isInteger(tile.x) && Number.isInteger(tile.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tile content of a given tile
|
||||
* @param {Vector} tile
|
||||
* @returns {Entity} Entity or null
|
||||
*/
|
||||
getTileContent(tile) {
|
||||
if (G_IS_DEV) {
|
||||
this.internalCheckTile(tile);
|
||||
}
|
||||
const chunk = this.getChunkAtTileOrNull(tile.x, tile.y);
|
||||
return chunk && chunk.getTileContentFromWorldCoords(tile.x, tile.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the lower layers content of the given tile
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @returns {BaseItem=}
|
||||
*/
|
||||
getLowerLayerContentXY(x, y) {
|
||||
return this.getOrCreateChunkAtTile(x, y).getLowerLayerFromWorldCoords(x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tile content of a given tile
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @returns {Entity} Entity or null
|
||||
*/
|
||||
getTileContentXY(x, y) {
|
||||
const chunk = this.getChunkAtTileOrNull(x, y);
|
||||
return chunk && chunk.getTileContentFromWorldCoords(x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the tile is used
|
||||
* @param {Vector} tile
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isTileUsed(tile) {
|
||||
if (G_IS_DEV) {
|
||||
this.internalCheckTile(tile);
|
||||
}
|
||||
const chunk = this.getChunkAtTileOrNull(tile.x, tile.y);
|
||||
return chunk && chunk.getTileContentFromWorldCoords(tile.x, tile.y) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the tiles content
|
||||
* @param {Vector} tile
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setTileContent(tile, entity) {
|
||||
if (G_IS_DEV) {
|
||||
this.internalCheckTile(tile);
|
||||
}
|
||||
|
||||
this.getOrCreateChunkAtTile(tile.x, tile.y).setTileContentFromWorldCords(tile.x, tile.y, entity);
|
||||
|
||||
const staticComponent = entity.components.StaticMapEntity;
|
||||
assert(staticComponent, "Can only place static map entities in tiles");
|
||||
}
|
||||
|
||||
/**
|
||||
* Places an entity with the StaticMapEntity component
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
placeStaticEntity(entity) {
|
||||
assert(entity.components.StaticMapEntity, "Entity is not static");
|
||||
const staticComp = entity.components.StaticMapEntity;
|
||||
const rect = staticComp.getTileSpaceBounds();
|
||||
for (let dx = 0; dx < rect.w; ++dx) {
|
||||
for (let dy = 0; dy < rect.h; ++dy) {
|
||||
const x = rect.x + dx;
|
||||
const y = rect.y + dy;
|
||||
this.getOrCreateChunkAtTile(x, y).setTileContentFromWorldCords(x, y, entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an entity with the StaticMapEntity component
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
removeStaticEntity(entity) {
|
||||
assert(entity.components.StaticMapEntity, "Entity is not static");
|
||||
const staticComp = entity.components.StaticMapEntity;
|
||||
const rect = staticComp.getTileSpaceBounds();
|
||||
for (let dx = 0; dx < rect.w; ++dx) {
|
||||
for (let dy = 0; dy < rect.h; ++dy) {
|
||||
const x = rect.x + dx;
|
||||
const y = rect.y + dy;
|
||||
this.getOrCreateChunkAtTile(x, y).setTileContentFromWorldCords(x, y, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the tiles content
|
||||
* @param {Vector} tile
|
||||
*/
|
||||
clearTile(tile) {
|
||||
if (G_IS_DEV) {
|
||||
this.internalCheckTile(tile);
|
||||
}
|
||||
this.getOrCreateChunkAtTile(tile.x, tile.y).setTileContentFromWorldCords(tile.x, tile.y, null);
|
||||
}
|
||||
|
||||
// Internal
|
||||
|
||||
/**
|
||||
* Checks a given tile for validty
|
||||
* @param {Vector} tile
|
||||
*/
|
||||
internalCheckTile(tile) {
|
||||
assert(tile instanceof Vector, "tile is not a vector: " + tile);
|
||||
assert(tile.x % 1 === 0, "Tile X is not a valid integer: " + tile.x);
|
||||
assert(tile.y % 1 === 0, "Tile Y is not a valid integer: " + tile.y);
|
||||
}
|
||||
}
|
||||
359
src/js/game/map_chunk.js
Normal file
359
src/js/game/map_chunk.js
Normal file
@@ -0,0 +1,359 @@
|
||||
/* typehints:start */
|
||||
import { GameRoot } from "./root";
|
||||
/* typehints:end */
|
||||
|
||||
import { Math_ceil, Math_max, Math_min, Math_random, Math_round } from "../core/builtins";
|
||||
import { globalConfig } from "../core/config";
|
||||
import { createLogger } from "../core/logging";
|
||||
import {
|
||||
clamp,
|
||||
fastArrayDeleteValueIfContained,
|
||||
make2DUndefinedArray,
|
||||
randomChoice,
|
||||
randomInt,
|
||||
} from "../core/utils";
|
||||
import { Vector } from "../core/vector";
|
||||
import { BaseItem } from "./base_item";
|
||||
import { enumColors } from "./colors";
|
||||
import { Entity } from "./entity";
|
||||
import { ColorItem } from "./items/color_item";
|
||||
import { ShapeItem } from "./items/shape_item";
|
||||
import { enumSubShape } from "./shape_definition";
|
||||
|
||||
const logger = createLogger("map_chunk");
|
||||
|
||||
export class MapChunk {
|
||||
/**
|
||||
*
|
||||
* @param {GameRoot} root
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
*/
|
||||
constructor(root, x, y) {
|
||||
this.root = root;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.tileX = x * globalConfig.mapChunkSize;
|
||||
this.tileY = y * globalConfig.mapChunkSize;
|
||||
|
||||
/** @type {Array<Array<?Entity>>} */
|
||||
this.contents = make2DUndefinedArray(
|
||||
globalConfig.mapChunkSize,
|
||||
globalConfig.mapChunkSize,
|
||||
"map-chunk@" + this.x + "|" + this.y
|
||||
);
|
||||
|
||||
/** @type {Array<Array<?BaseItem>>} */
|
||||
this.lowerLayer = make2DUndefinedArray(
|
||||
globalConfig.mapChunkSize,
|
||||
globalConfig.mapChunkSize,
|
||||
"map-chunk-lower@" + this.x + "|" + this.y
|
||||
);
|
||||
|
||||
/** @type {Array<Entity>} */
|
||||
this.containedEntities = [];
|
||||
|
||||
/**
|
||||
* Store which patches we have so we can render them in the overview
|
||||
* @type {Array<{pos: Vector, item: BaseItem, size: number }>}
|
||||
*/
|
||||
this.patches = [];
|
||||
|
||||
this.generateLowerLayer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a patch filled with the given item
|
||||
* @param {number} patchSize
|
||||
* @param {BaseItem} item
|
||||
* @param {number=} overrideX Override the X position of the patch
|
||||
* @param {number=} overrideY Override the Y position of the patch
|
||||
*/
|
||||
internalGeneratePatch(patchSize, item, overrideX = null, overrideY = null) {
|
||||
const border = Math_ceil(patchSize / 2 + 3);
|
||||
|
||||
// Find a position within the chunk which is not blocked
|
||||
let patchX = randomInt(border, globalConfig.mapChunkSize - border - 1);
|
||||
let patchY = randomInt(border, globalConfig.mapChunkSize - border - 1);
|
||||
|
||||
if (overrideX !== null) {
|
||||
patchX = overrideX;
|
||||
}
|
||||
|
||||
if (overrideY !== null) {
|
||||
patchY = overrideY;
|
||||
}
|
||||
|
||||
const avgPos = new Vector(0, 0);
|
||||
let patchesDrawn = 0;
|
||||
|
||||
// Each patch consists of multiple circles
|
||||
const numCircles = patchSize;
|
||||
// const numCircles = 1;
|
||||
|
||||
for (let i = 0; i <= numCircles; ++i) {
|
||||
// Determine circle parameters
|
||||
const circleRadius = Math_min(1 + i, patchSize);
|
||||
const circleRadiusSquare = circleRadius * circleRadius;
|
||||
const circleOffsetRadius = (numCircles - i) / 2 + 2;
|
||||
|
||||
// We draw an elipsis actually
|
||||
const circleScaleY = 1 + (Math_random() * 2 - 1) * 0.1;
|
||||
const circleScaleX = 1 + (Math_random() * 2 - 1) * 0.1;
|
||||
|
||||
const circleX = patchX + randomInt(-circleOffsetRadius, circleOffsetRadius);
|
||||
const circleY = patchY + randomInt(-circleOffsetRadius, circleOffsetRadius);
|
||||
|
||||
for (let dx = -circleRadius * circleScaleX - 2; dx <= circleRadius * circleScaleX + 2; ++dx) {
|
||||
for (let dy = -circleRadius * circleScaleY - 2; dy <= circleRadius * circleScaleY + 2; ++dy) {
|
||||
const x = Math_round(circleX + dx);
|
||||
const y = Math_round(circleY + dy);
|
||||
if (x >= 0 && x < globalConfig.mapChunkSize && y >= 0 && y <= globalConfig.mapChunkSize) {
|
||||
const originalDx = dx / circleScaleX;
|
||||
const originalDy = dy / circleScaleY;
|
||||
if (originalDx * originalDx + originalDy * originalDy <= circleRadiusSquare) {
|
||||
if (!this.lowerLayer[x][y]) {
|
||||
this.lowerLayer[x][y] = item;
|
||||
++patchesDrawn;
|
||||
avgPos.x += x;
|
||||
avgPos.y += y;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// logger.warn("Tried to spawn resource out of chunk");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.patches.push({
|
||||
pos: avgPos.divideScalar(patchesDrawn),
|
||||
item,
|
||||
size: patchSize,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a color patch
|
||||
* @param {number} colorPatchSize
|
||||
* @param {number} distanceToOriginInChunks
|
||||
*/
|
||||
internalGenerateColorPatch(colorPatchSize, distanceToOriginInChunks) {
|
||||
// First, determine available colors
|
||||
let availableColors = [enumColors.red, enumColors.green];
|
||||
if (distanceToOriginInChunks > 2) {
|
||||
availableColors.push(enumColors.blue);
|
||||
}
|
||||
this.internalGeneratePatch(colorPatchSize, new ColorItem(randomChoice(availableColors)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a shape patch
|
||||
* @param {number} shapePatchSize
|
||||
* @param {number} distanceToOriginInChunks
|
||||
*/
|
||||
internalGenerateShapePatch(shapePatchSize, distanceToOriginInChunks) {
|
||||
/** @type {[enumSubShape, enumSubShape, enumSubShape, enumSubShape]} */
|
||||
let subShapes = null;
|
||||
|
||||
let weights = {};
|
||||
|
||||
if (distanceToOriginInChunks < 3) {
|
||||
// In the beginning, there are just circles
|
||||
weights = {
|
||||
[enumSubShape.circle]: 100,
|
||||
};
|
||||
} else if (distanceToOriginInChunks < 6) {
|
||||
// Later there come rectangles
|
||||
if (Math_random() > 0.4) {
|
||||
weights = {
|
||||
[enumSubShape.circle]: 100,
|
||||
};
|
||||
} else {
|
||||
weights = {
|
||||
[enumSubShape.rect]: 100,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Finally there is a mix of everything
|
||||
weights = {
|
||||
[enumSubShape.rect]: 100,
|
||||
[enumSubShape.circle]: Math_round(50 + clamp(distanceToOriginInChunks * 2, 0, 50)),
|
||||
[enumSubShape.star]: Math_round(20 + clamp(distanceToOriginInChunks * 2, 0, 30)),
|
||||
[enumSubShape.windmill]: Math_round(5 + clamp(distanceToOriginInChunks * 2, 0, 20)),
|
||||
};
|
||||
}
|
||||
subShapes = [
|
||||
this.internalGenerateRandomSubShape(weights),
|
||||
this.internalGenerateRandomSubShape(weights),
|
||||
this.internalGenerateRandomSubShape(weights),
|
||||
this.internalGenerateRandomSubShape(weights),
|
||||
];
|
||||
|
||||
const definition = this.root.shapeDefinitionMgr.getDefinitionFromSimpleShapes(subShapes);
|
||||
this.internalGeneratePatch(shapePatchSize, new ShapeItem(definition));
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses a random shape with the given weights
|
||||
* @param {Object.<enumSubShape, number>} weights
|
||||
* @returns {enumSubShape}
|
||||
*/
|
||||
internalGenerateRandomSubShape(weights) {
|
||||
// @ts-ignore
|
||||
const sum = Object.values(weights).reduce((a, b) => a + b, 0);
|
||||
|
||||
const chosenNumber = randomInt(0, sum - 1);
|
||||
let accumulated = 0;
|
||||
for (const key in weights) {
|
||||
const weight = weights[key];
|
||||
if (accumulated + weight > chosenNumber) {
|
||||
return key;
|
||||
}
|
||||
accumulated += weight;
|
||||
}
|
||||
|
||||
logger.error("Failed to find matching shape in chunk generation");
|
||||
return enumSubShape.circle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the lower layer "terrain"
|
||||
*/
|
||||
generateLowerLayer() {
|
||||
if (this.generatePredefined()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chunkCenter = new Vector(this.x, this.y).addScalar(0.5);
|
||||
const distanceToOriginInChunks = Math_round(chunkCenter.length());
|
||||
|
||||
// Determine how likely it is that there is a color patch
|
||||
const colorPatchChance = 0.9 - clamp(distanceToOriginInChunks / 25, 0, 1) * 0.5;
|
||||
if (Math_random() < colorPatchChance) {
|
||||
const colorPatchSize = Math_max(2, Math_round(1 + clamp(distanceToOriginInChunks / 8, 0, 4)));
|
||||
this.internalGenerateColorPatch(colorPatchSize, distanceToOriginInChunks);
|
||||
}
|
||||
|
||||
// Determine how likely it is that there is a shape patch
|
||||
const shapePatchChance = 0.9 - clamp(distanceToOriginInChunks / 25, 0, 1) * 0.5;
|
||||
if (Math_random() < shapePatchChance) {
|
||||
const shapePatchSize = Math_max(2, Math_round(1 + clamp(distanceToOriginInChunks / 8, 0, 4)));
|
||||
this.internalGenerateShapePatch(shapePatchSize, distanceToOriginInChunks);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this chunk has predefined contents, and if so returns true and generates the
|
||||
* predefined contents
|
||||
* @returns {boolean}
|
||||
*/
|
||||
generatePredefined() {
|
||||
if (this.x === 0 && this.y === 0) {
|
||||
this.internalGeneratePatch(2, new ColorItem(enumColors.red), 7, 7);
|
||||
return true;
|
||||
}
|
||||
if (this.x === -1 && this.y === 0) {
|
||||
const definition = this.root.shapeDefinitionMgr.getDefinitionFromSimpleShapes([
|
||||
enumSubShape.circle,
|
||||
enumSubShape.circle,
|
||||
enumSubShape.circle,
|
||||
enumSubShape.circle,
|
||||
]);
|
||||
this.internalGeneratePatch(2, new ShapeItem(definition), globalConfig.mapChunkSize - 9, 7);
|
||||
return true;
|
||||
}
|
||||
if (this.x === 0 && this.y === -1) {
|
||||
const definition = this.root.shapeDefinitionMgr.getDefinitionFromSimpleShapes([
|
||||
enumSubShape.rect,
|
||||
enumSubShape.rect,
|
||||
enumSubShape.rect,
|
||||
enumSubShape.rect,
|
||||
]);
|
||||
this.internalGeneratePatch(2, new ShapeItem(definition), 5, globalConfig.mapChunkSize - 7);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.x === -1 && this.y === -1) {
|
||||
this.internalGeneratePatch(2, new ColorItem(enumColors.green));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} worldX
|
||||
* @param {number} worldY
|
||||
* @returns {BaseItem=}
|
||||
*/
|
||||
getLowerLayerFromWorldCoords(worldX, worldY) {
|
||||
const localX = worldX - this.tileX;
|
||||
const localY = worldY - this.tileY;
|
||||
assert(localX >= 0, "Local X is < 0");
|
||||
assert(localY >= 0, "Local Y is < 0");
|
||||
assert(localX < globalConfig.mapChunkSize, "Local X is >= chunk size");
|
||||
assert(localY < globalConfig.mapChunkSize, "Local Y is >= chunk size");
|
||||
return this.lowerLayer[localX][localY] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the contents of this chunk from the given world space coordinates
|
||||
* @param {number} worldX
|
||||
* @param {number} worldY
|
||||
* @returns {Entity=}
|
||||
*/
|
||||
getTileContentFromWorldCoords(worldX, worldY) {
|
||||
const localX = worldX - this.tileX;
|
||||
const localY = worldY - this.tileY;
|
||||
assert(localX >= 0, "Local X is < 0");
|
||||
assert(localY >= 0, "Local Y is < 0");
|
||||
assert(localX < globalConfig.mapChunkSize, "Local X is >= chunk size");
|
||||
assert(localY < globalConfig.mapChunkSize, "Local Y is >= chunk size");
|
||||
return this.contents[localX][localY] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the chunks contents from the given local coordinates
|
||||
* @param {number} localX
|
||||
* @param {number} localY
|
||||
* @returns {Entity=}
|
||||
*/
|
||||
getTileContentFromLocalCoords(localX, localY) {
|
||||
assert(localX >= 0, "Local X is < 0");
|
||||
assert(localY >= 0, "Local Y is < 0");
|
||||
assert(localX < globalConfig.mapChunkSize, "Local X is >= chunk size");
|
||||
assert(localY < globalConfig.mapChunkSize, "Local Y is >= chunk size");
|
||||
|
||||
return this.contents[localX][localY] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the chunks contents
|
||||
* @param {number} tileX
|
||||
* @param {number} tileY
|
||||
* @param {Entity=} contents
|
||||
*/
|
||||
setTileContentFromWorldCords(tileX, tileY, contents) {
|
||||
const localX = tileX - this.tileX;
|
||||
const localY = tileY - this.tileY;
|
||||
assert(localX >= 0, "Local X is < 0");
|
||||
assert(localY >= 0, "Local Y is < 0");
|
||||
assert(localX < globalConfig.mapChunkSize, "Local X is >= chunk size");
|
||||
assert(localY < globalConfig.mapChunkSize, "Local Y is >= chunk size");
|
||||
const oldContents = this.contents[localX][localY];
|
||||
assert(contents === null || !oldContents, "Tile already used: " + tileX + " / " + tileY);
|
||||
|
||||
if (oldContents) {
|
||||
// Remove from list
|
||||
fastArrayDeleteValueIfContained(this.containedEntities, oldContents);
|
||||
}
|
||||
this.contents[localX][localY] = contents;
|
||||
if (contents) {
|
||||
if (this.containedEntities.indexOf(contents) < 0) {
|
||||
this.containedEntities.push(contents);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
226
src/js/game/map_chunk_view.js
Normal file
226
src/js/game/map_chunk_view.js
Normal file
@@ -0,0 +1,226 @@
|
||||
import { MapChunk } from "./map_chunk";
|
||||
import { GameRoot } from "./root";
|
||||
import { globalConfig } from "../core/config";
|
||||
import { DrawParameters } from "../core/draw_parameters";
|
||||
import { round1Digit } from "../core/utils";
|
||||
import { Math_max, Math_round } from "../core/builtins";
|
||||
import { Rectangle } from "../core/rectangle";
|
||||
import { createLogger } from "../core/logging";
|
||||
import { smoothenDpi } from "../core/dpi_manager";
|
||||
|
||||
const logger = createLogger("chunk");
|
||||
const chunkSizePixels = globalConfig.mapChunkSize * globalConfig.tileSize;
|
||||
|
||||
export class MapChunkView extends MapChunk {
|
||||
/**
|
||||
*
|
||||
* @param {GameRoot} root
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
*/
|
||||
constructor(root, x, y) {
|
||||
super(root, x, y);
|
||||
|
||||
this.boundInternalDrawBackgroundToContext = this.internalDrawBackgroundToContext.bind(this);
|
||||
this.boundInternalDrawForegroundToContext = this.internalDrawForegroundToContext.bind(this);
|
||||
|
||||
/**
|
||||
* Whenever something changes, we increase this number - so we know we need to redraw
|
||||
*/
|
||||
this.renderIteration = 0;
|
||||
|
||||
this.markDirty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks this chunk as dirty, rerendering all caches
|
||||
*/
|
||||
markDirty() {
|
||||
++this.renderIteration;
|
||||
this.renderKey = this.x + "/" + this.y + "@" + this.renderIteration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the background layer
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
drawBackgroundLayer(parameters) {
|
||||
if (parameters.zoomLevel > globalConfig.mapChunkPrerenderMinZoom) {
|
||||
this.internalDrawBackgroundSystems(parameters);
|
||||
return;
|
||||
}
|
||||
|
||||
const dpi = smoothenDpi(parameters.zoomLevel);
|
||||
const buffer = this.root.buffers.getForKey(
|
||||
"" + dpi,
|
||||
this.renderKey + "@bg",
|
||||
chunkSizePixels,
|
||||
chunkSizePixels,
|
||||
dpi,
|
||||
this.boundInternalDrawBackgroundToContext,
|
||||
{ zoomLevel: parameters.zoomLevel }
|
||||
);
|
||||
|
||||
parameters.context.drawImage(
|
||||
buffer,
|
||||
this.tileX * globalConfig.tileSize,
|
||||
this.tileY * globalConfig.tileSize,
|
||||
chunkSizePixels,
|
||||
chunkSizePixels
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the foreground layer
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
drawForegroundLayer(parameters) {
|
||||
if (parameters.zoomLevel > globalConfig.mapChunkPrerenderMinZoom) {
|
||||
this.internalDrawForegroundSystems(parameters);
|
||||
return;
|
||||
}
|
||||
|
||||
const dpi = smoothenDpi(parameters.zoomLevel);
|
||||
const buffer = this.root.buffers.getForKey(
|
||||
"" + dpi,
|
||||
this.renderKey + "@fg",
|
||||
chunkSizePixels,
|
||||
chunkSizePixels,
|
||||
dpi,
|
||||
this.boundInternalDrawForegroundToContext,
|
||||
{ zoomLevel: parameters.zoomLevel }
|
||||
);
|
||||
parameters.context.drawImage(
|
||||
buffer,
|
||||
this.tileX * globalConfig.tileSize,
|
||||
this.tileY * globalConfig.tileSize,
|
||||
chunkSizePixels,
|
||||
chunkSizePixels
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
* @param {number} dpi
|
||||
*/
|
||||
internalDrawBackgroundToContext(canvas, context, w, h, dpi, { zoomLevel }) {
|
||||
const pattern = context.createPattern(this.root.map.cachedBackgroundCanvas, "repeat");
|
||||
context.scale(dpi, dpi);
|
||||
|
||||
if (zoomLevel >= globalConfig.mapChunkOverviewMinZoom) {
|
||||
const bgDpi = this.root.map.backgroundCacheDPI;
|
||||
context.scale(1 / bgDpi, 1 / bgDpi);
|
||||
context.fillStyle = pattern;
|
||||
context.fillRect(0, 0, chunkSizePixels * bgDpi, chunkSizePixels * bgDpi);
|
||||
context.scale(bgDpi, bgDpi);
|
||||
} else {
|
||||
if (this.containedEntities.length > 0) {
|
||||
context.fillStyle = "#c5ccd6";
|
||||
} else {
|
||||
context.fillStyle = "#a6afbb";
|
||||
}
|
||||
context.fillRect(0, 0, 10000, 10000);
|
||||
}
|
||||
|
||||
if (G_IS_DEV && globalConfig.debug.showChunkBorders) {
|
||||
context.fillStyle = "rgba(0, 0, 255, 0.1)";
|
||||
context.fillRect(0, 0, 10000, 10000);
|
||||
}
|
||||
|
||||
const parameters = new DrawParameters({
|
||||
context,
|
||||
visibleRect: new Rectangle(
|
||||
this.tileX * globalConfig.tileSize,
|
||||
this.tileY * globalConfig.tileSize,
|
||||
chunkSizePixels,
|
||||
chunkSizePixels
|
||||
),
|
||||
desiredAtlasScale: "1",
|
||||
zoomLevel,
|
||||
root: this.root,
|
||||
});
|
||||
|
||||
parameters.context.translate(
|
||||
-this.tileX * globalConfig.tileSize,
|
||||
-this.tileY * globalConfig.tileSize
|
||||
);
|
||||
// parameters.context.save();
|
||||
// parameters.context.transform(
|
||||
// 1,
|
||||
// 0,
|
||||
// 0,
|
||||
// zoomLevel,
|
||||
// this.tileX * globalConfig.tileSize,
|
||||
// this.tileY * globalConfig.tileSize
|
||||
// );
|
||||
|
||||
this.internalDrawBackgroundSystems(parameters);
|
||||
|
||||
// parameters.context.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
* @param {number} dpi
|
||||
*/
|
||||
internalDrawForegroundToContext(canvas, context, w, h, dpi, { zoomLevel }) {
|
||||
context.scale(dpi, dpi);
|
||||
|
||||
const parameters = new DrawParameters({
|
||||
context,
|
||||
visibleRect: new Rectangle(
|
||||
this.tileX * globalConfig.tileSize,
|
||||
this.tileY * globalConfig.tileSize,
|
||||
chunkSizePixels,
|
||||
chunkSizePixels
|
||||
),
|
||||
desiredAtlasScale: "1",
|
||||
zoomLevel,
|
||||
root: this.root,
|
||||
});
|
||||
// parameters.context.save();
|
||||
// parameters.context.save();
|
||||
// parameters.context.transform(
|
||||
// zoomLevel,
|
||||
// 0,
|
||||
// 0,
|
||||
// zoomLevel,
|
||||
// this.tileX * globalConfig.tileSize,
|
||||
// this.tileY * globalConfig.tileSize
|
||||
// );
|
||||
|
||||
parameters.context.translate(
|
||||
-this.tileX * globalConfig.tileSize,
|
||||
-this.tileY * globalConfig.tileSize
|
||||
);
|
||||
this.internalDrawForegroundSystems(parameters);
|
||||
|
||||
// parameters.context.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
internalDrawBackgroundSystems(parameters) {
|
||||
const systems = this.root.systemMgr.systems;
|
||||
systems.mapResources.drawChunk(parameters, this);
|
||||
systems.belt.drawChunk(parameters, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
internalDrawForegroundSystems(parameters) {
|
||||
const systems = this.root.systemMgr.systems;
|
||||
systems.miner.drawChunk(parameters, this);
|
||||
systems.staticMapEntities.drawChunk(parameters, this);
|
||||
}
|
||||
}
|
||||
249
src/js/game/map_view.js
Normal file
249
src/js/game/map_view.js
Normal file
@@ -0,0 +1,249 @@
|
||||
import { Math_max, Math_min, Math_floor, Math_ceil } from "../core/builtins";
|
||||
import { globalConfig } from "../core/config";
|
||||
import { DrawParameters } from "../core/draw_parameters";
|
||||
import { BaseMap } from "./map";
|
||||
import { freeCanvas, makeOffscreenBuffer } from "../core/buffer_utils";
|
||||
import { Entity } from "./entity";
|
||||
|
||||
/**
|
||||
* This is the view of the map, it extends the map which is the raw model and allows
|
||||
* to draw it
|
||||
*/
|
||||
export class MapView extends BaseMap {
|
||||
constructor(root) {
|
||||
super(root);
|
||||
|
||||
/**
|
||||
* DPI of the background cache images, required in some places
|
||||
*/
|
||||
this.backgroundCacheDPI = 4;
|
||||
|
||||
/**
|
||||
* The cached background sprite, containing the flat background
|
||||
* @type {HTMLCanvasElement} */
|
||||
this.cachedBackgroundCanvas = null;
|
||||
|
||||
/** @type {CanvasRenderingContext2D} */
|
||||
this.cachedBackgroundContext = null;
|
||||
/**
|
||||
* Cached pattern of the stripes background
|
||||
* @type {CanvasPattern} */
|
||||
this.cachedBackgroundPattern = null;
|
||||
|
||||
this.internalInitializeCachedBackgroundCanvases();
|
||||
this.root.signals.aboutToDestruct.add(this.cleanup, this);
|
||||
|
||||
this.root.signals.entityAdded.add(this.onEntityChanged, this);
|
||||
this.root.signals.entityDestroyed.add(this.onEntityChanged, this);
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
freeCanvas(this.cachedBackgroundCanvas);
|
||||
this.cachedBackgroundCanvas = null;
|
||||
this.cachedBackgroundPattern = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an entity was added or removed
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
onEntityChanged(entity) {
|
||||
const staticComp = entity.components.StaticMapEntity;
|
||||
if (staticComp) {
|
||||
const rect = staticComp.getTileSpaceBounds();
|
||||
for (let x = rect.x; x <= rect.right(); ++x) {
|
||||
for (let y = rect.y; y <= rect.bottom(); ++y) {
|
||||
this.root.map.getOrCreateChunkAtTile(x, y).markDirty();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws all static entities like buildings etc.
|
||||
* @param {DrawParameters} drawParameters
|
||||
*/
|
||||
drawStaticEntities(drawParameters) {
|
||||
const cullRange = drawParameters.visibleRect.toTileCullRectangle();
|
||||
const top = cullRange.top();
|
||||
const right = cullRange.right();
|
||||
const bottom = cullRange.bottom();
|
||||
const left = cullRange.left();
|
||||
|
||||
const border = 1;
|
||||
|
||||
const minY = top - border;
|
||||
const maxY = bottom + border;
|
||||
const minX = left - border;
|
||||
const maxX = right + border - 1;
|
||||
|
||||
// Render y from top down for proper blending
|
||||
for (let y = minY; y <= maxY; ++y) {
|
||||
for (let x = minX; x <= maxX; ++x) {
|
||||
// const content = this.tiles[x][y];
|
||||
const chunk = this.getChunkAtTileOrNull(x, y);
|
||||
if (!chunk) {
|
||||
continue;
|
||||
}
|
||||
const content = chunk.getTileContentFromWorldCoords(x, y);
|
||||
if (content) {
|
||||
let isBorder = x <= left - 1 || x >= right + 1 || y <= top - 1 || y >= bottom + 1;
|
||||
if (!isBorder) {
|
||||
content.draw(drawParameters);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes all canvases used for background rendering
|
||||
*/
|
||||
internalInitializeCachedBackgroundCanvases() {
|
||||
// Background canvas
|
||||
const dims = globalConfig.tileSize;
|
||||
const dpi = this.backgroundCacheDPI;
|
||||
const [canvas, context] = makeOffscreenBuffer(dims * dpi, dims * dpi, {
|
||||
smooth: false,
|
||||
label: "map-cached-bg",
|
||||
});
|
||||
context.scale(dpi, dpi);
|
||||
|
||||
context.fillStyle = "#fff";
|
||||
context.fillRect(0, 0, dims, dims);
|
||||
|
||||
context.fillStyle = "#fafafa";
|
||||
context.fillRect(0, 0, dims, 1);
|
||||
context.fillRect(0, 0, 1, dims);
|
||||
context.fillRect(dims - 1, 0, 1, dims);
|
||||
context.fillRect(0, dims - 1, dims, 1);
|
||||
|
||||
this.cachedBackgroundCanvas = canvas;
|
||||
this.cachedBackgroundContext = context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the maps foreground
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
drawForeground(parameters) {
|
||||
const cullRange = parameters.visibleRect.toTileCullRectangle();
|
||||
const top = cullRange.top();
|
||||
const right = cullRange.right();
|
||||
const bottom = cullRange.bottom();
|
||||
const left = cullRange.left();
|
||||
|
||||
const border = 1;
|
||||
const minY = top - border;
|
||||
const maxY = bottom + border;
|
||||
const minX = left - border;
|
||||
const maxX = right + border - 1;
|
||||
|
||||
const chunkStartX = Math_floor(minX / globalConfig.mapChunkSize);
|
||||
const chunkStartY = Math_floor(minY / globalConfig.mapChunkSize);
|
||||
|
||||
const chunkEndX = Math_ceil(maxX / globalConfig.mapChunkSize);
|
||||
const chunkEndY = Math_ceil(maxY / globalConfig.mapChunkSize);
|
||||
|
||||
// Render y from top down for proper blending
|
||||
for (let chunkX = chunkStartX; chunkX <= chunkEndX; ++chunkX) {
|
||||
for (let chunkY = chunkStartY; chunkY <= chunkEndY; ++chunkY) {
|
||||
const chunk = this.root.map.getChunk(chunkX, chunkY, true);
|
||||
chunk.drawForegroundLayer(parameters);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the map background
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
drawBackground(parameters) {
|
||||
// If not using prerendered, draw background
|
||||
if (parameters.zoomLevel > globalConfig.mapChunkPrerenderMinZoom) {
|
||||
if (!this.cachedBackgroundPattern) {
|
||||
this.cachedBackgroundPattern = parameters.context.createPattern(
|
||||
this.cachedBackgroundCanvas,
|
||||
"repeat"
|
||||
);
|
||||
}
|
||||
|
||||
const dpi = this.backgroundCacheDPI;
|
||||
parameters.context.scale(1 / dpi, 1 / dpi);
|
||||
|
||||
parameters.context.fillStyle = this.cachedBackgroundPattern;
|
||||
parameters.context.fillRect(
|
||||
parameters.visibleRect.x * dpi,
|
||||
parameters.visibleRect.y * dpi,
|
||||
parameters.visibleRect.w * dpi,
|
||||
parameters.visibleRect.h * dpi
|
||||
);
|
||||
parameters.context.scale(dpi, dpi);
|
||||
}
|
||||
|
||||
const cullRange = parameters.visibleRect.toTileCullRectangle();
|
||||
const top = cullRange.top();
|
||||
const right = cullRange.right();
|
||||
const bottom = cullRange.bottom();
|
||||
const left = cullRange.left();
|
||||
|
||||
const border = 1;
|
||||
const minY = top - border;
|
||||
const maxY = bottom + border;
|
||||
const minX = left - border;
|
||||
const maxX = right + border - 1;
|
||||
|
||||
const chunkStartX = Math_floor(minX / globalConfig.mapChunkSize);
|
||||
const chunkStartY = Math_floor(minY / globalConfig.mapChunkSize);
|
||||
|
||||
const chunkEndX = Math_ceil(maxX / globalConfig.mapChunkSize);
|
||||
const chunkEndY = Math_ceil(maxY / globalConfig.mapChunkSize);
|
||||
|
||||
// Render y from top down for proper blending
|
||||
for (let chunkX = chunkStartX; chunkX <= chunkEndX; ++chunkX) {
|
||||
for (let chunkY = chunkStartY; chunkY <= chunkEndY; ++chunkY) {
|
||||
const chunk = this.root.map.getChunk(chunkX, chunkY, true);
|
||||
chunk.drawBackgroundLayer(parameters);
|
||||
}
|
||||
}
|
||||
|
||||
if (G_IS_DEV && globalConfig.debug.showChunkBorders) {
|
||||
const cullRange = parameters.visibleRect.toTileCullRectangle();
|
||||
const top = cullRange.top();
|
||||
const right = cullRange.right();
|
||||
const bottom = cullRange.bottom();
|
||||
const left = cullRange.left();
|
||||
|
||||
const border = 1;
|
||||
const minY = top - border;
|
||||
const maxY = bottom + border;
|
||||
const minX = left - border;
|
||||
const maxX = right + border - 1;
|
||||
|
||||
const chunkStartX = Math_floor(minX / globalConfig.mapChunkSize);
|
||||
const chunkStartY = Math_floor(minY / globalConfig.mapChunkSize);
|
||||
|
||||
const chunkEndX = Math_ceil(maxX / globalConfig.mapChunkSize);
|
||||
const chunkEndY = Math_ceil(maxY / globalConfig.mapChunkSize);
|
||||
|
||||
// Render y from top down for proper blending
|
||||
for (let chunkX = chunkStartX; chunkX <= chunkEndX; ++chunkX) {
|
||||
for (let chunkY = chunkStartY; chunkY <= chunkEndY; ++chunkY) {
|
||||
parameters.context.fillStyle = "#ffaaaa";
|
||||
parameters.context.fillRect(
|
||||
chunkX * globalConfig.mapChunkSize * globalConfig.tileSize,
|
||||
chunkY * globalConfig.mapChunkSize * globalConfig.tileSize,
|
||||
globalConfig.mapChunkSize * globalConfig.tileSize,
|
||||
3
|
||||
);
|
||||
parameters.context.fillRect(
|
||||
chunkX * globalConfig.mapChunkSize * globalConfig.tileSize,
|
||||
chunkY * globalConfig.mapChunkSize * globalConfig.tileSize,
|
||||
3,
|
||||
globalConfig.mapChunkSize * globalConfig.tileSize
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
155
src/js/game/meta_building.js
Normal file
155
src/js/game/meta_building.js
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Vector, enumDirection, enumAngleToDirection } from "../core/vector";
|
||||
import { Loader } from "../core/loader";
|
||||
import { GameRoot } from "./root";
|
||||
import { AtlasSprite } from "../core/sprites";
|
||||
import { Entity } from "./entity";
|
||||
import { StaticMapEntityComponent } from "./components/static_map_entity";
|
||||
|
||||
export class MetaBuilding {
|
||||
/**
|
||||
*
|
||||
* @param {string} id Building id
|
||||
*/
|
||||
constructor(id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the id of this building
|
||||
*/
|
||||
getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return the dimensions of the building
|
||||
*/
|
||||
getDimensions() {
|
||||
return new Vector(1, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return the name of this building
|
||||
*/
|
||||
getName() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return the description of this building
|
||||
*/
|
||||
getDescription() {
|
||||
return "No Description";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to stay in placement mode after having placed a building
|
||||
*/
|
||||
getStayInPlacementMode() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to flip the orientation after a building has been placed - useful
|
||||
* for tunnels.
|
||||
*/
|
||||
getFlipOrientationAfterPlacement() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a preview sprite
|
||||
* @returns {AtlasSprite}
|
||||
*/
|
||||
getPreviewSprite(rotationVariant = 0) {
|
||||
return Loader.getSprite("sprites/buildings/" + this.id + ".png");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether this building is rotateable
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isRotateable() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether this building is unlocked for the given game
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
getIsUnlocked(root) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return a silhouette color for the map overview or null if not set
|
||||
*/
|
||||
getSilhouetteColor() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {GameRoot} root
|
||||
* @param {Vector} origin Origin tile
|
||||
* @param {number=} rotation Rotation
|
||||
* @param {number=} rotationVariant Rotation variant
|
||||
*/
|
||||
createAndPlaceEntity(root, origin, rotation = 0, rotationVariant = 0) {
|
||||
const entity = new Entity(root);
|
||||
entity.addComponent(
|
||||
new StaticMapEntityComponent({
|
||||
spriteKey: "sprites/buildings/" + this.id + ".png",
|
||||
origin: new Vector(origin.x, origin.y),
|
||||
rotationDegrees: rotation,
|
||||
tileSize: this.getDimensions().copy(),
|
||||
silhouetteColor: this.getSilhouetteColor(),
|
||||
})
|
||||
);
|
||||
|
||||
this.setupEntityComponents(entity, root);
|
||||
this.updateRotationVariant(entity, rotationVariant);
|
||||
|
||||
root.entityMgr.registerEntity(entity);
|
||||
root.map.placeStaticEntity(entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should compute the optimal rotation variant on the given tile
|
||||
* @param {GameRoot} root
|
||||
* @param {Vector} tile
|
||||
* @param {number} rotation
|
||||
* @return {{ rotation: number, rotationVariant: number, connectedEntities?: Array<Entity> }}
|
||||
*/
|
||||
computeOptimalDirectionAndRotationVariantAtTile(root, tile, rotation) {
|
||||
if (!this.isRotateable()) {
|
||||
return {
|
||||
rotation: 0,
|
||||
rotationVariant: 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
rotation,
|
||||
rotationVariant: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Should update the entity to match the given rotation variant
|
||||
* @param {Entity} entity
|
||||
* @param {number} rotationVariant
|
||||
*/
|
||||
updateRotationVariant(entity, rotationVariant) {}
|
||||
|
||||
// PRIVATE INTERFACE
|
||||
|
||||
/**
|
||||
* Should setup the entity components
|
||||
* @param {Entity} entity
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
setupEntityComponents(entity, root) {
|
||||
abstract;
|
||||
}
|
||||
}
|
||||
26
src/js/game/meta_building_registry.js
Normal file
26
src/js/game/meta_building_registry.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { gMetaBuildingRegistry } from "../core/global_registries";
|
||||
import { MetaBeltBaseBuilding } from "./buildings/belt_base";
|
||||
import { MetaCutterBuilding } from "./buildings/cutter";
|
||||
import { MetaMinerBuilding } from "./buildings/miner";
|
||||
import { MetaMixerBuilding } from "./buildings/mixer";
|
||||
import { MetaPainterBuilding } from "./buildings/painter";
|
||||
import { MetaRotaterBuilding } from "./buildings/rotater";
|
||||
import { MetaSplitterBuilding } from "./buildings/splitter";
|
||||
import { MetaStackerBuilding } from "./buildings/stacker";
|
||||
import { MetaTrashBuilding } from "./buildings/trash";
|
||||
import { MetaUndergroundBeltBuilding } from "./buildings/underground_belt";
|
||||
import { MetaHubBuilding } from "./buildings/hub";
|
||||
|
||||
export function initMetaBuildingRegistry() {
|
||||
gMetaBuildingRegistry.register(MetaSplitterBuilding);
|
||||
gMetaBuildingRegistry.register(MetaMinerBuilding);
|
||||
gMetaBuildingRegistry.register(MetaCutterBuilding);
|
||||
gMetaBuildingRegistry.register(MetaRotaterBuilding);
|
||||
gMetaBuildingRegistry.register(MetaStackerBuilding);
|
||||
gMetaBuildingRegistry.register(MetaMixerBuilding);
|
||||
gMetaBuildingRegistry.register(MetaPainterBuilding);
|
||||
gMetaBuildingRegistry.register(MetaTrashBuilding);
|
||||
gMetaBuildingRegistry.register(MetaBeltBaseBuilding);
|
||||
gMetaBuildingRegistry.register(MetaUndergroundBeltBuilding);
|
||||
gMetaBuildingRegistry.register(MetaHubBuilding);
|
||||
}
|
||||
214
src/js/game/root.js
Normal file
214
src/js/game/root.js
Normal file
@@ -0,0 +1,214 @@
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
import { Signal } from "../core/signal";
|
||||
import { RandomNumberGenerator } from "../core/rng";
|
||||
// import { gFactionRegistry } from "./global_registries";
|
||||
import { createLogger } from "../core/logging";
|
||||
|
||||
// Type hints
|
||||
/* typehints:start */
|
||||
import { GameTime } from "./time/game_time";
|
||||
import { EntityManager } from "./entity_manager";
|
||||
import { GameSystemManager } from "./game_system_manager";
|
||||
import { GameHUD } from "./hud/hud";
|
||||
// import { GameLogic } from "./game_logic";
|
||||
import { MapView } from "./map_view";
|
||||
import { Camera } from "./camera";
|
||||
// import { ParticleManager } from "../particles/particle_manager";
|
||||
import { InGameState } from "../states/ingame";
|
||||
// import { CanvasClickInterceptor } from "/canvas_click_interceptor";
|
||||
import { AutomaticSave } from "./automatic_save";
|
||||
import { Application } from "../application";
|
||||
import { SoundProxy } from "./sound_proxy";
|
||||
import { Savegame } from "../savegame/savegame";
|
||||
import { GameLogic } from "./logic";
|
||||
import { ShapeDefinitionManager } from "./shape_definition_manager";
|
||||
import { CanvasClickInterceptor } from "./canvas_click_interceptor";
|
||||
import { PerlinNoise } from "../core/perlin_noise";
|
||||
import { HubGoals } from "./hub_goals";
|
||||
import { BufferMaintainer } from "../core/buffer_maintainer";
|
||||
/* typehints:end */
|
||||
|
||||
const logger = createLogger("game/root");
|
||||
|
||||
/**
|
||||
* The game root is basically the whole game state at a given point,
|
||||
* combining all important classes. We don't have globals, but this
|
||||
* class is passed to almost all game classes.
|
||||
*/
|
||||
export class GameRoot {
|
||||
/**
|
||||
* Constructs a new game root
|
||||
* @param {Application} app
|
||||
*/
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
|
||||
/** @type {Savegame} */
|
||||
this.savegame = null;
|
||||
|
||||
/** @type {InGameState} */
|
||||
this.gameState = null;
|
||||
|
||||
// Store game dimensions
|
||||
this.gameWidth = 500;
|
||||
this.gameHeight = 500;
|
||||
|
||||
// Stores whether the current session is a fresh game (true), or was continued (false)
|
||||
/** @type {boolean} */
|
||||
this.gameIsFresh = true;
|
||||
|
||||
// Stores whether the logic is already initialized
|
||||
/** @type {boolean} */
|
||||
this.logicInitialized = false;
|
||||
|
||||
// Stores whether the game is already initialized, that is, all systems etc have been created
|
||||
/** @type {boolean} */
|
||||
this.gameInitialized = false;
|
||||
|
||||
//////// Other properties ///////
|
||||
|
||||
/** @type {Camera} */
|
||||
this.camera = null;
|
||||
|
||||
/** @type {HTMLCanvasElement} */
|
||||
this.canvas = null;
|
||||
|
||||
/** @type {CanvasRenderingContext2D} */
|
||||
this.context = null;
|
||||
|
||||
/** @type {MapView} */
|
||||
this.map = null;
|
||||
|
||||
/** @type {GameLogic} */
|
||||
this.logic = null;
|
||||
|
||||
/** @type {EntityManager} */
|
||||
this.entityMgr = null;
|
||||
|
||||
/** @type {GameHUD} */
|
||||
this.hud = null;
|
||||
|
||||
/** @type {GameSystemManager} */
|
||||
this.systemMgr = null;
|
||||
|
||||
/** @type {GameTime} */
|
||||
this.time = null;
|
||||
|
||||
/** @type {PerlinNoise} */
|
||||
this.mapNoiseGenerator = null;
|
||||
|
||||
/** @type {HubGoals} */
|
||||
this.hubGoals = null;
|
||||
|
||||
/** @type {BufferMaintainer} */
|
||||
this.buffers = null;
|
||||
|
||||
// /** @type {ParticleManager} */
|
||||
// this.particleMgr = null;
|
||||
|
||||
// /** @type {ParticleManager} */
|
||||
// this.uiParticleMgr = null;
|
||||
|
||||
/** @type {CanvasClickInterceptor} */
|
||||
this.canvasClickInterceptor = null;
|
||||
|
||||
/** @type {AutomaticSave} */
|
||||
this.automaticSave = null;
|
||||
|
||||
/** @type {SoundProxy} */
|
||||
this.soundProxy = null;
|
||||
|
||||
// /** @type {MinimapRenderer} */
|
||||
// this.minimapRenderer = null;
|
||||
|
||||
/** @type {ShapeDefinitionManager} */
|
||||
this.shapeDefinitionMgr = null;
|
||||
|
||||
this.signals = {
|
||||
// Entities
|
||||
entityAdded: new Signal(/* entity */),
|
||||
entityGotNewComponent: new Signal(/* entity */),
|
||||
entityQueuedForDestroy: new Signal(/* entity */),
|
||||
entityDestroyed: new Signal(/* entity */),
|
||||
|
||||
// Global
|
||||
resized: new Signal(/* w, h */), // Game got resized,
|
||||
readyToRender: new Signal(),
|
||||
aboutToDestruct: new Signal(),
|
||||
|
||||
// Game Hooks
|
||||
gameSaved: new Signal(), // Game got saved
|
||||
gameRestored: new Signal(), // Game got restored
|
||||
gameOver: new Signal(), // Game over
|
||||
|
||||
storyGoalCompleted: new Signal(/* level, reward */),
|
||||
upgradePurchased: new Signal(),
|
||||
|
||||
// Called right after game is initialized
|
||||
postLoadHook: new Signal(),
|
||||
|
||||
// Can be used to trigger an async task
|
||||
performAsync: new Signal(),
|
||||
};
|
||||
|
||||
// RNG's
|
||||
/** @type {Object.<string, Object.<string, RandomNumberGenerator>>} */
|
||||
this.rngs = {};
|
||||
|
||||
// Work queue
|
||||
this.queue = {
|
||||
requireRedraw: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Destructs the game root
|
||||
*/
|
||||
destruct() {
|
||||
logger.log("destructing root");
|
||||
this.signals.aboutToDestruct.dispatch();
|
||||
|
||||
this.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the root for game over, this sets the right flags and
|
||||
* detaches all signals so no bad stuff happens
|
||||
*/
|
||||
prepareGameOver() {
|
||||
this.gameInitialized = false;
|
||||
this.logicInitialized = false;
|
||||
// for (const key in this.signals) {
|
||||
// if (key !== "aboutToDestruct") {
|
||||
// this.signals[key].removeAll();
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the whole root and removes all properties
|
||||
*/
|
||||
reset() {
|
||||
if (this.signals) {
|
||||
// Destruct all signals
|
||||
for (let i = 0; i < this.signals.length; ++i) {
|
||||
this.signals[i].removeAll();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.hud) {
|
||||
this.hud.cleanup();
|
||||
}
|
||||
if (this.camera) {
|
||||
this.camera.cleanup();
|
||||
}
|
||||
|
||||
// Finally free all properties
|
||||
for (let prop in this) {
|
||||
if (this.hasOwnProperty(prop)) {
|
||||
delete this[prop];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
447
src/js/game/shape_definition.js
Normal file
447
src/js/game/shape_definition.js
Normal file
@@ -0,0 +1,447 @@
|
||||
import { makeOffscreenBuffer } from "../core/buffer_utils";
|
||||
import { JSON_parse, JSON_stringify, Math_max, Math_PI, Math_radians } from "../core/builtins";
|
||||
import { globalConfig } from "../core/config";
|
||||
import { smoothenDpi } from "../core/dpi_manager";
|
||||
import { DrawParameters } from "../core/draw_parameters";
|
||||
import { createLogger } from "../core/logging";
|
||||
import { Vector } from "../core/vector";
|
||||
import { BasicSerializableObject } from "../savegame/serialization";
|
||||
import { enumColors, enumColorsToHexCode, enumColorToShortcode, enumShortcodeToColor } from "./colors";
|
||||
|
||||
const rusha = require("rusha");
|
||||
|
||||
const logger = createLogger("shape_definition");
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* subShape: enumSubShape,
|
||||
* color: enumColors,
|
||||
* }} ShapeLayerItem
|
||||
*/
|
||||
|
||||
/**
|
||||
* Order is Q1 (tr), Q2(br), Q3(bl), Q4(tl)
|
||||
* @typedef {[ShapeLayerItem?, ShapeLayerItem?, ShapeLayerItem?, ShapeLayerItem?]} ShapeLayer
|
||||
*/
|
||||
|
||||
const arrayQuadrantIndexToOffset = [
|
||||
new Vector(1, -1), // tr
|
||||
new Vector(1, 1), // br
|
||||
new Vector(-1, 1), // bl
|
||||
new Vector(-1, -1), // tl
|
||||
];
|
||||
|
||||
/** @enum {string} */
|
||||
export const enumSubShape = {
|
||||
rect: "rect",
|
||||
circle: "circle",
|
||||
star: "star",
|
||||
windmill: "windmill",
|
||||
};
|
||||
|
||||
/** @enum {string} */
|
||||
export const enumSubShapeToShortcode = {
|
||||
[enumSubShape.rect]: "R",
|
||||
[enumSubShape.circle]: "C",
|
||||
[enumSubShape.star]: "S",
|
||||
[enumSubShape.windmill]: "W",
|
||||
};
|
||||
|
||||
/** @enum {enumSubShape} */
|
||||
export const enumShortcodeToSubShape = {};
|
||||
for (const key in enumSubShapeToShortcode) {
|
||||
enumShortcodeToSubShape[enumSubShapeToShortcode[key]] = key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the given parameters to a valid shape definition
|
||||
* @param {*} layers
|
||||
* @returns {Array<import("./shape_definition").ShapeLayer>}
|
||||
*/
|
||||
export function createSimpleShape(layers) {
|
||||
layers.forEach(layer => {
|
||||
layer.forEach(item => {
|
||||
if (item) {
|
||||
item.color = item.color || enumColors.uncolored;
|
||||
}
|
||||
});
|
||||
});
|
||||
return layers;
|
||||
}
|
||||
|
||||
export class ShapeDefinition extends BasicSerializableObject {
|
||||
static getId() {
|
||||
return "ShapeDefinition";
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {Array<ShapeLayer>=} param0.layers
|
||||
*/
|
||||
constructor({ layers = [] }) {
|
||||
super();
|
||||
|
||||
/**
|
||||
* The layers from bottom to top
|
||||
* @type {Array<ShapeLayer>} */
|
||||
this.layers = layers;
|
||||
|
||||
/** @type {string} */
|
||||
this.cachedHash = null;
|
||||
|
||||
// Set on demand
|
||||
this.bufferGenerator = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the definition from the given short key
|
||||
*/
|
||||
static fromShortKey(key) {
|
||||
const sourceLayers = key.split(":");
|
||||
let layers = [];
|
||||
for (let i = 0; i < sourceLayers.length; ++i) {
|
||||
const text = sourceLayers[i];
|
||||
assert(text.length === 8, "Invalid shape short key: " + key);
|
||||
|
||||
/** @type {ShapeLayer} */
|
||||
const quads = [null, null, null, null];
|
||||
for (let quad = 0; quad < 4; ++quad) {
|
||||
const shapeText = text[quad * 2 + 0];
|
||||
const subShape = enumShortcodeToSubShape[shapeText];
|
||||
const color = enumShortcodeToColor[text[quad * 2 + 1]];
|
||||
if (subShape) {
|
||||
assert(color, "Invalid shape short key:", key);
|
||||
quads[quad] = {
|
||||
subShape,
|
||||
color,
|
||||
};
|
||||
} else if (shapeText !== "-") {
|
||||
assert(false, "Invalid shape key: " + shapeText);
|
||||
}
|
||||
}
|
||||
layers.push(quads);
|
||||
}
|
||||
|
||||
return new ShapeDefinition({ layers });
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to clone the shape definition
|
||||
* @returns {Array<ShapeLayer>}
|
||||
*/
|
||||
internalCloneLayers() {
|
||||
return JSON_parse(JSON_stringify(this.layers));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the definition is entirely empty^
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isEntirelyEmpty() {
|
||||
return this.layers.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a unique id for this shape
|
||||
* @returns {string}
|
||||
*/
|
||||
getHash() {
|
||||
if (this.cachedHash) {
|
||||
return this.cachedHash;
|
||||
}
|
||||
|
||||
let id = "";
|
||||
for (let layerIndex = 0; layerIndex < this.layers.length; ++layerIndex) {
|
||||
const layer = this.layers[layerIndex];
|
||||
|
||||
for (let quadrant = 0; quadrant < layer.length; ++quadrant) {
|
||||
const item = layer[quadrant];
|
||||
if (item) {
|
||||
id += enumSubShapeToShortcode[item.subShape] + enumColorToShortcode[item.color];
|
||||
} else {
|
||||
id += "--";
|
||||
}
|
||||
}
|
||||
}
|
||||
this.cachedHash = id;
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the shape definition
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
draw(x, y, parameters, size = 20) {
|
||||
const dpi = smoothenDpi(globalConfig.shapesSharpness * parameters.zoomLevel);
|
||||
|
||||
if (!this.bufferGenerator) {
|
||||
this.bufferGenerator = this.internalGenerateShapeBuffer.bind(this);
|
||||
}
|
||||
|
||||
const key = size + "/" + dpi;
|
||||
const canvas = parameters.root.buffers.getForKey(
|
||||
key,
|
||||
this.cachedHash,
|
||||
size,
|
||||
size,
|
||||
dpi,
|
||||
this.bufferGenerator
|
||||
);
|
||||
parameters.context.drawImage(canvas, x - size / 2, y - size / 2, size, size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates this shape as a canvas
|
||||
* @param {number} size
|
||||
*/
|
||||
generateAsCanvas(size = 20) {
|
||||
const [canvas, context] = makeOffscreenBuffer(size, size, {
|
||||
smooth: true,
|
||||
label: "definition-canvas-cache-" + this.getHash(),
|
||||
reusable: false,
|
||||
});
|
||||
|
||||
this.internalGenerateShapeBuffer(canvas, context, size, size, 1);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
* @param {number} dpi
|
||||
*/
|
||||
internalGenerateShapeBuffer(canvas, context, w, h, dpi) {
|
||||
context.translate((w * dpi) / 2, (h * dpi) / 2);
|
||||
context.scale((dpi * w) / 23, (dpi * h) / 23);
|
||||
|
||||
context.fillStyle = "#e9ecf7";
|
||||
|
||||
const quadrantSize = 10;
|
||||
const quadrantHalfSize = quadrantSize / 2;
|
||||
|
||||
context.fillStyle = "rgba(40, 50, 65, 0.1)";
|
||||
context.beginCircle(0, 0, quadrantSize * 1.15);
|
||||
context.fill();
|
||||
|
||||
for (let layerIndex = 0; layerIndex < this.layers.length; ++layerIndex) {
|
||||
const quadrants = this.layers[layerIndex];
|
||||
|
||||
const layerScale = Math_max(0.1, 0.9 - layerIndex * 0.22);
|
||||
|
||||
for (let quadrantIndex = 0; quadrantIndex < 4; ++quadrantIndex) {
|
||||
if (!quadrants[quadrantIndex]) {
|
||||
continue;
|
||||
}
|
||||
const { subShape, color } = quadrants[quadrantIndex];
|
||||
|
||||
const quadrantPos = arrayQuadrantIndexToOffset[quadrantIndex];
|
||||
const centerQuadrantX = quadrantPos.x * quadrantHalfSize;
|
||||
const centerQuadrantY = quadrantPos.y * quadrantHalfSize;
|
||||
|
||||
const rotation = Math_radians(quadrantIndex * 90);
|
||||
|
||||
context.translate(centerQuadrantX, centerQuadrantY);
|
||||
context.rotate(rotation);
|
||||
|
||||
context.fillStyle = enumColorsToHexCode[color];
|
||||
context.strokeStyle = "#555";
|
||||
context.lineWidth = 1;
|
||||
|
||||
const insetPadding = 0.0;
|
||||
|
||||
switch (subShape) {
|
||||
case enumSubShape.rect: {
|
||||
context.beginPath();
|
||||
const dims = quadrantSize * layerScale;
|
||||
context.rect(
|
||||
insetPadding + -quadrantHalfSize,
|
||||
-insetPadding + quadrantHalfSize - dims,
|
||||
dims,
|
||||
dims
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
case enumSubShape.star: {
|
||||
context.beginPath();
|
||||
const dims = quadrantSize * layerScale;
|
||||
|
||||
let originX = insetPadding - quadrantHalfSize;
|
||||
let originY = -insetPadding + quadrantHalfSize - dims;
|
||||
|
||||
const moveInwards = dims * 0.4;
|
||||
context.moveTo(originX, originY + moveInwards);
|
||||
context.lineTo(originX + dims, originY);
|
||||
context.lineTo(originX + dims - moveInwards, originY + dims);
|
||||
context.lineTo(originX, originY + dims);
|
||||
context.closePath();
|
||||
break;
|
||||
}
|
||||
|
||||
case enumSubShape.windmill: {
|
||||
context.beginPath();
|
||||
const dims = quadrantSize * layerScale;
|
||||
|
||||
let originX = insetPadding - quadrantHalfSize;
|
||||
let originY = -insetPadding + quadrantHalfSize - dims;
|
||||
const moveInwards = dims * 0.4;
|
||||
context.moveTo(originX, originY + moveInwards);
|
||||
context.lineTo(originX + dims, originY);
|
||||
context.lineTo(originX + dims, originY + dims);
|
||||
context.lineTo(originX, originY + dims);
|
||||
context.closePath();
|
||||
break;
|
||||
}
|
||||
|
||||
case enumSubShape.circle: {
|
||||
context.beginPath();
|
||||
context.moveTo(insetPadding + -quadrantHalfSize, -insetPadding + quadrantHalfSize);
|
||||
context.arc(
|
||||
insetPadding + -quadrantHalfSize,
|
||||
-insetPadding + quadrantHalfSize,
|
||||
quadrantSize * layerScale,
|
||||
-Math_PI * 0.5,
|
||||
0
|
||||
);
|
||||
context.closePath();
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
assertAlways(false, "Unkown sub shape: " + subShape);
|
||||
}
|
||||
}
|
||||
|
||||
context.fill();
|
||||
context.stroke();
|
||||
|
||||
context.rotate(-rotation);
|
||||
context.translate(-centerQuadrantX, -centerQuadrantY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a definition with only the given quadrants
|
||||
* @param {Array<number>} includeQuadrants
|
||||
* @returns {ShapeDefinition}
|
||||
*/
|
||||
cloneFilteredByQuadrants(includeQuadrants) {
|
||||
const newLayers = this.internalCloneLayers();
|
||||
for (let layerIndex = 0; layerIndex < newLayers.length; ++layerIndex) {
|
||||
const quadrants = newLayers[layerIndex];
|
||||
let anyContents = false;
|
||||
for (let quadrantIndex = 0; quadrantIndex < 4; ++quadrantIndex) {
|
||||
if (includeQuadrants.indexOf(quadrantIndex) < 0) {
|
||||
quadrants[quadrantIndex] = null;
|
||||
} else if (quadrants[quadrantIndex]) {
|
||||
anyContents = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the layer is entirely empty
|
||||
if (!anyContents) {
|
||||
newLayers.splice(layerIndex, 1);
|
||||
layerIndex -= 1;
|
||||
}
|
||||
}
|
||||
return new ShapeDefinition({ layers: newLayers });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a definition which was rotated clockwise
|
||||
* @returns {ShapeDefinition}
|
||||
*/
|
||||
cloneRotateCW() {
|
||||
const newLayers = this.internalCloneLayers();
|
||||
for (let layerIndex = 0; layerIndex < newLayers.length; ++layerIndex) {
|
||||
const quadrants = newLayers[layerIndex];
|
||||
quadrants.unshift(quadrants[3]);
|
||||
quadrants.pop();
|
||||
}
|
||||
return new ShapeDefinition({ layers: newLayers });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stacks the given shape definition on top.
|
||||
* @param {ShapeDefinition} definition
|
||||
*/
|
||||
cloneAndStackWith(definition) {
|
||||
const newLayers = this.internalCloneLayers();
|
||||
|
||||
if (this.isEntirelyEmpty() || definition.isEntirelyEmpty()) {
|
||||
assert(false, "Can not stack entirely empty definition");
|
||||
}
|
||||
|
||||
// Put layer for layer on top
|
||||
for (let i = 0; i < definition.layers.length; ++i) {
|
||||
const layerToAdd = definition.layers[i];
|
||||
|
||||
// On which layer we can merge this upper layer
|
||||
let mergeOnLayerIndex = null;
|
||||
|
||||
// Go from top to bottom and check if there is anything intercepting it
|
||||
for (let k = newLayers.length - 1; k >= 0; --k) {
|
||||
const lowerLayer = newLayers[k];
|
||||
|
||||
let canMerge = true;
|
||||
for (let quadrantIndex = 0; quadrantIndex < 4; ++quadrantIndex) {
|
||||
const upperItem = layerToAdd[quadrantIndex];
|
||||
const lowerItem = lowerLayer[quadrantIndex];
|
||||
|
||||
if (upperItem && lowerItem) {
|
||||
// so, we can't merge it because two items conflict
|
||||
canMerge = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If we can merge it, store it - since we go from top to bottom
|
||||
// we can simply override it
|
||||
if (canMerge) {
|
||||
mergeOnLayerIndex = k;
|
||||
}
|
||||
}
|
||||
|
||||
if (mergeOnLayerIndex !== null) {
|
||||
// Simply merge using an OR mask
|
||||
for (let quadrantIndex = 0; quadrantIndex < 4; ++quadrantIndex) {
|
||||
newLayers[mergeOnLayerIndex][quadrantIndex] =
|
||||
newLayers[mergeOnLayerIndex][quadrantIndex] || layerToAdd[quadrantIndex];
|
||||
}
|
||||
} else {
|
||||
// Add new layer
|
||||
newLayers.push(layerToAdd);
|
||||
}
|
||||
}
|
||||
|
||||
newLayers.splice(4);
|
||||
|
||||
return new ShapeDefinition({ layers: newLayers });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones the shape and colors everything in the given color
|
||||
* @param {enumColors} color
|
||||
*/
|
||||
cloneAndPaintWith(color) {
|
||||
const newLayers = this.internalCloneLayers();
|
||||
|
||||
for (let layerIndex = 0; layerIndex < newLayers.length; ++layerIndex) {
|
||||
const quadrants = newLayers[layerIndex];
|
||||
for (let quadrantIndex = 0; quadrantIndex < 4; ++quadrantIndex) {
|
||||
const item = quadrants[quadrantIndex];
|
||||
if (item) {
|
||||
item.color = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ShapeDefinition({ layers: newLayers });
|
||||
}
|
||||
}
|
||||
142
src/js/game/shape_definition_manager.js
Normal file
142
src/js/game/shape_definition_manager.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import { BasicSerializableObject } from "../savegame/serialization";
|
||||
import { GameRoot } from "./root";
|
||||
import { ShapeDefinition, enumSubShape } from "./shape_definition";
|
||||
import { createLogger } from "../core/logging";
|
||||
import { enumColors } from "./colors";
|
||||
|
||||
const logger = createLogger("shape_definition_manager");
|
||||
|
||||
export class ShapeDefinitionManager extends BasicSerializableObject {
|
||||
static getId() {
|
||||
return "ShapeDefinitionManager";
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
super();
|
||||
this.root = root;
|
||||
|
||||
this.shapeKeyToDefinition = {};
|
||||
|
||||
// Caches operations in the form of 'operation:def1[:def2]'
|
||||
/** @type {Object.<string, Array<ShapeDefinition>|ShapeDefinition>} */
|
||||
this.operationCache = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new shape definition
|
||||
* @param {ShapeDefinition} definition
|
||||
*/
|
||||
registerShapeDefinition(definition) {
|
||||
const id = definition.getHash();
|
||||
assert(!this.shapeKeyToDefinition[id], "Shape Definition " + id + " already exists");
|
||||
this.shapeKeyToDefinition[id] = definition;
|
||||
// logger.log("Registered shape with key", id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a definition for splitting a shape definition in two halfs
|
||||
* @param {ShapeDefinition} definition
|
||||
* @returns {[ShapeDefinition, ShapeDefinition]}
|
||||
*/
|
||||
shapeActionCutHalf(definition) {
|
||||
const key = "cut:" + definition.getHash();
|
||||
if (this.operationCache[key]) {
|
||||
return /** @type {[ShapeDefinition, ShapeDefinition]} */ (this.operationCache[key]);
|
||||
}
|
||||
const rightSide = definition.cloneFilteredByQuadrants([0, 1]);
|
||||
const leftSide = definition.cloneFilteredByQuadrants([2, 3]);
|
||||
|
||||
return /** @type {[ShapeDefinition, ShapeDefinition]} */ (this.operationCache[key] = [
|
||||
this.registerOrReturnHandle(rightSide),
|
||||
this.registerOrReturnHandle(leftSide),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a definition for rotating a shape clockwise
|
||||
* @param {ShapeDefinition} definition
|
||||
* @returns {ShapeDefinition}
|
||||
*/
|
||||
shapeActionRotateCW(definition) {
|
||||
const key = "rotate:" + definition.getHash();
|
||||
if (this.operationCache[key]) {
|
||||
return /** @type {ShapeDefinition} */ (this.operationCache[key]);
|
||||
}
|
||||
|
||||
const rotated = definition.cloneRotateCW();
|
||||
|
||||
return /** @type {ShapeDefinition} */ (this.operationCache[key] = this.registerOrReturnHandle(
|
||||
rotated
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a definition for stacking the upper definition onto the lower one
|
||||
* @param {ShapeDefinition} lowerDefinition
|
||||
* @param {ShapeDefinition} upperDefinition
|
||||
* @returns {ShapeDefinition}
|
||||
*/
|
||||
shapeActionStack(lowerDefinition, upperDefinition) {
|
||||
const key = "stack:" + lowerDefinition.getHash() + ":" + upperDefinition.getHash();
|
||||
if (this.operationCache[key]) {
|
||||
return /** @type {ShapeDefinition} */ (this.operationCache[key]);
|
||||
}
|
||||
const stacked = lowerDefinition.cloneAndStackWith(upperDefinition);
|
||||
return /** @type {ShapeDefinition} */ (this.operationCache[key] = this.registerOrReturnHandle(
|
||||
stacked
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a definition for painting it with the given color
|
||||
* @param {ShapeDefinition} definition
|
||||
* @param {string} color
|
||||
* @returns {ShapeDefinition}
|
||||
*/
|
||||
shapeActionPaintWith(definition, color) {
|
||||
const key = "paint:" + definition.getHash() + ":" + color;
|
||||
if (this.operationCache[key]) {
|
||||
return /** @type {ShapeDefinition} */ (this.operationCache[key]);
|
||||
}
|
||||
const colorized = definition.cloneAndPaintWith(color);
|
||||
return /** @type {ShapeDefinition} */ (this.operationCache[key] = this.registerOrReturnHandle(
|
||||
colorized
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if we already have cached this definition, and if so throws it away and returns the already
|
||||
* cached variant
|
||||
* @param {ShapeDefinition} definition
|
||||
*/
|
||||
registerOrReturnHandle(definition) {
|
||||
const id = definition.getHash();
|
||||
if (this.shapeKeyToDefinition[id]) {
|
||||
return this.shapeKeyToDefinition[id];
|
||||
}
|
||||
this.shapeKeyToDefinition[id] = definition;
|
||||
// logger.log("Registered shape with key (2)", id);
|
||||
return definition;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {[enumSubShape, enumSubShape, enumSubShape, enumSubShape]} subShapes
|
||||
* @returns {ShapeDefinition}
|
||||
*/
|
||||
getDefinitionFromSimpleShapes(subShapes, color = enumColors.uncolored) {
|
||||
const shapeLayer = /** @type {import("./shape_definition").ShapeLayer} */ (subShapes.map(
|
||||
subShape => ({
|
||||
subShape,
|
||||
rotation: 0,
|
||||
color,
|
||||
})
|
||||
));
|
||||
|
||||
return this.registerOrReturnHandle(new ShapeDefinition({ layers: [shapeLayer] }));
|
||||
}
|
||||
}
|
||||
83
src/js/game/sound_proxy.js
Normal file
83
src/js/game/sound_proxy.js
Normal file
@@ -0,0 +1,83 @@
|
||||
/* typehints:start */
|
||||
import { GameRoot } from "./root";
|
||||
/* typehints:end */
|
||||
|
||||
import { Vector } from "../core/vector";
|
||||
import { SOUNDS } from "../platform/sound";
|
||||
|
||||
const avgSoundDurationSeconds = 0.25;
|
||||
const maxOngoingSounds = 10;
|
||||
|
||||
// Proxy to the application sound instance
|
||||
export class SoundProxy {
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
|
||||
// Store a list of sounds and when we started them
|
||||
this.playingSounds = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays a new ui sound
|
||||
* @param {string} id Sound ID
|
||||
*/
|
||||
playUi(id) {
|
||||
assert(typeof id === "string", "Not a valid sound id: " + id);
|
||||
this.root.app.sound.playUiSound(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays the ui click sound
|
||||
*/
|
||||
playUiClick() {
|
||||
this.playUi(SOUNDS.uiClick);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays the ui error sound
|
||||
*/
|
||||
playUiError() {
|
||||
this.playUi(SOUNDS.uiError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays a 3D sound whose volume is scaled based on where it was emitted
|
||||
* @param {string} id Sound ID
|
||||
* @param {Vector} pos World space position
|
||||
*/
|
||||
play3D(id, pos) {
|
||||
assert(typeof id === "string", "Not a valid sound id: " + id);
|
||||
assert(pos instanceof Vector, "Invalid sound position");
|
||||
this.internalUpdateOngoingSounds();
|
||||
|
||||
if (this.playingSounds.length > maxOngoingSounds) {
|
||||
// Too many ongoing sounds
|
||||
// console.warn(
|
||||
// "Not playing",
|
||||
// id,
|
||||
// "because there are too many sounds playing"
|
||||
// );
|
||||
return false;
|
||||
}
|
||||
|
||||
this.root.app.sound.play3DSound(id, pos, this.root);
|
||||
this.playingSounds.push(this.root.time.realtimeNow());
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the list of ongoing sounds
|
||||
*/
|
||||
internalUpdateOngoingSounds() {
|
||||
const now = this.root.time.realtimeNow();
|
||||
for (let i = 0; i < this.playingSounds.length; ++i) {
|
||||
if (now - this.playingSounds[i] > avgSoundDurationSeconds) {
|
||||
this.playingSounds.splice(i, 1);
|
||||
i -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
201
src/js/game/systems/belt.js
Normal file
201
src/js/game/systems/belt.js
Normal file
@@ -0,0 +1,201 @@
|
||||
import { Math_radians, Math_min } from "../../core/builtins";
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { DrawParameters } from "../../core/draw_parameters";
|
||||
import { Loader } from "../../core/loader";
|
||||
import { AtlasSprite } from "../../core/sprites";
|
||||
import { BeltComponent } from "../components/belt";
|
||||
import { Entity } from "../entity";
|
||||
import { GameSystemWithFilter } from "../game_system_with_filter";
|
||||
import { enumDirection, enumDirectionToVector, Vector } from "../../core/vector";
|
||||
import { MapChunkView } from "../map_chunk_view";
|
||||
|
||||
const BELT_ANIM_COUNT = 6;
|
||||
|
||||
export class BeltSystem extends GameSystemWithFilter {
|
||||
constructor(root) {
|
||||
super(root, [BeltComponent]);
|
||||
/**
|
||||
* @type {Object.<enumDirection, Array<AtlasSprite>>}
|
||||
*/
|
||||
this.beltSprites = {
|
||||
[enumDirection.top]: Loader.getSprite("sprites/belt/forward_0.png"),
|
||||
[enumDirection.left]: Loader.getSprite("sprites/belt/left_0.png"),
|
||||
[enumDirection.right]: Loader.getSprite("sprites/belt/right_0.png"),
|
||||
};
|
||||
/**
|
||||
* @type {Object.<enumDirection, Array<AtlasSprite>>}
|
||||
*/
|
||||
this.beltAnimations = {
|
||||
[enumDirection.top]: [
|
||||
Loader.getSprite("sprites/belt/forward_0.png"),
|
||||
Loader.getSprite("sprites/belt/forward_1.png"),
|
||||
Loader.getSprite("sprites/belt/forward_2.png"),
|
||||
Loader.getSprite("sprites/belt/forward_3.png"),
|
||||
Loader.getSprite("sprites/belt/forward_4.png"),
|
||||
Loader.getSprite("sprites/belt/forward_5.png"),
|
||||
],
|
||||
[enumDirection.left]: [
|
||||
Loader.getSprite("sprites/belt/left_0.png"),
|
||||
Loader.getSprite("sprites/belt/left_1.png"),
|
||||
Loader.getSprite("sprites/belt/left_2.png"),
|
||||
Loader.getSprite("sprites/belt/left_3.png"),
|
||||
Loader.getSprite("sprites/belt/left_4.png"),
|
||||
Loader.getSprite("sprites/belt/left_5.png"),
|
||||
],
|
||||
[enumDirection.right]: [
|
||||
Loader.getSprite("sprites/belt/right_0.png"),
|
||||
Loader.getSprite("sprites/belt/right_1.png"),
|
||||
Loader.getSprite("sprites/belt/right_2.png"),
|
||||
Loader.getSprite("sprites/belt/right_3.png"),
|
||||
Loader.getSprite("sprites/belt/right_4.png"),
|
||||
Loader.getSprite("sprites/belt/right_5.png"),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
draw(parameters) {
|
||||
this.forEachMatchingEntityOnScreen(parameters, this.drawEntityItems.bind(this));
|
||||
}
|
||||
|
||||
update() {
|
||||
const beltSpeed = this.root.hubGoals.getBeltBaseSpeed() * globalConfig.physicsDeltaSeconds;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
itemAndProgress[0] = Math_min(newProgress, maxProgress);
|
||||
maxProgress = itemAndProgress[0] - globalConfig.itemSpacingOnBelts;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {MapChunkView} chunk
|
||||
*/
|
||||
drawChunk(parameters, chunk) {
|
||||
if (parameters.zoomLevel < globalConfig.mapChunkOverviewMinZoom) {
|
||||
return;
|
||||
1;
|
||||
}
|
||||
|
||||
const speedMultiplier = this.root.hubGoals.getBeltBaseSpeed();
|
||||
|
||||
// SYNC with systems/item_processor.js:drawEntityUnderlays!
|
||||
// 126 / 42 is the exact animation speed of the png animation
|
||||
const animationIndex = Math.floor(
|
||||
(this.root.time.now() * speedMultiplier * BELT_ANIM_COUNT * 126) / 42
|
||||
);
|
||||
const contents = chunk.contents;
|
||||
for (let y = 0; y < globalConfig.mapChunkSize; ++y) {
|
||||
for (let x = 0; x < globalConfig.mapChunkSize; ++x) {
|
||||
const entity = contents[x][y];
|
||||
|
||||
if (entity && entity.components.Belt) {
|
||||
const direction = entity.components.Belt.direction;
|
||||
const sprite = this.beltAnimations[direction][animationIndex % BELT_ANIM_COUNT];
|
||||
|
||||
entity.components.StaticMapEntity.drawSpriteOnFullEntityBounds(
|
||||
parameters,
|
||||
sprite,
|
||||
0,
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
drawEntityItems(parameters, entity) {
|
||||
const beltComp = entity.components.Belt;
|
||||
const staticComp = entity.components.StaticMapEntity;
|
||||
|
||||
const items = beltComp.sortedItems;
|
||||
|
||||
if (items.length === 0) {
|
||||
// Fast out for performance
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < items.length; ++i) {
|
||||
const itemAndProgress = items[i];
|
||||
|
||||
// Nice would be const [pos, item] = itemAndPos; but that gets polyfilled and is super slow then
|
||||
const progress = itemAndProgress[0];
|
||||
const item = itemAndProgress[1];
|
||||
|
||||
const position = staticComp.applyRotationToVector(beltComp.transformBeltToLocalSpace(progress));
|
||||
|
||||
item.draw(
|
||||
(staticComp.origin.x + position.x + 0.5) * globalConfig.tileSize,
|
||||
(staticComp.origin.y + position.y + 0.5) * globalConfig.tileSize,
|
||||
parameters
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
83
src/js/game/systems/hub.js
Normal file
83
src/js/game/systems/hub.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import { GameSystemWithFilter } from "../game_system_with_filter";
|
||||
import { HubComponent } from "../components/hub";
|
||||
import { DrawParameters } from "../../core/draw_parameters";
|
||||
import { Entity } from "../entity";
|
||||
import { formatBigNumber } from "../../core/utils";
|
||||
|
||||
export class HubSystem extends GameSystemWithFilter {
|
||||
constructor(root) {
|
||||
super(root, [HubComponent]);
|
||||
}
|
||||
|
||||
draw(parameters) {
|
||||
this.forEachMatchingEntityOnScreen(parameters, this.drawEntity.bind(this));
|
||||
}
|
||||
|
||||
update() {
|
||||
for (let i = 0; i < this.allEntities.length; ++i) {
|
||||
const entity = this.allEntities[i];
|
||||
|
||||
const hubComponent = entity.components.Hub;
|
||||
|
||||
const queue = hubComponent.definitionsToAnalyze;
|
||||
for (let k = 0; k < queue.length; ++k) {
|
||||
const definition = queue[k];
|
||||
this.root.hubGoals.handleDefinitionDelivered(definition);
|
||||
}
|
||||
|
||||
hubComponent.definitionsToAnalyze = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
drawEntity(parameters, entity) {
|
||||
const context = parameters.context;
|
||||
const staticComp = entity.components.StaticMapEntity;
|
||||
|
||||
const pos = staticComp.getTileSpaceBounds().getCenter().toWorldSpace();
|
||||
|
||||
const definition = this.root.hubGoals.currentGoal.definition;
|
||||
|
||||
definition.draw(pos.x - 25, pos.y - 10, parameters, 40);
|
||||
|
||||
const goals = this.root.hubGoals.currentGoal;
|
||||
|
||||
const textOffsetX = 2;
|
||||
const textOffsetY = -6;
|
||||
|
||||
// Deliver count
|
||||
context.font = "bold 25px GameFont";
|
||||
context.fillStyle = "#64666e";
|
||||
context.textAlign = "left";
|
||||
context.fillText(
|
||||
"" + formatBigNumber(this.root.hubGoals.getCurrentGoalDelivered()),
|
||||
pos.x + textOffsetX,
|
||||
pos.y + textOffsetY
|
||||
);
|
||||
|
||||
// Required
|
||||
context.font = "13px GameFont";
|
||||
context.fillStyle = "#a4a6b0";
|
||||
context.fillText(
|
||||
"/ " + formatBigNumber(goals.required),
|
||||
pos.x + textOffsetX,
|
||||
pos.y + textOffsetY + 13
|
||||
);
|
||||
|
||||
// Reward
|
||||
context.font = "bold 11px GameFont";
|
||||
context.fillStyle = "#fd0752";
|
||||
context.textAlign = "center";
|
||||
context.fillText(goals.reward.toUpperCase(), pos.x, pos.y + 46);
|
||||
|
||||
// Level
|
||||
context.font = "bold 11px GameFont";
|
||||
context.fillStyle = "#fff";
|
||||
context.fillText("" + this.root.hubGoals.level, pos.x - 42, pos.y - 36);
|
||||
|
||||
context.textAlign = "left";
|
||||
}
|
||||
}
|
||||
173
src/js/game/systems/item_ejector.js
Normal file
173
src/js/game/systems/item_ejector.js
Normal file
@@ -0,0 +1,173 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { DrawParameters } from "../../core/draw_parameters";
|
||||
import { enumDirectionToVector, Vector } from "../../core/vector";
|
||||
import { BaseItem } from "../base_item";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { Entity } from "../entity";
|
||||
import { GameSystemWithFilter } from "../game_system_with_filter";
|
||||
import { Math_min } from "../../core/builtins";
|
||||
|
||||
export class ItemEjectorSystem extends GameSystemWithFilter {
|
||||
constructor(root) {
|
||||
super(root, [ItemEjectorComponent]);
|
||||
}
|
||||
|
||||
update() {
|
||||
const effectiveBeltSpeed = this.root.hubGoals.getBeltBaseSpeed();
|
||||
const progressGrowth = (effectiveBeltSpeed / 0.5) * globalConfig.physicsDeltaSeconds;
|
||||
|
||||
// Try to find acceptors for every ejector
|
||||
for (let i = 0; i < this.allEntities.length; ++i) {
|
||||
const entity = this.allEntities[i];
|
||||
const ejectorComp = entity.components.ItemEjector;
|
||||
const staticComp = entity.components.StaticMapEntity;
|
||||
|
||||
// For every ejector slot, try to find an acceptor
|
||||
for (let ejectorSlotIndex = 0; ejectorSlotIndex < ejectorComp.slots.length; ++ejectorSlotIndex) {
|
||||
const ejectorSlot = ejectorComp.slots[ejectorSlotIndex];
|
||||
const ejectingItem = ejectorSlot.item;
|
||||
if (!ejectingItem) {
|
||||
// No item ejected
|
||||
continue;
|
||||
}
|
||||
|
||||
ejectorSlot.progress = Math_min(1, ejectorSlot.progress + progressGrowth);
|
||||
if (ejectorSlot.progress < 1.0) {
|
||||
// Still ejecting
|
||||
continue;
|
||||
}
|
||||
|
||||
// Figure out where and into which direction we eject items
|
||||
const ejectSlotWsTile = staticComp.localTileToWorld(ejectorSlot.pos);
|
||||
const ejectSlotWsDirection = staticComp.localDirectionToWorld(ejectorSlot.direction);
|
||||
const ejectSlotWsDirectionVector = enumDirectionToVector[ejectSlotWsDirection];
|
||||
const ejectSlotTargetWsTile = ejectSlotWsTile.add(ejectSlotWsDirectionVector);
|
||||
|
||||
// Try to find the given acceptor component to take the item
|
||||
const targetEntity = this.root.map.getTileContent(ejectSlotTargetWsTile);
|
||||
if (!targetEntity) {
|
||||
// No consumer for item
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetAcceptorComp = targetEntity.components.ItemAcceptor;
|
||||
const targetStaticComp = targetEntity.components.StaticMapEntity;
|
||||
if (!targetAcceptorComp) {
|
||||
// Entity doesn't accept items
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchingSlot = targetAcceptorComp.findMatchingSlot(
|
||||
targetStaticComp.worldToLocalTile(ejectSlotTargetWsTile),
|
||||
targetStaticComp.worldDirectionToLocal(ejectSlotWsDirection)
|
||||
);
|
||||
|
||||
if (!matchingSlot) {
|
||||
// No matching slot found
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!targetAcceptorComp.canAcceptItem(matchingSlot.index, ejectingItem)) {
|
||||
// Can not accept item
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
this.tryPassOverItem(
|
||||
ejectingItem,
|
||||
targetEntity,
|
||||
matchingSlot.index,
|
||||
matchingSlot.acceptedDirection
|
||||
)
|
||||
) {
|
||||
ejectorSlot.item = null;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {BaseItem} item
|
||||
* @param {Entity} receiver
|
||||
* @param {number} slotIndex
|
||||
* @param {string} localDirection
|
||||
*/
|
||||
tryPassOverItem(item, receiver, slotIndex, localDirection) {
|
||||
// Try figuring out how what to do with the item
|
||||
// TODO: Kinda hacky. How to solve this properly? Don't want to go through inheritance hell.
|
||||
// Also its just a few cases (hope it stays like this .. :x).
|
||||
|
||||
const beltComp = receiver.components.Belt;
|
||||
if (beltComp) {
|
||||
// Ayy, its a belt!
|
||||
if (beltComp.canAcceptNewItem(localDirection)) {
|
||||
beltComp.takeNewItem(item, localDirection);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const itemProcessorComp = receiver.components.ItemProcessor;
|
||||
if (itemProcessorComp) {
|
||||
// Its an item processor ..
|
||||
if (itemProcessorComp.tryTakeItem(item, slotIndex, localDirection)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const undergroundBeltCmop = receiver.components.UndergroundBelt;
|
||||
if (undergroundBeltCmop) {
|
||||
// Its an underground belt. yay.
|
||||
if (
|
||||
undergroundBeltCmop.tryAcceptExternalItem(
|
||||
item,
|
||||
this.root.hubGoals.getUndergroundBeltBaseSpeed()
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
draw(parameters) {
|
||||
this.forEachMatchingEntityOnScreen(parameters, this.drawSingleEntity.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
drawSingleEntity(parameters, entity) {
|
||||
const ejectorComp = entity.components.ItemEjector;
|
||||
const staticComp = entity.components.StaticMapEntity;
|
||||
|
||||
for (let i = 0; i < ejectorComp.slots.length; ++i) {
|
||||
const slot = ejectorComp.slots[i];
|
||||
const ejectedItem = slot.item;
|
||||
if (!ejectedItem) {
|
||||
// No item
|
||||
continue;
|
||||
}
|
||||
|
||||
const realPosition = slot.pos.rotateFastMultipleOf90(staticComp.rotationDegrees);
|
||||
const realDirection = Vector.transformDirectionFromMultipleOf90(
|
||||
slot.direction,
|
||||
staticComp.rotationDegrees
|
||||
);
|
||||
const realDirectionVector = enumDirectionToVector[realDirection];
|
||||
|
||||
const tileX =
|
||||
staticComp.origin.x + realPosition.x + 0.5 + realDirectionVector.x * 0.5 * slot.progress;
|
||||
const tileY =
|
||||
staticComp.origin.y + realPosition.y + 0.5 + realDirectionVector.y * 0.5 * slot.progress;
|
||||
|
||||
const worldX = tileX * globalConfig.tileSize;
|
||||
const worldY = tileY * globalConfig.tileSize;
|
||||
|
||||
ejectedItem.draw(worldX, worldY, parameters);
|
||||
}
|
||||
}
|
||||
}
|
||||
341
src/js/game/systems/item_processor.js
Normal file
341
src/js/game/systems/item_processor.js
Normal file
@@ -0,0 +1,341 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { DrawParameters } from "../../core/draw_parameters";
|
||||
import { Loader } from "../../core/loader";
|
||||
import { Entity } from "../entity";
|
||||
import { GameSystemWithFilter } from "../game_system_with_filter";
|
||||
import { ItemProcessorComponent, enumItemProcessorTypes } from "../components/item_processor";
|
||||
import { Math_max, Math_radians } from "../../core/builtins";
|
||||
import { BaseItem } from "../base_item";
|
||||
import { ShapeItem } from "../items/shape_item";
|
||||
import { enumDirectionToVector, enumDirection, enumDirectionToAngle } from "../../core/vector";
|
||||
import { ColorItem } from "../items/color_item";
|
||||
import { enumColorMixingResults } from "../colors";
|
||||
import { drawRotatedSprite } from "../../core/draw_utils";
|
||||
|
||||
export class ItemProcessorSystem extends GameSystemWithFilter {
|
||||
constructor(root) {
|
||||
super(root, [ItemProcessorComponent]);
|
||||
|
||||
this.sprites = {};
|
||||
for (const key in enumItemProcessorTypes) {
|
||||
this.sprites[key] = Loader.getSprite("sprites/buildings/" + key + ".png");
|
||||
}
|
||||
|
||||
this.underlayBeltSprites = [
|
||||
Loader.getSprite("sprites/belt/forward_0.png"),
|
||||
Loader.getSprite("sprites/belt/forward_1.png"),
|
||||
Loader.getSprite("sprites/belt/forward_2.png"),
|
||||
Loader.getSprite("sprites/belt/forward_3.png"),
|
||||
Loader.getSprite("sprites/belt/forward_4.png"),
|
||||
Loader.getSprite("sprites/belt/forward_5.png"),
|
||||
];
|
||||
}
|
||||
|
||||
draw(parameters) {
|
||||
this.forEachMatchingEntityOnScreen(parameters, this.drawEntity.bind(this));
|
||||
}
|
||||
|
||||
drawUnderlays(parameters) {
|
||||
this.forEachMatchingEntityOnScreen(parameters, this.drawEntityUnderlays.bind(this));
|
||||
}
|
||||
|
||||
update() {
|
||||
for (let i = 0; i < this.allEntities.length; ++i) {
|
||||
const entity = this.allEntities[i];
|
||||
|
||||
const processorComp = entity.components.ItemProcessor;
|
||||
const ejectorComp = entity.components.ItemEjector;
|
||||
|
||||
// First of all, process the current recipe
|
||||
processorComp.secondsUntilEject = Math_max(
|
||||
0,
|
||||
processorComp.secondsUntilEject - globalConfig.physicsDeltaSeconds
|
||||
);
|
||||
|
||||
// Also, process item consumption animations to avoid items popping from the belts
|
||||
for (let animIndex = 0; animIndex < processorComp.itemConsumptionAnimations.length; ++animIndex) {
|
||||
const anim = processorComp.itemConsumptionAnimations[animIndex];
|
||||
anim.animProgress +=
|
||||
globalConfig.physicsDeltaSeconds * this.root.hubGoals.getBeltBaseSpeed() * 2;
|
||||
if (anim.animProgress > 1) {
|
||||
processorComp.itemConsumptionAnimations.splice(animIndex, 1);
|
||||
animIndex -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
} else {
|
||||
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 {
|
||||
processorComp.itemsToEject.splice(itemIndex, 1);
|
||||
itemIndex -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have an empty queue and can start a new charge
|
||||
if (processorComp.itemsToEject.length === 0) {
|
||||
if (processorComp.inputSlots.length === processorComp.inputsPerCharge) {
|
||||
this.startNewCharge(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a new charge for the entity
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
startNewCharge(entity) {
|
||||
const processorComp = entity.components.ItemProcessor;
|
||||
|
||||
// First, take items
|
||||
const items = processorComp.inputSlots;
|
||||
processorComp.inputSlots = [];
|
||||
|
||||
const baseSpeed = this.root.hubGoals.getProcessorBaseSpeed(processorComp.type);
|
||||
processorComp.secondsUntilEject = 1 / baseSpeed;
|
||||
|
||||
/** @type {Array<{item: BaseItem, requiredSlot?: number, preferredSlot?: number}>} */
|
||||
const outItems = [];
|
||||
|
||||
// DO SOME MAGIC
|
||||
|
||||
switch (processorComp.type) {
|
||||
// SPLITTER
|
||||
case enumItemProcessorTypes.splitter: {
|
||||
let nextSlot = processorComp.nextOutputSlot++ % 2;
|
||||
for (let i = 0; i < items.length; ++i) {
|
||||
outItems.push({
|
||||
item: items[i].item,
|
||||
preferredSlot: (nextSlot + i) % 2,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// CUTTER
|
||||
case enumItemProcessorTypes.cutter: {
|
||||
const inputItem = /** @type {ShapeItem} */ (items[0].item);
|
||||
assert(inputItem instanceof ShapeItem, "Input for cut is not a shape");
|
||||
const inputDefinition = inputItem.definition;
|
||||
|
||||
const [cutDefinition1, cutDefinition2] = this.root.shapeDefinitionMgr.shapeActionCutHalf(
|
||||
inputDefinition
|
||||
);
|
||||
|
||||
if (!cutDefinition1.isEntirelyEmpty()) {
|
||||
outItems.push({
|
||||
item: new ShapeItem(cutDefinition1),
|
||||
requiredSlot: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (!cutDefinition2.isEntirelyEmpty()) {
|
||||
outItems.push({
|
||||
item: new ShapeItem(cutDefinition2),
|
||||
requiredSlot: 1,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// ROTATER
|
||||
case enumItemProcessorTypes.rotater: {
|
||||
const inputItem = /** @type {ShapeItem} */ (items[0].item);
|
||||
assert(inputItem instanceof ShapeItem, "Input for cut is not a shape");
|
||||
const inputDefinition = inputItem.definition;
|
||||
|
||||
const rotatedDefinition = this.root.shapeDefinitionMgr.shapeActionRotateCW(inputDefinition);
|
||||
outItems.push({
|
||||
item: new ShapeItem(rotatedDefinition),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// STACKER
|
||||
|
||||
case enumItemProcessorTypes.stacker: {
|
||||
const item1 = items[0];
|
||||
const item2 = items[1];
|
||||
|
||||
const lowerItem = /** @type {ShapeItem} */ (item1.sourceSlot === 0 ? item1.item : item2.item);
|
||||
const upperItem = /** @type {ShapeItem} */ (item1.sourceSlot === 1 ? item1.item : item2.item);
|
||||
assert(lowerItem instanceof ShapeItem, "Input for lower stack is not a shape");
|
||||
assert(upperItem instanceof ShapeItem, "Input for upper stack is not a shape");
|
||||
|
||||
const stackedDefinition = this.root.shapeDefinitionMgr.shapeActionStack(
|
||||
lowerItem.definition,
|
||||
upperItem.definition
|
||||
);
|
||||
outItems.push({
|
||||
item: new ShapeItem(stackedDefinition),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// TRASH
|
||||
|
||||
case enumItemProcessorTypes.trash: {
|
||||
// Well this one is easy .. simply do nothing with the item
|
||||
break;
|
||||
}
|
||||
|
||||
// MIXER
|
||||
|
||||
case enumItemProcessorTypes.mixer: {
|
||||
// Find both colors and combine them
|
||||
const item1 = /** @type {ColorItem} */ (items[0].item);
|
||||
const item2 = /** @type {ColorItem} */ (items[1].item);
|
||||
assert(item1 instanceof ColorItem, "Input for color mixer is not a color");
|
||||
assert(item2 instanceof ColorItem, "Input for color mixer is not a color");
|
||||
|
||||
const color1 = item1.color;
|
||||
const color2 = item2.color;
|
||||
|
||||
// Try finding mixer color, and if we can't mix it we simply return the same color
|
||||
const mixedColor = enumColorMixingResults[color1][color2];
|
||||
let resultColor = color1;
|
||||
if (mixedColor) {
|
||||
resultColor = mixedColor;
|
||||
}
|
||||
outItems.push({
|
||||
item: new ColorItem(resultColor),
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// PAINTER
|
||||
|
||||
case enumItemProcessorTypes.painter: {
|
||||
const item1 = items[0];
|
||||
const item2 = items[1];
|
||||
|
||||
const shapeItem = /** @type {ShapeItem} */ (item1.sourceSlot === 0 ? item1.item : item2.item);
|
||||
const colorItem = /** @type {ColorItem} */ (item1.sourceSlot === 1 ? item1.item : item2.item);
|
||||
|
||||
const colorizedDefinition = this.root.shapeDefinitionMgr.shapeActionPaintWith(
|
||||
shapeItem.definition,
|
||||
colorItem.color
|
||||
);
|
||||
|
||||
outItems.push({
|
||||
item: new ShapeItem(colorizedDefinition),
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// HUB
|
||||
|
||||
case enumItemProcessorTypes.hub: {
|
||||
const shapeItem = /** @type {ShapeItem} */ (items[0].item);
|
||||
|
||||
const hubComponent = entity.components.Hub;
|
||||
assert(hubComponent, "Hub item processor has no hub component");
|
||||
|
||||
hubComponent.queueShapeDefinition(shapeItem.definition);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
assertAlways(false, "Unkown item processor type: " + processorComp.type);
|
||||
}
|
||||
|
||||
processorComp.itemsToEject = outItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
drawEntity(parameters, entity) {
|
||||
const staticComp = entity.components.StaticMapEntity;
|
||||
const processorComp = entity.components.ItemProcessor;
|
||||
const acceptorComp = entity.components.ItemAcceptor;
|
||||
|
||||
for (let animIndex = 0; animIndex < processorComp.itemConsumptionAnimations.length; ++animIndex) {
|
||||
const { item, slotIndex, animProgress, direction } = processorComp.itemConsumptionAnimations[
|
||||
animIndex
|
||||
];
|
||||
|
||||
const slotData = acceptorComp.slots[slotIndex];
|
||||
const slotWorldPos = staticComp.applyRotationToVector(slotData.pos).add(staticComp.origin);
|
||||
|
||||
const fadeOutDirection = enumDirectionToVector[staticComp.localDirectionToWorld(direction)];
|
||||
const finalTile = slotWorldPos.subScalars(
|
||||
fadeOutDirection.x * (animProgress / 2 - 0.5),
|
||||
fadeOutDirection.y * (animProgress / 2 - 0.5)
|
||||
);
|
||||
item.draw(
|
||||
(finalTile.x + 0.5) * globalConfig.tileSize,
|
||||
(finalTile.y + 0.5) * globalConfig.tileSize,
|
||||
parameters
|
||||
);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
drawEntityUnderlays(parameters, entity) {
|
||||
const staticComp = entity.components.StaticMapEntity;
|
||||
const processorComp = entity.components.ItemProcessor;
|
||||
|
||||
const underlays = processorComp.beltUnderlays;
|
||||
for (let i = 0; i < underlays.length; ++i) {
|
||||
const { pos, direction } = underlays[i];
|
||||
|
||||
const transformedPos = staticComp.localTileToWorld(pos);
|
||||
const angle = enumDirectionToAngle[staticComp.localDirectionToWorld(direction)];
|
||||
|
||||
// SYNC with systems/belt.js:drawSingleEntity!
|
||||
const animationIndex = Math.floor(
|
||||
(this.root.time.now() *
|
||||
this.root.hubGoals.getBeltBaseSpeed() *
|
||||
this.underlayBeltSprites.length *
|
||||
126) /
|
||||
42
|
||||
);
|
||||
|
||||
drawRotatedSprite({
|
||||
parameters,
|
||||
sprite: this.underlayBeltSprites[animationIndex % this.underlayBeltSprites.length],
|
||||
x: (transformedPos.x + 0.5) * globalConfig.tileSize,
|
||||
y: (transformedPos.y + 0.5) * globalConfig.tileSize,
|
||||
angle: Math_radians(angle),
|
||||
size: globalConfig.tileSize,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
56
src/js/game/systems/map_resources.js
Normal file
56
src/js/game/systems/map_resources.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { GameSystem } from "../game_system";
|
||||
import { DrawParameters } from "../../core/draw_parameters";
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { MapChunkView } from "../map_chunk_view";
|
||||
|
||||
export class MapResourcesSystem extends GameSystem {
|
||||
/**
|
||||
* Draws the map resources
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {MapChunkView} chunk
|
||||
*/
|
||||
drawChunk(parameters, chunk) {
|
||||
const renderItems = parameters.zoomLevel >= globalConfig.mapChunkOverviewMinZoom;
|
||||
|
||||
parameters.context.globalAlpha = 0.5;
|
||||
const layer = chunk.lowerLayer;
|
||||
for (let x = 0; x < globalConfig.mapChunkSize; ++x) {
|
||||
const row = layer[x];
|
||||
for (let y = 0; y < globalConfig.mapChunkSize; ++y) {
|
||||
const lowerItem = row[y];
|
||||
if (lowerItem) {
|
||||
parameters.context.fillStyle = lowerItem.getBackgroundColorAsResource();
|
||||
parameters.context.fillRect(
|
||||
(chunk.tileX + x) * globalConfig.tileSize,
|
||||
(chunk.tileY + y) * globalConfig.tileSize,
|
||||
globalConfig.tileSize,
|
||||
globalConfig.tileSize
|
||||
);
|
||||
if (renderItems) {
|
||||
lowerItem.draw(
|
||||
(chunk.tileX + x + 0.5) * globalConfig.tileSize,
|
||||
(chunk.tileY + y + 0.5) * globalConfig.tileSize,
|
||||
parameters
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
parameters.context.globalAlpha = 1;
|
||||
|
||||
if (!renderItems) {
|
||||
// Render patches instead
|
||||
const patches = chunk.patches;
|
||||
for (let i = 0; i < patches.length; ++i) {
|
||||
const { pos, item, size } = patches[i];
|
||||
|
||||
item.draw(
|
||||
(chunk.tileX + pos.x + 0.5) * globalConfig.tileSize,
|
||||
(chunk.tileY + pos.y + 0.5) * globalConfig.tileSize,
|
||||
parameters,
|
||||
80
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
87
src/js/game/systems/miner.js
Normal file
87
src/js/game/systems/miner.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { DrawParameters } from "../../core/draw_parameters";
|
||||
import { MinerComponent } from "../components/miner";
|
||||
import { GameSystemWithFilter } from "../game_system_with_filter";
|
||||
import { MapChunkView } from "../map_chunk_view";
|
||||
|
||||
export class MinerSystem extends GameSystemWithFilter {
|
||||
constructor(root) {
|
||||
super(root, [MinerComponent]);
|
||||
}
|
||||
|
||||
update() {
|
||||
const miningSpeed = this.root.hubGoals.getMinerBaseSpeed();
|
||||
for (let i = 0; i < this.allEntities.length; ++i) {
|
||||
const entity = this.allEntities[i];
|
||||
|
||||
const minerComp = entity.components.Miner;
|
||||
const staticComp = entity.components.StaticMapEntity;
|
||||
const ejectComp = entity.components.ItemEjector;
|
||||
|
||||
if (this.root.time.isIngameTimerExpired(minerComp.lastMiningTime, 1 / miningSpeed)) {
|
||||
if (!ejectComp.canEjectOnSlot(0)) {
|
||||
// We can't eject further
|
||||
continue;
|
||||
}
|
||||
|
||||
// Actually mine
|
||||
minerComp.lastMiningTime = this.root.time.now();
|
||||
|
||||
const lowerLayerItem = this.root.map.getLowerLayerContentXY(
|
||||
staticComp.origin.x,
|
||||
staticComp.origin.y
|
||||
);
|
||||
if (!lowerLayerItem) {
|
||||
// Nothing below;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try actually ejecting
|
||||
if (!ejectComp.tryEject(0, lowerLayerItem)) {
|
||||
assert(false, "Failed to eject");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {MapChunkView} chunk
|
||||
*/
|
||||
drawChunk(parameters, chunk) {
|
||||
const contents = chunk.contents;
|
||||
for (let y = 0; y < globalConfig.mapChunkSize; ++y) {
|
||||
for (let x = 0; x < globalConfig.mapChunkSize; ++x) {
|
||||
const entity = contents[x][y];
|
||||
|
||||
if (entity && entity.components.Miner) {
|
||||
const staticComp = entity.components.StaticMapEntity;
|
||||
const lowerLayerItem = this.root.map.getLowerLayerContentXY(
|
||||
staticComp.origin.x,
|
||||
staticComp.origin.y
|
||||
);
|
||||
|
||||
if (lowerLayerItem) {
|
||||
const padding = 3;
|
||||
parameters.context.fillStyle = lowerLayerItem.getBackgroundColorAsResource();
|
||||
parameters.context.fillRect(
|
||||
staticComp.origin.x * globalConfig.tileSize + padding,
|
||||
staticComp.origin.y * globalConfig.tileSize + padding,
|
||||
globalConfig.tileSize - 2 * padding,
|
||||
globalConfig.tileSize - 2 * padding
|
||||
);
|
||||
}
|
||||
|
||||
if (lowerLayerItem) {
|
||||
lowerLayerItem.draw(
|
||||
(0.5 + staticComp.origin.x) * globalConfig.tileSize,
|
||||
(0.5 + staticComp.origin.y) * globalConfig.tileSize,
|
||||
parameters
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
72
src/js/game/systems/static_map_entity.js
Normal file
72
src/js/game/systems/static_map_entity.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { GameSystem } from "../game_system";
|
||||
import { DrawParameters } from "../../core/draw_parameters";
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { MapChunkView } from "../map_chunk_view";
|
||||
import { Loader } from "../../core/loader";
|
||||
import { enumDirection } from "../../core/vector";
|
||||
|
||||
export class StaticMapEntitySystem extends GameSystem {
|
||||
constructor(root) {
|
||||
super(root);
|
||||
|
||||
this.beltOverviewSprites = {
|
||||
[enumDirection.top]: Loader.getSprite("sprites/map_overview/belt_forward.png"),
|
||||
[enumDirection.right]: Loader.getSprite("sprites/map_overview/belt_right.png"),
|
||||
[enumDirection.left]: Loader.getSprite("sprites/map_overview/belt_left.png"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the static entities
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {MapChunkView} chunk
|
||||
*/
|
||||
drawChunk(parameters, chunk) {
|
||||
if (G_IS_DEV && globalConfig.debug.doNotRenderStatics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const drawOutlinesOnly = parameters.zoomLevel < globalConfig.mapChunkOverviewMinZoom;
|
||||
|
||||
const contents = chunk.contents;
|
||||
for (let y = 0; y < globalConfig.mapChunkSize; ++y) {
|
||||
for (let x = 0; x < globalConfig.mapChunkSize; ++x) {
|
||||
const entity = contents[x][y];
|
||||
|
||||
if (entity) {
|
||||
const staticComp = entity.components.StaticMapEntity;
|
||||
if (drawOutlinesOnly) {
|
||||
const rect = staticComp.getTileSpaceBounds();
|
||||
parameters.context.fillStyle = staticComp.silhouetteColor || "#aaa";
|
||||
const beltComp = entity.components.Belt;
|
||||
if (beltComp) {
|
||||
const sprite = this.beltOverviewSprites[beltComp.direction];
|
||||
staticComp.drawSpriteOnFullEntityBounds(parameters, sprite, 0, false);
|
||||
} else {
|
||||
parameters.context.fillRect(
|
||||
rect.x * globalConfig.tileSize,
|
||||
rect.y * globalConfig.tileSize,
|
||||
rect.w * globalConfig.tileSize,
|
||||
rect.h * globalConfig.tileSize
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const spriteKey = staticComp.spriteKey;
|
||||
if (spriteKey) {
|
||||
// Check if origin is contained to avoid drawing entities multiple times
|
||||
if (
|
||||
staticComp.origin.x >= chunk.tileX &&
|
||||
staticComp.origin.x < chunk.tileX + globalConfig.mapChunkSize &&
|
||||
staticComp.origin.y >= chunk.tileY &&
|
||||
staticComp.origin.y < chunk.tileY + globalConfig.mapChunkSize
|
||||
) {
|
||||
const sprite = Loader.getSprite(spriteKey);
|
||||
staticComp.drawSpriteOnFullEntityBounds(parameters, sprite, 2, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
129
src/js/game/systems/underground_belt.js
Normal file
129
src/js/game/systems/underground_belt.js
Normal file
@@ -0,0 +1,129 @@
|
||||
import { GameSystemWithFilter } from "../game_system_with_filter";
|
||||
import { UndergroundBeltComponent, enumUndergroundBeltMode } from "../components/underground_belt";
|
||||
import { Entity } from "../entity";
|
||||
import { Loader } from "../../core/loader";
|
||||
import { Math_max } from "../../core/builtins";
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { enumDirection, enumDirectionToVector, enumDirectionToAngle } from "../../core/vector";
|
||||
import { MapChunkView } from "../map_chunk_view";
|
||||
import { DrawParameters } from "../../core/draw_parameters";
|
||||
|
||||
export class UndergroundBeltSystem extends GameSystemWithFilter {
|
||||
constructor(root) {
|
||||
super(root, [UndergroundBeltComponent]);
|
||||
|
||||
this.beltSprites = {
|
||||
[enumUndergroundBeltMode.sender]: Loader.getSprite(
|
||||
"sprites/buildings/underground_belt_entry.png"
|
||||
),
|
||||
[enumUndergroundBeltMode.receiver]: Loader.getSprite(
|
||||
"sprites/buildings/underground_belt_exit.png"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
update() {
|
||||
for (let i = 0; i < this.allEntities.length; ++i) {
|
||||
const entity = this.allEntities[i];
|
||||
|
||||
const undergroundComp = entity.components.UndergroundBelt;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
if (undergroundComp.mode === enumUndergroundBeltMode.sender) {
|
||||
this.handleSender(entity);
|
||||
} else {
|
||||
this.handleReceiver(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
handleSender(entity) {
|
||||
const staticComp = entity.components.StaticMapEntity;
|
||||
const undergroundComp = entity.components.UndergroundBelt;
|
||||
|
||||
// Check if we have any item
|
||||
|
||||
if (undergroundComp.pendingItems.length > 0) {
|
||||
const nextItemAndDuration = undergroundComp.pendingItems[0];
|
||||
const remainingTime = nextItemAndDuration[1];
|
||||
const nextItem = nextItemAndDuration[0];
|
||||
|
||||
if (remainingTime === 0) {
|
||||
// Try to find a receiver
|
||||
const searchDirection = staticComp.localDirectionToWorld(enumDirection.top);
|
||||
const searchVector = enumDirectionToVector[searchDirection];
|
||||
const targetRotation = enumDirectionToAngle[searchDirection];
|
||||
|
||||
let currentTile = staticComp.origin;
|
||||
|
||||
for (
|
||||
let searchOffset = 0;
|
||||
searchOffset < globalConfig.undergroundBeltMaxTiles;
|
||||
++searchOffset
|
||||
) {
|
||||
currentTile = currentTile.add(searchVector);
|
||||
|
||||
const contents = this.root.map.getTileContent(currentTile);
|
||||
if (contents) {
|
||||
const receiverUndergroundComp = contents.components.UndergroundBelt;
|
||||
if (receiverUndergroundComp) {
|
||||
const receiverStaticComp = contents.components.StaticMapEntity;
|
||||
if (receiverStaticComp.rotationDegrees === targetRotation) {
|
||||
if (receiverUndergroundComp.mode === enumUndergroundBeltMode.receiver) {
|
||||
// Try to pass over the item to the receiver
|
||||
if (
|
||||
receiverUndergroundComp.tryAcceptTunneledItem(
|
||||
nextItem,
|
||||
searchOffset,
|
||||
this.root.hubGoals.getUndergroundBeltBaseSpeed()
|
||||
)
|
||||
) {
|
||||
undergroundComp.pendingItems = [];
|
||||
}
|
||||
}
|
||||
|
||||
// When we hit some underground belt, always stop, no matter what
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
handleReceiver(entity) {
|
||||
const undergroundComp = entity.components.UndergroundBelt;
|
||||
|
||||
// Try to eject items, we only check the first one cuz its sorted by remaining time
|
||||
const items = undergroundComp.pendingItems;
|
||||
if (items.length > 0) {
|
||||
const nextItemAndDuration = undergroundComp.pendingItems[0];
|
||||
const remainingTime = nextItemAndDuration[1];
|
||||
const nextItem = nextItemAndDuration[0];
|
||||
|
||||
if (remainingTime <= 0) {
|
||||
const ejectorComp = entity.components.ItemEjector;
|
||||
const nextSlotIndex = ejectorComp.getFirstFreeSlot();
|
||||
if (nextSlotIndex !== null) {
|
||||
if (ejectorComp.tryEject(nextSlotIndex, nextItem)) {
|
||||
items.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
55
src/js/game/time/base_game_speed.js
Normal file
55
src/js/game/time/base_game_speed.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/* typehints:start */
|
||||
import { GameRoot } from "../root";
|
||||
/* typehints:end */
|
||||
|
||||
import { BasicSerializableObject } from "../../savegame/serialization";
|
||||
|
||||
export class BaseGameSpeed extends BasicSerializableObject {
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
super();
|
||||
this.root = root;
|
||||
this.initializeAfterDeserialize(root);
|
||||
}
|
||||
|
||||
/** @returns {string} */
|
||||
static getId() {
|
||||
abstract;
|
||||
return "unknown-speed";
|
||||
}
|
||||
|
||||
getId() {
|
||||
// @ts-ignore
|
||||
return this.constructor.getId();
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return {};
|
||||
}
|
||||
|
||||
initializeAfterDeserialize(root) {
|
||||
this.root = root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the time multiplier
|
||||
*/
|
||||
getTimeMultiplier() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns how many logic steps there may be queued
|
||||
*/
|
||||
getMaxLogicStepsInQueue() {
|
||||
return 3;
|
||||
}
|
||||
|
||||
// Internals
|
||||
/** @returns {BaseGameSpeed} */
|
||||
newSpeed(instance) {
|
||||
return new instance(this.root);
|
||||
}
|
||||
}
|
||||
16
src/js/game/time/fast_forward_game_speed.js
Normal file
16
src/js/game/time/fast_forward_game_speed.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { BaseGameSpeed } from "./base_game_speed";
|
||||
import { globalConfig } from "../../core/config";
|
||||
|
||||
export class FastForwardGameSpeed extends BaseGameSpeed {
|
||||
static getId() {
|
||||
return "fast-forward";
|
||||
}
|
||||
|
||||
getTimeMultiplier() {
|
||||
return globalConfig.fastForwardSpeed;
|
||||
}
|
||||
|
||||
getMaxLogicStepsInQueue() {
|
||||
return 3 * globalConfig.fastForwardSpeed;
|
||||
}
|
||||
}
|
||||
233
src/js/game/time/game_time.js
Normal file
233
src/js/game/time/game_time.js
Normal file
@@ -0,0 +1,233 @@
|
||||
/* typehints:start */
|
||||
import { GameRoot } from "../root";
|
||||
/* typehints:end */
|
||||
|
||||
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 { FastForwardGameSpeed } from "./fast_forward_game_speed";
|
||||
import { gGameSpeedRegistry } from "../../core/global_registries";
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { checkTimerExpired, quantizeFloat } from "../../core/utils";
|
||||
import { createLogger } from "../../core/logging";
|
||||
|
||||
const logger = createLogger("game_time");
|
||||
|
||||
export class GameTime extends BasicSerializableObject {
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
super();
|
||||
this.root = root;
|
||||
|
||||
// Current ingame time seconds, not incremented while paused
|
||||
this.timeSeconds = 0;
|
||||
|
||||
// Current "realtime", a timer which always is incremented no matter whether the game is paused or no
|
||||
this.realtimeSeconds = 0;
|
||||
|
||||
// The adjustment, used when loading savegames so we can continue where we were
|
||||
this.realtimeAdjust = 0;
|
||||
|
||||
/** @type {BaseGameSpeed} */
|
||||
this.speed = new RegularGameSpeed(this.root);
|
||||
|
||||
// Store how much time we have in bucket
|
||||
this.logicTimeBudget = 0;
|
||||
|
||||
if (G_IS_DEV) {
|
||||
window.addEventListener("keydown", ev => {
|
||||
if (ev.key === "p") {
|
||||
this.requestSpeedToggle();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static getId() {
|
||||
return "GameTime";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return {
|
||||
timeSeconds: types.float,
|
||||
speed: types.obj(gGameSpeedRegistry),
|
||||
realtimeSeconds: types.float,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the new "real" time, called from the core once per frame, since performance now() is kinda slow
|
||||
*/
|
||||
updateRealtimeNow() {
|
||||
this.realtimeSeconds = performanceNow() / 1000.0 + this.realtimeAdjust;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ingame time in milliseconds
|
||||
*/
|
||||
getTimeMs() {
|
||||
return this.timeSeconds * 1000.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe check to check if a timer is expired. quantizes numbers
|
||||
* @param {number} lastTick Last tick of the timer
|
||||
* @param {number} tickRateSeconds Interval of the timer in seconds
|
||||
*/
|
||||
isIngameTimerExpired(lastTick, tickRateSeconds) {
|
||||
return checkTimerExpired(this.timeSeconds, lastTick, tickRateSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns how many seconds we are in the grace period
|
||||
* @returns {number}
|
||||
*/
|
||||
getRemainingGracePeriodSeconds() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if we are currently in the grace period
|
||||
* @returns {boolean}
|
||||
*/
|
||||
getIsWithinGracePeriod() {
|
||||
return this.getRemainingGracePeriodSeconds() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to generate new logic time budget
|
||||
* @param {number} deltaMs
|
||||
*/
|
||||
înternalAddDeltaToBudget(deltaMs) {
|
||||
// Only update if game is supposed to update
|
||||
if (this.root.hud.shouldPauseGame()) {
|
||||
this.logicTimeBudget = 0;
|
||||
} else {
|
||||
const multiplier = this.getSpeed().getTimeMultiplier();
|
||||
this.logicTimeBudget += deltaMs * multiplier;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs update ticks based on the queued logic budget
|
||||
* @param {number} deltaMs
|
||||
* @param {function():boolean} updateMethod
|
||||
*/
|
||||
performTicks(deltaMs, updateMethod) {
|
||||
this.înternalAddDeltaToBudget(deltaMs);
|
||||
|
||||
const speedAtStart = this.root.time.getSpeed();
|
||||
|
||||
// Update physics & logic
|
||||
while (this.logicTimeBudget >= globalConfig.physicsDeltaMs) {
|
||||
this.logicTimeBudget -= globalConfig.physicsDeltaMs;
|
||||
|
||||
if (!updateMethod()) {
|
||||
// Gameover happened or so, do not update anymore
|
||||
return;
|
||||
}
|
||||
|
||||
// Step game time
|
||||
this.timeSeconds = quantizeFloat(this.timeSeconds + globalConfig.physicsDeltaSeconds);
|
||||
|
||||
// Game time speed changed, need to abort since our logic steps are no longer valid
|
||||
if (speedAtStart.getId() !== this.speed.getId()) {
|
||||
logger.warn(
|
||||
"Skipping update because speed changed from",
|
||||
speedAtStart.getId(),
|
||||
"to",
|
||||
this.speed.getId()
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// If we queued async tasks, perform them next frame and do not update anymore
|
||||
if (this.root.hud.parts.processingOverlay.hasTasks()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns ingame time in seconds
|
||||
* @returns {number} seconds
|
||||
*/
|
||||
now() {
|
||||
return this.timeSeconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns "real" time in seconds
|
||||
* @returns {number} seconds
|
||||
*/
|
||||
realtimeNow() {
|
||||
return this.realtimeSeconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns "real" time in seconds
|
||||
* @returns {number} seconds
|
||||
*/
|
||||
systemNow() {
|
||||
return (this.realtimeSeconds - this.realtimeAdjust) * 1000.0;
|
||||
}
|
||||
|
||||
getIsPaused() {
|
||||
return this.speed.getId() === PausedGameSpeed.getId();
|
||||
}
|
||||
|
||||
requestSpeedToggle() {
|
||||
logger.warn("Request speed toggle");
|
||||
switch (this.speed.getId()) {
|
||||
case PausedGameSpeed.getId():
|
||||
this.setSpeed(new RegularGameSpeed(this.root));
|
||||
break;
|
||||
|
||||
case RegularGameSpeed.getId():
|
||||
this.setSpeed(new PausedGameSpeed(this.root));
|
||||
break;
|
||||
|
||||
case FastForwardGameSpeed.getId():
|
||||
this.setSpeed(new RegularGameSpeed(this.root));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
getSpeed() {
|
||||
return this.speed;
|
||||
}
|
||||
|
||||
setSpeed(speed) {
|
||||
assert(speed instanceof BaseGameSpeed, "Not a valid game speed");
|
||||
if (this.speed.getId() === speed.getId()) {
|
||||
logger.warn("Same speed set than current one:", speed.constructor.getId());
|
||||
}
|
||||
this.speed = speed;
|
||||
}
|
||||
|
||||
deserialize(data) {
|
||||
const errorCode = super.deserialize(data);
|
||||
if (errorCode) {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
// Adjust realtime now difference so they match
|
||||
this.realtimeAdjust = this.realtimeSeconds - performanceNow() / 1000.0;
|
||||
this.updateRealtimeNow();
|
||||
|
||||
// Make sure we have a quantizied time
|
||||
this.timeSeconds = quantizeFloat(this.timeSeconds);
|
||||
|
||||
this.speed.initializeAfterDeserialize(this.root);
|
||||
}
|
||||
}
|
||||
15
src/js/game/time/paused_game_speed.js
Normal file
15
src/js/game/time/paused_game_speed.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { BaseGameSpeed } from "./base_game_speed";
|
||||
|
||||
export class PausedGameSpeed extends BaseGameSpeed {
|
||||
static getId() {
|
||||
return "paused";
|
||||
}
|
||||
|
||||
getTimeMultiplier() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
getMaxLogicStepsInQueue() {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
11
src/js/game/time/regular_game_speed.js
Normal file
11
src/js/game/time/regular_game_speed.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { BaseGameSpeed } from "./base_game_speed";
|
||||
|
||||
export class RegularGameSpeed extends BaseGameSpeed {
|
||||
static getId() {
|
||||
return "regular";
|
||||
}
|
||||
|
||||
getTimeMultiplier() {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
163
src/js/game/tutorial_goals.js
Normal file
163
src/js/game/tutorial_goals.js
Normal file
@@ -0,0 +1,163 @@
|
||||
import { enumSubShape, ShapeDefinition, createSimpleShape } from "./shape_definition";
|
||||
import { enumColors } from "./colors";
|
||||
|
||||
/**
|
||||
* @enum {string}
|
||||
*/
|
||||
export const enumHubGoalRewards = {
|
||||
reward_cutter_and_trash: "Cutting Shapes",
|
||||
reward_rotater: "Rotating",
|
||||
reward_painter: "Painting",
|
||||
reward_mixer: "Color Mixing",
|
||||
reward_stacker: "Combiner",
|
||||
reward_splitter: "Splitter/Merger",
|
||||
reward_tunnel: "Tunnel",
|
||||
|
||||
no_reward: "Next level",
|
||||
};
|
||||
|
||||
export const tutorialGoals = [
|
||||
// Circle
|
||||
{
|
||||
shape: "CuCuCuCu",
|
||||
required: 40,
|
||||
reward: enumHubGoalRewards.reward_cutter_and_trash,
|
||||
},
|
||||
|
||||
// Cutter
|
||||
{
|
||||
shape: "CuCu----",
|
||||
required: 150,
|
||||
reward: enumHubGoalRewards.no_reward,
|
||||
},
|
||||
|
||||
{
|
||||
shape: "----CuCu",
|
||||
required: 200,
|
||||
reward: enumHubGoalRewards.reward_splitter,
|
||||
},
|
||||
|
||||
// Rectangle
|
||||
{
|
||||
shape: "RuRuRuRu",
|
||||
required: 80,
|
||||
reward: enumHubGoalRewards.no_reward,
|
||||
},
|
||||
|
||||
{
|
||||
shape: "RuRu----",
|
||||
required: 250,
|
||||
reward: enumHubGoalRewards.reward_rotater,
|
||||
},
|
||||
|
||||
// Rotater
|
||||
{
|
||||
shape: "--CuCu--",
|
||||
required: 300,
|
||||
reward: enumHubGoalRewards.no_reward,
|
||||
},
|
||||
|
||||
{
|
||||
shape: "Ru----Ru",
|
||||
required: 400,
|
||||
reward: enumHubGoalRewards.reward_tunnel,
|
||||
},
|
||||
|
||||
{
|
||||
shape: "Cu------",
|
||||
required: 800,
|
||||
reward: enumHubGoalRewards.no_reward,
|
||||
},
|
||||
|
||||
{
|
||||
shape: "------Ru",
|
||||
required: 1000,
|
||||
reward: enumHubGoalRewards.reward_painter,
|
||||
},
|
||||
|
||||
// Painter
|
||||
{
|
||||
shape: "CrCrCrCr",
|
||||
required: 1500,
|
||||
reward: enumHubGoalRewards.no_reward,
|
||||
},
|
||||
|
||||
{
|
||||
shape: "RbRb----",
|
||||
required: 2500,
|
||||
reward: enumHubGoalRewards.reward_mixer,
|
||||
},
|
||||
|
||||
// Mixing (purple)
|
||||
{
|
||||
shape: "CpCpCpCp",
|
||||
required: 4000,
|
||||
reward: enumHubGoalRewards.no_reward,
|
||||
},
|
||||
|
||||
// Star shape + cyan
|
||||
{
|
||||
shape: "ScScScSc",
|
||||
required: 500,
|
||||
reward: enumHubGoalRewards.reward_stacker,
|
||||
},
|
||||
|
||||
// Stacker
|
||||
{
|
||||
shape: "CcCcRgRg",
|
||||
required: 3000,
|
||||
reward: enumHubGoalRewards.no_reward,
|
||||
},
|
||||
|
||||
{
|
||||
shape: "RgRgRgRg:CcCcCcCc",
|
||||
required: 4000,
|
||||
reward: enumHubGoalRewards.no_reward,
|
||||
},
|
||||
|
||||
{
|
||||
shape: "CgCgRgRg",
|
||||
required: 6000,
|
||||
reward: enumHubGoalRewards.no_reward,
|
||||
},
|
||||
|
||||
{
|
||||
shape: "CwSwCwSw",
|
||||
required: 6000,
|
||||
reward: enumHubGoalRewards.no_reward,
|
||||
},
|
||||
|
||||
{
|
||||
shape: "WyWyWyWy",
|
||||
required: 2000,
|
||||
reward: enumHubGoalRewards.no_reward,
|
||||
},
|
||||
|
||||
{
|
||||
shape: "WyWgWyWg:CbCpCbCp",
|
||||
required: 4000,
|
||||
reward: enumHubGoalRewards.no_reward,
|
||||
},
|
||||
|
||||
{
|
||||
shape: "WyRgWyCg:CbCpCbCp:CwCwCwCw",
|
||||
required: 9000,
|
||||
reward: enumHubGoalRewards.no_reward,
|
||||
},
|
||||
|
||||
{
|
||||
shape: "CwRrWbSp:WcWrCpCw",
|
||||
required: 15000,
|
||||
reward: enumHubGoalRewards.no_reward,
|
||||
},
|
||||
];
|
||||
|
||||
if (G_IS_DEV) {
|
||||
tutorialGoals.forEach(({ shape, required, reward }) => {
|
||||
try {
|
||||
ShapeDefinition.fromShortKey(shape);
|
||||
} catch (ex) {
|
||||
throw new Error("Invalid tutorial goal: '" + ex + "' for shape" + shape);
|
||||
}
|
||||
});
|
||||
}
|
||||
326
src/js/game/upgrades.js
Normal file
326
src/js/game/upgrades.js
Normal file
@@ -0,0 +1,326 @@
|
||||
import { createSimpleShape, enumSubShape, ShapeDefinition } from "./shape_definition";
|
||||
import { enumColors } from "./colors";
|
||||
import { findNiceIntegerValue } from "../core/utils";
|
||||
import { globalConfig } from "../core/config";
|
||||
|
||||
export const TIER_LABELS = [
|
||||
"I",
|
||||
"II",
|
||||
"III",
|
||||
"IV",
|
||||
"V",
|
||||
"VI",
|
||||
"VII",
|
||||
"VIII",
|
||||
"IX",
|
||||
"X",
|
||||
"XI",
|
||||
"XII",
|
||||
"XIII",
|
||||
"XIV",
|
||||
"XV",
|
||||
"XVI",
|
||||
"XVII",
|
||||
"XVIII",
|
||||
"XIX",
|
||||
"XX",
|
||||
];
|
||||
|
||||
export const UPGRADES = {
|
||||
belt: {
|
||||
label: "Belts, Distributer & Tunnels",
|
||||
description: improvement => "Speed +" + Math.floor(improvement * 100.0) + "%",
|
||||
tiers: [
|
||||
{
|
||||
required: [{ shape: "CuCuCuCu", amount: 80 }],
|
||||
improvement: 1,
|
||||
},
|
||||
{
|
||||
required: [{ shape: "Ru----Ru", amount: 4000 }],
|
||||
improvement: 2,
|
||||
},
|
||||
{
|
||||
required: [{ shape: "CwSwCwSw", amount: 30000 }],
|
||||
improvement: 4,
|
||||
},
|
||||
{
|
||||
required: [{ shape: "RgRgSpSp:CwSwCwSw:Cr--Sw--", amount: 80000 }],
|
||||
improvement: 8,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
miner: {
|
||||
label: "Extraction",
|
||||
description: improvement => "Speed +" + Math.floor(improvement * 100.0) + "%",
|
||||
tiers: [
|
||||
{
|
||||
required: [{ shape: "RuRuRuRu", amount: 200 }],
|
||||
improvement: 1,
|
||||
},
|
||||
{
|
||||
required: [{ shape: "Cu------", amount: 4000 }],
|
||||
improvement: 2,
|
||||
},
|
||||
{
|
||||
required: [{ shape: "WyWgWyWg:CbCpCbCp", amount: 30000 }],
|
||||
improvement: 4,
|
||||
},
|
||||
{
|
||||
required: [{ shape: "WyWgWyWg:CbCpCbCp:Rp----Rp", amount: 90000 }],
|
||||
improvement: 8,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
processors: {
|
||||
label: "Shape Processing",
|
||||
description: improvement => "Speed +" + Math.floor(improvement * 100.0) + "%",
|
||||
tiers: [
|
||||
{
|
||||
required: [{ shape: "RuRuRuRu", amount: 200 }],
|
||||
improvement: 1,
|
||||
},
|
||||
{
|
||||
required: [{ shape: "Cu------", amount: 4000 }],
|
||||
improvement: 2,
|
||||
},
|
||||
{
|
||||
required: [{ shape: "WyWgWyWg:CbCpCbCp", amount: 30000 }],
|
||||
improvement: 4,
|
||||
},
|
||||
{
|
||||
required: [{ shape: "WyWgWyWg:CbCpCbCp:Rp----Rp", amount: 90000 }],
|
||||
improvement: 8,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
painting: {
|
||||
label: "Mixing & Painting",
|
||||
description: improvement => "Speed +" + Math.floor(improvement * 100.0) + "%",
|
||||
tiers: [
|
||||
{
|
||||
required: [{ shape: "RuRuRuRu", amount: 200 }],
|
||||
improvement: 1,
|
||||
},
|
||||
{
|
||||
required: [{ shape: "Cu------", amount: 4000 }],
|
||||
improvement: 2,
|
||||
},
|
||||
{
|
||||
required: [{ shape: "WyWgWyWg:CbCpCbCp", amount: 30000 }],
|
||||
improvement: 4,
|
||||
},
|
||||
{
|
||||
required: [{ shape: "WyWgWyWg:CbCpCbCp:Rp----Rp", amount: 90000 }],
|
||||
improvement: 8,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// cutter: {
|
||||
// label: "Cut Half",
|
||||
// description: improvement => "Speed +" + Math.floor(improvement * 100.0) + "%",
|
||||
// tiers: [
|
||||
// {
|
||||
// required: [{ shape: "----CuCu", amount: 450 }],
|
||||
// improvement: 1,
|
||||
// },
|
||||
// {
|
||||
// required: [{ shape: "CpCpCpCp", amount: 12000 }],
|
||||
// improvement: 2,
|
||||
// },
|
||||
// {
|
||||
// required: [{ shape: "CwRrWbSp:WcWrCpCw", amount: 45000 }],
|
||||
// improvement: 4,
|
||||
// },
|
||||
// {
|
||||
// required: [{ shape: "CwRrWbSp:WcWrCpCw:WpWpWb--", amount: 100000 }],
|
||||
// improvement: 8,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// splitter: {
|
||||
// label: "Distribute",
|
||||
// description: improvement => "Speed +" + Math.floor(improvement * 100.0) + "%",
|
||||
// tiers: [
|
||||
// {
|
||||
// required: [{ shape: "CuCu----", amount: 350 }],
|
||||
// improvement: 1,
|
||||
// },
|
||||
// {
|
||||
// required: [{ shape: "CrCrCrCr", amount: 7000 }],
|
||||
// improvement: 2,
|
||||
// },
|
||||
// {
|
||||
// required: [{ shape: "WyWyWyWy", amount: 30000 }],
|
||||
// improvement: 4,
|
||||
// },
|
||||
// {
|
||||
// required: [{ shape: "WyWyWyWy:CwSpRgRc", amount: 100000 }],
|
||||
// improvement: 8,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
|
||||
// rotater: {
|
||||
// label: "Rotate",
|
||||
// description: improvement => "Speed +" + Math.floor(improvement * 100.0) + "%",
|
||||
// tiers: [
|
||||
// {
|
||||
// required: [{ shape: "RuRu----", amount: 750 }],
|
||||
// improvement: 1,
|
||||
// },
|
||||
// {
|
||||
// required: [{ shape: "ScScScSc", amount: 3000 }],
|
||||
// improvement: 2,
|
||||
// },
|
||||
// {
|
||||
// required: [{ shape: "ScSpRwRw:Cw----Cw", amount: 15000 }],
|
||||
// improvement: 4,
|
||||
// },
|
||||
// {
|
||||
// required: [{ shape: "ScSpRwRw:Cw----Cw:CpCpCpCp", amount: 80000 }],
|
||||
// improvement: 8,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
|
||||
// underground_belt: {
|
||||
// label: "Tunnel",
|
||||
// description: improvement => "Speed +" + Math.floor(improvement * 100.0) + "%",
|
||||
// tiers: [
|
||||
// {
|
||||
// required: [{ shape: "--CuCu--", amount: 1000 }],
|
||||
// improvement: 1,
|
||||
// },
|
||||
// {
|
||||
// required: [{ shape: "RbRb----", amount: 9000 }],
|
||||
// improvement: 2,
|
||||
// },
|
||||
// {
|
||||
// required: [{ shape: "RbRb----:WpWpWpWp", amount: 25000 }],
|
||||
// improvement: 4,
|
||||
// },
|
||||
// {
|
||||
// required: [{ shape: "RbRb----:WpWpWpWp:RwRwRpRp", amount: 100000 }],
|
||||
// improvement: 8,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
|
||||
// painter: {
|
||||
// label: "Dye",
|
||||
// description: improvement => "Speed +" + Math.floor(improvement * 100.0) + "%",
|
||||
// tiers: [
|
||||
// {
|
||||
// required: [{ shape: "------Ru", amount: 4000 }],
|
||||
// improvement: 1,
|
||||
// },
|
||||
// {
|
||||
// required: [{ shape: "CcCcRgRg", amount: 15000 }],
|
||||
// improvement: 2,
|
||||
// },
|
||||
// {
|
||||
// required: [{ shape: "CcCcRgRg:WgWgWgWg", amount: 35000 }],
|
||||
// improvement: 4,
|
||||
// },
|
||||
// {
|
||||
// required: [{ shape: "CcCcRgRg:WgWgWgWg:CpRpCpRp", amount: 100000 }],
|
||||
// improvement: 8,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
|
||||
// mixer: {
|
||||
// label: "Mix Colors",
|
||||
// description: improvement => "Speed +" + Math.floor(improvement * 100.0) + "%",
|
||||
// tiers: [
|
||||
// {
|
||||
// required: [{ shape: "RgRgRgRg:CcCcCcCc", amount: 11000 }],
|
||||
// improvement: 1,
|
||||
// },
|
||||
// {
|
||||
// required: [{ shape: "WyWgWyWg:CbCpCbCp", amount: 15000 }],
|
||||
// improvement: 2,
|
||||
// },
|
||||
// {
|
||||
// required: [{ shape: "CcCcRgRg:WgWgWgWg:CpRpCpRp", amount: 45000 }],
|
||||
// improvement: 4,
|
||||
// },
|
||||
// {
|
||||
// required: [{ shape: "CcCcRgRg:WgWgWgWg:CpRpCpRp:CpCpCpCp", amount: 100000 }],
|
||||
// improvement: 8,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// stacker: {
|
||||
// label: "Combine",
|
||||
// description: improvement => "Speed +" + Math.floor(improvement * 100.0) + "%",
|
||||
// tiers: [
|
||||
// {
|
||||
// required: [{ shape: "CgCgRgRg", amount: 20000 }],
|
||||
// improvement: 1,
|
||||
// },
|
||||
// {
|
||||
// required: [{ shape: "CgCgRgRg:WpRpWpRp", amount: 50000 }],
|
||||
// improvement: 2,
|
||||
// },
|
||||
// {
|
||||
// required: [{ shape: "CgCgRgRg:WpRpWpRp:SpSwSpSw", amount: 70000 }],
|
||||
// improvement: 4,
|
||||
// },
|
||||
// {
|
||||
// required: [{ shape: "CgCgRgRg:WpRpWpRp:SpSwSpSw:CwCwCwCw", amount: 100000 }],
|
||||
// improvement: 8,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
};
|
||||
|
||||
// Tiers need % of the previous tier as requirement too
|
||||
const tierGrowth = 2;
|
||||
|
||||
// Automatically generate tier levels
|
||||
for (const upgradeId in UPGRADES) {
|
||||
const upgrade = UPGRADES[upgradeId];
|
||||
|
||||
let currentTierRequirements = [];
|
||||
for (let i = 0; i < upgrade.tiers.length; ++i) {
|
||||
const tierHandle = upgrade.tiers[i];
|
||||
const originalRequired = tierHandle.required.slice();
|
||||
|
||||
for (let k = currentTierRequirements.length - 1; k >= 0; --k) {
|
||||
const oldTierRequirement = currentTierRequirements[k];
|
||||
tierHandle.required.unshift({
|
||||
shape: oldTierRequirement.shape,
|
||||
amount: oldTierRequirement.amount,
|
||||
});
|
||||
}
|
||||
currentTierRequirements.push(
|
||||
...originalRequired.map(req => ({
|
||||
amount: req.amount,
|
||||
shape: req.shape,
|
||||
}))
|
||||
);
|
||||
currentTierRequirements.forEach(tier => {
|
||||
tier.amount = findNiceIntegerValue(tier.amount * tierGrowth);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (G_IS_DEV) {
|
||||
for (const upgradeId in UPGRADES) {
|
||||
const upgrade = UPGRADES[upgradeId];
|
||||
upgrade.tiers.forEach(tier => {
|
||||
tier.required.forEach(({ shape }) => {
|
||||
try {
|
||||
ShapeDefinition.fromShortKey(shape);
|
||||
} catch (ex) {
|
||||
throw new Error("Invalid upgrade goal: '" + ex + "' for shape" + shape);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user