1
0
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:
Tobias Springer
2020-05-09 16:45:23 +02:00
commit 93c6ea683d
304 changed files with 56031 additions and 0 deletions

View 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
View 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";
}
}

View 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;
}
}

View 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,
},
],
})
);
}
}

View 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,
},
],
})
);
}
}

View 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 }],
})
);
}
}

View 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,
},
],
})
);
}
}

View 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,
},
],
})
);
}
}

View 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,
},
],
})
);
}
}

View 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 },
],
})
);
}
}

View 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,
},
],
})
);
}
}

View 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,
],
},
],
})
);
}
}

View 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
View 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;
}
}
}

View 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
View 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
View 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 */
}

View 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");
}

View 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];
}
}

View 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);
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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";
}
}

View 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);
}
}
}

View 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;
}
}

View 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
View 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
View 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;
}
}

View 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 */
}
}

View 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");
}
}
}

View 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);
}
}

View 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);
}
}

View 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();
}
}
}

View 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
View 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;
}
}

View 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",
]);
}
}

View 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
View 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();
}
}
}

View 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() {}
}

View 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;
}
}
}
}

View 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);
}
}

View 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);
}
});
}
}

View 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);
}
}

View 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();
});
}
}

View 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);
}
}

View 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);
}
}

View File

@@ -0,0 +1,6 @@
import { gItemRegistry } from "../core/global_registries";
import { ShapeItem } from "./items/shape_item";
export function initItemRegistry() {
gItemRegistry.register(ShapeItem);
}

View 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();
}
}

View 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);
}
}

View 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
View 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
View 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
View 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);
}
}
}
}

View 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
View 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
);
}
}
}
}
}

View 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;
}
}

View 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
View 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];
}
}
}
}

View 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 });
}
}

View 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] }));
}
}

View 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
View 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
);
}
}
}

View 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";
}
}

View 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);
}
}
}

View 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,
});
}
}
}

View 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
);
}
}
}
}

View 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
);
}
}
}
}
}
}

View 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);
}
}
}
}
}
}
}
}

View 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();
}
}
}
}
}
}

View 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);
}
}

View 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;
}
}

View 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);
}
}

View 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;
}
}

View File

@@ -0,0 +1,11 @@
import { BaseGameSpeed } from "./base_game_speed";
export class RegularGameSpeed extends BaseGameSpeed {
static getId() {
return "regular";
}
getTimeMultiplier() {
return 1;
}
}

View 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
View 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);
}
});
});
}
}